fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案

This commit is contained in:
jingyun committed 2026-09-10 09:52:36 +08:00
1 parent 6997a65c96
commit d364c6d908
46 files changed
+2060 -395

No files matched your search

+13 -9
View File
@@ -1,20 +1,24 @@
import 'package:dio/dio.dart';
import 'api_exception.dart';
String? kratosReason(Object e) {
if (e is! DioException) return null;
final body = e.response?.data;
if (body is Map && body['reason'] is String) return body['reason'] as String;
if (e is DioException || e is ApiException || e is FormatException) {
return ApiException.from(e).reason;
}
return null;
}
String kratosDisplayMessage(Object e, {required String fallback}) {
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
if (e is DioException) {
final body = e.response?.data;
if (body is Map && body['message'] is String) {
final m = body['message'] as String;
if (m.isNotEmpty) return m;
}
final exception = ApiException.from(e);
if (exception.serverMessage case final message? when message.isNotEmpty) {
return message;
}
return fallback;
}
abstract final class ErrorMessageMapper {
static String message(Object error, {required String fallback}) =>
kratosDisplayMessage(error, fallback: fallback);
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:dio/dio.dart';
import '../../models/pb/errors/errors.pb.dart' as kerrors;
enum ApiExceptionKind { transport, http, backend, decode, cancelled }
/// The single network failure shape exposed to application code.
class ApiException implements Exception {
const ApiException({
required this.kind,
this.httpStatus,
this.backendCode,
this.reason,
this.serverMessage,
this.requestOptions,
this.cause,
});
final ApiExceptionKind kind;
final int? httpStatus;
final int? backendCode;
final String? reason;
final String? serverMessage;
final RequestOptions? requestOptions;
final Object? cause;
bool get isUnauthorized =>
httpStatus == 401 ||
const {'INVALID_TOKEN', 'TOKEN_EXPIRED'}.contains(reason?.toUpperCase());
bool get isRetryable {
if (kind == ApiExceptionKind.transport) return true;
final status = httpStatus;
return status == 408 || status == 429 || (status != null && status >= 500);
}
int? get sessionGeneration =>
requestOptions?.extra[authSessionGenerationExtra] as int?;
static ApiException from(Object error, {StackTrace? stackTrace}) {
if (error is ApiException) return error;
if (error is DioException) {
if (error.error is ApiException) return error.error! as ApiException;
return fromDio(error);
}
if (error is FormatException) {
return ApiException(kind: ApiExceptionKind.decode, cause: error);
}
return ApiException(kind: ApiExceptionKind.decode, cause: error);
}
static ApiException fromDio(DioException error) {
final response = error.response;
final status = response?.statusCode;
final kratos = parseKratosError(response?.data);
if (error.type == DioExceptionType.cancel) {
return ApiException(
kind: ApiExceptionKind.cancelled,
httpStatus: status,
requestOptions: error.requestOptions,
cause: error,
);
}
final kind = kratos != null
? ApiExceptionKind.backend
: status != null
? ApiExceptionKind.http
: ApiExceptionKind.transport;
return ApiException(
kind: kind,
httpStatus: status,
backendCode: kratos?.code,
reason: _normalized(kratos?.reason),
serverMessage: _normalized(kratos?.message),
requestOptions: error.requestOptions,
cause: error,
);
}
@override
String toString() =>
'ApiException($kind, status: $httpStatus, code: $backendCode, '
'reason: $reason, message: $serverMessage)';
}
const authSessionGenerationExtra = 'authSessionGeneration';
kerrors.Status? parseKratosError(Object? raw) {
if (raw is List<int> && raw.isNotEmpty) {
try {
final status = kerrors.Status.fromBuffer(raw);
if (status.code != 0 || status.reason.isNotEmpty) return status;
} catch (_) {
return null;
}
}
if (raw is Map) {
final code = raw['code'];
final reason = '${raw['reason'] ?? ''}';
final message = '${raw['message'] ?? ''}';
if (code is num || reason.isNotEmpty || message.isNotEmpty) {
return kerrors.Status(
code: code is num ? code.toInt() : 0,
reason: reason,
message: message,
);
}
}
return null;
}
String? _normalized(String? value) {
final result = value?.trim() ?? '';
return result.isEmpty ? null : result;
}
class ApiExceptionInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final apiException = ApiException.fromDio(err);
handler.next(err.copyWith(error: apiException));
}
}
+35 -4
View File
@@ -2,10 +2,12 @@ import 'package:dio/dio.dart';
import '../services/device_header_service.dart';
import '../storage/secure_identity_storage.dart';
import '../storage/preferences_storage.dart';
import '../storage/storage_service.dart';
import 'api_exception.dart';
final _defaultIdentityStorage = SecureIdentityStorage(
StorageService.to.preferences,
PreferencesStorage(StorageService.to.preferences),
);
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
@@ -13,22 +15,51 @@ Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._identityStorage);
AuthInterceptor(this._sessionStorage, {required Uri backendOrigin})
: _backendOrigin = backendOrigin;
final SecureIdentityStorage _identityStorage;
final AuthSessionStorage _sessionStorage;
final Uri _backendOrigin;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (!_sameOrigin(options.uri, _backendOrigin)) {
_stripSensitiveHeaders(options.headers);
handler.next(options);
return;
}
await DeviceHeaderService.to.ensureInitialized();
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
final jwt = await _identityStorage.readJwt();
final jwt = _sessionStorage.accessToken ?? await _sessionStorage.restore();
if (jwt != null && jwt.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $jwt';
}
options.extra[authSessionGenerationExtra] = _sessionStorage.generation;
handler.next(options);
}
}
bool _sameOrigin(Uri request, Uri backend) =>
request.scheme.toLowerCase() == backend.scheme.toLowerCase() &&
request.host.toLowerCase() == backend.host.toLowerCase() &&
request.port == backend.port;
void _stripSensitiveHeaders(Map<String, dynamic> headers) {
const sensitive = {
'authorization',
'x-device-id',
'user-devicetoken',
'user-android-id',
'user-android-oaid',
'user-android-imei',
'user-android-mac',
'user-ios-idfv',
'user-ios-idfa',
'user-ios-paid',
};
headers.removeWhere((key, _) => sensitive.contains(key.toLowerCase()));
}
+53 -3
View File
@@ -6,6 +6,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
import '../storage/secure_identity_storage.dart';
import '../../features/auth/application/auth_session_coordinator.dart';
import 'api_exception.dart';
import 'auth_interceptor.dart';
import 'kratos_error_interceptor.dart';
@@ -26,7 +28,9 @@ AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
@Riverpod(keepAlive: true)
Dio dio(Ref ref) {
final config = ref.watch(appConfigProvider);
final identityStorage = ref.watch(secureIdentityStorageProvider);
final sessionStorage = ref.watch(authSessionStorageProvider);
final coordinator = ref.watch(authSessionCoordinatorProvider);
final backendOrigin = Uri.parse(config.apiBaseUrl);
final d = Dio(
BaseOptions(
baseUrl: config.apiBaseUrl,
@@ -40,11 +44,57 @@ Dio dio(Ref ref) {
},
),
);
d.interceptors.add(AuthInterceptor(identityStorage));
d.interceptors.add(
AuthInterceptor(sessionStorage, backendOrigin: backendOrigin),
);
d.interceptors.add(KratosErrorInterceptor());
d.interceptors.add(_SessionExpiryInterceptor(coordinator));
if (!kReleaseMode) {
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
d.interceptors.add(FancyDioInterceptor());
}
d.interceptors.add(FancyDioInterceptor());
return d;
}
/// Explicit name for new code. [dioProvider] remains as a compatibility alias
/// while existing feature clients are migrated incrementally.
final backendDioProvider = dioProvider;
@Riverpod(keepAlive: true)
Dio publicDio(Ref ref) => Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
),
);
class _SessionExpiryInterceptor extends Interceptor {
_SessionExpiryInterceptor(this._coordinator);
final AuthSessionCoordinator _coordinator;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final exception = ApiException.from(err);
if (exception.isUnauthorized) {
// Network code only emits the session event. Navigation and presentation
// observe AuthState elsewhere.
try {
await _coordinator.expireIfNeeded(exception);
} catch (error, stackTrace) {
FlutterError.reportError(
FlutterErrorDetails(
exception: error,
stack: stackTrace,
library: 'jinzhi network',
context: ErrorDescription('while expiring an unauthorized session'),
),
);
}
}
handler.next(err);
}
}
@@ -1,27 +1,16 @@
import 'package:dio/dio.dart';
import '../../models/pb/errors/errors.pb.dart' as kerrors;
import 'api_exception.dart';
class KratosErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final response = err.response;
final raw = response?.data;
if (response != null && raw is List<int> && raw.isNotEmpty) {
try {
final status = kerrors.Status.fromBuffer(raw);
if (status.code != 0 || status.reason.isNotEmpty) {
response.data = <String, dynamic>{
'code': status.code,
'reason': status.reason,
'message': status.message,
'metadata': status.metadata,
};
}
} catch (_) {
// A non-Kratos error body keeps its original representation.
}
if (err.error is ApiException) {
handler.next(err);
return;
}
handler.next(err);
// Preserve response.data for diagnostics and retries. The parsed error is
// carried as DioException.error and normalized by ApiException.from().
handler.next(err.copyWith(error: ApiException.fromDio(err)));
}
}
@@ -0,0 +1,58 @@
import 'dart:convert';
/// Minimal reader retained only for endpoints that do not have an
/// authoritative generated protobuf model yet.
class ProtobufWireMessage {
ProtobufWireMessage(this.bytes);
final List<int> bytes;
Map<int, List<Object>>? _cache;
Map<int, List<Object>> get _fields => _cache ??= _read();
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
String string(int tag) {
final value = _fields[tag]?.lastOrNull;
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
}
Map<int, List<Object>> _read() {
final result = <int, List<Object>>{};
var index = 0;
int varint() {
var value = 0;
var shift = 0;
while (index < bytes.length) {
final byte = bytes[index++];
value |= (byte & 127) << shift;
if (byte < 128) return value;
shift += 7;
}
throw const FormatException('Invalid protobuf');
}
while (index < bytes.length) {
final key = varint();
final tag = key >> 3;
final Object field;
if ((key & 7) == 0) {
field = varint();
} else if ((key & 7) == 2) {
final length = varint();
final end = index + length;
if (end > bytes.length) throw const FormatException('Invalid protobuf');
field = bytes.sublist(index, end);
index = end;
} else {
throw const FormatException('Unsupported protobuf');
}
(result[tag] ??= []).add(field);
}
return result;
}
}
extension on List<Object> {
Object? get lastOrNull => isEmpty ? null : last;
}