Semantics (frozen for the M2 contract, iteration-2/02 P7): + *
The check is one indexed query (pets ⋈ pet_owners, both on their
+ * primary keys) per request — no caching, so revoking a caregiver row takes
+ * effect immediately (iteration-2/02 §6: this is why pet permissions don't
+ * need an access-token blacklist).
+ */
+@Service
+public class PetAccessService {
+
+ private final JdbcClient jdbcClient;
+
+ public PetAccessService(JdbcClient jdbcClient) {
+ this.jdbcClient = jdbcClient;
+ }
+
+ /**
+ * Asserts the caller may act on the pet at the given level.
+ *
+ * @return the caller's resolved relationship, for handlers that need the
+ * role (e.g. pet detail echoes {@code myRole})
+ * @throws BusinessException 40401 (pet invisible to this caller) or
+ * 40300 (visible but insufficient role)
+ */
+ public PetAccess require(UUID userId, UUID petId, AccessLevel level) {
+ PetAccess access = jdbcClient.sql("""
+ SELECT po.role
+ FROM pet_health.pets p
+ JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
+ WHERE p.id = :petId AND p.status <> 'deleted'
+ """)
+ .param("userId", userId)
+ .param("petId", petId)
+ .query((rs, rowNum) -> new PetAccess(petId, PetRole.fromDb(rs.getString("role"))))
+ .optional()
+ .orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
+ if (!level.allowedFor(access.role())) {
+ throw new BusinessException(ErrorCode.PET_ACCESS_DENIED);
+ }
+ return access;
+ }
+
+ /** The caller's verified relationship to a pet. */
+ public record PetAccess(UUID petId, PetRole role) {
+ }
+}
diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetRole.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetRole.java
new file mode 100644
index 0000000..b4f52c1
--- /dev/null
+++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/access/PetRole.java
@@ -0,0 +1,27 @@
+package com.patbond.patbond.pet.access;
+
+import com.patbond.patbond.common.error.BusinessException;
+import com.patbond.patbond.common.error.ErrorCode;
+
+/**
+ * The caller's relationship to a pet, straight from pet_owners.role
+ * (ADR-015 three-tier model: owner / caregiver / viewer).
+ */
+public enum PetRole {
+
+ OWNER,
+ CAREGIVER,
+ VIEWER;
+
+ public static PetRole fromDb(String value) {
+ try {
+ return valueOf(value.toUpperCase());
+ } catch (IllegalArgumentException | NullPointerException e) {
+ throw new BusinessException(ErrorCode.INTERNAL_ERROR, "未知的照护角色: " + value);
+ }
+ }
+
+ public String toWire() {
+ return name().toLowerCase();
+ }
+}
diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/config/PetSecurityProperties.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/PetSecurityProperties.java
new file mode 100644
index 0000000..2b9d068
--- /dev/null
+++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/PetSecurityProperties.java
@@ -0,0 +1,37 @@
+package com.patbond.patbond.pet.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+/**
+ * Security knobs of the pet service: only the RS256 public key for verifying
+ * access tokens issued by patbond-auth (same contract as patbond-user's
+ * {@code patbond.jwt.public-key}). No /internal routes exist here yet, so no
+ * service token property.
+ */
+@ConfigurationProperties(prefix = "patbond")
+public class PetSecurityProperties {
+
+ private final Jwt jwt = new Jwt();
+
+ public Jwt getJwt() {
+ return jwt;
+ }
+
+ public static class Jwt {
+
+ /**
+ * RS256 public key for verifying access tokens signed by
+ * patbond-auth: either inline PEM (starts with -----BEGIN) or a
+ * filesystem path. The private key never reaches this service.
+ */
+ private String publicKey;
+
+ public String getPublicKey() {
+ return publicKey;
+ }
+
+ public void setPublicKey(String publicKey) {
+ this.publicKey = publicKey;
+ }
+ }
+}
diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/config/SecurityConfig.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/SecurityConfig.java
new file mode 100644
index 0000000..7950d7e
--- /dev/null
+++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/SecurityConfig.java
@@ -0,0 +1,34 @@
+package com.patbond.patbond.pet.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.patbond.patbond.pet.security.BearerAuthFilter;
+import com.patbond.patbond.pet.security.JwtVerifier;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.web.servlet.FilterRegistrationBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Wires bearer authentication for /api/v1/** without pulling in
+ * spring-security — the same single-filter pattern patbond-user uses. The
+ * /health probe stays outside /api/v1 and therefore unauthenticated.
+ */
+@Configuration
+@EnableConfigurationProperties(PetSecurityProperties.class)
+public class SecurityConfig {
+
+ @Bean
+ public JwtVerifier jwtVerifier(PetSecurityProperties properties) {
+ return new JwtVerifier(properties.getJwt().getPublicKey());
+ }
+
+ @Bean
+ public FilterRegistrationBean Roles are seeded directly into pet_owners (ADR-015: invitation flow is
+ * post-M2; T2-10 mandates test-data construction so the permission code
+ * never goes unverified).
+ */
+class PetPermissionIntegrationTest extends PetIntegrationTestSupport {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ private String createPetAs(UUID ownerId) throws Exception {
+ MvcResult result = mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(ownerId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"权限猫\",\"species\":\"cat\",\"customBreedName\":\"狸花\"}"))
+ .andExpect(status().isCreated())
+ .andReturn();
+ return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
+ }
+
+ // ---- 无关系用户:一律 404(防枚举) ----
+
+ @Test
+ void strangerGetsSame404AsNonexistentPet() throws Exception {
+ UUID owner = newUser("owner_stranger_read");
+ UUID stranger = newUser("stranger_read");
+ String petId = createPetAs(owner);
+
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(stranger)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(40401));
+
+ // 与真正不存在的 id 响应完全一致 —— 无法据此探测 id 是否有效
+ mockMvc.perform(get("/api/v1/pets/{id}", UUID.randomUUID())
+ .header("Authorization", "Bearer " + tokenFor(stranger)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(40401));
+ }
+
+ @Test
+ void strangerPatchAlsoReturns404() throws Exception {
+ UUID owner = newUser("owner_stranger_write");
+ UUID stranger = newUser("stranger_write");
+ String petId = createPetAs(owner);
+
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(stranger))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"抢注\"}"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(40401));
+ }
+
+ @Test
+ void strangerListDoesNotContainOthersPets() throws Exception {
+ UUID owner = newUser("owner_list_iso");
+ UUID stranger = newUser("stranger_list_iso");
+ createPetAs(owner);
+
+ mockMvc.perform(get("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(stranger)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data").isEmpty());
+ }
+
+ // ---- viewer:只读 ----
+
+ @Test
+ void viewerCanReadButNotWrite() throws Exception {
+ UUID owner = newUser("owner_viewer_case");
+ UUID viewer = newUser("viewer_case");
+ String petId = createPetAs(owner);
+ grantRole(UUID.fromString(petId), viewer, "viewer");
+
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(viewer)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.myRole").value("viewer"));
+
+ mockMvc.perform(get("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(viewer)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[0].id").value(petId));
+
+ // 可见但越权 → 403(与无关系的 404 语义区分开)
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(viewer))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"越权改名\"}"))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(40300));
+ }
+
+ // ---- caregiver:可读、可写记录,但不可改档案 ----
+
+ @Test
+ void caregiverCanReadButNotManageProfile() throws Exception {
+ UUID owner = newUser("owner_cg_case");
+ UUID caregiver = newUser("caregiver_case");
+ String petId = createPetAs(owner);
+ grantRole(UUID.fromString(petId), caregiver, "caregiver");
+
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(caregiver)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.myRole").value("caregiver"));
+
+ // 宠物档案本身是 MANAGE 级:caregiver 的写权限只覆盖健康记录子资源
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(caregiver))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"照护人改名\"}"))
+ .andExpect(status().isForbidden())
+ .andExpect(jsonPath("$.code").value(40300));
+ }
+
+ // ---- 权限即时性:撤销关系立刻生效 ----
+
+ @Test
+ void revokedViewerImmediatelyLosesAccess() throws Exception {
+ UUID owner = newUser("owner_revoke");
+ UUID viewer = newUser("viewer_revoke");
+ String petId = createPetAs(owner);
+ grantRole(UUID.fromString(petId), viewer, "viewer");
+
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(viewer)))
+ .andExpect(status().isOk());
+
+ jdbcClient.sql("DELETE FROM pet_health.pet_owners WHERE pet_id = :petId AND user_id = :userId")
+ .param("petId", UUID.fromString(petId))
+ .param("userId", viewer)
+ .update();
+
+ // 每请求实时查 pet_owners,无缓存:降级为无关系 → 404
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(viewer)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(40401));
+ }
+
+ // ---- 未认证:401 ----
+
+ @Test
+ void missingTokenReturns401() throws Exception {
+ mockMvc.perform(get("/api/v1/pets"))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value(40101));
+ }
+
+ @Test
+ void tokenSignedByWrongKeyReturns401() throws Exception {
+ UUID user = newUser("wrong_key_user");
+ String forged = TestJwtKeys.accessToken(
+ TestJwtKeys.WRONG_KEY_PAIR.getPrivate(), user, Duration.ofMinutes(15));
+ mockMvc.perform(get("/api/v1/pets")
+ .header("Authorization", "Bearer " + forged))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value(40101));
+ }
+
+ @Test
+ void expiredTokenReturns401() throws Exception {
+ UUID user = newUser("expired_user");
+ String expired = TestJwtKeys.accessToken(
+ TestJwtKeys.KEY_PAIR.getPrivate(), user, Duration.ofMinutes(-5));
+ mockMvc.perform(get("/api/v1/pets")
+ .header("Authorization", "Bearer " + expired))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value(40101));
+ }
+}
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
new file mode 100644
index 0000000..d68fc94
--- /dev/null
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetCrudIntegrationTest.java
@@ -0,0 +1,322 @@
+package com.patbond.patbond.pet.controller;
+
+import com.jayway.jsonpath.JsonPath;
+import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.MediaType;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+
+import java.util.UUID;
+
+import static org.hamcrest.Matchers.greaterThan;
+import static org.hamcrest.Matchers.hasSize;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * T2-03 acceptance: create → list → detail → update → archive against real
+ * PostgreSQL, plus the six mandated paths (success, validation error, not
+ * found, no permission, concurrent conflict, idempotency/duplicate) and the
+ * three-role permission matrix (T2-10: roles constructed as test data,
+ * ADR-015).
+ */
+class PetCrudIntegrationTest extends PetIntegrationTestSupport {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ private String createPet(UUID ownerId, String name) throws Exception {
+ MvcResult result = mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(ownerId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"%s","species":"cat","customBreedName":"狸花猫"}
+ """.formatted(name)))
+ .andExpect(status().isCreated())
+ .andReturn();
+ return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
+ }
+
+ // ---- 成功路径 ----
+
+ @Test
+ void createListDetailUpdateArchiveFullChain() throws Exception {
+ UUID owner = newUser("chain_owner");
+ UUID breedId = anyBreedId("dog");
+
+ MvcResult created = mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"旺财","species":"dog","breedId":"%s",
+ "sex":"male","birthDate":"2024-05-01","birthDateEstimated":true,
+ "personality":"活泼","microchipNo":"chip-chain-001"}
+ """.formatted(breedId)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.name").value("旺财"))
+ .andExpect(jsonPath("$.data.species").value("dog"))
+ .andExpect(jsonPath("$.data.breedId").value(breedId.toString()))
+ .andExpect(jsonPath("$.data.breedDisplayName").isNotEmpty())
+ .andExpect(jsonPath("$.data.customBreedName").isEmpty())
+ .andExpect(jsonPath("$.data.status").value("active"))
+ .andExpect(jsonPath("$.data.myRole").value("owner"))
+ .andExpect(jsonPath("$.data.version").value(0))
+ .andReturn();
+ String petId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.id");
+
+ // 创建者自动成为 primary owner(pet_owners 落库校验)
+ Boolean isPrimary = jdbcClient.sql("""
+ SELECT is_primary FROM pet_health.pet_owners
+ WHERE pet_id = :petId AND user_id = :userId AND role = 'owner'
+ """)
+ .param("petId", UUID.fromString(petId))
+ .param("userId", owner)
+ .query(Boolean.class)
+ .single();
+ org.assertj.core.api.Assertions.assertThat(isPrimary).isTrue();
+
+ mockMvc.perform(get("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(owner)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(1)))
+ .andExpect(jsonPath("$.data[0].id").value(petId));
+
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.id").value(petId))
+ .andExpect(jsonPath("$.data.birthDate").value("2024-05-01"))
+ .andExpect(jsonPath("$.data.myRole").value("owner"));
+
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"旺财二世\",\"personality\":\"沉稳\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.name").value("旺财二世"))
+ .andExpect(jsonPath("$.data.personality").value("沉稳"))
+ // 未提交的字段保持不变(部分更新语义)
+ .andExpect(jsonPath("$.data.birthDate").value("2024-05-01"))
+ .andExpect(jsonPath("$.data.version").value(1));
+
+ // 归档(D2-7:前端首版只出归档入口)
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":1,\"status\":\"archived\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.status").value("archived"))
+ .andExpect(jsonPath("$.data.version").value(2));
+
+ // 归档后仍可见(软删除才隐藏)
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.status").value("archived"));
+ }
+
+ @Test
+ void breedsDictionaryFiltersBySpecies() throws Exception {
+ UUID user = newUser("breeds_user");
+ mockMvc.perform(get("/api/v1/breeds").param("species", "cat")
+ .header("Authorization", "Bearer " + tokenFor(user)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data", hasSize(greaterThan(0))))
+ .andExpect(jsonPath("$.data[?(@.species != 'cat')]", hasSize(0)))
+ .andExpect(jsonPath("$.data[0].displayName").isNotEmpty());
+
+ mockMvc.perform(get("/api/v1/breeds")
+ .header("Authorization", "Bearer " + tokenFor(user)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data[?(@.species == 'dog')]", hasSize(greaterThan(0))))
+ .andExpect(jsonPath("$.data[?(@.species == 'cat')]", hasSize(greaterThan(0))));
+ }
+
+ // ---- 参数错误路径 ----
+
+ @Test
+ void createWithBothBreedAndCustomNameIsRejected() throws Exception {
+ UUID user = newUser("breed_both");
+ mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"小白","species":"dog","breedId":"%s","customBreedName":"串串"}
+ """.formatted(anyBreedId("dog"))))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void createWithNeitherBreedNorCustomNameIsRejected() throws Exception {
+ UUID user = newUser("breed_neither");
+ mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"小白\",\"species\":\"dog\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void createWithSpeciesMismatchedBreedIsRejected() throws Exception {
+ UUID user = newUser("breed_mismatch");
+ mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"name":"错配","species":"dog","breedId":"%s"}
+ """.formatted(anyBreedId("cat"))))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void createWithInvalidSpeciesIsRejected() throws Exception {
+ UUID user = newUser("bad_species");
+ mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"龙\",\"species\":\"dragon\",\"customBreedName\":\"东方龙\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void patchWithoutVersionIsRejected() throws Exception {
+ UUID owner = newUser("patch_nover");
+ String petId = createPet(owner, "无版本");
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"改名\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void patchCannotSetDeletedStatus() throws Exception {
+ UUID owner = newUser("patch_del");
+ String petId = createPet(owner, "禁删");
+ // 软删除不走 PATCH(ck_pets_deleted 的 deleted_at 记账不能被绕过)
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"status\":\"deleted\"}"))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void breedsWithInvalidSpeciesParamIsRejected() throws Exception {
+ UUID user = newUser("breeds_bad");
+ mockMvc.perform(get("/api/v1/breeds").param("species", "bird")
+ .header("Authorization", "Bearer " + tokenFor(user)))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ // ---- 资源不存在路径 ----
+
+ @Test
+ void getUnknownPetReturns404() throws Exception {
+ UUID user = newUser("get_unknown");
+ mockMvc.perform(get("/api/v1/pets/{id}", UUID.randomUUID())
+ .header("Authorization", "Bearer " + tokenFor(user)))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(40401));
+ }
+
+ @Test
+ void patchUnknownPetReturns404() throws Exception {
+ UUID user = newUser("patch_unknown");
+ mockMvc.perform(patch("/api/v1/pets/{id}", UUID.randomUUID())
+ .header("Authorization", "Bearer " + tokenFor(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"改名\"}"))
+ .andExpect(status().isNotFound())
+ .andExpect(jsonPath("$.code").value(40401));
+ }
+
+ // ---- 并发冲突路径 ----
+
+ @Test
+ void staleVersionReturns409() throws Exception {
+ UUID owner = newUser("conflict_owner");
+ String petId = createPet(owner, "冲突猫");
+
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"先到先得\"}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.version").value(1));
+
+ // 第二个客户端仍持有 version=0 —— 明确冲突,不静默覆盖
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"version\":0,\"name\":\"后到冲突\"}"))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(40902));
+
+ mockMvc.perform(get("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner)))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.name").value("先到先得"));
+ }
+
+ // ---- 幂等/重复路径 ----
+
+ @Test
+ void duplicateMicrochipReturns409() throws Exception {
+ UUID owner = newUser("chip_owner");
+ mockMvc.perform(post("/api/v1/pets")
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"name\":\"芯片一号\",\"species\":\"cat\",\"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\"}"))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(40903));
+ }
+
+ @Test
+ void retriedPatchWithSameVersionConflictsInsteadOfDoubleApplying() throws Exception {
+ UUID owner = newUser("retry_owner");
+ String petId = createPet(owner, "重试猫");
+
+ String body = "{\"version\":0,\"name\":\"重试后的名字\"}";
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isOk());
+
+ // 客户端重发同一请求(如超时重试):version 已消耗,返回 409 而非重复生效;
+ // 客户端收到 409 后刷新即可发现更新其实已成功 —— 乐观锁即幂等兜底。
+ mockMvc.perform(patch("/api/v1/pets/{id}", petId)
+ .header("Authorization", "Bearer " + tokenFor(owner))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content(body))
+ .andExpect(status().isConflict())
+ .andExpect(jsonPath("$.code").value(40902));
+
+ Integer version = jdbcClient.sql("SELECT version FROM pet_health.pets WHERE id = :id")
+ .param("id", UUID.fromString(petId))
+ .query(Integer.class)
+ .single();
+ org.assertj.core.api.Assertions.assertThat(version).isEqualTo(1);
+ }
+}
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java
new file mode 100644
index 0000000..b9f89e3
--- /dev/null
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java
@@ -0,0 +1,75 @@
+package com.patbond.patbond.pet.support;
+
+import com.patbond.patbond.pet.TestcontainersConfiguration;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import java.time.Duration;
+import java.util.UUID;
+
+/**
+ * Base for pet-domain integration tests: Testcontainers postgres:18 with the
+ * full V1..V4 migration chain (pulled from patbond-user's jar on the test
+ * classpath), MockMvc behind the real BearerAuthFilter, and helpers to mint
+ * users, tokens and pet_owners rows.
+ *
+ * Roles are seeded by writing pet_owners directly (ADR-015 / T2-10: the
+ * invitation flow is out of M2, so caregiver/viewer scenarios are
+ * constructed as test data — this is the sanctioned way to keep the
+ * permission paths verified).
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Import(TestcontainersConfiguration.class)
+public abstract class PetIntegrationTestSupport {
+
+ @Autowired
+ protected JdbcClient jdbcClient;
+
+ @DynamicPropertySource
+ static void jwtPublicKey(DynamicPropertyRegistry registry) {
+ registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
+ }
+
+ /** Inserts an identity.users row and returns its id. */
+ protected UUID newUser(String username) {
+ UUID id = UuidV7.generate();
+ jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
+ .param("id", id)
+ .param("username", username)
+ .update();
+ return id;
+ }
+
+ /** A valid access token for the user, signed like patbond-auth does. */
+ protected static String tokenFor(UUID userId) {
+ return TestJwtKeys.accessToken(TestJwtKeys.KEY_PAIR.getPrivate(), userId,
+ Duration.ofMinutes(15));
+ }
+
+ /** Adds a non-primary pet_owners row (test-data stand-in for invitations). */
+ protected void grantRole(UUID petId, UUID userId, String role) {
+ jdbcClient.sql("""
+ INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
+ VALUES (:petId, :userId, :role, false)
+ """)
+ .param("petId", petId)
+ .param("userId", userId)
+ .param("role", role)
+ .update();
+ }
+
+ /** Any enabled breed id of the given species, from the V4 seed. */
+ protected UUID anyBreedId(String species) {
+ return jdbcClient.sql(
+ "SELECT id FROM pet_health.breeds WHERE species = :species AND enabled LIMIT 1")
+ .param("species", species)
+ .query(UUID.class)
+ .single();
+ }
+}
diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/support/TestJwtKeys.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/TestJwtKeys.java
new file mode 100644
index 0000000..d3375d8
--- /dev/null
+++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/TestJwtKeys.java
@@ -0,0 +1,59 @@
+package com.patbond.patbond.pet.support;
+
+import io.jsonwebtoken.Jwts;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.PrivateKey;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.Date;
+import java.util.UUID;
+
+/**
+ * Runtime-generated RSA material for JWT tests. Nothing here is committed
+ * key material (git-workflow: no credentials in the repository) — every test
+ * run mints a fresh pair and injects the public key via
+ * {@code @DynamicPropertySource}.
+ */
+public final class TestJwtKeys {
+
+ public static final KeyPair KEY_PAIR = generate();
+ /** A second pair, for signing tokens the service must reject. */
+ public static final KeyPair WRONG_KEY_PAIR = generate();
+
+ private TestJwtKeys() {
+ }
+
+ public static String publicPem() {
+ return "-----BEGIN PUBLIC KEY-----\n"
+ + Base64.getEncoder().encodeToString(KEY_PAIR.getPublic().getEncoded())
+ + "\n-----END PUBLIC KEY-----";
+ }
+
+ /** Signs an access token the way patbond-auth does (sub/jti/sid/iat/exp). */
+ public static String accessToken(PrivateKey key, UUID userId, Duration ttl) {
+ Instant now = Instant.now();
+ return Jwts.builder()
+ .id(UUID.randomUUID().toString())
+ .subject(userId.toString())
+ .issuer("patbond-auth")
+ .claim("sid", UUID.randomUUID().toString())
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plus(ttl)))
+ .signWith(key, Jwts.SIG.RS256)
+ .compact();
+ }
+
+ private static KeyPair generate() {
+ try {
+ KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA");
+ generator.initialize(2048);
+ return generator.generateKeyPair();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+}
> list(
+ @RequestParam(required = false)
+ @Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other")
+ String species) {
+ return ApiResponse.success(breedRepository.listEnabled(species));
+ }
+}
diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetController.java
new file mode 100644
index 0000000..bfc8a6c
--- /dev/null
+++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/PetController.java
@@ -0,0 +1,68 @@
+package com.patbond.patbond.pet.controller;
+
+import com.patbond.patbond.common.response.ApiResponse;
+import com.patbond.patbond.pet.dto.CreatePetRequest;
+import com.patbond.patbond.pet.dto.PetResponse;
+import com.patbond.patbond.pet.dto.UpdatePetRequest;
+import com.patbond.patbond.pet.security.BearerAuthFilter;
+import com.patbond.patbond.pet.service.PetService;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.UUID;
+
+/**
+ * Pet profile CRUD (T2-03). Authentication is the filter's job; every
+ * pet-scoped handler delegates authorization to PetAccessService through
+ * PetService. The list endpoint needs no explicit check — its query is
+ * scoped to the caller's own pet_owners rows by construction.
+ */
+@RestController
+@RequestMapping("/api/v1/pets")
+public class PetController {
+
+ private final PetService petService;
+
+ public PetController(PetService petService) {
+ this.petService = petService;
+ }
+
+ @GetMapping
+ public ApiResponse
> list(
+ @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
+ return ApiResponse.success(petService.list(userId));
+ }
+
+ @PostMapping
+ @ResponseStatus(HttpStatus.CREATED)
+ public ApiResponse
+ *
+ *
+ *