Compare commits

..

2 Commits

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:44:23 +08:00
lixi 825dde3928 feat: 体重记录接口与 cursor 分页、Idempotency-Key 幂等(T2-04)
- GET/POST /api/v1/pets/{petId}/weights,复用 PetAccessService 闸口
  (READ 读 / WRITE 写,含 caregiver 写成功正向用例)
- cursor 分页按 (measured_at DESC, id DESC) 与 ix_pet_weight_pet_measured
  对齐,limit+1 探测 hasMore,同刻记录跨页不丢不重
- Idempotency-Key 可选头:键派生确定性主键 + ON CONFLICT (id) DO NOTHING,
  重试返回原记录,零新增迁移
- weight_kg 校验镜像 ck_pet_weight(>0 且 ≤500,两位小数)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-07 17:43:59 +08:00
22 changed files with 2081 additions and 0 deletions
@@ -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, "依赖服务暂不可用");
@@ -0,0 +1,68 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.CreateVaccinationRequest;
import com.patbond.patbond.pet.dto.UpdateVaccinationRequest;
import com.patbond.patbond.pet.dto.VaccinationResponse;
import com.patbond.patbond.pet.security.BearerAuthFilter;
import com.patbond.patbond.pet.service.VaccinationService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
/**
* Vaccination records (T2-05). List/create live under the pet; PATCH
* addresses the record directly by its globally-unique id (contract draft
* decision #1 — avoids the ambiguity of a mismatched path petId).
*/
@RestController
@Validated
public class VaccinationController {
private final VaccinationService vaccinationService;
public VaccinationController(VaccinationService vaccinationService) {
this.vaccinationService = vaccinationService;
}
@GetMapping("/api/v1/pets/{petId}/vaccinations")
public ApiResponse<List<VaccinationResponse>> list(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId) {
return ApiResponse.success(vaccinationService.list(userId, petId));
}
@PostMapping("/api/v1/pets/{petId}/vaccinations")
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<VaccinationResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID petId,
@RequestHeader(value = "Idempotency-Key", required = false)
@Size(max = 255, message = "Idempotency-Key 最长 255 字符")
String idempotencyKey,
@Valid @RequestBody CreateVaccinationRequest request) {
return ApiResponse.success(
vaccinationService.create(userId, petId, idempotencyKey, request));
}
@PatchMapping("/api/v1/vaccinations/{vaccinationId}")
public ApiResponse<VaccinationResponse> update(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID vaccinationId,
@Valid @RequestBody UpdateVaccinationRequest request) {
return ApiResponse.success(vaccinationService.update(userId, vaccinationId, request));
}
}
@@ -0,0 +1,39 @@
package com.patbond.patbond.pet.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.pet.dto.VaccineCatalogResponse;
import com.patbond.patbond.pet.repository.VaccineCatalogRepository;
import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* Read-only vaccine dictionary (T2-05). Authenticated like every /api/v1
* route but not permission-checked — dictionary rows are not user data.
* Returned in full (10 seed rows, V4); no pagination — same treatment as
* /api/v1/breeds.
*/
@RestController
@RequestMapping("/api/v1/vaccine-catalog")
@Validated
public class VaccineCatalogController {
private final VaccineCatalogRepository vaccineCatalogRepository;
public VaccineCatalogController(VaccineCatalogRepository vaccineCatalogRepository) {
this.vaccineCatalogRepository = vaccineCatalogRepository;
}
@GetMapping
public ApiResponse<List<VaccineCatalogResponse>> list(
@RequestParam(required = false)
@Pattern(regexp = "dog|cat|other", message = "species 仅支持 dog/cat/other")
String species) {
return ApiResponse.success(vaccineCatalogRepository.listEnabled(species));
}
}
@@ -0,0 +1,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.
@@ -0,0 +1,147 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.VaccinationResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* pet_health.pet_vaccinations access. Reads join the catalog for the
* display name (same convenience as breeds on pets). Permission gating
* happens in the service before any call lands here.
*/
@Repository
public class VaccinationRepository {
private static final String SELECT_VACCINATION = """
SELECT v.id, v.pet_id, v.vaccine_id, c.name AS vaccine_name, v.series_key,
v.dose_no, v.dose_label, v.status, v.planned_on, v.administered_on,
v.next_due_on, v.manufacturer, v.batch_no, v.notes,
v.created_at, v.updated_at, v.version
FROM pet_health.pet_vaccinations v
JOIN pet_health.vaccine_catalog c ON c.id = v.vaccine_id
""";
private final JdbcClient jdbcClient;
public VaccinationRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Inserts one vaccination. {@code ON CONFLICT (id) DO NOTHING} targets
* only the primary key (idempotent replay); a violation of
* uq_pet_vaccination_dose still raises DuplicateKeyException, which the
* service maps to 40904.
*
* @return rows inserted — 0 means the id already exists (keyed retry)
*/
public int insert(UUID id, UUID petId, UUID vaccineId, String seriesKey, int doseNo,
String doseLabel, String status, LocalDate plannedOn,
LocalDate administeredOn, LocalDate nextDueOn, String manufacturer,
String batchNo, String notes) {
return jdbcClient.sql("""
INSERT INTO pet_health.pet_vaccinations
(id, pet_id, vaccine_id, series_key, dose_no, dose_label, status,
planned_on, administered_on, next_due_on, manufacturer, batch_no, notes)
VALUES (:id, :petId, :vaccineId, :seriesKey, :doseNo, :doseLabel, :status,
:plannedOn, :administeredOn, :nextDueOn, :manufacturer, :batchNo, :notes)
ON CONFLICT (id) DO NOTHING
""")
.param("id", id)
.param("petId", petId)
.param("vaccineId", vaccineId)
.param("seriesKey", seriesKey)
.param("doseNo", doseNo)
.param("doseLabel", doseLabel)
.param("status", status)
.param("plannedOn", plannedOn)
.param("administeredOn", administeredOn)
.param("nextDueOn", nextDueOn)
.param("manufacturer", manufacturer)
.param("batchNo", batchNo)
.param("notes", notes)
.update();
}
public Optional<VaccinationResponse> findById(UUID id) {
return jdbcClient.sql(SELECT_VACCINATION + " WHERE v.id = :id")
.param("id", id)
.query(VaccinationRepository::mapVaccination)
.optional();
}
/**
* Full list for one pet, unpaginated (per-pet volume is a handful of
* rows). Ordered by series then dose so the client renders vaccine
* cards without re-sorting; created_at/id break residual ties.
*/
public List<VaccinationResponse> listByPet(UUID petId) {
return jdbcClient.sql(SELECT_VACCINATION + """
WHERE v.pet_id = :petId
ORDER BY v.series_key, v.dose_no, v.created_at, v.id
""")
.param("petId", petId)
.query(VaccinationRepository::mapVaccination)
.list();
}
/**
* Optimistic-lock update of the merged state; updated_at is bumped by
* the trg_vaccinations_updated_at trigger.
*
* @return rows updated — 0 means the version is stale
*/
public int updateWithVersion(UUID id, int expectedVersion, String status,
LocalDate plannedOn, LocalDate administeredOn,
LocalDate nextDueOn, String doseLabel, String manufacturer,
String batchNo, String notes) {
return jdbcClient.sql("""
UPDATE pet_health.pet_vaccinations
SET status = :status, planned_on = :plannedOn,
administered_on = :administeredOn, next_due_on = :nextDueOn,
dose_label = :doseLabel, manufacturer = :manufacturer,
batch_no = :batchNo, notes = :notes, version = version + 1
WHERE id = :id AND version = :expectedVersion
""")
.param("id", id)
.param("expectedVersion", expectedVersion)
.param("status", status)
.param("plannedOn", plannedOn)
.param("administeredOn", administeredOn)
.param("nextDueOn", nextDueOn)
.param("doseLabel", doseLabel)
.param("manufacturer", manufacturer)
.param("batchNo", batchNo)
.param("notes", notes)
.update();
}
private static VaccinationResponse mapVaccination(ResultSet rs, int rowNum) throws SQLException {
return new VaccinationResponse(
rs.getObject("id", UUID.class),
rs.getObject("pet_id", UUID.class),
rs.getObject("vaccine_id", UUID.class),
rs.getString("vaccine_name"),
rs.getString("series_key"),
rs.getInt("dose_no"),
rs.getString("dose_label"),
rs.getString("status"),
rs.getObject("planned_on", LocalDate.class),
rs.getObject("administered_on", LocalDate.class),
rs.getObject("next_due_on", LocalDate.class),
rs.getString("manufacturer"),
rs.getString("batch_no"),
rs.getString("notes"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
rs.getInt("version"));
}
}
@@ -0,0 +1,53 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.VaccineCatalogResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/** Read-only pet_health.vaccine_catalog dictionary access. */
@Repository
public class VaccineCatalogRepository {
private final JdbcClient jdbcClient;
public VaccineCatalogRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/** Enabled rows, optionally filtered by species; (species, name) order matches the partial index. */
public List<VaccineCatalogResponse> listEnabled(String species) {
String sql = """
SELECT id, code, name, species, description
FROM pet_health.vaccine_catalog
WHERE enabled
""" + (species != null ? " AND species = :species" : "") + """
ORDER BY species, name
""";
var spec = jdbcClient.sql(sql);
if (species != null) {
spec = spec.param("species", species);
}
return spec.query((rs, rowNum) -> new VaccineCatalogResponse(
rs.getObject("id", UUID.class),
rs.getString("code"),
rs.getString("name"),
rs.getString("species"),
rs.getString("description")))
.list();
}
/** The species of an enabled catalog entry — empty when unknown or disabled. */
public Optional<String> findEnabledSpecies(UUID vaccineId) {
return jdbcClient.sql("""
SELECT species FROM pet_health.vaccine_catalog
WHERE id = :id AND enabled
""")
.param("id", vaccineId)
.query(String.class)
.optional();
}
}
@@ -0,0 +1,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 无效");
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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();
}
} }