# -*- 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 /roles(auth.roles 动态读取)+ GET /site-staff(ETL 直连查 dim_assistant/dim_staff)| Verify:tenant-admin 审核弹窗角色下拉动态加载 + 人员联动 + 手机号匹配 - 2026-03-24 | Prompt: 用户管理绑定功能改造 | Direct cause:EditModal+BindModal 分离,BindModal 手动输入 ID 体验差 | Summary:PATCH /users/{id} 合并角色+绑定提交,换角色自动清除旧绑定,互斥校验(coach→助教,其他→员工);移除 PUT /binding 端点清单引用 | Verify:编辑弹窗角色联动人员列表 + 解绑 + 换角色清除旧绑定 - 2026-03-23 19:30:00 | Prompt: P20260323-190000(手机号不显示+自动匹配+身份标签中文化)| Direct cause:list_applications 用 u.phone(可能 null);identity_label 显示数字而非中文 | Summary:SQL phone 改为 ua.phone;助教 identity_label 从 cfg_assistant_level_price 读中文等级名;员工用 job 字段 | Verify:Playwright 验证手机号显示+身份标签中文+入职日期前缀 - 2026-03-23 20:00:00 | Prompt: P20260323-200000(店铺筛选+时间格式+姓名格式+李小燕)| Direct cause:list_my_sites 只查 managed_site_ids 不含新建店铺;created_at::text 含微秒+时区;助教只查 real_name 无 nickname | Summary:list_my_sites tenant_admin 按 tenant_id 查所有店铺;created_at 改 to_char;助教加 nickname 组装"昵称(真实姓名)";entry_time 改 to_char | Verify:Playwright 验证店铺下拉含 2 店+时间精确到秒+姓名格式+李小燕可搜到 - 2026-03-23 21:00:00 | Prompt: P20260323-210000(根治 tenant_admin managed_site_ids 限制)| Direct cause:JWT managed_site_ids 静态,新建店铺后所有端点受限 | Summary:所有 verify_site_access/site_filter_clause 调用改用 admin=admin 参数,自动区分 tenant_admin(查库)/site_admin(用 JWT);回退 list_my_sites/list_applications 的头痛医头代码 | Verify:tenant-admin 所有功能覆盖新建店铺 - 2026-03-24 | Prompt: 审核弹窗头像昵称+排版优化 | Direct cause:list_applications SQL 未查 avatar_url | Summary:SELECT 新增 u.avatar_url,ApplicationListItem 构造新增 avatar_url=r[9] | Verify:GET /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="角色 code(coach → 查助教表,其他 → 查员工表)"), 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_assistant(scd2_is_current=1) - 其他 role → 查 ETL 库 dwd.dim_staff(scd2_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.phone(user_applications 表,NOT NULL), # 原 u.phone(auth.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_assistant(FDW,需要 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_id(integer FK),不是 role;需先查 auth.roles 把 code 转 id # CHANGE 2026-03-23 | BUG: 前端可能传 code(coach)或 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_id(NOT NULL)和 binding_type(NOT 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_id(DDL 列名修正) # 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_id(DDL 列名修正) 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 code(coach/staff/head_coach/manager);staffBinding="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": "绑定更新成功"}