- 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:
+43
@@ -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) {
|
||||||
|
}
|
||||||
|
}
|
||||||
+145
@@ -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 不是有效的时区标识");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+408
@@ -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<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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user