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