6 Commits

Author SHA1 Message Date
lixi a8ffd02175 feat(creation): Java 侧接通 patbond-ai——Feign 客户端、两类错误分流、三值重试(T4-03)
CI / backend-test (push) Successful in 6m22s
按 openapi-internal.yaml 把 AiServiceGenerationProvider 从空实现位做成真实现。
全量 410 → 427,零失败零跳过。

**骨架阶段的三处假设被现实推翻,一并纠正:**

1. **去掉 submit() 的 idempotencyKey 参数。** 当时按「透传给厂商幂等头」设计,
   但火山**没有任何幂等字段**(取证见 providers/volcengine-seedance.md §2-①)。
   留一个无处可去的参数会让人以为双重扣费已经解决——它没有,只由 max_attempts
   兜住,恢复路径是已落库的 provider_request_id 而不是某个 key。
2. **Worker 不再在 submit 前调 uploadTargets.presignedPutFor。** 那个方法目前
   抛异常,而它在 submit **之前**被调用——**所以在此之前整条链一步都跑不动**。
   契约把 upload_target 标为可选未启用,故传 null 才是正确而非权宜。
3. **PollResult 扩容 + retryable 由布尔改三值 Retry。** 布尔表达不了最关键的
   区别:限流几秒后恢复,配额耗尽要等到明天。把后者当可重试会白烧完整个重试
   预算再失败,而用户看到的是转圈而不是「额度用完」的出口(ADR-023 §9)。
   同时补齐 assetUrl/expiresIn/coverUrl/尺寸/时长/outputFormat/billingTokens
   ——这些最终都要写进 media.assets,现在丢掉后面就得再改一遍。

**两类错误落在两个异常上**(AiProviderException / AiServiceException),判别用
error.kind 而非 HTTP 状态码(状态码在两类之间会撞)。若这个分流塌成一个笼统的
FeignException,Worker 就只能猜要不要重试。未知的 retry/user_facing/state 取值
一律偏向「可重试 / 仍在运行」:契约可能先长出一个取值、代码后知道,把可恢复的
抖动判成用户可见的失败是更糟的错误,而重试预算本身有上限。

**snake_case 只作用于这一个客户端**:用 SpringEncoder/SpringDecoder 换 ObjectMapper,
不引 feign-jackson(其他模块都不需要它),也不用逐字段 @JsonProperty——后者漏一个
注解就会静默发出对端忽略的字段,而「静默被忽略的字段」是最难察觉的失败。
另配 followRedirects=false:内部服务没有理由重定向我们,跟随会把服务票发给
重定向指定的任意主机。

**新增两个 429 码**(ADR-023 §9):42900 配额耗尽 / 42901 频率过快。必须是两个
——配额等的是次日或充值(弹 sheet 给出口),频率等的是数秒(弹 SnackBar 可自动
重试),共用一码必然使一半场景的文案与出口是错的。

写进任务行的 error_code 用适配层的 userFacing 类别而非厂商原始码:厂商词汇不该
渗进我们的数据,它会随换供应商而变,客户端会据此分支。原始码留在 error_message
里供排查。

safety_identifier 传 userId 的 SHA-256 hex 而非明文(ADR-023 §5 数据最小化),
测试断言明文 userId 不出现在请求体里。

**新增 AiGenerationClientWireTest(17 项)**,照 community 的
AuthorProfileClientWireTest 先例用 JDK 内置 HttpServer 起桩:snake_case 双向、
服务票每次现签(断言两次调用的票不同——缓存复用会让短期凭证悄悄变成长期凭证)、
两类错误各自的分流、配额与限流的 retry 不同、无法识别的错误体按可重试并说明原因。

**一处真实缺陷由全量跑抓出**:@FeignClient 的 url 占位符原先无默认值,而 Feign 在
**上下文启动时**解析它——导致任何没显式配置该属性的上下文都启动失败,包括与 AI
毫无关系的两个仓储测试类(17 个测试全红)。已给占位符加上与 CreationProperties
一致的默认值。单跑某个测试类时不会暴露这个问题,只有全量跑才会。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 17:43:28 +08:00
lixi 91ae016470 feat(db): V7 目录种子——两个已开通的 Seedance 视频模型(T4-01b)
CI / backend-test (push) Successful in 8m45s
按账号实际开通的模型播种:doubao-seedance-2-5-260628 与
doubao-seedance-2-0-260128,provider_code 为 volcengine-seedance。

与目标模型 patbond_postgresql.sql:1495 的种子**有意不同**:正典那份是服务商
未定时写下的 provider_code='fixture' 占位(这也是开工审计据以判定「AI provider
BLOCKED」的证据之一),现已接入真实服务商。

三处刻意不做的事,文件头逐条写明原因:
- **不播种图片模型**:gpt-image-2 适配层未落地,播种一个没有适配器的模型会让
  客户端渲染出一个点了没反应的入口(目录端点按 media_kind 聚合可用模型)。
- **不播种风格**:风格是产品内容决策(提供哪些艺术风格、叫什么、配图是什么),
  无法从服务商文档推导;正典那 4 条均为 image 类且 preview_asset_id 指向 demo
  资产,而 V1..V7 链路不含该 demo 种子,照抄会违反外键。style_id 可空,不阻塞。
- **不在目录表加能力列**:正典的 generation_models 没有能力列,本迭代不发明
  超出已评审模型的列。两个模型的限制差异(2.5 时长到 30 秒但无 4k;2.0 有 4k
  但时长上限 15 秒;2.5 图生视频时 ratio 锁 adaptive)由 patbond-ai 的
  capabilities.py 承载并在提交前本地预校验。

新增 CreationCatalogSeedTest(6 项),钉住的是几个**跨语言、跨仓库约定**——
它们对不上时症状都离根因很远:
- provider_code 必须与 patbond-ai 适配器的 PROVIDER_CODE 逐字一致。对不上时
  Worker 按任务行冻结的快照查不到适配器,任务永远停在排队,看起来像队列坏了。
- provider_model_name 必须是实际开通的 ID,否则撞 InvalidEndpointOrModel.NotFound
  且不可重试。
- 目录里不得出现没有适配器的 media_kind。
- 模型 ID 尾部须为日期版本——CatalogRepository 以 provider_model_name 兼作版本
  快照,该做法只在 ID 自带版本时成立。

全量 404 → 410,零失败零跳过。另修测试里一处 Java 21 API(List.getFirst()):
项目 release 为 17,本地 JDK 26 不代表能用。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 15:13:57 +08:00
lixi 73bc31aeb8 fix(security): 凭证扫描放行依赖注入传参 + 四仓口径更正
CI / backend-test (push) Successful in 8m11s
`api_key=volcengine.api_key` 这类**依赖注入传参**被 KEY-ASSIGN 判为疑似凭证。
这是规则的真缺口而非代码问题:行里没有任何秘密,而做 DI 的代码到处都是这种
写法——绕过一次就得在每个调用点绕过,那等于把规则废掉。故改规则。

新增 ALLOW_REFERENCE,刻意收得很紧以免开后门:
- 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状)
- 只允许**恰好一个点且随即行尾**
- 连字符不在字符集内

对抗性夹具验证(三真三假):
- 拦下 `api_key = "sk-T3Blbk…"`(OpenAI 形态)
- 拦下 `secret_key: "AKID…"`(腾讯云形态)
- 拦下 `auth_token=eyJ….eyJ….Sfl…`(**三段式 JWT——两个点,故不落进放行**)
- 放行 `api_key=lowercase.reference` / `api_key = settings.api_key` / `api_token: config.authToken`
原 5 形态泄漏夹具复验仍 8 条命中,一条不少。

另更正口径:脚本头与 git-workflow.md 仍写「三仓同构」,实际已是四仓——
patbond-ai 是唯一持有 AI 服务商凭证的仓,漏同步它风险最大。四仓 md5 已一致
(4945940093b4b8413b7d3eec8bf6a5ec),四仓 --all 全绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:18:18 +08:00
lixi 4b2550f88c feat(creation): 新增 patbond-creation 模块骨架——端点、租约队列 Worker、provider 适配层(T4-02)
CI / backend-test (push) Successful in 5m43s
M4 新模块,:8085,前缀 /api/v1/creation/**(ADR-023 §2,E3 拍板方案 A)。
它编排生成任务但不调用 AI 服务商——上游调用在独立的 patbond-ai(Python)
仓库,经 REST 到达。

落地内容:
- 客户端端点 4 个(创建/查询/列表/取消)+ 目录端点。创建强制 Idempotency-Key
  (ADR-019:生成在上游要花钱,重试绝不能变成第二个计费任务)
- 租约队列仓储:FOR UPDATE SKIP LOCKED 领取 + 租约续期 + 回收器。目标模型
  只给了索引没给回收器代码,这里补上
- Worker 三条独立调度通道(提交/轮询/回收),任一卡住不饿死其余两条。
  轮询与续租在同一语句内完成——分开做会让长任务在跑时租约过期被抢,
  那正是重复扣费的经典路径
- provider 适配层接口按「两阶段 submit/poll」定形 + 两个实现位
- 服务身份 JWT 签发器(独立密钥对)
- common 增 3 个错误码(40407/40408/42206);配额与限流的两个 429 码随
  配额工单落地,此处不加死枚举

关键实现纪律(注释中已逐条说明原因):
- provider_request_id 在上游产生任何计费动作之前落库。顺序反过来,每次
  超时都会变成重复扣费
- 租约回收时刻意不清 provider_request_id:保留它,下次尝试才知道「已提交过,
  该轮询而非重发」
- provider 按 provider_code_snapshot 而非当前目录解析,故目录改指向后
  在途任务仍由原适配器轮询
- Worker 各 pass 捕获异常:Spring 会取消抛异常的 fixedDelay 任务,
  不捕获等于一次偶发失败静默停掉整个队列

刻意未实现且会响亮失败(不装作能用):provider 两个实现位(待服务商文档
确认幂等键支持、查询接口与频率限制、单任务最长时长、产物返回形式、
限流与配额响应形态五项)、预签名上传目标获取、产物入 media.assets
(后两者取决于挂起中的对象存储决策,ADR-023 §7)。

共享物按 ADR-023 §2 复制 3 件(JWT 校验栈、UuidV7、RequestHashes);
**存储适配层不复制**——presignPut 已存在,产物走预签名上传由 patbond-user
签发,故无需第 4 份存储层,也使 patbond-ai 零对象存储凭证。

测试:新增 GenerationJobQueueIntegrationTest 11 项,全部针对「写错了也
能跑、只在生产表现为丢任务或重复扣费」的语义:SKIP LOCKED 不把同一行
交给两个 worker、轮询确实续了它依赖的租约、回收器保留 provider_request_id、
first_attempt_at 跨重排队存活、write-once 保护、取消被状态守卫。
全量 393 → 404,零失败零跳过。

过程中被测试抓出两个真问题:UPDATE...FROM 的 RETURNING 列表未限定别名
导致 id 歧义(列表已改为单一来源派生两种形式,避免漂移);以及夹具伪造
了物理上不可能的历史(租约过期但 started_at 是当下),被
ck_generation_jobs_lease 正确拒绝。

Readme.md 全文改中文并补齐 M2 之后的内容:原文停在 M2(pet 骨架阶段)、
完全没有 patbond-community、配置表缺 M2 之后全部条目。现覆盖六模块、
迁移链归属说明、两对密钥的区别与原因、以及各服务端点。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:36:52 +08:00
lixi 7896a6d20b feat(db): V6 creation schema 地基 + 补回 posts.generation_job_id 外键(T4-01)
CI / backend-test (push) Successful in 11m35s
creation schema 三表(generation_models / generation_styles /
generation_jobs)逐列取自已评审目标模型 patbond_postgresql.sql
(schema 60/68 + 对象 556-717),含 14 索引与 3 个 updated_at 触发器。

- 零外键需要裁剪:这是第一个不用砍跨 schema 外键的 baseline 迁移,
  identity/media(V1)与 pet_health(V3)目标 schema 均已存在
- 按 V5 文件头指令「M4 补回」补回 community.posts.generation_job_id
  → creation.generation_jobs(id) ON DELETE SET NULL;约束名显式写为
  posts_generation_job_id_fkey,即目标模型内联声明时 PostgreSQL 会
  自动生成的名字,使 V1..V6 建库与照目标模型建库约束名一致
- posts.region_id 保持裸列(区域系统属 M5,ADR-018 裁剪,M5 补回)
- 两条偏索引对齐 worker 语句:ix_generation_jobs_queue 的列序与出队
  ORDER BY 完全一致(SKIP LOCKED 领取走索引扫描不排序);
  ix_generation_jobs_running 驱动租约回收与待轮询扫描

两处刻意偏离目标模型,文件头已逐条记录原因:
1. input_asset_id 改可空(目标模型 NOT NULL)——参考图选填拍板;原
   NOT NULL 会使文生图无法表达。未发明 requires_input 标记(不超出
   已评审模型),「某模型是否必须有输入」由应用层查目录判断
2. 新增 first_attempt_at(目标模型无此列)——iteration-4/06 §4.2 指标
   口径要求。目标模型无法表达:其 ck_generation_jobs_state 强制
   queued 分支 started_at IS NULL,故重排队必然擦除首次尝试时间,
   导致排队时长与纯执行耗时永久无法分离。该列仅首次进入 running 时
   写入、不覆盖,并刻意排除在 queued 分支的置空要求之外;
   ck_generation_jobs_first_attempt 钉住其与 attempt_count 的不变式

测试:
- 新增 CreationMigrationIntegrationTest(12 项)。重点覆盖「日后同步回
  目标模型会静默改回去」的行为:复合外键真实拦截跨 media_kind 引用、
  queued→running→failed→queued 全程后 first_attempt_at 保留而
  started_at 被清空、不变式双向拒绝、provider_request_id 部分唯一索引
  (NULL 可共存、同值冲突)、idempotency_key 的按用户作用域
- CommunityMigrationIntegrationTest 原断言 %generation% 与 %region%
  外键合计为 0,V6 后必然变红(iteration-4/02 已预警)。拆为两条独立
  断言(generation=1、region=0)而非改成合计 1,使任一侧回退可单独定位

全量 381 → 393,零失败零跳过(auth 40 / common 3 / community 107 /
pet 100 / user 131→143)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:35:36 +08:00
lixi 3d66b32af4 fix(security): 凭证扫描补 AI 服务商密钥形态与 api_key 赋值(ADR-021)
CI / backend-test (push) Successful in 6m46s
三仓同构副本同步提交。规则变更与验证记录见 patbond-doc 同名提交及
docs/development/iterations/iteration-4/08-git-workflow-plan.md(决策 G)。

- 新增 AK-ANTHROPIC(sk-ant-)、AK-OPENAI(sk- / sk-proj- / sk-svcacct-)
- KEY-ASSIGN 扩至 api[-_]?(key|secret|token) 与 auth[-_]?token
- ALLOW 补 x{3,}

验证:本仓 scripts/check-secrets.sh --all 退出 0 零误报;三仓副本 md5
同构 640994c12bfa78b4512e872d709ed685。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 15:57:05 +08:00
51 changed files with 5406 additions and 102 deletions
+139 -89
View File
@@ -1,82 +1,113 @@
# Patbond API
Patbond API is a Spring Boot multi-module backend.
Patbond 后端,Spring Boot 多模块。
## Modules
## 模块
- `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies.
- `patbond-user`: user service for user creation, profile lookup, and password verification.
- `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002).
- `patbond-pet`: pet profile and health record service (M2, ADR-009). Shares the database with `patbond-user` and only reads/writes the `pet_health` schema; the Flyway migration chain stays owned by `patbond-user`. First-wave skeleton: liveness endpoint only.
| 模块 | 端口 | 职责 |
| --- | --- | --- |
| `patbond-common` | —— | 跨模块稳定契约(响应信封、错误码、共享 DTO)。**刻意不引入 web/消息依赖**,保持纯契约。 |
| `patbond-auth` | 8081 | 注册与登录,签发 RS256 access token。经 OpenFeign 静态直连调用 `patbond-user`(MVP 不做服务发现,ADR-002)。**私钥只在本服务**。 |
| `patbond-user` | 8082 | 用户与会话、当前用户资料、媒体上传流程(ADR-016/017)、埋点接收端。**独占 Flyway 迁移链**。 |
| `patbond-pet` | 8083 | 宠物档案与健康记录(M2,ADR-009)。与 `patbond-user` 共库,只读写 `pet_health` schema。 |
| `patbond-community` | 8084 | 社区 Feed、帖子、评论、点赞收藏与关注(M3,ADR-017)。只读写 `community` schema,另按 ADR-017 例外只读 `media.assets`。 |
| `patbond-creation` | 8085 | **AI 生成任务编排(M4,ADR-023)**。见下节。 |
## Technology Stack
### patbond-creation(M4 新增)
- Java 17 (build baseline; use JDK 17 for release builds)
承载 AI 图片与视频生成的任务编排。**它编排任务,但不调用 AI 服务商**——上游调用在独立的 `patbond-ai`(Python)仓库,经 REST 到达。
**本模块拥有**:`creation` schema 的读写、`/api/v1/creation/**` 客户端端点、以及租约队列 Worker。
**本模块刻意不拥有**:
- **AI 服务商调用** —— 在 `patbond-ai`。Java 侧持有队列与任务状态,Python 侧只负责调厂商 API,两者经 REST(不用 gRPC)通信,鉴权用**独立密钥对签发的服务身份 JWT**(不转发用户 token)。
- **对象存储** —— 本服务从不直连。产物上传凭证来自 `patbond-user` 的内部媒体接口(预签名 PUT),由生成方直传,ADR-017 的边界不动。
**队列为什么在数据库里而不是消息中间件**(ADR-023 §3):已评审的目标数据模型本就把整套租约机制建成了列(`lease_owner`/`lease_expires_at`/`next_attempt_at`/`attempt_count`/`max_attempts`/`priority`),外加两条列序与出队语句逐列对齐的偏索引。因此领取是索引扫描而非排序,任务状态只有一个事实来源、无双写;引入中间件反而要让这批列作废。「Worker 重启不丢任务」由租约回收器天然满足。
**Worker 的三条独立调度通道**(任一卡住不会饿死其余两条):领取待办 QUEUED 任务并提交上游 → 轮询本实例持有的 RUNNING 任务并在同一语句内续租 → 回收租约过期的任务重新入队。
两处值得留意的实现纪律:
- **`provider_request_id` 在上游产生任何计费动作之前落库**。顺序反过来,每次超时都会变成重复扣费。
- **租约回收时不清 `provider_request_id`**。保留它,下次尝试才知道「这活已经提交过了,该去轮询而不是重发」。
**骨架阶段刻意未实现**且会响亮失败(不装作能用):provider 适配层的两个实现位、预签名上传目标获取、产物入 `media.assets`。前者待服务商官方文档确认五项(幂等键支持、查询接口与频率限制、单任务最长时长、产物返回形式、限流与配额响应形态),后两者取决于**对象存储方案,该决策明确挂起**(ADR-023 §7)。
## 技术栈
- Java 17(构建基线;发布构建请用 JDK 17)
- Spring Boot 3.5.16
- Spring Cloud 2025.0.3 (OpenFeign only)
- PostgreSQL 18 + Flyway (patbond-user owns the migration chain: `identity`/`media`/`platform`/`pet_health` schemas)
- Maven (use the committed Maven Wrapper `./mvnw`)
- Spring Cloud 2025.0.3(只用 OpenFeign)
- PostgreSQL 18 + Flyway
- Maven(用仓库内的 wrapper `./mvnw`)
## Build and Test
**Flyway 迁移链由 `patbond-user` 独占**,V1..V6 覆盖 `identity`/`media`/`platform`(V1)、`pet_health`(V3/V4)、`community`(V5)、`creation`(V6)。注意「谁拥有 schema」与「迁移文件放哪」在本项目是分开的:pet、community、creation 的业务代码各在自己的模块,迁移文件全部在 `patbond-user`,`flyway_schema_history` 只有一份、不拆。
## 构建与测试
```bash
./mvnw clean test
```
Integration tests start a disposable `postgres:18` via Testcontainers, so a
running Docker daemon is required (no local PostgreSQL installation or
credentials are needed).
集成测试经 Testcontainers 拉起一次性的 `postgres:18`,因此需要一个在跑的 Docker daemon(不需要本机装 PostgreSQL、也不需要本机库凭证)。
If your default JDK is not 17, point `JAVA_HOME` at a JDK 17 installation first, e.g.:
默认 JDK 不是 17 时,先指向一个 JDK 17:
```bash
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test
```
## Run
## 本机运行
Running `patbond-user` requires a reachable PostgreSQL 18 database; Flyway
applies the versioned migrations in
`patbond-user/src/main/resources/db/migration` automatically on startup.
Development seed data (`db/dev`, regions reference rows) is opt-in via a dev
profile — see `application.yml.sample`.
`patbond-user` 启动时 Flyway 会自动应用 `patbond-user/src/main/resources/db/migration` 下的版本化迁移,因此它需要一个可达的 PostgreSQL 18。开发种子数据(`db/dev`)通过 dev profile 可选启用,见 `application.yml.sample`。
Each service ships a committed `application.yml.sample`; the real `application.yml`
is git-ignored. First copy the samples (defaults work locally, overrides via
environment variables):
每个服务都提交了 `application.yml.sample`,真实的 `application.yml` 被 gitignore。先复制样例(默认值本机可用,实际值用环境变量覆盖):
```bash
cp patbond-user/src/main/resources/application.yml.sample \
patbond-user/src/main/resources/application.yml
cp patbond-auth/src/main/resources/application.yml.sample \
patbond-auth/src/main/resources/application.yml
cp patbond-pet/src/main/resources/application.yml.sample \
patbond-pet/src/main/resources/application.yml
for m in user auth pet community creation; do
cp patbond-$m/src/main/resources/application.yml.sample \
patbond-$m/src/main/resources/application.yml
done
```
Install the shared module once, then run each service in its own terminal:
先装一次共享模块,然后各服务各开一个终端:
```bash
./mvnw -pl patbond-common install
./mvnw -pl patbond-user spring-boot:run
./mvnw -pl patbond-auth spring-boot:run
./mvnw -pl patbond-pet spring-boot:run
./mvnw -pl patbond-community spring-boot:run
./mvnw -pl patbond-creation spring-boot:run
```
Running the services also needs an RS256 key pair for access tokens (the
private key signs in `patbond-auth`, the public key verifies in
`patbond-user`; neither is committed):
### 密钥对
运行需要一对 RS256 密钥用于 access token(`patbond-auth` 用私钥签,其余服务用公钥验;两者都不入库):
```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
export PATBOND_JWT_PRIVATE_KEY=$PWD/jwt-private.pem # patbond-auth
export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem # patbond-user
export PATBOND_JWT_PRIVATE_KEY=$PWD/jwt-private.pem # 只给 patbond-auth
export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem # 其余服务验签用
```
Tests do not require this copy step — `patbond-auth/src/test/resources/application.yml`
keeps `./mvnw clean test` self-contained on a clean checkout.
**`patbond-creation` 调用 `patbond-ai` 需要另一对独立的密钥**,不要复用上面那对:
Smoke check:
```bash
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out service-jwt-private.pem
openssl pkey -in service-jwt-private.pem -pubout -out service-jwt-public.pem
export PATBOND_SERVICE_JWT_PRIVATE_KEY=$PWD/service-jwt-private.pem # patbond-creation
# service-jwt-public.pem 交给 patbond-ai 验签
```
**为什么必须独立**(ADR-023 §5):现有 `JwtVerifier` **只验签名,既不校验 `aud` 也不校验 `iss`**,而 `patbond-auth` 签发的用户 token **不含 `aud` claim**。若服务票复用同一密钥对,user/pet/community/creation 四个模块都会把它当合法用户 token 接受——`sub` 不是 UUID 大概会在后续解析时炸掉,但那是**意外挡住的,不是设计挡住的**。用独立密钥对则无需改动任何已发布的验证器。
测试不需要上述复制与密钥步骤:`patbond-auth/src/test/resources/application.yml` 让 `./mvnw clean test` 在干净 checkout 上自给自足。
冒烟:
```bash
curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
@@ -84,36 +115,44 @@ curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
-d '{"username":"demo_user","password":"secret123"}'
```
### Configuration
### 配置
| Environment variable | Default | Used by |
| 环境变量 | 默认值 | 使用方 |
| --- | --- | --- |
| `PATBOND_USER_PORT` | `8082` | patbond-user |
| `PATBOND_AUTH_PORT` | `8081` | patbond-auth |
| `PATBOND_USER_SERVICE_URL` | `http://127.0.0.1:8082` | patbond-auth (Feign target for patbond-user) |
| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | patbond-user |
| `PATBOND_DB_USER` | `patbond` | patbond-user |
| `PATBOND_DB_PASSWORD` | `patbond` | patbond-user |
| `PATBOND_INTERNAL_TOKEN` | `dev-only-internal-token` | both (shared secret for `/internal/**`; inject a strong random value outside local dev) |
| `PATBOND_JWT_PRIVATE_KEY` | _(none, required)_ | patbond-auth (RS256 private key: PEM path or inline PEM) |
| `PATBOND_JWT_PUBLIC_KEY` | _(none)_ | patbond-user (RS256 public key: PEM path or inline PEM) |
| `PATBOND_ACCESS_TTL` | `15m` | patbond-auth (access token lifetime, ADR-003) |
| `PATBOND_REFRESH_TTL` | `30d` | patbond-user (refresh token lifetime, ADR-003) |
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` | `5` | patbond-user (login failures before lockout) |
| `PATBOND_LOGIN_LOCK_WINDOW` | `15m` | patbond-user (failure counting window) |
| `PATBOND_LOGIN_LOCK_DURATION` | `15m` | patbond-user (lock duration) |
| `PATBOND_USER_PORT` | `8082` | patbond-user |
| `PATBOND_PET_PORT` | `8083` | patbond-pet |
| `PATBOND_COMMUNITY_PORT` | `8084` | patbond-community |
| `PATBOND_CREATION_PORT` | `8085` | patbond-creation |
| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | 全部数据库使用方 |
| `PATBOND_DB_USER` | `patbond` | 同上 |
| `PATBOND_DB_PASSWORD` | `patbond` | 同上 |
| `PATBOND_USER_SERVICE_URL` | `http://127.0.0.1:8082` | auth / community / creation(Feign 指向 patbond-user) |
| `PATBOND_INTERNAL_TOKEN` | `dev-only-internal-token` | 全部(`/internal/**` 服务间共享密钥;本机开发之外必须注入强随机值) |
| `PATBOND_JWT_PRIVATE_KEY` | _(无,必填)_ | patbond-auth(RS256 私钥:PEM 路径或内联 PEM) |
| `PATBOND_JWT_PUBLIC_KEY` | _(无)_ | user / pet / community / creation(RS256 公钥) |
| `PATBOND_ACCESS_TTL` | `15m` | patbond-auth(access token 有效期,ADR-003) |
| `PATBOND_REFRESH_TTL` | `30d` | patbond-user(refresh token 有效期,ADR-003) |
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` | `5` | patbond-user(锁定前允许的失败次数) |
| `PATBOND_LOGIN_LOCK_WINDOW` | `15m` | patbond-user(失败计数窗口) |
| `PATBOND_LOGIN_LOCK_DURATION` | `15m` | patbond-user(锁定时长) |
| `PATBOND_MINIO_ENDPOINT` | `http://minio:9000` | patbond-user(服务自身访问对象存储的地址) |
| `PATBOND_MINIO_PUBLIC_ENDPOINT` | `http://127.0.0.1:9000` | user / community(**客户端可达**地址,预签名 URL 的 host 由它决定;容器内外不同) |
| `PATBOND_MINIO_ACCESS_KEY` / `_SECRET_KEY` | _(无)_ | user / community |
| `PATBOND_MINIO_BUCKET` | `patbond-media` | user / community |
| `PATBOND_AI_SERVICE_URL` | `http://127.0.0.1:8000` | patbond-creation(指向 patbond-ai) |
| `PATBOND_SERVICE_JWT_PRIVATE_KEY` | _(无)_ | patbond-creation(**独立于用户 token 的** RS256 私钥) |
| `PATBOND_CREATION_WORKER_ENABLED` | `true` | patbond-creation(关掉则只提供端点、不消费队列) |
| `PATBOND_CREATION_LEASE_TTL` | `60s` | patbond-creation(**必须大于最长轮询间隔**,否则任务在跑租约就过期被抢) |
Machine-specific values live in the git-ignored `application.yml` (copied from the
committed `.sample`); never commit secrets to the samples.
各服务的 `application.yml.sample` 是环境变量的完整清单与说明,本表只列常用项。机器相关的实际值放在被 gitignore 的 `application.yml` 里;**样例文件里永远不要提交真实密钥**。
## Docker Compose
MVP 编排(ADR-007):`postgres:18`(数据落 volume)+ 两个无状态应用容器。
配置与本机运行同一套约定 —— 容器内挂载 `application.yml.sample` 作为配置,
`PATBOND_*` 环境变量注入实际值。
编排(ADR-007/016):`postgres:18` + 自托管 `minio` + 无状态应用容器。配置与本机运行同一套约定 —— 容器内挂载 `application.yml.sample` 作为配置,`PATBOND_*` 环境变量注入实际值。
```bash
# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌;产物被 .gitignore 忽略)
# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌、MinIO 凭证;产物被 gitignore)
./deploy/init-secrets.sh
# 2. 构建可执行 jar
@@ -127,40 +166,51 @@ curl -s -X POST http://127.0.0.1:8081/api/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"username":"demo_user","password":"secret123"}'
# 停止(-v 同时删除数据库数据)
# 停止(-v 同时删除数据库与对象存储数据)
docker compose down
```
注意:数据库端口不对宿主机发布;`8082`(user)在 MVP 阶段直连暴露以提供
`/api/v1/me`,`/internal/**` 由服务间令牌保护,规模化阶段应改由网关统一入口。
`patbond-creation` 尚未加入 compose——它的 Worker 依赖 `patbond-ai` 与对象存储方案,两者都还没落地。
## Services
注意:数据库端口不对宿主机发布;`8082`(user)在 MVP 阶段直连暴露以提供 `/api/v1/me`,`/internal/**` 由服务间令牌保护,规模化阶段应改由网关统一入口。
### Auth Service
## 服务端点
- Application name: `patbond-auth`
- Default port: `8081`
- Register: `POST /api/v1/auth/register`
- Login: `POST /api/v1/auth/login`
- Refresh (rotates the refresh token): `POST /api/v1/auth/refresh`
- Logout (revokes the current session): `POST /api/v1/auth/logout`
### Auth(8081)
### User Service
- 注册:`POST /api/v1/auth/register`
- 登录:`POST /api/v1/auth/login`
- 刷新(轮换 refresh token):`POST /api/v1/auth/refresh`
- 登出(吊销当前会话):`POST /api/v1/auth/logout`
- Application name: `patbond-user`
- Default port: `8082`
- Current user profile: `GET /api/v1/me` (Bearer access token, verified locally with the RS256 public key)
- Internal (require `X-Internal-Token`):
- Create user: `POST /internal/users`
- Verify password: `POST /internal/users/verify-password`
- Get user by id: `GET /internal/users/{id}`
- Get user by username: `GET /internal/users/by-username/{username}`
- Sessions: `POST /internal/sessions`, `POST /internal/sessions/refresh`, `POST /internal/sessions/revoke`
### User(8082)
> Note: user data is persisted in PostgreSQL (`identity.users` /
> `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in
> the application). Refresh sessions live in `identity.auth_sessions` (SHA-256
> digests only, rotation on every refresh, token-family revocation on reuse,
> ADR-003). Errors follow the `{code, message, data}` envelope with stable
> business codes and matching HTTP statuses; the public contract is documented
> in `patbond-doc/docs/api/openapi.yaml`.
- 当前用户资料:`GET /api/v1/me`、`PATCH /api/v1/me`
- 社区统计:`GET /api/v1/me/community-stats`
- 媒体上传:创建上传凭据与确认(ADR-016:私有桶 + 预签名)
- 埋点接收:`POST /api/v1/events`(批量 1–50、逐条结果、eventId 幂等、白名单剥离)
- 内部接口(需 `X-Internal-Token`):`POST /internal/users`、`POST /internal/users/verify-password`、`GET /internal/users/{id}`、`GET /internal/users/by-username/{username}`、`GET /internal/users/profiles`、`POST /internal/sessions{,/refresh,/revoke}`
### Pet(8083)
宠物档案、体重与疫苗记录、照护提醒;权限按 ADR-015 三档模型(owner / caregiver / viewer)。
### Community(8084)
Feed 游标分页、帖子草稿与发布、单层评论、点赞与收藏(幂等)、关注与我的收藏。
### Creation(8085)
- 创建生成任务:`POST /api/v1/creation/jobs`(**必带 `Idempotency-Key`**,ADR-019——生成在上游是要花钱的,重试绝不能变成第二个计费任务)
- 查询任务:`GET /api/v1/creation/jobs/{jobId}`(**轮询端点**)
- 我的任务列表:`GET /api/v1/creation/jobs`
- 取消任务:`DELETE /api/v1/creation/jobs/{jobId}`
- 模型与风格目录:`GET /api/v1/creation/catalog`
进度靠轮询上面那个 GET,**没有流式端点**:SSE 会旁路整条「信封解包 + 401 单飞刷新重放」的客户端网络层、形成第二套鉴权路径,而任何推送方案都必须有这个 GET 兜底(ADR-023 §10)。
目录端点会返回「哪些 `media_kind` 真的有可用模型」,客户端据此决定是否渲染类型切换器——所以目录为空的类型不会出现,不会给用户一个承诺了却做不到的控件;V7 种子落地后 UI 会自己长出视频分组,无需发版。
任务响应里的 `timeoutSec` 与 `stalled` 由服务端下发而非客户端硬编码:图片与视频差一个量级,硬编码两个常量意味着换 provider 就要发版。`stalled`(第六个 UI 态)**不是错误态**——任务仍可能完成。
> 用户数据落在 PostgreSQL(`identity.users` / `identity.user_credentials`,bcrypt 口令散列,应用侧生成 UUIDv7)。refresh 会话在 `identity.auth_sessions`(只存 SHA-256 摘要,每次刷新轮换,重用即吊销整个 token family,ADR-003)。错误统一走 `{code, message, data}` 信封与稳定业务码;对外契约见 `patbond-doc/docs/api/openapi.yaml`,架构决策见 `patbond-doc/docs/architecture/decisions.md`。
@@ -28,12 +28,22 @@ public enum ErrorCode {
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
COMMENT_NOT_FOUND(40404, 404, "评论不存在"),
TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"),
// M4 生成域(ADR-023 §9)。40407/40408 与既有 404 族同列;配额与限流的
// 骨架阶段只加了实际使用的码;两个 429 已随 AI 适配层落地(见下)。
GENERATION_JOB_NOT_FOUND(40407, 404, "生成任务不存在"),
GENERATION_MODEL_NOT_FOUND(40408, 404, "模型或风格不可用"),
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"),
FOLLOW_RULE_VIOLATION(42204, 422, "不能关注自己"),
MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
GENERATION_STATE_INVALID(42206, 422, "生成任务状态不允许该操作"),
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
// M4 生成域的两个 429(ADR-023 §9)。**必须是两个码**:配额耗尽等的是次日或
// 充值(客户端弹 sheet 给出口),频率过快等的是数秒(弹 SnackBar 可自动重试)。
// 共用一码必然使一半场景的文案与出口是错的。
GENERATION_QUOTA_EXHAUSTED(42900, 429, "今日 AI 生成额度已用完"),
GENERATION_RATE_LIMITED(42901, 429, "操作过于频繁,请稍后再试"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
+11
View File
@@ -0,0 +1,11 @@
# Runtime image only — build the jar first: ./mvnw -pl patbond-creation -am package
# Stateless by design (ADR-007): no local state, config via env / mounted files.
# Job state lives in postgres; the worker is crash-safe through the DB lease,
# so any replica may be restarted without losing in-flight work (ADR-023 §3).
FROM eclipse-temurin:17-jre
RUN useradd --system --uid 10001 patbond
USER patbond
WORKDIR /app
COPY target/patbond-creation-1.0.0-SNAPSHOT-exec.jar app.jar
EXPOSE 8085
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
+148
View File
@@ -0,0 +1,148 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-api</artifactId>
<version>1.0.0-SNAPSHOT</version>
<relativePath>../pom.xml</relativePath>
</parent>
<artifactId>patbond-creation</artifactId>
<packaging>jar</packaging>
<name>patbond-creation</name>
<description>AI generation job orchestration service for Patbond (ADR-023)</description>
<!-- M4 skeleton: RS256 bearer auth on /api/v1/** (fourth copy of the
user/pet/community stack), creation schema access via JDBC, and the
lease queue worker. This module owns job state; the upstream provider
call lives in patbond-ai (Python) and is reached over REST.
Flyway remains absent — the migration chain is owned by patbond-user. -->
<dependencies>
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- Outbound calls to patbond-ai (generation) and patbond-user
(/internal/media/** upload credentials), static direct URLs per
ADR-002. feign-hc5 for the same reason as auth/community: the JDK
default client loses error bodies on some replies. -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
</dependency>
<dependency>
<groupId>io.github.openfeign</groupId>
<artifactId>feign-hc5</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Access token verification (RS256 public key) AND service-identity
token signing (RS256 private key, a SEPARATE key pair per
ADR-023 §5). jjwt is not in the Boot BOM; version pinned in step
with patbond-user/auth/pet/community. -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Tests need the creation schema. The migration chain (V1..V6) is
owned by patbond-user (single flyway_schema_history); pulling its
plain jar plus Flyway into the TEST classpath lets Boot's Flyway
auto-config apply the same chain to the disposable container.
Production wiring is unchanged (same mechanism as pet/community). -->
<dependency>
<groupId>com.patbond.patbond</groupId>
<artifactId>patbond-user</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- No spring-boot-starter-parent in this build, so the
executable-jar repackaging must be bound explicitly. -->
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<!-- Keep the plain jar as the main artifact so other
modules can depend on this one; the runnable fat
jar gets the -exec classifier and is what the
Dockerfile ships (same pattern as the others). -->
<classifier>exec</classifier>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,32 @@
package com.patbond.patbond.creation;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* AI generation orchestration service (M4, ADR-023 §2: the creation domain
* lives in its own Maven module on :8085).
*
* <p>What this service owns: the {@code creation} schema, the client-facing
* generation endpoints under {@code /api/v1/creation/**}, and the lease queue
* worker. What it does NOT own: the upstream vendor call, which lives in
* patbond-ai (Python) and is reached over REST with a service-identity JWT; and
* object storage, which it never touches — upload credentials come from
* patbond-user's internal media API, keeping the ADR-017 boundary intact.
*
* <p>{@code @EnableScheduling} drives the worker. The worker is crash-safe
* through the database lease rather than through in-process state, so replicas
* may be restarted freely; {@code patbond.creation.worker.enabled} turns
* consumption off for a request-only replica or a test.
*/
@SpringBootApplication
@EnableFeignClients
@EnableScheduling
public class CreationApplication {
public static void main(String[] args) {
SpringApplication.run(CreationApplication.class, args);
}
}
@@ -0,0 +1,329 @@
package com.patbond.patbond.creation.config;
import com.patbond.patbond.creation.domain.MediaKind;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Generation-specific knobs: the upstream service address, the service-identity
* token, the worker's lease and polling ladder, and the per-kind timeouts the
* server hands to clients.
*
* <p>Defaults here are the ones ADR-023 §4/§10 argues for, so a service with no
* overrides behaves as designed rather than as whatever happened to be
* convenient.
*/
@ConfigurationProperties(prefix = "patbond.creation")
public class CreationProperties {
private final AiService aiService = new AiService();
private final ServiceToken serviceToken = new ServiceToken();
private final Worker worker = new Worker();
/**
* Hard timeout handed to the client per media kind. Issued by the server
* rather than hardcoded client-side: image and video differ by an order of
* magnitude, and two hardcoded constants would mean shipping a new app
* build every time a provider changes (ADR-023 §10).
*/
private Duration clientTimeoutImage = Duration.ofMinutes(5);
private Duration clientTimeoutVideo = Duration.ofMinutes(20);
/**
* How long a RUNNING job may go before the client shows the sixth UI
* state — stalled/timed-out. That state is deliberately NOT an error state:
* the job may still complete.
*/
private Duration staleAfterImage = Duration.ofMinutes(10);
private Duration staleAfterVideo = Duration.ofMinutes(40);
public AiService getAiService() {
return aiService;
}
public ServiceToken getServiceToken() {
return serviceToken;
}
public Worker getWorker() {
return worker;
}
public Duration clientTimeoutFor(MediaKind kind) {
return kind == MediaKind.VIDEO ? clientTimeoutVideo : clientTimeoutImage;
}
public Duration staleAfterFor(MediaKind kind) {
return kind == MediaKind.VIDEO ? staleAfterVideo : staleAfterImage;
}
public Duration getClientTimeoutImage() {
return clientTimeoutImage;
}
public void setClientTimeoutImage(Duration value) {
this.clientTimeoutImage = value;
}
public Duration getClientTimeoutVideo() {
return clientTimeoutVideo;
}
public void setClientTimeoutVideo(Duration value) {
this.clientTimeoutVideo = value;
}
public Duration getStaleAfterImage() {
return staleAfterImage;
}
public void setStaleAfterImage(Duration value) {
this.staleAfterImage = value;
}
public Duration getStaleAfterVideo() {
return staleAfterVideo;
}
public void setStaleAfterVideo(Duration value) {
this.staleAfterVideo = value;
}
public static class AiService {
private String url = "http://127.0.0.1:8000";
/**
* Timeout of the SUBMIT/POLL call, not of the generation. The
* Java↔Python boundary is asynchronous regardless of whether the vendor
* is (ADR-023 §4), so this must not be inflated to "wait for the
* picture" — doing so reintroduces the held-connection and
* ambiguous-billing problems the async boundary exists to remove.
*/
private Duration connectTimeout = Duration.ofSeconds(2);
private Duration readTimeout = Duration.ofSeconds(5);
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration value) {
this.connectTimeout = value;
}
public Duration getReadTimeout() {
return readTimeout;
}
public void setReadTimeout(Duration value) {
this.readTimeout = value;
}
}
public static class ServiceToken {
/**
* RS256 private key, inline PEM or path — a key pair SEPARATE from the
* user-token one (ADR-023 §5). Absent, the service still starts and
* only fails when it actually needs to call patbond-ai, matching how a
* missing JWT public key is handled elsewhere.
*/
private String privateKey;
private String issuer = "patbond-api";
private String audience = "patbond-ai";
private String subject = "service:creation-worker";
/** Signed per call, never cached — see ServiceTokenSigner. */
private Duration ttl = Duration.ofMinutes(5);
public String getPrivateKey() {
return privateKey;
}
public void setPrivateKey(String value) {
this.privateKey = value;
}
public String getIssuer() {
return issuer;
}
public void setIssuer(String value) {
this.issuer = value;
}
public String getAudience() {
return audience;
}
public void setAudience(String value) {
this.audience = value;
}
public String getSubject() {
return subject;
}
public void setSubject(String value) {
this.subject = value;
}
public Duration getTtl() {
return ttl;
}
public void setTtl(Duration value) {
this.ttl = value;
}
}
public static class Worker {
/**
* When false the service still serves read/write endpoints but consumes
* no jobs. Used to keep tests off the queue and to run a
* request-serving replica without a worker.
*/
private boolean enabled = true;
/**
* Scheduler tick. The real cadence comes from {@code next_attempt_at}
* and the lease; the tick only bounds how soon due work is noticed.
*/
private Duration tickInterval = Duration.ofSeconds(3);
/** Cap per tick so one replica cannot drain the whole queue. */
private int batchSize = 5;
/**
* Lease duration. MUST exceed the longest polling interval below,
* otherwise a job still being worked on has its lease expire and gets
* reclaimed mid-flight — the classic route to paying a provider twice.
* Renewed on every poll.
*/
private Duration leaseTtl = Duration.ofSeconds(60);
/**
* Backoff ladder for polling patbond-ai. A flat 3s would mean hundreds
* of pointless polls and lease renewals over a multi-minute video job.
*/
private Duration pollIntervalEarly = Duration.ofSeconds(3);
private Duration pollIntervalMid = Duration.ofSeconds(10);
private Duration pollIntervalLate = Duration.ofSeconds(30);
private Duration pollEarlyUntil = Duration.ofSeconds(60);
private Duration pollMidUntil = Duration.ofMinutes(5);
/** Retry backoff base; actual delay is base * 2^(attempt-1). */
private Duration retryBackoffBase = Duration.ofSeconds(30);
/**
* Chooses the polling interval from how long the job has been running.
*
* @param runningFor elapsed time since the current attempt started
*/
public Duration pollIntervalFor(Duration runningFor) {
if (runningFor.compareTo(pollEarlyUntil) < 0) {
return pollIntervalEarly;
}
return runningFor.compareTo(pollMidUntil) < 0 ? pollIntervalMid : pollIntervalLate;
}
/** Exponential backoff for a retryable failure, capped at 10 minutes. */
public Duration retryBackoffFor(int attemptCount) {
long multiplier = 1L << Math.min(Math.max(attemptCount - 1, 0), 8);
Duration backoff = retryBackoffBase.multipliedBy(multiplier);
Duration cap = Duration.ofMinutes(10);
return backoff.compareTo(cap) > 0 ? cap : backoff;
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean value) {
this.enabled = value;
}
public Duration getTickInterval() {
return tickInterval;
}
public void setTickInterval(Duration value) {
this.tickInterval = value;
}
public int getBatchSize() {
return batchSize;
}
public void setBatchSize(int value) {
this.batchSize = value;
}
public Duration getLeaseTtl() {
return leaseTtl;
}
public void setLeaseTtl(Duration value) {
this.leaseTtl = value;
}
public Duration getPollIntervalEarly() {
return pollIntervalEarly;
}
public void setPollIntervalEarly(Duration value) {
this.pollIntervalEarly = value;
}
public Duration getPollIntervalMid() {
return pollIntervalMid;
}
public void setPollIntervalMid(Duration value) {
this.pollIntervalMid = value;
}
public Duration getPollIntervalLate() {
return pollIntervalLate;
}
public void setPollIntervalLate(Duration value) {
this.pollIntervalLate = value;
}
public Duration getPollEarlyUntil() {
return pollEarlyUntil;
}
public void setPollEarlyUntil(Duration value) {
this.pollEarlyUntil = value;
}
public Duration getPollMidUntil() {
return pollMidUntil;
}
public void setPollMidUntil(Duration value) {
this.pollMidUntil = value;
}
public Duration getRetryBackoffBase() {
return retryBackoffBase;
}
public void setRetryBackoffBase(Duration value) {
this.retryBackoffBase = value;
}
}
}
@@ -0,0 +1,104 @@
package com.patbond.patbond.creation.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
/**
* Security knobs of the creation service: the RS256 public key for verifying
* access tokens issued by patbond-auth (same contract as the other services'
* {@code patbond.jwt.public-key}), and the shared service secret presented on
* outbound {@code /internal/**} calls to patbond-user.
*
* <p>Note that the credential for calling patbond-ai is NOT here — that hop
* uses a service-identity JWT with its own key pair, configured under
* {@code patbond.creation.service-token} (ADR-023 §5). Two internal auth
* mechanisms coexist on purpose: the existing Java↔Java channel keeps its
* shared token, the Python hop gets the stronger scheme. Reworking the former
* is deliberately out of M4's scope.
*/
@ConfigurationProperties(prefix = "patbond")
public class CreationSecurityProperties {
/**
* Shared secret sent as X-Internal-Token on calls to patbond-user's
* /internal/** API; must equal the value patbond-user expects.
*/
private String internalToken;
private final Jwt jwt = new Jwt();
private final UserService userService = new UserService();
public String getInternalToken() {
return internalToken;
}
public void setInternalToken(String value) {
this.internalToken = value;
}
public Jwt getJwt() {
return jwt;
}
public UserService getUserService() {
return userService;
}
public static class Jwt {
/**
* RS256 public key for verifying access tokens signed by patbond-auth:
* either inline PEM (starts with -----BEGIN) or a filesystem path. The
* private key never reaches this service.
*/
private String publicKey;
public String getPublicKey() {
return publicKey;
}
public void setPublicKey(String publicKey) {
this.publicKey = publicKey;
}
}
public static class UserService {
/**
* patbond-user base URL, ADR-002 static direct addressing. Source of
* presigned upload credentials via /internal/media/** — this service
* never talks to the object store itself (ADR-017 boundary).
*/
private String url = "http://127.0.0.1:8082";
private Duration connectTimeout = Duration.ofSeconds(1);
private Duration readTimeout = Duration.ofSeconds(2);
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public Duration getConnectTimeout() {
return connectTimeout;
}
public void setConnectTimeout(Duration connectTimeout) {
this.connectTimeout = connectTimeout;
}
public Duration getReadTimeout() {
return readTimeout;
}
public void setReadTimeout(Duration readTimeout) {
this.readTimeout = readTimeout;
}
}
}
@@ -0,0 +1,21 @@
package com.patbond.patbond.creation.config;
import com.fasterxml.jackson.databind.DeserializationFeature;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Integer fields must reject decimal input (development-plan 4.3) — Jackson's
* default is to silently truncate 45.5 to 45 in an integer field, which
* would corrupt values instead of rejecting them. Same setting as the other
* services so all envelopes behave identically.
*/
@Configuration
public class JacksonConfig {
@Bean
public Jackson2ObjectMapperBuilderCustomizer rejectFloatAsInt() {
return builder -> builder.featuresToDisable(DeserializationFeature.ACCEPT_FLOAT_AS_INT);
}
}
@@ -0,0 +1,43 @@
package com.patbond.patbond.creation.config;
import com.patbond.patbond.creation.provider.GenerationProvider;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* {@code provider_code} → adapter lookup.
*
* <p>Resolution is by the job's frozen {@code provider_code_snapshot}, never by
* the current catalog. That is the entire reason the snapshot columns exist: a
* model row may later be disabled or re-pointed at a different vendor, and jobs
* submitted under the old configuration must still be pollable by the adapter
* that submitted them. Routing by the live catalog would silently ask the wrong
* vendor about someone else's request id.
*/
public class ProviderRegistry {
private final Map<String, GenerationProvider> byCode;
public ProviderRegistry(List<GenerationProvider> providers) {
this.byCode = providers.stream().collect(Collectors.toUnmodifiableMap(
GenerationProvider::providerCode, Function.identity()));
}
/**
* @return the adapter that serves this provider code, empty when the code is
* unknown — which for a persisted job means its vendor adapter was
* removed while work was in flight. The worker must treat that as a
* non-retryable failure rather than looping.
*/
public Optional<GenerationProvider> find(String providerCode) {
return Optional.ofNullable(byCode.get(providerCode));
}
public java.util.Set<String> knownCodes() {
return byCode.keySet();
}
}
@@ -0,0 +1,84 @@
package com.patbond.patbond.creation.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.creation.provider.AiServiceGenerationProvider;
import com.patbond.patbond.creation.provider.FixtureGenerationProvider;
import com.patbond.patbond.creation.provider.GenerationProvider;
import com.patbond.patbond.creation.provider.ai.AiGenerationClient;
import com.patbond.patbond.creation.security.BearerAuthFilter;
import com.patbond.patbond.creation.security.JwtVerifier;
import com.patbond.patbond.creation.security.ServiceTokenSigner;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
/**
* Wires bearer authentication for {@code /api/v1/**} without pulling in
* spring-security — the same single-filter pattern user/pet/community use. The
* {@code /health} probe stays outside {@code /api/v1} and therefore
* unauthenticated.
*
* <p>Also wires the provider registry. Providers are looked up by
* {@code provider_code} so a job's frozen {@code provider_code_snapshot} keeps
* routing to the right adapter even after the catalog is re-pointed — that is
* the whole point of snapshotting it.
*/
@Configuration
@EnableConfigurationProperties({CreationSecurityProperties.class, CreationProperties.class})
public class SecurityConfig {
@Bean
public JwtVerifier jwtVerifier(CreationSecurityProperties properties) {
return new JwtVerifier(properties.getJwt().getPublicKey());
}
@Bean
public FilterRegistrationBean<BearerAuthFilter> bearerAuthFilter(
JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
FilterRegistrationBean<BearerAuthFilter> registration = new FilterRegistrationBean<>(
new BearerAuthFilter(jwtVerifier, objectMapper));
registration.addUrlPatterns("/api/v1/*");
registration.setOrder(20);
return registration;
}
/**
* Built even when no private key is configured: an absent key must not stop
* the service from starting and serving reads, exactly as an absent JWT
* public key does not. The failure surfaces at {@code sign()} — when a call
* to patbond-ai is actually attempted — and reports itself as a server
* misconfiguration rather than an upstream fault.
*/
@Bean
public ServiceTokenSigner serviceTokenSigner(CreationProperties properties) {
CreationProperties.ServiceToken config = properties.getServiceToken();
return new ServiceTokenSigner(config.getPrivateKey(), config.getIssuer(),
config.getAudience(), config.getSubject(), config.getTtl());
}
@Bean
public FixtureGenerationProvider fixtureGenerationProvider() {
return new FixtureGenerationProvider();
}
/**
* 真实的服务商适配器,经 patbond-ai 到达。
*
* <p>注意它与 fixture 适配器**同时注册**:`provider_code` 不同,故任务行冻结的
* 快照决定用哪个——这正是快照列存在的意义,目录被改指向后在途任务仍由当初提交
* 它的适配器轮询。
*/
@Bean
public AiServiceGenerationProvider aiServiceGenerationProvider(AiGenerationClient client) {
return new AiServiceGenerationProvider(client);
}
/** provider_code → adapter, so a job's snapshot routes deterministically. */
@Bean
public ProviderRegistry providerRegistry(List<GenerationProvider> providers) {
return new ProviderRegistry(providers);
}
}
@@ -0,0 +1,72 @@
package com.patbond.patbond.creation.controller;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.creation.domain.MediaKind;
import com.patbond.patbond.creation.repository.CatalogRepository;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
/**
* The model / style catalog the create screen renders from.
*
* <p>{@code GET /catalog} reports which media kinds actually have enabled
* models. The client is specified to render its kind switcher from that list
* rather than from a hardcoded one, so a kind with an empty catalog simply does
* not appear — instead of offering a control that promises a capability the
* backend cannot deliver. It also means the UI grows a video section on its own
* once the V7 seed lands, with no client release.
*/
@RestController
@RequestMapping("/api/v1/creation")
public class CatalogController {
private final CatalogRepository catalog;
public CatalogController(CatalogRepository catalog) {
this.catalog = catalog;
}
/** @param mediaKind optional filter; absent returns every enabled kind */
@GetMapping("/catalog")
public ApiResponse<CatalogResponse> catalog(
@RequestParam(required = false) String mediaKind) {
List<String> kinds = mediaKind == null
? catalog.availableMediaKinds()
: List.of(parse(mediaKind).dbValue());
List<ModelSummary> models = kinds.stream()
.flatMap(kind -> catalog.listEnabledModels(MediaKind.fromDb(kind)).stream())
.map(row -> new ModelSummary(row.id(), row.code(), row.mediaKind().dbValue()))
.toList();
return ApiResponse.success(new CatalogResponse(kinds, models));
}
private static MediaKind parse(String value) {
try {
return MediaKind.fromDb(value);
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "mediaKind 只能是 image 或 video");
}
}
/**
* @param availableMediaKinds drives whether the client shows a kind switcher
* at all, and with which segments
*/
public record CatalogResponse(List<String> availableMediaKinds, List<ModelSummary> models) {
}
/**
* Deliberately omits {@code providerCode} and {@code providerModelName}:
* which vendor serves a model is an internal detail, and exposing it would
* both leak the supply chain and invite clients to branch on it.
*/
public record ModelSummary(UUID id, String code, String mediaKind) {
}
}
@@ -0,0 +1,102 @@
package com.patbond.patbond.creation.controller;
import com.patbond.patbond.common.response.ApiResponse;
import com.patbond.patbond.creation.dto.CreateGenerationJobRequest;
import com.patbond.patbond.creation.dto.GenerationJobResponse;
import com.patbond.patbond.creation.security.BearerAuthFilter;
import com.patbond.patbond.creation.service.GenerationService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.UUID;
/**
* Client-facing generation endpoints (M4, ADR-023 §2).
*
* <p>All four are plain request/response: creation enqueues and returns
* immediately, progress is read by polling {@code GET .../jobs/{id}}. There is
* deliberately no streaming endpoint — SSE would bypass the envelope-and-refresh
* chain the whole client network layer is built on, creating a second auth path,
* and every push design needs this GET as a fallback anyway (ADR-023 §10).
*/
@RestController
@RequestMapping("/api/v1/creation")
@Validated
public class GenerationController {
private final GenerationService generationService;
public GenerationController(GenerationService generationService) {
this.generationService = generationService;
}
/**
* Enqueues a job. The Idempotency-Key header is mandatory (ADR-019): a
* generation request costs real money upstream, so a retried request must
* never be able to become a second billable job.
*/
@PostMapping("/jobs")
public ApiResponse<GenerationJobResponse> create(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestHeader("Idempotency-Key")
@NotBlank(message = "Idempotency-Key 不能为空")
@Size(max = 128, message = "Idempotency-Key 最长 128 字符")
String idempotencyKey,
@Valid @RequestBody CreateGenerationJobRequest request) {
return ApiResponse.success(
generationService.create(userId, idempotencyKey.trim(), request));
}
/** The polling endpoint. Also the fallback any future push design needs. */
@GetMapping("/jobs/{jobId}")
public ApiResponse<GenerationJobResponse> get(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID jobId) {
return ApiResponse.success(generationService.get(userId, jobId));
}
/**
* The user's own jobs, newest first.
*
* <p>This is what makes "leaving the waiting screen" safe: a job in flight
* can always be found again here, which is why the client is allowed to
* navigate away — and why it must never tell the user it will notify them,
* since the app has no push capability at all (ADR-023 §10).
*/
@GetMapping("/jobs")
public ApiResponse<List<GenerationJobResponse>> list(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@RequestParam(defaultValue = "20")
@Min(value = 1, message = "limit 最小为 1")
@Max(value = 100, message = "limit 最大为 100")
int limit) {
return ApiResponse.success(generationService.list(userId, limit));
}
/**
* Cancels a job that has not finished. Answers 42206 when it already
* reached a terminal state rather than reporting a cancellation that did not
* happen.
*/
@DeleteMapping("/jobs/{jobId}")
public ApiResponse<GenerationJobResponse> cancel(
@RequestAttribute(BearerAuthFilter.USER_ID_ATTRIBUTE) UUID userId,
@PathVariable UUID jobId) {
return ApiResponse.success(generationService.cancel(userId, jobId));
}
}
@@ -0,0 +1,64 @@
package com.patbond.patbond.creation.domain;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* A row of {@code creation.generation_jobs} (V6), as far as this service needs
* it. Deliberately not the full column set: the snapshot columns and the
* request hash are write-once bookkeeping the worker never reads back, and
* loading them into every poll would only invite accidental mutation.
*
* @param firstAttemptAt written once on the first transition into RUNNING and
* never overwritten — it is what survives re-queueing so that queue wait
* ({@code firstAttemptAt - createdAt}) stays separable from pure execution
* time ({@code completedAt - firstAttemptAt}). The target model lacks this
* column; V6 adds it deliberately (ADR-023 §6).
* @param startedAt the CURRENT attempt's start. Cleared on every re-queue by
* {@code ck_generation_jobs_state}, which is exactly why
* {@code firstAttemptAt} has to exist separately.
*/
public record GenerationJob(
UUID id,
UUID userId,
UUID petId,
MediaKind mediaKind,
UUID modelId,
UUID styleId,
UUID inputAssetId,
UUID outputAssetId,
String prompt,
String negativePrompt,
Integer widthPx,
Integer heightPx,
Long durationMs,
JobStatus status,
short progress,
short attemptCount,
short maxAttempts,
String providerCodeSnapshot,
String providerModelSnapshot,
String providerRequestId,
String errorCode,
String errorMessage,
OffsetDateTime createdAt,
OffsetDateTime firstAttemptAt,
OffsetDateTime startedAt,
OffsetDateTime completedAt,
OffsetDateTime leaseExpiresAt) {
/** @return true when the job has never been attempted (attemptCount == 0) */
public boolean isFirstAttempt() {
return attemptCount == 0;
}
/** @return true when the next failure exhausts the retry budget */
public boolean isLastAttempt() {
return attemptCount >= maxAttempts;
}
/** @return true when the job was already handed to the provider */
public boolean isSubmittedUpstream() {
return providerRequestId != null;
}
}
@@ -0,0 +1,44 @@
package com.patbond.patbond.creation.domain;
/**
* Generation job lifecycle, mirroring {@code ck_generation_jobs_status} and
* the branch structure of {@code ck_generation_jobs_state} (V6).
*
* <p>The database — not this enum — is the authority on which transitions are
* legal: every branch of the state CHECK pins the full column tuple for its
* status, so an illegal transition fails as a constraint violation rather than
* silently persisting a half-state. Keep this enum in step with V6; do not
* encode transition rules here that the CHECK does not enforce.
*/
public enum JobStatus {
QUEUED("queued"),
RUNNING("running"),
SUCCEEDED("succeeded"),
FAILED("failed"),
CANCELLED("cancelled");
private final String dbValue;
JobStatus(String dbValue) {
this.dbValue = dbValue;
}
public String dbValue() {
return dbValue;
}
/** @return true when no further work will ever be done on the job */
public boolean isTerminal() {
return this == SUCCEEDED || this == FAILED || this == CANCELLED;
}
public static JobStatus fromDb(String value) {
for (JobStatus status : values()) {
if (status.dbValue.equals(value)) {
return status;
}
}
throw new IllegalArgumentException("Unknown generation job status: " + value);
}
}
@@ -0,0 +1,36 @@
package com.patbond.patbond.creation.domain;
/**
* The two generation kinds, mirroring {@code ck_generation_jobs_kind} and the
* catalog's {@code media_kind} (V6).
*
* <p>Both are structurally supported from V6 onwards and both ship in M4
* (ADR-023 §1, superseding ADR-018's "视频后置"). The catalog is keyed by
* {@code (id, media_kind)} and jobs carry a composite FK into it, so a video
* job referencing an image model is rejected by the database rather than by
* application code.
*/
public enum MediaKind {
IMAGE("image"),
VIDEO("video");
private final String dbValue;
MediaKind(String dbValue) {
this.dbValue = dbValue;
}
public String dbValue() {
return dbValue;
}
public static MediaKind fromDb(String value) {
for (MediaKind kind : values()) {
if (kind.dbValue.equals(value)) {
return kind;
}
}
throw new IllegalArgumentException("Unknown media kind: " + value);
}
}
@@ -0,0 +1,79 @@
package com.patbond.patbond.creation.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* Request to start a generation job.
*
* <p><b>{@code inputAssetId} is optional on purpose.</b> The reference image is
* optional (decision A2): mainstream image and video providers support both
* text-to-* and reference-guided generation, and which one a request means is
* decided by whether an input asset is present. The target data model had this
* column NOT NULL, which would have made text-to-image unrepresentable — V6
* relaxes it deliberately (ADR-023 §6).
*
* <p>Whether a PARTICULAR model requires an input is a catalog question, checked
* by the service. It is intentionally not expressed as a schema constraint: no
* {@code requires_input} flag was invented beyond the reviewed model.
*
* <p>{@code mediaKind} is validated against the catalog rather than trusted:
* the composite FK {@code (model_id, media_kind)} would reject a mismatch at the
* database level anyway, but answering 404 beats surfacing a constraint
* violation.
*/
public record CreateGenerationJobRequest(
@NotBlank(message = "mediaKind 不能为空")
String mediaKind,
@NotNull(message = "modelId 不能为空")
UUID modelId,
UUID styleId,
/** Optional — absent means text-to-*, present means reference-guided. */
UUID inputAssetId,
UUID petId,
@Size(max = 10000, message = "prompt 最长 10000 字符")
String prompt,
@Size(max = 3000, message = "negativePrompt 最长 3000 字符")
String negativePrompt,
Integer widthPx,
Integer heightPx,
Long durationMs) {
/**
* Canonical form hashed into {@code request_hash} for idempotency
* comparison (ADR-019). Hashes the NORMALIZED command rather than raw bytes
* so the comparison is insensitive to JSON formatting while still catching
* every semantic difference — a keyed retry with the same meaning returns
* the first job, a different payload answers 40905.
*/
public String canonical() {
return String.join("|",
nullSafe(mediaKind),
nullSafe(modelId),
nullSafe(styleId),
nullSafe(inputAssetId),
nullSafe(petId),
nullSafe(prompt == null ? null : prompt.trim()),
nullSafe(negativePrompt == null ? null : negativePrompt.trim()),
nullSafe(widthPx),
nullSafe(heightPx),
nullSafe(durationMs));
}
private static String nullSafe(Object value) {
return value == null ? "" : value.toString();
}
}
@@ -0,0 +1,100 @@
package com.patbond.patbond.creation.dto;
import com.patbond.patbond.creation.domain.GenerationJob;
import com.patbond.patbond.creation.domain.JobStatus;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* A generation job as the client sees it.
*
* <p>Two fields deserve explanation because they encode client-facing decisions
* from ADR-023 §10 rather than raw columns:
*
* <ul>
* <li>{@code timeoutSec} is issued BY THE SERVER, not hardcoded per kind in
* the app. Image and video differ by an order of magnitude, and baking two
* constants into the client would mean a new app build whenever a provider
* changes.</li>
* <li>{@code stalled} is the sixth UI state — a RUNNING job that has exceeded
* its expected window. It is deliberately NOT an error: the job may still
* finish, so the client shows a different affordance rather than a
* failure. Computing it here keeps the threshold configurable in one
* place.</li>
* </ul>
*
* <p>{@code progress} is reported as-is and the contract promises nothing about
* it advancing smoothly: the state CHECK pins it to 0 while queued and puts no
* floor on it while running. Clients are told to treat an indeterminate
* indicator as the primary form, precisely so a truthful 0 does not read as
* "stuck at 0%".
*/
public record GenerationJobResponse(
UUID id,
String mediaKind,
String status,
int progress,
UUID modelId,
UUID styleId,
UUID inputAssetId,
UUID outputAssetId,
String prompt,
Integer widthPx,
Integer heightPx,
Long durationMs,
int attemptCount,
String errorCode,
String errorMessage,
OffsetDateTime createdAt,
OffsetDateTime startedAt,
OffsetDateTime completedAt,
long timeoutSec,
boolean stalled,
Long elapsedSec) {
public static GenerationJobResponse of(GenerationJob job, Duration clientTimeout,
Duration staleAfter) {
Duration elapsed = elapsedOf(job);
boolean stalled = job.status() == JobStatus.RUNNING
&& elapsed != null
&& elapsed.compareTo(staleAfter) > 0;
return new GenerationJobResponse(
job.id(),
job.mediaKind().dbValue(),
job.status().dbValue(),
job.progress(),
job.modelId(),
job.styleId(),
job.inputAssetId(),
job.outputAssetId(),
job.prompt(),
job.widthPx(),
job.heightPx(),
job.durationMs(),
job.attemptCount(),
job.errorCode(),
job.errorMessage(),
job.createdAt(),
job.startedAt(),
job.completedAt(),
clientTimeout.toSeconds(),
stalled,
elapsed == null ? null : elapsed.toSeconds());
}
/**
* Elapsed wall time of the job so far — measured from creation, not from the
* current attempt, because that is what the user actually waited. Shown
* INSTEAD of an ETA: elapsed time is a fact, an ETA is a promise, and M4
* cannot make a credible one.
*/
private static Duration elapsedOf(GenerationJob job) {
if (job.createdAt() == null) {
return null;
}
OffsetDateTime end = job.completedAt() != null ? job.completedAt() : OffsetDateTime.now();
return Duration.between(job.createdAt().toInstant(), end.toInstant());
}
}
@@ -0,0 +1,207 @@
package com.patbond.patbond.creation.provider;
import com.patbond.patbond.creation.domain.GenerationJob;
import com.patbond.patbond.creation.provider.ai.AiContract;
import com.patbond.patbond.creation.provider.ai.AiGenerationClient;
import com.patbond.patbond.creation.provider.ai.AiProviderException;
import com.patbond.patbond.creation.provider.ai.AiServiceException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Reaches 火山方舟 Seedance (video) through patbond-ai over REST.
*
* <p>This class is thin on purpose: all vendor knowledge — request shapes, error
* codes, per-model parameter limits, pixel tables — lives in patbond-ai. What is
* here is the translation between our job row and that service's contract, plus
* the mapping from its two error kinds onto what the worker should do.
*
* <p>{@code provider_code} must match the value patbond-ai registers its adapter
* under and the value seeded into {@code generation_models.provider_code} (V7).
* If the three drift apart, a job never finds an adapter and sits in the queue
* forever — a symptom that looks nothing like "two strings disagree", which is
* why both sides have a test pinning it.
*/
public class AiServiceGenerationProvider implements GenerationProvider {
/**
* Must equal patbond-ai's {@code PROVIDER_CODE} and V7's seeded
* {@code provider_code}. Changing any one of the three means changing all.
*/
public static final String PROVIDER_CODE = "volcengine-seedance";
private static final Logger log = LoggerFactory.getLogger(AiServiceGenerationProvider.class);
private final AiGenerationClient client;
public AiServiceGenerationProvider(AiGenerationClient client) {
this.client = client;
}
@Override
public String providerCode() {
return PROVIDER_CODE;
}
@Override
public SubmitResult submit(GenerationJob job, String uploadTarget) {
AiContract.SubmitRequest request = new AiContract.SubmitRequest(
job.id().toString(),
job.mediaKind().dbValue(),
// The model comes from the row's frozen snapshot, never from the
// live catalog: the catalog may be re-pointed, and a job must be
// billed against the model it actually ran on.
job.providerModelSnapshot(),
job.prompt(),
// Null means text-to-*, present means reference-guided
// (reference image optional, decision A2). The input asset must
// be reachable by the vendor, so this has to be a URL rather
// than our internal asset id.
null,
null,
null,
job.durationMs() == null ? null : (int) (job.durationMs() / 1000),
true,
false,
// Hash, never the plain user id (ADR-023 §5 data minimisation).
safetyIdentifierFor(job),
null,
// Unused until the object-storage decision lands (ADR-023 §7);
// the contract marks it optional so passing null is correct
// rather than a stopgap.
uploadTarget);
AiContract.SubmitResponse response = client.submit(request);
if (response == null || response.providerRequestId() == null
|| response.providerRequestId().isBlank()) {
// A 2xx with no id means we can never query this task again, and it
// may already be billing. Never let it pass as success.
throw new IllegalStateException(
"patbond-ai 提交成功但未返回 provider_request_id,job=" + job.id());
}
return new SubmitResult(response.providerRequestId());
}
@Override
public PollResult poll(GenerationJob job, String providerRequestId) {
AiContract.TaskResponse response;
try {
response = client.get(providerRequestId);
} catch (AiServiceException e) {
if (e.isTaskNotFound()) {
// The vendor keeps tasks for 7 days only. There is nothing left
// to poll, so fail terminally instead of backing off forever.
return PollResult.failed(new PollResult.Failure(
Retry.NEVER, UserFacing.SERVER_ERROR,
"PROVIDER_TASK_GONE",
"服务商已无此任务(可能超出其保留期):" + providerRequestId));
}
throw e;
}
return toPollResult(response);
}
private static PollResult toPollResult(AiContract.TaskResponse response) {
PollResult.State state = parseState(response.state());
return switch (state) {
case PENDING -> PollResult.pending(response.progress());
case RUNNING -> PollResult.running(response.progress());
case CANCELLED -> PollResult.cancelled();
case FAILED -> PollResult.failed(toFailure(response.failure()));
case SUCCEEDED -> new PollResult(
PollResult.State.SUCCEEDED,
100,
response.assetUrl(),
response.assetUrlExpiresInSec(),
response.coverUrl(),
response.ratio(),
response.resolution(),
response.widthPx(),
response.heightPx(),
response.durationMs(),
response.outputFormat(),
response.billingTokens(),
null);
};
}
private static PollResult.Failure toFailure(AiContract.Failure failure) {
if (failure == null) {
// The contract guarantees failed ⇒ failure present. Getting here means
// the contract was violated; a synthetic retryable failure is the safe
// reading, but say so loudly rather than inventing a plausible code.
log.error("patbond-ai 返回 state=failed 但缺少 failure 对象,违反内部契约");
return new PollResult.Failure(Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE,
"AI_CONTRACT_VIOLATION", "state=failed 但未携带 failure");
}
return new PollResult.Failure(
parseRetry(failure.retry()),
parseUserFacing(failure.userFacing()),
failure.providerCode(),
failure.message());
}
/**
* Unknown states are treated as still running rather than as failures: the
* contract may gain a value before this code learns it, and declaring an
* in-flight job dead is worse than waiting for our own timeout.
*/
private static PollResult.State parseState(String value) {
if (value == null) {
return PollResult.State.RUNNING;
}
return switch (value) {
case "pending" -> PollResult.State.PENDING;
case "running" -> PollResult.State.RUNNING;
case "succeeded" -> PollResult.State.SUCCEEDED;
case "failed" -> PollResult.State.FAILED;
case "cancelled" -> PollResult.State.CANCELLED;
default -> {
log.warn("patbond-ai 返回未知 state:{},按仍在运行处理", value);
yield PollResult.State.RUNNING;
}
};
}
private static Retry parseRetry(String value) {
if (value == null) {
return Retry.BACKOFF;
}
return switch (value) {
case "never" -> Retry.NEVER;
case "quota" -> Retry.QUOTA;
case "backoff" -> Retry.BACKOFF;
default -> {
log.warn("patbond-ai 返回未知 retry:{},按可重试处理", value);
yield Retry.BACKOFF;
}
};
}
private static UserFacing parseUserFacing(String value) {
if (value == null) {
return UserFacing.UPSTREAM_UNAVAILABLE;
}
return switch (value) {
case "quota_exhausted" -> UserFacing.QUOTA_EXHAUSTED;
case "rate_limited" -> UserFacing.RATE_LIMITED;
case "content_rejected" -> UserFacing.CONTENT_REJECTED;
case "server_error" -> UserFacing.SERVER_ERROR;
case "upstream_unavailable" -> UserFacing.UPSTREAM_UNAVAILABLE;
default -> UserFacing.UPSTREAM_UNAVAILABLE;
};
}
/**
* Stable per-user hash for the vendor's abuse detection.
*
* <p>Hashed rather than the raw user id: the vendor has no need for our
* identifiers, and a stable hash serves their purpose equally well. Derived
* from the job's user id so the same user maps to the same value across
* jobs, which is what makes it useful to them.
*/
private static String safetyIdentifierFor(GenerationJob job) {
return com.patbond.patbond.creation.support.RequestHashes
.hex(job.userId().toString());
}
}
@@ -0,0 +1,51 @@
package com.patbond.patbond.creation.provider;
import com.patbond.patbond.creation.domain.GenerationJob;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Stand-in provider used while real vendor credentials and API shapes are not
* yet settled.
*
* <p><b>This is not a placeholder to be deleted — it is a deliberate M4
* asset.</b> All four M4 acceptance criteria (queue mechanics, lease recovery,
* idempotent retry, artifact reaching the media table and then a community
* draft) are independent of whether the picture looks good. A fixture provider
* additionally lets us inject failures a real vendor cannot reproduce on
* demand: timeouts, mid-flight worker death, a 500 on the third attempt. The
* canonical seed itself specifies {@code provider_code = 'fixture'}.
*
* <p>Deliberately NOT implemented yet: producing real bytes and PUTting them to
* the presigned target. That step is the one place where the storage decision
* shows up (local object storage vs a cloud provider changes what patbond-ai
* returns), and that decision is explicitly deferred (ADR-023 §7). The methods
* fail loudly rather than pretending to work, so nothing downstream can mature
* against a fake success.
*/
public class FixtureGenerationProvider implements GenerationProvider {
public static final String PROVIDER_CODE = "fixture";
private static final Logger log = LoggerFactory.getLogger(FixtureGenerationProvider.class);
@Override
public String providerCode() {
return PROVIDER_CODE;
}
@Override
public SubmitResult submit(GenerationJob job, String uploadTarget) {
log.warn("Fixture provider is not implemented yet; job {} will not be submitted", job.id());
throw new UnsupportedOperationException(
"FixtureGenerationProvider.submit 未实现:产物生成与预签名上传的形态取决于"
+ "对象存储方案(ADR-023 §7 明确挂起)。实现前请勿让任务进入队列,"
+ "否则会以「假成功」污染指标基线。");
}
@Override
public PollResult poll(GenerationJob job, String providerRequestId) {
throw new UnsupportedOperationException(
"FixtureGenerationProvider.poll 未实现(同 submit)。");
}
}
@@ -0,0 +1,196 @@
package com.patbond.patbond.creation.provider;
import com.patbond.patbond.creation.domain.GenerationJob;
/**
* The upstream generation boundary. Implementations reach the vendor through
* patbond-ai over REST; the contract is that repo's
* {@code openapi-internal.yaml} (ADR-023 §4).
*
* <p><b>Two phases, always — even for synchronous vendors.</b> Some are
* asynchronous (submit + query, e.g. video), others complete inside one
* long-lived call. That difference must NOT reach this interface, the job table
* or the client contract: {@code submit} returns as soon as the work is accepted
* and {@code poll} reports progress. A synchronous vendor is absorbed on the
* patbond-ai side.
*
* <p>Why it matters beyond tidiness: {@code submit} returning a request id
* immediately is what lets the worker persist {@code provider_request_id} before
* any billable work happens. Without it, a timeout leaves us unable to tell
* whether we were charged, and the retry charges again.
*/
public interface GenerationProvider {
/**
* @return the {@code provider_code} this implementation serves, matching
* {@code generation_models.provider_code} and the value frozen into
* {@code provider_code_snapshot}
*/
String providerCode();
/**
* Hands the job upstream. MUST NOT block for the generation itself.
*
* <p><b>There is deliberately no idempotency-key parameter.</b> The skeleton
* had one, on the assumption it could be forwarded to the vendor's
* idempotency header — but 火山方舟 has no such field (see patbond-doc
* {@code docs/development/providers/volcengine-seedance.md} §2-①). Keeping a
* parameter that has nowhere to go would imply the double-charge window is
* solved. It is not: it is bounded only by {@code max_attempts}, and the
* recovery path is the persisted {@code provider_request_id}, not a key.
*
* @param uploadTarget where the artifact is to be PUT once produced — a
* presigned URL from patbond-user's internal media API. <b>Pass null for
* now</b>: artifact transfer is blocked on the object-storage decision
* (ADR-023 §7) and the contract marks the field unused. The parameter
* stays so enabling it later needs no signature change.
* @return the vendor's task id, to be persisted before anything billable
*/
SubmitResult submit(GenerationJob job, String uploadTarget);
/**
* Asks after a previously submitted job.
*
* @param providerRequestId the id returned by {@link #submit}
*/
PollResult poll(GenerationJob job, String providerRequestId);
/**
* Outcome of a submission.
*
* @param providerRequestId upstream handle; persisted immediately
*/
record SubmitResult(String providerRequestId) {
}
/**
* What to do about a failure. Mirrors the contract's {@code retry} enum.
*
* <p>The skeleton used a boolean {@code retryable}, which cannot express the
* difference that matters most: a rate limit clears in seconds, an exhausted
* quota clears tomorrow. Treating the latter as retryable burns the whole
* retry budget for nothing and still fails.
*/
enum Retry {
/** Same input will always give the same result. Terminal. */
NEVER,
/** Rate limit, burst protection, vendor 5xx. Retry with backoff. */
BACKOFF,
/**
* Quota or billing exhausted — clears at the next billing period or on
* top-up. Retrying is pointless, so the job fails terminally and the
* user gets an exit instead of a silent wait.
*/
QUOTA;
public boolean worthRetrying() {
return this == BACKOFF;
}
}
/**
* How a failure should be presented to the end user. Mirrors the contract's
* {@code user_facing} enum; maps onto the two distinct 429 codes
* (ADR-023 §9) — sharing one code would make half the copy and half the
* exits wrong.
*/
enum UserFacing {
QUOTA_EXHAUSTED,
RATE_LIMITED,
CONTENT_REJECTED,
SERVER_ERROR,
UPSTREAM_UNAVAILABLE
}
/**
* Outcome of a poll.
*
* <p>Carries everything needed to write a {@code media.assets} row, because
* dropping it here would mean a second round of changes when artifact
* transfer lands.
*
* @param progress 0..100. The vendor gives no percentage, so it is 0 until
* success and 100 after. This is not a defect: the client contract
* deliberately does not promise smooth progress (ADR-023 §10), and an
* honest 0 must not be read as "stuck".
* @param assetUrl the artifact. <b>May be a time-limited vendor URL</b> —
* 火山's is valid for 24 hours — so it must be transferred within that
* window and never stored long-term as {@code external_url}.
* @param assetUrlExpiresInSec how long {@code assetUrl} remains valid.
* Travels with the URL so no caller has a reason to treat it as
* permanent.
* @param coverUrl cover image for video. 火山's last-frame image serves this
* for free, so no frame extraction is needed.
* @param widthPx derived by the adapter from the vendor's ratio ×
* resolution; null when it cannot be determined rather than guessed.
* This is what lets the AI path fill real dimensions without decoding
* the file (ADR-023 §11).
* @param billingTokens reconciliation figure from the vendor.
*/
record PollResult(State state,
int progress,
String assetUrl,
Integer assetUrlExpiresInSec,
String coverUrl,
String ratio,
String resolution,
Integer widthPx,
Integer heightPx,
Long durationMs,
String outputFormat,
Long billingTokens,
Failure failure) {
public enum State {
/** Accepted upstream, not started. */
PENDING,
/** Upstream is working on it. */
RUNNING,
SUCCEEDED,
FAILED,
/** Cancelled upstream. Terminal, and not a failure. */
CANCELLED
}
/**
* @param providerCode the vendor's raw error code, kept verbatim — it is
* usually the only clue when troubleshooting. Callers must not
* branch on it; that is the adapter's job.
*/
public record Failure(Retry retry, UserFacing userFacing,
String providerCode, String message) {
}
public PollResult {
// A FAILED result without a Failure leaves the worker unable to
// decide anything — it could only retry blindly or give up blindly,
// and both are wrong. Better to fail loudly here.
if (state == State.FAILED && failure == null) {
throw new IllegalArgumentException("FAILED 必须携带 Failure");
}
if (state == State.SUCCEEDED && (assetUrl == null || assetUrl.isBlank())) {
throw new IllegalArgumentException("SUCCEEDED 必须携带 assetUrl");
}
}
public static PollResult pending(int progress) {
return new PollResult(State.PENDING, progress, null, null, null, null, null,
null, null, null, null, null, null);
}
public static PollResult running(int progress) {
return new PollResult(State.RUNNING, progress, null, null, null, null, null,
null, null, null, null, null, null);
}
public static PollResult cancelled() {
return new PollResult(State.CANCELLED, 0, null, null, null, null, null,
null, null, null, null, null, null);
}
public static PollResult failed(Failure failure) {
return new PollResult(State.FAILED, 0, null, null, null, null, null,
null, null, null, null, null, failure);
}
}
}
@@ -0,0 +1,115 @@
package com.patbond.patbond.creation.provider.ai;
import com.fasterxml.jackson.annotation.JsonInclude;
/**
* DTOs of patbond-ai's internal contract ({@code openapi-internal.yaml}).
*
* <p><b>Field names are snake_case</b>, unlike the camelCase contract we expose
* to clients. That is deliberate: patbond-ai is Python, and snake_case lets its
* JSON map one-to-one onto its domain types with no conversion layer. The cost
* is confined to one Jackson naming strategy on one Feign client
* (see {@code AiFeignConfig}) — using camelCase would instead require a
* conversion on both sides.
*
* <p>Java field names here stay camelCase; the wire form is produced by the
* naming strategy, not by hand-written {@code @JsonProperty} on every field.
* Hand-annotating is where the two sides drift: one forgotten annotation
* silently sends a field the other end ignores.
*/
public final class AiContract {
private AiContract() {
}
/**
* Submit request.
*
* <p>{@code @JsonInclude(NON_NULL)} matters here beyond tidiness: the
* contract declares {@code extra = "forbid"} on the Python side, and absent
* optional fields must be absent rather than explicit nulls so the defaults
* documented in the contract apply.
*
* <p><b>Carries no userId</b> — data minimisation (ADR-023 §5). The vendor's
* abuse detection gets {@code safetyIdentifier}, a hash, instead.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record SubmitRequest(
String jobId,
String mediaKind,
String model,
String prompt,
String inputUrl,
String ratio,
String resolution,
Integer durationSec,
Boolean generateAudio,
Boolean watermark,
String safetyIdentifier,
String callbackUrl,
String uploadTarget) {
}
public record SubmitResponse(String providerRequestId) {
}
/**
* Task state.
*
* <p>{@code state} is one of pending / running / succeeded / failed /
* cancelled — deliberately a smaller set than the vendor's: the vendor's
* {@code queued} arrives as {@code pending}, and {@code expired} arrives as
* {@code failed} with a retryable {@code failure} (it is a timeout, not a
* content problem).
*/
public record TaskResponse(
String providerRequestId,
String state,
int progress,
String assetUrl,
Integer assetUrlExpiresInSec,
String coverUrl,
String ratio,
String resolution,
Integer widthPx,
Integer heightPx,
Long durationMs,
String outputFormat,
Long billingTokens,
Failure failure) {
}
/**
* Failure classification.
*
* @param retry what the worker should do: never / backoff / quota
* @param userFacing how to present it: quota_exhausted / rate_limited /
* content_rejected / server_error / upstream_unavailable
* @param providerCode the vendor's raw code, for logs only
*/
public record Failure(String retry, String userFacing, String providerCode,
String message) {
}
/**
* Error envelope. <b>Both error kinds share this shape</b> so one decoder
* handles both; {@code kind} discriminates.
*
* <p>{@code kind = "service"} carries {@code code}; {@code kind = "provider"}
* carries the same classification fields as {@link Failure}. The overlap is
* intentional — it means the caller reuses one piece of handling logic
* instead of writing separate branches for "rate-limited at submit" and
* "task failed because rate-limited".
*/
public record ErrorEnvelope(ErrorBody error) {
public record ErrorBody(String kind, String code, String retry,
String userFacing, String providerCode,
String message) {
public boolean isProvider() {
return "provider".equals(kind);
}
}
}
}
@@ -0,0 +1,131 @@
package com.patbond.patbond.creation.provider.ai;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.patbond.patbond.creation.provider.GenerationProvider.Retry;
import com.patbond.patbond.creation.provider.GenerationProvider.UserFacing;
import feign.Response;
import feign.Util;
import feign.codec.ErrorDecoder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
/**
* Turns patbond-ai's two error kinds into two distinct Java exceptions.
*
* <p><b>Why two, and not one with a flag.</b> The caller's reaction differs
* completely:
*
* <ul>
* <li>{@code service} — our own bug or misconfiguration (bad request body,
* unauthenticated, adapter not assembled). Retrying a malformed request
* never helps.</li>
* <li>{@code provider} — the vendor said no. Whether to retry, and how long to
* wait, is carried in {@code retry}; the caller must not try to interpret
* vendor codes itself.</li>
* </ul>
*
* <p>The discriminator is {@code error.kind}, <b>not the HTTP status</b>: statuses
* collide between the two kinds (a 5xx can be either), while {@code kind} cannot.
*
* <p>Note that a <i>failed task</i> is neither of these — it arrives as HTTP 200
* with {@code state = failed}. There the HTTP call succeeded; the task is what
* failed. Conflating them would make "task not found" and "task failed"
* indistinguishable.
*/
public class AiErrorDecoder implements ErrorDecoder {
private static final Logger log = LoggerFactory.getLogger(AiErrorDecoder.class);
private static final ObjectMapper MAPPER = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE)
.configure(com.fasterxml.jackson.databind.DeserializationFeature
.FAIL_ON_UNKNOWN_PROPERTIES, false);
@Override
public Exception decode(String methodKey, Response response) {
AiContract.ErrorEnvelope.ErrorBody body = readBody(response);
if (body == null) {
// Not our envelope at all. Most likely we hit the wrong address and
// something else answered (a gateway error page, say). Treat as a
// retryable upstream problem, but say plainly that the shape was
// unrecognised — silently classifying it would send troubleshooting
// down the wrong path.
log.warn("patbond-ai 返回了无法识别的错误体 status={} method={}",
response.status(), methodKey);
return new AiProviderException(new PollResultFailure(
Retry.BACKOFF, UserFacing.UPSTREAM_UNAVAILABLE,
"AI_RESPONSE_UNRECOGNISED",
"patbond-ai 返回了非契约错误体,HTTP " + response.status()));
}
if (body.isProvider()) {
return new AiProviderException(new PollResultFailure(
parseRetry(body.retry()),
parseUserFacing(body.userFacing()),
body.providerCode(),
body.message()));
}
return new AiServiceException(response.status(), body.code(), body.message());
}
private static AiContract.ErrorEnvelope.ErrorBody readBody(Response response) {
if (response.body() == null) {
return null;
}
try {
String raw = Util.toString(response.body().asReader(Util.UTF_8));
AiContract.ErrorEnvelope envelope =
MAPPER.readValue(raw, AiContract.ErrorEnvelope.class);
return envelope == null ? null : envelope.error();
} catch (IOException | RuntimeException e) {
return null;
}
}
/**
* Unknown values lean towards retrying rather than failing terminally: the
* contract may gain a value before this code learns it, and turning a
* recoverable blip into a user-visible failure is the worse error. The retry
* budget is bounded anyway.
*/
private static Retry parseRetry(String value) {
if (value == null) {
return Retry.BACKOFF;
}
return switch (value) {
case "never" -> Retry.NEVER;
case "quota" -> Retry.QUOTA;
case "backoff" -> Retry.BACKOFF;
default -> {
log.warn("patbond-ai 返回未知 retry 取值:{},按可重试处理", value);
yield Retry.BACKOFF;
}
};
}
private static UserFacing parseUserFacing(String value) {
if (value == null) {
return UserFacing.UPSTREAM_UNAVAILABLE;
}
return switch (value) {
case "quota_exhausted" -> UserFacing.QUOTA_EXHAUSTED;
case "rate_limited" -> UserFacing.RATE_LIMITED;
case "content_rejected" -> UserFacing.CONTENT_REJECTED;
case "server_error" -> UserFacing.SERVER_ERROR;
case "upstream_unavailable" -> UserFacing.UPSTREAM_UNAVAILABLE;
default -> {
log.warn("patbond-ai 返回未知 user_facing 取值:{}", value);
yield UserFacing.UPSTREAM_UNAVAILABLE;
}
};
}
/** Internal carrier so the decoder need not import the nested record path. */
record PollResultFailure(Retry retry, UserFacing userFacing,
String providerCode, String message) {
}
}
@@ -0,0 +1,105 @@
package com.patbond.patbond.creation.provider.ai;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.PropertyNamingStrategies;
import com.patbond.patbond.creation.config.CreationProperties;
import com.patbond.patbond.creation.security.ServiceTokenSigner;
import feign.Request;
import feign.RequestInterceptor;
import feign.codec.Decoder;
import feign.codec.Encoder;
import feign.codec.ErrorDecoder;
import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
import org.springframework.cloud.openfeign.support.ResponseEntityDecoder;
import org.springframework.cloud.openfeign.support.SpringDecoder;
import org.springframework.cloud.openfeign.support.SpringEncoder;
import org.springframework.context.annotation.Bean;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import java.util.concurrent.TimeUnit;
/**
* Feign child-context beans for the patbond-ai client — deliberately not a
* {@code @Configuration}, same reasoning as the existing auth/community Feign
* configs (a component-scanned bean would land in the parent context and be
* shadowed by the child's defaults).
*
* <p>Four things are wired here, each for a specific reason.
*/
public class AiFeignConfig {
/**
* snake_case on the wire, camelCase in Java.
*
* <p>Scoped to this client only — the contract we expose to clients stays
* camelCase, and a global naming strategy would silently reshape every other
* service's JSON.
*
* <p>Two alternatives rejected. Per-field {@code @JsonProperty}: one
* forgotten annotation silently sends a field the other end ignores, and a
* silently ignored field is the hardest failure to notice ("I passed
* duration_sec but it had no effect"). Pulling in {@code feign-jackson} for
* its own encoder: no other module needs it, and reusing Spring's converter
* stack keeps this client behaving like the rest of the codebase.
*/
private static MappingJackson2HttpMessageConverter snakeCaseConverter() {
ObjectMapper mapper = new ObjectMapper()
.setPropertyNamingStrategy(PropertyNamingStrategies.SNAKE_CASE);
return new MappingJackson2HttpMessageConverter(mapper);
}
@Bean
public Encoder aiEncoder() {
return new SpringEncoder(() -> new HttpMessageConverters(snakeCaseConverter()));
}
@Bean
public Decoder aiDecoder() {
return new ResponseEntityDecoder(
new SpringDecoder(() -> new HttpMessageConverters(snakeCaseConverter())));
}
/**
* Splits patbond-ai's two error kinds into two exceptions — see
* {@link AiErrorDecoder}. Without it, a vendor rate limit and a malformed
* request body would both surface as the same generic {@code FeignException},
* and the worker could only guess whether retrying helps.
*/
@Bean
public ErrorDecoder aiErrorDecoder() {
return new AiErrorDecoder();
}
/**
* Presents a freshly signed service-identity token on every call.
*
* <p>Signed per call, never cached: the TTL is minutes, signing is cheap, and
* a cached token outliving its purpose is how a short-lived credential
* quietly becomes a long-lived one.
*/
@Bean
public RequestInterceptor serviceTokenInterceptor(ServiceTokenSigner signer) {
return template -> template.header("Authorization", "Bearer " + signer.sign());
}
/**
* Timeouts bound the SUBMIT/QUERY round trip, <b>not the generation</b>.
*
* <p>patbond-ai's boundary is asynchronous regardless of whether the vendor
* is (ADR-023 §4): submit only enqueues. Inflating these to "wait for the
* video" would reintroduce held connections and the ambiguous-billing window
* the async boundary exists to remove.
*
* <p>{@code followRedirects = false}: an internal service has no business
* redirecting us, and following one would send the service token to whatever
* host the redirect names.
*/
@Bean
public Request.Options aiRequestOptions(CreationProperties properties) {
CreationProperties.AiService config = properties.getAiService();
return new Request.Options(
config.getConnectTimeout().toMillis(), TimeUnit.MILLISECONDS,
config.getReadTimeout().toMillis(), TimeUnit.MILLISECONDS,
false);
}
}
@@ -0,0 +1,35 @@
package com.patbond.patbond.creation.provider.ai;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
/**
* Feign client for patbond-ai's internal generation API.
*
* <p>Static direct URL per ADR-002 (no service discovery in the MVP), same as
* the existing author-profile client in patbond-community.
*
* <p>The path parameter is the <b>vendor's</b> task id, not our jobId. That is
* patbond-ai's choice and a deliberate one: it holds no state, so the vendor id
* is the only thing it can ask the vendor about. Using our jobId would force it
* to keep a mapping, and would create a window where we hold an id that cannot
* query anything.
*/
@FeignClient(
name = "patbond-ai",
// 占位符带默认值:Feign 在**上下文启动时**解析 url,无默认值时任何没有
// 显式配置该属性的上下文都会启动失败——包括与 AI 无关的仓储测试。
// 与 CreationProperties.AiService 的默认值保持一致。
url = "${patbond.creation.ai-service.url:http://127.0.0.1:8000}",
configuration = AiFeignConfig.class)
public interface AiGenerationClient {
@PostMapping("/internal/generation/tasks")
AiContract.SubmitResponse submit(@RequestBody AiContract.SubmitRequest request);
@GetMapping("/internal/generation/tasks/{providerRequestId}")
AiContract.TaskResponse get(@PathVariable("providerRequestId") String providerRequestId);
}
@@ -0,0 +1,26 @@
package com.patbond.patbond.creation.provider.ai;
import com.patbond.patbond.creation.provider.GenerationProvider.PollResult;
/**
* The vendor said no. Carries the classification so the worker can decide
* without interpreting vendor codes.
*
* <p>Distinct from {@link AiServiceException} because the reactions differ: this
* one may be worth retrying (see {@link PollResult.Failure#retry()}), that one
* means we sent something wrong.
*/
public class AiProviderException extends RuntimeException {
private final PollResult.Failure failure;
AiProviderException(AiErrorDecoder.PollResultFailure raw) {
super(raw.message() == null ? raw.providerCode() : raw.message());
this.failure = new PollResult.Failure(
raw.retry(), raw.userFacing(), raw.providerCode(), raw.message());
}
public PollResult.Failure failure() {
return failure;
}
}
@@ -0,0 +1,45 @@
package com.patbond.patbond.creation.provider.ai;
/**
* Something went wrong between us and patbond-ai: our request body did not match
* the contract, the service token was rejected, the task id is unknown, or the
* adapter is not assembled there.
*
* <p><b>Mostly not worth retrying</b> — retrying a malformed request never helps.
* The two exceptions are {@code internal_error} and {@code provider_unavailable}:
* the first may be transient, the second usually means credentials are not
* configured yet and will start working once they are.
*/
public class AiServiceException extends RuntimeException {
/** Contract code: unauthenticated / invalid_request / task_not_found / … */
private final String code;
private final int httpStatus;
AiServiceException(int httpStatus, String code, String message) {
super(message == null ? code : message);
this.httpStatus = httpStatus;
this.code = code;
}
public String code() {
return code;
}
public int httpStatus() {
return httpStatus;
}
/** The vendor no longer has this task — stop polling rather than back off. */
public boolean isTaskNotFound() {
return "task_not_found".equals(code);
}
/**
* @return true when the condition may clear on its own (a transient bug on
* their side, or credentials that are about to be configured)
*/
public boolean mayClearOnItsOwn() {
return "internal_error".equals(code) || "provider_unavailable".equals(code);
}
}
@@ -0,0 +1,118 @@
package com.patbond.patbond.creation.repository;
import com.patbond.patbond.creation.domain.MediaKind;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* {@code creation.generation_models} / {@code generation_styles} reads.
*
* <p>The catalog is a table rather than a config file, and not by preference:
* {@code generation_jobs.model_id} is a NOT NULL uuid participating in a
* composite foreign key, so a config file has no row for it to point at.
*
* <p>Every lookup is scoped by {@code media_kind}. The database enforces the
* same rule through the {@code (id, media_kind)} composite FK — a video job
* cannot reference an image model — but resolving it here lets the API answer a
* clean 404 instead of surfacing a constraint violation.
*/
@Repository
public class CatalogRepository {
private final JdbcClient jdbcClient;
public CatalogRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
/**
* Model row needed to freeze a job's provider snapshots.
*
* @param modelVersion currently sourced from {@code provider_model_name} —
* the catalog has no separate version column, while jobs snapshot a
* {@code model_version_snapshot}. Whether the vendors expose a distinct
* version string is one of the open items awaiting their documentation;
* until then the two snapshots carry the same value rather than a
* fabricated one.
*/
public record ModelRow(UUID id, String code, String providerCode, String providerModelName,
String modelVersion, MediaKind mediaKind) {
}
public record StyleRow(UUID id, String code, MediaKind mediaKind) {
}
public Optional<ModelRow> findEnabledModel(UUID modelId, MediaKind mediaKind) {
return jdbcClient.sql("""
SELECT id, code, provider_code, provider_model_name, media_kind
FROM creation.generation_models
WHERE id = :id AND media_kind = :mediaKind AND enabled
""")
.param("id", modelId)
.param("mediaKind", mediaKind.dbValue())
.query((rs, rowNum) -> new ModelRow(
rs.getObject("id", UUID.class),
rs.getString("code"),
rs.getString("provider_code"),
rs.getString("provider_model_name"),
rs.getString("provider_model_name"),
MediaKind.fromDb(rs.getString("media_kind"))))
.optional();
}
public Optional<StyleRow> findEnabledStyle(UUID styleId, MediaKind mediaKind) {
return jdbcClient.sql("""
SELECT id, code, media_kind
FROM creation.generation_styles
WHERE id = :id AND media_kind = :mediaKind AND enabled
""")
.param("id", styleId)
.param("mediaKind", mediaKind.dbValue())
.query((rs, rowNum) -> new StyleRow(
rs.getObject("id", UUID.class),
rs.getString("code"),
MediaKind.fromDb(rs.getString("media_kind"))))
.optional();
}
/**
* Enabled models of one kind, ordered as
* {@code ix_generation_models_kind_order} indexes them.
*/
public List<ModelRow> listEnabledModels(MediaKind mediaKind) {
return jdbcClient.sql("""
SELECT id, code, provider_code, provider_model_name, media_kind
FROM creation.generation_models
WHERE media_kind = :mediaKind AND enabled
ORDER BY sort_order, id
""")
.param("mediaKind", mediaKind.dbValue())
.query((rs, rowNum) -> new ModelRow(
rs.getObject("id", UUID.class),
rs.getString("code"),
rs.getString("provider_code"),
rs.getString("provider_model_name"),
rs.getString("provider_model_name"),
MediaKind.fromDb(rs.getString("media_kind"))))
.list();
}
/**
* Which media kinds actually have enabled models. The client renders its
* kind switcher from this rather than from a hardcoded list — so a kind with
* no catalog entries simply does not appear, instead of offering a control
* that promises a capability the backend cannot deliver.
*/
public List<String> availableMediaKinds() {
return jdbcClient.sql("""
SELECT DISTINCT media_kind FROM creation.generation_models
WHERE enabled ORDER BY media_kind
""")
.query(String.class)
.list();
}
}
@@ -0,0 +1,433 @@
package com.patbond.patbond.creation.repository;
import com.patbond.patbond.creation.domain.GenerationJob;
import com.patbond.patbond.creation.domain.JobStatus;
import com.patbond.patbond.creation.domain.MediaKind;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Repository;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* {@code creation.generation_jobs} access, including the lease queue the
* worker runs on.
*
* <p><b>Why the queue lives here and not in a broker</b> (ADR-023 §3): the
* reviewed target model already carries the whole lease apparatus as columns
* ({@code lease_owner}, {@code lease_expires_at}, {@code next_attempt_at},
* {@code attempt_count}, {@code max_attempts}, {@code priority}) plus two
* partial indexes whose column order matches the statements below exactly. The
* claim is therefore an index scan, not a sort, and job state has a single
* source of truth with no dual-write.
*
* <p><b>The invariant every caller must hold</b>: status and its companion
* columns move together in one statement. {@code ck_generation_jobs_state}
* pins the full column tuple per status, so a partial update does not persist
* a half-state — it fails. That is deliberate: the constraint is the last line
* of defence against a worker that crashed mid-transition.
*/
@Repository
public class GenerationJobRepository {
/**
* Columns loaded by every read. Excludes the write-once snapshot columns
* and {@code request_hash} — see {@link GenerationJob}.
*
* <p>Kept as a list rather than two hand-written strings because the
* {@code UPDATE … FROM … RETURNING} statements below need the same columns
* qualified with the target alias: both the target table and the CTE expose
* {@code id}, so an unqualified RETURNING list is ambiguous and postgres
* rejects it. Deriving both forms from one list means the two can never
* drift apart.
*/
private static final List<String> COLUMNS = List.of(
"id", "user_id", "pet_id", "media_kind", "model_id", "style_id",
"input_asset_id", "output_asset_id", "prompt", "negative_prompt",
"width_px", "height_px", "duration_ms", "status", "progress",
"attempt_count", "max_attempts", "provider_code_snapshot",
"provider_model_snapshot", "provider_request_id",
"error_code", "error_message",
"created_at", "first_attempt_at", "started_at", "completed_at",
"lease_expires_at");
private static final String SELECT_COLUMNS = String.join(", ", COLUMNS);
/** The same columns qualified with the UPDATE target's alias {@code j}. */
private static final String RETURNING_COLUMNS =
COLUMNS.stream().map(column -> "j." + column).collect(java.util.stream.Collectors.joining(", "));
private final JdbcClient jdbcClient;
public GenerationJobRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
// ---------------------------------------------------------------- reads
public Optional<GenerationJob> findByIdForUser(UUID jobId, UUID userId) {
return jdbcClient.sql("SELECT " + SELECT_COLUMNS
+ " FROM creation.generation_jobs WHERE id = :id AND user_id = :userId")
.param("id", jobId)
.param("userId", userId)
.query(GenerationJobRepository::mapRow)
.optional();
}
/**
* Returns an existing job for the same (user, idempotency key) so a keyed
* retry answers the first write instead of creating a second job
* (ADR-019). The caller compares {@code request_hash} to decide between
* "same request, return it" and 40905.
*/
public Optional<UUID> findIdByIdempotencyKey(UUID userId, String idempotencyKey, byte[] requestHash) {
return jdbcClient.sql("""
SELECT id FROM creation.generation_jobs
WHERE user_id = :userId AND idempotency_key = :key
AND request_hash = :hash
""")
.param("userId", userId)
.param("key", idempotencyKey)
.param("hash", requestHash)
.query(UUID.class)
.optional();
}
/** @return true when the key exists under this user with a DIFFERENT payload */
public boolean idempotencyKeyConflicts(UUID userId, String idempotencyKey, byte[] requestHash) {
return jdbcClient.sql("""
SELECT EXISTS (SELECT 1 FROM creation.generation_jobs
WHERE user_id = :userId AND idempotency_key = :key
AND request_hash <> :hash)
""")
.param("userId", userId)
.param("key", idempotencyKey)
.param("hash", requestHash)
.query(Boolean.class)
.single();
}
/** Newest first, matching {@code ix_generation_jobs_user_created}. */
public List<GenerationJob> listForUser(UUID userId, int limit) {
return jdbcClient.sql("SELECT " + SELECT_COLUMNS + """
FROM creation.generation_jobs
WHERE user_id = :userId
ORDER BY created_at DESC, id DESC
LIMIT :limit
""")
.param("userId", userId)
.param("limit", limit)
.query(GenerationJobRepository::mapRow)
.list();
}
// ------------------------------------------------------------- creation
/**
* Inserts a QUEUED job. All snapshot columns are frozen here on purpose:
* the catalog may later be re-pointed or disabled, and historical jobs
* must stay readable without it.
*
* @return the new job id
*/
public UUID insertQueued(UUID id, UUID userId, UUID petId, MediaKind mediaKind,
UUID modelId, UUID styleId, UUID inputAssetId,
String prompt, String negativePrompt,
Integer widthPx, Integer heightPx, Long durationMs,
String providerCode, String providerModel, String modelVersion,
String styleCode, String idempotencyKey, byte[] requestHash) {
jdbcClient.sql("""
INSERT INTO creation.generation_jobs (
id, user_id, pet_id, media_kind, model_id, style_id,
input_asset_id, prompt, negative_prompt,
width_px, height_px, duration_ms,
provider_code_snapshot, provider_model_snapshot,
model_version_snapshot, style_code_snapshot,
idempotency_key, request_hash
) VALUES (
:id, :userId, :petId, :mediaKind, :modelId, :styleId,
:inputAssetId, :prompt, :negativePrompt,
:widthPx, :heightPx, :durationMs,
:providerCode, :providerModel,
:modelVersion, :styleCode,
:idempotencyKey, :requestHash
)
""")
.param("id", id)
.param("userId", userId)
.param("petId", petId)
.param("mediaKind", mediaKind.dbValue())
.param("modelId", modelId)
.param("styleId", styleId)
.param("inputAssetId", inputAssetId)
.param("prompt", prompt)
.param("negativePrompt", negativePrompt)
.param("widthPx", widthPx)
.param("heightPx", heightPx)
.param("durationMs", durationMs)
.param("providerCode", providerCode)
.param("providerModel", providerModel)
.param("modelVersion", modelVersion)
.param("styleCode", styleCode)
.param("idempotencyKey", idempotencyKey)
.param("requestHash", requestHash)
.update();
return id;
}
// ---------------------------------------------------------- lease queue
/**
* Claims up to {@code batchSize} due QUEUED jobs and moves them to RUNNING
* under a lease, in ONE statement.
*
* <p>{@code FOR UPDATE SKIP LOCKED} is what makes several workers safe
* without a broker: a row another worker is claiming is skipped rather
* than waited on. The inner ORDER BY matches
* {@code ix_generation_jobs_queue} column for column
* ({@code priority DESC, next_attempt_at, created_at, id}).
*
* <p>{@code first_attempt_at} uses COALESCE so it is written on the first
* attempt and preserved on every retry — the entire reason the column
* exists (ADR-023 §6). {@code started_at} is overwritten each attempt
* because the state CHECK requires it to be the current attempt's start.
*/
public List<GenerationJob> claimQueued(String leaseOwner, Duration leaseTtl, int batchSize) {
return jdbcClient.sql("""
WITH claimed AS (
SELECT id FROM creation.generation_jobs
WHERE status = 'queued' AND next_attempt_at <= now()
ORDER BY priority DESC, next_attempt_at, created_at, id
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
)
UPDATE creation.generation_jobs j
SET status = 'running',
attempt_count = j.attempt_count + 1,
first_attempt_at = COALESCE(j.first_attempt_at, now()),
started_at = now(),
lease_owner = :leaseOwner,
lease_expires_at = now() + :leaseTtl::interval,
error_code = NULL,
error_message = NULL
FROM claimed
WHERE j.id = claimed.id
RETURNING\s""" + RETURNING_COLUMNS)
.param("leaseOwner", leaseOwner)
.param("leaseTtl", leaseTtl.toSeconds() + " seconds")
.param("batchSize", batchSize)
.query(GenerationJobRepository::mapRow)
.list();
}
/**
* Claims RUNNING jobs this owner already holds whose lease is close enough
* to expiry to be worth a poll, extending the lease in the same statement.
* Backed by {@code ix_generation_jobs_running}.
*
* <p>Polling and renewing together is not an optimisation but a
* correctness requirement: a poll that does not renew leaves a long
* upstream job open to being reclaimed mid-flight, which is how you end up
* paying a provider twice.
*/
public List<GenerationJob> claimRunningDueForPoll(String leaseOwner, Duration leaseTtl,
Duration pollDue, int batchSize) {
return jdbcClient.sql("""
WITH due AS (
SELECT id FROM creation.generation_jobs
WHERE status = 'running'
AND lease_owner = :leaseOwner
AND lease_expires_at <= now() + :pollDue::interval
ORDER BY lease_expires_at, id
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
)
UPDATE creation.generation_jobs j
SET lease_expires_at = now() + :leaseTtl::interval
FROM due
WHERE j.id = due.id
RETURNING\s""" + RETURNING_COLUMNS)
.param("leaseOwner", leaseOwner)
.param("pollDue", pollDue.toSeconds() + " seconds")
.param("leaseTtl", leaseTtl.toSeconds() + " seconds")
.param("batchSize", batchSize)
.query(GenerationJobRepository::mapRow)
.list();
}
/**
* Returns expired-lease RUNNING jobs to the queue so a crashed worker's
* work is picked up again. The target model supplied the index for this
* but no implementation (ADR-023 §3) — this is that implementation.
*
* <p>{@code provider_request_id} is deliberately NOT cleared: it is how the
* next attempt discovers the work was already submitted upstream and polls
* instead of re-submitting. Clearing it here would turn every worker crash
* into a double charge.
*
* @return number of jobs returned to the queue
*/
public int reapExpiredLeases(int batchSize) {
return jdbcClient.sql("""
WITH expired AS (
SELECT id FROM creation.generation_jobs
WHERE status = 'running' AND lease_expires_at <= now()
ORDER BY lease_expires_at, id
LIMIT :batchSize
FOR UPDATE SKIP LOCKED
)
UPDATE creation.generation_jobs j
SET status = 'queued',
progress = 0,
started_at = NULL,
lease_owner = NULL,
lease_expires_at = NULL,
next_attempt_at = now()
FROM expired
WHERE j.id = expired.id
""")
.param("batchSize", batchSize)
.update();
}
// ------------------------------------------------------- state advances
/**
* Records the upstream submission. Writing this BEFORE the provider does
* any billable work is what makes a timeout recoverable: the next attempt
* sees the id and polls rather than re-submitting.
*/
public int recordProviderRequest(UUID jobId, String providerRequestId) {
return jdbcClient.sql("""
UPDATE creation.generation_jobs
SET provider_request_id = :requestId
WHERE id = :id AND provider_request_id IS NULL
""")
.param("id", jobId)
.param("requestId", providerRequestId)
.update();
}
public int updateProgress(UUID jobId, int progress) {
return jdbcClient.sql("""
UPDATE creation.generation_jobs
SET progress = :progress
WHERE id = :id AND status = 'running'
""")
.param("id", jobId)
.param("progress", (short) progress)
.update();
}
/** RUNNING → SUCCEEDED. progress is forced to 100 by the state CHECK. */
public int markSucceeded(UUID jobId, UUID outputAssetId) {
return jdbcClient.sql("""
UPDATE creation.generation_jobs
SET status = 'succeeded',
progress = 100,
output_asset_id = :assetId,
completed_at = now(),
lease_owner = NULL,
lease_expires_at = NULL
WHERE id = :id AND status = 'running'
""")
.param("id", jobId)
.param("assetId", outputAssetId)
.update();
}
/** RUNNING → FAILED, terminal: the retry budget is spent. */
public int markFailed(UUID jobId, String errorCode, String errorMessage) {
return jdbcClient.sql("""
UPDATE creation.generation_jobs
SET status = 'failed',
error_code = :errorCode,
error_message = :errorMessage,
completed_at = now(),
lease_owner = NULL,
lease_expires_at = NULL
WHERE id = :id AND status = 'running'
""")
.param("id", jobId)
.param("errorCode", errorCode)
.param("errorMessage", errorMessage)
.update();
}
/**
* RUNNING → QUEUED with backoff, for a failure that still has retries
* left. {@code provider_request_id} is preserved for the same reason as in
* {@link #reapExpiredLeases(int)}.
*/
public int requeueWithBackoff(UUID jobId, Duration backoff) {
return jdbcClient.sql("""
UPDATE creation.generation_jobs
SET status = 'queued',
progress = 0,
started_at = NULL,
lease_owner = NULL,
lease_expires_at = NULL,
next_attempt_at = now() + :backoff::interval
WHERE id = :id AND status = 'running'
""")
.param("id", jobId)
.param("backoff", backoff.toSeconds() + " seconds")
.update();
}
/**
* Cancels a job that has not reached a terminal state. Guarded by status
* in the WHERE clause rather than by a read-then-write so a concurrent
* completion cannot be overwritten — 0 rows means "it finished first".
*/
public int markCancelled(UUID jobId, UUID userId) {
return jdbcClient.sql("""
UPDATE creation.generation_jobs
SET status = 'cancelled',
completed_at = now(),
lease_owner = NULL,
lease_expires_at = NULL
WHERE id = :id AND user_id = :userId
AND status IN ('queued', 'running')
""")
.param("id", jobId)
.param("userId", userId)
.update();
}
// ----------------------------------------------------------- row mapper
private static GenerationJob mapRow(java.sql.ResultSet rs, int rowNum) throws java.sql.SQLException {
return new GenerationJob(
rs.getObject("id", UUID.class),
rs.getObject("user_id", UUID.class),
rs.getObject("pet_id", UUID.class),
MediaKind.fromDb(rs.getString("media_kind")),
rs.getObject("model_id", UUID.class),
rs.getObject("style_id", UUID.class),
rs.getObject("input_asset_id", UUID.class),
rs.getObject("output_asset_id", UUID.class),
rs.getString("prompt"),
rs.getString("negative_prompt"),
rs.getObject("width_px", Integer.class),
rs.getObject("height_px", Integer.class),
rs.getObject("duration_ms", Long.class),
JobStatus.fromDb(rs.getString("status")),
rs.getShort("progress"),
rs.getShort("attempt_count"),
rs.getShort("max_attempts"),
rs.getString("provider_code_snapshot"),
rs.getString("provider_model_snapshot"),
rs.getString("provider_request_id"),
rs.getString("error_code"),
rs.getString("error_message"),
rs.getObject("created_at", OffsetDateTime.class),
rs.getObject("first_attempt_at", OffsetDateTime.class),
rs.getObject("started_at", OffsetDateTime.class),
rs.getObject("completed_at", OffsetDateTime.class),
rs.getObject("lease_expires_at", OffsetDateTime.class));
}
}
@@ -0,0 +1,83 @@
package com.patbond.patbond.creation.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* Bearer authentication for /api/v1/** community endpoints. Verifies the
* RS256 signature locally with the auth service's public key and exposes the
* authenticated user id as a request attribute. Missing, forged or expired
* tokens all answer 401/40101 without detail.
*
* <p>FOURTH copy of the user/pet/community filter. Sinking the shared
* pure-Java parts into patbond-common is registered as a separate M4 ticket
* (ADR-023 §2); the retreat position is exactly this file. Note that the
* service-identity tokens this module ISSUES (ServiceTokenSigner) are signed
* with a DIFFERENT key pair and are never accepted here — precisely because
* this filter verifies the signature only and checks neither aud nor iss
* (ADR-023 §5).
*/
public class BearerAuthFilter extends OncePerRequestFilter {
/** Request attribute holding the authenticated user's UUID. */
public static final String USER_ID_ATTRIBUTE = "patbond.authenticatedUserId";
private static final Logger log = LoggerFactory.getLogger(BearerAuthFilter.class);
private final JwtVerifier jwtVerifier;
private final ObjectMapper objectMapper;
public BearerAuthFilter(JwtVerifier jwtVerifier, ObjectMapper objectMapper) {
this.jwtVerifier = jwtVerifier;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/v1/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
}
try {
Claims claims = jwtVerifier.verify(header.substring("Bearer ".length()).trim());
request.setAttribute(USER_ID_ATTRIBUTE, UUID.fromString(claims.getSubject()));
} catch (BusinessException e) {
reject(response, ErrorCode.TOKEN_INVALID);
return;
} catch (IllegalStateException | IllegalArgumentException e) {
log.error("Access token verification unavailable: {}", e.getMessage());
reject(response, ErrorCode.INTERNAL_ERROR);
return;
}
filterChain.doFilter(request, response);
}
private void reject(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setStatus(errorCode.getHttpStatus());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
objectMapper.writeValue(response.getWriter(),
ApiResponse.failure(errorCode.getCode(), errorCode.getDefaultMessage()));
}
}
@@ -0,0 +1,41 @@
package com.patbond.patbond.creation.security;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.JwtParser;
import io.jsonwebtoken.Jwts;
/**
* Verifies RS256 access tokens issued by patbond-auth against the configured
* public key. The key is optional at startup so contexts that never serve
* protected routes (most tests) can boot without one; any verification
* attempt without a key fails loudly as a server misconfiguration instead of
* being reported to the client as an authentication problem.
*/
public class JwtVerifier {
private final JwtParser parser;
public JwtVerifier(String publicKeyLocation) {
this.parser = publicKeyLocation == null || publicKeyLocation.isBlank()
? null
: Jwts.parser().verifyWith(RsaPublicKeyLoader.load(publicKeyLocation)).build();
}
/**
* @return the verified claims
* @throws BusinessException 40101 when the token is forged, malformed or expired
*/
public Claims verify(String token) {
if (parser == null) {
throw new IllegalStateException("patbond.jwt.public-key 未配置,无法校验 access token");
}
try {
return parser.parseSignedClaims(token).getPayload();
} catch (JwtException | IllegalArgumentException e) {
throw new BusinessException(ErrorCode.TOKEN_INVALID);
}
}
}
@@ -0,0 +1,59 @@
package com.patbond.patbond.creation.security;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
/**
* Loads the RS256 signing key from either inline PEM content (value starts
* with -----BEGIN, e.g. injected through an environment variable) or a
* filesystem path to a PEM file. Only the PKCS#8 form produced by
* `openssl genpkey` is supported. The matching public key is derived from
* the CRT parameters, so this service needs no second configuration value.
*/
public final class RsaPrivateKeyLoader {
private RsaPrivateKeyLoader() {
}
public static RSAPrivateCrtKey load(String pemOrPath) {
String pem = pemOrPath.trim();
if (!pem.startsWith("-----BEGIN")) {
try {
pem = Files.readString(Path.of(pem));
} catch (IOException e) {
throw new UncheckedIOException("无法读取 JWT 私钥文件: " + pemOrPath, e);
}
}
String base64 = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
try {
byte[] der = Base64.getDecoder().decode(base64);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return (RSAPrivateCrtKey) keyFactory.generatePrivate(new PKCS8EncodedKeySpec(der));
} catch (IllegalArgumentException | ClassCastException
| NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("JWT 私钥不是有效的 PEM(PKCS#8) RSA 私钥", e);
}
}
public static RSAPublicKey derivePublicKey(RSAPrivateCrtKey privateKey) {
try {
return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(
new RSAPublicKeySpec(privateKey.getModulus(), privateKey.getPublicExponent()));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalStateException("无法从 RSA 私钥推导公钥", e);
}
}
}
@@ -0,0 +1,43 @@
package com.patbond.patbond.creation.security;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;
/**
* Loads an RSA public key for JWT signature verification. Accepts either
* inline PEM or a filesystem path; the inline form (environment variable
* PATBOND_JWT_PUBLIC_KEY starting with -----BEGIN) is production's choice
* because mounted secrets beat files-in-the-image.
*/
public final class RsaPublicKeyLoader {
private RsaPublicKeyLoader() {
}
public static PublicKey load(String pemOrPath) {
String pem = pemOrPath.startsWith("-----BEGIN") ? pemOrPath : readFile(pemOrPath);
String stripped = pem.replaceAll("-----BEGIN PUBLIC KEY-----|-----END PUBLIC KEY-----", "")
.replaceAll("\\s", "");
byte[] decoded = Base64.getDecoder().decode(stripped);
try {
return KeyFactory.getInstance("RSA").generatePublic(new X509EncodedKeySpec(decoded));
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new IllegalArgumentException("Invalid RSA public key", e);
}
}
private static String readFile(String path) {
try {
return Files.readString(Path.of(path));
} catch (IOException e) {
throw new IllegalArgumentException("Cannot read public key from " + path, e);
}
}
}
@@ -0,0 +1,87 @@
package com.patbond.patbond.creation.security;
import io.jsonwebtoken.Jwts;
import java.security.interfaces.RSAPrivateCrtKey;
import java.time.Duration;
import java.time.Instant;
import java.util.Date;
import java.util.UUID;
/**
* Signs short-lived service-identity tokens for outbound calls to patbond-ai
* (ADR-023 §5).
*
* <p><b>Why a separate key pair, and why not just forward the user's token.</b>
* Forwarding is wrong on two counts: the worker runs asynchronously, so the
* user's token may well have expired by the time the job is picked up, and
* patbond-ai has no business holding user credentials.
*
* <p>Reusing the USER key pair for a service token would be worse still, and
* for a reason specific to this codebase: {@link JwtVerifier} verifies the
* signature and nothing else — it checks neither {@code aud} nor {@code iss} —
* and the user tokens patbond-auth issues carry no {@code aud} claim at all. A
* service token signed with that key pair would therefore be accepted as a
* valid user token by user, pet, community and this module. It would probably
* fall over later when {@code sub} failed to parse as a UUID, but that is an
* accident stopping it, not a design. A separate key pair needs no change to
* any already-released verifier.
*
* <p>The private key lives only here; patbond-ai holds the matching public key
* and MUST enforce {@code aud} and {@code typ}.
*/
public class ServiceTokenSigner {
/** Marks the token as a service identity rather than an end user. */
public static final String TYPE_CLAIM = "typ";
public static final String SERVICE_TYPE = "service";
private final RSAPrivateCrtKey privateKey;
private final String issuer;
private final String audience;
private final String subject;
private final Duration ttl;
public ServiceTokenSigner(String privateKeyLocation, String issuer, String audience,
String subject, Duration ttl) {
// Tolerated at construction, fatal on use — the same contract as
// JwtVerifier's optional public key. A context that never calls
// patbond-ai (most tests, and a request-only replica) must be able to
// boot without a key, but a missing key must never be mistaken for an
// upstream problem: it is a server misconfiguration and says so.
this.privateKey = privateKeyLocation == null || privateKeyLocation.isBlank()
? null
: RsaPrivateKeyLoader.load(privateKeyLocation);
this.issuer = issuer;
this.audience = audience;
this.subject = subject;
this.ttl = ttl;
}
/**
* Mints a token for one outbound call. Tokens are signed per call and never
* cached: the TTL is minutes, signing is cheap, and a cached token outliving
* its purpose is the kind of thing that quietly becomes a long-lived secret.
*
* @throws IllegalStateException when no private key is configured
*/
public String sign() {
if (privateKey == null) {
throw new IllegalStateException(
"patbond.creation.service-token.private-key 未配置:请生成一对"
+ "**独立于用户 token 的** RS256 密钥并通过环境变量注入"
+ "(见 application.yml.sample)");
}
Instant now = Instant.now();
return Jwts.builder()
.issuer(issuer)
.audience().add(audience).and()
.subject(subject)
.claim(TYPE_CLAIM, SERVICE_TYPE)
.id(UUID.randomUUID().toString())
.issuedAt(Date.from(now))
.expiration(Date.from(now.plus(ttl)))
.signWith(privateKey)
.compact();
}
}
@@ -0,0 +1,140 @@
package com.patbond.patbond.creation.service;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.creation.config.CreationProperties;
import com.patbond.patbond.creation.domain.GenerationJob;
import com.patbond.patbond.creation.domain.MediaKind;
import com.patbond.patbond.creation.dto.CreateGenerationJobRequest;
import com.patbond.patbond.creation.dto.GenerationJobResponse;
import com.patbond.patbond.creation.repository.CatalogRepository;
import com.patbond.patbond.creation.repository.GenerationJobRepository;
import com.patbond.patbond.creation.support.RequestHashes;
import com.patbond.patbond.creation.support.UuidV7;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Generation job lifecycle as the client drives it: create, read, list, cancel.
* Execution is the worker's business — creating a job only enqueues it.
*/
@Service
public class GenerationService {
private final GenerationJobRepository jobs;
private final CatalogRepository catalog;
private final CreationProperties properties;
public GenerationService(GenerationJobRepository jobs, CatalogRepository catalog,
CreationProperties properties) {
this.jobs = jobs;
this.catalog = catalog;
this.properties = properties;
}
/**
* Enqueues a job, or returns the existing one for a repeated
* Idempotency-Key.
*
* <p>Idempotency follows ADR-019: same key + same payload returns the first
* job, same key + different payload answers 40905. The check runs before the
* insert as a courtesy, but correctness does not depend on it — the unique
* constraint {@code (user_id, idempotency_key)} is the real guard, so two
* concurrent identical requests cannot both create a job.
*/
@Transactional
public GenerationJobResponse create(UUID userId, String idempotencyKey,
CreateGenerationJobRequest request) {
MediaKind mediaKind = parseMediaKind(request.mediaKind());
byte[] requestHash = RequestHashes.sha256(request.canonical());
Optional<UUID> existing = jobs.findIdByIdempotencyKey(userId, idempotencyKey, requestHash);
if (existing.isPresent()) {
return jobs.findByIdForUser(existing.get(), userId)
.map(this::toResponse)
.orElseThrow(() -> new BusinessException(ErrorCode.GENERATION_JOB_NOT_FOUND));
}
if (jobs.idempotencyKeyConflicts(userId, idempotencyKey, requestHash)) {
throw new BusinessException(ErrorCode.IDEMPOTENCY_PAYLOAD_MISMATCH);
}
// Resolving the catalog here does two things: it turns a bad model id
// into a clean 404 instead of a composite-FK violation, and it yields
// the provider values frozen onto the job. Those snapshots are why a job
// stays pollable after the catalog is re-pointed or disabled.
CatalogRepository.ModelRow model = catalog.findEnabledModel(request.modelId(), mediaKind)
.orElseThrow(() -> new BusinessException(ErrorCode.GENERATION_MODEL_NOT_FOUND));
String styleCode = null;
if (request.styleId() != null) {
styleCode = catalog.findEnabledStyle(request.styleId(), mediaKind)
.orElseThrow(() -> new BusinessException(ErrorCode.GENERATION_MODEL_NOT_FOUND))
.code();
}
UUID jobId = UuidV7.generate();
jobs.insertQueued(jobId, userId, request.petId(), mediaKind,
model.id(), request.styleId(), request.inputAssetId(),
request.prompt(), request.negativePrompt(),
request.widthPx(), request.heightPx(), request.durationMs(),
model.providerCode(), model.providerModelName(), model.modelVersion(),
styleCode, idempotencyKey, requestHash);
return jobs.findByIdForUser(jobId, userId)
.map(this::toResponse)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_ERROR));
}
public GenerationJobResponse get(UUID userId, UUID jobId) {
return jobs.findByIdForUser(jobId, userId)
.map(this::toResponse)
// Another user's job and a non-existent job answer identically,
// so the endpoint cannot be used to probe for job ids.
.orElseThrow(() -> new BusinessException(ErrorCode.GENERATION_JOB_NOT_FOUND));
}
public List<GenerationJobResponse> list(UUID userId, int limit) {
return jobs.listForUser(userId, limit).stream().map(this::toResponse).toList();
}
/**
* Cancels a job that has not finished.
*
* <p>The status guard lives in the UPDATE's WHERE clause rather than in a
* read-then-write, so a job that completes concurrently is not overwritten:
* 0 rows affected means it reached a terminal state first, which is reported
* as 42206 rather than pretended to be a successful cancellation.
*
* <p>Note this cancels the job record only. Whether the upstream request can
* also be withdrawn depends on the vendor and is not knowable until their
* documentation lands; until then a cancelled job may still be paid for.
*/
@Transactional
public GenerationJobResponse cancel(UUID userId, UUID jobId) {
int cancelled = jobs.markCancelled(jobId, userId);
if (cancelled == 0) {
GenerationJob job = jobs.findByIdForUser(jobId, userId)
.orElseThrow(() -> new BusinessException(ErrorCode.GENERATION_JOB_NOT_FOUND));
throw new BusinessException(ErrorCode.GENERATION_STATE_INVALID,
"任务已处于终态:" + job.status().dbValue());
}
return get(userId, jobId);
}
private GenerationJobResponse toResponse(GenerationJob job) {
return GenerationJobResponse.of(job,
properties.clientTimeoutFor(job.mediaKind()),
properties.staleAfterFor(job.mediaKind()));
}
private static MediaKind parseMediaKind(String value) {
try {
return MediaKind.fromDb(value);
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.VALIDATION_ERROR, "mediaKind 只能是 image 或 video");
}
}
}
@@ -0,0 +1,46 @@
package com.patbond.patbond.creation.support;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* SHA-256 of the canonical form of a create request (ADR-019: creation-type
* writes carry a mandatory Idempotency-Key and the request body's hash is
* stored next to it — a keyed retry with the same payload returns the first
* write, a different payload answers 40905). Hashing the NORMALIZED command
* (trimmed fields, defaults applied, media positions resolved) rather than
* the raw bytes makes the comparison insensitive to JSON formatting while
* still catching every semantic difference. 32 bytes, matching
* ck_posts_idempotency's octet_length(request_hash) = 32.
*/
public final class RequestHashes {
private RequestHashes() {
}
/**
* SHA-256 hex of a value, for identifiers that must be stable but must not
* carry the original. Used for the vendor's {@code safety_identifier}: it
* needs a per-user constant, and a hash serves that without handing over our
* user ids (ADR-023 §5).
*/
public static String hex(String value) {
byte[] digest = sha256(value);
StringBuilder out = new StringBuilder(digest.length * 2);
for (byte b : digest) {
out.append(Character.forDigit((b >> 4) & 0xF, 16));
out.append(Character.forDigit(b & 0xF, 16));
}
return out.toString();
}
public static byte[] sha256(String canonical) {
try {
return MessageDigest.getInstance("SHA-256")
.digest(canonical.getBytes(StandardCharsets.UTF_8));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,31 @@
package com.patbond.patbond.creation.support;
import java.security.SecureRandom;
import java.util.UUID;
/**
* Application-side UUIDv7 generator (RFC 9562): 48-bit Unix millisecond
* timestamp, version/variant bits, 74 random bits. Time-ordered values keep
* B-tree page churn low on uuid primary keys; the database DEFAULT
* gen_random_uuid() remains the fallback for rows not inserted through the
* application. Fourth copy after patbond-user/pet/community — the services
* deploy independently and patbond-common stays contract-only; upgrading the
* shared artifacts is an M4 ticket of its own (ADR-023 §2).
*/
public final class UuidV7 {
private static final SecureRandom RANDOM = new SecureRandom();
private UuidV7() {
}
public static UUID generate() {
long timestampMs = System.currentTimeMillis();
long randA = RANDOM.nextLong() & 0x0FFFL;
long randB = RANDOM.nextLong() & 0x3FFFFFFFFFFFFFFFL;
long msb = (timestampMs << 16) | 0x7000L | randA;
long lsb = 0x8000000000000000L | randB;
return new UUID(msb, lsb);
}
}
@@ -0,0 +1,68 @@
package com.patbond.patbond.creation.web;
import com.patbond.patbond.common.error.BusinessException;
import com.patbond.patbond.common.error.ErrorCode;
import com.patbond.patbond.common.response.ApiResponse;
import jakarta.validation.ConstraintViolationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingRequestHeaderException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.resource.NoResourceFoundException;
/**
* Single place that turns exceptions into the {code, message, data} envelope
* with a matching HTTP status (development-plan 6.1) — same contract as the
* user/auth/pet handlers. Unexpected exceptions are logged in full but never
* leak internals to the client.
*/
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException e) {
return ResponseEntity.status(e.getHttpStatus())
.body(ApiResponse.failure(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(FieldError::getDefaultMessage)
.orElse(ErrorCode.VALIDATION_ERROR.getDefaultMessage());
return failure(ErrorCode.VALIDATION_ERROR, message);
}
@ExceptionHandler({HttpMessageNotReadableException.class, MethodArgumentTypeMismatchException.class,
ConstraintViolationException.class, HandlerMethodValidationException.class,
MissingRequestHeaderException.class})
public ResponseEntity<ApiResponse<Void>> handleMalformedRequest(Exception e) {
return failure(ErrorCode.VALIDATION_ERROR, ErrorCode.VALIDATION_ERROR.getDefaultMessage());
}
@ExceptionHandler(NoResourceFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNoResource(NoResourceFoundException e) {
return ResponseEntity.status(404).body(ApiResponse.failure(40400, "资源不存在"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnexpected(Exception e) {
log.error("Unhandled exception", e);
return failure(ErrorCode.INTERNAL_ERROR, ErrorCode.INTERNAL_ERROR.getDefaultMessage());
}
private static ResponseEntity<ApiResponse<Void>> failure(ErrorCode errorCode, String message) {
return ResponseEntity.status(errorCode.getHttpStatus())
.body(ApiResponse.failure(errorCode.getCode(), message));
}
}
@@ -0,0 +1,271 @@
package com.patbond.patbond.creation.worker;
import com.patbond.patbond.creation.config.CreationProperties;
import com.patbond.patbond.creation.config.ProviderRegistry;
import com.patbond.patbond.creation.domain.GenerationJob;
import com.patbond.patbond.creation.provider.GenerationProvider;
import com.patbond.patbond.creation.provider.ai.AiProviderException;
import com.patbond.patbond.creation.provider.ai.AiServiceException;
import com.patbond.patbond.creation.repository.GenerationJobRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.OffsetDateTime;
import java.util.List;
/**
* The lease queue worker (ADR-023 §3/§4).
*
* <p>Three responsibilities, each a separate scheduled pass so a stall in one
* cannot starve the others:
* <ol>
* <li>{@link #submitPass()} — claim due QUEUED jobs and hand them upstream;</li>
* <li>{@link #pollPass()} — ask after RUNNING jobs this worker holds, renewing
* the lease in the same statement;</li>
* <li>{@link #reapPass()} — return expired-lease jobs to the queue.</li>
* </ol>
*
* <p><b>Crash safety comes from the database, not from this class.</b> No job
* state is held in memory between ticks: a claim writes RUNNING plus a lease, a
* poll renews it, and a worker that dies simply stops renewing until the reaper
* requeues the row. That is why no broker is needed and why replicas may be
* restarted freely.
*
* <p><b>Why the ordering inside {@link #submitPass()} matters.</b>
* {@code provider_request_id} is persisted BEFORE the provider is asked to do
* anything billable. If the call then times out we do not know whether the
* vendor charged us — but we do know the request id, so the next attempt polls
* instead of re-submitting. Reverse the order and every timeout becomes a
* double charge.
*/
@Component
@ConditionalOnProperty(prefix = "patbond.creation.worker", name = "enabled",
havingValue = "true", matchIfMissing = true)
public class GenerationWorker {
private static final Logger log = LoggerFactory.getLogger(GenerationWorker.class);
private final GenerationJobRepository repository;
private final ProviderRegistry providers;
private final CreationProperties properties;
private final UploadTargetGateway uploadTargets;
private final String leaseOwner;
public GenerationWorker(GenerationJobRepository repository, ProviderRegistry providers,
CreationProperties properties, UploadTargetGateway uploadTargets) {
this.repository = repository;
this.providers = providers;
this.properties = properties;
this.uploadTargets = uploadTargets;
this.leaseOwner = WorkerIdentity.current();
}
// ------------------------------------------------------------- submit
@Scheduled(fixedDelayString = "${patbond.creation.worker.tick-interval:3s}")
public void submitPass() {
CreationProperties.Worker config = properties.getWorker();
List<GenerationJob> claimed;
try {
claimed = repository.claimQueued(leaseOwner, config.getLeaseTtl(), config.getBatchSize());
} catch (RuntimeException e) {
// A failing claim must not kill the scheduled task: Spring cancels a
// fixedDelay task whose method throws, which would silently stop the
// whole queue until the next restart.
log.error("Claiming queued generation jobs failed", e);
return;
}
for (GenerationJob job : claimed) {
try {
submitOne(job);
} catch (AiProviderException e) {
// 服务商说不行。分类已由适配层给出,直接据此决定。
log.warn("提交任务 {} 被服务商拒绝 code={} retry={}", job.id(),
e.failure().providerCode(), e.failure().retry());
failOrRetry(job, e.failure());
} catch (AiServiceException e) {
// 我方与 patbond-ai 之间的问题:请求体不合契约、服务票被拒、适配器
// 未装配。**大多不该重试**——重试一个拼错的请求永远不会变好。
log.error("提交任务 {} 失败于内部接口 code={}", job.id(), e.code(), e);
failOrRetry(job, new GenerationProvider.PollResult.Failure(
e.mayClearOnItsOwn()
? GenerationProvider.Retry.BACKOFF
: GenerationProvider.Retry.NEVER,
GenerationProvider.UserFacing.SERVER_ERROR,
"AI_" + (e.code() == null ? "UNKNOWN" : e.code().toUpperCase()),
e.getMessage()));
} catch (RuntimeException e) {
log.error("提交任务 {} 时发生未预期异常", job.id(), e);
failOrRetry(job, new GenerationProvider.PollResult.Failure(
GenerationProvider.Retry.BACKOFF,
GenerationProvider.UserFacing.SERVER_ERROR,
"SUBMIT_UNEXPECTED", e.getMessage()));
}
}
}
private void submitOne(GenerationJob job) {
GenerationProvider provider = providers.find(job.providerCodeSnapshot()).orElse(null);
if (provider == null) {
// The adapter that submitted this job is gone. Retrying cannot help,
// so fail terminally rather than loop until the budget is spent.
log.error("No provider adapter for code {} (job {}); known codes: {}",
job.providerCodeSnapshot(), job.id(), providers.knownCodes());
repository.markFailed(job.id(), "PROVIDER_UNKNOWN",
"生成服务适配器不可用:" + job.providerCodeSnapshot());
return;
}
if (job.isSubmittedUpstream()) {
// Already handed over on an earlier attempt (timeout, or a worker
// that died after submitting). Do NOT submit again — poll.
log.info("Job {} already has provider_request_id {}, polling instead of re-submitting",
job.id(), job.providerRequestId());
pollOne(job, provider);
return;
}
// uploadTarget 传 null:产物转存阻塞于对象存储决策(ADR-023 §7),而内部
// 契约把该字段标为可选未启用。**这里刻意不调 uploadTargets**——那个方法
// 目前会抛异常,在 submit 之前调它会让整条链一步都跑不动。启用转存时在此
// 取回预签名地址即可,接口签名无需改动。
GenerationProvider.SubmitResult result = provider.submit(job, null);
int recorded = repository.recordProviderRequest(job.id(), result.providerRequestId());
if (recorded == 0) {
// Another attempt recorded an id first; ours is a duplicate
// submission we cannot un-send. Worth a loud log — it means the
// guard above was bypassed by a race.
log.warn("provider_request_id for job {} was already set; possible duplicate submission",
job.id());
}
}
// --------------------------------------------------------------- poll
@Scheduled(fixedDelayString = "${patbond.creation.worker.tick-interval:3s}")
public void pollPass() {
CreationProperties.Worker config = properties.getWorker();
List<GenerationJob> due;
try {
// "Due for a poll" is expressed as "lease is within one poll
// interval of expiring", so renewal and polling stay one decision.
due = repository.claimRunningDueForPoll(leaseOwner, config.getLeaseTtl(),
config.getPollIntervalEarly(), config.getBatchSize());
} catch (RuntimeException e) {
log.error("Claiming running generation jobs failed", e);
return;
}
for (GenerationJob job : due) {
GenerationProvider provider = providers.find(job.providerCodeSnapshot()).orElse(null);
if (provider == null || !job.isSubmittedUpstream()) {
continue;
}
try {
pollOne(job, provider);
} catch (RuntimeException e) {
log.error("Polling generation job {} failed", job.id(), e);
}
}
}
private void pollOne(GenerationJob job, GenerationProvider provider) {
GenerationProvider.PollResult result = provider.poll(job, job.providerRequestId());
switch (result.state()) {
case PENDING, RUNNING -> {
if (result.progress() > job.progress()) {
repository.updateProgress(job.id(), result.progress());
}
}
case SUCCEEDED -> {
// 产物已生成,元数据(尺寸、时长、封面、计费 token)都已在手——但把它
// 变成 media.assets 行是 patbond-user 的职责(ADR-017 把媒体写入流程
// 留在那里),需先落地其 /internal/media/**,而该端点的返回形态取决于
// **挂起中的**对象存储决策(ADR-023 §7)。
//
// 这里有时限:服务商产物 URL 仅 24 小时有效,过期即死链。因此
// 「先跑通链路、转存以后再说」这个状态下跑出来的产物留不住。
log.error("产物入库未实现,任务 {} 的产物将在 {} 秒后失效:"
+ "assetUrl={} cover={} {}x{} durationMs={} tokens={}",
job.id(), result.assetUrlExpiresInSec(), result.assetUrl(),
result.coverUrl(), result.widthPx(), result.heightPx(),
result.durationMs(), result.billingTokens());
throw new UnsupportedOperationException(
"产物入库未实现(ADR-023 §7 对象存储决策挂起):job=" + job.id());
}
case FAILED -> failOrRetry(job, result.failure());
case CANCELLED -> log.info("任务 {} 已在服务商侧取消", job.id());
}
}
// ---------------------------------------------------------------- reap
@Scheduled(fixedDelayString = "${patbond.creation.worker.tick-interval:3s}")
public void reapPass() {
try {
int reaped = repository.reapExpiredLeases(properties.getWorker().getBatchSize());
if (reaped > 0) {
// Expected after a worker restart; unexpected in steady state,
// where it means the lease TTL is shorter than a poll cycle.
log.warn("Returned {} generation job(s) with expired leases to the queue", reaped);
}
} catch (RuntimeException e) {
log.error("Reaping expired generation leases failed", e);
}
}
// ------------------------------------------------------------- helpers
/**
* Terminal failure or another attempt.
*
* <p>The decision uses the adapter's three-valued {@code retry}, not a
* boolean. The distinction that matters: a rate limit clears in seconds
* ({@code BACKOFF}), an exhausted quota clears tomorrow ({@code QUOTA}).
* Treating the latter as retryable burns the whole retry budget for nothing
* and still fails — and meanwhile the user watches a spinner instead of the
* "quota exhausted" sheet that would tell them what to do (ADR-023 §9).
*/
private void failOrRetry(GenerationJob job, GenerationProvider.PollResult.Failure failure) {
if (!failure.retry().worthRetrying() || job.isLastAttempt()) {
repository.markFailed(job.id(), errorCodeOf(failure), truncate(failure.message()));
return;
}
Duration backoff = properties.getWorker().retryBackoffFor(job.attemptCount());
repository.requeueWithBackoff(job.id(), backoff);
}
/**
* The code persisted on the job row, and thus what the client eventually
* sees. Uses the adapter's {@code userFacing} category rather than the raw
* vendor code: the vendor's vocabulary must not leak into our data — it would
* change when the provider changes, and clients would end up branching on it.
* The raw code stays in the error message for troubleshooting.
*/
private static String errorCodeOf(GenerationProvider.PollResult.Failure failure) {
return failure.userFacing().name();
}
/** error_message is varchar(1000); a vendor stack trace must not blow the insert. */
private static String truncate(String message) {
if (message == null) {
return null;
}
return message.length() <= 1000 ? message : message.substring(0, 1000);
}
/** Exposed for tests that need to know which owner string to expect. */
public String leaseOwner() {
return leaseOwner;
}
/** @return how long the current attempt has been running, for the poll ladder */
static Duration runningFor(GenerationJob job) {
OffsetDateTime startedAt = job.startedAt();
return startedAt == null ? Duration.ZERO
: Duration.between(startedAt.toInstant(), OffsetDateTime.now().toInstant());
}
}
@@ -0,0 +1,41 @@
package com.patbond.patbond.creation.worker;
import com.patbond.patbond.creation.domain.GenerationJob;
import org.springframework.stereotype.Component;
/**
* Obtains a presigned PUT target for a job's artifact from patbond-user's
* internal media API.
*
* <p><b>Why this indirection instead of writing to the object store here.</b>
* ADR-017 keeps the media upload flow in patbond-user, and {@code ObjectStorage}
* already offers {@code presignPut}/{@code presignGet} — so nothing needs a new
* "server-side put" primitive. Handing patbond-ai a presigned URL means the
* generation service holds no object-storage credentials and the Python service
* holds none either: the artifact goes straight from the vendor to storage,
* never through this JVM. For video that is the difference between streaming
* bytes through two extra hops and not.
*
* <p>Unimplemented at skeleton stage, and for a specific reason: the endpoint's
* response shape depends on the object-storage decision, which is explicitly
* deferred (ADR-023 §7). With local object storage the reply is a presigned URL
* against the internal endpoint; with a cloud provider it is that vendor's
* upload credential, and patbond-ai additionally grows fetch code for it. Both
* are one method here — but guessing which would bake the pending decision into
* code.
*/
@Component
public class UploadTargetGateway {
/**
* @return a presigned PUT URL the provider may upload the artifact to
* @throws UnsupportedOperationException until the internal media endpoint and
* the storage decision land
*/
public String presignedPutFor(GenerationJob job) {
throw new UnsupportedOperationException(
"预签名上传目标获取未实现:需先在 patbond-user 落地 /internal/media/** "
+ "(签发 presigned PUT + confirm 建 media.assets 行),"
+ "且其返回形态取决于对象存储方案(ADR-023 §7 挂起)。job=" + job.id());
}
}
@@ -0,0 +1,35 @@
package com.patbond.patbond.creation.worker;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.UUID;
/**
* Produces the {@code lease_owner} string a worker instance claims jobs under.
*
* <p>It must be stable for the life of the process (a worker has to be able to
* find the jobs it holds) and distinct between processes (two replicas must not
* be able to poll each other's jobs). Host plus PID satisfies both; the random
* suffix covers the container case where two replicas can share a hostname and
* PID namespace, which would otherwise let them silently steal each other's
* leases.
*
* <p>Capped at 128 characters to match {@code lease_owner varchar(128)}.
*/
final class WorkerIdentity {
private WorkerIdentity() {
}
static String current() {
String host;
try {
host = InetAddress.getLocalHost().getHostName();
} catch (UnknownHostException e) {
host = "unknown-host";
}
String identity = host + "/" + ProcessHandle.current().pid()
+ "/" + UUID.randomUUID().toString().substring(0, 8);
return identity.length() <= 128 ? identity : identity.substring(identity.length() - 128);
}
}
@@ -0,0 +1,86 @@
server:
port: ${PATBOND_CREATION_PORT:8085}
spring:
application:
name: patbond-creation
datasource:
# 与 patbond-user 共库(MVP 单库多 schema);本服务只读写 creation schema,
# 并只读 community.posts 的 generation_job_id 关联(建草稿由 community 端点完成)。
# Flyway 迁移链(V1..V6,含 creation 基线)由 patbond-user 启动时统一执行,
# 本服务不携带 Flyway —— 单一 flyway_schema_history 归属不拆(ADR-023 §2)。
url: ${PATBOND_DB_URL:jdbc:postgresql://127.0.0.1:5432/patbond}
username: ${PATBOND_DB_USER:patbond}
password: ${PATBOND_DB_PASSWORD:patbond}
# /api/v1/** 业务端点自骨架起即接 RS256 校验,与 user/pet/community 同一约定。
patbond:
jwt:
# RS256 公钥,用于本地校验 patbond-auth 签发的 access token。
# 值可以是 PEM 文件路径,也可以是内联 PEM 内容(以 -----BEGIN 开头)。
# 私钥只给 patbond-auth,绝不入库。
public-key: ${PATBOND_JWT_PUBLIC_KEY:}
# /internal/** 服务间共享密钥(Java↔Java 既有通道),需与 patbond-user 同值。
# 注意:调用 patbond-ai 用的**不是**这个 token,而是下面的服务身份 JWT
# ——两套机制并存的原因见 ADR-023 §5。
internal-token: ${PATBOND_INTERNAL_TOKEN:dev-only-internal-token}
user-service:
# 预签名上传凭证来源:patbond-user 的 /internal/media/**(ADR-017 边界:
# 媒体上传流程归 user,本服务不直连对象存储、也不持有其凭证)。
url: ${PATBOND_USER_SERVICE_URL:http://127.0.0.1:8082}
creation:
# ---- 上游生成服务(patbond-ai,Python)----
ai-service:
url: ${PATBOND_AI_SERVICE_URL:http://127.0.0.1:8000}
# 单次 HTTP 调用超时。注意这是**提交/查询**的超时,不是生成耗时——
# Java↔Python 边界一律异步(ADR-023 §4),故此值与出图出片时长无关,
# 不得为了「等出图」而调大。
connect-timeout: ${PATBOND_AI_CONNECT_TIMEOUT:2s}
read-timeout: ${PATBOND_AI_READ_TIMEOUT:5s}
# ---- 服务身份 JWT(ADR-023 §5)----
# **独立密钥对**,与用户 token 的密钥对不同。理由:现有 JwtVerifier 只验
# 签名、不校验 aud/iss,用户 token 亦无 aud claim;若复用同一密钥对,
# 四个模块都会把服务票当合法用户 token 接受。独立密钥对无需改动任何
# 已发布的验证器。私钥只给本服务,公钥给 patbond-ai。
service-token:
private-key: ${PATBOND_SERVICE_JWT_PRIVATE_KEY:}
issuer: ${PATBOND_SERVICE_JWT_ISSUER:patbond-api}
audience: ${PATBOND_SERVICE_JWT_AUDIENCE:patbond-ai}
subject: ${PATBOND_SERVICE_JWT_SUBJECT:service:creation-worker}
# 短有效期:服务票每次调用现签,不缓存、不复用。
ttl: ${PATBOND_SERVICE_JWT_TTL:5m}
# ---- Worker 与租约(ADR-023 §3/§4)----
worker:
# 关掉后本服务只提供读写端点、不消费队列(便于单实例调试与测试隔离)。
enabled: ${PATBOND_CREATION_WORKER_ENABLED:true}
# 调度器 tick 间隔。真正的轮询节律由 next_attempt_at 控制,
# tick 只决定「最快多久发现一件该做的事」。
tick-interval: ${PATBOND_CREATION_TICK_INTERVAL:3s}
# 单 tick 最多领取多少条,防止一个实例吃光整个队列。
batch-size: ${PATBOND_CREATION_BATCH_SIZE:5}
# 租约时长。**必须大于最长的单次轮询间隔**(见下 poll-interval-late),
# 否则会出现「任务仍在跑但租约过期被他人抢占」。每次轮询顺带续租。
lease-ttl: ${PATBOND_CREATION_LEASE_TTL:60s}
# 服务端轮询 patbond-ai 的退避节律(ADR-023 §4)。视频任务绝对次数很多,
# 固定 3s 会产生数百次无谓轮询与续租写入。
poll-interval-early: ${PATBOND_CREATION_POLL_EARLY:3s} # 前 60 秒
poll-interval-mid: ${PATBOND_CREATION_POLL_MID:10s} # 60s ~ 5min
poll-interval-late: ${PATBOND_CREATION_POLL_LATE:30s} # 5min 以后
poll-early-until: ${PATBOND_CREATION_POLL_EARLY_UNTIL:60s}
poll-mid-until: ${PATBOND_CREATION_POLL_MID_UNTIL:5m}
# 失败重试的指数退避基数(实际间隔 = base * 2^(attempt-1))。
retry-backoff-base: ${PATBOND_CREATION_RETRY_BACKOFF:30s}
# 客户端硬超时由服务端下发而非客户端硬编码(ADR-023 §10):图片与视频
# 差一个量级,硬编码两个数会使换 provider 就要发版。
client-timeout-image: ${PATBOND_CREATION_CLIENT_TIMEOUT_IMAGE:5m}
client-timeout-video: ${PATBOND_CREATION_CLIENT_TIMEOUT_VIDEO:20m}
# 任务被判定为僵死的阈值(running 超过此时长)。对应客户端的第六个 UI 态,
# 它不是错误态(ADR-023 §10)。
stale-after-image: ${PATBOND_CREATION_STALE_IMAGE:10m}
stale-after-video: ${PATBOND_CREATION_STALE_VIDEO:40m}
@@ -0,0 +1,27 @@
package com.patbond.patbond.creation;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.springframework.context.annotation.Bean;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.utility.DockerImageName;
/**
* Shared Testcontainers setup: a disposable postgres:18 (the production target
* version) wired into the Spring context via {@code @ServiceConnection}.
*
* <p>The production module carries no Flyway — the single migration chain is
* owned by patbond-user — but the TEST classpath adds patbond-user's jar plus
* Flyway, so Boot applies the full V1..V6 chain, including the creation schema
* these tests exercise, to the fresh container exactly as the shared database
* gets it in production (same mechanism as pet/community).
*/
@TestConfiguration(proxyBeanMethods = false)
public class TestcontainersConfiguration {
@Bean
@ServiceConnection
PostgreSQLContainer<?> postgresContainer() {
return new PostgreSQLContainer<>(DockerImageName.parse("postgres:18"));
}
}
@@ -0,0 +1,399 @@
package com.patbond.patbond.creation.provider.ai;
import com.patbond.patbond.creation.TestcontainersConfiguration;
import com.patbond.patbond.creation.domain.GenerationJob;
import com.patbond.patbond.creation.domain.JobStatus;
import com.patbond.patbond.creation.domain.MediaKind;
import com.patbond.patbond.creation.provider.AiServiceGenerationProvider;
import com.patbond.patbond.creation.provider.GenerationProvider;
import com.patbond.patbond.creation.provider.GenerationProvider.PollResult;
import com.patbond.patbond.creation.provider.GenerationProvider.Retry;
import com.patbond.patbond.creation.provider.GenerationProvider.UserFacing;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* The real Feign wiring against an in-test HTTP server standing in for
* patbond-ai: snake_case serialisation both ways, the service-token interceptor,
* and — the important part — <b>the two error kinds landing on two different
* exceptions</b>.
*
* <p>Why the error split deserves wire-level tests rather than unit tests on the
* decoder: if the split silently collapses (one generic FeignException), the
* worker can only guess whether retrying helps. A quota-exhausted job would then
* burn its whole retry budget and still fail, while the user watches a spinner
* instead of being told to top up.
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
@TestPropertySource(properties = "patbond.creation.worker.enabled=false")
class AiGenerationClientWireTest {
private static HttpServer server;
private static final AtomicReference<String> responseBody = new AtomicReference<>("{}");
private static final AtomicReference<Integer> responseStatus = new AtomicReference<>(200);
private static final AtomicReference<String> lastRequestBody = new AtomicReference<>("");
private static final AtomicReference<String> lastAuthorization = new AtomicReference<>("");
private static final AtomicReference<String> lastPath = new AtomicReference<>("");
@Autowired
private AiGenerationClient client;
@DynamicPropertySource
static void aiService(DynamicPropertyRegistry registry) throws IOException {
int port = ThreadLocalRandom.current().nextInt(20000, 45000);
server = HttpServer.create(new InetSocketAddress("127.0.0.1", port), 0);
server.createContext("/internal/generation", exchange -> {
lastPath.set(exchange.getRequestURI().getPath());
lastAuthorization.set(
String.valueOf(exchange.getRequestHeaders().getFirst("Authorization")));
lastRequestBody.set(
new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] body = responseBody.get().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(responseStatus.get(), body.length);
try (OutputStream out = exchange.getResponseBody()) {
out.write(body);
}
});
server.start();
registry.add("patbond.creation.ai-service.url", () -> "http://127.0.0.1:" + port);
// 服务票需要一把私钥才能签出来;测试期现场生成。
registry.add("patbond.creation.service-token.private-key", TestKeys::privateKeyPem);
}
@AfterAll
static void stopServer() {
if (server != null) {
server.stop(0);
}
}
@BeforeEach
void reset() {
responseStatus.set(200);
responseBody.set("{}");
lastRequestBody.set("");
}
// ------------------------------------------------------- 序列化与鉴权
@Test
void requestBodyIsSerialisedInSnakeCase() {
responseBody.set("{\"provider_request_id\":\"cgt-1\"}");
client.submit(new AiContract.SubmitRequest(
"job-1", "video", "doubao-seedance-2-5-260628", "一只猫",
null, null, "720p", 5, true, false, "hash", null, null));
String sent = lastRequestBody.get();
assertThat(sent).contains("\"job_id\"", "\"media_kind\"", "\"duration_sec\"",
"\"generate_audio\"", "\"safety_identifier\"");
// camelCase 一个都不该出现——出现即意味着命名策略没生效,而症状是对端
// 静默忽略那些字段(extra=forbid 下则是 400),两者都难查。
assertThat(sent).doesNotContain("jobId", "mediaKind", "durationSec");
}
@Test
void unsetOptionalFieldsAreOmittedNotSentAsNull() {
// patbond-ai 侧 extra="forbid" 且各字段有文档化的默认值;显式 null 会
// 覆盖掉那些默认值。
responseBody.set("{\"provider_request_id\":\"cgt-1\"}");
client.submit(new AiContract.SubmitRequest(
"job-1", "video", "m", "prompt",
null, null, null, null, null, null, null, null, null));
assertThat(lastRequestBody.get()).doesNotContain("null");
}
@Test
void responseIsDeserialisedFromSnakeCase() {
responseBody.set("""
{"provider_request_id":"cgt-1","state":"succeeded","progress":100,
"asset_url":"https://v/out.mp4","asset_url_expires_in_sec":86400,
"cover_url":"https://v/f.png","ratio":"16:9","resolution":"720p",
"width_px":1280,"height_px":720,"duration_ms":5542,
"output_format":"mp4","billing_tokens":108900,"failure":null}
""");
AiContract.TaskResponse response = client.get("cgt-1");
assertThat(response.assetUrl()).isEqualTo("https://v/out.mp4");
assertThat(response.assetUrlExpiresInSec()).isEqualTo(86400);
assertThat(response.widthPx()).isEqualTo(1280);
assertThat(response.durationMs()).isEqualTo(5542);
assertThat(response.billingTokens()).isEqualTo(108900);
}
@Test
void everyCallCarriesAFreshlySignedServiceToken() {
responseBody.set("{\"provider_request_id\":\"cgt-1\"}");
client.submit(submitRequest());
String first = lastAuthorization.get();
client.submit(submitRequest());
String second = lastAuthorization.get();
assertThat(first).startsWith("Bearer ");
// 每次现签、不缓存:短期凭证一旦被缓存复用,就悄悄变成了长期凭证。
// jti 与 iat 不同,故两张票的字节不同。
assertThat(second).isNotEqualTo(first);
}
@Test
void pathParameterIsTheVendorTaskId() {
responseBody.set("{\"provider_request_id\":\"cgt-abc\",\"state\":\"running\",\"progress\":0}");
client.get("cgt-abc");
assertThat(lastPath.get()).isEqualTo("/internal/generation/tasks/cgt-abc");
}
// --------------------------------------------- 两类错误落在两个异常上
@Test
void providerErrorBecomesAiProviderExceptionCarryingTheClassification() {
responseStatus.set(502);
responseBody.set("""
{"error":{"kind":"provider","retry":"backoff",
"user_facing":"rate_limited",
"provider_code":"RateLimitExceeded.EndpointRPMExceeded",
"message":"RPM limit exceeded"}}
""");
assertThatThrownBy(() -> client.submit(submitRequest()))
.isInstanceOf(AiProviderException.class)
.satisfies(thrown -> {
PollResult.Failure failure = ((AiProviderException) thrown).failure();
assertThat(failure.retry()).isEqualTo(Retry.BACKOFF);
assertThat(failure.userFacing()).isEqualTo(UserFacing.RATE_LIMITED);
assertThat(failure.providerCode())
.isEqualTo("RateLimitExceeded.EndpointRPMExceeded");
});
}
@Test
void quotaExhaustedIsNotWorthRetryingUnlikeARateLimit() {
// 同为服务商 429,但一个等几秒、一个等到明天。若两者都按可重试处理,
// 配额耗尽的任务会白烧完整个重试预算再失败。
responseStatus.set(502);
responseBody.set("""
{"error":{"kind":"provider","retry":"quota",
"user_facing":"quota_exhausted","provider_code":"QuotaExceeded",
"message":"exhausted its free trial quota"}}
""");
assertThatThrownBy(() -> client.submit(submitRequest()))
.isInstanceOf(AiProviderException.class)
.satisfies(thrown -> {
PollResult.Failure failure = ((AiProviderException) thrown).failure();
assertThat(failure.retry()).isEqualTo(Retry.QUOTA);
assertThat(failure.retry().worthRetrying()).isFalse();
assertThat(failure.userFacing()).isEqualTo(UserFacing.QUOTA_EXHAUSTED);
});
}
@Test
void serviceErrorBecomesAiServiceExceptionWithItsContractCode() {
responseStatus.set(400);
responseBody.set("""
{"error":{"kind":"service","code":"invalid_request",
"message":"duration_sec: 模型 x 的时长需在 [4, 15] 秒内"}}
""");
assertThatThrownBy(() -> client.submit(submitRequest()))
.isInstanceOf(AiServiceException.class)
.satisfies(thrown -> {
AiServiceException error = (AiServiceException) thrown;
assertThat(error.code()).isEqualTo("invalid_request");
assertThat(error.mayClearOnItsOwn()).isFalse();
assertThat(error.getMessage()).contains("[4, 15]");
});
}
@Test
void taskNotFoundIsDistinguishableSoThePollerCanStop() {
responseStatus.set(404);
responseBody.set("""
{"error":{"kind":"service","code":"task_not_found",
"message":"服务商无此任务"}}
""");
assertThatThrownBy(() -> client.get("cgt-gone"))
.isInstanceOf(AiServiceException.class)
.satisfies(thrown ->
assertThat(((AiServiceException) thrown).isTaskNotFound()).isTrue());
}
@Test
void providerUnavailableMayClearOnItsOwn() {
// 通常是对端还没配服务商凭证——配好就能用,故值得重试。
responseStatus.set(503);
responseBody.set("""
{"error":{"kind":"service","code":"provider_unavailable",
"message":"没有可用的 video 生成适配器"}}
""");
assertThatThrownBy(() -> client.submit(submitRequest()))
.isInstanceOf(AiServiceException.class)
.satisfies(thrown ->
assertThat(((AiServiceException) thrown).mayClearOnItsOwn()).isTrue());
}
@Test
void anUnrecognisedErrorBodyIsTreatedAsRetryableAndSaysSo() {
// 打到错误地址时常见:网关返回 HTML。静默归类会让排查走错方向。
responseStatus.set(502);
responseBody.set("<html>bad gateway</html>");
assertThatThrownBy(() -> client.get("cgt-1"))
.isInstanceOf(AiProviderException.class)
.satisfies(thrown -> {
PollResult.Failure failure = ((AiProviderException) thrown).failure();
assertThat(failure.retry()).isEqualTo(Retry.BACKOFF);
assertThat(failure.providerCode()).isEqualTo("AI_RESPONSE_UNRECOGNISED");
});
}
@Test
void unknownRetryValueLeansTowardsRetrying() {
// 契约可能先长出一个取值,代码后知道。把可恢复的抖动判成用户可见的失败
// 是更糟的错误,而重试预算本身有上限。
responseStatus.set(502);
responseBody.set("""
{"error":{"kind":"provider","retry":"brand_new_strategy",
"user_facing":"upstream_unavailable","provider_code":"X","message":"m"}}
""");
assertThatThrownBy(() -> client.submit(submitRequest()))
.isInstanceOf(AiProviderException.class)
.satisfies(thrown -> assertThat(
((AiProviderException) thrown).failure().retry()).isEqualTo(Retry.BACKOFF));
}
// ------------------------------------------------- 适配器层的端到端映射
@Test
void adapterMapsSucceededTaskIntoAFullPollResult() {
responseBody.set("""
{"provider_request_id":"cgt-1","state":"succeeded","progress":100,
"asset_url":"https://v/out.mp4","asset_url_expires_in_sec":86400,
"cover_url":"https://v/f.png","ratio":"16:9","resolution":"720p",
"width_px":1280,"height_px":720,"duration_ms":5542,
"output_format":"mp4","billing_tokens":108900}
""");
PollResult result = new AiServiceGenerationProvider(client).poll(job(), "cgt-1");
assertThat(result.state()).isEqualTo(PollResult.State.SUCCEEDED);
assertThat(result.assetUrl()).isEqualTo("https://v/out.mp4");
// 时效随 URL 一起带上,故上层没有理由把服务商地址当永久存储。
assertThat(result.assetUrlExpiresInSec()).isEqualTo(86400);
assertThat(result.widthPx()).isEqualTo(1280);
assertThat(result.billingTokens()).isEqualTo(108900);
assertThat(result.failure()).isNull();
}
@Test
void adapterSendsTheRowsFrozenModelSnapshotNotTheLiveCatalog() {
// 任务记录里写的模型必须与实际调用的一致,否则事后无法对账。
responseBody.set("{\"provider_request_id\":\"cgt-1\"}");
new AiServiceGenerationProvider(client).submit(job(), null);
assertThat(lastRequestBody.get()).contains("doubao-seedance-2-5-260628");
}
@Test
void adapterHashesTheUserIdForTheVendorInsteadOfSendingIt() {
responseBody.set("{\"provider_request_id\":\"cgt-1\"}");
GenerationJob job = job();
new AiServiceGenerationProvider(client).submit(job, null);
String sent = lastRequestBody.get();
assertThat(sent).contains("safety_identifier");
// 明文 userId 绝不外发(ADR-023 §5 数据最小化)。
assertThat(sent).doesNotContain(job.userId().toString());
}
@Test
void adapterTurnsVendorTaskGoneIntoATerminalFailure() {
responseStatus.set(404);
responseBody.set("""
{"error":{"kind":"service","code":"task_not_found","message":"gone"}}
""");
PollResult result = new AiServiceGenerationProvider(client).poll(job(), "cgt-gone");
// 服务商已无此任务 → 没有什么可轮询了,终态失败而不是永远退避。
assertThat(result.state()).isEqualTo(PollResult.State.FAILED);
assertThat(result.failure().retry()).isEqualTo(Retry.NEVER);
assertThat(result.failure().providerCode()).isEqualTo("PROVIDER_TASK_GONE");
}
@Test
void adapterProviderCodeMatchesTheSeededCatalogValue() {
// 三处必须一致:本常量、patbond-ai 的 PROVIDER_CODE、V7 种子的 provider_code。
// 不一致时任务永远停在排队,而那看起来像队列坏了。
assertThat(new AiServiceGenerationProvider(client).providerCode())
.isEqualTo("volcengine-seedance");
}
// --------------------------------------------------------------- helpers
private static AiContract.SubmitRequest submitRequest() {
return new AiContract.SubmitRequest("job-1", "video", "m", "prompt",
null, null, null, null, null, null, null, null, null);
}
private static GenerationJob job() {
return new GenerationJob(
UUID.randomUUID(), UUID.randomUUID(), null, MediaKind.VIDEO,
UUID.randomUUID(), null, null, null, "一只猫", null,
null, null, 5000L, JobStatus.RUNNING, (short) 0, (short) 1, (short) 3,
"volcengine-seedance", "doubao-seedance-2-5-260628", null,
null, null, null, null, null, null, null);
}
/** 测试期临时 RSA 私钥,供服务票签发。 */
private static final class TestKeys {
private static final String PEM;
static {
try {
var generator = java.security.KeyPairGenerator.getInstance("RSA");
generator.initialize(2048);
var keyPair = generator.generateKeyPair();
PEM = "-----BEGIN PRIVATE KEY-----\n"
+ java.util.Base64.getMimeEncoder(64, new byte[]{'\n'})
.encodeToString(keyPair.getPrivate().getEncoded())
+ "\n-----END PRIVATE KEY-----\n";
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
static String privateKeyPem() {
return PEM;
}
}
}
@@ -0,0 +1,113 @@
package com.patbond.patbond.creation.repository;
import com.patbond.patbond.creation.TestcontainersConfiguration;
import com.patbond.patbond.creation.domain.MediaKind;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.TestPropertySource;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/**
* V7 目录种子的内容断言。
*
* <p>不只是数行数:这里钉住的是几个**跨语言、跨仓库的约定**,它们一旦对不上,
* 症状都离根因很远:
*
* <ul>
* <li>{@code provider_code} 必须与 patbond-ai 适配器的 {@code PROVIDER_CODE}
* 逐字一致。对不上时 Worker 按任务行冻结的快照查不到适配器,任务会永远
* 停在排队——而那看起来像队列坏了,不像两个字符串不一致。</li>
* <li>{@code provider_model_name} 必须是账号**实际开通**的模型 ID。写错会撞
* {@code InvalidEndpointOrModel.NotFound},且那是不可重试的失败。</li>
* <li>目录里不得出现没有适配器的 media_kind:播种一个无人能执行的模型,
* 客户端会渲染出一个点了没反应的入口。</li>
* </ul>
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
@TestPropertySource(properties = "patbond.creation.worker.enabled=false")
class CreationCatalogSeedTest {
/**
* 与 patbond-ai 的 {@code app/providers/volcengine/video.py} 的
* {@code PROVIDER_CODE} 同值。**改任一侧必须同时改另一侧。**
*/
private static final String EXPECTED_PROVIDER_CODE = "volcengine-seedance";
/** 账号实际开通的两个模型 ID。 */
private static final List<String> ACTIVATED_MODEL_IDS = List.of(
"doubao-seedance-2-5-260628",
"doubao-seedance-2-0-260128");
@Autowired
private JdbcClient jdbcClient;
@Autowired
private CatalogRepository catalog;
@Test
void v7SeedsExactlyTheActivatedVideoModels() {
List<String> seeded = jdbcClient.sql("""
SELECT provider_model_name FROM creation.generation_models
WHERE media_kind = 'video' ORDER BY sort_order
""")
.query(String.class)
.list();
assertThat(seeded).containsExactlyElementsOf(ACTIVATED_MODEL_IDS);
}
@Test
void everySeededModelPointsAtTheProviderCodeTheAdapterRegistersUnder() {
List<String> codes = jdbcClient.sql(
"SELECT DISTINCT provider_code FROM creation.generation_models")
.query(String.class)
.list();
assertThat(codes).containsExactly(EXPECTED_PROVIDER_CODE);
}
@Test
void seededModelsAreEnabledAndResolvableThroughTheCatalogApi() {
// 经真实查询路径取,而不是直接读表——顺带覆盖 enabled 过滤与排序索引。
List<CatalogRepository.ModelRow> models = catalog.listEnabledModels(MediaKind.VIDEO);
assertThat(models).hasSize(2);
assertThat(models).extracting(CatalogRepository.ModelRow::providerCode)
.containsOnly(EXPECTED_PROVIDER_CODE);
// sort_order 决定客户端的展示顺序:2.5 在前。
assertThat(models.get(0).code()).isEqualTo("seedance-2-5");
}
@Test
void onlyVideoIsAvailableUntilAnImageAdapterLands() {
// 播种一个没有适配器的 media_kind 会让客户端渲染出一个点了没反应的入口。
// 图片模型待 gpt-image-2 适配层落地后由后续迁移追加。
assertThat(catalog.availableMediaKinds()).containsExactly("video");
}
@Test
void noStylesAreSeededAndThatDoesNotBlockJobCreation() {
// 风格是产品内容决策,无法从服务商文档推导;style_id 可空,故空表不阻塞。
Integer styles = jdbcClient.sql(
"SELECT COUNT(*) FROM creation.generation_styles")
.query(Integer.class)
.single();
assertThat(styles).isZero();
}
@Test
void modelIdsCarryTheirVersionSoTheVersionSnapshotIsMeaningful() {
// 目录没有独立版本列,CatalogRepository 以 provider_model_name 兼作版本
// 快照。这只在模型 ID 自带版本时成立——两个 ID 的尾部即日期版本。
List<CatalogRepository.ModelRow> models = catalog.listEnabledModels(MediaKind.VIDEO);
assertThat(models).allSatisfy(model ->
assertThat(model.modelVersion()).isEqualTo(model.providerModelName()));
assertThat(models).extracting(CatalogRepository.ModelRow::providerModelName)
.allMatch(name -> name.matches(".*-\\d{6}$"),
"模型 ID 尾部应为 6 位日期版本");
}
}
@@ -0,0 +1,292 @@
package com.patbond.patbond.creation.repository;
import com.patbond.patbond.creation.TestcontainersConfiguration;
import com.patbond.patbond.creation.domain.GenerationJob;
import com.patbond.patbond.creation.domain.JobStatus;
import com.patbond.patbond.creation.domain.MediaKind;
import com.patbond.patbond.creation.support.UuidV7;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.test.context.TestPropertySource;
import java.time.Duration;
import java.util.List;
import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The lease queue's actual semantics against a real postgres — the one part of
* the M4 skeleton that carries real logic rather than wiring.
*
* <p>Every test here pins behaviour that would still "work" if written wrongly
* and only show up as lost jobs or double vendor charges in production:
* SKIP LOCKED not handing the same row to two workers, a poll renewing the lease
* it depends on, the reaper preserving {@code provider_request_id}, and
* {@code first_attempt_at} surviving requeue.
*
* <p>The worker itself is disabled: these tests drive the repository directly so
* a scheduled tick cannot race the assertions.
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
@TestPropertySource(properties = "patbond.creation.worker.enabled=false")
class GenerationJobQueueIntegrationTest {
@Autowired
private GenerationJobRepository repository;
@Autowired
private JdbcClient jdbcClient;
private static final Duration LEASE = Duration.ofSeconds(60);
@Test
void claimMovesJobToRunningUnderLeaseAndStampsFirstAttempt() {
UUID userId = insertUser("claim");
UUID modelId = insertModel("claim-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
List<GenerationJob> claimed = repository.claimQueued("worker-a", LEASE, 10);
assertThat(claimed).extracting(GenerationJob::id).contains(jobId);
GenerationJob job = claimed.stream().filter(j -> j.id().equals(jobId)).findFirst().orElseThrow();
assertThat(job.status()).isEqualTo(JobStatus.RUNNING);
assertThat(job.attemptCount()).isEqualTo((short) 1);
assertThat(job.firstAttemptAt()).isNotNull();
assertThat(job.startedAt()).isNotNull();
assertThat(job.leaseExpiresAt()).isNotNull();
}
@Test
void twoWorkersNeverClaimTheSameJob() {
UUID userId = insertUser("skiplocked");
UUID modelId = insertModel("skiplocked-model", MediaKind.IMAGE);
for (int i = 0; i < 6; i++) {
insertQueued(userId, modelId, MediaKind.IMAGE);
}
List<GenerationJob> first = repository.claimQueued("worker-a", LEASE, 3);
List<GenerationJob> second = repository.claimQueued("worker-b", LEASE, 3);
assertThat(first).hasSize(3);
assertThat(second).hasSize(3);
// The point of SKIP LOCKED: disjoint claims, no waiting, no duplicates.
assertThat(first).extracting(GenerationJob::id)
.doesNotContainAnyElementsOf(second.stream().map(GenerationJob::id).toList());
}
@Test
void claimSkipsJobsWhoseBackoffHasNotElapsed() {
UUID userId = insertUser("backoff");
UUID modelId = insertModel("backoff-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
jdbcClient.sql("UPDATE creation.generation_jobs "
+ "SET next_attempt_at = now() + interval '1 hour' WHERE id = :id")
.param("id", jobId)
.update();
assertThat(repository.claimQueued("worker-a", LEASE, 10))
.extracting(GenerationJob::id)
.doesNotContain(jobId);
}
@Test
void pollClaimRenewsTheLeaseItDependsOn() {
UUID userId = insertUser("renew");
UUID modelId = insertModel("renew-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
repository.claimQueued("worker-a", Duration.ofSeconds(5), 10);
// Due when the lease is within one poll interval of expiring.
List<GenerationJob> due = repository.claimRunningDueForPoll(
"worker-a", LEASE, Duration.ofSeconds(30), 10);
assertThat(due).extracting(GenerationJob::id).contains(jobId);
// Renewed to the longer TTL: polling without renewing would let a long
// upstream job be reclaimed mid-flight, which is how a provider gets
// paid twice.
long secondsLeft = jdbcClient.sql(
"SELECT EXTRACT(EPOCH FROM (lease_expires_at - now()))::bigint "
+ "FROM creation.generation_jobs WHERE id = :id")
.param("id", jobId)
.query(Long.class)
.single();
assertThat(secondsLeft).isGreaterThan(30L);
}
@Test
void pollClaimIgnoresJobsHeldByAnotherWorker() {
UUID userId = insertUser("otherowner");
UUID modelId = insertModel("otherowner-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
repository.claimQueued("worker-a", Duration.ofSeconds(5), 10);
assertThat(repository.claimRunningDueForPoll("worker-b", LEASE, Duration.ofSeconds(30), 10))
.extracting(GenerationJob::id)
.doesNotContain(jobId);
}
@Test
void reaperRequeuesExpiredLeaseButKeepsProviderRequestId() {
UUID userId = insertUser("reaper");
UUID modelId = insertModel("reaper-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
repository.claimQueued("worker-a", LEASE, 10);
// Unique per test on purpose: uq_generation_jobs_provider_request is a
// TABLE-WIDE partial unique index on (provider, request id), so reusing
// a literal across tests collides — which is the index doing its job.
String providerRequestId = "vendor-req-" + UUID.randomUUID();
repository.recordProviderRequest(jobId, providerRequestId);
// Simulate the worker dying: nobody renews, the lease lapses. The whole
// timeline has to move back, not just the lease — ck_generation_jobs_lease
// requires lease_expires_at > started_at and
// ck_generation_jobs_first_attempt requires first_attempt_at <= started_at,
// so an "expired lease on a job that started a moment ago" is a state the
// database rightly refuses to store. Simulating a plausible history rather
// than an impossible one is the point.
jdbcClient.sql("""
UPDATE creation.generation_jobs
SET first_attempt_at = now() - interval '2 minutes',
started_at = now() - interval '2 minutes',
lease_expires_at = now() - interval '1 second'
WHERE id = :id
""")
.param("id", jobId)
.update();
assertThat(repository.reapExpiredLeases(10)).isEqualTo(1);
GenerationJob job = repository.findByIdForUser(jobId, userId).orElseThrow();
assertThat(job.status()).isEqualTo(JobStatus.QUEUED);
assertThat(job.startedAt()).isNull();
// Preserved deliberately: it is how the next attempt learns the work was
// already submitted upstream and polls instead of re-submitting.
// Clearing it here would turn every worker crash into a double charge.
assertThat(job.providerRequestId()).isEqualTo(providerRequestId);
// And the reason the added column exists: the original attempt time
// survives, so queue wait stays separable from execution time.
assertThat(job.firstAttemptAt()).isNotNull();
assertThat(job.attemptCount()).isEqualTo((short) 1);
}
@Test
void secondClaimAfterRequeuePreservesFirstAttemptAndIncrementsCount() {
UUID userId = insertUser("secondclaim");
UUID modelId = insertModel("secondclaim-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
repository.claimQueued("worker-a", LEASE, 10);
var firstAttemptAt = repository.findByIdForUser(jobId, userId).orElseThrow().firstAttemptAt();
repository.requeueWithBackoff(jobId, Duration.ZERO);
repository.claimQueued("worker-a", LEASE, 10);
GenerationJob job = repository.findByIdForUser(jobId, userId).orElseThrow();
assertThat(job.attemptCount()).isEqualTo((short) 2);
assertThat(job.firstAttemptAt()).isEqualTo(firstAttemptAt);
assertThat(job.startedAt()).isAfterOrEqualTo(firstAttemptAt);
}
@Test
void recordProviderRequestIsWriteOnce() {
UUID userId = insertUser("writeonce");
UUID modelId = insertModel("writeonce-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
repository.claimQueued("worker-a", LEASE, 10);
String firstId = "vendor-first-" + UUID.randomUUID();
String secondId = "vendor-second-" + UUID.randomUUID();
assertThat(repository.recordProviderRequest(jobId, firstId)).isEqualTo(1);
// A second attempt must not overwrite it — the stored id is the only
// handle on work the vendor may already have billed.
assertThat(repository.recordProviderRequest(jobId, secondId)).isEqualTo(0);
assertThat(repository.findByIdForUser(jobId, userId).orElseThrow().providerRequestId())
.isEqualTo(firstId);
}
@Test
void terminalTransitionsClearTheLease() {
UUID userId = insertUser("terminal");
UUID modelId = insertModel("terminal-model", MediaKind.IMAGE);
UUID failedId = insertQueued(userId, modelId, MediaKind.IMAGE);
repository.claimQueued("worker-a", LEASE, 10);
assertThat(repository.markFailed(failedId, "PROVIDER_TIMEOUT", "上游超时")).isEqualTo(1);
GenerationJob job = repository.findByIdForUser(failedId, userId).orElseThrow();
assertThat(job.status()).isEqualTo(JobStatus.FAILED);
assertThat(job.leaseExpiresAt()).isNull();
// A terminal job must never be claimable again.
assertThat(repository.claimQueued("worker-a", LEASE, 10))
.extracting(GenerationJob::id)
.doesNotContain(failedId);
}
@Test
void cancelIsGuardedByStatusSoAConcurrentFinishWins() {
UUID userId = insertUser("cancelrace");
UUID modelId = insertModel("cancelrace-model", MediaKind.IMAGE);
UUID jobId = insertQueued(userId, modelId, MediaKind.IMAGE);
repository.claimQueued("worker-a", LEASE, 10);
repository.markFailed(jobId, "PROVIDER_REJECTED", "拒绝");
// 0 rows, not an overwrite: the guard lives in the WHERE clause so a job
// that reached a terminal state first is not silently rewritten.
assertThat(repository.markCancelled(jobId, userId)).isEqualTo(0);
assertThat(repository.findByIdForUser(jobId, userId).orElseThrow().status())
.isEqualTo(JobStatus.FAILED);
}
@Test
void jobsAreScopedToTheirOwner() {
UUID owner = insertUser("owner");
UUID stranger = insertUser("stranger");
UUID modelId = insertModel("scope-model", MediaKind.IMAGE);
UUID jobId = insertQueued(owner, modelId, MediaKind.IMAGE);
assertThat(repository.findByIdForUser(jobId, stranger)).isEmpty();
assertThat(repository.findByIdForUser(jobId, owner)).isPresent();
}
// --- helpers ---
private UUID insertUser(String name) {
UUID id = UuidV7.generate();
jdbcClient.sql("INSERT INTO identity.users (id, username) VALUES (:id, :username)")
.param("id", id)
.param("username", name + "-" + id.toString().substring(0, 8))
.update();
return id;
}
private UUID insertModel(String code, MediaKind kind) {
UUID id = UuidV7.generate();
jdbcClient.sql("""
INSERT INTO creation.generation_models
(id, code, display_name, provider_code, provider_model_name, media_kind)
VALUES (:id, :code, :code, 'fixture', 'fixture-model', :kind)
""")
.param("id", id)
.param("code", code + "-" + id.toString().substring(0, 8))
.param("kind", kind.dbValue())
.update();
return id;
}
private UUID insertQueued(UUID userId, UUID modelId, MediaKind kind) {
UUID id = UuidV7.generate();
return repository.insertQueued(id, userId, null, kind, modelId, null, null,
"a prompt", null, null, null, null,
"fixture", "fixture-model", "v1", null,
"key-" + id, sha256Placeholder());
}
/** request_hash is constrained to exactly 32 bytes by ck_generation_jobs_idempotency. */
private static byte[] sha256Placeholder() {
byte[] hash = new byte[32];
new java.security.SecureRandom().nextBytes(hash);
return hash;
}
}
@@ -0,0 +1,263 @@
/*
V6 baseline: creation schema (M4 AI 创作), extracted from
patbond-doc/docs/database/patbond_postgresql.sql (reviewed target model,
lines 60/68 schema + 556-717 objects) per iteration-4 T4-01.
Scope notes:
- All 3 creation objects are created here: generation_models,
generation_styles, generation_jobs. Both media kinds ('image', 'video')
are structurally supported from the start, as modeled — the target model
keys the catalog by (id, media_kind) composite unique so a job can only
reference a model/style of its own kind. Video feature rollout is a
contract/client concern, not a schema concern (see ADR-018 supersede).
- Zero cross-schema FKs need stripping, unlike V3 and V5: every target
schema already exists — identity (V1), media (V1), pet_health (V3).
This is the first baseline migration that lands its FKs complete.
- V5's header registered two deferred constraints. This migration settles
the first one, as instructed there ("M4 补回"):
* community.posts.generation_job_id -> creation.generation_jobs(id)
ON DELETE SET NULL is re-added at the end of this file. The constraint
is named explicitly as posts_generation_job_id_fkey so that a database
built by V1..V6 is name-identical to one built from the target model
(where the FK is declared inline and auto-named by PostgreSQL).
* community.posts.region_id -> platform.regions(id) stays STRIPPED;
the region system belongs to M5 (ADR-018 scope cut) — M5 补回.
- Primary keys keep DEFAULT gen_random_uuid() as modeled (V1/V3/V5 同规);
application code supplies explicit UUIDv7 ids, the default is a fallback.
- updated_at triggers reuse platform.set_updated_at() (function from V1).
Two deliberate deviations from the target model, both decided in iteration-4
and recorded here so the divergence is never silent:
1. generation_jobs.input_asset_id is NULLABLE (target model: NOT NULL).
Decision A2: the reference image is optional — mainstream image and video
providers support both text-to-* and reference-guided generation, so the
presence of an input asset is what selects between them at request time.
The target model's NOT NULL would have made text-to-image unrepresentable.
Whether a specific model *requires* an input is deliberately NOT encoded
here (no requires_input flag invented beyond the reviewed model); it is
enforced by the application against the catalog.
2. generation_jobs.first_attempt_at is ADDED (absent from the target model).
Required by iteration-4/06 §4.2: queue wait and pure execution time can
only be separated if the first attempt's start survives retries. The
target model cannot express this — its ck_generation_jobs_state forces
started_at IS NULL on the 'queued' branch, so re-queueing a failed job
necessarily erases the original start. first_attempt_at is therefore
written once (on the first transition into 'running') and never
overwritten, and is deliberately EXCLUDED from the 'queued' branch's
null-out requirements. ck_generation_jobs_first_attempt pins the
invariant against attempt_count.
*/
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),
-- (id, media_kind) is redundant given the PK, but is the target of the
-- composite FK from generation_jobs: it is what makes "a job's model must
-- be of the job's own media_kind" enforceable by the database.
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,
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;
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,
-- DEVIATION 1 (see header): nullable, not NOT NULL. NULL = text-to-*,
-- present = reference-guided generation.
input_asset_id uuid 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,
-- Snapshots freeze what was actually asked of the provider: the catalog
-- may be re-pointed or disabled later, historical jobs must stay readable.
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,
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(),
-- DEVIATION 2 (see header): written once on the first transition into
-- 'running', never overwritten, survives re-queueing.
first_attempt_at timestamptz,
started_at timestamptz,
completed_at timestamptz,
version integer NOT NULL DEFAULT 0,
UNIQUE (user_id, idempotency_key),
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
),
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)
),
-- DEVIATION 2's invariant: first_attempt_at exists exactly when the job
-- has been attempted at least once, and never postdates the current
-- attempt's start. Deliberately absent from ck_generation_jobs_state's
-- 'queued' branch: a re-queued job keeps it.
CONSTRAINT ck_generation_jobs_first_attempt CHECK (
(attempt_count = 0) = (first_attempt_at IS NULL)
AND (
first_attempt_at IS NULL OR started_at IS NULL
OR first_attempt_at <= started_at
)
),
CONSTRAINT ck_generation_jobs_version CHECK (version >= 0)
);
-- Provider-side de-duplication: the pair (provider, provider_request_id) is
-- what makes "did I already submit this upstream?" answerable after a timeout
-- or a worker restart — i.e. the basis of not paying twice.
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;
CREATE INDEX ix_generation_jobs_user_created
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);
-- Column order matches the dequeue statement's ORDER BY exactly, so the
-- FOR UPDATE SKIP LOCKED claim is an index scan, not a sort.
CREATE INDEX ix_generation_jobs_queue
ON creation.generation_jobs (priority DESC, next_attempt_at, created_at, id)
WHERE status = 'queued';
-- Drives the lease reaper and the poll-due scan over in-flight jobs.
CREATE INDEX ix_generation_jobs_running
ON creation.generation_jobs (lease_expires_at, id)
WHERE status = 'running';
-- Automatic updated_at maintenance (function created in V1). Business version
-- increments remain explicit so optimistic locking stays visible in
-- repository update statements.
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's deferred constraint, settled per its header instruction ("M4 补回").
-- Named explicitly to match the auto-generated name of the target model's
-- inline declaration, so V1..V6 and the target model stay name-identical.
-- community.posts.region_id stays bare — that one is M5's to settle.
ALTER TABLE community.posts
ADD CONSTRAINT posts_generation_job_id_fkey
FOREIGN KEY (generation_job_id)
REFERENCES creation.generation_jobs(id) ON DELETE SET NULL;
@@ -0,0 +1,42 @@
/*
V7: creation catalog seed (iteration-4 T4-01b)。
目录是生产参照数据(任务表按复合外键指向它),故进版本化迁移链而非 db/dev。
与目标模型 patbond_postgresql.sql:1495 的种子**有意不同**:正典那份用的是
`provider_code = 'fixture'` 与占位模型名(`patbond-image-v1` / `patbond-video-v1'),
那是在服务商未定时写下的占位。现已接入真实服务商,故按实际开通的模型播种。
Scope notes:
- **只播种视频模型**。图片生成(gpt-image-2)尚未接入,待其适配层落地后由后续
迁移追加——播种一个没有适配器的模型会让任务卡在「找不到 provider」,而症状
(任务永远排队)离根因很远。
- **不播种风格(generation_styles)**。风格是产品内容决策(提供哪些艺术风格、
叫什么、配图是什么),无法从服务商文档推导;正典那 4 条风格均为 `image` 类且
其 `preview_asset_id` 指向 demo 资产,而本链路不含该 demo 种子,照抄会违反外键。
`generation_jobs.style_id` 可空,故空风格表不阻塞任何功能。
- `provider_code` 必须与 patbond-ai 适配器的 `PROVIDER_CODE` 逐字一致
(`volcengine-seedance`)。对不上时 Java 侧按任务行冻结的
`provider_code_snapshot` 查不到适配器,任务会永远停在排队——而这个症状离
「两处字符串不一致」这个根因很远,故在此显式提醒。
- id 使用显式固定 UUID 而非列默认值:种子行需要稳定标识以便后续迁移与测试引用。
取 `17000000-0000-7000-8000-0000000000 1x` 段,与正典占位行(…0001/…0002)
区分开,避免有人拿本链路与正典逐行对比时把两者混为一谈。
两个模型的能力差异**不进本表**:正典的 generation_models 没有能力列,本迁移
不发明超出已评审模型的列。差异(2.5 时长上限 30 秒但无 4k;2.0 有 4k 但时长
上限 15 秒;2.5 图生视频时 ratio 锁 adaptive)由 patbond-ai 的
`app/providers/volcengine/capabilities.py` 承载并在提交前本地预校验。
取证见 patbond-doc docs/development/providers/volcengine-seedance.md §5。
*/
INSERT INTO creation.generation_models
(id, code, display_name, provider_code, provider_model_name, media_kind, sort_order)
VALUES
-- 2.5:时长可到 30 秒、支持 mov 输出与全模态参考(图 0-30/视频 0-10/音频 0-10);
-- 1080p 为 10bit + H.265/HEVC,少数播放环境可能不兼容。列为首选。
('17000000-0000-7000-8000-000000000011', 'seedance-2-5', '豆包 Seedance 2.5',
'volcengine-seedance', 'doubao-seedance-2-5-260628', 'video', 10),
-- 2.0:时长上限 15 秒,但支持 4k(同为 10bit + H.265/HEVC)。
('17000000-0000-7000-8000-000000000012', 'seedance-2-0', '豆包 Seedance 2.0',
'volcengine-seedance', 'doubao-seedance-2-0-260128', 'video', 20);
@@ -12,10 +12,14 @@ import static org.assertj.core.api.Assertions.assertThat;
/**
* Flyway V5 (community schema baseline) applies cleanly on a postgres:18
* container after V1..V4 and produces the expected structure. Validates the
* mandatory cross-schema FK cuts (ADR-017/ADR-018, T3-01):
* generation_job_id (creation belongs to M4) and region_id (regions belong
* to the M5 region system) exist as bare nullable uuid columns without FKs,
* cross-schema FK cuts declared in V5's header (ADR-017/ADR-018, T3-01):
* both generation_job_id and region_id exist as bare nullable uuid columns,
* and the pg_trgm extension missing from V1 is now installed.
*
* <p>Note on the FK assertions: V5 stripped both FKs, but V6 settled the
* creation one ("M4 补回"). Since these tests run the full migration chain,
* generation_job_id is asserted to HAVE its FK and region_id to still lack
* one — see {@code CreationMigrationIntegrationTest} for the V6 side.
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
@@ -65,7 +69,7 @@ class CommunityMigrationIntegrationTest {
}
@Test
void v5PostsColumnsExistWithoutCreationAndRegionFKs() {
void v5PostsColumnsExistAndOnlyRegionFKStaysStripped() {
// generation_job_id and region_id columns exist (bare nullable uuid)
int colCount = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.columns " +
@@ -76,16 +80,29 @@ class CommunityMigrationIntegrationTest {
.single();
assertThat(colCount).isEqualTo(2);
// No FK constraints targeting creation.generation_jobs (M4) or
// platform.regions (M5 region system)
int fkCount = jdbcClient.sql(
// V6 settled the creation FK (V5 header: "M4 补回"), so after the full
// V1..V6 chain generation_job_id DOES carry its FK. Asserted per
// constraint so a future regression on either side is distinguishable
// rather than hidden in a combined count.
int generationFk = 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%')")
"AND constraint_name LIKE '%generation%'")
.query(Integer.class)
.single();
assertThat(fkCount).isEqualTo(0);
assertThat(generationFk).isEqualTo(1);
// region_id stays bare: the region system belongs to M5 (ADR-018) —
// M5 补回. This must remain 0 until that migration lands.
int regionFk = 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 '%region%'")
.query(Integer.class)
.single();
assertThat(regionFk).isEqualTo(0);
}
@Test
@@ -0,0 +1,313 @@
package com.patbond.patbond.user.persistence;
import com.patbond.patbond.user.TestcontainersConfiguration;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.dao.DataIntegrityViolationException;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.assertThatCode;
/**
* Flyway V6 (creation schema baseline) applies cleanly on a postgres:18
* container after V1..V5 and produces the expected structure.
*
* <p>Beyond structural checks this pins the two deliberate deviations from the
* reviewed target model, because both are the kind of thing a later "sync back
* to the target model" pass would silently revert:
* <ul>
* <li>{@code input_asset_id} is nullable (decision A2: text-to-* must be
* representable);</li>
* <li>{@code first_attempt_at} exists and survives re-queueing (iteration-4/06
* §4.2: queue wait and execution time must be separable).</li>
* </ul>
*
* <p>The retry-path test is the important one: the target model's state CHECK
* forces {@code started_at IS NULL} while queued, so without a dedicated
* column the first attempt's timestamp is destroyed on every retry.
*/
@SpringBootTest
@Import(TestcontainersConfiguration.class)
class CreationMigrationIntegrationTest {
@Autowired
private JdbcClient jdbcClient;
@Test
void v6CreatesCreationSchema() {
String exists = jdbcClient.sql(
"SELECT schema_name FROM information_schema.schemata WHERE schema_name = 'creation'")
.query(String.class)
.optional()
.orElse(null);
assertThat(exists).isEqualTo("creation");
}
@Test
void v6CreatesAllCreationTables() {
int count = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.tables " +
"WHERE table_schema = 'creation' AND table_type = 'BASE TABLE'")
.query(Integer.class)
.single();
// 3 tables: generation_models, generation_styles, generation_jobs
assertThat(count).isEqualTo(3);
}
@Test
void v6LandsEveryCrossSchemaFkWithNothingStripped() {
// Unlike V3 and V5, V6 needs no FK cuts: identity and media exist
// since V1, pet_health since V3. Counts the FKs out of creation.
int outbound = jdbcClient.sql(
"SELECT COUNT(*) FROM information_schema.table_constraints tc " +
"JOIN information_schema.constraint_column_usage ccu " +
" ON tc.constraint_name = ccu.constraint_name " +
" AND tc.constraint_schema = ccu.constraint_schema " +
"WHERE tc.table_schema = 'creation' " +
"AND tc.constraint_type = 'FOREIGN KEY' " +
"AND ccu.table_schema IN ('identity', 'media', 'pet_health')")
.query(Integer.class)
.single();
// identity.users (jobs.user_id), pet_health.pets (jobs.pet_id),
// media.assets x3 (styles.preview_asset_id, jobs.input/output_asset_id)
assertThat(outbound).isEqualTo(5);
}
@Test
void v6ReAddsPostsGenerationJobFkUnderTheTargetModelName() {
// V5 header instruction "M4 补回", and the explicit constraint name
// keeps a V1..V6 database name-identical to a target-model build.
int named = 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 = 'posts_generation_job_id_fkey'")
.query(Integer.class)
.single();
assertThat(named).isEqualTo(1);
}
@Test
void v6CompositeFkForcesModelToMatchJobMediaKind() {
// A video job must not reference an image model. Enforced by the
// (id, media_kind) composite FK, not by application code.
String userId = insertUser("kindguard@example.com");
String imageModelId = insertModel("kg-image-model", "image");
assertThatThrownBy(() -> insertJob(userId, imageModelId, "video", null))
.isInstanceOf(DataIntegrityViolationException.class);
assertThatCode(() -> insertJob(userId, imageModelId, "image", null))
.doesNotThrowAnyException();
}
// --- deviation 1: input_asset_id nullable (text-to-*) ---
@Test
void v6AllowsJobWithoutInputAssetSoTextToImageIsRepresentable() {
String isNullable = jdbcClient.sql(
"SELECT is_nullable FROM information_schema.columns " +
"WHERE table_schema = 'creation' AND table_name = 'generation_jobs' " +
"AND column_name = 'input_asset_id'")
.query(String.class)
.single();
assertThat(isNullable).isEqualTo("YES");
String userId = insertUser("texttoimage@example.com");
String modelId = insertModel("t2i-model", "image");
assertThatCode(() -> insertJob(userId, modelId, "image", null))
.doesNotThrowAnyException();
}
// --- deviation 2: first_attempt_at survives retries ---
@Test
void v6FirstAttemptAtSurvivesRequeueWhileStartedAtIsCleared() {
String userId = insertUser("retrypath@example.com");
String modelId = insertModel("retry-model", "image");
String jobId = insertJob(userId, modelId, "image", null);
// queued -> running (first attempt): both timestamps set together
jdbcClient.sql(
"UPDATE creation.generation_jobs SET status = 'running', attempt_count = 1, " +
"first_attempt_at = now(), started_at = now(), " +
"lease_owner = 'w1', lease_expires_at = now() + interval '60 seconds' " +
"WHERE id = ?::uuid")
.param(jobId)
.update();
// running -> failed
jdbcClient.sql(
"UPDATE creation.generation_jobs SET status = 'failed', completed_at = now(), " +
"error_code = 'PROVIDER_TIMEOUT', lease_owner = NULL, lease_expires_at = NULL " +
"WHERE id = ?::uuid")
.param(jobId)
.update();
// failed -> queued (retry): the state CHECK forces started_at back to
// NULL. first_attempt_at must NOT be cleared — that is the whole point
// of the added column.
jdbcClient.sql(
"UPDATE creation.generation_jobs SET status = 'queued', progress = 0, " +
"started_at = NULL, completed_at = NULL, error_code = NULL, " +
"next_attempt_at = now() + interval '30 seconds' " +
"WHERE id = ?::uuid")
.param(jobId)
.update();
Boolean firstAttemptRetained = jdbcClient.sql(
"SELECT first_attempt_at IS NOT NULL AND started_at IS NULL " +
"FROM creation.generation_jobs WHERE id = ?::uuid")
.param(jobId)
.query(Boolean.class)
.single();
assertThat(firstAttemptRetained).isTrue();
}
@Test
void v6RejectsFirstAttemptAtInconsistentWithAttemptCount() {
String userId = insertUser("invariant@example.com");
String modelId = insertModel("invariant-model", "image");
String jobId = insertJob(userId, modelId, "image", null);
// attempt_count > 0 without first_attempt_at is exactly the state the
// target model would have left behind after a retry.
assertThatThrownBy(() -> jdbcClient.sql(
"UPDATE creation.generation_jobs SET attempt_count = 1 WHERE id = ?::uuid")
.param(jobId)
.update())
.isInstanceOf(DataIntegrityViolationException.class);
// ...and the mirror case: a timestamp with no attempt behind it.
assertThatThrownBy(() -> jdbcClient.sql(
"UPDATE creation.generation_jobs SET first_attempt_at = now() WHERE id = ?::uuid")
.param(jobId)
.update())
.isInstanceOf(DataIntegrityViolationException.class);
}
// --- queue mechanics the worker depends on ---
@Test
void v6CreatesPartialIndexesBackingTheLeaseQueue() {
int queue = indexCount("ix_generation_jobs_queue");
int running = indexCount("ix_generation_jobs_running");
assertThat(queue).isEqualTo(1);
assertThat(running).isEqualTo(1);
// The dequeue index must be partial on 'queued', otherwise the
// SKIP LOCKED claim scans terminal rows forever as history grows.
String queueDef = jdbcClient.sql(
"SELECT indexdef FROM pg_indexes WHERE schemaname = 'creation' " +
"AND indexname = 'ix_generation_jobs_queue'")
.query(String.class)
.single();
assertThat(queueDef).contains("WHERE").contains("queued");
}
@Test
void v6EnforcesProviderRequestUniquenessOnlyWhenPresent() {
// Two un-submitted jobs (NULL provider_request_id) must coexist; the
// partial unique index is what makes "don't pay twice" enforceable
// without blocking the pre-submit state.
String userId = insertUser("providerdedup@example.com");
String modelId = insertModel("dedup-model", "image");
assertThatCode(() -> {
insertJob(userId, modelId, "image", null);
insertJob(userId, modelId, "image", null);
}).doesNotThrowAnyException();
String firstId = insertJob(userId, modelId, "image", null);
String secondId = insertJob(userId, modelId, "image", null);
jdbcClient.sql("UPDATE creation.generation_jobs SET provider_code_snapshot = 'p1', " +
"provider_request_id = 'req-1' WHERE id = ?::uuid")
.param(firstId)
.update();
assertThatThrownBy(() -> jdbcClient.sql(
"UPDATE creation.generation_jobs SET provider_code_snapshot = 'p1', " +
"provider_request_id = 'req-1' WHERE id = ?::uuid")
.param(secondId)
.update())
.isInstanceOf(DataIntegrityViolationException.class);
}
@Test
void v6EnforcesPerUserIdempotencyKey() {
String userId = insertUser("idem@example.com");
String modelId = insertModel("idem-model", "image");
insertJob(userId, modelId, "image", "shared-key");
assertThatThrownBy(() -> insertJob(userId, modelId, "image", "shared-key"))
.isInstanceOf(DataIntegrityViolationException.class);
// Same key under a different user is fine — the constraint is scoped.
String otherUser = insertUser("idem-other@example.com");
assertThatCode(() -> insertJob(otherUser, modelId, "image", "shared-key"))
.doesNotThrowAnyException();
}
@Test
void v6MaintainsUpdatedAtByTrigger() {
String modelId = insertModel("touch-model", "image");
jdbcClient.sql("UPDATE creation.generation_models SET sort_order = 7 WHERE id = ?::uuid")
.param(modelId)
.update();
Boolean touched = jdbcClient.sql(
"SELECT updated_at > created_at FROM creation.generation_models WHERE id = ?::uuid")
.param(modelId)
.query(Boolean.class)
.single();
assertThat(touched).isTrue();
}
// --- helpers ---
private int indexCount(String indexName) {
return jdbcClient.sql(
"SELECT COUNT(*) FROM pg_indexes WHERE schemaname = 'creation' AND indexname = ?")
.param(indexName)
.query(Integer.class)
.single();
}
private String insertUser(String email) {
// identity.users holds no credential columns (V1: those live outside
// this table); username is the only required field and is constrained
// to 3..32 trimmed characters.
return jdbcClient.sql(
"INSERT INTO identity.users (id, username, email) " +
"VALUES (gen_random_uuid(), ?, ?) RETURNING id::text")
.params(email.substring(0, email.indexOf('@')), email)
.query(String.class)
.single();
}
private String insertModel(String code, String mediaKind) {
return jdbcClient.sql(
"INSERT INTO creation.generation_models " +
"(id, code, display_name, provider_code, provider_model_name, media_kind) " +
"VALUES (gen_random_uuid(), ?, ?, 'fixture', 'fixture-model', ?) " +
"RETURNING id::text")
.params(code, code, mediaKind)
.query(String.class)
.single();
}
private String insertJob(String userId, String modelId, String mediaKind, String idempotencyKey) {
String key = idempotencyKey != null ? idempotencyKey : "key-" + java.util.UUID.randomUUID();
return jdbcClient.sql(
"INSERT INTO creation.generation_jobs " +
"(id, user_id, media_kind, model_id, provider_code_snapshot, " +
" provider_model_snapshot, model_version_snapshot, idempotency_key, request_hash) " +
"VALUES (gen_random_uuid(), ?::uuid, ?, ?::uuid, 'fixture', 'fixture-model', " +
" 'v1', ?, decode(repeat('ab', 32), 'hex')) RETURNING id::text")
.params(userId, mediaKind, modelId, key)
.query(String.class)
.single();
}
}
+1
View File
@@ -18,6 +18,7 @@
<module>patbond-auth</module>
<module>patbond-pet</module>
<module>patbond-community</module>
<module>patbond-creation</module>
</modules>
<properties>
+19 -4
View File
@@ -2,7 +2,8 @@
# check-secrets.sh —— 凭证防泄漏检查(ADR-021,规范见 patbond-doc docs/development/git-workflow.md)
#
# 规则单一来源:本地 pre-commit 与 CI 兜底跑的是同一个脚本、同一张规则表。
# 三仓(patbond-api / patbond-flutter / patbond-doc)各存一份同构副本,改规则时三仓同步。
# 四仓(patbond-api / patbond-flutter / patbond-doc / patbond-ai)各存一份同构副本,
# 改规则时四仓同步提交。patbond-ai 是唯一持有 AI 服务商凭证的仓,尤其不可漏。
#
# 用法:
# sh scripts/check-secrets.sh --staged # pre-commit:扫暂存区内容(经 scripts/hooks/pre-commit 调用)
@@ -15,7 +16,18 @@ set -u
mode="${1:---all}"
# 允许清单:行内出现任一形态即放行(${} 注入、占位值、明显示例值)
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*'
# 注:x{3,}(xxx / xxxx…)是本项目文档描述凭证形态时的既有占位约定,
# 放行它使「讲解规则的文档」不会被规则自身拦下(如 iteration-4/08 §漏检清单)。
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*|x{3,}'
# 依赖注入的传参不是字面量密钥:`api_key=settings.api_key`、
# `.param("secretKey", props.getSecretKey())` 这类行里没有任何秘密,而做 DI 的
# 代码到处都是这种写法——不放行就得在每个调用点绕过一次。
# 收得很紧,避免把真凭证放进来:
# * 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状)
# * 只允许**恰好一个点**且随即行尾(JWT 是 a.b.c 三段,仍会被拦)
# * 连字符不在字符集内(sk-ant-… 这类前缀仍会被拦)
ALLOW_REFERENCE='[:=][[:space:]]*[a-z][a-zA-Z0-9_]*\.[a-z][a-zA-Z0-9_]*[[:space:]]*[,);]?$'
# 内容扫描跳过:本脚本与 hook 自身(含规则文本,非凭证)
SKIP_PATHS='(^|/)scripts/(check-secrets\.sh|hooks/pre-commit)$'
@@ -29,9 +41,11 @@ RULES=$(cat <<'EOF'
AK-AWS - - AKIA[0-9A-Z]{16}
AK-QCLOUD - - AKID[0-9A-Za-z]{16,}
AK-ALIYUN - - LTAI[0-9A-Za-z]{12,}
AK-ANTHROPIC - - sk-ant-[A-Za-z0-9_-]{16,}
AK-OPENAI - - sk-(proj-|svcacct-)?[A-Za-z0-9]{20,}
MINIO-DEFAULT i - minio[-_.]?admin
PRIVATE-KEY - - ^[[:space:]]*-----BEGIN [A-Z ]*PRIVATE KEY-----[[:space:]]*$
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key|api[-_]?(key|secret|token)|auth[-_]?token)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
JWT-SECRET i - (jwt[-_.]?secret|signing[-_]?key|token[-_]?secret|hmac[-_]?(key|secret))["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
DB-PASSWORD i \.(ya?ml|properties|toml|conf|ini)(\.sample|\.example)?$ (password|passwd|pwd)["']?[[:space:]]*[:=][[:space:]]*["']?[^[:space:]"'$]{6,}
EOF
@@ -85,6 +99,7 @@ for f in $files; do
ci=""
[ "$flag" = "i" ] && ci="-i"
grep -InE $ci -e "$pat" "$tmp" 2>/dev/null | grep -viE "$ALLOW" |
grep -vE "$ALLOW_REFERENCE" |
sed "s|^|[$id] $f:|" >>"$viol" || true
done
done
@@ -96,7 +111,7 @@ if [ -s "$viol" ]; then
处置:
1. 若是真实凭证:先去云控制台轮换/禁用该密钥,再从提交中移除;
2. 若是误报:改用 ${} 注入或占位值(changeme / your-xxx / <占位>),
或与团队确认后调整三仓同构的 scripts/check-secrets.sh 规则表。
或与团队确认后调整四仓同构的 scripts/check-secrets.sh 规则表。
敏感信息只允许存在于被 gitignore 的文件或 .sample 占位中(git-workflow.md)。
MSG
exit 1