diff --git a/01-project-structure.md b/01-project-structure.md index 23591c0..9648481 100644 --- a/01-project-structure.md +++ b/01-project-structure.md @@ -8,51 +8,100 @@ ``` conti-app/ - melos.yaml + pubspec.yaml # 根 workspace 配置(melos 8.x 不再有独立 melos.yaml,见下文) + .fvmrc # 锁定 Flutter SDK 版本 + analysis_options.yaml # 全仓库共享 lint 规则 app/ # 壳工程:唯一的 Flutter application,负责路由汇总、DI 装配、编译出 ipa/apk packages/ core_ui/ # 通用组件、主题、设计 token - core_network/ # dio 封装、拦截器、统一异常 - core_storage/ # 本地存储抽象(Drift/secure storage 封装) - core_auth/ # 登录态、token 管理 - core_router/ # 路由聚合、公共 route guard - feature_scan/ # 原「扫码」小程序 - feature_payment/ # 原「支付」小程序 - feature_store/ # 原「门店」小程序 - feature_.../ - native_scan/ # 原生插件包:扫码(android/ios/ohos 三端实现) - native_payment/ - native_bluetooth/ + core_network/ # dio 封装、拦截器、统一异常、ApiResult 解包 + core_storage/ # 本地存储抽象(Drift + shared_preferences 封装) + core_auth/ # 登录态、token 管理、secure storage、门店上下文 + core_router/ # 路由聚合、公共 route guard、动态菜单映射 + core_webview/ # F6 H5 容器 + JSBridge(见 10-webview-h5.md) + core_analytics/ # 埋点统一 API(见 13-observability-analytics.md) + core_logging/ # 日志规范、脱敏、崩溃上报接入 + feature_auth/ # 登录、验证码、用户协议与隐私政策 + feature_home/ # 首页工作台:动态菜单、待办、预警、公告、促销位 + feature_store_mgmt/ # 店铺管理:基础信息、服务信息、执照、人员管理 + feature_sales/ # 销售流程:客户查询、历史工单、商机(H5 承载的部分走 core_webview) + feature_purchase/ # 采购:产品查询、购物车、结算、订单、收货 + feature_inventory/ # 库存:明细、安全库存、盘点、DOT + feature_analytics/ # 经营分析:对账单、核销收入、返利、报表 + feature_profile/ # 个人中心:地址、热线、客服 + feature_scan/ # 扫码业务入口(VIN/车牌/二维码/条码 → 分发到对应业务) + native_scan/ # 原生插件包:扫码能力(android/ios 两端实现) + native_media/ # 相机、相册、文件选择/上传 + native_device/ # 拨号、设备信息、权限申请 ``` +包清单按 [PRD](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) 的模块划分列出,实际开工时按迭代顺序逐个建,不需要一次性全建出来。 + ## 依赖规则(编译期强制边界,是这套结构的核心价值) - `app` 可以依赖所有 `core_*` 和 `feature_*`。 -- `feature_*` **只能**依赖 `core_*`,**不能**相互依赖(`feature_payment` 的 `pubspec.yaml` 里不允许出现 `feature_store` 的 path dependency)。 -- `core_*` 之间尽量不互相依赖;唯一允许的例外是 `core_network` 依赖 `core_auth`(取 token 做请求签名/刷新)。 -- `feature_*` 可以依赖对应的 `native_*` 包(如 `feature_scan` 依赖 `native_scan`)。 +- `feature_*` **只能**依赖 `core_*` 和 `native_*`,**不能**相互依赖(`feature_purchase` 的 `pubspec.yaml` 里不允许出现 `feature_inventory` 的 path dependency)。 +- `core_*` 可以依赖 `native_*`(`core_webview` 的 JSBridge 需要调起扫码/相机/上传)。 - `native_*` 只依赖 Flutter SDK 和 [Pigeon](https://pub.dev/packages/pigeon) 生成的代码,不依赖任何 `core_*` / `feature_*`——保证原生插件包可以脱离业务单独编译、单独测试(详见 [07-native-integration.md](./07-native-integration.md))。 +`core_*` 之间原则上不互相依赖,允许的例外只有下面三条,多一条都要走评审: + +| 允许的依赖 | 原因 | +|---|---| +| `core_network` → `core_auth` | 取 token 附加到请求头、401 时触发刷新 | +| `core_router` → `core_auth` | 路由 `redirect` 里判断登录态(见 [04-routing.md](./04-routing.md)) | +| `core_webview` → `core_auth` | H5 换票需要当前登录态与门店上下文(见 [10-webview-h5.md](./10-webview-h5.md)) | + +两条容易踩的反向约束,必须记住: + +- **`core_auth` 不依赖 `core_network`**。`core_auth` 要发 refresh 请求,如果依赖 `core_network` 就和上表第一行构成循环依赖。做法是:`core_auth` 直接依赖 `dio` 包,内部自建一个**不挂任何拦截器的裸 `Dio` 实例**专门用于刷新——这同时也避免了"刷新请求本身被 `AuthInterceptor` 拦截 → 401 → 再刷新"的递归(见 [05-networking.md](./05-networking.md))。 +- **`core_auth` 不依赖 `core_storage`**。token / refresh token 走 `flutter_secure_storage`,这个依赖**归 `core_auth` 独占**;`core_storage` 只负责 Drift 和 `shared_preferences`(见 [06-local-storage.md](./06-local-storage.md))。这样划分是为了不让 `core_*` 之间再多一条依赖边。 + +`feature_*` 不直接依赖 `go_router`,路由相关类型由 `core_router` 统一 re-export(`export 'package:go_router/go_router.dart';`),这样将来换路由库时只有 `core_router` 一个包要改。 + 这些规则由 Dart 的包依赖机制**物理强制**:`feature_a` 根本 import 不到 `feature_b` 的任何符号,不是靠代码规范或 review 口头约束。 ## Feature 间通信怎么处理 这是最容易被绕开、也是这套边界能否守住的关键点,必须写清楚合法方式: -1. **路由跳转 + 可序列化参数**(多数场景)——比如从 `feature_store` 跳到 `feature_payment`,通过 `core_router` 声明的路径 + query/extra 参数传递,不直接引用对方的 Dart 类型。 -2. **通过 `core_*` 定义的抽象接口 + DI 注册实现**——真正需要跨 feature 拿数据或发通知的场景(比如支付完成后要清空购物车),在某个 `core_*` 包里定义接口,各 feature 各自实现并在 `app` 层注册,调用方只依赖 `core_*` 里的抽象类型。 +1. **路由跳转 + 可序列化参数**(多数场景)——比如从 `feature_home` 跳到 `feature_purchase`,通过 `core_router` 声明的路径 + query/extra 参数传递,不直接引用对方的 Dart 类型。 +2. **通过 `core_*` 定义的抽象接口 + DI 注册实现**——真正需要跨 feature 拿数据或发通知的场景(比如切换门店后要清空购物车),在某个 `core_*` 包里定义接口,各 feature 各自实现并在 `app` 层注册,调用方只依赖 `core_*` 里的抽象类型(门店切换的级联失效见 [11-store-context-and-session.md](./11-store-context-and-session.md))。 **不允许**的做法:任何 `feature_*` 在 `pubspec.yaml` 里直接 path dependency 另一个 `feature_*`,哪怕只是想复用一个 widget——这种情况应该把这个 widget 提到 `core_ui`。 ## 命名规范 - `core_xxx`:基础设施层,不含具体业务逻辑。 -- `feature_xxx`:对应一个原小程序/业务域。 -- `native_xxx`:原生能力插件包,内部含 `android/`、`ios/`、`ohos/` 三套原生实现目录。 +- `feature_xxx`:对应一个业务域(多数是原来的某个小程序,也有全新的,如首页工作台)。 +- `native_xxx`:原生能力插件包,首版含 `android/`、`ios/` 两套原生实现目录(OHOS 不在首版范围,见 [07-native-integration.md](./07-native-integration.md))。 + +## SDK 版本基线 + +| 项 | 版本 | 说明 | +|---|---|---| +| Flutter | **3.44.9** | 用 [FVM](https://fvm.app/) 锁定,仓库根目录提交 `.fvmrc` | +| Dart | 随 Flutter 3.44.9 附带(3.12.x) | 具体号以 `flutter --version` 实测为准;`environment: sdk: ^3.12.0` 对整个 3.12.x 都成立 | + +**为什么不跟最新 stable(3.47.0 / Dart 3.13.0,2026-08-12 发布)**:鸿蒙(OpenHarmony)的 Flutter 分支适配落后于官方 stable 一段时间,虽然 OHOS 不在首版范围(见 [07-native-integration.md](./07-native-integration.md) 的「OHOS 后续演进」),但 SDK 基线要为后续接 OHOS 留出兼容窗口,所以刻意停在 3.44.9 而不是追最新。这条约束在决定升级 Flutter 版本时必须重新评估,不要因为"新版本有新特性"就单方面升。 + +**为什么必须用 FVM 锁**:monorepo 里各人本地 Flutter 版本不一致,会导致同一份代码有人 `flutter analyze` 过、有人不过,生成代码(`build_runner` 产物)也可能不一致——这类问题排查成本远高于装一次 FVM。CI 也用 `.fvmrc` 里的版本,保证本地和流水线一致。 + +```json +// .fvmrc +{ "flutter": "3.44.9" } +``` ## Melos 配置示例(8.x,基于 Dart Pub Workspaces) -Melos 7.0 起改用 Dart 官方原生的 **[Pub Workspaces](https://dart.dev/tools/pub/workspaces)** 机制,不再有独立的 `melos.yaml` 文件,配置写进根目录 `pubspec.yaml`;每个子包的 `pubspec.yaml` 需要加 `resolution: workspace`。要求 **Dart SDK ≥ 3.6.0**(我们的基线 Flutter 3.44.8 对应 Dart 3.12.2,满足要求)。 +Melos 7.0 起改用 Dart 官方原生的 **[Pub Workspaces](https://dart.dev/tools/pub/workspaces)** 机制,不再有独立的 `melos.yaml` 文件,配置写进根目录 `pubspec.yaml`;每个子包的 `pubspec.yaml` 需要加 `resolution: workspace`。 + +两个不同的 SDK 下限,别搞混: + +- **Pub Workspaces 机制本身**要求 Dart SDK ≥ **3.6.0**。 +- **melos 8.2.2 这个工具**自己要求 Dart SDK **^3.9.0**。 + +我们的基线(Dart 3.12.x)两条都满足。 根目录 `pubspec.yaml`: @@ -69,12 +118,14 @@ workspace: - packages/core_storage - packages/core_auth - packages/core_router - - packages/feature_scan - - packages/feature_payment - - packages/feature_store + - packages/core_webview + - packages/core_analytics + - packages/core_logging + - packages/feature_auth + - packages/feature_home + - packages/feature_purchase - packages/native_scan - - packages/native_payment - - packages/native_bluetooth + # ... 其余包按实际建包进度追加 dev_dependencies: melos: ^8.2.2 @@ -82,17 +133,25 @@ dev_dependencies: melos: scripts: analyze: - run: melos exec -- flutter analyze + run: melos exec --fail-fast -- flutter analyze test: - run: melos exec -- flutter test + # --dir-exists=test 跳过还没有测试目录的包(如新建的 native_*), + # 否则批量命令会因为「找不到 test 目录」整体失败 + run: melos exec --dir-exists=test --fail-fast -- flutter test format: run: melos exec -- dart format --set-exit-if-changed . + gen: + # 代码生成:riverpod_generator / drift_dev / json_serializable + run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs + pigeon: + # 原生接口生成,见 07-native-integration.md + run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/ ``` -每个子包(比如 `packages/feature_scan/pubspec.yaml`): +每个子包(比如 `packages/feature_purchase/pubspec.yaml`): ```yaml -name: feature_scan +name: feature_purchase resolution: workspace dependencies: @@ -100,8 +159,21 @@ dependencies: path: ../core_ui core_network: path: ../core_network + core_router: + path: ../core_router ``` +## 共享 lint 配置 + +根目录一份 `analysis_options.yaml`,各子包 include 它,不允许各包自己维护一套规则(选型与具体规则见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md)): + +```yaml +# packages/feature_purchase/analysis_options.yaml +include: ../../analysis_options.yaml +``` + +用到 `custom_lint`(`riverpod_lint` 依赖它)的包,需要各自在 `dev_dependencies` 里加 `custom_lint`,并在自己的 `analysis_options.yaml` 里启用 `custom_lint` 插件——`custom_lint` 是按包运行的,不能只在根目录配一次(见 [03-state-management.md](./03-state-management.md))。 + ## 新增 feature 包的标准脚手架 ``` @@ -116,7 +188,7 @@ feature_xxx/ test/ ``` -`src/` 目录下的内容视为包内私有实现,只有 `feature_xxx.dart` 这一个文件是对外契约——这条靠 code review 检查,Dart 语言本身没有强制的 package-private 关键字。`domain/` 目录的取舍规则详见 [02-layering.md](./02-layering.md)(待写)。 +`src/` 目录下的内容视为包内私有实现,只有 `feature_xxx.dart` 这一个文件是对外契约——这条靠 code review 检查,Dart 语言本身没有强制的 package-private 关键字。`domain/` 目录的取舍规则详见 [02-layering.md](./02-layering.md)。 ## 版本管理 @@ -134,26 +206,34 @@ Dart 官方的包管理工具 `pub` 天生只认"一个 `pubspec.yaml` = 一个 ### 核心概念 -1. **根目录 `pubspec.yaml` 里的 `workspace:` 字段 + `melos:` 配置块**:8.x 版本不再有独立的 `melos.yaml` 文件(7.0 之前是独立文件,现已合并进 Dart 官方原生的 Pub Workspaces 机制)。`workspace:` 列出所有子包路径,`melos:` 块下的 `scripts:` 定义可复用脚本(见上文示例)。要求 Dart SDK ≥ 3.6.0。 -2. **`melos bootstrap`**(简写 `melos bs`):一键解析 workspace 内所有包之间的依赖关系——自动生成 `pubspec_overrides.yaml`,把 `feature_scan` 依赖 `core_network` 这种关系用本地路径链接起来,不用手写相对路径,也不需要真的发布到 pub.dev 才能互相依赖。**新人拉下代码后第一步永远是跑这个命令**。 -3. **`melos exec`**:在每一个包目录下依次/并行执行同一条命令,比如 `melos exec -- flutter test` 就是把所有包都跑一遍测试,替代手动 `cd packages/feature_scan && flutter test && cd ../feature_payment && ...`。 -4. **`melos run `**:调用 `melos.yaml` 里预定义的脚本别名(比如上文的 `melos run test`),团队里统一敲固定命令,不用记 `exec` 的完整写法。 +1. **根目录 `pubspec.yaml` 里的 `workspace:` 字段 + `melos:` 配置块**:8.x 版本不再有独立的 `melos.yaml` 文件(7.0 之前是独立文件,现已合并进 Dart 官方原生的 Pub Workspaces 机制)。`workspace:` 列出所有子包路径,`melos:` 块下的 `scripts:` 定义可复用脚本(见上文示例)。 +2. **`melos bootstrap`**(简写 `melos bs`):一键解析 workspace 内所有包之间的依赖关系。在 8.x 的 Pub Workspaces 模式下,它的效果约等于"在仓库根目录跑一次 `flutter pub get` + 校验各包 `resolution: workspace` 配置是否正确"——包间链接由 pub 原生的 workspace 机制完成,**不再生成 `pubspec_overrides.yaml`**(那是 7.0 之前的实现方式)。**新人拉下代码后第一步永远是跑这个命令**。 +3. **`melos exec`**:在每一个包目录下依次/并行执行同一条命令,比如 `melos exec -- flutter test` 就是把所有包都跑一遍测试,替代手动 `cd packages/feature_purchase && flutter test && cd ../feature_inventory && ...`。常用过滤参数:`--scope`(只跑匹配名字的包)、`--dir-exists=test`(只跑有测试目录的包)、`--fail-fast`(有一个包失败就停)。 +4. **`melos run `**:调用根目录 `pubspec.yaml` 里 `melos: scripts:` 下预定义的脚本别名(比如上文的 `melos run test`),团队里统一敲固定命令,不用记 `exec` 的完整写法。 ### 日常开发流程(拿本仓库举例) ```bash +# 0. 一次性:安装 fvm 并装上基线版本的 Flutter +dart pub global activate fvm +fvm install # 读 .fvmrc,装 3.44.9 +fvm flutter --version + # 1. 第一次拉代码,或者别人加了新包/新依赖之后 melos bootstrap -# 2. 正常改代码,比如在 feature_scan 里改一个页面 -cd packages/feature_scan +# 2. 正常改代码,比如在 feature_purchase 里改一个页面 +cd packages/feature_purchase flutter run # 这一步跟平时写单个 Flutter 项目完全一样,感觉不到 melos 的存在 -# 3. 提交前,跑一遍全仓库检查 +# 3. 改了带注解的代码(Riverpod / Drift / json_serializable)之后 +melos run gen + +# 4. 提交前,跑一遍全仓库检查 melos run analyze melos run test -# 4. 新增了 feature 包,或者某个包新增了对另一个包的依赖之后 +# 5. 新增了 feature 包,或者某个包新增了对另一个包的依赖之后 melos bootstrap # 重新解析依赖关系 ``` @@ -179,6 +259,7 @@ dart pub global activate melos - [Melos changelog](https://pub.dev/packages/melos/changelog) - [Melos Configuration overview](https://melos.invertase.dev/configuration/overview) - [Dart Pub Workspaces 官方文档](https://dart.dev/tools/pub/workspaces) +- [FVM(Flutter Version Management)](https://fvm.app/) - [Pigeon | Dart package](https://pub.dev/packages/pigeon) - [Drift | Dart package](https://pub.dev/packages/drift) - [Lerna(JS 生态对标工具)](https://lerna.js.org/) diff --git a/02-layering.md b/02-layering.md index 07a18a4..2c11e0b 100644 --- a/02-layering.md +++ b/02-layering.md @@ -43,12 +43,80 @@ domain → 不依赖 presentation / data data → 依赖 domain 的接口(若有),依赖 core_network / core_storage ``` -`domain` 层禁止 import 任何 Flutter SDK(`package:flutter/...`)——保持纯 Dart,可脱离 UI 单独做 unit test。 +`domain` 层禁止 import 的东西,不只是 Flutter SDK: + +- `package:flutter/...`(UI 框架) +- `package:dio/...`(网络库) +- `package:drift/...`(数据库) +- 任何做 IO 的第三方库 + +`domain` 只允许 `dart:core`/`dart:async` 这类纯语言能力和项目内的纯 Dart 类型。这条如果松了,"domain 可以脱离 UI 和网络单独跑 unit test"就名存实亡——只要 import 了 `dio`,测试就得处理它的初始化和平台依赖。 + +## 数据模型与 JSON 序列化 + +**决策**:DTO 用 [json_serializable](https://pub.dev/packages/json_serializable) 生成 `fromJson`/`toJson`,不手写;**不引入 freezed**。 + +```yaml +dependencies: + json_annotation: ^4.9.0 + +dev_dependencies: + json_serializable: ^6.9.0 + build_runner: ^2.15.2 +``` + +- **为什么不上 freezed**:freezed 主要提供不可变类、`copyWith`、联合类型(sealed class)。Dart 3 已经原生支持 `sealed class`/`final class` 和模式匹配,联合类型这块的收益大幅缩水;而 `copyWith` 的收益不足以抵消"再加一个 codegen 目标 + 生成文件体积翻倍 + 编译变慢"的成本。项目里已经有 `riverpod_generator`、`drift_dev`、`json_serializable`、`pigeon` 四个 codegen 目标,能不加就不加(同 [09-testing.md](./09-testing.md) 里不选 `mockito` 的理由)。 +- **DTO 与 entity 是否分两套类型**:默认**不分**,`data` 层的 DTO 直接当 `domain` 的 entity 用,只在下面两种情况才拆两套并写转换函数: + 1. 后端字段结构明显不适合业务使用(比如时间戳是字符串、状态是魔法数字、嵌套层级很深)。 + 2. 同一个业务概念由多个接口拼出来(比如首页 tile 聚合了多个 Mini 域的返回)。 + + 拆两套要付出双份类型 + 一份转换代码的成本,多数简单 CRUD 场景不值得。 +- 有 `domain` 层的 feature 如果拆了两套类型,转换函数放在 `data` 层(`domain` 不能知道 JSON 长什么样)。 + +## 后端统一响应包装在哪一层解开 + +后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](./backend/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**: + +- `code == 0` → 把 `data` 取出来交给 repository,repository 的 `fromJson` 只需要认识 `data` 的结构,完全不用感知外层包装。 +- `code != 0` → 直接抛 `BusinessException(code, message, traceId)`。 +- `traceId` 无论成功失败都记录进日志。 + +完整契约见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。这条规则的意义是:以后如果后端调整了包装格式,只有 `core_network` 一个地方要改。 + +## 分页的统一约定 + +PRD §21.1 要求列表页支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名: + +```dart +// core_network 里定义的通用分页类型 +class PageQuery { + const PageQuery({required this.page, this.size = 20}); + final int page; // 从 1 开始 + final int size; +} + +class PageResult { + const PageResult({required this.items, required this.total, required this.page}); + final List items; + final int total; + final int page; + bool get hasMore => items.length + (page - 1) * items.length < total; +} + +// feature 侧 +abstract class PurchaseOrderRepository { + Future> fetchOrders(PageQuery query); +} +``` + +具体字段名以后端最终约定为准(backend 06 的「待补充」里也挂着分页约定这一项),联调前需要跟后端对齐一次。 ## 附录:分层架构是什么,为什么要分层 给还没接触过这套分层习惯的同学看的入门说明。 +> 下面示例里的 `feature_payment` / `feature_store` 是为了讲清分层概念用的简化例子,不是最终包清单(实际包清单见 [01-project-structure.md](./01-project-structure.md))。 + ### 要解决的问题 如果 UI 代码里直接写网络请求、直接 new 一个 `Dio` 实例、直接操作数据库——短期能跑,但会导致两个问题: @@ -107,22 +175,25 @@ class ConfirmPaymentUseCase { // data/repository/payment_repository_impl.dart class PaymentRepositoryImpl implements PaymentRepository { - final Dio _dio; // 来自 core_network - PaymentRepositoryImpl(this._dio); + final ApiClient _api; // 来自 core_network,不是裸 Dio,见 05-networking.md + PaymentRepositoryImpl(this._api); @override Future fetchOrder(String orderId) async { - final res = await _dio.get('/orders/$orderId'); + // 注意:返回的已经是 ApiResult 里的 data 部分—— + // { code, message, data, traceId } 这层包装由 core_network 的拦截器统一解开, + // repository 不感知它的存在(见上文「后端统一响应包装在哪一层解开」) + final json = await _api.get>('/api/v1/orders/$orderId'); return PaymentOrder( - orderId: res.data['orderId'], - amountCents: res.data['amountCents'], - status: PaymentStatus.values.byName(res.data['status']), + orderId: json['orderId'] as String, + amountCents: json['amountCents'] as int, + status: PaymentStatus.values.byName(json['status'] as String), ); } @override Future confirmPayment(String orderId, String pinToken) => - _dio.post('/orders/$orderId/confirm', data: {'pinToken': pinToken}); + _api.post('/api/v1/orders/$orderId/confirm', data: {'pinToken': pinToken}); } ``` @@ -137,13 +208,17 @@ abstract class StoreRepository { } class StoreRepositoryImpl implements StoreRepository { - final Dio _dio; - StoreRepositoryImpl(this._dio); + final ApiClient _api; + StoreRepositoryImpl(this._api); @override Future> fetchNearbyStores(double lat, double lng) async { - final res = await _dio.get('/stores', queryParameters: {'lat': lat, 'lng': lng}); - return (res.data as List).map((e) => Store.fromJson(e)).toList(); + // 同上:拿到的是解开 ApiResult 包装之后的 data + final list = await _api.get>( + '/api/v1/stores', + query: {'lat': lat, 'lng': lng}, + ); + return list.map((e) => Store.fromJson(e as Map)).toList(); } } ``` @@ -155,3 +230,5 @@ class StoreRepositoryImpl implements StoreRepository { - [Flutter 官方状态管理文档](https://docs.flutter.dev/data-and-backend/state-mgmt) - [The Clean Architecture(Uncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) - [依赖倒置原则(Dependency Inversion Principle)](https://en.wikipedia.org/wiki/Dependency_inversion_principle) +- [json_serializable | Dart package](https://pub.dev/packages/json_serializable) +- [Dart 3 sealed class 与模式匹配](https://dart.dev/language/patterns) diff --git a/03-state-management.md b/03-state-management.md index 0c1cdf5..315eb80 100644 --- a/03-state-management.md +++ b/03-state-management.md @@ -15,11 +15,13 @@ dependencies: dev_dependencies: riverpod_generator: ^3.4.2 - build_runner: ^2.4.0 - custom_lint: ^0.6.0 - riverpod_lint: ^3.0.0 + build_runner: ^2.15.2 + custom_lint: ^0.8.1 + riverpod_lint: ^3.1.8 ``` +> `custom_lint` 的版本必须是 `^0.8.x`:`riverpod_lint 3.x` 依赖的是 `custom_lint 0.8.x`,写成 `^0.6.0` 会直接 `pub get` 解析失败。`custom_lint` 的版本约束比较严,每次升 `riverpod_lint` 都要顺带核一下它要求的 `custom_lint` 版本。 + ## 使用规则 - 所有跨 widget 共享的状态、依赖注入,统一通过 Riverpod provider 暴露,不额外引入 `get_it`/`provider` 等其他 DI 方案。 @@ -28,10 +30,93 @@ dev_dependencies: - `domain`/`data` 层的 repository 实现通过 provider 注入到 `presentation` 层,`presentation` 只依赖 provider 暴露的接口类型(见 [02-layering.md](./02-layering.md))。 - 每个 `feature_*` 包各自维护自己的 provider,不跨包直接引用另一个 feature 的 provider(同 [01-project-structure.md](./01-project-structure.md) 的 feature 隔离规则);跨 feature 共享的 provider 定义在对应的 `core_*` 包里。 +## Riverpod 3 的自动重试:全局关掉 + +Riverpod 3 起,**provider 抛异常后会自动重试**,默认策略是指数退避(200ms 起,翻倍到 6.4s 封顶)。这个默认行为在本项目里弊大于利,有三个具体问题: + +1. **和 401 刷新打架**:access token 过期时,`core_network` 的 `AuthInterceptor` 已经在做刷新 + 重放(见 [05-networking.md](./05-networking.md))。provider 层再自动重试一轮,等于同一个失败被两套机制各重试一次,日志里会出现莫名其妙的重复请求。更糟的是后端 refresh token 是**一次性轮换**的(见 [backend/04-security-auth.md](./backend/04-security-auth.md)),并发刷新会被判定为重放攻击,导致该用户所有 refresh token 被撤销、被强制登出。 +2. **错误提示会闪**:UI 拿到 `AsyncError` 弹了错误提示,200ms 后自动重试又切回 `AsyncLoading`,用户看到的是提示一闪而过。 +3. **测试 flaky**:单测里断言 `AsyncError` 时,后台还挂着一个待重试的定时器,测试跑完 container 被 dispose 会报 pending timer,或者断言时机不对直接读到 `AsyncLoading`。 + +**决策**:在 `ProviderScope` 上全局关闭 retry,需要重试的地方显式打开。 + +```dart +// app/lib/main.dart +void main() { + runApp( + ProviderScope( + // 全局关掉自动重试:返回 null 表示"不重试" + retry: (retryCount, error) => null, + child: const ContiApp(), + ), + ); +} +``` + +单个 provider 确实需要重试时(比如首页 tile 这种失败了自己悄悄重试一次比弹错更好的场景),在该 provider 上单独开: + +```dart +@Riverpod(retry: _homeTileRetry) +Future> homeTiles(Ref ref) async { /* ... */ } + +// 只重试一次,且只对网络类错误重试;业务错误(BusinessException)重试没有意义 +Duration? _homeTileRetry(int retryCount, Object error) { + if (retryCount >= 1) return null; + if (error is! NetworkException) return null; + return const Duration(milliseconds: 500); +} +``` + +规则:**重试只对"重试一次可能就好了"的错误有意义**——超时、连接失败。业务错误码(后端返回 `code != 0`)、401、参数错误重试多少次都是同样的结果,只是在浪费用户的时间和流量。 + +## 缓存生命周期:默认 autoDispose,长驻要写理由 + +`@riverpod` 注解生成的 provider **默认是 autoDispose 的**(没有 listener 时自动销毁并释放状态)。这个默认值保持不变,原因是门店切换的场景下(见下一节)"用完就销毁"能省掉一大堆手动清理。 + +要改成长驻的写 `@Riverpod(keepAlive: true)`,并且**必须在注释里写清为什么**。目前认可的长驻场景只有三类: + +- 全局单例依赖(`Dio` 实例、`Database` 实例、`SharedPreferences`)——本来就该活到进程结束。 +- 全局会话状态(登录态、当前门店上下文,见 [11-store-context-and-session.md](./11-store-context-and-session.md))。 +- 明确要跨页面保留的数据(比如工作台数据,用户从子页面返回时不希望再 loading 一次)。 + +除此之外一律 autoDispose。列表页数据尤其不要 keepAlive——门店切了、权限变了,长驻的旧数据会直接显示成错的。 + +需要"短时间内返回不重新加载、但也不永久长驻"的,用 `ref.keepAlive()` + 定时器的写法,别直接 `keepAlive: true`: + +```dart +@riverpod +Future> storeList(Ref ref) async { + final link = ref.keepAlive(); + final timer = Timer(const Duration(minutes: 5), link.close); // 5 分钟后允许被回收 + ref.onDispose(timer.cancel); + return ref.watch(storeRepositoryProvider).fetchStores(); +} +``` + +## 门店切换 / 登出时的批量失效 + +PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。 + +统一做法:所有与门店相关的 provider 都 `ref.watch(currentStoreIdProvider)`,让 Riverpod 的依赖图自己完成级联失效。 + +```dart +@riverpod +Future> purchaseOrders(Ref ref) async { + // watch 而不是 read:门店一变,这个 provider 自动重建 + final storeId = ref.watch(currentStoreIdProvider); + return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId); +} +``` + +这条规则要写进 code review checklist:**任何请求带 storeId 的 provider,storeId 必须来自 `ref.watch(currentStoreIdProvider)`,不允许从别处传参或 `ref.read`**。`ref.read` 拿到的是快照,门店变了不会触发重建,这正是最容易漏的地方。 + +依赖图管不到的部分(Drift 本地缓存、H5 会话、导航栈)需要显式清理,完整清单见 [11-store-context-and-session.md](./11-store-context-and-session.md)。 + ## 测试 -- `Notifier`/`AsyncNotifier` 的单元测试用 `ProviderContainer` 直接实例化,不依赖 widget tree。 +- `Notifier`/`AsyncNotifier` 的单元测试用 **`ProviderContainer.test()`** 直接实例化,不依赖 widget tree——这是 Riverpod 3 新增的测试专用构造,自带 `addTearDown(container.dispose)`,不需要再手写。 - Widget 测试中用 `ProviderScope(overrides: [...])` 注入 mock 依赖。 +- 测试里如果某个 provider 单独开了 retry,断言错误状态前记得覆盖掉,否则会遇到 pending timer(详见 [09-testing.md](./09-testing.md))。 ## 附录:Riverpod 是什么,日常怎么用 @@ -62,16 +147,32 @@ class StoreListNotifier extends _$StoreListNotifier { @override Future> build() async { final repository = ref.watch(storeRepositoryProvider); - return repository.fetchNearbyStores(_currentLat, _currentLng); + final position = ref.watch(currentPositionProvider); // 定位也是一个 provider,不是 notifier 的字段 + return repository.fetchNearbyStores(position.lat, position.lng); } Future refresh() async { - state = const AsyncLoading(); - state = await AsyncValue.guard(() => build()); + // 让 Riverpod 重跑 build(),而不是自己去调 build() + ref.invalidateSelf(); + await future; // 等这一轮重建完成,方便下拉刷新的 RefreshIndicator 收起动画 } } ``` +> **不要写成 `state = await AsyncValue.guard(() => build())`。** `build()` 里有 `ref.watch`,只有 Riverpod 自己在重建流程中调用它才能正确重建订阅关系;手动调用会让旧的订阅残留、新的订阅重复注册。需要重跑 `build()` 就用 `ref.invalidateSelf()`。 +> +> 只想改一部分状态、不想重跑整个 `build()` 时,才用 `AsyncValue.guard`,而且里面调的是 repository 而不是 `build()`: +> +> ```dart +> Future loadMore() async { +> final current = state.valueOrNull ?? const []; +> state = await AsyncValue.guard(() async { +> final next = await ref.read(storeRepositoryProvider).fetchNearbyStores(/* ... */); +> return [...current, ...next]; +> }); +> } +> ``` + ```dart // presentation/store_list_page.dart class StoreListPage extends ConsumerWidget { @@ -99,12 +200,13 @@ class StoreListPage extends ConsumerWidget { ```dart test('刷新后状态应更新为最新门店列表', () async { - final container = ProviderContainer( + // ProviderContainer.test() 是 Riverpod 3 的测试专用构造, + // 自动注册 tearDown 做 dispose,不用再写 addTearDown(container.dispose) + final container = ProviderContainer.test( overrides: [ storeRepositoryProvider.overrideWithValue(FakeStoreRepository()), ], ); - addTearDown(container.dispose); final stores = await container.read(storeListNotifierProvider.future); expect(stores, isNotEmpty); @@ -116,6 +218,8 @@ test('刷新后状态应更新为最新门店列表', () async { ## 参考链接 - [Riverpod 官方文档](https://riverpod.dev/) +- [Riverpod 3 迁移指南](https://riverpod.dev/docs/whats_new) +- [Riverpod: Automatic retry](https://riverpod.dev/docs/whats_new#automatic-retry) - [riverpod_generator | Dart package](https://pub.dev/packages/riverpod_generator) - [flutter_riverpod | Dart package](https://pub.dev/packages/flutter_riverpod) - [riverpod_lint | Dart package](https://pub.dev/packages/riverpod_lint) diff --git a/04-routing.md b/04-routing.md index 91773b8..6d1a151 100644 --- a/04-routing.md +++ b/04-routing.md @@ -19,11 +19,131 @@ dependencies: - 底部导航等常驻 UI 用 `ShellRoute`/`StatefulShellRoute` 包裹对应的 feature 路由,不在每个页面里重复搭一遍导航栏。 - 登录态校验统一在 `core_router` 聚合层用 `redirect` 实现,不在每个页面里各自判断 token 是否过期。 - 跨 feature 跳转只能传**可序列化参数**(path 参数、query 参数,或可序列化的 `extra`),不允许把一个 feature 内部的 Dart 类实例通过 `extra` 传给另一个 feature——这是 [01-project-structure.md](./01-project-structure.md) "Feature 间通信" 规则在路由层的具体落地。 +- `feature_*` 不直接依赖 `go_router`,而是依赖 `core_router`,由 `core_router` re-export `GoRoute`/`RouteBase`/`GoRouterState` 等类型。这样将来换路由库或升大版本时,只有 `core_router` 一个地方要动。 + +## `GoRouter` 实例不能因为登录态变化被重建 + +这是 go_router + Riverpod 组合里最常见的一个坑,写错了表现是"用户在三级页面停留时 token 刷新了一下,人被弹回首页"。 + +`GoRouter` 内部持有导航栈。如果 provider 里写 `ref.watch(authStateProvider)`,登录态一变整个 provider 重建、旧 `GoRouter` 被丢弃、新的从 `initialLocation` 开始——导航栈就没了。 + +**正确写法**:`redirect` 里用 `ref.read` 读当前登录态,外面用 `ref.listen` 监听变化并调 `router.refresh()` 让 go_router 重跑一次 `redirect`。 + +```dart +// packages/core_router/lib/src/app_router.dart +final rootNavigatorKey = GlobalKey(); + +final goRouterProvider = Provider((ref) { + final router = GoRouter( + navigatorKey: rootNavigatorKey, // 全局 dialog / 顶层跳转需要它 + initialLocation: '/home', + observers: [NavigationObserver(ref.read(crashReporterProvider))], // 崩溃前的页面路径,见 13 + redirect: (context, state) { + // read 不是 watch:这里只要当前值,订阅由下面的 listen 负责 + final auth = ref.read(authStateProvider); + final loggingIn = state.matchedLocation == '/login'; + + if (!auth.isLoggedIn) { + if (loggingIn) return null; + // 带上原目标,登录成功后回跳 + return '/login?from=${Uri.encodeComponent(state.uri.toString())}'; + } + if (loggingIn) { + final from = state.uri.queryParameters['from']; + return (from == null || from.isEmpty) ? '/home' : Uri.decodeComponent(from); + } + return null; + }, + errorBuilder: (context, state) => RouteNotFoundPage(location: state.uri.toString()), + routes: [ + GoRoute(path: '/login', builder: (context, state) => const LoginPage()), + StatefulShellRoute.indexedStack( + builder: (context, state, navigationShell) => MainShell(navigationShell: navigationShell), + branches: [ + StatefulShellBranch(routes: buildHomeRoutes()), + StatefulShellBranch(routes: buildPurchaseRoutes()), + StatefulShellBranch(routes: buildProfileRoutes()), + ], + ), + ], + ); + + // 登录态变化时只重跑 redirect,不重建 router,导航栈得以保留 + ref.listen(authStateProvider, (_, __) => router.refresh()); + ref.onDispose(router.dispose); + return router; +}); +``` + +要点: + +- `redirect` 里**只能 `ref.read`**,不能 `ref.watch`(`Provider` 的 `create` 已经跑完了,`watch` 在回调里语义也不对)。 +- `ref.onDispose(router.dispose)` 不能漏:`GoRouter` 持有 `Listenable`,不 dispose 在热重载和测试里会泄漏。 +- 用 `ref.listen` 而不是 `refreshListenable`,是因为登录态本身是一个 Riverpod provider,用 `refreshListenable` 还要额外包一个 `ChangeNotifier` 适配层,没必要。 + +### `errorBuilder` 是必须的 + +不写 `errorBuilder`,遇到未注册的路径(深链接拼错、后端下发了一个 App 还不认识的菜单 code、H5 回跳的 URL 有问题)go_router 会显示一个英文的默认错误页,对门店一线员工来说等于崩溃。统一给一个"页面不存在,请检查是否需要升级 App"的兜底页,并把 `state.uri` 上报(见 [13-observability-analytics.md](./13-observability-analytics.md))——这个上报很有价值,能直接暴露出后端下发了 App 不支持的菜单。 + +## 后端动态菜单 → 本地路由的映射 + +PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。 + +约定:后端下发的每个菜单项带一个稳定的 `code`(如 `PURCHASE_ORDER`、`INVENTORY_CHECK`),`core_router` 里维护 `code → 路由路径` 的映射。 + +```dart +// packages/core_router/lib/src/menu_route_map.dart +const menuRouteMap = { + 'PURCHASE_ORDER': '/purchase/orders', + 'INVENTORY_CHECK': '/inventory/check', + 'QUOTE_ORDER': '/webview?target=QUOTE_ORDER', // H5 承载的功能也走这张表 + // ... +}; + +/// 未知 code 返回 null,调用方据此决定隐藏还是提示升级 +String? resolveMenuRoute(String code) => menuRouteMap[code]; +``` + +**未知 `code` 的兜底策略**:直接**隐藏**该菜单项,同时上报一条 `menu_code_unsupported` 事件(带 code 和 App 版本)。 + +- 不选"提示升级":老版本 App 上会因为后端加了新菜单就弹升级提示,对完全不需要这个新功能的门店是骚扰。 +- 隐藏 + 上报的组合能让我们从数据上看到"有多少用户因为版本旧看不到新功能",需要推升级时再针对性推。 + +`code` 一旦定义就不能改含义(改了等于老版本 App 跳错页面),新增功能只能加新 `code`。这条要在后端接口评审时对齐。 + +## H5 页面的路由约定 + +PRD §7 的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为: + +``` +/webview?target=&title=<可选标题> +``` + +**只传目标标识,不传裸 URL。** 真实 URL 由 `core_webview` 拿 `target` 去 App Backend 换票后拿到(见 [10-webview-h5.md](./10-webview-h5.md))。 + +理由:如果路由里能直接塞 URL,那么任何能构造深链接的地方(推送、H5 内跳转、剪贴板)都能让 App 打开任意网页,是一个明确的安全洞。`target` 是一个白名单枚举,能打开哪些页面完全由后端和 App 共同决定。 + +即便如此,`core_webview` 拿到后端返回的 URL 后**仍要做一次域名白名单校验**——纵深防御,后端被打穿或配置写错时还有一道。 + +## 门店切换后的路由重置 + +PRD §11.4:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。 + +**规则:切换门店成功后,清空导航栈回工作台。** + +```dart +// 门店切换成功的回调里 +ref.read(goRouterProvider).go('/home'); // go 而不是 push:替换整个栈 +``` + +`StatefulShellRoute` 的各 branch 栈也会跟着重置。这个动作和 provider 失效、缓存清理、H5 会话失效是一组,统一在 `11-store-context-and-session.md` 里编排,不散在各处调用。 ## 参考链接 - [go_router 官方文档](https://pub.dev/packages/go_router) -- [go_router | Dart package](https://pub.dev/packages/go_router) +- [go_router: Redirection](https://pub.dev/documentation/go_router/latest/topics/Redirection-topic.html) +- [go_router: Navigation(go vs push)](https://pub.dev/documentation/go_router/latest/topics/Navigation-topic.html) +- [StatefulShellRoute API](https://pub.dev/documentation/go_router/latest/go_router/StatefulShellRoute-class.html) ## 附录:go_router 是什么,日常怎么用 @@ -47,38 +167,12 @@ dependencies: 4. **`redirect`**:每次路由变化前会先跑一遍 `redirect` 回调,返回非空字符串就强制跳转——这是实现"未登录访问需要登录的页面 → 自动跳登录页"的地方。 5. **`context.go()` / `context.push()`**:`go` 是替换当前路由(浏览器前进后退语义),`push` 是在当前栈上叠加一层(可以 `pop` 回去)——日常最容易混淆的两个 API,选错会导致返回键行为不符合预期。 -### 使用示例(底部导航 + 门店详情页 + 登录拦截) +### 使用示例(底部导航 + 门店详情页) + +> 完整的 `goRouterProvider`(含登录拦截、回跳、错误兜底)见上文「`GoRouter` 实例不能因为登录态变化被重建」,这里只演示 feature 侧怎么声明自己的路由。 ```dart -// packages/core_router/lib/src/app_router.dart -final goRouterProvider = Provider((ref) { - final authState = ref.watch(authStateProvider); - - return GoRouter( - initialLocation: '/store', - redirect: (context, state) { - final loggingIn = state.matchedLocation == '/login'; - if (!authState.isLoggedIn && !loggingIn) return '/login'; - if (authState.isLoggedIn && loggingIn) return '/store'; - return null; // 不需要重定向 - }, - routes: [ - GoRoute(path: '/login', builder: (context, state) => const LoginPage()), - StatefulShellRoute.indexedStack( - builder: (context, state, navigationShell) => - MainShell(navigationShell: navigationShell), - branches: [ - StatefulShellBranch(routes: buildStoreRoutes()), - StatefulShellBranch(routes: buildPaymentRoutes()), - ], - ), - ], - ); -}); -``` - -```dart -// packages/feature_store/lib/feature_store.dart +// packages/feature_store_mgmt/lib/feature_store_mgmt.dart List buildStoreRoutes() => [ GoRoute( path: '/store', @@ -101,4 +195,4 @@ List buildStoreRoutes() => [ context.push('/store/${store.id}'); ``` -`buildStoreRoutes()` 只在 `feature_store` 包内声明,`app_router.dart` 里只 import 这个函数、不 import `feature_store` 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。 +`buildStoreRoutes()` 只在 `feature_store_mgmt` 包内声明,`app_router.dart` 里只 import 这个函数、不 import 该 feature 的任何页面 widget 类型——保持 [01-project-structure.md](./01-project-structure.md) 的编译期边界。 diff --git a/05-networking.md b/05-networking.md index 52d7d8d..016c574 100644 --- a/05-networking.md +++ b/05-networking.md @@ -2,28 +2,363 @@ ## 决策 -使用 **[dio](https://pub.dev/packages/dio)**(`^5.11.0`,2026-08 快照)作为唯一 HTTP client,统一封装在 `core_network` 包里,通过拦截器链处理鉴权、日志、异常归一化,不允许各 `feature_*` 自建 `Dio` 实例。 +使用 **[dio](https://pub.dev/packages/dio)**(`^5.11.0`,2026-08 快照)作为唯一 HTTP client,统一封装在 `core_network` 包里,通过拦截器链处理鉴权、日志、响应解包、异常归一化,不允许各 `feature_*` 自建 `Dio` 实例。 + +`feature_*` 的 repository **不直接依赖 `Dio`,而是依赖 `core_network` 暴露的 `ApiClient`**——原因见下文「为什么要在 `Dio` 外面再包一层 `ApiClient`」。 ## 依赖 ```yaml dependencies: dio: ^5.11.0 + uuid: ^4.5.1 # 生成客户端 traceId ``` ## 使用规则 -- `core_network` 暴露一个单例 `Dio` 实例(通过 Riverpod provider 注入,见 [03-state-management.md](./03-state-management.md)),所有 `feature_*` 的 repository 只能通过依赖注入拿这个实例,不允许 `Dio()` 直接 new。 -- 拦截器按固定顺序注册:`LogInterceptor`(仅 dev/staging 环境开启)→ `AuthInterceptor`(附加 token,401 时排队刷新)→ `ErrorMappingInterceptor`(把 `DioException` 统一转成项目自定义的 `AppException` 体系)。 +- `core_network` 暴露一个单例 `Dio` 实例和基于它的 `ApiClient`(通过 Riverpod provider 注入,见 [03-state-management.md](./03-state-management.md)),所有 `feature_*` 的 repository 只能通过依赖注入拿这个实例,不允许 `Dio()` 直接 new。 +- 拦截器按固定顺序注册:`LogInterceptor`(仅 dev/staging 环境开启)→ `AuthInterceptor`(附加 token,401 时串行刷新)→ `ApiResultInterceptor`(解开后端统一响应包装)→ `ErrorMappingInterceptor`(把 `DioException` 统一转成项目自定义的 `AppException` 体系)。 - 业务代码只捕获 `AppException` 及其子类(如 `NetworkException`、`UnauthorizedException`、`BusinessException`),不直接处理 `DioException`——异常归一化只在 `core_network` 内部发生一次。 -- 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,`baseUrl` 和超时时间统一在 `core_network` 里按环境配置(见 [08-build-flavors.md](./08-build-flavors.md),待写)。 -- 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 `CancelToken`,在对应 `Notifier`/`State` 的 `dispose` 时调用 `cancel()`。 +- 每个 feature 的 repository 只声明自己需要的接口方法,不直接拼接完整 URL 字符串到处写,`baseUrl` 和超时时间统一在 `core_network` 里按环境配置(见 [08-build-flavors.md](./08-build-flavors.md))。 +- 需要取消请求的场景(比如页面销毁时中断未完成的请求)统一使用 `CancelToken`,在对应 provider 的 `ref.onDispose` 里调用 `cancel()`。 + +## 后端契约:统一响应包装 + +后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](./backend/06-api-design.md))。**解包只在 `ApiResultInterceptor` 里做一次**,repository 拿到的 `response.data` 已经是里层的 `data`。 + +```dart +// packages/core_network/lib/src/api_result_interceptor.dart +class ApiResultInterceptor extends Interceptor { + ApiResultInterceptor(this._logger); + final AppLogger _logger; + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + final body = response.data; + // 非 JSON 对象响应(如文件下载)不走解包 + if (body is! Map || !body.containsKey('code')) { + return handler.next(response); + } + + // 用 num 而不是 int:万一后端某个字段序列化成了 JSON 浮点数,as int 会直接抛 + final code = (body['code'] as num?)?.toInt(); + final traceId = body['traceId'] as String?; + _logger.d('[api] ${response.requestOptions.uri} code=$code traceId=$traceId'); + + if (code == 0) { + // 把外层包装剥掉,repository 的 fromJson 只需要认识 data 的结构 + response.data = body['data']; + return handler.next(response); + } + + // code != 0 一律转成业务异常,不让它以"成功响应"的形态流到业务层 + handler.reject( + DioException( + requestOptions: response.requestOptions, + response: response, + error: BusinessException( + code: code ?? -1, + message: (body['message'] as String?) ?? '请求失败', + traceId: traceId, + ), + ), + true, // callFollowingErrorInterceptor + ); + } +} +``` + +**`traceId` 必须留存**:backend 06/08 明确指望"用户报一个 traceId,后端就能在日志里定位这次请求"。所以 + +- 每条 API 日志都带 `traceId`(成功失败都带)。 +- 错误提示 UI 上要能看到 traceId(不用显眼,可以放在"详情"里或长按复制),具体展示形式见 [12-error-and-api-contract.md](./12-error-and-api-contract.md)。 +- 崩溃/错误上报时把 traceId 作为 tag 带上(见 [13-observability-analytics.md](./13-observability-analytics.md))。 + +## 统一请求头 + +```dart +// packages/core_network/lib/src/header_interceptor.dart +@override +void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final env = _ref.read(appEnvProvider); + options.headers.addAll({ + 'X-Trace-Id': const Uuid().v4(), // 客户端生成,便于端到端串联 + 'X-App-Version': env.appVersion, // 如 1.4.0+142 + 'X-Platform': Platform.isIOS ? 'ios' : 'android', + 'X-Device-Id': _ref.read(deviceIdProvider), // 安装级匿名 ID,不是 IMEI/IDFA + }); + // 当前门店上下文;未登录/未选门店时不带 + final storeId = _ref.read(currentStoreIdProvider.select((s) => s)); + if (storeId != null) options.headers['X-Store-Id'] = '$storeId'; + handler.next(options); +} +``` + +- `X-Store-Id` 是**冗余信息**:access token 的 claims 里已经有 `storeId`(backend 04),后端以 token 为准。带这个头只是为了日志排查时能一眼看出客户端当时认为自己在哪个门店——如果两者不一致,说明切换门店后 token 没换,是个 bug 信号。 +- **`X-Trace-Id` 需要和后端对齐一次**:backend 06 说 traceId 由后端入口 filter 生成。约定是**后端优先复用请求头里的 `X-Trace-Id`,没有才自己生成**,否则客户端日志和服务端日志会各用一套 ID 对不上。这条挂在待确认项里。 +- 不采集 IMEI/IDFA/MAC 等设备唯一标识,`deviceId` 用首次安装时生成并存本地的随机 UUID,避免踩合规红线(见 [07-native-integration.md](./07-native-integration.md) 的隐私清单部分)。 + +## Token 刷新:必须串行,失败即登出 + +这一段是整个网络层最容易写错、错了后果最严重的地方,因为它和后端的 **refresh token 轮换策略**强耦合。 + +按 [backend/04-security-auth.md](./backend/04-security-auth.md): + +- refresh token 是**一次性**的,每次换 access token 都会签发新的、旧的立刻 `revokedAt`。 +- **旧 token 再被用一次 = 判定为泄漏重放,该用户名下所有 refresh token 全部撤销**。 + +由此推出三条客户端硬性约束: + +1. **绝对不能并发刷新。** 两个请求同时 401、同时拿同一个旧 refresh token 去换,第二个必然被判为重放 → 用户被全设备强制登出。这就是刷新队列存在的真正原因,不是为了"省一次请求"。 +2. **刷新失败不能重试。** 失败意味着 refresh token 已过期/已撤销/已被重放,再试一次结果一样。直接登出跳登录页。 +3. **刷新请求本身不能走带 `AuthInterceptor` 的那个 `Dio`**,否则刷新接口返回 401 时会再次触发刷新,无限递归。`core_auth` 内部自建一个**裸 `Dio`**(不装任何拦截器)专门发刷新请求——这也是 [01-project-structure.md](./01-project-structure.md) 里 "`core_auth` 不依赖 `core_network`" 这条规则的由来。 + +```dart +// packages/core_network/lib/src/auth_interceptor.dart +class AuthInterceptor extends Interceptor { + AuthInterceptor(this._ref); + final Ref _ref; + + /// 同一时刻最多一个刷新在跑;其他 401 请求 await 同一个 Future + Future? _refreshing; + + static const _retriedKey = 'x-retried'; + + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + final token = _ref.read(authStateProvider).accessToken; + if (token != null) options.headers['Authorization'] = 'Bearer $token'; + handler.next(options); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) async { + if (err.response?.statusCode != 401) return handler.next(err); + + // 一次性重试标记:带着新 token 重放后又 401,说明不是 token 的问题,别再刷了 + if (err.requestOptions.extra[_retriedKey] == true) { + _ref.read(authStateProvider.notifier).logout(); + return handler.next(err); + } + + try { + // 用一个共享的 Future 天然实现串行:先到的发起刷新,后到的复用同一个 Future + _refreshing ??= _ref.read(authRepositoryProvider).refreshToken(); + await _refreshing; + } catch (e) { + // 刷新失败 = refresh token 已失效,不重试,直接登出 + _ref.read(authStateProvider.notifier).logout(); + return handler.next(err); + } finally { + _refreshing = null; + } + + // 刷新成功,用新 token 重放原请求 + try { + final options = err.requestOptions + ..extra[_retriedKey] = true + ..headers['Authorization'] = + 'Bearer ${_ref.read(authStateProvider).accessToken}'; + handler.resolve(await _ref.read(dioProvider).fetch(options)); + } on DioException catch (e) { + handler.next(e); + } + } +} +``` + +> 对比:常见的"`bool _isRefreshing` + `List` 队列"写法有个致命缺陷——`catch` 分支里如果忘了对队列里的 `Completer` 调 `completeError` 并清空,所有排队的请求会**永久挂起**(`await completer.future` 永不返回),表现是 UI 一直转圈、用户只能杀进程。用共享 `Future` 的写法从结构上就不存在这个问题:刷新失败时 `await _refreshing` 对每个等待者都会抛异常,各自走各自的 `catch`,没有需要手动清理的队列。 + +`core_auth` 侧的刷新实现: + +```dart +// packages/core_auth/lib/src/token_refresher.dart +class TokenRefresher { + // 裸 Dio:不装任何拦截器,避免刷新请求自己再触发一轮刷新 + final _bare = Dio(BaseOptions( + baseUrl: AppEnv.current.apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + )); + + Future refresh(String refreshToken) async { + final res = await _bare.post('/api/v1/auth/refresh', data: {'refreshToken': refreshToken}); + final data = res.data['data'] as Map; // 裸 Dio 没有解包拦截器,手动取 + // 后端轮换:新的 refreshToken 必须立刻覆盖存储,旧的已经作废了 + return TokenPair( + accessToken: data['accessToken'] as String, + refreshToken: data['refreshToken'] as String, + ); + } +} +``` + +**新的 refresh token 一定要写回 secure storage**(见 [06-local-storage.md](./06-local-storage.md))。写回失败或写回前进程被杀,下次启动用旧 token 就会触发重放判定——所以写回要在"通知 `authState` 更新"之前完成。 + +## 异常归一化 + +```dart +// packages/core_network/lib/src/error_mapping_interceptor.dart +class ErrorMappingInterceptor extends Interceptor { + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + // 已经是 AppException 的(比如 ApiResultInterceptor 抛的 BusinessException)直接放行, + // 不要二次包装成 NetworkException + if (err.error is AppException) return handler.next(err); + + final mapped = switch (err.type) { + DioExceptionType.connectionTimeout || + DioExceptionType.sendTimeout || + DioExceptionType.receiveTimeout => NetworkException('网络超时,请检查网络后重试'), + DioExceptionType.cancel => RequestCancelledException(), + DioExceptionType.badResponse when err.response?.statusCode == 401 => + UnauthorizedException(), + DioExceptionType.badResponse => HttpException( + statusCode: err.response?.statusCode ?? -1, + message: '服务异常(${err.response?.statusCode})', + ), + _ => NetworkException('网络异常,请稍后重试'), + }; + handler.next(DioException( + requestOptions: err.requestOptions, + response: err.response, + error: mapped, + )); + } +} +``` + +### 为什么要在 `Dio` 外面再包一层 `ApiClient` + +拦截器**没有办法让 `dio.get()` 抛出 `AppException`**。dio 的错误通道只认 `DioException`,`handler.reject(...)` 传进去的必须是 `DioException`,我们的 `AppException` 只能挂在它的 `error` 字段上。也就是说,如果 repository 直接调 `dio.get()`,业务层写 + +```dart +try { ... } on UnauthorizedException { ... } // ❌ 永远进不来 +``` + +是**捕获不到的**——实际抛出来的仍然是 `DioException`。 + +解决办法是在 `core_network` 的出口把 `DioException.error` 拆出来重抛: + +```dart +// packages/core_network/lib/src/api_client.dart +class ApiClient { + ApiClient(this._dio); + final Dio _dio; + + Future get(String path, {Map? query, CancelToken? cancelToken}) => + _run(() => _dio.get(path, queryParameters: query, cancelToken: cancelToken)); + + Future post(String path, {Object? data, CancelToken? cancelToken}) => + _run(() => _dio.post(path, data: data, cancelToken: cancelToken)); + + Future _run(Future> Function() send) async { + try { + final res = await send(); + return res.data as T; + } on DioException catch (e, st) { + final error = e.error; + // 拦截器已经归一化过,这里只负责把它从 DioException 的壳里拿出来重抛 + if (error is AppException) Error.throwWithStackTrace(error, st); + Error.throwWithStackTrace(NetworkException('网络异常,请稍后重试'), st); + } + } +} +``` + +**规则:repository 一律注入 `ApiClient`,不注入 `Dio`。** 只有 `core_network` 内部和 `core_auth` 的裸 Dio 会直接碰 `Dio` 类型。这样上面那段 `on UnauthorizedException` 才真的成立。 + +`Error.throwWithStackTrace` 保留原始堆栈,否则上报到崩溃平台的堆栈会全部指向 `_run` 这一行,等于没有堆栈。 + +## 超时、重试与幂等 + +```dart +BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + sendTimeout: const Duration(seconds: 30), // 上传单独放宽,见下文 +) +``` + +**默认不做自动重试。** 理由和 [03-state-management.md](./03-state-management.md) 里全局关掉 Riverpod retry 是同一条:多层重试叠加会让一次用户操作变成难以预测的 N 次请求,日志也没法看。需要重试的地方显式写、并且必须满足: + +- **只重试 GET**,或后端明确支持幂等键(`Idempotency-Key` 头)的 POST。 +- 只对超时/连接失败重试,业务错误码和 4xx 不重试。 +- 最多 1 次。 + +`f6-integration` 侧后端已经配了重试和熔断([backend/05-integration-layer.md](./backend/05-integration-layer.md)),客户端再叠一层意义不大,反而会把后端的熔断窗口打满。 + +## `CancelToken` 与 provider 生命周期 + +```dart +@riverpod +Future> purchaseOrders(Ref ref) async { + final cancelToken = CancelToken(); + ref.onDispose(cancelToken.cancel); // 页面销毁 / 门店切换导致 provider 重建时自动中断 + + final storeId = ref.watch(currentStoreIdProvider); + return ref.watch(purchaseRepositoryProvider).fetchOrders(storeId, cancelToken: cancelToken); +} +``` + +被取消的请求会抛 `RequestCancelledException`。**UI 层必须把它当"什么都不做"处理,不能弹错误提示**——用户主动离开页面时看到"请求失败"是很糟的体验。这条在 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的错误展示规则里统一约定。 + +## 文件与图片上传 + +PRD §7.4(H5 桥接的图片选择/上传)和施工照片场景都要用到。 + +```dart +// packages/core_network/lib/src/api_client.dart +Future upload( + String path, { + required List files, + Map? fields, + void Function(int sent, int total)? onProgress, + CancelToken? cancelToken, +}) async { + final formData = FormData.fromMap({ + ...?fields, + 'files': [ + for (final f in files) + await MultipartFile.fromFile(f.path, filename: p.basename(f.path)), + ], + }); + return _run(() => _dio.post( + path, + data: formData, + cancelToken: cancelToken, + onSendProgress: onProgress, + // 上传单独放宽超时,用全局的 30s 传几张原图会超 + options: Options(sendTimeout: const Duration(minutes: 3)), + )); +} +``` + +约定: + +- **上传前必须压缩**。门店员工用手机直接拍的照片通常 3–8 MB,原图上传在门店 WiFi 环境下大概率超时。统一压到长边 1600px、JPEG 质量 80,超过 2 MB 再降一档。 +- **进度必须可见**:多图上传要有整体进度,否则用户会以为卡死反复点。 +- **失败要能单张重传**,不能因为第 5 张失败就让前 4 张重来。所以 UI 上传状态按单张维护。 +- `FormData` **不可重用**:dio 的 `FormData` 是流,重试必须重新构造一个,直接复用会报 stream already listened。 + +## 传输安全 + +- **全环境强制 HTTPS**,包括 dev。Android 侧在 `network_security_config.xml` 里关掉明文流量(`cleartextTrafficPermitted="false"`),iOS 不放开 ATS 例外。这样"某个环境不小心配了 http 的 baseUrl"会在开发阶段就直接失败,而不是上线后才发现。 +- **证书 pinning:首版不做。** 取舍如下——pinning 能防中间人抓包,但代价是证书轮换时必须发新版 App,否则全线不可用;而门店 App 走的是公司自有域名 + 标准 CA,主要威胁模型是"员工手机装了抓包工具看接口",这个用 pinning 挡的收益不高。如果后续有合规要求再加,届时用**双证书 pin(当前 + 备用)** 并且 pin 到中间 CA 而不是叶子证书,留出轮换空间。 +- 日志脱敏:`LogInterceptor` 只在 dev/staging 开启,且 `Authorization` 头、密码、手机号在打日志前替换成掩码。这条同样适用于上报到崩溃平台的面包屑(见 [13-observability-analytics.md](./13-observability-analytics.md))。 + +## 待确认项 + +- `X-Trace-Id` 由客户端生成、后端复用——需与后端确认入口 filter 的实现。 +- 分页参数字段名(backend 06 的「待补充」里也挂着这一项,见 [02-layering.md](./02-layering.md))。 +- 错误码表(backend 06 待补充),拿到后补进 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 的映射表。 +- 上传接口的大小上限、允许的文件类型、是否走对象存储直传。 ## 参考链接 - [dio 官方文档](https://pub.dev/packages/dio) -- [dio | Dart package](https://pub.dev/packages/dio) - [Dio Interceptors 文档](https://pub.dev/packages/dio#interceptors) +- [Dio CancelToken](https://pub.dev/packages/dio#cancellation) +- [Android network security config](https://developer.android.com/privacy-and-security/security-config) ## 附录:dio 是什么,日常怎么用 @@ -42,110 +377,71 @@ Dart 内置的 `http` 包只提供最基础的请求能力,实际项目里几 ### 核心概念 1. **`Dio` 实例**:一个 client 对象,带 `BaseOptions`(`baseUrl`、`connectTimeout` 等默认配置),项目里应该只有一个全局实例,而不是每个地方各建一个。 -2. **`Interceptor`**:可以拦截请求发出前(`onRequest`)、响应回来后(`onResponse`)、出错时(`onError`)三个时机,多个拦截器按注册顺序像洋葱一样依次包裹。 +2. **`Interceptor`**:可以拦截请求发出前(`onRequest`)、响应回来后(`onResponse`)、出错时(`onError`)三个时机。**注意 dio 的执行顺序**:三个时机都是按注册顺序**正向**执行的,不是"请求正向、响应反向"的洋葱模型——这一点和很多人的直觉不同,配置拦截器顺序时要留意。 3. **`DioException`**:dio 所有的错误(超时、连接失败、非 2xx 状态码等)都会包装成这个类型,可以在拦截器里统一识别、转换。 4. **`CancelToken`**:用来主动取消一个还没完成的请求,常见场景是页面销毁或用户切走时中断请求,避免拿到结果时 widget 已经不存在了。 -### 使用示例(token 自动附加 + 401 自动刷新排队 + 异常归一化) +### 拦截器链的组装 ```dart // packages/core_network/lib/src/dio_client.dart final dioProvider = Provider((ref) { + final env = ref.watch(appEnvProvider); final dio = Dio(BaseOptions( - baseUrl: ref.watch(appEnvProvider).apiBaseUrl, // 见 08-build-flavors.md + baseUrl: env.apiBaseUrl, // 见 08-build-flavors.md connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 15), + sendTimeout: const Duration(seconds: 30), )); dio.interceptors.addAll([ - if (ref.watch(appEnvProvider).enableLog) LogInterceptor(responseBody: false), + HeaderInterceptor(ref), + if (env.enableLog) LogInterceptor(responseBody: false), AuthInterceptor(ref), + ApiResultInterceptor(ref.watch(loggerProvider)), ErrorMappingInterceptor(), ]); return dio; }); + +final apiClientProvider = Provider((ref) => ApiClient(ref.watch(dioProvider))); ``` +顺序的理由:`AuthInterceptor` 必须排在 `ErrorMappingInterceptor` 前面,才能在 401 被归一化成 `UnauthorizedException` **之前**先尝试刷新 token;`ApiResultInterceptor` 排在 `ErrorMappingInterceptor` 前面,是因为它抛出的 `BusinessException` 需要能被后者识别并放行(后者第一行就是判断 `err.error is AppException`)。 + +### 业务层看到的样子 + ```dart -// packages/core_network/lib/src/auth_interceptor.dart -class AuthInterceptor extends Interceptor { - final Ref _ref; - bool _isRefreshing = false; - final _pendingRequests = >[]; - - AuthInterceptor(this._ref); +// data/repository/purchase_repository_impl.dart +class PurchaseRepositoryImpl implements PurchaseRepository { + PurchaseRepositoryImpl(this._api); + final ApiClient _api; @override - void onRequest(RequestOptions options, RequestInterceptorHandler handler) { - final token = _ref.read(authStateProvider).accessToken; - if (token != null) options.headers['Authorization'] = 'Bearer $token'; - handler.next(options); - } - - @override - void onError(DioException err, ErrorInterceptorHandler handler) async { - if (err.response?.statusCode != 401) return handler.next(err); - - if (_isRefreshing) { - // 已经有一个刷新请求在跑,排队等它完成,避免并发刷新 - final completer = Completer(); - _pendingRequests.add(completer); - await completer.future; - return handler.resolve(await _retry(err.requestOptions)); - } - - _isRefreshing = true; - try { - await _ref.read(authRepositoryProvider).refreshToken(); - for (final c in _pendingRequests) { - c.complete(); - } - _pendingRequests.clear(); - handler.resolve(await _retry(err.requestOptions)); - } catch (_) { - _ref.read(authStateProvider.notifier).logout(); - handler.next(err); - } finally { - _isRefreshing = false; - } - } - - Future _retry(RequestOptions options) { - final dio = _ref.read(dioProvider); - return dio.fetch(options); + Future> fetchOrders(int storeId, {CancelToken? cancelToken}) async { + // 返回的已经是 ApiResult 里的 data,外层包装由拦截器解开 + final list = await _api.get>( + '/api/v1/purchase/orders', + query: {'storeId': storeId}, + cancelToken: cancelToken, + ); + return list.map((e) => PurchaseOrder.fromJson(e as Map)).toList(); } } ``` ```dart -// packages/core_network/lib/src/error_mapping_interceptor.dart -class ErrorMappingInterceptor extends Interceptor { - @override - void onError(DioException err, ErrorInterceptorHandler handler) { - final mapped = switch (err.type) { - DioExceptionType.connectionTimeout || - DioExceptionType.receiveTimeout => NetworkException('网络超时'), - DioExceptionType.badResponse when err.response?.statusCode == 401 => - UnauthorizedException(), - DioExceptionType.badResponse => BusinessException( - err.response?.data['message'] ?? '请求失败', - code: err.response?.statusCode, - ), - _ => NetworkException('网络异常,请稍后重试'), - }; - handler.reject(DioException(requestOptions: err.requestOptions, error: mapped)); - } -} -``` - -业务层代码只需要这样写,不用关心 dio 内部细节: - -```dart +// presentation 层 try { - final stores = await storeRepository.fetchNearbyStores(lat, lng); + final orders = await repository.fetchOrders(storeId); } on UnauthorizedException { - // 跳登录页 + // 已经被 AuthInterceptor 处理过登出,这里一般只需要静默 +} on BusinessException catch (e) { + showToast('${e.message}(${e.traceId})'); +} on RequestCancelledException { + // 用户主动离开,什么都不做 } on AppException catch (e) { - // 统一展示 e.message + showToast(e.message); } ``` diff --git a/06-local-storage.md b/06-local-storage.md index 4de696b..943b07f 100644 --- a/06-local-storage.md +++ b/06-local-storage.md @@ -2,28 +2,38 @@ ## 决策 -按数据类型分三档存储,全部封装在 `core_storage` 包内,`feature_*` 不直接依赖底层存储库: +按数据类型分三档存储,`feature_*` 不直接依赖底层存储库: -| 数据类型 | 方案 | 版本(2026-08 快照) | -|---|---|---| -| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` | -| 敏感数据(token、refresh token) | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `^11.0.0` | -| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` | +| 数据类型 | 方案 | 版本(2026-08 快照) | 归属包 | +|---|---|---|---| +| 结构化/关系型数据(门店列表缓存、订单历史等) | **[Drift](https://pub.dev/packages/drift)** | `^2.34.3` | `core_storage` | +| 敏感数据(token、refresh token) | **[flutter_secure_storage](https://pub.dev/packages/flutter_secure_storage)** | `11.0.0`(锁死) | **`core_auth`** | +| 简单非敏感 KV(是否看过引导页、用户偏好设置) | **[shared_preferences](https://pub.dev/packages/shared_preferences)** | `^2.5.5` | `core_storage` | + +> **secure storage 归 `core_auth` 独占,不放进 `core_storage`。** 唯一读写 token 的地方就是 `core_auth`,把它放进 `core_storage` 会逼出一条 `core_auth → core_storage` 的依赖,而 `core_storage` 里其他东西 `core_auth` 一样都用不上(见 [01-project-structure.md](./01-project-structure.md) 的依赖例外表)。代价是 `core_auth` 自己要依赖 `flutter_secure_storage`,这比多一条包间依赖划算。 ## 依赖 ```yaml +# core_storage dependencies: drift: ^2.34.3 - sqlite3_flutter_libs: ^0.5.0 - flutter_secure_storage: ^11.0.0 + drift_flutter: ^0.3.1 # 打开数据库的官方 Flutter 胶水包 + path_provider: ^2.1.6 shared_preferences: ^2.5.5 dev_dependencies: - drift_dev: ^2.34.3 - build_runner: ^2.4.0 + drift_dev: ^2.34.5 + build_runner: ^2.15.2 + +# core_auth +dependencies: + flutter_secure_storage: 11.0.0 # 锁死,不用 ^,理由见下文 ``` +> **不要再写 `sqlite3_flutter_libs`。** 这个包已经 **EOL**(最新版本号就叫 `0.6.0+eol`),sqlite3 3.x 起不再需要它。drift 官方现在的推荐组合是 `drift_flutter` + `path_provider`,`driftDatabase()` 会帮你处理原生库加载、数据库文件路径、以及后台 isolate。 + + ## 使用规则 - 全仓库**只有一个 Drift 数据库实例**,定义在 `core_storage` 里,不允许每个 `feature_*` 各自建一个 SQLite 文件——避免多个数据库文件之间做跨 feature 查询/事务的麻烦。 @@ -32,10 +42,155 @@ dev_dependencies: - token / refresh token 只能经过 `core_auth` 包里封装的 secure storage 读写方法,不允许其他 `core_*`/`feature_*` 直接调用 `FlutterSecureStorage` 实例。 - 数据库表结构变更必须写 migration(`onUpgrade` + `schemaVersion` 递增),不允许直接改字段定义后期望"重装了事"——线上用户已有数据需要平滑迁移。 +## 所有业务缓存表必须带 `storeId` + +PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。 + +**硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且 + +- 所有查询都带 `where(tbl.storeId.equals(currentStoreId))`,不允许无门店条件的全表查询; +- `storeId` 建索引; +- 表的主键包含 `storeId`(或用 `(storeId, businessId)` 联合主键),避免不同门店的同 ID 记录互相覆盖。 + +```dart +class PurchaseOrderCache extends Table { + IntColumn get storeId => integer()(); + TextColumn get orderId => text()(); + TextColumn get payload => text()(); + DateTimeColumn get cachedAt => dateTime()(); + + @override + Set get primaryKey => {storeId, orderId}; // 联合主键,天然按门店隔离 +} +``` + +不设 `storeId` 的表只有一类:**与门店无关的全局数据**(如 App 配置、引导页标记),这类应该放 `shared_preferences` 而不是 Drift。 + +## 登出 / 切换门店的清理策略 + +| 场景 | Drift 业务表 | shared_preferences | secure storage (token) | H5 会话 | +|---|---|---|---|---| +| **切换门店** | 删除**非当前门店**的行(或全清,见下) | 保留 | 保留 | 失效(见 [10-webview-h5.md](./10-webview-h5.md)) | +| **登出** | **全部清空** | 只清与用户相关的键,保留 App 级偏好 | **全部清空** | 失效 + 清 Cookie/LocalStorage | +| **切换账号** | 同登出 | 同登出 | 同登出 | 同登出 | + +**切换门店时是"只留当前门店"还是"全清"**:选**全清**。理由是保留其他门店的旧数据没有实际收益(用户切回去时数据早已过期,还是要重新拉),但会带来"用户看到的是几天前的数据却没有任何提示"这类问题;而全清的代价只是切回去时多一次 loading。 + +```dart +// packages/core_storage/lib/src/app_database.dart +extension StoreScopedCleanup on AppDatabase { + /// 切换门店 / 登出时调用;在一个事务里清,避免清一半被杀进程留下不一致状态 + Future clearBusinessCache() => transaction(() async { + for (final table in allTables.where(_isBusinessCache)) { + await delete(table).go(); + } + }); +} +``` + +**清理动作由谁触发**:统一在 `11-store-context-and-session.md` 定义的会话编排里调用,各 feature 不自己监听门店变化去清自己的表——分散清理必然会漏。 + +**清理顺序也有讲究**:先切断新写入(让 provider 失效、请求取消),再清库。反过来会出现"刚清完,一个在途请求的回调又把旧门店数据写回去了"。 + +## 缓存 TTL + +Drift 里的缓存**默认都是"降级用"的,不是"优先用"的**:正常路径永远走网络,缓存只在网络失败或首屏加载时先垫一下。这样 TTL 的作用就不是"过期就不能用",而是"过期了就不要再拿它当有效内容展示"。 + +| 数据 | TTL | 过期后行为 | +|---|---|---| +| 门店列表 | 24h | 仍展示,但顶部提示"数据可能不是最新" | +| 工作台 tile 数据 | 5min | 不展示缓存,直接走 loading | +| 订单/采购单列表 | 10min | 展示缓存 + 下拉刷新 | +| 经营/财务分析数据 | 不缓存 | — | + +每张缓存表都有 `cachedAt` 列,判断逻辑写在 `local_datasource` 里,不散落在 UI。 + +**经营/财务类数据不落本地**:这类是敏感数据,手机丢失或被拿去 root 后 SQLite 文件可以直接读。收益(离线可看)远小于风险,直接不缓存最省事——也就不需要引入 SQLCipher 这类数据库加密方案(引入的话要处理密钥存哪、密钥丢了怎么办、以及原生库体积增加)。这条如果后续业务要求离线查看经营数据,再重新评估。 + +## Migration 必须被验证,不能只靠"写了" + +"必须写 migration"这条规则没有配套验证手段的话,等于没有——migration 写错的表现是**线上用户升级后 App 一启动就崩**,而开发机上因为是全新安装,永远测不出来。 + +Drift 官方提供了 schema 快照 + 验证工具链,纳入流程: + +```bash +# 1. 每次 schemaVersion 递增后,导出当前 schema 快照(产物入库) +fvm dart run drift_dev schema dump lib/src/app_database.dart drift_schemas/ + +# 2. 生成迁移测试的辅助代码 +fvm dart run drift_dev schema generate drift_schemas/ test/generated_migrations/ +``` + +```dart +// packages/core_storage/test/migration_test.dart +void main() { + late SchemaVerifier verifier; + setUpAll(() => verifier = SchemaVerifier(GeneratedHelper())); + + test('从 v1 到最新版本的迁移都能跑通', () async { + for (var from = 1; from < AppDatabase.latestSchemaVersion; from++) { + final connection = await verifier.startAt(from); + final db = AppDatabase.forTesting(connection); + await verifier.migrateAndValidate(db, AppDatabase.latestSchemaVersion); + await db.close(); + } + }); +} +``` + +规则: + +- `drift_schemas/` 下的 JSON 快照**入 git**,每次改表结构必须跟着生成新快照,PR 里能直接看到 schema diff。 +- 迁移测试进 `melos run test`,CI 卡点(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。 +- `migrateAndValidate` 只验证**结构**,不验证数据。涉及数据搬迁(拆表、改语义)的迁移要额外写一个"造老数据 → 迁移 → 断言新数据"的用例。 + +## flutter_secure_storage 11.0.0 的升级风险 + +`flutter_secure_storage 11.0.0` 是 2026-08 才发的大版本,**改了 Android 侧的默认加密实现**(RSA OAEP + AES-GCM)。这意味着: + +- 用旧版本写入的数据,升级后有**读不出来**的风险(返回 null 或抛异常)。对我们来说就是"用户升级 App 后被登出"。 +- 版本号在 `pubspec.yaml` 里**写死 `11.0.0`,不用 `^`**。这个包的历史上出现过 minor 版本改加密实现的情况,`^` 会让某次 `pub upgrade` 悄悄换掉加密方式,而问题只在真机升级路径上暴露,CI 和新装都测不出来。升级它必须是一次显式的、带回归验证的动作。 +- **首版是新 App,不存在历史数据**,所以本次没有实际迁移风险;这条规则是为**后续升级**立的。 + +读取失败的兜底必须写: + +```dart +Future readAccessToken() async { + try { + return await _storage.read(key: _kAccessToken); + } catch (e, st) { + // 读不出来一律当作未登录:清空 + 跳登录页,而不是抛异常让用户卡在启动页 + _logger.e('secure storage 读取失败,按未登录处理', error: e, stackTrace: st); + await clear(); + return null; + } +} +``` + +**绝对不能让 secure storage 的异常向上冒到启动流程**——那会变成"升级后一打开就白屏/崩溃",比重新登录严重得多。 + +## 数据库在后台 isolate 打开 + +大批量写入(比如一次同步几百条订单)在主 isolate 上跑会掉帧。`drift_flutter` 的 `driftDatabase()` **默认就用后台 isolate**,只要不手动关掉即可: + +```dart +// packages/core_storage/lib/src/connection.dart +QueryExecutor openConnection() => driftDatabase( + name: 'conti_app', + native: const DriftNativeOptions( + databaseDirectory: getApplicationSupportDirectory, // iOS 上不要用 Documents,会被 iCloud 备份 + ), + ); +``` + +iOS 上数据库文件放 `Application Support` 而不是 `Documents`:`Documents` 会被 iCloud 备份,缓存数据没必要占用户的 iCloud 空间,苹果审核也可能因此提意见。 + + ## 参考链接 - [Drift 官方文档](https://drift.simonbinder.eu/) -- [drift | Dart package](https://pub.dev/packages/drift) +- [drift_flutter | Dart package](https://pub.dev/packages/drift_flutter) +- [Drift: Migrations 与 schema 验证](https://drift.simonbinder.eu/Migrations/tests/) - [flutter_secure_storage | Dart package](https://pub.dev/packages/flutter_secure_storage) - [shared_preferences | Dart package](https://pub.dev/packages/shared_preferences) @@ -96,12 +251,15 @@ class StoreDao extends DatabaseAccessor with _$StoreDaoMixin { ```dart // packages/core_storage/lib/src/app_database.dart -@DriftDatabase(tables: [StoreCache, PaymentHistory], daos: [StoreDao, PaymentHistoryDao]) +@DriftDatabase(tables: [StoreCache, PurchaseOrderCache], daos: [StoreDao, PurchaseOrderDao]) class AppDatabase extends _$AppDatabase { - AppDatabase() : super(_openConnection()); + AppDatabase() : super(openConnection()); + AppDatabase.forTesting(super.connection); // 迁移测试用 + + static const latestSchemaVersion = 2; @override - int get schemaVersion => 2; + int get schemaVersion => latestSchemaVersion; @override MigrationStrategy get migration => MigrationStrategy( @@ -114,6 +272,8 @@ class AppDatabase extends _$AppDatabase { } ``` +> 门店列表这张表存的是"当前用户能访问哪些门店",属于用户级而不是门店级数据,所以没有 `storeId` 列——它是上文那条"业务缓存表必须带 `storeId`"规则的合理例外。`PurchaseOrderCache` 那种才是典型的门店级数据。 + ```dart // feature_store 的 local_datasource 只依赖 StoreDao,不直接碰 AppDatabase class StoreLocalDataSource { @@ -156,3 +316,8 @@ class TokenStorage { ``` `TokenStorage` 是全仓库唯一直接持有 `FlutterSecureStorage` 实例的类,其他包只能通过 `core_auth` 暴露的 provider 间接读写 token。 + +## 待确认项 + +- 经营/财务数据是否需要离线查看。如果需要,要重新评估数据库加密(SQLCipher)方案,涉及密钥保管和原生库体积。 +- 门店切换时"全清缓存"在门店数量多、切换频繁的用户上的实际体验,上线后看埋点再调。 diff --git a/07-native-integration.md b/07-native-integration.md index f5ae98a..8ba96fc 100644 --- a/07-native-integration.md +++ b/07-native-integration.md @@ -15,33 +15,222 @@ dev_dependencies: ``` native_scan/ + pubspec.yaml # 必须有 flutter: plugin: platforms: 声明,见下文 pigeons/ scan_api.dart # 接口 schema 定义,唯一手写的源文件 lib/ - native_scan.dart # 对外导出:封装好的公共 API 类(feature 只调这个) + native_scan.dart # 对外导出:封装好的公共 API 类(调用方只调这个) src/ generated/ # pigeon 生成的 Dart 端代码,不手动修改 android/ - src/main/kotlin/.../ScanApiImpl.kt # 生成的 Kotlin host API 接口的具体实现 + src/main/kotlin/.../ScanApi.g.kt # pigeon 生成 + src/main/kotlin/.../ScanApiImpl.kt # 手写:生成的 Kotlin host API 接口的实现 + src/main/kotlin/.../NativeScanPlugin.kt # 手写:插件注册入口 ios/ - Classes/ScanApiImpl.swift # 生成的 Swift host API 接口的具体实现 - ohos/ - src/main/ets/ScanApiImpl.ets # 生成的 ArkTS host API 接口的具体实现 + Classes/ScanApi.g.swift # pigeon 生成 + Classes/ScanApiImpl.swift # 手写:生成的 Swift host API 协议的实现 + Classes/NativeScanPlugin.swift # 手写:插件注册入口 ``` +首版只有 `android/` 和 `ios/`(OHOS 不在首版范围,见文末「OHOS 后续演进」)。 + +### `pubspec.yaml` 必须声明 plugin platforms + +这是最容易漏、漏了最难排查的一条:**`native_*` 包如果没有 `flutter: plugin:` 声明,`android/`、`ios/` 下的原生代码根本不会被编译进宿主 App**。表现是 Dart 侧调用直接抛 `MissingPluginException`,而代码看上去哪里都没问题。 + +```yaml +# packages/native_scan/pubspec.yaml +name: native_scan +resolution: workspace + +environment: + sdk: ^3.12.0 + flutter: '>=3.44.0' + +flutter: + plugin: + platforms: + android: + package: com.conti.native_scan + pluginClass: NativeScanPlugin + ios: + pluginClass: NativeScanPlugin +``` + +`pluginClass` 指向的类需要实现 `FlutterPlugin`(Android)/ `FlutterPlugin` 协议(iOS),在 `onAttachedToEngine` 里把 `ScanApiImpl` 注册到 pigeon 生成的 `setUp` 方法上: + +```kotlin +// android/src/main/kotlin/com/conti/native_scan/NativeScanPlugin.kt +class NativeScanPlugin : FlutterPlugin, ActivityAware { + private var impl: ScanApiImpl? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + impl = ScanApiImpl() + ScanHostApi.setUp(binding.binaryMessenger, impl) // pigeon 生成的注册方法 + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + ScanHostApi.setUp(binding.binaryMessenger, null) + impl = null + } + + // 扫码需要 Activity(起 CameraX 预览页),通过 ActivityAware 拿 + override fun onAttachedToActivity(binding: ActivityPluginBinding) { impl?.activity = binding.activity } + override fun onDetachedFromActivity() { impl?.activity = null } + override fun onReattachedToActivityForConfigChanges(b: ActivityPluginBinding) = onAttachedToActivity(b) + override fun onDetachedFromActivityForConfigChanges() = onDetachedFromActivity() +} +``` + +> `ActivityAware` 不能省。扫码、相册选择、拨号这类能力都需要 `Activity`(起页面、申请权限、收 `onActivityResult`),只在 `onAttachedToEngine` 里拿 `applicationContext` 是不够的。而且 `onDetachedFromActivity` 里必须把引用置空,否则横竖屏切换或后台回收后会持有已销毁的 Activity,导致内存泄漏和崩溃。 + +## 扫码的归属:App 原生实现 + +**扫码由 App 原生实现(`native_scan`),不是 F6 的功能。** + +`native_scan` 同时服务两个调用方: + +``` +feature_scan(App 内的扫码页:扫码入库、扫码查件) + ↘ + native_scan → 原生相机 + 解码 + ↗ +core_webview 的 JSBridge(H5 页面调起扫码,见 10-webview-h5.md) +``` + +这也是 [01-project-structure.md](./01-project-structure.md) 里"`core_*` 允许依赖 `native_*`"这条例外存在的原因——如果只允许 `feature_* → native_*`,`core_webview` 的 JSBridge 就没法调起扫码,只能退化成"复制一份扫码实现"或者"让 core_webview 反向依赖 feature_scan",两条都不可接受。 + +> **与 PRD 的已知冲突**:PRD §11.5 和 `202606-Conti-Retail-APP-Component-data-source.md` 里把扫码写成"嵌入 F6 扫码页",与此处不一致。以本文档为准(扫码是 App 做的),**PRD 需要回头修订**。 + +### 待确认:VIN 码与车牌识别的技术路径 + +这是一个**还没解决的能力缺口**,必须在开工前定下来。 + +PRD 要求扫描 **VIN 码**和**车牌**。但通用扫码库(`mobile_scanner`、ZXing、MLKit Barcode Scanning)解的是**二维码/条形码**,识别不了车牌这种自然场景文字;VIN 虽然常以 Code 39 条码形式印在车身铭牌上,但也大量存在"只有印刷字符、没有条码"的情况。这两个都需要 **OCR**。 + +| 需求 | 能力 | 候选方案 | +|---|---|---| +| 二维码 / 条形码(商品、库位) | Barcode | MLKit Barcode Scanning(Android)/ Vision(iOS),或 `mobile_scanner` | +| VIN 条码 | Barcode(Code 39) | 同上 | +| VIN 印刷字符 | OCR + 校验位算法 | MLKit Text Recognition / Vision;VIN 有第 9 位校验码,可用来过滤误识别 | +| 车牌 | 专用 OCR | MLKit/Vision 通用 OCR 准确率偏低;或接第三方车牌识别 SDK(如车牌识别专用商用 SDK) | + +**建议路径**:条码走 MLKit/Vision(免费、离线、成熟);VIN 印刷字符用通用 OCR + VIN 校验位过滤,先验证准确率;**车牌单独做一次技术验证**,通用 OCR 达不到可用准确率就要评估采购商用 SDK(涉及成本、离线授权、包体积、以及是否上传图片到第三方服务器的合规问题)。 + +在验证结论出来之前,**`native_scan` 的 Pigeon schema 要预留 `ScanMode` 参数**(`barcode` / `vin` / `plate`),避免后面加识别类型时要改接口签名。 + +## 权限与合规 + +`native_*` 涉及的运行时权限: + +| 能力 | Android 权限 | iOS `Info.plist` key | +|---|---|---| +| 扫码 / 拍照 | `CAMERA` | `NSCameraUsageDescription` | +| 相册选择 | `READ_MEDIA_IMAGES`(API 33+) | `NSPhotoLibraryUsageDescription` | +| 保存图片 | `WRITE_EXTERNAL_STORAGE`(API ≤ 28) | `NSPhotoLibraryAddUsageDescription` | +| 拨号 | 无需权限(`ACTION_DIAL` 不需要 `CALL_PHONE`) | 无(`tel:` scheme) | + +规则: + +- **权限申请必须在用到的那一刻发起,不在启动时批量申请。** 启动就要相机权限是应用商店审核和用户流失的双重风险。 +- **被拒绝后要有引导**:拒绝一次 → 说明为什么需要 + 再次申请;选了"不再询问" → 提示并提供跳转系统设置的入口。不能只是 toast 一句"没有权限"然后什么也做不了。 +- iOS 用途说明文案要写具体("用于扫描商品条码入库"),写"需要相机权限"这种会被审核打回。 +- 拨号用 `ACTION_DIAL` / `tel:` **拉起拨号盘让用户自己按拨出**,不用 `CALL_PHONE` 直接拨号——后者要额外的危险权限,还容易被审核质疑。 + +### iOS 隐私清单 `PrivacyInfo.xcprivacy`(上架强制) + +苹果自 2024 年起强制要求 App 及其使用的三方 SDK 提供隐私清单,**没有会直接被拒**。每个 `native_*` 包如果访问了需要声明的 API,要在 `ios/Resources/PrivacyInfo.xcprivacy` 里声明: + +```xml +NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + C617.1 + + +``` + +同时确认三方依赖(相机/图片压缩/崩溃上报 SDK)是否自带隐私清单——不带的需要我们在主 App 里替它声明,或者换一个带的。这条要在**首次提交 TestFlight 前**验证,别留到上架当天。 + +**我们不采集设备唯一标识**(IMEI/IDFA/MAC),所以不需要声明 `NSPrivacyTracking`(见 [05-networking.md](./05-networking.md) 的 `X-Device-Id` 约定)。 + +## Pigeon 的工程化 + +生成命令不写在 README 里让人手敲,而是把配置写进 schema、动作做成 melos script。 + +```dart +// native_scan/pigeons/scan_api.dart +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/scan_api.g.dart', + dartOptions: DartOptions(), + kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'), + swiftOut: 'ios/Classes/ScanApi.g.swift', + swiftOptions: SwiftOptions(), + dartPackageName: 'native_scan', +)) +library; + +@HostApi() +abstract class ScanHostApi { /* ... */ } +``` + +配置写进 `@ConfigurePigeon` 之后,生成命令就退化成一行,不会出现"某人生成时路径敲错,生成物落到别的目录": + +```bash +fvm dart run pigeon --input pigeons/scan_api.dart +``` + +melos script(见 [01-project-structure.md](./01-project-structure.md)): + +```yaml +pigeon: + run: melos exec --scope="native_*" -- dart run pigeon --input pigeons/ +``` + +生成产物的处理: + +- `*.g.dart` / `*.g.kt` / `*.g.swift` **入 git**(同 riverpod/drift 的生成物,理由见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。 +- Dart 生成物在根 `analysis_options.yaml` 里排除 lint(`analyzer: exclude: - "**/*.g.dart"`)。 +- CI 要有一步"重新生成后 `git diff --exit-code`",防止有人改了 schema 但忘了提交生成物。 + +## OHOS 后续演进 + +鸿蒙(OpenHarmony)**不在首版范围**,但基线决策是为它留了口子的,这里记录清楚,避免后面接的时候重新走一遍弯路。 + +接 OHOS 需要处理三件事: + +1. **SDK 分支不同**:OHOS 用的是 OpenHarmony 社区维护的 Flutter 分支,版本落后于官方 stable 一段时间。这正是 [01-project-structure.md](./01-project-structure.md) 里 SDK 基线刻意停在 **3.44.9** 而不追 3.47.0 的原因——基线跑太前,OHOS 分支跟不上就接不进来。 +2. **Pigeon 没有 ArkTS 生成器**:Pigeon 官方只生成 Kotlin/Java、Swift/Objective-C、C++、GObject,**没有 ArkTS/OHOS**。所以 OHOS 侧的 channel 代码只能**手写**,需要人工保证方法名、参数结构与 Pigeon 生成的 Dart 端编解码格式一致——这是一份实打实的额外维护成本,接 OHOS 时要预留出来。 +3. **`native_*` 包要加 `ohos:` 平台声明**,并新增 `ohos/` 目录。 + +在此之前,`native_*` 的公共 API 类里遇到不支持的平台,一律抛明确的 `UnsupportedPlatformException`,不静默返回空值或占位假数据——静默返回会让"这个平台其实没实现"的问题一直藏到用户手里。 + + ## 使用规则 -- `pigeons/xxx_api.dart` 是**唯一手写**的接口定义文件,Dart 端和三端原生的桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改,改需求就改 schema 重新生成。 +- `pigeons/xxx_api.dart` 是**唯一手写**的接口定义文件,Dart 端和两端原生的桩代码全部由 `dart run pigeon --input pigeons/xxx_api.dart` 生成,生成产物不手动修改,改需求就改 schema 重新生成。 - Dart 调原生用 `@HostApi()`;原生主动推事件给 Dart(比如扫码结果的持续回调)用 `@FlutterApi()`——不允许为了图省事用 `@HostApi()` 硬凑双向通信。 -- `feature_*` 只允许依赖对应 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类,不允许直接 import `src/generated/` 里的生成代码。 -- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到 `feature_*` 业务代码里。 -- 三端(Android/iOS/OHOS)中若某一端暂未实现,公共 API 类里对应平台分支返回明确的 `UnimplementedError`,不允许静默返回空值或占位假数据。 +- **调用方只允许依赖 `native_*` 包 `lib/native_xxx.dart` 导出的公共 API 类**,不允许直接 import `src/generated/` 里的生成代码。调用方包括 `feature_*` 和 `core_webview`(JSBridge)。 +- 原生侧异常需要在生成的 host API 实现里捕获并转换成 Pigeon schema 里声明的错误类型,Dart 侧统一映射成 [05-networking.md](./05-networking.md) 里同一套 `AppException` 体系,不让原生异常类型(如 `PlatformException`)直接抛到业务代码里。 +- 某一端暂未实现的能力,公共 API 类里对应平台分支抛明确的 `UnsupportedPlatformException`,不允许静默返回空值或占位假数据。 + +## 待确认项 + +- **车牌识别的技术路径**(通用 OCR 是否够用,还是要采购商用 SDK)——见上文,开工前必须有结论。 +- VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证。 +- 三方 SDK 的 iOS 隐私清单覆盖情况,首次提交 TestFlight 前核完。 ## 参考链接 - [Pigeon 官方文档](https://pub.dev/packages/pigeon) -- [pigeon | Dart package](https://pub.dev/packages/pigeon) - [Flutter 平台通道官方文档](https://docs.flutter.dev/platform-integration/platform-channels) +- [编写 Flutter plugin package](https://docs.flutter.dev/packages-and-plugins/developing-packages#plugin-platforms) +- [Apple: 隐私清单文件](https://developer.apple.com/documentation/bundleresources/privacy-manifest-files) +- [Android 运行时权限最佳实践](https://developer.android.com/training/permissions/requesting) ## 附录:Pigeon 是什么,日常怎么用 @@ -74,6 +263,15 @@ final result = await MethodChannel('scan_channel').invokeMethod('startScan', {'t ```dart // native_scan/pigeons/scan_api.dart —— 唯一手写的 schema 文件 +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/generated/scan_api.g.dart', + kotlinOut: 'android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt', + kotlinOptions: KotlinOptions(package: 'com.conti.native_scan'), + swiftOut: 'ios/Classes/ScanApi.g.swift', + dartPackageName: 'native_scan', +)) +library; + @HostApi() abstract class ScanHostApi { @async @@ -81,33 +279,38 @@ abstract class ScanHostApi { void stopScan(); } +/// 预留识别类型,避免后面加车牌/VIN 识别时改接口签名 +enum ScanMode { barcode, vin, plate } + class ScanOptions { - ScanOptions({required this.timeoutMs}); + ScanOptions({required this.mode, required this.timeoutMs}); + final ScanMode mode; final int timeoutMs; } class ScanResult { - ScanResult({required this.code, required this.format}); - final String code; - final String format; + ScanResult({required this.value, required this.format}); + final String value; + final String format; // QR_CODE / CODE_39 / OCR_TEXT ... } ``` ```bash -dart run pigeon \ - --input pigeons/scan_api.dart \ - --dart_out lib/src/generated/scan_api.g.dart \ - --kotlin_out android/src/main/kotlin/com/conti/native_scan/ScanApi.g.kt \ - --swift_out ios/Classes/ScanApi.g.swift +# 配置已写进 @ConfigurePigeon,命令里不用再重复一遍输出路径 +fvm dart run pigeon --input pigeons/scan_api.dart ``` Android 端实现生成的抽象类(`ScanApiImpl.kt`,非生成代码,是需要手写的实现): ```kotlin -class ScanApiImpl(private val activity: Activity) : ScanHostApi { +class ScanApiImpl : ScanHostApi { + var activity: Activity? = null // 由 NativeScanPlugin 的 ActivityAware 回调注入 + override fun startScan(options: ScanOptions, callback: (Result) -> Unit) { + val act = activity ?: return callback(Result.failure( + FlutterError("NO_ACTIVITY", "扫码需要前台 Activity", null))) // 调用具体的扫码 SDK,拿到结果后: - callback(Result.success(ScanResult(code = "123456", format = "QR_CODE"))) + callback(Result.success(ScanResult(value = "123456", format = "QR_CODE"))) } override fun stopScan() { @@ -116,17 +319,24 @@ class ScanApiImpl(private val activity: Activity) : ScanHostApi { } ``` -Dart 端对外的公共 API(`native_scan.dart`,`feature_scan` 唯一能调用的入口): +Dart 端对外的公共 API(`native_scan.dart`,调用方唯一能用的入口): ```dart class NativeScan { final ScanHostApi _api = ScanHostApi(); - Future startScan({Duration timeout = const Duration(seconds: 5)}) async { + Future startScan({ + ScanMode mode = ScanMode.barcode, + Duration timeout = const Duration(seconds: 30), + }) async { try { - return await _api.startScan(ScanOptions(timeoutMs: timeout.inMilliseconds)); - } on PlatformException catch (e) { - throw NativeCapabilityException('扫码失败: ${e.message}'); + return await _api.startScan( + ScanOptions(mode: mode, timeoutMs: timeout.inMilliseconds), + ); + } on PlatformException catch (e, st) { + // 原生异常不外泄,统一转成 05 里的 AppException 体系 + Error.throwWithStackTrace( + NativeCapabilityException('扫码失败: ${e.message}', code: e.code), st); } } @@ -134,4 +344,4 @@ class NativeScan { } ``` -`feature_scan` 只 import `NativeScan` 这一个类,完全不知道底层是 Pigeon 生成的还是手写 `MethodChannel`——这也是把原生能力做成独立 `native_*` package(而不是散落在各 feature 里直接写平台通道代码)的意义:原生实现细节被这一层完全封装。 +`feature_scan` 和 `core_webview` 的 JSBridge 都只 import `NativeScan` 这一个类,完全不知道底层是 Pigeon 生成的还是手写 `MethodChannel`——这也是把原生能力做成独立 `native_*` package(而不是散落在各 feature 里直接写平台通道代码)的意义:原生实现细节被这一层完全封装,将来换扫码 SDK 或补 OHOS 实现,调用方一行都不用动。 diff --git a/08-build-flavors.md b/08-build-flavors.md index 4a99874..2714bc4 100644 --- a/08-build-flavors.md +++ b/08-build-flavors.md @@ -2,29 +2,168 @@ ## 决策 -App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有后端 CI/CD(见 `gitlab-cicd-azure-deployment-diagram.drawio` 里的 Dev/UAT/Prod Azure 环境)保持一致命名,三个 flavor 各自对应不同的 API 地址、应用图标/名称、包名后缀,可在同一台设备上同时安装、互不覆盖。CI 沿用现有 GitLab CI(Runner 与后端共用),但产物是 App 二进制(apk/ipa),分发渠道与后端的 ACR/Azure App Hosting 不同。 +App 侧维护 **3 个 flavor:`dev` / `uat` / `prod`**,环境划分与现有后端 CI/CD(见 `gitlab-cicd-azure-deployment-diagram.drawio` 里的 Dev/UAT/Prod Azure 环境)保持一致命名,三个 flavor 各自对应不同的 API 地址、应用图标/名称、包名后缀,可在同一台设备上同时安装、互不覆盖。CI 沿用现有 GitLab CI,但产物是 App 二进制(apk/ipa),分发渠道与后端的 ACR/Azure App Hosting 不同。 + +> ⚠️ **Android 可以复用与后端共用的 Linux Runner,iOS 不行。** `flutter build ipa` 必须跑在 macOS 上,这是首版发版前必须先解决的工程阻塞项,详见下文「iOS 构建链路:当前不成立,必须先解决」。 + ## Flavor 划分规则 | Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 | |---|---|---|---| -| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | Firebase App Distribution(内部测试) | -| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | Firebase App Distribution(验收测试) | -| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod) | App Store Connect / Google Play(生产发布) | +| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(渠道待定,见下文) | +| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(渠道待定,见下文) | +| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod) | App Store Connect / 各安卓应用市场 | ## 使用规则 - 每个 flavor 对应一个独立的 Dart 入口文件(`main_dev.dart`/`main_uat.dart`/`main_prod.dart`),三者都只是设置好环境标识后调用同一个共享的 `bootstrap()` 启动函数,不允许在入口文件里写业务逻辑分支。 - 环境相关的可变配置(API base URL、是否开启日志等,见 [05-networking.md](./05-networking.md) 的 `appEnvProvider`)通过 `--dart-define-from-file=env/{flavor}.json` 注入,不写死在代码里、也不用 `if (flavor == 'dev')` 这种运行时字符串判断来分支配置。 - `env/*.json` 只包含非敏感配置(API 地址等);密钥类配置(如第三方 SDK App Key)通过 CI 变量在构建时注入,不提交进仓库。 -- Android 侧用 Gradle `productFlavors` 区分 `applicationId`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。 +- Android 侧用 Gradle `productFlavors` 区分 `applicationIdSuffix`/图标/`versionNameSuffix`;iOS 侧用对应的 xcconfig + Scheme 区分 `Bundle Identifier`/图标;两端 flavor 名称必须完全一致(`dev`/`uat`/`prod`),不允许两端用不同命名。 - CI 流水线阶段固定为:`melos run analyze` → `melos run test` → 按 flavor `flutter build apk/ipa --flavor {flavor} --dart-define-from-file=env/{flavor}.json` → 上传对应分发渠道。`prod` flavor 的构建触发条件是打 tag,不是每次 push 都触发(避免误发生产包)。 +## 用 `applicationIdSuffix` 而不是覆盖 `applicationId` + +```gradle +productFlavors { + dev { dimension "env"; applicationIdSuffix ".dev"; versionNameSuffix "-dev" } + uat { dimension "env"; applicationIdSuffix ".uat"; versionNameSuffix "-uat" } + prod { dimension "env" } // 用 defaultConfig 的 applicationId,不加后缀 +} +``` + +理由:直接覆盖 `applicationId` 会让 `applicationId` 和 **Kotlin 源码的 package 名脱钩**。Android 的 `R` 类、`BuildConfig` 类是按 `namespace`(源码 package)生成的,而 `applicationId` 只影响安装标识——两者写成不同的值本身合法,但很多三方 SDK(推送、地图、统计)的初始化会隐式假设它们一致,配错的表现是"dev 包能跑,uat 包某个 SDK 静默失效"。用 `applicationIdSuffix` 只在末尾加后缀,`namespace` 保持不变,从结构上避免这类问题。 + +对应地 iOS 侧 xcconfig 里也用 `PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail$(BUNDLE_ID_SUFFIX)`,`BUNDLE_ID_SUFFIX` 按 Build Configuration 取 `.dev` / `.uat` / 空。 + +## Release 构建必须开混淆和符号剥离 + +```bash +fvm flutter build appbundle \ + --flavor prod --target lib/main_prod.dart \ + --dart-define-from-file=env/prod.json \ + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG +``` + +- `--obfuscate` 混淆 Dart 符号名,`--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。 +- **两个参数必须一起用**,只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝。 +- **符号表必须归档,并且构建完立刻上传到 Sentry**:混淆后崩溃堆栈是不可读的乱码。CI 在 build 之后紧跟一条 `fvm dart run sentry_dart_plugin`,把 Dart 符号表、Android mapping、iOS dSYM 一起传上去(见 [13-observability-analytics.md](./13-observability-analytics.md))。**上传时的 `release` 必须和 App 里 `options.release` 严格一致**,对不上的表现是"传了但堆栈还是混淆的",且后台不报错。 +- 同时把 `build/symbols/` 按 `版本号+构建号` 归档为 CI artifact 保留至少 1 年,作为 Sentry 侧数据过期或服务不可用时的兜底。**丢了符号表 = 那个版本的所有线上崩溃永远无法定位**,这是个不可逆的失误。 +- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。 + +## 版本号规则 + +| 字段 | 来源 | 示例 | +|---|---|---| +| `versionName` | git tag(去掉 `v` 前缀) | tag `v1.4.0` → `1.4.0` | +| `versionCode` / `CFBundleVersion` | CI pipeline ID(单调递增) | `$CI_PIPELINE_ID` → `48213` | + +要点: + +- `versionCode` **必须单调递增且永不重复**——Google Play 和 App Store Connect 都会拒绝重复或回退的版本号,而这个错误只在上传那一刻才暴露,很容易卡在发版当天。用 `CI_PIPELINE_ID` 天然满足递增,比手工维护数字可靠。 +- `pubspec.yaml` 里的 `version:` 在 CI 构建时被 `--build-name` / `--build-number` 覆盖,仓库里的值只作为本地开发的占位,不作为发版依据。 +- dev/uat 包的 `versionName` 带 `-dev`/`-uat` 后缀,测试反馈时一眼能看出装的是哪个环境的包。 + +## Android 签名与 keystore 注入 + +keystore **不入 git**(包括 dev 的)。CI 里通过变量注入: + +```yaml +# GitLab CI 变量(类型选 File,masked) +# ANDROID_KEYSTORE_BASE64 - keystore 文件的 base64 +# ANDROID_KEYSTORE_PASSWORD / ANDROID_KEY_ALIAS / ANDROID_KEY_PASSWORD +before_script: + - echo "$ANDROID_KEYSTORE_BASE64" | base64 -d > android/app/release.keystore + - | + cat > android/key.properties < bootstrap(AppEnv.fromDartDefine(name: 'dev')); 构建命令: ```bash -flutter build apk \ +fvm flutter build apk \ --flavor dev \ --target lib/main_dev.dart \ --dart-define-from-file=env/dev.json ``` -GitLab CI 片段(衔接现有 Runner,产物走 Firebase App Distribution 而非后端用的 ACR): +GitLab CI 片段(衔接现有 Runner;注意 Android 和 iOS 必须落到不同的 runner 上): ```yaml -build_dev: +.flutter_base: &flutter_base + image: ghcr.io/cirruslabs/flutter:3.44.9 # 与 .fvmrc 保持一致 + before_script: + - dart pub global activate melos + - melos bootstrap + +build_android_dev: + <<: *flutter_base stage: build script: - melos run analyze - melos run test - flutter build apk --flavor dev --target lib/main_dev.dart --dart-define-from-file=env/dev.json - - firebase appdistribution:distribute build/app/outputs/.../dev/release/app-dev-release.apk + artifacts: + paths: [build/app/outputs/flutter-apk/app-dev-release.apk] rules: - if: '$CI_COMMIT_BRANCH == "develop"' -build_prod: +build_android_prod: + <<: *flutter_base stage: build script: - - flutter build appbundle --flavor prod --target lib/main_prod.dart --dart-define-from-file=env/prod.json + - flutter build appbundle --flavor prod --target lib/main_prod.dart + --dart-define-from-file=env/prod.json + --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG + artifacts: + paths: + - build/app/outputs/bundle/prodRelease/ + - build/symbols/ # 符号表必须归档,丢了就没法解混淆崩溃堆栈 + expire_in: 1 year rules: - if: '$CI_COMMIT_TAG' # 只在打 tag 时触发,避免误发生产包 + +build_ios_prod: + stage: build + tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建链路」 + script: + - fvm flutter build ipa --flavor prod --target lib/main_prod.dart + --dart-define-from-file=env/prod.json + --build-name=${CI_COMMIT_TAG#v} --build-number=$CI_PIPELINE_ID + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG + rules: + - if: '$CI_COMMIT_TAG' ``` diff --git a/09-testing.md b/09-testing.md index 79c8229..b0f4fdd 100644 --- a/09-testing.md +++ b/09-testing.md @@ -19,17 +19,117 @@ dev_dependencies: ## 分层测试规则 - **domain 层(有 domain 的 feature)**:use case 用纯 Dart 单元测试,mock 掉 `repository` 接口,覆盖多步骤业务规则的分支(如 [02-layering.md](./02-layering.md) 里 `ConfirmPaymentUseCase` 的状态校验、金额校验)。 -- **data 层**:repository 实现用单元测试,mock 掉 `Dio`(或用 dio 自带的 `DioAdapter`/假响应),验证请求参数拼装和响应解析是否正确,不发真实网络请求。 -- **presentation 层(Notifier)**:用 `ProviderContainer` + `overrides` 直接测试 `Notifier`/`AsyncNotifier` 的状态流转(见 [03-state-management.md](./03-state-management.md) 的测试示例),不需要启动完整 widget 树。 +- **data 层**:repository 实现用单元测试,mock 掉 `ApiClient`(见 [05-networking.md](./05-networking.md)),验证请求参数拼装和响应解析是否正确,不发真实网络请求。拦截器本身(`ApiResultInterceptor`/`AuthInterceptor`/`ErrorMappingInterceptor`)单独测,用 `DioAdapter` 造假响应——**401 刷新的串行逻辑必须有测试**,它是最容易写错、出错代价最高的一段(见 05 里关于并发刷新会导致全设备登出的说明)。 +- **presentation 层(Notifier)**:用 `ProviderContainer.test()` + `overrides` 直接测试 `Notifier`/`AsyncNotifier` 的状态流转(见 [03-state-management.md](./03-state-management.md) 的测试示例),不需要启动完整 widget 树。 - **Widget 测试**:只覆盖有实际业务分支的页面(比如列表的 loading/data/error 三态渲染是否正确),纯展示型 widget(无状态分支)不强制要求。 - **集成测试**:只覆盖黄金路径(1-2 条最核心的用户旅程),跑在真实/模拟设备上,验证跨 feature 的路由跳转和端到端流程;不追求覆盖所有页面组合,避免集成测试维护成本超过收益。 - 每个 `feature_*` 包的 `test/` 目录结构镜像 `lib/src/`(如 `test/domain/`、`test/data/`、`test/presentation/`),单元测试和 Widget 测试都通过 `melos run test`(见 [01-project-structure.md](./01-project-structure.md))统一跑;集成测试单独一个 CI job,不并入这条批量命令(跑得慢、需要设备/模拟器,不适合每次 `analyze`/`test` 都触发)。 +## mocktail 的 `registerFallbackValue` + +**用 `any()` 匹配自定义类型的参数时,必须先 `registerFallbackValue`**,否则运行时直接报错。这是 mocktail 最常见的踩坑点,而且报错信息不看文档很难对上号。 + +```dart +class FakePageQuery extends Fake implements PageQuery {} +class FakeCancelToken extends Fake implements CancelToken {} + +void main() { + setUpAll(() { + // 每个会出现在 any() 位置的非基础类型都要注册一次,注册一次即可全局生效 + registerFallbackValue(FakePageQuery()); + registerFallbackValue(FakeCancelToken()); + }); + + test('...', () { + when(() => repo.fetchOrders(any(), cancelToken: any(named: 'cancelToken'))) + .thenAnswer((_) async => const PageResult(items: [], total: 0, page: 1)); + }); +} +``` + +`int`/`String`/`bool`/`double` 这些基础类型不需要注册。约定:`registerFallbackValue` 统一写在包的 `test/helpers/fallbacks.dart` 里,各测试文件的 `setUpAll` 调用同一个 `registerAllFallbacks()`,避免每个文件各注册一遍、漏一个就挂。 + +## 测试里必须关掉 Riverpod 的自动重试 + +Riverpod 3 的 provider 失败后会自动重试(见 [03-state-management.md](./03-state-management.md))。虽然我们在 `ProviderScope` 上全局关掉了,但**测试不走 `main.dart`,`ProviderContainer` 默认仍带着重试策略**。后果是断言 `AsyncError` 的测试会 flaky,或者测试跑完报 "A Timer is still pending"。 + +统一在测试辅助里建 container: + +```dart +// test/helpers/container.dart +ProviderContainer makeContainer({List overrides = const []}) => + ProviderContainer.test( + retry: (_, __) => null, // 与线上 ProviderScope 的配置保持一致 + overrides: overrides, + ); +``` + +所有测试用 `makeContainer()`,不直接 `ProviderContainer.test(...)`——这样将来全局策略变了只改一处。 + +## 覆盖率门禁 + +```yaml +# 根 pubspec.yaml 的 melos: scripts: +test: + run: melos exec --dir-exists=test --fail-fast -- flutter test --coverage +coverage: + run: | + dart pub global run coverde value -i coverage/lcov.info --min-coverage 60 +``` + +阈值定 **60%**,只卡**整体**、不卡单文件。理由: + +- 卡单文件会逼着大家给 `*.g.dart`、纯展示 widget、`toString()` 这类东西补无意义的测试,产出的是"覆盖率数字"而不是"信心"。 +- 60% 不是终点,是**不允许倒退的地板**。真正该高覆盖的是 domain use case 和 repository,这两块应该接近 90%,靠 review 保证而不是靠数字。 +- 生成产物(`**/*.g.dart`)、生成的 pigeon 代码要从 lcov 里排除,否则数字会被生成代码稀释得没有参考价值。 + +覆盖率报告作为 CI artifact 上传,PR 上能看到(见 [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md))。 + +## 集成测试在 CI 的运行环境 + +| 平台 | Runner | 说明 | +|---|---|---| +| Android | 现有 **Linux** runner + Android Emulator | 可行。用 `avdmanager` 起一个无头模拟器(`-no-window -gpu swiftshader`),或用 Docker 镜像。启动慢(1–3 分钟),所以只跑黄金路径 | +| iOS | 需要 **mac runner** | 与 [08-build-flavors.md](./08-build-flavors.md) 的 iOS 构建链路是同一个阻塞项。mac runner 落地前,iOS 集成测试**手工在本机跑**,并在发版 checklist 里列为必做项 | + +集成测试的触发时机:**不进每次 push 的流水线**,只在合入 `develop`/`main` 和打 tag 时跑。每次 push 都跑模拟器,流水线时间会从 3 分钟涨到 10 分钟以上,实际效果是大家开始绕过 CI。 + +集成测试连的是 **UAT 后端**,需要一组固定的测试账号和测试门店,数据由后端侧准备并保证可重复(这一项要和后端对齐)。 + +## JSBridge 的测试策略 + +`core_webview` 的 JSBridge(见 [10-webview-h5.md](./10-webview-h5.md))分两块测,**不要试图在 CI 里跑真实 H5 页面**: + +1. **协议编解码 → 纯 Dart 单元测试**。`{id, method, params}` 的解析、未知 `method` 的处理、参数缺失/类型错误的报错、回包格式、来源域名校验——这些都是纯函数,不需要 WebView,覆盖率应该接近 100%。这是 JSBridge 里最容易出错也最好测的部分。 +2. **原生能力调用 → mock 掉 `native_*` 的公共 API 类**。验证"H5 发来 `scan` 请求 → 调了 `NativeScan.startScan` → 回包格式正确",不真的起相机。 +3. **端到端联调 → 走契约用例,不进 `melos run test`**。维护一个 H5 侧和 App 侧共用的 bridge 契约用例清单(12 项能力各一条),联调时人工逐条过,作为 checklist 而不是自动化测试。真起 WebView 加载真 H5 的自动化测试在 CI 上又慢又不稳定,投入产出比很差。 + +## Golden 测试:`core_ui` 做,业务页面不做 + +**结论**:只对 `core_ui` 里的基础组件(按钮、输入框、卡片、状态占位图)写 golden 测试,`feature_*` 的业务页面不写。 + +理由: + +- `core_ui` 组件被所有 feature 复用,改一处影响面大,而它们的输出是稳定的——正是 golden 测试的适用场景。 +- 业务页面的 UI 改动频繁,golden 会变成"每次改 UI 都要 `--update-goldens` 一遍"的负担,而且没人真的去看那张图对不对,最后退化成走过场。 +- golden 图片对**渲染环境敏感**(字体、平台、Flutter 版本)。必须在 CI 里用固定环境生成和比对,本机生成的图传上去大概率对不上。所以 golden 测试**只在 Linux runner 上跑**,本地开发时用 `--tags golden` 排除掉。 + +字体要显式加载,不然 golden 里全是方块: + +```dart +setUpAll(() async { + await loadAppFonts(); // golden_toolkit 或自己写的 FontLoader 封装 +}); +``` + + ## 参考链接 - [Flutter 官方测试文档](https://docs.flutter.dev/testing) - [mocktail | Dart package](https://pub.dev/packages/mocktail) +- [mocktail: registerFallbackValue](https://pub.dev/packages/mocktail#how-it-works) - [integration_test 官方文档](https://docs.flutter.dev/testing/integration-tests) +- [Flutter: golden 文件测试](https://api.flutter.dev/flutter/flutter_test/matchesGoldenFile.html) ## 附录:分层怎么测,日常怎么写 @@ -87,10 +187,11 @@ void main() { when(() => repository.fetchNearbyStores(any(), any())) .thenThrow(NetworkException('超时')); - final container = ProviderContainer( + // makeContainer 内部是 ProviderContainer.test(retry: (_, __) => null): + // 自动 dispose + 关掉自动重试,否则这条断言会 flaky + final container = makeContainer( overrides: [storeRepositoryProvider.overrideWithValue(repository)], ); - addTearDown(container.dispose); await container.read(storeListNotifierProvider.future).catchError((_) {}); final state = container.read(storeListNotifierProvider); @@ -146,3 +247,8 @@ void main() { ``` 集成测试用真实的(或半真实的、通过测试环境后端的)依赖跑通整条链路,不 mock 掉 repository——这条测试的意义就是验证各层真实拼接在一起没有问题,跟单元测试的定位互补而不是重复。 + +## 待确认项 + +- 集成测试用的 UAT 测试账号/测试门店,以及数据可重复性由后端保证的方式。 +- 覆盖率阈值 60% 是起点,跑一个迭代后按实际情况调。 diff --git a/10-webview-h5.md b/10-webview-h5.md new file mode 100644 index 0000000..f019efd --- /dev/null +++ b/10-webview-h5.md @@ -0,0 +1,361 @@ +# 10. Embedded H5 容器与 JSBridge + +## 为什么单独一篇 + +PRD §7 的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。 + +**适用范围**:Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD §7.1)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。 + +## 决策 + +| 项 | 决策 | +|---|---| +| WebView 库 | **[webview_flutter](https://pub.dev/packages/webview_flutter) `^4.14.1`** | +| 归属包 | `core_webview`(依赖 `core_auth`、`native_scan`/`native_media`/`native_device`) | +| 桥接通道 | 单一 JavaScript Channel `ContiBridge`,统一 `{id, method, params}` 协议 | +| URL 来源 | 只接受 App Backend 换票后下发的 URL,**路由里不传裸 URL** | + +### 为什么选 webview_flutter 而不是 flutter_inappwebview + +| | webview_flutter | flutter_inappwebview | +|---|---|---| +| 维护方 | **Flutter 官方(flutter.dev)** | 社区个人维护 | +| 最新 stable | `4.14.1`,一个月前发布,持续更新 | `6.1.5`,**距今约 22 个月**,新特性都在 `6.2.0-beta` | +| 能力覆盖 | 基础能力齐全,高级能力走平台特定 controller | 更丰富(拦截请求、Cookie 精细管理、下载) | +| 我们实际需要的 | JS Channel、导航拦截、文件选择、Cookie 清理 | 同 | + +`flutter_inappwebview` 能力更全,但**它的 stable 版本已经近两年没发布**,新功能和 bugfix 都压在 beta 上。对一个要跑核心交易链路、生命周期以年计的 App 来说,这是不能接受的维护风险——真出问题时我们只能自己 fork。 + +`webview_flutter` 的能力缺口(Android 的 ``)有官方解法,用平台特定 controller 即可: + +```dart +if (controller.platform is AndroidWebViewController) { + await AndroidWebViewController.enableDebugging(env.enableLog); + (controller.platform as AndroidWebViewController) + .setOnShowFileSelector(_onShowFileSelector); // 交给 native_media 处理 +} +``` + +**如果后续发现 F6 页面用到了 `webview_flutter` 确实做不了的能力**(比如需要拦截并改写请求),再评估切换;届时因为所有 WebView 交互都收在 `core_webview` 一个包里,切换代价是可控的。这也是不让 `feature_*` 直接依赖 WebView 库的原因。 + +## H5 启动流程 + +对应 PRD §7.2: + +``` +用户点击功能入口(feature_* 或工作台菜单) + ↓ +context.push('/webview?target=QUOTE_ORDER') ← 路由里只有 target,没有 URL + ↓ +core_webview: POST /api/v1/h5/launch { target } + ↓ +App Backend: 校验登录态 / 门店上下文 / 角色权限 + → 经 F6 Integration Adapter 取票据 + ↓ +返回 { url, ticket, expiresIn, title } + ↓ +core_webview: 域名白名单校验 → WebViewController.loadRequest(url) +``` + +```dart +// packages/core_webview/lib/src/h5_launch_repository.dart +class H5LaunchInfo { + final String url; // 已由后端拼好票据和上下文参数 + final String title; + final Duration ttl; // 票据有效期,用于判断是否需要换票 +} +``` + +**启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。** 客户端拼参数意味着 `userId`/`storeId`/`roleCode` 这些权限相关字段可以被本地篡改,而后端拼接时这些值都从服务端的会话上下文取,客户端只能说"我要开 `QUOTE_ORDER`"。 + +客户端唯一负责传的是 `traceId`——请求 `/h5/launch` 时带的 `X-Trace-Id`(见 [05-networking.md](./05-networking.md)),后端把它带进 H5 URL,这样"用户在 H5 里遇到问题"能一路追到 App 侧的请求。 + +## 域名白名单 + +```dart +// packages/core_webview/lib/src/url_guard.dart +class UrlGuard { + const UrlGuard(this._allowedHosts); + final Set _allowedHosts; // 来自 env/{flavor}.json,各环境不同 + + bool isAllowed(Uri uri) { + if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD §7.6) + final host = uri.host.toLowerCase(); + return _allowedHosts.any((allowed) => + host == allowed || host.endsWith('.$allowed')); + } +} +``` + +白名单在**三个位置**都要生效,缺一不可: + +1. **首次加载前**:后端返回的 URL 校验一次(防后端配置错误)。 +2. **导航拦截**(`NavigationDelegate.onNavigationRequest`):H5 内部跳转到非白名单域名一律 `NavigationDecision.prevent`,并记一条埋点。 +3. **JSBridge 消息处理时**:每条消息都校验当前页面的 host(见下文「来源校验」)。 + +`endsWith('.$allowed')` 而不是 `contains`:`contains` 会让 `f6.example.com.evil.com` 通过校验,这是白名单实现里最经典的一个洞。 + +非白名单链接(比如 H5 里的外部帮助文档)不是静默阻止,而是**弹确认框后用系统浏览器打开**,避免用户点了没反应以为坏了。 + +## JSBridge 协议 + +### 通道与消息格式 + +只开**一个** JavaScript Channel,所有能力走同一个通道分发。开多个 channel(每个能力一个)会让来源校验、日志、错误处理各写一遍。 + +```dart +controller.addJavaScriptChannel( + 'ContiBridge', + onMessageReceived: (message) => _bridge.handle(message.message), +); +``` + +H5 侧调用: + +```js +// 由 App 在页面加载完成后注入的一小段 JS 提供(见下文「JS 侧胶水」) +const result = await window.ContiBridge.call('scan', { mode: 'barcode' }); +``` + +**请求**(H5 → App): + +```json +{ "id": "c8f1-...", "method": "scan", "params": { "mode": "barcode" } } +``` + +**回包**(App → H5): + +```json +{ "id": "c8f1-...", "ok": true, "data": { "value": "6901234567892", "format": "EAN_13" } } +{ "id": "c8f1-...", "ok": false, "error": { "code": "PERMISSION_DENIED", "message": "未授予相机权限" } } +``` + +**主动事件**(App → H5,无 `id`): + +```json +{ "event": "storeChanged", "payload": { "storeId": 7 } } +``` + +约定: + +- `id` 由 **H5 侧生成**并原样回传,App 不生成——这样 H5 侧的 Promise 映射表完全由它自己管理。 +- **所有回包都是异步的**,即使是同步能力(如 `getStoreContext`)。统一异步避免 H5 侧写两套调用方式。 +- `error.code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码。H5 侧按 code 分支处理,`message` 只用于展示。 +- 未知 `method` 返回 `{ code: "UNSUPPORTED_METHOD" }` 而不是静默忽略——H5 版本比 App 新时能明确知道"这个 App 版本不支持这个能力",可以降级而不是卡死。 + +### 能力清单(PRD §7.4) + +| method | 说明 | 底层 | 备注 | +|---|---|---|---| +| `scan` | 打开扫码 | `native_scan` | `params.mode`: `barcode`/`vin`/`plate`(见 [07](./07-native-integration.md)) | +| `camera` | 打开相机拍照 | `native_media` | 返回压缩后的本地路径 | +| `pickImage` | 打开相册 | `native_media` | 支持多选,`params.maxCount` | +| `uploadFile` | 上传图片/文件 | `core_network` | 带进度事件,见下文 | +| `dial` | 调起拨号 | `native_device` | `ACTION_DIAL`/`tel:`,不直接拨出 | +| `closePage` | 关闭当前 H5 页 | `core_router` | 等价于 `context.pop()` | +| `goBack` | H5 内返回上一页 | WebView | 无历史时降级为 `closePage` | +| `refresh` | 刷新页面 | WebView | | +| `getAuthState` | 获取登录态 / 触发换票 | `core_auth` | **不返回 token 明文**,见安全约定 | +| `getStoreContext` | 获取当前门店上下文 | `core_auth` | 返回 `storeId`/`storeCode`/`orgId`/`roleCode` | +| `toast` / `dialog` / `loading` | 弹出提示 | `core_ui` | 用原生控件,保证与 App 其他页面视觉一致 | +| `navigate` | 跳转 App 原生页面 | `core_router` | `params.route` 必须是**预定义的路由白名单**,不接受任意路径 | +| `setTitle` | 设置导航栏标题 | `core_ui` | 与自动的 `title` 同步互补 | + +`navigate` 的路由白名单和 `04-routing.md` 的「后端动态菜单 → 本地路由」用同一张 `menuRouteMap`——不允许 H5 拼一个任意路由字符串跳过去(那等于把 App 的所有内部页面都暴露给了 H5)。 + +### 来源校验(PRD §7.6) + +**JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。** 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 `ContiBridge`。所以每条消息进来都要校验: + +```dart +Future handle(String raw) async { + // 1. 当前页面必须在白名单内 + final current = await _controller.currentUrl(); + if (current == null || !_urlGuard.isAllowed(Uri.parse(current))) { + _logger.w('[bridge] 拒绝来自非白名单页面的调用: $current'); + return; // 静默丢弃,不回包——不给探测者任何反馈 + } + + // 2. 解析必须容错:H5 传了畸形 JSON 不能让 App 崩 + final Map req; + try { + req = jsonDecode(raw) as Map; + } catch (_) { + return _logger.w('[bridge] 无法解析的消息'); + } + + final id = req['id'] as String?; + final method = req['method'] as String?; + if (id == null || method == null) return; + + final handler = _handlers[method]; + if (handler == null) { + return _reply(id, error: const BridgeError('UNSUPPORTED_METHOD', '当前 App 版本不支持该能力')); + } + + try { + _reply(id, data: await handler(req['params'] as Map? ?? const {})); + } on AppException catch (e) { + _reply(id, error: BridgeError(e.bridgeCode, e.message)); + } catch (e, st) { + _logger.e('[bridge] $method 未预期异常', error: e, stackTrace: st); + _reply(id, error: const BridgeError('INTERNAL_ERROR', '操作失败,请重试')); + } +} +``` + +> `currentUrl()` 返回的是**主 frame** 的 URL,所以这个校验能挡住"整页被导航到恶意站点后调 bridge",但挡不住"白名单页面内的恶意 iframe"。后者的正确解法是不让 F6 页面嵌不受信的 iframe(协议层面约定),以及在导航拦截里限制 iframe 加载的域名。这个限制要在与 F6 的接口评审里明确。 + +### 其他安全约定 + +- **`getAuthState` 不返回 token 明文**(PRD §7.6:"H5 页面不得直接保存 APP 明文 Token")。它返回的是 `{ loggedIn: true, ticketRefreshed: true }` 这类状态,H5 需要新票据时由 App 重新换票并 `loadRequest` 新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。 +- **H5 侧的所有输入都当作不可信**:`params` 里的路径、路由、URL 一律校验后再用。特别是 `uploadFile` 的文件路径,必须限制在 App 沙盒内的临时目录,否则 H5 可以让 App 上传任意本地文件。 +- **供应商错误不透传**:F6 返回的原始错误信息转换成用户能懂的提示(PRD §7.6),原始信息只进日志。 + +### JS 侧胶水 + +`window.ContiBridge` 只是一个原始的 `postMessage` 通道,H5 侧直接用很难写。App 在 `onPageFinished` 时注入一段封装,把它包成 Promise: + +```dart +const _bridgeShim = r''' +(function () { + if (window.__contiBridgeReady) return; + const pending = new Map(); + window.__contiBridgeCallback = function (resp) { + const p = pending.get(resp.id); + if (!p) return; + pending.delete(resp.id); + resp.ok ? p.resolve(resp.data) : p.reject(resp.error); + }; + window.__contiBridgeEvent = function (evt) { + window.dispatchEvent(new CustomEvent('conti:' + evt.event, { detail: evt.payload })); + }; + const raw = window.ContiBridge; + window.ContiBridge = { + call: function (method, params) { + const id = String(Date.now()) + Math.random().toString(36).slice(2); + return new Promise(function (resolve, reject) { + pending.set(id, { resolve: resolve, reject: reject }); + raw.postMessage(JSON.stringify({ id: id, method: method, params: params || {} })); + }); + }, + }; + window.__contiBridgeReady = true; +})(); +'''; +``` + +**注入时机是 `onPageFinished`,不是 `onPageStarted`**——`onPageStarted` 时 H5 的脚本可能还没执行完,重复注入或时序错乱。同时 `__contiBridgeReady` 做幂等保护,因为 SPA 内部路由变化可能触发多次回调。 + +H5 侧要处理"bridge 还没就绪"的情况(比如页面脚本跑得比注入早),约定 H5 等待 `window.__contiBridgeReady` 或监听一个 `conti:ready` 事件。**这条要写进给 F6 的接入文档**。 + +## 生命周期管理(PRD §7.5) + +| 场景 | 处理 | +|---|---| +| **标题同步** | `onPageFinished` 后读 `document.title` 写入导航栏;`setTitle` bridge 调用优先级更高 | +| **返回 vs 关闭** | 导航栏同时有「返回」和「关闭」。返回:有 H5 历史则 `goBack()`,无历史则退出容器。关闭:直接退出容器,不管 H5 历史 | +| **Android 物理返回键** | 与「返回」按钮同语义。**必须拦截**,否则一次返回直接退出整个 H5,用户填了一半的表单就没了 | +| **缓存策略** | 默认走 WebView 的 HTTP 缓存(F6 的静态资源应带 `Cache-Control`)。**不做 App 侧的离线包**——首版没有这个必要,且离线包会引入版本管理复杂度 | +| **票据过期** | 见下文 | +| **白屏/超时** | 见下文 | +| **上传中断** | 见下文 | +| **门店切换 / 登出** | 见下文 | + +### 票据过期后重新换票 + +票据是短时的(F6 侧决定,通常几分钟到几十分钟)。两种触发路径: + +1. **H5 主动发现**:F6 页面收到票据失效的响应,调 `getAuthState` 请求刷新 → App 重新调 `/h5/launch` 拿新 URL → `loadRequest` 新 URL。 +2. **App 预判**:进入前台时若距离上次换票已超过 `ttl * 0.8`,主动换票并 reload。 + +**不要在票据过期时静默 reload**——用户正在填表单,reload 会丢数据。正确做法是弹一个"登录信息已过期,需要重新加载页面"的确认框,让用户决定。如果 H5 侧能保存草稿就更好(这一项要和 F6 对齐)。 + +### 白屏、超时、网络失败兜底 + +WebView 加载失败时用户看到的是一片空白,没有任何提示——这是 H5 容器体验最差的一类问题,必须显式处理: + +```dart +NavigationDelegate( + onPageStarted: (_) => _startWatchdog(const Duration(seconds: 15)), + onPageFinished: (_) { _cancelWatchdog(); _injectShim(); }, + onWebResourceError: (error) { + // 只处理主文档的错误,子资源(某张图、某个 JS)失败不该整页报错 + if (!error.isForMainFrame!) return; + _showErrorState(error); + }, + onHttpError: (error) { + if (error.response?.statusCode == 404) _showErrorState(...); + }, +) +``` + +- **15 秒看门狗**:`onPageStarted` 后 15 秒还没 `onPageFinished` 就展示"加载超时,请重试"。WebView 在某些网络状况下既不成功也不报错,只有超时能兜住。 +- 错误页给「重试」和「返回」两个按钮,重试重新走完整的换票流程(票据可能已经过期了),不是简单 `reload()`。 +- 每次白屏/超时都**上报埋点**(`h5_failed`,带 `target`、错误码、耗时、`traceId`),见 [13-observability-analytics.md](./13-observability-analytics.md)。**这一类失败后端完全看不到**——换票请求是成功的,页面加载失败发生在 WebView 内部,所以它必须由客户端报。这是 H5 链路健康度最重要的指标。 + +### 上传中断与重新提交 + +`uploadFile` 是耗时最长、最容易被打断的桥接能力(切后台、网络切换、用户误触返回)。约定: + +- 上传期间**拦截返回和关闭**,弹确认框「上传未完成,确定要离开吗?」。 +- 上传进度通过主动事件推给 H5(`{ event: "uploadProgress", payload: { taskId, sent, total } }`),让 H5 自己画进度条——比 App 弹一个盖住页面的 loading 体验好。 +- 上传失败的回包里带 `taskId`,H5 可以用同一个 `taskId` 重试,避免重复上传已成功的部分。 +- 具体上传实现(压缩、超时、单张重传)复用 [05-networking.md](./05-networking.md) 的 `ApiClient.upload`,`core_webview` 不自己写一套。 + +### 门店切换与登出时的会话失效 + +PRD §7.5 的默认策略是硬要求: + +- **门店切换后,当前 H5 页面必须失效并提示用户重新进入。** +- **用户退出登录后,所有 H5 会话必须同步失效。** + +```dart +// packages/core_webview/lib/src/webview_session.dart +class WebViewSession { + /// 门店切换 / 登出时由会话编排调用(见 11-store-context-and-session.md) + Future invalidateAll({required bool clearCookies}) async { + for (final controller in _openControllers) { + await controller.loadRequest(Uri.parse('about:blank')); // 先停掉页面,防止在途请求继续 + } + if (clearCookies) { + await WebViewCookieManager().clearCookies(); + await _controller.clearLocalStorage(); + await _controller.clearCache(); + } + _openControllers.clear(); + } +} +``` + +区别: + +- **门店切换**:关闭已打开的 H5 页并提示"门店已切换,请重新进入",**不清 Cookie**(用户还是同一个人,清了会导致 F6 侧重新走一遍登录)。 +- **登出**:关闭所有 H5 页 + **清 Cookie / LocalStorage / Cache**。不清的话下一个登录的人可能直接进到上一个人的 F6 会话——同一台门店共用设备上这是真实会发生的。 + +清理动作**必须等待完成**再让新用户登录,不能 fire-and-forget。 + +## 与 F6 的接口对齐清单 + +以下几项需要和 F6 侧明确约定,不对齐会在联调阶段集中爆发: + +1. `ContiBridge` 的 12 项能力,H5 侧如何检测可用性(`__contiBridgeReady` 的等待方式)。 +2. 票据过期时 F6 页面的表现(返回什么响应,是否能保存草稿)。 +3. F6 页面是否嵌第三方 iframe,若有需要哪些域名。 +4. F6 静态资源的 `Cache-Control` 策略。 +5. `error.code` 枚举表(App 侧定义,F6 侧按 code 分支)。 +6. H5 内部跳转是否会离开白名单域名。 + +## 待确认项 + +- 各环境的域名白名单具体值(写进 `env/{flavor}.json`)。 +- `/api/v1/h5/launch` 的接口契约(后端侧对应 `bff-orchestration` + `webview-ticket`,见 [backend/05-integration-layer.md](./backend/05-integration-layer.md)),需要与后端一起定。 +- 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。 + +## 参考链接 + +- [webview_flutter | Dart package](https://pub.dev/packages/webview_flutter) +- [webview_flutter: JavaScript Channel](https://pub.dev/packages/webview_flutter#javascript-channels) +- [AndroidWebViewController.setOnShowFileSelector](https://pub.dev/documentation/webview_flutter_android/latest/webview_flutter_android/AndroidWebViewController/setOnShowFileSelector.html) +- [OWASP MASVS:WebView 安全](https://mas.owasp.org/MASVS/) +- [PRD §7 Embedded H5 接入规范](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) diff --git a/11-store-context-and-session.md b/11-store-context-and-session.md new file mode 100644 index 0000000..08179bb --- /dev/null +++ b/11-store-context-and-session.md @@ -0,0 +1,302 @@ +# 11. 门店上下文与会话管理 + +## 为什么单独一篇 + +门店上下文是**贯穿整个 App 的隐式依赖**:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD §11.4:「当前门店影响所有业务数据」)。它不属于任何一个 `feature_*`,但每个 `feature_*` 都依赖它。 + +更关键的是**切换门店时的级联失效**——这是最容易漏、漏了就会出"看到别的门店数据"这种严重问题的地方。之前 01-10 里只在各自话题下提了一句(03 讲 provider 失效、06 讲缓存清理、10 讲 H5 失效),没有一个地方定义完整顺序。这一篇负责收口。 + +## 会话状态模型 + +``` +AppSession +├── AuthState 登录态(token 生命周期,归 core_auth) +├── UserContext 用户上下文(PRD §6.4.1) +└── StoreContext 门店上下文(PRD §6.4.2) +``` + +```dart +// packages/core_auth/lib/src/model/app_session.dart +sealed class AppSession {} + +/// 冷启动读本地态期间,UI 停在 splash +class SessionLoading extends AppSession {} + +class SessionUnauthenticated extends AppSession { + final LogoutReason? reason; // 主动登出 / token 失效 / 被踢,用于登录页提示文案 +} + +/// 已登录但还没确定门店(多门店用户需要选,或门店列表拉取失败) +class SessionAwaitingStore extends AppSession { + final UserContext user; +} + +class SessionActive extends AppSession { + final UserContext user; + final StoreContext store; +} +``` + +**四个状态,不是布尔值。** 用 `bool isLoggedIn` 表达会立刻遇到两个说不清的场景:冷启动期间算不算已登录(算,会闪一下首页;不算,会闪一下登录页),以及"已登录但没门店"该去哪(PRD §11.3 要求「门店上下文缺失时引导重新选择门店」,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 `04-routing.md` 的 redirect 能穷举分支,漏一个编译器就报错。 + +```dart +final class UserContext { + final String userId, employeeId, phone, roleCode, channel; + final Set permissions; // 权限集 +} + +final class StoreContext { + final int storeId; + final String storeCode, storeName; + final int orgId; + final String? parentStoreId; // 所属总店,无则为分店/独立店 + final List menus; // 当前门店可访问菜单,见 04-routing.md 的 menuRouteMap +} +``` + +`menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD §6.4.2 明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。 + +## 唯一真相源 + +```dart +@riverpod +class SessionNotifier extends _$SessionNotifier { + @override + Future build() async { ... } +} + +/// 全 App 读 storeId 的唯一入口 +@riverpod +int currentStoreId(Ref ref) { + final session = ref.watch(sessionNotifierProvider).valueOrNull; + return switch (session) { + SessionActive(:final store) => store.storeId, + _ => throw StateError('在没有门店上下文时访问了 currentStoreId'), + }; +} +``` + +规则(与 [03-state-management.md](./03-state-management.md) 一致): + +- **任何请求里带 storeId 的 provider,必须 `ref.watch(currentStoreIdProvider)` 拿它**,不能 `ref.read`,也不能作为参数从上层传下来。这样切店时依赖图自动失效,不需要维护"哪些 provider 要手动 invalidate"的清单——那份清单一定会漏。 +- `currentStoreId` 在非 `SessionActive` 时**抛异常而不是返回 0 或 null**。能读到这个 provider 说明 UI 已经渲染到了业务页面,此时没有门店上下文是路由守卫的 bug,应该在开发期直接炸出来,而不是发一个 `storeId=0` 的请求让后端返回一堆空数据。 + +## 登录流程 + +PRD §10.1:「登录成功后必须立即获取门店上下文」。 + +``` +输入手机号 + 验证码(或账号密码) + ↓ +POST /api/v1/auth/login → { accessToken, refreshToken, user } + ↓ +写入 secure storage(core_auth 独占,见 06) + ↓ +GET /api/v1/stores/accessible → 门店列表 + ↓ + ┌────┴────┬──────────────┐ + 0 个 1 个 多个 + ↓ ↓ ↓ +"无门店权限" 直接选中 上次门店仍在列表 → 选中 + 提示 + 登出 否则 → 门店选择页 + ↓ +POST /api/v1/stores/{id}/switch → StoreContext(含菜单) + ↓ + SessionActive → 跳首页 +``` + +几个容易做错的点: + +- **`stores/accessible` 失败不等于登录失败**。token 已经拿到了,此时应该进 `SessionAwaitingStore` 并展示一个可重试的页面,而不是回登录页让用户重新发一遍验证码。 +- **"上次门店"只是一个提示,不是权限依据**。它存在 `shared_preferences`(非敏感,见 [06-local-storage.md](./06-local-storage.md)),冷启动/登录时用来预选,但**必须先确认它在后端返回的可访问列表里**——用户的门店权限可能已经被管理员回收了。 +- **0 个门店时必须登出**,不能停在一个空白首页。PRD §10.1/§10.2 把"用户无门店权限"列为登录异常流程。 + +## 切换门店:级联失效清单 + +这是本篇的核心。PRD §11.4:「购物车、待办、预警、订单和 H5 页面上下文必须同步切换」。 + +**顺序是有意义的**,不能随便调: + +```dart +Future switchStore(int targetStoreId) async { + // ── 0. 前置:有未完成的写操作就拦住 ────────────────── + if (ref.read(pendingWriteProvider).isNotEmpty) { + throw const PreconditionException('有未完成的操作,请稍后再试'); // 本地判定,不编后端错误码,见 12 + } + + // ── 1. 先让 UI 进入切换中,挡住用户继续操作 ────────── + state = const AsyncLoading(); + + // ── 2. 服务端切换(失败则整个流程中止,本地状态不动)── + final newStore = await _repo.switchStore(targetStoreId); + + // ── 3. 关闭 H5 会话(不清 Cookie,见 10)───────────── + await ref.read(webViewSessionProvider).invalidateAll(clearCookies: false); + + // ── 4. 清本地业务缓存(事务内,见 06)──────────────── + await ref.read(appDatabaseProvider).clearBusinessCache(); + + // ── 5. 落新的门店上下文 → 依赖 currentStoreId 的 provider 自动失效 ── + state = AsyncData(SessionActive(user: _user, store: newStore)); + + // ── 6. 路由清栈回首页(见 04)──────────────────────── + ref.read(goRouterProvider).go('/home'); + + // ── 7. 记住这次选择,供下次冷启动预选 ──────────────── + await ref.read(prefsProvider).setInt('last_store_id', newStore.storeId); + + // ── 8. 同步观测上下文(见 13)──────────────────────── + ref.read(crashReporterProvider).setTag('storeId', '${newStore.storeId}'); + ref.read(analyticsProvider).registerSuperProperties({'storeId': newStore.storeId}); + // 切店事件本身由后端从 /stores/{id}/switch 的接口日志出,客户端不重复上报,见 13 +} +``` + +| 步 | 为什么必须在这个位置 | +|---|---| +| 2 在 3/4 之前 | 服务端切换失败(网络断、权限被回收)时**本地必须原样不动**。反过来先清缓存再请求,一旦失败用户就停在一个"门店没变但数据全没了"的状态 | +| 3 在 5 之前 | H5 页面里可能有在途请求。先 `about:blank` 停掉,再换上下文,否则旧门店的 H5 请求会带着新门店的票据回来 | +| 4 在 5 之前 | 缓存表带 `storeId`(见 06),但**清理和新上下文之间不能有窗口期**:如果先落新上下文,provider 立刻失效并重新请求,可能在清理完成前就把新数据写进去,然后被 `clearBusinessCache()` 一起删掉 | +| 6 在 5 之后 | 清栈时目标页面(首页)要用新上下文渲染 | +| 8 在 5 之后 | 观测上下文要和业务上下文保持一致;漏了这一步的表现是**切店后的崩溃和埋点还挂在旧门店名下**,按门店维度分析时数据是错的,而且错得很隐蔽 | + +**关于步骤 0(未完成写操作)**:切店时用户可能正在提交订单或上传图片。默认策略是**阻止切换并提示**,而不是静默取消——取消一个已经发出去的下单请求,客户端不知道服务端到底成没成。`pendingWriteProvider` 由发起写操作的 feature 自己注册/注销。 + +**关于购物车**:PRD 要求切店后购物车同步切换。购物车走 `clearBusinessCache()` 一起清(它是门店维度的业务数据)。如果后续产品要求"每个门店各自保留购物车",那就改成按 `storeId` 分区保留而不是清空——表结构已经带 `storeId`,改动只在这一处。 + +## 登出:清理清单 + +PRD §10.4:「清理 Token、门店上下文、本地用户信息和缓存」+「关闭所有已打开的 F6 H5 会话」。 + +```dart +Future logout({LogoutReason reason = LogoutReason.userInitiated}) async { + // 1. 通知服务端撤销 refresh token(尽力而为,失败不阻断本地登出) + if (reason == LogoutReason.userInitiated) { + await _repo.revokeSession().timeout(const Duration(seconds: 3)).catchError((_) {}); + } + + // 2. H5 会话 + Cookie/LocalStorage/Cache 全清(见 10) + await ref.read(webViewSessionProvider).invalidateAll(clearCookies: true); + + // 3. 本地数据 + await ref.read(appDatabaseProvider).clearAllUserData(); // Drift 业务表 + await ref.read(secureStorageProvider).deleteAll(); // token + await ref.read(prefsProvider).clearUserScoped(); // 只清用户相关的 key + + // 4. 状态置为未登录 → 路由守卫自动跳登录页 + state = AsyncData(SessionUnauthenticated(reason: reason)); + + // 5. 断开观测/埋点的用户关联(门店设备是共用的,不断开会让下一个人的数据串到上一个人身上) + ref.read(analyticsProvider) + ..track(AnalyticsEvent.logout, {'reason': reason.name}) // 报完再 reset,顺序不能反 + ..reset(); + ref.read(crashReporterProvider).setUser(''); + + // 6. 兜底:清掉所有 provider 缓存 + ref.invalidate(...); // 或在 ProviderScope 层重建,见下文 +} +``` + +要点: + +- **第 1 步失败不能阻断登出**。网络不通时用户点登出必须能退出去,否则用户体验是"这个 App 退不出来"。服务端 token 会自然过期,不撤销的代价可以接受。加 3 秒超时。 +- **`prefs.clearUserScoped()` 而不是 `prefs.clear()`**。`shared_preferences` 里还有"是否同意过协议""夜间模式偏好""是否看过新手引导"这类设备级配置,全清会导致下一个用户看一遍新手引导。约定:用户相关的 key 统一加 `u_` 前缀,`clearUserScoped()` 按前缀删。 +- **必须等第 2/3 步完成再切状态**。fire-and-forget 会出现"新用户已经登录进首页了,上一个用户的缓存清理才刚跑完",然后把新用户的数据也删了。门店共用设备上这不是理论问题。 +- **`clearCookies: true` 在登出时是硬要求**。不清的话下一个人打开 H5 会直接落进上一个人的 F6 会话——这是本项目最有可能出现的一个真实安全事故。 + +### 登出兜底:为什么还要一步 provider 清理 + +`ref.invalidate` 一个个点名会漏。更稳的做法是让整个业务 provider 树挂在一个 key 上重建: + +```dart +// main.dart +ProviderScope( + retry: (_, __) => null, + child: Consumer(builder: (context, ref, _) { + final sessionKey = ref.watch(sessionKeyProvider); // 每次登录/登出自增 + return KeyedSubtree(key: ValueKey(sessionKey), child: const ContiApp()); + }), +) +``` + +**注意这只重建 widget 树,不重建 provider(provider 挂在 `ProviderScope` 上,在 `KeyedSubtree` 外面)。** 真正让业务 provider 全部失效的是"它们都直接或间接 `ref.watch(currentStoreIdProvider)` / `sessionNotifierProvider`"这条规则 —— 状态一变,`autoDispose` 的 provider 自然重算,`keepAlive` 的少数几个(见 03 的三类白名单)**必须在登出时显式 invalidate**,清单就是那三类,是有限且可维护的。 + +## 与 refresh token 轮换的配合 + +后端采用**一次性 refresh token + 重放即全量撤销**(见 [backend/04-security-auth.md](./backend/04-security-auth.md))。这对客户端有两条硬约束,已经在 [05-networking.md](./05-networking.md) 的 `AuthInterceptor` 里实现,这里说明它和会话状态的关系: + +1. **刷新必须串行**。并发刷新会把同一个 refresh token 用两次,后端判定为重放,**撤销该用户所有设备的会话**——用户会在自己毫无操作的情况下被全端踢下线。 +2. **刷新失败立即登出,不重试**。失败意味着 refresh token 已失效(过期、被撤销、或已被重放),重试只会再触发一次重放判定。 + +```dart +// TokenRefresher 刷新失败 → 通知会话层 +void _onRefreshFailed() { + ref.read(sessionNotifierProvider.notifier).logout(reason: LogoutReason.tokenExpired); +} +``` + +`LogoutReason.tokenExpired` 让登录页能显示「登录已过期,请重新登录」而不是一个没有解释的空登录页。用户在别处被踢(`sessionRevoked`)时提示文案也不同。 + +## 冷启动恢复 + +``` +App 启动 → SessionLoading(splash) + ↓ +读 secure storage 的 token + ↓ + 没有 → SessionUnauthenticated + 有 → GET /api/v1/auth/me + /stores/accessible + ↓ + ┌───┴────────────────┬─────────────────┐ + 成功 401 网络失败 + ↓ ↓ ↓ +预选 last_store_id → 登出 进首页 + 用本地缓存渲染 + → SessionActive (见 12 的降级约定) +``` + +- **secure storage 读失败要当作未登录处理**,不能让异常冒到启动流程里(见 [06-local-storage.md](./06-local-storage.md) 关于 `flutter_secure_storage 11.0.0` 的说明)。启动崩溃是最难排查也最致命的一类问题。 +- **网络失败时不要把用户踢到登录页**。门店里网络不稳是常态,本地有 token 就先按已登录处理,用缓存渲染首页,顶部提示"数据可能不是最新"。真正无效的 token 会在第一个业务请求返回 401 时被发现,那时再登出。 +- splash 有**最长等待时间**(3 秒)。超时就按"网络失败"分支走,不能无限转圈。 + +## 回到前台时的一致性校验 + +App 从后台回来时,服务端的门店权限可能已经变了(管理员回收了权限、门店被停用)。 + +```dart +// 冷时间超过 5 分钟才校验,避免频繁切前后台打接口 +if (elapsedSinceBackground > const Duration(minutes: 5)) { + final stores = await _repo.fetchAccessibleStores(); + if (!stores.any((s) => s.storeId == currentStoreId)) { + // 当前门店已不可访问 + await switchStore(stores.first.storeId); // 或引导重选 + showToast('您对当前门店的权限已变更,已切换到 ${stores.first.storeName}'); + } +} +``` + +不做这个校验的后果是:用户带着一个已失效的 storeId 继续操作,每个请求都被后端拒绝,界面上表现为"什么都点不动但也不说为什么"。 + +## 埋点 + +会话相关事件大部分**由后端从自己的接口日志出**(登录、切店都是接口调用),客户端不重复报(见 [13-observability-analytics.md](./13-observability-analytics.md) 的分工原则)。客户端只补后端看不到的两件事: + +| 事件 | 谁报 | 关键字段 | +|---|---|---| +| 登录成功/失败、门店切换 | **后端** | 接口日志即可,客户端不重复上报 | +| `logout` | **客户端** | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出往往没有对应的接口调用**——token 刷新失败是客户端本地判定的,后端只看到一个失败的刷新请求,看不到"用户因此被踢了出去" | +| `session_restore_failed` | **客户端** | 失败阶段(读 storage / me / stores)。冷启动恢复失败在读 secure storage 这一步时**完全不产生网络请求**,后端无从知晓 | + +`logout` 的 `reason` 分布是最有价值的一个指标——如果 `tokenExpired` 占比异常高,说明刷新逻辑有问题(很可能就是并发刷新触发了后端的重放撤销)。这个指标只能由客户端提供。 + +## 待确认项 + +- `/api/v1/stores/accessible` 与 `/api/v1/stores/{id}/switch` 的接口契约,以及切换是否需要服务端记录(影响多端一致性)。 +- 切店时"未完成写操作"的判定粒度:是全局阻止,还是只阻止发起写操作的那个 feature。 +- 购物车是否需要按门店分别保留(当前决策:清空)。 +- 前台一致性校验的触发阈值(当前定 5 分钟)需要跑一个迭代后按实际接口压力调整。 + +## 参考链接 + +- [PRD §6.4 上下文定义 / §10.4 退出登录 / §11.4 门店切换](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) +- [backend/04-security-auth.md:refresh token 轮换](./backend/04-security-auth.md) +- [Riverpod: Combining requests](https://riverpod.dev/docs/essentials/combining_requests) diff --git a/12-error-and-api-contract.md b/12-error-and-api-contract.md new file mode 100644 index 0000000..a679319 --- /dev/null +++ b/12-error-and-api-contract.md @@ -0,0 +1,370 @@ +# 12. 错误处理与 API 契约 + +## 为什么单独一篇 + +[05-networking.md](./05-networking.md) 定义了"网络层怎么抛异常",但没定义"UI 层怎么显示、什么时候降级、用户看到什么文案"。这两件事必须一起定,否则会出现每个 feature 各写一套错误提示:有的弹 Toast、有的弹 Dialog、有的整页红字、有的干脆什么都不显示。 + +这一篇负责三件事:**客户端侧的 `ApiResult` 契约**、**`AppException` 体系全貌**、**错误到 UI 的映射规则(含降级)**。 + +## 一、`ApiResult` 客户端契约 + +后端所有接口统一返回(见 [backend/06-api-design.md](./backend/06-api-design.md)): + +```json +{ "code": 0, "message": "success", "data": { ... }, "traceId": "a1b2c3..." } +``` + +**`code` 是数字,`0` 表示成功。** 客户端契约(在 `core_network` 的 `ApiResultInterceptor` 里实现,见 05): + +| 情况 | 客户端行为 | +|---|---| +| HTTP 2xx + `code == 0` | 解包,业务层只拿到 `data` | +| HTTP 2xx + `code != 0` | 抛 `BusinessException(code, message, traceId)` | +| HTTP 4xx/5xx + body 是 `ApiResult` | 同上,按 `code` 抛 `BusinessException` | +| HTTP 4xx/5xx + body 不是 `ApiResult`(网关、CDN、Nginx 返回的 HTML) | 抛 `ServerException(statusCode, traceId: null)` | +| 连接失败 / 超时 | 抛 `NetworkException` | + +**第四行是最容易漏的。** 请求不一定能到达后端——网关 502、Nginx 413(上传超限)、运营商劫持返回的 HTML 页面,都不会带 `ApiResult` 结构。直接 `jsonDecode` 会抛 `FormatException`,业务层完全接不住。所以解包前必须判断 body 是不是 `Map` 且含 `code` 字段。 + +### 数字错误码的代价,以及怎么消化它 + +数字码在日志和监控里聚合方便(可以直接 `group by code` 出趋势),但**它不自解释**:日志里一条 `code=10403` 不看码表完全不知道是什么。所以配套要求: + +1. **必须有一份双方共享、和代码一起维护的码表**,不能只存在于某个人的 Excel 里。 +2. **客户端不允许出现字面量数字**。所有用到的码定义成命名常量,`if (e.code == ApiCode.forbidden)` 而不是 `if (e.code == 10403)`。 +3. **日志里 code 和 message 一起打**,因为 `message` 是唯一能让人在不查码表时看懂的东西。 + +### 分段方案(建议,待后端确认) + +`backend/06-api-design.md` 的待补充项里「按 domain 分段还是全局统一编码」还没定。建议 **5 位数字,前 2 位是域段**: + +| 段 | 域 | 例 | +|---|---|---| +| `0` | 成功 | `0` | +| `10xxx` | 平台通用 | `10001` 参数错误、`10401` 未登录、`10403` 无权限、`10500` 系统错误 | +| `11xxx` | 认证与门店 | `11001` 门店不可访问、`11002` 无门店权限 | +| `20xxx` | 采购 | | +| `21xxx` | 库存 | | +| `3xxxx` | F6 / Mini 透传类错误 | 后端做过转换,不透传供应商原始码 | + +分段的价值是**看到码的前两位就知道该找谁**。全局连续编号(1、2、3…)在多域并行开发时必然撞号。 + +### `data` 为 `null` 的语义 + +`code == 0` 但 `data == null` 是合法的(后端 `ApiResult.ok(Unit)`)。约定: + +```dart +Future → data 可以为 null,忽略 +Future → data 为 null 时抛 ServerException('响应缺少 data'),不返回 null +Future → 显式声明可空时才允许 null +``` + +不加这层校验的话,后端某个字段漏返回会变成 UI 层莫名其妙的 `Null check operator used on a null value`,排查时完全看不出是接口问题。 + +### 完整码表还没定 + +分段方案(上表)只是骨架,**具体的码表还没和后端对齐**。在它定下来之前: + +- **默认直接展示后端的 `message`**。后端的 `GlobalExceptionHandler` 已经保证了 `message` 是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。这条策略让客户端在码表缺席时也能正常工作。 +- **客户端只对一小组"需要特殊 UX 而不只是提示文案"的 code 做分支**,这组必须尽可能小: + +```dart +// packages/core_network/lib/src/error/api_code.dart +abstract final class ApiCode { + static const ok = 0; + + static const invalidParam = 10001; // → 表单内联报错,不弹 Toast + static const unauthorized = 10401; // → 触发刷新 / 登出 + static const forbidden = 10403; // → 权限变更,可能要重拉门店上下文 + static const internalError = 10500; // → 展示 traceId + + static const storeNotAccessible = 11001; // → 引导重选门店 +} +``` + +**这份清单要和后端一起确认**,是本文档最重要的待确认项。清单之外的 code 一律走默认展示。 + +## 二、`AppException` 体系 + +```dart +// packages/core_network/lib/src/error/app_exception.dart +sealed class AppException implements Exception { + const AppException(this.message, {this.traceId}); + final String message; + final String? traceId; +} + +/// 网络不通、超时、DNS 失败——用户重试可能就好了 +final class NetworkException extends AppException { + const NetworkException(super.message, {this.kind}); + final NetworkErrorKind? kind; // connectTimeout / receiveTimeout / noConnection +} + +/// 后端返回了 code != 0,message 可直接展示 +final class BusinessException extends AppException { + const BusinessException(this.code, super.message, {super.traceId}); + final int code; +} + +/// 5xx、非 ApiResult 响应、解析失败——用户重试大概率也不好 +final class ServerException extends AppException { + const ServerException(super.message, {this.statusCode, super.traceId}); + final int? statusCode; +} + +/// token 失效且刷新失败,已触发登出 +final class UnauthorizedException extends AppException {} + +/// 请求被 CancelToken 取消(页面销毁、用户主动退出) +final class RequestCancelledException extends AppException {} + +/// 客户端本地判定的前置条件不满足(如切店时有未完成的写操作),message 可直接展示 +/// 不复用 BusinessException:后者的 code 来自后端错误码表,纯本地的判定没有、也不该编一个 code +final class PreconditionException extends AppException { + const PreconditionException(super.message); +} + +/// 本地存储 / 数据库错误 +final class StorageException extends AppException {} + +/// 原生能力错误(权限拒绝、设备不支持),见 07 +final class NativeException extends AppException { + const NativeException(this.code, super.message); + final String code; // PERMISSION_DENIED / UNAVAILABLE / CANCELLED +} +``` + +`sealed` 是有意的:UI 层的错误映射用 `switch` 穷举,将来新增一种异常类型,所有映射点编译报错,逼着人去处理,而不是悄悄落进 `default` 分支变成"未知错误"。 + +**`RequestCancelledException` 必须被 UI 静默处理**(见 05)。用户返回上一页时在途请求被取消,弹一个"请求已取消"的 Toast 是纯粹的噪音。 + +## 三、错误 → UI 映射 + +### 三种展示形态,按"用户当时在干什么"选 + +| 形态 | 适用 | 例子 | +|---|---|---| +| **整页错误态** | 用户在等这个页面的主数据,没数据页面就是空的 | 订单列表加载失败 | +| **局部错误态** | 页面有多块数据,一块失败不影响其他 | 首页某个 tile 失败 | +| **Toast / SnackBar** | 用户主动触发了一个动作,失败了要立刻知道 | 提交订单失败、下拉刷新失败 | +| **表单内联** | 参数校验类错误,要指到具体字段 | `ApiCode.invalidParam` | + +**不要用 Dialog 报错**,除非错误需要用户做决定("登录已过期,是否重新登录")。Dialog 阻断操作,而大部分错误用户能做的只有"知道了"。 + +### 统一的错误文案映射 + +```dart +// packages/core_ui/lib/src/error/error_presenter.dart +({String title, String? detail, bool retryable, bool showTraceId}) present(AppException e) => + switch (e) { + NetworkException(kind: NetworkErrorKind.noConnection) => + (title: '网络未连接', detail: '请检查网络后重试', retryable: true, showTraceId: false), + NetworkException() => + (title: '网络不太稳定', detail: '请稍后重试', retryable: true, showTraceId: false), + ServerException() => + (title: '系统繁忙', detail: '请稍后重试', retryable: true, showTraceId: true), + BusinessException(:final message) => + (title: message, detail: null, retryable: false, showTraceId: false), + StorageException() => + (title: '本地数据异常', detail: '请重启 App', retryable: false, showTraceId: false), + NativeException(code: 'PERMISSION_DENIED', :final message) => + (title: message, detail: '可在系统设置中开启', retryable: false, showTraceId: false), + NativeException(:final message) => + (title: message, detail: null, retryable: false, showTraceId: false), + UnauthorizedException() || RequestCancelledException() => + (title: '', detail: null, retryable: false, showTraceId: false), // 不展示 + }; +``` + +要点: + +- **`BusinessException` 的 `retryable` 是 `false`**。业务错误(比如"库存不足""订单已支付")重试没有意义,给一个重试按钮只会让用户反复点。 +- **`NetworkException` 不展示 traceId**。请求根本没到后端,traceId 在服务端日志里查不到,展示出来只会误导。 +- `ServerException` 展示 traceId——这正是 `traceId` 存在的意义(见 backend/06 附录)。 + +### traceId 怎么展示 + +**`traceId` 保留,但它是一个低成本、低存在感的字段,不要为它做重的交互。** 后端侧它本来就有(`TraceIdFilter` 写 MDC + 落 ELK,见 [backend/08-observability.md](./backend/08-observability.md)),响应里多带一个字符串对客户端来说接近零成本;它唯一的价值是**把一次用户投诉精确定位到一条服务端日志**,省掉"大概是下午三点多,某个门店"这种模糊排查。所以: + +- **绝大多数错误不展示它**,只有 `ServerException`(5xx / 系统错误)才展示——那正是需要研发介入的场景。 +- 无条件写进本地日志和错误上报(见 [13-observability-analytics.md](./13-observability-analytics.md)),这部分不依赖 UI。 + +不要把 `traceId` 直接印在主文案里(用户看到一串乱码只会更慌)。约定: + +``` + 系统繁忙 + 请稍后重试 + + [ 重试 ] 问题反馈 › +``` + +「问题反馈」展开后显示 `traceId` 并提供**一键复制**。客服话术是"请点击问题反馈,把那串编号发给我"。 + +同时 traceId **无条件写进本地日志**(不管展不展示),见 [13-observability-analytics.md](./13-observability-analytics.md)。 + +### 通用错误 Widget + +`core_ui` 提供,所有 feature 复用,不各写一套: + +```dart +// 整页 +AsyncValueView( + value: ref.watch(orderListProvider), + onRetry: () => ref.invalidate(orderListProvider), + data: (orders) => OrderList(orders), +) + +// 局部(tile 级降级) +TileErrorView(error: e, onRetry: ...) // 尺寸自适应,不撑破布局 +``` + +`AsyncValueView` 内部统一处理:loading 骨架屏、error → `present()` → 错误态、`RequestCancelledException` 静默、空数据 → 空态图。**每个 feature 自己写 `switch (asyncValue)` 是最常见的重复劳动,也是三态处理不一致的根源。** + +## 四、降级:局部失败不能拖垮整页 + +PRD §21.1「首页支持部分失败降级」、§21.2「Mini 某一服务失败应仅影响对应模块」「F6 异常不得导致主 APP 全部不可用」。 + +### 首页的降级模型 + +首页由多块数据组成(门店信息、菜单、待办、预警、公告、促销位),它们来自**不同的后端聚合**,失败是独立的。 + +**做法:每块数据一个独立 provider,页面不做 `Future.wait`。** + +```dart +// ❌ 错的:任何一块失败,整个首页变成错误态 +@riverpod +Future homeData(Ref ref) async { + final (menus, todos, alerts) = await ( + ref.watch(menuProvider.future), + ref.watch(todoProvider.future), + ref.watch(alertProvider.future), + ).wait; + return HomeData(menus, todos, alerts); +} + +// ✅ 对的:各自独立,各自渲染,各自重试 +class HomePage extends ConsumerWidget { + Widget build(context, ref) => ListView(children: [ + const StoreHeader(), + MenuSection(), // 内部 watch(menuProvider) + TodoSection(), // 内部 watch(todoProvider) + AlertSection(), + ]); +} +``` + +`Future.wait` 看起来更"干净",但它把 N 个独立的失败面耦合成了一个——公告服务挂了,用户连待办都看不到。这直接违反 PRD §21.1。 + +**唯一的例外是"没有它整页就没意义"的数据**:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。 + +### 降级的粒度约定 + +| 数据 | 失败时 | +|---|---| +| 门店上下文、菜单 | **整页错误态 + 重试**(没有它首页无意义) | +| 待办、预警、公告、促销位 | **该区块显示局部错误态**,其余正常 | +| 首页各 tile 的数字/角标 | **降级为不显示角标**,不显示错误 UI——一个角标加载失败不值得占用用户注意力 | +| H5 页面 | 容器内错误页,不影响 App 其他部分(见 [10-webview-h5.md](./10-webview-h5.md)) | + +### 有缓存时优先展示缓存 + +网络失败但本地有缓存(见 [06-local-storage.md](./06-local-storage.md))时,**展示缓存 + 顶部提示条**,比展示一个错误页好得多——门店里网络不稳是常态。 + +```dart +// 顶部一条细提示条,不遮挡内容 +if (state.isFromCache) StaleDataBanner(updatedAt: state.cachedAt, onRefresh: ...) +``` + +前提是缓存**必须带时间戳并显示**("更新于 10 分钟前")。展示旧数据却不告诉用户是旧的,比展示错误更危险——尤其是库存和价格。 + +## 五、兜底:没被 catch 的异常 + +```dart +// main.dart +void main() { + runZonedGuarded(() { + WidgetsFlutterBinding.ensureInitialized(); + + // widget 构建/布局/绘制期的错误 + FlutterError.onError = (details) { + FlutterError.presentError(details); // 保留控制台输出 + reporter.recordFlutterError(details); + }; + + // 平台层/异步的未捕获错误(Flutter 3.3+) + PlatformDispatcher.instance.onError = (error, stack) { + reporter.recordError(error, stack, fatal: true); + return true; + }; + + runApp(ProviderScope( + retry: (_, __) => null, // 全局关掉自动重试,见 03 + observers: [ErrorObserver()], + child: const ContiApp(), + )); + }, (error, stack) => reporter.recordError(error, stack, fatal: true)); +} +``` + +另外在 Riverpod 侧加一个全局观察者,把所有 provider 抛出的错误上报(即使 UI 已经优雅处理了): + +```dart +class ErrorObserver extends ProviderObserver { + @override + void providerDidFail(context, error, stackTrace) { + if (error is RequestCancelledException) return; // 取消不是错误 + reporter.recordError(error, stackTrace, fatal: false, context: {'provider': ...}); + } +} +``` + +**"UI 优雅处理了"和"不需要上报"是两回事。** 用户看到一个漂亮的错误页,我们仍然需要知道有多少人看到了它。上报细节见 [13-observability-analytics.md](./13-observability-analytics.md)。 + +### release 模式的错误页 + +```dart +ErrorWidget.builder = (details) => const AppCrashView(); // 不显示红屏 +``` + +默认的红色错误屏在 release 下也会出现(虽然是灰色的)。换成一个统一的"页面出错了,请返回重试"视图。 + +## 六、错误处理的反模式 + +这几条在 review 时直接打回: + +```dart +// ❌ 吞掉异常 +try { await repo.submit(); } catch (_) {} + +// ❌ 用 catch-all 把所有错误变成同一句话,丢掉了 BusinessException 的 message +try { ... } catch (e) { showToast('操作失败'); } + +// ❌ 在 repository / use case 里弹 UI +class OrderRepository { + Future submit() async { + try { ... } catch (e) { showToast(...); } // data 层不能碰 UI,见 02 + } +} + +// ❌ 用 message 内容做判断 +if (e.message.contains('库存')) { ... } // 后端改一个字就失效 + +// ❌ 写裸数字错误码 +if (e.code == 10403) { ... } // 用 ApiCode.forbidden +``` + +正确做法:异常一路向上抛到 `Notifier`,由 `AsyncValue` 承载,UI 层统一映射。需要分支时用 `ApiCode` 常量,不用 `message`、不用字面量数字。 + +## 待确认项 + +- **错误码表(最高优先级)**:需要和后端一起把上面的分段方案落成完整码表,特别是 `ApiCode` 里那组需要特殊 UX 的码。这一项不定,客户端只能全部走默认文案。同时 `backend/06-api-design.md` 的「待补充」里也挂着这一条。 +- 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(`Idempotency-Key`)支持才能安全重试。当前决策是**不重试、提示用户手动确认结果**。 +- 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。 + +## 参考链接 + +- [backend/06-api-design.md:`ApiResult` 与全局异常处理](./backend/06-api-design.md) +- [backend/08-observability.md:traceId 全链路](./backend/08-observability.md) +- [Flutter: Handling errors](https://docs.flutter.dev/testing/errors) +- [Riverpod: ProviderObserver](https://pub.dev/documentation/riverpod/latest/riverpod/ProviderObserver-class.html) +- [Dart 3 patterns: switch expressions](https://dart.dev/language/patterns) diff --git a/13-observability-analytics.md b/13-observability-analytics.md new file mode 100644 index 0000000..0b6e92a --- /dev/null +++ b/13-observability-analytics.md @@ -0,0 +1,442 @@ +# 13. 可观测性与埋点 + +## 为什么单独一篇 + +PRD §21.4 和 §22.1 有明确要求(主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、9 类埋点事件),但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。 + +这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。 + +## 决策 + +| 项 | 决策 | +|---|---| +| 崩溃上报 | **[sentry_flutter](https://pub.dev/packages/sentry_flutter) `^9.26.0`**(官方 verified publisher),配套 [sentry_dart_plugin](https://pub.dev/packages/sentry_dart_plugin) `^3.4.0` 上传符号表 | +| Sentry 部署形态 | **自建优先**(`sentry.io` SaaS 是跨境上报),**待确认** | +| 本地日志 | **[logger](https://pub.dev/packages/logger) `^2.7.0`**,封装在 `core_logging` 的 `AppLogger` 后面 | +| 客户端埋点 | **[神策 `sensors_analytics_flutter_plugin`](https://pub.dev/packages/sensors_analytics_flutter_plugin) `^4.2.3`**(官方 verified publisher `sensorsdata.cn`;团队过往项目用过,本项目待正式确认) | +| 业务埋点 | **以后端为主**,客户端只补后端看不到的那部分 | +| 链路关联 | 客户端生成 `X-Trace-Id`(见 05),写入本地日志并作为崩溃上报的自定义字段 | + +## 一、崩溃上报:Sentry + +### 为什么不是 Bugly + +Bugly 是团队过往项目用过的方案,国内可达性没问题,本来是很自然的默认选项。**否掉它的理由只有一条,但这一条是决定性的:Bugly 没有上传 Dart 符号表的能力。** + +- Flutter App 的**绝大多数异常是 Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败),不是原生崩溃。Bugly 只能把它们当"自定义异常"收下,存成一段字符串堆栈。 +- [08-build-flavors.md](./08-build-flavors.md) 要求 release 必须 `--obfuscate --split-debug-info`。两者相加的结果是:**线上占比最大的那一半崩溃,在 Bugly 后台是一串读不出来的混淆符号**,只能人工把堆栈拷出来跑 `flutter symbolize` 还原。 + +Bugly 在原生侧(Java/Kotlin 异常、SIGSEGV、ANR、iOS crash)确实做得好,能自动符号化。但它强的正好是我们占比小的那一半。 + +其余差别一并记录在此,作为决策存档: + +| | 腾讯 Bugly | Sentry | +|---|---|---| +| **Dart 异常堆栈还原** | **做不到** | **做得到**(`sentry_dart_plugin` 自动上传) | +| Flutter 官方 SDK | **没有**,只有 Android/iOS 原生 SDK,pub.dev 上只有 `flutter_bugly` 1.1.1、`bugly_pro_flutter` 0.4.21 两个 unverified 社区插件 | **有**,官方维护、13 天前刚发版 | +| 原生崩溃 | 强项,自动符号化 | 支持,mapping/dSYM 由同一个插件上传 | +| 接入成本 | 要自己写 `native_crash`(Pigeon + 几十行 Kotlin/Swift) | `pubspec.yaml` 加两行 | +| 国内可达性 | 无问题 | **自建无问题;SaaS 是跨境上报**,见下 | +| 运维成本 | 无 | 自建的话有(存储、升级、告警) | +| 账号/合同 | 本项目**没有**现成的,要新申请 | 同样要新建 | + +代价是运维:Sentry 这条路把"接入成本"换成了"部署成本"。这是这次选型唯一真正付出的东西。 + +注意最后一行:**本项目在两边都没有既有账号或合同**,所以"沿用现成的"这个通常最有分量的理由,在这次选型里不成立——两条路的启动成本都要从零算。 + +**连带影响:不再需要 `native_crash` 这个包。** [07-native-integration.md](./07-native-integration.md) 里的 Pigeon 包只剩 `native_scan` / `native_media`。 + +### 唯一还没定的:自建还是 SaaS + +这一条**必须在开工前定**,它决定的不只是可达性,还有合规: + +- **自建(推荐)**:崩溃数据不出境,门店网络下上报可靠,长期成本可控。代价是要一套内网 K8s/VM 资源和运维承接方。 +- **`sentry.io` SaaS**:零运维,但崩溃报告里带着 `userId`、`storeId`、面包屑和日志片段,属于**数据出境**,要走合规评估;同时门店网络访问境外服务的丢报率无法预估。 + +需要在讨论时明确的:有没有可用的内网资源、谁运维、以及法务对崩溃数据出境的口径。**在结论出来之前,`SENTRY_DSN` 走 `--dart-define-from-file`(见 08),代码里不写死任何地址——换 DSN 不需要改一行代码。** + +### 依赖与初始化 + +```yaml +dependencies: + sentry_flutter: ^9.26.0 + +dev_dependencies: + sentry_dart_plugin: ^3.4.0 +``` + +```dart +// main.dart —— 崩溃上报必须在最早期初始化,晚一步就漏掉启动期崩溃 +await SentryFlutter.init( + (options) { + options.dsn = env.sentryDsn; // 来自 --dart-define-from-file,见 08 + options.environment = env.flavorName; // dev / uat / prod 分开看,否则测试数据污染线上崩溃率 + options.release = '${env.appVersion}+${env.buildNumber}'; // 必须和符号表归档对得上 + options.tracesSampleRate = 0.0; // 首版不开性能追踪,见下 + options.sendDefaultPii = false; // 关键:默认不采集 IP / 请求头 / 用户信息 + options.beforeBreadcrumb = scrubBreadcrumb; // 见「脱敏」 + options.beforeSend = scrubEvent; + }, + appRunner: () => runApp(ProviderScope( + retry: (_, __) => null, + observers: [ErrorObserver()], // 见 12 + child: const ContiApp(), + )), +); +``` + +**`appRunner` 不是可选写法。** 传了它,Sentry 会自己接管 `FlutterError.onError` 和 `PlatformDispatcher.instance.onError` 并把 `runApp` 放进受保护的 error zone;**这时候再手写一遍这两个回调,结果是同一个异常上报两次**,线上崩溃数直接翻倍,是这个 SDK 最常见的接入错误。 + +业务代码仍然只依赖 `core_logging` 暴露的 `CrashReporter` 接口,不直接 import `sentry_flutter`: + +```dart +// packages/core_logging/lib/src/crash_reporter.dart +abstract interface class CrashReporter { + void setUser(String userId); + void setTag(String key, String value); + void leaveBreadcrumb(String message); + void report(Object error, StackTrace? stack, {Map extra = const {}}); +} +``` + +这一层不是为了"将来可能换 Sentry"——**是为了测试里能直接 mock 掉,不必真的初始化 SDK**,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则)。 + +### 符号表:唯一必须打通的一步 + +`sentry_dart_plugin` 包装 `sentry-cli`,构建后一条命令把 Dart 符号表、Android mapping、iOS dSYM 一起传上去: + +```yaml +# pubspec.yaml +sentry: + upload_debug_symbols: true + upload_source_maps: false # 不做 Web + project: conti-retail-app + org: continental + # auth_token 走 CI 环境变量 SENTRY_AUTH_TOKEN,不写进仓库 +``` + +```bash +# CI:build 之后立刻跑,见 08 +fvm flutter build appbundle --flavor prod --target lib/main_prod.dart \ + --dart-define-from-file=env/prod.json \ + --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG +fvm dart run sentry_dart_plugin +``` + +三条硬约束: + +- **`options.release` 必须和上传符号表时的 release 严格一致**,对不上的表现是"符号表传上去了,堆栈还是混淆的"——这是接入 Sentry 最常见的坑,且后台不会报错。统一由 `versionName+versionCode` 生成(见 08 的版本号规则)。 +- **上传步骤必须在 CI 里、紧跟 build**,不能靠人工。漏传一次,那个版本的崩溃就永久读不出来。 +- **本地符号表归档照旧保留**(08 要求 ≥1 年)。Sentry 能自动还原之后它不再是唯一手段,但仍是 Sentry 服务出问题/数据过期时的兜底。 + +### 必须关掉的默认行为 + +Sentry 的默认配置面向公网 C 端产品,有几项在门店场景下不能开: + +| 项 | 结论 | +|---|---| +| `sendDefaultPii` | **false**。开了会自动带上 IP、请求头(含 `Authorization`)、用户信息 | +| Session Replay / 截图(`attachScreenshot`) | **关闭**。收银、经营分析页面上有金额和客户信息 | +| `attachViewHierarchy` | 关闭。控件树里会出现输入框内容 | +| `tracesSampleRate` | **0.0**,首版不开性能追踪。接口耗时后端已有(见 backend/08),开了只是多一份跨境流量 | +| HTTP 面包屑里的 URL | **必须脱敏**:H5 URL 的 query 里带着 `ticket`,原样进面包屑等于把 token 发出去,见「脱敏」一节 | + +### 用户与门店上下文 + +会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)): + +```dart +CrashReporter.instance + ..setUser(session.user.userId) // 只传 ID,不传手机号/姓名 + ..setTag('storeId', '${session.store.storeId}') + ..setTag('roleCode', session.user.roleCode) + ..setTag('flavor', env.flavorName); +``` + +**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。 + +`traceId` 在网络相关的错误上报时作为自定义字段带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](./backend/08-observability.md))。 + +### 崩溃前的页面路径 + +崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报: + +```dart +class NavigationObserver extends NavigatorObserver { + NavigationObserver(this._reporter); + final CrashReporter _reporter; + + @override + void didPush(Route route, Route? previous) => + _reporter.leaveBreadcrumb('nav: ${route.settings.name}'); +} +``` + +注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。 + +### 上报什么、不上报什么 + +- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。 +- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。 + +### 验证接入真的成功了 + +崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。所以: + +- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS 都能在 Sentry 里看到。 +- **同时验证堆栈是不是可读的**——这一步比"能收到"更容易漏。用 `--obfuscate` 打一个 release 包、跑一遍 `sentry_dart_plugin`、再触发一次异常,确认后台显示的是 Dart 文件名行号而不是 `_x12`。`release` 对不上的话就是这个表现,见上文。 +- uat 环境跑一个迭代后,对一下"Sentry 上的错误数"和"埋点里的 `api_failed` 数量级",如果差得离谱说明有一侧漏了。 + +## 二、日志规范 + +### `AppLogger` + +```dart +// packages/core_logging/lib/src/app_logger.dart +abstract interface class AppLogger { + void d(String message, {Map? data}); + void i(String message, {Map? data}); + void w(String message, {Object? error, StackTrace? stackTrace}); + void e(String message, {Object? error, StackTrace? stackTrace}); +} +``` + +各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。 + +### 级别与环境 + +| 环境 | 级别 | 输出 | +|---|---|---| +| dev | `debug` | 控制台,带颜色和调用栈 | +| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) | +| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 | + +**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。 + +**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上挂在 `beforeSend` 里作为 `contexts` 附加,单个事件体积有上限,所以实际带的是**最近 30 条**,不是全部 500 条。 + +### 脱敏(PRD §21.3「敏感字段脱敏」) + +```dart +// packages/core_logging/lib/src/scrubber.dart +const _sensitiveKeys = { + 'token', 'accessToken', 'refreshToken', 'ticket', 'password', + 'code', // 短信验证码 + 'phone', 'mobile', 'idCard', 'bankCard', +}; + +String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***'; +``` + +规则: + +- **请求/响应体不整体打日志**。只打 method、path、状态码、耗时、`code`、`traceId`。真要看 body 只在 dev 下开,且过一遍脱敏器。 +- **`Authorization` 头永远不打**,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。 +- **H5 URL 打日志前必须去掉 query**:URL 里带着 `ticket`,整条打出去等于打 token。 +- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志会随崩溃一起传上去。 + +**同一个脱敏器要挂到 Sentry 的两个钩子上**,这是上文 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend` 的实现: + +```dart +// packages/core_logging/lib/src/sentry_scrubber.dart +Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) { + if (crumb == null) return null; + // SDK 自动记录的 HTTP 面包屑里 url 是完整的,query 里可能带 ticket / token + final url = crumb.data?['url']; + if (url is String) { + final u = Uri.tryParse(url); + crumb.data?['url'] = u == null ? '' : u.replace(query: '').toString(); + } + return crumb; +} +``` + +**`beforeBreadcrumb` 不能漏。** Sentry 默认会自动记录所有 HTTP 请求作为面包屑,我们在 05/10 里辛苦保证的"URL 不落日志",会被这条默认行为绕过去——它不走我们的 `AppLogger`。 + +### 不采集什么 + +出于合规(个人信息保护法「最小必要」原则)和 PRD §21.3: + +| 项 | 结论 | +|---|---| +| 崩溃截图 / Session Replay / View Hierarchy | **关闭**。收银、经营分析页面上有金额和客户信息 | +| 精确位置 | 不采集。App 没有需要精确位置的功能 | +| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;Sentry 侧靠 `sendDefaultPii = false` | +| 通讯录、短信 | 不申请权限 | +| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) | + +这份清单要和 App 的隐私政策(PRD §10.3,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Sentry 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**(两个 SDK 都支持延迟初始化),否则「同意前不采集」这条硬要求就破了。 + +## 三、埋点:以后端为主 + +**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。** + +理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](./backend/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。 + +**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。 + +### Metabase 不是神策的替代品 + +后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层: + +| | 神策 | Metabase | +|---|---|---| +| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 | +| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 | +| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 | + +Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。 + +所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。 + +### 分工 + +| PRD §22.1 事件 | 谁来出 | 说明 | +|---|---|---| +| 登录成功/失败 | **后端** | 登录本身就是接口调用 | +| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 | +| 门店切换 | **后端** | 切换接口 | +| 采购下单 | **后端** | | +| 入库成功 | **后端** | | +| 待办点击 | **后端** | 点击后会请求详情接口 | +| **扫码成功/失败** | **客户端** | 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到 | +| **H5 关闭 / 异常** | **客户端** | 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败后端看不到** | +| 客服点击 | **客户端** | 拨号、企微二维码是纯客户端行为 | + +**客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由客户端上报。** + +### 客户端事件表 + +按上面的判据筛下来,客户端只需要这几个: + +| 事件 | 触发 | 关键参数 | +|---|---|---| +| `scan_succeeded` / `scan_failed` | 扫码结果 | `mode`(barcode/vin/plate)、`durationMs`、`failReason` | +| `h5_closed` | H5 页关闭 | `target`、`stayDurationMs` | +| `h5_failed` | 白屏 / 超时 / 加载失败 | `target`、`errorCode`、`elapsedMs`、`traceId` | +| `h5_first_paint` | H5 首屏完成 | `target`、`ticketMs`(换票耗时)、`loadMs`(页面加载耗时) | +| `support_clicked` | 客服入口点击 | `channel`(hotline/dealer/o2o) | +| `api_failed` | 请求失败 | `path`、`code`、`httpStatus`、`traceId` | +| `app_cold_start` | 冷启动完成 | `durationMs` | +| `logout` | 登出 | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出没有对应的接口调用**,见 [11](./11-store-context-and-session.md) | +| `session_restore_failed` | 冷启动恢复会话失败 | 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求 | + +两条说明: + +- **`h5_first_paint` 必须把耗时拆成 `ticketMs` 和 `loadMs` 两段**。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。 +- **`api_failed` 客户端也要报**,虽然后端也能看到失败。因为**后端看不到"请求根本没发出去"和"响应没收到"**:超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。 + +### 实现约定:神策 SDK,外面包一层 + +客户端埋点走**神策 `sensors_analytics_flutter_plugin`**:官方 verified publisher `sensorsdata.cn`,`4.2.3` 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见(对比 Bugly 那两个 unverified 社区插件)。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。 + +#### 神策不是"开箱即用",这些活一样要干 + +先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**: + +- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 SDK 无关。 +- **`core_analytics` 的接口封装**(见下)。 +- **接入点的编排**——超级属性什么时候注册、切店后重注册、登录/登出的 ID 关联。神策给了 API,但在哪调是我们的事(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 +- **私有化部署的运维**(如果走私有化)。 + +**神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。** 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是**容易写得看起来对、实际在丢数据**的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。 + +#### 依赖与封装 + +```yaml +dependencies: + sensors_analytics_flutter_plugin: ^4.2.3 +``` + +业务代码仍然只见 `core_analytics` 的接口,不直接 import 神策: + +```dart +// packages/core_analytics/lib/src/analytics.dart +abstract interface class Analytics { + void track(String event, [Map params = const {}]); + void registerSuperProperties(Map props); // 公共属性,注册一次全局附加 + void identify(String userId); // 登录成功后调 + void reset(); // 登出时调 +} +``` + +理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件(代价就是上面那条可靠投递要自己补)。 + +接入约定: + +- **公共属性用「超级属性」注册一次,不在每个调用点手写**:`storeId`、`roleCode`、`flavor`、`appVersion`、`buildNumber`。`storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。**门店切换后必须重新注册**(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 +- **登录/登出走 `login()` / `logout()`**:登录成功后用后端的 `userId` 关联匿名 ID,登出时断开,否则同一台设备上换人登录的数据会串到一起(门店设备是共用的,这个场景一定会发生)。 +- **埋点失败绝不能影响业务**:`track()` 内部 try-catch 兜住,任何异常只记日志不外抛。 +- **dev/uat 与 prod 必须分开**——独立项目,或至少用不同的数据接收地址。共用一个项目的话,测试数据会直接污染运营报表,且事后无法剔除。 + +#### 全埋点(AutoTrack):只开启动/退出,其余关掉 + +神策的全埋点支持 `APP_START` / `APP_END` / `APP_CLICK` / `APP_VIEW_SCREEN` 四类。我们的结论: + +| 类型 | 结论 | +|---|---| +| `APP_START` / `APP_END` | **开**。启动次数、使用时长是零成本拿到的基础指标 | +| `APP_CLICK` | **关**。Flutter 的控件树没有原生 `id`/`resource-name`,采上来的元素标识基本不可读,是纯噪音 | +| `APP_VIEW_SCREEN` | **关**。改用我们自己的 `NavigationObserver` 上报路由名——既更准,也**避免把 `/webview?target=X&ticket=...` 整条 URL 采上去**(见脱敏一节) | + +“少采一点”在这里不是保守,是因为**采上来读不懂的数据比没有更糟**:它会让报表看起来有数据,实际没法用。 + +#### H5 内部的埋点不归我们 + +F6 的 H5 页面是外部系统,页面内部的行为埋点由 F6 自己负责。**客户端只报容器级事件**(打开/关闭/失败/首屏耗时),不往 WebView 里注入神策的 JS SDK——注进去就等于我们要为别人页面里的数据质量负责,而且 JSBridge 的能力清单([10-webview-h5.md](./10-webview-h5.md) 的 12 项)里也没有埋点这一项。 + +### 命名约定 + +`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。 + +事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。 + +## 四、性能指标 + +| 指标 | 怎么测 | 目标 | +|---|---|---| +| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s | +| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s | +| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD §21.1 要求有超时策略) | +| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s | +| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — | + +**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。 + +## 五、和后端审计日志的分工 + +`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。 + +| | 客户端埋点 | 服务端日志/审计 | +|---|---|---| +| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 | +| 可信度 | 参考 | 权威 | +| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 | + +## 待确认项 + +- **Sentry 是自建还是用 SaaS**——这是本篇最硬的阻塞项,决定可达性和数据出境合规口径。需要明确内网资源、运维承接方、法务意见。见上文「唯一还没定的」。 +- Sentry 的 org/project 划分:dev/uat/prod 是三个 project 还是靠 `environment` 区分(建议 prod 单独一个 project,避免测试数据污染线上崩溃率告警)。 +- **公司有没有在用的神策服务?** 本项目没有现成账号。有的话拿数据接收地址即可;**没有的话开通神策是采购流程,不是配置项**,周期可能比开发长。这一项是埋点唯一的外部依赖——**但它不阻塞开工**:事件方案设计和 `core_analytics` 接口先做,这两块工作量与最终用什么 SDK 无关。真的走不通,实现类换成最小的 `POST /api/v1/events/batch`,代价是可靠投递要自己补。 +- 若确认用神策:数据接收地址是私有化部署还是神策云,以及 dev/uat/prod 的项目划分。地址走 `--dart-define-from-file`,代码里不写死。 +- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。 +- 神策原生 SDK 的默认设备信息采集项(`distinct_id` 的生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。 +- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。 +- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把 PRD §22.1 的 9 类事件逐条落到接口上。 +- 神策和 Sentry 都要在**用户同意隐私政策之后**才初始化,具体的延迟初始化时机要和 `feature_auth` 的协议弹窗流程对齐。 +- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。 + +## 参考链接 + +- [sentry_flutter | Dart package](https://pub.dev/packages/sentry_flutter) +- [sentry_dart_plugin | Dart package](https://pub.dev/packages/sentry_dart_plugin)(上传 Dart 符号表 / mapping / dSYM) +- [Sentry: Flutter Debug Symbols](https://docs.sentry.io/platforms/dart/guides/flutter/debug-symbols/) +- [Sentry: 自建(self-hosted)](https://develop.sentry.dev/self-hosted/) +- [sensors_analytics_flutter_plugin | Dart package](https://pub.dev/packages/sensors_analytics_flutter_plugin) +- [神策:Flutter 插件集成文档](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_plugin/v0300) +- [神策:Flutter 全埋点](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_auto_track/v0205) +- [Metabase](https://www.metabase.com/)(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成) +- [Flutter: 混淆与 `flutter symbolize`](https://docs.flutter.dev/deployment/obfuscate) +- [logger | Dart package](https://pub.dev/packages/logger) +- [backend/08-observability.md:traceId 与审计日志](./backend/08-observability.md) +- [PRD §21.4 可观测性 / §22.1 埋点](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) diff --git a/14-conventions-and-ci-gates.md b/14-conventions-and-ci-gates.md new file mode 100644 index 0000000..c1fe715 --- /dev/null +++ b/14-conventions-and-ci-gates.md @@ -0,0 +1,289 @@ +# 14. 工程规范与 CI 门禁 + +## 为什么单独一篇 + +这一篇是**建项目当天就要用上**的东西:lint 配置、格式化、生成产物是否入库、分支和提交规范、CI 卡什么。这些规则本身不难,难的是"没有在第一天定下来"——等到有 10 个人各写各的风格再统一,成本是第一天的几十倍。 + +## 一、SDK 版本锁定 + +``` +# .fvmrc(仓库根目录,入库) +{ "flutter": "3.44.9" } +``` + +所有人用 [FVM](https://fvm.app/) 装同一个版本,命令统一走 `fvm flutter ...`。理由见 [01-project-structure.md](./01-project-structure.md):monorepo 里 SDK 版本不一致会导致 `.dart_tool` 反复重建、生成代码差异、以及"我这跑得好好的"这类无法复现的问题。CI 也用 FVM 装同一版本,保证本地和 CI 完全一致。 + +`flutter --version` 的实测 Dart 版本要写进 README,因为 `environment.sdk` 的约束以它为准。 + +## 二、静态分析 + +### 选 `flutter_lints`,不选 `very_good_analysis` + +| | `flutter_lints` 6.0.0 | `very_good_analysis` 10.3.0 | +|---|---|---| +| 维护方 | **Flutter 官方** | Very Good Ventures | +| 规则数量 | 适中,只收官方认为普遍适用的 | 非常多,包含大量风格约束 | +| 跟随 SDK | 随 Flutter 版本同步更新 | 独立节奏 | + +`very_good_analysis` 更严格,但它在一个新项目上开箱会产生**成百上千条 warning**,其中很大一部分是纯风格问题(比如强制所有 public API 写文档注释、强制 `final` 局部变量)。团队的第一反应必然是批量 `// ignore:` 或者在 `analysis_options.yaml` 里关掉一半规则——最后既没享受到严格的好处,还多了一层配置负担。 + +**结论:以 `flutter_lints` 为底,手动加一小组"能抓真 bug"的规则,而不是"管风格"的规则。** + +### 根级共享配置 + +```yaml +# analysis_options.yaml(仓库根目录) +include: package:flutter_lints/flutter.yaml + +analyzer: + language: + strict-casts: true # 禁止 dynamic 隐式转型——最容易藏 bug 的一条 + strict-raw-types: true # 禁止裸 List/Map,逼着写类型参数 + strict-inference: true + errors: + invalid_annotation_target: ignore # json_serializable + 注解组合会误报 + # 下面几条从 warning 提到 error,即 CI 直接失败 + unused_import: error + dead_code: error + unawaited_futures: error + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "**/generated/**" # pigeon 生成产物,见 07 + plugins: + - custom_lint # riverpod_lint,见 03 + +formatter: + page_width: 100 + +linter: + rules: + # —— 能抓真 bug 的 —— + - always_declare_return_types + - avoid_dynamic_calls + - avoid_slow_async_io + - cancel_subscriptions # StreamSubscription 忘了 cancel 是常见内存泄漏 + - close_sinks + - discarded_futures # 忘了 await 的异步调用 + - unawaited_futures + - no_adjacent_strings_in_list # 少写一个逗号导致字符串被拼接 + - test_types_in_equals + - throw_in_finally + - unnecessary_statements + # —— 团队约定 —— + - prefer_single_quotes + - require_trailing_commas # 配合 formatter,diff 更干净 + - directives_ordering + - sort_pub_dependencies +``` + +各包的 `analysis_options.yaml` 只写一行继承,不允许在包级关规则(要关就在根上关,让所有人都看得见): + +```yaml +# packages/feature_xxx/analysis_options.yaml +include: ../../analysis_options.yaml +``` + +### `strict-casts` 值得单独说 + +它是这份配置里**唯一一条会真的挡住线上 bug** 的开关。没有它,`jsonDecode(...)` 返回的 `dynamic` 可以隐式赋给任何类型,类型错误要到运行时才炸;开了之后必须显式 `as Map`,写的人会被迫想一下"这里到底是什么类型"。 + +代价是接手 JSON 解析时要多写一些 `as`。这个代价值得付。 + +### `custom_lint` 在 workspace 下的接法 + +`riverpod_lint`(见 [03-state-management.md](./03-state-management.md))通过 `custom_lint` 插件运行。在 pub workspace 下: + +- `custom_lint` 和 `riverpod_lint` 加在**根 `pubspec.yaml` 的 `dev_dependencies`**(workspace 共享)。 +- 检查命令是 `dart run custom_lint`,**它不包含在 `flutter analyze` 里**——两条命令都要跑,CI 里是两个独立步骤。这一点很多人不知道,结果 riverpod_lint 装了但从来没生效过。 + +## 三、格式化 + +```bash +dart format --set-exit-if-changed --line-length 100 . +``` + +- **行宽 100,不是默认的 80。** Dart 3.9 起可以写在 `analysis_options.yaml` 的 `formatter: page_width:` 里(上面已配),命令行参数是给 CI 用的双保险。80 在 Flutter 的 widget 嵌套下换行过于频繁,一个三层嵌套的 `Column` 就能占满整屏。100 是一个在宽屏和可读性之间比较平衡的值。 +- **不允许手动排版**。`dart format` 的结果就是唯一正确的结果,不接受"我觉得这样更好看"。省下的是每次 review 里关于换行的争论。 +- CI 用 `--set-exit-if-changed` 卡死。 + +## 四、生成产物是否入库 + +**这是一个必须明确的二选一,模糊处理会导致仓库里一半入库一半不入库。** + +| 类型 | 结论 | 理由 | +|---|---|---| +| `*.g.dart`(riverpod / json_serializable / drift) | **不入库** | 这类文件改动频繁且巨大,几乎每个 PR 都会产生冲突,而冲突的正确解法永远是"重新生成"——那入库就没有意义。加进 `.gitignore` | +| pigeon 生成产物(Dart + Kotlin + Swift) | **入库** | 见 [07-native-integration.md](./07-native-integration.md)。原生侧的 Kotlin/Swift 文件要被 Gradle/Xcode 编译,而**这两条工具链不会跑 `build_runner`**。不入库的话原生构建直接失败 | +| `pubspec.lock` | 根目录**入库**,各 package 的**不入库** | workspace 模式下只有根 lock 生效 | + +不入库 `.g.dart` 的代价是:**新克隆仓库后必须先跑一次生成,否则 IDE 满屏报错**。所以: + +```yaml +# 根 pubspec.yaml 的 melos scripts +gen: + run: melos exec --depends-on=build_runner -- dart run build_runner build --delete-conflicting-outputs +gen:watch: + run: melos exec --depends-on=build_runner -- dart run build_runner watch --delete-conflicting-outputs +``` + +README 的"第一次跑起来"步骤必须是:`fvm flutter pub get` → `melos run gen` → `fvm flutter run`。**少写这一步,每个新人入职第一天都会卡住。** + +CI 在 analyze 之前必须先 `melos run gen`。 + +**pigeon 产物入库需要一道防腐**:CI 里重新生成后 `git diff --exit-code`,确保有人改了 schema 但忘了提交生成结果时流水线会红(见 07)。 + +## 五、分支与提交 + +### 分支 + +``` +main ← 生产,只接受来自 release/* 和 hotfix/* 的合并,打 tag 出包 +develop ← 集成,日常合并目标 +feature/-<短描述> +fix/-<短描述> +release/ +hotfix/ +``` + +`main`/`develop` **保护分支,禁止直接 push**,只能通过 MR 合入。 + +### 提交信息 + +用 [Conventional Commits](https://www.conventionalcommits.org/): + +``` +feat(feature_purchase): 支持采购单批量提交 +fix(core_network): 修复 401 并发刷新导致全端登出 +docs(05): 补充上传失败重传约定 +chore(deps): 升级 drift 到 2.34.5 +``` + +`scope` 用**包名**(`feature_purchase`、`core_network`)或文档编号。monorepo 里没有 scope 的提交信息基本等于没有信息——`fix: 修复崩溃` 在半年后完全无法定位。 + +不引入自动化的 changelog 生成(首版没这个需求),但格式先立住,将来要加成本为零。 + +### MR 规范 + +- MR 标题同 commit 规范。 +- 描述里必须有:**改了什么**、**为什么**、**怎么验证的**。 +- **一个 MR 只做一件事。** 顺手格式化半个仓库的 MR 直接打回——它会让 review 变成不可能。 +- 至少 1 人 approve。涉及 `core_*` 的改动需要 2 人(这些包被所有 feature 依赖,改错影响面最大)。 + +## 六、CI 门禁 + +```yaml +# .gitlab-ci.yml(App 部分,与 08-build-flavors.md 的构建 job 拼在一起) +stages: [setup, verify, test, build] + +.flutter_base: &flutter_base + image: <内部 flutter 镜像,预装 FVM 3.44.9> + before_script: + - fvm flutter --version + - dart pub global activate melos 8.2.2 + - melos bootstrap + - melos run gen # 生成产物不入库,必须先生成 + cache: + key: "$CI_COMMIT_REF_SLUG" + paths: [.dart_tool/, .pub-cache/] + +format: + <<: *flutter_base + stage: verify + script: dart format --set-exit-if-changed --line-length 100 . + +analyze: + <<: *flutter_base + stage: verify + script: + - melos exec -- fvm flutter analyze --fatal-infos + - dart run custom_lint # riverpod_lint,analyze 不含它 + +pigeon_check: + <<: *flutter_base + stage: verify + script: + - melos run gen:pigeon + - git diff --exit-code || (echo "pigeon 生成产物未提交" && exit 1) + +test: + <<: *flutter_base + stage: test + script: + - melos run test + - melos run coverage # 阈值 60%,见 09 + coverage: '/lines\.*: \d+\.\d+\%/' + artifacts: + paths: [coverage/] + reports: { coverage_report: { coverage_format: cobertura, path: coverage/cobertura.xml } } +``` + +### 门禁清单 + +| 检查 | 卡点 | 说明 | +|---|---|---| +| `dart format` | **阻断** | | +| `flutter analyze --fatal-infos` | **阻断** | `--fatal-infos` 让 info 级别也算失败,否则 lint 规则形同虚设 | +| `dart run custom_lint` | **阻断** | | +| pigeon 产物一致性 | **阻断** | | +| 单元测试 + Widget 测试 | **阻断** | | +| 覆盖率 ≥ 60% | **阻断** | 见 [09-testing.md](./09-testing.md) | +| 集成测试 | **不卡 MR**,只在合入 develop/main 时跑 | 慢,见 09 | +| Android release 构建 | 只在 tag 上跑 | 见 [08-build-flavors.md](./08-build-flavors.md) | + +`--fatal-infos` 值得强调:不加这个参数,`flutter analyze` 对 info 级别的问题只是打印一下就返回 0,CI 永远绿。半年后仓库里会积累几百条 info,然后没人再看 analyze 的输出。 + +### 关于 `melos bootstrap` 的缓存 + +`.pub-cache` 必须缓存,否则每次 CI 都要重新下载所有依赖,一个 monorepo 下来是几分钟。缓存 key 用 `$CI_COMMIT_REF_SLUG`(按分支),并配一个按 `pubspec.yaml` 哈希的 fallback key。 + +## 七、本地钩子(可选但推荐) + +```yaml +# lefthook.yml +pre-commit: + parallel: true + commands: + format: + glob: "*.dart" + run: dart format --line-length 100 {staged_files} && git add {staged_files} + analyze: + glob: "*.dart" + run: fvm flutter analyze --fatal-infos {staged_files} +``` + +**只跑 format 和 analyze,不跑测试。** pre-commit 跑测试会让每次提交等几十秒,人的第一反应是 `--no-verify`,钩子就废了。测试留给 CI。 + +钩子是**建议不是强制**——CI 才是真正的门禁。钩子的价值只是让人少推一次红色流水线。 + +## 八、目录与命名速查 + +| 项 | 约定 | +|---|---| +| 包名 / 目录 / 文件 | `snake_case` | +| 类 / enum | `UpperCamelCase` | +| 变量 / 方法 | `lowerCamelCase`,私有加 `_` | +| 常量 | `lowerCamelCase`(Dart 惯例,不是 `SCREAMING_CASE`) | +| 文件名 | 与主类名对应:`OrderListPage` → `order_list_page.dart` | +| provider | `xxxProvider`,由 `@riverpod` 生成,不手写 | +| 测试文件 | `<被测文件>_test.dart`,目录镜像 `lib/src/` | +| 包的公共 API | 只从 `lib/.dart` 导出,`lib/src/` 下的一律视为私有(见 01) | + +import 顺序由 `directives_ordering` 强制:`dart:` → `package:`(外部)→ `package:`(本仓库)→ 相对路径。 + +**包内用相对路径 import,跨包用 `package:`。** 混用会导致同一个类被 Dart 认为是两个不同的类型(典型症状:`type 'X' is not a subtype of type 'X'`),这个错误看起来完全不可理喻,实际就是 import 路径不一致。 + +## 待确认项 + +- GitLab Runner 上是否已有可用的 Flutter 镜像,还是需要自建(与 [08-build-flavors.md](./08-build-flavors.md) 的 runner 问题一起解决)。 +- JIRA(或其他)issue key 的格式,用于分支和提交信息里的 ``。 +- 是否引入 lefthook(需要每个人本地 `lefthook install` 一次)。 + +## 参考链接 + +- [flutter_lints | Dart package](https://pub.dev/packages/flutter_lints) +- [Dart: Customizing static analysis](https://dart.dev/tools/analysis) +- [Dart linter rules 全量列表](https://dart.dev/tools/linter-rules) +- [FVM 官方文档](https://fvm.app/) +- [Conventional Commits](https://www.conventionalcommits.org/) +- [Melos 官方文档](https://melos.invertase.dev/) diff --git a/README.md b/README.md index 7df217d..cd905e4 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,13 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读 | [07-native-integration.md](./07-native-integration.md) | 原生能力集成方式(Pigeon) | | [08-build-flavors.md](./08-build-flavors.md) | 多环境构建(dev/uat/prod flavor) | | [09-testing.md](./09-testing.md) | 测试策略(单元/Widget/集成测试) | +| [10-webview-h5.md](./10-webview-h5.md) | Embedded H5 容器与 JSBridge(PRD §7 核心链路) | +| [11-store-context-and-session.md](./11-store-context-and-session.md) | 门店上下文与会话管理(切店级联失效、登出清理) | +| [12-error-and-api-contract.md](./12-error-and-api-contract.md) | 错误处理与 API 契约(`ApiResult`、异常体系、降级) | +| [13-observability-analytics.md](./13-observability-analytics.md) | 可观测性与埋点(Sentry 崩溃上报、神策客户端埋点、日志脱敏) | +| [14-conventions-and-ci-gates.md](./14-conventions-and-ci-gates.md) | 工程规范与 CI 门禁(lint、格式化、分支、流水线卡点) | + +首版范围为 **Android / iOS**,鸿蒙 OHOS 不在首版内(但 SDK 基线锁 3.44.9 是为后续 OHOS 适配留窗口,见 01 和 07)。 ### Architecture-Diagram/ @@ -53,9 +60,34 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读 | [backend/09-build-deploy.md](./backend/09-build-deploy.md) | 构建与多环境部署(Gradle、Docker、GitLab CI/CD) | | [backend/10-testing.md](./backend/10-testing.md) | 测试策略 | +## 已知文档间差异(PRD 待修订) + +以下几处 PRD / 前期材料与 App 架构文档的结论不一致。**以架构文档为准**,PRD 侧需要回头修订: + +| # | 差异 | 现状 | 实际结论 | +| --- | --- | --- | --- | +| 1 | 技术路线 | PRD 表头写「主技术路线 React Native / 备选 Flutter」 | 实际选型是 **Flutter**,01-14 全部基于 Flutter | +| 2 | 扫码归属 | PRD §11.5 与 `Architecture-Diagram/202606-Conti-Retail-APP-Component-data-source.md` 写成「嵌入 F6 扫码页」 | 扫码是 **App 原生实现**(`native_scan`),同时服务 `feature_scan` 和 H5 的 JSBridge,见 [07](./07-native-integration.md) 和 [10](./10-webview-h5.md) | +| 3 | JSBridge 能力数 | 计划稿一度记为 13 项 | PRD §7.4 实际列出 **12 项**,见 [10](./10-webview-h5.md) | + ## 待补充 - API 文档 +- `15-ui-design-system.md` — `core_ui` 的 Material 3 主题、设计 token、暗色模式(对应 PRD §8.2 统一交互规则) +- `16-i18n.md` — 首版单语言,但需预留 `flutter_localizations` + `intl` 结构(后补代价高) + +## 跨文档的阻塞项 + +这几条不解决会直接卡住工程落地,集中列在这里: + +| 阻塞项 | 出处 | 影响 | +| --- | --- | --- | +| **iOS 构建链路不成立** | [08](./08-build-flavors.md) | 现有 GitLab Runner 是与后端共用的 Linux runner,`flutter build ipa` 需要 macOS。需决策自建 mac runner / 云端 mac runner / iOS 手工出包 | +| **后端错误码表未定** | [12](./12-error-and-api-contract.md)、[backend/06](./backend/06-api-design.md) | 客户端无法对错误码做分支处理,只能全部走默认文案 | +| **Sentry 自建还是 SaaS 未定** | [13](./13-observability-analytics.md) | 崩溃平台已定为 Sentry(Bugly 无法还原 Dart 混淆堆栈,而我们的异常绝大多数是 Dart 异常)。但 `sentry.io` SaaS 属于数据出境且门店网络可达性存疑,自建则需要内网资源和运维承接方——需明确 | +| **神策服务是否可用未确认** | [13](./13-observability-analytics.md) | 客户端埋点定为神策(团队有经验、官方插件在维护),但本项目**没有现成账号**。公司若未在用,开通是采购流程而非配置项。**不阻塞开工**——事件方案和 `core_analytics` 接口先做,两者与 SDK 无关;实在走不通再换自建 endpoint | +| **内测分发渠道未定** | [08](./08-build-flavors.md) | Firebase App Distribution 国内可达性存疑,需选替代方案 | +| **车牌识别技术路径未验证** | [07](./07-native-integration.md) | 通用扫码库只能解条码/二维码,VIN 印刷字符和车牌需要 OCR,车牌可能需要商用 SDK | ## 语言约定 diff --git a/backend/06-api-design.md b/backend/06-api-design.md index 849aaca..8f3ef1c 100644 --- a/backend/06-api-design.md +++ b/backend/06-api-design.md @@ -23,20 +23,36 @@ domains/xxx/api/ ```kotlin // platform-web/.../ApiResult.kt data class ApiResult( - val code: String, + val code: Int, // 0 = 成功;非 0 见下面的错误码分段 val message: String, val data: T?, val traceId: String, ) { companion object { fun ok(data: T): ApiResult = - ApiResult("OK", "success", data, TraceIdHolder.current()) + ApiResult(ErrorCode.OK, "success", data, TraceIdHolder.current()) - fun error(code: String, message: String): ApiResult = + fun error(code: Int, message: String): ApiResult = ApiResult(code, message, null, TraceIdHolder.current()) } } +// platform-web/.../ErrorCode.kt +object ErrorCode { + const val OK = 0 + + // 10xxx 平台通用 + const val INVALID_PARAM = 10001 + const val UNAUTHORIZED = 10401 + const val FORBIDDEN = 10403 + const val INTERNAL_ERROR = 10500 + + // 11xxx 认证与门店 + const val STORE_NOT_ACCESSIBLE = 11001 + const val NO_STORE_PERMISSION = 11002 + // 20xxx 采购 / 21xxx 库存 / 3xxxx F6·Mini 透传类,各 domain 在自己的段内分配 +} + // platform-web/.../GlobalExceptionHandler.kt @RestControllerAdvice class GlobalExceptionHandler { @@ -44,7 +60,7 @@ class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException::class) fun handleValidation(ex: MethodArgumentNotValidException): ResponseEntity> { val message = ex.bindingResult.fieldErrors.joinToString("; ") { "${it.field}: ${it.defaultMessage}" } - return ResponseEntity.badRequest().body(ApiResult.error("INVALID_PARAM", message)) + return ResponseEntity.badRequest().body(ApiResult.error(ErrorCode.INVALID_PARAM, message)) } @ExceptionHandler(BusinessException::class) @@ -54,7 +70,7 @@ class GlobalExceptionHandler { @ExceptionHandler(Exception::class) fun handleUnexpected(ex: Exception): ResponseEntity> { // 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md) - return ResponseEntity.internalServerError().body(ApiResult.error("INTERNAL_ERROR", "系统繁忙,请稍后重试")) + return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试")) } } ``` @@ -133,12 +149,15 @@ interface StoreMapper { - 路径版本化:`/api/v1/...`,未来 breaking change 走 `/api/v2/...`,不在原路径上做不兼容修改。 - 用 [springdoc-openapi](https://springdoc.org/) 自动生成接口文档,Controller 上写清楚的 `@Operation` 描述;`build.gradle` 加 `implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'` 即可在 `/swagger-ui.html` 看到文档。 - `traceId` 贯穿请求全链路(对应架构图 `Observability` 的要求),从入口 filter 生成,写入 `ApiResult` 和日志,详见 [08-observability.md](./08-observability.md)。 +- **错误码是数字,`0` 表示成功**,按 domain 分段(`10xxx` 平台通用 / `11xxx` 认证与门店 / `20xxx` 采购 / `21xxx` 库存 / `3xxxx` F6·Mini 透传类)。分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。 +- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code` 和 `message` 一起打。 +- 客户端侧的对应契约见 [../12-error-and-api-contract.md](../12-error-and-api-contract.md),两边的分段方案必须保持一致。 ## 附录:为什么要统一响应包装,而不是直接返回业务对象 不统一包装的话,前端(APP)拿到的成功响应是 `{ id, name }`,失败响应是 Spring 默认的 `{ timestamp, status, error, path }`——两种结构完全不一样,前端每个接口都要单独判断"这次失败长什么样"。统一成 `{ code, message, data, traceId }` 之后: -- 前端只需要判断 `code == "OK"` 就知道成功与否,不用对着 HTTP status code 猜。 +- 前端只需要判断 `code == 0` 就知道成功与否,不用对着 HTTP status code 猜。 - `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。 - 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。 @@ -147,7 +166,7 @@ interface StoreMapper { ## 待补充 - 分页/排序参数的统一约定。 -- 错误码表(按 domain 分段还是全局统一编码)。 +- **完整错误码表**:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 `ApiCode`([../12-error-and-api-contract.md](../12-error-and-api-contract.md))保持同步。 ## 参考链接 diff --git a/backend/10-testing.md b/backend/10-testing.md index 0edce1d..f47a966 100644 --- a/backend/10-testing.md +++ b/backend/10-testing.md @@ -101,7 +101,7 @@ class StoreControllerTest { content = """{}""" }.andExpect { status { isBadRequest() } - jsonPath("$.code") { value("INVALID_PARAM") } // 对应 06-api-design.md 的 ApiResult 结构 + jsonPath("$.code") { value(ErrorCode.INVALID_PARAM) } // 对应 06-api-design.md 的 ApiResult 结构 } } }