Files
meijiaka-zy/python-api/app/api/v1/vidu.py
T
小鱼开发 e58159fc42 refactor: 第三方平台架构改造(Adapter Protocol + Gateway)
Phase 1: 异常体系统一
- 新增 PlatformError / PlatformErrorType 标准定义
- 改造所有 Provider 异常抛出为 PlatformError
- 注册全局 PlatformError exception handler

Phase 2: Adapter Protocol
- 新增 app/ai/adapters/base.py(PlatformAdapter + SyncCapable + TaskCapable + CallbackCapable)
- 新增 app/ai/adapters/constants.py(Method 常量)
- 新增 PlatformConfigLoader(config/platform-config.yaml)

Phase 3: HTTP Client 统一
- ViduProvider 从 aiohttp 迁移到 httpx(注入方式)
- VolcengineCaptionService 改为注入 http_client
- lifespan 统一管理所有 Client 创建和关闭

Phase 4: Gateway 骨架 + Adapter 实现
- 新增 ViduAdapter / VolcengineArkAdapter / VolcengineCaptionAdapter
- 新增 PlatformGateway(call_sync / submit_task / query_task / handle_webhook)
- 新增 LLMGateway(带 Fallback 降级链)
- lifespan 注册所有 Adapter 和 Gateway

Phase 6: 清理与验证
- 从 Settings 移除 VIDU_BASE_URL / VOLCENGINE_BASE_URL
- Provider 改为从 PlatformConfigLoader 读取 base_url
- 清理 volcengine_caption_service 全局单例
- config_loader 默认路径改为 platform-config.yaml
- Scheduler 注入共享 HTTP client
- vidu.py 回调路由使用 Adapter 验签和解析
- ruff 全量通过,应用启动测试通过
2026-05-04 16:07:16 +08:00

470 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Vidu API 代理路由
================
提供 Vidu 对口型(lip-sync)任务的提交、查询和回调接口。
前端通过此接口提交任务并轮询状态,无需直接访问 Vidu API。
"""
import base64
import hashlib
import hmac
import json
import logging
import time
from urllib.parse import urlparse
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, Field
from app.api.deps import get_current_user
from app.config import get_settings
from app.core.exceptions import PlatformError
from app.core.redis_client import get_redis_client
from app.models.user import User
from app.platform_gateway import PlatformGateway
from app.schemas.common import ApiResponse, success_response
from app.services.vidu_service import ViduService, get_vidu_service
def get_platform_gateway(request: Request) -> PlatformGateway:
"""从 app.state 获取 PlatformGateway"""
return request.app.state.platform_gateway
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/vidu", tags=["Vidu"])
# ========== 请求/响应模型 ==========
class LipSyncRequest(BaseModel):
"""对口型请求"""
video_url: str = Field(..., min_length=1, description="原视频 URL")
audio_url: str | None = Field(None, description="音频 URL(与 text 二选一)")
text: str | None = Field(None, description="文本内容(与 audio_url 二选一)")
voice_id: str | None = Field(None, description="音色 ID(文字驱动时生效)")
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="语速")
volume: int = Field(default=0, ge=0, le=10, description="音量")
ref_photo_url: str | None = Field(None, description="人脸参考图 URL")
@staticmethod
def validate_at_least_one_audio_source(values: dict) -> dict:
"""验证至少提供 audio_url 或 text 之一"""
audio_url = values.get("audio_url")
text = values.get("text")
if not audio_url and not text:
raise ValueError("必须提供 audio_url 或 text 之一")
return values
class LipSyncResponse(BaseModel):
"""对口型任务提交响应"""
task_id: str = Field(..., description="Vidu 任务 ID")
message: str = Field(default="任务已提交", description="状态消息")
class LipSyncQueryResponse(BaseModel):
"""对口型任务查询响应"""
task_id: str = Field(..., description="任务 ID")
state: str = Field(..., description="任务状态: pending/processing/success/failed")
video_url: str | None = Field(None, description="生成后的视频 URL(成功时)")
message: str | None = Field(None, description="状态描述或错误信息")
creations: list[dict] | None = Field(None, description="Vidu 原始 creations 数据")
class LipSyncCallbackRequest(BaseModel):
"""Vidu 对口型任务回调请求"""
task_id: str = Field(..., description="任务 ID")
state: str = Field(..., description="任务状态")
creations: list[dict] | None = Field(None, description="生成物列表")
message: str | None = Field(None, description="错误信息")
class LipSyncStatusResponse(BaseModel):
"""对口型任务状态查询响应(供前端轮询)"""
task_id: str = Field(..., description="任务 ID")
state: str = Field(..., description="任务状态")
video_url: str | None = Field(None, description="生成后的视频 URL")
message: str | None = Field(None, description="错误信息")
updated_at: float = Field(..., description="状态更新时间戳")
# ========== 回调签名验证 ==========
def _build_vidu_signing_string(
http_method: str,
callback_url: str,
date: str,
headers: dict[str, str],
header_order: list[str],
) -> str:
"""构建 Vidu 回调签名字符串(signingString)。
格式:
http_method + "\n"
+ http_uri + "\n"
+ canonical_query_string + "\n"
+ access_key + "\n"
+ Date + "\n"
+ signed_headers_string
其中:
- http_uri: 从 callback_url 解析出的 path(必须以 "/" 开头)
- canonical_query_string: 从 callback_url 解析出的原始 query(不含 "?"
- access_key: 固定为 "vidu"
- signed_headers_string: 按 X-HMAC-SIGNED-HEADERS 顺序拼接
HeaderKey:HeaderValue + "\n"
"""
parsed = urlparse(callback_url)
http_uri = parsed.path or "/"
canonical_query_string = parsed.query or ""
signing_string = (
f"{http_method.upper()}\n"
f"{http_uri}\n"
f"{canonical_query_string}\n"
f"vidu\n"
f"{date}\n"
)
for header_name in header_order:
value = headers.get(header_name, "")
signing_string += f"{header_name}:{value}\n"
return signing_string
async def _verify_vidu_callback(request: Request) -> bool:
"""验证 Vidu 回调请求的 HMAC-SHA256 签名,并检查 nonce 防重放。
Returns:
True: 签名验证通过且 nonce 未重复使用
False: 验证失败(已记录日志)
"""
settings = get_settings()
secret_key = settings.VIDU_API_KEY
if not secret_key:
logger.warning("[Vidu] VIDU_API_KEY 未配置,跳过回调签名验证")
return False
# 1. 读取签名相关 header
signature = request.headers.get("X-HMAC-SIGNATURE")
algorithm = request.headers.get("X-HMAC-ALGORITHM")
access_key = request.headers.get("X-HMAC-ACCESS-KEY")
signed_headers_str = request.headers.get("X-HMAC-SIGNED-HEADERS")
date = request.headers.get("Date")
nonce = request.headers.get("x-request-nonce")
# 2. 基础校验
if not all([signature, algorithm, access_key, signed_headers_str, date, nonce]):
logger.warning("[Vidu] 回调缺少必要签名头")
return False
if algorithm != "hmac-sha256":
logger.warning(f"[Vidu] 回调签名算法不匹配: {algorithm}")
return False
if access_key != "vidu":
logger.warning(f"[Vidu] 回调 access_key 不匹配: {access_key}")
return False
# 3. 防重放:检查 nonce 是否已使用
redis = get_redis_client()
nonce_key = f"vidu:callback_nonce:{nonce}"
nonce_exists = await redis.exists(nonce_key)
if nonce_exists:
logger.warning(f"[Vidu] 回调 nonce 已使用,可能为重放攻击: {nonce}")
return False
# 4. 解析签名头顺序并提取对应 header 值
header_names = [h.strip() for h in signed_headers_str.split(";") if h.strip()]
if not header_names:
logger.warning("[Vidu] 回调 X-HMAC-SIGNED-HEADERS 为空")
return False
headers: dict[str, str] = {}
for header_name in header_names:
value = request.headers.get(header_name)
if value is None:
logger.warning(f"[Vidu] 回调缺少签名头: {header_name}")
return False
headers[header_name] = value
# 5. 构建 signingString(使用当前请求 URL 提取 path/query
callback_url = str(request.url)
signing_string = _build_vidu_signing_string(
http_method=request.method,
callback_url=callback_url,
date=date,
headers=headers,
header_order=header_names,
)
# 6. 计算期望签名
expected = base64.b64encode(
hmac.new(
secret_key.encode("utf-8"),
signing_string.encode("utf-8"),
hashlib.sha256,
).digest()
).decode("utf-8")
# 7. 安全比对(防时序攻击)
if not hmac.compare_digest(signature, expected):
logger.warning("[Vidu] 回调签名验证失败")
return False
# 8. 标记 nonce 已使用(TTL 5 分钟)
await redis.setex(nonce_key, 300, "1")
logger.info(f"[Vidu] 回调签名验证通过,nonce={nonce}")
return True
# ========== API 路由 ==========
@router.post("/lip-sync", response_model=ApiResponse[LipSyncResponse])
async def create_lip_sync_task(
request: LipSyncRequest,
current_user: User = Depends(get_current_user),
service: ViduService = Depends(get_vidu_service),
):
"""
提交 Vidu 对口型任务
后端自动拼接 callback_url,Vidu 任务完成后会主动通知。
前端通过 /vidu/tasks/{task_id}/status 轮询状态。
"""
try:
# 验证至少提供一种音频来源
if not request.audio_url and not request.text:
raise HTTPException(
status_code=400,
detail="必须提供 audio_url 或 text 之一",
)
settings = get_settings()
callback_url = f"{settings.app_base_url}/api/v1/vidu/callback"
task_id = await service.lip_sync_create(
video_url=request.video_url,
audio_url=request.audio_url,
text=request.text,
voice_id=request.voice_id,
speed=request.speed,
volume=request.volume,
ref_photo_url=request.ref_photo_url,
callback_url=callback_url,
)
# 初始化任务状态到 Redis(供前端轮询)
redis = get_redis_client()
await redis.setex(
f"vidu:lipsync:{task_id}",
3600,
json.dumps({
"state": "processing",
"video_url": None,
"message": None,
"updated_at": time.time(),
}),
)
logger.info(f"[Vidu] 对口型任务提交成功: task_id={task_id}, user={current_user.id}, callback={callback_url}")
return success_response(
data=LipSyncResponse(
task_id=task_id,
message="对口型任务已提交",
)
)
except HTTPException:
raise
except PlatformError:
raise
except Exception as e:
logger.error(f"[Vidu] 提交对口型任务失败: {e}")
raise HTTPException(status_code=500, detail=f"提交对口型任务失败: {e}")
@router.post("/callback")
async def vidu_callback(request: Request):
"""
Vidu 对口型任务完成回调
Vidu 任务完成后主动 POST 通知此接口。
无需登录校验(Vidu 外部调用),但需通过 HMAC-SHA256 签名验证 + nonce 防重放。
"""
settings = get_settings()
secret_key = settings.VIDU_API_KEY
# 1. 基础 header 校验
signature = request.headers.get("X-HMAC-SIGNATURE")
algorithm = request.headers.get("X-HMAC-ALGORITHM")
access_key = request.headers.get("X-HMAC-ACCESS-KEY")
signed_headers_str = request.headers.get("X-HMAC-SIGNED-HEADERS")
date = request.headers.get("Date")
nonce = request.headers.get("x-request-nonce")
if not all([signature, algorithm, access_key, signed_headers_str, date, nonce]):
logger.warning("[Vidu] 回调缺少必要签名头")
raise HTTPException(status_code=401, detail="回调签名验证失败")
if algorithm != "hmac-sha256" or access_key != "vidu":
logger.warning("[Vidu] 回调签名算法或 access_key 不匹配")
raise HTTPException(status_code=401, detail="回调签名验证失败")
# 2. nonce 防重放检查
redis = get_redis_client()
nonce_key = f"vidu:callback_nonce:{nonce}"
if await redis.exists(nonce_key):
logger.warning(f"[Vidu] 回调 nonce 已使用,可能为重放攻击: {nonce}")
raise HTTPException(status_code=401, detail="回调签名验证失败")
# 3. HMAC 签名验证(统一走 Adapter
if secret_key:
adapter = request.app.state.vidu_adapter
headers_dict = dict(request.headers)
body_bytes = await request.body()
if not await adapter.verify_signature(
headers_dict, body_bytes, secret_key, callback_url=str(request.url)
):
logger.warning("[Vidu] 回调 HMAC 签名验证失败")
raise HTTPException(status_code=401, detail="回调签名验证失败")
# 4. 标记 nonce 已使用(TTL 5 分钟)
await redis.setex(nonce_key, 300, "1")
# 5. 解析回调体(统一走 Adapter)
try:
task_status = await request.app.state.vidu_adapter.parse_callback(body_bytes)
except Exception as e:
logger.error(f"[Vidu] 回调解析失败: {e}")
raise HTTPException(status_code=500, detail=f"回调解析失败: {e}")
# 6. 更新 Redis 状态
task_id = task_status.result.get("task_id") if task_status.result else None
video_url = task_status.result.get("video_url") if task_status.result else None
await redis.setex(
f"vidu:lipsync:{task_id}",
3600,
json.dumps({
"state": task_status.state,
"video_url": video_url,
"message": task_status.error_message,
"updated_at": time.time(),
}),
)
logger.info(f"[Vidu] 回调接收: task_id={task_id}, state={task_status.state}")
return success_response(message="回调已接收")
@router.get("/tasks/{task_id}/status", response_model=ApiResponse[LipSyncStatusResponse])
async def query_lip_sync_status(
task_id: str,
current_user: User = Depends(get_current_user),
service: ViduService = Depends(get_vidu_service),
):
"""
查询对口型任务状态(供前端轮询)
优先从 Redis 读取状态(由回调更新),
Redis 无数据时回退到直接查询 Vidu API。
"""
try:
redis = get_redis_client()
cached = await redis.get(f"vidu:lipsync:{task_id}")
if cached:
data = json.loads(cached)
return success_response(
data=LipSyncStatusResponse(
task_id=task_id,
state=data.get("state", "unknown"),
video_url=data.get("video_url"),
message=data.get("message"),
updated_at=data.get("updated_at", 0),
)
)
# Redis 无缓存,回退到直接查询 Vidu
result = await service.lip_sync_query(task_id)
state = result.get("state", "unknown")
creations = result.get("creations", [])
video_url = None
if state == "success" and creations:
first = creations[0] if creations else {}
video_url = first.get("url")
return success_response(
data=LipSyncStatusResponse(
task_id=task_id,
state=state,
video_url=video_url,
message=result.get("message"),
updated_at=time.time(),
)
)
except PlatformError:
raise
except Exception as e:
logger.error(f"[Vidu] 查询任务状态失败: {e}")
raise HTTPException(status_code=500, detail=f"查询任务状态失败: {e}")
@router.get("/tasks/{task_id}/creations", response_model=ApiResponse[LipSyncQueryResponse])
async def query_lip_sync_task(
task_id: str,
current_user: User = Depends(get_current_user),
service: ViduService = Depends(get_vidu_service),
):
"""
直接查询 Vidu 对口型任务状态(保留兼容)
前端优先使用 /tasks/{task_id}/status(走 Redis 缓存)。
"""
try:
result = await service.lip_sync_query(task_id)
state = result.get("state", "unknown")
creations = result.get("creations", [])
# 提取视频 URL(成功时)
video_url = None
if state == "success" and creations:
first_creation = creations[0] if creations else {}
video_url = first_creation.get("url")
logger.info(
f"[Vidu] 查询对口型任务: task_id={task_id}, state={state}, user={current_user.id}"
)
return success_response(
data=LipSyncQueryResponse(
task_id=task_id,
state=state,
video_url=video_url,
message=result.get("message") if state == "failed" else None,
creations=creations if creations else None,
)
)
except PlatformError:
raise
except Exception as e:
logger.error(f"[Vidu] 查询对口型任务失败: {e}")
raise HTTPException(status_code=500, detail=f"查询任务失败: {e}")