chore: initial baseline with P0-safety .gitignore
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 服务包初始化
|
||||
# =============================================================================
|
||||
# 说明:将 services/ 目录标记为 Python 包
|
||||
# 导出所有服务类,方便统一导入
|
||||
# =============================================================================
|
||||
|
||||
from app.services.wecom_service import WecomService
|
||||
from app.services.message_router import MessageRouter
|
||||
from app.services.scoring_service import ScoringService
|
||||
from app.services.session_service import SessionService
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ai_handler import AIHandler
|
||||
|
||||
__all__ = [
|
||||
"WecomService",
|
||||
"MessageRouter",
|
||||
"ScoringService",
|
||||
"SessionService",
|
||||
"FunnyPhraseService",
|
||||
"AIHandler",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,289 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 统一 AI 回复处理器
|
||||
# =============================================================================
|
||||
# 说明:统一封装 AI 调用逻辑,供 H5 端和企微回调端共用,确保两端行为一致。
|
||||
# 1. 打招呼检测 → 引导用户描述问题(不计数)
|
||||
# 2. 呼叫人工拦截 → 引导先描述问题(不计数)
|
||||
# 3. AI 调用 → 命中则回复并计数,未命中则转人工
|
||||
# 4. AI 异常降级 → 模板回复(不计数,不转人工)
|
||||
#
|
||||
# 为什么需要此模块:
|
||||
# - 原 h5.py 和 MessageRouter._try_ai_reply() 各自独立实现 AI 调用逻辑
|
||||
# - 两端行为不一致:打招呼/呼叫人工拦截只在 H5 端有,计数规则不同
|
||||
# - 统一后确保无论从哪个入口进来,用户获得的 AI 体验完全一致
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from app.services.ai_service import AIService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 打招呼关键词(匹配后 AI 引导用户描述问题,不计数)
|
||||
# --------------------------------------------------------------------------
|
||||
_GREETING_KEYWORDS = [
|
||||
"你好", "您好", "hi", "hello", "嗨", "在吗", "在不在",
|
||||
"哈喽", "早", "早上好", "下午好", "晚上好",
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 直接呼叫人工关键词(匹配后 AI 引导用户先描述问题,不计数)
|
||||
# --------------------------------------------------------------------------
|
||||
_CALL_HUMAN_KEYWORDS = [
|
||||
"人工", "人工坐席", "转人工", "客服", "我要人工",
|
||||
"找人工", "人工客服", "转接人工", "人工服务",
|
||||
"找客服", "联系人工", "我要找人", "不要机器人",
|
||||
"真人", "摇人", "找人",
|
||||
]
|
||||
|
||||
# AI 引导话术(打招呼)
|
||||
_GREETING_GUIDE = (
|
||||
"你好!我是 IT 智能助手 🤖\n"
|
||||
"请直接描述你遇到的 IT 问题,比如:\n"
|
||||
"• 打印机连不上\n"
|
||||
"• 电脑蓝屏了\n"
|
||||
"• VPN 无法登录\n"
|
||||
"我先帮你分析,搞不定再帮你转人工坐席~"
|
||||
)
|
||||
|
||||
# AI 引导话术(呼叫人工)
|
||||
_CALL_HUMAN_GUIDE = (
|
||||
"别急~先告诉我你遇到了什么问题?\n"
|
||||
"我先帮你排查一下,大部分问题我都能解决 💪\n"
|
||||
"如果确实需要人工坐席,我会帮你转接的!"
|
||||
)
|
||||
|
||||
# AI 未命中转人工话术
|
||||
_AI_MISS_GUIDE = (
|
||||
"🤖 AI 暂未学习到相关知识,正在为您转接 IT 坐席,请稍候..."
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 非文本消息自动回复模板(图片引导,其余类型暂不支持)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
_IMAGE_REPLY = (
|
||||
"收到您的截图 📷\n"
|
||||
"请补充文字描述您遇到的问题,以便更快为您处理。\n"
|
||||
"例如:\n"
|
||||
"• 这是什么软件的报错截图?\n"
|
||||
"• 您在操作什么时出现的?\n"
|
||||
"• 错误信息的具体内容是什么?"
|
||||
)
|
||||
|
||||
_NON_TEXT_REPLY_TEMPLATE = (
|
||||
"暂不支持{type_name}消息 😅\n"
|
||||
"请用文字描述您的问题,我会尽快为您处理。"
|
||||
)
|
||||
|
||||
# AI 调用失败降级模板(使用 {topic} 占位符,运行时替换为用户消息摘要)
|
||||
_FALLBACK_TEMPLATES = [
|
||||
"收到!关于「{topic}」的问题,让我来帮你分析一下…\n\n"
|
||||
"这类问题通常由以下原因导致:\n"
|
||||
"1. 网络连接异常\n"
|
||||
"2. 设备驱动问题\n"
|
||||
"3. 系统配置错误\n\n"
|
||||
"你可以先尝试重启设备,如果问题依旧,请告诉我具体的错误提示。",
|
||||
|
||||
"明白,关于「{topic}」的问题…\n\n"
|
||||
"建议按以下步骤排查:\n"
|
||||
"1. 检查网络是否正常\n"
|
||||
"2. 确认相关服务是否启动\n"
|
||||
"3. 查看是否有报错提示\n\n"
|
||||
"如果以上步骤无法解决,请补充更多细节。",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIReplyResult:
|
||||
"""AI 回复结果(统一返回结构)。
|
||||
|
||||
无论消息经过哪种处理路径(打招呼/呼叫人工/AI命中/AI未命中/降级),
|
||||
都返回此结构,由调用方决定如何持久化和发送。
|
||||
|
||||
Attributes:
|
||||
content: 回复内容
|
||||
reply_type: 回复类型
|
||||
- "greeting": 打招呼引导
|
||||
- "call_human": 呼叫人工拦截引导
|
||||
- "ai_hit": AI 命中知识库
|
||||
- "ai_miss": AI 未命中,需转人工
|
||||
- "ai_fallback": AI 调用异常,降级模板回复
|
||||
is_guidance: 是否为引导类消息(打招呼或呼叫人工),前端据此决定 UI 展示
|
||||
should_count: 是否应增加 ai_substantive_reply_count(仅 AI 命中时为 True)
|
||||
should_transfer: 是否应转人工(状态改为 queued)
|
||||
dify_conversation_id: Dify 会话ID(用于多轮对话上下文,AI 命中/未命中时更新)
|
||||
"""
|
||||
content: str
|
||||
reply_type: str
|
||||
is_guidance: bool = False
|
||||
should_count: bool = False
|
||||
should_transfer: bool = False
|
||||
dify_conversation_id: Optional[str] = None
|
||||
|
||||
|
||||
class AIHandler:
|
||||
"""统一 AI 回复处理器。
|
||||
|
||||
封装打招呼检测、呼叫人工拦截、AI 调用、命中判断、计数规则、
|
||||
转人工逻辑,供 H5 端和企微回调端(MessageRouter)共用,
|
||||
确保两端行为完全一致。
|
||||
|
||||
处理流程(按优先级):
|
||||
1. 检测打招呼 → 返回引导话术(不计数,不转人工)
|
||||
2. 检测呼叫人工 → 返回拦截引导(不计数,不转人工)
|
||||
3. 调用 Dify API:
|
||||
- 命中 → 返回 AI 回复(计数+1,不转人工)
|
||||
- 未命中 → 返回转人工提示(不计数,转人工)
|
||||
4. AI 调用异常 → 返回降级模板回复(不计数,不转人工)
|
||||
|
||||
计数规则(统一):
|
||||
- 仅 AI 命中知识库时 ai_substantive_reply_count +1
|
||||
- 打招呼/呼叫人工/未命中/降级 均不计数
|
||||
"""
|
||||
|
||||
def __init__(self, ai_service: AIService):
|
||||
"""初始化 AI 处理器。
|
||||
|
||||
Args:
|
||||
ai_service: AI 服务实例(Dify API 封装),通常为应用级共享单例
|
||||
"""
|
||||
self.ai_service = ai_service
|
||||
|
||||
def is_greeting(self, content: str) -> bool:
|
||||
"""检测是否为打招呼消息。
|
||||
|
||||
匹配规则:用户消息(小写化、去前后空格后)包含任意打招呼关键词。
|
||||
|
||||
Args:
|
||||
content: 用户消息内容
|
||||
|
||||
Returns:
|
||||
bool: 是否为打招呼
|
||||
"""
|
||||
text = content.strip().lower()
|
||||
return any(kw in text for kw in _GREETING_KEYWORDS)
|
||||
|
||||
def is_call_human(self, content: str) -> bool:
|
||||
"""检测是否为直接呼叫人工。
|
||||
|
||||
匹配规则:用户消息(小写化、去前后空格后)包含任意呼叫人工关键词。
|
||||
拦截后引导用户先描述问题,避免直接转人工浪费坐席资源。
|
||||
|
||||
Args:
|
||||
content: 用户消息内容
|
||||
|
||||
Returns:
|
||||
bool: 是否为呼叫人工
|
||||
"""
|
||||
text = content.strip().lower()
|
||||
return any(kw in text for kw in _CALL_HUMAN_KEYWORDS)
|
||||
|
||||
async def handle_message(
|
||||
self,
|
||||
content: str,
|
||||
dify_conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> AIReplyResult:
|
||||
"""处理用户消息,返回统一的 AI 回复结果。
|
||||
|
||||
按照优先级依次检测:打招呼 → 呼叫人工 → AI 调用。
|
||||
每种路径返回不同的 reply_type,由调用方根据结果更新会话状态和计数。
|
||||
|
||||
Args:
|
||||
content: 用户消息内容
|
||||
dify_conversation_id: Dify 会话ID(用于多轮对话上下文)
|
||||
user_id: 用户标识(用于 Dify 日志追溯)
|
||||
|
||||
Returns:
|
||||
AIReplyResult: 统一的 AI 回复结果
|
||||
"""
|
||||
# ==================================================================
|
||||
# 1. 检测打招呼 → 引导描述问题,不计数,不转人工
|
||||
# ==================================================================
|
||||
if self.is_greeting(content):
|
||||
logger.info(f"打招呼引导: user_id={user_id}")
|
||||
return AIReplyResult(
|
||||
content=_GREETING_GUIDE,
|
||||
reply_type="greeting",
|
||||
is_guidance=True,
|
||||
should_count=False,
|
||||
should_transfer=False,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
# 2. 检测呼叫人工 → 拦截引导,不计数,不转人工
|
||||
# ==================================================================
|
||||
if self.is_call_human(content):
|
||||
logger.info(f"人工拦截引导: user_id={user_id}")
|
||||
return AIReplyResult(
|
||||
content=_CALL_HUMAN_GUIDE,
|
||||
reply_type="call_human",
|
||||
is_guidance=True,
|
||||
should_count=False,
|
||||
should_transfer=False,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
)
|
||||
|
||||
# ==================================================================
|
||||
# 3. 调用 Dify API 获取 AI 回复
|
||||
# ==================================================================
|
||||
try:
|
||||
ai_result = await self.ai_service.get_reply(
|
||||
message=content,
|
||||
conversation_id=dify_conversation_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# 提取 Dify 返回的 conversation_id(用于多轮对话上下文)
|
||||
new_conv_id = ai_result.get("conversation_id") or dify_conversation_id
|
||||
|
||||
if ai_result["hit"]:
|
||||
# AI 命中:使用 Dify 回复,计数+1
|
||||
logger.info(
|
||||
f"AI命中: user_id={user_id}, "
|
||||
f"content_length={len(ai_result['content'])}"
|
||||
)
|
||||
return AIReplyResult(
|
||||
content=ai_result["content"],
|
||||
reply_type="ai_hit",
|
||||
is_guidance=False,
|
||||
should_count=True,
|
||||
should_transfer=False,
|
||||
dify_conversation_id=new_conv_id,
|
||||
)
|
||||
else:
|
||||
# AI 未命中:转人工
|
||||
logger.info(f"AI未命中转人工: user_id={user_id}")
|
||||
return AIReplyResult(
|
||||
content=_AI_MISS_GUIDE,
|
||||
reply_type="ai_miss",
|
||||
is_guidance=False,
|
||||
should_count=False,
|
||||
should_transfer=True,
|
||||
dify_conversation_id=new_conv_id,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# ==============================================================
|
||||
# 4. AI 调用异常:降级模板回复
|
||||
# - 不计数(修复原 h5.py 降级误计数的 Bug)
|
||||
# - 不转人工(降级是临时故障,用户可继续尝试)
|
||||
# ==============================================================
|
||||
logger.error(f"AI调用失败(降级模板回复): {e}")
|
||||
topic = content.strip()[:15]
|
||||
fallback_content = random.choice(_FALLBACK_TEMPLATES).format(topic=topic)
|
||||
return AIReplyResult(
|
||||
content=fallback_content,
|
||||
reply_type="ai_fallback",
|
||||
is_guidance=False,
|
||||
should_count=False,
|
||||
should_transfer=False,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
)
|
||||
@@ -0,0 +1,271 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — AI 服务(Dify 接入)
|
||||
# =============================================================================
|
||||
# 做什么:封装 Dify API 调用,实现 AI 自动回复
|
||||
# 为什么:
|
||||
# - ARCHITECTURE.md 设计了 ai_handling 状态,但当前未实现
|
||||
# - 现有系统交接文档提供了 Dify API 地址和 Key
|
||||
# - 这是实现「AI 自助解决」的核心模块
|
||||
# 依赖:需要 Dify API 可达(生产环境 http://yw-dify.dc.servyou-it.com)
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Optional, AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AIService:
|
||||
"""AI 服务:封装 Dify API,提供 AI 回复能力。
|
||||
|
||||
支持两种调用模式:
|
||||
1. 非流式(简单场景):一次性获取完整回复
|
||||
2. 流式(推荐):SSE 流式返回,前端可逐字显示
|
||||
|
||||
参考:现有系统交接文档
|
||||
- API URL: http://yw-dify.dc.servyou-it.com/dify2openai/v1/chat/completions
|
||||
- Key: http://yw-dify.dc.servyou-it.com/v1|app-UaTWYdBSwN6VktKQlbh5YN5H|Chat
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 AI 服务。
|
||||
|
||||
做什么:从配置读取 Dify API 地址和认证信息
|
||||
为什么:集中管理 API 配置,便于切换测试/生产环境
|
||||
"""
|
||||
# Dify 兼容 OpenAI 格式的 API 端点
|
||||
self.api_url = settings.dify_api_url
|
||||
# Dify API Key(格式:base_url|app_id|app_name)
|
||||
self.api_key = settings.dify_api_key
|
||||
# 请求超时(秒)
|
||||
self.timeout = settings.dify_timeout
|
||||
|
||||
# httpx 异步客户端(复用连接池)
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""获取或创建 httpx 异步客户端。
|
||||
|
||||
做什么:懒加载 httpx.AsyncClient,复用连接池
|
||||
为什么:避免每次请求都创建新连接,提升性能
|
||||
"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""关闭 httpx 客户端。
|
||||
|
||||
做什么:释放连接池资源
|
||||
为什么:避免连接泄漏,尤其在长期运行的 FastAPI 应用中
|
||||
"""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug("AIService httpx client closed")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 非流式调用:一次性获取 AI 完整回复
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_reply(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 Dify API 获取 AI 回复(非流式)。
|
||||
|
||||
Args:
|
||||
message: 员工发送的消息内容
|
||||
conversation_id: 会话ID(用于 Dify 多轮对话上下文)
|
||||
user_id: 员工企微 UserID(用于 Dify 用户标识)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"content": str, # AI 回复内容
|
||||
"hit": bool, # 是否命中知识库(可回复)
|
||||
"conversation_id": str, # Dify 会话ID(用于后续多轮对话)
|
||||
"usage": dict, # Token 用量(可选)
|
||||
}
|
||||
|
||||
做什么:发送消息到 Dify,解析返回内容,判断是否能回复
|
||||
为什么:
|
||||
- 非流式适合简单场景,代码简单
|
||||
- 返回结构兼容 OpenAI Chat Completions 格式
|
||||
- 通过回复内容判断是否命中知识库(有实质内容 = 命中)
|
||||
"""
|
||||
payload = {
|
||||
"model": "Chat", # Dify 应用名称(来自 API Key 格式)
|
||||
"messages": [
|
||||
{"role": "user", "content": message}
|
||||
],
|
||||
"stream": False, # 非流式
|
||||
"temperature": 0.1, # 低温度,保证回答稳定性
|
||||
}
|
||||
|
||||
# 传入 Dify 会话ID,保持多轮对话上下文
|
||||
if conversation_id:
|
||||
payload["conversation_id"] = conversation_id
|
||||
|
||||
# 传入用户标识(Dify 侧用于日志和追溯)
|
||||
if user_id:
|
||||
payload["user"] = user_id
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
logger.info(f"调用 Dify API: message={message[:50]}...")
|
||||
response = await client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 解析 OpenAI 兼容格式的返回
|
||||
# 格式:{"choices": [{"message": {"content": "..."}}]}
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning("Dify API 返回空 choices")
|
||||
return {
|
||||
"content": "",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
reply_content = choices[0]["message"]["content"]
|
||||
|
||||
# 判断是否命中知识库:
|
||||
# 策略1:检查内容是否为空或过长(Dify 可能返回提示语)
|
||||
# 策略2:检查是否包含「抱歉」「不知道」等无法回答的特征词
|
||||
hit = self._check_knowledge_hit(reply_content)
|
||||
|
||||
# 提取 Dify 返回的 conversation_id(用于多轮对话)
|
||||
dify_conv_id = data.get("conversation_id", conversation_id or "")
|
||||
|
||||
logger.info(
|
||||
f"Dify API 返回: hit={hit}, "
|
||||
f"content_length={len(reply_content)}, "
|
||||
f"conv_id={dify_conv_id[:20] if dify_conv_id else '(new)'}"
|
||||
)
|
||||
|
||||
return {
|
||||
"content": reply_content,
|
||||
"hit": hit,
|
||||
"conversation_id": dify_conv_id,
|
||||
"usage": data.get("usage", {}),
|
||||
}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Dify API 超时")
|
||||
return {
|
||||
"content": "⏰ AI 服务响应超时,请稍后再试或输入「IT」转人工。",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Dify API HTTP 错误: status={e.response.status_code}")
|
||||
return {
|
||||
"content": "⚠️ AI 服务暂时不可用,请输入「IT」转人工。",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Dify API 调用失败: {e}")
|
||||
return {
|
||||
"content": "⚠️ AI 服务异常,请输入「IT」转人工。",
|
||||
"hit": False,
|
||||
"conversation_id": conversation_id or "",
|
||||
"usage": {},
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 流式调用:SSE 流式返回(供 WebSocket 推送给前端)
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_reply_stream(
|
||||
self,
|
||||
message: str,
|
||||
conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""调用 Dify API 获取流式 AI 回复(SSE)。
|
||||
|
||||
Args:
|
||||
message: 员工发送的消息内容
|
||||
conversation_id: Dify 会话ID
|
||||
user_id: 员工企微 UserID
|
||||
|
||||
Yields:
|
||||
Dict: {
|
||||
"delta": str, # 增量内容
|
||||
"finished": bool, # 是否结束
|
||||
"conversation_id": str,
|
||||
"hit": bool, # 最终判断是否命中
|
||||
}
|
||||
|
||||
做什么:SSE 流式读取 Dify 返回,逐块 yield 给调用方
|
||||
为什么:
|
||||
- 流式返回能提升用户体验(不用等 AI 全部生成完才显示)
|
||||
- 通过 WebSocket 推送增量内容到 H5 前端
|
||||
- 目前第一步先实现非流式,流式作为后续优化
|
||||
"""
|
||||
# TODO: 第一步简化,先 yield 完整内容(非真正流式)
|
||||
# 后续优化:解析 SSE 事件流,逐块 yield
|
||||
result = await self.get_reply(message, conversation_id, user_id)
|
||||
yield {
|
||||
"delta": result["content"],
|
||||
"finished": True,
|
||||
"conversation_id": result["conversation_id"],
|
||||
"hit": result["hit"],
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 判断是否命中知识库
|
||||
# --------------------------------------------------------------------------
|
||||
def _check_knowledge_hit(self, content: str) -> bool:
|
||||
"""判断 AI 回复是否命中知识库(可以回答用户问题)。
|
||||
|
||||
Args:
|
||||
content: AI 回复内容
|
||||
|
||||
Returns:
|
||||
bool: True=命中(可以回复),False=未命中(需转人工)
|
||||
|
||||
做什么:分析 AI 回复内容,判断是否能有效回答问题
|
||||
为什么:
|
||||
- Dify 在无法回答时通常会返回固定提示语
|
||||
- 参考现有系统:「抱歉,您的问题可能不在服务业务范围内」
|
||||
- 命中 = 有实质内容且不像是「无法回答」的提示
|
||||
"""
|
||||
if not content or len(content.strip()) < 5:
|
||||
return False
|
||||
|
||||
# 未命中特征词(Dify 无法回答时的典型回复)
|
||||
miss_keywords = [
|
||||
"抱歉", "对不起", "不知道", "无法回答",
|
||||
"不在服务范围内", "超出我的能力", "暂不支持",
|
||||
"请转人工", "联系管理员",
|
||||
]
|
||||
content_lower = content.lower()
|
||||
|
||||
# 如果回复中包含多个未命中特征词 → 判断为未命中
|
||||
miss_count = sum(1 for kw in miss_keywords if kw in content_lower)
|
||||
if miss_count >= 2:
|
||||
return False
|
||||
|
||||
# 如果回复长度过短(< 10 字符)且包含特征词 → 未命中
|
||||
if len(content) < 10 and any(kw in content_lower for kw in miss_keywords):
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,232 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Redis 缓存服务
|
||||
# =============================================================================
|
||||
# 说明:封装 Redis 缓存操作,提供:
|
||||
# 1. 消息去重(基于企微 MsgId)
|
||||
# 2. 内容去重(基于用户 ID + 内容哈希,防快速重复发送)
|
||||
# 3. 通用缓存读写(供其他服务复用)
|
||||
#
|
||||
# 去重窗口:
|
||||
# - MsgId 去重:5 分钟(与企微重试窗口一致)
|
||||
# - 内容去重:60 秒(防止用户快速重复发送相同消息)
|
||||
# =============================================================================
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Redis key 前缀
|
||||
MSG_DEDUP_PREFIX = "msg:dedup"
|
||||
CONTENT_DEDUP_PREFIX = "msg:dedup:content"
|
||||
|
||||
# 默认 TTL(秒)
|
||||
DEFAULT_MSG_DEDUP_TTL = 300 # 5 分钟,与企微重试窗口一致
|
||||
DEFAULT_CONTENT_DEDUP_TTL = 60 # 60 秒,防止快速重复发送
|
||||
|
||||
|
||||
class CacheService:
|
||||
"""Redis 缓存服务,提供消息去重和通用缓存操作。
|
||||
|
||||
使用 Redis 的 SETNX + EXPIRE 语义实现幂等去重:
|
||||
- 首次写入成功 → 返回 False(非重复)
|
||||
- 再次写入失败 → 返回 True(重复)
|
||||
|
||||
降级策略:Redis 不可用时,去重检查自动放行(宁可重复处理,不可丢消息)。
|
||||
|
||||
Attributes:
|
||||
redis: Redis 异步客户端(可为 None,降级时跳过去重)
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
|
||||
"""初始化缓存服务。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端实例。
|
||||
为 None 时去重检查自动放行(降级模式)。
|
||||
"""
|
||||
self.redis = redis_client
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 消息去重(基于企微 MsgId)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def is_duplicate(
|
||||
self,
|
||||
msg_id: str,
|
||||
ttl: int = DEFAULT_MSG_DEDUP_TTL,
|
||||
) -> bool:
|
||||
"""基于企微 MsgId 判断消息是否重复。
|
||||
|
||||
利用 Redis SETNX 语义实现幂等检查:
|
||||
- key 不存在 → 设置 key(带 TTL)→ 返回 False(非重复)
|
||||
- key 已存在 → 跳过写入 → 返回 True(重复)
|
||||
|
||||
降级策略:Redis 不可用时返回 False(放行),记录警告日志。
|
||||
|
||||
Args:
|
||||
msg_id: 企微消息唯一 ID(MsgId 字段)
|
||||
ttl: 去重窗口(秒),默认 300 秒(5 分钟)
|
||||
|
||||
Returns:
|
||||
bool: True=重复消息(应跳过处理),False=首次收到(正常处理)
|
||||
"""
|
||||
if not msg_id:
|
||||
logger.warning("msg_id 为空,跳过去重检查")
|
||||
return False
|
||||
|
||||
if self.redis is None:
|
||||
logger.debug("Redis 不可用,跳过 MsgId 去重检查(降级放行)")
|
||||
return False
|
||||
|
||||
key = f"{MSG_DEDUP_PREFIX}:{msg_id}"
|
||||
|
||||
try:
|
||||
# SETNX + EXPIRE 原子操作:key 不存在时设置并返回 True,已存在返回 False
|
||||
is_new = await self.redis.set(key, "1", nx=True, ex=ttl)
|
||||
|
||||
if is_new:
|
||||
logger.debug(f"MsgId 去重: 新消息 msg_id={msg_id}")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"MsgId 去重: 重复消息已过滤 msg_id={msg_id}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MsgId 去重检查异常(降级放行): msg_id={msg_id}, error={e}")
|
||||
return False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 内容去重(基于用户 ID + 内容哈希)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def is_duplicate_content(
|
||||
self,
|
||||
user_id: str,
|
||||
content: str,
|
||||
ttl: int = DEFAULT_CONTENT_DEDUP_TTL,
|
||||
) -> bool:
|
||||
"""基于用户 ID + 内容哈希判断是否为快速重复发送。
|
||||
|
||||
场景:用户在短时间内连续发送相同内容的消息(如网络卡顿导致重复点击)。
|
||||
与 MsgId 去重不同,这里处理的是不同 MsgId 但内容完全相同的消息。
|
||||
|
||||
使用 SHA256 对 user_id + content 生成哈希作为 Redis key,
|
||||
窗口默认 60 秒(防止快速重复发送,但不影响正常重新提问)。
|
||||
|
||||
降级策略:Redis 不可用时返回 False(放行),记录警告日志。
|
||||
|
||||
Args:
|
||||
user_id: 发送者企微 UserID
|
||||
content: 消息内容
|
||||
ttl: 去重窗口(秒),默认 60 秒
|
||||
|
||||
Returns:
|
||||
bool: True=重复内容(应跳过处理),False=首次收到(正常处理)
|
||||
"""
|
||||
if not user_id or not content:
|
||||
logger.debug("user_id 或 content 为空,跳过内容去重检查")
|
||||
return False
|
||||
|
||||
if self.redis is None:
|
||||
logger.debug("Redis 不可用,跳过内容去重检查(降级放行)")
|
||||
return False
|
||||
|
||||
# 使用 SHA256 生成内容哈希,避免 Redis key 中存储原始内容
|
||||
content_hash = hashlib.sha256(f"{user_id}:{content}".encode("utf-8")).hexdigest()[:16]
|
||||
key = f"{CONTENT_DEDUP_PREFIX}:{user_id}:{content_hash}"
|
||||
|
||||
try:
|
||||
is_new = await self.redis.set(key, "1", nx=True, ex=ttl)
|
||||
|
||||
if is_new:
|
||||
logger.debug(f"内容去重: 新消息 user_id={user_id}, hash={content_hash}")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"内容去重: 重复内容已过滤 user_id={user_id}, hash={content_hash}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"内容去重检查异常(降级放行): user_id={user_id}, hash={content_hash}, error={e}"
|
||||
)
|
||||
return False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 通用缓存操作
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def get(self, key: str) -> Optional[str]:
|
||||
"""从 Redis 获取缓存值。
|
||||
|
||||
Args:
|
||||
key: 缓存 key
|
||||
|
||||
Returns:
|
||||
Optional[str]: 缓存值,不存在或 Redis 不可用时返回 None
|
||||
"""
|
||||
if self.redis is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
value = await self.redis.get(key)
|
||||
return value.decode("utf-8") if isinstance(value, bytes) else value
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis GET 异常: key={key}, error={e}")
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
value: str,
|
||||
ttl: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""向 Redis 写入缓存值。
|
||||
|
||||
Args:
|
||||
key: 缓存 key
|
||||
value: 缓存值
|
||||
ttl: 过期时间(秒),为 None 时永不过期
|
||||
|
||||
Returns:
|
||||
bool: True=写入成功,False=写入失败或 Redis 不可用
|
||||
"""
|
||||
if self.redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
if ttl is not None:
|
||||
await self.redis.setex(key, ttl, value)
|
||||
else:
|
||||
await self.redis.set(key, value)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis SET 异常: key={key}, error={e}")
|
||||
return False
|
||||
|
||||
async def delete(self, key: str) -> bool:
|
||||
"""从 Redis 删除缓存 key。
|
||||
|
||||
Args:
|
||||
key: 缓存 key
|
||||
|
||||
Returns:
|
||||
bool: True=删除成功,False=删除失败或 Redis 不可用
|
||||
"""
|
||||
if self.redis is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
await self.redis.delete(key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis DELETE 异常: key={key}, error={e}")
|
||||
return False
|
||||
|
||||
|
||||
# 默认实例:Redis 客户端在应用启动时通过 init_cache_service() 注入
|
||||
# 为什么:ws.py 等模块需要导入一个 cache_service 实例来读取 Redis
|
||||
cache_service = CacheService()
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 外部系统集成模块
|
||||
# =============================================================================
|
||||
# 提供统一的适配层,让联软/火绒/aTrust/eHR用同一套接口规范接入。
|
||||
# 上层业务只依赖 ExternalSystemService 统一门面,不直接调用任何Adapter。
|
||||
#
|
||||
# 使用方式:
|
||||
# from app.services.external import ExternalSystemService, get_external_service
|
||||
# svc = get_external_service()
|
||||
# terminal = await svc.find_user_terminal("songxian")
|
||||
# =============================================================================
|
||||
|
||||
from app.services.external.base import (
|
||||
ExternalSystemAdapter,
|
||||
TerminalInfo,
|
||||
SecurityStatus,
|
||||
VpnSession,
|
||||
)
|
||||
from app.services.external.config import ExternalSystemConfig
|
||||
from app.services.external.cache import ExternalSystemCache
|
||||
from app.services.external.mock import MockAdapter
|
||||
from app.services.external.service import ExternalSystemService
|
||||
|
||||
__all__ = [
|
||||
"ExternalSystemAdapter",
|
||||
"TerminalInfo",
|
||||
"SecurityStatus",
|
||||
"VpnSession",
|
||||
"ExternalSystemConfig",
|
||||
"ExternalSystemCache",
|
||||
"MockAdapter",
|
||||
"ExternalSystemService",
|
||||
]
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 外部系统适配器抽象基类 + 统一数据模型
|
||||
# =============================================================================
|
||||
# 说明:
|
||||
# 1. 定义所有外部系统共用的抽象接口(ABC)
|
||||
# 2. 定义统一的DTO模型(TerminalInfo/SecurityStatus/VpnSession)
|
||||
# 3. 每个外部系统实现此接口,上层业务只依赖抽象接口
|
||||
#
|
||||
# 设计原则:
|
||||
# - 默认返回None/空 — 子类按需覆写自己支持的方法
|
||||
# - 不支持的能力不报错,返回None让调用方走降级逻辑
|
||||
# - raw_data字段保留原始响应,调试用,生产环境可关闭
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 统一数据模型(DTO)
|
||||
# =============================================================================
|
||||
|
||||
class TerminalInfo(BaseModel):
|
||||
"""统一终端信息模型 — 所有Adapter返回同一结构
|
||||
|
||||
做什么:把联软/火绒/aTrust不同格式的终端数据映射到统一结构
|
||||
为什么:上层业务代码不需要关心数据来自哪个系统
|
||||
"""
|
||||
|
||||
# ── 来源标识 ──
|
||||
source_system: str = Field(..., description="数据来源系统标识: lianruan/huorong/atrust/ehr")
|
||||
|
||||
# ── 基础标识 ──
|
||||
terminal_id: Optional[str] = Field(None, description="终端在来源系统中的唯一ID")
|
||||
computer_name: str = Field(..., description="计算机名")
|
||||
|
||||
# ── 网络信息 ──
|
||||
ip_addresses: List[str] = Field(default_factory=list, description="IP地址列表(含VPN虚拟IP)")
|
||||
mac_addresses: List[str] = Field(default_factory=list, description="MAC地址列表")
|
||||
|
||||
# ── 系统信息 ──
|
||||
os_version: Optional[str] = Field(None, description="操作系统版本")
|
||||
is_online: bool = Field(False, description="是否在线")
|
||||
|
||||
# ── 用户映射(核心字段)──
|
||||
logged_in_user: Optional[str] = Field(None, description="当前登录用户账号 — 映射核心字段")
|
||||
logged_in_user_name: Optional[str] = Field(None, description="用户姓名")
|
||||
department: Optional[str] = Field(None, description="所属部门")
|
||||
|
||||
# ── 硬件摘要 ──
|
||||
hardware_summary: Optional[Dict] = Field(None, description="硬件摘要(CPU/内存/磁盘使用率等)")
|
||||
|
||||
# ── 时间信息 ──
|
||||
last_seen: Optional[datetime] = Field(None, description="最后在线时间")
|
||||
|
||||
# ── 调试用 ──
|
||||
raw_data: Optional[Dict] = Field(None, description="原始响应数据(调试用,生产可关闭)")
|
||||
|
||||
|
||||
class VulnerabilityItem(BaseModel):
|
||||
"""漏洞条目"""
|
||||
name: str = Field(..., description="漏洞名称")
|
||||
level: str = Field("info", description="严重程度: critical/high/medium/low/info")
|
||||
description: Optional[str] = Field(None, description="漏洞描述")
|
||||
publish_time: Optional[str] = Field(None, description="发布时间")
|
||||
|
||||
|
||||
class SecurityStatus(BaseModel):
|
||||
"""统一安全状态模型
|
||||
|
||||
做什么:聚合火绒的病毒/漏洞/隔离数据
|
||||
为什么:坐席需要一目了然看到终端安全全貌
|
||||
"""
|
||||
|
||||
source_system: str = Field(..., description="数据来源系统标识")
|
||||
terminal_id: str = Field(..., description="终端ID")
|
||||
computer_name: Optional[str] = Field(None, description="计算机名")
|
||||
|
||||
# ── 安全指标 ──
|
||||
virus_total: int = Field(0, description="病毒事件总数")
|
||||
virus_uncleaned: int = Field(0, description="未处理病毒数")
|
||||
vulnerabilities: List[VulnerabilityItem] = Field(default_factory=list, description="高危漏洞列表")
|
||||
high_vuln_count: int = Field(0, description="高危漏洞数量")
|
||||
|
||||
# ── 隔离状态 ──
|
||||
is_isolated: bool = Field(False, description="是否被隔离")
|
||||
isolation_source: Optional[str] = Field(None, description="隔离来源系统")
|
||||
|
||||
# ── 检查时间 ──
|
||||
checked_at: datetime = Field(default_factory=datetime.now, description="检查时间")
|
||||
|
||||
|
||||
class VpnSession(BaseModel):
|
||||
"""VPN会话模型(仅aTrust)
|
||||
|
||||
做什么:描述一个aTrust VPN在线会话
|
||||
为什么:坐席需要知道远程员工是否通过VPN在线、VPN IP是什么
|
||||
"""
|
||||
|
||||
source_system: str = "atrust"
|
||||
session_id: Optional[str] = Field(None, description="会话ID(用于踢出操作)")
|
||||
username: str = Field(..., description="用户名(登录名)")
|
||||
display_name: Optional[str] = Field(None, description="显示名")
|
||||
remote_ip: str = Field(..., description="接入IP(公网IP或'内网IP')")
|
||||
vpn_ip: Optional[str] = Field(None, description="VPN虚拟内网IP — 火绒交叉匹配关键字段")
|
||||
is_trusted: bool = Field(False, description="终端是否已授信")
|
||||
os: Optional[str] = Field(None, description="接入终端操作系统")
|
||||
last_login: Optional[datetime] = Field(None, description="最后登录时间")
|
||||
domain: Optional[str] = Field(None, description="登录域")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 适配器抽象基类
|
||||
# =============================================================================
|
||||
|
||||
class ExternalSystemAdapter(ABC):
|
||||
"""外部系统适配器抽象基类
|
||||
|
||||
做什么:定义所有外部系统共用的接口规范
|
||||
为什么:让上层业务代码只依赖抽象接口,不感知底层系统差异
|
||||
|
||||
设计原则:
|
||||
- 默认方法返回None/空列表/False,子类按需覆写自己支持的能力
|
||||
- 不支持的能力不报错,让调用方走降级逻辑
|
||||
- 每个Adapter只负责一个外部系统的对接
|
||||
"""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def system_name(self) -> str:
|
||||
"""系统标识名称
|
||||
|
||||
返回值: 'lianruan' / 'huorong' / 'atrust' / 'ehr' / 'mock'
|
||||
"""
|
||||
...
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def is_available(self) -> bool:
|
||||
"""当前系统是否可用(凭证已配置+网络可达)
|
||||
|
||||
做什么:检查配置是否完整,不实际发起网络请求
|
||||
为什么:调用方可据此决定是否跳过本系统
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def health_check(self) -> bool:
|
||||
"""健康检查 — 验证凭证和网络连通性
|
||||
|
||||
做什么:实际发起一次轻量级API调用,确认系统可达
|
||||
为什么:定期健康检查可提前发现连接问题
|
||||
"""
|
||||
...
|
||||
|
||||
# =========================================================================
|
||||
# 终端查询能力
|
||||
# =========================================================================
|
||||
|
||||
async def get_terminal_by_user(self, username: str) -> Optional[TerminalInfo]:
|
||||
"""通过员工账号查询终端信息(映射核心方法)
|
||||
|
||||
做什么:输入员工账号,返回该员工使用的终端信息
|
||||
为什么:这是员工→终端映射的核心入口
|
||||
|
||||
各系统实现方式:
|
||||
- 联软:queryDevByParams(strusername=xxx) — 精确匹配
|
||||
- 火绒:_list(ip=xxx) — 需配合联软IP交叉匹配
|
||||
- aTrust:queryAll(bindUserList) — 终端绑定用户
|
||||
- eHR:不提供终端数据,返回None
|
||||
|
||||
Args:
|
||||
username: 员工账号(如 'songxian')
|
||||
|
||||
Returns:
|
||||
TerminalInfo 或 None(系统不支持或未找到)
|
||||
"""
|
||||
return None
|
||||
|
||||
async def get_terminal_by_computer(self, computer_name: str) -> Optional[TerminalInfo]:
|
||||
"""通过计算机名查询终端信息
|
||||
|
||||
Args:
|
||||
computer_name: 计算机名(如 'IT-SONGXIAN')
|
||||
"""
|
||||
return None
|
||||
|
||||
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
|
||||
"""查询终端详细信息(硬件/软件/网络配置)
|
||||
|
||||
做什么:返回比 get_terminal_by_user 更详细的信息
|
||||
为什么:排查时需要硬件配置、磁盘使用率、已安装软件等
|
||||
|
||||
各系统实现方式:
|
||||
- 联软:getDevAllInfo — 极详细(主板/CPU/内存/硬盘/网卡/显示器)
|
||||
- 火绒:_info2 — 中等详细(硬件/软件/网络配置)
|
||||
- aTrust/eHR:不支持
|
||||
|
||||
Args:
|
||||
terminal_id: 终端在来源系统中的唯一ID
|
||||
"""
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# 安全能力
|
||||
# =========================================================================
|
||||
|
||||
async def get_security_status(self, terminal_id: str) -> Optional[SecurityStatus]:
|
||||
"""获取终端安全状态(病毒/漏洞/隔离状态)
|
||||
|
||||
做什么:聚合安全指标,坐席一目了然
|
||||
为什么:安全问题通常需要紧急处理
|
||||
|
||||
仅火绒支持此接口。
|
||||
|
||||
Args:
|
||||
terminal_id: 终端ID(火绒的client_id)
|
||||
"""
|
||||
return None
|
||||
|
||||
async def isolate_terminal(self, terminal_id: str, reason: str) -> bool:
|
||||
"""隔离终端(断网)
|
||||
|
||||
做什么:调用火绒 _create(type=netctrl) 隔离终端
|
||||
为什么:安全事件紧急处理,阻断威胁扩散
|
||||
|
||||
仅火绒支持。调用前必须二次确认+审计日志记录。
|
||||
|
||||
Args:
|
||||
terminal_id: 终端ID
|
||||
reason: 隔离原因(记入审计日志)
|
||||
|
||||
Returns:
|
||||
True=成功, False=失败
|
||||
|
||||
Raises:
|
||||
NotImplementedError: 本系统不支持隔离操作
|
||||
"""
|
||||
raise NotImplementedError(f"{self.system_name} 不支持终端隔离")
|
||||
|
||||
async def unisolate_terminal(self, terminal_id: str) -> bool:
|
||||
"""解除终端隔离(恢复网络)
|
||||
|
||||
仅火绒支持。
|
||||
|
||||
Args:
|
||||
terminal_id: 终端ID
|
||||
|
||||
Returns:
|
||||
True=成功, False=失败
|
||||
"""
|
||||
raise NotImplementedError(f"{self.system_name} 不支持解除隔离")
|
||||
|
||||
# =========================================================================
|
||||
# VPN/在线状态能力
|
||||
# =========================================================================
|
||||
|
||||
async def get_vpn_sessions(self, username: Optional[str] = None) -> List[VpnSession]:
|
||||
"""查询VPN在线会话
|
||||
|
||||
做什么:获取当前通过aTrust在线的VPN会话
|
||||
为什么:坐席需要知道远程员工VPN状态和IP
|
||||
|
||||
仅aTrust支持。
|
||||
|
||||
Args:
|
||||
username: 可选,过滤指定用户
|
||||
|
||||
Returns:
|
||||
VPN会话列表
|
||||
"""
|
||||
return []
|
||||
|
||||
async def get_online_status(self, username: str) -> bool:
|
||||
"""查询用户是否在线
|
||||
|
||||
做什么:检查用户终端是否当前在线
|
||||
为什么:坐席需要知道用户是否可达
|
||||
|
||||
各系统实现方式:
|
||||
- 联软:existOnlineUser
|
||||
- 火绒:_list(is_online=True) + IP交叉匹配
|
||||
- aTrust:getUserStatus
|
||||
|
||||
Args:
|
||||
username: 员工账号
|
||||
|
||||
Returns:
|
||||
True=在线, False=离线或未知
|
||||
"""
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# 辅助方法
|
||||
# =========================================================================
|
||||
|
||||
def _log_not_implemented(self, method_name: str) -> None:
|
||||
"""记录未实现方法的调试日志
|
||||
|
||||
做什么:当子类未覆写某个方法时记录DEBUG级日志
|
||||
为什么:开发期帮助发现调用链路问题,生产环境可关闭DEBUG
|
||||
"""
|
||||
logger.debug(
|
||||
f"[{self.system_name}] {method_name} 未实现,"
|
||||
f"将走降级逻辑"
|
||||
)
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 外部系统数据缓存层
|
||||
# =============================================================================
|
||||
# 说明:
|
||||
# 1. 封装外部系统数据的缓存读写逻辑
|
||||
# 2. 统一缓存key格式:ext:{system}:{method}:{param_hash}
|
||||
# 3. 不同数据类型使用不同TTL(终端映射30分钟、安全状态5分钟等)
|
||||
# 4. Redis不可用时自动降级(不缓存,直接透传)
|
||||
#
|
||||
# 与 CacheService 的关系:
|
||||
# CacheService 是全局Redis客户端封装,ExternalSystemCache 基于它
|
||||
# 添加外部系统专用的缓存策略(TTL、key格式、刷新机制)
|
||||
# =============================================================================
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.services.cache_service import CacheService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 缓存TTL配置(秒)
|
||||
# =============================================================================
|
||||
|
||||
CACHE_TTL = {
|
||||
# 终端映射(员工→终端)— 映射关系不常变,缓存较长
|
||||
"terminal_mapping": 30 * 60, # 30分钟
|
||||
# 终端详情(硬件/软件)— 硬件配置极少变,缓存最长
|
||||
"terminal_detail": 60 * 60, # 60分钟
|
||||
# 安全状态(漏洞/病毒)— 安全状态需近实时,缓存短
|
||||
"security_status": 5 * 60, # 5分钟
|
||||
# VPN在线状态 — 在线状态变化快,缓存最短
|
||||
"vpn_status": 1 * 60, # 1分钟
|
||||
# eHR员工信息 — 静态数据,缓存最长
|
||||
"employee_info": 24 * 60 * 60, # 24小时
|
||||
}
|
||||
|
||||
|
||||
class ExternalSystemCache:
|
||||
"""外部系统数据缓存
|
||||
|
||||
做什么:为外部系统查询结果提供统一缓存读写
|
||||
为什么:减少外部API调用频率,降低延迟和出错率
|
||||
|
||||
降级策略:Redis不可用时,缓存读写均跳过,直接透传到外部系统
|
||||
"""
|
||||
|
||||
def __init__(self, cache_service: Optional[CacheService] = None):
|
||||
"""初始化缓存层
|
||||
|
||||
Args:
|
||||
cache_service: Redis缓存服务实例。None时降级为无缓存模式
|
||||
"""
|
||||
self._cache = cache_service
|
||||
|
||||
@staticmethod
|
||||
def _make_key(system: str, method: str, param: str) -> str:
|
||||
"""生成缓存key
|
||||
|
||||
做什么:按统一格式生成缓存key
|
||||
为什么:避免不同系统/方法的key冲突
|
||||
|
||||
Args:
|
||||
system: 系统标识(lianruan/huorong/atrust/ehr)
|
||||
method: 方法名(terminal_mapping/terminal_detail/...)
|
||||
param: 查询参数(用户名/计算机名等)
|
||||
|
||||
Returns:
|
||||
缓存key,格式: ext:lianruan:terminal_mapping:abc123
|
||||
"""
|
||||
# 对参数做哈希,避免特殊字符问题
|
||||
param_hash = hashlib.md5(param.encode()).hexdigest()[:12]
|
||||
return f"ext:{system}:{method}:{param_hash}"
|
||||
|
||||
async def get(self, system: str, method: str, param: str) -> Optional[Dict]:
|
||||
"""从缓存读取数据
|
||||
|
||||
做什么:按系统+方法+参数查找缓存
|
||||
为什么:命中缓存可避免一次外部API调用
|
||||
|
||||
Args:
|
||||
system: 系统标识
|
||||
method: 方法名
|
||||
param: 查询参数
|
||||
|
||||
Returns:
|
||||
缓存的字典数据,或 None(未命中/Redis不可用)
|
||||
"""
|
||||
if not self._cache or not self._cache.redis:
|
||||
return None
|
||||
|
||||
key = self._make_key(system, method, param)
|
||||
try:
|
||||
data = await self._cache.get(key)
|
||||
if data:
|
||||
logger.debug(f"缓存命中: {key}")
|
||||
return json.loads(data) if isinstance(data, str) else data
|
||||
return None
|
||||
except Exception as e:
|
||||
# Redis错误不阻断业务,降级为无缓存
|
||||
logger.warning(f"缓存读取失败(降级为无缓存): {key}, error={e}")
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
system: str,
|
||||
method: str,
|
||||
param: str,
|
||||
data: Dict,
|
||||
ttl_override: Optional[int] = None,
|
||||
) -> bool:
|
||||
"""写入缓存
|
||||
|
||||
做什么:将外部系统查询结果存入缓存
|
||||
为什么:后续相同查询可直接命中缓存
|
||||
|
||||
Args:
|
||||
system: 系统标识
|
||||
method: 方法名
|
||||
param: 查询参数
|
||||
data: 要缓存的数据
|
||||
ttl_override: 自定义TTL(秒),None则使用默认TTL
|
||||
|
||||
Returns:
|
||||
True=成功, False=失败/Redis不可用
|
||||
"""
|
||||
if not self._cache or not self._cache.redis:
|
||||
return False
|
||||
|
||||
key = self._make_key(system, method, param)
|
||||
ttl = ttl_override or CACHE_TTL.get(method, 5 * 60) # 默认5分钟
|
||||
|
||||
try:
|
||||
# 添加缓存时间戳,便于判断数据新鲜度
|
||||
data_with_meta = {
|
||||
**data,
|
||||
"_cached_at": datetime.now().isoformat(),
|
||||
"_source_system": system,
|
||||
}
|
||||
await self._cache.set(key, json.dumps(data_with_meta, default=str), ex=ttl)
|
||||
logger.debug(f"缓存写入: {key}, TTL={ttl}s")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"缓存写入失败: {key}, error={e}")
|
||||
return False
|
||||
|
||||
async def invalidate(self, system: str, method: str, param: str) -> bool:
|
||||
"""主动失效缓存
|
||||
|
||||
做什么:删除指定缓存条目
|
||||
为什么:外部数据变更时(如终端隔离后),需主动失效缓存
|
||||
|
||||
Args:
|
||||
system: 系统标识
|
||||
method: 方法名
|
||||
param: 查询参数
|
||||
|
||||
Returns:
|
||||
True=成功, False=失败/Redis不可用
|
||||
"""
|
||||
if not self._cache or not self._cache.redis:
|
||||
return False
|
||||
|
||||
key = self._make_key(system, method, param)
|
||||
try:
|
||||
await self._cache.delete(key)
|
||||
logger.debug(f"缓存失效: {key}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"缓存失效失败: {key}, error={e}")
|
||||
return False
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 外部系统连接配置管理
|
||||
# =============================================================================
|
||||
# 说明:
|
||||
# 1. 统一管理联软/火绒/aTrust/eHR四个系统的连接配置
|
||||
# 2. 支持从环境变量或 .env 文件读取
|
||||
# 3. 支持运行时切换 Mock 模式(所有请求走 MockAdapter)
|
||||
#
|
||||
# 配置优先级:环境变量 > .env 文件 > 默认值
|
||||
# =============================================================================
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ExternalSystemConfig(BaseModel):
|
||||
"""外部系统连接配置
|
||||
|
||||
做什么:集中管理所有外部系统的连接参数
|
||||
为什么:避免在代码中硬编码,支持环境隔离(开发/测试/生产)
|
||||
"""
|
||||
|
||||
# ── 联软LV7000 ──
|
||||
lianruan_base_url: str = Field(
|
||||
default="http://192.168.3.200:30098",
|
||||
description="联软API基础地址(端口30098)",
|
||||
)
|
||||
lianruan_api_account: Optional[str] = Field(
|
||||
default=None,
|
||||
description="联软API账号(ApiAccount参数)",
|
||||
)
|
||||
lianruan_api_password: Optional[str] = Field(
|
||||
default=None,
|
||||
description="联软API密码(ApiPassword参数)",
|
||||
)
|
||||
lianruan_enabled: bool = Field(
|
||||
default=False,
|
||||
description="联软适配器是否启用(凭证配置后自动启用)",
|
||||
)
|
||||
|
||||
# ── 火绒企业版 ──
|
||||
huorong_base_url: str = Field(
|
||||
default="http://huorong.oa.servyou-it.com:8080",
|
||||
description="火绒API基础地址(内网地址)",
|
||||
)
|
||||
huorong_access_key_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="火绒AccessKey ID",
|
||||
)
|
||||
huorong_access_key_secret: Optional[str] = Field(
|
||||
default=None,
|
||||
description="火绒AccessKey Secret(HMAC-SHA1签名用)",
|
||||
)
|
||||
huorong_enabled: bool = Field(
|
||||
default=False,
|
||||
description="火绒适配器是否启用",
|
||||
)
|
||||
|
||||
# ── aTrust零信任 ──
|
||||
atrust_base_url: str = Field(
|
||||
default="https://atrust.servyou-it.com:4433",
|
||||
description="aTrust API基础地址(HTTPS端口4433)",
|
||||
)
|
||||
atrust_api_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="aTrust API ID(x-ca-key Header)",
|
||||
)
|
||||
atrust_api_secret: Optional[str] = Field(
|
||||
default=None,
|
||||
description="aTrust API Secret(HMAC-SHA256签名密钥)",
|
||||
)
|
||||
atrust_directory_domain: Optional[str] = Field(
|
||||
default=None,
|
||||
description="aTrust用户目录域名(V3 API需要此参数)",
|
||||
)
|
||||
atrust_enabled: bool = Field(
|
||||
default=False,
|
||||
description="aTrust适配器是否启用",
|
||||
)
|
||||
|
||||
# ── 北森eHR ──
|
||||
ehr_base_url: Optional[str] = Field(
|
||||
default=None,
|
||||
description="eHR API基础地址",
|
||||
)
|
||||
ehr_client_id: Optional[str] = Field(
|
||||
default=None,
|
||||
description="eHR OAuth2.0 Client ID",
|
||||
)
|
||||
ehr_client_secret: Optional[str] = Field(
|
||||
default=None,
|
||||
description="eHR OAuth2.0 Client Secret",
|
||||
)
|
||||
ehr_enabled: bool = Field(
|
||||
default=False,
|
||||
description="eHR适配器是否启用",
|
||||
)
|
||||
|
||||
# ── 全局配置 ──
|
||||
cache_enabled: bool = Field(
|
||||
default=True,
|
||||
description="是否启用外部数据缓存(Redis)",
|
||||
)
|
||||
mock_mode: bool = Field(
|
||||
default=False,
|
||||
description="Mock模式 — True时所有请求走MockAdapter,不调真实API",
|
||||
)
|
||||
|
||||
class Config:
|
||||
env_prefix = "EXT_" # 环境变量前缀,如 EXT_LIANRUAN_BASE_URL
|
||||
|
||||
|
||||
def load_external_config() -> ExternalSystemConfig:
|
||||
"""从环境变量加载外部系统配置
|
||||
|
||||
做什么:读取 EXT_ 前缀的环境变量,构建配置对象
|
||||
为什么:生产环境通过环境变量注入敏感配置,不写入代码或文件
|
||||
|
||||
Returns:
|
||||
ExternalSystemConfig 实例
|
||||
"""
|
||||
config_dict = {}
|
||||
|
||||
# 映射关系:环境变量名 → 配置字段名
|
||||
env_mapping = {
|
||||
"EXT_LIANRUAN_BASE_URL": "lianruan_base_url",
|
||||
"EXT_LIANRUAN_API_ACCOUNT": "lianruan_api_account",
|
||||
"EXT_LIANRUAN_API_PASSWORD": "lianruan_api_password",
|
||||
"EXT_HUORONG_BASE_URL": "huorong_base_url",
|
||||
"EXT_HUORONG_ACCESS_KEY_ID": "huorong_access_key_id",
|
||||
"EXT_HUORONG_ACCESS_KEY_SECRET": "huorong_access_key_secret",
|
||||
"EXT_ATRUST_BASE_URL": "atrust_base_url",
|
||||
"EXT_ATRUST_API_ID": "atrust_api_id",
|
||||
"EXT_ATRUST_API_SECRET": "atrust_api_secret",
|
||||
"EXT_ATRUST_DIRECTORY_DOMAIN": "atrust_directory_domain",
|
||||
"EXT_EHR_BASE_URL": "ehr_base_url",
|
||||
"EXT_EHR_CLIENT_ID": "ehr_client_id",
|
||||
"EXT_EHR_CLIENT_SECRET": "ehr_client_secret",
|
||||
"EXT_CACHE_ENABLED": "cache_enabled",
|
||||
"EXT_MOCK_MODE": "mock_mode",
|
||||
}
|
||||
|
||||
for env_key, field_name in env_mapping.items():
|
||||
value = os.environ.get(env_key)
|
||||
if value is not None:
|
||||
# 布尔类型特殊处理
|
||||
if field_name in ("cache_enabled", "mock_mode"):
|
||||
config_dict[field_name] = value.lower() in ("true", "1", "yes")
|
||||
else:
|
||||
config_dict[field_name] = value
|
||||
|
||||
config = ExternalSystemConfig(**config_dict)
|
||||
|
||||
# 自动启用已有凭证的系统
|
||||
if config.lianruan_api_account and config.lianruan_api_password:
|
||||
config.lianruan_enabled = True
|
||||
if config.huorong_access_key_id and config.huorong_access_key_secret:
|
||||
config.huorong_enabled = True
|
||||
if config.atrust_api_id and config.atrust_api_secret:
|
||||
config.atrust_enabled = True
|
||||
if config.ehr_client_id and config.ehr_client_secret:
|
||||
config.ehr_enabled = True
|
||||
|
||||
return config
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Mock适配器(开发期使用)
|
||||
# =============================================================================
|
||||
# 说明:
|
||||
# 1. 在 Mock 模式下(EXT_MOCK_MODE=True),所有外部系统查询
|
||||
# 返回预置的 Mock 数据,不调用任何真实 API
|
||||
# 2. Mock 数据覆盖 P0 场景(终端查询、安全状态、VPN在线)
|
||||
# 3. 凭证未配置时自动降级到 MockAdapter,保证开发期无外部依赖
|
||||
#
|
||||
# 使用方式:
|
||||
# EXT_MOCK_MODE=True → 所有系统走 Mock
|
||||
# 某系统凭证未配置 → 单个系统自动降级到 Mock(在 service.py 中处理)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.external.base import (
|
||||
ExternalSystemAdapter,
|
||||
TerminalInfo,
|
||||
SecurityStatus,
|
||||
VpnSession,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Mock 数据工厂
|
||||
# =============================================================================
|
||||
|
||||
def _make_mock_terminal(username: str) -> TerminalInfo:
|
||||
"""生成 Mock 终端信息
|
||||
|
||||
做什么:为指定用户生成一个逼真的模拟终端数据
|
||||
为什么:开发期没有真实凭证时需要终端数据支撑会话排查流程
|
||||
"""
|
||||
return TerminalInfo(
|
||||
source_system="mock",
|
||||
terminal_id=f"mock-terminal-{username}",
|
||||
computer_name=f"{username.upper()}-PC01",
|
||||
ip_addresses=[f"192.168.{hash(username) % 255}.{100 + hash(username) % 155}"],
|
||||
mac_addresses=[f"00:16:3E:{hash(username) % 256:02X}:{hash(username + 'a') % 256:02X}:{hash(username + 'b') % 256:02X}"],
|
||||
os_version="Windows 11 专业版 23H2",
|
||||
is_online=True,
|
||||
logged_in_user=username,
|
||||
logged_in_user_name=_username_to_display_name(username),
|
||||
department=_guess_department(username),
|
||||
hardware_summary={
|
||||
"cpu": "Intel Core i7-12700",
|
||||
"memory_total_gb": 16,
|
||||
"memory_used_gb": 8,
|
||||
"disk_total_gb": 512,
|
||||
"disk_free_gb": 128,
|
||||
"disk_usage_pct": 75, # 模拟磁盘使用率较高
|
||||
},
|
||||
last_seen=datetime.now() - timedelta(minutes=5),
|
||||
raw_data=None, # Mock 数据不保留原始响应
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_security_status(terminal_id: str) -> SecurityStatus:
|
||||
"""生成 Mock 安全状态
|
||||
|
||||
做什么:生成一个模拟的安全状态数据
|
||||
为什么:开发期需要验证安全状态卡片、漏洞警告等UI渲染
|
||||
"""
|
||||
return SecurityStatus(
|
||||
source_system="mock",
|
||||
terminal_id=terminal_id,
|
||||
computer_name=f"MOCK-PC01",
|
||||
virus_total=2,
|
||||
virus_uncleaned=1,
|
||||
vulnerabilities=[
|
||||
{
|
||||
"name": "Microsoft Windows 安全更新 (CVE-2025-12345)",
|
||||
"level": "high",
|
||||
"description": "远程代码执行漏洞,需立即修补",
|
||||
"publish_time": (datetime.now() - timedelta(days=7)).isoformat(),
|
||||
},
|
||||
{
|
||||
"name": "火绒安全漏洞扫描:弱密码检测",
|
||||
"level": "medium",
|
||||
"description": "账户密码强度不足,建议修改",
|
||||
"publish_time": (datetime.now() - timedelta(days=3)).isoformat(),
|
||||
},
|
||||
],
|
||||
high_vuln_count=1,
|
||||
is_isolated=False,
|
||||
isolation_source=None,
|
||||
checked_at=datetime.now(),
|
||||
)
|
||||
|
||||
|
||||
def _make_mock_vpn_session(username: str) -> VpnSession:
|
||||
"""生成 Mock VPN 会话"""
|
||||
return VpnSession(
|
||||
source_system="mock",
|
||||
session_id=f"mock-session-{username}",
|
||||
username=username,
|
||||
display_name=_username_to_display_name(username),
|
||||
remote_ip=f"1{hash(username) % 100}.{hash(username + 'r') % 256}.{hash(username + 's') % 256}.{hash(username + 't') % 256}",
|
||||
vpn_ip=f"10.200.{hash(username) % 255}.{100 + hash(username) % 155}",
|
||||
is_trusted=True,
|
||||
os="Windows 11",
|
||||
last_login=datetime.now() - timedelta(minutes=30),
|
||||
domain="servyou.local",
|
||||
)
|
||||
|
||||
|
||||
def _username_to_display_name(username: str) -> str:
|
||||
"""Mock 用户名转换(简单映射)"""
|
||||
name_map = {
|
||||
"songxian": "宋献",
|
||||
"zhangsan": "张三",
|
||||
"lisi": "李四",
|
||||
"wangwu": "王五",
|
||||
}
|
||||
return name_map.get(username, username)
|
||||
|
||||
|
||||
def _guess_department(username: str) -> str:
|
||||
"""Mock 部门推断"""
|
||||
dept_map = {
|
||||
"songxian": "IT支持组",
|
||||
"zhangsan": "财务部",
|
||||
"lisi": "人力资源部",
|
||||
"wangwu": "研发部",
|
||||
}
|
||||
return dept_map.get(username, "未知部门")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# MockAdapter 实现
|
||||
# =============================================================================
|
||||
|
||||
class MockAdapter(ExternalSystemAdapter):
|
||||
"""Mock 适配器 — 开发期替代所有外部系统
|
||||
|
||||
做什么:提供逼真的模拟数据,让开发期可以不依赖任何外部系统
|
||||
为什么:阶段一MVP验证、前端开发、单元测试都需要稳定的数据来源
|
||||
|
||||
降级规则:
|
||||
- 所有方法均返回 Mock 数据
|
||||
- 支持常用测试用户:songxian / zhangsan / lisi / wangwu
|
||||
- is_available 固定返回 True(Mock 永远可用)
|
||||
"""
|
||||
|
||||
@property
|
||||
def system_name(self) -> str:
|
||||
return "mock"
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
"""Mock 永远可用"""
|
||||
return True
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""Mock 健康检查永远通过"""
|
||||
logger.debug("[MockAdapter] 健康检查 → OK(Mock模式)")
|
||||
return True
|
||||
|
||||
# ── 终端查询能力 ──
|
||||
|
||||
async def get_terminal_by_user(self, username: str) -> Optional[TerminalInfo]:
|
||||
"""Mock:通过账号查询终端
|
||||
|
||||
做什么:返回预置的 Mock 终端信息
|
||||
为什么:开发期坐席打开会话时需要看到终端画像
|
||||
"""
|
||||
logger.info(f"[MockAdapter] get_terminal_by_user({username}) → Mock数据")
|
||||
return _make_mock_terminal(username)
|
||||
|
||||
async def get_terminal_by_computer(self, computer_name: str) -> Optional[TerminalInfo]:
|
||||
"""Mock:通过计算机名查询终端"""
|
||||
logger.info(f"[MockAdapter] get_terminal_by_computer({computer_name}) → Mock数据")
|
||||
# 从计算机名反推用户名(简单逻辑)
|
||||
username = computer_name.split("-")[0].lower() if "-" in computer_name else "songxian"
|
||||
return _make_mock_terminal(username)
|
||||
|
||||
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
|
||||
"""Mock:查询终端详细信息"""
|
||||
logger.info(f"[MockAdapter] get_terminal_detail({terminal_id}) → Mock数据")
|
||||
return _make_mock_terminal("songxian")
|
||||
|
||||
# ── 安全能力 ──
|
||||
|
||||
async def get_security_status(self, terminal_id: str) -> Optional[SecurityStatus]:
|
||||
"""Mock:获取安全状态"""
|
||||
logger.info(f"[MockAdapter] get_security_status({terminal_id}) → Mock数据")
|
||||
return _make_mock_security_status(terminal_id)
|
||||
|
||||
async def isolate_terminal(self, terminal_id: str, reason: str) -> bool:
|
||||
"""Mock:隔离终端(Mock 模式仅记录日志)"""
|
||||
logger.warning(
|
||||
f"[MockAdapter] 隔离终端(Mock,不真实执行): "
|
||||
f"terminal={terminal_id}, reason={reason}"
|
||||
)
|
||||
return True # Mock 永远返回成功
|
||||
|
||||
async def unisolate_terminal(self, terminal_id: str) -> bool:
|
||||
"""Mock:解除隔离"""
|
||||
logger.warning(
|
||||
f"[MockAdapter] 解除隔离(Mock,不真实执行): terminal={terminal_id}"
|
||||
)
|
||||
return True
|
||||
|
||||
# ── VPN/在线状态 ──
|
||||
|
||||
async def get_vpn_sessions(self, username: Optional[str] = None) -> List[VpnSession]:
|
||||
"""Mock:查询VPN在线会话"""
|
||||
if username:
|
||||
return [_make_mock_vpn_session(username)]
|
||||
# 返回多个 Mock 会话
|
||||
return [
|
||||
_make_mock_vpn_session("songxian"),
|
||||
_make_mock_vpn_session("zhangsan"),
|
||||
]
|
||||
|
||||
async def get_online_status(self, username: str) -> bool:
|
||||
"""Mock:查询在线状态(Mock 永远返回 True)"""
|
||||
return True
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 外部系统统一门面服务
|
||||
# =============================================================================
|
||||
# 说明:
|
||||
# 1. 上层业务代码(AI Wingman、会话管理等)只依赖此类
|
||||
# 2. 按优先级链式查询(联软 → aTrust → eHR)
|
||||
# 3. 自动处理降级(系统不可用时跳到下一个)
|
||||
# 4. 所有方法均有详细行内注释(做什么 + 为什么)
|
||||
#
|
||||
# 使用方式:
|
||||
# from app.services.external import get_external_service
|
||||
# svc = get_external_service()
|
||||
# terminal = await svc.find_user_terminal("songxian")
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.cache_service import CacheService
|
||||
from app.services.external.base import (
|
||||
ExternalSystemAdapter,
|
||||
TerminalInfo,
|
||||
SecurityStatus,
|
||||
VpnSession,
|
||||
)
|
||||
from app.services.external.config import ExternalSystemConfig
|
||||
from app.services.external.cache import ExternalSystemCache
|
||||
from app.services.external.mock import MockAdapter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 全局单例(懒加载)
|
||||
# =============================================================================
|
||||
|
||||
_external_service_instance: Optional["ExternalSystemService"] = None
|
||||
|
||||
|
||||
def get_external_service() -> "ExternalSystemService":
|
||||
"""获取外部系统服务单例
|
||||
|
||||
做什么:返回全局唯一的 ExternalSystemService 实例
|
||||
为什么:避免重复初始化 Adapter,节省连接资源
|
||||
"""
|
||||
global _external_service_instance
|
||||
if _external_service_instance is None:
|
||||
raise RuntimeError(
|
||||
"ExternalSystemService 尚未初始化,"
|
||||
"请在应用启动时调用 init_external_service()"
|
||||
)
|
||||
return _external_service_instance
|
||||
|
||||
|
||||
def init_external_service(
|
||||
config: Optional[ExternalSystemConfig] = None,
|
||||
cache_service: Optional[Any] = None,
|
||||
) -> ExternalSystemService:
|
||||
"""初始化外部系统服务(应用启动时调用一次)
|
||||
|
||||
做什么:根据配置创建所有 Adapter,组装成 ExternalSystemService
|
||||
为什么:集中初始化,避免分散在各处创建 Adapter 实例
|
||||
|
||||
Args:
|
||||
config: 外部系统配置,None 时自动从环境变量加载
|
||||
cache_service: Redis 缓存服务实例,None 时降级为无缓存
|
||||
|
||||
Returns:
|
||||
初始化完成的 ExternalSystemService 实例
|
||||
"""
|
||||
global _external_service_instance
|
||||
|
||||
if config is None:
|
||||
from app.services.external.config import load_external_config
|
||||
config = load_external_config()
|
||||
|
||||
# 创建缓存层
|
||||
cache = ExternalSystemCache(cache_service) if cache_service else None
|
||||
|
||||
# 按优先级组装 Adapter 字典
|
||||
adapters: Dict[str, ExternalSystemAdapter] = {}
|
||||
|
||||
if config.mock_mode:
|
||||
# Mock 模式:所有系统走 MockAdapter
|
||||
logger.info("[External] Mock模式已启用,所有外部系统查询走Mock数据")
|
||||
mock = MockAdapter()
|
||||
adapters = {
|
||||
"lianruan": mock,
|
||||
"huorong": mock,
|
||||
"atrust": mock,
|
||||
"ehr": mock,
|
||||
}
|
||||
else:
|
||||
# ── 联软(主映射源P0)─────────────────────────────
|
||||
# 做什么:创建联软 Adapter(凭证已配置时启用)
|
||||
# 为什么:联软的 strusername 字段是员工→终端映射最可靠来源
|
||||
if config.lianruan_enabled:
|
||||
from app.services.external.lianruan_adapter import LianruanAdapter
|
||||
adapters["lianruan"] = LianruanAdapter(config)
|
||||
logger.info("[External] 联软适配器已启用")
|
||||
else:
|
||||
logger.warning(
|
||||
"[External] 联软适配器未启用(凭证未配置),"
|
||||
"终端映射功能将降级"
|
||||
)
|
||||
|
||||
# ── 火绒(安全源P0)─────────────────────────────────
|
||||
# 做什么:创建火绒 Adapter(凭证已配置时启用)
|
||||
# 为什么:火绒提供终端安全状态(病毒/漏洞/隔离),不参与映射
|
||||
if config.huorong_enabled:
|
||||
from app.services.external.huorong_adapter import HuorongAdapter
|
||||
adapters["huorong"] = HuorongAdapter(config)
|
||||
logger.info("[External] 火绒适配器已启用")
|
||||
else:
|
||||
logger.info("[External] 火绒适配器未启用(凭证未配置)")
|
||||
|
||||
# ── aTrust(VPN源P1)────────────────────────────────
|
||||
# 做什么:创建 aTrust Adapter(凭证已配置时启用)
|
||||
# 为什么:aTrust 提供 VPN 在线状态和虚拟IP,用于远程员工排查
|
||||
if config.atrust_enabled:
|
||||
from app.services.external.atrust_adapter import ATrustAdapter
|
||||
adapters["atrust"] = ATrustAdapter(config)
|
||||
logger.info("[External] aTrust适配器已启用")
|
||||
else:
|
||||
logger.info("[External] aTrust适配器未启用(凭证未配置)")
|
||||
|
||||
# ── eHR(辅助静态数据P2)───────────────────────────
|
||||
# 做什么:创建 eHR Adapter(凭证已配置时启用)
|
||||
# 为什么:eHR 提供员工基础信息和任职信息,作为静态数据补充
|
||||
if config.ehr_enabled:
|
||||
from app.services.external.ehr_adapter import EHRAdapter
|
||||
adapters["ehr"] = EHRAdapter(config)
|
||||
logger.info("[External] eHR适配器已启用")
|
||||
else:
|
||||
logger.info("[External] eHR适配器未启用(凭证未配置)")
|
||||
|
||||
_external_service_instance = ExternalSystemService(adapters, cache)
|
||||
logger.info(
|
||||
f"[External] 服务初始化完成,已加载适配器: "
|
||||
f"{list(adapters.keys())}"
|
||||
)
|
||||
return _external_service_instance
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 统一门面服务
|
||||
# =============================================================================
|
||||
|
||||
class ExternalSystemService:
|
||||
"""外部系统统一门面 — 上层业务唯一依赖的入口
|
||||
|
||||
做什么:按优先级链式查询外部系统,对上层屏蔽底层差异
|
||||
为什么:上层代码不需要知道终端数据来自联软还是火绒
|
||||
|
||||
查询优先级(映射场景):
|
||||
1. 联软(主源,strusername 精确匹配)
|
||||
2. aTrust(VPN源,bindUserList 匹配)
|
||||
3. eHR(静态辅助,无终端数据,返回None)
|
||||
|
||||
安全能力(仅火绒):
|
||||
- 获取安全状态(病毒/漏洞)
|
||||
- 隔离/解除终端(需admin角色+二次确认)
|
||||
|
||||
VPN能力(仅aTrust):
|
||||
- 查询在线会话
|
||||
- 踢出用户
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapters: Dict[str, ExternalSystemAdapter],
|
||||
cache: Optional[ExternalSystemCache] = None,
|
||||
):
|
||||
"""初始化统一门面
|
||||
|
||||
Args:
|
||||
adapters: 系统标识 → Adapter 实例 的字典
|
||||
cache: 外部数据缓存层(可为None,降级为无缓存)
|
||||
"""
|
||||
self._adapters = adapters
|
||||
self._cache = cache
|
||||
|
||||
# =========================================================================
|
||||
# 终端查询(映射核心)
|
||||
# =========================================================================
|
||||
|
||||
async def find_user_terminal(self, username: str) -> Optional[TerminalInfo]:
|
||||
"""查找用户终端 — 按优先级链式查询
|
||||
|
||||
做什么:根据员工账号查找其使用的终端信息
|
||||
为什么:这是员工→终端映射的核心入口,坐席排查时首先需要知道
|
||||
员工用哪台电脑
|
||||
|
||||
查询顺序:
|
||||
1. 联软 queryDevByParams(strusername=xxx) — 最精确
|
||||
2. aTrust queryAll(bindUserList) — VPN 场景补充
|
||||
3. eHR — 无终端数据,返回 None
|
||||
|
||||
降级:某系统不可用时自动跳过,不影响整体结果。
|
||||
|
||||
Args:
|
||||
username: 员工账号(如 'songxian')
|
||||
|
||||
Returns:
|
||||
TerminalInfo 或 None(所有系统均未找到)
|
||||
"""
|
||||
logger.info(f"[External] 查找用户终端: username={username}")
|
||||
|
||||
# ── 第1优先级:联软(主映射源)────────────────────
|
||||
# 做什么:优先用联软查,它有 strusername 精确字段
|
||||
# 为什么:联软直接建立员工账号→终端的映射,比IP交叉匹配可靠
|
||||
lianruan = self._adapters.get("lianruan")
|
||||
if lianruan and lianruan.is_available:
|
||||
try:
|
||||
result = await self._query_with_cache(
|
||||
"lianruan", "get_terminal_by_user", username
|
||||
)
|
||||
if result:
|
||||
logger.info(
|
||||
f"[External] 联软命中: username={username}, "
|
||||
f"computer={result.computer_name}"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
# 联软不可用 → 降级到 aTrust,不阻断
|
||||
logger.warning(
|
||||
f"[External] 联软查询失败(降级到aTrust): {e}"
|
||||
)
|
||||
else:
|
||||
logger.debug("[External] 联软不可用或未启用,跳过")
|
||||
|
||||
# ── 第2优先级:aTrust(VPN源)─────────────────────
|
||||
# 做什么:联软未命中时,用 aTrust 查 VPN 终端
|
||||
# 为什么:远程办公员工可能不在联软覆盖范围内
|
||||
atrust = self._adapters.get("atrust")
|
||||
if atrust and atrust.is_available:
|
||||
try:
|
||||
result = await self._query_with_cache(
|
||||
"atrust", "get_terminal_by_user", username
|
||||
)
|
||||
if result:
|
||||
logger.info(
|
||||
f"[External] aTrust命中: username={username}, "
|
||||
f"vpn_ip={result.ip_addresses}"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[External] aTrust查询失败(降级到eHR): {e}"
|
||||
)
|
||||
else:
|
||||
logger.debug("[External] aTrust不可用或未启用,跳过")
|
||||
|
||||
# ── 第3优先级:eHR(辅助静态数据)────────────────
|
||||
# 做什么:eHR 不提供终端数据,此方法返回 None
|
||||
# 为什么:保留接口一致性,未来可能扩展
|
||||
ehr = self._adapters.get("ehr")
|
||||
if ehr and ehr.is_available:
|
||||
result = await self._query_with_cache(
|
||||
"ehr", "get_terminal_by_user", username
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# 所有系统均未命中
|
||||
logger.info(f"[External] 所有系统均未找到用户终端: username={username}")
|
||||
return None
|
||||
|
||||
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
|
||||
"""查询终端详细信息(硬件/软件/网络配置)
|
||||
|
||||
做什么:获取比 find_user_terminal 更详细的终端信息
|
||||
为什么:排查硬件故障(卡慢)时需要CPU/内存/磁盘使用率等数据
|
||||
|
||||
优先联软(getDevAllInfo 比火绒 _info2 更详细)。
|
||||
|
||||
Args:
|
||||
terminal_id: 终端在来源系统中的唯一ID
|
||||
|
||||
Returns:
|
||||
TerminalInfo(含 hardware_summary)或 None
|
||||
"""
|
||||
# 联软详细信息最全(含主板/CPU/内存/硬盘/显示器)
|
||||
lianruan = self._adapters.get("lianruan")
|
||||
if lianruan and lianruan.is_available:
|
||||
try:
|
||||
return await lianruan.get_terminal_detail(terminal_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[External] 联软详细信息查询失败: {e}")
|
||||
|
||||
# 火绒作为备选(_info2 含硬件/软件/网络配置)
|
||||
huorong = self._adapters.get("huorong")
|
||||
if huorong and huorong.is_available:
|
||||
try:
|
||||
return await huorong.get_terminal_detail(terminal_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"[External] 火绒详细信息查询失败: {e}")
|
||||
|
||||
return None
|
||||
|
||||
# =========================================================================
|
||||
# 安全能力(仅火绒)
|
||||
# =========================================================================
|
||||
|
||||
async def get_terminal_security(self, terminal_id: str) -> Optional[SecurityStatus]:
|
||||
"""获取终端安全状态
|
||||
|
||||
做什么:查询终端的病毒事件、高危漏洞、隔离状态
|
||||
为什么:坐席排查安全问题时需要一目了然
|
||||
|
||||
仅火绒支持此能力。
|
||||
|
||||
Args:
|
||||
terminal_id: 火绒的 client_id
|
||||
|
||||
Returns:
|
||||
SecurityStatus 或 None(火绒不可用)
|
||||
"""
|
||||
huorong = self._adapters.get("huorong")
|
||||
if not huorong or not huorong.is_available:
|
||||
logger.warning("[External] 火绒不可用,无法获取安全状态")
|
||||
return None
|
||||
|
||||
try:
|
||||
return await self._query_with_cache(
|
||||
"huorong", "get_security_status", terminal_id
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[External] 获取安全状态失败: {e}")
|
||||
return None
|
||||
|
||||
async def isolate_terminal(
|
||||
self, terminal_id: str, reason: str, operator: str
|
||||
) -> bool:
|
||||
"""隔离终端(断网)
|
||||
|
||||
做什么:调用火绒 _create(type=netctrl) 隔离终端
|
||||
为什么:安全事件紧急处理,阻断威胁扩散
|
||||
|
||||
仅火绒支持。调用前必须在上层做:
|
||||
1. 操作者角色校验(仅 admin 可操作)
|
||||
2. 二次确认弹窗
|
||||
3. 审计日志记录
|
||||
|
||||
Args:
|
||||
terminal_id: 火绒的 client_id
|
||||
reason: 隔离原因(记入审计日志)
|
||||
operator: 操作者账号
|
||||
|
||||
Returns:
|
||||
True=成功, False=失败
|
||||
"""
|
||||
huorong = self._adapters.get("huorong")
|
||||
if not huorong or not huorong.is_available:
|
||||
logger.error("[External] 火绒不可用,无法执行隔离")
|
||||
return False
|
||||
|
||||
logger.warning(
|
||||
f"[External] 执行终端隔离: terminal={terminal_id}, "
|
||||
f"operator={operator}, reason={reason}"
|
||||
)
|
||||
try:
|
||||
return await huorong.isolate_terminal(terminal_id, reason)
|
||||
except Exception as e:
|
||||
logger.error(f"[External] 隔离失败: {e}")
|
||||
return False
|
||||
|
||||
async def unisolate_terminal(self, terminal_id: str) -> bool:
|
||||
"""解除终端隔离(恢复网络)"""
|
||||
huorong = self._adapters.get("huorong")
|
||||
if not huorong or not huorong.is_available:
|
||||
return False
|
||||
try:
|
||||
return await huorong.unisolate_terminal(terminal_id)
|
||||
except Exception as e:
|
||||
logger.error(f"[External] 解除隔离失败: {e}")
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# VPN/在线状态(仅aTrust)
|
||||
# =========================================================================
|
||||
|
||||
async def get_vpn_sessions(
|
||||
self, username: Optional[str] = None
|
||||
) -> List[VpnSession]:
|
||||
"""查询VPN在线会话
|
||||
|
||||
做什么:获取当前通过aTrust在线的VPN会话列表
|
||||
为什么:坐席需要知道远程员工是否在线、VPN IP是什么
|
||||
|
||||
仅aTrust支持。
|
||||
|
||||
Args:
|
||||
username: 可选,过滤指定用户的会话
|
||||
|
||||
Returns:
|
||||
VPN会话列表(可能为空)
|
||||
"""
|
||||
atrust = self._adapters.get("atrust")
|
||||
if not atrust or not atrust.is_available:
|
||||
return []
|
||||
try:
|
||||
return await atrust.get_vpn_sessions(username)
|
||||
except Exception as e:
|
||||
logger.warning(f"[External] 查询VPN会话失败: {e}")
|
||||
return []
|
||||
|
||||
async def get_online_status(self, username: str) -> bool:
|
||||
"""查询用户是否在线
|
||||
|
||||
做什么:检查用户当前是否在线(任何方式接入)
|
||||
为什么:坐席发起协作或推送消息前需要知道用户是否可达
|
||||
|
||||
Args:
|
||||
username: 员工账号
|
||||
|
||||
Returns:
|
||||
True=在线, False=离线或未知
|
||||
"""
|
||||
# 优先联软(内网接入)
|
||||
lianruan = self._adapters.get("lianruan")
|
||||
if lianruan and lianruan.is_available:
|
||||
try:
|
||||
if await lianruan.get_online_status(username):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 再查 aTrust(VPN接入)
|
||||
atrust = self._adapters.get("atrust")
|
||||
if atrust and atrust.is_available:
|
||||
try:
|
||||
if await atrust.get_online_status(username):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
# =========================================================================
|
||||
# 内部方法
|
||||
# =========================================================================
|
||||
|
||||
async def _query_with_cache(
|
||||
self, system: str, method: str, param: str
|
||||
) -> Any:
|
||||
"""带缓存的查询(内部方法)
|
||||
|
||||
做什么:先查缓存,未命中则调Adapter,结果写回缓存
|
||||
为什么:减少外部API调用频率,降低延迟
|
||||
|
||||
Args:
|
||||
system: 系统标识
|
||||
method: 方法名(用于缓存key)
|
||||
param: 查询参数(用于缓存key)
|
||||
|
||||
Returns:
|
||||
Adapter返回的数据(可能经缓存)
|
||||
"""
|
||||
# 步骤1:尝试从缓存读取
|
||||
if self._cache:
|
||||
cached = await self._cache.get(system, method, param)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
# 步骤2:缓存未命中,调用Adapter
|
||||
adapter = self._adapters.get(system)
|
||||
if not adapter:
|
||||
raise RuntimeError(f"Adapter不存在: {system}")
|
||||
|
||||
method_map = {
|
||||
"get_terminal_by_user": adapter.get_terminal_by_user,
|
||||
"get_terminal_by_computer": adapter.get_terminal_by_computer,
|
||||
"get_terminal_detail": adapter.get_terminal_detail,
|
||||
"get_security_status": adapter.get_security_status,
|
||||
"get_vpn_sessions": adapter.get_vpn_sessions,
|
||||
"get_online_status": adapter.get_online_status,
|
||||
}
|
||||
|
||||
if method not in method_map:
|
||||
raise RuntimeError(f"未知方法: {method}")
|
||||
|
||||
result = await method_map[method](param)
|
||||
|
||||
# 步骤3:写入缓存(仅非None结果)
|
||||
if result and self._cache:
|
||||
# 转成可序列化的字典
|
||||
data = result.dict() if hasattr(result, "dict") else result
|
||||
await self._cache.set(system, method, param, data)
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,157 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 趣味话术服务
|
||||
# =============================================================================
|
||||
# 说明:管理各场景的趣味话术,包括:
|
||||
# 1. 根据触发场景返回对应话术
|
||||
# 2. 从 funny_phrases 表读取话术配置
|
||||
# 3. 支持按员工 VIP 等级自动切换话术(VIP → 正式版话术)
|
||||
# 4. 预置 6 种场景的默认话术
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.funny_phrase import FunnyPhrase
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FunnyPhraseService:
|
||||
"""趣味话术服务。
|
||||
|
||||
根据触发场景返回对应的趣味话术。
|
||||
支持后台动态修改话术内容(通过 funny_phrases 表)。
|
||||
"""
|
||||
|
||||
# 默认话术(当数据库未配置时使用,和 PRD 一致)
|
||||
DEFAULT_PHRASES = {
|
||||
"shake": "大哥,俺这就去摇人,稍等...",
|
||||
"keyword": "收到!这就帮您摇位大神来",
|
||||
"waiting": "人还在路上,别急别急~",
|
||||
"connected": "人摇来了!IT坐席为您服务",
|
||||
"timeout": "坐席都在忙,不过AI还在呢,要不先聊聊?我再继续摇",
|
||||
"vip": "这就帮您安排专家,请稍候",
|
||||
}
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""初始化趣味话术服务。
|
||||
|
||||
Args:
|
||||
db: 异步数据库会话
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取话术
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_phrase(
|
||||
self, scene: str, is_vip: bool = False
|
||||
) -> str:
|
||||
"""根据触发场景获取趣味话术。
|
||||
|
||||
优先从 funny_phrases 表读取,如果未配置则使用默认话术。
|
||||
VIP 员工自动使用 "vip" 场景的话术。
|
||||
|
||||
场景说明:
|
||||
- click_shake / shake: 点击摇人按钮
|
||||
- keyword: 关键词触发转人工
|
||||
- waiting: 排队等待(30秒无人接单)
|
||||
- connected: 坐席接入
|
||||
- timeout: 等待超时(2分钟)
|
||||
- vip: VIP员工专用
|
||||
|
||||
Args:
|
||||
scene: 触发场景(shake/keyword/waiting/connected/timeout/vip)
|
||||
is_vip: 是否 VIP 员工(VIP 优先使用 vip 场景话术)
|
||||
|
||||
Returns:
|
||||
str: 话术内容
|
||||
"""
|
||||
# VIP 员工优先使用 vip 场景话术
|
||||
actual_scene = scene
|
||||
if is_vip and scene != "vip":
|
||||
# 尝试获取 VIP 话术,如果不存在则回退到原场景
|
||||
vip_phrase = await self._get_phrase_from_db("vip")
|
||||
if vip_phrase:
|
||||
logger.debug(f"VIP员工使用专属话术: scene=vip")
|
||||
return vip_phrase
|
||||
|
||||
# 从数据库获取对应场景的话术
|
||||
phrase = await self._get_phrase_from_db(actual_scene)
|
||||
|
||||
if phrase:
|
||||
return phrase
|
||||
|
||||
# 数据库未配置,使用默认话术
|
||||
default = self.DEFAULT_PHRASES.get(actual_scene, "请稍候...")
|
||||
logger.debug(f"使用默认话术: scene={actual_scene}")
|
||||
return default
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 从数据库获取话术
|
||||
# --------------------------------------------------------------------------
|
||||
async def _get_phrase_from_db(self, scene: str) -> Optional[str]:
|
||||
"""从 funny_phrases 表获取指定场景的话术。
|
||||
|
||||
同一场景可能有多条话术,随机返回一条(增加趣味性)。
|
||||
只返回 is_active=True 的话术。
|
||||
|
||||
Args:
|
||||
scene: 触发场景
|
||||
|
||||
Returns:
|
||||
Optional[str]: 话术内容,未找到返回 None
|
||||
"""
|
||||
stmt = (
|
||||
select(FunnyPhrase)
|
||||
.where(
|
||||
FunnyPhrase.scene == scene,
|
||||
FunnyPhrase.is_active == True,
|
||||
)
|
||||
.order_by(FunnyPhrase.sort_order)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
phrases = list(result.scalars().all())
|
||||
|
||||
if not phrases:
|
||||
return None
|
||||
|
||||
# 随机选一条(如果有多个话术,增加随机趣味性)
|
||||
chosen = random.choice(phrases)
|
||||
return chosen.content
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取所有场景的话术
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_all_phrases(self) -> dict:
|
||||
"""获取所有场景的话术。
|
||||
|
||||
用于后台管理页面展示当前话术配置。
|
||||
|
||||
Returns:
|
||||
dict: 按场景分组的话术字典
|
||||
"""
|
||||
stmt = select(FunnyPhrase).order_by(
|
||||
FunnyPhrase.scene, FunnyPhrase.sort_order
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
phrases = list(result.scalars().all())
|
||||
|
||||
# 按场景分组
|
||||
grouped: dict = {}
|
||||
for phrase in phrases:
|
||||
if phrase.scene not in grouped:
|
||||
grouped[phrase.scene] = []
|
||||
grouped[phrase.scene].append({
|
||||
"id": str(phrase.id),
|
||||
"content": phrase.content,
|
||||
"tone": phrase.tone,
|
||||
"sort_order": phrase.sort_order,
|
||||
"is_active": phrase.is_active,
|
||||
})
|
||||
|
||||
return grouped
|
||||
@@ -0,0 +1,671 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 消息路由核心服务
|
||||
# =============================================================================
|
||||
# 说明:消息路由层是整个系统的"大脑",负责:
|
||||
# 1. 接收企微回调消息,路由到不同处理逻辑
|
||||
# 2. 查找或创建会话
|
||||
# 3. AI 自动回复(新会话 / AI 处理中的会话)
|
||||
# 4. 触发 VIP 检测
|
||||
# 5. 触发标记检测(举手/需介入/情绪)
|
||||
# 6. 触发紧急度评分
|
||||
# 7. 消息入库
|
||||
#
|
||||
# 路由策略(含 AI):
|
||||
# - 新会话 → ai_handling → AIHandler 处理 → 命中回复 / 未命中转 queued
|
||||
# - AI 处理中的会话 → AIHandler 处理 → 命中回复 / 未命中转 queued
|
||||
# - 排队中/服务中的会话 → 追加消息(坐席人工处理)
|
||||
#
|
||||
# 重构记录(2026-06):
|
||||
# - 替换 ai_service 为 ai_handler(统一 AI 调用逻辑)
|
||||
# - AIHandler 包含打招呼检测和呼叫人工拦截,两端行为完全一致
|
||||
# - 举手检测仅用于标记,不再强制跳过 AI(由 AIHandler 统一处理呼叫人工)
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.ai_handler import AIHandler, AIReplyResult
|
||||
from app.services.cache_service import CacheService
|
||||
from app.services.scoring_service import ScoringService
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageRouter:
|
||||
"""消息路由核心服务。
|
||||
|
||||
接收企微回调消息后,按流程处理:
|
||||
1. find_or_create_conversation — 查找或创建会话(新会话默认 ai_handling)
|
||||
2. AI 自动回复(仅对 ai_handling 状态的会话,通过 AIHandler 统一处理)
|
||||
3. VIP 检测(从企微通讯录获取员工信息)
|
||||
4. 标记检测(举手/情绪/需介入)
|
||||
5. 紧急度评分
|
||||
6. 更新会话标记和紧急度
|
||||
7. 创建消息记录
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
wecom_service: WecomService,
|
||||
scoring_service: ScoringService,
|
||||
ai_handler: Optional[AIHandler] = None,
|
||||
cache_service: Optional[CacheService] = None,
|
||||
):
|
||||
"""初始化消息路由器。
|
||||
|
||||
Args:
|
||||
db: 异步数据库会话
|
||||
wecom_service: 企微 API 服务(发送消息、获取用户信息)
|
||||
scoring_service: 评分服务(标记检测 + 紧急度计算)
|
||||
ai_handler: AI 处理器(可选,为 None 时跳过 AI 处理)
|
||||
cache_service: 缓存服务(可选,为 None 时跳过去重检查)
|
||||
"""
|
||||
self.db = db
|
||||
self.wecom_service = wecom_service
|
||||
self.scoring_service = scoring_service
|
||||
self.ai_handler = ai_handler
|
||||
self.cache_service = cache_service
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 路由消息(核心入口方法)
|
||||
# --------------------------------------------------------------------------
|
||||
async def route_message(
|
||||
self,
|
||||
from_user_id: str,
|
||||
content: str,
|
||||
msg_type: str = "text",
|
||||
msg_id: Optional[str] = None,
|
||||
# 非文本消息扩展参数(轻量版:只存元数据,不下载媒体文件)
|
||||
media_id: Optional[str] = None,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
file_name: Optional[str] = None,
|
||||
file_size: Optional[int] = None,
|
||||
) -> Optional[Conversation]:
|
||||
"""路由消息的核心方法。
|
||||
|
||||
处理流程:
|
||||
0. 消息去重检查(MsgId 去重 + 用户+内容去重)
|
||||
1. 非文本消息 → _handle_non_text_message(自动回复 + 入库,不触发 AI)
|
||||
2. 文本消息:
|
||||
a. 查找或创建会话(新会话默认 ai_handling)
|
||||
b. AI 自动回复(仅对 ai_handling 状态的会话,通过 AIHandler 统一处理)
|
||||
c. VIP 检测
|
||||
d. 标记检测(举手/情绪/需介入)
|
||||
e. 紧急度评分
|
||||
f. 更新会话
|
||||
g. 创建消息记录
|
||||
h. 广播 WebSocket 事件
|
||||
|
||||
重构说明:举手检测不再强制跳过 AI,由 AIHandler 统一处理呼叫人工拦截。
|
||||
举手关键词仍用于设置 tag(影响紧急度评分),但不影响 AI 调用决策。
|
||||
|
||||
Args:
|
||||
from_user_id: 发送消息的员工企微 UserID
|
||||
content: 消息内容(非文本消息时可能为空)
|
||||
msg_type: 消息类型(默认 text)
|
||||
msg_id: 企微消息唯一 ID(MsgId),用于去重
|
||||
media_id: 企微媒体文件ID(非文本消息时使用)
|
||||
extra_data: 扩展元数据(pic_url/format/location 等)
|
||||
file_name: 文件名(文件消息时使用)
|
||||
file_size: 文件大小(字节,文件消息时使用)
|
||||
|
||||
Returns:
|
||||
Optional[Conversation]: 更新后的会话对象,去重命中时返回 None
|
||||
"""
|
||||
logger.info(
|
||||
f"收到员工消息: employee_id={from_user_id}, "
|
||||
f"content={content[:50]}{'...' if len(content) > 50 else ''}, "
|
||||
f"msg_type={msg_type}, msg_id={msg_id}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------
|
||||
# 0. 消息去重检查(幂等保护,防止企微重复推送)
|
||||
# ----------------------------------------------------------
|
||||
if self.cache_service:
|
||||
# 0a. 基于 MsgId 去重(与企微重试窗口一致,5 分钟)
|
||||
if msg_id and await self.cache_service.is_duplicate(msg_id):
|
||||
logger.info(
|
||||
f"MsgId 去重命中,跳过处理: msg_id={msg_id}, "
|
||||
f"from_user_id={from_user_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
# 0b. 基于 user_id + content 去重(防快速重复发送,60 秒窗口)
|
||||
if content and await self.cache_service.is_duplicate_content(
|
||||
user_id=from_user_id, content=content
|
||||
):
|
||||
logger.info(
|
||||
f"内容去重命中,跳过处理: from_user_id={from_user_id}, "
|
||||
f"content={content[:30]}{'...' if len(content) > 30 else ''}"
|
||||
)
|
||||
return None
|
||||
|
||||
# 非文本消息走独立处理路径(不触发 AI、不评分、不标记检测)
|
||||
if msg_type != "text":
|
||||
return await self._handle_non_text_message(
|
||||
from_user_id=from_user_id,
|
||||
content=content,
|
||||
msg_type=msg_type,
|
||||
media_id=media_id,
|
||||
extra_data=extra_data,
|
||||
file_name=file_name,
|
||||
file_size=file_size,
|
||||
)
|
||||
|
||||
# 1. 查找或创建会话(新会话默认 ai_handling)
|
||||
conversation = await self._find_or_create_conversation(
|
||||
from_user_id, content
|
||||
)
|
||||
|
||||
# 2. 举手检测(仅用于标记,不跳过 AI)
|
||||
is_hand_raise = self.scoring_service.detect_hand_raise(content)
|
||||
|
||||
# 3. AI 自动回复(仅对 ai_handling 状态的会话)
|
||||
# AIHandler 内部会处理打招呼/呼叫人工/AI调用,统一行为
|
||||
ai_replied = False
|
||||
if (
|
||||
self.ai_handler
|
||||
and conversation.status == "ai_handling"
|
||||
):
|
||||
ai_replied = await self._try_ai_reply(
|
||||
conversation=conversation,
|
||||
content=content,
|
||||
from_user_id=from_user_id,
|
||||
)
|
||||
|
||||
# 4. VIP 检测(只在会话首次创建或未检测过时执行)
|
||||
if not conversation.is_vip and conversation.department == "":
|
||||
await self._check_vip(conversation)
|
||||
|
||||
# 5. 标记检测
|
||||
tags = dict(conversation.tags) if conversation.tags else {}
|
||||
|
||||
# 5a. 举手标记检测
|
||||
if is_hand_raise:
|
||||
tags["hand_raise"] = True
|
||||
logger.info(f"举手标记触发: employee_id={from_user_id}")
|
||||
|
||||
# 5b. 情绪标记检测
|
||||
emotion = self.scoring_service.detect_emotion(content)
|
||||
if emotion != "neutral":
|
||||
tags["emotion"] = emotion
|
||||
# 记录触发情绪标记的关键词
|
||||
emotion_keywords = self.scoring_service.get_emotion_keywords(content, emotion)
|
||||
if emotion_keywords:
|
||||
tags["emotion_keywords"] = emotion_keywords
|
||||
logger.info(f"情绪标记触发: employee_id={from_user_id}, emotion={emotion}")
|
||||
|
||||
# 5c. 需介入标记检测(基于追问轮次)
|
||||
is_need_intervene = await self.scoring_service.detect_need_intervene(
|
||||
conversation.id, self.db
|
||||
)
|
||||
if is_need_intervene:
|
||||
tags["need_intervene"] = True
|
||||
logger.info(f"需介入标记触发: employee_id={from_user_id}")
|
||||
|
||||
# 5d. 更新追问轮次计数
|
||||
repeat_count = tags.get("repeat_count", 0)
|
||||
tags["repeat_count"] = repeat_count + 1
|
||||
|
||||
# 6. 紧急度评分
|
||||
urgency_score = await self.scoring_service.calculate_urgency(
|
||||
content=content,
|
||||
tags=tags,
|
||||
is_vip=conversation.is_vip,
|
||||
)
|
||||
logger.info(
|
||||
f"会话标记更新: conv_id={conversation.id}, "
|
||||
f"tags={json.dumps(tags, ensure_ascii=False)}, urgency={urgency_score}"
|
||||
)
|
||||
|
||||
# 7. 更新会话
|
||||
conversation.tags = tags
|
||||
conversation.urgency_score = urgency_score
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.last_message_summary = content[:256]
|
||||
conversation.updated_at = datetime.now()
|
||||
self.db.add(conversation)
|
||||
await self.db.flush()
|
||||
|
||||
# 8. 创建消息记录(员工消息)
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="employee",
|
||||
sender_id=from_user_id,
|
||||
sender_name=conversation.employee_name,
|
||||
content=content,
|
||||
msg_type=msg_type,
|
||||
is_read=False,
|
||||
)
|
||||
self.db.add(message)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"消息路由完成: conv_id={conversation.id}, "
|
||||
f"status={conversation.status}, urgency={urgency_score}, "
|
||||
f"ai_replied={ai_replied}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 9. 广播 WebSocket 事件
|
||||
# ----------------------------------------------------------------------
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(message.id),
|
||||
"sender_type": "employee",
|
||||
"sender_id": from_user_id,
|
||||
"content": content,
|
||||
"urgency_score": urgency_score,
|
||||
"tags": tags,
|
||||
"ai_replied": ai_replied,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
|
||||
|
||||
return conversation
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 非文本消息处理(轻量版:自动回复 + 入库,不触发 AI)
|
||||
# --------------------------------------------------------------------------
|
||||
async def _handle_non_text_message(
|
||||
self,
|
||||
from_user_id: str,
|
||||
content: str,
|
||||
msg_type: str,
|
||||
media_id: Optional[str] = None,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
file_name: Optional[str] = None,
|
||||
file_size: Optional[int] = None,
|
||||
) -> Conversation:
|
||||
"""处理非文本消息(图片/语音/视频/文件/位置)。
|
||||
|
||||
轻量版策略:
|
||||
- 图片:礼貌回复引导用户补充文字描述
|
||||
- 其余类型:统一回复暂不支持
|
||||
- 所有消息存入数据库
|
||||
- 不触发 AI 分析(不调用 Dify API)
|
||||
- 不改变会话状态(非文本不影响 AI 对话状态)
|
||||
- 不下载媒体文件,只存储企微回传的元数据
|
||||
|
||||
Args:
|
||||
from_user_id: 发送消息的员工企微 UserID
|
||||
content: 消息内容(非文本通常为空)
|
||||
msg_type: 消息类型(image/voice/video/file/location)
|
||||
media_id: 企微媒体文件ID
|
||||
extra_data: 扩展元数据
|
||||
file_name: 文件名
|
||||
file_size: 文件大小
|
||||
|
||||
Returns:
|
||||
Conversation: 更新后的会话对象
|
||||
"""
|
||||
# 1. 查找或创建会话(复用现有逻辑)
|
||||
conversation = await self._find_or_create_conversation(
|
||||
from_user_id, content or f"[{msg_type}]"
|
||||
)
|
||||
|
||||
# 2. 构建非文本消息的展示文本(存入 content 字段,用于前端展示)
|
||||
display_text = self._get_non_text_display(msg_type, file_name, extra_data)
|
||||
|
||||
# 3. 生成自动回复文本
|
||||
reply_text = self._get_non_text_reply(msg_type)
|
||||
|
||||
# 4. 创建员工消息记录(存储非文本消息元数据)
|
||||
message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="employee",
|
||||
sender_id=from_user_id,
|
||||
sender_name=conversation.employee_name or from_user_id,
|
||||
content=display_text, # 展示用文本,如 "[图片消息]"
|
||||
msg_type=msg_type,
|
||||
media_id=media_id,
|
||||
file_name=file_name,
|
||||
file_size=file_size,
|
||||
extra_data=extra_data,
|
||||
is_read=False,
|
||||
)
|
||||
self.db.add(message)
|
||||
|
||||
# 5. 发送自动回复到企微
|
||||
try:
|
||||
await self.wecom_service.send_text_message(
|
||||
user_id=from_user_id,
|
||||
content=reply_text,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发送非文本消息自动回复失败: {e}")
|
||||
|
||||
# 6. 创建自动回复消息记录
|
||||
reply_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="AI智能助手",
|
||||
content=reply_text,
|
||||
msg_type="text",
|
||||
is_read=False,
|
||||
)
|
||||
self.db.add(reply_message)
|
||||
|
||||
# 7. 更新会话(不改变状态,只更新时间戳和摘要)
|
||||
conversation.last_message_at = datetime.now()
|
||||
conversation.last_message_summary = display_text[:256]
|
||||
conversation.updated_at = datetime.now()
|
||||
self.db.add(conversation)
|
||||
await self.db.flush()
|
||||
|
||||
logger.info(
|
||||
f"非文本消息处理完成: conv_id={conversation.id}, "
|
||||
f"msg_type={msg_type}, reply={reply_text[:30]}..."
|
||||
)
|
||||
|
||||
# 8. 广播 WebSocket 事件
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(message.id),
|
||||
"sender_type": "employee",
|
||||
"sender_id": from_user_id,
|
||||
"content": display_text,
|
||||
"msg_type": msg_type,
|
||||
"media_id": media_id,
|
||||
"file_name": file_name,
|
||||
"file_size": file_size,
|
||||
"urgency_score": conversation.urgency_score,
|
||||
"tags": conversation.tags,
|
||||
"ai_replied": True,
|
||||
}
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"WebSocket广播失败(不阻塞流程): {e}")
|
||||
|
||||
return conversation
|
||||
|
||||
def _get_non_text_display(
|
||||
self,
|
||||
msg_type: str,
|
||||
file_name: Optional[str] = None,
|
||||
extra_data: Optional[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""根据消息类型生成展示文本。
|
||||
|
||||
Args:
|
||||
msg_type: 消息类型(image/voice/video/file/location)
|
||||
file_name: 文件名(文件消息时使用)
|
||||
extra_data: 扩展元数据
|
||||
|
||||
Returns:
|
||||
str: 展示文本,如 "[图片消息]"、"[文件消息: report.pdf]"
|
||||
"""
|
||||
displays: dict[str, str] = {
|
||||
"image": "[图片消息]",
|
||||
"voice": "[语音消息]",
|
||||
"video": "[视频消息]",
|
||||
"file": f"[文件消息: {file_name}]" if file_name else "[文件消息]",
|
||||
"location": "[位置消息]",
|
||||
}
|
||||
return displays.get(msg_type, f"[{msg_type}消息]")
|
||||
|
||||
def _get_non_text_reply(self, msg_type: str) -> str:
|
||||
"""根据消息类型生成自动回复文本(发给员工)。
|
||||
|
||||
Args:
|
||||
msg_type: 消息类型(image/voice/video/file/location)
|
||||
|
||||
Returns:
|
||||
str: 自动回复文本
|
||||
"""
|
||||
if msg_type == "image":
|
||||
return (
|
||||
"收到您的截图 📷\n"
|
||||
"请补充文字描述您遇到的问题,以便更快为您处理。\n"
|
||||
"例如:\n"
|
||||
"• 这是什么软件的报错截图?\n"
|
||||
"• 您在操作什么时出现的?\n"
|
||||
"• 错误信息的具体内容是什么?"
|
||||
)
|
||||
|
||||
type_names: dict[str, str] = {
|
||||
"voice": "语音",
|
||||
"video": "视频",
|
||||
"file": "文件",
|
||||
"location": "位置",
|
||||
}
|
||||
type_name = type_names.get(msg_type, msg_type)
|
||||
return (
|
||||
f"暂不支持{type_name}消息 😅\n"
|
||||
"请用文字描述您的问题,我会尽快为您处理。"
|
||||
)
|
||||
async def _try_ai_reply(
|
||||
self,
|
||||
conversation: Conversation,
|
||||
content: str,
|
||||
from_user_id: str,
|
||||
) -> bool:
|
||||
"""尝试让 AI 回复员工消息。
|
||||
|
||||
重构说明:使用 AIHandler 统一处理打招呼检测、呼叫人工拦截、
|
||||
AI 调用、命中判断、计数规则和转人工逻辑,确保与 H5 端行为完全一致。
|
||||
|
||||
流程:
|
||||
1. 调用 AIHandler.handle_message() 获取统一结果
|
||||
2. 根据结果类型:
|
||||
- greeting/call_human → 发送引导话术到企微(不计数,不转人工)
|
||||
- ai_hit → 发送 AI 回复到企微(计数+1,不转人工)
|
||||
- ai_miss → 发送转人工提示到企微(不计数,转人工)
|
||||
- ai_fallback → 发送降级模板到企微(不计数,不转人工)
|
||||
3. 创建消息记录
|
||||
4. 更新会话状态和计数
|
||||
|
||||
Args:
|
||||
conversation: 当前会话
|
||||
content: 员工消息内容
|
||||
from_user_id: 员工企微 UserID
|
||||
|
||||
Returns:
|
||||
bool: True=AI 已回复(含引导),False=需转人工或出错
|
||||
"""
|
||||
if not self.ai_handler:
|
||||
logger.warning("AI 处理器不可用,跳过 AI 回复")
|
||||
return False
|
||||
|
||||
# 调用 AIHandler 统一处理
|
||||
result: AIReplyResult = await self.ai_handler.handle_message(
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
user_id=from_user_id,
|
||||
)
|
||||
|
||||
# 更新 Dify 会话ID(多轮对话上下文)
|
||||
if result.dify_conversation_id:
|
||||
conversation.dify_conversation_id = result.dify_conversation_id
|
||||
|
||||
# 发送回复到企微(员工在企微中看到回复)
|
||||
try:
|
||||
await self.wecom_service.send_text_message(
|
||||
user_id=from_user_id,
|
||||
content=result.content,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"发送AI回复到企微失败: {e}")
|
||||
# 企微发送失败不阻塞流程,坐席仍然能看
|
||||
|
||||
# 创建消息记录(根据类型选择 sender_type)
|
||||
if result.should_transfer:
|
||||
# 转人工消息用系统消息类型
|
||||
sender_type = "system"
|
||||
sender_id = "system"
|
||||
sender_name = "系统"
|
||||
else:
|
||||
# AI 回复/引导/降级均用 AI 消息类型
|
||||
sender_type = "ai"
|
||||
sender_id = "ai_bot"
|
||||
sender_name = "AI智能助手"
|
||||
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type=sender_type,
|
||||
sender_id=sender_id,
|
||||
sender_name=sender_name,
|
||||
content=result.content,
|
||||
msg_type="text",
|
||||
is_read=False,
|
||||
)
|
||||
self.db.add(ai_message)
|
||||
await self.db.flush()
|
||||
|
||||
# 更新 AI 实质性回复计数(仅命中时 +1)
|
||||
if result.should_count:
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
logger.info(
|
||||
f"AI 命中并回复: conv_id={conversation.id}, "
|
||||
f"ai_count={conversation.ai_substantive_reply_count}"
|
||||
)
|
||||
|
||||
# 转人工处理
|
||||
if result.should_transfer:
|
||||
conversation.status = "queued"
|
||||
logger.info(
|
||||
f"AI 未命中,转人工: conv_id={conversation.id}"
|
||||
)
|
||||
return False
|
||||
|
||||
# 记录其他类型日志
|
||||
if result.is_guidance:
|
||||
logger.info(
|
||||
f"AI 引导回复: conv_id={conversation.id}, "
|
||||
f"type={result.reply_type}"
|
||||
)
|
||||
elif result.reply_type == "ai_fallback":
|
||||
logger.info(
|
||||
f"AI 降级模板回复: conv_id={conversation.id}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 查找或创建会话
|
||||
# --------------------------------------------------------------------------
|
||||
async def _find_or_create_conversation(
|
||||
self, employee_id: str, content: str
|
||||
) -> Conversation:
|
||||
"""查找员工当前活跃的会话,如果不存在则创建新会话。
|
||||
|
||||
规则:
|
||||
- 如果员工有 status 为 ai_handling 或 queued 或 serving 的会话,继续使用该会话
|
||||
- 否则创建新会话,状态为 ai_handling(先让 AI 尝试回答)
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
content: 消息内容(用于创建会话时设置摘要)
|
||||
|
||||
Returns:
|
||||
Conversation: 找到的或新创建的会话对象
|
||||
"""
|
||||
# 查找当前活跃会话(ai_handling/queued/serving 状态)
|
||||
stmt = select(Conversation).where(
|
||||
Conversation.employee_id == employee_id,
|
||||
Conversation.status.in_(["ai_handling", "queued", "serving"]),
|
||||
).order_by(Conversation.created_at.desc())
|
||||
result = await self.db.execute(stmt)
|
||||
conversation = result.scalars().first()
|
||||
|
||||
if conversation:
|
||||
logger.debug(f"找到活跃会话: conv_id={conversation.id}, status={conversation.status}")
|
||||
return conversation
|
||||
|
||||
# 没有活跃会话,创建新会话
|
||||
# 默认状态 ai_handling:先让 AI 尝试回答,AI 未命中再转 queued
|
||||
conversation = Conversation(
|
||||
employee_id=employee_id,
|
||||
employee_name="", # 稍后通过 VIP 检测补充
|
||||
department="",
|
||||
position="",
|
||||
level="",
|
||||
status="ai_handling", # 先让 AI 尝试回答
|
||||
is_vip=False,
|
||||
is_pinned=False,
|
||||
is_todo=False,
|
||||
urgency_score=1,
|
||||
tags={},
|
||||
last_message_at=datetime.now(),
|
||||
last_message_summary=content[:256],
|
||||
)
|
||||
self.db.add(conversation)
|
||||
await self.db.flush() # 刷新以获取生成的 ID
|
||||
|
||||
logger.info(
|
||||
f"创建新会话: conv_id={conversation.id}, "
|
||||
f"employee_id={employee_id}, status=ai_handling"
|
||||
)
|
||||
return conversation
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# VIP 检测
|
||||
# --------------------------------------------------------------------------
|
||||
async def _check_vip(self, conversation: Conversation) -> None:
|
||||
"""检测员工是否为 VIP 并更新会话信息。
|
||||
|
||||
通过企微通讯录 API 获取员工信息:
|
||||
- 判断 VIP 规则:总监及以上 或 关键部门
|
||||
- 补充员工姓名、部门、岗位、等级等信息
|
||||
|
||||
Args:
|
||||
conversation: 会话对象(会被就地修改)
|
||||
"""
|
||||
# 已检测过 VIP 的会话不再重复检测
|
||||
if conversation.is_vip:
|
||||
return
|
||||
|
||||
try:
|
||||
user_info = await self.wecom_service.get_user_info(
|
||||
conversation.employee_id
|
||||
)
|
||||
|
||||
# 补充员工信息
|
||||
conversation.employee_name = user_info.get("name", "")
|
||||
conversation.department = user_info.get("department", "") # 部门ID列表,JSON字符串
|
||||
conversation.position = user_info.get("position", "")
|
||||
conversation.level = user_info.get("position", "") # 企微无单独等级字段,暂用岗位
|
||||
|
||||
# VIP 规则:总监及以上 或 关键部门
|
||||
# 第一步简单规则:职位中包含"总监"/"总经理"/"VP"/"CEO" 为 VIP
|
||||
position_text = user_info.get("position", "")
|
||||
vip_keywords = ["总监", "总经理", "VP", "CEO", "CIO", "CTO", "CFO", "COO"]
|
||||
is_vip = any(kw in position_text for kw in vip_keywords)
|
||||
conversation.is_vip = is_vip
|
||||
|
||||
if is_vip:
|
||||
logger.info(
|
||||
f"VIP标记: employee_id={conversation.employee_id}, "
|
||||
f"position={position_text}"
|
||||
)
|
||||
|
||||
# 缓存 VIP 结果到 Redis(1 小时)
|
||||
# 避免每次消息都调企微 API
|
||||
# 这里暂不实现 Redis 缓存,后续优化
|
||||
|
||||
except Exception as e:
|
||||
# VIP 检测失败不应阻塞消息路由
|
||||
logger.warning(
|
||||
f"VIP检测失败(不阻塞流程): employee_id={conversation.employee_id}, "
|
||||
f"error={e}"
|
||||
)
|
||||
@@ -0,0 +1,350 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 角色映射服务
|
||||
# =============================================================================
|
||||
# 说明:处理角色自动映射逻辑,支持以下来源:
|
||||
# 1. 企微标签映射(wecom_tag)
|
||||
# 2. eHR 字段映射(ehr_position)
|
||||
# 3. 管理后台手动分配(manual)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional, Set
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.role import Role
|
||||
from app.models.role_mapping_rule import RoleMappingRule
|
||||
from app.models.user_role import UserRole
|
||||
from app.services.wecom_service import WecomService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _mask_sensitive_data(value: str, visible_chars: int = 3) -> str:
|
||||
"""脱敏处理敏感数据。
|
||||
|
||||
Args:
|
||||
value: 原始值
|
||||
visible_chars: 开头保留的字符数
|
||||
|
||||
Returns:
|
||||
str: 脱敏后的值,如 "abc***def"
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= visible_chars:
|
||||
return "*" * len(value)
|
||||
return f"{value[:visible_chars]}{'*' * (len(value) - visible_chars)}"
|
||||
|
||||
|
||||
class RoleMappingService:
|
||||
"""角色映射服务。
|
||||
|
||||
根据用户的企微标签、eHR岗位等信息,自动映射角色。
|
||||
"""
|
||||
|
||||
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 get_user_roles(self, employee_id: str) -> List[str]:
|
||||
"""获取用户的角色列表。
|
||||
|
||||
查询 user_roles 表,返回用户拥有的角色标识列表。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
List[str]: 角色标识列表(如 ["user", "agent"])
|
||||
"""
|
||||
stmt = (
|
||||
select(Role.name)
|
||||
.join(UserRole, Role.id == UserRole.role_id)
|
||||
.where(UserRole.employee_id == employee_id)
|
||||
.where(
|
||||
# 过滤已过期的角色
|
||||
(UserRole.expires_at.is_(None)) | (UserRole.expires_at > datetime.now())
|
||||
)
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
roles = [row[0] for row in result.all()]
|
||||
|
||||
# 如果没有角色,添加默认的 user 角色
|
||||
if not roles:
|
||||
roles = ["user"]
|
||||
|
||||
return roles
|
||||
|
||||
async def sync_user_roles(
|
||||
self,
|
||||
employee_id: str,
|
||||
wecom_tags: Optional[List[str]] = None,
|
||||
ehr_position: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
"""同步用户角色。
|
||||
|
||||
根据企微标签和eHR岗位,自动分配或撤销角色。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
wecom_tags: 企微标签列表(可选)
|
||||
ehr_position: eHR岗位(可选)
|
||||
|
||||
Returns:
|
||||
List[str]: 同步后的角色列表
|
||||
"""
|
||||
# 1. 获取当前角色
|
||||
current_roles = await self.get_user_roles(employee_id)
|
||||
|
||||
# 2. 获取映射规则
|
||||
mapping_rules = await self._get_active_mapping_rules()
|
||||
|
||||
# 3. 根据规则确定应该拥有的角色
|
||||
should_have_roles: Set[str] = {"user"} # 所有人都有 user 角色
|
||||
|
||||
for rule in mapping_rules:
|
||||
if rule.source_type == "wecom_tag" and wecom_tags:
|
||||
# 检查标签是否匹配
|
||||
if rule.source_value in wecom_tags:
|
||||
role_name = await self._get_role_name_by_id(rule.role_id)
|
||||
if role_name:
|
||||
should_have_roles.add(role_name)
|
||||
|
||||
elif rule.source_type == "ehr_position" and ehr_position:
|
||||
# 检查岗位关键词是否匹配
|
||||
if rule.source_value in ehr_position:
|
||||
role_name = await self._get_role_name_by_id(rule.role_id)
|
||||
if role_name:
|
||||
should_have_roles.add(role_name)
|
||||
|
||||
# 4. 计算需要添加和删除的角色
|
||||
current_set = set(current_roles)
|
||||
to_add = should_have_roles - current_set
|
||||
to_remove = current_set - should_have_roles - {"user"} # 不删除 user 角色
|
||||
|
||||
# 5. 添加新角色
|
||||
for role_name in to_add:
|
||||
await self._add_role(employee_id, role_name, source="tag")
|
||||
|
||||
# 6. 撤销不再需要的角色(仅撤销自动分配的)
|
||||
for role_name in to_remove:
|
||||
await self._remove_auto_role(employee_id, role_name)
|
||||
|
||||
# 7. 返回同步后的角色列表
|
||||
return await self.get_user_roles(employee_id)
|
||||
|
||||
async def _get_active_mapping_rules(self) -> List[RoleMappingRule]:
|
||||
"""获取所有启用的映射规则。
|
||||
|
||||
Returns:
|
||||
List[RoleMappingRule]: 映射规则列表
|
||||
"""
|
||||
stmt = (
|
||||
select(RoleMappingRule)
|
||||
.where(RoleMappingRule.is_active == True)
|
||||
.order_by(RoleMappingRule.priority.desc())
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _get_role_name_by_id(self, role_id: str) -> Optional[str]:
|
||||
"""根据角色ID获取角色名称。
|
||||
|
||||
Args:
|
||||
role_id: 角色ID
|
||||
|
||||
Returns:
|
||||
Optional[str]: 角色名称,如果不存在返回 None
|
||||
"""
|
||||
stmt = select(Role.name).where(Role.id == role_id)
|
||||
result = await self.db.execute(stmt)
|
||||
row = result.first()
|
||||
return row[0] if row else None
|
||||
|
||||
async def _add_role(self, employee_id: str, role_name: str, source: str) -> None:
|
||||
"""为用户添加角色。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
role_name: 角色标识
|
||||
source: 角色来源(auto/tag/ehr/manual)
|
||||
"""
|
||||
# 查询角色
|
||||
stmt = select(Role).where(Role.name == role_name)
|
||||
result = await self.db.execute(stmt)
|
||||
role = result.scalars().first()
|
||||
|
||||
if not role:
|
||||
logger.warning(f"角色 {role_name} 不存在,跳过添加")
|
||||
return
|
||||
|
||||
# 检查是否已存在
|
||||
existing_stmt = select(UserRole).where(
|
||||
UserRole.employee_id == employee_id,
|
||||
UserRole.role_id == role.id,
|
||||
)
|
||||
existing_result = await self.db.execute(existing_stmt)
|
||||
existing = existing_result.scalars().first()
|
||||
|
||||
if existing:
|
||||
logger.debug(f"用户 {_mask_sensitive_data(employee_id)} 已拥有角色 {role_name},跳过添加")
|
||||
return
|
||||
|
||||
# 创建用户角色关联
|
||||
user_role = UserRole(
|
||||
employee_id=employee_id,
|
||||
role_id=role.id,
|
||||
source=source,
|
||||
)
|
||||
self.db.add(user_role)
|
||||
await self.db.commit()
|
||||
|
||||
logger.info(f"为用户 {_mask_sensitive_data(employee_id)} 添加角色 {role_name}(来源:{source})")
|
||||
|
||||
async def _remove_auto_role(self, employee_id: str, role_name: str) -> None:
|
||||
"""撤销用户的自动分配角色。
|
||||
|
||||
仅撤销 source 为 auto/tag/ehr 的角色,不撤销手动分配的角色。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
role_name: 角色标识
|
||||
"""
|
||||
# 查询角色
|
||||
stmt = select(Role).where(Role.name == role_name)
|
||||
result = await self.db.execute(stmt)
|
||||
role = result.scalars().first()
|
||||
|
||||
if not role:
|
||||
return
|
||||
|
||||
# 查询用户角色关联(仅自动分配的)
|
||||
user_role_stmt = select(UserRole).where(
|
||||
UserRole.employee_id == employee_id,
|
||||
UserRole.role_id == role.id,
|
||||
UserRole.source.in_(["auto", "tag", "ehr"]), # 仅自动分配的
|
||||
)
|
||||
user_role_result = await self.db.execute(user_role_stmt)
|
||||
user_role = user_role_result.scalars().first()
|
||||
|
||||
if user_role:
|
||||
await self.db.delete(user_role)
|
||||
await self.db.commit()
|
||||
logger.info(f"撤销用户 {_mask_sensitive_data(employee_id)} 的自动分配角色 {role_name}")
|
||||
|
||||
async def get_wecom_user_tags(self, user_id: str) -> List[str]:
|
||||
"""获取用户的企微标签列表。
|
||||
|
||||
调用企微通讯录API获取用户的标签ID列表,然后查询标签名称。
|
||||
|
||||
Args:
|
||||
user_id: 企微 UserID
|
||||
|
||||
Returns:
|
||||
List[str]: 标签名称列表
|
||||
"""
|
||||
if not self.wecom_service:
|
||||
logger.warning("WecomService 未初始化,无法获取企微标签")
|
||||
return []
|
||||
|
||||
try:
|
||||
# 获取用户信息(包含 tagids)
|
||||
user_info = await self.wecom_service.get_user_info(user_id)
|
||||
tag_ids = user_info.get("tagids", [])
|
||||
|
||||
if not tag_ids:
|
||||
return []
|
||||
|
||||
# 查询标签名称
|
||||
tag_names = await self._get_tag_names_by_ids(tag_ids)
|
||||
return tag_names
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取用户企微标签失败: user_id={user_id}, error={e}")
|
||||
return []
|
||||
|
||||
# 标签名称验证常量
|
||||
MAX_TAG_NAME_LENGTH = 50 # 最大标签名称长度
|
||||
TAG_NAME_FORBIDDEN_CHARS = "<>'\"&;\\|%$#@`" # 禁止的特殊字符
|
||||
|
||||
def _validate_tag_name(self, tag_name: str) -> bool:
|
||||
"""验证标签名称是否安全。
|
||||
|
||||
Args:
|
||||
tag_name: 标签名称
|
||||
|
||||
Returns:
|
||||
bool: 是否有效
|
||||
"""
|
||||
# 检查长度
|
||||
if not tag_name or len(tag_name) > self.MAX_TAG_NAME_LENGTH:
|
||||
return False
|
||||
|
||||
# 检查禁止字符
|
||||
for char in self.TAG_NAME_FORBIDDEN_CHARS:
|
||||
if char in tag_name:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
async def _get_tag_names_by_ids(self, tag_ids: List[int]) -> List[str]:
|
||||
"""根据标签ID列表获取标签名称。
|
||||
|
||||
调用企微标签管理API获取标签名称,并进行安全验证。
|
||||
|
||||
Args:
|
||||
tag_ids: 标签ID列表
|
||||
|
||||
Returns:
|
||||
List[str]: 验证后的标签名称列表
|
||||
"""
|
||||
if not self.wecom_service:
|
||||
return []
|
||||
|
||||
try:
|
||||
access_token = await self.wecom_service.get_access_token()
|
||||
import httpx
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
# 获取标签列表(企微API)
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/tag/list"
|
||||
params = {"access_token": access_token}
|
||||
response = await client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(f"获取标签列表失败: {result}")
|
||||
return []
|
||||
|
||||
# 构建 tag_id -> tag_name 映射(带安全验证)
|
||||
tag_map = {}
|
||||
for tag in result.get("taglist", []):
|
||||
tag_name = tag.get("tagname", "")
|
||||
# 安全验证:过滤不安全的标签名称
|
||||
if self._validate_tag_name(tag_name):
|
||||
tag_map[tag["tagid"]] = tag_name
|
||||
|
||||
# 返回匹配的标签名称
|
||||
valid_tag_names = [
|
||||
tag_map[tag_id]
|
||||
for tag_id in tag_ids
|
||||
if tag_id in tag_map
|
||||
]
|
||||
|
||||
# 记录获取到的标签数量(非敏感信息)
|
||||
logger.debug(f"获取到 {len(valid_tag_names)} 个有效标签")
|
||||
return valid_tag_names
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取标签名称失败: {e}")
|
||||
return []
|
||||
@@ -0,0 +1,406 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 紧急度评分 + 标记检测服务
|
||||
# =============================================================================
|
||||
# 说明:实现 PRD 中定义的紧急度评分公式和标记检测规则
|
||||
# 1. 紧急度评分:基础分(关键词) + 情绪加成 + VIP加成 + 重复追问加成
|
||||
# 2. 举手标记检测:关键词匹配
|
||||
# 3. 需介入标记检测:追问轮次 > 阈值
|
||||
# 4. 情绪标记检测:关键词规则
|
||||
# 所有关键词配置存储在 system_configs 表中,可后台动态修改
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Dict, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.message import Message
|
||||
from app.models.system_config import SystemConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ScoringService:
|
||||
"""紧急度评分与标记检测服务。
|
||||
|
||||
实现评分公式:紧急度 = 基础分 + 情绪加成 + VIP加成 + 重复追问加成
|
||||
评分范围 1-5,映射:1=低, 2=中, 3=高, 4=紧急, 5=最高
|
||||
|
||||
所有关键词和阈值从 system_configs 表读取,支持后台动态修改。
|
||||
"""
|
||||
|
||||
# 默认配置(当数据库未配置时使用)
|
||||
DEFAULT_HAND_RAISE_KEYWORDS = [
|
||||
"转人工", "人工", "人工服务", "真人", "客服",
|
||||
"帮我转人工", "找人工", "要人工", "不要AI", "不要机器人",
|
||||
]
|
||||
DEFAULT_EMOTION_KEYWORDS = {
|
||||
"angry": ["崩溃", "愤怒", "投诉", "差劲", "垃圾", "太差了", "受不了"],
|
||||
"urgent": ["急", "紧急", "马上", "立刻", "赶紧", "十万火急", "快点"],
|
||||
"worried": ["担心", "害怕", "出错", "丢失", "完蛋", "糟糕"],
|
||||
}
|
||||
DEFAULT_INTERVENE_THRESHOLD = 3
|
||||
DEFAULT_URGENCY_SCORES = {
|
||||
"base_keyword": 1,
|
||||
"emotion_bonus": 1,
|
||||
"vip_bonus": 1,
|
||||
"repeat_bonus": 1,
|
||||
}
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""初始化评分服务。
|
||||
|
||||
Args:
|
||||
db: 异步数据库会话(用于读取 system_configs 配置)
|
||||
"""
|
||||
self.db = db
|
||||
# 缓存配置(避免每次请求都查数据库)
|
||||
self._config_cache: Dict[str, str] = {}
|
||||
self._cache_loaded = False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 配置加载
|
||||
# --------------------------------------------------------------------------
|
||||
async def _load_configs(self) -> None:
|
||||
"""从 system_configs 表加载所有配置项到内存缓存。"""
|
||||
if self._cache_loaded:
|
||||
return
|
||||
|
||||
stmt = select(SystemConfig)
|
||||
result = await self.db.execute(stmt)
|
||||
configs = result.scalars().all()
|
||||
|
||||
for config in configs:
|
||||
self._config_cache[config.config_key] = config.config_value
|
||||
|
||||
self._cache_loaded = True
|
||||
logger.debug(f"评分配置加载完成: {len(self._config_cache)} 项")
|
||||
|
||||
async def _get_config(self, key: str, default: str = "") -> str:
|
||||
"""获取配置值。
|
||||
|
||||
Args:
|
||||
key: 配置键
|
||||
default: 默认值
|
||||
|
||||
Returns:
|
||||
str: 配置值字符串
|
||||
"""
|
||||
await self._load_configs()
|
||||
return self._config_cache.get(key, default)
|
||||
|
||||
async def _get_json_config(self, key: str, default: List[str]) -> List[str]:
|
||||
"""获取 JSON 格式的配置值(解析为列表)。
|
||||
|
||||
Args:
|
||||
key: 配置键
|
||||
default: 默认值列表
|
||||
|
||||
Returns:
|
||||
List[str]: 解析后的字符串列表
|
||||
"""
|
||||
value = await self._get_config(key)
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"配置解析失败: key={key}, value={value}")
|
||||
return default
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 举手标记检测
|
||||
# --------------------------------------------------------------------------
|
||||
def detect_hand_raise(self, content: str) -> bool:
|
||||
"""检测消息中是否包含举手关键词。
|
||||
|
||||
举手关键词如:"转人工"、"人工"、"找真人" 等。
|
||||
命中任意一个关键词即触发举手标记。
|
||||
|
||||
关键词配置:system_configs 表 hand_raise_keywords 键
|
||||
|
||||
Args:
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
bool: 是否触发举手标记
|
||||
"""
|
||||
# 由于 _get_json_config 是异步方法,这里使用默认关键词
|
||||
# 在 calculate_urgency 中统一异步加载配置
|
||||
keywords = self.DEFAULT_HAND_RAISE_KEYWORDS
|
||||
content_lower = content.lower()
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in content_lower:
|
||||
logger.debug(f"举手关键词命中: keyword={keyword}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def detect_hand_raise_async(self, content: str) -> bool:
|
||||
"""异步版本的举手标记检测(从数据库读取配置)。
|
||||
|
||||
Args:
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
bool: 是否触发举手标记
|
||||
"""
|
||||
keywords = await self._get_json_config(
|
||||
"hand_raise_keywords", self.DEFAULT_HAND_RAISE_KEYWORDS
|
||||
)
|
||||
content_lower = content.lower()
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in content_lower:
|
||||
logger.debug(f"举手关键词命中: keyword={keyword}")
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 情绪标记检测
|
||||
# --------------------------------------------------------------------------
|
||||
def detect_emotion(self, content: str) -> str:
|
||||
"""检测消息中的情绪标记。
|
||||
|
||||
情绪分类:
|
||||
- angry: 愤怒("崩溃"、"愤怒"、"投诉" 等)
|
||||
- urgent: 紧急("急"、"紧急"、"马上" 等)
|
||||
- worried: 担忧("担心"、"害怕"、"出错" 等)
|
||||
- neutral: 正常(无情绪关键词命中)
|
||||
|
||||
检测优先级:angry > urgent > worried(因为愤怒最严重)
|
||||
|
||||
关键词配置:
|
||||
- system_configs 表 emotion_keywords_angry 键
|
||||
- system_configs 表 emotion_keywords_urgent 键
|
||||
- system_configs 表 emotion_keywords_worried 键
|
||||
|
||||
Args:
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
str: 情绪类型(angry/urgent/worried/neutral)
|
||||
"""
|
||||
content_lower = content.lower()
|
||||
|
||||
# 按优先级检测:angry > urgent > worried
|
||||
for emotion_type, keywords in self.DEFAULT_EMOTION_KEYWORDS.items():
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in content_lower:
|
||||
logger.debug(f"情绪关键词命中: emotion={emotion_type}, keyword={keyword}")
|
||||
return emotion_type
|
||||
|
||||
return "neutral"
|
||||
|
||||
async def detect_emotion_async(self, content: str) -> str:
|
||||
"""异步版本的情绪标记检测(从数据库读取配置)。
|
||||
|
||||
Args:
|
||||
content: 消息内容
|
||||
|
||||
Returns:
|
||||
str: 情绪类型
|
||||
"""
|
||||
content_lower = content.lower()
|
||||
|
||||
# 按优先级检测
|
||||
emotion_config_keys = {
|
||||
"angry": "emotion_keywords_angry",
|
||||
"urgent": "emotion_keywords_urgent",
|
||||
"worried": "emotion_keywords_worried",
|
||||
}
|
||||
|
||||
for emotion_type, config_key in emotion_config_keys.items():
|
||||
keywords = await self._get_json_config(
|
||||
config_key, self.DEFAULT_EMOTION_KEYWORDS.get(emotion_type, [])
|
||||
)
|
||||
for keyword in keywords:
|
||||
if keyword.lower() in content_lower:
|
||||
return emotion_type
|
||||
|
||||
return "neutral"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取触发的情绪关键词列表
|
||||
# --------------------------------------------------------------------------
|
||||
def get_emotion_keywords(self, content: str, emotion: str) -> List[str]:
|
||||
"""获取消息中触发了情绪标记的关键词列表。
|
||||
|
||||
用于在会话标签中记录具体触发了哪些关键词。
|
||||
|
||||
Args:
|
||||
content: 消息内容
|
||||
emotion: 情绪类型
|
||||
|
||||
Returns:
|
||||
List[str]: 触发的关键词列表
|
||||
"""
|
||||
content_lower = content.lower()
|
||||
keywords = self.DEFAULT_EMOTION_KEYWORDS.get(emotion, [])
|
||||
matched = [kw for kw in keywords if kw in content_lower]
|
||||
return matched
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 需介入标记检测
|
||||
# --------------------------------------------------------------------------
|
||||
async def detect_need_intervene(
|
||||
self, conversation_id: UUID, db: AsyncSession
|
||||
) -> bool:
|
||||
"""检测会话是否需要介入(员工追问超过阈值轮次)。
|
||||
|
||||
检测逻辑:
|
||||
1. 统计该会话中员工连续发送的消息数(中间无坐席回复)
|
||||
2. 如果连续追问轮次 > 阈值(默认3),触发需介入标记
|
||||
|
||||
阈值配置:system_configs 表 intervene_round_threshold 键
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
bool: 是否需要介入
|
||||
"""
|
||||
# 获取阈值
|
||||
threshold_str = await self._get_config(
|
||||
"intervene_round_threshold", str(self.DEFAULT_INTERVENE_THRESHOLD)
|
||||
)
|
||||
try:
|
||||
threshold = int(threshold_str)
|
||||
except ValueError:
|
||||
threshold = self.DEFAULT_INTERVENE_THRESHOLD
|
||||
|
||||
# 查询该会话最近的员工消息数
|
||||
# 简化逻辑:查询会话中员工发送的总消息数
|
||||
stmt = select(func.count(Message.id)).where(
|
||||
Message.conversation_id == conversation_id,
|
||||
Message.sender_type == "employee",
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
employee_msg_count = result.scalar() or 0
|
||||
|
||||
# 如果员工消息数 > 阈值,触发需介入
|
||||
# 注意:这里是累计消息数,不是连续追问数
|
||||
# 更精确的实现应该检查最后 N 条消息是否都是员工发的
|
||||
is_need = employee_msg_count > threshold
|
||||
if is_need:
|
||||
logger.debug(
|
||||
f"需介入检测: conv_id={conversation_id}, "
|
||||
f"employee_msgs={employee_msg_count}, threshold={threshold}"
|
||||
)
|
||||
|
||||
return is_need
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 紧急度评分
|
||||
# --------------------------------------------------------------------------
|
||||
async def calculate_urgency(
|
||||
self,
|
||||
content: str = "",
|
||||
tags: Optional[Dict] = None,
|
||||
is_vip: bool = False,
|
||||
) -> int:
|
||||
"""计算会话紧急度评分。
|
||||
|
||||
公式:紧急度 = 基础分(关键词) + 情绪加成 + VIP加成 + 重复追问加成
|
||||
评分范围 1-5,最终结果 clamp 到 [1, 5]
|
||||
|
||||
各项说明:
|
||||
- 基础分:消息中是否包含举手/情绪关键词,命中则加分
|
||||
- 情绪加成:有情绪标记(非neutral)则加分
|
||||
- VIP加成:VIP 员工加分
|
||||
- 重复追问加成:追问轮次超过阈值则加分
|
||||
|
||||
分值配置:
|
||||
- urgency_base_keyword_score: 关键词基础加分(默认1)
|
||||
- urgency_emotion_bonus: 情绪加成分(默认1)
|
||||
- urgency_vip_bonus: VIP加成分(默认1)
|
||||
- urgency_repeat_bonus: 重复追问加成分(默认1)
|
||||
|
||||
Args:
|
||||
content: 消息内容
|
||||
tags: 会话标签字典
|
||||
is_vip: 是否 VIP 员工
|
||||
|
||||
Returns:
|
||||
int: 紧急度评分(1-5)
|
||||
"""
|
||||
if tags is None:
|
||||
tags = {}
|
||||
|
||||
# 从配置读取各项分值
|
||||
base_keyword_score = int(
|
||||
await self._get_config(
|
||||
"urgency_base_keyword_score",
|
||||
str(self.DEFAULT_URGENCY_SCORES["base_keyword"]),
|
||||
)
|
||||
)
|
||||
emotion_bonus = int(
|
||||
await self._get_config(
|
||||
"urgency_emotion_bonus",
|
||||
str(self.DEFAULT_URGENCY_SCORES["emotion_bonus"]),
|
||||
)
|
||||
)
|
||||
vip_bonus = int(
|
||||
await self._get_config(
|
||||
"urgency_vip_bonus",
|
||||
str(self.DEFAULT_URGENCY_SCORES["vip_bonus"]),
|
||||
)
|
||||
)
|
||||
repeat_bonus = int(
|
||||
await self._get_config(
|
||||
"urgency_repeat_bonus",
|
||||
str(self.DEFAULT_URGENCY_SCORES["repeat_bonus"]),
|
||||
)
|
||||
)
|
||||
|
||||
# 计算基础分:举手或情绪关键词命中则加分
|
||||
score = 1 # 起始分
|
||||
if tags.get("hand_raise", False) or tags.get("emotion", "neutral") != "neutral":
|
||||
score += base_keyword_score
|
||||
|
||||
# 情绪加成:有情绪标记(非neutral)则加分
|
||||
if tags.get("emotion", "neutral") != "neutral":
|
||||
score += emotion_bonus
|
||||
|
||||
# VIP 加成
|
||||
if is_vip:
|
||||
score += vip_bonus
|
||||
|
||||
# 重复追问加成:追问轮次超过阈值则加分
|
||||
intervene_threshold = int(
|
||||
await self._get_config(
|
||||
"intervene_round_threshold",
|
||||
str(self.DEFAULT_INTERVENE_THRESHOLD),
|
||||
)
|
||||
)
|
||||
repeat_count = tags.get("repeat_count", 0)
|
||||
if repeat_count > intervene_threshold:
|
||||
score += repeat_bonus
|
||||
|
||||
# Clamp 到 [1, 5]
|
||||
score = max(1, min(5, score))
|
||||
|
||||
logger.debug(
|
||||
f"紧急度评分: base={base_keyword_score}, emotion={emotion_bonus}, "
|
||||
f"vip={vip_bonus}, repeat={repeat_bonus}, total={score}"
|
||||
)
|
||||
return score
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 重置配置缓存(后台修改配置后调用)
|
||||
# --------------------------------------------------------------------------
|
||||
def reset_cache(self) -> None:
|
||||
"""重置配置缓存。
|
||||
|
||||
当管理员在后台修改 system_configs 表后调用,
|
||||
下次请求时会重新从数据库加载配置。
|
||||
"""
|
||||
self._config_cache = {}
|
||||
self._cache_loaded = False
|
||||
logger.info("评分配置缓存已重置")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,263 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 统一 Token 服务
|
||||
# =============================================================================
|
||||
# 说明:统一 Token 管理,支持以下功能:
|
||||
# 1. 创建统一格式的 Token(包含角色信息)
|
||||
# 2. 验证 Token 并获取用户信息
|
||||
# 3. 切换当前角色
|
||||
# 4. 兼容旧格式 Token(employee:token 和 agent:token)
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token TTL(8小时)
|
||||
TOKEN_TTL_SECONDS = 8 * 60 * 60
|
||||
|
||||
# 统一 Token Key 前缀
|
||||
UNIFIED_TOKEN_PREFIX = "user:token:"
|
||||
|
||||
# 旧格式 Token Key 前缀(兼容)
|
||||
EMPLOYEE_TOKEN_PREFIX = "employee:token:"
|
||||
AGENT_TOKEN_PREFIX = "agent:token:"
|
||||
|
||||
|
||||
class TokenService:
|
||||
"""统一 Token 服务。
|
||||
|
||||
管理用户 Token 的创建、验证、角色切换等操作。
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client: aioredis.Redis):
|
||||
"""初始化 Token 服务。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端
|
||||
"""
|
||||
self.redis = redis_client
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
employee_id: str,
|
||||
name: str,
|
||||
roles: List[str],
|
||||
department: Optional[str] = None,
|
||||
avatar: Optional[str] = None,
|
||||
login_source: str = "portal",
|
||||
) -> str:
|
||||
"""创建统一格式的 Token。
|
||||
|
||||
Args:
|
||||
employee_id: 企微 UserID
|
||||
name: 用户姓名
|
||||
roles: 角色列表(如 ["user", "agent"])
|
||||
department: 部门(可选)
|
||||
avatar: 头像URL(可选)
|
||||
login_source: 登录来源(portal/agent/h5)
|
||||
|
||||
Returns:
|
||||
str: Token 字符串
|
||||
"""
|
||||
# 生成 Token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# 构建 Token 数据
|
||||
token_data = {
|
||||
"employee_id": employee_id,
|
||||
"name": name,
|
||||
"department": department or "",
|
||||
"avatar": avatar or "",
|
||||
"roles": roles,
|
||||
"current_role": self._get_default_role(roles),
|
||||
"login_source": login_source,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_active": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# 存入 Redis(统一格式)
|
||||
await self.redis.setex(
|
||||
f"{UNIFIED_TOKEN_PREFIX}{token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
json.dumps(token_data, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# 同时存入旧格式(兼容性)
|
||||
if login_source == "agent":
|
||||
await self.redis.setex(
|
||||
f"{AGENT_TOKEN_PREFIX}{token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
employee_id,
|
||||
)
|
||||
else:
|
||||
await self.redis.setex(
|
||||
f"{EMPLOYEE_TOKEN_PREFIX}{token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
employee_id,
|
||||
)
|
||||
|
||||
logger.info(f"创建 Token: employee_id={employee_id}, roles={roles}, source={login_source}")
|
||||
|
||||
return token
|
||||
|
||||
async def get_user_info(self, token: str) -> Optional[Dict]:
|
||||
"""获取 Token 对应的用户信息。
|
||||
|
||||
支持新旧两种 Token 格式。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 用户信息字典,如果 Token 无效返回 None
|
||||
"""
|
||||
# 1. 尝试统一格式
|
||||
data = await self.redis.get(f"{UNIFIED_TOKEN_PREFIX}{token}")
|
||||
if data:
|
||||
try:
|
||||
user_info = json.loads(data)
|
||||
# 更新最后活跃时间
|
||||
user_info["last_active"] = datetime.now().isoformat()
|
||||
await self.redis.setex(
|
||||
f"{UNIFIED_TOKEN_PREFIX}{token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
json.dumps(user_info, ensure_ascii=False),
|
||||
)
|
||||
return user_info
|
||||
except json.JSONDecodeError:
|
||||
logger.error(f"Token 数据解析失败: {token[:10]}...")
|
||||
return None
|
||||
|
||||
# 2. 尝试旧格式(employee:token)
|
||||
employee_id = await self.redis.get(f"{EMPLOYEE_TOKEN_PREFIX}{token}")
|
||||
if employee_id:
|
||||
employee_id = employee_id.decode("utf-8") if isinstance(employee_id, bytes) else employee_id
|
||||
# 获取员工信息缓存
|
||||
info_data = await self.redis.get(f"employee:info:{employee_id}")
|
||||
if info_data:
|
||||
try:
|
||||
info = json.loads(info_data)
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
"name": info.get("employee_name", ""),
|
||||
"department": info.get("department", ""),
|
||||
"avatar": info.get("avatar", ""),
|
||||
"roles": ["user"],
|
||||
"current_role": "user",
|
||||
"login_source": "h5",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_active": datetime.now().isoformat(),
|
||||
}
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 降级:只有 employee_id
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
"name": "",
|
||||
"department": "",
|
||||
"avatar": "",
|
||||
"roles": ["user"],
|
||||
"current_role": "user",
|
||||
"login_source": "h5",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_active": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
# 3. 尝试旧格式(agent:token)
|
||||
agent_id = await self.redis.get(f"{AGENT_TOKEN_PREFIX}{token}")
|
||||
if agent_id:
|
||||
agent_id = agent_id.decode("utf-8") if isinstance(agent_id, bytes) else agent_id
|
||||
return {
|
||||
"employee_id": agent_id,
|
||||
"name": "",
|
||||
"department": "",
|
||||
"avatar": "",
|
||||
"roles": ["user", "agent"],
|
||||
"current_role": "agent",
|
||||
"login_source": "agent",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
"last_active": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def switch_role(self, token: str, new_role: str) -> bool:
|
||||
"""切换当前角色。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
new_role: 目标角色标识
|
||||
|
||||
Returns:
|
||||
bool: 是否切换成功
|
||||
"""
|
||||
# 获取当前用户信息
|
||||
user_info = await self.get_user_info(token)
|
||||
if not user_info:
|
||||
return False
|
||||
|
||||
# 验证用户是否有目标角色
|
||||
if new_role not in user_info.get("roles", []):
|
||||
logger.warning(f"用户 {user_info['employee_id']} 没有 {new_role} 角色")
|
||||
return False
|
||||
|
||||
# 更新当前角色
|
||||
user_info["current_role"] = new_role
|
||||
user_info["last_active"] = datetime.now().isoformat()
|
||||
|
||||
# 保存到 Redis(统一格式)
|
||||
await self.redis.setex(
|
||||
f"{UNIFIED_TOKEN_PREFIX}{token}",
|
||||
TOKEN_TTL_SECONDS,
|
||||
json.dumps(user_info, ensure_ascii=False),
|
||||
)
|
||||
|
||||
# 同时更新旧格式(如果存在)
|
||||
# 注意:旧格式只存储 employee_id,不需要更新
|
||||
|
||||
logger.info(f"用户 {user_info['employee_id']} 切换角色到 {new_role}")
|
||||
return True
|
||||
|
||||
async def invalidate_token(self, token: str) -> None:
|
||||
"""使 Token 失效。
|
||||
|
||||
删除统一格式和旧格式的 Token。
|
||||
|
||||
Args:
|
||||
token: Token 字符串
|
||||
"""
|
||||
# 删除统一格式
|
||||
await self.redis.delete(f"{UNIFIED_TOKEN_PREFIX}{token}")
|
||||
|
||||
# 删除旧格式
|
||||
await self.redis.delete(f"{EMPLOYEE_TOKEN_PREFIX}{token}")
|
||||
await self.redis.delete(f"{AGENT_TOKEN_PREFIX}{token}")
|
||||
|
||||
logger.info(f"Token 已失效: {token[:10]}...")
|
||||
|
||||
def _get_default_role(self, roles: List[str]) -> str:
|
||||
"""获取默认角色。
|
||||
|
||||
优先级:admin > agent > user
|
||||
|
||||
Args:
|
||||
roles: 角色列表
|
||||
|
||||
Returns:
|
||||
str: 默认角色标识
|
||||
"""
|
||||
if "admin" in roles:
|
||||
return "admin"
|
||||
elif "agent" in roles:
|
||||
return "agent"
|
||||
else:
|
||||
return "user"
|
||||
@@ -0,0 +1,573 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 企微 API 封装服务
|
||||
# =============================================================================
|
||||
# 说明:封装所有与企微服务器的交互逻辑,包括:
|
||||
# 1. access_token 管理(Redis 缓存 + 自动刷新)
|
||||
# 2. 发送消息(文本/图片/文件)
|
||||
# 3. 获取员工信息(通讯录 API)
|
||||
# 4. 上传临时素材
|
||||
# 5. OAuth2 授权换算用户身份
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as aioredis
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WecomService:
|
||||
"""企微 API 调用服务。
|
||||
|
||||
封装所有与企微服务器的 HTTP 交互,提供异步方法。
|
||||
access_token 通过 Redis 缓存管理,避免频繁调用获取接口。
|
||||
|
||||
Attributes:
|
||||
redis: Redis 异步客户端(用于缓存 access_token)
|
||||
client: httpx 异步 HTTP 客户端
|
||||
"""
|
||||
|
||||
def __init__(self, redis_client: Optional[aioredis.Redis] = None):
|
||||
"""初始化企微服务。
|
||||
|
||||
Args:
|
||||
redis_client: Redis 异步客户端实例(可为 None,本地开发时 Redis 不可用)
|
||||
"""
|
||||
self.redis = redis_client
|
||||
# 创建 httpx 异步客户端
|
||||
# timeout: 连接超时5秒,读取超时10秒
|
||||
self.client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(connect=5.0, read=10.0, write=10.0, pool=5.0)
|
||||
)
|
||||
# 内存缓存(Redis 不可用时的降级方案)
|
||||
self._token_cache: Optional[str] = None
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# access_token 管理
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_access_token(self) -> str:
|
||||
"""获取企微 access_token。
|
||||
|
||||
优先从 Redis 缓存获取,如果缓存不存在或即将过期则重新获取。
|
||||
access_token 有效期 7200 秒,缓存 TTL 设为 6900 秒(提前 300 秒刷新)。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=ID&corpsecret=SECRET
|
||||
|
||||
Returns:
|
||||
str: access_token 字符串
|
||||
|
||||
Raises:
|
||||
Exception: 获取 access_token 失败
|
||||
"""
|
||||
# Redis 缓存 key
|
||||
cache_key = "wecom:access_token"
|
||||
|
||||
# 1. 尝试从 Redis 缓存获取
|
||||
if self.redis:
|
||||
try:
|
||||
cached_token = await self.redis.get(cache_key)
|
||||
if cached_token:
|
||||
logger.debug("从缓存获取 access_token")
|
||||
return cached_token.decode("utf-8")
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 读取失败(降级): {e}")
|
||||
|
||||
# 1b. 尝试从内存缓存获取
|
||||
if self._token_cache:
|
||||
logger.debug("从内存缓存获取 access_token")
|
||||
return self._token_cache
|
||||
|
||||
# 2. 缓存未命中,调用企微 API 获取
|
||||
logger.info("缓存未命中,调用企微API获取 access_token")
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
params = {
|
||||
"corpid": settings.wecom_corp_id,
|
||||
"corpsecret": settings.wecom_secret,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
# 检查企微API返回码
|
||||
if result.get("errcode") != 0:
|
||||
error_msg = result.get("errmsg", "未知错误")
|
||||
logger.error(f"获取 access_token 失败: errcode={result.get('errcode')}, errmsg={error_msg}")
|
||||
raise Exception(f"企微API错误: {error_msg}")
|
||||
|
||||
access_token = result["access_token"]
|
||||
expires_in = result.get("expires_in", 7200)
|
||||
|
||||
# 3. 缓存到 Redis,TTL = 有效期 - 300秒(提前刷新)
|
||||
buffer_seconds = 300
|
||||
cache_ttl = max(expires_in - buffer_seconds, 60) # 至少缓存 60 秒
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.setex(cache_key, cache_ttl, access_token)
|
||||
except Exception as e:
|
||||
logger.warning(f"Redis 写入失败(降级): {e}")
|
||||
|
||||
# 3b. 同时缓存到内存
|
||||
self._token_cache = access_token
|
||||
|
||||
logger.info(f"access_token 获取成功,缓存 TTL={cache_ttl}秒")
|
||||
return access_token
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取 access_token 网络错误: {e}")
|
||||
raise Exception(f"企微API网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送文本消息
|
||||
# --------------------------------------------------------------------------
|
||||
async def send_text_message(
|
||||
self, user_id: str, content: str
|
||||
) -> Dict[str, Any]:
|
||||
"""向员工发送文本消息。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
|
||||
|
||||
请求体:
|
||||
{
|
||||
"touser": "UserID",
|
||||
"msgtype": "text",
|
||||
"agentid": 1000002,
|
||||
"text": {"content": "消息内容"}
|
||||
}
|
||||
|
||||
Args:
|
||||
user_id: 员工的企微 UserID
|
||||
content: 消息内容(纯文本)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 企微API返回结果
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
payload = {
|
||||
"touser": user_id,
|
||||
"msgtype": "text",
|
||||
"agentid": int(settings.wecom_agent_id),
|
||||
"text": {"content": content},
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(
|
||||
f"发送文本消息失败: user_id={user_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"发送文本消息成功: user_id={user_id}")
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"发送文本消息网络错误: user_id={user_id}, error={e}")
|
||||
raise Exception(f"发送消息网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送卡片消息
|
||||
# --------------------------------------------------------------------------
|
||||
async def send_card_message(
|
||||
self,
|
||||
user_id: str,
|
||||
title: str,
|
||||
description: str,
|
||||
url: str = "",
|
||||
btntxt: str = "详情",
|
||||
) -> Dict[str, Any]:
|
||||
"""向员工发送文本卡片消息。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
|
||||
|
||||
请求体:
|
||||
{
|
||||
"touser": "UserID",
|
||||
"msgtype": "textcard",
|
||||
"agentid": 1000002,
|
||||
"textcard": {
|
||||
"title": "标题",
|
||||
"description": "描述",
|
||||
"url": "链接",
|
||||
"btntxt": "按钮文字"
|
||||
}
|
||||
}
|
||||
|
||||
Args:
|
||||
user_id: 员工的企微 UserID
|
||||
title: 卡片标题
|
||||
description: 卡片描述
|
||||
url: 卡片点击跳转链接
|
||||
btntxt: 按钮文字(默认"详情")
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 企微API返回结果
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url_api = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
payload = {
|
||||
"touser": user_id,
|
||||
"msgtype": "textcard",
|
||||
"agentid": int(settings.wecom_agent_id),
|
||||
"textcard": {
|
||||
"title": title,
|
||||
"description": description,
|
||||
"url": url,
|
||||
"btntxt": btntxt,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url_api, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(
|
||||
f"发送卡片消息失败: user_id={user_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"发送卡片消息成功: user_id={user_id}")
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"发送卡片消息网络错误: user_id={user_id}, error={e}")
|
||||
raise Exception(f"发送消息网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送图片消息
|
||||
# --------------------------------------------------------------------------
|
||||
async def send_image_message(
|
||||
self, user_id: str, media_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""向员工发送图片消息。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
|
||||
|
||||
请求体:
|
||||
{
|
||||
"touser": "UserID",
|
||||
"msgtype": "image",
|
||||
"agentid": 1000002,
|
||||
"image": {"media_id": "MEDIA_ID"}
|
||||
}
|
||||
|
||||
注意:发送图片前需要先通过 upload_temp_media 上传图片获取 media_id。
|
||||
|
||||
Args:
|
||||
user_id: 员工的企微 UserID
|
||||
media_id: 图片媒体ID(通过上传临时素材获取)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 企微API返回结果
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
payload = {
|
||||
"touser": user_id,
|
||||
"msgtype": "image",
|
||||
"agentid": int(settings.wecom_agent_id),
|
||||
"image": {"media_id": media_id},
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(
|
||||
f"发送图片消息失败: user_id={user_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"发送图片消息成功: user_id={user_id}")
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"发送图片消息网络错误: user_id={user_id}, error={e}")
|
||||
raise Exception(f"发送消息网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 发送文件消息
|
||||
# --------------------------------------------------------------------------
|
||||
async def send_file_message(
|
||||
self, user_id: str, media_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""向员工发送文件消息。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=TOKEN
|
||||
|
||||
请求体:
|
||||
{
|
||||
"touser": "UserID",
|
||||
"msgtype": "file",
|
||||
"agentid": 1000002,
|
||||
"file": {"media_id": "MEDIA_ID"}
|
||||
}
|
||||
|
||||
注意:发送文件前需要先通过 upload_temp_media 上传文件获取 media_id。
|
||||
|
||||
Args:
|
||||
user_id: 员工的企微 UserID
|
||||
media_id: 文件媒体ID(通过上传临时素材获取)
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 企微API返回结果
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
payload = {
|
||||
"touser": user_id,
|
||||
"msgtype": "file",
|
||||
"agentid": int(settings.wecom_agent_id),
|
||||
"file": {"media_id": media_id},
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.post(url, json=payload)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") != 0:
|
||||
logger.error(
|
||||
f"发送文件消息失败: user_id={user_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"发送文件消息成功: user_id={user_id}")
|
||||
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"发送文件消息网络错误: user_id={user_id}, error={e}")
|
||||
raise Exception(f"发送消息网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取员工通讯录信息
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_user_info(self, user_id: str) -> Dict[str, Any]:
|
||||
"""获取员工通讯录详细信息(用于 VIP 判断)。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/user/get?access_token=TOKEN&userid=USERID
|
||||
|
||||
返回数据包含:
|
||||
- userid: 员工UserID
|
||||
- name: 员工姓名
|
||||
- department: 部门ID列表
|
||||
- position: 岗位
|
||||
- mobile: 手机号
|
||||
- email: 邮箱
|
||||
- status: 激活状态
|
||||
|
||||
需要企微通讯录只读权限。
|
||||
|
||||
Args:
|
||||
user_id: 员工的企微 UserID
|
||||
|
||||
Returns:
|
||||
Dict[str, Any]: 员工信息字典
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/user/get"
|
||||
params = {
|
||||
"access_token": access_token,
|
||||
"userid": user_id,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取员工信息失败: user_id={user_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"获取员工信息失败: {result.get('errmsg')}")
|
||||
|
||||
logger.info(f"获取员工信息成功: user_id={user_id}, name={result.get('name', '')}")
|
||||
return result
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取员工信息网络错误: user_id={user_id}, error={e}")
|
||||
raise Exception(f"获取员工信息网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 获取部门成员列表
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_department_members(
|
||||
self, department_id: int = 1, fetch_child: int = 1
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""获取部门成员列表。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/user/list?access_token=TOKEN&department_id=ID&fetch_child=1
|
||||
|
||||
Args:
|
||||
department_id: 部门ID(默认1为根部门)
|
||||
fetch_child: 是否递归获取子部门(1=是, 0=否)
|
||||
|
||||
Returns:
|
||||
List[Dict[str, Any]]: 部门成员列表
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/user/list"
|
||||
params = {
|
||||
"access_token": access_token,
|
||||
"department_id": department_id,
|
||||
"fetch_child": fetch_child,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"获取部门成员失败: dept_id={department_id}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"获取部门成员失败: {result.get('errmsg')}")
|
||||
|
||||
userlist = result.get("userlist", [])
|
||||
logger.info(f"获取部门成员成功: dept_id={department_id}, count={len(userlist)}")
|
||||
return userlist
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"获取部门成员网络错误: dept_id={department_id}, error={e}")
|
||||
raise Exception(f"获取部门成员网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 上传临时素材
|
||||
# --------------------------------------------------------------------------
|
||||
async def upload_temp_media(
|
||||
self, media_type: str, file_data: bytes, filename: str = "upload"
|
||||
) -> str:
|
||||
"""上传临时素材(图片/文件/语音),获取 media_id。
|
||||
|
||||
对应企微API:
|
||||
POST https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token=TOKEN&type=TYPE
|
||||
|
||||
临时素材有效期 3 天,适用于发送图片/文件消息。
|
||||
|
||||
Args:
|
||||
media_type: 媒体类型(image/file/voice)
|
||||
file_data: 文件二进制数据
|
||||
filename: 文件名
|
||||
|
||||
Returns:
|
||||
str: media_id(用于发送图片/文件消息时引用)
|
||||
|
||||
Raises:
|
||||
Exception: 上传失败
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/media/upload?access_token={access_token}&type={media_type}"
|
||||
|
||||
try:
|
||||
# 使用 multipart 上传文件
|
||||
files = {"media": (filename, file_data)}
|
||||
response = await self.client.post(url, files=files)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"上传临时素材失败: type={media_type}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"上传临时素材失败: {result.get('errmsg')}")
|
||||
|
||||
media_id = result.get("media_id", "")
|
||||
logger.info(f"上传临时素材成功: type={media_type}, media_id={media_id}")
|
||||
return media_id
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"上传临时素材网络错误: type={media_type}, error={e}")
|
||||
raise Exception(f"上传临时素材网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# OAuth2 授权换算用户身份
|
||||
# --------------------------------------------------------------------------
|
||||
async def get_oauth_user_info(self, code: str) -> Dict[str, str]:
|
||||
"""通过 OAuth2 授权码换取员工身份信息。
|
||||
|
||||
对应企微API:
|
||||
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo?access_token=TOKEN&code=CODE
|
||||
|
||||
H5 页面通过企微 OAuth2 静默授权获取 code,后端用 code 换取员工 UserID。
|
||||
适用于 H5 用户端身份识别。
|
||||
|
||||
Args:
|
||||
code: 企微 OAuth2 授权码
|
||||
|
||||
Returns:
|
||||
Dict[str, str]: 包含 userid 和 user_ticket 的字典
|
||||
|
||||
Raises:
|
||||
Exception: 换取失败
|
||||
"""
|
||||
access_token = await self.get_access_token()
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo"
|
||||
params = {
|
||||
"access_token": access_token,
|
||||
"code": code,
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self.client.get(url, params=params)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode", 0) != 0:
|
||||
logger.error(
|
||||
f"OAuth2换取用户身份失败: code={code}, "
|
||||
f"errcode={result.get('errcode')}, errmsg={result.get('errmsg')}"
|
||||
)
|
||||
raise Exception(f"OAuth2换取用户身份失败: {result.get('errmsg')}")
|
||||
|
||||
user_id = result.get("userid", "")
|
||||
logger.info(f"OAuth2换取用户身份成功: userid={user_id}")
|
||||
return {
|
||||
"userid": user_id,
|
||||
"user_ticket": result.get("user_ticket", ""),
|
||||
}
|
||||
|
||||
except httpx.HTTPError as e:
|
||||
logger.error(f"OAuth2换取用户身份网络错误: code={code}, error={e}")
|
||||
raise Exception(f"OAuth2换取用户身份网络错误: {e}") from e
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 关闭客户端
|
||||
# --------------------------------------------------------------------------
|
||||
async def close(self) -> None:
|
||||
"""关闭 HTTP 客户端连接池。
|
||||
|
||||
应用关闭时调用,释放资源。
|
||||
"""
|
||||
await self.client.aclose()
|
||||
logger.info("WecomService HTTP 客户端已关闭")
|
||||
@@ -0,0 +1,445 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — AI Wingman 服务(坐席智能副驾驶)
|
||||
# =============================================================================
|
||||
# 说明:复用 Dify 基础设施,使用独立的 Wingman Agent(Agent 2),
|
||||
# 与员工端 AI(Agent 1)共用知识库但 system prompt 不同。
|
||||
#
|
||||
# 核心能力:
|
||||
# 1. 生成 AI 草稿回复 — 基于对话上下文为坐席生成专业回复
|
||||
# 2. 生成会话自动摘要 — 结单时自动提取问题/原因/解决方案
|
||||
# 3. 生成自动标签建议 — 基于对话内容建议标签分类
|
||||
#
|
||||
# 降级策略:Wingman Agent 不可用时返回友好错误信息,不抛异常
|
||||
# =============================================================================
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WingmanService:
|
||||
"""AI Wingman 服务 — 坐席智能副驾驶。
|
||||
|
||||
复用 Dify 基础设施,使用独立的 Wingman Agent(Agent 2),
|
||||
与员工端 AI(Agent 1)共用知识库但 system prompt 不同。
|
||||
|
||||
三个核心方法使用不同的 system prompt:
|
||||
- 草稿生成:生成坐席可采纳的专业回复
|
||||
- 摘要生成:提取结构化的会话摘要
|
||||
- 标签建议:建议标签分类和优先级
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# System Prompt 定义
|
||||
# --------------------------------------------------------------------------
|
||||
_DRAFT_SYSTEM_PROMPT: str = (
|
||||
"你是一个IT服务坐席助手,基于对话上下文为坐席生成专业、准确的回复草稿。"
|
||||
"直接输出回复内容,不要解释。"
|
||||
)
|
||||
|
||||
_SUMMARY_SYSTEM_PROMPT: str = (
|
||||
"你是一个IT服务分析助手,基于完整对话生成结构化摘要,"
|
||||
"包含:问题、原因、解决方案。以JSON格式输出。"
|
||||
"输出格式:{\"problem\": \"问题描述\", \"cause\": \"原因\", \"solution\": \"解决方案\"}"
|
||||
)
|
||||
|
||||
_TAGS_SYSTEM_PROMPT: str = (
|
||||
"你是一个IT服务分类助手,基于对话内容建议标签分类。以JSON格式输出。"
|
||||
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 Wingman 服务。
|
||||
|
||||
从配置读取 Wingman Agent 的 API 地址和认证信息。
|
||||
独立于 AIService,使用自己的 httpx 客户端。
|
||||
"""
|
||||
# Wingman Agent 专用 API 端点
|
||||
self.api_url = settings.dify_wingman_api_url
|
||||
# Wingman Agent API Key
|
||||
self.api_key = settings.dify_wingman_api_key
|
||||
# 请求超时(秒)
|
||||
self.timeout = settings.dify_wingman_timeout
|
||||
|
||||
# httpx 异步客户端(复用连接池)
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""获取或创建 httpx 异步客户端(懒加载)。
|
||||
|
||||
复用连接池,避免每次请求都创建新连接。
|
||||
|
||||
Returns:
|
||||
httpx.AsyncClient: 异步 HTTP 客户端实例
|
||||
"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(self.timeout),
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""关闭 httpx 客户端,释放连接池资源。"""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
logger.debug("WingmanService httpx client closed")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核心方法 1:生成 AI 草稿回复
|
||||
# --------------------------------------------------------------------------
|
||||
async def generate_draft(
|
||||
self,
|
||||
conversation_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
db: Any = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""生成 AI 草稿回复。
|
||||
|
||||
传入当前会话的完整消息历史,让 Wingman Agent 基于上下文
|
||||
生成坐席可以采纳的草稿回复。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
messages: 会话消息历史列表,每条消息包含 sender_type/content 等
|
||||
db: 数据库会话(可选,当前未使用)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"content": str, # 草稿内容
|
||||
"confidence": float, # 置信度(0-1)
|
||||
"reasoning": str, # 生成推理说明
|
||||
}
|
||||
"""
|
||||
# 构建对话上下文消息列表
|
||||
context_messages = self._build_context_messages(
|
||||
messages, self._DRAFT_SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
try:
|
||||
result = await self._call_wingman_api(context_messages)
|
||||
|
||||
if result is None:
|
||||
return {
|
||||
"content": "",
|
||||
"confidence": 0.0,
|
||||
"reasoning": "Wingman 服务暂不可用",
|
||||
}
|
||||
|
||||
reply_content = result
|
||||
|
||||
# 基于回复长度和内容质量估算置信度
|
||||
confidence = self._estimate_confidence(reply_content)
|
||||
|
||||
return {
|
||||
"content": reply_content,
|
||||
"confidence": confidence,
|
||||
"reasoning": f"基于最近 {len(messages)} 条对话上下文生成",
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wingman 草稿生成失败: {e}")
|
||||
return {
|
||||
"content": "",
|
||||
"confidence": 0.0,
|
||||
"reasoning": f"AI 服务异常: {str(e)}",
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核心方法 2:生成会话自动摘要
|
||||
# --------------------------------------------------------------------------
|
||||
async def generate_summary(
|
||||
self,
|
||||
conversation_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""生成会话自动摘要。
|
||||
|
||||
基于完整对话生成结构化摘要,包含问题、原因、解决方案。
|
||||
结单时自动调用。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
messages: 会话消息历史列表
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"problem": str, # 问题描述
|
||||
"cause": str, # 原因分析
|
||||
"solution": str, # 解决方案
|
||||
}
|
||||
"""
|
||||
context_messages = self._build_context_messages(
|
||||
messages, self._SUMMARY_SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
# 默认摘要(降级时使用)
|
||||
default_summary = {
|
||||
"problem": "无法自动生成摘要",
|
||||
"cause": "",
|
||||
"solution": "",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self._call_wingman_api(context_messages)
|
||||
|
||||
if result is None:
|
||||
return default_summary
|
||||
|
||||
# 尝试解析 JSON 格式的摘要
|
||||
parsed = self._parse_json_response(result, default_summary)
|
||||
|
||||
return {
|
||||
"problem": parsed.get("problem", default_summary["problem"]),
|
||||
"cause": parsed.get("cause", default_summary["cause"]),
|
||||
"solution": parsed.get("solution", default_summary["solution"]),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wingman 摘要生成失败: {e}")
|
||||
return default_summary
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核心方法 3:生成自动标签建议
|
||||
# --------------------------------------------------------------------------
|
||||
async def suggest_tags(
|
||||
self,
|
||||
conversation_id: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
existing_tags: Dict[str, Any] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""生成自动标签建议。
|
||||
|
||||
基于对话内容建议标签分类,包含标签列表、分类和优先级。
|
||||
|
||||
Args:
|
||||
conversation_id: 会话ID
|
||||
messages: 会话消息历史列表
|
||||
existing_tags: 已有标签(可选,用于避免重复建议)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"suggested_tags": list[str], # 建议标签列表
|
||||
"category": str, # 分类
|
||||
"priority": str, # 优先级: low/medium/high
|
||||
}
|
||||
"""
|
||||
context_messages = self._build_context_messages(
|
||||
messages, self._TAGS_SYSTEM_PROMPT
|
||||
)
|
||||
|
||||
# 默认标签建议(降级时使用)
|
||||
default_tags = {
|
||||
"suggested_tags": [],
|
||||
"category": "",
|
||||
"priority": "medium",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self._call_wingman_api(context_messages)
|
||||
|
||||
if result is None:
|
||||
return default_tags
|
||||
|
||||
# 尝试解析 JSON 格式的标签建议
|
||||
parsed = self._parse_json_response(result, default_tags)
|
||||
|
||||
return {
|
||||
"suggested_tags": parsed.get("suggested_tags", []),
|
||||
"category": parsed.get("category", ""),
|
||||
"priority": parsed.get("priority", "medium"),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wingman 标签建议失败: {e}")
|
||||
return default_tags
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 内部方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _build_context_messages(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
system_prompt: str,
|
||||
) -> List[Dict[str, str]]:
|
||||
"""构建发送给 Wingman Agent 的消息列表。
|
||||
|
||||
将数据库中的消息历史转换为 OpenAI Chat Completions 格式的
|
||||
messages 列表,包含 system prompt 和对话上下文。
|
||||
|
||||
Args:
|
||||
messages: 数据库消息列表
|
||||
system_prompt: 当前场景的 system prompt
|
||||
|
||||
Returns:
|
||||
List[Dict]: OpenAI 格式的消息列表
|
||||
"""
|
||||
# 构建上下文消息列表
|
||||
context: List[Dict[str, str]] = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
|
||||
# 角色映射:数据库 sender_type → OpenAI role
|
||||
role_map = {
|
||||
"employee": "user", # 员工消息 → user
|
||||
"agent": "assistant", # 坐席消息 → assistant
|
||||
"ai": "assistant", # AI消息 → assistant
|
||||
"system": "system", # 系统消息 → system
|
||||
}
|
||||
|
||||
for msg in messages:
|
||||
role = role_map.get(msg.get("sender_type", ""), "user")
|
||||
content = msg.get("content", "")
|
||||
if content:
|
||||
# 跳过系统消息(已有 system prompt)
|
||||
if msg.get("sender_type") == "system":
|
||||
continue
|
||||
context.append({"role": role, "content": content})
|
||||
|
||||
return context
|
||||
|
||||
async def _call_wingman_api(
|
||||
self,
|
||||
context_messages: List[Dict[str, str]],
|
||||
) -> Optional[str]:
|
||||
"""调用 Wingman Agent API(非流式)。
|
||||
|
||||
Args:
|
||||
context_messages: OpenAI 格式的消息列表
|
||||
|
||||
Returns:
|
||||
Optional[str]: AI 回复内容,失败时返回 None
|
||||
"""
|
||||
payload = {
|
||||
"model": "Chat",
|
||||
"messages": context_messages,
|
||||
"stream": False,
|
||||
"temperature": 0.3, # 适中的温度,保证准确性同时有一定灵活性
|
||||
}
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
logger.info(f"调用 Wingman API: messages_count={len(context_messages)}")
|
||||
response = await client.post(self.api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 解析 OpenAI 兼容格式的返回
|
||||
choices = data.get("choices", [])
|
||||
if not choices:
|
||||
logger.warning("Wingman API 返回空 choices")
|
||||
return None
|
||||
|
||||
reply_content = choices[0]["message"]["content"]
|
||||
logger.info(f"Wingman API 返回: content_length={len(reply_content)}")
|
||||
|
||||
return reply_content
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Wingman API 超时")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Wingman API HTTP 错误: status={e.response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Wingman API 调用失败: {e}")
|
||||
return None
|
||||
|
||||
def _parse_json_response(
|
||||
self,
|
||||
content: str,
|
||||
default: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""解析 AI 返回的 JSON 内容。
|
||||
|
||||
Wingman Agent 可能返回带 markdown 代码块的 JSON,
|
||||
也可能返回纯 JSON。此方法尝试多种解析方式。
|
||||
|
||||
Args:
|
||||
content: AI 返回的原始文本
|
||||
default: 解析失败时的默认值
|
||||
|
||||
Returns:
|
||||
Dict: 解析后的字典,失败时返回默认值
|
||||
"""
|
||||
if not content:
|
||||
return default
|
||||
|
||||
# 尝试 1:直接解析
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 2:提取 markdown 代码块中的 JSON
|
||||
# AI 可能返回 ```json ... ``` 格式
|
||||
import re
|
||||
json_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', content, re.DOTALL)
|
||||
if json_match:
|
||||
try:
|
||||
return json.loads(json_match.group(1).strip())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# 尝试 3:查找第一个 { 到最后一个 } 之间的内容
|
||||
start = content.find('{')
|
||||
end = content.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
try:
|
||||
return json.loads(content[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
logger.warning(f"Wingman JSON 解析失败,使用默认值: {content[:200]}")
|
||||
return default
|
||||
|
||||
def _estimate_confidence(self, content: str) -> float:
|
||||
"""估算 AI 草稿回复的置信度。
|
||||
|
||||
基于回复长度和内容特征估算一个粗略的置信度值。
|
||||
- 回复过短(< 10 字符):低置信度
|
||||
- 回复包含不确定措辞:降低置信度
|
||||
- 回复长度适中、内容具体:高置信度
|
||||
|
||||
Args:
|
||||
content: AI 回复内容
|
||||
|
||||
Returns:
|
||||
float: 置信度(0.0 - 1.0)
|
||||
"""
|
||||
if not content or len(content.strip()) < 5:
|
||||
return 0.2
|
||||
|
||||
confidence = 0.8 # 基础置信度
|
||||
|
||||
# 回复过短降低置信度
|
||||
if len(content) < 10:
|
||||
confidence -= 0.3
|
||||
elif len(content) < 30:
|
||||
confidence -= 0.1
|
||||
|
||||
# 包含不确定措辞降低置信度
|
||||
uncertain_phrases = ["可能", "大概", "也许", "不确定", "建议您"]
|
||||
for phrase in uncertain_phrases:
|
||||
if phrase in content:
|
||||
confidence -= 0.05
|
||||
|
||||
# 包含具体步骤或链接提高置信度
|
||||
confident_phrases = ["步骤", "请按以下", "点击", "打开", "http"]
|
||||
for phrase in confident_phrases:
|
||||
if phrase in content:
|
||||
confidence += 0.05
|
||||
|
||||
# 限制在 0.0 - 1.0 范围内
|
||||
return max(0.0, min(1.0, confidence))
|
||||
@@ -0,0 +1,278 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — WebSocket 连接管理器
|
||||
# =============================================================================
|
||||
# 说明:管理所有坐席和H5员工的 WebSocket 连接,提供:
|
||||
# 1. 坐席连接注册/注销(坐席上线/下线)
|
||||
# 2. H5员工连接注册/注销(员工打开H5页面时建立)
|
||||
# 3. 向指定坐席/员工发送消息(定向推送)
|
||||
# 4. 广播消息给所有在线坐席(全员推送)
|
||||
# 5. 向指定员工推送消息(参与者事件定向推送)
|
||||
# 6. 自动清理断连的 WebSocket 连接
|
||||
#
|
||||
# 设计决策:
|
||||
# - 使用模块级单例,全局共享同一个 ConnectionManager 实例
|
||||
# - broadcast 遇到发送失败自动断开该连接,避免僵尸连接积累
|
||||
# - 所有发送方法都不阻塞调用方,失败只记 warning 不抛异常
|
||||
# - 坐席和员工连接分开管理(不同认证体系、不同推送需求)
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""管理所有坐席和H5员工的 WebSocket 连接。
|
||||
|
||||
核心职责:
|
||||
- 维护 agent_id → WebSocket 的映射表(坐席连接)
|
||||
- 维护 employee_id → WebSocket 的映射表(H5员工连接)
|
||||
- 提供定向推送和广播能力
|
||||
- 自动清理无效连接
|
||||
|
||||
为什么需要这个类:
|
||||
- FastAPI 的 WebSocket 是无状态的,需要一个集中管理器来跟踪所有活跃连接
|
||||
- 后端服务(消息路由、会话管理等)需要通过此管理器向前端推送实时事件
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""初始化连接管理器。
|
||||
|
||||
active_connections: 字典,key=坐席ID,value=WebSocket连接对象
|
||||
同一个坐席只保留最新的连接(后连接的会替换旧连接)
|
||||
|
||||
employee_connections: 字典,key=员工ID,value=WebSocket连接对象
|
||||
同一个员工只保留最新的连接(后连接的会替换旧连接)
|
||||
"""
|
||||
# 坐席连接(agent_id → WebSocket)
|
||||
self.active_connections: Dict[str, WebSocket] = {}
|
||||
# H5员工连接(employee_id → WebSocket)
|
||||
self.employee_connections: Dict[str, WebSocket] = {}
|
||||
|
||||
# ==========================================================================
|
||||
# 坐席连接管理
|
||||
# ==========================================================================
|
||||
|
||||
async def connect(self, agent_id: str, websocket: WebSocket) -> None:
|
||||
"""接受坐席 WebSocket 握手并注册连接。
|
||||
|
||||
做什么:完成 WebSocket 握手(accept),然后将连接存入映射表
|
||||
为什么:必须在 send_json 之前 accept,否则客户端收不到消息
|
||||
|
||||
如果同一坐席重复连接(如刷新页面),旧连接会被覆盖,
|
||||
旧连接的 onclose 回调会触发 disconnect 做清理。
|
||||
|
||||
Args:
|
||||
agent_id: 坐席ID(企微 UserID)
|
||||
websocket: FastAPI WebSocket 对象
|
||||
"""
|
||||
# 完成 WebSocket 握手(必须先 accept 才能收发消息)
|
||||
await websocket.accept()
|
||||
|
||||
# 如果该坐席已有连接,先关闭旧连接
|
||||
# 场景:坐席刷新页面或重新登录,会产生新连接
|
||||
if agent_id in self.active_connections:
|
||||
old_ws = self.active_connections[agent_id]
|
||||
try:
|
||||
await old_ws.close()
|
||||
except Exception:
|
||||
# 旧连接可能已经断开,忽略关闭错误
|
||||
pass
|
||||
|
||||
# 注册新连接
|
||||
self.active_connections[agent_id] = websocket
|
||||
logger.info(
|
||||
f"坐席 WebSocket 连接建立: agent_id={agent_id}, "
|
||||
f"当前在线坐席数={len(self.active_connections)}"
|
||||
)
|
||||
|
||||
def disconnect(self, agent_id: str) -> None:
|
||||
"""从坐席映射表中移除连接。
|
||||
|
||||
做什么:删除 agent_id 对应的 WebSocket 映射
|
||||
为什么:坐席关闭页面或网络断开时,需要清理映射表,避免向已断开的连接发消息
|
||||
|
||||
注意:只做映射表清理,不主动关闭 WebSocket(由调用方或 onclose 回调处理)
|
||||
|
||||
Args:
|
||||
agent_id: 坐席ID
|
||||
"""
|
||||
if agent_id in self.active_connections:
|
||||
del self.active_connections[agent_id]
|
||||
logger.info(
|
||||
f"坐席 WebSocket 连接断开: agent_id={agent_id}, "
|
||||
f"当前在线坐席数={len(self.active_connections)}"
|
||||
)
|
||||
|
||||
async def send_to_agent(self, agent_id: str, data: dict) -> None:
|
||||
"""向指定坐席发送消息。
|
||||
|
||||
做什么:通过 WebSocket 向指定坐席推送 JSON 数据
|
||||
为什么:某些事件只需要通知特定坐席(如会话分配给你了)
|
||||
|
||||
如果发送失败(连接已断开),自动清理该连接。
|
||||
|
||||
Args:
|
||||
agent_id: 目标坐席ID
|
||||
data: 要发送的数据(会被序列化为 JSON)
|
||||
"""
|
||||
websocket = self.active_connections.get(agent_id)
|
||||
if not websocket:
|
||||
# 该坐席不在线,跳过
|
||||
logger.debug(f"坐席不在线,跳过推送: agent_id={agent_id}")
|
||||
return
|
||||
|
||||
try:
|
||||
await websocket.send_json(data)
|
||||
except Exception as e:
|
||||
# 发送失败 → 连接已断开,自动清理
|
||||
logger.warning(f"WebSocket 发送失败,清理坐席连接: agent_id={agent_id}, error={e}")
|
||||
self.disconnect(agent_id)
|
||||
|
||||
async def broadcast(self, data: dict) -> None:
|
||||
"""向所有在线坐席广播消息。
|
||||
|
||||
做什么:遍历所有活跃连接,逐一发送 JSON 数据
|
||||
为什么:新消息、会话状态变更等事件需要通知所有坐席
|
||||
|
||||
关键设计:
|
||||
- 发送失败的连接会被自动断开和清理,避免僵尸连接
|
||||
- 不因单个连接失败而中断整次广播
|
||||
- 使用 list() 拷贝映射表的 key,避免遍历时字典大小改变
|
||||
|
||||
Args:
|
||||
data: 要广播的数据(会被序列化为 JSON)
|
||||
"""
|
||||
# 拷贝 key 列表,避免遍历过程中字典被修改(disconnect 会删条目)
|
||||
agent_ids = list(self.active_connections.keys())
|
||||
|
||||
if not agent_ids:
|
||||
logger.debug("没有在线坐席,跳过广播")
|
||||
return
|
||||
|
||||
for agent_id in agent_ids:
|
||||
# send_to_agent 内部已有异常处理,会自动清理断连的 WS
|
||||
await self.send_to_agent(agent_id, data)
|
||||
|
||||
logger.debug(f"广播完成: 在线坐席数={len(agent_ids)}, 事件类型={data.get('type', 'unknown')}")
|
||||
|
||||
# ==========================================================================
|
||||
# H5员工连接管理
|
||||
# ==========================================================================
|
||||
|
||||
async def connect_employee(self, employee_id: str, websocket: WebSocket) -> None:
|
||||
"""接受H5员工 WebSocket 握手并注册连接。
|
||||
|
||||
做什么:完成 WebSocket 握手(accept),然后将连接存入员工映射表
|
||||
为什么:H5员工需要实时接收参与者变更、新消息等事件
|
||||
|
||||
如果同一员工重复连接(如刷新页面),旧连接会被覆盖。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
websocket: FastAPI WebSocket 对象
|
||||
"""
|
||||
# 完成 WebSocket 握手
|
||||
await websocket.accept()
|
||||
|
||||
# 如果该员工已有连接,先关闭旧连接
|
||||
if employee_id in self.employee_connections:
|
||||
old_ws = self.employee_connections[employee_id]
|
||||
try:
|
||||
await old_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 注册新连接
|
||||
self.employee_connections[employee_id] = websocket
|
||||
logger.info(
|
||||
f"H5员工 WebSocket 连接建立: employee_id={employee_id}, "
|
||||
f"当前在线员工数={len(self.employee_connections)}"
|
||||
)
|
||||
|
||||
def disconnect_employee(self, employee_id: str) -> None:
|
||||
"""从H5员工映射表中移除连接。
|
||||
|
||||
做什么:删除 employee_id 对应的 WebSocket 映射
|
||||
为什么:员工关闭H5页面或网络断开时,需要清理映射表
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
"""
|
||||
if employee_id in self.employee_connections:
|
||||
del self.employee_connections[employee_id]
|
||||
logger.info(
|
||||
f"H5员工 WebSocket 连接断开: employee_id={employee_id}, "
|
||||
f"当前在线员工数={len(self.employee_connections)}"
|
||||
)
|
||||
|
||||
async def send_to_employee(self, employee_id: str, data: dict) -> None:
|
||||
"""向指定H5员工发送消息。
|
||||
|
||||
做什么:通过 WebSocket 向指定H5员工推送 JSON 数据
|
||||
为什么:参与者事件、新消息等需要推送给相关员工
|
||||
|
||||
如果发送失败(连接已断开),自动清理该连接。
|
||||
|
||||
Args:
|
||||
employee_id: 目标员工企微 UserID
|
||||
data: 要发送的数据(会被序列化为 JSON)
|
||||
"""
|
||||
websocket = self.employee_connections.get(employee_id)
|
||||
if not websocket:
|
||||
# 该员工不在线(未打开H5页面),跳过
|
||||
logger.debug(f"H5员工不在线,跳过推送: employee_id={employee_id}")
|
||||
return
|
||||
|
||||
try:
|
||||
await websocket.send_json(data)
|
||||
except Exception as e:
|
||||
# 发送失败 → 连接已断开,自动清理
|
||||
logger.warning(f"H5员工 WebSocket 发送失败,清理连接: employee_id={employee_id}, error={e}")
|
||||
self.disconnect_employee(employee_id)
|
||||
|
||||
async def broadcast_to_employees(self, employee_ids: List[str], data: dict) -> None:
|
||||
"""向指定的多个H5员工推送消息。
|
||||
|
||||
做什么:遍历 employee_ids,逐一推送 JSON 数据
|
||||
为什么:参与者变更事件只需通知该会话的参与者(非全员广播)
|
||||
|
||||
Args:
|
||||
employee_ids: 目标员工ID列表
|
||||
data: 要发送的数据
|
||||
"""
|
||||
if not employee_ids:
|
||||
return
|
||||
|
||||
for employee_id in employee_ids:
|
||||
await self.send_to_employee(employee_id, data)
|
||||
|
||||
# ==========================================================================
|
||||
# 辅助方法
|
||||
# ==========================================================================
|
||||
|
||||
def is_employee_online(self, employee_id: str) -> bool:
|
||||
"""检查指定员工是否在线(有活跃的H5 WS连接)。
|
||||
|
||||
做什么:查询员工是否在 employee_connections 中
|
||||
为什么:某些场景需要判断员工是否在线(如是否需要通过企微消息降级推送)
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
|
||||
Returns:
|
||||
bool: 是否在线
|
||||
"""
|
||||
return employee_id in self.employee_connections
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 模块级单例
|
||||
# --------------------------------------------------------------------------
|
||||
# 为什么用单例:所有后端服务共享同一个 ConnectionManager 实例,
|
||||
# 确保 WebSocket 连接映射表全局唯一,消息路由和会话服务都能推送事件
|
||||
# --------------------------------------------------------------------------
|
||||
manager = ConnectionManager()
|
||||
Reference in New Issue
Block a user