Files
qihuaiflow/backend/python/app/api/admin/tenant_admin.py
T

427 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
多租户管理后端 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,
}}