包含多个会话的累积代码变更: - 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>
1155 lines
47 KiB
Python
1155 lines
47 KiB
Python
# -*- 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": "绑定更新成功"}
|