Files
patbond-doc/docs/development/iterations/iteration-2/25-weights-vaccines-ui-report.md
T
lixi b81c050e03
CI / docs-build (push) Successful in 58s
docs: M2 第三波收口——报告 22~27 入档挂导航
- 22~26 Flutter 接入五单报告(T2-11~14 + 白名单扩充,flutter 测试 64→272)
- 27 第三波收口总表:demo 数据消亡、四态纪律、埋点端到端贯通、DEBT-1 偿还
- 三次 compose 实测无契约偏差;波内 agent 中断续跑事故记录在案

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 14:10:36 +08:00

154 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`