包含多个会话的累积代码变更: - 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>
355 lines
12 KiB
Python
355 lines
12 KiB
Python
# -*- 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 cause:JWT managed_site_ids 静态签发,新建店铺后所有端点受限 | Summary:create_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}
|