From 3b27f9fcbe2bafeaf5ecc9e65179c7decf197b89 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Mon, 7 Sep 2026 17:59:34 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=85=A7=E6=8A=A4=E6=8F=90=E9=86=92?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E4=B8=8E=20pending=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=B5=81=E8=BD=AC=EF=BC=88T2-07=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../patbond/common/error/ErrorCode.java | 1 + .../controller/CareReminderController.java | 72 +++ .../patbond/pet/dto/CareReminderResponse.java | 22 + .../pet/dto/CreateCareReminderRequest.java | 52 ++ .../pet/dto/UpdateCareReminderRequest.java | 39 ++ .../repository/CareReminderRepository.java | 115 +++++ .../pet/service/CareReminderService.java | 122 +++++ .../CareReminderIntegrationTest.java | 454 ++++++++++++++++++ 8 files changed, 877 insertions(+) create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/controller/CareReminderController.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CareReminderResponse.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateCareReminderRequest.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateCareReminderRequest.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/repository/CareReminderRepository.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/service/CareReminderService.java create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/controller/CareReminderIntegrationTest.java diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index bfcb3b4..d716b77 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -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, "依赖服务暂不可用"); diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/CareReminderController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/CareReminderController.java new file mode 100644 index 0000000..0c73812 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/CareReminderController.java @@ -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( + @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 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 updateStatus( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID reminderId, + @Valid @RequestBody UpdateCareReminderRequest request) { + return ApiResponse.success( + careReminderService.updateStatus(userId, reminderId, request)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CareReminderResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CareReminderResponse.java new file mode 100644 index 0000000..31a0bb1 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CareReminderResponse.java @@ -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) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateCareReminderRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateCareReminderRequest.java new file mode 100644 index 0000000..80e9207 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateCareReminderRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateCareReminderRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateCareReminderRequest.java new file mode 100644 index 0000000..c43e2c7 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateCareReminderRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/CareReminderRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/CareReminderRepository.java new file mode 100644 index 0000000..fd42a45 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/CareReminderRepository.java @@ -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 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 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)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/CareReminderService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/CareReminderService.java new file mode 100644 index 0000000..bf8be1a --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/CareReminderService.java @@ -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: + * + *
+ *   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)
+ * 
+ */ +@Service +public class CareReminderService { + + private static final Set 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 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")); + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/CareReminderIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/CareReminderIntegrationTest.java new file mode 100644 index 0000000..0d98cbe --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/CareReminderIntegrationTest.java @@ -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 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 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 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); + } +}