From 825dde3928df07f02135cb215186255bb4ce7af3 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Mon, 7 Sep 2026 17:43:59 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=93=E9=87=8D=E8=AE=B0=E5=BD=95?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=B8=8E=20cursor=20=E5=88=86=E9=A1=B5?= =?UTF-8?q?=E3=80=81Idempotency-Key=20=E5=B9=82=E7=AD=89=EF=BC=88T2-04?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET/POST /api/v1/pets/{petId}/weights,复用 PetAccessService 闸口 (READ 读 / WRITE 写,含 caregiver 写成功正向用例) - cursor 分页按 (measured_at DESC, id DESC) 与 ix_pet_weight_pet_measured 对齐,limit+1 探测 hasMore,同刻记录跨页不丢不重 - Idempotency-Key 可选头:键派生确定性主键 + ON CONFLICT (id) DO NOTHING, 重试返回原记录,零新增迁移 - weight_kg 校验镜像 ck_pet_weight(>0 且 ≤500,两位小数) Co-Authored-By: Claude Fable 5 --- .../pet/controller/WeightController.java | 67 ++++ .../patbond/pet/dto/CreateWeightRequest.java | 66 ++++ .../patbond/patbond/pet/dto/CursorPage.java | 14 + .../patbond/pet/dto/WeightResponse.java | 16 + .../pet/repository/WeightRepository.java | 99 +++++ .../patbond/pet/service/WeightService.java | 74 ++++ .../patbond/pet/support/IdempotencyKeys.java | 50 +++ .../patbond/pet/support/WeightCursor.java | 47 +++ .../pet/controller/WeightIntegrationTest.java | 343 ++++++++++++++++++ 9 files changed, 776 insertions(+) create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/controller/WeightController.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateWeightRequest.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CursorPage.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/WeightResponse.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/repository/WeightRepository.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/service/WeightService.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/support/IdempotencyKeys.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/support/WeightCursor.java create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/WeightController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/WeightController.java new file mode 100644 index 0000000..49aebec --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/WeightController.java @@ -0,0 +1,67 @@ +package com.patbond.patbond.pet.controller; + +import com.patbond.patbond.common.response.ApiResponse; +import com.patbond.patbond.pet.dto.CreateWeightRequest; +import com.patbond.patbond.pet.dto.CursorPage; +import com.patbond.patbond.pet.dto.WeightResponse; +import com.patbond.patbond.pet.security.BearerAuthFilter; +import com.patbond.patbond.pet.service.WeightService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import org.springframework.http.HttpStatus; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +/** + * Weight records (T2-04): cursor-paginated list plus create with optional + * Idempotency-Key. Authorization goes through the T2-03 gate inside + * WeightService. + */ +@RestController +@RequestMapping("/api/v1/pets/{petId}/weights") +@Validated +public class WeightController { + + private final WeightService weightService; + + public WeightController(WeightService weightService) { + this.weightService = weightService; + } + + @GetMapping + public ApiResponse> list( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID petId, + @RequestParam(defaultValue = "20") + @Min(value = 1, message = "limit 最小为 1") + @Max(value = 100, message = "limit 最大为 100") + int limit, + @RequestParam(required = false) String cursor) { + return ApiResponse.success(weightService.list(userId, petId, limit, cursor)); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public ApiResponse create( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID petId, + @RequestHeader(value = "Idempotency-Key", required = false) + @Size(max = 255, message = "Idempotency-Key 最长 255 字符") + String idempotencyKey, + @Valid @RequestBody CreateWeightRequest request) { + return ApiResponse.success(weightService.create(userId, petId, idempotencyKey, request)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateWeightRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateWeightRequest.java new file mode 100644 index 0000000..484dc03 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateWeightRequest.java @@ -0,0 +1,66 @@ +package com.patbond.patbond.pet.dto; + +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +/** + * POST /api/v1/pets/{petId}/weights. The weight bounds mirror + * ck_pet_weight (>0 and ≤500); {@code @Digits} keeps the value inside + * numeric(6,2) instead of letting PostgreSQL round silently. + */ +public class CreateWeightRequest { + + @NotNull(message = "weightKg 不能为空") + @DecimalMin(value = "0", inclusive = false, message = "weightKg 必须大于 0") + @DecimalMax(value = "500", message = "weightKg 不能超过 500") + @Digits(integer = 3, fraction = 2, message = "weightKg 最多两位小数") + private BigDecimal weightKg; + + @NotNull(message = "measuredAt 不能为空") + private OffsetDateTime measuredAt; + + @Pattern(regexp = "manual|clinic|device", message = "source 仅支持 manual/clinic/device") + private String source; + + @Size(max = 500, message = "note 最长 500 字符") + private String note; + + public BigDecimal getWeightKg() { + return weightKg; + } + + public void setWeightKg(BigDecimal weightKg) { + this.weightKg = weightKg; + } + + public OffsetDateTime getMeasuredAt() { + return measuredAt; + } + + public void setMeasuredAt(OffsetDateTime measuredAt) { + this.measuredAt = measuredAt; + } + + public String getSource() { + return source; + } + + public void setSource(String source) { + this.source = source; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CursorPage.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CursorPage.java new file mode 100644 index 0000000..d4e511b --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CursorPage.java @@ -0,0 +1,14 @@ +package com.patbond.patbond.pet.dto; + +import java.util.List; + +/** + * Cursor-pagination envelope body (contract draft §3.5: this shape is the + * pagination canon for the whole API). {@code nextCursor} is null exactly + * when {@code hasMore} is false. + */ +public record CursorPage( + List items, + String nextCursor, + boolean hasMore) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/WeightResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/WeightResponse.java new file mode 100644 index 0000000..f96d5dc --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/WeightResponse.java @@ -0,0 +1,16 @@ +package com.patbond.patbond.pet.dto; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.UUID; + +/** One weight record as the API returns it. */ +public record WeightResponse( + UUID id, + UUID petId, + BigDecimal weightKg, + OffsetDateTime measuredAt, + String source, + String note, + OffsetDateTime createdAt) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/WeightRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/WeightRepository.java new file mode 100644 index 0000000..54c8652 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/WeightRepository.java @@ -0,0 +1,99 @@ +package com.patbond.patbond.pet.repository; + +import com.patbond.patbond.pet.dto.WeightResponse; +import com.patbond.patbond.pet.support.WeightCursor; +import org.springframework.jdbc.core.simple.JdbcClient; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * pet_health.pet_weight_records access. Permission is the caller's problem + * (PetAccessService gate runs before any of these); every query is still + * scoped by pet_id so a record can never leak across pets. + */ +@Repository +public class WeightRepository { + + private static final String SELECT_WEIGHT = """ + SELECT id, pet_id, weight_kg, measured_at, source, note, created_at + FROM pet_health.pet_weight_records + WHERE pet_id = :petId + """; + + private final JdbcClient jdbcClient; + + public WeightRepository(JdbcClient jdbcClient) { + this.jdbcClient = jdbcClient; + } + + /** + * Inserts one weight row; {@code ON CONFLICT (id) DO NOTHING} makes a + * replay with an idempotency-derived id a no-op. + * + * @return rows inserted — 0 means the id already exists (keyed retry) + */ + public int insert(UUID id, UUID petId, BigDecimal weightKg, OffsetDateTime measuredAt, + String source, String note) { + return jdbcClient.sql(""" + INSERT INTO pet_health.pet_weight_records + (id, pet_id, weight_kg, measured_at, source, note) + VALUES (:id, :petId, :weightKg, :measuredAt, :source, :note) + ON CONFLICT (id) DO NOTHING + """) + .param("id", id) + .param("petId", petId) + .param("weightKg", weightKg) + .param("measuredAt", measuredAt) + .param("source", source) + .param("note", note) + .update(); + } + + public Optional findById(UUID id, UUID petId) { + return jdbcClient.sql(SELECT_WEIGHT + " AND id = :id") + .param("petId", petId) + .param("id", id) + .query(WeightRepository::mapWeight) + .optional(); + } + + /** + * One page in (measured_at DESC, id DESC) order — the exact key of + * ix_pet_weight_pet_measured, so this is a plain index range scan. The + * caller asks for limit+1 rows to learn whether more pages exist; the + * tuple comparison against the cursor keeps ties on measured_at exact. + */ + public List pageByPet(UUID petId, WeightCursor after, int limitPlusOne) { + String sql = SELECT_WEIGHT; + if (after != null) { + sql += " AND (measured_at, id) < (:cursorMeasuredAt, :cursorId)"; + } + sql += " ORDER BY measured_at DESC, id DESC LIMIT :limit"; + var spec = jdbcClient.sql(sql) + .param("petId", petId) + .param("limit", limitPlusOne); + if (after != null) { + spec = spec.param("cursorMeasuredAt", after.measuredAt()) + .param("cursorId", after.id()); + } + return spec.query(WeightRepository::mapWeight).list(); + } + + private static WeightResponse mapWeight(ResultSet rs, int rowNum) throws SQLException { + return new WeightResponse( + rs.getObject("id", UUID.class), + rs.getObject("pet_id", UUID.class), + rs.getBigDecimal("weight_kg"), + rs.getObject("measured_at", OffsetDateTime.class), + rs.getString("source"), + rs.getString("note"), + rs.getObject("created_at", OffsetDateTime.class)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/WeightService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/WeightService.java new file mode 100644 index 0000000..de51034 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/WeightService.java @@ -0,0 +1,74 @@ +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.CreateWeightRequest; +import com.patbond.patbond.pet.dto.CursorPage; +import com.patbond.patbond.pet.dto.WeightResponse; +import com.patbond.patbond.pet.repository.WeightRepository; +import com.patbond.patbond.pet.support.IdempotencyKeys; +import com.patbond.patbond.pet.support.UuidV7; +import com.patbond.patbond.pet.support.WeightCursor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +/** + * Weight-record use-cases (T2-04). Every entry point opens with the + * PetAccessService gate (READ to list, WRITE to record — owner and + * caregiver both record weights, T2-03 §4 matrix). + */ +@Service +public class WeightService { + + private final WeightRepository weightRepository; + private final PetAccessService petAccessService; + + public WeightService(WeightRepository weightRepository, PetAccessService petAccessService) { + this.weightRepository = weightRepository; + this.petAccessService = petAccessService; + } + + /** + * With an Idempotency-Key the record id is derived from the key, so a + * retried request re-inserts the same primary key, the ON CONFLICT + * insert is a no-op and the original record is returned — no duplicate + * row, same 201 body both times. + */ + public WeightResponse create(UUID userId, UUID petId, String idempotencyKey, + CreateWeightRequest request) { + petAccessService.require(userId, petId, AccessLevel.WRITE); + boolean keyed = idempotencyKey != null && !idempotencyKey.isBlank(); + UUID id = keyed + ? IdempotencyKeys.deriveId("weight", userId, petId, idempotencyKey) + : UuidV7.generate(); + weightRepository.insert(id, petId, request.getWeightKg(), request.getMeasuredAt(), + request.getSource() == null ? "manual" : request.getSource(), + trimOrNull(request.getNote())); + return weightRepository.findById(id, petId) + .orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)); + } + + public CursorPage list(UUID userId, UUID petId, int limit, String cursor) { + petAccessService.require(userId, petId, AccessLevel.READ); + WeightCursor after = cursor == null ? null : WeightCursor.decode(cursor); + List rows = weightRepository.pageByPet(petId, after, limit + 1); + boolean hasMore = rows.size() > limit; + List items = hasMore ? rows.subList(0, limit) : rows; + String nextCursor = hasMore + ? new WeightCursor(items.get(limit - 1).measuredAt(), items.get(limit - 1).id()).encode() + : null; + return new CursorPage<>(items, nextCursor, hasMore); + } + + private static String trimOrNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/support/IdempotencyKeys.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/IdempotencyKeys.java new file mode 100644 index 0000000..a3ebad7 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/IdempotencyKeys.java @@ -0,0 +1,50 @@ +package com.patbond.patbond.pet.support; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.UUID; + +/** + * Idempotency without a key store: the record id is derived + * deterministically from (resource, caller, pet, Idempotency-Key), so a + * retried POST computes the same primary key and + * {@code INSERT ... ON CONFLICT (id) DO NOTHING} makes the second attempt a + * no-op — the handler then returns the already-created record. Scoping to + * the caller keeps two users' identical keys from colliding on the same + * pet; scoping to the pet keeps one client key from spanning pets. No table + * and no TTL needed (V3 stays untouched), at the cost of the key being + * idempotent forever rather than for a retry window — acceptable for M2: + * clients mint a fresh UUID key per logical submission. + * + *

Derived ids carry version bits 8 (custom) — they are deliberately not + * time-ordered UUIDv7, which only costs B-tree locality on the rare keyed + * insert. + */ +public final class IdempotencyKeys { + + private IdempotencyKeys() { + } + + public static UUID deriveId(String resource, UUID userId, UUID petId, String key) { + byte[] hash = sha256(resource + "|" + userId + "|" + petId + "|" + key); + long msb = 0; + long lsb = 0; + for (int i = 0; i < 8; i++) { + msb = (msb << 8) | (hash[i] & 0xFF); + lsb = (lsb << 8) | (hash[i + 8] & 0xFF); + } + msb = (msb & ~0xF000L) | 0x8000L; // version 8 + lsb = (lsb & 0x3FFFFFFFFFFFFFFFL) | 0x8000000000000000L; // IETF variant + return new UUID(msb, lsb); + } + + private static byte[] sha256(String input) { + try { + return MessageDigest.getInstance("SHA-256") + .digest(input.getBytes(StandardCharsets.UTF_8)); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/support/WeightCursor.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/WeightCursor.java new file mode 100644 index 0000000..44c6d2d --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/WeightCursor.java @@ -0,0 +1,47 @@ +package com.patbond.patbond.pet.support; + +import com.patbond.patbond.common.error.BusinessException; +import com.patbond.patbond.common.error.ErrorCode; + +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.UUID; + +/** + * Opaque cursor for the weight list (measured_at DESC, id DESC — the exact + * key of ix_pet_weight_pet_measured). Encodes the last row of a page as + * base64url("epochMicros:id"); the next page selects + * {@code (measured_at, id) < (cursor)} so ties on measured_at are broken by + * id and rows are neither lost nor repeated across page boundaries. + * Microsecond precision matches timestamptz exactly — no truncation drift + * between what the row holds and what the cursor replays. + */ +public record WeightCursor(OffsetDateTime measuredAt, UUID id) { + + public String encode() { + long micros = Math.multiplyExact(measuredAt.toInstant().getEpochSecond(), 1_000_000L) + + measuredAt.getNano() / 1_000L; + return Base64.getUrlEncoder().withoutPadding() + .encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8)); + } + + /** @throws BusinessException 40000 when the cursor is not one we issued */ + public static WeightCursor decode(String cursor) { + try { + String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + int sep = raw.indexOf(':'); + long micros = Long.parseLong(raw.substring(0, sep)); + UUID id = UUID.fromString(raw.substring(sep + 1)); + OffsetDateTime measuredAt = Instant.ofEpochSecond( + Math.floorDiv(micros, 1_000_000L), + Math.floorMod(micros, 1_000_000L) * 1_000L) + .atOffset(ZoneOffset.UTC); + return new WeightCursor(measuredAt, id); + } catch (RuntimeException e) { + throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效"); + } + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java new file mode 100644 index 0000000..e601928 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java @@ -0,0 +1,343 @@ +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.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +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-04 acceptance over real PostgreSQL: cursor pagination (no loss, no + * duplicate, tie-break across page boundaries), weight bounds, the + * Idempotency-Key retry contract, and the T2-03 permission matrix — + * including the caregiver write-success case the T2-03 report handed over + * as mandatory. + */ +class WeightIntegrationTest extends PetIntegrationTestSupport { + + @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 MvcResult postWeight(UUID userId, UUID petId, String measuredAt, String weightKg) + throws Exception { + return 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()) + .andReturn(); + } + + // ---- 成功路径 ---- + + @Test + void ownerCreatesAndListsWeights() throws Exception { + UUID owner = newUser("weight_owner"); + UUID petId = createPet(owner, "阿福"); + + mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner)) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"weightKg":5.25,"measuredAt":"2026-09-01T08:00:00Z", + "source":"clinic","note":"年检称重"} + """)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.code").value(0)) + .andExpect(jsonPath("$.data.petId").value(petId.toString())) + .andExpect(jsonPath("$.data.weightKg").value(5.25)) + .andExpect(jsonPath("$.data.source").value("clinic")) + .andExpect(jsonPath("$.data.note").value("年检称重")); + + mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items", hasSize(1))) + .andExpect(jsonPath("$.data.items[0].weightKg").value(5.25)) + // source 缺省为 manual 的对照走 caregiver 用例;这里校验分页收尾 + .andExpect(jsonPath("$.data.hasMore").value(false)) + .andExpect(jsonPath("$.data.nextCursor").isEmpty()); + } + + /** T2-03 移交要求:WRITE 档 caregiver 的正向写用例(T2-03 只有 403 反证)。 */ + @Test + void caregiverCanRecordWeights() throws Exception { + UUID owner = newUser("weight_cg_owner"); + UUID caregiver = newUser("weight_caregiver"); + UUID petId = createPet(owner, "阿旺"); + grantRole(petId, caregiver, "caregiver"); + + mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(caregiver)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"weightKg\":4.80,\"measuredAt\":\"2026-09-02T09:00:00Z\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.source").value("manual")); + + // 双方都能读到 caregiver 写入的记录 + mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items", hasSize(1))) + .andExpect(jsonPath("$.data.items[0].weightKg").value(4.80)); + mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(caregiver))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items", hasSize(1))); + } + + // ---- 分页正确性 ---- + + @Test + void paginationWalksAllPagesWithoutLossOrDuplicate() throws Exception { + UUID owner = newUser("weight_pager"); + UUID petId = createPet(owner, "分页猫"); + List createdIds = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + MvcResult r = postWeight(owner, petId, "2026-09-0%dT08:00:00Z".formatted(i), "4.1" + i); + createdIds.add(JsonPath.read(r.getResponse().getContentAsString(), "$.data.id")); + } + + Set seen = new LinkedHashSet<>(); + String cursor = null; + int pages = 0; + boolean hasMore = true; + while (hasMore) { + var request = get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner)) + .param("limit", "2"); + if (cursor != null) { + request = request.param("cursor", cursor); + } + MvcResult page = mockMvc.perform(request) + .andExpect(status().isOk()) + .andReturn(); + String body = page.getResponse().getContentAsString(); + List ids = JsonPath.read(body, "$.data.items[*].id"); + for (String id : ids) { + assertThat(seen.add(id)).as("跨页不得重复出现 %s", id).isTrue(); + } + hasMore = JsonPath.read(body, "$.data.hasMore"); + cursor = JsonPath.read(body, "$.data.nextCursor"); + pages++; + assertThat(hasMore ? ids.size() == 2 : ids.size() <= 2).isTrue(); + assertThat(pages).isLessThanOrEqualTo(5); + } + assertThat(cursor).isNull(); + // 不丢:全部 5 条都被翻到;顺序为 measured_at DESC(创建序的倒序) + List expected = new ArrayList<>(createdIds); + java.util.Collections.reverse(expected); + assertThat(seen).containsExactlyElementsOf(expected); + assertThat(pages).isEqualTo(3); + } + + @Test + void paginationBreaksMeasuredAtTiesAcrossPageBoundary() throws Exception { + UUID owner = newUser("weight_ties"); + UUID petId = createPet(owner, "同刻猫"); + for (int i = 0; i < 3; i++) { + postWeight(owner, petId, "2026-09-05T10:00:00Z", "5.0" + i); + } + + MvcResult page1 = mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner)) + .param("limit", "2")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items", hasSize(2))) + .andExpect(jsonPath("$.data.hasMore").value(true)) + .andReturn(); + String body1 = page1.getResponse().getContentAsString(); + List ids1 = JsonPath.read(body1, "$.data.items[*].id"); + String cursor = JsonPath.read(body1, "$.data.nextCursor"); + + MvcResult page2 = mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner)) + .param("limit", "2") + .param("cursor", cursor)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items", hasSize(1))) + .andExpect(jsonPath("$.data.hasMore").value(false)) + .andReturn(); + List ids2 = JsonPath.read(page2.getResponse().getContentAsString(), + "$.data.items[*].id"); + + Set all = new LinkedHashSet<>(ids1); + all.addAll(ids2); + assertThat(all).as("measured_at 相同的记录按 id 断续,不丢不重").hasSize(3); + } + + // ---- 参数错误 ---- + + @Test + void validationErrorsAreRejected() throws Exception { + UUID owner = newUser("weight_bad_input"); + UUID petId = createPet(owner, "验证猫"); + String token = tokenFor(owner); + + List badBodies = List.of( + "{\"measuredAt\":\"2026-09-01T08:00:00Z\"}", // 缺 weightKg + "{\"weightKg\":0,\"measuredAt\":\"2026-09-01T08:00:00Z\"}", + "{\"weightKg\":500.01,\"measuredAt\":\"2026-09-01T08:00:00Z\"}", + "{\"weightKg\":5.123,\"measuredAt\":\"2026-09-01T08:00:00Z\"}", // 3 位小数 + "{\"weightKg\":5.2}", // 缺 measuredAt + "{\"weightKg\":5.2,\"measuredAt\":\"2026-09-01T08:00:00Z\",\"source\":\"vet\"}"); + for (String bad : badBodies) { + mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(bad)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + // 上限恰好 500.00 合法(边界值) + mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"weightKg\":500.00,\"measuredAt\":\"2026-09-01T08:00:00Z\"}")) + .andExpect(status().isCreated()); + + for (String limit : List.of("0", "101")) { + mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + token) + .param("limit", limit)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + token) + .param("cursor", "not-a-cursor")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(40000)); + } + + // ---- 不存在 / 无权限(防枚举:两者响应一致) ---- + + @Test + void unknownOrInvisiblePetAnswersIdentical404() throws Exception { + UUID owner = newUser("weight_secret_owner"); + UUID stranger = newUser("weight_stranger"); + UUID petId = createPet(owner, "隐私猫"); + String weightJson = "{\"weightKg\":4.2,\"measuredAt\":\"2026-09-01T08:00:00Z\"}"; + + for (Object target : List.of(UUID.randomUUID(), petId)) { + mockMvc.perform(get("/api/v1/pets/{petId}/weights", target) + .header("Authorization", "Bearer " + tokenFor(stranger))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40401)) + .andExpect(jsonPath("$.message").value("宠物不存在")); + mockMvc.perform(post("/api/v1/pets/{petId}/weights", target) + .header("Authorization", "Bearer " + tokenFor(stranger)) + .contentType(MediaType.APPLICATION_JSON) + .content(weightJson)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value(40401)) + .andExpect(jsonPath("$.message").value("宠物不存在")); + } + } + + @Test + void viewerReadsButCannotWrite() throws Exception { + UUID owner = newUser("weight_view_owner"); + UUID viewer = newUser("weight_viewer"); + UUID petId = createPet(owner, "围观猫"); + grantRole(petId, viewer, "viewer"); + postWeight(owner, petId, "2026-09-01T08:00:00Z", "4.5"); + + mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(viewer))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items", hasSize(1))); + + mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(viewer)) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"weightKg\":4.6,\"measuredAt\":\"2026-09-02T08:00:00Z\"}")) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.code").value(40300)); + } + + // ---- 幂等重试 ---- + + @Test + void idempotencyKeyRetryDoesNotDuplicate() throws Exception { + UUID owner = newUser("weight_idem"); + UUID petId = createPet(owner, "重试猫"); + String key = "idem-" + UUID.randomUUID(); + String json = "{\"weightKg\":6.10,\"measuredAt\":\"2026-09-03T08:00:00Z\"}"; + + MvcResult first = mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner)) + .header("Idempotency-Key", key) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andReturn(); + String firstId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.id"); + + // 同键重试:同一条记录、同样 201,不产生新行 + MvcResult retry = mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner)) + .header("Idempotency-Key", key) + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andReturn(); + String retryId = JsonPath.read(retry.getResponse().getContentAsString(), "$.data.id"); + assertThat(retryId).isEqualTo(firstId); + + Integer count = jdbcClient.sql( + "SELECT count(*) FROM pet_health.pet_weight_records WHERE pet_id = :petId") + .param("petId", petId) + .query(Integer.class) + .single(); + assertThat(count).isEqualTo(1); + + // 换键则是新记录;不带键的两次提交各自成行(无幂等语义) + mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId) + .header("Authorization", "Bearer " + tokenFor(owner)) + .header("Idempotency-Key", key + "-2") + .contentType(MediaType.APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.id").value(org.hamcrest.Matchers.not(firstId))); + postWeight(owner, petId, "2026-09-03T08:00:00Z", "6.10"); + Integer total = jdbcClient.sql( + "SELECT count(*) FROM pet_health.pet_weight_records WHERE pet_id = :petId") + .param("petId", petId) + .query(Integer.class) + .single(); + assertThat(total).isEqualTo(3); + } +}