docs: M2 第二波收口——报告 13~21 与契约草案入档挂导航
CI / docs-build (push) Successful in 1m19s

- 13~18 后端纵切六单报告(T2-03~08,测试 95→182)
- 14 + openapi-pets-draft.yaml 契约起草档案
- 19 契约冻结报告(v1.2.0,22 项草案修正对照)
- 20 契约一致性测试(快照机制 + 1 漂移修复)
- 21 第二波收口总表(定型语义汇总,第三波接入依据)
- mkdocs build --strict 通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 10:40:27 +08:00
parent 511617be55
commit 222990e587
11 changed files with 2654 additions and 0 deletions
@@ -0,0 +1,110 @@
# 18 · T2-08 档案聚合摘要接口交付报告
- **日期**2026-09-08
- **工单**:T2-08(档案聚合摘要,M,第二波最后一单)
- **仓库**patbond-apidev 分支(提交 `00f7dbd`,已推送)
- **角色**Senior Developer(后端)
- **前置**:复用 T2-03 `PetAccessService.require(userId, petId, READ)` 单一闸口,未新造权限逻辑;零新增数据库迁移;四项聚合全部从事实表实时计算,**无任何写路径**(开发计划 4.3 红线:不持久化展示字符串——聚合仓储只有 SELECT,测试有零写入落库断言)。
本报告 §2/§3 是 T2-09 契约冻结对 PetSummary 占位 schemaopenapi-pets-draft.yaml `TODO-FREEZE`)的最终输入,聚合口径描述可逐字进契约。
---
## 1. 端点
| 端点 | 权限级别 | 成功响应 | 说明 |
| --- | --- | --- | --- |
| `GET /api/v1/pets/{petId}/summary?tz=` | READ(三角色皆可读) | 200PetSummary(统一信封) | `tz` 可选,IANA 时区标识(如 `Asia/Shanghai`,也接受固定偏移如 `+08:00`),缺省 `UTC`,仅作用于当月花费的月度窗口;非法 tz → 400/40000 |
错误语义全部继承既有定型:401/40101(无 token)、404/40401(宠物不存在/软删/无关系,防枚举、响应逐字一致,有测试)、400/40000(tz 非法或超 64 字符)。本单**无新增错误码**。
## 2. PetSummary 最终 schema(契约冻结直接采用)
```json
{
"petId": "uuid",
"latestWeight": { "weightKg": 5.25, "measuredAt": "2026-09-05T08:00:00Z" },
"vaccinationProgress": { "completedDoses": 2, "totalDoses": 3 },
"nextVaccination": { "vaccinationId": "uuid", "vaccineId": "uuid",
"vaccineName": "狂犬疫苗(猫)", "doseNo": 1,
"doseLabel": "年度加强", "dueOn": "2026-09-01",
"source": "nextDue" },
"monthlyExpense": { "month": "2026-09", "timezone": "UTC", "amountCents": 300 }
}
```
### 字段与 null 语义
| 字段 | 类型 | null 语义 |
| --- | --- | --- |
| `petId` | string(uuid) | 恒非 null,回显路径参数 |
| `latestWeight` | object \| **null** | null ⟺ 无体重记录 |
| `latestWeight.weightKg` | number(两位小数,numeric(6,2) | 对象存在时非 null |
| `latestWeight.measuredAt` | string(date-time, ISO 8601) | 对象存在时非 null |
| `vaccinationProgress` | object \| **null** | null ⟺ 无非 cancelled 疫苗记录(**不是 0/0** |
| `vaccinationProgress.completedDoses` | integer ≥ 0 | 对象存在时非 null |
| `vaccinationProgress.totalDoses` | integer ≥ 1 | 对象存在时非 null=0 即整体 null |
| `nextVaccination` | object \| **null** | null ⟺ 候选集为空(见 §3.3) |
| `nextVaccination.vaccinationId` | string(uuid) | 非 null,命中的疫苗记录 id(客户端可跳详情) |
| `nextVaccination.vaccineId` | string(uuid) | 非 null |
| `nextVaccination.vaccineName` | string | 非 null,出自 vaccine_catalog(同 breedDisplayName 先例) |
| `nextVaccination.doseNo` | integer | 非 null |
| `nextVaccination.doseLabel` | string \| null | 记录本身可无标签 |
| `nextVaccination.dueOn` | string(date) | 非 null**可为过去日期**(逾期针仍是下一针) |
| `nextVaccination.source` | string enum`planned` \| `nextDue` | 非 null,标注取值来源(17 号报告 §7 要求区分两口径) |
| `monthlyExpense` | object | **恒非 null**(月份/时区总可确定) |
| `monthlyExpense.month` | stringISO year-month`2026-09` | 非 null |
| `monthlyExpense.timezone` | string | 非 null,回显窗口所用时区(缺省 `UTC` |
| `monthlyExpense.amountCents` | integer(int64) ≥ 0 | 非 null,无支出为 **0** |
与草案占位的差异:`nextVaccination``dueOn` + `source` 替代草案单一 `plannedOn`(两种来源的日期语义不同,混用一个字段名会误导);增加 `vaccinationId/vaccineId/doseNo/doseLabel`(客户端展示"第 N 针"与跳转所需,纯增量);`monthlyExpense` 增加 `timezone` 回显、`month` 定为 ISO year-month。
## 3. 四项聚合口径定型表(逐字进契约描述)
| # | 聚合 | 口径(定型) |
| --- | --- | --- |
| 3.1 | **最新体重** | pet_weight_records 按 `(measured_at DESC, id DESC)` 取首行——与体重列表接口首行完全一致(同一索引 ix_pet_weight_pet_measured、同一 tie-break),同刻多条时后写入者(id 更大)胜出。无记录 → null。 |
| 3.2 | **疫苗进度** | 范围 = 该宠物**非 cancelled** 的 pet_vaccinations 行。`completedDoses` = 其中 status=completed 的行数;`totalDoses` = 全部非 cancelled 行数(= scheduled + completed,即"已登记剂次"——数据模型没有权威的"系列应打总针数",分母取用户已登记数,T2-09 草案 TODO 的"总剂次 vs 已登记剂次"按后者定案)。cancelled 分子分母皆不计入。totalDoses=0 → 整体 null。 |
| 3.3 | **下次接种** | 候选集两类并集:① 全部 scheduled 行的 `planned_on`(约束保证非空;含过期——逾期计划在完成/取消前仍是下一针),source=`planned`;② completed 行的非空 `next_due_on`**仅当同 (pet, vaccine, series_key) 不存在更高 dose_no 的非 cancelled 记录**(后续针一经登记,其自身即代表下一针,前一针的到期日失效),source=`nextDue`。cancelled 行不产生任何候选。取 `dueOn` 最小者;同日 planned 优先于 nextDue,再按 id 升序保证确定性。候选集空 → null。 |
| 3.4 | **当月花费** | health_events.`amount_cents` 求和,窗口为**请求时刻在 `tz` 时区的自然月半开区间** `[当月1日00:00, 次月1日00:00)`,对 `occurred_at`timestamptz)比较;月初第一刻含、次月第一刻不含。`amount_cents` 为 NULL 的事件不计入;不按 event_type 过滤(任何事件类型的金额都算支出)。`tz` 缺省 **UTC**(服务端无状态、口径明确),客户端(目标用户 Asia/Shanghai)应传自己的时区获得符合直觉的月边界——月边界随 tz 移动,有测试锁定。恒返回对象:`month` 为窗口所属 ISO 年月、`timezone` 回显、无支出 `amountCents=0`。 |
**时区口径权衡记录(供冻结评审)**:工单给出 UTC 或 client 时区参数两选项。定案"**tz 参数 + 缺省 UTC**":纯 UTC 会把北京时间月初 0~8 点的支出记到上月(对 +8 用户每月两端各错 8 小时);服务端猜用户时区则引入状态。参数化让口径显式进契约,缺省 UTC 保证不传参数时行为完全可预期。非法 tz(`ZoneId.of` 不识别)→ 40000"tz 不是有效的时区标识"。
## 4. 实现
- `PetSummaryRepository`:四条只读 SQL 集中一处,与 §3 逐条对应可审计。最新体重走 ix_pet_weight_pet_measured;下次接种的 scheduled 支走 ix_vaccinations_due 部分索引(16 号 §7 移交建议);当月花费走 ix_health_events_pet_time 前缀 (pet_id, occurred_at)。
- `PetSummaryService`READ 闸口 → tz 解析(Java 侧算出月窗口两端 instant,SQL 只做区间比较,索引友好)→ 组装。
- `PetSummaryController`:单 GET`tz` 参数 @Size(max=64) 兜底。
- 文件(patbond-pet 模块):`dto/PetSummaryResponse.java`(含 4 个嵌套 record)、`repository/PetSummaryRepository.java``service/PetSummaryService.java``controller/PetSummaryController.java`
## 5. 测试(12 例,全部集成测试锁口径)
| 类别 | 用例 |
| --- | --- |
| 空数据语义 | 新建宠物:三聚合 null、monthlyExpense={当月, UTC, 0}、petId 回显 |
| 最新体重 | 乱序写入取最大 measured_at;同刻两条 id 大者胜(与列表口径一致断言) |
| 疫苗进度 | completed 2 + scheduled 1 + cancelled 1 → 2/3;仅剩 cancelled → progress 与 nextVaccination 双 null |
| 下次接种 | 跨来源取最早:逾期 nextDue2026-09-01)胜过较晚 planned2026-12-01),source/doseLabel/vaccineName 全字段断言;被接续剔除:第 1 针 next_due_on 更早但第 2 针已排期 → 取第 2 针 planned |
| 当月花费 | UTC 半开区间四边界(月初 0 秒含、月末最后一秒含、上月最后一秒不含、次月 0 秒不含)+ 无金额事件不计 → 精确 300;Asia/Shanghai 窗口按上海月边界(月初含/上月末不含)+ month/timezone 回显;非法 tz → 40000 |
| 多宠隔离 | 宠 A 的体重/疫苗/支出不泄入宠 B 摘要 |
| 权限 | viewer 200 可读;陌生人访问真实宠物与随机 UUID 响应**逐字一致**40401 防枚举);无 token 40101 |
| 红线 | 摘要请求前后三张事实表行数不变(零写入断言) |
### 测试数变化
| 模块 | 交付前 | 交付后 |
| --- | --- | --- |
| patbond-common | 3 | 3 |
| patbond-user | 59 | 59 |
| patbond-auth | 31 | 31 |
| patbond-pet | 66 | **78**+12 |
| **合计** | **159** | **171** |
`JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 全绿(2026-09-08,一次通过)。
## 6. 遗留与移交
- **T2-09 冻结**:§2 schema + §3 口径表为最终输入,PetSummary 的 TODO-FREEZE 可全部解除;需评审拍板两处:① tz 参数 + 缺省 UTC 的时区口径(§3.4 权衡);② `nextVaccination` 相对草案的字段调整(dueOn/source 替代 plannedOn,纯语义修正)。
- **T2-13/T2-14Flutter**:疫苗进度、"下一针"、月度花费全部改从本接口取数;客户端务必传 `tz`Asia/Shanghai),并按 §2 null 语义渲染空态(progress null ≠ 0/0)。
- **T2-18E2E**"摘要数值核对"步骤可按 §3 口径手算比对;tz 传 Asia/Shanghai。
- 分母口径若产品后续引入"系列应打总针数"(目录扩展字段),totalDoses 语义变更属破坏性调整,须走契约变更上报。