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
+107 -20
View File
@@ -3,8 +3,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../auth/auth_state.dart';
import '../config/app_config.dart';
import '../config/license_copy.dart';
import '../errors/error_reporter.dart';
import '../../providers/connectivity_provider.dart';
import '../../providers/license_provider.dart';
import 'retry_interceptor.dart';
/// Public Dio instance for unauthenticated calls (login / refresh)
@@ -30,8 +32,10 @@ final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(
token: user?.accessToken,
refreshToken: user?.refreshToken,
onTokenRefreshed: (newToken) {
ref.read(authStateProvider.notifier).updateAccessToken(newToken);
onTokensRefreshed: (accessToken, refreshToken) {
ref
.read(authStateProvider.notifier)
.updateAccessToken(accessToken, refreshToken);
},
onAuthFailed: (reason) {
if (ref.read(authStateProvider).isLoggedIn) {
@@ -44,6 +48,27 @@ final apiClientProvider = Provider<ApiClient>((ref) {
onConnectionError: () {
ref.read(connectivityProvider.notifier).forceCheck();
},
onForbidden: (body) {
final map = body is Map ? body : const <String, dynamic>{};
final code = map['code'];
final phase = map['phase'];
// 只读角色写被拒:直接提示(无需刷新授权)。
if (code == 'READONLY_USER') {
ref.read(apiMessageProvider.notifier).state =
LicenseCopy.readonlyUserToast;
return;
}
// 授权过期(readonly/locked)写被拒:刷新授权令横幅/按钮即时降级,并提示。
if (phase == 'readonly' || phase == 'locked') {
ref.read(licenseProvider.notifier).refresh().then((_) {
final lic = ref.read(licenseProvider).valueOrNull;
ref.read(apiMessageProvider.notifier).state = lic != null
? LicenseCopy.writeBlockedToast(lic)
: '授权已过期,无法执行写操作';
});
}
// 其它 403(管理员/超管权限不足等)不在此统一处理,交由调用方。
},
);
ref.onDispose(client.dispose);
return client;
@@ -53,13 +78,28 @@ class ApiClient {
late final Dio _dio;
bool _disposed = false;
/// 当前 refresh token。续期会轮换它,故必须可变并随响应更新——
/// provider 不会因 token 变化重建本实例(只监听 isLoggedIn)。
String? _refreshToken;
/// 单飞:并发 401 共享同一次刷新。否则每个 401 各发一次 /auth/refresh
/// 各自轮换 jti,后到的请求重放已被取代的 jti → 触发盗用检测吊销整条会话。
Future<String?>? _refreshing;
void Function(String accessToken, String refreshToken)? _onTokensRefreshed;
void Function(String? reason)? _onAuthFailed;
ApiClient({
String? token,
String? refreshToken,
void Function(String newToken)? onTokenRefreshed,
void Function(String accessToken, String refreshToken)? onTokensRefreshed,
void Function(String? reason)? onAuthFailed,
void Function()? onConnectionError,
void Function(dynamic body)? onForbidden,
}) {
_refreshToken = refreshToken;
_onTokensRefreshed = onTokensRefreshed;
_onAuthFailed = onAuthFailed;
_dio = Dio(BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 8),
@@ -98,29 +138,37 @@ class ApiClient {
);
}
if (e.response?.statusCode == 401 && (refreshToken ?? '').isNotEmpty) {
// 403:写权限被拒(只读角色 / 授权过期)。交给上层刷新授权 + 提示。
// 后端契约:body 带 code=READONLY_USER 或 phase=readonly|locked。
if (e.response?.statusCode == 403) {
if (!_disposed) onForbidden?.call(e.response?.data);
return handler.next(e);
}
// 账号被停用/删除:中间件返回 401 + code=USER_DISABLED。这是终态,
// 续期也救不回(refresh 会因 is_active=0 再次失败),直接强制重新登录。
if (e.response?.statusCode == 401 &&
e.response?.data is Map &&
e.response?.data['code'] == 'USER_DISABLED') {
if (!_disposed) {
_onAuthFailed?.call('您的账号已被停用或删除,请重新登录或联系管理员');
}
return handler.next(e);
}
if (e.response?.statusCode == 401 && (_refreshToken ?? '').isNotEmpty) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, trying refresh...');
final newToken = await _refreshAccessToken();
if (newToken == null) {
return handler.next(e);
}
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': refreshToken,
});
final newToken = resp.data['data']['access_token'] as String;
_dio.options.headers['Authorization'] = 'Bearer $newToken';
if (!_disposed) onTokenRefreshed?.call(newToken);
final opts = e.requestOptions;
opts.headers['Authorization'] = 'Bearer $newToken';
final retryResp = await _dio.fetch(opts);
return handler.resolve(retryResp);
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) onAuthFailed?.call(reason);
} catch (retryErr) {
return handler.next(retryErr is DioException ? retryErr : e);
}
} else if (e.response?.statusCode == 401) {
debugPrint('[ApiClient] got 401 on ${e.requestOptions.path}, no refresh token');
@@ -131,6 +179,45 @@ class ApiClient {
);
}
/// 单飞刷新:并发 401 复用同一次 /auth/refresh,避免多次轮换 jti 触发盗用检测。
/// 成功返回新的 access token;失败(含会话已撤销)返回 null 并通知 onAuthFailed。
Future<String?> _refreshAccessToken() {
return _refreshing ??= _doRefresh().whenComplete(() => _refreshing = null);
}
Future<String?> _doRefresh() async {
try {
final resp = await _publicDio.post('/auth/refresh', data: {
'refresh_token': _refreshToken,
});
final data = resp.data['data'] as Map;
final newAccess = data['access_token'] as String;
// 后端轮换 refresh token,必须采纳新值(无则沿用旧值,兼容老后端)。
final newRefresh = (data['refresh_token'] as String?) ?? _refreshToken;
_refreshToken = newRefresh;
_dio.options.headers['Authorization'] = 'Bearer $newAccess';
if (!_disposed && newRefresh != null) {
_onTokensRefreshed?.call(newAccess, newRefresh);
}
return newAccess;
} catch (refreshErr) {
debugPrint('[ApiClient] refresh failed: $refreshErr');
// 区分「被踢/会话失效」与普通登录过期,便于登录页给出明确提示
String? reason;
if (refreshErr is DioException &&
refreshErr.response?.data is Map &&
refreshErr.response?.data['code'] == 'SESSION_REVOKED') {
reason = '您的账号已在其他设备登录,或登录已失效,请重新登录';
}
if (!_disposed) _onAuthFailed?.call(reason);
return null;
}
}
/// 仅供测试:访问内部 Dio,以便挂载 mock adapter 走真实拦截器(401/403 等)。
@visibleForTesting
Dio get dioForTest => _dio;
/// 取消所有进行中的请求,标记实例为已废弃
void dispose() {
_disposed = true;