Compare commits

..

2 Commits

Author SHA1 Message Date
lixi 3b27f9fcbe feat: 照护提醒接口与 pending 状态流转(T2-07)
CI / backend-test (push) Successful in 5m8s
- GET/POST /api/v1/pets/{petId}/care-reminders + PATCH /api/v1/care-reminders/{reminderId}
- 四类提醒白名单;创建恒为 pending;列表按 due_at ASC,
  ?status=pending 待办视图走 ix_care_reminders_due 部分索引
- 状态机 pending→completed/dismissed,终态不可迁移、同状态重放幂等;
  completed 必带 completedAt、其余状态禁带(镜像 ck_care_reminder_completed),
  违反 → 新错误码 42202 REMINDER_RULE_VIOLATION
- 表无 version 列:流转用当前状态条件更新守卫竞态(落空 → 40902);
  顶层记录路径 40402 记录级防枚举;Idempotency-Key 派生主键幂等
- 仅 app 内数据接口,不做推送(D2-5,通知系统属 M6)
- Testcontainers 集成测试 10 例(六类路径 + 状态-completedAt 一致性)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:59:34 +08:00
lixi d8303bf446 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>
2026-09-07 17:59:17 +08:00
17 changed files with 1895 additions and 0 deletions
@@ -23,6 +23,7 @@ public enum ErrorCode {
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
@@ -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,72 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.CareReminderResponse;
import com.patbond.patbond.pet.dto.CreateCareReminderRequest;
import com.patbond.patbond.pet.dto.UpdateCareReminderRequest;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.service.CareReminderService;
import jakarta.validation.Valid;
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.List;
import java.util.UUID;
/**
* Care reminders (T2-07): unpaginated per-pet list (with optional status
* filter for the due/to-do view) and create under the pet, plus a top-level
* PATCH for the status flow (T2-05 precedent). Authorization goes through
* the T2-03 gate inside CareReminderService.
*/
@RestController
@Validated
public class CareReminderController {
private final CareReminderService careReminderService;
public CareReminderController(CareReminderService careReminderService) {
this.careReminderService = careReminderService;
}
@GetMapping("/api/v1/pets/{petId}/care-reminders")
public ApiResponse<List<CareReminderResponse>> list(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId,
@RequestParam(required = false) String status) {
return ApiResponse.success(careReminderService.list(userId, petId, status));
}
@PostMapping("/api/v1/pets/{petId}/care-reminders")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<CareReminderResponse> 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 CreateCareReminderRequest request) {
return ApiResponse.success(
careReminderService.create(userId, petId, idempotencyKey, request));
}
@PatchMapping("/api/v1/care-reminders/{reminderId}")
public ApiResponse<CareReminderResponse> updateStatus(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID reminderId,
@Valid @RequestBody UpdateCareReminderRequest request) {
return ApiResponse.success(
careReminderService.updateStatus(userId, reminderId, request));
}
}
@@ -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,22 @@
package com.patbond.patbond.pet.dto;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* One care reminder as the API returns it. {@code completedAt} is non-null
* exactly when status is completed (ck_care_reminder_completed). No version
* field: the table has no version column — the status flow is guarded by a
* conditional update on the current status instead.
*/
public record CareReminderResponse(
UUID id,
UUID petId,
String reminderType,
String title,
OffsetDateTime dueAt,
String status,
OffsetDateTime completedAt,
OffsetDateTime createdAt,
OffsetDateTime updatedAt) {
}
@@ -0,0 +1,52 @@
package com.patbond.patbond.pet.dto;
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}/care-reminders. The four reminder types mirror
* ck_care_reminder_type. A reminder is always created as {@code pending} —
* status is not accepted here; completion goes through the PATCH flow.
*/
public class CreateCareReminderRequest {
@NotBlank(message = "reminderType 不能为空")
@Pattern(regexp = "deworming|checkup|medication|other",
message = "reminderType 仅支持 deworming/checkup/medication/other")
private String reminderType;
@NotBlank(message = "title 不能为空")
@Size(max = 160, message = "title 最长 160 字符")
private String title;
@NotNull(message = "dueAt 不能为空")
private OffsetDateTime dueAt;
public String getReminderType() {
return reminderType;
}
public void setReminderType(String reminderType) {
this.reminderType = reminderType;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public OffsetDateTime getDueAt() {
return dueAt;
}
public void setDueAt(OffsetDateTime dueAt) {
this.dueAt = dueAt;
}
}
@@ -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,39 @@
package com.patbond.patbond.pet.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import java.time.OffsetDateTime;
/**
* PATCH /api/v1/care-reminders/{reminderId} — the status flow
* (pending → completed/dismissed). {@code completedAt} must accompany
* {@code status=completed} and must be absent otherwise; both rules are
* checked in the service (42202) so ck_care_reminder_completed never
* surfaces as a 500.
*/
public class UpdateCareReminderRequest {
@NotBlank(message = "status 不能为空")
@Pattern(regexp = "pending|completed|dismissed",
message = "status 仅支持 pending/completed/dismissed")
private String status;
private OffsetDateTime completedAt;
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public OffsetDateTime getCompletedAt() {
return completedAt;
}
public void setCompletedAt(OffsetDateTime completedAt) {
this.completedAt = completedAt;
}
}
@@ -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,115 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.CareReminderResponse;
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.care_reminders access. Permission gating happens in the
* service before any call lands here.
*/
@Repository
public class CareReminderRepository {
private static final String SELECT_REMINDER = """
SELECT id, pet_id, reminder_type, title, due_at, status, completed_at,
created_at, updated_at
FROM pet_health.care_reminders
""";
private final JdbcClient jdbcClient;
public CareReminderRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one reminder, always as {@code pending};
* {@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 reminderType, String title,
OffsetDateTime dueAt) {
return jdbcClient.sql("""
INSERT INTO pet_health.care_reminders
(id, pet_id, reminder_type, title, due_at, status)
VALUES (:id, :petId, :reminderType, :title, :dueAt, 'pending')
ON CONFLICT (id) DO NOTHING
""")
.param("id", id)
.param("petId", petId)
.param("reminderType", reminderType)
.param("title", title)
.param("dueAt", dueAt)
.update();
}
public Optional<CareReminderResponse> findById(UUID id) {
return jdbcClient.sql(SELECT_REMINDER + " WHERE id = :id")
.param("id", id)
.query(CareReminderRepository::mapReminder)
.optional();
}
/**
* List for one pet ordered by due_at ASC (soonest first — the to-do
* reading), id breaking ties. The pending-only filter rides
* ix_care_reminders_due (partial index on status = 'pending').
*/
public List<CareReminderResponse> listByPet(UUID petId, String status) {
String sql = SELECT_REMINDER + " WHERE pet_id = :petId";
if (status != null) {
sql += " AND status = :status";
}
sql += " ORDER BY due_at, id";
var spec = jdbcClient.sql(sql).param("petId", petId);
if (status != null) {
spec = spec.param("status", status);
}
return spec.query(CareReminderRepository::mapReminder).list();
}
/**
* Status transition guarded by the current status (the table has no
* version column): the row is only touched if it still is in the state
* the service validated against, so two racing transitions cannot both
* win. updated_at is bumped by the trg_reminders_updated_at trigger.
*
* @return rows updated — 0 means the status changed concurrently
*/
public int updateStatusGuarded(UUID id, String expectedStatus, String newStatus,
OffsetDateTime completedAt) {
return jdbcClient.sql("""
UPDATE pet_health.care_reminders
SET status = :newStatus, completed_at = :completedAt
WHERE id = :id AND status = :expectedStatus
""")
.param("id", id)
.param("expectedStatus", expectedStatus)
.param("newStatus", newStatus)
.param("completedAt", completedAt)
.update();
}
private static CareReminderResponse mapReminder(ResultSet rs, int rowNum) throws SQLException {
return new CareReminderResponse(
rs.getObject("id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getString("reminder_type"),
rs.getString("title"),
rs.getObject("due_at", OffsetDateTime.class),
rs.getString("status"),
rs.getObject("completed_at", OffsetDateTime.class),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class));
}
}
@@ -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,122 @@
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.CareReminderResponse;
import com.patbond.patbond.pet.dto.CreateCareReminderRequest;
import com.patbond.patbond.pet.dto.UpdateCareReminderRequest;
import com.patbond.patbond.pet.repository.CareReminderRepository;
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.Set;
import java.util.UUID;
/**
* Care-reminder use-cases (T2-07): in-app data only, no push (D2-5 — the
* notification system is M6). The status machine mirrors
* ck_care_reminder_completed exactly and is enforced here first so clients
* get a readable 42202 instead of a constraint 500:
*
* <pre>
* pending ──→ completed (completedAt required)
* pending ──→ dismissed (completedAt must be absent)
* completed, dismissed: terminal (same-status replay stays allowed for
* idempotent retries; no reactivation back to pending)
* </pre>
*/
@Service
public class CareReminderService {
private static final Set<String> STATUSES = Set.of("pending", "completed", "dismissed");
private final CareReminderRepository careReminderRepository;
private final PetAccessService petAccessService;
public CareReminderService(CareReminderRepository careReminderRepository,
PetAccessService petAccessService) {
this.careReminderRepository = careReminderRepository;
this.petAccessService = petAccessService;
}
public List<CareReminderResponse> list(UUID userId, UUID petId, String status) {
petAccessService.require(userId, petId, AccessLevel.READ);
if (status != null && !STATUSES.contains(status)) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"status 仅支持 pending/completed/dismissed");
}
return careReminderRepository.listByPet(petId, status);
}
public CareReminderResponse create(UUID userId, UUID petId, String idempotencyKey,
CreateCareReminderRequest request) {
petAccessService.require(userId, petId, AccessLevel.WRITE);
boolean keyed = idempotencyKey != null && !idempotencyKey.isBlank();
UUID id = keyed
? IdempotencyKeys.deriveId("care-reminder", userId, petId, idempotencyKey)
: UuidV7.generate();
careReminderRepository.insert(id, petId, request.getReminderType(),
request.getTitle().trim(), request.getDueAt());
return careReminderRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
/**
* Top-level record path (/api/v1/care-reminders/{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 CareReminderResponse updateStatus(UUID userId, UUID reminderId,
UpdateCareReminderRequest request) {
CareReminderResponse current = careReminderRepository.findById(reminderId)
.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 target = request.getStatus();
if (!isLegalTransition(current.status(), target)) {
throw new BusinessException(ErrorCode.REMINDER_RULE_VIOLATION,
"状态不可从 %s 迁移到 %s".formatted(current.status(), target));
}
// Twin of ck_care_reminder_completed: completedAt exactly with completed.
if (target.equals("completed") && request.getCompletedAt() == null) {
throw new BusinessException(ErrorCode.REMINDER_RULE_VIOLATION,
"completed 状态必须填写 completedAt");
}
if (!target.equals("completed") && request.getCompletedAt() != null) {
throw new BusinessException(ErrorCode.REMINDER_RULE_VIOLATION,
"仅 completed 状态可携带 completedAt");
}
int updated = careReminderRepository.updateStatusGuarded(
reminderId, current.status(), target,
target.equals("completed") ? request.getCompletedAt() : null);
if (updated == 0) {
// The row left the validated state between our read and write —
// a concurrent transition won.
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
return careReminderRepository.findById(reminderId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
private static boolean isLegalTransition(String from, String to) {
if (from.equals(to)) {
return true;
}
return from.equals("pending")
&& (to.equals("completed") || to.equals("dismissed"));
}
}
@@ -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,454 @@
package com.patbond.patbond.pet.controller;
import com.jayway.jsonpath.JsonPath;
import com.patbond.patbond.pet.repository.CareReminderRepository;
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.time.OffsetDateTime;
import java.util.List;
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-07 acceptance over real PostgreSQL: the four-type reminder whitelist,
* pending-only creation, the due_at-ordered to-do view, the
* pending→completed/dismissed machine with the completed/completedAt
* consistency rule (42202, mirroring ck_care_reminder_completed),
* record-level anti-enumeration on the top-level PATCH (40402), the keyed
* idempotent create, and the T2-03 role matrix including caregiver write
* success.
*/
class CareReminderIntegrationTest extends PetIntegrationTestSupport {
@Autowired
private MockMvc mockMvc;
@Autowired
private CareReminderRepository careReminderRepository;
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 postReminder(UUID userId, UUID petId, String type, String title, String dueAt)
throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + tokenFor(userId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reminderType":"%s","title":"%s","dueAt":"%s"}
""".formatted(type, title, dueAt)))
.andExpect(status().isCreated())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
}
// ---- 成功路径 ----
@Test
void ownerCreatesAndListsRemindersOrderedByDueAt() throws Exception {
UUID owner = newUser("rem_owner");
UUID petId = createPet(owner, "提醒猫");
// 乱序创建,列表按 due_at ASC(待办最先到期在前)
postReminder(owner, petId, "checkup", "年度体检", "2026-10-15T09:00:00Z");
postReminder(owner, petId, "deworming", "体内驱虫", "2026-09-20T09:00:00Z");
postReminder(owner, petId, "medication", "耳螨用药", "2026-12-01T09:00:00Z");
mockMvc.perform(get("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data", hasSize(3)))
.andExpect(jsonPath("$.data[0].reminderType").value("deworming"))
.andExpect(jsonPath("$.data[1].reminderType").value("checkup"))
.andExpect(jsonPath("$.data[2].reminderType").value("medication"))
// 创建即 pendingcompletedAt 为空
.andExpect(jsonPath("$.data[0].status").value("pending"))
.andExpect(jsonPath("$.data[0].completedAt").isEmpty())
.andExpect(jsonPath("$.data[0].petId").value(petId.toString()));
}
/** WRITE 档 caregiver 正向用例(T2-03 移交要求延续到每个子资源单)。 */
@Test
void caregiverCanCreateAndCompleteReminders() throws Exception {
UUID owner = newUser("rem_cg_owner");
UUID caregiver = newUser("rem_caregiver");
UUID petId = createPet(owner, "协作猫");
grantRole(petId, caregiver, "caregiver");
String reminderId = postReminder(caregiver, petId, "deworming", "驱虫",
"2026-09-10T08:00:00Z");
mockMvc.perform(patch("/api/v1/care-reminders/{id}", reminderId)
.header("Authorization", "Bearer " + tokenFor(caregiver))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"status":"completed","completedAt":"2026-09-07T10:00:00Z"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("completed"))
.andExpect(jsonPath("$.data.completedAt").isNotEmpty());
// owner 也能读到 caregiver 的完成结果
mockMvc.perform(get("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(1)))
.andExpect(jsonPath("$.data[0].status").value("completed"));
}
@Test
void pendingFilterReturnsOnlyToDoItems() throws Exception {
UUID owner = newUser("rem_filter");
UUID petId = createPet(owner, "筛选猫");
String token = tokenFor(owner);
String done = postReminder(owner, petId, "checkup", "已完成项", "2026-09-08T08:00:00Z");
String skipped = postReminder(owner, petId, "other", "已忽略项", "2026-09-09T08:00:00Z");
postReminder(owner, petId, "medication", "待办项", "2026-09-10T08:00:00Z");
mockMvc.perform(patch("/api/v1/care-reminders/{id}", done)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"completed\",\"completedAt\":\"2026-09-07T10:00:00Z\"}"))
.andExpect(status().isOk());
mockMvc.perform(patch("/api/v1/care-reminders/{id}", skipped)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"dismissed\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.completedAt").isEmpty());
// 待办视图只剩 pending,一条
mockMvc.perform(get("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + token)
.param("status", "pending"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(1)))
.andExpect(jsonPath("$.data[0].title").value("待办项"));
// 不带过滤仍是全量三条
mockMvc.perform(get("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(3)));
}
// ---- 状态-completedAt 一致性 ----
@Test
void completedAtConsistencyIsEnforced() throws Exception {
UUID owner = newUser("rem_rules");
UUID petId = createPet(owner, "规则猫");
String token = tokenFor(owner);
String reminderId = postReminder(owner, petId, "checkup", "规则测试",
"2026-09-15T08:00:00Z");
// completed 缺 completedAt → 42202
mockMvc.perform(patch("/api/v1/care-reminders/{id}", reminderId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"completed\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42202));
// dismissed 携带 completedAt → 42202
mockMvc.perform(patch("/api/v1/care-reminders/{id}", reminderId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"dismissed\",\"completedAt\":\"2026-09-07T10:00:00Z\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42202));
// 两次违规后记录仍是 pending 且 completed_at 为空(落库一致性断言)
List<String> row = jdbcClient.sql("""
SELECT status || '|' || COALESCE(completed_at::text, 'null')
FROM pet_health.care_reminders WHERE id = :id
""")
.param("id", UUID.fromString(reminderId))
.query(String.class)
.list();
assertThat(row).containsExactly("pending|null");
// 正常完成后 completed_at 落库非空
mockMvc.perform(patch("/api/v1/care-reminders/{id}", reminderId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"completed\",\"completedAt\":\"2026-09-07T10:00:00Z\"}"))
.andExpect(status().isOk());
OffsetDateTime completedAt = jdbcClient.sql(
"SELECT completed_at FROM pet_health.care_reminders WHERE id = :id")
.param("id", UUID.fromString(reminderId))
.query(OffsetDateTime.class)
.single();
assertThat(completedAt).isNotNull();
}
@Test
void terminalStatesRejectTransitionsButAllowIdempotentReplay() throws Exception {
UUID owner = newUser("rem_terminal");
UUID petId = createPet(owner, "终态猫");
String token = tokenFor(owner);
String completedId = postReminder(owner, petId, "checkup", "已完成", "2026-09-15T08:00:00Z");
String dismissedId = postReminder(owner, petId, "other", "已忽略", "2026-09-16T08:00:00Z");
mockMvc.perform(patch("/api/v1/care-reminders/{id}", completedId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"completed\",\"completedAt\":\"2026-09-07T10:00:00Z\"}"))
.andExpect(status().isOk());
mockMvc.perform(patch("/api/v1/care-reminders/{id}", dismissedId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"dismissed\"}"))
.andExpect(status().isOk());
// 终态之间与回退 pending 均拒绝
record Case(String id, String body) {
}
List<Case> illegal = List.of(
new Case(completedId, "{\"status\":\"dismissed\"}"),
new Case(completedId, "{\"status\":\"pending\"}"),
new Case(dismissedId,
"{\"status\":\"completed\",\"completedAt\":\"2026-09-07T11:00:00Z\"}"),
new Case(dismissedId, "{\"status\":\"pending\"}"));
for (Case c : illegal) {
mockMvc.perform(patch("/api/v1/care-reminders/{id}", c.id())
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(c.body()))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42202));
}
// 同状态重放(客户端重试「标记完成」)保持幂等成功
mockMvc.perform(patch("/api/v1/care-reminders/{id}", completedId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"completed\",\"completedAt\":\"2026-09-07T10:00:00Z\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("completed"));
}
// ---- 参数错误 ----
@Test
void validationErrorsAreRejected() throws Exception {
UUID owner = newUser("rem_bad_input");
UUID petId = createPet(owner, "验证猫二号");
String token = tokenFor(owner);
List<String> badBodies = List.of(
// 缺 reminderType / 类型不在四类白名单
"{\"title\":\"缺类型\",\"dueAt\":\"2026-09-10T08:00:00Z\"}",
"{\"reminderType\":\"walking\",\"title\":\"非法类型\",\"dueAt\":\"2026-09-10T08:00:00Z\"}",
// 缺 title / title 空白 / title 超 160 / 缺 dueAt
"{\"reminderType\":\"other\",\"dueAt\":\"2026-09-10T08:00:00Z\"}",
"{\"reminderType\":\"other\",\"title\":\" \",\"dueAt\":\"2026-09-10T08:00:00Z\"}",
"{\"reminderType\":\"other\",\"title\":\"%s\",\"dueAt\":\"2026-09-10T08:00:00Z\"}"
.formatted("".repeat(161)),
"{\"reminderType\":\"other\",\"title\":\"缺时间\"}");
for (String bad : badBodies) {
mockMvc.perform(post("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(bad))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
// 列表 status 过滤参数非法 → 40000
mockMvc.perform(get("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + token)
.param("status", "done"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
// PATCH:缺 status / status 非法 → 40000
String reminderId = postReminder(owner, petId, "other", "待流转", "2026-09-10T08:00:00Z");
for (String bad : List.of("{}", "{\"status\":\"done\"}")) {
mockMvc.perform(patch("/api/v1/care-reminders/{id}", reminderId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(bad))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
}
// ---- 不存在 / 无权限(防枚举) ----
@Test
void unknownOrInvisiblePetAnswersIdentical404() throws Exception {
UUID owner = newUser("rem_secret_owner");
UUID stranger = newUser("rem_stranger");
UUID petId = createPet(owner, "隐私猫二号");
String reminderJson = """
{"reminderType":"other","title":"探测","dueAt":"2026-09-10T08:00:00Z"}
""";
for (Object target : List.of(UUID.randomUUID(), petId)) {
mockMvc.perform(get("/api/v1/pets/{petId}/care-reminders", 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}/care-reminders", target)
.header("Authorization", "Bearer " + tokenFor(stranger))
.contentType(MediaType.APPLICATION_JSON)
.content(reminderJson))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401))
.andExpect(jsonPath("$.message").value("宠物不存在"));
}
}
@Test
void invisibleOrUnknownReminderAnswersIdentical404AndViewerGets403() throws Exception {
UUID owner = newUser("rem_secret2");
UUID stranger = newUser("rem_stranger2");
UUID viewer = newUser("rem_viewer");
UUID petId = createPet(owner, "保密猫二号");
grantRole(petId, viewer, "viewer");
String reminderId = postReminder(owner, petId, "checkup", "秘密提醒",
"2026-09-10T08:00:00Z");
// 顶层记录路径:不存在的 id 与他人的真实 id 同样 40402(记录级防枚举)
for (Object target : List.of(UUID.randomUUID().toString(), reminderId)) {
mockMvc.perform(patch("/api/v1/care-reminders/{id}", target)
.header("Authorization", "Bearer " + tokenFor(stranger))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"dismissed\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40402))
.andExpect(jsonPath("$.message").value("记录不存在"));
}
// viewer 可读列表
mockMvc.perform(get("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(1)));
// viewer 越权创建/流转得到 403(可见者才可能收到 403)
mockMvc.perform(post("/api/v1/pets/{petId}/care-reminders", petId)
.header("Authorization", "Bearer " + tokenFor(viewer))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"reminderType":"other","title":"越权","dueAt":"2026-09-10T08:00:00Z"}
"""))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
mockMvc.perform(patch("/api/v1/care-reminders/{id}", reminderId)
.header("Authorization", "Bearer " + tokenFor(viewer))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"dismissed\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
}
// ---- 并发冲突(状态守卫) ----
/**
* care_reminders 无 version 列,读写窗口内的竞态由「条件更新当前状态」
* 守卫:这里直接断言守卫落空返回 0 行(服务侧映射 40902);经 API 的
* 迟到流转则已被终态检查拦成 42202。
*/
@Test
void concurrentTransitionLosesWhenStatusGuardMisses() throws Exception {
UUID owner = newUser("rem_racer");
UUID petId = createPet(owner, "并发猫二号");
String token = tokenFor(owner);
String reminderId = postReminder(owner, petId, "checkup", "抢跑测试",
"2026-09-15T08:00:00Z");
UUID id = UUID.fromString(reminderId);
// 模拟另一事务在校验快照(pending)之后抢先流转
jdbcClient.sql("""
UPDATE pet_health.care_reminders
SET status = 'completed', completed_at = now()
WHERE id = :id
""")
.param("id", id)
.update();
// 守卫以过期的 pending 为前提写入 → 0 行,先写者结果保留
int updated = careReminderRepository.updateStatusGuarded(
id, "pending", "dismissed", null);
assertThat(updated).isZero();
String status = jdbcClient.sql(
"SELECT status FROM pet_health.care_reminders WHERE id = :id")
.param("id", id)
.query(String.class)
.single();
assertThat(status).isEqualTo("completed");
// 经 API 的迟到流转:重读后按终态语义拒绝(42202)
mockMvc.perform(patch("/api/v1/care-reminders/{id}", reminderId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"dismissed\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42202));
}
// ---- 幂等重试 ----
@Test
void idempotencyKeyRetryDoesNotDuplicate() throws Exception {
UUID owner = newUser("rem_idem");
UUID petId = createPet(owner, "重试猫三号");
String key = "idem-" + UUID.randomUUID();
String json = """
{"reminderType":"deworming","title":"幂等提醒","dueAt":"2026-09-20T08:00:00Z"}
""";
MvcResult first = mockMvc.perform(post("/api/v1/pets/{petId}/care-reminders", 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}/care-reminders", 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);
Integer count = jdbcClient.sql(
"SELECT count(*) FROM pet_health.care_reminders WHERE pet_id = :petId")
.param("petId", petId)
.query(Integer.class)
.single();
assertThat(count).isEqualTo(1);
}
}
@@ -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);
}
}