1235 lines
45 KiB
Python
1235 lines
45 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微IT智能服务台 — 会话状态管理服务
|
|||
|
|
# =============================================================================
|
|||
|
|
# 说明:管理会话的完整生命周期,包括:
|
|||
|
|
# 1. 创建会话(新员工发消息时自动创建)
|
|||
|
|
# 2. 更新会话状态(queued → serving → resolved)
|
|||
|
|
# 3. 分配坐席
|
|||
|
|
# 4. 结单
|
|||
|
|
# 5. 获取会话列表(支持排序:紧急→举手→需介入→活跃→已结单)
|
|||
|
|
# 6. 获取坐席当前服务的会话列表
|
|||
|
|
# =============================================================================
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import List, Optional
|
|||
|
|
from uuid import UUID
|
|||
|
|
|
|||
|
|
from sqlalchemy import and_, case, desc, func, select
|
|||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|||
|
|
|
|||
|
|
from app.models.agent import Agent
|
|||
|
|
from app.models.conversation import Conversation
|
|||
|
|
from app.services.wecom_service import WecomService
|
|||
|
|
from app.utils.response import (
|
|||
|
|
AppException,
|
|||
|
|
ERR_AGENT_BUSY,
|
|||
|
|
ERR_AGENT_NOT_FOUND,
|
|||
|
|
ERR_CONVERSATION_NOT_FOUND,
|
|||
|
|
ERR_CONVERSATION_RESOLVED,
|
|||
|
|
ERR_DUPLICATE_ASSIGN,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SessionService:
|
|||
|
|
"""会话状态管理服务。
|
|||
|
|
|
|||
|
|
管理会话的完整生命周期,实现会话状态流转和坐席分配逻辑。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def __init__(
|
|||
|
|
self,
|
|||
|
|
db: AsyncSession,
|
|||
|
|
wecom_service: Optional[WecomService] = None,
|
|||
|
|
):
|
|||
|
|
"""初始化会话状态管理服务。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
db: 异步数据库会话
|
|||
|
|
wecom_service: 企微 API 服务(用于坐席接入时发送通知,可选)
|
|||
|
|
"""
|
|||
|
|
self.db = db
|
|||
|
|
self.wecom_service = wecom_service
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 创建会话
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def create_conversation(
|
|||
|
|
self,
|
|||
|
|
employee_id: str,
|
|||
|
|
employee_name: str = "",
|
|||
|
|
department: str = "",
|
|||
|
|
position: str = "",
|
|||
|
|
level: str = "",
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""创建新会话。
|
|||
|
|
|
|||
|
|
当员工首次发消息或摇人时自动创建。
|
|||
|
|
新会话默认状态为 queued(排队等坐席)。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
employee_id: 企微员工 UserID
|
|||
|
|
employee_name: 员工姓名
|
|||
|
|
department: 部门
|
|||
|
|
position: 岗位
|
|||
|
|
level: 等级
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 新创建的会话对象
|
|||
|
|
"""
|
|||
|
|
conversation = Conversation(
|
|||
|
|
employee_id=employee_id,
|
|||
|
|
employee_name=employee_name,
|
|||
|
|
department=department,
|
|||
|
|
position=position,
|
|||
|
|
level=level,
|
|||
|
|
status="queued",
|
|||
|
|
is_vip=False,
|
|||
|
|
is_pinned=False,
|
|||
|
|
is_todo=False,
|
|||
|
|
urgency_score=1,
|
|||
|
|
tags={},
|
|||
|
|
)
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(f"创建会话: conv_id={conversation.id}, employee={employee_id}")
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 更新会话状态
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def update_status(
|
|||
|
|
self, conversation_id: UUID, new_status: str
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""更新会话状态。
|
|||
|
|
|
|||
|
|
状态流转规则:
|
|||
|
|
- queued → serving: 坐席接单
|
|||
|
|
- serving → resolved: 结单
|
|||
|
|
- queued → resolved: 直接结单(排队中员工问题已自行解决)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
new_status: 新状态(queued/serving/resolved/ai_handling)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 会话不存在或状态流转不合法
|
|||
|
|
"""
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# 校验状态流转合法性
|
|||
|
|
valid_transitions = {
|
|||
|
|
"queued": ["serving", "resolved"],
|
|||
|
|
"serving": ["resolved"],
|
|||
|
|
"ai_handling": ["queued", "serving", "resolved"],
|
|||
|
|
"resolved": [], # 已结单不能再改状态
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
allowed = valid_transitions.get(conversation.status, [])
|
|||
|
|
if new_status not in allowed and new_status != conversation.status:
|
|||
|
|
raise AppException(
|
|||
|
|
3010,
|
|||
|
|
f"会话状态流转不合法: {conversation.status} → {new_status}",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 如果是已结单,不能再改状态
|
|||
|
|
if conversation.status == "resolved":
|
|||
|
|
raise ERR_CONVERSATION_RESOLVED
|
|||
|
|
|
|||
|
|
conversation.status = new_status
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"会话状态更新: conv_id={conversation_id}, "
|
|||
|
|
f"{conversation.status} → {new_status}"
|
|||
|
|
)
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 分配坐席(接单)
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def assign_agent(
|
|||
|
|
self, conversation_id: UUID, agent_id: str
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""分配坐席接入会话。
|
|||
|
|
|
|||
|
|
流程:
|
|||
|
|
1. 校验会话存在且未结单
|
|||
|
|
2. 校验坐席存在且在线
|
|||
|
|
3. 校验坐席未满负荷
|
|||
|
|
4. 更新会话状态为 serving
|
|||
|
|
5. 更新坐席当前服务数 +1
|
|||
|
|
6. 通过企微 API 向员工发送接入通知
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
agent_id: 坐席ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 校验不通过
|
|||
|
|
"""
|
|||
|
|
# 1. 校验会话
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
if conversation.status == "resolved":
|
|||
|
|
raise ERR_CONVERSATION_RESOLVED
|
|||
|
|
if conversation.assigned_agent_id and conversation.status == "serving":
|
|||
|
|
raise ERR_DUPLICATE_ASSIGN
|
|||
|
|
|
|||
|
|
# 2. 校验坐席(本地开发模式:自动创建不存在的坐席)
|
|||
|
|
stmt = select(Agent).where(Agent.user_id == agent_id)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
agent = result.scalars().first()
|
|||
|
|
|
|||
|
|
if not agent:
|
|||
|
|
# DEV MODE: 本地开发时自动创建坐席,避免必须先登录才能测试接单
|
|||
|
|
logger.warning(f"坐席不存在,自动创建: user_id={agent_id}")
|
|||
|
|
agent = Agent(
|
|||
|
|
user_id=agent_id,
|
|||
|
|
name="未知坐席",
|
|||
|
|
status="online",
|
|||
|
|
current_load=0,
|
|||
|
|
max_load=5,
|
|||
|
|
)
|
|||
|
|
self.db.add(agent)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
if agent.status == "offline":
|
|||
|
|
raise AppException(3007, "坐席不在线,无法接单")
|
|||
|
|
if agent.current_load >= agent.max_load:
|
|||
|
|
raise ERR_AGENT_BUSY
|
|||
|
|
|
|||
|
|
# 3. 更新会话
|
|||
|
|
conversation.status = "serving"
|
|||
|
|
conversation.assigned_agent_id = agent_id
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
|
|||
|
|
# 4. 更新坐席服务数
|
|||
|
|
agent.current_load += 1
|
|||
|
|
self.db.add(agent)
|
|||
|
|
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
# 5. 发送接入通知给员工
|
|||
|
|
if self.wecom_service:
|
|||
|
|
try:
|
|||
|
|
await self.wecom_service.send_text_message(
|
|||
|
|
conversation.employee_id,
|
|||
|
|
"人摇来了!IT坐席为您服务",
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"发送接入通知失败(不阻塞流程): {e}")
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"坐席接单: conv_id={conversation_id}, agent={agent_id}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 广播 WebSocket 事件:会话状态变更
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 做什么:通知所有在线坐席,有会话被接单了
|
|||
|
|
# 为什么:其他坐席需要实时看到该会话从"排队"变为"服务中"
|
|||
|
|
# 用 try/except 包裹:广播失败不能阻塞接单主流程
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"WebSocket广播失败: {e}")
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 结单
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def resolve_conversation(
|
|||
|
|
self, conversation_id: UUID, agent_id: Optional[str] = None
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""结单。
|
|||
|
|
|
|||
|
|
流程:
|
|||
|
|
1. 校验会话存在
|
|||
|
|
2. 更新会话状态为 resolved
|
|||
|
|
3. 如果有坐席,更新坐席当前服务数 -1
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
agent_id: 坐席ID(可选,用于更新坐席服务数)
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
"""
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
if conversation.status == "resolved":
|
|||
|
|
raise ERR_CONVERSATION_RESOLVED
|
|||
|
|
|
|||
|
|
# 更新会话状态
|
|||
|
|
conversation.status = "resolved"
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
|
|||
|
|
# 更新坐席服务数
|
|||
|
|
assigned_agent_id = agent_id or conversation.assigned_agent_id
|
|||
|
|
if assigned_agent_id:
|
|||
|
|
stmt = select(Agent).where(Agent.user_id == assigned_agent_id)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
agent = result.scalars().first()
|
|||
|
|
if agent and agent.current_load > 0:
|
|||
|
|
agent.current_load -= 1
|
|||
|
|
self.db.add(agent)
|
|||
|
|
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(f"会话结单: conv_id={conversation_id}")
|
|||
|
|
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 广播 WebSocket 事件:会话已结单
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 做什么:通知所有在线坐席,有会话已结单
|
|||
|
|
# 为什么:坐席需要实时看到该会话从"服务中"变为"已结单"
|
|||
|
|
# 用 try/except 包裹:广播失败不能阻塞结单主流程
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"WebSocket广播失败: {e}")
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 置顶/取消置顶
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def toggle_pin(self, conversation_id: UUID) -> Conversation:
|
|||
|
|
"""切换会话置顶状态。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
"""
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
conversation.is_pinned = not conversation.is_pinned
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"会话置顶{'开启' if conversation.is_pinned else '取消'}: "
|
|||
|
|
f"conv_id={conversation_id}"
|
|||
|
|
)
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 代办/取消代办
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def toggle_todo(self, conversation_id: UUID) -> Conversation:
|
|||
|
|
"""切换会话代办状态。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
"""
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
conversation.is_todo = not conversation.is_todo
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"会话代办{'开启' if conversation.is_todo else '取消'}: "
|
|||
|
|
f"conv_id={conversation_id}"
|
|||
|
|
)
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 摇人 — 邀请坐席加入协作
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def invite_collaborator(
|
|||
|
|
self,
|
|||
|
|
conversation_id: UUID,
|
|||
|
|
inviter_agent_id: str,
|
|||
|
|
invitee_agent_id: str,
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""邀请坐席加入协作。
|
|||
|
|
|
|||
|
|
做什么:坐席A在处理会话时发现需要坐席B的专业知识,点击「摇人」
|
|||
|
|
将坐席B加入 collaborating_agent_ids 列表,坐席B可查看和回复但不能结单。
|
|||
|
|
|
|||
|
|
校验规则:
|
|||
|
|
1. 会话存在且为 serving(已结单的不能摇人)
|
|||
|
|
2. 邀请人在主责坐席或协作坐席列表中
|
|||
|
|
3. 被邀请人不能是主责坐席,也不能已在协作列表中(防重复)
|
|||
|
|
4. 被邀请坐席存在且在线
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
inviter_agent_id: 邀请人坐席ID
|
|||
|
|
invitee_agent_id: 被邀请坐席ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 校验不通过
|
|||
|
|
"""
|
|||
|
|
# 1. 校验会话
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
if conversation.status == "resolved":
|
|||
|
|
raise ERR_CONVERSATION_RESOLVED
|
|||
|
|
if conversation.status != "serving":
|
|||
|
|
raise AppException(3020, "只能邀请协作服务中的会话")
|
|||
|
|
|
|||
|
|
# 2. 校验邀请人权限:必须是主责坐席或已在协作列表中
|
|||
|
|
is_owner = conversation.assigned_agent_id == inviter_agent_id
|
|||
|
|
is_collaborator = inviter_agent_id in (conversation.collaborating_agent_ids or [])
|
|||
|
|
if not is_owner and not is_collaborator:
|
|||
|
|
raise AppException(3021, "只有主责坐席或协作坐席才能摇人")
|
|||
|
|
|
|||
|
|
# 3. 校验被邀请人:不能是主责坐席
|
|||
|
|
if conversation.assigned_agent_id == invitee_agent_id:
|
|||
|
|
raise AppException(3022, "不能邀请主责坐席协作(他已经在处理了)")
|
|||
|
|
|
|||
|
|
# 4. 校验被邀请人:不能已在协作列表中
|
|||
|
|
if invitee_agent_id in (conversation.collaborating_agent_ids or []):
|
|||
|
|
raise AppException(3023, "该坐席已在协作中,无需重复邀请")
|
|||
|
|
|
|||
|
|
# 5. 校验被邀请坐席存在且在线
|
|||
|
|
stmt = select(Agent).where(Agent.user_id == invitee_agent_id)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
invitee = result.scalars().first()
|
|||
|
|
if not invitee:
|
|||
|
|
raise ERR_AGENT_NOT_FOUND
|
|||
|
|
if invitee.status == "offline":
|
|||
|
|
raise AppException(3024, "被邀请坐席不在线")
|
|||
|
|
|
|||
|
|
# 6. 将被邀请人加入协作列表
|
|||
|
|
collab_ids = list(conversation.collaborating_agent_ids or [])
|
|||
|
|
collab_ids.append(invitee_agent_id)
|
|||
|
|
conversation.collaborating_agent_ids = collab_ids
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"摇人成功: conv_id={conversation_id}, "
|
|||
|
|
f"inviter={inviter_agent_id}, invitee={invitee_agent_id}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 广播 + 定向推送 WebSocket 事件
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 做什么:
|
|||
|
|
# 1. broadcast — 所有坐席看到协作关系变化(刷新会话列表)
|
|||
|
|
# 2. send_to_agent — 被邀请人收到定向通知(弹窗提示)
|
|||
|
|
# 用 try/except 包裹:推送失败不能阻塞主流程
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
from app.services.ws_manager import manager as ws_manager
|
|||
|
|
inviter_name = inviter_agent_id # 前端会从 agent_name_map 获取真实姓名
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 广播:会话协作关系变更(所有坐席刷新列表)
|
|||
|
|
await ws_manager.broadcast({
|
|||
|
|
"type": "collaborator_joined",
|
|||
|
|
"data": {
|
|||
|
|
"conversation_id": str(conversation.id),
|
|||
|
|
"agent_id": invitee_agent_id,
|
|||
|
|
"inviter_agent_id": inviter_agent_id,
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
# 定向推送:通知被邀请人
|
|||
|
|
await ws_manager.send_to_agent(invitee_agent_id, {
|
|||
|
|
"type": "collaborator_invited",
|
|||
|
|
"data": {
|
|||
|
|
"conversation_id": str(conversation.id),
|
|||
|
|
"inviter_agent_id": inviter_agent_id,
|
|||
|
|
"invitee_agent_id": invitee_agent_id,
|
|||
|
|
"employee_name": conversation.employee_name,
|
|||
|
|
"last_message_summary": conversation.last_message_summary or "",
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"WebSocket推送失败(不阻塞流程): {e}")
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 摇人 — 退出协作
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def leave_collaboration(
|
|||
|
|
self,
|
|||
|
|
conversation_id: UUID,
|
|||
|
|
agent_id: str,
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""坐席退出协作。
|
|||
|
|
|
|||
|
|
做什么:将坐席从 collaborating_agent_ids 中移除。
|
|||
|
|
|
|||
|
|
校验规则:
|
|||
|
|
1. 坐席必须在协作列表中
|
|||
|
|
2. 坐席不能是主责坐席(主责坐席不能"退出",只能转接或结单)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
agent_id: 退出协作的坐席ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 校验不通过
|
|||
|
|
"""
|
|||
|
|
# 1. 校验会话
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# 2. 校验:不能是主责坐席
|
|||
|
|
if conversation.assigned_agent_id == agent_id:
|
|||
|
|
raise AppException(3025, "主责坐席不能退出协作,请使用转接或结单")
|
|||
|
|
|
|||
|
|
# 3. 校验:必须在协作列表中
|
|||
|
|
collab_ids = list(conversation.collaborating_agent_ids or [])
|
|||
|
|
if agent_id not in collab_ids:
|
|||
|
|
raise AppException(3026, "您不在该会话的协作列表中")
|
|||
|
|
|
|||
|
|
# 4. 移除
|
|||
|
|
collab_ids.remove(agent_id)
|
|||
|
|
conversation.collaborating_agent_ids = collab_ids
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(f"退出协作: conv_id={conversation_id}, agent={agent_id}")
|
|||
|
|
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 广播 WebSocket 事件:协作坐席退出
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
from app.services.ws_manager import manager as ws_manager
|
|||
|
|
try:
|
|||
|
|
await ws_manager.broadcast({
|
|||
|
|
"type": "collaborator_left",
|
|||
|
|
"data": {
|
|||
|
|
"conversation_id": str(conversation.id),
|
|||
|
|
"agent_id": agent_id,
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"WebSocket推送失败(不阻塞流程): {e}")
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 转接会话
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def transfer_conversation(
|
|||
|
|
self, conversation_id: UUID, target_agent_id: str
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""转接会话到另一个坐席。
|
|||
|
|
|
|||
|
|
第一步简化版:只更换坐席,不做转接通知。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
target_agent_id: 目标坐席ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
"""
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# 校验目标坐席
|
|||
|
|
stmt = select(Agent).where(Agent.user_id == target_agent_id)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
target_agent = result.scalars().first()
|
|||
|
|
|
|||
|
|
if not target_agent:
|
|||
|
|
raise ERR_AGENT_NOT_FOUND
|
|||
|
|
if target_agent.current_load >= target_agent.max_load:
|
|||
|
|
raise ERR_AGENT_BUSY
|
|||
|
|
|
|||
|
|
# 旧坐席服务数 -1
|
|||
|
|
old_agent_id = conversation.assigned_agent_id
|
|||
|
|
if old_agent_id:
|
|||
|
|
stmt = select(Agent).where(Agent.user_id == old_agent_id)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
old_agent = result.scalars().first()
|
|||
|
|
if old_agent and old_agent.current_load > 0:
|
|||
|
|
old_agent.current_load -= 1
|
|||
|
|
self.db.add(old_agent)
|
|||
|
|
|
|||
|
|
# 新坐席服务数 +1
|
|||
|
|
target_agent.current_load += 1
|
|||
|
|
self.db.add(target_agent)
|
|||
|
|
|
|||
|
|
# 更新会话
|
|||
|
|
conversation.assigned_agent_id = target_agent_id
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"会话转接: conv_id={conversation_id}, "
|
|||
|
|
f"from={old_agent_id} to={target_agent_id}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 广播 WebSocket 事件:会话转接
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
# 做什么:通知所有在线坐席,有会话被转接了
|
|||
|
|
# 为什么:原坐席和目标坐席都需要实时看到会话归属变化
|
|||
|
|
# 用 try/except 包裹:广播失败不能阻塞转接主流程
|
|||
|
|
# ----------------------------------------------------------------------
|
|||
|
|
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,
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"WebSocket广播失败: {e}")
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 获取会话列表(坐席端)
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def get_conversations(
|
|||
|
|
self,
|
|||
|
|
status: Optional[str] = None,
|
|||
|
|
agent_id: Optional[str] = None,
|
|||
|
|
page: int = 1,
|
|||
|
|
page_size: int = 50,
|
|||
|
|
) -> tuple[List[Conversation], int]:
|
|||
|
|
"""获取会话列表,支持过滤和排序。
|
|||
|
|
|
|||
|
|
排序规则(PRD 定义):
|
|||
|
|
紧急 → 举手 → 需介入 → 活跃 → 已结单
|
|||
|
|
同级别按 last_message_at 倒序
|
|||
|
|
|
|||
|
|
实现方式:先按数据库基础排序(状态+置顶+紧急度),
|
|||
|
|
再在 Python 侧按完整规则精细排序(含 JSON tags 字段)。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
status: 按状态过滤(可选)
|
|||
|
|
agent_id: 按坐席ID过滤(可选,查看某坐席的会话)
|
|||
|
|
page: 页码(从1开始)
|
|||
|
|
page_size: 每页数量
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
tuple[List[Conversation], int]: (会话列表, 总数)
|
|||
|
|
"""
|
|||
|
|
# 构建查询条件
|
|||
|
|
conditions = []
|
|||
|
|
if status:
|
|||
|
|
conditions.append(Conversation.status == status)
|
|||
|
|
if agent_id:
|
|||
|
|
conditions.append(Conversation.assigned_agent_id == agent_id)
|
|||
|
|
|
|||
|
|
# 查询总数
|
|||
|
|
count_stmt = select(func.count(Conversation.id))
|
|||
|
|
if conditions:
|
|||
|
|
count_stmt = count_stmt.where(and_(*conditions))
|
|||
|
|
total_result = await self.db.execute(count_stmt)
|
|||
|
|
total = total_result.scalar() or 0
|
|||
|
|
|
|||
|
|
# 数据库侧基础排序(快速过滤):
|
|||
|
|
# 置顶 > 紧急度5 > 紧急度4 > 紧急度3 > 状态排序 > 最后消息时间
|
|||
|
|
# JSON tags 字段的排序在 Python 侧完成(SQLite 不支持 JSON 操作符)
|
|||
|
|
db_order_weight = case(
|
|||
|
|
(Conversation.is_pinned == True, 1000),
|
|||
|
|
(Conversation.urgency_score >= 5, 900),
|
|||
|
|
(Conversation.urgency_score >= 4, 600),
|
|||
|
|
(Conversation.urgency_score >= 3, 300),
|
|||
|
|
(Conversation.status == "queued", 200),
|
|||
|
|
(Conversation.status == "ai_handling", 150),
|
|||
|
|
(Conversation.status == "serving", 100),
|
|||
|
|
else_=0,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
stmt = select(Conversation)
|
|||
|
|
if conditions:
|
|||
|
|
stmt = stmt.where(and_(*conditions))
|
|||
|
|
# 数据库侧先按基础权重 + 最后消息时间排序
|
|||
|
|
stmt = stmt.order_by(desc(db_order_weight), desc(Conversation.last_message_at))
|
|||
|
|
|
|||
|
|
# 查询所有符合条件的会话(数据量不大时可行;生产环境建议改用 PostgreSQL + JSONB 操作符)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
all_conversations = list(result.scalars().all())
|
|||
|
|
|
|||
|
|
# ===== Python 侧精细排序(支持 JSON tags 字段)=====
|
|||
|
|
def _sort_key(conv: Conversation):
|
|||
|
|
"""计算完整排序权重(数值越大越靠前)"""
|
|||
|
|
weight = 0
|
|||
|
|
tags = conv.tags or {}
|
|||
|
|
|
|||
|
|
# 置顶(最高优先级)
|
|||
|
|
if conv.is_pinned:
|
|||
|
|
weight += 10000
|
|||
|
|
|
|||
|
|
# 紧急度评分(越高越靠前)
|
|||
|
|
urgency = conv.urgency_score or 0
|
|||
|
|
if urgency >= 5:
|
|||
|
|
weight += 9000
|
|||
|
|
elif urgency >= 4:
|
|||
|
|
weight += 6000
|
|||
|
|
elif urgency >= 3:
|
|||
|
|
weight += 3000
|
|||
|
|
|
|||
|
|
# 举手标记
|
|||
|
|
if tags.get("hand_raise"):
|
|||
|
|
weight += 8000
|
|||
|
|
|
|||
|
|
# 需介入标记
|
|||
|
|
if tags.get("need_intervene"):
|
|||
|
|
weight += 7000
|
|||
|
|
|
|||
|
|
# 情绪标记(非 neutral)
|
|||
|
|
emotion = tags.get("emotion", "neutral")
|
|||
|
|
if emotion and emotion != "neutral":
|
|||
|
|
weight += 5000
|
|||
|
|
|
|||
|
|
# 状态排序
|
|||
|
|
status_order = {
|
|||
|
|
"queued": 2000,
|
|||
|
|
"ai_handling": 1500,
|
|||
|
|
"serving": 1000,
|
|||
|
|
"resolved": 0,
|
|||
|
|
}
|
|||
|
|
weight += status_order.get(conv.status, 0)
|
|||
|
|
|
|||
|
|
# 最后消息时间(时间戳越大越靠前,除以 1e6 归一化到合理范围)
|
|||
|
|
import time
|
|||
|
|
if conv.last_message_at:
|
|||
|
|
ts = conv.last_message_at.timestamp()
|
|||
|
|
else:
|
|||
|
|
ts = 0
|
|||
|
|
# 用 (weight, ts) 元组排序:先按 weight 降序,再按 ts 降序
|
|||
|
|
return (weight, ts)
|
|||
|
|
|
|||
|
|
# 按完整规则排序(降序)
|
|||
|
|
all_conversations.sort(key=_sort_key, reverse=True)
|
|||
|
|
|
|||
|
|
# 分页
|
|||
|
|
offset = (page - 1) * page_size
|
|||
|
|
conversations = all_conversations[offset:offset + page_size]
|
|||
|
|
|
|||
|
|
return conversations, total
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 获取坐席当前服务的会话列表
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def get_agent_conversations(
|
|||
|
|
self, agent_id: str
|
|||
|
|
) -> List[Conversation]:
|
|||
|
|
"""获取坐席当前正在服务的会话列表。
|
|||
|
|
|
|||
|
|
只返回状态为 serving 且分配给该坐席的会话。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
agent_id: 坐席ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
List[Conversation]: 会话列表
|
|||
|
|
"""
|
|||
|
|
stmt = (
|
|||
|
|
select(Conversation)
|
|||
|
|
.where(
|
|||
|
|
Conversation.assigned_agent_id == agent_id,
|
|||
|
|
Conversation.status == "serving",
|
|||
|
|
)
|
|||
|
|
.order_by(desc(Conversation.last_message_at))
|
|||
|
|
)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
return list(result.scalars().all())
|
|||
|
|
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
# 获取会话详情
|
|||
|
|
# --------------------------------------------------------------------------
|
|||
|
|
async def _get_conversation(self, conversation_id: UUID) -> Conversation:
|
|||
|
|
"""获取会话对象,不存在则抛异常。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 会话不存在
|
|||
|
|
"""
|
|||
|
|
# 将 UUID 转为字符串,确保与 String(36) 列类型匹配
|
|||
|
|
conv_id_str = str(conversation_id)
|
|||
|
|
stmt = select(Conversation).where(Conversation.id == conv_id_str)
|
|||
|
|
result = await self.db.execute(stmt)
|
|||
|
|
conversation = result.scalars().first()
|
|||
|
|
|
|||
|
|
if not conversation:
|
|||
|
|
raise ERR_CONVERSATION_NOT_FOUND
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
async def get_conversation(self, conversation_id: UUID) -> Conversation:
|
|||
|
|
"""获取会话详情(公开方法)。
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 会话不存在
|
|||
|
|
"""
|
|||
|
|
return await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# ======================================================================
|
|||
|
|
# 邀请功能(P0-09~P0-11):坐席邀请员工/部门加入会话
|
|||
|
|
# ======================================================================
|
|||
|
|
|
|||
|
|
async def _get_employee_avatar(self, employee_id: str) -> str:
|
|||
|
|
"""获取员工头像URL。
|
|||
|
|
|
|||
|
|
做什么:从 employees 表或企微通讯录API获取头像
|
|||
|
|
为什么:邀请参与者时需要展示头像,前端无法单独获取被邀请人头像
|
|||
|
|
优先级:employees 表(本地缓存) > 企微API(实时获取)
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
employee_id: 企微员工UserID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
str: 头像URL,获取不到返回空字符串
|
|||
|
|
"""
|
|||
|
|
# 1. 优先从 employees 表查(本地缓存,速度快)
|
|||
|
|
from app.models.employee import Employee
|
|||
|
|
result = await self.db.execute(
|
|||
|
|
select(Employee.avatar).where(Employee.employee_id == employee_id)
|
|||
|
|
)
|
|||
|
|
row = result.first()
|
|||
|
|
if row and row[0]:
|
|||
|
|
return row[0]
|
|||
|
|
|
|||
|
|
# 2. employees 表没有,尝试从企微通讯录API获取
|
|||
|
|
if self.wecom_service:
|
|||
|
|
try:
|
|||
|
|
user_info = await self.wecom_service.get_user_info(employee_id)
|
|||
|
|
avatar = user_info.get("avatar", "")
|
|||
|
|
return avatar
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"从企微API获取头像失败: employee_id={employee_id}, error={e}")
|
|||
|
|
|
|||
|
|
return ""
|
|||
|
|
|
|||
|
|
async def invite_participants(
|
|||
|
|
self,
|
|||
|
|
conversation_id: UUID,
|
|||
|
|
inviter_agent_id: str,
|
|||
|
|
participants: list[dict],
|
|||
|
|
history_mode: str = "recent10",
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""邀请员工/部门加入会话(P0-09)。
|
|||
|
|
|
|||
|
|
做什么:主责坐席邀请非坐席人员(员工/部门)参与会话
|
|||
|
|
为什么:复杂IT问题可能需要业务方人员补充信息
|
|||
|
|
权限:只有主责坐席可以发起邀请
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
inviter_agent_id: 邀请人坐席ID
|
|||
|
|
participants: 被邀请人列表 [{"id": "xxx", "name": "xxx", "department": "xxx", "type": "employee"}]
|
|||
|
|
history_mode: 历史共享模式 — recent10/all/none
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 校验不通过
|
|||
|
|
"""
|
|||
|
|
# 1. 校验会话
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# 2. 权限:只有主责坐席可以邀请
|
|||
|
|
if conversation.assigned_agent_id != inviter_agent_id:
|
|||
|
|
raise AppException(3030, "只有主责坐席才能邀请人员加入会话")
|
|||
|
|
|
|||
|
|
# 3. 校验:会话必须是服务中状态
|
|||
|
|
if conversation.status != "serving":
|
|||
|
|
raise AppException(3031, f"只有服务中的会话才能邀请,当前状态: {conversation.status}")
|
|||
|
|
|
|||
|
|
# 4. 合并参与者(去重),同时补充头像
|
|||
|
|
existing_participants = list(conversation.participants or [])
|
|||
|
|
existing_ids = {p.get("id") for p in existing_participants}
|
|||
|
|
new_added = []
|
|||
|
|
|
|||
|
|
for p in participants:
|
|||
|
|
if p.get("id") not in existing_ids:
|
|||
|
|
# 补充头像:员工类型的参与者,从 employees 表或企微API获取头像
|
|||
|
|
if p.get("type", "employee") == "employee" and not p.get("avatar"):
|
|||
|
|
try:
|
|||
|
|
avatar_url = await self._get_employee_avatar(p["id"])
|
|||
|
|
if avatar_url:
|
|||
|
|
p["avatar"] = avatar_url
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"获取参与者头像失败(不阻塞流程): employee_id={p['id']}, error={e}")
|
|||
|
|
|
|||
|
|
existing_participants.append(p)
|
|||
|
|
existing_ids.add(p.get("id"))
|
|||
|
|
new_added.append(p)
|
|||
|
|
|
|||
|
|
if not new_added:
|
|||
|
|
raise AppException(3032, "所有被邀请人已在该会话中")
|
|||
|
|
|
|||
|
|
# 5. 更新 participants 字段
|
|||
|
|
conversation.participants = existing_participants
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(
|
|||
|
|
f"邀请参与者: conv_id={conversation_id}, "
|
|||
|
|
f"inviter={inviter_agent_id}, new_participants={len(new_added)}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 6. 发送企微卡片通知给被邀请人
|
|||
|
|
if self.wecom_service:
|
|||
|
|
for p in new_added:
|
|||
|
|
if p.get("type") == "employee":
|
|||
|
|
try:
|
|||
|
|
# 生成邀请链接:H5 端加入会话的 URL
|
|||
|
|
# 格式:https://itsupport.servyou.com.cn/itdesk/?invite={conv_id}&eid={employee_id}
|
|||
|
|
invite_url = (
|
|||
|
|
f"{getattr(self, '_h5_base_url', '')}/itdesk/"
|
|||
|
|
f"?invite={conversation.id}&eid={p['id']}"
|
|||
|
|
)
|
|||
|
|
await self.wecom_service.send_card_message(
|
|||
|
|
user_id=p["id"],
|
|||
|
|
title="IT服务台邀请您加入会话",
|
|||
|
|
description=(
|
|||
|
|
f"坐席邀请您加入一个IT服务会话,"
|
|||
|
|
f"请点击「加入会话」查看详情并参与讨论。"
|
|||
|
|
),
|
|||
|
|
url=invite_url,
|
|||
|
|
btntxt="加入会话",
|
|||
|
|
)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"发送邀请通知失败(不阻塞流程): user_id={p['id']}, error={e}")
|
|||
|
|
|
|||
|
|
# 7. 创建系统消息广播
|
|||
|
|
await self._create_system_message(
|
|||
|
|
conversation_id=conversation.id,
|
|||
|
|
content=f"坐席邀请 {', '.join(p['name'] for p in new_added)} 加入会话",
|
|||
|
|
extra_data={"action": "participant_invited", "participants": new_added, "history_mode": history_mode},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 8. WebSocket 广播:参与者变更通知
|
|||
|
|
await self._broadcast_participant_change(conversation, "participant_invited", new_added)
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
async def join_conversation(
|
|||
|
|
self,
|
|||
|
|
conversation_id: UUID,
|
|||
|
|
employee_id: str,
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""被邀请人通过链接加入会话(P0-10)。
|
|||
|
|
|
|||
|
|
做什么:被邀请人点击企微卡片链接后加入会话
|
|||
|
|
为什么:实现邀请-加入闭环
|
|||
|
|
校验:该员工必须在 participants 列表中
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
employee_id: 加入的员工企微UserID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 校验不通过
|
|||
|
|
"""
|
|||
|
|
# 1. 校验会话
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# 2. 校验:会话必须是服务中状态
|
|||
|
|
if conversation.status != "serving":
|
|||
|
|
raise AppException(3033, "该会话已结束,无法加入")
|
|||
|
|
|
|||
|
|
# 3. 校验:该员工必须在 participants 列表中(被邀请过才能加入)
|
|||
|
|
participants = list(conversation.participants or [])
|
|||
|
|
is_invited = any(p.get("id") == employee_id for p in participants)
|
|||
|
|
if not is_invited:
|
|||
|
|
raise AppException(3034, "您未被邀请加入该会话")
|
|||
|
|
|
|||
|
|
# 4. 更新参与者的 joined 状态
|
|||
|
|
for p in participants:
|
|||
|
|
if p.get("id") == employee_id:
|
|||
|
|
p["joined"] = True
|
|||
|
|
p["joined_at"] = datetime.now().isoformat()
|
|||
|
|
break
|
|||
|
|
|
|||
|
|
conversation.participants = participants
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
# 5. 获取参与者姓名
|
|||
|
|
participant_name = next(
|
|||
|
|
(p["name"] for p in participants if p.get("id") == employee_id),
|
|||
|
|
"未知"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 6. 系统消息
|
|||
|
|
await self._create_system_message(
|
|||
|
|
conversation_id=conversation.id,
|
|||
|
|
content=f"{participant_name} 已加入会话",
|
|||
|
|
extra_data={"action": "participant_joined", "employee_id": employee_id},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 7. WebSocket 广播
|
|||
|
|
await self._broadcast_participant_change(
|
|||
|
|
conversation, "participant_joined", [{"id": employee_id, "name": participant_name}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
async def remove_participant(
|
|||
|
|
self,
|
|||
|
|
conversation_id: UUID,
|
|||
|
|
remover_agent_id: str,
|
|||
|
|
target_user_id: str,
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""移除参与者(P0-11)。
|
|||
|
|
|
|||
|
|
做什么:主责坐席将参与者移出会话
|
|||
|
|
为什么:参与者不再需要参与时,主责坐席可以移除
|
|||
|
|
权限:只有主责坐席可以移除参与者
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
remover_agent_id: 操作坐席ID
|
|||
|
|
target_user_id: 被移除的员工UserID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 校验不通过
|
|||
|
|
"""
|
|||
|
|
# 1. 校验会话
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# 2. 权限:只有主责坐席可以移除
|
|||
|
|
if conversation.assigned_agent_id != remover_agent_id:
|
|||
|
|
raise AppException(3035, "只有主责坐席才能移除参与者")
|
|||
|
|
|
|||
|
|
# 3. 查找并移除
|
|||
|
|
participants = list(conversation.participants or [])
|
|||
|
|
removed_name = None
|
|||
|
|
new_participants = []
|
|||
|
|
for p in participants:
|
|||
|
|
if p.get("id") == target_user_id:
|
|||
|
|
removed_name = p.get("name", "未知")
|
|||
|
|
else:
|
|||
|
|
new_participants.append(p)
|
|||
|
|
|
|||
|
|
if removed_name is None:
|
|||
|
|
raise AppException(3036, "该人员不在会话参与者列表中")
|
|||
|
|
|
|||
|
|
conversation.participants = new_participants
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
# 4. 系统消息
|
|||
|
|
await self._create_system_message(
|
|||
|
|
conversation_id=conversation.id,
|
|||
|
|
content=f"{removed_name} 已被移出会话",
|
|||
|
|
extra_data={"action": "participant_removed", "user_id": target_user_id},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 5. WebSocket 广播
|
|||
|
|
await self._broadcast_participant_change(
|
|||
|
|
conversation, "participant_removed", [{"id": target_user_id, "name": removed_name}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
async def leave_as_participant(
|
|||
|
|
self,
|
|||
|
|
conversation_id: UUID,
|
|||
|
|
employee_id: str,
|
|||
|
|
) -> Conversation:
|
|||
|
|
"""参与者主动退出会话。
|
|||
|
|
|
|||
|
|
做什么:被邀请人主动退出会话
|
|||
|
|
为什么:参与者不再需要参与时,可以自行退出
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID
|
|||
|
|
employee_id: 退出的员工企微UserID
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Conversation: 更新后的会话对象
|
|||
|
|
|
|||
|
|
Raises:
|
|||
|
|
AppException: 校验不通过
|
|||
|
|
"""
|
|||
|
|
# 1. 校验会话
|
|||
|
|
conversation = await self._get_conversation(conversation_id)
|
|||
|
|
|
|||
|
|
# 2. 查找并移除
|
|||
|
|
participants = list(conversation.participants or [])
|
|||
|
|
leaving_name = None
|
|||
|
|
new_participants = []
|
|||
|
|
for p in participants:
|
|||
|
|
if p.get("id") == employee_id:
|
|||
|
|
leaving_name = p.get("name", "未知")
|
|||
|
|
else:
|
|||
|
|
new_participants.append(p)
|
|||
|
|
|
|||
|
|
if leaving_name is None:
|
|||
|
|
raise AppException(3037, "您不在该会话的参与者列表中")
|
|||
|
|
|
|||
|
|
conversation.participants = new_participants
|
|||
|
|
conversation.updated_at = datetime.now()
|
|||
|
|
self.db.add(conversation)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
# 3. 系统消息
|
|||
|
|
await self._create_system_message(
|
|||
|
|
conversation_id=conversation.id,
|
|||
|
|
content=f"{leaving_name} 已退出会话",
|
|||
|
|
extra_data={"action": "participant_left", "employee_id": employee_id},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 4. WebSocket 广播
|
|||
|
|
await self._broadcast_participant_change(
|
|||
|
|
conversation, "participant_left", [{"id": employee_id, "name": leaving_name}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return conversation
|
|||
|
|
|
|||
|
|
# ======================================================================
|
|||
|
|
# 邀请功能 — 内部辅助方法
|
|||
|
|
# ======================================================================
|
|||
|
|
|
|||
|
|
async def _create_system_message(
|
|||
|
|
self,
|
|||
|
|
conversation_id: str,
|
|||
|
|
content: str,
|
|||
|
|
extra_data: Optional[dict] = None,
|
|||
|
|
) -> None:
|
|||
|
|
"""创建系统消息并保存到数据库。
|
|||
|
|
|
|||
|
|
做什么:在会话中插入一条系统类型的消息
|
|||
|
|
为什么:邀请/加入/退出等事件需要在消息流中可见
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation_id: 会话ID(字符串)
|
|||
|
|
content: 消息内容
|
|||
|
|
extra_data: 扩展元数据(JSON)
|
|||
|
|
"""
|
|||
|
|
from app.models.message import Message
|
|||
|
|
|
|||
|
|
system_msg = Message(
|
|||
|
|
conversation_id=conversation_id,
|
|||
|
|
sender_type="system",
|
|||
|
|
sender_id="system",
|
|||
|
|
sender_name="系统",
|
|||
|
|
content=content,
|
|||
|
|
msg_type="system",
|
|||
|
|
extra_data=extra_data,
|
|||
|
|
)
|
|||
|
|
self.db.add(system_msg)
|
|||
|
|
await self.db.flush()
|
|||
|
|
|
|||
|
|
logger.info(f"系统消息已创建: conv_id={conversation_id}, content={content[:50]}")
|
|||
|
|
|
|||
|
|
async def _broadcast_participant_change(
|
|||
|
|
self,
|
|||
|
|
conversation: Conversation,
|
|||
|
|
action: str,
|
|||
|
|
changed_participants: list[dict],
|
|||
|
|
) -> None:
|
|||
|
|
"""通过 WebSocket 广播参与者变更事件。
|
|||
|
|
|
|||
|
|
做什么:
|
|||
|
|
1. 通知所有在线坐席参与者列表发生变化
|
|||
|
|
2. 通知相关H5员工(会话的原始员工 + 被邀请参与者)参与者变更
|
|||
|
|
为什么:
|
|||
|
|
- 坐席端需要实时更新参与者展示
|
|||
|
|
- H5员工端需要实时更新参与者面板(如新参与者加入、有人退出)
|
|||
|
|
降级策略:广播失败不阻塞主流程
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
conversation: 会话对象
|
|||
|
|
action: 事件类型(participant_invited/joined/removed/left)
|
|||
|
|
changed_participants: 变更的参与者列表
|
|||
|
|
"""
|
|||
|
|
from app.services.ws_manager import manager as ws_manager
|
|||
|
|
event_data = {
|
|||
|
|
"type": action,
|
|||
|
|
"data": {
|
|||
|
|
"conversation_id": str(conversation.id),
|
|||
|
|
"participants": conversation.participants or [],
|
|||
|
|
"changed": changed_participants,
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
try:
|
|||
|
|
# 1. 广播给所有在线坐席
|
|||
|
|
await ws_manager.broadcast(event_data)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"WebSocket广播参与者变更失败(坐席端,不阻塞流程): {e}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
# 2. 推送给相关H5员工(原始员工 + 所有参与者中已加入的员工)
|
|||
|
|
# 做什么:收集会话相关员工的ID,通过 WS 推送
|
|||
|
|
# 为什么:H5端需要实时刷新参与者面板,不用等3秒轮询
|
|||
|
|
employee_ids = set()
|
|||
|
|
|
|||
|
|
# 原始员工(会话发起人)
|
|||
|
|
if conversation.employee_id:
|
|||
|
|
employee_ids.add(conversation.employee_id)
|
|||
|
|
|
|||
|
|
# 所有参与者中的员工类型
|
|||
|
|
for p in (conversation.participants or []):
|
|||
|
|
if p.get("type", "employee") == "employee" and p.get("id"):
|
|||
|
|
employee_ids.add(p["id"])
|
|||
|
|
|
|||
|
|
if employee_ids:
|
|||
|
|
await ws_manager.broadcast_to_employees(list(employee_ids), event_data)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.warning(f"WebSocket推送参与者变更失败(H5员工端,不阻塞流程): {e}")
|