From 10a43f876c2a6acd55be4e422080042025db940d Mon Sep 17 00:00:00 2001 From: Lixi20 Date: Tue, 8 Sep 2026 17:11:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20media=20=E5=9F=9F=E6=9C=80=E5=B0=8F?= =?UTF-8?q?=E9=97=AD=E7=8E=AF=E2=80=94=E2=80=94=E5=AD=98=E5=82=A8=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=B1=82=20+=20MinIO=20=E9=A2=84=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E4=B8=A4=E6=AD=A5=E4=B8=8A=E4=BC=A0=EF=BC=88ADR-016/017?= =?UTF-8?q?=EF=BC=8CT3-03=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ObjectStorage 适配层(供应商唯一缝)+ S3ObjectStorage(AWS SDK v2, path-style,内网/公网双端点分离签发);endpoint 未配置时服务可启动、 media 端点 500(沿 JWT 公钥先例) - POST /api/v1/media/uploads:白名单校验(purpose/mime/byteSize 全配置项) → uploading 行(objectKey 服务端生成不含用户输入)→ 预签名 PUT 凭据 (requiredHeaders 定型仅 Content-Type,TTL 10m) - complete:HEAD 校验存在性/大小/类型 → uploading→ready(guarded UPDATE 并发幂等);对象缺失保持 uploading 可重试,内容不符置 failed 终态; 防枚举 40405;新错误码 40405/42205 - 读取侧预签名 GET(TTL 1h),桶保持私有 - compose 纳入 minio 第六容器(镜像与 Testcontainer 同 tag,桶初始化走 应用启动 ensureBucket,三环境零分叉);init-secrets.sh 幂等追加 MinIO 根凭证到 .env(凭证零入库,ADR-021) - MediaUploadIntegrationTest 12 例:MinIO Testcontainer 全链路直传取回 + 六类失败路径 + 库层约束一致性;uploading 超时清理只记方案(报告 13 §6) Co-Authored-By: Claude Fable 5 --- deploy/init-secrets.sh | 11 + docker-compose.yml | 32 ++ .../patbond/common/error/ErrorCode.java | 2 + patbond-user/pom.xml | 13 + .../user/media/CreateMediaUploadRequest.java | 69 +++ .../user/media/MediaAssetRepository.java | 101 +++++ .../user/media/MediaAssetResponse.java | 24 ++ .../patbond/user/media/MediaController.java | 44 ++ .../patbond/user/media/MediaProperties.java | 147 +++++++ .../patbond/user/media/MediaService.java | 129 ++++++ .../user/media/MediaStorageConfig.java | 62 +++ .../media/MediaUploadCredentialsResponse.java | 18 + .../patbond/user/media/ObjectStorage.java | 38 ++ .../patbond/user/media/S3ObjectStorage.java | 136 ++++++ .../src/main/resources/application.yml.sample | 20 + .../media/MediaUploadIntegrationTest.java | 393 ++++++++++++++++++ pom.xml | 9 + 17 files changed, 1248 insertions(+) create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/CreateMediaUploadRequest.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetRepository.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/MediaAssetResponse.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/MediaController.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/MediaProperties.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/MediaService.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/MediaStorageConfig.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/MediaUploadCredentialsResponse.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/ObjectStorage.java create mode 100644 patbond-user/src/main/java/com/patbond/patbond/user/media/S3ObjectStorage.java create mode 100644 patbond-user/src/test/java/com/patbond/patbond/user/media/MediaUploadIntegrationTest.java 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): + * + *
    + *
  1. 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;
  2. + *
  3. 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).
  4. + *
+ * + * 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