Files
aiagent/backend/app/api/billing.py
renjianbo 876789fac1 feat: multi-tenant workspace isolation, RBAC, sidebar nav, billing, and Android enhancements
- Backend: workspace_id isolation for 14 model tables + safe migration/backfill
- Backend: RBAC system with 4 roles and 23 permissions, seeded on startup
- Backend: workspace admin endpoints (list/manage all workspaces)
- Backend: admin user management API (CRUD, reset password)
- Backend: billing API with subscription plans, usage tracking, rate limiting
- Backend: fix system_logs.py UNION query and wrong column references
- Backend: WebSocket JWT auth and workspace enforcement
- Frontend: sidebar navigation replacing top dropdown menu
- Frontend: user management page (Users.vue) for admins
- Frontend: enhanced Workspaces.vue with admin table view
- Frontend: workspace RBAC computed properties in user store
- Android: agent marketplace, billing/subscription UI, onboarding wizard
- Android: phone login, analytics tracker, crash handler, network diagnostics
- Android: splash screen, encrypted token storage, app update enhancements
- Docs: multi-tenant RBAC guide with 8 sections and role-permission matrix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-07-04 01:00:22 +08:00

304 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
付费体系 API套餐、订阅、订单、用量
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from typing import Optional, List
from sqlalchemy.orm import Session
from datetime import date, datetime, timedelta
from app.core.database import get_db
from app.api.auth import get_current_user
from app.models.user import User
from app.models.billing import BillingPlan, BillingOrder, UserSubscription, UsageRecord
router = APIRouter(prefix="/api/v1/billing", tags=["billing"])
# ─── Pydantic Models ───
class PlanResponse(BaseModel):
id: str
name: str
tier: str
price_monthly: float
price_yearly: float
daily_quota: int
agent_limit: int
knowledge_limit: int
file_upload_mb: int = 5
models: Optional[list] = None
features: Optional[list] = None
is_active: bool = True
sort_order: int = 0
class Config:
from_attributes = True
class SubscriptionResponse(BaseModel):
tier: str = "free"
status: str = "active"
plan_name: Optional[str] = None
started_at: Optional[str] = None
expires_at: Optional[str] = None
auto_renew: bool = False
daily_quota: int = 50
agent_limit: int = 3
knowledge_limit: int = 10
file_upload_mb: int = 5
models: Optional[list] = None
features: Optional[list] = None
class UsageResponse(BaseModel):
daily_used: int = 0
daily_limit: int = 50
daily_remaining: int = 50
is_exhausted: bool = False
class CreateOrderRequest(BaseModel):
plan_id: str = Field(..., description="套餐ID")
period: str = Field(default="monthly", description="周期: monthly/yearly")
payment_method: str = Field(default="mock", description="支付方式: mock/wechat/alipay")
class OrderResponse(BaseModel):
id: str
plan_id: Optional[str] = None
amount: float
period: str
status: str
payment_method: Optional[str] = None
payment_ref: Optional[str] = None
created_at: Optional[str] = None
paid_at: Optional[str] = None
class Config:
from_attributes = True
# ─── Helpers ───
QUOTA_DEFAULTS = {
"free": {"daily_quota": 50, "agent_limit": 3, "knowledge_limit": 10, "file_upload_mb": 5},
"pro": {"daily_quota": 500, "agent_limit": 20, "knowledge_limit": 500, "file_upload_mb": 50},
"enterprise": {"daily_quota": 0, "agent_limit": 0, "knowledge_limit": 0, "file_upload_mb": 200},
}
def _get_user_subscription_info(db: Session, user: User) -> SubscriptionResponse:
"""获取用户当前订阅信息(套餐+权益)"""
sub = db.query(UserSubscription).filter(UserSubscription.user_id == user.id).first()
tier = sub.tier if sub else (user.subscription_tier or "free")
plan = db.query(BillingPlan).filter(BillingPlan.id == sub.plan_id).first() if (sub and sub.plan_id) else None
quota = QUOTA_DEFAULTS.get(tier, QUOTA_DEFAULTS["free"])
return SubscriptionResponse(
tier=tier,
status=sub.status if sub else "active",
plan_name=plan.name if plan else (sub.tier if sub else "免费版"),
started_at=sub.started_at.isoformat() if (sub and sub.started_at) else None,
expires_at=sub.expires_at.isoformat() if (sub and sub.expires_at) else None,
auto_renew=sub.auto_renew if sub else False,
daily_quota=plan.daily_quota if plan else quota["daily_quota"],
agent_limit=plan.agent_limit if plan else quota["agent_limit"],
knowledge_limit=plan.knowledge_limit if plan else quota["knowledge_limit"],
file_upload_mb=plan.file_upload_mb if plan else quota["file_upload_mb"],
models=plan.models if plan else None,
features=plan.features if plan else None,
)
def _get_daily_limit(tier: str) -> int:
plan = QUOTA_DEFAULTS.get(tier)
return plan["daily_quota"] if plan else 50
# ─── Endpoints ───
@router.get("/plans", response_model=List[PlanResponse])
async def list_plans(
db: Session = Depends(get_db),
):
"""获取所有可用套餐"""
return db.query(BillingPlan).filter(BillingPlan.is_active == True).order_by(BillingPlan.sort_order.asc()).all()
@router.get("/user/subscription", response_model=SubscriptionResponse)
async def get_user_subscription(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取当前用户订阅状态与权益"""
return _get_user_subscription_info(db, current_user)
@router.get("/user/usage", response_model=UsageResponse)
async def get_user_usage(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取当前用户今日用量"""
today = date.today()
sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first()
tier = sub.tier if sub else (current_user.subscription_tier or "free")
daily_limit = _get_daily_limit(tier)
# 如果日期变了,重置计数
if current_user.daily_usage_date and current_user.daily_usage_date < today:
current_user.daily_usage_count = 0
current_user.daily_usage_date = today
db.commit()
used = current_user.daily_usage_count or 0
remaining = max(0, daily_limit - used) if daily_limit > 0 else 999999
return UsageResponse(
daily_used=used,
daily_limit=daily_limit,
daily_remaining=remaining,
is_exhausted=daily_limit > 0 and used >= daily_limit,
)
@router.post("/orders", response_model=OrderResponse)
async def create_order(
req: CreateOrderRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""创建订单"""
plan = db.query(BillingPlan).filter(BillingPlan.id == req.plan_id, BillingPlan.is_active == True).first()
if not plan:
raise HTTPException(status_code=404, detail="套餐不存在")
if plan.tier == "free":
raise HTTPException(status_code=400, detail="免费套餐无需购买")
amount = plan.price_yearly if req.period == "yearly" else plan.price_monthly
if amount <= 0:
raise HTTPException(status_code=400, detail="套餐价格无效")
order = BillingOrder(
user_id=current_user.id,
plan_id=plan.id,
amount=amount,
period=req.period,
status="pending",
payment_method=req.payment_method,
)
db.add(order)
db.commit()
db.refresh(order)
return OrderResponse(
id=order.id,
plan_id=order.plan_id,
amount=order.amount,
period=order.period,
status=order.status,
payment_method=order.payment_method,
payment_ref=order.payment_ref,
created_at=order.created_at.isoformat() if order.created_at else None,
paid_at=order.paid_at.isoformat() if order.paid_at else None,
)
@router.get("/orders/{order_id}", response_model=OrderResponse)
async def get_order(
order_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""查询订单状态"""
order = db.query(BillingOrder).filter(
BillingOrder.id == order_id,
BillingOrder.user_id == current_user.id,
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
return OrderResponse(
id=order.id,
plan_id=order.plan_id,
amount=order.amount,
period=order.period,
status=order.status,
payment_method=order.payment_method,
payment_ref=order.payment_ref,
created_at=order.created_at.isoformat() if order.created_at else None,
paid_at=order.paid_at.isoformat() if order.paid_at else None,
)
@router.post("/orders/{order_id}/pay")
async def pay_order(
order_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""模拟支付Phase 4 替换为真实支付)"""
order = db.query(BillingOrder).filter(
BillingOrder.id == order_id,
BillingOrder.user_id == current_user.id,
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail="订单状态不允许支付")
plan = db.query(BillingPlan).filter(BillingPlan.id == order.plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="套餐不存在")
# 计算到期时间
days = 365 if order.period == "yearly" else 30
now = datetime.utcnow()
expires_at = now + timedelta(days=days)
# 更新订单状态
order.status = "paid"
order.paid_at = now
order.payment_ref = f"mock_{order.id}"
# 创建或更新订阅
sub = db.query(UserSubscription).filter(UserSubscription.user_id == current_user.id).first()
if not sub:
sub = UserSubscription(user_id=current_user.id)
db.add(sub)
sub.plan_id = plan.id
sub.tier = plan.tier
sub.status = "active"
sub.started_at = now
sub.expires_at = expires_at
# 更新用户模型
current_user.subscription_tier = plan.tier
current_user.subscription_expires_at = expires_at
current_user.daily_usage_count = 0
current_user.daily_usage_date = date.today()
db.commit()
return {
"success": True,
"tier": plan.tier,
"expires_at": expires_at.isoformat(),
"message": f"已升级到 {plan.name},有效期至 {expires_at.strftime('%Y-%m-%d')}",
}
@router.post("/callback/wechat")
async def wechat_callback():
"""微信支付回调Phase 4 实现)"""
return {"code": "SUCCESS", "message": "not implemented yet"}
@router.post("/callback/alipay")
async def alipay_callback():
"""支付宝回调Phase 4 实现)"""
return {"code": "SUCCESS", "message": "not implemented yet"}