Compare commits
6 Commits
3cd8005577
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| a8ffd02175 | |||
| 91ae016470 | |||
| 73bc31aeb8 | |||
| 4b2550f88c | |||
| 7896a6d20b | |||
| 3d66b32af4 |
@@ -1,82 +1,113 @@
|
||||
# Patbond API
|
||||
|
||||
Patbond API is a Spring Boot multi-module backend.
|
||||
Patbond 后端,Spring Boot 多模块。
|
||||
|
||||
## Modules
|
||||
## 模块
|
||||
|
||||
- `patbond-common`: stable cross-module contracts (response wrapper, shared DTOs). No web/messaging dependencies.
|
||||
- `patbond-user`: user service for user creation, profile lookup, and password verification.
|
||||
- `patbond-auth`: authentication service for registration and login. Calls `patbond-user` over HTTP via OpenFeign with a configured static URL (no service discovery in the MVP, see ADR-002).
|
||||
- `patbond-pet`: pet profile and health record service (M2, ADR-009). Shares the database with `patbond-user` and only reads/writes the `pet_health` schema; the Flyway migration chain stays owned by `patbond-user`. First-wave skeleton: liveness endpoint only.
|
||||
| 模块 | 端口 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| `patbond-common` | —— | 跨模块稳定契约(响应信封、错误码、共享 DTO)。**刻意不引入 web/消息依赖**,保持纯契约。 |
|
||||
| `patbond-auth` | 8081 | 注册与登录,签发 RS256 access token。经 OpenFeign 静态直连调用 `patbond-user`(MVP 不做服务发现,ADR-002)。**私钥只在本服务**。 |
|
||||
| `patbond-user` | 8082 | 用户与会话、当前用户资料、媒体上传流程(ADR-016/017)、埋点接收端。**独占 Flyway 迁移链**。 |
|
||||
| `patbond-pet` | 8083 | 宠物档案与健康记录(M2,ADR-009)。与 `patbond-user` 共库,只读写 `pet_health` schema。 |
|
||||
| `patbond-community` | 8084 | 社区 Feed、帖子、评论、点赞收藏与关注(M3,ADR-017)。只读写 `community` schema,另按 ADR-017 例外只读 `media.assets`。 |
|
||||
| `patbond-creation` | 8085 | **AI 生成任务编排(M4,ADR-023)**。见下节。 |
|
||||
|
||||
## Technology Stack
|
||||
### patbond-creation(M4 新增)
|
||||
|
||||
- Java 17 (build baseline; use JDK 17 for release builds)
|
||||
承载 AI 图片与视频生成的任务编排。**它编排任务,但不调用 AI 服务商**——上游调用在独立的 `patbond-ai`(Python)仓库,经 REST 到达。
|
||||
|
||||
**本模块拥有**:`creation` schema 的读写、`/api/v1/creation/**` 客户端端点、以及租约队列 Worker。
|
||||
|
||||
**本模块刻意不拥有**:
|
||||
|
||||
- **AI 服务商调用** —— 在 `patbond-ai`。Java 侧持有队列与任务状态,Python 侧只负责调厂商 API,两者经 REST(不用 gRPC)通信,鉴权用**独立密钥对签发的服务身份 JWT**(不转发用户 token)。
|
||||
- **对象存储** —— 本服务从不直连。产物上传凭证来自 `patbond-user` 的内部媒体接口(预签名 PUT),由生成方直传,ADR-017 的边界不动。
|
||||
|
||||
**队列为什么在数据库里而不是消息中间件**(ADR-023 §3):已评审的目标数据模型本就把整套租约机制建成了列(`lease_owner`/`lease_expires_at`/`next_attempt_at`/`attempt_count`/`max_attempts`/`priority`),外加两条列序与出队语句逐列对齐的偏索引。因此领取是索引扫描而非排序,任务状态只有一个事实来源、无双写;引入中间件反而要让这批列作废。「Worker 重启不丢任务」由租约回收器天然满足。
|
||||
|
||||
**Worker 的三条独立调度通道**(任一卡住不会饿死其余两条):领取待办 QUEUED 任务并提交上游 → 轮询本实例持有的 RUNNING 任务并在同一语句内续租 → 回收租约过期的任务重新入队。
|
||||
|
||||
两处值得留意的实现纪律:
|
||||
|
||||
- **`provider_request_id` 在上游产生任何计费动作之前落库**。顺序反过来,每次超时都会变成重复扣费。
|
||||
- **租约回收时不清 `provider_request_id`**。保留它,下次尝试才知道「这活已经提交过了,该去轮询而不是重发」。
|
||||
|
||||
**骨架阶段刻意未实现**且会响亮失败(不装作能用):provider 适配层的两个实现位、预签名上传目标获取、产物入 `media.assets`。前者待服务商官方文档确认五项(幂等键支持、查询接口与频率限制、单任务最长时长、产物返回形式、限流与配额响应形态),后两者取决于**对象存储方案,该决策明确挂起**(ADR-023 §7)。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- Java 17(构建基线;发布构建请用 JDK 17)
|
||||
- Spring Boot 3.5.16
|
||||
- Spring Cloud 2025.0.3 (OpenFeign only)
|
||||
- PostgreSQL 18 + Flyway (patbond-user owns the migration chain: `identity`/`media`/`platform`/`pet_health` schemas)
|
||||
- Maven (use the committed Maven Wrapper `./mvnw`)
|
||||
- Spring Cloud 2025.0.3(只用 OpenFeign)
|
||||
- PostgreSQL 18 + Flyway
|
||||
- Maven(用仓库内的 wrapper `./mvnw`)
|
||||
|
||||
## Build and Test
|
||||
**Flyway 迁移链由 `patbond-user` 独占**,V1..V6 覆盖 `identity`/`media`/`platform`(V1)、`pet_health`(V3/V4)、`community`(V5)、`creation`(V6)。注意「谁拥有 schema」与「迁移文件放哪」在本项目是分开的:pet、community、creation 的业务代码各在自己的模块,迁移文件全部在 `patbond-user`,`flyway_schema_history` 只有一份、不拆。
|
||||
|
||||
## 构建与测试
|
||||
|
||||
```bash
|
||||
./mvnw clean test
|
||||
```
|
||||
|
||||
Integration tests start a disposable `postgres:18` via Testcontainers, so a
|
||||
running Docker daemon is required (no local PostgreSQL installation or
|
||||
credentials are needed).
|
||||
集成测试经 Testcontainers 拉起一次性的 `postgres:18`,因此需要一个在跑的 Docker daemon(不需要本机装 PostgreSQL、也不需要本机库凭证)。
|
||||
|
||||
If your default JDK is not 17, point `JAVA_HOME` at a JDK 17 installation first, e.g.:
|
||||
默认 JDK 不是 17 时,先指向一个 JDK 17:
|
||||
|
||||
```bash
|
||||
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test
|
||||
```
|
||||
|
||||
## Run
|
||||
## 本机运行
|
||||
|
||||
Running `patbond-user` requires a reachable PostgreSQL 18 database; Flyway
|
||||
applies the versioned migrations in
|
||||
`patbond-user/src/main/resources/db/migration` automatically on startup.
|
||||
Development seed data (`db/dev`, regions reference rows) is opt-in via a dev
|
||||
profile — see `application.yml.sample`.
|
||||
`patbond-user` 启动时 Flyway 会自动应用 `patbond-user/src/main/resources/db/migration` 下的版本化迁移,因此它需要一个可达的 PostgreSQL 18。开发种子数据(`db/dev`)通过 dev profile 可选启用,见 `application.yml.sample`。
|
||||
|
||||
Each service ships a committed `application.yml.sample`; the real `application.yml`
|
||||
is git-ignored. First copy the samples (defaults work locally, overrides via
|
||||
environment variables):
|
||||
每个服务都提交了 `application.yml.sample`,真实的 `application.yml` 被 gitignore。先复制样例(默认值本机可用,实际值用环境变量覆盖):
|
||||
|
||||
```bash
|
||||
cp patbond-user/src/main/resources/application.yml.sample \
|
||||
patbond-user/src/main/resources/application.yml
|
||||
cp patbond-auth/src/main/resources/application.yml.sample \
|
||||
patbond-auth/src/main/resources/application.yml
|
||||
cp patbond-pet/src/main/resources/application.yml.sample \
|
||||
patbond-pet/src/main/resources/application.yml
|
||||
for m in user auth pet community creation; do
|
||||
cp patbond-$m/src/main/resources/application.yml.sample \
|
||||
patbond-$m/src/main/resources/application.yml
|
||||
done
|
||||
```
|
||||
|
||||
Install the shared module once, then run each service in its own terminal:
|
||||
先装一次共享模块,然后各服务各开一个终端:
|
||||
|
||||
```bash
|
||||
./mvnw -pl patbond-common install
|
||||
./mvnw -pl patbond-user spring-boot:run
|
||||
./mvnw -pl patbond-auth spring-boot:run
|
||||
./mvnw -pl patbond-pet spring-boot:run
|
||||
./mvnw -pl patbond-community spring-boot:run
|
||||
./mvnw -pl patbond-creation spring-boot:run
|
||||
```
|
||||
|
||||
Running the services also needs an RS256 key pair for access tokens (the
|
||||
private key signs in `patbond-auth`, the public key verifies in
|
||||
`patbond-user`; neither is committed):
|
||||
### 密钥对
|
||||
|
||||
运行需要一对 RS256 密钥用于 access token(`patbond-auth` 用私钥签,其余服务用公钥验;两者都不入库):
|
||||
|
||||
```bash
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out jwt-private.pem
|
||||
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
||||
export PATBOND_JWT_PRIVATE_KEY=$PWD/jwt-private.pem # patbond-auth
|
||||
export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem # patbond-user
|
||||
export PATBOND_JWT_PRIVATE_KEY=$PWD/jwt-private.pem # 只给 patbond-auth
|
||||
export PATBOND_JWT_PUBLIC_KEY=$PWD/jwt-public.pem # 其余服务验签用
|
||||
```
|
||||
|
||||
Tests do not require this copy step — `patbond-auth/src/test/resources/application.yml`
|
||||
keeps `./mvnw clean test` self-contained on a clean checkout.
|
||||
**`patbond-creation` 调用 `patbond-ai` 需要另一对独立的密钥**,不要复用上面那对:
|
||||
|
||||
Smoke check:
|
||||
```bash
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out service-jwt-private.pem
|
||||
openssl pkey -in service-jwt-private.pem -pubout -out service-jwt-public.pem
|
||||
export PATBOND_SERVICE_JWT_PRIVATE_KEY=$PWD/service-jwt-private.pem # patbond-creation
|
||||
# service-jwt-public.pem 交给 patbond-ai 验签
|
||||
```
|
||||
|
||||
**为什么必须独立**(ADR-023 §5):现有 `JwtVerifier` **只验签名,既不校验 `aud` 也不校验 `iss`**,而 `patbond-auth` 签发的用户 token **不含 `aud` claim**。若服务票复用同一密钥对,user/pet/community/creation 四个模块都会把它当合法用户 token 接受——`sub` 不是 UUID 大概会在后续解析时炸掉,但那是**意外挡住的,不是设计挡住的**。用独立密钥对则无需改动任何已发布的验证器。
|
||||
|
||||
测试不需要上述复制与密钥步骤:`patbond-auth/src/test/resources/application.yml` 让 `./mvnw clean test` 在干净 checkout 上自给自足。
|
||||
|
||||
冒烟:
|
||||
|
||||
```bash
|
||||
curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
|
||||
@@ -84,36 +115,44 @@ curl -X POST http://127.0.0.1:8081/api/v1/auth/register \
|
||||
-d '{"username":"demo_user","password":"secret123"}'
|
||||
```
|
||||
|
||||
### Configuration
|
||||
### 配置
|
||||
|
||||
| Environment variable | Default | Used by |
|
||||
| 环境变量 | 默认值 | 使用方 |
|
||||
| --- | --- | --- |
|
||||
| `PATBOND_USER_PORT` | `8082` | patbond-user |
|
||||
| `PATBOND_AUTH_PORT` | `8081` | patbond-auth |
|
||||
| `PATBOND_USER_SERVICE_URL` | `http://127.0.0.1:8082` | patbond-auth (Feign target for patbond-user) |
|
||||
| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | patbond-user |
|
||||
| `PATBOND_DB_USER` | `patbond` | patbond-user |
|
||||
| `PATBOND_DB_PASSWORD` | `patbond` | patbond-user |
|
||||
| `PATBOND_INTERNAL_TOKEN` | `dev-only-internal-token` | both (shared secret for `/internal/**`; inject a strong random value outside local dev) |
|
||||
| `PATBOND_JWT_PRIVATE_KEY` | _(none, required)_ | patbond-auth (RS256 private key: PEM path or inline PEM) |
|
||||
| `PATBOND_JWT_PUBLIC_KEY` | _(none)_ | patbond-user (RS256 public key: PEM path or inline PEM) |
|
||||
| `PATBOND_ACCESS_TTL` | `15m` | patbond-auth (access token lifetime, ADR-003) |
|
||||
| `PATBOND_REFRESH_TTL` | `30d` | patbond-user (refresh token lifetime, ADR-003) |
|
||||
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` | `5` | patbond-user (login failures before lockout) |
|
||||
| `PATBOND_LOGIN_LOCK_WINDOW` | `15m` | patbond-user (failure counting window) |
|
||||
| `PATBOND_LOGIN_LOCK_DURATION` | `15m` | patbond-user (lock duration) |
|
||||
| `PATBOND_USER_PORT` | `8082` | patbond-user |
|
||||
| `PATBOND_PET_PORT` | `8083` | patbond-pet |
|
||||
| `PATBOND_COMMUNITY_PORT` | `8084` | patbond-community |
|
||||
| `PATBOND_CREATION_PORT` | `8085` | patbond-creation |
|
||||
| `PATBOND_DB_URL` | `jdbc:postgresql://127.0.0.1:5432/patbond` | 全部数据库使用方 |
|
||||
| `PATBOND_DB_USER` | `patbond` | 同上 |
|
||||
| `PATBOND_DB_PASSWORD` | `patbond` | 同上 |
|
||||
| `PATBOND_USER_SERVICE_URL` | `http://127.0.0.1:8082` | auth / community / creation(Feign 指向 patbond-user) |
|
||||
| `PATBOND_INTERNAL_TOKEN` | `dev-only-internal-token` | 全部(`/internal/**` 服务间共享密钥;本机开发之外必须注入强随机值) |
|
||||
| `PATBOND_JWT_PRIVATE_KEY` | _(无,必填)_ | patbond-auth(RS256 私钥:PEM 路径或内联 PEM) |
|
||||
| `PATBOND_JWT_PUBLIC_KEY` | _(无)_ | user / pet / community / creation(RS256 公钥) |
|
||||
| `PATBOND_ACCESS_TTL` | `15m` | patbond-auth(access token 有效期,ADR-003) |
|
||||
| `PATBOND_REFRESH_TTL` | `30d` | patbond-user(refresh token 有效期,ADR-003) |
|
||||
| `PATBOND_LOGIN_LOCK_MAX_FAILURES` | `5` | patbond-user(锁定前允许的失败次数) |
|
||||
| `PATBOND_LOGIN_LOCK_WINDOW` | `15m` | patbond-user(失败计数窗口) |
|
||||
| `PATBOND_LOGIN_LOCK_DURATION` | `15m` | patbond-user(锁定时长) |
|
||||
| `PATBOND_MINIO_ENDPOINT` | `http://minio:9000` | patbond-user(服务自身访问对象存储的地址) |
|
||||
| `PATBOND_MINIO_PUBLIC_ENDPOINT` | `http://127.0.0.1:9000` | user / community(**客户端可达**地址,预签名 URL 的 host 由它决定;容器内外不同) |
|
||||
| `PATBOND_MINIO_ACCESS_KEY` / `_SECRET_KEY` | _(无)_ | user / community |
|
||||
| `PATBOND_MINIO_BUCKET` | `patbond-media` | user / community |
|
||||
| `PATBOND_AI_SERVICE_URL` | `http://127.0.0.1:8000` | patbond-creation(指向 patbond-ai) |
|
||||
| `PATBOND_SERVICE_JWT_PRIVATE_KEY` | _(无)_ | patbond-creation(**独立于用户 token 的** RS256 私钥) |
|
||||
| `PATBOND_CREATION_WORKER_ENABLED` | `true` | patbond-creation(关掉则只提供端点、不消费队列) |
|
||||
| `PATBOND_CREATION_LEASE_TTL` | `60s` | patbond-creation(**必须大于最长轮询间隔**,否则任务在跑租约就过期被抢) |
|
||||
|
||||
Machine-specific values live in the git-ignored `application.yml` (copied from the
|
||||
committed `.sample`); never commit secrets to the samples.
|
||||
各服务的 `application.yml.sample` 是环境变量的完整清单与说明,本表只列常用项。机器相关的实际值放在被 gitignore 的 `application.yml` 里;**样例文件里永远不要提交真实密钥**。
|
||||
|
||||
## Docker Compose
|
||||
|
||||
MVP 编排(ADR-007):`postgres:18`(数据落 volume)+ 两个无状态应用容器。
|
||||
配置与本机运行同一套约定 —— 容器内挂载 `application.yml.sample` 作为配置,
|
||||
`PATBOND_*` 环境变量注入实际值。
|
||||
编排(ADR-007/016):`postgres:18` + 自托管 `minio` + 无状态应用容器。配置与本机运行同一套约定 —— 容器内挂载 `application.yml.sample` 作为配置,`PATBOND_*` 环境变量注入实际值。
|
||||
|
||||
```bash
|
||||
# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌;产物被 .gitignore 忽略)
|
||||
# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌、MinIO 凭证;产物被 gitignore)
|
||||
./deploy/init-secrets.sh
|
||||
|
||||
# 2. 构建可执行 jar
|
||||
@@ -127,40 +166,51 @@ curl -s -X POST http://127.0.0.1:8081/api/v1/auth/register \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"demo_user","password":"secret123"}'
|
||||
|
||||
# 停止(-v 同时删除数据库数据)
|
||||
# 停止(-v 同时删除数据库与对象存储数据)
|
||||
docker compose down
|
||||
```
|
||||
|
||||
注意:数据库端口不对宿主机发布;`8082`(user)在 MVP 阶段直连暴露以提供
|
||||
`/api/v1/me`,`/internal/**` 由服务间令牌保护,规模化阶段应改由网关统一入口。
|
||||
`patbond-creation` 尚未加入 compose——它的 Worker 依赖 `patbond-ai` 与对象存储方案,两者都还没落地。
|
||||
|
||||
## Services
|
||||
注意:数据库端口不对宿主机发布;`8082`(user)在 MVP 阶段直连暴露以提供 `/api/v1/me`,`/internal/**` 由服务间令牌保护,规模化阶段应改由网关统一入口。
|
||||
|
||||
### Auth Service
|
||||
## 服务端点
|
||||
|
||||
- Application name: `patbond-auth`
|
||||
- Default port: `8081`
|
||||
- Register: `POST /api/v1/auth/register`
|
||||
- Login: `POST /api/v1/auth/login`
|
||||
- Refresh (rotates the refresh token): `POST /api/v1/auth/refresh`
|
||||
- Logout (revokes the current session): `POST /api/v1/auth/logout`
|
||||
### Auth(8081)
|
||||
|
||||
### User Service
|
||||
- 注册:`POST /api/v1/auth/register`
|
||||
- 登录:`POST /api/v1/auth/login`
|
||||
- 刷新(轮换 refresh token):`POST /api/v1/auth/refresh`
|
||||
- 登出(吊销当前会话):`POST /api/v1/auth/logout`
|
||||
|
||||
- Application name: `patbond-user`
|
||||
- Default port: `8082`
|
||||
- Current user profile: `GET /api/v1/me` (Bearer access token, verified locally with the RS256 public key)
|
||||
- Internal (require `X-Internal-Token`):
|
||||
- Create user: `POST /internal/users`
|
||||
- Verify password: `POST /internal/users/verify-password`
|
||||
- Get user by id: `GET /internal/users/{id}`
|
||||
- Get user by username: `GET /internal/users/by-username/{username}`
|
||||
- Sessions: `POST /internal/sessions`, `POST /internal/sessions/refresh`, `POST /internal/sessions/revoke`
|
||||
### User(8082)
|
||||
|
||||
> Note: user data is persisted in PostgreSQL (`identity.users` /
|
||||
> `identity.user_credentials`, bcrypt password hashes, UUIDv7 ids generated in
|
||||
> the application). Refresh sessions live in `identity.auth_sessions` (SHA-256
|
||||
> digests only, rotation on every refresh, token-family revocation on reuse,
|
||||
> ADR-003). Errors follow the `{code, message, data}` envelope with stable
|
||||
> business codes and matching HTTP statuses; the public contract is documented
|
||||
> in `patbond-doc/docs/api/openapi.yaml`.
|
||||
- 当前用户资料:`GET /api/v1/me`、`PATCH /api/v1/me`
|
||||
- 社区统计:`GET /api/v1/me/community-stats`
|
||||
- 媒体上传:创建上传凭据与确认(ADR-016:私有桶 + 预签名)
|
||||
- 埋点接收:`POST /api/v1/events`(批量 1–50、逐条结果、eventId 幂等、白名单剥离)
|
||||
- 内部接口(需 `X-Internal-Token`):`POST /internal/users`、`POST /internal/users/verify-password`、`GET /internal/users/{id}`、`GET /internal/users/by-username/{username}`、`GET /internal/users/profiles`、`POST /internal/sessions{,/refresh,/revoke}`
|
||||
|
||||
### Pet(8083)
|
||||
|
||||
宠物档案、体重与疫苗记录、照护提醒;权限按 ADR-015 三档模型(owner / caregiver / viewer)。
|
||||
|
||||
### Community(8084)
|
||||
|
||||
Feed 游标分页、帖子草稿与发布、单层评论、点赞与收藏(幂等)、关注与我的收藏。
|
||||
|
||||
### Creation(8085)
|
||||
|
||||
- 创建生成任务:`POST /api/v1/creation/jobs`(**必带 `Idempotency-Key`**,ADR-019——生成在上游是要花钱的,重试绝不能变成第二个计费任务)
|
||||
- 查询任务:`GET /api/v1/creation/jobs/{jobId}`(**轮询端点**)
|
||||
- 我的任务列表:`GET /api/v1/creation/jobs`
|
||||
- 取消任务:`DELETE /api/v1/creation/jobs/{jobId}`
|
||||
- 模型与风格目录:`GET /api/v1/creation/catalog`
|
||||
|
||||
进度靠轮询上面那个 GET,**没有流式端点**:SSE 会旁路整条「信封解包 + 401 单飞刷新重放」的客户端网络层、形成第二套鉴权路径,而任何推送方案都必须有这个 GET 兜底(ADR-023 §10)。
|
||||
|
||||
目录端点会返回「哪些 `media_kind` 真的有可用模型」,客户端据此决定是否渲染类型切换器——所以目录为空的类型不会出现,不会给用户一个承诺了却做不到的控件;V7 种子落地后 UI 会自己长出视频分组,无需发版。
|
||||
|
||||
任务响应里的 `timeoutSec` 与 `stalled` 由服务端下发而非客户端硬编码:图片与视频差一个量级,硬编码两个常量意味着换 provider 就要发版。`stalled`(第六个 UI 态)**不是错误态**——任务仍可能完成。
|
||||
|
||||
> 用户数据落在 PostgreSQL(`identity.users` / `identity.user_credentials`,bcrypt 口令散列,应用侧生成 UUIDv7)。refresh 会话在 `identity.auth_sessions`(只存 SHA-256 摘要,每次刷新轮换,重用即吊销整个 token family,ADR-003)。错误统一走 `{code, message, data}` 信封与稳定业务码;对外契约见 `patbond-doc/docs/api/openapi.yaml`,架构决策见 `patbond-doc/docs/architecture/decisions.md`。
|
||||
|
||||
@@ -28,12 +28,22 @@ public enum ErrorCode {
|
||||
MEDIA_NOT_FOUND(40405, 404, "媒体资源不存在"),
|
||||
COMMENT_NOT_FOUND(40404, 404, "评论不存在"),
|
||||
TARGET_USER_NOT_FOUND(40406, 404, "用户不存在"),
|
||||
// M4 生成域(ADR-023 §9)。40407/40408 与既有 404 族同列;配额与限流的
|
||||
// 骨架阶段只加了实际使用的码;两个 429 已随 AI 适配层落地(见下)。
|
||||
GENERATION_JOB_NOT_FOUND(40407, 404, "生成任务不存在"),
|
||||
GENERATION_MODEL_NOT_FOUND(40408, 404, "模型或风格不可用"),
|
||||
VACCINATION_RULE_VIOLATION(42201, 422, "疫苗状态或日期约束不满足"),
|
||||
REMINDER_RULE_VIOLATION(42202, 422, "提醒状态或 completedAt 约束不满足"),
|
||||
MEDIA_NOT_READY(42203, 422, "媒体尚未就绪"),
|
||||
FOLLOW_RULE_VIOLATION(42204, 422, "不能关注自己"),
|
||||
MEDIA_UPLOAD_STATE_INVALID(42205, 422, "上传状态不允许确认"),
|
||||
GENERATION_STATE_INVALID(42206, 422, "生成任务状态不允许该操作"),
|
||||
LOGIN_LOCKED(42300, 423, "登录失败次数过多,账号已临时锁定"),
|
||||
// M4 生成域的两个 429(ADR-023 §9)。**必须是两个码**:配额耗尽等的是次日或
|
||||
// 充值(客户端弹 sheet 给出口),频率过快等的是数秒(弹 SnackBar 可自动重试)。
|
||||
// 共用一码必然使一半场景的文案与出口是错的。
|
||||
GENERATION_QUOTA_EXHAUSTED(42900, 429, "今日 AI 生成额度已用完"),
|
||||
GENERATION_RATE_LIMITED(42901, 429, "操作过于频繁,请稍后再试"),
|
||||
INTERNAL_ERROR(50000, 500, "服务器内部错误"),
|
||||
DOWNSTREAM_UNAVAILABLE(50300, 503, "依赖服务暂不可用");
|
||||
|
||||
|
||||
@@ -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
|
||||
* container after V1..V4 and produces the expected structure. Validates the
|
||||
* mandatory cross-schema FK cuts (ADR-017/ADR-018, T3-01):
|
||||
* generation_job_id (creation belongs to M4) and region_id (regions belong
|
||||
* to the M5 region system) exist as bare nullable uuid columns without FKs,
|
||||
* cross-schema FK cuts declared in V5's header (ADR-017/ADR-018, T3-01):
|
||||
* both generation_job_id and region_id exist as bare nullable uuid columns,
|
||||
* and the pg_trgm extension missing from V1 is now installed.
|
||||
*
|
||||
* <p>Note on the FK assertions: V5 stripped both FKs, but V6 settled the
|
||||
* creation one ("M4 补回"). Since these tests run the full migration chain,
|
||||
* generation_job_id is asserted to HAVE its FK and region_id to still lack
|
||||
* one — see {@code CreationMigrationIntegrationTest} for the V6 side.
|
||||
*/
|
||||
@SpringBootTest
|
||||
@Import(TestcontainersConfiguration.class)
|
||||
@@ -65,7 +69,7 @@ class CommunityMigrationIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void v5PostsColumnsExistWithoutCreationAndRegionFKs() {
|
||||
void v5PostsColumnsExistAndOnlyRegionFKStaysStripped() {
|
||||
// generation_job_id and region_id columns exist (bare nullable uuid)
|
||||
int colCount = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM information_schema.columns " +
|
||||
@@ -76,16 +80,29 @@ class CommunityMigrationIntegrationTest {
|
||||
.single();
|
||||
assertThat(colCount).isEqualTo(2);
|
||||
|
||||
// No FK constraints targeting creation.generation_jobs (M4) or
|
||||
// platform.regions (M5 region system)
|
||||
int fkCount = jdbcClient.sql(
|
||||
// V6 settled the creation FK (V5 header: "M4 补回"), so after the full
|
||||
// V1..V6 chain generation_job_id DOES carry its FK. Asserted per
|
||||
// constraint so a future regression on either side is distinguishable
|
||||
// rather than hidden in a combined count.
|
||||
int generationFk = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM information_schema.table_constraints " +
|
||||
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||
"AND constraint_type = 'FOREIGN KEY' " +
|
||||
"AND (constraint_name LIKE '%generation%' OR constraint_name LIKE '%region%')")
|
||||
"AND constraint_name LIKE '%generation%'")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(fkCount).isEqualTo(0);
|
||||
assertThat(generationFk).isEqualTo(1);
|
||||
|
||||
// region_id stays bare: the region system belongs to M5 (ADR-018) —
|
||||
// M5 补回. This must remain 0 until that migration lands.
|
||||
int regionFk = jdbcClient.sql(
|
||||
"SELECT COUNT(*) FROM information_schema.table_constraints " +
|
||||
"WHERE table_schema = 'community' AND table_name = 'posts' " +
|
||||
"AND constraint_type = 'FOREIGN KEY' " +
|
||||
"AND constraint_name LIKE '%region%'")
|
||||
.query(Integer.class)
|
||||
.single();
|
||||
assertThat(regionFk).isEqualTo(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+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-pet</module>
|
||||
<module>patbond-community</module>
|
||||
<module>patbond-creation</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
# check-secrets.sh —— 凭证防泄漏检查(ADR-021,规范见 patbond-doc docs/development/git-workflow.md)
|
||||
#
|
||||
# 规则单一来源:本地 pre-commit 与 CI 兜底跑的是同一个脚本、同一张规则表。
|
||||
# 三仓(patbond-api / patbond-flutter / patbond-doc)各存一份同构副本,改规则时三仓同步。
|
||||
# 四仓(patbond-api / patbond-flutter / patbond-doc / patbond-ai)各存一份同构副本,
|
||||
# 改规则时四仓同步提交。patbond-ai 是唯一持有 AI 服务商凭证的仓,尤其不可漏。
|
||||
#
|
||||
# 用法:
|
||||
# sh scripts/check-secrets.sh --staged # pre-commit:扫暂存区内容(经 scripts/hooks/pre-commit 调用)
|
||||
@@ -15,7 +16,18 @@ set -u
|
||||
mode="${1:---all}"
|
||||
|
||||
# 允许清单:行内出现任一形态即放行(${} 注入、占位值、明显示例值)
|
||||
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*'
|
||||
# 注:x{3,}(xxx / xxxx…)是本项目文档描述凭证形态时的既有占位约定,
|
||||
# 放行它使「讲解规则的文档」不会被规则自身拦下(如 iteration-4/08 §漏检清单)。
|
||||
ALLOW='\$\{[^}]*\}|\{\{[^}]*\}\}|changeme|change[-_]me|your[-_][a-zA-Z0-9_-]+|<[a-zA-Z0-9 ,_.-]+>|placeholder|example|sample|dummy|fake|redacted|\*\*\*|x{3,}'
|
||||
|
||||
# 依赖注入的传参不是字面量密钥:`api_key=settings.api_key`、
|
||||
# `.param("secretKey", props.getSecretKey())` 这类行里没有任何秘密,而做 DI 的
|
||||
# 代码到处都是这种写法——不放行就得在每个调用点绕过一次。
|
||||
# 收得很紧,避免把真凭证放进来:
|
||||
# * 值必须是**未加引号**的小写起首标识符引用(真密钥要么带引号,要么不是标识符形状)
|
||||
# * 只允许**恰好一个点**且随即行尾(JWT 是 a.b.c 三段,仍会被拦)
|
||||
# * 连字符不在字符集内(sk-ant-… 这类前缀仍会被拦)
|
||||
ALLOW_REFERENCE='[:=][[:space:]]*[a-z][a-zA-Z0-9_]*\.[a-z][a-zA-Z0-9_]*[[:space:]]*[,);]?$'
|
||||
|
||||
# 内容扫描跳过:本脚本与 hook 自身(含规则文本,非凭证)
|
||||
SKIP_PATHS='(^|/)scripts/(check-secrets\.sh|hooks/pre-commit)$'
|
||||
@@ -29,9 +41,11 @@ RULES=$(cat <<'EOF'
|
||||
AK-AWS - - AKIA[0-9A-Z]{16}
|
||||
AK-QCLOUD - - AKID[0-9A-Za-z]{16,}
|
||||
AK-ALIYUN - - LTAI[0-9A-Za-z]{12,}
|
||||
AK-ANTHROPIC - - sk-ant-[A-Za-z0-9_-]{16,}
|
||||
AK-OPENAI - - sk-(proj-|svcacct-)?[A-Za-z0-9]{20,}
|
||||
MINIO-DEFAULT i - minio[-_.]?admin
|
||||
PRIVATE-KEY - - ^[[:space:]]*-----BEGIN [A-Z ]*PRIVATE KEY-----[[:space:]]*$
|
||||
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
|
||||
KEY-ASSIGN i - (access[-_]?key(_?id)?|secret[-_]?(access[-_]?)?key|api[-_]?(key|secret|token)|auth[-_]?token)["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
|
||||
JWT-SECRET i - (jwt[-_.]?secret|signing[-_]?key|token[-_]?secret|hmac[-_]?(key|secret))["']?[[:space:]]*[:=][[:space:]]*["']?[A-Za-z0-9+/=_-]{8,}
|
||||
DB-PASSWORD i \.(ya?ml|properties|toml|conf|ini)(\.sample|\.example)?$ (password|passwd|pwd)["']?[[:space:]]*[:=][[:space:]]*["']?[^[:space:]"'$]{6,}
|
||||
EOF
|
||||
@@ -85,6 +99,7 @@ for f in $files; do
|
||||
ci=""
|
||||
[ "$flag" = "i" ] && ci="-i"
|
||||
grep -InE $ci -e "$pat" "$tmp" 2>/dev/null | grep -viE "$ALLOW" |
|
||||
grep -vE "$ALLOW_REFERENCE" |
|
||||
sed "s|^|[$id] $f:|" >>"$viol" || true
|
||||
done
|
||||
done
|
||||
@@ -96,7 +111,7 @@ if [ -s "$viol" ]; then
|
||||
处置:
|
||||
1. 若是真实凭证:先去云控制台轮换/禁用该密钥,再从提交中移除;
|
||||
2. 若是误报:改用 ${} 注入或占位值(changeme / your-xxx / <占位>),
|
||||
或与团队确认后调整三仓同构的 scripts/check-secrets.sh 规则表。
|
||||
或与团队确认后调整四仓同构的 scripts/check-secrets.sh 规则表。
|
||||
敏感信息只允许存在于被 gitignore 的文件或 .sample 占位中(git-workflow.md)。
|
||||
MSG
|
||||
exit 1
|
||||
|
||||
Reference in New Issue
Block a user