- 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:
+68
@@ -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));
|
||||
}
|
||||
}
|
||||
+39
@@ -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.
|
||||
|
||||
+147
@@ -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"));
|
||||
}
|
||||
}
|
||||
+53
@@ -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;
|
||||
}
|
||||
}
|
||||
+467
@@ -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);
|
||||
}
|
||||
}
|
||||
+9
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user