# ============================================================================= # 企微IT智能服务台 — 扫码登录 API # ============================================================================= # 说明:扫码登录是 Phase 1.1 的核心功能,用于替代坐席端"用户名密码+企微 # OAuth"双因素登录,提供"用企微 App 扫一扫登录浏览器坐席端"的体验。 # # 完整流程: # ┌─────────┐ create ┌─────────────┐ scan ┌──────────┐ # │ 浏览器 │ ───────→ │ ticket(120s)│ ←───── │ 企微 App │ # │ 前端 │ ←─────── │ +OAuth URL │ OAuth │ 扫码授权 │ # └─────────┘ qrcode_url └─────────────┘ code └──────────┘ # │ │ │ # │ poll │ scan │ # │ waiting/scanned │ 写 scan:{ticket} │ # │ ↓ │ # │ ┌────────────────┐ │ # │ │ 已登录坐席(企微)│ confirm │ # │ │ 点"确认登录"按钮 │ ────────→ │ # │ └────────────────┘ │ # │ │ │ # │ poll │ confirm │ # │ confirmed+token │ 写 confirm:{ticket} │ # ↓ ↓ │ # 拿到 token,跳坐席端主页 │ # # 端点列表(4 个): # POST /api/auth_qrcode/create — 浏览器前端生成 ticket # GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态 # POST /api/auth_qrcode/scan — 企微 OAuth2 回调(接收 code) # POST /api/auth_qrcode/confirm — 当前登录坐席点确认 # # 鉴权说明: # - create / scan / poll: 无需登录(浏览器刚加载登录页,用户未登录) # - confirm: 需要已登录坐席点确认(角色: agent / admin) # - 票据状态全部存 Redis,TTL 到期自动失效,无 DB 表 # ============================================================================= import logging from typing import Optional import redis.asyncio as aioredis from fastapi import APIRouter, Depends, Path from app.config import settings from app.dependencies import dep_redis, get_current_user, UserInfo from app.schemas.qrcode import ( QrcodeConfirmRequest, QrcodeConfirmResponse, QrcodeCreateResponse, QrcodePollResponse, QrcodeScanRequest, QrcodeScanResponse, ) from app.services.qrcode_service import QrcodeService from app.utils.response import AppException, success_response logger = logging.getLogger(__name__) # 创建路由器 # prefix="/auth_qrcode" + tags=["扫码登录"] 用于 Swagger 分组 router = APIRouter(prefix="/auth_qrcode", tags=["扫码登录"]) def _get_qrcode_service(redis_client: aioredis.Redis) -> QrcodeService: """工厂函数: 构造扫码登录业务服务。 拆出来便于测试时 monkey-patch,以及后续接入 DI。 """ return QrcodeService(redis_client) # -------------------------------------------------------------------------- # POST /api/auth_qrcode/create — 创建扫码登录票据 # -------------------------------------------------------------------------- @router.post("/create", response_model=None) async def create_qrcode( redis_client: aioredis.Redis = Depends(dep_redis), ): """创建扫码登录票据。 无需鉴权(用户尚未登录,正在登录页)。 返回 ticket + 企微 OAuth2 授权 URL,前端渲染二维码。 Returns: Dict: 统一响应格式,data 字段是 QrcodeCreateResponse """ try: service = _get_qrcode_service(redis_client) result = await service.create_ticket() return success_response(data={ "ticket": result["ticket"], "qrcode_url": result["qrcode_url"], "expires_in": result["expires_in"], "expires_at": result["expires_at"].isoformat(), }) except Exception as e: logger.error(f"创建扫码票据异常: {e}", exc_info=True) raise AppException(1005, f"创建扫码票据失败: {str(e)}") # -------------------------------------------------------------------------- # GET /api/auth_qrcode/poll/{ticket} — 前端轮询扫码状态 # -------------------------------------------------------------------------- @router.get("/poll/{ticket}", response_model=None) async def poll_qrcode( ticket: str = Path(..., description="扫码登录票据"), redis_client: aioredis.Redis = Depends(dep_redis), ): """轮询扫码状态。 无需鉴权(浏览器未登录态访问)。 状态机: - waiting: ticket 有效,等待扫码 - scanned: 已扫码,等待 confirm - confirmed: 已确认,返回 token - expired: ticket 过期/不存在 Returns: Dict: 统一响应格式,data 字段是 QrcodePollResponse """ try: service = _get_qrcode_service(redis_client) result = await service.get_poll_state(ticket) return success_response(data={ "status": result["status"], "employee_id": result.get("employee_id"), "name": result.get("name"), "token": result.get("token"), }) except Exception as e: logger.error(f"轮询扫码状态异常: ticket={ticket[:8]}..., error={e}", exc_info=True) raise AppException(1005, f"轮询扫码状态失败: {str(e)}") # -------------------------------------------------------------------------- # POST /api/auth_qrcode/scan — 企微 OAuth code 回调 # -------------------------------------------------------------------------- @router.post("/scan", response_model=None) async def scan_qrcode( body: QrcodeScanRequest, redis_client: aioredis.Redis = Depends(dep_redis), ): """处理企微 OAuth2 扫码回调。 无需鉴权(此端点被企微服务器回调,带 code + ticket)。 用 code 换取企微 userid,然后写 Redis scan:{ticket} 等待 confirm 端点。 dev 模式: code 形如 "dev:dev-user-001",跳过企微 API 调用。 Args: body: 包含 ticket 和 code Returns: Dict: 统一响应格式,data 字段是 QrcodeScanResponse """ try: service = _get_qrcode_service(redis_client) result = await service.process_scan(ticket=body.ticket, code=body.code) return success_response(data={ "success": result["success"], "message": result["message"], }) except ValueError as ve: # 票据过期/不存在 → 业务错误 logger.warning(f"扫码业务错误: {ve}") raise AppException(1003, str(ve)) except Exception as e: logger.error(f"扫码处理异常: ticket={body.ticket[:8]}..., error={e}", exc_info=True) raise AppException(1005, f"扫码处理失败: {str(e)}") # -------------------------------------------------------------------------- # POST /api/auth_qrcode/confirm — 当前已登录坐席确认授权 # -------------------------------------------------------------------------- @router.post("/confirm", response_model=None) async def confirm_qrcode( body: QrcodeConfirmRequest, current_user: UserInfo = Depends(get_current_user), redis_client: aioredis.Redis = Depends(dep_redis), ): """处理当前已登录坐席的扫码确认授权。 需要鉴权: 只有已登录的坐席/管理员能确认授权。 把扫码用户身份变成可登录 Token(roles=['agent']), 写 Redis confirm:{ticket},前端 poll 拿到后跳坐席主页。 otp_code: admin 场景下可选,Phase 1.1 仅记录日志, 真实 OTP 校验留给 Phase 2.1(参考 agents.py:272-274 的 totp.verify)。 Args: body: 包含 ticket 和 otp_code(可选) current_user: 当前已登录用户(由 get_current_user 注入) redis_client: Redis 客户端 Returns: Dict: 统一响应格式,data 字段是 QrcodeConfirmResponse """ try: service = _get_qrcode_service(redis_client) result = await service.process_confirm( ticket=body.ticket, current_user_id=current_user.employee_id, current_user_name=current_user.name, current_roles=current_user.roles, otp_code=body.otp_code, ) return success_response(data={ "token": result["token"], "employee_id": result["employee_id"], "name": result["name"], "roles": result["roles"], "require_otp": result.get("require_otp"), }) except ValueError as ve: # 票据过期/未扫码 → 业务错误 logger.warning( f"扫码确认业务错误: ticket={body.ticket[:8]}..., " f"current_user={current_user.employee_id}, error={ve}" ) raise AppException(1003, str(ve)) except Exception as e: logger.error( f"扫码确认异常: ticket={body.ticket[:8]}..., " f"current_user={current_user.employee_id}, error={e}", exc_info=True, ) raise AppException(1005, f"扫码确认失败: {str(e)}")