Template
P0 代码修复:删除 imSdkAppId、清除内网 IP 与原项目域名、修正 USE_MOCK 默认值 P1 CLAUDE.md 重组:零容忍规则前置,新增 dart-defines 字段对照表 P2 文档优化:先跑再配的 setup 流程,新增 architecture.md 架构图
232 lines
8.9 KiB
Markdown
232 lines
8.9 KiB
Markdown
# 架构概览
|
||
|
||
---
|
||
|
||
## 整体分层
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────┐
|
||
│ 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│
|
||
└─────────┘ └──────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 认证流程(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)
|
||
```
|
||
|
||
---
|
||
|
||
## 网络请求链(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)
|
||
|
||
```
|
||
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
|
||
```
|
||
|
||
> **token 不存 DB**:accessToken 的唯一真源是 SecureStorage(Keychain / 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
|
||
```
|