diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java index 316b0d1..bfcb3b4 100644 --- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java +++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java @@ -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, "依赖服务暂不可用"); diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/VaccinationController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/VaccinationController.java new file mode 100644 index 0000000..7b73acc --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/VaccinationController.java @@ -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( + @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 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 update( + @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId, + @PathVariable UUID vaccinationId, + @Valid @RequestBody UpdateVaccinationRequest request) { + return ApiResponse.success(vaccinationService.update(userId, vaccinationId, request)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/VaccineCatalogController.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/VaccineCatalogController.java new file mode 100644 index 0000000..0ff1b11 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/controller/VaccineCatalogController.java @@ -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( + @RequestParam(required = false) + @Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other") + String species) { + return ApiResponse.success(vaccineCatalogRepository.listEnabled(species)); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateVaccinationRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateVaccinationRequest.java new file mode 100644 index 0000000..1cee88d --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/CreateVaccinationRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateVaccinationRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateVaccinationRequest.java new file mode 100644 index 0000000..2d1bec2 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdateVaccinationRequest.java @@ -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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/VaccinationResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/VaccinationResponse.java new file mode 100644 index 0000000..588921a --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/VaccinationResponse.java @@ -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) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/VaccineCatalogResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/VaccineCatalogResponse.java new file mode 100644 index 0000000..146f638 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/VaccineCatalogResponse.java @@ -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) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java index 6cb0519..4347b64 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java @@ -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 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. diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/VaccinationRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/VaccinationRepository.java new file mode 100644 index 0000000..dda39f3 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/VaccinationRepository.java @@ -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 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 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")); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/VaccineCatalogRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/VaccineCatalogRepository.java new file mode 100644 index 0000000..00edc79 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/VaccineCatalogRepository.java @@ -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 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 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(); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/VaccinationService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/VaccinationService.java new file mode 100644 index 0000000..1bb1e38 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/VaccinationService.java @@ -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: + * + *
+ *   scheduled ──→ completed   (administeredOn required)
+ *   scheduled ──→ cancelled
+ *   completed, cancelled: terminal (same-status edits stay allowed)
+ * 
+ * + * 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 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; + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/VaccinationIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/VaccinationIntegrationTest.java new file mode 100644 index 0000000..cbb5fa4 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/VaccinationIntegrationTest.java @@ -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 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 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); + } +} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java index b9f89e3..339f4c8 100644 --- a/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/support/PetIntegrationTestSupport.java @@ -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(); + } }