Template
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 实体 <T> → «T»
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5cdcfa152a
commit
f194ac7d3f
@@ -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<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)
|
||||
@@ -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<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`;NavigationStack(iOS 16+)的 `NavigationPath` 类似 GoRouter 的声明式路由栈。
|
||||
|
||||
---
|
||||
|
||||
## 网络请求链(7 拦截器顺序固定)
|
||||
@@ -109,6 +265,46 @@ Dio.request()
|
||||
否则 → 无操作(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] TokenRefreshInterceptor(onError)<br/>🔄 401 自动刷新<br/>业务码分类 → 不消耗非 token 类错误<br/>Completer 互斥防并发重复消耗"]
|
||||
I4["[4] RetryInterceptor(onError)<br/>🔁 指数退避重试<br/>408/429/5xx → 最多3次<br/>200ms→400ms→800ms"]
|
||||
I5["[5] ErrorInterceptor(onError)<br/>🗺️ 异常映射<br/>DioException → sealed Failure<br/>写入 err.error 供下游读取"]
|
||||
I6["[6] AuthLogoutInterceptor(onError)<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 的拦截层。
|
||||
|
||||
---
|
||||
|
||||
## 错误传播链
|
||||
@@ -130,6 +326,47 @@ Dio.request()
|
||||
└─→ 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["ErrorLogger(fire-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 模式)
|
||||
@@ -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<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)
|
||||
@@ -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]<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 的唯一真源是 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<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`
|
||||
@@ -229,3 +567,154 @@ 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/>→ ThemeData(light)"]
|
||||
AT_D["AppTheme.dark(scheme)<br/>→ ThemeData(dark)"]
|
||||
|
||||
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。
|
||||
|
||||
Reference in New Issue
Block a user