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

1155 lines
47 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 -*-
"""
租户管理后台 — 用户审核 + 用户管理路由。
端点清单:
- GET /api/tenant/my-sites — 管辖店铺列表
- GET /api/tenant/roles — 小程序可用角色列表(动态)
- GET /api/tenant/site-staff — 按角色+门店查询人员候选列表
- GET /api/tenant/applications — 申请列表status 筛选 + 分页)
- GET /api/tenant/applications/{id}/match-suggestions — 关联匹配建议
- POST /api/tenant/applications/{id}/approve — 审核通过
- POST /api/tenant/applications/{id}/reject — 审核拒绝
- GET /api/tenant/users — 用户列表(角色筛选 + 搜索 + 分页)
- PATCH /api/tenant/users/{id} — 编辑用户(角色+门店+绑定合并)
- DELETE /api/tenant/users/{id} — 移除用户
需求: 3.1-3.6, 4.1-4.5
AI_CHANGELOG
- 2026-03-23 17:00:00 | Prompt: P20260323-164500审核弹窗改造角色动态化+人员联动)| Direct cause角色硬编码无法适应增减人员列表无联动查询 | Summary新增 GET /rolesauth.roles 动态读取)+ GET /site-staffETL 直连查 dim_assistant/dim_staff| Verifytenant-admin 审核弹窗角色下拉动态加载 + 人员联动 + 手机号匹配
- 2026-03-24 | Prompt: 用户管理绑定功能改造 | Direct causeEditModal+BindModal 分离BindModal 手动输入 ID 体验差 | SummaryPATCH /users/{id} 合并角色+绑定提交换角色自动清除旧绑定互斥校验coach→助教其他→员工移除 PUT /binding 端点清单引用 | Verify编辑弹窗角色联动人员列表 + 解绑 + 换角色清除旧绑定
- 2026-03-23 19:30:00 | Prompt: P20260323-190000手机号不显示+自动匹配+身份标签中文化)| Direct causelist_applications 用 u.phone可能 nullidentity_label 显示数字而非中文 | SummarySQL phone 改为 ua.phone助教 identity_label 从 cfg_assistant_level_price 读中文等级名;员工用 job 字段 | VerifyPlaywright 验证手机号显示+身份标签中文+入职日期前缀
- 2026-03-23 20:00:00 | Prompt: P20260323-200000店铺筛选+时间格式+姓名格式+李小燕)| Direct causelist_my_sites 只查 managed_site_ids 不含新建店铺created_at::text 含微秒+时区;助教只查 real_name 无 nickname | Summarylist_my_sites tenant_admin 按 tenant_id 查所有店铺created_at 改 to_char助教加 nickname 组装"昵称(真实姓名)"entry_time 改 to_char | VerifyPlaywright 验证店铺下拉含 2 店+时间精确到秒+姓名格式+李小燕可搜到
- 2026-03-23 21:00:00 | Prompt: P20260323-210000根治 tenant_admin managed_site_ids 限制)| Direct causeJWT managed_site_ids 静态,新建店铺后所有端点受限 | Summary所有 verify_site_access/site_filter_clause 调用改用 admin=admin 参数,自动区分 tenant_admin查库/site_admin用 JWT回退 list_my_sites/list_applications 的头痛医头代码 | Verifytenant-admin 所有功能覆盖新建店铺
- 2026-03-24 | Prompt: 审核弹窗头像昵称+排版优化 | Direct causelist_applications SQL 未查 avatar_url | SummarySELECT 新增 u.avatar_urlApplicationListItem 构造新增 avatar_url=r[9] | VerifyGET /applications 返回 avatarUrl 字段
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from app.auth.tenant_admins import (
CurrentTenantAdmin,
get_effective_site_ids,
require_tenant_admin,
site_filter_clause,
verify_site_access,
)
from app.database import get_connection, get_etl_readonly_connection
from app.schemas.tenant_users import (
ApplicationListItem,
ApproveRequest,
MatchSuggestion,
RejectRequest,
RoleItem,
StaffCandidate,
UserBindingRequest,
UserEditRequest,
UserListItem,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tenant", tags=["租户用户管理"])
def _format_display_name(nickname: str | None, real_name: str | None) -> str:
"""组装显示姓名:昵称(真实姓名)。"""
nick = (nickname or "").strip()
real = (real_name or "").strip()
if nick and real and nick != real:
return f"{nick}{real}"
return real or nick or ""
# ── GET /api/tenant/my-sites ──────────────────────────────
@router.get("/my-sites")
async def list_my_sites(
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""返回当前管理员管辖的店铺列表(用于前端筛选下拉)。
通过 get_effective_site_ids 统一获取有效 site_ids
- tenant_admin实时查 biz.sites覆盖新建店铺
- site_admin使用 JWT 中的 managed_site_ids
"""
# [CHANGE P20260323-210000] intent: 使用根治方案 get_effective_site_ids
# 替代之前的 tenant_admin/site_admin 分支硬编码
effective_ids = get_effective_site_ids(admin)
if not effective_ids:
return []
conn = get_connection()
try:
with conn.cursor() as cur:
placeholders = ", ".join(["%s"] * len(effective_ids))
cur.execute(
f"""
SELECT site_id, site_name, site_code
FROM biz.sites
WHERE site_id IN ({placeholders}) AND is_active = true
ORDER BY site_name
""",
tuple(effective_ids),
)
rows = cur.fetchall()
finally:
conn.close()
return [
{"siteId": r[0], "siteName": r[1], "siteCode": r[2]}
for r in rows
]
# ── GET /api/tenant/roles ──────────────────────────────────
# [CHANGE P20260323-164500] intent: 角色列表从 auth.roles 动态读取,排除管理类角色
# assumptions: auth.roles 表已有 coach/staff/head_coach/manager 四条小程序角色
# verify: GET /api/tenant/roles 返回 4 个角色项
@router.get("/roles")
async def list_roles(
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""返回小程序可用角色列表(从 auth.roles 动态读取,排除管理类角色)。"""
# 排除管理类角色tenant_admin / site_admin只返回小程序端角色
excluded_codes = ("tenant_admin", "site_admin")
conn = get_connection()
try:
with conn.cursor() as cur:
placeholders = ", ".join(["%s"] * len(excluded_codes))
cur.execute(
f"""
SELECT id, code, name, description
FROM auth.roles
WHERE code NOT IN ({placeholders})
ORDER BY id
""",
excluded_codes,
)
rows = cur.fetchall()
finally:
conn.close()
return [
RoleItem(id=r[0], code=r[1], name=r[2], description=r[3]).model_dump(by_alias=True)
for r in rows
]
# ── GET /api/tenant/site-staff ────────────────────────────
# [CHANGE P20260323-164500] intent: 按角色+门店查询人员候选列表coach→dim_assistant其他→dim_staff
# assumptions: 不用 FDW 视图RLS 跨库不生效),直连 ETL 库底层表 + 手动 site_id 过滤
# edge cases: site_code 大小写不敏感匹配ETL 连接失败返回 500
# verify: 选择 coach 返回助教列表,选择 staff/head_coach/manager 返回员工列表
@router.get("/site-staff")
async def list_site_staff(
role: str = Query(..., description="角色 codecoach → 查助教表,其他 → 查员工表)"),
site_id: Optional[int] = Query(None, description="店铺 site_id上游 BIGINT"),
site_code: Optional[str] = Query(None, description="店铺编号(与 site_id 二选一)"),
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""根据角色 + site_id 查询对应人员列表。
- role=coach → 查 ETL 库 dwd.dim_assistantscd2_is_current=1
- 其他 role → 查 ETL 库 dwd.dim_staffscd2_is_current=1
site_id 和 site_code 二选一,优先 site_id。
使用 get_etl_readonly_connection 直连 ETL 库查底层表,
手动加 site_id 过滤FDW 视图的 RLS 在跨库场景下不生效)。
"""
# 如果传了 site_code 但没传 site_id先查 biz.sites 获取 site_id
if site_id is None and site_code:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT site_id FROM biz.sites
WHERE UPPER(site_code) = %s AND is_active = true
LIMIT 1
""",
(site_code.upper(),),
)
row = cur.fetchone()
if row is None:
raise HTTPException(status_code=404, detail="未找到对应店铺")
site_id = row[0]
finally:
conn.close()
if site_id is None:
raise HTTPException(status_code=400, detail="需要提供 site_id 或 site_code")
# 权限校验:校验 site_id 在管辖范围内
verify_site_access(site_id, admin=admin)
try:
etl_conn = get_etl_readonly_connection(site_id)
try:
with etl_conn.cursor() as cur:
if role == "coach":
# [CHANGE P20260323-190000] intent: 助教 identity_label 从配置表读中文等级名,
# 而非 level::text数字。员工用 job 字段代替 staff_identity 数字。
# assumptions: cfg_assistant_level_price 有 level_code→level_name 映射
# verify: 弹窗人员下拉显示如 "初级 - 张三 - 手机号 - 入职日期 YYYY-MM-DD"
# 先查等级映射配置表feiqiu-data-rules 规则 6: 禁止硬编码)
cur.execute(
"""
SELECT DISTINCT level_code, level_name
FROM dws.cfg_assistant_level_price
WHERE effective_from <= CURRENT_DATE
AND effective_to >= CURRENT_DATE
"""
)
level_map = {row[0]: row[1] for row in cur.fetchall()}
# [CHANGE P20260323-200000] intent: 加 nickname 字段,
# 姓名格式改为 "昵称(真实姓名)"
cur.execute(
"""
SELECT assistant_id, level, nickname, real_name, mobile,
to_char(entry_time, 'YYYY-MM-DD') AS entry_date
FROM dwd.dim_assistant
WHERE scd2_is_current = 1 AND site_id = %s
ORDER BY entry_time DESC NULLS LAST
""",
(site_id,),
)
rows = cur.fetchall()
result = [
StaffCandidate(
id=r[0],
identity_label=level_map.get(r[1], str(r[1]) if r[1] is not None else None),
name=_format_display_name(r[2], r[3]),
mobile=r[4],
entry_time=r[5],
source="assistant",
).model_dump(by_alias=True)
for r in rows
]
else:
cur.execute(
"""
SELECT staff_id, job, staff_name, mobile,
to_char(entry_time, 'YYYY-MM-DD') AS entry_date
FROM dwd.dim_staff
WHERE scd2_is_current = 1 AND site_id = %s
ORDER BY entry_time DESC NULLS LAST
""",
(site_id,),
)
rows = cur.fetchall()
result = [
StaffCandidate(
id=r[0],
identity_label=r[1],
name=r[2] or "",
mobile=r[3],
entry_time=r[4],
source="staff",
).model_dump(by_alias=True)
for r in rows
]
finally:
etl_conn.close()
except Exception:
logger.error("查询人员列表失败role=%s, site_id=%s", role, site_id, exc_info=True)
raise HTTPException(status_code=500, detail="查询人员列表失败")
return result
# ── GET /api/tenant/applications ──────────────────────────
@router.get("/applications")
async def list_applications(
status_filter: Optional[str] = Query(
None, alias="status", description="按状态筛选pending/approved/rejected",
),
site_id: Optional[int] = Query(
None, description="按店铺筛选site_id",
),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页条数"),
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""申请列表status/site_id 筛选 + 分页。排除 cancelled 申请。
隔离策略:
- tenant_admin看到该租户下所有店铺的申请通过 JOIN biz.sites 按 tenant_id 过滤)
- site_admin只看到 managed_site_ids 范围内的申请
"""
# [CHANGE P20260323-210000] intent: 统一使用 site_filter_clause(admin=admin)
# 替代之前的 tenant_admin/site_admin 分支硬编码
if site_id is not None:
verify_site_access(site_id, admin=admin)
offset = (page - 1) * page_size
conn = get_connection()
try:
with conn.cursor() as cur:
# 构建 WHERE 子句
if site_id is not None:
# 精确筛选单个店铺
where_parts = ["ua.site_id = %s", "ua.status <> 'cancelled'"]
params: list = [site_id]
else:
# 统一按有效 site_ids 过滤tenant_admin 查库site_admin 用 JWT
site_sql, site_params = site_filter_clause(admin=admin)
where_parts = [f"ua.{site_sql}", "ua.status <> 'cancelled'"]
params: list = list(site_params)
if status_filter:
where_parts.append("ua.status = %s")
params.append(status_filter)
where_clause = " AND ".join(where_parts)
# 查询总数
cur.execute(
f"""
SELECT COUNT(*)
FROM auth.user_applications ua
WHERE {where_clause}
""",
tuple(params),
)
total = cur.fetchone()[0]
# 查询列表
# [CHANGE P20260323-190000] intent: phone 改用 ua.phoneuser_applications 表NOT NULL
# 原 u.phoneauth.users可能为 null 导致前端显示 "-"
cur.execute(
f"""
SELECT ua.id, ua.user_id, u.nickname, ua.phone,
ua.site_code, ua.applied_role_text,
ua.employee_number,
to_char(ua.created_at, 'YYYY-MM-DD HH24:MI:SS') AS created_at,
ua.status,
u.avatar_url
FROM auth.user_applications ua
LEFT JOIN auth.users u ON u.id = ua.user_id
WHERE {where_clause}
ORDER BY ua.created_at DESC
LIMIT %s OFFSET %s
""",
(*params, page_size, offset),
)
rows = cur.fetchall()
finally:
conn.close()
items = [
ApplicationListItem(
id=r[0], user_id=r[1], nickname=r[2], phone=r[3],
site_code=r[4], applied_role_text=r[5],
employee_number=r[6], created_at=r[7], status=r[8],
avatar_url=r[9],
).model_dump(by_alias=True)
for r in rows
]
return {"items": items, "total": total, "page": page, "pageSize": page_size}
# ── GET /api/tenant/applications/{id}/match-suggestions ───
@router.get("/applications/{application_id}/match-suggestions")
async def get_match_suggestions(
application_id: int,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""通过 biz.sites 查 site_id并行匹配助教和员工。"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 获取申请记录
cur.execute(
"SELECT user_id, site_code, phone FROM auth.user_applications WHERE id = %s",
(application_id,),
)
app_row = cur.fetchone()
if app_row is None:
raise HTTPException(status_code=404, detail="申请不存在")
_user_id, site_code, phone = app_row
# 通过 biz.sites 查 site_id优先当前活跃编码再查历史编码
cur.execute(
"""
SELECT site_id FROM biz.sites
WHERE site_code = %s AND is_active = true
UNION ALL
SELECT s.site_id FROM biz.site_code_history h
JOIN biz.sites s ON s.site_id = h.site_id
WHERE h.site_code = %s AND h.is_current = false AND s.is_active = true
LIMIT 1
""",
(site_code, site_code),
)
scm_row = cur.fetchone()
finally:
conn.close()
if scm_row is None:
return []
site_id = scm_row[0]
verify_site_access(site_id, admin=admin)
if not phone:
return []
suggestions: list[dict] = []
# 匹配 v_dim_assistantFDW需要 RLS 设置)
try:
etl_conn = get_etl_readonly_connection(site_id)
try:
with etl_conn.cursor() as cur:
cur.execute(
"""
SELECT assistant_id, name, number
FROM fdw_etl.v_dim_assistant
WHERE phone = %s AND scd2_is_current = 1
""",
(phone,),
)
for row in cur.fetchall():
suggestions.append(
MatchSuggestion(
assistant_id=row[0], name=row[1],
number=row[2], source_table="v_dim_assistant",
).model_dump(by_alias=True)
)
finally:
etl_conn.close()
except Exception:
logger.warning("v_dim_assistant 匹配失败site_id=%s", site_id, exc_info=True)
# 匹配 v_dim_staff + v_dim_staff_ex
try:
etl_conn = get_etl_readonly_connection(site_id)
try:
with etl_conn.cursor() as cur:
cur.execute(
"""
SELECT s.staff_id, s.name, s.number
FROM fdw_etl.v_dim_staff s
LEFT JOIN fdw_etl.v_dim_staff_ex sx ON sx.staff_id = s.staff_id
WHERE s.phone = %s OR sx.phone = %s
""",
(phone, phone),
)
for row in cur.fetchall():
suggestions.append(
MatchSuggestion(
staff_id=row[0], name=row[1],
number=row[2], source_table="v_dim_staff",
).model_dump(by_alias=True)
)
finally:
etl_conn.close()
except Exception:
logger.warning("v_dim_staff 匹配失败site_id=%s", site_id, exc_info=True)
return suggestions
# ── POST /api/tenant/applications/{id}/approve ────────────
@router.post("/applications/{application_id}/approve")
async def approve_application(
application_id: int,
body: ApproveRequest,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""
审核通过:事务内多表写入。
1. 更新 users.status='approved'
2. 写入 user_site_roles
3. 写入 user_assistant_binding如提供 assistant_id/staff_id
4. 更新 user_applications.status='approved' + 审核人 + 审核时间
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 获取申请记录
cur.execute(
"SELECT user_id, site_code, status FROM auth.user_applications WHERE id = %s",
(application_id,),
)
app_row = cur.fetchone()
if app_row is None:
raise HTTPException(status_code=404, detail="申请不存在")
user_id, site_code, app_status = app_row
# 非 pending 状态拒绝审核
if app_status != "pending":
raise HTTPException(status_code=409, detail="该申请已被处理")
# 查 site_id优先当前活跃编码再查历史编码
# CHANGE 2026-03-23 | 大小写不敏感匹配 site_code
site_code_upper = site_code.upper()
cur.execute(
"""
SELECT site_id FROM biz.sites
WHERE UPPER(site_code) = %s AND is_active = true
UNION ALL
SELECT s.site_id FROM biz.site_code_history h
JOIN biz.sites s ON s.site_id = h.site_id
WHERE UPPER(h.site_code) = %s AND h.is_current = false AND s.is_active = true
LIMIT 1
""",
(site_code_upper, site_code_upper),
)
scm_row = cur.fetchone()
if scm_row is None:
raise HTTPException(status_code=400, detail="无效的球房编号")
site_id = scm_row[0]
verify_site_access(site_id, admin=admin)
# 1. 更新 users.status='approved'
cur.execute(
"UPDATE auth.users SET status = 'approved' WHERE id = %s",
(user_id,),
)
# 2. 写入 user_site_roles
# CHANGE 2026-03-23 | BUG: 列名是 role_idinteger FK不是 role需先查 auth.roles 把 code 转 id
# CHANGE 2026-03-23 | BUG: 前端可能传 codecoach或 name助教需同时匹配
cur.execute(
"SELECT id FROM auth.roles WHERE code = %s OR name = %s",
(body.role, body.role),
)
role_row = cur.fetchone()
if role_row is None:
raise HTTPException(status_code=400, detail=f"无效的角色: {body.role}")
role_id = role_row[0]
# CHANGE 2026-03-25 | 软删除恢复:旧记录 is_removed=true 时ON CONFLICT DO NOTHING 会跳过
# 导致 approved 用户无角色。改为 upsert冲突时恢复 is_removed=false
cur.execute(
"""
INSERT INTO auth.user_site_roles (user_id, site_id, role_id)
VALUES (%s, %s, %s)
ON CONFLICT (user_id, site_id, role_id)
DO UPDATE SET is_removed = false, removed_at = NULL
""",
(user_id, site_id, role_id),
)
# 3. 写入 user_assistant_binding如提供
# CHANGE 2026-03-23 | BUG: 表有 site_idNOT NULL和 binding_typeNOT NULL原代码漏传
if body.assistant_id is not None or body.staff_id is not None:
binding_type = "assistant" if body.assistant_id is not None else "staff"
cur.execute(
"""
INSERT INTO auth.user_assistant_binding
(user_id, site_id, assistant_id, staff_id, binding_type)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT DO NOTHING
""",
(user_id, site_id, body.assistant_id, body.staff_id, binding_type),
)
# 4. 更新 user_applications
# CHANGE 2026-03-22 | #1 BUG: reviewed_by → reviewer_idDDL 列名修正)
# intent: SQL 列名与 auth.user_applications DDL 一致
cur.execute(
"""
UPDATE auth.user_applications
SET status = 'approved',
reviewer_id = %s,
reviewed_at = NOW()
WHERE id = %s
""",
(admin.admin_id, application_id),
)
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
logger.error("审核通过失败application_id=%s", application_id, exc_info=True)
raise HTTPException(status_code=500, detail="审核操作失败")
finally:
conn.close()
return {"message": "审核通过"}
# ── POST /api/tenant/applications/{id}/reject ─────────────
@router.post("/applications/{application_id}/reject")
async def reject_application(
application_id: int,
body: RejectRequest,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""
审核拒绝:更新 user_applications 状态 + 拒绝原因 + 审核时间。
累加 rejection_count第三次自动禁用账号。
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 获取申请记录
cur.execute(
"SELECT user_id, status, site_code FROM auth.user_applications WHERE id = %s",
(application_id,),
)
app_row = cur.fetchone()
if app_row is None:
raise HTTPException(status_code=404, detail="申请不存在")
app_user_id, app_status, site_code = app_row
if app_status != "pending":
raise HTTPException(status_code=409, detail="该申请已被处理")
# 验证门店权限(优先当前活跃编码,再查历史编码)
cur.execute(
"""
SELECT site_id FROM biz.sites
WHERE site_code = %s AND is_active = true
UNION ALL
SELECT s.site_id FROM biz.site_code_history h
JOIN biz.sites s ON s.site_id = h.site_id
WHERE h.site_code = %s AND h.is_current = false AND s.is_active = true
LIMIT 1
""",
(site_code, site_code),
)
scm_row = cur.fetchone()
if scm_row:
verify_site_access(scm_row[0], admin=admin)
# CHANGE 2026-03-22 | #1 BUG: reviewed_by → reviewer_idDDL 列名修正)
cur.execute(
"""
UPDATE auth.user_applications
SET status = 'rejected',
review_note = %s,
reviewer_id = %s,
reviewed_at = NOW()
WHERE id = %s
""",
(body.reason, admin.admin_id, application_id),
)
# CHANGE 2026-03-23 | 审核流程增强:累加 rejection_count第三次自动禁用
cur.execute(
"""
UPDATE auth.users
SET rejection_count = rejection_count + 1,
updated_at = NOW()
WHERE id = %s
RETURNING rejection_count
""",
(app_user_id,),
)
new_count = cur.fetchone()[0]
if new_count >= 3:
# 第三次拒绝:自动禁用账号
cur.execute(
"UPDATE auth.users SET status = 'disabled', updated_at = NOW() WHERE id = %s",
(app_user_id,),
)
logger.warning(
"用户 %s 累计被拒绝 %d 次,已自动禁用", app_user_id, new_count,
)
else:
# 未达阈值:回退用户状态为 rejected允许重新申请
cur.execute(
"UPDATE auth.users SET status = 'rejected', updated_at = NOW() WHERE id = %s",
(app_user_id,),
)
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
logger.error("审核拒绝失败application_id=%s", application_id, exc_info=True)
raise HTTPException(status_code=500, detail="审核操作失败")
finally:
conn.close()
result = {"message": "已拒绝"}
if new_count >= 3:
result["user_disabled"] = True
result["message"] = "已拒绝,该用户累计被拒绝 3 次,账号已自动禁用"
return result
# ── GET /api/tenant/users ─────────────────────────────────
@router.get("/users")
async def list_users(
role: Optional[str] = Query(None, description="按角色筛选"),
keyword: Optional[str] = Query(None, description="关键词搜索(姓名/手机号)"),
page: int = Query(1, ge=1, description="页码"),
page_size: int = Query(20, ge=1, le=100, description="每页条数"),
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""已通过审核用户列表,角色筛选 + 关键词搜索 + 分页。"""
site_sql, site_params = site_filter_clause(admin=admin)
offset = (page - 1) * page_size
conn = get_connection()
try:
with conn.cursor() as cur:
where_parts = [f"usr.{site_sql}", "u.status IN ('approved', 'disabled')"]
params: list = list(site_params)
if role:
# CHANGE 2026-03-23 | 角色筛选同时支持 code 和 name
where_parts.append("(r.code = %s OR r.name = %s)")
params.extend([role, role])
if keyword:
where_parts.append("(u.nickname ILIKE %s OR u.phone ILIKE %s)")
like_kw = f"%{keyword}%"
params.extend([like_kw, like_kw])
where_clause = " AND ".join(where_parts)
# 总数
cur.execute(
f"""
SELECT COUNT(DISTINCT u.id)
FROM auth.users u
JOIN auth.user_site_roles usr ON usr.user_id = u.id
AND usr.is_removed = false
JOIN auth.roles r ON r.id = usr.role_id
WHERE {where_clause}
""",
tuple(params),
)
total = cur.fetchone()[0]
# 列表
cur.execute(
f"""
SELECT DISTINCT ON (u.id)
u.id, u.nickname, r.name AS role, r.code AS role_code,
uab.assistant_id, uab.staff_id, usr.site_id, u.status
FROM auth.users u
JOIN auth.user_site_roles usr ON usr.user_id = u.id
AND usr.is_removed = false
JOIN auth.roles r ON r.id = usr.role_id
LEFT JOIN auth.user_assistant_binding uab ON uab.user_id = u.id
AND uab.is_removed = false
WHERE {where_clause}
ORDER BY u.id DESC
LIMIT %s OFFSET %s
""",
(*params, page_size, offset),
)
rows = cur.fetchall()
# 批量查 site_name
site_ids = list({r[6] for r in rows if r[6]})
site_name_map: dict[int, str] = {}
if site_ids:
placeholders = ", ".join(["%s"] * len(site_ids))
cur.execute(
f"SELECT site_id, site_name FROM biz.sites WHERE site_id IN ({placeholders})",
tuple(site_ids),
)
for sid, sname in cur.fetchall():
site_name_map[sid] = sname
finally:
conn.close()
items = [
UserListItem(
id=r[0], nickname=r[1], role=r[2], role_code=r[3],
assistant_id=r[4], staff_id=r[5],
assistant_name=None, # 助教姓名需要 FDW 查询,此处简化
site_name=site_name_map.get(r[6]),
site_id=r[6], status=r[7],
).model_dump(by_alias=True)
for r in rows
]
return {"items": items, "total": total, "page": page, "pageSize": page_size}
# ── PATCH /api/tenant/users/{id} ──────────────────────────
@router.patch("/users/{user_id}")
async def edit_user(
user_id: int,
body: UserEditRequest,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""编辑用户角色/门店 + 绑定(合并接口)。
- 角色与绑定互斥coach 只能绑 assistant_id其他角色只能绑 staff_id
- 换角色时自动清除旧绑定
- site_id 超出管辖范围返回 403
"""
# [CHANGE P20260324] intent: 合并 edit + binding 为单一接口,换角色自动清除旧绑定
# assumptions: 前端传 role codecoach/staff/head_coach/managerstaffBinding="none" 时 assistant_id 和 staff_id 均为 None
# verify: 编辑弹窗提交后角色+绑定同时更新,换角色旧绑定被清除
# 验证新 site_id 在管辖范围内
if body.site_id is not None:
verify_site_access(body.site_id, admin=admin)
# 校验互斥coach 只能绑 assistant_id非 coach 只能绑 staff_id
if body.role == "coach" and body.staff_id is not None:
raise HTTPException(status_code=400, detail="助教角色只能关联助教,不能关联员工")
if body.role is not None and body.role != "coach" and body.assistant_id is not None:
raise HTTPException(status_code=400, detail="非助教角色只能关联员工,不能关联助教")
conn = get_connection()
try:
with conn.cursor() as cur:
# 验证用户存在且在管辖范围内
site_sql, site_params = site_filter_clause(admin=admin)
cur.execute(
f"""
SELECT u.id, u.status
FROM auth.users u
JOIN auth.user_site_roles usr ON usr.user_id = u.id
WHERE u.id = %s AND usr.{site_sql}
AND usr.is_removed = false
LIMIT 1
""",
(user_id, *site_params),
)
user_row = cur.fetchone()
if user_row is None:
raise HTTPException(status_code=404, detail="用户不存在")
# 更新角色
role_changed = False
if body.role is not None:
cur.execute(
"SELECT id FROM auth.roles WHERE code = %s OR name = %s",
(body.role, body.role),
)
role_row = cur.fetchone()
if role_row is None:
raise HTTPException(status_code=400, detail=f"无效的角色: {body.role}")
new_role_id = role_row[0]
# 检查角色是否真的变了
cur.execute(
"SELECT role_id FROM auth.user_site_roles WHERE user_id = %s AND is_removed = false LIMIT 1",
(user_id,),
)
old_role_row = cur.fetchone()
if old_role_row and old_role_row[0] != new_role_id:
role_changed = True
cur.execute(
"""
UPDATE auth.user_site_roles SET role_id = %s
WHERE user_id = %s AND is_removed = false AND site_id IN (
SELECT site_id FROM auth.user_site_roles WHERE user_id = %s AND is_removed = false LIMIT 1
)
""",
(new_role_id, user_id, user_id),
)
# 更新门店
if body.site_id is not None:
cur.execute(
"""
UPDATE auth.user_site_roles SET site_id = %s
WHERE user_id = %s AND is_removed = false
""",
(body.site_id, user_id),
)
# 更新绑定:只要前端提交了角色字段,就同步更新绑定(含解绑场景)
# [CHANGE P20260324] fix: 适配 user_assistant_binding 实际表结构
# 表有 site_id(NOT NULL), binding_type(NOT NULL), is_removed 列
# 唯一索引是 (user_id, site_id) WHERE is_removed = false
if body.role is not None:
new_assistant_id = body.assistant_id
new_staff_id = body.staff_id
# 获取用户的 site_id
cur.execute(
"""
SELECT site_id FROM auth.user_site_roles
WHERE user_id = %s AND is_removed = false
ORDER BY site_id LIMIT 1
""",
(user_id,),
)
site_row = cur.fetchone()
bind_site_id = site_row[0] if site_row else None
if bind_site_id is not None:
# 换角色时先软删除旧绑定
if role_changed:
cur.execute(
"""
UPDATE auth.user_assistant_binding
SET is_removed = true
WHERE user_id = %s AND site_id = %s AND is_removed = false
""",
(user_id, bind_site_id),
)
# 有绑定目标时写入新绑定
if new_assistant_id is not None or new_staff_id is not None:
binding_type = "assistant" if new_assistant_id is not None else "staff"
# 尝试更新现有活跃记录
cur.execute(
"""
UPDATE auth.user_assistant_binding
SET assistant_id = %s, staff_id = %s, binding_type = %s
WHERE id = (
SELECT id FROM auth.user_assistant_binding
WHERE user_id = %s AND site_id = %s AND is_removed = false
ORDER BY id DESC LIMIT 1
)
""",
(new_assistant_id, new_staff_id, binding_type, user_id, bind_site_id),
)
if cur.rowcount == 0:
# 无活跃记录,插入新行
cur.execute(
"""
INSERT INTO auth.user_assistant_binding
(user_id, site_id, assistant_id, staff_id, binding_type)
VALUES (%s, %s, %s, %s, %s)
""",
(user_id, bind_site_id, new_assistant_id, new_staff_id, binding_type),
)
elif not role_changed:
# 解绑场景(选了"无"但角色没变):软删除现有绑定
cur.execute(
"""
UPDATE auth.user_assistant_binding
SET is_removed = true
WHERE user_id = %s AND site_id = %s AND is_removed = false
""",
(user_id, bind_site_id),
)
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
logger.error("编辑用户失败user_id=%s", user_id, exc_info=True)
raise HTTPException(status_code=500, detail="编辑操作失败")
finally:
conn.close()
return {"message": "更新成功"}
# ── DELETE /api/tenant/users/{id} ─────────────────────────
@router.delete("/users/{user_id}")
async def remove_user(
user_id: int,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""从当前租户管辖的店铺中移除用户(软删除)。
CHANGE 2026-03-23 | 租户"移除"替代"禁用"
CHANGE 2026-03-24 | 物理删除改软删除:标记 is_removed + removed_at同步标记 user_assistant_binding
- 标记 auth.user_site_roles 中该用户在管辖 site 下的记录为已移除
- 标记 auth.user_assistant_binding 中对应记录为已移除
- 若用户不再有任何活跃 site 绑定,将 auth.users.status 改为 'new'(可重新申请)
- 不影响用户的微信身份(第一层),只解除店铺关系(第二层)
"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 验证用户存在且在管辖范围内(只查未移除的)
site_sql, site_params = site_filter_clause(admin=admin)
cur.execute(
f"""
SELECT usr.site_id
FROM auth.user_site_roles usr
WHERE usr.user_id = %s AND usr.{site_sql}
AND usr.is_removed = false
""",
(user_id, *site_params),
)
rows = cur.fetchall()
if not rows:
raise HTTPException(status_code=404, detail="用户不存在或不在管辖范围内")
managed_site_ids = [r[0] for r in rows]
# CHANGE 2026-03-24 | 软删除:标记 is_removed 而非物理删除
placeholders = ", ".join(["%s"] * len(managed_site_ids))
cur.execute(
f"""
UPDATE auth.user_site_roles
SET is_removed = true, removed_at = now()
WHERE user_id = %s AND site_id IN ({placeholders})
AND is_removed = false
""",
(user_id, *managed_site_ids),
)
# 同步软删除 user_assistant_binding旧代码漏删此处补全
cur.execute(
f"""
UPDATE auth.user_assistant_binding
SET is_removed = true, removed_at = now()
WHERE user_id = %s AND site_id IN ({placeholders})
AND is_removed = false
""",
(user_id, *managed_site_ids),
)
# 检查用户是否还有其他活跃 site 绑定
cur.execute(
"SELECT COUNT(*) FROM auth.user_site_roles WHERE user_id = %s AND is_removed = false",
(user_id,),
)
remaining = cur.fetchone()[0]
if remaining == 0:
# 无任何店铺绑定,重置为 new 状态(可重新申请)
cur.execute(
"UPDATE auth.users SET status = 'new', updated_at = now() WHERE id = %s",
(user_id,),
)
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
logger.error("移除用户失败user_id=%s", user_id, exc_info=True)
raise HTTPException(status_code=500, detail="移除操作失败")
finally:
conn.close()
return {"message": "用户已从店铺移除"}
# ── PUT /api/tenant/users/{id}/binding ────────────────────
@router.put("/users/{user_id}/binding")
async def update_binding(
user_id: int,
body: UserBindingRequest,
admin: CurrentTenantAdmin = Depends(require_tenant_admin),
):
"""更新 user_assistant_binding。"""
conn = get_connection()
try:
with conn.cursor() as cur:
# 验证用户在管辖范围内
site_sql, site_params = site_filter_clause(admin=admin)
cur.execute(
f"""
SELECT 1 FROM auth.user_site_roles
WHERE user_id = %s AND {site_sql}
AND is_removed = false
LIMIT 1
""",
(user_id, *site_params),
)
if cur.fetchone() is None:
raise HTTPException(status_code=404, detail="用户不存在")
# CHANGE 2026-03-24 | 修复:旧代码 ON CONFLICT (user_id) 无效(表无唯一约束),
# 导致每次"更新"实际插入新行,旧绑定残留。改为先更新最新记录,无记录则插入。
# 同时从 user_site_roles 获取 site_id补全 binding_type。
cur.execute(
"""
SELECT usr.site_id
FROM auth.user_site_roles usr
WHERE usr.user_id = %s
AND usr.is_removed = false
ORDER BY usr.site_id
LIMIT 1
""",
(user_id,),
)
site_row = cur.fetchone()
if site_row is None:
raise HTTPException(status_code=400, detail="用户无门店绑定")
bind_site_id = site_row[0]
# 确定 binding_type
binding_type = "assistant" if body.assistant_id is not None else "staff"
# 尝试更新最新一条绑定记录
cur.execute(
"""
UPDATE auth.user_assistant_binding
SET assistant_id = %s,
staff_id = %s,
binding_type = %s
WHERE id = (
SELECT id FROM auth.user_assistant_binding
WHERE user_id = %s AND site_id = %s
AND is_removed = false
ORDER BY id DESC
LIMIT 1
)
""",
(body.assistant_id, body.staff_id, binding_type, user_id, bind_site_id),
)
if cur.rowcount == 0:
# 无现有记录,插入新行
cur.execute(
"""
INSERT INTO auth.user_assistant_binding
(user_id, site_id, assistant_id, staff_id, binding_type)
VALUES (%s, %s, %s, %s, %s)
""",
(user_id, bind_site_id, body.assistant_id, body.staff_id, binding_type),
)
conn.commit()
except HTTPException:
conn.rollback()
raise
except Exception:
conn.rollback()
logger.error("更新绑定失败user_id=%s", user_id, exc_info=True)
raise HTTPException(status_code=500, detail="绑定更新失败")
finally:
conn.close()
return {"message": "绑定更新成功"}