Files
patbond-ai/app/providers/volcengine/video.py
T
lixi 4762358f54
CI / ai-gates (push) Failing after 29s
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

194 lines
7.8 KiB
Python

"""Seedance 视频生成:领域请求 ↔ 火山请求体的翻译。
本模块只管**视频生成这一个业务**:怎么拼 `content` 数组、状态怎么映射到领域状态、
失败怎么分类。HTTP 传输与鉴权在 `client.py`,厂商 DTO 在 `models.py`,错误码映射
在 `errors.py`。
以后接火山的**图片生成**时,应新建一个兄弟模块(如 `image.py`)复用同一个
`ArkClient` 与 `errors.py`,而不是在本文件里加分支——视频与图片的参数集、状态机、
产物形态都不同,混在一处会让两边都难改。
"""
from __future__ import annotations
import logging
from app.domain.generation import (
Failure,
GenerationRequest,
GenerationResult,
MediaKind,
Retry,
TaskState,
UserFacing,
)
from app.providers.base import GenerationProvider
from .client import ArkClient
from .models import (
ASSET_URL_TTL_SEC,
TASKS_PATH,
SeedanceStatus,
SeedanceSubmission,
SeedanceTask,
)
log = logging.getLogger(__name__)
#: `creation.generation_models.provider_code` 里对应本适配器的值。
PROVIDER_CODE = "volcengine-seedance"
class SeedanceVideoProvider(GenerationProvider):
"""火山方舟 Seedance 的视频生成适配器。"""
def __init__(
self,
client: ArkClient,
default_model: str = "",
task_expiry_sec: int = 3600,
):
self._client = client
self._default_model = default_model
self._task_expiry_sec = task_expiry_sec
@property
def provider_code(self) -> str:
return PROVIDER_CODE
@property
def media_kind(self) -> MediaKind:
return MediaKind.VIDEO
# ------------------------------------------------------------- submit
def submit(self, request: GenerationRequest) -> str:
if request.media_kind is not MediaKind.VIDEO:
# 路由错了。与其把一个图片请求塞给视频接口让厂商拒,不如就地说清楚。
raise ValueError(
f"{PROVIDER_CODE} 只处理视频生成,收到 {request.media_kind.value}"
)
submission = self._to_submission(request)
payload = self._client.post(TASKS_PATH, submission.to_body())
task_id = payload.get("id")
if not task_id:
# 2xx 但没有 ID:我们再也查不到这个任务,而它可能已经在计费。
# 绝不能当成功放过。
raise ValueError(f"火山方舟提交成功但响应缺少任务 ID:{payload!r}")
return str(task_id)
def _to_submission(self, request: GenerationRequest) -> SeedanceSubmission:
"""领域请求 → 厂商请求体。
模型取值优先用请求里的(来自任务行冻结的快照),仅在缺失时回落到配置的
默认值。**适配层不自行选模型**:选型是目录的职责,且任务行里的快照必须
与实际调用的模型一致,否则事后无法对账。
"""
model = request.model or self._default_model
if not model:
raise ValueError("未指定模型:请求未携带 model 且 ARK_VIDEO_MODEL 未配置")
return SeedanceSubmission(
model=model,
prompt=request.prompt,
first_frame_url=request.input_url,
ratio=request.ratio or SeedanceSubmission.ratio,
resolution=request.resolution or SeedanceSubmission.resolution,
duration=(
request.duration_sec
if request.duration_sec is not None
else SeedanceSubmission.duration
),
generate_audio=request.generate_audio,
watermark=request.watermark,
execution_expires_after=self._task_expiry_sec,
safety_identifier=request.safety_identifier,
callback_url=request.callback_url,
)
# -------------------------------------------------------------- fetch
def fetch(self, provider_request_id: str) -> GenerationResult:
payload = self._client.get(f"{TASKS_PATH}/{provider_request_id}")
task = SeedanceTask.from_payload(payload)
return self._to_result(task, provider_request_id)
def _to_result(self, task: SeedanceTask, request_id: str) -> GenerationResult:
"""厂商任务 → 领域结果。
状态映射见下。注意领域状态集比厂商的**更小**:queued/running 都还没结束,
expired 是失败的一种(重试策略由 `Failure` 表达,不需要一个独立状态)。
"""
state = self._map_state(task)
failure = self._map_failure(task)
if state is TaskState.SUCCEEDED and not task.video_url:
# 厂商说成功却没给 URL:这不是成功。当成可重试的上游异常,
# 而不是把一个没有产物的「成功」放给上层去空指针。
log.error("火山方舟任务 %s 状态为 succeeded 但缺少 video_url", task.id)
return GenerationResult(
provider_request_id=request_id,
state=TaskState.FAILED,
failure=Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
"MissingVideoUrl",
"厂商返回 succeeded 但 content.video_url 为空",
),
)
return GenerationResult(
provider_request_id=request_id,
state=state,
# 火山不返回百分比进度。这不是缺陷而是事实:客户端契约本就不承诺
# 平滑进度(ADR-023 §10),成功前一律 0、成功即 100。
progress=100 if state is TaskState.SUCCEEDED else 0,
asset_url=task.video_url,
asset_url_expires_in_sec=(ASSET_URL_TTL_SEC if task.video_url else None),
cover_url=task.last_frame_url,
ratio=task.ratio,
resolution=task.resolution,
# 像素尺寸留空:火山只返回 ratio 与 resolution,换算需要一张随模型族
# 变化的对照表。在 V7 目录种子定下模型族之前不编造尺寸。
duration_ms=task.duration_ms,
output_format=task.output_format,
failure=failure,
billing_tokens=task.completion_tokens,
)
@staticmethod
def _map_state(task: SeedanceTask) -> TaskState:
match task.status:
case SeedanceStatus.QUEUED:
return TaskState.PENDING
case SeedanceStatus.RUNNING:
return TaskState.RUNNING
case SeedanceStatus.SUCCEEDED:
return TaskState.SUCCEEDED
case SeedanceStatus.CANCELLED:
return TaskState.CANCELLED
case SeedanceStatus.FAILED | SeedanceStatus.EXPIRED:
return TaskState.FAILED
case _:
# 未知状态当作「还在跑」:厂商新增状态时把在途任务判死更糟,
# 最坏是等到我们自己的超时。
log.warning("火山方舟返回未知任务状态:%r", task.raw_status)
return TaskState.RUNNING
@staticmethod
def _map_failure(task: SeedanceTask) -> Failure | None:
from .errors import classify
if task.status is SeedanceStatus.EXPIRED:
# 超时不是内容或参数问题,可重试。单独处理是因为厂商在 expired 时
# 不一定给 error.code,走通用分类会被当成「无码」。
return Failure(
Retry.BACKOFF,
UserFacing.UPSTREAM_UNAVAILABLE,
"TaskExpired",
"任务超过 execution_expires_after 被厂商终止",
)
if task.status is SeedanceStatus.FAILED:
return classify(task.error_code, task.error_message)
return None