Files
patbond-doc/docs/development/iterations/iteration-4/02-backend-technical-assessment.md
T
lixi 79b33dba31 docs: M4「AI 创作」开工分析八份报告 + 汇总拍板页入档挂导航
八角色并行开工分析,合计 7448 行;另出 00 汇总页(跨角色收敛结论、
13 项待拍板、6 项待仲裁分歧、未取证项汇总),挂第四迭代导航最前。
mkdocs build --strict 通过。

基线实测修正(文档与实况不符):
- api 测试 381(releases.md 记 379,成因待仲裁)
- 埋点白名单 41(四份文档记 42,experiment_exposed 重复计数)
- 真机验证挂起 10 项(转述链 4→6→8→10 每跳丢项)
- E2E 断言机械可数 226(声称 234 无可复核来源)
- v0.4.0 实际发布 09-14 11:17;CI 非红,三仓五上下文全绿

多方独立收敛(无需拍板):
- 队列用 Postgres SKIP LOCKED + 租约列,不引入 Redis/MQ
- 服务端零对象写能力(ObjectStorage 无 put/get),M4 立足点缺地基
- 「四模块字节级快照锁 CI」不存在,实际门禁仅结构断言
- 定稿模型 input_asset_id NOT NULL,即图生图不支持文生图
- 跨 schema 外键补回是 V5 自身指令,裁剪理由已不成立

阻塞项与安全缺口:
- AI provider BLOCKED:零 SDK/endpoint/额度,正典种子即 fixture
- 分支保护必需上下文选错触发器:(push) 限定 branches:[dev],
  致「推 dev 即满足门禁」且「非 dev 分支 PR 永久无法合并」
- check-secrets.sh 对 sk-/sk-ant- 零覆盖,须先于任何 AI key 落地
- 北极星 09-21 窗口已于 09-13 关闭,补救无从下手,建议改事件驱动

本批核心教训:13 处文档/注释与代码相反且多已被下游采信,其中
5 处造成实际规模误判(widthPx M→S、数据模型早已定稿 L→M、
社区侧 purpose 校验实际不存在等)。汇总页 §0 立转述纪律。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:48:56 +08:00

1115 lines
91 KiB
Markdown
Raw 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.
# 02 M4 后端技术评估:AI 创作(模型目录 / 生成任务 / Worker 队列 / 输出落地)
> 作者:Senior Developer(后端)
> 日期:2026-09-14
> 输入:patbond-api dev@3cd8005**381** 测试基线,工作区干净,tag v0.4.0);契约 openapi.yaml v1.4.032 路径 / 45 操作 / 75 schema);Flyway V1~V5ADR-001~022
> 纪律:本报告全部结论以**原始 Flyway SQL / 原始 openapi.yaml / 原始 Java 源码**取证,逐项标注文件与行号;不采信任何文档转述
---
## 结论先行
1. **队列方案 = DB 表轮询 + `FOR UPDATE SKIP LOCKED` + 租约列**,不引入 Redis、不引入 MQ。决定性证据:本项目 compose **无 Redis、无任何 MQ**`patbond-api/docker-compose.yml` 仅 postgres/minio/auth/user/pet/community 六服务),而**已评审目标模型早已为 DB 队列备好全部列与两条部分索引**——`lease_owner` / `lease_expires_at` / `next_attempt_at` / `attempt_count` / `max_attempts` / `priority`,以及 `ix_generation_jobs_queue``ix_generation_jobs_running``patbond-doc/docs/database/patbond_postgresql.sql:605-716`)。换 Redis/MQ 等于让这批列变成死重,并把任务状态真值劈成两份。
2. **需要新模块 `patbond-ai`:8085**,沿 ADR-009 / ADR-017 两次先例(新模块 + 共库 + patbond-user 单迁移链)。但新模块会把已存在的 3 份复制代码变成 4 份(`BearerAuthFilter` 自述「第三份拷贝」),故建议**同波次把 4 件共享物上提 patbond-common**。
3. **Flyway 迁移 2 个**`V6__creation_baseline.sql`(建 creation schema 三表 + 索引 + 触发器 + **补回 `posts.generation_job_id` 外键**)、`V7__creation_catalog_seed.sql`(模型/风格目录种子)。沿 V3(结构)/ V4(种子)拆分先例。
4. **契约影响面:纯增量**v1.4.0 → **v1.5.0**。新增 4 路径 / 5 操作 / 8 schema;改动 3 个既有 schema2 处 category 枚举加宽 + `Post``generationJobId`);新增 1 个错误码 42900 + 1 个 `components.responses`。**零破坏性变更**。
5. **配额与 429 建议在本迭代落地**(收窄版,非 M6 的横切限流):M4 验收标准写明「相同幂等请求不重复**扣费**」,而全项目**无任何计费/额度表**(目标模型 40 张表无 credits/wallet/balance),「扣费」只能落到配额语义上,因此配额是验收前置,不是可选项。
6. **上游 AI 服务走可插拔 `GenerationProvider` 接口 + 真实字节 stub 实现**,M4 默认 stub,真实厂商适配器为纯增加实现类。stub 必须做成**两阶段(submit → poll**,否则「Worker 重启不丢任务 / 不重复扣费」这条最关键的恢复路径无法被端到端验证。
---
## 章节
1. [取证:现有可复用资产逐项清点](#1)
2. [关键发现:目标模型已把队列机制写完了](#2)
3. [Worker 队列选型:租约 / 重试 / 幂等](#3)
4. [生成任务状态机与三层幂等](#4)
5. [模型 / 风格目录(catalog)方案](#5)
6. [生成输出落地媒体链路 + 一键社区草稿](#6)
7. [上游 AI 服务接入与本迭代可验证性](#7)
8. [限流与配额(429 + Retry-After](#8)
9. [模块归属:patbond-ai 与共享物上提](#9)
10. [数据模型草案与 DDL 草稿](#10)
11. [Flyway 迁移计划](#11)
12. [API 端点清单与契约影响面](#12)
13. [契约同步机械清单(含第 5 份快照)](#13)
14. [风险清单](#14)
15. [待拍板决策](#15)
16. [本报告推翻 / 修正的既有结论](#16)
---
<a id="1"></a>
## 1. 取证:现有可复用资产逐项清点
以下每一行都打开过原始文件确认,**没有一条来自文档转述**。路径根为 `patbond-api/`(除标注 doc 仓者)。
### 1.1 数据库与迁移链
| 资产 | 取证位置 | 实情 |
| --- | --- | --- |
| 迁移链持有者 | `patbond-user/src/main/resources/application.yml:11-12``flyway.locations: classpath:db/migration`);auth/pet/community 三模块 `src/main/resources/` 下**只有 application.yml.sample,无 db/migration 目录** | 迁移只进 patbond-user**V6/V7 无选择余地**,即便新建 patbond-ai 模块也一样 |
| 现有迁移 | `patbond-user/src/main/resources/db/migration/` 共 5 个:V1 identity+media+platform、V2 platform.product_events、V3 pet_health、V4 字典种子、V5 community | **下一个版本号 = V6**(ADR-022 明确「V6 留给后续真正需要建表的迭代」,`decisions.md:192` |
| 结构/种子拆分先例 | V3 = 纯结构;V4 = 纯种子(`V4__pet_health_dictionary_seed.sql:1-13` 注释自述「production reference data, not development fixtures」,2 条 INSERT | M4 照此拆 V6(结构)+ V7(目录种子) |
| `posts.generation_job_id` | `V5__community_baseline.sql:47-48``-- generation_job_id: bare nullable uuid, FK to creation.generation_jobs stripped (M4 补回)` / `generation_job_id uuid,`**零 `REFERENCES`**;索引已建于 `:95``ix_posts_generation_job`) | 裸列 + 索引俱在,V6 只需 `ADD CONSTRAINT`,**不需要加列、不需要建索引** |
| 裁剪理由是否仍成立 | `V5:15-19` 原文:「Cross-schema FKs into schemas **not yet migrated** are STRIPPED」;`V3__pet_health_baseline.sql:14-19` 同一措辞裁掉 4 条 marketplace 外键 | 裁剪理由是「目标 schema 尚不存在」,**不是「反对跨 schema 外键」**。反证:`V1:262-264` 保留 `identity.users.avatar_asset_id → media.assets``V5:45-46,107` 保留 `posts → identity.users / pet_health.pets``post_media → media.assets`。**V6 一旦建出 creation schema,裁剪理由即消失,补回外键是 V5 自己下的指令** |
| 存量数据风险 | 全仓 grep `generation_job_id` 命中仅 3 处:V5 SQL、`CommunityMigrationIntegrationTest`、以及**无任何 INSERT/UPDATE 写它**`PostRepository.insertPost` 列清单为 id/author_user_id/pet_id/category/title/content/status/published_at/idempotency_key/request_hash,见 `PostRepository.java:56-78` | 存量值恒为 NULL`ADD CONSTRAINT` **无需 `NOT VALID`**,零回填风险 |
| `region_id` | `V5:54-55` 同为裸列(M5 补回) | **本迭代不碰**V6 不得顺手补它 |
### 1.2 目标模型(doc 仓,已评审)
| 资产 | 取证位置 | 实情 |
| --- | --- | --- |
| creation schema 全量 DDL | `patbond-doc/docs/database/patbond_postgresql.sql:556-716` | 三表 `generation_models` / `generation_styles` / `generation_jobs` + 13 条索引已完整写好,**M4 是抄写不是设计**(详见 §2) |
| schema 注释 | 同上 `:68` `COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation';` | V6 照抄 |
| 触发器 | 同上 `:1245-1250` 三张表各一条 `platform.set_updated_at()` 触发器 | 函数 V1 已建(`V1:28-36`),V6 直接挂 |
| 全库规模 | 同上:7 schema / 40 张 `CREATE TABLE` | 建完 V6 后为 6 个 schema(缺 marketplace,属 M5 |
| **无计费表** | 同上全文件 `CREATE TABLE` 清单无 credits / wallet / balance / ledger 任何一张 | 验收标准里的「不重复**扣费**」只能解释为**配额**语义(见 §8) |
### 1.3 媒体链路(M3 交付,ADR-016/017
| 资产 | 取证位置 | 实情 |
| --- | --- | --- |
| `media.assets` 表 | `V1:205-260` | 有 `purpose varchar(32) NOT NULL`**全表无 `purpose` 的 CHECK 约束**`:225-248` 六条 ck 分别管 kind/storage_type/location/size/hash/dimensions/status/ready/deleted |
| purpose 白名单 | `patbond-user/.../media/MediaProperties.java:66` `List.of("post_image", "user_avatar", "pet_avatar")`Javadoc `:57-65` 自述「新增用途是配置 + 契约枚举变更,never a migration」 | **新增 `ai_input` / `ai_output` 零迁移**,改配置 + 契约枚举即可 |
| 两步上传 | `MediaService.java:51-119`createUpload / completeUpload | 客户端直传,**服务端从不经手字节** |
| 存储适配层 | `ObjectStorage.java`(接口)+ `S3ObjectStorage.java`AWS SDK v2 实现) | 接口仅 4 个方法:`ensureBucket` / `presignPut` / `stat` / `presignGet`。**没有服务端 put,也没有服务端 get** ← M4 必须补的两个原语 |
| 配置三份复制 | `patbond-user/.../MediaProperties.java:15``patbond-pet/.../PetMediaProperties.java:16``patbond-community/.../CommunityMediaProperties.java:15`**三个类同一前缀 `patbond.media`** | pet/community 只持读侧子集(无 bucket、无 uploadTtl |
| 预签名 GET 三份复制 | `patbond-community/.../media/MediaUrlSigner.java:43-53``patbond-pet/.../media/MediaUrlSigner.java:43-53`、user 侧走 `S3ObjectStorage.presignGet` | 两个 `MediaUrlSigner` 的 Javadoc 自述与对方「same as」——**已知复制债,新模块会变第 4 份** |
| 凭证下发先例 | `docker-compose.yml`pet`:106-109`)与 community`:139-141`**都拿到了 `PATBOND_MINIO_ACCESS_KEY/SECRET_KEY`**,注释说明「本地 SigV4 计算,不直连 MinIO,无需 depends_on minio」 | 业务模块持有对象存储凭证是**既有实践**,patbond-ai 照此拿凭证不破规矩 |
| 跨 schema 读媒体先例 | `patbond-community/.../media/MediaAssetGateway.java:37-47` 直接 SQL 读 `media.assets`Javadoc `:15-23` 自述「Same-database read was chosen over an internal HTTP call to patbond-user (ADR-017 precedent)」 | patbond-ai 跨 schema 读 `media.assets` 有先例;**写** 侧无先例(见 §6 与 D4-4) |
### 1.4 社区侧(M3 交付)
| 资产 | 取证位置 | 实情 |
| --- | --- | --- |
| 建帖幂等 | `PostController.java:47-54``Idempotency-Key` **强制头**)→ `PostService.create:83-126``PostRepository.insertPost:56-78``ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING` | 与 `generation_jobs``idempotency_key NOT NULL` + `request_hash NOT NULL` + `UNIQUE(user_id, idempotency_key)` **形状完全一致**,可原样复用 |
| request_hash 算法 | `PostService.canonicalize:361-375`(规范化命令而非原始 JSON+ `RequestHashes.sha256:22-29` | 32 字节,匹配 `ck_generation_jobs_idempotency``octet_length(request_hash)=32` |
| 幂等冲突语义 | `PostService.java:104-118`:同键同载荷 → 返回原资源;同键异载荷 → `IDEMPOTENCY_PAYLOAD_MISMATCH` **409/40905** | M4 直接复用 40905,不新增码 |
| 草稿/发布 | `CreatePostRequest.java:29` status 枚举 `draft|published`,缺省 draft`PostService.java:89`);发布走 `PATCH` + `status=published``UpdatePostRequest.java:38` | **「一键建草稿」不需要新端点**`POST /api/v1/posts` 已具备 |
| `ai_creation` 可达性 | DB 侧 `V5:68` `ck_posts_category CHECK (category IN ('general','help','ai_creation'))` **已允许**API 侧被 `CreatePostRequest.java:26``UpdatePostRequest.java:33``@Pattern(regexp = "general|help")` 拦死;有测试固化该拦截(`PostLifecycleIntegrationTest.java:102-106` 断言 400/40000 | 解锁 = 改 2 处 `@Pattern` + 改 1 处测试 + 契约枚举加宽,**零迁移** |
| 媒体挂帖校验 | `PostService.validateAssets:343-358` 只校验 **归属**(非本人 → 40405 防枚举)与 **ready**(否则 422/42203 | ⚠️ **无 purpose 校验**`MediaAssetRef.java:9-16``MediaAssetGateway` 的 SQL 都**不含 purpose 列**。对比 pet 侧确有校验(`PetService.java:196` `AVATAR_PURPOSE.equals(asset.purpose())`)。这修正了任务书「走 assets / purpose 白名单」的前提——**社区侧目前没有 purpose 闸门**(见 §6 与 D4-6 |
| 乐观锁 | `PostService.java:141-143` 比 version + `PostRepository.updatePost:134-152` `WHERE ... AND version = :expectedVersion`0 行 → 409/40902 | 生成任务**不需要** version 乐观锁(客户端不改任务字段),但表里有 `version` 列备用 |
### 1.5 平台能力
| 资产 | 取证位置 | 实情 |
| --- | --- | --- |
| 错误码体系 | `patbond-common/.../error/ErrorCode.java:12-38`(27 个码);命名法 = HTTP 三位 + 两位序号 | 已用满:404 段至 40406、409 段至 40905、422 段至 42205。**429 段完全空白** |
| 定时任务先例 | `patbond-user/.../session/SessionCleanupJob.java:32-41``@Scheduled(fixedDelayString=..., initialDelayString=...)`,间隔走配置项) | Worker 轮询与 reaper 照此形态;`@EnableScheduling``UserApplication.java` |
| 全仓限流 | grep `429|TOO_MANY_REQUESTS|Retry-After|RateLimit` over `patbond-*/src/main`**零命中**;契约同 grep → **零命中** | 429 是**全新地基**,非改造 |
| 服务间调用 | Feign 静态 URLADR-002):`SessionClient.java:16``UserClient.java:12``AuthorProfileClient.java:18` | patbond-ai 若需内部调用照此形态 |
| UUIDv7 | `patbond-pet/.../support/UuidV7.java``patbond-community/.../support/UuidV7.java` **两份复制** | 新模块会变第 3 份 |
| 鉴权 | `BearerAuthFilter`community 版 Javadoc `:26` 自述「Third copy of the user/pet filter」)→ 请求属性 `patbond.authenticatedUserId` → 控制器 `@RequestAttribute` | 新模块会变**第 4 份** |
| 埋点白名单 | `patbond-user/.../analytics/EventDictionary.java` **41 条**(实测 `Map.entry(` 计数;无测试断言该数量);grep `generation|creation|ai_`**零命中** | M4 新增创作域事件 = 改这一个 Java 常量表,零迁移、零契约变更(`/api/v1/events` 形状不变) |
| 测试基线 | **381**(采用协调方 Evidence Collector 实测值;我未重跑 `mvnw test`,按任务书授权跳过耗时命令) | 增量估算以 381 为基数 |
---
<a id="2"></a>
## 2. 关键发现:目标模型已把队列机制写完了
这是本次评估**最重要的一条**,它把 §3 的选型从「开放式技术选择」压缩成「确认既有设计」。
`patbond-doc/docs/database/patbond_postgresql.sql:605-716``creation.generation_jobs` 里,下列列**只有 DB 队列这一种用途**:
| 列(行号) | 用途 |
| --- | --- |
| `next_attempt_at timestamptz NOT NULL DEFAULT now()``:635` | 退避重试的可见时间 |
| `lease_owner varchar(128)``:636`) | 租约持有者(worker 实例标识) |
| `lease_expires_at timestamptz``:637`) | 租约到期时间 → 崩溃恢复 |
| `attempt_count` / `max_attempts smallint NOT NULL DEFAULT 3``:628-629` | 重试计数与上限 |
| `priority smallint NOT NULL DEFAULT 0``:627` | 出队优先级 |
| `provider_request_id varchar(128)``:630`) | 上游请求 id(防重复提交/重复扣费) |
配套两条**部分索引**,逐列对齐两条出队/回收语句:
```sql
-- :711-713 出队:正是 (priority DESC, next_attempt_at, created_at, id) 的排序键
CREATE INDEX ix_generation_jobs_queue
ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id)
WHERE status = 'queued';
-- :714-716 回收:正是「租约已过期的 running」的扫描键
CREATE INDEX ix_generation_jobs_running
ON creation.generation_jobs (lease_expires_at, id)
WHERE status = 'running';
-- :699-701 上游幂等:同一 provider 的同一 request_id 只允许一行
CREATE UNIQUE INDEX uq_generation_jobs_provider_request
ON creation.generation_jobs (provider_code_snapshot, provider_request_id)
WHERE provider_request_id IS NOT NULL;
```
并且 `ck_generation_jobs_state``:667-691`)与 `ck_generation_jobs_lease``:692-695`)**把状态机与租约的合法组合写进了 DB 约束**——五个状态各自规定了 `started_at` / `completed_at` / `output_asset_id` / `error_code` / `lease_owner` / `lease_expires_at` / `progress` 的取值,`(lease_owner IS NULL) = (lease_expires_at IS NULL)``lease_expires_at > started_at`
**推论**:目标模型评审时事实上已经拍过「DB 表即队列」。选 Redis 或 MQ 不是「加一个组件」,而是**推翻已评审的数据模型**(这批列与三条索引全部作废,任务状态真值劈成 DB + 中间件两份)。
### 2.1 由约束反推出的三条实现约定(容易踩,必须写进工单)
1. **`started_at` 是「本次尝试的开始时间」,不是「首次入队时间」**。因为 `:669` 规定 `status='queued'``started_at IS NULL`,所以每次重试重新入队都必须把 `started_at` 置回 NULL。若产品要展示「任务创建至今耗时」,用 `created_at`
2. **重新入队必须同时清 5 个字段**`progress=0``started_at=NULL``error_code=NULL``error_message=NULL``lease_owner=NULL``lease_expires_at=NULL`,否则违反 `:668-672`
3. **出队谓词必须带 `attempt_count < max_attempts`**`:664-666` 规定 `attempt_count <= max_attempts`,而认领动作会 `attempt_count+1`;一个 `attempt_count = max_attempts` 的行若还留在 `queued`,认领即违约。正确做法是失败时直接判终态,但出队谓词加这一条作为防御。
---
<a id="3"></a>
## 3. Worker 队列选型:租约 / 重试 / 幂等
### 3.1 部署现实(自行核实,非转述)
`patbond-api/docker-compose.yml` 全文件 `image:` / 服务名清点:`postgres`postgres:18`:12-20`)、`minio``:33-47`)、`auth``user``pet``community`。**无 redis、无 rabbitmq、无 kafka、无任何 MQ**。volumes 仅 `pgdata` / `minio-data``:154-156`)。
即:**Redis 与 MQ 都是「新增容器 + 新增运维面」**,不是「用上已有的东西」。
### 3.2 三方案对比
| 维度 | **A. DB 表轮询 + SKIP LOCKED + 租约列**(推荐) | B. RedisStreams 消费组) | C. MQRabbitMQ / Kafka |
| --- | --- | --- | --- |
| 新增容器 | **0** | +1 | +1~3Kafka 还需协调进程) |
| 与已评审模型契合 | **完全契合**,用满 §2 全部列与 3 条索引 | 那批列作废 | 那批列作废 |
| 任务状态真值 | **单一真值(Postgres** | 双写:Redis 队列 + PG 状态行 → 需处理不一致 | 同 B |
| 租约 | `lease_owner/lease_expires_at` + reaper,语义显式可查 | XCLAIM/XAUTOCLAIM,语义隐含在中间件内 | 需 consumer ack + 可见性超时 |
| 重试 | `attempt_count` + `next_attempt_at` 退避,**可用 SQL 直接审计** | 需自建重试流或 DLQ | 需 DLQ + 延迟队列插件 |
| 幂等 | 表内 `UNIQUE(user_id, idempotency_key)` + `uq_generation_jobs_provider_request`,**DB 强约束** | 需应用层保证 | 需应用层保证 |
| 事务性 | **与任务行同事务**,提交即入队,无 dual-write | 有 dual-write 窗口 | 有 dual-write 窗口(除非上 outbox |
| 崩溃不丢任务 | 任务本来就在 PG 里,**天然不丢** | 需持久化配置 + AOF 正确性 | 需持久化配置 |
| 出队延迟 | 轮询间隔(建议 1s);必要时 `LISTEN/NOTIFY` 可降到毫秒 | 毫秒 | 毫秒 |
| 吞吐上限 | 单表每秒数百任务量级,**远超 MVP 需求** | 高 | 很高 |
| 运维成本(双人团队) | **最低** | 中 | 高 |
| ADR 一致性 | 契合 ADR-002「MVP 不引入解决『规模问题』的组件」、ADR-007「应用无状态、状态在 DB」 | 与 ADR-002 精神冲突 | 冲突更强;且 `development-plan.md:271` 已把「事务 Outbox 发布器」排到 **M6** |
**推荐 A**。三条独立理由:目标模型已为 A 备好一切(§2);compose 无现成 Redis/MQB/C 都是净新增运维面;ADR-002 的既有判例(为同样理由移除 Nacos)与 development-plan 把 Outbox/限流排到 M6 的排期,都指向「M4 不引入消息中间件」。
**A 的已知代价(诚实列出)**:出队有轮询延迟;worker 数量扩大到几十个时轮询会成为无谓负载。两者在 MVP 量级都不成立,且都有不改数据模型的升级路径(`LISTEN/NOTIFY` 降延迟;分片或改中间件时任务表仍是状态真值)。
### 3.3 租约:单语句原子认领
```sql
-- 认领:SKIP LOCKED 让 N 个 worker 拿到互不相交的集合,且互不阻塞
UPDATE creation.generation_jobs j
SET status = 'running',
started_at = now(),
lease_owner = :owner, -- 如 "ai-1/pid-17/uuid"
lease_expires_at = now() + :leaseTtl, -- 建议 120s
attempt_count = attempt_count + 1,
progress = 0,
updated_at = now()
WHERE j.id IN (
SELECT id FROM creation.generation_jobs
WHERE status = 'queued'
AND next_attempt_at <= now()
AND attempt_count < max_attempts -- §2.1 第 3 条
ORDER BY priority DESC, next_attempt_at, created_at, id -- 对齐 ix_generation_jobs_queue
FOR UPDATE SKIP LOCKED
LIMIT :batch -- 建议 1~2
)
RETURNING j.id, j.media_kind, j.model_id, j.style_id, j.input_asset_id,
j.prompt, j.negative_prompt, j.width_px, j.height_px, j.parameters,
j.provider_code_snapshot, j.provider_model_snapshot,
j.provider_request_id, j.attempt_count, j.user_id;
```
合法性核对:`status='running'` 分支(`:673-677`)要求 `started_at NOT NULL``completed_at NULL``output_asset_id NULL``error_code NULL``lease_owner NOT NULL``lease_expires_at NOT NULL` —— 全部满足;`ck_generation_jobs_lease` 要求 `lease_expires_at > started_at`,而 `now()` 在同一事务内为同一时刻、`leaseTtl > 0`,严格成立。
**心跳续租**(长任务防误回收),每 30s 一次:
```sql
UPDATE creation.generation_jobs
SET lease_expires_at = now() + :leaseTtl, progress = :progress, updated_at = now()
WHERE id = :id AND status = 'running' AND lease_owner = :owner;
```
返回 0 行 = 租约已被夺走或任务已被取消 → **worker 立即放弃本次执行**(这也是取消能生效的机制,见 §4.3)。
### 3.4 重试:退避 + 回收
```sql
-- (a) 可重试失败 → 重新入队(§2.1 第 2 条:必须清 5 个字段)
UPDATE creation.generation_jobs
SET status = 'queued', progress = 0, started_at = NULL,
error_code = NULL, error_message = NULL,
lease_owner = NULL, lease_expires_at = NULL,
next_attempt_at = now() + :backoff, -- 10s * 2^(attempt-1) + 抖动
updated_at = now()
WHERE id = :id AND status = 'running' AND lease_owner = :owner
AND attempt_count < max_attempts;
-- (b) 不可重试 或 次数耗尽 → 终态
UPDATE creation.generation_jobs
SET status = 'failed', completed_at = now(),
error_code = :errorCode, error_message = :errorMessage,
lease_owner = NULL, lease_expires_at = NULL, updated_at = now()
WHERE id = :id AND status = 'running' AND lease_owner = :owner;
-- (c) reaper:租约过期的孤儿(worker 崩溃/重启),走 ix_generation_jobs_running
UPDATE creation.generation_jobs
SET status = CASE WHEN attempt_count < max_attempts THEN 'queued' ELSE 'failed' END,
progress = 0,
started_at = CASE WHEN attempt_count < max_attempts THEN NULL ELSE started_at END,
completed_at = CASE WHEN attempt_count < max_attempts THEN NULL ELSE now() END,
error_code = CASE WHEN attempt_count < max_attempts THEN NULL ELSE 'lease_expired' END,
lease_owner = NULL, lease_expires_at = NULL,
next_attempt_at = now() + :backoff, updated_at = now()
WHERE status = 'running' AND lease_expires_at < now();
```
(c) 就是验收标准「**Worker 重启不丢任务**」的实现:任务一直在 PG 里,重启后租约到期即被回收重投。reaper 建议 30s 一跑,与 `SessionCleanupJob.java:32-34` 同形态(`@Scheduled(fixedDelayString="${...}")`,间隔走配置)。
### 3.5 Worker 跑在哪
| 选项 | 说明 | 评价 |
| --- | --- | --- |
| **A1(推荐)** | 与 API 同进程,`@Scheduled` 轮询 + 有界线程池,由 `patbond.creation.worker.enabled` 开关控制 | 零新增容器;先例 `SessionCleanupJob`。**关键**:开关从第一天就做,则 A2 变成纯部署动作、零代码改动 |
| A2 | 同一 jar 起第 7 个容器,`worker.enabled=true` 且不暴露端口 | 隔离更好,M4 不必做;有开关后随时可切 |
| A3 | 独立 worker 模块/镜像 | 过度设计,否决 |
推荐 **A1 + 开关**:单容器交付,但把 A2 的门留着。租约机制本来就允许多实例并存,A1→A2 无需任何逻辑变更。
---
<a id="4"></a>
## 4. 生成任务状态机与三层幂等
### 4.1 状态机(DB 约束即真值,`patbond_postgresql.sql:660-691`
```
┌──────────────────────────────────┐
│ 可重试失败 / 租约过期 (§3.4 a,c) │
▼ │
[提交] ──► queued ──(认领 §3.3)──► running ──────────────┘
│ │
│ 取消 ├──► succeeded (progress=100, output_asset_id NOT NULL)
▼ ├──► failed (error_code NOT NULL, 不可重试或次数耗尽)
cancelled ◄──────────────────┘ 取消(见 §4.3)
```
合法迁移共 7 条:`queued→running``running→succeeded``running→failed``running→queued`(重试)、`queued→cancelled``running→cancelled`、以及 reaper 的 `running→failed`。**终态 3 个不可再迁移**succeeded / failed / cancelled)。
「任务状态流转合法」这条验收标准由**两道闸门**保证:应用层的条件 UPDATE(`WHERE status=... AND lease_owner=...`),以及 DB 的 `ck_generation_jobs_state`。后者意味着即便应用写错,数据库也会拒绝——建议专门写一组「非法迁移被 DB 拒绝」的集成测试直接断言约束生效。
### 4.2 三层幂等(对应验收标准「相同幂等请求不重复扣费或生成」)
| 层 | 机制 | 取证 / 位置 | 防住什么 |
| --- | --- | --- | --- |
| **L1 提交层** | `Idempotency-Key` 强制头 + `UNIQUE(user_id, idempotency_key)` + `request_hash` 比对 | 表约束 `patbond_postgresql.sql:643,655-659`;算法复用 `RequestHashes.sha256` + 规范化命令(`PostService.canonicalize:361-375` 同形态) | 客户端重试/双击 → 同键同载荷返回**同一 job**;同键异载荷 → 409/40905 |
| **L2 上游层** | 提交给厂商后**立即持久化 `provider_request_id`**`uq_generation_jobs_provider_request` 唯一索引 | `patbond_postgresql.sql:630,699-701` | worker 在「已提交上游、尚未收到结果」时崩溃 → 重新认领后发现 `provider_request_id != NULL`,**改为继续轮询而不是重新提交**,这是「不重复扣费」的真正防线 |
| **L3 配额层** | 按窗口计数 + 429(见 §8) | 新建 | 防单用户刷量 |
⚠️ **L2 是全套设计里最容易被漏掉、又最贵的一环**。它要求 worker 的执行流程必须是「submit → 持久化 request_id → poll」三步,中间那步单独提交事务;如果 stub provider 做成同步的一步返回,这条路径**永远不会被测试覆盖**,等接真实厂商时才暴露 → 直接导致重复扣费。故 §7 要求 stub 也做两阶段。
### 4.3 取消语义
`ck_generation_jobs_state` 的 cancelled 分支(`:687-690`)只要求 `completed_at NOT NULL` 且租约字段为空,**对 `progress` / `started_at` / `output_asset_id` 不作限制** —— 也就是说目标模型允许从 queued 和 running 两处取消。
| 选项 | 行为 | 评价 |
| --- | --- | --- |
| **X1(推荐)** | queued 与 running 都可取消。API 直接 `UPDATE ... SET status='cancelled', completed_at=now(), lease_owner=NULL, lease_expires_at=NULL WHERE id=:id AND user_id=:me AND status IN ('queued','running')`。worker 的心跳/收尾条件 UPDATE 因 `status='running'` 不再成立而返回 0 行,**自行放弃并丢弃产物**(§3.3 末) | 无需新列、无需偏离目标模型;UX 好(stub 约 6s,多数取消会落在 running)。代价:running 取消会留下已生成但被丢弃的对象/资产行,需按「孤儿清理」处理 |
| X2 | 仅 queued 可取消,running → 422 | 实现更简单,但 UX 差(大部分点击会被拒),且没省下什么 |
| X3 | 加 `cancel_requested boolean` 列 | 偏离已评审目标模型,且 X1 已能达成同样效果,否决 |
推荐 **X1**。孤儿处理:worker 发现收尾 UPDATE 返回 0 行时,把刚写的 `media.assets` 行置 `deleted``status='deleted', deleted_at=now()`,满足 `ck_media_deleted``V1:248`)并尽力删除对象。注意这类孤儿本来就是**已存在的已知缺口**(`V1:258-260``ix_media_uploading_created` 就是为「从未完成的 uploading 行清理」预留的,`MediaService.java:30-32` 注释自述该清理「M3 未实现」),M4 不必顺手把整套清理做完,但不应新增无标记的孤儿。
### 4.4 「失败重试」怎么给客户端
`development-plan.md:253` 要求「Flutter 展示排队、生成进度、失败重试和取消状态」。两种解释:
- **R1(推荐)**:用户点「重试」= 客户端**用新的 `Idempotency-Key` 提交一个新任务**。零新增端点;审计链干净(每次尝试一行);与 L1 幂等不冲突。
- R2:加 `POST /jobs/{id}/retry` 把 failed 复活成 queued。需要把 `error_code` 清空、`completed_at` 清空、`attempt_count` 归零,等于**擦掉失败证据**,与验收标准「失败原因可追踪」相抵;且 `attempt_count <= max_attempts` 约束下语义混乱。
推荐 **R1**,故 §12 的端点清单**不含 retry 端点**。worker 内部重试(§3.4)与用户可见重试是两件事,不要混为一个端点。
### 4.5 进度上报
`progress smallint` 0~100`:626``ck_generation_jobs_progress` `:663`)。客户端**轮询** `GET /api/v1/creation/jobs/{jobId}`,建议 1.5s 间隔(写进契约 description,不做服务端推送)。不引入 SSE/WebSocket:需要新的连接管理与网关配置,收益在 MVP 量级不成立。
---
<a id="5"></a>
## 5. 模型 / 风格目录(catalog)方案
### 5.1 配置文件 vs 数据库表 —— 这题已被外键锁死
`generation_jobs` 对目录是**复合外键**引用(`patbond_postgresql.sql:644-647`):
```sql
FOREIGN KEY (model_id, media_kind)
REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT,
FOREIGN KEY (style_id, media_kind)
REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT,
```
`model_id uuid NOT NULL``:610`)。**配置文件方案无法满足 NOT NULL 外键**(没有行可指)。所以:**必须是数据库表**,无可选项。
顺带说明这两个外键为什么是复合的:`generation_models``generation_styles` 各有一条看似冗余的 `UNIQUE (id, media_kind)``:568``:592`),它存在的唯一目的就是给这两条复合外键提供被引用的唯一约束——它把「任务的 media_kind 必须与所选模型/风格的 media_kind 一致」这条业务规则**下沉成了数据库约束**。抄写 V6 时不要以为它冗余而删掉。
### 5.2 是否需要后台可配
**M4 不做后台**。理由:全项目**没有任何管理后台/管理端点**(契约 32 条路径全为 `/api/v1/**` 终端用户面,无 `/admin``development-plan.md` 也未在 M4 前排入后台)。目录变更用两条既有手段覆盖:
- `enabled boolean``:563``:587`+ `sort_order integer``:564``:588`):上下架与排序**改一条 SQL 即可**,不需要发版。
- 新增条目:后续 Flyway 迁移追加(V4 已是这个先例——`V4:5-8` 自述字典是「production reference data」,走版本链而非 dev fixture)。
真正需要运营自助时再单开 `/admin` 迭代。
### 5.3 种子内容与风格预览图的坑
客户端 mock 已有现成的目录内容可直接对齐(`patbond-flutter/lib/data/demo_data.dart:206+``creationStyles``healing`/治愈动画、`3d`/3D 卡通、`comic`/漫画风、`watercolor`/水彩 …;`patbond-flutter/lib/features/create/create_page.dart:179` 的模型下拉 `['Patbond-V1','Pet-Art Pro','Cute Motion']`)。V7 种子建议直接采用这批 code/title/subtitle,使客户端替换 mock 时**文案零变化**。
⚠️ **预览图取不到**`generation_styles.preview_asset_id``REFERENCES media.assets(id)``:586`),而迁移执行时刻**既没有 assets 行、也没有 MinIO 里的对象**,Flyway 无法种子二进制。三个选项:
| 选项 | 说明 | 评价 |
| --- | --- | --- |
| **P1(推荐)** | V7 种子 `preview_asset_id = NULL`;契约里 `previewUrl``nullable`**客户端按 `code` 用内置图片资源**做预览 | 零运维动作、零二进制入库;风格预览是装饰性内容,本就适合随包分发。需与客户端 agent 对齐 `code` 契约 |
| P2 | 上线后人工走一次 `/api/v1/media/uploads` 上传 4 张图,再 `UPDATE generation_styles SET preview_asset_id=...` | 引入不可复现的手工步骤(三环境各做一次),与 ADR-006「测试可复现」精神冲突 |
| P3 | 用 `storage_type='external'` 的 assets 行指向外链图 | `ck_media_location``V1:227-238`)允许 external,但把外部 URL 写进生产种子等于引入外部依赖,且 M3.5 已因「需接外部服务」为由把天气/位置推迟(`decisions.md:188`),同理否决 |
推荐 **P1**,并按 ADR-022 的纪律在代码注释与本报告显式标注为「刻意的客户端内置资源,非缺陷」。→ 待拍板 **D4-7**
### 5.4 `model_version_snapshot` 的来源缺口(取证发现的模型不自洽)
`generation_jobs.model_version_snapshot varchar(64) **NOT NULL**``:623`),但 `generation_models` 表(`:556-574`**没有任何 version 列**——`code` / `display_name` / `provider_code` / `provider_model_name` / `media_kind` / `enabled` / `sort_order` / 时间戳,仅此。所以这个 NOT NULL 值无处可取。
| 选项 | 说明 | 评价 |
| --- | --- | --- |
| **V1x(推荐)** | 由 provider 适配器声明版本(`GenerationProvider.version()`),提交时快照写入 | 零 schema 偏离;语义上也更准——真正决定输出的是**适配器/厂商模型版本**,不是目录行 |
| V2x | V6 给 `generation_models``model_version varchar(64)` 列 | 偏离已评审目标模型;且目录行的版本与厂商实际版本会漂移 |
推荐 **V1x**。→ 待拍板 **D4-8**(低风险,但因涉及是否偏离目标模型,列入清单)。
### 5.5 图片先行、视频后置
目标模型的 `media_kind` CHECK 是 `IN ('image','video')``:569``:593``:648`)。ADR-018 已明确「视频后置」(`decisions.md:161`)。建议**DB 侧照抄含 video 的 CHECKAPI 侧枚举只开 `image`** —— 这正是 `ai_creation` 当年的处理手法(DB CHECK 允许、契约描述标注预留、`@Pattern` 拦截),有现成先例可循,M5/M6 开视频时零迁移。
---
<a id="6"></a>
## 6. 生成输出落地媒体链路 + 一键社区草稿
### 6.1 两个新 purpose,且**只有一个**能进客户端白名单
`media.assets.purpose` 无 CHECK 约束(§1.3 取证),所以两个新用途**零迁移**。但二者的性质完全不同:
| purpose | 谁写 | 是否进 `MediaProperties.allowedPurposes` |
| --- | --- | --- |
| `ai_input` | 客户端经 `/api/v1/media/uploads` 两步上传的宠物原图 | **是**`MediaProperties.java:66` 追加) |
| `ai_output` | **worker 服务端生成**,客户端永不上传 | **否** ⚠️ |
⚠️ **安全要点**:若把 `ai_output` 也加进 `allowedPurposes`,用户就能自己上传任意图片并声明其为「AI 生成产物」,AI 创作的可信度归零。`allowedPurposes` 是客户端上传端点的闸门(`MediaService.java:53-56`),`ai_output` 必须只由服务端写入路径产生。
### 6.2 服务端落地缺两个原语
`ObjectStorage` 接口只有 `ensureBucket` / `presignPut` / `stat` / `presignGet`(§1.3 取证)。worker 需要的是:
- **读入图**:把 `ai_input` 的字节交给 provider(或给厂商一个可拉取的 URL)
- **写出图**:把生成字节落进 MinIO,再写 `media.assets`
方案对比:
| 选项 | 做法 | 评价 |
| --- | --- | --- |
| **M1(推荐)** | 把 `ObjectStorage` + `S3ObjectStorage` + 存储配置上提 `patbond-common`,并补 `putObject(key, bytes, contentType)``getObject(key)` 两个方法;patbond-ai 直接用 | 两个新原语**只实现一次**;顺手偿还三份 `patbond.media` 配置类 + 两份 `MediaUrlSigner` 的复制债;compose 已有给业务模块下发 MinIO 凭证的先例(§1.3),不破规矩。代价:动到 v0.4.0 已发布的 media 代码(由 381 测试兜底),且 `patbond-common` 会引入 aws-sdk 依赖(auth 模块被动带上,无 autoconfig 不产生行为) |
| M2 | patbond-ai 复制第 4 份存储适配层 | 与既有三份复制「一致」,但两个新原语要写两遍,债务继续滚 |
| M3x | patbond-user 新增内部端点 `POST /internal/media/assets`(服务端登记 + 存字节)与取预签名 GET 的内部端点,patbond-ai 经 Feign 调用,**自身不带 S3 SDK** | 严格守住 ADR-017「media 写侧在 user」;patbond-ai 极轻。代价:图片字节走一趟内部 HTTP、新增 2 个 `/internal` 端点与服务间认证面 |
推荐 **M1**,M2 作为工期压力下的退路。→ 待拍板 **D4-4**(含 ADR-017 边界解释,见下)。
**ADR-017 边界说明**(诚实标注):ADR-017 原文(`decisions.md:156`)是「media **上传流程**实现在 patbond-user(横切基础能力、避免业务模块被反向依赖)」。M1 让 patbond-ai 写 `media.assets`,严格说超出了「只读」范围。但 (a) ADR-017 反对的是「业务模块被反向依赖」,M1 是把共享物下沉到 common,方向相反;(b) `ai_output` 是服务端产物,天然不属于「上传流程」。建议 M4 以一条新 ADR 明确「服务端产出型资产由产出域写入 `media.assets`,客户端上传流程仍归 user」。
### 6.3 worker 落地时序(含孤儿与幂等)
```
1. 认领任务(§3.3
2. 若 provider_request_id 为空:
2a. 取 ai_input 对象(getObject 或预签名 GET
2b. 提交 provider → 拿到 providerRequestId
2c. ★ 单独事务立刻持久化 providerRequestIdL2 幂等,见 §4.2
否则:跳到 3(恢复路径,绝不重复提交)
3. 轮询 provider 直至完成;每 30s 心跳续租 + 写 progress(返回 0 行 → 已被取消,放弃)
4. 拿到输出字节:
4a. putObject → objectKey = "ai_output/" + yyyy/MM + "/" + assetId
(与 MediaService.java:68-69 的 key 规则同构:purpose 前缀 + 年月 + assetId,全服务端生成、不含用户输入)
4b. INSERT media.assetsowner_user_id = job.user_id(★ 必须,否则社区挂帖的归属校验会 404),
kind='image', purpose='ai_output', storage_type='object',
status='ready', ready_at=now(), width_px/height_px 按实际填
4c. 条件 UPDATE job → succeeded (progress=100, output_asset_id=:assetId, completed_at=now(), 清租约)
WHERE status='running' AND lease_owner=:owner
4d. 若 4c 返回 0 行(已被取消)→ 4b 的资产行置 deleted + 尽力删对象(§4.3
```
`owner_user_id = job.user_id` 这一步是**跨模块的隐性契约**:社区侧 `PostService.validateAssets:343-358``!userId.equals(ref.ownerUserId())` 判归属,若 worker 把 owner 写成别的值(或 NULL——`V1:207` 允许 NULL),用户就无法把自己的 AI 产物发帖,且报错是防枚举的 404/40405,**极难排查**。必须写进工单并配集成测试。
顺带填坑:`insertUploading``MediaAssetRepository.java:32-51`)从不写 `width_px` / `height_px`,两列在上传链路里恒为 NULL。worker 知道真实尺寸,应当填上——这会让 `MediaAsset` 契约里已存在的 `widthPx`/`heightPx` 字段(openapi.yaml `:3514+`)第一次有值。
### 6.4 一键建社区草稿:零新增端点
| 选项 | 做法 | 评价 |
| --- | --- | --- |
| **E1(推荐)** | 复用 `POST /api/v1/posts`:加宽 category 枚举放开 `ai_creation`,请求体加可选 `generationJobId`media 传 `output_asset_id`。草稿语义已有(status 缺省 draft`PostService.java:89` | **0 新增路径**;发布仍走既有 `PATCH``Post` 响应加 `generationJobId` 一个字段。契约自己就为此留了话:`Post` schema 描述(openapi.yaml `:3717-3718`)写「region/generationJob/topics 等裁剪字段整体不出现……**后续按新增可选字段纯增量补入**」 |
| E2 | 在 patbond-ai 加 `POST /creation/jobs/{id}/post-draft` | patbond-ai 写 `community` schema,破坏「模块边界 = schema 边界」纪律(`backend-modules.md:63,70`),否决 |
推荐 **E1**。社区侧需补的校验(当 `generationJobId` 非空时,跨 schema 只读 `creation.generation_jobs`,先例见 `MediaAssetGateway`):
1. `job.user_id = 调用者``job.status='succeeded'`,否则 404(防枚举,沿 40405/40403 惯例)
2. **是否强制要求 media 里包含 `job.output_asset_id`** —— 建议**强制**。否则用户可以挂一张任意图片却标注 `generationJobId`,把「AI 创作」变成可伪造的标签。→ 待拍板 **D4-6**
另需注意 §1.4 取证到的既有缺口:社区挂帖**完全没有 purpose 校验**`MediaAssetRef` 不含 purpose 列)。这意味着 `ai_input`(用户原图)也能挂上 ai_creation 帖子。D4-6 若采纳「强制包含 output_asset_id」,这个缺口在 AI 场景下即被覆盖;是否为社区全域补 purpose 白名单校验属独立议题,建议**不进 M4**(会改动已发布的社区写路径)。
---
<a id="7"></a>
## 7. 上游 AI 服务接入与本迭代可验证性
### 7.1 现状取证
全仓无任何 AI 厂商 SDK / 调用代码:`patbond-api/pom.xml` 依赖管理仅 spring-boot-dependencies、spring-cloud-dependencies、aws-sdk bom、hutool、lombok`:40-67`)。`.env` 与 compose 无任何第三方模型服务凭证。**上游是纯零起点**,且厂商尚未选型(任务书亦未指定)。
### 7.2 可插拔接口(推荐)
```java
public interface GenerationProvider {
String code(); // → provider_code_snapshotvarchar(64)
String version(); // → model_version_snapshotvarchar(64),见 §5.4 D4-8
/** 提交,立即返回上游请求 id;不等待完成 */
String submit(GenerationCommand command) throws ProviderException;
/** 轮询一次;返回 进行中(progress) / 完成(bytes+mime+尺寸) / 失败 */
PollResult poll(String providerRequestId) throws ProviderException;
}
```
**两阶段是硬要求**(§4.2 L2):`submit``poll` 分开,才能让「已提交上游 → 崩溃 → 恢复后继续轮询而非重复提交」这条路径被真实执行和测试。若接口设计成一次性 `generate()`L2 幂等只是纸面设计。
失败分类由 `ProviderException` 承载,直接决定 §3.4 走 (a) 重试还是 (b) 终态:
| `error_code`varchar(64),契约可见枚举) | 可重试 | 触发 |
| --- | --- | --- |
| `provider_timeout` | ✅ | 上游超时 |
| `provider_unavailable` | ✅ | 上游 5xx |
| `provider_rate_limited` | ✅ | 上游 429(退避后重试) |
| `provider_error` | ✅ | 其他上游异常 |
| `content_rejected` | ❌ | 上游内容策略拒绝 |
| `input_unreadable` | ❌ | 入图损坏/无法解码 |
| `lease_expired` | ❌ | reaper 判定次数耗尽(§3.4 c |
| `internal_error` | ❌ | 本侧缺陷 |
这张表就是验收标准「**失败原因可追踪**」的落点,应同时写进契约的 enum 与 description。
### 7.3 让本迭代端到端可验证:真实字节 stub
| 选项 | 说明 | 评价 |
| --- | --- | --- |
| **S1(推荐)** | `StubGenerationProvider``submit` 生成一个假 requestId 并记录起始时刻;`poll` 按配置时长(默认 6s)分档返回 progress,到点后**真的读入图、用 JDK 内置 `javax.imageio` + Java2D 做可见变换(风格色调叠加 + 角标文字),输出真 JPEG 字节** | **零新依赖**ImageIO/Java2D 在 JDK 内);把「取入图 → 变换 → 写出图 → 写资产行 → 挂帖」整条链路用**真实字节**跑通,而不是塞一个假 URL 蒙混过关;两阶段实现使 L2 恢复路径可测 |
| S2 | 只回写入图(原图复制) | 链路同样通,但产物与入图无差异,人工验收时无法判断「是否真的生成过」 |
| S3 | 返回硬编码占位图 URL | `storage_type='external'` 绕过对象存储,**整条媒体链路不被验证**,否决 |
| S4 | 等真实厂商 | 本迭代无法交付,否决 |
推荐 **S1**,配置项 `patbond.creation.provider=stub|<vendor>`,用 `@ConditionalOnProperty` 选实现。另建议提供 `patbond.creation.stub.failure-rate` 与可强制注入指定 `error_code` 的测试开关,用于覆盖 §7.2 那张失败表与重试路径。
**注意**(按 ADR-022 纪律):stub 是**刻意的占位实现**,须在类注释、application.yml.sample 与本迭代报告三处显式标注,避免后续实测反馈重复提出「AI 是假的」。同时它不是「demo 代码」——它是真实执行媒体链路的可插拔实现,接厂商时只增加一个实现类,**不动任何调用方**。
### 7.4 接真实厂商时才需要新决策的事项(本迭代不做,列出以免遗漏)
- 第三方 API Key 的注入与轮换(现有 `.env` + `.sample` 模式可承接,但 `check-secrets.sh` 规则表需评估是否覆盖新键名格式)
- 上游超时/并发上限与本侧 `lease-ttl``max_attempts` 的配比
- 上游计费与 L3 配额的对账(届时才可能需要真正的「扣费」表)
- 内容安全审核(`content_rejected` 的判定归属)
---
<a id="8"></a>
## 8. 限流与配额(429 + Retry-After
### 8.1 该不该在本迭代做
**该做,但只做收窄版。** 三条理由:
1. **验收标准依赖它**`development-plan.md:255` 写「相同幂等请求**不重复扣费**或生成」。而目标模型 40 张表**无任何计费/额度表**(§1.2 取证),全项目也无余额概念。「扣费」在 M4 唯一可落地的解释就是**配额消耗**。没有配额,这条验收标准无从验证。
2. **AI 生成天然需要**。它是全项目第一个**单次调用成本显著 > 0**(真实厂商按次计费)、且**单用户可无限触发**的写接口。既有写接口(发帖/评论/点赞)都无此性质。
3. **成本可控**。见 8.3,推荐方案**零新表、零新依赖**。
**但要与 M6 划清界限**`development-plan.md:272` 把「限流、审计、结构化日志、指标、Trace、告警」整体排在 M6。M4 做的**不是**横切限流基础设施(无网关级令牌桶、无全端点覆盖、无 Redis 计数器),而是**创作域一个端点的业务配额**。这个区分要写进 ADR,否则容易被误读为「M6 的限流提前做了」而在 M6 重复投入。
### 8.2 现状取证
- `patbond-*/src/main` grep `429|TOO_MANY_REQUESTS|Retry-After|RateLimit|rateLimit`**零命中**
- `openapi.yaml` grep 同上 → **零命中**(无 429 响应、无 RateLimit/Retry-After 头)
- `ErrorCode.java:12-38` 27 个码中 **429 段完全空白**
即:地基全新。
### 8.3 方案对比
| 选项 | 做法 | 评价 |
| --- | --- | --- |
| **Q1(推荐)** | **派生计数,零新表**。提交前 `SELECT count(*) FROM creation.generation_jobs WHERE user_id=:me AND created_at >= :windowStart AND status <> 'cancelled'`,与配置上限比较;另查 `status IN ('queued','running')` 得在途数。走 `ix_generation_jobs_user_created``:702-703`,已存在) | 零迁移、零新依赖、零新表;与 ADR-022「获赞总数走读侧实时聚合,不引入冗余列」的判例完全同构(`decisions.md:193`)。代价:并发提交有小幅超发(见下) |
| Q2 | 新建 `creation.generation_quotas` 计数表 + 原子 upsert | 严格精确,但**目标模型里没有这张表**(凭空新增偏离已评审模型),且为 MVP 精度买了一张需要窗口滚动与清理的表 |
| Q3 | Redis 计数器 | 需新增容器(§3.1),与 §3 的整体判断矛盾 |
推荐 **Q1**。超发问题:两个并发提交可能都通过检查。若需严格,加 `SELECT pg_advisory_xact_lock(hashtext(:userId::text))` 把同一用户的提交串行化——一行代码、无新表。建议**默认加上**,因为提交本来就是低频操作,串行化无性能代价。
### 8.4 建议的两道闸门与响应形态
| 闸门 | 配置项 | 建议默认 | 语义 |
| --- | --- | --- | --- |
| 日配额 | `patbond.creation.quota.daily-limit` | 20 | 同一用户当日(建议按 UTC 日切,与埋点 `server_ts` 口径一致)非取消任务数上限 |
| 在途上限 | `patbond.creation.quota.max-in-flight` | 2 | `queued + running` 并发数,防单用户占满队列 |
响应:**429** + 业务码 **42900**`GENERATION_QUOTA_EXCEEDED`,符合 `ErrorCode` 的 HTTP 三位 + 两位序号命名法)+ **`Retry-After` 响应头**(日配额 → 距下一个窗口起点的秒数;在途上限 → 建议固定小值如 10)。
⚠️ 取消是否退还配额:推荐**退还**(计数条件 `status <> 'cancelled'`),对用户更友好,且取消通常意味着未真正消耗上游。→ 归入 **D4-5**
⚠️ 429 是**全新错误段**,两处需同步:`ErrorCode.java` 新增枚举项,以及契约 `info.description` 的错误码表(openapi.yaml `:32-60`,当前止于 42300)。
---
<a id="9"></a>
## 9. 模块归属:patbond-ai 与共享物上提
### 9.1 新模块 vs 并入既有模块
| 选项 | 评价 |
| --- | --- |
| **N1(推荐):新建 `patbond-ai`:8085** | 沿 ADR-009M2 建 patbond-pet)与 ADR-017M3 建 patbond-community**两次先例**。注意 ADR-009 原文(`decisions.md:106`)写明:「PM 建议新建模块,后端评估建议 user 内独立包。**用户裁定采用新建模块方案**,为后续微服务化保持模块边界清晰」——后端立场连输两次,本次不再重复主张内包。`creation` schema 也与「模块边界 = schema 边界」纪律(`backend-modules.md:63,70`)对齐。附带收益:worker 是长跑后台消费者,与 API 请求路径隔离在独立进程里是真实的运维好处 |
| N2:并入 patbond-community | 唯一实质优势是「输出→草稿」在同一进程内。但让 community 同时持有 `community``creation` 两个 schema,破坏纪律;且 §6.4 已论证建草稿复用既有 `POST /api/v1/posts`,跨进程也毫无摩擦。否决 |
| N3:并入 patbond-user | user 已持有 identity + media + platform + 迁移链,再塞 creation 会让它变成事实上的单体。否决 |
推荐 **N1**`patbond-ai`,端口 **8085**8081/8082/8083/8084 顺延),API 路径前缀 **`/api/v1/creation/**`**(与 schema 名和契约 tag 一致,避免出现第三种命名 `ai`/`creation` 混用)。
### 9.2 新模块的隐性代价:复制代码从 3 份变 4 份
新模块需要下列每一样,而它们目前**都是各模块手抄的副本**:
| 共享物 | 现状份数 | 取证 |
| --- | --- | --- |
| `BearerAuthFilter` + `JwtVerifier` + `RsaPublicKeyLoader` | **3** → 将成 4 | community 版 Javadoc `:26` 自述「Third copy of the user/pet filter」 |
| `UuidV7` | **2** → 将成 3 | `patbond-pet/.../support/UuidV7.java``patbond-community/.../support/UuidV7.java` |
| `patbond.media` 配置类 | **3** → 将成 4 | `MediaProperties:15` / `PetMediaProperties:16` / `CommunityMediaProperties:15`,同一前缀 |
| 预签名 GET`MediaUrlSigner` / `S3ObjectStorage` | **3** → 将成 4 | 两个 `MediaUrlSigner` 的 Javadoc 互相自述「same as」 |
| `GlobalExceptionHandler` | 每模块一份 | community 版 `:31-35` |
| `RequestHashes` | 1community)→ 将成 2 | `RequestHashes.java:22-29` |
| 契约快照 + `OpenApiContract` | **4** → 将成 5 | 见 §13 |
**建议**:把 **JWT 资源侧校验、`UuidV7`、`RequestHashes`、存储适配层(含 §6.2 的两个新原语)** 四件上提 `patbond-common`,作为 M4 的一个独立前置工单。理由不是洁癖,而是算术:M4 本来就要给存储适配层加两个方法,抄 4 份 vs 抄 1 份;且这批复制**每多一份就多一处将来漂移的地方**(`MediaProperties` 三份已经在字段集上不一致——pet/community 版缺 `bucket``uploadTtl`)。
代价与风险要诚实说:这会改动 v0.4.0 已发布的 auth/user/pet/community 四模块的公共路径,由 381 测试兜底,属**行为保持型重构**,但仍是本迭代最大的回归面。若工期紧,退路是 **M2 方案**patbond-ai 各抄一份,债务记账留待 M6「交付加固」偿还)。→ 待拍板 **D4-3**
### 9.3 部署形态变化
六容器 → **七容器**(新增 `ai`),compose 块可直接照抄 community 块(`docker-compose.yml:118-152`):同库连接、同 JWT 公钥挂载、同 MinIO 凭证与 `PATBOND_MINIO_PUBLIC_ENDPOINT``depends_on: postgres(healthy) + user(started)`(等 user 跑完 Flyway)。若采纳 §3.5 的 worker 开关且选 A2,则为八容器(api + worker 同 jar),M4 建议先七容器。
需同步更新(**本报告不改,交由对应工单**):`backend-modules.md`(模块图、职责、端口)、`docker-compose.yml``deploy/init-secrets.sh`(若需新凭证)、根 `pom.xml``<modules>`
---
<a id="10"></a>
## 10. 数据模型草案与 DDL 草稿
**总原则:抄写目标模型,不重新设计。** 下面的 DDL 与 `patbond-doc/docs/database/patbond_postgresql.sql:556-716` **逐列一致**;所有偏离都在 §10.4 单独列出并附理由。字段含义已在 §2/§4/§5 逐项论证,此处不重复。
### 10.1 目录两表(DDL 草稿)
```sql
CREATE SCHEMA creation;
COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation';
CREATE TABLE creation.generation_models (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL,
display_name varchar(128) NOT NULL,
provider_code varchar(64) NOT NULL,
provider_model_name varchar(128) NOT NULL,
media_kind varchar(16) NOT NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code, media_kind),
-- 非冗余:为 generation_jobs 的复合外键提供被引用唯一约束(§5.1)
UNIQUE (id, media_kind),
CONSTRAINT ck_generation_models_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_models_names CHECK (
code = btrim(code) AND char_length(code) BETWEEN 2 AND 64
AND char_length(btrim(display_name)) BETWEEN 1 AND 128
)
);
CREATE INDEX ix_generation_models_kind_order
ON creation.generation_models (media_kind, sort_order, id)
WHERE enabled;
CREATE TABLE creation.generation_styles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
code varchar(64) NOT NULL,
title varchar(64) NOT NULL,
subtitle varchar(128),
media_kind varchar(16) NOT NULL,
-- 跨 schema 外键保留:media 自 V1 起存在(同 V5 先例)
preview_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
enabled boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (code, media_kind),
UNIQUE (id, media_kind),
CONSTRAINT ck_generation_styles_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_styles_names CHECK (
code = btrim(code) AND char_length(code) BETWEEN 2 AND 64
AND char_length(btrim(title)) BETWEEN 1 AND 64
)
);
CREATE INDEX ix_generation_styles_preview ON creation.generation_styles (preview_asset_id);
CREATE INDEX ix_generation_styles_kind_order
ON creation.generation_styles (media_kind, sort_order, id)
WHERE enabled;
```
### 10.2 任务表(DDL 草稿)
```sql
CREATE TABLE creation.generation_jobs (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT,
pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL,
media_kind varchar(16) NOT NULL,
model_id uuid NOT NULL,
style_id uuid,
input_asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT,
output_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL,
prompt varchar(10000),
negative_prompt varchar(3000),
width_px integer,
height_px integer,
duration_ms bigint,
upscale boolean NOT NULL DEFAULT false,
parameters jsonb NOT NULL DEFAULT '{}'::jsonb,
-- 快照四列:目录行改名/停用后历史任务仍可解释(§5.4 说明 model_version_snapshot 取值来源)
provider_code_snapshot varchar(64) NOT NULL,
provider_model_snapshot varchar(128) NOT NULL,
model_version_snapshot varchar(64) NOT NULL,
style_code_snapshot varchar(64),
status varchar(16) NOT NULL DEFAULT 'queued',
progress smallint NOT NULL DEFAULT 0,
priority smallint NOT NULL DEFAULT 0,
-- 队列机制六列(§2
attempt_count smallint NOT NULL DEFAULT 0,
max_attempts smallint NOT NULL DEFAULT 3,
provider_request_id varchar(128),
error_code varchar(64),
error_message varchar(1000),
idempotency_key varchar(128) NOT NULL,
request_hash bytea NOT NULL,
next_attempt_at timestamptz NOT NULL DEFAULT now(),
lease_owner varchar(128),
lease_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
started_at timestamptz, -- ★ 本次尝试的开始时间,非首次入队(§2.1)
completed_at timestamptz,
version integer NOT NULL DEFAULT 0,
UNIQUE (user_id, idempotency_key), -- L1 幂等(§4.2
FOREIGN KEY (model_id, media_kind)
REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT,
FOREIGN KEY (style_id, media_kind)
REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT,
CONSTRAINT ck_generation_jobs_kind CHECK (media_kind IN ('image', 'video')),
CONSTRAINT ck_generation_jobs_dimensions CHECK (
(width_px IS NULL OR width_px BETWEEN 64 AND 8192)
AND (height_px IS NULL OR height_px BETWEEN 64 AND 8192)
AND (duration_ms IS NULL OR duration_ms > 0)
),
CONSTRAINT ck_generation_jobs_parameters CHECK (jsonb_typeof(parameters) = 'object'),
CONSTRAINT ck_generation_jobs_idempotency CHECK (
idempotency_key = btrim(idempotency_key)
AND char_length(idempotency_key) BETWEEN 1 AND 128
AND octet_length(request_hash) = 32
),
CONSTRAINT ck_generation_jobs_status CHECK (
status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')
),
CONSTRAINT ck_generation_jobs_progress CHECK (progress BETWEEN 0 AND 100),
CONSTRAINT ck_generation_jobs_attempts CHECK (
attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts
),
-- 状态机下沉为 DB 约束(§4.1):五状态各自规定 7 个字段的合法组合
CONSTRAINT ck_generation_jobs_state CHECK (
(
status = 'queued' AND progress = 0 AND started_at IS NULL AND completed_at IS NULL
AND output_asset_id IS NULL AND error_code IS NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL
AND output_asset_id IS NULL AND error_code IS NULL
AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL
)
OR (
status = 'succeeded' AND progress = 100 AND started_at IS NOT NULL
AND completed_at IS NOT NULL AND output_asset_id IS NOT NULL AND error_code IS NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'failed' AND completed_at IS NOT NULL AND error_code IS NOT NULL
AND output_asset_id IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL
)
OR (
status = 'cancelled' AND completed_at IS NOT NULL
AND lease_owner IS NULL AND lease_expires_at IS NULL
)
),
CONSTRAINT ck_generation_jobs_lease CHECK (
(lease_owner IS NULL) = (lease_expires_at IS NULL)
AND (lease_expires_at IS NULL OR started_at IS NULL OR lease_expires_at > started_at)
),
CONSTRAINT ck_generation_jobs_version CHECK (version >= 0)
);
CREATE UNIQUE INDEX uq_generation_jobs_provider_request -- L2 幂等(§4.2
ON creation.generation_jobs (provider_code_snapshot, provider_request_id)
WHERE provider_request_id IS NOT NULL;
CREATE INDEX ix_generation_jobs_user_created -- 我的任务列表 + 配额计数(§8.3)
ON creation.generation_jobs (user_id, created_at DESC, id DESC);
CREATE INDEX ix_generation_jobs_pet ON creation.generation_jobs (pet_id);
CREATE INDEX ix_generation_jobs_model ON creation.generation_jobs (model_id);
CREATE INDEX ix_generation_jobs_style ON creation.generation_jobs (style_id);
CREATE INDEX ix_generation_jobs_model_kind ON creation.generation_jobs (model_id, media_kind);
CREATE INDEX ix_generation_jobs_style_kind ON creation.generation_jobs (style_id, media_kind);
CREATE INDEX ix_generation_jobs_input ON creation.generation_jobs (input_asset_id);
CREATE INDEX ix_generation_jobs_output ON creation.generation_jobs (output_asset_id);
CREATE INDEX ix_generation_jobs_queue -- 出队(§3.3
ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id)
WHERE status = 'queued';
CREATE INDEX ix_generation_jobs_running -- reaper 回收(§3.4 c
ON creation.generation_jobs (lease_expires_at, id)
WHERE status = 'running';
```
### 10.3 触发器与外键补回
```sql
-- platform.set_updated_at() 自 V1:28-36 起存在,直接挂(目标模型 :1245-1250
CREATE TRIGGER trg_generation_models_updated_at BEFORE UPDATE ON creation.generation_models
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_generation_styles_updated_at BEFORE UPDATE ON creation.generation_styles
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
CREATE TRIGGER trg_generation_jobs_updated_at BEFORE UPDATE ON creation.generation_jobs
FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at();
-- ★ V5:17-19 指定的「M4 补回」:裸列 + ix_posts_generation_job 索引均已存在,
-- 仅补约束。存量值恒为 NULL(§1.1 取证),无需 NOT VALID,无回填。
ALTER TABLE community.posts
ADD CONSTRAINT fk_posts_generation_job
FOREIGN KEY (generation_job_id) REFERENCES creation.generation_jobs(id) ON DELETE SET NULL;
-- ⚠️ region_id 的外键属 M5V5:20-22),本迁移不得顺手补
```
### 10.4 相对目标模型的偏离清单(仅此 3 处,全部为「不改 schema」)
| # | 事项 | 处置 | 理由 |
| --- | --- | --- | --- |
| 1 | `model_version_snapshot` 无来源列 | **不加列**,由 provider 适配器 `version()` 提供(§5.4) | 避免偏离已评审模型;语义更准 |
| 2 | `media_kind``video` | **DB 照抄含 video**,仅 API 枚举收窄到 `image` | ADR-018 视频后置;沿 `ai_creation` 的「DB 允许 / API 收窄」先例 |
| 3 | 取消不加 `cancel_requested` 列 | 用条件 UPDATE + worker 收尾失败自弃(§4.3 X1 | 目标模型的 cancelled 分支本就允许 running 取消 |
**结论:M4 对 creation schema 零结构偏离**`generation_models` / `generation_styles` / `generation_jobs` 三表与目标模型逐列一致。
---
<a id="11"></a>
## 11. Flyway 迁移计划
### 11.1 迁移数量:**2 个**
| 版本 | 文件名 | 内容 | 依据 |
| --- | --- | --- | --- |
| **V6** | `V6__creation_baseline.sql` | `CREATE SCHEMA creation` + `COMMENT ON SCHEMA` + 三表 + 13 条索引 + 3 条触发器 + **`ALTER TABLE community.posts ADD CONSTRAINT fk_posts_generation_job`**(§10.3) | 结构迁移;V6 是既定的下一个版本号(ADR-022,`decisions.md:192` |
| **V7** | `V7__creation_catalog_seed.sql` | `generation_models``generation_styles` 种子行(`preview_asset_id` 全为 NULL,§5.3 P1 | 沿 V3(结构)/ V4(种子)拆分先例(`V4:1-13` |
放置位置**唯一**`patbond-api/patbond-user/src/main/resources/db/migration/`(§1.1 取证:只有 user 模块配了 `flyway.locations`)。即便新建 patbond-ai 模块,迁移也**不进** patbond-ai。
### 11.2 单文件内的顺序约束(写错会直接失败)
V6 内部必须严格按此顺序:
1. `CREATE SCHEMA creation`
2. `generation_models``generation_styles`(含各自的 `UNIQUE (id, media_kind)`
3. `generation_jobs`(其复合外键引用步骤 2 的唯一约束;其 `input_asset_id`/`output_asset_id` 引用 `media.assets`V1 已建;`user_id` 引用 `identity.users`V1 已建;`pet_id` 引用 `pet_health.pets`V3 已建)
4. 索引与触发器
5. **最后**才 `ALTER TABLE community.posts ADD CONSTRAINT`(被引用表必须先存在)
### 11.3 是否需要 V8 及以后
不需要。本迭代**没有任何**需要额外迁移的项,逐条确认:
| 候选 | 是否需要迁移 | 取证 |
| --- | --- | --- |
| 新增 `ai_input` / `ai_output` purpose | **否** | `media.assets.purpose` 无 CHECK`V1:205-249` 逐行确认);白名单是配置项 `MediaProperties.java:66` |
| 放开 `category='ai_creation'` | **否** | `V5:68``ck_posts_category` **已含** `ai_creation`;拦截在 Java `@Pattern` |
| `posts.generation_job_id` 加列 | **否** | 裸列 `V5:48` 已存在 |
| `ix_posts_generation_job` 索引 | **否** | `V5:95` 已建 |
| 埋点新增创作域事件 | **否** | `platform.product_events``event_name varchar(64)` + `props jsonb``V2`),白名单在 `EventDictionary.java`Java 常量表,41 条) |
| 配额计数 | **否** | 派生计数,零新表(§8.3 Q1) |
| 429 错误码 | **否** | Java 枚举 + 契约文本 |
### 11.4 迁移相关的测试影响(**会红,必须提前排进工单**)
⚠️ **`CommunityMigrationIntegrationTest.v5PostsColumnsExistWithoutCreationAndRegionFKs` 会因 V6 变红。** 取证:`patbond-user/src/test/java/com/patbond/patbond/user/persistence/CommunityMigrationIntegrationTest.java:81-88`
```java
int fkCount = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.table_constraints " +
"WHERE table_schema = 'community' AND table_name = 'posts' " +
"AND constraint_type = 'FOREIGN KEY' " +
"AND (constraint_name LIKE '%generation%' OR constraint_name LIKE '%region%')")
.query(Integer.class).single();
assertThat(fkCount).isEqualTo(0);
```
Flyway 在测试容器上跑**全链**,V6 之后这个查询会返回 1(`fk_posts_generation_job` 命中 `LIKE '%generation%'`)。**处置**:把该断言拆成两条——generation 类 FK 期望 **1**region 类 FK 仍期望 **0**(M5 才补)——并同步更新测试类 Javadoc(`:12-18` 现写「generation_job_idcreation belongs to M4)…exist as bare nullable uuid columns without FKs」)。
同一文件 `v5PostsKeepsInSchemaAndV1V3FKs``:92-108`)用 `IN ('identity.users','pet_health.pets')` 过滤,**不受影响**。
**新增测试**:按 `PetHealthMigrationIntegrationTest` / `CommunityMigrationIntegrationTest` 先例补 `CreationMigrationIntegrationTest`,断言 creation schema 存在、3 张表、13 条索引中的关键部分索引(`ix_generation_jobs_queue``ix_generation_jobs_running``uq_generation_jobs_provider_request`)、3 条触发器、复合外键存在、以及**一组「非法状态迁移被 `ck_generation_jobs_state` 拒绝」的直接断言**(§4.1)。
---
<a id="12"></a>
## 12. API 端点清单与契约影响面
### 12.1 新增端点(4 路径 / 5 操作)
| # | 方法 + 路径 | operationId | 说明 | 主要错误 |
| --- | --- | --- | --- | --- |
| 1 | `GET /api/v1/creation/catalog` | `getCreationCatalog` | 模型 + 风格**一次取回**`?mediaKind=image`,缺省 image)。仅 `enabled`,按 `sort_order, id` 排序(走 `ix_generation_models_kind_order` / `ix_generation_styles_kind_order` | 400/40000、401/40101 |
| 2 | `POST /api/v1/creation/jobs` | `createGenerationJob` | 提交任务。**`Idempotency-Key` 强制头**(同 `POST /api/v1/posts` 先例)。201 | 400/40000、401/40101、404/40405inputAsset 不存在/非本人/已删)、404/40401petId 不可见)、409/40905(同键异载荷)、422/42203inputAsset 非 ready)、422/42206model/style 与 mediaKind 不匹配或已停用)、**429/42900** |
| 3 | `GET /api/v1/creation/jobs` | `listGenerationJobs` | 我的任务列表,**cursor 分页正典** `{items, nextCursor, hasMore}`,排序 `created_at DESC, id DESC`(走 `ix_generation_jobs_user_created`)。可选 `status` 过滤 | 400/40000、401/40101 |
| 4 | `GET /api/v1/creation/jobs/{jobId}` | `getGenerationJob` | 轮询状态/进度/结果(建议 1.5s 间隔,写进 description)。含 `outputUrl`(预签名 GET,会过期,不得持久化——同 `avatarUrl` 惯例) | 401/40101、404/40407 |
| 5 | `POST /api/v1/creation/jobs/{jobId}/cancel` | `cancelGenerationJob` | 取消 queued 或 running(§4.3 X1)。终态 → 422/42206 | 401/40101、404/40407、422/42206 |
**不设 retry 端点**(§4.4 R1:用户重试 = 用新 `Idempotency-Key` 重新提交)。
**不设目录管理端点**(§5.2:全项目无 `/admin` 面)。
新增 tag`creation` —— 「AI 创作:模型/风格目录、生成任务提交/查询/取消(patbond-ai,异步队列)」,插入 openapi.yaml 的 tags 块(当前 11 个 tag`:190-211`)。
### 12.2 既有端点的改动(0 新路径)
| 端点 | 改动 | 兼容性 |
| --- | --- | --- |
| `POST /api/v1/posts` | 请求体加可选 `generationJobId`uuid);`category` 枚举 `[general, help]``[general, help, ai_creation]` | **向后兼容**(新增可选字段 + 请求枚举**加宽**只会接受更多输入) |
| `PATCH /api/v1/posts/{postId}` | `category` 枚举同上加宽 | 向后兼容 |
| `GET /api/v1/posts/{postId}` 等所有返回 `Post` 的操作 | `Post` 响应加 `generationJobId`nullable uuid | 向后兼容;且契约**已预告**此增量(`:3717-3718`「后续按新增可选字段纯增量补入」) |
| `POST /api/v1/media/uploads` | `purpose` 枚举加 `ai_input`**不加 `ai_output`**,§6.1 安全要点) | 向后兼容 |
### 12.3 契约影响面汇总
| 计量 | v1.4.0 现状 | M4 增量 | v1.5.0 预计 |
| --- | --- | --- | --- |
| `paths` | **32** | **+4** | **36** |
| operations | **45** | **+5** | **50** |
| `components.schemas` | **75** | **+8** | **83** |
| `components.responses` | 现有若干(含 `MediaNotFound:2030``MediaNotReady:2070` | **+1**`QuotaExceeded`,含 `Retry-After` 头) | — |
| tags | 11 | +1`creation` | 12 |
| 错误码表(`info.description:32-60` | 止于 42300 | +40407、+42206、+42900 | — |
| 既有 schema 修改 | — | **3**`CreatePostRequest:3641``UpdatePostRequest:3676``Post:3714`+ `CreateMediaUploadRequest:3450` 的 purpose 枚举 = 共 **4** | — |
新增 8 个 schema(按项目「每个列表端点一个 `*ListEnvelope`、每个单资源一个 `*Envelope`」惯例):
1. `CreationModel`
2. `CreationStyle``previewUrl` nullable,§5.3 P1
3. `CreationCatalog``{models, styles}`
4. `CreationCatalogEnvelope`
5. `CreateGenerationJobRequest`
6. `GenerationJob`(详情与列表项共用,含 `status` / `progress` / `errorCode` 枚举 / `outputAssetId` / `outputUrl` / `modelCode` / `styleCode` / `createdAt` / `completedAt`
7. `GenerationJobEnvelope`
8. `GenerationJobListEnvelope``{items, nextCursor, hasMore}`
**是否纯增量:是。** 逐条核验:新增路径/操作/schema/tag 全为增加;请求枚举**加宽**(服务端接受更多,旧客户端不受影响);新增**可选**请求字段;新增响应字段(Flutter 侧非 required 字段被忽略)。**无删除、无重命名、无必填收紧、无类型变更、无枚举收窄**。故版本号 **v1.4.0 → v1.5.0**(功能增量,非补丁),与 ADR-022 的 v0.4.0 定版逻辑一致。
### 12.4 新增业务错误码(3 个)
| 码 | HTTP | 名称 | 语义 |
| --- | --- | --- | --- |
| **40407** | 404 | `GENERATION_JOB_NOT_FOUND` | 任务不存在 / 非本人(防枚举合并,沿 40405 先例) |
| **42206** | 422 | `GENERATION_RULE_VIOLATION` | model/style 与 mediaKind 不匹配、目录项已停用、或对终态任务发起取消 |
| **42900** | 429 | `GENERATION_QUOTA_EXCEEDED` | 日配额或在途上限(§8.4),**配 `Retry-After` 头** |
复用既有码:40000、40101、40401、40405、40905、42203、50000、50300。命名与编号均遵循 `ErrorCode.java:3-9` 的纪律(「codes are contract, never reuse or renumber a released value」)。
---
<a id="13"></a>
## 13. 契约同步机械清单(含第 5 份快照)
### 13.1 先修正一条既有认知:CI 锁的不是字节
任务书写的是「四模块**字节级**快照锁 CI」。实测**不成立**:
- CI 工作流(`patbond-api/.gitea/workflows/ci.yml`)只有两个校验步骤:`sh scripts/check-secrets.sh --all``./mvnw -B clean test`。**无任何 diff / 校验和 / 文件比对步骤**。
- 四份 POM 中**无**任何拷贝或比对插件配置。
- 「byte-identical」只出现在 Javadoc 里,是**约定的措辞**(如 `patbond-pet/.../contract/OpenApiContract.java:20`「this snapshot is a byte-identical copy taken at freeze time」),而该类的实现只用 SnakeYAML 解析(`:54` `new Yaml().load(in)`)并暴露 `version()/paths()/operations()/schemas()`
- 真正的门禁是**结构断言**:每模块一个 `frozenSnapshotIsTheExpectedContractVersion()`,断言版本字符串 + 三个计数 + 本域 tag 的操作集合。
实测四处断言(全部 `1.4.0` / `32` / `45` / `75`):
| 模块 | 断言位置 | tag 集合 |
| --- | --- | --- |
| auth | `AuthContractConformanceTest.java:401-405` | `{auth, user, analytics}` |
| user | `MediaContractConformanceTest.java:248-252` | `{media}` |
| pet | `ContractConformanceTest.java:757-761` | `{pets, dictionaries, health-records}` |
| community | `CommunityContractConformanceTest.java:524-528` | posts/feed/comments/interactions/follows 一组) |
四份快照当前确实互为字节相同(`diff` 无输出),但**这是纪律的结果,不是机制的保证**。差别对 M4 有实际后果:如果有人只改了 doc 仓的 openapi.yaml 里某个 description 而不动计数,四个模块的守卫**全都不会红**。所以「契约先行」在本项目仍然依赖人工纪律,M4 的契约冻结工单不能只跑 CI 就认为同步到位。
### 13.2 升版必须同步改的清单(漏一处 CI 即红)
1. doc 仓 `docs/api/openapi.yaml``info.version``1.5.0`;错误码表加 3 行;tags 加 `creation`;新增 4 路径 / 8 schema / 1 response;改 4 个既有 schema
2. `patbond-doc/site/api/openapi.yaml`(当前与正典字节相同的站点镜像;**未取证它是否由 mkdocs 构建自动产出**,若为构建产物则不手改)
3. 四份 → **五份** 快照文件:`patbond-{auth,user,pet,community,ai}/src/test/resources/contract/openapi-v1.5.0.yaml`(新文件名,旧的 v1.4.0 按既有做法删除)
4. 四份 → **五份** `OpenApiContract.java``RESOURCE` 常量(`:39`pet 版在 `:35`)→ `/contract/openapi-v1.5.0.yaml`
5. 四处 → **五处** `frozenSnapshotIsTheExpectedContractVersion()`:版本 `1.5.0``paths` **36**`operations` **50**`schemas` **83**
6. community 的 tag 操作集合:`Post` / `CreatePostRequest` 相关操作签名未变,集合内容不变(仅 schema 内部变化),**但仍要复核**
7. **新增** patbond-ai 的 `OpenApiContract` + `AiContractConformanceTest`tag 集合 `{creation}`5 个操作)
⚠️ 特别注意:**auth 与 pet 两个模块本域一行代码都不改,但它们的守卫会因全局计数变化而变红**,必须一并更新。这正是 M3.5 出现「11 格漂移」的同一机制(`iteration-3.5/03` 报告 §6 记载 auth 2 + pet 9 格漂移)。M4 的漂移格数预计更大(新增 5 操作 + 8 schema),建议**契约冻结单独成一个工单**并放在实现工单之后,与 T3.5-07 同一节奏。
---
<a id="14"></a>
## 14. 风险清单
| # | 风险 | 概率 | 影响 | 缓解 |
| --- | --- | --- | --- | --- |
| R1 | **L2 上游幂等被做成同步单步调用**,「已提交上游 → 崩溃 → 重复提交」路径永不被测试,接真实厂商后重复扣费 | 中 | **高**(真金白银) | §7.2 强制 `submit`/`poll` 两阶段接口;stub 也两阶段;专写「持久化 requestId 后杀 worker,恢复后不得二次 submit」的集成测试 |
| R2 | **`CommunityMigrationIntegrationTest:81-88` 因 V6 变红**,被误当成 V6 写错而反复调试迁移 | **高**(几乎必然) | 中(浪费工时) | §11.4 已定位到行号与断言原文,工单里直接写「这条断言要拆成 generation=1 / region=0」 |
| R3 | worker 写 `media.assets``owner_user_id` 写错或为 NULL,用户无法把产物发帖,且报错是防枚举 404/40405,**极难排查** | 中 | 中 | §6.3 写进工单 + 集成测试断言 `owner_user_id = job.user_id` |
| R4 | `ck_generation_jobs_state` 严格,重试重新入队时漏清 `started_at`/`error_code`/`progress` → DB 直接拒绝,表现为「重试莫名失败」 | **高** | 中 | §2.1 三条约定 + §3.4 给出完整 UPDATE 语句,照抄即可 |
| R5 | §9.2 的共享物上提改到 v0.4.0 已发布的四模块公共路径,引入回归 | 中 | 中~高 | 381 测试兜底;拆成独立前置工单、单独提交、单独 CI 绿;退路是 M2(各抄一份) |
| R6 | `ai_output` 被误加进 `MediaProperties.allowedPurposes`,用户可伪造「AI 产物」 | 中 | 中(可信度) | §6.1 明确标注;配一条「上传 purpose=ai_output 必须 400」的测试 |
| R7 | running 取消产生的孤儿对象与资产行无人清理,MinIO 缓慢涨 | 中 | 低 | §4.3 X1 要求置 `deleted` + 尽力删对象;彻底清理沿用 `ix_media_uploading_created` 的既有待办(M6 |
| R8 | 契约计数漂移导致 auth/pet 无辜变红被误诊 | **高** | 低 | §13.2 清单;M3.5 已有同类先例可引 |
| R9 | 轮询进度导致客户端请求量放大(每任务 ~6s / 1.5s = 4 次以上) | 中 | 低 | MVP 量级可忽略;`GET /jobs/{id}` 是单行主键查询;必要时加 `ETag`/延长间隔 |
| R10 | 风格预览图缺失(§5.3)被实测反馈为「界面空图」 | 中 | 低 | P1 方案需**与客户端 agent 明确对齐**(按 `code` 内置资源),并按 ADR-022 在报告/注释标注为刻意占位 |
| R11 | 七容器后单机资源(腾讯云服务器同时跑 postgres + minio + 5 应用 + 生成变换)吃紧 | 中 | 中 | stub 的 Java2D 变换是短时 CPU 峰值;worker 线程池上限设 2;`patbond.creation.worker.enabled` 可随时关停 |
| R12 | `duration_ms` / `upscale` / `negative_prompt` 等列为视频与高级参数预留,M4 不用,评审时被质疑「为什么建了不用」 | 低 | 低 | 与 V5 建 `topics` 表但功能剪出(ADR-018)完全同构,先例充分 |
| R13 | 429 被误解为「M6 限流提前做了」,M6 重复投入或反之漏做 | 中 | 低 | §8.1 的边界说明需写进 M4 的 ADR |
---
<a id="15"></a>
## 15. 待拍板决策
**10 项**。**最关键的 3 项是 D4-1 / D4-2 / D4-3**(分别决定技术路线、迭代能否交付、以及本迭代最大回归面)。
### D4-1 ★ 队列实现方案
**推荐:DB 表轮询 + `FOR UPDATE SKIP LOCKED` + 租约列(方案 A),不引入 Redis、不引入 MQ。**
理由:(1) 已评审目标模型 `patbond_postgresql.sql:605-716` 已备好 `lease_owner`/`lease_expires_at`/`next_attempt_at`/`attempt_count`/`max_attempts`/`priority` 六列与 `ix_generation_jobs_queue`/`ix_generation_jobs_running` 两条部分索引——换中间件等于让这批列作废并推翻已评审模型;(2) compose 实测**无 Redis 无 MQ**,B/C 都是净新增容器与运维面,与 ADR-002 为同样理由移除 Nacos 的判例一致;(3) 任务状态真值保持单一(Postgres),无 dual-write(4) `development-plan.md:271` 已把「事务 Outbox」排到 M6。代价(轮询延迟、极大规模下轮询负载)在 MVP 量级不成立,且升级路径不改数据模型。
### D4-2 ★ 上游 AI 服务形态
**推荐:可插拔 `GenerationProvider`(两阶段 submit/poll+ 真实字节 stub 实现(S1),M4 默认 stub。**
理由:厂商未选型,S1 是本迭代唯一能端到端交付并验收的路径;用 JDK 内置 `javax.imageio`+Java2D 做可见变换**零新依赖**,且让「取入图 → 变换 → 写出图 → 写资产行 → 挂帖」整链走真实字节;两阶段是 L2 幂等(不重复扣费)能被测试覆盖的前提。接厂商时只增加一个实现类,调用方零改动。须按 ADR-022 纪律在三处显式标注 stub 为刻意占位。
### D4-3 ★ 是否把共享物上提 patbond-common
**推荐:上提 4 件(JWT 资源侧校验、`UuidV7`、`RequestHashes`、存储适配层含新增 `putObject`/`getObject`),作为 M4 独立前置工单。退路:patbond-ai 各抄一份(M2)。**
理由:新模块会把复制份数推到 `BearerAuthFilter` 4 份、存储/配置 4 份、`UuidV7` 3 份(§9.2 逐项取证,community 版 Javadoc 自称「Third copy」);而 M4 本来就要给存储适配层加两个原语——抄 4 遍 vs 抄 1 遍。且三份 `patbond.media` 配置类**已经开始漂移**pet/community 版缺 `bucket`/`uploadTtl`)。风险是动到 v0.4.0 已发布路径,由 381 测试兜底,属行为保持型重构,建议单独提交单独过 CI。**这是本迭代最大回归面,需明确拍板。**
### D4-4 生成输出写 `media.assets` 的归属(含 ADR-017 边界)
**推荐:M1 —— patbond-ai 经上提到 common 的适配层直接写 `media.assets`;并新立一条 ADR 明确「服务端产出型资产由产出域写入,客户端上传流程仍归 patbond-user」。**
理由:ADR-017 原文反对的是「业务模块被反向依赖」,上提 common 方向相反;`ai_output` 是服务端产物,本不属于「上传流程」;compose 已有给 pet/community 下发 MinIO 凭证的先例。备选 M3xuser 开 `/internal` 端点、字节走内部 HTTP)严格守 ADR-017 但多两个内部端点与一趟字节传输。**与 D4-3 强耦合**:若 D4-3 选退路 M2,则 D4-4 建议改选 M3x(避免第 4 份适配层还带新原语)。
### D4-5 配额与 429 是否进本迭代
**推荐:进,但只做创作域业务配额(Q1 派生计数,零新表):日配额 20、在途上限 2、429 + 42900 + `Retry-After`;取消退还配额。同时在 ADR 明确它不是 M6 的横切限流。**
理由:M4 验收标准写明「不重复**扣费**」,而全项目无任何计费/额度表(§1.2 取证 40 张表),「扣费」只能落在配额语义上——配额是验收前置而非可选项;Q1 零迁移零新表,与 ADR-022「获赞走读侧聚合、不引入冗余列」判例同构。默认值需产品侧确认。
### D4-6 `ai_creation` 帖子是否强制包含 `output_asset_id`
**推荐:强制。** `POST /api/v1/posts``generationJobId` 时,校验 (a) 任务属调用者且 `status='succeeded'`(b) media 列表**必须包含** `job.output_asset_id`
理由:社区侧挂帖**完全没有 purpose 校验**(实测 `MediaAssetRef.java:9-16``MediaAssetGateway` 的 SQL 均无 `purpose` 列,与 pet 侧 `PetService.java:196` 有校验形成对比)。不强制则用户可挂任意图片却标注 `generationJobId`,「AI 创作」变成可伪造标签。是否为社区**全域**补 purpose 白名单校验属独立议题,建议**不进 M4**(会改已发布写路径)。
### D4-7 风格预览图方案
**推荐:P1 —— V7 种子 `preview_asset_id = NULL`,契约 `previewUrl` nullable,客户端按 `code` 使用内置图片资源。**
理由:Flyway 无法种子二进制(`preview_asset_id` 引用 `media.assets`,迁移时刻既无行也无对象);P2 引入三环境各做一次的不可复现手工步骤(违 ADR-006 精神);P3 走 external URL 引入外部依赖(M3.5 已因同理由推迟天气/位置)。**需与客户端 agent 对齐 `code` 契约**,并按 ADR-022 标注为刻意占位。
### D4-8 `model_version_snapshot` 取值来源
**推荐:V1x —— 由 provider 适配器 `version()` 提供,不给 `generation_models` 加 version 列。**
理由:目标模型该列为 `NOT NULL``generation_models``:556-574`)无任何 version 列,存在不自洽(本次取证发现)。V1x 零 schema 偏离,且语义更准——决定输出的是适配器/厂商模型版本,不是目录行。
### D4-9 取消的可及范围
**推荐:X1 —— queued 与 running 均可取消**worker 因条件 UPDATE 返回 0 行而自弃产物,并把已写的资产行置 `deleted` + 尽力删对象。
理由:目标模型 cancelled 分支(`:687-690`)本就不限制 `started_at`/`progress`/`output_asset_id`,允许 running 取消;stub 约 6sX2(仅 queued 可取消)会让多数点击被 422 拒绝,UX 差且没省成本;X3(加 `cancel_requested` 列)偏离目标模型且无额外收益。
### D4-10 用户可见「失败重试」的形态
**推荐:R1 —— 客户端用新的 `Idempotency-Key` 重新提交,不设 retry 端点。**
理由:R2(复活 failed 任务)需清空 `error_code`/`completed_at`/`attempt_count`,等于擦掉失败证据,与验收标准「失败原因可追踪」相抵,且在 `attempt_count <= max_attempts` 下语义混乱。R1 零新增端点、审计链每次尝试一行。**需与客户端 agent 对齐**(客户端要为「重试」生成新键,而非复用原键——复用原键会命中 L1 幂等返回那个已失败的任务)。
### 15.1 决策依赖关系
```
D4-1(队列)── 独立,先拍
D4-2stub)── 独立,先拍
D4-3(上提)──┬─► D4-4(写 media 归属):D4-3 选 M2 时,D4-4 建议改 M3x
D4-5(配额)── 独立(默认值需产品确认)
D4-6 / D4-7 / D4-10 ── 需与客户端 agent 对齐后落契约
D4-8 / D4-9 ── 低风险,可随实现工单一并确认
```
---
<a id="16"></a>
## 16. 本报告推翻 / 修正的既有结论
按 M3.5 的教训(一份报告写「无 nickname 字段」实指「契约未暴露」,被误读成缺列需迁移,规模高估一整档),以下每条都注明**原始取证位置**与**误读会造成的后果**。
| # | 既有说法 | 实测 | 误读的后果 |
| --- | --- | --- | --- |
| 1 | 任务书:「四模块**字节级**快照锁 CI」 | **不成立**。CI`.gitea/workflows/ci.yml`)只有 `check-secrets.sh --all` + `mvnw clean test`,**无任何 diff/校验和步骤**POM 里也没有。「byte-identical」只是 Javadoc 措辞(`OpenApiContract.java:20`),实现只做 SnakeYAML 解析。真正的门禁是**结构断言**(版本串 + 32/45/75 三个计数 + 本域 tag 操作集)。四份快照当前确为字节相同,但那是纪律而非机制 | 以为「改 openapi 忘同步会被机器抓住」→ 只改 description 的变更四个模块**全都不会红**,契约漂移悄悄溜过 |
| 2 | 任务书:「输出写入 M3 已有的媒体表链路(**assets / purpose 白名单**),然后一键建社区草稿」 | **社区侧不存在 purpose 白名单校验**`PostService.validateAssets:343-358` 只校验归属与 ready`MediaAssetRef.java:9-16``MediaAssetGateway` 的 SQL **都不含 `purpose` 列**。purpose 白名单只存在于两处:客户端上传端点(`MediaProperties.java:66` + `MediaService.java:53-56`)与 pet 头像(`PetService.java:196`) | 以为挂帖有 purpose 闸门 → 不做 D4-6 的强制校验,「AI 创作」标签可被任意图片伪造 |
| 3 | 任务书:「M2 期间曾裁剪过跨 schema 外键……补 FK 前请核实当时的裁剪理由是否仍然成立」 | **裁剪理由已不成立,且补回是 V5 自己下的指令**`V5:15-19``V3:14-19` 的原话都是「FKs into schemas **not yet migrated** are STRIPPED」——理由是「目标 schema 尚不存在」,**不是反对跨 schema 外键**。反证三条:`V1:262-264``V5:45-46``V5:107` 都保留了跨 schema 外键 | 若误读为「本项目政策上避免跨 schema 外键」→ 该补的 FK 不补,`generation_job_id` 永久裸列,且与目标模型漂移 |
| 4 | 任务书:「api 379 测试」 | **381**(采用协调方 Evidence Collector 实测;契约冻结报告自身写的是「379→381」,`releases.md` 未跟新) | 增量基数错 2,后续「测试数对不上」被当成丢测试排查 |
| 5 | 我自己的中途假设:「契约的 category 请求枚举已含 `ai_creation`,只需改 Java」 | **不成立**。请求侧 `CreatePostRequest:3657``UpdatePostRequest:3697` 的枚举都是 `[general, help]`,只有 description 提到预留;**响应侧** `Post:3747``FeedCard:3826` 才已含 `ai_creation` | 少算了 2 处契约枚举加宽,冻结时才发现 |
| 6 | 隐含预期:「M3 的存储适配层可直接供 worker 落地产物」 | **缺两个原语**`ObjectStorage` 只有 `ensureBucket`/`presignPut`/`stat`/`presignGet`(全文 4 个方法),**无服务端 put、无服务端 get**。这是 D4-3/D4-4 存在的根因 | 排期时以为「媒体链路已就绪、直接调用即可」→ 低估工作量,且可能各模块自行硬写 S3 调用 |
| 7 | 「ADR-017 = media 写侧只能在 patbond-user」 | 原文(`decisions.md:156`)是「media **上传流程**实现在 patbond-user」,约束的是上传流程与反向依赖方向,**未涵盖服务端产出型资产**。这是一个需要新 ADR 澄清的边界,而非既有禁令 | 以为被 ADR 禁止 → 被迫选 M3x 并让图片字节多走一趟内部 HTTP,或误认为方案违规 |
| 8 | 「模型/风格目录可以先用配置文件,不建表」 | **不可能**`generation_jobs.model_id uuid NOT NULL` 且为**复合外键** `(model_id, media_kind) REFERENCES generation_models(id, media_kind)``:610,644-647`),配置文件方案无行可指 | 按配置文件排期 → 进入实现才发现必须建表,V6 范围临时膨胀 |
| 9 | 「Worker 队列需要先选中间件」 | 目标模型**早已把 DB 队列的列与索引写完**(§2)。这题不是开放选型,是确认既有设计 | 花时间做中间件选型,并可能推翻已评审模型 |
| 10 | 任务书:「埋点白名单 42 条」 | **41 条**(采用协调方实测;`EventDictionary.java` 41 个 `Map.entry(`,且**无测试断言该数量**) | 数量对不上被当成漏加事件排查;另需注意该表**没有计数守卫**,加事件不会有测试提醒 |
### 16.1 未取证的项(诚实标注缺口)
| 项 | 缺什么 |
| --- | --- |
| 381 / 41 两个数字 | 我**未自行重跑** `./mvnw clean test`(按任务书授权跳过耗时命令),采用协调方 Evidence Collector 的实测值;`EventDictionary` 我实测到 41 个 `Map.entry(` 与之一致 |
| `patbond-doc/site/api/openapi.yaml` | 未确认它是 mkdocs 构建产物还是需手工同步的第二份副本。影响 §13.2 第 2 项该不该手改 |
| 真实厂商侧参数 | 无厂商 → 无法给出 `lease-ttl``max_attempts`、并发上限的实证配比;§3/§7 的默认值是工程估计,接厂商时须以实测重定 |
| 配额默认值(20 / 2) | 纯工程估计,**无产品侧输入**,需 D4-5 拍板时由产品确认 |
| 单机资源余量(R11) | 未实测服务器现有 CPU/内存余量,无法判断第七个容器 + Java2D 变换的实际压力 |
---
> 本报告为**只读调研**产出:未修改任何生产代码,未编写迁移脚本,未改 `mkdocs.yml`,未执行任何 `git commit` / `push`。