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

232 lines
8.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 架构概览
---
## 整体分层
```
┌─────────────────────────────────────────────────┐
│ 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 不存 DB**accessToken 的唯一真源是 SecureStorageKeychain / EncryptedSharedPrefs)。
> UsersTable.refreshToken 仅作可观测性镜像,TokenRefreshInterceptor 始终从 SecureStorage 读取。
---
## API Envelope 格式
脚手架假设后端使用统一 JSON 响应包装(sa-token 风格):
```json
{
"code": "00000", // 成功码(ResponseCode.success = "00000"
"msg": "success",
"data": { ... } // 业务数据(可为 null
}
```
`parseEnvelope<T>(body, fromJsonT)` 解析此结构;`apiResp.unwrapVoid()` 用于无数据响应。
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
```