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
+159
View File
@@ -0,0 +1,159 @@
import 'package:flutter/material.dart';
import '../../models/license.dart';
import '../theme/app_theme.dart';
/// 授权阶段文案 + 阈值的集中配置。
///
/// 所有授权相关提示(顶部横幅、到期弹窗、设置页状态、底部状态栏、过期说明)
/// 统一在此维护,组件层不再硬编码文案与天数阈值。
///
/// 阈值须与后端 `backend/internal/middleware/license_guard.go` 保持一致:
/// 宽限期 0[graceDays] 天(仍可写)→ 只读 [graceDays][readonlyDays] 天 → 锁定 [readonlyDays] 天+。
class LicenseCopy {
LicenseCopy._();
/// 宽限期上限(天):过期 ≤ 此值仍可正常写入。
static const int graceDays = 7;
/// 只读期上限(天):过期 ≤ 此值进入只读;超过则锁定登录。
static const int readonlyDays = 15;
/// 顶部横幅文案(仅 grace/readonly/locked 显示;normal 返回空串)。
static String banner(LicenseInfo lic) {
final d = lic.daysExpired;
switch (lic.phase) {
case 'grace':
return '授权已过期 $d 天,请及时续费,否则将影响您的正常使用';
case 'readonly':
return '授权已过期 $d 天,已进入只读模式,暂时无法新增或修改数据;'
'过期满 $readonlyDays 天后将彻底锁定,请尽快续费';
case 'locked':
return '授权已过期 $d 天并已锁定,所有功能已停用,请立即续费或激活新授权码';
default:
return '';
}
}
/// 设置页「授权状态」行文案。
static String statusText(LicenseInfo lic) {
switch (lic.phase) {
case 'grace':
return '宽限期 · 已过期 ${lic.daysExpired}';
case 'readonly':
return '只读模式 · 已过期 ${lic.daysExpired}';
case 'locked':
return '已锁定 · 已过期 ${lic.daysExpired}';
default:
return lic.expiresAt == null
? '正常(永久授权)'
: '正常(剩余 ${lic.daysRemaining ?? 0} 天)';
}
}
/// 底部状态栏紧凑文案。[expiryDate] 为已格式化的到期日(yyyy-MM-dd)。
static String statusBar(LicenseInfo lic, String expiryDate) {
switch (lic.phase) {
case 'grace':
return '授权宽限期 · 已过期 ${lic.daysExpired}';
case 'readonly':
return '只读模式 · 已过期 ${lic.daysExpired}';
case 'locked':
return '已锁定 · 已过期 ${lic.daysExpired}';
default:
return lic.expiresAt == null ? '永久授权' : '授权正常 · 到期 $expiryDate';
}
}
/// 到期提醒弹窗的(标题, 正文)。
static (String title, String body) dialog(LicenseInfo lic) {
final d = lic.daysExpired;
switch (lic.phase) {
case 'locked':
return (
'授权已锁定',
'您的授权已过期 $d 天(超过 $readonlyDays 天),所有功能已停用。\n'
'请前往「设置 → 授权」激活新的授权码,或联系客服续费。',
);
case 'readonly':
return (
'授权已过期 · 只读模式',
'您的授权已过期 $d 天,系统进入只读模式,无法执行任何写操作。\n'
'过期满 $readonlyDays 天后将彻底锁定登录,请尽快续费。',
);
default: // grace
return (
'授权已过期',
'您的授权已过期 $d 天,目前仍可正常使用(宽限期 $graceDays 天)。\n'
'过期超过 $graceDays 天将进入只读模式,请尽快续费以免影响使用。',
);
}
}
/// 只读账号尝试写操作被后端拒绝(403 code=READONLY_USER)时的提示。
static const String readonlyUserToast = '当前为只读账号,仅可查看数据,无法新增或修改';
/// 写按钮被授权过期禁用时,点击弹出的提示文案。
static String writeBlockedToast(LicenseInfo lic) {
switch (lic.phase) {
case 'locked':
return '授权已过期 ${lic.daysExpired} 天并已锁定,无法操作,请前往「设置 → 授权」续费或激活';
default: // readonly
return '授权已过期 ${lic.daysExpired} 天,已进入只读模式,续费后可继续操作(设置 → 授权)';
}
}
// ---- 阶段视觉(颜色 / 图标)集中映射 ----
// 顶部横幅、底部状态条、设置页状态三处此前各自硬编码 phase→色,readonly 取色还分叉
// (横幅 0xFFB71C1C vs 状态条 0xFFEF5350)。这里按「角色」给出单一来源:
// - [bannerColor]:顶部横幅**实底背景**(白字承其上),仅 grace/readonly/locked 显示。
// - [phaseColor] :前景强调色(底部状态条图标+文字、设置页状态文字共用一套)。
// - [phaseIcon] :状态图标(状态条用)。
/// 顶部横幅实底背景色(深色调,承载白字)。normal 不显示横幅,返回透明。
static Color bannerColor(String phase) {
switch (phase) {
case 'locked':
return AppTheme.danger;
case 'readonly':
return const Color(0xFFB71C1C); // 深红,强调即将彻底锁定
case 'grace':
return const Color(0xFFE65100); // 深橙
default:
return Colors.transparent;
}
}
/// 前景强调色:normal=正常绿,grace=警告橙,readonly/locked=危险红。
static Color phaseColor(String phase) {
switch (phase) {
case 'grace':
return AppTheme.warning500;
case 'readonly':
case 'locked':
return AppTheme.danger;
default:
return AppTheme.success;
}
}
/// 状态图标:normal=已验证,grace=警告,readonly/locked=错误。
static IconData phaseIcon(String phase) {
switch (phase) {
case 'grace':
return Icons.warning_amber_rounded;
case 'readonly':
case 'locked':
return Icons.error_outline;
default:
return Icons.verified_user_outlined;
}
}
/// 过期降级说明(设置页固定展示),随阈值自动联动。
static List<String> degradationNotes() => [
'过期 $graceDays 天内(宽限期):仍可正常使用,请尽快续费',
'过期 $graceDays$readonlyDays 天(只读期):仅能查看数据,无法新增或修改',
'过期满 $readonlyDays 天:系统锁定,无法登录,需续费或激活新授权码后恢复',
];
}