Files
flutter-template/docs/architecture.md
T
SkyJourney b8b63282ee fix: 清除 platform-flutter 残留代码,重构文档与开发者体验
P0 代码修复:删除 imSdkAppId、清除内网 IP 与原项目域名、修正 USE_MOCK 默认值
P1 CLAUDE.md 重组:零容忍规则前置,新增 dart-defines 字段对照表
P2 文档优化:先跑再配的 setup 流程,新增 architecture.md 架构图
2026-05-14 13:21:06 +08:00

8.9 KiB
Raw Blame History

架构概览


整体分层

┌─────────────────────────────────────────────────┐
│                  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│
  └─────────┘    └──────────┘

认证流程(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

网络请求链(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 包零日志)

错误传播链

网络/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 展示 + 自动写日志)

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 重放请求

数据库表结构(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

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


API Envelope 格式

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

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

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

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