689 lines
25 KiB
Python
689 lines
25 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微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)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =============================================================================
|
|||
|
|
# 邀请功能 API(P0-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)
|