Files
lixi 222990e587
CI / docs-build (push) Successful in 1m19s
docs: M2 第二波收口——报告 13~21 与契约草案入档挂导航
- 13~18 后端纵切六单报告(T2-03~08,测试 95→182)
- 14 + openapi-pets-draft.yaml 契约起草档案
- 19 契约冻结报告(v1.2.0,22 项草案修正对照)
- 20 契约一致性测试(快照机制 + 1 漂移修复)
- 21 第二波收口总表(定型语义汇总,第三波接入依据)
- mkdocs build --strict 通过

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

12 KiB
Raw Permalink Blame History

16 · T2-04/T2-05 体重记录与疫苗接口交付报告

  • 日期2026-09-07
  • 工单T2-04(体重记录,M)+ T2-05(疫苗目录与疫苗记录,L),同域内聚一并交付
  • 仓库patbond-apidev 分支(提交 825dde3 T2-04、4c2653c T2-05,已推送)
  • 角色Senior Developer(后端)
  • 前置:完全复用 T2-03 的 PetAccessService.require(userId, petId, AccessLevel) 单一闸口(13 号报告 §4),未新造任何权限逻辑;零新增数据库迁移(V3 表结构原样够用)

1. 端点清单与语义定型表(T2-09 契约冻结输入)

端点 权限级别 成功响应 说明
GET /api/v1/pets/{petId}/weights?limit=&cursor= READ 200{items, nextCursor, hasMore} cursor 分页,measured_at DESC, id DESC(与 ix_pet_weight_pet_measured 逐列对齐);limit 1~100 默认 20
POST /api/v1/pets/{petId}/weights WRITE 201,完整 WeightResponse 可选 Idempotency-Key 头(≤255 字符),见 §3
GET /api/v1/vaccine-catalog?species= 无(字典非用户数据,仅 Bearer 200,全量数组 仅 enabled 行;V4 种子 10 行;ORDER BY species, name
GET /api/v1/pets/{petId}/vaccinations READ 200,数组不分页 单宠疫苗量级小(定案 TODO-FREEZE #5);ORDER BY series_key, dose_no, created_at, id,客户端按系列直接成卡
POST /api/v1/pets/{petId}/vaccinations WRITE 201,完整 VaccinationResponse 可选 Idempotency-Key;创建状态仅 scheduled/completed
PATCH /api/v1/vaccinations/{vaccinationId} WRITE 200,更新后完整 VaccinationResponse 顶层短路径(草案裁量 #1 照采);version 必填乐观锁

WRITE 档 = owner + caregiverT2-03 §4 矩阵);caregiver 写成功的正向用例已按移交要求补齐(体重、疫苗各一,见 §6)。

1.1 响应字段

  • WeightResponseid, petId, weightKg, measuredAt, source, note, createdAt。weightKg 两位小数(numeric(6,2));source ∈ manual/clinic/device,缺省 manual。
  • VaccineCatalogResponseid, code, name, species, description
  • VaccinationResponseid, petId, vaccineId, vaccineName, seriesKey, doseNo, doseLabel, status, plannedOn, administeredOn, nextDueOn, manufacturer, batchNo, notes, createdAt, updatedAt, versioncertificate_asset_id / provider_id / provider_name_snapshot / booking_id 整体不出现(ADR-010,与草案 §4 注一致,M5 时纯增量补入)。
  • 分页信封 data: {items, nextCursor, hasMore} 照草案形态落地;nextCursor 为不透明 base64url 游标(编码 measured_at 微秒 + id),hasMore=false 时恒为 null。

1.2 PATCH 疫苗语义(定型)

  • 部分更新:缺席字段不变;沿用 T2-03 定型的「M2 不支持清空回 null」
  • vaccineId / seriesKey / doseNo 不可改(不在请求体)——登记错剂次的修正路径是 cancel 后重建(§2)。
  • version 必填(缺失 40000),比对通过才写入并 +1updated_at 由 V3 触发器维护。

2. 状态机实现说明

scheduled ──→ completed   (合并态必须有 administeredOn
scheduled ──→ cancelled   (合并态 administeredOn 必须为空)
completed / cancelled:终态;同状态编辑(补批号/备注等)始终允许
  • 校验时点:PATCH 先在「当前行 + 请求字段」的合并态上跑与创建完全相同的状态-日期规则,即改完后的行必须重新满足 ck_vaccination_dates——数据库约束保持兜底,客户端永远收到 42201 可读消息而非约束 500。
  • 日期规则(镜像 V3):scheduled 必有 plannedOn 且不得带 administeredOncompleted 必有 administeredOncancelled 不得带 administeredOnnextDueOn ≥ administeredOn(两者皆有时)。
  • completed 定为终态的理由:ck_vaccination_dates 要求 cancelled 行 administered_on 为空,completed→cancelled 必须先抹掉已接种事实,语义上不成立。
  • cancelled 定为终态(不提供复活):uq_pet_vaccination_dose 只约束非 cancelled 行,取消即释放同系列同剂次占位、可重新登记(有测试锁定);若允许 cancelled→scheduled 复活,会与替代记录撞唯一索引,产生无法自洽的错误语义。
  • next_due_on 维护:创建与 PATCH 均可写,仅做与 administeredOn 的次序校验;到期提醒的消费属 T2-07/T2-08。

3. 幂等实现(Idempotency-Key,草案可选头形态)

记录主键由 (资源类型, userId, petId, key) 经 SHA-256 确定性派生,插入用 ON CONFLICT (id) DO NOTHING:同键重试算出同一主键 → 插入空操作 → 返回已创建记录(同样 201)。零新增表/迁移(本单未动迁移链,符合工单预期)。语义边界(供契约冻结采纳措辞):

  • 键按「调用者 × 宠物 × 资源」隔离,两个用户的同名键不互斥;
  • 不比对请求体:同键不同体的重试返回原记录(客户端应每次逻辑提交换新键,建议 UUID);
  • 键永久幂等(无 TTL);不带键则无幂等语义,重复提交各自成行(体重本就允许同刻多条;疫苗由剂次唯一约束兜底 40904)。
  • ON CONFLICT 显式指定主键为仲裁索引,因此 uq_pet_vaccination_dose 违反仍正常抛出并映射 40904,两种冲突不混淆。

4. 错误码定型表(含新码,供 T2-09 冻结采用)

场景 HTTP code 说明
参数形状/字典错误:weightKg 越界(≤0、>500、>2 位小数)、limit 越界、cursor 无效、source/species 非白名单、创建疫苗 status=cancelled、疫苗不存在或停用、疫苗与宠物物种不匹配、PATCH 缺 version、Idempotency-Key 超长 400 40000 沿用既有码,message 带具体字段原因
宠物不存在/软删/无关系(weights、vaccinations 的宠物级路径) 404 40401 防枚举语义自动继承 T2-03 闸口,响应与不存在完全一致
顶层记录路径 PATCH /vaccinations/{id}:记录不存在 或 记录所属宠物对调用者不可见 404 40402 记录级防枚举(新定型):顶层短路径下探测 vaccinationId 与探测 petId 同理必须封死,两种情况响应完全一致;仅对宠物可见者才可能见到 40300
有关系但角色不覆盖(viewer 写体重/疫苗、viewer PATCH 记录) 403 40300 沿用
PATCH version 过期 409 40902 沿用;先写者数据保留(有测试)
同宠物同疫苗同系列同剂次已有非 cancelled 记录 409 40904(新增) VACCINATION_DOSE_EXISTS草案提议的 40903 已被 T2-03 的 MICROCHIP_EXISTS 占用(14 号报告起草时 13 号尚未定稿,两处撞号),按「错误码不复用不改号」原则顺延取 40904
状态机非法迁移 / 状态-日期规则违反(scheduled 缺 plannedOn、completed 缺 administeredOn、scheduled/cancelled 带 administeredOn、nextDueOn 早于 administeredOn、completed→cancelled、cancelled→scheduled 等) 422 42201(新增) VACCINATION_RULE_VIOLATION。采纳草案「422 区分业务规则违反与 40000 形状错误」的理由;一码多场景、message 说明具体规则

5. 与契约草案(14 号 + openapi-pets-draft.yaml)的偏差清单(7 项,供冻结评审)

# 草案 实现定案 理由
1 剂次重复用 40903 40904 40903 与 T2-03 已定型的 MICROCHIP_EXISTS 撞号(见 §4
2 新码 42200(品种互斥) 不采纳 品种互斥属 T2-03 已交付语义(40000),已被测试锁定;追改属破坏性调整且收益低。42201 照采
3 资源自身 ID 用类型化名(weightId/vaccinationId/vaccineId 作主键名) id 与已交付的 PetResponse/BreedResponse 一致(id + myRole/关联字段带类型名);域内一致性优先于草案裁量 #7,冻结时统一措辞
4 Vaccination schema 无疫苗名称 增加 vaccineName 与 pets 的 breedDisplayName 同一先例:列表页免于客户端二次查字典;纯增量字段
5 CreateVaccinationRequest.status 枚举含 cancelled 创建仅 scheduled/completed 创建即取消无业务意义,且会造成「占位再释放」的怪异路径;40000 拒绝
6 疫苗列表分页待定(TODO-FREEZE #5 不分页series_key, dose_no, created_at, id 排序 单宠疫苗记录量级为个位数~十位数;排序服务端定死,客户端按系列直接分组
7 UpdateVaccinationRequest 字段标 nullable(暗示可清空) 缺席=不变,不支持清空回 null 沿用 T2-03 §2.3 冻结的 PATCH 语义,把 null-vs-absent 歧义挡在 M2 契约外

实现侧新增而草案未提的收紧(建议一并写入契约描述):疫苗必须存在、enabled 且 species 与宠物一致(40000);doseNo 上限 32767smallint 边界);Idempotency-Key 语义细则见 §3。

6. 测试

覆盖 T2-10 六类路径,沿用 T2-03 测试基建(Testcontainers postgres:18 + 完整 V1..V4 迁移链、真实 BearerAuthFilter、pet_owners 直写构造角色):

类别 体重(8 用例) 疫苗(12 用例)
成功 owner 建→列全链路;**caregiver 写成功(T2-03 移交要求)**且双方可读 目录列表/过滤;scheduled→completed 全链路(部分更新字段保持);caregiver 建+改成功;列表排序
参数错误 weightKg 缺失/0/500.01/三位小数、缺 measuredAt、source 非法、limit 0/101、cursor 乱串(皆 40000);500.00 边界值合法 缺 vaccineId、doseNo=0、创建即 cancelled、疫苗不存在、犬苗打猫(皆 40000);PATCH 缺 version
不存在 随机 petId GET/POST → 40401 随机 petId → 40401;随机 vaccinationId PATCH → 40402
无权限 陌生人与随机 petId 响应逐字一致(防枚举断言);viewer 读通过/写 40300 陌生人 PATCH 真实记录与随机 id 同为 40402(记录级防枚举断言);viewer 读通过/POST与PATCH 40300
并发冲突 —(体重无乐观锁,append-only 旧 version PATCH → 40902,先写者 notes 保留(落库断言)
幂等重试 同键两次 201 同 id、落库 1 行;换键/不带键各自成行 同键两次 201 同 id、落库 1 行;不带键重复 → 40904;cancel 后同剂次可重建
分页专项 5 条走 3 页不丢不重、顺序严格 DESC、nextCursor 收尾为 null同 measured_at 三条跨页断续id 断续断言) —(不分页)

测试数变化

模块 交付前 交付后
patbond-common 3 3
patbond-user 59 59
patbond-auth 31 31
patbond-pet 25 45+20:体重 8 + 疫苗 12
合计 118 138

JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test 全绿(2026-09-07,一次通过)。

7. 遗留与移交

  • T2-09 冻结:§1/§4 为定型输入;§5 七项偏差需评审拍板(其中 #1 40904、#2 不引 42200 建议直接采纳,纯编号事实问题)。
  • T2-06/T2-07health-events 的 cursor 分页可直接复用 CursorPage 信封与 WeightCursor 同构游标(occurred_at DESC, id DESC);IdempotencyKeys 换 resource 前缀即用。
  • T2-08 summary:疫苗进度分母口径注意排除 cancelled(本单列表不过滤 status,聚合侧自行过滤);下次接种可用 ix_vaccinations_duescheduled 部分索引)。
  • 幂等键无 TTL 的取舍(§3)若契约侧不接受,需要专门的 idempotency 表 + 迁移,建议 M3 再议。