062e11b1ff
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>
137 lines
4.7 KiB
Python
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
|
|
),
|
|
)
|