From d8303bf4460892d268233d85e5ba1459f99642e2 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Mon, 7 Sep 2026 17:59:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=81=A5=E5=BA=B7=E4=BA=8B=E4=BB=B6?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BA=BF=E6=8E=A5=E5=8F=A3=E4=B8=8E=20occurr?= =?UTF-8?q?ed=5Fat=20=E6=B8=B8=E6=A0=87=E5=88=86=E9=A1=B5=EF=BC=88T2-06?= =?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}/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 --- .../patbond/pet/config/JacksonConfig.java | 21 + .../pet/controller/HealthEventController.java | 77 +++ .../pet/dto/CreateHealthEventRequest.java | 76 +++ .../patbond/pet/dto/HealthEventResponse.java | 24 + .../pet/dto/UpdateHealthEventRequest.java | 61 +++ .../pet/repository/HealthEventRepository.java | 127 +++++ .../pet/service/HealthEventService.java | 112 +++++ .../patbond/pet/support/EventCursor.java | 45 ++ .../HealthEventIntegrationTest.java | 475 ++++++++++++++++++ 9 files changed, 1018 insertions(+) create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/config/JacksonConfig.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthEventController.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateHealthEventRequest.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/HealthEventResponse.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateHealthEventRequest.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/repository/HealthEventRepository.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/service/HealthEventService.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/support/EventCursor.java create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/config/JacksonConfig.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/JacksonConfig.java new file mode 100644 index 0000000..2635bbb --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/JacksonConfig.java @@ -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); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthEventController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthEventController.java new file mode 100644 index 0000000..52f0400 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/HealthEventController.java @@ -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> 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 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 update( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID eventId, + @Valid @RequestBody UpdateHealthEventRequest request) { + return ApiResponse.success(healthEventService.update(userId, eventId, request)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateHealthEventRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateHealthEventRequest.java new file mode 100644 index 0000000..071f690 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateHealthEventRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/HealthEventResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/HealthEventResponse.java new file mode 100644 index 0000000..e736645 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/HealthEventResponse.java @@ -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) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateHealthEventRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateHealthEventRequest.java new file mode 100644 index 0000000..17ea403 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateHealthEventRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/HealthEventRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/HealthEventRepository.java new file mode 100644 index 0000000..b8665c6 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/HealthEventRepository.java @@ -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 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 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")); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/HealthEventService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/HealthEventService.java new file mode 100644 index 0000000..bdcc2cc --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/HealthEventService.java @@ -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 list(UUID userId, UUID petId, int limit, String cursor) { + petAccessService.require(userId, petId, AccessLevel.READ); + EventCursor after = cursor == null ? null : EventCursor.decode(cursor); + List rows = healthEventRepository.pageByPet(petId, after, limit + 1); + boolean hasMore = rows.size() > limit; + List 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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/support/EventCursor.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/EventCursor.java new file mode 100644 index 0000000..80e5de4 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/support/EventCursor.java @@ -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 无效"); + } + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java new file mode 100644 index 0000000..1a2410d --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java @@ -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 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 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 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 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 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 ids2 = JsonPath.read(page2.getResponse().getContentAsString(), + "$.data.items[*].id"); + + Set 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 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); + } +}