# 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 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 必有 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 | 路由名断言 ✅ | 口径决策(报数据侧知悉): 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=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`