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:
@@ -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="消费成功",
|
||||
)
|
||||
|
||||
|
||||
# ── 充值订单查询 ──────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user