feat: 体重记录接口与 cursor 分页、Idempotency-Key 幂等(T2-04)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<CursorPage<WeightResponse>> 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<WeightResponse> 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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<T>(
|
||||
List<T> items,
|
||||
String nextCursor,
|
||||
boolean hasMore) {
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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<WeightResponse> 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<WeightResponse> 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));
|
||||
}
|
||||
}
|
||||
@@ -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<WeightResponse> list(UUID userId, UUID petId, int limit, String cursor) {
|
||||
petAccessService.require(userId, petId, AccessLevel.READ);
|
||||
WeightCursor after = cursor == null ? null : WeightCursor.decode(cursor);
|
||||
List<WeightResponse> rows = weightRepository.pageByPet(petId, after, limit + 1);
|
||||
boolean hasMore = rows.size() > limit;
|
||||
List<WeightResponse> 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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+343
@@ -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<String> 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<String> 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<String> 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<String> 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<String> 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<String> ids2 = JsonPath.read(page2.getResponse().getContentAsString(),
|
||||
"$.data.items[*].id");
|
||||
|
||||
Set<String> 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<String> 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user