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

689 lines
25 KiB
Python
Raw Normal View History

# =============================================================================
# 企微IT智能服务台 — 会话管理 API
# =============================================================================
# 说明:坐席端的会话管理接口,包括:
# 1. GET /api/conversations — 坐席获取会话列表(支持状态过滤、排序)
# 2. GET /api/conversations/{id} — 获取会话详情
# 3. POST /api/conversations/{id}/assign — 接单(坐席接入会话)
# 4. POST /api/conversations/{id}/resolve — 结单
# 5. POST /api/conversations/{id}/pin — 置顶/取消置顶
# 6. POST /api/conversations/{id}/todo — 代办/取消代办
# 7. POST /api/conversations/{id}/transfer — 转接
# =============================================================================
import logging
from datetime import datetime
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.agent import Agent
from app.schemas.conversation import (
ConversationAssign,
ConversationInvite,
ConversationListResponse,
ConversationResponse,
ConversationStatusUpdate,
InviteParticipantRequest,
JoinConversationRequest,
)
from app.services.session_service import SessionService
from app.services.wecom_service import WecomService
from app.utils.response import AppException, success_response
# 坐席认证依赖(从 agents.py 导入)
from app.api.agents import get_current_agent
logger = logging.getLogger(__name__)
# 创建路由器
router = APIRouter()
# --------------------------------------------------------------------------
# GET /api/conversations — 获取坐席会话列表(全局可见)
# --------------------------------------------------------------------------
@router.get("/conversations")
async def list_conversations(
status: Optional[str] = Query(None, description="按状态过滤: ai_handling/queued/serving/resolved"),
agent_id: Optional[str] = Query(None, description="按坐席ID过滤"),
page: int = Query(1, ge=1, description="页码(从1开始)"),
page_size: int = Query(50, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席获取会话列表(全局可见)。
返回所有活跃会话,每个会话增加字段:
- is_mine: 是否为当前坐席的会话
- assigned_agent_name: 分配的坐席姓名(其他坐席会话显示用)
- can_grab: 是否可以接手(其他坐席已接单的会话为 True)
排序规则:紧急→举手→需介入→活跃→已结单。
Args:
status: 按状态过滤(可选)
agent_id: 按坐席ID过滤(可选)
page: 页码
page_size: 每页数量
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含会话列表和总数
"""
session_service = SessionService(db)
conversations, total = await session_service.get_conversations(
status=status,
agent_id=agent_id,
page=page,
page_size=page_size,
)
# 批量查询所有涉及坐席的信息,避免 N+1 查询
# 收集所有需要查询姓名的坐席ID(主责坐席 + 协作坐席)
agent_ids_to_query = set()
for conv in conversations:
if conv.assigned_agent_id:
agent_ids_to_query.add(conv.assigned_agent_id)
for aid in (conv.collaborating_agent_ids or []):
agent_ids_to_query.add(aid)
# 一次性查询所有相关坐席姓名
agent_name_map: dict[str, str] = {}
if agent_ids_to_query:
stmt = select(Agent).where(Agent.user_id.in_(agent_ids_to_query))
result = await db.execute(stmt)
for agent in result.scalars().all():
agent_name_map[agent.user_id] = agent.name
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab 字段
items = []
for conv in conversations:
conv_data = ConversationResponse.model_validate(conv).model_dump()
# 是否为当前坐席的会话
conv_data["is_mine"] = conv.assigned_agent_id == current_agent.user_id
# 坐席姓名(从批量查询结果中获取)
conv_data["assigned_agent_name"] = agent_name_map.get(conv.assigned_agent_id) if conv.assigned_agent_id else None
# 是否可以接手:其他坐席已接单(assigned 且不是自己的)
conv_data["can_grab"] = (
conv.assigned_agent_id is not None
and conv.assigned_agent_id != current_agent.user_id
and conv.status == "serving"
)
# ----- 多坐席协作扩展字段 -----
# 协作坐席ID列表
collab_ids = conv.collaborating_agent_ids or []
conv_data["collaborating_agent_ids"] = collab_ids
# 协作坐席姓名映射
conv_data["collaborating_agent_names"] = {
aid: agent_name_map.get(aid, "未知") for aid in collab_ids
}
# 是否为协作坐席(在协作列表中但不是主责坐席)
conv_data["is_collaborator"] = (
current_agent.user_id in collab_ids
and conv.assigned_agent_id != current_agent.user_id
)
items.append(conv_data)
return success_response(
data={
"items": items,
"total": total,
}
)
# --------------------------------------------------------------------------
# GET /api/conversations/{id} — 获取会话详情
# --------------------------------------------------------------------------
@router.get("/conversations/{conversation_id}")
async def get_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""获取会话详情。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含会话详情
"""
session_service = SessionService(db)
conversation = await session_service.get_conversation(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/assign — 坐席接单
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/assign")
async def assign_conversation(
conversation_id: str,
body: ConversationAssign,
db: AsyncSession = Depends(get_db),
):
"""坐席接单(接入会话)。
坐席点击"接单"按钮时调用,将会话状态从 queued 改为 serving。
Args:
conversation_id: 会话ID
body: 接单请求体(包含 agent_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
# 创建企微服务实例用于发送接入通知
redis_client = None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
session_service = SessionService(db, wecom_service=wecom_service)
except Exception:
logger.warning("创建企微服务失败,接入通知将不发送")
session_service = SessionService(db)
conversation = await session_service.assign_agent(
conversation_id=conversation_id,
agent_id=body.agent_id,
)
# 关闭企微服务连接
if redis_client:
try:
await session_service.wecom_service.close()
await redis_client.close()
except Exception:
pass
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/resolve — 结单
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/resolve")
async def resolve_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""结单。
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
权限控制:只有主责坐席(assigned_agent_id)才能结单。
协作坐席和其他坐席不能结单。
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
# 先查询会话,验证主责坐席身份
from sqlalchemy import select as _select
from app.models.conversation import Conversation as _Conversation
stmt = _select(_Conversation).where(_Conversation.id == conversation_id)
result = await db.execute(stmt)
conv = result.scalars().first()
if not conv:
raise AppException(3003, "会话不存在")
if conv.assigned_agent_id != current_agent.user_id:
raise AppException(3027, "只有主责坐席才能结单")
conversation = await session_service.resolve_conversation(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/pin — 置顶/取消置顶
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/pin")
async def toggle_pin(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""切换会话置顶状态。
每次调用切换当前状态:置顶→取消置顶,取消置顶→置顶。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.toggle_pin(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/todo — 代办/取消代办
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/todo")
async def toggle_todo(
conversation_id: str,
db: AsyncSession = Depends(get_db),
):
"""切换会话代办状态。
每次调用切换当前状态:代办→取消代办,取消代办→代办。
Args:
conversation_id: 会话ID
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.toggle_todo(conversation_id)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/transfer — 转接
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/transfer")
async def transfer_conversation(
conversation_id: str,
body: ConversationAssign,
db: AsyncSession = Depends(get_db),
):
"""转接会话到另一个坐席。
第一步简化版:只更换坐席,不做转接通知。
Args:
conversation_id: 会话ID
body: 转接请求体(包含 target agent_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.transfer_conversation(
conversation_id=conversation_id,
target_agent_id=body.agent_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/grab — 接手会话(抢单)
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/grab")
async def grab_conversation(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""接手其他坐席的会话(抢单)。
接手后原坐席自动释放,会话 assigned_agent_id 切换为当前坐席。
验证规则:
1. 会话必须已分配给其他坐席(不能接手自己的,不能接手未分配的)
2. 当前坐席未满负荷
3. 会话状态为 serving
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含接手后的会话信息
"""
# 1. 查找目标会话
session_service = SessionService(db)
conversation = await session_service.get_conversation(conversation_id)
# 2. 校验:会话必须已分配给其他坐席
if not conversation.assigned_agent_id:
raise AppException(3011, "该会话尚未分配坐席,请使用接单功能")
if conversation.assigned_agent_id == current_agent.user_id:
raise AppException(3012, "不能接手自己的会话")
if conversation.status == "resolved":
raise AppException(3002, "会话已结单")
if conversation.status != "serving":
raise AppException(3013, f"只能接手服务中的会话,当前状态: {conversation.status}")
# 3. 校验当前坐席未满负荷
# 刷新坐席数据(current_agent 可能是缓存的旧数据)
stmt = select(Agent).where(Agent.user_id == current_agent.user_id)
result = await db.execute(stmt)
fresh_agent = result.scalars().first()
if fresh_agent and fresh_agent.current_load >= fresh_agent.max_load:
raise AppException(3005, "您已满负荷,无法接手更多会话")
# 4. 原坐席 current_load 减 1
old_agent_id = conversation.assigned_agent_id
stmt = select(Agent).where(Agent.user_id == old_agent_id)
result = await db.execute(stmt)
old_agent = result.scalars().first()
if old_agent and old_agent.current_load > 0:
old_agent.current_load -= 1
db.add(old_agent)
# 5. 更新会话 assigned_agent_id 为当前坐席
conversation.assigned_agent_id = current_agent.user_id
conversation.updated_at = datetime.now()
db.add(conversation)
# 6. 当前坐席 current_load 加 1
if fresh_agent:
fresh_agent.current_load += 1
db.add(fresh_agent)
await db.flush()
logger.info(
f"会话接手: conv_id={conversation_id}, "
f"from={old_agent_id} to={current_agent.user_id}"
)
# 7. WS 广播 conversation_updated 事件(原坐席和当前坐席都能收到)
from app.services.ws_manager import manager as ws_manager
try:
await ws_manager.broadcast({
"type": "conversation_updated",
"data": {
"conversation_id": str(conversation.id),
"status": conversation.status,
"assigned_agent_id": conversation.assigned_agent_id,
"old_agent_id": old_agent_id,
"new_agent_id": current_agent.user_id,
}
})
except Exception as e:
logger.warning(f"WebSocket广播失败: {e}")
# 8. 返回接手成功的会话信息
response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["is_mine"] = True
response_data["assigned_agent_name"] = current_agent.name
response_data["can_grab"] = False
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/invite — 摇人(邀请坐席协作)
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/invite")
async def invite_collaborator(
conversation_id: str,
body: ConversationInvite,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席A邀请坐席B加入会话协作。
校验规则:
1. 当前坐席必须是主责坐席或已加入的协作坐席
2. 被邀请坐席存在且在线
3. 被邀请坐席不是主责坐席,也不在协作列表中(防止重复邀请)
4. 会话必须为 serving(已结单的不能摇人)
副作用:
- WebSocket 推送给被邀请坐席(collaborator_invited 定向通知)
- WebSocket 广播给所有坐席(collaborator_joined 刷新列表)
Args:
conversation_id: 会话ID
body: 邀请请求(含 agent_id
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.invite_collaborator(
conversation_id=conversation_id,
inviter_agent_id=current_agent.user_id,
invitee_agent_id=body.agent_id,
)
# 构建响应
response_data = ConversationResponse.model_validate(conversation).model_dump()
response_data["is_mine"] = conversation.assigned_agent_id == current_agent.user_id
response_data["is_collaborator"] = False # 邀请人自己不是被邀请的协作坐席
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/leave — 退出协作
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/leave")
async def leave_collaboration(
conversation_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席退出协作。
校验规则:
1. 当前坐席必须在协作列表中
2. 当前坐席不能是主责坐席(主责坐席不能"退出",只能转接或结单)
副作用:
- WebSocket 广播给所有坐席(collaborator_left 刷新列表)
Args:
conversation_id: 会话ID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.leave_collaboration(
conversation_id=conversation_id,
agent_id=current_agent.user_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# =============================================================================
# 邀请功能 APIP0-09~P0-11
# =============================================================================
# 和「摇人」的区别:
# 摇人 (invite) = 坐席 → 坐席协作(collaborating_agent_ids
# 邀请 (invite-participant) = 坐席 → 任意员工/部门(participants
# =============================================================================
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/invite-participant — 邀请员工/部门加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/invite-participant")
async def invite_participant(
conversation_id: str,
body: InviteParticipantRequest,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""坐席邀请员工/部门加入会话(P0-09 邀请发起)。
权限:只有主责坐席可以发起邀请。
副作用:
- 向被邀请人发送企微卡片通知(含「加入会话」按钮)
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 邀请请求(含被邀请人列表 + 历史共享模式)
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
# 创建企微服务实例(发送卡片通知用)
redis_client = None
try:
import redis.asyncio as aioredis
from app.config import settings
redis_client = settings.create_redis_client()
wecom_service = WecomService(redis_client)
session_service = SessionService(db, wecom_service=wecom_service)
except Exception:
logger.warning("创建企微服务失败,邀请通知将不发送")
session_service = SessionService(db)
conversation = await session_service.invite_participants(
conversation_id=conversation_id,
inviter_agent_id=current_agent.user_id,
participants=[p.model_dump() for p in body.participants],
history_mode=body.history_mode,
)
# 关闭连接
if redis_client:
try:
await session_service.wecom_service.close()
await redis_client.close()
except Exception:
pass
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/join — 被邀请人加入会话
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/join")
async def join_conversation(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""被邀请人通过链接加入会话(P0-10 加入会话)。
校验:该员工必须在 participants 列表中(被邀请过才能加入)。
副作用:
- 更新参与者的 joined 状态
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 加入请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.join_conversation(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# DELETE /api/conversations/{id}/participants/{user_id} — 移除参与者
# --------------------------------------------------------------------------
@router.delete("/conversations/{conversation_id}/participants/{user_id}")
async def remove_participant(
conversation_id: str,
user_id: str,
db: AsyncSession = Depends(get_db),
current_agent: Agent = Depends(get_current_agent),
):
"""移除参与者(P0-11 参与者管理)。
权限:只有主责坐席可以移除参与者。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
user_id: 被移除的员工UserID
db: 数据库会话
current_agent: 当前坐席(认证依赖注入)
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.remove_participant(
conversation_id=conversation_id,
remover_agent_id=current_agent.user_id,
target_user_id=user_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)
# --------------------------------------------------------------------------
# POST /api/conversations/{id}/leave-participant — 参与者主动退出
# --------------------------------------------------------------------------
@router.post("/conversations/{conversation_id}/leave-participant")
async def leave_as_participant(
conversation_id: str,
body: JoinConversationRequest,
db: AsyncSession = Depends(get_db),
):
"""参与者主动退出会话。
副作用:
- 在会话中创建系统消息
- WebSocket 广播参与者变更
Args:
conversation_id: 会话ID
body: 退出请求(含 employee_id
db: 数据库会话
Returns:
Dict: 统一响应格式,包含更新后的会话信息
"""
session_service = SessionService(db)
conversation = await session_service.leave_as_participant(
conversation_id=conversation_id,
employee_id=body.employee_id,
)
response_data = ConversationResponse.model_validate(conversation).model_dump()
return success_response(data=response_data)