Compare commits
2 Commits
8fbf44472d
...
4c2653c643
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c2653c643 | |||
| 825dde3928 |
@@ -21,6 +21,8 @@ public enum ErrorCode {
|
|||||||
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
PHONE_EXISTS(40901, 409, "手机号已被使用"),
|
||||||
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
|
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
|
||||||
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
|
||||||
|
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
|
||||||
|
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
|
||||||
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
||||||
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
||||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
||||||
|
|||||||
+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,67 @@
|
|||||||
|
package com.patbond.patbond.pet.controller;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.response.ApiResponse;
|
||||||
|
import com.patbond.patbond.pet.dto.CreateWeightRequest;
|
||||||
|
import com.patbond.patbond.pet.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.pet.dto.WeightResponse;
|
||||||
|
import com.patbond.patbond.pet.security.BearerAuthFilter;
|
||||||
|
import com.patbond.patbond.pet.service.WeightService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Max;
|
||||||
|
import jakarta.validation.constraints.Min;
|
||||||
|
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.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.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weight records (T2-04): cursor-paginated list plus create with optional
|
||||||
|
* Idempotency-Key. Authorization goes through the T2-03 gate inside
|
||||||
|
* WeightService.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/pets/{petId}/weights")
|
||||||
|
@Validated
|
||||||
|
public class WeightController {
|
||||||
|
|
||||||
|
private final WeightService weightService;
|
||||||
|
|
||||||
|
public WeightController(WeightService weightService) {
|
||||||
|
this.weightService = weightService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<CursorPage<WeightResponse>> list(
|
||||||
|
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
|
||||||
|
@PathVariable UUID petId,
|
||||||
|
@RequestParam(defaultValue = "20")
|
||||||
|
@Min(value = 1, message = "limit 最小为 1")
|
||||||
|
@Max(value = 100, message = "limit 最大为 100")
|
||||||
|
int limit,
|
||||||
|
@RequestParam(required = false) String cursor) {
|
||||||
|
return ApiResponse.success(weightService.list(userId, petId, limit, cursor));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
@ResponseStatus(HttpStatus.CREATED)
|
||||||
|
public ApiResponse<WeightResponse> 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 CreateWeightRequest request) {
|
||||||
|
return ApiResponse.success(weightService.create(userId, petId, idempotencyKey, request));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,66 @@
|
|||||||
|
package com.patbond.patbond.pet.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.DecimalMax;
|
||||||
|
import jakarta.validation.constraints.DecimalMin;
|
||||||
|
import jakarta.validation.constraints.Digits;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Pattern;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/pets/{petId}/weights. The weight bounds mirror
|
||||||
|
* ck_pet_weight (>0 and ≤500); {@code @Digits} keeps the value inside
|
||||||
|
* numeric(6,2) instead of letting PostgreSQL round silently.
|
||||||
|
*/
|
||||||
|
public class CreateWeightRequest {
|
||||||
|
|
||||||
|
@NotNull(message = "weightKg 不能为空")
|
||||||
|
@DecimalMin(value = "0", inclusive = false, message = "weightKg 必须大于 0")
|
||||||
|
@DecimalMax(value = "500", message = "weightKg 不能超过 500")
|
||||||
|
@Digits(integer = 3, fraction = 2, message = "weightKg 最多两位小数")
|
||||||
|
private BigDecimal weightKg;
|
||||||
|
|
||||||
|
@NotNull(message = "measuredAt 不能为空")
|
||||||
|
private OffsetDateTime measuredAt;
|
||||||
|
|
||||||
|
@Pattern(regexp = "manual|clinic|device", message = "source 仅支持 manual/clinic/device")
|
||||||
|
private String source;
|
||||||
|
|
||||||
|
@Size(max = 500, message = "note 最长 500 字符")
|
||||||
|
private String note;
|
||||||
|
|
||||||
|
public BigDecimal getWeightKg() {
|
||||||
|
return weightKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setWeightKg(BigDecimal weightKg) {
|
||||||
|
this.weightKg = weightKg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OffsetDateTime getMeasuredAt() {
|
||||||
|
return measuredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setMeasuredAt(OffsetDateTime measuredAt) {
|
||||||
|
this.measuredAt = measuredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSource() {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setSource(String source) {
|
||||||
|
this.source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getNote() {
|
||||||
|
return note;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setNote(String note) {
|
||||||
|
this.note = note;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.patbond.patbond.pet.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cursor-pagination envelope body (contract draft §3.5: this shape is the
|
||||||
|
* pagination canon for the whole API). {@code nextCursor} is null exactly
|
||||||
|
* when {@code hasMore} is false.
|
||||||
|
*/
|
||||||
|
public record CursorPage<T>(
|
||||||
|
List<T> items,
|
||||||
|
String nextCursor,
|
||||||
|
boolean hasMore) {
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.patbond.patbond.pet.dto;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/** One weight record as the API returns it. */
|
||||||
|
public record WeightResponse(
|
||||||
|
UUID id,
|
||||||
|
UUID petId,
|
||||||
|
BigDecimal weightKg,
|
||||||
|
OffsetDateTime measuredAt,
|
||||||
|
String source,
|
||||||
|
String note,
|
||||||
|
OffsetDateTime createdAt) {
|
||||||
|
}
|
||||||
@@ -87,6 +87,19 @@ public class PetRepository {
|
|||||||
.optional();
|
.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
|
* Full-row optimistic-lock update: writes the merged state and bumps
|
||||||
* version only when the row still carries the version the caller read.
|
* 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,99 @@
|
|||||||
|
package com.patbond.patbond.pet.repository;
|
||||||
|
|
||||||
|
import com.patbond.patbond.pet.dto.WeightResponse;
|
||||||
|
import com.patbond.patbond.pet.support.WeightCursor;
|
||||||
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.SQLException;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* pet_health.pet_weight_records access. Permission is the caller's problem
|
||||||
|
* (PetAccessService gate runs before any of these); every query is still
|
||||||
|
* scoped by pet_id so a record can never leak across pets.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public class WeightRepository {
|
||||||
|
|
||||||
|
private static final String SELECT_WEIGHT = """
|
||||||
|
SELECT id, pet_id, weight_kg, measured_at, source, note, created_at
|
||||||
|
FROM pet_health.pet_weight_records
|
||||||
|
WHERE pet_id = :petId
|
||||||
|
""";
|
||||||
|
|
||||||
|
private final JdbcClient jdbcClient;
|
||||||
|
|
||||||
|
public WeightRepository(JdbcClient jdbcClient) {
|
||||||
|
this.jdbcClient = jdbcClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inserts one weight row; {@code ON CONFLICT (id) DO NOTHING} makes a
|
||||||
|
* replay with an idempotency-derived id a no-op.
|
||||||
|
*
|
||||||
|
* @return rows inserted — 0 means the id already exists (keyed retry)
|
||||||
|
*/
|
||||||
|
public int insert(UUID id, UUID petId, BigDecimal weightKg, OffsetDateTime measuredAt,
|
||||||
|
String source, String note) {
|
||||||
|
return jdbcClient.sql("""
|
||||||
|
INSERT INTO pet_health.pet_weight_records
|
||||||
|
(id, pet_id, weight_kg, measured_at, source, note)
|
||||||
|
VALUES (:id, :petId, :weightKg, :measuredAt, :source, :note)
|
||||||
|
ON CONFLICT (id) DO NOTHING
|
||||||
|
""")
|
||||||
|
.param("id", id)
|
||||||
|
.param("petId", petId)
|
||||||
|
.param("weightKg", weightKg)
|
||||||
|
.param("measuredAt", measuredAt)
|
||||||
|
.param("source", source)
|
||||||
|
.param("note", note)
|
||||||
|
.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<WeightResponse> findById(UUID id, UUID petId) {
|
||||||
|
return jdbcClient.sql(SELECT_WEIGHT + " AND id = :id")
|
||||||
|
.param("petId", petId)
|
||||||
|
.param("id", id)
|
||||||
|
.query(WeightRepository::mapWeight)
|
||||||
|
.optional();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page in (measured_at DESC, id DESC) order — the exact key of
|
||||||
|
* ix_pet_weight_pet_measured, so this is a plain index range scan. The
|
||||||
|
* caller asks for limit+1 rows to learn whether more pages exist; the
|
||||||
|
* tuple comparison against the cursor keeps ties on measured_at exact.
|
||||||
|
*/
|
||||||
|
public List<WeightResponse> pageByPet(UUID petId, WeightCursor after, int limitPlusOne) {
|
||||||
|
String sql = SELECT_WEIGHT;
|
||||||
|
if (after != null) {
|
||||||
|
sql += " AND (measured_at, id) < (:cursorMeasuredAt, :cursorId)";
|
||||||
|
}
|
||||||
|
sql += " ORDER BY measured_at DESC, id DESC LIMIT :limit";
|
||||||
|
var spec = jdbcClient.sql(sql)
|
||||||
|
.param("petId", petId)
|
||||||
|
.param("limit", limitPlusOne);
|
||||||
|
if (after != null) {
|
||||||
|
spec = spec.param("cursorMeasuredAt", after.measuredAt())
|
||||||
|
.param("cursorId", after.id());
|
||||||
|
}
|
||||||
|
return spec.query(WeightRepository::mapWeight).list();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WeightResponse mapWeight(ResultSet rs, int rowNum) throws SQLException {
|
||||||
|
return new WeightResponse(
|
||||||
|
rs.getObject("id", UUID.class),
|
||||||
|
rs.getObject("pet_id", UUID.class),
|
||||||
|
rs.getBigDecimal("weight_kg"),
|
||||||
|
rs.getObject("measured_at", OffsetDateTime.class),
|
||||||
|
rs.getString("source"),
|
||||||
|
rs.getString("note"),
|
||||||
|
rs.getObject("created_at", OffsetDateTime.class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.patbond.patbond.pet.service;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
import com.patbond.patbond.pet.access.AccessLevel;
|
||||||
|
import com.patbond.patbond.pet.access.PetAccessService;
|
||||||
|
import com.patbond.patbond.pet.dto.CreateVaccinationRequest;
|
||||||
|
import com.patbond.patbond.pet.dto.UpdateVaccinationRequest;
|
||||||
|
import com.patbond.patbond.pet.dto.VaccinationResponse;
|
||||||
|
import com.patbond.patbond.pet.repository.PetRepository;
|
||||||
|
import com.patbond.patbond.pet.repository.VaccinationRepository;
|
||||||
|
import com.patbond.patbond.pet.repository.VaccineCatalogRepository;
|
||||||
|
import com.patbond.patbond.pet.support.IdempotencyKeys;
|
||||||
|
import com.patbond.patbond.pet.support.UuidV7;
|
||||||
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vaccination use-cases (T2-05). The status machine mirrors
|
||||||
|
* ck_vaccination_dates exactly and is enforced here first so clients get
|
||||||
|
* 42201 with a readable reason instead of a constraint 500:
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* scheduled ──→ completed (administeredOn required)
|
||||||
|
* scheduled ──→ cancelled
|
||||||
|
* completed, cancelled: terminal (same-status edits stay allowed)
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* A cancelled record leaves the uq_pet_vaccination_dose partition, so the
|
||||||
|
* same series/dose can be registered again — the sanctioned way to fix a
|
||||||
|
* mis-entered record, which is also why cancelled→scheduled reactivation is
|
||||||
|
* not offered (it could collide with the replacement).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class VaccinationService {
|
||||||
|
|
||||||
|
private final VaccinationRepository vaccinationRepository;
|
||||||
|
private final VaccineCatalogRepository vaccineCatalogRepository;
|
||||||
|
private final PetRepository petRepository;
|
||||||
|
private final PetAccessService petAccessService;
|
||||||
|
|
||||||
|
public VaccinationService(VaccinationRepository vaccinationRepository,
|
||||||
|
VaccineCatalogRepository vaccineCatalogRepository,
|
||||||
|
PetRepository petRepository,
|
||||||
|
PetAccessService petAccessService) {
|
||||||
|
this.vaccinationRepository = vaccinationRepository;
|
||||||
|
this.vaccineCatalogRepository = vaccineCatalogRepository;
|
||||||
|
this.petRepository = petRepository;
|
||||||
|
this.petAccessService = petAccessService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<VaccinationResponse> list(UUID userId, UUID petId) {
|
||||||
|
petAccessService.require(userId, petId, AccessLevel.READ);
|
||||||
|
return vaccinationRepository.listByPet(petId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public VaccinationResponse create(UUID userId, UUID petId, String idempotencyKey,
|
||||||
|
CreateVaccinationRequest request) {
|
||||||
|
petAccessService.require(userId, petId, AccessLevel.WRITE);
|
||||||
|
validateVaccineForPet(request.getVaccineId(), petId);
|
||||||
|
validateStatusDates(request.getStatus(), request.getPlannedOn(),
|
||||||
|
request.getAdministeredOn(), request.getNextDueOn());
|
||||||
|
|
||||||
|
boolean keyed = idempotencyKey != null && !idempotencyKey.isBlank();
|
||||||
|
UUID id = keyed
|
||||||
|
? IdempotencyKeys.deriveId("vaccination", userId, petId, idempotencyKey)
|
||||||
|
: UuidV7.generate();
|
||||||
|
try {
|
||||||
|
vaccinationRepository.insert(id, petId, request.getVaccineId(),
|
||||||
|
request.getSeriesKey().trim(), request.getDoseNo(),
|
||||||
|
trimOrNull(request.getDoseLabel()), request.getStatus(),
|
||||||
|
request.getPlannedOn(), request.getAdministeredOn(), request.getNextDueOn(),
|
||||||
|
trimOrNull(request.getManufacturer()), trimOrNull(request.getBatchNo()),
|
||||||
|
trimOrNull(request.getNotes()));
|
||||||
|
} catch (DuplicateKeyException e) {
|
||||||
|
// ON CONFLICT only swallows the id clash, so this is
|
||||||
|
// uq_pet_vaccination_dose: same vaccine+series+dose, not cancelled.
|
||||||
|
throw new BusinessException(ErrorCode.VACCINATION_DOSE_EXISTS);
|
||||||
|
}
|
||||||
|
return vaccinationRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top-level record path (/api/v1/vaccinations/{id}), so anti-enumeration
|
||||||
|
* moves down one level: a record that does not exist and a record on a
|
||||||
|
* pet the caller cannot see answer identically with 40402. Only a caller
|
||||||
|
* who can already read the pet ever gets a 40300.
|
||||||
|
*/
|
||||||
|
public VaccinationResponse update(UUID userId, UUID vaccinationId,
|
||||||
|
UpdateVaccinationRequest request) {
|
||||||
|
VaccinationResponse current = vaccinationRepository.findById(vaccinationId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.RECORD_NOT_FOUND));
|
||||||
|
try {
|
||||||
|
petAccessService.require(userId, current.petId(), AccessLevel.WRITE);
|
||||||
|
} catch (BusinessException e) {
|
||||||
|
if (e.getCode() == ErrorCode.PET_NOT_FOUND.getCode()) {
|
||||||
|
throw new BusinessException(ErrorCode.RECORD_NOT_FOUND);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
String targetStatus = request.getStatus() != null ? request.getStatus() : current.status();
|
||||||
|
if (!isLegalTransition(current.status(), targetStatus)) {
|
||||||
|
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
|
||||||
|
"状态不可从 %s 迁移到 %s".formatted(current.status(), targetStatus));
|
||||||
|
}
|
||||||
|
// Merge then validate: the row after this PATCH must satisfy the
|
||||||
|
// same status/date rules as a fresh insert.
|
||||||
|
LocalDate plannedOn = request.getPlannedOn() != null
|
||||||
|
? request.getPlannedOn() : current.plannedOn();
|
||||||
|
LocalDate administeredOn = request.getAdministeredOn() != null
|
||||||
|
? request.getAdministeredOn() : current.administeredOn();
|
||||||
|
LocalDate nextDueOn = request.getNextDueOn() != null
|
||||||
|
? request.getNextDueOn() : current.nextDueOn();
|
||||||
|
validateStatusDates(targetStatus, plannedOn, administeredOn, nextDueOn);
|
||||||
|
|
||||||
|
int updated = vaccinationRepository.updateWithVersion(
|
||||||
|
vaccinationId, request.getVersion(), targetStatus, plannedOn, administeredOn,
|
||||||
|
nextDueOn,
|
||||||
|
request.getDoseLabel() != null
|
||||||
|
? trimOrNull(request.getDoseLabel()) : current.doseLabel(),
|
||||||
|
request.getManufacturer() != null
|
||||||
|
? trimOrNull(request.getManufacturer()) : current.manufacturer(),
|
||||||
|
request.getBatchNo() != null
|
||||||
|
? trimOrNull(request.getBatchNo()) : current.batchNo(),
|
||||||
|
request.getNotes() != null
|
||||||
|
? trimOrNull(request.getNotes()) : current.notes());
|
||||||
|
if (updated == 0) {
|
||||||
|
// The record existed a moment ago, so a missed conditional
|
||||||
|
// update means the version is stale.
|
||||||
|
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||||
|
}
|
||||||
|
return vaccinationRepository.findById(vaccinationId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateVaccineForPet(UUID vaccineId, UUID petId) {
|
||||||
|
String vaccineSpecies = vaccineCatalogRepository.findEnabledSpecies(vaccineId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"疫苗不存在或已停用"));
|
||||||
|
String petSpecies = petRepository.findSpecies(petId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
||||||
|
if (!vaccineSpecies.equals(petSpecies)) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "疫苗与宠物物种不匹配");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isLegalTransition(String from, String to) {
|
||||||
|
if (from.equals(to)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return from.equals("scheduled")
|
||||||
|
&& (to.equals("completed") || to.equals("cancelled"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Application-side twin of ck_vaccination_dates + ck_vaccination_next_due. */
|
||||||
|
private static void validateStatusDates(String status, LocalDate plannedOn,
|
||||||
|
LocalDate administeredOn, LocalDate nextDueOn) {
|
||||||
|
switch (status) {
|
||||||
|
case "scheduled" -> {
|
||||||
|
if (plannedOn == null) {
|
||||||
|
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
|
||||||
|
"scheduled 状态必须填写 plannedOn");
|
||||||
|
}
|
||||||
|
if (administeredOn != null) {
|
||||||
|
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
|
||||||
|
"scheduled 状态不能携带 administeredOn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "completed" -> {
|
||||||
|
if (administeredOn == null) {
|
||||||
|
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
|
||||||
|
"completed 状态必须填写 administeredOn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "cancelled" -> {
|
||||||
|
if (administeredOn != null) {
|
||||||
|
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
|
||||||
|
"cancelled 状态不能携带 administeredOn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default -> throw new BusinessException(ErrorCode.VALIDATION_ERROR,
|
||||||
|
"status 不合法");
|
||||||
|
}
|
||||||
|
if (nextDueOn != null && administeredOn != null && nextDueOn.isBefore(administeredOn)) {
|
||||||
|
throw new BusinessException(ErrorCode.VACCINATION_RULE_VIOLATION,
|
||||||
|
"nextDueOn 不能早于 administeredOn");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimOrNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
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.CreateWeightRequest;
|
||||||
|
import com.patbond.patbond.pet.dto.CursorPage;
|
||||||
|
import com.patbond.patbond.pet.dto.WeightResponse;
|
||||||
|
import com.patbond.patbond.pet.repository.WeightRepository;
|
||||||
|
import com.patbond.patbond.pet.support.IdempotencyKeys;
|
||||||
|
import com.patbond.patbond.pet.support.UuidV7;
|
||||||
|
import com.patbond.patbond.pet.support.WeightCursor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Weight-record use-cases (T2-04). Every entry point opens with the
|
||||||
|
* PetAccessService gate (READ to list, WRITE to record — owner and
|
||||||
|
* caregiver both record weights, T2-03 §4 matrix).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class WeightService {
|
||||||
|
|
||||||
|
private final WeightRepository weightRepository;
|
||||||
|
private final PetAccessService petAccessService;
|
||||||
|
|
||||||
|
public WeightService(WeightRepository weightRepository, PetAccessService petAccessService) {
|
||||||
|
this.weightRepository = weightRepository;
|
||||||
|
this.petAccessService = petAccessService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With an Idempotency-Key the record id is derived from the key, so a
|
||||||
|
* retried request re-inserts the same primary key, the ON CONFLICT
|
||||||
|
* insert is a no-op and the original record is returned — no duplicate
|
||||||
|
* row, same 201 body both times.
|
||||||
|
*/
|
||||||
|
public WeightResponse create(UUID userId, UUID petId, String idempotencyKey,
|
||||||
|
CreateWeightRequest request) {
|
||||||
|
petAccessService.require(userId, petId, AccessLevel.WRITE);
|
||||||
|
boolean keyed = idempotencyKey != null && !idempotencyKey.isBlank();
|
||||||
|
UUID id = keyed
|
||||||
|
? IdempotencyKeys.deriveId("weight", userId, petId, idempotencyKey)
|
||||||
|
: UuidV7.generate();
|
||||||
|
weightRepository.insert(id, petId, request.getWeightKg(), request.getMeasuredAt(),
|
||||||
|
request.getSource() == null ? "manual" : request.getSource(),
|
||||||
|
trimOrNull(request.getNote()));
|
||||||
|
return weightRepository.findById(id, petId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
||||||
|
}
|
||||||
|
|
||||||
|
public CursorPage<WeightResponse> list(UUID userId, UUID petId, int limit, String cursor) {
|
||||||
|
petAccessService.require(userId, petId, AccessLevel.READ);
|
||||||
|
WeightCursor after = cursor == null ? null : WeightCursor.decode(cursor);
|
||||||
|
List<WeightResponse> rows = weightRepository.pageByPet(petId, after, limit + 1);
|
||||||
|
boolean hasMore = rows.size() > limit;
|
||||||
|
List<WeightResponse> items = hasMore ? rows.subList(0, limit) : rows;
|
||||||
|
String nextCursor = hasMore
|
||||||
|
? new WeightCursor(items.get(limit - 1).measuredAt(), items.get(limit - 1).id()).encode()
|
||||||
|
: null;
|
||||||
|
return new CursorPage<>(items, nextCursor, hasMore);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimOrNull(String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String trimmed = value.trim();
|
||||||
|
return trimmed.isEmpty() ? null : trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.patbond.patbond.pet.support;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Idempotency without a key store: the record id is derived
|
||||||
|
* deterministically from (resource, caller, pet, Idempotency-Key), so a
|
||||||
|
* retried POST computes the same primary key and
|
||||||
|
* {@code INSERT ... ON CONFLICT (id) DO NOTHING} makes the second attempt a
|
||||||
|
* no-op — the handler then returns the already-created record. Scoping to
|
||||||
|
* the caller keeps two users' identical keys from colliding on the same
|
||||||
|
* pet; scoping to the pet keeps one client key from spanning pets. No table
|
||||||
|
* and no TTL needed (V3 stays untouched), at the cost of the key being
|
||||||
|
* idempotent forever rather than for a retry window — acceptable for M2:
|
||||||
|
* clients mint a fresh UUID key per logical submission.
|
||||||
|
*
|
||||||
|
* <p>Derived ids carry version bits 8 (custom) — they are deliberately not
|
||||||
|
* time-ordered UUIDv7, which only costs B-tree locality on the rare keyed
|
||||||
|
* insert.
|
||||||
|
*/
|
||||||
|
public final class IdempotencyKeys {
|
||||||
|
|
||||||
|
private IdempotencyKeys() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static UUID deriveId(String resource, UUID userId, UUID petId, String key) {
|
||||||
|
byte[] hash = sha256(resource + "|" + userId + "|" + petId + "|" + key);
|
||||||
|
long msb = 0;
|
||||||
|
long lsb = 0;
|
||||||
|
for (int i = 0; i < 8; i++) {
|
||||||
|
msb = (msb << 8) | (hash[i] & 0xFF);
|
||||||
|
lsb = (lsb << 8) | (hash[i + 8] & 0xFF);
|
||||||
|
}
|
||||||
|
msb = (msb & ~0xF000L) | 0x8000L; // version 8
|
||||||
|
lsb = (lsb & 0x3FFFFFFFFFFFFFFFL) | 0x8000000000000000L; // IETF variant
|
||||||
|
return new UUID(msb, lsb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] sha256(String input) {
|
||||||
|
try {
|
||||||
|
return MessageDigest.getInstance("SHA-256")
|
||||||
|
.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new IllegalStateException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.patbond.patbond.pet.support;
|
||||||
|
|
||||||
|
import com.patbond.patbond.common.error.BusinessException;
|
||||||
|
import com.patbond.patbond.common.error.ErrorCode;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.OffsetDateTime;
|
||||||
|
import java.time.ZoneOffset;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opaque cursor for the weight list (measured_at DESC, id DESC — the exact
|
||||||
|
* key of ix_pet_weight_pet_measured). Encodes the last row of a page as
|
||||||
|
* base64url("epochMicros:id"); the next page selects
|
||||||
|
* {@code (measured_at, id) < (cursor)} so ties on measured_at are broken by
|
||||||
|
* id and rows are neither lost nor repeated across page boundaries.
|
||||||
|
* Microsecond precision matches timestamptz exactly — no truncation drift
|
||||||
|
* between what the row holds and what the cursor replays.
|
||||||
|
*/
|
||||||
|
public record WeightCursor(OffsetDateTime measuredAt, UUID id) {
|
||||||
|
|
||||||
|
public String encode() {
|
||||||
|
long micros = Math.multiplyExact(measuredAt.toInstant().getEpochSecond(), 1_000_000L)
|
||||||
|
+ measuredAt.getNano() / 1_000L;
|
||||||
|
return Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString((micros + ":" + id).getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws BusinessException 40000 when the cursor is not one we issued */
|
||||||
|
public static WeightCursor decode(String cursor) {
|
||||||
|
try {
|
||||||
|
String raw = new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8);
|
||||||
|
int sep = raw.indexOf(':');
|
||||||
|
long micros = Long.parseLong(raw.substring(0, sep));
|
||||||
|
UUID id = UUID.fromString(raw.substring(sep + 1));
|
||||||
|
OffsetDateTime measuredAt = Instant.ofEpochSecond(
|
||||||
|
Math.floorDiv(micros, 1_000_000L),
|
||||||
|
Math.floorMod(micros, 1_000_000L) * 1_000L)
|
||||||
|
.atOffset(ZoneOffset.UTC);
|
||||||
|
return new WeightCursor(measuredAt, id);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "cursor 无效");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
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.ArrayList;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* T2-04 acceptance over real PostgreSQL: cursor pagination (no loss, no
|
||||||
|
* duplicate, tie-break across page boundaries), weight bounds, the
|
||||||
|
* Idempotency-Key retry contract, and the T2-03 permission matrix —
|
||||||
|
* including the caregiver write-success case the T2-03 report handed over
|
||||||
|
* as mandatory.
|
||||||
|
*/
|
||||||
|
class WeightIntegrationTest 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 MvcResult postWeight(UUID userId, UUID petId, String measuredAt, String weightKg)
|
||||||
|
throws Exception {
|
||||||
|
return mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(userId))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"weightKg":%s,"measuredAt":"%s"}
|
||||||
|
""".formatted(weightKg, measuredAt)))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 成功路径 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void ownerCreatesAndListsWeights() throws Exception {
|
||||||
|
UUID owner = newUser("weight_owner");
|
||||||
|
UUID petId = createPet(owner, "阿福");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"weightKg":5.25,"measuredAt":"2026-09-01T08:00:00Z",
|
||||||
|
"source":"clinic","note":"年检称重"}
|
||||||
|
"""))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.code").value(0))
|
||||||
|
.andExpect(jsonPath("$.data.petId").value(petId.toString()))
|
||||||
|
.andExpect(jsonPath("$.data.weightKg").value(5.25))
|
||||||
|
.andExpect(jsonPath("$.data.source").value("clinic"))
|
||||||
|
.andExpect(jsonPath("$.data.note").value("年检称重"));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].weightKg").value(5.25))
|
||||||
|
// source 缺省为 manual 的对照走 caregiver 用例;这里校验分页收尾
|
||||||
|
.andExpect(jsonPath("$.data.hasMore").value(false))
|
||||||
|
.andExpect(jsonPath("$.data.nextCursor").isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** T2-03 移交要求:WRITE 档 caregiver 的正向写用例(T2-03 只有 403 反证)。 */
|
||||||
|
@Test
|
||||||
|
void caregiverCanRecordWeights() throws Exception {
|
||||||
|
UUID owner = newUser("weight_cg_owner");
|
||||||
|
UUID caregiver = newUser("weight_caregiver");
|
||||||
|
UUID petId = createPet(owner, "阿旺");
|
||||||
|
grantRole(petId, caregiver, "caregiver");
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(caregiver))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"weightKg\":4.80,\"measuredAt\":\"2026-09-02T09:00:00Z\"}"))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.data.source").value("manual"));
|
||||||
|
|
||||||
|
// 双方都能读到 caregiver 写入的记录
|
||||||
|
mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.data.items[0].weightKg").value(4.80));
|
||||||
|
mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(caregiver)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items", hasSize(1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 分页正确性 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paginationWalksAllPagesWithoutLossOrDuplicate() throws Exception {
|
||||||
|
UUID owner = newUser("weight_pager");
|
||||||
|
UUID petId = createPet(owner, "分页猫");
|
||||||
|
List<String> createdIds = new ArrayList<>();
|
||||||
|
for (int i = 1; i <= 5; i++) {
|
||||||
|
MvcResult r = postWeight(owner, petId, "2026-09-0%dT08:00:00Z".formatted(i), "4.1" + i);
|
||||||
|
createdIds.add(JsonPath.read(r.getResponse().getContentAsString(), "$.data.id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
String cursor = null;
|
||||||
|
int pages = 0;
|
||||||
|
boolean hasMore = true;
|
||||||
|
while (hasMore) {
|
||||||
|
var request = get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||||
|
.param("limit", "2");
|
||||||
|
if (cursor != null) {
|
||||||
|
request = request.param("cursor", cursor);
|
||||||
|
}
|
||||||
|
MvcResult page = mockMvc.perform(request)
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andReturn();
|
||||||
|
String body = page.getResponse().getContentAsString();
|
||||||
|
List<String> ids = JsonPath.read(body, "$.data.items[*].id");
|
||||||
|
for (String id : ids) {
|
||||||
|
assertThat(seen.add(id)).as("跨页不得重复出现 %s", id).isTrue();
|
||||||
|
}
|
||||||
|
hasMore = JsonPath.read(body, "$.data.hasMore");
|
||||||
|
cursor = JsonPath.read(body, "$.data.nextCursor");
|
||||||
|
pages++;
|
||||||
|
assertThat(hasMore ? ids.size() == 2 : ids.size() <= 2).isTrue();
|
||||||
|
assertThat(pages).isLessThanOrEqualTo(5);
|
||||||
|
}
|
||||||
|
assertThat(cursor).isNull();
|
||||||
|
// 不丢:全部 5 条都被翻到;顺序为 measured_at DESC(创建序的倒序)
|
||||||
|
List<String> expected = new ArrayList<>(createdIds);
|
||||||
|
java.util.Collections.reverse(expected);
|
||||||
|
assertThat(seen).containsExactlyElementsOf(expected);
|
||||||
|
assertThat(pages).isEqualTo(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void paginationBreaksMeasuredAtTiesAcrossPageBoundary() throws Exception {
|
||||||
|
UUID owner = newUser("weight_ties");
|
||||||
|
UUID petId = createPet(owner, "同刻猫");
|
||||||
|
for (int i = 0; i < 3; i++) {
|
||||||
|
postWeight(owner, petId, "2026-09-05T10:00:00Z", "5.0" + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
MvcResult page1 = mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||||
|
.param("limit", "2"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items", hasSize(2)))
|
||||||
|
.andExpect(jsonPath("$.data.hasMore").value(true))
|
||||||
|
.andReturn();
|
||||||
|
String body1 = page1.getResponse().getContentAsString();
|
||||||
|
List<String> ids1 = JsonPath.read(body1, "$.data.items[*].id");
|
||||||
|
String cursor = JsonPath.read(body1, "$.data.nextCursor");
|
||||||
|
|
||||||
|
MvcResult page2 = mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||||
|
.param("limit", "2")
|
||||||
|
.param("cursor", cursor))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.data.hasMore").value(false))
|
||||||
|
.andReturn();
|
||||||
|
List<String> ids2 = JsonPath.read(page2.getResponse().getContentAsString(),
|
||||||
|
"$.data.items[*].id");
|
||||||
|
|
||||||
|
Set<String> all = new LinkedHashSet<>(ids1);
|
||||||
|
all.addAll(ids2);
|
||||||
|
assertThat(all).as("measured_at 相同的记录按 id 断续,不丢不重").hasSize(3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 参数错误 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void validationErrorsAreRejected() throws Exception {
|
||||||
|
UUID owner = newUser("weight_bad_input");
|
||||||
|
UUID petId = createPet(owner, "验证猫");
|
||||||
|
String token = tokenFor(owner);
|
||||||
|
|
||||||
|
List<String> badBodies = List.of(
|
||||||
|
"{\"measuredAt\":\"2026-09-01T08:00:00Z\"}", // 缺 weightKg
|
||||||
|
"{\"weightKg\":0,\"measuredAt\":\"2026-09-01T08:00:00Z\"}",
|
||||||
|
"{\"weightKg\":500.01,\"measuredAt\":\"2026-09-01T08:00:00Z\"}",
|
||||||
|
"{\"weightKg\":5.123,\"measuredAt\":\"2026-09-01T08:00:00Z\"}", // 3 位小数
|
||||||
|
"{\"weightKg\":5.2}", // 缺 measuredAt
|
||||||
|
"{\"weightKg\":5.2,\"measuredAt\":\"2026-09-01T08:00:00Z\",\"source\":\"vet\"}");
|
||||||
|
for (String bad : badBodies) {
|
||||||
|
mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(bad))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上限恰好 500.00 合法(边界值)
|
||||||
|
mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"weightKg\":500.00,\"measuredAt\":\"2026-09-01T08:00:00Z\"}"))
|
||||||
|
.andExpect(status().isCreated());
|
||||||
|
|
||||||
|
for (String limit : List.of("0", "101")) {
|
||||||
|
mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.param("limit", limit))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + token)
|
||||||
|
.param("cursor", "not-a-cursor"))
|
||||||
|
.andExpect(status().isBadRequest())
|
||||||
|
.andExpect(jsonPath("$.code").value(40000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 不存在 / 无权限(防枚举:两者响应一致) ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownOrInvisiblePetAnswersIdentical404() throws Exception {
|
||||||
|
UUID owner = newUser("weight_secret_owner");
|
||||||
|
UUID stranger = newUser("weight_stranger");
|
||||||
|
UUID petId = createPet(owner, "隐私猫");
|
||||||
|
String weightJson = "{\"weightKg\":4.2,\"measuredAt\":\"2026-09-01T08:00:00Z\"}";
|
||||||
|
|
||||||
|
for (Object target : List.of(UUID.randomUUID(), petId)) {
|
||||||
|
mockMvc.perform(get("/api/v1/pets/{petId}/weights", target)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(stranger)))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40401))
|
||||||
|
.andExpect(jsonPath("$.message").value("宠物不存在"));
|
||||||
|
mockMvc.perform(post("/api/v1/pets/{petId}/weights", target)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(stranger))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(weightJson))
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value(40401))
|
||||||
|
.andExpect(jsonPath("$.message").value("宠物不存在"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void viewerReadsButCannotWrite() throws Exception {
|
||||||
|
UUID owner = newUser("weight_view_owner");
|
||||||
|
UUID viewer = newUser("weight_viewer");
|
||||||
|
UUID petId = createPet(owner, "围观猫");
|
||||||
|
grantRole(petId, viewer, "viewer");
|
||||||
|
postWeight(owner, petId, "2026-09-01T08:00:00Z", "4.5");
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(viewer)))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.data.items", hasSize(1)));
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(viewer))
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("{\"weightKg\":4.6,\"measuredAt\":\"2026-09-02T08:00:00Z\"}"))
|
||||||
|
.andExpect(status().isForbidden())
|
||||||
|
.andExpect(jsonPath("$.code").value(40300));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 幂等重试 ----
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idempotencyKeyRetryDoesNotDuplicate() throws Exception {
|
||||||
|
UUID owner = newUser("weight_idem");
|
||||||
|
UUID petId = createPet(owner, "重试猫");
|
||||||
|
String key = "idem-" + UUID.randomUUID();
|
||||||
|
String json = "{\"weightKg\":6.10,\"measuredAt\":\"2026-09-03T08:00:00Z\"}";
|
||||||
|
|
||||||
|
MvcResult first = mockMvc.perform(post("/api/v1/pets/{petId}/weights", 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");
|
||||||
|
|
||||||
|
// 同键重试:同一条记录、同样 201,不产生新行
|
||||||
|
MvcResult retry = mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||||
|
.header("Idempotency-Key", key)
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(json))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andReturn();
|
||||||
|
String retryId = JsonPath.read(retry.getResponse().getContentAsString(), "$.data.id");
|
||||||
|
assertThat(retryId).isEqualTo(firstId);
|
||||||
|
|
||||||
|
Integer count = jdbcClient.sql(
|
||||||
|
"SELECT count(*) FROM pet_health.pet_weight_records WHERE pet_id = :petId")
|
||||||
|
.param("petId", petId)
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(count).isEqualTo(1);
|
||||||
|
|
||||||
|
// 换键则是新记录;不带键的两次提交各自成行(无幂等语义)
|
||||||
|
mockMvc.perform(post("/api/v1/pets/{petId}/weights", petId)
|
||||||
|
.header("Authorization", "Bearer " + tokenFor(owner))
|
||||||
|
.header("Idempotency-Key", key + "-2")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(json))
|
||||||
|
.andExpect(status().isCreated())
|
||||||
|
.andExpect(jsonPath("$.data.id").value(org.hamcrest.Matchers.not(firstId)));
|
||||||
|
postWeight(owner, petId, "2026-09-03T08:00:00Z", "6.10");
|
||||||
|
Integer total = jdbcClient.sql(
|
||||||
|
"SELECT count(*) FROM pet_health.pet_weight_records WHERE pet_id = :petId")
|
||||||
|
.param("petId", petId)
|
||||||
|
.query(Integer.class)
|
||||||
|
.single();
|
||||||
|
assertThat(total).isEqualTo(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
+9
@@ -72,4 +72,13 @@ public abstract class PetIntegrationTestSupport {
|
|||||||
.query(UUID.class)
|
.query(UUID.class)
|
||||||
.single();
|
.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