feat(genex-mobile): Token 持久化 — 登录状态跨重启保持

## 核心变更

### 新增 SessionStorage (lib/core/storage/session_storage.dart)
- flutter_secure_storage 封装层
- Android: EncryptedSharedPreferences(AES-256,Android Keystore 托管密钥)
- iOS: Keychain(AccessibilityFirstUnlock)
- 存储键: genex_access_token / genex_refresh_token / genex_user_json
- API: save() / load() / updateTokens() / getRefreshToken() / clear()

### 升级 ApiClient (lib/core/network/api_client.dart)
- 新增 Dio 错误拦截器(_buildAuthInterceptor)
- 401 自动触发 Token 静默刷新,成功后无感重试原始请求
- Completer 并发锁:多个 401 同时发生只执行一次刷新,其余等待结果
- 跳过重试的端点:/auth/refresh、/auth/login、/auth/register 等
- configureAuthCallbacks():注册 onTokenRefreshed / onSessionExpired 反向回调

### 升级 AuthService (lib/core/services/auth_service.dart)
- _setAuth():登录/注册后 await 写入 SecureStorage
- _clearAuth():登出/Token 过期后 await 清除 SecureStorage
- restoreSession():App 冷启动时从 SecureStorage 恢复 Token + 注册 ApiClient 回调
- refreshToken():主动刷新(正常由拦截器自动触发,无需手动调用)

### 升级 main.dart
- await AuthService.instance.restoreSession() 在 runApp 前执行
- initialRoute 动态判断:isLoggedIn → '/main',否则 '/'
- 全局 NavigatorKey(_navigatorKey)挂载到 MaterialApp
- 监听 authState ValueNotifier:Token 过期后自动导航回 '/'(pushNamedAndRemoveUntil)

## 用户体验
- 登录后关闭 App 再打开:直接进主界面,无需重新登录
- Access Token 过期:ApiClient 自动静默刷新,用户无感知
- Refresh Token 过期:清除本地会话,跳回欢迎页,提示重新登录
- 主动登出:清除 SecureStorage,跳回欢迎页

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-04 02:01:27 -08:00
parent 1c36c849e2
commit a893dbdb1b
5 changed files with 516 additions and 43 deletions

View File

@ -1,11 +1,50 @@
import 'package:dio/dio.dart';
// ============================================================
// ApiClient Genex HTTP
//
// Dio HTTP
// 1. baseUrl /
// 2. Authorization setToken()
// 3. 401 Token +
// 4. 401 Completer
// 5. Token AuthService
//
//
// SessionStorage RefreshToken
// AuthService AuthResult import
//
// POST /api/v1/auth/refresh { refreshToken }
// : { data: { accessToken, refreshToken, expiresIn } }
//
//
// /auth/refresh, /auth/login, /auth/register, /auth/login-phone
// Options(extra: {'skipAuthRetry': true})
// ============================================================
/// Genex API
/// Dio HTTP
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../storage/session_storage.dart';
/// Genex API
class ApiClient {
static ApiClient? _instance;
late final Dio _dio;
// Token
// 401
// _refreshCompleter.future
Completer<bool>? _refreshCompleter;
// AuthService.restoreSession()
// api_client.dart import auth_service.dart
/// Token AuthService
Function(String accessToken, String refreshToken)? _onTokenRefreshed;
/// Token refresh AuthService
VoidCallback? _onSessionExpired;
//
ApiClient._({required String baseUrl}) {
_dio = Dio(BaseOptions(
baseUrl: baseUrl,
@ -17,10 +56,10 @@ class ApiClient {
'Accept': 'application/json',
},
));
_dio.interceptors.add(_buildAuthInterceptor());
}
/// API Nginx Kong
/// https://api.gogenex.cn
/// API Nginx Kong :8080
static const String defaultBaseUrl = 'https://api.gogenex.com';
static ApiClient get instance {
@ -34,7 +73,11 @@ class ApiClient {
Dio get dio => _dio;
/// JWT Token
// API
/// / JWT Authorization
///
/// null Authorization
void setToken(String? token) {
if (token != null) {
_dio.options.headers['Authorization'] = 'Bearer $token';
@ -43,6 +86,20 @@ class ApiClient {
}
}
/// Auth AuthService.restoreSession()
///
/// [onTokenRefreshed] AuthResult
/// [onSessionExpired] AuthService
void configureAuthCallbacks({
required Function(String accessToken, String refreshToken) onTokenRefreshed,
required VoidCallback onSessionExpired,
}) {
_onTokenRefreshed = onTokenRefreshed;
_onSessionExpired = onSessionExpired;
}
// HTTP
Future<Response> get(
String path, {
Map<String, dynamic>? queryParameters,
@ -77,4 +134,135 @@ class ApiClient {
}) {
return _dio.delete(path, data: data, queryParameters: queryParameters, options: options);
}
// 401
///
///
/// 401
/// 1. auth
/// 2. Token
/// 3. Token
/// 4. AuthService
InterceptorsWrapper _buildAuthInterceptor() {
return InterceptorsWrapper(
onError: (DioException error, ErrorInterceptorHandler handler) async {
// 401
if (error.response?.statusCode != 401) {
return handler.next(error);
}
final path = error.requestOptions.path;
// 401
final isAuthEndpoint = path.contains('/auth/refresh') ||
path.contains('/auth/login') ||
path.contains('/auth/register') ||
path.contains('/auth/login-phone') ||
path.contains('/auth/sms/send');
//
final skipRetry = error.requestOptions.extra['skipAuthRetry'] == true;
if (isAuthEndpoint || skipRetry) {
return handler.next(error);
}
// Token
final refreshed = await _tryRefreshToken();
if (refreshed) {
// Token
try {
final retryResp = await _retryWithNewToken(error.requestOptions);
return handler.resolve(retryResp);
} catch (_) {
//
return handler.next(error);
}
} else {
// Access Token + Refresh Token
_onSessionExpired?.call();
return handler.next(error);
}
},
);
}
// Token
/// Token Completer
///
///
///
///
/// true Token SessionStorage + Dio headers
/// false RefreshToken
Future<bool> _tryRefreshToken() async {
//
if (_refreshCompleter != null) {
return _refreshCompleter!.future;
}
_refreshCompleter = Completer<bool>();
try {
// RefreshToken
final refreshToken = await SessionStorage.instance.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
_refreshCompleter!.complete(false);
return false;
}
// extra skipAuthRetry: true 401
final resp = await _dio.post(
'/api/v1/auth/refresh',
data: {'refreshToken': refreshToken},
options: Options(extra: {'skipAuthRetry': true}),
);
final tokens = resp.data['data'] as Map<String, dynamic>;
final newAccess = tokens['accessToken'] as String;
final newRefresh = tokens['refreshToken'] as String;
// Token
await SessionStorage.instance.updateTokens(newAccess, newRefresh);
// Dio Authorization
setToken(newAccess);
// AuthService AuthResult
_onTokenRefreshed?.call(newAccess, newRefresh);
_refreshCompleter!.complete(true);
return true;
} catch (_) {
_refreshCompleter!.complete(false);
return false;
} finally {
//
_refreshCompleter = null;
}
}
/// 使 Token
///
/// Dio Authorization
Future<Response> _retryWithNewToken(RequestOptions original) {
final headers = Map<String, dynamic>.from(original.headers);
// Token
final authHeader = _dio.options.headers['Authorization'];
if (authHeader != null) {
headers['Authorization'] = authHeader;
}
return _dio.request(
original.path,
data: original.data,
queryParameters: original.queryParameters,
options: Options(
method: original.method,
headers: headers,
extra: original.extra,
),
);
}
}

View File

@ -1,5 +1,28 @@
// ============================================================
// AuthService
//
//
// 1. auth-service API ///
// 2. authState ValueNotifier
// 3. Token via SessionStorage
// 4. App restoreSession
// 5. ApiClient Token /
//
// Token
// / _setAuth() SecureStorage + ApiClient Header + authState
// 401 ApiClient _onTokenRefreshed authState
// ApiClient _clearAuth SecureStorage + authState /
// logout() DELETE /api/v1/auth/logout _clearAuth()
//
//
// ApiClient HTTP lib/core/network/api_client.dart
// SessionStorage lib/core/storage/session_storage.dart
// ============================================================
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../network/api_client.dart';
import '../storage/session_storage.dart';
/// SMS
enum SmsCodeType {
@ -12,14 +35,29 @@ enum SmsCodeType {
const SmsCodeType(this.value);
}
///
/// //Token
///
/// /api/v1/auth/login
/// ```json
/// {
/// "code": 0,
/// "data": {
/// "user": { "id": "uuid", "phone": "+8618....", "nickname": "..." },
/// "tokens": {
/// "accessToken": "eyJ...",
/// "refreshToken": "eyJ...",
/// "expiresIn": 900 // Access Token 900 = 15 min
/// }
/// }
/// }
/// ```
class AuthResult {
final Map<String, dynamic> user;
final String accessToken;
final String refreshToken;
final int expiresIn;
AuthResult({
const AuthResult({
required this.user,
required this.accessToken,
required this.refreshToken,
@ -37,7 +75,7 @@ class AuthResult {
}
}
/// Auth Service auth-service API
///
class AuthService {
static final AuthService _instance = AuthService._();
static AuthService get instance => _instance;
@ -45,14 +83,76 @@ class AuthService {
final _api = ApiClient.instance;
//
//
/// null
///
/// Notifier /
/// main.dart
/// - isLoggedIn '/main' '/'
/// - Session
final ValueNotifier<AuthResult?> authState = ValueNotifier(null);
///
bool get isLoggedIn => authState.value != null;
/* ── SMS 验证码 ── */
//
/// App runApp await
///
///
/// 1. ApiClient Token /
/// 2. SecureStorage Token +
/// 3. authState ApiClient Authorization
/// 4. false/
///
/// Token
/// Access Token ApiClient API
/// Refresh Token [_clearAuth] '/'
///
/// Returns true /main
/// Returns false
Future<bool> restoreSession() async {
// 1. ApiClient
_api.configureAuthCallbacks(
// Token AuthResult
onTokenRefreshed: (newAccess, newRefresh) {
final current = authState.value;
if (current != null) {
authState.value = AuthResult(
user: current.user,
accessToken: newAccess,
refreshToken: newRefresh,
expiresIn: 0, //
);
}
},
// Token ValueNotifier main.dart
onSessionExpired: _clearAuth,
);
// 2. SecureStorage
final saved = await SessionStorage.instance.load();
if (saved == null) return false;
// 3. expiresIn=0
authState.value = AuthResult(
user: saved.user,
accessToken: saved.accessToken,
refreshToken: saved.refreshToken,
expiresIn: 0,
);
_api.setToken(saved.accessToken);
return true;
}
// SMS
///
/// expiresIn ()
///
/// [type] 使REGISTER / LOGIN / RESET_PASSWORD / CHANGE_PHONE
///
/// Returns expiresIn ()UI
Future<int> sendSmsCode(String phone, SmsCodeType type) async {
final resp = await _api.post('/api/v1/auth/sms/send', data: {
'phone': phone,
@ -62,22 +162,27 @@ class AuthService {
return data['expiresIn'] as int;
}
/* ── 推荐码 ── */
//
/// ( referral-service)
///
Future<bool> validateReferralCode(String code) async {
try {
final resp = await _api.get('/api/v1/referral/validate', queryParameters: {'code': code});
final resp = await _api.get(
'/api/v1/referral/validate',
queryParameters: {'code': code},
);
final data = resp.data['data'] as Map<String, dynamic>;
return data['valid'] == true;
} catch (_) {
return false;
return false; //
}
}
/* ── 注册 ── */
//
/// ( REGISTER )
/// SmsCodeType.register
///
/// _setAuth Token +
Future<AuthResult> register({
required String phone,
required String smsCode,
@ -94,13 +199,15 @@ class AuthService {
'referralCode': referralCode.toUpperCase(),
});
final result = AuthResult.fromJson(resp.data['data']);
_setAuth(result);
await _setAuth(result);
return result;
}
/* ── 登录 ── */
//
///
///
/// [identifier] E.164
Future<AuthResult> loginByPassword({
required String identifier,
required String password,
@ -112,11 +219,11 @@ class AuthService {
if (deviceInfo != null) 'deviceInfo': deviceInfo,
});
final result = AuthResult.fromJson(resp.data['data']);
_setAuth(result);
await _setAuth(result);
return result;
}
/// ( LOGIN )
/// SmsCodeType.login
Future<AuthResult> loginByPhone({
required String phone,
required String smsCode,
@ -128,13 +235,13 @@ class AuthService {
if (deviceInfo != null) 'deviceInfo': deviceInfo,
});
final result = AuthResult.fromJson(resp.data['data']);
_setAuth(result);
await _setAuth(result);
return result;
}
/* ── 密码管理 ── */
//
/// ( RESET_PASSWORD )
/// SmsCodeType.resetPassword
Future<void> resetPassword({
required String phone,
required String smsCode,
@ -147,7 +254,9 @@ class AuthService {
});
}
/// ()
///
///
/// Token
Future<void> changePassword({
required String oldPassword,
required String newPassword,
@ -156,13 +265,12 @@ class AuthService {
'oldPassword': oldPassword,
'newPassword': newPassword,
});
//
logout();
await logout();
}
/* ── 手机号管理 ── */
//
/// ( + CHANGE_PHONE )
/// + SmsCodeType.changePhone
Future<void> changePhone({
required String newPhone,
required String newSmsCode,
@ -173,16 +281,20 @@ class AuthService {
});
}
/* ── Token 管理 ── */
// Token
/// Token
/// TokenApiClient
///
/// 使 Access Token
Future<void> refreshToken() async {
final current = authState.value;
if (current == null) return;
final resp = await _api.post('/api/v1/auth/refresh', data: {
'refreshToken': current.refreshToken,
});
final resp = await _api.post(
'/api/v1/auth/refresh',
data: {'refreshToken': current.refreshToken},
options: Options(extra: {'skipAuthRetry': true}),
);
final tokens = resp.data['data'] as Map<String, dynamic>;
final newResult = AuthResult(
user: current.user,
@ -190,28 +302,47 @@ class AuthService {
refreshToken: tokens['refreshToken'] as String,
expiresIn: tokens['expiresIn'] as int,
);
_setAuth(newResult);
await _setAuth(newResult);
}
///
///
///
/// Tokenfire-and-forget使
Future<void> logout() async {
try {
await _api.post('/api/v1/auth/logout');
} catch (_) {
// 使
//
}
_clearAuth();
await _clearAuth();
}
/* ── Private ── */
//
void _setAuth(AuthResult result) {
/// //Token
///
/// 1. authState UI
/// 2. ApiClient Authorization
/// 3. SecureStorage
Future<void> _setAuth(AuthResult result) async {
authState.value = result;
_api.setToken(result.accessToken);
// Token await
await SessionStorage.instance.save(
accessToken: result.accessToken,
refreshToken: result.refreshToken,
user: result.user,
);
}
void _clearAuth() {
/// /Token
///
/// 1. authStateValueNotifier main.dart '/'
/// 2. ApiClient Authorization
/// 3. SecureStorage
Future<void> _clearAuth() async {
authState.value = null;
_api.setToken(null);
await SessionStorage.instance.clear();
}
}

View File

@ -0,0 +1,127 @@
// ============================================================
// SessionStorage
//
// flutter_secure_storage
// Android : EncryptedSharedPreferencesAES-256-GCMKeystore
// iOS : KeychainAccessibilityFirstUnlock
//
// "genex_" App
// genex_access_token JWT Access Token 15 min2 h
// genex_refresh_token JWT Refresh Token 730
// genex_user_json JSON URLuserId
//
//
// save() //Token AuthService._setAuth()
// load() App AuthService.restoreSession()
// updateTokens() Token ApiClient JSON
// clear() /Token AuthService._clearAuth()
// ============================================================
import 'dart:convert';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
///
class SavedSession {
/// Access Token ApiClient
final String accessToken;
/// Refresh Token Access Token
final String refreshToken;
/// 线
final Map<String, dynamic> user;
const SavedSession({
required this.accessToken,
required this.refreshToken,
required this.user,
});
}
///
class SessionStorage {
static final SessionStorage _instance = SessionStorage._();
static SessionStorage get instance => _instance;
SessionStorage._();
//
static const _storage = FlutterSecureStorage(
// Android: 使 EncryptedSharedPreferences Android Keystore
aOptions: AndroidOptions(encryptedSharedPreferences: true),
// iOS: 访
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
//
static const _kAccessToken = 'genex_access_token';
static const _kRefreshToken = 'genex_refresh_token';
static const _kUserJson = 'genex_user_json';
// API
/// /
///
///
Future<void> save({
required String accessToken,
required String refreshToken,
required Map<String, dynamic> user,
}) async {
await Future.wait([
_storage.write(key: _kAccessToken, value: accessToken),
_storage.write(key: _kRefreshToken, value: refreshToken),
_storage.write(key: _kUserJson, value: jsonEncode(user)),
]);
}
/// App
///
/// null
/// [SavedSession.accessToken]
/// API 401 ApiClient
Future<SavedSession?> load() async {
final accessToken = await _storage.read(key: _kAccessToken);
if (accessToken == null || accessToken.isEmpty) return null;
final refreshToken = await _storage.read(key: _kRefreshToken);
if (refreshToken == null || refreshToken.isEmpty) return null;
final userJson = await _storage.read(key: _kUserJson);
Map<String, dynamic> user = {};
if (userJson != null) {
try {
user = jsonDecode(userJson) as Map<String, dynamic>;
} catch (_) {
// JSON Map Token
}
}
return SavedSession(
accessToken: accessToken,
refreshToken: refreshToken,
user: user,
);
}
/// TokenToken JSON
Future<void> updateTokens(String accessToken, String refreshToken) async {
await Future.wait([
_storage.write(key: _kAccessToken, value: accessToken),
_storage.write(key: _kRefreshToken, value: refreshToken),
]);
}
/// Refresh TokenApiClient
Future<String?> getRefreshToken() => _storage.read(key: _kRefreshToken);
/// / Token
Future<void> clear() async {
await Future.wait([
_storage.delete(key: _kAccessToken),
_storage.delete(key: _kRefreshToken),
_storage.delete(key: _kUserJson),
]);
}
}

View File

@ -4,6 +4,7 @@ import 'app/theme/app_theme.dart';
import 'app/main_shell.dart';
import 'app/i18n/app_localizations.dart';
import 'app/i18n/locale_manager.dart';
import 'core/services/auth_service.dart';
import 'core/updater/update_service.dart';
import 'core/updater/models/update_config.dart';
import 'core/push/push_service.dart';
@ -56,6 +57,9 @@ Future<void> main() async {
//
await LocaleManager.init();
// Token /main
await AuthService.instance.restoreSession();
runApp(const GenexConsumerApp());
}
@ -73,15 +77,24 @@ class GenexConsumerApp extends StatefulWidget {
}
class _GenexConsumerAppState extends State<GenexConsumerApp> {
// Navigator Key BuildContext Session /
final _navigatorKey = GlobalKey<NavigatorState>();
// Session
bool _wasLoggedIn = false;
@override
void initState() {
super.initState();
LocaleManager.userLocale.addListener(_onLocaleChanged);
_wasLoggedIn = AuthService.instance.isLoggedIn;
AuthService.instance.authState.addListener(_onAuthStateChanged);
}
@override
void dispose() {
LocaleManager.userLocale.removeListener(_onLocaleChanged);
AuthService.instance.authState.removeListener(_onAuthStateChanged);
super.dispose();
}
@ -89,12 +102,24 @@ class _GenexConsumerAppState extends State<GenexConsumerApp> {
setState(() {});
}
/// Token ApiClient AuthService._clearAuth()
/// authState null
void _onAuthStateChanged() {
final isLoggedIn = AuthService.instance.isLoggedIn;
if (_wasLoggedIn && !isLoggedIn) {
// Session
_navigatorKey.currentState?.pushNamedAndRemoveUntil('/', (_) => false);
}
_wasLoggedIn = isLoggedIn;
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Genex',
theme: AppTheme.light,
debugShowCheckedModeBanner: false,
navigatorKey: _navigatorKey,
// i18n
locale: LocaleManager.userLocale.value,
@ -115,7 +140,8 @@ class _GenexConsumerAppState extends State<GenexConsumerApp> {
return LocaleManager.userLocale.value;
},
initialRoute: '/',
// Token
initialRoute: AuthService.instance.isLoggedIn ? '/main' : '/',
onGenerateRoute: _generateRoute,
);
}

View File

@ -25,6 +25,7 @@ dependencies:
shared_preferences: ^2.2.3
qr_flutter: ^4.1.0
share_plus: ^10.0.2
flutter_secure_storage: ^9.2.2
dev_dependencies:
flutter_test: