diff --git a/deploy/init-secrets.sh b/deploy/init-secrets.sh
index 2392a95..dc85f37 100755
--- a/deploy/init-secrets.sh
+++ b/deploy/init-secrets.sh
@@ -20,6 +20,17 @@ if [ ! -f .env ]; then
echo "已生成 .env(随机 DB 口令与内部令牌)"
fi
+# M3(ADR-016/021):MinIO 根凭证。按键幂等追加,兼容已有 .env;
+# 凭证只存在于被 gitignore 的 .env 中,绝不入库。
+if ! grep -q '^PATBOND_MINIO_ROOT_USER=' .env; then
+ echo "PATBOND_MINIO_ROOT_USER=patbond-minio-$(openssl rand -hex 4)" >> .env
+ echo "已追加 PATBOND_MINIO_ROOT_USER 到 .env"
+fi
+if ! grep -q '^PATBOND_MINIO_ROOT_PASSWORD=' .env; then
+ echo "PATBOND_MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)" >> .env
+ echo "已追加 PATBOND_MINIO_ROOT_PASSWORD 到 .env"
+fi
+
# 容器内以 uid 10001 运行,密钥需可读
chmod 644 deploy/keys/jwt-public.pem deploy/keys/jwt-private.pem
echo "OK:deploy/keys/ 与 .env 就绪(均已被 .gitignore 忽略)"
diff --git a/docker-compose.yml b/docker-compose.yml
index c3145ac..31b6738 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -25,6 +25,27 @@ services:
retries: 30
# 数据库不对宿主机发布端口;调试需要时可临时加 ports: ["15432:5432"]
+ # M3(ADR-016):自托管 MinIO 对象存储。镜像 tag 与集成测试的 MinIO
+ # Testcontainer 钉同一版本(三环境零分叉);对象数据落 volume(ADR-007,
+ # 应用容器保持无状态)。桶初始化由 user 服务启动时执行(ensureBucket,
+ # 本地/compose/CI 同一条路径),无需 mc 初始化容器。9000 端口必须对客户端
+ # 可达:预签名直传/读取 URL 都直接指向 MinIO,不经应用服务器。
+ minio:
+ image: minio/minio:RELEASE.2025-04-22T22-12-26Z
+ command: server /data
+ environment:
+ MINIO_ROOT_USER: ${PATBOND_MINIO_ROOT_USER:?先运行 deploy/init-secrets.sh 生成 .env}
+ MINIO_ROOT_PASSWORD: ${PATBOND_MINIO_ROOT_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
+ volumes:
+ - minio-data:/data
+ healthcheck:
+ test: ["CMD-SHELL", "curl -sf http://127.0.0.1:9000/minio/health/live"]
+ interval: 2s
+ timeout: 3s
+ retries: 30
+ ports:
+ - "${PATBOND_MINIO_PORT:-9000}:9000"
+
user:
build: ./patbond-user
environment:
@@ -34,6 +55,14 @@ services:
PATBOND_DB_PASSWORD: ${PATBOND_DB_PASSWORD:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_INTERNAL_TOKEN: ${PATBOND_INTERNAL_TOKEN:?先运行 deploy/init-secrets.sh 生成 .env}
PATBOND_JWT_PUBLIC_KEY: /run/patbond/keys/jwt-public.pem
+ # ADR-016/017:media 上传流程在 user 服务。服务自身走内网端点;
+ # 预签名 URL 按 PATBOND_MINIO_PUBLIC_ENDPOINT 签发(默认本机回环,
+ # 真机联调/生产改为客户端可达地址)。
+ PATBOND_MINIO_ENDPOINT: http://minio:9000
+ 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}
+ PATBOND_MINIO_BUCKET: ${PATBOND_MINIO_BUCKET:-patbond-media}
volumes:
- ./patbond-user/src/main/resources/application.yml.sample:/config/application.yml:ro
- ./deploy/keys:/run/patbond/keys:ro
@@ -44,6 +73,8 @@ services:
depends_on:
postgres:
condition: service_healthy
+ minio:
+ condition: service_healthy
auth:
build: ./patbond-auth
@@ -107,3 +138,4 @@ services:
volumes:
pgdata:
+ minio-data:
diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java
index d716b77..89980e3 100644
--- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java
+++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java
@@ -22,8 +22,10 @@ public enum ErrorCode {
VERSION_CONFLICT(40902, 409, "数据已被修改,请刷新后重试"),
MICROCHIP_EXISTS(40903, 409, "芯片号已被其他宠物登记"),
VACCINATION_DOSE_EXISTS(40904, 409, "该疫苗系列剂次已登记"),
+ MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
+ MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
diff --git a/patbond-user/pom.xml b/patbond-user/pom.xml
index 475217d..feb48d8 100644
--- a/patbond-user/pom.xml
+++ b/patbond-user/pom.xml
@@ -71,6 +71,12 @@
0.12.6
runtime
+
+
+ software.amazon.awssdk
+ s3
+
org.springframework.boot
spring-boot-starter-test
@@ -86,6 +92,13 @@
postgresql
test
+
+
+ org.testcontainers
+ minio
+ test
+
org.testcontainers
junit-jupiter
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/CreateMediaUploadRequest.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/CreateMediaUploadRequest.java
new file mode 100644
index 0000000..6744bc5
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/CreateMediaUploadRequest.java
@@ -0,0 +1,69 @@
+package com.patbond.patbond.user.media;
+
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Pattern;
+import jakarta.validation.constraints.Positive;
+
+/**
+ * Body of POST /api/v1/media/uploads. Fixed enums (kind) are bean-validated;
+ * configuration-driven whitelists (purpose, mimeType, byteSize cap) are
+ * checked in {@link MediaService} so limits stay configuration, not code.
+ */
+public class CreateMediaUploadRequest {
+
+ @NotBlank(message = "kind 不能为空")
+ @Pattern(regexp = "image", message = "kind 仅支持 image")
+ private String kind;
+
+ @NotBlank(message = "purpose 不能为空")
+ private String purpose;
+
+ @NotBlank(message = "mimeType 不能为空")
+ private String mimeType;
+
+ @Positive(message = "byteSize 必须为正整数")
+ private long byteSize;
+
+ @Pattern(regexp = "^[0-9a-f]{64}$", message = "sha256 须为 64 位小写十六进制")
+ private String sha256;
+
+ public String getKind() {
+ return kind;
+ }
+
+ public void setKind(String kind) {
+ this.kind = kind;
+ }
+
+ public String getPurpose() {
+ return purpose;
+ }
+
+ public void setPurpose(String purpose) {
+ this.purpose = purpose;
+ }
+
+ public String getMimeType() {
+ return mimeType;
+ }
+
+ public void setMimeType(String mimeType) {
+ this.mimeType = mimeType;
+ }
+
+ public long getByteSize() {
+ return byteSize;
+ }
+
+ public void setByteSize(long byteSize) {
+ this.byteSize = byteSize;
+ }
+
+ public String getSha256() {
+ return sha256;
+ }
+
+ public void setSha256(String sha256) {
+ this.sha256 = sha256;
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetRepository.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetRepository.java
new file mode 100644
index 0000000..fe83b47
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetRepository.java
@@ -0,0 +1,101 @@
+package com.patbond.patbond.user.media;
+
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.stereotype.Repository;
+
+import java.time.OffsetDateTime;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * media.assets access for the upload flow. All writes keep the V1 CHECK
+ * constraints as the last line of defence: storage_type='object' rows always
+ * carry bucket + object_key and no external_url (ck_media_location), and the
+ * ready transition is the only place ready_at is set (ck_media_ready).
+ */
+@Repository
+public class MediaAssetRepository {
+
+ /** The columns the upload flow reads back. */
+ public record AssetRow(UUID id, UUID ownerUserId, String kind, String purpose,
+ String bucket, String objectKey, String mimeType, Long byteSize,
+ Integer widthPx, Integer heightPx, String status,
+ OffsetDateTime createdAt, OffsetDateTime readyAt) {
+ }
+
+ private final JdbcClient jdbcClient;
+
+ public MediaAssetRepository(JdbcClient jdbcClient) {
+ this.jdbcClient = jdbcClient;
+ }
+
+ public void insertUploading(UUID id, UUID ownerUserId, String kind, String purpose,
+ String bucket, String objectKey, String mimeType,
+ long byteSize, byte[] sha256) {
+ jdbcClient.sql("""
+ INSERT INTO media.assets
+ (id, owner_user_id, kind, purpose, storage_type, bucket, object_key,
+ mime_type, byte_size, sha256, status)
+ VALUES (:id, :owner, :kind, :purpose, 'object', :bucket, :objectKey,
+ :mimeType, :byteSize, :sha256, 'uploading')
+ """)
+ .param("id", id)
+ .param("owner", ownerUserId)
+ .param("kind", kind)
+ .param("purpose", purpose)
+ .param("bucket", bucket)
+ .param("objectKey", objectKey)
+ .param("mimeType", mimeType)
+ .param("byteSize", byteSize)
+ .param("sha256", sha256)
+ .update();
+ }
+
+ public Optional findByIdAndOwner(UUID id, UUID ownerUserId) {
+ return jdbcClient.sql("""
+ SELECT id, owner_user_id, kind, purpose, bucket, object_key, mime_type,
+ byte_size, width_px, height_px, status, created_at, ready_at
+ FROM media.assets
+ WHERE id = :id AND owner_user_id = :owner
+ """)
+ .param("id", id)
+ .param("owner", ownerUserId)
+ .query((rs, rowNum) -> new AssetRow(
+ rs.getObject("id", UUID.class),
+ rs.getObject("owner_user_id", UUID.class),
+ rs.getString("kind"),
+ rs.getString("purpose"),
+ rs.getString("bucket"),
+ rs.getString("object_key"),
+ rs.getString("mime_type"),
+ rs.getObject("byte_size", Long.class),
+ rs.getObject("width_px", Integer.class),
+ rs.getObject("height_px", Integer.class),
+ rs.getString("status"),
+ rs.getObject("created_at", OffsetDateTime.class),
+ rs.getObject("ready_at", OffsetDateTime.class)))
+ .optional();
+ }
+
+ /** uploading → ready, guarded so a concurrent complete cannot double-fire. */
+ public boolean markReady(UUID id) {
+ return jdbcClient.sql("""
+ UPDATE media.assets
+ SET status = 'ready', ready_at = now(), updated_at = now()
+ WHERE id = :id AND status = 'uploading'
+ """)
+ .param("id", id)
+ .update() > 0;
+ }
+
+ /** uploading → failed (server-side verification rejected the object). */
+ public boolean markFailed(UUID id) {
+ return jdbcClient.sql("""
+ UPDATE media.assets
+ SET status = 'failed', updated_at = now()
+ WHERE id = :id AND status = 'uploading'
+ """)
+ .param("id", id)
+ .update() > 0;
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetResponse.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetResponse.java
new file mode 100644
index 0000000..ef71e70
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetResponse.java
@@ -0,0 +1,24 @@
+package com.patbond.patbond.user.media;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * Public shape of a media asset (openapi draft MediaAsset). The bucket and
+ * object key never leave the server; {@code url} is a short-lived presigned
+ * GET link, non-null only for ready assets (the bucket stays private —
+ * T3-03 #4, a recorded deviation from the draft's public-read example).
+ */
+public record MediaAssetResponse(
+ UUID id,
+ String kind,
+ String purpose,
+ String mimeType,
+ Long byteSize,
+ Integer widthPx,
+ Integer heightPx,
+ String status,
+ String url,
+ OffsetDateTime readyAt,
+ OffsetDateTime createdAt) {
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaController.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaController.java
new file mode 100644
index 0000000..f4703ca
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaController.java
@@ -0,0 +1,44 @@
+package com.patbond.patbond.user.media;
+
+import com.patbond.patbond.common.response.ApiResponse;
+import com.patbond.patbond.user.security.BearerAuthFilter;
+import jakarta.validation.Valid;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.UUID;
+
+/**
+ * Media two-step upload endpoints (openapi community draft, media tag).
+ * Authentication is mandatory and happens in BearerAuthFilter; the media
+ * bytes themselves never touch this service (presigned direct PUT).
+ */
+@RestController
+public class MediaController {
+
+ private final MediaService mediaService;
+
+ public MediaController(MediaService mediaService) {
+ this.mediaService = mediaService;
+ }
+
+ @PostMapping("/api/v1/media/uploads")
+ public ResponseEntity> createUpload(
+ @Valid @RequestBody CreateMediaUploadRequest request,
+ @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
+ return ResponseEntity.status(HttpStatus.CREATED)
+ .body(ApiResponse.success(mediaService.createUpload(userId, request)));
+ }
+
+ @PostMapping("/api/v1/media/uploads/{assetId}/complete")
+ public ApiResponse completeUpload(
+ @PathVariable UUID assetId,
+ @RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
+ return ApiResponse.success(mediaService.completeUpload(userId, assetId));
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java
new file mode 100644
index 0000000..23f97a8
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java
@@ -0,0 +1,147 @@
+package com.patbond.patbond.user.media;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+import java.time.Duration;
+import java.util.List;
+
+/**
+ * Object-storage and upload-policy knobs of the media domain (ADR-016).
+ * Vendor specifics stay behind {@link ObjectStorage}; everything here is
+ * plain S3-compatible configuration, so moving from self-hosted MinIO to a
+ * cloud object store is a matter of changing these values (and credentials),
+ * never code.
+ */
+@ConfigurationProperties(prefix = "patbond.media")
+public class MediaProperties {
+
+ /**
+ * S3-compatible API endpoint the service itself talks to (bucket init,
+ * HEAD checks), e.g. http://minio:9000 inside compose. Empty means media
+ * is unconfigured: the service still starts, but the /api/v1/media/**
+ * endpoints answer 500 (same precedent as the missing JWT public key).
+ */
+ private String endpoint = "";
+
+ /**
+ * Endpoint presigned URLs are issued against — the address CLIENTS can
+ * reach (e.g. the host's public address), which inside Docker differs
+ * from {@link #endpoint}. Empty falls back to {@link #endpoint}.
+ */
+ 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 = "";
+
+ /** Bucket holding all media objects; created at startup when missing. */
+ private String bucket = "patbond-media";
+
+ /** SigV4 region; MinIO accepts any value, cloud stores need the real one. */
+ private String region = "us-east-1";
+
+ /** TTL of presigned PUT credentials (contract: expiresAt). */
+ private Duration uploadTtl = Duration.ofMinutes(10);
+
+ /** TTL of presigned GET URLs (the bucket stays private, T3-03 range #4). */
+ private Duration downloadTtl = Duration.ofHours(1);
+
+ /** Per-file upload cap in bytes (contract cap for byteSize). */
+ private long maxByteSize = 10 * 1024 * 1024;
+
+ /** Mime whitelist for kind=image (M3: jpeg/png/webp). */
+ private List allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp");
+
+ /** Purpose whitelist; decides the objectKey prefix. M3: post_image. */
+ private List allowedPurposes = List.of("post_image");
+
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ public void setEndpoint(String endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ public String getPublicEndpoint() {
+ return publicEndpoint;
+ }
+
+ public void setPublicEndpoint(String publicEndpoint) {
+ this.publicEndpoint = publicEndpoint;
+ }
+
+ public String getAccessKey() {
+ return accessKey;
+ }
+
+ public void setAccessKey(String accessKey) {
+ this.accessKey = accessKey;
+ }
+
+ public String getSecretKey() {
+ return secretKey;
+ }
+
+ public void setSecretKey(String secretKey) {
+ this.secretKey = secretKey;
+ }
+
+ public String getBucket() {
+ return bucket;
+ }
+
+ public void setBucket(String bucket) {
+ this.bucket = bucket;
+ }
+
+ public String getRegion() {
+ return region;
+ }
+
+ public void setRegion(String region) {
+ this.region = region;
+ }
+
+ public Duration getUploadTtl() {
+ return uploadTtl;
+ }
+
+ public void setUploadTtl(Duration uploadTtl) {
+ this.uploadTtl = uploadTtl;
+ }
+
+ public Duration getDownloadTtl() {
+ return downloadTtl;
+ }
+
+ public void setDownloadTtl(Duration downloadTtl) {
+ this.downloadTtl = downloadTtl;
+ }
+
+ public long getMaxByteSize() {
+ return maxByteSize;
+ }
+
+ public void setMaxByteSize(long maxByteSize) {
+ this.maxByteSize = maxByteSize;
+ }
+
+ public List getAllowedMimeTypes() {
+ return allowedMimeTypes;
+ }
+
+ public void setAllowedMimeTypes(List allowedMimeTypes) {
+ this.allowedMimeTypes = allowedMimeTypes;
+ }
+
+ public List getAllowedPurposes() {
+ return allowedPurposes;
+ }
+
+ public void setAllowedPurposes(List allowedPurposes) {
+ this.allowedPurposes = allowedPurposes;
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaService.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaService.java
new file mode 100644
index 0000000..f187ae3
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaService.java
@@ -0,0 +1,129 @@
+package com.patbond.patbond.user.media;
+
+import com.patbond.patbond.common.error.BusinessException;
+import com.patbond.patbond.common.error.ErrorCode;
+import com.patbond.patbond.user.media.MediaAssetRepository.AssetRow;
+import com.patbond.patbond.user.support.UuidV7;
+import org.springframework.stereotype.Service;
+
+import java.time.OffsetDateTime;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.HexFormat;
+import java.util.Locale;
+import java.util.UUID;
+
+/**
+ * Two-step upload flow of the media domain (T3-03, ADR-016/017):
+ *
+ *
+ * - createUpload — whitelist checks, media.assets row in 'uploading',
+ * presigned PUT credentials; the client uploads directly to the store,
+ * no media bytes ever pass through this service;
+ * - completeUpload — server-side HEAD verification (existence, byte size,
+ * content type), then uploading → ready. A missing object keeps the row
+ * in 'uploading' (the client may still be uploading — retryable); an
+ * object that exists but contradicts the declaration goes to 'failed'
+ * (terminal: the credentials were used for something else).
+ *
+ *
+ * Cleanup of uploading rows that never complete is a documented follow-up
+ * (ix_media_uploading_created is reserved for it), not implemented in M3
+ * wave 1 — see iteration-3 report 13.
+ */
+@Service
+public class MediaService {
+
+ private static final DateTimeFormatter KEY_MONTH =
+ DateTimeFormatter.ofPattern("yyyy/MM").withZone(ZoneOffset.UTC);
+
+ private final MediaAssetRepository repository;
+ private final ObjectStorage storage;
+ private final MediaProperties properties;
+
+ public MediaService(MediaAssetRepository repository, ObjectStorage storage,
+ MediaProperties properties) {
+ this.repository = repository;
+ this.storage = storage;
+ this.properties = properties;
+ }
+
+ public MediaUploadCredentialsResponse createUpload(UUID userId, CreateMediaUploadRequest request) {
+ String mimeType = request.getMimeType().toLowerCase(Locale.ROOT);
+ if (!properties.getAllowedPurposes().contains(request.getPurpose())) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "purpose 仅支持: " + String.join(", ", properties.getAllowedPurposes()));
+ }
+ if (!properties.getAllowedMimeTypes().contains(mimeType)) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "mimeType 仅支持: " + String.join(", ", properties.getAllowedMimeTypes()));
+ }
+ if (request.getByteSize() > properties.getMaxByteSize()) {
+ throw new BusinessException(ErrorCode.VALIDATION_ERROR,
+ "byteSize 超过单文件上限 " + properties.getMaxByteSize() + " 字节");
+ }
+
+ UUID assetId = UuidV7.generate();
+ // objectKey 完全由服务端生成,不含任何用户输入(评估 §5.4)
+ String objectKey = request.getPurpose() + "/"
+ + KEY_MONTH.format(OffsetDateTime.now(ZoneOffset.UTC)) + "/" + assetId;
+ byte[] sha256 = request.getSha256() == null ? null : HexFormat.of().parseHex(request.getSha256());
+ repository.insertUploading(assetId, userId, request.getKind(), request.getPurpose(),
+ properties.getBucket(), objectKey, mimeType, request.getByteSize(), sha256);
+
+ ObjectStorage.PresignedPut put =
+ storage.presignPut(objectKey, mimeType, properties.getUploadTtl());
+ return new MediaUploadCredentialsResponse(assetId, put.url(), "PUT", put.headers(),
+ put.expiresAt());
+ }
+
+ public MediaAssetResponse completeUpload(UUID userId, UUID assetId) {
+ AssetRow asset = repository.findByIdAndOwner(assetId, userId)
+ // 防枚举:不存在与非本人所有同答 40405(草案错误码表)
+ .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
+ switch (asset.status()) {
+ case "ready" -> {
+ return toResponse(asset); // 幂等重复确认
+ }
+ case "deleted" -> throw new BusinessException(ErrorCode.MEDIA_NOT_FOUND);
+ case "failed" -> throw new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID,
+ "该上传已失败,请重新创建上传");
+ default -> {
+ // uploading:走服务端校验
+ }
+ }
+
+ ObjectStorage.ObjectStat stat = storage.stat(asset.objectKey())
+ .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID,
+ "对象尚未上传完成,请先完成直传再确认"));
+ boolean sizeMatches = asset.byteSize() != null && stat.byteSize() == asset.byteSize();
+ boolean mimeMatches = stat.contentType() == null
+ || stat.contentType().startsWith(asset.mimeType());
+ if (!sizeMatches || !mimeMatches) {
+ repository.markFailed(asset.id());
+ throw new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID,
+ "对象与登记不符(大小或类型),该上传已置为失败");
+ }
+
+ if (!repository.markReady(asset.id())) {
+ // 并发确认竞争:重读终态,ready 则幂等成功
+ AssetRow raced = repository.findByIdAndOwner(assetId, userId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND));
+ if (!"ready".equals(raced.status())) {
+ throw new BusinessException(ErrorCode.MEDIA_UPLOAD_STATE_INVALID);
+ }
+ return toResponse(raced);
+ }
+ return toResponse(repository.findByIdAndOwner(assetId, userId)
+ .orElseThrow(() -> new BusinessException(ErrorCode.MEDIA_NOT_FOUND)));
+ }
+
+ private MediaAssetResponse toResponse(AssetRow asset) {
+ String url = "ready".equals(asset.status())
+ ? storage.presignGet(asset.objectKey(), properties.getDownloadTtl())
+ : null;
+ return new MediaAssetResponse(asset.id(), asset.kind(), asset.purpose(), asset.mimeType(),
+ asset.byteSize(), asset.widthPx(), asset.heightPx(), asset.status(), url,
+ asset.readyAt(), asset.createdAt());
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaStorageConfig.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaStorageConfig.java
new file mode 100644
index 0000000..389a6dc
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaStorageConfig.java
@@ -0,0 +1,62 @@
+package com.patbond.patbond.user.media;
+
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+import java.util.Optional;
+
+/**
+ * Wires the media storage adapter. With a configured endpoint the bucket is
+ * ensured right at startup — the one and only bucket-init path, shared by
+ * local runs, compose and Testcontainers (ADR-016: three environments, zero
+ * divergence). Without one (endpoint empty) the service still starts and
+ * only the media endpoints fail with 500 — the same precedent as the
+ * missing JWT public key.
+ */
+@Configuration
+@EnableConfigurationProperties(MediaProperties.class)
+public class MediaStorageConfig {
+
+ @Bean(destroyMethod = "close")
+ public ObjectStorage objectStorage(MediaProperties properties) {
+ if (properties.getEndpoint() == null || properties.getEndpoint().isBlank()) {
+ return new UnconfiguredObjectStorage();
+ }
+ S3ObjectStorage storage = new S3ObjectStorage(properties);
+ storage.ensureBucket();
+ return storage;
+ }
+
+ /** Fails every use with a clear message instead of a dead connection. */
+ static final class UnconfiguredObjectStorage implements ObjectStorage, AutoCloseable {
+
+ private static final String MESSAGE =
+ "对象存储未配置:请设置 patbond.media.endpoint(PATBOND_MINIO_ENDPOINT)等属性";
+
+ @Override
+ public void ensureBucket() {
+ throw new IllegalStateException(MESSAGE);
+ }
+
+ @Override
+ public PresignedPut presignPut(String objectKey, String contentType, Duration ttl) {
+ throw new IllegalStateException(MESSAGE);
+ }
+
+ @Override
+ public Optional stat(String objectKey) {
+ throw new IllegalStateException(MESSAGE);
+ }
+
+ @Override
+ public String presignGet(String objectKey, Duration ttl) {
+ throw new IllegalStateException(MESSAGE);
+ }
+
+ @Override
+ public void close() {
+ }
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaUploadCredentialsResponse.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaUploadCredentialsResponse.java
new file mode 100644
index 0000000..3e0f3da
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/MediaUploadCredentialsResponse.java
@@ -0,0 +1,18 @@
+package com.patbond.patbond.user.media;
+
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+
+/**
+ * data of the 201 answer to POST /api/v1/media/uploads: the registered asset
+ * id plus the presigned direct-PUT credentials (openapi draft
+ * MediaUploadCredentials; the exact shape is T3-10 freeze input).
+ */
+public record MediaUploadCredentialsResponse(
+ UUID assetId,
+ String uploadUrl,
+ String method,
+ Map requiredHeaders,
+ Instant expiresAt) {
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/ObjectStorage.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/ObjectStorage.java
new file mode 100644
index 0000000..0db5650
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/ObjectStorage.java
@@ -0,0 +1,38 @@
+package com.patbond.patbond.user.media;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * Storage adapter of the media domain (ADR-016): the ONLY seam through which
+ * media code touches an object store. Callers deal in object keys and plain
+ * values; bucket, endpoint, credentials and vendor SDK types stay inside the
+ * implementation, so swapping self-hosted MinIO for a cloud S3-compatible
+ * store must never require a change on the caller side.
+ */
+public interface ObjectStorage {
+
+ /** Creates the configured bucket when missing; idempotent. */
+ void ensureBucket();
+
+ /**
+ * Issues short-lived credentials for a direct client PUT of the object.
+ * The Content-Type is part of the signature: the client must send the
+ * returned headers verbatim or the store rejects the upload.
+ */
+ PresignedPut presignPut(String objectKey, String contentType, Duration ttl);
+
+ /** Metadata of the stored object, or empty when nothing was uploaded. */
+ Optional stat(String objectKey);
+
+ /** Short-lived read URL; the bucket itself stays private (T3-03 #4). */
+ String presignGet(String objectKey, Duration ttl);
+
+ record PresignedPut(String url, Map headers, Instant expiresAt) {
+ }
+
+ record ObjectStat(long byteSize, String contentType) {
+ }
+}
diff --git a/patbond-user/src/main/java/com/patbond/patbond/user/media/S3ObjectStorage.java b/patbond-user/src/main/java/com/patbond/patbond/user/media/S3ObjectStorage.java
new file mode 100644
index 0000000..8bc52ee
--- /dev/null
+++ b/patbond-user/src/main/java/com/patbond/patbond/user/media/S3ObjectStorage.java
@@ -0,0 +1,136 @@
+package com.patbond.patbond.user.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.S3Client;
+import software.amazon.awssdk.services.s3.S3Configuration;
+import software.amazon.awssdk.services.s3.model.HeadObjectResponse;
+import software.amazon.awssdk.services.s3.model.NoSuchBucketException;
+import software.amazon.awssdk.services.s3.model.NoSuchKeyException;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
+import software.amazon.awssdk.services.s3.presigner.model.PutObjectPresignRequest;
+
+import java.net.URI;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * S3-compatible {@link ObjectStorage} over the AWS SDK v2, pointed at
+ * self-hosted MinIO for now (ADR-016). Two endpoints are in play: the SDK
+ * client talks to the internal endpoint (compose network), while presigned
+ * URLs are signed against the public endpoint clients can actually reach —
+ * SigV4 signs the Host header, so the two must be kept apart. Path-style
+ * addressing is forced because MinIO has no wildcard DNS for
+ * virtual-host-style buckets.
+ */
+public class S3ObjectStorage implements ObjectStorage, AutoCloseable {
+
+ private final String bucket;
+ private final S3Client client;
+ private final S3Presigner presigner;
+
+ public S3ObjectStorage(MediaProperties properties) {
+ this.bucket = properties.getBucket();
+ StaticCredentialsProvider credentials = StaticCredentialsProvider.create(
+ AwsBasicCredentials.create(properties.getAccessKey(), properties.getSecretKey()));
+ Region region = Region.of(properties.getRegion());
+ S3Configuration pathStyle = S3Configuration.builder()
+ .pathStyleAccessEnabled(true)
+ .build();
+ this.client = S3Client.builder()
+ .endpointOverride(URI.create(properties.getEndpoint()))
+ .region(region)
+ .credentialsProvider(credentials)
+ .serviceConfiguration(pathStyle)
+ .build();
+ String publicEndpoint = properties.getPublicEndpoint().isBlank()
+ ? properties.getEndpoint()
+ : properties.getPublicEndpoint();
+ this.presigner = S3Presigner.builder()
+ .endpointOverride(URI.create(publicEndpoint))
+ .region(region)
+ .credentialsProvider(credentials)
+ .serviceConfiguration(pathStyle)
+ .build();
+ }
+
+ @Override
+ public void ensureBucket() {
+ try {
+ client.headBucket(b -> b.bucket(bucket));
+ } catch (NoSuchBucketException e) {
+ try {
+ client.createBucket(b -> b.bucket(bucket));
+ } catch (S3Exception raced) {
+ // 与并行启动的实例竞争建同名桶:对方赢了即目标达成
+ if (raced.statusCode() != 409) {
+ throw raced;
+ }
+ }
+ }
+ }
+
+ @Override
+ public PresignedPut presignPut(String objectKey, String contentType, Duration ttl) {
+ PresignedPutObjectRequest presigned = presigner.presignPutObject(
+ PutObjectPresignRequest.builder()
+ .signatureDuration(ttl)
+ .putObjectRequest(b -> b.bucket(bucket).key(objectKey).contentType(contentType))
+ .build());
+ Map headers = new LinkedHashMap<>();
+ // Content-Type 恒定回传(大小写规范化):客户端必须原样携带;SDK 的
+ // signedHeaders 键名大小写与是否包含它随版本浮动,不作为唯一来源
+ headers.put("Content-Type", contentType);
+ presigned.signedHeaders().forEach((name, values) -> {
+ if (!"host".equalsIgnoreCase(name) && !"content-type".equalsIgnoreCase(name)
+ && !values.isEmpty()) {
+ headers.put(name, values.get(0));
+ }
+ });
+ return new PresignedPut(presigned.url().toString(), headers, presigned.expiration());
+ }
+
+ @Override
+ public Optional stat(String objectKey) {
+ try {
+ HeadObjectResponse head = client.headObject(b -> b.bucket(bucket).key(objectKey));
+ String contentType = head.contentType() == null
+ ? null
+ : head.contentType().toLowerCase(Locale.ROOT);
+ return Optional.of(new ObjectStat(head.contentLength(), contentType));
+ } catch (NoSuchKeyException e) {
+ return Optional.empty();
+ } catch (S3Exception e) {
+ // MinIO 对 HEAD 缺失对象返回无实体的 404,SDK 未必映射为 NoSuchKeyException
+ if (e.statusCode() == 404) {
+ return Optional.empty();
+ }
+ throw e;
+ }
+ }
+
+ @Override
+ public String presignGet(String objectKey, Duration ttl) {
+ return presigner.presignGetObject(
+ GetObjectPresignRequest.builder()
+ .signatureDuration(ttl)
+ .getObjectRequest(b -> b.bucket(bucket).key(objectKey))
+ .build())
+ .url()
+ .toString();
+ }
+
+ @Override
+ public void close() {
+ presigner.close();
+ client.close();
+ }
+}
diff --git a/patbond-user/src/main/resources/application.yml.sample b/patbond-user/src/main/resources/application.yml.sample
index dcc1423..ff0a6b2 100644
--- a/patbond-user/src/main/resources/application.yml.sample
+++ b/patbond-user/src/main/resources/application.yml.sample
@@ -37,6 +37,26 @@ patbond:
max-failures: ${PATBOND_LOGIN_LOCK_MAX_FAILURES:5}
failure-window: ${PATBOND_LOGIN_LOCK_WINDOW:15m}
lock-duration: ${PATBOND_LOGIN_LOCK_DURATION:15m}
+ media:
+ # ADR-016:S3 兼容对象存储(自托管 MinIO 起步,迁云只换这里的配置与凭证)。
+ # endpoint 为空时服务可启动,但 /api/v1/media/** 返回 500。
+ # 凭证一律经环境变量注入、绝不入库(ADR-021);compose 场景由
+ # deploy/init-secrets.sh 生成随机值写入被 gitignore 的 .env。
+ endpoint: ${PATBOND_MINIO_ENDPOINT:}
+ # 预签名 URL 面向客户端可达地址签发:SigV4 会把 Host 签进签名,
+ # 客户端不可达 endpoint(如 compose 内网 http://minio:9000)时必须
+ # 将本项设为客户端可达地址(如 http://<服务器公网地址>:9000)。
+ public-endpoint: ${PATBOND_MINIO_PUBLIC_ENDPOINT:}
+ access-key: ${PATBOND_MINIO_ACCESS_KEY:}
+ secret-key: ${PATBOND_MINIO_SECRET_KEY:}
+ bucket: ${PATBOND_MINIO_BUCKET:patbond-media}
+ # 预签名 PUT 凭据与 GET URL 的有效期
+ upload-ttl: ${PATBOND_MEDIA_UPLOAD_TTL:10m}
+ download-ttl: ${PATBOND_MEDIA_DOWNLOAD_TTL:1h}
+ # 单文件上限(字节)与 mime/purpose 白名单(M3 首版:图片、帖子配图)
+ max-byte-size: ${PATBOND_MEDIA_MAX_BYTE_SIZE:10485760}
+ allowed-mime-types: image/jpeg,image/png,image/webp
+ allowed-purposes: post_image
# Development seed data (regions reference rows) is opt-in. To load it,
# activate a dev profile that widens the Flyway locations:
diff --git a/patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java b/patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java
new file mode 100644
index 0000000..2e5b464
--- /dev/null
+++ b/patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java
@@ -0,0 +1,393 @@
+package com.patbond.patbond.user.media;
+
+import com.jayway.jsonpath.JsonPath;
+import com.patbond.patbond.user.TestcontainersConfiguration;
+import com.patbond.patbond.user.support.TestJwtKeys;
+import com.patbond.patbond.user.support.UuidV7;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.MediaType;
+import org.springframework.jdbc.core.simple.JdbcClient;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.testcontainers.containers.MinIOContainer;
+import org.testcontainers.utility.DockerImageName;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.time.OffsetDateTime;
+import java.util.Map;
+import java.util.UUID;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+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-03 media 域最小闭环全链路(真实 MinIO Testcontainer,与 compose 钉同一
+ * 镜像 tag):创建上传 → 凭据直传(真实 HTTP PUT)→ complete 确认 →
+ * ready + 预签名 GET 可访问;以及六类失败路径(非法 mime / 超限 / 未上传就
+ * 确认 / 大小不符置 failed / 重复确认幂等 / 他人与不存在 asset 防枚举)与
+ * 数据库约束-应用层校验一致性。
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@Import(TestcontainersConfiguration.class)
+class MediaUploadIntegrationTest {
+
+ /** 与 docker-compose.yml 的 minio 服务钉同一 tag(ADR-016 三环境零分叉)。 */
+ private static final MinIOContainer MINIO = new MinIOContainer(
+ DockerImageName.parse("minio/minio:RELEASE.2025-04-22T22-12-26Z"))
+ // 值仅为测试占位(dummy),非真实凭证
+ .withUserName("minio-dummy-access")
+ .withPassword("minio-dummy-secret");
+
+ private static final HttpClient HTTP = HttpClient.newBuilder()
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+
+ private static final byte[] FAKE_JPEG = fakeJpeg();
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private JdbcClient jdbcClient;
+
+ @DynamicPropertySource
+ static void wireMedia(DynamicPropertyRegistry registry) {
+ MINIO.start();
+ registry.add("patbond.jwt.public-key", TestJwtKeys::publicPem);
+ // 测试 JVM 直接可达容器映射端口,公网端点无需分离
+ registry.add("patbond.media.endpoint", MINIO::getS3URL);
+ registry.add("patbond.media.access-key", MINIO::getUserName);
+ registry.add("patbond.media.secret-key", MINIO::getPassword);
+ }
+
+ // ---- helpers -------------------------------------------------------
+
+ private static byte[] fakeJpeg() {
+ byte[] bytes = new byte[2048];
+ for (int i = 0; i < bytes.length; i++) {
+ bytes[i] = (byte) (i * 31);
+ }
+ bytes[0] = (byte) 0xFF;
+ bytes[1] = (byte) 0xD8; // JPEG SOI,凑个像样的文件头
+ return bytes;
+ }
+
+ private UUID newUser(String username) {
+ UUID id = UuidV7.generate();
+ jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
+ .param("id", id)
+ .param("username", username)
+ .update();
+ return id;
+ }
+
+ private static String bearer(UUID userId) {
+ return "Bearer " + TestJwtKeys.accessToken(
+ TestJwtKeys.KEY_PAIR.getPrivate(), userId, Duration.ofMinutes(15));
+ }
+
+ /** 创建上传并断言 201 凭据形态,返回响应体。 */
+ private String createUpload(UUID user, long byteSize) throws Exception {
+ return mockMvc.perform(post("/api/v1/media/uploads")
+ .header("Authorization", bearer(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"kind":"image","purpose":"post_image",
+ "mimeType":"image/jpeg","byteSize":%d}
+ """.formatted(byteSize)))
+ .andExpect(status().isCreated())
+ .andExpect(jsonPath("$.code").value(0))
+ .andExpect(jsonPath("$.data.assetId").isNotEmpty())
+ .andExpect(jsonPath("$.data.uploadUrl").isNotEmpty())
+ .andExpect(jsonPath("$.data.method").value("PUT"))
+ .andExpect(jsonPath("$.data.requiredHeaders['Content-Type']").value("image/jpeg"))
+ .andExpect(jsonPath("$.data.expiresAt").isNotEmpty())
+ .andReturn().getResponse().getContentAsString();
+ }
+
+ /** 按凭据把字节真实 PUT 到 MinIO,返回 HTTP 状态码。 */
+ private int directPut(String createdBody, byte[] bytes) throws Exception {
+ String uploadUrl = JsonPath.read(createdBody, "$.data.uploadUrl");
+ Map headers = JsonPath.read(createdBody, "$.data.requiredHeaders");
+ HttpRequest.Builder put = HttpRequest.newBuilder(URI.create(uploadUrl))
+ .PUT(HttpRequest.BodyPublishers.ofByteArray(bytes));
+ headers.forEach(put::header);
+ return HTTP.send(put.build(), HttpResponse.BodyHandlers.discarding()).statusCode();
+ }
+
+ private String complete(UUID user, String assetId, int expectedStatus) throws Exception {
+ return mockMvc.perform(post("/api/v1/media/uploads/{assetId}/complete", assetId)
+ .header("Authorization", bearer(user)))
+ .andExpect(status().is(expectedStatus))
+ .andReturn().getResponse().getContentAsString(java.nio.charset.StandardCharsets.UTF_8);
+ }
+
+ private String dbStatus(String assetId) {
+ return jdbcClient.sql("SELECT status FROM media.assets WHERE id = :id")
+ .param("id", UUID.fromString(assetId))
+ .query(String.class)
+ .single();
+ }
+
+ // ---- 全链路 --------------------------------------------------------
+
+ @Test
+ void fullChainUploadCompleteReadyAndFetch() throws Exception {
+ UUID user = newUser("media_full_chain");
+ String created = createUpload(user, FAKE_JPEG.length);
+ String assetId = JsonPath.read(created, "$.data.assetId");
+
+ assertThat(directPut(created, FAKE_JPEG)).isEqualTo(200);
+
+ String completed = complete(user, assetId, 200);
+ assertThat((String) JsonPath.read(completed, "$.data.status")).isEqualTo("ready");
+ assertThat((String) JsonPath.read(completed, "$.data.id")).isEqualTo(assetId);
+ assertThat((int) JsonPath.read(completed, "$.data.byteSize")).isEqualTo(FAKE_JPEG.length);
+ assertThat((String) JsonPath.read(completed, "$.data.readyAt")).isNotNull();
+ String url = JsonPath.read(completed, "$.data.url");
+ assertThat(url).as("ready 资产必须带预签名 GET URL").isNotNull();
+
+ // 预签名 GET 真实取回,字节一致(桶保持私有,无签名访问应被拒)
+ HttpResponse fetched = HTTP.send(
+ HttpRequest.newBuilder(URI.create(url)).GET().build(),
+ HttpResponse.BodyHandlers.ofByteArray());
+ assertThat(fetched.statusCode()).isEqualTo(200);
+ assertThat(fetched.body()).isEqualTo(FAKE_JPEG);
+
+ String bareUrl = url.substring(0, url.indexOf('?'));
+ HttpResponse unsigned = HTTP.send(
+ HttpRequest.newBuilder(URI.create(bareUrl)).GET().build(),
+ HttpResponse.BodyHandlers.discarding());
+ assertThat(unsigned.statusCode()).as("无签名直访私有桶必须被拒").isEqualTo(403);
+
+ // 落库行满足 ck_media_location 的 object 分支与 ck_media_ready
+ Map row = jdbcClient.sql("""
+ SELECT storage_type, bucket, object_key, external_url, status,
+ ready_at, byte_size
+ FROM media.assets WHERE id = :id
+ """)
+ .param("id", UUID.fromString(assetId))
+ .query()
+ .singleRow();
+ assertThat(row.get("storage_type")).isEqualTo("object");
+ assertThat(row.get("bucket")).isEqualTo("patbond-media");
+ assertThat((String) row.get("object_key"))
+ .startsWith("post_image/")
+ .endsWith(assetId)
+ .doesNotContain("..");
+ assertThat(row.get("external_url")).isNull();
+ assertThat(row.get("status")).isEqualTo("ready");
+ assertThat(row.get("ready_at")).isNotNull();
+ assertThat(((Number) row.get("byte_size")).longValue()).isEqualTo(FAKE_JPEG.length);
+ }
+
+ @Test
+ void repeatedCompleteIsIdempotent() throws Exception {
+ UUID user = newUser("media_idem");
+ String created = createUpload(user, FAKE_JPEG.length);
+ String assetId = JsonPath.read(created, "$.data.assetId");
+ directPut(created, FAKE_JPEG);
+ complete(user, assetId, 200);
+
+ String again = complete(user, assetId, 200);
+ assertThat((String) JsonPath.read(again, "$.data.id")).isEqualTo(assetId);
+ assertThat((String) JsonPath.read(again, "$.data.status")).isEqualTo("ready");
+ assertThat(dbStatus(assetId)).isEqualTo("ready");
+ }
+
+ // ---- 失败路径 ------------------------------------------------------
+
+ @Test
+ void rejectsMimeOutsideWhitelist() throws Exception {
+ UUID user = newUser("media_bad_mime");
+ mockMvc.perform(post("/api/v1/media/uploads")
+ .header("Authorization", bearer(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"kind":"image","purpose":"post_image",
+ "mimeType":"image/gif","byteSize":1024}
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void rejectsByteSizeOverCap() throws Exception {
+ UUID user = newUser("media_too_big");
+ mockMvc.perform(post("/api/v1/media/uploads")
+ .header("Authorization", bearer(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"kind":"image","purpose":"post_image",
+ "mimeType":"image/png","byteSize":10485761}
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void rejectsKindAndPurposeOutsideWhitelist() throws Exception {
+ UUID user = newUser("media_bad_enum");
+ mockMvc.perform(post("/api/v1/media/uploads")
+ .header("Authorization", bearer(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"kind":"video","purpose":"post_image",
+ "mimeType":"image/jpeg","byteSize":1024}
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ mockMvc.perform(post("/api/v1/media/uploads")
+ .header("Authorization", bearer(user))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"kind":"image","purpose":"pet_avatar",
+ "mimeType":"image/jpeg","byteSize":1024}
+ """))
+ .andExpect(status().isBadRequest())
+ .andExpect(jsonPath("$.code").value(40000));
+ }
+
+ @Test
+ void completeBeforeUploadKeepsAssetRetryable() throws Exception {
+ UUID user = newUser("media_premature");
+ String created = createUpload(user, FAKE_JPEG.length);
+ String assetId = JsonPath.read(created, "$.data.assetId");
+
+ String body = complete(user, assetId, 422);
+ assertThat((int) JsonPath.read(body, "$.code")).isEqualTo(42205);
+ // 对象未上传只拒不置 failed:客户端补传后可重试确认
+ assertThat(dbStatus(assetId)).isEqualTo("uploading");
+
+ directPut(created, FAKE_JPEG);
+ String recovered = complete(user, assetId, 200);
+ assertThat((String) JsonPath.read(recovered, "$.data.status")).isEqualTo("ready");
+ }
+
+ @Test
+ void sizeMismatchMarksAssetFailedTerminally() throws Exception {
+ UUID user = newUser("media_mismatch");
+ String created = createUpload(user, FAKE_JPEG.length + 100);
+ String assetId = JsonPath.read(created, "$.data.assetId");
+ directPut(created, FAKE_JPEG); // 实传字节数与登记不符
+
+ String body = complete(user, assetId, 422);
+ assertThat((int) JsonPath.read(body, "$.code")).isEqualTo(42205);
+ assertThat(dbStatus(assetId)).isEqualTo("failed");
+
+ // failed 为终态:再次确认仍 42205
+ String again = complete(user, assetId, 422);
+ assertThat((int) JsonPath.read(again, "$.code")).isEqualTo(42205);
+ assertThat(dbStatus(assetId)).isEqualTo("failed");
+ }
+
+ @Test
+ void completeIsAntiEnumerationOnForeignAndGhostAssets() throws Exception {
+ UUID owner = newUser("media_owner");
+ UUID intruder = newUser("media_intruder");
+ String created = createUpload(owner, FAKE_JPEG.length);
+ String assetId = JsonPath.read(created, "$.data.assetId");
+ directPut(created, FAKE_JPEG);
+
+ // 他人 asset 与不存在 asset 同答 40405(防枚举合并)
+ String foreign = complete(intruder, assetId, 404);
+ assertThat((int) JsonPath.read(foreign, "$.code")).isEqualTo(40405);
+ String ghost = complete(owner, UUID.randomUUID().toString(), 404);
+ assertThat((int) JsonPath.read(ghost, "$.code")).isEqualTo(40405);
+ // 未被打扰,本人仍可正常确认
+ complete(owner, assetId, 200);
+ }
+
+ @Test
+ void mediaEndpointsRequireAuthentication() throws Exception {
+ mockMvc.perform(post("/api/v1/media/uploads")
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("""
+ {"kind":"image","purpose":"post_image",
+ "mimeType":"image/jpeg","byteSize":1024}
+ """))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value(40101));
+ mockMvc.perform(post("/api/v1/media/uploads/{id}/complete", UUID.randomUUID()))
+ .andExpect(status().isUnauthorized())
+ .andExpect(jsonPath("$.code").value(40101));
+ }
+
+ // ---- 数据库约束与应用层校验一致性 -----------------------------------
+
+ @Test
+ void databaseConstraintsBackTheApplicationChecks() {
+ UUID user = newUser("media_constraints");
+
+ // ck_media_ready:应用层以外的路径也不可能造出无 ready_at 的 ready 行
+ UUID id = UuidV7.generate();
+ jdbcClient.sql("""
+ INSERT INTO media.assets (id, owner_user_id, kind, purpose, storage_type,
+ bucket, object_key, mime_type, status)
+ VALUES (:id, :owner, 'image', 'post_image', 'object',
+ 'patbond-media', 'post_image/t/' || :id, 'image/jpeg', 'uploading')
+ """)
+ .param("id", id).param("owner", user).update();
+ assertThatThrownBy(() -> jdbcClient
+ .sql("UPDATE media.assets SET status = 'ready', ready_at = NULL WHERE id = :id")
+ .param("id", id).update())
+ .as("ck_media_ready 兜底 ready 必有 ready_at")
+ .hasMessageContaining("ck_media_ready");
+
+ // ck_media_location:object 行缺 bucket/object_key 直接被库拒绝
+ assertThatThrownBy(() -> jdbcClient.sql("""
+ INSERT INTO media.assets (id, owner_user_id, kind, purpose, storage_type,
+ mime_type, status)
+ VALUES (:id, :owner, 'image', 'post_image', 'object', 'image/jpeg', 'uploading')
+ """)
+ .param("id", UuidV7.generate()).param("owner", user).update())
+ .as("ck_media_location 兜底 object 行必有 bucket+object_key")
+ .hasMessageContaining("ck_media_location");
+
+ // uq_media_object:同桶同 key 不可能登记两次
+ assertThatThrownBy(() -> jdbcClient.sql("""
+ INSERT INTO media.assets (id, owner_user_id, kind, purpose, storage_type,
+ bucket, object_key, mime_type, status)
+ VALUES (:newId, :owner, 'image', 'post_image', 'object',
+ 'patbond-media', 'post_image/t/' || :dupId, 'image/jpeg', 'uploading')
+ """)
+ .param("newId", UuidV7.generate()).param("dupId", id).param("owner", user).update())
+ .as("uq_media_object 兜底对象键唯一")
+ .hasMessageContaining("uq_media_object");
+ }
+
+ @Test
+ void uploadingTimeoutSweepIndexIsInPlace() {
+ // 清理方案(报告 13 §6,不在本迭代实现)依赖的部分索引自 V1 就位
+ Integer count = jdbcClient.sql("""
+ SELECT COUNT(*) FROM pg_indexes
+ WHERE schemaname = 'media' AND indexname = 'ix_media_uploading_created'
+ """)
+ .query(Integer.class)
+ .single();
+ assertThat(count).isEqualTo(1);
+ }
+
+ @Test
+ void expiredCredentialWindowIsHonoured() throws Exception {
+ UUID user = newUser("media_ttl");
+ String created = createUpload(user, FAKE_JPEG.length);
+ OffsetDateTime expiresAt = OffsetDateTime.parse(JsonPath.read(created, "$.data.expiresAt"));
+ OffsetDateTime now = OffsetDateTime.now();
+ // 配置默认 10 分钟 TTL 生效(形态定型输入:expiresAt 语义)
+ assertThat(expiresAt).isAfter(now.plusMinutes(8)).isBefore(now.plusMinutes(12));
+ }
+}
diff --git a/pom.xml b/pom.xml
index 55adb48..f437bd8 100644
--- a/pom.xml
+++ b/pom.xml
@@ -27,6 +27,8 @@
3.5.16
2025.0.3
+
+ 2.54.13
1.18.36
5.8.35
@@ -47,6 +49,13 @@
pom
import
+
+ software.amazon.awssdk
+ bom
+ ${aws-sdk.version}
+ pom
+ import
+
cn.hutool
hutool-all