Files
wecom_it_smart_desk/backend/app/api/agents.py
T

520 lines
18 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 坐席管理 API
# =============================================================================
# 说明:坐席端的管理接口,包括:
# 1. POST /api/agents/login — 坐席登录(用户名密码,返回JWT token)
# 2. GET /api/agents/me — 获取当前坐席信息
# 3. PUT /api/agents/me/status — 更新坐席状态(online/busy/offline
# 4. GET /api/agents — 获取坐席列表(用于转接选择)
# 坐席认证使用 JWTtoken 存 RedisTTL 8小时)
# =============================================================================
import base64
import io
import json
import logging
import secrets
from datetime import datetime
from typing import Optional
from uuid import UUID
import pyotp
import qrcode
import redis.asyncio as aioredis
from fastapi import APIRouter, Depends, Header, Query, Request
from slowapi import Limiter
from slowapi.util import get_remote_address
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user, require_role
from app.models.agent import Agent
from app.schemas.agent import AgentLogin, AgentResponse, AgentStatusUpdate
from app.services.wecom_service import WecomService
from app.utils.response import AppException, ERR_UNAUTHORIZED, success_response
# 速率限制器实例(与 main.py 共享同一配置)
# 移除 env_file=None 参数:slowapi 0.1.9 不支持该参数
# python-dotenv 已在应用启动时处理 .env 文件
limiter = Limiter(key_func=get_remote_address)
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# JWT 简化版:使用随机 token 存 RedisTTL 8 小时
# 为什么不用标准 JWT:第一步简化实现,token 存 Redis 更容易实现登出和状态管理
TOKEN_TTL_SECONDS = 8 * 60 * 60 # 8小时
def _get_redis() -> aioredis.Redis:
"""获取 Redis 客户端。"""
return settings.create_redis_client()
# --------------------------------------------------------------------------
# 坐席认证依赖
# --------------------------------------------------------------------------
async def get_current_agent(
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db),
) -> Agent:
"""从请求头中提取坐席身份(认证依赖)。
支持两种 Token 格式:
1. 统一格式:user:token:{token} → JSON 包含 employee_id 和 roles
2. 旧格式:agent:token:{token} → 直接存储 user_id
Args:
authorization: 请求头中的 Authorization 字段(格式:Bearer token
db: 数据库会话
Returns:
Agent: 当前坐席对象
Raises:
AppException: 未授权(token 缺失、无效或过期)
"""
if not authorization:
raise ERR_UNAUTHORIZED
# 提取 token(支持 "Bearer xxx" 格式)
token = authorization.replace("Bearer ", "") if authorization.startswith("Bearer ") else authorization
if not token:
raise ERR_UNAUTHORIZED
# 从 Redis 查找坐席ID
redis_client = _get_redis()
try:
# 1. 尝试统一格式(新)
unified_data = await redis_client.get(f"user:token:{token}")
if unified_data:
try:
user_info = json.loads(unified_data)
agent_user_id = user_info.get("employee_id")
if agent_user_id:
# 从数据库查找坐席
stmt = select(Agent).where(Agent.user_id == agent_user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if agent:
return agent
except json.JSONDecodeError:
logger.warning(f"统一 Token 数据解析失败: {token[:10]}...")
# 2. 尝试旧格式(兼容)
agent_user_id = await redis_client.get(f"agent:token:{token}")
if not agent_user_id:
raise ERR_UNAUTHORIZED
# 从数据库查找坐席
# agent_user_id 可能是 bytesRedis 返回)或 str
uid = agent_user_id.decode("utf-8") if isinstance(agent_user_id, bytes) else agent_user_id
stmt = select(Agent).where(Agent.user_id == uid)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
raise ERR_UNAUTHORIZED
return agent
except AppException:
# 业务异常直接抛出(如 ERR_UNAUTHORIZED
raise
except Exception as e:
# Redis 连接失败等底层异常
logger.error(f"Redis 读取失败: {e}")
raise ERR_UNAUTHORIZED
finally:
try:
await redis_client.close()
except Exception:
pass
# --------------------------------------------------------------------------
# POST /api/agents/login — 坐席登录
# --------------------------------------------------------------------------
@router.post("/agents/login")
@limiter.limit("10/minute") # 登录接口限流:每IP每分钟最多10次,防暴力破解
async def agent_login(
request: Request,
body: AgentLogin,
db: AsyncSession = Depends(get_db),
):
"""坐席登录。
第一步使用简单的用户名密码登录。
登录成功后生成 token 存入 RedisTTL 8小时)。
流程:
1. 查找坐席记录(按 user_id),不存在则自动创建
2. 生成随机 token
3. token 存 Rediskey: agent:token:{token}, value: user_id
4. 更新坐席状态为 online
5. 返回坐席信息和 token
Args:
body: 登录请求体(包含 user_id 和 name
db: 数据库会话
Returns:
Dict: 统一响应格式,包含坐席信息和 token
"""
try:
# 0. 企微通讯录身份验证(防止任意 user_id 冒充坐席)
# 调用企微API校验 user_id 是否存在于通讯录中
# 安全策略:
# - 企微验证通过 → 正常登录,用企微真实姓名覆盖前端传入值
# - 企微验证失败(用户不存在) → 拒绝登录
# - 企微API不可达(网络故障) → 仅允许已注册坐席降级登录,新注册必须验证
wecom_verified = False
try:
redis_client_verify = _get_redis()
try:
wecom_service = WecomService(redis_client_verify)
user_info = await wecom_service.get_user_info(body.user_id)
# 验证通过:用户存在于企微通讯录
wecom_verified = True
# 用企微返回的真实姓名覆盖前端传入的姓名(防止冒用他人身份)
real_name = user_info.get("name", "")
if real_name:
body.name = real_name
logger.info(f"坐席企微身份验证通过: user_id={body.user_id}, name={real_name}")
finally:
try:
await redis_client_verify.close()
except Exception:
pass
try:
await wecom_service.close()
except Exception:
pass
except Exception as wecom_err:
# 企微API不可达时:仅允许已注册坐席降级登录,新注册必须验证
# 原因:网络故障不应阻断已注册坐席工作,但不能让未验证用户注册新账号
logger.warning(
f"企微通讯录验证失败: user_id={body.user_id}, "
f"error={wecom_err}"
)
# 检查是否为已注册坐席(数据库已有记录才允许降级登录)
check_stmt = select(Agent).where(Agent.user_id == body.user_id)
check_result = await db.execute(check_stmt)
existing_agent = check_result.scalars().first()
if not existing_agent:
# 新坐席注册必须通过企微验证,防止任意 user_id 冒充
raise AppException(
1003,
"企微通讯录验证失败,新坐席注册需要企微身份验证。请稍后重试或联系管理员。"
)
logger.warning(
f"企微API不可达,已注册坐席降级放行: user_id={body.user_id}"
)
# 1. 查找或创建坐席记录
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent:
# 首次登录,创建坐席记录
agent = Agent(
user_id=body.user_id,
name=body.name,
status="online",
current_load=0,
max_load=5,
)
db.add(agent)
await db.flush()
logger.info(f"新坐席注册: user_id={body.user_id}, name={body.name}")
else:
# 更新坐席名称(可能改名了)
agent.name = body.name
agent.status = "online"
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"坐席登录: user_id={body.user_id}, name={body.name}")
# 2. OTP 二次验证(admin 角色且已绑定 OTP)
if agent.role == "admin" and agent.otp_enabled == 1:
if not body.otp_code:
# 需要 OTP 验证,返回 require_otp 标记
return success_response(data={
"require_otp": True,
"message": "请输入OTP动态码",
"user_id": agent.user_id,
"name": agent.name,
})
else:
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误,请重新输入")
# 3. 生成随机 token(使用统一格式)
from app.services.token_service import TokenService
from app.dependencies import get_redis
# 使用共享 Redis 连接(从连接池获取,不要手动关闭)
redis_client = await get_redis()
token_service = TokenService(redis_client)
# 查询用户角色
from app.services.role_mapping_service import RoleMappingService
role_service = RoleMappingService(db)
roles = await role_service.get_user_roles(body.user_id)
# 创建统一格式的 Token
token = await token_service.create_token(
employee_id=body.user_id,
name=body.name,
roles=roles,
login_source="agent",
)
# 5. 返回坐席信息和 token
agent_data = AgentResponse.model_validate(agent).model_dump()
agent_data["token"] = token
return success_response(data=agent_data)
except AppException:
# 业务异常直接抛出
raise
except Exception as e:
# 未预期的异常:记录日志,返回友好错误
logger.error(f"登录异常: {e}", exc_info=True)
raise AppException(1005, f"登录失败: {str(e)}")
# --------------------------------------------------------------------------
# GET /api/agents/me — 获取当前坐席信息
# --------------------------------------------------------------------------
@router.get("/agents/me")
async def get_agent_me(
agent: Agent = Depends(get_current_agent),
):
"""获取当前坐席信息。
需要在请求头中携带有效的 token。
Args:
agent: 当前坐席(通过认证依赖注入)
Returns:
Dict: 统一响应格式,包含坐席信息
"""
agent_data = AgentResponse.model_validate(agent).model_dump()
return success_response(data=agent_data)
# --------------------------------------------------------------------------
# PUT /api/agents/me/status — 更新坐席状态
# --------------------------------------------------------------------------
@router.put("/agents/me/status")
async def update_agent_status(
body: AgentStatusUpdate,
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""更新坐席状态。
坐席可以切换为 online/busy/offline。
- online: 在线,可以接收新会话
- busy: 忙碌,不接收新会话但继续处理已有的
- offline: 离线,不接收任何会话
Args:
body: 状态更新请求体
agent: 当前坐席
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的坐席信息
"""
agent.status = body.status
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"坐席状态更新: agent={agent.user_id}, status={body.status}")
agent_data = AgentResponse.model_validate(agent).model_dump()
return success_response(data=agent_data)
# --------------------------------------------------------------------------
# GET /api/agents — 获取坐席列表(需要 agent 或 admin 角色)
# --------------------------------------------------------------------------
@router.get("/agents")
@require_role("agent", "admin")
async def list_agents(
status: Optional[str] = Query(None, description="按状态过滤: online/busy/offline"),
db: AsyncSession = Depends(get_db),
):
"""获取坐席列表。
用于转接选择时展示可用的坐席列表。
Args:
status: 按状态过滤(可选)
db: 数据库会话
Returns:
Dict: 统一响应格式,包含坐席列表
"""
stmt = select(Agent).order_by(Agent.name)
if status:
stmt = stmt.where(Agent.status == status)
result = await db.execute(stmt)
agents = list(result.scalars().all())
items = [AgentResponse.model_validate(a).model_dump() for a in agents]
return success_response(data={"items": items})
# --------------------------------------------------------------------------
# OTP 绑定接口
# --------------------------------------------------------------------------
@router.post("/agents/otp-bind")
async def bind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""为当前坐席生成 OTP 密钥和二维码。
生成 TOTP 密钥,生成 otpauth:// URI 用于扫码绑定 Google Authenticator。
返回二维码(base64编码)和密钥,供用户手动输入备用。
Returns:
Dict: 二维码图片(base64)和密钥
"""
try:
# 检查是否已绑定
if agent.otp_secret:
# 已绑定,返回现有密钥的二维码
totp = pyotp.TOTP(agent.otp_secret)
else:
# 生成新密钥
secret = pyotp.random_base32()
agent.otp_secret = secret
# otp_enabled 保持 0,等待首次验证后启用
db.add(agent)
await db.flush()
totp = pyotp.TOTP(secret)
# 生成 otpauth:// URI
otpauth_uri = totp.provisioning_uri(
name=f"IT支持服务:{agent.name}",
issuer_name="IT支持服务",
)
# 生成二维码图片
qr = qrcode.make(otpauth_uri)
buffer = io.BytesIO()
qr.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
logger.info(f"OTP绑定: agent={agent.user_id}, secret={agent.otp_secret[:4]}...")
return success_response(data={
"qr_code": f"data:image/png;base64,{qr_base64}",
"secret": agent.otp_secret,
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP绑定异常: {e}", exc_info=True)
raise AppException(1007, f"OTP绑定失败: {str(e)}")
@router.post("/agents/otp-verify")
async def verify_agent_otp(
body: AgentLogin, # 复用 AgentLoginotp_code 为必填
db: AsyncSession = Depends(get_db),
):
"""验证并启用 OTP。
用户输入 OTP 码验证成功后,启用 OTP。
首次验证成功后 otp_enabled 设为 1。
Args:
body.otp_code: 用户输入的 OTP 码(必填)
Returns:
Dict: 验证结果
"""
try:
# 查找坐席
stmt = select(Agent).where(Agent.user_id == body.user_id)
result = await db.execute(stmt)
agent = result.scalars().first()
if not agent or not agent.otp_secret:
raise AppException(1008, "请先绑定OTP")
# 验证 OTP 码
totp = pyotp.TOTP(agent.otp_secret)
if not totp.verify(body.otp_code, valid_window=1):
raise AppException(1006, "OTP验证码错误")
# 验证成功,启用 OTP
agent.otp_enabled = 1
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP验证成功并启用: agent={agent.user_id}")
return success_response(data={
"otp_enabled": True,
"message": "OTP验证成功,已启用",
})
except AppException:
raise
except Exception as e:
logger.error(f"OTP验证异常: {e}", exc_info=True)
raise AppException(1009, f"OTP验证失败: {str(e)}")
@router.post("/agents/otp-unbind")
async def unbind_agent_otp(
agent: Agent = Depends(get_current_agent),
db: AsyncSession = Depends(get_db),
):
"""解绑 OTP。
解绑后 otp_secret 和 otp_enabled 都清空。
需要管理员操作。
Returns:
Dict: 解绑结果
"""
try:
agent.otp_secret = None
agent.otp_enabled = 0
agent.updated_at = datetime.now()
db.add(agent)
await db.flush()
logger.info(f"OTP解绑: agent={agent.user_id}")
return success_response(data={"message": "OTP已解绑"})
except AppException:
raise
except Exception as e:
logger.error(f"OTP解绑异常: {e}", exc_info=True)
raise AppException(1010, f"OTP解绑失败: {str(e)}")