feat: 实现 Auth 全局状态机与 401 统一处理机制
- 新增 AuthSessionInvalidated 事件处理 token 失效场景 - ApiInterceptor 新增 authFailureCallback 单飞机制 - AuthBloc 区分 manual logout 与 auto expiry 语义 - 新增 startup recovery fallback 防止启动卡死 feat: 重构 Calendar DayWeek 视图事件布局引擎 - 新增 DayEventLayoutEngine 解耦事件计算与渲染 - 新增 DayTimelineMetrics 统一时间轴常量 - 新增 DayViewScale 支持捏合缩放 feat: 新增 Settings 页面共享 UI 组件 - 新增 BackTitlePageHeader 统一页面 header - 新增 DetailHeaderActionMenu 统一操作菜单 - 新增 DestructiveActionSheet 统一删除确认 - 新增 AppToggleSwitch 统一开关组件 feat: Chat UI Schema 支持导航操作 - 支持 navigation 类型 action 触发内部路由跳转 - 新增路径验证与参数处理 chore: 更新相关测试覆盖 auth 失效路径
This commit is contained in:
@@ -0,0 +1,102 @@
|
||||
# Auth 全局模块重写设计(跨端并存、同端互斥)
|
||||
|
||||
## 1. 目标
|
||||
|
||||
- 彻底消除 Auth 分裂状态:`token` 状态与 `AuthBloc` 状态必须单一真相源。
|
||||
- 会话策略升级为:
|
||||
- 同账号允许跨端并存:`mobile + web + desktop`
|
||||
- 同账号同端互斥:同端新登录会挤下线旧设备
|
||||
- 保证任何 401 链路在刷新失败后都能统一收敛为“未登录 + 清理本地 + 路由回到登录页”。
|
||||
- 消除设备差异导致的不一致行为(部分机型“假登录”或“卡死页”)。
|
||||
|
||||
## 2. 边界与约束
|
||||
|
||||
- 仅重写 `apps/**` 的 Auth 客户端架构与规则,不改后端协议语义。
|
||||
- 保持现有登录/注册 UI 路由入口,避免用户操作路径变化。
|
||||
- 认证属于高风险域,重写必须覆盖:
|
||||
- 启动恢复
|
||||
- 运行时 token 过期
|
||||
- 并发 401
|
||||
- 手动登出与自动过期登出的差异行为
|
||||
|
||||
## 3. 核心架构
|
||||
|
||||
### 3.1 单一真相源(Single Source of Truth)
|
||||
|
||||
- `AuthBloc` 成为唯一认证状态源。
|
||||
- `ApiInterceptor` 只负责协议级拦截与刷新,不直接做路由跳转。
|
||||
- 401 刷新失败时,`ApiInterceptor -> ApiClient callback -> AuthBloc(AuthSessionInvalidated)`。
|
||||
- 路由守卫只看 `AuthBloc` 状态,不再做隐式 token 判定。
|
||||
|
||||
### 3.2 会话状态机
|
||||
|
||||
- `AuthInitial`
|
||||
- `AuthLoading`(启动恢复/会话检查)
|
||||
- `AuthAuthenticated(user)`
|
||||
- `AuthUnauthenticated(reason)`
|
||||
|
||||
`reason` 取值:
|
||||
- `signedOut`
|
||||
- `expired`
|
||||
- `startupRecoveryFailed`
|
||||
|
||||
### 3.3 登出语义分离
|
||||
|
||||
- 手动登出:`deleteSession()`
|
||||
- 尝试调用后端注销
|
||||
- 最终清本地
|
||||
- 自动过期:`clearSessionLocalOnly()`
|
||||
- 仅清本地
|
||||
- 不调用后端注销接口
|
||||
|
||||
### 3.4 并发与抖动控制
|
||||
|
||||
- `ApiInterceptor` 继续使用 refresh singleflight。
|
||||
- 新增 auth failure singleflight:多并发 401 刷新失败,只触发一次全局会话失效事件。
|
||||
|
||||
### 3.5 设备差异治理
|
||||
|
||||
- 启动时 token 读取异常必须兜底:进入 `AuthUnauthenticated(startupRecoveryFailed)`,避免卡死在 Boot。
|
||||
- `FlutterSecureStorage` 显式平台配置:
|
||||
- Android 使用 `encryptedSharedPreferences`
|
||||
- iOS 指定 keychain accessibility(保证行为稳定)
|
||||
|
||||
## 4. 数据流
|
||||
|
||||
### 4.1 冷启动
|
||||
|
||||
1. `main` 触发 `AuthStarted`
|
||||
2. `AuthBloc` 读取 refresh token
|
||||
3. 有 refresh token -> 刷新会话 -> 成功进入 `AuthAuthenticated`
|
||||
4. 无 token 或异常 -> `AuthUnauthenticated(startupRecoveryFailed)`
|
||||
|
||||
### 4.2 运行时 API 请求
|
||||
|
||||
1. 请求携带 access token
|
||||
2. 401 -> 触发 refresh
|
||||
3. refresh 成功 -> 自动重试原请求
|
||||
4. refresh 失败 -> 触发一次全局 auth failure
|
||||
5. `AuthBloc` 收到 `AuthSessionInvalidated(expired)` -> 清本地 -> `AuthUnauthenticated(expired)`
|
||||
6. Router 根据状态回登录页
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
- `AuthBloc`:
|
||||
- 启动读取 refresh token 异常兜底
|
||||
- session invalidated 事件导致统一未登录
|
||||
- `ApiInterceptor`:
|
||||
- 并发 401 refresh 失败仅触发一次 auth failure
|
||||
- `AuthRepository`:
|
||||
- 手动登出 vs 自动过期清理行为差异
|
||||
|
||||
## 6. 迁移计划
|
||||
|
||||
- 先引入新事件/新回调/新状态原因,不改 UI 交互。
|
||||
- 再改路由守卫识别未登录原因。
|
||||
- 最后补齐 `apps/AGENTS.md` Auth 强约束,防止后续回归为“各处乱写”。
|
||||
|
||||
## 7. 风险与回滚
|
||||
|
||||
- 风险:回调链路接错导致频繁误登出。
|
||||
- 规避:并发 singleflight + 精确触发条件(仅 401 refresh 失败)。
|
||||
- 回滚:保留旧事件兼容层,出现异常可快速退回旧路由判定。
|
||||
@@ -0,0 +1,158 @@
|
||||
# Auth Global Rewrite Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** 将 Flutter 客户端 Auth 重构为全局单一状态源,解决 401 后会话不一致、页面卡死和设备行为分裂问题。
|
||||
|
||||
**Architecture:** 以 `AuthBloc` 为唯一认证真相源,`ApiInterceptor` 仅负责协议层刷新与失败信号上抛。401 刷新失败后通过统一回调触发 `AuthSessionInvalidated`,由 `AuthBloc` 执行本地会话失效与状态切换,Router 仅根据 Auth 状态跳转。
|
||||
|
||||
**Tech Stack:** Flutter, flutter_bloc, dio, flutter_secure_storage, flutter_test, mocktail, bloc_test
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 定义 Auth 失效语义与事件模型
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/auth/presentation/bloc/auth_event.dart`
|
||||
- Modify: `apps/lib/features/auth/presentation/bloc/auth_state.dart`
|
||||
- Test: `apps/test/features/auth/presentation/bloc/auth_bloc_test.dart`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
新增失败测试:收到 session invalidated 事件后,状态应进入 `AuthUnauthenticated(expired)`。
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart`
|
||||
Expected: FAIL(事件/状态原因不存在)
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
新增失效来源枚举、失效事件、未登录原因字段。
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
### Task 2: 重写 AuthBloc 启动恢复与失效收敛逻辑
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/features/auth/presentation/bloc/auth_bloc.dart`
|
||||
- Modify: `apps/lib/features/auth/data/auth_repository.dart`
|
||||
- Modify: `apps/lib/features/auth/data/auth_repository_impl.dart`
|
||||
- Test: `apps/test/features/auth/presentation/bloc/auth_bloc_test.dart`
|
||||
- Test: `apps/test/features/auth/data/auth_repository_test.dart`
|
||||
|
||||
**Step 1: Write failing tests**
|
||||
|
||||
- 启动读取 refresh token 抛异常 -> `AuthUnauthenticated(startupRecoveryFailed)`
|
||||
- 自动过期登出只清本地不调后端
|
||||
|
||||
**Step 2: Run tests to verify failure**
|
||||
|
||||
Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart test/features/auth/data/auth_repository_test.dart`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Implement minimal code**
|
||||
|
||||
- `AuthBloc._onStarted` 增加异常兜底
|
||||
- `AuthRepository` 新增 `clearSessionLocalOnly()`
|
||||
- `AuthBloc` 处理 `AuthSessionInvalidated`
|
||||
|
||||
**Step 4: Run tests to verify pass**
|
||||
|
||||
Run: `flutter test test/features/auth/presentation/bloc/auth_bloc_test.dart test/features/auth/data/auth_repository_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
### Task 3: 改造 ApiInterceptor / ApiClient 全局失效回调链
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/core/api/api_interceptor.dart`
|
||||
- Modify: `apps/lib/core/api/api_client.dart`
|
||||
- Modify: `apps/lib/core/di/injection.dart`
|
||||
- Test: `apps/test/core/api/api_interceptor_test.dart`
|
||||
|
||||
**Step 1: Write failing test**
|
||||
|
||||
并发 401 + refresh 失败时,`onAuthFailure` 仅触发一次。
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `flutter test test/core/api/api_interceptor_test.dart`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Implement minimal code**
|
||||
|
||||
- interceptor 新增 auth failure singleflight
|
||||
- api client 新增 `setAuthFailureCallback`
|
||||
- DI 中将回调绑定到 `AuthBloc(AuthSessionInvalidated)`
|
||||
|
||||
**Step 4: Run test to verify pass**
|
||||
|
||||
Run: `flutter test test/core/api/api_interceptor_test.dart`
|
||||
Expected: PASS
|
||||
|
||||
### Task 4: 平台安全存储配置与稳定性增强
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/core/di/injection.dart`
|
||||
|
||||
**Step 1: Add platform options**
|
||||
|
||||
为 `FlutterSecureStorage` 显式设置 Android/iOS 选项,减少机型差异。
|
||||
|
||||
**Step 2: Run targeted tests/analyze**
|
||||
|
||||
Run: `flutter analyze lib/core/di/injection.dart`
|
||||
Expected: PASS
|
||||
|
||||
### Task 5: 路由与使用点适配
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/lib/core/router/app_router.dart`
|
||||
- Modify: `apps/lib/features/settings/ui/screens/account_screen.dart`
|
||||
- Modify: `apps/lib/features/settings/ui/screens/change_password_screen.dart`
|
||||
|
||||
**Step 1: Update route/auth checks**
|
||||
|
||||
兼容 `AuthUnauthenticated(reason)` 新结构,保持原有登录流 UX。
|
||||
|
||||
**Step 2: Run focused tests**
|
||||
|
||||
Run: `flutter test test/features/auth`
|
||||
Expected: PASS
|
||||
|
||||
### Task 6: 增加 Auth 全局强约束
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/AGENTS.md`
|
||||
|
||||
**Step 1: Add mandatory auth rules**
|
||||
|
||||
新增“Auth 全局模块(MUST)”章节:
|
||||
- 401 只允许走统一失效回调链
|
||||
- 禁止 feature 私自清 token/私自跳登录
|
||||
- Auth 状态只能由全局模块写入
|
||||
|
||||
**Step 2: Verify docs consistency**
|
||||
|
||||
Run: `git diff -- apps/AGENTS.md`
|
||||
Expected: 仅新增约束,不改现有视觉/UI强规则
|
||||
|
||||
### Task 7: 全量验证
|
||||
|
||||
**Files:**
|
||||
- Modify if needed after fixes
|
||||
|
||||
**Step 1: Run test suites**
|
||||
|
||||
Run: `flutter test test/core/api/api_interceptor_test.dart test/features/auth`
|
||||
|
||||
**Step 2: Run analyze on touched auth scope**
|
||||
|
||||
Run: `flutter analyze lib/core/api lib/features/auth lib/core/router/app_router.dart lib/core/di/injection.dart`
|
||||
|
||||
**Step 3: Report residual risks**
|
||||
|
||||
输出剩余风险、可观测性建议、生产灰度建议。
|
||||
@@ -119,3 +119,29 @@ tool 结果不再走 UI 编译链路:`TOOL_CALL_RESULT` 提供 `tool_call_args
|
||||
2. 再接入 `/events` 处理后续增量
|
||||
3. 以 `runId` + `messageId/toolCallId` 做去重与合并
|
||||
4. 统一消费 `ui_schema`
|
||||
|
||||
---
|
||||
|
||||
## 7) Navigation Action 数据流(ui_schema.actions)
|
||||
|
||||
### 7.1 后端生成
|
||||
|
||||
- runtime 使用 `ui_hints.action.type = navigation` 产出导航动作。
|
||||
- 编译后在 `ui_schema` 中保持 `action.type = navigation`、`action.path`、`action.params`。
|
||||
- 路由来源应受后端静态路由目录约束:
|
||||
- `backend/src/core/config/static/route/frontend_routes.yaml`
|
||||
|
||||
### 7.2 前端消费(统一解析规则)
|
||||
|
||||
- 对 `type = navigation`,前端仅走一条解析路径:
|
||||
1. 读取 `path` 作为内部路由目标;
|
||||
2. 将 `params` 仅视为 query 参数(不用于 path 模板替换);
|
||||
3. 执行 GoRouter 跳转(建议 `context.go(...)`)。
|
||||
- `path` 必须是已落地页面路由,且应是已实参化路径(如 `/todo/123`,而非 `/todo/:id`)。
|
||||
|
||||
### 7.3 约束建议
|
||||
|
||||
- 为了让前端只保留一种解析逻辑,推荐强约束:
|
||||
- `path` 只接受内部路由;
|
||||
- `params` 只接受标量值(string/number/boolean);
|
||||
- 禁止在 `params` 里放嵌套对象数组。
|
||||
|
||||
@@ -282,6 +282,15 @@ interface NavigateAction {
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
// Navigation Contract (current implementation constraint)
|
||||
// 1) path MUST be an internal app route and MUST be fully materialized
|
||||
// (e.g. '/todo/123', not '/todo/:id').
|
||||
// 2) path MUST NOT include query string or fragment.
|
||||
// 3) params, when provided, is treated as query params only.
|
||||
// 4) params values MUST be scalar (string | number | boolean).
|
||||
// 5) Backend MUST generate path from route catalog
|
||||
// `backend/src/core/config/static/route/frontend_routes.yaml`.
|
||||
|
||||
// URL action
|
||||
interface UrlAction {
|
||||
type: 'url';
|
||||
|
||||
Reference in New Issue
Block a user