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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 12:18:18 +08:00
2026-09-03 17:59:56 +08:00
2026-09-03 17:59:56 +08:00
2026-09-03 17:59:56 +08:00

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 只有一份、不拆。

构建与测试

./mvnw clean test

集成测试经 Testcontainers 拉起一次性的 postgres:18,因此需要一个在跑的 Docker daemon(不需要本机装 PostgreSQL、也不需要本机库凭证)。

默认 JDK 不是 17 时,先指向一个 JDK 17:

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。先复制样例(默认值本机可用,实际值用环境变量覆盖):

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

先装一次共享模块,然后各服务各开一个终端:

./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 用私钥签,其余服务用公钥验;两者都不入库):

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 需要另一对独立的密钥,不要复用上面那对:

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 上自给自足。

冒烟:

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_* 环境变量注入实际值。

# 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。

S
Description
Patbond api
Readme 1.1 MiB
Languages
Java 98.3%
PLpgSQL 1.1%
Shell 0.5%
Dockerfile 0.1%