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>
This commit is contained in:
2026-09-07 17:59:34 +08:00
parent d8303bf446
commit 3b27f9fcbe
8 changed files with 877 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,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,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,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,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,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,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);
}
}