- 22~26 Flutter 接入五单报告(T2-11~14 + 白名单扩充,flutter 测试 64→272) - 27 第三波收口总表:demo 数据消亡、四态纪律、埋点端到端贯通、DEBT-1 偿还 - 三次 compose 实测无契约偏差;波内 agent 中断续跑事故记录在案 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 KiB
T2-13 体重与疫苗模块接入(交付报告)
执行日期:2026-09-08
角色:Frontend Developer(Flutter)
工单: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 analyze0 问题,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) |
页面结构与导航:
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. 疫苗状态机拦截(前端 + 后端兜底)
前端两层拦截:
- 结构化拦截:scheduled 态只渲染「计划接种日期」、completed 态只渲染「接种日期(+可选下次接种日期)」——「scheduled 携带 administeredOn」在 UI 上不可表达;请求体按状态只发对应字段(测试锁定 scheduled 请求无
administeredOn/nextDueOn键)。 - 纯函数校验
vaccinationDateRuleError(health_record_display.dart,与 42201 规则逐条对齐,9 分支单测):scheduled 必有 plannedOn;completed 必有 administeredOn(「未填接种日期就标完成」拦截,验收标准原文场景);nextDueOn ≥ administeredOn。
后端兜底(均有 widget 测试 + compose 实测):
| 码 | 场景 | 呈现 |
|---|---|---|
| 42201 | 状态-日期规则违反(前端拦截被绕过/契约漂移兜底) | 横幅「接种状态与日期不符合规则,请核对后重试」 |
| 40904 | 同系列同剂次非 cancelled 记录已存在 | 横幅「该系列该剂次已有记录(40904);如登记有误,可取消原记录后重新登记」 |
其余错误分层沿用 T2-12:40300 横幅、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 | 路由名断言 ✅ |
口径决策(报数据侧知悉):
- viewed 时点与 06 §1.4 的出入:06 定义 viewed 在记录「详情页」可见;M2 体重/疫苗无独立详情页,按工单指令取「列表曝光」——每次进入列表页在首个成功加载时上报一次,不随滚动逐条上报,06 的防事件洪水意图保持。
source取进入来源pet_detail。若后续增设记录详情页(05 §4.3 P3),届时 viewed 语义回归 06 原文。 - 列表页不设 page_viewed:字典 v2 pageName 枚举无「记录列表」页名(仅 record_form/record_detail),按 06 §5.2 验收 4「字典外不上报」处理,列表曝光已由 viewed 承载;如数据侧需要,建议字典 v3 增补
record_list页名。 failureReason沿用 T2-12 口径:业务拒绝(40904/42201/40000)归validation_error以errorCode细分;断网/超时/5xx 并入network_error;permission_denied/not_found对应 40300/4040x。- 编辑/删除交互本单未落地(见 §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=0,petId=01a07f70…(UUIDv7) |
| 2 | 设备A | POST /pets/{id}/weights(4.35kg,UTC 时间戳,带 Idempotency-Key) | code=0,回读 weightKg=4.35 |
| 3 | 设备A | GET /vaccine-catalog?species=dog → POST vaccinations 第1针 completed(administeredOn 2026-08-10、nextDueOn 2027-08-10)+ 第2针 scheduled(plannedOn 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-01(source=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