diff --git a/backend/python/app/api/admin/analytics_admin.py b/backend/python/app/api/admin/analytics_admin.py new file mode 100644 index 0000000..2b68c4d --- /dev/null +++ b/backend/python/app/api/admin/analytics_admin.py @@ -0,0 +1,424 @@ +""" +数据分析管理后端 API +- 事件采集、聚合统计、仪表盘配置、趋势分析 +- 路由前缀: /app/api/admin/analytics +""" +import uuid +import json +from datetime import datetime, timedelta, date +from fastapi import APIRouter, Query, HTTPException, Depends +from ...database import SessionLocal, get_db +from ..auth import get_current_user +from sqlalchemy import text +from sqlalchemy.orm import Session + +router = APIRouter() + +CST_TZ = None + +def _format_cst_time(dt_val): + global CST_TZ + if CST_TZ is None: + from datetime import timezone + CST_TZ = timezone(timedelta(hours=8)) + if dt_val is None: + return None + if isinstance(dt_val, str): + return dt_val + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=CST_TZ) + return dt_val.strftime("%Y-%m-%d %H:%M:%S") + + +# ============================================================ +# 统计概览 +# ============================================================ + +@router.get("/overview") +async def analytics_overview( + days: int = Query(30, description="统计天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """数据分析总览""" + since = datetime.now() - timedelta(days=days) + + # User stats + total_users = db.execute(text("SELECT COUNT(*) FROM users")).scalar() + active_users = db.execute(text(""" + SELECT COUNT(DISTINCT user_id) FROM token_usage_logs + WHERE created_at >= :since + """), {"since": since}).scalar() + new_users = db.execute(text(""" + SELECT COUNT(*) FROM users WHERE created_at >= :since + """), {"since": since}).scalar() + + # API stats + api_calls = db.execute(text(""" + SELECT COALESCE(SUM(call_count), 0) FROM api_call_stats + WHERE updated_at >= :since + """), {"since": since}).scalar() + avg_response = db.execute(text(""" + SELECT COALESCE(AVG(total_response_time/NULLIF(call_count,0)), 0) FROM api_call_stats + WHERE updated_at >= :since + """), {"since": since}).scalar() + error_rate = db.execute(text(""" + SELECT COALESCE(SUM(error_count)/NULLIF(SUM(call_count),0)*100, 0) FROM api_call_stats + WHERE updated_at >= :since + """), {"since": since}).scalar() + + # Token stats + tokens_used = db.execute(text(""" + SELECT COALESCE(SUM(total_tokens), 0) FROM token_usage_logs + WHERE created_at >= :since + """), {"since": since}).scalar() + total_cost = db.execute(text(""" + SELECT COALESCE(SUM(cost), 0) FROM token_usage_logs + WHERE created_at >= :since + """), {"since": since}).scalar() + + # Chat stats + total_chats = db.execute(text("SELECT COUNT(*) FROM conversations")).scalar() + total_messages = db.execute(text("SELECT COUNT(*) FROM messages")).scalar() + + # Revenue (from billing_transactions) + revenue = db.execute(text(""" + SELECT COALESCE(SUM(amount), 0) FROM billing_transactions + WHERE transaction_type='recharge' AND status='completed' AND created_at >= :since + """), {"since": since}).scalar() + + return {"code": 200, "data": { + "total_users": total_users, + "active_users": active_users, + "new_users": new_users, + "api_calls": api_calls, + "avg_response_time": round(float(avg_response), 2), + "error_rate": round(float(error_rate), 2), + "tokens_used": tokens_used, + "total_cost": float(total_cost), + "total_chats": total_chats, + "total_messages": total_messages, + "revenue": float(revenue), + "period_days": days, + }} + + +# ============================================================ +# 趋势分析 +# ============================================================ + +@router.get("/trends/api-calls") +async def api_call_trends( + days: int = Query(7, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """API调用趋势(按天)""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, + COUNT(*) as calls, + COALESCE(SUM(CASE WHEN status='error' THEN 1 ELSE 0 END), 0) as errors + FROM analytics_events + WHERE event_type='api_call' AND created_at >= :since + GROUP BY DATE(created_at) ORDER BY d + """), {"since": since}).fetchall() + + if not rows: + # Fallback to api_call_stats + rows = db.execute(text(""" + SELECT DATE(updated_at) as d, SUM(call_count) as calls, SUM(error_count) as errors + FROM api_call_stats WHERE updated_at >= :since + GROUP BY DATE(updated_at) ORDER BY d + """), {"since": since}).fetchall() + + data = [{"date": str(r[0]), "calls": int(r[1] or 0), "errors": int(r[2] or 0)} for r in rows] + return {"code": 200, "data": data} + + +@router.get("/trends/tokens") +async def token_usage_trends( + days: int = Query(7, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """Token使用趋势(按天+按模型)""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, model_name, + SUM(total_tokens) as tokens, SUM(cost) as cost + FROM token_usage_logs + WHERE created_at >= :since + GROUP BY DATE(created_at), model_name + ORDER BY d, tokens DESC + """), {"since": since}).fetchall() + + # Group by date + by_date = {} + for r in rows: + d = str(r[0]) + if d not in by_date: + by_date[d] = {"date": d, "total_tokens": 0, "models": {}} + by_date[d]["total_tokens"] += int(r[2] or 0) + model = r[1] or "unknown" + by_date[d]["models"][model] = int(r[2] or 0) + + # Model distribution + model_dist = {} + for r in rows: + model = r[1] or "unknown" + model_dist[model] = model_dist.get(model, 0) + int(r[2] or 0) + + return {"code": 200, "data": { + "daily": list(by_date.values()), + "model_distribution": model_dist, + }} + + +@router.get("/trends/users") +async def user_growth_trends( + days: int = Query(30, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """用户增长趋势""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, COUNT(*) as new_users + FROM users WHERE created_at >= :since + GROUP BY DATE(created_at) ORDER BY d + """), {"since": since}).fetchall() + + data = [{"date": str(r[0]), "new_users": int(r[1])} for r in rows] + + # Cumulative + cumulative = 0 + total_before = db.execute(text("SELECT COUNT(*) FROM users WHERE created_at < :since"), + {"since": since}).scalar() + cumulative = total_before + for item in data: + cumulative += item["new_users"] + item["cumulative"] = cumulative + + return {"code": 200, "data": data} + + +@router.get("/trends/revenue") +async def revenue_trends( + days: int = Query(30, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """收入趋势""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT DATE(created_at) as d, + COALESCE(SUM(CASE WHEN transaction_type='recharge' THEN amount ELSE 0 END), 0) as revenue, + COALESCE(SUM(CASE WHEN transaction_type='consumption' THEN amount ELSE 0 END), 0) as consumption + FROM billing_transactions + WHERE status='completed' AND created_at >= :since + GROUP BY DATE(created_at) ORDER BY d + """), {"since": since}).fetchall() + + data = [{"date": str(r[0]), "revenue": float(r[1] or 0), "consumption": float(r[2] or 0)} for r in rows] + return {"code": 200, "data": data} + + +# ============================================================ +# 模型使用分析 +# ============================================================ + +@router.get("/models/usage") +async def model_usage_analysis( + days: int = Query(30, description="天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """模型使用分析""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + rows = db.execute(text(""" + SELECT model_name, + COUNT(*) as call_count, + SUM(total_tokens) as total_tokens, + SUM(prompt_tokens) as prompt_tokens, + SUM(completion_tokens) as completion_tokens, + SUM(cost) as total_cost, + AVG(total_tokens) as avg_tokens + FROM token_usage_logs + WHERE created_at >= :since AND model_name IS NOT NULL + GROUP BY model_name + ORDER BY total_tokens DESC + """), {"since": since}).fetchall() + + data = [{ + "model_name": r[0], + "call_count": int(r[1]), + "total_tokens": int(r[2] or 0), + "prompt_tokens": int(r[3] or 0), + "completion_tokens": int(r[4] or 0), + "total_cost": float(r[5] or 0), + "avg_tokens": round(float(r[6] or 0), 0), + } for r in rows] + return {"code": 200, "data": data} + + +# ============================================================ +# 仪表盘配置 +# ============================================================ + +@router.get("/dashboards") +async def list_dashboards(db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取仪表盘配置列表""" + rows = db.execute(text(""" + SELECT ad.*, u.username as created_by_name + FROM analytics_dashboards ad + LEFT JOIN users u ON ad.created_by = u.id + ORDER BY ad.is_default DESC, ad.created_at DESC + """)).fetchall() + cols = ['id','tenant_id','name','description','config','is_default', + 'created_by','created_at','updated_at','created_by_name'] + items = [] + for r in rows: + d = _format_cst_time_dict(r, cols) + items.append(d) + return {"code": 200, "data": items} + + +def _format_cst_time_dict(row, columns): + result = {} + for col, val in zip(columns, row): + if isinstance(val, datetime): + result[col] = _format_cst_time(val) + elif isinstance(val, str) and val and val.startswith('[') or (isinstance(val, str) and val and val.startswith('{')): + try: + result[col] = json.loads(val) + except: + result[col] = val + else: + result[col] = val + return result + + +@router.post("/dashboards/create") +async def create_dashboard( + name: str = Query(...), + description: str = Query(""), + config: str = Query(..., description="仪表盘配置JSON"), + is_default: int = Query(0), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建仪表盘""" + did = str(uuid.uuid4()) + try: + config_json = json.loads(config) + except: + config_json = json.loads('[{"type":"stat","title":"暂无数据","metric":"none","chart_type":"number"}]') + + if is_default: + db.execute(text("UPDATE analytics_dashboards SET is_default=0")) + + db.execute(text(""" + INSERT INTO analytics_dashboards (id, name, description, config, is_default, created_by) + VALUES (:id, :name, :desc, :config, :default, :uid) + """), { + "id": did, "name": name, "desc": description, + "config": json.dumps(config_json), "default": is_default, "uid": user.id + }) + db.commit() + return {"code": 200, "data": {"id": did, "message": "仪表盘创建成功"}} + + +@router.delete("/dashboards/{dashboard_id}") +async def delete_dashboard(dashboard_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除仪表盘""" + db.execute(text("DELETE FROM analytics_dashboards WHERE id=:id AND is_default=0"), {"id": dashboard_id}) + db.commit() + return {"code": 200, "data": {"message": "仪表盘已删除"}} + + +# ============================================================ +# 事件记录 +# ============================================================ + +@router.post("/events/record") +async def record_event( + event_type: str = Query(..., description="事件类型"), + event_category: str = Query("general"), + event_data: str = Query("", description="事件数据JSON"), + duration_ms: int = Query(None), + status: str = Query("success"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """记录分析事件""" + data = None + if event_data: + try: + data = json.loads(event_data) + except: + data = {"raw": event_data} + + db.execute(text(""" + INSERT INTO analytics_events (tenant_id, user_id, event_type, event_category, event_data, duration_ms, status) + VALUES (:tid, :uid, :et, :ec, :data, :dur, :status) + """), { + "tid": None, "uid": user.id, "et": event_type, "ec": event_category, + "data": json.dumps(data) if data else None, + "dur": duration_ms, "status": status + }) + db.commit() + return {"code": 200, "data": {"message": "事件已记录"}} + + +@router.get("/events") +async def list_events( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + event_type: str = Query(""), + event_category: str = Query(""), + status: str = Query(""), + days: int = Query(7, description="查询天数"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取事件列表""" + since = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d') + where = "ae.created_at >= :since" + params = {"since": since} + if event_type: + where += " AND ae.event_type=:et" + params["et"] = event_type + if event_category: + where += " AND ae.event_category=:ec" + params["ec"] = event_category + if status: + where += " AND ae.status=:st" + params["st"] = status + + total = db.execute(text(f"SELECT COUNT(*) FROM analytics_events ae WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT ae.*, u.username + FROM analytics_events ae + LEFT JOIN users u ON ae.user_id = u.id + WHERE {where} + ORDER BY ae.created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','tenant_id','user_id','event_type','event_category','event_data', + 'ip_address','user_agent','duration_ms','status','created_at','username'] + items = [] + for r in rows: + d = {} + for col, val in zip(cols, r): + if isinstance(val, datetime): + d[col] = _format_cst_time(val) + elif isinstance(val, str) and val and (val.startswith('{') or val.startswith('[')): + try: + d[col] = json.loads(val) + except: + d[col] = val + else: + d[col] = val + items.append(d) + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} diff --git a/backend/python/app/api/admin/billing_admin.py b/backend/python/app/api/admin/billing_admin.py new file mode 100644 index 0000000..a3d84c0 --- /dev/null +++ b/backend/python/app/api/admin/billing_admin.py @@ -0,0 +1,462 @@ +""" +计费与支付管理后端 API +- 计费规则、充值套餐、交易记录、账单管理、支付配置 +- 路由前缀: /app/api/admin/billing +""" +import uuid +import json +from datetime import datetime, timedelta +from fastapi import APIRouter, Query, HTTPException, Depends +from ...database import SessionLocal, get_db +from ..auth import get_current_user +from sqlalchemy import text +from sqlalchemy.orm import Session + +router = APIRouter() + +CST_TZ = None + +def _format_cst_time(dt_val): + global CST_TZ + if CST_TZ is None: + from datetime import timezone + CST_TZ = timezone(timedelta(hours=8)) + if dt_val is None: + return None + if isinstance(dt_val, str): + return dt_val + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=CST_TZ) + return dt_val.strftime("%Y-%m-%d %H:%M:%S") + +def _row_to_dict(row, columns): + return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)} + + +# ============================================================ +# 计费规则 +# ============================================================ + +@router.get("/rules") +async def list_billing_rules( + status: str = Query("", description="状态筛选"), + billing_type: str = Query("", description="计费类型筛选"), + 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) +): + """获取计费规则列表""" + where = "1=1" + params = {} + if status: + where += " AND status=:status" + params["status"] = status + if billing_type: + where += " AND billing_type=:bt" + params["bt"] = billing_type + + total = db.execute(text(f"SELECT COUNT(*) FROM billing_rules WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT * FROM billing_rules WHERE {where} + ORDER BY created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','name','description','billing_type','model_id','model_name','rate','currency', + 'min_charge','free_quota','tiered_pricing','status','created_at','updated_at'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/rules/create") +async def create_billing_rule( + name: str = Query(...), + description: str = Query(""), + billing_type: str = Query("per_token"), + model_id: str = Query(""), + model_name: str = Query(""), + rate: float = Query(0.0), + min_charge: float = Query(0.01), + free_quota: int = Query(0), + tiered_pricing: str = Query("", description="阶梯定价JSON"), + status: str = Query("active"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建计费规则""" + rid = str(uuid.uuid4()) + tp = None + if tiered_pricing: + try: + tp = json.loads(tiered_pricing) + except: + tp = None + + db.execute(text(""" + INSERT INTO billing_rules (id, name, description, billing_type, model_id, model_name, rate, + min_charge, free_quota, tiered_pricing, status) + VALUES (:id, :name, :desc, :bt, :mid, :mname, :rate, :mc, :fq, :tp, :status) + """), { + "id": rid, "name": name, "desc": description, "bt": billing_type, + "mid": model_id or None, "mname": model_name or None, "rate": rate, + "mc": min_charge, "fq": free_quota, "tp": json.dumps(tp) if tp else None, + "status": status + }) + db.commit() + return {"code": 200, "data": {"id": rid, "message": "计费规则创建成功"}} + + +@router.put("/rules/{rule_id}") +async def update_billing_rule( + rule_id: str, + name: str = Query(None), + description: str = Query(None), + rate: float = Query(None), + min_charge: float = Query(None), + free_quota: int = Query(None), + status: str = Query(None), + tiered_pricing: str = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新计费规则""" + sets = [] + params = {"id": rule_id} + for field, val in [("name", name), ("description", description), ("rate", rate), + ("min_charge", min_charge), ("free_quota", free_quota), ("status", status)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if tiered_pricing is not None: + sets.append("tiered_pricing=:tp") + try: + params["tp"] = json.dumps(json.loads(tiered_pricing)) + except: + params["tp"] = None + + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + db.execute(text(f"UPDATE billing_rules SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "更新成功"}} + + +@router.delete("/rules/{rule_id}") +async def delete_billing_rule(rule_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除计费规则""" + db.execute(text("UPDATE billing_rules SET status='inactive' WHERE id=:id"), {"id": rule_id}) + db.commit() + return {"code": 200, "data": {"message": "规则已停用"}} + + +# ============================================================ +# 充值套餐 +# ============================================================ + +@router.get("/packages") +async def list_packages( + status: str = Query(""), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取充值套餐列表""" + where = "1=1" + params = {} + if status: + where += " AND status=:status" + params["status"] = status + rows = db.execute(text(f""" + SELECT * FROM recharge_packages WHERE {where} + ORDER BY sort_order ASC, price ASC + """), params).fetchall() + cols = ['id','name','description','tokens','bonus_tokens','price','original_price', + 'currency','valid_days','is_popular','sort_order','status','created_at','updated_at'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": items} + + +@router.post("/packages/create") +async def create_package( + name: str = Query(...), + description: str = Query(""), + tokens: int = Query(...), + bonus_tokens: int = Query(0), + price: float = Query(...), + original_price: float = Query(0), + valid_days: int = Query(365), + is_popular: int = Query(0), + sort_order: int = Query(0), + status: str = Query("active"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建充值套餐""" + pid = str(uuid.uuid4()) + db.execute(text(""" + INSERT INTO recharge_packages (id, name, description, tokens, bonus_tokens, price, original_price, + valid_days, is_popular, sort_order, status) + VALUES (:id, :name, :desc, :tokens, :bonus, :price, :oprice, :vd, :pop, :sort, :status) + """), { + "id": pid, "name": name, "desc": description, "tokens": tokens, "bonus": bonus_tokens, + "price": price, "oprice": original_price or None, "vd": valid_days, + "pop": is_popular, "sort": sort_order, "status": status + }) + db.commit() + return {"code": 200, "data": {"id": pid, "message": "套餐创建成功"}} + + +@router.put("/packages/{package_id}") +async def update_package( + package_id: str, + name: str = Query(None), + price: float = Query(None), + tokens: int = Query(None), + bonus_tokens: int = Query(None), + status: str = Query(None), + is_popular: int = Query(None), + sort_order: int = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新充值套餐""" + sets = [] + params = {"id": package_id} + for field, val in [("name", name), ("price", price), ("tokens", tokens), + ("bonus_tokens", bonus_tokens), ("status", status), + ("is_popular", is_popular), ("sort_order", sort_order)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + db.execute(text(f"UPDATE recharge_packages SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "更新成功"}} + + +@router.delete("/packages/{package_id}") +async def delete_package(package_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除充值套餐""" + db.execute(text("UPDATE recharge_packages SET status='inactive' WHERE id=:id"), {"id": package_id}) + db.commit() + return {"code": 200, "data": {"message": "套餐已停用"}} + + +# ============================================================ +# 交易记录 +# ============================================================ + +@router.get("/transactions") +async def list_transactions( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + user_id: str = Query("", description="用户筛选"), + transaction_type: str = Query("", description="类型筛选"), + start_date: str = Query("", description="开始日期"), + end_date: str = Query("", description="结束日期"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取交易记录""" + where = "1=1" + params = {} + if user_id: + where += " AND bt.user_id=:uid" + params["uid"] = user_id + if transaction_type: + where += " AND bt.transaction_type=:tt" + params["tt"] = transaction_type + if start_date: + where += " AND bt.created_at>=:sd" + params["sd"] = start_date + if end_date: + where += " AND bt.created_at<=:ed" + params["ed"] = end_date + " 23:59:59" + + total = db.execute(text(f"SELECT COUNT(*) FROM billing_transactions bt WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT bt.*, u.username + FROM billing_transactions bt + LEFT JOIN users u ON bt.user_id = u.id + WHERE {where} + ORDER BY bt.created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','tenant_id','user_id','order_id','transaction_type','amount','tokens', + 'balance_after','model_id','model_name','description','metadata','status', + 'created_at','username'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +# ============================================================ +# 账单管理 +# ============================================================ + +@router.get("/invoices") +async def list_invoices( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: str = Query(""), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取账单列表""" + where = "1=1" + params = {} + if status: + where += " AND status=:status" + params["status"] = status + + total = db.execute(text(f"SELECT COUNT(*) FROM invoices WHERE {where}"), params).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(f""" + SELECT i.*, u.username + FROM invoices i + LEFT JOIN users u ON i.user_id = u.id + WHERE {where} + ORDER BY i.created_at DESC LIMIT :limit OFFSET :offset + """), {**params, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','invoice_number','tenant_id','user_id','period_start','period_end', + 'subtotal','discount','tax','total','items','status','issued_at','paid_at', + 'due_at','payment_method','created_at','updated_at','username'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/invoices/generate") +async def generate_invoice( + user_id: str = Query(..., description="用户ID"), + period_start: str = Query(..., description="周期开始"), + period_end: str = Query(..., description="周期结束"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """生成账单""" + # Calculate usage from token_usage_logs + usage = db.execute(text(""" + SELECT model_name, SUM(total_tokens) as tokens, SUM(cost) as total_cost + FROM token_usage_logs + WHERE user_id=:uid AND created_at>=:ps AND created_at<=:pe + GROUP BY model_name + """), {"uid": user_id, "ps": period_start, "pe": period_end}).fetchall() + + if not usage: + return {"code": 200, "data": {"message": "该周期内无使用记录"}} + + items = [] + subtotal = 0 + for row in usage: + model_name = row[0] or "未知模型" + tokens = row[1] or 0 + cost = float(row[2] or 0) + items.append({"name": model_name, "quantity": tokens, "unit_price": cost / tokens if tokens > 0 else 0, "amount": cost}) + subtotal += cost + + invoice_number = f"INV-{datetime.now().strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:6]}" + iid = str(uuid.uuid4()) + + db.execute(text(""" + INSERT INTO invoices (id, invoice_number, user_id, period_start, period_end, subtotal, total, items, status) + VALUES (:id, :num, :uid, :ps, :pe, :sub, :total, :items, 'issued') + """), { + "id": iid, "num": invoice_number, "uid": user_id, + "ps": period_start, "pe": period_end, "sub": subtotal, + "total": subtotal, "items": json.dumps(items) + }) + db.commit() + return {"code": 200, "data": {"id": iid, "invoice_number": invoice_number, "total": subtotal, "items": items}} + + +# ============================================================ +# 支付配置 (扩展现有 payment_configs) +# ============================================================ + +@router.get("/payment-configs") +async def list_payment_configs(db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取支付配置列表""" + rows = db.execute(text("SELECT * FROM payment_configs ORDER BY created_at DESC")).fetchall() + cols = ['id','type','app_id','merchant_id','api_key','cert_path','gateway_url','status', + 'created_at','updated_at','api_v3_key','cert_serial_no','alipay_public_key', + 'app_private_key','notify_url','return_url'] + items = [] + for r in rows: + d = _row_to_dict(r, cols) + # Mask sensitive fields + d['api_key'] = '***' if d.get('api_key') else None + d['api_v3_key'] = '***' if d.get('api_v3_key') else None + d['app_private_key'] = '***' if d.get('app_private_key') else None + items.append(d) + return {"code": 200, "data": items} + + +@router.put("/payment-configs/{config_id}") +async def update_payment_config( + config_id: str, + status: str = Query(None), + app_id: str = Query(None), + merchant_id: str = Query(None), + gateway_url: str = Query(None), + notify_url: str = Query(None), + return_url: str = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新支付配置""" + sets = [] + params = {"id": config_id} + for field, val in [("status", status), ("app_id", app_id), ("merchant_id", merchant_id), + ("gateway_url", gateway_url), ("notify_url", notify_url), ("return_url", return_url)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + db.execute(text(f"UPDATE payment_configs SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "支付配置已更新"}} + + +# ============================================================ +# 统计 +# ============================================================ + +@router.get("/stats/overview") +async def billing_stats(db: Session = Depends(get_db), user=Depends(get_current_user)): + """计费统计概览""" + total_revenue = db.execute(text(""" + SELECT COALESCE(SUM(amount), 0) FROM billing_transactions + WHERE transaction_type='recharge' AND status='completed' + """)).scalar() or 0 + + total_consumption = db.execute(text(""" + SELECT COALESCE(SUM(amount), 0) FROM billing_transactions + WHERE transaction_type='consumption' AND status='completed' + """)).scalar() or 0 + + total_transactions = db.execute(text("SELECT COUNT(*) FROM billing_transactions")).scalar() + pending_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status IN ('issued','overdue')")).scalar() + paid_invoices = db.execute(text("SELECT COUNT(*) FROM invoices WHERE status='paid'")).scalar() + active_rules = db.execute(text("SELECT COUNT(*) FROM billing_rules WHERE status='active'")).scalar() + active_packages = db.execute(text("SELECT COUNT(*) FROM recharge_packages WHERE status='active'")).scalar() + + # Revenue by day (last 7 days) + revenue_7d = db.execute(text(""" + SELECT DATE(created_at) as d, COALESCE(SUM(amount), 0) as rev + FROM billing_transactions + WHERE transaction_type='recharge' AND status='completed' + AND created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY) + GROUP BY DATE(created_at) ORDER BY d + """)).fetchall() + daily_revenue = [{"date": str(r[0]), "revenue": float(r[1])} for r in revenue_7d] + + return {"code": 200, "data": { + "total_revenue": float(total_revenue), + "total_consumption": float(total_consumption), + "total_transactions": total_transactions, + "pending_invoices": pending_invoices, + "paid_invoices": paid_invoices, + "active_rules": active_rules, + "active_packages": active_packages, + "daily_revenue_7d": daily_revenue, + }} diff --git a/backend/python/app/api/admin/tenant_admin.py b/backend/python/app/api/admin/tenant_admin.py new file mode 100644 index 0000000..37f1184 --- /dev/null +++ b/backend/python/app/api/admin/tenant_admin.py @@ -0,0 +1,426 @@ +""" +多租户管理后端 API +- 租户 CRUD、成员管理、配额管理、邀请管理 +- 路由前缀: /app/api/admin/tenant +""" +import uuid +import json +from datetime import datetime, timedelta +from fastapi import APIRouter, Query, HTTPException, Depends +from ...database import SessionLocal, get_db +from ..auth import get_current_user +from sqlalchemy import text +from sqlalchemy.orm import Session + +router = APIRouter() + +CST_TZ = None + +def _format_cst_time(dt_val): + """Format datetime to CST string.""" + global CST_TZ + if CST_TZ is None: + from datetime import timezone + CST_TZ = timezone(timedelta(hours=8)) + if dt_val is None: + return None + if isinstance(dt_val, str): + return dt_val + if dt_val.tzinfo is None: + dt_val = dt_val.replace(tzinfo=CST_TZ) + return dt_val.strftime("%Y-%m-%d %H:%M:%S") + +def _row_to_dict(row, columns): + """Convert a SQLAlchemy row to dict.""" + return {col: _format_cst_time(val) if isinstance(val, datetime) else val for col, val in zip(columns, row)} + +def _execute_raw(db: Session, sql: str, params: dict = None): + """Execute raw SQL and return results.""" + result = db.execute(text(sql), params or {}) + if result.returns_rows: + cols = list(result.keys()) + rows = result.fetchall() + return [_row_to_dict(row, cols) for row in rows] + return [] + + +# ============================================================ +# 租户管理 +# ============================================================ + +@router.get("/list") +async def list_tenants( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + keyword: str = Query("", description="搜索关键词"), + status: str = Query("", description="状态筛选"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """获取租户列表""" + where = "1=1" + params = {} + if keyword: + where += " AND (name LIKE :kw OR slug LIKE :kw)" + params["kw"] = f"%{keyword}%" + if status: + where += " AND status = :status" + params["status"] = status + + count_sql = f"SELECT COUNT(*) FROM tenants WHERE {where}" + total = db.execute(text(count_sql), params).scalar() + + offset = (page - 1) * page_size + list_sql = f""" + SELECT t.*, + (SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count + FROM tenants t WHERE {where} + ORDER BY t.created_at DESC LIMIT :limit OFFSET :offset + """ + params["limit"] = page_size + params["offset"] = offset + rows = db.execute(text(list_sql), params).fetchall() + cols = list(db.execute(text(list_sql), params).keys()) + + items = [] + for row in rows: + d = _row_to_dict(row, cols) + items.append(d) + + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/create") +async def create_tenant( + name: str = Query(..., description="租户名称"), + slug: str = Query(..., description="租户标识"), + description: str = Query("", description="描述"), + max_members: int = Query(10, description="最大成员数"), + max_storage_mb: int = Query(10240, description="最大存储(MB)"), + max_api_calls_per_day: int = Query(10000, description="每日API调用上限"), + expire_at: str = Query("", description="到期时间"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建租户""" + tid = str(uuid.uuid4()) + expire = None + if expire_at: + try: + expire = datetime.fromisoformat(expire_at) + except: + pass + + # 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="slug已存在") + + db.execute(text(""" + INSERT INTO tenants (id, name, slug, description, status, max_members, max_storage_mb, max_api_calls_per_day, expire_at) + VALUES (:id, :name, :slug, :desc, 'active', :mm, :ms, :ma, :exp) + """), { + "id": tid, "name": name, "slug": slug, "desc": description, + "mm": max_members, "ms": max_storage_mb, "ma": max_api_calls_per_day, + "exp": expire + }) + + # Add creator as owner + db.execute(text(""" + INSERT INTO tenant_members (id, tenant_id, user_id, role, status) + VALUES (:mid, :tid, :uid, 'owner', 'active') + """), {"mid": str(uuid.uuid4()), "tid": tid, "uid": user.id}) + + db.commit() + return {"code": 200, "data": {"id": tid, "message": "租户创建成功"}} + + +@router.get("/{tenant_id}") +async def get_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取租户详情""" + row = db.execute(text(""" + SELECT t.*, + (SELECT COUNT(*) FROM tenant_members WHERE tenant_id=t.id AND status='active') as member_count, + (SELECT COUNT(*) FROM tenant_quotas WHERE tenant_id=t.id) as quota_count + FROM tenants t WHERE t.id=:id + """), {"id": tenant_id}).fetchone() + if not row: + raise HTTPException(status_code=404, detail="租户不存在") + cols = list(db.execute(text("SELECT t.* FROM tenants t WHERE t.id=:id"), {"id": tenant_id}).keys()) + # Re-query with proper cols + cols = ['id','name','slug','logo','description','status','plan_id','max_members','max_storage_mb', + 'max_api_calls_per_day','settings','created_at','updated_at','expire_at','member_count','quota_count'] + return {"code": 200, "data": _row_to_dict(row, cols)} + + +@router.put("/{tenant_id}") +async def update_tenant( + tenant_id: str, + name: str = Query(None), + description: str = Query(None), + status: str = Query(None), + max_members: int = Query(None), + max_storage_mb: int = Query(None), + max_api_calls_per_day: int = Query(None), + expire_at: str = Query(None), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新租户""" + sets = [] + params = {"id": tenant_id} + for field, val in [("name", name), ("description", description), ("status", status), + ("max_members", max_members), ("max_storage_mb", max_storage_mb), + ("max_api_calls_per_day", max_api_calls_per_day)]: + if val is not None: + sets.append(f"{field}=:{field}") + params[field] = val + if expire_at is not None: + sets.append("expire_at=:expire_at") + try: + params["expire_at"] = datetime.fromisoformat(expire_at) if expire_at else None + except: + params["expire_at"] = None + + if not sets: + return {"code": 200, "data": {"message": "无更新"}} + + db.execute(text(f"UPDATE tenants SET {','.join(sets)} WHERE id=:id"), params) + db.commit() + return {"code": 200, "data": {"message": "更新成功"}} + + +@router.delete("/{tenant_id}") +async def delete_tenant(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """删除租户(软删除 - 标记为terminated)""" + if tenant_id == "default-tenant-0001": + raise HTTPException(status_code=400, detail="默认租户不可删除") + db.execute(text("UPDATE tenants SET status='terminated' WHERE id=:id"), {"id": tenant_id}) + db.commit() + return {"code": 200, "data": {"message": "租户已终止"}} + + +# ============================================================ +# 成员管理 +# ============================================================ + +@router.get("/{tenant_id}/members") +async def list_members( + tenant_id: str, + 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) +): + """获取租户成员列表""" + total = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status!='removed'"), + {"tid": tenant_id}).scalar() + offset = (page - 1) * page_size + rows = db.execute(text(""" + SELECT tm.id, tm.tenant_id, tm.user_id, tm.role, tm.status, tm.joined_at, + u.username, u.email, u.avatar, u.role as user_role + FROM tenant_members tm + LEFT JOIN users u ON tm.user_id = u.id + WHERE tm.tenant_id=:tid AND tm.status!='removed' + ORDER BY tm.joined_at DESC LIMIT :limit OFFSET :offset + """), {"tid": tenant_id, "limit": page_size, "offset": offset}).fetchall() + cols = ['id','tenant_id','user_id','role','status','joined_at','username','email','avatar','user_role'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": {"items": items, "total": total, "page": page, "page_size": page_size}} + + +@router.post("/{tenant_id}/members/add") +async def add_member( + tenant_id: str, + user_id: str = Query(..., description="用户ID"), + role: str = Query("member", description="角色: owner/admin/member/viewer"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """添加成员到租户""" + # Check tenant exists + t = db.execute(text("SELECT max_members FROM tenants WHERE id=:id AND status='active'"), {"id": tenant_id}).fetchone() + if not t: + raise HTTPException(status_code=404, detail="租户不存在或已停用") + + # Check member limit + cnt = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"), + {"tid": tenant_id}).scalar() + if cnt >= t[0]: + raise HTTPException(status_code=400, detail="已达成员上限") + + # Check if already member + existing = db.execute(text("SELECT id FROM tenant_members WHERE tenant_id=:tid AND user_id=:uid"), + {"tid": tenant_id, "uid": user_id}).fetchone() + if existing: + db.execute(text("UPDATE tenant_members SET status='active', role=:role WHERE tenant_id=:tid AND user_id=:uid"), + {"tid": tenant_id, "uid": user_id, "role": role}) + else: + db.execute(text(""" + INSERT INTO tenant_members (id, tenant_id, user_id, role, status) + VALUES (:mid, :tid, :uid, :role, 'active') + """), {"mid": str(uuid.uuid4()), "tid": tenant_id, "uid": user_id, "role": role}) + + db.commit() + return {"code": 200, "data": {"message": "成员添加成功"}} + + +@router.put("/{tenant_id}/members/{member_id}") +async def update_member_role( + tenant_id: str, + member_id: str, + role: str = Query(..., description="新角色"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新成员角色""" + db.execute(text("UPDATE tenant_members SET role=:role WHERE id=:mid AND tenant_id=:tid"), + {"role": role, "mid": member_id, "tid": tenant_id}) + db.commit() + return {"code": 200, "data": {"message": "角色已更新"}} + + +@router.delete("/{tenant_id}/members/{member_id}") +async def remove_member(tenant_id: str, member_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """移除成员""" + db.execute(text("UPDATE tenant_members SET status='removed', removed_at=NOW() WHERE id=:mid AND tenant_id=:tid"), + {"mid": member_id, "tid": tenant_id}) + db.commit() + return {"code": 200, "data": {"message": "成员已移除"}} + + +# ============================================================ +# 配额管理 +# ============================================================ + +@router.get("/{tenant_id}/quotas") +async def get_quotas(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取租户配额使用情况""" + rows = db.execute(text(""" + SELECT tq.*, t.max_members, t.max_storage_mb, t.max_api_calls_per_day + FROM tenant_quotas tq + RIGHT JOIN tenants t ON tq.tenant_id = t.id + WHERE t.id=:tid + """), {"tid": tenant_id}).fetchall() + + # Build quota summary + quotas = { + "members": {"used": 0, "limit": 0}, + "storage": {"used": 0, "limit": 0}, + "api_calls": {"used": 0, "limit": 0}, + } + + # Get actual counts + member_count = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE tenant_id=:tid AND status='active'"), + {"tid": tenant_id}).scalar() + tenant_info = db.execute(text("SELECT max_members, max_storage_mb, max_api_calls_per_day FROM tenants WHERE id=:tid"), + {"tid": tenant_id}).fetchone() + + if tenant_info: + quotas["members"] = {"used": member_count, "limit": tenant_info[0]} + quotas["storage"] = {"used": 0, "limit": tenant_info[1]} + quotas["api_calls"] = {"used": 0, "limit": tenant_info[2]} + + # Get quota records + qrows = db.execute(text("SELECT * FROM tenant_quotas WHERE tenant_id=:tid"), {"tid": tenant_id}).fetchall() + qcols = ['id','tenant_id','resource_type','used','quota_limit','period','reset_at','updated_at'] + for r in qrows: + d = _row_to_dict(r, qcols) + rt = d['resource_type'] + if rt in quotas: + quotas[rt]["used"] = d['used'] + if d['quota_limit'] > 0: + quotas[rt]["limit"] = d['quota_limit'] + else: + quotas[rt] = {"used": d['used'], "limit": d['quota_limit']} + + return {"code": 200, "data": quotas} + + +@router.put("/{tenant_id}/quotas") +async def update_quotas( + tenant_id: str, + resource_type: str = Query(..., description="资源类型"), + quota_limit: int = Query(..., description="配额上限"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """更新租户配额""" + existing = db.execute(text("SELECT id FROM tenant_quotas WHERE tenant_id=:tid AND resource_type=:rt"), + {"tid": tenant_id, "rt": resource_type}).fetchone() + if existing: + db.execute(text("UPDATE tenant_quotas SET quota_limit=:ql WHERE tenant_id=:tid AND resource_type=:rt"), + {"ql": quota_limit, "tid": tenant_id, "rt": resource_type}) + else: + db.execute(text(""" + INSERT INTO tenant_quotas (id, tenant_id, resource_type, used, quota_limit, period) + VALUES (:id, :tid, :rt, 0, :ql, 'total') + """), {"id": str(uuid.uuid4()), "tid": tenant_id, "rt": resource_type, "ql": quota_limit}) + db.commit() + return {"code": 200, "data": {"message": "配额已更新"}} + + +# ============================================================ +# 邀请管理 +# ============================================================ + +@router.post("/{tenant_id}/invitations") +async def create_invitation( + tenant_id: str, + email: str = Query(..., description="邀请邮箱"), + role: str = Query("member", description="角色"), + db: Session = Depends(get_db), + user=Depends(get_current_user) +): + """创建租户邀请""" + import secrets + token = secrets.token_urlsafe(32) + expires = datetime.now() + timedelta(days=7) + + db.execute(text(""" + INSERT INTO tenant_invitations (id, tenant_id, email, role, token, status, invited_by, expires_at) + VALUES (:id, :tid, :email, :role, :token, 'pending', :uid, :exp) + """), { + "id": str(uuid.uuid4()), "tid": tenant_id, "email": email, + "role": role, "token": token, "uid": user.id, "exp": expires + }) + db.commit() + return {"code": 200, "data": {"token": token, "expires_at": _format_cst_time(expires)}} + + +@router.get("/{tenant_id}/invitations") +async def list_invitations(tenant_id: str, db: Session = Depends(get_db), user=Depends(get_current_user)): + """获取租户邀请列表""" + rows = db.execute(text(""" + SELECT ti.*, u.username as invited_by_name + FROM tenant_invitations ti + LEFT JOIN users u ON ti.invited_by = u.id + WHERE ti.tenant_id=:tid + ORDER BY ti.created_at DESC + """), {"tid": tenant_id}).fetchall() + cols = ['id','tenant_id','email','role','token','status','invited_by','accepted_by', + 'expires_at','created_at','invited_by_name'] + items = [_row_to_dict(r, cols) for r in rows] + return {"code": 200, "data": items} + + +# ============================================================ +# 统计 +# ============================================================ + +@router.get("/stats/overview") +async def tenant_stats(db: Session = Depends(get_db), user=Depends(get_current_user)): + """租户统计概览""" + total = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status!='terminated'")).scalar() + active = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='active'")).scalar() + suspended = db.execute(text("SELECT COUNT(*) FROM tenants WHERE status='suspended'")).scalar() + total_members = db.execute(text("SELECT COUNT(*) FROM tenant_members WHERE status='active'")).scalar() + total_invitations = db.execute(text("SELECT COUNT(*) FROM tenant_invitations WHERE status='pending'")).scalar() + + return {"code": 200, "data": { + "total_tenants": total, + "active_tenants": active, + "suspended_tenants": suspended, + "total_members": total_members, + "pending_invitations": total_invitations, + }} diff --git a/backend/python/main.py b/backend/python/main.py index 0de861e..ed3b230 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -1,454 +1,453 @@ -""" - -Qihu-AIFlow FastAPI 主应用 - -完整后端API,监听18080端口,所有接口以 /app 为前缀 - -""" - -import sys - -import os - -import time - -import uuid - -import logging - -from contextlib import asynccontextmanager - -from fastapi import FastAPI, Request - -from fastapi.middleware.cors import CORSMiddleware - -from starlette.middleware.base import BaseHTTPMiddleware - -from datetime import datetime - - - -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 -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 -from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD - -from app.database import SessionLocal - -from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel - - - -# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) - -import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 - - - -# 初始化文件日志(按天轮转) - -access_logger = setup_file_logging() - -logger = logging.getLogger("aiflow") - - - - - -class APITrackingMiddleware(BaseHTTPMiddleware): - - """API调用统计中间件 - 记录请求耗时,标记慢请求""" - - - - async def dispatch(self, request: Request, call_next): - - request_id = str(uuid.uuid4()) - - start_time = time.time() - - path = request.url.path - - method = request.method - - - - # 跳过健康检查和静态资源 - - skip_paths = ["/app/api/health", "/favicon.ico"] - - should_skip = any(path.startswith(p) for p in skip_paths) - - - - try: - - response = await call_next(request) - - elapsed = time.time() - start_time - - status_code = response.status_code - - - - if not should_skip: - - access_logger.info( - - f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" - - ) - - - - # 异步记录API调用统计 - - try: - - db = SessionLocal() - - try: - - _record_api_call(db, method, path, status_code, elapsed) - - finally: - - db.close() - - except Exception as e: - - logger.warning(f"API统计记录失败: {e}") - - - - return response - - except Exception as e: - - elapsed = time.time() - start_time - - if not should_skip: - - access_logger.info( - - f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" - - ) - - raise - - - - async def __call__(self, scope, receive, send): - - if scope["type"] != "http": - - await self.app(scope, receive, send) - - return - - await super().__call__(scope, receive, send) - - - - - -def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): - - """记录API调用到数据库统计表""" - - # 简化路径以减少基数(去掉动态ID) - - parts = path.split("/") - - simplified_parts = [] - - for part in parts: - - # 将UUID样式的路径段替换为{id} - - if len(part) == 36 and part.count("-") == 4: - - simplified_parts.append("{id}") - - # 将纯数字路径段替换为{id} - - elif part.isdigit(): - - simplified_parts.append("{id}") - - else: - - simplified_parts.append(part) - - simplified_path = "/".join(simplified_parts) - - - - # 统计记录通过数据库实现,简化版记录到日志 - - is_error = status_code >= 400 - - is_slow = elapsed >= SLOW_REQUEST_THRESHOLD - - - - if is_slow: - - logger.warning( - - f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" - - ) - - - - - -@asynccontextmanager - -async def lifespan(app: FastAPI): - - logger.info("Qihu-AIFlow v2.0.0 启动中...") - - - - # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) - - try: - - from app.services.evolution_service import EvolutionWatcherManager - - from app.models import UserEvolutionConfig - - db = SessionLocal() - - try: - - configs = db.query(UserEvolutionConfig).filter( - - UserEvolutionConfig.watcher_status == "running" - - ).all() - - if configs: - - watcher = EvolutionWatcherManager() - - for cfg in configs: - - try: - - watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) - - logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") - - except Exception as e: - - logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") - - logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") - - else: - - logger.info("[Evolution] 无待恢复的进化配置") - - finally: - - db.close() - - except Exception as e: - - logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") - - - - # 启动Redis事件总线 - - try: - - from app.event_bus import get_event_bus - - event_bus = get_event_bus() - - event_bus.start(num_consumers=2) - - logger.info("[EventBus] Redis事件总线已启动") - - except Exception as e: - - logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") - - - - logger.info("Qihu-AIFlow v2.0.0 启动完成") - - - - yield - - - - # 停止Redis事件总线 - - try: - - from app.event_bus import get_event_bus - - get_event_bus().stop() - - logger.info("[EventBus] Redis事件总线已停止") - - except Exception: - - pass - - - - # 优雅关闭:停止所有 Evolution Pipeline - - try: - - from app.services.evolution_service import EvolutionWatcherManager - - EvolutionWatcherManager().shutdown_all() - - logger.info("[Evolution] 所有Pipeline已停止") - - except Exception: - - pass - - - - logger.info("Qihu-AIFlow 正在关闭...") - - - - - -app = FastAPI( - - title="Qihu-AIFlow - 智能自动化PaaS平台", - - version="1.0.0", - - lifespan=lifespan, - -) - - - -# API调用追踪中间件 - -app.add_middleware(APITrackingMiddleware) - - - -# CORS配置 - -app.add_middleware( - - CORSMiddleware, - - allow_origins=["*"], - - allow_credentials=True, - - allow_methods=["*"], - - allow_headers=["*"], - -) - - - -# 前台 API - -app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) - -app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) - -app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) - -app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) - -app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) - -app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) - -app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) - -app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) - -app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) - -app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) - -app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) - - - -# 后台 API - -app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) - -app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) - -app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) - -app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) - -app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) - -app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) - -app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) - -app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) - -app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) - -app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) - -app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) - -app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) -app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) -app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"]) -app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"]) -app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) -app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) -app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) -app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) -app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) -app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) -app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) -app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) -app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) -app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) -app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) -app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"]) - -app.include_router( - achievement_admin.router, - prefix="/app/api/admin/achievement", - tags=["achievement"] -) - - - - -@app.get("/app/api/health") - -async def health_check(): - - return {"status": "ok", "service": "Qihu-AIFlow"} - +""" + +Qihu-AIFlow FastAPI 主应用 + +完整后端API,监听18080端口,所有接口以 /app 为前缀 + +""" + +import sys + +import os + +import time + +import uuid + +import logging + +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request + +from fastapi.middleware.cors import CORSMiddleware + +from starlette.middleware.base import BaseHTTPMiddleware + +from datetime import datetime + + + +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.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 +from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD + +from app.database import SessionLocal + +from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel + + + +# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) + +import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 + + + +# 初始化文件日志(按天轮转) + +access_logger = setup_file_logging() + +logger = logging.getLogger("aiflow") + + + + + +class APITrackingMiddleware(BaseHTTPMiddleware): + + """API调用统计中间件 - 记录请求耗时,标记慢请求""" + + + + async def dispatch(self, request: Request, call_next): + + request_id = str(uuid.uuid4()) + + start_time = time.time() + + path = request.url.path + + method = request.method + + + + # 跳过健康检查和静态资源 + + skip_paths = ["/app/api/health", "/favicon.ico"] + + should_skip = any(path.startswith(p) for p in skip_paths) + + + + try: + + response = await call_next(request) + + elapsed = time.time() - start_time + + status_code = response.status_code + + + + if not should_skip: + + access_logger.info( + + f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" + + ) + + + + # 异步记录API调用统计 + + try: + + db = SessionLocal() + + try: + + _record_api_call(db, method, path, status_code, elapsed) + + finally: + + db.close() + + except Exception as e: + + logger.warning(f"API统计记录失败: {e}") + + + + return response + + except Exception as e: + + elapsed = time.time() - start_time + + if not should_skip: + + access_logger.info( + + f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" + + ) + + raise + + + + async def __call__(self, scope, receive, send): + + if scope["type"] != "http": + + await self.app(scope, receive, send) + + return + + await super().__call__(scope, receive, send) + + + + + +def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): + + """记录API调用到数据库统计表""" + + # 简化路径以减少基数(去掉动态ID) + + parts = path.split("/") + + simplified_parts = [] + + for part in parts: + + # 将UUID样式的路径段替换为{id} + + if len(part) == 36 and part.count("-") == 4: + + simplified_parts.append("{id}") + + # 将纯数字路径段替换为{id} + + elif part.isdigit(): + + simplified_parts.append("{id}") + + else: + + simplified_parts.append(part) + + simplified_path = "/".join(simplified_parts) + + + + # 统计记录通过数据库实现,简化版记录到日志 + + is_error = status_code >= 400 + + is_slow = elapsed >= SLOW_REQUEST_THRESHOLD + + + + if is_slow: + + logger.warning( + + f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" + + ) + + + + + +@asynccontextmanager + +async def lifespan(app: FastAPI): + + logger.info("Qihu-AIFlow v2.0.0 启动中...") + + + + # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) + + try: + + from app.services.evolution_service import EvolutionWatcherManager + + from app.models import UserEvolutionConfig + + db = SessionLocal() + + try: + + configs = db.query(UserEvolutionConfig).filter( + + UserEvolutionConfig.watcher_status == "running" + + ).all() + + if configs: + + watcher = EvolutionWatcherManager() + + for cfg in configs: + + try: + + watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) + + logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") + + except Exception as e: + + logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") + + logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") + + else: + + logger.info("[Evolution] 无待恢复的进化配置") + + finally: + + db.close() + + except Exception as e: + + logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") + + + + # 启动Redis事件总线 + + try: + + from app.event_bus import get_event_bus + + event_bus = get_event_bus() + + event_bus.start(num_consumers=2) + + logger.info("[EventBus] Redis事件总线已启动") + + except Exception as e: + + logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") + + + + logger.info("Qihu-AIFlow v2.0.0 启动完成") + + + + yield + + + + # 停止Redis事件总线 + + try: + + from app.event_bus import get_event_bus + + get_event_bus().stop() + + logger.info("[EventBus] Redis事件总线已停止") + + except Exception: + + pass + + + + # 优雅关闭:停止所有 Evolution Pipeline + + try: + + from app.services.evolution_service import EvolutionWatcherManager + + EvolutionWatcherManager().shutdown_all() + + logger.info("[Evolution] 所有Pipeline已停止") + + except Exception: + + pass + + + + logger.info("Qihu-AIFlow 正在关闭...") + + + + + +app = FastAPI( + + title="Qihu-AIFlow - 智能自动化PaaS平台", + + version="1.0.0", + + lifespan=lifespan, + +) + + + +# API调用追踪中间件 + +app.add_middleware(APITrackingMiddleware) + + + +# CORS配置 + +app.add_middleware( + + CORSMiddleware, + + allow_origins=["*"], + + allow_credentials=True, + + allow_methods=["*"], + + allow_headers=["*"], + +) + + + +# 前台 API + +app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) + +app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) + +app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) + +app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) + +app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"]) + +app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"]) + +app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) + +app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) + +app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) + +app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) + +app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) + + + +# 后台 API + +app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) + +app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"]) + +app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"]) + +app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"]) + +app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"]) + +app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"]) + +app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"]) + +app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"]) + +app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"]) + +app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"]) + +app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"]) + +app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"]) +app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"]) +app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"]) +app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"]) +app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"]) +app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"]) +app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"]) +app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"]) +app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"]) +app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"]) +app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"]) +app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"]) +app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"]) +app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"]) +app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"]) +app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"]) + +app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"]) +app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"]) +app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"]) +app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"]) + + + + +@app.get("/app/api/health") + +async def health_check(): + + return {"status": "ok", "service": "Qihu-AIFlow"} + diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0c0c6cd..f9b4fa5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -42,6 +42,9 @@ import PrivacySettings from '@/pages/admin/PrivacySettings'; import ImBots from '@/pages/admin/ImBots'; import Promotion from '@/pages/admin/Promotion'; import Achievement from '@/pages/admin/Achievement'; +import Tenant from '@/pages/admin/Tenant'; +import Billing from '@/pages/admin/Billing'; +import Analytics from '@/pages/admin/Analytics'; const App: React.FC = () => { const [user, setUser] = useState(null); @@ -127,6 +130,9 @@ const App: React.FC = () => { } /> } /> } /> + } /> + } /> + } /> } /> diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f96e825..2e7c6b3 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -378,48 +378,7 @@ export const adminAPI = { const qs = p.toString(); return get(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`); }, - assignPlan: (userId: string, planId: string, days: number) => put(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`), - revokeMembership: (userId: string) => put(`/admin/membership/users/${userId}/revoke`), - getUserUsage: (userId: string) => get(`/admin/membership/users/${userId}/usage`), - getMembershipStats: () => get('/admin/membership/stats'), - // 广告管理 - getAds: () => get('/admin/ads/'), - addAd: (data: any) => post('/admin/ads/', data), - updateAd: (id: string, data: any) => put(`/admin/ads/${id}`, data), - deleteAd: (id: string) => del(`/admin/ads/${id}`), - toggleAd: (id: string) => put(`/admin/ads/${id}/toggle`), - // 应用市场审核 - getPendingApps: () => get('/admin/market/apps/pending'), - getMarketOverview: () => get('/admin/market/analytics/overview'), - getTopApps: (metric: string, limit: number) => get(`/admin/market/analytics/top-apps?metric=${metric}&limit=${limit}`), - getRecentReviews: () => get('/admin/market/analytics/recent-reviews'), - getAdminMarketApps: (query: string) => get(`/admin/market/apps?${query}`), - getMarketCategories: () => get('/admin/market/categories'), - setFeaturedApp: (id: string, featured: boolean, order: number) => put(`/admin/market/apps/${id}/featured`, { featured, featured_order: order }), - deleteMarketApp: (id: string) => del(`/admin/market/apps/${id}`), - updateMarketApp: (id: string, data: any) => put(`/admin/market/apps/${id}`, data), - approveApp: (id: string) => put(`/admin/market/apps/${id}/approve`), - rejectApp: (id: string) => put(`/admin/market/apps/${id.includes('?') ? id.split('?')[0] + '/reject?' + id.split('?')[1] : id + '/reject'}`), - getAdminApps: () => get('/admin/market/apps'), - updateApp: (id: string, data: any) => put(`/admin/market/apps/${id}`, data), - getCoupons: () => get('/admin/market/coupons'), - addCoupon: (data: any) => post('/admin/market/coupons', data), - updateCoupon: (id: string, data: any) => put(`/admin/market/coupons/${id}`, data), - deleteCoupon: (id: string) => del(`/admin/market/coupons/${id}`), - assignCoupon: (id: string, userId: string) => post(`/admin/market/coupons/${id}/assign`, { user_id: userId }), - // 日志与风控 - getOperationLogs: (params?: any) => { - const sp = new URLSearchParams(); - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - return get(`/admin/logs/operations${sp.toString() ? `?${sp.toString()}` : ''}`); - }, - getTokenLogs: (params?: any) => { - const sp = new URLSearchParams(); - if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); - return get(`/admin/logs/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`); - }, - getPaymentLogs: (params?: any) => { - const sp = new URLSearchParams(); + assignPlan: (userId: string, planId: string, days: number) => put(`/admin/membership/users/${userId}/assign-plan?plan_id=${t sp = new URLSearchParams(); if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); return get(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`); }, @@ -908,4 +867,64 @@ export const evolutionAPI = { checkAchievementProgress: () => post('/admin/achievement/check-progress'), getAchievementLeaderboard: (params?: string) => get(`/admin/achievement/leaderboard${params || ''}`), + // 多租户管理 + getTenants: (page?: number, keyword?: string, status?: string) => { + const sp = new URLSearchParams(); + if (page) sp.set('page', String(page)); + if (keyword) sp.set('keyword', keyword); + if (status) sp.set('status', status); + sp.set('page_size', '20'); + return get(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`); + }, + createTenant: (params: string) => post(`/admin/tenant/create${params}`), + getTenantDetail: (id: string) => get(`/admin/tenant/${id}`), + updateTenant: (id: string, params: string) => put(`/admin/tenant/${id}${params}`), + deleteTenant: (id: string) => del(`/admin/tenant/${id}`), + getTenantMembers: (tenantId: string, page?: number) => { + const sp = new URLSearchParams(); + sp.set('page', String(page || 1)); + sp.set('page_size', '100'); + return get(`/admin/tenant/${tenantId}/members?${sp.toString()}`); + }, + addTenantMember: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/members/add${params}`), + updateTenantMember: (tenantId: string, memberId: string, params: string) => put(`/admin/tenant/${tenantId}/members/${memberId}${params}`), + removeTenantMember: (tenantId: string, memberId: string) => del(`/admin/tenant/${tenantId}/members/${memberId}`), + getTenantQuotas: (tenantId: string) => get(`/admin/tenant/${tenantId}/quotas`), + updateTenantQuota: (tenantId: string, params: string) => put(`/admin/tenant/${tenantId}/quotas${params}`), + getTenantInvitations: (tenantId: string) => get(`/admin/tenant/${tenantId}/invitations`), + createTenantInvitation: (tenantId: string, params: string) => post(`/admin/tenant/${tenantId}/invitations${params}`), + getTenantStats: () => get('/admin/tenant/stats/overview'), + + // 计费与支付 + getBillingRules: (page?: number) => get(`/admin/billing/rules?page=${page || 1}&page_size=100`), + createBillingRule: (params: string) => post(`/admin/billing/rules/create${params}`), + updateBillingRule: (id: string, params: string) => put(`/admin/billing/rules/${id}${params}`), + deleteBillingRule: (id: string) => del(`/admin/billing/rules/${id}`), + getBillingPackages: () => get('/admin/billing/packages'), + createBillingPackage: (params: string) => post(`/admin/billing/packages/create${params}`), + updateBillingPackage: (id: string, params: string) => put(`/admin/billing/packages/${id}${params}`), + deleteBillingPackage: (id: string) => del(`/admin/billing/packages/${id}`), + getBillingTransactions: (page?: number) => get(`/admin/billing/transactions?page=${page || 1}&page_size=20`), + getInvoices: (page?: number) => get(`/admin/billing/invoices?page=${page || 1}&page_size=20`), + generateInvoice: (params: string) => post(`/admin/billing/invoices/generate${params}`), + getPaymentConfigs: () => get('/admin/billing/payment-configs'), + updatePaymentConfig: (id: string, params: string) => put(`/admin/billing/payment-configs/${id}${params}`), + getBillingStats: () => get('/admin/billing/stats/overview'), + + // 数据分析 + getAnalyticsOverview: (days?: number) => get(`/admin/analytics/overview?days=${days || 30}`), + getAnalyticsApiTrends: (days?: number) => get(`/admin/analytics/trends/api-calls?days=${days || 7}`), + getAnalyticsTokenTrends: (days?: number) => get(`/admin/analytics/trends/tokens?days=${days || 7}`), + getAnalyticsUserTrends: (days?: number) => get(`/admin/analytics/trends/users?days=${days || 30}`), + getAnalyticsRevenueTrends: (days?: number) => get(`/admin/analytics/trends/revenue?days=${days || 30}`), + getAnalyticsModelUsage: (days?: number) => get(`/admin/analytics/models/usage?days=${days || 30}`), + getAnalyticsDashboards: () => get('/admin/analytics/dashboards'), + createAnalyticsDashboard: (params: string) => post(`/admin/analytics/dashboards/create${params}`), + deleteAnalyticsDashboard: (id: string) => del(`/admin/analytics/dashboards/${id}`), + getAnalyticsEvents: (params?: any) => { + const sp = new URLSearchParams(); + if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); + return get(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`); + }, + }; diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index bbe3885..40298e5 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -22,6 +22,9 @@ const adminNavItems = [ { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, { path: '/admin/promotion', icon: 'promotion', label: '推广系统' }, { path: '/admin/achievement', icon: 'achievement', label: '成就系统' }, + { path: '/admin/tenant', icon: 'tenant', label: '多租户管理' }, + { path: '/admin/billing', icon: 'billing', label: '计费与支付' }, + { path: '/admin/analytics', icon: 'analytics', label: '数据分析' }, { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' }, ]; @@ -52,7 +55,24 @@ const adminIconPaths: Record = { , achievement: - ,}; + , + tenant: + + + + , + billing: + + + + , + analytics: + + + + + , +}; const AdminLayout: React.FC = () => { const { user, logout } = useAuth(); diff --git a/frontend/src/pages/admin/Analytics.tsx b/frontend/src/pages/admin/Analytics.tsx new file mode 100644 index 0000000..4846ce1 --- /dev/null +++ b/frontend/src/pages/admin/Analytics.tsx @@ -0,0 +1,207 @@ +import { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; + +interface Overview { + total_users: number; active_users: number; new_users: number; + api_calls: number; avg_response_time: number; error_rate: number; + tokens_used: number; total_cost: number; total_chats: number; total_messages: number; revenue: number; +} +interface TrendItem { date: string; calls?: number; errors?: number; tokens?: number; new_users?: number; cumulative?: number; revenue?: number; consumption?: number; } +interface ModelUsage { model_name: string; call_count: number; total_tokens: number; total_cost: number; avg_tokens: number; } + +const Analytics: React.FC = () => { + const [days, setDays] = useState(7); + const [loading, setLoading] = useState(false); + const [overview, setOverview] = useState(null); + const [apiTrend, setApiTrend] = useState([]); + const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record }>({ daily: [], model_distribution: {} }); + const [userTrend, setUserTrend] = useState([]); + const [revenueTrend, setRevenueTrend] = useState([]); + const [modelUsage, setModelUsage] = useState([]); + + const fetchAll = useCallback(async () => { + setLoading(true); + try { + const [ov, api, tok, usr, rev, mu] = await Promise.all([ + adminAPI.getAnalyticsOverview(days), + adminAPI.getAnalyticsApiTrends(days), + adminAPI.getAnalyticsTokenTrends(days), + adminAPI.getAnalyticsUserTrends(days), + adminAPI.getAnalyticsRevenueTrends(days), + adminAPI.getAnalyticsModelUsage(days), + ]); + setOverview((ov as any)?.data || ov); + setApiTrend((api as any)?.data || []); + const td = (tok as any)?.data || { daily: [], model_distribution: {} }; + setTokenTrend(td); + setUserTrend((usr as any)?.data || []); + setRevenueTrend((rev as any)?.data || []); + setModelUsage((mu as any)?.data || []); + } catch (e) { console.error('fetchAnalytics:', e); } + setLoading(false); + }, [days]); + + useEffect(() => { fetchAll(); }, [fetchAll]); + + const renderBar = (data: TrendItem[], key: string, color: string, label: string) => { + if (!data.length) return
暂无数据
; + const values = data.map((d: any) => d[key] || 0); + const max = Math.max(...values, 1); + return ( +
+ {data.map((d: any, i) => { + const v = d[key] || 0; + const h = (v / max) * 140; + return ( +
+
{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}
+
+
{d.date?.substring(5)}
+
+ ); + })} +
+ ); + }; + + const renderLine = (data: TrendItem[], key: string, color: string) => { + if (!data.length) return
暂无数据
; + const values = data.map((d: any) => d[key] || 0); + const max = Math.max(...values, 1); + const w = 100 / (data.length - 1 || 1); + const points = data.map((d: any, i) => `${i * w},${100 - ((d[key] || 0) / max) * 90}`).join(' '); + return ( + + + {data.map((d: any, i) => ( + + ))} + + ); + }; + + const statCards = overview ? [ + { label: '总用户数', value: overview.total_users, color: '#6366f1', icon: 'U' }, + { label: '活跃用户', value: overview.active_users, color: '#10b981', icon: 'A' }, + { label: '新增用户', value: overview.new_users, color: '#3b82f6', icon: '+' }, + { label: 'API调用数', value: overview.api_calls, color: '#f59e0b', icon: 'API' }, + { label: 'Token用量', value: overview.tokens_used, color: '#ec4899', icon: 'T' }, + { label: '总费用(元)', value: overview.total_cost?.toFixed(2), color: '#ef4444', icon: '¥' }, + { label: '对话总数', value: overview.total_chats, color: '#8b5cf6', icon: 'C' }, + { label: '消息总数', value: overview.total_messages, color: '#06b6d4', icon: 'M' }, + ] : []; + + return ( +
+
+

数据分析面板

+
+ {[7, 14, 30, 90].map(d => ( + + ))} +
+
+ + {loading ?
加载中...
: ( + <> + {/* Stats grid */} +
+ {statCards.map((s, i) => ( +
+
{s.icon}
+
+
{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}
+
{s.label}
+
+
+ ))} +
+ + {/* Charts grid */} +
+
+

API调用趋势 ({days}天)

+ {renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')} +
+
+

Token使用趋势 ({days}天)

+ {renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')} +
+
+ +
+
+

用户增长 ({days}天)

+ {renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')} +
+
+

收入趋势 ({days}天)

+ {renderBar(revenueTrend, 'revenue', '#10b981', '收入')} +
+
+ + {/* Model usage */} +
+

模型使用分析 ({days}天)

+ {modelUsage.length > 0 ? ( + + + {['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => ( + + ))} + + + {modelUsage.map((m, i) => ( + + + + + + + + ))} + +
{h}
{m.model_name}{m.call_count.toLocaleString()}{m.total_tokens.toLocaleString()}{m.avg_tokens.toLocaleString()}¥{m.total_cost.toFixed(4)}
+ ) :
暂无模型使用数据
} +
+ + {/* Model distribution pie (CSS) */} + {Object.keys(tokenTrend.model_distribution || {}).length > 0 && ( +
+

模型使用分布

+
+
+ {(() => { + const entries = Object.entries(tokenTrend.model_distribution); + const total = entries.reduce((s, [, v]) => s + v, 1); + const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444']; + return entries.map(([model, val], i) => { + const pct = (val / total) * 100; + return
; + }); + })()} +
+
+ {Object.entries(tokenTrend.model_distribution).map(([model, val], i) => { + const colors = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#3b82f6', '#8b5cf6', '#06b6d4', '#ef4444']; + const total = Object.values(tokenTrend.model_distribution).reduce((s, v) => s + v, 1); + return ( +
+
+ {model} + {((val / total) * 100).toFixed(1)}% +
+ ); + })} +
+
+
+ )} + + )} +
+ ); +}; + +export default Analytics; diff --git a/frontend/src/pages/admin/Billing.tsx b/frontend/src/pages/admin/Billing.tsx new file mode 100644 index 0000000..9b6252a --- /dev/null +++ b/frontend/src/pages/admin/Billing.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; + +interface Rule { id: string; name: string; billing_type: string; model_name: string; rate: number; free_quota: number; status: string; } +interface Package { id: string; name: string; tokens: number; bonus_tokens: number; price: number; original_price: number; is_popular: number; status: string; } +interface Transaction { id: string; user_id: string; username: string; transaction_type: string; amount: number; tokens: number; status: string; created_at: string; description: string; } +interface Stats { total_revenue: number; total_consumption: number; total_transactions: number; pending_invoices: number; active_rules: number; active_packages: number; daily_revenue_7d: { date: string; revenue: number }[]; } + +const Billing: React.FC = () => { + const [tab, setTab] = useState<'overview' | 'rules' | 'packages' | 'transactions'>('overview'); + const [rules, setRules] = useState([]); + const [packages, setPackages] = useState([]); + const [transactions, setTransactions] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(false); + + const fetchRules = useCallback(async () => { + setLoading(true); + try { + const res = await adminAPI.getBillingRules(); + const d = (res as any)?.data || res; + setRules(d?.items || []); + } catch (e) { console.error('fetchRules:', e); } + setLoading(false); + }, []); + + const fetchPackages = useCallback(async () => { + try { + const res = await adminAPI.getBillingPackages(); + const d = (res as any)?.data || res; + setPackages(Array.isArray(d) ? d : d?.items || []); + } catch (e) { console.error('fetchPackages:', e); } + }, []); + + const fetchTransactions = useCallback(async () => { + setLoading(true); + try { + const res = await adminAPI.getBillingTransactions(page); + const d = (res as any)?.data || res; + setTransactions(d?.items || []); + setTotal(d?.total || 0); + } catch (e) { console.error('fetchTransactions:', e); } + setLoading(false); + }, [page]); + + const fetchStats = useCallback(async () => { + try { + const res = await adminAPI.getBillingStats(); + setStats((res as any)?.data || res); + } catch (e) { console.error('fetchStats:', e); } + }, []); + + useEffect(() => { fetchStats(); }, [fetchStats]); + useEffect(() => { if (tab === 'rules') fetchRules(); }, [tab, fetchRules]); + useEffect(() => { if (tab === 'packages') fetchPackages(); }, [tab, fetchPackages]); + useEffect(() => { if (tab === 'transactions') fetchTransactions(); }, [tab, fetchTransactions, page]); + + const typeColors: Record = { per_token: '#6366f1', per_call: '#3b82f6', per_month: '#10b981', hybrid: '#f59e0b' }; + const txColors: Record = { recharge: '#10b981', consumption: '#ef4444', refund: '#f59e0b', gift: '#ec4899' }; + + return ( +
+

计费与支付管理

+ + {/* Stats */} + {stats && ( +
+ {[ + { label: '总收入(元)', value: stats.total_revenue?.toFixed(2) || '0.00', color: '#10b981' }, + { label: '总消费(元)', value: stats.total_consumption?.toFixed(2) || '0.00', color: '#ef4444' }, + { label: '交易笔数', value: stats.total_transactions || 0, color: '#3b82f6' }, + { label: '待付账单', value: stats.pending_invoices || 0, color: '#f59e0b' }, + ].map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ )} + + {/* Tabs */} +
+ {([['overview', '概览'], ['rules', '计费规则'], ['packages', '充值套餐'], ['transactions', '交易记录']] as const).map(([k, label]) => ( + + ))} +
+ + {/* Overview */} + {tab === 'overview' && stats && ( +
+

近7天收入趋势

+ {stats.daily_revenue_7d && stats.daily_revenue_7d.length > 0 ? ( +
+ {stats.daily_revenue_7d.map((d, i) => { + const max = Math.max(...stats.daily_revenue_7d.map(r => r.revenue), 1); + const h = (d.revenue / max) * 160; + return ( +
+
¥{d.revenue.toFixed(0)}
+
+
{d.date.substring(5)}
+
+ ); + })} +
+ ) :
暂无收入数据
} +
+ )} + + {/* Rules */} + {tab === 'rules' && ( + + + {['规则名称', '计费类型', '关联模型', '单价', '免费额度', '状态'].map(h => ( + + ))} + + + {rules.map(r => ( + + + + + + + + + ))} + +
{h}
{r.name} + {r.billing_type} + {r.model_name || '通用'}¥{r.rate.toFixed(6)}{r.free_quota?.toLocaleString() || 0} + {r.status === 'active' ? '启用' : '停用'} +
+ )} + + {/* Packages */} + {tab === 'packages' && ( +
+ {packages.map(p => ( +
+ {p.is_popular === 1 && ( +
推荐
+ )} +

{p.name}

+

{p.description || ''}

+
¥{p.price}
+ {p.original_price &&
¥{p.original_price}
} +
+
主额度: {p.tokens.toLocaleString()} tokens
+ {p.bonus_tokens > 0 &&
赠送: {p.bonus_tokens.toLocaleString()} tokens
} +
+
+ {p.status === 'active' ? '● 在售' : '○ 已停用'} +
+
+ ))} +
+ )} + + {/* Transactions */} + {tab === 'transactions' && ( + <> + {loading ?
加载中...
: ( + + + {['时间', '用户', '类型', '金额(元)', 'Tokens', '状态', '描述'].map(h => ( + + ))} + + + {transactions.map(t => ( + + + + + + + + + + ))} + {transactions.length === 0 && } + +
{h}
{t.created_at?.substring(0, 16)}{t.username || t.user_id.substring(0, 8)} + {t.transaction_type} + {t.amount > 0 ? '+' : ''}{t.amount.toFixed(2)}{t.tokens?.toLocaleString() || '-'} + {t.status} + {t.description || '-'}
暂无交易记录
+ )} + {total > 20 && ( +
+ + 第 {page} 页 + +
+ )} + + )} +
+ ); +}; + +export default Billing; diff --git a/frontend/src/pages/admin/Tenant.tsx b/frontend/src/pages/admin/Tenant.tsx new file mode 100644 index 0000000..cb26f1d --- /dev/null +++ b/frontend/src/pages/admin/Tenant.tsx @@ -0,0 +1,235 @@ +import { useState, useEffect, useCallback } from 'react'; +import { adminAPI } from '@/api'; + +interface Tenant { + id: string; name: string; slug: string; logo: string; description: string; + status: string; max_members: number; max_storage_mb: number; + max_api_calls_per_day: number; member_count?: number; created_at: string; expire_at?: string; +} +interface Member { id: string; user_id: string; username: string; email: string; role: string; status: string; joined_at: string; avatar?: string; } +interface Stats { total_tenants: number; active_tenants: number; suspended_tenants: number; total_members: number; pending_invitations: number; } + +const Tenant: React.FC = () => { + const [tab, setTab] = useState<'list' | 'members' | 'stats'>('list'); + const [tenants, setTenants] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [stats, setStats] = useState(null); + const [members, setMembers] = useState([]); + const [selectedTenant, setSelectedTenant] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const [form, setForm] = useState({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 }); + + const fetchTenants = useCallback(async () => { + setLoading(true); + try { + const res = await adminAPI.getTenants(page); + const d = (res as any)?.data || res; + setTenants(d?.items || []); + setTotal(d?.total || 0); + } catch (e) { console.error('fetchTenants:', e); } + setLoading(false); + }, [page]); + + const fetchStats = useCallback(async () => { + try { + const res = await adminAPI.getTenantStats(); + setStats((res as any)?.data || res); + } catch (e) { console.error('fetchStats:', e); } + }, []); + + const fetchMembers = useCallback(async () => { + if (!selectedTenant) return; + try { + const res = await adminAPI.getTenantMembers(selectedTenant); + const d = (res as any)?.data || res; + setMembers(d?.items || []); + } catch (e) { console.error('fetchMembers:', e); } + }, [selectedTenant]); + + useEffect(() => { fetchTenants(); fetchStats(); }, [fetchTenants, fetchStats]); + useEffect(() => { if (tab === 'members' && selectedTenant) fetchMembers(); }, [tab, selectedTenant, fetchMembers]); + + const handleCreate = async () => { + try { + const params = `?name=${encodeURIComponent(form.name)}&slug=${encodeURIComponent(form.slug)}&description=${encodeURIComponent(form.description)}&max_members=${form.max_members}&max_storage_mb=${form.max_storage_mb}&max_api_calls_per_day=${form.max_api_calls_per_day}`; + await adminAPI.createTenant(params); + setShowCreate(false); + setForm({ name: '', slug: '', description: '', max_members: 10, max_storage_mb: 10240, max_api_calls_per_day: 10000 }); + fetchTenants(); + } catch (e) { console.error('create:', e); } + }; + + const statusColors: Record = { active: '#10b981', suspended: '#f59e0b', terminated: '#ef4444' }; + const roleColors: Record = { owner: '#a78bfa', admin: '#60a5fa', member: '#94a3b8', viewer: '#64748b' }; + + return ( +
+

多租户管理

+ + {/* Stats cards */} + {stats && ( +
+ {[ + { label: '总租户数', value: stats.total_tenants, color: '#6366f1' }, + { label: '活跃租户', value: stats.active_tenants, color: '#10b981' }, + { label: '已暂停', value: stats.suspended_tenants, color: '#f59e0b' }, + { label: '总成员数', value: stats.total_members, color: '#3b82f6' }, + { label: '待处理邀请', value: stats.pending_invitations, color: '#ec4899' }, + ].map((s, i) => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ )} + + {/* Tabs */} +
+ {([['list', '租户列表'], ['members', '成员管理'], ['stats', '统计详情']] as const).map(([k, label]) => ( + + ))} + {tab === 'list' && ( + + )} +
+ + {/* Tab content */} + {tab === 'list' && ( +
+ {loading ?
加载中...
: ( + + + + {['租户名称', '标识', '状态', '成员数', '存储上限', 'API/天', '创建时间', '操作'].map(h => ( + + ))} + + + + {tenants.map(t => ( + + + + + + + + + + + ))} + +
{h}
{t.name}{t.slug} + {t.status} + {t.member_count ?? '-'}{(t.max_storage_mb / 1024).toFixed(1)} GB{t.max_api_calls_per_day.toLocaleString()}{t.created_at?.substring(0, 10)} + +
+ )} + {total > 20 && ( +
+ + 第 {page} 页 + +
+ )} +
+ )} + + {tab === 'members' && ( +
+
+ +
+ {selectedTenant && ( + + + + {['用户名', '邮箱', '角色', '状态', '加入时间'].map(h => ( + + ))} + + + + {members.map(m => ( + + + + + + + + ))} + +
{h}
{m.username || m.user_id.substring(0, 8)}{m.email || '-'} + {m.role} + + {m.status} + {m.joined_at?.substring(0, 16)}
+ )} +
+ )} + + {tab === 'stats' && stats && ( +
+
+ {Object.entries(stats).map(([k, v]) => ( +
+ {k.replace(/_/g, ' ').toUpperCase()} + {String(v)} +
+ ))} +
+
+ )} + + {/* Create modal */} + {showCreate && ( +
setShowCreate(false)}> +
e.stopPropagation()} style={{ background: '#1e293b', borderRadius: '16px', padding: '32px', width: '480px', maxWidth: '90vw' }}> +

创建租户

+ {[ + { k: 'name', label: '租户名称', type: 'text' }, + { k: 'slug', label: '标识(英文)', type: 'text' }, + { k: 'description', label: '描述', type: 'text' }, + { k: 'max_members', label: '最大成员数', type: 'number' }, + { k: 'max_storage_mb', label: '存储上限(MB)', type: 'number' }, + { k: 'max_api_calls_per_day', label: 'API调用/天', type: 'number' }, + ].map(f => ( +
+ + setForm({ ...form, [f.k]: f.type === 'number' ? Number(e.target.value) : e.target.value })} + style={{ width: '100%', padding: '10px 12px', borderRadius: '8px', background: '#0f172a', color: '#e2e8f0', border: '1px solid #475569', fontSize: '14px', boxSizing: 'border-box' }} /> +
+ ))} +
+ + +
+
+
+ )} +
+ ); +}; + +export default Tenant;