410 lines
14 KiB
Python
410 lines
14 KiB
Python
from datetime import datetime
|
|
|
|
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
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
from app.enums.credit_balance import CreditBalanceSourceType, CreditLevel
|
|
from app.enums.credit_record import CreditRecordType
|
|
from app.services.credit.ledger_service import (
|
|
CreditMutationResult,
|
|
deduct_credits as deduct_dynamic_credits,
|
|
grant_credits,
|
|
refund_consumption,
|
|
)
|
|
from app.services.credit.query_service import get_available_credits
|
|
from app.services.credit.time_policy import add_natural_months
|
|
from app.services.credit.utils import to_float, utc_now
|
|
from app.services.credit_record_meta_service import CreditRecordMeta
|
|
|
|
|
|
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,
|
|
request_time: datetime | None = None,
|
|
allowed_scopes: set[str] | None = None,
|
|
) -> CreditMutationResult:
|
|
"""旧调用兼容门面;新账本始终足额同步扣除,allow_negative 不再生效。"""
|
|
return await deduct_dynamic_credits(
|
|
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,
|
|
create_zero_record=create_zero_record,
|
|
request_time=request_time,
|
|
allowed_scopes=allowed_scopes,
|
|
)
|
|
|
|
|
|
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,
|
|
request_time: datetime | None = None,
|
|
allowed_scopes: set[str] | None = None,
|
|
) -> User:
|
|
return (
|
|
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,
|
|
request_time=request_time,
|
|
allowed_scopes=allowed_scopes,
|
|
)
|
|
).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,
|
|
valid_from: datetime | None = None,
|
|
expires_at: datetime | None = None,
|
|
credit_level: str = CreditLevel.GENERAL.value,
|
|
source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
|
|
source_id: str | None = None,
|
|
product_id: str | None = None,
|
|
payment_order_id: str | None = None,
|
|
subscription_id: str | None = None,
|
|
subscription_period_id: str | None = None,
|
|
request_time: datetime | None = None,
|
|
) -> CreditMutationResult:
|
|
checked_at = request_time or utc_now()
|
|
if record_type == CreditRecordType.REFUND.value and refund_for_biz_key:
|
|
refunded = await refund_consumption(
|
|
db,
|
|
user_id=user_id,
|
|
refund_for_biz_key=refund_for_biz_key,
|
|
description=description,
|
|
related_id=related_id,
|
|
biz_key=biz_key,
|
|
record_meta=record_meta,
|
|
refund_time=checked_at,
|
|
)
|
|
user_result = await db.execute(select(User).where(User.id == user_id).limit(1))
|
|
user = user_result.scalar_one()
|
|
setattr(user, "credits", to_float(refunded.balance_after))
|
|
record = refunded.records[0] if refunded.records else None
|
|
return CreditMutationResult(
|
|
user=user,
|
|
record=record,
|
|
created=refunded.created,
|
|
amount=to_float(refunded.total_amount),
|
|
balance_before=to_float(refunded.balance_before),
|
|
balance_after=to_float(refunded.balance_after),
|
|
refund_available=to_float(refunded.available_amount),
|
|
refund_expired=to_float(refunded.expired_amount),
|
|
)
|
|
starts_at = valid_from or checked_at
|
|
return await grant_credits(
|
|
db,
|
|
user_id=user_id,
|
|
amount=amount,
|
|
description=description,
|
|
source_type=source_type,
|
|
valid_from=starts_at,
|
|
expires_at=expires_at or add_natural_months(starts_at, 1),
|
|
credit_level=credit_level,
|
|
source_id=source_id or related_id,
|
|
product_id=product_id,
|
|
payment_order_id=payment_order_id,
|
|
subscription_id=subscription_id,
|
|
subscription_period_id=subscription_period_id,
|
|
related_id=related_id,
|
|
record_type=record_type,
|
|
biz_key=biz_key,
|
|
record_meta=record_meta,
|
|
request_time=checked_at,
|
|
)
|
|
|
|
|
|
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,
|
|
valid_from: datetime | None = None,
|
|
expires_at: datetime | None = None,
|
|
credit_level: str = CreditLevel.GENERAL.value,
|
|
source_type: str = CreditBalanceSourceType.ADMIN_GRANT.value,
|
|
source_id: str | None = None,
|
|
product_id: str | None = None,
|
|
payment_order_id: str | None = None,
|
|
subscription_id: str | None = None,
|
|
subscription_period_id: str | None = None,
|
|
request_time: datetime | None = None,
|
|
) -> User:
|
|
return (
|
|
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,
|
|
valid_from=valid_from,
|
|
expires_at=expires_at,
|
|
credit_level=credit_level,
|
|
source_type=source_type,
|
|
source_id=source_id,
|
|
product_id=product_id,
|
|
payment_order_id=payment_order_id,
|
|
subscription_id=subscription_id,
|
|
subscription_period_id=subscription_period_id,
|
|
request_time=request_time,
|
|
)
|
|
).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,
|
|
request_time: datetime | None = None,
|
|
) -> User:
|
|
if not refund_for_biz_key:
|
|
raise ValueError("动态积分退款必须指定原消费 biz_key")
|
|
return await add_credits(
|
|
db,
|
|
user_id=user_id,
|
|
amount=amount,
|
|
description=description,
|
|
related_id=related_id,
|
|
record_type=CreditRecordType.REFUND.value,
|
|
biz_key=biz_key,
|
|
refund_for_biz_key=refund_for_biz_key,
|
|
record_meta=record_meta,
|
|
request_time=request_time,
|
|
)
|
|
|
|
|
|
async def get_credit_balance(db: AsyncSession, user_id: str, request_time: datetime | None = None) -> float:
|
|
return to_float(await get_available_credits(db, user_id, request_time=request_time or utc_now()))
|
|
|
|
|
|
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(), CreditRecord.id.desc())
|
|
.offset((page - 1) * page_size)
|
|
.limit(page_size)
|
|
)
|
|
return list(result.scalars().all()), int(total)
|