主要变更: 1. 新增桌面端托盘图标支持深色/浅色主题切换 2. 重构应用锁、动画配置、小组件导航服务职责 3. 修复Riverpod初始化断言、防重复点击、工作台模式残留选中态问题 4. 优化诗词服务、阅读进度、搜索结果空状态体验 5. 完善macOS打包配置与错误静默处理逻辑 6. 新增快速卡片多语言适配与动画退出队列管理
205 lines
6.8 KiB
Dart
205 lines
6.8 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — Token管理服务
|
||
/// 创建时间: 2026-04-28
|
||
/// 更新时间: 2026-06-22
|
||
/// 作用: Token检测/刷新/自动续期/登录状态守护
|
||
/// 上次更新: 新增 silentRefreshIfExpiringSoon 静默刷新方法,
|
||
/// 缓存 expires_at 供拦截器在请求前判断是否即将过期
|
||
/// ============================================================
|
||
|
||
import 'dart:async';
|
||
|
||
import '../../network/api_client.dart';
|
||
import '../../storage/secure_storage.dart';
|
||
import '../../utils/logger.dart';
|
||
|
||
class TokenService {
|
||
TokenService._();
|
||
|
||
static final ApiClient _api = ApiClient.instance;
|
||
|
||
static Timer? _checkTimer;
|
||
static const _checkInterval = Duration(minutes: 15);
|
||
static const _refreshThresholdSeconds = 3600;
|
||
|
||
static bool _isRefreshing = false;
|
||
|
||
// ============================================================
|
||
// 静默刷新支持(供 ApiInterceptor 在请求前调用)
|
||
// ============================================================
|
||
|
||
/// 缓存的 Token 过期时间(由 checkToken/refreshToken 更新)
|
||
/// 首次启动时为 null,等定时检测或首次 checkToken 后填充
|
||
static DateTime? _cachedExpiresAt;
|
||
|
||
/// 静默刷新阈值:提前 5 分钟(300秒)刷新
|
||
static const int _silentRefreshThresholdSeconds = 300;
|
||
|
||
static void startPeriodicCheck() {
|
||
_checkTimer?.cancel();
|
||
_checkTimer = Timer.periodic(_checkInterval, (_) async {
|
||
await checkAndRefresh();
|
||
});
|
||
Log.i('Token定时检测已启动,间隔: ${_checkInterval.inMinutes}分钟');
|
||
}
|
||
|
||
static void stopPeriodicCheck() {
|
||
_checkTimer?.cancel();
|
||
_checkTimer = null;
|
||
Log.i('Token定时检测已停止');
|
||
}
|
||
|
||
static Future<TokenCheckResult> checkToken() async {
|
||
try {
|
||
final token = await SecureStorage.authToken;
|
||
if (token == null || token.isEmpty) {
|
||
return const TokenCheckResult(valid: false, reason: '无Token');
|
||
}
|
||
|
||
final response = await _api.post<Map<String, dynamic>>(
|
||
'/api/token/check',
|
||
);
|
||
final data = response.data as Map<String, dynamic>;
|
||
final code = (data['code'] as num?)?.toInt() ?? 0;
|
||
|
||
if (code == 1) {
|
||
final tokenData = data['data'] as Map<String, dynamic>? ?? {};
|
||
final expiresIn = (tokenData['expires_in'] as num?)?.toInt() ?? 0;
|
||
// 缓存过期时间,供拦截器静默刷新判断使用
|
||
if (expiresIn > 0) {
|
||
_cachedExpiresAt = DateTime.now().add(Duration(seconds: expiresIn));
|
||
}
|
||
return TokenCheckResult(
|
||
valid: true,
|
||
expiresIn: expiresIn,
|
||
reason: '有效',
|
||
);
|
||
}
|
||
final reason = data['msg'] as String? ?? '无效';
|
||
return TokenCheckResult(valid: false, reason: reason);
|
||
} catch (e) {
|
||
Log.e('Token检测失败', e);
|
||
return const TokenCheckResult(valid: false, reason: '检测异常');
|
||
}
|
||
}
|
||
|
||
static Future<bool> refreshToken() async {
|
||
if (_isRefreshing) return false;
|
||
_isRefreshing = true;
|
||
|
||
try {
|
||
final response = await _api.post<Map<String, dynamic>>(
|
||
'/api/token/refresh',
|
||
);
|
||
final data = response.data as Map<String, dynamic>;
|
||
final code = (data['code'] as num?)?.toInt() ?? 0;
|
||
|
||
if (code == 1) {
|
||
final tokenData = data['data'] as Map<String, dynamic>?;
|
||
final newToken = tokenData?['token'] as String?;
|
||
if (newToken != null && newToken.isNotEmpty) {
|
||
await SecureStorage.setAuthToken(newToken);
|
||
// 尝试读取并缓存过期时间(若响应中包含 expires_in)
|
||
final expiresIn = (tokenData?['expires_in'] as num?)?.toInt() ?? 0;
|
||
if (expiresIn > 0) {
|
||
_cachedExpiresAt = DateTime.now().add(Duration(seconds: expiresIn));
|
||
}
|
||
Log.i('Token刷新成功');
|
||
return true;
|
||
}
|
||
}
|
||
Log.w('Token刷新失败: ${data['msg']}');
|
||
return false;
|
||
} catch (e) {
|
||
Log.e('Token刷新异常', e);
|
||
return false;
|
||
} finally {
|
||
_isRefreshing = false;
|
||
}
|
||
}
|
||
|
||
static Future<bool> checkAndRefresh() async {
|
||
final result = await checkToken();
|
||
if (!result.valid) {
|
||
Log.w('Token无效: ${result.reason}');
|
||
return false;
|
||
}
|
||
|
||
if (result.expiresIn > 0 && result.expiresIn < _refreshThresholdSeconds) {
|
||
Log.i('Token即将过期(${result.expiresIn}秒),自动续期');
|
||
return await refreshToken();
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// ============================================================
|
||
// 静默刷新(供 ApiInterceptor 在请求前调用)
|
||
// ============================================================
|
||
|
||
/// 静默刷新即将过期的 Token
|
||
///
|
||
/// 由 [ApiInterceptor] 在每次请求前调用(fire-and-forget,不阻塞当前请求)。
|
||
/// 基于缓存的 [_cachedExpiresAt] 判断是否即将过期(提前 5 分钟),
|
||
/// 若即将过期则调用 [refreshToken] 静默刷新。
|
||
///
|
||
/// 并发安全:[refreshToken] 内部有 [_isRefreshing] 锁,
|
||
/// 多个请求同时触发时只会刷新一次,其余直接返回 false。
|
||
///
|
||
/// 刷新失败不阻塞:返回 false,让原 Token 继续使用直到 401,
|
||
/// 由拦截器的 401 处理逻辑引导用户重新登录。
|
||
///
|
||
/// 注意:首次启动时 [_cachedExpiresAt] 为 null(等定时检测填充),
|
||
/// 此方法会直接返回 false,不会触发刷新。
|
||
static Future<bool> silentRefreshIfExpiringSoon() async {
|
||
// 没有缓存的过期时间,无法判断,跳过
|
||
if (_cachedExpiresAt == null) return false;
|
||
|
||
final now = DateTime.now();
|
||
final remaining = _cachedExpiresAt!.difference(now).inSeconds;
|
||
|
||
// 剩余时间大于阈值,无需刷新
|
||
if (remaining > _silentRefreshThresholdSeconds) return false;
|
||
|
||
Log.i(
|
||
'Token即将过期(剩余${remaining}秒),触发静默刷新',
|
||
null,
|
||
null,
|
||
LogCategory.auth,
|
||
);
|
||
final success = await refreshToken();
|
||
if (success) {
|
||
// 刷新成功后,如果缓存未更新(响应中没有 expires_in),
|
||
// 设置一个默认过期时间(2小时),避免后续请求重复触发刷新。
|
||
// 定时检测(checkAndRefresh)会随后更新为准确值。
|
||
if (_cachedExpiresAt == null ||
|
||
_cachedExpiresAt!.isBefore(
|
||
now.add(const Duration(seconds: _silentRefreshThresholdSeconds)),
|
||
)) {
|
||
_cachedExpiresAt = now.add(const Duration(hours: 2));
|
||
Log.i(
|
||
'Token刷新成功,使用默认过期时间(2小时)',
|
||
null,
|
||
null,
|
||
LogCategory.auth,
|
||
);
|
||
}
|
||
}
|
||
return success;
|
||
}
|
||
|
||
static bool get isRunning => _checkTimer != null;
|
||
}
|
||
|
||
class TokenCheckResult {
|
||
const TokenCheckResult({
|
||
required this.valid,
|
||
this.expiresIn = 0,
|
||
this.reason = '',
|
||
});
|
||
|
||
final bool valid;
|
||
final int expiresIn;
|
||
final String reason;
|
||
}
|