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>
This commit is contained in:
405
apps/backend/app/routers/admin_tenant_admins.py
Normal file
405
apps/backend/app/routers/admin_tenant_admins.py
Normal file
@@ -0,0 +1,405 @@
|
||||
# -*- 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}
|
||||
Reference in New Issue
Block a user