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

145 lines
10 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.
# 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)无需额外配置。