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:
@@ -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);
|
||||
}
|
||||
}
|
||||
+77
@@ -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;
|
||||
}
|
||||
}
|
||||
+127
@@ -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 无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
+475
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user