Files
flutter-template/docs/architecture.md
T
SkyJourneyandClaude Sonnet 4.6 f194ac7d3f docs: 新增 flutter-guide 小白指南,深化 architecture 架构文档
- 新建 docs/flutter-guide.md(约 1600 行)
  - 第一章 Dart 核心:空安全、async/await、sealed class、代码生成
  - 第二章 Flutter 框架:三棵树、Widget 类型、生命周期、布局系统
    各节附 Android / iOS 原生等价对照
  - 第三章 12 个生态库逐一解析(Freezed 先于 Riverpod 的依赖顺序)
    含「平台生态对比总览」表 + 每库「📱 原生对比」callout
  - 第四章 项目核心组件深读,含「参见 3.x」交叉引用
  - 第五章 新增 Feature 标准流程与常见坑

- 增强 docs/architecture.md
  - 新增「架构设计原则」首节(DIP / SSOT / SoC),各附 Android / iOS 对比表
  - 全文 10 张 Mermaid 深度图(flowchart / sequenceDiagram / classDiagram)
  - 6 处「📱 原生对比」callout(OkHttp / Alamofire / Room / CoreData / actor 等)
  - 修复 Mermaid 语法:\n → <br/>,flowchart 中非法 <|..| → -.->,
    HTML 实体 &lt;T&gt; → «T»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 15:56:50 +08:00

721 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构概览
> **阅读提示**:每节先有 ASCII 快速参考图(终端/纯文本友好),后有 Mermaid 深度图(GitHub / GitLab / Obsidian 渲染)。
---
## 架构设计原则
> 在阅读具体架构图前,先理解这三条原则——它们解释了"为什么这样设计",而非"是什么"。
### 依赖倒置(DIP
**核心思想**:高层模块(Presentation)依赖抽象(`abstract interface Repository`),低层模块(Data)实现抽象。依赖箭头方向与控制流方向相反。
```
Presentation ──→ Domain(接口)←── Data(实现)
[稳定,不变,纯 Dart]
```
实际效果:
- `AuthNotifier` 持有 `AuthRepository`(接口),不知道 `AuthRepositoryImpl` 的存在
- 替换网络层(Dio → GraphQL)只改 `Data` 层,`Domain``Presentation` 零修改
- 单元测试直接 Mock 接口,无需真实网络或数据库
| 平台 | 对应实现 |
|------|---------|
| Android | MVVM + RepositoryGoogle AAC);ViewModel 持有 Repository 接口;Hilt 注入实现类 |
| iOS | Protocol-oriented 编程;ViewModel 持有 ProtocolSwiftUI `@EnvironmentObject` 或 Swinject 注入实现 |
### 单一真源(SSOT
每份数据只有一个权威来源,其他位置只能读取或镜像:
| 数据 | 唯一真源 | 镜像位置 |
|------|---------|---------|
| accessToken | SecureStorageKeychain / EncryptedPrefs| 无(HTTP Header 是临时注入)|
| refreshToken | SecureStorage | UsersTable.refreshToken(只读可观测)|
| 认证状态 | `authStatusProvider`ValueNotifier| GoRouter redirect 函数 |
| 主题配色 | SharedPreferences `theme_scheme` | ThemeNotifier.state(内存缓存)|
| 错误记录 | Drift `error_logs` 表 | Sentry 云端(异步上报)|
| 平台 | 对应实现 |
|------|---------|
| Android | `StateFlow` / `LiveData` 作为 UI 状态真源;Repository 作为数据真源;`DataStore` 持久化配置 |
| iOS | `@Published` / `@State` 作为 UI 真源;`@AppStorage` / `UserDefaults` 持久化;Core Data 管理本地结构化数据 |
### 关注点分离(SoC
三层之间严格禁止跨层直接依赖:
| 层 | 知道什么 | 不知道什么 |
|---|---------|----------|
| Domain | 业务规则、实体结构、接口契约 | JSON 字段名、HTTP 状态码、Widget |
| Data | JSON 反序列化、网络/DB 调用方式 | Widget 渲染、路由逻辑 |
| Presentation | UI 状态、用户交互 | `snake_case` 字段、SQL 语法 |
**违反后的典型故障**Presentation 直接调用 `UsersDao`(跨越 Data 层)→ 缓存逻辑散落各处 → 离线状态不一致 → 用户看到"幽灵"数据。
| 平台 | 对应实现 |
|------|---------|
| Android | Jetpack Compose View 只消费 `StateFlow`,不直接调用 RetrofitViewModel 协调 Repository |
| iOS | SwiftUI View 只消费 `@Published`ViewModel 协调 ServiceService 封装 URLSession / Core Data |
---
## 整体分层
```
┌─────────────────────────────────────────────────┐
│ Presentation 层 │
│ ConsumerWidget / ConsumerStatefulWidget │
│ ref.watch(provider) → UI 响应式更新 │
│ ref.listen(provider) → 副作用(Toast / 导航) │
└──────────────┬──────────────────────────────────┘
│ @riverpod Notifier
┌──────────────▼──────────────────────────────────┐
│ Domain 层 │
│ abstract interface Repository │
│ @freezed Entity(纯 Dart,无框架依赖) │
└──────────────┬──────────────────────────────────┘
│ @riverpod impl
┌──────────────▼──────────────────────────────────┐
│ Data 层 │
│ @riverpod RemoteDatasourceDio
│ @riverpod RepositoryImpl(组合 Dio + Drift
│ @freezed Model+ fromJson / toEntity
└──────────────┬──────────────────────────────────┘
┌───────┴───────┐
▼ ▼
┌─────────┐ ┌──────────┐
│ Dio 网络│ │ Drift DB│
│ 7拦截器 │ │ SQLCipher│
└─────────┘ └──────────┘
```
```mermaid
flowchart TD
subgraph P["Presentation 层"]
PW["ConsumerWidget<br/>ConsumerStatefulWidget"]
PN["@riverpod Notifier<br/>state: sealed XxxState"]
PW -->|"ref.watch(notifier)"| PN
PN -->|"state 变化 → rebuild"| PW
end
subgraph D["Domain 层(纯 Dart,无框架)"]
DR["abstract interface<br/>XxxRepository"]
DE["@freezed XxxEntity<br/>纯值对象,可跨端复用"]
end
subgraph DA["Data 层"]
DM["@freezed XxxModel<br/>+ fromJson + toEntity()"]
DS["@riverpod RemoteDatasource<br/>只调用 Dio,返回 Model"]
DI["@riverpod RepositoryImpl<br/>实现接口,Model → Entity"]
DI --> DS
DI --> DM
end
PN -->|"调用接口方法"| DR
DI -.->|"实现"| DR
DS -->|"parseEnvelope"| DM
DM -->|"toEntity()"| DE
PN -->|"持有 Entity"| DE
subgraph Infra["基础设施"]
DIO["Dio<br/>7 层拦截器"]
DRIFT["Drift<br/>SQLCipher AES-256"]
end
DS -->|"HTTP"| DIO
DI -->|"DAO"| DRIFT
style P fill:#D6EAF8
style D fill:#D5F5E3
style DA fill:#FCF3CF
style Infra fill:#F5CBA7
```
**设计决策**
- **Domain 层零依赖**`UserEntity``AuthRepository` 只依赖 Dart SDK,不 import `dio`/`drift`/`flutter_riverpod`。这让 Domain 层可在服务端 Dart CLI 复用,也便于单元测试(无需 Mock 框架)。
- **`toEntity()` 桥接**`Model.toEntity()` 是跨越 Data/Domain 边界的唯一通道,防止 JSON 字段名称(`snake_case`)泄露到 Domain 层。
- **Notifier 不持有 Model**Notifier 的 `state` 类型是 `Entity``sealed State<Entity>`,不是 `Model`,确保 UI 层与序列化格式解耦。
> **📱 原生对比**
> - **Android**`ViewModel`Presentation)→ `Repository`Domain 接口)→ `Room DAO / Retrofit Service`Data 实现)是 Google AAC 推荐分层。`Hilt` 负责依赖注入,对应 Riverpod 的 Provider 角色;`Flow<UiState>` 对应 `AsyncNotifier` 的 `state`。
> - **iOS**`View`SwiftUI)→ `@ObservableObject ViewModel`Presentation)→ `Protocol Repository`Domain)→ `URLSession / Core Data`Data)。`@EnvironmentObject` / `@StateObject` 注入 ViewModel,对应 `ConsumerWidget` + `ref.watch`Combine `Publisher` 对应 Dart `Stream`。
---
## 认证流程(Auth Flow
```
App 启动
CrashReporter.preInit() # 准备崩溃写入路径
CrashReporter.consumePending() # 读上次崩溃文件(同步)
SentrySetup.init() # 包裹 runAppDSN 空则直接 runApp
AppDatabase.open() # AES-256 解锁 SQLite
ProviderScope(注入 DB + pendingCrash
CrashReporter.installHooks() # 接管 FlutterError + Zone 异常
authStatusProvider._bootstrap() # 异步读 SecureStorage.getToken()
│ ├── token 非空 → markLoggedIn()
│ └── token 为空 → markLoggedOut()
GoRouter.redirect() # 监听 authStatus.listenableValueNotifier
├── loggedIn == null → 不跳转(等待 bootstrap 完成)
├── loggedIn == false → push /login
└── loggedIn == true → push /home(若当前在 /login
```
```mermaid
sequenceDiagram
participant M as main()
participant CR as CrashReporter
participant Sentry as SentrySetup
participant DB as AppDatabase
participant PS as ProviderScope
participant AS as authStatusProvider
participant SS as SecureStorage
participant GR as GoRouter
M->>CR: preInit()(准备崩溃目录)
CR-->>M: pendingCrash(上次崩溃,可为 null
M->>Sentry: init(appRunner)
Sentry->>CR: installHooks()(接管 FlutterError + Zone
Sentry->>DB: open()(探测→PRAGMA key→后台 Isolate
DB-->>Sentry: AppDatabase 实例
Sentry->>PS: runApp(ProviderScope)
PS->>AS: 创建(keepAlive
AS->>SS: getToken()(异步)
alt token 非空
SS-->>AS: "xxx_token"
AS->>GR: markLoggedIn() → ValueNotifier(true)
GR->>GR: redirect() → 当前在/login → push /home
else token 为空 / 读取异常
SS-->>AS: null
AS->>GR: markLoggedOut() → ValueNotifier(false)
GR->>GR: redirect() → 非公开路径 → push /login
end
Note over AS,GR: bootstrap 期间 auth.value==null<br/>redirect 返回 null 不动(避免闪烁)
```
**设计决策**
- **三态 `bool?`**`null` 作为"未知"态,防止启动时 `redirect` 在 Bootstrap 完成前误跳页面(白屏/闪烁问题)。
- **`ValueNotifier` 而非 Riverpod Provider**GoRouter 的 `refreshListenable` 接受 `Listenable``ValueNotifier` 实现了它),用 Riverpod Provider 则需要额外适配层,`ValueNotifier` 更直接。
- **`keepAlive: true`**:认证状态必须全局唯一且永不销毁,避免路由切换时 Provider 被回收导致状态丢失。
> **📱 原生对比**
> - **Android**Navigation Component + `AuthManager`(单例)控制目的地访问权限;`NavController.navigate()` 配合 `LoginGraph` 实现跳转;也可用 `Hilt` 注入的全局 `SessionManager` 监听登录状态。Jetpack Compose 下 `NavHost` 的 `route guard lambda` 类似 GoRouter 的 `redirect`。
> - **iOS**SwiftUI 通过 `@EnvironmentObject AuthState` + `.sheet`/`.fullScreenCover` 控制页面展示;UIKit 下通过 `AppCoordinator` 切换 `rootViewController`NavigationStackiOS 16+)的 `NavigationPath` 类似 GoRouter 的声明式路由栈。
---
## 网络请求链(7 拦截器顺序固定)
```
Dio.request()
▼ [1] CertPinningInterceptor
│ ├── PINNED_FINGERPRINTS 为空 → 跳过(dev 环境)
│ └── 指纹不匹配 → throw NetworkFailure(badCertificate)
▼ [2] AuthInterceptor
│ ├── extra['skip_auth'] == true → 跳过(登录 / 刷新 Token 接口)
│ └── 读 SecureStorage.getToken() + getTokenName() → 注入 Header
▼ [3] TokenRefreshInterceptor(仅 onError
│ ├── status != 401 → 透传
│ ├── retCode 不在 token 类码 → 标记 _auth_not_refreshable → 透传
│ ├── 已在刷新(_refreshing != null)→ await 同一 Completer(防并发)
│ └── 刷新成功 → 更新 token → 重放原请求
│ └── 刷新失败 → 标记 _auth_refresh_failed → 透传
▼ [4] RetryInterceptor(仅 onError
│ └── 408/429/5xx 且未超过 3 次 → 指数退避重试(200ms→400ms→800ms
▼ [5] ErrorInterceptor(仅 onError
│ └── DioException → ExceptionMapper.fromDio() → sealed Failure
│ 写入 err.error(供下游拦截器识别)
▼ [6] AuthLogoutInterceptor(仅 onError
│ └── err.error is AuthFailure(unauthorized|refreshFailed)
│ → SecureStorage.clearAll() + authStatus.markLoggedOut()
│ → GoRouter 自动跳 /login
▼ [7] LogInterceptor
└── Env.enableDevPanel 为 true → TalkerDioLogger 输出
否则 → 无操作(Release 包零日志)
```
```mermaid
flowchart TD
REQ(["📤 Dio.request()<br/>业务代码发起请求"])
subgraph Chain["拦截器链(onRequest 从上到下,onError 从下到上)"]
direction TB
I1["[1] CertPinningInterceptor<br/>🔐 TLS 指纹校验<br/>PINNED_FINGERPRINTS 为空 → 跳过<br/>不匹配 → NetworkFailure(badCertificate)"]
I2["[2] AuthInterceptor<br/>🔑 Token 注入<br/>skip_auth=true → 跳过<br/>否则读 SecureStorage → header[tokenName]=token"]
I3["[3] TokenRefreshInterceptoronError<br/>🔄 401 自动刷新<br/>业务码分类 → 不消耗非 token 类错误<br/>Completer 互斥防并发重复消耗"]
I4["[4] RetryInterceptoronError<br/>🔁 指数退避重试<br/>408/429/5xx → 最多3次<br/>200ms→400ms→800ms"]
I5["[5] ErrorInterceptoronError<br/>🗺️ 异常映射<br/>DioException → sealed Failure<br/>写入 err.error 供下游读取"]
I6["[6] AuthLogoutInterceptoronError<br/>🚪 强制登出<br/>err.error is AuthFailure → clearAll()<br/>→ markLoggedOut() → GoRouter 跳 /login"]
I7["[7] LogInterceptor<br/>📋 请求日志<br/>Dev/Internal → TalkerDioLogger<br/>Release → noop(零日志泄漏)"]
end
NET(["🌐 服务器"])
RESP(["📥 Response<br/>成功响应返回业务代码"])
REQ --> I1 --> I2 --> NET
NET --> RESP
NET -- 错误 --> I7 --> I6 --> I5 --> I4 --> I3
style I1 fill:#E74C3C,color:#fff
style I2 fill:#3498DB,color:#fff
style I3 fill:#9B59B6,color:#fff
style I4 fill:#F39C12,color:#fff
style I5 fill:#1ABC9C,color:#fff
style I6 fill:#E67E22,color:#fff
style I7 fill:#7F8C8D,color:#fff
```
**设计决策**
- **顺序即语义**`ErrorInterceptor`[5])必须在 `AuthLogoutInterceptor`[6])之前,因为 AuthLogout 通过 `e.error is AuthFailure` 判断类型,而 `e.error` 只有经过 ErrorInterceptor 映射后才是 `AuthFailure`。调换顺序会导致自动登出失效。
- **`Options.extra` 信号机制**:拦截器间通过 `RequestOptions.extra` Map 传递信号(`skip_auth``_token_refreshed``_auth_not_refreshable`),避免拦截器之间直接引用,保持松耦合。
- **onError 逆序**Dio 的 onError 按**逆序**执行拦截器(与 onRequest 方向相反),这是 Dio 框架设计,本项目拦截器排列已考虑此特性。
> **📱 原生对比**
> - **Android**OkHttp 的 `Interceptor` 接口(`chain.proceed(request)`)构成完全相同的链式拦截模型。`addInterceptor()`Application Interceptor,全程可见)vs `addNetworkInterceptor()`Network Interceptor,仅在实际网络层),对应 Dio 的 `onRequest`/`onError` 触发阶段。Retrofit 本身没有拦截器,依赖 OkHttp 层。
> - **iOS**Alamofire 的 `RequestInterceptor``adapt` 注入 token + `retry` 处理 401+ `EventMonitor`(日志)组合等价于本项目的 7 拦截器。URLSession 原生只有 `URLSessionDelegate`,功能有限,企业级 iOS 项目几乎都依赖 Alamofire 的拦截层。
---
## 错误传播链
```
网络/DB 异常
▼ ExceptionMapper.fromUnknown(e, st)
▼ sealed Failure(一律通过此分类)
│ ├── NetworkFailure → 超时 / 无网络 / 证书错误
│ ├── AuthFailure → 未授权 / token 过期 / 加密失败
│ ├── ServerFailure → HTTP 4xx/5xx / 业务码非 00000
│ ├── CacheFailure → Drift / IO 异常
│ └── UnknownFailure → 兜底
├─→ UI 层:failure.message(用户可读文案,Notifier.state = error(msg)
├─→ ErrorLogger.log(failure)(写入 Drift error_logs 表,fire-and-forget
└─→ context.showError(ref, e, st)Toast 展示 + 自动写日志)
```
```mermaid
flowchart LR
EX["异常来源<br/>DioException<br/>DriftException<br/>RsaEncryptionException<br/>其他 Exception"]
EM["ExceptionMapper<br/>.fromUnknown(e, st)"]
subgraph SF["sealed Failure(编译期强制穷举)"]
NF["NetworkFailure<br/>kind: timeout/noNetwork<br/>/badCertificate/cancelled/unknown"]
AF["AuthFailure<br/>kind: unauthorized/forbidden<br/>/refreshFailed/cryptoFailed"]
SVF["ServerFailure<br/>code: A0400/A0500...<br/>statusCode: 4xx/5xx"]
CF["CacheFailure<br/>Drift/SQLCipher/IO"]
UF["UnknownFailure<br/>兜底(不应频繁出现)"]
end
UI["UI 层<br/>failure.userMessage<br/>→ AppToast/ErrorView"]
LOG["ErrorLoggerfire-and-forget<br/>failure.message(技术细节)<br/>→ Drift error_logs"]
SEN["Sentry<br/>failure.toString()<br/>→ 云端错误聚合"]
EX --> EM
EM --> NF & AF & SVF & CF & UF
NF & AF & SVF & CF & UF --> UI
NF & AF & SVF & CF & UF --> LOG
UF --> SEN
style NF fill:#E74C3C,color:#fff
style AF fill:#9B59B6,color:#fff
style SVF fill:#E67E22,color:#fff
style CF fill:#F39C12,color:#fff
style UF fill:#7F8C8D,color:#fff
```
**设计决策**
- **`sealed` 强制穷举**`switch (failure)` 不加 `default` 分支,当新增 Failure 子类时,所有 switch 点在编译期报错,不会有遗漏处理的情况。
- **`userMessage``message` 分离**`message` 含技术细节(适合 Sentry/Talker),`userMessage` 是用户可读文案(适合 Toast)。`ServerFailure` 对 5xx 额外屏蔽后端堆栈,防止信息泄露。
- **`ErrorLogger` fire-and-forget**`unawaited(ErrorLogger.log(failure))`,不阻塞主流程,本地存储的错误日志作为 Sentry 的补充(离线场景)。
> **📱 原生对比**
> - **Android**Kotlin `sealed class Result<out T>` 或标准库 `kotlin.Result<T>` 承担相同角色。`when(result)` 对 sealed class 强制穷举,与 Dart `switch(failure)` 语义完全等价。`NetworkResult` / `ApiResponse` 等封装类是 Android 社区常见模式(如 [sandwich](https://github.com/skydoves/sandwich) 库)。
> - **iOS**Swift `enum NetworkError: Error { case timeout; case unauthorized; ... }` 或 `Result<T, MyError>` 泛型承担相同角色。Swift `switch` 对枚举同样强制穷举(无 `default` 时编译报错)。Swift 的关联值(associated values)对应 Dart sealed class 的子类字段(如 `NetworkFailure.kind`)。
---
## Token 刷新互斥(Completer 模式)
解决并发场景下多个 401 同时触发 refresh 消耗 RefreshToken 的问题:
```
请求 A ──401──▶ _refreshing == null
│ 创建 Completer,赋值 _refreshing
│ 调用 _runRefresh()
│ │
请求 B ──401──▶ │ _refreshing != null
│ await _refreshing.future(阻塞等待)
│ │
请求 C ──401──▶ │ _refreshing != null
│ await _refreshing.future(阻塞等待)
│ │
│ refresh 完成
│ _refreshing = null ← 先清空,再 complete
│ completer.complete(true)
│ │
└─────────┴──▶ B、C 收到结果,用新 token 重放请求
```
```mermaid
sequenceDiagram
participant A as 请求 A(首个 401
participant B as 请求 B(并发 401
participant C as 请求 C(并发 401
participant TRI as TokenRefreshInterceptor
participant SS as SecureStorage
participant SRV as 服务端 /auth/refresh
A->>TRI: onError(401)
Note over TRI: _refreshing == null
TRI->>TRI: new Completer<bool>()<br/>_refreshing = completer
B->>TRI: onError(401)
Note over TRI: _refreshing != null
TRI->>TRI: await _refreshing.future(挂起等待)
C->>TRI: onError(401)
Note over TRI: _refreshing != null
TRI->>TRI: await _refreshing.future(挂起等待)
TRI->>SS: getRefreshToken()
SS-->>TRI: "refresh_xyz"
TRI->>SRV: POST /auth/refresh<br/>{refresh_token: "refresh_xyz"}
SRV-->>TRI: {code:"00000", data:{access_token:"new_token"}}
TRI->>SS: setToken("new_token")
Note over TRI: 先清空 _refreshing,再 complete
TRI->>TRI: _refreshing = null
TRI->>TRI: completer.complete(true)
Note over B,C: B 和 C 从 future 拿到 true
B->>TRI: 用新 token 重放原请求 ✅
C->>TRI: 用新 token 重放原请求 ✅
A->>TRI: 用新 token 重放原请求 ✅
```
**为什么先 `_refreshing = null` 再 `completer.complete(true)`**
如果先 `complete` 再清空 `_refreshing`,B/C 醒来后可能有新的 401 进入,看到 `_refreshing != null`(旧 completer 已完成),`await` 会立即返回旧的 `true`,跳过新一轮刷新。顺序颠倒虽然概率低,但属于 race condition。先清空确保新 401 进入时看到 `null`,走正常刷新路径。
> **📱 原生对比**
> - **Android**OkHttp `Authenticator` 接口处理 401,但官方不处理并发互斥——需要配合 `@Synchronized` 或 Kotlin Coroutines `Mutex``kotlinx.coroutines`)手动实现。`mutex.withLock { ... }` 对应 `Completer` 的作用。也可用 `SharedFlow``replay=0`+ `flatMapLatest` 组合实现 "多个订阅者共享同一次刷新结果"的语义。
> - **iOS**Swift `actor` 天然提供 Actor Isolation(串行访问保障),将 refresh 逻辑放入 `actor TokenRefresher` 中,并发调用自动排队,无需手动锁。Alamofire 的 `RequestInterceptor.retry()` 内部已集成基于 `DispatchSemaphore` 的互斥机制。
---
## 数据库表结构(schemaVersion 1
```
UsersTableuserId 主键)
├── userId TEXT NOT NULL PK
├── username TEXT nullable
├── realName TEXT nullable
├── phone TEXT nullable
├── avatar TEXT nullable
├── gender INT nullable
├── age INT nullable
├── refreshToken TEXT nullable ← 镜像,SoT 在 SecureStorage
├── email TEXT nullable
├── birthday DATETIME nullable
├── employeeNo TEXT nullable
├── company TEXT nullable
├── department TEXT nullable
└── [SyncColumns: syncStatus, localUpdatedAt, serverUpdatedAt, conflictPayload]
ErrorLogsTable(自增 id
├── id INT PK AUTOINCREMENT
├── kind TEXT NOT NULL ← 'network'|'server'|'auth'|'cache'|'unknown'
├── message TEXT NOT NULL ← 技术细节(Sentry / Talker 用)
├── displayMessage TEXT NOT NULL ← 用户可读文案
├── code TEXT nullable ← 业务错误码(ServerFailure
├── statusCode INT nullable ← HTTP 状态码
├── occurredAt DATETIME NOT NULL
├── reported BOOL DEFAULT false
├── deviceModel TEXT nullable
├── osVersion TEXT nullable
├── appVersion TEXT nullable
└── appBuild TEXT nullable
```
```mermaid
flowchart TD
subgraph DB["AppDatabaseSQLCipher AES-256"]
subgraph UT["UsersTable"]
U1["userId TEXT PK"]
U2["username TEXT?"]
U3["phone TEXT?"]
U4["avatar TEXT?"]
U5["refreshToken TEXT? ← 只读镜像"]
U6["[SyncColumns]<br/>syncStatus / localUpdatedAt<br/>serverUpdatedAt / conflictPayload"]
end
subgraph EL["ErrorLogsTable"]
E1["id INT PK AUTOINCREMENT"]
E2["kind TEXT<br/>'network'|'server'|'auth'<br/>'cache'|'unknown'"]
E3["message TEXT(技术细节)"]
E4["displayMessage TEXT(用户文案)"]
E5["occurredAt DATETIME"]
E6["reported BOOL DEFAULT false"]
E7["deviceModel / osVersion<br/>appVersion / appBuild"]
end
end
SS["SecureStorage<br/>Keychain / EncryptedPrefs<br/>Token 真源"]
EL_Reporter["ErrorLogger<br/>写入 error_logs<br/>清理 > 100 条旧记录"]
Batch["批量上报<br/>error_report_datasource<br/>标记 reported=true"]
SS -->|"token/refreshToken 唯一真源"| UT
UT -->|"refreshToken 镜像(可观测性)"| SS
EL_Reporter --> EL
EL --> Batch
style DB fill:#F0F3F4
style SS fill:#E8F8F5
```
> **token 不存 DB**accessToken 的唯一真源是 SecureStorageKeychain / EncryptedSharedPrefs)。
> UsersTable.refreshToken 仅作可观测性镜像,TokenRefreshInterceptor 始终从 SecureStorage 读取。
**设计决策**
- **Token 不存 DB 的原因**SQLCipher 密钥存在 SecureStorage,若 Token 也存 DB,则 Token 的安全性等同于 DB 密钥的安全性——循环依赖。SecureStorage 是操作系统级安全保障(Keychain Enclave / Android KeyStore TEE),比应用层数据库更可信。
- **refreshToken DB 镜像**:只用于调试和可观测性(如查看用户登录状态),不参与认证逻辑,仅在 `AuthRepositoryImpl._persistUser()` 时顺带写入。
> **📱 原生对比**
> - **Android**RoomJetpack)是官方 ORM 层,`@Database`/`@Dao`/`@Entity` 注解对应 Drift 的 `@DriftDatabase`/`@DriftAccessor`/表定义。Room 默认禁止主线程 DB 操作(强制 Coroutines + Dispatcher.IO),对应 Drift 的后台 Isolate。加密方案:SQLCipher for Androidzetetic/android-database-sqlcipher)接入方式与本项目高度一致;或使用 [Room + SQLCipher](https://www.zetetic.net/sqlcipher/sqlcipher-for-android/) 官方文档。
> - **iOS**Core Data + `NSPersistentContainer` 是官方 ORM 方案;`newBackgroundContext()` 对应 Drift 后台 Isolate(禁止主线程写操作)。轻量替代:GRDB.swift / SQLite.swift。加密:SQLCipher for iOSzetetic)或 Realm 内置加密配置(`Realm.Configuration.encryptionKey`)。`NSManagedObject` 对应 Drift 生成的 Data Class`@DataClassName`)。
---
## API Envelope 格式
脚手架假设后端使用统一 JSON 响应包装(sa-token 风格):
```json
{
"code": "00000", // 成功码(ResponseCode.success = "00000"
"msg": "success",
"data": { ... } // 业务数据(可为 null
}
```
`parseEnvelope<T>(body, fromJsonT)` 解析此结构;`apiResp.unwrapVoid()` 用于无数据响应。
```mermaid
flowchart LR
R["HTTP Response<br/>{code, msg, data}"]
PE["parseEnvelope«T»<br/>检查 code == '00000'<br/>否则 throw ServerFailure<br/>是则 fromJsonT(data)"]
UV["unwrapVoid()<br/>只检查 code<br/>不解析 data<br/>用于 DELETE/logout 等"]
T["T(业务对象)"]
V["void(操作成功)"]
R --> PE --> T
R --> UV --> V
```
token 类错误码(触发 TokenRefreshInterceptor):
- `A0401`unauthorized
- `TOKEN_EXPIRED` / `TOKEN_INVALID`
---
## 主题系统
```
AppColorScheme (enum) — 5 套色板
├── blue (默认)
├── red
├── green
├── purple
└── teal
ThemeNotifier (@Riverpod, keepAlive)
└── 读/写 SharedPreferences('theme_scheme' + 'theme_mode')
└── app.dart 的 MaterialApp.router 监听 themeProvider + themeModeProvider
```
```mermaid
flowchart LR
SP["SharedPreferences<br/>'theme_scheme': 'blue'<br/>'theme_mode': 'system'"]
TN["ThemeNotifier<br/>@Riverpod keepAlive<br/>state: AppColorScheme"]
TMN["ThemeModeNotifier<br/>@Riverpod keepAlive<br/>state: ThemeMode"]
AT_L["AppTheme.light(scheme)<br/>→ ThemeDatalight"]
AT_D["AppTheme.dark(scheme)<br/>→ ThemeDatadark"]
APP["MaterialApp.router<br/>theme: light<br/>darkTheme: dark<br/>themeMode: ThemeMode.system"]
SP -->|"启动读取"| TN
SP -->|"启动读取"| TMN
TN -->|"setScheme() → 写入"| SP
TMN -->|"setMode() → 写入"| SP
TN --> AT_L & AT_D
AT_L & AT_D --> APP
TMN -->|"系统/手动切换"| APP
```
**5 套色板切换**(运行时热切换,无需重启):
```dart
// 任意页面
ref.read(themeNotifierProvider.notifier).setScheme(AppColorScheme.red);
// → SharedPreferences 持久化
// → themeProvider 通知
// → App 重建(只有 App widget,成本极低)
// → MaterialApp.router 使用新 ThemeData
```
---
## 认证模块 Clean Architecture 类图
```mermaid
classDiagram
class AuthRepository {
<<interface>>
+loginWithPhone(phone, code) Future~UserEntity~
+loginWithPassword(phone, pwd) Future~UserEntity~
+logout() Future~void~
+getCurrentUser() Future~UserEntity?~
+sendSmsCode(phone) Future~String?~
+fetchUserProfile() Future~UserEntity~
}
class AuthRepositoryImpl {
-AuthRemoteDatasource _remote
-UsersDao _dao
-SecureStorageService _storage
+loginWithPhone(phone, code) Future~UserEntity~
+loginWithPassword(phone, pwd) Future~UserEntity~
-_persistUser(UserModel) Future~void~
}
class AuthRemoteDatasource {
-Dio _dio
+loginWithPhone(phone, code) Future~UserModel~
+loginWithPassword(phone, pwd) Future~UserModel~
+sendSmsCode(phone) Future~String?~
+logout() Future~void~
+getUserProfile() Future~UserModel~
}
class AuthNotifier {
<<Riverpod Notifier>>
-int _generation
+state: AuthState
+loginWithPhone(phone, code) Future~void~
+loginWithPassword(phone, pwd) Future~void~
+logout() Future~void~
+sendSmsCode(phone) Future~void~
+refreshUserInfo() Future~void~
}
class AuthStatusController {
-ValueNotifier~bool?~ _notifier
+value: bool?
+listenable: ValueNotifier
+markLoggedIn() void
+markLoggedOut() void
}
class UserModel {
<<Freezed + JSON>>
+userId: String
+username: String?
+token: String?
+tokenName: String?
+refreshToken: String?
+toEntity() UserEntity
}
class UserEntity {
<<Freezed, Pure Dart>>
+userId: String
+username: String?
+phone: String?
+avatar: String?
}
class AuthState {
<<sealed Freezed>>
initial()
loading()
authenticated(UserEntity)
unauthenticated()
error(String)
}
AuthRepository <|.. AuthRepositoryImpl : implements
AuthRepositoryImpl --> AuthRemoteDatasource : uses
AuthRepositoryImpl --> UserModel : receives
UserModel --> UserEntity : toEntity()
AuthNotifier --> AuthRepository : calls
AuthNotifier --> AuthState : state
AuthNotifier --> AuthStatusController : markLoggedIn/Out
AuthStatusController --> GoRouter : refreshListenable
```
---
## 数据库启动与密钥派生流程
```mermaid
flowchart TD
A["AppDatabase.open()"] --> B{"Platform.isAndroid?"}
B -- Yes --> C["applyWorkaroundToOpenSqlCipherOnOldAndroidVersions()<br/>确保 Java 先通过 System.loadLibrary<br/>加载 libsqlcipher.so 到进程内存"]
B -- No --> D
C --> D["_sqlCipherIsolateSetup()(主 Isolate<br/>注册 SQLCipher open override<br/>→ 探测时 sqlite3.open 走 SQLCipher"]
D --> E["DbKeyProvider.key<br/>→ SecureStorage.read('db_key')<br/>→ 有则直接用<br/>→ 无则 Random.secure().nextBytes(32) + base64Url + 写入"]
E --> F{"app.db 文件已存在?"}
F -- Yes --> G["_canOpenWithKey(path, escapedKey)<br/>主 Isolate 同步探测<br/>PRAGMA key = '...' + PRAGMA user_version"]
G -- 失败 --> H["file.deleteSync()<br/>密钥不匹配/文件损坏 → 删除重建<br/>(开发阶段无不可恢复的用户数据)"]
G -- 成功 --> I
F -- No --> I
H --> I["NativeDatabase.createInBackground()<br/>isolateSetup: _sqlCipherIsolateSetup ← 顶层函数!<br/>setup: db.execute(PRAGMA key = '...')"]
I --> J["后台 Isolate 注册 SQLCipher<br/>PRAGMA key 解锁<br/>所有 Drift 查询透明 AES-256"]
J --> K["AppDatabase 就绪<br/>注入 ProviderScope"]
style C fill:#F39C12,color:#fff
style H fill:#E74C3C,color:#fff
style I fill:#27AE60,color:#fff
```
**关键约束**
- `isolateSetup` 必须传**顶层函数**引用(非 lambda/闭包):Drift 通过 `Isolate.spawn` 将其传递给后台 Isolate,经过 `SendPort.send` 序列化。闭包可能捕获外部变量,跨 Isolate 传递时会静默失败(无报错,加密不生效)。
- PRAGMA key 使用**字符串格式**`'key'`),而非 raw hex 格式(`x'hexkey'`):当前 `RandomKeyStrategy` 产出 base64Url(32 bytes) ≈ 43 字符,不符合 raw key 要求的 64 hex 字符。切换格式会破坏已加密 DB。