docs: M3 第一波收口——报告 09~14 与契约草案入档挂导航
CI / docs-build (push) Successful in 34s

- 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>
This commit is contained in:
2026-09-08 17:13:49 +08:00
parent 8e1fe2f754
commit f17e6f1215
9 changed files with 1966 additions and 4 deletions
+13 -4
View File
@@ -2,7 +2,7 @@
> 本页是后端结构的**唯一权威速览**:模块怎么划分、各自负责什么、端口与依赖关系。 > 本页是后端结构的**唯一权威速览**:模块怎么划分、各自负责什么、端口与依赖关系。
> 结构性变更(新增/拆分模块)须经 ADR 决策并同步更新本页。 > 结构性变更(新增/拆分模块)须经 ADR 决策并同步更新本页。
> 最后更新:2026-09-07M2波,T2-03 进行中)。 > 最后更新:2026-09-08M3波,T3-02 骨架落地)。
## 一图速览 ## 一图速览
@@ -11,10 +11,11 @@ patbond-apiMaven 多模块,Spring Boot 3.5 + JDK 17
├── patbond-common 公共库(无端口,被其余模块依赖) ├── patbond-common 公共库(无端口,被其余模块依赖)
├── patbond-auth 认证服务 :8081 ├── patbond-auth 认证服务 :8081
├── patbond-user 用户服务 + 埋点 :8082 ← Flyway 迁移链唯一持有者 ├── patbond-user 用户服务 + 埋点 :8082 ← Flyway 迁移链唯一持有者
── patbond-pet 宠物健康档案服务 :8083 ← M2 新增(ADR-009 ── patbond-pet 宠物健康档案服务 :8083 ← M2 新增(ADR-009
└── patbond-community 社区服务 :8084 ← M3 新增(ADR-017
``` ```
部署形态:docker compose 容器(postgres:18 + auth + user + pet),应用容器无状态(ADR-007)。 部署形态:docker compose 容器(postgres:18 + auth + user + pet + community),应用容器无状态(ADR-007)。
## 模块职责 ## 模块职责
@@ -22,7 +23,7 @@ patbond-apiMaven 多模块,Spring Boot 3.5 + JDK 17
- 统一错误信封与业务错误码体系(`code`/`message`/`data` 结构,稳定错误码契约) - 统一错误信封与业务错误码体系(`code`/`message`/`data` 结构,稳定错误码契约)
- 共享异常类型与基础组件 - 共享异常类型与基础组件
- **不含业务逻辑、不起服务**;其余三个模块都依赖它 - **不含业务逻辑、不起服务**;其余四个服务模块都依赖它
### patbond-auth(认证域,:8081 ### patbond-auth(认证域,:8081
@@ -47,6 +48,14 @@ patbond-apiMaven 多模块,Spring Boot 3.5 + JDK 17
- 照片/附件本迭代不做(ADR-010,media 域待对象存储选型) - 照片/附件本迭代不做(ADR-010,media 域待对象存储选型)
- **当前状态**:模块骨架 + T2-03(CRUD + 权限框架)施工中;数据表已由 V3 建好 - **当前状态**:模块骨架 + T2-03(CRUD + 权限框架)施工中;数据表已由 V3 建好
### patbond-community(社区域,:8084M3 新增)
- 社区 Feed / 帖子 / 单层评论 / 点赞收藏 / 关注(ADR-018 的 M3 MVP 范围;话题表已建但功能首版剪出)
- 只读写 `community` schema(数据表由 V5 建);作者公开资料按 D3-9 方案 B 经 patbond-user 的 /internal 批量接口取数(后续波次落地)
- `/api/v1/**` 自骨架起即接 RS256 资源侧校验(与 user/pet 同一公钥约定);`/health` 探活在 /api/v1 之外
- media 上传流程不在本模块(ADR-017:实现在 patbond-user,社区侧只做 asset 只读校验)
- **当前状态**:第一波骨架(T3-02)已落地;业务端点随 M3 后续波次按契约实现
## 关键纪律 ## 关键纪律
1. **契约先行**:所有对外端点以 `patbond-doc/docs/api/openapi.yaml` 为唯一事实源,新接口先冻结契约再实现(M2 起)。 1. **契约先行**:所有对外端点以 `patbond-doc/docs/api/openapi.yaml` 为唯一事实源,新接口先冻结契约再实现(M2 起)。
@@ -0,0 +1,120 @@
# M3 第一波社区地基施工报告(数据与骨架线:V5 迁移 + patbond-community 骨架)
> 作者:Senior Developer(后端)
> 日期:2026-09-08
> 工单:T3-01Flyway V5 community schema 迁移)、T3-02patbond-community 模块骨架与鉴权接入)
> 代码基线:patbond-api `64c9b72`191 测试全绿)→ 交付 `3c671fc`(206 测试全绿)
> 结论先行:**V5 建 community 全部 8 表,2 条跨 schema 外键(generation_job_id→creation、region_id→platform.regions)按拍板剥离;patbond-community:8084)挂入构建链、自骨架起 /api/v1/** 即接 RS256 校验并纳入 compose 第五容器;干净 postgres:18 上 V1→V5 全量迁移一次成功,全套 206 测试全绿。**
---
## 1. 提交清单
按工单各一逻辑提交,全部已推送 `origin/dev`
| 提交 | 内容 |
| --- | --- |
| `a97814a` | feat: Flyway V5 community schema 基线 + 迁移验证集成测试(T3-01) |
| `3c671fc` | feat: 新建 patbond-community 模块骨架(ADR-017T3-02 |
## 2. Flyway V5:表清单与裁剪对照(T3-01)
### 2.1 V5 结构基线(`patbond-user/src/main/resources/db/migration/V5__community_baseline.sql`
从目标模型 `patbond-doc/docs/database/patbond_postgresql.sql`718~875 行)提取,共建 **8 张表**
| # | 表 | 处置 | 与目标模型的差异 |
| --- | --- | --- | --- |
| 1 | `community.posts` | 建 | **剥离 2 条跨 schema FK**(见 2.2);列全保留,其余约束/索引无差异 |
| 2 | `community.post_media` | 建 | 无差异(`asset_id → media.assets` RESTRICT 保留,media 表 V1 已建;封面部分唯一索引 `uq_post_media_cover` 照建) |
| 3 | `community.comments` | 建 | 无差异(刻意单层平铺,`reply_to_user_id` 支持 @ 回复;幂等列 `client_request_id + request_hash` 照建) |
| 4 | `community.post_likes` | 建 | 无差异(PK (post_id, user_id) 天然幂等) |
| 5 | `community.post_bookmarks` | 建 | 无差异(同上) |
| 6 | `community.user_follows` | 建 | 无差异(含禁自关注 CHECK) |
| 7 | `community.topics` | 建 | 无差异(`name citext UNIQUE`)。**表建功能剪**ADR-018 话题剪出 M3 MVP,但结构按目标模型建;**无种子数据进生产链**(测试断言 topics 为空) |
| 8 | `community.post_topics` | 建 | 无差异 |
其余保留项:全部 CHECK 约束(`ck_posts_publish_state``ck_posts_idempotency``ck_comments_deleted` 等)、Feed 部分索引 `ix_posts_feed (published_at DESC, id DESC) WHERE status='published' AND visibility='public'``uq_posts_author_idempotency`、2 个 `updated_at` 触发器(posts/comments,复用 V1 的 `platform.set_updated_at()`)。到 `identity.users``pet_health.pets``media.assets` 的跨 schema FK 全部保留(三个 schema V1/V3 已存在,共库阶段先例)。
### 2.2 强制裁剪:2 条跨 schema 外键(逐条对照)
按拍板(工单 T3-01,照 V3 剪 4 条 marketplace FK 的先例格式),对应字段保留为**裸可空 uuid 列**,索引照建,迁移文件头注释逐条标明补回时点:
| # | 原定义 | V5 处置 | 补回时点 |
| --- | --- | --- | --- |
| 1 | `posts.generation_job_id → creation.generation_jobs(id) ON DELETE SET NULL` | 剥离;裸列保留,`ix_posts_generation_job` 索引保留 | **M4** 建 creation schema 的迁移补回 |
| 2 | `posts.region_id → platform.regions(id) ON DELETE SET NULL` | 剥离;裸列保留,`ix_posts_region``ix_posts_region_feed` 索引保留 | **M5** 地区体系迁移补回(ADR-018 将 region 剪出 M3 |
说明:`platform.regions` 表 V1 已存在(02 号评估 1.4 节原判「保留」),但 PM 拆解按 ADR-018 范围裁剪把 region 体系整体划入 M5,本工单按拍板剪 FK——列与索引保留,M5 补回约束零成本。
### 2.3 扩展启用
- **`pg_trgm`**:02 号评估发现 V1 只建了 pgcrypto 与 citext,而 `ix_posts_content_trgm`gin, `gin_trgm_ops`)需要 pg_trgm——V5 文件头 `CREATE EXTENSION IF NOT EXISTS pg_trgm` 补齐;postgres:18 官方镜像含 contribTestcontainers 与 compose 均实测无障碍。trgm 索引按 02 号建议照建(M3 无搜索需求,但成本极低、剪了偏离目标模型)。
- **`citext`**V1 已建;V5 以 `IF NOT EXISTS` 幂等重申(迁移日志出现一条「already exists, skipping」提示,无害)。
### 2.4 主键 DEFAULT 的取舍
02 号评估表格建议 V5「去掉主键 `DEFAULT gen_random_uuid()`」,但核对既有链:V1(users)与 V3pets**均保留了该 DEFAULT**,应用侧显式写入 UUIDv7、DB DEFAULT 仅作兜底。V5 与 V1/V3 同规**保留 DEFAULT**(工单要求「照 V3 先例写法」优先于评估建议;两者对运行时行为无差异,应用永远显式供 id)。
## 3. patbond-community 模块骨架(T3-02ADR-017
```text
patbond-community/
├── Dockerfile # 同 user/auth/pet 模式(temurin-17-jreuid 10001,无状态,EXPOSE 8084
├── pom.xml # 挂入父 pom;依赖对齐 petcommon/web/validation/jdbc/jjwt + 测试侧 user jar + Flyway + Testcontainers
└── src/
├── main/java/com/patbond/patbond/community/
│ ├── CommunityApplication.java # Spring Boot 入口
│ ├── config/CommunitySecurityProperties.java # patbond.jwt.public-key
│ ├── config/SecurityConfig.java # BearerAuthFilter 注册到 /api/v1/*order 20
│ ├── config/JacksonConfig.java # 整数字段拒绝小数(与其余服务同规)
│ ├── security/{BearerAuthFilter,JwtVerifier,RsaPublicKeyLoader}.java # RS256 资源侧校验(user/pet 同款第三份复制)
│ ├── web/GlobalExceptionHandler.java # {code,message,data} 信封契约
│ └── controller/HealthController.java # GET /health 探活(含 SELECT 1 连通检查,在 /api/v1 之外)
├── main/resources/application.yml.sample # .sample 模式,默认端口 8084,DB/公钥经环境变量注入
└── test/java/com/patbond/patbond/community/
├── TestcontainersConfiguration.java # postgres:18 @ServiceConnection;测试 classpath 挂 user jar + Flyway 跑全链 V1..V5
├── CommunityApplicationTests.java # 上下文冒烟
├── controller/HealthControllerTest.java # /health 200 + db=up
├── security/BearerAuthIntegrationTest.java # 无 token/畸形/错签/过期 → 401+40101;有效 token 过滤器放行(未实现路由 404+40400)
└── support/TestJwtKeys.java # 运行时生成 RSA 对,无密钥材料入库
```
关键取舍:
- **鉴权自骨架起接入**(与 pet 骨架期不同):T3-02 验收要求无 token/过期 token 返回 401 + 40100 系,故 `BearerAuthFilter`/`JwtVerifier`/`RsaPublicKeyLoader` 随骨架落地(user/pet 同款第三份复制)。02 号评估 P9 建议的「纯 Java 件下沉 common」未进 ADR-016~021 拍板,本单不做,留待后续决策——届时三处复制件切换为共享件、测试全绿即证等价。
- **Flyway 归属不拆**community 生产 classpath 无 Flyway;单迁移链(V1..V5)由 patbond-user 启动统一执行。模块只经 JdbcClient 读写 `community` schema。
- **compose 第五容器**:照 pet 服务块模式(build + .sample 挂载 + 环境变量注入 + 公钥只读挂载);`depends_on` postgres 健康 + user 先起(保证 V5 已执行、community schema 就绪)。
- **作者资料取数**:按 ADR-017/D3-9 方案 Buser 增 /internal 批量公开资料接口),本单只搭骨架不实现。
## 4. compose 五容器验证
`./mvnw -DskipTests package` + `docker compose up -d --build` 实测(既有 pgdata volume,数据库处于 V4):
- **五容器全部 Up**postgreshealthy+ auth + user + pet + community。
- **增量迁移零影响**:user 启动日志 `Migrating schema "public" to version "5 - community baseline"``Successfully applied 1 migration ... now at version v5`——在带 M2 数据的既有库上 V5 增量应用成功(纯增量 schema,对 V1~V4 数据零影响的实证)。
- **community 探活**`GET :8084/health``{"code":0,...,"data":{"status":"ok","db":"up"}}`pet :8083 同绿)。
- **鉴权实证**`GET :8084/api/v1/posts` 无 token → HTTP 401 + `{"code":40101,"message":"token 无效或过期"}`,与验收标准一致。
- 验证后 `docker compose down`(保留 pgdata volume),恢复环境原状。
## 5. 测试数变化:191 → 206+15,0 回归)
| 模块 | 基线 | 交付 | 新增内容 |
| --- | --- | --- | --- |
| patbond-common | 3 | 3 | — |
| patbond-user | 68 | 76 | `CommunityMigrationIntegrationTest` 8 例(schema 存在、8 表齐、pg_trgm 扩展与 trgm 索引、2 条裁剪 FK 确不存在且裸列在、保留 FK 抽查、结构抽查、触发器 2 个、topics 无种子) |
| patbond-auth | 31 | 31 | — |
| patbond-pet | 89 | 89 | — |
| patbond-community | — | 7 | 上下文冒烟 1 + /health 探活 1 + 鉴权集成 5(无 token/畸形/错签/过期 → 401+40101、有效 token 放行)+ 迁移链随上下文启动隐式验证 |
| **合计** | **191** | **206** | `JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test` 一次通过,BUILD SUCCESS |
V1→V2→V3→V4→V5 全量迁移经 Testcontainers 在全新 postgres:18 容器上自动验证通过(user 与 community 两模块的每个 @SpringBootTest 上下文启动即执行全链迁移)。
## 6. 遗留与下一波衔接
- **2 条裁剪 FK 补回**`generation_job_id` 随 M4 creation schema 迁移、`region_id` 随 M5 地区体系迁移(V5 文件头注释已标明)。
- **P9 共享设施下沉**:JWT 校验件已是第三份复制,待拍板后统一下沉 common。
- **作者公开资料 /internal 批量接口**D3-9 方案 B):随 Feed/评论纵切(T3-05 等)在 patbond-user 侧落地。
- **media 上传流程**T3-03):归 patbond-user,本模块只做 asset 只读校验,随帖子纵切接入。
- **CI**:多模块 reactor 自动含 patbond-community`.gitea/workflows/ci.yml` 零改动。
- **backend-modules.md**:已同步加 patbond-community 行与五容器口径(doc 仓工作区修改,随波末收口提交)。
@@ -0,0 +1,68 @@
# 埋点队列三项完善实施报告(M3 第一波 T3-19 前端半边)
> 作者:Frontend DeveloperFlutter
> 日期:2026-09-08
> 依据:`iteration-2/15-analytics-persistent-queue.md` §4 遗留清单、`iteration-3/06-experiment-tracking-plan.md` §2.3(三项处置与优先级)、`iteration-1/13-tracking-implementation-spec.md` §3.3/§3.4
> 仓库:patbond-flutter dev 分支,提交 `4d40c38`(基线 `720865b`
---
## 1. 背景
15 号报告 §4 留下队列三个未做项:30 秒定时冲刷、失败退避、anonymousId 持久化。iteration-3 06 号 §2.3 把三项全部排入 M3(定时冲刷 P1:社区长前台会话最多积压 19 条不上传;anonymousId P2A/B 前置 #4「登录前分流」硬依赖),ADR-020 拍板升为第一波必做。本波三项一次落地,既有语义(flushNow、4xx 毒丸丢弃、at-least-once 删段、按段拼批 ≤50、eventId UUIDv7)零回退。
## 2. 设计要点
### 2.1 30 秒定时冲刷(13 号 §3.4 第 4 触发点,四触发点补齐)
- `AnalyticsService` 新增 `startPeriodicFlush()` / `stopPeriodicFlush()`:前台期间 `Timer.periodic`(周期 `flushInterval`,默认 30 秒,构造参数化便于测试)触发 `_flush()``startPeriodicFlush` 幂等(`??=`),不叠加定时器。
- 生命周期挂接(`app.dart` + `SessionTracker`):
- `SessionTracker` 新增 `onEnterForeground` 回调,复用既有「只在离开/回到 resumed 的第一次变更触发」的级联去重逻辑——回前台级联 `hidden → inactive → resumed` 只回调一次,冷启动首个 resumed(此前未离开过前台)不触发。
- App `initState` 启动定时器;退后台回调改为「停定时器 + `flushNow()`」(既有退后台冲刷保留);回前台恢复定时器;App `dispose` 停定时器(widget 测试无悬挂 Timer)。
- 与既有触发共存:满 20 条、退后台 `flushNow`、冷启动 `restore` 三个触发点原样保留;队列为空时定时器 tick 是廉价空转(`takeBatch` 为空即返回,无网络请求、无持久化写)。
### 2.2 失败指数退避(06 号 §2.3:客户端退避先行,不依赖后端限流)
- 上传失败(网络错误/5xx)后进入退避:首次 30s,×2 递增(30s→60s→120s→240s),封顶 5 分钟;退避窗口内**定时冲刷 tick 直接跳过**,到点后下一 tick 重试。
- 任一批上传拿到服务端应答(202 受理或 4xx 拒绝——连通性已恢复)即重置退避,恢复 30 秒节奏。
- **退避只挡定时冲刷**`flushNow`(退后台)、满 20 条、冷启动 `restore` 等显式触发不受限——退后台是最后的上传窗口,不能被退避挡掉。
- **429 处理**:从「4xx 毒丸丢弃」改为按网络错误同路径(保段 + 退避重试)。后端限流从未实现(iteration-2/09 出入项核实),`Retry-After` 精细分支待其落地后一并做,代码内已留注释说明。
- 时钟经构造注入(`now` 参数,照 SessionTracker 先例),退避判定测试免真实等待。
### 2.3 anonymousId 持久化(13 号 §3.3 key,跨启动稳定)
- 现状是每次冷启动 `Uuid().v4()` 随机生成,登录前事件无法跨启动归并。本波在 `restore()` 中增加采用/落盘:shared_preferences key `pb.analytics.anonymousId` 已有值则采用;无值则把本次构造生成的 v4 落盘——首次生成后跨冷启动稳定。
- 读取/写入失败(持久化不可用)降级为进程内临时 id,只打日志绝不抛出(埋点旁路原则),埋点照常入队。
- 构造显式注入 `anonymousId` 的测试通道不参与持久化采用/落盘,既有测试语义(`anon-123` 断言)不受影响。
- 已知边界:`restore()` 完成前 track 的事件仍带构造时的临时 id(首启时两者同值无影响;后续启动 app 装配层在挂接 tracker 前即调用 restore,实际窗口趋近于零),记录备查。
### 2.4 可测性改造
`_upload` 提升为 `@protected @visibleForTesting``uploadBatch`:定时/退避测试以假上传子类替换网络层,在 `fakeAsync` 内驱动 `Timer.periodic`(真实 HttpServer 在假异步区无法完成 IO)。既有 HttpServer 集成测试不受影响,继续走真实 HTTP 路径。
## 3. 测试变化
- 基线 272 → **286 全绿**+14);`flutter analyze` 0 问题、`dart format` 无 diff。
- 新增 `test/analytics/analytics_flush_scheduler_test.dart`8 个,fakeAsync + 时钟注入 + 假上传子类):29 秒不触发 / 30 秒冲刷不满额队列、空队列不发起上传、stop 停 start 恢复(退后台/回前台)、start 幂等不叠加、30s→60s→120s 退避序列且窗口内 tick 跳过、退避封顶 5 分钟(240s×2 → 300s)、退避期间 flushNow 不受限、成功重置退避恢复 30 秒节奏。
- `analytics_persistent_queue_test.dart` +4:429 保段不丢弃不计丢弃数;anonymousId 首次 restore 落盘、冷启动新实例沿用存储值且事件携带、构造注入通道不被覆盖。
- `analytics_service_test.dart` +1:持久化不可用时 restore 降级临时 id 不崩溃。
- `session_tracker_test.dart` +1:前后台回调级联下成对各触发一次,重复 resumed 不触发。
- 既有语义回归零改动:4xx 毒丸、at-least-once、40+20 分批、flushNow 等原测试全部原样通过。
- 依赖:dev_dependencies 显式声明 `fake_async ^1.3.3`flutter_test 既有传递依赖,无新增第三方)。
## 4. 与 15 号 §4 遗留清单对照
| 15 号 §4 未做项 | 本波状态 | 说明 |
| --- | --- | --- |
| 30 秒定时冲刷 | **已做** | 前台 Timer.periodic,退后台停/回前台恢复;四触发点补齐 |
| 指数退避 | **已做** | 30s ×2 封顶 5min,只挡定时冲刷,成功即重置;15 号原案「5s ×2」按 T3-19 拍板参数调整为 30s 起步 |
| 429 按 Retry-After | **部分**(范围内的全部) | 429 已从毒丸丢弃改为保段退避;Retry-After 精细分支依赖后端限流(09 号出入项,未实现),随其落地一并做 |
| `pb.analytics.anonymousId` 持久化 | **已做** | 首次生成落盘、跨启动稳定、失败降级临时 id |
| `lastActiveAt` 持久化 | 不做(维持决策) | 03 号评估 §3.1 已裁定会话纯内存方案,非遗留项 |
| 401 去 Authorization 重试一次 | 未做 | 不在 T3-19 三项范围,继续遗留 |
## 5. 交付物
- 代码:patbond-flutter `dev` 提交 `4d40c38`(已推送),改动 9 文件 +445/−14。
- 新增:`test/analytics/analytics_flush_scheduler_test.dart`
- 修改:`lib/analytics/analytics_service.dart`(定时器、退避、anonymousId 持久化、uploadBatch 可测性)、`lib/analytics/session_tracker.dart`onEnterForeground)、`lib/app/app.dart`(定时器生命周期装配)、`pubspec.yaml`/`pubspec.lock`fake_async 显式声明)、3 个既有测试文件
@@ -0,0 +1,134 @@
# M3 community/media 域契约草案说明(T3-10 起草态)
> 作者:API 契约工程师
> 日期:2026-09-08
> 状态:**草案(DRAFT)——非冻结稿**。冻结须待 T3-03(媒体凭据)/T3-04(权限与错误语义)/T3-05(Feed 卡片)定型回填,按 M2 迭代式冻结流程升版 v1.3.0 合入 `docs/api/openapi.yaml` 并同步 api 侧字节级快照。本文与草案文件均不触碰正典 openapi.yaml。
> 草案文件:`openapi-community-draft.yaml`(同目录,独立可解析,13 路径 / 19 操作)
> 依据:iteration-3/01T3-03~09 端点定义与 T3-10 规范)、iteration-3/02(表结构、错误码段、media 状态机)、ADR-016~021、`docs/api/openapi.yaml` v1.2.0 通用约定
## 0. 字段正典基准声明
**T3-01 的 Flyway V5 迁移尚未推送 dev**(起草时 patbond-api 迁移链仅 V1~V4),本草案以 `docs/database/patbond_postgresql.sql` 的 community schema718~875 行)与 media.assets272~331 行)为字段正典。V5 落地后若与 bootstrap 有差异(预期仅两处:剪 `generation_job_id` 外键为裸列、主键默认值改应用侧 UUIDv7,均不影响契约面),以 V5 为准复核本草案。
## 1. 端点清单(13 路径 / 19 操作)
| # | 端点 | 操作 | 对应工单 | 说明 |
| --- | --- | --- | --- | --- |
| 1 | `POST /api/v1/media/uploads` | 1 | T3-03 | 登记 asset + 签发预签名 PUT 凭据(201 |
| 2 | `POST /api/v1/media/uploads/{assetId}/complete` | 1 | T3-03 | HEAD 校验后 uploading→ready200,幂等重复确认返回同 asset) |
| 3 | `POST /api/v1/posts` | 1 | T3-04 | 创建草稿或直接发布;Idempotency-Key 必带 |
| 4 | `/api/v1/posts/{postId}` | GET/PATCH/DELETE | T3-04 | 详情 / 编辑与发布(version 乐观锁)/ 软删 |
| 5 | `GET /api/v1/me/posts` | 1 | T3-04 | 我的帖子(含草稿),`(created_at,id)` 游标,status 过滤 |
| 6 | `GET /api/v1/feed` | 1 | T3-05 | 公共 Feed`(published_at,id)` 游标,谓词=ix_posts_feed |
| 7 | `/api/v1/posts/{postId}/comments` | GET/POST | T3-07 | 评论列表(游标)/ 创建(幂等 + replyToUserId |
| 8 | `DELETE /api/v1/comments/{commentId}` | 1 | T3-07 | 顶层短路径(pets 域先例),仅评论作者 |
| 9 | `/api/v1/posts/{postId}/like` | PUT/DELETE | T3-06 | 语义幂等,响应回 `{liked, likeCount}` 权威态 |
| 10 | `/api/v1/posts/{postId}/bookmark` | PUT/DELETE | T3-06 | 同构,`{bookmarked, bookmarkCount}` |
| 11 | `GET /api/v1/me/bookmarks` | 1 | T3-06 | 收藏列表,`(bookmarks.created_at, post_id)` 游标,项复用 FeedCard |
| 12 | `/api/v1/users/{userId}/follow` | PUT/DELETE | T3-08 | 语义幂等;自关注 422/42204 |
| 13 | `GET /api/v1/users/{userId}/follow-stats` | 1 | T3-08 | 计数 + followedByMeADR-018「最小接口 + 数量」口径) |
裁剪不出现(与 ADR-018 对齐):话题全部端点(T3-09 条件单未启)、关注/粉丝**列表**(最小接口仅留 follow/unfollow + 计数,列表需时纯增量补)、作者主页 `GET /users/{userId}/posts`M3 工单未列)、`region`/`generationJob`/`visibility=followers|private` 字段整体不出现(ADR-010「裁剪字段整体不出现,后续按新增可选字段补入」先例)。
## 2. 设计决策记录
1. **幂等按域(ADR-019**:二元互动(like/bookmark/followPUT/DELETE 语义幂等,重复调用返回 200 同一权威终态(非 409)——复合主键即幂等键,无键管理;创建型(发帖/评论)`Idempotency-Key` **必带**(与 pets 域「可选、≤255、不比对请求体」刻意不同:本域 ≤128 对齐表列宽,且比对 request_hash,不符 40905)。差异已在草案头参数描述中显式声明,防止 SDK/客户端按 pets 惯例误用。
2. **写响应携带权威终态**like/bookmark 回 `{liked|bookmarked, count}`follow 回 `{following, followerCount}`——iteration-3/02 §7.5 的乐观更新对账契约,客户端回滚=用响应覆盖本地值。
3. **防枚举 404 沿 pets 先例并分域给码**:帖子(40403,合并不存在/软删/hidden/他人 draft)、评论(40404)、asset(40405,合并非本人所有)、用户(40406)。403/40301 只发给「可见但无权」的调用者。
4. **发布即状态迁移**:不设独立 `/publish` 端点,`PATCH {status: published}` 是唯一开放迁移(draft→published),与 pets 域「状态流转走 PATCH」惯例一致,少一个端点少一处幂等语义。
5. **PATCH media 整组替换**(草案态):部分更新语义下图片增删排序的逐项 diff 契约复杂且易错,草案取「media 字段出现即全量替换」,随 T3-04 实现定型。
6. **AuthorSummary 服务端回退**:nickname 为空时由服务端回退 username,required 非空——客户端不做拼装(R10「缺失字段不留本地拼凑」);取数为 community 跨 schema 只读 identityADR-017),契约面不感知取数方式。
7. **列表信封零新形态**:全部列表复用 v1.2.0 cursor 分页正典 `{items, nextCursor, hasMore}`limit 1~100 缺省 20,游标不透明;每列表排序键与支撑索引在 description 中逐一写死(iteration-3/02 §7.3 对照)。
8. **complete 幂等语义**:重复 complete 已 ready 的 asset 返回 200 同 asset(客户端弱网重试友好);failed/deleted 态 422/42205——比「非 uploading 一律拒」多保留一条安全重试路径。
## 3. 与 bootstrap SQL 的字段对照
### 3.1 media.assets → MediaAsset / CreateMediaUploadRequest
| DB 列 | 契约字段 | 说明 |
| --- | --- | --- |
| id | id / assetId | UUID 字符串 |
| kind | kind | 契约 M3 仅 `image`DB CHECK 含 video/document,读侧枚举预留) |
| purpose | purpose | 白名单草案仅 `post_image`TODO-FREEZE #1 |
| mime_type | mimeType | 白名单草案 jpeg/png/webpTODO-FREEZE #1 |
| byte_size | byteSize | 创建时声明,complete 实测比对;上限草案 10 MiBTODO-FREEZE #1 |
| sha256 (bytea) | sha256 | 契约为 64 位小写 hex 字符串,可选 |
| width_px / height_px | widthPx / heightPx | complete 后回填,可空 |
| status | status | 契约仅露 uploading/ready/faileddeleted 恒 404 |
| ready_at / created_at | readyAt / createdAt | ISO 8601 |
| bucket / object_key / storage_type / duration_ms / external_url / owner_user_id | **不出现** | 存储内部细节不进契约;owner 由 token 隐含;duration 视频后置 |
### 3.2 community.posts → Post / CreatePostRequest / UpdatePostRequest
| DB 列 | 契约字段 | 说明 |
| --- | --- | --- |
| id / author_user_id | id / author(AuthorSummary) | 作者展开为公开摘要,不露裸 authorUserId(含在 author.userId |
| pet_id | petId | 可空 |
| category | category | 写侧 enum [general, help]ai_creation M4 预留只读) |
| title / content | title / content | 长度约束与 ck_posts_title/content 同宽(1~120 / 1~10000 |
| status | status | 契约露 draft/publishedhidden/archived 不开放(D3-7),草案对作者也不露 |
| visibility | visibility | M3 恒 `public`ADR-018DB 三值保留) |
| like/comment/bookmark_count | 同名 camelCase | int64 |
| idempotency_key / request_hash | Idempotency-Key 头 | 不进 body;≤128 对齐列宽 |
| published_at / created_at / updated_at / version | 同名 camelCase | version 进 PATCH 请求体(必带) |
| deleted_at | **不出现** | 软删即 404 |
| generation_job_id / region_id / location_text_snapshot | **不出现** | M4/M5 裁剪(V5 剪外键,ADR-018 |
| (关联)post_likes/post_bookmarks 行 | likedByMe / bookmarkedByMe | 批量查询组装,required |
### 3.3 community.post_media → PostMediaItem / PostMediaAttachRequest
| DB 列 | 契约字段 | 说明 |
| --- | --- | --- |
| asset_id / position / is_cover / caption | assetId / position / isCover / caption | position 0~8(≤9 图,D3-4);isCover 至多一(uq_post_media_cover),全 false 服务端取 position 0 |
| — | url / widthPx / heightPx | 响应侧由 asset 展开,免客户端二次请求 |
### 3.4 community.comments → Comment / CreateCommentRequest
| DB 列 | 契约字段 | 说明 |
| --- | --- | --- |
| id / post_id | id / postId | — |
| author_user_id / reply_to_user_id | author / replyToUser(均 AuthorSummary | 请求侧 replyToUserId 裸 UUID |
| content | content | 1~2000 同宽 |
| client_request_id / request_hash | Idempotency-Key 头 | 落 client_request_id 列 |
| status / deleted_at / updated_at | **不出现** | deleted/hidden 过滤在列表外;契约无评论编辑,不露 updatedAt |
### 3.5 post_likes / post_bookmarks / user_follows
关系行不作为资源暴露,仅以 `likedByMe`/`bookmarkedByMe`/`following`/`followedByMe` 布尔态与计数出现;复合主键 = PUT/DELETE 幂等的实现本体(`ON CONFLICT DO NOTHING` + 同事务计数增减)。`ck_user_follows_self` → 422/42204。
## 4. TODO-FREEZE 清单(PM 三处 + 补充一处)
草案 YAML 内共 10 处 `# TODO-FREEZE` 标注,归并为 4 个待定型点:
| # | 待定型点 | 等待 | 草案内位置 | 草案预设 |
| --- | --- | --- | --- | --- |
| 1 | **媒体凭据形态**PM 列①):uploadUrl 签名形态、requiredHeaders 键集、TTL、读取侧 URL(公共读稳定 URL vs 签名读);连带 purpose/mime 白名单与大小上限数值 | T3-03 | `createMediaUpload``MediaUploadCredentials``MediaAsset.url``CreateMediaUploadRequest` 三字段 | 预签名 PUT + TTL 10 分钟 + 10 MiB + jpeg/png/webp + purpose 仅 post_image |
| 2 | **Feed 卡片字段**PM 列②):contentPreview 截断规则、coverImage 选取规则、是否需 mediaCount 外的图列表 | T3-05 | `getFeed``FeedCard`;收藏列表「已删帖静默剔除 vs 占位」联动 | 200 字符截断 + isCover→position 0 + 仅封面一图 + mediaCount |
| 3 | **作者公开资料形态**(PM 列③,D3-9 方案 B 预设字段) | T3-05 | `AuthorSummary` | userId + nickname(服务端回退 username+ avatarUrl 可空;bio/username 露出与注销墓碑待定 |
| 4 | **权限矩阵与错误语义边界**(补充,冻结条件之一) | T3-04 | `getPost``UpdatePostRequest.media` 整组替换语义、作者视角 hidden 露出 | 403/404 边界按 §2-3 草案;media 整组替换 |
收敛期限沿 PM 要求:第二波中期。冻结时逐项回填、删除标注、升版 v1.3.0、同步 api 侧字节级快照。
## 5. 错误码段草案(新增 9 码,延续既有分段不重编号)
| 业务码 | HTTP | 稳定名 | 场景 |
| --- | --- | --- | --- |
| 40301 | 403 | POST_ACCESS_DENIED | 可见但无权操作(改删他人帖/评论) |
| 40403 | 404 | POST_NOT_FOUND | 不存在/软删/hidden/不可见,防枚举合并 |
| 40404 | 404 | COMMENT_NOT_FOUND | 评论不存在/已删/所属帖不可见 |
| 40405 | 404 | MEDIA_NOT_FOUND | asset 不存在或非本人所有 |
| 40406 | 404 | USER_NOT_FOUND | 关注目标用户不存在/已注销(**草案新增**,02 号报告未列) |
| 40905 | 409 | IDEMPOTENCY_PAYLOAD_MISMATCH | 同键不同 payloadrequest_hash 不符) |
| 42203 | 422 | MEDIA_NOT_READY | 引用非 ready 的 asset |
| 42204 | 422 | FOLLOW_RULE_VIOLATION | 自关注(**草案新增** |
| 42205 | 422 | MEDIA_UPLOAD_STATE_INVALID | complete 时 asset 非 uploading(幂等 ready 除外)(**草案新增** |
复用既有码:40000(参数校验,含 mime/大小白名单拒绝、游标非法、limit 越界、Idempotency-Key 缺失/超长、非法状态迁移)、40101token)、40401petId 引用不可见宠物,沿 pets 域语义)、40902version 冲突)、50000/50300。与 iteration-3/02 §6 六码草案的差异:新增 40406/42204/42205 三码(关注与 complete 状态机在 02 号报告端点表中有行为但无码位),冻结评审时定夺。
## 6. 冻结前必办事项(交接给冻结时点)
1. T3-01 V5 推送后与 bootstrap 复核一遍字段对照(§0)。
2. 四个 TODO-FREEZE 点逐项回填(§4),删除全部标注。
3. 错误码段三枚草案新增码(40406/42204/42205)评审定夺。
4. 合入正典 openapi.yaml:升版 1.3.0、错误码表并入 info 头、servers 增 :8084、tags 并入;`mkdocs build --strict` + api 侧字节级快照同步。
5. Idempotency-Key「必带 + 比对 hash + ≤128」与 pets 域差异在正典 info 头「通用约定」中显式成文。
@@ -0,0 +1,80 @@
# 12 M3 第一波:凭证防泄漏检查落地(ADR-021)
- 执行人:Git Workflow Master
- 日期:2026-09-08
- 依据:ADR-021(CI 兜底 grep 第一波必做、先于 MinIO 凭证进开发机)、iteration-3/08 §4 两层纯 shell 方案
- 交付边界:本报告只记录,不入 mkdocs 导航;T3-03 自本波 CI 全绿起解除对象存储凭证引入限制。
---
## 1. 落地形态
两层检查、同一规则表,单一来源为各仓入库的 `scripts/check-secrets.sh`(纯 shell + git + grep,零外部依赖、零外部 action,符合三仓 CI「手动克隆本实例」模式约束)。三仓副本内容逐字节同构(`cp -p` 分发),调整规则时三仓同步提交。
| 层 | 载体 | 触发 | 扫描范围 |
| --- | --- | --- | --- |
| 第一层(推荐) | `scripts/hooks/pre-commit` → 同一脚本 `--staged` | 本地 `git commit``git config core.hooksPath scripts/hooks` 启用,每人每仓一次) | 暂存区内容 + 暂存文件名 |
| 第二层(强制兜底) | 三仓 `ci.yml` checkout 后首个 step,同一脚本 `--all` | 每次 push / PR | 全部已跟踪文件(本次 push 变更文件的超集;`--force-with-lease``--no-verify` 均无法绕过) |
CI 采用 `--all` 而非「仅 diff 变更文件」的原因:三仓 CI 均为 depth-1 浅克隆,无可靠的 push 前基点可 diff;全量扫描是变更文件的严格超集且实测最慢仓仅 6.7 秒,顺带覆盖历史存量。ci.yml 只加 step,既有逻辑零改动。
## 2. 规则集清单(9 条)
内容规则 8 条(规则表内 ID):
| ID | 检测 | 形态 |
| --- | --- | --- |
| AK-AWS | AWS/MinIO S3 兼容 AK | `AKIA` + 16 位大写字母数字 |
| AK-QCLOUD | 腾讯云 SecretId | `AKID` + 16 位以上字母数字 |
| AK-ALIYUN | 阿里云 AK | `LTAI` + 12 位以上字母数字 |
| MINIO-DEFAULT | MinIO 默认凭证 | minio·admin 及连写变体(忽略大小写) |
| PRIVATE-KEY | 私钥块 | **独占一行**的 `-----BEGIN …PRIVATE KEY-----` PEM 头 |
| KEY-ASSIGN | access/secret key 实值赋值 | `accessKey/secret_key/…` 后接 `:`/`=` 与 8 位以上实值 |
| JWT-SECRET | JWT/签名密钥材料 | `jwt-secret/signing-key/token-secret/hmac-key` 赋值实值 |
| DB-PASSWORD | 数据库口令非注入形态 | 仅限配置类文件(yml/yaml/properties/toml/conf/ini 及其 .sample/.example),`password/passwd/pwd` 赋 6 位以上非 `${}` 实值 |
文件名黑名单 1 条(NAME-DENY):`.env`/`.env.*``credentials*`、密钥导出 CSV`rootkey.csv``*accessKeys*.csv` 形态)本体禁入版本库;`.sample`/`.example` 后缀豁免。
允许清单(行级放行):`${…}`/`{{…}}` 注入形态、`changeme`/`change-me``your-xxx``<占位>``placeholder`/`example`/`sample`/`dummy`/`fake`/`redacted``***`。二进制文件经 `grep -I` 自然跳过;脚本与 hook 自身(含规则文本)路径豁免。
### 2.1 关键校准(避免误伤的两处设计)
1. **PRIVATE-KEY 采用「PEM 头独占一行」判据**patbond-api 有两处合法的 PEM 头字面量——`TestJwtKeys.java`(测试密钥**运行时生成**,无入库密钥材料)与 `RsaPrivateKeyLoader.java`(解析代码的 `.replace(...)`)。两处 PEM 头都嵌在代码字符串中而非独占一行,该判据下自然通过,无需路径白名单;真实 .pem 文件或粘进 yaml 的密钥块(头行独立)仍必中。
2. **DB-PASSWORD 限定配置类文件**api 测试代码与 Readme 的 curl 示例大量使用 `"password":"secret123"` 假值,Java/Markdown 不在该规则文件范围内;配置类文件中现有口令全部为 `${PATBOND_DB_PASSWORD:…}` 注入形态(docker-compose.yml、application.yml.sample 逐行核实),实值直写才会命中。
## 3. 误报实测:三仓现有全部已跟踪文件零误报
| 仓库 | 已跟踪文件数 | `--all` 扫描结果 | 耗时 |
| --- | --- | --- | --- |
| patbond-api | 194+本次 3 | 零命中,exit 0 | 5.6s |
| patbond-flutter | 234+本次 3 | 零命中,exit 0 | 6.7s |
| patbond-doc | 74+本次 4 | 零命中,exit 0 | 2.1s |
另以 `--staged` 模式对本次新增文件(脚本、hook、ci.yml、git-workflow.md)复扫,同样零命中——即规则集对自身与规范文档不误伤。
## 4. 拦截自测(临时仓构造假凭证,验证后已删除,未入库)
在 scratchpad 一次性 git 仓中构造全假样本(编造值,无任何真实凭证),结果:
- **应拦 9 类全部命中**AKIA 假 AK、AKID、LTAI、minio·admin(连写形态)、独立 PEM 头、accessKey/secretKey 实值赋值、yml 中 password 实值、`.env` 文件本体(NAME-DENY)——`--staged``--all`、文件参数三种模式一致,exit 1。
- **hook 真实阻断**`git config core.hooksPath scripts/hooks``git commit` 被 pre-commit 拒绝(exit 1),输出命中清单与处置指引(真凭证先轮换后清历史)。
- **应放行全部通过**`${PATBOND_DB_PASSWORD:patbond}` 注入、`changeme`/`your-access-key` 占位、`.env.sample`——零误拦,exit 0。
- 顺带发现的既有防线:本机全局 gitignore 已含 `.env``git add -A` 根本加不进暂存区,NAME-DENY 是其后的第二道。
## 5. 三仓提交与 CI 状态
| 仓库 | 分支 | 提交 | 内容 | CI |
| --- | --- | --- | --- | --- |
| patbond-api | dev | `8330885` | 脚本 + hook + ci.yml 加 Secret scan step | 见下 |
| patbond-flutter | dev | `66f983d` | 同上(同构副本) | 见下 |
| patbond-doc | main | `8e1fe2f` | 脚本 + hook + ci.yml step + git-workflow.md「凭证防泄漏检查」节 | 见下 |
CI 状态(Gitea commit status API 逐仓核实,2026-09-08):三仓全部 **success**——api `CI / backend-test (push)`16:35:08 完成)、flutter `CI / flutter-gates (push)`16:37:29)、doc `CI / docs-build (push)`16:38:14)。新增 Secret scan step 未破坏任何既有流水线。
patbond-doc 本地 `mkdocs build --strict` 通过后才提交;他人未提交内容(backend-modules.md 改动、09/10/11 号报告)未混入本次提交。启用说明见 patbond-doc `docs/development/git-workflow.md`「凭证防泄漏检查(ADR-021)」节,命令示例已按参数化路径规范书写(`cd <你的工作区>/<仓名>`)。
## 6. 遗留与提醒
- **T3-03 解锁条件已满足后**引入 MinIO 凭证时:AK/SK 只进被 gitignore 的 `.env`compose `${}` 注入),`.sample` 用占位值——直写实值会被本规则集拦下。
- 两位开发者各自需在三仓执行一次 `git config core.hooksPath scripts/hooks`(CI 兜底不依赖此步,但本地拦截更早更省事)。
- 规则表若增补(如 M4 引入新云厂商),三仓 `scripts/check-secrets.sh` 必须同步修改、同波提交。
@@ -0,0 +1,136 @@
# 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 迁云条件。
@@ -0,0 +1,30 @@
# 14 M3 第一波收口:地基、媒体闭环与防泄漏
**执行日期**:2026-09-08
**交付**:V5 迁移 + community 骨架、MinIO 媒体最小闭环、埋点队列加固、契约草案、凭证防泄漏三仓、auth 契约测试补齐
---
## 0. 概要
| 工单 | 交付 | 提交 | 测试 |
|------|------|------|------|
| T3-01 V5 迁移 | community 8 表 + pg_trgm,剪 2 条跨 schema FKM4/M5 补回) | api dev@a97814a | 191→199 |
| T3-02 community 骨架 | :8084 五容器、骨架期即接 RS256 校验 | api dev@3c671fc | →206 |
| T3-03 media 闭环 | MinIO 适配层 + 两步上传 + 私有桶签名读(ADR-016/017 | api dev@10a43f8 | →218 |
| T3-19 auth 契约测试 | 6 操作 19 单元格全矩阵,抓修 1 真实漂移(reason NON_NULL | api dev@263cd88 | →226 |
| T3-19 队列三项 | 30s 定时冲刷 + 指数退避 + anonymousId 持久化 | flutter dev@4d40c38 | 272→286 |
| T3-10 起草态 | community/media 契约草案 13 路径/19 操作 + 4 待定型点 | 草案在 iteration-3/ | — |
| ADR-021 防泄漏 | 9 规则两层检查三仓落地,零误报 + 拦截自测全命中 | api@8330885 flutter@66f983d doc@8e1fe2f | CI 全绿 |
**波末状态**:patbond-api 226 测试 / patbond-flutter 286 测试全绿;compose 六容器(postgres+minio+auth+user+pet+community)实测健康;三仓 CI 绿。
## 1. 契约冻结输入已定型(T3-03 部分)
媒体凭据形态:创建上传返回 `{assetId, uploadUrl(预签名 PUT), method, requiredHeaders, expiresAt(10min)}`;读取一律私有桶预签名 GET1h TTL);purpose=post_image、mime 白名单 jpeg/png/webp、单文件 10 MiB。与草案偏差 7 项见 13 号报告 §4。剩余待定型:Feed 卡片字段与公开资料形态(T3-05)、权限/错误语义(T3-04)。
## 2. 遗留与下波
- uploading 超时清理:方案已记录(13 号报告),定时任务另排。
- 401 去 Authorization 重试、429 Retry-After 精细分支:待后端限流(10 号报告记录)。
- **第二波**:T3-04 帖子生命周期 → T3-05 Feed → T3-06/07 评论互动 → 契约冻结闸门;D3-9 方案 B 的 /internal 批量公开资料接口随 T3-05 落地。
File diff suppressed because it is too large Load Diff
+6
View File
@@ -72,6 +72,12 @@ nav:
- 06 埋点规划: development/iterations/iteration-3/06-experiment-tracking-plan.md - 06 埋点规划: development/iterations/iteration-3/06-experiment-tracking-plan.md
- 07 证据基线审计: development/iterations/iteration-3/07-evidence-baseline-audit.md - 07 证据基线审计: development/iterations/iteration-3/07-evidence-baseline-audit.md
- 08 Git 工作流规划: development/iterations/iteration-3/08-git-workflow-plan.md - 08 Git 工作流规划: development/iterations/iteration-3/08-git-workflow-plan.md
- 09 社区地基报告: development/iterations/iteration-3/09-community-foundation-report.md
- 10 埋点队列加固: development/iterations/iteration-3/10-analytics-queue-hardening.md
- 11 社区契约起草: development/iterations/iteration-3/11-community-contract-draft.md
- 12 防泄漏检查落地: development/iterations/iteration-3/12-secret-scan-rollout.md
- 13 media MinIO 闭环: development/iterations/iteration-3/13-media-minio-report.md
- 14 第一波收口: development/iterations/iteration-3/14-wave1-closure.md
- API: - API:
- 契约说明: api/index.md - 契约说明: api/index.md
- 架构: - 架构: