diff --git a/docs/architecture.md b/docs/architecture.md
index 0040d24..cfefe64 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -1,5 +1,67 @@
# 架构概览
+> **阅读提示**:每节先有 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 + Repository(Google AAC);ViewModel 持有 Repository 接口;Hilt 注入实现类 |
+| iOS | Protocol-oriented 编程;ViewModel 持有 Protocol;SwiftUI `@EnvironmentObject` 或 Swinject 注入实现 |
+
+### 单一真源(SSOT)
+
+每份数据只有一个权威来源,其他位置只能读取或镜像:
+
+| 数据 | 唯一真源 | 镜像位置 |
+|------|---------|---------|
+| accessToken | SecureStorage(Keychain / 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`,不直接调用 Retrofit;ViewModel 协调 Repository |
+| iOS | SwiftUI View 只消费 `@Published`;ViewModel 协调 Service;Service 封装 URLSession / Core Data |
+
---
## 整体分层
@@ -33,6 +95,57 @@
└─────────┘ └──────────┘
```
+```mermaid
+flowchart TD
+ subgraph P["Presentation 层"]
+ PW["ConsumerWidget
ConsumerStatefulWidget"]
+ PN["@riverpod Notifier
state: sealed XxxState"]
+ PW -->|"ref.watch(notifier)"| PN
+ PN -->|"state 变化 → rebuild"| PW
+ end
+
+ subgraph D["Domain 层(纯 Dart,无框架)"]
+ DR["abstract interface
XxxRepository"]
+ DE["@freezed XxxEntity
纯值对象,可跨端复用"]
+ end
+
+ subgraph DA["Data 层"]
+ DM["@freezed XxxModel
+ fromJson + toEntity()"]
+ DS["@riverpod RemoteDatasource
只调用 Dio,返回 Model"]
+ DI["@riverpod RepositoryImpl
实现接口,Model → Entity"]
+ DI --> DS
+ DI --> DM
+ end
+
+ PN -->|"调用接口方法"| DR
+ DI -.->|"实现"| DR
+ DS -->|"parseEnvelope"| DM
+ DM -->|"toEntity()"| DE
+ PN -->|"持有 Entity"| DE
+
+ subgraph Infra["基础设施"]
+ DIO["Dio
7 层拦截器"]
+ DRIFT["Drift
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`,不是 `Model`,确保 UI 层与序列化格式解耦。
+
+> **📱 原生对比**
+> - **Android**:`ViewModel`(Presentation)→ `Repository`(Domain 接口)→ `Room DAO / Retrofit Service`(Data 实现)是 Google AAC 推荐分层。`Hilt` 负责依赖注入,对应 Riverpod 的 Provider 角色;`Flow` 对应 `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)
@@ -70,6 +183,49 @@ GoRouter.redirect() # 监听 authStatus.listenable(ValueNotifier
└── 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
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`;NavigationStack(iOS 16+)的 `NavigationPath` 类似 GoRouter 的声明式路由栈。
+
---
## 网络请求链(7 拦截器顺序固定)
@@ -109,6 +265,46 @@ Dio.request()
否则 → 无操作(Release 包零日志)
```
+```mermaid
+flowchart TD
+ REQ(["📤 Dio.request()
业务代码发起请求"])
+
+ subgraph Chain["拦截器链(onRequest 从上到下,onError 从下到上)"]
+ direction TB
+ I1["[1] CertPinningInterceptor
🔐 TLS 指纹校验
PINNED_FINGERPRINTS 为空 → 跳过
不匹配 → NetworkFailure(badCertificate)"]
+ I2["[2] AuthInterceptor
🔑 Token 注入
skip_auth=true → 跳过
否则读 SecureStorage → header[tokenName]=token"]
+ I3["[3] TokenRefreshInterceptor(onError)
🔄 401 自动刷新
业务码分类 → 不消耗非 token 类错误
Completer 互斥防并发重复消耗"]
+ I4["[4] RetryInterceptor(onError)
🔁 指数退避重试
408/429/5xx → 最多3次
200ms→400ms→800ms"]
+ I5["[5] ErrorInterceptor(onError)
🗺️ 异常映射
DioException → sealed Failure
写入 err.error 供下游读取"]
+ I6["[6] AuthLogoutInterceptor(onError)
🚪 强制登出
err.error is AuthFailure → clearAll()
→ markLoggedOut() → GoRouter 跳 /login"]
+ I7["[7] LogInterceptor
📋 请求日志
Dev/Internal → TalkerDioLogger
Release → noop(零日志泄漏)"]
+ end
+
+ NET(["🌐 服务器"])
+ RESP(["📥 Response
成功响应返回业务代码"])
+
+ 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 的拦截层。
+
---
## 错误传播链
@@ -130,6 +326,47 @@ Dio.request()
└─→ context.showError(ref, e, st)(Toast 展示 + 自动写日志)
```
+```mermaid
+flowchart LR
+ EX["异常来源
DioException
DriftException
RsaEncryptionException
其他 Exception"]
+
+ EM["ExceptionMapper
.fromUnknown(e, st)"]
+
+ subgraph SF["sealed Failure(编译期强制穷举)"]
+ NF["NetworkFailure
kind: timeout/noNetwork
/badCertificate/cancelled/unknown"]
+ AF["AuthFailure
kind: unauthorized/forbidden
/refreshFailed/cryptoFailed"]
+ SVF["ServerFailure
code: A0400/A0500...
statusCode: 4xx/5xx"]
+ CF["CacheFailure
Drift/SQLCipher/IO"]
+ UF["UnknownFailure
兜底(不应频繁出现)"]
+ end
+
+ UI["UI 层
failure.userMessage
→ AppToast/ErrorView"]
+ LOG["ErrorLogger(fire-and-forget)
failure.message(技术细节)
→ Drift error_logs"]
+ SEN["Sentry
failure.toString()
→ 云端错误聚合"]
+
+ 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` 或标准库 `kotlin.Result` 承担相同角色。`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` 泛型承担相同角色。Swift `switch` 对枚举同样强制穷举(无 `default` 时编译报错)。Swift 的关联值(associated values)对应 Dart sealed class 的子类字段(如 `NetworkFailure.kind`)。
+
---
## Token 刷新互斥(Completer 模式)
@@ -154,6 +391,51 @@ Dio.request()
└─────────┴──▶ 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()
_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
{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)
@@ -190,9 +472,53 @@ ErrorLogsTable(自增 id)
└── appBuild TEXT nullable
```
+```mermaid
+flowchart TD
+ subgraph DB["AppDatabase(SQLCipher AES-256)"]
+ subgraph UT["UsersTable"]
+ U1["userId TEXT PK"]
+ U2["username TEXT?"]
+ U3["phone TEXT?"]
+ U4["avatar TEXT?"]
+ U5["refreshToken TEXT? ← 只读镜像"]
+ U6["[SyncColumns]
syncStatus / localUpdatedAt
serverUpdatedAt / conflictPayload"]
+ end
+
+ subgraph EL["ErrorLogsTable"]
+ E1["id INT PK AUTOINCREMENT"]
+ E2["kind TEXT
'network'|'server'|'auth'
'cache'|'unknown'"]
+ E3["message TEXT(技术细节)"]
+ E4["displayMessage TEXT(用户文案)"]
+ E5["occurredAt DATETIME"]
+ E6["reported BOOL DEFAULT false"]
+ E7["deviceModel / osVersion
appVersion / appBuild"]
+ end
+ end
+
+ SS["SecureStorage
(Keychain / EncryptedPrefs)
Token 真源"]
+ EL_Reporter["ErrorLogger
写入 error_logs
清理 > 100 条旧记录"]
+ Batch["批量上报
error_report_datasource
标记 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 的唯一真源是 SecureStorage(Keychain / EncryptedSharedPrefs)。
> UsersTable.refreshToken 仅作可观测性镜像,TokenRefreshInterceptor 始终从 SecureStorage 读取。
+**设计决策**:
+- **Token 不存 DB 的原因**:SQLCipher 密钥存在 SecureStorage,若 Token 也存 DB,则 Token 的安全性等同于 DB 密钥的安全性——循环依赖。SecureStorage 是操作系统级安全保障(Keychain Enclave / Android KeyStore TEE),比应用层数据库更可信。
+- **refreshToken DB 镜像**:只用于调试和可观测性(如查看用户登录状态),不参与认证逻辑,仅在 `AuthRepositoryImpl._persistUser()` 时顺带写入。
+
+> **📱 原生对比**
+> - **Android**:Room(Jetpack)是官方 ORM 层,`@Database`/`@Dao`/`@Entity` 注解对应 Drift 的 `@DriftDatabase`/`@DriftAccessor`/表定义。Room 默认禁止主线程 DB 操作(强制 Coroutines + Dispatcher.IO),对应 Drift 的后台 Isolate。加密方案:SQLCipher for Android(zetetic/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 iOS(zetetic)或 Realm 内置加密配置(`Realm.Configuration.encryptionKey`)。`NSManagedObject` 对应 Drift 生成的 Data Class(`@DataClassName`)。
+
---
## API Envelope 格式
@@ -209,6 +535,18 @@ ErrorLogsTable(自增 id)
`parseEnvelope(body, fromJsonT)` 解析此结构;`apiResp.unwrapVoid()` 用于无数据响应。
+```mermaid
+flowchart LR
+ R["HTTP Response
{code, msg, data}"]
+ PE["parseEnvelope«T»
检查 code == '00000'
否则 throw ServerFailure
是则 fromJsonT(data)"]
+ UV["unwrapVoid()
只检查 code
不解析 data
用于 DELETE/logout 等"]
+ T["T(业务对象)"]
+ V["void(操作成功)"]
+
+ R --> PE --> T
+ R --> UV --> V
+```
+
token 类错误码(触发 TokenRefreshInterceptor):
- `A0401`(unauthorized)
- `TOKEN_EXPIRED` / `TOKEN_INVALID`
@@ -229,3 +567,154 @@ ThemeNotifier (@Riverpod, keepAlive)
└── 读/写 SharedPreferences('theme_scheme' + 'theme_mode')
└── app.dart 的 MaterialApp.router 监听 themeProvider + themeModeProvider
```
+
+```mermaid
+flowchart LR
+ SP["SharedPreferences
'theme_scheme': 'blue'
'theme_mode': 'system'"]
+
+ TN["ThemeNotifier
@Riverpod keepAlive
state: AppColorScheme"]
+ TMN["ThemeModeNotifier
@Riverpod keepAlive
state: ThemeMode"]
+
+ AT_L["AppTheme.light(scheme)
→ ThemeData(light)"]
+ AT_D["AppTheme.dark(scheme)
→ ThemeData(dark)"]
+
+ APP["MaterialApp.router
theme: light
darkTheme: dark
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 {
+ <>
+ +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 {
+ <>
+ -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 {
+ <>
+ +userId: String
+ +username: String?
+ +token: String?
+ +tokenName: String?
+ +refreshToken: String?
+ +toEntity() UserEntity
+ }
+
+ class UserEntity {
+ <>
+ +userId: String
+ +username: String?
+ +phone: String?
+ +avatar: String?
+ }
+
+ class AuthState {
+ <>
+ 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()
确保 Java 先通过 System.loadLibrary
加载 libsqlcipher.so 到进程内存"]
+ B -- No --> D
+ C --> D["_sqlCipherIsolateSetup()(主 Isolate)
注册 SQLCipher open override
→ 探测时 sqlite3.open 走 SQLCipher"]
+ D --> E["DbKeyProvider.key
→ SecureStorage.read('db_key')
→ 有则直接用
→ 无则 Random.secure().nextBytes(32) + base64Url + 写入"]
+ E --> F{"app.db 文件已存在?"}
+ F -- Yes --> G["_canOpenWithKey(path, escapedKey)
主 Isolate 同步探测
PRAGMA key = '...' + PRAGMA user_version"]
+ G -- 失败 --> H["file.deleteSync()
密钥不匹配/文件损坏 → 删除重建
(开发阶段无不可恢复的用户数据)"]
+ G -- 成功 --> I
+ F -- No --> I
+ H --> I["NativeDatabase.createInBackground()
isolateSetup: _sqlCipherIsolateSetup ← 顶层函数!
setup: db.execute(PRAGMA key = '...')"]
+ I --> J["后台 Isolate 注册 SQLCipher
PRAGMA key 解锁
所有 Drift 查询透明 AES-256"]
+ J --> K["AppDatabase 就绪
注入 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。
diff --git a/docs/flutter-guide.md b/docs/flutter-guide.md
new file mode 100644
index 0000000..c79d2c3
--- /dev/null
+++ b/docs/flutter-guide.md
@@ -0,0 +1,1268 @@
+# Flutter 企业级开发指南(小白入门)
+
+> 本文档面向**初次接触 Flutter / Dart 的开发者**,系统讲解语言基础、框架原理、生态库,
+> 以及 sunny_mochi 脚手架中每个核心组件的设计原理和使用方式。
+>
+> **阅读建议**:按章顺序读一遍建立心智模型,之后按需跳阅第三、四章查阅具体库和组件。
+> 如果你有 Android 或 iOS 原生开发背景,每节的"📱 原生对比"可以帮你快速建立映射关系。
+
+---
+
+## 目录
+
+- [第一章:Dart 语言核心](#第一章dart-语言核心)
+- [第二章:Flutter 框架原理](#第二章flutter-框架原理)
+- [第三章:生态库逐一解析](#第三章生态库逐一解析)
+- [第四章:项目核心组件深度解读](#第四章项目核心组件深度解读)
+- [第五章:新增 Feature 的标准流程](#第五章新增-feature-的标准流程)
+
+---
+
+## 第一章:Dart 语言核心
+
+> Dart 是 Flutter 的唯一编程语言。Flutter 框架的 Widget 系统、Riverpod 的响应式机制、
+> Drift 的类型安全查询,底层都是 Dart 特性在发挥作用。读懂本章,后续所有库的用法
+> 自然就能理解。
+
+### 1.1 类型系统与空安全(Null Safety)
+
+Dart 是**强类型、静态类型**语言,从 Dart 2.12 起引入**空安全(Sound Null Safety)**。
+空安全意味着编译器在编译期就能捕获空指针错误,而不是等到运行时崩溃。
+
+```dart
+// 不允许为 null 的类型(默认)
+String name = 'Alice';
+// name = null; // ❌ 编译错误
+
+// 允许为 null 的类型(加 ?)
+String? nickname;
+nickname = null; // ✅ 合法
+
+// 使用前必须判空
+if (nickname != null) {
+ print(nickname.length); // 自动 smart cast,不需要 !
+}
+
+// 非空断言(确信不为 null 时用,运行时失败会抛异常)
+print(nickname!.length);
+
+// 空值合并运算符
+final display = nickname ?? '匿名用户';
+
+// 可选链(null 时短路,不崩溃)
+final len = nickname?.length;
+```
+
+**项目中的典型用法**:
+
+```dart
+// failures.dart — cause 可为 null(某些错误没有原因对象)
+const Failure({required this.message, this.cause, this.stackTrace});
+final Object? cause;
+
+// token_refresh_interceptor.dart — Completer 可为 null(未在刷新中)
+Completer? _refreshing;
+```
+
+> **📱 原生对比**
+> Kotlin 有完全相同的空安全语法(`?`、`!!`、`?.`、`?:`),Dart 的设计明显借鉴了它。
+> Swift 用 `Optional` / `T?` 实现相同语义,`guard let` 和 `if let` 等价于 Dart 的 `if (x != null)`。
+
+---
+
+### 1.2 异步编程(Future / Stream / async-await)
+
+Dart 是**单线程**的(与 JavaScript 相同),通过事件循环(Event Loop)处理并发。
+`Future` 代表"将来某时的一个值",`Stream` 代表"将来某时的一系列值"。
+
+```dart
+// Future:一次性异步结果
+Future fetchToken() async {
+ await Future.delayed(Duration(seconds: 1)); // 模拟网络延迟
+ return 'my_token_abc123';
+}
+
+// await:等待 Future 完成(只能在 async 函数内使用)
+final token = await fetchToken();
+print(token); // my_token_abc123
+
+// 错误处理
+try {
+ final token = await fetchToken();
+} on NetworkException catch (e) {
+ // 处理特定类型异常
+} on Object catch (e, st) {
+ // 兜底(本项目标准写法)
+}
+
+// Stream:持续的数据流
+Stream counter() async* {
+ for (int i = 0; i < 3; i++) {
+ await Future.delayed(Duration(seconds: 1));
+ yield i; // 逐个发出值
+ }
+}
+
+// 监听 Stream
+await for (final value in counter()) {
+ print(value); // 0, 1, 2
+}
+```
+
+**`unawaited`**:启动 Future 但不等待它——通常用于"fire-and-forget"操作:
+
+```dart
+// main.dart — 不 await sync 启动,不阻塞 UI 渲染
+unawaited(ref.read(syncServiceProvider).start());
+```
+
+**`Completer`**:手动控制 Future 的完成时机,本项目用于 Token 刷新互斥:
+
+```dart
+// token_refresh_interceptor.dart 精简版
+Completer? _refreshing;
+
+// 第一个进来的 401
+final completer = Completer();
+_refreshing = completer;
+final result = await _runRefresh(token);
+_refreshing = null;
+completer.complete(result); // 通知所有等待者
+
+// 后来的 401 直接等已有的 Future
+final ok = await _refreshing!.future;
+```
+
+> **📱 原生对比**
+> Android:`suspend fun`(Kotlin Coroutines)等价于 `async`/`await`,`Flow` 等价于 `Stream`,`Deferred` 等价于 `Completer`。
+> iOS:`async/await`(Swift 5.5+)语法与 Dart 几乎相同,`AsyncStream` 等价于 `Stream`,Swift 的 `actor` 提供类似 `Completer` 的互斥能力。
+
+---
+
+### 1.3 Sealed Class、Extension、Mixin
+
+#### Sealed Class(Dart 3.0+)
+
+`sealed` 关键字让编译器知道子类集合是**封闭的**,在 `switch` 时强制穷举所有情况:
+
+```dart
+// failures.dart
+sealed class Failure implements Exception {
+ const Failure({required this.message, this.cause, this.stackTrace});
+ final String message;
+ final Object? cause;
+ final StackTrace? stackTrace;
+}
+
+class NetworkFailure extends Failure { /* ... */ }
+class AuthFailure extends Failure { /* ... */ }
+class ServerFailure extends Failure { /* ... */ }
+class CacheFailure extends Failure { /* ... */ }
+class UnknownFailure extends Failure { /* ... */ }
+```
+
+使用时编译器确保你处理了所有情况:
+
+```dart
+switch (failure) {
+ case NetworkFailure(:final kind):
+ showToast('网络错误:$kind');
+ case AuthFailure():
+ router.go('/login');
+ case ServerFailure(:final code):
+ showToast('服务器错误 $code');
+ case CacheFailure():
+ showToast('本地数据异常');
+ case UnknownFailure():
+ showToast('未知错误');
+}
+// 如果少写任何一个 case → 编译错误!
+```
+
+> **📱 原生对比**
+> Kotlin 有完全相同的 `sealed class`(Dart 的 `sealed` 语义来自 Kotlin)。
+> Swift 用带关联值的 `enum` 实现同等效果:`enum Failure { case network(kind: NetworkKind), case auth, ... }`,`switch` 同样强制穷举。
+
+#### Extension(扩展方法)
+
+为**已有类型**添加方法,无需继承,无需修改源码:
+
+```dart
+extension ContextX on BuildContext {
+ void showError(WidgetRef ref, Object error, StackTrace st) {
+ // 统一展示错误 + 写日志
+ }
+}
+
+// 使用时就像原生方法一样
+context.showError(ref, failure, st);
+```
+
+> **📱 原生对比**
+> Kotlin 有同名的 `extension function`,语法略有不同。iOS Swift 的 `extension` 关键字功能完全相同,是 Swift 的一等公民特性。
+
+#### Mixin
+
+在不同类之间复用方法,比继承更灵活(一个类可以 mixin 多个):
+
+```dart
+mixin SmsCountdownMixin on State {
+ int _seconds = 0;
+ void startCountdown() { /* 60s 倒计时逻辑 */ }
+}
+
+class _LoginPageState extends ConsumerState with SmsCountdownMixin {
+ // 直接使用 startCountdown() 和 _seconds
+}
+```
+
+> **📱 原生对比**
+> Kotlin 用 `interface` + `default implementation` 实现类似效果(Kotlin 没有 mixin 关键字)。
+> Swift 的 `protocol` + `extension` 组合是完整等价物:在 `extension` 里为 protocol 提供默认实现,`class` 遵循 protocol 即可复用。
+
+---
+
+### 1.4 代码生成(build_runner / @riverpod / @freezed)
+
+Dart 通过**注解 + 代码生成**消除样板代码。项目用到三套:
+
+| 注解 | 生成器 | 生成文件 | 作用 |
+|------|--------|---------|------|
+| `@riverpod` | riverpod_generator | `.g.dart` | Provider 工厂函数 |
+| `@freezed` | freezed | `.freezed.dart` | 值对象 + copyWith + JSON |
+| `@TypedGoRoute` | go_router_builder | `.g.dart` | 类型安全路由类 |
+| `@DriftDatabase` | drift_dev | `.g.dart` | SQL 查询代码 |
+
+**运行代码生成**:
+
+```bash
+# 一次性生成(修改 .dart 文件后必跑)
+make gen
+# 等价于
+flutter pub run build_runner build --delete-conflicting-outputs
+```
+
+> **黄金法则**:永远不要手动修改 `.g.dart` 或 `.freezed.dart` 文件——它们在下次 `make gen` 时会被覆盖。
+
+> **📱 原生对比**
+> Android:KSP(Kotlin Symbol Processing)+ Room/Hilt/Retrofit 注解处理器,与 build_runner 机制完全相同。
+> iOS:Swift 宏(Swift 5.9+)提供类似能力;传统方案是 Sourcery(代码生成工具)或 Codegen 脚本。
+
+---
+
+## 第二章:Flutter 框架原理
+
+> **从原生视角理解 Flutter**
+> Android 开发者:Flutter 的 Widget 树对应 Android 的 View 树,但 Widget 是**不可变的配置描述**,
+> 而不是 View 实例本身。Flutter 自己调用 Skia/Impeller 绘制,不复用平台原生控件。
+> iOS 开发者:Flutter 的 Widget 对应 SwiftUI 的 `View`(结构体,不可变,声明式)。
+> UIKit 的 `UIViewController` 生命周期对应 `StatefulWidget` 的 `State`。
+
+### 2.1 三棵树:Widget / Element / RenderObject
+
+Flutter 渲染界面靠三层树协同工作:
+
+```mermaid
+flowchart TD
+ W["Widget 树
(配置描述,不可变,轻量)"]
+ E["Element 树
(实例持有者,持久化,生命周期载体)"]
+ R["RenderObject 树
(真正测量/绘制,昂贵)"]
+
+ W -->|"首次构建: createElement()"| E
+ E -->|"首次挂载: createRenderObject()"| R
+ W -->|"rebuild: didUpdateWidget()"| E
+ E -->|"布局/绘制变化: markNeedsLayout()"| R
+
+ style W fill:#4A90D9,color:#fff
+ style E fill:#27AE60,color:#fff
+ style R fill:#E67E22,color:#fff
+```
+
+- **Widget**:描述"我想要什么"(配置),是不可变对象,每次 `setState` / `ref.watch` 都可能重建
+- **Element**:Widget 的实例,负责"连接新旧 Widget"并决定是否需要更新 RenderObject
+- **RenderObject**:实际执行测量(`performLayout`)和绘制(`paint`),创建/销毁成本高
+
+**关键洞察**:Widget 重建(rebuild)**≠** 界面重绘。Flutter 足够聪明,当新旧 Widget 类型和 key 相同时,只更新 Element 和 RenderObject 中变化的部分。
+
+> **📱 原生对比**
+> Android:View 树只有一层,View 对象同时承担"配置"和"渲染"两个职责,这也是 Android View 系统性能问题的根源之一。Jetpack Compose 的设计与 Flutter 更接近(声明式、不可变配置 + 运行时状态树)。
+> iOS UIKit:UIView 同样是"配置+渲染"合一;SwiftUI 的 `View` 协议(结构体)则和 Flutter Widget 概念完全对齐——轻量不可变的配置,框架自己管理渲染树。
+
+---
+
+### 2.2 三种 Widget 类型
+
+| Widget 类型 | 何时使用 | 本项目用法 | Android 类比 | iOS 类比 |
+|-------------|---------|-----------|------------|--------|
+| `StatelessWidget` | 纯展示,无状态 | `AppRoot`、大多数展示组件 | `@Composable` fun(无状态)| SwiftUI `View`(纯 `let` 属性)|
+| `StatefulWidget` | 有本地状态(动画、表单) | `_SyncBootstrap` | `@Composable` + `remember {}` | `@State` 属性 |
+| `ConsumerWidget` | 需要读取 Riverpod Provider | `App`、功能页面 | `@Composable` + `viewModel()` | `@ObservedObject` / `@EnvironmentObject` |
+| `ConsumerStatefulWidget` | 同时需要本地状态和 Provider | `LoginPage` | `@Composable` + `remember` + `viewModel()` | `@StateObject` + `@State` |
+
+```dart
+// ConsumerWidget 是最常用的 Widget 类型(来自 flutter_riverpod)
+class App extends ConsumerWidget {
+ const App({super.key});
+
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ // ref 用于读取/监听 Provider
+ final router = ref.watch(appRouterProvider);
+ final themeMode = ref.watch(themeModeProvider);
+ final scheme = ref.watch(themeProvider);
+
+ return MaterialApp.router(
+ routerConfig: router,
+ themeMode: themeMode,
+ theme: AppTheme.light(scheme: scheme),
+ darkTheme: AppTheme.dark(scheme: scheme),
+ );
+ }
+}
+```
+
+---
+
+### 2.3 BuildContext 与 InheritedWidget
+
+`BuildContext` 是 Widget 在 Element 树中的位置标识,通过它可以:
+
+```dart
+// 向上查找最近的 InheritedWidget(MediaQuery、Theme 都是 InheritedWidget)
+final mediaQuery = MediaQuery.of(context);
+final theme = Theme.of(context);
+
+// 导航(GoRouter)
+context.go('/home');
+context.push('/detail');
+
+// 显示 SnackBar / Dialog
+ScaffoldMessenger.of(context).showSnackBar(SnackBar(...));
+showDialog(context: context, builder: (_) => AlertDialog(...));
+```
+
+**注意**:`BuildContext` 只在 `build()` 方法内可靠使用。在异步回调中使用时,需先检查 `mounted`:
+
+```dart
+Future onTap() async {
+ await someAsyncOp();
+ if (!mounted) return; // Widget 可能已卸载
+ context.go('/next');
+}
+```
+
+> **📱 原生对比**
+> Android:`Context` 是 Android 的核心概念,用于访问资源、启动 Activity、获取系统服务——与 `BuildContext` 功能重叠但范围更广(Android Context 贯穿整个 App 生命周期,BuildContext 只在 Widget 树范围内有效)。
+> iOS:没有直接等价物;SwiftUI 的 `Environment` 和 `EnvironmentValues` 实现了类似的"向上查找"机制(`@Environment(\.colorScheme) var colorScheme`)。
+
+---
+
+### 2.4 生命周期
+
+```mermaid
+stateDiagram-v2
+ [*] --> Created : new Widget()
+ Created --> Mounted : createState() + initState()
+ Mounted --> Built : build()
+ Built --> Rebuilt : setState() / ref.watch 变化
+ Rebuilt --> Built : build()
+ Built --> Disposed : 离开 Widget 树
+ Disposed --> [*]
+
+ note right of Mounted : initState() 只调用一次
可以启动 Animation、订阅 Stream
+ note right of Disposed : dispose() 必须释放资源
Timer、StreamSubscription、Controller
+```
+
+> **📱 原生对比**
+> Android Activity/Fragment:`onCreate → onStart → onResume → onPause → onStop → onDestroy`。`initState()` ≈ `onCreate()`,`dispose()` ≈ `onDestroy()`。
+> iOS UIViewController:`viewDidLoad → viewWillAppear → viewDidAppear → viewWillDisappear → viewDidDisappear → deinit`。`initState()` ≈ `viewDidLoad()`,`dispose()` ≈ `deinit`。
+
+---
+
+### 2.5 布局系统
+
+Flutter 布局的核心约束是:**父 Widget 向子 Widget 传入约束(constraints),子 Widget 在约束范围内决定自己的尺寸**。
+
+```
+父约束向下传递:
+ BoxConstraints(minW=0, maxW=375, minH=0, maxH=812)
+ ↓
+ 子 Widget 决定尺寸:
+ Size(200, 50)
+ ↑
+ 子尺寸向上返回
+```
+
+| Widget | 用途 | Android 类比 | iOS 类比 |
+|--------|------|------------|--------|
+| `Column` / `Row` | 垂直/水平排列 | `LinearLayout` (vertical/horizontal) | `VStack` / `HStack` |
+| `Expanded` / `Flexible` | 按比例分配空间 | `layout_weight` | `.frame(maxWidth: .infinity)` |
+| `Stack` / `Positioned` | 绝对定位层叠 | `FrameLayout` / `ConstraintLayout` | `ZStack` / `.offset` |
+| `SizedBox` | 固定尺寸/间距 | `Space` / `View(match_parent)` | `Spacer` / `.frame(width:height:)` |
+| `ListView` / `GridView` | 可滚动列表/网格 | `RecyclerView` | `List` / `LazyVGrid` |
+| `CustomScrollView` + `Sliver` | 复杂滚动(吸顶、动态 Header)| `CoordinatorLayout` + `NestedScrollView` | `ScrollView` + `LazyVStack` |
+
+---
+
+## 第三章:生态库逐一解析
+
+> **学习建议**:本章库的讲解顺序经过设计——**Freezed(3.1)先于 Riverpod(3.2)**,
+> 因为 Riverpod 的状态类(`sealed AuthState`)都是 Freezed 生成的数据类,
+> 理解 Freezed 后再看 Riverpod 代码会豁然开朗。
+
+### 平台生态对比总览
+
+| 功能 | Flutter 库 | Android 官方/主流 | iOS 官方/主流 |
+|------|-----------|-----------------|-------------|
+| 值对象/数据类 | Freezed | `data class` + kotlinx.serialization | `Codable` (Decodable/Encodable) |
+| 状态管理 | Riverpod | ViewModel + StateFlow (AAC) | `@StateObject` + Combine |
+| 依赖注入 | Riverpod ProviderScope | Hilt / Dagger 2 | Swinject / Factory(三方)|
+| 路由 | GoRouter | Jetpack Navigation Component | NavigationPath (SwiftUI) / Coordinator |
+| HTTP 客户端 | Dio | OkHttp + Retrofit | URLSession + Alamofire(三方)|
+| ORM / 数据库 | Drift | Room | Core Data / GRDB(三方)|
+| 数据库加密 | SQLCipher | SQLCipher for Android / EncryptedRoom | SQLCipher for iOS |
+| 安全存储 | flutter_secure_storage | EncryptedSharedPreferences + Keystore | Keychain Services |
+| 日志 | Talker | Timber + Logcat | OSLog / CocoaLumberjack(三方)|
+| 错误追踪 | Sentry | Firebase Crashlytics / Sentry Android | Firebase Crashlytics / Sentry iOS |
+| 国际化 | Slang | String Resources (res/values/strings.xml) | Localizable.strings + SwiftGen(三方)|
+| 屏幕适配 | flutter_screenutil | dp/sp + ConstraintLayout 百分比 | Auto Layout + Size Classes |
+| RSA 加密 | pointycastle + asn1lib | JCE (`javax.crypto`) / Android Keystore | Security framework / CryptoKit |
+
+---
+
+### 3.1 Freezed —— 值对象与联合类型
+
+> **为什么先讲 Freezed**:本项目里 Riverpod 的状态类(`AuthState`)、所有实体(`UserEntity`)、
+> 所有网络模型(`UserModel`)都是 Freezed 生成的。理解 Freezed 是理解后续所有 `@riverpod` 代码的前提。
+
+**是什么**:代码生成库,为 Dart 类自动生成 `==`、`hashCode`、`copyWith`、`toString`、`toJson`/`fromJson`,并支持联合类型(Union Types / Discriminated Union)。
+
+**解决什么问题**:Dart 原生没有 `data class`,手写 `==` 和 `copyWith` 极其繁琐且容易出错。
+
+```dart
+// user_entity.dart — 值对象
+@freezed
+class UserEntity with _$UserEntity {
+ const factory UserEntity({
+ required String userId,
+ String? username,
+ String? phone,
+ String? avatar,
+ }) = _UserEntity;
+}
+
+// 自动生成的能力
+final user = UserEntity(userId: 'u1', username: 'Alice');
+final updated = user.copyWith(username: 'Bob'); // 不变性
+print(user == updated); // false(值比较,不是引用比较)
+
+// auth_state.dart — 联合类型(状态机)
+@freezed
+sealed class AuthState with _$AuthState {
+ const factory AuthState.initial() = _Initial;
+ const factory AuthState.loading() = _Loading;
+ const factory AuthState.authenticated(UserEntity user)= _Authenticated;
+ const factory AuthState.unauthenticated() = _Unauthenticated;
+ const factory AuthState.error(String message) = _Error;
+}
+
+// 使用时穷举所有状态(编译期安全)
+switch (state) {
+ case AuthState.loading():
+ return CircularProgressIndicator();
+ case AuthState.authenticated(:final user):
+ return Text('欢迎,${user.username}');
+ case AuthState.error(:final message):
+ return Text('错误:$message');
+ // ...
+}
+```
+
+> **📱 原生对比**
+> **Android**:Kotlin 的 `data class` 原生提供 `equals`/`hashCode`/`copy`(对应 `copyWith`),`sealed class` 原生支持穷举 `when`——Freezed 的功能在 Kotlin 里几乎是语言内置的。
+> **iOS**:Swift 的 `struct` + `Codable` 提供值语义和 JSON 序列化;`enum` with associated values 提供联合类型。Swift 没有内置 `copyWith`,通常手写或用 `@dynamicMemberLookup` 变通。
+
+---
+
+### 3.2 Riverpod —— 响应式状态管理
+
+**是什么**:Flutter 社区最主流的状态管理方案,基于"Provider"概念,解决了原 Provider 包的诸多痛点(类型安全、测试隔离、自动处理异步等)。它同时承担**状态管理**和**依赖注入**两个职责。
+
+**解决什么问题**:在多个 Widget 之间共享状态,避免"状态提升"导致的层层传递 `callback`,同时保证 UI 响应式更新。
+
+```dart
+// 定义 Provider(以主题为例)
+@Riverpod(keepAlive: true)
+class ThemeNotifier extends _$ThemeNotifier {
+ @override
+ AppColorScheme build() => AppColorScheme.blue; // 初始值
+
+ void setScheme(AppColorScheme s) => state = s;
+}
+
+// 使用 Provider(在 ConsumerWidget 中)
+class MyPage extends ConsumerWidget {
+ @override
+ Widget build(BuildContext context, WidgetRef ref) {
+ // watch:监听变化,变化时重建 Widget
+ final scheme = ref.watch(themeProvider);
+
+ // read:只读一次,不监听变化(用于事件回调)
+ ref.read(themeNotifierProvider.notifier).setScheme(AppColorScheme.red);
+
+ // listen:监听变化,执行副作用(导航、Toast)
+ ref.listen(themeProvider, (prev, next) {
+ print('主题变了: $next');
+ });
+ }
+}
+```
+
+**Provider 类型对照**:
+
+| 注解写法 | 生成的 Provider | 适合场景 |
+|---------|----------------|---------|
+| `@riverpod Future foo(Ref)` | `FutureProvider` | 异步一次性数据(网络请求) |
+| `@riverpod Stream foo(Ref)` | `StreamProvider` | 持续数据流(WebSocket) |
+| `@riverpod T foo(Ref)` | `Provider` | 同步只读计算值 |
+| `class Foo extends _$Foo` | `NotifierProvider` | 可变状态 + 方法 |
+| `@Riverpod(keepAlive: true)` | 永不销毁的 Provider | 全局单例(路由、DB、Dio 实例) |
+
+> **📱 原生对比**
+> **Android**:ViewModel(AAC)提供类似"状态容器",`StateFlow` 对应 `AsyncValue`(Riverpod 的异步状态包装),`viewModels()` / Hilt `@Inject` 对应 `ref.watch/read`。Riverpod 的 `ProviderScope` ≈ Hilt 的 `@HiltAndroidApp`(应用级依赖注入容器)。
+> **iOS**:`@StateObject` + `ObservableObject`/`@Published` 对应 Riverpod Notifier;`@EnvironmentObject` 对应 `keepAlive: true` 的全局 Provider;Combine `Publisher` 对应 `StreamProvider`。TCA(The Composable Architecture)是功能上更完整的对标方案。
+
+---
+
+### 3.3 GoRouter —— 声明式路由
+
+**是什么**:Flutter 官方推荐的路由库,支持深链接(Deep Link)、Web URL 路由、类型安全路由、权限守卫(redirect)。
+
+**解决什么问题**:原生 `Navigator.push/pop` 无法用 URL 描述路由状态,深链接处理复杂,无法做全局路由守卫。
+
+```dart
+// routes.dart — 类型安全路由定义
+@TypedGoRoute(path: '/login')
+class LoginRoute extends GoRouteData with $LoginRoute {
+ const LoginRoute();
+ @override
+ Widget build(BuildContext context, GoRouterState state) => const LoginPage();
+}
+
+// 跳转(类型安全,不再用字符串)
+LoginRoute().go(context); // 替换当前页面
+LoginRoute().push(context); // 压栈
+
+// app_router.dart — 全局路由守卫
+GoRouter(
+ refreshListenable: auth.listenable, // 登录态变化时重新执行 redirect
+ redirect: (context, state) {
+ final loggedIn = auth.value;
+ if (loggedIn == null) return null; // 启动中,不动
+ if (!loggedIn) return '/login'; // 未登录,跳登录
+ if (loggedIn && state.matchedLocation == '/login') return '/home';
+ return null;
+ },
+)
+```
+
+> **📱 原生对比**
+> **Android**:Jetpack Navigation Component + Safe Args(类型安全参数传递)是直接对标;`NavController.navigate()` ≈ `context.go()`;Navigation Graph XML 的全局 action ≈ GoRouter `redirect`。
+> **iOS**:SwiftUI 的 `NavigationPath` + `.navigationDestination(for:destination:)` 是声明式路由的原生方案;UIKit 的 `UINavigationController` + Coordinator pattern 是更成熟的命令式替代。
+
+---
+
+### 3.4 Dio —— HTTP 客户端
+
+**是什么**:Flutter 最流行的 HTTP 客户端,支持拦截器链、文件上传、取消请求、FormData 等。
+
+**解决什么问题**:原生 `http` 包功能简单,Dio 提供完整的中间件(拦截器)机制,可以统一处理 Token 注入、错误映射、重试等横切关注点。
+
+**Options.extra 信号机制**(本项目独特设计):
+
+拦截器之间通过 `RequestOptions.extra` Map 传递信号,避免拦截器之间直接耦合:
+
+```dart
+// 登录接口调用时设置,告知 AuthInterceptor 跳过 Token 注入
+dio.post('/auth/login', options: Options(extra: {'skip_auth': true}));
+
+// TokenRefreshInterceptor 防止无限重试:
+// 重放请求时打标记
+err.requestOptions.extra['_token_refreshed'] = true;
+// 下一次进入拦截器时检查
+final retried = err.requestOptions.extra['_token_refreshed'] == true;
+if (retried) return handler.next(err); // 不再重试
+```
+
+> **📱 原生对比**
+> **Android**:OkHttp 的 `Interceptor` 接口与 Dio 拦截器机制几乎完全相同(连"应用层拦截器 vs 网络层拦截器"的分类思路都一致)。Retrofit 基于 OkHttp 提供注解式 API 定义。`OkHttpClient.Builder().addInterceptor()` 对应 `dio.interceptors.add()`。
+> **iOS**:URLSession 原生没有拦截器链;Alamofire 的 `RequestInterceptor`(`adapt` + `retry`)和 `EventMonitor` 提供等价能力;Moya(基于 Alamofire)提供更高层的抽象。
+
+---
+
+### 3.5 Drift —— 类型安全 ORM
+
+**是什么**:Flutter/Dart 的类型安全 SQLite ORM,通过代码生成将 Dart 类映射为数据库表,查询语句在编译期检查。
+
+**解决什么问题**:直接写 SQL 字符串没有类型检查,容易出现拼写错误和运行时崩溃。Drift 让你用 Dart 类型化的 API 写查询。
+
+```dart
+// 表定义(users_table.dart)
+class Users extends Table {
+ TextColumn get userId => text()(); // TEXT NOT NULL
+ TextColumn get username => text().nullable()(); // TEXT NULL
+ IntColumn get gender => integer().nullable()();
+}
+
+// DAO(数据访问对象)
+@DriftAccessor(tables: [Users])
+class UsersDao extends DatabaseAccessor with _$UsersDaoMixin {
+ Future findById(String userId) =>
+ (select(users)..where((t) => t.userId.equals(userId)))
+ .getSingleOrNull(); // 编译期类型安全
+
+ Future upsert(UsersCompanion companion) =>
+ into(users).insertOnConflictUpdate(companion);
+}
+```
+
+**后台 Isolate**:Drift 的 `NativeDatabase.createInBackground` 在独立 Isolate 中运行数据库操作,不阻塞 UI 线程:
+
+```dart
+// app_database.dart
+NativeDatabase.createInBackground(
+ file,
+ isolateSetup: _sqlCipherIsolateSetup, // 必须是顶层函数!
+ setup: (db) {
+ db.execute("PRAGMA key = '$escaped';"); // 解锁加密
+ },
+)
+```
+
+> **📱 原生对比**
+> **Android**:Room 是直接对标——同样注解式、同样代码生成 DAO、同样禁止主线程 IO(Room 默认在 IO Dispatcher 执行)。`@Entity` ≈ Drift `Table`,`@Dao` ≈ `@DriftAccessor`,`@Database` ≈ `@DriftDatabase`。
+> **iOS**:Core Data 是官方 ORM(基于 NSManagedObject,面向对象风格);GRDB.swift(三方)更接近 Drift 的风格(类型安全 SQL)。两者都支持后台 `managedObjectContext` 避免主线程阻塞。
+
+---
+
+### 3.6 Flutter Secure Storage —— 安全存储
+
+**是什么**:跨平台安全键值存储,iOS 使用 Keychain,Android 使用 EncryptedSharedPreferences(AES-256 + Android Keystore)。
+
+**解决什么问题**:普通 `SharedPreferences` 存储 Token 在 Android 上明文可读(rooted 设备),Secure Storage 的加密存储符合安全规范。
+
+```dart
+// 零容忍规则:Token 只能从 SecureStorage 读写,禁止存 SharedPreferences
+class SecureStorageService {
+ Future getToken() => _storage.read(key: _kToken);
+ Future setToken(String token) => _storage.write(key: _kToken, value: token);
+ Future clearAll() => _storage.deleteAll(); // 退出登录时调用
+}
+```
+
+> **📱 原生对比**
+> **Android**:`EncryptedSharedPreferences`(Jetpack Security)直接对标,底层用 Android Keystore + AES-256-GCM;也可直接操作 Android Keystore 存证书/密钥。
+> **iOS**:Keychain Services 是原生安全存储,`SecItemAdd` / `SecItemCopyMatching` 是底层 API;大多数项目用 `KeychainAccess`(三方)简化调用。
+
+---
+
+### 3.7 Talker —— 结构化日志
+
+**是什么**:Flutter 的结构化日志库,支持按级别过滤(verbose/debug/info/warning/error)、颜色输出、集成 Dio 和 Riverpod 的自动日志。
+
+**解决什么问题**:`print()` 没有级别、没有颜色、Release 包无法关闭,会泄露敏感信息。
+
+```dart
+// 使用(零容忍:禁止 print/debugPrint)
+appTalker.info('App 启动');
+appTalker.warning('[TokenRefresh] 刷新失败', error, stackTrace);
+appTalker.error('[Database] 打开失败', error, stackTrace);
+appTalker.verbose('[Router] path=/home loggedIn=true');
+// Release 包自动关闭,Dev Panel 中可查看历史日志
+```
+
+> **📱 原生对比**
+> **Android**:Timber(三方)是最流行的日志库,`Timber.d/w/e()` 对应 Talker 的各级别,`DebugTree` vs `ReleaseTree` 实现 Release 环境静默——与 Talker 设计理念完全一致。
+> **iOS**:`OSLog`(官方,iOS 14+)是结构化日志的现代方案;`CocoaLumberjack`(三方)提供更丰富的级别过滤和文件输出,功能上更接近 Talker。
+
+---
+
+### 3.8 Sentry —— 云端错误追踪
+
+**是什么**:错误监控 SaaS,将 App 崩溃和异常上报到云端,提供错误聚合、影响用户数统计、堆栈还原(ProGuard / dSYM)。
+
+**本项目重点:PII 脱敏**
+
+```dart
+// sentry_setup.dart — beforeSend 钩子过滤敏感信息
+SentryFlutter.init(
+ (options) {
+ options.beforeSend = (event, hint) => _sanitize(event);
+ // 手机号脱敏、Token 完全移除、身份证号脱敏
+ },
+);
+// DSN 留空 = 不上报(dev 环境默认不上报)
+```
+
+> **📱 原生对比**
+> **Android / iOS**:Firebase Crashlytics 是最主流的替代(Google 出品,免费,与 Firebase 生态集成深);Sentry 同样提供 Android SDK 和 iOS SDK,功能对等。两者都支持 `beforeSend` 钩子脱敏。
+
+---
+
+### 3.9 Slang —— 编译期 i18n
+
+**是什么**:基于代码生成的国际化方案,翻译文件(JSON)在编译期转为类型安全的 Dart 类,拼错翻译键名会在编译期报错。
+
+```dart
+// 使用(context.t 是类型安全的)
+Text(context.t.auth.login) // "登录"
+Text(context.t.auth.phoneHint) // "请输入手机号"(camelCase 自动转换)
+// 修改翻译后:make gen-i18n
+```
+
+> **📱 原生对比**
+> **Android**:String Resources(`res/values/strings.xml`)是官方方案,通过 `R.string.xxx` 访问(编译期检查)。无需三方库,但键名是字符串常量,IDE 支持弱于 Slang 的类型链式访问。
+> **iOS**:`Localizable.strings` + `NSLocalizedString("key", comment:)` 是传统方案;SwiftGen(三方)与 Slang 最接近,从资源文件生成类型安全的 Swift 访问器。
+
+---
+
+### 3.10 flutter_screenutil —— 屏幕适配
+
+**是什么**:基于设计稿尺寸自动缩放 UI 元素,确保在不同屏幕尺寸的设备上界面比例一致。
+
+**本项目配置**:设计基准 375×812(与 iOS 6s 逻辑分辨率相同),`minTextAdapt: true` 防止文字在小屏上过小。
+
+```dart
+// 使用
+Container(
+ width: 200.w, // 200 / 375 * 屏幕宽度
+ height: 50.h, // 50 / 812 * 屏幕高度
+ padding: EdgeInsets.all(16.r), // 按最小边缩放
+ child: Text('登录', style: TextStyle(fontSize: 16.sp)),
+)
+```
+
+> **📱 原生对比**
+> **Android**:dp(density-independent pixels)+ sp(scale-independent pixels)是系统内置适配方案;ConstraintLayout 的百分比约束和 `guideline` 实现比例布局。无需三方库。
+> **iOS**:Auto Layout + Size Classes 是官方适配方案(设置 leading/trailing/top/bottom 约束相对于 Safe Area);SnapKit(三方)简化约束书写,功能上更贴近 ScreenUtil 的开发体验。
+
+---
+
+### 3.11 SQLCipher —— 数据库加密
+
+**是什么**:SQLite 的加密扩展,在 SQLite 基础上增加 AES-256 全库加密,文件在文件系统层面不可读。
+
+**与 Drift 集成**:
+
+```
+普通 SQLite 裸库
+ ↓ 替换为
+SQLCipher(libsqlcipher.so / SQLCipher.xcframework)
+ ↓ 在 setup 回调中解锁
+PRAGMA key = 'your_key';
+ ↓ 之后所有操作透明加密
+Drift ORM(正常使用,感知不到加密层)
+```
+
+**关键坑**:Drift 后台 Isolate 的 setup 函数必须是**顶层函数**(不能是 lambda),否则 Isolate 间序列化失败,加密静默不生效。
+
+> **📱 原生对比**
+> **Android**:`SQLCipher for Android`(同一家公司的 SDK)可直接替换 `android.database.sqlite`;Jetpack Security 的 `EncryptedFile` + Room 组合提供另一种加密路径(实验性,不如 SQLCipher 成熟)。
+> **iOS**:`SQLCipher for iOS`(同一 SDK 体系);Core Data 不原生支持加密,需要通过 SQLCipher 替换底层存储或使用 SQLite 层加密。
+
+---
+
+### 3.12 pointycastle + asn1lib —— RSA 加密传输
+
+**是什么**:纯 Dart 密码学库,`pointycastle` 提供 RSA/AES 等算法实现,`asn1lib` 解析 ASN.1/DER 格式的密钥。
+
+**本项目用途**:登录密码在客户端用服务端公钥加密后传输,防止明文密码在网络中传输(即使 HTTPS 中间人攻击场景下也有额外保护层)。
+
+```dart
+// rsa_helper.dart
+// 失败时抛 RsaEncryptionException(禁止 fallback 明文,零容忍)
+static String encrypt(String plainText, String spkiBase64) {
+ // Base64 解码 → DER → ASN.1 解析 → RSA 公钥 → PKCS#1 v1.5 加密 → Base64 输出
+}
+```
+
+> **📱 原生对比**
+> **Android**:`javax.crypto`(JCE)+ `java.security`(JCA)是 JVM 原生加密 API;Android Keystore System 提供硬件级密钥保护。Bouncy Castle 是最流行的三方密码学库(pointycastle 是其 Dart 移植版)。
+> **iOS**:`Security` framework(`SecKey`、`SecKeyCreateWithData`)处理 RSA 操作;Swift 5.5+ 的 `CryptoKit` 提供更现代的 API(但 RSA PKCS#1 v1.5 不在 CryptoKit 支持范围,需用 Security framework)。
+
+---
+
+## 第四章:项目核心组件深度解读
+
+> 本章是第三章各库的**项目级集成讲解**——从单个库的"怎么用"升级到"为什么这样组合"。
+> 每节会标注"参见第三章 X.X"以便回查库的基础知识。
+
+### 4.1 启动序列(main.dart)
+
+(参见:3.7 Talker、3.8 Sentry、3.5 Drift、3.2 Riverpod)
+
+```mermaid
+sequenceDiagram
+ participant M as main()
+ participant CR as CrashReporter
+ participant S as SentrySetup
+ participant DB as AppDatabase
+ participant RS as ProviderScope
+ participant SB as _SyncBootstrap
+
+ M->>CR: preInit()(准备崩溃写入路径)
+ CR-->>M: pendingCrash(上次崩溃数据)
+ M->>S: init(appRunner)(空 DSN 直接执行 appRunner)
+ S->>CR: installHooks()(接管 FlutterError + Zone 异常)
+ S->>DB: open()(AES-256 解锁 SQLite)
+ DB-->>S: AppDatabase 实例
+ S->>RS: runApp(ProviderScope(...))
+ RS->>SB: initState()
+ SB->>SB: syncService.start()(后台同步,unawaited)
+ SB->>SB: errorLogger.pruneOld()(清理旧日志,unawaited)
+```
+
+**为什么顺序不能颠倒**:
+1. `CrashReporter.preInit()` 必须最早——它准备崩溃文件写入路径,后续任何阶段崩溃都能被捕获
+2. `SentrySetup.init()` 包裹整个 `appRunner`——确保 Sentry 捕获到启动期异常
+3. `AppDatabase.open()` 必须在 `ProviderScope` 之前——`appDatabaseProvider` 用 `overrideWithValue` 注入,需要实例准备好
+
+---
+
+### 4.2 七层拦截器网络栈
+
+(参见:3.4 Dio 拦截器链)
+
+```mermaid
+flowchart TD
+ REQ(["📤 Dio.request()
业务代码发起请求"])
+
+ subgraph Chain["拦截器链(onRequest 从上到下,onError 从下到上)"]
+ direction TB
+ I1["[1] CertPinningInterceptor
🔐 TLS 指纹校验
PINNED_FINGERPRINTS 为空 → 跳过
不匹配 → NetworkFailure(badCertificate)"]
+ I2["[2] AuthInterceptor
🔑 Token 注入
skip_auth=true → 跳过
否则读 SecureStorage → header[tokenName]=token"]
+ I3["[3] TokenRefreshInterceptor(onError)
🔄 401 自动刷新
业务码分类 → 不消耗非 token 类错误
Completer 互斥防并发重复消耗"]
+ I4["[4] RetryInterceptor(onError)
🔁 指数退避重试
408/429/5xx → 最多3次
200ms→400ms→800ms"]
+ I5["[5] ErrorInterceptor(onError)
🗺️ 异常映射
DioException → sealed Failure
写入 err.error 供下游读取"]
+ I6["[6] AuthLogoutInterceptor(onError)
🚪 强制登出
err.error is AuthFailure → clearAll()
→ markLoggedOut() → GoRouter 跳 /login"]
+ I7["[7] LogInterceptor
📋 请求日志
Dev/Internal → TalkerDioLogger
Release → noop(零日志泄漏)"]
+ end
+
+ NET(["🌐 服务器"])
+ RESP(["📥 Response
成功响应返回业务代码"])
+
+ 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
+```
+
+---
+
+### 4.3 sealed Failure 错误体系
+
+(参见:1.3 Sealed Class、3.1 Freezed)
+
+```mermaid
+flowchart LR
+ NET["网络/DB 异常
DioException
DriftException
RsaEncryptionException"]
+ EM["ExceptionMapper
.fromUnknown(e, st)"]
+
+ subgraph SF["sealed Failure(编译期强制穷举)"]
+ NF["NetworkFailure
kind: timeout/noNetwork
/badCertificate/cancelled"]
+ AF["AuthFailure
kind: unauthorized/forbidden
/refreshFailed/cryptoFailed"]
+ SF2["ServerFailure
code: 业务错误码
statusCode: HTTP 状态码"]
+ CF["CacheFailure
本地数据库 IO 异常"]
+ UF["UnknownFailure
兜底"]
+ end
+
+ UI1["UI 层
failure.userMessage
→ AppToast"]
+ UI2["ErrorLogger
failure.message(技术细节)
→ Drift error_logs 表"]
+ UI3["Sentry
failure.toString()
→ 云端聚合"]
+
+ NET --> EM
+ EM --> NF & AF & SF2 & CF & UF
+ NF & AF & SF2 & CF & UF --> UI1
+ NF & AF & SF2 & CF & UF --> UI2
+ UF --> UI3
+
+ style NF fill:#E74C3C,color:#fff
+ style AF fill:#9B59B6,color:#fff
+ style SF2 fill:#E67E22,color:#fff
+ style CF fill:#F39C12,color:#fff
+ style UF fill:#7F8C8D,color:#fff
+```
+
+---
+
+### 4.4 认证模块 Clean Architecture
+
+(参见:3.1 Freezed、3.2 Riverpod、3.3 GoRouter、3.4 Dio、3.6 SecureStorage)
+
+```mermaid
+classDiagram
+ class AuthRepository {
+ <>
+ +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 {
+ <>
+ -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 UserModel {
+ <>
+ +userId: String
+ +username: String?
+ +token: String?
+ +tokenName: String?
+ +refreshToken: String?
+ +toEntity() UserEntity
+ }
+
+ class UserEntity {
+ <>
+ +userId: String
+ +username: String?
+ +phone: String?
+ +avatar: String?
+ }
+
+ class AuthState {
+ <>
+ 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
+```
+
+**分层原则**:
+- **Domain 层**(`UserEntity`、`AuthRepository`):纯 Dart,无框架依赖,可在服务端/命令行复用
+- **Data 层**(`UserModel`、`AuthRepositoryImpl`):负责 JSON 解析和持久化,`UserModel.toEntity()` 是跨层桥梁
+- **Presentation 层**(`AuthNotifier`、`LoginPage`):只依赖 `AuthRepository` 接口,测试时可 Mock
+
+---
+
+### 4.5 SQLCipher 加密数据库启动流程
+
+(参见:3.5 Drift、3.11 SQLCipher、3.6 SecureStorage)
+
+```mermaid
+flowchart TD
+ A["AppDatabase.open()"] --> B{"Platform.isAndroid?"}
+ B -- Yes --> C["applyWorkaroundToOpenSqlCipherOnOldAndroidVersions()
确保 Java 先加载 libsqlcipher.so"]
+ B -- No --> D
+ C --> D["_sqlCipherIsolateSetup()
主 Isolate 注册 SQLCipher open override"]
+ D --> E["DbKeyProvider.key
从 SecureStorage 读取/生成 AES-256 密钥
(RandomKeyStrategy:首次随机 32 字节)"]
+ E --> F{"app.db 文件已存在?"}
+ F -- Yes --> G["_canOpenWithKey()
用当前密钥探测是否可打开"]
+ G -- 失败 --> H["file.deleteSync()
删除损坏/密钥不匹配的旧文件"]
+ G -- 成功 --> I
+ F -- No --> I
+ H --> I["NativeDatabase.createInBackground()
后台 Isolate:isolateSetup=_sqlCipherIsolateSetup(顶层函数!)
setup: PRAGMA key = 'escaped_key'"]
+ I --> J["AppDatabase 就绪
所有读写透明 AES-256 加密"]
+```
+
+---
+
+### 4.6 GoRouter 三态认证跳转
+
+(参见:3.3 GoRouter、1.2 Completer 与异步)
+
+```mermaid
+stateDiagram-v2
+ [*] --> Bootstrapping : App 启动
+
+ Bootstrapping --> CheckStorage : authStatusProvider 创建
+ CheckStorage --> LoggedIn : SecureStorage 有 token
+ CheckStorage --> LoggedOut : SecureStorage 无 token
+ CheckStorage --> LoggedOut : 读取异常
+
+ state LoggedIn {
+ [*] --> Home
+ Home --> [*]
+ }
+
+ state LoggedOut {
+ [*] --> Login
+ Login --> [*]
+ }
+
+ state Bootstrapping {
+ [*] --> Waiting : auth.value == null
+ Waiting --> [*] : redirect 返回 null(不跳转)
+ }
+
+ LoggedIn --> LoggedOut : logout()
→ markLoggedOut()
→ GoRouter 重新 redirect
+ LoggedOut --> LoggedIn : login 成功
→ markLoggedIn()
→ GoRouter 重新 redirect
+```
+
+---
+
+### 4.7 崩溃日志系统
+
+(参见:3.8 Sentry、3.5 Drift)
+
+```mermaid
+sequenceDiagram
+ participant M as main()
+ participant CR as CrashReporter
+ participant FH as FlutterError.onError
+ participant ZH as Zone onError
+ participant CG as CrashGate(UI)
+
+ M->>CR: preInit()(创建崩溃文件目录)
+ CR-->>M: pendingCrash(读取上次遗留的崩溃记录)
+ Note over M,CG: pendingCrash 注入 ProviderScope,CrashGate 自动弹窗
+
+ M->>CR: installHooks()
+ CR->>FH: 接管 FlutterError.onError(Widget 层异常)
+ CR->>ZH: 接管 runZonedGuarded(Dart 层未捕获异常)
+
+ Note over FH,ZH: 崩溃发生时
+ FH->>CR: 同步写入 crash.json(不依赖 async)
+ ZH->>CR: 同步写入 crash.json
+
+ Note over CR,CG: 下次启动
+ CR->>CR: consumePending()(读 crash.json 并删除)
+ CR-->>CG: pendingCrash 非 null → 弹对话框询问上报
+```
+
+**"同步写入"是关键设计**:崩溃发生时 Dart 运行时处于不稳定状态,异步操作(`await`)可能无法完成。`CrashReporter` 使用同步文件写入,确保崩溃信息在进程退出前落盘。
+
+---
+
+### 4.8 主题系统
+
+(参见:3.2 Riverpod、3.10 flutter_screenutil)
+
+```mermaid
+flowchart LR
+ SP["SharedPreferences
'theme_scheme'
'theme_mode'"]
+
+ TN["ThemeNotifier
@Riverpod keepAlive
→ themeProvider
(AppColorScheme)"]
+
+ TMN["ThemeModeNotifier
@Riverpod keepAlive
→ themeModeProvider
(ThemeMode)"]
+
+ AT["AppTheme
.light(scheme)
.dark(scheme)
→ ThemeData"]
+
+ APP["App(ConsumerWidget)
ref.watch(themeProvider)
ref.watch(themeModeProvider)
→ MaterialApp.router"]
+
+ SP -->|"启动读取"| TN
+ SP -->|"启动读取"| TMN
+ TN -->|"setScheme() → 写入"| SP
+ TMN -->|"setMode() → 写入"| SP
+ TN --> AT
+ AT --> APP
+ TMN --> APP
+```
+
+**5 套色板切换**(运行时热切换,无需重启):
+
+```dart
+ref.read(themeNotifierProvider.notifier).setScheme(AppColorScheme.red);
+// → SharedPreferences 持久化
+// → themeProvider 通知
+// → App 重建(只有 App widget,成本极低)
+// → MaterialApp.router 使用新 ThemeData
+```
+
+---
+
+## 第五章:新增 Feature 的标准流程
+
+### 5.1 目录结构模板
+
+```
+lib/features/{feature_name}/
+├── domain/
+│ ├── entities/{name}_entity.dart # @freezed,纯 Dart,无框架依赖
+│ └── repositories/{name}_repository.dart # abstract interface,定义业务契约
+├── data/
+│ ├── models/{name}_model.dart # @freezed + fromJson + toEntity()
+│ ├── datasources/{name}_remote_datasource.dart # @riverpod,只调用 Dio
+│ └── repositories/{name}_repository_impl.dart # @riverpod,实现接口
+└── presentation/
+ ├── notifiers/{name}_notifier.dart # @riverpod class XxxNotifier
+ └── pages/{name}_page.dart # ConsumerWidget 或 ConsumerStatefulWidget
+```
+
+### 5.2 完整示例(以"订单列表"为例)
+
+**Step 1:Domain 层**
+
+```dart
+// lib/features/order/domain/entities/order_entity.dart
+@freezed
+class OrderEntity with _$OrderEntity {
+ const factory OrderEntity({
+ required String orderId,
+ required String title,
+ required double amount,
+ required String status,
+ }) = _OrderEntity;
+}
+
+// lib/features/order/domain/repositories/order_repository.dart
+abstract interface class OrderRepository {
+ Future> getOrders({int page = 1});
+}
+```
+
+**Step 2:Data 层**
+
+```dart
+// lib/features/order/data/models/order_model.dart
+@freezed
+class OrderModel with _$OrderModel {
+ const factory OrderModel({
+ @JsonKey(name: 'order_id') required String orderId,
+ required String title,
+ required double amount,
+ required String status,
+ }) = _OrderModel;
+
+ factory OrderModel.fromJson(Map json) =>
+ _$OrderModelFromJson(json);
+}
+
+extension OrderModelX on OrderModel {
+ OrderEntity toEntity() => OrderEntity(
+ orderId: orderId,
+ title: title,
+ amount: amount,
+ status: status,
+ );
+}
+
+// lib/features/order/data/repositories/order_repository_impl.dart
+@riverpod
+OrderRepository orderRepository(Ref ref) => OrderRepositoryImpl(
+ ref.read(orderRemoteDatasourceProvider),
+);
+
+class OrderRepositoryImpl implements OrderRepository {
+ OrderRepositoryImpl(this._remote);
+ final OrderRemoteDatasource _remote;
+
+ @override
+ Future> getOrders({int page = 1}) async {
+ final models = await _remote.getOrders(page: page);
+ return models.map((m) => m.toEntity()).toList();
+ }
+}
+```
+
+**Step 3:Presentation 层**
+
+```dart
+// lib/features/order/presentation/notifiers/order_notifier.dart
+@freezed
+sealed class OrderState with _$OrderState {
+ const factory OrderState.initial() = _Initial;
+ const factory OrderState.loading() = _Loading;
+ const factory OrderState.loaded(List orders) = _Loaded;
+ const factory OrderState.error(String message) = _Error;
+}
+
+@riverpod
+class OrderNotifier extends _$OrderNotifier {
+ @override
+ OrderState build() => const OrderState.initial();
+
+ Future load() async {
+ state = const OrderState.loading();
+ try {
+ final orders = await ref.read(orderRepositoryProvider).getOrders();
+ state = OrderState.loaded(orders);
+ } on Object catch (e, st) {
+ final failure = ExceptionMapper().fromUnknown(e, st);
+ state = OrderState.error(failure.message);
+ }
+ }
+}
+```
+
+**Step 4:注册路由**
+
+```dart
+// lib/core/router/routes.dart 新增
+@TypedGoRoute(path: '/orders')
+class OrderRoute extends GoRouteData with $OrderRoute {
+ const OrderRoute();
+ @override
+ Widget build(BuildContext context, GoRouterState state) => const OrderPage();
+}
+```
+
+**Step 5:代码生成**
+
+```bash
+make gen # 生成 order_model.g.dart / order_notifier.g.dart 等
+```
+
+---
+
+### 5.3 常见坑与最佳实践
+
+| 坑 | 原因 | 正确做法 |
+|----|------|---------|
+| `initState` 里 `ref.watch` 后立即报错 | `initState` 时 Widget 还未 build,Provider 依赖图未稳定 | 用 `addPostFrameCallback` 延迟到 build 后 |
+| Drift 后台 Isolate 不生效(数据明文)| `isolateSetup` 传了 lambda 而非顶层函数 | 必须是**顶层函数**(定义在类外层)|
+| `context.go('/login')` 在 `async` 后崩溃 | 异步操作后 Widget 已卸载 | `await` 后加 `if (!mounted) return;` |
+| `ref.watch` 在非 `build` 方法调用 | `watch` 只在 `build` 里有效 | 事件回调用 `ref.read`,监听用 `ref.listen` |
+| `print()` 被 Lint 报错 | `very_good_analysis` 禁止 `print` | 用 `appTalker.info/warning/error()` |
+| `Navigator.push()` 破坏路由栈 | GoRouter 管理整个导航栈 | 用 `XxxRoute().go(context)` |
+| 手动修改 `.g.dart` 文件被覆盖 | `make gen` 覆盖所有生成文件 | 只修改手写源文件,重新 `make gen` |
+| `ApiPaths` 中路径为空字符串 | 脚手架占位,未配置 | 接手时填入真实 API 路径 |
+
+---
+
+> **下一步**:阅读 [docs/architecture.md](architecture.md) 了解整体架构决策,
+> 阅读 [docs/setup-project.md](setup-project.md) 完成新项目配置。