From a893dbdb1b94a815e3a00f189ad8e0a04d4a0018 Mon Sep 17 00:00:00 2001 From: hailin Date: Wed, 4 Mar 2026 02:01:27 -0800 Subject: [PATCH] =?UTF-8?q?feat(genex-mobile):=20Token=20=E6=8C=81?= =?UTF-8?q?=E4=B9=85=E5=8C=96=20=E2=80=94=20=E7=99=BB=E5=BD=95=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E8=B7=A8=E9=87=8D=E5=90=AF=E4=BF=9D=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 核心变更 ### 新增 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 --- .../lib/core/network/api_client.dart | 200 ++++++++++++++++- .../lib/core/services/auth_service.dart | 203 ++++++++++++++---- .../lib/core/storage/session_storage.dart | 127 +++++++++++ frontend/genex-mobile/lib/main.dart | 28 ++- frontend/genex-mobile/pubspec.yaml | 1 + 5 files changed, 516 insertions(+), 43 deletions(-) create mode 100644 frontend/genex-mobile/lib/core/storage/session_storage.dart diff --git a/frontend/genex-mobile/lib/core/network/api_client.dart b/frontend/genex-mobile/lib/core/network/api_client.dart index f6cfbb7..8690749 100644 --- a/frontend/genex-mobile/lib/core/network/api_client.dart +++ b/frontend/genex-mobile/lib/core/network/api_client.dart @@ -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? _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 get( String path, { Map? 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 _tryRefreshToken() async { + // 已有刷新进行中,等待其结果 + if (_refreshCompleter != null) { + return _refreshCompleter!.future; + } + + _refreshCompleter = Completer(); + 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; + 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 _retryWithNewToken(RequestOptions original) { + final headers = Map.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, + ), + ); + } } diff --git a/frontend/genex-mobile/lib/core/services/auth_service.dart b/frontend/genex-mobile/lib/core/services/auth_service.dart index 8ce926e..5b99584 100644 --- a/frontend/genex-mobile/lib/core/services/auth_service.dart +++ b/frontend/genex-mobile/lib/core/services/auth_service.dart @@ -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 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 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 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 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 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; return data['valid'] == true; } catch (_) { - return false; + return false; // 网络异常时降级为不阻断注册 } } - /* ── 注册 ── */ + // ── 注册 ───────────────────────────────────────────────────────────────── - /// 手机号注册 (需先获取 REGISTER 类型验证码) + /// 手机号注册(需先用 SmsCodeType.register 获取验证码) + /// + /// 注册成功后自动登录(调用 _setAuth 保存 Token + 设置请求头)。 Future 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 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 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 resetPassword({ required String phone, required String smsCode, @@ -147,7 +254,9 @@ class AuthService { }); } - /// 修改密码 (需登录) + /// 修改密码(已登录状态,需要当前密码验证) + /// + /// 修改成功后强制登出——旧 Token 会被后端撤销。 Future 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 changePhone({ required String newPhone, required String newSmsCode, @@ -173,16 +281,20 @@ class AuthService { }); } - /* ── Token 管理 ── */ + // ── Token 管理 ──────────────────────────────────────────────────────────── - /// 刷新 Token + /// 主动刷新 Token(一般不需要手动调用,ApiClient 拦截器会自动触发) + /// + /// 使用场景:定时主动刷新(如 Access Token 快过期时提前刷新)。 Future 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; 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); } - /// 登出 + /// 主动登出 + /// + /// 通知后端撤销 Token(fire-and-forget,即使失败也清除本地状态)。 Future 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 _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. 清除内存 authState(ValueNotifier 通知 main.dart 监听者导航到 '/') + /// 2. 清除 ApiClient Authorization 头 + /// 3. 清除 SecureStorage + Future _clearAuth() async { authState.value = null; _api.setToken(null); + await SessionStorage.instance.clear(); } } diff --git a/frontend/genex-mobile/lib/core/storage/session_storage.dart b/frontend/genex-mobile/lib/core/storage/session_storage.dart new file mode 100644 index 0000000..41510cf --- /dev/null +++ b/frontend/genex-mobile/lib/core/storage/session_storage.dart @@ -0,0 +1,127 @@ +// ============================================================ +// SessionStorage — 认证令牌安全持久化存储 +// +// 存储后端:flutter_secure_storage +// Android : EncryptedSharedPreferences(AES-256-GCM,Keystore 托管密钥) +// iOS : Keychain(AccessibilityFirstUnlock — 首次解锁后可读) +// +// 存储键(全部带 "genex_" 前缀,避免与其他 App 数据冲突): +// genex_access_token — JWT Access Token(短期,通常 15 min~2 h) +// genex_refresh_token — JWT Refresh Token(长期,通常 7~30 天) +// genex_user_json — 用户基本信息 JSON(昵称、头像 URL、userId 等) +// +// 调用时机: +// 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 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 save({ + required String accessToken, + required String refreshToken, + required Map 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 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 user = {}; + if (userJson != null) { + try { + user = jsonDecode(userJson) as Map; + } catch (_) { + // JSON 损坏时降级为空 Map,不影响 Token 有效性 + } + } + + return SavedSession( + accessToken: accessToken, + refreshToken: refreshToken, + user: user, + ); + } + + /// 仅更新双 Token(Token 静默刷新后调用,不覆盖用户 JSON) + Future updateTokens(String accessToken, String refreshToken) async { + await Future.wait([ + _storage.write(key: _kAccessToken, value: accessToken), + _storage.write(key: _kRefreshToken, value: refreshToken), + ]); + } + + /// 读取 Refresh Token(ApiClient 拦截器执行刷新时调用) + Future getRefreshToken() => _storage.read(key: _kRefreshToken); + + /// 清空所有会话数据(登出 / Token 彻底过期时调用) + Future clear() async { + await Future.wait([ + _storage.delete(key: _kAccessToken), + _storage.delete(key: _kRefreshToken), + _storage.delete(key: _kUserJson), + ]); + } +} diff --git a/frontend/genex-mobile/lib/main.dart b/frontend/genex-mobile/lib/main.dart index 0b52d4b..e1b9fa4 100644 --- a/frontend/genex-mobile/lib/main.dart +++ b/frontend/genex-mobile/lib/main.dart @@ -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 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 { + // Navigator Key — 用于在无 BuildContext 时执行命令式导航(如 Session 过期后跳 /) + final _navigatorKey = GlobalKey(); + + // 上一帧的登录状态,用于检测 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 { 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 { return LocaleManager.userLocale.value; }, - initialRoute: '/', + // 启动路由:已有保存的 Token → 直接进主界面;否则显示欢迎页 + initialRoute: AuthService.instance.isLoggedIn ? '/main' : '/', onGenerateRoute: _generateRoute, ); } diff --git a/frontend/genex-mobile/pubspec.yaml b/frontend/genex-mobile/pubspec.yaml index 66a9c5f..a95a5d2 100644 --- a/frontend/genex-mobile/pubspec.yaml +++ b/frontend/genex-mobile/pubspec.yaml @@ -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: