docs: M2 第二波收口——报告 13~21 与契约草案入档挂导航
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>
This commit is contained in:
2026-09-08 10:40:27 +08:00
parent 511617be55
commit 222990e587
11 changed files with 2654 additions and 0 deletions
@@ -0,0 +1,144 @@
# 13 · T2-03 宠物 CRUD 与 pet_owners 权限框架交付报告
- **日期**2026-09-07
- **工单**:T2-03(M2 第二波关键路径)
- **仓库**patbond-apidev 分支
- **角色**Senior Developer(后端)
---
## 1. 交付范围
patbond-pet 模块(ADR-009)从第一波骨架升级为完整业务服务:
- `GET /api/v1/pets``POST /api/v1/pets``GET /api/v1/pets/{petId}``PATCH /api/v1/pets/{petId}`
- `GET /api/v1/breeds`(只读字典,`?species=dog|cat|other` 过滤)
- RS256 bearer 鉴权接入(与 patbond-user 同一公钥约定,`PATBOND_JWT_PUBLIC_KEY`
- 统一权限框架 `PetAccessService`(T2-04~07 的复用入口,见 §4)
- `version` 乐观锁、`ck_pets_breed` 互斥、软删除防护、芯片号唯一冲突
- docker-compose 的 pet 服务挂载 JWT 公钥(与 user 同一 deploy/keys
**明确不在本单**`DELETE /api/v1/pets/{petId}`(软删除端点)。D2-7 拍板首版前端只出「归档」入口;PATCH 已显式禁止 `status=deleted`(防绕过 `ck_pets_deleted` 的 deleted_at 记账),软删除端点留待契约冻结时决定是否收录(02 号报告亦标注「是否进 M2 契约冻结时定」)。归档(`status=archived`)已实现并有测试。
## 2. 端点清单与语义定型表(T2-09 契约冻结输入)
### 2.1 端点
| 端点 | 鉴权 | 权限级别 | 成功响应 |
| --- | --- | --- | --- |
| `GET /api/v1/breeds?species=` | Bearer | 无(字典非用户数据) | 200,全量数组(种子约 30 行,不分页) |
| `GET /api/v1/pets` | Bearer | 隐式(查询按调用者 pet_owners 行过滤) | 200,数组按 created_at DESC;无分页(单人宠物量小,02 号报告建议) |
| `POST /api/v1/pets` | Bearer | 任何登录用户 | **201**,返回完整 PetResponse;调用者自动写入 pet_ownersrole=owner, is_primary=true),与建宠同事务 |
| `GET /api/v1/pets/{petId}` | Bearer | READ(三角色皆可) | 200,含 `myRole` 字段(调用者自己的角色,客户端据此显隐写入口) |
| `PATCH /api/v1/pets/{petId}` | Bearer | MANAGE(仅 owner | 200,返回更新后完整 PetResponse |
### 2.2 PetResponse 字段(camelCaseUUID 字符串,日期 ISO 8601
`id, name, species, breedId, breedDisplayName, customBreedName, sex, birthDate, birthDateEstimated, personality, microchipNo, sterilizedOn, status, myRole, createdAt, updatedAt, version`
- `breedId`/`customBreedName` 恰有其一非空(ck_pets_breed);`breedDisplayName` 由字典解出,随 breedId 存在。
- `avatarAssetId` 不出现在 M2 契约(ADR-010 照片裁出)。
- `myRole` ∈ owner/caregiver/viewer。
### 2.3 PATCH 语义(定型)
- 部分更新:缺席/null 字段不变;**M2 不支持将可选字段清空回 null**(把 null-vs-absent 歧义挡在契约外)。
- 例外:品种对(breedId/customBreedName)整体替换 —— 提交任一侧即替换整对,二者互斥校验同创建。
- `version` 必填(40000 缺失即拒),比对通过才写入并 +1。
- `species` 不可改(创建即定,避免与品种配对失效)。
- `status` 可迁移至 active/lost/deceased/archived**`deleted` 不可经 PATCH 设置**40000)。
### 2.4 错误/权限语义定型表(冻结候选)
| 场景 | HTTP | code | 说明 |
| --- | --- | --- | --- |
| 未带/无效/过期 token 访问 /api/v1/** | 401 | 40101 | BearerAuthFilter,先于一切业务逻辑 |
| 参数校验失败(含品种互斥、species 白名单、PATCH 缺 version、PATCH status=deleted、breeds 非法 species 参数、品种与物种错配、品种不存在或停用) | 400 | 40000 | message 携带具体字段原因 |
| 宠物不存在 / 已软删除 / **调用者与宠物无 pet_owners 关系** | 404 | 40401 | **防枚举语义(推荐定案)**:三种情况响应完全一致,随机探测 UUID 无法得知命中真实记录。GET 与 PATCH 一致适用 |
| 有关系但角色不覆盖操作(viewer 或 caregiver PATCH 档案) | 403 | 40300 | 只有对宠物「可见」的用户才可能收到 403 |
| PATCH version 过期(并发冲突/重试) | 409 | 40902 | 明确冲突,不静默覆盖;客户端刷新取新 version |
| 芯片号已被登记(uq_pets_microchip | 409 | **40903(新增)** | 新错误码 MICROCHIP_EXISTS,延续 409xx 段;跨用户唯一,属可公开的业务冲突 |
**防枚举推荐及理由(供拍板)**:采纳 02 号报告 P7 —— 无关系一律 404/40401。403 会向无关用户泄露「该 UUID 存在一只宠物」;宠物 id 会出现在分享场景(M3+ 邀请),枚举面必须封死。**403/40300 仅保留给「可见但越权」**:该用户本就能读到这只宠物,403 不泄露新信息,且给客户端明确的「无权操作」提示语义。此语义已在 `PetAccessService` 单点实现,T2-04~07 自动继承。
**幂等定型**:pets 不在开发计划 6.1 的 Idempotency-Key 强制名单,写接口不要求幂等键。重试安全由乐观锁 + 唯一约束兜底:PATCH 重发(version 已消耗)得 409/40902,刷新即见已生效结果;POST 带芯片号重发得 409/40903。均有集成测试锁定。
### 2.5 未登录/失败样例(统一信封)
```json
{ "code": 40401, "message": "宠物不存在", "data": null }
```
## 3. 数据库约束对齐
| 约束 | 应用层行为 |
| --- | --- |
| ck_pets_breed | 服务层先校验互斥 + 字典品种存在/启用/物种匹配 → 40000 可读消息;约束兜底 |
| uq_pets_microchip | DuplicateKeyException → 40903 |
| ck_pets_status | DTO @Pattern 白名单(且排除 deleted)→ 40000 |
| ck_pets_deleted | PATCH 不可达 deleted 状态;软删除留待专用端点统一写 status+deleted_at |
| ck_pets_version | version 必填非负;UPDATE 条件比对 version 才 +1 |
| uq_pet_primary_owner | 创建事务内写唯一 primary owner 行 |
## 4. 权限框架与 T2-04~07 复用方式
核心类(patbond-pet 模块 `access` 包):
- **`PetRole`**owner/caregiver/viewer,映射 pet_owners.role。
- **`AccessLevel`**:三档操作级别,一处定义角色矩阵:
- `READ` — 三角色皆可(GET 详情、列表类子资源);
- `WRITE` — owner + caregiver**T2-04~07 的健康记录写接口用这一档**:体重、疫苗、健康事件、提醒的 POST/PATCH);
- `MANAGE` — 仅 owner(宠物档案 PATCH、状态流转,将来的成员管理/软删除)。
- **`PetAccessService.require(userId, petId, level)`**:唯一权限闸口。一条索引查询(pets ⋈ pet_owners,双主键)完成「存在性 + 可见性 + 角色」三合一判定,异常语义即 §2.4 的 40401/40300。返回 `PetAccess(petId, role)` 供需要角色的 handler 使用。
**T2-04~07 接入模板**(每个子资源 handler 第一行):
```java
petAccessService.require(userId, petId, AccessLevel.WRITE); // 写记录
petAccessService.require(userId, petId, AccessLevel.READ); // 读记录
```
- userId 来自 `@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE)`(过滤器已验签注入)。
- 子资源自身的「记录不存在」用 40402 RECORD_NOT_FOUND(权限闸后才查记录,故 40402 不会泄露越权信息)。
- 每请求实时查库、无缓存:撤销照护关系立即生效(有测试 `revokedViewerImmediatelyLosesAccess`),这是 M2 不需要 access token 黑名单的前提(02 号报告 §6)。
- 选择「显式 service 调用」而非注解/切面:pet 域全部端点都以 petId 为路径变量,一行调用无重复膨胀;切面需要反射提参、隐藏了「先鉴权后查数」的顺序约束,且测试更难定位。若 M5+ 端点形态多样化再评估注解化。
选型说明:鉴权(BearerAuthFilter/JwtVerifier/RsaPublicKeyLoader)从 patbond-user **复制**到 pet 模块而非下沉 common —— patbond-common 是纯契约模块(仅 validation-api + jackson-annotations,无 servlet/jjwt 依赖,见其 pom 注释),为三个类引入 web 依赖破坏其定位;两服务独立部署,安全代码各自持有与 auth 公钥约定对齐。pet 模块去掉了 user 特有的 `/api/v1/events` 匿名白名单 —— pet 域全部端点强制登录。
## 5. 测试
### 5.1 测试基建
- pet 模块测试引入 `patbond-user`test scope+ Flywaytest scope):Testcontainers postgres:18 上执行与生产完全相同的 V1..V4 迁移链。生产 wiring 不变(pet 服务仍不带 Flyway,链由 user 启动执行)。
- 三角色场景按 T2-10 要求以测试数据直写 pet_owners 构造(ADR-015 邀请流后置,`grantRole` helper)。
- JWT 密钥每次测试运行时生成,不入库(沿用第一迭代 TestJwtKeys 模式)。
### 5.2 覆盖矩阵(T2-10 六类路径)
| 类别 | 用例 |
| --- | --- |
| 成功 | 建→列→详→改→归档全链路(真实 PG,含 primary owner 落库断言、部分更新字段保持);breeds 按 species 过滤 |
| 参数错误 | 品种双填/双空/物种错配、非法 species、PATCH 缺 version、PATCH status=deleted、breeds 非法参数 |
| 不存在 | GET/PATCH 随机 UUID → 404/40401 |
| 无权限 | 陌生人 GET/PATCH → 404(与不存在响应一致,防枚举断言);列表隔离;viewer 读通过/写 403caregiver 读通过/档案 PATCH 403;撤销关系即时生效;401 三例(缺 token/错签名/过期) |
| 并发冲突 | 旧 version PATCH → 409/40902,先写者数据保留 |
| 幂等/重复 | 芯片号重复 → 409/40903;同 version 重发 PATCH → 409 不重复生效(version 落库断言) |
### 5.3 测试数变化
| 模块 | 交付前 | 交付后 |
| --- | --- | --- |
| patbond-common | 3 | 3 |
| patbond-user | 59 | 59 |
| patbond-auth | 31 | 31 |
| patbond-pet | 2 | **25**+23CRUD/字典 14 + 权限/鉴权 9 |
| **合计** | **95** | **118** |
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 全绿(2026-09-07)。
## 6. 遗留与移交
- **T2-09**:§2 全表为契约冻结输入;两处需 PM/契约侧确认:40903 新错误码收录;软删除端点是否进 M2 契约(本单按 D2-7 未实现)。
- **T2-04~07**:按 §4 模板接入;WRITE 档在本单只有矩阵定义与 caregiver 403 反证,第一个子资源单(T2-04)须补 caregiver 写成功的正向用例。
- **T2-08**summary 聚合同样以 `require(userId, petId, READ)` 开闸。
- compose 的 pet 服务已挂 JWT 公钥;E2E(T2-18)无需额外配置。