chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+22
View File
@@ -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
+289
View File
@@ -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,
)
+271
View File
@@ -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
+232
View File
@@ -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: 企微消息唯一 IDMsgId 字段)
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
View File
@@ -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
View File
@@ -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交叉匹配
- aTrustqueryAll(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交叉匹配
- aTrustgetUserStatus
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
View File
@@ -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
View File
@@ -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 SecretHMAC-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 IDx-ca-key Header",
)
atrust_api_secret: Optional[str] = Field(
default=None,
description="aTrust API SecretHMAC-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
View File
@@ -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 固定返回 TrueMock 永远可用)
"""
@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] 健康检查 → OKMock模式)")
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
View File
@@ -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. aTrustVPN源,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
# 再查 aTrustVPN接入)
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
+671
View File
@@ -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: 企微消息唯一 IDMsgId),用于去重
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 结果到 Redis1 小时)
# 避免每次消息都调企微 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 []
+406
View File
@@ -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
+263
View File
@@ -0,0 +1,263 @@
# =============================================================================
# 企微IT智能服务台 — 统一 Token 服务
# =============================================================================
# 说明:统一 Token 管理,支持以下功能:
# 1. 创建统一格式的 Token(包含角色信息)
# 2. 验证 Token 并获取用户信息
# 3. 切换当前角色
# 4. 兼容旧格式 Tokenemployee: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 TTL8小时)
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"
+573
View File
@@ -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. 缓存到 RedisTTL = 有效期 - 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 客户端已关闭")
+445
View File
@@ -0,0 +1,445 @@
# =============================================================================
# 企微IT智能服务台 — AI Wingman 服务(坐席智能副驾驶)
# =============================================================================
# 说明:复用 Dify 基础设施,使用独立的 Wingman AgentAgent 2),
# 与员工端 AIAgent 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 AgentAgent 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))
+278
View File
@@ -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=坐席IDvalue=WebSocket连接对象
同一个坐席只保留最新的连接(后连接的会替换旧连接)
employee_connections: 字典,key=员工IDvalue=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()