feat: 健康事件时间线接口与 occurred_at 游标分页(T2-06)

- GET/POST /api/v1/pets/{petId}/health-events + PATCH /api/v1/health-events/{eventId}
- 六类事件白名单、amount_cents 整数分非负(禁用 Jackson float→int 截断)、
  created_by_user_id 记操作者
- occurred_at DESC, id DESC 游标分页对齐 ix_health_events_pet_time;
  Idempotency-Key 派生主键幂等;version 乐观锁 40902;
  顶层记录路径 40402 记录级防枚举
- health_event_media / provider / booking 不实现不外露(ADR-010)
- Testcontainers 集成测试 11 例(六类路径 + 分页专项)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:59:17 +08:00
parent 4c2653c643
commit d8303bf446
9 changed files with 1018 additions and 0 deletions
@@ -0,0 +1,21 @@
package com.patbond.patbond.pet.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Money fields travel as integer cents (development-plan 4.3), so a decimal
* like 45.5 in an integer field must be a 40000 — Jackson's default is to
* silently truncate it to 45, which would corrupt amounts instead of
* rejecting them.
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}
@@ -0,0 +1,77 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.CreateHealthEventRequest;
import com.patbond.patbond.pet.dto.CursorPage;
import com.patbond.patbond.pet.dto.HealthEventResponse;
import com.patbond.patbond.pet.dto.UpdateHealthEventRequest;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.service.HealthEventService;
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.PatchMapping;
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.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.UUID;
/**
* Health-event timeline (T2-06): cursor-paginated list and create under the
* pet, plus a top-level PATCH addressing the record by its globally-unique
* id (T2-05 precedent). Authorization goes through the T2-03 gate inside
* HealthEventService.
*/
@RestController
@Validated
public class HealthEventController {
private final HealthEventService healthEventService;
public HealthEventController(HealthEventService healthEventService) {
this.healthEventService = healthEventService;
}
@GetMapping("/api/v1/pets/{petId}/health-events")
public ApiResponse<CursorPage<HealthEventResponse>> 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(healthEventService.list(userId, petId, limit, cursor));
}
@PostMapping("/api/v1/pets/{petId}/health-events")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<HealthEventResponse> 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 CreateHealthEventRequest request) {
return ApiResponse.success(
healthEventService.create(userId, petId, idempotencyKey, request));
}
@PatchMapping("/api/v1/health-events/{eventId}")
public ApiResponse<HealthEventResponse> update(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID eventId,
@Valid @RequestBody UpdateHealthEventRequest request) {
return ApiResponse.success(healthEventService.update(userId, eventId, request));
}
}
@@ -0,0 +1,76 @@
package com.patbond.patbond.pet.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.OffsetDateTime;
/**
* POST /api/v1/pets/{petId}/health-events. The six event types mirror
* ck_health_event_type; {@code amountCents} is integer cents and
* non-negative (ck_health_event_amount). provider/booking fields are absent
* by design (ADR-010: no write path in M2).
*/
public class CreateHealthEventRequest {
@NotBlank(message = "eventType 不能为空")
@Pattern(regexp = "medical|feeding|deworming|grooming|measurement|note",
message = "eventType 仅支持 medical/feeding/deworming/grooming/measurement/note")
private String eventType;
@NotNull(message = "occurredAt 不能为空")
private OffsetDateTime occurredAt;
@NotBlank(message = "title 不能为空")
@Size(max = 160, message = "title 最长 160 字符")
private String title;
@Size(max = 2000, message = "notes 最长 2000 字符")
private String notes;
@Min(value = 0, message = "amountCents 不能为负数")
private Long amountCents;
public String getEventType() {
return eventType;
}
public void setEventType(String eventType) {
this.eventType = eventType;
}
public OffsetDateTime getOccurredAt() {
return occurredAt;
}
public void setOccurredAt(OffsetDateTime occurredAt) {
this.occurredAt = occurredAt;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public Long getAmountCents() {
return amountCents;
}
public void setAmountCents(Long amountCents) {
this.amountCents = amountCents;
}
}
@@ -0,0 +1,24 @@
package com.patbond.patbond.pet.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* One health event as the API returns it. {@code createdByUserId} records
* the operator (owner or caregiver). provider/booking columns are
* intentionally not exposed and health_event_media does not exist in M2
* (ADR-010); both arrive later as purely additive changes.
*/
public record HealthEventResponse(
UUID id,
UUID petId,
String eventType,
OffsetDateTime occurredAt,
String title,
String notes,
Long amountCents,
UUID createdByUserId,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
Integer version) {
}
@@ -0,0 +1,61 @@
package com.patbond.patbond.pet.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.OffsetDateTime;
/**
* PATCH /api/v1/health-events/{eventId}. Partial update under the optimistic
* lock ({@code version} mandatory): absent fields keep their value; M2 does
* not support clearing a field back to null (rule frozen for pets in T2-03).
* Only title/notes/amountCents are editable — eventType and occurredAt are
* the identity of a timeline entry and stay immutable.
*/
public class UpdateHealthEventRequest {
@NotNull(message = "version 不能为空")
private Integer version;
@Size(max = 160, message = "title 最长 160 字符")
private String title;
@Size(max = 2000, message = "notes 最长 2000 字符")
private String notes;
@Min(value = 0, message = "amountCents 不能为负数")
private Long amountCents;
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
this.version = version;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
public Long getAmountCents() {
return amountCents;
}
public void setAmountCents(Long amountCents) {
this.amountCents = amountCents;
}
}
@@ -0,0 +1,127 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.HealthEventResponse;
import com.patbond.patbond.pet.support.EventCursor;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
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.health_events access. Permission gating happens in the service
* before any call lands here; provider/booking columns are never selected
* (ADR-010).
*/
@Repository
public class HealthEventRepository {
private static final String SELECT_EVENT = """
SELECT id, pet_id, event_type, occurred_at, title, notes, amount_cents,
created_by_user_id, created_at, updated_at, version
FROM pet_health.health_events
""";
private final JdbcClient jdbcClient;
public HealthEventRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one event; {@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, String eventType, OffsetDateTime occurredAt,
String title, String notes, Long amountCents, UUID createdByUserId) {
return jdbcClient.sql("""
INSERT INTO pet_health.health_events
(id, pet_id, event_type, occurred_at, title, notes, amount_cents,
created_by_user_id)
VALUES (:id, :petId, :eventType, :occurredAt, :title, :notes, :amountCents,
:createdByUserId)
ON CONFLICT (id) DO NOTHING
""")
.param("id", id)
.param("petId", petId)
.param("eventType", eventType)
.param("occurredAt", occurredAt)
.param("title", title)
.param("notes", notes)
.param("amountCents", amountCents)
.param("createdByUserId", createdByUserId)
.update();
}
public Optional<HealthEventResponse> findById(UUID id) {
return jdbcClient.sql(SELECT_EVENT + " WHERE id = :id")
.param("id", id)
.query(HealthEventRepository::mapEvent)
.optional();
}
/**
* One timeline page in (occurred_at DESC, id DESC) order — the exact key
* of ix_health_events_pet_time, 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 occurred_at exact.
*/
public List<HealthEventResponse> pageByPet(UUID petId, EventCursor after, int limitPlusOne) {
String sql = SELECT_EVENT + " WHERE pet_id = :petId";
if (after != null) {
sql += " AND (occurred_at, id) < (:cursorOccurredAt, :cursorId)";
}
sql += " ORDER BY occurred_at DESC, id DESC LIMIT :limit";
var spec = jdbcClient.sql(sql)
.param("petId", petId)
.param("limit", limitPlusOne);
if (after != null) {
spec = spec.param("cursorOccurredAt", after.occurredAt())
.param("cursorId", after.id());
}
return spec.query(HealthEventRepository::mapEvent).list();
}
/**
* Optimistic-lock update of the editable fields; updated_at is bumped by
* the trg_health_events_updated_at trigger.
*
* @return rows updated — 0 means the version is stale
*/
public int updateWithVersion(UUID id, int expectedVersion, String title, String notes,
Long amountCents) {
return jdbcClient.sql("""
UPDATE pet_health.health_events
SET title = :title, notes = :notes, amount_cents = :amountCents,
version = version + 1
WHERE id = :id AND version = :expectedVersion
""")
.param("id", id)
.param("expectedVersion", expectedVersion)
.param("title", title)
.param("notes", notes)
.param("amountCents", amountCents)
.update();
}
private static HealthEventResponse mapEvent(ResultSet rs, int rowNum) throws SQLException {
return new HealthEventResponse(
rs.getObject("id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getString("event_type"),
rs.getObject("occurred_at", OffsetDateTime.class),
rs.getString("title"),
rs.getString("notes"),
rs.getObject("amount_cents", Long.class),
rs.getObject("created_by_user_id", UUID.class),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
rs.getInt("version"));
}
}
@@ -0,0 +1,112 @@
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.CreateHealthEventRequest;
import com.patbond.patbond.pet.dto.CursorPage;
import com.patbond.patbond.pet.dto.HealthEventResponse;
import com.patbond.patbond.pet.dto.UpdateHealthEventRequest;
import com.patbond.patbond.pet.repository.HealthEventRepository;
import com.patbond.patbond.pet.support.EventCursor;
import com.patbond.patbond.pet.support.IdempotencyKeys;
import com.patbond.patbond.pet.support.UuidV7;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.UUID;
/**
* Health-event timeline use-cases (T2-06). Every entry point opens with the
* PetAccessService gate (READ to list, WRITE to record/edit — T2-03 §4
* matrix); the creator is stamped into created_by_user_id from the verified
* token, never from the request body.
*/
@Service
public class HealthEventService {
private final HealthEventRepository healthEventRepository;
private final PetAccessService petAccessService;
public HealthEventService(HealthEventRepository healthEventRepository,
PetAccessService petAccessService) {
this.healthEventRepository = healthEventRepository;
this.petAccessService = petAccessService;
}
public CursorPage<HealthEventResponse> list(UUID userId, UUID petId, int limit, String cursor) {
petAccessService.require(userId, petId, AccessLevel.READ);
EventCursor after = cursor == null ? null : EventCursor.decode(cursor);
List<HealthEventResponse> rows = healthEventRepository.pageByPet(petId, after, limit + 1);
boolean hasMore = rows.size() > limit;
List<HealthEventResponse> items = hasMore ? rows.subList(0, limit) : rows;
String nextCursor = hasMore
? new EventCursor(items.get(limit - 1).occurredAt(), items.get(limit - 1).id()).encode()
: null;
return new CursorPage<>(items, nextCursor, hasMore);
}
public HealthEventResponse create(UUID userId, UUID petId, String idempotencyKey,
CreateHealthEventRequest request) {
petAccessService.require(userId, petId, AccessLevel.WRITE);
boolean keyed = idempotencyKey != null && !idempotencyKey.isBlank();
UUID id = keyed
? IdempotencyKeys.deriveId("health-event", userId, petId, idempotencyKey)
: UuidV7.generate();
healthEventRepository.insert(id, petId, request.getEventType(), request.getOccurredAt(),
request.getTitle().trim(), trimOrNull(request.getNotes()),
request.getAmountCents(), userId);
return healthEventRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
/**
* Top-level record path (/api/v1/health-events/{id}), so anti-enumeration
* moves down one level (T2-05 precedent): a record that does not exist
* and a record on a pet the caller cannot see answer identically with
* 40402. Only a caller who can already read the pet ever gets a 40300.
*/
public HealthEventResponse update(UUID userId, UUID eventId,
UpdateHealthEventRequest request) {
HealthEventResponse current = healthEventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.RECORD_NOT_FOUND));
try {
petAccessService.require(userId, current.petId(), AccessLevel.WRITE);
} catch (BusinessException e) {
if (e.getCode() == ErrorCode.PET_NOT_FOUND.getCode()) {
throw new BusinessException(ErrorCode.RECORD_NOT_FOUND);
}
throw e;
}
String title = current.title();
if (request.getTitle() != null) {
title = request.getTitle().trim();
if (title.isEmpty()) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "title 不能为空白");
}
}
int updated = healthEventRepository.updateWithVersion(
eventId, request.getVersion(), title,
request.getNotes() != null
? trimOrNull(request.getNotes()) : current.notes(),
request.getAmountCents() != null
? request.getAmountCents() : current.amountCents());
if (updated == 0) {
// The record existed a moment ago, so a missed conditional
// update means the version is stale.
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
return healthEventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
private static String trimOrNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,45 @@
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 health-event timeline (occurred_at DESC, id DESC —
* the exact key of ix_health_events_pet_time), isomorphic to
* {@link WeightCursor}: base64url("epochMicros:id"), next page selects
* {@code (occurred_at, id) < (cursor)} so ties on occurred_at are broken by
* id and rows are neither lost nor repeated across page boundaries.
*/
public record EventCursor(OffsetDateTime occurredAt, UUID id) {
public String encode() {
long micros = Math.multiplyExact(occurredAt.toInstant().getEpochSecond(), 1_000_000L)
+ occurredAt.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 EventCursor 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 occurredAt = Instant.ofEpochSecond(
Math.floorDiv(micros, 1_000_000L),
Math.floorMod(micros, 1_000_000L) * 1_000L)
.atOffset(ZoneOffset.UTC);
return new EventCursor(occurredAt, id);
} catch (RuntimeException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
}
}
}
@@ -0,0 +1,475 @@
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.Collections;
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.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-06 acceptance over real PostgreSQL: the six-type event whitelist,
* integer non-negative amount_cents, occurred_at DESC cursor pagination
* (loss/duplicate/tie-break), created_by_user_id stamping, the optimistic
* lock on the top-level PATCH (40902) with record-level anti-enumeration
* (40402), the keyed idempotent create, and the T2-03 role matrix including
* caregiver write success.
*/
class HealthEventIntegrationTest 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 String postEvent(UUID userId, UUID petId, String occurredAt, String title)
throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(userId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"eventType":"note","occurredAt":"%s","title":"%s"}
""".formatted(occurredAt, title)))
.andExpect(status().isCreated())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
}
// ---- 成功路径 ----
@Test
void ownerCreatesAndListsEvents() throws Exception {
UUID owner = newUser("event_owner");
UUID petId = createPet(owner, "阿吉");
mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"eventType":"medical","occurredAt":"2026-09-01T10:00:00Z",
"title":"年度体检","notes":"一切正常","amountCents":45000}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.petId").value(petId.toString()))
.andExpect(jsonPath("$.data.eventType").value("medical"))
.andExpect(jsonPath("$.data.title").value("年度体检"))
.andExpect(jsonPath("$.data.amountCents").value(45000))
.andExpect(jsonPath("$.data.createdByUserId").value(owner.toString()))
.andExpect(jsonPath("$.data.version").value(0));
// 金额可缺省(null),列表按 occurred_at DESC
mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"eventType":"feeding","occurredAt":"2026-09-02T08:00:00Z",
"title":"换新粮"}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.amountCents").isEmpty());
mockMvc.perform(get("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items", hasSize(2)))
.andExpect(jsonPath("$.data.items[0].eventType").value("feeding"))
.andExpect(jsonPath("$.data.items[1].eventType").value("medical"))
.andExpect(jsonPath("$.data.hasMore").value(false))
.andExpect(jsonPath("$.data.nextCursor").isEmpty());
}
/** WRITE 档 caregiver 正向用例(T2-03 移交要求延续到每个子资源单)。 */
@Test
void caregiverCanCreateAndEditEvents() throws Exception {
UUID owner = newUser("event_cg_owner");
UUID caregiver = newUser("event_caregiver");
UUID petId = createPet(owner, "阿贵");
grantRole(petId, caregiver, "caregiver");
String eventId = postEvent(caregiver, petId, "2026-09-03T09:00:00Z", "驱虫");
// createdByUserId 记录的是操作者 caregiver 而非 owner
mockMvc.perform(get("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items", hasSize(1)))
.andExpect(jsonPath("$.data.items[0].createdByUserId")
.value(caregiver.toString()));
mockMvc.perform(patch("/api/v1/health-events/{id}", eventId)
.header("Authorization", "Bearer " + tokenFor(caregiver))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"体内驱虫已完成\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.notes").value("体内驱虫已完成"))
.andExpect(jsonPath("$.data.version").value(1));
}
@Test
void patchEditsFieldsPartiallyAndKeepsTheRest() throws Exception {
UUID owner = newUser("event_editor");
UUID petId = createPet(owner, "阿修");
String token = tokenFor(owner);
MvcResult created = mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"eventType":"grooming","occurredAt":"2026-09-04T15:00:00Z",
"title":"洗澡","notes":"顺毛","amountCents":8000}
"""))
.andExpect(status().isCreated())
.andReturn();
String eventId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.id");
// 只改金额:标题/备注/事件类型/发生时间保持
mockMvc.perform(patch("/api/v1/health-events/{id}", eventId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"amountCents\":8800}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.amountCents").value(8800))
.andExpect(jsonPath("$.data.title").value("洗澡"))
.andExpect(jsonPath("$.data.notes").value("顺毛"))
.andExpect(jsonPath("$.data.eventType").value("grooming"))
.andExpect(jsonPath("$.data.occurredAt").isNotEmpty())
.andExpect(jsonPath("$.data.version").value(1));
// 再改标题(带首尾空白会被 trim,镜像 ck_health_event_title 的 btrim 约束)
mockMvc.perform(patch("/api/v1/health-events/{id}", eventId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"title\":\" 洗澡加剪指甲 \"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.title").value("洗澡加剪指甲"))
.andExpect(jsonPath("$.data.amountCents").value(8800))
.andExpect(jsonPath("$.data.version").value(2));
}
// ---- 分页正确性 ----
@Test
void paginationWalksTimelineWithoutLossOrDuplicate() throws Exception {
UUID owner = newUser("event_pager");
UUID petId = createPet(owner, "刷屏猫");
List<String> createdIds = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
createdIds.add(postEvent(owner, petId, "2026-09-0%dT08:00:00Z".formatted(i), "事件" + i));
}
Set<String> seen = new LinkedHashSet<>();
String cursor = null;
int pages = 0;
boolean hasMore = true;
while (hasMore) {
var request = get("/api/v1/pets/{petId}/health-events", 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(pages).isLessThanOrEqualTo(5);
}
assertThat(cursor).isNull();
// 不丢:全部 5 条都被翻到;顺序为 occurred_at DESC(创建序的倒序)
List<String> expected = new ArrayList<>(createdIds);
Collections.reverse(expected);
assertThat(seen).containsExactlyElementsOf(expected);
assertThat(pages).isEqualTo(3);
}
@Test
void paginationBreaksOccurredAtTiesAcrossPageBoundary() throws Exception {
UUID owner = newUser("event_ties");
UUID petId = createPet(owner, "同刻犬");
for (int i = 0; i < 3; i++) {
postEvent(owner, petId, "2026-09-05T10:00:00Z", "同刻事件" + i);
}
MvcResult page1 = mockMvc.perform(get("/api/v1/pets/{petId}/health-events", 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}/health-events", 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("occurred_at 相同的记录按 id 断续,不丢不重").hasSize(3);
}
// ---- 参数错误 ----
@Test
void validationErrorsAreRejected() throws Exception {
UUID owner = newUser("event_bad_input");
UUID petId = createPet(owner, "验证犬");
String token = tokenFor(owner);
List<String> badBodies = List.of(
// 缺 eventType / 类型不在六类白名单
"{\"occurredAt\":\"2026-09-01T08:00:00Z\",\"title\":\"缺类型\"}",
"{\"eventType\":\"surgery\",\"occurredAt\":\"2026-09-01T08:00:00Z\",\"title\":\"非法类型\"}",
// 缺 occurredAt / 缺 title / title 空白 / title 超 160
"{\"eventType\":\"note\",\"title\":\"缺时间\"}",
"{\"eventType\":\"note\",\"occurredAt\":\"2026-09-01T08:00:00Z\"}",
"{\"eventType\":\"note\",\"occurredAt\":\"2026-09-01T08:00:00Z\",\"title\":\" \"}",
"{\"eventType\":\"note\",\"occurredAt\":\"2026-09-01T08:00:00Z\",\"title\":\"%s\"}"
.formatted("".repeat(161)),
// 金额负数 / 非整数分
"{\"eventType\":\"medical\",\"occurredAt\":\"2026-09-01T08:00:00Z\",\"title\":\"负金额\",\"amountCents\":-1}",
"{\"eventType\":\"medical\",\"occurredAt\":\"2026-09-01T08:00:00Z\",\"title\":\"小数金额\",\"amountCents\":45.5}");
for (String bad : badBodies) {
mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(bad))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
// 金额 0 合法(边界值)
mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"eventType":"note","occurredAt":"2026-09-01T08:00:00Z",
"title":"零金额","amountCents":0}
"""))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.amountCents").value(0));
for (String limit : List.of("0", "101")) {
mockMvc.perform(get("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + token)
.param("limit", limit))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
mockMvc.perform(get("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + token)
.param("cursor", "not-a-cursor"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// PATCH:缺 version / title 空白
String eventId = postEvent(owner, petId, "2026-09-01T09:00:00Z", "待编辑");
for (String bad : List.of("{\"title\":\"没带版本\"}", "{\"version\":0,\"title\":\" \"}")) {
mockMvc.perform(patch("/api/v1/health-events/{id}", eventId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(bad))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
// ---- 并发冲突 ----
@Test
void staleVersionLosesAndFirstWriteIsKept() throws Exception {
UUID owner = newUser("event_racer");
UUID petId = createPet(owner, "并发犬");
String token = tokenFor(owner);
String eventId = postEvent(owner, petId, "2026-09-06T08:00:00Z", "并发编辑");
mockMvc.perform(patch("/api/v1/health-events/{id}", eventId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"先写者\"}"))
.andExpect(status().isOk());
mockMvc.perform(patch("/api/v1/health-events/{id}", eventId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"迟到者\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40902));
String notes = jdbcClient.sql(
"SELECT notes FROM pet_health.health_events WHERE id = :id")
.param("id", UUID.fromString(eventId))
.query(String.class)
.single();
assertThat(notes).isEqualTo("先写者");
}
// ---- 不存在 / 无权限(防枚举) ----
@Test
void unknownOrInvisiblePetAnswersIdentical404() throws Exception {
UUID owner = newUser("event_secret_owner");
UUID stranger = newUser("event_stranger");
UUID petId = createPet(owner, "隐私犬");
String eventJson = """
{"eventType":"note","occurredAt":"2026-09-01T08:00:00Z","title":"探测"}
""";
for (Object target : List.of(UUID.randomUUID(), petId)) {
mockMvc.perform(get("/api/v1/pets/{petId}/health-events", 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}/health-events", target)
.header("Authorization", "Bearer " + tokenFor(stranger))
.contentType(MediaType.APPLICATION_JSON)
.content(eventJson))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401))
.andExpect(jsonPath("$.message").value("宠物不存在"));
}
}
@Test
void invisibleRecordsAnswerIdentical404OnTopLevelPatch() throws Exception {
UUID owner = newUser("event_secret2");
UUID stranger = newUser("event_stranger2");
UUID petId = createPet(owner, "保密犬");
String eventId = postEvent(owner, petId, "2026-09-01T08:00:00Z", "秘密事件");
// 顶层记录路径:不存在的 id 与他人的真实 id 同样 40402(记录级防枚举)
for (Object target : List.of(UUID.randomUUID().toString(), eventId)) {
mockMvc.perform(patch("/api/v1/health-events/{id}", target)
.header("Authorization", "Bearer " + tokenFor(stranger))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"探测\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40402))
.andExpect(jsonPath("$.message").value("记录不存在"));
}
}
@Test
void viewerReadsButCannotWrite() throws Exception {
UUID owner = newUser("event_view_owner");
UUID viewer = newUser("event_viewer");
UUID petId = createPet(owner, "旁观犬");
grantRole(petId, viewer, "viewer");
String eventId = postEvent(owner, petId, "2026-09-01T08:00:00Z", "只读事件");
mockMvc.perform(get("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.items", hasSize(1)));
mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(viewer))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"eventType":"note","occurredAt":"2026-09-02T08:00:00Z","title":"越权"}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
// viewer 对宠物可见,越权修改记录得到 403(而非 404)
mockMvc.perform(patch("/api/v1/health-events/{id}", eventId)
.header("Authorization", "Bearer " + tokenFor(viewer))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"越权\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
}
// ---- 幂等重试 ----
@Test
void idempotencyKeyRetryDoesNotDuplicate() throws Exception {
UUID owner = newUser("event_idem");
UUID petId = createPet(owner, "重试犬");
String key = "idem-" + UUID.randomUUID();
String json = """
{"eventType":"medical","occurredAt":"2026-09-01T08:00:00Z",
"title":"幂等事件","amountCents":12300}
""";
MvcResult first = mockMvc.perform(post("/api/v1/pets/{petId}/health-events", 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");
MvcResult retry = mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.header("Idempotency-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
assertThat((String) JsonPath.read(retry.getResponse().getContentAsString(), "$.data.id"))
.isEqualTo(firstId);
// 换键则各自成行
mockMvc.perform(post("/api/v1/pets/{petId}/health-events", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.header("Idempotency-Key", key + "-another")
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated());
Integer count = jdbcClient.sql(
"SELECT count(*) FROM pet_health.health_events WHERE pet_id = :petId")
.param("petId", petId)
.query(Integer.class)
.single();
assertThat(count).isEqualTo(2);
}
}