222990e587
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>
111 lines
9.7 KiB
Markdown
111 lines
9.7 KiB
Markdown
# 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 语义变更属破坏性调整,须走契约变更上报。
|