Fix: User Portal deployment - add put function, portal backend, frontend build fix
This commit is contained in:
1 parent
144af68a0a
commit
66e8b633ea
3 files changed
+521
No files matched your search
@@ -0,0 +1,505 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User Portal Backend API - provides user-scoped endpoints (not admin).
|
||||
Endpoints for: overview, API keys, billing/recharge, tenants, analytics.
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
from ...database import SessionLocal
|
||||
from ..auth import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
CST_TZ = None
|
||||
from datetime import timezone, timedelta as td
|
||||
CST_TZ = timezone(td(hours=8))
|
||||
|
||||
def _format_cst_time(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if isinstance(dt, str):
|
||||
return dt
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=CST_TZ)
|
||||
return dt.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
# ==================== Overview ====================
|
||||
|
||||
@router.get("/overview")
|
||||
async def get_overview(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""用户门户概览"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
# User stats
|
||||
user_info = db.execute(text("""
|
||||
SELECT id, username, email, avatar, role, membership_level, membership_expire_at, created_at
|
||||
FROM users WHERE id = :uid
|
||||
"""), {"uid": user_id}).fetchone()
|
||||
|
||||
if not user_info:
|
||||
raise HTTPException(status_code=404, detail="用户不存在")
|
||||
|
||||
# Token usage this month
|
||||
now = datetime.now()
|
||||
month_start = now.replace(day=1, hour=0, minute=0, second=0)
|
||||
token_stats = db.execute(text("""
|
||||
SELECT
|
||||
COALESCE(SUM(total_tokens), 0) as total_tokens,
|
||||
COALESCE(SUM(prompt_tokens), 0) as prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) as completion_tokens,
|
||||
COUNT(*) as call_count
|
||||
FROM token_usage_logs
|
||||
WHERE user_id = :uid AND created_at >= :month_start
|
||||
"""), {"uid": user_id, "month_start": month_start}).fetchone()
|
||||
|
||||
# API calls last 7 days
|
||||
seven_days_ago = now - timedelta(days=7)
|
||||
api_calls = db.execute(text("""
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count, COALESCE(SUM(total_tokens), 0) as tokens
|
||||
FROM token_usage_logs
|
||||
WHERE user_id = :uid AND created_at >= :since
|
||||
GROUP BY DATE(created_at) ORDER BY date
|
||||
"""), {"uid": user_id, "since": seven_days_ago}).fetchall()
|
||||
|
||||
daily_data = [{"date": str(r.date), "calls": r.count, "tokens": r.tokens} for r in api_calls]
|
||||
|
||||
# Tenants
|
||||
tenants = db.execute(text("""
|
||||
SELECT t.id, t.name, t.slug, t.status, tm.role
|
||||
FROM tenant_members tm
|
||||
JOIN tenants t ON tm.tenant_id = t.id
|
||||
WHERE tm.user_id = :uid AND tm.status = 'active'
|
||||
"""), {"uid": user_id}).fetchall()
|
||||
|
||||
# Recent transactions
|
||||
recent_txns = db.execute(text("""
|
||||
SELECT id, transaction_type, amount, tokens, description, status, created_at
|
||||
FROM billing_transactions
|
||||
WHERE user_id = :uid
|
||||
ORDER BY created_at DESC LIMIT 5
|
||||
"""), {"uid": user_id}).fetchall()
|
||||
|
||||
# API keys count
|
||||
api_key_count = db.execute(text("""
|
||||
SELECT COUNT(*) FROM api_keys WHERE user_id = :uid AND status = 'active'
|
||||
"""), {"uid": user_id}).scalar() or 0
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"user": {
|
||||
"id": user_info.id,
|
||||
"username": user_info.username,
|
||||
"email": user_info.email,
|
||||
"avatar": user_info.avatar,
|
||||
"role": user_info.role,
|
||||
"membership_level": user_info.membership_level,
|
||||
"membership_expire_at": _format_cst_time(user_info.membership_expire_at),
|
||||
"created_at": _format_cst_time(user_info.created_at),
|
||||
},
|
||||
"token_stats": {
|
||||
"total_tokens": token_stats.total_tokens if token_stats else 0,
|
||||
"prompt_tokens": token_stats.prompt_tokens if token_stats else 0,
|
||||
"completion_tokens": token_stats.completion_tokens if token_stats else 0,
|
||||
"call_count": token_stats.call_count if token_stats else 0,
|
||||
},
|
||||
"daily_usage": daily_data,
|
||||
"tenants": [{"id": t.id, "name": t.name, "slug": t.slug, "status": t.status, "role": t.role} for t in tenants],
|
||||
"recent_transactions": [{
|
||||
"id": r.id, "type": r.transaction_type, "amount": float(r.amount) if r.amount else 0,
|
||||
"tokens": r.tokens, "description": r.description, "status": r.status,
|
||||
"created_at": _format_cst_time(r.created_at)
|
||||
} for r in recent_txns],
|
||||
"api_key_count": api_key_count,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ==================== API Keys ====================
|
||||
|
||||
@router.get("/api-keys")
|
||||
async def list_api_keys(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""用户API密钥列表"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
rows = db.execute(text("""
|
||||
SELECT id, key_name, key_prefix, status, created_at, last_used_at
|
||||
FROM api_keys
|
||||
WHERE user_id = :uid
|
||||
ORDER BY created_at DESC
|
||||
"""), {"uid": user_id}).fetchall()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": [{
|
||||
"id": r.id,
|
||||
"key_name": r.key_name,
|
||||
"key_prefix": r.key_prefix,
|
||||
"status": r.status,
|
||||
"created_at": _format_cst_time(r.created_at),
|
||||
"last_used_at": _format_cst_time(r.last_used_at),
|
||||
} for r in rows]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api-keys/create")
|
||||
async def create_api_key(
|
||||
key_name: str = Query(..., description="密钥名称"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""创建API密钥"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
# Generate API key
|
||||
raw_key = secrets.token_hex(32)
|
||||
key_prefix = "sk-" + raw_key[:8]
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
key_id = str(uuid.uuid4())
|
||||
|
||||
# Check if api_keys table exists, create if not
|
||||
db.execute(text("""
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
user_id VARCHAR(36) NOT NULL,
|
||||
key_name VARCHAR(100) NOT NULL,
|
||||
key_prefix VARCHAR(20) NOT NULL,
|
||||
key_hash VARCHAR(128) NOT NULL,
|
||||
status ENUM('active', 'revoked') DEFAULT 'active',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used_at DATETIME NULL,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_status (status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
"""))
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO api_keys (id, user_id, key_name, key_prefix, key_hash, status)
|
||||
VALUES (:id, :uid, :name, :prefix, :hash, 'active')
|
||||
"""), {
|
||||
"id": key_id, "uid": user_id,
|
||||
"name": key_name, "prefix": key_prefix, "hash": key_hash
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"id": key_id,
|
||||
"key_name": key_name,
|
||||
"key_prefix": key_prefix,
|
||||
"api_key": raw_key,
|
||||
"message": "请妥善保存密钥,此为唯一一次显示完整密钥"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/api-keys/{key_id}")
|
||||
async def revoke_api_key(
|
||||
key_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""撤销API密钥"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
result = db.execute(text("""
|
||||
UPDATE api_keys SET status = 'revoked'
|
||||
WHERE id = :kid AND user_id = :uid
|
||||
"""), {"kid": key_id, "uid": user_id})
|
||||
db.commit()
|
||||
|
||||
if result.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="密钥不存在或无权操作")
|
||||
|
||||
return {"code": 200, "data": {"message": "密钥已撤销"}}
|
||||
|
||||
|
||||
# ==================== Billing / Recharge ====================
|
||||
|
||||
@router.get("/billing/packages")
|
||||
async def list_packages(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""可用充值套餐"""
|
||||
rows = db.execute(text("""
|
||||
SELECT id, name, description, tokens, price, original_price, bonus_tokens, is_popular, status
|
||||
FROM recharge_packages
|
||||
WHERE status = 'active'
|
||||
ORDER BY price ASC
|
||||
""")).fetchall()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": [{
|
||||
"id": r.id, "name": r.name, "description": r.description,
|
||||
"tokens": r.tokens, "price": float(r.price) if r.price else 0,
|
||||
"original_price": float(r.original_price) if r.original_price else None,
|
||||
"bonus_tokens": r.bonus_tokens or 0,
|
||||
"is_popular": bool(r.is_popular) if r.is_popular else False,
|
||||
} for r in rows]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/billing/transactions")
|
||||
async def list_user_transactions(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""用户交易记录"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
total = db.execute(text("""
|
||||
SELECT COUNT(*) FROM billing_transactions WHERE user_id = :uid
|
||||
"""), {"uid": user_id}).scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
rows = db.execute(text("""
|
||||
SELECT id, transaction_type, amount, tokens, balance_after, model_name,
|
||||
description, status, created_at
|
||||
FROM billing_transactions
|
||||
WHERE user_id = :uid
|
||||
ORDER BY created_at DESC LIMIT :limit OFFSET :offset
|
||||
"""), {"uid": user_id, "limit": page_size, "offset": offset}).fetchall()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"items": [{
|
||||
"id": r.id, "type": r.transaction_type,
|
||||
"amount": float(r.amount) if r.amount else 0,
|
||||
"tokens": r.tokens, "balance_after": r.balance_after,
|
||||
"model_name": r.model_name, "description": r.description,
|
||||
"status": r.status, "created_at": _format_cst_time(r.created_at)
|
||||
} for r in rows],
|
||||
"total": total, "page": page, "page_size": page_size
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.post("/billing/recharge")
|
||||
async def recharge(
|
||||
package_id: str = Query(..., description="套餐ID"),
|
||||
payment_method: str = Query("wechat", description="支付方式"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""充值购买"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
# Get package info
|
||||
pkg = db.execute(text("""
|
||||
SELECT id, name, tokens, price FROM recharge_packages
|
||||
WHERE id = :pid AND status = 'active'
|
||||
"""), {"pid": package_id}).fetchone()
|
||||
|
||||
if not pkg:
|
||||
raise HTTPException(status_code=404, detail="套餐不存在")
|
||||
|
||||
# Create transaction
|
||||
txn_id = str(uuid.uuid4())
|
||||
db.execute(text("""
|
||||
INSERT INTO billing_transactions (id, user_id, transaction_type, amount, tokens, description, metadata, status)
|
||||
VALUES (:id, :uid, 'recharge', :amount, :tokens, :desc, :meta, 'pending')
|
||||
"""), {
|
||||
"id": txn_id, "uid": user_id,
|
||||
"amount": float(pkg.price), "tokens": pkg.tokens,
|
||||
"desc": f"充值 - {pkg.name}",
|
||||
"meta": json.dumps({"package_id": package_id, "payment_method": payment_method})
|
||||
})
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"transaction_id": txn_id,
|
||||
"amount": float(pkg.price),
|
||||
"tokens": pkg.tokens,
|
||||
"payment_method": payment_method,
|
||||
"status": "pending",
|
||||
"message": "订单已创建,请完成支付"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ==================== Tenants ====================
|
||||
|
||||
@router.get("/tenants")
|
||||
async def list_user_tenants(
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""用户所属租户列表"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
rows = db.execute(text("""
|
||||
SELECT t.id, t.name, t.slug, t.description, t.status, t.max_members,
|
||||
t.max_storage_mb, t.max_api_calls_per_day, tm.role, tm.joined_at,
|
||||
(SELECT COUNT(*) FROM tenant_members tm2 WHERE tm2.tenant_id = t.id AND tm2.status = 'active') as member_count
|
||||
FROM tenant_members tm
|
||||
JOIN tenants t ON tm.tenant_id = t.id
|
||||
WHERE tm.user_id = :uid AND tm.status = 'active'
|
||||
ORDER BY tm.joined_at DESC
|
||||
"""), {"uid": user_id}).fetchall()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": [{
|
||||
"id": r.id, "name": r.name, "slug": r.slug,
|
||||
"description": r.description, "status": r.status,
|
||||
"max_members": r.max_members, "max_storage_mb": r.max_storage_mb,
|
||||
"max_api_calls_per_day": r.max_api_calls_per_day,
|
||||
"role": r.role, "joined_at": _format_cst_time(r.joined_at),
|
||||
"member_count": r.member_count
|
||||
} for r in rows]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tenants/create")
|
||||
async def create_tenant(
|
||||
name: str = Query(..., description="租户名称"),
|
||||
slug: str = Query(..., description="租户标识"),
|
||||
description: str = Query("", description="描述"),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""创建租户"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
|
||||
# Check slug uniqueness
|
||||
existing = db.execute(text("SELECT id FROM tenants WHERE slug = :slug"), {"slug": slug}).fetchone()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="租户标识已存在")
|
||||
|
||||
tenant_id = str(uuid.uuid4())
|
||||
member_id = str(uuid.uuid4())
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO tenants (id, name, slug, description, status, max_members, max_storage_mb, max_api_calls_per_day)
|
||||
VALUES (:id, :name, :slug, :desc, 'active', 10, 10240, 10000)
|
||||
"""), {"id": tenant_id, "name": name, "slug": slug, "desc": description})
|
||||
|
||||
db.execute(text("""
|
||||
INSERT INTO tenant_members (id, tenant_id, user_id, role, status)
|
||||
VALUES (:mid, :tid, :uid, 'owner', 'active')
|
||||
"""), {"mid": member_id, "tid": tenant_id, "uid": user_id})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"tenant_id": tenant_id,
|
||||
"name": name,
|
||||
"slug": slug,
|
||||
"message": "租户创建成功"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ==================== Analytics ====================
|
||||
|
||||
@router.get("/analytics/usage")
|
||||
async def get_usage_analytics(
|
||||
days: int = Query(30, ge=1, le=365),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""个人用量分析"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
# Daily usage
|
||||
daily = db.execute(text("""
|
||||
SELECT DATE(created_at) as date,
|
||||
COUNT(*) as calls,
|
||||
COALESCE(SUM(total_tokens), 0) as tokens,
|
||||
COALESCE(SUM(prompt_tokens), 0) as prompt_tokens,
|
||||
COALESCE(SUM(completion_tokens), 0) as completion_tokens
|
||||
FROM token_usage_logs
|
||||
WHERE user_id = :uid AND created_at >= :since
|
||||
GROUP BY DATE(created_at) ORDER BY date
|
||||
"""), {"uid": user_id, "since": since}).fetchall()
|
||||
|
||||
# Model distribution
|
||||
models = db.execute(text("""
|
||||
SELECT model_name, COUNT(*) as calls, COALESCE(SUM(total_tokens), 0) as tokens
|
||||
FROM token_usage_logs
|
||||
WHERE user_id = :uid AND created_at >= :since AND model_name IS NOT NULL
|
||||
GROUP BY model_name ORDER BY tokens DESC
|
||||
"""), {"uid": user_id, "since": since}).fetchall()
|
||||
|
||||
# Total stats
|
||||
total = db.execute(text("""
|
||||
SELECT COUNT(*) as calls, COALESCE(SUM(total_tokens), 0) as tokens
|
||||
FROM token_usage_logs WHERE user_id = :uid AND created_at >= :since
|
||||
"""), {"uid": user_id, "since": since}).fetchone()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": {
|
||||
"daily": [{
|
||||
"date": str(r.date), "calls": r.calls,
|
||||
"tokens": r.tokens, "prompt_tokens": r.prompt_tokens,
|
||||
"completion_tokens": r.completion_tokens
|
||||
} for r in daily],
|
||||
"model_distribution": [{
|
||||
"model": r.model_name, "calls": r.calls, "tokens": r.tokens
|
||||
} for r in models],
|
||||
"total": {
|
||||
"calls": total.calls if total else 0,
|
||||
"tokens": total.tokens if total else 0
|
||||
},
|
||||
"days": days
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics/trends")
|
||||
async def get_usage_trends(
|
||||
days: int = Query(7, ge=1, le=90),
|
||||
db: Session = Depends(get_db),
|
||||
user=Depends(get_current_user)
|
||||
):
|
||||
"""用量趋势"""
|
||||
user_id = user.get("id", user.get("sub", "")) if isinstance(user, dict) else str(user)
|
||||
since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
|
||||
|
||||
rows = db.execute(text("""
|
||||
SELECT DATE(created_at) as date, COUNT(*) as calls,
|
||||
COALESCE(SUM(total_tokens), 0) as tokens
|
||||
FROM token_usage_logs
|
||||
WHERE user_id = :uid AND created_at >= :since
|
||||
GROUP BY DATE(created_at) ORDER BY date
|
||||
"""), {"uid": user_id, "since": since}).fetchall()
|
||||
|
||||
return {
|
||||
"code": 200,
|
||||
"data": [{
|
||||
"date": str(r.date), "calls": r.calls, "tokens": r.tokens
|
||||
} for r in rows]
|
||||
}
|
||||
@@ -35,6 +35,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin , tenant_admin, billing_admin, analytics_admin
|
||||
from app.api.portal import user_portal
|
||||
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||
from app.api.admin.mcp_market_admin import router as mcp_market_router
|
||||
from app.api.admin import asset_admin, achievement_admin
|
||||
|
||||
Reference in new issue
Block a user