from fastapi import APIRouter, Depends, Query from sqlalchemy import case, func, select from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_current_user, get_db from app.models.credit.balance import UserCreditBalance from app.models.credit.allocation import CreditRecordAllocation from app.models.credit_record import CreditRecord from app.enums.credit_balance import ( CREDIT_BALANCE_SOURCE_TYPE_LABELS, CREDIT_BALANCE_STATUS_LABELS, CREDIT_LEVEL_LABELS, CREDIT_SCOPE_LABELS, CreditScope, ) from app.enums.credit_record import CREDIT_RECORD_BILLING_SCENE_LABELS, CREDIT_RECORD_TYPE_LABELS from app.models.credit_ratio import CreditRatio from app.models.image_engine import ImageEngine from app.models.user import User from app.models.video_engine import VideoEngine from app.schemas.credit import CreditRecordOut from app.schemas.credit_balance import CreditBalanceItemOut from app.schemas.credit_ratio import CreditRatioOut from app.services.credit.query_service import ( apply_balance_status_filter, effective_balance_status, get_balance_summary, ) from app.services.credit.utils import utc_now from app.services.credit.time_policy import last_usable_at from app.services.credit_ratio_service import list_all_credit_ratios from app.services.credits import get_records router = APIRouter(prefix="/credits", tags=["credits"]) def _balance_to_out(item: UserCreditBalance, *, checked_at) -> CreditBalanceItemOut: return CreditBalanceItemOut( id=item.id, credit_scope=item.credit_scope, credit_scope_label=CREDIT_SCOPE_LABELS.get(item.credit_scope, "其他积分"), team_id=item.team_id, credit_level=item.credit_level, credit_level_label=CREDIT_LEVEL_LABELS.get(item.credit_level, "其他积分等级"), source_type=item.source_type, source_type_label=CREDIT_BALANCE_SOURCE_TYPE_LABELS.get(item.source_type, "其他来源"), source_id=item.source_id, product_id=item.product_id, payment_order_id=item.payment_order_id, subscription_id=item.subscription_id, subscription_period_id=item.subscription_period_id, grant_amount=float(item.grant_amount), unspent_amount=float(item.unspent_amount), consumed_amount=float(item.consumed_amount), expired_amount=float(item.expired_amount), revoked_amount=float(item.revoked_amount), valid_from=item.valid_from, expires_at=item.expires_at, last_usable_at=last_usable_at(item.expires_at), status=effective_balance_status(item, request_time=checked_at), status_label=CREDIT_BALANCE_STATUS_LABELS.get( effective_balance_status(item, request_time=checked_at), "其他状态" ), created_at=item.created_at, ) async def _records_with_scope_amounts(db: AsyncSession, records: list[CreditRecord]) -> list[dict]: if not records: return [] ids = [item.id for item in records] result = await db.execute( select( CreditRecordAllocation.credit_record_id, CreditRecordAllocation.credit_scope_snapshot, func.coalesce(func.sum(CreditRecordAllocation.amount), 0).label("amount"), ) .where(CreditRecordAllocation.credit_record_id.in_(ids)) .group_by(CreditRecordAllocation.credit_record_id, CreditRecordAllocation.credit_scope_snapshot) ) scope_map: dict[str, dict[str, float]] = {} for row in result.all(): scope_map.setdefault(str(row.credit_record_id), {})[str(row.credit_scope_snapshot)] = float(row.amount or 0) output = [] for record in records: parts = scope_map.get(record.id, {}) sign = -1.0 if float(record.amount or 0) < 0 else 1.0 output.append({ "id": record.id, "type": record.type, "type_label": CREDIT_RECORD_TYPE_LABELS.get(record.type, "其他"), "amount": float(record.amount), "personal_amount": sign * float(parts.get("personal", 0)), "team_amount": sign * float(parts.get("team", 0)), "balance_delta": float(record.balance_delta or 0), "expired_amount": float(record.expired_amount or 0), "balance_after": float(record.balance_after or 0), "description": record.description, "billing_scene": record.billing_scene, "billing_scene_label": CREDIT_RECORD_BILLING_SCENE_LABELS.get(record.billing_scene, "其他场景") if record.billing_scene else None, "scene_name_snapshot": record.scene_name_snapshot, "input_tokens": record.input_tokens, "output_tokens": record.output_tokens, "total_tokens": record.total_tokens, "llm_call_count": record.llm_call_count, "llm_success_call_count": record.llm_success_call_count, "llm_failed_call_count": record.llm_failed_call_count, "created_at": record.created_at, }) return output @router.get("") async def get_credits( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): records, total = await get_records(db, current_user.id, page, page_size) summary = await get_balance_summary(db, current_user.id) totals_result = await db.execute( select( func.coalesce(func.sum(case((CreditRecord.type == "recharge", CreditRecord.amount), else_=0)), 0), func.coalesce(func.sum(case((CreditRecord.type == "consume", func.abs(CreditRecord.amount)), else_=0)), 0), func.coalesce(func.sum(case((CreditRecord.type == "refund", CreditRecord.amount), else_=0)), 0), func.coalesce(func.sum(case((CreditRecord.type == "expire", CreditRecord.expired_amount), else_=0)), 0), ).where(CreditRecord.user_id == current_user.id) ) total_granted, total_consumed, total_refunded, total_expired = totals_result.one() return { **summary.to_dict(), "records": await _records_with_scope_amounts(db, records), "total": total, "total_granted": float(total_granted or 0), "total_consumed": float(total_consumed or 0), "total_refunded": float(total_refunded or 0), "total_expired": float(total_expired or 0), } @router.get("/balances") async def list_credit_balances( status: str | None = Query(default=None), page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): checked_at = utc_now() # 通用积分页只展示用户自己的个人积分批次。团队资金池归属成交时队长, # 不能因为 Balance.owner 是队长就在这里展示整个团队资金池;团队席位与资金池明细统一在团队管理页查看。 stmt = select(UserCreditBalance).where( UserCreditBalance.user_id == current_user.id, UserCreditBalance.credit_scope == CreditScope.PERSONAL.value, ) stmt = apply_balance_status_filter(stmt, status, request_time=checked_at) stmt = stmt.order_by(UserCreditBalance.expires_at.asc(), UserCreditBalance.id.asc()) result = await db.execute(stmt.offset((page - 1) * page_size).limit(page_size)) return [_balance_to_out(item, checked_at=checked_at) for item in result.scalars().all()] @router.get( "/credit-ratios", response_model=list[CreditRatioOut], summary="获取积分比例列表", ) async def list_client_credit_ratios( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): return await list_all_credit_ratios(db) @router.get("/ratios", response_model=dict) async def get_credit_ratios( current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): async def get_ratios_for_engine_type(gen_type: str, engine_ids: list): for engine_id in engine_ids: result = await db.execute( select(CreditRatio) .where(CreditRatio.gen_type == gen_type) .where(CreditRatio.model_config_id == engine_id) ) ratios = result.scalars().all() if ratios: return [CreditRatioOut.model_validate(r) for r in ratios] return [] video_result = await db.execute( select(VideoEngine.id) .where(VideoEngine.is_active.is_(True), VideoEngine.deleted_at.is_(None)) .order_by(VideoEngine.priority.desc()) ) image_result = await db.execute( select(ImageEngine.id) .where(ImageEngine.is_active.is_(True), ImageEngine.deleted_at.is_(None)) .order_by(ImageEngine.priority.desc()) ) grouped = {} video_ratios = await get_ratios_for_engine_type("video", list(video_result.scalars().all())) image_ratios = await get_ratios_for_engine_type("image", list(image_result.scalars().all())) if video_ratios: grouped["video"] = video_ratios if image_ratios: grouped["image"] = image_ratios return grouped