八角色并行开工分析,合计 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>
91 KiB
02 M4 后端技术评估:AI 创作(模型目录 / 生成任务 / Worker 队列 / 输出落地)
作者:Senior Developer(后端) 日期:2026-09-14 输入:patbond-api dev@3cd8005(381 测试基线,工作区干净,tag v0.4.0);契约 openapi.yaml v1.4.0(32 路径 / 45 操作 / 75 schema);Flyway V1
V5;ADR-001022 纪律:本报告全部结论以原始 Flyway SQL / 原始 openapi.yaml / 原始 Java 源码取证,逐项标注文件与行号;不采信任何文档转述
结论先行
- 队列方案 = 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 等于让这批列变成死重,并把任务状态真值劈成两份。 - 需要新模块
patbond-ai(:8085),沿 ADR-009 / ADR-017 两次先例(新模块 + 共库 + patbond-user 单迁移链)。但新模块会把已存在的 3 份复制代码变成 4 份(BearerAuthFilter自述「第三份拷贝」),故建议同波次把 4 件共享物上提 patbond-common。 - Flyway 迁移 2 个:
V6__creation_baseline.sql(建 creation schema 三表 + 索引 + 触发器 + 补回posts.generation_job_id外键)、V7__creation_catalog_seed.sql(模型/风格目录种子)。沿 V3(结构)/ V4(种子)拆分先例。 - 契约影响面:纯增量,v1.4.0 → v1.5.0。新增 4 路径 / 5 操作 / 8 schema;改动 3 个既有 schema(2 处 category 枚举加宽 +
Post加generationJobId);新增 1 个错误码 42900 + 1 个components.responses。零破坏性变更。 - 配额与 429 建议在本迭代落地(收窄版,非 M6 的横切限流):M4 验收标准写明「相同幂等请求不重复扣费」,而全项目无任何计费/额度表(目标模型 40 张表无 credits/wallet/balance),「扣费」只能落到配额语义上,因此配额是验收前置,不是可选项。
- 上游 AI 服务走可插拔
GenerationProvider接口 + 真实字节 stub 实现,M4 默认 stub,真实厂商适配器为纯增加实现类。stub 必须做成两阶段(submit → poll),否则「Worker 重启不丢任务 / 不重复扣费」这条最关键的恢复路径无法被端到端验证。
章节
- 取证:现有可复用资产逐项清点
- 关键发现:目标模型已把队列机制写完了
- Worker 队列选型:租约 / 重试 / 幂等
- 生成任务状态机与三层幂等
- 模型 / 风格目录(catalog)方案
- 生成输出落地媒体链路 + 一键社区草稿
- 上游 AI 服务接入与本迭代可验证性
- 限流与配额(429 + Retry-After)
- 模块归属:patbond-ai 与共享物上提
- 数据模型草案与 DDL 草稿
- Flyway 迁移计划
- API 端点清单与契约影响面
- 契约同步机械清单(含第 5 份快照)
- 风险清单
- 待拍板决策
- 本报告推翻 / 修正的既有结论
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`) |
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) |
| 媒体挂帖校验 | 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 |
| 服务间调用 | Feign 静态 URL(ADR-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 |
| 测试基线 | 381(采用协调方 Evidence Collector 实测值;我未重跑 mvnw test,按任务书授权跳过耗时命令) |
增量估算以 381 为基数 |
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(防重复提交/重复扣费) |
配套两条部分索引,逐列对齐两条出队/回收语句:
-- :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 由约束反推出的三条实现约定(容易踩,必须写进工单)
started_at是「本次尝试的开始时间」,不是「首次入队时间」。因为:669规定status='queued'时started_at IS NULL,所以每次重试重新入队都必须把started_at置回 NULL。若产品要展示「任务创建至今耗时」,用created_at。- 重新入队必须同时清 5 个字段:
progress=0、started_at=NULL、error_code=NULL、error_message=NULL、lease_owner=NULL、lease_expires_at=NULL,否则违反:668-672。 - 出队谓词必须带
attempt_count < max_attempts。:664-666规定attempt_count <= max_attempts,而认领动作会attempt_count+1;一个attempt_count = max_attempts的行若还留在queued,认领即违约。正确做法是失败时直接判终态,但出队谓词加这一条作为防御。
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. Redis(Streams 消费组) | C. MQ(RabbitMQ / Kafka) |
|---|---|---|---|
| 新增容器 | 0 | +1 | +1~3(Kafka 还需协调进程) |
| 与已评审模型契合 | 完全契合,用满 §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/MQ,B/C 都是净新增运维面;ADR-002 的既有判例(为同样理由移除 Nacos)与 development-plan 把 Outbox/限流排到 M6 的排期,都指向「M4 不引入消息中间件」。
A 的已知代价(诚实列出):出队有轮询延迟;worker 数量扩大到几十个时轮询会成为无谓负载。两者在 MVP 量级都不成立,且都有不改数据模型的升级路径(LISTEN/NOTIFY 降延迟;分片或改中间件时任务表仍是状态真值)。
3.3 租约:单语句原子认领
-- 认领: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 一次:
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 重试:退避 + 回收
-- (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 无需任何逻辑变更。
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 量级不成立。
5. 模型 / 风格目录(catalog)方案
5.1 配置文件 vs 数据库表 —— 这题已被外键锁死
generation_jobs 对目录是复合外键引用(patbond_postgresql.sql:644-647):
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 的 CHECK,API 侧枚举只开 image —— 这正是 ai_creation 当年的处理手法(DB CHECK 允许、契约描述标注预留、@Pattern 拦截),有现成先例可循,M5/M6 开视频时零迁移。
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. ★ 单独事务立刻持久化 providerRequestId(L2 幂等,见 §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.assets:owner_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):
job.user_id = 调用者且job.status='succeeded',否则 404(防枚举,沿 40405/40403 惯例)- 是否强制要求 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(会改动已发布的社区写路径)。
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 可插拔接口(推荐)
public interface GenerationProvider {
String code(); // → provider_code_snapshot(varchar(64))
String version(); // → model_version_snapshot(varchar(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的判定归属)
8. 限流与配额(429 + Retry-After)
8.1 该不该在本迭代做
该做,但只做收窄版。 三条理由:
- 验收标准依赖它。
development-plan.md:255写「相同幂等请求不重复扣费或生成」。而目标模型 40 张表无任何计费/额度表(§1.2 取证),全项目也无余额概念。「扣费」在 M4 唯一可落地的解释就是配额消耗。没有配额,这条验收标准无从验证。 - AI 生成天然需要。它是全项目第一个单次调用成本显著 > 0(真实厂商按次计费)、且单用户可无限触发的写接口。既有写接口(发帖/评论/点赞)都无此性质。
- 成本可控。见 8.3,推荐方案零新表、零新依赖。
但要与 M6 划清界限:development-plan.md:272 把「限流、审计、结构化日志、指标、Trace、告警」整体排在 M6。M4 做的不是横切限流基础设施(无网关级令牌桶、无全端点覆盖、无 Redis 计数器),而是创作域一个端点的业务配额。这个区分要写进 ADR,否则容易被误读为「M6 的限流提前做了」而在 M6 重复投入。
8.2 现状取证
patbond-*/src/maingrep429|TOO_MANY_REQUESTS|Retry-After|RateLimit|rateLimit→ 零命中openapi.yamlgrep 同上 → 零命中(无 429 响应、无 RateLimit/Retry-After 头)ErrorCode.java:12-3827 个码中 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)。
9. 模块归属:patbond-ai 与共享物上提
9.1 新模块 vs 并入既有模块
| 选项 | 评价 |
|---|---|
N1(推荐):新建 patbond-ai(:8085) |
沿 ADR-009(M2 建 patbond-pet)与 ADR-017(M3 建 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 |
1(community)→ 将成 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>。
10. 数据模型草案与 DDL 草稿
总原则:抄写目标模型,不重新设计。 下面的 DDL 与 patbond-doc/docs/database/patbond_postgresql.sql:556-716 逐列一致;所有偏离都在 §10.4 单独列出并附理由。字段含义已在 §2/§4/§5 逐项论证,此处不重复。
10.1 目录两表(DDL 草稿)
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 草稿)
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 触发器与外键补回
-- 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 的外键属 M5(V5: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 三表与目标模型逐列一致。
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 内部必须严格按此顺序:
CREATE SCHEMA creationgeneration_models、generation_styles(含各自的UNIQUE (id, media_kind))generation_jobs(其复合外键引用步骤 2 的唯一约束;其input_asset_id/output_asset_id引用media.assets,V1 已建;user_id引用identity.users,V1 已建;pet_id引用pet_health.pets,V3 已建)- 索引与触发器
- 最后才
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
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_id(creation 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)。
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/40405(inputAsset 不存在/非本人/已删)、404/40401(petId 不可见)、409/40905(同键异载荷)、422/42203(inputAsset 非 ready)、422/42206(model/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」惯例):
CreationModelCreationStyle(previewUrlnullable,§5.3 P1)CreationCatalog({models, styles})CreationCatalogEnvelopeCreateGenerationJobRequestGenerationJob(详情与列表项共用,含status/progress/errorCode枚举 /outputAssetId/outputUrl/modelCode/styleCode/createdAt/completedAt)GenerationJobEnvelopeGenerationJobListEnvelope({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」)。
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 解析(:54new 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 即红)
- doc 仓
docs/api/openapi.yaml:info.version→1.5.0;错误码表加 3 行;tags 加creation;新增 4 路径 / 8 schema / 1 response;改 4 个既有 schema patbond-doc/site/api/openapi.yaml(当前与正典字节相同的站点镜像;未取证它是否由 mkdocs 构建自动产出,若为构建产物则不手改)- 四份 → 五份 快照文件:
patbond-{auth,user,pet,community,ai}/src/test/resources/contract/openapi-v1.5.0.yaml(新文件名,旧的 v1.4.0 按既有做法删除) - 四份 → 五份
OpenApiContract.java的RESOURCE常量(:39,pet 版在:35)→/contract/openapi-v1.5.0.yaml - 四处 → 五处
frozenSnapshotIsTheExpectedContractVersion():版本1.5.0、paths36、operations50、schemas83 - community 的 tag 操作集合:
Post/CreatePostRequest相关操作签名未变,集合内容不变(仅 schema 内部变化),但仍要复核 - 新增 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 同一节奏。
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 |
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 凭证的先例。备选 M3x(user 开 /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 约 6s,X2(仅 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-2(stub)── 独立,先拍
D4-3(上提)──┬─► D4-4(写 media 归属):D4-3 选 M2 时,D4-4 建议改 M3x
D4-5(配额)── 独立(默认值需产品确认)
D4-6 / D4-7 / D4-10 ── 需与客户端 agent 对齐后落契约
D4-8 / D4-9 ── 低风险,可随实现工单一并确认
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。