427 lines
17 KiB
Python
427 lines
17 KiB
Python
"""
|
||
多租户管理后端 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,
|
||
}}
|