Fix: User Portal deployment - add put function, portal backend, frontend build fix

This commit is contained in:
qyyfw committed 2026-07-07 20:50:21 +08:00
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]
}