feat(client): 实时授权状态与会话失效处理

- 心跳读取 /auth/ping 回带的授权概况,直接刷新横幅/状态栏/只读门禁,省去单独轮询 /license/info
- 账号被停用/删除(401 USER_DISABLED)即强制重新登录
- 令牌持久化经串行队列 + 会话代号守卫,杜绝续期写入与登出交叉把失效 token 写回
- 写操作门禁(write_guard)+ 授权文案(license_copy)按 grace/readonly/locked 分阶段降级

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-19 07:34:07 +08:00
parent e41085a878
commit 32bd64d676
23 changed files with 1319 additions and 408 deletions
+63 -23
View File
@@ -45,6 +45,22 @@ class AuthState {
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState());
/// 会话代号:每次 login/logout 自增,使「上一会话」遗留的异步持久化写入失效。
/// 续期写回(updateAccessToken)是 fire-and-forget,若在并发登出清空 token 之后
/// 才落盘,会把已失效的 token 重新写回;持有此代号即可在写入前丢弃过期操作。
int _sessionGen = 0;
/// 串行化 SharedPreferences 写入:保证 login / updateAccessToken / logout 的持久化
/// 按调用顺序落盘,杜绝并发交错(如续期写入与登出 remove 交叉)。
Future<void> _storageQueue = Future.value();
Future<void> _runStorage(Future<void> Function() op) {
final next = _storageQueue.then((_) => op());
// 吞掉单次写入异常,避免队列被卡死。
_storageQueue = next.catchError((_) {});
return next;
}
/// Called at app startup to restore persisted session.
Future<void> restore() async {
try {
@@ -80,28 +96,43 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> login(AuthUser user) async {
debugPrint('[Auth] login() called, username=${user.username} shopId=${user.shopId}');
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, user.accessToken);
await prefs.setString(_kRefreshToken, user.refreshToken);
await prefs.setString(_kUsername, user.username);
await prefs.setString(_kRealName, user.realName);
await prefs.setString(_kShopNo, user.shopNo);
await prefs.setString(_kShopId, user.shopId.toString());
await prefs.setString(_kRole, user.role);
debugPrint('[Auth] login() setting state, token prefix: ${user.accessToken.substring(0, user.accessToken.length.clamp(0, 20))}...');
final gen = ++_sessionGen;
await _runStorage(() async {
if (gen != _sessionGen) return; // 期间又发生 login/logout → 放弃这次写盘
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, user.accessToken);
await prefs.setString(_kRefreshToken, user.refreshToken);
await prefs.setString(_kUsername, user.username);
await prefs.setString(_kRealName, user.realName);
await prefs.setString(_kShopNo, user.shopNo);
await prefs.setString(_kShopId, user.shopId.toString());
await prefs.setString(_kRole, user.role);
});
state = AuthState(initialized: true, user: user);
debugPrint('[Auth] login() state set, isLoggedIn=${state.isLoggedIn}');
}
void updateAccessToken(String newToken) {
/// 续期后写回令牌。后端会**轮换 refresh token**jti 轮换 + 盗用检测),
/// 因此必须连同新的 refresh token 一起持久化;若仍沿用旧 refresh token
/// 下次续期会重放已被取代的 jti,触发盗用检测吊销整条会话,导致被强制登出。
/// [newRefreshToken] 省略(如旧调用方)时保留原 refresh token。
void updateAccessToken(String newToken, [String? newRefreshToken]) {
if (state.user == null) return;
SharedPreferences.getInstance()
.then((prefs) => prefs.setString(_kAccessToken, newToken));
final gen = _sessionGen;
final refreshToken = newRefreshToken ?? state.user!.refreshToken;
// fire-and-forget,但经队列串行 + 代号守卫:若落盘前发生过登出/重登,丢弃此次写入,
// 避免把已失效的 token 写回 SharedPreferences。
_runStorage(() async {
if (gen != _sessionGen) return;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_kAccessToken, newToken);
await prefs.setString(_kRefreshToken, refreshToken);
});
state = AuthState(
initialized: true,
user: AuthUser(
accessToken: newToken,
refreshToken: state.user!.refreshToken,
refreshToken: refreshToken,
username: state.user!.username,
realName: state.user!.realName,
shopNo: state.user!.shopNo,
@@ -113,8 +144,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
debugPrint('[Auth] logout() called! stack: ${StackTrace.current}');
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
final token = state.user?.accessToken;
// 先作废在途的令牌持久化并立即本地登出,使任何并发的续期写入在落盘前被丢弃。
_sessionGen++;
state = const AuthState(initialized: true);
// 尽力通知后端撤销当前会话(离线/失败均忽略,不阻塞本地登出)
if (token != null && token.isNotEmpty) {
try {
await Dio(BaseOptions(
@@ -127,15 +161,17 @@ class AuthNotifier extends StateNotifier<AuthState> {
// ignore: 后端不可达或会话已失效都无所谓
}
}
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUsername);
await prefs.remove(_kRealName);
await prefs.remove(_kShopNo);
await prefs.remove(_kShopId);
await prefs.remove(_kRole);
state = const AuthState(initialized: true);
// 经队列串行清空 token,排在任何先前入队的续期写入之后,确保最终为已登出状态。
await _runStorage(() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_kAccessToken);
await prefs.remove(_kRefreshToken);
await prefs.remove(_kUsername);
await prefs.remove(_kRealName);
await prefs.remove(_kShopNo);
await prefs.remove(_kShopId);
await prefs.remove(_kRole);
});
}
}
@@ -146,6 +182,10 @@ final authStateProvider = StateNotifierProvider<AuthNotifier, AuthState>(
/// 会话结束提示语(被踢下线 / 会话失效)。登录页监听后弹出提示并清空。
final sessionEndedMessageProvider = StateProvider<String?>((ref) => null);
/// 全局轻提示(如写请求被后端 403 拒绝时的原因说明)。
/// app_shell 监听后 showSnackBar 并清空,避免各业务层各自处理。
final apiMessageProvider = StateProvider<String?>((ref) => null);
/// 当前登录用户是否为只读角色(role == 'readonly')。
/// 只读用户禁止任何写操作:UI 据此隐藏新增/编辑/删除/审核等按钮,
/// 后端亦有 middleware.ReadOnly() 兜底返回 403。