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,
}}