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