# 架构概览 > **阅读提示**:每节先有 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 | --- ## 整体分层 ``` ┌─────────────────────────────────────────────────┐ │ Presentation 层 │ │ ConsumerWidget / ConsumerStatefulWidget │ │ ref.watch(provider) → UI 响应式更新 │ │ ref.listen(provider) → 副作用(Toast / 导航) │ └──────────────┬──────────────────────────────────┘ │ @riverpod Notifier ┌──────────────▼──────────────────────────────────┐ │ Domain 层 │ │ abstract interface Repository │ │ @freezed Entity(纯 Dart,无框架依赖) │ └──────────────┬──────────────────────────────────┘ │ @riverpod impl ┌──────────────▼──────────────────────────────────┐ │ Data 层 │ │ @riverpod RemoteDatasource(Dio) │ │ @riverpod RepositoryImpl(组合 Dio + Drift) │ │ @freezed Model(+ fromJson / toEntity) │ └──────────────┬──────────────────────────────────┘ │ ┌───────┴───────┐ ▼ ▼ ┌─────────┐ ┌──────────┐ │ Dio 网络│ │ Drift DB│ │ 7拦截器 │ │ SQLCipher│ └─────────┘ └──────────┘ ``` ```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) ``` App 启动 │ ▼ CrashReporter.preInit() # 准备崩溃写入路径 │ ▼ CrashReporter.consumePending() # 读上次崩溃文件(同步) │ ▼ SentrySetup.init() # 包裹 runApp(DSN 空则直接 runApp) │ ▼ AppDatabase.open() # AES-256 解锁 SQLite │ ▼ ProviderScope(注入 DB + pendingCrash) │ ▼ CrashReporter.installHooks() # 接管 FlutterError + Zone 异常 │ ▼ authStatusProvider._bootstrap() # 异步读 SecureStorage.getToken() │ ├── token 非空 → markLoggedIn() │ └── token 为空 → markLoggedOut() ▼ GoRouter.redirect() # 监听 authStatus.listenable(ValueNotifier) │ ├── 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
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 拦截器顺序固定) ``` 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()
业务代码发起请求"]) 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 的拦截层。 --- ## 错误传播链 ``` 网络/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["异常来源
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 模式) 解决并发场景下多个 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()
_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) ``` UsersTable(userId 主键) ├── 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["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 格式 脚手架假设后端使用统一 JSON 响应包装(sa-token 风格): ```json { "code": "00000", // 成功码(ResponseCode.success = "00000") "msg": "success", "data": { ... } // 业务数据(可为 null) } ``` `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` --- ## 主题系统 ``` 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
'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。