Files
video-gen/video-gen-api/app/services/credits.py
T
2026-07-24 09:18:05 +08:00

479 lines
16 KiB
Python

import math
from dataclasses import dataclass
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.credit_record import CreditRecord
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
from app.enums.common import BillingBlockEventEnum
from app.services.operation_log_service import log_operation_event
from app.services.system_config_cache import get_system_config_value
from app.services.credit_record_meta_service import CreditRecordMeta, with_user_snapshot
async def calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float:
"""Calculate text credits based on actual token usage and cached configurable rate."""
raw_rate = await get_system_config_value(db, "text_credits_per_1000_tokens")
try:
rate = float(raw_rate) if raw_rate not in (None, "") else 1.0
except (TypeError, ValueError):
rate = 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,
input_video_duration: float | None = None,
input_image_count: int | 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. 原硬编码默认算法。
input_video_duration: 用户上传的参考视频总时长(秒),不为空时额外计费
input_image_count: 用户上传的参考图片数量,不为空时额外计费
"""
if not engine_id:
video_engines_result = await db.execute(
select(VideoEngine.id)
.where(VideoEngine.is_active == True, VideoEngine.deleted_at.is_(None))
.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:
base_cost = (ratio.base_credits + ratio.per_second_credits * duration) * ratio.ratio
if input_video_duration and input_video_duration > 0:
input_video_cost = (
ratio.input_video_base_credits + ratio.input_video_per_second_credits * input_video_duration
) * ratio.input_video_ratio
base_cost += input_video_cost
if input_image_count and input_image_count > 0:
input_image_cost = (
ratio.input_image_base_credits + ratio.input_image_per_image_credits * input_image_count
) * ratio.input_image_ratio
base_cost += input_image_cost
return round(base_cost, 2)
base = 60.0
duration_cost = duration * 2.0
multiplier = {"480p": 1, "1080p": 2, "720p": 1.5}.get(resolution, 1.0)
total = (base + duration_cost) * multiplier
if input_video_duration and input_video_duration > 0:
total += input_video_duration * 0.5 * multiplier
if input_image_count and input_image_count > 0:
total += input_image_count * 0.5 * multiplier
return round(total, 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,
input_image_count: int | 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. 原硬编码默认算法。
input_image_count: 用户上传的参考图片数量,不为空时额外计费
"""
# 如果engine_id为空,默认查询权重最高的图片引擎积分规则
if not engine_id:
image_engines_result = await db.execute(
select(ImageEngine.id)
.where(ImageEngine.is_active == True, ImageEngine.deleted_at.is_(None))
.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:
base_cost = ratio.base_credits * ratio.ratio
if input_image_count and input_image_count > 0:
input_image_cost = (
ratio.input_image_base_credits + ratio.input_image_per_image_credits * input_image_count
) * ratio.input_image_ratio
base_cost += input_image_cost
return round(base_cost, 2)
# Fallback
multiplier = {"4K": 2.0, "2K": 1.0}.get(image_size, 1.0)
base_cost = 4.0
total = base_cost * multiplier
if input_image_count and input_image_count > 0:
total += input_image_count * 0.5 * multiplier
return round(total, 2)
@dataclass(slots=True)
class CreditMutationResult:
user: User
record: CreditRecord | None
created: bool
amount: float
balance_before: float
balance_after: float
async def _get_existing_credit_record_by_biz_key(
db: AsyncSession,
*,
user_id: str,
biz_key: str | None,
) -> CreditRecord | None:
"""按正式业务幂等键查找已有积分流水。"""
if not biz_key:
return None
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id, CreditRecord.biz_key == biz_key)
.limit(1)
)
return result.scalar_one_or_none()
async def deduct_credits_result(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
*,
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
) -> CreditMutationResult:
"""并发安全且可观察幂等结果的积分扣减。"""
amount = round(float(amount or 0), 2)
result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
before_balance = round(float(user.credits or 0), 2)
if biz_key:
existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(round(float(existing.amount or 0), 2)),
balance_before=before_balance,
balance_after=before_balance,
)
if amount <= 0 and not create_zero_record:
return CreditMutationResult(
user=user,
record=None,
created=False,
amount=0.0,
balance_before=before_balance,
balance_after=before_balance,
)
if amount > 0 and not allow_negative and before_balance < amount:
event_type = (
BillingBlockEventEnum.NEGATIVE_BALANCE.value
if before_balance < 0
else BillingBlockEventEnum.INSUFFICIENT_CREDITS.value
)
log_operation_event(
domain="billing",
module="credits",
event_type=event_type,
event_status="failed",
source="app.services.credits.deduct_credits_result",
user_id=user_id,
task_id=related_id,
message="积分不足,已拦截新的扣费请求",
detail={
"user_id": user_id,
"amount": amount,
"before_balance": before_balance,
"allow_negative": allow_negative,
"biz_key": biz_key,
"refund_for_biz_key": refund_for_biz_key,
"description": description,
"record_type": record_type,
},
)
raise InsufficientCreditsError()
user.credits = round(before_balance - max(0.0, amount), 2)
meta_kwargs: dict = {}
if record_meta:
if isinstance(record_meta, CreditRecordMeta):
record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
meta_kwargs = record_meta.to_record_kwargs()
elif isinstance(record_meta, dict):
meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=-amount,
balance_after=user.credits,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=amount,
balance_before=before_balance,
balance_after=round(float(user.credits or 0), 2),
)
async def deduct_credits(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
*,
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
record_type: str = "consume",
allow_negative: bool = False,
create_zero_record: bool = False,
) -> User:
"""兼容旧调用:返回 User;精确幂等状态请使用 deduct_credits_result。"""
mutation = await deduct_credits_result(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
record_type=record_type,
allow_negative=allow_negative,
create_zero_record=create_zero_record,
)
return mutation.user
async def add_credits_result(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
*,
record_type: str = "recharge",
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
) -> CreditMutationResult:
"""并发安全且可观察幂等结果的积分增加。"""
amount = round(float(amount or 0), 2)
result = await db.execute(select(User).where(User.id == user_id).with_for_update().limit(1))
user = result.scalar_one_or_none()
if not user:
raise ValueError("User not found")
before_balance = round(float(user.credits or 0), 2)
if biz_key:
existing = await _get_existing_credit_record_by_biz_key(db, user_id=user_id, biz_key=biz_key)
if existing:
return CreditMutationResult(
user=user,
record=existing,
created=False,
amount=abs(round(float(existing.amount or 0), 2)),
balance_before=before_balance,
balance_after=before_balance,
)
if amount <= 0:
return CreditMutationResult(
user=user,
record=None,
created=False,
amount=0.0,
balance_before=before_balance,
balance_after=before_balance,
)
user.credits = round(before_balance + amount, 2)
meta_kwargs: dict = {}
if record_meta:
if isinstance(record_meta, CreditRecordMeta):
record_meta = await with_user_snapshot(db, record_meta, user_id, user=user)
meta_kwargs = record_meta.to_record_kwargs()
elif isinstance(record_meta, dict):
meta_kwargs = {k: v for k, v in record_meta.items() if v is not None}
record = CreditRecord(
id=generate_id(),
user_id=user_id,
type=record_type,
amount=amount,
balance_after=user.credits,
description=description,
related_id=related_id,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
**meta_kwargs,
)
db.add(record)
await db.flush()
return CreditMutationResult(
user=user,
record=record,
created=True,
amount=amount,
balance_before=before_balance,
balance_after=round(float(user.credits or 0), 2),
)
async def add_credits(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
*,
record_type: str = "recharge",
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
) -> User:
"""兼容旧调用:返回 User;精确幂等状态请使用 add_credits_result。"""
mutation = await add_credits_result(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
record_type=record_type,
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
)
return mutation.user
async def refund_credits(
db: AsyncSession,
user_id: str,
amount: float,
description: str,
related_id: str | None = None,
*,
biz_key: str | None = None,
refund_for_biz_key: str | None = None,
record_meta: CreditRecordMeta | dict | None = None,
) -> User:
"""生成失败积分回退。"""
return await add_credits(
db,
user_id=user_id,
amount=amount,
description=description,
related_id=related_id,
record_type="refund",
biz_key=biz_key,
refund_for_biz_key=refund_for_biz_key,
record_meta=record_meta,
)
async def get_records(db: AsyncSession, user_id: str, page: int = 1, page_size: int = 20) -> tuple[list[CreditRecord], int]:
count_query = select(func.count(CreditRecord.id)).where(CreditRecord.user_id == user_id)
total = (await db.execute(count_query)).scalar() or 0
result = await db.execute(
select(CreditRecord)
.where(CreditRecord.user_id == user_id)
.order_by(CreditRecord.created_at.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
return list(result.scalars().all()), total