Files
video-gen/video-gen-api/app/services/credits.py
T

211 lines
6.6 KiB
Python

import math
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.credit_record import CreditRecord
from app.models.system_config import SystemConfig
from app.models.video_engine import VideoEngine
from app.models.image_engine import ImageEngine
from app.models.credit_ratio import CreditRatio
from app.utils.id_gen import generate_id
from app.utils.exceptions import InsufficientCreditsError
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
"""Calculate text credits based on actual token usage and configurable rate."""
result = await db.execute(
select(SystemConfig).where(SystemConfig.key == "text_credits_per_1000_tokens").limit(1)
)
config = result.scalar_one_or_none()
rate = float(config.value) if config else 1.0
total_tokens = input_tokens + output_tokens
return round(total_tokens * rate / 1000, 2)
async def _get_credit_ratio(
db: AsyncSession,
*,
gen_type: str,
resolution: str,
engine_id: str | None = None,
) -> CreditRatio | None:
"""按引擎精确规则优先获取积分规则;找不到时回退到同类型同参数最高规则。"""
gen_type = (gen_type or "").lower().strip()
resolution = (resolution or "").strip()
engine_id = (engine_id or "").strip() or None
if engine_id:
result = await db.execute(
select(CreditRatio)
.where(CreditRatio.gen_type == gen_type)
.where(CreditRatio.model_config_id == engine_id)
.where(CreditRatio.resolution == resolution)
.order_by(CreditRatio.base_credits.desc(), CreditRatio.per_second_credits.desc())
.limit(1)
)
ratio = result.scalar_one_or_none()
if ratio:
return ratio
result = await db.execute(
select(CreditRatio)
.where(CreditRatio.gen_type == gen_type)
.where(CreditRatio.resolution == resolution)
.order_by(CreditRatio.base_credits.desc(), CreditRatio.per_second_credits.desc())
.limit(1)
)
return result.scalar_one_or_none()
async def calc_video_credits(
db: AsyncSession,
duration: int,
resolution: str,
engine_id: str | None = None,
) -> float:
"""Calculate video credits using CreditRatio table, with fallback to hardcoded.
查询优先级:
1. gen_type=video + engine_id + resolution 精确规则;
2. gen_type=video + resolution 下 base_credits/per_second_credits 最高规则;
3. 原硬编码默认算法。
"""
# 如果engine_id为空,默认查询权重最高的视频引擎积分规则
if not engine_id:
video_engines_result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True)
.order_by(VideoEngine.priority.desc())
.limit(1)
)
engine_id = video_engines_result.scalar_one_or_none()
ratio = await _get_credit_ratio(
db,
gen_type="video",
resolution=resolution,
engine_id=engine_id,
)
if ratio:
return round((ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio, 2)
# Fallback
base = 60.0
duration_cost = duration * 2.0
multiplier = {"480p": 1, "1080p": 2, "720p": 1.5}.get(resolution, 1.0)
return round((base + duration_cost) * multiplier, 2)
def calc_credits(duration: int, resolution: str) -> float:
"""Legacy: hardcoded credit calculation. Prefer calc_video_credits for new code."""
base = 60.0
duration_cost = duration * 2.0
multiplier = {"480p": 2.5, "1080p": 1.5, "720p": 1.0}.get(resolution, 1.0)
return round((base + duration_cost) * multiplier, 2)
async def calc_image_credits(
db: AsyncSession,
image_size: str,
engine_id: str | None = None,
) -> float:
"""Calculate image credits using CreditRatio table, with fallback to hardcoded.
查询优先级:
1. gen_type=image + engine_id + image_size 精确规则;
2. gen_type=image + image_size 下 base_credits/per_second_credits 最高规则;
3. 原硬编码默认算法。
"""
# 如果engine_id为空,默认查询权重最高的图片引擎积分规则
if not engine_id:
image_engines_result = await db.execute(
select(ImageEngine.id)
.where(ImageEngine.is_active == True)
.order_by(ImageEngine.priority.desc())
.limit(1)
)
engine_id = image_engines_result.scalar_one_or_none()
ratio = await _get_credit_ratio(
db,
gen_type="image",
resolution=image_size,
engine_id=engine_id,
)
if ratio:
return round(ratio.base_credits * ratio.ratio, 2)
# Fallback
multiplier = {"4K": 2.0, "2K": 1.0}.get(image_size, 1.0)
base_cost = 4.0
return round(base_cost * multiplier, 2)
async def deduct_credits(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
) -> User:
"""Atomically deduct credits from user. Raises InsufficientCreditsError."""
result = await db.execute(
select(User).where(User.id == user_id).limit(1)
)
user = result.scalar_one_or_none()
if not user or user.credits < amount:
raise InsufficientCreditsError()
user.credits = round(user.credits - amount, 2)
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type="consume",
amount=-round(amount, 2),
balance_after=user.credits,
description=description,
related_id=related_id,
)
db.add(record)
await db.flush()
return user
async def add_credits(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
) -> User:
"""Add credits to user."""
result = await db.execute(
select(User).where(User.id == user_id).limit(1)
)
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
user.credits = round(user.credits + amount, 2)
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type="recharge",
amount=round(amount, 2),
balance_after=user.credits,
description=description,
related_id=related_id,
)
db.add(record)
await db.flush()
return user
async def get_records(db: AsyncSession, user_id: str) -> list[CreditRecord]:
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id)
.order_by(CreditRecord.created_at.desc())
)
return list(result.scalars().all())