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

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

No files matched your search

+13 -9
View File
@@ -1,20 +1,24 @@
import 'package:dio/dio.dart';
import 'api_exception.dart';
String? kratosReason(Object e) {
if (e is! DioException) return null;
final body = e.response?.data;
if (body is Map && body['reason'] is String) return body['reason'] as String;
if (e is DioException || e is ApiException || e is FormatException) {
return ApiException.from(e).reason;
}
return null;
}
String kratosDisplayMessage(Object e, {required String fallback}) {
if (kratosReason(e) == 'INTERNAL_ERROR') return fallback;
if (e is DioException) {
final body = e.response?.data;
if (body is Map && body['message'] is String) {
final m = body['message'] as String;
if (m.isNotEmpty) return m;
}
final exception = ApiException.from(e);
if (exception.serverMessage case final message? when message.isNotEmpty) {
return message;
}
return fallback;
}
abstract final class ErrorMessageMapper {
static String message(Object error, {required String fallback}) =>
kratosDisplayMessage(error, fallback: fallback);
}
+123
View File
@@ -0,0 +1,123 @@
import 'package:dio/dio.dart';
import '../../models/pb/errors/errors.pb.dart' as kerrors;
enum ApiExceptionKind { transport, http, backend, decode, cancelled }
/// The single network failure shape exposed to application code.
class ApiException implements Exception {
const ApiException({
required this.kind,
this.httpStatus,
this.backendCode,
this.reason,
this.serverMessage,
this.requestOptions,
this.cause,
});
final ApiExceptionKind kind;
final int? httpStatus;
final int? backendCode;
final String? reason;
final String? serverMessage;
final RequestOptions? requestOptions;
final Object? cause;
bool get isUnauthorized =>
httpStatus == 401 ||
const {'INVALID_TOKEN', 'TOKEN_EXPIRED'}.contains(reason?.toUpperCase());
bool get isRetryable {
if (kind == ApiExceptionKind.transport) return true;
final status = httpStatus;
return status == 408 || status == 429 || (status != null && status >= 500);
}
int? get sessionGeneration =>
requestOptions?.extra[authSessionGenerationExtra] as int?;
static ApiException from(Object error, {StackTrace? stackTrace}) {
if (error is ApiException) return error;
if (error is DioException) {
if (error.error is ApiException) return error.error! as ApiException;
return fromDio(error);
}
if (error is FormatException) {
return ApiException(kind: ApiExceptionKind.decode, cause: error);
}
return ApiException(kind: ApiExceptionKind.decode, cause: error);
}
static ApiException fromDio(DioException error) {
final response = error.response;
final status = response?.statusCode;
final kratos = parseKratosError(response?.data);
if (error.type == DioExceptionType.cancel) {
return ApiException(
kind: ApiExceptionKind.cancelled,
httpStatus: status,
requestOptions: error.requestOptions,
cause: error,
);
}
final kind = kratos != null
? ApiExceptionKind.backend
: status != null
? ApiExceptionKind.http
: ApiExceptionKind.transport;
return ApiException(
kind: kind,
httpStatus: status,
backendCode: kratos?.code,
reason: _normalized(kratos?.reason),
serverMessage: _normalized(kratos?.message),
requestOptions: error.requestOptions,
cause: error,
);
}
@override
String toString() =>
'ApiException($kind, status: $httpStatus, code: $backendCode, '
'reason: $reason, message: $serverMessage)';
}
const authSessionGenerationExtra = 'authSessionGeneration';
kerrors.Status? parseKratosError(Object? raw) {
if (raw is List<int> && raw.isNotEmpty) {
try {
final status = kerrors.Status.fromBuffer(raw);
if (status.code != 0 || status.reason.isNotEmpty) return status;
} catch (_) {
return null;
}
}
if (raw is Map) {
final code = raw['code'];
final reason = '${raw['reason'] ?? ''}';
final message = '${raw['message'] ?? ''}';
if (code is num || reason.isNotEmpty || message.isNotEmpty) {
return kerrors.Status(
code: code is num ? code.toInt() : 0,
reason: reason,
message: message,
);
}
}
return null;
}
String? _normalized(String? value) {
final result = value?.trim() ?? '';
return result.isEmpty ? null : result;
}
class ApiExceptionInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final apiException = ApiException.fromDio(err);
handler.next(err.copyWith(error: apiException));
}
}
+35 -4
View File
@@ -2,10 +2,12 @@ import 'package:dio/dio.dart';
import '../services/device_header_service.dart';
import '../storage/secure_identity_storage.dart';
import '../storage/preferences_storage.dart';
import '../storage/storage_service.dart';
import 'api_exception.dart';
final _defaultIdentityStorage = SecureIdentityStorage(
StorageService.to.preferences,
PreferencesStorage(StorageService.to.preferences),
);
Future<void> saveJwt(String token) => _defaultIdentityStorage.saveJwt(token);
@@ -13,22 +15,51 @@ Future<void> clearJwt() => _defaultIdentityStorage.clearJwt();
Future<String?> loadJwt() => _defaultIdentityStorage.readJwt();
class AuthInterceptor extends Interceptor {
AuthInterceptor(this._identityStorage);
AuthInterceptor(this._sessionStorage, {required Uri backendOrigin})
: _backendOrigin = backendOrigin;
final SecureIdentityStorage _identityStorage;
final AuthSessionStorage _sessionStorage;
final Uri _backendOrigin;
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
if (!_sameOrigin(options.uri, _backendOrigin)) {
_stripSensitiveHeaders(options.headers);
handler.next(options);
return;
}
await DeviceHeaderService.to.ensureInitialized();
options.headers.addAll(DeviceHeaderService.to.commonHeaders());
options.headers.addAll(DeviceHeaderService.to.platformIdentityHeaders());
final jwt = await _identityStorage.readJwt();
final jwt = _sessionStorage.accessToken ?? await _sessionStorage.restore();
if (jwt != null && jwt.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $jwt';
}
options.extra[authSessionGenerationExtra] = _sessionStorage.generation;
handler.next(options);
}
}
bool _sameOrigin(Uri request, Uri backend) =>
request.scheme.toLowerCase() == backend.scheme.toLowerCase() &&
request.host.toLowerCase() == backend.host.toLowerCase() &&
request.port == backend.port;
void _stripSensitiveHeaders(Map<String, dynamic> headers) {
const sensitive = {
'authorization',
'x-device-id',
'user-devicetoken',
'user-android-id',
'user-android-oaid',
'user-android-imei',
'user-android-mac',
'user-ios-idfv',
'user-ios-idfa',
'user-ios-paid',
};
headers.removeWhere((key, _) => sensitive.contains(key.toLowerCase()));
}
+53 -3
View File
@@ -6,6 +6,8 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../config/app_config.dart';
import '../storage/secure_identity_storage.dart';
import '../../features/auth/application/auth_session_coordinator.dart';
import 'api_exception.dart';
import 'auth_interceptor.dart';
import 'kratos_error_interceptor.dart';
@@ -26,7 +28,9 @@ AppConfig appConfig(Ref ref) => AppConfig.fromEnvironment();
@Riverpod(keepAlive: true)
Dio dio(Ref ref) {
final config = ref.watch(appConfigProvider);
final identityStorage = ref.watch(secureIdentityStorageProvider);
final sessionStorage = ref.watch(authSessionStorageProvider);
final coordinator = ref.watch(authSessionCoordinatorProvider);
final backendOrigin = Uri.parse(config.apiBaseUrl);
final d = Dio(
BaseOptions(
baseUrl: config.apiBaseUrl,
@@ -40,11 +44,57 @@ Dio dio(Ref ref) {
},
),
);
d.interceptors.add(AuthInterceptor(identityStorage));
d.interceptors.add(
AuthInterceptor(sessionStorage, backendOrigin: backendOrigin),
);
d.interceptors.add(KratosErrorInterceptor());
d.interceptors.add(_SessionExpiryInterceptor(coordinator));
if (!kReleaseMode) {
d.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
d.interceptors.add(FancyDioInterceptor());
}
d.interceptors.add(FancyDioInterceptor());
return d;
}
/// Explicit name for new code. [dioProvider] remains as a compatibility alias
/// while existing feature clients are migrated incrementally.
final backendDioProvider = dioProvider;
@Riverpod(keepAlive: true)
Dio publicDio(Ref ref) => Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 30),
),
);
class _SessionExpiryInterceptor extends Interceptor {
_SessionExpiryInterceptor(this._coordinator);
final AuthSessionCoordinator _coordinator;
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final exception = ApiException.from(err);
if (exception.isUnauthorized) {
// Network code only emits the session event. Navigation and presentation
// observe AuthState elsewhere.
try {
await _coordinator.expireIfNeeded(exception);
} catch (error, stackTrace) {
FlutterError.reportError(
FlutterErrorDetails(
exception: error,
stack: stackTrace,
library: 'jinzhi network',
context: ErrorDescription('while expiring an unauthorized session'),
),
);
}
}
handler.next(err);
}
}
@@ -1,27 +1,16 @@
import 'package:dio/dio.dart';
import '../../models/pb/errors/errors.pb.dart' as kerrors;
import 'api_exception.dart';
class KratosErrorInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) {
final response = err.response;
final raw = response?.data;
if (response != null && raw is List<int> && raw.isNotEmpty) {
try {
final status = kerrors.Status.fromBuffer(raw);
if (status.code != 0 || status.reason.isNotEmpty) {
response.data = <String, dynamic>{
'code': status.code,
'reason': status.reason,
'message': status.message,
'metadata': status.metadata,
};
}
} catch (_) {
// A non-Kratos error body keeps its original representation.
}
if (err.error is ApiException) {
handler.next(err);
return;
}
handler.next(err);
// Preserve response.data for diagnostics and retries. The parsed error is
// carried as DioException.error and normalized by ApiException.from().
handler.next(err.copyWith(error: ApiException.fromDio(err)));
}
}
@@ -0,0 +1,58 @@
import 'dart:convert';
/// Minimal reader retained only for endpoints that do not have an
/// authoritative generated protobuf model yet.
class ProtobufWireMessage {
ProtobufWireMessage(this.bytes);
final List<int> bytes;
Map<int, List<Object>>? _cache;
Map<int, List<Object>> get _fields => _cache ??= _read();
int integer(int tag) => (_fields[tag]?.lastOrNull as int?) ?? 0;
String string(int tag) {
final value = _fields[tag]?.lastOrNull;
return value is List<int> ? utf8.decode(value, allowMalformed: true) : '';
}
Map<int, List<Object>> _read() {
final result = <int, List<Object>>{};
var index = 0;
int varint() {
var value = 0;
var shift = 0;
while (index < bytes.length) {
final byte = bytes[index++];
value |= (byte & 127) << shift;
if (byte < 128) return value;
shift += 7;
}
throw const FormatException('Invalid protobuf');
}
while (index < bytes.length) {
final key = varint();
final tag = key >> 3;
final Object field;
if ((key & 7) == 0) {
field = varint();
} else if ((key & 7) == 2) {
final length = varint();
final end = index + length;
if (end > bytes.length) throw const FormatException('Invalid protobuf');
field = bytes.sublist(index, end);
index = end;
} else {
throw const FormatException('Unsupported protobuf');
}
(result[tag] ??= []).add(field);
}
return result;
}
}
extension on List<Object> {
Object? get lastOrNull => isEmpty ? null : last;
}
+32 -61
View File
@@ -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<String>? _subscription;
String? _lastToken;
bool? _lastPermission;
int _attempt = 0;
Future<void> _registrationQueue = Future.value();
void start() {
_subscription ??= UmengPushService.onDeviceToken.listen(_register);
_subscription ??= UmengPushService.onDeviceToken.listen(_enqueue);
UmengPushService.emitCachedToken();
}
Future<void> stop() async => _subscription?.cancel();
Future<void> 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<void> _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<List<int>>(
'/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<void>.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<void>.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();
}
}
+24
View File
@@ -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<RegisterPushDeviceReply> registerDevice(
@Body() RegisterPushDeviceRequest request,
);
}
final pushApiClientProvider = Provider<PushApiClient>(
(ref) => PushApiClient(ref.watch(backendDioProvider)),
);
+10 -6
View File
@@ -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<String>.broadcast();
static Future<void>? _starting;
static bool _started = false;
static void Function(String payload)? _onNotificationOpen;
static String? _pendingNotificationOpen;
static final List<String> _pendingNotificationOpens = [];
static Stream<String> 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<String>.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);
}
+161 -12
View File
@@ -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<DeepLinkEvent> _pending = [];
static final Set<String> _pendingEventIds = {};
static final Map<String, DateTime> _seenEventIds = {};
static final Set<String> _configuredHttpsHosts = _normalizedHosts(
_environmentHosts.split(','),
);
static Set<String> _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<String> 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<String> _normalizedHosts(Iterable<String> hosts) => {
for (final host in hosts)
if (host.trim().isNotEmpty) host.trim().toLowerCase(),
};
}
+4 -2
View File
@@ -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<TabShell>
@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);
+4 -2
View File
@@ -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<void> 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');
}
}
@@ -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<void>? _baseInitialization;
Future<void>? _identityInitialization;
SecureIdentityStorage get _identityStorage =>
SecureIdentityStorage(PreferencesStorage(StorageService.to.preferences));
Future<void> ensureInitialized() async {
_baseInitialization ??= _initializeBase();
await _baseInitialization;
@@ -89,14 +91,14 @@ class DeviceHeaderService {
/// X-Device-Id:iOS 优先 IDFV(per-vendor 稳定),否则 UUIDv4;
/// 首次生成后写入 secure_storage,后续复用。
Future<String> _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;
}
@@ -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<void> setBool(String key, bool value) =>
_preferences.setBool(key, value);
Future<void> setString(String key, String value) =>
_preferences.setString(key, value);
Future<void> remove(String key) => _preferences.remove(key);
}
final preferencesStorageProvider = Provider<PreferencesStorage>(
(ref) => PreferencesStorage(StorageService.to.preferences),
);
+104 -17
View File
@@ -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<String?> 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<String?> 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<String?> 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<void> clearJwt() async {
await _storage.delete(_accessTokenKey);
await _storage.delete(_legacySecureJwtKey);
await _deleteAllAuthCredentials();
await _preferences.remove(storageLegacyAccessToken);
await _preferences.remove(storageAuthSessionActive);
}
Future<void> clearSessionCredentials() async {
await clearJwt();
await _storage.delete(secureMallCredential);
await _storage.delete(secureRefreshToken);
}
Future<void> clearAccountCredentials() async {
await clearSessionCredentials();
await _storage.delete(secureBiometricCredential);
}
Future<String?> readDeviceId() => _storage.read(secureDeviceId);
Future<void> saveDeviceId(String deviceId) =>
_storage.write(secureDeviceId, deviceId);
Future<void> _deleteAllAuthCredentials() async {
await _storage.delete(secureAccessToken);
await _storage.delete(secureLegacyJwtToken);
}
}
final secureIdentityStorageProvider = Provider<SecureIdentityStorage>(
(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<String?>? _restoreFuture;
Future<void> _operationTail = Future<void>.value();
String? get accessToken => _accessToken;
int get generation => _generation;
Future<String?> restore() => _restoreFuture ??= _serialize(_restore);
Future<String?> _restore() async {
_accessToken = await _identityStorage.restoreAccessToken();
_generation++;
return _accessToken;
}
Future<void> save(String token) async {
await _serialize(() async {
await _identityStorage.saveJwt(token);
_accessToken = token.trim();
_generation++;
_restoreFuture = Future<String?>.value(_accessToken);
});
}
Future<void> clear({bool accountDeleted = false}) async {
await _serialize(() async {
if (accountDeleted) {
await _identityStorage.clearAccountCredentials();
} else {
await _identityStorage.clearSessionCredentials();
}
_accessToken = null;
_generation++;
_restoreFuture = Future<String?>.value();
});
}
Future<T> _serialize<T>(Future<T> Function() operation) {
final result = _operationTail.then((_) => operation());
_operationTail = result.then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
);
return result;
}
}
final authSessionStorageProvider = Provider<AuthSessionStorage>(
(ref) => AuthSessionStorage(ref.watch(secureIdentityStorageProvider)),
);
String? _normalized(String? value) {
+11
View File
@@ -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
@@ -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<String, String>? 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,
+44 -2
View File
@@ -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<T> 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<T> extends StatelessWidget {
);
}
}
/// Sliver counterpart for CustomScrollView pages without forcing one global
/// card layout on successful feature content.
class SliverAsyncState<T> extends StatelessWidget {
const SliverAsyncState({
super.key,
required this.state,
required this.data,
this.loading,
this.onRetry,
this.errorSubtitle = '请检查网络后重试',
});
final AsyncValue<T> 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,
),
),
),
);
}