- GET/POST /api/v1/pets、GET/PATCH /api/v1/pets/{petId}、GET /api/v1/breeds
(species 过滤);创建者自动写入 pet_owners primary owner(同事务)
- PetAccessService 统一权限闸口(READ/WRITE/MANAGE 三档 × owner/caregiver/
viewer 三角色,ADR-015):无关系一律 404/40401 防枚举,可见越权 403/40300;
每请求实时查库无缓存,撤销关系即时生效;T2-04~07 复用同一入口
- PATCH version 乐观锁(40902);部分更新语义;status 白名单排除 deleted,
软删除不可经 PATCH 绕过 ck_pets_deleted
- ck_pets_breed 互斥 + 字典品种存在/物种匹配应用层先行校验(40000);
芯片号唯一冲突新错误码 40903 MICROCHIP_EXISTS
- pet 模块接入 RS256 bearer 鉴权(与 user 同一公钥约定),compose 挂载公钥
- 测试基建:test classpath 引入 patbond-user + Flyway,Testcontainers 上跑
完整 V1..V4 迁移链;三角色以测试数据直写 pet_owners 构造(T2-10)
- 集成测试覆盖六类路径 23 例;全套 118 测试全绿(基线 95)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+5
-3
@@ -60,9 +60,9 @@ services:
|
||||
depends_on:
|
||||
- user
|
||||
|
||||
# M2(ADR-009):宠物健康档案服务。第一波为骨架(仅 /health 探活,无业务端点)。
|
||||
# 与 user 共库;Flyway 迁移链由 user 服务统一执行,故依赖 user 先起,
|
||||
# 保证 pet_health schema 已就绪。
|
||||
# M2(ADR-009):宠物健康档案服务。第二波起提供 /api/v1/pets、/api/v1/breeds
|
||||
# 业务端点(RS256 校验,与 user 同一公钥)。与 user 共库;Flyway 迁移链由
|
||||
# user 服务统一执行,故依赖 user 先起,保证 pet_health schema 已就绪。
|
||||
pet:
|
||||
build: ./patbond-pet
|
||||
environment:
|
||||
@@ -70,8 +70,10 @@ services:
|
||||
PATBOND_DB_URL: jdbc:postgresql://postgres:5432/${PATBOND_DB_NAME:-patbond}
|
||||
PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond}
|
||||
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
||||
volumes:
|
||||
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||
- ./deploy/keys:/run/patbond/keys:ro
|
||||
ports:
|
||||
- "${PATBOND_PET_PORT:-8083}:8083"
|
||||
depends_on:
|
||||
|
||||
@@ -20,6 +20,7 @@ public enum ErrorCode {
|
||||
USERNAME_EXISTS(40900, 409, "用户名已存在"),
|
||||
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
||||
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
|
||||
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
||||
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
||||
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
||||
|
||||
+44
-5
@@ -17,11 +17,9 @@
|
||||
<name>patbond-pet</name>
|
||||
<description>Pet profile and health record service for Patbond (ADR-009)</description>
|
||||
|
||||
<!-- M2 first-wave skeleton: web + datasource wiring and a liveness endpoint.
|
||||
Flyway is intentionally absent — the single migration chain (V1..V4,
|
||||
including the pet_health schema) is owned and applied by patbond-user,
|
||||
the shared database's startup path. This module only reads/writes the
|
||||
pet_health schema once business endpoints land (M2 second wave). -->
|
||||
<!-- M2 second-wave: business endpoints with RS256 bearer auth (same JWT
|
||||
verification stack as patbond-user), pet_health schema CRUD. Flyway
|
||||
remains absent — the migration chain is owned by patbond-user. -->
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
@@ -45,11 +43,52 @@
|
||||
<artifactId>postgresql</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<!-- Access token verification (RS256, public key only): jjwt is not in
|
||||
the Boot BOM, version pinned in step with patbond-user/auth. -->
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-api</artifactId>
|
||||
<version>0.12.6</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-impl</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>io.jsonwebtoken</groupId>
|
||||
<artifactId>jjwt-jackson</artifactId>
|
||||
<version>0.12.6</version>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<!-- Tests need the pet_health schema. The migration chain (V1..V4) is
|
||||
owned by patbond-user (single flyway_schema_history); pulling its
|
||||
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
|
||||
auto-config apply the same chain to the disposable container.
|
||||
Production wiring is unchanged: this module still ships without
|
||||
Flyway and the chain runs in patbond-user's startup path. -->
|
||||
<dependency>
|
||||
<groupId>com.patbond.patbond</groupId>
|
||||
<artifactId>patbond-user</artifactId>
|
||||
<version>${project.version}</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-database-postgresql</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-testcontainers</artifactId>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.patbond.patbond.pet.access;
|
||||
|
||||
/**
|
||||
* What a request wants to do with a pet. Every /pets/** endpoint maps to
|
||||
* exactly one level, checked by {@link PetAccessService}:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code READ} — owner, caregiver and viewer.</li>
|
||||
* <li>{@code WRITE} — owner and caregiver; the level for health-record
|
||||
* style sub-resources (weights, vaccinations, health events,
|
||||
* reminders — T2-04~07 reuse this).</li>
|
||||
* <li>{@code MANAGE} — owner only; the pet profile itself (PATCH /pets/
|
||||
* {petId}, status transitions) and, later, member management.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum AccessLevel {
|
||||
|
||||
READ,
|
||||
WRITE,
|
||||
MANAGE;
|
||||
|
||||
boolean allowedFor(PetRole role) {
|
||||
return switch (this) {
|
||||
case READ -> true;
|
||||
case WRITE -> role == PetRole.OWNER || role == PetRole.CAREGIVER;
|
||||
case MANAGE -> role == PetRole.OWNER;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.patbond.patbond.pet.access;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* The single permission gate for every /pets/{petId}/** request — T2-04~07
|
||||
* call {@link #require} first and then trust the returned {@link PetAccess}.
|
||||
*
|
||||
* <p>Semantics (frozen for the M2 contract, iteration-2/02 P7):
|
||||
* <ul>
|
||||
* <li>Pet does not exist, is soft-deleted, or the caller has NO row in
|
||||
* pet_owners → {@code 404/40401 PET_NOT_FOUND}. A caller without a
|
||||
* relationship cannot distinguish "someone else's pet" from "no such
|
||||
* pet" — anti-enumeration: the response must not leak that a random
|
||||
* UUID hits a real record.</li>
|
||||
* <li>Relationship exists but the role does not cover the requested
|
||||
* {@link AccessLevel} (e.g. viewer tries to write) →
|
||||
* {@code 403/40300 PET_ACCESS_DENIED}.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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) {
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<BearerAuthFilter> bearerAuthFilter(
|
||||
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
|
||||
new BearerAuthFilter(jwtVerifier, objectMapper));
|
||||
registration.addUrlPatterns("/api/v1/*");
|
||||
registration.setOrder(20);
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.patbond.patbond.pet.controller;
|
||||
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import com.patbond.patbond.pet.dto.BreedResponse;
|
||||
import com.patbond.patbond.pet.repository.BreedRepository;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Read-only breed dictionary. Authenticated (behind BearerAuthFilter like
|
||||
* every /api/v1 route) but not permission-checked — dictionary rows are not
|
||||
* user data. Returned in full (≈30 seed rows, D2-6); no pagination.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/breeds")
|
||||
@Validated
|
||||
public class BreedController {
|
||||
|
||||
private final BreedRepository breedRepository;
|
||||
|
||||
public BreedController(BreedRepository breedRepository) {
|
||||
this.breedRepository = breedRepository;
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<BreedResponse>> list(
|
||||
@RequestParam(required = false)
|
||||
@Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other")
|
||||
String species) {
|
||||
return ApiResponse.success(breedRepository.listEnabled(species));
|
||||
}
|
||||
}
|
||||
@@ -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<PetResponse>> list(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
|
||||
return ApiResponse.success(petService.list(userId));
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
public ApiResponse<PetResponse> create(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@Valid @RequestBody CreatePetRequest request) {
|
||||
return ApiResponse.success(petService.create(userId, request));
|
||||
}
|
||||
|
||||
@GetMapping("/{petId}")
|
||||
public ApiResponse<PetResponse> get(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@PathVariable UUID petId) {
|
||||
return ApiResponse.success(petService.get(userId, petId));
|
||||
}
|
||||
|
||||
@PatchMapping("/{petId}")
|
||||
public ApiResponse<PetResponse> update(
|
||||
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||
@PathVariable UUID petId,
|
||||
@Valid @RequestBody UpdatePetRequest request) {
|
||||
return ApiResponse.success(petService.update(userId, petId, request));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.patbond.patbond.pet.dto;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/** One row of the read-only breed dictionary (GET /api/v1/breeds). */
|
||||
public record BreedResponse(
|
||||
UUID id,
|
||||
String species,
|
||||
String code,
|
||||
String displayName) {
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package com.patbond.patbond.pet.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* POST /api/v1/pets. Exactly one of breedId / customBreedName must be given
|
||||
* (mirrors ck_pets_breed); the cross-field rule is checked in the service so
|
||||
* it can answer with a precise message. Species is fixed at creation — a
|
||||
* later species change would invalidate the breed pairing.
|
||||
*/
|
||||
public class CreatePetRequest {
|
||||
|
||||
@NotBlank(message = "宠物名称不能为空")
|
||||
@Size(max = 64, message = "宠物名称最长 64 字符")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "物种不能为空")
|
||||
@Pattern(regexp = "dog|cat|other", message = "物种仅支持 dog/cat/other")
|
||||
private String species;
|
||||
|
||||
private UUID breedId;
|
||||
|
||||
@Size(min = 1, max = 64, message = "自定义品种名称长度须在 1~64 字符")
|
||||
private String customBreedName;
|
||||
|
||||
@Pattern(regexp = "male|female|unknown", message = "性别仅支持 male/female/unknown")
|
||||
private String sex;
|
||||
|
||||
private LocalDate birthDate;
|
||||
|
||||
private Boolean birthDateEstimated;
|
||||
|
||||
@Size(max = 64, message = "性格描述最长 64 字符")
|
||||
private String personality;
|
||||
|
||||
@Size(max = 64, message = "芯片号最长 64 字符")
|
||||
private String microchipNo;
|
||||
|
||||
private LocalDate sterilizedOn;
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public String getSpecies() {
|
||||
return species;
|
||||
}
|
||||
|
||||
public void setSpecies(String species) {
|
||||
this.species = species;
|
||||
}
|
||||
|
||||
public UUID getBreedId() {
|
||||
return breedId;
|
||||
}
|
||||
|
||||
public void setBreedId(UUID breedId) {
|
||||
this.breedId = breedId;
|
||||
}
|
||||
|
||||
public String getCustomBreedName() {
|
||||
return customBreedName;
|
||||
}
|
||||
|
||||
public void setCustomBreedName(String customBreedName) {
|
||||
this.customBreedName = customBreedName;
|
||||
}
|
||||
|
||||
public String getSex() {
|
||||
return sex;
|
||||
}
|
||||
|
||||
public void setSex(String sex) {
|
||||
this.sex = sex;
|
||||
}
|
||||
|
||||
public LocalDate getBirthDate() {
|
||||
return birthDate;
|
||||
}
|
||||
|
||||
public void setBirthDate(LocalDate birthDate) {
|
||||
this.birthDate = birthDate;
|
||||
}
|
||||
|
||||
public Boolean getBirthDateEstimated() {
|
||||
return birthDateEstimated;
|
||||
}
|
||||
|
||||
public void setBirthDateEstimated(Boolean birthDateEstimated) {
|
||||
this.birthDateEstimated = birthDateEstimated;
|
||||
}
|
||||
|
||||
public String getPersonality() {
|
||||
return personality;
|
||||
}
|
||||
|
||||
public void setPersonality(String personality) {
|
||||
this.personality = personality;
|
||||
}
|
||||
|
||||
public String getMicrochipNo() {
|
||||
return microchipNo;
|
||||
}
|
||||
|
||||
public void setMicrochipNo(String microchipNo) {
|
||||
this.microchipNo = microchipNo;
|
||||
}
|
||||
|
||||
public LocalDate getSterilizedOn() {
|
||||
return sterilizedOn;
|
||||
}
|
||||
|
||||
public void setSterilizedOn(LocalDate sterilizedOn) {
|
||||
this.sterilizedOn = sterilizedOn;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.patbond.patbond.pet.dto;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A pet as the API returns it. {@code breedDisplayName} is resolved from the
|
||||
* dictionary when {@code breedId} is set; exactly one of {@code breedId} /
|
||||
* {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the
|
||||
* calling user's own pet_owners role — the client uses it to gate write UI.
|
||||
*/
|
||||
public record PetResponse(
|
||||
UUID id,
|
||||
String name,
|
||||
String species,
|
||||
UUID breedId,
|
||||
String breedDisplayName,
|
||||
String customBreedName,
|
||||
String sex,
|
||||
LocalDate birthDate,
|
||||
Boolean birthDateEstimated,
|
||||
String personality,
|
||||
String microchipNo,
|
||||
LocalDate sterilizedOn,
|
||||
String status,
|
||||
String myRole,
|
||||
OffsetDateTime createdAt,
|
||||
OffsetDateTime updatedAt,
|
||||
Integer version) {
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.patbond.patbond.pet.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import jakarta.validation.constraints.PositiveOrZero;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* PATCH /api/v1/pets/{petId}. Partial update: an absent (or null) field is
|
||||
* left unchanged — M2 does not support clearing an optional field back to
|
||||
* null, keeping the null-vs-absent question out of the contract. The breed
|
||||
* pair is the exception: providing either breedId or customBreedName
|
||||
* replaces the pair as a whole (they are mutually exclusive per
|
||||
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
|
||||
* the whole endpoint exists to enforce.
|
||||
*/
|
||||
public class UpdatePetRequest {
|
||||
|
||||
@NotNull(message = "version 不能为空")
|
||||
@PositiveOrZero(message = "version 必须为非负整数")
|
||||
private Integer version;
|
||||
|
||||
@Size(min = 1, max = 64, message = "宠物名称长度须在 1~64 字符")
|
||||
private String name;
|
||||
|
||||
private UUID breedId;
|
||||
|
||||
@Size(min = 1, max = 64, message = "自定义品种名称长度须在 1~64 字符")
|
||||
private String customBreedName;
|
||||
|
||||
@Pattern(regexp = "male|female|unknown", message = "性别仅支持 male/female/unknown")
|
||||
private String sex;
|
||||
|
||||
private LocalDate birthDate;
|
||||
|
||||
private Boolean birthDateEstimated;
|
||||
|
||||
@Size(max = 64, message = "性格描述最长 64 字符")
|
||||
private String personality;
|
||||
|
||||
@Size(max = 64, message = "芯片号最长 64 字符")
|
||||
private String microchipNo;
|
||||
|
||||
private LocalDate sterilizedOn;
|
||||
|
||||
/**
|
||||
* Status transition; allowed targets are active/lost/deceased/archived.
|
||||
* 'deleted' is not settable through PATCH — soft delete has its own
|
||||
* endpoint so its deleted_at bookkeeping (ck_pets_deleted) cannot be
|
||||
* bypassed.
|
||||
*/
|
||||
@Pattern(regexp = "active|lost|deceased|archived",
|
||||
message = "status 仅支持 active/lost/deceased/archived")
|
||||
private String status;
|
||||
|
||||
public Integer getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(Integer version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public UUID getBreedId() {
|
||||
return breedId;
|
||||
}
|
||||
|
||||
public void setBreedId(UUID breedId) {
|
||||
this.breedId = breedId;
|
||||
}
|
||||
|
||||
public String getCustomBreedName() {
|
||||
return customBreedName;
|
||||
}
|
||||
|
||||
public void setCustomBreedName(String customBreedName) {
|
||||
this.customBreedName = customBreedName;
|
||||
}
|
||||
|
||||
public String getSex() {
|
||||
return sex;
|
||||
}
|
||||
|
||||
public void setSex(String sex) {
|
||||
this.sex = sex;
|
||||
}
|
||||
|
||||
public LocalDate getBirthDate() {
|
||||
return birthDate;
|
||||
}
|
||||
|
||||
public void setBirthDate(LocalDate birthDate) {
|
||||
this.birthDate = birthDate;
|
||||
}
|
||||
|
||||
public Boolean getBirthDateEstimated() {
|
||||
return birthDateEstimated;
|
||||
}
|
||||
|
||||
public void setBirthDateEstimated(Boolean birthDateEstimated) {
|
||||
this.birthDateEstimated = birthDateEstimated;
|
||||
}
|
||||
|
||||
public String getPersonality() {
|
||||
return personality;
|
||||
}
|
||||
|
||||
public void setPersonality(String personality) {
|
||||
this.personality = personality;
|
||||
}
|
||||
|
||||
public String getMicrochipNo() {
|
||||
return microchipNo;
|
||||
}
|
||||
|
||||
public void setMicrochipNo(String microchipNo) {
|
||||
this.microchipNo = microchipNo;
|
||||
}
|
||||
|
||||
public LocalDate getSterilizedOn() {
|
||||
return sterilizedOn;
|
||||
}
|
||||
|
||||
public void setSterilizedOn(LocalDate sterilizedOn) {
|
||||
this.sterilizedOn = sterilizedOn;
|
||||
}
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.patbond.patbond.pet.repository;
|
||||
|
||||
import com.patbond.patbond.pet.dto.BreedResponse;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/** Read-only access to the pet_health.breeds dictionary (V4 seed, D2-6). */
|
||||
@Repository
|
||||
public class BreedRepository {
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public BreedRepository(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
public List<BreedResponse> listEnabled(String species) {
|
||||
String sql = """
|
||||
SELECT id, species, code, display_name
|
||||
FROM pet_health.breeds
|
||||
WHERE enabled
|
||||
""" + (species != null ? " AND species = :species" : "") + """
|
||||
ORDER BY species, sort_order, id
|
||||
""";
|
||||
var spec = jdbcClient.sql(sql);
|
||||
if (species != null) {
|
||||
spec = spec.param("species", species);
|
||||
}
|
||||
return spec.query((rs, rowNum) -> new BreedResponse(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("species"),
|
||||
rs.getString("code"),
|
||||
rs.getString("display_name")))
|
||||
.list();
|
||||
}
|
||||
|
||||
/** @return the species of an enabled breed, empty when unknown/disabled */
|
||||
public Optional<String> findEnabledSpecies(UUID breedId) {
|
||||
return jdbcClient.sql(
|
||||
"SELECT species FROM pet_health.breeds WHERE id = :id AND enabled")
|
||||
.param("id", breedId)
|
||||
.query(String.class)
|
||||
.optional();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package com.patbond.patbond.pet.repository;
|
||||
|
||||
import com.patbond.patbond.pet.dto.PetResponse;
|
||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* pet_health.pets + pet_owners access. All reads join pet_owners on the
|
||||
* calling user so a row only comes back when a relationship exists — the
|
||||
* repository layer itself never exposes another user's pet.
|
||||
*/
|
||||
@Repository
|
||||
public class PetRepository {
|
||||
|
||||
private static final String SELECT_PET = """
|
||||
SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name,
|
||||
p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
|
||||
p.personality, p.microchip_no, p.sterilized_on, p.status,
|
||||
po.role, p.created_at, p.updated_at, p.version
|
||||
FROM pet_health.pets p
|
||||
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
|
||||
LEFT JOIN pet_health.breeds b ON b.id = p.breed_id
|
||||
WHERE p.status <> 'deleted'
|
||||
""";
|
||||
|
||||
private final JdbcClient jdbcClient;
|
||||
|
||||
public PetRepository(JdbcClient jdbcClient) {
|
||||
this.jdbcClient = jdbcClient;
|
||||
}
|
||||
|
||||
public void insertPet(UUID petId, String name, String species, UUID breedId,
|
||||
String customBreedName, String sex, LocalDate birthDate,
|
||||
boolean birthDateEstimated, String personality,
|
||||
String microchipNo, LocalDate sterilizedOn) {
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO pet_health.pets
|
||||
(id, name, species, breed_id, custom_breed_name, sex, birth_date,
|
||||
birth_date_estimated, personality, microchip_no, sterilized_on)
|
||||
VALUES (:id, :name, :species, :breedId, :customBreedName, :sex, :birthDate,
|
||||
:birthDateEstimated, :personality, :microchipNo, :sterilizedOn)
|
||||
""")
|
||||
.param("id", petId)
|
||||
.param("name", name)
|
||||
.param("species", species)
|
||||
.param("breedId", breedId)
|
||||
.param("customBreedName", customBreedName)
|
||||
.param("sex", sex)
|
||||
.param("birthDate", birthDate)
|
||||
.param("birthDateEstimated", birthDateEstimated)
|
||||
.param("personality", personality)
|
||||
.param("microchipNo", microchipNo)
|
||||
.param("sterilizedOn", sterilizedOn)
|
||||
.update();
|
||||
}
|
||||
|
||||
public void insertPrimaryOwner(UUID petId, UUID userId) {
|
||||
jdbcClient.sql("""
|
||||
INSERT INTO pet_health.pet_owners (pet_id, user_id, role, is_primary)
|
||||
VALUES (:petId, :userId, 'owner', true)
|
||||
""")
|
||||
.param("petId", petId)
|
||||
.param("userId", userId)
|
||||
.update();
|
||||
}
|
||||
|
||||
public List<PetResponse> listByUser(UUID userId) {
|
||||
return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC")
|
||||
.param("userId", userId)
|
||||
.query(PetRepository::mapPet)
|
||||
.list();
|
||||
}
|
||||
|
||||
public Optional<PetResponse> findByIdForUser(UUID petId, UUID userId) {
|
||||
return jdbcClient.sql(SELECT_PET + " AND p.id = :petId")
|
||||
.param("userId", userId)
|
||||
.param("petId", petId)
|
||||
.query(PetRepository::mapPet)
|
||||
.optional();
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-row optimistic-lock update: writes the merged state and bumps
|
||||
* version only when the row still carries the version the caller read.
|
||||
*
|
||||
* @return number of rows updated — 0 means the version is stale
|
||||
*/
|
||||
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
|
||||
String customBreedName, String sex, LocalDate birthDate,
|
||||
boolean birthDateEstimated, String personality,
|
||||
String microchipNo, LocalDate sterilizedOn, String status) {
|
||||
return jdbcClient.sql("""
|
||||
UPDATE pet_health.pets
|
||||
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
|
||||
sex = :sex, birth_date = :birthDate,
|
||||
birth_date_estimated = :birthDateEstimated, personality = :personality,
|
||||
microchip_no = :microchipNo, sterilized_on = :sterilizedOn,
|
||||
status = :status, version = version + 1
|
||||
WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted'
|
||||
""")
|
||||
.param("petId", petId)
|
||||
.param("expectedVersion", expectedVersion)
|
||||
.param("name", name)
|
||||
.param("breedId", breedId)
|
||||
.param("customBreedName", customBreedName)
|
||||
.param("sex", sex)
|
||||
.param("birthDate", birthDate)
|
||||
.param("birthDateEstimated", birthDateEstimated)
|
||||
.param("personality", personality)
|
||||
.param("microchipNo", microchipNo)
|
||||
.param("sterilizedOn", sterilizedOn)
|
||||
.param("status", status)
|
||||
.update();
|
||||
}
|
||||
|
||||
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException {
|
||||
return new PetResponse(
|
||||
rs.getObject("id", UUID.class),
|
||||
rs.getString("name"),
|
||||
rs.getString("species"),
|
||||
rs.getObject("breed_id", UUID.class),
|
||||
rs.getString("breed_display_name"),
|
||||
rs.getString("custom_breed_name"),
|
||||
rs.getString("sex"),
|
||||
rs.getObject("birth_date", LocalDate.class),
|
||||
rs.getBoolean("birth_date_estimated"),
|
||||
rs.getString("personality"),
|
||||
rs.getString("microchip_no"),
|
||||
rs.getObject("sterilized_on", LocalDate.class),
|
||||
rs.getString("status"),
|
||||
rs.getString("role"),
|
||||
rs.getObject("created_at", OffsetDateTime.class),
|
||||
rs.getObject("updated_at", OffsetDateTime.class),
|
||||
rs.getInt("version"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.patbond.patbond.pet.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Bearer authentication for /api/v1/** pet endpoints. Verifies the RS256
|
||||
* signature locally with the auth service's public key and exposes the
|
||||
* authenticated user id as a request attribute. Missing, forged or expired
|
||||
* tokens all answer 401/40101 without detail.
|
||||
*/
|
||||
public class BearerAuthFilter extends OncePerRequestFilter {
|
||||
|
||||
/** Request attribute holding the authenticated user's UUID. */
|
||||
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
|
||||
|
||||
private final JwtVerifier jwtVerifier;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
|
||||
this.jwtVerifier = jwtVerifier;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||
return !request.getRequestURI().startsWith("/api/v1/");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header == null || !header.startsWith("Bearer ")) {
|
||||
reject(response, ErrorCode.TOKEN_INVALID);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
|
||||
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
|
||||
} catch (BusinessException e) {
|
||||
reject(response, ErrorCode.TOKEN_INVALID);
|
||||
return;
|
||||
} catch (IllegalStateException | IllegalArgumentException e) {
|
||||
log.error("Access token verification unavailable: {}", e.getMessage());
|
||||
reject(response, ErrorCode.INTERNAL_ERROR);
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(request, response);
|
||||
}
|
||||
|
||||
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
|
||||
response.setStatus(errorCode.getHttpStatus());
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding("UTF-8");
|
||||
objectMapper.writeValue(response.getWriter(),
|
||||
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.patbond.patbond.pet.security;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import io.jsonwebtoken.JwtException;
|
||||
import io.jsonwebtoken.JwtParser;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
|
||||
/**
|
||||
* Verifies RS256 access tokens issued by patbond-auth against the configured
|
||||
* public key. The key is optional at startup so contexts that never serve
|
||||
* protected routes (most tests) can boot without one; any verification
|
||||
* attempt without a key fails loudly as a server misconfiguration instead of
|
||||
* being reported to the client as an authentication problem.
|
||||
*/
|
||||
public class JwtVerifier {
|
||||
|
||||
private final JwtParser parser;
|
||||
|
||||
public JwtVerifier(String publicKeyLocation) {
|
||||
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
|
||||
? null
|
||||
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the verified claims
|
||||
* @throws BusinessException 40101 when the token is forged, malformed or expired
|
||||
*/
|
||||
public Claims verify(String token) {
|
||||
if (parser == null) {
|
||||
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
|
||||
}
|
||||
try {
|
||||
return parser.parseSignedClaims(token).getPayload();
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
throw new BusinessException(ErrorCode.TOKEN_INVALID);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.patbond.patbond.pet.security;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Loads an RSA public key for JWT signature verification. Accepts either
|
||||
* inline PEM or a filesystem path; the inline form (environment variable
|
||||
* PATBOND_JWT_PUBLIC_KEY starting with -----BEGIN) is production's choice
|
||||
* because mounted secrets beat files-in-the-image.
|
||||
*/
|
||||
public final class RsaPublicKeyLoader {
|
||||
|
||||
private RsaPublicKeyLoader() {
|
||||
}
|
||||
|
||||
public static PublicKey load(String pemOrPath) {
|
||||
String pem = pemOrPath.startsWith("-----BEGIN") ? pemOrPath : readFile(pemOrPath);
|
||||
String stripped = pem.replaceAll("-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----", "")
|
||||
.replaceAll("\\s", "");
|
||||
byte[] decoded = Base64.getDecoder().decode(stripped);
|
||||
try {
|
||||
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
|
||||
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
|
||||
throw new IllegalArgumentException("Invalid RSA public key", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String readFile(String path) {
|
||||
try {
|
||||
return Files.readString(Path.of(path));
|
||||
} catch (IOException e) {
|
||||
throw new IllegalArgumentException("Cannot read public key from " + path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package com.patbond.patbond.pet.service;
|
||||
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.pet.access.AccessLevel;
|
||||
import com.patbond.patbond.pet.access.PetAccessService;
|
||||
import com.patbond.patbond.pet.dto.CreatePetRequest;
|
||||
import com.patbond.patbond.pet.dto.PetResponse;
|
||||
import com.patbond.patbond.pet.dto.UpdatePetRequest;
|
||||
import com.patbond.patbond.pet.repository.BreedRepository;
|
||||
import com.patbond.patbond.pet.repository.PetRepository;
|
||||
import com.patbond.patbond.pet.support.UuidV7;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Pet profile use-cases. The application-level rules here deliberately
|
||||
* mirror the V3 database constraints (ck_pets_breed, ck_pets_status,
|
||||
* uq_pets_microchip) so clients get a stable business error instead of a
|
||||
* constraint-violation 500 — the constraints stay as the last line of
|
||||
* defense.
|
||||
*/
|
||||
@Service
|
||||
public class PetService {
|
||||
|
||||
private final PetRepository petRepository;
|
||||
private final BreedRepository breedRepository;
|
||||
private final PetAccessService petAccessService;
|
||||
|
||||
public PetService(PetRepository petRepository, BreedRepository breedRepository,
|
||||
PetAccessService petAccessService) {
|
||||
this.petRepository = petRepository;
|
||||
this.breedRepository = breedRepository;
|
||||
this.petAccessService = petAccessService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pet and its primary-owner relationship in one transaction
|
||||
* (ADR-015: the creator is the primary owner; invitations come later).
|
||||
*/
|
||||
@Transactional
|
||||
public PetResponse create(UUID userId, CreatePetRequest request) {
|
||||
validateBreedPair(request.getBreedId(), request.getCustomBreedName(), request.getSpecies());
|
||||
UUID petId = UuidV7.generate();
|
||||
try {
|
||||
petRepository.insertPet(
|
||||
petId,
|
||||
request.getName().trim(),
|
||||
request.getSpecies(),
|
||||
request.getBreedId(),
|
||||
trimOrNull(request.getCustomBreedName()),
|
||||
request.getSex() == null ? "unknown" : request.getSex(),
|
||||
request.getBirthDate(),
|
||||
Boolean.TRUE.equals(request.getBirthDateEstimated()),
|
||||
trimOrNull(request.getPersonality()),
|
||||
trimOrNull(request.getMicrochipNo()),
|
||||
request.getSterilizedOn());
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
||||
}
|
||||
petRepository.insertPrimaryOwner(petId, userId);
|
||||
return petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
}
|
||||
|
||||
public List<PetResponse> list(UUID userId) {
|
||||
return petRepository.listByUser(userId);
|
||||
}
|
||||
|
||||
public PetResponse get(UUID userId, UUID petId) {
|
||||
petAccessService.require(userId, petId, AccessLevel.READ);
|
||||
return petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial update under the optimistic lock. Merge happens in Java (read
|
||||
* current row → overlay non-null request fields → single conditional
|
||||
* UPDATE), so a stale {@code version} loses even when the write would be
|
||||
* a no-op.
|
||||
*/
|
||||
@Transactional
|
||||
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
|
||||
petAccessService.require(userId, petId, AccessLevel.MANAGE);
|
||||
PetResponse current = petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
||||
|
||||
UUID breedId = current.breedId();
|
||||
String customBreedName = current.customBreedName();
|
||||
if (request.getBreedId() != null || request.getCustomBreedName() != null) {
|
||||
// The pair is replaced as a whole: sending one side clears the other.
|
||||
breedId = request.getBreedId();
|
||||
customBreedName = trimOrNull(request.getCustomBreedName());
|
||||
validateBreedPair(breedId, customBreedName, current.species());
|
||||
}
|
||||
|
||||
String name = request.getName() != null ? request.getName().trim() : current.name();
|
||||
String sex = request.getSex() != null ? request.getSex() : current.sex();
|
||||
String status = request.getStatus() != null ? request.getStatus() : current.status();
|
||||
|
||||
int updated;
|
||||
try {
|
||||
updated = petRepository.updateWithVersion(
|
||||
petId,
|
||||
request.getVersion(),
|
||||
name,
|
||||
breedId,
|
||||
customBreedName,
|
||||
sex,
|
||||
request.getBirthDate() != null ? request.getBirthDate() : current.birthDate(),
|
||||
request.getBirthDateEstimated() != null
|
||||
? request.getBirthDateEstimated() : current.birthDateEstimated(),
|
||||
request.getPersonality() != null
|
||||
? trimOrNull(request.getPersonality()) : current.personality(),
|
||||
request.getMicrochipNo() != null
|
||||
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
|
||||
request.getSterilizedOn() != null
|
||||
? request.getSterilizedOn() : current.sterilizedOn(),
|
||||
status);
|
||||
} catch (DuplicateKeyException e) {
|
||||
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
||||
}
|
||||
if (updated == 0) {
|
||||
// The pet was visible a moment ago (require() passed), so a
|
||||
// missed conditional update means the version is stale.
|
||||
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||
}
|
||||
return petRepository.findByIdForUser(petId, userId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly one of breedId / customBreedName (ck_pets_breed), and a chosen
|
||||
* dictionary breed must exist, be enabled and match the pet's species
|
||||
* (the composite FK would reject the mismatch — this turns it into a
|
||||
* 40000 with a readable message).
|
||||
*/
|
||||
private void validateBreedPair(UUID breedId, String customBreedName, String species) {
|
||||
boolean hasBreed = breedId != null;
|
||||
boolean hasCustom = customBreedName != null && !customBreedName.isBlank();
|
||||
if (hasBreed == hasCustom) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"breedId 与 customBreedName 必须二选一");
|
||||
}
|
||||
if (hasBreed) {
|
||||
String breedSpecies = breedRepository.findEnabledSpecies(breedId)
|
||||
.orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||
"品种不存在或已停用"));
|
||||
if (!breedSpecies.equals(species)) {
|
||||
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "品种与物种不匹配");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String trimOrNull(String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
String trimmed = value.trim();
|
||||
return trimmed.isEmpty() ? null : trimmed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.patbond.patbond.pet.support;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond
|
||||
* timestamp, version/variant bits, 74 random bits. Time-ordered values keep
|
||||
* B-tree page churn low on uuid primary keys; the database DEFAULT
|
||||
* gen_random_uuid() remains the fallback for rows not inserted through the
|
||||
* application. Same implementation as patbond-user's — the services are
|
||||
* independently deployed and patbond-common stays contract-only, so each
|
||||
* carries its own copy of this 20-line utility.
|
||||
*/
|
||||
public final class UuidV7 {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private UuidV7() {
|
||||
}
|
||||
|
||||
public static UUID generate() {
|
||||
long timestampMs = System.currentTimeMillis();
|
||||
long randA = RANDOM.nextLong() & 0x0FFFL;
|
||||
long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL;
|
||||
|
||||
long msb = (timestampMs << 16) | 0x7000L | randA;
|
||||
long lsb = 0x8000000000000000L | randB;
|
||||
return new UUID(msb, lsb);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.patbond.patbond.pet.web;
|
||||
import com.patbond.patbond.common.error.BusinessException;
|
||||
import com.patbond.patbond.common.error.ErrorCode;
|
||||
import com.patbond.patbond.common.response.ApiResponse;
|
||||
import jakarta.validation.ConstraintViolationException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -11,6 +12,7 @@ import org.springframework.validation.FieldError;
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
import org.springframework.web.method.annotation.HandlerMethodValidationException;
|
||||
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||
import org.springframework.web.servlet.resource.NoResourceFoundException;
|
||||
|
||||
@@ -40,7 +42,8 @@ public class GlobalExceptionHandler {
|
||||
return failure(ErrorCode.VALIDATION_ERROR, message);
|
||||
}
|
||||
|
||||
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class})
|
||||
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
|
||||
ConstraintViolationException.class, HandlerMethodValidationException.class})
|
||||
public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) {
|
||||
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
|
||||
}
|
||||
|
||||
@@ -12,5 +12,10 @@ spring:
|
||||
username: ${PATBOND_DB_USER:patbond}
|
||||
password: ${PATBOND_DB_PASSWORD:patbond}
|
||||
|
||||
# M2 第一波骨架:暂无 /api/v1 业务端点,故尚无 JWT 公钥配置。第二波接口
|
||||
# 落地时将与 patbond-user 同一约定接入 RS256 校验(PATBOND_JWT_PUBLIC_KEY)。
|
||||
# M2 第二波:/api/v1/** 业务端点已接 RS256 校验,与 patbond-user 同一约定。
|
||||
patbond:
|
||||
jwt:
|
||||
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
|
||||
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||
# 私钥只给 patbond-auth,绝不入库。
|
||||
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
||||
|
||||
@@ -9,9 +9,11 @@ import org.testcontainers.utility.DockerImageName;
|
||||
/**
|
||||
* Shared Testcontainers setup: a disposable postgres:18 (the production
|
||||
* target version) wired into the Spring context via @ServiceConnection.
|
||||
* This module carries no Flyway (the single migration chain is owned by
|
||||
* patbond-user), so tests here run against a clean database — schema-level
|
||||
* assertions about pet_health live in patbond-user's migration tests.
|
||||
* The production module carries no Flyway (the single migration chain is
|
||||
* owned by patbond-user), but the TEST classpath adds patbond-user's jar
|
||||
* plus Flyway, so Boot applies the full V1..V4 chain — including the
|
||||
* pet_health schema these tests exercise — to the fresh container exactly
|
||||
* as the shared database gets it in production.
|
||||
*/
|
||||
@TestConfiguration(proxyBeanMethods = false)
|
||||
public class TestcontainersConfiguration {
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
package com.patbond.patbond.pet.access;
|
||||
|
||||
import com.jayway.jsonpath.JsonPath;
|
||||
import com.patbond.patbond.pet.support.PetIntegrationTestSupport;
|
||||
import com.patbond.patbond.pet.support.TestJwtKeys;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.MvcResult;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* The frozen permission semantics (T2-03, contract input for T2-09):
|
||||
*
|
||||
* <ul>
|
||||
* <li>No pet_owners relationship → 404/40401 on every verb — a stranger
|
||||
* cannot distinguish someone else's pet from a nonexistent id
|
||||
* (anti-enumeration, iteration-2/02 P7).</li>
|
||||
* <li>viewer: reads succeed, writes → 403/40300.</li>
|
||||
* <li>caregiver: reads succeed, profile PATCH (MANAGE) → 403/40300; the
|
||||
* WRITE level is exercised by T2-04+ sub-resources.</li>
|
||||
* <li>owner: everything.</li>
|
||||
* <li>Unauthenticated /api/v1/** → 401/40101 (filter, before any
|
||||
* permission logic).</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>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));
|
||||
}
|
||||
}
|
||||
+322
@@ -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);
|
||||
}
|
||||
}
|
||||
+75
@@ -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.
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user