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