# 架构概览 --- ## 整体分层 ``` ┌─────────────────────────────────────────────────┐ │ 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(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 ```