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

15 KiB
Raw Blame History

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 全绿(+47flutter analyze 0 问题,dart format 无 diff(两个提交逐个通过门禁:5b34fa3 时点 205 全绿)。

1. 页面与四态覆盖表

页面 / 网络面 loading empty error retry 测试文件
体重历史列表(weight_records_page.dart 居中转圈 EmptyStateIllustration「还没有体重记录」+ 录入 CTA(canWrite InlineErrorBanner 按错误分档 重试按钮 + 下拉刷新 weight_records_page_test.dart7
体重分页(同页「加载更多」) 行内小转圈 末页收起按钮 翻页失败 SnackBar、按钮保留 可再点 同上(cursor 透传/追加不重不漏有测试)
疫苗记录列表(vaccination_records_page.dart 居中转圈 「还没有疫苗记录」+ 登记 CTA 横幅 重试按钮 + 下拉刷新 vaccination_records_page_test.dart5
疫苗表单目录面(vaccination_form_page.dart 内) 内联转圈 「该物种暂无可选疫苗目录」 「目录加载失败」提示 内联重试 vaccination_form_page_test.dart9
档案页摘要卡行(pet_detail_page.dart 内) 卡行小转圈 逐卡 null 空态文案(§2 行内「健康数据加载失败」(不阻塞档案主链路,有测试) 行内重试 pet_detail_page_test.dart 增量(5

页面结构与导航:

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.vaccinationProgresscompletedDoses/totalDoses null → 「未登记」(不是 0/0,有测试锁定)
下一针卡 vaccines.reminderVaccine 本地字符串(T2-12 已删) summary.nextVaccinationdueOn + vaccineNameplanned/nextDue 并集口径,dueOn 可为过去日期) null → 「暂无安排」
  • 展示字符串全部由服务端事实字段即时计算(第 4.3 节「不持久化展示字符串」红线,客户端同样不缓存);
  • tz 参数本单不传(缺省 UTC):三卡均不消费 monthlyExpense,月度窗口口径留给 T2-14 月度花费卡一并接(测试锁定 tz 缺席)。

3. 疫苗状态机拦截(前端 + 后端兜底)

前端两层拦截:

  1. 结构化拦截:scheduled 态只渲染「计划接种日期」、completed 态只渲染「接种日期(+可选下次接种日期)」——「scheduled 携带 administeredOn」在 UI 上不可表达;请求体按状态只发对应字段(测试锁定 scheduled 请求无 administeredOn/nextDueOn 键)。
  2. 纯函数校验 vaccinationDateRuleErrorhealth_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,转 UTCISO 带 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 体重/疫苗表单首次输入(每次进入一次,表单层去重) recordTypeweight/vaccine)、entryPointrecord_list——表单均由列表页进入) 去重
2 health_record_create_succeeded 创建接口 code=0 recordTypedurationMs(表单打开→成功)、photoCountM2 无媒体恒 0 属性齐备
3 health_record_create_failed 失败响应 / 本地校验拦截 / 网络 recordTypefailureReason(六值枚举)、errorCode(可空)、httpStatuscode ~/ 100 推导)、attemptSeq 校验/40904/42201/40000/40300/网络路径
4 health_record_viewed 体重/疫苗列表页每次进入的首个成功加载(工单口径:列表曝光) recordTypesource=pet_detail(列表由详情页进入) 仅一次
5 page_viewed(record_form) 两个表单页 pushRouteSettings(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_errorerrorCode 细分;断网/超时/5xx 并入 network_errorpermission_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 founddart format --set-exit-if-changed 无 diff(两个提交逐个通过)。

6. 跨设备验收实测记录(工单硬项)

环境:patbond-api dev@64c9b72JAVA_HOME=java-17 ./mvnw -DskipTests package + docker compose up -d --buildauth :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 downpatbond-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 纯函数模式、列表页四态骨架、HealthRecordAnalyticsrecordType 枚举已含 health_event/reminder)、_SummaryCard(月度花费卡加一列即可,记得透传 tz——monthlyExpense 月边界随 tz 移动);
  • E2E 烟囱(T2-18):本单 §6 的 curl 序列可直接并入烟囱脚本(建档→记体重→登疫苗→摘要核对→第二账号拒绝→重登可见)。

Frontend Developer · 2026-09-08 · patbond-flutter dev@c91f18a