Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b27f9fcbe | |||
| d8303bf446 |
@@ -23,6 +23,7 @@ public enum ErrorCode {
|
|||||||
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
||||||
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
|
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
|
||||||
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
|
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
|
||||||
|
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
|
||||||
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
||||||
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
||||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+72
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+115
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
+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,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 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+454
@@ -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"))
|
||||||
|
// 创建即 pending,completedAt 为空
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+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