feat(points): 积分消耗系统全链路集成

后端:
- 简化积分服务: 删除 freeze/settle/refund, 保留 consume/recharge/expire
- 计费配置化: config/points-config.yaml 驱动 fixed/duration/free 三种模式
- TTS 时长探测: app/utils/audio_utils.py (httpx + mutagen 纯 Python)
- Python 层扣费: script(5)/polish(1)/title(1)/voice_clone(200)/tts(按秒)/video(按秒)
- 字幕 free_services: caption/auto_align 不扣费
- 新增 POST /points/consume 端点(402余额预检)
- 新增 check_balance + /points/cost 返回 sufficient/balance/required
- 新增 expire_batches 定时回收, 接入 scheduler main(每5分钟)
- 删除废弃 tts_handler.py
- Alembic 迁移: 删除 frozen/total_refunded 字段
- 同步 requirements.lock 添加 mutagen

前端:
- Rust/IPC 层扣费: compose(5)/subtitle_burn(2)/cover_design(2)
- 字幕打轴改异步: 走 scheduler subtitle handler
- 对口型传 duration: VideoGeneration 传 actualDuration
- 创建 pointStore: 全局余额 + fetchBalance + 充值弹窗控制
- 402 欠费弹 RechargeModal: VideoGeneration/SubtitleBurning/CoverDesign
- 修复 VoiceDubbing.tsx 类型错误 (alignResult never)
- 同步 PointBalance 类型(删除 frozen/available/totalRefunded)

Refs: 积分消耗集成收尾
This commit is contained in:
小鱼开发
2026-05-09 15:42:54 +08:00
parent 8f55093457
commit c6eba97b43
28 changed files with 790 additions and 652 deletions
+58
View File
@@ -0,0 +1,58 @@
"""
音频时长探测工具
================
基于 mutagen,支持从内存或文件路径探测音频时长。
不依赖 ffprobe,纯 Python 实现。
"""
from __future__ import annotations
import io
import logging
import httpx
from mutagen.mp3 import MP3
from mutagen.wave import WAVE
logger = logging.getLogger(__name__)
async def get_audio_duration(url: str, timeout: float = 10.0) -> float:
"""
探测远程音频文件的时长(秒)。
下载音频到内存后,用 mutagen 读取文件头获取时长。
不写入磁盘,全部在内存中完成。
:param url: 音频文件 URL(需可访问)
:param timeout: HTTP 下载超时(秒)
:return: 音频时长(秒,float
:raises ValueError: 无法解析时长
"""
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=timeout)
resp.raise_for_status()
data = io.BytesIO(resp.content)
# 根据文件头判断格式
header = data.read(12)
data.seek(0)
try:
if header[:3] == b"ID3" or header[:2] == b"\xff\xfb" or header[:2] == b"\xff\xf3":
audio = MP3(data)
elif header[:4] == b"RIFF":
audio = WAVE(data)
else:
# fallback:先尝试 MP3(大多数 TTS 返回 mp3
audio = MP3(data)
duration = audio.info.length
if duration is None or duration <= 0:
raise ValueError("音频时长解析失败")
return float(duration)
except Exception as e:
logger.error(f"探测音频时长失败: url={url[:60]}..., error={e}")
raise ValueError(f"无法解析音频时长: {e}") from e