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
This commit is contained in:
TradeMate Dev
2026-06-12 10:39:45 +08:00
parent 5d895ae12c
commit 2a107a42f3
21 changed files with 1528 additions and 33 deletions
+308
View File
@@ -0,0 +1,308 @@
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()