Template
P0 代码修复:删除 imSdkAppId、清除内网 IP 与原项目域名、修正 USE_MOCK 默认值 P1 CLAUDE.md 重组:零容忍规则前置,新增 dart-defines 字段对照表 P2 文档优化:先跑再配的 setup 流程,新增 architecture.md 架构图
8.9 KiB
8.9 KiB
架构概览
整体分层
┌─────────────────────────────────────────────────┐
│ 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 风格):
{
"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