- 09 V5+community 骨架(191→206)、10 埋点队列三项(272→286)、 11 契约草案(13 路径/19 操作)、12 防泄漏三仓落地、 13 media MinIO 闭环 + auth 契约测试(→226,抓修 1 漂移)、14 收口总表 - backend-modules.md 更新五模块/六容器口径 - 媒体凭据形态已定型(契约冻结输入),剩余待定型点在 T3-04/05 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
14 KiB
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经根 pomawssdk 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 镜像三处钉同一 tagminio/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-datavolume(ADR-007 应用容器无状态不破坏);healthcheck 走/minio/health/live;发布 9000 端口——预签名直传/读取 URL 都直接指向 MinIO,客户端必须可达。 user服务注入 5 个PATBOND_MINIO_*环境变量,depends_onminio 健康;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)
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-usermedia/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-authAuthContractConformanceTest(8 个,T3-19):机制与 patbond-petContractConformanceTest同构(模块内复制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/events202 响应中 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 迁云条件。