222990e587
CI / docs-build (push) Successful in 1m19s
- 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>
12 KiB
12 KiB
16 · T2-04/T2-05 体重记录与疫苗接口交付报告
- 日期:2026-09-07
- 工单:T2-04(体重记录,M)+ T2-05(疫苗目录与疫苗记录,L),同域内聚一并交付
- 仓库:patbond-api,dev 分支(提交
825dde3T2-04、4c2653cT2-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 + caregiver(T2-03 §4 矩阵);caregiver 写成功的正向用例已按移交要求补齐(体重、疫苗各一,见 §6)。
1.1 响应字段
- WeightResponse:
id, petId, weightKg, measuredAt, source, note, createdAt。weightKg 两位小数(numeric(6,2));source ∈ manual/clinic/device,缺省 manual。 - VaccineCatalogResponse:
id, code, name, species, description。 - VaccinationResponse:
id, petId, vaccineId, vaccineName, seriesKey, doseNo, doseLabel, status, plannedOn, administeredOn, nextDueOn, manufacturer, batchNo, notes, createdAt, updatedAt, version。certificate_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),比对通过才写入并 +1;updated_at 由 V3 触发器维护。
2. 状态机实现说明
scheduled ──→ completed (合并态必须有 administeredOn)
scheduled ──→ cancelled (合并态 administeredOn 必须为空)
completed / cancelled:终态;同状态编辑(补批号/备注等)始终允许
- 校验时点:PATCH 先在「当前行 + 请求字段」的合并态上跑与创建完全相同的状态-日期规则,即改完后的行必须重新满足
ck_vaccination_dates——数据库约束保持兜底,客户端永远收到 42201 可读消息而非约束 500。 - 日期规则(镜像 V3):scheduled 必有 plannedOn 且不得带 administeredOn;completed 必有 administeredOn;cancelled 不得带 administeredOn;
nextDueOn ≥ 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 上限 32767(smallint 边界);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-07:health-events 的 cursor 分页可直接复用
CursorPage信封与WeightCursor同构游标(occurred_at DESC, id DESC);IdempotencyKeys换 resource 前缀即用。 - T2-08 summary:疫苗进度分母口径注意排除 cancelled(本单列表不过滤 status,聚合侧自行过滤);下次接种可用 ix_vaccinations_due(scheduled 部分索引)。
- 幂等键无 TTL 的取舍(§3)若契约侧不接受,需要专门的 idempotency 表 + 迁移,建议 M3 再议。