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:
parent
1c36c849e2
commit
a893dbdb1b
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 主动刷新 Token(一般不需要手动调用,ApiClient 拦截器会自动触发)
|
||||
///
|
||||
/// 使用场景:定时主动刷新(如 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);
|
||||
}
|
||||
|
||||
/// 登出
|
||||
/// 主动登出
|
||||
///
|
||||
/// 通知后端撤销 Token(fire-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. 清除内存 authState(ValueNotifier 通知 main.dart 监听者导航到 '/')
|
||||
/// 2. 清除 ApiClient Authorization 头
|
||||
/// 3. 清除 SecureStorage
|
||||
Future<void> _clearAuth() async {
|
||||
authState.value = null;
|
||||
_api.setToken(null);
|
||||
await SessionStorage.instance.clear();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
);
|
||||
}
|
||||
|
||||
/// 仅更新双 Token(Token 静默刷新后调用,不覆盖用户 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 Token(ApiClient 拦截器执行刷新时调用)
|
||||
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),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue