04e467e433
后端: - 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试 - recharge() 加 order_id 幂等保护,防重复充值 - time_expire 使用北京时间(UTC+8),修复时区 bug - 充值档位后端配置化(points-config.yaml + /recharge-options API) - 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等) 前端: - 充值弹窗:自动轮询 + 【我已支付】手动兜底 - 二维码倒计时显示,过期后遮罩 + 刷新按钮 - 充值档位从后端动态加载 - 去掉 select/qrcode 弹窗标题,金额红色突出显示 - 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等) - Modal 关闭按钮独立于 title 显示
277 lines
9.7 KiB
Python
277 lines
9.7 KiB
Python
"""
|
||
Vidu API Provider
|
||
=================
|
||
|
||
封装 Vidu 语音/视频相关 HTTP API:
|
||
- 同步 TTS(/ent/v2/audio-tts)
|
||
- 声音复刻(/ent/v2/audio-clone)
|
||
- 视频生成(/ent/v2/lip-sync)
|
||
- 查询任务(/ent/v2/tasks/{id}/creations)
|
||
|
||
统一使用 httpx.AsyncClient,由 lifespan 统一管理生命周期。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from typing import Any
|
||
|
||
import httpx
|
||
|
||
from app.config import get_settings
|
||
from app.core.exceptions import PlatformError, PlatformErrorType
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _map_vidu_error(status: int, message: str) -> PlatformError:
|
||
"""把 Vidu HTTP 错误映射为标准 PlatformError"""
|
||
mapping = {
|
||
429: (PlatformErrorType.RATE_LIMIT, True),
|
||
401: (PlatformErrorType.AUTH_FAILED, False),
|
||
403: (PlatformErrorType.AUTH_FAILED, False),
|
||
400: (PlatformErrorType.BAD_REQUEST, False),
|
||
404: (PlatformErrorType.NOT_FOUND, False),
|
||
500: (PlatformErrorType.SERVER_ERROR, True),
|
||
502: (PlatformErrorType.SERVER_ERROR, True),
|
||
503: (PlatformErrorType.SERVER_ERROR, True),
|
||
}
|
||
error_type, retryable = mapping.get(status, (PlatformErrorType.UNKNOWN, False))
|
||
return PlatformError(
|
||
message=message,
|
||
platform="vidu",
|
||
retryable=retryable,
|
||
error_type=error_type,
|
||
status_code=status,
|
||
)
|
||
|
||
|
||
class ViduProvider:
|
||
"""Vidu API 客户端封装
|
||
|
||
使用 httpx.AsyncClient,支持外部注入(由 lifespan 管理生命周期)。
|
||
"""
|
||
|
||
def __init__(
|
||
self,
|
||
api_key: str | None = None,
|
||
base_url: str | None = None,
|
||
client: httpx.AsyncClient | None = None,
|
||
):
|
||
settings = get_settings()
|
||
self.api_key = api_key or settings.VIDU_API_KEY
|
||
if base_url:
|
||
self.base_url = base_url.rstrip("/")
|
||
else:
|
||
from app.core.platform_config import get_platform_config_loader
|
||
|
||
platform_config = get_platform_config_loader().get_platform("vidu")
|
||
self.base_url = (platform_config.base_url if platform_config else "https://api.vidu.cn").rstrip("/")
|
||
|
||
if not self.api_key:
|
||
raise ValueError("Vidu API Key 未配置,请在 .env 中设置 VIDU_API_KEY")
|
||
|
||
if client is not None:
|
||
self.client = client
|
||
self._owns_client = False
|
||
# 外部传入的 client 也要补认证头(main.py / scheduler 共用 client 场景)
|
||
self.client.headers["Authorization"] = f"Token {self.api_key}"
|
||
self.client.headers["Content-Type"] = "application/json"
|
||
else:
|
||
self.client = httpx.AsyncClient(
|
||
timeout=httpx.Timeout(30.0, connect=5.0),
|
||
limits=httpx.Limits(max_connections=20, max_keepalive_connections=20),
|
||
headers={
|
||
"Authorization": f"Token {self.api_key}",
|
||
"Content-Type": "application/json",
|
||
},
|
||
)
|
||
self._owns_client = True
|
||
|
||
async def close(self) -> None:
|
||
"""关闭 HTTP Client,释放连接池。仅在自己创建 Client 时关闭。"""
|
||
if self._owns_client and not self.client.is_closed:
|
||
await self.client.aclose()
|
||
|
||
# ==================== TTS 语音合成 ====================
|
||
|
||
async def tts_sync(
|
||
self,
|
||
text: str,
|
||
voice_id: str,
|
||
speed: float = 1.0,
|
||
volume: int = 0,
|
||
pitch: int = 0,
|
||
emotion: str | None = None,
|
||
pronunciation_dict_tone: list[str] | None = None,
|
||
payload: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""同步语音合成
|
||
|
||
POST /ent/v2/audio-tts
|
||
"""
|
||
url = f"{self.base_url}/ent/v2/audio-tts"
|
||
|
||
body: dict[str, Any] = {
|
||
"text": text,
|
||
"voice_setting_voice_id": voice_id,
|
||
"voice_setting_speed": speed,
|
||
"voice_setting_volume": volume,
|
||
"voice_setting_pitch": pitch,
|
||
}
|
||
if emotion:
|
||
body["voice_setting_emotion"] = emotion
|
||
if pronunciation_dict_tone:
|
||
body["pronunciation_dict_tone"] = pronunciation_dict_tone
|
||
if payload:
|
||
body["payload"] = payload
|
||
|
||
logger.info(f"[Vidu TTS] 请求参数: text_length={len(text)}")
|
||
|
||
logger.info(f"[Vidu LipSync] 提交请求: url={url}, body={body}")
|
||
|
||
try:
|
||
resp = await self.client.post(url, json=body)
|
||
data = resp.json()
|
||
if resp.status_code != 200 or data.get("state") == "failed":
|
||
msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||
logger.error(f"[Vidu TTS] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||
raise _map_vidu_error(resp.status_code, f"Vidu TTS error: {msg}")
|
||
return data
|
||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||
logger.error(f"[Vidu TTS] 网络错误: {e}")
|
||
raise PlatformError(
|
||
f"Vidu TTS 网络错误: {e}",
|
||
platform="vidu",
|
||
retryable=True,
|
||
error_type=PlatformErrorType.TIMEOUT,
|
||
) from e
|
||
|
||
# ==================== 声音复刻 ====================
|
||
|
||
async def clone_voice(
|
||
self,
|
||
audio_url: str,
|
||
voice_id: str,
|
||
text: str,
|
||
prompt_audio_url: str | None = None,
|
||
prompt_text: str | None = None,
|
||
payload: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""声音复刻(同步接口)
|
||
|
||
POST /ent/v2/audio-clone
|
||
"""
|
||
url = f"{self.base_url}/ent/v2/audio-clone"
|
||
|
||
body: dict[str, Any] = {
|
||
"audio_url": audio_url,
|
||
"voice_id": voice_id,
|
||
"text": text,
|
||
}
|
||
if prompt_audio_url:
|
||
body["prompt_audio_url"] = prompt_audio_url
|
||
if prompt_text:
|
||
body["prompt_text"] = prompt_text
|
||
if payload:
|
||
body["payload"] = payload
|
||
|
||
try:
|
||
resp = await self.client.post(url, json=body)
|
||
data = resp.json()
|
||
if resp.status_code != 200 or data.get("state") == "failed":
|
||
msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||
logger.error(f"[Vidu Clone] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||
raise _map_vidu_error(resp.status_code, f"Vidu clone error: {msg}")
|
||
return data
|
||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||
logger.error(f"[Vidu Clone] 网络错误: {e}")
|
||
raise PlatformError(
|
||
f"Vidu Clone 网络错误: {e}",
|
||
platform="vidu",
|
||
retryable=True,
|
||
error_type=PlatformErrorType.TIMEOUT,
|
||
) from e
|
||
|
||
# ==================== 视频生成 ====================
|
||
|
||
async def lip_sync(
|
||
self,
|
||
video_url: str,
|
||
audio_url: str | None = None,
|
||
text: str | None = None,
|
||
voice_id: str | None = None,
|
||
speed: float = 1.0,
|
||
volume: int = 0,
|
||
ref_photo_url: str | None = None,
|
||
callback_url: str | None = None,
|
||
payload: str | None = None,
|
||
) -> dict[str, Any]:
|
||
"""视频生成(异步接口)
|
||
|
||
POST /ent/v2/lip-sync
|
||
"""
|
||
url = f"{self.base_url}/ent/v2/lip-sync"
|
||
|
||
body: dict[str, Any] = {"video_url": video_url}
|
||
|
||
if audio_url:
|
||
body["audio_url"] = audio_url
|
||
if text:
|
||
body["text"] = text
|
||
if voice_id:
|
||
body["voice_id"] = voice_id
|
||
if speed != 1.0:
|
||
body["speed"] = speed
|
||
if volume != 0:
|
||
body["volume"] = volume
|
||
if ref_photo_url:
|
||
body["ref_photo_url"] = ref_photo_url
|
||
if callback_url:
|
||
body["callback_url"] = callback_url
|
||
if payload:
|
||
body["payload"] = payload
|
||
|
||
try:
|
||
resp = await self.client.post(url, json=body)
|
||
data = resp.json()
|
||
if resp.status_code != 200 or data.get("state") == "failed":
|
||
msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||
logger.error(f"[Vidu LipSync] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||
raise _map_vidu_error(resp.status_code, f"Vidu lip-sync error: {msg}")
|
||
return data
|
||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||
logger.error(f"[Vidu LipSync] 网络错误: {e}")
|
||
raise PlatformError(
|
||
f"Vidu LipSync 网络错误: {e}",
|
||
platform="vidu",
|
||
retryable=True,
|
||
error_type=PlatformErrorType.TIMEOUT,
|
||
) from e
|
||
|
||
# ==================== 查询任务 ====================
|
||
|
||
async def query_task(self, task_id: str) -> dict[str, Any]:
|
||
"""查询任务状态及生成物
|
||
|
||
GET /ent/v2/tasks/{task_id}/creations
|
||
"""
|
||
url = f"{self.base_url}/ent/v2/tasks/{task_id}/creations"
|
||
|
||
try:
|
||
resp = await self.client.get(url)
|
||
data = resp.json()
|
||
if resp.status_code != 200:
|
||
msg = data.get("err_code") or data.get("message") or f"HTTP {resp.status_code}"
|
||
logger.error(f"[Vidu Query] 请求失败: url={url}, status={resp.status_code}, response={data}")
|
||
raise _map_vidu_error(resp.status_code, f"Vidu query task error: {msg}")
|
||
return data
|
||
except (httpx.NetworkError, httpx.TimeoutException) as e:
|
||
logger.error(f"[Vidu Query] 网络错误: {e}")
|
||
raise PlatformError(
|
||
f"Vidu Query 网络错误: {e}",
|
||
platform="vidu",
|
||
retryable=True,
|
||
error_type=PlatformErrorType.TIMEOUT,
|
||
) from e
|