Files
lixi f17e6f1215
CI / docs-build (push) Successful in 34s
docs: M3 第一波收口——报告 09~14 与契约草案入档挂导航
- 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>
2026-09-08 17:13:49 +08:00

137 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 ─┬─ MediaAssetRepositorymedia.assetsJdbcClient
└─ ObjectStorage(接口,媒体域唯一存储缝)
└─ S3ObjectStorageAWS 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 可重试**
不含任何用户输入) ├─ 大小/类型与登记不符 → 置 failed422/42205
│ └─ 通过 → uploading→readyguarded 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 | 已登记 assetstatus=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-FREEZEpurpose 白名单是否随 P6 扩 | **M3 定 `post_image` 一项**P6 扩 `user_avatar`/`pet_avatar` 时为纯配置追加 + 契约枚举扩展(向后兼容) | 白名单是配置项,扩展零代码 |
| 6 | TODO-FREEZEmime 白名单与 HEIC | **定 `image/jpeg` `image/png` `image/webp`,不收 HEIC** | 客户端压缩管线统一转码 jpeg(T3-13 侧约定,见 01 号工单 T3-13 描述);服务端收 HEIC 需转码能力,M3 无 |
| 7 | TODO-FREEZEbyteSize 上限草案 10 MiB | **定 1048576010 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` volumeADR-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 # 六容器 Uppostgres/minio/user (healthy)
# 媒体链路冒烟:注册 → 创建上传 → 直传 → 确认 → GET URL 取回
docker compose down
```
实测结果(2026-09-08,本机):**六容器全部 Uppostgres/minio healthy**;媒体链路冒烟全通——注册取 token → `POST /api/v1/media/uploads` 201uploadUrl 指向 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**+20media 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-13Flutter 端)联调输入**:§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 迁云条件。