docs: M2 第三波收口——报告 22~27 入档挂导航
CI / docs-build (push) Successful in 58s

- 22~26 Flutter 接入五单报告(T2-11~14 + 白名单扩充,flutter 测试 64→272)
- 27 第三波收口总表:demo 数据消亡、四态纪律、埋点端到端贯通、DEBT-1 偿还
- 三次 compose 实测无契约偏差;波内 agent 中断续跑事故记录在案

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 14:10:36 +08:00
parent 222990e587
commit b81c050e03
7 changed files with 768 additions and 0 deletions
@@ -0,0 +1,153 @@
# T2-13 体重与疫苗模块接入(交付报告)
**执行日期**2026-09-08
**角色**Frontend DeveloperFlutter
**工单**:T2-13(L,关键路径最后一个 L 单)
**依据**01 号拆解 T2-13 节、22 号数据层交付(T2-11)、23 号页面交付(T2-12)、05 号 UI 规范、06 号埋点规划、24 号白名单 v2(后端 dev@64c9b72)、冻结契约 openapi.yaml v1.2.0
**提交**patbond-flutter dev@`c91f18a`(基线 97a1f46,已 push origin dev),拆 2 个逻辑提交:
| 提交 | 内容 |
|------|------|
| `5b34fa3` | 体重半边:体重录入表单 + 历史列表(cursor 分页四态)、health_record 埋点封装、展示纯函数、控制器 repository 暴露 |
| `c91f18a` | 疫苗半边:疫苗登记表单 + 记录列表(状态机拦截)、档案页数据卡行接 summary、埋点装配 |
---
## 0. 结论摘要
- 体重(录入 + cursor 分页历史)与疫苗(目录选择登记 + 系列分组列表)全链路走 T2-11 数据层,页面零直连 ApiClient;
- **档案页数据卡行改接 `GET /pets/{id}/summary` 实时聚合**:最新体重 / 疫苗进度 / 下一针三卡取数,null 语义为空态文案而非 0/0(demo 的 `vaccines.reminderVaccine` 等本地字符串已在 T2-12 随 AppState.vaccines 删除,本单完成「接真实数」的另一半);
- 疫苗状态机非法路径前端拦截(结构化 + 纯函数校验)+ 后端 42201/40904 兜底提示,**均有测试与 compose 实测**
- 埋点:health_record 域 4 事件挂通(create 三事件 recordType=weight/vaccine + viewed),照 T2-12 强类型封装模式;
- 四态硬要求达成:体重列表、疫苗列表、疫苗目录、摘要卡行四个网络面均 loading/empty/error/retry 齐备且有 widget 测试;
- **跨设备验收(工单硬项)通过**:compose 实测建档→记体重→登疫苗后,同账号全新会话(等价清本地数据重登/第二设备)数据全量可见;第二账号访问 40401 防枚举(§6);
- 测试 **177 → 224 全绿(+47**`flutter analyze` 0 问题,`dart format` 无 diff(两个提交逐个通过门禁:5b34fa3 时点 205 全绿)。
## 1. 页面与四态覆盖表
| 页面 / 网络面 | loading | empty | error | retry | 测试文件 |
|---|---|---|---|---|---|
| 体重历史列表(`weight_records_page.dart` | 居中转圈 ✅ | `EmptyStateIllustration`「还没有体重记录」+ 录入 CTA(canWrite)✅ | `InlineErrorBanner` 按错误分档 ✅ | 重试按钮 + 下拉刷新 ✅ | `weight_records_page_test.dart`7 |
| 体重分页(同页「加载更多」) | 行内小转圈 ✅ | 末页收起按钮 ✅ | 翻页失败 SnackBar、按钮保留 ✅ | 可再点 ✅ | 同上(cursor 透传/追加不重不漏有测试) |
| 疫苗记录列表(`vaccination_records_page.dart`) | 居中转圈 ✅ | 「还没有疫苗记录」+ 登记 CTA ✅ | 横幅 ✅ | 重试按钮 + 下拉刷新 ✅ | `vaccination_records_page_test.dart`5 |
| 疫苗表单目录面(`vaccination_form_page.dart` 内) | 内联转圈 ✅ | 「该物种暂无可选疫苗目录」✅ | 「目录加载失败」提示 ✅ | 内联重试 ✅ | `vaccination_form_page_test.dart`9 |
| 档案页摘要卡行(`pet_detail_page.dart` 内) | 卡行小转圈 ✅ | 逐卡 null 空态文案(§2)✅ | 行内「健康数据加载失败」✅(不阻塞档案主链路,有测试) | 行内重试 ✅ | `pet_detail_page_test.dart` 增量(5 |
页面结构与导航:
```text
P2 宠物详情(pet_detail
├─ 健康数据卡行(summary 三卡,可点)
│ ├─ 最新体重卡 ──→ 体重历史列表(无路由名,曝光走 viewed)
│ │ └─ + → 体重录入表单(路由名 record_form
│ └─ 疫苗进度卡 / 下一针卡 ──→ 疫苗记录列表(按系列分组)
│ └─ + → 疫苗登记表单(路由名 record_form
└─ 基本资料(T2-12 既有)
```
- 从记录页返回详情即重拉 summary(服务端实时聚合是唯一事实来源);
- 权限:记录写入为 WRITE 档(owner+caregiver),`viewer` 在两个列表页均隐藏录入/登记入口(40300 语义前置,有测试);40300 后端兜底为表单横幅。
## 2. summary 取数替换 demo 对照
| 展示位 | demo 时代(T2-12 前) | 现取数(本单) | null 语义 |
|---|---|---|---|
| 最新体重卡 | `AppState.pet.weight` 本地常量(5.2 | `summary.latestWeight.weightKg`(口径:weights 列表首行同源) | null → 「暂无记录」 |
| 疫苗进度卡 | `AppState.vaccines` 推导字符串(T2-12 已删) | `summary.vaccinationProgress``completedDoses/totalDoses` | null → 「未登记」(**不是 0/0**,有测试锁定) |
| 下一针卡 | `vaccines.reminderVaccine` 本地字符串(T2-12 已删) | `summary.nextVaccination``dueOn + vaccineName`planned/nextDue 并集口径,dueOn 可为过去日期) | null → 「暂无安排」 |
- 展示字符串全部由服务端事实字段即时计算(第 4.3 节「不持久化展示字符串」红线,客户端同样不缓存);
- `tz` 参数本单不传(缺省 UTC):三卡均不消费 monthlyExpense,月度窗口口径留给 T2-14 月度花费卡一并接(测试锁定 tz 缺席)。
## 3. 疫苗状态机拦截(前端 + 后端兜底)
前端两层拦截:
1. **结构化拦截**:scheduled 态只渲染「计划接种日期」、completed 态只渲染「接种日期(+可选下次接种日期)」——「scheduled 携带 administeredOn」在 UI 上不可表达;请求体按状态只发对应字段(测试锁定 scheduled 请求无 `administeredOn`/`nextDueOn` 键)。
2. **纯函数校验** `vaccinationDateRuleError``health_record_display.dart`,与 42201 规则逐条对齐,9 分支单测):scheduled 必有 plannedOncompleted 必有 administeredOn(「未填接种日期就标完成」拦截,验收标准原文场景);nextDueOn ≥ administeredOn。
后端兜底(均有 widget 测试 + compose 实测):
| 码 | 场景 | 呈现 |
|---|---|---|
| 42201 | 状态-日期规则违反(前端拦截被绕过/契约漂移兜底) | 横幅「接种状态与日期不符合规则,请核对后重试」 |
| 40904 | 同系列同剂次非 cancelled 记录已存在 | 横幅「该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记」 |
其余错误分层沿用 T2-1240300 横幅、40401 SnackBar+返回、40000 横幅、429、网络 SnackBar+重试、会话失效静默(两表单同款矩阵,测试锁定)。
体重表单前端校验对齐契约:weightKg (0, 500] 且最多两位小数(正则 + 区间,越界/三位小数/非数字拦截有测试),40000 后端兜底横幅;称重时刻今日取此刻、历史日期取当日 12:00,**转 UTC(ISO 带 Z)上送**,规避无时区后缀的解析歧义。
## 4. 埋点挂接清单(T2-17 前端半边 · health_record 域)
强类型封装 `lib/features/pets/health_record_analytics.dart`(枚举编译期锁死;后端白名单 dev@64c9b72 已就绪,24 号 §2.2),注入链 app.dart → MainShellPage → PetsPage → PetDetailPage → 记录页面族:
| # | 事件 / 页名 | 触发点 | 属性 | 测试 |
|---|---|---|---|---|
| 1 | `health_record_create_started` | 体重/疫苗表单**首次输入**(每次进入一次,表单层去重) | `recordType`weight/vaccine)、`entryPoint``record_list`——表单均由列表页进入) | 去重 ✅ |
| 2 | `health_record_create_succeeded` | 创建接口 code=0 | `recordType``durationMs`(表单打开→成功)、`photoCount`(M2 无媒体恒 0) | 属性齐备 ✅ |
| 3 | `health_record_create_failed` | 失败响应 / 本地校验拦截 / 网络 | `recordType``failureReason`(六值枚举)、`errorCode`(可空)、`httpStatus``code ~/ 100` 推导)、`attemptSeq` | 校验/40904/42201/40000/40300/网络路径 ✅ |
| 4 | `health_record_viewed` | 体重/疫苗**列表页每次进入的首个成功加载**(工单口径:列表曝光) | `recordType``source=pet_detail`(列表由详情页进入) | 仅一次 ✅ |
| 5 | `page_viewed(record_form)` | 两个表单页 push`RouteSettings(name: 'record_form')`,既有 AnalyticsRouteObserver 采集) | 既有 pageName/referrer | 路由名断言 ✅ |
口径决策(报数据侧知悉):
1. **viewed 时点与 06 §1.4 的出入**:06 定义 viewed 在记录「详情页」可见;M2 体重/疫苗无独立详情页,按工单指令取「列表曝光」——每次进入列表页在首个成功加载时上报一次,不随滚动逐条上报,06 的防事件洪水意图保持。`source` 取进入来源 `pet_detail`。若后续增设记录详情页(05 §4.3 P3),届时 viewed 语义回归 06 原文。
2. **列表页不设 page_viewed**:字典 v2 pageName 枚举无「记录列表」页名(仅 record_form/record_detail),按 06 §5.2 验收 4「字典外不上报」处理,列表曝光已由 viewed 承载;如数据侧需要,建议字典 v3 增补 `record_list` 页名。
3. `failureReason` 沿用 T2-12 口径:业务拒绝(40904/42201/40000)归 `validation_error``errorCode` 细分;断网/超时/5xx 并入 `network_error``permission_denied`/`not_found` 对应 40300/4040x。
4. 编辑/删除交互本单未落地(见 §7),`health_record_edit_*`/`deleted` 事件白名单已就绪、暂无挂接点。
## 5. 测试数变化
| 时点 | 测试数 | 说明 |
|------|--------|------|
| 基线(dev@97a1f46 | 177 | T2-12 交付 |
| 体重半边(dev@5b34fa3) | 205(+28,全绿) | 分提交门禁 |
| 本单(dev@`c91f18a` | **224+47,全绿)** | 见下分布 |
| 文件 | 数量 | 覆盖 |
|------|------|------|
| `health_record_analytics_test.dart` | 5 | 四事件属性形状、httpStatus 推导、可空属性缺席语义 |
| `health_record_display_test.dart` | 9 | 体重解析全矩阵(含 500 边界/三位小数/科学计数拒绝)、去尾零展示、疫苗状态/剂次/日期行映射、42201 规则函数 9 分支 |
| `weight_form_page_test.dart` | 7 | 空值/越界/三位小数拦截不发请求、成功请求形状(UTC 时间戳/可选 note/无 source)、started 去重、40000/40300/网络三兜底 + 事件断言 |
| `weight_records_page_test.dart` | 7 | 四态、cursor 透传与追加、末页收起、翻页失败保留重试、viewed 一次、viewer 无入口、录入闭环(record_form 路由名 + 插入列表头) |
| `vaccination_form_page_test.dart` | 9 | 目录按物种过滤/失败重试、疫苗与日期双拦截、completed 缺接种日期拦截、seriesKey 目录 code 预填、scheduled/completed 请求形状(scheduled 无 administeredOn 键)、40904/42201 兜底 + 事件、剂次非法拦截 |
| `vaccination_records_page_test.dart` | 5 | 四态、系列分组头/剂次/日期行/三态 TagPill(含 cancelled)、viewed 一次、登记闭环(成功重拉列表)、viewer 无入口 |
| `pet_detail_page_test.dart` 增量 | 5 | 三卡取数值、**null 空态而非 0/0**、摘要失败不阻塞主链路 + 行内重试、点卡导航 + 返回重拉摘要、viewer 权限透传 |
质量门禁:`flutter test` 224/224 全绿;`flutter analyze` No issues found`dart format --set-exit-if-changed` 无 diff(两个提交逐个通过)。
## 6. 跨设备验收实测记录(工单硬项)
环境:patbond-api dev@64c9b72`JAVA_HOME=java-17 ./mvnw -DskipTests package` + `docker compose up -d --build`auth :8081 / pet :8083)。curl 按页面实际请求复演,测试账号随机生成、token 脱敏、用后随 `docker compose down` 丢弃:
| 步骤 | 设备/账号 | 请求 | 结果 |
|------|------|------|------|
| 1 | 设备A · 账号A | POST /auth/register → POST /pets(柴犬「验收豆豆」) | code=0petId=01a07f70…(UUIDv7 |
| 2 | 设备A | POST /pets/{id}/weights4.35kgUTC 时间戳,带 Idempotency-Key | code=0,回读 weightKg=4.35 |
| 3 | 设备A | GET /vaccine-catalog?species=dog → POST vaccinations 第1针 completedadministeredOn 2026-08-10、nextDueOn 2027-08-10+ 第2针 scheduledplannedOn 2026-10-01 | 两针 code=0(犬二联疫苗,seriesKey=canine_2in1 |
| 4 | 设备A | 兜底路径:重复登记第1针 / 第3针 completed 不带 administeredOn | `40904 该疫苗系列剂次已登记` / `42201 completed 状态必须填写 administeredOn` —— 与表单兜底提示路径对齐 ✅ |
| 5 | 设备A | GET /pets/{id}/summary | latestWeight=4.35、vaccinationProgress **1/2**、nextVaccination=第2针 dueOn 2026-10-01source=planned)——三卡口径逐一核对 ✅ |
| 6 | **设备B(同账号清本地重登)** | POST /auth/login 取全新会话 → GET pets / weights / vaccinations / summary | 宠物、1 条体重、2 条疫苗、摘要三聚合**全量可见**——M2「数据可跨设备读取」✅ |
| 7 | **无关系账号B** | GET 宠物详情 / 体重 / 摘要、POST 体重 | 四路均 `40401 宠物不存在`(防枚举三态同响应)——「无权限用户不能访问」✅ |
结论:**跨设备读取与越权拒绝两条 M2 验收标准在真实后端逐条通过;40904/42201 兜底真实响应与前端提示路径一致;未发现契约偏差**。实测后 `docker compose down`patbond-api 仓库零改动。
## 7. 决策与遗留
| # | 事项 | 说明 |
|---|------|------|
| 1 | 记录表单用整页而非 05 §4.4 底部 sheet | 沿 T2-12 PetFormPage 整页先例:`record_form` 路由名可被既有 RouteObserver 采集(sheet 为 PopupRoute 采不到),漏斗到达段不缺口;视觉骨架与 05 字段规范一致 |
| 2 | 疫苗表单未含厂商/批号字段 | 契约可选字段,控制表单长度;PATCH 支持补录,随「编辑疫苗记录」交互一并落地(记小遗留) |
| 3 | 疫苗 scheduled→completed/cancelled 的列表操作未做 | 工单范围为登记表单+记录列表;PATCH updateVaccination 数据层就绪(T2-11),交互建议随 T2-14 或收口单补「标记完成/取消登记」,届时挂 `health_record_edit_*` 事件(白名单已就绪) |
| 4 | 体重表单不暴露 source 选择 | 客户端录入恒 manual(服务端缺省),clinic/device 留给后续接入场景 |
| 5 | seriesKey 交互 | 以目录 code 自动预填、可改;「系列」概念的更友好交互(预设初免/加强)待 UI 侧定稿 |
| 6 | 归档入口(T2-12 遗留 5) | 本单未动,仍留收口单 |
| 7 | 05 §4.2 stat 行第三卡「本月记录/花费」 | 本单第三卡为「下一针」(工单指定 nextVaccination 落点);月度花费卡随 T2-14 接 `monthlyExpense`(届时补 `tz` 透传) |
## 8. 交接 T2-14 / T2-18
- 时间线/提醒页可直接复用:`health_record_display.dart` 纯函数模式、列表页四态骨架、`HealthRecordAnalytics`recordType 枚举已含 `health_event`/`reminder`)、`_SummaryCard`(月度花费卡加一列即可,记得透传 `tz`——`monthlyExpense` 月边界随 tz 移动);
- E2E 烟囱(T2-18):本单 §6 的 curl 序列可直接并入烟囱脚本(建档→记体重→登疫苗→摘要核对→第二账号拒绝→重登可见)。
---
**Frontend Developer** · 2026-09-08 · patbond-flutter dev@`c91f18a`