Files
Neo-ZQYY/apps/backend/app/routers/admin_tenant_admins.py
Neo 6f8f12314f feat: 累积功能变更 — 聊天集成、租户管理、小程序更新、ETL 增强、迁移脚本
包含多个会话的累积代码变更:
- backend: AI 聊天服务、触发器调度、认证增强、WebSocket、调度器最小间隔
- admin-web: ETL 状态页、任务管理、调度配置、登录优化
- miniprogram: 看板页面、聊天集成、UI 组件、导航更新
- etl: DWS 新任务(finance_area_daily/board_cache)、连接器增强
- tenant-admin: 项目初始化
- db: 19 个迁移脚本(etl_feiqiu 11 + zqyy_app 8)
- packages/shared: 枚举和工具函数更新
- tools: 数据库工具、报表生成、健康检查
- docs: PRD/架构/部署/合约文档更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-06 00:03:48 +08:00

406 lines
13 KiB
Python
Raw Permalink 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.
# -*- 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}