From 15c2e66519608e09465bc43900ec7073c796b826 Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Fri, 11 Sep 2026 10:02:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=A0=E7=89=A9=E5=A4=B4=E5=83=8F?= =?UTF-8?q?=E8=AF=BB=E5=86=99=E2=80=94=E2=80=94PATCH=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=20avatarAssetId=E3=80=81=E8=AF=A6=E6=83=85=E4=B8=8E=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=A1=A5=20avatarUrl=EF=BC=88T3.5-05=EF=BC=8CADR-022?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docker-compose.yml | 5 + patbond-pet/pom.xml | 9 + .../patbond/pet/config/MediaConfig.java | 22 ++ .../patbond/patbond/pet/dto/PetResponse.java | 8 + .../patbond/pet/dto/UpdatePetRequest.java | 42 +++ .../patbond/pet/media/MediaAssetGateway.java | 42 +++ .../patbond/pet/media/MediaAssetRef.java | 10 + .../patbond/pet/media/MediaUrlSigner.java | 61 ++++ .../patbond/pet/media/PetMediaProperties.java | 80 +++++ .../patbond/pet/repository/PetRepository.java | 59 ++- .../patbond/pet/service/PetService.java | 118 +++++- .../src/main/resources/application.yml.sample | 9 + .../controller/PetAvatarIntegrationTest.java | 335 ++++++++++++++++++ 13 files changed, 782 insertions(+), 18 deletions(-) create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java create mode 100644 patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java create mode 100644 patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java diff --git a/docker-compose.yml b/docker-compose.yml index bd462f2..37c942a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -102,6 +102,11 @@ services: PATBOND_DB_USER: ${PATBOND_DB_USER:-patbond} PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem + # 媒体读取侧(M3.5 T3.5-05):宠物头像的预签名 GET 与 user 服务同一凭证/ + # 同一客户端可达地址(本地 SigV4 计算,不直连 MinIO,无需 depends_on minio)。 + PATBOND_MINIO_PUBLIC_ENDPOINT: ${PATBOND_MINIO_PUBLIC_ENDPOINT:-http://127.0.0.1:9000} + PATBOND_MINIO_ACCESS_KEY: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env} + PATBOND_MINIO_SECRET_KEY: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env} volumes: - ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro - ./deploy/keys:/run/patbond/keys:ro diff --git a/patbond-pet/pom.xml b/patbond-pet/pom.xml index d4c2110..9ddbf8f 100644 --- a/patbond-pet/pom.xml +++ b/patbond-pet/pom.xml @@ -43,6 +43,15 @@ postgresql runtime + + + software.amazon.awssdk + s3 + diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java new file mode 100644 index 0000000..3587133 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/config/MediaConfig.java @@ -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); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java index cdb27ac..8eb6b4c 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/PetResponse.java @@ -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. + * + *

{@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.

*/ 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, diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java index f3a326e..9804db5 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/dto/UpdatePetRequest.java @@ -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. + * + *

{@code avatarAssetId} is the one three-state field (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.

+ * + *

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.

*/ 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; } diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java new file mode 100644 index 0000000..943a156 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetGateway.java @@ -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 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(); + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java new file mode 100644 index 0000000..62c5d3d --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaAssetRef.java @@ -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) { +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java new file mode 100644 index 0000000..c3bc395 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/MediaUrlSigner.java @@ -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(); + } + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java new file mode 100644 index 0000000..19ed491 --- /dev/null +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/media/PetMediaProperties.java @@ -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 形参名取 value:check-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; + } +} diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java index 4347b64..f0ca535 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/repository/PetRepository.java @@ -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. + * + *

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.

*/ @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 listByUser(UUID userId) { + public List 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 findByIdForUser(UUID petId, UUID userId) { + public Optional 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), diff --git a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java index e979cd5..7a73e4a 100644 --- a/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java +++ b/patbond-pet/src/main/java/com/patbond/patbond/pet/service/PetService.java @@ -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. + * + *

Avatar semantics (T3.5-05, ADR-022): + *

    + *
  • 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).
  • + *
  • 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.
  • + *
  • 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.
  • + *
*/ @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 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; diff --git a/patbond-pet/src/main/resources/application.yml.sample b/patbond-pet/src/main/resources/application.yml.sample index e0a8527..5a79b65 100644 --- a/patbond-pet/src/main/resources/application.yml.sample +++ b/patbond-pet/src/main/resources/application.yml.sample @@ -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} diff --git a/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java new file mode 100644 index 0000000..50ad777 --- /dev/null +++ b/patbond-pet/src/test/java/com/patbond/patbond/pet/controller/PetAvatarIntegrationTest.java @@ -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) + * / 重放(同版本重放必冲突、新版本重放幂等)。 + * + *

预签名 GET 是纯本地 SigV4 计算,故这里用占位端点与占位凭证即可断言 URL + * 形态(与 patbond-community 的 PostApiTestBase 同先例),无需 MinIO 容器; + * 「签名真能下载」的实证由 user 模块的 MeAvatarSigningIntegrationTest 承担。

+ */ +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_deleted:status='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> 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); + } +}