From b8b63282eeca492f4859a08d8bebe2974590c32a Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Thu, 14 May 2026 13:21:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=B8=85=E9=99=A4=20platform-flutter=20?= =?UTF-8?q?=E6=AE=8B=E7=95=99=E4=BB=A3=E7=A0=81=EF=BC=8C=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E4=B8=8E=E5=BC=80=E5=8F=91=E8=80=85=E4=BD=93?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0 代码修复:删除 imSdkAppId、清除内网 IP 与原项目域名、修正 USE_MOCK 默认值 P1 CLAUDE.md 重组:零容忍规则前置,新增 dart-defines 字段对照表 P2 文档优化:先跑再配的 setup 流程,新增 architecture.md 架构图 --- CLAUDE.md | 306 +++++++++++----------- README.md | 16 +- dart-defines/dev.json | 5 +- dart-defines/prod.json.template | 5 +- dart-defines/staging.json | 3 +- docs/architecture.md | 231 ++++++++++++++++ docs/setup-project.md | 275 ++++++++++--------- lib/core/config/api_config.dart | 45 ++-- lib/core/config/env.dart | 114 +++----- lib/core/error/exception_mapper.dart | 2 +- lib/core/error/failures.dart | 2 +- lib/core/observability/sentry_setup.dart | 8 +- lib/core/observability/talker_setup.dart | 2 +- lib/core/storage/app_database.dart | 2 +- lib/core/storage/db_key_provider.dart | 2 +- lib/core/storage/tables/sync_columns.dart | 2 +- lib/core/sync/sync_service.dart | 2 +- 17 files changed, 605 insertions(+), 417 deletions(-) create mode 100644 docs/architecture.md diff --git a/CLAUDE.md b/CLAUDE.md index e578741..68e28de 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,25 +1,82 @@ # CLAUDE.md — Flutter 企业级脚手架(sunny_mochi) -> 本文件为 Claude Code 提供项目上下文。**所有 AI 辅助开发会话必须首先读取本文件**, -> 再按需加载 `docs/` 下的补充文档。 +> 本文件为 Claude Code 提供项目上下文。**每次新会话首先读取本文件**, +> 需要时再按需加载 `docs/` 下的补充文档。 + +--- + +## ⚡ 30 秒上手(开箱即用验证) + +```bash +flutter pub get && make gen && make gen-i18n +# dev.json 默认 USE_MOCK=true,无需后端即可运行: +make run-dev +# 期望结果:App 启动 → 显示登录页 → 右上角可进 Dev Panel(Talker 日志) +``` + +**这是脚手架,不是完整 App**。以下内容"需要业务项目填写": +- `lib/core/config/api_paths.dart` — 所有路径均为空字符串 +- `lib/core/config/api_config.dart` — 三套 baseUrl 均为 localhost 占位符 +- `dart-defines/*.json` — RSA 公钥 / Sentry DSN 均为占位符 --- ## 项目身份信息 -> **以下带 `TODO` 的字段由业务项目接手时填入,脚手架默认保留占位符。** +> **业务项目接手后,将下表 TODO 字段替换为真实值,AI 才能给出精准建议。** -| 字段 | 当前值(脚手架默认) | 说明 | +| 字段 | 当前值(脚手架占位)| 说明 | |------|-----------------|------| | 项目名称 | sunny_mochi | TODO: 替换为实际项目名 | -| Application ID (Android) | com.example.sunny_mochi | TODO: 如 com.company.projectname | -| Bundle ID (iOS) | com.example.sunny-mochi | TODO: 与 Android 对应 | -| API Base URL (dev) | http://localhost:8080 | TODO: 填入 `dart-defines/dev.json` | -| API Base URL (staging) | https://staging.example.com | TODO: 填入 `dart-defines/staging.json` | -| API Base URL (prod) | https://api.example.com | TODO: 填入 `dart-defines/prod.json` | -| 后端鉴权框架 | sa-token(动态 header tokenName) | TODO: 若使用 Bearer/JWT 修改 auth_interceptor | -| Sentry DSN | 空(未启用) | TODO: 填入 `dart-defines/prod.json` | -| RSA 公钥 | REPLACE_WITH_RSA_PUBLIC_KEY | TODO: 填入三个 dart-defines JSON | +| Application ID | com.example.sunny_mochi | TODO: 如 com.company.appname | +| API Base URL (dev) | http://localhost:8080 | TODO: 改 api_config.dart + dev.json | +| API Base URL (staging) | https://staging.your-domain.com | TODO: 改 api_config.dart | +| API Base URL (prod) | https://api.your-domain.com | TODO: 改 api_config.dart | +| 后端鉴权框架 | sa-token(动态 tokenName header)| TODO: 若用 Bearer JWT 改 auth_interceptor | +| Sentry DSN | 空(不上报)| TODO: prod.json | +| RSA 公钥 | 空(加密跳过)| TODO: 三个 dart-defines JSON | + +--- + +## 零容忍规则(必须遵守,不可商量) + +| 规则 | ✅ 正确 | ❌ 禁止 | +|------|---------|---------| +| API 路径 | `ApiPaths.xxx` 常量 | 字符串字面量散落代码中 | +| 错误处理 | `ExceptionMapper().fromUnknown(e, st)` | 裸 `catch (e)` 直接 `toString()` 显示 | +| Token 存储 | `SecureStorage`(Keychain / EncryptedPrefs)| 普通 `SharedPreferences` | +| 数据库加密 | SQLCipher(默认,禁止替换)| 无加密 sqflite | +| 日志 | `appTalker.info/warning/error()` | `print()` / `debugPrint()` | +| 路由跳转 | `XxxRoute().go(context)` | `Navigator.push()` | +| 状态管理 | `@riverpod` / `@Riverpod(keepAlive: true)` | `setState` 跨组件 / Provider 包 | +| Riverpod provider 命名 | 生成名(`AuthNotifier` → `authProvider`)| 手写 `authNotifierProvider` | +| 生成文件 | 只读,运行 `make gen` 刷新 | 手动修改 `.g.dart` / `.freezed.dart` | +| Domain 层依赖 | 纯 Dart,无 Flutter/Drift/Dio | domain 层 `import 'package:dio'` | + +--- + +## 禁止修改的文件(架构决策,除非全面评估影响) + +``` +lib/core/network/dio_client.dart # 7 拦截器顺序固定,顺序即语义 +lib/core/storage/db_key_provider.dart # AES-256 密钥派生策略,改动会破坏已有 DB +lib/core/error/failures.dart # 全局错误分类,改 sealed 影响所有 switch +lib/core/network/response_code.dart # 业务码分类,影响 TokenRefresh 互斥逻辑 +``` + +--- + +## dart-defines 字段完整对照表 + +| JSON 键 | `Env.dart` 读取字段 | 类型 | 说明 | +|---------|-------------------|------|------| +| `ENV` | `Env.name` | String | `dev` / `test` / `release` | +| `API_BASE_URL` | `Env.apiBaseUrlOverride` | String | 留空则用 api_config.dart 中的固定 URL | +| `USE_MOCK` | `Env.useMock` | bool | `true` = 走 MockAdapter,无需后端 | +| `INTERNAL_BUILD` | `Env.isInternalBuild` | bool | `true` = 显示 Dev Panel 入口 | +| `SENTRY_DSN` | `Env.sentryDsn` | String | 留空时 Sentry 自动跳过 | +| `PINNED_FINGERPRINTS` | `Env.pinnedFingerprints` | List | 逗号分隔的 SHA-256 指纹,留空跳过 SSL 绑定 | +| `RSA_PUBLIC_KEY` | `Env.rsaPublicKey` | String | DER-SPKI Base64,留空时密码不加密传输 | --- @@ -27,208 +84,146 @@ | 分类 | 技术 | 版本 | |------|------|------| -| Flutter SDK | Flutter | ≥ 3.41.0 | -| 状态管理 | Riverpod + riverpod_annotation | ^3.3.0 / ^4.0.0 | -| 路由 | GoRouter + go_router_builder | ^17.0.0 / ^4.3.0 | +| 状态管理 | flutter_riverpod + riverpod_annotation | ^3.3.0 / ^4.0.0 | +| 路由 | go_router + go_router_builder | ^17.0.0 / ^4.3.0 | | 网络 | Dio | ^5.9.0 | -| 本地数据库 | Drift + SQLCipher | 2.31.0(锁定)| +| 本地数据库 | Drift + SQLCipher | **2.31.0(锁定)** | | 安全存储 | flutter_secure_storage | ^10.0.0 | -| 序列化 | Freezed + json_serializable | ^3.x / 6.13.0(锁定)| +| 序列化 | Freezed + json_serializable | ^3.x / **6.13.0(锁定)** | | 日志 | Talker + talker_flutter | ^5.x | -| 错误追踪 | Sentry Flutter | ^9.0.0 | -| i18n | Slang | ^4.0.0 | +| 错误追踪 | sentry_flutter | ^9.0.0 | +| i18n | Slang(base_locale: zh-CN)| ^4.0.0 | | UI 自适应 | flutter_screenutil(基准 375×812)| ^5.9.0 | -| 图片缓存 | cached_network_image | ^3.4.0 | -| 字体 | HarmonyOS Sans SC(Regular/Medium/Bold/Black)| GB2312 子集 | -**版本锁定原因(勿随意升级):** -- `drift: 2.31.0` — 2.32+ 需要 analyzer ^10.x,与 riverpod_generator 的 analyzer ^9.x 冲突 -- `json_serializable: 6.13.0` — 6.13.1+ 同样需要 analyzer ^10.x +> `drift: 2.31.0` 和 `json_serializable: 6.13.0` 锁定原因: +> 更新版本需要 analyzer ^10.x,与 riverpod_generator 的 analyzer ^9.x 冲突。**勿升级。** --- -## 目录结构 +## 目录结构与关键文件 ``` lib/ -├── main.dart # 6步启动序列(顺序固定,勿调整) -├── app.dart # AppRoot → ScreenUtilInit → App → MaterialApp.router -├── i18n/ # Slang 翻译(zh-CN 基准 + en 备用) +├── main.dart # 启动序列(固定顺序:preInit→consumePending→SentryInit→installHooks) +├── app.dart # AppRoot → ScreenUtilInit(375×812) → App → MaterialApp.router +├── i18n/ # Slang 翻译(zh-CN 基准 + en 备用) ├── core/ │ ├── config/ -│ │ ├── env.dart # 从 dart-defines 读取环境变量(只读,勿改结构) -│ │ ├── api_config.dart # baseUrl 按 Flavor 决策 -│ │ └── api_paths.dart # ★ 新项目必填:所有 API 路径常量 +│ │ ├── env.dart # ← 读取 dart-defines,只读 +│ │ ├── api_config.dart # ★ 填入三套环境 baseUrl +│ │ └── api_paths.dart # ★ 填入所有 API 路径常量 │ ├── router/ -│ │ ├── app_router.dart # GoRouter provider(keepAlive) -│ │ └── routes.dart # ★ 新项目扩展:添加 @TypedGoRoute +│ │ ├── app_router.dart # GoRouter provider(ref.read + refreshListenable) +│ │ └── routes.dart # ★ 添加业务 @TypedGoRoute(然后 make gen) │ ├── network/ -│ │ ├── dio_client.dart # 7 拦截器注册(顺序固定) -│ │ ├── api_response.dart # 统一 envelope 解析(parseEnvelope / unwrapVoid) -│ │ ├── response_code.dart# 业务状态码(成功=00000,token 类码分类) -│ │ ├── interceptors/ # 7 个拦截器(勿修改拦截顺序) -│ │ └── mock/ # MockAdapter + fixture JSON(dev 调试用) +│ │ ├── dio_client.dart # 7 拦截器(禁止改顺序) +│ │ ├── api_response.dart # envelope 解析(parseEnvelope / unwrapVoid) +│ │ ├── response_code.dart # 业务状态码(成功=00000,token 类码分类) +│ │ ├── interceptors/ # 7 个拦截器(禁止改顺序) +│ │ └── mock/ # USE_MOCK=true 时生效,读 assets/fixtures/ │ ├── storage/ -│ │ ├── app_database.dart # Drift @DriftDatabase(schemaVersion 从 1 开始) -│ │ ├── secure_storage.dart# token / userId / tokenName 的唯一真源 -│ │ ├── db_key_provider.dart# AES-256 密钥派生(勿修改策略) -│ │ ├── tables/ # Drift 表定义(Users + ErrorLogs + 业务表) -│ │ └── daos/ # DAO(只放纯 DB 操作) -│ ├── crash/ # CrashReporter(preInit/consumePending/installHooks) +│ │ ├── app_database.dart # schemaVersion=1,@DriftDatabase([Users, ErrorLogs]) +│ │ ├── secure_storage.dart # token / userId / tokenName 的唯一真源 +│ │ ├── db_key_provider.dart # AES-256 密钥派生(禁止改策略) +│ │ ├── tables/ # ★ 业务项目在此添加 Drift 表 +│ │ └── daos/ # DAO(只放纯 DB 操作) │ ├── error/ -│ │ ├── failures.dart # Sealed Failure 层级(唯一错误分类) -│ │ └── exception_mapper.dart # 异常 → Failure 映射 -│ ├── theme/ # 5 色板 + 深色模式(ThemeNotifier + SharedPreferences) -│ ├── observability/ # Talker(全局 appTalker)+ Sentry(Release 上报) -│ ├── sync/ # Offline-First 同步框架骨架(SyncService) -│ ├── crypto/ # RSA PKCS#1 v1.5(RsaHelper.encrypt) -│ ├── extensions/ # BuildContextX / StringX / NumX / DateTimeX -│ ├── utils/ # Validator / PasswordValidator / SmsCountdownMixin -│ ├── data/ # FreshnessPolicy(缓存新鲜度判断) -│ └── widgets/ # 通用 UI 组件(AppToast / AppButton / EmptyView 等) +│ │ ├── failures.dart # sealed Failure(禁止改结构) +│ │ └── exception_mapper.dart # 异常 → Failure 映射 + Riverpod provider +│ └── widgets/ # AppToast / AppButton / EmptyView / AvatarWidget 等 └── features/ - ├── auth/ # ★ 认证(完整骨架,业务项目修改 LoginPage UI) - ├── dev_panel/ # Talker 面板(Internal 包,勿在 release 中暴露入口) - └── error_report/ # 本地错误日志 + 上报(完整实现) -``` - ---- - -## 开发命令 - -```bash -# 代码生成(修改 Dart 文件后运行) -make gen - -# i18n 生成(修改 JSON 翻译文件后运行) -make gen-i18n - -# 运行 -make run-dev # dev 包(连 dev 服务器,mock 可开) -make run-staging # staging 包(连 test 服务器,release 模式) - -# 构建 -make build-apk-dev # dev APK(快速真机验证) -make build-staging # staging APK(交测试团队) -make build-prod # prod AAB(需 prod.json + key.properties) - -# 质量检查 -flutter analyze # 目标 0 errors(103 条 info 为正常) -flutter test # 全量测试 + ├── auth/ # ★ 修改 LoginPage UI 品牌,业务路由骨架已就绪 + ├── dev_panel/ # Talker 面板(INTERNAL_BUILD=true 时可路由进入) + └── error_report/ # 本地错误日志 + 批量上报(完整实现) ``` --- ## 常用开发范式 -### 新增 Feature +### 新增 Feature(标准目录结构) ``` -lib/features/{feature_name}/ +lib/features/{feature}/ ├── domain/ -│ ├── entities/{name}_entity.dart # @freezed,纯 Dart 字段 -│ └── repositories/{name}_repository.dart # abstract interface +│ ├── entities/{name}_entity.dart # @freezed,纯 Dart +│ └── repositories/{name}_repository.dart # abstract interface ├── data/ -│ ├── models/{name}_model.dart # @freezed + fromJson,含 toEntity() -│ ├── datasources/{name}_remote_datasource.dart # @riverpod,只调 Dio -│ └── repositories/{name}_repository_impl.dart # @riverpod,实现 domain 接口 +│ ├── models/{name}_model.dart # @freezed + fromJson + toEntity() +│ ├── datasources/{name}_remote_datasource.dart # @riverpod,只用 Dio +│ └── repositories/{name}_repository_impl.dart # @riverpod,实现接口 └── presentation/ - ├── notifiers/{name}_notifier.dart # @riverpod class XxxNotifier - └── pages/{name}_page.dart # ConsumerWidget / ConsumerStatefulWidget + ├── notifiers/{name}_notifier.dart # @riverpod class XxxNotifier + └── pages/{name}_page.dart # ConsumerWidget ``` -### 新增路由 - -在 `lib/core/router/routes.dart` 添加(然后运行 `make gen`): +### 新增路由(routes.dart → make gen) ```dart -@TypedGoRoute(path: '/my-new-path') -class MyNewRoute extends GoRouteData with $MyNewRoute { - const MyNewRoute(); - +@TypedGoRoute(path: '/my-path') +class MyRoute extends GoRouteData with $MyRoute { + const MyRoute(); @override - Widget build(BuildContext context, GoRouterState state) => const MyNewPage(); + Widget build(BuildContext context, GoRouterState state) => const MyPage(); } ``` -### 新增 API 路径 - -在 `lib/core/config/api_paths.dart` 添加: +### 标准错误处理 ```dart -// ============== My Feature ============== -static const String myFeatureList = '/api/v1/my-feature/list'; -``` - -### 新增 DB 表 - -1. 在 `lib/core/storage/tables/` 新建表文件 -2. 在 `lib/core/storage/app_database.dart` 的 `@DriftDatabase(tables: [...])` 添加 -3. 递增 `schemaVersion` 并在 `MigrationStrategy.onUpgrade` 中添加迁移 -4. 运行 `make gen` - -### 错误处理标准模式 - -```dart -// Notifier 层:捕获 + 映射 +// Notifier 层 } on Object catch (e, st) { final failure = ExceptionMapper().fromUnknown(e, st); state = MyState.error(failure.message); } -// UI 层:监听 + 显示 +// UI 层 ref.listen(myProvider, (_, next) { if (next is AsyncError) context.showError(ref, next.error!, next.stackTrace!); }); ``` ---- +### 新增 DB 表 -## 零容忍规则(AI 必须遵守) - -| 规则 | 正确 | 禁止 | -|------|------|------| -| API 路径 | `ApiPaths.xxx` 常量 | 字符串字面量散落在代码中 | -| 错误处理 | `ExceptionMapper().fromUnknown(e, st)` | 裸 `catch` 直接 `toString()` 显示 | -| Token 存储 | `SecureStorage`(Keychain/EncryptedPrefs)| 普通 `SharedPreferences` | -| 数据库加密 | SQLCipher(默认) | 无加密 `sqflite` | -| 日志输出 | `appTalker.info/warning/error()` | `print()` / `debugPrint()` | -| 路由跳转 | `XxxRoute().go(context)` / `XxxRoute().push(context)` | `Navigator.push()` | -| 状态管理 | Riverpod `@riverpod` / `@Riverpod(keepAlive: true)` | `setState` 跨组件共享 / `Provider` 包 | -| Riverpod 命名 | 生成 provider 名(`AuthNotifier` → `authProvider`)| 手写 `authNotifierProvider` | -| 代码生成文件 | 只读,不手写 `.g.dart` / `.freezed.dart` | 手动修改生成文件 | -| Domain 层 | 纯 Dart,无 Flutter / Drift / Dio 依赖 | domain 层 import `package:dio` | -| Mock fixture | `assets/fixtures/{group}/{name}.json` | 在代码中硬编码 mock 数据 | -| 注释语言 | 中文(团队约定) | 英文注释(除公共 API doc)| +1. `lib/core/storage/tables/{name}_table.dart` — 定义 `@DataClassName` + 字段 +2. `app_database.dart` — `@DriftDatabase(tables: [..., NewTable])` + 递增 schemaVersion + 添加 onUpgrade 迁移 +3. `lib/core/storage/daos/{name}_dao.dart` — `@DriftAccessor(tables: [NewTable])` +4. 运行 `make gen` --- -## 关键架构决策(勿重议) +## 开发命令 -1. **SQLCipher 不可替换**:企业合规要求,部署后无法无损切换到无加密数据库 -2. **7 拦截器顺序固定**:CertPinning→Auth→TokenRefresh→Retry→Error→AuthLogout→Log,错误处理链依赖此顺序 -3. **TokenRefresh 使用 Completer 互斥**:防止并发 401 重复消耗 RefreshToken,不可改为简单 flag -4. **SecureStorage 是 token 唯一真源**:DB 中的 refreshToken 仅作可观测性镜像 -5. **GoRouter 用 `ref.read`(非 `ref.watch`)构建**:router 是 keepAlive 单例,通过 `refreshListenable` 响应 auth 变化 -6. **Failure 不可绕过**:所有网络/DB 异常必须经 ExceptionMapper 映射,UI 只看 Failure.message +```bash +make gen # build_runner → .g.dart / .freezed.dart(改 Dart 文件后必跑) +make gen-i18n # slang → lib/i18n/strings.g.dart(改翻译 JSON 后必跑) +make run-dev # dev 包(USE_MOCK=true,无需后端) +make run-staging # staging 包(连 test 服务器) +make build-staging# staging APK +make build-prod # prod AAB(需先配置 prod.json + key.properties) +flutter analyze # 目标 0 errors(当前正常 info 数约 103 条) +flutter test # 全量测试 +``` --- ## 新项目接手 Checklist -> 完成后删除本节或移至项目 wiki +> 完成并删除本节(或移至项目 wiki) -- [ ] 更新本文件"项目身份信息"表格 +- [ ] 更新本文件"项目身份信息"表格(让 AI 上下文准确) +- [ ] 修改 `lib/core/config/api_config.dart` 三套 baseUrl - [ ] 填入 `lib/core/config/api_paths.dart` 所有路径 -- [ ] 配置三个 `dart-defines/*.json`(含 RSA 公钥、Sentry DSN) -- [ ] 修改 `android/app/build.gradle.kts` 中的 `applicationId` -- [ ] 修改 `pubspec.yaml` 中的 `name`(含相关 import 路径) -- [ ] 完成 `docs/setup-android.md` 中的 Android 签名配置 -- [ ] 完成 `docs/setup-ios.md` 中的 iOS Scheme / Signing 配置 +- [ ] 配置三个 `dart-defines/*.json`(RSA 公钥 / Sentry DSN) +- [ ] 修改 Android 包名:`android/app/build.gradle.kts` applicationId +- [ ] 修改 iOS Bundle ID:Xcode → Runner Target → General +- [ ] 修改 `pubspec.yaml` name 并批量替换 import 路径(见 setup-project.md Step 5) +- [ ] Android 签名:`cp android/key.properties.template android/key.properties` 并填写 +- [ ] iOS 签名:Xcode → Signing & Capabilities(见 setup-ios.md) - [ ] 替换登录页 UI 品牌(`lib/features/auth/presentation/pages/login_page.dart`) - [ ] 替换 App Icon(Android mipmap / iOS AppIcon.appiconset) -- [ ] 实现业务 Tab 页面(替换 `routes.dart` 中的 placeholder build) -- [ ] 更新 README.md 项目描述 +- [ ] 实现 Tab 页面(`lib/core/router/routes.dart` HomeRoute / MineRoute 的 build) +- [ ] 更新 README.md 项目描述和快速开始地址 --- @@ -236,7 +231,8 @@ ref.listen(myProvider, (_, next) { | 文档 | 内容 | |------|------| -| [README.md](README.md) | 项目概述、技术栈、快速开始 | -| [docs/setup-project.md](docs/setup-project.md) | 完整项目配置指南(所有开发者必读)| -| [docs/setup-android.md](docs/setup-android.md) | Android Flavor / 签名 / 构建详细配置 | -| [docs/setup-ios.md](docs/setup-ios.md) | iOS Scheme / CocoaPods / 签名详细配置 | +| [README.md](../README.md) | 项目概述、技术栈、基础设施一览 | +| [docs/setup-project.md](setup-project.md) | 完整配置指南(dart-defines / 包名 / 首次运行)| +| [docs/setup-android.md](setup-android.md) | Android Flavor / 签名 / 构建 / CI | +| [docs/setup-ios.md](setup-ios.md) | iOS Scheme / CocoaPods / 证书 / Archive | +| [docs/architecture.md](architecture.md) | 数据流 / 认证流 / 错误链 ASCII 架构图 | diff --git a/README.md b/README.md index 2f5840d..c82a5c6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,17 @@ --- +## 30 秒可运行验证 + +```bash +flutter pub get && make gen && make gen-i18n +make run-dev # USE_MOCK=true,无需后端,显示登录页即为成功 +``` + +> 登录:任意手机号 + 验证码 `123456`(Mock 自动填入)→ 跳 Home 占位页 ✅ + +--- + ## 已实现的基础设施 ### 构建体系 @@ -150,7 +161,8 @@ make run-dev | 文档 | 适用人群 | 内容 | |------|---------|------| | **本文(README.md)** | 所有成员 | 项目概述、技术栈、快速开始 | -| [docs/setup-project.md](docs/setup-project.md) | 所有开发者 | 环境配置、API 路径、dart-defines 填写 | +| [docs/setup-project.md](docs/setup-project.md) | 所有开发者 | 先跑起来、dart-defines 字段表、API 路径、包名替换 | | [docs/setup-android.md](docs/setup-android.md) | Android / 全栈 | Flavor、签名、keystore、构建 | | [docs/setup-ios.md](docs/setup-ios.md) | iOS / 全栈 | Scheme、CocoaPods、证书、Archive | -| [CLAUDE.md](CLAUDE.md) | AI 辅助开发 | 架构规范、零容忍规则、开发范式 | +| [docs/architecture.md](docs/architecture.md) | 所有开发者 + AI | 数据流 / 认证流 / 错误链 / 拦截器链 ASCII 图 | +| [CLAUDE.md](CLAUDE.md) | AI 辅助开发 | 零容忍规则、禁止改的文件、开发范式、新项目 Checklist | diff --git a/dart-defines/dev.json b/dart-defines/dev.json index a868c9e..1576f94 100644 --- a/dart-defines/dev.json +++ b/dart-defines/dev.json @@ -1,8 +1,9 @@ { "ENV": "dev", - "USE_MOCK": "false", + "USE_MOCK": "true", "INTERNAL_BUILD": "true", + "API_BASE_URL": "", "SENTRY_DSN": "", "PINNED_FINGERPRINTS": "", - "RSA_PUBLIC_KEY": "REPLACE_WITH_RSA_PUBLIC_KEY" + "RSA_PUBLIC_KEY": "" } diff --git a/dart-defines/prod.json.template b/dart-defines/prod.json.template index 34d2558..5666ded 100644 --- a/dart-defines/prod.json.template +++ b/dart-defines/prod.json.template @@ -2,7 +2,8 @@ "ENV": "release", "USE_MOCK": "false", "INTERNAL_BUILD": "false", + "API_BASE_URL": "", "SENTRY_DSN": "REPLACE_WITH_ACTUAL_SENTRY_DSN", - "PINNED_FINGERPRINTS": "REPLACE_WITH_ACTUAL_FINGERPRINTS_AA:BB:CC", - "RSA_PUBLIC_KEY": "REPLACE_WITH_RSA_PUBLIC_KEY" + "PINNED_FINGERPRINTS": "REPLACE_WITH_CERT_SHA256_AA:BB:CC:DD", + "RSA_PUBLIC_KEY": "REPLACE_WITH_RSA_PUBLIC_KEY_BASE64_DER" } diff --git a/dart-defines/staging.json b/dart-defines/staging.json index 11e78a4..063e840 100644 --- a/dart-defines/staging.json +++ b/dart-defines/staging.json @@ -2,7 +2,8 @@ "ENV": "test", "USE_MOCK": "false", "INTERNAL_BUILD": "true", + "API_BASE_URL": "", "SENTRY_DSN": "", "PINNED_FINGERPRINTS": "", - "RSA_PUBLIC_KEY": "REPLACE_WITH_RSA_PUBLIC_KEY" + "RSA_PUBLIC_KEY": "" } diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0040d24 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,231 @@ +# 架构概览 + +--- + +## 整体分层 + +``` +┌─────────────────────────────────────────────────┐ +│ 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 +``` diff --git a/docs/setup-project.md b/docs/setup-project.md index 50036db..59ceef7 100644 --- a/docs/setup-project.md +++ b/docs/setup-project.md @@ -1,234 +1,225 @@ -# 项目配置指南(所有开发者必读) +# 项目配置指南 -本文档覆盖从克隆脚手架到可以运行第一个 dev 包所需的全部步骤。 -Android 和 iOS 的平台专属配置请分别参阅 [setup-android.md](setup-android.md) 和 [setup-ios.md](setup-ios.md)。 +> **阅读顺序建议**:先跑起来(Step 1–2),再按需配置后续步骤。 +> Android / iOS 平台专属配置见 [setup-android.md](setup-android.md) / [setup-ios.md](setup-ios.md)。 --- ## 前提条件 -| 工具 | 最低版本 | 安装方式 | -|------|---------|---------| -| Flutter SDK | 3.41.0 | [flutter.dev](https://docs.flutter.dev/get-started/install) | -| Dart SDK | 3.7.0 | 随 Flutter 一起安装 | -| Android Studio | Meerkat (2024.3) | [developer.android.com](https://developer.android.com/studio) | -| Xcode | 16.0 | Mac App Store(仅 iOS 开发需要)| +| 工具 | 最低版本 | 说明 | +|------|---------|------| +| Flutter SDK | 3.41.0 | [flutter.dev/get-started](https://docs.flutter.dev/get-started/install) | +| Android Studio | Meerkat 2024.3 | 含 Android SDK API 34 | +| Xcode | 16.0 | 仅 iOS 开发需要(macOS 专属)| | CocoaPods | 1.15+ | `sudo gem install cocoapods` | -| make | 系统内置 | macOS/Linux 自带;Windows 用 Git Bash 或 WSL | -验证安装: ```bash -flutter --version # 应显示 ≥ 3.41.0 -flutter doctor # 确认 Android toolchain + Xcode 全绿 +flutter doctor # 确认 Android toolchain + Xcode 全绿后再继续 ``` --- -## Step 1 — 克隆与初始化 +## Step 1 — 克隆 + 安装依赖 + 代码生成 ```bash -git clone https://gitea.example.com/your-org/your-project.git # TODO: 替换实际地址 +git clone https://gitea.example.com/your-org/your-project.git # TODO: 替换真实地址 cd your-project -flutter pub get # 拉取所有依赖 -make gen # 生成 .g.dart / .freezed.dart(约 1 分钟) -make gen-i18n # 生成 lib/i18n/strings.g.dart +flutter pub get # 拉取所有依赖(约 1 分钟) +make gen # 生成 .g.dart / .freezed.dart(约 1 分钟) +make gen-i18n # 生成 lib/i18n/strings.g.dart(秒级) ``` -> **注意**:如果 `make gen` 报错 `build_runner` 找不到,请先确认 `dart` 在 PATH 中: -> ```bash -> dart --version -> ``` +> 如果 `make gen` 报错,确认 `dart` 在 PATH 中:`dart --version` --- -## Step 2 — 填入项目 API 路径 +## Step 2 — 立即运行(无需后端) + +`dart-defines/dev.json` 默认开启 Mock 模式(`USE_MOCK=true`),App 使用 `assets/fixtures/` 下的预设 JSON 响应,**不需要任何后端服务**即可运行。 + +```bash +make run-dev +``` + +**期望结果:** +- ✅ App 启动,显示登录页(手机号 + SMS/密码双模式) +- ✅ 点击"发送验证码"→ 自动填入 `123456`(Mock 响应) +- ✅ 输入任意手机号 + `123456` 登录 → 跳转 Home 占位页 +- ✅ 进入 Dev Panel(Talker 日志面板可正常显示请求日志) + +> `flutter analyze` 输出约 103 条 `info` 提示,**这是正常的**(全为风格建议,0 errors)。 + +--- + +## Step 3 — 填入 API 路径 编辑 `lib/core/config/api_paths.dart`,将所有空字符串替换为实际后端路径: ```dart -// 示例(sa-token + Spring Boot) +// 示例(sa-token 后端) static const String authLoginSms = '/sys/auth/sms-login'; static const String authLoginPwd = '/sys/auth/password-login'; static const String authSmsSend = '/sys/auth/send-sms'; static const String authLogout = '/sys/auth/logout'; static const String authRefresh = '/sys/auth/refresh-token'; -static const String userProfile = '/sys/sys-user/app/userInfo'; +static const String userProfile = '/sys/user/profile'; +static const String crashReport = '/sys/crash-report'; +static const String errorReport = '/sys/error-report'; // ... 其余路径按业务填写 ``` -所有 datasource 文件通过 `ApiPaths.xxx` 引用路径,**严禁字符串字面量散落**。 - --- -## Step 3 — 配置环境变量(dart-defines) +## Step 4 — 配置环境变量(连接真实后端) -### 3.1 开发环境(dev.json) +### dart-defines 字段说明 + +| JSON 键 | 作用 | dev 默认 | 必须填写时机 | +|---------|------|---------|------------| +| `ENV` | 环境名(dev/test/release)| `dev` | 通常不改 | +| `API_BASE_URL` | 临时覆盖 baseUrl | 空 | 见下方说明 | +| `USE_MOCK` | 开启 Mock 模式 | `true` | 连真实后端时改 `false` | +| `INTERNAL_BUILD` | 显示 Dev Panel | `true` | 通常不改 | +| `SENTRY_DSN` | Sentry 上报地址 | 空 | prod 上线前 | +| `PINNED_FINGERPRINTS` | SSL 证书指纹 | 空 | 正式上线前 | +| `RSA_PUBLIC_KEY` | 密码加密公钥 | 空 | 登录加密时 | + +### 方式 A:修改 api_config.dart(推荐,永久生效) + +编辑 `lib/core/config/api_config.dart`: + +```dart +return switch (Env.name) { + 'dev' => 'http://192.168.1.100:8080', // ← 改为 dev 服务器 + 'test' => 'https://staging.your-domain.com', // ← 改为 staging 服务器 + 'release' => 'https://api.your-domain.com', // ← 改为生产服务器 + _ => 'http://192.168.1.100:8080', +}; +``` + +同时将 `dart-defines/dev.json` 中的 `USE_MOCK` 改为 `false`。 + +### 方式 B:临时覆盖(调试私有环境) ```bash -# 直接编辑,此文件可提交(含占位符,无真实密钥) -nano dart-defines/dev.json +flutter run --flavor dev \ + --dart-define-from-file=dart-defines/dev.json \ + --dart-define=API_BASE_URL=http://192.168.1.200:8080 \ + --dart-define=USE_MOCK=false ``` -需填入的字段: +### 配置 RSA 公钥(密码加密) -```json -{ - "ENV": "dev", - "USE_MOCK": "true", // true = 使用 Mock Adapter(不调真实服务) - "INTERNAL_BUILD": "true", // true = 显示 Dev Panel 入口 - "SENTRY_DSN": "", // dev 通常留空 - "PINNED_FINGERPRINTS": "", // 证书指纹(dev 可留空跳过 SSL 绑定) - "API_BASE_URL": "http://192.168.1.100:8080", // TODO: 实际 dev 服务器 - "RSA_PUBLIC_KEY": "MIIBIjANBg..." // TODO: 后端 RSA 公钥(Base64 DER) -} -``` - -### 3.2 测试环境(staging.json) - -```json -{ - "ENV": "test", - "USE_MOCK": "false", - "INTERNAL_BUILD": "true", - "SENTRY_DSN": "", - "PINNED_FINGERPRINTS": "", - "API_BASE_URL": "https://staging.your-domain.com", // TODO - "RSA_PUBLIC_KEY": "MIIBIjANBg..." // TODO -} -``` - -### 3.3 生产环境(prod.json) +若后端使用 RSA 加密传输密码,从后端获取公钥后填入各环境 JSON: ```bash -cp dart-defines/prod.json.template dart-defines/prod.json -nano dart-defines/prod.json +# 从 Java 后端 PEM 文件提取 Base64 DER(去掉 header/footer,合并为单行) +cat server-public.key | grep -v "BEGIN\|END" | tr -d '\n' ``` -```json -{ - "ENV": "prod", - "USE_MOCK": "false", - "INTERNAL_BUILD": "false", - "SENTRY_DSN": "https://xxx@sentry.io/yyy", // TODO: Sentry 项目 DSN - "PINNED_FINGERPRINTS": "AA:BB:CC:...", // TODO: 服务器证书 SHA-256 指纹 - "API_BASE_URL": "https://api.your-domain.com",// TODO - "RSA_PUBLIC_KEY": "MIIBIjANBg..." // TODO -} -``` +将结果填入 `dart-defines/dev.json` / `staging.json` / `prod.json` 的 `RSA_PUBLIC_KEY` 字段。 -> **prod.json 已在 .gitignore 中**,不会被提交。CI/CD 系统通过 secret 变量注入。 +若后端**不需要** RSA 加密,修改 `auth_repository_impl.dart` 的 `loginWithPassword` 方法,将明文密码直接传入(或使用 HTTPS 保护)。 -### 获取 RSA 公钥 - -后端一般提供 RSA 公钥的 Base64 DER 编码。若后端使用 sa-token + RSA: +### 配置 SSL 证书指纹(生产必须) ```bash -# Java 后端:从 public.key 文件提取(去掉 PEM header/footer,拼接成单行) -cat public.key | grep -v "BEGIN\|END" | tr -d '\n' -``` - -### 获取证书 SHA-256 指纹 - -```bash -# 方法 1:openssl(推荐) +# 获取服务器证书 SHA-256 指纹 echo | openssl s_client -connect api.your-domain.com:443 2>/dev/null \ | openssl x509 -fingerprint -sha256 -noout \ | sed 's/SHA256 Fingerprint=//' - -# 方法 2:Chrome → 锁图标 → 证书 → 指纹 ``` ---- +将结果填入 `dart-defines/prod.json` 的 `PINNED_FINGERPRINTS` 字段(多个指纹用逗号分隔)。 -## Step 4 — 验证首次运行 +### 配置生产环境(prod.json) ```bash -# 方式 1:使用 Mock(推荐首次验证,不需要后端服务) -# 确保 dart-defines/dev.json 中 USE_MOCK=true -make run-dev - -# 方式 2:连接真实后端 -# 确保 dart-defines/dev.json 中 USE_MOCK=false + API_BASE_URL 已填入 -make run-dev +cp dart-defines/prod.json.template dart-defines/prod.json +# 编辑 prod.json,填入 Sentry DSN / 证书指纹 / RSA 公钥 ``` -**期望结果:** -- APP 启动,显示登录页(手机号 + SMS/密码双模式) -- 右上角(或侧滑)可进入 Dev Panel(Talker 日志面板) -- `flutter analyze` 输出 `103 issues found`(均为 info 级别,0 errors) +> **prod.json 已在 .gitignore 中**,不会被提交。CI/CD 通过 secret 注入。 --- -## Step 5 — 更新包名(业务项目必须) - -脚手架默认包名为 `com.example.sunny_mochi`,接手后必须替换: +## Step 5 — 修改包名(业务项目必须) ### Android -编辑 `android/app/build.gradle.kts`: + +`android/app/build.gradle.kts` → `defaultConfig.applicationId`: + ```kotlin -// dev Flavor -applicationId = "com.your_company.your_app.dev" - -// staging Flavor -applicationId = "com.your_company.your_app.staging" - -// prod / release defaultConfig { - applicationId = "com.your_company.your_app" + applicationId = "com.your_company.your_app" // ← 修改 +} +productFlavors { + create("dev") { + applicationIdSuffix = ".dev" + resValue("string", "app_name", "YourApp Dev") // ← 修改 + } + create("staging") { + applicationIdSuffix = ".staging" + resValue("string", "app_name", "YourApp Beta") // ← 修改 + } + create("prod") { + resValue("string", "app_name", "YourApp") // ← 修改 + } } ``` ### iOS -在 Xcode 中修改 Bundle Identifier(见 [setup-ios.md](setup-ios.md) Step 3)。 + +打开 Xcode(必须用 `.xcworkspace`): +```bash +open ios/Runner.xcworkspace +``` +Runner Target → General → Bundle Identifier → 修改为实际 ID。 ### pubspec.yaml + Dart 导入路径 + ```yaml -name: your_app_name # 修改后所有 import 路径也需要更新 +# pubspec.yaml +name: your_app_name # ← 修改 ``` -批量替换导入: +批量替换代码中的 package 名: + ```bash # macOS / Linux -find lib -name "*.dart" -exec sed -i '' 's/package:sunny_mochi/package:your_app_name/g' {} \; +find lib -name "*.dart" -exec sed -i '' \ + 's/package:sunny_mochi/package:your_app_name/g' {} \; # Windows PowerShell -Get-ChildItem -Path lib -Recurse -Filter "*.dart" | - ForEach-Object { (Get-Content $_.FullName) -replace 'sunny_mochi', 'your_app_name' | - Set-Content $_.FullName } +Get-ChildItem -Path lib -Recurse -Filter "*.dart" | ForEach-Object { + (Get-Content $_.FullName) -replace 'sunny_mochi', 'your_app_name' | + Set-Content $_.FullName +} ``` -运行 `make gen` 重新生成后确认无编译错误。 +替换后运行 `make gen` 重新生成,确认 `flutter analyze` 无错误。 --- ## Step 6 — 平台专属配置 -- **Android**(签名 / Flavor 验证 / 发布包构建)→ [setup-android.md](setup-android.md) -- **iOS**(Xcode Scheme / CocoaPods / 证书 / Archive)→ [setup-ios.md](setup-ios.md) +| 需求 | 文档 | +|------|------| +| Android 签名 / Flavor 验证 / 发布包 | [setup-android.md](setup-android.md) | +| iOS Scheme / CocoaPods / 证书 / Archive | [setup-ios.md](setup-ios.md) | --- ## 常见问题 -### `build_runner` 生成失败,报 analyzer 版本冲突 - -确认 `pubspec.yaml` 中的 `dependency_overrides`: -```yaml -dependency_overrides: - drift: 2.31.0 - json_serializable: 6.13.0 -``` -若被修改,恢复后重跑 `flutter pub get && make gen`。 - -### App 启动后 token 丢失 / 每次重启都跳登录 - -SecureStorage 在 iOS Simulator 上可能行为异常。请在真机测试,或检查 `Keychain Sharing` 是否开启。 - -### Mock 模式下请求崩溃 `Unable to load asset` - -检查 `assets/fixtures/` 下是否有对应的 JSON 文件,且 `pubspec.yaml` 中的 `assets:` 包含了对应子目录。 - -### `flutter analyze` 报错(非 info 级别) - -先运行 `make gen` 确保生成文件是最新的,再重新 analyze。 +| 症状 | 原因 | 解决 | +|------|------|------| +| `make gen` 报错 `dart: command not found` | Dart 不在 PATH | `export PATH="$PATH:/path/to/flutter/bin"` | +| `build_runner` 报 analyzer 版本冲突 | dependency_overrides 被修改 | 恢复 `drift: 2.31.0` 和 `json_serializable: 6.13.0` | +| App 启动后立即跳登录页 | 正常(未配置 token)| 用 Mock 登录验证,或连后端后正式登录 | +| Mock 登录后 Home 显示占位文字 | 正常(脚手架默认)| 实现 `routes.dart` 中 HomeRoute 的 build | +| `flutter analyze` 显示 103 issues | 正常(全为 info 级别)| 0 errors 即通过,info 为风格建议 | +| 连接后端但请求无响应 | api_config.dart 仍是占位 URL | 修改 api_config.dart 三套 baseUrl | +| iOS 真机崩溃,模拟器正常 | SecureStorage Keychain 权限 | 确认 Signing & Capabilities 配置正确 | diff --git a/lib/core/config/api_config.dart b/lib/core/config/api_config.dart index 32c245c..ec6883e 100644 --- a/lib/core/config/api_config.dart +++ b/lib/core/config/api_config.dart @@ -1,43 +1,36 @@ import 'package:sunny_mochi/core/config/env.dart'; -/// API 网络配置 — **baseUrl 决策的单一权威** +/// API 网络配置 — baseUrl 的单一决策权威。 /// -/// **对应 iOS BasicModule/Configuration/NetworkConfig.swift(2026-05 同步)**: +/// **新项目必填**:在下方 switch 中填入三套环境的实际 baseUrl, +/// 或者在 dart-defines/*.json 中设置 API_BASE_URL 做临时覆盖。 /// -/// | 环境 | iOS apiBaseURL | Flutter ApiConfig.baseUrl | -/// |------|---------------|--------------------------| -/// | dev | http://192.168.1.201:24801 | 同 | -/// | test | https://dev.yixiong-tech.com:8081 | 同 | -/// | release | https://bac.new.hamkke.top | 同 | +/// 优先级: +/// 1. dart-define `API_BASE_URL` 不为空时优先(CI/CD / 临时调试) +/// 2. 否则按 `Env.name`(dev / test / release)返回对应 URL /// -/// **优先级**: -/// 1. CI/CD / 临时调试通过 `--dart-define=API_BASE_URL=...` 注入 → 优先 -/// 2. 否则按 `Env.name`(dev/test/release)返回 iOS 同源 URL -/// -/// **不再引入 h5BaseURL** — H5 评估报告已全部 Flutter 原生化(详见 -/// docs/h5-to-native-decision-2026-05-10.md),不再需要 in-app WebView。 -/// -/// **响应码请用 [ResponseCode]**(lib/core/network/response_code.dart)。 -/// **API 路径请用 [ApiPaths]**(lib/core/config/api_paths.dart)— -/// 不允许 datasource 散落字面量。 +/// **API 路径**:见 lib/core/config/api_paths.dart(禁止 datasource 散落字面量) +/// **响应码**:见 lib/core/network/response_code.dart abstract class ApiConfig { - /// API 网关 baseUrl — 与 iOS NetworkConfig.swift 同源 + /// API 网关 baseUrl + /// + /// TODO: 将下方三个 URL 替换为项目实际后端地址。 static String get baseUrl { - // 1. dart-define 覆盖优先(CI/CD / 临时切换私有环境) + // 1. dart-define 覆盖优先(临时切换私有环境 / CI 注入) if (Env.apiBaseUrlOverride.isNotEmpty) { return Env.apiBaseUrlOverride; } - // 2. 按 Env.name 返回 iOS 同源 URL + // 2. 按环境返回固定 URL — TODO: 填入实际地址 return switch (Env.name) { - 'dev' => 'http://192.168.1.201:24801', - 'test' => 'https://dev.yixiong-tech.com:8081', - 'release' => 'https://bac.new.hamkke.top', - _ => 'http://192.168.1.201:24801', // 默认 dev(与 iOS Debug 包一致) + 'dev' => 'http://localhost:8080', // TODO: dev 服务器地址 + 'test' => 'https://staging.your-domain.com', // TODO: staging 服务器地址 + 'release' => 'https://api.your-domain.com', // TODO: 生产服务器地址 + _ => 'http://localhost:8080', }; } - // 网络超时 — 对应共性需求说明 §移动端 7.网络异常处理(10s 超时建议) + // 网络超时配置 static const Duration connectTimeout = Duration(seconds: 15); static const Duration receiveTimeout = Duration(seconds: 30); - static const Duration sendTimeout = Duration(seconds: 30); + static const Duration sendTimeout = Duration(seconds: 30); } diff --git a/lib/core/config/env.dart b/lib/core/config/env.dart index 6ef4df0..537b362 100644 --- a/lib/core/config/env.dart +++ b/lib/core/config/env.dart @@ -1,109 +1,71 @@ import 'package:flutter/foundation.dart'; -import 'package:sentry_flutter/sentry_flutter.dart' show SentryFlutter; -/// 全局编译期环境配置。所有值通过 --dart-define 注入,避免明文 secrets。 +/// 全局编译期环境配置。所有值通过 `--dart-define-from-file` 注入,明文 secrets 不入代码。 /// -/// **环境名与 iOS NetworkConfig.swift 对齐**:dev / test / release +/// 三套环境对应 Flavor: +/// - `dev` → dev Flavor,开发调试(可开 Mock) +/// - `test` → staging Flavor,连测试服务器 +/// - `release` → prod Flavor,正式发布 /// /// 用法: /// ```bash -/// # 开发环境 + mock(默认) -/// flutter run --dart-define=ENV=dev --dart-define=USE_MOCK=true +/// # 开发 + Mock(无需后端) +/// flutter run --flavor dev --dart-define-from-file=dart-defines/dev.json /// -/// # 测试环境(连真服 dev.yixiong-tech.com:8081) -/// flutter run --dart-define=ENV=test -/// -/// # 正式环境(连 bac.new.hamkke.top) -/// flutter build apk --release --dart-define=ENV=release \ -/// --dart-define=SENTRY_DSN=https://...@sentry/1 -/// -/// # CI/CD 临时覆盖 API URL(不修改源码) +/// # 临时覆盖 API URL(不修改源码) /// flutter run --dart-define=API_BASE_URL=https://my-private.example.com /// ``` /// -/// 详见: -/// - `docs/flutter-architecture-design.md` §九.1 / §十一.5 -/// - `docs/real-environment-verification.md`(环境就绪后填值) -/// - `lib/core/config/api_config.dart`(baseUrl 决策权威) +/// **需要填写的字段**:见 dart-defines/dev.json(所有字段带 TODO 注释) +/// **API 路径**:见 lib/core/config/api_paths.dart +/// **baseUrl 决策**:见 lib/core/config/api_config.dart abstract class Env { - /// 当前环境名:**dev / test / release**(与 iOS NetworkConfig 三态对齐)。默认 dev。 - /// - /// - dev:开发环境(局域网 192.168.1.201:24801 / mock 模式可用) - /// - test:测试环境(dev.yixiong-tech.com:8081 — 与 iOS test 同源) - /// - release:正式环境(bac.new.hamkke.top — Release 包应锁定此值) - static const String name = String.fromEnvironment('ENV', defaultValue: 'dev'); + /// 当前环境名:dev / test / release + static const String name = String.fromEnvironment( + 'ENV', + defaultValue: 'dev', + ); /// API baseUrl 临时覆盖(CI/CD / 私有环境调试用)。 /// - /// **正常情况下不应使用此变量** — 让 [ApiConfig.baseUrl] 按 [name] 自动选择 - /// iOS 同源 URL。仅当需要连私有/临时环境时通过 dart-define 注入。 - /// - /// ```bash - /// flutter run --dart-define=API_BASE_URL=https://my-private.example.com - /// ``` + /// 正常情况下留空 — 由 [api_config.dart] 按 [name] 自动选择。 + /// 仅当需要临时连私有/临时环境时通过 dart-define 注入: + /// `--dart-define=API_BASE_URL=https://my-private.example.com` static const String apiBaseUrlOverride = String.fromEnvironment( 'API_BASE_URL', ); - /// Sentry DSN。**Q9 待答前**为空,[SentryFlutter.init] 自动跳过实际上报。 - static const String sentryDsn = String.fromEnvironment( - 'SENTRY_DSN', - ); + /// Sentry DSN。空字符串时 SentryFlutter.init 自动跳过实际上报。 + /// TODO: prod 环境通过 dart-defines/prod.json 填入真实 DSN。 + static const String sentryDsn = String.fromEnvironment('SENTRY_DSN'); - /// 是否为内部测试包(决定 TalkerScreen 调试面板是否挂载)。 - /// 详见 docs/real-environment-verification.md §M4 / §Talker 准入。 - static const bool isInternalBuild = bool.fromEnvironment( - 'INTERNAL_BUILD', - ); + /// 是否为内部测试包(控制 Talker 调试面板入口是否挂载)。 + static const bool isInternalBuild = bool.fromEnvironment('INTERNAL_BUILD'); - /// 是否启用 mock fixtures 路径(绕过真实网络请求)。 - /// 详见 plan §Mock 与真实环境验证分层策略。 - static const bool useMock = bool.fromEnvironment( - 'USE_MOCK', - ); - - /// 腾讯云 IM SDKAppID(数字 ID)。 - /// **Q3 待答前**为 0(无效值,init 会返回失败但不崩溃)。 - /// 沙箱测试用控制台测试 SDKAppID;生产用真实业务 SDKAppID。 - /// 注入:--dart-define=IM_SDK_APP_ID=1400xxxxxx - static const int imSdkAppId = int.fromEnvironment( - 'IM_SDK_APP_ID', - ); + /// 是否启用 Mock 模式(绕过真实网络,使用 assets/fixtures/ 下的 JSON)。 + /// TODO: dev.json 默认 true,上线前确认 staging/prod 为 false。 + static const bool useMock = bool.fromEnvironment('USE_MOCK'); /// RSA 公钥(DER-SPKI Base64)— 用于登录密码加密。 - /// 默认值 = iOS dev 公钥(对应 platform-ios/.../RSAEncryption.swift 中的 publicKeyString)。 - /// 生产 / Q6 答复后通过 --dart-define=RSA_PUBLIC_KEY=... 注入真实公钥。 - static const String rsaPublicKey = String.fromEnvironment( - 'RSA_PUBLIC_KEY', - defaultValue: - 'MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmZfR/bA9X3vp86y1aEpvwzXJYKRRF1fLau2+05/ZtaITLpV8bhkmSf3neSy/Q9gAdvG75Fr73E+GWE+K5b0BpvIS1jDGo319+PpZR39SaZTKZ27XFXrosmJTZutN79t819HS1VseleunHAFgMVufE9U5jP6LGzl/wbkSy01GhzwIDAQAB', - ); + /// TODO: 从后端获取公钥后填入 dart-defines/*.json 的 RSA_PUBLIC_KEY 字段。 + /// 若后端不需要 RSA 加密,可忽略此字段并移除 auth_repository_impl 中的加密逻辑。 + static const String rsaPublicKey = String.fromEnvironment('RSA_PUBLIC_KEY'); - /// TLS 证书绑定指纹列表(SHA-256,多指纹支持轮换)。 - /// **Q2 待答前**为空,CertificatePinningInterceptor 在空列表时跳过校验。 - /// 真实指纹通过 --dart-define=PINNED_FINGERPRINTS=AA:BB,CC:DD 注入。 + /// TLS 证书绑定指纹列表(SHA-256,逗号分隔,支持多指纹轮换)。 + /// TODO: 填入 PINNED_FINGERPRINTS 字段;dev 留空时 CertPinning 拦截器自动跳过。 static List get pinnedFingerprints { - const raw = String.fromEnvironment( - 'PINNED_FINGERPRINTS', - ); + const raw = String.fromEnvironment('PINNED_FINGERPRINTS'); if (raw.isEmpty) return const []; - return raw - .split(',') - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(); + return raw.split(',').map((s) => s.trim()).where((s) => s.isNotEmpty).toList(); } - // ---- 便捷判断(对齐 iOS NetworkConfig.Environment 三态)---- + // ── 便捷判断 ────────────────────────────────────────────────────────────── + static bool get isDev => name == 'dev'; static bool get isTest => name == 'test'; static bool get isRelease => name == 'release'; - /// 兼容旧调用 — 历史代码可能用 isProd 判断 - /// @Deprecated 新代码请用 [isRelease] - static bool get isProd => isRelease; - - /// Release 包除非 INTERNAL_BUILD=true,否则视为生产模式。 - /// 用于 TalkerScreen / Riverpod observer 等开发面板的门控。 + /// Talker / Riverpod observer 等调试工具的门控。 + /// Debug 包(flutter run)或 INTERNAL_BUILD=true 时开启。 static bool get enableDevPanel => kDebugMode || isInternalBuild; } diff --git a/lib/core/error/exception_mapper.dart b/lib/core/error/exception_mapper.dart index 8a2bd6b..11eb169 100644 --- a/lib/core/error/exception_mapper.dart +++ b/lib/core/error/exception_mapper.dart @@ -17,7 +17,7 @@ ExceptionMapper exceptionMapper(Ref ref) => const ExceptionMapper(); /// - 数据库异常 → CacheFailure /// - 兜底 → UnknownFailure /// -/// 详见 docs/flutter-architecture-design.md §四.2。 + class ExceptionMapper { const ExceptionMapper(); diff --git a/lib/core/error/failures.dart b/lib/core/error/failures.dart index 5805d1c..48ff6c1 100644 --- a/lib/core/error/failures.dart +++ b/lib/core/error/failures.dart @@ -9,7 +9,7 @@ import 'package:sunny_mochi/core/error/exception_mapper.dart' /// **实现 [Exception]**:让 datasource/repository 可以直接 `throw failure;` /// 不触发 `only_throw_errors` lint。 /// -/// 详见 docs/flutter-architecture-design.md §四.2。 + sealed class Failure implements Exception { const Failure({required this.message, this.cause, this.stackTrace}); diff --git a/lib/core/observability/sentry_setup.dart b/lib/core/observability/sentry_setup.dart index 9057bf5..5768835 100644 --- a/lib/core/observability/sentry_setup.dart +++ b/lib/core/observability/sentry_setup.dart @@ -3,9 +3,9 @@ import 'package:sunny_mochi/core/config/env.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; /// 包装 [SentryFlutter.init],统一注入: -/// - DSN 来自 [Env.sentryDsn](空 DSN 跳过实际上报,便于 P0 兜底) -/// - PII 脱敏(健康类 App 强约束,详见 docs/flutter-architecture-design.md §十一.5) -/// - 屏蔽 screenshot / view hierarchy(含敏感页面) +/// - DSN 来自 [Env.sentryDsn](空 DSN 跳过实际上报) +/// - PII 脱敏(屏蔽手机号 / token / 身份证等敏感数据) +/// - 屏蔽 screenshot / view hierarchy /// /// 调用方在 main.dart 包一层: /// ```dart @@ -27,7 +27,7 @@ class SentrySetup { options ..dsn = Env.sentryDsn ..environment = Env.name - ..tracesSampleRate = Env.isProd ? 0.2 : 1.0 + ..tracesSampleRate = Env.isRelease ? 0.2 : 1.0 ..debug = kDebugMode // === 健康数据 PII 脱敏(强约束)=== ..sendDefaultPii = false diff --git a/lib/core/observability/talker_setup.dart b/lib/core/observability/talker_setup.dart index a29acb2..a8501f7 100644 --- a/lib/core/observability/talker_setup.dart +++ b/lib/core/observability/talker_setup.dart @@ -14,7 +14,7 @@ import 'package:talker_flutter/talker_flutter.dart'; /// /// **Release 包准入**(合规底线):[TalkerSettings.enabled] = `kDebugMode || INTERNAL_BUILD`, /// 用户线 Release 包硬关闭日志记录与设备调试面板(避免敏感数据/PII 写入设备)。 -/// 详见 docs/flutter-architecture-design.md §十一.5。 + final Talker appTalker = TalkerFlutter.init( settings: TalkerSettings( enabled: kDebugMode || Env.isInternalBuild, diff --git a/lib/core/storage/app_database.dart b/lib/core/storage/app_database.dart index 08a37ea..532f38b 100644 --- a/lib/core/storage/app_database.dart +++ b/lib/core/storage/app_database.dart @@ -57,7 +57,7 @@ void _sqlCipherIsolateSetup() { /// 3. 探测现有文件是否可用当前密钥打开;失败则删除重建 /// 4. NativeDatabase setup 时执行 `PRAGMA key = '...'` 解锁 /// -/// 详见 docs/flutter-architecture-design.md §九.1.1 + §十一.5。 + @DriftDatabase(tables: [Users, ErrorLogs]) class AppDatabase extends _$AppDatabase { AppDatabase._(super.e); diff --git a/lib/core/storage/db_key_provider.dart b/lib/core/storage/db_key_provider.dart index 9601302..4460bde 100644 --- a/lib/core/storage/db_key_provider.dart +++ b/lib/core/storage/db_key_provider.dart @@ -16,7 +16,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; /// - 服务端下发:可主动撤销,离线不可解锁 /// - 两段式(A+B 组合):最复杂 /// -/// 详见 docs/flutter-architecture-design.md §九.1.2 与 §十一.5。 + abstract class KeyDerivationStrategy { Future deriveKey(); } diff --git a/lib/core/storage/tables/sync_columns.dart b/lib/core/storage/tables/sync_columns.dart index 2afea9a..8bfd3b1 100644 --- a/lib/core/storage/tables/sync_columns.dart +++ b/lib/core/storage/tables/sync_columns.dart @@ -9,7 +9,7 @@ import 'package:sunny_mochi/core/sync/sync_status.dart'; /// - [serverUpdatedAt] 服务端最后修改时间(last-write-wins 比较基准) /// - [conflictPayload] 冲突时备份的服务端版本 JSON(人工或策略恢复) /// -/// 详见 docs/flutter-architecture-design.md §五.21。 + mixin SyncColumns on Table { IntColumn get syncStatus => intEnum().withDefault(const Constant(0))(); diff --git a/lib/core/sync/sync_service.dart b/lib/core/sync/sync_service.dart index 5d23c08..93b560b 100644 --- a/lib/core/sync/sync_service.dart +++ b/lib/core/sync/sync_service.dart @@ -23,7 +23,7 @@ abstract class SyncTask { /// - 业务层通过 [enqueue] 注册 SyncTask /// - 串行执行(避免并发污染服务端) /// -/// 详见 docs/flutter-architecture-design.md §五.21。 + class SyncService { SyncService({ required Connectivity connectivity,