diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetSummaryController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetSummaryController.java new file mode 100644 index 0000000..e36ab7a --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetSummaryController.java @@ -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 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)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetSummaryResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetSummaryResponse.java new file mode 100644 index 0000000..bcf91a8 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetSummaryResponse.java @@ -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). + * + *

Null semantics (frozen for the M2 contract): + *

+ */ +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) { + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetSummaryRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetSummaryRepository.java new file mode 100644 index 0000000..177a961 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetSummaryRepository.java @@ -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(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(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 + *
    + *
  • scheduled rows' planned_on (non-null by ck_vaccination_dates), + * walking ix_vaccinations_due;
  • + *
  • 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.
  • + *
+ * 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(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(); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetSummaryService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetSummaryService.java new file mode 100644 index 0000000..08d1701 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetSummaryService.java @@ -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. + * + *

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 不是有效的时区标识"); + } + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetSummaryIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetSummaryIntegrationTest.java new file mode 100644 index 0000000..0b469e4 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetSummaryIntegrationTest.java @@ -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"); + + // viewer:READ 档可读摘要 + 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 tables = List.of("pet_weight_records", "pet_vaccinations", "health_events"); + List before = countRows(tables, petId); + getSummary(owner, petId, "Asia/Shanghai"); + assertThat(countRows(tables, petId)).isEqualTo(before); + } + + private List countRows(List 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(); + } +}