Files
Neo-ZQYY/apps/backend/app/routers/tenant_site_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

355 lines
12 KiB
Python
Raw 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 路由。
仅 admin_type='tenant_admin' 的管理员可调用。
店铺管理员复用 auth.tenant_admins 表admin_type='site_admin'
端点清单:
- GET /api/tenant/site-admins — 店铺管理员列表
- POST /api/tenant/site-admins — 创建店铺管理员
- PATCH /api/tenant/site-admins/{id} — 编辑店铺管理员
- DELETE /api/tenant/site-admins/{id} — 软删除店铺管理员
- POST /api/tenant/site-admins/{id}/reset-password — 重置密码
AI_CHANGELOG
- 2026-03-23 21:00:00 | Prompt: P20260323-210000根治 tenant_admin managed_site_ids 限制)| Direct causeJWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summarycreate_site_admin 和 edit_site_admin 的权限子集校验改用 get_effective_site_ids(admin)(覆盖新建店铺)| Verify创建/编辑店铺管理员时可选新建店铺
"""
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 pydantic import Field
from app.auth.jwt import hash_password
from app.auth.tenant_admins import CurrentTenantAdmin, get_effective_site_ids, require_tenant_admin
from app.database import get_connection
from app.schemas.base import CamelModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tenant", tags=["租户店铺管理员"])
# ── 权限守卫:仅 tenant_admin 可调用 ─────────────────────
def _require_tenant_admin_type(admin: CurrentTenantAdmin) -> CurrentTenantAdmin:
"""校验当前登录者为租户管理员(非店铺管理员)。"""
if admin.admin_type != "tenant_admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="仅租户管理员可执行此操作",
)
return admin
# ── Schema ────────────────────────────────────────────────
class SiteAdminCreateRequest(CamelModel):
"""创建店铺管理员请求。"""
username: str = Field(..., max_length=56, description="用户名site_code 前缀 + 最长 50 字符)")
password: str = Field(..., min_length=6, description="初始密码")
display_name: str | None = Field(None, max_length=100, description="显示名称")
managed_site_ids: list[int] = Field(..., min_length=1, description="管辖门店 ID 列表")
class SiteAdminEditRequest(CamelModel):
"""编辑店铺管理员请求。"""
display_name: str | None = Field(None, max_length=100)
managed_site_ids: list[int] | None = Field(None, min_length=1)
is_active: bool | None = None
class SiteAdminResetPasswordRequest(CamelModel):
"""重置密码请求。"""
new_password: str = Field(..., min_length=6)
# ── GET /api/tenant/site-admins ───────────────────────────
@router.get("/site-admins")
async def list_site_admins(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
keyword: Optional[str] = Query(None, description="搜索用户名/显示名称"),
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""列出当前租户下的店铺管理员。"""
_require_tenant_admin_type(admin)
offset = (page - 1) * page_size
conn = get_connection()
try:
with conn.cursor() as cur:
where_parts = [
"ta.tenant_id = %s",
"ta.admin_type = 'site_admin'",
"ta.deleted_at IS NULL",
]
params: list = [admin.tenant_id]
if keyword:
where_parts.append("(ta.username ILIKE %s OR ta.display_name ILIKE %s)")
like_val = f"%{keyword}%"
params.extend([like_val, like_val])
where_sql = " AND ".join(where_parts)
cur.execute(f"SELECT COUNT(*) FROM auth.tenant_admins ta WHERE {where_sql}", params)
total = cur.fetchone()[0]
cur.execute(
f"""
SELECT ta.id, ta.username, ta.display_name, ta.managed_site_ids,
ta.is_active, ta.created_at, ta.last_login_at
FROM auth.tenant_admins ta
WHERE {where_sql}
ORDER BY ta.created_at DESC
LIMIT %s OFFSET %s
""",
params + [page_size, offset],
)
rows = cur.fetchall()
finally:
conn.close()
items = [
{
"id": r[0], "username": r[1], "displayName": r[2],
"managedSiteIds": list(r[3]) if r[3] else [],
"isActive": r[4],
"createdAt": r[5].isoformat() if r[5] else None,
"lastLoginAt": r[6].isoformat() if r[6] else None,
}
for r in rows
]
return {"items": items, "total": total, "page": page, "pageSize": page_size}
# ── POST /api/tenant/site-admins ──────────────────────────
@router.post("/site-admins", status_code=status.HTTP_201_CREATED)
async def create_site_admin(
body: SiteAdminCreateRequest,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""
创建店铺管理员。
用户名校验:必须以管辖店铺的 site_code 开头。
managed_site_ids 必须是当前租户管理员管辖范围的子集。
"""
_require_tenant_admin_type(admin)
# 校验 managed_site_ids 是当前管理员有效管辖范围的子集
# [CHANGE P20260323-210000] intent: 使用 get_effective_site_ids 替代 JWT managed_site_ids
# tenant_admin 按 tenant_id 查库获取有效范围(覆盖新建店铺)
effective_ids = get_effective_site_ids(admin)
if not set(body.managed_site_ids).issubset(set(effective_ids)):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="所选门店超出您的管辖范围",
)
# 校验用户名以 site_code 开头
conn = get_connection()
try:
with conn.cursor() as cur:
# 查询第一个管辖店铺的 site_code
cur.execute(
"SELECT site_code FROM biz.sites WHERE site_id = %s AND is_active = true",
(body.managed_site_ids[0],),
)
site_row = cur.fetchone()
if site_row is None or site_row[0] is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="第一个管辖店铺未设置简写ID",
)
expected_prefix = site_row[0].upper()
if not body.username.upper().startswith(expected_prefix):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"用户名必须以简写ID '{expected_prefix}' 开头",
)
# 插入记录
password_hash = hash_password(body.password)
cur.execute(
"""
INSERT INTO auth.tenant_admins
(username, password_hash, display_name, tenant_id,
managed_site_ids, admin_type, created_by)
VALUES (LOWER(%s), %s, %s, %s, %s, 'site_admin', %s)
RETURNING id, created_at
""",
(
body.username,
password_hash,
body.display_name,
admin.tenant_id,
body.managed_site_ids,
admin.admin_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()
logger.error("创建店铺管理员失败", exc_info=True)
raise HTTPException(status_code=500, detail="创建失败")
finally:
conn.close()
return {"id": row[0], "createdAt": row[1].isoformat() if row[1] else None}
# ── PATCH /api/tenant/site-admins/{id} ────────────────────
@router.patch("/site-admins/{admin_id}")
async def edit_site_admin(
admin_id: int,
body: SiteAdminEditRequest,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""编辑店铺管理员(显示名称/管辖门店/启用状态)。"""
_require_tenant_admin_type(admin)
if body.managed_site_ids is not None:
effective_ids = get_effective_site_ids(admin)
if not set(body.managed_site_ids).issubset(set(effective_ids)):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="所选门店超出您的管辖范围")
set_clauses: list[str] = []
params: list = []
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=422, detail="至少需要提供一个修改字段")
params.extend([admin_id, admin.tenant_id])
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
f"""
UPDATE auth.tenant_admins
SET {', '.join(set_clauses)}
WHERE id = %s AND tenant_id = %s
AND admin_type = 'site_admin' AND deleted_at IS NULL
RETURNING id
""",
params,
)
if cur.fetchone() is None:
raise HTTPException(status_code=404, detail="店铺管理员不存在")
conn.commit()
except HTTPException:
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()
return {"id": admin_id}
# ── DELETE /api/tenant/site-admins/{id} ───────────────────
@router.delete("/site-admins/{admin_id}")
async def delete_site_admin(
admin_id: int,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""软删除店铺管理员。"""
_require_tenant_admin_type(admin)
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE auth.tenant_admins SET deleted_at = NOW()
WHERE id = %s AND tenant_id = %s
AND admin_type = 'site_admin' AND deleted_at IS NULL
RETURNING id
""",
(admin_id, admin.tenant_id),
)
if cur.fetchone() is None:
raise HTTPException(status_code=404, detail="店铺管理员不存在")
conn.commit()
except HTTPException:
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()
return {"id": admin_id}
# ── POST /api/tenant/site-admins/{id}/reset-password ──────
@router.post("/site-admins/{admin_id}/reset-password")
async def reset_site_admin_password(
admin_id: int,
body: SiteAdminResetPasswordRequest,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""重置店铺管理员密码。"""
_require_tenant_admin_type(admin)
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 tenant_id = %s
AND admin_type = 'site_admin' AND deleted_at IS NULL
RETURNING id
""",
(new_hash, admin_id, admin.tenant_id),
)
if cur.fetchone() is None:
raise HTTPException(status_code=404, detail="店铺管理员不存在")
conn.commit()
except HTTPException:
raise
except Exception:
conn.rollback()
raise
finally:
conn.close()
return {"id": admin_id}