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)无需额外配置。
@@ -0,0 +1,119 @@
# T2-09 起草报告:pets 域 OpenAPI 契约草案
> 作者:API 契约工程师
> 日期:2026-09-07
> 状态:**起草态(DRAFT)——未冻结、未并入 docs/api/openapi.yaml**
> 草案文件:`docs/development/iterations/iteration-2/openapi-pets-draft.yaml`(可独立 YAML 解析:12 路径 / 18 操作 / 33 schema$ref 全部可解析)
> 冻结条件:T2-03 权限/错误语义定型 + T2-08 聚合字段定型,由主会话协调执行冻结合并。
---
## 1. 范围与依据
| 依据 | 用途 |
| --- | --- |
| iteration-2/01 §1.1 端点表 + T2-03~T2-08 工单描述 | 端点清单、cursor 分页、乐观锁、Idempotency-Key 要求 |
| iteration-2/02 §4 资源设计草案 + 错误码扩展段 | 40300/40401/40402/40902 语义、P7 防枚举裁决 |
| patbond-api V3 迁移(`V3__pet_health_baseline.sql`) | 字段名、长度、枚举值、CHECK 约束、状态机(**唯一正典**) |
| 既有契约 `docs/api/openapi.yaml` 1.1.0 | 信封、错误组件、camelCase、ISO 8601、securityScheme 风格 |
| ADR-010 | certificate/provider/booking/avatar 不开放写入 |
| ADR-015 | owner/caregiver/viewer 三角色权限模型,邀请流程后置 |
## 2. 起草的端点(12 路径 / 18 操作)
| # | 端点 | 操作 | 对应工单 |
| --- | --- | --- | --- |
| 1 | `/api/v1/pets` | GET / POST | T2-03 |
| 2 | `/api/v1/pets/{petId}` | GET / PATCH | T2-03 |
| 3 | `/api/v1/breeds` | GET | T2-03 |
| 4 | `/api/v1/pets/{petId}/weights` | GET / POST | T2-04 |
| 5 | `/api/v1/vaccine-catalog` | GET | T2-05 |
| 6 | `/api/v1/pets/{petId}/vaccinations` | GET / POST | T2-05 |
| 7 | `/api/v1/vaccinations/{vaccinationId}` | PATCH | T2-05 |
| 8 | `/api/v1/pets/{petId}/health-events` | GET / POST | T2-06 |
| 9 | `/api/v1/health-events/{eventId}` | PATCH | T2-06 |
| 10 | `/api/v1/pets/{petId}/care-reminders` | GET / POST | T2-07 |
| 11 | `/api/v1/care-reminders/{reminderId}` | PATCH | T2-07 |
| 12 | `/api/v1/pets/{petId}/summary` | GET | T2-08 |
`DELETE /api/v1/pets/{petId}`(软删)**未起草**:02 号评估标注"是否进 M2 契约冻结时定",且 PM 决策 D2-7 建议首版仅归档。归档经 `PATCH status=archived` 已覆盖,软删端点留待冻结时裁决(记入 TODO-FREEZE 清单第 11 项)。
## 3. 设计决策(起草者裁量,冻结评审时可推翻)
1. **子资源 PATCH 走顶层短路径**`/api/v1/vaccinations/{id}` 而非 `/api/v1/pets/{petId}/vaccinations/{id}`):记录 ID 全局唯一(UUID),短路径避免冗余 petId 校验歧义(path petId 与记录归属不一致时如何报错)。与 02 号评估的 `PATCH .../vaccinations/{id}` 写法在语义上一致,仅路径层级不同——**冻结评审时需拍板**(列入 TODO-FREEZE)。
2. **提醒资源名用 `care-reminders`**(与表名 care_reminders 对齐),02 号评估用的是 `reminders`——冻结时统一。
3. **疫苗目录路径用 `/api/v1/vaccine-catalog`**02 号评估用 `/api/v1/vaccines`——冻结时统一。
4. **新增错误码 40903(疫苗剂次重复)、42200(品种互斥)、42201(状态机违反)**:延续既有编号段追加,不与 40900/40901/40902 冲突。T2-05 验收标准要求"同系列同剂次重复登记返回冲突"与"状态机非法迁移被拒绝并返回稳定错误码",用 40902 一码多义会让客户端无法区分"重试可解"(版本冲突→刷新重提)与"业务性冲突"(剂次已存在→改剂次)。42200/42201 用 422 区分"参数格式合法但业务规则违反"与 40000 的"参数格式错误"。**此三码为草案新提,需后端确认后进 ErrorCode 枚举**。
5. **cursor 分页信封形态**`data: { items, nextCursor, hasMore }`。既有契约无分页先例,此形态为 pets 域首次定义,将成为全 API 的分页正典——按"一次定死、处处一致"原则,weights 与 health-events 完全一致。
6. **Idempotency-Key 定为可选头**01 号拆解 T2-04 要求"写接口支持 Idempotency-Key",但 02 号评估指出开发计划 6.1 的强制名单不含 pets。草案折中:weights/vaccinations/health-events 三个 POST 声明可选头,语义为"带则幂等去重"care-reminders 与 pets 创建不声明(低重复风险,乐观锁与唯一约束兜底)。**两份输入存在张力,冻结时需拍板**。
7. **响应字段 ID 命名**:资源自身 ID 用类型化名(petId/weightId/vaccinationId/eventId/reminderId),与既有契约 Me.userId 的先例一致,避免裸 `id` 在嵌套结构中歧义。
8. **PetDetail.myRole**:详情返回调用者角色(02 号评估"详情含调用者自己的 role"),供前端决定编辑入口显隐。列表 Pet 不带 role(避免 N 次 join 语义进列表,前端列表页不需要)。
9. **金额一律 `amountCents` 整数分**int64、非负),日期区分 `date`birthDate/plannedOn 等,数据库 date 列)与 `date-time`timestamptz 列),与 V3 列类型一一对应。
10. **UpdateCareReminderRequest 无 version**care_reminders 表**没有 version 列**V3 确认),状态流转 pending→completed/dismissed 天然幂等,不做乐观锁。其余三个 PATCHpets/vaccinations/health-events)均强制 version。
## 4. 与 V3 约束的对照表
| V3 约束 | 契约体现 |
| --- | --- |
| `ck_pets_species` (dog/cat/other) | species enum,三处字典/宠物一致 |
| `ck_pets_sex` (male/female/unknown) | sex enum |
| `ck_pets_status` 5 值 | Pet.status enum 全 5 值;UpdatePetRequest 只开放 4 值(deleted 不开放写) |
| `ck_pets_breed` breed/custom 互斥 | 请求描述 + 422/42200 错误分支 |
| `ck_pets_name` 164 | name minLength/maxLength |
| `ck_pet_weight` >0 且 ≤500 | weightKg minimum 0.01 / maximum 500numeric(6,2),最小正两位小数值) |
| `ck_pet_weight_source` 3 值 | source enum (manual/clinic/device) |
| `ck_vaccination_status` 3 值 | status enum (scheduled/completed/cancelled) |
| `ck_vaccination_dates` 状态-日期联动 | createVaccination/updateVaccination 描述 + 422/42201 |
| `uq_pet_vaccination_dose`(非 cancelled 唯一) | 409/40903 错误分支 |
| `ck_vaccination_dose` >0 | doseNo minimum 1 |
| `ck_health_event_type` 6 值 | eventType enum 与 V3 逐字一致 |
| `ck_health_event_title` 1160 | title 长度约束 |
| `ck_health_event_amount` ≥0 或 null | amountCents minimum 0, nullable |
| `ck_care_reminder_type` 4 值 | reminderType enum |
| `ck_care_reminder_status` 3 值 | status enum (pending/completed/dismissed) |
| `ck_care_reminder_completed` 联动 | UpdateCareReminderRequest 描述 + 422 分支 |
| version 列(pets/vaccinations/health_events | 三资源响应必含 versionPATCH 请求必填 version |
| care_reminders 无 version 列 | CareReminder 响应无 versionPATCH 无乐观锁 |
| `ix_pet_weight_pet_measured` (measured_at DESC, id DESC) | weights 分页排序描述与索引对齐 |
| `ix_health_events_pet_time` (occurred_at DESC, id DESC) | health-events 分页排序与索引对齐 |
| ADR-010 剪出列(avatar/certificate/provider/booking | 全部请求体不含;Pet.avatarAssetId 只读回显、疫苗/事件的 provider/booking/certificate 字段响应中**不出现**(见 §5 注) |
注:`certificate_asset_id``provider_id``provider_name_snapshot``booking_id` 在草案的响应 schema 中**整体未列出**(而非标 readOnly)——M2 无任何写入路径,值恒为 null,列出只会诱导客户端建模死字段;M5/媒体迭代时按"新增可选响应字段"作纯增量扩展,无破坏性。`pets.deleted_at` 同理不出现(软删语义未开放)。
## 5. 与既有契约(1.1.0)风格一致性自查
| 检查项 | 结论 |
| --- | --- |
| 统一信封 `{code, message, data}`,成功 code 恒 0(enum [0] | 一致,每资源独立 XxxEnvelope,与 MeEnvelope 等先例同构 |
| ErrorEnvelope 结构(code integer / message / data nullable | 逐字段一致 |
| ValidationError / AccessTokenInvalid 复用组件 | 与既有 components/responses 同名同构,合并时直接去重 |
| camelCase、UUID 字符串(format: uuid)、ISO 8601 date-time | 一致 |
| securityScheme bearerAuthhttp/bearer/JWT | 逐字一致 |
| 错误码不复用不改号,追加式扩展 | 40300/40401/40402/40902 取自 02 号评估;40903/42200/42201 为新提追加 |
| 中文 summary/description、错误响应带 code 注释 | 一致 |
| openapi 3.0.3、tags 分组 | 一致 |
| 与既有契约的偏差 | 仅两处有意偏差:创建返回 **201**(既有 auth 全 200,但 02 号评估明确"返回 201",且 pets 域为资源创建语义,属域内新约定不破坏旧端点);分页信封为新增形态(既有无先例) |
## 6. TODO-FREEZE 清单(11 项)
草案 YAML 内以 `# TODO-FREEZE:` 注释标注 10 处,加上本报告第 11 项:
| # | 位置 | 等待 | 内容 |
| --- | --- | --- | --- |
| 1 | info.description 权限模型段 | T2-03 | 每端点权限规则逐条定死(owner/caregiver/viewer 读写矩阵)与错误示例 |
| 2 | GET /pets | T2-03 | 列表是否分页(建议不分页) |
| 3 | GET /pets/{petId} | T2-03 | 不可见宠物 404/40401 vs 越权 403/40300 的最终边界(P7 建议已按防枚举写入,待实现确认) |
| 4 | PATCH /pets/{petId} | T2-03 | caregiver 是否可改档案(建议仅 owner) |
| 5 | GET .../vaccinations | T2-05 | 疫苗列表分页策略(量小或可不分页) |
| 6 | POST .../vaccinations | ADR-010 后续 | provider/booking/certificate 字段的未来开放方式(纯增量) |
| 7 | POST .../health-events | ADR-010 后续 | 同上(provider/booking |
| 8 | GET .../care-reminders | T2-07 | 分页与 status=pending 过滤参数形态 |
| 9 | GET .../summary 端点描述 | T2-08 | 聚合字段命名、月度边界时区口径、进度分母口径、下次接种取值优先级 |
| 10 | PetSummary schema | T2-08 | 全 schema 为占位,逐字段待定 |
| 11 | 本报告 §2/§3 | 冻结评审 | DELETE 软删端点是否入 M2;子资源 PATCH 路径层级;`care-reminders`/`vaccine-catalog` 资源命名与 02 号评估用词统一;Idempotency-Key 可选 vs 强制;40903/42200/42201 三个新错误码后端确认 |
## 7. 冻结前禁止事项(自我约束声明)
- 本草案**未合入** `docs/api/openapi.yaml`(仍为 1.1.0 / 6 端点,未做任何修改)。
- 未修改 mkdocs.yml、未 commit/push、未改动任何代码仓。
- 冻结时的合并动作:去重 componentsErrorEnvelope/两个 responses/securityScheme)、版本号升 1.2.0、错误码表并入 info.description、消除全部 TODO-FREEZE——由主会话在 T2-03/T2-08 定型后协调执行。
@@ -0,0 +1,71 @@
# 埋点分段持久化队列实施报告(M2 第二波)
> 作者:Frontend DeveloperFlutter
> 日期:2026-09-07
> 依据:`iterations/iteration-1/13-tracking-implementation-spec.md` §3.3/§3.4(分段队列原始设计)、`iteration-2/06-experiment-tracking-plan.md` §基础设施评估(约两周离线积压容量)、`iteration-2/10-flutter-analytics-repair.md`(第一波修复语义基线)
> 仓库:patbond-flutter dev 分支,提交 `33b993c`(基线 `1afec6a`
---
## 1. 背景
第一波按计划只做了内存队列的一行级加固(失败重回队列、上限 500 丢最旧),分段持久化推迟到本波。本波将队列升级为 13 号规范 §3.3 的 shared_preferences 分段持久化方案:应用被杀/冷启动不再丢失未上传事件,离线积压容量约两周(500 条上限,06 号报告估算)。
## 2. 设计要点
### 2.1 存储布局(新文件 `lib/analytics/analytics_event_store.dart`
按 13 号规范 §3.3 的 key 布局实现:
| Key | 内容 |
| --- | --- |
| `pb.analytics.segIndex` | JSON 数组:段 ID 有序列表(旧 → 新) |
| `pb.analytics.seg.<segId>` | JSON 数组:该段最多 20 条序列化事件 |
| `pb.analytics.droppedCount` | 本地累计丢弃计数(溢出淘汰 + 4xx 丢批 + 损坏段),诊断用 |
- **写入**`trackEvent` 追加到当前开放段并只重写该段(≤ 20 条、几 KB),避免整队列单 key 的 O(n) 重写放大;段满 20 条封段、开新段。
- **上限与淘汰**:总量 500 条(25 段),超限丢最旧整段并累加 `droppedCount`
- **at-least-once**:上传拿到终态才删段——202 受理删段,4xx 永久拒绝删段并计入丢弃数;网络错误/5xx 段原样保留在本地。应用在响应前被杀,事件仍在,冷启动重发,服务端靠 eventId(UUIDv7)幂等去重。
- **内存为唯一事实来源**shared_preferences 是尽力而为的镜像,持久化不可用(如插件未初始化)时降级纯内存队列,任何存取失败只打日志绝不抛出(埋点旁路原则)。
### 2.2 并发与损坏容错
- **冲刷中新事件不丢**`takeBatch` 取最旧整段拼批时即封段(sealed),上传在途期间新事件只会写入新的开放段;批内容与对应段不再变化,202 后整段删除安全。
- **损坏段**:JSON 解析失败的段直接删 key 丢弃、计入 `droppedCount`,恢复流程不崩溃;段索引本身损坏时按 key 前缀清扫孤儿段后从空队列重建。
- **恢复顺序**:restore 前已入队的内存事件排在恢复事件之后(恢复的更旧,优先上传/淘汰),并在恢复时补落盘。
### 2.3 服务接入(`lib/analytics/analytics_service.dart` + `lib/app/app.dart`
- 冲刷触发点保持不变:满 20 条 + 离开前台 `flushNow()`;新增冷启动 `restore()`app.dart initState 后台调用,不阻塞渲染)恢复积压并冲刷一次——即 13 号 §3.4 四个触发点落地三个(30 秒定时器仍未做,见 §4)。
- 冲刷改为按段拼批 ≤ 50 条循环上传,对齐契约单批上限(13 号 §1.1;旧实现失败重回后可能单批远超 50 被服务端整批 400 拒绝,本波顺带修复)。
- 第一波语义无回退:`flushNow()`、4xx 毒丸丢弃、eventId UUIDv7、sessionId 注入、`_platformName()` 均保留。
## 3. 测试变化
- 基线 51 → **64 全绿**+13);`flutter analyze` 0 问题、`dart format` 无 diff。
- 新增 `test/analytics/analytics_event_store_test.dart`(8 个):持久化恢复与分段数、501 条触发丢最旧整段、损坏段容错与索引清理、索引损坏清扫重建、封段隔离在途批次、按段拼批 ≤50、删段后 prefs 无残留、无持久化降级纯内存。
- 新增 `test/analytics/analytics_persistent_queue_test.dart`5 个,本地 HttpServer 模拟 202/400):满 20 冲刷且 202 后清段、flushNow 冲刷不满额队列、上传失败持久化 + 冷启动恢复自动重传、4xx 删段丢弃计数、60 条积压按 40+20 分批上传。
- 既有 8 个 AnalyticsService 测试未改动全部通过(`pendingEvents` 语义兼容)。
## 4. 与 13 号规范符合度对照
| 规范条目(§3.3/§3.4) | 状态 | 说明 |
| --- | --- | --- |
| 分段存储 key 布局(segIndex / seg.\<id\> / droppedCount | 符合 | key 名与规范一致 |
| 每段 ≤ 20 条、写入只重写当前段 | 符合 | |
| 总上限 500 条、超限丢最旧整段 | 符合 | |
| 202 后才删段(at-least-once) | 符合 | 取整段组批,无部分消费段重写的需要 |
| 单批 ≤ 50 条 | 符合 | 每批最多 2 整段(40 条),循环冲刷 |
| 冷启动恢复 + 冲刷触发 | 符合 | `restore()` 于 app 启动挂接 |
| 满 20 条 / 退后台冲刷触发 | 符合 | 第一波语义保留 |
| `pb.analytics.anonymousId` / `lastActiveAt` 持久化 | 未做 | anonymousId 仍每冷启动重新生成,属会话/身份持久化范畴,非本工单队列范围,建议下波补 |
| 30 秒定时冲刷 | 未做 | 本波任务明确保持触发点不变;低活跃场景已由退后台 + 冷启动冲刷兜底 |
| 指数退避(5s ×2 上限 5min)、429 按 Retry-After | 未做 | 沿用第一波语义:4xx(含 429)一律永久丢弃;有限流上量前风险低,遗留下波 |
| 401 去 Authorization 重试一次 | 未做 | 第一波遗留项,本波未扩展 |
## 5. 交付物
- 代码:patbond-flutter `dev` 提交 `33b993c`(已推送),改动 5 文件 +577/−28。
- 新增:`lib/analytics/analytics_event_store.dart``test/analytics/analytics_event_store_test.dart``test/analytics/analytics_persistent_queue_test.dart`
- 修改:`lib/analytics/analytics_service.dart`(接入持久化队列、分批冲刷)、`lib/app/app.dart`(冷启动 restore 挂接)
- 依赖:无新增(`shared_preferences ^2.5.4` 已在 pubspec
@@ -0,0 +1,117 @@
# 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 再议。
@@ -0,0 +1,118 @@
# 17 · T2-06/T2-07 健康事件时间线与照护提醒接口交付报告
- **日期**2026-09-07
- **工单**:T2-06(健康事件时间线,M)+ T2-07(照护提醒,M),同域内聚一并交付
- **仓库**patbond-apidev 分支(提交 `d8303bf` T2-06、`3b27f9f` T2-07,已推送)
- **角色**Senior Developer(后端)
- **前置**:完全复用 T2-03 的 `PetAccessService.require(userId, petId, AccessLevel)` 单一闸口(13 号报告 §4),未新造任何权限逻辑;零新增数据库迁移(V3 表结构原样够用);cursor 分页 / Idempotency-Key / 顶层短路径 40402 / 乐观锁 40902 全部沿用 T2-04/05 定型惯例(16 号报告)
---
## 1. 端点清单与语义定型表(T2-09 契约冻结输入)
| 端点 | 权限级别 | 成功响应 | 说明 |
| --- | --- | --- | --- |
| `GET /api/v1/pets/{petId}/health-events?limit=&cursor=` | READ | 200`{items, nextCursor, hasMore}` | cursor 分页,`occurred_at DESC, id DESC`(与 ix_health_events_pet_time 逐列对齐);limit 1~100 默认 20 |
| `POST /api/v1/pets/{petId}/health-events` | WRITE | 201,完整 HealthEventResponse | 可选 `Idempotency-Key` 头(≤255 字符,键派生确定性主键 + ON CONFLICT,语义细则同 16 号 §3resource 前缀 `health-event`);`created_by_user_id` 取自验签 token,不收请求体 |
| `PATCH /api/v1/health-events/{eventId}` | WRITE | 200,更新后完整 HealthEventResponse | 顶层短路径;仅可编辑 title/notes/amountCents`version` 必填乐观锁 |
| `GET /api/v1/pets/{petId}/care-reminders?status=` | READ | 200,数组**不分页** | 单宠提醒量级小(同疫苗先例);`ORDER BY due_at ASC, id`(待办最先到期在前);`?status=pending` 即「按 due_at 查询待办」,走 ix_care_reminders_due 部分索引 |
| `POST /api/v1/pets/{petId}/care-reminders` | WRITE | 201,完整 CareReminderResponse | 创建恒为 `pending`(请求体不收 status);可选 `Idempotency-Key`(前缀 `care-reminder` |
| `PATCH /api/v1/care-reminders/{reminderId}` | WRITE | 200,更新后完整 CareReminderResponse | 顶层短路径;状态流转专用(请求体仅 status + completedAt |
WRITE 档 = owner + caregiverT2-03 §4 矩阵);两单均有 caregiver 写成功正向用例(§5)。
### 1.1 响应字段
- **HealthEventResponse**`id, petId, eventType, occurredAt, title, notes, amountCents, createdByUserId, createdAt, updatedAt, version`。eventType ∈ medical/feeding/deworming/grooming/measurement/noteamountCents 整数分、可空、非负(bigint)。`provider_id / provider_name_snapshot / booking_id` **整体不出现**`health_event_media` 本迭代不实现(ADR-010,M5 纯增量补入)。
- **CareReminderResponse**`id, petId, reminderType, title, dueAt, status, completedAt, createdAt, updatedAt`。reminderType ∈ deworming/checkup/medication/other**无 version 字段**(表无该列,见 §2.2)。completedAt 非空当且仅当 status=completed。
- 分页信封与游标形态与 T2-04 完全一致(`nextCursor` 为 base64url(微秒:id)`hasMore=false` 时恒为 null)。
### 1.2 PATCH 健康事件语义(定型)
- 部分更新:缺席字段不变;沿用 T2-03 定型的「M2 不支持清空回 null」。
- `eventType / occurredAt` 不可改(时间线条目的身份,不在请求体);`createdByUserId` 永不可改。
- `version` 必填(缺失 40000),比对通过才写入并 +1updated_at 由 V3 触发器维护。
- title 服务端 btrim(镜像 ck_health_event_title),trim 后为空 → 40000。
## 2. 状态机实现说明(care_reminders
```
pending ──→ completed (必带 completedAt
pending ──→ dismissed (禁带 completedAt
completed / dismissed:终态;同状态重放始终允许(客户端重试「标记完成」幂等成功)
```
### 2.1 completed/completedAt 一致性
- 应用层先于数据库校验(镜像 ck_care_reminder_completed):`status=completed` 必带 completedAt、其余状态禁带,违反 → **42202** 可读消息而非约束 500;数据库约束保持兜底。
- 终态互迁(completed↔dismissed)与回退 pending(复活)均拒绝 → 42202。dismissed 不写 completedAt,落库断言见 §5。
- completedAt 由客户端提交(而非服务端 now()):照草案「标记 completed 时必填」形态,允许补记实际完成时刻。
### 2.2 无 version 列的并发语义
care_reminders 是 V3 中唯一无 version 列的业务表(状态流转单向、无字段编辑,设计如此)。流转采用**当前状态条件更新**守卫:`UPDATE ... WHERE id = ? AND status = <校验时快照>`,读写窗口内被并发流转抢先则 0 行命中 → **40902**(复用「数据已被修改请刷新」语义,客户端处理方式与乐观锁一致);窗口外的迟到流转由终态检查拦成 42202。守卫落空路径有仓储级测试锁定(§5)。
## 3. 幂等实现
与 16 号 §3 完全同构:`(资源前缀, userId, petId, key)` SHA-256 派生主键 + `ON CONFLICT (id) DO NOTHING`,同键重试返回原记录(同样 201),键按调用者 × 宠物 × 资源隔离、不比对请求体、无 TTL。零新增表/迁移。
## 4. 错误码定型表(含新码,供 T2-09 冻结采用)
| 场景 | HTTP | code | 说明 |
| --- | --- | --- | --- |
| 参数形状/字典错误:eventType/reminderType 非白名单、title 缺失/空白/超 160、缺 occurredAt/dueAt、amountCents 负数或**非整数**(见下)、notes 超 2000、limit 越界、cursor 无效、列表 status 过滤参数非法、PATCH 事件缺 version、PATCH 提醒缺 status 或 status 非法、Idempotency-Key 超长 | 400 | 40000 | 沿用既有码,message 带具体字段原因 |
| 宠物不存在/软删/无关系(两资源的宠物级路径 GET/POST | 404 | 40401 | 防枚举语义自动继承 T2-03 闸口 |
| 顶层记录路径 `PATCH /health-events/{id}``PATCH /care-reminders/{id}`:记录不存在 **或** 所属宠物对调用者不可见 | 404 | 40402 | 记录级防枚举,照 T2-05 §4 定型语义,两种情况响应完全一致 |
| 有关系但角色不覆盖(viewer 写事件/提醒、viewer PATCH 记录) | 403 | 40300 | 沿用 |
| 事件 PATCH version 过期;提醒流转状态守卫落空(读写窗口竞态) | 409 | 40902 | 沿用;先写者数据保留(有测试) |
| 提醒状态机非法迁移 / completed-completedAt 一致性违反(completed 缺 completedAt、非 completed 带 completedAt、终态互迁、回退 pending | 422 | **42202(新增)** | `REMINDER_RULE_VIOLATION`。草案提议复用 42201,未采纳(见 §6 偏差 #1);一码多场景、message 说明具体规则 |
健康事件无状态机,本单未用到 42201;42201 语义保持疫苗专属不变。
**金额整数分收紧**pet 服务全局禁用 Jackson `ACCEPT_FLOAT_AS_INT`——`"amountCents": 45.5` 此前会被静默截断为 45 入库,现按 40000 拒绝(验收标准「金额只收整数分」的必要条件)。该收紧同时作用于 pet 服务其余整数字段(doseNo、version 等收到小数同样 400),属纯收紧、既有测试全部通过。
## 5. 测试
覆盖 T2-10 六类路径,沿用既有测试基建(Testcontainers postgres:18 + 完整 V1..V4 迁移链、真实 BearerAuthFilter、pet_owners 直写构造角色):
| 类别 | 健康事件(11 用例) | 提醒(10 用例) |
| --- | --- | --- |
| 成功 | owner 建(含金额/备注/零金额边界)→列全链路;**caregiver 建+改成功**且 createdByUserId 记 caregiverPATCH 部分更新字段保持、title trim | 乱序创建按 due_at ASC 列出、创建即 pending**caregiver 建+完成成功**双方可读;?status=pending 待办视图;dismiss 流转 |
| 参数错误 | 缺/非法 eventType、缺 occurredAt、title 缺失/空白/161、金额 -1/45.5、limit 0/101、cursor 乱串、PATCH 缺 version、PATCH title 空白(皆 40000);amountCents=0 边界合法 | 缺/非法 reminderType、title 缺失/空白/161、缺 dueAt、列表 status=done、PATCH 缺 status/status 非法(皆 40000 |
| 不存在 | 随机 petId GET/POST → 40401;随机 eventId PATCH → 40402 | 随机 petId GET/POST → 40401;随机 reminderId PATCH → 40402 |
| 无权限 | 陌生人与随机 petId 响应逐字一致(防枚举断言);陌生人 PATCH 真实记录与随机 id 同为 40402viewer 读通过/POST 与 PATCH 40300 | 同左(记录级防枚举断言 + viewer 三断言) |
| 并发冲突 | 旧 version PATCH → 40902,先写者 notes 保留(落库断言) | 状态守卫以过期 pending 快照写入 → 0 行、先写者 completed 保留(仓储级断言);迟到流转经 API → 42202 |
| 幂等重试 | 同键两次 201 同 id;换键各自成行(落库计数断言) | 同键两次 201 同 id、落库 1 行 |
| 专项 | 分页:5 条走 3 页不丢不重、严格 DESC、同 occurred_at 三条跨页断续(id 断续断言)、nextCursor 收尾 null | 状态-completedAt 一致性:两次违规后落库仍 `pending|null`、完成后 completed_at 非空;终态四组非法迁移 + 同状态重放幂等 |
### 测试数变化
| 模块 | 交付前 | 交付后 |
| --- | --- | --- |
| patbond-common | 3 | 3 |
| patbond-user | 59 | 59 |
| patbond-auth | 31 | 31 |
| patbond-pet | 45 | **66**+21:事件 11 + 提醒 10 |
| **合计** | **138** | **159** |
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 全绿(2026-09-07,一次通过)。
## 6. 与契约草案(openapi-pets-draft.yaml)的偏差清单(6 项,供冻结评审)
| # | 草案 | 实现定案 | 理由 |
| --- | --- | --- | --- |
| 1 | 提醒 422 复用 42201 | **42202 REMINDER_RULE_VIOLATION(新增)** | 42201 已在 T2-05 定型为 `VACCINATION_RULE_VIOLATION`(疫苗专属消息与语义);按 16 号 §4 确立的「错误码不复用不改号」原则,跨资源另立新码 |
| 2 | 资源自身 ID 用类型化名(eventId/reminderId 作 schema 主键名) | **裸 `id`** | 与 16 号偏差 #3 同一决定,域内一致性优先;路径参数名不受影响 |
| 3 | UpdateHealthEventRequest 的 notes/amountCents 标 nullable(暗示可清空) | **缺席=不变,不支持清空回 null** | 沿用 T2-03 §2.3 冻结的 PATCH 语义(与 16 号偏差 #7 同源) |
| 4 | care-reminders 列表「是否分页、待办过滤参数」TODO-FREEZE 待定 | **不分页 + `?status=` 白名单过滤,`due_at ASC, id` 排序** | 单宠提醒量级小(同疫苗不分页先例);pending 过滤恰好命中 V3 部分索引;排序服务端定死 |
| 5 | POST care-reminders 无 Idempotency-Key 头 | **支持可选 Idempotency-Key** | 16 号 §7 移交明示「换 resource 前缀即用」;提醒表无任何唯一约束兜底,重复提交只能靠键防;纯增量 |
| 6 | care-reminders PATCH 无 409 响应 | **补 40902(状态守卫落空)** | 表无 version 列,读写窗口竞态需要明确错误而非静默覆盖(§2.2);建议契约补录该响应 |
实现侧新增而草案未提的收紧(建议一并写入契约描述):notes 上限 2000 字符(草案未设上限,text 列防滥用);amountCents 拒绝小数(§4 末段);PATCH 事件 title 提交空白串 → 40000;创建提醒不收 status 字段(多余字段被忽略,与全 API 一致)。
## 7. 遗留与移交
- **T2-09 冻结**:§1/§4 为定型输入;§6 六项偏差需评审拍板(#1 42202、#2 裸 id 与 16 号先例同构,建议直接采纳)。
- **T2-08 summary**:当月花费可聚合 `SUM(amount_cents)`(注意 NULL 行不计入、月度边界口径待 T2-08 定);「下次接种」与「待办提醒」两个口径并存——前者出自 pet_vaccinations.next_due_on,后者出自 care_reminders pending 行,聚合字段命名时需区分。
- **T2-07 与疫苗 next_due_on 的联动**(完成接种自动生成 deworming/checkup 提醒)本单未做——工单为纯数据接口,联动属产品逻辑,建议 M2 收尾或 M3 拍板。
- 提醒的 title/dueAt 后续编辑与删除端点均不在本单(草案亦无);M2 内改期只能忽略后重建,契约冻结时可确认是否接受。
@@ -0,0 +1,110 @@
# 18 · T2-08 档案聚合摘要接口交付报告
- **日期**2026-09-08
- **工单**:T2-08(档案聚合摘要,M,第二波最后一单)
- **仓库**patbond-apidev 分支(提交 `00f7dbd`,已推送)
- **角色**Senior Developer(后端)
- **前置**:复用 T2-03 `PetAccessService.require(userId, petId, READ)` 单一闸口,未新造权限逻辑;零新增数据库迁移;四项聚合全部从事实表实时计算,**无任何写路径**(开发计划 4.3 红线:不持久化展示字符串——聚合仓储只有 SELECT,测试有零写入落库断言)。
本报告 §2/§3 是 T2-09 契约冻结对 PetSummary 占位 schemaopenapi-pets-draft.yaml `TODO-FREEZE`)的最终输入,聚合口径描述可逐字进契约。
---
## 1. 端点
| 端点 | 权限级别 | 成功响应 | 说明 |
| --- | --- | --- | --- |
| `GET /api/v1/pets/{petId}/summary?tz=` | READ(三角色皆可读) | 200PetSummary(统一信封) | `tz` 可选,IANA 时区标识(如 `Asia/Shanghai`,也接受固定偏移如 `+08:00`),缺省 `UTC`,仅作用于当月花费的月度窗口;非法 tz → 400/40000 |
错误语义全部继承既有定型:401/40101(无 token)、404/40401(宠物不存在/软删/无关系,防枚举、响应逐字一致,有测试)、400/40000(tz 非法或超 64 字符)。本单**无新增错误码**。
## 2. PetSummary 最终 schema(契约冻结直接采用)
```json
{
"petId": "uuid",
"latestWeight": { "weightKg": 5.25, "measuredAt": "2026-09-05T08:00:00Z" },
"vaccinationProgress": { "completedDoses": 2, "totalDoses": 3 },
"nextVaccination": { "vaccinationId": "uuid", "vaccineId": "uuid",
"vaccineName": "狂犬疫苗(猫)", "doseNo": 1,
"doseLabel": "年度加强", "dueOn": "2026-09-01",
"source": "nextDue" },
"monthlyExpense": { "month": "2026-09", "timezone": "UTC", "amountCents": 300 }
}
```
### 字段与 null 语义
| 字段 | 类型 | null 语义 |
| --- | --- | --- |
| `petId` | string(uuid) | 恒非 null,回显路径参数 |
| `latestWeight` | object \| **null** | null ⟺ 无体重记录 |
| `latestWeight.weightKg` | number(两位小数,numeric(6,2) | 对象存在时非 null |
| `latestWeight.measuredAt` | string(date-time, ISO 8601) | 对象存在时非 null |
| `vaccinationProgress` | object \| **null** | null ⟺ 无非 cancelled 疫苗记录(**不是 0/0** |
| `vaccinationProgress.completedDoses` | integer ≥ 0 | 对象存在时非 null |
| `vaccinationProgress.totalDoses` | integer ≥ 1 | 对象存在时非 null=0 即整体 null |
| `nextVaccination` | object \| **null** | null ⟺ 候选集为空(见 §3.3) |
| `nextVaccination.vaccinationId` | string(uuid) | 非 null,命中的疫苗记录 id(客户端可跳详情) |
| `nextVaccination.vaccineId` | string(uuid) | 非 null |
| `nextVaccination.vaccineName` | string | 非 null,出自 vaccine_catalog(同 breedDisplayName 先例) |
| `nextVaccination.doseNo` | integer | 非 null |
| `nextVaccination.doseLabel` | string \| null | 记录本身可无标签 |
| `nextVaccination.dueOn` | string(date) | 非 null**可为过去日期**(逾期针仍是下一针) |
| `nextVaccination.source` | string enum`planned` \| `nextDue` | 非 null,标注取值来源(17 号报告 §7 要求区分两口径) |
| `monthlyExpense` | object | **恒非 null**(月份/时区总可确定) |
| `monthlyExpense.month` | stringISO year-month`2026-09` | 非 null |
| `monthlyExpense.timezone` | string | 非 null,回显窗口所用时区(缺省 `UTC` |
| `monthlyExpense.amountCents` | integer(int64) ≥ 0 | 非 null,无支出为 **0** |
与草案占位的差异:`nextVaccination``dueOn` + `source` 替代草案单一 `plannedOn`(两种来源的日期语义不同,混用一个字段名会误导);增加 `vaccinationId/vaccineId/doseNo/doseLabel`(客户端展示"第 N 针"与跳转所需,纯增量);`monthlyExpense` 增加 `timezone` 回显、`month` 定为 ISO year-month。
## 3. 四项聚合口径定型表(逐字进契约描述)
| # | 聚合 | 口径(定型) |
| --- | --- | --- |
| 3.1 | **最新体重** | pet_weight_records 按 `(measured_at DESC, id DESC)` 取首行——与体重列表接口首行完全一致(同一索引 ix_pet_weight_pet_measured、同一 tie-break),同刻多条时后写入者(id 更大)胜出。无记录 → null。 |
| 3.2 | **疫苗进度** | 范围 = 该宠物**非 cancelled** 的 pet_vaccinations 行。`completedDoses` = 其中 status=completed 的行数;`totalDoses` = 全部非 cancelled 行数(= scheduled + completed,即"已登记剂次"——数据模型没有权威的"系列应打总针数",分母取用户已登记数,T2-09 草案 TODO 的"总剂次 vs 已登记剂次"按后者定案)。cancelled 分子分母皆不计入。totalDoses=0 → 整体 null。 |
| 3.3 | **下次接种** | 候选集两类并集:① 全部 scheduled 行的 `planned_on`(约束保证非空;含过期——逾期计划在完成/取消前仍是下一针),source=`planned`;② completed 行的非空 `next_due_on`**仅当同 (pet, vaccine, series_key) 不存在更高 dose_no 的非 cancelled 记录**(后续针一经登记,其自身即代表下一针,前一针的到期日失效),source=`nextDue`。cancelled 行不产生任何候选。取 `dueOn` 最小者;同日 planned 优先于 nextDue,再按 id 升序保证确定性。候选集空 → null。 |
| 3.4 | **当月花费** | health_events.`amount_cents` 求和,窗口为**请求时刻在 `tz` 时区的自然月半开区间** `[当月1日00:00, 次月1日00:00)`,对 `occurred_at`timestamptz)比较;月初第一刻含、次月第一刻不含。`amount_cents` 为 NULL 的事件不计入;不按 event_type 过滤(任何事件类型的金额都算支出)。`tz` 缺省 **UTC**(服务端无状态、口径明确),客户端(目标用户 Asia/Shanghai)应传自己的时区获得符合直觉的月边界——月边界随 tz 移动,有测试锁定。恒返回对象:`month` 为窗口所属 ISO 年月、`timezone` 回显、无支出 `amountCents=0`。 |
**时区口径权衡记录(供冻结评审)**:工单给出 UTC 或 client 时区参数两选项。定案"**tz 参数 + 缺省 UTC**":纯 UTC 会把北京时间月初 0~8 点的支出记到上月(对 +8 用户每月两端各错 8 小时);服务端猜用户时区则引入状态。参数化让口径显式进契约,缺省 UTC 保证不传参数时行为完全可预期。非法 tz(`ZoneId.of` 不识别)→ 40000"tz 不是有效的时区标识"。
## 4. 实现
- `PetSummaryRepository`:四条只读 SQL 集中一处,与 §3 逐条对应可审计。最新体重走 ix_pet_weight_pet_measured;下次接种的 scheduled 支走 ix_vaccinations_due 部分索引(16 号 §7 移交建议);当月花费走 ix_health_events_pet_time 前缀 (pet_id, occurred_at)。
- `PetSummaryService`READ 闸口 → tz 解析(Java 侧算出月窗口两端 instant,SQL 只做区间比较,索引友好)→ 组装。
- `PetSummaryController`:单 GET`tz` 参数 @Size(max=64) 兜底。
- 文件(patbond-pet 模块):`dto/PetSummaryResponse.java`(含 4 个嵌套 record)、`repository/PetSummaryRepository.java``service/PetSummaryService.java``controller/PetSummaryController.java`
## 5. 测试(12 例,全部集成测试锁口径)
| 类别 | 用例 |
| --- | --- |
| 空数据语义 | 新建宠物:三聚合 null、monthlyExpense={当月, UTC, 0}、petId 回显 |
| 最新体重 | 乱序写入取最大 measured_at;同刻两条 id 大者胜(与列表口径一致断言) |
| 疫苗进度 | completed 2 + scheduled 1 + cancelled 1 → 2/3;仅剩 cancelled → progress 与 nextVaccination 双 null |
| 下次接种 | 跨来源取最早:逾期 nextDue2026-09-01)胜过较晚 planned2026-12-01),source/doseLabel/vaccineName 全字段断言;被接续剔除:第 1 针 next_due_on 更早但第 2 针已排期 → 取第 2 针 planned |
| 当月花费 | UTC 半开区间四边界(月初 0 秒含、月末最后一秒含、上月最后一秒不含、次月 0 秒不含)+ 无金额事件不计 → 精确 300;Asia/Shanghai 窗口按上海月边界(月初含/上月末不含)+ month/timezone 回显;非法 tz → 40000 |
| 多宠隔离 | 宠 A 的体重/疫苗/支出不泄入宠 B 摘要 |
| 权限 | viewer 200 可读;陌生人访问真实宠物与随机 UUID 响应**逐字一致**40401 防枚举);无 token 40101 |
| 红线 | 摘要请求前后三张事实表行数不变(零写入断言) |
### 测试数变化
| 模块 | 交付前 | 交付后 |
| --- | --- | --- |
| patbond-common | 3 | 3 |
| patbond-user | 59 | 59 |
| patbond-auth | 31 | 31 |
| patbond-pet | 66 | **78**+12 |
| **合计** | **159** | **171** |
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 全绿(2026-09-08,一次通过)。
## 6. 遗留与移交
- **T2-09 冻结**:§2 schema + §3 口径表为最终输入,PetSummary 的 TODO-FREEZE 可全部解除;需评审拍板两处:① tz 参数 + 缺省 UTC 的时区口径(§3.4 权衡);② `nextVaccination` 相对草案的字段调整(dueOn/source 替代 plannedOn,纯语义修正)。
- **T2-13/T2-14Flutter**:疫苗进度、"下一针"、月度花费全部改从本接口取数;客户端务必传 `tz`Asia/Shanghai),并按 §2 null 语义渲染空态(progress null ≠ 0/0)。
- **T2-18E2E**"摘要数值核对"步骤可按 §3 口径手算比对;tz 传 Asia/Shanghai。
- 分母口径若产品后续引入"系列应打总针数"(目录扩展字段),totalDoses 语义变更属破坏性调整,须走契约变更上报。
@@ -0,0 +1,117 @@
# 19 · T2-09 契约冻结报告:pets 域 12 路径合入正典(v1.2.0
- **日期**2026-09-08
- **工单**:T2-09(M2 第二波,契约冻结)
- **仓库**patbond-docmain 分支
- **角色**API 契约工程师
- **结论先行**`docs/api/openapi.yaml` 由 1.1.06 路径)升至 **1.2.018 路径 / 24 操作 / 45 schema**pets 域 12 路径按 13/16/17/18 号定型表修正草案后合入;新增错误码 8 个(40300/40401/40402/40902/40903/40904/42201/42202,其中 40903/40904/42201/42202 为 M2 新引入,42200 不引入);校验通过(YAML 解析、$ref 全解析、`mkdocs build --strict`)。**自本报告起 pets 域契约冻结。**
---
## 1. 冻结端点总表(12 路径 / 18 操作)
| # | 端点 | 操作 | 权限档 | 成功 | 分页/排序 | 幂等 | 定型依据 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 1 | `/api/v1/pets` | GET | 隐式(按 pet_owners 过滤) | 200 数组 | 不分页,created_at DESC | — | 13 §2.1 |
| 2 | `/api/v1/pets` | POST | 任何登录用户 | **201** | — | 无键(唯一约束兜底) | 13 §2.1/§2.4 |
| 3 | `/api/v1/pets/{petId}` | GET | READ | 200(含 myRole | — | — | 13 §2.1 |
| 4 | `/api/v1/pets/{petId}` | PATCH | MANAGE(仅 owner | 200 | — | 乐观锁 version | 13 §2.1/§2.3 |
| 5 | `/api/v1/breeds` | GET | 仅 Bearer(字典) | 200 数组 | 不分页,sort_order | — | 13 §2.1 |
| 6 | `/api/v1/pets/{petId}/weights` | GET | READ | 200 分页信封 | cursormeasured_at DESC, id DESC | — | 16 §1 |
| 7 | `/api/v1/pets/{petId}/weights` | POST | WRITE | **201** | — | 可选 Idempotency-Key | 16 §1/§3 |
| 8 | `/api/v1/vaccine-catalog` | GET | 仅 Bearer(字典) | 200 数组 | 不分页,species, name | — | 16 §1 |
| 9 | `/api/v1/pets/{petId}/vaccinations` | GET | READ | 200 数组 | **不分页**series_key, dose_no, created_at, id | — | 16 §1(偏差 #6 |
| 10 | `/api/v1/pets/{petId}/vaccinations` | POST | WRITE | **201** | — | 可选 Idempotency-Key | 16 §1/§3 |
| 11 | `/api/v1/vaccinations/{vaccinationId}` | PATCH | WRITE | 200 | 顶层短路径 | 乐观锁 version | 16 §114 号裁量 #1 照采) |
| 12 | `/api/v1/pets/{petId}/health-events` | GET | READ | 200 分页信封 | cursoroccurred_at DESC, id DESC | — | 17 §1 |
| 13 | `/api/v1/pets/{petId}/health-events` | POST | WRITE | **201** | — | 可选 Idempotency-Key | 17 §1 |
| 14 | `/api/v1/health-events/{eventId}` | PATCH | WRITE | 200 | 顶层短路径 | 乐观锁 version | 17 §1 |
| 15 | `/api/v1/pets/{petId}/care-reminders` | GET | READ | 200 数组 | **不分页**due_at ASC, id`?status=` 过滤 | — | 17 §1(偏差 #4 |
| 16 | `/api/v1/pets/{petId}/care-reminders` | POST | WRITE | **201** | — | 可选 Idempotency-Key | 17 §1(偏差 #5,拍板 B |
| 17 | `/api/v1/care-reminders/{reminderId}` | PATCH | WRITE | 200 | 顶层短路径 | 状态守卫(无 version 列) | 17 §1/§2.2(偏差 #6 |
| 18 | `/api/v1/pets/{petId}/summary` | GET | READ | 200 | `?tz=` IANA,缺省 UTC | — | 18 §1/§2/§3 |
**软删除端点 `DELETE /api/v1/pets/{petId}` 不进 M2 契约**(拍板 B;D2-7 首版仅归档,13 §1 明确不在单)。
### 汇总数
| 维度 | 1.1.0 | 1.2.0 |
| --- | --- | --- |
| 路径 | 6 | **18**+12 |
| 操作 | 6 | **24**+18 |
| schema | 15 | **45**+30 |
| 错误码(业务码,不含 0) | 10 | **18**+840300/40401/40402/40902/40903/40904/42201/42202 |
| 复用组件 | — | 新增 responses 4PetNotFound/RecordNotFound/PetWriteDenied/VersionConflict)、parameters 4PetIdParam/PageLimitParam/PageCursorParam/IdempotencyKeyHeader |
## 2. 草案 → 冻结的全部修正项对照(22 项)
草案 = `openapi-pets-draft.yaml` + 14 号起草报告;修正一律以实现定型表为准(实现定型表 > 草案)。
### 2.1 错误码(拍板 A1
| # | 草案 | 冻结定案 | 依据 |
| --- | --- | --- | --- |
| 1 | 剂次重复用 40903 | **40904 VACCINATION_DOSE_EXISTS**40903 已被 T2-03 的 MICROCHIP_EXISTS 占用,按「不复用不改号」顺延) | 16 §4、偏差 #1 |
| 2 | 42200 BREED_CONSTRAINT_VIOLATION(品种互斥 422 | **不引入**;品种双填/双空/物种错配/品种不存在或停用一律 400/40000 | 13 §2.4、16 偏差 #2 |
| 3 | 42201 疫苗状态机(草案提议) | **照采**,语义定为疫苗专属 VACCINATION_RULE_VIOLATION | 16 §4 |
| 4 | 提醒 422 复用 42201 | **42202 REMINDER_RULE_VIOLATION(新增)**,跨资源不复用错误码 | 17 §4、偏差 #1 |
| 5 | 草案无 40903 芯片号语义 | **40903 MICROCHIP_EXISTS 新增**POST/PATCH pets 的 409 分支) | 13 §2.4 |
### 2.2 响应形态与命名(拍板 A2)
| # | 草案 | 冻结定案 | 依据 |
| --- | --- | --- | --- |
| 6 | 资源主键用类型化名(petId/weightId/vaccinationId/eventId/reminderId14 号裁量 #7 | **裸 `id`**,关联字段保留类型名(petId/vaccineId 等);路径参数名不变 | 16 偏差 #3、17 偏差 #2 |
| 7 | Vaccination 无疫苗名称 | 响应**增加 `vaccineName`**(同 breedDisplayName 先例) | 16 偏差 #4 |
| 8 | Pet 无 breedDisplayName;列表 Pet 不带 myRole14 号裁量 #8 | **增加 `breedDisplayName`****myRole 进全部宠物响应**(列表/详情/创建/更新统一 Pet schemaPetDetail 撤销) | 13 §2.2 |
| 9 | Pet 含 avatarAssetId(只读回显)、status 枚举含 deleted | **avatarAssetId 移除**(ADR-010 整体不出现);响应 status 枚举去 deleted(软删宠物一律 404/40401,永不返回) | 13 §2.2/§2.4 |
| 10 | 创建 201、分页信封 `{items, nextCursor, hasMore}`(草案形态) | **照采并升格为全 API 分页正典**,写入 info 通用约定 | 拍板 A2、16 §1.1 |
### 2.3 分页与列表(拍板 A3)
| # | 草案 | 冻结定案 | 依据 |
| --- | --- | --- | --- |
| 11 | 疫苗列表分页待定(TODO-FREEZE #5 | **不分页**`series_key, dose_no, created_at, id` 排序定死;列表不过滤 status | 16 偏差 #6 |
| 12 | 提醒列表分页/待办过滤待定(TODO-FREEZE #8 | **不分页** + `?status=` 白名单过滤,`due_at ASC, id` 排序 | 17 偏差 #4 |
| 13 | GET /pets 是否分页待定(TODO-FREEZE #2 | **不分页**created_at DESC | 13 §2.1 |
| 14 | 列表 GET 无 400 分支 | 补 400/40000limit 越界、cursor 无效、status/species 非法参数) | 13 §2.4、16 §4、17 §4 |
### 2.4 PATCH 语义与请求体(拍板 A4/B)
| # | 草案 | 冻结定案 | 依据 |
| --- | --- | --- | --- |
| 15 | Update 请求字段标 nullable(暗示可清空) | **缺席=不变,不支持清空回 null**,三个 Update schema 全部去 nullable;宠物品种对为唯一例外(整体替换) | 13 §2.3、16 偏差 #7、17 偏差 #3 |
| 16 | UpdatePetRequest 权限待定(TODO-FREEZE #4 | MANAGE 仅 ownerspecies 不可改;status=deleted 经 PATCH 一律 400/40000 | 13 §2.1/§2.3 |
| 17 | CreateVaccinationRequest.status 含 cancelled | 创建仅 **scheduled/completed**(创建即取消 400/40000 | 16 偏差 #5 |
| 18 | UpdateVaccinationRequest 可改 vaccineId/seriesKey/doseNo?(草案未禁) | **不可改**(不在请求体),修正路径 cancel 后重建;completed/cancelled 均为终态 | 16 §1.2/§2 |
| 19 | care-reminders PATCH 无 409 | **补 409/40902**(无 version 列,当前状态条件更新守卫落空) | 17 偏差 #6、§2.2 |
| 20 | 顶层短路径待拍板(TODO-FREEZE #11 | **照采**;40402 定型为记录级防枚举(记录不存在与所属宠物不可见响应完全一致);40401 定型为宠物级防枚举(不存在/软删/无关系一致) | 拍板 A4、13 §2.4、16 §4 |
### 2.5 PetSummary 与其它(拍板 A5/B
| # | 草案 | 冻结定案 | 依据 |
| --- | --- | --- | --- |
| 21 | PetSummary 全 schema 占位(TODO-FREEZE #9/#10 | 按 18 §2 全量替换:`nextVaccination``dueOn`+`source(planned|nextDue)` 替代单一 plannedOn,增加 vaccinationId/vaccineId/doseNo/doseLabel`monthlyExpense` 恒非 null、增 timezone 回显、month 定 ISO year-month;进度分母 = 已登记剂次;无记录 null 语义(progress null ≠ 0/0);四项聚合口径**逐字**进 schema 描述;新增 `tz` 查询参数(IANA,缺省 UTC,非法 40000 | 18 §2/§3 |
| 22 | 实现侧收紧补进契约描述 | 疫苗须存在/enabled/物种匹配(40000);doseNo ≤32767health-event notes ≤2000amountCents 拒绝小数(40000,不静默截断);title btrim 空白 40000;创建提醒不收 statuscreatedByUserId 取自 token 不收请求体;Idempotency-Key 语义细则(≤255、调用者×宠物×资源隔离、不比对请求体、无 TTL) | 16 §5 末段、17 §4/§6 末段 |
## 3. 定型表间矛盾核查
逐项交叉核对 13/16/17/18 号定型表:**未发现互相矛盾处**(40902 在提醒流转守卫上的复用为 17 号显式定型,非撞号;42201/42202 分立与「不复用不改号」原则自洽;防枚举语义 13→16→17 单点继承一致;18 号聚合口径与 16 号「聚合侧自行排除 cancelled」的移交一致)。
**一处拍板措辞与定型表的出入(已按定型表执行,非仲裁)**:拍板 B 组表述为「Idempotency-Key 为可选头(weights/health-events/care-reminders 三个 POST)」,未列 vaccinations POST;而 16 号定型表明确 `POST .../vaccinations` 支持可选 Idempotency-Key 且有测试锁定(同键两次 201 同 id、落库 1 行),草案亦本已声明该头(14 号裁量 #6,三个 POST 含 vaccinations)。判断拍板枚举的是「本次需拍板的三处」(care-reminders 为 17 号新增偏差 #5weights/health-events 为可选性确认),vaccinations 属草案既有、无争议项。冻结契约按实现收录**四个** POST 的可选 Idempotency-Key。若此判断与拍板本意不符,请显著上报——收窄为三个属于从契约中移除已实现并已测试的行为,需两端同步。
## 4. 冻结纪律声明
自 v1.2.0 起,pets 域 12 路径与全部 schema/错误码**冻结**
1. **任何字段变更(增、删、改名、改类型、改必填性、改枚举、改口径)须显著上报**,经评审后走契约变更流程,**两端(后端 patbond-api、客户端 patbond-flutter)同步**,禁止任一侧单方面偏离。
2. 纯增量扩展(新增可选响应字段、新增端点、新增错误码)允许在次版本内追加,但同样先改契约再改实现(契约先行,docs/api/index.md 约定)。
3. 错误码永不复用、永不改号、永不改义(40903=MICROCHIP_EXISTS、40904=VACCINATION_DOSE_EXISTS、42201=疫苗专属、42202=提醒专属,已在错误码表定死)。
4. 已知的未来破坏性调整须走上报流程的存量项:① totalDoses 分母若引入「系列应打总针数」(18 §6);② 幂等键无 TTL 若改为 idempotency 表 + TTL16 §7);③ ADR-010 裁剪字段(avatar/certificate/provider/bookingM5 按纯增量补入(非破坏性,但须契约先行)。
5. 提醒的 title/dueAt 编辑与删除端点、宠物软删除端点均**不在** M2 契约;M2 内改期路径为 dismiss 后重建(17 §7),归档经 `PATCH status=archived`
## 5. 校验与提交
- `python3 yaml.safe_load` 解析通过;158 个 `$ref` 全部可解析;18 路径 / 24 操作 / 45 schema / 错误码表 19 行计数核对一致。
- `mkdocs build --strict` 通过。
- 提交:`docs/api/openapi.yaml` + `docs/api/index.md` 独立提交并推送 main(提交 `511617b`);本报告与草案文件(14 号、openapi-pets-draft.yaml)按波末统一入档,暂不提交;mkdocs.yml 未动。
@@ -0,0 +1,69 @@
# 20 · T2-09 契约测试报告:实现与冻结契约 v1.2.0 的一致性保障
- **日期**2026-09-08
- **角色**Senior Developer(后端)
- **工单**:T2-09 验收的契约一致性保障
- **代码提交**patbond-api dev `d026f2f`(基线 `00f7dbd`
- **结论**:pets 域 18 操作全矩阵契约测试落地并入 CI(`./mvnw test` 即自动执行,ci.yml 零改动);发现并修复漂移 1 项;全套 `./mvnw clean test` **182 项全绿**171 → 182+11)。
---
## 1. 机制选型:冻结快照进测试资源
**选定方案**:把 doc 仓正典 `docs/api/openapi.yaml`v1.2.0,冻结于 doc main@511617b**字节级复制**为 patbond-api 测试资源 `patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml`,契约测试对照快照跑。复制时点双方 sha256 均为 `243fe648…4a4cd689d`
**否决的备选**CI 里 checkout doc 仓再喂给测试。现有 ci.yml 是零外部 action、手动 `git init + fetch` 克隆本 Gitea 实例的模式,跨仓 checkout 意味着在工作流里再造一段带 token 的手动克隆、并让**本地** `./mvnw test` 依赖兄弟目录存在——本地与 CI 行为分叉,违背「门禁与本地同一条命令」的既定纪律。快照方案零 CI 改动、本地 CI 完全同构,代价只是一条同步纪律(见 §1.2)。
**解析与校验实现**:不引 swagger-parser / openapi-validator 类库——快照只用到 OpenAPI 3.0 的一个小子集(本地 `$ref`、type/required/nullable/enum/format/min-max),用构建里已有的 snakeyaml(Boot 传递依赖)解析 + 自写严格断言(约 500 行测试代码),零新增 Maven 依赖。自写的关键收益:**未声明字段即报漂移**——标准 OpenAPI 语义默认允许 additionalProperties,而冻结契约的语义是「恰好这些字段」,现成校验器恰恰放过改名/新增泄漏字段这类最常见漂移。
### 1.1 三个测试类
| 文件(均在 `patbond-pet/src/test/java/...pet/contract/` | 职责 |
| --- | --- |
| `OpenApiContract` | 加载快照、解析本地 `$ref`、枚举操作/状态码/schema |
| `ContractValidator` | 响应体对 schema 严格校验:必填缺失、null 无 nullable、**契约未声明的字段**、类型/枚举/uuid/date-time/date 格式、min/max(Length) 边界 |
| `ContractConformanceTest` | 沿用既有 Testcontainers + MockMvc 基建真实起服务,18 操作逐一发请求校验,最后两个门禁测试(见 §2) |
### 1.2 快照同步纪律
1. **正典唯一**:契约的唯一权威是 doc 仓 `docs/api/openapi.yaml`;api 仓快照是冻结副本,**永不单独修改**。
2. **契约变更流程**:doc 仓升版(如 1.3.0)→ 复制新文件为 `src/test/resources/contract/openapi-v1.3.0.yaml`(删旧快照)→ 更新 `OpenApiContract.RESOURCE` 与守卫测试期望值(版本号、路径/操作/schema 数)→ 按新契约增删测试用例,一并提交。
3. **忘同步的兜底**:守卫测试 `frozenSnapshotIsTheExpectedContractVersion` 锁定 `info.version == 1.2.0` 且 18 路径 / 24 操作 / 45 schema——契约变更后只改快照不改测试(或反之)都会在 CI 立即变红,不会默默对着旧契约测试。
## 2. 测试什么:全响应矩阵 + 双门禁
覆盖 pets 域 **18 个操作**(契约中 tags ∈ {pets, dictionaries, health-records} 的全部操作,恰为 v1.2.0 新冻结的 12 路径)。每个操作真实发请求,对**契约声明的每一个 (操作, 状态码) 单元格**做结构校验:
- **成功形态**(6 个用例):宠物 CRUD 全字段/全空两种形态、品种与疫苗目录(含 species 过滤)、体重与健康事件的 cursor 分页翻页(并断言 `hasMore=true ⇒ nextCursor 非空``hasMore=false ⇒ nextCursor 恒 null`)、疫苗 scheduled/completed 两形态与状态机 PATCH、提醒 completed/dismissed 两种流转、摘要空档案(三聚合 null)与满档案(四聚合非 null)+ tz 参数。
- **错误信封**(3 个用例):18 操作逐一裸请求验 401/4010111 个 pet 路径操作验 40401 防枚举、3 个顶层短路径验 40402、8 个写操作按 viewer/caregiver 角色验 4030012 处 400/40000(缺必填、limit 越界、非法 cursor、非法 species/status/tz)、40902 乐观锁过期(pets/vaccinations/health-events 三处)、40903 芯片号冲突、40904 剂次冲突、42201 疫苗规则两形态、42202 提醒规则。
- **门禁一**(快照守卫):见 §1.2 第 3 条。
- **门禁二**(覆盖率自证):`everyDeclaredResponseCellIsExercised` 断言上述用例真实触发并通过校验了契约声明的**每一个**响应单元格——契约将来新增操作或状态码,此测试自动变红,覆盖不会静默滑坡。**唯一豁免**:`PATCH /care-reminders/{id}` 的 409(无 version 列,靠并发条件更新守卫落空触发,单线程 MockMvc 无法确定性构造;其行为语义由第一波并发一致性设计与集成测试背书)。
行为语义(状态机迁移合法性、防枚举响应一致性、权限矩阵、幂等键语义)不在本单重复——既有 78 项 pet 集成测试已锁定,本单只锁**结构**。
**有效性自证(mutation check,未入库)**:向快照 Pet schema 注入假必填字段 `bogusDriftField` 后跑测试,9/11 用例即刻红(`$.data.bogusDriftField: 契约必填字段缺失`);还原快照后全绿。校验器确实在咬合,不是恒真。
## 3. 发现并修复的漂移
| # | 位置 | 契约 | 实现(修复前) | 定性与处理 |
| --- | --- | --- | --- | --- |
| 1 | `POST /api/v1/pets` 请求体 `sex` | `CreatePetRequest.required``sex` | `sex` 可缺席,服务端静默补 `unknown` | 结构性漂移,按「以冻结契约为准」修实现:`CreatePetRequest.sex``@NotBlank`(缺失 400/40000),`PetService` 移除缺省补值;7 个既有测试文件的创建载荷补 `sex` 字段 |
仅此 1 项。其余 17 个操作的请求必填、响应字段名/类型/nullable、错误码值与冻结契约零偏差——第二波「先定型实测行为、再按行为冻结契约」的流程有效。**无语义级冲突**,无需仲裁项。
## 4. 测试数变化
| 模块 | 之前 | 之后 | 变化 |
| --- | --- | --- | --- |
| patbond-common | 3 | 3 | — |
| patbond-user | 59 | 59 | — |
| patbond-auth | 31 | 31 | — |
| patbond-pet | 78 | 89 | **+11**ContractConformanceTest6 成功形态 + 3 错误信封 + 2 门禁) |
| **合计** | **171** | **182** | **+11** |
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`BUILD SUCCESS182 项 0 失败。CI 无需任何改动——契约测试就是普通 surefire 测试,`./mvnw -B clean test` 门禁自动携带。
## 5. 范围外记录
- **auth 域 6 操作无契约测试**register/login/refresh/logout/me/trackEvents):M1 交付时无此机制,本单按工单口径不补,**建议 M2 内另立工单**——机制已就绪(快照已含 auth 域全部 schema`OpenApiContract`/`ContractValidator` 直接复用),估计半天以内,落在 patbond-auth 与 patbond-user 的测试模块。
- **提醒 PATCH 409 豁免**:如后续想消除唯一豁免,可在测试中直接 UPDATE 数据库把提醒改成终态后再以旧状态提交 PATCH,确定性触发守卫落空;本单未做(属行为构造技巧,优先级低)。
@@ -0,0 +1,64 @@
# M2 第二波收口报告:后端接口纵切与契约冻结
**执行日期**:2026-09-07 ~ 2026-09-08
**参与方**:Senior Developer(后端)× 4 批次 / API Platform Engineer × 2 / Frontend Developer / 主会话协调
**交付形态**:pets 域 18 操作全实现、契约冻结 v1.2.0、契约一致性测试入 CI
---
## 0. 执行概要
第二波目标:后端接口纵切(T2-03~T2-08)→ 契约冻结(T2-09)→ 为第三波 Flutter 接入放行。
**结果:全部完成。** patbond-api 测试 95 → **182** 全绿,openapi.yaml 冻结至 **v1.2.0**(18 路径/24 操作/45 schema),契约一致性测试(全响应矩阵 + mutation 自证)纳入 CI。并行完成 Flutter 埋点持久化队列(51→64 测试)。
| 工单 | 交付 | 提交(api dev) | 测试增量 |
|------|------|------|------|
| T2-03 宠物 CRUD + 权限框架 | 权限闸口三档 + 防枚举 404 | 8fbf444 | 95→118 |
| T2-04/05 体重 + 疫苗 | cursor 分页正典 + 状态机 + 幂等 | 825dde3 / 4c2653c | 118→138 |
| T2-06/07 健康事件 + 提醒 | 六类事件 + 四类提醒 + 42202 | d8303bf / 3b27f9f | 138→159 |
| T2-08 聚合摘要 | 四聚合口径定型(tz 参数) | 00f7dbd | 159→171 |
| T2-09 契约冻结 | openapi v1.2.0(doc main@511617b) | — | — |
| T2-09 契约测试 | 快照 + 严格校验器 + 1 漂移修复 | d026f2f | 171→182 |
| 埋点持久化队列 | 分段 at-least-once(flutter dev@33b993c) | — | 51→64 |
---
## 1. 定型的关键语义(第三波 Flutter 接入的依据)
- **权限**:`PetAccessService.require` 三档——READ(三角色)/WRITE(owner+caregiver)/MANAGE(仅 owner);无关系/不存在/已软删一律 404/40401 响应逐字一致(防枚举);记录级顶层短路径 404/40402
- **错误码新增 8 个**:40300/40401/40402/40902/40903(芯片号冲突)/40904(疫苗剂次冲突)/42201(疫苗规则)/42202(提醒规则)
- **分页正典**:cursor 信封 `{items, nextCursor, hasMore}`,limit 1~100 默认 20(体重、健康事件);疫苗/提醒列表不分页
- **幂等**:Idempotency-Key 可选头(weights/vaccinations/health-events/care-reminders 四个 POST),键派生确定性主键 + ON CONFLICT,零迁移
- **创建 201**;PATCH 不支持清空回 null;响应主键统一裸 `id`
- **PetSummary**:四聚合对象,无记录 null 语义,tz 参数(IANA)缺省 UTC,口径逐字入契约
## 2. 契约冻结纪律(自 v1.2.0 起生效)
- `docs/api/openapi.yaml` 为唯一事实源;冻结后任何字段变更须显著上报、两端同步
- api 侧持有字节级冻结快照(`patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml`),守卫测试锁版本号与规模(18 路径/24 操作/45 schema),契约升版须同步快照否则 CI 红
- 契约测试为全响应矩阵覆盖:契约声明的每个(操作,状态码)单元格都被真实请求触发并结构校验;「契约未声明的字段即报漂移」
## 3. 修复与发现
- **契约漂移 1 项**(已修):CreatePetRequest.sex 契约必填、实现原静默补 unknown → 按冻结契约改 @NotBlank
- **草案→冻结修正 22 项**(19 号报告 §2 对照表,均有 13/16/17/18 号定型依据)
- **收紧**:pet 服务禁用 Jackson float→int 静默截断(amountCents: 45.5 → 40000)
- Idempotency-Key 拍板措辞出入说明:拍板列三个 POST,实现与冻结按 16 号定型表收录四个(vaccinations 也支持且有测试锁定),属拍板本意内(可选头)的完整收录
## 4. 遗留(下波或后续)
1. **第三波 Flutter 接入**(T2-11 起):契约已冻结,DTO/Client 可开工
2. auth 域 6 操作无契约测试(M1 交付时无此机制,机制可直接复用,建议另立工单)
3. 埋点队列:30 秒定时冲刷、退避/429、anonymousId 持久化(15 号报告 §4)
4. 09 号报告的实现-规范 5 处出入(64KB 上限、429 限流等)仍待排期评估
5. 真机联调补验(第一波方案 A 挂起项):事件落库确认 + SessionTracker 30min 手测
6. 提醒 PATCH 409 并发守卫为契约测试唯一豁免格(单线程无法确定性构造)
## 5. 三仓状态(收口时点)
| 仓库 | HEAD | 测试 |
|------|------|------|
| patbond-api | dev@d026f2f | 182/182 |
| patbond-flutter | dev@33b993c | 64/64 |
| patbond-doc | main@511617b(契约)+ 本收口提交 | strict 通过 |
File diff suppressed because it is too large Load Diff