Feature: Multi-tenant + Billing + Analytics - 3 new modules with 40+ API endpoints
This commit is contained in:
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
@@ -1,454 +1,453 @@
|
||||
"""
|
||||
|
||||
Qihu-AIFlow FastAPI 主应用
|
||||
|
||||
完整后端API,监听18080端口,所有接口以 /app 为前缀
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import os
|
||||
|
||||
import time
|
||||
|
||||
import uuid
|
||||
|
||||
import logging
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
|
||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin
|
||||
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||
from app.api.admin.mcp_market_admin import router as mcp_market_router
|
||||
from app.api.admin import asset_admin, achievement_admin
|
||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
||||
|
||||
from app.database import SessionLocal
|
||||
|
||||
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
|
||||
|
||||
|
||||
|
||||
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
|
||||
|
||||
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
|
||||
|
||||
|
||||
|
||||
# 初始化文件日志(按天轮转)
|
||||
|
||||
access_logger = setup_file_logging()
|
||||
|
||||
logger = logging.getLogger("aiflow")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class APITrackingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
|
||||
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
path = request.url.path
|
||||
|
||||
method = request.method
|
||||
|
||||
|
||||
|
||||
# 跳过健康检查和静态资源
|
||||
|
||||
skip_paths = ["/app/api/health", "/favicon.ico"]
|
||||
|
||||
should_skip = any(path.startswith(p) for p in skip_paths)
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
status_code = response.status_code
|
||||
|
||||
|
||||
|
||||
if not should_skip:
|
||||
|
||||
access_logger.info(
|
||||
|
||||
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 异步记录API调用统计
|
||||
|
||||
try:
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
|
||||
_record_api_call(db, method, path, status_code, elapsed)
|
||||
|
||||
finally:
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"API统计记录失败: {e}")
|
||||
|
||||
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if not should_skip:
|
||||
|
||||
access_logger.info(
|
||||
|
||||
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
|
||||
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
|
||||
if scope["type"] != "http":
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
return
|
||||
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
|
||||
|
||||
"""记录API调用到数据库统计表"""
|
||||
|
||||
# 简化路径以减少基数(去掉动态ID)
|
||||
|
||||
parts = path.split("/")
|
||||
|
||||
simplified_parts = []
|
||||
|
||||
for part in parts:
|
||||
|
||||
# 将UUID样式的路径段替换为{id}
|
||||
|
||||
if len(part) == 36 and part.count("-") == 4:
|
||||
|
||||
simplified_parts.append("{id}")
|
||||
|
||||
# 将纯数字路径段替换为{id}
|
||||
|
||||
elif part.isdigit():
|
||||
|
||||
simplified_parts.append("{id}")
|
||||
|
||||
else:
|
||||
|
||||
simplified_parts.append(part)
|
||||
|
||||
simplified_path = "/".join(simplified_parts)
|
||||
|
||||
|
||||
|
||||
# 统计记录通过数据库实现,简化版记录到日志
|
||||
|
||||
is_error = status_code >= 400
|
||||
|
||||
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
|
||||
|
||||
|
||||
|
||||
if is_slow:
|
||||
|
||||
logger.warning(
|
||||
|
||||
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
logger.info("Qihu-AIFlow v2.0.0 启动中...")
|
||||
|
||||
|
||||
|
||||
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
|
||||
|
||||
try:
|
||||
|
||||
from app.services.evolution_service import EvolutionWatcherManager
|
||||
|
||||
from app.models import UserEvolutionConfig
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
|
||||
configs = db.query(UserEvolutionConfig).filter(
|
||||
|
||||
UserEvolutionConfig.watcher_status == "running"
|
||||
|
||||
).all()
|
||||
|
||||
if configs:
|
||||
|
||||
watcher = EvolutionWatcherManager()
|
||||
|
||||
for cfg in configs:
|
||||
|
||||
try:
|
||||
|
||||
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
|
||||
|
||||
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
|
||||
|
||||
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
|
||||
|
||||
else:
|
||||
|
||||
logger.info("[Evolution] 无待恢复的进化配置")
|
||||
|
||||
finally:
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
|
||||
|
||||
|
||||
|
||||
# 启动Redis事件总线
|
||||
|
||||
try:
|
||||
|
||||
from app.event_bus import get_event_bus
|
||||
|
||||
event_bus = get_event_bus()
|
||||
|
||||
event_bus.start(num_consumers=2)
|
||||
|
||||
logger.info("[EventBus] Redis事件总线已启动")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
|
||||
|
||||
|
||||
|
||||
logger.info("Qihu-AIFlow v2.0.0 启动完成")
|
||||
|
||||
|
||||
|
||||
yield
|
||||
|
||||
|
||||
|
||||
# 停止Redis事件总线
|
||||
|
||||
try:
|
||||
|
||||
from app.event_bus import get_event_bus
|
||||
|
||||
get_event_bus().stop()
|
||||
|
||||
logger.info("[EventBus] Redis事件总线已停止")
|
||||
|
||||
except Exception:
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
# 优雅关闭:停止所有 Evolution Pipeline
|
||||
|
||||
try:
|
||||
|
||||
from app.services.evolution_service import EvolutionWatcherManager
|
||||
|
||||
EvolutionWatcherManager().shutdown_all()
|
||||
|
||||
logger.info("[Evolution] 所有Pipeline已停止")
|
||||
|
||||
except Exception:
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
logger.info("Qihu-AIFlow 正在关闭...")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
title="Qihu-AIFlow - 智能自动化PaaS平台",
|
||||
|
||||
version="1.0.0",
|
||||
|
||||
lifespan=lifespan,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# API调用追踪中间件
|
||||
|
||||
app.add_middleware(APITrackingMiddleware)
|
||||
|
||||
|
||||
|
||||
# CORS配置
|
||||
|
||||
app.add_middleware(
|
||||
|
||||
CORSMiddleware,
|
||||
|
||||
allow_origins=["*"],
|
||||
|
||||
allow_credentials=True,
|
||||
|
||||
allow_methods=["*"],
|
||||
|
||||
allow_headers=["*"],
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 前台 API
|
||||
|
||||
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
|
||||
|
||||
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
|
||||
|
||||
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])
|
||||
|
||||
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
|
||||
|
||||
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
|
||||
|
||||
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
|
||||
|
||||
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
|
||||
|
||||
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
|
||||
|
||||
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
|
||||
|
||||
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
|
||||
|
||||
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
|
||||
|
||||
|
||||
|
||||
# 后台 API
|
||||
|
||||
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
|
||||
|
||||
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
|
||||
|
||||
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
|
||||
|
||||
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
|
||||
|
||||
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
|
||||
|
||||
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
|
||||
|
||||
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
|
||||
|
||||
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
|
||||
|
||||
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
|
||||
|
||||
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
|
||||
|
||||
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
|
||||
|
||||
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
||||
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
||||
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
|
||||
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
|
||||
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
||||
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
|
||||
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
|
||||
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
|
||||
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
|
||||
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
|
||||
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
|
||||
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
|
||||
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
|
||||
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
|
||||
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
|
||||
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
|
||||
|
||||
app.include_router(
|
||||
achievement_admin.router,
|
||||
prefix="/app/api/admin/achievement",
|
||||
tags=["achievement"]
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/app/api/health")
|
||||
|
||||
async def health_check():
|
||||
|
||||
return {"status": "ok", "service": "Qihu-AIFlow"}
|
||||
|
||||
"""
|
||||
|
||||
Qihu-AIFlow FastAPI 主应用
|
||||
|
||||
完整后端API,监听18080端口,所有接口以 /app 为前缀
|
||||
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
import os
|
||||
|
||||
import time
|
||||
|
||||
import uuid
|
||||
|
||||
import logging
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
|
||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||
|
||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin, distro_admin, code_exec_admin, privacy_admin, im_bot_admin, promotion_admin, achievement_admin , tenant_admin, billing_admin, analytics_admin
|
||||
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||
from app.api.admin.mcp_market_admin import router as mcp_market_router
|
||||
from app.api.admin import asset_admin, achievement_admin
|
||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
||||
|
||||
from app.database import SessionLocal
|
||||
|
||||
from app.models import OperationLog as OperationLogModel, TokenUsageLog as TokenUsageLogModel
|
||||
|
||||
|
||||
|
||||
# 导入平台适配器触发注册(AstrBot借鉴优化方案 P1)
|
||||
|
||||
import app.platform_adapters # noqa: F401 - 触发所有平台适配器装饰器注册
|
||||
|
||||
|
||||
|
||||
# 初始化文件日志(按天轮转)
|
||||
|
||||
access_logger = setup_file_logging()
|
||||
|
||||
logger = logging.getLogger("aiflow")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class APITrackingMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
"""API调用统计中间件 - 记录请求耗时,标记慢请求"""
|
||||
|
||||
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
path = request.url.path
|
||||
|
||||
method = request.method
|
||||
|
||||
|
||||
|
||||
# 跳过健康检查和静态资源
|
||||
|
||||
skip_paths = ["/app/api/health", "/favicon.ico"]
|
||||
|
||||
should_skip = any(path.startswith(p) for p in skip_paths)
|
||||
|
||||
|
||||
|
||||
try:
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
status_code = response.status_code
|
||||
|
||||
|
||||
|
||||
if not should_skip:
|
||||
|
||||
access_logger.info(
|
||||
|
||||
f"[{request_id}] {method} {path} -> {status_code} ({elapsed:.3f}s)"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 异步记录API调用统计
|
||||
|
||||
try:
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
|
||||
_record_api_call(db, method, path, status_code, elapsed)
|
||||
|
||||
finally:
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"API统计记录失败: {e}")
|
||||
|
||||
|
||||
|
||||
return response
|
||||
|
||||
except Exception as e:
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
if not should_skip:
|
||||
|
||||
access_logger.info(
|
||||
|
||||
f"[{request_id}] {method} {path} -> ERROR ({elapsed:.3f}s): {str(e)}"
|
||||
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
|
||||
if scope["type"] != "http":
|
||||
|
||||
await self.app(scope, receive, send)
|
||||
|
||||
return
|
||||
|
||||
await super().__call__(scope, receive, send)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def _record_api_call(db, method: str, path: str, status_code: int, elapsed: float):
|
||||
|
||||
"""记录API调用到数据库统计表"""
|
||||
|
||||
# 简化路径以减少基数(去掉动态ID)
|
||||
|
||||
parts = path.split("/")
|
||||
|
||||
simplified_parts = []
|
||||
|
||||
for part in parts:
|
||||
|
||||
# 将UUID样式的路径段替换为{id}
|
||||
|
||||
if len(part) == 36 and part.count("-") == 4:
|
||||
|
||||
simplified_parts.append("{id}")
|
||||
|
||||
# 将纯数字路径段替换为{id}
|
||||
|
||||
elif part.isdigit():
|
||||
|
||||
simplified_parts.append("{id}")
|
||||
|
||||
else:
|
||||
|
||||
simplified_parts.append(part)
|
||||
|
||||
simplified_path = "/".join(simplified_parts)
|
||||
|
||||
|
||||
|
||||
# 统计记录通过数据库实现,简化版记录到日志
|
||||
|
||||
is_error = status_code >= 400
|
||||
|
||||
is_slow = elapsed >= SLOW_REQUEST_THRESHOLD
|
||||
|
||||
|
||||
|
||||
if is_slow:
|
||||
|
||||
logger.warning(
|
||||
|
||||
f"慢请求: {method} {simplified_path} -> {status_code} ({elapsed:.3f}s, 阈值: {SLOW_REQUEST_THRESHOLD}s)"
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
||||
async def lifespan(app: FastAPI):
|
||||
|
||||
logger.info("Qihu-AIFlow v2.0.0 启动中...")
|
||||
|
||||
|
||||
|
||||
# 启动后台 Evolution Pipeline(知识库图谱已存在的用户配置自动恢复运行)
|
||||
|
||||
try:
|
||||
|
||||
from app.services.evolution_service import EvolutionWatcherManager
|
||||
|
||||
from app.models import UserEvolutionConfig
|
||||
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
|
||||
configs = db.query(UserEvolutionConfig).filter(
|
||||
|
||||
UserEvolutionConfig.watcher_status == "running"
|
||||
|
||||
).all()
|
||||
|
||||
if configs:
|
||||
|
||||
watcher = EvolutionWatcherManager()
|
||||
|
||||
for cfg in configs:
|
||||
|
||||
try:
|
||||
|
||||
watcher.start_user_evolution(cfg.user_id, lambda: SessionLocal())
|
||||
|
||||
logger.info(f"[Evolution] 恢复用户 {cfg.user_id} 的进化Pipeline")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"[Evolution] 恢复用户 {cfg.user_id} 失败: {e}")
|
||||
|
||||
logger.info(f"[Evolution] 已恢复 {len(configs)} 个用户的进化Pipeline")
|
||||
|
||||
else:
|
||||
|
||||
logger.info("[Evolution] 无待恢复的进化配置")
|
||||
|
||||
finally:
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"[Evolution] Pipeline自动启动异常(不影响正常服务): {e}")
|
||||
|
||||
|
||||
|
||||
# 启动Redis事件总线
|
||||
|
||||
try:
|
||||
|
||||
from app.event_bus import get_event_bus
|
||||
|
||||
event_bus = get_event_bus()
|
||||
|
||||
event_bus.start(num_consumers=2)
|
||||
|
||||
logger.info("[EventBus] Redis事件总线已启动")
|
||||
|
||||
except Exception as e:
|
||||
|
||||
logger.warning(f"[EventBus] 启动失败(不影响正常服务): {e}")
|
||||
|
||||
|
||||
|
||||
logger.info("Qihu-AIFlow v2.0.0 启动完成")
|
||||
|
||||
|
||||
|
||||
yield
|
||||
|
||||
|
||||
|
||||
# 停止Redis事件总线
|
||||
|
||||
try:
|
||||
|
||||
from app.event_bus import get_event_bus
|
||||
|
||||
get_event_bus().stop()
|
||||
|
||||
logger.info("[EventBus] Redis事件总线已停止")
|
||||
|
||||
except Exception:
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
# 优雅关闭:停止所有 Evolution Pipeline
|
||||
|
||||
try:
|
||||
|
||||
from app.services.evolution_service import EvolutionWatcherManager
|
||||
|
||||
EvolutionWatcherManager().shutdown_all()
|
||||
|
||||
logger.info("[Evolution] 所有Pipeline已停止")
|
||||
|
||||
except Exception:
|
||||
|
||||
pass
|
||||
|
||||
|
||||
|
||||
logger.info("Qihu-AIFlow 正在关闭...")
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
||||
title="Qihu-AIFlow - 智能自动化PaaS平台",
|
||||
|
||||
version="1.0.0",
|
||||
|
||||
lifespan=lifespan,
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# API调用追踪中间件
|
||||
|
||||
app.add_middleware(APITrackingMiddleware)
|
||||
|
||||
|
||||
|
||||
# CORS配置
|
||||
|
||||
app.add_middleware(
|
||||
|
||||
CORSMiddleware,
|
||||
|
||||
allow_origins=["*"],
|
||||
|
||||
allow_credentials=True,
|
||||
|
||||
allow_methods=["*"],
|
||||
|
||||
allow_headers=["*"],
|
||||
|
||||
)
|
||||
|
||||
|
||||
|
||||
# 前台 API
|
||||
|
||||
app.include_router(auth.router, prefix="/app/api/auth", tags=["认证"])
|
||||
|
||||
app.include_router(chat.router, prefix="/app/api/chat", tags=["AI聊天"])
|
||||
|
||||
app.include_router(channels.router, prefix="/app/api/channels", tags=["频道"])
|
||||
|
||||
app.include_router(knowledge.router, prefix="/app/api/knowledge", tags=["知识库"])
|
||||
|
||||
app.include_router(market.router, prefix="/app/api/market", tags=["应用市场"])
|
||||
|
||||
app.include_router(token_usage.router, prefix="/app/api/token", tags=["Token驾驶舱"])
|
||||
|
||||
app.include_router(payment.router, prefix="/app/api/payment", tags=["付费中心"])
|
||||
|
||||
app.include_router(profile.router, prefix="/app/api/profile", tags=["个人中心"])
|
||||
|
||||
app.include_router(notifications.router, prefix="/app/api/notifications", tags=["消息通知"])
|
||||
|
||||
app.include_router(assets.router, prefix="/app/api/assets", tags=["资产管理"])
|
||||
|
||||
app.include_router(evolution.router, prefix="/app/api/evolution", tags=["AI自进化"])
|
||||
|
||||
|
||||
|
||||
# 后台 API
|
||||
|
||||
app.include_router(dashboard.router, prefix="/app/api/admin/dashboard", tags=["后台-仪表盘"])
|
||||
|
||||
app.include_router(users.router, prefix="/app/api/admin/users", tags=["后台-用户管理"])
|
||||
|
||||
app.include_router(models.router, prefix="/app/api/admin/models", tags=["后台-模型管理"])
|
||||
|
||||
app.include_router(settings.router, prefix="/app/api/admin/settings", tags=["后台-网站设置"])
|
||||
|
||||
app.include_router(payment_admin.router, prefix="/app/api/admin/payment", tags=["后台-支付配置"])
|
||||
|
||||
app.include_router(services.router, prefix="/app/api/admin/services", tags=["后台-增值服务"])
|
||||
|
||||
app.include_router(membership_admin.router, prefix="/app/api/admin/membership", tags=["后台-会员管理"])
|
||||
|
||||
app.include_router(ads.router, prefix="/app/api/admin/ads", tags=["后台-广告管理"])
|
||||
|
||||
app.include_router(market_admin.router, prefix="/app/api/admin/market", tags=["后台-应用审核"])
|
||||
|
||||
app.include_router(logs.router, prefix="/app/api/admin/logs", tags=["后台-日志风控"])
|
||||
|
||||
app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["后台-技能管理"])
|
||||
|
||||
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
||||
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
||||
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
|
||||
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
|
||||
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
||||
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
|
||||
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
|
||||
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
|
||||
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
|
||||
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
|
||||
app.include_router(mcp_market_router, prefix="/app/api/admin/mcp-market", tags=["mcp-market"])
|
||||
app.include_router(code_exec_admin.router, prefix="/app/api/admin/code-exec", tags=["code-exec"])
|
||||
app.include_router(distro_admin.router, prefix="/app/api/admin/distributions", tags=["distributions"])
|
||||
app.include_router(privacy_admin.router, prefix="/app/api/admin/privacy", tags=["privacy"])
|
||||
app.include_router(im_bot_admin.router, prefix="/app/api/admin/im-bot", tags=["im-bot"])
|
||||
app.include_router(promotion_admin.router, prefix="/app/api/admin/promotion", tags=["promotion"])
|
||||
|
||||
app.include_router(tenant_admin.router, prefix="/app/api/admin/tenant", tags=["tenant"])
|
||||
app.include_router(billing_admin.router, prefix="/app/api/admin/billing", tags=["billing"])
|
||||
app.include_router(analytics_admin.router, prefix="/app/api/admin/analytics", tags=["analytics"])
|
||||
app.include_router(achievement_admin.router, prefix="/app/api/admin/achievement", tags=["achievement"])
|
||||
|
||||
|
||||
|
||||
|
||||
@app.get("/app/api/health")
|
||||
|
||||
async def health_check():
|
||||
|
||||
return {"status": "ok", "service": "Qihu-AIFlow"}
|
||||
|
||||
Reference in new issue
Block a user