Files
patbond-api/Readme.md
T
lixi 4b2550f88c
CI / backend-test (push) Successful in 5m43s
feat(creation): 新增 patbond-creation 模块骨架——端点、租约队列 Worker、provider 适配层(T4-02)
M4 新模块,:8085,前缀 /api/v1/creation/**(ADR-023 §2,E3 拍板方案 A)。
它编排生成任务但不调用 AI 服务商——上游调用在独立的 patbond-ai(Python)
仓库,经 REST 到达。

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:36:52 +08:00

217 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Patbond API
Patbond 后端,Spring Boot 多模块。
## 模块
| 模块 | 端口 | 职责 |
| --- | --- | --- |
| `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)**。见下节。 |
### patbond-creation(M4 新增)
承载 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)
- PostgreSQL 18 + Flyway
- Maven(用仓库内的 wrapper `./mvnw`)
**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
```
集成测试经 Testcontainers 拉起一次性的 `postgres:18`,因此需要一个在跑的 Docker daemon(不需要本机装 PostgreSQL、也不需要本机库凭证)。
默认 JDK 不是 17 时,先指向一个 JDK 17:
```bash
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw clean test
```
## 本机运行
`patbond-user` 启动时 Flyway 会自动应用 `patbond-user/src/main/resources/db/migration` 下的版本化迁移,因此它需要一个可达的 PostgreSQL 18。开发种子数据(`db/dev`)通过 dev profile 可选启用,见 `application.yml.sample`。
每个服务都提交了 `application.yml.sample`,真实的 `application.yml` 被 gitignore。先复制样例(默认值本机可用,实际值用环境变量覆盖):
```bash
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
```
先装一次共享模块,然后各服务各开一个终端:
```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
```
### 密钥对
运行需要一对 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-creation` 调用 `patbond-ai` 需要另一对独立的密钥**,不要复用上面那对:
```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 \
-H 'Content-Type: application/json' \
-d '{"username":"demo_user","password":"secret123"}'
```
### 配置
| 环境变量 | 默认值 | 使用方 |
| --- | --- | --- |
| `PATBOND_AUTH_PORT` | `8081` | patbond-auth |
| `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(**必须大于最长轮询间隔**,否则任务在跑租约就过期被抢) |
各服务的 `application.yml.sample` 是环境变量的完整清单与说明,本表只列常用项。机器相关的实际值放在被 gitignore 的 `application.yml` 里;**样例文件里永远不要提交真实密钥**。
## Docker Compose
编排(ADR-007/016):`postgres:18` + 自托管 `minio` + 无状态应用容器。配置与本机运行同一套约定 —— 容器内挂载 `application.yml.sample` 作为配置,`PATBOND_*` 环境变量注入实际值。
```bash
# 1. 生成 RS256 密钥对与 .env(DB 口令、内部令牌、MinIO 凭证;产物被 gitignore)
./deploy/init-secrets.sh
# 2. 构建可执行 jar
JAVA_HOME=/usr/lib/jvm/java-17-openjdk ./mvnw -DskipTests package
# 3. 启动(首次会构建镜像)
docker compose up -d --build
# 冒烟
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 同时删除数据库与对象存储数据)
docker compose down
```
`patbond-creation` 尚未加入 compose——它的 Worker 依赖 `patbond-ai` 与对象存储方案,两者都还没落地。
注意:数据库端口不对宿主机发布;`8082`(user)在 MVP 阶段直连暴露以提供 `/api/v1/me`,`/internal/**` 由服务间令牌保护,规模化阶段应改由网关统一入口。
## 服务端点
### Auth(8081)
- 注册:`POST /api/v1/auth/register`
- 登录:`POST /api/v1/auth/login`
- 刷新(轮换 refresh token):`POST /api/v1/auth/refresh`
- 登出(吊销当前会话):`POST /api/v1/auth/logout`
### User(8082)
- 当前用户资料:`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`。