04e467e433
后端: - 微信回调 db.commit 失败仍返回 SUCCESS,避免无限重试 - recharge() 加 order_id 幂等保护,防重复充值 - time_expire 使用北京时间(UTC+8),修复时区 bug - 充值档位后端配置化(points-config.yaml + /recharge-options API) - 代码审查 20 项修复(认证加固、扣费顺序、错误响应、状态同步等) 前端: - 充值弹窗:自动轮询 + 【我已支付】手动兜底 - 二维码倒计时显示,过期后遮罩 + 刷新按钮 - 充值档位从后端动态加载 - 去掉 select/qrcode 弹窗标题,金额红色突出显示 - 全项目命名统一(视频生成/压制成片/配音合成/声音复刻等) - Modal 关闭按钮独立于 title 显示
309 lines
13 KiB
Python
309 lines
13 KiB
Python
"""
|
|
Video 任务处理器
|
|
================
|
|
|
|
管理 Vidu 视频生成任务的提交与轮询。
|
|
采用提交 + 轮询两阶段设计。
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any
|
|
|
|
from app.core.platform_config import get_platform_config_loader
|
|
from app.db.session import AsyncSessionLocal
|
|
from app.scheduler.handlers.base import AsyncHandler
|
|
from app.scheduler.models import StateChange
|
|
from app.scheduler.registry import TaskRegistry
|
|
from app.scheduler.slot_manager import SlotManager
|
|
from app.services import point_service as ps
|
|
from app.services.vidu_service import ViduService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SLOT_KEY = "vidu:video_slots"
|
|
|
|
|
|
def _get_video_max_slots() -> int:
|
|
"""从 platform-config.yaml 读取 rate_limit 配置作为 max_slots"""
|
|
try:
|
|
loader = get_platform_config_loader()
|
|
platform = loader.get_platform("vidu")
|
|
if platform and "lip_sync" in platform.methods:
|
|
return int(platform.methods["lip_sync"].rate_limit_qps)
|
|
if platform:
|
|
return int(platform.rate_limit_qps)
|
|
except Exception:
|
|
pass
|
|
return 5
|
|
|
|
|
|
class VideoHandler(AsyncHandler):
|
|
name = "video"
|
|
slot_key = SLOT_KEY
|
|
max_slots = _get_video_max_slots()
|
|
|
|
def __init__(self, service: ViduService | None = None):
|
|
self.service = service
|
|
|
|
def _get_service(self) -> ViduService:
|
|
if self.service is None:
|
|
raise RuntimeError(
|
|
"VideoHandler 需要通过构造函数传入 ViduService 实例"
|
|
)
|
|
return self.service
|
|
|
|
async def _deduct_video_points(self, task: Any, params: dict[str, Any]) -> None:
|
|
"""视频生成后置扣费(callback 成功和主动查询成功共用)"""
|
|
try:
|
|
duration = float(params.get("duration", 0) or 0)
|
|
if duration > 0:
|
|
async with AsyncSessionLocal() as db:
|
|
points = ps._calculate_cost("video", {"seconds": duration})
|
|
await ps.consume(
|
|
db,
|
|
user_id=task.user_id,
|
|
points=points,
|
|
source_type="video",
|
|
source_id=task.task_id,
|
|
description="【视频生成】",
|
|
duration=duration,
|
|
)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.error(f"[Video {task.task_id}] 扣费失败: {e}")
|
|
|
|
async def tick(
|
|
self, tasks: list[Any], registry: TaskRegistry, slots: SlotManager
|
|
) -> list[StateChange]:
|
|
changes: list[StateChange] = []
|
|
|
|
for task in tasks:
|
|
params = task.params or {}
|
|
vidu_task_id = params.get("vidu_task_id")
|
|
|
|
if vidu_task_id:
|
|
# 轮询阶段:优先检查 callback 结果,fallback 主动查询 Vidu API
|
|
result_data = task.result or {}
|
|
if result_data.get("video_url") or result_data.get("state") == "success":
|
|
# callback 已到达,结果已写入 TaskRegistry
|
|
await self._deduct_video_points(task, params)
|
|
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="status",
|
|
value="completed",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="message",
|
|
value="视频生成完成",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="completed",
|
|
value=1,
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id, field_path="total", value=1
|
|
)
|
|
)
|
|
elif task.status == "failed":
|
|
# callback 已标记失败,移除 running
|
|
await registry.remove_running(task.task_id)
|
|
else:
|
|
# callback 尚未到达,fallback:主动查询 Vidu API
|
|
try:
|
|
service = self._get_service()
|
|
vidu_status = await service.lip_sync_query(task.task_id)
|
|
vidu_state = vidu_status.get("state", "")
|
|
creations = vidu_status.get("creations", [])
|
|
video_url = creations[0].get("url") if creations else None
|
|
|
|
if vidu_state == "success" and video_url:
|
|
await self._deduct_video_points(task, params)
|
|
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="status",
|
|
value="completed",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="message",
|
|
value="视频生成完成",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="completed",
|
|
value=1,
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="total",
|
|
value=1,
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="result",
|
|
value={
|
|
"video_url": video_url,
|
|
"state": "success",
|
|
},
|
|
)
|
|
)
|
|
# 移除 running set
|
|
await registry.remove_running(task.task_id)
|
|
logger.info(
|
|
f"[Video {task.task_id}] 主动查询 Vidu 任务已完成: "
|
|
f"video_url={video_url[:60]}..."
|
|
)
|
|
elif vidu_state == "failed":
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="status",
|
|
value="failed",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="message",
|
|
value="视频生成失败",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="error",
|
|
value=vidu_status.get("message") or "视频生成失败",
|
|
)
|
|
)
|
|
await registry.remove_running(task.task_id)
|
|
logger.warning(
|
|
f"[Video {task.task_id}] 主动查询 Vidu 任务失败: "
|
|
f"{vidu_status.get('message')}"
|
|
)
|
|
else:
|
|
# 仍在处理中,继续等待
|
|
logger.debug(
|
|
f"[Video {task.task_id}] 主动查询 Vidu 状态: {vidu_state},继续等待"
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"[Video {task.task_id}] 主动查询 Vidu 失败: {e}"
|
|
)
|
|
continue # ← 已提交,不再重复提交
|
|
|
|
# 提交阶段:占用 slot,提交成功后自动释放
|
|
async with slots.acquire_ctx(
|
|
SLOT_KEY, task.task_id, self.max_slots
|
|
) as acquired:
|
|
if not acquired:
|
|
continue
|
|
|
|
try:
|
|
service = self._get_service()
|
|
|
|
# 自动构建回调地址(前端未传时兜底)
|
|
callback_url = params.get("callback_url")
|
|
if not callback_url:
|
|
from app.config import get_settings
|
|
|
|
base_url = get_settings().app_base_url
|
|
if base_url:
|
|
callback_url = f"{base_url}/api/v1/vidu/callback"
|
|
else:
|
|
raise ValueError("callback_url 未配置且无法自动推断应用地址")
|
|
|
|
logger.info(
|
|
f"[Video {task.task_id}] 准备提交 Vidu 视频生成: "
|
|
f"callback_url={callback_url}, video_url={params.get('video_url', '')[:60]}..."
|
|
)
|
|
vidu_task_id = await service.lip_sync_create(
|
|
video_url=params.get("video_url", ""),
|
|
audio_url=params.get("audio_url"),
|
|
text=params.get("text"),
|
|
voice_id=params.get("voice_id"),
|
|
speed=float(params.get("speed", 1.0)),
|
|
volume=int(params.get("volume", 0)),
|
|
ref_photo_url=params.get("ref_photo_url"),
|
|
callback_url=callback_url,
|
|
task_id=task.task_id,
|
|
)
|
|
if not vidu_task_id:
|
|
raise ValueError("未返回任务ID")
|
|
params["vidu_task_id"] = vidu_task_id
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id, field_path="params", value=params
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="message",
|
|
value="视频生成任务已提交",
|
|
)
|
|
)
|
|
except RuntimeError:
|
|
logger.error(f"[Video {task.task_id}] service not initialized")
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id, field_path="status", value="failed"
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="message",
|
|
value="视频处理服务未就绪",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="error",
|
|
value="视频处理服务未就绪",
|
|
)
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"[Video {task.task_id}] submit error: {e}")
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id, field_path="status", value="failed"
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="message",
|
|
value="视频生成任务提交失败,请稍后重试",
|
|
)
|
|
)
|
|
changes.append(
|
|
StateChange(
|
|
task_id=task.task_id,
|
|
field_path="error",
|
|
value="视频生成任务提交失败",
|
|
)
|
|
)
|
|
|
|
return changes
|