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:
@@ -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
|
||||
|
||||
@@ -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 形参名取 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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+335
@@ -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_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<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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user