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,
|
||||
}}
|
||||
Reference in new issue
Block a user