feat: media 域最小闭环——存储适配层 + MinIO 预签名两步上传(ADR-016/017,T3-03)

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 17:11:44 +08:00
parent 8330885b06
commit 10a43f876c
17 changed files with 1248 additions and 0 deletions
+11
View File
@@ -20,6 +20,17 @@ if [ ! -f .env ]; then
echo "已生成 .env(随机 DB 口令与内部令牌)"
fi
# M3ADR-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 "OKdeploy/keys/ 与 .env 就绪(均已被 .gitignore 忽略)"
+32
View File
@@ -25,6 +25,27 @@ services:
retries: 30
# 数据库不对宿主机发布端口;调试需要时可临时加 ports: ["15432:5432"]
# M3ADR-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/017media 上传流程在 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:
@@ -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, "依赖服务暂不可用");
+13
View File
@@ -71,6 +71,12 @@
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<!-- ADR-016:对象存储适配层的 S3 兼容实现(自托管 MinIO / 迁云同一套代码)。
版本经根 pom 的 awssdk bom 管理。 -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
@@ -86,6 +92,13 @@
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- 媒体链路集成测试:MinIO Testcontainer,与 docker-compose 钉同一镜像
tagADR-016 三环境零分叉),CI 无外部依赖。 -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>minio</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
@@ -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;
}
}
@@ -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<AssetRow> 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;
}
}
@@ -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) {
}
@@ -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<ApiResponse<MediaUploadCredentialsResponse>> 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<MediaAssetResponse> completeUpload(
@PathVariable UUID assetId,
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId) {
return ApiResponse.success(mediaService.completeUpload(userId, assetId));
}
}
@@ -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<String> allowedMimeTypes = List.of("image/jpeg", "image/png", "image/webp");
/** Purpose whitelist; decides the objectKey prefix. M3: post_image. */
private List<String> 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<String> getAllowedMimeTypes() {
return allowedMimeTypes;
}
public void setAllowedMimeTypes(List<String> allowedMimeTypes) {
this.allowedMimeTypes = allowedMimeTypes;
}
public List<String> getAllowedPurposes() {
return allowedPurposes;
}
public void setAllowedPurposes(List<String> allowedPurposes) {
this.allowedPurposes = allowedPurposes;
}
}
@@ -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):
*
* <ol>
* <li>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;</li>
* <li>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).</li>
* </ol>
*
* 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());
}
}
@@ -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.endpointPATBOND_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<ObjectStat> stat(String objectKey) {
throw new IllegalStateException(MESSAGE);
}
@Override
public String presignGet(String objectKey, Duration ttl) {
throw new IllegalStateException(MESSAGE);
}
@Override
public void close() {
}
}
}
@@ -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<String, String> requiredHeaders,
Instant expiresAt) {
}
@@ -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<ObjectStat> 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<String, String> headers, Instant expiresAt) {
}
record ObjectStat(long byteSize, String contentType) {
}
}
@@ -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<String, String> 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<ObjectStat> 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 缺失对象返回无实体的 404SDK 未必映射为 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();
}
}
@@ -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-016S3 兼容对象存储(自托管 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:
@@ -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 服务钉同一 tagADR-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<String, String> 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<byte[]> 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<Void> 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<String, Object> 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_locationobject 行缺 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));
}
}
+9
View File
@@ -27,6 +27,8 @@
<spring-boot.version>3.5.16</spring-boot.version>
<spring-cloud.version>2025.0.3</spring-cloud.version>
<!-- ADR-016:对象存储走 S3 兼容 API(自托管 MinIO 起步),SDK 不在 Boot BOM 内 -->
<aws-sdk.version>2.54.13</aws-sdk.version>
<lombok.version>1.18.36</lombok.version>
<hutool.version>5.8.35</hutool.version>
</properties>
@@ -47,6 +49,13 @@
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${aws-sdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>