diff --git a/Readme.md b/Readme.md
index 458a69d..d33a55e 100644
--- a/Readme.md
+++ b/Readme.md
@@ -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`。
diff --git a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java
index bfd4f42..ae38982 100644
--- a/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java
+++ b/patbond-common/src/main/java/com/patbond/patbond/common/error/ErrorCode.java
@@ -28,11 +28,16 @@ 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 码随配额工单落地时补入——此处只加骨架实际使用的码,避免死枚举。
+ 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, "登录失败次数过多,账号已临时锁定"),
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
diff --git a/patbond-creation/Dockerfile b/patbond-creation/Dockerfile
new file mode 100644
index 0000000..0a6fc6f
--- /dev/null
+++ b/patbond-creation/Dockerfile
@@ -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"]
diff --git a/patbond-creation/pom.xml b/patbond-creation/pom.xml
new file mode 100644
index 0000000..e2a3751
--- /dev/null
+++ b/patbond-creation/pom.xml
@@ -0,0 +1,148 @@
+
+
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. + * + *
{@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 +@EnableScheduling +public class CreationApplication { + + public static void main(String[] args) { + SpringApplication.run(CreationApplication.class, args); + } +} diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/config/CreationProperties.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/CreationProperties.java new file mode 100644 index 0000000..e04ec67 --- /dev/null +++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/CreationProperties.java @@ -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. + * + *
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; + } + } +} diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/config/CreationSecurityProperties.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/CreationSecurityProperties.java new file mode 100644 index 0000000..4606dc8 --- /dev/null +++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/CreationSecurityProperties.java @@ -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. + * + *
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; + } + } +} diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/config/JacksonConfig.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/JacksonConfig.java new file mode 100644 index 0000000..35f9919 --- /dev/null +++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/JacksonConfig.java @@ -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); + } +} diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/config/ProviderRegistry.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/ProviderRegistry.java new file mode 100644 index 0000000..cd39c4b --- /dev/null +++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/config/ProviderRegistry.java @@ -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. + * + *
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 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 {@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 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 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 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);
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/domain/MediaKind.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/domain/MediaKind.java
new file mode 100644
index 0000000..68ec01a
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/domain/MediaKind.java
@@ -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).
+ *
+ * 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);
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/dto/CreateGenerationJobRequest.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/dto/CreateGenerationJobRequest.java
new file mode 100644
index 0000000..aceaf83
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/dto/CreateGenerationJobRequest.java
@@ -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.
+ *
+ * {@code inputAssetId} is optional on purpose. 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).
+ *
+ * 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.
+ *
+ * {@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();
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/dto/GenerationJobResponse.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/dto/GenerationJobResponse.java
new file mode 100644
index 0000000..826d6b6
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/dto/GenerationJobResponse.java
@@ -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.
+ *
+ * Two fields deserve explanation because they encode client-facing decisions
+ * from ADR-023 §10 rather than raw columns:
+ *
+ * {@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());
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/AiServiceGenerationProvider.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/AiServiceGenerationProvider.java
new file mode 100644
index 0000000..bf87715
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/AiServiceGenerationProvider.java
@@ -0,0 +1,65 @@
+package com.patbond.patbond.creation.provider;
+
+import com.patbond.patbond.creation.domain.GenerationJob;
+
+/**
+ * Talks to patbond-ai (Python) over REST for real vendor generation —
+ * 火山引擎 Seedance for video, gpt-image-2 for images (ADR-023 §1).
+ *
+ * Deliberately unimplemented at skeleton stage. Five facts must come out of
+ * the vendors' official documentation before the request/response shape can be
+ * fixed, and guessing any of them would produce an adapter that compiles and
+ * silently misbehaves:
+ * Note what is NOT blocked by the above: the transport, auth and error
+ * envelope are already settled and independent of vendor specifics — REST/JSON
+ * (not gRPC), a service-identity JWT signed with a separate key pair, and a
+ * request body that carries {@code jobId}, generation parameters and the
+ * presigned upload target but deliberately NOT {@code userId} (ADR-023 §5 data
+ * minimisation).
+ */
+public class AiServiceGenerationProvider implements GenerationProvider {
+
+ /**
+ * Placeholder code. The real value comes from
+ * {@code generation_models.provider_code} in the V7 catalog seed, which is
+ * itself waiting on the same vendor documentation.
+ */
+ public static final String PROVIDER_CODE = "patbond-ai";
+
+ @Override
+ public String providerCode() {
+ return PROVIDER_CODE;
+ }
+
+ @Override
+ public SubmitResult submit(GenerationJob job, String uploadTarget, String idempotencyKey) {
+ throw new UnsupportedOperationException(
+ "AiServiceGenerationProvider.submit 未实现:待 Seedance 与 gpt-image-2 官方文档"
+ + "确认五项(幂等键支持、查询接口与频率限制、单任务最长时长、产物返回形式、"
+ + "限流与配额响应形态)后落地。");
+ }
+
+ @Override
+ public PollResult poll(GenerationJob job, String providerRequestId) {
+ throw new UnsupportedOperationException(
+ "AiServiceGenerationProvider.poll 未实现(同 submit)。");
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/FixtureGenerationProvider.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/FixtureGenerationProvider.java
new file mode 100644
index 0000000..5e9a280
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/FixtureGenerationProvider.java
@@ -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.
+ *
+ * This is not a placeholder to be deleted — it is a deliberate M4
+ * asset. 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'}.
+ *
+ * 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, String idempotencyKey) {
+ 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)。");
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/GenerationProvider.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/GenerationProvider.java
new file mode 100644
index 0000000..fbd5c43
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/provider/GenerationProvider.java
@@ -0,0 +1,108 @@
+package com.patbond.patbond.creation.provider;
+
+import com.patbond.patbond.creation.domain.GenerationJob;
+
+/**
+ * The upstream generation boundary. One implementation talks to patbond-ai
+ * over REST; a fixture implementation stands in while provider credentials are
+ * not yet available (ADR-023 §1/§4).
+ *
+ * Two phases, always — even for synchronous providers. Some vendors
+ * are asynchronous (submit + query, e.g. video), others complete inside one
+ * long-lived HTTP call (e.g. image). 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 by running the long
+ * call as a background task.
+ *
+ * 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. The partial
+ * unique index {@code uq_generation_jobs_provider_request} exists to enforce
+ * exactly that de-duplication.
+ */
+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.
+ *
+ * @param job the claimed job, already RUNNING under this worker's lease
+ * @param uploadTarget where the artifact is to be PUT once produced —
+ * a presigned URL obtained from patbond-user's internal media API.
+ * Passing the target in at submit time is what keeps object-storage
+ * credentials out of this service and out of patbond-ai entirely
+ * (ADR-023 §4).
+ * @param idempotencyKey the job's own key, to be forwarded upstream as the
+ * vendor's idempotency header where the vendor supports one. Where it
+ * does not, the double-charge window is bounded only by
+ * {@code max_attempts} — a residual risk ADR-023 §4 records rather
+ * than pretends to have solved.
+ */
+ SubmitResult submit(GenerationJob job, String uploadTarget, String idempotencyKey);
+
+ /**
+ * 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) {
+ }
+
+ /**
+ * Outcome of a poll.
+ *
+ * @param state where the upstream job is now
+ * @param progress 0..100 when known; the client contract deliberately does
+ * not promise a percentage (ADR-023 §10), so 0 while queued is normal
+ * rather than a bug
+ * @param objectKey set only on {@link State#SUCCEEDED} — the key the
+ * artifact was uploaded under, which this service turns into a
+ * {@code media.assets} row
+ * @param errorCode set only on {@link State#FAILED}
+ * @param retryable whether a FAILED outcome is worth another attempt; a
+ * rejected prompt is not, a vendor 5xx is
+ */
+ record PollResult(State state, int progress, String objectKey,
+ String errorCode, String errorMessage, boolean retryable) {
+
+ public enum State {
+ /** Accepted upstream, not started. */
+ PENDING,
+ /** Upstream is working on it. */
+ RUNNING,
+ SUCCEEDED,
+ FAILED
+ }
+
+ public static PollResult pending(int progress) {
+ return new PollResult(State.PENDING, progress, null, null, null, false);
+ }
+
+ public static PollResult running(int progress) {
+ return new PollResult(State.RUNNING, progress, null, null, null, false);
+ }
+
+ public static PollResult succeeded(String objectKey) {
+ return new PollResult(State.SUCCEEDED, 100, objectKey, null, null, false);
+ }
+
+ public static PollResult failed(String errorCode, String errorMessage, boolean retryable) {
+ return new PollResult(State.FAILED, 0, null, errorCode, errorMessage, retryable);
+ }
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/repository/CatalogRepository.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/repository/CatalogRepository.java
new file mode 100644
index 0000000..837de4b
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/repository/CatalogRepository.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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 Why the queue lives here and not in a broker (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.
+ *
+ * The invariant every caller must hold: 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}.
+ *
+ * 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 {@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}).
+ *
+ * {@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 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 {@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_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));
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/security/BearerAuthFilter.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/BearerAuthFilter.java
new file mode 100644
index 0000000..553c215
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/BearerAuthFilter.java
@@ -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.
+ *
+ * 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()));
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/security/JwtVerifier.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/JwtVerifier.java
new file mode 100644
index 0000000..16ed42b
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/JwtVerifier.java
@@ -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);
+ }
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/security/RsaPrivateKeyLoader.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/RsaPrivateKeyLoader.java
new file mode 100644
index 0000000..444b097
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/RsaPrivateKeyLoader.java
@@ -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);
+ }
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/security/RsaPublicKeyLoader.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/RsaPublicKeyLoader.java
new file mode 100644
index 0000000..ffe7436
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/RsaPublicKeyLoader.java
@@ -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);
+ }
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/security/ServiceTokenSigner.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/ServiceTokenSigner.java
new file mode 100644
index 0000000..75177a2
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/security/ServiceTokenSigner.java
@@ -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).
+ *
+ * Why a separate key pair, and why not just forward the user's token.
+ * 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.
+ *
+ * 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.
+ *
+ * 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();
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/service/GenerationService.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/service/GenerationService.java
new file mode 100644
index 0000000..9bdd99e
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/service/GenerationService.java
@@ -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.
+ *
+ * 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 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.
+ *
+ * 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");
+ }
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/support/RequestHashes.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/support/RequestHashes.java
new file mode 100644
index 0000000..f78917d
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/support/RequestHashes.java
@@ -0,0 +1,30 @@
+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() {
+ }
+
+ 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);
+ }
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/support/UuidV7.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/support/UuidV7.java
new file mode 100644
index 0000000..f49a4fe
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/support/UuidV7.java
@@ -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);
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/web/GlobalExceptionHandler.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/web/GlobalExceptionHandler.java
new file mode 100644
index 0000000..28a3e94
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/web/GlobalExceptionHandler.java
@@ -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 Three responsibilities, each a separate scheduled pass so a stall in one
+ * cannot starve the others:
+ * Crash safety comes from the database, not from this class. 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.
+ *
+ * Why the ordering inside {@link #submitPass()} matters.
+ * {@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 Why this indirection instead of writing to the object store here.
+ * 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.
+ *
+ * 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());
+ }
+}
diff --git a/patbond-creation/src/main/java/com/patbond/patbond/creation/worker/WorkerIdentity.java b/patbond-creation/src/main/java/com/patbond/patbond/creation/worker/WorkerIdentity.java
new file mode 100644
index 0000000..e04121e
--- /dev/null
+++ b/patbond-creation/src/main/java/com/patbond/patbond/creation/worker/WorkerIdentity.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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);
+ }
+}
diff --git a/patbond-creation/src/main/resources/application.yml.sample b/patbond-creation/src/main/resources/application.yml.sample
new file mode 100644
index 0000000..01bd3fc
--- /dev/null
+++ b/patbond-creation/src/main/resources/application.yml.sample
@@ -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}
diff --git a/patbond-creation/src/test/java/com/patbond/patbond/creation/TestcontainersConfiguration.java b/patbond-creation/src/test/java/com/patbond/patbond/creation/TestcontainersConfiguration.java
new file mode 100644
index 0000000..d3f408b
--- /dev/null
+++ b/patbond-creation/src/test/java/com/patbond/patbond/creation/TestcontainersConfiguration.java
@@ -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}.
+ *
+ * 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"));
+ }
+}
diff --git a/patbond-creation/src/test/java/com/patbond/patbond/creation/repository/GenerationJobQueueIntegrationTest.java b/patbond-creation/src/test/java/com/patbond/patbond/creation/repository/GenerationJobQueueIntegrationTest.java
new file mode 100644
index 0000000..46dd43d
--- /dev/null
+++ b/patbond-creation/src/test/java/com/patbond/patbond/creation/repository/GenerationJobQueueIntegrationTest.java
@@ -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.
+ *
+ * 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.
+ *
+ * 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> 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
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *