diff --git a/.gitignore b/.gitignore index 2f489a5..f21680a 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,8 @@ desktop.ini # agents .claude .agents - +CLAUDE.md +AGENTS.md # origin-files origin-prd/ \ No newline at end of file diff --git a/13-observability-analytics.md b/13-observability-analytics.md deleted file mode 100644 index 0b6e92a..0000000 --- a/13-observability-analytics.md +++ /dev/null @@ -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 extra = const {}}); -} -``` - -这一层不是为了"将来可能换 Sentry"——**是为了测试里能直接 mock 掉,不必真的初始化 SDK**,以及让 `feature_*` 不多一条对三方 SDK 的直接依赖(见 [01-project-structure.md](./01-project-structure.md) 的依赖规则)。 - -### 符号表:唯一必须打通的一步 - -`sentry_dart_plugin` 包装 `sentry-cli`,构建后一条命令把 Dart 符号表、Android mapping、iOS dSYM 一起传上去: - -```yaml -# pubspec.yaml -sentry: - upload_debug_symbols: true - upload_source_maps: false # 不做 Web - project: conti-retail-app - org: continental - # auth_token 走 CI 环境变量 SENTRY_AUTH_TOKEN,不写进仓库 -``` - -```bash -# CI:build 之后立刻跑,见 08 -fvm flutter build appbundle --flavor prod --target lib/main_prod.dart \ - --dart-define-from-file=env/prod.json \ - --obfuscate --split-debug-info=build/symbols/$CI_COMMIT_TAG -fvm dart run sentry_dart_plugin -``` - -三条硬约束: - -- **`options.release` 必须和上传符号表时的 release 严格一致**,对不上的表现是"符号表传上去了,堆栈还是混淆的"——这是接入 Sentry 最常见的坑,且后台不会报错。统一由 `versionName+versionCode` 生成(见 08 的版本号规则)。 -- **上传步骤必须在 CI 里、紧跟 build**,不能靠人工。漏传一次,那个版本的崩溃就永久读不出来。 -- **本地符号表归档照旧保留**(08 要求 ≥1 年)。Sentry 能自动还原之后它不再是唯一手段,但仍是 Sentry 服务出问题/数据过期时的兜底。 - -### 必须关掉的默认行为 - -Sentry 的默认配置面向公网 C 端产品,有几项在门店场景下不能开: - -| 项 | 结论 | -|---|---| -| `sendDefaultPii` | **false**。开了会自动带上 IP、请求头(含 `Authorization`)、用户信息 | -| Session Replay / 截图(`attachScreenshot`) | **关闭**。收银、经营分析页面上有金额和客户信息 | -| `attachViewHierarchy` | 关闭。控件树里会出现输入框内容 | -| `tracesSampleRate` | **0.0**,首版不开性能追踪。接口耗时后端已有(见 backend/08),开了只是多一份跨境流量 | -| HTTP 面包屑里的 URL | **必须脱敏**:H5 URL 的 query 里带着 `ticket`,原样进面包屑等于把 token 发出去,见「脱敏」一节 | - -### 用户与门店上下文 - -会话状态变化时同步(见 [11-store-context-and-session.md](./11-store-context-and-session.md)): - -```dart -CrashReporter.instance - ..setUser(session.user.userId) // 只传 ID,不传手机号/姓名 - ..setTag('storeId', '${session.store.storeId}') - ..setTag('roleCode', session.user.roleCode) - ..setTag('flavor', env.flavorName); -``` - -**`storeId` 一定要带。** 它能直接回答"这个崩溃是不是只发生在某几家门店"——门店设备型号和网络环境高度集中,很多崩溃是设备相关的,没有这个维度只能盲猜。 - -`traceId` 在网络相关的错误上报时作为自定义字段带上,这样一条崩溃能直接关联到后端 ELK 里的那次请求(见 [backend/08-observability.md](./backend/08-observability.md))。 - -### 崩溃前的页面路径 - -崩溃报告里最有用的上下文之一是"崩之前用户在哪几个页面"。go_router 的 `observers` 挂一个 `NavigationObserver`(见 [04-routing.md](./04-routing.md)),把最近的路由变化写进环形缓冲,随崩溃一起上报: - -```dart -class NavigationObserver extends NavigatorObserver { - NavigationObserver(this._reporter); - final CrashReporter _reporter; - - @override - void didPush(Route route, Route? previous) => - _reporter.leaveBreadcrumb('nav: ${route.settings.name}'); -} -``` - -注意**记的是路由名不是完整 URL**——`/webview?target=X&ticket=...` 里带着票据(见脱敏一节)。同理再补三处业务关键节点:H5 启动/失败、门店切换、扫码。这三条链路最长、最容易出问题。 - -### 上报什么、不上报什么 - -- **上报**:未捕获的 Dart 异常、原生崩溃、ANR、Riverpod provider 抛出的异常(通过 `ErrorObserver`,即使 UI 已经优雅处理了——"用户看到了漂亮的错误页"和"不需要知道有多少人看到"是两回事,见 12)。 -- **不上报**:`RequestCancelledException`(用户正常退出页面)、`UnauthorizedException`(正常的登出流程)。这两类是业务流程的一部分,上报只会把真正的崩溃淹掉。 - -### 验证接入真的成功了 - -崩溃上报最常见的失败模式是**静默不上报**——数据没传上来,但你以为 App 很稳定。所以: - -- `core_logging` 暴露一个 `throwTestException()`,**只在 dev flavor 下可调**,每次发版前在 dev 上验证一遍 Android 和 iOS 都能在 Sentry 里看到。 -- **同时验证堆栈是不是可读的**——这一步比"能收到"更容易漏。用 `--obfuscate` 打一个 release 包、跑一遍 `sentry_dart_plugin`、再触发一次异常,确认后台显示的是 Dart 文件名行号而不是 `_x12`。`release` 对不上的话就是这个表现,见上文。 -- uat 环境跑一个迭代后,对一下"Sentry 上的错误数"和"埋点里的 `api_failed` 数量级",如果差得离谱说明有一侧漏了。 - -## 二、日志规范 - -### `AppLogger` - -```dart -// packages/core_logging/lib/src/app_logger.dart -abstract interface class AppLogger { - void d(String message, {Map? data}); - void i(String message, {Map? data}); - void w(String message, {Object? error, StackTrace? stackTrace}); - void e(String message, {Object? error, StackTrace? stackTrace}); -} -``` - -各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。 - -### 级别与环境 - -| 环境 | 级别 | 输出 | -|---|---|---| -| dev | `debug` | 控制台,带颜色和调用栈 | -| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) | -| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 | - -**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。 - -**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上挂在 `beforeSend` 里作为 `contexts` 附加,单个事件体积有上限,所以实际带的是**最近 30 条**,不是全部 500 条。 - -### 脱敏(PRD §21.3「敏感字段脱敏」) - -```dart -// packages/core_logging/lib/src/scrubber.dart -const _sensitiveKeys = { - 'token', 'accessToken', 'refreshToken', 'ticket', 'password', - 'code', // 短信验证码 - 'phone', 'mobile', 'idCard', 'bankCard', -}; - -String maskPhone(String v) => v.length >= 11 ? '${v.substring(0, 3)}****${v.substring(7)}' : '***'; -``` - -规则: - -- **请求/响应体不整体打日志**。只打 method、path、状态码、耗时、`code`、`traceId`。真要看 body 只在 dev 下开,且过一遍脱敏器。 -- **`Authorization` 头永远不打**,一个字符都不打——打前 8 位也不行,那既足够辅助暴力破解,也足够在日志里认出是谁的 token。 -- **H5 URL 打日志前必须去掉 query**:URL 里带着 `ticket`,整条打出去等于打 token。 -- 崩溃上报前再做一遍同样的脱敏——环形缓冲里的日志会随崩溃一起传上去。 - -**同一个脱敏器要挂到 Sentry 的两个钩子上**,这是上文 `SentryFlutter.init` 里 `beforeBreadcrumb` / `beforeSend` 的实现: - -```dart -// packages/core_logging/lib/src/sentry_scrubber.dart -Breadcrumb? scrubBreadcrumb(Breadcrumb? crumb, Hint hint) { - if (crumb == null) return null; - // SDK 自动记录的 HTTP 面包屑里 url 是完整的,query 里可能带 ticket / token - final url = crumb.data?['url']; - if (url is String) { - final u = Uri.tryParse(url); - crumb.data?['url'] = u == null ? '' : u.replace(query: '').toString(); - } - return crumb; -} -``` - -**`beforeBreadcrumb` 不能漏。** Sentry 默认会自动记录所有 HTTP 请求作为面包屑,我们在 05/10 里辛苦保证的"URL 不落日志",会被这条默认行为绕过去——它不走我们的 `AppLogger`。 - -### 不采集什么 - -出于合规(个人信息保护法「最小必要」原则)和 PRD §21.3: - -| 项 | 结论 | -|---|---| -| 崩溃截图 / Session Replay / View Hierarchy | **关闭**。收银、经营分析页面上有金额和客户信息 | -| 精确位置 | 不采集。App 没有需要精确位置的功能 | -| IMEI / IDFA / MAC / AndroidID | **不采集**(见 05,`X-Device-Id` 用的是匿名安装 UUID)。**神策原生 SDK 默认会采集设备标识来生成 `distinct_id`,必须在初始化时逐项关掉**;Sentry 侧靠 `sendDefaultPii = false` | -| 通讯录、短信 | 不申请权限 | -| 用户输入的原文 | 不打日志(包括搜索关键词里可能出现的车牌、手机号) | - -这份清单要和 App 的隐私政策(PRD §10.3,由 App Backend 下发)**逐条对齐**——隐私政策里没写的,代码里就不能采。**第三方 SDK 的默认采集行为是最容易在合规审查时出问题的地方**:神策和 Sentry 的隐私说明都要单独过一遍,并且要在**用户同意隐私政策之前不初始化**(两个 SDK 都支持延迟初始化),否则「同意前不采集」这条硬要求就破了。 - -## 三、埋点:以后端为主 - -**大部分业务埋点由后端从自己的请求日志和审计日志里出,客户端不重复做一遍。** - -理由很直接:任何一个业务动作(登录、切店、下单、入库、打开 H5)都会打到 App Backend 的接口上,后端已经有 `traceId`、用户上下文、门店上下文和 `@Audited` 审计通道(见 [backend/08-observability.md](./backend/08-observability.md))。客户端再报一遍,得到的是同一件事的两份数据——而且客户端那份还更不可靠(可能丢、可能延迟、可能被篡改)。 - -**这两份数据最好落到同一个地方。** 神策有服务端 SDK / 数据导入接口,后端把业务事件写进同一个神策项目的话,运营就能做「扫码失败的门店,后续下单转化率是不是更低」这种跨端漏斗;分成两套系统也能跑,但每次跨端分析都要人工对数。**这一条要和后端确认**,见待确认项。 - -### Metabase 不是神策的替代品 - -后端侧提到过 Metabase。**它和神策不冲突,也不是二选一**——两者根本不在一层: - -| | 神策 | Metabase | -|---|---|---| -| 客户端采集 SDK | **有** | **没有**,它不采集任何数据 | -| 数据来源 | 自己的 SDK / 服务端导入 | 接已有的数据库、数仓 | -| 定位 | 采集 + 管道 + 分析平台 | BI / 看板层 | - -Metabase 官网自己把 Mixpanel、PostHog、Amplitude 列为**上游集成**——由那些工具负责采集,Metabase 在导出的数据上出图。这就说明了它的位置。 - -所以合理的分工是:**神策收客户端事件;后端的业务埋点本来就在自己库里,Metabase 接上去出报表。** 后端如果已经在用 Metabase,那是个好消息而不是冲突信号——它意味着上面「两份数据落到一个地方」这条有了第二种解法:不把业务数据推进神策,而是反过来把神策的客户端事件导出到同一个库,用 Metabase 统一出图,还能直接和订单、门店主数据 join。哪一种更合适取决于后端的数仓现状,一并列进待确认项。 - -### 分工 - -| PRD §22.1 事件 | 谁来出 | 说明 | -|---|---|---| -| 登录成功/失败 | **后端** | 登录本身就是接口调用 | -| 首页曝光 | **后端** | 首页聚合接口的调用即曝光 | -| 门店切换 | **后端** | 切换接口 | -| 采购下单 | **后端** | | -| 入库成功 | **后端** | | -| 待办点击 | **后端** | 点击后会请求详情接口 | -| **扫码成功/失败** | **客户端** | 扫码是 App 原生实现(见 07),**不产生任何请求**,后端完全看不到 | -| **H5 关闭 / 异常** | **客户端** | 「打开」有 `/h5/launch` 请求后端能看到;**关闭、白屏、超时、加载失败后端看不到** | -| 客服点击 | **客户端** | 拨号、企微二维码是纯客户端行为 | - -**客户端埋点的判据只有一条:这件事会不会产生一次后端请求?不会,才由客户端上报。** - -### 客户端事件表 - -按上面的判据筛下来,客户端只需要这几个: - -| 事件 | 触发 | 关键参数 | -|---|---|---| -| `scan_succeeded` / `scan_failed` | 扫码结果 | `mode`(barcode/vin/plate)、`durationMs`、`failReason` | -| `h5_closed` | H5 页关闭 | `target`、`stayDurationMs` | -| `h5_failed` | 白屏 / 超时 / 加载失败 | `target`、`errorCode`、`elapsedMs`、`traceId` | -| `h5_first_paint` | H5 首屏完成 | `target`、`ticketMs`(换票耗时)、`loadMs`(页面加载耗时) | -| `support_clicked` | 客服入口点击 | `channel`(hotline/dealer/o2o) | -| `api_failed` | 请求失败 | `path`、`code`、`httpStatus`、`traceId` | -| `app_cold_start` | 冷启动完成 | `durationMs` | -| `logout` | 登出 | `reason`(userInitiated / tokenExpired / sessionRevoked)。**被动登出没有对应的接口调用**,见 [11](./11-store-context-and-session.md) | -| `session_restore_failed` | 冷启动恢复会话失败 | 失败阶段(读 storage / me / stores)。卡在读 secure storage 时不产生任何网络请求 | - -两条说明: - -- **`h5_first_paint` 必须把耗时拆成 `ticketMs` 和 `loadMs` 两段**。合成一个数字的话,慢了不知道该找 App Backend / F6 / 还是网络——这是这个 App 里最长的一条跨系统链路,也是最容易互相甩锅的地方。 -- **`api_failed` 客户端也要报**,虽然后端也能看到失败。因为**后端看不到"请求根本没发出去"和"响应没收到"**:超时、连接失败、DNS 失败、运营商劫持,这些在后端日志里要么完全没有记录,要么表现为一次正常的成功响应。门店网络不稳时这类失败占大头。 - -### 实现约定:神策 SDK,外面包一层 - -客户端埋点走**神策 `sensors_analytics_flutter_plugin`**:官方 verified publisher `sensorsdata.cn`,`4.2.3` 一个多月前发布,是当前维护中的官方插件——这在 pub.dev 上的国内三方 SDK 里不多见(对比 Bugly 那两个 unverified 社区插件)。**团队过往项目用过,事件模型和数据接入的坑踩过一遍**,这是选它最实在的理由。 - -#### 神策不是"开箱即用",这些活一样要干 - -先把预期摆正,否则排期一定会低估。**接了神策之后,下面这些工作量和自建一套上报是完全一样的**: - -- **事件方案设计**——事件名、属性、口径对齐。这才是埋点的大头,跟用什么 SDK 无关。 -- **`core_analytics` 的接口封装**(见下)。 -- **接入点的编排**——超级属性什么时候注册、切店后重注册、登录/登出的 ID 关联。神策给了 API,但在哪调是我们的事(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 -- **私有化部署的运维**(如果走私有化)。 - -**神策真正替我们省掉的只有一件具体的事:客户端的可靠投递。** 原生 SDK 自带本地缓存、批量上报、弱网重传、后台 flush 和进程被杀后的补发。门店网络不稳,这个模块不能省,自己写的话是**容易写得看起来对、实际在丢数据**的那一类——丢了还不会有人发现。这一条就是选现成 SDK 的全部收益,其余都要照做。 - -#### 依赖与封装 - -```yaml -dependencies: - sensors_analytics_flutter_plugin: ^4.2.3 -``` - -业务代码仍然只见 `core_analytics` 的接口,不直接 import 神策: - -```dart -// packages/core_analytics/lib/src/analytics.dart -abstract interface class Analytics { - void track(String event, [Map params = const {}]); - void registerSuperProperties(Map props); // 公共属性,注册一次全局附加 - void identify(String userId); // 登录成功后调 - void reset(); // 登出时调 -} -``` - -理由和 `CrashReporter` 一样:测试里能 mock,`feature_*` 不多一条对三方 SDK 的直接依赖。**另外它也是采购未落地时的缓冲**——接口先定、事件方案先做,实现类换成一个最小的 `POST /api/v1/events/batch` 也只改一个文件(代价就是上面那条可靠投递要自己补)。 - -接入约定: - -- **公共属性用「超级属性」注册一次,不在每个调用点手写**:`storeId`、`roleCode`、`flavor`、`appVersion`、`buildNumber`。`storeId` 尤其重要——运营侧几乎所有分析都按门店维度看,靠每个调用点自己传一定会漏。**门店切换后必须重新注册**(见 [11-store-context-and-session.md](./11-store-context-and-session.md) 的级联清单)。 -- **登录/登出走 `login()` / `logout()`**:登录成功后用后端的 `userId` 关联匿名 ID,登出时断开,否则同一台设备上换人登录的数据会串到一起(门店设备是共用的,这个场景一定会发生)。 -- **埋点失败绝不能影响业务**:`track()` 内部 try-catch 兜住,任何异常只记日志不外抛。 -- **dev/uat 与 prod 必须分开**——独立项目,或至少用不同的数据接收地址。共用一个项目的话,测试数据会直接污染运营报表,且事后无法剔除。 - -#### 全埋点(AutoTrack):只开启动/退出,其余关掉 - -神策的全埋点支持 `APP_START` / `APP_END` / `APP_CLICK` / `APP_VIEW_SCREEN` 四类。我们的结论: - -| 类型 | 结论 | -|---|---| -| `APP_START` / `APP_END` | **开**。启动次数、使用时长是零成本拿到的基础指标 | -| `APP_CLICK` | **关**。Flutter 的控件树没有原生 `id`/`resource-name`,采上来的元素标识基本不可读,是纯噪音 | -| `APP_VIEW_SCREEN` | **关**。改用我们自己的 `NavigationObserver` 上报路由名——既更准,也**避免把 `/webview?target=X&ticket=...` 整条 URL 采上去**(见脱敏一节) | - -“少采一点”在这里不是保守,是因为**采上来读不懂的数据比没有更糟**:它会让报表看起来有数据,实际没法用。 - -#### H5 内部的埋点不归我们 - -F6 的 H5 页面是外部系统,页面内部的行为埋点由 F6 自己负责。**客户端只报容器级事件**(打开/关闭/失败/首屏耗时),不往 WebView 里注入神策的 JS SDK——注进去就等于我们要为别人页面里的数据质量负责,而且 JSBridge 的能力清单([10-webview-h5.md](./10-webview-h5.md) 的 12 项)里也没有埋点这一项。 - -### 命名约定 - -`snake_case`,`对象_动作` 或 `对象_动作_结果`。结果类用过去式(`succeeded`/`failed`),动作类用现在式(`clicked`)。 - -事件名和参数名一旦上线**不再改**——改名意味着历史数据断裂,运营报表要重做。要加维度就加参数。事件名统一定义为常量(`AnalyticsEvent.scanSucceeded`),不允许在调用处写字符串字面量:拼写错误编译期发现不了,在报表里表现为"这个事件怎么没数据"。 - -## 四、性能指标 - -| 指标 | 怎么测 | 目标 | -|---|---|---| -| 冷启动到首帧 | `WidgetsBinding.instance.addTimingsCallback` | < 2s | -| 冷启动到首页可用 | `main()` → 首页数据渲染完成 | < 3s | -| H5 打开耗时 | `h5_first_paint` 的 `ticketMs + loadMs` | < 3s(PRD §21.1 要求有超时策略) | -| 接口耗时 | 后端侧统计即可,客户端不重复报 | P95 < 1s | -| 帧率 | 先不做自动采集,用 DevTools 人工测关键页面 | — | - -**接口耗时不由客户端报**:后端有完整的请求日志和 Micrometer 指标(见 backend/08)。客户端唯一能补充的是"客户端观测到的耗时 - 服务端处理耗时 = 网络耗时",这个差值有价值但不是首版必须,先不做。 - -## 五、和后端审计日志的分工 - -`backend/08-observability.md` 已经有 `@Audited` 审计日志通道。**审计以服务端为准,客户端不做审计**——客户端日志可被篡改,不能作为审计依据。 - -| | 客户端埋点 | 服务端日志/审计 | -|---|---|---| -| 目的 | 补齐后端看不到的行为 | 业务分析、合规追溯 | -| 可信度 | 参考 | 权威 | -| 覆盖 | 纯客户端行为、请求失败 | 所有到达服务端的操作 | - -## 待确认项 - -- **Sentry 是自建还是用 SaaS**——这是本篇最硬的阻塞项,决定可达性和数据出境合规口径。需要明确内网资源、运维承接方、法务意见。见上文「唯一还没定的」。 -- Sentry 的 org/project 划分:dev/uat/prod 是三个 project 还是靠 `environment` 区分(建议 prod 单独一个 project,避免测试数据污染线上崩溃率告警)。 -- **公司有没有在用的神策服务?** 本项目没有现成账号。有的话拿数据接收地址即可;**没有的话开通神策是采购流程,不是配置项**,周期可能比开发长。这一项是埋点唯一的外部依赖——**但它不阻塞开工**:事件方案设计和 `core_analytics` 接口先做,这两块工作量与最终用什么 SDK 无关。真的走不通,实现类换成最小的 `POST /api/v1/events/batch`,代价是可靠投递要自己补。 -- 若确认用神策:数据接收地址是私有化部署还是神策云,以及 dev/uat/prod 的项目划分。地址走 `--dart-define-from-file`,代码里不写死。 -- **客户端事件和后端业务数据怎么汇到一起**:是后端用神策服务端 SDK 写进同一个神策项目,还是把神策的客户端事件导出到后端数仓、统一用 Metabase 出图。取决于后端数仓现状和 Metabase 的实际使用情况,要和后端一起定。 -- 神策原生 SDK 的默认设备信息采集项(`distinct_id` 的生成方式、是否取 AndroidID/IDFA),需逐项关闭并与隐私政策对齐(法务侧)。 -- **后端的业务埋点写不写进同一个神策项目**(用神策服务端 SDK / 数据导入),还是留在自己的 ELK 里出报表。影响的是能不能做跨端漏斗分析。 -- 后端从请求日志出业务埋点的具体口径(哪个接口对应哪个事件),需要和后端一起把 PRD §22.1 的 9 类事件逐条落到接口上。 -- 神策和 Sentry 都要在**用户同意隐私政策之后**才初始化,具体的延迟初始化时机要和 `feature_auth` 的协议弹窗流程对齐。 -- 性能指标目标值需在真机(门店常用的中低端 Android)实测后校准,上表是初始预期值。 - -## 参考链接 - -- [sentry_flutter | Dart package](https://pub.dev/packages/sentry_flutter) -- [sentry_dart_plugin | Dart package](https://pub.dev/packages/sentry_dart_plugin)(上传 Dart 符号表 / mapping / dSYM) -- [Sentry: Flutter Debug Symbols](https://docs.sentry.io/platforms/dart/guides/flutter/debug-symbols/) -- [Sentry: 自建(self-hosted)](https://develop.sentry.dev/self-hosted/) -- [sensors_analytics_flutter_plugin | Dart package](https://pub.dev/packages/sensors_analytics_flutter_plugin) -- [神策:Flutter 插件集成文档](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_plugin/v0300) -- [神策:Flutter 全埋点](https://manual.sensorsdata.cn/sa/docs/tech_sdk_client_flutter_auto_track/v0205) -- [Metabase](https://www.metabase.com/)(BI 层,非采集方案;官网把 Mixpanel/PostHog/Amplitude 列为上游采集集成) -- [Flutter: 混淆与 `flutter symbolize`](https://docs.flutter.dev/deployment/obfuscate) -- [logger | Dart package](https://pub.dev/packages/logger) -- [backend/08-observability.md:traceId 与审计日志](./backend/08-observability.md) -- [PRD §21.4 可观测性 / §22.1 埋点](./Architecture-Diagram/202606-Continental-Retail-APP-PRD.md) diff --git a/Architecture-Diagram/extract_component_data_to_md.py b/Architecture-Diagram/extract_component_data_to_md.py deleted file mode 100644 index e44ab44..0000000 --- a/Architecture-Diagram/extract_component_data_to_md.py +++ /dev/null @@ -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() diff --git a/Architecture-Diagram/extract_requirements_to_md.py b/Architecture-Diagram/extract_requirements_to_md.py deleted file mode 100644 index d054cc0..0000000 --- a/Architecture-Diagram/extract_requirements_to_md.py +++ /dev/null @@ -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() diff --git a/README.md b/README.md index 0155ae3..83fbe6b 100644 --- a/README.md +++ b/README.md @@ -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 接入流水线的时点。 ## 语言约定 diff --git a/backend/02-layering.md b/backend/02-layering.md index 43e59da..b98830e 100644 --- a/backend/02-layering.md +++ b/backend/02-layering.md @@ -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/ diff --git a/backend/04-security-auth.md b/backend/04-security-auth.md index c090697..35a6b70 100644 --- a/backend/04-security-auth.md +++ b/backend/04-security-auth.md @@ -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 diff --git a/backend/05-integration-layer.md b/backend/05-integration-layer.md index ed048de..07452ce 100644 --- a/backend/05-integration-layer.md +++ b/backend/05-integration-layer.md @@ -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)——那不属于单个客户端的职责。 diff --git a/backend/06-api-design.md b/backend/06-api-design.md index 8c74979..c5cdc6e 100644 --- a/backend/06-api-design.md +++ b/backend/06-api-design.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))。 - 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。 ## 参考链接 diff --git a/backend/08-observability.md b/backend/08-observability.md index 8087da6..b5d507d 100644 --- a/backend/08-observability.md +++ b/backend/08-observability.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` 头,所以中间需要一层桥接: **契约(同时解决客户端文档里挂着的那条待确认项)**: diff --git a/01-project-structure.md b/flutter-app/01-project-structure.md similarity index 97% rename from 01-project-structure.md rename to flutter-app/01-project-structure.md index 9648481..e64f315 100644 --- a/01-project-structure.md +++ b/flutter-app/01-project-structure.md @@ -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) 的模块划分列出,实际开工时按迭代顺序逐个建,不需要一次性全建出来。 ## 依赖规则(编译期强制边界,是这套结构的核心价值) diff --git a/02-layering.md b/flutter-app/02-layering.md similarity index 97% rename from 02-layering.md rename to flutter-app/02-layering.md index 2c11e0b..e80efa8 100644 --- a/02-layering.md +++ b/flutter-app/02-layering.md @@ -75,7 +75,7 @@ dev_dependencies: ## 后端统一响应包装在哪一层解开 -后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](./backend/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**: +后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](../backend/06-api-design.md))。**解包统一发生在 `core_network` 的拦截器里,不在各 feature 的 repository 里重复写**: - `code == 0` → 把 `data` 取出来交给 repository,repository 的 `fromJson` 只需要认识 `data` 的结构,完全不用感知外层包装。 - `code != 0` → 直接抛 `BusinessException(code, message, traceId)`。 @@ -85,7 +85,7 @@ dev_dependencies: ## 分页的统一约定 -PRD §21.1 要求列表页支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名: +列表页要支持分页/分段加载。repository 层的分页方法统一签名,不让每个 feature 各自发明一套参数名: ```dart // core_network 里定义的通用分页类型 diff --git a/03-state-management.md b/flutter-app/03-state-management.md similarity index 95% rename from 03-state-management.md rename to flutter-app/03-state-management.md index 315eb80..005e3f5 100644 --- a/03-state-management.md +++ b/flutter-app/03-state-management.md @@ -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> storeList(Ref ref) async { ## 门店切换 / 登出时的批量失效 -PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。 +PRD REQ-LGN-010(门店上下文)要求切换门店时级联失效所有门店相关缓存与在途请求——购物车、待办、预警、订单上下文全部跟着切。落到 Riverpod 上,**不能靠每个 feature 自己去监听门店变化**——总会漏掉一个,而漏掉的表现是"用户在 A 门店看到 B 门店的数据",属于严重问题。 统一做法:所有与门店相关的 provider 都 `ref.watch(currentStoreIdProvider)`,让 Riverpod 的依赖图自己完成级联失效。 diff --git a/04-routing.md b/flutter-app/04-routing.md similarity index 94% rename from 04-routing.md rename to flutter-app/04-routing.md index 6d1a151..39ec4fa 100644 --- a/04-routing.md +++ b/flutter-app/04-routing.md @@ -87,7 +87,7 @@ final goRouterProvider = Provider((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=&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 门店可能不存在,或者更糟,存在但是另一张单。 **规则:切换门店成功后,清空导航栈回工作台。** diff --git a/05-networking.md b/flutter-app/05-networking.md similarity index 97% rename from 05-networking.md rename to flutter-app/05-networking.md index 016c574..901d744 100644 --- a/05-networking.md +++ b/flutter-app/05-networking.md @@ -24,7 +24,7 @@ dependencies: ## 后端契约:统一响应包装 -后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](./backend/06-api-design.md))。**解包只在 `ApiResultInterceptor` 里做一次**,repository 拿到的 `response.data` 已经是里层的 `data`。 +后端所有接口返回 `ApiResult { code, message, data, traceId }`(见 [backend/06-api-design.md](../backend/06-api-design.md))。**解包只在 `ApiResultInterceptor` 里做一次**,repository 拿到的 `response.data` 已经是里层的 `data`。 ```dart // packages/core_network/lib/src/api_result_interceptor.dart @@ -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> purchaseOrders(Ref ref) async { ## 文件与图片上传 -PRD §7.4(H5 桥接的图片选择/上传)和施工照片场景都要用到。 +PRD 第 7.3 节的 JSBridge 能力清单(H5 桥接的图片选择/上传)和施工照片场景都要用到。 ```dart // packages/core_network/lib/src/api_client.dart diff --git a/06-local-storage.md b/flutter-app/06-local-storage.md similarity index 97% rename from 06-local-storage.md rename to flutter-app/06-local-storage.md index 943b07f..efae5c3 100644 --- a/06-local-storage.md +++ b/flutter-app/06-local-storage.md @@ -44,7 +44,7 @@ dependencies: ## 所有业务缓存表必须带 `storeId` -PRD §11.4 要求切换门店后购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。 +PRD REQ-LGN-010(门店上下文)要求切换门店时级联失效所有门店相关缓存——购物车、待办、预警、订单上下文全部跟着切。本地缓存是最容易漏的一环——provider 失效了,但 Drift 里 A 门店的数据还在,切到 B 门店离线打开页面就会读到 A 门店的数据。 **硬性规则**:任何缓存业务数据的表都必须有 `storeId` 列,并且 diff --git a/07-native-integration.md b/flutter-app/07-native-integration.md similarity index 71% rename from 07-native-integration.md rename to flutter-app/07-native-integration.md index 8ba96fc..cb29f3f 100644 --- a/07-native-integration.md +++ b/flutter-app/07-native-integration.md @@ -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}); diff --git a/08-build-flavors.md b/flutter-app/08-build-flavors.md similarity index 66% rename from 08-build-flavors.md rename to flutter-app/08-build-flavors.md index 2714bc4..46a812c 100644 --- a/08-build-flavors.md +++ b/flutter-app/08-build-flavors.md @@ -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' ``` diff --git a/09-testing.md b/flutter-app/09-testing.md similarity index 100% rename from 09-testing.md rename to flutter-app/09-testing.md diff --git a/10-webview-h5.md b/flutter-app/10-webview-h5.md similarity index 91% rename from 10-webview-h5.md rename to flutter-app/10-webview-h5.md index f019efd..412c34e 100644 --- a/10-webview-h5.md +++ b/flutter-app/10-webview-h5.md @@ -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 _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 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) diff --git a/11-store-context-and-session.md b/flutter-app/11-store-context-and-session.md similarity index 90% rename from 11-store-context-and-session.md rename to flutter-app/11-store-context-and-session.md index 08179bb..4d9e0b3 100644 --- a/11-store-context-and-session.md +++ b/flutter-app/11-store-context-and-session.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 switchStore(int targetStoreId) async { ## 登出:清理清单 -PRD §10.4:「清理 Token、门店上下文、本地用户信息和缓存」+「关闭所有已打开的 F6 H5 会话」。 +PRD REQ-LGN-008(登出):清理本地会话、门店上下文、缓存的业务数据与 WebView Cookie。 ```dart Future 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) diff --git a/12-error-and-api-contract.md b/flutter-app/12-error-and-api-contract.md similarity index 85% rename from 12-error-and-api-contract.md rename to flutter-app/12-error-and-api-contract.md index a679319..240a292 100644 --- a/12-error-and-api-contract.md +++ b/flutter-app/12-error-and-api-contract.md @@ -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 → 显式声明可空时才允许 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) diff --git a/flutter-app/13-observability-analytics.md b/flutter-app/13-observability-analytics.md new file mode 100644 index 0000000..053c8d8 --- /dev/null +++ b/flutter-app/13-observability-analytics.md @@ -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 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 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 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? data}); + void i(String message, {Map? data}); + void w(String message, {Object? error, StackTrace? stackTrace}); + void e(String message, {Object? error, StackTrace? stackTrace}); +} +``` + +各包**不直接用 `logger` 包,也不用 `print`/`debugPrint`**,统一注入 `AppLogger`。理由:将来换日志实现只改一处;同时 `print` 在 release 下不会被剥离,是一条实打实的信息泄漏通道。 + +### 级别与环境 + +| 环境 | 级别 | 输出 | +|---|---|---| +| dev | `debug` | 控制台,带颜色和调用栈 | +| uat | `info` | 控制台 + 内存环形缓冲(最近 500 条) | +| prod | `warning` | **不输出到控制台**,只进内存环形缓冲 + 随崩溃上报 | + +**prod 不打控制台日志**:Android 上 `logcat` 是全局可读的,任何装了 adb 或第三方日志 App 的人都能看到。门店设备上这不是理论风险。 + +**内存环形缓冲**的作用是:崩溃时把最近 N 条日志一起传上去,相当于一个"黑匣子"。不落磁盘,App 退出即消失,避免日志文件成为新的泄漏面。实现上由 `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 params = const {}]); + void registerSuperProperties(Map 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) diff --git a/14-conventions-and-ci-gates.md b/flutter-app/14-conventions-and-ci-gates.md similarity index 100% rename from 14-conventions-and-ci-gates.md rename to flutter-app/14-conventions-and-ci-gates.md diff --git a/prd/Continental-Retail-APP-PRD.md b/prd/Continental-Retail-APP-PRD.md index 17d666d..4b6ca9d 100644 --- a/prd/Continental-Retail-APP-PRD.md +++ b/prd/Continental-Retail-APP-PRD.md @@ -18,7 +18,7 @@ | V0.9 | 2026-07 | 业务侧 | 初版需求分析文档 | | V1.0 | 2026-08 | — | 完整版产品需求文档 | -> **本文档规模**:20 个模块、191 条编号需求(其中 99 条待业务确认)、173 张图、65 张表。全部图片引用与章节内链已校验通过。 +> **本文档规模**:20 个模块、192 条编号需求(其中 99 条待业务确认)、173 张图、43 张表。需求描述与编号需求均以文本书写,表格只用于字段清单、矩阵与统计。全部图片引用与章节内链已校验通过。 --- @@ -141,12 +141,27 @@ **精简模板(6 维)** —— 适用于其余模块(4.7–4.15):业务目标 / 入口 / 页面内容 / 主流程 / 权限规则 / 数据来源。 +**书写形式:需求一律用文本描述,不用表格。** 上表只是维度的定义清单,正文里每个维度写成一个自然段,维度名加粗置于段首,后接破折号与内容: + +``` +**业务目标** —— 用一套账号替代 6 套小程序各自的登录,消除重复登录…… + +**目标角色** —— 店长、技工 +``` + +三个维度例外,写成列表而非段落,因为它们天然是多条并列项:**页面内容**(逐条列控件与信息)、**主流程**(有序列表,一步一行)、**异常流程**(每行一个「触发条件 → 处理方式」)。**状态变化**、**访问链路**涉及多个对象或多条链路时同样用列表,每行一条。 + +编号需求同理,写成 `**REQ-xxx-nnn 需求名** —— 规则内容` 的段落;需求下挂的提示或待确认说明另起一段,用 `>` 引用块承接,以免与下一条需求混淆。 + +之所以不用表格:需求正文长短差异大,塞进单元格后要靠 `
` 硬断行,Word 导出时列宽被迫压缩、长条目难以阅读;改成文本后段落可自由折行,编号仍在行首便于检索。表格只保留给**字段清单、矩阵、对照与统计**这类真正的二维数据。 + ### 1.8.2 图表约定 - 本文档为**独立交付件**,不引用其他文档的编号,全部结论就地写明; - 插图直接嵌在所属小节内,正文以「本节设计稿」「现状截图」等方式称呼,不使用图号; +- **需求描述与编号需求用文本,不用表格**,写法见 [1.8.1](#181-需求描述模板);表格只用于字段清单、矩阵、对照与统计; - 表格下方以一行文字说明其内容,不使用表号; -- 全部插图与表格的清单见[附录 C 图表清单](#附录-c-图表清单),可用于核对导出后的配图完整性。 +- 全部 173 张插图的清单见[附录 C 图表清单](#附录-c-图表清单),可用于核对导出后的配图完整性;同附录的表格清单收录正文中的实质性表格,附录内部的清单表不重复登记。 ### 1.8.3 图片来源标注 @@ -372,39 +387,86 @@ O2O 接单宝小程序功能清单(78 张截图) ### 4.1.1 需求描述 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 用一套账号替代 6 套小程序各自的登录,消除重复登录;同时建立可审核、可回收的账号管控机制,解决[痛点 2.2](#22-账号权限管控) | -| **目标角色** | 店长、技工 | -| **入口** | APP 冷启动且无有效会话;会话失效后的任意页面被动跳转 | -| **前置条件** | 账号已在 O2O 接单宝后台存在并通过审核;设备可访问网络 | -| **页面内容** | 品牌背景 + Continental Logo;手机号输入框;验证码输入框 + 「获取验证码」;「登录」主按钮;「密码登录」次按钮(切换到用户名 + 密码模式);「我要注册」文字入口;第三方登录区(微信、支付宝);底部协议勾选「已阅读并同意《用户协议》与《隐私政策》」 | -| **主流程** | ① 输入手机号 → ② 获取验证码 → ③ 输入验证码 → ④ 勾选协议 → ⑤ 点击登录 → ⑥ 后端校验通过下发 access / refresh token → ⑦ 拉取该账号的门店列表与角色 → ⑧ 单门店直接进入首页;多门店弹出门店选择 → ⑨ 进入 APP 首页 | -| **异常流程** | 手机号未注册 → 提示并引导「我要注册」;验证码错误 → 提示剩余可试次数;验证码超时 → 提示重新获取;未勾选协议 → 登录按钮不可用;账号被停用 → 提示联系门店管理员;账号无任何门店归属 → 阻断登录并提示;网络异常 → 保留已输入内容并允许重试 | -| **业务规则** | 见 [4.1.2](#412-业务规则) | -| **权限规则** | 登录本身不区分角色;登录成功后由后端下发角色(店长 / 技工)与该角色的可见 tab 集合、功能权限,见 [4.2.5](#425-导航收敛与角色化配置) 与[附录 B](#附录-b-权限矩阵) | -| **访问链路** | App → App Backend → O2O 后台(用户主数据校验);App Backend → 短信网关(验证码下发);App Backend → 马上下单(门店列表) | -| **逻辑数据来源** | 用户主数据:**O2O 接单宝后台**(手机号、用户名、密码、角色);门店列表:马上下单;协议内容:APP 后台管理 Web | -| **回写目标** | 登录日志、设备信息写入 App Backend;协议同意记录(版本号 + 时间戳)写入 App Backend | -| **状态变化** | 无会话 → 已登录(持有 access/refresh token)→ 已选定门店上下文 | -| **验收标准** | 见 [4.1.6](#416-验收标准) | +**业务目标** —— 用一套账号替代 6 套小程序各自的登录,消除重复登录;同时建立可审核、可回收的账号管控机制,解决[痛点 2.2](#22-账号权限管控) + +**目标角色** —— 店长、技工 + +**入口** —— APP 冷启动且无有效会话;会话失效后的任意页面被动跳转 + +**前置条件** —— 账号已在 O2O 接单宝后台存在并通过审核;设备可访问网络 + +**页面内容**: + +- 品牌背景 + Continental Logo +- 手机号输入框 +- 验证码输入框 + 「获取验证码」 +- 「登录」主按钮 +- 「密码登录」次按钮(切换到用户名 + 密码模式) +- 「我要注册」文字入口 +- 第三方登录区(微信、支付宝) +- 底部协议勾选「已阅读并同意《用户协议》与《隐私政策》」 + +**主流程**: + +1. 输入手机号 +2. 获取验证码 +3. 输入验证码 +4. 勾选协议 +5. 点击登录 +6. 后端校验通过下发 access / refresh token +7. 拉取该账号的门店列表与角色 +8. 单门店直接进入首页;多门店弹出门店选择 +9. 进入 APP 首页 + +**异常流程**: + +- 手机号未注册 → 提示并引导「我要注册」 +- 验证码错误 → 提示剩余可试次数 +- 验证码超时 → 提示重新获取 +- 未勾选协议 → 登录按钮不可用 +- 账号被停用 → 提示联系门店管理员 +- 账号无任何门店归属 → 阻断登录并提示 +- 网络异常 → 保留已输入内容并允许重试 + +**业务规则** —— 见 [4.1.2](#412-业务规则) + +**权限规则** —— 登录本身不区分角色;登录成功后由后端下发角色(店长 / 技工)与该角色的可见 tab 集合、功能权限,见 [4.2.5](#425-导航收敛与角色化配置) 与[附录 B](#附录-b-权限矩阵) + +**访问链路** —— App → App Backend → O2O 后台(用户主数据校验);App Backend → 短信网关(验证码下发);App Backend → 马上下单(门店列表) + +**逻辑数据来源** —— 用户主数据:**O2O 接单宝后台**(手机号、用户名、密码、角色);门店列表:马上下单;协议内容:APP 后台管理 Web + +**回写目标** —— 登录日志、设备信息写入 App Backend;协议同意记录(版本号 + 时间戳)写入 App Backend + +**状态变化** —— 无会话 → 已登录(持有 access/refresh token)→ 已选定门店上下文 + +**验收标准** —— 见 [4.1.6](#416-验收标准) ### 4.1.2 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-LGN-001 | 手机号验证码登录 | 手机号为 11 位中国大陆号码;验证码 6 位数字,有效期 5 分钟;同一手机号 60 秒内只能获取一次;单日获取上限 `TODO(REQ-LGN-001)` | -| REQ-LGN-002 | 用户名密码登录 | 用户名与密码沿用 O2O 注册时设置的凭据;密码在传输与存储全程不可逆 | -| REQ-LGN-003 | 第三方登录 | 支持微信、支付宝授权登录。首次授权需绑定已有手机号账号后方可进入;未绑定账号不允许直接创建新账号。
⚠️ 该需求仅见于设计稿 —— `TODO(REQ-LGN-003)` 需确认是否纳入本期 | -| REQ-LGN-004 | 注册与审核 | 设计稿含「我要注册」入口。注册后账号处于「待审核」状态,需门店店长或后台管理员审核通过方可登录,以解决[痛点 2.2](#22-账号权限管控) 的「随意注册」问题。
⚠️ 注册表单字段、审核人、审核时效均未定义 —— `TODO(REQ-LGN-004)` | -| REQ-LGN-005 | 协议展示与留痕 | 用户协议、隐私政策内容由后台管理 Web 维护,经法务审核;用户点击可查看全文;同意时记录协议版本号与同意时间;协议版本更新后需重新征得同意 | -| REQ-LGN-006 | 密码策略 | 密码长度、复杂度、有效期、历史密码不可复用条数 —— `TODO(REQ-LGN-006)`。忘记密码通过手机号验证码重置 | -| REQ-LGN-007 | 登录失败锁定 | 连续登录失败达到阈值后锁定账号一段时间。阈值与锁定时长 —— `TODO(REQ-LGN-007)` | -| REQ-LGN-008 | 登出 | 用户主动登出时清理本地会话、门店上下文、缓存的业务数据与 WebView Cookie,见《App 门店上下文与会话管理文档》 | -| REQ-LGN-009 | 会话与 Token | access token 短期有效,refresh token 轮换续期;refresh 失效后跳转登录页。轮换策略见《后端安全与认证文档》。具体有效期 —— `TODO(REQ-LGN-009)` | -| REQ-LGN-010 | 门店上下文 | 登录后必须确定唯一「当前门店」;切换门店时级联失效所有门店相关缓存与在途请求,见《App 门店上下文与会话管理文档》 | +**REQ-LGN-001 手机号验证码登录** —— 手机号为 11 位中国大陆号码;验证码 6 位数字,有效期 5 分钟;同一手机号 60 秒内只能获取一次;单日获取上限 `TODO(REQ-LGN-001)` -账号登录业务规则 +**REQ-LGN-002 用户名密码登录** —— 用户名与密码沿用 O2O 注册时设置的凭据;密码在传输与存储全程不可逆 + +**REQ-LGN-003 第三方登录** —— 支持微信、支付宝授权登录。首次授权需绑定已有手机号账号后方可进入;未绑定账号不允许直接创建新账号。 + +> ⚠️ 该需求仅见于设计稿 —— `TODO(REQ-LGN-003)` 需确认是否纳入本期 + +**REQ-LGN-004 注册与审核** —— 设计稿含「我要注册」入口。注册后账号处于「待审核」状态,需门店店长或后台管理员审核通过方可登录,以解决[痛点 2.2](#22-账号权限管控) 的「随意注册」问题。 + +> ⚠️ 注册表单字段、审核人、审核时效均未定义 —— `TODO(REQ-LGN-004)` + +**REQ-LGN-005 协议展示与留痕** —— 用户协议、隐私政策内容由后台管理 Web 维护,经法务审核;用户点击可查看全文;同意时记录协议版本号与同意时间;协议版本更新后需重新征得同意 + +**REQ-LGN-006 密码策略** —— 密码长度、复杂度、有效期、历史密码不可复用条数 —— `TODO(REQ-LGN-006)`。忘记密码通过手机号验证码重置 + +**REQ-LGN-007 登录失败锁定** —— 连续登录失败达到阈值后锁定账号一段时间。阈值与锁定时长 —— `TODO(REQ-LGN-007)` + +**REQ-LGN-008 登出** —— 用户主动登出时清理本地会话、门店上下文、缓存的业务数据与 WebView Cookie,见《App 门店上下文与会话管理文档》 + +**REQ-LGN-009 会话与 Token** —— access token 短期有效,refresh token 轮换续期;refresh 失效后跳转登录页。轮换策略见《后端安全与认证文档》。具体有效期 —— `TODO(REQ-LGN-009)` + +**REQ-LGN-010 门店上下文** —— 登录后必须确定唯一「当前门店」;切换门店时级联失效所有门店相关缓存与在途请求,见《App 门店上下文与会话管理文档》 ### 4.1.3 店长 @@ -463,22 +525,42 @@ APP 首页是用户登录成功后展示的第一个页面。不同角色因权 ### 4.2.1 需求描述 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 把分散在三套小程序的入口、待办、经营数据聚合到一屏,让门店开工第一眼就知道「今天有什么要做、有什么要盯」,解决[痛点 2.1](#21-多小程序分散) 与 [2.5](#25-数据经营分析) | -| **目标角色** | 店长、技工(内容差异见 [4.2.3](#423-店长首页)、[4.2.4](#424-技工首页)) | -| **入口** | 登录成功后默认落地;任意页面点击底部「首页」tab | -| **前置条件** | 已登录且已确定当前门店上下文 | -| **页面内容** | 见 [4.2.3](#423-店长首页)、[4.2.4](#424-技工首页) | -| **主流程** | ① 进入首页 → ② 并行拉取门店信息、待办、预警、促销、经营卡片 → ③ 分区渲染,任一分区失败不阻塞其它分区 → ④ 用户点击分区进入对应模块 | -| **异常流程** | 单个数据源超时/失败 → 该卡片显示占位与「重试」,其余正常展示(局部降级,见《后端跨域协作与聚合文档》);门店未接 F6 → 隐藏依赖 F6 的分区与 tab;无待办 → 显示空态而非隐藏分区 | -| **业务规则** | 见 [4.2.6](#426-业务规则) | -| **权限规则** | 底部 tab 集合、卡片可见性均由 App Backend 按「角色 + 门店能力」下发,客户端不硬编码,见 [4.2.5](#425-导航收敛与角色化配置) | -| **访问链路** | App → App Backend(聚合)→ 并行 fan-out 至 O2O / ROOS / 延保 / CDMS / F6 | -| **逻辑数据来源** | 见 [4.2.7](#427-业务数据列表) | -| **回写目标** | 消息已读状态回写 App Backend;其余为只读聚合 | -| **状态变化** | 消息:未读 → 已读;待办项计数随源系统单据状态变化 | -| **验收标准** | 见 [4.2.8](#428-验收标准) | +**业务目标** —— 把分散在三套小程序的入口、待办、经营数据聚合到一屏,让门店开工第一眼就知道「今天有什么要做、有什么要盯」,解决[痛点 2.1](#21-多小程序分散) 与 [2.5](#25-数据经营分析) + +**目标角色** —— 店长、技工(内容差异见 [4.2.3](#423-店长首页)、[4.2.4](#424-技工首页)) + +**入口** —— 登录成功后默认落地;任意页面点击底部「首页」tab + +**前置条件** —— 已登录且已确定当前门店上下文 + +**页面内容** —— 见 [4.2.3](#423-店长首页)、[4.2.4](#424-技工首页) + +**主流程**: + +1. 进入首页 +2. 并行拉取门店信息、待办、预警、促销、经营卡片 +3. 分区渲染,任一分区失败不阻塞其它分区 +4. 用户点击分区进入对应模块 + +**异常流程**: + +- 单个数据源超时/失败 → 该卡片显示占位与「重试」,其余正常展示(局部降级,见《后端跨域协作与聚合文档》) +- 门店未接 F6 → 隐藏依赖 F6 的分区与 tab +- 无待办 → 显示空态而非隐藏分区 + +**业务规则** —— 见 [4.2.6](#426-业务规则) + +**权限规则** —— 底部 tab 集合、卡片可见性均由 App Backend 按「角色 + 门店能力」下发,客户端不硬编码,见 [4.2.5](#425-导航收敛与角色化配置) + +**访问链路** —— App → App Backend(聚合)→ 并行 fan-out 至 O2O / ROOS / 延保 / CDMS / F6 + +**逻辑数据来源** —— 见 [4.2.7](#427-业务数据列表) + +**回写目标** —— 消息已读状态回写 App Backend;其余为只读聚合 + +**状态变化** —— 消息:未读 → 已读;待办项计数随源系统单据状态变化 + +**验收标准** —— 见 [4.2.8](#428-验收标准) ### 4.2.2 现状导航与目标导航的差异 @@ -529,7 +611,7 @@ APP 首页是用户登录成功后展示的第一个页面。不同角色因权 当客户车开进马牌门店时,用户可以直接点击「扫码」按钮,对准客户车头识别车辆号牌,进入销售流程。 -> 扫码由 **App 原生能力**实现(`native_scan`),非 F6 提供的扫码页,见 [C5](#10-风险与待确认项)。车牌识别属于 OCR 场景,技术路径尚未验证,见[第 10 章风险 R3](#104-风险登记)。 +> 扫码由 **App 原生能力**实现(`native_scan`),非 F6 提供的扫码页,见 [C5](#10-风险与待确认项)。**车牌识别走云端 OCR 服务**:拍一张照上传识别,不是取景框里的实时识别,因而**依赖网络**——「输码」不是失败后的降级,而是与「扫码」并列的常驻入口。见[第 10 章风险 R9](#104-风险登记)。 **六、输码** @@ -652,23 +734,31 @@ APP 首页是用户登录成功后展示的第一个页面。不同角色因权 ### 4.2.6 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-HOM-001 | 门店切换 | 多门店账号可在首页顶部切换门店;切换后级联刷新全部数据,见《App 门店上下文与会话管理文档》 | -| REQ-HOM-002 | 问候语 | 由「姓氏 + 角色称谓 + 时段问候」拼接;时段划分口径 `TODO(REQ-HOM-002)` | -| REQ-HOM-003 | 消息公告 | 角标为未读数;点击进入列表后清零。消息分类体系待定 `TODO(REQ-HOM-003)` | -| REQ-HOM-004 | 促销信息 | 来源 ROOS 接口,APP 后台定时拉取;首页展示最近一次活动,往期在消息列表 | -| REQ-HOM-005 | 扫码接车 | App 原生扫码识别车牌 → 进入销售流程 | -| REQ-HOM-006 | 输码接车 | 扫码失败时手工输入车牌 → 进入销售流程 | -| REQ-HOM-007 | 快速核销 | 扫核销码或手工输入核销码 → 进入核销流程([4.3.4](#434-核销)) | -| REQ-HOM-008 | 待办事项 | 两层口径并存,最终展示形态待定 `TODO(REQ-HOM-008)` | -| REQ-HOM-009 | 动态预警 | 四项预警,阈值待定 `TODO(REQ-HOM-009)`;库存预警依赖 F6 | -| REQ-HOM-010 | 角色化导航 | tab 集合由后端按角色 + 门店能力下发,清单待定 `TODO(REQ-HOM-010)` | -| REQ-HOM-011 | 今日经营卡片 | 店长看金额类指标(可隐藏),技工看完工单数;口径待定 `TODO(REQ-HOM-011)` | -| REQ-HOM-012 | 当前施工队列 | 技工独有;分派规则与状态机待定 `TODO(REQ-HOM-012)` | -| REQ-HOM-013 | 分区降级 | 任一数据源失败仅该卡片降级,不影响其它分区渲染 | +**REQ-HOM-001 门店切换** —— 多门店账号可在首页顶部切换门店;切换后级联刷新全部数据,见《App 门店上下文与会话管理文档》 -APP 首页业务规则 +**REQ-HOM-002 问候语** —— 由「姓氏 + 角色称谓 + 时段问候」拼接;时段划分口径 `TODO(REQ-HOM-002)` + +**REQ-HOM-003 消息公告** —— 角标为未读数;点击进入列表后清零。消息分类体系待定 `TODO(REQ-HOM-003)` + +**REQ-HOM-004 促销信息** —— 来源 ROOS 接口,APP 后台定时拉取;首页展示最近一次活动,往期在消息列表 + +**REQ-HOM-005 扫码接车** —— App 原生扫码识别车牌 → 进入销售流程 + +**REQ-HOM-006 输码接车** —— 扫码失败时手工输入车牌 → 进入销售流程 + +**REQ-HOM-007 快速核销** —— 扫核销码或手工输入核销码 → 进入核销流程([4.3.4](#434-核销)) + +**REQ-HOM-008 待办事项** —— 两层口径并存,最终展示形态待定 `TODO(REQ-HOM-008)` + +**REQ-HOM-009 动态预警** —— 四项预警,阈值待定 `TODO(REQ-HOM-009)`;库存预警依赖 F6 + +**REQ-HOM-010 角色化导航** —— tab 集合由后端按角色 + 门店能力下发,清单待定 `TODO(REQ-HOM-010)` + +**REQ-HOM-011 今日经营卡片** —— 店长看金额类指标(可隐藏),技工看完工单数;口径待定 `TODO(REQ-HOM-011)` + +**REQ-HOM-012 当前施工队列** —— 技工独有;分派规则与状态机待定 `TODO(REQ-HOM-012)` + +**REQ-HOM-013 分区降级** —— 任一数据源失败仅该卡片降级,不影响其它分区渲染 ### 4.2.7 业务数据列表 @@ -703,22 +793,59 @@ APP 首页信息字段 ### 4.3.1 需求描述 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 把「车开进门店」到「结算完成并办理延保」的全链路装进一个 APP,消除 F6、O2O、延保三系统间的重复录入,解决[痛点 2.6](#26-售后延保) | -| **目标角色** | 店长;技工(授权后功能一致,见 [4.3.8](#438-角色差异)) | -| **入口** | 首页「扫码 / 车牌」(接车);首页「快速核销」(线上订单核销);底部导航进入订单列表 | -| **前置条件** | 已登录并确定门店上下文;接车与施工链路要求**门店已接入 F6**;核销链路要求门店已开通 O2O | -| **页面内容** | 见 [4.3.2](#432-接车与车辆识别)–[4.3.7](#437-结算与延保跳转) | -| **主流程** | ① 扫码/输码识别车牌 → ② 后端凭车牌向 F6 取车主车辆信息 → ③ 展示历史工单与延保历史 → ④ 新建工单 / 检测开单 → ⑤ 施工查车 → ⑥ 生成检测报告并发送车主 → ⑦ 检测单转工单 → ⑧ 完工 → ⑨ 结算收银 → ⑩ 结算后跳转延保 | -| **异常流程** | 车牌识别失败 → 转手工输码;F6 无该车档案 → 进入新建车辆建档流程;门店未接 F6 → 隐藏历史工单、销售商机、施工查车、结算等 F6 分区;F6 接口超时 → 提示重试且不生成半截单据;核销码无效/已核销/过期 → 分别给出可区分的错误提示 | -| **业务规则** | 见 [4.3.9](#439-业务规则) | -| **权限规则** | 技工被分配销售权限后功能与店长一致;未授权技工不可见销售入口。金额类信息(商品总价、实收金额)对技工的可见性 —— `TODO(REQ-SAL-012)` | -| **访问链路** | 车辆信息/工单/检测/结算:App → App Backend → **F6 Integration Adapter** → F6;F6 页面以 **Embedded H5** 形式嵌入,原生能力经 JSBridge 提供(见《App Embedded H5 容器与 JSBridge 文档》)
线上订单/核销/服务单:App → App Backend → O2O
延保历史:App → App Backend → 延保后台 | -| **逻辑数据来源** | 车主车辆、历史工单、检测、工单、结算:**F6**;销售商机(服务提醒 + 意向池):**F6**;延保历史:**延保后台**;线上订单、服务单、核销:**O2O** | -| **回写目标** | 工单、检测单、结算单回写 F6;核销结果回写 O2O;延保建单回写延保后台 | -| **状态变化** | 车辆:未到店 → **已在店** → 离店
工单:接车 → 开单 → 施工 → 完工 → 已结算
检测单:待检 → 已检(正常/异常)→ 转工单 / 转商机
线上订单:待接单 → 调货中 → 待安装 → 待配送 → 配送中 → 已完成 | -| **验收标准** | 见 [4.3.10](#4310-验收标准) | +**业务目标** —— 把「车开进门店」到「结算完成并办理延保」的全链路装进一个 APP,消除 F6、O2O、延保三系统间的重复录入,解决[痛点 2.6](#26-售后延保) + +**目标角色** —— 店长;技工(授权后功能一致,见 [4.3.8](#438-角色差异)) + +**入口** —— 首页「扫码 / 车牌」(接车);首页「快速核销」(线上订单核销);底部导航进入订单列表 + +**前置条件** —— 已登录并确定门店上下文;接车与施工链路要求**门店已接入 F6**;核销链路要求门店已开通 O2O + +**页面内容** —— 见 [4.3.2](#432-接车与车辆识别)–[4.3.7](#437-结算与延保跳转) + +**主流程**: + +1. 扫码/输码识别车牌 +2. 后端凭车牌向 F6 取车主车辆信息 +3. 展示历史工单与延保历史 +4. 新建工单 / 检测开单 +5. 施工查车 +6. 生成检测报告并发送车主 +7. 检测单转工单 +8. 完工 +9. 结算收银 +10. 结算后跳转延保 + +**异常流程**: + +- 车牌识别失败 → 转手工输码 +- F6 无该车档案 → 进入新建车辆建档流程 +- 门店未接 F6 → 隐藏历史工单、销售商机、施工查车、结算等 F6 分区 +- F6 接口超时 → 提示重试且不生成半截单据 +- 核销码无效/已核销/过期 → 分别给出可区分的错误提示 + +**业务规则** —— 见 [4.3.9](#439-业务规则) + +**权限规则** —— 技工被分配销售权限后功能与店长一致;未授权技工不可见销售入口。金额类信息(商品总价、实收金额)对技工的可见性 —— `TODO(REQ-SAL-012)` + +**访问链路**: + +- 车辆信息/工单/检测/结算:App → App Backend → **F6 Integration Adapter** → F6;F6 页面以 **Embedded H5** 形式嵌入,原生能力经 JSBridge 提供(见《App Embedded H5 容器与 JSBridge 文档》) +- 线上订单/核销/服务单:App → App Backend → O2O +- 延保历史:App → App Backend → 延保后台 + +**逻辑数据来源** —— 车主车辆、历史工单、检测、工单、结算:**F6**;销售商机(服务提醒 + 意向池):**F6**;延保历史:**延保后台**;线上订单、服务单、核销:**O2O** + +**回写目标** —— 工单、检测单、结算单回写 F6;核销结果回写 O2O;延保建单回写延保后台 + +**状态变化**: + +- 车辆:未到店 → **已在店** → 离店 +- 工单:接车 → 开单 → 施工 → 完工 → 已结算 +- 检测单:待检 → 已检(正常/异常)→ 转工单 / 转商机 +- 线上订单:待接单 → 调货中 → 待安装 → 待配送 → 配送中 → 已完成 + +**验收标准** —— 见 [4.3.10](#4310-验收标准) ### 4.3.2 接车与车辆识别 @@ -746,7 +873,7 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 | # | 字段 | 字段名 | 数据来源 | 说明 | | --- | --- | --- | --- | --- | -| 1 | 车牌 | CarPlate | 扫码获取 | | +| 1 | 车牌 | CarPlate | 拍照识别或手工输入 | 识别经云端 OCR,需联网;置信度低时以「待确认」展示 | | 2 | 车主姓名 | OwnerName | F6 接口获取 | | | 3 | 车架号 | VIN | F6 接口获取 | 支持一键复制 | | 4 | 里程 | Milage | F6 接口获取 | | @@ -896,24 +1023,33 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.3.9 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-SAL-001 | 交易流水号 | APP 按规则生成、业务保存时提交,保证唯一与幂等;规则待定 `TODO(REQ-SAL-001)` | -| REQ-SAL-002 | 车牌识别 | 原生扫码 + OCR 识别车牌;失败可转手工输码 | -| REQ-SAL-003 | 车辆信息获取 | 凭车牌向 F6 取车主、VIN、里程、最近到店时间;F6 无档案时进入建档 | -| REQ-SAL-004 | 历史工单 | 来自 F6;**门店未接 F6 则整个分区不显示** | -| REQ-SAL-005 | 延保历史 | 来自延保后台;与历史工单并列为两个 tab | -| REQ-SAL-006 | 销售商机 | 仅开通 F6 的门店可见;「服务提醒」与「意向池」来自 F6 | -| REQ-SAL-007 | 检测与工单 | 页面由 F6 提供(Embedded H5);App 负责容器、导航栏、原生能力桥接与登录态透传 | -| REQ-SAL-008 | 检测报告发送 | 支持车主微信 / 公众号 / 企业微信 / 短信等渠道;短信渠道需购买 | -| REQ-SAL-009 | 检测单转单 | 异常项可转维修单或转商机,二选一 | -| REQ-SAL-010 | 扫码核销 | 流程待定 `TODO(REQ-SAL-010)` | -| REQ-SAL-011 | 结算后跳延保 | F6 结算页增加跳转按钮;参数契约待定 `TODO(REQ-SAL-011)` | -| REQ-SAL-012 | 技工金额可见性 | 待定 `TODO(REQ-SAL-012)` | -| REQ-SAL-013 | 订单渠道维度 | 渠道清单是否可配置待定 `TODO(REQ-SAL-013)` | -| REQ-SAL-014 | 服务单与工单关系 | 待定 `TODO(REQ-SAL-014)` | +**REQ-SAL-001 交易流水号** —— APP 按规则生成、业务保存时提交,保证唯一与幂等;规则待定 `TODO(REQ-SAL-001)` -销售业务规则 +**REQ-SAL-002 车牌识别** —— **拍照上传至云端 OCR 识别**(经 App 后端代理),返回车牌号与置信度;置信度偏低时以「待确认」展示、由用户核对后再提交。**需联网**;**手工输码为常驻并列入口**,不是识别失败后的降级 + +**REQ-SAL-003 车辆信息获取** —— 凭车牌向 F6 取车主、VIN、里程、最近到店时间;F6 无档案时进入建档 + +**REQ-SAL-004 历史工单** —— 来自 F6;**门店未接 F6 则整个分区不显示** + +**REQ-SAL-005 延保历史** —— 来自延保后台;与历史工单并列为两个 tab + +**REQ-SAL-006 销售商机** —— 仅开通 F6 的门店可见;「服务提醒」与「意向池」来自 F6 + +**REQ-SAL-007 检测与工单** —— 页面由 F6 提供(Embedded H5);App 负责容器、导航栏、原生能力桥接与登录态透传 + +**REQ-SAL-008 检测报告发送** —— 支持车主微信 / 公众号 / 企业微信 / 短信等渠道;短信渠道需购买 + +**REQ-SAL-009 检测单转单** —— 异常项可转维修单或转商机,二选一 + +**REQ-SAL-010 扫码核销** —— 流程待定 `TODO(REQ-SAL-010)` + +**REQ-SAL-011 结算后跳延保** —— F6 结算页增加跳转按钮;参数契约待定 `TODO(REQ-SAL-011)` + +**REQ-SAL-012 技工金额可见性** —— 待定 `TODO(REQ-SAL-012)` + +**REQ-SAL-013 订单渠道维度** —— 渠道清单是否可配置待定 `TODO(REQ-SAL-013)` + +**REQ-SAL-014 服务单与工单关系** —— 待定 `TODO(REQ-SAL-014)` ### 4.3.10 验收标准 @@ -930,26 +1066,48 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > **重要前提**:提醒能力由 F6 承载,且**现状的提醒功能位于 F6 的 PC 端后台(会员营销 > 商机规则设置 / 服务提醒),而非移动端**。因此本模块在 APP 中的形态需要先做产品决策,见 [4.4.5](#445-移植范围待确认)。 +本模块分三步:**设置提醒规则 → 生成提醒单 → 跟进提醒单**,分别对应 [4.4.2](#442-设置提醒规则)–[4.4.4](#444-跟进提醒单)。 + ### 4.4.1 需求描述 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 基于车辆保养周期、保险到期、检测异常等规则自动生成提醒单,由服务顾问跟进转化,提升复购与到店率,支撑[痛点 2.4](#24-支付与营销) 的「客户分层营销」诉求 | -| **目标角色** | 店长(规则配置 + 跟进);技工(`TODO(REQ-RMD-005)` 是否参与跟进待定) | -| **入口** | `TODO(REQ-RMD-001)` —— 现状在 F6 PC 端,APP 内入口未定义 | -| **前置条件** | 门店已接入 F6;已有车辆与消费历史数据 | -| **页面内容** | 见 [4.4.2](#442-设置提醒规则)–[4.4.4](#444-跟进提醒单) | -| **主流程** | ① 设置提醒规则 → ② 车主到店消费 → ③ 车主完工离店 → ④ 生成提醒单并跟进 → ⑤ 临近服务日提醒车主(可自动发短信/微信)→ ⑥ 车主再次到店 | -| **异常流程** | 车主手机号缺失 → 无法发送短信/微信,仅支持电话提醒;短信额度不足 → 提示「未购短信,无法分享」并提供购买入口;规则冲突(同一车同一项目命中多条规则)→ `TODO(REQ-RMD-004)` | -| **业务规则** | 见 [4.4.6](#446-业务规则) | -| **权限规则** | 规则配置属门店管理职能,建议限店长;提醒单跟进可下放 `TODO(REQ-RMD-005)` | -| **访问链路** | App → App Backend → F6 Integration Adapter → F6;若采用 Embedded H5 方案则直接嵌入 F6 页面 | -| **逻辑数据来源** | **F6**(规则、提醒单、车辆与消费历史) | -| **回写目标** | 跟进动作(电话提醒 / 发送短信 / 发送微信 / 完成 / 转交)回写 F6 | -| **状态变化** | 提醒单:未处理 → 我未完成 / 我已完成 → 所有已完成 | -| **验收标准** | 见 [4.4.7](#447-验收标准) | +**业务目标** —— 基于车辆保养周期、保险到期、检测异常等规则自动生成提醒单,由服务顾问跟进转化,提升复购与到店率,支撑[痛点 2.4](#24-支付与营销) 的「客户分层营销」诉求 -三步流程:**设置提醒规则 → 生成提醒单 → 跟进提醒单**。 +**目标角色** —— 店长(规则配置 + 跟进);技工(`TODO(REQ-RMD-005)` 是否参与跟进待定) + +**入口** —— `TODO(REQ-RMD-001)` —— 现状在 F6 PC 端,APP 内入口未定义 + +**前置条件** —— 门店已接入 F6;已有车辆与消费历史数据 + +**页面内容** —— 见 [4.4.2](#442-设置提醒规则)–[4.4.4](#444-跟进提醒单) + +**主流程**: + +1. 设置提醒规则 +2. 车主到店消费 +3. 车主完工离店 +4. 生成提醒单并跟进 +5. 临近服务日提醒车主(可自动发短信/微信) +6. 车主再次到店 + +**异常流程**: + +- 车主手机号缺失 → 无法发送短信/微信,仅支持电话提醒 +- 短信额度不足 → 提示「未购短信,无法分享」并提供购买入口 +- 规则冲突(同一车同一项目命中多条规则)→ `TODO(REQ-RMD-004)` + +**业务规则** —— 见 [4.4.6](#446-业务规则) + +**权限规则** —— 规则配置属门店管理职能,建议限店长;提醒单跟进可下放 `TODO(REQ-RMD-005)` + +**访问链路** —— App → App Backend → F6 Integration Adapter → F6;若采用 Embedded H5 方案则直接嵌入 F6 页面 + +**逻辑数据来源** —— **F6**(规则、提醒单、车辆与消费历史) + +**回写目标** —— 跟进动作(电话提醒 / 发送短信 / 发送微信 / 完成 / 转交)回写 F6 + +**状态变化** —— 提醒单:未处理 → 我未完成 / 我已完成 → 所有已完成 + +**验收标准** —— 见 [4.4.7](#447-验收标准) ### 4.4.2 设置提醒规则 @@ -1014,16 +1172,17 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.4.6 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-RMD-001 | 移植范围 | 见 [4.4.5](#445-移植范围待确认),待确认 `TODO(REQ-RMD-001)` | -| REQ-RMD-002 | 规则数量上限 | 最多自定义 20 个提醒规则 | -| REQ-RMD-003 | 自动提醒 | 规则开启「是否自动提醒」后,临近服务日系统自动发送短信/微信 | -| REQ-RMD-004 | 规则冲突 | 同一车同一项目命中多条规则时的去重策略待定 `TODO(REQ-RMD-004)` | -| REQ-RMD-005 | 跟进权限 | 技工是否可跟进提醒单待定 `TODO(REQ-RMD-005)` | -| REQ-RMD-006 | 短信额度 | 短信为付费资源,额度不足时阻断发送并提供购买入口 | +**REQ-RMD-001 移植范围** —— 见 [4.4.5](#445-移植范围待确认),待确认 `TODO(REQ-RMD-001)` -提醒业务规则 +**REQ-RMD-002 规则数量上限** —— 最多自定义 20 个提醒规则 + +**REQ-RMD-003 自动提醒** —— 规则开启「是否自动提醒」后,临近服务日系统自动发送短信/微信 + +**REQ-RMD-004 规则冲突** —— 同一车同一项目命中多条规则时的去重策略待定 `TODO(REQ-RMD-004)` + +**REQ-RMD-005 跟进权限** —— 技工是否可跟进提醒单待定 `TODO(REQ-RMD-005)` + +**REQ-RMD-006 短信额度** —— 短信为付费资源,额度不足时阻断发送并提供购买入口 ### 4.4.7 验收标准 @@ -1048,13 +1207,15 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ![现状-延保 德国马牌零售商延保使用条款须知](mini-program-images/Warranty/德国马牌零售商延保使用条款须知.png) -| 维度 | 内容 | -| --- | --- | -| 目标角色 | 店长、技工(只读查看) | -| 入口 | 延保 tab 首页顶部「零售商延保使用条款须知」;保单详情页「保单协议」 | -| 权限规则 | 全角色可见;同意动作记录操作人 | -| 数据来源 | 延保后台 | -| 回写目标 | 条款同意时间戳回写延保后台 | +**目标角色** —— 店长、技工(只读查看) + +**入口** —— 延保 tab 首页顶部「零售商延保使用条款须知」;保单详情页「保单协议」 + +**权限规则** —— 全角色可见;同意动作记录操作人 + +**数据来源** —— 延保后台 + +**回写目标** —— 条款同意时间戳回写延保后台 ### 4.5.2 消费者激活与门店建单 @@ -1244,36 +1405,61 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.5.8 需求描述汇总 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 见各子节 | -| **目标角色** | 店长:全部功能;技工:建单、待办处理、保单查询、理赔受理(`TODO(REQ-WTY-003)` 需确认) | -| **入口** | 底部导航「延保」tab;销售结算页跳转([4.3.7](#437-结算与延保跳转));首页待办「延保视频上传提醒」 | -| **前置条件** | 已登录、已确定门店;门店已开通延保业务 | -| **主流程** | 建单:扫描/录入车牌 → 绑定车辆 → 上传装车视频 → 保单生效
理赔:扫描理赔码/预约码 → 受理 → 采集证据 → 上报 → 跟踪结果 | -| **异常流程** | 车牌识别失败 → 手动输入 / 特殊车牌录入;扫码不可用 → 手动输入预约码;无关联保单 → 走无用户信息鉴定通道(仅鉴定不理赔);上传失败 → 本地暂存并重试 | -| **权限规则** | 保单作废、返利查看建议限店长 —— `TODO(REQ-WTY-005)`、`TODO(REQ-WTY-004)` | -| **访问链路** | App → App Backend → 延保后台 | -| **逻辑数据来源** | 延保后台(保单、预约、理赔、鉴定、返利、教程) | -| **回写目标** | 建单、装车视频、理赔申请、鉴定资料、条款同意记录 → 延保后台 | -| **状态变化** | 保单:待补充 → 待确认 → 正常 → 已作废
预约:预约车检 → 已受理 → 已上报
鉴定单:待上传 → 正在进行 → 已完成 | +**业务目标** —— 见各子节 + +**目标角色** —— 店长:全部功能;技工:建单、待办处理、保单查询、理赔受理(`TODO(REQ-WTY-003)` 需确认) + +**入口** —— 底部导航「延保」tab;销售结算页跳转([4.3.7](#437-结算与延保跳转));首页待办「延保视频上传提醒」 + +**前置条件** —— 已登录、已确定门店;门店已开通延保业务 + +**主流程**: + +- 建单:扫描/录入车牌 → 绑定车辆 → 上传装车视频 → 保单生效 +- 理赔:扫描理赔码/预约码 → 受理 → 采集证据 → 上报 → 跟踪结果 + +**异常流程**: + +- 车牌识别失败 → 手动输入 / 特殊车牌录入 +- 扫码不可用 → 手动输入预约码 +- 无关联保单 → 走无用户信息鉴定通道(仅鉴定不理赔) +- 上传失败 → 本地暂存并重试 + +**权限规则** —— 保单作废、返利查看建议限店长 —— `TODO(REQ-WTY-005)`、`TODO(REQ-WTY-004)` + +**访问链路** —— App → App Backend → 延保后台 + +**逻辑数据来源** —— 延保后台(保单、预约、理赔、鉴定、返利、教程) + +**回写目标** —— 建单、装车视频、理赔申请、鉴定资料、条款同意记录 → 延保后台 + +**状态变化**: + +- 保单:待补充 → 待确认 → 正常 → 已作废 +- 预约:预约车检 → 已受理 → 已上报 +- 鉴定单:待上传 → 正在进行 → 已完成 ### 4.5.9 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-WTY-001 | 双保障并行 | 原厂质保与撞击延保互不替代;制造缺陷走原厂质保,外力撞击走延保换新 | -| REQ-WTY-002 | 待办归并 | 延保待办与首页待办的归并方式待定 `TODO(REQ-WTY-002)` | -| REQ-WTY-003 | 技工权限范围 | 技工可执行的延保操作范围待定 `TODO(REQ-WTY-003)` | -| REQ-WTY-004 | 返利可见性 | 延保返利对技工是否可见待定 `TODO(REQ-WTY-004)` | -| REQ-WTY-005 | 保单作废 | 高风险操作,需权限 + 状态校验 + 审计记录;具体管控待定 `TODO(REQ-WTY-005)` | -| REQ-WTY-006 | CATI 归属 | 延保与 O2O 两处 CATI 入口的关系待定 `TODO(REQ-WTY-006)` | -| REQ-WTY-007 | 无用户信息鉴定 | 仅做故障鉴定,**不可走延保理赔**;必采字段见 [4.5.5](#455-售后鉴定与证据采集) | -| REQ-WTY-008 | 消费者未认证兼容 | 该状态不影响延保返利及管理端查询;消费者后续扫码可找回保单 | -| REQ-WTY-009 | 保单筛选 | 支持激活起止日期 + 品牌(全部 / 马牌 / 维京) | -| REQ-WTY-010 | 条款同意留痕 | 记录条款版本与同意时间 | +**REQ-WTY-001 双保障并行** —— 原厂质保与撞击延保互不替代;制造缺陷走原厂质保,外力撞击走延保换新 -延保业务规则 +**REQ-WTY-002 待办归并** —— 延保待办与首页待办的归并方式待定 `TODO(REQ-WTY-002)` + +**REQ-WTY-003 技工权限范围** —— 技工可执行的延保操作范围待定 `TODO(REQ-WTY-003)` + +**REQ-WTY-004 返利可见性** —— 延保返利对技工是否可见待定 `TODO(REQ-WTY-004)` + +**REQ-WTY-005 保单作废** —— 高风险操作,需权限 + 状态校验 + 审计记录;具体管控待定 `TODO(REQ-WTY-005)` + +**REQ-WTY-006 CATI 归属** —— 延保与 O2O 两处 CATI 入口的关系待定 `TODO(REQ-WTY-006)` + +**REQ-WTY-007 无用户信息鉴定** —— 仅做故障鉴定,**不可走延保理赔**;必采字段见 [4.5.5](#455-售后鉴定与证据采集) + +**REQ-WTY-008 消费者未认证兼容** —— 该状态不影响延保返利及管理端查询;消费者后续扫码可找回保单 + +**REQ-WTY-009 保单筛选** —— 支持激活起止日期 + 品牌(全部 / 马牌 / 维京) + +**REQ-WTY-010 条款同意留痕** —— 记录条款版本与同意时间 ### 4.5.10 验收标准 @@ -1290,21 +1476,35 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.6.1 需求描述 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 门店在 App 内完成轮胎订货全流程(搜索 → 加购 → 结算 → 收货 → 售后),并保留 ROOS 的额度、返利、优惠券结算能力,解决[痛点 2.3](#23-进销存--erp-数据) | -| **目标角色** | 店长:全部;技工:**需被授权**后功能同店长 | -| **入口** | 底部导航「采购」tab;首页快捷入口「采购」 | -| **前置条件** | 已登录、已确定门店;门店在 ROOS 有有效的经销商/信用账户 | -| **页面内容** | 商品搜索与树形分类 → 商品列表 → 购物车 → 订单确认(支付方式)→ 我的订单(五状态)→ 订单详情 → 售后/退款;扫码收货 | -| **主流程** | 搜索商品 → 加入购物车 → 调整数量并勾选 → 结算 → 选择支付方式 → 提交订单 → 扫码收货 | -| **异常流程** | 信用额度不足 → 提示并阻断提交;商品下架/无库存 → 列表置灰;扫码收货条码不匹配 → 提示并拒收 | -| **业务规则** | 见 [4.6.7](#467-业务规则) | -| **权限规则** | 技工默认无采购权限,需店长/后台授权([REQ-PUR-001](#467-业务规则)) | -| **访问链路** | App → App Backend → ROOS | -| **逻辑数据来源** | ROOS(商品主数据、价格、库存、购物车、订单、售后、额度、支付方式) | -| **回写目标** | 购物车、采购订单、支付方式选择、收货确认、售后申请 → ROOS | -| **状态变化** | 采购订单:待支付 → 待发货 → 已发货 →(收货)完成;可取消 → 已取消 | +**业务目标** —— 门店在 App 内完成轮胎订货全流程(搜索 → 加购 → 结算 → 收货 → 售后),并保留 ROOS 的额度、返利、优惠券结算能力,解决[痛点 2.3](#23-进销存--erp-数据) + +**目标角色** —— 店长:全部;技工:**需被授权**后功能同店长 + +**入口** —— 底部导航「采购」tab;首页快捷入口「采购」 + +**前置条件** —— 已登录、已确定门店;门店在 ROOS 有有效的经销商/信用账户 + +**页面内容** —— 商品搜索与树形分类 → 商品列表 → 购物车 → 订单确认(支付方式)→ 我的订单(五状态)→ 订单详情 → 售后/退款;扫码收货 + +**主流程** —— 搜索商品 → 加入购物车 → 调整数量并勾选 → 结算 → 选择支付方式 → 提交订单 → 扫码收货 + +**异常流程**: + +- 信用额度不足 → 提示并阻断提交 +- 商品下架/无库存 → 列表置灰 +- 扫码收货条码不匹配 → 提示并拒收 + +**业务规则** —— 见 [4.6.7](#467-业务规则) + +**权限规则** —— 技工默认无采购权限,需店长/后台授权([REQ-PUR-001](#467-业务规则)) + +**访问链路** —— App → App Backend → ROOS + +**逻辑数据来源** —— ROOS(商品主数据、价格、库存、购物车、订单、售后、额度、支付方式) + +**回写目标** —— 购物车、采购订单、支付方式选择、收货确认、售后申请 → ROOS + +**状态变化** —— 采购订单:待支付 → 待发货 → 已发货 →(收货)完成;可取消 → 已取消 ### 4.6.2 产品搜索与商品列表 @@ -1365,18 +1565,21 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.6.7 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-PUR-001 | 采购授权 | 技工需被显式授权才能进入采购模块;授权维度参见[人员管理「可用系统」](#410-门店管理) | -| REQ-PUR-002 | 技工下单权限 | 技工被授权后是否可提交订单待定 `TODO(REQ-PUR-002)` | -| REQ-PUR-003 | 额度校验 | 提交订单前校验信用额度;不足时阻断并提示 | -| REQ-PUR-004 | 支付方式 | 由支付优先级设置决定默认扣账方式;与 CDMS 支付的关系待定 `TODO(REQ-PUR-004)` | -| REQ-PUR-005 | 收货与入库 | 扫码收货与扫码入库是否合并为一次动作待定 `TODO(REQ-PUR-005)` | -| REQ-PUR-006 | 订单状态机 | 待支付 → 待发货 → 已发货 →(收货)完成;可取消 → 已取消。与销售订单状态机隔离 | -| REQ-PUR-007 | 数据来源 | 商品、价格、库存、订单、售后全部以 ROOS 为准,App 不落地二次计算的价格 | -| REQ-PUR-008 | 扫码实现 | 收货扫码使用 App 原生扫码能力,不嵌入第三方 H5 扫码页 | +**REQ-PUR-001 采购授权** —— 技工需被显式授权才能进入采购模块;授权维度参见[人员管理「可用系统」](#410-门店管理) -采购业务规则 +**REQ-PUR-002 技工下单权限** —— 技工被授权后是否可提交订单待定 `TODO(REQ-PUR-002)` + +**REQ-PUR-003 额度校验** —— 提交订单前校验信用额度;不足时阻断并提示 + +**REQ-PUR-004 支付方式** —— 由支付优先级设置决定默认扣账方式;与 CDMS 支付的关系待定 `TODO(REQ-PUR-004)` + +**REQ-PUR-005 收货与入库** —— 扫码收货与扫码入库是否合并为一次动作待定 `TODO(REQ-PUR-005)` + +**REQ-PUR-006 订单状态机** —— 待支付 → 待发货 → 已发货 →(收货)完成;可取消 → 已取消。与销售订单状态机隔离 + +**REQ-PUR-007 数据来源** —— 商品、价格、库存、订单、售后全部以 ROOS 为准,App 不落地二次计算的价格 + +**REQ-PUR-008 扫码实现** —— 收货扫码使用 App 原生扫码能力,不嵌入第三方 H5 扫码页 ### 4.6.8 业务数据列表 @@ -1407,14 +1610,17 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ## 4.7 其它采购 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 承接**非马牌品牌 / 非 ROOS 渠道**的采购需求(如维京轮胎、耗材、辅料),使门店不必再跳出 App | -| **入口** | 采购 tab 内二级入口 `TODO(REQ-OPU-001)` | -| **页面内容** | 待确认 `TODO(REQ-OPU-002)` | -| **主流程** | 待确认 `TODO(REQ-OPU-002)` | -| **权限规则** | 同 [REQ-PUR-001](#467-业务规则) | -| **数据来源** | 待确认(是否仍为 ROOS,或对接其它供应商系统)`TODO(REQ-OPU-003)` | +**业务目标** —— 承接**非马牌品牌 / 非 ROOS 渠道**的采购需求(如维京轮胎、耗材、辅料),使门店不必再跳出 App + +**入口** —— 采购 tab 内二级入口 `TODO(REQ-OPU-001)` + +**页面内容** —— 待确认 `TODO(REQ-OPU-002)` + +**主流程** —— 待确认 `TODO(REQ-OPU-002)` + +**权限规则** —— 同 [REQ-PUR-001](#467-业务规则) + +**数据来源** —— 待确认(是否仍为 ROOS,或对接其它供应商系统)`TODO(REQ-OPU-003)` > **本模块目前既无需求描述,也无现状页面可参考。** 建议在需求评审时明确:其它采购是否属于首版范围。若不属于,本节应整体删除而非留白 —— `TODO(REQ-OPU-004)`,见 [10.2](#102-待确认项清单)。 @@ -1424,14 +1630,20 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > 底部导航的三版方案中,业务菜单版有「库存」tab、设计稿 5 tab 方案有「入库」tab(见 [三版底部导航方案](#425-导航收敛与角色化配置))。本节内容由现状截图反推。 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 把「查得到货、扫得进库、对得上账」收敛到 App 内,解决[痛点 2.3](#23-进销存--erp-数据)中「门店无法实时判断可售库存」的问题 | -| **入口** | 底部导航「库存 / 入库」tab;首页快捷入口「扫码入库」「扫码出库」;采购收货流程 | -| **页面内容** | 库存查询(SKU 列表 + 四级轮胎参数筛选)、扫码入库记录、扫码出库、条码库存(入库记录 / 出库记录 / 在库条码) | -| **主流程** | 查询:选择搜索类型 → 输入关键字或逐级筛选 → 查看 SKU 库存状态与结算价
入库:扫描轮胎条码 → 校验 → 写入在库条码 → 生成入库记录 | -| **权限规则** | 店长全量;技工可查询与扫码入/出库,`TODO(REQ-INV-001)` 确认技工是否可见结算价 | -| **数据来源** | O2O(库存查询、扫码入库)、ROOS(条码库存、扫码出库) | +**业务目标** —— 把「查得到货、扫得进库、对得上账」收敛到 App 内,解决[痛点 2.3](#23-进销存--erp-数据)中「门店无法实时判断可售库存」的问题 + +**入口** —— 底部导航「库存 / 入库」tab;首页快捷入口「扫码入库」「扫码出库」;采购收货流程 + +**页面内容** —— 库存查询(SKU 列表 + 四级轮胎参数筛选)、扫码入库记录、扫码出库、条码库存(入库记录 / 出库记录 / 在库条码) + +**主流程**: + +- 查询:选择搜索类型 → 输入关键字或逐级筛选 → 查看 SKU 库存状态与结算价 +- 入库:扫描轮胎条码 → 校验 → 写入在库条码 → 生成入库记录 + +**权限规则** —— 店长全量;技工可查询与扫码入/出库,`TODO(REQ-INV-001)` 确认技工是否可见结算价 + +**数据来源** —— O2O(库存查询、扫码入库)、ROOS(条码库存、扫码出库) ### 4.8.1 库存查询 @@ -1483,17 +1695,19 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.8.4 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-INV-001 | 结算价可见性 | 技工是否可见小程序结算价待定 `TODO(REQ-INV-001)` | -| REQ-INV-002 | SKU 与产品编码 | 主键口径待统一 `TODO(REQ-INV-002)` | -| REQ-INV-003 | 库存状态覆盖 | 库存状态标签的数据覆盖率与刷新频率待定 `TODO(REQ-INV-003)` | -| REQ-INV-004 | 筛选维度 | 黑科技 / 促销两个维度的取舍待定 `TODO(REQ-INV-004)` | -| REQ-INV-005 | 入库写入目标 | 扫码入库的权威写入目标待定 `TODO(REQ-INV-005)` | -| REQ-INV-006 | 扫码实现 | 入库/出库扫码均为 App 原生实现([REQ-INT-003](#73-f6-集成边界)) | -| REQ-INV-007 | 重复条码 | 同一条码重复扫描应拒绝并提示已入库时间与门店 | +**REQ-INV-001 结算价可见性** —— 技工是否可见小程序结算价待定 `TODO(REQ-INV-001)` -库存业务规则 +**REQ-INV-002 SKU 与产品编码** —— 主键口径待统一 `TODO(REQ-INV-002)` + +**REQ-INV-003 库存状态覆盖** —— 库存状态标签的数据覆盖率与刷新频率待定 `TODO(REQ-INV-003)` + +**REQ-INV-004 筛选维度** —— 黑科技 / 促销两个维度的取舍待定 `TODO(REQ-INV-004)` + +**REQ-INV-005 入库写入目标** —— 扫码入库的权威写入目标待定 `TODO(REQ-INV-005)` + +**REQ-INV-006 扫码实现** —— 入库/出库扫码均为 App 原生实现([REQ-INT-003](#73-f6-集成边界)) + +**REQ-INV-007 重复条码** —— 同一条码重复扫描应拒绝并提示已入库时间与门店 ### 4.8.5 验收标准 @@ -1508,14 +1722,17 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > 三套现状小程序各有一个「我的」,本节将其合并为 App 的统一个人中心。 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 提供统一的账号、门店、资产(额度/返利/优惠券/积分)与设置入口,取代三套小程序各自的「我的」 | -| **入口** | 底部导航「我的」tab | -| **页面内容** | 用户信息卡(头像/姓名/门店/角色标签)+ 资产卡(积分、优惠券)+ 功能列表 + 退出登录 | -| **主流程** | 进入个人中心 → 查看资产 / 进入子功能 → 返回 | -| **权限规则** | 全角色可见;资产类(额度、返利、对账单)建议限店长 `TODO(REQ-MIN-002)` | -| **数据来源** | App Backend(账号、门店)、ROOS(账户、优惠券、地址、收藏、支付优先级)、O2O(设置、手机号)、延保后台(姓名、门店) | +**业务目标** —— 提供统一的账号、门店、资产(额度/返利/优惠券/积分)与设置入口,取代三套小程序各自的「我的」 + +**入口** —— 底部导航「我的」tab + +**页面内容** —— 用户信息卡(头像/姓名/门店/角色标签)+ 资产卡(积分、优惠券)+ 功能列表 + 退出登录 + +**主流程** —— 进入个人中心 → 查看资产 / 进入子功能 → 返回 + +**权限规则** —— 全角色可见;资产类(额度、返利、对账单)建议限店长 `TODO(REQ-MIN-002)` + +**数据来源** —— App Backend(账号、门店)、ROOS(账户、优惠券、地址、收藏、支付优先级)、O2O(设置、手机号)、延保后台(姓名、门店) ### 4.9.1 目标形态 @@ -1603,14 +1820,17 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ## 4.10 门店管理 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 门店管理的入口在「我的」菜单,门店管理包括门店的基础信息,人员管理,门店项目信息,营业执照信息等 | -| **入口** | 「我的」菜单 → 门店管理;宫格版导航中为一级 tab(见 [三版底部导航方案](#425-导航收敛与角色化配置)) | -| **页面内容** | 四个 Tab:基础信息 / 门店项目信息 / 营业执照信息 / 渠道信息;另有人员管理、收款信息、经营范围与开票方式、协议中心 | -| **主流程** | 进入门店管理 → 切换 Tab 查看 → 点击「修改」提交变更 →(如需)等待审核 | -| **权限规则** | 店长可以修改门店基础信息,添加和修改人员,修改门店项目信息,营业执照信息;**技工基本没有门店管理权限** | -| **数据来源** | O2O(店铺管理、经营范围、协议)、马上下单(门店主数据)、App Backend(人员与授权) | +**业务目标** —— 门店管理的入口在「我的」菜单,门店管理包括门店的基础信息,人员管理,门店项目信息,营业执照信息等 + +**入口** —— 「我的」菜单 → 门店管理;宫格版导航中为一级 tab(见 [三版底部导航方案](#425-导航收敛与角色化配置)) + +**页面内容** —— 四个 Tab:基础信息 / 门店项目信息 / 营业执照信息 / 渠道信息;另有人员管理、收款信息、经营范围与开票方式、协议中心 + +**主流程** —— 进入门店管理 → 切换 Tab 查看 → 点击「修改」提交变更 →(如需)等待审核 + +**权限规则** —— 店长可以修改门店基础信息,添加和修改人员,修改门店项目信息,营业执照信息;**技工基本没有门店管理权限** + +**数据来源** —— O2O(店铺管理、经营范围、协议)、马上下单(门店主数据)、App Backend(人员与授权) ### 4.10.1 目标形态 @@ -1666,17 +1886,19 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.10.6 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-STM-001 | 人员授权模型 | 角色 + 可用系统的二维授权,取值集合待定 `TODO(REQ-STM-001)` | -| REQ-STM-002 | Tab 口径 | 「2.0 店铺信息」与「门店项目信息」的对应关系待定 `TODO(REQ-STM-002)` | -| REQ-STM-003 | 资质变更审核 | 营业执照等资质变更的审核流程待定 `TODO(REQ-STM-003)` | -| REQ-STM-004 | 协议体系 | 协议中心与延保条款是否合并待定 `TODO(REQ-STM-004)` | -| REQ-STM-005 | 技工权限 | 技工基本无门店管理权限,仅可只读查看门店基础信息 | -| REQ-STM-006 | 渠道到期提醒 | 高德/美团/抖音/百度等渠道到期应产生[首页待办或预警](#423-店长首页) `TODO(REQ-STM-007)` | -| REQ-STM-007 | 主数据边界 | 门店主数据来源为马上下单([第 5 章](#5-主数据)),App 内可改的字段范围待定 `TODO(REQ-STM-008)` | +**REQ-STM-001 人员授权模型** —— 角色 + 可用系统的二维授权,取值集合待定 `TODO(REQ-STM-001)` -门店管理业务规则 +**REQ-STM-002 Tab 口径** —— 「2.0 店铺信息」与「门店项目信息」的对应关系待定 `TODO(REQ-STM-002)` + +**REQ-STM-003 资质变更审核** —— 营业执照等资质变更的审核流程待定 `TODO(REQ-STM-003)` + +**REQ-STM-004 协议体系** —— 协议中心与延保条款是否合并待定 `TODO(REQ-STM-004)` + +**REQ-STM-005 技工权限** —— 技工基本无门店管理权限,仅可只读查看门店基础信息 + +**REQ-STM-006 渠道到期提醒** —— 高德/美团/抖音/百度等渠道到期应产生[首页待办或预警](#423-店长首页) `TODO(REQ-STM-007)` + +**REQ-STM-007 主数据边界** —— 门店主数据来源为马上下单([第 5 章](#5-主数据)),App 内可改的字段范围待定 `TODO(REQ-STM-008)` ### 4.10.7 验收标准 @@ -1691,14 +1913,20 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > 本节内容由 O2O 与 ROOS 的对账 / 提现 / 结算截图反推。 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 让门店在 App 内看清「挣了多少、能提多少、什么时候到账、和厂商怎么对账」,解决[痛点 2.4](#24-支付与营销)与[痛点 2.5](#25-数据经营分析) | -| **入口** | 「我的」菜单 → 对账提现 / 对账单;宫格版导航中为一级 tab「账务对账」 | -| **页面内容** | 对账提现(可提现金额 + 收入 + 提现历史)、收入明细、服务结算单、结算单明细、采购对账单、银行账号绑定 | -| **主流程** | 查看可提现金额 → 进入收入/提现历史核对 → 发起提现 → 到账
采购侧:按月查看对账单 → 核对汇总与明细 | -| **权限规则** | **建议限店长**(涉及资金)`TODO(REQ-FIN-001)` | -| **数据来源** | O2O(O2O 收入、提现、服务结算单)、ROOS(采购对账单) | +**业务目标** —— 让门店在 App 内看清「挣了多少、能提多少、什么时候到账、和厂商怎么对账」,解决[痛点 2.4](#24-支付与营销)与[痛点 2.5](#25-数据经营分析) + +**入口** —— 「我的」菜单 → 对账提现 / 对账单;宫格版导航中为一级 tab「账务对账」 + +**页面内容** —— 对账提现(可提现金额 + 收入 + 提现历史)、收入明细、服务结算单、结算单明细、采购对账单、银行账号绑定 + +**主流程**: + +- 查看可提现金额 → 进入收入/提现历史核对 → 发起提现 → 到账 +- 采购侧:按月查看对账单 → 核对汇总与明细 + +**权限规则** —— **建议限店长**(涉及资金)`TODO(REQ-FIN-001)` + +**数据来源** —— O2O(O2O 收入、提现、服务结算单)、ROOS(采购对账单) ### 4.11.1 对账提现 @@ -1744,17 +1972,19 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.11.6 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-FIN-001 | 权限 | 财务模块建议限店长可见 `TODO(REQ-FIN-001)` | -| REQ-FIN-002 | 可提现金额口径 | T+1 工作日 12:30 更新;基数为已核销订单货款;具体计算口径待定 `TODO(REQ-FIN-002)` | -| REQ-FIN-003 | 收支合并 | 采购对账单与 O2O 提现是否合成资金总览待定 `TODO(REQ-FIN-003)` | -| REQ-FIN-004 | 账户绑定安全 | 银行账号绑定/解绑的二次验证方式待定 `TODO(REQ-FIN-004)` | -| REQ-FIN-005 | 金额一致性 | App 不做金额二次计算,全部以源系统返回值展示;不同页面同一笔金额必须一致 | -| REQ-FIN-006 | 手续费透明 | 交易手续费说明须在提现入口可达 | -| REQ-FIN-007 | 与 CDMS 支付关系 | 首页待办中的「CDMS 支付提醒」与本模块的关系待定 `TODO(REQ-FIN-005)` | +**REQ-FIN-001 权限** —— 财务模块建议限店长可见 `TODO(REQ-FIN-001)` -财务与对账业务规则 +**REQ-FIN-002 可提现金额口径** —— T+1 工作日 12:30 更新;基数为已核销订单货款;具体计算口径待定 `TODO(REQ-FIN-002)` + +**REQ-FIN-003 收支合并** —— 采购对账单与 O2O 提现是否合成资金总览待定 `TODO(REQ-FIN-003)` + +**REQ-FIN-004 账户绑定安全** —— 银行账号绑定/解绑的二次验证方式待定 `TODO(REQ-FIN-004)` + +**REQ-FIN-005 金额一致性** —— App 不做金额二次计算,全部以源系统返回值展示;不同页面同一笔金额必须一致 + +**REQ-FIN-006 手续费透明** —— 交易手续费说明须在提现入口可达 + +**REQ-FIN-007 与 CDMS 支付关系** —— 首页待办中的「CDMS 支付提醒」与本模块的关系待定 `TODO(REQ-FIN-005)` ### 4.11.7 验收标准 @@ -1769,14 +1999,22 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > 返利分布在两侧:延保侧有延保返利,O2O 侧另有一套完全独立、维度更丰富的返利体系(9 张截图),设计稿为其单独出了页面。 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 让门店随时看清「这单能拿多少返利、为什么是这个数、核算到哪一步了」,解决[痛点 2.4](#24-支付与营销)中返利不透明的问题 | -| **入口** | O2O 工具条「返利中心」;宫格版导航一级入口 | -| **页面内容** | 双 Tab:**返利详情 / 返利核算**;顶部订单号搜索 + 四个筛选(渠道 / 品牌 / 标签 / 月份);汇总卡(返利补贴、核算后返利)+ 三项构成(消费者补贴、安装费用、抽奖红包返利);明细列表 | -| **主流程** | 选择月份与筛选 → 查看汇总 → 按标签切换明细 → 展开单条查看构成与状态 | -| **权限规则** | 建议限店长 `TODO(REQ-RBT-001)` | -| **数据来源** | O2O(返利核算)、延保后台(延保返利,见 [4.5.6](#456-工作台返利与经营数据)) | +**业务目标** —— 让门店随时看清「这单能拿多少返利、为什么是这个数、核算到哪一步了」,解决[痛点 2.4](#24-支付与营销)中返利不透明的问题 + +**入口** —— O2O 工具条「返利中心」;宫格版导航一级入口 + +**页面内容**: + +- 双 Tab:**返利详情 / 返利核算** +- 顶部订单号搜索 + 四个筛选(渠道 / 品牌 / 标签 / 月份) +- 汇总卡(返利补贴、核算后返利)+ 三项构成(消费者补贴、安装费用、抽奖红包返利) +- 明细列表 + +**主流程** —— 选择月份与筛选 → 查看汇总 → 按标签切换明细 → 展开单条查看构成与状态 + +**权限规则** —— 建议限店长 `TODO(REQ-RBT-001)` + +**数据来源** —— O2O(返利核算)、延保后台(延保返利,见 [4.5.6](#456-工作台返利与经营数据)) ### 4.12.1 目标形态 @@ -1814,16 +2052,17 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.12.5 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-RBT-001 | 权限 | 返利对技工是否可见待定 `TODO(REQ-RBT-001)` | -| REQ-RBT-002 | 计算口径 | 返利补贴、核算后返利、三项构成的关系需给出公式说明 `TODO(REQ-RBT-002)` | -| REQ-RBT-003 | 规则说明可达 | 三项构成的 `ⓘ` 说明页必须在 App 内保留,不得外链小程序 | -| REQ-RBT-004 | 与延保返利合并 | O2O 返利与[延保返利](#456-工作台返利与经营数据)是否合并入口待定 `TODO(REQ-RBT-004)` | -| REQ-RBT-005 | 退款扣减 | 明细中的「退款扣减」状态需与[财务收入](#4112-收入明细)对齐,同一笔不得两侧不一致 | -| REQ-RBT-006 | 数据来源 | 返利金额全部由 O2O 返回,App 不做二次计算(同 [REQ-FIN-005](#4116-业务规则)) | +**REQ-RBT-001 权限** —— 返利对技工是否可见待定 `TODO(REQ-RBT-001)` -返利中心业务规则 +**REQ-RBT-002 计算口径** —— 返利补贴、核算后返利、三项构成的关系需给出公式说明 `TODO(REQ-RBT-002)` + +**REQ-RBT-003 规则说明可达** —— 三项构成的 `ⓘ` 说明页必须在 App 内保留,不得外链小程序 + +**REQ-RBT-004 与延保返利合并** —— O2O 返利与[延保返利](#456-工作台返利与经营数据)是否合并入口待定 `TODO(REQ-RBT-004)` + +**REQ-RBT-005 退款扣减** —— 明细中的「退款扣减」状态需与[财务收入](#4112-收入明细)对齐,同一笔不得两侧不一致 + +**REQ-RBT-006 数据来源** —— 返利金额全部由 O2O 返回,App 不做二次计算(同 [REQ-FIN-005](#4116-业务规则)) ### 4.12.6 验收标准 @@ -1837,14 +2076,21 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > 本节内容由 O2O 经营业绩、ROOS 业绩详情与设计稿构建。 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 门店在一处看到订货、入库、售出、延保、收入的全链路经营数据,解决[痛点 2.5](#25-数据经营分析) | -| **入口** | 「我的」菜单 → 经营业绩;宫格版导航「经营分析」;[店长首页](#423-店长首页)业绩卡片 | -| **页面内容** | 三 Tab:**1.0 经营业绩 / 2.0 经营业绩 / 2.0 引流转化**;三个筛选(时间粒度 / 渠道 / 日期);扫码入库双计数、分品牌性能指标、销售统计、收入统计 | -| **主流程** | 选择时间粒度与渠道 → 查看指标卡 → 下钻明细 | -| **权限规则** | 建议限店长;技工可见与其相关的施工量指标 `TODO(REQ-PRF-001)` | -| **数据来源** | O2O(O2O 售出、收入、引流转化)、ROOS(签约量、订货量、扫码入库量) | +**业务目标** —— 门店在一处看到订货、入库、售出、延保、收入的全链路经营数据,解决[痛点 2.5](#25-数据经营分析) + +**入口** —— 「我的」菜单 → 经营业绩;宫格版导航「经营分析」;[店长首页](#423-店长首页)业绩卡片 + +**页面内容**: + +- 三 Tab:**1.0 经营业绩 / 2.0 经营业绩 / 2.0 引流转化** +- 三个筛选(时间粒度 / 渠道 / 日期) +- 扫码入库双计数、分品牌性能指标、销售统计、收入统计 + +**主流程** —— 选择时间粒度与渠道 → 查看指标卡 → 下钻明细 + +**权限规则** —— 建议限店长;技工可见与其相关的施工量指标 `TODO(REQ-PRF-001)` + +**数据来源** —— O2O(O2O 售出、收入、引流转化)、ROOS(签约量、订货量、扫码入库量) ### 4.13.1 目标形态 @@ -1880,16 +2126,17 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.13.4 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-PRF-001 | 权限 | 技工可见的指标范围待定 `TODO(REQ-PRF-001)` | -| REQ-PRF-002 | 命名 | 「1.0 / 2.0」版本代号需替换为业务可读名称 `TODO(REQ-PRF-002)` | -| REQ-PRF-003 | 报表合并 | ROOS 与 O2O 两套业绩的合并方式待定 `TODO(REQ-PRF-003)` | -| REQ-PRF-004 | 延保数据并入 | 延保经营数据是否并入统一报表待定 `TODO(REQ-PRF-004)` | -| REQ-PRF-005 | 月度签约达成预警 | [首页动态预警](#423-店长首页)中的「月度签约达成」阈值取自签约量指标,规则待定 `TODO(REQ-HOM-009)` | -| REQ-PRF-006 | 首页营收卡片 | 设计稿的「今日预计营收 / 毛利 / 客单价」在本模块无对应指标,口径待定 `TODO(REQ-HOM-011)` | +**REQ-PRF-001 权限** —— 技工可见的指标范围待定 `TODO(REQ-PRF-001)` -经营业绩与报表业务规则 +**REQ-PRF-002 命名** —— 「1.0 / 2.0」版本代号需替换为业务可读名称 `TODO(REQ-PRF-002)` + +**REQ-PRF-003 报表合并** —— ROOS 与 O2O 两套业绩的合并方式待定 `TODO(REQ-PRF-003)` + +**REQ-PRF-004 延保数据并入** —— 延保经营数据是否并入统一报表待定 `TODO(REQ-PRF-004)` + +**REQ-PRF-005 月度签约达成预警** —— [首页动态预警](#423-店长首页)中的「月度签约达成」阈值取自签约量指标,规则待定 `TODO(REQ-HOM-009)` + +**REQ-PRF-006 首页营收卡片** —— 设计稿的「今日预计营收 / 毛利 / 客单价」在本模块无对应指标,口径待定 `TODO(REQ-HOM-011)` ### 4.13.5 验收标准 @@ -1903,14 +2150,20 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > 本节对应[痛点 2.4](#24-支付与营销)「支付与营销」,内容由 O2O 优惠券、会员权益、门店海报截图构建。 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 让门店在开单现场就能用上券和会员权益,并具备自主获客的物料,解决[痛点 2.4](#24-支付与营销) | -| **入口** | O2O 工具条「优惠券」「会员权益」「门店海报」;[开单结算](#437-结算与延保跳转)流程中的选券环节 | -| **页面内容** | 消费券 / 门店营销券、选择营销券、会员权益选择弹窗、会员体系开通状态、门店海报 | -| **主流程** | 开单选券:结算 → 选择营销券 → 应用 → 计入金额
会员权益:开单 → 选择会员权益 → 应用 | -| **权限规则** | 店长可管理;技工在开单时可使用 `TODO(REQ-MKT-001)` | -| **数据来源** | O2O(券、会员权益、海报)、ROOS([采购优惠券](#492-roos我的采购域资产)) | +**业务目标** —— 让门店在开单现场就能用上券和会员权益,并具备自主获客的物料,解决[痛点 2.4](#24-支付与营销) + +**入口** —— O2O 工具条「优惠券」「会员权益」「门店海报」;[开单结算](#437-结算与延保跳转)流程中的选券环节 + +**页面内容** —— 消费券 / 门店营销券、选择营销券、会员权益选择弹窗、会员体系开通状态、门店海报 + +**主流程**: + +- 开单选券:结算 → 选择营销券 → 应用 → 计入金额 +- 会员权益:开单 → 选择会员权益 → 应用 + +**权限规则** —— 店长可管理;技工在开单时可使用 `TODO(REQ-MKT-001)` + +**数据来源** —— O2O(券、会员权益、海报)、ROOS([采购优惠券](#492-roos我的采购域资产)) ### 4.14.1 优惠券 @@ -1940,15 +2193,15 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ### 4.14.4 业务规则 -| 编号 | 需求 | 规则 | -| --- | --- | --- | -| REQ-MKT-001 | 权限 | 技工在开单时可用券与权益,但不可管理 `TODO(REQ-MKT-001)` | -| REQ-MKT-002 | 券体系归并 | 三套券的适用场景与叠加规则待定 `TODO(REQ-MKT-002)` | -| REQ-MKT-003 | 海报分享 | 分享目标与相册权限方案待定 `TODO(REQ-MKT-003)` | -| REQ-MKT-004 | 会员能力开关 | 未开通会员体系的门店隐藏相关入口 | -| REQ-MKT-005 | 券与返利关系 | 消费者补贴([返利构成](#4124-返利构成说明))与消费券是否为同一资金来源待定 `TODO(REQ-MKT-004)` | +**REQ-MKT-001 权限** —— 技工在开单时可用券与权益,但不可管理 `TODO(REQ-MKT-001)` -营销与会员业务规则 +**REQ-MKT-002 券体系归并** —— 三套券的适用场景与叠加规则待定 `TODO(REQ-MKT-002)` + +**REQ-MKT-003 海报分享** —— 分享目标与相册权限方案待定 `TODO(REQ-MKT-003)` + +**REQ-MKT-004 会员能力开关** —— 未开通会员体系的门店隐藏相关入口 + +**REQ-MKT-005 券与返利关系** —— 消费者补贴([返利构成](#4124-返利构成说明))与消费券是否为同一资金来源待定 `TODO(REQ-MKT-004)` ### 4.14.5 验收标准 @@ -1962,14 +2215,17 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 > **本节内容基本待补。** ROOS 与 O2O 首页均挂有「积分兑换」入口(见 [2.7.2](#272-roos-采购小程序)、[2.7.3](#273-o2o-接单宝小程序)),设计稿宫格版有「福利兑换」(见 [4.2.5](#425-导航收敛与角色化配置)),[个人中心](#491-目标形态)有「积分 460」资产卡,但业务侧尚未给出任何需求描述。 -| 维度 | 内容 | -| --- | --- | -| **业务目标** | 门店店员用积分兑换福利,作为激励手段(推断)`TODO(REQ-MSP-001)` | -| **入口** | 现状:ROOS / O2O 首页「积分兑换」;目标:宫格版「福利兑换」或个人中心积分卡 | -| **页面内容** | 待确认 `TODO(REQ-MSP-002)` | -| **主流程** | 待确认 `TODO(REQ-MSP-002)` | -| **权限规则** | 积分归属人是门店还是店员个人 —— 这决定权限模型 `TODO(REQ-MSP-003)` | -| **数据来源** | MSIP | +**业务目标** —— 门店店员用积分兑换福利,作为激励手段(推断)`TODO(REQ-MSP-001)` + +**入口** —— 现状:ROOS / O2O 首页「积分兑换」;目标:宫格版「福利兑换」或个人中心积分卡 + +**页面内容** —— 待确认 `TODO(REQ-MSP-002)` + +**主流程** —— 待确认 `TODO(REQ-MSP-002)` + +**权限规则** —— 积分归属人是门店还是店员个人 —— 这决定权限模型 `TODO(REQ-MSP-003)` + +**数据来源** —— MSIP **必须先确认的三个问题**: @@ -2004,14 +2260,13 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ## 5.2 主数据使用原则 -| 编号 | 规则 | -| --- | --- | -| REQ-MDM-003 | **App 不是主数据的权威源**。所有主数据字段在 App 内默认只读;可编辑字段必须显式列出并回写到权威源 | -| REQ-MDM-004 | 价格以源系统返回值为准,App 侧不做二次计算(同 [REQ-PUR-007](#467-业务规则)、[REQ-FIN-005](#4116-业务规则)) | -| REQ-MDM-005 | 产品主数据经 SFTP 文件同步,存在时延;App 需展示数据口径时间,避免门店以为是实时价 `TODO(REQ-MDM-005)` | -| REQ-MDM-006 | 门店主数据变更后,需级联刷新所有已缓存的门店上下文(见《App 门店上下文与会话管理文档》) | +**REQ-MDM-003** —— **App 不是主数据的权威源**。所有主数据字段在 App 内默认只读;可编辑字段必须显式列出并回写到权威源 -主数据使用原则 +**REQ-MDM-004** —— 价格以源系统返回值为准,App 侧不做二次计算(同 [REQ-PUR-007](#467-业务规则)、[REQ-FIN-005](#4116-业务规则)) + +**REQ-MDM-005** —— 产品主数据经 SFTP 文件同步,存在时延;App 需展示数据口径时间,避免门店以为是实时价 `TODO(REQ-MDM-005)` + +**REQ-MDM-006** —— 门店主数据变更后,需级联刷新所有已缓存的门店上下文(见《App 门店上下文与会话管理文档》) > **CDMS 非轮数据来源未定(REQ-MDM-001)**,它直接卡住[其它采购](#47-其它采购)与非轮产品的分类,见 [10.2](#102-待确认项清单)。 @@ -2027,11 +2282,11 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ![原型-后台-登录](images/原型-后台-登录.png) -| 维度 | 内容 | -| --- | --- | -| **目标角色** | 运营管理员(非门店角色,与[店长/技工](#31-终端用户门店角色)体系隔离) | -| **权限规则** | 后台账号与 App 账号**不互通** `TODO(REQ-ADM-001)` | -| **数据来源** | App Backend | +**目标角色** —— 运营管理员(非门店角色,与[店长/技工](#31-终端用户门店角色)体系隔离) + +**权限规则** —— 后台账号与 App 账号**不互通** `TODO(REQ-ADM-001)` + +**数据来源** —— App Backend ## 6.2 后台管理首页 @@ -2059,32 +2314,33 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 门店与后台用户账号的全生命周期管理。 -| 维度 | 内容 | -| --- | --- | -| **页面内容** | 账号列表、账号详情、创建/停用/删除、重置密码、绑定门店 | -| **关联** | 对应 App 侧的[注册审核](#412-业务规则)([REQ-LGN-002](#412-业务规则))与[人员管理](#410-门店管理) | -| **待确认** | 账号审核流是在后台还是由店长在 App 内完成 `TODO(REQ-ADM-004)` | +**页面内容** —— 账号列表、账号详情、创建/停用/删除、重置密码、绑定门店 + +**关联** —— 对应 App 侧的[注册审核](#412-业务规则)([REQ-LGN-002](#412-业务规则))与[人员管理](#410-门店管理) + +**待确认** —— 账号审核流是在后台还是由店长在 App 内完成 `TODO(REQ-ADM-004)` ## 6.4 RBAC 权限管理 用户权限管理。 -| 维度 | 内容 | -| --- | --- | -| **页面内容** | 角色定义、权限点维护、角色-权限绑定、用户-角色绑定 | -| **关联** | 首版角色仅[店长/技工](#31-终端用户门店角色);「可用系统」维度([REQ-STM-001](#4106-业务规则))需在此建模 | -| **待确认** | 角色是全局定义还是可按门店自定义 `TODO(REQ-ADM-005)` | +**页面内容** —— 角色定义、权限点维护、角色-权限绑定、用户-角色绑定 + +**关联** —— 首版角色仅[店长/技工](#31-终端用户门店角色);「可用系统」维度([REQ-STM-001](#4106-业务规则))需在此建模 + +**待确认** —— 角色是全局定义还是可按门店自定义 `TODO(REQ-ADM-005)` ## 6.5 接口监控 监控 APP 后台调用其它外围系统的健康状态。 -| 维度 | 内容 | -| --- | --- | -| **监控对象** | 后台接口 / 小程序接口(ROOS、O2O、延保、马上下单)/ F6 接口 | -| **指标** | 响应时间(SLA **200ms**)、成功率、错误码分布 | -| **关联** | 与[集成矩阵](#72-集成矩阵)一一对应;告警联动见《后端可观测性文档》 | -| **待确认** | 200ms 是 P50 还是 P95、是否分接口分级 `TODO(REQ-ADM-006)` | +**监控对象** —— 后台接口 / 小程序接口(ROOS、O2O、延保、马上下单)/ F6 接口 + +**指标** —— 响应时间(SLA **200ms**)、成功率、错误码分布 + +**关联** —— 与[集成矩阵](#72-集成矩阵)一一对应;告警联动见《后端可观测性文档》 + +**待确认** —— 200ms 是 P50 还是 P95、是否分接口分级 `TODO(REQ-ADM-006)` ## 6.6 系统管理 @@ -2102,7 +2358,9 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 埋点方案见仓库文档《App 可观测性与埋点文档》。 -> 该文档记录:客户端埋点选型为**神策**,但本项目**没有现成账号**,开通属采购流程;崩溃上报选型为 **Sentry**,自建还是 SaaS 未定。两项均为跨文档阻塞项,见 [10.3](#103-跨文档阻塞项)。 +> 该文档记录:客户端埋点选型为**神策**,但本项目**没有现成账号**,开通属采购流程,属跨文档阻塞项,见 [10.3](#103-跨文档阻塞项)。崩溃上报不引入独立崩溃平台,由**现有的腾讯 Bugly**(原生崩溃 / ANR)加**神策自定义事件**(Dart 异常明细)共同承担,错误看板在神策上二次开发。 +> +> 因此**神策的可用性同时决定埋点与错误看板两件事**,权重比原先更高。 > > 后台首页看板的「APP 各功能当日访问趋势」「门店客户转化漏斗」「7 日活跃 / 90 天僵尸用户」全部依赖埋点数据 —— **埋点方案不落地,后台首页看板就是空的**。 @@ -2114,15 +2372,15 @@ APP 获取手机系统当前时间;格式:`YYYY-MM-DD HH:mm`。 ## 7.1 访问链路原则 -| 编号 | 原则 | -| --- | --- | -| REQ-INT-001 | **`Mobile App → App Backend` 是唯一主链路**。App 不直连任何外围系统(ROOS / O2O / 延保后台 / 马上下单 / RMS / MSIP / F6) | -| REQ-INT-002 | 外围系统由 App Backend 的**集成层**统一代理,采用同步 `RestClient` + Resilience4j,不引入响应式栈(见《后端集成层设计文档》) | -| REQ-INT-003 | **扫码是 App 原生实现**(`native_scan`),同时服务原生页面与 H5 的 JSBridge。**不嵌入第三方扫码页** | -| REQ-INT-004 | 门店上下文(`storeId`/`storeCode`/`orgId`/`roleCode`)由 App Backend 统一签发,切店时级联失效所有缓存(见《App 门店上下文与会话管理文档》) | -| REQ-INT-005 | 单个外围系统不可用时**局部降级**,不阻断整个页面(见《后端跨域协作与聚合文档》) | +**REQ-INT-001** —— **`Mobile App → App Backend` 是唯一主链路**。App 不直连任何外围系统(ROOS / O2O / 延保后台 / 马上下单 / RMS / MSIP / F6) -访问链路原则 +**REQ-INT-002** —— 外围系统由 App Backend 的**集成层**统一代理,采用同步 `RestClient` + Resilience4j,不引入响应式栈(见《后端集成层设计文档》) + +**REQ-INT-003** —— **扫码是 App 原生实现**(`native_scan`),同时服务原生页面与 H5 的 JSBridge。**不嵌入第三方扫码页** + +**REQ-INT-004** —— 门店上下文(`storeId`/`storeCode`/`orgId`/`roleCode`)由 App Backend 统一签发,切店时级联失效所有缓存(见《App 门店上下文与会话管理文档》) + +**REQ-INT-005** —— 单个外围系统不可用时**局部降级**,不阻断整个页面(见《后端跨域协作与聚合文档》) > **REQ-INT-003 是对前期材料的明确纠正。** 《Conti Retail APP 功能模块与数据来源整理》 在第 2.4 节「扫一扫」、第 3.2.1 节「首页扫码」、第 4.5 节「收货」三处均写「Involve F6 Page / 嵌入 F6 扫码页面」。这与《App 原生能力集成文档》 和《App Embedded H5 容器与 JSBridge 文档》 的结论冲突,**以架构文档为准**。原因:通用扫码库解条码/二维码,VIN 与车牌需要 OCR,二者都要作为原生能力提供给 H5,嵌一个 H5 扫码页反而多一层。参见《文档仓库说明》。 @@ -2203,14 +2461,13 @@ JSBridge 能力清单 门店能力开关 -| 编号 | 规则 | -| --- | --- | -| REQ-INT-009 | 能力开关由 App Backend 在登录/切店时随门店上下文一次性下发,客户端不自行推断 | -| REQ-INT-010 | 未开通的能力**不下发入口**,而非展示后提示「未开通」 | -| REQ-INT-011 | 能力开关 + 角色共同决定 tab 集合(见 [4.2.5](#425-导航收敛与角色化配置)) | -| REQ-INT-012 | 深链/推送跳转到未开通模块时,需有统一的兜底页 | +**REQ-INT-009** —— 能力开关由 App Backend 在登录/切店时随门店上下文一次性下发,客户端不自行推断 -门店能力开关规则 +**REQ-INT-010** —— 未开通的能力**不下发入口**,而非展示后提示「未开通」 + +**REQ-INT-011** —— 能力开关 + 角色共同决定 tab 集合(见 [4.2.5](#425-导航收敛与角色化配置)) + +**REQ-INT-012** —— 深链/推送跳转到未开通模块时,需有统一的兜底页 --- @@ -2220,93 +2477,97 @@ JSBridge 能力清单 ## 8.1 性能 -| 编号 | 项 | 要求 | -| --- | --- | --- | -| REQ-NFR-001 | 冷启动 | `TODO(REQ-NFR-001)` | -| REQ-NFR-002 | 首页首屏 | 首页聚合多个外围系统(待办来自 O2O + 延保,预警来自 ROOS + O2O),需并行 fan-out 且局部降级(《后端跨域协作与聚合文档》)。目标值 `TODO(REQ-NFR-002)` | -| REQ-NFR-003 | 接口 SLA | 后台/小程序/F6 三类接口监控口径为 **200ms**([6.5](#65-接口监控)),P50/P95 归属待定 `TODO(REQ-ADM-006)` | -| REQ-NFR-004 | 扫码识别 | 条码/二维码、VIN、车牌三种模式的识别时延与成功率 `TODO(REQ-NFR-004)` | -| REQ-NFR-005 | H5 首屏 | F6 页面白屏超时阈值与重试策略见《App Embedded H5 容器与 JSBridge 文档》 | -| REQ-NFR-006 | 并发 | 门店总数与日活峰值 `TODO(REQ-NFR-006)` | +**REQ-NFR-001 冷启动** —— `TODO(REQ-NFR-001)` -性能要求 +**REQ-NFR-002 首页首屏** —— 首页聚合多个外围系统(待办来自 O2O + 延保,预警来自 ROOS + O2O),需并行 fan-out 且局部降级(《后端跨域协作与聚合文档》)。目标值 `TODO(REQ-NFR-002)` + +**REQ-NFR-003 接口 SLA** —— 后台/小程序/F6 三类接口监控口径为 **200ms**([6.5](#65-接口监控)),P50/P95 归属待定 `TODO(REQ-ADM-006)` + +**REQ-NFR-004 扫码识别** —— 条码/二维码、VIN、车牌三种模式的识别时延与成功率 `TODO(REQ-NFR-004)` + +**REQ-NFR-005 H5 首屏** —— F6 页面白屏超时阈值与重试策略见《App Embedded H5 容器与 JSBridge 文档》 + +**REQ-NFR-006 并发** —— 门店总数与日活峰值 `TODO(REQ-NFR-006)` ## 8.2 统一交互规则 整合六套小程序,最大的隐性风险是**同一业务概念在不同来源页面上叫法与交互不一致**。 -| 编号 | 规则 | -| --- | --- | -| REQ-NFR-007 | 统一设计系统:`core_ui` 的 Material 3 主题与设计 token(对应仓库待编的《UI 设计系统文档》) | -| REQ-NFR-008 | **同名不同义必须消歧**:如[采购订单五状态](#465-订单管理与售后)与[销售订单五状态](#435-线上订单管理)、[两套返利](#412-返利中心)、[三套经营业绩](#413-经营业绩与报表)、[三套券](#4141-优惠券) | -| REQ-NFR-009 | 版本代号(「1.0/2.0 经营业绩」「2.0 店铺信息」)不得出现在 App 界面 `TODO(REQ-PRF-002)` | -| REQ-NFR-010 | H5 页面的 toast/dialog/loading 使用原生控件,保证与原生页视觉一致 | -| REQ-NFR-011 | 首版**单语言**,但需预留 `flutter_localizations` + `intl` 结构 | +**REQ-NFR-007** —— 统一设计系统:`core_ui` 的 Material 3 主题与设计 token(对应仓库待编的《UI 设计系统文档》) -统一交互规则 +**REQ-NFR-008** —— **同名不同义必须消歧**:如[采购订单五状态](#465-订单管理与售后)与[销售订单五状态](#435-线上订单管理)、[两套返利](#412-返利中心)、[三套经营业绩](#413-经营业绩与报表)、[三套券](#4141-优惠券) + +**REQ-NFR-009** —— 版本代号(「1.0/2.0 经营业绩」「2.0 店铺信息」)不得出现在 App 界面 `TODO(REQ-PRF-002)` + +**REQ-NFR-010** —— H5 页面的 toast/dialog/loading 使用原生控件,保证与原生页视觉一致 + +**REQ-NFR-011** —— 首版**单语言**,但需预留 `flutter_localizations` + `intl` 结构 ## 8.3 可用性与容错 -| 编号 | 规则 | -| --- | --- | -| REQ-NFR-012 | 单一外围系统故障时局部降级,首页其余卡片正常展示并标注「暂不可用」 | -| REQ-NFR-013 | 统一错误处理与 `ApiResult` 契约(见《App 错误处理与 API 契约文档》) | -| REQ-NFR-014 | **后端错误码表未定**,客户端目前只能全部走默认文案 —— 跨文档阻塞项,见 [10.3](#103-跨文档阻塞项) | -| REQ-NFR-015 | 崩溃上报:Sentry;客户端埋点:神策。两者均有落地阻塞,见 [10.3](#103-跨文档阻塞项) | +**REQ-NFR-012** —— 单一外围系统故障时局部降级,首页其余卡片正常展示并标注「暂不可用」 -可用性与容错 +**REQ-NFR-013** —— 统一错误处理与 `ApiResult` 契约(见《App 错误处理与 API 契约文档》) + +**REQ-NFR-014** —— 错误码为 5 位数字、前 2 位为域段(`10xxx` 平台通用、`11xxx` 认证与门店、`20xxx`/`21xxx` 采购/库存、`3xxxx` 外部系统集成)。**基础码已定,各域业务码在开发对应模块时随接口增补**;客户端对未识别的码统一展示后端返回的提示文案 + +**REQ-NFR-015** —— 崩溃上报:**腾讯 Bugly**(原生崩溃 / ANR);Dart 异常明细与错误看板:**神策自定义事件**;客户端埋点:**神策**。神策账号可用性为阻塞项,见 [10.3](#103-跨文档阻塞项) ## 8.4 安全与合规 -| 编号 | 规则 | -| --- | --- | -| REQ-NFR-016 | 全链路 HTTPS | -| REQ-NFR-017 | JWT + refresh 轮换,门店上下文参与越权隔离(见《后端安全与认证文档》) | -| REQ-NFR-018 | H5 **不传递 token 明文**,`getAuthState` 只返回状态并触发换票 | -| REQ-NFR-019 | 日志脱敏:手机号、车牌、身份证、银行账号(见《后端可观测性文档》、《App 可观测性与埋点文档》) | -| REQ-NFR-020 | 用户协议与隐私政策须在注册/登录前可查看并确认 | -| REQ-NFR-021 | 权限申请:相机(扫码/拍照)、相册读写([海报保存](#4143-门店海报)、[证据上传](#455-售后鉴定与证据采集))、定位([扫码入库以地图定位为准](#492-roos我的采购域资产))、拨号 | -| REQ-NFR-022 | 高风险操作二次确认 + 审计:[保单作废](#453-保单与延保生命周期管理)、[银行账号解绑](#4115-银行账号)、[人员授权变更](#410-门店管理)、[登出](#495-合并规则) | -| REQ-NFR-023 | 数据出境:Sentry SaaS 属数据出境,需评估,见 [10.3](#103-跨文档阻塞项) | +**REQ-NFR-016** —— 全链路 HTTPS -安全与合规 +**REQ-NFR-017** —— JWT + refresh 轮换,门店上下文参与越权隔离(见《后端安全与认证文档》) + +**REQ-NFR-018** —— H5 **不传递 token 明文**,`getAuthState` 只返回状态并触发换票 + +**REQ-NFR-019** —— 日志脱敏:手机号、车牌、身份证、银行账号(见《后端可观测性文档》、《App 可观测性与埋点文档》) + +**REQ-NFR-020** —— 用户协议与隐私政策须在注册/登录前可查看并确认 + +**REQ-NFR-021** —— 权限申请:相机(扫码/拍照)、相册读写([海报保存](#4143-门店海报)、[证据上传](#455-售后鉴定与证据采集))、定位([扫码入库以地图定位为准](#492-roos我的采购域资产))、拨号 + +**REQ-NFR-022** —— 高风险操作二次确认 + 审计:[保单作废](#453-保单与延保生命周期管理)、[银行账号解绑](#4115-银行账号)、[人员授权变更](#410-门店管理)、[登出](#495-合并规则) + +**REQ-NFR-023** —— 数据出境:**首版不涉及**(崩溃、埋点、车牌识别均落在境内服务)。但**车辆照片会上传至第三方 OCR 服务进行识别**,属须告知的处理行为,必须写入隐私政策;照片在服务端为临时对象、识别完成后按生命周期规则自动清除。引入任何境外 SaaS 前须重新评估 ## 8.5 兼容性 -| 编号 | 项 | 要求 | -| --- | --- | --- | -| REQ-NFR-024 | 平台 | 首版 **Android / iOS**;鸿蒙 OHOS 不在首版内(SDK 基线锁 3.44.9 为后续 OHOS 适配留窗口) | -| REQ-NFR-025 | 最低系统版本 | `TODO(REQ-NFR-025)` | -| REQ-NFR-026 | 设备形态 | 门店常用机型与屏幕尺寸清单 `TODO(REQ-NFR-026)` | -| REQ-NFR-027 | 暗色模式 | 是否首版支持 `TODO(REQ-NFR-027)` | +**REQ-NFR-024 平台** —— 首版 **Android / iOS**;鸿蒙 OHOS 不在首版内(SDK 基线锁 3.44.9 为后续 OHOS 适配留窗口) -兼容性 +**REQ-NFR-025 最低系统版本** —— `TODO(REQ-NFR-025)` + +**REQ-NFR-026 设备形态** —— 门店常用机型与屏幕尺寸清单 `TODO(REQ-NFR-026)` + +**REQ-NFR-027 暗色模式** —— 是否首版支持 `TODO(REQ-NFR-027)` ## 8.6 弱网与离线 门店场地(地下车库、仓库、施工区)网络条件差,而恰恰是这些场景要上传大文件。 -| 编号 | 规则 | -| --- | --- | -| REQ-NFR-028 | [装车视频](#452-消费者激活与门店建单)、[鉴定照片/视频](#455-售后鉴定与证据采集)、[营业执照](#4103-营业执照信息)、[检测报告](#433-检测开单与施工)上传须支持失败重试与本地暂存 | -| REQ-NFR-029 | [扫码入库](#482-扫码入库)在断网时可本地暂存,恢复后批量提交且**幂等不重复** | -| REQ-NFR-030 | [购物车](#463-购物车与结算)勾选与数量修改在弱网下不丢失 | -| REQ-NFR-031 | H5 上传中断的恢复策略见《App Embedded H5 容器与 JSBridge 文档》 | -| REQ-NFR-032 | 离线可读的数据范围(如今日待办、当前施工队列)`TODO(REQ-NFR-032)` | +**REQ-NFR-028** —— [装车视频](#452-消费者激活与门店建单)、[鉴定照片/视频](#455-售后鉴定与证据采集)、[营业执照](#4103-营业执照信息)、[检测报告](#433-检测开单与施工)上传须支持失败重试与本地暂存 -弱网与离线 +**REQ-NFR-029** —— [扫码入库](#482-扫码入库)在断网时可本地暂存,恢复后批量提交且**幂等不重复** + +**REQ-NFR-030** —— [购物车](#463-购物车与结算)勾选与数量修改在弱网下不丢失 + +**REQ-NFR-031** —— H5 上传中断的恢复策略见《App Embedded H5 容器与 JSBridge 文档》 + +**REQ-NFR-032** —— 离线可读的数据范围(如今日待办、当前施工队列)`TODO(REQ-NFR-032)` + +**REQ-NFR-038** —— [车牌识别](#432-接车与车辆识别)依赖网络(云端 OCR),断网或弱网下不可用:**「手工输码」须为常驻并列入口**;识别失败不自动重试,直接引导至手工输码 ## 8.7 包体积与发布 -| 编号 | 项 | 要求 | -| --- | --- | --- | -| REQ-NFR-033 | 包体积上限 | `TODO(REQ-NFR-033)` | -| REQ-NFR-034 | 多环境 | dev / uat / prod flavor(见《App 多环境构建文档》) | -| REQ-NFR-035 | **iOS 构建链路** | 现有 GitLab Runner 为 Linux,`flutter build ipa` 需 macOS —— 阻塞项,见 [10.3](#103-跨文档阻塞项) | -| REQ-NFR-036 | **内测分发** | Firebase App Distribution 国内可达性存疑 —— 阻塞项,见 [10.3](#103-跨文档阻塞项) | -| REQ-NFR-037 | 强制升级 | 是否需要版本强制升级机制 `TODO(REQ-NFR-037)` | +**REQ-NFR-033 包体积上限** —— `TODO(REQ-NFR-033)` -包体积与发布 +**REQ-NFR-034 多环境** —— dev / uat / prod flavor(见《App 多环境构建文档》) + +**REQ-NFR-035 iOS 构建** —— 由**远程 Mac 出包**;首版手工执行仓库内构建脚本,后续接入流水线 + +**REQ-NFR-036 内测分发** —— Android 走**托管的 OTA 分发页**,iOS 走 **TestFlight**;不使用 Firebase App Distribution + +**REQ-NFR-037 强制升级** —— 是否需要版本强制升级机制 `TODO(REQ-NFR-037)`;若需要,倾向与内测分发一并收进后台的「APP 发布管理」功能 --- @@ -2316,13 +2577,11 @@ JSBridge 能力清单 ## 9.1 验收原则 -| 编号 | 原则 | -| --- | --- | -| REQ-ACC-001 | 每条验收标准必须可由一名不了解实现的测试人员在真机上独立执行并判定通过/失败 | -| REQ-ACC-002 | 带 `TODO` 的需求在其待确认项关闭前**不进入验收范围**;关闭后须补写对应验收标准 | -| REQ-ACC-003 | 验收环境须覆盖**已接入 F6** 与**未接入 F6** 两类门店(见 [REQ-INT-008](#73-f6-集成边界)) | +**REQ-ACC-001** —— 每条验收标准必须可由一名不了解实现的测试人员在真机上独立执行并判定通过/失败 -验收原则 +**REQ-ACC-002** —— 带 `TODO` 的需求在其待确认项关闭前**不进入验收范围**;关闭后须补写对应验收标准 + +**REQ-ACC-003** —— 验收环境须覆盖**已接入 F6** 与**未接入 F6** 两类门店(见 [REQ-INT-008](#73-f6-集成边界)) ## 9.2 整合类验收 @@ -2382,7 +2641,7 @@ JSBridge 能力清单 # 10 风险与待确认项 -本章汇总全部未决事项。**共 13 项需求冲突、99 项编号待确认项、6 项跨文档阻塞项。** +本章汇总全部未决事项。**共 13 项需求冲突、99 项编号待确认项、1 项跨文档阻塞项**(原 6 项中的 5 项已于 2026-08 裁决,结论见 [10.3](#103-跨文档阻塞项))。 ## 10.1 需求冲突清单 @@ -2567,7 +2826,7 @@ JSBridge 能力清单 | --- | --- | --- | | REQ-NFR-001 | 冷启动时长指标 | 架构 | | REQ-NFR-002 | 首页首屏时长指标 | 架构 | -| REQ-NFR-004 | 扫码识别时延与成功率指标(三种模式) | 架构 | +| REQ-NFR-004 | 扫码识别时延与成功率指标(**条码 / VIN 在端上离线完成;车牌为云端 OCR,时延含网络往返与图片上传,需分开定指标**) | 架构 | | REQ-NFR-006 | 门店总数与日活峰值(并发基线) | 业务 | | REQ-NFR-025 | 最低系统版本 | 架构 | | REQ-NFR-026 | 门店常用机型与屏幕尺寸清单 | 业务 | @@ -2578,18 +2837,25 @@ JSBridge 能力清单 ## 10.3 跨文档阻塞项 -以下 6 项来自仓库《文档仓库说明》,不解决会直接卡住工程落地。本文档不重复论证,只登记其对需求的影响。 +以下来自仓库《文档仓库说明》,不解决会直接卡住工程落地。本文档不重复论证,只登记其对需求的影响。**当前仅剩 1 项未决**,另 5 项已于 2026-08 裁决,结论一并登记在下方。 | 阻塞项 | 出处 | 对本文档的影响 | | --- | --- | --- | -| **iOS 构建链路不成立** | 《App 多环境构建文档》 | 现有 GitLab Runner 为 Linux,`flutter build ipa` 需 macOS。影响 [REQ-NFR-035](#87-包体积与发布) | -| **后端错误码表未定** | 《App 错误处理与 API 契约文档》、《后端 API 设计规范文档》 | 客户端只能全部走默认文案,影响各模块「异常流程」的可验收性([REQ-NFR-014](#83-可用性与容错)) | -| **Sentry 自建还是 SaaS 未定** | 《App 可观测性与埋点文档》 | SaaS 属数据出境且门店网络可达性存疑;影响 [REQ-NFR-023](#84-安全与合规) | -| **神策服务是否可用未确认** | 《App 可观测性与埋点文档》 | **[后台首页看板](#62-后台管理首页)的访问趋势、转化漏斗、活跃/僵尸用户全部依赖埋点** —— 埋点不落地,看板即为空 | -| **内测分发渠道未定** | 《App 多环境构建文档》 | 影响 UAT 阶段的验收执行 | -| **车牌识别技术路径未验证** | 《App 原生能力集成文档》 | 通用扫码库只解条码/二维码,VIN 与车牌需 OCR、车牌可能需商用 SDK。**直接影响 [4.3.2 接车](#432-接车与车辆识别)与 [4.5.2 延保建单](#452-消费者激活与门店建单)两条主流程** | +| **神策服务是否可用未确认** | 《App 可观测性与埋点文档》 | **[后台首页看板](#62-后台管理首页)的访问趋势、转化漏斗、活跃/僵尸用户全部依赖埋点** —— 埋点不落地,看板即为空。**且 Dart 异常的明细看板也建在神策上**(见下表崩溃上报一行),该项权重高于最初评估 | -跨文档阻塞项 +未决的跨文档阻塞项 + +已裁决的 5 项: + +| 原阻塞项 | 结论 | 对本文档的影响 | +| --- | --- | --- | +| iOS 构建链路不成立 | 由**远程 Mac 出包**,首版手工执行仓库内构建脚本,后续注册成流水线 Runner | [REQ-NFR-035](#87-包体积与发布) 已改写为确定要求 | +| 后端错误码表未定 | **分段方案与基础码已定死,业务码在开发对应模块时随接口增补**;客户端对未识别的码统一展示后端提示文案 | [REQ-NFR-014](#83-可用性与容错) 已改写;各模块「异常流程」可按默认文案验收 | +| 崩溃上报平台未定 | **不引入独立崩溃平台**。原生崩溃 / ANR 走**腾讯 Bugly**,Dart 异常明细与错误看板走**神策自定义事件**(在神策上二次开发) | [REQ-NFR-015](#83-可用性与容错) 已改写;[REQ-NFR-023](#84-安全与合规) 数据出境风险随之消除 | +| 内测分发渠道未定 | Android 走**托管的 OTA 分发页**,iOS 走 **TestFlight**,不使用 Firebase;后续可能并入后台的「APP 发布管理」 | [REQ-NFR-036](#87-包体积与发布) 已改写;与 [REQ-NFR-037](#87-包体积与发布) 强制升级的实现方式相关 | +| 车牌识别技术路径未验证 | **采用付费的云端 OCR 服务**(拍照上传换识别结果),客户端不直连、由 App 后端代理 | [REQ-SAL-002](#439-业务规则) 已改写。**交互由「取景框自动识别」改为「拍一张照」**,涉及该处设计稿的调整;弱网不可用与计费风险登记为 [R9](#104-风险登记);照片上传第三方需写入隐私政策([REQ-NFR-023](#84-安全与合规)) | + +已裁决的跨文档阻塞项 ## 10.4 风险登记 @@ -2603,6 +2869,7 @@ JSBridge 能力清单 | R6 | **弱网 + 大文件上传是刚需场景** | 装车视频、鉴定证据上传失败会直接阻断理赔业务 | 断点续传与本地暂存作为首版必做项([8.6](#86-弱网与离线)) | | R7 | **导航配置端与 App 强耦合** | 后台「APP 配置」未建模则 tab 无法下发 | `TODO(REQ-ADM-002)` 与 `TODO(REQ-HOM-010)` 需一并裁决 | | R8 | **能力陈述式需求不可直接验收** | 开发理解偏差 | 全部需求须沿用 [1.8](#18-文档编写约定) 的模板书写,新增与修订同样适用 | +| R9 | **车牌识别依赖网络** —— 技术路径已定为付费的云端 OCR 服务,识别准确率由供应商保证,但**门店地下车库、施工区弱网时该功能不可用**,且按调用次数计费 | 接车与延保建单两条主流程在弱网下退化为纯手工录入 | ① 手工输码作为常驻并列入口,不是失败后的降级;② 上传前压缩图片,超时与重试上限设死,失败即转手工输码不自动重试;③ 置信度低于阈值时以「待确认」展示而非直接回填;④ 调用量需监控告警,防止误做成连续帧调用 | 风险登记 @@ -2778,7 +3045,7 @@ JSBridge 能力清单 # 附录 C 图表清单 -本文档共 **173 张图**与 **65 张表**。图片按章节顺序排列,本清单给出每张图的说明与源文件,可用于核对导出后的 Word 文档配图是否完整、以及追溯每张截图的出处。 +本文档共 **173 张图**与 **66 张表**。图片按章节顺序排列,本清单给出每张图的说明与源文件,可用于核对导出后的 Word 文档配图是否完整、以及追溯每张截图的出处。 ## C.1 图片来源分布 @@ -3062,61 +3329,39 @@ JSBridge 能力清单 | 8 | 企业内部干系人 | [3.2 企业内部干系人](#32-企业内部干系人) | | 9 | 外部对接系统厂商 | [3.3 外部对接系统厂商](#33-外部对接系统厂商) | | 10 | 模块总览 | [4.0 模块总览](#40-模块总览) | -| 11 | 账号登录业务规则 | [4.1 账号登录](#41-账号登录) | -| 12 | 账号登录业务数据 | [4.1 账号登录](#41-账号登录) | -| 13 | 现状入口 → App 导航映射 | [4.2 APP 首页](#42-app-首页) | -| 14 | 待办事项两层口径 | [4.2 APP 首页](#42-app-首页) | -| 15 | 动态预警项 | [4.2 APP 首页](#42-app-首页) | -| 16 | 店长 / 技工首页分区差异 | [4.2 APP 首页](#42-app-首页) | -| 17 | 三版底部导航方案 | [4.2 APP 首页](#42-app-首页) | -| 18 | 角色化 tab 配置(建议值,待确认) | [4.2 APP 首页](#42-app-首页) | -| 19 | APP 首页业务规则 | [4.2 APP 首页](#42-app-首页) | -| 20 | APP 首页信息字段 | [4.2 APP 首页](#42-app-首页) | -| 21 | 根据车牌获取车主车辆信息 | [4.3 销售](#43-销售) | -| 22 | 销售业务规则 | [4.3 销售](#43-销售) | -| 23 | 保养提醒规则字段 | [4.4 提醒](#44-提醒) | -| 24 | 提醒模块移植方案候选 | [4.4 提醒](#44-提醒) | -| 25 | 提醒业务规则 | [4.4 提醒](#44-提醒) | -| 26 | 延保业务规则 | [4.5 延保](#45-延保) | -| 27 | 采购业务规则 | [4.6 采购](#46-采购) | -| 28 | 采购业务数据表 | [4.6 采购](#46-采购) | -| 29 | 库存查询现状与目标差异 | [4.8 库存](#48-库存) | -| 30 | 库存业务规则 | [4.8 库存](#48-库存) | -| 31 | 三套「我的」的合并归属 | [4.9 我的 / 个人中心](#49-我的--个人中心) | -| 32 | 门店管理业务规则 | [4.10 门店管理](#410-门店管理) | -| 33 | 财务与对账业务规则 | [4.11 财务与对账](#411-财务与对账) | -| 34 | 返利中心业务规则 | [4.12 返利中心](#412-返利中心) | -| 35 | 经营业绩与报表业务规则 | [4.13 经营业绩与报表](#413-经营业绩与报表) | -| 36 | 营销与会员业务规则 | [4.14 营销与会员](#414-营销与会员) | -| 37 | 福利兑换(MSIP)待确认问题 | [4.15 福利兑换(MSIP)](#415-福利兑换msip) | -| 38 | 主数据来源 | [5.1 主数据来源](#51-主数据来源) | -| 39 | 主数据使用原则 | [5.2 主数据使用原则](#52-主数据使用原则) | -| 40 | 后台管理首页看板构成 | [6.2 后台管理首页](#62-后台管理首页) | -| 41 | 访问链路原则 | [7.1 访问链路原则](#71-访问链路原则) | -| 42 | 系统集成矩阵 | [7.2 集成矩阵](#72-集成矩阵) | -| 43 | F6 集成边界 | [7.3 F6 集成边界](#73-f6-集成边界) | -| 44 | JSBridge 能力清单 | [7.3 F6 集成边界](#73-f6-集成边界) | -| 45 | 门店能力开关 | [7.4 门店能力开关](#74-门店能力开关) | -| 46 | 门店能力开关规则 | [7.4 门店能力开关](#74-门店能力开关) | -| 47 | 性能要求 | [8.1 性能](#81-性能) | -| 48 | 统一交互规则 | [8.2 统一交互规则](#82-统一交互规则) | -| 49 | 可用性与容错 | [8.3 可用性与容错](#83-可用性与容错) | -| 50 | 安全与合规 | [8.4 安全与合规](#84-安全与合规) | -| 51 | 兼容性 | [8.5 兼容性](#85-兼容性) | -| 52 | 弱网与离线 | [8.6 弱网与离线](#86-弱网与离线) | -| 53 | 包体积与发布 | [8.7 包体积与发布](#87-包体积与发布) | -| 54 | 验收原则 | [9.1 验收原则](#91-验收原则) | -| 55 | 整合类验收标准 | [9.2 整合类验收](#92-整合类验收) | -| 56 | 角色与权限验收标准 | [9.3 角色与权限验收](#93-角色与权限验收) | -| 57 | 集成验收标准 | [9.4 集成验收](#94-集成验收) | -| 58 | 非功能验收标准 | [9.5 非功能验收](#95-非功能验收) | -| 59 | 需求冲突清单 | [10.1 需求冲突清单](#101-需求冲突清单) | -| 60 | 跨文档阻塞项 | [10.3 跨文档阻塞项](#103-跨文档阻塞项) | -| 61 | 风险登记 | [10.4 风险登记](#104-风险登记) | -| 62 | 权限矩阵(功能 × 角色) | [附录 B 权限矩阵](#附录-b-权限矩阵) | -| 63 | 权限实施规则 | [附录 B 权限矩阵](#附录-b-权限矩阵) | -| 64 | 图片来源分布 | [C.1 图片来源分布](#c1-图片来源分布) | -| 65 | 需求追溯矩阵(RTM)骨架 | [D.2 模块级追溯汇总](#d2-模块级追溯汇总) | +| 11 | 账号登录业务数据 | [4.1 账号登录](#41-账号登录) | +| 12 | 现状入口 → App 导航映射 | [4.2 APP 首页](#42-app-首页) | +| 13 | 待办事项两层口径 | [4.2 APP 首页](#42-app-首页) | +| 14 | 动态预警项 | [4.2 APP 首页](#42-app-首页) | +| 15 | 店长 / 技工首页分区差异 | [4.2 APP 首页](#42-app-首页) | +| 16 | 三版底部导航方案 | [4.2 APP 首页](#42-app-首页) | +| 17 | 角色化 tab 配置(建议值,待确认) | [4.2 APP 首页](#42-app-首页) | +| 18 | APP 首页信息字段 | [4.2 APP 首页](#42-app-首页) | +| 19 | 根据车牌获取车主车辆信息 | [4.3 销售](#43-销售) | +| 20 | 保养提醒规则字段 | [4.4 提醒](#44-提醒) | +| 21 | 提醒模块移植方案候选 | [4.4 提醒](#44-提醒) | +| 22 | 采购业务数据表 | [4.6 采购](#46-采购) | +| 23 | 库存查询现状与目标差异 | [4.8 库存](#48-库存) | +| 24 | 三套「我的」的合并归属 | [4.9 我的 / 个人中心](#49-我的--个人中心) | +| 25 | 福利兑换(MSIP)待确认问题 | [4.15 福利兑换(MSIP)](#415-福利兑换msip) | +| 26 | 主数据来源 | [5.1 主数据来源](#51-主数据来源) | +| 27 | 后台管理首页看板构成 | [6.2 后台管理首页](#62-后台管理首页) | +| 28 | 系统集成矩阵 | [7.2 集成矩阵](#72-集成矩阵) | +| 29 | F6 集成边界 | [7.3 F6 集成边界](#73-f6-集成边界) | +| 30 | JSBridge 能力清单 | [7.3 F6 集成边界](#73-f6-集成边界) | +| 31 | 门店能力开关 | [7.4 门店能力开关](#74-门店能力开关) | +| 32 | 整合类验收标准 | [9.2 整合类验收](#92-整合类验收) | +| 33 | 角色与权限验收标准 | [9.3 角色与权限验收](#93-角色与权限验收) | +| 34 | 集成验收标准 | [9.4 集成验收](#94-集成验收) | +| 35 | 非功能验收标准 | [9.5 非功能验收](#95-非功能验收) | +| 36 | 需求冲突清单 | [10.1 需求冲突清单](#101-需求冲突清单) | +| 37 | 未决的跨文档阻塞项 | [10.3 跨文档阻塞项](#103-跨文档阻塞项) | +| 38 | 已裁决的跨文档阻塞项 | [10.3 跨文档阻塞项](#103-跨文档阻塞项) | +| 39 | 风险登记 | [10.4 风险登记](#104-风险登记) | +| 40 | 权限矩阵(功能 × 角色) | [附录 B 权限矩阵](#附录-b-权限矩阵) | +| 41 | 权限实施规则 | [附录 B 权限矩阵](#附录-b-权限矩阵) | +| 42 | 图片来源分布 | [C.1 图片来源分布](#c1-图片来源分布) | +| 43 | 需求追溯矩阵(RTM)骨架 | [D.2 模块级追溯汇总](#d2-模块级追溯汇总) | --- @@ -3143,12 +3388,13 @@ RTM 用于保证「痛点 → 需求 → 设计 → 开发 → 测试」四段 **填写规则** -| 编号 | 规则 | -| --- | --- | -| REQ-ACC-008 | 每条需求**必须**有至少一条验收标准;无验收标准的需求不得进入开发排期 | -| REQ-ACC-009 | 状态为「待确认」的需求**不得**进入开发排期;其在 [10.2](#102-待确认项清单) 中必须有对应条目与责任人 | -| REQ-ACC-010 | 需求变更时同步更新 RTM 与本文档修订历史,变更未同步的视为未变更 | -| REQ-ACC-011 | [10.1](#101-需求冲突清单) 中 C1–C13 任一冲突未裁决前,受其影响的需求一律标「待确认」 | +**REQ-ACC-008** —— 每条需求**必须**有至少一条验收标准;无验收标准的需求不得进入开发排期 + +**REQ-ACC-009** —— 状态为「待确认」的需求**不得**进入开发排期;其在 [10.2](#102-待确认项清单) 中必须有对应条目与责任人 + +**REQ-ACC-010** —— 需求变更时同步更新 RTM 与本文档修订历史,变更未同步的视为未变更 + +**REQ-ACC-011** —— [10.1](#101-需求冲突清单) 中 C1–C13 任一冲突未裁决前,受其影响的需求一律标「待确认」 ## D.2 模块级追溯汇总 @@ -3172,9 +3418,9 @@ RTM 用于保证「痛点 → 需求 → 设计 → 开发 → 测试」四段 | MDM | 主数据 | [第 5 章](#5-主数据) | 6 | 3 | 50% | | ADM | 后台管理 | [第 6 章](#6-后台管理) | 7 | 7 | **0%** | | INT | 系统集成 | [第 7 章](#7-系统集成与架构边界) | 12 | 3 | 75% | -| NFR | 非功能需求 | [第 8 章](#8-非功能需求) | 37 | 10 | 73% | +| NFR | 非功能需求 | [第 8 章](#8-非功能需求) | 38 | 10 | 74% | | ACC | 验收与追溯 | [第 9 章](#9-验收标准)、本附录 | 11 | 0 | 100% | -| | **合计** | | **191** | **99** | **48%** | +| | **合计** | | **192** | **99** | **48%** | 需求追溯矩阵(RTM)骨架 @@ -3206,8 +3452,8 @@ RTM 用于保证「痛点 → 需求 → 设计 → 开发 → 测试」四段 本文档覆盖账号登录、首页与导航、销售、提醒、延保、采购、库存、个人中心、门店管理、财务对账、返利中心、经营业绩、营销与会员、福利兑换等 20 个模块,并把 173 张设计稿与现状截图全部编入对应章节作为需求实证。 -**本文档不是终稿。** 191 条需求中有 99 条处于「待确认」,集中在[第 10 章](#10-风险与待确认项)。这些是业务侧尚未给出结论的事项 —— 显式列出来,比用看似完整的文字掩盖过去更有价值。建议下一步: +**本文档不是终稿。** 192 条需求中有 99 条处于「待确认」,集中在[第 10 章](#10-风险与待确认项)。这些是业务侧尚未给出结论的事项 —— 显式列出来,比用看似完整的文字掩盖过去更有价值。建议下一步: 1. 先关闭 [C1 底部导航三版](#101-需求冲突清单) 与 [REQ-ADM-002 后台 APP 配置](#62-后台管理首页) —— 这两条卡住整个信息架构 2. 再补 OPU / MSP / MIN / ADM 四个 0% 模块的需求 -3. 同步推进 [10.3 跨文档阻塞项](#103-跨文档阻塞项) 中的 iOS 构建链路与后端错误码表 —— 这两条卡工程落地,与需求评审可并行 +3. 确认[埋点服务的可用性](#103-跨文档阻塞项) —— 这是仅剩的跨文档阻塞项,同时决定[后台首页看板](#62-后台管理首页)与线上错误看板能否落地,属采购流程,与需求评审可并行 diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..94df130 --- /dev/null +++ b/skills-lock.json @@ -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" + } + } +}