feat(ai): 内部 HTTP 端点 + 服务票校验 + 契约漂移守卫(166 测试)
CI / ai-gates (push) Successful in 39s

按 openapi-internal.yaml 落地两个端点与鉴权,全部离线可测。

**鉴权(app/api/auth.py)**是这一层的安全边界,三条纪律各有测试:
- **强制校验 aud 与 iss**(交给 pyjwt 强制,不解出来自己比——自己比容易漏掉
  「claim 不存在」那一路)。这是独立密钥对方案唯一的意义所在:Java 侧现有的
  JwtVerifier 只验签名、不校验 aud,正因如此才必须独立密钥;若本服务也只验签名,
  那对独立密钥就白搭了。
- **额外强制 typ=service**,作为「有人误把用户密钥配到这里」的第二道防线。
- **缺公钥时全拒而非全过**。配置缺失必须立刻暴露,而不是变成一扇敞开的门;
  与 Java 侧 InternalAuthFilter 未配置 internal-token 时全拒 /internal/** 一致。
- 失败一律返回同一个笼统原因,**不告诉调用方是签名错/过期/aud 不符**——对方是
  我们自己的服务,真正原因在日志里;细节回给调用方只会在将来某天变成提示。

**三类失败分开**(契约核心)。第三类「任务失败」走 200 + state=failed + failure,
因为那种情况下 HTTP 调用是成功的;做成非 2xx 会让「查不到任务」与「任务失败了」
难以区分。另外两处刻意选择:
- 服务商 429 **不透传**,一律 502 + provider 信封。429 既可能是「等几秒」也可能是
  「等到明天」,状态码表达不了这个区别,retry 才是权威信号。
- 超出模型限制的请求归 service/invalid_request 而非 provider——请求**根本没发出去**,
  归到 provider 会让调用方以为是上游问题而去退避重试,而重试同样的参数永远不会变好。
- 服务商 404(含超出其 7 天保留期)归 service/task_not_found:调用方要做的是
  **停止轮询**,不是退避重试。
- 接管 FastAPI 默认的 {"detail": [...]} 校验错误,否则调用方要处理三种形状,
  而第三种还是框架实现细节。

**契约漂移守卫(tests/api/test_contract_drift.py,14 项)**:本项目开工审计已证明
「没有门禁的契约必然漂移」——四模块快照锁实际不存在,白名单 42/41 之误四份文档
一路不可见整整一个迭代。内部契约同样需要门禁。比对路径/方法/字段集/枚举/必填项,
**不比对字节**(永远不会相同,只会制造一个天天红然后被 skip 绕过的门禁)。

写这个守卫时它立刻抓到一处问题,但**问题在我的内省方式而不在应用**:本版本
FastAPI 的 include_router 会包一层内部 wrapper,路由并不平铺在 app.routes 上。
已改用 app.openapi()——它是「究竟暴露了什么」的权威视图,且不依赖框架内部结构,
否则这个门禁会在升级框架时莫名失效,而一个悄悄失效的门禁比没有门禁更糟。

**新增 pyproject.toml**:`Depends()` 放在参数默认值里正是 FastAPI 的标准写法,
ruff 的 B008 在此是误报。按 ruff 正规方式声明这些调用可安全求值,而不是在每个
端点上撒 noqa——后者迟早被用来压制真正的问题。

schemas 用 extra="forbid":拼错的字段应当立刻报错而非被静默忽略,后者表现为
「我传了 durationSec 却没生效」这类极难查的问题。测试覆盖了 camelCase 与
user_id(契约刻意不含它)两种被拒情形。

新增依赖 fastapi / uvicorn / pyjwt[crypto];测试 127 → 166。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-20 16:59:44 +08:00
parent fd23dfc0b9
commit 062e11b1ff
15 changed files with 1329 additions and 1 deletions
+25
View File
@@ -0,0 +1,25 @@
"""启动入口:`python -m app`。
配置全部从环境变量读(见 `app/config.py` 与 `.env.sample`)。缺服务商凭证时服务
仍会启动,只是该适配器不装配——`/health` 会报出实际装配了哪些,便于确认。
"""
from __future__ import annotations
import os
import uvicorn
from app.api.main import create_app
def main() -> None:
# 只监听回环是默认:本服务只接受来自 patbond-creation 的内部调用,
# 不该对外可达(与项目「默认拒绝、需要外部访问时经 nginx 反代」的纪律一致)。
host = os.environ.get("PATBOND_AI_HOST", "127.0.0.1")
port = int(os.environ.get("PATBOND_AI_PORT", "8000"))
uvicorn.run(create_app(), host=host, port=port)
if __name__ == "__main__":
main()
View File
+78
View File
@@ -0,0 +1,78 @@
"""服务身份 JWT 校验(ADR-023 §5)。
本模块是**唯一**的鉴权入口。三条纪律,每条都有测试钉住:
1. **强制校验 `aud` 与 `typ`。** 这是独立密钥对方案唯一的意义所在——Java 侧那边
现有的 `JwtVerifier` 只验签名、不校验 `aud`/`iss`,正是因为如此才必须用一对
独立密钥;如果本服务也只验签名,那对独立密钥就白搭了。
2. **用户 token 一律拒绝。** 它们由另一对密钥签发,签名校验本身就会失败;但即使
有人误把用户密钥配到这里,缺 `aud` 与 `typ=service` 也会被拦下——两道。
3. **缺公钥时拒绝所有请求,而不是放行。** 配置缺失必须是「全拒」而非「全过」:
前者立刻暴露,后者是敞开的门。这与 Java 侧 `InternalAuthFilter` 未配置
`internal-token` 时全拒 `/internal/**` 的既有选择一致。
"""
from __future__ import annotations
import logging
import jwt
from fastapi import Depends, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.config import ServiceAuthSettings
from .errors import ServiceError, ServiceErrorCode
log = logging.getLogger(__name__)
#: auto_error=False:自己产生 401 以便统一走 service 错误信封,
#: 而不是 FastAPI 默认的 `{"detail": ...}`——后者与本契约的两类错误形状都不符。
_bearer = HTTPBearer(auto_error=False)
def verify_service_token(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> None:
"""校验服务票;不通过即抛 `ServiceError(unauthenticated)`。
所有失败都返回同一个笼统原因,**不告诉调用方是签名错、过期、还是 aud 不符**。
对方是我们自己的服务,真正的原因在本服务日志里;把细节回给调用方只会在将来
某天变成攻击者的提示。
"""
settings: ServiceAuthSettings = request.app.state.settings.service_auth
if not settings.public_key:
# 配置缺失 → 全拒。绝不「未配置即放行」。
log.error("PATBOND_SERVICE_JWT_PUBLIC_KEY 未配置,/internal/** 全部拒绝")
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务端未配置服务票公钥")
if credentials is None or not credentials.credentials:
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "缺少服务票")
try:
claims = jwt.decode(
credentials.credentials,
settings.public_key,
algorithms=["RS256"],
# audience 与 issuer 交给库强制校验,而不是解出来自己比——
# 自己比容易漏掉「claim 不存在」这一路。
audience=settings.audience,
issuer=settings.issuer,
options={"require": ["exp", "aud", "iss", "sub"]},
)
except jwt.InvalidTokenError as exc:
# 具体原因只进日志。
log.warning("服务票校验失败:%s", exc)
raise ServiceError(
ServiceErrorCode.UNAUTHENTICATED, "服务票无效或已过期"
) from exc
token_type = claims.get("typ")
if token_type != settings.required_type:
# 用户 token 走到这里的唯一可能是有人误配了用户密钥。拦住它。
log.warning(
"服务票 typ 不符:期望 %s,实际 %r", settings.required_type, token_type
)
raise ServiceError(ServiceErrorCode.UNAUTHENTICATED, "服务票类型不符")
+86
View File
@@ -0,0 +1,86 @@
"""两类错误的异常与信封(契约 `openapi-internal.yaml` 的核心设计)。
**两类失败必须分开**,因为调用方对它们的反应完全不同:
- `service`(Java↔Python):我方 bug 或配置问题。除 `internal_error` /
`provider_unavailable` 外都不该重试——重试一个拼错的请求体不会变好。
- `provider`(Python↔服务商):以 `retry` 字段为准,调用方不该自己解读厂商码。
还有**第三类不在这里**:任务本身失败(生成跑完但失败了)走 `200 + state=failed`
+ `failure`。因为那种情况下 **HTTP 调用是成功的**,失败的是任务。做成错误响应会
让「查不到任务」与「任务失败了」难以区分。
判别字段是 `error.kind`,**不是 HTTP 状态码**:状态码在两类之间会撞(502 既可能是
服务商挂了也可能是我们打错地址),而 `kind` 不会。
"""
from __future__ import annotations
from enum import Enum
from app.domain.generation import Failure
class ServiceErrorCode(Enum):
"""`service` 类错误码,与契约的枚举逐一对应。"""
#: 服务票缺失、过期、签名错、aud/typ 不符。细节只进日志。
UNAUTHENTICATED = ("unauthenticated", 401)
#: 请求体不合契约,或参数超出该模型的限制(本地预校验拦下)。
INVALID_REQUEST = ("invalid_request", 400)
#: 路径里的服务商任务 ID 不存在(含超过服务商 7 天保留期)。
TASK_NOT_FOUND = ("task_not_found", 404)
#: 适配器未装配(如缺服务商凭证)或 media_kind 无对应适配器。
PROVIDER_UNAVAILABLE = ("provider_unavailable", 503)
#: 本服务 bug。
INTERNAL_ERROR = ("internal_error", 500)
def __init__(self, code: str, http_status: int):
self.code = code
self.http_status = http_status
class ServiceError(Exception):
"""Java↔Python 之间的问题。"""
def __init__(self, code: ServiceErrorCode, message: str):
super().__init__(message)
self.code = code
self.message = message
def to_envelope(self) -> dict:
return {
"error": {
"kind": "service",
"code": self.code.code,
"message": self.message,
}
}
class ProviderError(Exception):
"""Python↔服务商之间的问题,携带完整分类。
字段与 `Failure` 一致**是刻意的**:调用方因此可以复用同一段处置逻辑,不必为
「提交时被限流」和「任务失败于限流」写两套分支。
"""
#: 502:本服务本身没问题,是上游调用没成功。
#: 刻意不透传服务商的状态码——429 既可能是「等几秒」也可能是「等到明天」,
#: 状态码表达不了这个区别,`retry` 才是权威信号。
HTTP_STATUS = 502
def __init__(self, failure: Failure):
super().__init__(failure.message or failure.provider_code)
self.failure = failure
def to_envelope(self) -> dict:
return {
"error": {
"kind": "provider",
"retry": self.failure.retry.value,
"user_facing": self.failure.user_facing.value,
"provider_code": self.failure.provider_code,
"message": self.failure.message,
}
}
+121
View File
@@ -0,0 +1,121 @@
"""FastAPI 应用工厂。
装配顺序:读配置 → 建适配器注册表 → 挂路由与异常处理器。配置与注册表放在
`app.state` 上,请求处理时从 `request.app` 取——**不用模块级全局**,这样测试能起
多个配置不同的实例而互不干扰。
**异常处理器是本模块的重点**:契约定义了两类错误信封(`kind = service` /
`provider`),而 FastAPI 默认的校验错误是 `{"detail": [...]}`,与两者都不符。若不
接管,调用方就要处理三种形状——而第三种还是框架的实现细节。
"""
from __future__ import annotations
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from app.assembly import build_registry
from app.config import Settings
from .errors import ProviderError, ServiceError, ServiceErrorCode
from .routes import router
log = logging.getLogger(__name__)
def create_app(settings: Settings | None = None) -> FastAPI:
"""建应用。
:param settings: 省略即从环境变量读。测试传入显式配置以免依赖环境。
"""
resolved = settings or Settings.from_env()
logging.basicConfig(level=resolved.log_level)
app = FastAPI(
title="patbond-ai 内部接口",
version="0.1.0",
description=(
"patbond-creation(Java)调用本服务执行 AI 生成的内部契约。"
"权威契约见仓库根目录的 openapi-internal.yaml——"
"本服务自动生成的 /openapi.json 仅供调试,不是契约。"
),
# 内部服务不开交互文档:少一个对外表面,且契约以手写 yaml 为准。
docs_url=None,
redoc_url=None,
)
app.state.settings = resolved
app.state.registry = build_registry(resolved)
app.include_router(router)
_install_exception_handlers(app)
_install_health(app)
return app
def _install_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(ServiceError)
def handle_service_error(request: Request, exc: ServiceError) -> JSONResponse:
return JSONResponse(status_code=exc.code.http_status, content=exc.to_envelope())
@app.exception_handler(ProviderError)
def handle_provider_error(request: Request, exc: ProviderError) -> JSONResponse:
log.warning(
"服务商调用失败 code=%s retry=%s",
exc.failure.provider_code,
exc.failure.retry.value,
)
return JSONResponse(
status_code=ProviderError.HTTP_STATUS, content=exc.to_envelope()
)
@app.exception_handler(RequestValidationError)
def handle_validation_error(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""把 FastAPI 的校验错误翻成 service 信封。
默认形状是 `{"detail": [...]}`,与契约的两类信封都不符。不接管的话调用方
要处理三种形状,而第三种还是框架的实现细节——换个框架就变了。
"""
first = exc.errors()[0] if exc.errors() else {}
# 字段路径形如 ("body", "duration_sec"),去掉 "body" 前缀更可读。
location = ".".join(
str(part) for part in first.get("loc", ()) if part != "body"
)
detail = first.get("msg", "请求体不合契约")
message = f"{location}: {detail}" if location else detail
error = ServiceError(ServiceErrorCode.INVALID_REQUEST, message)
return JSONResponse(
status_code=error.code.http_status, content=error.to_envelope()
)
@app.exception_handler(Exception)
def handle_unexpected(request: Request, exc: Exception) -> JSONResponse:
"""兜底:本服务的 bug。
完整堆栈只进日志,绝不回给调用方——内部细节外泄没有任何好处。
"""
log.exception("未处理的异常")
error = ServiceError(ServiceErrorCode.INTERNAL_ERROR, "服务内部错误")
return JSONResponse(
status_code=error.code.http_status, content=error.to_envelope()
)
def _install_health(app: FastAPI) -> None:
@app.get("/health", include_in_schema=False)
def health() -> dict:
"""探活。**在 /internal 之外,故不需要服务票**——与 Java 侧各服务
`/health` 不挂鉴权的既有约定一致。
同时报出已装配的适配器:缺凭证时适配器不装配,而那个症状(任务永远排队)
离根因很远,让探活直接说出来能省很多排查时间。
"""
registry = app.state.registry
return {
"status": "ok",
"providers": sorted(registry.codes),
}
+108
View File
@@ -0,0 +1,108 @@
"""内部接口路由。
**本层只做编排,不含任何厂商知识**:解析请求 → 按 `media_kind` 找适配器 → 调用 →
把领域结果序列化。厂商差异全在 `app/providers/<厂商>/` 内。
一处刻意的选择:路径参数是**服务商的任务 ID**,不是我方 `job_id`。本服务不持有
状态,唯一能用来问服务商的就是这个 ID;若用 `job_id` 就得存一份映射,「无状态」
不再成立,且会多出一个「Java 拿到 ID 但查不到任何东西」的中间态。
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Request
from app.domain.generation import MediaKind
from app.providers.base import GenerationProvider
from app.providers.registry import ProviderRegistry
from app.providers.volcengine.client import ArkError
from .auth import verify_service_token
from .errors import ProviderError, ServiceError, ServiceErrorCode
from .schemas import SubmitRequest, SubmitResponse, TaskResponse
log = logging.getLogger(__name__)
router = APIRouter(
prefix="/internal/generation",
dependencies=[Depends(verify_service_token)],
)
def _registry(request: Request) -> ProviderRegistry:
return request.app.state.registry
def _provider_for(
registry: ProviderRegistry, media_kind: MediaKind
) -> GenerationProvider:
"""按 `media_kind` 取适配器。
查不到即 `provider_unavailable`(503)而不是 500:这通常是凭证未配置导致适配器
没装配,属环境问题而非代码缺陷,且对调用方而言是**可重试**的——配好就能用。
"""
for provider in registry.all():
if provider.media_kind is media_kind:
return provider
raise ServiceError(
ServiceErrorCode.PROVIDER_UNAVAILABLE,
f"没有可用的 {media_kind.value} 生成适配器(检查服务商凭证是否已配置)",
)
@router.post("/tasks", response_model=SubmitResponse)
def submit_task(payload: SubmitRequest, request: Request) -> SubmitResponse:
"""提交生成任务,返回服务商任务 ID。
调用方**必须在上游产生任何计费动作之前**把该 ID 落库——火山不支持幂等键,
这是提交超时后唯一能判断「是否已提交」的依据。
"""
provider = _provider_for(_registry(request), payload.media_kind)
try:
provider_request_id = provider.submit(payload.to_domain())
except ArkError as exc:
# 服务商调用失败:原样带上分类,调用方据 retry 决定重试。
raise ProviderError(exc.failure) from exc
except ValueError as exc:
# 本地预校验拦下的参数问题(超出该模型的时长/分辨率/ratio 限制,或内容为空)。
# 属 invalid_request 而非 provider 错误——请求根本没发出去。
raise ServiceError(ServiceErrorCode.INVALID_REQUEST, str(exc)) from exc
log.info(
"已提交生成任务 job_id=%s provider_request_id=%s",
payload.job_id,
provider_request_id,
)
return SubmitResponse(provider_request_id=provider_request_id)
@router.get("/tasks/{provider_request_id}", response_model=TaskResponse)
def get_task(
provider_request_id: str,
request: Request,
media_kind: MediaKind = MediaKind.VIDEO,
) -> TaskResponse:
"""查询任务。
**任务失败也返回 200**:HTTP 调用成功了,失败的是任务,`state=failed` +
`failure` 表达它。做成非 2xx 会让「查不到任务」与「任务失败了」难以区分。
:param media_kind: 决定问哪个适配器。默认 video——当前只有视频适配器;
接入图片后调用方应显式传入。刻意不做「遍历所有适配器试一遍」:那会把
一个任务 ID 拿去问不相干的服务商。
"""
provider = _provider_for(_registry(request), media_kind)
try:
result = provider.fetch(provider_request_id)
except ArkError as exc:
# 服务商说任务不存在(含超过 7 天保留期)→ 这是 service 类的 task_not_found,
# 而不是 provider 错误:调用方要做的是停止轮询,不是退避重试。
if exc.http_status == 404:
raise ServiceError(
ServiceErrorCode.TASK_NOT_FOUND,
f"服务商无此任务(或已超出其 7 天保留期):{provider_request_id}",
) from exc
raise ProviderError(exc.failure) from exc
return TaskResponse.of(result)
+136
View File
@@ -0,0 +1,136 @@
"""请求/响应的 pydantic 模型。
字段名为 **snake_case**,与契约一致。这使 JSON、pydantic 模型、领域 dataclass
三者字段逐一对应,**无需任何转换层**——若用 camelCase,两端都要加转换。
这层与 `app/domain/generation.py` 的分工:领域模型是纯数据、无框架依赖(那是
「换供应商不动上层」的支点);本层负责 HTTP 边界的校验与序列化。两者字段重合,
但让领域层依赖 pydantic 会把框架拖进本该最干净的一层。
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field, model_validator
from app.domain.generation import (
GenerationRequest,
GenerationResult,
MediaKind,
)
class SubmitRequest(BaseModel):
"""提交生成任务。
**刻意不含 `user_id`**(数据最小化,ADR-023 §5):本服务干活只需要任务参数。
需要给服务商做滥用检测时传的是 `safety_identifier`——一个哈希,不是明文 ID。
"""
# 拒绝未声明的字段:调用方拼错字段名时应当立刻知道,而不是让那个参数被静默
# 忽略——后者会表现为「我传了 duration 却没生效」这类极难查的问题。
model_config = ConfigDict(extra="forbid")
job_id: str
media_kind: MediaKind
model: str = Field(min_length=1)
prompt: str | None = Field(default=None, max_length=10000)
input_url: str | None = None
ratio: str | None = None
resolution: str | None = None
duration_sec: int | None = None
generate_audio: bool = True
watermark: bool = False
safety_identifier: str | None = Field(default=None, max_length=64)
callback_url: str | None = None
upload_target: str | None = None
@model_validator(mode="after")
def require_prompt_or_input(self) -> SubmitRequest:
"""`prompt` 与 `input_url` 至少要有一个。
空内容必然被服务商拒,但那要花一次往返、还可能计入 RPM。本地先拦。
"""
if not self.prompt and not self.input_url:
raise ValueError("prompt 与 input_url 至少要有一个")
return self
def to_domain(self) -> GenerationRequest:
return GenerationRequest(
job_id=self.job_id,
media_kind=self.media_kind,
model=self.model,
prompt=self.prompt,
input_url=self.input_url,
ratio=self.ratio,
resolution=self.resolution,
duration_sec=self.duration_sec,
generate_audio=self.generate_audio,
watermark=self.watermark,
safety_identifier=self.safety_identifier,
callback_url=self.callback_url,
upload_target=self.upload_target,
)
class SubmitResponse(BaseModel):
provider_request_id: str
class FailureModel(BaseModel):
"""失败分类。三个字段各有明确消费方,见契约说明。"""
retry: str
user_facing: str
provider_code: str
message: str = ""
class TaskResponse(BaseModel):
"""任务当前状态。
注意 `state` 的取值集**比服务商的更小**:服务商的 `queued` 映射为 `pending`、
`expired` 归入 `failed`(它是超时,属失败的一种,重试策略由 `failure` 表达)。
"""
provider_request_id: str
state: str
progress: int
asset_url: str | None = None
asset_url_expires_in_sec: int | None = None
cover_url: str | None = None
ratio: str | None = None
resolution: str | None = None
width_px: int | None = None
height_px: int | None = None
duration_ms: int | None = None
output_format: str | None = None
billing_tokens: int | None = None
failure: FailureModel | None = None
@classmethod
def of(cls, result: GenerationResult) -> TaskResponse:
return cls(
provider_request_id=result.provider_request_id,
state=result.state.value,
progress=result.progress,
asset_url=result.asset_url,
asset_url_expires_in_sec=result.asset_url_expires_in_sec,
cover_url=result.cover_url,
ratio=result.ratio,
resolution=result.resolution,
width_px=result.width_px,
height_px=result.height_px,
duration_ms=result.duration_ms,
output_format=result.output_format,
billing_tokens=result.billing_tokens,
failure=(
FailureModel(
retry=result.failure.retry.value,
user_facing=result.failure.user_facing.value,
provider_code=result.failure.provider_code,
message=result.failure.message,
)
if result.failure
else None
),
)
+7
View File
@@ -34,6 +34,13 @@ class ProviderRegistry:
"""
return self._by_code.get(provider_code)
def all(self) -> list[GenerationProvider]:
"""全部已装配的适配器。
供「按 media_kind 找适配器」这类查询用——按能力而非按 code 检索时用它。
"""
return list(self._by_code.values())
@property
def codes(self) -> frozenset[str]:
return frozenset(self._by_code)