feat: 疫苗目录与疫苗记录接口、状态机与剂次唯一(T2-05)
CI / backend-test (push) Successful in 5m18s

- GET /api/v1/vaccine-catalog(只读字典,species 过滤,仅 enabled)
- GET/POST /api/v1/pets/{petId}/vaccinations、PATCH /api/v1/vaccinations/{id}
  (顶层短路径,记录级防枚举:不可见记录一律 404/40402)
- 状态机 scheduled→completed/cancelled,completed/cancelled 终态;
  日期规则镜像 ck_vaccination_dates/ck_vaccination_next_due,违反返回
  422/42201(新码 VACCINATION_RULE_VIOLATION)
- 剂次唯一冲突 409/40904(新码 VACCINATION_DOSE_EXISTS,草案提议的
  40903 已被 MICROCHIP_EXISTS 占用故改号);cancelled 释放唯一占位
- version 乐观锁 409/40902;Idempotency-Key 幂等与 T2-04 同机制
- 疫苗须存在/启用且物种与宠物匹配(40000)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 17:44:23 +08:00
parent 825dde3928
commit 4c2653c643
13 changed files with 1305 additions and 0 deletions
@@ -21,6 +21,8 @@ public enum ErrorCode {
PHONE_EXISTS(40901, 409, "手机号已被使用"),
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
@@ -0,0 +1,68 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.CreateVaccinationRequest;
import com.patbond.patbond.pet.dto.UpdateVaccinationRequest;
import com.patbond.patbond.pet.dto.VaccinationResponse;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.service.VaccinationService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
/**
* Vaccination records (T2-05). List/create live under the pet; PATCH
* addresses the record directly by its globally-unique id (contract draft
* decision #1 — avoids the ambiguity of a mismatched path petId).
*/
@RestController
@Validated
public class VaccinationController {
private final VaccinationService vaccinationService;
public VaccinationController(VaccinationService vaccinationService) {
this.vaccinationService = vaccinationService;
}
@GetMapping("/api/v1/pets/{petId}/vaccinations")
public ApiResponse<List<VaccinationResponse>> list(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId) {
return ApiResponse.success(vaccinationService.list(userId, petId));
}
@PostMapping("/api/v1/pets/{petId}/vaccinations")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<VaccinationResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId,
@RequestHeader(value = "Idempotency-Key", required = false)
@Size(max = 255, message = "Idempotency-Key 最长 255 字符")
String idempotencyKey,
@Valid @RequestBody CreateVaccinationRequest request) {
return ApiResponse.success(
vaccinationService.create(userId, petId, idempotencyKey, request));
}
@PatchMapping("/api/v1/vaccinations/{vaccinationId}")
public ApiResponse<VaccinationResponse> update(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID vaccinationId,
@Valid @RequestBody UpdateVaccinationRequest request) {
return ApiResponse.success(vaccinationService.update(userId, vaccinationId, request));
}
}
@@ -0,0 +1,39 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.VaccineCatalogResponse;
import com.patbond.patbond.pet.repository.VaccineCatalogRepository;
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 vaccine dictionary (T2-05). Authenticated like every /api/v1
* route but not permission-checked — dictionary rows are not user data.
* Returned in full (10 seed rows, V4); no pagination — same treatment as
* /api/v1/breeds.
*/
@RestController
@RequestMapping("/api/v1/vaccine-catalog")
@Validated
public class VaccineCatalogController {
private final VaccineCatalogRepository vaccineCatalogRepository;
public VaccineCatalogController(VaccineCatalogRepository vaccineCatalogRepository) {
this.vaccineCatalogRepository = vaccineCatalogRepository;
}
@GetMapping
public ApiResponse<List<VaccineCatalogResponse>> list(
@RequestParam(required = false)
@Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other")
String species) {
return ApiResponse.success(vaccineCatalogRepository.listEnabled(species));
}
}
@@ -0,0 +1,145 @@
package com.patbond.patbond.pet.dto;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.UUID;
/**
* POST /api/v1/pets/{petId}/vaccinations. A record is created as
* {@code scheduled} (plan) or {@code completed} (backfilled shot) —
* creating one already {@code cancelled} makes no sense and the status
* whitelist rejects it. Cross-field status/date rules
* (ck_vaccination_dates) are checked in the service with 42201.
* certificate/provider/booking fields are absent by design (ADR-010: no
* write path in M2).
*/
public class CreateVaccinationRequest {
@NotNull(message = "vaccineId 不能为空")
private UUID vaccineId;
@NotBlank(message = "seriesKey 不能为空")
@Size(max = 64, message = "seriesKey 最长 64 字符")
private String seriesKey;
@NotNull(message = "doseNo 不能为空")
@Min(value = 1, message = "doseNo 必须大于 0")
@Max(value = 32767, message = "doseNo 超出范围")
private Integer doseNo;
@Size(max = 64, message = "doseLabel 最长 64 字符")
private String doseLabel;
@NotBlank(message = "status 不能为空")
@Pattern(regexp = "scheduled|completed", message = "创建时 status 仅支持 scheduled/completed")
private String status;
private LocalDate plannedOn;
private LocalDate administeredOn;
private LocalDate nextDueOn;
@Size(max = 128, message = "manufacturer 最长 128 字符")
private String manufacturer;
@Size(max = 64, message = "batchNo 最长 64 字符")
private String batchNo;
@Size(max = 1000, message = "notes 最长 1000 字符")
private String notes;
public UUID getVaccineId() {
return vaccineId;
}
public void setVaccineId(UUID vaccineId) {
this.vaccineId = vaccineId;
}
public String getSeriesKey() {
return seriesKey;
}
public void setSeriesKey(String seriesKey) {
this.seriesKey = seriesKey;
}
public Integer getDoseNo() {
return doseNo;
}
public void setDoseNo(Integer doseNo) {
this.doseNo = doseNo;
}
public String getDoseLabel() {
return doseLabel;
}
public void setDoseLabel(String doseLabel) {
this.doseLabel = doseLabel;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDate getPlannedOn() {
return plannedOn;
}
public void setPlannedOn(LocalDate plannedOn) {
this.plannedOn = plannedOn;
}
public LocalDate getAdministeredOn() {
return administeredOn;
}
public void setAdministeredOn(LocalDate administeredOn) {
this.administeredOn = administeredOn;
}
public LocalDate getNextDueOn() {
return nextDueOn;
}
public void setNextDueOn(LocalDate nextDueOn) {
this.nextDueOn = nextDueOn;
}
public String getManufacturer() {
return manufacturer;
}
public void setManufacturer(String manufacturer) {
this.manufacturer = manufacturer;
}
public String getBatchNo() {
return batchNo;
}
public void setBatchNo(String batchNo) {
this.batchNo = batchNo;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}
@@ -0,0 +1,115 @@
package com.patbond.patbond.pet.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.util.UUID;
/**
* PATCH /api/v1/vaccinations/{vaccinationId}. Partial update under the
* optimistic lock ({@code version} mandatory, like pets): absent fields keep
* their value; M2 does not support clearing a field back to null (same rule
* frozen for pets in T2-03). Identity fields (vaccineId/seriesKey/doseNo)
* are immutable — cancel and re-create to fix a wrong dose. Status/date
* consistency is validated on the merged state (42201).
*/
public class UpdateVaccinationRequest {
@NotNull(message = "version 不能为空")
private Integer version;
@Pattern(regexp = "scheduled|completed|cancelled", message = "status 仅支持 scheduled/completed/cancelled")
private String status;
private LocalDate plannedOn;
private LocalDate administeredOn;
private LocalDate nextDueOn;
@Size(max = 64, message = "doseLabel 最长 64 字符")
private String doseLabel;
@Size(max = 128, message = "manufacturer 最长 128 字符")
private String manufacturer;
@Size(max = 64, message = "batchNo 最长 64 字符")
private String batchNo;
@Size(max = 1000, message = "notes 最长 1000 字符")
private String notes;
public Integer getVersion() {
return version;
}
public void setVersion(Integer version) {
this.version = version;
}
public String getStatus() {
return status;
}
public void setStatus(String status) {
this.status = status;
}
public LocalDate getPlannedOn() {
return plannedOn;
}
public void setPlannedOn(LocalDate plannedOn) {
this.plannedOn = plannedOn;
}
public LocalDate getAdministeredOn() {
return administeredOn;
}
public void setAdministeredOn(LocalDate administeredOn) {
this.administeredOn = administeredOn;
}
public LocalDate getNextDueOn() {
return nextDueOn;
}
public void setNextDueOn(LocalDate nextDueOn) {
this.nextDueOn = nextDueOn;
}
public String getDoseLabel() {
return doseLabel;
}
public void setDoseLabel(String doseLabel) {
this.doseLabel = doseLabel;
}
public String getManufacturer() {
return manufacturer;
}
public void setManufacturer(String manufacturer) {
this.manufacturer = manufacturer;
}
public String getBatchNo() {
return batchNo;
}
public void setBatchNo(String batchNo) {
this.batchNo = batchNo;
}
public String getNotes() {
return notes;
}
public void setNotes(String notes) {
this.notes = notes;
}
}
@@ -0,0 +1,32 @@
package com.patbond.patbond.pet.dto;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* One vaccination record as the API returns it. {@code vaccineName} is
* resolved from the catalog (same convenience as breedDisplayName on pets).
* certificate/provider/booking columns are intentionally not exposed
* (ADR-010: value is always null in M2; they will appear later as a purely
* additive change).
*/
public record VaccinationResponse(
UUID id,
UUID petId,
UUID vaccineId,
String vaccineName,
String seriesKey,
Integer doseNo,
String doseLabel,
String status,
LocalDate plannedOn,
LocalDate administeredOn,
LocalDate nextDueOn,
String manufacturer,
String batchNo,
String notes,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
Integer version) {
}
@@ -0,0 +1,12 @@
package com.patbond.patbond.pet.dto;
import java.util.UUID;
/** One row of the read-only vaccine dictionary (GET /api/v1/vaccine-catalog). */
public record VaccineCatalogResponse(
UUID id,
String code,
String name,
String species,
String description) {
}
@@ -87,6 +87,19 @@ public class PetRepository {
.optional();
}
/**
* The pet's species, for gate-passed callers (vaccine/species match
* check in T2-05) — no pet_owners join because PetAccessService has
* already vouched for visibility.
*/
public Optional<String> findSpecies(UUID petId) {
return jdbcClient.sql(
"SELECT species FROM pet_health.pets WHERE id = :petId AND status <> 'deleted'")
.param("petId", petId)
.query(String.class)
.optional();
}
/**
* Full-row optimistic-lock update: writes the merged state and bumps
* version only when the row still carries the version the caller read.
@@ -0,0 +1,147 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.VaccinationResponse;
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.pet_vaccinations access. Reads join the catalog for the
* display name (same convenience as breeds on pets). Permission gating
* happens in the service before any call lands here.
*/
@Repository
public class VaccinationRepository {
private static final String SELECT_VACCINATION = """
SELECT v.id, v.pet_id, v.vaccine_id, c.name AS vaccine_name, v.series_key,
v.dose_no, v.dose_label, v.status, v.planned_on, v.administered_on,
v.next_due_on, v.manufacturer, v.batch_no, v.notes,
v.created_at, v.updated_at, v.version
FROM pet_health.pet_vaccinations v
JOIN pet_health.vaccine_catalog c ON c.id = v.vaccine_id
""";
private final JdbcClient jdbcClient;
public VaccinationRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one vaccination. {@code ON CONFLICT (id) DO NOTHING} targets
* only the primary key (idempotent replay); a violation of
* uq_pet_vaccination_dose still raises DuplicateKeyException, which the
* service maps to 40904.
*
* @return rows inserted — 0 means the id already exists (keyed retry)
*/
public int insert(UUID id, UUID petId, UUID vaccineId, String seriesKey, int doseNo,
String doseLabel, String status, LocalDate plannedOn,
LocalDate administeredOn, LocalDate nextDueOn, String manufacturer,
String batchNo, String notes) {
return jdbcClient.sql("""
INSERT INTO pet_health.pet_vaccinations
(id, pet_id, vaccine_id, series_key, dose_no, dose_label, status,
planned_on, administered_on, next_due_on, manufacturer, batch_no, notes)
VALUES (:id, :petId, :vaccineId, :seriesKey, :doseNo, :doseLabel, :status,
:plannedOn, :administeredOn, :nextDueOn, :manufacturer, :batchNo, :notes)
ON CONFLICT (id) DO NOTHING
""")
.param("id", id)
.param("petId", petId)
.param("vaccineId", vaccineId)
.param("seriesKey", seriesKey)
.param("doseNo", doseNo)
.param("doseLabel", doseLabel)
.param("status", status)
.param("plannedOn", plannedOn)
.param("administeredOn", administeredOn)
.param("nextDueOn", nextDueOn)
.param("manufacturer", manufacturer)
.param("batchNo", batchNo)
.param("notes", notes)
.update();
}
public Optional<VaccinationResponse> findById(UUID id) {
return jdbcClient.sql(SELECT_VACCINATION + " WHERE v.id = :id")
.param("id", id)
.query(VaccinationRepository::mapVaccination)
.optional();
}
/**
* Full list for one pet, unpaginated (per-pet volume is a handful of
* rows). Ordered by series then dose so the client renders vaccine
* cards without re-sorting; created_at/id break residual ties.
*/
public List<VaccinationResponse> listByPet(UUID petId) {
return jdbcClient.sql(SELECT_VACCINATION + """
WHERE v.pet_id = :petId
ORDER BY v.series_key, v.dose_no, v.created_at, v.id
""")
.param("petId", petId)
.query(VaccinationRepository::mapVaccination)
.list();
}
/**
* Optimistic-lock update of the merged state; updated_at is bumped by
* the trg_vaccinations_updated_at trigger.
*
* @return rows updated — 0 means the version is stale
*/
public int updateWithVersion(UUID id, int expectedVersion, String status,
LocalDate plannedOn, LocalDate administeredOn,
LocalDate nextDueOn, String doseLabel, String manufacturer,
String batchNo, String notes) {
return jdbcClient.sql("""
UPDATE pet_health.pet_vaccinations
SET status = :status, planned_on = :plannedOn,
administered_on = :administeredOn, next_due_on = :nextDueOn,
dose_label = :doseLabel, manufacturer = :manufacturer,
batch_no = :batchNo, notes = :notes, version = version + 1
WHERE id = :id AND version = :expectedVersion
""")
.param("id", id)
.param("expectedVersion", expectedVersion)
.param("status", status)
.param("plannedOn", plannedOn)
.param("administeredOn", administeredOn)
.param("nextDueOn", nextDueOn)
.param("doseLabel", doseLabel)
.param("manufacturer", manufacturer)
.param("batchNo", batchNo)
.param("notes", notes)
.update();
}
private static VaccinationResponse mapVaccination(ResultSet rs, int rowNum) throws SQLException {
return new VaccinationResponse(
rs.getObject("id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getObject("vaccine_id", UUID.class),
rs.getString("vaccine_name"),
rs.getString("series_key"),
rs.getInt("dose_no"),
rs.getString("dose_label"),
rs.getString("status"),
rs.getObject("planned_on", LocalDate.class),
rs.getObject("administered_on", LocalDate.class),
rs.getObject("next_due_on", LocalDate.class),
rs.getString("manufacturer"),
rs.getString("batch_no"),
rs.getString("notes"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
rs.getInt("version"));
}
}
@@ -0,0 +1,53 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.VaccineCatalogResponse;
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 pet_health.vaccine_catalog dictionary access. */
@Repository
public class VaccineCatalogRepository {
private final JdbcClient jdbcClient;
public VaccineCatalogRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/** Enabled rows, optionally filtered by species; (species, name) order matches the partial index. */
public List<VaccineCatalogResponse> listEnabled(String species) {
String sql = """
SELECT id, code, name, species, description
FROM pet_health.vaccine_catalog
WHERE enabled
""" + (species != null ? " AND species = :species" : "") + """
ORDER BY species, name
""";
var spec = jdbcClient.sql(sql);
if (species != null) {
spec = spec.param("species", species);
}
return spec.query((rs, rowNum) -> new VaccineCatalogResponse(
rs.getObject("id", UUID.class),
rs.getString("code"),
rs.getString("name"),
rs.getString("species"),
rs.getString("description")))
.list();
}
/** The species of an enabled catalog entry — empty when unknown or disabled. */
public Optional<String> findEnabledSpecies(UUID vaccineId) {
return jdbcClient.sql("""
SELECT species FROM pet_health.vaccine_catalog
WHERE id = :id AND enabled
""")
.param("id", vaccineId)
.query(String.class)
.optional();
}
}
@@ -0,0 +1,203 @@
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.CreateVaccinationRequest;
import com.patbond.patbond.pet.dto.UpdateVaccinationRequest;
import com.patbond.patbond.pet.dto.VaccinationResponse;
import com.patbond.patbond.pet.repository.PetRepository;
import com.patbond.patbond.pet.repository.VaccinationRepository;
import com.patbond.patbond.pet.repository.VaccineCatalogRepository;
import com.patbond.patbond.pet.support.IdempotencyKeys;
import com.patbond.patbond.pet.support.UuidV7;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
/**
* Vaccination use-cases (T2-05). The status machine mirrors
* ck_vaccination_dates exactly and is enforced here first so clients get
* 42201 with a readable reason instead of a constraint 500:
*
* <pre>
* scheduled ──→ completed (administeredOn required)
* scheduled ──→ cancelled
* completed, cancelled: terminal (same-status edits stay allowed)
* </pre>
*
* A cancelled record leaves the uq_pet_vaccination_dose partition, so the
* same series/dose can be registered again — the sanctioned way to fix a
* mis-entered record, which is also why cancelled→scheduled reactivation is
* not offered (it could collide with the replacement).
*/
@Service
public class VaccinationService {
private final VaccinationRepository vaccinationRepository;
private final VaccineCatalogRepository vaccineCatalogRepository;
private final PetRepository petRepository;
private final PetAccessService petAccessService;
public VaccinationService(VaccinationRepository vaccinationRepository,
VaccineCatalogRepository vaccineCatalogRepository,
PetRepository petRepository,
PetAccessService petAccessService) {
this.vaccinationRepository = vaccinationRepository;
this.vaccineCatalogRepository = vaccineCatalogRepository;
this.petRepository = petRepository;
this.petAccessService = petAccessService;
}
public List<VaccinationResponse> list(UUID userId, UUID petId) {
petAccessService.require(userId, petId, AccessLevel.READ);
return vaccinationRepository.listByPet(petId);
}
public VaccinationResponse create(UUID userId, UUID petId, String idempotencyKey,
CreateVaccinationRequest request) {
petAccessService.require(userId, petId, AccessLevel.WRITE);
validateVaccineForPet(request.getVaccineId(), petId);
validateStatusDates(request.getStatus(), request.getPlannedOn(),
request.getAdministeredOn(), request.getNextDueOn());
boolean keyed = idempotencyKey != null && !idempotencyKey.isBlank();
UUID id = keyed
? IdempotencyKeys.deriveId("vaccination", userId, petId, idempotencyKey)
: UuidV7.generate();
try {
vaccinationRepository.insert(id, petId, request.getVaccineId(),
request.getSeriesKey().trim(), request.getDoseNo(),
trimOrNull(request.getDoseLabel()), request.getStatus(),
request.getPlannedOn(), request.getAdministeredOn(), request.getNextDueOn(),
trimOrNull(request.getManufacturer()), trimOrNull(request.getBatchNo()),
trimOrNull(request.getNotes()));
} catch (DuplicateKeyException e) {
// ON CONFLICT only swallows the id clash, so this is
// uq_pet_vaccination_dose: same vaccine+series+dose, not cancelled.
throw new BusinessException(ErrorCode.VACCINATION_DOSE_EXISTS);
}
return vaccinationRepository.findById(id)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
/**
* Top-level record path (/api/v1/vaccinations/{id}), so anti-enumeration
* moves down one level: a record that does not exist and a record on a
* pet the caller cannot see answer identically with 40402. Only a caller
* who can already read the pet ever gets a 40300.
*/
public VaccinationResponse update(UUID userId, UUID vaccinationId,
UpdateVaccinationRequest request) {
VaccinationResponse current = vaccinationRepository.findById(vaccinationId)
.orElseThrow(() -> new BusinessException(ErrorCode.RECORD_NOT_FOUND));
try {
petAccessService.require(userId, current.petId(), AccessLevel.WRITE);
} catch (BusinessException e) {
if (e.getCode() == ErrorCode.PET_NOT_FOUND.getCode()) {
throw new BusinessException(ErrorCode.RECORD_NOT_FOUND);
}
throw e;
}
String targetStatus = request.getStatus() != null ? request.getStatus() : current.status();
if (!isLegalTransition(current.status(), targetStatus)) {
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
"状态不可从 %s 迁移到 %s".formatted(current.status(), targetStatus));
}
// Merge then validate: the row after this PATCH must satisfy the
// same status/date rules as a fresh insert.
LocalDate plannedOn = request.getPlannedOn() != null
? request.getPlannedOn() : current.plannedOn();
LocalDate administeredOn = request.getAdministeredOn() != null
? request.getAdministeredOn() : current.administeredOn();
LocalDate nextDueOn = request.getNextDueOn() != null
? request.getNextDueOn() : current.nextDueOn();
validateStatusDates(targetStatus, plannedOn, administeredOn, nextDueOn);
int updated = vaccinationRepository.updateWithVersion(
vaccinationId, request.getVersion(), targetStatus, plannedOn, administeredOn,
nextDueOn,
request.getDoseLabel() != null
? trimOrNull(request.getDoseLabel()) : current.doseLabel(),
request.getManufacturer() != null
? trimOrNull(request.getManufacturer()) : current.manufacturer(),
request.getBatchNo() != null
? trimOrNull(request.getBatchNo()) : current.batchNo(),
request.getNotes() != null
? trimOrNull(request.getNotes()) : current.notes());
if (updated == 0) {
// The record existed a moment ago, so a missed conditional
// update means the version is stale.
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
return vaccinationRepository.findById(vaccinationId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
private void validateVaccineForPet(UUID vaccineId, UUID petId) {
String vaccineSpecies = vaccineCatalogRepository.findEnabledSpecies(vaccineId)
.orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR,
"疫苗不存在或已停用"));
String petSpecies = petRepository.findSpecies(petId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
if (!vaccineSpecies.equals(petSpecies)) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "疫苗与宠物物种不匹配");
}
}
private static boolean isLegalTransition(String from, String to) {
if (from.equals(to)) {
return true;
}
return from.equals("scheduled")
&& (to.equals("completed") || to.equals("cancelled"));
}
/** Application-side twin of ck_vaccination_dates + ck_vaccination_next_due. */
private static void validateStatusDates(String status, LocalDate plannedOn,
LocalDate administeredOn, LocalDate nextDueOn) {
switch (status) {
case "scheduled" -> {
if (plannedOn == null) {
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
"scheduled 状态必须填写 plannedOn");
}
if (administeredOn != null) {
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
"scheduled 状态不能携带 administeredOn");
}
}
case "completed" -> {
if (administeredOn == null) {
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
"completed 状态必须填写 administeredOn");
}
}
case "cancelled" -> {
if (administeredOn != null) {
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
"cancelled 状态不能携带 administeredOn");
}
}
default -> throw new BusinessException(ErrorCode.VALIDATION_ERROR,
"status 不合法");
}
if (nextDueOn != null && administeredOn != null && nextDueOn.isBefore(administeredOn)) {
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
"nextDueOn 不能早于 administeredOn");
}
}
private static String trimOrNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
}
@@ -0,0 +1,467 @@
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.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.everyItem;
import static org.hamcrest.Matchers.greaterThanOrEqualTo;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.is;
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-05 acceptance over real PostgreSQL: read-only catalog, the
* scheduled/completed/cancelled machine with its date rules (42201), the
* non-cancelled dose uniqueness (40904, freed again by cancelling), the
* optimistic lock (40902), record-level anti-enumeration on the top-level
* PATCH path (40402), the keyed idempotent create, and the T2-03 role
* matrix including caregiver write success.
*/
class VaccinationIntegrationTest extends PetIntegrationTestSupport {
@Autowired
private MockMvc mockMvc;
private UUID createPet(UUID ownerId, String name) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"%s","species":"cat","customBreedName":"狸花猫"}
""".formatted(name)))
.andExpect(status().isCreated())
.andReturn();
return UUID.fromString(
JsonPath.read(result.getResponse().getContentAsString(), "$.data.id"));
}
private String scheduledJson(UUID vaccineId, String seriesKey, int doseNo) {
return """
{"vaccineId":"%s","seriesKey":"%s","doseNo":%d,
"status":"scheduled","plannedOn":"2026-10-01"}
""".formatted(vaccineId, seriesKey, doseNo);
}
private String postVaccination(UUID userId, UUID petId, String json) throws Exception {
MvcResult result = mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(userId))
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
return JsonPath.read(result.getResponse().getContentAsString(), "$.data.id");
}
// ---- 疫苗目录 ----
@Test
void catalogListsAndFiltersBySpecies() throws Exception {
UUID user = newUser("vc_reader");
mockMvc.perform(get("/api/v1/vaccine-catalog")
.header("Authorization", "Bearer " + tokenFor(user)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data", hasSize(greaterThanOrEqualTo(10))));
mockMvc.perform(get("/api/v1/vaccine-catalog")
.header("Authorization", "Bearer " + tokenFor(user))
.param("species", "cat"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data[*].species", everyItem(is("cat"))))
.andExpect(jsonPath("$.data", hasSize(4)));
mockMvc.perform(get("/api/v1/vaccine-catalog")
.header("Authorization", "Bearer " + tokenFor(user))
.param("species", "bird"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
// ---- 成功路径:计划 → 完成 ----
@Test
void ownerSchedulesThenCompletesVaccination() throws Exception {
UUID owner = newUser("vax_owner");
UUID petId = createPet(owner, "针针猫");
UUID vaccineId = vaccineIdByCode("feline_3in1");
MvcResult created = mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"vaccineId":"%s","seriesKey":"kitten-core","doseNo":1,
"doseLabel":"首针","status":"scheduled","plannedOn":"2026-10-01"}
""".formatted(vaccineId)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.code").value(0))
.andExpect(jsonPath("$.data.petId").value(petId.toString()))
.andExpect(jsonPath("$.data.vaccineId").value(vaccineId.toString()))
.andExpect(jsonPath("$.data.vaccineName").value("猫三联疫苗"))
.andExpect(jsonPath("$.data.status").value("scheduled"))
.andExpect(jsonPath("$.data.plannedOn").value("2026-10-01"))
.andExpect(jsonPath("$.data.administeredOn").isEmpty())
.andExpect(jsonPath("$.data.version").value(0))
.andReturn();
String vaccinationId = JsonPath.read(created.getResponse().getContentAsString(), "$.data.id");
mockMvc.perform(patch("/api/v1/vaccinations/{id}", vaccinationId)
.header("Authorization", "Bearer " + tokenFor(owner))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"version":0,"status":"completed","administeredOn":"2026-10-02",
"nextDueOn":"2027-10-02","manufacturer":"硕腾","batchNo":"B123"}
"""))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.status").value("completed"))
.andExpect(jsonPath("$.data.administeredOn").value("2026-10-02"))
.andExpect(jsonPath("$.data.nextDueOn").value("2027-10-02"))
// 未提交的字段保持不变(部分更新语义)
.andExpect(jsonPath("$.data.plannedOn").value("2026-10-01"))
.andExpect(jsonPath("$.data.doseLabel").value("首针"))
.andExpect(jsonPath("$.data.version").value(1));
mockMvc.perform(get("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(1)))
.andExpect(jsonPath("$.data[0].id").value(vaccinationId));
}
/** T2-03 移交要求:WRITE 档 caregiver 的正向写用例。 */
@Test
void caregiverCanCreateAndPatchVaccinations() throws Exception {
UUID owner = newUser("vax_cg_owner");
UUID caregiver = newUser("vax_caregiver");
UUID petId = createPet(owner, "代管猫");
grantRole(petId, caregiver, "caregiver");
UUID vaccineId = vaccineIdByCode("rabies_cat");
// caregiver 直接补录已完成的接种
String vaccinationId = postVaccination(caregiver, petId, """
{"vaccineId":"%s","seriesKey":"rabies-2026","doseNo":1,
"status":"completed","administeredOn":"2026-09-01"}
""".formatted(vaccineId));
mockMvc.perform(patch("/api/v1/vaccinations/{id}", vaccinationId)
.header("Authorization", "Bearer " + tokenFor(caregiver))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"接种后无异常\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.notes").value("接种后无异常"))
.andExpect(jsonPath("$.data.version").value(1));
}
@Test
void listIsOrderedBySeriesThenDose() throws Exception {
UUID owner = newUser("vax_sorter");
UUID petId = createPet(owner, "排序猫");
UUID core = vaccineIdByCode("feline_3in1");
UUID rabies = vaccineIdByCode("rabies_cat");
postVaccination(owner, petId, scheduledJson(core, "kitten-core", 2));
postVaccination(owner, petId, scheduledJson(rabies, "annual-rabies", 1));
postVaccination(owner, petId, scheduledJson(core, "kitten-core", 1));
mockMvc.perform(get("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(3)))
.andExpect(jsonPath("$.data[0].seriesKey").value("annual-rabies"))
.andExpect(jsonPath("$.data[1].seriesKey").value("kitten-core"))
.andExpect(jsonPath("$.data[1].doseNo").value(1))
.andExpect(jsonPath("$.data[2].seriesKey").value("kitten-core"))
.andExpect(jsonPath("$.data[2].doseNo").value(2));
}
// ---- 参数错误(40000:请求形状/字典问题) ----
@Test
void createValidationErrorsAreRejected() throws Exception {
UUID owner = newUser("vax_bad_input");
UUID petId = createPet(owner, "验证猫二号");
UUID catVaccine = vaccineIdByCode("feline_3in1");
UUID dogVaccine = vaccineIdByCode("rabies_dog");
String token = tokenFor(owner);
List<String> badBodies = List.of(
// 缺 vaccineId
"{\"seriesKey\":\"s\",\"doseNo\":1,\"status\":\"scheduled\",\"plannedOn\":\"2026-10-01\"}",
// doseNo 非正
scheduledJson(catVaccine, "s", 0),
// 创建即 cancelled 不被接受
"{\"vaccineId\":\"%s\",\"seriesKey\":\"s\",\"doseNo\":1,\"status\":\"cancelled\"}"
.formatted(catVaccine),
// 疫苗不存在
scheduledJson(UUID.randomUUID(), "s", 1),
// 犬用疫苗打在猫身上
scheduledJson(dogVaccine, "s", 1));
for (String bad : badBodies) {
mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(bad))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
// PATCH 缺 version → 40000
String vaccinationId = postVaccination(owner, petId, scheduledJson(catVaccine, "ok", 1));
mockMvc.perform(patch("/api/v1/vaccinations/{id}", vaccinationId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"notes\":\"没带版本号\"}"))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.code").value(40000));
}
// ---- 状态机 / 日期约束(42201 ----
@Test
void statusDateRulesAreEnforcedOnCreate() throws Exception {
UUID owner = newUser("vax_dates");
UUID petId = createPet(owner, "日期猫");
UUID vaccineId = vaccineIdByCode("feline_3in1");
String token = tokenFor(owner);
List<String> illegalBodies = List.of(
// scheduled 缺 plannedOn
"{\"vaccineId\":\"%s\",\"seriesKey\":\"s\",\"doseNo\":1,\"status\":\"scheduled\"}"
.formatted(vaccineId),
// scheduled 却带 administeredOn
("{\"vaccineId\":\"%s\",\"seriesKey\":\"s\",\"doseNo\":1,\"status\":\"scheduled\","
+ "\"plannedOn\":\"2026-10-01\",\"administeredOn\":\"2026-10-01\"}")
.formatted(vaccineId),
// completed 缺 administeredOn
"{\"vaccineId\":\"%s\",\"seriesKey\":\"s\",\"doseNo\":1,\"status\":\"completed\"}"
.formatted(vaccineId),
// nextDueOn 早于 administeredOn
("{\"vaccineId\":\"%s\",\"seriesKey\":\"s\",\"doseNo\":1,\"status\":\"completed\","
+ "\"administeredOn\":\"2026-09-01\",\"nextDueOn\":\"2026-08-31\"}")
.formatted(vaccineId));
for (String bad : illegalBodies) {
mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(bad))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42201));
}
}
@Test
void illegalTransitionsAreRejected() throws Exception {
UUID owner = newUser("vax_machine");
UUID petId = createPet(owner, "状态机猫");
UUID vaccineId = vaccineIdByCode("feline_3in1");
String token = tokenFor(owner);
// scheduled→completed 但缺 administeredOn
String scheduled = postVaccination(owner, petId, scheduledJson(vaccineId, "m1", 1));
mockMvc.perform(patch("/api/v1/vaccinations/{id}", scheduled)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"status\":\"completed\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42201));
// completed 为终态:completed→cancelled 拒绝
String completed = postVaccination(owner, petId, """
{"vaccineId":"%s","seriesKey":"m2","doseNo":1,
"status":"completed","administeredOn":"2026-09-01"}
""".formatted(vaccineId));
mockMvc.perform(patch("/api/v1/vaccinations/{id}", completed)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"status\":\"cancelled\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42201));
// cancelled 为终态:cancelled→scheduled 拒绝(补登记应新建记录)
String cancelled = postVaccination(owner, petId, scheduledJson(vaccineId, "m3", 1));
mockMvc.perform(patch("/api/v1/vaccinations/{id}", cancelled)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"status\":\"cancelled\"}"))
.andExpect(status().isOk());
mockMvc.perform(patch("/api/v1/vaccinations/{id}", cancelled)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":1,\"status\":\"scheduled\"}"))
.andExpect(status().isUnprocessableEntity())
.andExpect(jsonPath("$.code").value(42201));
}
// ---- 剂次唯一(40904)与 cancelled 释放占位 ----
@Test
void duplicateDoseConflictsAndCancelFreesTheSlot() throws Exception {
UUID owner = newUser("vax_dupe");
UUID petId = createPet(owner, "剂次猫");
UUID vaccineId = vaccineIdByCode("feline_3in1");
String token = tokenFor(owner);
String first = postVaccination(owner, petId, scheduledJson(vaccineId, "core", 1));
mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(scheduledJson(vaccineId, "core", 1)))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40904));
// 取消原记录后,同系列同剂次可重新登记(部分唯一索引仅约束非 cancelled)
mockMvc.perform(patch("/api/v1/vaccinations/{id}", first)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"status\":\"cancelled\"}"))
.andExpect(status().isOk());
mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content(scheduledJson(vaccineId, "core", 1)))
.andExpect(status().isCreated());
}
// ---- 并发冲突(40902 ----
@Test
void staleVersionLosesAndFirstWriteIsKept() throws Exception {
UUID owner = newUser("vax_racer");
UUID petId = createPet(owner, "并发猫");
UUID vaccineId = vaccineIdByCode("feline_3in1");
String token = tokenFor(owner);
String vaccinationId = postVaccination(owner, petId, scheduledJson(vaccineId, "race", 1));
mockMvc.perform(patch("/api/v1/vaccinations/{id}", vaccinationId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"先写者\"}"))
.andExpect(status().isOk());
mockMvc.perform(patch("/api/v1/vaccinations/{id}", vaccinationId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"迟到者\"}"))
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value(40902));
String notes = jdbcClient.sql(
"SELECT notes FROM pet_health.pet_vaccinations WHERE id = :id")
.param("id", UUID.fromString(vaccinationId))
.query(String.class)
.single();
assertThat(notes).isEqualTo("先写者");
}
// ---- 不存在 / 无权限(防枚举) ----
@Test
void invisibleRecordsAnswerIdentical404() throws Exception {
UUID owner = newUser("vax_secret");
UUID stranger = newUser("vax_stranger");
UUID petId = createPet(owner, "保密猫");
UUID vaccineId = vaccineIdByCode("feline_3in1");
String vaccinationId = postVaccination(owner, petId, scheduledJson(vaccineId, "s", 1));
// 陌生人访问宠物级路径 → 与不存在的 petId 完全一致的 40401
mockMvc.perform(get("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(stranger)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
mockMvc.perform(get("/api/v1/pets/{petId}/vaccinations", UUID.randomUUID())
.header("Authorization", "Bearer " + tokenFor(stranger)))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40401));
// 顶层记录路径:不存在的 id 与他人的真实 id 同样 40402(记录级防枚举)
for (Object target : List.of(UUID.randomUUID().toString(), vaccinationId)) {
mockMvc.perform(patch("/api/v1/vaccinations/{id}", target)
.header("Authorization", "Bearer " + tokenFor(stranger))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"探测\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value(40402))
.andExpect(jsonPath("$.message").value("记录不存在"));
}
}
@Test
void viewerReadsButCannotWrite() throws Exception {
UUID owner = newUser("vax_view_owner");
UUID viewer = newUser("vax_viewer");
UUID petId = createPet(owner, "旁观猫");
grantRole(petId, viewer, "viewer");
UUID vaccineId = vaccineIdByCode("feline_3in1");
String vaccinationId = postVaccination(owner, petId, scheduledJson(vaccineId, "v", 1));
mockMvc.perform(get("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data", hasSize(1)));
mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(viewer))
.contentType(MediaType.APPLICATION_JSON)
.content(scheduledJson(vaccineId, "v", 2)))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
// viewer 对宠物可见,越权修改记录得到 403(而非 404)
mockMvc.perform(patch("/api/v1/vaccinations/{id}", vaccinationId)
.header("Authorization", "Bearer " + tokenFor(viewer))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"version\":0,\"notes\":\"越权\"}"))
.andExpect(status().isForbidden())
.andExpect(jsonPath("$.code").value(40300));
}
// ---- 幂等重试 ----
@Test
void idempotencyKeyRetryDoesNotDuplicate() throws Exception {
UUID owner = newUser("vax_idem");
UUID petId = createPet(owner, "重试猫二号");
UUID vaccineId = vaccineIdByCode("feline_3in1");
String key = "idem-" + UUID.randomUUID();
String json = scheduledJson(vaccineId, "idem-series", 1);
MvcResult first = mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.header("Idempotency-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
String firstId = JsonPath.read(first.getResponse().getContentAsString(), "$.data.id");
MvcResult retry = mockMvc.perform(post("/api/v1/pets/{petId}/vaccinations", petId)
.header("Authorization", "Bearer " + tokenFor(owner))
.header("Idempotency-Key", key)
.contentType(MediaType.APPLICATION_JSON)
.content(json))
.andExpect(status().isCreated())
.andReturn();
assertThat((String) JsonPath.read(retry.getResponse().getContentAsString(), "$.data.id"))
.isEqualTo(firstId);
Integer count = jdbcClient.sql(
"SELECT count(*) FROM pet_health.pet_vaccinations WHERE pet_id = :petId")
.param("petId", petId)
.query(Integer.class)
.single();
assertThat(count).isEqualTo(1);
}
}
@@ -72,4 +72,13 @@ public abstract class PetIntegrationTestSupport {
.query(UUID.class)
.single();
}
/** The vaccine_catalog id for a seeded code (V4), e.g. "feline_3in1". */
protected UUID vaccineIdByCode(String code) {
return jdbcClient.sql(
"SELECT id FROM pet_health.vaccine_catalog WHERE code = :code AND enabled")
.param("code", code)
.query(UUID.class)
.single();
}
}