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 calc_text_credits(db: AsyncSession, input_tokens: int, output_tokens: int) -> float: """历史兼容函数:LLM 已按业务场景固定预扣,Token 不再折算用户积分。""" return 0.0 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, ) -> 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, ) 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, ) -> 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, ) ).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)