# -*- coding: utf-8 -*- """ 管理端路由 — 租户管理员 CRUD。 端点清单: - GET /api/admin/tenant-admins — 管理员列表(分页 + 关键词搜索) - POST /api/admin/tenant-admins — 创建管理员 - PATCH /api/admin/tenant-admins/{id} — 编辑管理员 - DELETE /api/admin/tenant-admins/{id} — 软删除管理员 - POST /api/admin/tenant-admins/{id}/reset-password — 重置密码 所有端点要求 JWT + site_admin 或 tenant_admin 角色。 需求: 14.1-14.7, A2.3, A2.6, A2.7, A2.8, A4.1 """ from __future__ import annotations import logging from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, status from psycopg2 import errors as pg_errors from app.auth.dependencies import CurrentUser, get_current_user from app.auth.jwt import hash_password from app.database import get_connection from app.schemas.admin_tenant_admins import ( ResetPasswordRequest, TenantAdminCreateRequest, TenantAdminEditRequest, TenantAdminListItem, ) logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/admin", tags=["管理端租户管理员"]) # ── 管理端权限依赖:要求 site_admin 或 tenant_admin 角色 ── def _require_admin(): """ 管理端依赖:要求 JWT 中角色包含 site_admin 或 tenant_admin。 直接从 JWT 校验角色,不查 auth.users 表(管理员在 admin_users 表, 不在 auth.users 中,require_permission 会报"用户不存在")。 """ async def _dependency( user: CurrentUser = Depends(get_current_user), ) -> CurrentUser: admin_roles = {"site_admin", "tenant_admin"} if not admin_roles.intersection(user.roles): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限(site_admin 或 tenant_admin)", ) return user return _dependency # ── GET /api/admin/tenant-admins ────────────────────────── @router.get("/tenant-admins") async def list_tenant_admins( page: int = Query(1, ge=1, description="页码"), page_size: int = Query(20, ge=1, le=100, description="每页条数"), keyword: Optional[str] = Query(None, description="关键词搜索(用户名/显示名称)"), include_inactive: bool = Query(False, description="是否包含已禁用的管理员"), user: CurrentUser = Depends(_require_admin()), ): """ 查询租户管理员列表,支持分页和关键词搜索。 默认只返回 is_active=true 的记录;include_inactive=true 时返回所有记录。 JOIN biz.tenants 获取 tenant_name。 需求 14.1, A2.7, A2.6 """ offset = (page - 1) * page_size conn = get_connection() try: with conn.cursor() as cur: # 构建查询 where_clauses: list[str] = [] params: list = [] # CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | 始终过滤已删除记录 where_clauses.append("ta.deleted_at IS NULL") # CHANGE 2026-03-23 | Prompt: 任务5.1 A2.7 | 默认过滤 is_active if not include_inactive: where_clauses.append("ta.is_active = true") if keyword: where_clauses.append( "(ta.username ILIKE %s OR ta.display_name ILIKE %s)" ) like_val = f"%{keyword}%" params.extend([like_val, like_val]) where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" # 查询总数 cur.execute( f""" SELECT COUNT(*) FROM auth.tenant_admins ta {where_sql} """, params, ) total = cur.fetchone()[0] # CHANGE 2026-03-23 | Prompt: 任务5.1 A2.6 | JOIN biz.tenants 获取 tenant_name # CHANGE 2026-03-23 | Prompt: 角色体系隔离 | 加入 admin_type 列 cur.execute( f""" SELECT ta.id, ta.username, ta.display_name, ta.tenant_id, ta.managed_site_ids, ta.is_active, ta.created_at, ta.last_login_at, bt.tenant_name, ta.admin_type FROM auth.tenant_admins ta LEFT JOIN biz.tenants bt ON bt.tenant_id = ta.tenant_id {where_sql} ORDER BY ta.created_at DESC LIMIT %s OFFSET %s """, params + [page_size, offset], ) rows = cur.fetchall() finally: conn.close() items = [ TenantAdminListItem( id=r[0], username=r[1], display_name=r[2], tenant_id=r[3], managed_site_ids=list(r[4]) if r[4] else [], is_active=r[5], created_at=r[6].isoformat() if r[6] else None, last_login_at=r[7].isoformat() if r[7] else None, tenant_name=r[8], admin_type=r[9], ) for r in rows ] # 返回分页格式(由 ResponseWrapperMiddleware 包装为 {code:0, data:...}) return {"items": items, "total": total, "page": page, "page_size": page_size} # ── POST /api/admin/tenant-admins ───────────────────────── @router.post("/tenant-admins", status_code=status.HTTP_201_CREATED) async def create_tenant_admin( body: TenantAdminCreateRequest, user: CurrentUser = Depends(_require_admin()), ): """ 创建租户管理员。 密码 bcrypt 哈希,username UNIQUE 冲突返回 409,记录 created_by。 创建时校验 tenant_id 在 biz.tenants 中存在且 is_active=true。 需求 14.2, 14.3, A2.6 """ password_hash = hash_password(body.password) conn = get_connection() try: with conn.cursor() as cur: # CHANGE 2026-03-23 | Prompt: 任务5.1 A2.6 | 校验 tenant_id 存在性 cur.execute( "SELECT id FROM biz.tenants WHERE tenant_id = %s AND is_active = true", (body.tenant_id,), ) if cur.fetchone() is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="租户不存在", ) # CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | 存储时统一小写 cur.execute( """ INSERT INTO auth.tenant_admins (username, password_hash, display_name, tenant_id, managed_site_ids, created_by) VALUES (LOWER(%s), %s, %s, %s, %s, %s) RETURNING id, created_at """, ( body.username, password_hash, body.display_name, body.tenant_id, body.managed_site_ids, user.user_id, ), ) row = cur.fetchone() conn.commit() except HTTPException: raise except pg_errors.UniqueViolation: conn.rollback() raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="用户名已存在", ) except Exception: conn.rollback() raise finally: conn.close() return {"id": row[0], "created_at": row[1].isoformat() if row[1] else None} # ── PATCH /api/admin/tenant-admins/{id} ─────────────────── @router.patch("/tenant-admins/{admin_id}") async def edit_tenant_admin( admin_id: int, body: TenantAdminEditRequest, user: CurrentUser = Depends(_require_admin()), ): """ 编辑租户管理员信息(username / display_name / managed_site_ids / is_active)。 管理员 ID 不存在返回 404。 修改 username 时校验全局唯一性(排除自身),冲突返回 409。 需求 14.4, 14.6, A2.8 """ # 构建动态 SET 子句 set_clauses: list[str] = [] params: list = [] # CHANGE 2026-03-23 | Prompt: 任务5.1 A2.8 | 支持修改 username # CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | 存储时统一小写 if body.username is not None: set_clauses.append("username = LOWER(%s)") params.append(body.username) if body.display_name is not None: set_clauses.append("display_name = %s") params.append(body.display_name) if body.managed_site_ids is not None: set_clauses.append("managed_site_ids = %s") params.append(body.managed_site_ids) if body.is_active is not None: set_clauses.append("is_active = %s") params.append(body.is_active) if not set_clauses: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="至少需要提供一个修改字段", ) params.append(admin_id) conn = get_connection() try: with conn.cursor() as cur: # CHANGE 2026-03-23 | Prompt: 任务5.1 A2.8 | username 唯一性校验(排除自身) # CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | 只在未删除记录中校验唯一性 # CHANGE 2026-03-23 | Prompt: 登录用户名大小写不敏感 | LOWER() 比较 + 存储小写 if body.username is not None: cur.execute( "SELECT id FROM auth.tenant_admins WHERE LOWER(username) = LOWER(%s) AND id != %s AND deleted_at IS NULL", (body.username, admin_id), ) if cur.fetchone() is not None: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="用户名已存在", ) cur.execute( f""" UPDATE auth.tenant_admins SET {', '.join(set_clauses)} WHERE id = %s AND deleted_at IS NULL RETURNING id """, params, ) row = cur.fetchone() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="租户管理员不存在", ) conn.commit() except HTTPException: raise except Exception: conn.rollback() raise finally: conn.close() return {"id": admin_id} # ── DELETE /api/admin/tenant-admins/{id} ────────────────── @router.delete("/tenant-admins/{admin_id}") async def delete_tenant_admin( admin_id: int, user: CurrentUser = Depends(_require_admin()), ): """ 软删除租户管理员(设置 deleted_at=NOW())。 无论 is_active 状态如何,均可删除。 管理员不存在或已删除返回 404;重复删除幂等返回 404。 需求 A2.3 """ # CHANGE 2026-03-22 | Prompt: 删除与禁用分离 | deleted_at 软删除,不再检查 is_active conn = get_connection() try: with conn.cursor() as cur: cur.execute( "UPDATE auth.tenant_admins SET deleted_at = NOW() " "WHERE id = %s AND deleted_at IS NULL " "RETURNING id", (admin_id,), ) row = cur.fetchone() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="管理员不存在", ) conn.commit() except HTTPException: raise except Exception: conn.rollback() raise finally: conn.close() return {"id": admin_id} # ── POST /api/admin/tenant-admins/{id}/reset-password ───── @router.post("/tenant-admins/{admin_id}/reset-password") async def reset_password( admin_id: int, body: ResetPasswordRequest, user: CurrentUser = Depends(_require_admin()), ): """ 重置租户管理员密码。 新密码 bcrypt 哈希后更新 password_hash。管理员 ID 不存在返回 404。 需求 14.5 """ new_hash = hash_password(body.new_password) conn = get_connection() try: with conn.cursor() as cur: cur.execute( """ UPDATE auth.tenant_admins SET password_hash = %s WHERE id = %s AND deleted_at IS NULL RETURNING id """, (new_hash, admin_id), ) row = cur.fetchone() if row is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="租户管理员不存在", ) conn.commit() except HTTPException: raise except Exception: conn.rollback() raise finally: conn.close() return {"id": admin_id}