From d364c6d908f0872e7f3e4456eae2d1be292cf97e Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Thu, 10 Sep 2026 09:52:36 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E5=8F=82=E8=80=83=E9=87=91?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E7=A0=94=E5=90=AC=EF=BC=8C=E7=A0=94=E5=80=BC?= =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E9=80=9A=E7=94=A8=E9=80=9A=E7=94=A8=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E5=85=AC=E5=85=B1=E8=83=BD=E5=8A=9B=EF=BC=8C=E9=87=8D?= =?UTF-8?q?=E7=82=B9=E5=AF=B9=E6=AF=94=E7=BD=91=E7=BB=9C=E8=AF=B7=E6=B1=82?= =?UTF-8?q?=E3=80=81=E7=99=BB=E5=BD=95=E8=AE=A4=E8=AF=81=E3=80=81=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E7=AE=A1=E7=90=86=E3=80=81=E6=9C=AC=E5=9C=B0=E5=AD=98?= =?UTF-8?q?=E5=82=A8=E3=80=81WebView=E3=80=81=E6=8E=A8=E9=80=81=E3=80=81?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E6=9B=B4=E6=96=B0=E3=80=81=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E3=80=81=E4=B8=BB=E9=A2=98/UI=20=E7=AD=89?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=EF=BC=8C=E7=A1=AE=E5=AE=9A=E5=90=8E=E7=BB=AD?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=95=B4=E6=94=B9=E6=96=B9=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/app/app.dart | 55 +++++ lib/app/bootstrap.dart | 4 - lib/common/network/api_error_message.dart | 22 +- lib/common/network/api_exception.dart | 123 ++++++++++ lib/common/network/auth_interceptor.dart | 39 +++- lib/common/network/dio_provider.dart | 56 ++++- .../network/kratos_error_interceptor.dart | 25 +-- lib/common/network/protobuf_wire_reader.dart | 58 +++++ lib/common/push/device_push_registrar.dart | 93 +++----- lib/common/push/push_api_client.dart | 24 ++ lib/common/push/umeng_push_service.dart | 16 +- lib/common/router/deep_link_service.dart | 173 ++++++++++++++- lib/common/router/tab_shell.dart | 6 +- lib/common/services/cache_clear_service.dart | 6 +- .../services/device_header_service.dart | 12 +- lib/common/storage/preferences_storage.dart | 29 +++ .../storage/secure_identity_storage.dart | 121 ++++++++-- lib/common/storage/storage_keys.dart | 11 + lib/common/widgets/app_network_image.dart | 3 - lib/common/widgets/async_state_view.dart | 46 +++- .../ads/application/splash_ad_controller.dart | 24 +- lib/features/ads/data/ads_api_client.dart | 210 ++++++------------ lib/features/ads/presentation/ad_media.dart | 7 +- .../asset_portfolio_controller.dart | 34 ++- lib/features/assets/data/asset_models.dart | 39 ++++ .../assets/data/mock_asset_repository.dart | 81 ++++++- .../auth/application/auth_controller.dart | 92 ++++---- .../application/auth_session_coordinator.dart | 122 ++++++++++ lib/features/auth/data/auth_models.dart | 11 +- .../application/mall_entry_promo_store.dart | 11 +- lib/features/mall/data/mall_auth_api.dart | 4 +- .../settings/data/update_api_client.dart | 2 + lib/features/web/presentation/web_page.dart | 59 ++--- pubspec.lock | 40 ++++ pubspec.yaml | 3 +- test/common/network/api_exception_test.dart | 74 ++++++ .../common/network/auth_interceptor_test.dart | 74 ++++++ test/common/push/push_api_client_test.dart | 55 +++++ .../common/router/deep_link_service_test.dart | 110 +++++++++ .../storage/secure_identity_storage_test.dart | 126 ++++++++++- test/features/ads/ads_api_client_test.dart | 68 ++++++ test/features/ads/proto_message_test.dart | 4 +- .../assets/mock_asset_repository_test.dart | 50 +++++ test/features/auth/auth_controller_test.dart | 102 ++++++++- .../auth/auth_session_coordinator_test.dart | 126 +++++++++++ .../mall/mall_entry_promo_store_test.dart | 5 +- 46 files changed, 2060 insertions(+), 395 deletions(-) create mode 100644 lib/common/network/api_exception.dart create mode 100644 lib/common/network/protobuf_wire_reader.dart create mode 100644 lib/common/push/push_api_client.dart create mode 100644 lib/common/storage/preferences_storage.dart create mode 100644 lib/features/auth/application/auth_session_coordinator.dart create mode 100644 test/common/network/api_exception_test.dart create mode 100644 test/common/network/auth_interceptor_test.dart create mode 100644 test/common/push/push_api_client_test.dart create mode 100644 test/common/router/deep_link_service_test.dart create mode 100644 test/features/ads/ads_api_client_test.dart create mode 100644 test/features/assets/mock_asset_repository_test.dart create mode 100644 test/features/auth/auth_session_coordinator_test.dart diff --git a/lib/app/app.dart b/lib/app/app.dart index 680dcd5..b213d23 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'package:app_links/app_links.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -15,6 +17,10 @@ import '../common/theme/theme_controller.dart'; import '../features/ads/application/ad_config_foreground_refresher.dart'; import '../features/settings/application/update_foreground_service.dart'; import '../features/web/application/mall_web_host_controller.dart'; +import '../features/auth/application/auth_controller.dart'; +import '../features/auth/application/auth_session_coordinator.dart'; +import '../features/auth/data/auth_models.dart'; +import '../features/assets/application/asset_portfolio_controller.dart'; import '../common/router/deep_link_service.dart'; import '../common/services/device_header_service.dart'; @@ -28,14 +34,43 @@ class JinzhiApp extends ConsumerStatefulWidget { class _JinzhiAppState extends ConsumerState with WidgetsBindingObserver { late final DevicePushRegistrar _pushRegistrar; + ProviderSubscription>? _authSubscription; + StreamSubscription? _appLinkSubscription; + late final SessionEndListener _sessionEndListener; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); _pushRegistrar = DevicePushRegistrar(ref.read)..start(); + _sessionEndListener = (reason) { + if (reason == SessionEndReason.accountDeleted) { + ref.read(assetPortfolioControllerProvider.notifier).clearHoldings(); + } + }; + ref.read(authSessionCoordinatorProvider).addListener(_sessionEndListener); + _authSubscription = ref.listenManual>(authProvider, ( + previous, + next, + ) { + final oldUser = previous?.valueOrNull is LoggedInAuthState + ? (previous!.valueOrNull! as LoggedInAuthState).user.id + : null; + final newUser = next.valueOrNull is LoggedInAuthState + ? (next.valueOrNull! as LoggedInAuthState).user.id + : null; + if (oldUser != newUser) _pushRegistrar.forceRegisterCurrentToken(); + DeepLinkService.notifyAuthChanged(); + }); UmengPushService.configureNotificationOpen(DeepLinkService.handle); + unawaited(_startAppLinks()); WidgetsBinding.instance.addPostFrameCallback((_) { + DeepLinkService.attachRouter((location) { + final context = rootNavigatorKey.currentContext; + if (context == null) return false; + context.go(location); + return true; + }, authGuard: (_) => !ref.read(authProvider).isLoading); // 首次 iOS 启动在欢迎页点击进入后才注册 APNs,确保系统弹窗不被启动页 // 或其他 Overlay 吞掉;后续启动和 Android 保持原有自动初始化。 if (!DeviceHeaderService.to.iosStartupRequired) { @@ -44,9 +79,29 @@ class _JinzhiAppState extends ConsumerState }); } + Future _startAppLinks() async { + try { + final appLinks = AppLinks(); + final initial = await appLinks.getInitialLink(); + if (initial != null) DeepLinkService.handleUri(initial); + _appLinkSubscription = appLinks.uriLinkStream.listen( + DeepLinkService.handleUri, + onError: (_) {}, + ); + } catch (_) { + // Desktop tests and unsupported platforms may not register the plugin. + } + } + @override void dispose() { unawaited(_pushRegistrar.stop()); + _authSubscription?.close(); + ref + .read(authSessionCoordinatorProvider) + .removeListener(_sessionEndListener); + unawaited(_appLinkSubscription?.cancel()); + DeepLinkService.detachRouter(); WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/lib/app/bootstrap.dart b/lib/app/bootstrap.dart index 67483d2..6cfec2e 100644 --- a/lib/app/bootstrap.dart +++ b/lib/app/bootstrap.dart @@ -7,7 +7,6 @@ import 'package:flutter/widgets.dart'; import '../common/services/device_header_service.dart'; import '../common/config/shorebird_service.dart'; import '../common/config/umeng_config.dart'; -import '../common/storage/secure_identity_storage.dart'; import '../common/storage/storage_service.dart'; import '../common/widgets/easy_refresh_localization.dart'; import 'app.dart'; @@ -17,9 +16,6 @@ Future bootstrap() async { configureEasyRefreshChineseLocalization(); await UmengConfig.load(); await StorageService.instance; - await SecureIdentityStorage( - StorageService.to.preferences, - ).restoreAccessToken(); if (!kIsWeb) { if (Platform.isAndroid) { // Before consent this only initializes app version and channel. The diff --git a/lib/common/network/api_error_message.dart b/lib/common/network/api_error_message.dart index 65d342d..d2a781a 100644 --- a/lib/common/network/api_error_message.dart +++ b/lib/common/network/api_error_message.dart @@ -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); +} diff --git a/lib/common/network/api_exception.dart b/lib/common/network/api_exception.dart new file mode 100644 index 0000000..85fe5fe --- /dev/null +++ b/lib/common/network/api_exception.dart @@ -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 && 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)); + } +} diff --git a/lib/common/network/auth_interceptor.dart b/lib/common/network/auth_interceptor.dart index e6a6352..9448bc7 100644 --- a/lib/common/network/auth_interceptor.dart +++ b/lib/common/network/auth_interceptor.dart @@ -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 saveJwt(String token) => _defaultIdentityStorage.saveJwt(token); @@ -13,22 +15,51 @@ Future clearJwt() => _defaultIdentityStorage.clearJwt(); Future 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 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 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())); +} diff --git a/lib/common/network/dio_provider.dart b/lib/common/network/dio_provider.dart index d0ed30a..94e865d 100644 --- a/lib/common/network/dio_provider.dart +++ b/lib/common/network/dio_provider.dart @@ -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 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); + } +} diff --git a/lib/common/network/kratos_error_interceptor.dart b/lib/common/network/kratos_error_interceptor.dart index 99c95c8..a03b15a 100644 --- a/lib/common/network/kratos_error_interceptor.dart +++ b/lib/common/network/kratos_error_interceptor.dart @@ -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 && raw.isNotEmpty) { - try { - final status = kerrors.Status.fromBuffer(raw); - if (status.code != 0 || status.reason.isNotEmpty) { - response.data = { - '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))); } } diff --git a/lib/common/network/protobuf_wire_reader.dart b/lib/common/network/protobuf_wire_reader.dart new file mode 100644 index 0000000..820ed56 --- /dev/null +++ b/lib/common/network/protobuf_wire_reader.dart @@ -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 bytes; + Map>? _cache; + + Map> 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 ? utf8.decode(value, allowMalformed: true) : ''; + } + + Map> _read() { + final result = >{}; + 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? get lastOrNull => isEmpty ? null : last; +} diff --git a/lib/common/push/device_push_registrar.dart b/lib/common/push/device_push_registrar.dart index b43dc23..40fed43 100644 --- a/lib/common/push/device_push_registrar.dart +++ b/lib/common/push/device_push_registrar.dart @@ -1,12 +1,12 @@ import 'dart:async'; -import 'dart:typed_data'; - import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import '../network/dio_provider.dart'; +import '../../models/pb/jinzhi/v1/push.pb.dart' as pb; +import '../network/api_exception.dart'; import 'notification_permission.dart'; +import 'push_api_client.dart'; import 'umeng_push_service.dart'; class DevicePushRegistrar { @@ -15,81 +15,52 @@ class DevicePushRegistrar { StreamSubscription? _subscription; String? _lastToken; bool? _lastPermission; - int _attempt = 0; + Future _registrationQueue = Future.value(); void start() { - _subscription ??= UmengPushService.onDeviceToken.listen(_register); + _subscription ??= UmengPushService.onDeviceToken.listen(_enqueue); UmengPushService.emitCachedToken(); } Future stop() async => _subscription?.cancel(); Future refresh() => UmengPushService.refreshDeviceToken(); void forceRegisterCurrentToken() { + // BLOCKED(server contract): guest registration is intentionally preserved. + // Do not add a client-side unbind call until the backend confirms whether + // guest registration reliably clears the previous user binding. _lastToken = null; _lastPermission = null; UmengPushService.emitCachedToken(); } + void _enqueue(String token) { + _registrationQueue = _registrationQueue + .catchError((_) {}) + .then((_) => _register(token)); + } + Future _register(String token) async { final permission = await NotificationPermission.isGranted(); if (token == _lastToken && permission == _lastPermission) return; - try { - final package = await PackageInfo.fromPlatform(); - await _read(dioProvider).post>( - '/api/v1/push/devices', - data: _PushRegisterRequest( - token: token, - platform: defaultTargetPlatform == TargetPlatform.iOS ? 1 : 2, - version: package.version, - permission: permission, - ).toBytes(), - options: protobufOptions(), - ); - _lastToken = token; - _lastPermission = permission; - _attempt = 0; - } catch (_) { - if (_attempt++ >= 3) return; - await Future.delayed(Duration(seconds: 1 << (_attempt - 1))); - await _register(token); - } - } -} - -class _PushRegisterRequest { - const _PushRegisterRequest({ - required this.token, - required this.platform, - required this.version, - required this.permission, - }); - final String token; - final int platform; - final String version; - final bool permission; - Uint8List toBytes() { - final bytes = BytesBuilder(copy: false); - void putInt(int value) { - while (value > 0x7f) { - bytes.addByte((value & 0x7f) | 0x80); - value >>= 7; + final package = await PackageInfo.fromPlatform(); + final request = pb.RegisterPushDeviceRequest( + umengDeviceToken: token, + platform: defaultTargetPlatform == TargetPlatform.iOS + ? pb.Platform.PLATFORM_IOS + : pb.Platform.PLATFORM_ANDROID, + appVersion: package.version, + permissionGranted: permission, + ); + for (var attempt = 0; attempt < 4; attempt++) { + try { + await _read(pushApiClientProvider).registerDevice(request); + _lastToken = token; + _lastPermission = permission; + return; + } catch (error) { + if (attempt == 3 || !ApiException.from(error).isRetryable) return; + await Future.delayed(Duration(seconds: 1 << attempt)); } - bytes.addByte(value); } - - void putString(int tag, String value) { - final raw = value.codeUnits; - putInt((tag << 3) | 2); - putInt(raw.length); - bytes.add(raw); - } - - putString(1, token); - putInt(2 << 3); - putInt(platform); - putString(3, version); - putInt(4 << 3); - putInt(permission ? 1 : 0); - return bytes.toBytes(); } } diff --git a/lib/common/push/push_api_client.dart b/lib/common/push/push_api_client.dart new file mode 100644 index 0000000..0a349e3 --- /dev/null +++ b/lib/common/push/push_api_client.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +// ignore: unused_import — compute() is referenced by generated Retrofit code. +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:retrofit/retrofit.dart'; + +import '../../models/pb/jinzhi/v1/push.pb.dart'; +import '../network/dio_provider.dart'; + +part 'push_api_client.g.dart'; + +@RestApi() +abstract class PushApiClient { + factory PushApiClient(Dio dio, {String? baseUrl}) = _PushApiClient; + + @POST('/api/v1/push/devices') + Future registerDevice( + @Body() RegisterPushDeviceRequest request, + ); +} + +final pushApiClientProvider = Provider( + (ref) => PushApiClient(ref.watch(backendDioProvider)), +); diff --git a/lib/common/push/umeng_push_service.dart b/lib/common/push/umeng_push_service.dart index 42bea6a..b8be221 100644 --- a/lib/common/push/umeng_push_service.dart +++ b/lib/common/push/umeng_push_service.dart @@ -12,11 +12,12 @@ import '../storage/storage_keys.dart'; import '../storage/storage_service.dart'; abstract final class UmengPushService { + static const _maxPendingNotificationOpens = 20; static final _tokens = StreamController.broadcast(); static Future? _starting; static bool _started = false; static void Function(String payload)? _onNotificationOpen; - static String? _pendingNotificationOpen; + static final List _pendingNotificationOpens = []; static Stream get onDeviceToken => _tokens.stream; @@ -24,10 +25,10 @@ abstract final class UmengPushService { void Function(String payload) callback, ) { _onNotificationOpen = callback; - final pending = _pendingNotificationOpen; - if (pending != null) { - _pendingNotificationOpen = null; - callback(pending); + final pending = List.of(_pendingNotificationOpens); + _pendingNotificationOpens.clear(); + for (final payload in pending) { + callback(payload); } } @@ -56,7 +57,10 @@ abstract final class UmengPushService { UmengPushSdk.setNotificationCallback((_) {}, (payload) { final callback = _onNotificationOpen; if (callback == null) { - _pendingNotificationOpen = payload; + if (_pendingNotificationOpens.length == _maxPendingNotificationOpens) { + _pendingNotificationOpens.removeAt(0); + } + _pendingNotificationOpens.add(payload); } else { callback(payload); } diff --git a/lib/common/router/deep_link_service.dart b/lib/common/router/deep_link_service.dart index 2278f6a..cec43c4 100644 --- a/lib/common/router/deep_link_service.dart +++ b/lib/common/router/deep_link_service.dart @@ -1,33 +1,177 @@ import 'dart:convert'; -import 'package:go_router/go_router.dart'; +enum DeepLinkSource { push, appLink, universalLink, customScheme } -import 'app_router.dart'; +typedef DeepLinkNavigator = bool Function(String location); +typedef DeepLinkAuthGuard = bool Function(String location); -/// 统一将 Scheme、Universal Link 和推送 payload 收敛为应用内路由。 +class DeepLinkEvent { + const DeepLinkEvent({ + required this.raw, + required this.source, + required this.receivedAt, + this.eventId, + }); + + final String raw; + final DeepLinkSource source; + final DateTime receivedAt; + final String? eventId; +} + +/// Source adapters feed one parser and a bounded FIFO. Navigation starts only +/// after GoRouter has a context and the auth guard accepts the target. abstract final class DeepLinkService { - static void handle(String raw) { - final target = _route(raw); - if (target != null) rootNavigatorKey.currentContext?.go(target); + static const maxPending = 20; + static const expiry = Duration(minutes: 15); + static const _environmentHosts = String.fromEnvironment( + 'JINZHI_DEEP_LINK_HOSTS', + ); + + static final List _pending = []; + static final Set _pendingEventIds = {}; + static final Map _seenEventIds = {}; + static final Set _configuredHttpsHosts = _normalizedHosts( + _environmentHosts.split(','), + ); + static Set _trustedHttpsHosts = {..._configuredHttpsHosts}; + static DeepLinkNavigator? _navigator; + static DeepLinkAuthGuard _authGuard = (_) => true; + + static int get pendingCount => _pending.length; + + static void handle(String raw) => enqueue(raw, source: DeepLinkSource.push); + + static void handleUri(Uri uri) => enqueue( + uri.toString(), + source: uri.scheme.toLowerCase() == 'jinzhi' + ? DeepLinkSource.customScheme + : DeepLinkSource.universalLink, + ); + + static void enqueue( + String raw, { + required DeepLinkSource source, + DateTime? receivedAt, + }) { + final normalized = raw.trim(); + if (normalized.isEmpty) return; + final event = DeepLinkEvent( + raw: normalized, + source: source, + receivedAt: receivedAt ?? DateTime.now(), + eventId: _eventId(normalized), + ); + _discardExpired(event.receivedAt); + _seenEventIds.removeWhere( + (_, seenAt) => event.receivedAt.difference(seenAt) > expiry, + ); + if (event.eventId case final id? when _seenEventIds.containsKey(id)) return; + if (_pending.length == maxPending) _removeAt(0); + _pending.add(event); + if (event.eventId case final id?) { + _pendingEventIds.add(id); + _seenEventIds[id] = event.receivedAt; + } + _drain(); + } + + static void attachRouter( + DeepLinkNavigator navigator, { + DeepLinkAuthGuard? authGuard, + }) { + _navigator = navigator; + _authGuard = authGuard ?? (_) => true; + _drain(); + } + + static void notifyAuthChanged() => _drain(); + + static void detachRouter() => _navigator = null; + + /// Universal/App Link domains are supplied by the release environment. An + /// empty allowlist intentionally disables HTTPS deep links until the native + /// association domain is confirmed. + static void configureTrustedHttpsHosts(Iterable hosts) { + _trustedHttpsHosts = _normalizedHosts(hosts); + } + + static void resetForTest() { + _pending.clear(); + _pendingEventIds.clear(); + _seenEventIds.clear(); + _trustedHttpsHosts = {..._configuredHttpsHosts}; + _navigator = null; + _authGuard = (_) => true; + } + + static void _drain() { + final navigator = _navigator; + if (navigator == null) return; + _discardExpired(DateTime.now()); + while (_pending.isNotEmpty) { + final event = _pending.first; + final target = _route(event.raw); + if (target == null) { + _removeAt(0); + continue; + } + if (!_authGuard(target) || !navigator(target)) return; + _removeAt(0); + } + } + + static void _discardExpired(DateTime now) { + while (_pending.isNotEmpty && + now.difference(_pending.first.receivedAt) > expiry) { + _removeAt(0); + } + } + + static void _removeAt(int index) { + final removed = _pending.removeAt(index); + if (removed.eventId case final id?) _pendingEventIds.remove(id); + } + + static String? _eventId(String raw) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + final id = '${decoded['event_id'] ?? decoded['eventId'] ?? ''}'.trim(); + if (id.isNotEmpty) return id; + } + } catch (_) {} + final uri = Uri.tryParse(raw); + final id = uri?.queryParameters['event_id']?.trim(); + return id == null || id.isEmpty ? null : id; } static String? _route(String raw) { var source = raw.trim(); try { - final json = jsonDecode(source); - if (json is Map) { - source = '${json['deep_link'] ?? json['url'] ?? json['link'] ?? ''}'; + final decoded = jsonDecode(source); + if (decoded is Map) { + source = + '${decoded['deep_link'] ?? decoded['url'] ?? decoded['link'] ?? ''}'; } } catch (_) {} final uri = Uri.tryParse(source); if (uri == null) return null; - if (uri.scheme == 'jinzhi') { - final path = uri.path.isEmpty ? '/assets' : uri.path; + if (uri.scheme.toLowerCase() == 'jinzhi') { + final path = uri.path.isNotEmpty + ? uri.path + : uri.host.isNotEmpty + ? '/${uri.host}' + : '/assets'; return _allowed(path) ? '$path${uri.hasQuery ? '?${uri.query}' : ''}' : null; } - if ((uri.scheme == 'https' || uri.scheme == 'http') && _allowed(uri.path)) { + if (uri.scheme.toLowerCase() == 'https' && + uri.port == 443 && + uri.userInfo.isEmpty && + _trustedHttpsHosts.contains(uri.host.toLowerCase()) && + _allowed(uri.path)) { return '${uri.path}${uri.hasQuery ? '?${uri.query}' : ''}'; } return null; @@ -40,4 +184,9 @@ abstract final class DeepLinkService { '/profile', '/settings', }.contains(path); + + static Set _normalizedHosts(Iterable hosts) => { + for (final host in hosts) + if (host.trim().isNotEmpty) host.trim().toLowerCase(), + }; } diff --git a/lib/common/router/tab_shell.dart b/lib/common/router/tab_shell.dart index 274de98..bcbbccf 100644 --- a/lib/common/router/tab_shell.dart +++ b/lib/common/router/tab_shell.dart @@ -19,7 +19,7 @@ import '../../features/web/presentation/mall_web_host.dart'; import '../theme/app_icons.dart'; import '../widgets/app_toast.dart'; import 'app_router.dart'; -import '../storage/storage_service.dart'; +import '../storage/preferences_storage.dart'; class TabShell extends ConsumerStatefulWidget { const TabShell({ @@ -65,7 +65,9 @@ class _TabShellState extends ConsumerState @override void initState() { super.initState(); - _mallEntryPromoStore = MallEntryPromoStore(StorageService.to.preferences); + _mallEntryPromoStore = MallEntryPromoStore( + ref.read(preferencesStorageProvider), + ); _mallEntryTipConsumed = _mallEntryPromoStore.hasOpenedMall; _mallHomeBadgeSeen = _mallEntryPromoStore.hasSeenHomeBadge; MallWebHostController.instance.state.addListener(_syncMallHost); diff --git a/lib/common/services/cache_clear_service.dart b/lib/common/services/cache_clear_service.dart index 9f4d6ae..39b7ff3 100644 --- a/lib/common/services/cache_clear_service.dart +++ b/lib/common/services/cache_clear_service.dart @@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../../features/web/application/mall_web_host_controller.dart'; import '../storage/storage_service.dart'; +import '../storage/preferences_storage.dart'; /// Clears only disposable presentation data. Credentials and user preferences /// deliberately remain outside this service. @@ -34,7 +35,8 @@ class CacheClearService { /// in SharedPreferences and is removed here without touching user data. abstract final class SplashAdControllerPlaceholder { static Future clear() async { - await StorageService.to.preferences.remove('ads.splash.entry'); - await StorageService.to.preferences.remove('ads.splash.daily'); + final preferences = PreferencesStorage(StorageService.to.preferences); + await preferences.remove('ads.splash.entry'); + await preferences.remove('ads.splash.daily'); } } diff --git a/lib/common/services/device_header_service.dart b/lib/common/services/device_header_service.dart index 8ed0077..e205c0a 100644 --- a/lib/common/services/device_header_service.dart +++ b/lib/common/services/device_header_service.dart @@ -5,13 +5,14 @@ import 'package:android_oaid/android_oaid.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/foundation.dart' show defaultTargetPlatform, kIsWeb; import 'package:flutter_paid/flutter_paid.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:platform_metadata/platform_metadata.dart'; import 'package:uuid/uuid.dart'; import '../storage/storage_keys.dart'; import '../storage/storage_service.dart'; +import '../storage/preferences_storage.dart'; +import '../storage/secure_identity_storage.dart'; import '../privacy/privacy_gate.dart'; /// 请求公共 + 平台身份 header 来源(完全对齐研值契约)。 @@ -26,8 +27,6 @@ import '../privacy/privacy_gate.dart'; class DeviceHeaderService { DeviceHeaderService._(); static final DeviceHeaderService to = DeviceHeaderService._(); - static const _storage = FlutterSecureStorage(); - static const _kDeviceIdKey = 'device_id'; // static const _platformMetadataChannel = MethodChannel('platform_metadata'); // 渠道:优先编译期 --dart-define=UMENG_CHANNEL=xxx;Android 其次读 manifest, @@ -48,6 +47,9 @@ class DeviceHeaderService { Future? _baseInitialization; Future? _identityInitialization; + SecureIdentityStorage get _identityStorage => + SecureIdentityStorage(PreferencesStorage(StorageService.to.preferences)); + Future ensureInitialized() async { _baseInitialization ??= _initializeBase(); await _baseInitialization; @@ -89,14 +91,14 @@ class DeviceHeaderService { /// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4; /// 首次生成后写入 secure_storage,后续复用。 Future _resolveDeviceId() async { - final stored = await _storage.read(key: _kDeviceIdKey); + final stored = await _identityStorage.readDeviceId(); if (stored != null && stored.isNotEmpty) return stored; var id = ''; if (!kIsWeb && Platform.isIOS) { id = await _resolveIdfv(); } if (id.isEmpty) id = const Uuid().v4(); - await _storage.write(key: _kDeviceIdKey, value: id); + await _identityStorage.saveDeviceId(id); return id; } diff --git a/lib/common/storage/preferences_storage.dart b/lib/common/storage/preferences_storage.dart new file mode 100644 index 0000000..ff900ec --- /dev/null +++ b/lib/common/storage/preferences_storage.dart @@ -0,0 +1,29 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'storage_service.dart'; + +/// Typed boundary for non-sensitive preferences and display caches. +/// +/// Feature code depends on this class instead of constructing or retaining a +/// [SharedPreferences] instance. +class PreferencesStorage { + const PreferencesStorage(this._preferences); + + final SharedPreferences _preferences; + + bool getBool(String key, {bool defaultValue = false}) => + _preferences.getBool(key) ?? defaultValue; + String? getString(String key) => _preferences.getString(key); + bool containsKey(String key) => _preferences.containsKey(key); + + Future setBool(String key, bool value) => + _preferences.setBool(key, value); + Future setString(String key, String value) => + _preferences.setString(key, value); + Future remove(String key) => _preferences.remove(key); +} + +final preferencesStorageProvider = Provider( + (ref) => PreferencesStorage(StorageService.to.preferences), +); diff --git a/lib/common/storage/secure_identity_storage.dart b/lib/common/storage/secure_identity_storage.dart index ccb98c8..d132f5f 100644 --- a/lib/common/storage/secure_identity_storage.dart +++ b/lib/common/storage/secure_identity_storage.dart @@ -1,9 +1,8 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import 'preferences_storage.dart'; import 'storage_keys.dart'; -import 'storage_service.dart'; abstract interface class SecureKeyValueStorage { Future read(String key); @@ -33,25 +32,33 @@ class SecureIdentityStorage { SecureKeyValueStorage secureStorage = const FlutterSecureKeyValueStorage(), }) : _storage = secureStorage; - static const _accessTokenKey = 'access_token'; - static const _legacySecureJwtKey = 'jwt_token'; - - final SharedPreferences _preferences; + final PreferencesStorage _preferences; final SecureKeyValueStorage _storage; Future restoreAccessToken() async { - final current = _normalized(await _storage.read(_accessTokenKey)); final legacyPreferences = _normalized( _preferences.getString(storageLegacyAccessToken), ); + final hasInstallSession = _preferences.getBool(storageAuthSessionActive); + + // A plaintext token proves that this installation predates secure storage + // migration. A Keychain token by itself does not: iOS may retain it after + // uninstall while SharedPreferences (including this marker) is reset. + if (!hasInstallSession && legacyPreferences == null) { + await _deleteAllAuthCredentials(); + await _preferences.remove(storageAuthSessionActive); + return null; + } + + final current = _normalized(await _storage.read(secureAccessToken)); final legacySecure = current == null - ? _normalized(await _storage.read(_legacySecureJwtKey)) + ? _normalized(await _storage.read(secureLegacyJwtToken)) : null; final token = current ?? legacyPreferences ?? legacySecure; if (current == null && token != null) { - await _storage.write(_accessTokenKey, token); + await _storage.write(secureAccessToken, token); } - await _storage.delete(_legacySecureJwtKey); + await _storage.delete(secureLegacyJwtToken); await _preferences.remove(storageLegacyAccessToken); if (token == null) { await _preferences.remove(storageAuthSessionActive); @@ -62,10 +69,12 @@ class SecureIdentityStorage { } Future readJwt() async { - final token = _normalized(await _storage.read(_accessTokenKey)); - if (token == null || _preferences.containsKey(storageLegacyAccessToken)) { + if (!_preferences.getBool(storageAuthSessionActive) || + _preferences.containsKey(storageLegacyAccessToken)) { return restoreAccessToken(); } + final token = _normalized(await _storage.read(secureAccessToken)); + if (token == null) await _preferences.remove(storageAuthSessionActive); return token; } @@ -74,22 +83,100 @@ class SecureIdentityStorage { if (normalized == null) { throw ArgumentError.value(token, 'token', 'must not be empty'); } - await _storage.write(_accessTokenKey, normalized); - await _storage.delete(_legacySecureJwtKey); + await _storage.write(secureAccessToken, normalized); + await _storage.delete(secureLegacyJwtToken); await _preferences.remove(storageLegacyAccessToken); await _preferences.setBool(storageAuthSessionActive, true); } Future clearJwt() async { - await _storage.delete(_accessTokenKey); - await _storage.delete(_legacySecureJwtKey); + await _deleteAllAuthCredentials(); await _preferences.remove(storageLegacyAccessToken); await _preferences.remove(storageAuthSessionActive); } + + Future clearSessionCredentials() async { + await clearJwt(); + await _storage.delete(secureMallCredential); + await _storage.delete(secureRefreshToken); + } + + Future clearAccountCredentials() async { + await clearSessionCredentials(); + await _storage.delete(secureBiometricCredential); + } + + Future readDeviceId() => _storage.read(secureDeviceId); + + Future saveDeviceId(String deviceId) => + _storage.write(secureDeviceId, deviceId); + + Future _deleteAllAuthCredentials() async { + await _storage.delete(secureAccessToken); + await _storage.delete(secureLegacyJwtToken); + } } final secureIdentityStorageProvider = Provider( - (ref) => SecureIdentityStorage(StorageService.to.preferences), + (ref) => SecureIdentityStorage(ref.watch(preferencesStorageProvider)), +); + +/// Process-memory session snapshot. Interceptors read this synchronously; +/// secure storage is touched only when a session starts, restores, or ends. +class AuthSessionStorage { + AuthSessionStorage(this._identityStorage); + + final SecureIdentityStorage _identityStorage; + String? _accessToken; + int _generation = 0; + Future? _restoreFuture; + Future _operationTail = Future.value(); + + String? get accessToken => _accessToken; + int get generation => _generation; + + Future restore() => _restoreFuture ??= _serialize(_restore); + + Future _restore() async { + _accessToken = await _identityStorage.restoreAccessToken(); + _generation++; + return _accessToken; + } + + Future save(String token) async { + await _serialize(() async { + await _identityStorage.saveJwt(token); + _accessToken = token.trim(); + _generation++; + _restoreFuture = Future.value(_accessToken); + }); + } + + Future clear({bool accountDeleted = false}) async { + await _serialize(() async { + if (accountDeleted) { + await _identityStorage.clearAccountCredentials(); + } else { + await _identityStorage.clearSessionCredentials(); + } + _accessToken = null; + _generation++; + _restoreFuture = Future.value(); + }); + } + + Future _serialize(Future Function() operation) { + final result = _operationTail.then((_) => operation()); + _operationTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } +} + +final authSessionStorageProvider = Provider( + (ref) => AuthSessionStorage(ref.watch(secureIdentityStorageProvider)), ); String? _normalized(String? value) { diff --git a/lib/common/storage/storage_keys.dart b/lib/common/storage/storage_keys.dart index 03bfc9a..60064a2 100644 --- a/lib/common/storage/storage_keys.dart +++ b/lib/common/storage/storage_keys.dart @@ -2,6 +2,7 @@ const storageThemeMode = 'theme_mode'; const storagePrivacyAgreed = 'privacy_agreed'; // Android 首启隐私同意(设备采集门控) const storageIosStartupSeen = 'ios_startup_seen'; // iOS 首次启动页已展示 const storageJinzhiHoldings = 'jinzhi_holdings'; +const storageJinzhiHoldingsDeleted = 'jinzhi_holdings_deleted'; const storageJinzhiAmountHidden = 'jinzhi_amount_hidden'; const storageJinzhiRecycleDiscounts = 'jinzhi_recycle_discounts'; const storageJinzhiPurityPresets = 'jinzhi_purity_presets'; @@ -18,6 +19,16 @@ const storageMallHomeBadgeSeen = 'jinzhi_mall_home_badge_seen'; const storageLegacyAccessToken = 'access_token'; const storageAuthSessionActive = 'auth_session_active'; +// Account-scoped secure values. They are intentionally listed even before +// every feature is implemented so account deletion has one complete cleanup +// boundary. Access tokens must never be reused as biometric credentials. +const secureAccessToken = 'access_token'; +const secureLegacyJwtToken = 'jwt_token'; +const secureDeviceId = 'device_id'; +const secureMallCredential = 'mall_credential'; +const secureRefreshToken = 'refresh_token'; +const secureBiometricCredential = 'biometric_credential'; + // 设备/广告标识。由各自的 SDK/ATT 流程写入,DeviceHeaderService 读出注入 // header;未写入则为空、注入时被过滤。 const storageDeviceToken = 'device_token'; // 推送 token diff --git a/lib/common/widgets/app_network_image.dart b/lib/common/widgets/app_network_image.dart index eb09616..dea5df1 100644 --- a/lib/common/widgets/app_network_image.dart +++ b/lib/common/widgets/app_network_image.dart @@ -16,7 +16,6 @@ class AppNetworkImage extends StatelessWidget { this.alignment = Alignment.center, this.borderRadius, this.cacheKey, - this.httpHeaders, this.placeholder, this.errorWidget, this.semanticLabel, @@ -29,7 +28,6 @@ class AppNetworkImage extends StatelessWidget { final Alignment alignment; final BorderRadius? borderRadius; final String? cacheKey; - final Map? httpHeaders; final Widget? placeholder; final Widget? errorWidget; final String? semanticLabel; @@ -41,7 +39,6 @@ class AppNetworkImage extends StatelessWidget { : CachedNetworkImage( imageUrl: imageUrl, cacheKey: cacheKey, - httpHeaders: httpHeaders, width: width, height: height, fit: fit, diff --git a/lib/common/widgets/async_state_view.dart b/lib/common/widgets/async_state_view.dart index 02a57e5..f7732dc 100644 --- a/lib/common/widgets/async_state_view.dart +++ b/lib/common/widgets/async_state_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../network/api_error_message.dart'; import 'app_state_card.dart'; /// Shared loading, failure and empty-state contract for feature pages. @@ -37,11 +38,11 @@ class AsyncStateView extends StatelessWidget { return state.when( loading: () => loading ?? const Center(child: CircularProgressIndicator()), - error: (_, _) => Center( + error: (error, _) => Center( child: AppStateCard( icon: Icons.error_outline, title: errorTitle, - subtitle: errorSubtitle, + subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle), buttonLabel: onRetry == null ? null : '重试', onButtonPressed: onRetry, buttonStyle: AppStateButtonStyle.filled, @@ -60,3 +61,44 @@ class AsyncStateView extends StatelessWidget { ); } } + +/// Sliver counterpart for CustomScrollView pages without forcing one global +/// card layout on successful feature content. +class SliverAsyncState extends StatelessWidget { + const SliverAsyncState({ + super.key, + required this.state, + required this.data, + this.loading, + this.onRetry, + this.errorSubtitle = '请检查网络后重试', + }); + + final AsyncValue state; + final Widget Function(T value) data; + final Widget? loading; + final VoidCallback? onRetry; + final String errorSubtitle; + + @override + Widget build(BuildContext context) => state.when( + data: data, + loading: () => SliverFillRemaining( + hasScrollBody: false, + child: loading ?? const Center(child: CircularProgressIndicator()), + ), + error: (error, _) => SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: AppStateCard( + icon: Icons.error_outline, + title: '加载失败', + subtitle: ErrorMessageMapper.message(error, fallback: errorSubtitle), + buttonLabel: onRetry == null ? null : '重试', + onButtonPressed: onRetry, + buttonStyle: AppStateButtonStyle.filled, + ), + ), + ), + ); +} diff --git a/lib/features/ads/application/splash_ad_controller.dart b/lib/features/ads/application/splash_ad_controller.dart index ca5d0c1..ee03022 100644 --- a/lib/features/ads/application/splash_ad_controller.dart +++ b/lib/features/ads/application/splash_ad_controller.dart @@ -4,7 +4,7 @@ import 'dart:io' show File; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../common/storage/storage_service.dart'; +import '../../../common/storage/preferences_storage.dart'; import '../data/ads_api_client.dart'; class SplashAdEntry { @@ -33,12 +33,13 @@ class SplashAdEntry { } class SplashAdController { - SplashAdController(this._api); + SplashAdController(this._api, this._preferences); final AdsApiClient _api; + final PreferencesStorage _preferences; static const _entryKey = 'ads.splash.entry'; static const _dailyKey = 'ads.splash.daily'; Future coldLaunchDecision() async { - final raw = StorageService.to.preferences.getString(_entryKey); + final raw = _preferences.getString(_entryKey); if (raw == null) return null; try { final entry = SplashAdEntry.fromJson( @@ -55,7 +56,7 @@ class SplashAdController { } bool _underCap(SplashAdEntry entry) { - final value = StorageService.to.preferences.getString(_dailyKey); + final value = _preferences.getString(_dailyKey); final today = DateTime.now().toIso8601String().substring(0, 10); if (value == null) return true; final map = Map.from(jsonDecode(value) as Map); @@ -68,13 +69,13 @@ class SplashAdController { entry.assetPath != null && await File(entry.assetPath!).exists(); Future markShown(SplashAdEntry entry) async { final today = DateTime.now().toIso8601String().substring(0, 10); - final raw = StorageService.to.preferences.getString(_dailyKey); + final raw = _preferences.getString(_dailyKey); var count = 0; if (raw != null) { final m = Map.from(jsonDecode(raw) as Map); if (m['date'] == today) count = (m['count'] as num?)?.toInt() ?? 0; } - await StorageService.to.preferences.setString( + await _preferences.setString( _dailyKey, jsonEncode({'date': today, 'count': count + 1}), ); @@ -92,7 +93,7 @@ class SplashAdController { result.ad!.imageUrl, ); if (!await file.exists()) return; - await StorageService.to.preferences.setString( + await _preferences.setString( _entryKey, jsonEncode( SplashAdEntry( @@ -115,14 +116,17 @@ class SplashAdController { Future skip(SplashAdEntry entry) => _report(entry.ad, 'ad_skip'); Future clearCache() async { await _clearEntry(); - await StorageService.to.preferences.remove(_dailyKey); + await _preferences.remove(_dailyKey); } Future _clearEntry() async { - await StorageService.to.preferences.remove(_entryKey); + await _preferences.remove(_entryKey); } } final splashAdControllerProvider = Provider( - (ref) => SplashAdController(ref.watch(adsApiClientProvider)), + (ref) => SplashAdController( + ref.watch(adsApiClientProvider), + ref.watch(preferencesStorageProvider), + ), ); diff --git a/lib/features/ads/data/ads_api_client.dart b/lib/features/ads/data/ads_api_client.dart index d5ad191..a487de8 100644 --- a/lib/features/ads/data/ads_api_client.dart +++ b/lib/features/ads/data/ads_api_client.dart @@ -1,11 +1,17 @@ -import 'dart:convert'; -import 'dart:typed_data'; - import 'package:dio/dio.dart'; +import 'package:fixnum/fixnum.dart'; +// ignore: unused_import — compute() is referenced by generated Retrofit code. +import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:retrofit/retrofit.dart'; import 'package:uuid/uuid.dart'; import '../../../common/network/dio_provider.dart'; +import '../../../models/pb/jinzhi/v1/ads.pb.dart' as pb; +import '../../../models/pb/jinzhi/v1/ads.pb.dart' + show GetAdReply, ListAdsReply, ReportAdEventReply, ReportAdEventRequest; + +part 'ads_api_client.g.dart'; const splashAdPlacement = 'splash'; const homeBannerAdPlacement = 'home_banner'; @@ -100,24 +106,26 @@ class AdCreative { ? AdOpenMode.inApp : AdOpenMode.external, ); - factory AdCreative.fromProto(ProtoMessage fields) => AdCreative( - adId: fields.string(1), - placement: fields.string(2), - targetApp: fields.string(3), - title: fields.string(4), - imageUrl: fields.string(6), - mediaType: switch (fields.integer(7)) { - 1 => AdMediaType.image, - 2 => AdMediaType.gif, - 3 => AdMediaType.video, + factory AdCreative.fromProto(pb.AdCreative creative) => AdCreative( + adId: creative.adId, + placement: creative.placement, + targetApp: creative.targetApp, + title: creative.title, + imageUrl: creative.imageUrl, + mediaType: switch (creative.mediaType) { + pb.MediaType.MEDIA_TYPE_IMAGE => AdMediaType.image, + pb.MediaType.MEDIA_TYPE_GIF => AdMediaType.gif, + pb.MediaType.MEDIA_TYPE_VIDEO => AdMediaType.video, _ => AdMediaType.unsupported, }, - h5Url: fields.string(9), - trackingId: fields.string(10), - countdownSeconds: fields.integer(11), - endAtMs: fields.integer(13), - trackedUrl: fields.string(14), - openMode: fields.integer(15) == 2 ? AdOpenMode.inApp : AdOpenMode.external, + h5Url: creative.h5Url, + trackingId: creative.trackingId, + countdownSeconds: creative.countdownSeconds, + endAtMs: creative.endAtMs.toInt(), + trackedUrl: creative.trackedUrl, + openMode: creative.openMode == pb.AdOpenMode.AD_OPEN_MODE_IN_APP + ? AdOpenMode.inApp + : AdOpenMode.external, ); } @@ -136,41 +144,49 @@ class FeaturedAdsState { bool get hasAds => enabled && ads.isNotEmpty; } +@RestApi() +abstract class AdsBackendApiClient { + factory AdsBackendApiClient(Dio dio, {String? baseUrl}) = + _AdsBackendApiClient; + + @GET('/api/v1/ads') + Future getAd({@Query('placement') required String placement}); + + @GET('/api/v1/ads/list') + Future listAds({ + @Query('placement') required String placement, + }); + + @POST('/api/v1/ads/events') + Future reportAdEvent( + @Body() pb.ReportAdEventRequest body, + ); +} + class AdsApiClient { - AdsApiClient(this._dio); - final Dio _dio; + AdsApiClient(this._api); + final AdsBackendApiClient _api; + Future getAdResult(String placement) async { - final response = await _dio.get>( - '/api/v1/ads', - queryParameters: {'placement': placement}, - options: protobufOptions(), - ); - final root = ProtoMessage(response.data ?? const []); - final creative = root.message(2); - final ad = AdCreative.fromProto(creative); + final reply = await _api.getAd(placement: placement); + final ad = reply.hasAd() ? AdCreative.fromProto(reply.ad) : null; return GetAdResult( - root.integer(1) != 0, - ad.isRenderable ? ad : null, - root.integer(3), + reply.enabled, + ad?.isRenderable == true ? ad : null, + reply.dailyCap, ); } Future listAds(String placement) async { - final response = await _dio.get>( - '/api/v1/ads/list', - queryParameters: {'placement': placement}, - options: protobufOptions(), - ); - final root = ProtoMessage(response.data ?? const []); - final ads = root - .messages(2) + final reply = await _api.listAds(placement: placement); + final ads = reply.ads .map(AdCreative.fromProto) .where((ad) => ad.isRenderable) .toList(growable: false); return FeaturedAdsState( - root.integer(1) != 0, + reply.enabled, ads, - trackingId: root.string(3).isEmpty ? null : root.string(3), + trackingId: reply.trackingId.isEmpty ? null : reply.trackingId, ); } @@ -179,23 +195,24 @@ class AdsApiClient { String event, { String? clickId, String? trackingId, - }) => _dio.post>( - '/api/v1/ads/events', - data: _Writer() - .string(1, event) - .string(2, ad.placement) - .string(3, ad.adId) - .string(4, trackingId ?? ad.trackingId) - .string(5, clickId ?? '') - .integer(6, DateTime.now().millisecondsSinceEpoch) - .toBytes(), - options: protobufOptions(), - ); + }) async { + await _api.reportAdEvent( + pb.ReportAdEventRequest( + eventName: event, + placement: ad.placement, + adId: ad.adId, + trackingId: trackingId ?? ad.trackingId, + clickId: clickId ?? '', + clientTsMs: Int64(DateTime.now().millisecondsSinceEpoch), + ), + ); + } + String newClickId() => const Uuid().v4(); } final adsApiClientProvider = Provider( - (ref) => AdsApiClient(ref.watch(dioProvider)), + (ref) => AdsApiClient(AdsBackendApiClient(ref.watch(backendDioProvider))), ); final featuredAdsProvider = FutureProvider.autoDispose .family( @@ -210,86 +227,3 @@ Uri? _http(String raw) { ? uri : null; } - -class ProtoMessage { - ProtoMessage(this.bytes); - final List bytes; - Map>? _cache; - Map> 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 ? utf8.decode(value, allowMalformed: true) : ''; - } - - ProtoMessage message(int tag) { - final value = _fields[tag]?.lastOrNull; - return ProtoMessage(value is List ? value : const []); - } - - List messages(int tag) => (_fields[tag] ?? const []) - .whereType>() - .map(ProtoMessage.new) - .toList(); - Map> _read() { - final out = >{}; - var i = 0; - int v() { - var n = 0, s = 0; - while (i < bytes.length) { - final b = bytes[i++]; - n |= (b & 127) << s; - if (b < 128) return n; - s += 7; - } - throw const FormatException('Invalid protobuf'); - } - - while (i < bytes.length) { - final key = v(), tag = key >> 3; - Object value; - if ((key & 7) == 0) { - value = v(); - } else if ((key & 7) == 2) { - final l = v(), end = i + l; - if (end > bytes.length) throw const FormatException('Invalid protobuf'); - value = bytes.sublist(i, end); - i = end; - } else { - throw const FormatException('Unsupported protobuf'); - } - (out[tag] ??= []).add(value); - } - return out; - } -} - -extension on List { - Object? get lastOrNull => isEmpty ? null : last; -} - -class _Writer { - final b = BytesBuilder(copy: false); - _Writer string(int t, String value) { - final d = value.codeUnits; - v((t << 3) | 2); - v(d.length); - b.add(d); - return this; - } - - _Writer integer(int t, int value) { - v(t << 3); - v(value); - return this; - } - - Uint8List toBytes() => b.toBytes(); - void v(int n) { - while (n > 127) { - b.addByte((n & 127) | 128); - n >>= 7; - } - b.addByte(n); - } -} diff --git a/lib/features/ads/presentation/ad_media.dart b/lib/features/ads/presentation/ad_media.dart index fcdda7c..5e9416b 100644 --- a/lib/features/ads/presentation/ad_media.dart +++ b/lib/features/ads/presentation/ad_media.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:video_player/video_player.dart'; +import '../../../common/widgets/app_network_image.dart'; import '../data/ads_api_client.dart'; /// 统一渲染广告图片/GIF/视频;开屏可优先使用已缓存的本地素材。 @@ -86,12 +87,12 @@ class _AdMediaState extends State { errorBuilder: (_, _, _) => _error(), ); } - return Image.network( - widget.ad.imageUrl, + return AppNetworkImage( + imageUrl: widget.ad.imageUrl, fit: widget.fit, width: double.infinity, height: double.infinity, - errorBuilder: (_, _, _) => _error(), + errorWidget: _error(), ); } final c = player; diff --git a/lib/features/assets/application/asset_portfolio_controller.dart b/lib/features/assets/application/asset_portfolio_controller.dart index faaefb3..ea255a7 100644 --- a/lib/features/assets/application/asset_portfolio_controller.dart +++ b/lib/features/assets/application/asset_portfolio_controller.dart @@ -1,16 +1,29 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../common/domain/metal_type.dart'; +import '../../auth/application/auth_controller.dart'; +import '../../auth/data/auth_models.dart'; import '../../market/data/mock_market_repository.dart'; import '../data/asset_models.dart'; import '../data/mock_asset_repository.dart'; final assetPortfolioControllerProvider = StateNotifierProvider((ref) { - final holdings = ref.watch(mockAssetRepositoryProvider).loadHoldings(); + final repository = ref.watch(mockAssetRepositoryProvider); + final authState = ref.watch(authProvider).valueOrNull; + final accountScope = switch (authState) { + LoggedInAuthState(:final user) => MockAssetRepository.accountScope( + userId: user.id, + phone: user.phone, + ), + _ => MockAssetRepository.guestAccountScope, + }; + final holdings = repository.loadHoldings(accountScope); final market = ref.watch(mockMarketRepositoryProvider); return AssetPortfolioController( holdings: holdings, + repository: repository, + accountScope: accountScope, spotPriceFor: (metal) => market.quoteFor(metal).spotPrice, changeFor: (metal) => market.quoteFor(metal).changeAmount, ); @@ -19,10 +32,14 @@ final assetPortfolioControllerProvider = class AssetPortfolioController extends StateNotifier { AssetPortfolioController({ required List holdings, + required MockAssetRepository repository, + required String accountScope, required double Function(MetalType metal) spotPriceFor, required double Function(MetalType metal) changeFor, this.recycleDiscount = 0.97, }) : _holdings = holdings, + _repository = repository, + _accountScope = accountScope, _spotPriceFor = spotPriceFor, _changeFor = changeFor, super( @@ -35,12 +52,25 @@ class AssetPortfolioController extends StateNotifier { ); final List _holdings; + final MockAssetRepository _repository; + final String _accountScope; final double Function(MetalType metal) _spotPriceFor; final double Function(MetalType metal) _changeFor; final double recycleDiscount; - void addHolding(GoldAssetHolding holding) { + Future addHolding(GoldAssetHolding holding) async { _holdings.add(holding); + await _repository.saveHoldings(_accountScope, _holdings); + state = _buildSummary( + holdings: _holdings, + spotPriceFor: _spotPriceFor, + changeFor: _changeFor, + recycleDiscount: recycleDiscount, + ); + } + + void clearHoldings() { + _holdings.clear(); state = _buildSummary( holdings: _holdings, spotPriceFor: _spotPriceFor, diff --git a/lib/features/assets/data/asset_models.dart b/lib/features/assets/data/asset_models.dart index c2e00a6..2798a3e 100644 --- a/lib/features/assets/data/asset_models.dart +++ b/lib/features/assets/data/asset_models.dart @@ -51,6 +51,45 @@ class GoldAssetHolding { final HoldingStatus status; double materialValue(double spotPrice) => weightGram * purity * spotPrice; + + Map toJson() => { + 'id': id, + 'name': name, + 'metal': metal.name, + 'category': category.name, + 'purity': purity, + 'purityLabel': purityLabel, + 'weightGram': weightGram, + 'costAmount': costAmount, + 'purchaseDate': purchaseDate?.toIso8601String(), + 'channel': channel, + 'note': note, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + 'status': status.name, + }; + + factory GoldAssetHolding.fromJson(Map json) { + return GoldAssetHolding( + id: json['id']! as String, + name: json['name']! as String, + metal: MetalType.values.byName(json['metal']! as String), + category: AssetCategory.values.byName(json['category']! as String), + purity: (json['purity']! as num).toDouble(), + purityLabel: json['purityLabel']! as String, + weightGram: (json['weightGram']! as num).toDouble(), + costAmount: (json['costAmount'] as num?)?.toDouble(), + purchaseDate: switch (json['purchaseDate']) { + final String value when value.isNotEmpty => DateTime.parse(value), + _ => null, + }, + channel: json['channel'] as String?, + note: json['note'] as String?, + createdAt: DateTime.parse(json['createdAt']! as String), + updatedAt: DateTime.parse(json['updatedAt']! as String), + status: HoldingStatus.values.byName(json['status']! as String), + ); + } } class HoldingValuation { diff --git a/lib/features/assets/data/mock_asset_repository.dart b/lib/features/assets/data/mock_asset_repository.dart index 455f47d..69a0f18 100644 --- a/lib/features/assets/data/mock_asset_repository.dart +++ b/lib/features/assets/data/mock_asset_repository.dart @@ -1,14 +1,91 @@ +import 'dart:convert'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../common/domain/metal_type.dart'; +import '../../../common/storage/preferences_storage.dart'; +import '../../../common/storage/storage_keys.dart'; import 'asset_models.dart'; final mockAssetRepositoryProvider = Provider( - (ref) => MockAssetRepository(), + (ref) => MockAssetRepository(ref.watch(preferencesStorageProvider)), ); class MockAssetRepository { - List loadHoldings() { + MockAssetRepository(this._preferences); + + static const guestAccountScope = 'guest'; + + final PreferencesStorage _preferences; + Future _operationTail = Future.value(); + + static String accountScope({String? userId, String? phone}) { + final normalizedUserId = userId?.trim() ?? ''; + if (normalizedUserId.isNotEmpty) return 'id:$normalizedUserId'; + final normalizedPhone = phone?.trim() ?? ''; + if (normalizedPhone.isNotEmpty) return 'phone:$normalizedPhone'; + return guestAccountScope; + } + + static String holdingsStorageKey(String accountScope) => + '$storageJinzhiHoldings:${Uri.encodeComponent(accountScope)}'; + + static String deletedStorageKey(String accountScope) => + '$storageJinzhiHoldingsDeleted:${Uri.encodeComponent(accountScope)}'; + + List loadHoldings(String accountScope) { + if (_preferences.getBool(deletedStorageKey(accountScope))) return []; + final encoded = _preferences.getString(holdingsStorageKey(accountScope)); + if (encoded == null || encoded.isEmpty) return _seedHoldings(); + try { + final decoded = jsonDecode(encoded); + if (decoded is! List) return _seedHoldings(); + return [ + for (final item in decoded) + GoldAssetHolding.fromJson(Map.from(item as Map)), + ]; + } catch (_) { + return _seedHoldings(); + } + } + + Future saveHoldings( + String accountScope, + List holdings, + ) async { + final encoded = jsonEncode([ + for (final holding in holdings) holding.toJson(), + ]); + await _serialize(() async { + await _preferences.setString(holdingsStorageKey(accountScope), encoded); + await _preferences.remove(deletedStorageKey(accountScope)); + }); + } + + Future deleteAccountHoldings(String accountScope) async { + await _serialize(() async { + final scopes = {accountScope, guestAccountScope}; + await Future.wait([ + for (final scope in scopes) ...[ + _preferences.remove(holdingsStorageKey(scope)), + _preferences.setBool(deletedStorageKey(scope), true), + ], + // Remove the pre-account-scoping key left by earlier builds. + _preferences.remove(storageJinzhiHoldings), + ]); + }); + } + + Future _serialize(Future Function() operation) { + final result = _operationTail.then((_) => operation()); + _operationTail = result.then( + (_) {}, + onError: (Object _, StackTrace _) {}, + ); + return result; + } + + List _seedHoldings() { final now = DateTime(2026, 6, 18, 16, 11); return [ GoldAssetHolding( diff --git a/lib/features/auth/application/auth_controller.dart b/lib/features/auth/application/auth_controller.dart index 5aaee7f..25a0357 100644 --- a/lib/features/auth/application/auth_controller.dart +++ b/lib/features/auth/application/auth_controller.dart @@ -1,13 +1,13 @@ import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shared_preferences/shared_preferences.dart'; +import '../../../common/network/api_exception.dart'; import '../../../common/network/dio_provider.dart'; -import '../../../common/storage/secure_identity_storage.dart'; +import '../../../common/storage/preferences_storage.dart'; import '../../../common/storage/storage_keys.dart'; -import '../../../common/storage/storage_service.dart'; import '../data/auth_api.dart'; import '../data/auth_models.dart'; +import 'auth_session_coordinator.dart'; const _legacyMockLoggedInKey = 'jinzhi_mock_logged_in'; @@ -15,26 +15,27 @@ final authProvider = StateNotifierProvider>((ref) { return AuthController( ref.read(dioProvider), - identityStorage: ref.read(secureIdentityStorageProvider), - preferences: StorageService.to.preferences, + preferences: ref.read(preferencesStorageProvider), + coordinator: ref.read(authSessionCoordinatorProvider), ); }); class AuthController extends StateNotifier> { AuthController( this._dio, { - required SecureIdentityStorage identityStorage, - required SharedPreferences preferences, + required PreferencesStorage preferences, + required AuthSessionCoordinator coordinator, bool autoBootstrap = true, - }) : _identityStorage = identityStorage, - _preferences = preferences, + }) : _preferences = preferences, + _coordinator = coordinator, super(const AsyncLoading()) { + _coordinator.addListener(_onSessionEnded); if (autoBootstrap) _bootstrap(); } final Dio _dio; - final SecureIdentityStorage _identityStorage; - final SharedPreferences _preferences; + final PreferencesStorage _preferences; + final AuthSessionCoordinator _coordinator; AuthApi get _api => AuthApi(_dio); @@ -42,28 +43,40 @@ class AuthController extends StateNotifier> { Future _bootstrap() async { state = const AsyncLoading(); - final token = await _identityStorage.readJwt(); + final token = await _coordinator.restore(); if (token == null || token.isEmpty) { state = const AsyncData(GuestAuthState()); return; } + final cached = _readCachedProfile(); + if (cached != null) { + state = AsyncData( + LoggedInAuthState(user: cached, token: token, isVerified: false), + ); + await _persistProfile(cached); + } + await _verifyProfile(token, _coordinator.generation); + } + + Future _verifyProfile(String token, int generation) async { try { final user = await _api.getProfile(); + if (generation != _coordinator.generation) return; await _persistProfile(user); - state = AsyncData(LoggedInAuthState(user: user, token: token)); + state = AsyncData( + LoggedInAuthState(user: user, token: token, isVerified: true), + ); } catch (error, stackTrace) { - if (_isUnauthorized(error)) { - await _clearLocalSession(); - state = const AsyncData(GuestAuthState()); + if (generation != _coordinator.generation) return; + final exception = ApiException.from(error, stackTrace: stackTrace); + if (await _coordinator.expireIfNeeded(exception)) { return; } - final cached = _canUseCachedProfile(error) ? _readCachedProfile() : null; - if (cached != null) { - await _persistProfile(cached); - state = AsyncData(LoggedInAuthState(user: cached, token: token)); - } else { - state = AsyncError(error, stackTrace); - } + // Any non-session error (transport, 403, 429, 5xx, decode) preserves a + // stale cached session. With no profile there is nothing safe to render, + // but the credential still remains available for retry. + if (state.valueOrNull is LoggedInAuthState) return; + state = AsyncError(exception, stackTrace); } } @@ -80,7 +93,7 @@ class AuthController extends StateNotifier> { } Future _persistLogin(AuthLoginResult reply) async { - await _identityStorage.saveJwt(reply.accessToken); + await _coordinator.startSession(reply.accessToken); await _persistProfile(reply.user); state = AsyncData( LoggedInAuthState(user: reply.user, token: reply.accessToken), @@ -88,8 +101,7 @@ class AuthController extends StateNotifier> { } Future logout() async { - await _clearLocalSession(); - state = const AsyncData(GuestAuthState()); + await _coordinator.logout(); } Future deleteAccount(String code) async { @@ -98,8 +110,7 @@ class AuthController extends StateNotifier> { throw ArgumentError.value(code, 'code', 'must not be empty'); } await _api.deleteAccount(normalized); - await _clearLocalSession(); - state = const AsyncData(GuestAuthState()); + await _coordinator.accountDeleted(); } Future _persistProfile(JinzhiUser user) async { @@ -122,22 +133,19 @@ class AuthController extends StateNotifier> { return JinzhiUser(id: id, phone: phone, createdAt: createdAt); } - Future _clearLocalSession() async { - await _identityStorage.clearJwt(); - await _preferences.remove(storageUserId); - await _preferences.remove(storageUserPhone); - await _preferences.remove(storageUserCreatedAt); - await _preferences.remove(_legacyMockLoggedInKey); + void _onSessionEnded(SessionEndReason reason) { + state = AsyncData( + reason == SessionEndReason.expired + ? const SessionExpiredAuthState() + : const GuestAuthState(), + ); } bool get isLoggedIn => state.valueOrNull is LoggedInAuthState; -} -bool _isUnauthorized(Object error) => - error is DioException && error.response?.statusCode == 401; - -bool _canUseCachedProfile(Object error) { - if (error is! DioException) return false; - final status = error.response?.statusCode; - return status == null || status == 408 || status == 429 || status >= 500; + @override + void dispose() { + _coordinator.removeListener(_onSessionEnded); + super.dispose(); + } } diff --git a/lib/features/auth/application/auth_session_coordinator.dart b/lib/features/auth/application/auth_session_coordinator.dart new file mode 100644 index 0000000..b312a9b --- /dev/null +++ b/lib/features/auth/application/auth_session_coordinator.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../common/network/api_exception.dart'; +import '../../../common/storage/preferences_storage.dart'; +import '../../../common/storage/secure_identity_storage.dart'; +import '../../../common/storage/storage_keys.dart'; +import '../../assets/data/mock_asset_repository.dart'; + +enum SessionEndReason { logout, expired, accountDeleted } + +typedef SessionEndListener = void Function(SessionEndReason reason); + +/// Owns session invalidation without depending on Navigator, Router or UI. +class AuthSessionCoordinator { + AuthSessionCoordinator( + this._sessionStorage, + this._preferences, + this._assetRepository, + ); + + final AuthSessionStorage _sessionStorage; + final PreferencesStorage _preferences; + final MockAssetRepository _assetRepository; + final Set _listeners = {}; + Future? _ending; + SessionEndReason? _endingReason; + + int get generation => _sessionStorage.generation; + + void addListener(SessionEndListener listener) => _listeners.add(listener); + void removeListener(SessionEndListener listener) => + _listeners.remove(listener); + + Future restore() => _sessionStorage.restore(); + + Future startSession(String token) async { + final ending = _ending; + if (ending != null) await ending; + await _sessionStorage.save(token); + } + + Future expireIfNeeded(ApiException exception) async { + if (!exception.isUnauthorized) return false; + final requestGeneration = exception.sessionGeneration; + if (requestGeneration != null && requestGeneration != generation) { + return false; + } + await end(SessionEndReason.expired); + return true; + } + + Future logout() => end(SessionEndReason.logout); + + Future accountDeleted() => end(SessionEndReason.accountDeleted); + + Future end(SessionEndReason reason) { + _endingReason = _strongerReason(_endingReason, reason); + final active = _ending; + if (active != null) return active; + final operation = _drainEnd(); + late final Future tracked; + tracked = operation.whenComplete(() { + if (identical(_ending, tracked)) { + _ending = null; + _endingReason = null; + } + }); + _ending = tracked; + return tracked; + } + + Future _drainEnd() async { + final assetAccountScope = MockAssetRepository.accountScope( + userId: _preferences.getString(storageUserId), + phone: _preferences.getString(storageUserPhone), + ); + SessionEndReason? completedReason; + while (completedReason != _endingReason) { + final reason = _endingReason!; + await _sessionStorage.clear( + accountDeleted: reason == SessionEndReason.accountDeleted, + ); + await Future.wait([ + _preferences.remove(storageUserId), + _preferences.remove(storageUserPhone), + _preferences.remove(storageUserCreatedAt), + _preferences.remove('jinzhi_mock_logged_in'), + if (reason == SessionEndReason.accountDeleted) + _assetRepository.deleteAccountHoldings(assetAccountScope), + ]); + completedReason = reason; + } + final finalReason = _endingReason!; + for (final listener in List.of(_listeners)) { + listener(finalReason); + } + } +} + +SessionEndReason _strongerReason( + SessionEndReason? current, + SessionEndReason next, +) { + if (current == null) return next; + return _reasonPriority(next) > _reasonPriority(current) ? next : current; +} + +int _reasonPriority(SessionEndReason reason) => switch (reason) { + SessionEndReason.expired => 0, + SessionEndReason.logout => 1, + SessionEndReason.accountDeleted => 2, +}; + +final authSessionCoordinatorProvider = Provider( + (ref) => AuthSessionCoordinator( + ref.watch(authSessionStorageProvider), + ref.watch(preferencesStorageProvider), + ref.watch(mockAssetRepositoryProvider), + ), +); diff --git a/lib/features/auth/data/auth_models.dart b/lib/features/auth/data/auth_models.dart index ceeb25e..fd5a249 100644 --- a/lib/features/auth/data/auth_models.dart +++ b/lib/features/auth/data/auth_models.dart @@ -20,9 +20,18 @@ class GuestAuthState extends AuthState { const GuestAuthState(); } +class SessionExpiredAuthState extends AuthState { + const SessionExpiredAuthState(); +} + class LoggedInAuthState extends AuthState { - const LoggedInAuthState({required this.user, required this.token}); + const LoggedInAuthState({ + required this.user, + required this.token, + this.isVerified = true, + }); final JinzhiUser user; final String token; + final bool isVerified; } diff --git a/lib/features/mall/application/mall_entry_promo_store.dart b/lib/features/mall/application/mall_entry_promo_store.dart index 8e0be82..8eff35b 100644 --- a/lib/features/mall/application/mall_entry_promo_store.dart +++ b/lib/features/mall/application/mall_entry_promo_store.dart @@ -1,17 +1,14 @@ -import 'package:shared_preferences/shared_preferences.dart'; - +import '../../../common/storage/preferences_storage.dart'; import '../../../common/storage/storage_keys.dart'; class MallEntryPromoStore { const MallEntryPromoStore(this._preferences); - final SharedPreferences _preferences; + final PreferencesStorage _preferences; - bool get hasOpenedMall => - _preferences.getBool(storageMallEntryPromoOpened) ?? false; + bool get hasOpenedMall => _preferences.getBool(storageMallEntryPromoOpened); - bool get hasSeenHomeBadge => - _preferences.getBool(storageMallHomeBadgeSeen) ?? false; + bool get hasSeenHomeBadge => _preferences.getBool(storageMallHomeBadgeSeen); Future markMallOpened() async { await _preferences.setBool(storageMallEntryPromoOpened, true); diff --git a/lib/features/mall/data/mall_auth_api.dart b/lib/features/mall/data/mall_auth_api.dart index 02281d8..9a9e3cc 100644 --- a/lib/features/mall/data/mall_auth_api.dart +++ b/lib/features/mall/data/mall_auth_api.dart @@ -4,7 +4,7 @@ import 'package:dio/dio.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../common/network/dio_provider.dart'; -import '../../ads/data/ads_api_client.dart'; +import '../../../common/network/protobuf_wire_reader.dart'; class AppMallAuthToken { const AppMallAuthToken({ @@ -33,7 +33,7 @@ class MallAuthApi { data: Uint8List(0), options: protobufOptions(), ); - final reply = ProtoMessage(response.data ?? const []); + final reply = ProtobufWireMessage(response.data ?? const []); final token = reply.string(1).trim(); final expiresIn = reply.integer(2); final sourceApp = reply.string(3).trim(); diff --git a/lib/features/settings/data/update_api_client.dart b/lib/features/settings/data/update_api_client.dart index eb4dfd1..42ba038 100644 --- a/lib/features/settings/data/update_api_client.dart +++ b/lib/features/settings/data/update_api_client.dart @@ -48,6 +48,8 @@ class UpgradeInfo { } } +// BLOCKED(proto): this endpoint has no authoritative generated Upgrade proto. +// Keep its existing wire contract until that source becomes available. class UpdateApiClient { UpdateApiClient(this._dio); diff --git a/lib/features/web/presentation/web_page.dart b/lib/features/web/presentation/web_page.dart index 15384f3..89d2825 100644 --- a/lib/features/web/presentation/web_page.dart +++ b/lib/features/web/presentation/web_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; import 'package:go_router/go_router.dart'; @@ -31,32 +33,37 @@ class _AppWebPageState extends State { @override void initState() { super.initState(); - _controller = WebViewController() - ..setJavaScriptMode(JavaScriptMode.unrestricted) - ..addJavaScriptChannel( - 'JinzhiBridge', - onMessageReceived: (message) { - if (message.message == 'close' && mounted) context.pop(); - }, - ) - ..setNavigationDelegate( - NavigationDelegate( - onProgress: (progress) => setState(() => _progress = progress), - onPageFinished: (_) async { - final title = await _controller.getTitle(); - if (mounted && title != null && title.trim().isNotEmpty) { - setState(() => _title = title.trim()); - } - }, - onWebResourceError: (error) { - if (error.isForMainFrame == true && mounted) { - setState(() => _error = error.description); - } - }, - onNavigationRequest: _onNavigationRequest, - ), - ) - ..loadRequest(widget.initialUri); + _controller = + WebViewController( + // Ordinary pages never receive camera/microphone or other protected + // resources. MallWebHost owns its separate permission policy. + onPermissionRequest: (request) => unawaited(request.deny()), + ) + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..addJavaScriptChannel( + 'JinzhiBridge', + onMessageReceived: (message) { + if (message.message == 'close' && mounted) context.pop(); + }, + ) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (progress) => setState(() => _progress = progress), + onPageFinished: (_) async { + final title = await _controller.getTitle(); + if (mounted && title != null && title.trim().isNotEmpty) { + setState(() => _title = title.trim()); + } + }, + onWebResourceError: (error) { + if (error.isForMainFrame == true && mounted) { + setState(() => _error = error.description); + } + }, + onNavigationRequest: _onNavigationRequest, + ), + ) + ..loadRequest(widget.initialUri); } NavigationDecision _onNavigationRequest(NavigationRequest request) { diff --git a/pubspec.lock b/pubspec.lock index 7e65e38..d8ab88d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -59,6 +59,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + app_links: + dependency: "direct main" + description: + name: app_links + sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_links_linux: + dependency: transitive + description: + name: app_links_linux + sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 + url: "https://pub.dev" + source: hosted + version: "1.0.3" + app_links_platform_interface: + dependency: transitive + description: + name: app_links_platform_interface + sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + app_links_web: + dependency: transitive + description: + name: app_links_web + sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 + url: "https://pub.dev" + source: hosted + version: "1.0.4" app_tracking_transparency: dependency: "direct main" description: @@ -664,6 +696,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + gtk: + dependency: transitive + description: + name: gtk + sha256: "4ff85b2a16724029dd9e5bbb5a94b6918f9973f74ba571c949d2002801879cf5" + url: "https://pub.dev" + source: hosted + version: "2.2.0" hooks_riverpod: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index df6846e..1441d0c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: jinzhi_flutter_app description: "金值 - 贵金属资产管家" publish_to: 'none' -version: 1.1.0+1 +version: 1.3.0+1 environment: sdk: ^3.9.2 @@ -18,6 +18,7 @@ dependencies: flutter_hooks: ^0.21.3+1 # 路由 go_router: ^16.2.4 + app_links: ^6.4.1 # 网络 + 序列化 dio: ^5.9.0 retrofit: ">=4.6.0 <4.9.2" diff --git a/test/common/network/api_exception_test.dart b/test/common/network/api_exception_test.dart new file mode 100644 index 0000000..aa973db --- /dev/null +++ b/test/common/network/api_exception_test.dart @@ -0,0 +1,74 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/common/network/api_exception.dart'; +import 'package:jinzhi_flutter_app/common/network/kratos_error_interceptor.dart'; +import 'package:jinzhi_flutter_app/models/pb/errors/errors.pb.dart' as errors; + +void main() { + test('classifies Kratos invalid token and preserves the raw body', () async { + final body = errors.Status( + code: 401, + reason: 'INVALID_TOKEN', + message: 'expired', + ).writeToBuffer(); + final dio = Dio() + ..httpClientAdapter = _ErrorAdapter(body, 401) + ..interceptors.add(KratosErrorInterceptor()); + + try { + await dio.get>( + '/me', + options: Options(responseType: ResponseType.bytes), + ); + fail('request should fail'); + } on DioException catch (error) { + final exception = ApiException.from(error); + expect(exception.kind, ApiExceptionKind.backend); + expect(exception.reason, 'INVALID_TOKEN'); + expect(exception.isUnauthorized, isTrue); + expect(error.response!.data, body); + } + }); + + test('classifies transport, retryable server errors, and cancellation', () { + final request = RequestOptions(path: '/test'); + expect( + ApiException.fromDio(DioException(requestOptions: request)).kind, + ApiExceptionKind.transport, + ); + expect( + ApiException.fromDio( + DioException( + requestOptions: request, + response: Response(requestOptions: request, statusCode: 503), + ), + ).isRetryable, + isTrue, + ); + expect( + ApiException.fromDio( + DioException(requestOptions: request, type: DioExceptionType.cancel), + ).kind, + ApiExceptionKind.cancelled, + ); + }); +} + +class _ErrorAdapter implements HttpClientAdapter { + _ErrorAdapter(this.body, this.status); + + final List body; + final int status; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async => ResponseBody.fromBytes(body, status); + + @override + void close({bool force = false}) {} +} diff --git a/test/common/network/auth_interceptor_test.dart b/test/common/network/auth_interceptor_test.dart new file mode 100644 index 0000000..606625e --- /dev/null +++ b/test/common/network/auth_interceptor_test.dart @@ -0,0 +1,74 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/common/network/auth_interceptor.dart'; +import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart'; +import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('external absolute URLs lose all backend identity headers', () async { + SharedPreferences.setMockInitialValues({}); + final preferences = await SharedPreferences.getInstance(); + final session = AuthSessionStorage( + SecureIdentityStorage( + PreferencesStorage(preferences), + secureStorage: _MemorySecureStorage(), + ), + ); + final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com')) + ..interceptors.add( + AuthInterceptor( + session, + backendOrigin: Uri.parse('https://api.example.com'), + ), + ) + ..httpClientAdapter = _CaptureAdapter(); + + final response = await dio.get( + 'https://cdn.example.net/ad.png', + options: Options( + headers: { + 'Authorization': 'Bearer leaked', + 'X-Device-Id': 'device', + 'User-iOS-IDFA': 'idfa', + 'User-DeviceToken': 'push', + }, + ), + ); + + final headers = response.requestOptions.headers.keys + .map((key) => key.toLowerCase()) + .toSet(); + expect(headers, isNot(contains('authorization'))); + expect(headers, isNot(contains('x-device-id'))); + expect(headers, isNot(contains('user-ios-idfa'))); + expect(headers, isNot(contains('user-devicetoken'))); + }); +} + +class _MemorySecureStorage implements SecureKeyValueStorage { + final Map values = {}; + + @override + Future delete(String key) async => values.remove(key); + @override + Future read(String key) async => values[key]; + @override + Future write(String key, String value) async => values[key] = value; +} + +class _CaptureAdapter implements HttpClientAdapter { + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async => ResponseBody.fromString('', 204); + + @override + void close({bool force = false}) {} +} diff --git a/test/common/push/push_api_client_test.dart b/test/common/push/push_api_client_test.dart new file mode 100644 index 0000000..9be9b0f --- /dev/null +++ b/test/common/push/push_api_client_test.dart @@ -0,0 +1,55 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/common/push/push_api_client.dart'; +import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/push.pb.dart' as pb; + +void main() { + test( + 'Retrofit push client uses generated protobuf in both directions', + () async { + final adapter = _PushAdapter(); + final client = PushApiClient( + Dio(BaseOptions(responseType: ResponseType.bytes)) + ..httpClientAdapter = adapter, + ); + + final reply = await client.registerDevice( + pb.RegisterPushDeviceRequest( + umengDeviceToken: 'push-token', + platform: pb.Platform.PLATFORM_IOS, + appVersion: '1.1.0', + permissionGranted: true, + ), + ); + + expect(reply.deviceRecordId, 'device-record'); + final request = pb.RegisterPushDeviceRequest.fromBuffer(adapter.body!); + expect(request.umengDeviceToken, 'push-token'); + expect(request.platform, pb.Platform.PLATFORM_IOS); + }, + ); +} + +class _PushAdapter implements HttpClientAdapter { + List? body; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + body = (options.data as List).toList(); + return ResponseBody.fromBytes( + pb.RegisterPushDeviceReply( + deviceRecordId: 'device-record', + ).writeToBuffer(), + 200, + ); + } + + @override + void close({bool force = false}) {} +} diff --git a/test/common/router/deep_link_service_test.dart b/test/common/router/deep_link_service_test.dart new file mode 100644 index 0000000..1319e8b --- /dev/null +++ b/test/common/router/deep_link_service_test.dart @@ -0,0 +1,110 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/common/router/deep_link_service.dart'; + +void main() { + setUp(DeepLinkService.resetForTest); + + test('keeps FIFO events until the router is ready', () { + DeepLinkService.configureTrustedHttpsHosts({'links.example.com'}); + DeepLinkService.handle('jinzhi://market'); + DeepLinkService.handleUri(Uri.parse('https://links.example.com/news?id=1')); + expect(DeepLinkService.pendingCount, 2); + + final navigated = []; + DeepLinkService.attachRouter((location) { + navigated.add(location); + return true; + }); + + expect(navigated, ['/market', '/news?id=1']); + expect(DeepLinkService.pendingCount, 0); + }); + + test('rejects HTTPS links outside the exact trusted host allowlist', () { + DeepLinkService.configureTrustedHttpsHosts({'links.example.com'}); + final navigated = []; + DeepLinkService.attachRouter((location) { + navigated.add(location); + return true; + }); + + DeepLinkService.handleUri(Uri.parse('https://evil.example/news')); + DeepLinkService.handleUri(Uri.parse('https://sub.links.example.com/news')); + DeepLinkService.handleUri(Uri.parse('https://links.example.com:8443/news')); + DeepLinkService.handleUri(Uri.parse('https://links.example.com/news')); + + expect(navigated, ['/news']); + expect(DeepLinkService.pendingCount, 0); + }); + + test('disables HTTPS deep links when no production host is configured', () { + final navigated = []; + DeepLinkService.attachRouter((location) { + navigated.add(location); + return true; + }); + + DeepLinkService.handleUri(Uri.parse('https://links.example.com/news')); + DeepLinkService.handle('jinzhi://news'); + + expect(navigated, ['/news']); + }); + + test('deduplicates event ids even after the first event is consumed', () { + final navigated = []; + DeepLinkService.attachRouter((location) { + navigated.add(location); + return true; + }); + final payload = jsonEncode({ + 'event_id': 'same-event', + 'deep_link': 'jinzhi://assets', + }); + + DeepLinkService.handle(payload); + DeepLinkService.handle(payload); + + expect(navigated, ['/assets']); + }); + + test('uses bounded queue and discards expired events', () { + final old = DateTime.now().subtract(DeepLinkService.expiry * 2); + DeepLinkService.enqueue( + 'jinzhi://assets', + source: DeepLinkSource.appLink, + receivedAt: old, + ); + for (var index = 0; index < DeepLinkService.maxPending + 5; index++) { + DeepLinkService.enqueue( + 'jinzhi://news?event_id=$index', + source: DeepLinkSource.push, + ); + } + expect(DeepLinkService.pendingCount, DeepLinkService.maxPending); + + final navigated = []; + DeepLinkService.attachRouter((location) { + navigated.add(location); + return true; + }); + expect(navigated, hasLength(DeepLinkService.maxPending)); + expect(navigated, isNot(contains('/assets'))); + }); + + test('auth guard leaves the head pending until auth changes', () { + var authenticated = false; + final navigated = []; + DeepLinkService.attachRouter((location) { + navigated.add(location); + return true; + }, authGuard: (_) => authenticated); + DeepLinkService.handle('jinzhi://profile'); + expect(DeepLinkService.pendingCount, 1); + + authenticated = true; + DeepLinkService.notifyAuthChanged(); + expect(navigated, ['/profile']); + }); +} diff --git a/test/common/storage/secure_identity_storage_test.dart b/test/common/storage/secure_identity_storage_test.dart index 808f3f0..11e6a4d 100644 --- a/test/common/storage/secure_identity_storage_test.dart +++ b/test/common/storage/secure_identity_storage_test.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart'; import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart'; +import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { @@ -14,7 +17,10 @@ void main() { }); final preferences = await SharedPreferences.getInstance(); final secure = _MemorySecureStorage(); - final storage = SecureIdentityStorage(preferences, secureStorage: secure); + final storage = SecureIdentityStorage( + PreferencesStorage(preferences), + secureStorage: secure, + ); expect(await storage.restoreAccessToken(), 'legacy'); expect(await secure.read('access_token'), 'legacy'); @@ -24,17 +30,109 @@ void main() { ); test( - 'migrates the previous secure jwt key when no current token exists', + 'rejects an orphaned Keychain token when the install marker is absent', () async { final preferences = await SharedPreferences.getInstance(); final secure = _MemorySecureStorage({'jwt_token': 'secure-legacy'}); - final storage = SecureIdentityStorage(preferences, secureStorage: secure); + final storage = SecureIdentityStorage( + PreferencesStorage(preferences), + secureStorage: secure, + ); - expect(await storage.restoreAccessToken(), 'secure-legacy'); - expect(await secure.read('access_token'), 'secure-legacy'); + expect(await storage.restoreAccessToken(), isNull); + expect(await secure.read('access_token'), isNull); expect(await secure.read('jwt_token'), isNull); }, ); + + test('restores a secure token only with the installation marker', () async { + SharedPreferences.setMockInitialValues({storageAuthSessionActive: true}); + final preferences = await SharedPreferences.getInstance(); + final secure = _MemorySecureStorage({secureAccessToken: 'active'}); + final storage = SecureIdentityStorage( + PreferencesStorage(preferences), + secureStorage: secure, + ); + + expect(await storage.restoreAccessToken(), 'active'); + }); + + test('logout clears session secrets but preserves device identity', () async { + SharedPreferences.setMockInitialValues({storageAuthSessionActive: true}); + final preferences = await SharedPreferences.getInstance(); + final secure = _MemorySecureStorage({ + secureAccessToken: 'access', + secureMallCredential: 'mall', + secureRefreshToken: 'refresh', + secureBiometricCredential: 'biometric', + secureDeviceId: 'device', + }); + final storage = SecureIdentityStorage( + PreferencesStorage(preferences), + secureStorage: secure, + ); + + await storage.clearSessionCredentials(); + + expect(await secure.read(secureAccessToken), isNull); + expect(await secure.read(secureMallCredential), isNull); + expect(await secure.read(secureRefreshToken), isNull); + expect(await secure.read(secureBiometricCredential), 'biometric'); + expect(await secure.read(secureDeviceId), 'device'); + + await storage.clearAccountCredentials(); + expect(await secure.read(secureBiometricCredential), isNull); + expect(await secure.read(secureDeviceId), 'device'); + }); + + test( + 'serializes restore with logout so an old token cannot revive', + () async { + SharedPreferences.setMockInitialValues({storageAuthSessionActive: true}); + final preferences = await SharedPreferences.getInstance(); + final secure = _BlockingReadSecureStorage({ + secureAccessToken: 'old-token', + }); + final session = AuthSessionStorage( + SecureIdentityStorage( + PreferencesStorage(preferences), + secureStorage: secure, + ), + ); + + final restoring = session.restore(); + await secure.readStarted.future; + final clearing = session.clear(); + secure.releaseRead.complete(); + + expect(await restoring, 'old-token'); + await clearing; + expect(session.accessToken, isNull); + expect(await secure.read(secureAccessToken), isNull); + }, + ); + + test('serializes restore with login so the new token wins', () async { + SharedPreferences.setMockInitialValues({storageAuthSessionActive: true}); + final preferences = await SharedPreferences.getInstance(); + final secure = _BlockingReadSecureStorage({secureAccessToken: 'old-token'}); + final session = AuthSessionStorage( + SecureIdentityStorage( + PreferencesStorage(preferences), + secureStorage: secure, + ), + ); + + final restoring = session.restore(); + await secure.readStarted.future; + final saving = session.save('new-token'); + secure.releaseRead.complete(); + + expect(await restoring, 'old-token'); + await saving; + expect(session.accessToken, 'new-token'); + expect(await secure.read(secureAccessToken), 'new-token'); + }); } class _MemorySecureStorage implements SecureKeyValueStorage { @@ -54,3 +152,21 @@ class _MemorySecureStorage implements SecureKeyValueStorage { _values[key] = value; } } + +class _BlockingReadSecureStorage extends _MemorySecureStorage { + _BlockingReadSecureStorage(super.initial); + + final readStarted = Completer(); + final releaseRead = Completer(); + var _blocked = false; + + @override + Future read(String key) async { + if (key == secureAccessToken && !_blocked) { + _blocked = true; + readStarted.complete(); + await releaseRead.future; + } + return super.read(key); + } +} diff --git a/test/features/ads/ads_api_client_test.dart b/test/features/ads/ads_api_client_test.dart new file mode 100644 index 0000000..4a51a5f --- /dev/null +++ b/test/features/ads/ads_api_client_test.dart @@ -0,0 +1,68 @@ +import 'dart:typed_data'; + +import 'package:dio/dio.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart'; +import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/ads.pb.dart' as pb; + +void main() { + test( + 'Retrofit client decodes generated ad protobuf and encodes events', + () async { + final adapter = _AdsAdapter(); + final backend = AdsBackendApiClient( + Dio(BaseOptions(responseType: ResponseType.bytes)) + ..httpClientAdapter = adapter, + ); + final client = AdsApiClient(backend); + + final result = await client.getAdResult(splashAdPlacement); + expect(result.enabled, isTrue); + expect(result.ad?.title, '商城'); + expect(result.ad?.endAtMs, 4102444800000); + + await client.report(result.ad!, 'impression'); + final report = pb.ReportAdEventRequest.fromBuffer(adapter.lastBody!); + expect(report.eventName, 'impression'); + expect(report.adId, 'ad-1'); + }, + ); +} + +class _AdsAdapter implements HttpClientAdapter { + List? lastBody; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + if (options.path == '/api/v1/ads') { + return ResponseBody.fromBytes( + pb.GetAdReply( + enabled: true, + dailyCap: 1, + ad: pb.AdCreative( + adId: 'ad-1', + placement: splashAdPlacement, + title: '商城', + imageUrl: 'https://cdn.example.com/ad.png', + h5Url: 'https://example.com', + trackedUrl: 'https://example.com', + mediaType: pb.MediaType.MEDIA_TYPE_IMAGE, + openMode: pb.AdOpenMode.AD_OPEN_MODE_EXTERNAL, + endAtMs: Int64(4102444800000), + ), + ).writeToBuffer(), + 200, + ); + } + lastBody = (options.data as List).toList(); + return ResponseBody.fromBytes(pb.ReportAdEventReply().writeToBuffer(), 200); + } + + @override + void close({bool force = false}) {} +} diff --git a/test/features/ads/proto_message_test.dart b/test/features/ads/proto_message_test.dart index 18fb248..d960fc4 100644 --- a/test/features/ads/proto_message_test.dart +++ b/test/features/ads/proto_message_test.dart @@ -1,12 +1,12 @@ import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; -import 'package:jinzhi_flutter_app/features/ads/data/ads_api_client.dart'; +import 'package:jinzhi_flutter_app/common/network/protobuf_wire_reader.dart'; void main() { test('protobuf strings are decoded as UTF-8', () { final value = utf8.encode('商城'); - final message = ProtoMessage([10, value.length, ...value]); + final message = ProtobufWireMessage([10, value.length, ...value]); expect(message.string(1), '商城'); }); diff --git a/test/features/assets/mock_asset_repository_test.dart b/test/features/assets/mock_asset_repository_test.dart new file mode 100644 index 0000000..ebace7b --- /dev/null +++ b/test/features/assets/mock_asset_repository_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart'; +import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() => SharedPreferences.setMockInitialValues({})); + + test('account deletion stays empty after repository recreation', () async { + final preferences = PreferencesStorage( + await SharedPreferences.getInstance(), + ); + final repository = MockAssetRepository(preferences); + final accountScope = MockAssetRepository.accountScope(userId: 'user-1'); + final initial = repository.loadHoldings(accountScope); + expect(initial, isNotEmpty); + expect( + repository.loadHoldings(MockAssetRepository.guestAccountScope), + isNotEmpty, + ); + await repository.saveHoldings(accountScope, initial); + + await repository.deleteAccountHoldings(accountScope); + final recreated = MockAssetRepository(preferences); + + expect(recreated.loadHoldings(accountScope), isEmpty); + expect( + recreated.loadHoldings(MockAssetRepository.guestAccountScope), + isEmpty, + ); + }); + + test('holdings are isolated by account and deletion is scoped', () async { + final preferences = PreferencesStorage( + await SharedPreferences.getInstance(), + ); + final repository = MockAssetRepository(preferences); + final first = MockAssetRepository.accountScope(userId: 'user-1'); + final second = MockAssetRepository.accountScope(userId: 'user-2'); + final firstHoldings = repository.loadHoldings(first); + final secondHoldings = repository.loadHoldings(second); + await repository.saveHoldings(first, firstHoldings.take(1).toList()); + await repository.saveHoldings(second, secondHoldings.take(2).toList()); + + await repository.deleteAccountHoldings(first); + + expect(repository.loadHoldings(first), isEmpty); + expect(repository.loadHoldings(second), hasLength(2)); + }); +} diff --git a/test/features/auth/auth_controller_test.dart b/test/features/auth/auth_controller_test.dart index 3ff2c92..d7eca0c 100644 --- a/test/features/auth/auth_controller_test.dart +++ b/test/features/auth/auth_controller_test.dart @@ -1,9 +1,14 @@ +import 'dart:async'; + import 'package:dio/dio.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart'; import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart'; +import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart'; import 'package:jinzhi_flutter_app/features/auth/application/auth_controller.dart'; +import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart'; import 'package:jinzhi_flutter_app/features/auth/data/auth_models.dart'; +import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart'; import 'package:jinzhi_flutter_app/models/pb/jinzhi/v1/account.pb.dart' as pb; import 'package:shared_preferences/shared_preferences.dart'; @@ -61,7 +66,7 @@ void main() { await fixture.controller.retryBootstrap(); - expect(fixture.controller.state.value, isA()); + expect(fixture.controller.state.value, isA()); expect(await fixture.identity.readJwt(), isNull); }); @@ -81,6 +86,59 @@ void main() { expect(fixture.preferences.containsKey('jinzhi_mock_logged_in'), isFalse); }); + test('restores cached profile before the background profile reply', () async { + final fixture = await _Fixture.create(); + await fixture.identity.saveJwt('token'); + await fixture.preferences.setString(storageUserId, 'cached-user'); + await fixture.preferences.setString(storageUserPhone, '13900139000'); + fixture.profileCompleter = Completer(); + + final bootstrap = fixture.controller.retryBootstrap(); + await Future.delayed(Duration.zero); + + final stale = fixture.controller.state.value! as LoggedInAuthState; + expect(stale.user.id, 'cached-user'); + expect(stale.isVerified, isFalse); + + fixture.profileCompleter!.complete(); + await bootstrap; + expect( + (fixture.controller.state.value! as LoggedInAuthState).isVerified, + isTrue, + ); + }); + + test('keeps cached login on 403, 429, 5xx, and decode failures', () async { + for (final failure in [403, 429, 503, -1]) { + final fixture = await _Fixture.create(); + await fixture.identity.saveJwt('token'); + await fixture.preferences.setString(storageUserId, 'cached-user'); + fixture.profileFailureStatus = failure; + + await fixture.controller.retryBootstrap(); + + expect(fixture.controller.state.value, isA()); + expect(await fixture.identity.readJwt(), 'token'); + } + }); + + test('logout preserves assets while account deletion removes them', () async { + final logoutFixture = await _Fixture.create(); + await logoutFixture.identity.saveJwt('token'); + await logoutFixture.preferences.setString(storageJinzhiHoldings, 'assets'); + await logoutFixture.controller.logout(); + expect( + logoutFixture.preferences.getString(storageJinzhiHoldings), + 'assets', + ); + + final deleteFixture = await _Fixture.create(); + await deleteFixture.identity.saveJwt('token'); + await deleteFixture.preferences.setString(storageJinzhiHoldings, 'assets'); + await deleteFixture.controller.deleteAccount('123456'); + expect(deleteFixture.preferences.getString(storageJinzhiHoldings), isNull); + }); + test('keeps the verified one-key login state shape', () async { final fixture = await _Fixture.create(); @@ -106,20 +164,47 @@ class _Fixture { bool failDeletion = false; bool failProfile = false; bool profileUnauthorized = false; + int? profileFailureStatus; + Completer? profileCompleter; String? deletedWithCode; static Future<_Fixture> create() async { final preferences = await SharedPreferences.getInstance(); final identity = SecureIdentityStorage( - preferences, + PreferencesStorage(preferences), secureStorage: _MemorySecureStorage(), ); late _Fixture fixture; final dio = Dio() ..interceptors.add( InterceptorsWrapper( - onRequest: (options, handler) { + onRequest: (options, handler) async { if (options.path == '/api/v1/me' && options.method == 'GET') { + if (fixture.profileCompleter case final completer?) { + await completer.future; + } + if (fixture.profileFailureStatus case final status?) { + if (status == -1) { + handler.resolve( + Response>( + requestOptions: options, + statusCode: 200, + data: const [255], + ), + ); + return; + } + handler.reject( + DioException( + requestOptions: options, + response: Response( + requestOptions: options, + statusCode: status, + ), + ), + ); + return; + } if (fixture.failProfile) { handler.reject(DioException(requestOptions: options)); return; @@ -185,10 +270,17 @@ class _Fixture { }, ), ); + final preferencesStorage = PreferencesStorage(preferences); + final sessionStorage = AuthSessionStorage(identity); + final coordinator = AuthSessionCoordinator( + sessionStorage, + preferencesStorage, + MockAssetRepository(preferencesStorage), + ); final controller = AuthController( dio, - identityStorage: identity, - preferences: preferences, + preferences: preferencesStorage, + coordinator: coordinator, autoBootstrap: false, ); fixture = _Fixture( diff --git a/test/features/auth/auth_session_coordinator_test.dart b/test/features/auth/auth_session_coordinator_test.dart new file mode 100644 index 0000000..472cf84 --- /dev/null +++ b/test/features/auth/auth_session_coordinator_test.dart @@ -0,0 +1,126 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/common/network/api_exception.dart'; +import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart'; +import 'package:jinzhi_flutter_app/common/storage/secure_identity_storage.dart'; +import 'package:jinzhi_flutter_app/common/storage/storage_keys.dart'; +import 'package:jinzhi_flutter_app/features/auth/application/auth_session_coordinator.dart'; +import 'package:jinzhi_flutter_app/features/assets/data/mock_asset_repository.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + test( + 'concurrent unauthorized responses expire one session only once', + () async { + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesStorage( + await SharedPreferences.getInstance(), + ); + final identity = SecureIdentityStorage( + preferences, + secureStorage: _MemorySecureStorage(), + ); + final session = AuthSessionStorage(identity); + final assets = MockAssetRepository(preferences); + final coordinator = AuthSessionCoordinator(session, preferences, assets); + await coordinator.startSession('token'); + final generation = coordinator.generation; + var notifications = 0; + coordinator.addListener((_) => notifications++); + final request = RequestOptions( + path: '/protected', + extra: {authSessionGenerationExtra: generation}, + ); + final exception = ApiException( + kind: ApiExceptionKind.backend, + httpStatus: 400, + reason: 'TOKEN_EXPIRED', + requestOptions: request, + ); + + await Future.wait([ + coordinator.expireIfNeeded(exception), + coordinator.expireIfNeeded(exception), + ]); + + expect(notifications, 1); + expect(await identity.readJwt(), isNull); + expect(await coordinator.expireIfNeeded(exception), isFalse); + }, + ); + + test('account deletion upgrades an in-flight expiration', () async { + SharedPreferences.setMockInitialValues({ + storageUserId: 'user-1', + storageUserPhone: '13800138000', + storageJinzhiHoldings: 'legacy-assets', + }); + final preferences = PreferencesStorage( + await SharedPreferences.getInstance(), + ); + final secure = _MemorySecureStorage({ + secureBiometricCredential: 'biometric', + }); + final identity = SecureIdentityStorage(preferences, secureStorage: secure); + final session = AuthSessionStorage(identity); + final assets = MockAssetRepository(preferences); + final accountScope = MockAssetRepository.accountScope(userId: 'user-1'); + await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope)); + final coordinator = AuthSessionCoordinator(session, preferences, assets); + await coordinator.startSession('token'); + final notifications = []; + coordinator.addListener(notifications.add); + + final expiring = coordinator.end(SessionEndReason.expired); + final deleting = coordinator.accountDeleted(); + await Future.wait([expiring, deleting]); + + expect(notifications, [SessionEndReason.accountDeleted]); + expect(await secure.read(secureBiometricCredential), isNull); + expect(assets.loadHoldings(accountScope), isEmpty); + expect(preferences.containsKey(storageJinzhiHoldings), isFalse); + }); + + test('a new login waits for an in-flight session end', () async { + SharedPreferences.setMockInitialValues({}); + final preferences = PreferencesStorage( + await SharedPreferences.getInstance(), + ); + final identity = SecureIdentityStorage( + preferences, + secureStorage: _MemorySecureStorage(), + ); + final session = AuthSessionStorage(identity); + final coordinator = AuthSessionCoordinator( + session, + preferences, + MockAssetRepository(preferences), + ); + final accountScope = MockAssetRepository.accountScope(userId: 'user-1'); + await preferences.setString(storageUserId, 'user-1'); + final assets = MockAssetRepository(preferences); + await assets.saveHoldings(accountScope, assets.loadHoldings(accountScope)); + await coordinator.startSession('old-token'); + + final ending = coordinator.logout(); + final starting = coordinator.startSession('new-token'); + await Future.wait([ending, starting]); + + expect(session.accessToken, 'new-token'); + expect(await identity.readJwt(), 'new-token'); + expect(assets.loadHoldings(accountScope), isNotEmpty); + }); +} + +class _MemorySecureStorage implements SecureKeyValueStorage { + _MemorySecureStorage([Map? initial]) : values = {...?initial}; + + final Map values; + + @override + Future delete(String key) async => values.remove(key); + @override + Future read(String key) async => values[key]; + @override + Future write(String key, String value) async => values[key] = value; +} diff --git a/test/features/mall/mall_entry_promo_store_test.dart b/test/features/mall/mall_entry_promo_store_test.dart index 7ae4918..e55433a 100644 --- a/test/features/mall/mall_entry_promo_store_test.dart +++ b/test/features/mall/mall_entry_promo_store_test.dart @@ -1,11 +1,14 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:jinzhi_flutter_app/common/storage/preferences_storage.dart'; import 'package:jinzhi_flutter_app/features/mall/application/mall_entry_promo_store.dart'; import 'package:shared_preferences/shared_preferences.dart'; void main() { test('records each first-install mall promotion exposure once', () async { SharedPreferences.setMockInitialValues({}); - final store = MallEntryPromoStore(await SharedPreferences.getInstance()); + final store = MallEntryPromoStore( + PreferencesStorage(await SharedPreferences.getInstance()), + ); expect(store.hasOpenedMall, isFalse); expect(store.hasSeenHomeBadge, isFalse);