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

9.7 KiB
Raw Blame History

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(契约冻结直接采用)

{
  "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 enumplanned | nextDue 非 null,标注取值来源(17 号报告 §7 要求区分两口径)
monthlyExpense object 恒非 null(月份/时区总可确定)
monthlyExpense.month stringISO year-month2026-09 非 null
monthlyExpense.timezone string 非 null,回显窗口所用时区(缺省 UTC
monthlyExpense.amountCents integer(int64) ≥ 0 非 null,无支出为 0

与草案占位的差异:nextVaccinationdueOn + 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_attimestamptz)比较;月初第一刻含、次月第一刻不含。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)。
  • PetSummaryServiceREAD 闸口 → tz 解析(Java 侧算出月窗口两端 instant,SQL 只做区间比较,索引友好)→ 组装。
  • PetSummaryController:单 GETtz 参数 @Size(max=64) 兜底。
  • 文件(patbond-pet 模块):dto/PetSummaryResponse.java(含 4 个嵌套 record)、repository/PetSummaryRepository.javaservice/PetSummaryService.javacontroller/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:疫苗进度、"下一针"、月度花费全部改从本接口取数;客户端务必传 tzAsia/Shanghai),并按 §2 null 语义渲染空态(progress null ≠ 0/0)。
  • T2-18E2E:"摘要数值核对"步骤可按 §3 口径手算比对;tz 传 Asia/Shanghai。
  • 分母口径若产品后续引入"系列应打总针数"(目录扩展字段),totalDoses 语义变更属破坏性调整,须走契约变更上报。