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

118 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 响应字段
- **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),比对通过才写入并 +1updated_at 由 V3 触发器维护。
## 2. 状态机实现说明
```
scheduled ──→ completed (合并态必须有 administeredOn
scheduled ──→ cancelled (合并态 administeredOn 必须为空)
completed / cancelled:终态;同状态编辑(补批号/备注等)始终允许
```
- **校验时点**:PATCH 先在「当前行 + 请求字段」的合并态上跑与创建完全相同的状态-日期规则,即改完后的行必须重新满足 `ck_vaccination_dates`——数据库约束保持兜底,客户端永远收到 42201 可读消息而非约束 500。
- 日期规则(镜像 V3):scheduled 必有 plannedOn 且不得带 administeredOncompleted 必有 administeredOncancelled 不得带 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 上限 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-07**health-events 的 cursor 分页可直接复用 `CursorPage` 信封与 `WeightCursor` 同构游标(occurred_at DESC, id DESC);`IdempotencyKeys` 换 resource 前缀即用。
- **T2-08 summary**:疫苗进度分母口径注意排除 cancelled(本单列表不过滤 status,聚合侧自行过滤);下次接种可用 ix_vaccinations_duescheduled 部分索引)。
- 幂等键无 TTL 的取舍(§3)若契约侧不接受,需要专门的 idempotency 表 + 迁移,建议 M3 再议。