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}")
|