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

91 KiB
Raw Blame History

02 M4 后端技术评估:AI 创作(模型目录 / 生成任务 / Worker 队列 / 输出落地)

作者:Senior Developer(后端) 日期:2026-09-14 输入:patbond-api dev@3cd8005381 测试基线,工作区干净,tag v0.4.0);契约 openapi.yaml v1.4.032 路径 / 45 操作 / 75 schema);Flyway V1V5ADR-001022 纪律:本报告全部结论以原始 Flyway SQL / 原始 openapi.yaml / 原始 Java 源码取证,逐项标注文件与行号;不采信任何文档转述


结论先行

  1. 队列方案 = DB 表轮询 + FOR UPDATE SKIP LOCKED + 租约列,不引入 Redis、不引入 MQ。决定性证据:本项目 compose 无 Redis、无任何 MQpatbond-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_queueix_generation_jobs_runningpatbond-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 枚举加宽 + PostgenerationJobId);新增 1 个错误码 42900 + 1 个 components.responses零破坏性变更
  5. 配额与 429 建议在本迭代落地(收窄版,非 M6 的横切限流):M4 验收标准写明「相同幂等请求不重复扣费」,而全项目无任何计费/额度表(目标模型 40 张表无 credits/wallet/balance),「扣费」只能落到配额语义上,因此配额是验收前置,不是可选项。
  6. 上游 AI 服务走可插拔 GenerationProvider 接口 + 真实字节 stub 实现,M4 默认 stub,真实厂商适配器为纯增加实现类。stub 必须做成两阶段(submit → poll,否则「Worker 重启不丢任务 / 不重复扣费」这条最关键的恢复路径无法被端到端验证。

章节

  1. 取证:现有可复用资产逐项清点
  2. 关键发现:目标模型已把队列机制写完了
  3. Worker 队列选型:租约 / 重试 / 幂等
  4. 生成任务状态机与三层幂等
  5. 模型 / 风格目录(catalog)方案
  6. 生成输出落地媒体链路 + 一键社区草稿
  7. 上游 AI 服务接入与本迭代可验证性
  8. 限流与配额(429 + Retry-After
  9. 模块归属:patbond-ai 与共享物上提
  10. 数据模型草案与 DDL 草稿
  11. Flyway 迁移计划
  12. API 端点清单与契约影响面
  13. 契约同步机械清单(含第 5 份快照)
  14. 风险清单
  15. 待拍板决策
  16. 本报告推翻 / 修正的既有结论

1. 取证:现有可复用资产逐项清点

以下每一行都打开过原始文件确认,没有一条来自文档转述。路径根为 patbond-api/(除标注 doc 仓者)。

1.1 数据库与迁移链

资产 取证位置 实情
迁移链持有者 patbond-user/src/main/resources/application.yml:11-12flyway.locations: classpath:db/migration);auth/pet/community 三模块 src/main/resources/只有 application.yml.sample,无 db/migration 目录 迁移只进 patbond-userV6/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;索引已建于 :95ix_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.assetsV5:45-46,107 保留 posts → identity.users / pet_health.petspost_media → media.assetsV6 一旦建出 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 存量值恒为 NULLADD 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-119createUpload / completeUpload 客户端直传,服务端从不经手字节
存储适配层 ObjectStorage.java(接口)+ S3ObjectStorage.javaAWS SDK v2 实现) 接口仅 4 个方法:ensureBucket / presignPut / stat / presignGet没有服务端 put,也没有服务端 get ← M4 必须补的两个原语
配置三份复制 patbond-user/.../MediaProperties.java:15patbond-pet/.../PetMediaProperties.java:16patbond-community/.../CommunityMediaProperties.java:15三个类同一前缀 patbond.media pet/community 只持读侧子集(无 bucket、无 uploadTtl
预签名 GET 三份复制 patbond-community/.../media/MediaUrlSigner.java:43-53patbond-pet/.../media/MediaUrlSigner.java:43-53、user 侧走 S3ObjectStorage.presignGet 两个 MediaUrlSigner 的 Javadoc 自述与对方「same as」——已知复制债,新模块会变第 4 份
凭证下发先例 docker-compose.ymlpet: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.assetsJavadoc :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-54Idempotency-Key 强制头)→ PostService.create:83-126PostRepository.insertPost:56-78ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING generation_jobsidempotency_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_idempotencyoctet_length(request_hash)=32
幂等冲突语义 PostService.java:104-118:同键同载荷 → 返回原资源;同键异载荷 → IDEMPOTENCY_PAYLOAD_MISMATCH 409/40905 M4 直接复用 40905,不新增码
草稿/发布 CreatePostRequest.java:29 status 枚举 `draft published,缺省 draftPostService.java:89);发布走 PATCH+status=publishedUpdatePostRequest.java:38`
ai_creation 可达性 DB 侧 V5:68 ck_posts_category CHECK (category IN ('general','help','ai_creation')) 已允许API 侧被 CreatePostRequest.java:26UpdatePostRequest.java:33 的 `@Pattern(regexp = "general help") 拦死;有测试固化该拦截(PostLifecycleIntegrationTest.java:102-106` 断言 400/40000
媒体挂帖校验 PostService.validateAssets:343-358 只校验 归属(非本人 → 40405 防枚举)与 ready(否则 422/42203 ⚠️ 无 purpose 校验MediaAssetRef.java:9-16MediaAssetGateway 的 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 = :expectedVersion0 行 → 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 照此形态;@EnableSchedulingUserApplication.java
全仓限流 grep `429 TOO_MANY_REQUESTS
服务间调用 Feign 静态 URLADR-002):SessionClient.java:16UserClient.java:12AuthorProfileClient.java:18 patbond-ai 若需内部调用照此形态
UUIDv7 patbond-pet/.../support/UuidV7.javapatbond-community/.../support/UuidV7.java 两份复制 新模块会变第 3 份
鉴权 BearerAuthFiltercommunity 版 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-716creation.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 由约束反推出的三条实现约定(容易踩,必须写进工单)

  1. started_at 是「本次尝试的开始时间」,不是「首次入队时间」。因为 :669 规定 status='queued'started_at IS NULL,所以每次重试重新入队都必须把 started_at 置回 NULL。若产品要展示「任务创建至今耗时」,用 created_at
  2. 重新入队必须同时清 5 个字段progress=0started_at=NULLerror_code=NULLerror_message=NULLlease_owner=NULLlease_expires_at=NULL,否则违反 :668-672
  3. 出队谓词必须带 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: / 服务名清点:postgrespostgres:18:12-20)、minio:33-47)、authuserpetcommunity无 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_requestDB 强约束 需应用层保证 需应用层保证
事务性 与任务行同事务,提交即入队,无 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 租约:单语句原子认领

-- 认领: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 NULLcompleted_at NULLoutput_asset_id NULLerror_code NULLlease_owner NOT NULLlease_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→runningrunning→succeededrunning→failedrunning→queued(重试)、queued→cancelledrunning→cancelled、以及 reaper 的 running→failed终态 3 个不可再迁移succeeded / failed / cancelled)。

「任务状态流转合法」这条验收标准由两道闸门保证:应用层的条件 UPDATEWHERE 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_iduq_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 行置 deletedstatus='deleted', deleted_at=now(),满足 ck_media_deletedV1:248)并尽力删除对象。注意这类孤儿本来就是已存在的已知缺口V1:258-260ix_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:626ck_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_modelsgeneration_styles 各有一条看似冗余的 UNIQUE (id, media_kind):568:592),它存在的唯一目的就是给这两条复合外键提供被引用的唯一约束——它把「任务的 media_kind 必须与所选模型/风格的 media_kind 一致」这条业务规则下沉成了数据库约束。抄写 V6 时不要以为它冗余而删掉。

5.2 是否需要后台可配

M4 不做后台。理由:全项目没有任何管理后台/管理端点(契约 32 条路径全为 /api/v1/** 终端用户面,无 /admindevelopment-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+creationStyleshealing/治愈动画、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_idREFERENCES media.assets(id):586),而迁移执行时刻既没有 assets 行、也没有 MinIO 里的对象,Flyway 无法种子二进制。三个选项:

选项 说明 评价
P1(推荐) V7 种子 preview_asset_id = NULL;契约里 previewUrlnullable客户端按 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_locationV1: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_modelsmodel_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 开视频时零迁移。


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,极难排查。必须写进工单并配集成测试。

顺带填坑:insertUploadingMediaAssetRepository.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,请求体加可选 generationJobIdmedia 传 output_asset_id。草稿语义已有(status 缺省 draftPostService.java:89 0 新增路径;发布仍走既有 PATCHPost 响应加 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(会改动已发布的社区写路径)。


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_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):submitpoll 分开,才能让「已提交上游 → 崩溃 → 恢复后继续轮询而非重复提交」这条路径被真实执行和测试。若接口设计成一次性 generate()L2 幂等只是纸面设计。

失败分类由 ProviderException 承载,直接决定 §3.4 走 (a) 重试还是 (b) 终态:

error_codevarchar(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(推荐) StubGenerationProvidersubmit 生成一个假 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-ttlmax_attempts 的配比
  • 上游计费与 L3 配额的对账(届时才可能需要真正的「扣费」表)
  • 内容安全审核(content_rejected 的判定归属)

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 + 业务码 42900GENERATION_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-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 同时持有 communitycreation 两个 schema,破坏纪律;且 §6.4 已论证建草稿复用既有 POST /api/v1/posts,跨进程也毫无摩擦。否决
N3:并入 patbond-user user 已持有 identity + media + platform + 迁移链,再塞 creation 会让它变成事实上的单体。否决

推荐 N1patbond-ai,端口 80858081/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.javapatbond-community/.../support/UuidV7.java
patbond.media 配置类 3 → 将成 4 MediaProperties:15 / PetMediaProperties:16 / CommunityMediaProperties:15,同一前缀
预签名 GETMediaUrlSigner / S3ObjectStorage 3 → 将成 4 两个 MediaUrlSigner 的 Javadoc 互相自述「same as」
GlobalExceptionHandler 每模块一份 community 版 :31-35
RequestHashes 1community)→ 将成 2 RequestHashes.java:22-29
契约快照 + OpenApiContract 4 → 将成 5 见 §13

建议:把 JWT 资源侧校验、UuidV7RequestHashes、存储适配层(含 §6.2 的两个新原语) 四件上提 patbond-common,作为 M4 的一个独立前置工单。理由不是洁癖,而是算术:M4 本来就要给存储适配层加两个方法,抄 4 份 vs 抄 1 份;且这批复制每多一份就多一处将来漂移的地方MediaProperties 三份已经在字段集上不一致——pet/community 版缺 bucketuploadTtl)。

代价与风险要诚实说:这会改动 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_ENDPOINTdepends_on: postgres(healthy) + user(started)(等 user 跑完 Flyway)。若采纳 §3.5 的 worker 开关且选 A2,则为八容器(api + worker 同 jar),M4 建议先七容器。

需同步更新(本报告不改,交由对应工单):backend-modules.md(模块图、职责、端口)、docker-compose.ymldeploy/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 的外键属 M5V5:20-22),本迁移不得顺手补

10.4 相对目标模型的偏离清单(仅此 3 处,全部为「不改 schema」)

# 事项 处置 理由
1 model_version_snapshot 无来源列 不加列,由 provider 适配器 version() 提供(§5.4 避免偏离已评审模型;语义更准
2 media_kindvideo 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_modelsgeneration_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_modelsgeneration_styles(含各自的 UNIQUE (id, media_kind)
  3. generation_jobs(其复合外键引用步骤 2 的唯一约束;其 input_asset_id/output_asset_id 引用 media.assetsV1 已建;user_id 引用 identity.usersV1 已建;pet_id 引用 pet_health.petsV3 已建)
  4. 索引与触发器
  5. 最后ALTER TABLE community.posts ADD CONSTRAINT(被引用表必须先存在)

11.3 是否需要 V8 及以后

不需要。本迭代没有任何需要额外迁移的项,逐条确认:

候选 是否需要迁移 取证
新增 ai_input / ai_output purpose media.assets.purpose 无 CHECKV1:205-249 逐行确认);白名单是配置项 MediaProperties.java:66
放开 category='ai_creation' V5:68ck_posts_category 已含 ai_creation;拦截在 Java @Pattern
posts.generation_job_id 加列 裸列 V5:48 已存在
ix_posts_generation_job 索引 V5:95 已建
埋点新增创作域事件 platform.product_eventsevent_name varchar(64) + props jsonbV2),白名单在 EventDictionary.javaJava 常量表,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 之后这个查询会返回 1fk_posts_generation_job 命中 LIKE '%generation%')。处置:把该断言拆成两条——generation 类 FK 期望 1region 类 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_queueix_generation_jobs_runninguq_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/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 面)。

新增 tagcreation —— 「AI 创作:模型/风格目录、生成任务提交/查询/取消(patbond-ai,异步队列)」,插入 openapi.yaml 的 tags 块(当前 11 个 tag:190-211)。

12.2 既有端点的改动(0 新路径)

端点 改动 兼容性
POST /api/v1/posts 请求体加可选 generationJobIduuid);category 枚举 [general, help][general, help, ai_creation] 向后兼容(新增可选字段 + 请求枚举加宽只会接受更多输入)
PATCH /api/v1/posts/{postId} category 枚举同上加宽 向后兼容
GET /api/v1/posts/{postId} 等所有返回 Post 的操作 Post 响应加 generationJobIdnullable 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:2030MediaNotReady:2070 +1QuotaExceeded,含 Retry-After 头)
tags 11 +1creation 12
错误码表(info.description:32-60 止于 42300 +40407、+42206、+42900
既有 schema 修改 3CreatePostRequest:3641UpdatePostRequest:3676Post:3714+ CreateMediaUploadRequest:3450 的 purpose 枚举 = 共 4

新增 8 个 schema(按项目「每个列表端点一个 *ListEnvelope、每个单资源一个 *Envelope」惯例):

  1. CreationModel
  2. CreationStylepreviewUrl 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」)。


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.yamlinfo.version1.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.javaRESOURCE 常量(:39pet 版在 :35)→ /contract/openapi-v1.5.0.yaml
  5. 四处 → 五处 frozenSnapshotIsTheExpectedContractVersion():版本 1.5.0paths 36operations 50schemas 83
  6. community 的 tag 操作集合:Post / CreatePostRequest 相关操作签名未变,集合内容不变(仅 schema 内部变化),但仍要复核
  7. 新增 patbond-ai 的 OpenApiContract + AiContractConformanceTesttag 集合 {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.assetsowner_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 资源侧校验、UuidV7RequestHashes、存储适配层含新增 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/postsgenerationJobId 时,校验 (a) 任务属调用者且 status='succeeded'(b) media 列表必须包含 job.output_asset_id

理由:社区侧挂帖完全没有 purpose 校验(实测 MediaAssetRef.java:9-16MediaAssetGateway 的 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 NULLgeneration_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-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 只校验归属与 readyMediaAssetRef.java:9-16MediaAssetGateway 的 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-19V3:14-19 的原话都是「FKs into schemas not yet migrated are STRIPPED」——理由是「目标 schema 尚不存在」,不是反对跨 schema 外键。反证三条:V1:262-264V5:45-46V5:107 都保留了跨 schema 外键 若误读为「本项目政策上避免跨 schema 外键」→ 该补的 FK 不补,generation_job_id 永久裸列,且与目标模型漂移
4 任务书:「api 379 测试」 381(采用协调方 Evidence Collector 实测;契约冻结报告自身写的是「379→381」,releases.md 未跟新) 增量基数错 2,后续「测试数对不上」被当成丢测试排查
5 我自己的中途假设:「契约的 category 请求枚举已含 ai_creation,只需改 Java」 不成立。请求侧 CreatePostRequest:3657UpdatePostRequest:3697 的枚举都是 [general, help],只有 description 提到预留;响应侧 Post:3747FeedCard: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-ttlmax_attempts、并发上限的实证配比;§3/§7 的默认值是工程估计,接厂商时须以实测重定
配额默认值(20 / 2 纯工程估计,无产品侧输入,需 D4-5 拍板时由产品确认
单机资源余量(R11 未实测服务器现有 CPU/内存余量,无法判断第七个容器 + Java2D 变换的实际压力

本报告为只读调研产出:未修改任何生产代码,未编写迁移脚本,未改 mkdocs.yml,未执行任何 git commit / push