fix:参考金值,研听,研值工程通用通用工程公共能力,重点对比网络请求、登录认证、状态管理、本地存储、WebView、推送、版本更新、一键登录、主题/UI 等模块,确定后续统一整改方案
This commit is contained in:
1 parent
6997a65c96
commit
d364c6d908
46 files changed
+2060
-395
No files matched your search
@@ -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<AuthController, AsyncValue<AuthState>>((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<AsyncValue<AuthState>> {
|
||||
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<AsyncValue<AuthState>> {
|
||||
|
||||
Future<void> _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<void> _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<AsyncValue<AuthState>> {
|
||||
}
|
||||
|
||||
Future<void> _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<AsyncValue<AuthState>> {
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
await _coordinator.logout();
|
||||
}
|
||||
|
||||
Future<void> deleteAccount(String code) async {
|
||||
@@ -98,8 +110,7 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
throw ArgumentError.value(code, 'code', 'must not be empty');
|
||||
}
|
||||
await _api.deleteAccount(normalized);
|
||||
await _clearLocalSession();
|
||||
state = const AsyncData(GuestAuthState());
|
||||
await _coordinator.accountDeleted();
|
||||
}
|
||||
|
||||
Future<void> _persistProfile(JinzhiUser user) async {
|
||||
@@ -122,22 +133,19 @@ class AuthController extends StateNotifier<AsyncValue<AuthState>> {
|
||||
return JinzhiUser(id: id, phone: phone, createdAt: createdAt);
|
||||
}
|
||||
|
||||
Future<void> _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();
|
||||
}
|
||||
}
|
||||
@@ -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<SessionEndListener> _listeners = {};
|
||||
Future<void>? _ending;
|
||||
SessionEndReason? _endingReason;
|
||||
|
||||
int get generation => _sessionStorage.generation;
|
||||
|
||||
void addListener(SessionEndListener listener) => _listeners.add(listener);
|
||||
void removeListener(SessionEndListener listener) =>
|
||||
_listeners.remove(listener);
|
||||
|
||||
Future<String?> restore() => _sessionStorage.restore();
|
||||
|
||||
Future<void> startSession(String token) async {
|
||||
final ending = _ending;
|
||||
if (ending != null) await ending;
|
||||
await _sessionStorage.save(token);
|
||||
}
|
||||
|
||||
Future<bool> 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<void> logout() => end(SessionEndReason.logout);
|
||||
|
||||
Future<void> accountDeleted() => end(SessionEndReason.accountDeleted);
|
||||
|
||||
Future<void> end(SessionEndReason reason) {
|
||||
_endingReason = _strongerReason(_endingReason, reason);
|
||||
final active = _ending;
|
||||
if (active != null) return active;
|
||||
final operation = _drainEnd();
|
||||
late final Future<void> tracked;
|
||||
tracked = operation.whenComplete(() {
|
||||
if (identical(_ending, tracked)) {
|
||||
_ending = null;
|
||||
_endingReason = null;
|
||||
}
|
||||
});
|
||||
_ending = tracked;
|
||||
return tracked;
|
||||
}
|
||||
|
||||
Future<void> _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<SessionEndListener>.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<AuthSessionCoordinator>(
|
||||
(ref) => AuthSessionCoordinator(
|
||||
ref.watch(authSessionStorageProvider),
|
||||
ref.watch(preferencesStorageProvider),
|
||||
ref.watch(mockAssetRepositoryProvider),
|
||||
),
|
||||
);
|
||||
Reference in new issue
Block a user