- 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>
304 lines
9.7 KiB
Python
304 lines
9.7 KiB
Python
"""
|
||
付费体系 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"}
|