# M3 第一波 media 域最小闭环施工报告(T3-03 MinIO 接入 + T3-19 auth 契约测试补齐) > 作者:Senior Developer(后端) > 日期:2026-09-08 > 工单:T3-03(media 域最小闭环:对象存储接入与上传流程,M3 关键路径起点)、T3-19 后端半边(auth 域契约一致性测试补齐) > 代码基线:patbond-api `8330885`(206 测试全绿)→ 交付 `263cd88`(226 测试全绿) > 结论先行:**媒体凭据形态定型为「预签名 PUT 直传 + 预签名 GET 读取(桶保持私有)」;两步上传全链路(创建→直传→确认→ready→GET 可访问)在 MinIO Testcontainer 与 compose 六容器上实测通过;与契约草案偏差 7 项逐条记录(T3-10 冻结输入);auth 域 6 操作 19 个响应单元格全矩阵入契约测试;全套 226 测试全绿。** --- ## 1. 提交清单 按工单各一逻辑提交,全部已推送 `origin/dev`: | 提交 | 内容 | | --- | --- | | `10a43f8` | T3-03:存储适配层 + 两步上传流程 + MinIO 编排与全链路集成测试 | | `263cd88` | T3-19:auth 域 6 操作契约一致性测试全响应矩阵 | ## 2. 存储适配层设计(ADR-016 落地) ### 2.1 分层与供应商隔离 ``` MediaController ─ MediaService ─┬─ MediaAssetRepository(media.assets,JdbcClient) └─ ObjectStorage(接口,媒体域唯一存储缝) └─ S3ObjectStorage(AWS SDK v2,指向自托管 MinIO) ``` - **`ObjectStorage` 接口**(`patbond-user/src/main/java/com/patbond/patbond/user/media/ObjectStorage.java`)只暴露四个供应商无关操作:`ensureBucket()` / `presignPut(objectKey, contentType, ttl)` / `stat(objectKey)` / `presignGet(objectKey, ttl)`。桶名、端点、凭证、SDK 类型全部收敛在实现内——迁云(COS 等 S3 兼容服务)只换 `MediaProperties` 配置与凭证,调用侧零改动(ADR-016 迁移触发条件见该 ADR)。 - **`S3ObjectStorage`**:AWS SDK v2(`software.amazon.awssdk:s3`,版本 `2.54.13` 经根 pom `awssdk bom` 管理)。强制 path-style(MinIO 无桶级泛域名)。**双端点设计**:SDK 客户端走内网端点(compose 内 `http://minio:9000`),预签名 URL 按 `public-endpoint`(客户端可达地址)签发——SigV4 把 Host 签进签名,两者必须分开。 - **未配置时的行为**:`patbond.media.endpoint` 为空时注入 `UnconfiguredObjectStorage` 桩,服务照常启动、仅 `/api/v1/media/**` 返回 500——与 JWT 公钥未配置的既有先例一致,保证 auth E2E 等不涉媒体的上下文零外部依赖。 - **三环境零分叉**:桶初始化是应用启动时的 `ensureBucket()`(幂等,headBucket→createBucket),本地、compose、Testcontainers 走同一条代码路径;MinIO 镜像三处钉同一 tag `minio/minio:RELEASE.2025-04-22T22-12-26Z`(compose 与集成测试)。 ### 2.2 两步上传状态机(实现语义,冻结输入) ``` POST /api/v1/media/uploads POST /api/v1/media/uploads/{assetId}/complete │ │ ▼ ▼ 白名单校验(purpose/mime/byteSize) findByIdAndOwner(不存在/非本人/deleted → 404/40405 防枚举合并) │ ├─ ready → 200 幂等返回(现签 GET URL) insert uploading 行 ├─ failed → 422/42205(终态,须重新创建上传) (objectKey 服务端生成: └─ uploading → HEAD 对象: {purpose}/{yyyy/MM}/{assetId}, ├─ 对象不存在 → 422/42205,**保持 uploading 可重试** 不含任何用户输入) ├─ 大小/类型与登记不符 → 置 failed,422/42205 │ └─ 通过 → uploading→ready(guarded UPDATE, ▼ 并发确认幂等收敛),200 + GET URL 201 + 预签名 PUT 凭据 ``` - ready 迁移用 `UPDATE ... WHERE status='uploading'` 守卫,并发 complete 竞争时输家重读终态、幂等返回,不会双写 `ready_at`。 - 库层 CHECK(`ck_media_location`/`ck_media_ready`/`ck_media_status`)与 `uq_media_object` 是应用校验的兜底,集成测试对三者逐一实证(见 §7)。 ### 2.3 配置面(全部环境变量注入,ADR-021) `patbond.media.*`(`application.yml(.sample)`,占位符形态):`endpoint` / `public-endpoint` / `access-key` / `secret-key` / `bucket`(默认 patbond-media)/ `upload-ttl`(默认 10m)/ `download-ttl`(默认 1h)/ `max-byte-size`(默认 10485760)/ `allowed-mime-types`(默认 jpeg/png/webp)/ `allowed-purposes`(默认 post_image)。上限与白名单按工单要求全部是配置项,不是代码常量。 ## 3. 媒体凭据形态定型表(T3-10 契约冻结输入) `POST /api/v1/media/uploads` → 201,`data` 形态: | 字段 | 定型 | 说明 | | --- | --- | --- | | `assetId` | UUID 字符串(应用侧 UUIDv7) | 已登记 asset,status=uploading | | `uploadUrl` | 预签名 PUT 完整 URL | 签名以 query 参数携带(`X-Amz-Algorithm/-Credential/-Signature/...`);指向 `public-endpoint`,客户端直传不经应用服务器 | | `method` | 恒 `"PUT"` | | | `requiredHeaders` | `{"Content-Type": <声明的 mimeType>}` | **键集定型为仅此一键**;Content-Type 被签进签名,客户端必须原样携带,改动即 403 | | `expiresAt` | ISO-8601 date-time | 凭据过期时刻 = 签发时刻 + `upload-ttl`(默认 10 分钟);过期后重新创建上传(原 asset 仍可在补传后确认,见 §4-2) | 确认/读取侧(`MediaAsset.url`):**预签名 GET URL,TTL 默认 1 小时,仅 `status='ready'` 非空**;桶保持私有,无签名直访 403(有测试)。消费方(T3-05 Feed、头像)由服务端在每次响应时现签,客户端不持久化 URL、过期即重取。 ## 4. 与契约草案(openapi-community-draft.yaml)偏差清单 | # | 草案 | 实现定型 | 理由 | | --- | --- | --- | --- | | 1 | 读取侧留白(TODO-FREEZE:公共读稳定 URL vs 签名读;avatarUrl 示例为公共读形态,D3-1 拍板意见曾倾向公共读桶) | **私有桶 + 预签名 GET**(TTL 1h 配置项) | 任务拍板「桶保持私有」;公共读桶对越权枚举无防御,且迁云后改回私有是破坏性变更,反向(私有→放开)是兼容变更 | | 2 | complete「校验失败置 failed」一刀切 | **对象不存在 → 42205 但保持 uploading(可重试)**;对象存在但大小/类型与登记不符 → 置 failed(终态) | 客户端直传完成前误触 complete 不应把凭据作废;「传了不符的东西」才是不可恢复失败 | | 3 | 「有 sha256 则一并核」 | **sha256 照收照存(bytea),M3 不核验** | S3 HEAD 拿不到 sha256;逐字节回读核验与单机带宽约束(ADR-016 背景)冲突。迁云或 M4 需要时经 S3 checksum 特性补,不改契约形态 | | 4 | complete 未声明 400 | 实现对非 UUID `assetId` 返回 400/40000 | 冻结时给 complete 补 400/ValidationError 声明 | | 5 | TODO-FREEZE:purpose 白名单是否随 P6 扩 | **M3 定 `post_image` 一项**;P6 扩 `user_avatar`/`pet_avatar` 时为纯配置追加 + 契约枚举扩展(向后兼容) | 白名单是配置项,扩展零代码 | | 6 | TODO-FREEZE:mime 白名单与 HEIC | **定 `image/jpeg` `image/png` `image/webp`,不收 HEIC** | 客户端压缩管线统一转码 jpeg(T3-13 侧约定,见 01 号工单 T3-13 描述);服务端收 HEIC 需转码能力,M3 无 | | 7 | TODO-FREEZE:byteSize 上限草案 10 MiB | **定 10485760(10 MiB),配置项** | 与客户端压缩目标(长边约束后 jpeg 远小于 10 MiB)留足余量 | 另注:`MediaUploadCredentials` 草案的 `requiredHeaders` 标注「键集草案态」,本次定型为仅 `Content-Type` 一键(§3);`expiresAt` TTL 草案 10 分钟维持。complete 幂等语义(重复确认 200 返回既有 ready asset)与草案一致,已实证。 ## 5. compose 变更与六容器实测 ### 5.1 变更点(docker-compose.yml) - 新增 `minio` 服务:镜像钉 `minio/minio:RELEASE.2025-04-22T22-12-26Z`(与集成测试同 tag);对象数据落 `minio-data` volume(ADR-007 应用容器无状态不破坏);healthcheck 走 `/minio/health/live`;**发布 9000 端口**——预签名直传/读取 URL 都直接指向 MinIO,客户端必须可达。 - `user` 服务注入 5 个 `PATBOND_MINIO_*` 环境变量,`depends_on` minio 健康;`PATBOND_MINIO_PUBLIC_ENDPOINT` 默认本机回环,真机联调/生产改为客户端可达地址(.env 或环境覆盖)。 - `deploy/init-secrets.sh` 幂等追加 `PATBOND_MINIO_ROOT_USER`(随机后缀)与 `PATBOND_MINIO_ROOT_PASSWORD`(32 hex 随机)到被 gitignore 的 `.env`——凭证零入库,`scripts/check-secrets.sh --all` 全仓通过。 - 桶初始化在 user 服务启动路径(`ensureBucket`),**无需 mc 初始化容器**,六容器封顶。 ### 5.2 六容器实测(postgres + minio + user + auth + pet + community) ```bash cd <你的工作区>/patbond-api ./deploy/init-secrets.sh JAVA_HOME=<你的 JDK17 路径> ./mvnw -DskipTests package docker compose up -d --build docker compose ps # 六容器 Up,postgres/minio/user (healthy) # 媒体链路冒烟:注册 → 创建上传 → 直传 → 确认 → GET URL 取回 docker compose down ``` 实测结果(2026-09-08,本机):**六容器全部 Up(postgres/minio healthy)**;媒体链路冒烟全通——注册取 token → `POST /api/v1/media/uploads` 201(uploadUrl 指向 public-endpoint,`X-Amz-SignedHeaders=content-type;host` 证实 Content-Type 已签进签名)→ 按凭据直传 PUT 200 → complete 200 `status=ready` → 预签名 GET 200 且取回字节与上传逐字节一致;随后 `docker compose down` 干净退出。 ## 6. uploading 超时清理——方案(本迭代只记录不实现) - **扫描**:定时任务照 `SessionCleanupJob` 既有模式(`@Scheduled` + 配置化节奏),`SELECT id, bucket, object_key FROM media.assets WHERE status='uploading' AND created_at < now() - :timeout`,命中 V1 预留的部分索引 `ix_media_uploading_created`(该索引在位有测试锚定)。 - **处置**:先删对象(`ObjectStorage` 补 `delete(objectKey)`,容忍对象本就不存在),再把行置 `failed`(保留审计轨迹与防枚举一致性;不物理删行)。两步顺序保证不产生「行没了对象还在」的孤儿。 - **参数建议**:超时阈值 24h、扫描间隔 6h、单批上限 500 行,全部配置项。 - **排期**:随 T3-09(或第二波收口)实现;实现前 uploading 僵尸行只占元数据行与零字节~少量对象空间,无正确性风险(业务侧只认 ready)。 ## 7. 测试变化 | 项 | 基线 | 交付 | | --- | --- | --- | | 全套 `./mvnw clean test` | 206 | **226**(+20:media 12 + auth 契约 8) | 新增: - `patbond-user` `media/MediaUploadIntegrationTest`(**12 个**,MinIO Testcontainer + postgres:18 真库):全链路(创建→真实 HTTP 直传→确认→ready→预签名 GET 取回字节一致→无签名直访 403);凭据形态(201 形态、TTL 窗口);六类失败路径——非法 mime、超限 byteSize、kind/purpose 白名单外、未上传就确认(保持可重试并实证补传后恢复)、大小不符置 failed 终态、他人/不存在 asset 防枚举 40405;401 矩阵;数据库约束与应用层一致性(`ck_media_ready`/`ck_media_location`/`uq_media_object` 逐一触发库层拒绝);清理索引在位。 - `patbond-auth` `AuthContractConformanceTest`(**8 个**,T3-19):机制与 patbond-pet `ContractConformanceTest` 同构(模块内复制 `OpenApiContract`/`ContractValidator` + v1.2.0 字节级快照,同一份每模块复制纪律);运行方式沿 `AuthE2eIntegrationTest` 编排——同 JVM 真实拉起 user 服务,register/login/refresh/logout 打 auth、me/trackEvents 打 user,跨服务真实纵切。**全响应矩阵门禁:6 操作 19 个 (操作, 状态码) 单元格零豁免**,含 register 409 双业务码(40900/40901)、login 423 锁定、refresh 40102 重放、me 404 幽灵用户、events 匿名 202 与带无效 token 401。auth 路径本就在 v1.2.0 快照内,无契约升版。 - **契约测试首轮即抓到一处真实漂移**:`/api/v1/events` 202 响应中 accepted/duplicate 条目序列化出 `"reason": null`,而契约声明 reason 仅 status=rejected 时出现(且未标 nullable)。已修实现侧(`EventResult.reason` 加 `@JsonInclude(NON_NULL)`),既有埋点测试零回归——这正是 T3-19 要补的防护网生效的实证。 错误码扩充(`patbond-common` `ErrorCode`):`MEDIA_NOT_FOUND(40405, 404)`、`MEDIA_UPLOAD_STATE_INVALID(42205, 422)`——与草案错误码段取值一致;`42203 MEDIA_NOT_READY` 属 T3-04 引用侧,未预占。 ## 8. 遗留与交接 - **T3-13(Flutter 端)联调输入**:§3 凭据形态定型表 + §4 偏差清单即两步上传协议的权威描述;客户端直传须原样携带 `requiredHeaders`,压缩管线出 jpeg(偏差 #6)。 - **T3-10 冻结回填**:§4 七项偏差均需回填草案(TODO-FREEZE 三处媒体位 + complete 400 声明);冻结时同步 api 侧快照升版(两处复制:patbond-pet 与 patbond-auth 的 `src/test/resources/contract/`,快照守卫测试会拦忘记同步)。 - **清理任务**(§6)随后续波次实现;`ObjectStorage.delete` 届时补。 - 生产化注意:`PATBOND_MINIO_PUBLIC_ENDPOINT` 必须配置为客户端可达地址;带宽瓶颈显现即触发 ADR-016 迁云条件。