Add skills-lock.json to manage skill dependencies for drawio-skill and prd
This commit is contained in:
+2
-1
@@ -22,7 +22,8 @@ desktop.ini
|
||||
# agents
|
||||
.claude
|
||||
.agents
|
||||
|
||||
CLAUDE.md
|
||||
AGENTS.md
|
||||
|
||||
# origin-files
|
||||
origin-prd/
|
||||
@@ -1,442 +0,0 @@
|
||||
# 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<String, String> 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<String, Object?>? data});
|
||||
void i(String message, {Map<String, Object?>? 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 ? '<redacted>' : 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<String, Object?> params = const {}]);
|
||||
void registerSuperProperties(Map<String, Object?> 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)
|
||||
@@ -1,101 +0,0 @@
|
||||
# pyright: reportMissingImports=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SOURCE_FILE = ROOT.parent / "202606 Conti Retail APP Component data source.xlsx"
|
||||
OUTPUT_FILE = ROOT / f"{SOURCE_FILE.stem}.md"
|
||||
|
||||
FIELDS = [
|
||||
("编号", 2),
|
||||
("模块", 3),
|
||||
("功能", 4),
|
||||
("负责人", 5),
|
||||
("前置任务", 6),
|
||||
("数据集", 7),
|
||||
("来源", 8),
|
||||
("安全", 9),
|
||||
("备注", 10),
|
||||
]
|
||||
|
||||
|
||||
def normalize_cell(value: object) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(value)
|
||||
|
||||
text = str(value).replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [re.sub(r"\s+", " ", line).strip() for line in text.split("\n")]
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
def add_field(parts: list[str], name: str, value: str) -> None:
|
||||
if not value:
|
||||
return
|
||||
|
||||
parts.append(f"**{name}**")
|
||||
parts.append("")
|
||||
|
||||
lines = [line.strip() for line in value.splitlines() if line.strip()]
|
||||
if len(lines) > 1:
|
||||
parts.extend(f"- {line}" for line in lines)
|
||||
else:
|
||||
parts.extend(lines)
|
||||
|
||||
parts.append("")
|
||||
|
||||
|
||||
def extract_workbook_to_md() -> None:
|
||||
workbook = load_workbook(SOURCE_FILE, data_only=True)
|
||||
parts: list[str] = [f"# {SOURCE_FILE.stem}", ""]
|
||||
|
||||
for worksheet in workbook.worksheets:
|
||||
rows: list[dict[str, str]] = []
|
||||
|
||||
for row_index in range(2, worksheet.max_row + 1):
|
||||
row = {
|
||||
field: normalize_cell(worksheet.cell(row=row_index, column=column).value)
|
||||
for field, column in FIELDS
|
||||
}
|
||||
if not any(row.values()):
|
||||
continue
|
||||
if not any(row[key] for key in ("编号", "模块", "功能")):
|
||||
continue
|
||||
rows.append(row)
|
||||
|
||||
if not rows:
|
||||
continue
|
||||
|
||||
parts.append(f"## {worksheet.title}")
|
||||
parts.append("")
|
||||
|
||||
for row in rows:
|
||||
number = row["编号"]
|
||||
module = row["模块"]
|
||||
title = " ".join(part for part in (number, module) if part).strip()
|
||||
level = min(6, 3 + number.count(".")) if number else 3
|
||||
|
||||
parts.append(f"{'#' * level} {title or 'Untitled'}")
|
||||
parts.append("")
|
||||
add_field(parts, "功能", row["功能"])
|
||||
add_field(parts, "负责人", row["负责人"])
|
||||
add_field(parts, "前置任务", row["前置任务"])
|
||||
add_field(parts, "数据集", row["数据集"])
|
||||
add_field(parts, "来源", row["来源"])
|
||||
add_field(parts, "安全", row["安全"])
|
||||
add_field(parts, "备注", row["备注"])
|
||||
|
||||
OUTPUT_FILE.write_text("\n".join(parts).strip() + "\n", encoding="utf-8")
|
||||
print("Wrote workbook markdown output")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
extract_workbook_to_md()
|
||||
@@ -1,128 +0,0 @@
|
||||
# pyright: reportMissingImports=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import fitz
|
||||
import numpy as np
|
||||
from pptx import Presentation
|
||||
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
||||
from rapidocr_onnxruntime import RapidOCR
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SOURCE_DIR = ROOT.parent
|
||||
|
||||
PDF_PATH = SOURCE_DIR / "User Journeys.pdf"
|
||||
PPTX_PATH = SOURCE_DIR / "零售商系统方案研讨会PPT.retro.pptx"
|
||||
|
||||
PDF_OUTPUT = ROOT / "User Journeys.md"
|
||||
PPTX_OUTPUT = ROOT / "零售商系统方案研讨会PPT.retro.md"
|
||||
|
||||
OCR = RapidOCR()
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
lines = [re.sub(r"\s+", " ", line).strip() for line in text.split("\n")]
|
||||
return "\n".join(line for line in lines if line)
|
||||
|
||||
|
||||
def extract_page_ocr_text(page: fitz.Page) -> str:
|
||||
pix = page.get_pixmap(matrix=fitz.Matrix(4, 4), alpha=False)
|
||||
image = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, pix.n)
|
||||
result, _ = OCR(image)
|
||||
if not result:
|
||||
return ""
|
||||
return normalize_text("\n".join(str(item[1]) for item in result))
|
||||
|
||||
|
||||
def extract_pdf_to_md(pdf_path: Path, output_path: Path) -> None:
|
||||
doc = fitz.open(pdf_path)
|
||||
parts: list[str] = [f"# {pdf_path.stem}", ""]
|
||||
|
||||
try:
|
||||
for index in range(doc.page_count):
|
||||
page = doc.load_page(index)
|
||||
text = normalize_text(str(page.get_text("text") or ""))
|
||||
if not text:
|
||||
text = extract_page_ocr_text(page)
|
||||
parts.append(f"## Page {index + 1}")
|
||||
parts.append("")
|
||||
parts.append(text or "[No extractable text]")
|
||||
parts.append("")
|
||||
finally:
|
||||
doc.close()
|
||||
|
||||
output_path.write_text("\n".join(parts).strip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def iter_shape_text(shape) -> list[str]:
|
||||
chunks: list[str] = []
|
||||
|
||||
if hasattr(shape, "has_text_frame") and shape.has_text_frame:
|
||||
text = normalize_text(shape.text_frame.text)
|
||||
if text:
|
||||
chunks.append(text)
|
||||
|
||||
if hasattr(shape, "has_table") and shape.has_table:
|
||||
for row in shape.table.rows:
|
||||
cells = [normalize_text(cell.text) for cell in row.cells]
|
||||
cells = [cell for cell in cells if cell]
|
||||
if cells:
|
||||
chunks.append(" | ".join(cells))
|
||||
|
||||
if shape.shape_type == MSO_SHAPE_TYPE.GROUP:
|
||||
for subshape in shape.shapes:
|
||||
chunks.extend(iter_shape_text(subshape))
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def extract_pptx_to_md(pptx_path: Path, output_path: Path) -> None:
|
||||
prs = Presentation(str(pptx_path))
|
||||
parts: list[str] = [f"# {pptx_path.stem}", ""]
|
||||
|
||||
for index, slide in enumerate(prs.slides, start=1):
|
||||
title = ""
|
||||
parts.append(f"## Slide {index}")
|
||||
parts.append("")
|
||||
|
||||
if slide.shapes.title and slide.shapes.title.text:
|
||||
title = normalize_text(slide.shapes.title.text)
|
||||
if title:
|
||||
parts.append(f"### {title}")
|
||||
parts.append("")
|
||||
|
||||
seen: set[str] = set()
|
||||
collected = []
|
||||
|
||||
for shape in slide.shapes:
|
||||
for chunk in iter_shape_text(shape):
|
||||
if title and chunk == title:
|
||||
continue
|
||||
if chunk and chunk not in seen:
|
||||
seen.add(chunk)
|
||||
collected.append(chunk)
|
||||
|
||||
if collected:
|
||||
parts.extend(f"- {chunk}" for chunk in collected)
|
||||
else:
|
||||
parts.append("[No extractable text]")
|
||||
|
||||
parts.append("")
|
||||
|
||||
output_path.write_text("\n".join(parts).strip() + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
extract_pdf_to_md(PDF_PATH, PDF_OUTPUT)
|
||||
extract_pptx_to_md(PPTX_PATH, PPTX_OUTPUT)
|
||||
print("Wrote PDF markdown output")
|
||||
print("Wrote PPTX markdown output")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -4,26 +4,26 @@ Continental Retail APP 相关的文档参考仓库,用于沉淀架构决策、
|
||||
|
||||
## 目录说明
|
||||
|
||||
### App 架构决策文档
|
||||
### flutter-app/
|
||||
|
||||
Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读:
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [01-project-structure.md](./01-project-structure.md) | 工程结构 / 分包策略(Melos monorepo) |
|
||||
| [02-layering.md](./02-layering.md) | 分层架构规范(presentation/domain/data) |
|
||||
| [03-state-management.md](./03-state-management.md) | 状态管理方案(Riverpod) |
|
||||
| [04-routing.md](./04-routing.md) | 路由方案(go_router) |
|
||||
| [05-networking.md](./05-networking.md) | 网络层设计(dio) |
|
||||
| [06-local-storage.md](./06-local-storage.md) | 本地存储方案(Drift / secure storage / shared_preferences) |
|
||||
| [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、格式化、分支、流水线卡点) |
|
||||
| [01-project-structure.md](./flutter-app/01-project-structure.md) | 工程结构 / 分包策略(Melos monorepo) |
|
||||
| [02-layering.md](./flutter-app/02-layering.md) | 分层架构规范(presentation/domain/data) |
|
||||
| [03-state-management.md](./flutter-app/03-state-management.md) | 状态管理方案(Riverpod) |
|
||||
| [04-routing.md](./flutter-app/04-routing.md) | 路由方案(go_router) |
|
||||
| [05-networking.md](./flutter-app/05-networking.md) | 网络层设计(dio) |
|
||||
| [06-local-storage.md](./flutter-app/06-local-storage.md) | 本地存储方案(Drift / secure storage / shared_preferences) |
|
||||
| [07-native-integration.md](./flutter-app/07-native-integration.md) | 原生能力集成方式(Pigeon) |
|
||||
| [08-build-flavors.md](./flutter-app/08-build-flavors.md) | 多环境构建(dev/uat/prod flavor) |
|
||||
| [09-testing.md](./flutter-app/09-testing.md) | 测试策略(单元/Widget/集成测试) |
|
||||
| [10-webview-h5.md](./flutter-app/10-webview-h5.md) | Embedded H5 容器与 JSBridge(PRD 第 7 章核心链路) |
|
||||
| [11-store-context-and-session.md](./flutter-app/11-store-context-and-session.md) | 门店上下文与会话管理(切店级联失效、登出清理) |
|
||||
| [12-error-and-api-contract.md](./flutter-app/12-error-and-api-contract.md) | 错误处理与 API 契约(`ApiResult`、异常体系、降级) |
|
||||
| [13-observability-analytics.md](./flutter-app/13-observability-analytics.md) | 可观测性与埋点(Bugly 崩溃上报、神策埋点与错误看板、日志脱敏) |
|
||||
| [14-conventions-and-ci-gates.md](./flutter-app/14-conventions-and-ci-gates.md) | 工程规范与 CI 门禁(lint、格式化、分支、流水线卡点) |
|
||||
|
||||
首版范围为 **Android / iOS**,鸿蒙 OHOS 不在首版内(但 SDK 基线锁 3.44.9 是为后续 OHOS 适配留窗口,见 01 和 07)。
|
||||
|
||||
@@ -49,7 +49,7 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [prd/Continental-Retail-APP-PRD.md](./prd/Continental-Retail-APP-PRD.md) | Continental Retail APP PRD V1.0 —— 20 个模块、191 条编号需求、173 张图、65 张表。**自包含文档**,不引用本仓库其它 md,可直接转 Word |
|
||||
| [prd/Continental-Retail-APP-PRD.md](./prd/Continental-Retail-APP-PRD.md) | Continental Retail APP PRD V1.0 —— 20 个模块、192 条编号需求、173 张图、43 张表。需求描述与编号需求以文本书写(表格只用于字段清单、矩阵与统计)。**自包含文档**,不引用本仓库其它 md,可直接转 Word |
|
||||
|
||||
同目录下的 `images/`(自 `origin-prd` 抢救的 F6 与后台原型图)、`app-design-images/`(设计稿)、`mini-program-images/{O2O,ROOS,Warranty}/`(现状小程序截图)为其图源。
|
||||
|
||||
@@ -91,14 +91,14 @@ V1.0 相对 V0.9 的主要变化:修复第 4 章编号断裂、将「我的」
|
||||
| # | 差异 | 前期材料 | 实际结论 | V1.0 处置 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 技术路线 | `Architecture-Diagram/202606-Continental-Retail-APP-PRD.md` 表头写「主技术路线 React Native / 备选 Flutter」 | 实际选型是 **Flutter**,01-14 全部基于 Flutter | ✅ 文档控制表已写 Flutter(PRD C4) |
|
||||
| 2 | 扫码归属 | 202606 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) | ✅ PRD REQ-INT-003 已校正(PRD C5) |
|
||||
| 3 | JSBridge 能力数 | 计划稿记 13 项、202606 PRD 记 12 项 | 以 [10](./10-webview-h5.md) 为准:表格 **13 行**,因 `toast`/`dialog`/`loading` 合并为一行,实际 `method` 名共 **15 个** | ✅ PRD 第 7.3 节的 JSBridge 能力清单已按此重述 |
|
||||
| 2 | 扫码归属 | 202606 PRD 第 11.5 节 与 `Architecture-Diagram/202606-Conti-Retail-APP-Component-data-source.md` 写成「嵌入 F6 扫码页」 | 扫码是 **App 原生实现**(`native_scan`),同时服务 `feature_scan` 和 H5 的 JSBridge,见 [07](./flutter-app/07-native-integration.md) 和 [10](./flutter-app/10-webview-h5.md) | ✅ PRD REQ-INT-003 已校正(PRD C5) |
|
||||
| 3 | JSBridge 能力数 | 计划稿记 13 项、202606 PRD 记 12 项 | 以 [10](./flutter-app/10-webview-h5.md) 为准:表格 **13 行**,因 `toast`/`dialog`/`loading` 合并为一行,实际 `method` 名共 **15 个** | ✅ PRD 第 7.3 节的 JSBridge 能力清单已按此重述 |
|
||||
|
||||
## 待补充
|
||||
|
||||
- API 文档
|
||||
- `15-ui-design-system.md` — `core_ui` 的 Material 3 主题、设计 token、暗色模式(对应 PRD 第 8.2 节统一交互规则)
|
||||
- `16-i18n.md` — 首版单语言,但需预留 `flutter_localizations` + `intl` 结构(后补代价高)
|
||||
- `flutter-app/15-ui-design-system.md` — `core_ui` 的 Material 3 主题、设计 token、暗色模式(对应 PRD 第 8.2 节统一交互规则)
|
||||
- `flutter-app/16-i18n.md` — 首版单语言,但需预留 `flutter_localizations` + `intl` 结构(后补代价高)
|
||||
|
||||
## 跨文档的阻塞项
|
||||
|
||||
@@ -106,23 +106,28 @@ V1.0 相对 V0.9 的主要变化:修复第 4 章编号断裂、将「我的」
|
||||
|
||||
| 阻塞项 | 出处 | 影响 |
|
||||
| --- | --- | --- |
|
||||
| **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 |
|
||||
| **神策服务是否可用未确认** | [13](./flutter-app/13-observability-analytics.md) | 客户端埋点定为神策,**且崩溃明细看板也要建在神策上**(见下方已解决表),但本项目**没有现成账号**。公司若未在用,开通是采购流程而非配置项。**不阻塞开工**——事件方案和 `core_analytics` 接口先做,两者与 SDK 无关;实在走不通,埋点和错误明细都要另找落点 |
|
||||
|
||||
### 已解决(2026-08 技术决策)
|
||||
|
||||
| 原阻塞项 | 结论 | 出处 |
|
||||
| --- | --- | --- |
|
||||
| iOS 构建链路不成立 | **远程一台 Mac 出包**。首版手工执行仓库内的 `scripts/build_ios.sh`;后续把这台 Mac 注册成 GitLab Runner(`macos` tag)接进流水线。构建命令必须来自仓库脚本,符号表/dSYM 要带回归档 | [08](./flutter-app/08-build-flavors.md) |
|
||||
| 后端错误码表未定 | **分段方案 + 一组基础码现在定死,业务码在开发对应模块时随接口增补**,不集中排期。客户端默认展示后端 `message`,只有需要特殊 UX 的码才进 `ApiCode` | [12](./flutter-app/12-error-and-api-contract.md)、[backend/06](./backend/06-api-design.md) |
|
||||
| Sentry 自建还是 SaaS 未定 | **不引入 Sentry**。崩溃走**现有的腾讯 Bugly**(原生崩溃/ANR),错误明细与看板走**神策自定义事件 `app_error` + 在神策上二次开发**。连带决策:**release 不开 `--obfuscate`**(两者都还原不了混淆后的 Dart 堆栈),只保留 `--split-debug-info` | [13](./flutter-app/13-observability-analytics.md)、[08](./flutter-app/08-build-flavors.md) |
|
||||
| 内测分发渠道未定 | **不用 Firebase**。Android 走自建/公司托管的 OTA 分发页,iOS 走 TestFlight;后续可能并入管理后台的「APP 发布管理」,把分发、版本检查、强制升级一起收口 | [08](./flutter-app/08-build-flavors.md) |
|
||||
| 车牌识别技术路径未验证 | **用付费的阿里云 OCR(`RecognizeLicensePlate`)**,拍照上传换识别结果,不做端侧模型。**客户端不直连**,由后端代理(AK/SK 不下发、图片经 OSS 中转)。交互随之从"取景框自动识别"变为"拍一张照",且弱网下不可用——手工输码是常驻并列入口 | [07](./flutter-app/07-native-integration.md)、[backend/05](./backend/05-integration-layer.md) |
|
||||
|
||||
### 已解决(2026-08 后端文档评审)
|
||||
|
||||
| 原待确认项 | 结论 | 出处 |
|
||||
| --- | --- | --- |
|
||||
| 数据库选型未定 | **MySQL 8.4**(UAT/Prod 用 Azure Database for MySQL Flexible Server + Private Endpoint) | [backend/03](./backend/03-persistence.md)、[backend/09](./backend/09-build-deploy.md) |
|
||||
| `X-Trace-Id` 请求头契约未定 | 客户端传 32 位小写 hex;服务端校验通过则复用,否则忽略并自行生成,最终值在响应头和 `ApiResult.traceId` 里回写 | [backend/08](./backend/08-observability.md)、[05](./05-networking.md) |
|
||||
| `X-Trace-Id` 请求头契约未定 | 客户端传 32 位小写 hex;服务端校验通过则复用,否则忽略并自行生成,最终值在响应头和 `ApiResult.traceId` 里回写 | [backend/08](./backend/08-observability.md)、[05](./flutter-app/05-networking.md) |
|
||||
| 分页参数约定未定 | `pageNum`(1 起)/ `pageSize`(≤100)/ `sort`(白名单),响应含 `hasMore` | [backend/06](./backend/06-api-design.md) |
|
||||
| 集成层同步还是响应式 | **同步 `RestClient`**,不引入 WebClient/Mono | [backend/05](./backend/05-integration-layer.md) |
|
||||
|
||||
后端错误码表仍未定(见上表阻塞项):分段规则已在 [backend/06](./backend/06-api-design.md) 定好,缺的是各业务域把自己的码填进去。
|
||||
上面几条都留了各自的收尾工作,散在对应文档的「待确认项」里:车牌识别的置信度阈值与图片压缩参数、OCR 调用量与计费口径、关掉混淆后的安全侧确认、Mac Runner 接入流水线的时点。
|
||||
|
||||
## 语言约定
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 决策
|
||||
|
||||
每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../02-layering.md) 保持一致的思路:
|
||||
每个 `domains/*` 模块内部采用简化分层,`domain` 层**可选**,判断标准与 Flutter 端 [02-layering.md](../flutter-app/02-layering.md) 保持一致的思路:
|
||||
|
||||
```
|
||||
domains/xxx/
|
||||
|
||||
@@ -25,7 +25,7 @@ domains/identity-store/
|
||||
|
||||
## 端点契约(与客户端已实现的行为对齐)
|
||||
|
||||
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../05-networking.md](../05-networking.md)、[../11-store-context-and-session.md](../11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
|
||||
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../flutter-app/05-networking.md](../flutter-app/05-networking.md)、[../flutter-app/11-store-context-and-session.md](../flutter-app/11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
|
||||
|
||||
| 端点 | 认证 | 说明 |
|
||||
| --- | --- | --- |
|
||||
@@ -272,7 +272,7 @@ class ApiResultAuthenticationEntryPoint(
|
||||
}
|
||||
```
|
||||
|
||||
**为什么这两个 handler 必须存在**:Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`([06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../05-networking.md](../05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出。
|
||||
**为什么这两个 handler 必须存在**:Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`([06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../flutter-app/05-networking.md](../flutter-app/05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出。
|
||||
|
||||
## Refresh Token:不用 Redis,直接用现有 MySQL
|
||||
|
||||
@@ -389,7 +389,7 @@ class RefreshTokenService(
|
||||
data class RotatedTokens(val userId: Long, val refreshToken: String)
|
||||
```
|
||||
|
||||
- **轮换(rotation)**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`。旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
|
||||
- **轮换(rotation)**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`。旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../flutter-app/11-store-context-and-session.md](../flutter-app/11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
|
||||
- **过期清理**:加一个定时任务定期删掉 `expires_at < now()` 且已撤销的行。**注意多副本下这个任务会在每个 Pod 各跑一次**,处理方式见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
|
||||
### 为什么现阶段不引入 Redis
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 05. 集成层设计(F6 / Mini 域)
|
||||
# 05. 集成层设计(F6 / Mini 域 / 阿里云 OCR)
|
||||
|
||||
## 决策
|
||||
|
||||
供应商(F6)和历史 Mini 域的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md)),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离。
|
||||
供应商(F6)、历史 Mini 域、以及阿里云 OCR(车牌识别)的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` / `integration/aliyun-ocr` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md)),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离。
|
||||
|
||||
HTTP 客户端用 **`RestClient`(同步)**,底层是 Apache HttpClient 5 连接池,不用 `WebClient`/`Mono`。
|
||||
|
||||
@@ -30,6 +30,9 @@ integration/f6-adapter/
|
||||
|
||||
integration/mini-clients/
|
||||
对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用
|
||||
|
||||
integration/aliyun-ocr/
|
||||
车牌识别:OSS 临时上传 + RecognizeLicensePlate 调用、AK/SK 持有、按次计费的用量管控
|
||||
```
|
||||
|
||||
`platform-integration` 依赖 `platform-web`(为了复用 `BusinessException` 和 `ErrorCode`)是本次允许的少数几个 platform 间依赖之一。
|
||||
@@ -221,7 +224,7 @@ class F6ClientException(message: String) :
|
||||
IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝")
|
||||
```
|
||||
|
||||
错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../12-error-and-api-contract.md](../12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。
|
||||
错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。
|
||||
|
||||
## Mini 域客户端示例(内部系统,策略更宽松)
|
||||
|
||||
@@ -244,6 +247,59 @@ class O2OClient(private val miniRestClient: RestClient) {
|
||||
}
|
||||
```
|
||||
|
||||
## 阿里云 OCR 客户端(车牌识别,按次计费)
|
||||
|
||||
车牌识别用[阿里云视觉智能开放平台](https://help.aliyun.com/zh/viapi/developer-reference/api-u92rj0)的 `RecognizeLicensePlate`,客户端不直连、由后端代理,理由与整条链路见 [../flutter-app/07-native-integration.md](../flutter-app/07-native-integration.md)。它和 F6、Mini 域是同一级别的外部依赖,收口在 `integration/aliyun-ocr`。
|
||||
|
||||
它有两点和前面两个都不一样,配置策略要跟着变:
|
||||
|
||||
**1. 按次计费,所以不配 `@Retry`。** F6 重试一次只是多花点时间,OCR 重试一次是多花一次钱,而且识别失败通常是图片本身的问题(模糊、遮挡、角度太偏),重试同一张图大概率还是失败。**失败就返回失败,让用户重拍或手工输码**——这一条要在代码注释里写清楚,否则后人看到别的 client 都有 `@Retry` 会以为这里是漏了。
|
||||
|
||||
**2. 收 `ImageURL` 不收图片二进制**,所以调 OCR 之前必须先把图片落到 OSS。这两步一起构成一次识别,中间任何一步失败对客户端都是"识别失败"。
|
||||
|
||||
```kotlin
|
||||
// integration/aliyun-ocr/.../LicensePlateClient.kt
|
||||
@Component
|
||||
class LicensePlateClient(
|
||||
private val ossClient: OssClient,
|
||||
private val ocrRestClient: RestClient,
|
||||
) {
|
||||
// 只做超时 + 舱壁 + 熔断,不加 @Retry:按次计费,且重试同一张图收益极低
|
||||
@Bulkhead(name = "aliyun-ocr")
|
||||
@CircuitBreaker(name = "aliyun-ocr", fallbackMethod = "fallbackRecognize")
|
||||
fun recognize(image: ByteArray, contentType: String): PlateRecognition {
|
||||
require(image.size <= 4 * 1024 * 1024) { "图片超过阿里云 4 MB 限制" }
|
||||
|
||||
val objectKey = ossClient.putTemp(image, contentType) // 临时对象,见下方生命周期
|
||||
val resp = ocrRestClient.post()
|
||||
.uri("/?Action=RecognizeLicensePlate")
|
||||
.body(mapOf("ImageURL" to ossClient.signedUrl(objectKey)))
|
||||
.retrieve()
|
||||
.body(AliyunPlateResponse::class.java)!!
|
||||
|
||||
return PlateRecognition(
|
||||
plateNumber = resp.data.plateNumber,
|
||||
plateType = resp.data.plateType,
|
||||
confidence = resp.data.confidence, // 阈值判断交给上层,这里只如实返回
|
||||
)
|
||||
}
|
||||
|
||||
private fun fallbackRecognize(image: ByteArray, contentType: String, ex: Throwable): PlateRecognition {
|
||||
log.warn("车牌识别不可用,转手工输码", ex)
|
||||
throw IntegrationException(ErrorCode.OCR_UNAVAILABLE, "车牌识别暂不可用,请手动输入车牌")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这里的降级和 Mini 域的"返回空对象"不同:**车牌识别没有合理的空值**,识别不出来就是识别不出来,只能明确抛错让 APP 切到手工输码。返回一个空字符串会被误当成识别成功。
|
||||
|
||||
几条配套约束:
|
||||
|
||||
- **AK/SK 走 [07-config-governance.md](./07-config-governance.md) 的密钥管理**,和数据库口令同级,绝不进代码库、绝不下发到客户端。
|
||||
- **上传的车辆照片是临时对象**:OSS 上设生命周期规则自动过期(识别完就没用了),不要让门店车辆照片无限期堆在桶里——这既是成本问题也是合规问题。
|
||||
- **要有调用量监控和告警**。按次计费的接口一旦被误用(比如客户端做成连续帧调用)账单会很难看,用量指标进 [08-observability.md](./08-observability.md) 的看板。
|
||||
- **Region 固定在上海或深圳**(该能力只在这两个 Region 提供),配置里写死,不要跟着其它服务的 region 走。
|
||||
|
||||
## traceId 透传
|
||||
|
||||
```kotlin
|
||||
@@ -267,7 +323,8 @@ class TracePropagationInterceptor : ClientHttpRequestInterceptor {
|
||||
- **F6 是外部供应商域**,稳定性不可控,必须配齐超时 + 重试 + 熔断 + 舱壁,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。
|
||||
- **异常统一转换**:F6 / Mini 域的异常和非标准错误,在集成模块内部转换成内部标准错误码,业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。
|
||||
- **Mini 域调用相对可控**(内部系统),熔断可以不加,但超时和舱壁不能省,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"。
|
||||
- **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
- **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。**按次计费的调用(阿里云 OCR)同样不重试**,理由见上文。
|
||||
- **降级返回要和业务语义对得上**:Mini 域可以返回空对象(首页 tile 空着好过整页 500),但**车牌识别不能**——识别不出来只能明确抛错让 APP 切手工输码,返回空字符串会被误当成识别成功。
|
||||
- **不在数据库事务里调外部 HTTP**,理由见 [03-persistence.md](./03-persistence.md)。
|
||||
- 业务 domain(如 `workbench`)只依赖 `integration/*` 暴露的接口,不自己构造 `RestClient` 发请求。
|
||||
- `workbench` 首页把多个下游并行拉起来的写法(线程池、整体超时预算、按 tile 降级)见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)——那不属于单个客户端的职责。
|
||||
|
||||
@@ -140,7 +140,7 @@ data class StoreResponse(
|
||||
)
|
||||
```
|
||||
|
||||
路径和响应体是照着客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md)、[../05-networking.md](../05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
|
||||
路径和响应体是照着客户端 [../flutter-app/11-store-context-and-session.md](../flutter-app/11-store-context-and-session.md)、[../flutter-app/05-networking.md](../flutter-app/05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
|
||||
|
||||
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
|
||||
|
||||
@@ -182,7 +182,7 @@ interface StoreMapper {
|
||||
|
||||
## 统一请求头约定
|
||||
|
||||
客户端每个请求固定携带以下头(见 [../05-networking.md](../05-networking.md)),后端的处理规则:
|
||||
客户端每个请求固定携带以下头(见 [../flutter-app/05-networking.md](../flutter-app/05-networking.md)),后端的处理规则:
|
||||
|
||||
| 请求头 | 必带 | 后端处理 |
|
||||
| --- | --- | --- |
|
||||
@@ -196,7 +196,7 @@ interface StoreMapper {
|
||||
|
||||
## 数据格式约定
|
||||
|
||||
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../12-error-and-api-contract.md](../12-error-and-api-contract.md)。
|
||||
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md)。
|
||||
|
||||
- **时间**:一律 ISO-8601 UTC 字符串,带毫秒和 `Z` 后缀——`"2026-08-14T03:21:45.123Z"`。Kotlin 侧类型是 `Instant`。**不传时间戳数字**(数字看不出单位是秒还是毫秒,出过太多次事),**不传本地时间**(不带时区的时间在跨时区场景下无解)。库里存的也是 UTC,见 [03-persistence.md](./03-persistence.md)。
|
||||
- **金额**:Kotlin 侧 `BigDecimal`,序列化成**字符串**(`"1234.56"`)而不是 JSON number。JSON number 在很多客户端会被解析成双精度浮点,`0.1 + 0.2` 那一类精度问题会直接变成对不上账。单位统一为元,小数位固定两位。
|
||||
@@ -208,7 +208,7 @@ interface StoreMapper {
|
||||
|
||||
## 分页与排序约定
|
||||
|
||||
(这条同时解决客户端 [../05-networking.md](../05-networking.md) 里挂着的"分页字段名待定"。)
|
||||
(这条同时解决客户端 [../flutter-app/05-networking.md](../flutter-app/05-networking.md) 里挂着的"分页字段名待定"。)
|
||||
|
||||
**请求参数**:
|
||||
|
||||
@@ -250,13 +250,43 @@ interface StoreMapper {
|
||||
| `20xxx` / `21xxx` | 采购 / 库存 |
|
||||
| `30xxx` | F6 集成 |
|
||||
| `31xxx` | Mini 域集成 |
|
||||
| `32xxx` | 阿里云 OCR 集成(车牌识别) |
|
||||
|
||||
分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
|
||||
|
||||
- **分段和下面这组基础码现在定死,业务码在开发对应模块时随接口增补**。不等一份"完整码表"齐了再开工——需求还在动的时候那份表不可能齐,等它等于卡住所有人。
|
||||
|
||||
```kotlin
|
||||
object ErrorCode {
|
||||
const val OK = 0
|
||||
|
||||
// 10xxx 平台通用
|
||||
const val INVALID_PARAM = 10001
|
||||
const val UNAUTHORIZED = 10401
|
||||
const val FORBIDDEN = 10403
|
||||
const val NOT_FOUND = 10404
|
||||
const val RATE_LIMITED = 10429
|
||||
const val INTERNAL_ERROR = 10500
|
||||
|
||||
// 11xxx 认证与门店
|
||||
const val STORE_NOT_ACCESSIBLE = 11001
|
||||
const val NO_STORE_PERMISSION = 11002
|
||||
|
||||
// 3xxxx 集成
|
||||
const val F6_UNAVAILABLE = 30001
|
||||
const val MINI_UNAVAILABLE = 31001
|
||||
const val OCR_UNAVAILABLE = 32001 // 车牌识别不可用 → APP 切手工输码
|
||||
const val OCR_NO_PLATE_FOUND = 32002 // 图片里没找到车牌 → 提示重拍
|
||||
}
|
||||
```
|
||||
|
||||
这组码**客户端有对应分支逻辑**(触发刷新、重拉门店上下文、展示 traceId 等),改动要两边一起改。段内新增的业务码不需要客户端配合——客户端默认直接展示 `message`。
|
||||
|
||||
- **码表和代码放在一起维护**(`ErrorCode` 常量即码表),不落在某个人的 Excel 里。新增码值时在常量上写注释说明触发场景。
|
||||
- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code` 和 `message` 一起打。
|
||||
- **`message` 是给用户看的**,不放技术细节(SQL、堆栈、下游状态码、内部服务名)。技术细节进日志,用 `traceId` 关联。
|
||||
- **HTTP status code 仍然要用对**:成功 200,参数错 400,未认证 401,无权限 403,不存在 404,并发冲突 409,下游不可用 502。客户端主要看 `code`,但 401 是例外——它触发自动刷新逻辑,必须准确(见 04)。网关、监控、日志分析也都依赖 status code。
|
||||
- 新增错误码时**同步更新客户端的 `ApiCode`**([../12-error-and-api-contract.md](../12-error-and-api-contract.md)),两边分段方案必须一致。
|
||||
- 新增**需要客户端特殊 UX** 的错误码时,同步更新客户端的 `ApiCode`([../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md)),两边分段方案必须一致。
|
||||
|
||||
## 版本与兼容
|
||||
|
||||
@@ -289,7 +319,7 @@ interface StoreMapper {
|
||||
|
||||
## 待补充
|
||||
|
||||
- **完整错误码表**:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 `ApiCode`([../12-error-and-api-contract.md](../12-error-and-api-contract.md))保持同步。
|
||||
- **各 domain 段内的业务码**:分段方案和基础码已定死(见上),采购、库存、集成段内的具体码值由各 domain 负责人在开发对应模块时随接口增补,不集中排期。只有需要客户端特殊 UX 的码才要同步到客户端 `ApiCode`([../flutter-app/12-error-and-api-contract.md](../flutter-app/12-error-and-api-contract.md))。
|
||||
- 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
@@ -46,7 +46,7 @@ management:
|
||||
|
||||
### 与客户端 `X-Trace-Id` 的对接契约
|
||||
|
||||
客户端每个请求都会带一个自己生成的 `X-Trace-Id`(见 [../05-networking.md](../05-networking.md)),要求"后端复用它",这样一次用户操作在 APP 日志和服务端日志里是同一个 ID。但 Micrometer 认的是 W3C 的 `traceparent` 头,所以中间需要一层桥接:
|
||||
客户端每个请求都会带一个自己生成的 `X-Trace-Id`(见 [../flutter-app/05-networking.md](../flutter-app/05-networking.md)),要求"后端复用它",这样一次用户操作在 APP 日志和服务端日志里是同一个 ID。但 Micrometer 认的是 W3C 的 `traceparent` 头,所以中间需要一层桥接:
|
||||
|
||||
**契约(同时解决客户端文档里挂着的那条待确认项)**:
|
||||
|
||||
|
||||
@@ -30,12 +30,13 @@ conti-app/
|
||||
feature_analytics/ # 经营分析:对账单、核销收入、返利、报表
|
||||
feature_profile/ # 个人中心:地址、热线、客服
|
||||
feature_scan/ # 扫码业务入口(VIN/车牌/二维码/条码 → 分发到对应业务)
|
||||
native_scan/ # 原生插件包:扫码能力(android/ios 两端实现)
|
||||
native_scan/ # 原生插件包:扫码 + VIN 识别(车牌走云端 OCR,不在这里)
|
||||
native_media/ # 相机、相册、文件选择/上传
|
||||
native_device/ # 拨号、设备信息、权限申请
|
||||
native_crash/ # Bugly 崩溃上报的原生桥(见 13-observability-analytics.md)
|
||||
```
|
||||
|
||||
包清单按 [PRD](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) 的模块划分列出,实际开工时按迭代顺序逐个建,不需要一次性全建出来。
|
||||
包清单按 [PRD 第 4 章](../prd/Continental-Retail-APP-PRD.md) 的模块划分列出,实际开工时按迭代顺序逐个建,不需要一次性全建出来。
|
||||
|
||||
## 依赖规则(编译期强制边界,是这套结构的核心价值)
|
||||
|
||||
@@ -75,7 +75,7 @@ dev_dependencies:
|
||||
|
||||
## 后端统一响应包装在哪一层解开
|
||||
|
||||
后端所有接口返回 `ApiResult<T> { code, message, data, traceId }`(见 [backend/06-api-design.md](./backend/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**:
|
||||
后端所有接口返回 `ApiResult<T> { 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)`。
|
||||
@@ -85,7 +85,7 @@ dev_dependencies:
|
||||
|
||||
## 分页的统一约定
|
||||
|
||||
PRD §21.1 要求列表页支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名:
|
||||
列表页要支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名:
|
||||
|
||||
```dart
|
||||
// core_network 里定义的通用分页类型
|
||||
@@ -34,7 +34,7 @@ dev_dependencies:
|
||||
|
||||
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 被撤销、被强制登出。
|
||||
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`。
|
||||
|
||||
@@ -95,7 +95,7 @@ Future<List<Store>> storeList(Ref ref) async {
|
||||
|
||||
## 门店切换 / 登出时的批量失效
|
||||
|
||||
PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。
|
||||
PRD REQ-LGN-010(门店上下文)要求切换门店时级联失效所有门店相关缓存与在途请求——购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。
|
||||
|
||||
统一做法:所有与门店相关的 provider 都 `ref.watch(currentStoreIdProvider)`,让 Riverpod 的依赖图自己完成级联失效。
|
||||
|
||||
@@ -87,7 +87,7 @@ final goRouterProvider = Provider<GoRouter>((ref) {
|
||||
|
||||
## 后端动态菜单 → 本地路由的映射
|
||||
|
||||
PRD §22.2:工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。
|
||||
PRD 第 4.2.5 节(导航收敛与角色化配置):工作台菜单由后端按角色权限下发,不是写死在 App 里的。但**路由表必须是编译期写死的**(页面是 Dart 代码,不可能动态下发)。所以中间需要一张映射表。
|
||||
|
||||
约定:后端下发的每个菜单项带一个稳定的 `code`(如 `PURCHASE_ORDER`、`INVENTORY_CHECK`),`core_router` 里维护 `code → 路由路径` 的映射。
|
||||
|
||||
@@ -113,7 +113,7 @@ String? resolveMenuRoute(String code) => menuRouteMap[code];
|
||||
|
||||
## H5 页面的路由约定
|
||||
|
||||
PRD §7 的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为:
|
||||
PRD 第 7.3 节(F6 集成边界)里的核心功能(报价开单、施工查车、结算收银)走 Embedded H5。这些页面在路由表里的形态统一为:
|
||||
|
||||
```
|
||||
/webview?target=<TARGET_CODE>&title=<可选标题>
|
||||
@@ -127,7 +127,7 @@ PRD §7 的核心功能(报价开单、施工查车、结算收银)走 Embed
|
||||
|
||||
## 门店切换后的路由重置
|
||||
|
||||
PRD §11.4:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。
|
||||
PRD REQ-LGN-010:切换门店后所有业务上下文跟着切。导航栈是其中一部分——用户在 A 门店的"采购单详情 `/purchase/orders/123`"页面切到 B 门店,这个订单 ID 在 B 门店可能不存在,或者更糟,存在但是另一张单。
|
||||
|
||||
**规则:切换门店成功后,清空导航栈回工作台。**
|
||||
|
||||
@@ -24,7 +24,7 @@ dependencies:
|
||||
|
||||
## 后端契约:统一响应包装
|
||||
|
||||
后端所有接口返回 `ApiResult<T> { code, message, data, traceId }`(见 [backend/06-api-design.md](./backend/06-api-design.md))。**解包只在 `ApiResultInterceptor` 里做一次**,repository 拿到的 `response.data` 已经是里层的 `data`。
|
||||
后端所有接口返回 `ApiResult<T> { 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
|
||||
@@ -102,7 +102,7 @@ void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
|
||||
这一段是整个网络层最容易写错、错了后果最严重的地方,因为它和后端的 **refresh token 轮换策略**强耦合。
|
||||
|
||||
按 [backend/04-security-auth.md](./backend/04-security-auth.md):
|
||||
按 [backend/04-security-auth.md](../backend/04-security-auth.md):
|
||||
|
||||
- refresh token 是**一次性**的,每次换 access token 都会签发新的、旧的立刻 `revokedAt`。
|
||||
- **旧 token 再被用一次 = 判定为泄漏重放,该用户名下所有 refresh token 全部撤销**。
|
||||
@@ -285,7 +285,7 @@ BaseOptions(
|
||||
- 只对超时/连接失败重试,业务错误码和 4xx 不重试。
|
||||
- 最多 1 次。
|
||||
|
||||
`f6-integration` 侧后端已经配了重试和熔断([backend/05-integration-layer.md](./backend/05-integration-layer.md)),客户端再叠一层意义不大,反而会把后端的熔断窗口打满。
|
||||
`f6-integration` 侧后端已经配了重试和熔断([backend/05-integration-layer.md](../backend/05-integration-layer.md)),客户端再叠一层意义不大,反而会把后端的熔断窗口打满。
|
||||
|
||||
## `CancelToken` 与 provider 生命周期
|
||||
|
||||
@@ -304,7 +304,7 @@ Future<List<PurchaseOrder>> purchaseOrders(Ref ref) async {
|
||||
|
||||
## 文件与图片上传
|
||||
|
||||
PRD §7.4(H5 桥接的图片选择/上传)和施工照片场景都要用到。
|
||||
PRD 第 7.3 节的 JSBridge 能力清单(H5 桥接的图片选择/上传)和施工照片场景都要用到。
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/api_client.dart
|
||||
@@ -44,7 +44,7 @@ dependencies:
|
||||
|
||||
## 所有业务缓存表必须带 `storeId`
|
||||
|
||||
PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。
|
||||
PRD REQ-LGN-010(门店上下文)要求切换门店时级联失效所有门店相关缓存——购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。
|
||||
|
||||
**硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且
|
||||
|
||||
@@ -100,24 +100,77 @@ 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 需要回头修订**。
|
||||
> **与前期材料的冲突(已裁决)**:前期草稿和 `202606-Conti-Retail-APP-Component-data-source.md` 里把扫码写成"嵌入 F6 扫码页",与此处不一致。已按本文档裁决(扫码是 App 原生做的),现行 PRD 的 **REQ-INT-003** 已校正。
|
||||
|
||||
### 待确认:VIN 码与车牌识别的技术路径
|
||||
|
||||
这是一个**还没解决的能力缺口**,必须在开工前定下来。
|
||||
### VIN 码与车牌识别:车牌走阿里云 OCR,其余在端上解
|
||||
|
||||
PRD 要求扫描 **VIN 码**和**车牌**。但通用扫码库(`mobile_scanner`、ZXing、MLKit Barcode Scanning)解的是**二维码/条形码**,识别不了车牌这种自然场景文字;VIN 虽然常以 Code 39 条码形式印在车身铭牌上,但也大量存在"只有印刷字符、没有条码"的情况。这两个都需要 **OCR**。
|
||||
|
||||
| 需求 | 能力 | 候选方案 |
|
||||
**结论:车牌用付费的[阿里云视觉智能开放平台车牌识别](https://help.aliyun.com/zh/viapi/developer-reference/api-u92rj0)(`RecognizeLicensePlate`),上传图片换识别结果,不做端侧模型。**
|
||||
|
||||
| 需求 | 能力 | 方案 | 在哪跑 |
|
||||
|---|---|---|---|
|
||||
| 二维码 / 条形码(商品、库位) | Barcode | MLKit Barcode Scanning(Android)/ Vision(iOS) | **端上**,离线 |
|
||||
| VIN 条码 | Barcode(Code 39) | 同上 | **端上**,离线 |
|
||||
| VIN 印刷字符 | OCR + 校验位算法 | MLKit Text Recognition / Vision 通用 OCR,**用 VIN 第 9 位校验码过滤误识别** | **端上**,离线 |
|
||||
| **车牌** | **云端 OCR** | **阿里云 `RecognizeLicensePlate`** | **云端**,联网 |
|
||||
|
||||
这张表最重要的是最后一列:**只有车牌这一路需要联网**,其余三路都在端上离线完成。下面的约束全部由这个差异推出来。
|
||||
|
||||
#### 车牌这一路和其它三路完全不是一回事
|
||||
|
||||
它不再是 `native_scan` 的一种扫码模式,而是「**拍照 → 上传 → 等结果**」的网络请求。三个直接后果:
|
||||
|
||||
**1. 交互从"取景框自动识别"变成"按快门"。** 端侧方案可以逐帧识别、对准就出结果;云端 API 按次计费且有网络往返,**不允许连续帧调用**。所以车牌入口的交互是拍一张照、上传、等一个明确的结果。设计稿如果画的是扫码式取景框自动识别,需要按这条调整。
|
||||
|
||||
**2. 弱网下这个功能直接不可用。** 门店地下车库、施工区网络条件差,而接车是高频动作。所以:
|
||||
|
||||
- **手工输码是常驻的并列入口,不是识别失败后的降级路径**(PRD 首页「扫码 / 车牌」本来就是两个按钮)。
|
||||
- 上传前**必须压缩**:API 限制单图 ≤ 4 MB、分辨率 15×15 ~ 4096×4096,而手机原图动辄十几 MB。压到长边 1920 左右、JPEG 质量 80 通常既满足识别又能在弱网下传得动。
|
||||
- 超时和重试上限要设死。**失败就退回手工输码,不要自动重试第二次** —— 每次调用都要花钱,而且用户已经在等了。
|
||||
|
||||
**3. 图片要离开设备,隐私政策必须写到。** 阿里云是境内服务,**不涉及数据出境**,但"车辆照片上传至第三方进行识别"属于必须告知的处理行为,要进隐私政策,并计入 `REQ-NFR-023`。
|
||||
|
||||
#### 客户端不直连阿里云
|
||||
|
||||
**AK/SK 绝对不能进客户端。** 打进 APK 的密钥等同于公开,反编译就能拿到,之后任何人都能拿我们的账号刷调用量。而且 `RecognizeLicensePlate` 收的是 `ImageURL`(OSS 链接),不是图片二进制 —— 客户端直连还得自己处理 OSS 上传凭证,更没必要。
|
||||
|
||||
**客户端只调我们后端的一个接口**,阿里云的存在对客户端完全透明:
|
||||
|
||||
```
|
||||
App ──① 压缩后的图片──▶ 后端 ──② 落 OSS──▶ 阿里云 OSS
|
||||
│
|
||||
└──③ RecognizeLicensePlate(ImageURL)──▶ 阿里云 OCR
|
||||
App ◀───────④ { plateNumber, confidence } ──────┘
|
||||
```
|
||||
|
||||
好处是换供应商、加缓存、加调用量管控都只动后端。代价是图片多走一跳(门店 → 我们的后端 → OSS)。**如果实测上传耗时不可接受**,再换成"后端签发 STS 临时凭证、客户端直传 OSS、只把 URL 交给后端"的两段式,但首版不必要 —— 别为还没测出来的问题先加一层复杂度。
|
||||
|
||||
后端侧的接法(超时、熔断、错误码段)按 [../backend/05-integration-layer.md](../backend/05-integration-layer.md) 的规矩走,阿里云 OCR 是一个和 F6、Mini 同级的外部依赖。
|
||||
|
||||
#### 置信度要用起来
|
||||
|
||||
响应里的 `Confidence` 不是装饰。约定:
|
||||
|
||||
- **低于阈值不直接填进表单**,而是把识别结果作为"待确认"展示,让用户点一下确认或改。阈值实测后定。
|
||||
- 识别出的字符串还要过一次**车牌格式校验**(省份简称 + 字母 + 5~6 位、新能源 8 位),不合规一律当失败处理。API 返回一个高置信度的非法车牌,比返回失败更危险 —— 它会被直接写进工单。
|
||||
|
||||
#### 为什么不自己训模型
|
||||
|
||||
评估过"自训练 YOLO 定位 + PaddleOCR 识别"的端侧方案,没有采用:
|
||||
|
||||
| | 阿里云 OCR(采用) | 自训练 YOLO + PaddleOCR |
|
||||
|---|---|---|
|
||||
| 二维码 / 条形码(商品、库位) | 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(涉及成本、离线授权、包体积、以及是否上传图片到第三方服务器的合规问题)。
|
||||
**取舍**:用调用费和联网依赖,换掉一整条算法工程链路和"准确率自负"的风险。对一个门店业务 APP 来说这笔账是划算的 —— 车牌识别不是我们的核心竞争力,没有理由自己养一套模型。离线不可用由手工输码入口兜住,这本来就是必须有的。
|
||||
|
||||
在验证结论出来之前,**`native_scan` 的 Pigeon schema 要预留 `ScanMode` 参数**(`barcode` / `vin` / `plate`),避免后面加识别类型时要改接口签名。
|
||||
VIN 印刷字符**继续在端上用通用 OCR + 校验位过滤**,不一并上云:VIN 是标准印刷字符,通用 OCR 本来就擅长,第 9 位校验码能把误识别挡在外面 —— 这是车牌没有的优势,白白花钱和牺牲离线能力没有道理。真到实测准确率不够,阿里云同一套 OCR 里也有 VIN 识别接口可以顶上。
|
||||
|
||||
## 权限与合规
|
||||
|
||||
@@ -220,8 +273,11 @@ pigeon:
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **车牌识别的技术路径**(通用 OCR 是否够用,还是要采购商用 SDK)——见上文,开工前必须有结论。
|
||||
- VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证。
|
||||
- **车牌识别的置信度阈值**:低于多少不直接回填、改成"待确认"让用户核对,需实测后定。
|
||||
- **图片压缩参数**(长边、JPEG 质量):要同时满足阿里云 ≤ 4 MB 的限制、弱网可传、以及识别准确率不明显下降,实测后固化。
|
||||
- **上传路径首版走"经我们后端中转"还是"STS 直传 OSS"**:默认中转(简单、密钥不出服务端),实测上传耗时不可接受再改,见上文。
|
||||
- **调用量管控与计费口径**:单次接车允许几次识别、失败是否计费、月度用量上限与告警,和后端一起定。
|
||||
- VIN 印刷字符 OCR 的实际准确率,需要拿真实车辆铭牌照片做一轮验证;不达标则改用阿里云的 VIN 识别接口。
|
||||
- 三方 SDK 的 iOS 隐私清单覆盖情况,首次提交 TestFlight 前核完。
|
||||
|
||||
## 参考链接
|
||||
@@ -279,8 +335,9 @@ abstract class ScanHostApi {
|
||||
void stopScan();
|
||||
}
|
||||
|
||||
/// 预留识别类型,避免后面加车牌/VIN 识别时改接口签名
|
||||
enum ScanMode { barcode, vin, plate }
|
||||
/// 端上能解的两类。**车牌不在这里** —— 它是"拍照 + 调后端接口",
|
||||
/// 不是取景框里的实时识别,见上文「VIN 码与车牌识别」
|
||||
enum ScanMode { barcode, vin }
|
||||
|
||||
class ScanOptions {
|
||||
ScanOptions({required this.mode, required this.timeoutMs});
|
||||
@@ -4,15 +4,14 @@
|
||||
|
||||
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 构建链路:当前不成立,必须先解决」。
|
||||
|
||||
> ⚠️ **Android 复用与后端共用的 Linux Runner,iOS 走单独的 Mac 机器。** `flutter build ipa` 必须跑在 macOS 上,本项目通过一台**远程 Mac** 出 iOS 包,详见下文「iOS 构建:远程 Mac」。
|
||||
|
||||
## Flavor 划分规则
|
||||
|
||||
| Flavor | Application ID / Bundle ID | API 目标 | 分发渠道 |
|
||||
|---|---|---|---|
|
||||
| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(渠道待定,见下文) |
|
||||
| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(渠道待定,见下文) |
|
||||
| `dev` | `com.conti.retail.dev` | Dev 环境(对应后端 Dev) | 内部测试分发(托管平台,见下文) |
|
||||
| `uat` | `com.conti.retail.uat` | UAT 环境(对应后端 UAT) | 内部测试分发(托管平台 / TestFlight,见下文) |
|
||||
| `prod` | `com.conti.retail` | Prod 环境(对应后端 Prod) | App Store Connect / 各安卓应用市场 |
|
||||
|
||||
## 使用规则
|
||||
@@ -37,20 +36,21 @@ productFlavors {
|
||||
|
||||
对应地 iOS 侧 xcconfig 里也用 `PRODUCT_BUNDLE_IDENTIFIER = com.conti.retail$(BUNDLE_ID_SUFFIX)`,`BUNDLE_ID_SUFFIX` 按 Build Configuration 取 `.dev` / `.uat` / 空。
|
||||
|
||||
## Release 构建必须开混淆和符号剥离
|
||||
## 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
|
||||
--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 侧数据过期或服务不可用时的兜底。**丢了符号表 = 那个版本的所有线上崩溃永远无法定位**,这是个不可逆的失误。
|
||||
- `--split-debug-info` 把调试符号剥离到单独目录(同时显著减小包体)。
|
||||
- **`--obfuscate` 是刻意不加的。** 崩溃上报走 Bugly + 神策,两者都没有还原 Dart 混淆堆栈的能力(见 [13-observability-analytics.md](./13-observability-analytics.md))。加上混淆的结果是**线上占比最大的那一半崩溃在后台是一串 `_x12`**,每条都要人工 `flutter symbolize`。不混淆时上报回来的堆栈类名方法名直接可读(`OrderRepository.submit`),代价是 Dart 符号留在产物里、逆向门槛降一档——**这个取舍要和安全侧确认,见待确认项**。
|
||||
- 若安全侧要求改回混淆,**两个参数必须一起用**:只写 `--obfuscate` 不写 `--split-debug-info` 会被工具链拒绝;同时 13 篇里的排查流程要改成"人工 symbolize"。
|
||||
- **符号表必须归档**:按 `版本号+构建号` 存成 CI artifact 保留至少 1 年。不混淆之后它不再是日常排查的必需品,但仍是拿到精确行号的唯一手段——`--split-debug-info` 把行号剥离出去了,堆栈里只剩类名和方法名。**丢了符号表 = 那个版本再也拿不到行号**,不可逆。
|
||||
- 符号表目录按版本号区分(用 tag 或 `versionName+versionCode`),不能所有版本堆一个目录。
|
||||
- **Android mapping(R8)和 iOS dSYM 照旧归档,并在 build 之后上传 Bugly**(Bugly 提供符号表上传的命令行工具)。原生侧的自动符号化是 Bugly 的强项,这一条不受上面那个 Dart 决策影响。
|
||||
|
||||
## 版本号规则
|
||||
|
||||
@@ -90,23 +90,25 @@ after_script:
|
||||
- dev/uat 可以共用一个非正式 keystore,prod 单独一个。
|
||||
- `android/key.properties` 加进 `.gitignore`。
|
||||
|
||||
## iOS 构建链路:当前不成立,必须先解决
|
||||
## iOS 构建:远程 Mac
|
||||
|
||||
**这是首版发布前最大的工程阻塞项,不是可以边做边说的事情。**
|
||||
`flutter build ipa` 依赖 Xcode,**必须在 macOS 上跑**,而现有 GitLab Runner 与后端共用、是 Linux runner。**本项目的方案是用一台远程 Mac 出 iOS 包**,不为此改造现有 Linux runner,也不引入云端 mac 构建服务(省掉把签名证书上传第三方带来的安全评审)。
|
||||
|
||||
现状:GitLab Runner 与后端共用,是 **Linux** runner。而 `flutter build ipa` **必须在 macOS 上跑**(依赖 Xcode),Linux runner 上这条流水线根本无法存在。除此之外 iOS 还需要证书和描述文件(Provisioning Profile)的管理,这在 CI 上是另一套工作量。
|
||||
两个阶段:
|
||||
|
||||
三个可选路径:
|
||||
| 阶段 | 做法 |
|
||||
|---|---|
|
||||
| **当前** | 远程连上 Mac 手工执行构建脚本出 ipa。脚本进仓库(`scripts/build_ios.sh`),保证每次构建参数一致,不靠人记命令 |
|
||||
| **后续** | 同一台 Mac 注册成 GitLab Runner(打 `macos` tag),iOS job 落到它上面,与 Android job 并行 |
|
||||
|
||||
| 方案 | 成本 | 说明 |
|
||||
|---|---|---|
|
||||
| **A. 自建 mac mini runner**(推荐) | 一次性硬件采购 + 机房/网络接入 | 一台 M 系列 mac mini 就够跑 iOS 构建。长期成本最低,网络在内网也方便访问私有仓库。缺点是要有人维护(Xcode 升级、磁盘清理、注册成 GitLab Runner) |
|
||||
| **B. 云端 mac runner** | 按分钟计费,持续支出 | GitLab 的 macOS runner / Codemagic / Bitrise。省运维,但涉及把签名证书上传到第三方,需要走安全评审;且国内访问速度和稳定性要实测 |
|
||||
| **C. 先只做 Android CI,iOS 手工出包** | 零成本,但有人力成本和风险 | 短期可行,作为 A/B 落地前的过渡。风险是"能出 iOS 包的只有某一台开发机 + 某一个人",属于典型的单点依赖 |
|
||||
**当前阶段的两条纪律**,它们是"手工出包"唯一的真实风险来源:
|
||||
|
||||
**建议**:立项时就按 **A** 走,把 mac mini 的采购提前提出来(采购周期通常比想象长),过渡期用 **C**。无论选哪个,证书和描述文件都用 [fastlane match](https://docs.fastlane.tools/actions/match/) 管理,存在一个私有 git 仓库里,不靠人肉在钥匙串之间导来导去。
|
||||
- **构建命令必须来自仓库里的脚本**,flavor、`--dart-define-from-file`、`--split-debug-info` 路径都在脚本里写死。手敲命令漏一个参数,出来的包看起来正常,实际连的是错的环境或者没有归档符号表。
|
||||
- **符号表和 dSYM 要从 Mac 上带回来归档**(见上文 Release 构建)。这是手工出包最容易漏的一步——Linux 上有 CI artifact 自动兜着,Mac 上没有。
|
||||
|
||||
CI 上 iOS job 需要单独打 tag 到 mac runner:
|
||||
证书和描述文件(Provisioning Profile)无论哪个阶段都用 [fastlane match](https://docs.fastlane.tools/actions/match/) 管理,存在一个私有 git 仓库里,**不靠人肉在钥匙串之间导来导去**。这一条在只有一台 Mac 的情况下更重要:机器坏了、人换了,签名能力不能跟着丢。
|
||||
|
||||
注册成 runner 之后,iOS job 打 tag 落到 mac runner:
|
||||
|
||||
```yaml
|
||||
build_ios_uat:
|
||||
@@ -117,26 +119,25 @@ build_ios_uat:
|
||||
--dart-define-from-file=env/uat.json --export-options-plist=ios/ExportOptions-uat.plist
|
||||
```
|
||||
|
||||
## 内测分发渠道:Firebase 在国内可达性存疑
|
||||
## 内测分发:托管平台 + 后台发布管理
|
||||
|
||||
原方案写的是 Firebase App Distribution。**问题**:本项目的使用者是**中国境内门店的一线员工**,Firebase 的下载域名在国内的可达性和速度都不稳定,很可能出现"链接点开一直转圈装不上"。这会直接影响 UAT 验收效率。
|
||||
**结论:用托管平台做内测分发,不引入 Firebase App Distribution。** 使用者是中国境内门店的一线员工,Firebase 的下载域名在国内可达性和速度都不稳定,"链接点开一直转圈装不上"会直接拖垮 UAT 验收效率。
|
||||
|
||||
候选方案对比:
|
||||
|
||||
| 渠道 | 国内可达 | 说明 |
|
||||
| 平台 | Android | iOS |
|
||||
|---|---|---|
|
||||
| Firebase App Distribution | ❌ 不稳定 | 与 Crashlytics 集成好,但国内下载体验是硬伤 |
|
||||
| **蒲公英 / fir.im** | ✅ | 国内主流内测分发,支持 iOS/Android,扫码安装。需要企业账号,注意上传的包属于放在第三方服务器 |
|
||||
| **自建 OTA 分发页** | ✅ | 一个静态页 + `itms-services://` plist(iOS)+ apk 直链。完全可控、无第三方依赖,但要自己做鉴权、版本管理 |
|
||||
| GitLab Package Registry | ✅(走公司网络) | 已有基础设施、无额外采购。缺点是 iOS 装包体验差(不支持 OTA 直装),Android 也要用户手动下载 apk |
|
||||
| **自建 / 公司托管的 OTA 分发页** | apk 直链下载 | `itms-services://` + plist(需企业签名或把设备 UDID 加进 ad-hoc 描述文件) |
|
||||
| **TestFlight** | — | 上架前必经的验证路径,国内可达性没问题,**推荐 iOS 走这条** |
|
||||
|
||||
**建议**:Android 用**自建 OTA 页或 GitLab Package Registry**(内网可控),iOS 用 **TestFlight**(苹果官方,国内可达性没问题,且是上架前必经的验证路径)。这个组合避免了引入新的第三方服务商和相应的安全评审。
|
||||
**下一步很可能是把分发收进后台管理端**:后台已经规划了「APP 配置」类功能(见 PRD 的后台模块),再加一个「APP 发布管理」是顺理成章的——版本列表、上传包、灰度范围、**强制升级开关**。做了它就同时解决三件事:内测分发、版本更新检查接口、强制升级,而不是各做各的。**这一条尚未定案**,见待确认项。
|
||||
|
||||
**列为待确认项**:需要和运维确认自建 OTA 页的托管位置与访问控制。
|
||||
无论最终托管在哪,两条不变:
|
||||
|
||||
- **包要按 flavor 和版本号归档**,不能只留"最新一个"。回归验证经常要装回上一版。
|
||||
- **分发入口要有访问控制**。apk 直链裸放在公网上,等于把内测包(含 uat 环境地址)交给任何人。
|
||||
|
||||
## Firebase 配置文件按 flavor 放置
|
||||
|
||||
如果最终引入 Firebase(如 Crashlytics),配置文件要按 flavor 分开放,否则三个环境的崩溃数据会混进同一个项目:
|
||||
本项目**不引入 Firebase**(崩溃上报走 Bugly + 神策,见 [13-observability-analytics.md](./13-observability-analytics.md);内测分发见上文)。以下写法仅在将来确实要引入某个 Firebase 服务时适用,留作参考——**配置文件必须按 flavor 分开放**,否则三个环境的数据会混进同一个项目:
|
||||
|
||||
```
|
||||
android/app/src/dev/google-services.json
|
||||
@@ -152,9 +153,10 @@ Android 的 flavor 源集目录(`src/{flavor}/`)会自动生效;iOS 没有
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **iOS 构建 runner 方案(A/B/C 选哪个)**——阻塞 iOS 发版,优先级最高。
|
||||
- 内测分发渠道的最终选型与托管位置。
|
||||
- 是否引入 Firebase(影响崩溃上报选型,见 [13-observability-analytics.md](./13-observability-analytics.md))。
|
||||
- **release 到底混不混淆**——本文的决策是**不混淆**(理由见上文 Release 构建一节),需要安全侧确认能否接受 Dart 符号暴露在产物里。改回混淆的话,[13-observability-analytics.md](./13-observability-analytics.md) 的崩溃排查流程要一并改成"人工 symbolize"。
|
||||
- **内测分发的托管位置与访问控制**——自建 OTA 页放在哪、谁维护、怎么鉴权,需要和运维确认。
|
||||
- **「APP 发布管理」是否进后台管理端**——做了它就一并解决版本更新检查与强制升级(对应 PRD 的 `REQ-NFR-036` / `REQ-NFR-037`),需要产品和后端一起裁决。
|
||||
- 远程 Mac 何时注册成 GitLab Runner(当前是手工出包,长期不宜停在这一步——"能出 iOS 包的只有某一台机器 + 某一个人"是典型的单点依赖)。
|
||||
- Android 上架渠道清单(华为/小米/OPPO/vivo 各家应用市场是否都要上,各家的加固/隐私合规要求不同)。
|
||||
|
||||
## 参考链接
|
||||
@@ -279,23 +281,23 @@ build_android_prod:
|
||||
- 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
|
||||
--split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||
artifacts:
|
||||
paths:
|
||||
- build/app/outputs/bundle/prodRelease/
|
||||
- build/symbols/ # 符号表必须归档,丢了就没法解混淆崩溃堆栈
|
||||
- build/symbols/ # 符号表必须归档,丢了就拿不到崩溃堆栈的行号
|
||||
expire_in: 1 year
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG' # 只在打 tag 时触发,避免误发生产包
|
||||
|
||||
build_ios_prod:
|
||||
stage: build
|
||||
tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建链路」
|
||||
tags: [macos] # 必须是 mac runner,Linux runner 跑不了,见上文「iOS 构建:远程 Mac」
|
||||
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
|
||||
--split-debug-info=build/symbols/$CI_COMMIT_TAG
|
||||
rules:
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
```
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
PRD §7 的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。
|
||||
PRD 第 7.3 节(F6 集成边界)定义的 Embedded H5 承载了 App 最核心的几条业务链路(报价开单、施工查车、结算收银),它不是"顺带加个 WebView",而是一个有票据换取、双向桥接、生命周期管理和安全边界的完整子系统。这些内容放不进 01-09 的任何一篇,所以单独成篇。
|
||||
|
||||
**适用范围**:Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD §7.1)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
|
||||
**适用范围**:Embedded H5 **仅用于承载 F6 页面**,不做通用外链容器(PRD 第 7.3 节)。任何"能不能顺便用它打开某个网页"的需求,默认答案是不能。
|
||||
|
||||
## 决策
|
||||
|
||||
@@ -40,7 +40,7 @@ if (controller.platform is AndroidWebViewController) {
|
||||
|
||||
## H5 启动流程
|
||||
|
||||
对应 PRD §7.2:
|
||||
对应 PRD 第 7.3 节的接入流程:
|
||||
|
||||
```
|
||||
用户点击功能入口(feature_* 或工作台菜单)
|
||||
@@ -66,7 +66,7 @@ class H5LaunchInfo {
|
||||
}
|
||||
```
|
||||
|
||||
**启动上下文参数(PRD §7.3)由 App Backend 拼进 URL,客户端不参与拼接。** 客户端拼参数意味着 `userId`/`storeId`/`roleCode` 这些权限相关字段可以被本地篡改,而后端拼接时这些值都从服务端的会话上下文取,客户端只能说"我要开 `QUOTE_ORDER`"。
|
||||
**启动上下文参数(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 侧的请求。
|
||||
|
||||
@@ -79,7 +79,7 @@ class UrlGuard {
|
||||
final Set<String> _allowedHosts; // 来自 env/{flavor}.json,各环境不同
|
||||
|
||||
bool isAllowed(Uri uri) {
|
||||
if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD §7.6)
|
||||
if (uri.scheme != 'https') return false; // 只允许 HTTPS(PRD REQ-NFR-016)
|
||||
final host = uri.host.toLowerCase();
|
||||
return _allowedHosts.any((allowed) =>
|
||||
host == allowed || host.endsWith('.$allowed'));
|
||||
@@ -143,7 +143,7 @@ const result = await window.ContiBridge.call('scan', { mode: 'barcode' });
|
||||
- `error.code` 是**稳定的字符串枚举**,不是数字,也不透传原生错误码。H5 侧按 code 分支处理,`message` 只用于展示。
|
||||
- 未知 `method` 返回 `{ code: "UNSUPPORTED_METHOD" }` 而不是静默忽略——H5 版本比 App 新时能明确知道"这个 App 版本不支持这个能力",可以降级而不是卡死。
|
||||
|
||||
### 能力清单(PRD §7.4)
|
||||
### 能力清单(PRD 第 7.3 节 JSBridge 能力清单)
|
||||
|
||||
| method | 说明 | 底层 | 备注 |
|
||||
|---|---|---|---|
|
||||
@@ -163,7 +163,7 @@ const result = await window.ContiBridge.call('scan', { mode: 'barcode' });
|
||||
|
||||
`navigate` 的路由白名单和 `04-routing.md` 的「后端动态菜单 → 本地路由」用同一张 `menuRouteMap`——不允许 H5 拼一个任意路由字符串跳过去(那等于把 App 的所有内部页面都暴露给了 H5)。
|
||||
|
||||
### 来源校验(PRD §7.6)
|
||||
### 来源校验(PRD 第 8.4 节 安全与合规)
|
||||
|
||||
**JavaScript Channel 会注入到 WebView 的所有 frame,包括 iframe。** 如果 F6 页面里嵌了第三方 iframe,那个 iframe 里的脚本也能调 `ContiBridge`。所以每条消息进来都要校验:
|
||||
|
||||
@@ -208,9 +208,9 @@ Future<void> handle(String raw) async {
|
||||
|
||||
### 其他安全约定
|
||||
|
||||
- **`getAuthState` 不返回 token 明文**(PRD §7.6:"H5 页面不得直接保存 APP 明文 Token")。它返回的是 `{ loggedIn: true, ticketRefreshed: true }` 这类状态,H5 需要新票据时由 App 重新换票并 `loadRequest` 新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。
|
||||
- **`getAuthState` 不返回 token 明文**(PRD REQ-NFR-018:H5 不传递 token 明文)。它返回的是 `{ loggedIn: true, ticketRefreshed: true }` 这类状态,H5 需要新票据时由 App 重新换票并 `loadRequest` 新 URL,票据始终在 URL 参数里由后端控制,不经 bridge 传递。
|
||||
- **H5 侧的所有输入都当作不可信**:`params` 里的路径、路由、URL 一律校验后再用。特别是 `uploadFile` 的文件路径,必须限制在 App 沙盒内的临时目录,否则 H5 可以让 App 上传任意本地文件。
|
||||
- **供应商错误不透传**:F6 返回的原始错误信息转换成用户能懂的提示(PRD §7.6),原始信息只进日志。
|
||||
- **供应商错误不透传**:F6 返回的原始错误信息转换成用户能懂的提示(PRD REQ-NFR-013 统一错误处理),原始信息只进日志。
|
||||
|
||||
### JS 侧胶水
|
||||
|
||||
@@ -249,7 +249,7 @@ const _bridgeShim = r'''
|
||||
|
||||
H5 侧要处理"bridge 还没就绪"的情况(比如页面脚本跑得比注入早),约定 H5 等待 `window.__contiBridgeReady` 或监听一个 `conti:ready` 事件。**这条要写进给 F6 的接入文档**。
|
||||
|
||||
## 生命周期管理(PRD §7.5)
|
||||
## 生命周期管理(PRD 第 7.3 节)
|
||||
|
||||
| 场景 | 处理 |
|
||||
|---|---|
|
||||
@@ -305,7 +305,7 @@ NavigationDelegate(
|
||||
|
||||
### 门店切换与登出时的会话失效
|
||||
|
||||
PRD §7.5 的默认策略是硬要求:
|
||||
PRD 第 7.3 节的默认策略是硬要求:
|
||||
|
||||
- **门店切换后,当前 H5 页面必须失效并提示用户重新进入。**
|
||||
- **用户退出登录后,所有 H5 会话必须同步失效。**
|
||||
@@ -339,7 +339,7 @@ class WebViewSession {
|
||||
|
||||
以下几项需要和 F6 侧明确约定,不对齐会在联调阶段集中爆发:
|
||||
|
||||
1. `ContiBridge` 的 12 项能力,H5 侧如何检测可用性(`__contiBridgeReady` 的等待方式)。
|
||||
1. `ContiBridge` 的 13 项能力,H5 侧如何检测可用性(`__contiBridgeReady` 的等待方式)。
|
||||
2. 票据过期时 F6 页面的表现(返回什么响应,是否能保存草稿)。
|
||||
3. F6 页面是否嵌第三方 iframe,若有需要哪些域名。
|
||||
4. F6 静态资源的 `Cache-Control` 策略。
|
||||
@@ -349,7 +349,7 @@ class WebViewSession {
|
||||
## 待确认项
|
||||
|
||||
- 各环境的域名白名单具体值(写进 `env/{flavor}.json`)。
|
||||
- `/api/v1/h5/launch` 的接口契约(后端侧对应 `bff-orchestration` + `webview-ticket`,见 [backend/05-integration-layer.md](./backend/05-integration-layer.md)),需要与后端一起定。
|
||||
- `/api/v1/h5/launch` 的接口契约(后端侧对应 `bff-orchestration` + `webview-ticket`,见 [backend/05-integration-layer.md](../backend/05-integration-layer.md)),需要与后端一起定。
|
||||
- 是否需要 H5 离线包(首版不做,若 F6 首屏加载慢再评估)。
|
||||
|
||||
## 参考链接
|
||||
@@ -358,4 +358,4 @@ class WebViewSession {
|
||||
- [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)
|
||||
- [PRD 第 7 章 系统集成与架构边界](../prd/Continental-Retail-APP-PRD.md)
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
门店上下文是**贯穿整个 App 的隐式依赖**:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD §11.4:「当前门店影响所有业务数据」)。它不属于任何一个 `feature_*`,但每个 `feature_*` 都依赖它。
|
||||
门店上下文是**贯穿整个 App 的隐式依赖**:首页 tile、菜单、购物车、待办、预警、订单、缓存表、H5 页面全部与"当前门店"绑定(PRD REQ-LGN-010:当前门店影响所有业务数据)。它不属于任何一个 `feature_*`,但每个 `feature_*` 都依赖它。
|
||||
|
||||
更关键的是**切换门店时的级联失效**——这是最容易漏、漏了就会出"看到别的门店数据"这种严重问题的地方。之前 01-10 里只在各自话题下提了一句(03 讲 provider 失效、06 讲缓存清理、10 讲 H5 失效),没有一个地方定义完整顺序。这一篇负责收口。
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
```
|
||||
AppSession
|
||||
├── AuthState 登录态(token 生命周期,归 core_auth)
|
||||
├── UserContext 用户上下文(PRD §6.4.1)
|
||||
└── StoreContext 门店上下文(PRD §6.4.2)
|
||||
├── UserContext 用户上下文(PRD 第 4.1 节)
|
||||
└── StoreContext 门店上下文(PRD REQ-LGN-010)
|
||||
```
|
||||
|
||||
```dart
|
||||
@@ -37,7 +37,7 @@ class SessionActive extends AppSession {
|
||||
}
|
||||
```
|
||||
|
||||
**四个状态,不是布尔值。** 用 `bool isLoggedIn` 表达会立刻遇到两个说不清的场景:冷启动期间算不算已登录(算,会闪一下首页;不算,会闪一下登录页),以及"已登录但没门店"该去哪(PRD §11.3 要求「门店上下文缺失时引导重新选择门店」,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 `04-routing.md` 的 redirect 能穷举分支,漏一个编译器就报错。
|
||||
**四个状态,不是布尔值。** 用 `bool isLoggedIn` 表达会立刻遇到两个说不清的场景:冷启动期间算不算已登录(算,会闪一下首页;不算,会闪一下登录页),以及"已登录但没门店"该去哪(PRD REQ-LGN-010 要求登录后必须确定唯一「当前门店」,缺失时需引导重新选择,这是一个独立页面,既不是登录页也不是首页)。sealed class 让 `04-routing.md` 的 redirect 能穷举分支,漏一个编译器就报错。
|
||||
|
||||
```dart
|
||||
final class UserContext {
|
||||
@@ -54,7 +54,7 @@ final class StoreContext {
|
||||
}
|
||||
```
|
||||
|
||||
`menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD §6.4.2 明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。
|
||||
`menus` 放在 `StoreContext` 里而不是 `UserContext` 里——PRD 第 4.2.5 节(导航收敛与角色化配置)明确菜单是**门店维度**的(同一个人在 A 店是店长、在 B 店是店员,菜单不同)。放错地方会导致切店后菜单不刷新。
|
||||
|
||||
## 唯一真相源
|
||||
|
||||
@@ -83,7 +83,7 @@ int currentStoreId(Ref ref) {
|
||||
|
||||
## 登录流程
|
||||
|
||||
PRD §10.1:「登录成功后必须立即获取门店上下文」。
|
||||
PRD REQ-LGN-010:登录后必须确定唯一「当前门店」。
|
||||
|
||||
```
|
||||
输入手机号 + 验证码(或账号密码)
|
||||
@@ -109,11 +109,11 @@ POST /api/v1/stores/{id}/switch → StoreContext(含菜单)
|
||||
|
||||
- **`stores/accessible` 失败不等于登录失败**。token 已经拿到了,此时应该进 `SessionAwaitingStore` 并展示一个可重试的页面,而不是回登录页让用户重新发一遍验证码。
|
||||
- **"上次门店"只是一个提示,不是权限依据**。它存在 `shared_preferences`(非敏感,见 [06-local-storage.md](./06-local-storage.md)),冷启动/登录时用来预选,但**必须先确认它在后端返回的可访问列表里**——用户的门店权限可能已经被管理员回收了。
|
||||
- **0 个门店时必须登出**,不能停在一个空白首页。PRD §10.1/§10.2 把"用户无门店权限"列为登录异常流程。
|
||||
- **0 个门店时必须登出**,不能停在一个空白首页。PRD 第 4.1.1 节的异常流程把「账号无任何门店归属」列为阻断登录的分支。
|
||||
|
||||
## 切换门店:级联失效清单
|
||||
|
||||
这是本篇的核心。PRD §11.4:「购物车、待办、预警、订单和 H5 页面上下文必须同步切换」。
|
||||
这是本篇的核心。PRD REQ-LGN-010:切换门店时级联失效所有门店相关缓存与在途请求。
|
||||
|
||||
**顺序是有意义的**,不能随便调:
|
||||
|
||||
@@ -166,7 +166,7 @@ Future<void> switchStore(int targetStoreId) async {
|
||||
|
||||
## 登出:清理清单
|
||||
|
||||
PRD §10.4:「清理 Token、门店上下文、本地用户信息和缓存」+「关闭所有已打开的 F6 H5 会话」。
|
||||
PRD REQ-LGN-008(登出):清理本地会话、门店上下文、缓存的业务数据与 WebView Cookie。
|
||||
|
||||
```dart
|
||||
Future<void> logout({LogoutReason reason = LogoutReason.userInitiated}) async {
|
||||
@@ -223,7 +223,7 @@ ProviderScope(
|
||||
|
||||
## 与 refresh token 轮换的配合
|
||||
|
||||
后端采用**一次性 refresh token + 重放即全量撤销**(见 [backend/04-security-auth.md](./backend/04-security-auth.md))。这对客户端有两条硬约束,已经在 [05-networking.md](./05-networking.md) 的 `AuthInterceptor` 里实现,这里说明它和会话状态的关系:
|
||||
后端采用**一次性 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 已失效(过期、被撤销、或已被重放),重试只会再触发一次重放判定。
|
||||
@@ -297,6 +297,6 @@ if (elapsedSinceBackground > const Duration(minutes: 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)
|
||||
- [PRD 第 4.1 节 账号登录(REQ-LGN-008 登出 / REQ-LGN-010 门店上下文)](../prd/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)
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 一、`ApiResult` 客户端契约
|
||||
|
||||
后端所有接口统一返回(见 [backend/06-api-design.md](./backend/06-api-design.md)):
|
||||
后端所有接口统一返回(见 [backend/06-api-design.md](../backend/06-api-design.md)):
|
||||
|
||||
```json
|
||||
{ "code": 0, "message": "success", "data": { ... }, "traceId": "a1b2c3..." }
|
||||
@@ -34,9 +34,9 @@
|
||||
2. **客户端不允许出现字面量数字**。所有用到的码定义成命名常量,`if (e.code == ApiCode.forbidden)` 而不是 `if (e.code == 10403)`。
|
||||
3. **日志里 code 和 message 一起打**,因为 `message` 是唯一能让人在不查码表时看懂的东西。
|
||||
|
||||
### 分段方案(建议,待后端确认)
|
||||
### 分段方案
|
||||
|
||||
`backend/06-api-design.md` 的待补充项里「按 domain 分段还是全局统一编码」还没定。建议 **5 位数字,前 2 位是域段**:
|
||||
**5 位数字,前 2 位是域段**(与 `backend/06-api-design.md` 一致):
|
||||
|
||||
| 段 | 域 | 例 |
|
||||
|---|---|---|
|
||||
@@ -45,7 +45,7 @@
|
||||
| `11xxx` | 认证与门店 | `11001` 门店不可访问、`11002` 无门店权限 |
|
||||
| `20xxx` | 采购 | |
|
||||
| `21xxx` | 库存 | |
|
||||
| `3xxxx` | F6 / Mini 透传类错误 | 后端做过转换,不透传供应商原始码 |
|
||||
| `3xxxx` | 外部系统集成 | `30xxx` F6、`31xxx` Mini 域、`32xxx` 阿里云 OCR;后端做过转换,不透传供应商原始码 |
|
||||
|
||||
分段的价值是**看到码的前两位就知道该找谁**。全局连续编号(1、2、3…)在多域并行开发时必然撞号。
|
||||
|
||||
@@ -61,28 +61,41 @@ Future<T?> → 显式声明可空时才允许 null
|
||||
|
||||
不加这层校验的话,后端某个字段漏返回会变成 UI 层莫名其妙的 `Null check operator used on a null value`,排查时完全看不出是接口问题。
|
||||
|
||||
### 完整码表还没定
|
||||
### 基础码先定死,业务码开发时增补
|
||||
|
||||
分段方案(上表)只是骨架,**具体的码表还没和后端对齐**。在它定下来之前:
|
||||
**分段方案和下面这组基础码现在就定死,各 domain 段内的业务码在开发对应模块时随接口一起定。** 不等一份"完整码表"齐了再开工——那份表在需求还在动的时候不可能齐,等它等于卡住所有人。
|
||||
|
||||
- **默认直接展示后端的 `message`**。后端的 `GlobalExceptionHandler` 已经保证了 `message` 是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。这条策略让客户端在码表缺席时也能正常工作。
|
||||
- **客户端只对一小组"需要特殊 UX 而不只是提示文案"的 code 做分支**,这组必须尽可能小:
|
||||
配套的两条策略让码表不齐也能正常工作:
|
||||
|
||||
- **默认直接展示后端的 `message`**。后端的 `GlobalExceptionHandler` 已经保证了 `message` 是给人看的(未预期异常统一兜底成"系统繁忙,请稍后重试",不泄漏堆栈)。**新增业务码不需要客户端改代码**,走的就是这条默认路径。
|
||||
- **客户端只对"需要特殊 UX 而不只是提示文案"的 code 做分支**,这组要尽可能小。下面这份就是当前的全集:
|
||||
|
||||
```dart
|
||||
// packages/core_network/lib/src/error/api_code.dart
|
||||
abstract final class ApiCode {
|
||||
static const ok = 0;
|
||||
|
||||
// 10xxx 平台通用
|
||||
static const invalidParam = 10001; // → 表单内联报错,不弹 Toast
|
||||
static const unauthorized = 10401; // → 触发刷新 / 登出
|
||||
static const forbidden = 10403; // → 权限变更,可能要重拉门店上下文
|
||||
static const notFound = 10404; // → 资源不存在,页面级空态
|
||||
static const rateLimited = 10429; // → 提示稍后重试,不自动重试
|
||||
static const internalError = 10500; // → 展示 traceId
|
||||
|
||||
// 11xxx 认证与门店
|
||||
static const storeNotAccessible = 11001; // → 引导重选门店
|
||||
static const noStorePermission = 11002; // → 退回门店选择页
|
||||
|
||||
// 3xxxx 集成
|
||||
static const ocrUnavailable = 32001; // → 车牌识别不可用,直接切手工输码
|
||||
static const ocrNoPlateFound = 32002; // → 没识别到车牌,提示重拍
|
||||
}
|
||||
```
|
||||
|
||||
**这份清单要和后端一起确认**,是本文档最重要的待确认项。清单之外的 code 一律走默认展示。
|
||||
**增补一个业务码的门槛**:只有当客户端需要"展示文案之外的动作"(跳转、重拉上下文、内联标红、拦截重试)时才加进这个类;只是文案不同的,一律走默认展示。这条不守住,`ApiCode` 会在半年内长成后端码表的副本。
|
||||
|
||||
码表本身**和后端代码放在一起维护**(见 `backend/06-api-design.md`),客户端这份常量是它的子集,不是第二份真相。
|
||||
|
||||
## 二、`AppException` 体系
|
||||
|
||||
@@ -184,7 +197,7 @@ final class NativeException extends AppException {
|
||||
|
||||
### traceId 怎么展示
|
||||
|
||||
**`traceId` 保留,但它是一个低成本、低存在感的字段,不要为它做重的交互。** 后端侧它本来就有(`TraceIdFilter` 写 MDC + 落 ELK,见 [backend/08-observability.md](./backend/08-observability.md)),响应里多带一个字符串对客户端来说接近零成本;它唯一的价值是**把一次用户投诉精确定位到一条服务端日志**,省掉"大概是下午三点多,某个门店"这种模糊排查。所以:
|
||||
**`traceId` 保留,但它是一个低成本、低存在感的字段,不要为它做重的交互。** 后端侧它本来就有(`TraceIdFilter` 写 MDC + 落 ELK,见 [backend/08-observability.md](../backend/08-observability.md)),响应里多带一个字符串对客户端来说接近零成本;它唯一的价值是**把一次用户投诉精确定位到一条服务端日志**,省掉"大概是下午三点多,某个门店"这种模糊排查。所以:
|
||||
|
||||
- **绝大多数错误不展示它**,只有 `ServerException`(5xx / 系统错误)才展示——那正是需要研发介入的场景。
|
||||
- 无条件写进本地日志和错误上报(见 [13-observability-analytics.md](./13-observability-analytics.md)),这部分不依赖 UI。
|
||||
@@ -222,7 +235,7 @@ TileErrorView(error: e, onRetry: ...) // 尺寸自适应,不撑破布局
|
||||
|
||||
## 四、降级:局部失败不能拖垮整页
|
||||
|
||||
PRD §21.1「首页支持部分失败降级」、§21.2「Mini 某一服务失败应仅影响对应模块」「F6 异常不得导致主 APP 全部不可用」。
|
||||
PRD REQ-NFR-012:单一外围系统故障时局部降级,首页其余卡片正常展示并标注「暂不可用」——Mini 某一服务失败只影响对应模块,F6 异常不得导致主 APP 全部不可用。
|
||||
|
||||
### 首页的降级模型
|
||||
|
||||
@@ -253,7 +266,7 @@ class HomePage extends ConsumerWidget {
|
||||
}
|
||||
```
|
||||
|
||||
`Future.wait` 看起来更"干净",但它把 N 个独立的失败面耦合成了一个——公告服务挂了,用户连待办都看不到。这直接违反 PRD §21.1。
|
||||
`Future.wait` 看起来更"干净",但它把 N 个独立的失败面耦合成了一个——公告服务挂了,用户连待办都看不到。这直接违反 PRD REQ-NFR-012。
|
||||
|
||||
**唯一的例外是"没有它整页就没意义"的数据**:门店上下文和菜单。这两块失败时首页确实应该整页错误态,因为菜单没了首页就是一个空壳。
|
||||
|
||||
@@ -357,14 +370,14 @@ if (e.code == 10403) { ... } // 用 ApiCode.forbidden
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **错误码表(最高优先级)**:需要和后端一起把上面的分段方案落成完整码表,特别是 `ApiCode` 里那组需要特殊 UX 的码。这一项不定,客户端只能全部走默认文案。同时 `backend/06-api-design.md` 的「待补充」里也挂着这一条。
|
||||
- **各 domain 段内的业务码**:分段方案和 `ApiCode` 里的基础码已定死,采购(`20xxx`)、库存(`21xxx`)、外部集成(`3xxxx`)段内的其余码值在开发对应模块时随接口一起定。新增码默认走 `message` 展示,只有需要特殊 UX 的才进 `ApiCode`。
|
||||
- 幂等:提交类接口(下单、入库)超时后客户端是否重试,需要后端提供幂等键(`Idempotency-Key`)支持才能安全重试。当前决策是**不重试、提示用户手动确认结果**。
|
||||
- 是否需要一个统一的"错误反馈"入口(用户可以带 traceId 一键提交问题)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [backend/06-api-design.md:`ApiResult` 与全局异常处理](./backend/06-api-design.md)
|
||||
- [backend/08-observability.md:traceId 全链路](./backend/08-observability.md)
|
||||
- [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)
|
||||
@@ -0,0 +1,464 @@
|
||||
# 13. 可观测性与埋点
|
||||
|
||||
## 为什么单独一篇
|
||||
|
||||
PRD REQ-NFR-015(崩溃与错误上报)和第 6.8 节(埋点)有明确要求——主链路 Trace ID、H5 打开/关闭/失败事件、关键业务审计日志、埋点事件——但 01-12 里完全没有落点。同时,**App 侧的可观测性是排查线上问题唯一的手段**——后端有 ELK 可以查日志,App 装在几百家门店的员工手机上,没有上报就等于全盲。
|
||||
|
||||
这一篇定三件事:**崩溃上报**、**日志规范**、**埋点规范**。
|
||||
|
||||
## 决策
|
||||
|
||||
| 项 | 决策 |
|
||||
|---|---|
|
||||
| 崩溃上报 | **腾讯 Bugly**(原生崩溃 / ANR / 启动崩溃),通过自建的 `native_crash` Pigeon 包接入 |
|
||||
| 错误明细与看板 | **神策自定义事件 `app_error` + 在神策上二次开发错误看板**,不引入独立崩溃平台 |
|
||||
| release 混淆 | **不开 `--obfuscate`**,只 `--split-debug-info` 归档符号表,见下文「必须关掉混淆」 |
|
||||
| 本地日志 | **[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),写入本地日志并作为崩溃/错误上报的自定义属性 |
|
||||
|
||||
## 一、崩溃上报:Bugly + 神策
|
||||
|
||||
### 分工
|
||||
|
||||
崩溃上报要收两类东西,它们的技术性质完全不同:
|
||||
|
||||
- **原生崩溃**(SIGSEGV、ANR、iOS crash、启动期崩溃)—— 交给 **Bugly**。团队现成在用,国内可达性没有任何问题,原生侧自动符号化是它的强项。
|
||||
- **Dart 异常**(`setState` 期间抛错、null check、JSON 解析失败、未 catch 的 `Future`)—— 我们**自己捕获**,作为结构化的自定义事件 `app_error` 上报到**神策**,在神策上二次开发出错误看板(按 `errorType` / `route` / `storeId` / `appVersion` 分组看趋势和影响设备数)。
|
||||
- 同一条 Dart 异常**再通过 Bugly 的自定义异常接口报一份**,这样 Bugly 后台的崩溃率是全量口径,不会因为"Dart 异常不在里面"而虚低。两边口径见下文「两边报同一件事,怎么不打架」。
|
||||
|
||||
不引入独立崩溃平台(Sentry 等),省掉一次采购、一套部署运维和一轮跨境数据合规评估。**代价在下一节,必须一起接受。**
|
||||
|
||||
### 必须关掉混淆
|
||||
|
||||
一个独立崩溃平台在这件事上唯一真正的优势,是能自动上传 Dart 符号表并在后台还原混淆堆栈。**Bugly 和神策都做不到** —— Bugly 只能把 Dart 异常当成一段字符串收下,神策更是只当事件属性存着。
|
||||
|
||||
如果继续按原计划用 `--obfuscate` 打 release 包,结果是**线上占比最大的那一半崩溃,堆栈是一串读不出来的 `_x12`**,每次排查都要人工把堆栈拷出来跑一遍 `flutter symbolize`。所以这里必须做一个连带决策:
|
||||
|
||||
| release 构建参数 | 堆栈可读性 | 结论 |
|
||||
|---|---|---|
|
||||
| `--obfuscate --split-debug-info` | 类名方法名全部混淆,必须 `flutter symbolize` 才能看 | ❌ 放弃(原计划) |
|
||||
| **只 `--split-debug-info`** | **类名、方法名直接可读**(`OrderRepository.submit`),行号被剥离到符号表,需要精确行号时再 `symbolize` | ✅ **采用** |
|
||||
| 两个都不加 | 完全可读(带 `file:line`),但产物变大、Dart 符号全部留在包里 | ❌ 不采用 |
|
||||
|
||||
**这是本次选型真正付出的东西**:上报回来的堆栈能直接定位到出错的类和方法,够用;代价是 Dart 层的符号不再混淆,逆向门槛降了一档。这一条要和安全侧确认(见待确认项)——安全侧若坚持要混淆,就必须接受"每条 Dart 崩溃都要人工 symbolize"这个排查成本,并把 [08-build-flavors.md](./08-build-flavors.md) 的构建参数改回去。
|
||||
|
||||
**符号表照旧归档**(08 要求 ≥1 年)。关掉混淆后它不再是日常排查的必需品,但仍是拿精确行号的唯一手段,且丢了不可逆。
|
||||
|
||||
### 存档:为什么不另起一个崩溃平台
|
||||
|
||||
| | Bugly + 神策(采用) | 独立崩溃平台(如 Sentry) |
|
||||
|---|---|---|
|
||||
| **Dart 堆栈还原** | 做不到,靠"不混淆"绕开 | 做得到(插件自动上传符号表) |
|
||||
| Flutter 官方 SDK | Bugly **没有**,pub.dev 上只有 unverified 社区插件,要自己写 `native_crash` | 有,`pubspec.yaml` 加两行 |
|
||||
| 原生崩溃 | Bugly 强项,自动符号化 | 支持 |
|
||||
| 账号 / 采购 | **Bugly、神策都是现成的** | 要新开,自建还要内网资源和运维承接方 |
|
||||
| 数据出境 | 无(都在境内) | SaaS 形态属数据出境,要走合规评估 |
|
||||
| 门店网络可达性 | 无问题 | SaaS 形态丢报率无法预估 |
|
||||
| 接入成本 | 高:`native_crash` + 三个 Dart 捕获入口 + 神策看板都要自己做 | 低 |
|
||||
|
||||
**决定性的是「现成」那一行。** 崩溃平台这类基础设施,"已经在用、有人维护、合规口径已经过"的价值,高于"SDK 接入省两天"。混淆那一条是可以用构建参数换掉的,采购周期和数据出境评估换不掉。
|
||||
|
||||
|
||||
### `native_crash`:Bugly 得自己包
|
||||
|
||||
Bugly 只有 Android / iOS 原生 SDK。pub.dev 上那两个社区插件(`flutter_bugly`、`bugly_pro_flutter`)都是 unverified,**不采用**——崩溃上报是出了问题最不该再出问题的一环,不押在一个可能停更的包上。
|
||||
|
||||
所以 `native_*` 里**要加一个 `native_crash` 包**(包清单见 [01-project-structure.md](./01-project-structure.md),Pigeon 约定见 [07-native-integration.md](./07-native-integration.md)),几十行 Kotlin/Swift:
|
||||
|
||||
```dart
|
||||
// packages/native_crash/pigeons/crash_api.dart
|
||||
@HostApi()
|
||||
abstract class NativeCrashApi {
|
||||
void setUserId(String userId);
|
||||
void putUserData(String key, String value); // storeId / roleCode / flavor 等维度
|
||||
void postException(String type, String message, String stackTrace, Map<String, String> extra);
|
||||
void log(int level, String tag, String message); // 进 Bugly 的崩溃附加日志
|
||||
}
|
||||
```
|
||||
|
||||
**`native_crash` 和其它 `native_*` 包有一条本质区别:它的初始化不在 Dart 侧。** Bugly SDK 必须在原生的 `Application.onCreate()` / `AppDelegate` 里尽早初始化,**启动期原生崩溃发生在 Flutter engine 起来之前**,等 Dart 调过来就已经漏了。因此:
|
||||
|
||||
- SDK 初始化写在原生侧,`appId` / `appKey` 按 flavor 从原生资源里取(Android 走 `src/{flavor}/`,iOS 走 xcconfig,见 [08-build-flavors.md](./08-build-flavors.md)),**不经过 `--dart-define`**——Dart 侧拿到的时候太晚了。
|
||||
- Pigeon 接口里因此**没有 `init()`**,只有初始化之后才用得上的那几个方法。
|
||||
- 但**「用户同意隐私政策前不初始化」这条硬要求依然成立**:原生侧读一个本地标记位,没同意就不初始化 SDK。这个标记位由 Dart 侧在同意后写入(见「不采集什么」)。
|
||||
|
||||
### Dart 异常的三个捕获入口
|
||||
|
||||
没有 SDK 帮忙接管,三个入口要自己写全,**漏掉哪个就是那一类异常静默丢失**。完整的 `main()` 写法见 [12-error-and-api-contract.md](./12-error-and-api-contract.md) 第五节,这里只强调三者缺一不可:
|
||||
|
||||
| 入口 | 覆盖 |
|
||||
|---|---|
|
||||
| `FlutterError.onError` | widget 构建 / 布局 / 绘制期的异常 |
|
||||
| `PlatformDispatcher.instance.onError` | 框架之外、engine 层冒上来的异步异常 |
|
||||
| `runZonedGuarded` 的 onError | zone 内未捕获的异步异常(`Future` 里 throw 且没人 catch) |
|
||||
|
||||
**这里和"用现成 SDK"是一个反转的风险**:接三方 SDK 时最常见的错误是"SDK 已经接管了还手写一遍,同一个异常报两次";自己接反过来变成**"以为有人管,结果三个入口都没写"**,表现是后台一片安静、看起来 App 特别稳定。这比重复上报危险得多,见下文「验证接入真的成功了」。
|
||||
|
||||
### `ErrorReporter`:业务代码只见这一个接口
|
||||
|
||||
```dart
|
||||
// packages/core_logging/lib/src/error_reporter.dart
|
||||
abstract interface class ErrorReporter {
|
||||
void setUser(String userId);
|
||||
void setTag(String key, String value);
|
||||
void leaveBreadcrumb(String message);
|
||||
void recordFlutterError(FlutterErrorDetails details);
|
||||
void recordError(Object error, StackTrace? stack, {bool fatal = false, Map<String, String> context = const {}});
|
||||
}
|
||||
```
|
||||
|
||||
`feature_*` 不直接 import `native_crash`,也不直接 import 神策。这一层的价值有三个:测试里能 mock 掉、`feature_*` 少一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则),以及**"一条异常同时进 Bugly 和神策"这个双写逻辑只写在一个地方**。
|
||||
|
||||
默认实现做的事:
|
||||
|
||||
```dart
|
||||
void recordError(Object error, StackTrace? stack, {bool fatal = false, Map<String, String> context = const {}}) {
|
||||
final type = error.runtimeType.toString();
|
||||
final scrubbed = scrub(stack.toString()); // 见「脱敏」
|
||||
final extra = {...context, ...currentTags}; // storeId / roleCode / route / traceId / flavor
|
||||
|
||||
// ① Bugly:进崩溃率口径,堆栈按 SDK 的长度上限截断
|
||||
_native.postException(type, scrub(error.toString()), truncate(scrubbed), extra);
|
||||
|
||||
// ② 神策:进错误看板,属性可查询、可分组
|
||||
_analytics.track('app_error', {
|
||||
'errorType': type,
|
||||
'errorMessage': scrub(error.toString()),
|
||||
'stackTrace': truncate(scrubbed),
|
||||
'fatal': fatal,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**双写只在这个方法里发生,业务侧永远只调一次 `recordError`。**
|
||||
|
||||
### 两边报同一件事,怎么不打架
|
||||
|
||||
同一条 Dart 异常在 Bugly 和神策各有一份,两边数字对不上是必然的(采样、投递时机、去重规则都不同)。所以先把口径定死,**不要指望两个数字相等**:
|
||||
|
||||
| | Bugly | 神策 `app_error` |
|
||||
|---|---|---|
|
||||
| 看什么 | **崩溃率、稳定性趋势、版本对比** | **错误明细、按门店/角色/路由下钻、和业务事件关联** |
|
||||
| 权威口径 | ✅ 对外汇报稳定性用这个 | 参考 |
|
||||
| 强项 | 原生崩溃自动符号化、设备/系统分布 | 自定义属性可查询,能和 `api_failed`、`h5_failed` 放在同一套分析里 |
|
||||
|
||||
**"这个版本稳不稳"看 Bugly,"这个错误是怎么来的"看神策。** 任何一份周报里不要把两个数字并排放,会引出解释不清的问题。
|
||||
|
||||
### 神策上的错误看板要自己搭
|
||||
|
||||
神策不是崩溃平台,`app_error` 对它来说就是一个普通事件。所以下面这些是**必须自己做的二次开发**,排期里要留出来:
|
||||
|
||||
- **事件属性先在神策后台建好**(`errorType`、`errorMessage`、`stackTrace`、`fatal`、`route`、`traceId`)。属性没预先定义,上报上去会被丢弃或者变成不可分组的字符串。
|
||||
- **`stackTrace` 必须截断**。神策的字符串属性有长度上限,超长属性会被截断甚至导致整条事件入库失败。约定**只取前若干帧**(建议 20 帧)而不是按字符数硬切——按字符切会把最关键的顶层帧留下、底层调用链切没,反而是切错了方向。具体上限值以所用神策版本的文档为准(见待确认项)。
|
||||
- **看板**:错误数趋势、`errorType` TOP N、影响设备数、按 `appVersion` 对比、按 `storeId` 分布。
|
||||
- **告警**:新版本发布后错误数突增、某个 `errorType` 首次出现。神策的预警功能够用,不需要另做。
|
||||
|
||||
**`stackTrace` 不做去重聚合是这套方案最大的短板。** 崩溃平台会自动把同一个错误的不同实例归并成一个 issue,神策没有这个能力——只能靠 `errorType` + 堆栈首帧拼一个 `errorGroup` 属性自己分组。**这个 `errorGroup` 要在客户端算好再上报**,放到神策里用公式算不出来。
|
||||
|
||||
### 用户与门店上下文
|
||||
|
||||
会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)):
|
||||
|
||||
```dart
|
||||
ErrorReporter.instance
|
||||
..setUser(session.user.userId) // 只传 ID,不传手机号/姓名
|
||||
..setTag('storeId', '${session.store.storeId}')
|
||||
..setTag('roleCode', session.user.roleCode)
|
||||
..setTag('flavor', env.flavorName);
|
||||
```
|
||||
|
||||
内部同时落到两侧:`setUser` 走 Bugly 的 `setUserId` 和神策的 `login`,`setTag` 走 Bugly 的 `putUserData` 和神策的超级属性。**这也是「一次调用、两处生效」只写在 `ErrorReporter` 里的原因**——散到调用点去写,迟早有一侧漏掉。
|
||||
|
||||
**`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 ErrorReporter _reporter;
|
||||
|
||||
@override
|
||||
void didPush(Route route, Route? previous) =>
|
||||
_reporter.leaveBreadcrumb('nav: ${route.settings.name}');
|
||||
}
|
||||
```
|
||||
|
||||
**这里没有 SDK 自带的面包屑可用**,环形缓冲是我们自己的:Bugly 侧靠 `log()` 写进崩溃附加日志,神策侧作为 `app_error` 的 `breadcrumbs` 属性带上(同样要截断)。当前路由名另外单独作为 `route` 属性上报——它是错误看板里最常用的分组维度,埋在一段拼接文本里就没法分组了。
|
||||
|
||||
注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。
|
||||
|
||||
### 上报什么、不上报什么
|
||||
|
||||
- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。
|
||||
- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。
|
||||
|
||||
### 验证接入真的成功了
|
||||
|
||||
崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。自己接三个捕获入口之后,这个风险比用现成 SDK 时更高,所以验证是硬要求:
|
||||
|
||||
- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS。
|
||||
- **三个入口要分别触发、分别验证**:build 期抛错(`FlutterError.onError`)、`Future` 里抛错不 catch(`runZonedGuarded`)、原生侧主动 crash(Bugly)。只测一个入口是通不过的——漏写的那个恰恰是没被测到的那个。
|
||||
- **每次验证都要确认两侧都收到了**:Bugly 后台有这条崩溃、神策里有对应的 `app_error` 事件。只对一侧就等于双写逻辑没验证。
|
||||
- **验证堆栈是不是可读的**——这一步比"能收到"更容易漏。打一个 release 包(按上文的 `--split-debug-info` 且**不带** `--obfuscate`)触发异常,确认上报回来的是 `OrderRepository.submit` 而不是 `_x12`。看到 `_x12` 就说明构建参数被改回去了。
|
||||
- uat 环境跑一个迭代后,对一下 Bugly 的错误数和神策里 `app_error` 的数量级,差得离谱说明有一侧漏了。
|
||||
|
||||
## 二、日志规范
|
||||
|
||||
### `AppLogger`
|
||||
|
||||
```dart
|
||||
// packages/core_logging/lib/src/app_logger.dart
|
||||
abstract interface class AppLogger {
|
||||
void d(String message, {Map<String, Object?>? data});
|
||||
void i(String message, {Map<String, Object?>? 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 退出即消失,避免日志文件成为新的泄漏面。实现上由 `ErrorReporter.recordError` 在上报时取出,Bugly 侧走 `log()` 写进崩溃附加日志、神策侧作为事件属性带上;两边都有长度上限,所以实际带的是**最近 30 条**,不是全部 500 条。
|
||||
|
||||
### 脱敏(PRD REQ-NFR-019 日志脱敏)
|
||||
|
||||
```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。
|
||||
- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志、异常 message 和堆栈都会随崩溃一起传上去。
|
||||
|
||||
**脱敏必须做在 `ErrorReporter` 的出口,不是各调用点。** 自己接上报有一个隐蔽的好处:没有任何 SDK 会背着我们自动记录 HTTP 面包屑,我们在 05/10 里保证的"URL 不落日志"不会被某个默认行为绕过去。但反过来,**凡是要送出去的东西都得自己过一遍脱敏器**,没有 SDK 的钩子兜底:
|
||||
|
||||
```dart
|
||||
// packages/core_logging/lib/src/scrubber.dart
|
||||
/// 异常 message 与堆栈里同样可能出现 URL、手机号、车牌
|
||||
String scrub(String raw) => raw
|
||||
.replaceAllMapped(_urlPattern, (m) => Uri.parse(m[0]!).replace(query: '').toString())
|
||||
.replaceAllMapped(_phonePattern, (m) => maskPhone(m[0]!));
|
||||
```
|
||||
|
||||
最容易漏的是**异常 message 本身**:`DioException` 的 `toString()` 里带着完整 URL(含 `ticket`),原样上报等于把 token 发出去。`recordError` 里对 `error.toString()` 和 `stack.toString()` 都要跑一遍 `scrub`,见上文的实现。
|
||||
|
||||
### 不采集什么
|
||||
|
||||
出于合规(个人信息保护法「最小必要」原则)和 PRD REQ-NFR-019:
|
||||
|
||||
| 项 | 结论 |
|
||||
|---|---|
|
||||
| 崩溃截图 / 页面录制 / 控件树 | **不采集**。收银、经营分析页面上有金额和客户信息 |
|
||||
| 精确位置 | 不采集。App 没有需要精确位置的功能 |
|
||||
| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;**Bugly 默认也会采集设备信息,同样要逐项过一遍并按隐私政策裁剪** |
|
||||
| 通讯录、短信 | 不申请权限 |
|
||||
| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) |
|
||||
|
||||
这份清单要和 App 的隐私政策(PRD REQ-LGN-005,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Bugly 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**,否则「同意前不采集」这条硬要求就破了。
|
||||
|
||||
**Bugly 这一条要特别注意**:它在原生侧初始化(见上文 `native_crash`),"延迟到同意之后"不是 Dart 里加个 if 就行的——原生侧要读一个本地标记位来决定初不初始化,标记位由 Dart 在用户同意后写入并持久化。**这个标记位一旦漏写,表现是"合规过不了"而不是"崩溃收不到",测不出来。**
|
||||
|
||||
## 三、埋点:以后端为主
|
||||
|
||||
**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。**
|
||||
|
||||
理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 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。哪一种更合适取决于后端的数仓现状,一并列进待确认项。
|
||||
|
||||
### 分工
|
||||
|
||||
| 埋点事件 | 谁来出 | 说明 |
|
||||
|---|---|---|
|
||||
| 登录成功/失败 | **后端** | 登录本身就是接口调用 |
|
||||
| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 |
|
||||
| 门店切换 | **后端** | 切换接口 |
|
||||
| 采购下单 | **后端** | |
|
||||
| 入库成功 | **后端** | |
|
||||
| 待办点击 | **后端** | 点击后会请求详情接口 |
|
||||
| **扫码成功/失败** | **客户端** | 扫码是 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 里不多见。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。
|
||||
|
||||
**注意本项目里神策承担了两件事**:客户端行为埋点(本节),以及上文的错误明细与看板(`app_error` 事件)。后者是二次开发出来的,不是神策的现成能力——**排期要按"两块工作"算**。
|
||||
|
||||
#### 神策不是"开箱即用",这些活一样要干
|
||||
|
||||
先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**:
|
||||
|
||||
- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 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<String, Object?> params = const {}]);
|
||||
void registerSuperProperties(Map<String, Object?> props); // 公共属性,注册一次全局附加
|
||||
void identify(String userId); // 登录成功后调
|
||||
void reset(); // 登出时调
|
||||
}
|
||||
```
|
||||
|
||||
理由和 `ErrorReporter` 一样:测试里能 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) 的 13 项)里也没有埋点这一项。
|
||||
|
||||
### 命名约定
|
||||
|
||||
`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。
|
||||
|
||||
事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。
|
||||
|
||||
## 四、性能指标
|
||||
|
||||
| 指标 | 怎么测 | 目标 |
|
||||
|---|---|---|
|
||||
| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s |
|
||||
| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s |
|
||||
| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD REQ-NFR-005 要求定义 H5 白屏超时阈值) |
|
||||
| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s |
|
||||
| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — |
|
||||
|
||||
**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。
|
||||
|
||||
## 五、和后端审计日志的分工
|
||||
|
||||
`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。
|
||||
|
||||
| | 客户端埋点 | 服务端日志/审计 |
|
||||
|---|---|---|
|
||||
| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 |
|
||||
| 可信度 | 参考 | 权威 |
|
||||
| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 |
|
||||
|
||||
## 待确认项
|
||||
|
||||
- **release 到底混不混淆**——本篇最需要拍板的一条。本文的决策是**不混淆**(只 `--split-debug-info`),换来 Dart 堆栈直接可读;要和安全侧确认能否接受 Dart 符号暴露。**若安全侧坚持混淆,就要接受每条 Dart 崩溃人工 `flutter symbolize` 的排查成本**,[08-build-flavors.md](./08-build-flavors.md) 的构建参数需一并改回。
|
||||
- **Bugly 的 appId / appKey 与项目划分**:dev/uat/prod 是三个 Bugly 产品还是一个产品靠版本号区分(建议 prod 单独一个,避免测试崩溃污染线上崩溃率)。
|
||||
- **神策的数据接收地址与项目划分**:私有化部署还是神策云,dev/uat/prod 怎么分。地址走 `--dart-define-from-file`,代码里不写死。
|
||||
- **神策字符串属性的实际长度上限**(决定 `stackTrace` 截多少帧),以所用神策版本的官方文档为准,接入时实测确认。
|
||||
- **`errorGroup` 的分组算法**:`errorType` + 堆栈首帧够不够用,还是要按包名过滤掉框架帧再取第一条业务帧。这一条直接决定错误看板可不可用,建议接入后拿真实数据调一轮。
|
||||
- 神策原生 SDK 与 Bugly SDK 的默认设备信息采集项(`distinct_id` 生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。
|
||||
- 两个 SDK 都要在**用户同意隐私政策之后**才初始化。Bugly 在原生侧初始化,需要一个由 Dart 写入、原生读取的持久化标记位,具体时机要和 `feature_auth` 的协议弹窗流程对齐。
|
||||
- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。
|
||||
- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。
|
||||
- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把埋点事件逐条落到接口上。
|
||||
- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [腾讯 Bugly](https://bugly.qq.com/)(崩溃上报,原生侧)
|
||||
- [Bugly Android SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-android/)
|
||||
- [Bugly iOS SDK 接入文档](https://bugly.qq.com/docs/user-guide/instruction-manual-ios/)
|
||||
- [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)
|
||||
- [Flutter: 错误处理(`FlutterError.onError` / `PlatformDispatcher.onError`)](https://docs.flutter.dev/testing/errors)
|
||||
- [logger | Dart package](https://pub.dev/packages/logger)
|
||||
- [backend/08-observability.md:traceId 与审计日志](../backend/08-observability.md)
|
||||
- [PRD 第 6.8 节 埋点 / 第 8.3 节 可用性与容错](../prd/Continental-Retail-APP-PRD.md)
|
||||
+741
-495
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"drawio-skill": {
|
||||
"source": "agents365-ai/drawio-skill",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/drawio-skill/SKILL.md",
|
||||
"computedHash": "5311fcbaa3d27077d5464024536e8665b714f633a964ca6b95ef697e7c76a0c1"
|
||||
},
|
||||
"prd": {
|
||||
"source": "github/awesome-copilot",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/prd/SKILL.md",
|
||||
"computedHash": "1f3e1f005fc40fda85d250ae906c8ee4f7e8e833e23adfeca6542c19609fd7cd"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user