449c6d4875
## H5 员工端 v4 (2026-07-13 00:48 已部署)
- 人工按钮三态文案统一为"人工坐席"
- 按钮位置移至发送键和语音按钮上方(垂直堆叠)
- 点按钮直接调 store.shakeAgent(),删除 CallAgentModal 弹窗动画
- 截图快捷键提示改为"截图->粘贴:Alt+Shift+A-Ctrl+V ---> Ctrl+V"
- 移动端隐藏截图提示(CSS 媒体查询)
- AI转人工提示改为"已为您呼叫人工坐席,请稍等!"
- 坐席接入提示改为"坐席正在查看您的信息,请等待处理回复!"
- 删除"摇铃呼叫坐席"入口和文案
- 删除孤儿组件 MessageList.vue + shake 动画 CSS
## H5 员工端 v5 (2026-07-13 02:08 已部署)
- RightPanel v2.1:删除"软件安装"和"资源权限"标签页
- 移除标签栏,智能推荐(DynamicRecommend)直接展示
- 删除 SoftwareDownloads/ApprovalLinks 引用和相关 CSS
## AI 对话链路全栈改造 Phase 1-6 (已部署)
- Phase 1: Dify JSON输出 + 后端blocking解析 + 双WS推送 + 错误降级
- Phase 2: 关键词收窄(~25强意图词) + 两级分类Prompt + 删除前端checkApprovalIntent
- Phase 3: WS扩展(ai_thinking+dynamic_recommend) + ai_structured气泡 + RightPanel v2 + 选项回传
- Phase 4: VisionService接入 + 图片消息融合(5秒窗口) + 降级策略
- Phase 5: 坐席端ai_thinking指示器 + ai_structured/byod_card渲染 + handleNewMessage修复
- Phase 6: diagnosis_stage(6值) + response_time_ms计时 + 慢响应告警(>10s)
## 坐席端 v5 (2026-07-13 01:38 已部署)
- ai_structured/byod_card 只读渲染
- AI思考指示器 UI
- handleNewMessage 透传 msg_type/extra_data 修复
- 布局优化v2.0: QuickReplyBar L1+L2悬浮 + ReplyBox左右分区 + 右栏260/560px切换
- 键盘快捷键v2.3: 纯数字路由 + ESC分层撤销 + Shift+Space用event.code
## 上下文感知智能诊断闭环 (2026-07-12 已部署)
- 三层诊断(API→Script→AI) + 三段排队(VIP→info_locked→not locked)
- 答题插队 + 五场景关闭
- 迁移052(6表+6列) + queue_service + quiz_service + closing_service
- H5前端: QueueWaiting + RightPanel双Tab + InputBar三态 + ResolveConfirmCard
- 坐席前端: pending_close结单流程 + 信息锁定(Dify步骤完成+有效回答率≥70%)
## 知识库迭代3 (2026-07-12 已部署)
- 分诊交互(H5+坐席+Dify独立应用)
- 拓扑预览(ECharts只读)
- 代答排除(4种匹配器: keyword/regex/intent/category)
- 迁移051 + 44文件43测试通过
## 后端变更
- 6个Python文件改造(h5_ai_task.py/h5.py/ai_service.py/closing_service.py等)
- funny_phrase_service.py: shake/connected/keyword 默认文案更新
- session_service.py: 企微消息文案同步
- 新增: queue.py/quiz.py/triage.py/exclusion_rules.py 等API端点
- 新增: diagnostic.py/quiz.py/triage_session.py 等模型
- 新增: closing_service/queue_service/quiz_service/triage_service 等服务
## 文档更新
- CHANGELOG.md: 新增 [未发布] 区全部变更记录
- 项目管理主文档 v2.5: 新增v0.7.3版本 + 已完成看板 + 最近搞定
- 版本记录: 新增v0.7.3条目
- AI对话链路实施计划: Phase 1-6 全部标记✅已实施
- 新增架构图/时序图/类图(mermaid)
## 部署路径修正
- 服务器项目根路径: /opt/wecom-it-desk/
- 所有前端dist均为ro bind mount,只能在宿主机源路径操作
- 服务器nginx /h5/ 是静态文件服务(非proxy_pass)
- elFinder上传二进制不可靠(MD5不匹配),改用base64分块上传
862 lines
33 KiB
Python
862 lines
33 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 pydantic import BaseModel, Field
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
import redis.asyncio as aioredis
|
||
from app.database import get_db
|
||
from app.models.agent import Agent
|
||
from app.models.conversation import Conversation
|
||
from app.schemas.conversation import (
|
||
ConversationAssign,
|
||
ConversationInvite,
|
||
ConversationListResponse,
|
||
ConversationResponse,
|
||
ConversationStatusUpdate,
|
||
InviteParticipantRequest,
|
||
JoinConversationRequest,
|
||
UpdateTagsRequest,
|
||
)
|
||
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
|
||
|
||
# RBAC 权限装饰器
|
||
from app.dependencies import get_redis, require_role, require_permission
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 创建路由器
|
||
router = APIRouter()
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/conversations — 获取坐席会话列表(全局可见)
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/conversations")
|
||
@require_permission("conversation", "read", "all")
|
||
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),
|
||
redis: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""坐席获取会话列表(全局可见)。
|
||
|
||
返回所有活跃会话,每个会话增加字段:
|
||
- 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, redis_client=redis)
|
||
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
|
||
|
||
# 批量获取员工头像(带缓存)
|
||
employee_ids = list(set([conv.employee_id for conv in conversations]))
|
||
employee_avatar_map = {}
|
||
for emp_id in employee_ids:
|
||
employee_avatar_map[emp_id] = await session_service._get_employee_avatar(emp_id)
|
||
|
||
# ── BUGFIX: 批量回退查询员工信息 ──
|
||
# 为什么需要:conversations 表中 employee_name/department/position 是冗余字段,
|
||
# 在会话创建时可能为空(异步创建、企微回调延迟等),导致列表API返回空字符串。
|
||
# 当这些字段为空时,从 employees 表批量查询并回填,确保坐席端能看到完整用户信息。
|
||
# 何时触发:仅当 conversations 表中的 employee_name 为空字符串时才会去 employees 表查找。
|
||
employee_name_map: dict[str, dict] = {}
|
||
empty_name_conv_ids = [
|
||
conv.employee_id for conv in conversations
|
||
if not conv.employee_name and conv.employee_id
|
||
]
|
||
if empty_name_conv_ids:
|
||
try:
|
||
from app.models.employee import Employee
|
||
stmt = select(Employee).where(Employee.employee_id.in_(empty_name_conv_ids))
|
||
result = await db.execute(stmt)
|
||
for emp in result.scalars().all():
|
||
employee_name_map[emp.employee_id] = {
|
||
"name": emp.name or "",
|
||
"department": emp.department or "",
|
||
"position": emp.position or "",
|
||
"level": getattr(emp, "it_level", "") or "",
|
||
}
|
||
if employee_name_map:
|
||
logger.info(
|
||
f"从employees表批量回退获取员工信息: "
|
||
f"请求={len(empty_name_conv_ids)}, 命中={len(employee_name_map)}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"从employees表批量回退获取员工信息失败: error={e}")
|
||
|
||
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab / avatar 字段
|
||
items = []
|
||
for conv in conversations:
|
||
# ── BUGFIX: 应用 employees 表回退信息 ──
|
||
# 如果 conv 的 employee_name 为空,用批量查询结果回填
|
||
# 这样 ConversationResponse.model_validate 序列化时就能拿到正确的值
|
||
emp_fallback = employee_name_map.get(conv.employee_id, {})
|
||
if emp_fallback:
|
||
if not conv.employee_name and emp_fallback.get("name"):
|
||
conv.employee_name = emp_fallback["name"]
|
||
if not conv.department and emp_fallback.get("department"):
|
||
conv.department = emp_fallback["department"]
|
||
if not conv.position and emp_fallback.get("position"):
|
||
conv.position = emp_fallback["position"]
|
||
if not conv.level and emp_fallback.get("level"):
|
||
conv.level = emp_fallback["level"]
|
||
|
||
conv_data = ConversationResponse.model_validate(conv).model_dump()
|
||
# 员工头像(从缓存获取)
|
||
conv_data["avatar"] = employee_avatar_map.get(conv.employee_id, "")
|
||
# 是否为当前坐席的会话
|
||
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}")
|
||
@require_permission("conversation", "read", "all")
|
||
async def get_conversation(
|
||
conversation_id: str,
|
||
db: AsyncSession = Depends(get_db),
|
||
redis: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""获取会话详情。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
db: 数据库会话
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,包含会话详情
|
||
"""
|
||
session_service = SessionService(db, redis_client=redis)
|
||
conversation = await session_service.get_conversation(conversation_id)
|
||
|
||
# 如果会话中员工姓名为空,从 employees 表回退获取
|
||
if not conversation.employee_name:
|
||
try:
|
||
from sqlalchemy import select
|
||
from app.models.employee import Employee
|
||
stmt = select(Employee).where(Employee.employee_id == conversation.employee_id)
|
||
result = await db.execute(stmt)
|
||
employee = result.scalars().first()
|
||
if employee and employee.name:
|
||
conversation.employee_name = employee.name
|
||
conversation.department = employee.department or ""
|
||
conversation.position = employee.position or ""
|
||
conversation.level = getattr(employee, "it_level", "") or ""
|
||
logger.info(
|
||
f"从employees表回退获取会话详情员工信息: employee_id={conversation.employee_id}, "
|
||
f"name={employee.name}"
|
||
)
|
||
except Exception as e:
|
||
logger.warning(
|
||
f"从employees表获取会话详情员工信息失败: employee_id={conversation.employee_id}, "
|
||
f"error={e}"
|
||
)
|
||
|
||
# 获取员工头像(带缓存)
|
||
avatar = await session_service._get_employee_avatar(conversation.employee_id)
|
||
|
||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||
response_data["avatar"] = avatar
|
||
return success_response(data=response_data)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/conversations/{id}/assign — 坐席接单
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/conversations/{conversation_id}/assign")
|
||
@require_permission("conversation", "update", "all")
|
||
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 as e:
|
||
logger.warning(f"创建企微服务失败: {e},接入通知将不发送")
|
||
session_service = SessionService(db)
|
||
|
||
try:
|
||
conversation = await session_service.assign_agent(
|
||
conversation_id=conversation_id,
|
||
agent_id=body.agent_id,
|
||
)
|
||
except Exception as e:
|
||
logger.error(f"接单失败: conversation_id={conversation_id}, agent_id={body.agent_id}, error={e}")
|
||
raise
|
||
|
||
# 关闭企微服务连接
|
||
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 — 坐席发起结单(触发员工确认)
|
||
# --------------------------------------------------------------------------
|
||
# 决策 G1:坐席发起→员工确认(员工有权否决)
|
||
# 流程变更:原直接 resolved → 新流程 pending_close → 员工确认 → resolved
|
||
class AgentResolveRequest(BaseModel):
|
||
"""坐席结单请求体。"""
|
||
resolve_summary: str = Field(..., description="结单摘要(问题类型+根因+解决方式)")
|
||
|
||
|
||
@router.post("/conversations/{conversation_id}/resolve")
|
||
@require_permission("conversation", "update", "own")
|
||
async def resolve_conversation(
|
||
conversation_id: str,
|
||
body: AgentResolveRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_agent: Agent = Depends(get_current_agent),
|
||
):
|
||
"""坐席发起结单(触发员工确认流程)。
|
||
|
||
改造说明(决策 G1):
|
||
- 原逻辑:直接将会话状态改为 resolved
|
||
- 新逻辑:状态改为 pending_close → 推送确认卡片给员工
|
||
- 员工确认后 → resolved
|
||
- 员工拒绝 → 恢复 serving
|
||
- 5分钟超时 → 自动 resolved
|
||
|
||
权限控制:只有主责坐席(assigned_agent_id)才能结单。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
body: 结单请求体(含 resolve_summary)
|
||
db: 数据库会话
|
||
current_agent: 当前坐席(认证依赖注入)
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,包含更新后的会话信息(状态为 pending_close)
|
||
"""
|
||
from app.services.closing_service import ClosingService
|
||
|
||
closing_service = ClosingService(db)
|
||
conversation = await closing_service.agent_initiate_resolve(
|
||
conversation_id=conversation_id,
|
||
agent_id=current_agent.user_id,
|
||
resolve_summary=body.resolve_summary,
|
||
)
|
||
await db.commit()
|
||
|
||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||
response_data["message"] = "结单请求已发送,等待员工确认(5分钟内未响应将自动关闭)。"
|
||
return success_response(data=response_data)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/conversations/{id}/pin — 置顶/取消置顶
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/conversations/{conversation_id}/pin")
|
||
@require_permission("conversation", "update", "own")
|
||
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")
|
||
@require_permission("conversation", "update", "own")
|
||
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")
|
||
@require_permission("conversation", "update", "all")
|
||
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")
|
||
@require_permission("conversation", "update", "all")
|
||
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")
|
||
@require_permission("conversation", "update", "own")
|
||
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")
|
||
@require_permission("conversation", "update", "own")
|
||
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")
|
||
@require_permission("conversation", "update", "own")
|
||
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 — 被邀请人加入会话
|
||
# --------------------------------------------------------------------------
|
||
# 注意:此端点允许被邀请的员工直接从H5加入,不需要坐席认证
|
||
@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}")
|
||
@require_permission("conversation", "update", "own")
|
||
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")
|
||
@require_permission("conversation", "update", "own")
|
||
async def leave_as_participant(
|
||
conversation_id: str,
|
||
body: JoinConversationRequest,
|
||
current_agent: Agent = Depends(get_current_agent),
|
||
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)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/conversations/{conversation_id}/tags — 保存会话标签
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/conversations/{conversation_id}/tags")
|
||
@require_permission("conversation", "update", "own")
|
||
async def update_conversation_tags(
|
||
conversation_id: str,
|
||
body: UpdateTagsRequest,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_agent: Agent = Depends(get_current_agent),
|
||
):
|
||
"""保存会话标签。
|
||
|
||
坐席可以为会话添加/更新标签,如问题分类、优先级、情绪状态等。
|
||
标签以 JSON 形式存储在会话的 tags 字段中。
|
||
|
||
Args:
|
||
conversation_id: 会话ID
|
||
body: 标签更新请求,包含 tags 字典
|
||
current_agent: 当前坐席(通过认证依赖注入)
|
||
db: 数据库会话
|
||
|
||
Returns:
|
||
更新后的会话详情
|
||
"""
|
||
# 1. 验证会话存在性
|
||
stmt = select(Conversation).where(Conversation.id == conversation_id)
|
||
result = await db.execute(stmt)
|
||
conversation = result.scalars().first()
|
||
|
||
if not conversation:
|
||
raise AppException("会话不存在", code=404)
|
||
|
||
# 2. 合并现有标签(如果有)
|
||
existing_tags = {}
|
||
if conversation.tags:
|
||
existing_tags = (
|
||
dict(conversation.tags) if isinstance(conversation.tags, dict) else {}
|
||
)
|
||
|
||
# 3. 合并新旧标签(body.tags 覆盖同名 key)
|
||
merged_tags = {**existing_tags, **body.tags}
|
||
|
||
# 4. 保存到数据库
|
||
conversation.tags = merged_tags
|
||
await db.commit()
|
||
await db.refresh(conversation)
|
||
|
||
logger.info(
|
||
f"坐席 {current_agent.id} 更新会话 {conversation_id} 标签: {merged_tags}"
|
||
)
|
||
|
||
# 5. 返回更新后的会话
|
||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||
return success_response(data=response_data)
|