# -*- 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}