包含多个会话的累积代码变更: - 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>
912 lines
35 KiB
Python
912 lines
35 KiB
Python
# AI_CHANGELOG
|
||
# | 日期 | Prompt | 变更 |
|
||
# |------|--------|------|
|
||
# | 2026-03-23 | P20260323-190012 禁用→移除+鉴权两层模型 | login/refresh 移除 disabled 403 拦截;disabled 签发受限令牌由前端路由;cancel-application 接口;角色列表更新 |
|
||
# | 2026-03-23 | 角色路由+页面权限守卫 | /api/xcx/me、/api/xcx/login、/api/xcx/dev-login 返回用户角色 |
|
||
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
小程序认证路由 —— 微信登录、申请提交、状态查询、店铺切换、令牌刷新。
|
||
|
||
端点清单:
|
||
- POST /api/xcx/login — 微信登录(查找/创建用户 + 签发 JWT)
|
||
- POST /api/xcx/apply — 提交入驻申请
|
||
- GET /api/xcx/me — 查询自身状态 + 申请列表
|
||
- GET /api/xcx/me/sites — 查询关联店铺
|
||
- POST /api/xcx/switch-site — 切换当前店铺
|
||
- POST /api/xcx/refresh — 刷新令牌
|
||
- POST /api/xcx/dev-login — 开发模式 mock 登录(仅 WX_DEV_MODE=true)
|
||
- POST /api/xcx/dev-switch-role — 切换角色(仅 WX_DEV_MODE=true)
|
||
- POST /api/xcx/dev-switch-status — 切换用户状态(仅 WX_DEV_MODE=true)
|
||
- POST /api/xcx/dev-switch-binding — 切换人员绑定(仅 WX_DEV_MODE=true)
|
||
- GET /api/xcx/dev-context — 查询调试上下文(仅 WX_DEV_MODE=true)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from jose import JWTError
|
||
from psycopg2 import errors as pg_errors
|
||
|
||
from app.auth.dependencies import (
|
||
CurrentUser,
|
||
get_current_user,
|
||
get_current_user_or_limited,
|
||
)
|
||
from app.auth.jwt import (
|
||
create_limited_token_pair,
|
||
create_token_pair,
|
||
decode_refresh_token,
|
||
)
|
||
from app import config
|
||
from app.database import get_connection
|
||
from app.services.application import (
|
||
cancel_application,
|
||
create_application,
|
||
get_user_applications,
|
||
)
|
||
from app.schemas.xcx_auth import (
|
||
ApplicationRequest,
|
||
ApplicationResponse,
|
||
CancelApplicationResponse,
|
||
DevLoginRequest,
|
||
DevSwitchBindingRequest,
|
||
DevSwitchRoleRequest,
|
||
DevSwitchStatusRequest,
|
||
DevContextResponse,
|
||
LatestApplicationDetail,
|
||
RefreshTokenRequest,
|
||
SiteInfo,
|
||
SwitchSiteRequest,
|
||
UserStatusResponse,
|
||
WxLoginRequest,
|
||
WxLoginResponse,
|
||
)
|
||
from app.services.wechat import WeChatAuthError, code2session
|
||
from app.services.role import get_user_permissions
|
||
from app.trace.decorators import trace_service
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter(prefix="/api/xcx", tags=["小程序认证"])
|
||
|
||
|
||
# ── 辅助:查询用户在指定 site_id 下的角色 code 列表 ──────────
|
||
|
||
def _get_user_roles_at_site(conn, user_id: int, site_id: int) -> list[str]:
|
||
"""查询用户在指定 site_id 下的角色 code 列表。"""
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT r.code
|
||
FROM auth.user_site_roles usr
|
||
JOIN auth.roles r ON usr.role_id = r.id
|
||
WHERE usr.user_id = %s AND usr.site_id = %s
|
||
AND usr.is_removed = false
|
||
""",
|
||
(user_id, site_id),
|
||
)
|
||
return [row[0] for row in cur.fetchall()]
|
||
|
||
|
||
def _get_user_default_site(conn, user_id: int) -> int | None:
|
||
"""获取用户第一个关联的 site_id(按创建时间排序)。"""
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT DISTINCT site_id
|
||
FROM auth.user_site_roles
|
||
WHERE user_id = %s
|
||
AND is_removed = false
|
||
ORDER BY site_id
|
||
LIMIT 1
|
||
""",
|
||
(user_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
return row[0] if row else None
|
||
|
||
|
||
# ── POST /api/xcx/login ──────────────────────────────────
|
||
|
||
@router.post("/login", response_model=WxLoginResponse)
|
||
@trace_service("微信登录", "WeChat login")
|
||
async def wx_login(body: WxLoginRequest):
|
||
"""
|
||
微信登录。
|
||
|
||
流程:code → code2session(openid) → 查找/创建 auth.users → 签发 JWT。
|
||
- disabled 用户签发受限令牌,由前端状态路由处理
|
||
- 新用户自动创建(status=new),前端引导至申请页
|
||
- approved 用户签发包含 site_id + roles 的完整令牌
|
||
- new/pending/rejected 用户签发受限令牌
|
||
"""
|
||
# 1. 调用微信 code2Session
|
||
try:
|
||
wx_result = await code2session(body.code)
|
||
except WeChatAuthError as exc:
|
||
raise HTTPException(status_code=exc.http_status, detail=exc.detail)
|
||
except RuntimeError as exc:
|
||
# 微信配置缺失
|
||
logger.error("微信配置错误: %s", exc)
|
||
raise HTTPException(
|
||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="服务器配置错误",
|
||
)
|
||
|
||
openid = wx_result["openid"]
|
||
unionid = wx_result.get("unionid")
|
||
|
||
# 2. 查找/创建用户
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT id, status FROM auth.users WHERE wx_openid = %s",
|
||
(openid,),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if row is None:
|
||
# 新用户:创建 new 记录(尚未提交申请)
|
||
try:
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO auth.users (wx_openid, wx_union_id, status)
|
||
VALUES (%s, %s, 'new')
|
||
RETURNING id, status
|
||
""",
|
||
(openid, unionid),
|
||
)
|
||
row = cur.fetchone()
|
||
conn.commit()
|
||
except pg_errors.UniqueViolation:
|
||
# 并发创建:回滚后查询已有记录
|
||
conn.rollback()
|
||
cur.execute(
|
||
"SELECT id, status FROM auth.users WHERE wx_openid = %s",
|
||
(openid,),
|
||
)
|
||
row = cur.fetchone()
|
||
else:
|
||
# CHANGE 2026-03-22 | #8: 已有用户登录时更新 wx_union_id(幂等保护)
|
||
# intent: unionid 可能在首次登录时为空(未绑定开放平台),后续登录补全
|
||
if unionid:
|
||
cur.execute(
|
||
"""
|
||
UPDATE auth.users
|
||
SET wx_union_id = %s
|
||
WHERE id = %s
|
||
AND (wx_union_id IS NULL OR wx_union_id <> %s)
|
||
""",
|
||
(unionid, row[0], unionid),
|
||
)
|
||
if cur.rowcount > 0:
|
||
conn.commit()
|
||
|
||
user_id, user_status = row
|
||
|
||
# CHANGE 2026-03-23 | disabled 不再拒绝登录
|
||
# 第一层(微信身份)始终有效,disabled 只影响第二层(业务状态路由)
|
||
# disabled/new/pending/rejected 统一签发受限令牌,由前端状态路由处理
|
||
|
||
# 4. 签发令牌
|
||
# CHANGE 2026-03-23 | 角色路由:登录时查询角色并返回
|
||
login_role: str | None = None
|
||
if user_status == "approved":
|
||
# 查找默认 site_id 和角色
|
||
default_site_id = _get_user_default_site(conn, user_id)
|
||
if default_site_id is not None:
|
||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
||
login_role = roles[0] if roles else None
|
||
else:
|
||
# approved 但无 site 绑定(异常边界),签发受限令牌
|
||
tokens = create_limited_token_pair(user_id)
|
||
else:
|
||
# new / pending / rejected → 受限令牌
|
||
tokens = create_limited_token_pair(user_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
return WxLoginResponse(
|
||
access_token=tokens["access_token"],
|
||
refresh_token=tokens["refresh_token"],
|
||
token_type=tokens["token_type"],
|
||
user_status=user_status,
|
||
user_id=user_id,
|
||
role=login_role,
|
||
)
|
||
|
||
|
||
# ── POST /api/xcx/apply ──────────────────────────────────
|
||
|
||
@router.post("/apply", response_model=ApplicationResponse)
|
||
@trace_service("提交入驻申请", "Submit application")
|
||
async def submit_application(
|
||
body: ApplicationRequest,
|
||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||
):
|
||
"""
|
||
提交入驻申请。
|
||
|
||
委托 application service 处理:
|
||
检查重复 pending → site_code 映射 → 创建记录 → 更新 nickname。
|
||
"""
|
||
result = await create_application(
|
||
user_id=user.user_id,
|
||
site_code=body.site_code,
|
||
applied_role_text=body.applied_role_text,
|
||
phone=body.phone,
|
||
employee_number=body.employee_number,
|
||
nickname=body.nickname,
|
||
)
|
||
return ApplicationResponse(**result)
|
||
|
||
|
||
# ── POST /api/xcx/cancel-application ─────────────────────
|
||
|
||
@router.post("/cancel-application", response_model=CancelApplicationResponse)
|
||
@trace_service("取消申请", "Cancel application")
|
||
async def cancel_my_application(
|
||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||
):
|
||
"""
|
||
用户主动取消当前 pending 申请。
|
||
|
||
将申请 status 改为 cancelled,用户 status 回退 new。
|
||
返回被取消申请的信息(用于前端预填重新申请表单)。
|
||
"""
|
||
result = await cancel_application(user_id=user.user_id)
|
||
return CancelApplicationResponse(**result)
|
||
|
||
|
||
# ── GET /api/xcx/me ───────────────────────────────────────
|
||
|
||
@router.get("/me", response_model=UserStatusResponse)
|
||
@trace_service("查询自身状态", "Get my status")
|
||
async def get_my_status(
|
||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||
):
|
||
"""
|
||
查询自身状态 + 所有申请记录。
|
||
|
||
pending / approved / rejected 用户均可访问。
|
||
"""
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 查询用户基本信息
|
||
# CHANGE 2026-03-24 | 头像:新增 avatar_url 字段查询
|
||
cur.execute(
|
||
"SELECT id, status, nickname, avatar_url FROM auth.users WHERE id = %s",
|
||
(user.user_id,),
|
||
)
|
||
user_row = cur.fetchone()
|
||
if user_row is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="用户不存在",
|
||
)
|
||
|
||
user_id, user_status, nickname, avatar_url = user_row
|
||
|
||
# CHANGE 2026-03-23 | 角色路由:approved 用户查询当前门店角色
|
||
role: str | None = None
|
||
store_name: str | None = None
|
||
coach_level: str | None = None
|
||
if user_status == "approved":
|
||
site_id = getattr(user, "site_id", None)
|
||
# CHANGE 2026-03-24 | 受限 token 兼容:token 无 site_id 时从数据库查默认 site
|
||
# 场景:用户从 pending→approved,旧的受限 token 不含 site_id
|
||
if not site_id:
|
||
site_id = _get_user_default_site(conn, user_id)
|
||
if site_id:
|
||
roles = _get_user_roles_at_site(conn, user_id, site_id)
|
||
# 用户在一个门店下仅一个角色
|
||
role = roles[0] if roles else None
|
||
# CHANGE 2026-03-23 | banner 数据修复:查询门店名
|
||
cur.execute(
|
||
"SELECT site_name FROM biz.sites WHERE site_id = %s",
|
||
(site_id,),
|
||
)
|
||
sn_row = cur.fetchone()
|
||
store_name = sn_row[0] if sn_row else None
|
||
# CHANGE 2026-03-23 | banner 数据修复:查询助教等级(coach_level)
|
||
cur.execute(
|
||
"""
|
||
SELECT assistant_id
|
||
FROM auth.user_assistant_binding
|
||
WHERE user_id = %s AND site_id = %s AND assistant_id IS NOT NULL
|
||
AND is_removed = false
|
||
LIMIT 1
|
||
""",
|
||
(user_id, site_id),
|
||
)
|
||
bind_row = cur.fetchone()
|
||
if bind_row:
|
||
try:
|
||
from datetime import datetime as _dt
|
||
from app.services import fdw_queries
|
||
_now = _dt.now()
|
||
# CHANGE 2026-03-24 | coach_level 回退链:salary_calc → monthly_summary
|
||
# salary_calc 月初结算前可能无数据,monthly_summary 每日更新更可靠
|
||
salary = fdw_queries.get_salary_calc(
|
||
conn, site_id, bind_row[0], _now.year, _now.month,
|
||
)
|
||
if salary:
|
||
coach_level = salary.get("coach_level") or None
|
||
if not coach_level:
|
||
ms = fdw_queries.get_monthly_summary(
|
||
conn, site_id, bind_row[0], _now.year, _now.month,
|
||
)
|
||
if ms:
|
||
coach_level = ms.get("coach_level") or None
|
||
except Exception:
|
||
pass # 优雅降级:FDW 查询失败不影响主流程
|
||
finally:
|
||
conn.close()
|
||
|
||
# CHANGE 2026-03-27 | 权限改造 W2:查询权限码列表
|
||
# get_user_permissions 内部自行获取连接,无需外部 conn
|
||
permissions: list[str] = []
|
||
if user_status == "approved" and role:
|
||
_perm_site_id = getattr(user, "site_id", None) or site_id
|
||
if _perm_site_id:
|
||
permissions = await get_user_permissions(user_id, _perm_site_id)
|
||
|
||
# 委托 service 查询申请列表(排除 cancelled)
|
||
app_list = await get_user_applications(user_id)
|
||
applications = [ApplicationResponse(**a) for a in app_list if a["status"] != "cancelled"]
|
||
|
||
# 最新申请(含 phone/employee_number,用于前端展示和预填)
|
||
latest = None
|
||
if app_list:
|
||
la = app_list[0] # 已按 created_at DESC 排序
|
||
latest = LatestApplicationDetail(
|
||
id=la["id"],
|
||
site_code=la["site_code"],
|
||
applied_role_text=la["applied_role_text"],
|
||
phone=la.get("phone", ""),
|
||
employee_number=la.get("employee_number"),
|
||
status=la["status"],
|
||
review_note=la.get("review_note"),
|
||
created_at=la["created_at"],
|
||
reviewed_at=la.get("reviewed_at"),
|
||
)
|
||
|
||
return UserStatusResponse(
|
||
user_id=user_id,
|
||
status=user_status,
|
||
nickname=nickname,
|
||
avatar_url=avatar_url,
|
||
role=role,
|
||
permissions=permissions,
|
||
store_name=store_name,
|
||
coach_level=coach_level,
|
||
applications=applications,
|
||
latest_application=latest,
|
||
)
|
||
|
||
|
||
# ── GET /api/xcx/me/sites ────────────────────────────────
|
||
|
||
@router.get("/me/sites", response_model=list[SiteInfo])
|
||
@trace_service("查询关联店铺", "Get my sites")
|
||
async def get_my_sites(
|
||
user: CurrentUser = Depends(get_current_user),
|
||
):
|
||
"""
|
||
查询当前用户关联的所有店铺及对应角色。
|
||
|
||
仅 approved 用户可访问(通过 get_current_user 依赖保证)。
|
||
"""
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"""
|
||
SELECT usr.site_id,
|
||
COALESCE(scm.site_name, '未知店铺') AS site_name,
|
||
r.code AS role_code,
|
||
r.name AS role_name
|
||
FROM auth.user_site_roles usr
|
||
JOIN auth.roles r ON usr.role_id = r.id
|
||
LEFT JOIN biz.sites scm ON scm.site_id = usr.site_id
|
||
WHERE usr.user_id = %s
|
||
AND usr.is_removed = false
|
||
ORDER BY usr.site_id, r.code
|
||
""",
|
||
(user.user_id,),
|
||
)
|
||
rows = cur.fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
# 按 site_id 分组
|
||
sites_map: dict[int, SiteInfo] = {}
|
||
for site_id, site_name, role_code, role_name in rows:
|
||
if site_id not in sites_map:
|
||
sites_map[site_id] = SiteInfo(
|
||
site_id=site_id, site_name=site_name, roles=[]
|
||
)
|
||
sites_map[site_id].roles.append({"code": role_code, "name": role_name})
|
||
|
||
return list(sites_map.values())
|
||
|
||
|
||
# ── POST /api/xcx/switch-site ────────────────────────────
|
||
|
||
@router.post("/switch-site", response_model=WxLoginResponse)
|
||
@trace_service("切换当前店铺", "Switch site")
|
||
async def switch_site(
|
||
body: SwitchSiteRequest,
|
||
user: CurrentUser = Depends(get_current_user),
|
||
):
|
||
"""
|
||
切换当前店铺。
|
||
|
||
验证用户在目标 site_id 下有角色绑定,然后签发包含新 site_id 的 JWT。
|
||
"""
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 验证用户在目标 site_id 下有角色
|
||
cur.execute(
|
||
"""
|
||
SELECT 1 FROM auth.user_site_roles
|
||
WHERE user_id = %s AND site_id = %s
|
||
AND is_removed = false
|
||
LIMIT 1
|
||
""",
|
||
(user.user_id, body.site_id),
|
||
)
|
||
if cur.fetchone() is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_403_FORBIDDEN,
|
||
detail="您在该店铺下没有角色绑定",
|
||
)
|
||
|
||
roles = _get_user_roles_at_site(conn, user.user_id, body.site_id)
|
||
|
||
# 查询用户状态
|
||
cur.execute(
|
||
"SELECT status FROM auth.users WHERE id = %s",
|
||
(user.user_id,),
|
||
)
|
||
user_row = cur.fetchone()
|
||
user_status = user_row[0] if user_row else "pending"
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
tokens = create_token_pair(user.user_id, body.site_id, roles=roles)
|
||
|
||
return WxLoginResponse(
|
||
access_token=tokens["access_token"],
|
||
refresh_token=tokens["refresh_token"],
|
||
token_type=tokens["token_type"],
|
||
user_status=user_status,
|
||
user_id=user.user_id,
|
||
)
|
||
|
||
|
||
# ── POST /api/xcx/refresh ────────────────────────────────
|
||
|
||
@router.post("/refresh", response_model=WxLoginResponse)
|
||
@trace_service("刷新令牌", "Refresh token")
|
||
async def refresh_token(body: RefreshTokenRequest):
|
||
"""
|
||
刷新令牌。
|
||
|
||
解码 refresh_token → 根据用户当前数据库状态签发新的令牌对。
|
||
- approved 用户 → 签发完整令牌(即使旧 token 是受限的,也自动升级)
|
||
- 其他状态 → 签发受限令牌
|
||
"""
|
||
try:
|
||
payload = decode_refresh_token(body.refresh_token)
|
||
except JWTError:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="无效的刷新令牌",
|
||
)
|
||
|
||
user_id = int(payload["sub"])
|
||
is_limited = payload.get("limited", False)
|
||
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 查询用户当前状态
|
||
cur.execute(
|
||
"SELECT id, status FROM auth.users WHERE id = %s",
|
||
(user_id,),
|
||
)
|
||
user_row = cur.fetchone()
|
||
if user_row is None:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail="用户不存在",
|
||
)
|
||
|
||
_, user_status = user_row
|
||
|
||
# CHANGE 2026-03-23 | 令牌升级:根据数据库当前状态决定签发类型
|
||
# 旧的受限 token 不应锁死用户——审核通过后 refresh 应自动升级为完整 token
|
||
if user_status == "approved":
|
||
# approved 用户:签发完整令牌(无论旧 token 是否 limited)
|
||
if is_limited:
|
||
# 受限 token 升级:查默认 site
|
||
site_id = _get_user_default_site(conn, user_id)
|
||
else:
|
||
# 完整 token 刷新:优先保持原 site_id
|
||
site_id = payload.get("site_id")
|
||
if site_id is None:
|
||
site_id = _get_user_default_site(conn, user_id)
|
||
|
||
if site_id is not None:
|
||
roles = _get_user_roles_at_site(conn, user_id, site_id)
|
||
tokens = create_token_pair(user_id, site_id, roles=roles)
|
||
else:
|
||
# approved 但无 site 绑定(异常边界)
|
||
tokens = create_limited_token_pair(user_id)
|
||
else:
|
||
# new / pending / rejected / disabled → 受限令牌
|
||
tokens = create_limited_token_pair(user_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
return WxLoginResponse(
|
||
access_token=tokens["access_token"],
|
||
refresh_token=tokens["refresh_token"],
|
||
token_type=tokens["token_type"],
|
||
user_status=user_status,
|
||
user_id=user_id,
|
||
)
|
||
|
||
# ── POST /api/xcx/dev-login(仅开发模式) ─────────────────
|
||
|
||
if config.WX_DEV_MODE:
|
||
|
||
@router.post("/dev-login", response_model=WxLoginResponse)
|
||
@trace_service("开发模式登录", "Dev mode login")
|
||
async def dev_login(body: DevLoginRequest):
|
||
"""
|
||
开发模式 mock 登录。
|
||
|
||
直接根据 openid 查找/创建用户,跳过微信 code2Session。
|
||
- 已有用户:status 参数为空时保留当前状态,非空时覆盖
|
||
- 新用户:status 参数为空时默认 new,非空时使用指定值
|
||
仅在 WX_DEV_MODE=true 时注册。
|
||
"""
|
||
openid = body.openid
|
||
target_status = body.status # 可能为 None
|
||
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 查找已有用户
|
||
cur.execute(
|
||
"SELECT id, status FROM auth.users WHERE wx_openid = %s",
|
||
(openid,),
|
||
)
|
||
row = cur.fetchone()
|
||
|
||
if row is None:
|
||
# 新用户:使用指定状态或默认 new
|
||
init_status = target_status or "new"
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO auth.users (wx_openid, status)
|
||
VALUES (%s, %s)
|
||
RETURNING id, status
|
||
""",
|
||
(openid, init_status),
|
||
)
|
||
row = cur.fetchone()
|
||
conn.commit()
|
||
else:
|
||
# 已有用户:仅在显式传入 status 时覆盖
|
||
if target_status is not None:
|
||
user_id_existing = row[0]
|
||
cur.execute(
|
||
"UPDATE auth.users SET status = %s, updated_at = NOW() WHERE id = %s",
|
||
(target_status, user_id_existing),
|
||
)
|
||
conn.commit()
|
||
row = (user_id_existing, target_status)
|
||
|
||
user_id, user_status = row
|
||
|
||
# 签发令牌(逻辑与正常登录一致)
|
||
# CHANGE 2026-03-23 | 角色路由:dev-login 也返回角色
|
||
dev_login_role: str | None = None
|
||
if user_status == "approved":
|
||
default_site_id = _get_user_default_site(conn, user_id)
|
||
if default_site_id is not None:
|
||
roles = _get_user_roles_at_site(conn, user_id, default_site_id)
|
||
tokens = create_token_pair(user_id, default_site_id, roles=roles)
|
||
dev_login_role = roles[0] if roles else None
|
||
else:
|
||
tokens = create_limited_token_pair(user_id)
|
||
else:
|
||
tokens = create_limited_token_pair(user_id)
|
||
|
||
finally:
|
||
conn.close()
|
||
|
||
return WxLoginResponse(
|
||
access_token=tokens["access_token"],
|
||
refresh_token=tokens["refresh_token"],
|
||
token_type=tokens["token_type"],
|
||
user_status=user_status,
|
||
user_id=user_id,
|
||
role=dev_login_role,
|
||
)
|
||
|
||
# ── GET /api/xcx/dev-context(仅开发模式) ────────────────
|
||
|
||
@router.get("/dev-context", response_model=DevContextResponse)
|
||
@trace_service("查询调试上下文", "Get dev context")
|
||
async def dev_context(
|
||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||
):
|
||
"""
|
||
返回当前用户的完整调试上下文。
|
||
|
||
包含:用户信息、当前门店、角色、权限、人员绑定、所有关联门店。
|
||
允许受限令牌访问(返回基础信息,门店/角色/权限为空)。
|
||
"""
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 用户基本信息
|
||
cur.execute(
|
||
"SELECT wx_openid, status, nickname FROM auth.users WHERE id = %s",
|
||
(user.user_id,),
|
||
)
|
||
u_row = cur.fetchone()
|
||
if u_row is None:
|
||
raise HTTPException(status_code=404, detail="用户不存在")
|
||
openid, u_status, nickname = u_row
|
||
|
||
# 当前门店名称
|
||
site_name = None
|
||
if user.site_id:
|
||
cur.execute(
|
||
"SELECT site_name FROM biz.sites WHERE site_id = %s",
|
||
(user.site_id,),
|
||
)
|
||
sn_row = cur.fetchone()
|
||
site_name = sn_row[0] if sn_row else None
|
||
|
||
# 当前门店下的角色
|
||
roles = _get_user_roles_at_site(conn, user.user_id, user.site_id) if user.site_id else []
|
||
|
||
# 当前门店下的权限
|
||
permissions = await get_user_permissions(user.user_id, user.site_id) if user.site_id else []
|
||
|
||
# 人员绑定
|
||
binding = None
|
||
if user.site_id:
|
||
cur.execute(
|
||
"""
|
||
SELECT assistant_id, staff_id, binding_type
|
||
FROM auth.user_assistant_binding
|
||
WHERE user_id = %s AND site_id = %s
|
||
AND is_removed = false
|
||
LIMIT 1
|
||
""",
|
||
(user.user_id, user.site_id),
|
||
)
|
||
b_row = cur.fetchone()
|
||
if b_row:
|
||
binding = {
|
||
"assistant_id": b_row[0],
|
||
"staff_id": b_row[1],
|
||
"binding_type": b_row[2],
|
||
}
|
||
|
||
# 所有关联门店
|
||
cur.execute(
|
||
"""
|
||
SELECT usr.site_id,
|
||
COALESCE(scm.site_name, '') AS site_name,
|
||
r.code, r.name
|
||
FROM auth.user_site_roles usr
|
||
JOIN auth.roles r ON usr.role_id = r.id
|
||
LEFT JOIN biz.sites scm ON scm.site_id = usr.site_id
|
||
WHERE usr.user_id = %s
|
||
AND usr.is_removed = false
|
||
ORDER BY usr.site_id, r.code
|
||
""",
|
||
(user.user_id,),
|
||
)
|
||
site_rows = cur.fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
sites_map: dict[int, dict] = {}
|
||
for sid, sname, rcode, rname in site_rows:
|
||
if sid not in sites_map:
|
||
sites_map[sid] = {"site_id": sid, "site_name": sname, "roles": []}
|
||
sites_map[sid]["roles"].append({"code": rcode, "name": rname})
|
||
|
||
return DevContextResponse(
|
||
user_id=user.user_id,
|
||
openid=openid,
|
||
status=u_status,
|
||
nickname=nickname,
|
||
site_id=user.site_id,
|
||
site_name=site_name,
|
||
roles=roles,
|
||
permissions=permissions,
|
||
binding=binding,
|
||
all_sites=list(sites_map.values()),
|
||
)
|
||
|
||
# ── POST /api/xcx/dev-switch-role(仅开发模式) ───────────
|
||
|
||
@router.post("/dev-switch-role", response_model=WxLoginResponse)
|
||
@trace_service("切换角色", "Dev switch role")
|
||
async def dev_switch_role(
|
||
body: DevSwitchRoleRequest,
|
||
user: CurrentUser = Depends(get_current_user),
|
||
):
|
||
"""
|
||
切换当前用户在当前门店下的角色。
|
||
|
||
删除旧角色绑定,插入新角色绑定,重签 token。
|
||
"""
|
||
# CHANGE 2026-03-23 | 角色体系隔离:小程序端只有 4 个角色,site_admin/tenant_admin 已移至租户管理后台
|
||
valid_roles = ("coach", "staff", "head_coach", "manager")
|
||
if body.role_code not in valid_roles:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"无效角色,可选: {', '.join(valid_roles)}",
|
||
)
|
||
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 查询目标角色 ID
|
||
cur.execute(
|
||
"SELECT id FROM auth.roles WHERE code = %s",
|
||
(body.role_code,),
|
||
)
|
||
role_row = cur.fetchone()
|
||
if role_row is None:
|
||
raise HTTPException(status_code=400, detail=f"角色 {body.role_code} 不存在")
|
||
role_id = role_row[0]
|
||
|
||
# 删除当前门店下的所有角色
|
||
cur.execute(
|
||
"DELETE FROM auth.user_site_roles WHERE user_id = %s AND site_id = %s",
|
||
(user.user_id, user.site_id),
|
||
)
|
||
|
||
# 插入新角色
|
||
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 NOTHING
|
||
""",
|
||
(user.user_id, user.site_id, role_id),
|
||
)
|
||
conn.commit()
|
||
|
||
# 重签 token
|
||
roles = _get_user_roles_at_site(conn, user.user_id, user.site_id)
|
||
cur.execute("SELECT status FROM auth.users WHERE id = %s", (user.user_id,))
|
||
u_status = cur.fetchone()[0]
|
||
finally:
|
||
conn.close()
|
||
|
||
tokens = create_token_pair(user.user_id, user.site_id, roles=roles)
|
||
return WxLoginResponse(
|
||
access_token=tokens["access_token"],
|
||
refresh_token=tokens["refresh_token"],
|
||
token_type=tokens["token_type"],
|
||
user_status=u_status,
|
||
user_id=user.user_id,
|
||
)
|
||
|
||
# ── POST /api/xcx/dev-switch-status(仅开发模式) ─────────
|
||
|
||
@router.post("/dev-switch-status", response_model=WxLoginResponse)
|
||
@trace_service("切换用户状态", "Dev switch status")
|
||
async def dev_switch_status(
|
||
body: DevSwitchStatusRequest,
|
||
user: CurrentUser = Depends(get_current_user_or_limited),
|
||
):
|
||
"""
|
||
切换当前用户状态,重签 token。
|
||
|
||
允许受限令牌访问(pending 用户也需要能切换状态)。
|
||
"""
|
||
valid_statuses = ("new", "pending", "approved", "rejected", "disabled")
|
||
if body.status not in valid_statuses:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"无效状态,可选: {', '.join(valid_statuses)}",
|
||
)
|
||
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"UPDATE auth.users SET status = %s, updated_at = NOW() WHERE id = %s",
|
||
(body.status, user.user_id),
|
||
)
|
||
conn.commit()
|
||
|
||
# 根据新状态签发对应令牌
|
||
if body.status == "approved":
|
||
default_site_id = _get_user_default_site(conn, user.user_id)
|
||
if default_site_id is not None:
|
||
roles = _get_user_roles_at_site(conn, user.user_id, default_site_id)
|
||
tokens = create_token_pair(user.user_id, default_site_id, roles=roles)
|
||
else:
|
||
tokens = create_limited_token_pair(user.user_id)
|
||
else:
|
||
tokens = create_limited_token_pair(user.user_id)
|
||
finally:
|
||
conn.close()
|
||
|
||
return WxLoginResponse(
|
||
access_token=tokens["access_token"],
|
||
refresh_token=tokens["refresh_token"],
|
||
token_type=tokens["token_type"],
|
||
user_status=body.status,
|
||
user_id=user.user_id,
|
||
)
|
||
|
||
# ── POST /api/xcx/dev-switch-binding(仅开发模式) ────────
|
||
|
||
@router.post("/dev-switch-binding")
|
||
@trace_service("切换人员绑定", "Dev switch binding")
|
||
async def dev_switch_binding(
|
||
body: DevSwitchBindingRequest,
|
||
user: CurrentUser = Depends(get_current_user),
|
||
):
|
||
"""
|
||
切换当前用户在当前门店下的人员绑定。
|
||
|
||
删除旧绑定,插入新绑定。
|
||
"""
|
||
valid_types = ("assistant", "staff", "manager")
|
||
if body.binding_type not in valid_types:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"无效绑定类型,可选: {', '.join(valid_types)}",
|
||
)
|
||
|
||
conn = get_connection()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
# 删除当前门店下的旧绑定
|
||
cur.execute(
|
||
"DELETE FROM auth.user_assistant_binding WHERE user_id = %s AND site_id = %s",
|
||
(user.user_id, user.site_id),
|
||
)
|
||
|
||
# 插入新绑定
|
||
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.user_id, user.site_id, body.assistant_id, body.staff_id, body.binding_type),
|
||
)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
return {"ok": True, "binding_type": body.binding_type}
|