e58159fc42
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 全量通过,应用启动测试通过
470 lines
16 KiB
Python
470 lines
16 KiB
Python
"""
|
||
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}")
|