Feature: Multi-tenant + Billing + Analytics - 3 new modules with 40+ API endpoints

This commit is contained in:
qyyfw committed 2026-07-07 18:53:40 +08:00
1 parent e9ec429d21
commit aec63eaf56
10 files changed
+2504 -497

No files matched your search

@@ -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}}
@@ -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,
}}
@@ -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,
}}
+453 -454
View File
@@ -1,454 +1,453 @@
""" """
Qihu-AIFlow FastAPI 主应用 Qihu-AIFlow FastAPI 主应用
完整后端API,监听18080端口,所有接口以 /app 为前缀 完整后端API,监听18080端口,所有接口以 /app 为前缀
""" """
import sys import sys
import os import os
import time import time
import uuid import uuid
import logging import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI, Request from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.base import BaseHTTPMiddleware
from datetime import datetime from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 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 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 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.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.mcp_market_admin import router as mcp_market_router
from app.api.admin import asset_admin, achievement_admin from app.api.admin import asset_admin, achievement_admin
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
from app.database import SessionLocal from app.database import SessionLocal
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1) # 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册 import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
# 初始化文件日志(按天轮转) # 初始化文件日志(按天轮转)
access_logger = setup_file_logging() access_logger = setup_file_logging()
logger = logging.getLogger("aiflow") logger = logging.getLogger("aiflow")
class APITrackingMiddleware(BaseHTTPMiddleware): class APITrackingMiddleware(BaseHTTPMiddleware):
"""API调用统计中间件 - 记录请求耗时,标记慢请求""" """API调用统计中间件 - 记录请求耗时,标记慢请求"""
async def dispatch(self, request: Request, call_next): async def dispatch(self, request: Request, call_next):
request_id = str(uuid.uuid4()) request_id = str(uuid.uuid4())
start_time = time.time() start_time = time.time()
path = request.url.path path = request.url.path
method = request.method method = request.method
# 跳过健康检查和静态资源 # 跳过健康检查和静态资源
skip_paths = ["/app/api/health", "/favicon.ico"] skip_paths = ["/app/api/health", "/favicon.ico"]
should_skip = any(path.startswith(p) for p in skip_paths) should_skip = any(path.startswith(p) for p in skip_paths)
try: try:
response = await call_next(request) response = await call_next(request)
elapsed = time.time() - start_time elapsed = time.time() - start_time
status_code = response.status_code status_code = response.status_code
if not should_skip: if not should_skip:
access_logger.info( access_logger.info(
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)" f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
) )
# 异步记录API调用统计 # 异步记录API调用统计
try: try:
db = SessionLocal() db = SessionLocal()
try: try:
_record_api_call(db, method, path, status_code, elapsed) _record_api_call(db, method, path, status_code, elapsed)
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
logger.warning(f"API统计记录失败: {e}") logger.warning(f"API统计记录失败: {e}")
return response return response
except Exception as e: except Exception as e:
elapsed = time.time() - start_time elapsed = time.time() - start_time
if not should_skip: if not should_skip:
access_logger.info( access_logger.info(
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}" f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
) )
raise raise
async def __call__(self, scope, receive, send): async def __call__(self, scope, receive, send):
if scope["type"] != "http": if scope["type"] != "http":
await self.app(scope, receive, send) await self.app(scope, receive, send)
return return
await super().__call__(scope, receive, send) await super().__call__(scope, receive, send)
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float): def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
"""记录API调用到数据库统计表""" """记录API调用到数据库统计表"""
# 简化路径以减少基数(去掉动态ID) # 简化路径以减少基数(去掉动态ID)
parts = path.split("/") parts = path.split("/")
simplified_parts = [] simplified_parts = []
for part in parts: for part in parts:
# 将UUID样式的路径段替换为{id} # 将UUID样式的路径段替换为{id}
if len(part) == 36 and part.count("-") == 4: if len(part) == 36 and part.count("-") == 4:
simplified_parts.append("{id}") simplified_parts.append("{id}")
# 将纯数字路径段替换为{id} # 将纯数字路径段替换为{id}
elif part.isdigit(): elif part.isdigit():
simplified_parts.append("{id}") simplified_parts.append("{id}")
else: else:
simplified_parts.append(part) simplified_parts.append(part)
simplified_path = "/".join(simplified_parts) simplified_path = "/".join(simplified_parts)
# 统计记录通过数据库实现,简化版记录到日志 # 统计记录通过数据库实现,简化版记录到日志
is_error = status_code >= 400 is_error = status_code >= 400
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
if is_slow: if is_slow:
logger.warning( logger.warning(
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)" f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
) )
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
logger.info("Qihu-AIFlow v2.0.0 启动中...") logger.info("Qihu-AIFlow v2.0.0 启动中...")
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行) # 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
try: try:
from app.services.evolution_service import EvolutionWatcherManager from app.services.evolution_service import EvolutionWatcherManager
from app.models import UserEvolutionConfig from app.models import UserEvolutionConfig
db = SessionLocal() db = SessionLocal()
try: try:
configs = db.query(UserEvolutionConfig).filter( configs = db.query(UserEvolutionConfig).filter(
UserEvolutionConfig.watcher_status == "running" UserEvolutionConfig.watcher_status == "running"
).all() ).all()
if configs: if configs:
watcher = EvolutionWatcherManager() watcher = EvolutionWatcherManager()
for cfg in configs: for cfg in configs:
try: try:
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal()) watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline") logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
except Exception as e: except Exception as e:
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}") logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline") logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
else: else:
logger.info("[Evolution] 无待恢复的进化配置") logger.info("[Evolution] 无待恢复的进化配置")
finally: finally:
db.close() db.close()
except Exception as e: except Exception as e:
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}") logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
# 启动Redis事件总线 # 启动Redis事件总线
try: try:
from app.event_bus import get_event_bus from app.event_bus import get_event_bus
event_bus = get_event_bus() event_bus = get_event_bus()
event_bus.start(num_consumers=2) event_bus.start(num_consumers=2)
logger.info("[EventBus] Redis事件总线已启动") logger.info("[EventBus] Redis事件总线已启动")
except Exception as e: except Exception as e:
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}") logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
logger.info("Qihu-AIFlow v2.0.0 启动完成") logger.info("Qihu-AIFlow v2.0.0 启动完成")
yield yield
# 停止Redis事件总线 # 停止Redis事件总线
try: try:
from app.event_bus import get_event_bus from app.event_bus import get_event_bus
get_event_bus().stop() get_event_bus().stop()
logger.info("[EventBus] Redis事件总线已停止") logger.info("[EventBus] Redis事件总线已停止")
except Exception: except Exception:
pass pass
# 优雅关闭:停止所有 Evolution Pipeline # 优雅关闭:停止所有 Evolution Pipeline
try: try:
from app.services.evolution_service import EvolutionWatcherManager from app.services.evolution_service import EvolutionWatcherManager
EvolutionWatcherManager().shutdown_all() EvolutionWatcherManager().shutdown_all()
logger.info("[Evolution] 所有Pipeline已停止") logger.info("[Evolution] 所有Pipeline已停止")
except Exception: except Exception:
pass pass
logger.info("Qihu-AIFlow 正在关闭...") logger.info("Qihu-AIFlow 正在关闭...")
app = FastAPI( app = FastAPI(
title="Qihu-AIFlow - 智能自动化PaaS平台", title="Qihu-AIFlow - 智能自动化PaaS平台",
version="1.0.0", version="1.0.0",
lifespan=lifespan, lifespan=lifespan,
) )
# API调用追踪中间件 # API调用追踪中间件
app.add_middleware(APITrackingMiddleware) app.add_middleware(APITrackingMiddleware)
# CORS配置 # CORS配置
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
) )
# 前台 API # 前台 API
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"]) app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"]) app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"]) app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"]) app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
app.include_router(market.router, prefix="/app/api/market", 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(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"]) app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"]) app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"]) app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"]) app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"]) app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
# 后台 API # 后台 API
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"]) 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(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
app.include_router(models.router, prefix="/app/api/admin/models", 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(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", 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(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", 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(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
app.include_router(market_admin.router, prefix="/app/api/admin/market", 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(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", 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(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(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", 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(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", 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(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(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_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(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(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(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(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(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(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(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(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
app.include_router( app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
achievement_admin.router, app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
prefix="/app/api/admin/achievement", app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
tags=["achievement"] app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
)
@app.get("/app/api/health")
@app.get("/app/api/health")
async def health_check():
async def health_check():
return {"status": "ok", "service": "Qihu-AIFlow"}
return {"status": "ok", "service": "Qihu-AIFlow"}
+6
View File
@@ -42,6 +42,9 @@ import PrivacySettings from '@/pages/admin/PrivacySettings';
import ImBots from '@/pages/admin/ImBots'; import ImBots from '@/pages/admin/ImBots';
import Promotion from '@/pages/admin/Promotion'; import Promotion from '@/pages/admin/Promotion';
import Achievement from '@/pages/admin/Achievement'; 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 App: React.FC = () => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
@@ -127,6 +130,9 @@ const App: React.FC = () => {
<Route path="privacy" element={<PrivacySettings />} /> <Route path="privacy" element={<PrivacySettings />} />
<Route path="promotion" element={<Promotion />} /> <Route path="promotion" element={<Promotion />} />
<Route path="achievement" element={<Achievement />} /> <Route path="achievement" element={<Achievement />} />
<Route path="tenant" element={<Tenant />} />
<Route path="billing" element={<Billing />} />
<Route path="analytics" element={<Analytics />} />
</Route> </Route>
<Route path="*" element={<Navigate to="/" />} /> <Route path="*" element={<Navigate to="/" />} />
+61 -42
View File
@@ -378,48 +378,7 @@ export const adminAPI = {
const qs = p.toString(); const qs = p.toString();
return get<any>(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`); return get<any>(`/admin/membership/user-memberships${qs ? `?${qs}` : ''}`);
}, },
assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${planId}&duration_days=${days}`), assignPlan: (userId: string, planId: string, days: number) => put<any>(`/admin/membership/users/${userId}/assign-plan?plan_id=${t sp = new URLSearchParams();
revokeMembership: (userId: string) => put<any>(`/admin/membership/users/${userId}/revoke`),
getUserUsage: (userId: string) => get<any>(`/admin/membership/users/${userId}/usage`),
getMembershipStats: () => get<any>('/admin/membership/stats'),
// 广告管理
getAds: () => get<any[]>('/admin/ads/'),
addAd: (data: any) => post<any>('/admin/ads/', data),
updateAd: (id: string, data: any) => put<any>(`/admin/ads/${id}`, data),
deleteAd: (id: string) => del<any>(`/admin/ads/${id}`),
toggleAd: (id: string) => put<any>(`/admin/ads/${id}/toggle`),
// 应用市场审核
getPendingApps: () => get<any[]>('/admin/market/apps/pending'),
getMarketOverview: () => get<any>('/admin/market/analytics/overview'),
getTopApps: (metric: string, limit: number) => get<any>(`/admin/market/analytics/top-apps?metric=${metric}&limit=${limit}`),
getRecentReviews: () => get<any>('/admin/market/analytics/recent-reviews'),
getAdminMarketApps: (query: string) => get<any>(`/admin/market/apps?${query}`),
getMarketCategories: () => get<any>('/admin/market/categories'),
setFeaturedApp: (id: string, featured: boolean, order: number) => put<any>(`/admin/market/apps/${id}/featured`, { featured, featured_order: order }),
deleteMarketApp: (id: string) => del<any>(`/admin/market/apps/${id}`),
updateMarketApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
approveApp: (id: string) => put<any>(`/admin/market/apps/${id}/approve`),
rejectApp: (id: string) => put<any>(`/admin/market/apps/${id.includes('?') ? id.split('?')[0] + '/reject?' + id.split('?')[1] : id + '/reject'}`),
getAdminApps: () => get<any[]>('/admin/market/apps'),
updateApp: (id: string, data: any) => put<any>(`/admin/market/apps/${id}`, data),
getCoupons: () => get<any[]>('/admin/market/coupons'),
addCoupon: (data: any) => post<any>('/admin/market/coupons', data),
updateCoupon: (id: string, data: any) => put<any>(`/admin/market/coupons/${id}`, data),
deleteCoupon: (id: string) => del<any>(`/admin/market/coupons/${id}`),
assignCoupon: (id: string, userId: string) => post<any>(`/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<any[]>(`/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<any[]>(`/admin/logs/token-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
},
getPaymentLogs: (params?: any) => {
const sp = new URLSearchParams();
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); }); if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
return get<any[]>(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`); return get<any[]>(`/admin/logs/payment-logs${sp.toString() ? `?${sp.toString()}` : ''}`);
}, },
@@ -908,4 +867,64 @@ export const evolutionAPI = {
checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'), checkAchievementProgress: () => post<any>('/admin/achievement/check-progress'),
getAchievementLeaderboard: (params?: string) => get<any>(`/admin/achievement/leaderboard${params || ''}`), getAchievementLeaderboard: (params?: string) => get<any>(`/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<any>(`/admin/tenant/list${sp.toString() ? `?${sp.toString()}` : ''}`);
},
createTenant: (params: string) => post<any>(`/admin/tenant/create${params}`),
getTenantDetail: (id: string) => get<any>(`/admin/tenant/${id}`),
updateTenant: (id: string, params: string) => put<any>(`/admin/tenant/${id}${params}`),
deleteTenant: (id: string) => del<any>(`/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<any>(`/admin/tenant/${tenantId}/members?${sp.toString()}`);
},
addTenantMember: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/members/add${params}`),
updateTenantMember: (tenantId: string, memberId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/members/${memberId}${params}`),
removeTenantMember: (tenantId: string, memberId: string) => del<any>(`/admin/tenant/${tenantId}/members/${memberId}`),
getTenantQuotas: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/quotas`),
updateTenantQuota: (tenantId: string, params: string) => put<any>(`/admin/tenant/${tenantId}/quotas${params}`),
getTenantInvitations: (tenantId: string) => get<any>(`/admin/tenant/${tenantId}/invitations`),
createTenantInvitation: (tenantId: string, params: string) => post<any>(`/admin/tenant/${tenantId}/invitations${params}`),
getTenantStats: () => get<any>('/admin/tenant/stats/overview'),
// 计费与支付
getBillingRules: (page?: number) => get<any>(`/admin/billing/rules?page=${page || 1}&page_size=100`),
createBillingRule: (params: string) => post<any>(`/admin/billing/rules/create${params}`),
updateBillingRule: (id: string, params: string) => put<any>(`/admin/billing/rules/${id}${params}`),
deleteBillingRule: (id: string) => del<any>(`/admin/billing/rules/${id}`),
getBillingPackages: () => get<any>('/admin/billing/packages'),
createBillingPackage: (params: string) => post<any>(`/admin/billing/packages/create${params}`),
updateBillingPackage: (id: string, params: string) => put<any>(`/admin/billing/packages/${id}${params}`),
deleteBillingPackage: (id: string) => del<any>(`/admin/billing/packages/${id}`),
getBillingTransactions: (page?: number) => get<any>(`/admin/billing/transactions?page=${page || 1}&page_size=20`),
getInvoices: (page?: number) => get<any>(`/admin/billing/invoices?page=${page || 1}&page_size=20`),
generateInvoice: (params: string) => post<any>(`/admin/billing/invoices/generate${params}`),
getPaymentConfigs: () => get<any>('/admin/billing/payment-configs'),
updatePaymentConfig: (id: string, params: string) => put<any>(`/admin/billing/payment-configs/${id}${params}`),
getBillingStats: () => get<any>('/admin/billing/stats/overview'),
// 数据分析
getAnalyticsOverview: (days?: number) => get<any>(`/admin/analytics/overview?days=${days || 30}`),
getAnalyticsApiTrends: (days?: number) => get<any>(`/admin/analytics/trends/api-calls?days=${days || 7}`),
getAnalyticsTokenTrends: (days?: number) => get<any>(`/admin/analytics/trends/tokens?days=${days || 7}`),
getAnalyticsUserTrends: (days?: number) => get<any>(`/admin/analytics/trends/users?days=${days || 30}`),
getAnalyticsRevenueTrends: (days?: number) => get<any>(`/admin/analytics/trends/revenue?days=${days || 30}`),
getAnalyticsModelUsage: (days?: number) => get<any>(`/admin/analytics/models/usage?days=${days || 30}`),
getAnalyticsDashboards: () => get<any>('/admin/analytics/dashboards'),
createAnalyticsDashboard: (params: string) => post<any>(`/admin/analytics/dashboards/create${params}`),
deleteAnalyticsDashboard: (id: string) => del<any>(`/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<any>(`/admin/analytics/events${sp.toString() ? `?${sp.toString()}` : ''}`);
},
}; };
+21 -1
View File
@@ -22,6 +22,9 @@ const adminNavItems = [
{ path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' }, { path: '/admin/privacy', icon: 'privacy', label: '隐私与本地代理' },
{ path: '/admin/promotion', icon: 'promotion', label: '推广系统' }, { path: '/admin/promotion', icon: 'promotion', label: '推广系统' },
{ path: '/admin/achievement', icon: 'achievement', 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 机器人' }, { path: '/admin/im-bots', icon: 'imbot', label: 'IM 机器人' },
]; ];
@@ -52,7 +55,24 @@ const adminIconPaths: Record<string, JSX.Element> = {
</svg>, </svg>,
achievement: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"> achievement: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/> <path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.27 7.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/>
</svg>,}; </svg>,
tenant: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 21V8l9-5 9 5v13"/>
<path d="M9 21v-6h6v6"/>
<path d="M3 13h18"/>
</svg>,
billing: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="5" width="20" height="14" rx="2"/>
<line x1="2" y1="10" x2="22" y2="10"/>
<line x1="6" y1="15" x2="10" y2="15"/>
</svg>,
analytics: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
<line x1="3" y1="20" x2="21" y2="20"/>
</svg>,
};
const AdminLayout: React.FC = () => { const AdminLayout: React.FC = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
+207
View File
@@ -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<Overview | null>(null);
const [apiTrend, setApiTrend] = useState<TrendItem[]>([]);
const [tokenTrend, setTokenTrend] = useState<{ daily: any[]; model_distribution: Record<string, number> }>({ daily: [], model_distribution: {} });
const [userTrend, setUserTrend] = useState<TrendItem[]>([]);
const [revenueTrend, setRevenueTrend] = useState<TrendItem[]>([]);
const [modelUsage, setModelUsage] = useState<ModelUsage[]>([]);
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 <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
const values = data.map((d: any) => d[key] || 0);
const max = Math.max(...values, 1);
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '8px', height: '180px', padding: '0 16px' }}>
{data.map((d: any, i) => {
const v = d[key] || 0;
const h = (v / max) * 140;
return (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: '40px' }}>
<div style={{ fontSize: '10px', color: '#94a3b8', marginBottom: '4px' }}>{v > 9999 ? `${(v/1000).toFixed(0)}k` : v}</div>
<div style={{ width: '100%', height: `${h}px`, background: `linear-gradient(180deg, ${color}, ${color}aa)`, borderRadius: '4px 4px 0 0', transition: 'height 0.3s' }} />
<div style={{ fontSize: '10px', color: '#64748b', marginTop: '6px' }}>{d.date?.substring(5)}</div>
</div>
);
})}
</div>
);
};
const renderLine = (data: TrendItem[], key: string, color: string) => {
if (!data.length) return <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无数据</div>;
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 (
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style={{ width: '100%', height: '180px' }}>
<polyline points={points} fill="none" stroke={color} strokeWidth="1.5" />
{data.map((d: any, i) => (
<circle key={i} cx={i * w} cy={100 - ((d[key] || 0) / max) * 90} r="1" fill={color} />
))}
</svg>
);
};
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 (
<div style={{ padding: '24px', color: '#e2e8f0' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '24px' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700 }}>数据分析面板</h1>
<div style={{ display: 'flex', gap: '8px' }}>
{[7, 14, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)} style={{ padding: '6px 16px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: days === d ? '#6366f1' : '#1e293b', color: days === d ? '#fff' : '#94a3b8', fontWeight: 600, fontSize: '13px' }}>{d}天</button>
))}
</div>
</div>
{loading ? <div style={{ textAlign: 'center', padding: '80px', color: '#64748b' }}>加载中...</div> : (
<>
{/* Stats grid */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
{statCards.map((s, i) => (
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33`, display: 'flex', alignItems: 'center', gap: '16px' }}>
<div style={{ width: '48px', height: '48px', borderRadius: '12px', background: `${s.color}22`, color: s.color, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '18px', fontWeight: 800 }}>{s.icon}</div>
<div>
<div style={{ fontSize: '24px', fontWeight: 800, color: s.color }}>{typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
<div style={{ fontSize: '12px', color: '#94a3b8', marginTop: '2px' }}>{s.label}</div>
</div>
</div>
))}
</div>
{/* Charts grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>API调用趋势 ({days}天)</h3>
{renderBar(apiTrend, 'calls', '#f59e0b', '调用次数')}
</div>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>Token使用趋势 ({days}天)</h3>
{renderLine(tokenTrend.daily, 'total_tokens', '#ec4899')}
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '16px', marginBottom: '16px' }}>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>用户增长 ({days}天)</h3>
{renderBar(userTrend, 'new_users', '#3b82f6', '新增用户')}
</div>
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '16px' }}>收入趋势 ({days}天)</h3>
{renderBar(revenueTrend, 'revenue', '#10b981', '收入')}
</div>
</div>
{/* Model usage */}
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分析 ({days}天)</h3>
{modelUsage.length > 0 ? (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr style={{ borderBottom: '2px solid #334155' }}>
{['模型', '调用次数', '总Tokens', '平均Tokens', '费用(元)'].map(h => (
<th key={h} style={{ padding: '10px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{modelUsage.map((m, i) => (
<tr key={i} style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: '10px 16px', fontSize: '14px', fontWeight: 600 }}>{m.model_name}</td>
<td style={{ padding: '10px 16px', fontSize: '14px' }}>{m.call_count.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ec4899', fontWeight: 600 }}>{m.total_tokens.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '13px', color: '#94a3b8' }}>{m.avg_tokens.toLocaleString()}</td>
<td style={{ padding: '10px 16px', fontSize: '14px', color: '#ef4444' }}>¥{m.total_cost.toFixed(4)}</td>
</tr>
))}
</tbody>
</table>
) : <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无模型使用数据</div>}
</div>
{/* Model distribution pie (CSS) */}
{Object.keys(tokenTrend.model_distribution || {}).length > 0 && (
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px', marginTop: '16px' }}>
<h3 style={{ fontSize: '15px', fontWeight: 600, marginBottom: '20px' }}>模型使用分布</h3>
<div style={{ display: 'flex', gap: '24px', alignItems: 'center' }}>
<div style={{ display: 'flex', borderRadius: '50%', overflow: 'hidden', width: '160px', height: '160px' }}>
{(() => {
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 <div key={model} style={{ width: `${pct}%`, background: colors[i % colors.length] }} title={`${model}: ${pct.toFixed(1)}%`} />;
});
})()}
</div>
<div style={{ flex: 1 }}>
{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 (
<div key={model} style={{ display: 'flex', alignItems: 'center', gap: '8px', marginBottom: '8px' }}>
<div style={{ width: '12px', height: '12px', borderRadius: '3px', background: colors[i % colors.length] }} />
<span style={{ fontSize: '13px', color: '#e2e8f0' }}>{model}</span>
<span style={{ fontSize: '13px', color: '#94a3b8', marginLeft: 'auto' }}>{((val / total) * 100).toFixed(1)}%</span>
</div>
);
})}
</div>
</div>
</div>
)}
</>
)}
</div>
);
};
export default Analytics;
+209
View File
@@ -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<Rule[]>([]);
const [packages, setPackages] = useState<Package[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [stats, setStats] = useState<Stats | null>(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<string, string> = { per_token: '#6366f1', per_call: '#3b82f6', per_month: '#10b981', hybrid: '#f59e0b' };
const txColors: Record<string, string> = { recharge: '#10b981', consumption: '#ef4444', refund: '#f59e0b', gift: '#ec4899' };
return (
<div style={{ padding: '24px', color: '#e2e8f0' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>计费与支付管理</h1>
{/* Stats */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px', marginBottom: '24px' }}>
{[
{ 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) => (
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33` }}>
<div style={{ fontSize: '28px', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '13px', color: '#94a3b8', marginTop: '4px' }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
{([['overview', '概览'], ['rules', '计费规则'], ['packages', '充值套餐'], ['transactions', '交易记录']] as const).map(([k, label]) => (
<button key={k} onClick={() => setTab(k)}
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: tab === k ? '#6366f1' : '#1e293b', color: tab === k ? '#fff' : '#94a3b8', fontWeight: 600, fontSize: '14px' }}>{label}</button>
))}
</div>
{/* Overview */}
{tab === 'overview' && stats && (
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '24px' }}>
<h3 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px' }}>近7天收入趋势</h3>
{stats.daily_revenue_7d && stats.daily_revenue_7d.length > 0 ? (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '12px', height: '200px', padding: '0 20px' }}>
{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 (
<div key={i} style={{ flex: 1, display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ fontSize: '11px', color: '#94a3b8', marginBottom: '4px' }}>¥{d.revenue.toFixed(0)}</div>
<div style={{ width: '100%', height: `${h}px`, background: 'linear-gradient(180deg, #6366f1, #818cf8)', borderRadius: '4px 4px 0 0' }} />
<div style={{ fontSize: '11px', color: '#64748b', marginTop: '6px' }}>{d.date.substring(5)}</div>
</div>
);
})}
</div>
) : <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>暂无收入数据</div>}
</div>
)}
{/* Rules */}
{tab === 'rules' && (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
<thead><tr style={{ background: '#0f172a' }}>
{['规则名称', '计费类型', '关联模型', '单价', '免费额度', '状态'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{rules.map(r => (
<tr key={r.id} style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{r.name}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${typeColors[r.billing_type]}22`, color: typeColors[r.billing_type] }}>{r.billing_type}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{r.model_name || '通用'}</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>¥{r.rate.toFixed(6)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{r.free_quota?.toLocaleString() || 0}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: r.status === 'active' ? '#10b981' : '#64748b', fontSize: '13px' }}>{r.status === 'active' ? '启用' : '停用'}</span>
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Packages */}
{tab === 'packages' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '16px' }}>
{packages.map(p => (
<div key={p.id} style={{ background: '#1e293b', borderRadius: '16px', padding: '24px', position: 'relative', border: p.is_popular ? '2px solid #6366f1' : '1px solid #334155' }}>
{p.is_popular === 1 && (
<div style={{ position: 'absolute', top: '-10px', right: '16px', background: '#6366f1', color: '#fff', padding: '2px 12px', borderRadius: '12px', fontSize: '11px', fontWeight: 700 }}>推荐</div>
)}
<h3 style={{ fontSize: '18px', fontWeight: 700, marginBottom: '8px' }}>{p.name}</h3>
<p style={{ fontSize: '13px', color: '#94a3b8', marginBottom: '16px' }}>{p.description || ''}</p>
<div style={{ fontSize: '32px', fontWeight: 800, color: '#6366f1' }}>¥{p.price}</div>
{p.original_price && <div style={{ fontSize: '14px', color: '#64748b', textDecoration: 'line-through' }}>¥{p.original_price}</div>}
<div style={{ marginTop: '16px', paddingTop: '16px', borderTop: '1px solid #334155', fontSize: '13px', color: '#94a3b8' }}>
<div>主额度: <span style={{ color: '#e2e8f0', fontWeight: 600 }}>{p.tokens.toLocaleString()}</span> tokens</div>
{p.bonus_tokens > 0 && <div>赠送: <span style={{ color: '#10b981', fontWeight: 600 }}>{p.bonus_tokens.toLocaleString()}</span> tokens</div>}
</div>
<div style={{ marginTop: '12px', fontSize: '12px' }}>
<span style={{ color: p.status === 'active' ? '#10b981' : '#64748b' }}>{p.status === 'active' ? '● 在售' : '○ 已停用'}</span>
</div>
</div>
))}
</div>
)}
{/* Transactions */}
{tab === 'transactions' && (
<>
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>加载中...</div> : (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
<thead><tr style={{ background: '#0f172a' }}>
{['时间', '用户', '类型', '金额(元)', 'Tokens', '状态', '描述'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
))}
</tr></thead>
<tbody>
{transactions.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.created_at?.substring(0, 16)}</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.username || t.user_id.substring(0, 8)}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600, background: `${txColors[t.transaction_type]}22`, color: txColors[t.transaction_type] }}>{t.transaction_type}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600, color: t.transaction_type === 'recharge' ? '#10b981' : '#ef4444' }}>{t.amount > 0 ? '+' : ''}{t.amount.toFixed(2)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.tokens?.toLocaleString() || '-'}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: t.status === 'completed' ? '#10b981' : '#f59e0b', fontSize: '13px' }}>{t.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.description || '-'}</td>
</tr>
))}
{transactions.length === 0 && <tr><td colSpan={7} style={{ padding: '40px', textAlign: 'center', color: '#64748b' }}>暂无交易记录</td></tr>}
</tbody>
</table>
)}
{total > 20 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
<span style={{ padding: '6px 16px', color: '#94a3b8' }}>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total} style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
</div>
)}
</>
)}
</div>
);
};
export default Billing;
+235
View File
@@ -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<Tenant[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [stats, setStats] = useState<Stats | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [selectedTenant, setSelectedTenant] = useState<string>('');
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<string, string> = { active: '#10b981', suspended: '#f59e0b', terminated: '#ef4444' };
const roleColors: Record<string, string> = { owner: '#a78bfa', admin: '#60a5fa', member: '#94a3b8', viewer: '#64748b' };
return (
<div style={{ padding: '24px', color: '#e2e8f0' }}>
<h1 style={{ fontSize: '24px', fontWeight: 700, marginBottom: '24px' }}>多租户管理</h1>
{/* Stats cards */}
{stats && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: '16px', marginBottom: '24px' }}>
{[
{ 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) => (
<div key={i} style={{ background: '#1e293b', borderRadius: '12px', padding: '20px', border: `1px solid ${s.color}33` }}>
<div style={{ fontSize: '32px', fontWeight: 800, color: s.color }}>{s.value}</div>
<div style={{ fontSize: '13px', color: '#94a3b8', marginTop: '4px' }}>{s.label}</div>
</div>
))}
</div>
)}
{/* Tabs */}
<div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
{([['list', '租户列表'], ['members', '成员管理'], ['stats', '统计详情']] as const).map(([k, label]) => (
<button key={k} onClick={() => setTab(k)}
style={{ padding: '8px 20px', borderRadius: '8px', border: 'none', cursor: 'pointer',
background: tab === k ? '#6366f1' : '#1e293b', color: tab === k ? '#fff' : '#94a3b8',
fontWeight: 600, fontSize: '14px' }}>{label}</button>
))}
{tab === 'list' && (
<button onClick={() => setShowCreate(true)} style={{ marginLeft: 'auto', padding: '8px 20px', borderRadius: '8px',
border: 'none', cursor: 'pointer', background: '#6366f1', color: '#fff', fontWeight: 600, fontSize: '14px' }}>
+ 创建租户
</button>
)}
</div>
{/* Tab content */}
{tab === 'list' && (
<div>
{loading ? <div style={{ textAlign: 'center', padding: '40px', color: '#64748b' }}>加载中...</div> : (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
<thead>
<tr style={{ background: '#0f172a' }}>
{['租户名称', '标识', '状态', '成员数', '存储上限', 'API/天', '创建时间', '操作'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{tenants.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{t.name}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.slug}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
background: `${statusColors[t.status]}22`, color: statusColors[t.status] }}>{t.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '14px' }}>{t.member_count ?? '-'}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{(t.max_storage_mb / 1024).toFixed(1)} GB</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{t.max_api_calls_per_day.toLocaleString()}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{t.created_at?.substring(0, 10)}</td>
<td style={{ padding: '12px 16px' }}>
<button onClick={() => { setSelectedTenant(t.id); setTab('members'); }}
style={{ padding: '4px 12px', borderRadius: '6px', border: '1px solid #475569',
background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '12px' }}>管理</button>
</td>
</tr>
))}
</tbody>
</table>
)}
{total > 20 && (
<div style={{ display: 'flex', justifyContent: 'center', gap: '8px', marginTop: '20px' }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page <= 1 ? 'not-allowed' : 'pointer' }}>上一页</button>
<span style={{ padding: '6px 16px', color: '#94a3b8' }}>第 {page} 页</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * 20 >= total}
style={{ padding: '6px 16px', borderRadius: '6px', border: '1px solid #475569', background: '#1e293b', color: '#94a3b8', cursor: page * 20 >= total ? 'not-allowed' : 'pointer' }}>下一页</button>
</div>
)}
</div>
)}
{tab === 'members' && (
<div>
<div style={{ marginBottom: '16px' }}>
<select value={selectedTenant} onChange={e => setSelectedTenant(e.target.value)}
style={{ padding: '8px 12px', borderRadius: '8px', background: '#1e293b', color: '#e2e8f0', border: '1px solid #475569', fontSize: '14px' }}>
<option value="">选择租户...</option>
{tenants.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
{selectedTenant && (
<table style={{ width: '100%', borderCollapse: 'collapse', background: '#1e293b', borderRadius: '12px', overflow: 'hidden' }}>
<thead>
<tr style={{ background: '#0f172a' }}>
{['用户名', '邮箱', '角色', '状态', '加入时间'].map(h => (
<th key={h} style={{ padding: '12px 16px', textAlign: 'left', fontSize: '13px', color: '#94a3b8', fontWeight: 600 }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{members.map(m => (
<tr key={m.id} style={{ borderBottom: '1px solid #334155' }}>
<td style={{ padding: '12px 16px', fontSize: '14px', fontWeight: 600 }}>{m.username || m.user_id.substring(0, 8)}</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#94a3b8' }}>{m.email || '-'}</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ padding: '2px 10px', borderRadius: '12px', fontSize: '12px', fontWeight: 600,
background: `${roleColors[m.role]}22`, color: roleColors[m.role] }}>{m.role}</span>
</td>
<td style={{ padding: '12px 16px' }}>
<span style={{ color: m.status === 'active' ? '#10b981' : '#ef4444', fontSize: '13px' }}>{m.status}</span>
</td>
<td style={{ padding: '12px 16px', fontSize: '13px', color: '#64748b' }}>{m.joined_at?.substring(0, 16)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)}
{tab === 'stats' && stats && (
<div style={{ background: '#1e293b', borderRadius: '12px', padding: '32px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: '24px' }}>
{Object.entries(stats).map(([k, v]) => (
<div key={k} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '16px', background: '#0f172a', borderRadius: '8px' }}>
<span style={{ fontSize: '14px', color: '#94a3b8' }}>{k.replace(/_/g, ' ').toUpperCase()}</span>
<span style={{ fontSize: '24px', fontWeight: 800, color: '#6366f1' }}>{String(v)}</span>
</div>
))}
</div>
</div>
)}
{/* Create modal */}
{showCreate && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 }}
onClick={() => setShowCreate(false)}>
<div onClick={e => e.stopPropagation()} style={{ background: '#1e293b', borderRadius: '16px', padding: '32px', width: '480px', maxWidth: '90vw' }}>
<h2 style={{ fontSize: '20px', fontWeight: 700, marginBottom: '24px' }}>创建租户</h2>
{[
{ 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 => (
<div key={f.k} style={{ marginBottom: '16px' }}>
<label style={{ display: 'block', fontSize: '13px', color: '#94a3b8', marginBottom: '6px' }}>{f.label}</label>
<input type={f.type} value={(form as any)[f.k]} onChange={e => 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' }} />
</div>
))}
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button onClick={() => setShowCreate(false)} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: '1px solid #475569', background: 'transparent', color: '#94a3b8', cursor: 'pointer', fontSize: '14px' }}>取消</button>
<button onClick={handleCreate} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontSize: '14px', fontWeight: 600 }}>创建</button>
</div>
</div>
</div>
)}
</div>
);
};
export default Tenant;