Files
patbond-ai/app/api/schemas.py
T
lixi 062e11b1ff
CI / ai-gates (push) Successful in 39s
feat(ai): 内部 HTTP 端点 + 服务票校验 + 契约漂移守卫(166 测试)
按 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>
2026-09-20 16:59:44 +08:00

137 lines
4.7 KiB
Python

"""请求/响应的 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
),
)