Files
2026-08-14 15:23:46 +08:00

210 lines
9.0 KiB
Python

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