# 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-001~022 > 纪律:本报告全部结论以**原始 Flyway SQL / 原始 openapi.yaml / 原始 Java 源码**取证,逐项标注文件与行号;不采信任何文档转述 --- ## 结论先行 1. **队列方案 = DB 表轮询 + `FOR UPDATE SKIP LOCKED` + 租约列**,不引入 Redis、不引入 MQ。决定性证据:本项目 compose **无 Redis、无任何 MQ**(`patbond-api/docker-compose.yml` 仅 postgres/minio/auth/user/pet/community 六服务),而**已评审目标模型早已为 DB 队列备好全部列与两条部分索引**——`lease_owner` / `lease_expires_at` / `next_attempt_at` / `attempt_count` / `max_attempts` / `priority`,以及 `ix_generation_jobs_queue`、`ix_generation_jobs_running`(`patbond-doc/docs/database/patbond_postgresql.sql:605-716`)。换 Redis/MQ 等于让这批列变成死重,并把任务状态真值劈成两份。 2. **需要新模块 `patbond-ai`(:8085)**,沿 ADR-009 / ADR-017 两次先例(新模块 + 共库 + patbond-user 单迁移链)。但新模块会把已存在的 3 份复制代码变成 4 份(`BearerAuthFilter` 自述「第三份拷贝」),故建议**同波次把 4 件共享物上提 patbond-common**。 3. **Flyway 迁移 2 个**:`V6__creation_baseline.sql`(建 creation schema 三表 + 索引 + 触发器 + **补回 `posts.generation_job_id` 外键**)、`V7__creation_catalog_seed.sql`(模型/风格目录种子)。沿 V3(结构)/ V4(种子)拆分先例。 4. **契约影响面:纯增量**,v1.4.0 → **v1.5.0**。新增 4 路径 / 5 操作 / 8 schema;改动 3 个既有 schema(2 处 category 枚举加宽 + `Post` 加 `generationJobId`);新增 1 个错误码 42900 + 1 个 `components.responses`。**零破坏性变更**。 5. **配额与 429 建议在本迭代落地**(收窄版,非 M6 的横切限流):M4 验收标准写明「相同幂等请求不重复**扣费**」,而全项目**无任何计费/额度表**(目标模型 40 张表无 credits/wallet/balance),「扣费」只能落到配额语义上,因此配额是验收前置,不是可选项。 6. **上游 AI 服务走可插拔 `GenerationProvider` 接口 + 真实字节 stub 实现**,M4 默认 stub,真实厂商适配器为纯增加实现类。stub 必须做成**两阶段(submit → poll)**,否则「Worker 重启不丢任务 / 不重复扣费」这条最关键的恢复路径无法被端到端验证。 --- ## 章节 1. [取证:现有可复用资产逐项清点](#1) 2. [关键发现:目标模型已把队列机制写完了](#2) 3. [Worker 队列选型:租约 / 重试 / 幂等](#3) 4. [生成任务状态机与三层幂等](#4) 5. [模型 / 风格目录(catalog)方案](#5) 6. [生成输出落地媒体链路 + 一键社区草稿](#6) 7. [上游 AI 服务接入与本迭代可验证性](#7) 8. [限流与配额(429 + Retry-After)](#8) 9. [模块归属:patbond-ai 与共享物上提](#9) 10. [数据模型草案与 DDL 草稿](#10) 11. [Flyway 迁移计划](#11) 12. [API 端点清单与契约影响面](#12) 13. [契约同步机械清单(含第 5 份快照)](#13) 14. [风险清单](#14) 15. [待拍板决策](#15) 16. [本报告推翻 / 修正的既有结论](#16) --- ## 1. 取证:现有可复用资产逐项清点 以下每一行都打开过原始文件确认,**没有一条来自文档转述**。路径根为 `patbond-api/`(除标注 doc 仓者)。 ### 1.1 数据库与迁移链 | 资产 | 取证位置 | 实情 | | --- | --- | --- | | 迁移链持有者 | `patbond-user/src/main/resources/application.yml:11-12`(`flyway.locations: classpath:db/migration`);auth/pet/community 三模块 `src/main/resources/` 下**只有 application.yml.sample,无 db/migration 目录** | 迁移只进 patbond-user,**V6/V7 无选择余地**,即便新建 patbond-ai 模块也一样 | | 现有迁移 | `patbond-user/src/main/resources/db/migration/` 共 5 个:V1 identity+media+platform、V2 platform.product_events、V3 pet_health、V4 字典种子、V5 community | **下一个版本号 = V6**(ADR-022 明确「V6 留给后续真正需要建表的迭代」,`decisions.md:192`) | | 结构/种子拆分先例 | V3 = 纯结构;V4 = 纯种子(`V4__pet_health_dictionary_seed.sql:1-13` 注释自述「production reference data, not development fixtures」,2 条 INSERT) | M4 照此拆 V6(结构)+ V7(目录种子) | | `posts.generation_job_id` | `V5__community_baseline.sql:47-48`:`-- generation_job_id: bare nullable uuid, FK to creation.generation_jobs stripped (M4 补回)` / `generation_job_id uuid,` — **零 `REFERENCES`**;索引已建于 `:95`(`ix_posts_generation_job`) | 裸列 + 索引俱在,V6 只需 `ADD CONSTRAINT`,**不需要加列、不需要建索引** | | 裁剪理由是否仍成立 | `V5:15-19` 原文:「Cross-schema FKs into schemas **not yet migrated** are STRIPPED」;`V3__pet_health_baseline.sql:14-19` 同一措辞裁掉 4 条 marketplace 外键 | 裁剪理由是「目标 schema 尚不存在」,**不是「反对跨 schema 外键」**。反证:`V1:262-264` 保留 `identity.users.avatar_asset_id → media.assets`;`V5:45-46,107` 保留 `posts → identity.users / pet_health.pets`、`post_media → media.assets`。**V6 一旦建出 creation schema,裁剪理由即消失,补回外键是 V5 自己下的指令** | | 存量数据风险 | 全仓 grep `generation_job_id` 命中仅 3 处:V5 SQL、`CommunityMigrationIntegrationTest`、以及**无任何 INSERT/UPDATE 写它**(`PostRepository.insertPost` 列清单为 id/author_user_id/pet_id/category/title/content/status/published_at/idempotency_key/request_hash,见 `PostRepository.java:56-78`) | 存量值恒为 NULL,`ADD CONSTRAINT` **无需 `NOT VALID`**,零回填风险 | | `region_id` | `V5:54-55` 同为裸列(M5 补回) | **本迭代不碰**,V6 不得顺手补它 | ### 1.2 目标模型(doc 仓,已评审) | 资产 | 取证位置 | 实情 | | --- | --- | --- | | creation schema 全量 DDL | `patbond-doc/docs/database/patbond_postgresql.sql:556-716` | 三表 `generation_models` / `generation_styles` / `generation_jobs` + 13 条索引已完整写好,**M4 是抄写不是设计**(详见 §2) | | schema 注释 | 同上 `:68` `COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation';` | V6 照抄 | | 触发器 | 同上 `:1245-1250` 三张表各一条 `platform.set_updated_at()` 触发器 | 函数 V1 已建(`V1:28-36`),V6 直接挂 | | 全库规模 | 同上:7 schema / 40 张 `CREATE TABLE` | 建完 V6 后为 6 个 schema(缺 marketplace,属 M5) | | **无计费表** | 同上全文件 `CREATE TABLE` 清单无 credits / wallet / balance / ledger 任何一张 | 验收标准里的「不重复**扣费**」只能解释为**配额**语义(见 §8) | ### 1.3 媒体链路(M3 交付,ADR-016/017) | 资产 | 取证位置 | 实情 | | --- | --- | --- | | `media.assets` 表 | `V1:205-260` | 有 `purpose varchar(32) NOT NULL`,**全表无 `purpose` 的 CHECK 约束**(`:225-248` 六条 ck 分别管 kind/storage_type/location/size/hash/dimensions/status/ready/deleted) | | purpose 白名单 | `patbond-user/.../media/MediaProperties.java:66` `List.of("post_image", "user_avatar", "pet_avatar")`,Javadoc `:57-65` 自述「新增用途是配置 + 契约枚举变更,never a migration」 | **新增 `ai_input` / `ai_output` 零迁移**,改配置 + 契约枚举即可 | | 两步上传 | `MediaService.java:51-119`(createUpload / completeUpload) | 客户端直传,**服务端从不经手字节** | | 存储适配层 | `ObjectStorage.java`(接口)+ `S3ObjectStorage.java`(AWS SDK v2 实现) | 接口仅 4 个方法:`ensureBucket` / `presignPut` / `stat` / `presignGet`。**没有服务端 put,也没有服务端 get** ← M4 必须补的两个原语 | | 配置三份复制 | `patbond-user/.../MediaProperties.java:15`、`patbond-pet/.../PetMediaProperties.java:16`、`patbond-community/.../CommunityMediaProperties.java:15` — **三个类同一前缀 `patbond.media`** | pet/community 只持读侧子集(无 bucket、无 uploadTtl) | | 预签名 GET 三份复制 | `patbond-community/.../media/MediaUrlSigner.java:43-53`、`patbond-pet/.../media/MediaUrlSigner.java:43-53`、user 侧走 `S3ObjectStorage.presignGet` | 两个 `MediaUrlSigner` 的 Javadoc 自述与对方「same as」——**已知复制债,新模块会变第 4 份** | | 凭证下发先例 | `docker-compose.yml`:pet(`:106-109`)与 community(`:139-141`)**都拿到了 `PATBOND_MINIO_ACCESS_KEY/SECRET_KEY`**,注释说明「本地 SigV4 计算,不直连 MinIO,无需 depends_on minio」 | 业务模块持有对象存储凭证是**既有实践**,patbond-ai 照此拿凭证不破规矩 | | 跨 schema 读媒体先例 | `patbond-community/.../media/MediaAssetGateway.java:37-47` 直接 SQL 读 `media.assets`;Javadoc `:15-23` 自述「Same-database read was chosen over an internal HTTP call to patbond-user (ADR-017 precedent)」 | patbond-ai 跨 schema 读 `media.assets` 有先例;**写** 侧无先例(见 §6 与 D4-4) | ### 1.4 社区侧(M3 交付) | 资产 | 取证位置 | 实情 | | --- | --- | --- | | 建帖幂等 | `PostController.java:47-54`(`Idempotency-Key` **强制头**)→ `PostService.create:83-126` → `PostRepository.insertPost:56-78`(`ON CONFLICT ON CONSTRAINT uq_posts_author_idempotency DO NOTHING`) | 与 `generation_jobs` 的 `idempotency_key NOT NULL` + `request_hash NOT NULL` + `UNIQUE(user_id, idempotency_key)` **形状完全一致**,可原样复用 | | request_hash 算法 | `PostService.canonicalize:361-375`(规范化命令而非原始 JSON)+ `RequestHashes.sha256:22-29` | 32 字节,匹配 `ck_generation_jobs_idempotency` 的 `octet_length(request_hash)=32` | | 幂等冲突语义 | `PostService.java:104-118`:同键同载荷 → 返回原资源;同键异载荷 → `IDEMPOTENCY_PAYLOAD_MISMATCH` **409/40905** | M4 直接复用 40905,不新增码 | | 草稿/发布 | `CreatePostRequest.java:29` status 枚举 `draft|published`,缺省 draft(`PostService.java:89`);发布走 `PATCH` + `status=published`(`UpdatePostRequest.java:38`) | **「一键建草稿」不需要新端点**,`POST /api/v1/posts` 已具备 | | `ai_creation` 可达性 | DB 侧 `V5:68` `ck_posts_category CHECK (category IN ('general','help','ai_creation'))` **已允许**;API 侧被 `CreatePostRequest.java:26` 与 `UpdatePostRequest.java:33` 的 `@Pattern(regexp = "general|help")` 拦死;有测试固化该拦截(`PostLifecycleIntegrationTest.java:102-106` 断言 400/40000) | 解锁 = 改 2 处 `@Pattern` + 改 1 处测试 + 契约枚举加宽,**零迁移** | | 媒体挂帖校验 | `PostService.validateAssets:343-358` 只校验 **归属**(非本人 → 40405 防枚举)与 **ready**(否则 422/42203) | ⚠️ **无 purpose 校验**:`MediaAssetRef.java:9-16` 与 `MediaAssetGateway` 的 SQL 都**不含 purpose 列**。对比 pet 侧确有校验(`PetService.java:196` `AVATAR_PURPOSE.equals(asset.purpose())`)。这修正了任务书「走 assets / purpose 白名单」的前提——**社区侧目前没有 purpose 闸门**(见 §6 与 D4-6) | | 乐观锁 | `PostService.java:141-143` 比 version + `PostRepository.updatePost:134-152` `WHERE ... AND version = :expectedVersion`,0 行 → 409/40902 | 生成任务**不需要** version 乐观锁(客户端不改任务字段),但表里有 `version` 列备用 | ### 1.5 平台能力 | 资产 | 取证位置 | 实情 | | --- | --- | --- | | 错误码体系 | `patbond-common/.../error/ErrorCode.java:12-38`(27 个码);命名法 = HTTP 三位 + 两位序号 | 已用满:404 段至 40406、409 段至 40905、422 段至 42205。**429 段完全空白** | | 定时任务先例 | `patbond-user/.../session/SessionCleanupJob.java:32-41`(`@Scheduled(fixedDelayString=..., initialDelayString=...)`,间隔走配置项) | Worker 轮询与 reaper 照此形态;`@EnableScheduling` 见 `UserApplication.java` | | 全仓限流 | grep `429|TOO_MANY_REQUESTS|Retry-After|RateLimit` over `patbond-*/src/main` → **零命中**;契约同 grep → **零命中** | 429 是**全新地基**,非改造 | | 服务间调用 | Feign 静态 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|ai_` → **零命中** | M4 新增创作域事件 = 改这一个 Java 常量表,零迁移、零契约变更(`/api/v1/events` 形状不变) | | 测试基线 | **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(防重复提交/重复扣费) | 配套两条**部分索引**,逐列对齐两条出队/回收语句: ```sql -- :711-713 出队:正是 (priority DESC, next_attempt_at, created_at, id) 的排序键 CREATE INDEX ix_generation_jobs_queue ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id) WHERE status = 'queued'; -- :714-716 回收:正是「租约已过期的 running」的扫描键 CREATE INDEX ix_generation_jobs_running ON creation.generation_jobs (lease_expires_at, id) WHERE status = 'running'; -- :699-701 上游幂等:同一 provider 的同一 request_id 只允许一行 CREATE UNIQUE INDEX uq_generation_jobs_provider_request ON creation.generation_jobs (provider_code_snapshot, provider_request_id) WHERE provider_request_id IS NOT NULL; ``` 并且 `ck_generation_jobs_state`(`:667-691`)与 `ck_generation_jobs_lease`(`:692-695`)**把状态机与租约的合法组合写进了 DB 约束**——五个状态各自规定了 `started_at` / `completed_at` / `output_asset_id` / `error_code` / `lease_owner` / `lease_expires_at` / `progress` 的取值,`(lease_owner IS NULL) = (lease_expires_at IS NULL)` 且 `lease_expires_at > started_at`。 **推论**:目标模型评审时事实上已经拍过「DB 表即队列」。选 Redis 或 MQ 不是「加一个组件」,而是**推翻已评审的数据模型**(这批列与三条索引全部作废,任务状态真值劈成 DB + 中间件两份)。 ### 2.1 由约束反推出的三条实现约定(容易踩,必须写进工单) 1. **`started_at` 是「本次尝试的开始时间」,不是「首次入队时间」**。因为 `:669` 规定 `status='queued'` 时 `started_at IS NULL`,所以每次重试重新入队都必须把 `started_at` 置回 NULL。若产品要展示「任务创建至今耗时」,用 `created_at`。 2. **重新入队必须同时清 5 个字段**:`progress=0`、`started_at=NULL`、`error_code=NULL`、`error_message=NULL`、`lease_owner=NULL`、`lease_expires_at=NULL`,否则违反 `:668-672`。 3. **出队谓词必须带 `attempt_count < max_attempts`**。`:664-666` 规定 `attempt_count <= max_attempts`,而认领动作会 `attempt_count+1`;一个 `attempt_count = max_attempts` 的行若还留在 `queued`,认领即违约。正确做法是失败时直接判终态,但出队谓词加这一条作为防御。 --- ## 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 租约:单语句原子认领 ```sql -- 认领:SKIP LOCKED 让 N 个 worker 拿到互不相交的集合,且互不阻塞 UPDATE creation.generation_jobs j SET status = 'running', started_at = now(), lease_owner = :owner, -- 如 "ai-1/pid-17/uuid" lease_expires_at = now() + :leaseTtl, -- 建议 120s attempt_count = attempt_count + 1, progress = 0, updated_at = now() WHERE j.id IN ( SELECT id FROM creation.generation_jobs WHERE status = 'queued' AND next_attempt_at <= now() AND attempt_count < max_attempts -- §2.1 第 3 条 ORDER BY priority DESC, next_attempt_at, created_at, id -- 对齐 ix_generation_jobs_queue FOR UPDATE SKIP LOCKED LIMIT :batch -- 建议 1~2 ) RETURNING j.id, j.media_kind, j.model_id, j.style_id, j.input_asset_id, j.prompt, j.negative_prompt, j.width_px, j.height_px, j.parameters, j.provider_code_snapshot, j.provider_model_snapshot, j.provider_request_id, j.attempt_count, j.user_id; ``` 合法性核对:`status='running'` 分支(`:673-677`)要求 `started_at NOT NULL`、`completed_at NULL`、`output_asset_id NULL`、`error_code NULL`、`lease_owner NOT NULL`、`lease_expires_at NOT NULL` —— 全部满足;`ck_generation_jobs_lease` 要求 `lease_expires_at > started_at`,而 `now()` 在同一事务内为同一时刻、`leaseTtl > 0`,严格成立。 **心跳续租**(长任务防误回收),每 30s 一次: ```sql UPDATE creation.generation_jobs SET lease_expires_at = now() + :leaseTtl, progress = :progress, updated_at = now() WHERE id = :id AND status = 'running' AND lease_owner = :owner; ``` 返回 0 行 = 租约已被夺走或任务已被取消 → **worker 立即放弃本次执行**(这也是取消能生效的机制,见 §4.3)。 ### 3.4 重试:退避 + 回收 ```sql -- (a) 可重试失败 → 重新入队(§2.1 第 2 条:必须清 5 个字段) UPDATE creation.generation_jobs SET status = 'queued', progress = 0, started_at = NULL, error_code = NULL, error_message = NULL, lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = now() + :backoff, -- 10s * 2^(attempt-1) + 抖动 updated_at = now() WHERE id = :id AND status = 'running' AND lease_owner = :owner AND attempt_count < max_attempts; -- (b) 不可重试 或 次数耗尽 → 终态 UPDATE creation.generation_jobs SET status = 'failed', completed_at = now(), error_code = :errorCode, error_message = :errorMessage, lease_owner = NULL, lease_expires_at = NULL, updated_at = now() WHERE id = :id AND status = 'running' AND lease_owner = :owner; -- (c) reaper:租约过期的孤儿(worker 崩溃/重启),走 ix_generation_jobs_running UPDATE creation.generation_jobs SET status = CASE WHEN attempt_count < max_attempts THEN 'queued' ELSE 'failed' END, progress = 0, started_at = CASE WHEN attempt_count < max_attempts THEN NULL ELSE started_at END, completed_at = CASE WHEN attempt_count < max_attempts THEN NULL ELSE now() END, error_code = CASE WHEN attempt_count < max_attempts THEN NULL ELSE 'lease_expired' END, lease_owner = NULL, lease_expires_at = NULL, next_attempt_at = now() + :backoff, updated_at = now() WHERE status = 'running' AND lease_expires_at < now(); ``` (c) 就是验收标准「**Worker 重启不丢任务**」的实现:任务一直在 PG 里,重启后租约到期即被回收重投。reaper 建议 30s 一跑,与 `SessionCleanupJob.java:32-34` 同形态(`@Scheduled(fixedDelayString="${...}")`,间隔走配置)。 ### 3.5 Worker 跑在哪 | 选项 | 说明 | 评价 | | --- | --- | --- | | **A1(推荐)** | 与 API 同进程,`@Scheduled` 轮询 + 有界线程池,由 `patbond.creation.worker.enabled` 开关控制 | 零新增容器;先例 `SessionCleanupJob`。**关键**:开关从第一天就做,则 A2 变成纯部署动作、零代码改动 | | A2 | 同一 jar 起第 7 个容器,`worker.enabled=true` 且不暴露端口 | 隔离更好,M4 不必做;有开关后随时可切 | | A3 | 独立 worker 模块/镜像 | 过度设计,否决 | 推荐 **A1 + 开关**:单容器交付,但把 A2 的门留着。租约机制本来就允许多实例并存,A1→A2 无需任何逻辑变更。 --- ## 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`): ```sql FOREIGN KEY (model_id, media_kind) REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT, FOREIGN KEY (style_id, media_kind) REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT, ``` 而 `model_id uuid NOT NULL`(`:610`)。**配置文件方案无法满足 NOT NULL 外键**(没有行可指)。所以:**必须是数据库表**,无可选项。 顺带说明这两个外键为什么是复合的:`generation_models` 与 `generation_styles` 各有一条看似冗余的 `UNIQUE (id, media_kind)`(`:568`、`:592`),它存在的唯一目的就是给这两条复合外键提供被引用的唯一约束——它把「任务的 media_kind 必须与所选模型/风格的 media_kind 一致」这条业务规则**下沉成了数据库约束**。抄写 V6 时不要以为它冗余而删掉。 ### 5.2 是否需要后台可配 **M4 不做后台**。理由:全项目**没有任何管理后台/管理端点**(契约 32 条路径全为 `/api/v1/**` 终端用户面,无 `/admin`;`development-plan.md` 也未在 M4 前排入后台)。目录变更用两条既有手段覆盖: - `enabled boolean`(`:563`、`:587`)+ `sort_order integer`(`:564`、`:588`):上下架与排序**改一条 SQL 即可**,不需要发版。 - 新增条目:后续 Flyway 迁移追加(V4 已是这个先例——`V4:5-8` 自述字典是「production reference data」,走版本链而非 dev fixture)。 真正需要运营自助时再单开 `/admin` 迭代。 ### 5.3 种子内容与风格预览图的坑 客户端 mock 已有现成的目录内容可直接对齐(`patbond-flutter/lib/data/demo_data.dart:206+` 的 `creationStyles`:`healing`/治愈动画、`3d`/3D 卡通、`comic`/漫画风、`watercolor`/水彩 …;`patbond-flutter/lib/features/create/create_page.dart:179` 的模型下拉 `['Patbond-V1','Pet-Art Pro','Cute Motion']`)。V7 种子建议直接采用这批 code/title/subtitle,使客户端替换 mock 时**文案零变化**。 ⚠️ **预览图取不到**:`generation_styles.preview_asset_id` 是 `REFERENCES media.assets(id)`(`:586`),而迁移执行时刻**既没有 assets 行、也没有 MinIO 里的对象**,Flyway 无法种子二进制。三个选项: | 选项 | 说明 | 评价 | | --- | --- | --- | | **P1(推荐)** | V7 种子 `preview_asset_id = NULL`;契约里 `previewUrl` 为 `nullable`;**客户端按 `code` 用内置图片资源**做预览 | 零运维动作、零二进制入库;风格预览是装饰性内容,本就适合随包分发。需与客户端 agent 对齐 `code` 契约 | | P2 | 上线后人工走一次 `/api/v1/media/uploads` 上传 4 张图,再 `UPDATE generation_styles SET preview_asset_id=...` | 引入不可复现的手工步骤(三环境各做一次),与 ADR-006「测试可复现」精神冲突 | | P3 | 用 `storage_type='external'` 的 assets 行指向外链图 | `ck_media_location`(`V1:227-238`)允许 external,但把外部 URL 写进生产种子等于引入外部依赖,且 M3.5 已因「需接外部服务」为由把天气/位置推迟(`decisions.md:188`),同理否决 | 推荐 **P1**,并按 ADR-022 的纪律在代码注释与本报告显式标注为「刻意的客户端内置资源,非缺陷」。→ 待拍板 **D4-7**。 ### 5.4 `model_version_snapshot` 的来源缺口(取证发现的模型不自洽) `generation_jobs.model_version_snapshot varchar(64) **NOT NULL**`(`:623`),但 `generation_models` 表(`:556-574`)**没有任何 version 列**——`code` / `display_name` / `provider_code` / `provider_model_name` / `media_kind` / `enabled` / `sort_order` / 时间戳,仅此。所以这个 NOT NULL 值无处可取。 | 选项 | 说明 | 评价 | | --- | --- | --- | | **V1x(推荐)** | 由 provider 适配器声明版本(`GenerationProvider.version()`),提交时快照写入 | 零 schema 偏离;语义上也更准——真正决定输出的是**适配器/厂商模型版本**,不是目录行 | | V2x | V6 给 `generation_models` 加 `model_version varchar(64)` 列 | 偏离已评审目标模型;且目录行的版本与厂商实际版本会漂移 | 推荐 **V1x**。→ 待拍板 **D4-8**(低风险,但因涉及是否偏离目标模型,列入清单)。 ### 5.5 图片先行、视频后置 目标模型的 `media_kind` CHECK 是 `IN ('image','video')`(`:569`、`:593`、`:648`)。ADR-018 已明确「视频后置」(`decisions.md:161`)。建议**DB 侧照抄含 video 的 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`): 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 可插拔接口(推荐) ```java 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|`,用 `@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 该不该在本迭代做 **该做,但只做收窄版。** 三条理由: 1. **验收标准依赖它**。`development-plan.md:255` 写「相同幂等请求**不重复扣费**或生成」。而目标模型 40 张表**无任何计费/额度表**(§1.2 取证),全项目也无余额概念。「扣费」在 M4 唯一可落地的解释就是**配额消耗**。没有配额,这条验收标准无从验证。 2. **AI 生成天然需要**。它是全项目第一个**单次调用成本显著 > 0**(真实厂商按次计费)、且**单用户可无限触发**的写接口。既有写接口(发帖/评论/点赞)都无此性质。 3. **成本可控**。见 8.3,推荐方案**零新表、零新依赖**。 **但要与 M6 划清界限**:`development-plan.md:272` 把「限流、审计、结构化日志、指标、Trace、告警」整体排在 M6。M4 做的**不是**横切限流基础设施(无网关级令牌桶、无全端点覆盖、无 Redis 计数器),而是**创作域一个端点的业务配额**。这个区分要写进 ADR,否则容易被误读为「M6 的限流提前做了」而在 M6 重复投入。 ### 8.2 现状取证 - `patbond-*/src/main` grep `429|TOO_MANY_REQUESTS|Retry-After|RateLimit|rateLimit` → **零命中** - `openapi.yaml` grep 同上 → **零命中**(无 429 响应、无 RateLimit/Retry-After 头) - `ErrorCode.java:12-38` 27 个码中 **429 段完全空白** 即:地基全新。 ### 8.3 方案对比 | 选项 | 做法 | 评价 | | --- | --- | --- | | **Q1(推荐)** | **派生计数,零新表**。提交前 `SELECT count(*) FROM creation.generation_jobs WHERE user_id=:me AND created_at >= :windowStart AND status <> 'cancelled'`,与配置上限比较;另查 `status IN ('queued','running')` 得在途数。走 `ix_generation_jobs_user_created`(`:702-703`,已存在) | 零迁移、零新依赖、零新表;与 ADR-022「获赞总数走读侧实时聚合,不引入冗余列」的判例完全同构(`decisions.md:193`)。代价:并发提交有小幅超发(见下) | | Q2 | 新建 `creation.generation_quotas` 计数表 + 原子 upsert | 严格精确,但**目标模型里没有这张表**(凭空新增偏离已评审模型),且为 MVP 精度买了一张需要窗口滚动与清理的表 | | Q3 | Redis 计数器 | 需新增容器(§3.1),与 §3 的整体判断矛盾 | 推荐 **Q1**。超发问题:两个并发提交可能都通过检查。若需严格,加 `SELECT pg_advisory_xact_lock(hashtext(:userId::text))` 把同一用户的提交串行化——一行代码、无新表。建议**默认加上**,因为提交本来就是低频操作,串行化无性能代价。 ### 8.4 建议的两道闸门与响应形态 | 闸门 | 配置项 | 建议默认 | 语义 | | --- | --- | --- | --- | | 日配额 | `patbond.creation.quota.daily-limit` | 20 | 同一用户当日(建议按 UTC 日切,与埋点 `server_ts` 口径一致)非取消任务数上限 | | 在途上限 | `patbond.creation.quota.max-in-flight` | 2 | `queued + running` 并发数,防单用户占满队列 | 响应:**429** + 业务码 **42900**(`GENERATION_QUOTA_EXCEEDED`,符合 `ErrorCode` 的 HTTP 三位 + 两位序号命名法)+ **`Retry-After` 响应头**(日配额 → 距下一个窗口起点的秒数;在途上限 → 建议固定小值如 10)。 ⚠️ 取消是否退还配额:推荐**退还**(计数条件 `status <> 'cancelled'`),对用户更友好,且取消通常意味着未真正消耗上游。→ 归入 **D4-5**。 ⚠️ 429 是**全新错误段**,两处需同步:`ErrorCode.java` 新增枚举项,以及契约 `info.description` 的错误码表(openapi.yaml `:32-60`,当前止于 42300)。 --- ## 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` 的 ``。 --- ## 10. 数据模型草案与 DDL 草稿 **总原则:抄写目标模型,不重新设计。** 下面的 DDL 与 `patbond-doc/docs/database/patbond_postgresql.sql:556-716` **逐列一致**;所有偏离都在 §10.4 单独列出并附理由。字段含义已在 §2/§4/§5 逐项论证,此处不重复。 ### 10.1 目录两表(DDL 草稿) ```sql CREATE SCHEMA creation; COMMENT ON SCHEMA creation IS 'Asynchronous AI image/video generation'; CREATE TABLE creation.generation_models ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(64) NOT NULL, display_name varchar(128) NOT NULL, provider_code varchar(64) NOT NULL, provider_model_name varchar(128) NOT NULL, media_kind varchar(16) NOT NULL, enabled boolean NOT NULL DEFAULT true, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (code, media_kind), -- 非冗余:为 generation_jobs 的复合外键提供被引用唯一约束(§5.1) UNIQUE (id, media_kind), CONSTRAINT ck_generation_models_kind CHECK (media_kind IN ('image', 'video')), CONSTRAINT ck_generation_models_names CHECK ( code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 AND char_length(btrim(display_name)) BETWEEN 1 AND 128 ) ); CREATE INDEX ix_generation_models_kind_order ON creation.generation_models (media_kind, sort_order, id) WHERE enabled; CREATE TABLE creation.generation_styles ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), code varchar(64) NOT NULL, title varchar(64) NOT NULL, subtitle varchar(128), media_kind varchar(16) NOT NULL, -- 跨 schema 外键保留:media 自 V1 起存在(同 V5 先例) preview_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, enabled boolean NOT NULL DEFAULT true, sort_order integer NOT NULL DEFAULT 0, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (code, media_kind), UNIQUE (id, media_kind), CONSTRAINT ck_generation_styles_kind CHECK (media_kind IN ('image', 'video')), CONSTRAINT ck_generation_styles_names CHECK ( code = btrim(code) AND char_length(code) BETWEEN 2 AND 64 AND char_length(btrim(title)) BETWEEN 1 AND 64 ) ); CREATE INDEX ix_generation_styles_preview ON creation.generation_styles (preview_asset_id); CREATE INDEX ix_generation_styles_kind_order ON creation.generation_styles (media_kind, sort_order, id) WHERE enabled; ``` ### 10.2 任务表(DDL 草稿) ```sql CREATE TABLE creation.generation_jobs ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), user_id uuid NOT NULL REFERENCES identity.users(id) ON DELETE RESTRICT, pet_id uuid REFERENCES pet_health.pets(id) ON DELETE SET NULL, media_kind varchar(16) NOT NULL, model_id uuid NOT NULL, style_id uuid, input_asset_id uuid NOT NULL REFERENCES media.assets(id) ON DELETE RESTRICT, output_asset_id uuid REFERENCES media.assets(id) ON DELETE SET NULL, prompt varchar(10000), negative_prompt varchar(3000), width_px integer, height_px integer, duration_ms bigint, upscale boolean NOT NULL DEFAULT false, parameters jsonb NOT NULL DEFAULT '{}'::jsonb, -- 快照四列:目录行改名/停用后历史任务仍可解释(§5.4 说明 model_version_snapshot 取值来源) provider_code_snapshot varchar(64) NOT NULL, provider_model_snapshot varchar(128) NOT NULL, model_version_snapshot varchar(64) NOT NULL, style_code_snapshot varchar(64), status varchar(16) NOT NULL DEFAULT 'queued', progress smallint NOT NULL DEFAULT 0, priority smallint NOT NULL DEFAULT 0, -- 队列机制六列(§2) attempt_count smallint NOT NULL DEFAULT 0, max_attempts smallint NOT NULL DEFAULT 3, provider_request_id varchar(128), error_code varchar(64), error_message varchar(1000), idempotency_key varchar(128) NOT NULL, request_hash bytea NOT NULL, next_attempt_at timestamptz NOT NULL DEFAULT now(), lease_owner varchar(128), lease_expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now(), started_at timestamptz, -- ★ 本次尝试的开始时间,非首次入队(§2.1) completed_at timestamptz, version integer NOT NULL DEFAULT 0, UNIQUE (user_id, idempotency_key), -- L1 幂等(§4.2) FOREIGN KEY (model_id, media_kind) REFERENCES creation.generation_models(id, media_kind) ON DELETE RESTRICT, FOREIGN KEY (style_id, media_kind) REFERENCES creation.generation_styles(id, media_kind) ON DELETE RESTRICT, CONSTRAINT ck_generation_jobs_kind CHECK (media_kind IN ('image', 'video')), CONSTRAINT ck_generation_jobs_dimensions CHECK ( (width_px IS NULL OR width_px BETWEEN 64 AND 8192) AND (height_px IS NULL OR height_px BETWEEN 64 AND 8192) AND (duration_ms IS NULL OR duration_ms > 0) ), CONSTRAINT ck_generation_jobs_parameters CHECK (jsonb_typeof(parameters) = 'object'), CONSTRAINT ck_generation_jobs_idempotency CHECK ( idempotency_key = btrim(idempotency_key) AND char_length(idempotency_key) BETWEEN 1 AND 128 AND octet_length(request_hash) = 32 ), CONSTRAINT ck_generation_jobs_status CHECK ( status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled') ), CONSTRAINT ck_generation_jobs_progress CHECK (progress BETWEEN 0 AND 100), CONSTRAINT ck_generation_jobs_attempts CHECK ( attempt_count >= 0 AND max_attempts > 0 AND attempt_count <= max_attempts ), -- 状态机下沉为 DB 约束(§4.1):五状态各自规定 7 个字段的合法组合 CONSTRAINT ck_generation_jobs_state CHECK ( ( status = 'queued' AND progress = 0 AND started_at IS NULL AND completed_at IS NULL AND output_asset_id IS NULL AND error_code IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) OR ( status = 'running' AND started_at IS NOT NULL AND completed_at IS NULL AND output_asset_id IS NULL AND error_code IS NULL AND lease_owner IS NOT NULL AND lease_expires_at IS NOT NULL ) OR ( status = 'succeeded' AND progress = 100 AND started_at IS NOT NULL AND completed_at IS NOT NULL AND output_asset_id IS NOT NULL AND error_code IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) OR ( status = 'failed' AND completed_at IS NOT NULL AND error_code IS NOT NULL AND output_asset_id IS NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) OR ( status = 'cancelled' AND completed_at IS NOT NULL AND lease_owner IS NULL AND lease_expires_at IS NULL ) ), CONSTRAINT ck_generation_jobs_lease CHECK ( (lease_owner IS NULL) = (lease_expires_at IS NULL) AND (lease_expires_at IS NULL OR started_at IS NULL OR lease_expires_at > started_at) ), CONSTRAINT ck_generation_jobs_version CHECK (version >= 0) ); CREATE UNIQUE INDEX uq_generation_jobs_provider_request -- L2 幂等(§4.2) ON creation.generation_jobs (provider_code_snapshot, provider_request_id) WHERE provider_request_id IS NOT NULL; CREATE INDEX ix_generation_jobs_user_created -- 我的任务列表 + 配额计数(§8.3) ON creation.generation_jobs (user_id, created_at DESC, id DESC); CREATE INDEX ix_generation_jobs_pet ON creation.generation_jobs (pet_id); CREATE INDEX ix_generation_jobs_model ON creation.generation_jobs (model_id); CREATE INDEX ix_generation_jobs_style ON creation.generation_jobs (style_id); CREATE INDEX ix_generation_jobs_model_kind ON creation.generation_jobs (model_id, media_kind); CREATE INDEX ix_generation_jobs_style_kind ON creation.generation_jobs (style_id, media_kind); CREATE INDEX ix_generation_jobs_input ON creation.generation_jobs (input_asset_id); CREATE INDEX ix_generation_jobs_output ON creation.generation_jobs (output_asset_id); CREATE INDEX ix_generation_jobs_queue -- 出队(§3.3) ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id) WHERE status = 'queued'; CREATE INDEX ix_generation_jobs_running -- reaper 回收(§3.4 c) ON creation.generation_jobs (lease_expires_at, id) WHERE status = 'running'; ``` ### 10.3 触发器与外键补回 ```sql -- platform.set_updated_at() 自 V1:28-36 起存在,直接挂(目标模型 :1245-1250) CREATE TRIGGER trg_generation_models_updated_at BEFORE UPDATE ON creation.generation_models FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); CREATE TRIGGER trg_generation_styles_updated_at BEFORE UPDATE ON creation.generation_styles FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); CREATE TRIGGER trg_generation_jobs_updated_at BEFORE UPDATE ON creation.generation_jobs FOR EACH ROW EXECUTE FUNCTION platform.set_updated_at(); -- ★ V5:17-19 指定的「M4 补回」:裸列 + ix_posts_generation_job 索引均已存在, -- 仅补约束。存量值恒为 NULL(§1.1 取证),无需 NOT VALID,无回填。 ALTER TABLE community.posts ADD CONSTRAINT fk_posts_generation_job FOREIGN KEY (generation_job_id) REFERENCES creation.generation_jobs(id) ON DELETE SET NULL; -- ⚠️ region_id 的外键属 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 内部必须严格按此顺序: 1. `CREATE SCHEMA creation` 2. `generation_models`、`generation_styles`(含各自的 `UNIQUE (id, media_kind)`) 3. `generation_jobs`(其复合外键引用步骤 2 的唯一约束;其 `input_asset_id`/`output_asset_id` 引用 `media.assets`,V1 已建;`user_id` 引用 `identity.users`,V1 已建;`pet_id` 引用 `pet_health.pets`,V3 已建) 4. 索引与触发器 5. **最后**才 `ALTER TABLE community.posts ADD CONSTRAINT`(被引用表必须先存在) ### 11.3 是否需要 V8 及以后 不需要。本迭代**没有任何**需要额外迁移的项,逐条确认: | 候选 | 是否需要迁移 | 取证 | | --- | --- | --- | | 新增 `ai_input` / `ai_output` purpose | **否** | `media.assets.purpose` 无 CHECK(`V1:205-249` 逐行确认);白名单是配置项 `MediaProperties.java:66` | | 放开 `category='ai_creation'` | **否** | `V5:68` 的 `ck_posts_category` **已含** `ai_creation`;拦截在 Java `@Pattern` | | `posts.generation_job_id` 加列 | **否** | 裸列 `V5:48` 已存在 | | `ix_posts_generation_job` 索引 | **否** | `V5:95` 已建 | | 埋点新增创作域事件 | **否** | `platform.product_events` 是 `event_name varchar(64)` + `props jsonb`(`V2`),白名单在 `EventDictionary.java`(Java 常量表,41 条) | | 配额计数 | **否** | 派生计数,零新表(§8.3 Q1) | | 429 错误码 | **否** | Java 枚举 + 契约文本 | ### 11.4 迁移相关的测试影响(**会红,必须提前排进工单**) ⚠️ **`CommunityMigrationIntegrationTest.v5PostsColumnsExistWithoutCreationAndRegionFKs` 会因 V6 变红。** 取证:`patbond-user/src/test/java/com/patbond/patbond/user/persistence/CommunityMigrationIntegrationTest.java:81-88` ```java int fkCount = jdbcClient.sql( "SELECT COUNT(*) FROM information_schema.table_constraints " + "WHERE table_schema = 'community' AND table_name = 'posts' " + "AND constraint_type = 'FOREIGN KEY' " + "AND (constraint_name LIKE '%generation%' OR constraint_name LIKE '%region%')") .query(Integer.class).single(); assertThat(fkCount).isEqualTo(0); ``` Flyway 在测试容器上跑**全链**,V6 之后这个查询会返回 1(`fk_posts_generation_job` 命中 `LIKE '%generation%'`)。**处置**:把该断言拆成两条——generation 类 FK 期望 **1**,region 类 FK 仍期望 **0**(M5 才补)——并同步更新测试类 Javadoc(`:12-18` 现写「generation_job_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`」惯例): 1. `CreationModel` 2. `CreationStyle`(`previewUrl` nullable,§5.3 P1) 3. `CreationCatalog`(`{models, styles}`) 4. `CreationCatalogEnvelope` 5. `CreateGenerationJobRequest` 6. `GenerationJob`(详情与列表项共用,含 `status` / `progress` / `errorCode` 枚举 / `outputAssetId` / `outputUrl` / `modelCode` / `styleCode` / `createdAt` / `completedAt`) 7. `GenerationJobEnvelope` 8. `GenerationJobListEnvelope`(`{items, nextCursor, hasMore}`) **是否纯增量:是。** 逐条核验:新增路径/操作/schema/tag 全为增加;请求枚举**加宽**(服务端接受更多,旧客户端不受影响);新增**可选**请求字段;新增响应字段(Flutter 侧非 required 字段被忽略)。**无删除、无重命名、无必填收紧、无类型变更、无枚举收窄**。故版本号 **v1.4.0 → v1.5.0**(功能增量,非补丁),与 ADR-022 的 v0.4.0 定版逻辑一致。 ### 12.4 新增业务错误码(3 个) | 码 | HTTP | 名称 | 语义 | | --- | --- | --- | --- | | **40407** | 404 | `GENERATION_JOB_NOT_FOUND` | 任务不存在 / 非本人(防枚举合并,沿 40405 先例) | | **42206** | 422 | `GENERATION_RULE_VIOLATION` | model/style 与 mediaKind 不匹配、目录项已停用、或对终态任务发起取消 | | **42900** | 429 | `GENERATION_QUOTA_EXCEEDED` | 日配额或在途上限(§8.4),**配 `Retry-After` 头** | 复用既有码:40000、40101、40401、40405、40905、42203、50000、50300。命名与编号均遵循 `ErrorCode.java:3-9` 的纪律(「codes are contract, never reuse or renumber a released value」)。 --- ## 13. 契约同步机械清单(含第 5 份快照) ### 13.1 先修正一条既有认知:CI 锁的不是字节 任务书写的是「四模块**字节级**快照锁 CI」。实测**不成立**: - CI 工作流(`patbond-api/.gitea/workflows/ci.yml`)只有两个校验步骤:`sh scripts/check-secrets.sh --all` 与 `./mvnw -B clean test`。**无任何 diff / 校验和 / 文件比对步骤**。 - 四份 POM 中**无**任何拷贝或比对插件配置。 - 「byte-identical」只出现在 Javadoc 里,是**约定的措辞**(如 `patbond-pet/.../contract/OpenApiContract.java:20`「this snapshot is a byte-identical copy taken at freeze time」),而该类的实现只用 SnakeYAML 解析(`:54` `new Yaml().load(in)`)并暴露 `version()/paths()/operations()/schemas()`。 - 真正的门禁是**结构断言**:每模块一个 `frozenSnapshotIsTheExpectedContractVersion()`,断言版本字符串 + 三个计数 + 本域 tag 的操作集合。 实测四处断言(全部 `1.4.0` / `32` / `45` / `75`): | 模块 | 断言位置 | tag 集合 | | --- | --- | --- | | auth | `AuthContractConformanceTest.java:401-405` | `{auth, user, analytics}` | | user | `MediaContractConformanceTest.java:248-252` | `{media}` | | pet | `ContractConformanceTest.java:757-761` | `{pets, dictionaries, health-records}` | | community | `CommunityContractConformanceTest.java:524-528` | (posts/feed/comments/interactions/follows 一组) | 四份快照当前确实互为字节相同(`diff` 无输出),但**这是纪律的结果,不是机制的保证**。差别对 M4 有实际后果:如果有人只改了 doc 仓的 openapi.yaml 里某个 description 而不动计数,四个模块的守卫**全都不会红**。所以「契约先行」在本项目仍然依赖人工纪律,M4 的契约冻结工单不能只跑 CI 就认为同步到位。 ### 13.2 升版必须同步改的清单(漏一处 CI 即红) 1. doc 仓 `docs/api/openapi.yaml`:`info.version` → `1.5.0`;错误码表加 3 行;tags 加 `creation`;新增 4 路径 / 8 schema / 1 response;改 4 个既有 schema 2. `patbond-doc/site/api/openapi.yaml`(当前与正典字节相同的站点镜像;**未取证它是否由 mkdocs 构建自动产出**,若为构建产物则不手改) 3. 四份 → **五份** 快照文件:`patbond-{auth,user,pet,community,ai}/src/test/resources/contract/openapi-v1.5.0.yaml`(新文件名,旧的 v1.4.0 按既有做法删除) 4. 四份 → **五份** `OpenApiContract.java` 的 `RESOURCE` 常量(`:39`,pet 版在 `:35`)→ `/contract/openapi-v1.5.0.yaml` 5. 四处 → **五处** `frozenSnapshotIsTheExpectedContractVersion()`:版本 `1.5.0`、`paths` **36**、`operations` **50**、`schemas` **83** 6. community 的 tag 操作集合:`Post` / `CreatePostRequest` 相关操作签名未变,集合内容不变(仅 schema 内部变化),**但仍要复核** 7. **新增** patbond-ai 的 `OpenApiContract` + `AiContractConformanceTest`(tag 集合 `{creation}`,5 个操作) ⚠️ 特别注意:**auth 与 pet 两个模块本域一行代码都不改,但它们的守卫会因全局计数变化而变红**,必须一并更新。这正是 M3.5 出现「11 格漂移」的同一机制(`iteration-3.5/03` 报告 §6 记载 auth 2 + pet 9 格漂移)。M4 的漂移格数预计更大(新增 5 操作 + 8 schema),建议**契约冻结单独成一个工单**并放在实现工单之后,与 T3.5-07 同一节奏。 --- ## 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`。