""" 多租户管理后端 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, }}