# 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, audience="miniapp")
                    login_role = roles[0] if roles else None
                else:
                    # approved 但无 site 绑定（异常边界），签发受限令牌
                    tokens = create_limited_token_pair(user_id, audience="miniapp")
            else:
                # new / pending / rejected → 受限令牌
                tokens = create_limited_token_pair(user_id, audience="miniapp")

    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, audience="miniapp")

    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, audience="miniapp")
                else:
                    # approved 但无 site 绑定（异常边界）
                    tokens = create_limited_token_pair(user_id, audience="miniapp")
            else:
                # new / pending / rejected / disabled → 受限令牌
                tokens = create_limited_token_pair(user_id, audience="miniapp")

    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, audience="miniapp")
                        dev_login_role = roles[0] if roles else None
                    else:
                        tokens = create_limited_token_pair(user_id, audience="miniapp")
                else:
                    tokens = create_limited_token_pair(user_id, audience="miniapp")

        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, audience="miniapp")
        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, audience="miniapp")
                    else:
                        tokens = create_limited_token_pair(user.user_id, audience="miniapp")
                else:
                    tokens = create_limited_token_pair(user.user_id, audience="miniapp")
        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}
