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
+72 -92
View File
@@ -361,123 +361,103 @@ async def query_recharge_status(
)
# ── 消费 ──────────────────────────────────────────────
# ── 积分预估查询 ──────────────────────────────────────
@router.post("/consume/freeze", response_model=ApiResponse[ConsumeFreezeResponse])
async def freeze_points(
request: ConsumeFreezeRequest,
@router.get("/cost")
async def get_cost(
source_type: str,
seconds: int = 0,
char_count: int = 0,
total_seconds: int = 0,
input_seconds: int = 0,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
消费预扣积分
查询某操作所需积分(预估上限和实际计费规则),并附带余额检查。
在调用 AI 服务前预扣积分,确保用户有足够余额
返回预扣结果,后续需要调用结算接口确认或退回。
用于前端在执行业务前预估所需积分,做余额检查
"""
try:
tx, _ = await point_service.freeze_for_consumption(
db,
user_id=current_user.id,
source_type=request.source_type,
source_id=request.source_id,
param=request.param,
description=request.description,
# 构建预估参数
estimate_param = {}
if source_type == "tts":
estimate_param["char_count"] = char_count
elif source_type in ("video", "caption"):
estimate_param["input_seconds"] = input_seconds
elif source_type == "compose":
estimate_param["total_seconds"] = total_seconds
estimated = point_service._estimate_max_cost(source_type, estimate_param)
# 实际计费(按传入秒数计算)
actual_param = {"seconds": seconds}
actual = point_service._calculate_cost(source_type, actual_param)
# 余额检查
balance_info = await point_service.check_balance(
db, current_user.id, required_points=estimated
)
# 预扣金额(取绝对值)
frozen_points = abs(tx.amount)
# 获取当前可用余额
balance = await point_service.get_user_balance(db, user_id=current_user.id)
return success_response(
data=ConsumeFreezeResponse(
transaction_id=tx.id,
frozen_points=frozen_points,
available_balance=balance["available"],
),
message=f"预扣 {frozen_points} 积分成功",
data={
"source_type": source_type,
"estimated_points": estimated,
"actual_points": actual,
"sufficient": balance_info["sufficient"],
"balance": balance_info["balance"],
"required": balance_info["required"],
},
message="积分预估查询成功",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/consume/settle", response_model=ApiResponse[ConsumeResultResponse])
async def settle_consumption(
request: ConsumeSettleRequest,
# ── 直接消费扣费(前端/Rust 层调用)───────────────────
@router.post("/consume", response_model=ApiResponse[dict])
async def consume_points(
request: schemas.ConsumeRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
消费结算
直接消费积分(不经过冻结)
AI 服务调用成功后调用此接口,根据实际消耗结算积分。
如果实际消耗少于预扣金额,差额自动退回。
用于 Rust/前端层业务在执行本地操作前扣费:
- compose(视频合成)
- subtitle_burn(字幕压制)
- cover_design(封面设计)
余额不足时返回 402,前端应拦截并引导充值。
"""
try:
refund_tx = await point_service.settle_consumption(
db,
user_id=current_user.id,
source_type=request.source_type,
source_id=request.source_id,
actual_points=request.actual_points,
description=request.description,
# 余额预检:不允许欠费
balance_info = await point_service.get_user_balance(db, current_user.id)
if balance_info["balance"] < request.points:
raise HTTPException(
status_code=402,
detail=f"积分不足,当前余额 {balance_info['balance']},需要 {request.points} 积分",
)
if refund_tx:
return success_response(
data=ConsumeResultResponse(
success=True,
transaction_id=refund_tx.id,
refunded_points=refund_tx.amount,
message=f"结算成功,退回 {refund_tx.amount} 积分",
),
message="消费结算完成",
)
tx = await point_service.consume(
db,
user_id=current_user.id,
points=request.points,
source_type=request.source_type,
source_id=request.source_id,
description=request.description,
)
return success_response(
data=ConsumeResultResponse(success=True),
message="消费结算完成",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/consume/refund", response_model=ApiResponse[ConsumeResultResponse])
async def refund_consumption(
request: ConsumeRefundRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
消费失败退款
AI 服务调用失败后调用此接口,全额退还预扣积分。
"""
try:
tx = await point_service.refund_consumption(
db,
user_id=current_user.id,
source_type=request.source_type,
source_id=request.source_id,
description=request.description,
)
return success_response(
data=ConsumeResultResponse(
success=True,
transaction_id=tx.id,
refunded_points=tx.amount,
message=f"退款 {tx.amount} 积分成功",
),
message="消费退款完成",
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return success_response(
data={
"transaction_id": tx.id,
"consumed_points": tx.amount,
"balance_after": tx.balance_after,
"source_type": tx.source_type,
},
message="消费成功",
)
# ── 充值订单查询 ──────────────────────────────────────