fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案
This commit is contained in:
1 parent
6997a65c96
commit
d364c6d908
46 files changed
+2060
-395
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user