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

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

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

32 KiB
Raw Blame History

架构概览

阅读提示:每节先有 ASCII 快速参考图(终端/纯文本友好),后有 Mermaid 深度图(GitHub / GitLab / Obsidian 渲染)。


架构设计原则

在阅读具体架构图前,先理解这三条原则——它们解释了"为什么这样设计",而非"是什么"。

依赖倒置(DIP

核心思想:高层模块(Presentation)依赖抽象(abstract interface Repository),低层模块(Data)实现抽象。依赖箭头方向与控制流方向相反。

Presentation ──→ Domain(接口)←── Data(实现)
                      ↑
             [稳定,不变,纯 Dart]

实际效果:

  • AuthNotifier 持有 AuthRepository(接口),不知道 AuthRepositoryImpl 的存在
  • 替换网络层(Dio → GraphQL)只改 Data 层,DomainPresentation 零修改
  • 单元测试直接 Mock 接口,无需真实网络或数据库
平台 对应实现
Android MVVM + RepositoryGoogle AAC);ViewModel 持有 Repository 接口;Hilt 注入实现类
iOS Protocol-oriented 编程;ViewModel 持有 ProtocolSwiftUI @EnvironmentObject 或 Swinject 注入实现

单一真源(SSOT

每份数据只有一个权威来源,其他位置只能读取或镜像:

数据 唯一真源 镜像位置
accessToken SecureStorageKeychain / EncryptedPrefs 无(HTTP Header 是临时注入)
refreshToken SecureStorage UsersTable.refreshToken(只读可观测)
认证状态 authStatusProviderValueNotifier GoRouter redirect 函数
主题配色 SharedPreferences theme_scheme ThemeNotifier.state(内存缓存)
错误记录 Drift error_logs Sentry 云端(异步上报)
平台 对应实现
Android StateFlow / LiveData 作为 UI 状态真源;Repository 作为数据真源;DataStore 持久化配置
iOS @Published / @State 作为 UI 真源;@AppStorage / UserDefaults 持久化;Core Data 管理本地结构化数据

关注点分离(SoC

三层之间严格禁止跨层直接依赖:

知道什么 不知道什么
Domain 业务规则、实体结构、接口契约 JSON 字段名、HTTP 状态码、Widget
Data JSON 反序列化、网络/DB 调用方式 Widget 渲染、路由逻辑
Presentation UI 状态、用户交互 snake_case 字段、SQL 语法

违反后的典型故障Presentation 直接调用 UsersDao(跨越 Data 层)→ 缓存逻辑散落各处 → 离线状态不一致 → 用户看到"幽灵"数据。

平台 对应实现
Android Jetpack Compose View 只消费 StateFlow,不直接调用 RetrofitViewModel 协调 Repository
iOS SwiftUI View 只消费 @PublishedViewModel 协调 ServiceService 封装 URLSession / Core Data

整体分层

┌─────────────────────────────────────────────────┐
│                  Presentation 层                 │
│  ConsumerWidget / ConsumerStatefulWidget         │
│  ref.watch(provider) → UI 响应式更新             │
│  ref.listen(provider) → 副作用(Toast / 导航)  │
└──────────────┬──────────────────────────────────┘
               │ @riverpod Notifier
┌──────────────▼──────────────────────────────────┐
│                  Domain 层                       │
│  abstract interface Repository                   │
│  @freezed Entity(纯 Dart,无框架依赖)           │
└──────────────┬──────────────────────────────────┘
               │ @riverpod impl
┌──────────────▼──────────────────────────────────┐
│                  Data 层                         │
│  @riverpod RemoteDatasourceDio               │
│  @riverpod RepositoryImpl(组合 Dio + Drift    │
│  @freezed Model+ fromJson / toEntity         │
└──────────────┬──────────────────────────────────┘
               │
       ┌───────┴───────┐
       ▼               ▼
  ┌─────────┐    ┌──────────┐
  │  Dio 网络│    │  Drift DB│
  │ 7拦截器  │    │ SQLCipher│
  └─────────┘    └──────────┘
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 层零依赖UserEntityAuthRepository 只依赖 Dart SDK,不 import dio/drift/flutter_riverpod。这让 Domain 层可在服务端 Dart CLI 复用,也便于单元测试(无需 Mock 框架)。
  • toEntity() 桥接Model.toEntity() 是跨越 Data/Domain 边界的唯一通道,防止 JSON 字段名称(snake_case)泄露到 Domain 层。
  • Notifier 不持有 ModelNotifier 的 state 类型是 Entitysealed State<Entity>,不是 Model,确保 UI 层与序列化格式解耦。

📱 原生对比

  • AndroidViewModelPresentation)→ RepositoryDomain 接口)→ Room DAO / Retrofit ServiceData 实现)是 Google AAC 推荐分层。Hilt 负责依赖注入,对应 Riverpod 的 Provider 角色;Flow<UiState> 对应 AsyncNotifierstate
  • iOSViewSwiftUI)→ @ObservableObject ViewModelPresentation)→ Protocol RepositoryDomain)→ URLSession / Core DataData)。@EnvironmentObject / @StateObject 注入 ViewModel,对应 ConsumerWidget + ref.watchCombine Publisher 对应 Dart Stream

认证流程(Auth Flow

App 启动
    │
    ▼
CrashReporter.preInit()          # 准备崩溃写入路径
    │
    ▼
CrashReporter.consumePending()   # 读上次崩溃文件(同步)
    │
    ▼
SentrySetup.init()               # 包裹 runAppDSN 空则直接 runApp
    │
    ▼
AppDatabase.open()               # AES-256 解锁 SQLite
    │
    ▼
ProviderScope(注入 DB + pendingCrash
    │
    ▼
CrashReporter.installHooks()     # 接管 FlutterError + Zone 异常
    │
    ▼
authStatusProvider._bootstrap()  # 异步读 SecureStorage.getToken()
    │                              ├── token 非空 → markLoggedIn()
    │                              └── token 为空 → markLoggedOut()
    ▼
GoRouter.redirect()              # 监听 authStatus.listenableValueNotifier
    │
    ├── loggedIn == null  → 不跳转(等待 bootstrap 完成)
    ├── loggedIn == false → push /login
    └── loggedIn == true  → push /home(若当前在 /login
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 ProviderGoRouter 的 refreshListenable 接受 ListenableValueNotifier 实现了它),用 Riverpod Provider 则需要额外适配层,ValueNotifier 更直接。
  • keepAlive: true:认证状态必须全局唯一且永不销毁,避免路由切换时 Provider 被回收导致状态丢失。

📱 原生对比

  • AndroidNavigation Component + AuthManager(单例)控制目的地访问权限;NavController.navigate() 配合 LoginGraph 实现跳转;也可用 Hilt 注入的全局 SessionManager 监听登录状态。Jetpack Compose 下 NavHostroute guard lambda 类似 GoRouter 的 redirect
  • iOSSwiftUI 通过 @EnvironmentObject AuthState + .sheet/.fullScreenCover 控制页面展示;UIKit 下通过 AppCoordinator 切换 rootViewControllerNavigationStackiOS 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 包零日志)
flowchart TD
    REQ(["📤 Dio.request()<br/>业务代码发起请求"])

    subgraph Chain["拦截器链(onRequest 从上到下,onError 从下到上)"]
        direction TB
        I1["[1] CertPinningInterceptor<br/>🔐 TLS 指纹校验<br/>PINNED_FINGERPRINTS 为空 → 跳过<br/>不匹配 → NetworkFailure(badCertificate)"]
        I2["[2] AuthInterceptor<br/>🔑 Token 注入<br/>skip_auth=true → 跳过<br/>否则读 SecureStorage → header[tokenName]=token"]
        I3["[3] TokenRefreshInterceptoronError<br/>🔄 401 自动刷新<br/>业务码分类 → 不消耗非 token 类错误<br/>Completer 互斥防并发重复消耗"]
        I4["[4] RetryInterceptoronError<br/>🔁 指数退避重试<br/>408/429/5xx → 最多3次<br/>200ms→400ms→800ms"]
        I5["[5] ErrorInterceptoronError<br/>🗺️ 异常映射<br/>DioException → sealed Failure<br/>写入 err.error 供下游读取"]
        I6["[6] AuthLogoutInterceptoronError<br/>🚪 强制登出<br/>err.error is AuthFailure → clearAll()<br/>→ markLoggedOut() → GoRouter 跳 /login"]
        I7["[7] LogInterceptor<br/>📋 请求日志<br/>Dev/Internal → TalkerDioLogger<br/>Release → noop(零日志泄漏)"]
    end

    NET(["🌐 服务器"])
    RESP(["📥 Response<br/>成功响应返回业务代码"])

    REQ --> I1 --> I2 --> NET
    NET --> RESP
    NET -- 错误 --> I7 --> I6 --> I5 --> I4 --> I3

    style I1 fill:#E74C3C,color:#fff
    style I2 fill:#3498DB,color:#fff
    style I3 fill:#9B59B6,color:#fff
    style I4 fill:#F39C12,color:#fff
    style I5 fill:#1ABC9C,color:#fff
    style I6 fill:#E67E22,color:#fff
    style I7 fill:#7F8C8D,color:#fff

设计决策

  • 顺序即语义ErrorInterceptor[5])必须在 AuthLogoutInterceptor[6])之前,因为 AuthLogout 通过 e.error is AuthFailure 判断类型,而 e.error 只有经过 ErrorInterceptor 映射后才是 AuthFailure。调换顺序会导致自动登出失效。
  • Options.extra 信号机制:拦截器间通过 RequestOptions.extra Map 传递信号(skip_auth_token_refreshed_auth_not_refreshable),避免拦截器之间直接引用,保持松耦合。
  • onError 逆序Dio 的 onError 按逆序执行拦截器(与 onRequest 方向相反),这是 Dio 框架设计,本项目拦截器排列已考虑此特性。

📱 原生对比

  • AndroidOkHttp 的 Interceptor 接口(chain.proceed(request))构成完全相同的链式拦截模型。addInterceptor()Application Interceptor,全程可见)vs addNetworkInterceptor()Network Interceptor,仅在实际网络层),对应 Dio 的 onRequest/onError 触发阶段。Retrofit 本身没有拦截器,依赖 OkHttp 层。
  • iOSAlamofire 的 RequestInterceptoradapt 注入 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 展示 + 自动写日志)
flowchart LR
    EX["异常来源<br/>DioException<br/>DriftException<br/>RsaEncryptionException<br/>其他 Exception"]

    EM["ExceptionMapper<br/>.fromUnknown(e, st)"]

    subgraph SF["sealed Failure(编译期强制穷举)"]
        NF["NetworkFailure<br/>kind: timeout/noNetwork<br/>/badCertificate/cancelled/unknown"]
        AF["AuthFailure<br/>kind: unauthorized/forbidden<br/>/refreshFailed/cryptoFailed"]
        SVF["ServerFailure<br/>code: A0400/A0500...<br/>statusCode: 4xx/5xx"]
        CF["CacheFailure<br/>Drift/SQLCipher/IO"]
        UF["UnknownFailure<br/>兜底(不应频繁出现)"]
    end

    UI["UI 层<br/>failure.userMessage<br/>→ AppToast/ErrorView"]
    LOG["ErrorLoggerfire-and-forget<br/>failure.message(技术细节)<br/>→ Drift error_logs"]
    SEN["Sentry<br/>failure.toString()<br/>→ 云端错误聚合"]

    EX --> EM
    EM --> NF & AF & SVF & CF & UF

    NF & AF & SVF & CF & UF --> UI
    NF & AF & SVF & CF & UF --> LOG
    UF --> SEN

    style NF fill:#E74C3C,color:#fff
    style AF fill:#9B59B6,color:#fff
    style SVF fill:#E67E22,color:#fff
    style CF fill:#F39C12,color:#fff
    style UF fill:#7F8C8D,color:#fff

设计决策

  • sealed 强制穷举switch (failure) 不加 default 分支,当新增 Failure 子类时,所有 switch 点在编译期报错,不会有遗漏处理的情况。
  • userMessagemessage 分离message 含技术细节(适合 Sentry/Talker),userMessage 是用户可读文案(适合 Toast)。ServerFailure 对 5xx 额外屏蔽后端堆栈,防止信息泄露。
  • ErrorLogger fire-and-forgetunawaited(ErrorLogger.log(failure)),不阻塞主流程,本地存储的错误日志作为 Sentry 的补充(离线场景)。

📱 原生对比

  • AndroidKotlin sealed class Result<out T> 或标准库 kotlin.Result<T> 承担相同角色。when(result) 对 sealed class 强制穷举,与 Dart switch(failure) 语义完全等价。NetworkResult / ApiResponse 等封装类是 Android 社区常见模式(如 sandwich 库)。
  • iOSSwift enum NetworkError: Error { case timeout; case unauthorized; ... }Result<T, MyError> 泛型承担相同角色。Swift switch 对枚举同样强制穷举(无 default 时编译报错)。Swift 的关联值(associated values)对应 Dart sealed class 的子类字段(如 NetworkFailure.kind)。

Token 刷新互斥(Completer 模式)

解决并发场景下多个 401 同时触发 refresh 消耗 RefreshToken 的问题:

请求 A ──401──▶ _refreshing == null
                  │ 创建 Completer,赋值 _refreshing
                  │ 调用 _runRefresh()
                  │         │
请求 B ──401──▶   │ _refreshing != null
                  │ await _refreshing.future(阻塞等待)
                  │         │
请求 C ──401──▶   │ _refreshing != null
                  │ await _refreshing.future(阻塞等待)
                  │         │
                  │ refresh 完成
                  │ _refreshing = null      ← 先清空,再 complete
                  │ completer.complete(true)
                  │         │
                  └─────────┴──▶ B、C 收到结果,用新 token 重放请求
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 = nullcompleter.complete(true)

如果先 complete 再清空 _refreshing,B/C 醒来后可能有新的 401 进入,看到 _refreshing != null(旧 completer 已完成),await 会立即返回旧的 true,跳过新一轮刷新。顺序颠倒虽然概率低,但属于 race condition。先清空确保新 401 进入时看到 null,走正常刷新路径。

📱 原生对比

  • AndroidOkHttp Authenticator 接口处理 401,但官方不处理并发互斥——需要配合 @Synchronized 或 Kotlin Coroutines Mutexkotlinx.coroutines)手动实现。mutex.withLock { ... } 对应 Completer 的作用。也可用 SharedFlowreplay=0+ flatMapLatest 组合实现 "多个订阅者共享同一次刷新结果"的语义。
  • iOSSwift actor 天然提供 Actor Isolation(串行访问保障),将 refresh 逻辑放入 actor TokenRefresher 中,并发调用自动排队,无需手动锁。Alamofire 的 RequestInterceptor.retry() 内部已集成基于 DispatchSemaphore 的互斥机制。

数据库表结构(schemaVersion 1

UsersTableuserId 主键)
  ├── userId      TEXT  NOT NULL  PK
  ├── username    TEXT  nullable
  ├── realName    TEXT  nullable
  ├── phone       TEXT  nullable
  ├── avatar      TEXT  nullable
  ├── gender      INT   nullable
  ├── age         INT   nullable
  ├── refreshToken TEXT nullable   ← 镜像,SoT 在 SecureStorage
  ├── email       TEXT  nullable
  ├── birthday    DATETIME nullable
  ├── employeeNo  TEXT  nullable
  ├── company     TEXT  nullable
  ├── department  TEXT  nullable
  └── [SyncColumns: syncStatus, localUpdatedAt, serverUpdatedAt, conflictPayload]

ErrorLogsTable(自增 id
  ├── id           INT   PK AUTOINCREMENT
  ├── kind         TEXT  NOT NULL  ← 'network'|'server'|'auth'|'cache'|'unknown'
  ├── message      TEXT  NOT NULL  ← 技术细节(Sentry / Talker 用)
  ├── displayMessage TEXT NOT NULL ← 用户可读文案
  ├── code         TEXT  nullable  ← 业务错误码(ServerFailure
  ├── statusCode   INT   nullable  ← HTTP 状态码
  ├── occurredAt   DATETIME NOT NULL
  ├── reported     BOOL  DEFAULT false
  ├── deviceModel  TEXT  nullable
  ├── osVersion    TEXT  nullable
  ├── appVersion   TEXT  nullable
  └── appBuild     TEXT  nullable
flowchart TD
    subgraph DB["AppDatabaseSQLCipher AES-256"]
        subgraph UT["UsersTable"]
            U1["userId TEXT PK"]
            U2["username TEXT?"]
            U3["phone TEXT?"]
            U4["avatar TEXT?"]
            U5["refreshToken TEXT? ← 只读镜像"]
            U6["[SyncColumns]<br/>syncStatus / localUpdatedAt<br/>serverUpdatedAt / conflictPayload"]
        end

        subgraph EL["ErrorLogsTable"]
            E1["id INT PK AUTOINCREMENT"]
            E2["kind TEXT<br/>'network'|'server'|'auth'<br/>'cache'|'unknown'"]
            E3["message TEXT(技术细节)"]
            E4["displayMessage TEXT(用户文案)"]
            E5["occurredAt DATETIME"]
            E6["reported BOOL DEFAULT false"]
            E7["deviceModel / osVersion<br/>appVersion / appBuild"]
        end
    end

    SS["SecureStorage<br/>Keychain / EncryptedPrefs<br/>Token 真源"]
    EL_Reporter["ErrorLogger<br/>写入 error_logs<br/>清理 > 100 条旧记录"]
    Batch["批量上报<br/>error_report_datasource<br/>标记 reported=true"]

    SS -->|"token/refreshToken 唯一真源"| UT
    UT -->|"refreshToken 镜像(可观测性)"| SS
    EL_Reporter --> EL
    EL --> Batch

    style DB fill:#F0F3F4
    style SS fill:#E8F8F5

token 不存 DBaccessToken 的唯一真源是 SecureStorageKeychain / EncryptedSharedPrefs)。
UsersTable.refreshToken 仅作可观测性镜像,TokenRefreshInterceptor 始终从 SecureStorage 读取。

设计决策

  • Token 不存 DB 的原因SQLCipher 密钥存在 SecureStorage,若 Token 也存 DB,则 Token 的安全性等同于 DB 密钥的安全性——循环依赖。SecureStorage 是操作系统级安全保障(Keychain Enclave / Android KeyStore TEE),比应用层数据库更可信。
  • refreshToken DB 镜像:只用于调试和可观测性(如查看用户登录状态),不参与认证逻辑,仅在 AuthRepositoryImpl._persistUser() 时顺带写入。

📱 原生对比

  • AndroidRoomJetpack)是官方 ORM 层,@Database/@Dao/@Entity 注解对应 Drift 的 @DriftDatabase/@DriftAccessor/表定义。Room 默认禁止主线程 DB 操作(强制 Coroutines + Dispatcher.IO),对应 Drift 的后台 Isolate。加密方案:SQLCipher for Androidzetetic/android-database-sqlcipher)接入方式与本项目高度一致;或使用 Room + SQLCipher 官方文档。
  • iOSCore Data + NSPersistentContainer 是官方 ORM 方案;newBackgroundContext() 对应 Drift 后台 Isolate(禁止主线程写操作)。轻量替代:GRDB.swift / SQLite.swift。加密:SQLCipher for iOSzetetic)或 Realm 内置加密配置(Realm.Configuration.encryptionKey)。NSManagedObject 对应 Drift 生成的 Data Class@DataClassName)。

API Envelope 格式

脚手架假设后端使用统一 JSON 响应包装(sa-token 风格):

{
  "code": "00000",          // 成功码(ResponseCode.success = "00000"
  "msg":  "success",
  "data": { ... }           // 业务数据(可为 null
}

parseEnvelope<T>(body, fromJsonT) 解析此结构;apiResp.unwrapVoid() 用于无数据响应。

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):

  • A0401unauthorized
  • 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
flowchart LR
    SP["SharedPreferences<br/>'theme_scheme': 'blue'<br/>'theme_mode': 'system'"]

    TN["ThemeNotifier<br/>@Riverpod keepAlive<br/>state: AppColorScheme"]
    TMN["ThemeModeNotifier<br/>@Riverpod keepAlive<br/>state: ThemeMode"]

    AT_L["AppTheme.light(scheme)<br/>→ ThemeDatalight"]
    AT_D["AppTheme.dark(scheme)<br/>→ ThemeDatadark"]

    APP["MaterialApp.router<br/>theme: light<br/>darkTheme: dark<br/>themeMode: ThemeMode.system"]

    SP -->|"启动读取"| TN
    SP -->|"启动读取"| TMN
    TN -->|"setScheme() → 写入"| SP
    TMN -->|"setMode() → 写入"| SP
    TN --> AT_L & AT_D
    AT_L & AT_D --> APP
    TMN -->|"系统/手动切换"| APP

5 套色板切换(运行时热切换,无需重启):

// 任意页面
ref.read(themeNotifierProvider.notifier).setScheme(AppColorScheme.red);
// → SharedPreferences 持久化
// → themeProvider 通知
// → App 重建(只有 App widget,成本极低)
// → MaterialApp.router 使用新 ThemeData

认证模块 Clean Architecture 类图

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

数据库启动与密钥派生流程

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。