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