# -*- 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 ( create_application, get_user_applications, ) from app.schemas.xcx_auth import ( ApplicationRequest, ApplicationResponse, DevLoginRequest, DevSwitchBindingRequest, DevSwitchRoleRequest, DevSwitchStatusRequest, DevContextResponse, RefreshTokenRequest, SiteInfo, SwitchSiteRequest, UserStatusResponse, WxLoginRequest, WxLoginResponse, ) from app.services.wechat import WeChatAuthError, code2session from app.services.role import get_user_permissions 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 """, (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 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) async def wx_login(body: WxLoginRequest): """ 微信登录。 流程:code → code2session(openid) → 查找/创建 auth.users → 签发 JWT。 - disabled 用户返回 403 - 新用户自动创建(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() user_id, user_status = row # 3. disabled 用户拒绝登录 if user_status == "disabled": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用", ) # 4. 签发令牌 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) 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, ) # ── POST /api/xcx/apply ────────────────────────────────── @router.post("/apply", response_model=ApplicationResponse) 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) # ── GET /api/xcx/me ─────────────────────────────────────── @router.get("/me", response_model=UserStatusResponse) 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: # 查询用户基本信息 cur.execute( "SELECT id, status, nickname 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 = user_row finally: conn.close() # 委托 service 查询申请列表 app_list = await get_user_applications(user_id) applications = [ApplicationResponse(**a) for a in app_list] return UserStatusResponse( user_id=user_id, status=user_status, nickname=nickname, applications=applications, ) # ── GET /api/xcx/me/sites ──────────────────────────────── @router.get("/me/sites", response_model=list[SiteInfo]) 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 auth.site_code_mapping scm ON usr.site_id = scm.site_id WHERE usr.user_id = %s 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) 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 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) async def refresh_token(body: RefreshTokenRequest): """ 刷新令牌。 解码 refresh_token → 根据用户当前状态签发新的令牌对。 - 受限 refresh_token(limited=True)→ 签发新的受限令牌对 - 完整 refresh_token → 签发新的完整令牌对(保持原 site_id) """ 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 if user_status == "disabled": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="账号已被禁用", ) if is_limited or user_status != "approved": # 受限令牌刷新 → 仍签发受限令牌 tokens = create_limited_token_pair(user_id) else: # 完整令牌刷新 → 使用原 site_id 签发 site_id = payload.get("site_id") if site_id is None: # 回退到默认 site 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: 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) 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 # 签发令牌(逻辑与正常登录一致) 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) 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, ) # ── GET /api/xcx/dev-context(仅开发模式) ──────────────── @router.get("/dev-context", response_model=DevContextResponse) 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 auth.site_code_mapping 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 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 auth.site_code_mapping scm ON usr.site_id = scm.site_id WHERE usr.user_id = %s 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) async def dev_switch_role( body: DevSwitchRoleRequest, user: CurrentUser = Depends(get_current_user), ): """ 切换当前用户在当前门店下的角色。 删除旧角色绑定,插入新角色绑定,重签 token。 """ valid_roles = ("coach", "staff", "site_admin", "tenant_admin") 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) 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") 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}