feat: 宠物头像读写——PATCH 支持 avatarAssetId、详情与列表补 avatarUrl(T3.5-05,ADR-022)

- PATCH /api/v1/pets/{petId} 支持 avatarAssetId 三态:缺省不改、显式 null
  清空、给值设置。这是 pets 域唯一的三态字段——M2 惯例「null 与缺省同义」
  无法表达「删掉头像」,而 name/species/sex 本就不允许为空,故差异刻意限定
  在本字段
- 权限按「本次请求碰了哪些字段」定档:仅改头像为 WRITE(owner+caregiver,
  ADR-022:头像属日常照护信息,与体重/疫苗同档),碰到任一资料字段仍是
  MANAGE(仅 owner),混合请求按更严的一半判;viewer 一律 403/40300
- 头像与资料共用同一把乐观锁:仅改头像也吃 version,旧版本必答 409/40902
- asset 校验复用 T3-03 引用侧协议 + purpose='pet_avatar':不存在/非本人/
  已删/用途不符答 404/40405,本人未就绪答 422/42203(user_avatar 资源也不能
  当宠物头像)
- 详情与列表响应补 avatarUrl(本地 SigV4 现签预签名 GET,沿用 community 读侧
  先例):仅当 asset 为 ready 才签,指针在而资源退出 ready 时降级为 null,
  不签一个下载必 404 的地址,也不隐式清理指针
- PetRepository 读改为返回 PetRow(含头像存储坐标),签名上移到 PetService,
  与 community 的 PostRow → PostResponse 装配同构——过期 URL 不下沉到仓储层
- pet 模块加入 aws-sdk s3(仅本地签名,不直连对象存储)与读侧 patbond.media
  配置;compose 补同一组 PATBOND_MINIO_* 环境变量
- 测试 +11(成功/清空/缺省保留/降级/caregiver 与 viewer 权限/防枚举 404/
  asset 五态/畸形入参/乐观锁与重放),pet 模块 89 → 100

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-11 10:02:50 +08:00
parent a5634c5f85
commit 15c2e66519
13 changed files with 782 additions and 18 deletions
+9
View File
@@ -43,6 +43,15 @@
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Read-side media URL signing only (presigned GET is a local SigV4
computation): this service never talks to the object store, the
media write flow stays in patbond-user (ADR-016/017). Same
precedent as patbond-community's read side. Version managed by
the root pom's awssdk bom. -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<!-- Access token verification (RS256, public key only): jjwt is not in
the Boot BOM, version pinned in step with patbond-user/auth. -->
<dependency>
@@ -0,0 +1,22 @@
package com.patbond.patbond.pet.config;
import com.patbond.patbond.pet.media.MediaUrlSigner;
import com.patbond.patbond.pet.media.PetMediaProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Read-side media wiring (T3.5-05): a presigned-GET signer over the same
* MinIO configuration patbond-user uses (ADR-016). Bean destruction closes
* the underlying presigner.
*/
@Configuration
@EnableConfigurationProperties(PetMediaProperties.class)
public class MediaConfig {
@Bean(destroyMethod = "close")
public MediaUrlSigner mediaUrlSigner(PetMediaProperties properties) {
return new MediaUrlSigner(properties);
}
}
@@ -9,6 +9,13 @@ import java.util.UUID;
* dictionary when {@code breedId} is set; exactly one of {@code breedId} /
* {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the
* calling user's own pet_owners role — the client uses it to gate write UI.
*
* <p>{@code avatarUrl} (T3.5-05) is a freshly signed presigned GET against a
* private bucket: it EXPIRES and must never be persisted client-side (the
* client's image cache key strips the signature parameters). It is null both
* when the pet has no avatar and when the referenced asset is not (or no
* longer) ready, so "has an avatar" is exactly {@code avatarUrl != null}. The
* asset id is not echoed — the client only ever writes it.</p>
*/
public record PetResponse(
UUID id,
@@ -24,6 +31,7 @@ public record PetResponse(
String microchipNo,
LocalDate sterilizedOn,
String status,
String avatarUrl,
String myRole,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
@@ -16,6 +16,21 @@ import java.util.UUID;
* replaces the pair as a whole (they are mutually exclusive per
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
* the whole endpoint exists to enforce.
*
* <p><b>{@code avatarAssetId} is the one three-state field</b> (T3.5-05):
* absent = unchanged, explicit {@code null} = remove the avatar, value = set
* it. Removing an avatar is a first-class user action with no other way to
* express it, whereas the M2 fields either cannot be empty at all (name,
* species, sex) or are edited, not erased — so the asymmetry is deliberate
* and confined to this field. Presence is tracked in the setter: Jackson
* calls it exactly when the JSON key is present, including for an explicit
* null.</p>
*
* <p>Permission note: this endpoint is MANAGE (owner only), but an
* avatar-ONLY patch is WRITE (owner + caregiver) per ADR-022 — the avatar is
* day-to-day care information, same tier as weights and vaccinations. The
* required level is therefore computed from which fields the body touches;
* see PetService.</p>
*/
public class UpdatePetRequest {
@@ -56,6 +71,33 @@ public class UpdatePetRequest {
message = "status 仅支持 active/lost/deceased/archived")
private String status;
private UUID avatarAssetId;
private boolean avatarAssetIdPresent;
public UUID getAvatarAssetId() {
return avatarAssetId;
}
public void setAvatarAssetId(UUID avatarAssetId) {
this.avatarAssetId = avatarAssetId;
this.avatarAssetIdPresent = true;
}
public boolean isAvatarAssetIdPresent() {
return avatarAssetIdPresent;
}
/**
* True when the body touches any pet-profile field, i.e. anything beyond
* the avatar. {@code version} does not count — it is the lock, not an
* edit. Drives the MANAGE-vs-WRITE decision in PetService.
*/
public boolean touchesProfileFields() {
return name != null || breedId != null || customBreedName != null || sex != null
|| birthDate != null || birthDateEstimated != null || personality != null
|| microchipNo != null || sterilizedOn != null || status != null;
}
public Integer getVersion() {
return version;
}
@@ -0,0 +1,42 @@
package com.patbond.patbond.pet.media;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.Optional;
import java.util.UUID;
/**
* Read-only cross-schema access to media.assets — the pet side of the T3-03
* 联调协议 (business references accept only assets owned by the caller with
* status='ready' and the matching purpose). Same-database read was chosen
* over an internal HTTP call to patbond-user, exactly as patbond-community
* did (ADR-017 precedent: while the schemas share one database this is a
* cross-schema read; splitting the database later moves every such gateway to
* an internal API together). This class never writes media.assets — the media
* state machine belongs to patbond-user.
*/
@Repository
public class MediaAssetGateway {
private final JdbcClient jdbcClient;
public MediaAssetGateway(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
public Optional<MediaAssetRef> findById(UUID assetId) {
return jdbcClient.sql("""
SELECT id, owner_user_id, purpose, status
FROM media.assets
WHERE id = :id
""")
.param("id", assetId)
.query((rs, rowNum) -> new MediaAssetRef(
rs.getObject("id", UUID.class),
rs.getObject("owner_user_id", UUID.class),
rs.getString("purpose"),
rs.getString("status")))
.optional();
}
}
@@ -0,0 +1,10 @@
package com.patbond.patbond.pet.media;
import java.util.UUID;
/**
* Read-only view of one media.assets row — exactly the columns the pet
* avatar flow needs for attach validation (owner, purpose, status).
*/
public record MediaAssetRef(UUID id, UUID ownerUserId, String purpose, String status) {
}
@@ -0,0 +1,61 @@
package com.patbond.patbond.pet.media;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
import java.net.URI;
/**
* Signs presigned GET URLs for pet avatars (T3-03 定型:private bucket +
* presigned GET, TTL configurable, signed fresh on every response — clients
* never persist the URL). Presigning is a local SigV4 computation against the
* public endpoint; this service never talks to the object store itself.
* Path-style addressing is forced because MinIO has no wildcard DNS for
* virtual-host-style buckets (same as patbond-user's S3ObjectStorage and
* patbond-community's signer). When unconfigured, {@link #signGet} returns
* null and pet responses degrade to {@code avatarUrl: null}.
*/
public class MediaUrlSigner implements AutoCloseable {
private final PetMediaProperties properties;
private final S3Presigner presigner;
public MediaUrlSigner(PetMediaProperties properties) {
this.properties = properties;
if (properties.getPublicEndpoint().isBlank()) {
this.presigner = null;
return;
}
this.presigner = S3Presigner.builder()
.endpointOverride(URI.create(properties.getPublicEndpoint()))
.region(Region.of(properties.getRegion()))
.credentialsProvider(StaticCredentialsProvider.create(
AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey())))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
/** @return a presigned GET URL, or null when storage is unconfigured */
public String signGet(String bucket, String objectKey) {
if (presigner == null || bucket == null || objectKey == null) {
return null;
}
return presigner.presignGetObject(GetObjectPresignRequest.builder()
.signatureDuration(properties.getDownloadTtl())
.getObjectRequest(b -> b.bucket(bucket).key(objectKey))
.build())
.url()
.toString();
}
@Override
public void close() {
if (presigner != null) {
presigner.close();
}
}
}
@@ -0,0 +1,80 @@
package com.patbond.patbond.pet.media;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Read-side subset of the media object-storage configuration (T3.5-05). The
* write side — upload flow, mime/purpose whitelists, bucket init — lives in
* patbond-user's MediaProperties; this service only signs presigned GET URLs
* for pet avatars, a purely local SigV4 computation, so no S3 client is
* needed. Values reuse the same PATBOND_MINIO_* / PATBOND_MEDIA_*
* environment variables as patbond-user and patbond-community, keeping one
* set of knobs per deployment (ADR-016/021).
*/
@ConfigurationProperties(prefix = "patbond.media")
public class PetMediaProperties {
/**
* Endpoint presigned GET URLs are issued against — the address CLIENTS
* can reach. Empty means media is unconfigured for this service: pet
* responses carry {@code avatarUrl: null} (same degradation precedent as
* the missing JWT public key).
*/
private String publicEndpoint = "";
/** S3 access key; injected via environment, never committed (ADR-021). */
private String accessKey = "";
/** S3 secret key; injected via environment, never committed (ADR-021). */
private String secretKey = "";
/** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
private String region = "us-east-1";
/** TTL of presigned GET URLs (the bucket stays private, T3-03 定型). */
private Duration downloadTtl = Duration.ofHours(1);
public String getPublicEndpoint() {
return publicEndpoint;
}
public void setPublicEndpoint(String publicEndpoint) {
this.publicEndpoint = publicEndpoint;
}
public String getAccessKey() {
return accessKey;
}
// setter 形参名取 valuecheck-secrets 的 KEY-ASSIGN 规则会把「字段 = 同名
// 形参」的自赋值误报为凭证字面量,规则表三仓同构不单方面改(ADR-021)
public void setAccessKey(String value) {
this.accessKey = value;
}
public String getSecretKey() {
return secretKey;
}
public void setSecretKey(String value) {
this.secretKey = value;
}
public String getRegion() {
return region;
}
public void setRegion(String region) {
this.region = region;
}
public Duration getDownloadTtl() {
return downloadTtl;
}
public void setDownloadTtl(Duration downloadTtl) {
this.downloadTtl = downloadTtl;
}
}
@@ -1,6 +1,5 @@
package com.patbond.patbond.pet.repository;
import com.patbond.patbond.pet.dto.PetResponse;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
@@ -16,6 +15,12 @@ import java.util.UUID;
* pet_health.pets + pet_owners access. All reads join pet_owners on the
* calling user so a row only comes back when a relationship exists — the
* repository layer itself never exposes another user's pet.
*
* <p>Reads return {@link PetRow}, not the API DTO: the avatar travels as
* storage coordinates (bucket + object key of a READY media asset) and the
* presigned URL is produced one layer up, in PetService — same split as
* patbond-community's PostRow → PostResponse assembly, and the reason a
* signed, expiring URL never leaks into a repository-level cache.</p>
*/
@Repository
public class PetRepository {
@@ -24,10 +29,13 @@ public class PetRepository {
SELECT p.id, p.name, p.species, p.breed_id, b.display_name AS breed_display_name,
p.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
p.personality, p.microchip_no, p.sterilized_on, p.status,
p.avatar_asset_id,
av.bucket AS avatar_bucket, av.object_key AS avatar_object_key,
po.role, p.created_at, p.updated_at, p.version
FROM pet_health.pets p
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
LEFT JOIN pet_health.breeds b ON b.id = p.breed_id
LEFT JOIN media.assets av ON av.id = p.avatar_asset_id AND av.status = 'ready'
WHERE p.status <> 'deleted'
""";
@@ -37,6 +45,37 @@ public class PetRepository {
this.jdbcClient = jdbcClient;
}
/**
* One pet as stored, from the calling user's perspective.
* {@code avatarAssetId} is the raw column (so a PATCH that does not touch
* the avatar can carry it through unchanged), while the two storage
* columns are already narrowed to a READY asset — a dangling or
* still-uploading avatar yields nulls there (→ {@code avatarUrl: null})
* rather than a signed URL that would 404 at the object store.
*/
public record PetRow(
UUID id,
String name,
String species,
UUID breedId,
String breedDisplayName,
String customBreedName,
String sex,
LocalDate birthDate,
Boolean birthDateEstimated,
String personality,
String microchipNo,
LocalDate sterilizedOn,
String status,
UUID avatarAssetId,
String avatarBucket,
String avatarObjectKey,
String myRole,
OffsetDateTime createdAt,
OffsetDateTime updatedAt,
Integer version) {
}
public void insertPet(UUID petId, String name, String species, UUID breedId,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
@@ -72,14 +111,14 @@ public class PetRepository {
.update();
}
public List<PetResponse> listByUser(UUID userId) {
public List<PetRow> listByUser(UUID userId) {
return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC")
.param("userId", userId)
.query(PetRepository::mapPet)
.list();
}
public Optional<PetResponse> findByIdForUser(UUID petId, UUID userId) {
public Optional<PetRow> findByIdForUser(UUID petId, UUID userId) {
return jdbcClient.sql(SELECT_PET + " AND p.id = :petId")
.param("userId", userId)
.param("petId", petId)
@@ -109,14 +148,16 @@ public class PetRepository {
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
String customBreedName, String sex, LocalDate birthDate,
boolean birthDateEstimated, String personality,
String microchipNo, LocalDate sterilizedOn, String status) {
String microchipNo, LocalDate sterilizedOn, String status,
UUID avatarAssetId) {
return jdbcClient.sql("""
UPDATE pet_health.pets
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
sex = :sex, birth_date = :birthDate,
birth_date_estimated = :birthDateEstimated, personality = :personality,
microchip_no = :microchipNo, sterilized_on = :sterilizedOn,
status = :status, version = version + 1
status = :status, avatar_asset_id = :avatarAssetId,
version = version + 1
WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted'
""")
.param("petId", petId)
@@ -131,11 +172,12 @@ public class PetRepository {
.param("microchipNo", microchipNo)
.param("sterilizedOn", sterilizedOn)
.param("status", status)
.param("avatarAssetId", avatarAssetId)
.update();
}
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetResponse(
private static PetRow mapPet(ResultSet rs, int rowNum) throws SQLException {
return new PetRow(
rs.getObject("id", UUID.class),
rs.getString("name"),
rs.getString("species"),
@@ -149,6 +191,9 @@ public class PetRepository {
rs.getString("microchip_no"),
rs.getObject("sterilized_on", LocalDate.class),
rs.getString("status"),
rs.getObject("avatar_asset_id", UUID.class),
rs.getString("avatar_bucket"),
rs.getString("avatar_object_key"),
rs.getString("role"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("updated_at", OffsetDateTime.class),
@@ -7,8 +7,12 @@ import com.patbond.patbond.pet.access.PetAccessService;
import com.patbond.patbond.pet.dto.CreatePetRequest;
import com.patbond.patbond.pet.dto.PetResponse;
import com.patbond.patbond.pet.dto.UpdatePetRequest;
import com.patbond.patbond.pet.media.MediaAssetGateway;
import com.patbond.patbond.pet.media.MediaAssetRef;
import com.patbond.patbond.pet.media.MediaUrlSigner;
import com.patbond.patbond.pet.repository.BreedRepository;
import com.patbond.patbond.pet.repository.PetRepository;
import com.patbond.patbond.pet.repository.PetRepository.PetRow;
import com.patbond.patbond.pet.support.UuidV7;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
@@ -23,19 +27,47 @@ import java.util.UUID;
* uq_pets_microchip) so clients get a stable business error instead of a
* constraint-violation 500 — the constraints stay as the last line of
* defense.
*
* <p>Avatar semantics (T3.5-05, ADR-022):
* <ul>
* <li>The write level is decided per REQUEST, not per endpoint: an
* avatar-only PATCH needs {@link AccessLevel#WRITE} (owner + caregiver
* — the avatar is day-to-day care information, same tier as weights and
* vaccinations), everything else stays {@link AccessLevel#MANAGE}
* (owner only). A body touching both is judged by the stricter half.
* Viewers are refused either way (403/40300).</li>
* <li>The referenced asset must exist, belong to the CALLER, carry
* {@code purpose='pet_avatar'} and be {@code ready} — the T3-03
* referencing protocol: unknown / someone else's / deleted → 404/40405
* (merged, anti-enumeration), wrong purpose → 404/40405 (a post image
* is not an avatar; reachable only for the caller's own assets, so the
* message may be specific), own pet_avatar asset still uploading or
* failed → 422/42203.</li>
* <li>The optimistic lock is unchanged: the avatar rides the same
* version-guarded UPDATE, so a stale version loses with 409/40902 even
* when only the avatar changes.</li>
* </ul>
*/
@Service
public class PetService {
/** The only media purpose acceptable as a pet avatar (ADR-022). */
private static final String AVATAR_PURPOSE = "pet_avatar";
private final PetRepository petRepository;
private final BreedRepository breedRepository;
private final PetAccessService petAccessService;
private final MediaAssetGateway mediaAssetGateway;
private final MediaUrlSigner mediaUrlSigner;
public PetService(PetRepository petRepository, BreedRepository breedRepository,
PetAccessService petAccessService) {
PetAccessService petAccessService, MediaAssetGateway mediaAssetGateway,
MediaUrlSigner mediaUrlSigner) {
this.petRepository = petRepository;
this.breedRepository = breedRepository;
this.petAccessService = petAccessService;
this.mediaAssetGateway = mediaAssetGateway;
this.mediaUrlSigner = mediaUrlSigner;
}
/**
@@ -63,18 +95,18 @@ public class PetService {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
petRepository.insertPrimaryOwner(petId, userId);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
}
public List<PetResponse> list(UUID userId) {
return petRepository.listByUser(userId);
return petRepository.listByUser(userId).stream().map(this::toResponse).toList();
}
public PetResponse get(UUID userId, UUID petId) {
petAccessService.require(userId, petId, AccessLevel.READ);
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)));
}
/**
@@ -85,8 +117,8 @@ public class PetService {
*/
@Transactional
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
petAccessService.require(userId, petId, AccessLevel.MANAGE);
PetResponse current = petRepository.findByIdForUser(petId, userId)
petAccessService.require(userId, petId, requiredLevel(request));
PetRow current = petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
UUID breedId = current.breedId();
@@ -102,6 +134,16 @@ public class PetService {
String sex = request.getSex() != null ? request.getSex() : current.sex();
String status = request.getStatus() != null ? request.getStatus() : current.status();
// Three-state avatar: absent → carry the stored id through; explicit
// null → clear; value → validate then set.
UUID avatarAssetId = current.avatarAssetId();
if (request.isAvatarAssetIdPresent()) {
avatarAssetId = request.getAvatarAssetId();
if (avatarAssetId != null) {
requireOwnReadyAvatarAsset(userId, avatarAssetId);
}
}
int updated;
try {
updated = petRepository.updateWithVersion(
@@ -120,7 +162,8 @@ public class PetService {
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
request.getSterilizedOn() != null
? request.getSterilizedOn() : current.sterilizedOn(),
status);
status,
avatarAssetId);
} catch (DuplicateKeyException e) {
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
}
@@ -129,8 +172,34 @@ public class PetService {
// missed conditional update means the version is stale.
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
}
return petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
return toResponse(petRepository.findByIdForUser(petId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
}
/**
* MANAGE for anything that edits the pet profile, WRITE when the request
* touches nothing but the avatar (ADR-022). A body carrying only
* {@code version} keeps the historical MANAGE level — it is a
* profile-shaped no-op, not an avatar edit.
*/
private static AccessLevel requiredLevel(UpdatePetRequest request) {
boolean avatarOnly = request.isAvatarAssetIdPresent() && !request.touchesProfileFields();
return avatarOnly ? AccessLevel.WRITE : AccessLevel.MANAGE;
}
private void requireOwnReadyAvatarAsset(UUID userId, UUID assetId) {
MediaAssetRef asset = mediaAssetGateway.findById(assetId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
if (!userId.equals(asset.ownerUserId()) || "deleted".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
}
if (!AVATAR_PURPOSE.equals(asset.purpose())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND,
"该媒体资源的用途不是 " + AVATAR_PURPOSE + ",不能作为宠物头像");
}
if (!"ready".equals(asset.status())) {
throw new BusinessException(ErrorCode.MEDIA_NOT_READY);
}
}
/**
@@ -156,6 +225,33 @@ public class PetService {
}
}
/**
* Signs the avatar URL fresh on every response (never cached, never
* persisted) and drops the storage coordinates — the DTO exposes a URL,
* not a bucket layout.
*/
private PetResponse toResponse(PetRow row) {
return new PetResponse(
row.id(),
row.name(),
row.species(),
row.breedId(),
row.breedDisplayName(),
row.customBreedName(),
row.sex(),
row.birthDate(),
row.birthDateEstimated(),
row.personality(),
row.microchipNo(),
row.sterilizedOn(),
row.status(),
mediaUrlSigner.signGet(row.avatarBucket(), row.avatarObjectKey()),
row.myRole(),
row.createdAt(),
row.updatedAt(),
row.version());
}
private static String trimOrNull(String value) {
if (value == null) {
return null;
@@ -19,3 +19,12 @@ patbond:
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
media:
# 媒体读取侧(ADR-016 定型:私有桶 + 预签名 GET)。本服务只做本地 SigV4
# 签名计算生成宠物头像访问 URL,从不直连对象存储;写入流程在 patbond-user。
# 环境变量与 patbond-user/patbond-community 共用同一组(一套部署一套旋钮)。
# public-endpoint 为空时服务照常启动,宠物响应中 avatarUrl 为 null。
public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
access-key: ${PATBOND_MINIO_ACCESS_KEY:}
secret-key: ${PATBOND_MINIO_SECRET_KEY:}
download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
@@ -0,0 +1,335 @@
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.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.web.servlet.MockMvc;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.nullValue;
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;
/**
* T3.5-05 宠物头像读写:PATCH /api/v1/pets/{petId} 的 {@code avatarAssetId}
* 三态(缺省不改 / 显式 null 清空 / 赋值设置),详情与列表的 {@code avatarUrl}
* 预签名 GET,以及六类路径 —— 成功 / 参数错(asset 非法四态)/ 不存在(防枚举
* 404/ 无权限(viewer 拒写、caregiver 只能改头像)/ 并发冲突(乐观锁 40902)
* / 重放(同版本重放必冲突、新版本重放幂等)。
*
* <p>预签名 GET 是纯本地 SigV4 计算,故这里用占位端点与占位凭证即可断言 URL
* 形态(与 patbond-community 的 PostApiTestBase 同先例),无需 MinIO 容器;
* 「签名真能下载」的实证由 user 模块的 MeAvatarSigningIntegrationTest 承担。</p>
*/
class PetAvatarIntegrationTest extends PetIntegrationTestSupport {
private static final String SIGNED_PREFIX = "http://127.0.0.1:9000/patbond-media/pet_avatar/";
@Autowired
private MockMvc mockMvc;
@DynamicPropertySource
static void wireMediaSigning(DynamicPropertyRegistry registry) {
// 占位值(dummy):仅用于本地 SigV4 计算,不连任何真实存储
registry.add("patbond.media.public-endpoint", () -> "http://127.0.0.1:9000");
registry.add("patbond.media.access-key", () -> "test-access-key");
registry.add("patbond.media.secret-key", () -> "test-secret-key");
}
// ---- helpers -------------------------------------------------------
private String createPetAs(UUID ownerId) throws Exception {
String body = mockMvc.perform(post("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(ownerId))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"name":"头像猫","species":"cat","sex":"female",
"customBreedName":"狸花"}
"""))
.andExpect(status().isCreated())
// 新建宠物尚无头像
.andExpect(jsonPath("$.data.avatarUrl").value(nullValue()))
.andReturn().getResponse().getContentAsString();
return JsonPath.read(body, "$.data.id");
}
/** 一枚 media.assets 行,用途/状态/归属可控(模拟 T3-03 上传的产物)。 */
private UUID insertAsset(UUID ownerUserId, String purpose, String status) {
UUID id = UUID.randomUUID();
jdbcClient.sql("""
INSERT INTO media.assets
(id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
mime_type, byte_size, status, ready_at, deleted_at)
VALUES (:id, :owner, 'image', :purpose, 'object', 'patbond-media',
:objectKey, 'image/jpeg', 2048, :status, :readyAt, :deletedAt)
""")
.param("id", id)
.param("owner", ownerUserId)
.param("purpose", purpose)
.param("objectKey", purpose + "/2026/09/" + id)
.param("status", status)
.param("readyAt", "ready".equals(status) ? OffsetDateTime.now() : null)
// ck_media_deletedstatus='deleted' 必带 deleted_at
.param("deletedAt", "deleted".equals(status) ? OffsetDateTime.now() : null)
.update();
return id;
}
private UUID readyPetAvatar(UUID ownerUserId) {
return insertAsset(ownerUserId, "pet_avatar", "ready");
}
private String patchPet(UUID actor, String petId, String body, int expectedStatus)
throws Exception {
return mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(expectedStatus))
.andReturn().getResponse().getContentAsString();
}
private void patchPetExpectingCode(UUID actor, String petId, String body,
int httpStatus, int bizCode) throws Exception {
mockMvc.perform(patch("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor))
.contentType(MediaType.APPLICATION_JSON)
.content(body))
.andExpect(status().is(httpStatus))
.andExpect(jsonPath("$.code").value(bizCode));
}
private static String setAvatarBody(int version, UUID assetId) {
return "{\"version\":%d,\"avatarAssetId\":\"%s\"}".formatted(version, assetId);
}
private String detail(UUID actor, String petId) throws Exception {
return mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(actor)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
}
private UUID dbAvatarAssetId(String petId) {
return jdbcClient.sql("SELECT avatar_asset_id FROM pet_health.pets WHERE id = :id")
.param("id", UUID.fromString(petId))
.query(UUID.class)
.optional()
.orElse(null);
}
// ---- 成功路径 -------------------------------------------------------
@Test
void ownerSetsAvatarAndDetailAndListBothCarryASignedUrl() throws Exception {
UUID owner = newUser("pet_avatar_owner");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
String patched = patchPet(owner, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX)
.contains("X-Amz-Signature=");
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
// 头像也吃乐观锁:写入后 version 前进
assertThat((int) JsonPath.read(patched, "$.data.version")).isEqualTo(1);
assertThat((String) JsonPath.read(detail(owner, petId), "$.data.avatarUrl"))
.startsWith(SIGNED_PREFIX);
String listBody = mockMvc.perform(get("/api/v1/pets")
.header("Authorization", "Bearer " + tokenFor(owner)))
.andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
List<Map<String, Object>> pets = JsonPath.read(listBody, "$.data");
assertThat((String) pets.get(0).get("avatarUrl")).startsWith(SIGNED_PREFIX);
}
@Test
void clearsAvatarWithAnExplicitNull() throws Exception {
UUID owner = newUser("pet_avatar_clear");
String petId = createPetAs(owner);
patchPet(owner, petId, setAvatarBody(0, readyPetAvatar(owner)), 200);
String cleared = patchPet(owner, petId, "{\"version\":1,\"avatarAssetId\":null}", 200);
assertThat((Object) JsonPath.read(cleared, "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isNull();
}
/**
* 缺省即不改:只改名字的 PATCH 不得把头像顺手清掉(这正是三态语义存在的
* 理由——若 null 与缺省同义,就无法既保留又能清空)。
*/
@Test
void absentAvatarFieldLeavesItUntouched() throws Exception {
UUID owner = newUser("pet_avatar_absent");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
String renamed = patchPet(owner, petId, "{\"version\":1,\"name\":\"改个名\"}", 200);
assertThat((String) JsonPath.read(renamed, "$.data.name")).isEqualTo("改个名");
assertThat((String) JsonPath.read(renamed, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
/**
* 指针在、资源却退出 ready(如后台清理置 failed):URL 降级为 null 而不是
* 签一个下载必 404 的地址;数据库指针本身保留,不做隐式清理。
*/
@Test
void avatarUrlDegradesToNullWhenTheAssetLeavesReady() throws Exception {
UUID owner = newUser("pet_avatar_degrade");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, asset), 200);
jdbcClient.sql("UPDATE media.assets SET status = 'failed' WHERE id = :id")
.param("id", asset)
.update();
assertThat((Object) JsonPath.read(detail(owner, petId), "$.data.avatarUrl")).isNull();
assertThat(dbAvatarAssetId(petId)).isEqualTo(asset);
}
// ---- 权限:WRITE 档(ADR-022 ---------------------------------------
/**
* caregiver 可改头像(WRITE 档:头像属日常照护信息,与体重/疫苗同档),
* 但资料本体仍是 MANAGE —— 同一端点按「本次请求碰了哪些字段」定档。
*/
@Test
void caregiverMayChangeTheAvatarButNotTheProfile() throws Exception {
UUID owner = newUser("pet_avatar_owner2");
UUID caregiver = newUser("pet_avatar_caregiver");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), caregiver, "caregiver");
UUID asset = readyPetAvatar(caregiver);
String patched = patchPet(caregiver, petId, setAvatarBody(0, asset), 200);
assertThat((String) JsonPath.read(patched, "$.data.avatarUrl")).startsWith(SIGNED_PREFIX);
// 资料字段仍需 MANAGE
patchPetExpectingCode(caregiver, petId, "{\"version\":1,\"name\":\"照护人改名\"}", 403, 40300);
// 头像 + 资料混合按更严的那一半判(MANAGE)
patchPetExpectingCode(caregiver, petId,
"{\"version\":1,\"name\":\"夹带改名\",\"avatarAssetId\":null}", 403, 40300);
}
@Test
void viewerCannotChangeTheAvatar() throws Exception {
UUID owner = newUser("pet_avatar_owner3");
UUID viewer = newUser("pet_avatar_viewer");
String petId = createPetAs(owner);
grantRole(UUID.fromString(petId), viewer, "viewer");
UUID asset = readyPetAvatar(viewer);
patchPetExpectingCode(viewer, petId, setAvatarBody(0, asset), 403, 40300);
assertThat(dbAvatarAssetId(petId)).isNull();
// 只读仍可见
mockMvc.perform(get("/api/v1/pets/{id}", petId)
.header("Authorization", "Bearer " + tokenFor(viewer)))
.andExpect(status().isOk());
}
// ---- 不存在路径(防枚举) --------------------------------------------
@Test
void strangerAndGhostPetAnswerTheSame404() throws Exception {
UUID owner = newUser("pet_avatar_owner4");
UUID stranger = newUser("pet_avatar_stranger");
String petId = createPetAs(owner);
UUID asset = readyPetAvatar(stranger);
patchPetExpectingCode(stranger, petId, setAvatarBody(0, asset), 404, 40401);
patchPetExpectingCode(stranger, UUID.randomUUID().toString(),
setAvatarBody(0, asset), 404, 40401);
}
// ---- 参数错:asset 非法四态 ------------------------------------------
@Test
void rejectsUnknownForeignOrWrongPurposeAsset() throws Exception {
UUID owner = newUser("pet_avatar_asset");
UUID stranger = newUser("pet_avatar_assetowner");
String petId = createPetAs(owner);
// 幽灵 id 与他人 asset 同答 40405(防枚举合并)
patchPetExpectingCode(owner, petId, setAvatarBody(0, UUID.randomUUID()), 404, 40405);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(stranger, "pet_avatar", "ready")), 404, 40405);
// 用途不符:帖子配图不能当宠物头像
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "post_image", "ready")), 404, 40405);
// 用户头像也不行:两种头像用途各归各
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "user_avatar", "ready")), 404, 40405);
// 已删资源对引用方即不存在
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "deleted")), 404, 40405);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsAssetThatIsNotReadyYet() throws Exception {
UUID owner = newUser("pet_avatar_state");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "uploading")), 422, 42203);
patchPetExpectingCode(owner, petId,
setAvatarBody(0, insertAsset(owner, "pet_avatar", "failed")), 422, 42203);
assertThat(dbAvatarAssetId(petId)).isNull();
}
@Test
void rejectsMalformedAvatarAssetIdAndMissingVersion() throws Exception {
UUID owner = newUser("pet_avatar_malformed");
String petId = createPetAs(owner);
patchPetExpectingCode(owner, petId,
"{\"version\":0,\"avatarAssetId\":\"not-a-uuid\"}", 400, 40000);
// version 仍是必填(乐观锁不可绕过),哪怕只改头像
patchPetExpectingCode(owner, petId,
"{\"avatarAssetId\":\"%s\"}".formatted(readyPetAvatar(owner)), 400, 40000);
}
// ---- 并发冲突与重放 --------------------------------------------------
/**
* 头像写入走同一把乐观锁:拿旧 version 的第二个写者必败 40902(并发冲突),
* 而带同一 body 的重放正是「旧 version 再来一次」,因此必须同样冲突——这
* 是 pets 域自 M2 起的一致语义,头像不另开后门。
*/
@Test
void staleVersionLosesAndReplayOfTheSameBodyConflicts() throws Exception {
UUID owner = newUser("pet_avatar_version");
String petId = createPetAs(owner);
UUID first = readyPetAvatar(owner);
UUID second = readyPetAvatar(owner);
patchPet(owner, petId, setAvatarBody(0, first), 200);
// 重放(同 body、同旧 version)→ 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, first), 409, 40902);
// 另一个写者拿旧 version 抢改 → 同样 40902
patchPetExpectingCode(owner, petId, setAvatarBody(0, second), 409, 40902);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
// 用新 version 重放同一头像 → 幂等地仍是这张图(version 继续前进)
String again = patchPet(owner, petId, setAvatarBody(1, first), 200);
assertThat(dbAvatarAssetId(petId)).isEqualTo(first);
assertThat((int) JsonPath.read(again, "$.data.version")).isEqualTo(2);
}
}