Files
patbond-doc/docs/development/iterations/iteration-2/18-pet-summary-report.md
T
lixi 222990e587
CI / docs-build (push) Successful in 1m19s
docs: M2 第二波收口——报告 13~21 与契约草案入档挂导航
- 13~18 后端纵切六单报告(T2-03~08,测试 95→182)
- 14 + openapi-pets-draft.yaml 契约起草档案
- 19 契约冻结报告(v1.2.0,22 项草案修正对照)
- 20 契约一致性测试(快照机制 + 1 漂移修复)
- 21 第二波收口总表(定型语义汇总,第三波接入依据)
- mkdocs build --strict 通过

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-08 10:40:27 +08:00

111 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 语义变更属破坏性调整,须走契约变更上报。