2026-06-14 16:49:18 +08:00
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# 企微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 — 获取坐席列表(用于转接选择)
|
|
|
|
|
|
# 坐席认证使用 JWT,token 存 Redis(TTL 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
|
2026-06-14 21:21:48 +08:00
|
|
|
|
import bcrypt # P1 修复: 直接使用 bcrypt 库替代 passlib
|
2026-06-14 16:49:18 +08:00
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query, Request
|
2026-06-14 19:32:36 +08:00
|
|
|
|
from pydantic import BaseModel, Field
|
2026-06-14 16:49:18 +08:00
|
|
|
|
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 存 Redis,TTL 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 可能是 bytes(Redis 返回)或 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 存入 Redis(TTL 8小时)。
|
|
|
|
|
|
|
|
|
|
|
|
流程:
|
|
|
|
|
|
1. 查找坐席记录(按 user_id),不存在则自动创建
|
|
|
|
|
|
2. 生成随机 token
|
|
|
|
|
|
3. token 存 Redis(key: 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}"
|
|
|
|
|
|
)
|
2026-06-14 21:21:48 +08:00
|
|
|
|
# P1 修复: 降级放行时,如果 agent 有 password_hash 则必须验证本地密码
|
|
|
|
|
|
if existing_agent and existing_agent.password_hash:
|
|
|
|
|
|
if not body.password:
|
|
|
|
|
|
raise AppException(1011, "请输入本地密码")
|
|
|
|
|
|
if not bcrypt.checkpw(body.password.encode('utf-8'), existing_agent.password_hash.encode('utf-8')):
|
|
|
|
|
|
raise AppException(1011, "本地密码错误")
|
2026-06-14 16:49:18 +08:00
|
|
|
|
|
2026-06-14 19:32:36 +08:00
|
|
|
|
# P0-#5: 本地密码认证(企微验证失败时的备用认证)
|
|
|
|
|
|
# 检查是否需要本地密码验证
|
|
|
|
|
|
local_password_verified = False
|
|
|
|
|
|
if body.password and agent and agent.password_hash:
|
|
|
|
|
|
# 验证本地密码
|
2026-06-14 21:21:48 +08:00
|
|
|
|
if bcrypt.checkpw(body.password.encode('utf-8'), agent.password_hash.encode('utf-8')):
|
2026-06-14 19:32:36 +08:00
|
|
|
|
local_password_verified = True
|
|
|
|
|
|
logger.info(f"本地密码验证通过: user_id={body.user_id}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
# 本地密码错误,拒绝登录
|
|
|
|
|
|
raise AppException(1011, "本地密码错误")
|
|
|
|
|
|
|
2026-06-14 16:49:18 +08:00
|
|
|
|
# 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, # 复用 AgentLogin,otp_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)}")
|
2026-06-14 19:32:36 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
# 本地密码管理接口(P0-#5)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
class AgentPasswordUpdate(BaseModel):
|
|
|
|
|
|
"""坐席修改密码请求 Schema"""
|
|
|
|
|
|
old_password: Optional[str] = Field(None, description="旧密码(验证用)")
|
|
|
|
|
|
new_password: str = Field(..., min_length=6, max_length=128, description="新密码")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/agents/password")
|
|
|
|
|
|
async def update_agent_password(
|
|
|
|
|
|
body: AgentPasswordUpdate,
|
|
|
|
|
|
agent: Agent = Depends(get_current_agent),
|
|
|
|
|
|
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""修改坐席本地密码。
|
|
|
|
|
|
|
|
|
|
|
|
可选功能,允许坐席设置本地密码作为备用认证方式。
|
|
|
|
|
|
企微验证失败时,可使用本地密码认证。
|
|
|
|
|
|
|
|
|
|
|
|
P0-#5 新增端点。
|
|
|
|
|
|
|
|
|
|
|
|
Args:
|
|
|
|
|
|
body.new_password: 新密码(6-128位)
|
|
|
|
|
|
|
|
|
|
|
|
Returns:
|
|
|
|
|
|
Dict: 修改结果
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
# 如果已有旧密码,验证旧密码
|
|
|
|
|
|
if agent.password_hash:
|
|
|
|
|
|
if not body.old_password:
|
|
|
|
|
|
raise AppException(1012, "请输入旧密码")
|
2026-06-14 21:21:48 +08:00
|
|
|
|
if not bcrypt.checkpw(body.old_password.encode('utf-8'), agent.password_hash.encode('utf-8')):
|
2026-06-14 19:32:36 +08:00
|
|
|
|
raise AppException(1013, "旧密码错误")
|
|
|
|
|
|
|
|
|
|
|
|
# 设置新密码
|
2026-06-14 21:21:48 +08:00
|
|
|
|
agent.password_hash = bcrypt.hashpw(body.new_password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
|
2026-06-14 19:32:36 +08:00
|
|
|
|
agent.updated_at = datetime.now()
|
|
|
|
|
|
db.add(agent)
|
|
|
|
|
|
await db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(f"本地密码已更新: agent={agent.user_id}")
|
|
|
|
|
|
|
|
|
|
|
|
return success_response(data={"message": "密码已更新"})
|
|
|
|
|
|
|
|
|
|
|
|
except AppException:
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
logger.error(f"密码更新异常: {e}", exc_info=True)
|
|
|
|
|
|
raise AppException(1014, f"密码更新失败: {str(e)}")
|