feat: 档案聚合摘要接口与四项聚合口径定型(T2-08)
CI / backend-test (push) Successful in 3m36s

- GET /api/v1/pets/{petId}/summary,READ 档(viewer 可读),四项聚合全部实时计算、零持久化(4.3 红线)
- 最新体重:measured_at DESC, id DESC 首行,与体重列表口径一致;无记录为 null
- 疫苗进度:completed 剂次 / 非 cancelled 总剂次;无非 cancelled 记录为 null
- 下次接种:scheduled planned_on 与 completed next_due_on(同系列更高剂次未登记时)取全候选最早日期,含逾期;source 区分 planned/nextDue
- 当月花费:health_events.amount_cents 按 tz 参数(IANA 时区,缺省 UTC)自然月半开区间求和,NULL 金额不计入;恒非 null,无支出为 0
- 集成测试 12 例:空数据语义、跨月边界(含时区口径)、cancelled 不计入、被接续加强针剔除、多宠隔离、防枚举 404、零写入断言

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 09:45:50 +08:00
parent 3b27f9fcbe
commit 00f7dbdb69
5 changed files with 744 additions and 0 deletions
@@ -0,0 +1,43 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.PetSummaryResponse;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.service.PetSummaryService;
import jakarta.validation.constraints.Size;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Profile aggregate summary (T2-08): one read-only endpoint, authorization
* through the T2-03 READ gate inside PetSummaryService. {@code tz} scopes
* only the monthly-expense window (IANA zone id, default UTC).
*/
@RestController
@RequestMapping("/api/v1/pets/{petId}/summary")
@Validated
public class PetSummaryController {
private final PetSummaryService petSummaryService;
public PetSummaryController(PetSummaryService petSummaryService) {
this.petSummaryService = petSummaryService;
}
@GetMapping
public ApiResponse<PetSummaryResponse> summary(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId,
@RequestParam(required = false)
@Size(max = 64, message = "tz 最长 64 字符")
String tz) {
return ApiResponse.success(petSummaryService.summarize(userId, petId, tz));
}
}
@@ -0,0 +1,73 @@
package com.patbond.patbond.pet.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* GET /api/v1/pets/{petId}/summary — the profile-page aggregate (T2-08).
* Every value is computed live from the fact tables at request time; nothing
* here is ever persisted (development-plan 4.3 red line: no stored display
* strings).
*
* <p>Null semantics (frozen for the M2 contract):
* <ul>
* <li>{@code latestWeight} — null iff the pet has no weight records.</li>
* <li>{@code vaccinationProgress} — null iff the pet has no non-cancelled
* vaccination records (cancelled rows count for nothing).</li>
* <li>{@code nextVaccination} — null iff the candidate set is empty (no
* scheduled rows and no non-superseded completed row with a
* next_due_on).</li>
* <li>{@code monthlyExpense} — never null; a month with no expenses is
* {@code amountCents = 0}.</li>
* </ul>
*/
public record PetSummaryResponse(
UUID petId,
LatestWeight latestWeight,
VaccinationProgress vaccinationProgress,
NextVaccination nextVaccination,
MonthlyExpense monthlyExpense) {
/** The row with the greatest (measured_at, id) — ties broken like the list. */
public record LatestWeight(BigDecimal weightKg, OffsetDateTime measuredAt) {
}
/**
* completedDoses = rows with status 'completed'; totalDoses = all
* non-cancelled rows (registered doses — there is no authoritative
* "expected series length" in the data model, so the denominator is what
* the user has registered: scheduled + completed).
*/
public record VaccinationProgress(int completedDoses, int totalDoses) {
}
/**
* The earliest upcoming (or overdue — past dates stay "next" until acted
* on) shot across two candidate kinds: a scheduled row's planned_on
* ({@code source = "planned"}) or a completed row's next_due_on
* ({@code source = "nextDue"}, only while no higher dose of the same
* series is registered non-cancelled). Same-day ties prefer planned,
* then the lowest id.
*/
public record NextVaccination(
UUID vaccinationId,
UUID vaccineId,
String vaccineName,
int doseNo,
String doseLabel,
LocalDate dueOn,
String source) {
}
/**
* SUM(amount_cents) of health_events whose occurred_at falls in the
* current calendar month of {@code timezone} (start inclusive, next month
* start exclusive); rows with a null amount contribute nothing.
* {@code month} is the ISO year-month ("2026-09") the window covers,
* {@code timezone} echoes the zone the boundaries were computed in.
*/
public record MonthlyExpense(String month, String timezone, long amountCents) {
}
}
@@ -0,0 +1,145 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.PetSummaryResponse.LatestWeight;
import com.patbond.patbond.pet.dto.PetSummaryResponse.NextVaccination;
import com.patbond.patbond.pet.dto.PetSummaryResponse.VaccinationProgress;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.Optional;
import java.util.UUID;
/**
* Read-only aggregates for the profile summary (T2-08), kept in one place so
* the frozen contract semantics are auditable as four queries. Everything is
* computed from the fact tables at request time — this repository has no
* write path by design (development-plan 4.3: no persisted display strings).
* Permission gating happens in the service; every query is scoped by pet_id.
*/
@Repository
public class PetSummaryRepository {
private final JdbcClient jdbcClient;
public PetSummaryRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Newest weight by (measured_at DESC, id DESC) — the exact key of
* ix_pet_weight_pet_measured and the same tie-break as the weight list,
* so the summary always equals the list's first row.
*/
public Optional<LatestWeight> latestWeight(UUID petId) {
return jdbcClient.sql("""
SELECT weight_kg, measured_at
FROM pet_health.pet_weight_records
WHERE pet_id = :petId
ORDER BY measured_at DESC, id DESC
LIMIT 1
""")
.param("petId", petId)
.query((rs, rowNum) -> new LatestWeight(
rs.getBigDecimal("weight_kg"),
rs.getObject("measured_at", OffsetDateTime.class)))
.optional();
}
/**
* completed count over all non-cancelled rows. Empty when the pet has no
* non-cancelled vaccinations (the summary field is null, not 0/0).
*/
public Optional<VaccinationProgress> vaccinationProgress(UUID petId) {
VaccinationProgress progress = jdbcClient.sql("""
SELECT COUNT(*) FILTER (WHERE status = 'completed') AS completed_doses,
COUNT(*) AS total_doses
FROM pet_health.pet_vaccinations
WHERE pet_id = :petId AND status <> 'cancelled'
""")
.param("petId", petId)
.query((rs, rowNum) -> new VaccinationProgress(
rs.getInt("completed_doses"),
rs.getInt("total_doses")))
.single();
return progress.totalDoses() == 0 ? Optional.empty() : Optional.of(progress);
}
/**
* The next shot: the earliest due date over the union of
* <ul>
* <li>scheduled rows' planned_on (non-null by ck_vaccination_dates),
* walking ix_vaccinations_due;</li>
* <li>completed rows' non-null next_due_on, excluding rows superseded
* by a higher dose of the same (vaccine_id, series_key) that is
* registered non-cancelled — once the follow-up dose exists, it
* speaks for itself and the stale booster date must not
* resurface.</li>
* </ul>
* Past dates are included on purpose: an overdue shot remains "next"
* until it is completed or cancelled. Ties on the date prefer the
* explicit plan over the derived due date, then the lowest id for
* determinism.
*/
public Optional<NextVaccination> nextVaccination(UUID petId) {
return jdbcClient.sql("""
SELECT id, vaccine_id, vaccine_name, dose_no, dose_label, due_on, source
FROM (
SELECT v.id, v.vaccine_id, c.name AS vaccine_name, v.dose_no,
v.dose_label, v.planned_on AS due_on,
'planned' AS source, 0 AS tie_rank
FROM pet_health.pet_vaccinations v
JOIN pet_health.vaccine_catalog c ON c.id = v.vaccine_id
WHERE v.pet_id = :petId AND v.status = 'scheduled'
UNION ALL
SELECT v.id, v.vaccine_id, c.name AS vaccine_name, v.dose_no,
v.dose_label, v.next_due_on AS due_on,
'nextDue' AS source, 1 AS tie_rank
FROM pet_health.pet_vaccinations v
JOIN pet_health.vaccine_catalog c ON c.id = v.vaccine_id
WHERE v.pet_id = :petId AND v.status = 'completed'
AND v.next_due_on IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM pet_health.pet_vaccinations s
WHERE s.pet_id = v.pet_id
AND s.vaccine_id = v.vaccine_id
AND s.series_key = v.series_key
AND s.dose_no > v.dose_no
AND s.status <> 'cancelled')
) candidates
ORDER BY due_on, tie_rank, id
LIMIT 1
""")
.param("petId", petId)
.query((rs, rowNum) -> new NextVaccination(
rs.getObject("id", UUID.class),
rs.getObject("vaccine_id", UUID.class),
rs.getString("vaccine_name"),
rs.getInt("dose_no"),
rs.getString("dose_label"),
rs.getObject("due_on", LocalDate.class),
rs.getString("source")))
.optional();
}
/**
* SUM(amount_cents) over [start, end) on occurred_at — the half-open
* month window the service computed in the requested zone. SQL SUM skips
* null amounts by definition; COALESCE turns the empty month into 0.
* Scans ix_health_events_pet_time (pet_id, occurred_at).
*/
public long expenseCentsBetween(UUID petId, OffsetDateTime start, OffsetDateTime end) {
return jdbcClient.sql("""
SELECT COALESCE(SUM(amount_cents), 0)
FROM pet_health.health_events
WHERE pet_id = :petId
AND occurred_at >= :start AND occurred_at < :end
""")
.param("petId", petId)
.param("start", start)
.param("end", end)
.query(Long.class)
.single();
}
}
@@ -0,0 +1,75 @@
package com.patbond.patbond.pet.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.pet.access.AccessLevel;
import com.patbond.patbond.pet.access.PetAccessService;
import com.patbond.patbond.pet.dto.PetSummaryResponse;
import com.patbond.patbond.pet.dto.PetSummaryResponse.MonthlyExpense;
import com.patbond.patbond.pet.repository.PetSummaryRepository;
import org.springframework.stereotype.Service;
import java.time.DateTimeException;
import java.time.OffsetDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.util.UUID;
/**
* Profile summary (T2-08). Opens with the READ gate (all three roles may
* look at the profile page), then assembles four live aggregates — nothing
* is written anywhere.
*
* <p>The monthly-expense window is the current calendar month in the
* caller-supplied {@code tz} (IANA zone id, default UTC): the client knows
* what "this month" means to its user, the server stays stateless. The
* boundaries are computed here as instants and compared against
* occurred_at (timestamptz) as a half-open interval [monthStart,
* nextMonthStart).
*/
@Service
public class PetSummaryService {
/** Contract default: UTC unless the client sends its own zone. */
static final String DEFAULT_TIMEZONE = "UTC";
private final PetSummaryRepository petSummaryRepository;
private final PetAccessService petAccessService;
public PetSummaryService(PetSummaryRepository petSummaryRepository,
PetAccessService petAccessService) {
this.petSummaryRepository = petSummaryRepository;
this.petAccessService = petAccessService;
}
public PetSummaryResponse summarize(UUID userId, UUID petId, String tz) {
petAccessService.require(userId, petId, AccessLevel.READ);
ZoneId zone = parseZone(tz);
YearMonth month = YearMonth.now(zone);
OffsetDateTime monthStart = month.atDay(1).atStartOfDay(zone).toOffsetDateTime();
OffsetDateTime nextMonthStart =
month.plusMonths(1).atDay(1).atStartOfDay(zone).toOffsetDateTime();
MonthlyExpense monthlyExpense = new MonthlyExpense(
month.toString(),
zone.getId(),
petSummaryRepository.expenseCentsBetween(petId, monthStart, nextMonthStart));
return new PetSummaryResponse(
petId,
petSummaryRepository.latestWeight(petId).orElse(null),
petSummaryRepository.vaccinationProgress(petId).orElse(null),
petSummaryRepository.nextVaccination(petId).orElse(null),
monthlyExpense);
}
/** @throws BusinessException 40000 when tz is not a zone the JDK knows */
private static ZoneId parseZone(String tz) {
String requested = tz == null || tz.isBlank() ? DEFAULT_TIMEZONE : tz.trim();
try {
return ZoneId.of(requested);
} catch (DateTimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "tz 不是有效的时区标识");
}
}
}
@@ -0,0 +1,408 @@
package com.patbond.patbond.pet.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.MvcResult;
import java.time.OffsetDateTime;
import java.time.YearMonth;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
/**
* T2-08 acceptance over real PostgreSQL — every aggregate's frozen semantics:
* empty-data null/zero contract, latest-weight tie-break, cancelled doses
* counting for nothing, next-vaccination priority (earliest date across
* planned/nextDue, superseded boosters excluded, overdue included), monthly
* expense month boundaries per requested timezone, multi-pet isolation and
* the T2-03 permission matrix (viewer reads, stranger gets the
* anti-enumeration 404).
*/
class PetSummaryIntegrationTest extends PetIntegrationTestSupport {
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
@Autowired
private MockMvc mockMvc;
private UUID createPet(UUID ownerId, String name) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
return UUID.fromString(
JsonPath.read(result.getResponse().getContentAsString(), "$.data.id"));
}
private void postWeight(UUID userId, UUID petId, String measuredAt, String weightKg)
throws Exception {
mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
.header("Authorization", "Bearer " + tokenFor(userId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"weightKg":%s,"measuredAt":"%s"}
""".formatted(weightKg, measuredAt)))
.andExpect(status().isCreated());
}
private String postVaccination(UUID userId, UUID petId, String body) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().isCreated())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
}
private void cancelVaccination(UUID userId, String vaccinationId) throws Exception {
mockMvc.perform(patch("/api/v1/vaccinations/{id}", vaccinationId)
.header("Authorization", "Bearer " + tokenFor(userId))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"cancelled\",\"version\":0}"))
.andExpect(status().isOk());
}
private void postEvent(UUID userId, UUID petId, OffsetDateTime occurredAt, Long amountCents)
throws Exception {
String amount = amountCents == null ? "" : ",\"amountCents\":" + amountCents;
mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(userId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"eventType":"medical","occurredAt":"%s","title":"账目"%s}
""".formatted(ISO.format(occurredAt), amount)))
.andExpect(status().isCreated());
}
private MvcResult getSummary(UUID userId, UUID petId, String tz) throws Exception {
var request = get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(userId));
if (tz != null) {
request = request.param("tz", tz);
}
return mockMvc.perform(request).andExpect(status().isOk()).andReturn();
}
// ---- 空数据语义 ----
@Test
void emptyPetHasNullAggregatesAndZeroExpense() throws Exception {
UUID owner = newUser("summary_empty_owner");
UUID petId = createPet(owner, "空档案猫");
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.petId").value(petId.toString()))
.andExpect(jsonPath("$.data.latestWeight").value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.vaccinationProgress")
.value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.nextVaccination")
.value(org.hamcrest.Matchers.nullValue()))
// monthlyExpense 恒非 null:无支出为 0,月份/时区仍可确定(缺省 UTC)
.andExpect(jsonPath("$.data.monthlyExpense.amountCents").value(0))
.andExpect(jsonPath("$.data.monthlyExpense.month")
.value(YearMonth.now(ZoneId.of("UTC")).toString()))
.andExpect(jsonPath("$.data.monthlyExpense.timezone").value("UTC"));
}
// ---- 最新体重 ----
@Test
void latestWeightIsGreatestMeasuredAtThenId() throws Exception {
UUID owner = newUser("summary_weight_owner");
UUID petId = createPet(owner, "称重猫");
// 乱序写入:最新 measured_at 胜出,与写入顺序无关
postWeight(owner, petId, "2026-09-05T08:00:00Z", "5.10");
postWeight(owner, petId, "2026-09-01T08:00:00Z", "4.80");
postWeight(owner, petId, "2026-09-03T08:00:00Z", "4.95");
getSummaryAndExpectWeight(owner, petId, 5.10, "2026-09-05T08:00:00Z");
// 同刻两条:id 更大(后写入)者胜出——与体重列表首行口径一致
postWeight(owner, petId, "2026-09-05T08:00:00Z", "5.25");
getSummaryAndExpectWeight(owner, petId, 5.25, "2026-09-05T08:00:00Z");
}
private void getSummaryAndExpectWeight(UUID userId, UUID petId, double weightKg,
String measuredAt) throws Exception {
String body = getSummary(userId, petId, null).getResponse().getContentAsString();
double actualKg = ((Number) JsonPath.read(body, "$.data.latestWeight.weightKg"))
.doubleValue();
assertThat(actualKg).isEqualTo(weightKg);
OffsetDateTime actual = OffsetDateTime.parse(
JsonPath.read(body, "$.data.latestWeight.measuredAt"));
assertThat(actual.toInstant()).isEqualTo(OffsetDateTime.parse(measuredAt).toInstant());
}
// ---- 疫苗进度 ----
@Test
void vaccinationProgressCountsCompletedOverNonCancelled() throws Exception {
UUID owner = newUser("summary_vacc_owner");
UUID petId = createPet(owner, "疫苗猫");
UUID feline3in1 = vaccineIdByCode("feline_3in1");
UUID rabies = vaccineIdByCode("rabies_cat");
// completed 第 1 针 + scheduled 第 2 针 + 另一系列 completed 1 针
postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":1,
"status":"completed","administeredOn":"2026-08-01"}
""".formatted(feline3in1));
postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":2,
"status":"scheduled","plannedOn":"2026-10-01"}
""".formatted(feline3in1));
postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"rabies-2026","doseNo":1,
"status":"completed","administeredOn":"2026-08-15"}
""".formatted(rabies));
// 建了又取消的一针:分子分母都不计入
String cancelled = postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":3,
"status":"scheduled","plannedOn":"2026-11-01"}
""".formatted(feline3in1));
cancelVaccination(owner, cancelled);
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.vaccinationProgress.completedDoses").value(2))
.andExpect(jsonPath("$.data.vaccinationProgress.totalDoses").value(3));
}
@Test
void onlyCancelledVaccinationsYieldNullProgressAndNoNextShot() throws Exception {
UUID owner = newUser("summary_cancel_owner");
UUID petId = createPet(owner, "取消猫");
String cancelled = postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":1,
"status":"scheduled","plannedOn":"2026-10-01"}
""".formatted(vaccineIdByCode("feline_3in1")));
cancelVaccination(owner, cancelled);
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.vaccinationProgress")
.value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.nextVaccination")
.value(org.hamcrest.Matchers.nullValue()));
}
// ---- 下次接种 ----
@Test
void nextVaccinationPicksEarliestDateAcrossPlannedAndNextDue() throws Exception {
UUID owner = newUser("summary_next_owner");
UUID petId = createPet(owner, "下一针猫");
UUID feline3in1 = vaccineIdByCode("feline_3in1");
UUID rabies = vaccineIdByCode("rabies_cat");
// scheduled 的 planned_on 较晚(2026-12-01
postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":1,
"status":"scheduled","plannedOn":"2026-12-01"}
""".formatted(feline3in1));
// completed 的 next_due_on 更早且已过期(2026-09-01)——逾期加强针优先
String completedId = postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"rabies-2025","doseNo":1,"doseLabel":"年度加强",
"status":"completed","administeredOn":"2025-09-01","nextDueOn":"2026-09-01"}
""".formatted(rabies));
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.nextVaccination.vaccinationId").value(completedId))
.andExpect(jsonPath("$.data.nextVaccination.vaccineName").value("狂犬疫苗(猫)"))
.andExpect(jsonPath("$.data.nextVaccination.doseNo").value(1))
.andExpect(jsonPath("$.data.nextVaccination.doseLabel").value("年度加强"))
.andExpect(jsonPath("$.data.nextVaccination.dueOn").value("2026-09-01"))
.andExpect(jsonPath("$.data.nextVaccination.source").value("nextDue"));
}
@Test
void nextVaccinationSkipsSupersededBooster() throws Exception {
UUID owner = newUser("summary_supersede_owner");
UUID petId = createPet(owner, "续针猫");
UUID feline3in1 = vaccineIdByCode("feline_3in1");
// 第 1 针已完成、next_due_on 很早;但同系列第 2 针已排期——第 1 针的到期日失效
postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":1,
"status":"completed","administeredOn":"2026-08-01","nextDueOn":"2026-08-22"}
""".formatted(feline3in1));
String dose2 = postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":2,
"status":"scheduled","plannedOn":"2026-10-15"}
""".formatted(feline3in1));
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.nextVaccination.vaccinationId").value(dose2))
.andExpect(jsonPath("$.data.nextVaccination.dueOn").value("2026-10-15"))
.andExpect(jsonPath("$.data.nextVaccination.source").value("planned"));
}
// ---- 当月花费 ----
@Test
void monthlyExpenseSumsHalfOpenUtcMonthWindow() throws Exception {
UUID owner = newUser("summary_expense_owner");
UUID petId = createPet(owner, "记账猫");
ZoneId utc = ZoneId.of("UTC");
YearMonth month = YearMonth.now(utc);
OffsetDateTime start = month.atDay(1).atStartOfDay(utc).toOffsetDateTime();
OffsetDateTime end = month.plusMonths(1).atDay(1).atStartOfDay(utc).toOffsetDateTime();
postEvent(owner, petId, start, 100L); // 月初 00:00:00 含(闭)
postEvent(owner, petId, end.minusSeconds(1), 200L); // 月末最后一秒含
postEvent(owner, petId, start.minusSeconds(1), 4000L); // 上月最后一秒不计
postEvent(owner, petId, end, 8000L); // 下月 00:00:00 不计(开)
postEvent(owner, petId, start.plusDays(1), null); // 无金额事件不计入
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.param("tz", "UTC"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.monthlyExpense.amountCents").value(300))
.andExpect(jsonPath("$.data.monthlyExpense.month").value(month.toString()))
.andExpect(jsonPath("$.data.monthlyExpense.timezone").value("UTC"));
}
@Test
void monthlyExpenseHonoursClientTimezoneBoundaries() throws Exception {
UUID owner = newUser("summary_tz_owner");
UUID petId = createPet(owner, "时区猫");
ZoneId shanghai = ZoneId.of("Asia/Shanghai");
YearMonth month = YearMonth.now(shanghai);
OffsetDateTime start = month.atDay(1).atStartOfDay(shanghai).toOffsetDateTime();
postEvent(owner, petId, start, 500L); // 上海月初含
postEvent(owner, petId, start.minusSeconds(1), 900L); // 上海上月最后一秒不计
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.param("tz", "Asia/Shanghai"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.monthlyExpense.amountCents").value(500))
.andExpect(jsonPath("$.data.monthlyExpense.month").value(month.toString()))
.andExpect(jsonPath("$.data.monthlyExpense.timezone").value("Asia/Shanghai"));
}
@Test
void invalidTimezoneIsRejected() throws Exception {
UUID owner = newUser("summary_bad_tz");
UUID petId = createPet(owner, "火星猫");
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.param("tz", "Mars/Olympus_Mons"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
// ---- 多宠隔离 ----
@Test
void aggregatesAreIsolatedPerPet() throws Exception {
UUID owner = newUser("summary_iso_owner");
UUID petA = createPet(owner, "隔离猫A");
UUID petB = createPet(owner, "隔离猫B");
postWeight(owner, petA, "2026-09-01T08:00:00Z", "6.00");
postVaccination(owner, petA, """
{"vaccineId":"%s","seriesKey":"kitten-2026","doseNo":1,
"status":"scheduled","plannedOn":"2026-10-01"}
""".formatted(vaccineIdByCode("feline_3in1")));
postEvent(owner, petA, OffsetDateTime.now(ZoneId.of("UTC")), 12345L);
postWeight(owner, petB, "2026-09-02T08:00:00Z", "3.30");
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petB)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.petId").value(petB.toString()))
.andExpect(jsonPath("$.data.latestWeight.weightKg").value(3.30))
.andExpect(jsonPath("$.data.vaccinationProgress")
.value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.nextVaccination")
.value(org.hamcrest.Matchers.nullValue()))
.andExpect(jsonPath("$.data.monthlyExpense.amountCents").value(0));
}
// ---- 权限 ----
@Test
void viewerReadsSummaryStrangerGetsAntiEnumeration404() throws Exception {
UUID owner = newUser("summary_perm_owner");
UUID viewer = newUser("summary_viewer");
UUID stranger = newUser("summary_stranger");
UUID petId = createPet(owner, "权限猫");
grantRole(petId, viewer, "viewer");
postWeight(owner, petId, "2026-09-01T08:00:00Z", "4.20");
// viewerREAD 档可读摘要
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.latestWeight.weightKg").value(4.20));
// 陌生人访问真实宠物与随机 UUID:响应逐字一致(防枚举)
String realBody = mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId)
.header("Authorization", "Bearer " + tokenFor(stranger)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401))
.andReturn().getResponse().getContentAsString();
String randomBody = mockMvc.perform(get("/api/v1/pets/{petId}/summary", UUID.randomUUID())
.header("Authorization", "Bearer " + tokenFor(stranger)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401))
.andReturn().getResponse().getContentAsString();
assertThat(randomBody).isEqualTo(realBody);
// 未带 token
mockMvc.perform(get("/api/v1/pets/{petId}/summary", petId))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value(40101));
}
@Test
void summaryEndpointNeverWritesAnything() throws Exception {
UUID owner = newUser("summary_readonly_owner");
UUID petId = createPet(owner, "只读猫");
postWeight(owner, petId, "2026-09-01T08:00:00Z", "4.00");
List<String> tables = List.of("pet_weight_records", "pet_vaccinations", "health_events");
List<Integer> before = countRows(tables, petId);
getSummary(owner, petId, "Asia/Shanghai");
assertThat(countRows(tables, petId)).isEqualTo(before);
}
private List<Integer> countRows(List<String> tables, UUID petId) {
return tables.stream()
.map(t -> jdbcClient.sql(
"SELECT count(*) FROM pet_health." + t + " WHERE pet_id = :petId")
.param("petId", petId)
.query(Integer.class)
.single())
.toList();
}
}