diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreatePetRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreatePetRequest.java
index c15f056..d648fc0 100644
--- a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreatePetRequest.java
+++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreatePetRequest.java
@@ -28,6 +28,9 @@ public class CreatePetRequest {
@Size(min = 1, max = 64, message = "自定义品种名称长度须在 1~64 字符")
private String customBreedName;
+ // 冻结契约 v1.2.0 的 CreatePetRequest.required 含 sex(T2-09 契约测试对齐:
+ // 客户端「不确定」也要显式提交 unknown,服务端不再静默默认)。
+ @NotBlank(message = "性别不能为空")
@Pattern(regexp = "male|female|unknown", message = "性别仅支持 male/female/unknown")
private String sex;
diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java
index b1d1e21..e979cd5 100644
--- a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java
+++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java
@@ -53,7 +53,7 @@ public class PetService {
request.getSpecies(),
request.getBreedId(),
trimOrNull(request.getCustomBreedName()),
- request.getSex() == null ? "unknown" : request.getSex(),
+ request.getSex(),
request.getBirthDate(),
Boolean.TRUE.equals(request.getBirthDateEstimated()),
trimOrNull(request.getPersonality()),
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/access/PetPermissionIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/access/PetPermissionIntegrationTest.java
index ab91edd..9954297 100644
--- a/patbond-pet/src/test/java/com/patbond/patbond/pet/access/PetPermissionIntegrationTest.java
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/access/PetPermissionIntegrationTest.java
@@ -46,7 +46,7 @@ class PetPermissionIntegrationTest extends PetIntegrationTestSupport {
MvcResult result = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"权限猫\",\"species\":\"cat\",\"customBreedName\":\"狸花\"}"))
+ .content("{\"name\":\"权限猫\",\"species\":\"cat\",\"sex\":\"female\",\"customBreedName\":\"狸花\"}"))
.andExpect(status().isCreated())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java
new file mode 100644
index 0000000..7475198
--- /dev/null
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractConformanceTest.java
@@ -0,0 +1,734 @@
+package com.patbond.patbond.pet.contract;
+
+import com.jayway.jsonpath.JsonPath;
+import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
+import org.junit.jupiter.api.MethodOrderer;
+import org.junit.jupiter.api.Order;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestMethodOrder;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+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.request.MockMvcRequestBuilders.request;
+
+/**
+ * T2-09 契约一致性保障:对冻结契约 v1.2.0(快照
+ * {@code src/test/resources/contract/openapi-v1.2.0.yaml},正典在 doc 仓
+ * {@code docs/api/openapi.yaml})的 pets 域 18 个操作逐一真实起服务发请求,
+ * 用 {@link ContractValidator} 严格校验响应结构:路径/方法/状态码已声明、
+ * 字段名与类型、必填与 nullable、枚举与格式、信封结构、错误码值。
+ *
+ *
覆盖目标是**全响应矩阵**:最后的 {@link #everyDeclaredResponseCellIsExercised()}
+ * 断言契约为这 18 个操作声明的每一个 (操作, 状态码) 单元格都被至少一次真实响应
+ * 校验过(唯一豁免:照护提醒 PATCH 的 409——并发条件更新守卫落空,单线程
+ * MockMvc 无法确定性触发)。契约新增操作或状态码时,本测试立即变红。
+ *
+ *
auth 域 6 个既有操作(register/login/refresh/logout/me/trackEvents)
+ * 不在本单范围(M1 交付无契约测试,补齐另立工单)。
+ */
+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
+class ContractConformanceTest extends PetIntegrationTestSupport {
+
+ private static final OpenApiContract CONTRACT = OpenApiContract.load();
+ private static final ContractValidator VALIDATOR = new ContractValidator(CONTRACT);
+
+ /** 已被真实响应校验过的 (操作, 状态码) 单元格,如 "GET /api/v1/pets 200"。 */
+ private static final Set COVERED = ConcurrentHashMap.newKeySet();
+
+ /** pets 域 18 个操作(= 契约中 tags ∈ {pets, dictionaries, health-records})。 */
+ private static final List PETS_OPERATIONS = List.of(
+ "GET /api/v1/pets",
+ "POST /api/v1/pets",
+ "GET /api/v1/pets/{petId}",
+ "PATCH /api/v1/pets/{petId}",
+ "GET /api/v1/breeds",
+ "GET /api/v1/pets/{petId}/weights",
+ "POST /api/v1/pets/{petId}/weights",
+ "GET /api/v1/vaccine-catalog",
+ "GET /api/v1/pets/{petId}/vaccinations",
+ "POST /api/v1/pets/{petId}/vaccinations",
+ "PATCH /api/v1/vaccinations/{vaccinationId}",
+ "GET /api/v1/pets/{petId}/health-events",
+ "POST /api/v1/pets/{petId}/health-events",
+ "PATCH /api/v1/health-events/{eventId}",
+ "GET /api/v1/pets/{petId}/care-reminders",
+ "POST /api/v1/pets/{petId}/care-reminders",
+ "PATCH /api/v1/care-reminders/{reminderId}",
+ "GET /api/v1/pets/{petId}/summary");
+
+ private static final String AUTH = "Authorization";
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ // ---- 校验骨架 ------------------------------------------------------
+
+ /**
+ * 执行请求,断言 HTTP 状态,并将响应体对照冻结契约严格校验;通过后把
+ * (操作, 状态码) 记入覆盖表。返回响应体供取 id。
+ */
+ private String verified(MockHttpServletRequestBuilder rq, String method,
+ String pathTemplate, int expectedStatus) throws Exception {
+ MvcResult result = mockMvc.perform(rq).andReturn();
+ int actual = result.getResponse().getStatus();
+ String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8);
+ assertThat(actual)
+ .as("%s %s 的 HTTP 状态(响应体: %s)", method, pathTemplate, body)
+ .isEqualTo(expectedStatus);
+ List drift = VALIDATOR.validateResponse(method, pathTemplate, actual, body);
+ assertThat(drift).as("%s %s %d 响应与冻结契约漂移", method, pathTemplate, actual).isEmpty();
+ COVERED.add(method + " " + pathTemplate + " " + actual);
+ return body;
+ }
+
+ /** 同上,并额外断言信封 code 等于契约错误码表约定的业务码。 */
+ private String verifiedError(MockHttpServletRequestBuilder rq, String method,
+ String pathTemplate, int status, int bizCode) throws Exception {
+ String body = verified(rq, method, pathTemplate, status);
+ assertThat((Integer) JsonPath.read(body, "$.code"))
+ .as("%s %s %d 的业务错误码", method, pathTemplate, status)
+ .isEqualTo(bizCode);
+ return body;
+ }
+
+ private String bearer(UUID userId) {
+ return "Bearer " + tokenFor(userId);
+ }
+
+ /** 建一只猫(走 verified,创建响应同样被契约校验),返回 petId。 */
+ private String newCat(UUID owner, String name) throws Exception {
+ String body = verified(post("/api/v1/pets")
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
+ """.formatted(name)),
+ "POST", "/api/v1/pets", 201);
+ return JsonPath.read(body, "$.data.id");
+ }
+
+ // ---- 成功路径:18 操作全覆盖 ---------------------------------------
+
+ @Test
+ @Order(1)
+ void petsAndBreedsSuccessShapes() throws Exception {
+ UUID owner = newUser("contract_pets_owner");
+ UUID breedId = anyBreedId("dog");
+
+ String created = verified(post("/api/v1/pets")
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"契约犬","species":"dog","breedId":"%s","sex":"male",
+ "birthDate":"2024-05-01","birthDateEstimated":true,"personality":"沉稳",
+ "microchipNo":"chip-contract-t209","sterilizedOn":"2025-06-01"}
+ """.formatted(breedId)),
+ "POST", "/api/v1/pets", 201);
+ String petId = JsonPath.read(created, "$.data.id");
+
+ // 可空字段全空的形态也过一遍(nullable 声明的实证)
+ newCat(owner, "契约猫");
+
+ verified(get("/api/v1/pets").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets", 200);
+ verified(get("/api/v1/pets/{petId}", petId).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}", 200);
+ verified(patch("/api/v1/pets/{petId}", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"契约犬二世\",\"status\":\"lost\"}"),
+ "PATCH", "/api/v1/pets/{petId}", 200);
+
+ verified(get("/api/v1/breeds").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/breeds", 200);
+ verified(get("/api/v1/breeds").param("species", "cat").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/breeds", 200);
+ }
+
+ @Test
+ @Order(2)
+ void weightsSuccessShapes() throws Exception {
+ UUID owner = newUser("contract_weight_owner");
+ String petId = newCat(owner, "契约称重猫");
+
+ verified(post("/api/v1/pets/{petId}/weights", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"weightKg":4.56,"measuredAt":"2026-09-01T10:00:00Z",
+ "source":"clinic","note":"年度体检"}
+ """),
+ "POST", "/api/v1/pets/{petId}/weights", 201);
+ for (int i = 2; i <= 3; i++) {
+ verified(post("/api/v1/pets/{petId}/weights", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"weightKg\":4.6,\"measuredAt\":\"2026-09-0%dT10:00:00Z\"}"
+ .formatted(i)),
+ "POST", "/api/v1/pets/{petId}/weights", 201);
+ }
+
+ String page1 = verified(get("/api/v1/pets/{petId}/weights", petId)
+ .param("limit", "2").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/weights", 200);
+ assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
+ String cursor = JsonPath.read(page1, "$.data.nextCursor");
+ assertThat(cursor).as("hasMore=true 时 nextCursor 非空").isNotNull();
+
+ String page2 = verified(get("/api/v1/pets/{petId}/weights", petId)
+ .param("limit", "2").param("cursor", cursor).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/weights", 200);
+ assertThat((Boolean) JsonPath.read(page2, "$.data.hasMore")).isFalse();
+ assertThat((Object) JsonPath.read(page2, "$.data.nextCursor"))
+ .as("hasMore=false 时 nextCursor 恒为 null").isNull();
+ }
+
+ @Test
+ @Order(3)
+ void vaccinationsAndCatalogSuccessShapes() throws Exception {
+ UUID owner = newUser("contract_vacc_owner");
+ String petId = newCat(owner, "契约疫苗猫");
+ UUID vaccineId = vaccineIdByCode("feline_3in1");
+
+ verified(get("/api/v1/vaccine-catalog").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/vaccine-catalog", 200);
+ verified(get("/api/v1/vaccine-catalog").param("species", "cat").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/vaccine-catalog", 200);
+
+ // 全字段 completed 形态
+ verified(post("/api/v1/pets/{petId}/vaccinations", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"primary","doseNo":1,"doseLabel":"首针",
+ "status":"completed","administeredOn":"2026-08-01","nextDueOn":"2027-08-01",
+ "manufacturer":"契约生物","batchNo":"B-2026-001","notes":"无不良反应"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 201);
+
+ // scheduled 形态 + 状态机 PATCH scheduled→completed
+ String scheduled = verified(post("/api/v1/pets/{petId}/vaccinations", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"primary","doseNo":2,
+ "status":"scheduled","plannedOn":"2026-10-01"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 201);
+ String vaccinationId = JsonPath.read(scheduled, "$.data.id");
+
+ verified(patch("/api/v1/vaccinations/{id}", vaccinationId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"version":0,"status":"completed",
+ "administeredOn":"2026-09-08","nextDueOn":"2027-09-08"}
+ """),
+ "PATCH", "/api/v1/vaccinations/{vaccinationId}", 200);
+
+ verified(get("/api/v1/pets/{petId}/vaccinations", petId).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/vaccinations", 200);
+ }
+
+ @Test
+ @Order(4)
+ void healthEventsSuccessShapes() throws Exception {
+ UUID owner = newUser("contract_event_owner");
+ String petId = newCat(owner, "契约事件猫");
+
+ String created = verified(post("/api/v1/pets/{petId}/health-events", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"eventType":"medical","occurredAt":"2026-09-05T09:30:00Z",
+ "title":"疫苗后复查","notes":"状态良好","amountCents":4500}
+ """),
+ "POST", "/api/v1/pets/{petId}/health-events", 201);
+ String eventId = JsonPath.read(created, "$.data.id");
+
+ verified(post("/api/v1/pets/{petId}/health-events", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"eventType":"note","occurredAt":"2026-09-06T09:30:00Z","title":"随手记"}
+ """),
+ "POST", "/api/v1/pets/{petId}/health-events", 201);
+
+ String page1 = verified(get("/api/v1/pets/{petId}/health-events", petId)
+ .param("limit", "1").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/health-events", 200);
+ assertThat((Boolean) JsonPath.read(page1, "$.data.hasMore")).isTrue();
+ String cursor = JsonPath.read(page1, "$.data.nextCursor");
+ verified(get("/api/v1/pets/{petId}/health-events", petId)
+ .param("cursor", cursor).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/health-events", 200);
+
+ verified(patch("/api/v1/health-events/{id}", eventId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"title\":\"疫苗后复查(改)\",\"amountCents\":5200}"),
+ "PATCH", "/api/v1/health-events/{eventId}", 200);
+ }
+
+ @Test
+ @Order(5)
+ void careRemindersSuccessShapes() throws Exception {
+ UUID owner = newUser("contract_reminder_owner");
+ String petId = newCat(owner, "契约提醒猫");
+
+ String first = verified(post("/api/v1/pets/{petId}/care-reminders", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"reminderType":"deworming","title":"体内驱虫","dueAt":"2026-10-01T09:00:00Z"}
+ """),
+ "POST", "/api/v1/pets/{petId}/care-reminders", 201);
+ String second = verified(post("/api/v1/pets/{petId}/care-reminders", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"reminderType":"checkup","title":"年度体检","dueAt":"2026-11-01T09:00:00Z"}
+ """),
+ "POST", "/api/v1/pets/{petId}/care-reminders", 201);
+
+ verified(get("/api/v1/pets/{petId}/care-reminders", petId).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/care-reminders", 200);
+ verified(get("/api/v1/pets/{petId}/care-reminders", petId)
+ .param("status", "pending").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/care-reminders", 200);
+
+ verified(patch("/api/v1/care-reminders/{id}", (String) JsonPath.read(first, "$.data.id"))
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"status\":\"completed\",\"completedAt\":\"2026-09-08T12:00:00Z\"}"),
+ "PATCH", "/api/v1/care-reminders/{reminderId}", 200);
+ verified(patch("/api/v1/care-reminders/{id}", (String) JsonPath.read(second, "$.data.id"))
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"status\":\"dismissed\"}"),
+ "PATCH", "/api/v1/care-reminders/{reminderId}", 200);
+ }
+
+ @Test
+ @Order(6)
+ void summarySuccessShapes() throws Exception {
+ UUID owner = newUser("contract_summary_owner");
+
+ // 空档案:三个 nullable 聚合为 null、monthlyExpense 恒在
+ String emptyPet = newCat(owner, "契约空摘要猫");
+ verified(get("/api/v1/pets/{petId}/summary", emptyPet).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/summary", 200);
+
+ // 满档案:四项聚合全部非 null
+ String petId = newCat(owner, "契约摘要猫");
+ UUID vaccineId = vaccineIdByCode("rabies_cat");
+ verified(post("/api/v1/pets/{petId}/weights", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"weightKg\":3.21,\"measuredAt\":\"2026-09-01T10:00:00Z\"}"),
+ "POST", "/api/v1/pets/{petId}/weights", 201);
+ verified(post("/api/v1/pets/{petId}/vaccinations", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"rabies","doseNo":1,"status":"completed",
+ "administeredOn":"2026-08-15","nextDueOn":"2027-08-15"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 201);
+ verified(post("/api/v1/pets/{petId}/health-events", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"eventType":"medical","occurredAt":"2026-09-07T08:00:00Z",
+ "title":"驱虫药","amountCents":8800}
+ """),
+ "POST", "/api/v1/pets/{petId}/health-events", 201);
+
+ String full = verified(get("/api/v1/pets/{petId}/summary", petId)
+ .header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/summary", 200);
+ assertThat((Object) JsonPath.read(full, "$.data.latestWeight")).isNotNull();
+ assertThat((Object) JsonPath.read(full, "$.data.vaccinationProgress")).isNotNull();
+ assertThat((Object) JsonPath.read(full, "$.data.nextVaccination")).isNotNull();
+
+ verified(get("/api/v1/pets/{petId}/summary", petId)
+ .param("tz", "Asia/Shanghai").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/summary", 200);
+ }
+
+ // ---- 错误信封 ------------------------------------------------------
+
+ @Test
+ @Order(7)
+ void unauthenticatedRequestsAnswer40101OnAllOperations() throws Exception {
+ for (String op : PETS_OPERATIONS) {
+ String[] parts = op.split(" ", 2);
+ String url = parts[1].replaceAll("\\{[^}]+}", UUID.randomUUID().toString());
+ MockHttpServletRequestBuilder rq = request(HttpMethod.valueOf(parts[0]), url);
+ if (!"GET".equals(parts[0])) {
+ rq = rq.contentType(MediaType.APPLICATION_JSON).content("{}");
+ }
+ verifiedError(rq, parts[0], parts[1], 401, 40101);
+ }
+ }
+
+ @Test
+ @Order(8)
+ void antiEnumerationAndPermissionErrorsMatchContract() throws Exception {
+ UUID owner = newUser("contract_err_owner");
+ UUID viewer = newUser("contract_err_viewer");
+ UUID caregiver = newUser("contract_err_caregiver");
+ String ghost = UUID.randomUUID().toString();
+ UUID vaccineId = vaccineIdByCode("felv");
+
+ // -- 40401:宠物级防枚举(11 个 pet 路径操作,随机 petId)--
+ verifiedError(get("/api/v1/pets/{id}", ghost).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}", 404, 40401);
+ verifiedError(patch("/api/v1/pets/{id}", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"幽灵\"}"),
+ "PATCH", "/api/v1/pets/{petId}", 404, 40401);
+ verifiedError(get("/api/v1/pets/{id}/weights", ghost).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/weights", 404, 40401);
+ verifiedError(post("/api/v1/pets/{id}/weights", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"weightKg\":3.5,\"measuredAt\":\"2026-09-08T10:00:00Z\"}"),
+ "POST", "/api/v1/pets/{petId}/weights", 404, 40401);
+ verifiedError(get("/api/v1/pets/{id}/vaccinations", ghost).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/vaccinations", 404, 40401);
+ verifiedError(post("/api/v1/pets/{id}/vaccinations", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"ghost","doseNo":1,
+ "status":"scheduled","plannedOn":"2026-10-01"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 404, 40401);
+ verifiedError(get("/api/v1/pets/{id}/health-events", ghost).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/health-events", 404, 40401);
+ verifiedError(post("/api/v1/pets/{id}/health-events", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"eventType":"note","occurredAt":"2026-09-08T10:00:00Z","title":"幽灵"}
+ """),
+ "POST", "/api/v1/pets/{petId}/health-events", 404, 40401);
+ verifiedError(get("/api/v1/pets/{id}/care-reminders", ghost).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/care-reminders", 404, 40401);
+ verifiedError(post("/api/v1/pets/{id}/care-reminders", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"reminderType":"other","title":"幽灵","dueAt":"2026-10-01T09:00:00Z"}
+ """),
+ "POST", "/api/v1/pets/{petId}/care-reminders", 404, 40401);
+ verifiedError(get("/api/v1/pets/{id}/summary", ghost).header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/summary", 404, 40401);
+
+ // -- 40402:记录级防枚举(3 个顶层短路径,随机记录 id)--
+ verifiedError(patch("/api/v1/vaccinations/{id}", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"notes\":\"幽灵\"}"),
+ "PATCH", "/api/v1/vaccinations/{vaccinationId}", 404, 40402);
+ verifiedError(patch("/api/v1/health-events/{id}", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"title\":\"幽灵\"}"),
+ "PATCH", "/api/v1/health-events/{eventId}", 404, 40402);
+ verifiedError(patch("/api/v1/care-reminders/{id}", ghost).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"status\":\"dismissed\"}"),
+ "PATCH", "/api/v1/care-reminders/{reminderId}", 404, 40402);
+
+ // -- 40300:可见但角色不覆盖(viewer 写记录、caregiver 改档案)--
+ String petId = newCat(owner, "契约权限猫");
+ grantRole(UUID.fromString(petId), viewer, "viewer");
+ grantRole(UUID.fromString(petId), caregiver, "caregiver");
+
+ String vaccination = verified(post("/api/v1/pets/{petId}/vaccinations", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"perm","doseNo":1,
+ "status":"scheduled","plannedOn":"2026-10-01"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 201);
+ String event = verified(post("/api/v1/pets/{petId}/health-events", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"eventType":"note","occurredAt":"2026-09-08T10:00:00Z","title":"权限记录"}
+ """),
+ "POST", "/api/v1/pets/{petId}/health-events", 201);
+ String reminder = verified(post("/api/v1/pets/{petId}/care-reminders", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"reminderType":"other","title":"权限提醒","dueAt":"2026-10-01T09:00:00Z"}
+ """),
+ "POST", "/api/v1/pets/{petId}/care-reminders", 201);
+
+ verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(caregiver))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"越权改名\"}"),
+ "PATCH", "/api/v1/pets/{petId}", 403, 40300);
+ verifiedError(post("/api/v1/pets/{id}/weights", petId).header(AUTH, bearer(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"weightKg\":3.5,\"measuredAt\":\"2026-09-08T10:00:00Z\"}"),
+ "POST", "/api/v1/pets/{petId}/weights", 403, 40300);
+ verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"perm","doseNo":2,
+ "status":"scheduled","plannedOn":"2026-11-01"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 403, 40300);
+ verifiedError(post("/api/v1/pets/{id}/health-events", petId).header(AUTH, bearer(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"eventType":"note","occurredAt":"2026-09-08T11:00:00Z","title":"越权"}
+ """),
+ "POST", "/api/v1/pets/{petId}/health-events", 403, 40300);
+ verifiedError(post("/api/v1/pets/{id}/care-reminders", petId).header(AUTH, bearer(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"reminderType":"other","title":"越权","dueAt":"2026-10-01T09:00:00Z"}
+ """),
+ "POST", "/api/v1/pets/{petId}/care-reminders", 403, 40300);
+ verifiedError(patch("/api/v1/vaccinations/{id}", (String) JsonPath.read(vaccination, "$.data.id"))
+ .header(AUTH, bearer(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"notes\":\"越权\"}"),
+ "PATCH", "/api/v1/vaccinations/{vaccinationId}", 403, 40300);
+ verifiedError(patch("/api/v1/health-events/{id}", (String) JsonPath.read(event, "$.data.id"))
+ .header(AUTH, bearer(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"title\":\"越权\"}"),
+ "PATCH", "/api/v1/health-events/{eventId}", 403, 40300);
+ verifiedError(patch("/api/v1/care-reminders/{id}", (String) JsonPath.read(reminder, "$.data.id"))
+ .header(AUTH, bearer(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"status\":\"dismissed\"}"),
+ "PATCH", "/api/v1/care-reminders/{reminderId}", 403, 40300);
+ }
+
+ @Test
+ @Order(9)
+ void validationConflictAndRuleErrorsMatchContract() throws Exception {
+ UUID owner = newUser("contract_rule_owner");
+ String petId = newCat(owner, "契约规则猫");
+ UUID vaccineId = vaccineIdByCode("feline_chlamydia");
+
+ // -- 400/40000:参数校验 --
+ verifiedError(post("/api/v1/pets").header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON).content("{}"),
+ "POST", "/api/v1/pets", 400, 40000);
+ verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"缺版本\"}"),
+ "PATCH", "/api/v1/pets/{petId}", 400, 40000);
+ verifiedError(get("/api/v1/breeds").param("species", "bird").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/breeds", 400, 40000);
+ verifiedError(get("/api/v1/vaccine-catalog").param("species", "bird").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/vaccine-catalog", 400, 40000);
+ verifiedError(get("/api/v1/pets/{id}/weights", petId)
+ .param("limit", "0").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/weights", 400, 40000);
+ verifiedError(post("/api/v1/pets/{id}/weights", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"weightKg\":600,\"measuredAt\":\"2026-09-08T10:00:00Z\"}"),
+ "POST", "/api/v1/pets/{petId}/weights", 400, 40000);
+ verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON).content("{}"),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 400, 40000);
+ verifiedError(get("/api/v1/pets/{id}/health-events", petId)
+ .param("cursor", "not-a-cursor").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/health-events", 400, 40000);
+ verifiedError(post("/api/v1/pets/{id}/health-events", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON).content("{}"),
+ "POST", "/api/v1/pets/{petId}/health-events", 400, 40000);
+ verifiedError(get("/api/v1/pets/{id}/care-reminders", petId)
+ .param("status", "bogus").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/care-reminders", 400, 40000);
+ verifiedError(post("/api/v1/pets/{id}/care-reminders", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON).content("{}"),
+ "POST", "/api/v1/pets/{petId}/care-reminders", 400, 40000);
+ verifiedError(get("/api/v1/pets/{id}/summary", petId)
+ .param("tz", "Not/AZone").header(AUTH, bearer(owner)),
+ "GET", "/api/v1/pets/{petId}/summary", 400, 40000);
+
+ // -- 409/40903:芯片号已被登记 --
+ verified(post("/api/v1/pets").header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"芯片猫","species":"cat","customBreedName":"狸花猫",
+ "sex":"female","microchipNo":"chip-contract-dup"}
+ """),
+ "POST", "/api/v1/pets", 201);
+ verifiedError(post("/api/v1/pets").header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"芯片猫二","species":"cat","customBreedName":"狸花猫",
+ "sex":"female","microchipNo":"chip-contract-dup"}
+ """),
+ "POST", "/api/v1/pets", 409, 40903);
+
+ // -- 409/40902:乐观锁过期(PATCH pet:先成功一次把 version 顶到 1)--
+ verified(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"personality\":\"乖\"}"),
+ "PATCH", "/api/v1/pets/{petId}", 200);
+ verifiedError(patch("/api/v1/pets/{id}", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"personality\":\"皮\"}"),
+ "PATCH", "/api/v1/pets/{petId}", 409, 40902);
+
+ // -- 疫苗:40904 重复剂次、42201 状态-日期规则、PATCH 400/409/422 --
+ verified(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"rule","doseNo":1,
+ "status":"completed","administeredOn":"2026-08-01"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 201);
+ verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"rule","doseNo":1,
+ "status":"completed","administeredOn":"2026-08-02"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 409, 40904);
+ verifiedError(post("/api/v1/pets/{id}/vaccinations", petId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"rule","doseNo":2,"status":"scheduled"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 422, 42201);
+
+ String scheduled = verified(post("/api/v1/pets/{id}/vaccinations", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"rule","doseNo":3,
+ "status":"scheduled","plannedOn":"2026-10-01"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 201);
+ String vaccinationId = JsonPath.read(scheduled, "$.data.id");
+ verifiedError(patch("/api/v1/vaccinations/{id}", vaccinationId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"notes\":\"缺版本\"}"),
+ "PATCH", "/api/v1/vaccinations/{vaccinationId}", 400, 40000);
+ verified(patch("/api/v1/vaccinations/{id}", vaccinationId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"notes\":\"第一次\"}"),
+ "PATCH", "/api/v1/vaccinations/{vaccinationId}", 200);
+ verifiedError(patch("/api/v1/vaccinations/{id}", vaccinationId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"notes\":\"过期版本\"}"),
+ "PATCH", "/api/v1/vaccinations/{vaccinationId}", 409, 40902);
+ // completed 为终态:completed→cancelled 拒绝
+ verifiedError(patch("/api/v1/vaccinations/{id}",
+ (String) JsonPath.read(
+ verified(post("/api/v1/pets/{id}/vaccinations", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"vaccineId":"%s","seriesKey":"rule","doseNo":4,
+ "status":"completed","administeredOn":"2026-08-03"}
+ """.formatted(vaccineId)),
+ "POST", "/api/v1/pets/{petId}/vaccinations", 201),
+ "$.data.id"))
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"status\":\"cancelled\"}"),
+ "PATCH", "/api/v1/vaccinations/{vaccinationId}", 422, 42201);
+
+ // -- 健康事件 PATCH:400 缺版本、409 过期版本 --
+ String event = verified(post("/api/v1/pets/{id}/health-events", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"eventType":"note","occurredAt":"2026-09-08T10:00:00Z","title":"规则事件"}
+ """),
+ "POST", "/api/v1/pets/{petId}/health-events", 201);
+ String eventId = JsonPath.read(event, "$.data.id");
+ verifiedError(patch("/api/v1/health-events/{id}", eventId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"title\":\"缺版本\"}"),
+ "PATCH", "/api/v1/health-events/{eventId}", 400, 40000);
+ verified(patch("/api/v1/health-events/{id}", eventId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"title\":\"第一次改\"}"),
+ "PATCH", "/api/v1/health-events/{eventId}", 200);
+ verifiedError(patch("/api/v1/health-events/{id}", eventId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"title\":\"过期版本\"}"),
+ "PATCH", "/api/v1/health-events/{eventId}", 409, 40902);
+
+ // -- 提醒 PATCH:400 缺 status、422 completed 缺 completedAt --
+ String reminder = verified(post("/api/v1/pets/{id}/care-reminders", petId)
+ .header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"reminderType":"medication","title":"规则提醒","dueAt":"2026-10-01T09:00:00Z"}
+ """),
+ "POST", "/api/v1/pets/{petId}/care-reminders", 201);
+ String reminderId = JsonPath.read(reminder, "$.data.id");
+ verifiedError(patch("/api/v1/care-reminders/{id}", reminderId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON).content("{}"),
+ "PATCH", "/api/v1/care-reminders/{reminderId}", 400, 40000);
+ verifiedError(patch("/api/v1/care-reminders/{id}", reminderId).header(AUTH, bearer(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"status\":\"completed\"}"),
+ "PATCH", "/api/v1/care-reminders/{reminderId}", 422, 42202);
+ }
+
+ // ---- 快照与覆盖门禁 -------------------------------------------------
+
+ /**
+ * 冻结快照守卫:契约变更时(doc 仓 openapi.yaml 升版),必须同步复制新快照
+ * 并更新这里的期望值——忘记同步会在 CI 立即变红,而不是默默对着旧契约测试。
+ */
+ @Test
+ @Order(98)
+ void frozenSnapshotIsTheExpectedContractVersion() {
+ assertThat(CONTRACT.version()).isEqualTo("1.2.0");
+ assertThat(CONTRACT.paths()).hasSize(18);
+ assertThat(CONTRACT.operations()).hasSize(24);
+ assertThat(CONTRACT.schemas()).hasSize(45);
+ assertThat(CONTRACT.operationsTagged(Set.of("pets", "dictionaries", "health-records")))
+ .containsExactlyInAnyOrderElementsOf(PETS_OPERATIONS);
+ }
+
+ /**
+ * 全矩阵覆盖门禁:pets 域 18 个操作声明的每个 (操作, 状态码) 都必须被前面的
+ * 测试真实触发并通过契约校验。唯一豁免:照护提醒 PATCH 的 409(并发守卫
+ * 落空,单线程测试无法确定性构造,行为语义由并发一致性设计文档背书)。
+ */
+ @Test
+ @Order(99)
+ void everyDeclaredResponseCellIsExercised() {
+ Set exempt = Set.of("PATCH /api/v1/care-reminders/{reminderId} 409");
+ List missing = new ArrayList<>();
+ for (String op : PETS_OPERATIONS) {
+ for (int status : CONTRACT.responseStatuses(op)) {
+ String cell = op + " " + status;
+ if (!exempt.contains(cell) && !COVERED.contains(cell)) {
+ missing.add(cell);
+ }
+ }
+ }
+ assertThat(missing).as("契约声明但未被契约测试触发的响应单元格").isEmpty();
+ }
+}
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractValidator.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractValidator.java
new file mode 100644
index 0000000..e46a18e
--- /dev/null
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/ContractValidator.java
@@ -0,0 +1,229 @@
+package com.patbond.patbond.pet.contract;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.math.BigDecimal;
+import java.time.LocalDate;
+import java.time.OffsetDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.ArrayList;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+
+import static com.patbond.patbond.pet.contract.OpenApiContract.cast;
+import static com.patbond.patbond.pet.contract.OpenApiContract.list;
+import static com.patbond.patbond.pet.contract.OpenApiContract.map;
+
+/**
+ * Validates an actual HTTP response against the frozen contract, strictly:
+ *
+ *
+ * the operation and the status must be declared;
+ * required fields must be present; a null value needs {@code nullable};
+ * fields the schema does not declare are rejected (this is what catches
+ * a renamed or newly leaked field — plain OpenAPI semantics would allow
+ * extra properties, but the frozen contract is "exactly these fields");
+ * types, enum membership, uuid / date-time / date formats and
+ * min/max(Length) bounds are checked.
+ *
+ *
+ * Behavioural semantics (state machines, anti-enumeration, permission logic)
+ * stay with the existing integration tests — this class only pins structure.
+ */
+final class ContractValidator {
+
+ private static final ObjectMapper MAPPER = new ObjectMapper();
+
+ private final OpenApiContract contract;
+
+ ContractValidator(OpenApiContract contract) {
+ this.contract = contract;
+ }
+
+ /**
+ * @return drift findings, empty when the response conforms; each entry is
+ * a human-readable "where: what" line
+ */
+ List validateResponse(String method, String pathTemplate, int status, String body) {
+ List errors = new ArrayList<>();
+ String opKey = method + " " + pathTemplate;
+ Map op = contract.operation(opKey);
+ if (op == null) {
+ errors.add("契约未声明该操作: " + opKey);
+ return errors;
+ }
+ Object respNode = map(op, "responses").get(String.valueOf(status));
+ if (respNode == null) {
+ errors.add("契约未为 " + opKey + " 声明状态码 " + status);
+ return errors;
+ }
+ Map content = map(contract.resolve(cast(respNode)), "content");
+ if (content == null) {
+ return errors; // response declared without a body
+ }
+ Map schema = map(map(content, "application/json"), "schema");
+ if (schema == null) {
+ errors.add(opKey + " " + status + ": 契约声明了 content 但无 application/json schema");
+ return errors;
+ }
+ JsonNode node;
+ try {
+ node = MAPPER.readTree(body);
+ } catch (JsonProcessingException e) {
+ errors.add(opKey + " " + status + ": 响应体不是合法 JSON: " + e.getOriginalMessage());
+ return errors;
+ }
+ validate(schema, node, "$", errors);
+ return errors;
+ }
+
+ private void validate(Map rawSchema, JsonNode node, String loc, List errors) {
+ Map schema = contract.resolve(rawSchema);
+ if (node == null || node.isMissingNode()) {
+ errors.add(loc + ": 字段缺失");
+ return;
+ }
+ if (node.isNull()) {
+ if (!Boolean.TRUE.equals(schema.get("nullable"))) {
+ errors.add(loc + ": 为 null,但契约未声明 nullable");
+ }
+ return;
+ }
+ List allowed = list(schema, "enum");
+ if (allowed != null && !enumMatches(allowed, node)) {
+ errors.add(loc + ": 值 " + node + " 不在契约枚举 " + allowed + " 内");
+ }
+ String type = (String) schema.get("type");
+ if (type == null) {
+ type = schema.containsKey("properties") ? "object" : null;
+ }
+ if (type == null) {
+ return;
+ }
+ switch (type) {
+ case "object" -> validateObject(schema, node, loc, errors);
+ case "array" -> validateArray(schema, node, loc, errors);
+ case "string" -> validateString(schema, node, loc, errors);
+ case "integer" -> {
+ if (!node.isIntegralNumber()) {
+ errors.add(loc + ": 应为 integer,实际 " + node.getNodeType() + " " + node);
+ } else {
+ checkRange(schema, node.decimalValue(), loc, errors);
+ }
+ }
+ case "number" -> {
+ if (!node.isNumber()) {
+ errors.add(loc + ": 应为 number,实际 " + node.getNodeType() + " " + node);
+ } else {
+ checkRange(schema, node.decimalValue(), loc, errors);
+ }
+ }
+ case "boolean" -> {
+ if (!node.isBoolean()) {
+ errors.add(loc + ": 应为 boolean,实际 " + node.getNodeType() + " " + node);
+ }
+ }
+ default -> errors.add(loc + ": 契约测试不支持的 type " + type);
+ }
+ }
+
+ private void validateObject(Map schema, JsonNode node, String loc, List errors) {
+ if (!node.isObject()) {
+ errors.add(loc + ": 应为 object,实际 " + node.getNodeType());
+ return;
+ }
+ Map props = map(schema, "properties");
+ List required = list(schema, "required");
+ if (required != null) {
+ for (Object r : required) {
+ if (!node.has((String) r)) {
+ errors.add(loc + "." + r + ": 契约必填字段缺失");
+ }
+ }
+ }
+ Object additional = schema.get("additionalProperties");
+ boolean open = Boolean.TRUE.equals(additional) || additional instanceof Map;
+ Iterator> fields = node.fields();
+ while (fields.hasNext()) {
+ Map.Entry field = fields.next();
+ Map propSchema = props == null ? null : cast(props.get(field.getKey()));
+ if (propSchema != null) {
+ validate(propSchema, field.getValue(), loc + "." + field.getKey(), errors);
+ } else if (!open) {
+ errors.add(loc + "." + field.getKey() + ": 契约未声明的字段(结构漂移)");
+ }
+ }
+ }
+
+ private void validateArray(Map schema, JsonNode node, String loc, List errors) {
+ if (!node.isArray()) {
+ errors.add(loc + ": 应为 array,实际 " + node.getNodeType());
+ return;
+ }
+ Map items = map(schema, "items");
+ if (items == null) {
+ return;
+ }
+ int i = 0;
+ for (JsonNode element : node) {
+ validate(items, element, loc + "[" + i++ + "]", errors);
+ }
+ }
+
+ private void validateString(Map schema, JsonNode node, String loc, List errors) {
+ if (!node.isTextual()) {
+ errors.add(loc + ": 应为 string,实际 " + node.getNodeType() + " " + node);
+ return;
+ }
+ String value = node.asText();
+ String format = (String) schema.get("format");
+ if (format != null) {
+ try {
+ switch (format) {
+ case "uuid" -> {
+ if (value.length() != 36) {
+ throw new IllegalArgumentException("非规范 UUID 长度");
+ }
+ java.util.UUID.fromString(value);
+ }
+ case "date-time" -> OffsetDateTime.parse(value);
+ case "date" -> LocalDate.parse(value);
+ default -> { /* password 等纯标注格式不校验 */ }
+ }
+ } catch (IllegalArgumentException | DateTimeParseException e) {
+ errors.add(loc + ": \"" + value + "\" 不符合 format=" + format);
+ }
+ }
+ if (schema.get("minLength") instanceof Number min && value.length() < min.intValue()) {
+ errors.add(loc + ": 长度 " + value.length() + " 小于契约 minLength " + min);
+ }
+ if (schema.get("maxLength") instanceof Number max && value.length() > max.intValue()) {
+ errors.add(loc + ": 长度 " + value.length() + " 大于契约 maxLength " + max);
+ }
+ }
+
+ private static void checkRange(Map schema, BigDecimal value, String loc, List errors) {
+ if (schema.get("minimum") instanceof Number min
+ && value.compareTo(new BigDecimal(min.toString())) < 0) {
+ errors.add(loc + ": 值 " + value + " 小于契约 minimum " + min);
+ }
+ if (schema.get("maximum") instanceof Number max
+ && value.compareTo(new BigDecimal(max.toString())) > 0) {
+ errors.add(loc + ": 值 " + value + " 大于契约 maximum " + max);
+ }
+ }
+
+ private static boolean enumMatches(List allowed, JsonNode node) {
+ if (node.isTextual()) {
+ return allowed.contains(node.asText());
+ }
+ if (node.isIntegralNumber()) {
+ long v = node.longValue();
+ return allowed.stream().anyMatch(a -> a instanceof Number n && n.longValue() == v);
+ }
+ return false;
+ }
+}
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java
new file mode 100644
index 0000000..93afb9c
--- /dev/null
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/contract/OpenApiContract.java
@@ -0,0 +1,145 @@
+package com.patbond.patbond.pet.contract;
+
+import org.yaml.snakeyaml.Yaml;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * The frozen v1.2.0 OpenAPI contract, loaded from the test-resource snapshot
+ * {@code /contract/openapi-v1.2.0.yaml}.
+ *
+ * Sync discipline (T2-09) : the canonical contract lives in the doc
+ * repo at {@code docs/api/openapi.yaml}; this snapshot is a byte-identical
+ * copy taken at freeze time. Whenever the canonical contract changes, copy it
+ * here under the new version's file name and update
+ * {@link ContractConformanceTest} (expected version + snapshot counts). The
+ * guard test on {@code info.version} makes a forgotten sync fail loudly in CI
+ * instead of silently testing against a stale contract.
+ *
+ *
Only the subset of OpenAPI 3.0 this contract actually uses is supported:
+ * local {@code #/} refs, plain types, {@code nullable}, {@code enum},
+ * {@code required}, {@code properties}, {@code items} — no allOf/oneOf.
+ */
+final class OpenApiContract {
+
+ static final String RESOURCE = "/contract/openapi-v1.2.0.yaml";
+
+ private static final Set HTTP_METHODS =
+ Set.of("get", "put", "post", "delete", "options", "head", "patch", "trace");
+
+ private final Map root;
+
+ private OpenApiContract(Map root) {
+ this.root = root;
+ }
+
+ static OpenApiContract load() {
+ try (InputStream in = Objects.requireNonNull(
+ OpenApiContract.class.getResourceAsStream(RESOURCE),
+ "契约快照缺失: " + RESOURCE)) {
+ return new OpenApiContract(new Yaml().load(in));
+ } catch (IOException e) {
+ throw new UncheckedIOException(e);
+ }
+ }
+
+ String version() {
+ return (String) map(root, "info").get("version");
+ }
+
+ Map paths() {
+ return map(root, "paths");
+ }
+
+ Map schemas() {
+ return map(map(root, "components"), "schemas");
+ }
+
+ /** All declared operations as "METHOD pathTemplate" (insertion order). */
+ Set operations() {
+ Set ops = new LinkedHashSet<>();
+ paths().forEach((path, item) -> cast(item).forEach((method, op) -> {
+ if (HTTP_METHODS.contains(method)) {
+ ops.add(method.toUpperCase(Locale.ROOT) + " " + path);
+ }
+ }));
+ return ops;
+ }
+
+ /** Operations whose first tag is in {@code tags}, as "METHOD pathTemplate". */
+ Set operationsTagged(Set tags) {
+ Set ops = new LinkedHashSet<>();
+ for (String key : operations()) {
+ List opTags = list(operation(key), "tags");
+ if (opTags != null && opTags.stream().anyMatch(tags::contains)) {
+ ops.add(key);
+ }
+ }
+ return ops;
+ }
+
+ /** Declared response statuses of an operation, as ints. */
+ Set responseStatuses(String operationKey) {
+ Set statuses = new LinkedHashSet<>();
+ map(operation(operationKey), "responses")
+ .keySet().forEach(s -> statuses.add(Integer.parseInt(s)));
+ return statuses;
+ }
+
+ /** The single 2xx status the operation declares. */
+ int successStatus(String operationKey) {
+ return responseStatuses(operationKey).stream()
+ .filter(s -> s >= 200 && s < 300)
+ .reduce((a, b) -> {
+ throw new IllegalStateException("多个 2xx 响应: " + operationKey);
+ })
+ .orElseThrow(() -> new IllegalStateException("无 2xx 响应: " + operationKey));
+ }
+
+ /** Operation object for "METHOD pathTemplate", or null when undeclared. */
+ Map operation(String operationKey) {
+ String[] parts = operationKey.split(" ", 2);
+ Map pathItem = map(paths(), parts[1]);
+ return pathItem == null ? null : map(pathItem, parts[0].toLowerCase(Locale.ROOT));
+ }
+
+ /** Follows local $ref chains; non-ref maps come back unchanged. */
+ Map resolve(Map node) {
+ while (node != null && node.get("$ref") instanceof String ref) {
+ if (!ref.startsWith("#/")) {
+ throw new IllegalStateException("仅支持本地 $ref: " + ref);
+ }
+ Map cur = root;
+ for (String seg : ref.substring(2).split("/")) {
+ cur = map(cur, seg);
+ if (cur == null) {
+ throw new IllegalStateException("$ref 指向不存在的节点: " + ref);
+ }
+ }
+ node = cur;
+ }
+ return node;
+ }
+
+ @SuppressWarnings("unchecked")
+ static Map cast(Object o) {
+ return (Map) o;
+ }
+
+ static Map map(Map m, String key) {
+ return m == null ? null : cast(m.get(key));
+ }
+
+ @SuppressWarnings("unchecked")
+ static List list(Map m, String key) {
+ return m == null ? null : (List) m.get(key);
+ }
+}
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
index 0d98cbe..c17d548 100644
--- 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
@@ -43,7 +43,7 @@ class CareReminderIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"%s","species":"cat","customBreedName":"狸花猫"}
+ {"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java
index 1a2410d..2720d06 100644
--- a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/HealthEventIntegrationTest.java
@@ -41,7 +41,7 @@ class HealthEventIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"%s","species":"cat","customBreedName":"狸花猫"}
+ {"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java
index d68fc94..be01702 100644
--- a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java
@@ -35,7 +35,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"%s","species":"cat","customBreedName":"狸花猫"}
+ {"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
@@ -147,7 +147,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"小白","species":"dog","breedId":"%s","customBreedName":"串串"}
+ {"name":"小白","species":"dog","sex":"male","breedId":"%s","customBreedName":"串串"}
""".formatted(anyBreedId("dog"))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
@@ -159,7 +159,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"小白\",\"species\":\"dog\"}"))
+ .content("{\"name\":\"小白\",\"species\":\"dog\",\"sex\":\"male\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@@ -171,7 +171,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"错配","species":"dog","breedId":"%s"}
+ {"name":"错配","species":"dog","sex":"male","breedId":"%s"}
""".formatted(anyBreedId("cat"))))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
@@ -183,7 +183,7 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(user))
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"龙\",\"species\":\"dragon\",\"customBreedName\":\"东方龙\"}"))
+ .content("{\"name\":\"龙\",\"species\":\"dragon\",\"sex\":\"male\",\"customBreedName\":\"东方龙\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
@@ -280,14 +280,14 @@ class PetCrudIntegrationTest extends PetIntegrationTestSupport {
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"芯片一号\",\"species\":\"cat\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
+ .content("{\"name\":\"芯片一号\",\"species\":\"cat\",\"sex\":\"female\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
.andExpect(status().isCreated());
// uq_pets_microchip:重复登记同一芯片号是明确业务冲突,而非 500
mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
- .content("{\"name\":\"芯片二号\",\"species\":\"cat\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
+ .content("{\"name\":\"芯片二号\",\"species\":\"cat\",\"sex\":\"female\",\"customBreedName\":\"狸花\",\"microchipNo\":\"CHIP-DUP-42\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40903));
}
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetSummaryIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetSummaryIntegrationTest.java
index 0b469e4..cf92eaf 100644
--- a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetSummaryIntegrationTest.java
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetSummaryIntegrationTest.java
@@ -43,7 +43,7 @@ class PetSummaryIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"%s","species":"cat","customBreedName":"狸花猫"}
+ {"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/VaccinationIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/VaccinationIntegrationTest.java
index cbb5fa4..7fa1fbc 100644
--- a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/VaccinationIntegrationTest.java
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/VaccinationIntegrationTest.java
@@ -40,7 +40,7 @@ class VaccinationIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"%s","species":"cat","customBreedName":"狸花猫"}
+ {"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java
index e601928..5f35bd1 100644
--- a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/WeightIntegrationTest.java
@@ -38,7 +38,7 @@ class WeightIntegrationTest extends PetIntegrationTestSupport {
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
- {"name":"%s","species":"cat","customBreedName":"狸花猫"}
+ {"name":"%s","species":"cat","customBreedName":"狸花猫","sex":"female"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
diff --git a/patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml b/patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml
new file mode 100644
index 0000000..1ecb5a9
--- /dev/null
+++ b/patbond-pet/src/test/resources/contract/openapi-v1.2.0.yaml
@@ -0,0 +1,2385 @@
+openapi: 3.0.3
+info:
+ title: Patbond API — Auth / Me / Events / Pets(公开契约)
+ version: 1.2.0
+ description: |
+ Patbond 第一迭代「真实登录纵切」公开契约(冻结稿的正式化,字段与草案零偏差),
+ 1.1.0 追加埋点上报端点 `POST /api/v1/events`(M2 第一波契约补录,以实现实测行为为准)。
+ **1.2.0 M2 契约冻结:pets 域 12 路径**(宠物 CRUD、品种/疫苗目录、体重记录、疫苗记录、
+ 健康事件、照护提醒、档案聚合摘要)按第二波已定型实现合入
+ (iteration-2 报告 13/16/17/18 定型表;冻结报告见 iteration-2/19)。
+
+ ## 通用约定(development-plan 第 6 节)
+ - 公开接口统一前缀 `/api/v1`;JSON 字段一律 `camelCase`;资源 ID 为 UUID 字符串。
+ - 所有时间字段为 ISO 8601 且带时区偏移(如 `2026-09-04T04:05:06.789Z`);
+ 纯日期字段(生日、接种日期等)为 `YYYY-MM-DD`。
+ - 统一响应信封 `{"code": 0, "message": "success", "data": …}`;错误同时携带正确的
+ HTTP 状态码与稳定业务码,业务码永不复用或改号。
+ - `/internal/**` 为服务间接口,不属于本公开契约,需 `X-Internal-Token` 服务凭证,
+ 未携带或错误一律 401。
+
+ ## 错误码表
+ | 业务码 | HTTP | 场景 |
+ | --- | --- | --- |
+ | 0 | 200 | 成功 |
+ | 40000 | 400 | 参数校验失败(含 JSON 不可解析;message 为首个字段错误) |
+ | 40100 | 401 | 用户名或密码错误 |
+ | 40101 | 401 | access token 无效或过期(缺失、伪造、篡改、过期) |
+ | 40102 | 401 | refresh token 已失效或被重用(未知、过期、已轮换、已退出、家族已撤销) |
+ | 40300 | 403 | PET_ACCESS_DENIED:对可见宠物无相应操作权限(viewer 写记录、caregiver 改宠物档案) |
+ | 40400 | 404 | 资源不存在 |
+ | 40401 | 404 | PET_NOT_FOUND:宠物不存在、已软删除或调用者与宠物无关系(防枚举,三种情况响应完全一致) |
+ | 40402 | 404 | RECORD_NOT_FOUND:顶层记录路径下记录不存在或所属宠物对调用者不可见(记录级防枚举,两种情况响应完全一致) |
+ | 40900 | 409 | 用户名已存在(大小写不敏感) |
+ | 40901 | 409 | 手机号已被使用 |
+ | 40902 | 409 | VERSION_CONFLICT:乐观锁版本冲突(PATCH 提交的 version 过期);照护提醒流转的状态守卫落空复用此码 |
+ | 40903 | 409 | MICROCHIP_EXISTS:芯片号已被登记(uq_pets_microchip,跨用户唯一) |
+ | 40904 | 409 | VACCINATION_DOSE_EXISTS:同宠物同疫苗同系列同剂次已有非 cancelled 记录(uq_pet_vaccination_dose) |
+ | 42201 | 422 | VACCINATION_RULE_VIOLATION:疫苗状态机非法迁移或状态-日期规则违反 |
+ | 42202 | 422 | REMINDER_RULE_VIOLATION:提醒状态机非法迁移或 completed-completedAt 一致性违反 |
+ | 42300 | 423 | 登录失败次数过多,账号已临时锁定(见下) |
+ | 50000 | 500 | 服务器内部错误 |
+ | 50300 | 503 | 依赖服务暂不可用 |
+
+ ## 会话模型(ADR-003,数值均为服务端配置项)
+ - access token:JWT(RS256),有效期 15 分钟;由资源服务用公钥本地验签。
+ - refresh token:不透明随机串,有效期 30 天;**每次刷新即轮换**,旧值立即失效。
+ - 已轮换/已失效的 refresh token 再次被使用时,判定为重用,**整个 token family
+ (该登录会话链)全部撤销**,持有者需重新登录。
+ - 允许多设备并行会话;退出仅撤销当前会话(由所提交的 refreshToken 标识),
+ 其他设备不受影响。已签发的 access token 在剩余有效期内仍可用。
+ - 登录失败限制:同一账号在 15 分钟窗口内密码错误累计 5 次(配置项),账号锁定
+ 15 分钟;锁定期间即使密码正确也返回 423/42300;一次成功登录重置计数窗口。
+
+ ## Pets 域约定(M2 冻结,iteration-2 报告 13/16/17/18 定型)
+ - **鉴权**:pets 域全部端点强制 Bearer 鉴权,无匿名端点。
+ - **权限模型(ADR-015:owner/caregiver/viewer 三角色,pet_owners 表)**,操作分三档:
+ - `READ`——三角色皆可:宠物详情/列表、各记录列表、档案摘要;
+ - `WRITE`——owner + caregiver:体重/疫苗/健康事件/提醒的 POST 与 PATCH;
+ - `MANAGE`——仅 owner:宠物档案 PATCH(含状态流转)。
+ 权限每请求实时查库、无缓存:撤销照护关系立即生效。
+ - **防枚举语义**:宠物不存在、已软删除、调用者与宠物无 pet_owners 关系三种情况
+ 响应完全一致(404/40401),GET 与写操作一致适用;顶层记录路径下「记录不存在」与
+ 「记录所属宠物对调用者不可见」响应完全一致(404/40402)。403/40300 只可能发给
+ 「对宠物可见但角色不覆盖该操作」的调用者,不泄露新信息。
+ - **PATCH 一律部分更新**:缺席字段不变;**M2 不支持将可选字段清空回 null**
+ (null-vs-absent 歧义挡在契约外)。pets / vaccinations / health-events 的 PATCH
+ 必须携带 `version` 乐观锁字段(缺失 400/40000,过期 409/40902,比对通过才写入并 +1)。
+ - **子资源 PATCH 走顶层短路径**(`/api/v1/vaccinations/{id}` 等):记录 ID 全局唯一
+ (UUID),短路径避免 path petId 与记录归属不一致的报错歧义。
+ - **创建操作返回 201**(pets 域新约定;既有 auth 端点维持 200 不追改)。
+ - **cursor 分页正典(全 API 唯一分页形态)**:响应 `data: {items, nextCursor, hasMore}`;
+ `limit` 1~100 缺省 20;`cursor` 传上一页返回的 `nextCursor`(不透明字符串,客户端不得
+ 解析),首页不传;`hasMore=false` 时 `nextCursor` 恒为 null。体重与健康事件列表采用;
+ 疫苗列表(`series_key, dose_no, created_at, id` 排序)与提醒列表(`due_at ASC, id`
+ 排序 + `status` 过滤)量级小,不分页。
+ - **幂等(可选 `Idempotency-Key` 头,≤255 字符)**:weights / vaccinations /
+ health-events / care-reminders 四个 POST 支持。键按「调用者 × 宠物 × 资源」隔离,
+ 两个用户的同名键不互斥;同键重试返回首次创建的记录(同样 201);**不比对请求体**
+ (客户端每次逻辑提交应换新键,建议 UUID);键永久幂等(无 TTL)。不带键则无幂等
+ 语义,重复提交各自成行(疫苗由剂次唯一约束兜底 40904)。pets 的写接口不用幂等键,
+ 重试安全由乐观锁与唯一约束兜底。
+ - **ADR-010 裁剪**:`avatarAssetId`、`certificateAssetId`、`providerId`、
+ `providerNameSnapshot`、`bookingId` 等字段整体不出现(响应与请求皆无),M5+ 按
+ 「新增可选字段」纯增量补入。软删除端点不在 M2 契约(D2-7:首版仅归档
+ `status=archived`);`DELETE /api/v1/pets/{petId}` 未收录。
+
+servers:
+ - url: http://127.0.0.1:8081
+ description: patbond-auth(本地开发,/api/v1/auth/**)
+ - url: http://127.0.0.1:8082
+ description: patbond-user(本地开发,/api/v1/me、/api/v1/events)
+ - url: http://127.0.0.1:8083
+ description: patbond-pet(本地开发,pets 域全部端点)
+
+tags:
+ - name: auth
+ description: 注册 / 登录 / 刷新 / 退出(patbond-auth)
+ - name: user
+ description: 当前用户(patbond-user)
+ - name: analytics
+ description: 产品事件批量上报(patbond-user)
+ - name: pets
+ description: 宠物档案 CRUD(patbond-pet)
+ - name: dictionaries
+ description: 品种与疫苗目录(只读字典,patbond-pet)
+ - name: health-records
+ description: 体重、疫苗、健康事件、照护提醒、档案摘要(patbond-pet)
+
+paths:
+ /api/v1/auth/register:
+ post:
+ tags: [auth]
+ summary: 注册并创建会话
+ operationId: register
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RegisterRequest'
+ responses:
+ '200':
+ description: 注册成功,返回令牌对
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthTokenEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '409':
+ description: 用户名或手机号已被占用(code 40900 / 40901)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ usernameTaken:
+ value: { code: 40900, message: 用户名已存在, data: null }
+ phoneTaken:
+ value: { code: 40901, message: 手机号已被使用, data: null }
+
+ /api/v1/auth/login:
+ post:
+ tags: [auth]
+ summary: 登录并创建会话
+ description: 多设备并行:每次登录开启独立会话(独立 token family),互不影响。
+ operationId: login
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LoginRequest'
+ responses:
+ '200':
+ description: 登录成功,返回令牌对
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthTokenEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ description: 用户名或密码错误(code 40100)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ invalidCredentials:
+ value: { code: 40100, message: 用户名或密码错误, data: null }
+ '423':
+ description: 登录失败次数过多,账号临时锁定(code 42300)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ locked:
+ value: { code: 42300, message: 登录失败次数过多,账号已临时锁定, data: null }
+
+ /api/v1/auth/refresh:
+ post:
+ tags: [auth]
+ summary: 轮换 refresh token
+ description: |
+ 成功时返回全新令牌对,旧 refreshToken 立即失效(轮换)。提交已轮换或已失效的
+ refreshToken 返回 401/40102,且视为重用攻击:该 token family 的全部会话被撤销。
+ operationId: refresh
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/RefreshRequest'
+ responses:
+ '200':
+ description: 轮换成功,返回新的令牌对
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/AuthTokenEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ description: refresh token 已失效或被重用(code 40102)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ invalidated:
+ value: { code: 40102, message: refresh token 已失效或被重用, data: null }
+
+ /api/v1/auth/logout:
+ post:
+ tags: [auth]
+ summary: 退出(撤销当前会话)
+ description: |
+ 撤销 body 中 refreshToken 对应的会话;其他设备的会话不受影响(ADR-003)。
+ 需携带有效的 access token(从中取用户身份,防止跨账号撤销)。幂等:对已
+ 失效的 refreshToken 仍返回成功。
+ operationId: logout
+ security:
+ - bearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/LogoutRequest'
+ responses:
+ '200':
+ description: 已退出
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VoidEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+
+ /api/v1/me:
+ get:
+ tags: [user]
+ summary: 当前用户资料
+ description: 由 patbond-user 提供;access token 以 RS256 公钥本地验签,无需经过 auth 服务。
+ operationId: me
+ security:
+ - bearerAuth: []
+ responses:
+ '200':
+ description: 当前用户
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MeEnvelope'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '404':
+ description: 用户不存在(如已注销;code 40400)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+
+ /api/v1/events:
+ post:
+ tags: [analytics]
+ summary: 批量上报产品事件
+ description: |
+ 埋点批量上报(事件字典见迭代报告 13 与第二迭代 06 号报告)。
+ `/api/v1` 下唯一允许匿名调用的写端点:`Authorization: Bearer` 可选——
+ 缺失时按匿名处理放行;**一旦携带则完整校验**,无效 token 仍返回 401/40101。
+
+ - 单批 1–50 条;条数越界、字段校验失败或 JSON 不可解析时**整批** 400/40000。
+ - 通过请求级校验的批次一律返回 **202**,`data.results` 与请求 `events`
+ 等长且按原顺序逐条给出结果(accepted / duplicate / rejected);
+ 客户端收到 202 即可删除本地队列中该批全部事件(rejected 条目不重试)。
+ - 幂等以每条事件的 `eventId` 去重(落库 ON CONFLICT DO NOTHING),重复条目
+ 返回 `duplicate`(视为成功);**不使用** `Idempotency-Key` 请求头。
+ - 单条拒绝原因:事件名不在字典(`unknown_event_name`);已认证请求中事件
+ `userId` 与 token subject 不一致(`identity_mismatch`);props 的键命中
+ 隐私红线模式 password/token/secret/phone/mobile/email/credential/idfa/gaid
+ (`forbidden_field`);落库失败(`schema_invalid`)。
+ - props 中字典白名单之外的键**剥离后入库**(事件保留,不拒绝)。
+ - 匿名请求中事件携带的 `userId` 原样落库(分析归因数据,不参与权限判断)。
+ operationId: trackEvents
+ security:
+ - {} # 匿名(注册/登录前)
+ - bearerAuth: [] # 登录后携带未过期 access token
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TrackEventsRequest'
+ responses:
+ '202':
+ description: 批次已受理,逐条结果见 data.results(与请求 events 等长、原顺序)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/TrackEventsEnvelope'
+ '400':
+ description: 整批拒绝——JSON 不可解析、events 为空或超过 50 条、单条事件字段校验失败(code 40000)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ emptyBatch:
+ value: { code: 40000, message: events 长度必须在 1-50 之间, data: null }
+ '401':
+ description: 携带了 Authorization 头但 access token 无效或过期(code 40101);不携带该头则按匿名放行,不会返回 401
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ tokenInvalid:
+ value: { code: 40101, message: token 无效或过期, data: null }
+
+ # ======================================================================
+ # Pets 域(M2 冻结,12 路径;定型依据:iteration-2 报告 13/16/17/18)
+ # ======================================================================
+
+ /api/v1/pets:
+ get:
+ tags: [pets]
+ summary: 当前用户可见宠物列表
+ description: |
+ 返回当前用户拥有任意角色(owner/caregiver/viewer)的宠物,按 `created_at DESC`
+ 排序,**不分页**(单人宠物量小)。每项含 `myRole`(调用者对该宠物的角色)。
+ 列表按调用者的 pet_owners 关系行过滤,天然隔离他人宠物。
+ operationId: listPets
+ security:
+ - bearerAuth: []
+ responses:
+ '200':
+ description: 宠物列表(created_at DESC,不分页)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PetListEnvelope'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ post:
+ tags: [pets]
+ summary: 创建宠物
+ description: |
+ 创建宠物,返回 **201** 与完整 Pet。创建者自动成为 primary owner
+ (pet_owners 写入 role=owner、is_primary=true,与建宠同事务)。
+
+ - 品种:`breedId` 与 `customBreedName` 必须**二选一且互斥**(双填、双空、
+ 品种与物种错配、品种不存在或已停用均为 400/40000,message 带具体原因)。
+ - 芯片号跨用户唯一(uq_pets_microchip):已被登记返回 409/40903。
+ - 不使用 `Idempotency-Key`:重试安全由唯一约束兜底(带芯片号重发得 40903)。
+ operationId: createPet
+ security:
+ - bearerAuth: []
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreatePetRequest'
+ responses:
+ '201':
+ description: 创建成功,返回完整 Pet(myRole 恒为 owner)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PetEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '409':
+ description: 芯片号已被登记(code 40903)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ microchipExists:
+ value: { code: 40903, message: 芯片号已被登记, data: null }
+
+ /api/v1/pets/{petId}:
+ get:
+ tags: [pets]
+ summary: 宠物详情
+ description: |
+ 权限档:READ(三角色皆可)。返回宠物详情及 `myRole`(调用者对该宠物的角色,
+ 客户端据此显隐写入口)。
+ operationId: getPet
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ responses:
+ '200':
+ description: 宠物详情(含 myRole)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PetEnvelope'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+ patch:
+ tags: [pets]
+ summary: 更新宠物档案
+ description: |
+ 权限档:MANAGE(**仅 owner**);caregiver/viewer 更新得 403/40300。
+
+ - 部分更新:缺席字段不变;**不支持将可选字段清空回 null**。
+ - 例外:品种对(`breedId`/`customBreedName`)**整体替换**——提交任一侧即替换
+ 整对,互斥校验同创建。
+ - `species` 不可改(创建即定,避免与品种配对失效,请求体不含该字段)。
+ - `status` 可迁移至 active/lost/deceased/archived;**`deleted` 不可经 PATCH
+ 设置**(400/40000,软删除留待专用端点,M2 契约不含)。
+ - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。
+ - 芯片号改为已被登记的值:409/40903。
+ operationId: updatePet
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdatePetRequest'
+ responses:
+ '200':
+ description: 更新成功,返回更新后完整 Pet
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PetEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+ '409':
+ description: 版本冲突(code 40902)或芯片号已被登记(code 40903)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ versionConflict:
+ value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
+ microchipExists:
+ value: { code: 40903, message: 芯片号已被登记, data: null }
+
+ /api/v1/breeds:
+ get:
+ tags: [dictionaries]
+ summary: 品种目录
+ description: |
+ 品种目录(只读字典,非用户数据,仅需 Bearer 鉴权、无用户级权限)。
+ 返回 enabled=true 的品种按 sort_order 排序,全量数组(种子约 30 行,不分页);
+ `?species=` 过滤,非法取值 400/40000。
+ operationId: listBreeds
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: species
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [dog, cat, other]
+ description: 过滤物种;不传则返回全部
+ responses:
+ '200':
+ description: 品种列表
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/BreedListEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+
+ /api/v1/pets/{petId}/weights:
+ get:
+ tags: [health-records]
+ summary: 体重记录列表
+ description: |
+ 权限档:READ。cursor 分页(分页正典形态 `{items, nextCursor, hasMore}`),
+ 按 `measured_at DESC, id DESC` 排序(与索引 ix_pet_weight_pet_measured 逐列对齐,
+ 同刻多条时 id 大者在前)。`limit` 越界或 `cursor` 无效:400/40000。
+ operationId: listWeights
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - $ref: '#/components/parameters/PageLimitParam'
+ - $ref: '#/components/parameters/PageCursorParam'
+ responses:
+ '200':
+ description: 体重记录分页结果
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WeightListEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+ post:
+ tags: [health-records]
+ summary: 添加体重记录
+ description: |
+ 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 WeightRecord。
+ 支持可选 `Idempotency-Key`(语义见 info 的「幂等」段)。体重记录 append-only、
+ 无乐观锁;同一时刻允许多条。`weightKg` 范围 (0, 500]、最多两位小数
+ (numeric(6,2)),违反 400/40000。
+ operationId: createWeight
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - $ref: '#/components/parameters/IdempotencyKeyHeader'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateWeightRequest'
+ responses:
+ '201':
+ description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/WeightEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+
+ /api/v1/vaccine-catalog:
+ get:
+ tags: [dictionaries]
+ summary: 疫苗目录
+ description: |
+ 疫苗目录(只读字典,非用户数据,仅需 Bearer 鉴权、无用户级权限)。
+ 返回 enabled=true 的疫苗(V4 种子 10 行),`ORDER BY species, name`,不分页;
+ `?species=` 过滤,非法取值 400/40000。
+ operationId: listVaccineCatalog
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: species
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [dog, cat, other]
+ description: 过滤物种;不传则返回全部
+ responses:
+ '200':
+ description: 疫苗目录列表
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VaccineCatalogListEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+
+ /api/v1/pets/{petId}/vaccinations:
+ get:
+ tags: [health-records]
+ summary: 疫苗记录列表
+ description: |
+ 权限档:READ。**不分页**(单宠疫苗量级为个位数~十位数),排序服务端定死:
+ `ORDER BY series_key, dose_no, created_at, id`,客户端按系列直接分组成卡。
+ 列表不过滤 status(含 cancelled 行,客户端自行按需过滤)。
+ operationId: listVaccinations
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ responses:
+ '200':
+ description: 疫苗记录列表(不分页,series_key/dose_no 排序)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VaccinationListEnvelope'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+ post:
+ tags: [health-records]
+ summary: 创建疫苗记录
+ description: |
+ 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 Vaccination。
+ 支持可选 `Idempotency-Key`。
+
+ - 创建状态仅 `scheduled` / `completed`(创建即 cancelled 无业务意义,400/40000)。
+ - 状态-日期规则(违反 422/42201):scheduled 必有 `plannedOn` 且不得带
+ `administeredOn`;completed 必有 `administeredOn`;`nextDueOn` 与
+ `administeredOn` 同时存在时须 `nextDueOn ≥ administeredOn`。
+ - 疫苗必须存在、enabled 且 species 与宠物一致(400/40000)。
+ - 同宠物同疫苗同系列同剂次的非 cancelled 记录唯一(uq_pet_vaccination_dose):
+ 重复 409/40904;cancel 后同剂次可重新登记。
+ operationId: createVaccination
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - $ref: '#/components/parameters/IdempotencyKeyHeader'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateVaccinationRequest'
+ responses:
+ '201':
+ description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VaccinationEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+ '409':
+ description: 同系列同剂次非 cancelled 记录已存在(code 40904)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ doseExists:
+ value: { code: 40904, message: 同系列同剂次记录已存在, data: null }
+ '422':
+ description: 状态机或状态-日期规则违反(code 42201)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ ruleViolation:
+ value: { code: 42201, message: scheduled 状态必须提供 plannedOn, data: null }
+
+ /api/v1/vaccinations/{vaccinationId}:
+ patch:
+ tags: [health-records]
+ summary: 更新疫苗记录
+ description: |
+ 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。
+
+ - 部分更新:缺席字段不变;**不支持清空回 null**。
+ - `vaccineId` / `seriesKey` / `doseNo` 不可改(不在请求体)——登记错剂次的
+ 修正路径是 cancel 后重建。
+ - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。
+ - 状态机:`scheduled → completed`(合并态必须有 administeredOn)、
+ `scheduled → cancelled`(合并态 administeredOn 必须为空);
+ **completed 与 cancelled 均为终态**(completed→cancelled、cancelled→scheduled
+ 等一律 422/42201);同状态编辑(补批号/备注等)始终允许。
+ - 校验时点:在「当前行 + 请求字段」的合并态上重跑与创建完全相同的状态-日期
+ 规则,违反 422/42201。
+ operationId: updateVaccination
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: vaccinationId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateVaccinationRequest'
+ responses:
+ '200':
+ description: 更新成功,返回更新后完整 Vaccination
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/VaccinationEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/RecordNotFound'
+ '409':
+ $ref: '#/components/responses/VersionConflict'
+ '422':
+ description: 状态机非法迁移或状态-日期规则违反(code 42201)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ terminalState:
+ value: { code: 42201, message: completed 为终态,不可迁移至 cancelled, data: null }
+
+ /api/v1/pets/{petId}/health-events:
+ get:
+ tags: [health-records]
+ summary: 健康事件时间线
+ description: |
+ 权限档:READ。cursor 分页(分页正典形态),按 `occurred_at DESC, id DESC` 排序
+ (与索引 ix_health_events_pet_time 逐列对齐)。`limit` 越界或 `cursor` 无效:
+ 400/40000。
+ operationId: listHealthEvents
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - $ref: '#/components/parameters/PageLimitParam'
+ - $ref: '#/components/parameters/PageCursorParam'
+ responses:
+ '200':
+ description: 健康事件分页结果
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HealthEventListEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+ post:
+ tags: [health-records]
+ summary: 添加健康事件
+ description: |
+ 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 HealthEvent。
+ 支持可选 `Idempotency-Key`。
+
+ - 六类事件类型:medical/feeding/deworming/grooming/measurement/note。
+ - `createdByUserId` 取自验签 token,**不收请求体**、永不可改。
+ - `title` 服务端 btrim,trim 后为空 400/40000。
+ - 金额 `amountCents` 以整数分传输、非负、可缺席;**提交小数一律 400/40000**
+ (不做静默截断)。
+ operationId: createHealthEvent
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - $ref: '#/components/parameters/IdempotencyKeyHeader'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateHealthEventRequest'
+ responses:
+ '201':
+ description: 创建成功(同 Idempotency-Key 重试返回首次创建的记录,同样 201)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HealthEventEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+
+ /api/v1/health-events/{eventId}:
+ patch:
+ tags: [health-records]
+ summary: 更新健康事件
+ description: |
+ 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。
+
+ - **仅可编辑 `title` / `notes` / `amountCents`**;`eventType` / `occurredAt`
+ 为时间线条目的身份,不可改(不在请求体);`createdByUserId` 永不可改。
+ - 部分更新:缺席字段不变;**不支持清空回 null**。
+ - `version` 必填(缺失 400/40000),比对通过才写入并 +1;过期 409/40902。
+ - `title` 提交空白串(trim 后为空)400/40000。
+ operationId: updateHealthEvent
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: eventId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateHealthEventRequest'
+ responses:
+ '200':
+ description: 更新成功,返回更新后完整 HealthEvent
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/HealthEventEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/RecordNotFound'
+ '409':
+ $ref: '#/components/responses/VersionConflict'
+
+ /api/v1/pets/{petId}/care-reminders:
+ get:
+ tags: [health-records]
+ summary: 照护提醒列表
+ description: |
+ 权限档:READ。**不分页**(单宠提醒量级小),`ORDER BY due_at ASC, id`
+ (待办最先到期在前)。`?status=` 白名单过滤(pending/completed/dismissed),
+ `?status=pending` 即「按 due_at 查询待办」视图;非法取值 400/40000。
+ operationId: listCareReminders
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - name: status
+ in: query
+ required: false
+ schema:
+ type: string
+ enum: [pending, completed, dismissed]
+ description: 按状态过滤;不传则返回全部
+ responses:
+ '200':
+ description: 提醒列表(不分页,due_at ASC 排序)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CareReminderListEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+ post:
+ tags: [health-records]
+ summary: 创建照护提醒
+ description: |
+ 权限档:WRITE(owner + caregiver)。返回 **201** 与完整 CareReminder。
+ 支持可选 `Idempotency-Key`(提醒表无唯一约束兜底,重复提交只能靠键防)。
+ 创建恒为 `pending`(请求体不收 status,多余字段被忽略,与全 API 一致)。
+ M2 仅 app 内数据,不做推送(ADR-010)。提醒的 title/dueAt 后续编辑与删除端点
+ 不在 M2 契约,改期路径为 dismiss 后重建。
+ operationId: createCareReminder
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - $ref: '#/components/parameters/IdempotencyKeyHeader'
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CreateCareReminderRequest'
+ responses:
+ '201':
+ description: 创建成功,状态恒为 pending(同 Idempotency-Key 重试返回首次创建的记录,同样 201)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CareReminderEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+
+ /api/v1/care-reminders/{reminderId}:
+ patch:
+ tags: [health-records]
+ summary: 更新提醒状态
+ description: |
+ 权限档:WRITE。顶层短路径,404/40402 为记录级防枚举语义。
+ **状态流转专用**:请求体仅 `status` + `completedAt`。
+
+ - 状态机:`pending → completed`(必带 completedAt)、`pending → dismissed`
+ (禁带 completedAt);completed / dismissed 为终态;**同状态重放始终允许**
+ (客户端重试「标记完成」幂等成功)。
+ - completed-completedAt 一致性(违反 422/42202):`status=completed` 必带
+ `completedAt`、其余状态禁带;终态互迁与回退 pending 均拒绝。
+ - `completedAt` 由客户端提交(而非服务端 now()),允许补记实际完成时刻。
+ - 提醒表无 version 列:并发流转采用当前状态条件更新守卫,读写窗口内被并发
+ 流转抢先则 409/40902(「数据已被修改请刷新」,客户端处理方式与乐观锁一致)。
+ operationId: updateCareReminder
+ security:
+ - bearerAuth: []
+ parameters:
+ - name: reminderId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateCareReminderRequest'
+ responses:
+ '200':
+ description: 更新成功,返回更新后完整 CareReminder
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/CareReminderEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '403':
+ $ref: '#/components/responses/PetWriteDenied'
+ '404':
+ $ref: '#/components/responses/RecordNotFound'
+ '409':
+ $ref: '#/components/responses/VersionConflict'
+ '422':
+ description: 状态机非法迁移或 completed-completedAt 一致性违反(code 42202)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ missingCompletedAt:
+ value: { code: 42202, message: 标记 completed 必须提供 completedAt, data: null }
+
+ /api/v1/pets/{petId}/summary:
+ get:
+ tags: [health-records]
+ summary: 档案聚合摘要
+ description: |
+ 权限档:READ(三角色皆可读)。实时聚合生成档案页摘要:最新体重、疫苗进度、
+ 下次接种、当月花费——四项聚合全部从事实表实时计算,**无任何写路径**
+ (不持久化展示字符串)。各聚合口径逐字见 PetSummary schema 字段描述
+ (iteration-2 报告 18 §3 定型)。
+
+ `tz`:可选,IANA 时区标识(如 `Asia/Shanghai`,也接受固定偏移如 `+08:00`),
+ 缺省 `UTC`,仅作用于当月花费的月度窗口;非法 tz 或超 64 字符 → 400/40000。
+ 客户端应传自己的时区以获得符合直觉的月边界。
+ operationId: getPetSummary
+ security:
+ - bearerAuth: []
+ parameters:
+ - $ref: '#/components/parameters/PetIdParam'
+ - name: tz
+ in: query
+ required: false
+ schema:
+ type: string
+ maxLength: 64
+ default: UTC
+ description: IANA 时区标识(如 Asia/Shanghai)或固定偏移(如 +08:00),仅作用于当月花费的月度窗口
+ example: Asia/Shanghai
+ responses:
+ '200':
+ description: 聚合摘要
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/PetSummaryEnvelope'
+ '400':
+ $ref: '#/components/responses/ValidationError'
+ '401':
+ $ref: '#/components/responses/AccessTokenInvalid'
+ '404':
+ $ref: '#/components/responses/PetNotFound'
+
+components:
+ securitySchemes:
+ bearerAuth:
+ type: http
+ scheme: bearer
+ bearerFormat: JWT
+ description: 'Authorization: Bearer (RS256 JWT)'
+
+ parameters:
+ PetIdParam:
+ name: petId
+ in: path
+ required: true
+ schema:
+ type: string
+ format: uuid
+ description: 宠物 ID
+ PageLimitParam:
+ name: limit
+ in: query
+ required: false
+ schema:
+ type: integer
+ minimum: 1
+ maximum: 100
+ default: 20
+ description: 每页条数(1~100,缺省 20);越界 400/40000
+ PageCursorParam:
+ name: cursor
+ in: query
+ required: false
+ schema:
+ type: string
+ description: 上一页返回的 nextCursor(不透明字符串,客户端不得解析),首页不传;无效 400/40000
+ IdempotencyKeyHeader:
+ name: Idempotency-Key
+ in: header
+ required: false
+ schema:
+ type: string
+ maxLength: 255
+ description: |
+ 可选幂等键(≤255 字符,超长 400/40000)。键按「调用者 × 宠物 × 资源」隔离;
+ 同键重试返回首次创建的记录(同样 201);不比对请求体(每次逻辑提交应换新键,
+ 建议 UUID);键永久幂等(无 TTL)。不带键则无幂等语义。
+
+ responses:
+ ValidationError:
+ description: 参数校验失败(code 40000)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ validation:
+ value: { code: 40000, message: 参数校验失败, data: null }
+ AccessTokenInvalid:
+ description: access token 缺失、无效或过期(code 40101)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ tokenInvalid:
+ value: { code: 40101, message: token 无效或过期, data: null }
+ PetNotFound:
+ description: |
+ 宠物不存在、已软删除或调用者与宠物无关系(code 40401)。防枚举语义:三种情况
+ 响应完全一致,随机探测 UUID 无法得知是否命中真实记录。
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ petNotFound:
+ value: { code: 40401, message: 宠物不存在, data: null }
+ RecordNotFound:
+ description: |
+ 记录不存在或记录所属宠物对调用者不可见(code 40402)。记录级防枚举语义:
+ 两种情况响应完全一致;只有对宠物可见的调用者才可能收到 403/40300。
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ recordNotFound:
+ value: { code: 40402, message: 记录不存在, data: null }
+ PetWriteDenied:
+ description: |
+ 对可见宠物无相应操作权限(code 40300):viewer 写记录、caregiver/viewer 改
+ 宠物档案。仅发给对宠物「可见」的调用者,不泄露新信息。
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ accessDenied:
+ value: { code: 40300, message: 无权限执行该操作, data: null }
+ VersionConflict:
+ description: |
+ 乐观锁版本冲突(code 40902):提交的 version 已过期(并发修改或重试)。
+ 不静默覆盖,先写者数据保留;客户端刷新取新 version 后重提。
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ErrorEnvelope'
+ examples:
+ versionConflict:
+ value: { code: 40902, message: 数据已被修改,请刷新后重试, data: null }
+
+ schemas:
+ RegisterRequest:
+ type: object
+ required: [username, password]
+ properties:
+ username:
+ type: string
+ minLength: 3
+ maxLength: 32
+ description: 用户名,大小写不敏感唯一
+ example: demo_user
+ phone:
+ type: string
+ pattern: '^\+[1-9][0-9]{7,14}$'
+ description: 手机号,E.164 格式;可选,唯一
+ example: '+8613800138000'
+ password:
+ type: string
+ format: password
+ minLength: 6
+ maxLength: 64
+ example: secret123
+ nickname:
+ type: string
+ minLength: 1
+ maxLength: 32
+ description: 昵称;可选(冻结稿之外的可选扩展字段,前端可忽略)
+ example: 小柴
+
+ LoginRequest:
+ type: object
+ required: [username, password]
+ properties:
+ username:
+ type: string
+ example: demo_user
+ password:
+ type: string
+ format: password
+ example: secret123
+
+ RefreshRequest:
+ type: object
+ required: [refreshToken]
+ properties:
+ refreshToken:
+ type: string
+ description: 当前持有的 refresh token(不透明随机串)
+ example: Zx3v…43位base64url…Qk
+
+ LogoutRequest:
+ type: object
+ required: [refreshToken]
+ properties:
+ refreshToken:
+ type: string
+ description: 要撤销的当前会话的 refresh token
+ example: Zx3v…43位base64url…Qk
+
+ AuthTokens:
+ type: object
+ description: 注册 / 登录 / 刷新共用的令牌对(冻结契约,恰好这 6 个字段)
+ required:
+ - userId
+ - tokenType
+ - accessToken
+ - accessTokenExpiresAt
+ - refreshToken
+ - refreshTokenExpiresAt
+ properties:
+ userId:
+ type: string
+ format: uuid
+ example: 019212aa-0000-7000-8000-000000000001
+ tokenType:
+ type: string
+ enum: [Bearer]
+ example: Bearer
+ accessToken:
+ type: string
+ description: RS256 JWT,有效期 15 分钟(配置项)
+ example: eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiI…
+ accessTokenExpiresAt:
+ type: string
+ format: date-time
+ description: ISO 8601 带时区
+ example: '2026-09-04T04:20:06.789Z'
+ refreshToken:
+ type: string
+ description: 不透明随机串,有效期 30 天(配置项),每次刷新轮换
+ example: Zx3v…43位base64url…Qk
+ refreshTokenExpiresAt:
+ type: string
+ format: date-time
+ example: '2026-10-04T04:05:06.789Z'
+
+ Me:
+ type: object
+ description: 当前用户资料(冻结契约,恰好这 4 个字段)
+ required: [userId, username, createdAt]
+ properties:
+ userId:
+ type: string
+ format: uuid
+ example: 019212aa-0000-7000-8000-000000000001
+ username:
+ type: string
+ example: demo_user
+ phone:
+ type: string
+ nullable: true
+ description: E.164;未绑定时为 null
+ example: '+8613800138000'
+ createdAt:
+ type: string
+ format: date-time
+ example: '2026-09-04T04:05:06.789Z'
+
+ AuthTokenEnvelope:
+ type: object
+ required: [code, message]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/AuthTokens'
+
+ MeEnvelope:
+ type: object
+ required: [code, message]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/Me'
+
+ VoidEnvelope:
+ type: object
+ required: [code, message]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ nullable: true
+ example: null
+
+ ErrorEnvelope:
+ type: object
+ required: [code, message]
+ properties:
+ code:
+ type: integer
+ description: 稳定业务错误码(见顶部错误码表)
+ example: 40101
+ message:
+ type: string
+ example: token 无效或过期
+ data:
+ nullable: true
+ example: null
+
+ TrackEventsRequest:
+ type: object
+ required: [events]
+ properties:
+ events:
+ type: array
+ minItems: 1
+ maxItems: 50
+ description: 单批 1–50 条;越界整批 400/40000
+ items:
+ $ref: '#/components/schemas/TrackedEvent'
+
+ TrackedEvent:
+ type: object
+ required:
+ - eventId
+ - eventName
+ - eventVersion
+ - anonymousId
+ - sessionId
+ - clientTs
+ - appVersion
+ - platform
+ - osVersion
+ properties:
+ eventId:
+ type: string
+ format: uuid
+ description: 客户端生成的 UUID(规范要求 v7),服务端幂等去重键
+ example: 019212aa-4444-7000-8000-000000000001
+ eventName:
+ type: string
+ pattern: '^[a-z][a-z0-9_]{1,63}$'
+ description: 须在服务端事件字典内;不在字典中的事件名整条 rejected(unknown_event_name)
+ example: auth_login_succeeded
+ eventVersion:
+ type: integer
+ description: 事件 schema 版本(字典 v1 全部为 1)
+ example: 1
+ anonymousId:
+ type: string
+ format: uuid
+ description: 设备级匿名标识,首次启动生成
+ example: 019212aa-0000-7000-8000-000000000001
+ userId:
+ type: string
+ format: uuid
+ nullable: true
+ description: |
+ 登录后填充,可选。已认证请求中若与 token subject 不一致,该条
+ rejected(identity_mismatch);匿名请求中原样落库,不做校验。
+ example: 019212aa-0000-7000-8000-000000000001
+ sessionId:
+ type: string
+ format: uuid
+ description: 客户端会话标识
+ example: 019212aa-1111-7000-8000-000000000001
+ clientTs:
+ type: string
+ format: date-time
+ description: 客户端本地时间(ISO 8601 带时区);serverTs 由服务端补写,客户端不发
+ example: '2026-09-07T04:05:06.789Z'
+ appVersion:
+ type: string
+ minLength: 1
+ maxLength: 32
+ example: 1.0.0+12
+ platform:
+ type: string
+ enum: [android, ios]
+ example: android
+ osVersion:
+ type: string
+ minLength: 1
+ maxLength: 32
+ example: android-14
+ props:
+ type: object
+ additionalProperties: true
+ description: |
+ 事件专有属性,可选。按事件字典白名单处理:白名单外的键剥离后入库
+ (事件保留);键名命中隐私红线模式(password/token/secret/phone/
+ mobile/email/credential/idfa/gaid,不区分大小写、子串匹配)则整条
+ rejected(forbidden_field)。
+ example: { identifierType: username, durationMs: 123 }
+
+ TrackEventsResult:
+ type: object
+ description: 批次逐条结果(results 与请求 events 等长、按原顺序对应)
+ required: [accepted, duplicated, rejected, results]
+ properties:
+ accepted:
+ type: integer
+ description: 新落库条数
+ example: 1
+ duplicated:
+ type: integer
+ description: eventId 去重命中条数(视为成功,客户端不必重试)
+ example: 0
+ rejected:
+ type: integer
+ description: 被拒条数(客户端不重试)
+ example: 0
+ results:
+ type: array
+ items:
+ $ref: '#/components/schemas/EventResult'
+
+ EventResult:
+ type: object
+ required: [eventId, status]
+ properties:
+ eventId:
+ type: string
+ format: uuid
+ example: 019212aa-4444-7000-8000-000000000001
+ status:
+ type: string
+ enum: [accepted, duplicate, rejected]
+ example: accepted
+ reason:
+ type: string
+ enum: [unknown_event_name, identity_mismatch, forbidden_field, schema_invalid]
+ description: 仅 status=rejected 时出现(accepted/duplicate 不含该字段)
+ example: unknown_event_name
+
+ TrackEventsEnvelope:
+ type: object
+ required: [code, message]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/TrackEventsResult'
+
+ # ==================================================================
+ # Pets 域 schemas(M2 冻结;响应主键统一裸 `id`,关联字段带类型名)
+ # ==================================================================
+
+ Pet:
+ type: object
+ description: |
+ 宠物档案(列表 / 详情 / 创建 / 更新的统一响应形态,皆含 myRole)。
+ `breedId` 与 `customBreedName` 恰有其一非空(ck_pets_breed);
+ `breedDisplayName` 由品种字典解出,随 breedId 存在。
+ 软删除态(deleted)的宠物在全部端点表现为 404/40401,本 schema 的
+ status 永不出现 deleted。`avatarAssetId` 不出现在 M2 契约(ADR-010)。
+ required:
+ - id
+ - name
+ - species
+ - sex
+ - birthDateEstimated
+ - status
+ - myRole
+ - createdAt
+ - updatedAt
+ - version
+ properties:
+ id:
+ type: string
+ format: uuid
+ name:
+ type: string
+ minLength: 1
+ maxLength: 64
+ species:
+ type: string
+ enum: [dog, cat, other]
+ description: 物种;创建即定,不可修改
+ breedId:
+ type: string
+ format: uuid
+ nullable: true
+ description: 品种 ID(与 customBreedName 互斥,恰有其一非空)
+ breedDisplayName:
+ type: string
+ nullable: true
+ description: 品种展示名,由字典解出,随 breedId 存在
+ customBreedName:
+ type: string
+ nullable: true
+ minLength: 1
+ maxLength: 64
+ description: 自定义品种名(与 breedId 互斥)
+ sex:
+ type: string
+ enum: [male, female, unknown]
+ birthDate:
+ type: string
+ format: date
+ nullable: true
+ description: 生日(YYYY-MM-DD)
+ birthDateEstimated:
+ type: boolean
+ description: 生日是否为估计值
+ personality:
+ type: string
+ nullable: true
+ maxLength: 64
+ description: 性格标签
+ microchipNo:
+ type: string
+ nullable: true
+ description: 芯片号(跨用户唯一)
+ sterilizedOn:
+ type: string
+ format: date
+ nullable: true
+ description: 绝育日期
+ status:
+ type: string
+ enum: [active, lost, deceased, archived]
+ description: 状态(deleted 为内部软删态,接口永不返回;软删宠物一律 404/40401)
+ myRole:
+ type: string
+ enum: [owner, caregiver, viewer]
+ description: 调用者对该宠物的权限角色(客户端据此显隐写入口)
+ createdAt:
+ type: string
+ format: date-time
+ updatedAt:
+ type: string
+ format: date-time
+ version:
+ type: integer
+ description: 乐观锁版本号(PATCH 时必须提交)
+
+ CreatePetRequest:
+ type: object
+ required: [name, species, sex]
+ properties:
+ name:
+ type: string
+ minLength: 1
+ maxLength: 64
+ species:
+ type: string
+ enum: [dog, cat, other]
+ description: 创建即定,之后不可修改
+ breedId:
+ type: string
+ format: uuid
+ description: 品种 ID(与 customBreedName 二选一且互斥;双填/双空/物种错配/品种不存在或停用 → 400/40000)
+ customBreedName:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 自定义品种名(与 breedId 二选一且互斥)
+ sex:
+ type: string
+ enum: [male, female, unknown]
+ birthDate:
+ type: string
+ format: date
+ birthDateEstimated:
+ type: boolean
+ default: false
+ personality:
+ type: string
+ maxLength: 64
+ microchipNo:
+ type: string
+ description: 芯片号;已被登记 → 409/40903
+ sterilizedOn:
+ type: string
+ format: date
+
+ UpdatePetRequest:
+ type: object
+ description: |
+ 部分更新:缺席字段不变;不支持清空回 null。例外:品种对
+ (breedId/customBreedName)整体替换——提交任一侧即替换整对,互斥校验同创建。
+ species 不可改(不在请求体)。
+ required: [version]
+ properties:
+ version:
+ type: integer
+ description: 当前持有的版本号(乐观锁,必填;缺失 400/40000,过期 409/40902)
+ name:
+ type: string
+ minLength: 1
+ maxLength: 64
+ breedId:
+ type: string
+ format: uuid
+ description: 品种对整体替换(与 customBreedName 互斥)
+ customBreedName:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 品种对整体替换(与 breedId 互斥)
+ sex:
+ type: string
+ enum: [male, female, unknown]
+ birthDate:
+ type: string
+ format: date
+ birthDateEstimated:
+ type: boolean
+ personality:
+ type: string
+ maxLength: 64
+ microchipNo:
+ type: string
+ description: 已被登记 → 409/40903
+ sterilizedOn:
+ type: string
+ format: date
+ status:
+ type: string
+ enum: [active, lost, deceased, archived]
+ description: 状态流转;deleted 不可经 PATCH 设置(400/40000)
+
+ PetEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/Pet'
+
+ PetListEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ type: array
+ description: created_at DESC 排序,不分页
+ items:
+ $ref: '#/components/schemas/Pet'
+
+ Breed:
+ type: object
+ required: [id, species, code, displayName]
+ properties:
+ id:
+ type: string
+ format: uuid
+ species:
+ type: string
+ enum: [dog, cat, other]
+ code:
+ type: string
+ description: 品种代码(唯一标识)
+ displayName:
+ type: string
+ description: 展示名称
+
+ BreedListEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/Breed'
+
+ WeightRecord:
+ type: object
+ required: [id, petId, weightKg, measuredAt, source, createdAt]
+ properties:
+ id:
+ type: string
+ format: uuid
+ petId:
+ type: string
+ format: uuid
+ weightKg:
+ type: number
+ format: double
+ minimum: 0.01
+ maximum: 500
+ description: 体重(公斤),最多两位小数(numeric(6,2))
+ measuredAt:
+ type: string
+ format: date-time
+ description: 称重时间
+ source:
+ type: string
+ enum: [manual, clinic, device]
+ description: 来源
+ note:
+ type: string
+ nullable: true
+ maxLength: 500
+ createdAt:
+ type: string
+ format: date-time
+
+ CreateWeightRequest:
+ type: object
+ required: [weightKg, measuredAt]
+ properties:
+ weightKg:
+ type: number
+ format: double
+ minimum: 0.01
+ maximum: 500
+ description: 体重(公斤),(0, 500],最多两位小数;越界或三位小数 400/40000
+ measuredAt:
+ type: string
+ format: date-time
+ source:
+ type: string
+ enum: [manual, clinic, device]
+ default: manual
+ note:
+ type: string
+ maxLength: 500
+
+ WeightEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/WeightRecord'
+
+ WeightListEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ type: object
+ description: cursor 分页正典信封;排序 measured_at DESC, id DESC
+ required: [items, hasMore]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/WeightRecord'
+ nextCursor:
+ type: string
+ nullable: true
+ description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null
+ hasMore:
+ type: boolean
+
+ VaccineCatalogItem:
+ type: object
+ required: [id, code, name, species]
+ properties:
+ id:
+ type: string
+ format: uuid
+ code:
+ type: string
+ description: 疫苗代码(唯一标识)
+ name:
+ type: string
+ description: 疫苗名称
+ species:
+ type: string
+ enum: [dog, cat, other]
+ description:
+ type: string
+ nullable: true
+ maxLength: 500
+
+ VaccineCatalogListEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ type: array
+ items:
+ $ref: '#/components/schemas/VaccineCatalogItem'
+
+ Vaccination:
+ type: object
+ description: |
+ 疫苗记录。`vaccineName` 由疫苗目录解出(同 Pet.breedDisplayName 先例,列表页免
+ 二次查字典)。`certificateAssetId / providerId / providerNameSnapshot / bookingId`
+ 整体不出现(ADR-010,M5 时纯增量补入)。
+ required:
+ - id
+ - petId
+ - vaccineId
+ - vaccineName
+ - seriesKey
+ - doseNo
+ - status
+ - createdAt
+ - updatedAt
+ - version
+ properties:
+ id:
+ type: string
+ format: uuid
+ petId:
+ type: string
+ format: uuid
+ vaccineId:
+ type: string
+ format: uuid
+ vaccineName:
+ type: string
+ description: 疫苗名称(出自疫苗目录)
+ seriesKey:
+ type: string
+ minLength: 1
+ maxLength: 64
+ description: 系列键(区分初次/加强等,与 doseNo 共同唯一);创建后不可改
+ doseNo:
+ type: integer
+ minimum: 1
+ maximum: 32767
+ description: 剂次号(smallint);创建后不可改
+ doseLabel:
+ type: string
+ nullable: true
+ maxLength: 64
+ description: 剂次标签(如「第一针」)
+ status:
+ type: string
+ enum: [scheduled, completed, cancelled]
+ plannedOn:
+ type: string
+ format: date
+ nullable: true
+ description: 计划接种日期(scheduled 必有)
+ administeredOn:
+ type: string
+ format: date
+ nullable: true
+ description: 实际接种日期(completed 必有;scheduled/cancelled 必空)
+ nextDueOn:
+ type: string
+ format: date
+ nullable: true
+ description: 下次到期日期(与 administeredOn 同时存在时 ≥ administeredOn)
+ manufacturer:
+ type: string
+ nullable: true
+ maxLength: 128
+ batchNo:
+ type: string
+ nullable: true
+ maxLength: 64
+ notes:
+ type: string
+ nullable: true
+ maxLength: 1000
+ createdAt:
+ type: string
+ format: date-time
+ updatedAt:
+ type: string
+ format: date-time
+ version:
+ type: integer
+ description: 乐观锁版本号(PATCH 时必须提交)
+
+ CreateVaccinationRequest:
+ type: object
+ description: |
+ 创建状态仅 scheduled / completed(创建即 cancelled 无业务意义,400/40000)。
+ 疫苗必须存在、enabled 且 species 与宠物一致(400/40000)。
+ 状态-日期规则违反 → 422/42201。
+ required: [vaccineId, seriesKey, doseNo, status]
+ properties:
+ vaccineId:
+ type: string
+ format: uuid
+ seriesKey:
+ type: string
+ minLength: 1
+ maxLength: 64
+ doseNo:
+ type: integer
+ minimum: 1
+ maximum: 32767
+ doseLabel:
+ type: string
+ maxLength: 64
+ status:
+ type: string
+ enum: [scheduled, completed]
+ plannedOn:
+ type: string
+ format: date
+ description: scheduled 状态必填
+ administeredOn:
+ type: string
+ format: date
+ description: completed 状态必填;scheduled 不得携带
+ nextDueOn:
+ type: string
+ format: date
+ description: 与 administeredOn 同时存在时须 ≥ administeredOn
+ manufacturer:
+ type: string
+ maxLength: 128
+ batchNo:
+ type: string
+ maxLength: 64
+ notes:
+ type: string
+ maxLength: 1000
+
+ UpdateVaccinationRequest:
+ type: object
+ description: |
+ 部分更新:缺席字段不变;不支持清空回 null。vaccineId / seriesKey / doseNo
+ 不可改(不在请求体)——登记错剂次的修正路径是 cancel 后重建。
+ 合并态重跑与创建相同的状态-日期规则,违反 422/42201。
+ required: [version]
+ properties:
+ version:
+ type: integer
+ description: 乐观锁(必填;缺失 400/40000,过期 409/40902)
+ status:
+ type: string
+ enum: [scheduled, completed, cancelled]
+ description: scheduled→completed / scheduled→cancelled;completed 与 cancelled 均为终态(非法迁移 422/42201);同状态编辑始终允许
+ plannedOn:
+ type: string
+ format: date
+ administeredOn:
+ type: string
+ format: date
+ nextDueOn:
+ type: string
+ format: date
+ doseLabel:
+ type: string
+ maxLength: 64
+ manufacturer:
+ type: string
+ maxLength: 128
+ batchNo:
+ type: string
+ maxLength: 64
+ notes:
+ type: string
+ maxLength: 1000
+
+ VaccinationEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/Vaccination'
+
+ VaccinationListEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ type: array
+ description: 不分页;ORDER BY series_key, dose_no, created_at, id(含 cancelled 行)
+ items:
+ $ref: '#/components/schemas/Vaccination'
+
+ HealthEvent:
+ type: object
+ description: |
+ 健康事件。`providerId / providerNameSnapshot / bookingId` 整体不出现
+ (ADR-010,M5 时纯增量补入)。
+ required:
+ - id
+ - petId
+ - eventType
+ - occurredAt
+ - title
+ - createdByUserId
+ - createdAt
+ - updatedAt
+ - version
+ properties:
+ id:
+ type: string
+ format: uuid
+ petId:
+ type: string
+ format: uuid
+ eventType:
+ type: string
+ enum: [medical, feeding, deworming, grooming, measurement, note]
+ description: 事件类型;创建后不可改
+ occurredAt:
+ type: string
+ format: date-time
+ description: 事件发生时间;创建后不可改
+ title:
+ type: string
+ minLength: 1
+ maxLength: 160
+ description: 标题(服务端 btrim,trim 后为空 400/40000)
+ notes:
+ type: string
+ nullable: true
+ maxLength: 2000
+ description: 备注(上限 2000 字符)
+ amountCents:
+ type: integer
+ format: int64
+ nullable: true
+ minimum: 0
+ description: 金额(整数分,非负);提交小数 400/40000(不做静默截断)
+ createdByUserId:
+ type: string
+ format: uuid
+ description: 创建者用户 ID(取自验签 token,永不可改)
+ createdAt:
+ type: string
+ format: date-time
+ updatedAt:
+ type: string
+ format: date-time
+ version:
+ type: integer
+ description: 乐观锁版本号(PATCH 时必须提交)
+
+ CreateHealthEventRequest:
+ type: object
+ required: [eventType, occurredAt, title]
+ properties:
+ eventType:
+ type: string
+ enum: [medical, feeding, deworming, grooming, measurement, note]
+ occurredAt:
+ type: string
+ format: date-time
+ title:
+ type: string
+ minLength: 1
+ maxLength: 160
+ notes:
+ type: string
+ maxLength: 2000
+ amountCents:
+ type: integer
+ format: int64
+ minimum: 0
+ description: 金额(整数分,非负);提交小数 400/40000
+
+ UpdateHealthEventRequest:
+ type: object
+ description: |
+ 部分更新:缺席字段不变;不支持清空回 null。仅可编辑 title / notes / amountCents;
+ eventType / occurredAt / createdByUserId 不可改(不在请求体)。
+ required: [version]
+ properties:
+ version:
+ type: integer
+ description: 乐观锁(必填;缺失 400/40000,过期 409/40902)
+ title:
+ type: string
+ minLength: 1
+ maxLength: 160
+ description: 提交空白串(trim 后为空)400/40000
+ notes:
+ type: string
+ maxLength: 2000
+ amountCents:
+ type: integer
+ format: int64
+ minimum: 0
+ description: 提交小数 400/40000
+
+ HealthEventEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/HealthEvent'
+
+ HealthEventListEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ type: object
+ description: cursor 分页正典信封;排序 occurred_at DESC, id DESC
+ required: [items, hasMore]
+ properties:
+ items:
+ type: array
+ items:
+ $ref: '#/components/schemas/HealthEvent'
+ nextCursor:
+ type: string
+ nullable: true
+ description: 下一页游标(不透明 base64url),hasMore=false 时恒为 null
+ hasMore:
+ type: boolean
+
+ CareReminder:
+ type: object
+ description: |
+ 照护提醒。**无 version 字段**(care_reminders 表无该列,状态流转用当前状态
+ 条件更新守卫,守卫落空 409/40902)。completedAt 非空当且仅当 status=completed。
+ required:
+ - id
+ - petId
+ - reminderType
+ - title
+ - dueAt
+ - status
+ - createdAt
+ - updatedAt
+ properties:
+ id:
+ type: string
+ format: uuid
+ petId:
+ type: string
+ format: uuid
+ reminderType:
+ type: string
+ enum: [deworming, checkup, medication, other]
+ title:
+ type: string
+ minLength: 1
+ maxLength: 160
+ dueAt:
+ type: string
+ format: date-time
+ description: 到期时间
+ status:
+ type: string
+ enum: [pending, completed, dismissed]
+ completedAt:
+ type: string
+ format: date-time
+ nullable: true
+ description: 完成时间;非空当且仅当 status=completed
+ createdAt:
+ type: string
+ format: date-time
+ updatedAt:
+ type: string
+ format: date-time
+
+ CreateCareReminderRequest:
+ type: object
+ description: 创建恒为 pending(不收 status 字段,多余字段被忽略)
+ required: [reminderType, title, dueAt]
+ properties:
+ reminderType:
+ type: string
+ enum: [deworming, checkup, medication, other]
+ title:
+ type: string
+ minLength: 1
+ maxLength: 160
+ dueAt:
+ type: string
+ format: date-time
+
+ UpdateCareReminderRequest:
+ type: object
+ description: |
+ 状态流转专用(仅 status + completedAt)。pending→completed 必带 completedAt、
+ pending→dismissed 禁带;终态互迁与回退 pending 拒绝(422/42202);
+ 同状态重放始终允许(幂等成功)。completedAt 由客户端提交,允许补记实际完成时刻。
+ required: [status]
+ properties:
+ status:
+ type: string
+ enum: [pending, completed, dismissed]
+ completedAt:
+ type: string
+ format: date-time
+ description: status=completed 时必填;其余状态禁带(违反 422/42202)
+
+ CareReminderEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/CareReminder'
+
+ CareReminderListEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ type: array
+ description: 不分页;ORDER BY due_at ASC, id;支持 ?status= 白名单过滤
+ items:
+ $ref: '#/components/schemas/CareReminder'
+
+ PetSummary:
+ type: object
+ description: |
+ 档案聚合摘要(四项聚合全部从事实表实时计算,无持久化;口径为 iteration-2
+ 报告 18 §3 定型表逐字收录)。latestWeight / vaccinationProgress /
+ nextVaccination 三项可为 null(无对应记录);monthlyExpense 恒非 null。
+ required: [petId, monthlyExpense]
+ properties:
+ petId:
+ type: string
+ format: uuid
+ description: 恒非 null,回显路径参数
+ latestWeight:
+ type: object
+ nullable: true
+ description: |
+ 最新体重。口径:pet_weight_records 按 (measured_at DESC, id DESC) 取首行——
+ 与体重列表接口首行完全一致(同一索引 ix_pet_weight_pet_measured、同一
+ tie-break),同刻多条时后写入者(id 更大)胜出。无记录 → null。
+ required: [weightKg, measuredAt]
+ properties:
+ weightKg:
+ type: number
+ format: double
+ description: 两位小数(numeric(6,2)),对象存在时非 null
+ measuredAt:
+ type: string
+ format: date-time
+ description: 对象存在时非 null
+ vaccinationProgress:
+ type: object
+ nullable: true
+ description: |
+ 疫苗进度。口径:范围 = 该宠物非 cancelled 的 pet_vaccinations 行。
+ completedDoses = 其中 status=completed 的行数;totalDoses = 全部非 cancelled
+ 行数(= scheduled + completed,即「已登记剂次」——数据模型没有权威的
+ 「系列应打总针数」,分母取用户已登记数)。cancelled 分子分母皆不计入。
+ totalDoses=0 → 整体 null(**不是 0/0**)。
+ required: [completedDoses, totalDoses]
+ properties:
+ completedDoses:
+ type: integer
+ minimum: 0
+ description: 已完成剂次,对象存在时非 null
+ totalDoses:
+ type: integer
+ minimum: 1
+ description: 已登记剂次(scheduled + completed),对象存在时非 null(=0 即整体 null)
+ nextVaccination:
+ type: object
+ nullable: true
+ description: |
+ 下次接种。口径:候选集两类并集:① 全部 scheduled 行的 planned_on(约束保证
+ 非空;含过期——逾期计划在完成/取消前仍是下一针),source=planned;
+ ② completed 行的非空 next_due_on,仅当同 (pet, vaccine, series_key) 不存在
+ 更高 dose_no 的非 cancelled 记录(后续针一经登记,其自身即代表下一针,
+ 前一针的到期日失效),source=nextDue。cancelled 行不产生任何候选。
+ 取 dueOn 最小者;同日 planned 优先于 nextDue,再按 id 升序保证确定性。
+ 候选集空 → null。
+ required: [vaccinationId, vaccineId, vaccineName, doseNo, dueOn, source]
+ properties:
+ vaccinationId:
+ type: string
+ format: uuid
+ description: 命中的疫苗记录 id(客户端可跳详情),非 null
+ vaccineId:
+ type: string
+ format: uuid
+ description: 非 null
+ vaccineName:
+ type: string
+ description: 非 null,出自 vaccine_catalog(同 breedDisplayName 先例)
+ doseNo:
+ type: integer
+ description: 非 null
+ doseLabel:
+ type: string
+ nullable: true
+ description: 记录本身可无标签
+ dueOn:
+ type: string
+ format: date
+ description: 非 null;**可为过去日期**(逾期针仍是下一针)
+ source:
+ type: string
+ enum: [planned, nextDue]
+ description: 非 null,标注取值来源(scheduled 的 plannedOn 或 completed 的 nextDueOn)
+ monthlyExpense:
+ type: object
+ description: |
+ 当月花费,**恒非 null**(月份/时区总可确定)。口径:health_events.amount_cents
+ 求和,窗口为请求时刻在 tz 时区的自然月半开区间 [当月1日00:00, 次月1日00:00),
+ 对 occurred_at(timestamptz)比较;月初第一刻含、次月第一刻不含。
+ amount_cents 为 NULL 的事件不计入;不按 event_type 过滤(任何事件类型的
+ 金额都算支出)。tz 缺省 UTC,客户端应传自己的时区获得符合直觉的月边界——
+ 月边界随 tz 移动。恒返回对象:month 为窗口所属 ISO 年月、timezone 回显、
+ 无支出 amountCents=0。
+ required: [month, timezone, amountCents]
+ properties:
+ month:
+ type: string
+ description: ISO year-month(如 2026-09),非 null
+ example: '2026-09'
+ timezone:
+ type: string
+ description: 回显窗口所用时区(缺省 UTC),非 null
+ example: UTC
+ amountCents:
+ type: integer
+ format: int64
+ minimum: 0
+ description: 非 null,无支出为 0
+
+ PetSummaryEnvelope:
+ type: object
+ required: [code, message, data]
+ properties:
+ code:
+ type: integer
+ enum: [0]
+ message:
+ type: string
+ example: success
+ data:
+ $ref: '#/components/schemas/PetSummary'