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_USER: ${PATBOND_DB_USER:-patbond}
|
||||||
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
|
||||||
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
|
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:
|
volumes:
|
||||||
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
|
- ./patbond-pet/src/main/resources/application.yml.sample:/config/application.yml:ro
|
||||||
- ./deploy/keys:/run/patbond/keys:ro
|
- ./deploy/keys:/run/patbond/keys:ro
|
||||||
|
|||||||
@@ -43,6 +43,15 @@
|
|||||||
<artifactId>postgresql</artifactId>
|
<artifactId>postgresql</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</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
|
<!-- Access token verification (RS256, public key only): jjwt is not in
|
||||||
the Boot BOM, version pinned in step with patbond-user/auth. -->
|
the Boot BOM, version pinned in step with patbond-user/auth. -->
|
||||||
<dependency>
|
<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} /
|
* dictionary when {@code breedId} is set; exactly one of {@code breedId} /
|
||||||
* {@code customBreedName} is non-null (ck_pets_breed). {@code myRole} is the
|
* {@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.
|
* 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(
|
public record PetResponse(
|
||||||
UUID id,
|
UUID id,
|
||||||
@@ -24,6 +31,7 @@ public record PetResponse(
|
|||||||
String microchipNo,
|
String microchipNo,
|
||||||
LocalDate sterilizedOn,
|
LocalDate sterilizedOn,
|
||||||
String status,
|
String status,
|
||||||
|
String avatarUrl,
|
||||||
String myRole,
|
String myRole,
|
||||||
OffsetDateTime createdAt,
|
OffsetDateTime createdAt,
|
||||||
OffsetDateTime updatedAt,
|
OffsetDateTime updatedAt,
|
||||||
|
|||||||
@@ -16,6 +16,21 @@ import java.util.UUID;
|
|||||||
* replaces the pair as a whole (they are mutually exclusive per
|
* replaces the pair as a whole (they are mutually exclusive per
|
||||||
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
|
* ck_pets_breed). {@code version} is mandatory — it is the optimistic lock
|
||||||
* the whole endpoint exists to enforce.
|
* 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 {
|
public class UpdatePetRequest {
|
||||||
|
|
||||||
@@ -56,6 +71,33 @@ public class UpdatePetRequest {
|
|||||||
message = "status 仅支持 active/lost/deceased/archived")
|
message = "status 仅支持 active/lost/deceased/archived")
|
||||||
private String status;
|
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() {
|
public Integer getVersion() {
|
||||||
return version;
|
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;
|
package com.patbond.patbond.pet.repository;
|
||||||
|
|
||||||
import com.patbond.patbond.pet.dto.PetResponse;
|
|
||||||
import org.springframework.jdbc.core.simple.JdbcClient;
|
import org.springframework.jdbc.core.simple.JdbcClient;
|
||||||
import org.springframework.stereotype.Repository;
|
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
|
* 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
|
* calling user so a row only comes back when a relationship exists — the
|
||||||
* repository layer itself never exposes another user's pet.
|
* 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
|
@Repository
|
||||||
public class PetRepository {
|
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,
|
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.custom_breed_name, p.sex, p.birth_date, p.birth_date_estimated,
|
||||||
p.personality, p.microchip_no, p.sterilized_on, p.status,
|
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
|
po.role, p.created_at, p.updated_at, p.version
|
||||||
FROM pet_health.pets p
|
FROM pet_health.pets p
|
||||||
JOIN pet_health.pet_owners po ON po.pet_id = p.id AND po.user_id = :userId
|
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 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'
|
WHERE p.status <> 'deleted'
|
||||||
""";
|
""";
|
||||||
|
|
||||||
@@ -37,6 +45,37 @@ public class PetRepository {
|
|||||||
this.jdbcClient = jdbcClient;
|
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,
|
public void insertPet(UUID petId, String name, String species, UUID breedId,
|
||||||
String customBreedName, String sex, LocalDate birthDate,
|
String customBreedName, String sex, LocalDate birthDate,
|
||||||
boolean birthDateEstimated, String personality,
|
boolean birthDateEstimated, String personality,
|
||||||
@@ -72,14 +111,14 @@ public class PetRepository {
|
|||||||
.update();
|
.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")
|
return jdbcClient.sql(SELECT_PET + " ORDER BY p.created_at DESC, p.id DESC")
|
||||||
.param("userId", userId)
|
.param("userId", userId)
|
||||||
.query(PetRepository::mapPet)
|
.query(PetRepository::mapPet)
|
||||||
.list();
|
.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")
|
return jdbcClient.sql(SELECT_PET + " AND p.id = :petId")
|
||||||
.param("userId", userId)
|
.param("userId", userId)
|
||||||
.param("petId", petId)
|
.param("petId", petId)
|
||||||
@@ -109,14 +148,16 @@ public class PetRepository {
|
|||||||
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
|
public int updateWithVersion(UUID petId, int expectedVersion, String name, UUID breedId,
|
||||||
String customBreedName, String sex, LocalDate birthDate,
|
String customBreedName, String sex, LocalDate birthDate,
|
||||||
boolean birthDateEstimated, String personality,
|
boolean birthDateEstimated, String personality,
|
||||||
String microchipNo, LocalDate sterilizedOn, String status) {
|
String microchipNo, LocalDate sterilizedOn, String status,
|
||||||
|
UUID avatarAssetId) {
|
||||||
return jdbcClient.sql("""
|
return jdbcClient.sql("""
|
||||||
UPDATE pet_health.pets
|
UPDATE pet_health.pets
|
||||||
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
|
SET name = :name, breed_id = :breedId, custom_breed_name = :customBreedName,
|
||||||
sex = :sex, birth_date = :birthDate,
|
sex = :sex, birth_date = :birthDate,
|
||||||
birth_date_estimated = :birthDateEstimated, personality = :personality,
|
birth_date_estimated = :birthDateEstimated, personality = :personality,
|
||||||
microchip_no = :microchipNo, sterilized_on = :sterilizedOn,
|
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'
|
WHERE id = :petId AND version = :expectedVersion AND status <> 'deleted'
|
||||||
""")
|
""")
|
||||||
.param("petId", petId)
|
.param("petId", petId)
|
||||||
@@ -131,11 +172,12 @@ public class PetRepository {
|
|||||||
.param("microchipNo", microchipNo)
|
.param("microchipNo", microchipNo)
|
||||||
.param("sterilizedOn", sterilizedOn)
|
.param("sterilizedOn", sterilizedOn)
|
||||||
.param("status", status)
|
.param("status", status)
|
||||||
|
.param("avatarAssetId", avatarAssetId)
|
||||||
.update();
|
.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PetResponse mapPet(ResultSet rs, int rowNum) throws SQLException {
|
private static PetRow mapPet(ResultSet rs, int rowNum) throws SQLException {
|
||||||
return new PetResponse(
|
return new PetRow(
|
||||||
rs.getObject("id", UUID.class),
|
rs.getObject("id", UUID.class),
|
||||||
rs.getString("name"),
|
rs.getString("name"),
|
||||||
rs.getString("species"),
|
rs.getString("species"),
|
||||||
@@ -149,6 +191,9 @@ public class PetRepository {
|
|||||||
rs.getString("microchip_no"),
|
rs.getString("microchip_no"),
|
||||||
rs.getObject("sterilized_on", LocalDate.class),
|
rs.getObject("sterilized_on", LocalDate.class),
|
||||||
rs.getString("status"),
|
rs.getString("status"),
|
||||||
|
rs.getObject("avatar_asset_id", UUID.class),
|
||||||
|
rs.getString("avatar_bucket"),
|
||||||
|
rs.getString("avatar_object_key"),
|
||||||
rs.getString("role"),
|
rs.getString("role"),
|
||||||
rs.getObject("created_at", OffsetDateTime.class),
|
rs.getObject("created_at", OffsetDateTime.class),
|
||||||
rs.getObject("updated_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.CreatePetRequest;
|
||||||
import com.patbond.patbond.pet.dto.PetResponse;
|
import com.patbond.patbond.pet.dto.PetResponse;
|
||||||
import com.patbond.patbond.pet.dto.UpdatePetRequest;
|
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.BreedRepository;
|
||||||
import com.patbond.patbond.pet.repository.PetRepository;
|
import com.patbond.patbond.pet.repository.PetRepository;
|
||||||
|
import com.patbond.patbond.pet.repository.PetRepository.PetRow;
|
||||||
import com.patbond.patbond.pet.support.UuidV7;
|
import com.patbond.patbond.pet.support.UuidV7;
|
||||||
import org.springframework.dao.DuplicateKeyException;
|
import org.springframework.dao.DuplicateKeyException;
|
||||||
import org.springframework.stereotype.Service;
|
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
|
* uq_pets_microchip) so clients get a stable business error instead of a
|
||||||
* constraint-violation 500 — the constraints stay as the last line of
|
* constraint-violation 500 — the constraints stay as the last line of
|
||||||
* defense.
|
* 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
|
@Service
|
||||||
public class PetService {
|
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 PetRepository petRepository;
|
||||||
private final BreedRepository breedRepository;
|
private final BreedRepository breedRepository;
|
||||||
private final PetAccessService petAccessService;
|
private final PetAccessService petAccessService;
|
||||||
|
private final MediaAssetGateway mediaAssetGateway;
|
||||||
|
private final MediaUrlSigner mediaUrlSigner;
|
||||||
|
|
||||||
public PetService(PetRepository petRepository, BreedRepository breedRepository,
|
public PetService(PetRepository petRepository, BreedRepository breedRepository,
|
||||||
PetAccessService petAccessService) {
|
PetAccessService petAccessService, MediaAssetGateway mediaAssetGateway,
|
||||||
|
MediaUrlSigner mediaUrlSigner) {
|
||||||
this.petRepository = petRepository;
|
this.petRepository = petRepository;
|
||||||
this.breedRepository = breedRepository;
|
this.breedRepository = breedRepository;
|
||||||
this.petAccessService = petAccessService;
|
this.petAccessService = petAccessService;
|
||||||
|
this.mediaAssetGateway = mediaAssetGateway;
|
||||||
|
this.mediaUrlSigner = mediaUrlSigner;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,18 +95,18 @@ public class PetService {
|
|||||||
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
||||||
}
|
}
|
||||||
petRepository.insertPrimaryOwner(petId, userId);
|
petRepository.insertPrimaryOwner(petId, userId);
|
||||||
return petRepository.findByIdForUser(petId, userId)
|
return toResponse(petRepository.findByIdForUser(petId, userId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<PetResponse> list(UUID userId) {
|
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) {
|
public PetResponse get(UUID userId, UUID petId) {
|
||||||
petAccessService.require(userId, petId, AccessLevel.READ);
|
petAccessService.require(userId, petId, AccessLevel.READ);
|
||||||
return petRepository.findByIdForUser(petId, userId)
|
return toResponse(petRepository.findByIdForUser(petId, userId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,8 +117,8 @@ public class PetService {
|
|||||||
*/
|
*/
|
||||||
@Transactional
|
@Transactional
|
||||||
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
|
public PetResponse update(UUID userId, UUID petId, UpdatePetRequest request) {
|
||||||
petAccessService.require(userId, petId, AccessLevel.MANAGE);
|
petAccessService.require(userId, petId, requiredLevel(request));
|
||||||
PetResponse current = petRepository.findByIdForUser(petId, userId)
|
PetRow current = petRepository.findByIdForUser(petId, userId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
.orElseThrow(() -> new BusinessException(ErrorCode.PET_NOT_FOUND));
|
||||||
|
|
||||||
UUID breedId = current.breedId();
|
UUID breedId = current.breedId();
|
||||||
@@ -102,6 +134,16 @@ public class PetService {
|
|||||||
String sex = request.getSex() != null ? request.getSex() : current.sex();
|
String sex = request.getSex() != null ? request.getSex() : current.sex();
|
||||||
String status = request.getStatus() != null ? request.getStatus() : current.status();
|
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;
|
int updated;
|
||||||
try {
|
try {
|
||||||
updated = petRepository.updateWithVersion(
|
updated = petRepository.updateWithVersion(
|
||||||
@@ -120,7 +162,8 @@ public class PetService {
|
|||||||
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
|
? trimOrNull(request.getMicrochipNo()) : current.microchipNo(),
|
||||||
request.getSterilizedOn() != null
|
request.getSterilizedOn() != null
|
||||||
? request.getSterilizedOn() : current.sterilizedOn(),
|
? request.getSterilizedOn() : current.sterilizedOn(),
|
||||||
status);
|
status,
|
||||||
|
avatarAssetId);
|
||||||
} catch (DuplicateKeyException e) {
|
} catch (DuplicateKeyException e) {
|
||||||
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
throw new BusinessException(ErrorCode.MICROCHIP_EXISTS);
|
||||||
}
|
}
|
||||||
@@ -129,8 +172,34 @@ public class PetService {
|
|||||||
// missed conditional update means the version is stale.
|
// missed conditional update means the version is stale.
|
||||||
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
throw new BusinessException(ErrorCode.VERSION_CONFLICT);
|
||||||
}
|
}
|
||||||
return petRepository.findByIdForUser(petId, userId)
|
return toResponse(petRepository.findByIdForUser(petId, userId)
|
||||||
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
|
.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) {
|
private static String trimOrNull(String value) {
|
||||||
if (value == null) {
|
if (value == null) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -19,3 +19,12 @@ patbond:
|
|||||||
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
|
||||||
# 私钥只给 patbond-auth,绝不入库。
|
# 私钥只给 patbond-auth,绝不入库。
|
||||||
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
|
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