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>
9.7 KiB
9.7 KiB
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(契约冻结直接采用)
{
"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 语义变更属破坏性调整,须走契约变更上报。