Files
trade-assistant/backend/app/api/v1/admin_credits.py
T
TradeMate Dev 2a107a42f3 feat: credit-based billing system
- New DB models: credit_packages, subscription_plans, user_credits, credit_consumptions, credit_purchases
- CreditService: balance, deduct, add_credits, grant_free_trial, history
- User API: /api/v1/credits/* (balance/history/packages/purchase/subscribe)
- Admin API: /api/v1/admin/credit-* (CRUD packages/plans, user credits, consumptions)
- PaymentService.create_credit_order + handle_callback for credit purchases
- Credit deduction on: discovery, translate, marketing, ai_chat, followup
- Free trial 30 credits on registration
- Documentation: docs/CREDIT_SYSTEM.md
2026-06-12 10:39:45 +08:00

309 lines
9.0 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.api.v1.admin import require_admin
from app.services.credit import CreditService
from app.models.credit_package import CreditPackage, SubscriptionPlan
from app.models.user_credit import UserCredit
from app.models.user import User
import uuid
router = APIRouter()
class PackageForm(BaseModel):
name: str
name_en: str
credits: int
price: float
price_usd: Optional[float] = None
original_price: Optional[float] = None
is_active: bool = True
sort_order: int = 0
class PlanForm(BaseModel):
name: str
name_en: str
credits_per_month: int
price: float
price_usd: Optional[float] = None
duration_days: int = 30
is_active: bool = True
sort_order: int = 0
class AdjustCreditsForm(BaseModel):
user_id: str
credits: float
reason: str = ""
@router.get("/credit-packages")
async def list_packages(
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(CreditPackage).order_by(CreditPackage.sort_order)
)
return [{
"id": str(p.id),
"name": p.name,
"name_en": p.name_en,
"credits": p.credits,
"price": p.price,
"price_usd": p.price_usd,
"original_price": p.original_price,
"is_active": p.is_active,
"sort_order": p.sort_order,
} for p in result.scalars().all()]
@router.post("/credit-packages")
async def create_package(
data: PackageForm,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
pkg = CreditPackage(**data.model_dump())
db.add(pkg)
await db.flush()
return {"id": str(pkg.id), "status": "ok"}
@router.put("/credit-packages/{pkg_id}")
async def update_package(
pkg_id: str,
data: PackageForm,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
try:
uid = uuid.UUID(pkg_id)
except ValueError:
raise HTTPException(status_code=400, detail="无效ID")
result = await db.execute(select(CreditPackage).where(CreditPackage.id == uid))
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="次数包不存在")
for k, v in data.model_dump().items():
setattr(pkg, k, v)
await db.flush()
return {"status": "ok"}
@router.delete("/credit-packages/{pkg_id}")
async def delete_package(
pkg_id: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
try:
uid = uuid.UUID(pkg_id)
except ValueError:
raise HTTPException(status_code=400, detail="无效ID")
result = await db.execute(select(CreditPackage).where(CreditPackage.id == uid))
pkg = result.scalar_one_or_none()
if not pkg:
raise HTTPException(status_code=404, detail="次数包不存在")
await db.delete(pkg)
await db.flush()
return {"status": "ok"}
@router.get("/subscription-plans")
async def list_plans(
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(
select(SubscriptionPlan).order_by(SubscriptionPlan.sort_order)
)
return [{
"id": str(p.id),
"name": p.name,
"name_en": p.name_en,
"credits_per_month": p.credits_per_month,
"price": p.price,
"price_usd": p.price_usd,
"duration_days": p.duration_days,
"is_active": p.is_active,
"sort_order": p.sort_order,
} for p in result.scalars().all()]
@router.post("/subscription-plans")
async def create_plan(
data: PlanForm,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
plan = SubscriptionPlan(**data.model_dump())
db.add(plan)
await db.flush()
return {"id": str(plan.id), "status": "ok"}
@router.put("/subscription-plans/{plan_id}")
async def update_plan(
plan_id: str,
data: PlanForm,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
try:
uid = uuid.UUID(plan_id)
except ValueError:
raise HTTPException(status_code=400, detail="无效ID")
result = await db.execute(select(SubscriptionPlan).where(SubscriptionPlan.id == uid))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="订阅套餐不存在")
for k, v in data.model_dump().items():
setattr(plan, k, v)
await db.flush()
return {"status": "ok"}
@router.delete("/subscription-plans/{plan_id}")
async def delete_plan(
plan_id: str,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
try:
uid = uuid.UUID(plan_id)
except ValueError:
raise HTTPException(status_code=400, detail="无效ID")
result = await db.execute(select(SubscriptionPlan).where(SubscriptionPlan.id == uid))
plan = result.scalar_one_or_none()
if not plan:
raise HTTPException(status_code=404, detail="订阅套餐不存在")
await db.delete(plan)
await db.flush()
return {"status": "ok"}
@router.get("/user-credits")
async def list_user_credits(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
offset = (page - 1) * size
result = await db.execute(
select(UserCredit).order_by(UserCredit.updated_at.desc()).offset(offset).limit(size)
)
items = result.scalars().all()
from sqlalchemy import func
count_result = await db.execute(select(func.count(UserCredit.id)))
total = count_result.scalar() or 0
enriched = []
for uc in items:
user_result = await db.execute(select(User).where(User.id == uc.user_id))
user = user_result.scalar_one_or_none()
enriched.append({
"id": str(uc.id),
"user_id": str(uc.user_id),
"username": user.username if user else "N/A",
"balance": uc.balance,
"total_purchased": uc.total_purchased,
"total_used": uc.total_used,
"subscription_plan_id": str(uc.subscription_plan_id) if uc.subscription_plan_id else None,
"subscription_expires_at": uc.subscription_expires_at.isoformat() if uc.subscription_expires_at else None,
"free_trial_used": uc.free_trial_used,
"updated_at": uc.updated_at.isoformat() if uc.updated_at else None,
})
return {"items": enriched, "total": total, "page": page, "size": size}
class AdjustForm(BaseModel):
user_id: str
credits: float
reason: str = ""
@router.post("/user-credits/adjust")
async def adjust_credits(
data: AdjustForm,
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
svc = CreditService(db)
try:
uid = uuid.UUID(data.user_id)
except ValueError:
raise HTTPException(status_code=400, detail="无效用户ID")
balance = await svc.add_credits(
user_id=uid,
credits=data.credits,
source="admin_grant",
description=data.reason or f"管理员调整: {data.credits:+.1f}",
)
return {"status": "ok", "balance": balance}
@router.get("/credit-consumptions")
async def list_consumptions(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=200),
user_id: str = Query(None),
result_type: str = Query(None),
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
from app.models.credit_consumption import CreditConsumption
from sqlalchemy import select, func, desc
conditions = []
if user_id:
try:
conditions.append(CreditConsumption.user_id == uuid.UUID(user_id))
except ValueError:
pass
if result_type:
conditions.append(CreditConsumption.result_type == result_type)
stmt = select(CreditConsumption).where(*conditions).order_by(
desc(CreditConsumption.created_at)
).offset((page - 1) * size).limit(size)
result = await db.execute(stmt)
items = result.scalars().all()
count_stmt = select(func.count(CreditConsumption.id)).where(*conditions)
count_result = await db.execute(count_stmt)
total = count_result.scalar() or 0
return {
"items": [{
"id": str(c.id),
"user_id": str(c.user_id),
"result_type": c.result_type,
"credits_change": c.credits_change,
"balance_after": c.balance_after,
"source": c.source,
"description": c.description,
"created_at": c.created_at.isoformat() if c.created_at else None,
} for c in items],
"total": total,
"page": page,
"size": size,
}
@router.get("/credit-stats")
async def credit_stats(
_: dict = Depends(require_admin),
db: AsyncSession = Depends(get_db),
):
svc = CreditService(db)
return await svc.get_stats()