diff --git a/backend/python/app/api/portal/user_portal.py b/backend/python/app/api/portal/user_portal.py new file mode 100644 index 0000000..7deccd6 --- /dev/null +++ b/backend/python/app/api/portal/user_portal.py @@ -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] + } diff --git a/backend/python/main.py b/backend/python/main.py index ed3b230..48b0e2a 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -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 diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 2e7c6b3..0224f86 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -65,6 +65,21 @@ function post(endpoint: string, body?: any): Promise<{ code: number; data: T; }); } +export async function put(endpoint: string, body?: any): Promise { + const token = localStorage.getItem('aiflow_token'); + const res = await fetch(API_BASE + endpoint, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return res.json(); +} + + // PUT请求 function put(endpoint: string, body?: any): Promise<{ code: number; data: T; message: string }> { return request(endpoint, {