From 222990e58739c9bdec1cbb36cbce8c2c035a2354 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 10:40:27 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20M2=20=E7=AC=AC=E4=BA=8C=E6=B3=A2?= =?UTF-8?q?=E6=94=B6=E5=8F=A3=E2=80=94=E2=80=94=E6=8A=A5=E5=91=8A=2013~21?= =?UTF-8?q?=20=E4=B8=8E=E5=A5=91=E7=BA=A6=E8=8D=89=E6=A1=88=E5=85=A5?= =?UTF-8?q?=E6=A1=A3=E6=8C=82=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../13-pets-crud-permission-report.md | 144 ++ .../iteration-2/14-pets-contract-draft.md | 119 ++ .../15-analytics-persistent-queue.md | 71 + .../16-weights-vaccinations-report.md | 117 ++ .../iteration-2/17-events-reminders-report.md | 118 ++ .../iteration-2/18-pet-summary-report.md | 110 ++ .../iteration-2/19-contract-freeze-report.md | 117 ++ .../iteration-2/20-contract-test-report.md | 69 + .../iteration-2/21-wave2-closure.md | 64 + .../iteration-2/openapi-pets-draft.yaml | 1716 +++++++++++++++++ mkdocs.yml | 9 + 11 files changed, 2654 insertions(+) create mode 100644 docs/development/iterations/iteration-2/13-pets-crud-permission-report.md create mode 100644 docs/development/iterations/iteration-2/14-pets-contract-draft.md create mode 100644 docs/development/iterations/iteration-2/15-analytics-persistent-queue.md create mode 100644 docs/development/iterations/iteration-2/16-weights-vaccinations-report.md create mode 100644 docs/development/iterations/iteration-2/17-events-reminders-report.md create mode 100644 docs/development/iterations/iteration-2/18-pet-summary-report.md create mode 100644 docs/development/iterations/iteration-2/19-contract-freeze-report.md create mode 100644 docs/development/iterations/iteration-2/20-contract-test-report.md create mode 100644 docs/development/iterations/iteration-2/21-wave2-closure.md create mode 100644 docs/development/iterations/iteration-2/openapi-pets-draft.yaml diff --git a/docs/development/iterations/iteration-2/13-pets-crud-permission-report.md b/docs/development/iterations/iteration-2/13-pets-crud-permission-report.md new file mode 100644 index 0000000..2758f0e --- /dev/null +++ b/docs/development/iterations/iteration-2/13-pets-crud-permission-report.md @@ -0,0 +1,144 @@ +# 13 · T2-03 宠物 CRUD 与 pet_owners 权限框架交付报告 + +- **日期**:2026-09-07 +- **工单**:T2-03(M2 第二波关键路径) +- **仓库**:patbond-api,dev 分支 +- **角色**: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_owners(role=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 字段(camelCase,UUID 字符串,日期 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)+ Flyway(test 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 读通过/写 403;caregiver 读通过/档案 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**(+23:CRUD/字典 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)无需额外配置。 diff --git a/docs/development/iterations/iteration-2/14-pets-contract-draft.md b/docs/development/iterations/iteration-2/14-pets-contract-draft.md new file mode 100644 index 0000000..5bf1187 --- /dev/null +++ b/docs/development/iterations/iteration-2/14-pets-contract-draft.md @@ -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 天然幂等,不做乐观锁。其余三个 PATCH(pets/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` 1–64 | name minLength/maxLength | +| `ck_pet_weight` >0 且 ≤500 | weightKg minimum 0.01 / maximum 500(numeric(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` 1–160 | 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) | 三资源响应必含 version,PATCH 请求必填 version | +| care_reminders 无 version 列 | CareReminder 响应无 version,PATCH 无乐观锁 | +| `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 bearerAuth(http/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、未改动任何代码仓。 +- 冻结时的合并动作:去重 components(ErrorEnvelope/两个 responses/securityScheme)、版本号升 1.2.0、错误码表并入 info.description、消除全部 TODO-FREEZE——由主会话在 T2-03/T2-08 定型后协调执行。 diff --git a/docs/development/iterations/iteration-2/15-analytics-persistent-queue.md b/docs/development/iterations/iteration-2/15-analytics-persistent-queue.md new file mode 100644 index 0000000..eb7e8e1 --- /dev/null +++ b/docs/development/iterations/iteration-2/15-analytics-persistent-queue.md @@ -0,0 +1,71 @@ +# 埋点分段持久化队列实施报告(M2 第二波) + +> 作者:Frontend Developer(Flutter) +> 日期: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.` | 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.\ / 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) diff --git a/docs/development/iterations/iteration-2/16-weights-vaccinations-report.md b/docs/development/iterations/iteration-2/16-weights-vaccinations-report.md new file mode 100644 index 0000000..f165d15 --- /dev/null +++ b/docs/development/iterations/iteration-2/16-weights-vaccinations-report.md @@ -0,0 +1,117 @@ +# 16 · T2-04/T2-05 体重记录与疫苗接口交付报告 + +- **日期**:2026-09-07 +- **工单**:T2-04(体重记录,M)+ T2-05(疫苗目录与疫苗记录,L),同域内聚一并交付 +- **仓库**:patbond-api,dev 分支(提交 `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 + 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 再议。 diff --git a/docs/development/iterations/iteration-2/17-events-reminders-report.md b/docs/development/iterations/iteration-2/17-events-reminders-report.md new file mode 100644 index 0000000..7320979 --- /dev/null +++ b/docs/development/iterations/iteration-2/17-events-reminders-report.md @@ -0,0 +1,118 @@ +# 17 · T2-06/T2-07 健康事件时间线与照护提醒接口交付报告 + +- **日期**:2026-09-07 +- **工单**:T2-06(健康事件时间线,M)+ T2-07(照护提醒,M),同域内聚一并交付 +- **仓库**:patbond-api,dev 分支(提交 `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 号 §3,resource 前缀 `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 + caregiver(T2-03 §4 矩阵);两单均有 caregiver 写成功正向用例(§5)。 + +### 1.1 响应字段 + +- **HealthEventResponse**:`id, petId, eventType, occurredAt, title, notes, amountCents, createdByUserId, createdAt, updatedAt, version`。eventType ∈ medical/feeding/deworming/grooming/measurement/note;amountCents 整数分、可空、非负(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),比对通过才写入并 +1;updated_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 记 caregiver;PATCH 部分更新字段保持、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 同为 40402;viewer 读通过/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 内改期只能忽略后重建,契约冻结时可确认是否接受。 diff --git a/docs/development/iterations/iteration-2/18-pet-summary-report.md b/docs/development/iterations/iteration-2/18-pet-summary-report.md new file mode 100644 index 0000000..b727cfc --- /dev/null +++ b/docs/development/iterations/iteration-2/18-pet-summary-report.md @@ -0,0 +1,110 @@ +# 18 · T2-08 档案聚合摘要接口交付报告 + +- **日期**:2026-09-08 +- **工单**:T2-08(档案聚合摘要,M,第二波最后一单) +- **仓库**:patbond-api,dev 分支(提交 `00f7dbd`,已推送) +- **角色**:Senior Developer(后端) +- **前置**:复用 T2-03 `PetAccessService.require(userId, petId, READ)` 单一闸口,未新造权限逻辑;零新增数据库迁移;四项聚合全部从事实表实时计算,**无任何写路径**(开发计划 4.3 红线:不持久化展示字符串——聚合仓储只有 SELECT,测试有零写入落库断言)。 + +本报告 §2/§3 是 T2-09 契约冻结对 PetSummary 占位 schema(openapi-pets-draft.yaml `TODO-FREEZE`)的最终输入,聚合口径描述可逐字进契约。 + +--- + +## 1. 端点 + +| 端点 | 权限级别 | 成功响应 | 说明 | +| --- | --- | --- | --- | +| `GET /api/v1/pets/{petId}/summary?tz=` | READ(三角色皆可读) | 200,PetSummary(统一信封) | `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` | string,ISO 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 | +| 下次接种 | 跨来源取最早:逾期 nextDue(2026-09-01)胜过较晚 planned(2026-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-14(Flutter)**:疫苗进度、"下一针"、月度花费全部改从本接口取数;客户端务必传 `tz`(Asia/Shanghai),并按 §2 null 语义渲染空态(progress null ≠ 0/0)。 +- **T2-18(E2E)**:"摘要数值核对"步骤可按 §3 口径手算比对;tz 传 Asia/Shanghai。 +- 分母口径若产品后续引入"系列应打总针数"(目录扩展字段),totalDoses 语义变更属破坏性调整,须走契约变更上报。 diff --git a/docs/development/iterations/iteration-2/19-contract-freeze-report.md b/docs/development/iterations/iteration-2/19-contract-freeze-report.md new file mode 100644 index 0000000..21799e6 --- /dev/null +++ b/docs/development/iterations/iteration-2/19-contract-freeze-report.md @@ -0,0 +1,117 @@ +# 19 · T2-09 契约冻结报告:pets 域 12 路径合入正典(v1.2.0) + +- **日期**:2026-09-08 +- **工单**:T2-09(M2 第二波,契约冻结) +- **仓库**:patbond-doc,main 分支 +- **角色**:API 契约工程师 +- **结论先行**:`docs/api/openapi.yaml` 由 1.1.0(6 路径)升至 **1.2.0(18 路径 / 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 分页信封 | cursor,measured_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 §1(14 号裁量 #1 照采) | +| 12 | `/api/v1/pets/{petId}/health-events` | GET | READ | 200 分页信封 | cursor,occurred_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**(+8:40300/40401/40402/40902/40903/40904/42201/42202) | +| 复用组件 | — | 新增 responses 4(PetNotFound/RecordNotFound/PetWriteDenied/VersionConflict)、parameters 4(PetIdParam/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/reminderId,14 号裁量 #7) | **裸 `id`**,关联字段保留类型名(petId/vaccineId 等);路径参数名不变 | 16 偏差 #3、17 偏差 #2 | +| 7 | Vaccination 无疫苗名称 | 响应**增加 `vaccineName`**(同 breedDisplayName 先例) | 16 偏差 #4 | +| 8 | Pet 无 breedDisplayName;列表 Pet 不带 myRole(14 号裁量 #8) | **增加 `breedDisplayName`**;**myRole 进全部宠物响应**(列表/详情/创建/更新统一 Pet schema,PetDetail 撤销) | 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/40000(limit 越界、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 仅 owner;species 不可改;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 ≤32767;health-event notes ≤2000;amountCents 拒绝小数(40000,不静默截断);title btrim 空白 40000;创建提醒不收 status;createdByUserId 取自 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 号新增偏差 #5,weights/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 表 + TTL(16 §7);③ ADR-010 裁剪字段(avatar/certificate/provider/booking)M5 按纯增量补入(非破坏性,但须契约先行)。 +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 未动。 diff --git a/docs/development/iterations/iteration-2/20-contract-test-report.md b/docs/development/iterations/iteration-2/20-contract-test-report.md new file mode 100644 index 0000000..6cdc0b8 --- /dev/null +++ b/docs/development/iterations/iteration-2/20-contract-test-report.md @@ -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/40101;11 个 pet 路径操作验 40401 防枚举、3 个顶层短路径验 40402、8 个写操作按 viewer/caregiver 角色验 40300;12 处 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**(ContractConformanceTest:6 成功形态 + 3 错误信封 + 2 门禁) | +| **合计** | **171** | **182** | **+11** | + +`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test`:BUILD SUCCESS,182 项 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,确定性触发守卫落空;本单未做(属行为构造技巧,优先级低)。 diff --git a/docs/development/iterations/iteration-2/21-wave2-closure.md b/docs/development/iterations/iteration-2/21-wave2-closure.md new file mode 100644 index 0000000..036fcd7 --- /dev/null +++ b/docs/development/iterations/iteration-2/21-wave2-closure.md @@ -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 通过 | diff --git a/docs/development/iterations/iteration-2/openapi-pets-draft.yaml b/docs/development/iterations/iteration-2/openapi-pets-draft.yaml new file mode 100644 index 0000000..88d5313 --- /dev/null +++ b/docs/development/iterations/iteration-2/openapi-pets-draft.yaml @@ -0,0 +1,1716 @@ +openapi: 3.0.3 +info: + title: Patbond API — Pets Domain (M2 Draft) + version: 1.2.0-draft + description: | + M2 宠物健康档案域契约草案(起草态,待 T2-03 权限/错误语义与 T2-08 聚合字段定型后冻结)。 + + 本草案扩展既有 1.1.0 契约(6 端点:auth 4 + me 1 + events 1),增加 pets 域全部端点。 + **未冻结,字段可能变更**;冻结后任何变更须显著上报、两端同步。 + + ## Pets 域端点概览(本草案范围) + - 宠物 CRUD:GET/POST /api/v1/pets、GET/PATCH /api/v1/pets/{petId} + - 品种目录:GET /api/v1/breeds(按 species 过滤) + - 体重记录:GET/POST /api/v1/pets/{petId}/weights(cursor 分页) + - 疫苗目录:GET /api/v1/vaccine-catalog(按 species 过滤) + - 疫苗记录:GET/POST /api/v1/pets/{petId}/vaccinations、PATCH /api/v1/vaccinations/{vaccinationId} + - 健康事件:GET/POST /api/v1/pets/{petId}/health-events、PATCH /api/v1/health-events/{eventId} + - 照护提醒:GET/POST /api/v1/pets/{petId}/care-reminders、PATCH /api/v1/care-reminders/{reminderId} + - 档案聚合:GET /api/v1/pets/{petId}/summary(最新体重、疫苗进度、下次接种、当月花费) + + ## 通用约定(继承既有契约) + - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。 + - 所有时间字段为 ISO 8601 且带时区偏移(timestamptz 传输)。 + - 统一响应信封 `{"code": 0, "message": "success", "data": …}`;错误同时携带正确的 + HTTP 状态码与稳定业务码。 + - Pets 域全部端点强制 Bearer 鉴权(`Authorization: Bearer `)。 + + ## Pets 域新增错误码(追加进既有表,不改号) + | 业务码 | HTTP | 场景 | + | --- | --- | --- | + | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 尝试写等) | + | 40401 | 404 | PET_NOT_FOUND:宠物不存在或调用者不可见(防 ID 枚举,不区分) | + | 40402 | 404 | RECORD_NOT_FOUND:宠物下的记录(体重/疫苗/事件/提醒)不存在 | + | 40902 | 409 | VERSION_CONFLICT:乐观锁版本冲突(PATCH 操作,version 过期) | + | 40903 | 409 | DUPLICATE_VACCINATION:同宠物同疫苗同系列同剂次非 cancelled 记录已存在 | + | 42200 | 422 | BREED_CONSTRAINT_VIOLATION:breed_id 与 custom_breed_name 未互斥 | + | 42201 | 422 | VACCINATION_STATE_INVALID:疫苗状态机非法迁移或日期约束违反 | + + ## 权限模型(ADR-015:owner/caregiver/viewer 三角色) + # TODO-FREEZE: 等待 T2-03 权限校验实现定型后,补齐每个端点的权限规则描述与错误响应示例。 + # 草案默认:owner 全权、caregiver 可读写记录不可改宠物档案、viewer 只读; + # 无权限访问他人宠物返回 404/40401(不可见)或 403/40300(可见但越权)。 + +servers: + - url: http://127.0.0.1:8082 + description: patbond-pet 模块(本地开发,与 user 同服务或独立端口) + +tags: + - name: pets + description: 宠物档案 CRUD + - name: dictionaries + description: 品种与疫苗目录(只读) + - name: health-records + description: 体重、疫苗、健康事件、提醒 + +paths: + /api/v1/pets: + get: + tags: [pets] + summary: 当前用户可见宠物列表 + description: | + 返回当前用户拥有任何权限(owner/caregiver/viewer)的宠物列表。 + # TODO-FREEZE: 列表是否分页待 T2-03 实现时定——量小建议不分页直接返回数组。 + operationId: listPets + security: + - bearerAuth: [] + responses: + '200': + description: 宠物列表 + content: + application/json: + schema: + $ref: '#/components/schemas/PetListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + post: + tags: [pets] + summary: 创建宠物 + description: | + 创建宠物,创建者自动成为 primary owner(pet_owners 自动写入)。 + 品种:breed_id 与 custom_breed_name 必须二选一且互斥(ck_pets_breed 约束)。 + operationId: createPet + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreatePetRequest' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '422': + description: 业务约束违反(breed_id/custom_breed_name 互斥等,code 42200) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/pets/{petId}: + get: + tags: [pets] + summary: 宠物详情 + description: | + 返回宠物详情及调用者对该宠物的权限角色(role 字段)。 + # TODO-FREEZE: 权限拒绝时 403 vs 404 的语义待 T2-03 定型。 + operationId: getPet + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 宠物详情 + content: + application/json: + schema: + $ref: '#/components/schemas/PetDetailEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + patch: + tags: [pets] + summary: 更新宠物档案 + description: | + 更新宠物档案(名称、品种、性别、生日等)。 + 请求体必须包含 `version` 乐观锁字段;冲突返回 409/40902。 + # TODO-FREEZE: owner/caregiver 权限边界待 T2-03 定型(建议仅 owner 可写档案)。 + operationId: updatePet + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdatePetRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PetEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限更新(code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: 版本冲突(code 40902) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: 业务约束违反(code 42200) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/breeds: + get: + tags: [dictionaries] + summary: 品种目录 + description: | + 品种目录(只读),按 species 过滤。返回 enabled=true 的品种按 sort_order 排序。 + operationId: listBreeds + security: + - bearerAuth: [] + parameters: + - name: species + in: query + required: false + schema: + type: string + enum: [dog, cat, other] + description: 过滤物种;不传则返回全部 + responses: + '200': + description: 品种列表 + content: + application/json: + schema: + $ref: '#/components/schemas/BreedListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/pets/{petId}/weights: + get: + tags: [health-records] + summary: 体重记录列表 + description: | + 体重记录按 measured_at DESC, id DESC 排序,cursor 分页。 + operationId: listWeights + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: cursor + in: query + schema: + type: string + description: 上一页返回的 nextCursor,首次不传 + responses: + '200': + description: 体重记录分页结果 + content: + application/json: + schema: + $ref: '#/components/schemas/WeightListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + post: + tags: [health-records] + summary: 添加体重记录 + description: | + 添加体重记录。支持 Idempotency-Key(可选但推荐,避免重复提交)。 + operationId: createWeight + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + - name: Idempotency-Key + in: header + required: false + schema: + type: string + maxLength: 255 + description: 幂等键(可选),相同键重试不产生重复记录 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWeightRequest' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/WeightEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限添加(viewer 角色,code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/vaccine-catalog: + get: + tags: [dictionaries] + summary: 疫苗目录 + description: | + 疫苗目录(只读),按 species 过滤。返回 enabled=true 的疫苗。 + operationId: listVaccineCatalog + security: + - bearerAuth: [] + parameters: + - name: species + in: query + required: false + schema: + type: string + enum: [dog, cat, other] + responses: + '200': + description: 疫苗目录列表 + content: + application/json: + schema: + $ref: '#/components/schemas/VaccineCatalogListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + + /api/v1/pets/{petId}/vaccinations: + get: + tags: [health-records] + summary: 疫苗记录列表 + description: | + 宠物的疫苗记录。不分页或按 administered_on/planned_on 排序分页(待定)。 + # TODO-FREEZE: 分页策略待 T2-05 实现时定。 + operationId: listVaccinations + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 疫苗记录列表 + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + post: + tags: [health-records] + summary: 创建疫苗记录 + description: | + 创建疫苗记录(计划或已完成)。 + 状态机:scheduled 必须带 plannedOn;completed 必须带 administeredOn; + 同宠物同疫苗同系列同剂次非 cancelled 记录唯一(uq_pet_vaccination_dose)。 + # TODO-FREEZE: provider_id/booking_id/certificate_asset_id 按 ADR-010 不开放写入, + # 待 M5/媒体上传流程补齐后再开放,字段在草案保留但标记只读或不出现在请求体。 + operationId: createVaccination + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + - name: Idempotency-Key + in: header + required: false + schema: + type: string + maxLength: 255 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateVaccinationRequest' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限添加(code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: 同系列同剂次记录已存在(code 40903) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: 状态机或日期约束违反(code 42201) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/vaccinations/{vaccinationId}: + patch: + tags: [health-records] + summary: 更新疫苗记录 + description: | + 更新疫苗记录(状态流转、补充剂次标签、批号等)。 + 请求体必须包含 `version` 乐观锁;冲突返回 409/40902。 + operationId: updateVaccination + security: + - bearerAuth: [] + parameters: + - name: vaccinationId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateVaccinationRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/VaccinationEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限更新(code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 记录不存在(code 40402) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: 版本冲突(code 40902) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: 状态机或日期约束违反(code 42201) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/pets/{petId}/health-events: + get: + tags: [health-records] + summary: 健康事件时间线 + description: | + 健康事件按 occurredAt DESC, id DESC 排序,cursor 分页。 + operationId: listHealthEvents + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + - name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: cursor + in: query + schema: + type: string + responses: + '200': + description: 健康事件分页结果 + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + post: + tags: [health-records] + summary: 添加健康事件 + description: | + 添加健康事件。六类事件类型:medical/feeding/deworming/grooming/measurement/note。 + 金额以整数分(amount_cents)传输,非负。 + # TODO-FREEZE: provider_id/booking_id 按 ADR-010 不开放写入。 + operationId: createHealthEvent + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + - name: Idempotency-Key + in: header + required: false + schema: + type: string + maxLength: 255 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateHealthEventRequest' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限添加(code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/health-events/{eventId}: + patch: + tags: [health-records] + summary: 更新健康事件 + description: | + 编辑健康事件(标题、备注、金额)。请求体必须包含 `version` 乐观锁。 + operationId: updateHealthEvent + security: + - bearerAuth: [] + parameters: + - name: eventId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateHealthEventRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/HealthEventEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限更新(code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 记录不存在(code 40402) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '409': + description: 版本冲突(code 40902) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/pets/{petId}/care-reminders: + get: + tags: [health-records] + summary: 照护提醒列表 + description: | + 照护提醒列表,按 dueAt 排序。四类提醒类型:deworming/checkup/medication/other。 + # TODO-FREEZE: 是否分页、过滤待办(status=pending)的查询参数待 T2-07 实现时定。 + operationId: listCareReminders + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 提醒列表 + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderListEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + post: + tags: [health-records] + summary: 创建照护提醒 + description: | + 创建提醒(M2 仅 app 内数据,不做推送,ADR-010 说明)。 + operationId: createCareReminder + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateCareReminderRequest' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限添加(code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/care-reminders/{reminderId}: + patch: + tags: [health-records] + summary: 更新提醒状态 + description: | + 标记提醒完成或忽略(pending → completed/dismissed)。 + completed 状态必须写入 completedAt(ck_care_reminder_completed 约束)。 + operationId: updateCareReminder + security: + - bearerAuth: [] + parameters: + - name: reminderId + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateCareReminderRequest' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/CareReminderEnvelope' + '400': + $ref: '#/components/responses/ValidationError' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '403': + description: 无权限更新(code 40300) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '404': + description: 记录不存在(code 40402) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + '422': + description: 状态与 completedAt 一致性违反(code 42201 复用) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + + /api/v1/pets/{petId}/summary: + get: + tags: [health-records] + summary: 档案聚合摘要 + description: | + 实时聚合生成档案页摘要:最新体重、疫苗进度、下次接种、当月花费。 + # TODO-FREEZE: 等待 T2-08 聚合字段定型——字段命名、月度边界口径(UTC 或指定时区)、 + # 疫苗进度分母计算规则(总剂次 vs 已登记剂次)、下次接种取值优先级等。 + # 草案字段为占位,冻结前可能变更。 + operationId: getPetSummary + security: + - bearerAuth: [] + parameters: + - name: petId + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: 聚合摘要 + content: + application/json: + schema: + $ref: '#/components/schemas/PetSummaryEnvelope' + '401': + $ref: '#/components/responses/AccessTokenInvalid' + '404': + description: 宠物不存在或不可见(code 40401) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: 'Authorization: Bearer (RS256 JWT)' + + responses: + ValidationError: + description: 参数校验失败(code 40000) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + validation: + value: { code: 40000, message: 参数校验失败, data: null } + AccessTokenInvalid: + description: access token 缺失、无效或过期(code 40101) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorEnvelope' + examples: + tokenInvalid: + value: { code: 40101, message: token 无效或过期, data: null } + + schemas: + ErrorEnvelope: + type: object + required: [code, message] + properties: + code: + type: integer + description: 稳定业务错误码 + example: 40401 + message: + type: string + example: 宠物不存在或不可见 + data: + nullable: true + example: null + + # ========== Pets CRUD ========== + Pet: + type: object + required: + - petId + - name + - species + - sex + - status + - createdAt + - updatedAt + - version + properties: + petId: + type: string + format: uuid + name: + type: string + minLength: 1 + maxLength: 64 + species: + type: string + enum: [dog, cat, other] + breedId: + type: string + format: uuid + nullable: true + description: 品种 ID(与 customBreedName 互斥) + customBreedName: + type: string + nullable: true + minLength: 1 + maxLength: 64 + description: 自定义品种名(与 breedId 互斥) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + nullable: true + description: 生日(YYYY-MM-DD) + birthDateEstimated: + type: boolean + description: 生日是否为估计值 + personality: + type: string + nullable: true + maxLength: 64 + description: 性格标签 + avatarAssetId: + type: string + format: uuid + nullable: true + description: 头像 asset ID(M2 不开放写入,ADR-010) + microchipNo: + type: string + nullable: true + description: 芯片号(唯一) + sterilizedOn: + type: string + format: date + nullable: true + description: 绝育日期 + status: + type: string + enum: [active, lost, deceased, archived, deleted] + description: 状态(deleted 为软删除) + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + description: 乐观锁版本号(PATCH 时必须提交) + + PetDetail: + allOf: + - $ref: '#/components/schemas/Pet' + - type: object + required: [myRole] + properties: + myRole: + type: string + enum: [owner, caregiver, viewer] + description: 调用者对该宠物的权限角色 + + CreatePetRequest: + type: object + required: [name, species, sex] + properties: + name: + type: string + minLength: 1 + maxLength: 64 + species: + type: string + enum: [dog, cat, other] + breedId: + type: string + format: uuid + description: 品种 ID(与 customBreedName 二选一) + customBreedName: + type: string + minLength: 1 + maxLength: 64 + description: 自定义品种名(与 breedId 二选一) + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + nullable: true + birthDateEstimated: + type: boolean + default: false + personality: + type: string + maxLength: 64 + microchipNo: + type: string + sterilizedOn: + type: string + format: date + + UpdatePetRequest: + type: object + required: [version] + properties: + version: + type: integer + description: 当前持有的版本号(乐观锁) + name: + type: string + minLength: 1 + maxLength: 64 + breedId: + type: string + format: uuid + nullable: true + customBreedName: + type: string + minLength: 1 + maxLength: 64 + nullable: true + sex: + type: string + enum: [male, female, unknown] + birthDate: + type: string + format: date + nullable: true + birthDateEstimated: + type: boolean + personality: + type: string + maxLength: 64 + nullable: true + microchipNo: + type: string + nullable: true + sterilizedOn: + type: string + format: date + nullable: true + status: + type: string + enum: [active, lost, deceased, archived] + description: 状态流转(deleted 不开放) + + PetEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Pet' + + PetDetailEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/PetDetail' + + PetListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/Pet' + + # ========== Breeds ========== + Breed: + type: object + required: [breedId, species, code, displayName] + properties: + breedId: + type: string + format: uuid + species: + type: string + enum: [dog, cat, other] + code: + type: string + description: 品种代码(唯一标识) + displayName: + type: string + description: 展示名称 + + BreedListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/Breed' + + # ========== Weights ========== + WeightRecord: + type: object + required: [weightId, petId, weightKg, measuredAt, source, createdAt] + properties: + weightId: + type: string + format: uuid + petId: + type: string + format: uuid + weightKg: + type: number + format: double + minimum: 0.01 + maximum: 500 + description: 体重(公斤),保留两位小数 + measuredAt: + type: string + format: date-time + description: 称重时间 + source: + type: string + enum: [manual, clinic, device] + description: 来源 + note: + type: string + nullable: true + maxLength: 500 + createdAt: + type: string + format: date-time + + CreateWeightRequest: + type: object + required: [weightKg, measuredAt] + properties: + weightKg: + type: number + format: double + minimum: 0.01 + maximum: 500 + measuredAt: + type: string + format: date-time + source: + type: string + enum: [manual, clinic, device] + default: manual + note: + type: string + maxLength: 500 + + WeightEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/WeightRecord' + + WeightListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: object + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/WeightRecord' + nextCursor: + type: string + nullable: true + description: 下一页游标,无更多时为 null + hasMore: + type: boolean + + # ========== Vaccine Catalog ========== + VaccineCatalogItem: + type: object + required: [vaccineId, code, name, species] + properties: + vaccineId: + type: string + format: uuid + code: + type: string + description: 疫苗代码(唯一标识) + name: + type: string + description: 疫苗名称 + species: + type: string + enum: [dog, cat, other] + description: + type: string + nullable: true + maxLength: 500 + + VaccineCatalogListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/VaccineCatalogItem' + + # ========== Vaccinations ========== + Vaccination: + type: object + required: + - vaccinationId + - petId + - vaccineId + - seriesKey + - doseNo + - status + - createdAt + - updatedAt + - version + properties: + vaccinationId: + type: string + format: uuid + petId: + type: string + format: uuid + vaccineId: + type: string + format: uuid + seriesKey: + type: string + minLength: 1 + maxLength: 64 + description: 系列键(区分初次/加强等,与 doseNo 共同唯一) + doseNo: + type: integer + minimum: 1 + description: 剂次号 + doseLabel: + type: string + nullable: true + maxLength: 64 + description: 剂次标签(如"第一针") + status: + type: string + enum: [scheduled, completed, cancelled] + plannedOn: + type: string + format: date + nullable: true + description: 计划接种日期(scheduled 必填) + administeredOn: + type: string + format: date + nullable: true + description: 实际接种日期(completed 必填) + nextDueOn: + type: string + format: date + nullable: true + description: 下次到期日期 + manufacturer: + type: string + nullable: true + maxLength: 128 + batchNo: + type: string + nullable: true + maxLength: 64 + notes: + type: string + nullable: true + maxLength: 1000 + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + + CreateVaccinationRequest: + type: object + required: [vaccineId, seriesKey, doseNo, status] + properties: + vaccineId: + type: string + format: uuid + seriesKey: + type: string + minLength: 1 + maxLength: 64 + doseNo: + type: integer + minimum: 1 + doseLabel: + type: string + maxLength: 64 + status: + type: string + enum: [scheduled, completed, cancelled] + plannedOn: + type: string + format: date + description: scheduled 状态必填 + administeredOn: + type: string + format: date + description: completed 状态必填 + nextDueOn: + type: string + format: date + manufacturer: + type: string + maxLength: 128 + batchNo: + type: string + maxLength: 64 + notes: + type: string + maxLength: 1000 + + UpdateVaccinationRequest: + type: object + required: [version] + properties: + version: + type: integer + status: + type: string + enum: [scheduled, completed, cancelled] + plannedOn: + type: string + format: date + nullable: true + administeredOn: + type: string + format: date + nullable: true + nextDueOn: + type: string + format: date + nullable: true + doseLabel: + type: string + maxLength: 64 + nullable: true + manufacturer: + type: string + maxLength: 128 + nullable: true + batchNo: + type: string + maxLength: 64 + nullable: true + notes: + type: string + maxLength: 1000 + nullable: true + + VaccinationEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/Vaccination' + + VaccinationListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/Vaccination' + + # ========== Health Events ========== + HealthEvent: + type: object + required: + - eventId + - petId + - eventType + - occurredAt + - title + - createdByUserId + - createdAt + - updatedAt + - version + properties: + eventId: + type: string + format: uuid + petId: + type: string + format: uuid + eventType: + type: string + enum: [medical, feeding, deworming, grooming, measurement, note] + occurredAt: + type: string + format: date-time + description: 事件发生时间 + title: + type: string + minLength: 1 + maxLength: 160 + notes: + type: string + nullable: true + description: 备注(text 长文本) + amountCents: + type: integer + format: int64 + nullable: true + minimum: 0 + description: 金额(整数分) + createdByUserId: + type: string + format: uuid + description: 创建者用户 ID + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + version: + type: integer + + CreateHealthEventRequest: + type: object + required: [eventType, occurredAt, title] + properties: + eventType: + type: string + enum: [medical, feeding, deworming, grooming, measurement, note] + occurredAt: + type: string + format: date-time + title: + type: string + minLength: 1 + maxLength: 160 + notes: + type: string + amountCents: + type: integer + format: int64 + minimum: 0 + description: 金额(整数分) + + UpdateHealthEventRequest: + type: object + required: [version] + properties: + version: + type: integer + title: + type: string + minLength: 1 + maxLength: 160 + notes: + type: string + nullable: true + amountCents: + type: integer + format: int64 + minimum: 0 + nullable: true + + HealthEventEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/HealthEvent' + + HealthEventListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: object + required: [items, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/HealthEvent' + nextCursor: + type: string + nullable: true + hasMore: + type: boolean + + # ========== Care Reminders ========== + CareReminder: + type: object + required: + - reminderId + - petId + - reminderType + - title + - dueAt + - status + - createdAt + - updatedAt + properties: + reminderId: + type: string + format: uuid + petId: + type: string + format: uuid + reminderType: + type: string + enum: [deworming, checkup, medication, other] + title: + type: string + minLength: 1 + maxLength: 160 + dueAt: + type: string + format: date-time + description: 到期时间 + status: + type: string + enum: [pending, completed, dismissed] + completedAt: + type: string + format: date-time + nullable: true + description: 完成时间(completed 状态必填) + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + CreateCareReminderRequest: + type: object + required: [reminderType, title, dueAt] + properties: + reminderType: + type: string + enum: [deworming, checkup, medication, other] + title: + type: string + minLength: 1 + maxLength: 160 + dueAt: + type: string + format: date-time + + UpdateCareReminderRequest: + type: object + required: [status] + properties: + status: + type: string + enum: [pending, completed, dismissed] + completedAt: + type: string + format: date-time + description: 标记 completed 时必填 + + CareReminderEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/CareReminder' + + CareReminderListEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + type: array + items: + $ref: '#/components/schemas/CareReminder' + + # ========== Pet Summary (Aggregation) ========== + # TODO-FREEZE: 等待 T2-08 定型,字段为占位草案 + PetSummary: + type: object + properties: + petId: + type: string + format: uuid + latestWeight: + type: object + nullable: true + description: 最新体重 + properties: + weightKg: + type: number + format: double + measuredAt: + type: string + format: date-time + vaccinationProgress: + type: object + nullable: true + description: 疫苗进度(口径待定:已完成剂次/总预期剂次) + properties: + completedDoses: + type: integer + description: 已完成剂次 + totalDoses: + type: integer + description: 总剂次(口径待定) + nextVaccination: + type: object + nullable: true + description: 下次接种(scheduled 中最近 plannedOn 或最近 nextDueOn,优先级待定) + properties: + vaccineName: + type: string + plannedOn: + type: string + format: date + monthlyExpense: + type: object + nullable: true + description: 当月花费(月度边界口径待定:UTC 月或指定时区月) + properties: + amountCents: + type: integer + format: int64 + description: 金额(整数分) + month: + type: string + description: 月份标识(格式待定,如 2026-09) + + PetSummaryEnvelope: + type: object + required: [code, message, data] + properties: + code: + type: integer + enum: [0] + message: + type: string + example: success + data: + $ref: '#/components/schemas/PetSummary' diff --git a/mkdocs.yml b/mkdocs.yml index 1299e74..cf1838c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,15 @@ nav: - 10 Flutter 埋点修复: development/iterations/iteration-2/10-flutter-analytics-repair.md - 11 后端地基报告: development/iterations/iteration-2/11-backend-foundation-report.md - 12 第一波收口: development/iterations/iteration-2/12-wave1-closure.md + - 13 宠物 CRUD 与权限框架: development/iterations/iteration-2/13-pets-crud-permission-report.md + - 14 契约起草说明: development/iterations/iteration-2/14-pets-contract-draft.md + - 15 埋点持久化队列: development/iterations/iteration-2/15-analytics-persistent-queue.md + - 16 体重与疫苗接口: development/iterations/iteration-2/16-weights-vaccinations-report.md + - 17 健康事件与提醒接口: development/iterations/iteration-2/17-events-reminders-report.md + - 18 档案聚合摘要: development/iterations/iteration-2/18-pet-summary-report.md + - 19 契约冻结报告: development/iterations/iteration-2/19-contract-freeze-report.md + - 20 契约一致性测试: development/iterations/iteration-2/20-contract-test-report.md + - 21 第二波收口: development/iterations/iteration-2/21-wave2-closure.md - API: - 契约说明: api/index.md - 架构: