refactor: sync running-container source back to repo for reproducible docker compose build; remove dead code (orphan pipeline.py + 104 stale files from iterative dev) and drop unused app/ mirror dir
This commit is contained in:
1 parent
1346fbbd4b
commit
5d7f32db0a
138 files changed
+12846
-84248
No files matched your search
@@ -1,426 +0,0 @@
|
||||
"""
|
||||
多租户管理后端 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