v3.1 + 批次0: 智能回复重构基线 - ApprovalMatcher + 关键词降级 + 文档速修 + v4.0任务书面化

This commit is contained in:
Simon
2026-07-17 23:08:59 +08:00
parent 5a77a89ab1
commit 3ed86d5fb3
181 changed files with 19738 additions and 2655 deletions
+3
View File
@@ -1489,6 +1489,7 @@ async def list_audit_conversations(
keyword: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
is_archived: Optional[bool] = None,
page: int = 1,
page_size: int = 20,
) -> Dict[str, Any]:
@@ -1521,6 +1522,8 @@ async def list_audit_conversations(
filters.append(Conversation.created_at < dt_to)
except ValueError:
pass
if is_archived is not None:
filters.append(Conversation.is_archived == is_archived)
if filters:
stmt = stmt.where(and_(*filters))
+16
View File
@@ -458,14 +458,26 @@ class AIService:
if parsed:
# JSON 解析成功
text = parsed.get("text", "")
# ★ 防御性类型保护:Dify 可能返回 text 为对象/列表而非字符串
# 当 text 为 dict/list 时,直接传给前端会显示 [object Object]
if not isinstance(text, str):
text = json.dumps(text, ensure_ascii=False) if text else ""
logger.warning(f"Dify 返回 text 非 String 类型,已转换为 JSON 字符串: {text[:80]}...")
action = parsed.get("action")
options = parsed.get("options")
diagnosis_stage = parsed.get("diagnosis_stage")
# ★ 调试日志:打印 action 对象的详细内容
logger.info(f"[DEBUG] Dify action 对象: {action}")
if action:
logger.info(f"[DEBUG] action.approval_type = {action.get('approval_type')}")
hit = self._check_knowledge_hit(text) if text else False
logger.info(
f"Dify 原生 structured 返回: hit={hit}, "
f"text_len={len(text)}, "
f"text_preview={text[:60]}, "
f"has_action={action is not None}, "
f"has_options={options is not None}, "
f"diagnosis_stage={diagnosis_stage}, "
@@ -556,6 +568,10 @@ class AIService:
if parsed:
# JSON 解析成功
text = parsed.get("text", "")
# ★ 防御性类型保护(与原生路径一致)
if not isinstance(text, str):
text = json.dumps(text, ensure_ascii=False) if text else ""
logger.warning(f"Dify 代理返回 text 非 String 类型,已转换: {text[:80]}...")
action = parsed.get("action")
options = parsed.get("options")
# Phase 6A: 提取诊断阶段(diagnosis_stage
+227
View File
@@ -0,0 +1,227 @@
# =============================================================================
# IT智能服务台 — 审批卡片匹配引擎
# =============================================================================
# 职责:统一审批匹配逻辑,替代前端 APPROVAL_OPTIONS 和后端分散的匹配代码
#
# 匹配优先级:
# 1. approval_type 精确匹配模板 ID(如 "zero_trust_vpn"
# 2. approval_type 关键词匹配模板 keywords(如 "VPN账号申请" 含 "VPN"
# 3. approval_type 匹配 category(如 "账号权限申请" → 返回该分类下所有模板)
# 4. title 匹配模板 name(如 "VPN账号申请" → zero_trust_vpn
# 5. 降级:用户原始文本遍历所有 keywords
#
# 输出:标准化卡片数据 card_data,前端纯渲染
# =============================================================================
import logging
from typing import Optional
from app.api.approval import APPROVAL_TEMPLATES
logger = logging.getLogger(__name__)
class ApprovalMatcher:
"""审批卡片匹配器 — 后端统一匹配入口"""
# ------------------------------------------------------------------
# 公开方法
# ------------------------------------------------------------------
def match_and_build_card(
self, approval_type: Optional[str], title: Optional[str] = None
) -> Optional[dict]:
"""一站式匹配 + 构建标准化卡片数据(Dify 正常路径)。
Args:
approval_type: Dify 返回的审批类型(可能是 ID、中文分类名或具体名称)
title: Dify 返回的具体审批名称(如"VPN账号申请"
Returns:
标准化 card_data,或 None(匹配失败)
"""
if not approval_type:
return None
# 优先级 1:精确 ID 匹配
template = self._match_by_id(approval_type)
if template:
return self._build_single_card(template)
# 优先级 2:关键词匹配
template = self._match_by_keyword(approval_type)
if template:
return self._build_single_card(template)
# 优先级 3category 匹配
options = self._match_by_category(approval_type)
if options:
return self._build_multi_card(approval_type, options)
# 优先级 4title 匹配
if title:
template = self._match_by_name(title)
if template:
return self._build_single_card(template)
# 优先级 5:文本 keywords 兜底
template = self._match_by_keyword(approval_type)
if template:
return self._build_single_card(template)
return None
def match_by_keywords(self, user_text: str) -> Optional[dict]:
"""Dify 不可用时,用用户原始文本关键词降级匹配。
Args:
user_text: 用户输入的原始文本
Returns:
标准化 card_data,或 None
"""
if not user_text:
return None
text_lower = user_text.lower()
# 遍历所有模板的 keywords
best_template = None
best_score = 0
for template_id, template in APPROVAL_TEMPLATES.items():
keywords = template.get("keywords", [])
score = 0
for kw in keywords:
if kw.lower() in text_lower:
score += len(kw) # 关键词越长,匹配越准确
if score > best_score:
best_score = score
best_template = template
if best_template and best_score > 0:
logger.info(
f"[ApprovalMatcher] 关键词降级匹配: text={user_text[:30]} -> "
f"template={best_template['id']}, score={best_score}"
)
return self._build_single_card(best_template)
return None
# ------------------------------------------------------------------
# 内部匹配方法
# ------------------------------------------------------------------
def _match_by_id(self, approval_type: str) -> Optional[dict]:
"""精确匹配模板 ID(如 "zero_trust_vpn")。"""
return APPROVAL_TEMPLATES.get(approval_type)
def _match_by_keyword(self, approval_type: str) -> Optional[dict]:
"""通过关键词匹配模板。
遍历所有模板的 keywords,检查 approval_type 是否包含任一关键词。
使用最长匹配优先策略(避免"设备升级"误匹配到"升级")。
"""
approval_lower = approval_type.lower()
best_template = None
best_len = 0
for template_id, template in APPROVAL_TEMPLATES.items():
keywords = template.get("keywords", [])
for kw in keywords:
if kw.lower() in approval_lower:
if len(kw) > best_len:
best_len = len(kw)
best_template = template
return best_template
def _match_by_category(self, approval_type: str) -> list[dict]:
"""按 category 匹配,返回该分类下所有模板的卡片选项。"""
result = []
for template_id, template in APPROVAL_TEMPLATES.items():
category = template.get("category", "")
if category == approval_type:
result.append(self._template_to_option(template))
return result
def _match_by_name(self, title: str) -> Optional[dict]:
"""通过模板 name 精确/模糊匹配(空格容错)。"""
normalized = title.replace(" ", "") # 去除空格容错
for template_id, template in APPROVAL_TEMPLATES.items():
name_clean = template["name"].replace(" ", "")
if name_clean == normalized or normalized in name_clean:
return template
return None
# ------------------------------------------------------------------
# 卡片数据构建
# ------------------------------------------------------------------
def _build_single_card(self, template: dict) -> dict:
"""构建单选项标准化卡片数据。"""
return {
"card_type": "single",
"title": template["name"],
"description": template.get("desc", ""),
"options": [self._template_to_option(template)],
}
def _build_multi_card(self, category_name: str, options: list[dict]) -> dict:
"""构建多选项标准化卡片数据。"""
return {
"card_type": "multiple",
"title": f"{category_name}{len(options)}项)",
"description": "请选择具体审批类型",
"options": options,
}
def _template_to_option(self, template: dict) -> dict:
"""将模板数据转换为前端卡片选项。"""
return {
"name": template["name"],
"icon": template.get("icon", "orders-o"),
"desc": template.get("desc", ""),
"url": template.get("url", ""),
"category": template.get("category", ""),
}
def get_all_categories(self) -> list[dict]:
"""获取所有分类及其选项(供前端全部展示用)。"""
categories = {}
for template_id, template in APPROVAL_TEMPLATES.items():
cat = template.get("category", "其他")
if cat not in categories:
categories[cat] = []
categories[cat].append(self._template_to_option(template))
result = []
for cat, options in categories.items():
result.append({
"category": cat,
"options": options,
})
return result
def get_all_templates(self) -> list[dict]:
"""获取所有模板(含新增字段)。"""
return [
{**template, "template_id": tid}
for tid, template in APPROVAL_TEMPLATES.items()
]
# ------------------------------------------------------------------
# 单例工厂
# ------------------------------------------------------------------
_approval_matcher: Optional[ApprovalMatcher] = None
def get_approval_matcher() -> ApprovalMatcher:
"""获取 ApprovalMatcher 单例。"""
global _approval_matcher
if _approval_matcher is None:
_approval_matcher = ApprovalMatcher()
return _approval_matcher
@@ -0,0 +1,430 @@
# -*- coding: utf-8 -*-
"""
资产推荐服务 (Asset Recommend Service)
用于右侧栏智能推荐的资产信息管理:
- 关键词匹配资产(L1
- 画像触发运维提醒(L2
- 角色通用资源(L3
支持 YAML 配置文件热重载
"""
import os
import json
import logging
import yaml
from pathlib import Path
from typing import List, Dict, Optional, Any
from datetime import datetime
from dataclasses import dataclass, asdict
logger = logging.getLogger(__name__)
@dataclass
class RecommendItem:
"""推荐操作项"""
label: str
type: str # download, approval, info, doc, guide, link, contact
url: Optional[str] = None
value: Optional[str] = None
copyable: Optional[bool] = False
description: Optional[str] = None
approval_type: Optional[str] = None
@dataclass
class RecommendCard:
"""推荐卡片"""
id: str
layer: str # L1, L2, L3
layer_label: str
source: str # dify_intent, keyword_assets, profile_trigger, role_assets
title: str
description: Optional[str] = None
icon: Optional[str] = None
items: Optional[List[Dict]] = None
action_url: Optional[str] = None
action_label: Optional[str] = None
confidence: Optional[float] = None
relevance: str = "high" # high, low
priority: int = 50
extra: Optional[Dict] = None
class AssetRecommendService:
"""
资产推荐服务
功能:
1. 从 YAML 配置文件加载资产数据
2. 关键词匹配查询
3. 画像触发规则匹配
4. 角色资源查询
5. 配置热重载
"""
_instance = None
def __new__(cls, *args, **kwargs):
"""单例模式"""
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self, config_path: str = None):
if self._initialized:
return
self._initialized = True
# 配置路径
self.config_path = config_path or os.environ.get(
'ASSETS_CONFIG_PATH',
str(Path(__file__).parent.parent / 'config' / 'assets.yaml')
)
# 数据存储
self.keyword_assets: Dict[str, Dict] = {}
self.role_assets: Dict[str, Dict] = {}
self._alias_map: Dict[str, str] = {}
# 加载配置
self.load()
logger.info(f"[AssetRecommend] 服务初始化完成,配置路径: {self.config_path}")
def load(self) -> None:
"""加载配置文件"""
try:
if not os.path.exists(self.config_path):
logger.warning(f"[AssetRecommend] 配置文件不存在: {self.config_path}")
return
with open(self.config_path, 'r', encoding='utf-8') as f:
config = yaml.safe_load(f)
if not config:
logger.warning("[AssetRecommend] 配置文件为空")
return
# 加载关键词资产
keyword_assets = config.get('keyword_assets', {})
self._build_keyword_assets(keyword_assets)
# 加载角色资产
self.role_assets = config.get('role_assets', {})
logger.info(
f"[AssetRecommend] 配置加载完成: "
f"关键词资产 {len(self.keyword_assets)} 个, "
f"角色资产 {len(self.role_assets)}"
)
except Exception as e:
logger.error(f"[AssetRecommend] 配置加载失败: {e}")
def _build_keyword_assets(self, keyword_assets: Dict) -> None:
"""构建关键词资产映射,处理别名"""
self.keyword_assets = {}
self._alias_map = {}
for keyword, asset in keyword_assets.items():
# 处理别名
if 'alias_of' in asset:
self._alias_map[keyword] = asset['alias_of']
continue
# 存储资产
self.keyword_assets[keyword] = asset
def reload(self) -> None:
"""热重载配置"""
logger.info("[AssetRecommend] 开始热重载配置")
self.load()
logger.info("[AssetRecommend] 配置热重载完成")
def get_by_keyword(self, keyword: str) -> Optional[Dict]:
"""
根据关键词查询资产
Args:
keyword: 关键词(如 "vpn""打印机"
Returns:
资产信息字典,未找到返回 None
"""
# 查别名
keyword = self._alias_map.get(keyword, keyword)
# 查资产
asset = self.keyword_assets.get(keyword)
if asset:
logger.debug(f"[AssetRecommend] 命中关键词: {keyword}")
return asset
def match_keywords(self, message: str) -> List[RecommendCard]:
"""
从消息中匹配关键词,返回推荐卡片列表
Args:
message: 用户消息
Returns:
推荐卡片列表
"""
cards = []
message_lower = message.lower()
for keyword, asset in self.keyword_assets.items():
if keyword in message_lower:
card = self._build_card_from_asset(keyword, asset, source='keyword_assets')
cards.append(card)
logger.debug(f"[AssetRecommend] 消息匹配关键词: {keyword}")
return cards
def get_by_role(self, role: str) -> List[RecommendCard]:
"""
根据角色获取推荐卡片
Args:
role: 角色标识(如 "new_employee""developer"
Returns:
推荐卡片列表
"""
cards = []
# 精确匹配
if role in self.role_assets:
asset = self.role_assets[role]
card = self._build_card_from_asset(role, asset, source='role_assets')
cards.append(card)
# 模糊匹配(角色名包含)
for role_key, asset in self.role_assets.items():
if role_key in role or role in role_key:
if role_key != role: # 避免重复
card = self._build_card_from_asset(role_key, asset, source='role_assets')
cards.append(card)
return cards
def _build_card_from_asset(
self,
keyword: str,
asset: Dict,
source: str,
layer: str = 'L1',
layer_label: str = '相关推荐'
) -> RecommendCard:
"""从资产数据构建推荐卡片"""
import uuid
return RecommendCard(
id=f"rec_{uuid.uuid4().hex[:8]}",
layer=layer,
layer_label=layer_label,
source=source,
title=asset.get('title', ''),
description=asset.get('description'),
icon=asset.get('icon'),
items=asset.get('items', []),
relevance='high' if layer == 'L1' else 'low',
priority=80 if layer == 'L1' else 50
)
def build_ws_message(self, recommends: List[RecommendCard]) -> Dict:
"""
构建 WebSocket 推送消息
Args:
recommends: 推荐卡片列表
Returns:
WS 消息字典
"""
return {
'type': 'asset_recommend',
'data': {
'recommends': [asdict(rec) for rec in recommends],
'layered': True
}
}
def match_profile_triggers(self, profile: Dict) -> List[RecommendCard]:
"""
画像触发规则匹配
Args:
profile: 员工画像数据
Returns:
推荐卡片列表(L2)
"""
cards = []
# 1. 火绒终端版本过旧
huorong_version = profile.get('huorong_version', '')
if huorong_version:
try:
current = float(huorong_version.replace('5.0.', ''))
if current < 73: # 假设最新版本是 5.0.73
cards.append(RecommendCard(
id=f"rec_huorong_version_{datetime.now().strftime('%Y%m%d')}",
layer='L2',
layer_label='运维提醒',
source='profile_trigger',
title='火绒终端版本过旧',
description=f'您的火绒终端版本为 {huorong_version},最新版本为 5.0.73,建议更新',
icon='🔶',
action_url='/itportal/resource/huorong-update',
relevance='low',
priority=90
))
except (ValueError, AttributeError):
pass
# 2. 火绒病毒库过期
virusdb_date = profile.get('huorong_virusdb_date')
if virusdb_date:
try:
if isinstance(virusdb_date, str):
virusdb_date = datetime.fromisoformat(virusdb_date.replace('Z', '+00:00'))
days_since = (datetime.now() - virusdb_date.replace(tzinfo=None)).days
if days_since > 7:
cards.append(RecommendCard(
id=f"rec_virusdb_{datetime.now().strftime('%Y%m%d')}",
layer='L2',
layer_label='运维提醒',
source='profile_trigger',
title='火绒病毒库过期',
description=f'病毒库已 {days_since} 天未更新,建议立即升级',
icon='🦠',
action_url='/itportal/resource/huorong-update',
relevance='low',
priority=80
))
except (ValueError, AttributeError):
pass
# 3. 终端防护离线
offline_days = profile.get('huorong_offline_days', 0)
if offline_days > 3:
cards.append(RecommendCard(
id=f"rec_offline_{datetime.now().strftime('%Y%m%d')}",
layer='L2',
layer_label='运维提醒',
source='profile_trigger',
title='终端防护离线',
description=f'火绒终端已离线 {offline_days} 天,请检查网络连接',
icon='📡',
action_url='/itportal/resource/huorong-reconnect',
relevance='low',
priority=100 # 最高优先级
))
# 4. 系统补丁缺失
missing_patches = profile.get('unionsoft_patches_missing', 0)
if missing_patches > 10:
cards.append(RecommendCard(
id=f"rec_patches_{datetime.now().strftime('%Y%m%d')}",
layer='L2',
layer_label='运维提醒',
source='profile_trigger',
title='系统补丁缺失',
description=f'您的电脑有 {missing_patches} 个安全补丁未安装,可能存在安全风险',
icon='🔧',
action_url='/itportal/resource/windows-update',
relevance='low',
priority=90
))
# 5. 联软违规项
violations = profile.get('unionsoft_violations', [])
if violations:
cards.append(RecommendCard(
id=f"rec_violations_{datetime.now().strftime('%Y%m%d')}",
layer='L2',
layer_label='运维提醒',
source='profile_trigger',
title='安全策略违规',
description=f'检测到 {len(violations)} 项违规项',
icon='⚠️',
action_url='/itportal/resource/security-compliance',
relevance='low',
priority=95,
extra={'violations': violations}
))
return cards
def process_dify_intent(
self,
dify_result: Dict,
employee_id: str,
profile: Optional[Dict] = None
) -> List[RecommendCard]:
"""
处理 Dify 意图输出,生成资产推荐
Args:
dify_result: Dify 返回的 JSON(包含 intent, need_asset, asset_keywords
employee_id: 员工 ID
profile: 员工画像(可选)
Returns:
推荐卡片列表
"""
recommends = []
# L1: Dify 意图触发的资产
if dify_result.get('need_asset') and dify_result.get('asset_keywords'):
for keyword in dify_result['asset_keywords']:
asset = self.get_by_keyword(keyword)
if asset:
card = self._build_card_from_asset(
keyword,
asset,
source='dify_intent',
layer='L1',
layer_label='相关推荐'
)
card.confidence = dify_result.get('confidence', 0.85)
recommends.append(card)
# L2: 画像触发(如果提供了 profile)
if profile:
profile_recs = self.match_profile_triggers(profile)
recommends.extend(profile_recs)
# L3: 角色通用(如果提供了 profile)
if profile:
role = profile.get('position') or profile.get('role', '')
if role:
role_recs = self.get_by_role(role)
for rec in role_recs:
rec.layer = 'L3'
rec.layer_label = '常用资源'
rec.relevance = 'low'
recommends.extend(role_recs)
return recommends
# 全局单例
_asset_recommend_service: Optional[AssetRecommendService] = None
def get_asset_recommend_service() -> AssetRecommendService:
"""获取资产推荐服务单例"""
global _asset_recommend_service
if _asset_recommend_service is None:
_asset_recommend_service = AssetRecommendService()
return _asset_recommend_service
@@ -9,13 +9,14 @@
import logging
from datetime import datetime
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from uuid import UUID
from sqlalchemy import and_, case, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.conversation import Conversation
from app.models.message import Message
logger = logging.getLogger(__name__)
@@ -212,3 +213,108 @@ class SessionQueryService:
raise ERR_CONVERSATION_NOT_FOUND
return conversation
# --------------------------------------------------------------------------
# 获取员工历史消息(跨会话聚合)
# --------------------------------------------------------------------------
async def get_employee_history_messages(
self,
employee_id: str,
limit: int = 50,
before: Optional[str] = None,
current_conversation_id: Optional[str] = None,
) -> Tuple[List[Message], bool, Dict[str, str]]:
"""获取员工的历史消息(跨会话聚合)。
将同一员工的所有会话消息合并为一条时间线,按时间排序。
用于"历史会话"功能,让坐席查看员工过去的所有咨询记录。
查询逻辑:
1. 查询 conversations 表中 employee_id = ? 的所有会话ID
2. 查询 messages 表中 conversation_id IN (会话ID列表) 的消息
3. 如有 before 参数,获取该消息的 created_at,只查更早的消息
4. 按 created_at DESC 排序,取 limit + 1 条(多取1条判断 has_more
5. 对涉及的每个会话,查询其 sender_type='employee' 的最早一条消息,
取前20字作为摘要
6. 返回消息列表 + has_more + conversation_summaries
Args:
employee_id: 员工企微 UserID
limit: 每页消息数量(默认50)
before: 游标消息ID,只查该消息之前的消息(向上翻页)
current_conversation_id: 当前会话ID(仅用于标记,不影响查询逻辑)
Returns:
tuple: (消息列表, 是否还有更多, {conversation_id: "前20字摘要"})
"""
# 1. 查询该员工的所有会话ID
conv_stmt = select(Conversation.id).where(
Conversation.employee_id == employee_id
)
conv_result = await self.db.execute(conv_stmt)
conversation_ids = [row[0] for row in conv_result.all()]
if not conversation_ids:
# 该员工没有任何会话
return [], False, {}
# 2. 构建消息查询(跨会话聚合,按时间倒序)
stmt = select(Message).where(
Message.conversation_id.in_(conversation_ids)
).order_by(Message.created_at.desc())
# 3. 如有 before 参数,获取该消息的 created_at,只查更早的消息
if before:
try:
before_stmt = select(Message.created_at).where(
Message.id == str(before)
)
before_result = await self.db.execute(before_stmt)
before_time = before_result.scalar_one_or_none()
if before_time:
stmt = stmt.where(Message.created_at < before_time)
except Exception:
pass # before 参数格式错误,忽略
# 4. 取 limit + 1 条(多取1条判断 has_more
stmt = stmt.limit(limit + 1)
result = await self.db.execute(stmt)
messages = list(result.scalars().all())
# 判断是否还有更多消息
has_more = len(messages) > limit
if has_more:
messages = messages[:limit]
# 5. 对涉及的每个会话,查询其 sender_type='employee' 的最早一条消息摘要
involved_conv_ids = list(set(m.conversation_id for m in messages))
conversation_summaries: Dict[str, str] = {}
for conv_id in involved_conv_ids:
# 查询该会话中员工发送的最早一条消息
summary_stmt = (
select(Message.content)
.where(
and_(
Message.conversation_id == conv_id,
Message.sender_type == "employee",
)
)
.order_by(Message.created_at.asc())
.limit(1)
)
summary_result = await self.db.execute(summary_stmt)
first_employee_msg = summary_result.scalar_one_or_none()
if first_employee_msg:
# 取前20字作为摘要
conversation_summaries[conv_id] = first_employee_msg[:20]
else:
conversation_summaries[conv_id] = "未知会话"
logger.debug(
f"查询员工历史消息: employee_id={employee_id}, "
f"conv_count={len(conversation_ids)}, "
f"msg_count={len(messages)}, has_more={has_more}"
)
return messages, has_more, conversation_summaries
@@ -0,0 +1,188 @@
# =============================================================================
# 设备清单导入服务
# =============================================================================
# 说明:从联软/火绒导出的 Excel 文件中导入设备清单
# =============================================================================
import uuid
from datetime import datetime
from typing import Dict, List, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.device_inventory import DeviceInventory
class DeviceImportService:
"""设备清单导入服务。
从联软/火绒导出的 Excel 文件中解析并导入设备清单。
"""
def __init__(self, db: AsyncSession):
self.db = db
async def import_huorong(self, records: List[Dict]) -> int:
"""导入火绒设备清单。
火绒字段映射:
- 终端名称 -> computer_name
- 本地IP -> ip_address
- MAC地址 -> mac_address
- 员工账户 -> employee_account
- 责任人 -> employee_name
- 部门名称 -> department
- 电脑固定资产编号 -> asset_tag
"""
count = 0
for record in records:
# 跳过无效记录
if not record.get("终端名称"):
continue
computer_name = str(record.get("终端名称", "")).strip()
ip_address = str(record.get("本地IP", "")).strip()
mac_address = str(record.get("MAC地址", "")).strip()
employee_account = str(record.get("员工账户", "")).strip()
employee_name = str(record.get("责任人", "")).strip()
asset_tag = str(record.get("电脑固定资产编号", "")).strip()
# 检查是否已存在(根据 computer_name + source
existing = await self.db.execute(
select(DeviceInventory).where(
DeviceInventory.computer_name == computer_name,
DeviceInventory.source == "huorong",
)
)
existing = existing.scalar_one_or_none()
if existing:
# 更新
existing.ip_address = ip_address
existing.mac_address = mac_address
existing.employee_account = employee_account
existing.employee_name = employee_name
existing.asset_tag = asset_tag
existing.updated_at = datetime.now()
else:
# 新增
device = DeviceInventory(
id=str(uuid.uuid4()),
computer_name=computer_name,
ip_address=ip_address,
mac_address=mac_address,
employee_account=employee_account,
employee_name=employee_name,
source="huorong",
asset_tag=asset_tag,
)
self.db.add(device)
count += 1
await self.db.commit()
return count
async def import_lianruan(self, records: List[Dict]) -> int:
"""导入联软设备清单。
联软字段映射:
- 设备名称 -> computer_name
- 设备IP -> ip_address
- MAC地址 -> mac_address
- 用户姓名 -> employee_name
- 部门名称 -> department
- 设备IP -> ip_address
"""
count = 0
for record in records:
# 跳过无效记录
if not record.get("设备名称"):
continue
computer_name = str(record.get("设备名称", "")).strip()
ip_address = str(record.get("设备IP", "")).strip()
mac_address = str(record.get("MAC地址", "")).strip()
employee_name = str(record.get("用户姓名", "")).strip()
department = str(record.get("部门名称", "")).strip()
# 检查是否已存在
existing = await self.db.execute(
select(DeviceInventory).where(
DeviceInventory.computer_name == computer_name,
DeviceInventory.source == "lianruan",
)
)
existing = existing.scalar_one_or_none()
if existing:
# 更新
existing.ip_address = ip_address
existing.mac_address = mac_address
existing.employee_name = employee_name
existing.department = department
existing.updated_at = datetime.now()
else:
# 新增
device = DeviceInventory(
id=str(uuid.uuid4()),
computer_name=computer_name,
ip_address=ip_address,
mac_address=mac_address,
employee_name=employee_name,
department=department,
source="lianruan",
)
self.db.add(device)
count += 1
await self.db.commit()
return count
async def find_by_employee(self, employee_account: str) -> Optional[DeviceInventory]:
"""根据员工账号查找设备。
优先从火绒数据查找(因为火绒有员工账户字段)。
"""
# 先查火绒
result = await self.db.execute(
select(DeviceInventory).where(
DeviceInventory.employee_account == employee_account,
DeviceInventory.source == "huorong",
)
)
device = result.scalar_one_or_none()
if device:
return device
# 再查联软(通过员工姓名匹配)
# 先获取员工姓名
from app.services.employee_directory import EmployeeDirectoryService
emp_service = EmployeeDirectoryService(self.db)
emp_info = await emp_service.get_employee_by_id(employee_account)
if emp_info:
emp_name = emp_info.get("name", "")
if emp_name:
result = await self.db.execute(
select(DeviceInventory).where(
DeviceInventory.employee_name == emp_name,
DeviceInventory.source == "lianruan",
)
)
device = result.scalar_one_or_none()
if device:
return device
return None
async def find_by_ip(self, ip_address: str) -> Optional[DeviceInventory]:
"""根据 IP 地址查找设备。"""
result = await self.db.execute(
select(DeviceInventory).where(
DeviceInventory.ip_address == ip_address,
)
)
return result.scalar_one_or_none()
+415 -16
View File
@@ -15,6 +15,7 @@
# 姓名搜索能力(full_directory=True),缺权限时仅覆盖已登录员工。
# =============================================================================
import asyncio
import json
import logging
from typing import Any, Dict, List, Optional, Tuple
@@ -28,23 +29,31 @@ from app.services.wecom_service import WecomService
logger = logging.getLogger(__name__)
# 组织目录 Redis 缓存 key 与 TTL10 分钟,避免频繁调用企微通讯录 API
# 组织目录 Redis 缓存 key 与 TTL30 分钟,减少企微通讯录 API 调用频率
ORG_DIRECTORY_CACHE_KEY = "wecom:org_directory"
ORG_DIRECTORY_CACHE_TTL = 600
ORG_DIRECTORY_CACHE_TTL = 1800
# 部门列表 Redis 缓存 key 与 TTL(含 parentid 层级信息,供 org tree 端点复用)
DEPT_LIST_CACHE_KEY = "wecom:dept_list"
DEPT_LIST_CACHE_TTL = 1800
# 组织架构树 Redis 缓存 key 前缀与 TTL(按端点区分,树构建是纯计算,数据源变了才需重建)
ORG_TREE_CACHE_KEY_PREFIX = "wecom:org_tree"
ORG_TREE_CACHE_TTL = 1800
async def get_org_directory(
db: AsyncSession,
redis: Optional[aioredis.Redis],
) -> Tuple[List[Dict[str, Any]], bool]:
"""获取「组织目录」(员工账号 + 姓名 列表),用于姓名 -> 账号匹配。
"""获取「组织目录」(员工账号 + 姓名 + 部门ID列表),用于姓名 -> 账号匹配和组织架构树构建
优先返回缓存;缓存未命中时尝试从企微通讯录拉全组织(需通讯录读取权限)。
优先返回缓存;缓存未命中时并行从企微通讯录拉全组织成员和部门列表(需通讯录读取权限)。
若企微权限不足或调用失败,降级到本地 employees 表。
Returns:
(directory, full_directory)
- directory: [{"employee_id": str, "name": str, "department": str}, ...]
- directory: [{"employee_id": str, "name": str, "department": str, "dept_ids": [int, ...]}, ...]
- full_directory: True=来自企微全组织(覆盖全公司);False=仅本地已登录员工
"""
# 1. 尝试命中缓存(缓存一定来自企微全组织,full=True)
@@ -57,25 +66,51 @@ async def get_org_directory(
except Exception as e:
logger.warning(f"读取组织目录缓存失败(降级): {e}")
# 2. 尝试从企微通讯录拉全组织
# 2. 尝试从企微通讯录拉全组织(并行调用两个 API,减少串行等待时间)
wecom = WecomService(redis_client=redis)
try:
members = await wecom.get_department_members(1, 1)
# 并行拉取部门成员和部门列表(return_exceptions=True 防止单个失败影响整体)
results = await asyncio.gather(
wecom.get_department_members(1, 1),
wecom.get_department_list(),
return_exceptions=True,
)
members_result = results[0]
dept_result = results[1]
# 获取部门列表,构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列
# 部门成员获取失败(权限不足等)→ 抛出异常触发降级到本地 employees
if isinstance(members_result, Exception):
raise members_result
members = members_result
# 部门列表获取失败时不阻塞主流程,降级使用部门ID字符串
departments: List[Dict[str, Any]] = (
dept_result if not isinstance(dept_result, Exception) else []
)
if isinstance(dept_result, Exception):
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {dept_result}")
# 构建 {部门ID: 部门名称} 映射,用于将成员的 department ID 列表
# 转换为可读的部门名称(企微 user/list 返回的 department 字段是 ID 列表如 [1,2]
dept_map: Dict[int, str] = {}
try:
departments = await wecom.get_department_list()
if departments:
dept_map = {
dept.get("id"): dept.get("name", "")
for dept in departments
if dept.get("id") is not None
}
logger.info(f"部门列表获取成功,共 {len(dept_map)} 个部门")
except Exception as e:
# 获取部门列表失败(权限不足等)时降级:使用原来的 ID 字符串,不阻塞主流程
logger.warning(f"获取部门列表失败,降级使用部门ID字符串: {e}")
# 缓存部门列表(含 parentid 层级信息),供 org tree 端点复用
if redis:
try:
await redis.setex(
DEPT_LIST_CACHE_KEY,
DEPT_LIST_CACHE_TTL,
json.dumps(departments, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入部门列表缓存失败: {e}")
directory = [
{
@@ -85,6 +120,8 @@ async def get_org_directory(
"department": ",".join(
dept_map.get(d, str(d)) for d in (m.get("department") or [])
),
# 保留原始部门 ID 列表,供 org tree 端点构建层级树使用
"dept_ids": list(m.get("department") or []),
}
for m in members
if m.get("userid")
@@ -122,12 +159,13 @@ async def get_org_directory(
# 尝试解析 JSON 数组格式(生产环境企微返回的是部门ID列表如 "[1,2]"
# 如果不是 JSON 格式(DEV_MODE 下直接存部门名),则原样使用
dept_name = ""
dept_ids: List[int] = []
if dept_raw:
try:
import json as _json
parsed = _json.loads(dept_raw)
parsed = json.loads(dept_raw)
if isinstance(parsed, list) and parsed:
# 部门ID列表:取第一个ID降级时无法解析ID为名称,留空
# 部门ID列表:降级时无法解析ID为名称,留空;但保留ID供树构建
dept_ids = [int(d) for d in parsed if d is not None]
dept_name = ""
else:
dept_name = str(parsed)
@@ -138,6 +176,7 @@ async def get_org_directory(
"employee_id": r[0],
"name": r[1] or "",
"department": dept_name,
"dept_ids": dept_ids,
})
logger.info(f"组织目录降级到本地 employees 表,共 {len(directory)}")
return directory, False
@@ -235,3 +274,363 @@ async def resolve_target(
"reason": f"未找到匹配「{target}」的员工",
"suggestion": "当前仅能按姓名搜索已登录过本系统的员工;请直接输入员工账号,或为企微应用开通「通讯录读取」权限以搜索全公司",
}
# =============================================================================
# 组织架构树构建(利用企微 department/list 的 parentid 构建真正的层级树)
# =============================================================================
# 说明:以下函数用于将扁平的员工目录 + 部门列表转换为层级组织架构树。
# - build_org_tree() 纯函数:根据 directory + dept_list 构建层级树
# - get_cached_dept_list() 从 Redis 缓存读取部门列表(含 parentid)
# - filter_user_from_tree() 从树中递归过滤掉指定用户
# - count_tree_employees() 统计树中的员工总数
# - get_org_tree_cached() 获取组织架构树(含独立缓存 + 用户过滤)
# =============================================================================
async def get_cached_dept_list(
redis: Optional[aioredis.Redis],
) -> List[Dict[str, Any]]:
"""从缓存获取部门列表(含 parentid 层级信息)。
优先从 Redis 缓存读取;缓存未命中时调用企微 API 获取并缓存。
供 org tree 端点复用,避免重复解析。
Args:
redis: Redis 客户端(可选)
Returns:
部门列表,每项含 id(部门ID)、name(部门名称)、parentid(父部门ID)
"""
if redis:
try:
raw = await redis.get(DEPT_LIST_CACHE_KEY)
if raw:
return json.loads(raw.decode("utf-8"))
except Exception as e:
logger.warning(f"读取部门列表缓存失败: {e}")
# 缓存未命中,调用企微 API 获取
wecom = WecomService(redis_client=redis)
try:
departments = await wecom.get_department_list()
if redis and departments:
try:
await redis.setex(
DEPT_LIST_CACHE_KEY,
DEPT_LIST_CACHE_TTL,
json.dumps(departments, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入部门列表缓存失败: {e}")
return departments
except Exception as e:
logger.warning(f"获取部门列表失败: {e}")
return []
def build_org_tree(
directory: List[Dict[str, Any]],
dept_list: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""根据部门列表的 parentid 层级信息和员工目录构建真正的层级组织架构树。
不再按部门名扁平分组,而是利用企微 department/list 返回的 parentid 字段
构建真正的层级树。部门ID加 ``dept_`` 前缀作为唯一 key,避免同名部门合并。
员工可以出现在其所属的所有部门下(不只取第一个)。
Args:
directory: 员工目录列表(每项含 employee_id, name, department, dept_ids
dept_list: 部门列表(每项含 id, name, parentid
Returns:
层级树节点列表,每个节点为:
- 部门节点: {id, label, dept_id, parentid, children: [...]}
- 员工节点: {id, label, isLeaf: true, department: str}
"""
# 部门列表为空时(权限不足或降级模式),退化为按部门名扁平分组
if not dept_list:
return _build_flat_tree_by_name(directory)
# 1. 构建部门映射:dept_id → 部门信息
dept_map: Dict[int, Dict[str, Any]] = {}
for dept in dept_list:
dept_id = dept.get("id")
if dept_id is not None:
dept_map[dept_id] = dept
# 2. 构建父部门→子部门ID列表映射
children_map: Dict[int, List[int]] = {}
for dept_id, dept in dept_map.items():
parent_id = dept.get("parentid", 0)
if parent_id not in children_map:
children_map[parent_id] = []
children_map[parent_id].append(dept_id)
# 3. 构建部门→员工映射(一个员工可属于多个部门)
dept_employees: Dict[int, List[Dict[str, Any]]] = {}
unassigned_employees: List[Dict[str, Any]] = []
for emp in directory:
emp_id = emp.get("employee_id", "")
emp_name = emp.get("name", "")
dept_ids = emp.get("dept_ids") or []
if not dept_ids:
# 没有部门ID的员工归到"未分配部门"
unassigned_employees.append({
"id": emp_id,
"label": emp_name,
"isLeaf": True,
"department": emp.get("department", ""),
})
continue
for did in dept_ids:
if did not in dept_employees:
dept_employees[did] = []
# 同一员工可能属于多个部门,在每个部门下都出现
dept_employees[did].append({
"id": emp_id,
"label": emp_name,
"isLeaf": True,
"department": emp.get("department", ""),
})
# 4. 递归构建部门子树
def build_dept_node(dept_id: int) -> Optional[Dict[str, Any]]:
"""递归构建单个部门的树节点(含子部门和员工)。"""
dept = dept_map.get(dept_id)
if dept is None:
return None
dept_name = dept.get("name", "") or f"部门{dept_id}"
node: Dict[str, Any] = {
"id": f"dept_{dept_id}",
"label": dept_name,
"dept_id": dept_id,
"parentid": dept.get("parentid", 0),
"children": [],
}
# 添加子部门(按名称排序)
child_ids = children_map.get(dept_id, [])
for child_id in sorted(
child_ids,
key=lambda cid: (dept_map.get(cid, {}).get("name", "") or ""),
):
child_node = build_dept_node(child_id)
if child_node:
node["children"].append(child_node)
# 添加该部门下的员工(按姓名排序)
emps = dept_employees.get(dept_id, [])
emps.sort(key=lambda e: e.get("label", ""))
node["children"].extend(emps)
return node
# 5. 确定根部门作为顶层节点
# 优先取 parentid=0 的部门;若无,则取 parentid=1 的部门
# 孤儿部门(parentid 不在 dept_map 中)也作为顶层节点
root_dept_ids: List[int] = []
root_id_set: set = set()
has_parentid_0 = any(d.get("parentid") == 0 for d in dept_map.values())
for did, dept in dept_map.items():
parent_id = dept.get("parentid", 0)
if has_parentid_0 and parent_id == 0:
root_id_set.add(did)
elif not has_parentid_0 and parent_id == 1:
root_id_set.add(did)
elif parent_id not in dept_map:
# 孤儿部门(父部门不存在于部门列表中)也作为根
root_id_set.add(did)
# 按部门名称排序
root_dept_ids = sorted(
root_id_set,
key=lambda did: (dept_map.get(did, {}).get("name", "") or ""),
)
tree: List[Dict[str, Any]] = []
for dept_id in root_dept_ids:
node = build_dept_node(dept_id)
if node:
tree.append(node)
# 6. 添加"未分配部门"(仅在没有部门信息的极少数员工时出现)
if unassigned_employees:
unassigned_employees.sort(key=lambda e: e.get("label", ""))
tree.append({
"id": "dept_unassigned",
"label": "未分配部门",
"dept_id": None,
"parentid": 0,
"children": unassigned_employees,
})
return tree
def _build_flat_tree_by_name(
directory: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
"""按部门名扁平分组构建树(降级模式:部门列表不可用时使用)。
保留原有逻辑:按 department 名称分组,多部门取第一个,空部门归"未分配部门"
部门按名称排序,员工按姓名排序。
Args:
directory: 员工目录列表
Returns:
扁平树节点列表(一级部门 + 员工叶子节点)
"""
dept_groups: Dict[str, List[Dict[str, Any]]] = {}
for emp in directory:
dept = (emp.get("department") or "").strip()
if not dept:
dept = "未分配部门"
else:
dept = dept.split(",")[0].strip()
if not dept:
dept = "未分配部门"
if dept not in dept_groups:
dept_groups[dept] = []
dept_groups[dept].append(emp)
tree: List[Dict[str, Any]] = []
for dept_name in sorted(dept_groups.keys()):
employees = dept_groups[dept_name]
if not employees:
continue
employees.sort(key=lambda e: e.get("name", ""))
tree.append({
"id": f"dept_name_{dept_name}",
"label": dept_name,
"dept_id": None,
"parentid": 0,
"children": [
{
"id": emp.get("employee_id", ""),
"label": emp.get("name", ""),
"isLeaf": True,
"department": dept_name,
}
for emp in employees
],
})
return tree
def filter_user_from_tree(
tree: List[Dict[str, Any]],
user_id: str,
) -> List[Dict[str, Any]]:
"""从组织架构树中递归过滤掉指定用户,并移除过滤后变空的部门节点。
用于 org tree 端点排除当前登录用户。由于树缓存包含所有员工,
读取后需过滤掉当前用户再返回。
Args:
tree: 完整的组织架构树(包含所有员工)
user_id: 要排除的用户 UserID
Returns:
过滤后的树(不含目标用户,也不含因此变空的部门节点)
"""
result: List[Dict[str, Any]] = []
for node in tree:
# 员工叶子节点:跳过目标用户
if node.get("isLeaf"):
if node.get("id") == user_id:
continue
result.append(node)
continue
# 部门节点:递归过滤子节点
children = node.get("children")
if children is not None:
new_children = filter_user_from_tree(children, user_id)
if not new_children:
# 过滤后部门为空,跳过该部门节点
continue
new_node = dict(node)
new_node["children"] = new_children
result.append(new_node)
else:
result.append(node)
return result
def count_tree_employees(tree: List[Dict[str, Any]]) -> int:
"""统计组织架构树中的员工总数(递归计算叶子节点数)。
Args:
tree: 组织架构树
Returns:
员工总数
"""
count = 0
for node in tree:
if node.get("isLeaf"):
count += 1
elif "children" in node:
count += count_tree_employees(node["children"])
return count
async def get_org_tree_cached(
db: AsyncSession,
redis: Optional[aioredis.Redis],
endpoint: str,
exclude_user_id: str,
) -> List[Dict[str, Any]]:
"""获取组织架构树(含独立缓存 + 排除当前用户)。
树构建是纯计算,数据源变了才需重建,因此独立缓存(TTL 30 分钟)。
缓存中包含所有员工,读取后过滤掉当前用户再返回。
缓存策略:
1. 优先读取 org tree 独立缓存
2. 缓存未命中时,从 directory 缓存 + dept_list 缓存构建树
3. 构建完成后写入 org tree 缓存
Args:
db: 数据库会话(用于 get_org_directory 降级)
redis: Redis 客户端
endpoint: 端点标识("agent""h5"),用于区分缓存 key
exclude_user_id: 要排除的用户 UserID(当前登录用户)
Returns:
组织架构树节点列表(已排除当前用户)
"""
cache_key = f"{ORG_TREE_CACHE_KEY_PREFIX}:{endpoint}"
# 1. 尝试命中树缓存
tree: Optional[List[Dict[str, Any]]] = None
if redis:
try:
raw = await redis.get(cache_key)
if raw:
tree = json.loads(raw.decode("utf-8"))
logger.debug(f"命中组织架构树缓存: {endpoint}")
except Exception as e:
logger.warning(f"读取组织架构树缓存失败: {e}")
# 2. 缓存未命中,构建树
if tree is None:
# 获取员工目录(含 Redis 缓存 + 本地降级)
directory, _ = await get_org_directory(db, redis)
# 获取部门列表(含 parentid 层级信息,从缓存或 API 获取)
dept_list = await get_cached_dept_list(redis)
# 构建层级树
tree = build_org_tree(directory, dept_list)
# 写入树缓存
if redis and tree:
try:
await redis.setex(
cache_key,
ORG_TREE_CACHE_TTL,
json.dumps(tree, ensure_ascii=False),
)
except Exception as e:
logger.warning(f"写入组织架构树缓存失败: {e}")
# 3. 过滤掉当前用户(缓存包含所有员工,需按请求者排除)
return filter_user_from_tree(tree, exclude_user_id)
@@ -0,0 +1,355 @@
# -*- coding: utf-8 -*-
"""
员工画像服务 (Employee Profile Service)
统一对接多个数据源,构建员工画像:
- 企微通讯录:员工基本信息
- 联软终端安全:补丁数、违规项
- 火绒终端安全:终端版本、病毒库日期
"""
import os
import json
import logging
from typing import Optional, Dict, List, Any
from datetime import datetime
from dataclasses import dataclass, asdict
import asyncio
import redis.asyncio as redis
logger = logging.getLogger(__name__)
@dataclass
class EmployeeProfile:
"""员工画像数据模型"""
employee_id: str
name: str
department: str
position: str
mobile: str
# 联软数据
unionsoft_patches_missing: int = 0
unionsoft_last_scan: Optional[datetime] = None
unionsoft_violations: List[str] = None
# 火绒数据
huorong_version: str = ""
huorong_virusdb_date: Optional[datetime] = None
huorong_offline_days: int = 0
# 元数据
last_updated: Optional[datetime] = None
def __post_init__(self):
if self.unionsoft_violations is None:
self.unionsoft_violations = []
class EmployeeProfileService:
"""
员工画像服务
功能:
1. 从企微/联软/火绒聚合员工画像
2. Redis 缓存,避免频繁调用第三方 API
3. 并行获取,提升响应速度
"""
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self):
if self._initialized:
return
self._initialized = True
# Redis 连接
redis_url = os.environ.get(
'REDIS_URL',
'redis://localhost:6379/0'
)
self.redis = redis.from_url(redis_url, decode_responses=True)
# 缓存 TTL(秒)
self.cache_ttl = int(os.environ.get('EMPLOYEE_PROFILE_TTL', '3600')) # 默认 1 小时
# 客户端初始化(延迟加载)
self._lianruan_client = None
self._huorong_client = None
self._wecom_service = None
logger.info("[EmployeeProfile] 服务初始化完成")
@property
def lianruan_client(self):
"""延迟加载联软客户端"""
if self._lianruan_client is None:
from app.integrations.lianruan.client import LianruanClient
from app.integrations.lianruan.config import LianruanConfig
config = LianruanConfig()
self._lianruan_client = LianruanClient(
base_url=config.base_url,
api_account=config.api_account,
api_password=config.api_password,
validate_key=config.validate_key
)
return self._lianruan_client
@property
def huorong_client(self):
"""延迟加载火绒客户端"""
if self._huorong_client is None:
from app.integrations.huorong.client import HuorongClient
from app.integrations.huorong.config import HuorongConfig
config = HuorongConfig()
self._huorong_client = HuorongClient(
access_key_id=config.access_key_id,
access_key_secret=config.access_key_secret,
base_url=config.base_url
)
return self._huorong_client
@property
def wecom_service(self):
"""延迟加载企微服务"""
if self._wecom_service is None:
from app.services.wecom_service import WecomService
self._wecom_service = WecomService()
return self._wecom_service
async def get_profile(self, employee_id: str) -> EmployeeProfile:
"""
获取员工画像(带缓存)
Args:
employee_id: 员工 ID(企微 UserID
Returns:
员工画像对象
"""
cache_key = f"employee_profile:{employee_id}"
try:
# 1. 尝试从缓存获取
cached = await self.redis.get(cache_key)
if cached:
data = json.loads(cached)
logger.debug(f"[EmployeeProfile] 缓存命中: {employee_id}")
return EmployeeProfile(**data)
# 2. 从各数据源聚合
profile = await self._aggregate_profile(employee_id)
# 3. 写入缓存
profile_dict = asdict(profile)
profile_dict['last_updated'] = datetime.now().isoformat()
await self.redis.setex(
cache_key,
self.cache_ttl,
json.dumps(profile_dict)
)
logger.debug(f"[EmployeeProfile] 画像已更新: {employee_id}")
return profile
except Exception as e:
logger.error(f"[EmployeeProfile] 获取画像失败: {employee_id}, {e}")
# 返回基础画像
return EmployeeProfile(
employee_id=employee_id,
name='',
department='',
position='',
mobile=''
)
async def _aggregate_profile(self, employee_id: str) -> EmployeeProfile:
"""
从多个数据源聚合员工画像
"""
# 并行获取各数据源
wecom_task = self._get_wecom_profile(employee_id)
unionsoft_task = self._get_unionsoft_status(employee_id)
huorong_task = self._get_huorong_status(employee_id)
try:
wecom, unionsoft, huorong = await asyncio.gather(
wecom_task,
unionsoft_task,
huorong_task,
return_exceptions=True
)
except Exception as e:
logger.error(f"[EmployeeProfile] 并行获取失败: {e}")
wecom, unionsoft, huorong = {}, {}, {}
return EmployeeProfile(
employee_id=employee_id,
name=wecom.get('name', ''),
department=wecom.get('department', ''),
position=wecom.get('position', ''),
mobile=wecom.get('mobile', ''),
unionsoft_patches_missing=unionsoft.get('patches_missing', 0),
unionsoft_last_scan=unionsoft.get('last_scan'),
unionsoft_violations=unionsoft.get('violations', []),
huorong_version=huorong.get('version', ''),
huorong_virusdb_date=huorong.get('virusdb_date'),
huorong_offline_days=huorong.get('offline_days', 0)
)
async def _get_wecom_profile(self, employee_id: str) -> Dict:
"""从企微获取员工基本信息"""
try:
# 简化实现,实际应调用企微 API
# 这里返回空字典,实际使用时接入企微通讯录 API
logger.debug(f"[EmployeeProfile] 企微画像查询: {employee_id}")
return {
'name': '',
'department': '',
'position': '',
'mobile': ''
}
except Exception as e:
logger.warning(f"[EmployeeProfile] 企微 API 调用失败: {e}")
return {}
async def _get_unionsoft_status(self, employee_id: str) -> Dict:
"""从联软获取终端安全状态"""
try:
# 简化实现,实际应调用联软 API
# 尝试查询终端信息
client = self.lianruan_client
# 根据员工姓名/账号查询终端
# 这里需要根据实际业务逻辑调整
terminals = await client.query_dev_by_params(strusername=employee_id)
if not terminals:
return {
'patches_missing': 0,
'last_scan': None,
'violations': []
}
# 取第一个终端的信息
terminal = terminals[0]
# 获取终端详情
detail = await client.get_dev_all_info(strdevname=terminal.device_name)
return {
'patches_missing': getattr(detail, 'patches_missing', 0) or 0,
'last_scan': getattr(detail, 'last_scan_time', None),
'violations': getattr(detail, 'violations', []) or []
}
except Exception as e:
logger.warning(f"[EmployeeProfile] 联软 API 调用失败: {e}")
return {
'patches_missing': 0,
'last_scan': None,
'violations': []
}
async def _get_huorong_status(self, employee_id: str) -> Dict:
"""从火绒获取终端状态"""
try:
client = self.huorong_client
# 根据员工账号查询终端
terminals = await client.list_terminals()
# 找到匹配的终端(通常按设备名或账号匹配)
matched = None
for t in terminals:
if employee_id in str(t.device_name) or employee_id in str(t.account):
matched = t
break
if not matched:
return {
'version': '',
'virusdb_date': None,
'offline_days': 0
}
# 获取终端详情
detail = await client.get_terminal_info2(matched.id)
# 计算离线天数
offline_days = 0
if hasattr(detail, 'last_online_time') and detail.last_online_time:
try:
last_online = datetime.fromisoformat(
detail.last_online_time.replace('Z', '+00:00')
)
offline_days = (datetime.now() - last_online.replace(tzinfo=None)).days
except:
pass
return {
'version': getattr(detail, 'agent_version', ''),
'virusdb_date': getattr(detail, 'virus_db_date', None),
'offline_days': offline_days
}
except Exception as e:
logger.warning(f"[EmployeeProfile] 火绒 API 调用失败: {e}")
return {
'version': '',
'virusdb_date': None,
'offline_days': 0
}
async def clear_expired_cache(self) -> int:
"""清理过期缓存"""
try:
cursor = 0
deleted = 0
while True:
cursor, keys = await self.redis.scan(
cursor=cursor,
match='employee_profile:*',
count=100
)
if keys:
deleted += await self.redis.delete(*keys)
if cursor == 0:
break
logger.info(f"[EmployeeProfile] 清理过期缓存: {deleted}")
return deleted
except Exception as e:
logger.error(f"[EmployeeProfile] 清理缓存失败: {e}")
return 0
async def invalidate_cache(self, employee_id: str) -> None:
"""使指定员工的缓存失效"""
try:
cache_key = f"employee_profile:{employee_id}"
await self.redis.delete(cache_key)
logger.debug(f"[EmployeeProfile] 缓存已失效: {employee_id}")
except Exception as e:
logger.error(f"[EmployeeProfile] 缓存失效失败: {employee_id}, {e}")
# 全局单例
_employee_profile_service: Optional[EmployeeProfileService] = None
def get_employee_profile_service() -> EmployeeProfileService:
"""获取员工画像服务单例"""
global _employee_profile_service
if _employee_profile_service is None:
_employee_profile_service = EmployeeProfileService()
return _employee_profile_service
+193
View File
@@ -0,0 +1,193 @@
# =============================================================================
# 企微IT智能服务台 — 图谱查询服务
# =============================================================================
# 说明:基于Neo4j知识图谱的智能问答服务。
# 用户问题 → 图谱查询 → 直接返回解决方案(毫秒级响应)
# 未命中 → 降级到Dify流程
#
# 核心功能:
# 1. 关键词提取(jieba分词 + 停用词过滤)
# 2. Neo4j模糊匹配Issue节点
# 3. 查找关联的Action解决方案
# 4. 返回最高匹配的SolutionResult
# =============================================================================
import logging
from typing import List, Optional
from pydantic import BaseModel
logger = logging.getLogger(__name__)
# 停用词列表(常见的无意义词汇)
STOPWORDS = {
"", "", "", "", "", "", "", "", "", "",
"", "", "一个", "", "", "", "", "", "", "",
"", "", "", "没有", "", "", "自己", "", "",
"什么", "怎么", "如何", "为什么", "请问", "帮忙", "帮助",
}
class SolutionResult(BaseModel):
"""图谱查询结果 - 解决方案"""
issue_name: str = ""
issue_uuid: str = ""
solution: str = ""
action_name: str = ""
confidence: float = 0.0
class GraphQueryService:
"""图谱查询服务 - 根据用户问题查找解决方案"""
def __init__(self, neo4j_client):
"""初始化图谱查询服务
Args:
neo4j_client: Neo4jClient实例
"""
self.neo4j_client = neo4j_client
self._keyword_cache: dict = {} # 简单缓存
async def find_solution_by_question(
self, question: str, timeout_ms: int = 100
) -> Optional[SolutionResult]:
"""根据用户问题查找解决方案(图谱查询主入口)
流程:
1. 提取问题关键词
2. 遍历关键词查询Neo4j图谱
3. 获取匹配的Issue及关联的Action
4. 返回置信度最高的解决方案
Args:
question: 用户问题文本
timeout_ms: 查询超时时间(毫秒),默认100ms
Returns:
Optional[SolutionResult]: 匹配的解决方案,未命中返回None
"""
if not question or not question.strip():
return None
# 1. 提取关键词
keywords = self._extract_keywords(question)
if not keywords:
logger.info(f"图谱查询:无法提取关键词 question={question[:50]}")
return None
logger.info(f"图谱查询:keywords={keywords}, question={question[:50]}")
# 2. 遍历关键词查询图谱(按置信度排序)
for kw in keywords:
try:
result = await self._query_by_keyword(kw)
if result:
logger.info(
f"图谱命中: keyword={kw}, issue={result.issue_name}, "
f"confidence={result.confidence}"
)
return result
except Exception as e:
logger.warning(f"图谱查询异常(降级继续): keyword={kw}, error={e}")
continue
logger.info(f"图谱未命中: question={question[:50]}")
return None
def _extract_keywords(self, text: str) -> List[str]:
"""从文本中提取关键词
使用jieba分词 + 停用词过滤
Args:
text: 输入文本
Returns:
List[str]: 关键词列表(按出现顺序)
"""
# 尝试导入jieba,如果失败则使用简单分词
try:
import jieba
words = jieba.lcut(text)
except ImportError:
# 降级:简单按空格和标点分词
words = text.replace("", " ").replace("", " ").replace("", " ").split()
# 过滤停用词和短词
keywords = [
w.strip()
for w in words
if w.strip() and len(w.strip()) >= 2 and w.strip() not in STOPWORDS
]
# 返回前5个关键词(避免查询过多)
return keywords[:5]
async def _query_by_keyword(self, keyword: str) -> Optional[SolutionResult]:
"""根据单个关键词查询解决方案
Args:
keyword: 搜索关键词
Returns:
Optional[SolutionResult]: 匹配的解决方案
"""
# 调用neo4j_client的模糊搜索方法
if not hasattr(self.neo4j_client, "find_issues_by_keyword"):
logger.warning("neo4j_client没有find_issues_by_keyword方法")
return None
issues = await self.neo4j_client.find_issues_by_keyword(keyword, limit=5)
if not issues:
return None
# 获取第一个Issue的详细信息和关联的Action
issue = issues[0]
# 查询关联的Action解决方案
actions = await self.neo4j_client.find_actions_by_issue(issue.uuid)
if not actions:
return None
action = actions[0]
# 构建返回结果
return SolutionResult(
issue_name=issue.name,
issue_uuid=issue.uuid,
solution=action.description or action.name,
action_name=action.name,
confidence=0.8, # 简化处理,默认0.8
)
# 全局单例
_graph_query_service: Optional[GraphQueryService] = None
async def get_graph_query_service(neo4j_client=None) -> Optional[GraphQueryService]:
"""获取GraphQueryService单例(异步版本)
Args:
neo4j_client: Neo4jClient实例,不传则自动获取
Returns:
Optional[GraphQueryService]: 图谱查询服务实例,Neo4j不可用时返回None
"""
global _graph_query_service
if _graph_query_service is None:
if neo4j_client is None:
from app.services.neo4j_client import get_neo4j_client
neo4j_client = await get_neo4j_client()
# 如果neo4j_client为None(图服务不可用),直接返回None
if neo4j_client is None:
return None
_graph_query_service = GraphQueryService(neo4j_client)
return _graph_query_service
@@ -0,0 +1,182 @@
# =============================================================================
# 企微IT智能服务台 — 知识库导入图谱脚本
# =============================================================================
# 说明:将KnowledgeBase表中的问题-答案映射批量导入Neo4j图谱
#
# 功能:
# 1. 读取knowledge_base表数据
# 2. 批量创建Issue和Action节点
# 3. 建立LEADS_TO关系
#
# 使用方式:
# python -m app.services.import_knowledge_to_graph
# =============================================================================
import asyncio
import logging
from datetime import datetime, timezone
from typing import List
from sqlalchemy import select
from app.database import _get_session_factory
from app.models.knowledge_base import KnowledgeBase
from app.models.neo4j_schema import ActionNode, IssueNode
from app.services.neo4j_client import Neo4jClient
logger = logging.getLogger(__name__)
class KnowledgeImporter:
"""知识库导入器 - 将SQL知识库数据导入Neo4j图谱"""
def __init__(self, neo4j_client: Neo4jClient):
self.neo4j_client = neo4j_client
async def import_all(self, batch_size: int = 50) -> dict:
"""批量导入所有知识库数据到图谱
Args:
batch_size: 每批处理数量,默认50
Returns:
dict: 导入统计 {issue_count, action_count, relation_count, errors}
"""
stats = {
"issue_count": 0,
"action_count": 0,
"relation_count": 0,
"errors": [],
}
factory = _get_session_factory()
async with factory() as db:
# 读取所有知识库数据
stmt = select(KnowledgeBase).where(
KnowledgeBase.category.isnot(None)
)
result = await db.execute(stmt)
all_knowledge = result.scalars().all()
logger.info(f"开始导入知识库,共 {len(all_knowledge)} 条记录")
# 按类别分组导入
category_groups = {}
for kb in all_knowledge:
cat = kb.category or "其他"
if cat not in category_groups:
category_groups[cat] = []
category_groups[cat].append(kb)
# 逐类导入
for category, items in category_groups.items():
logger.info(f"导入分类: {category}, 共 {len(items)}")
for kb in items:
try:
# 创建Issue节点
issue = await self._import_issue(kb)
if not issue:
continue
# 创建Action节点
action = await self._import_action(kb)
if not action:
continue
# 建立关系
await self._create_leads_to(issue.uuid, action.uuid)
stats["issue_count"] += 1
stats["action_count"] += 1
stats["relation_count"] += 1
except Exception as e:
error_msg = f"导入失败: title={kb.title}, error={e}"
logger.error(error_msg)
stats["errors"].append(error_msg)
logger.info(f"导入完成: {stats}")
return stats
async def _import_issue(self, kb: KnowledgeBase) -> IssueNode:
"""导入Issue节点
Args:
kb: KnowledgeBase记录
Returns:
IssueNode: 创建的Issue节点
"""
return await self.neo4j_client.merge_issue(
name=kb.title,
category=kb.category or "其他",
)
async def _import_action(self, kb: KnowledgeBase) -> ActionNode:
"""导入Action节点
Args:
kb: KnowledgeBase记录
Returns:
ActionNode: 创建的Action节点
"""
# 使用title作为action名称,content作为描述
action_name = f"{kb.title}_解决方案"
return await self.neo4j_client.merge_action(
name=action_name,
props={"description": kb.content or ""},
)
async def _create_leads_to(self, issue_uuid: str, action_uuid: str) -> bool:
"""建立Issue到Action的LEADS_TO关系
Args:
issue_uuid: Issue节点UUID
action_uuid: Action节点UUID
Returns:
bool: 是否创建成功
"""
from app.models.neo4j_schema import RelationEdge
rel = RelationEdge(type="LEADS_TO", order=1, weight=1.0)
return await self.neo4j_client.create_relation(
from_uuid=issue_uuid,
to_uuid=action_uuid,
rel=rel,
)
async def main():
"""主入口函数"""
logging.basicConfig(level=logging.INFO)
# 初始化Neo4j客户端
neo4j_client = Neo4jClient()
await neo4j_client.initialize()
try:
# 执行导入
importer = KnowledgeImporter(neo4j_client)
stats = await importer.import_all()
print("\n========== 导入完成 ==========")
print(f"Issue节点: {stats['issue_count']}")
print(f"Action节点: {stats['action_count']}")
print(f"关系数量: {stats['relation_count']}")
print(f"错误数量: {len(stats['errors'])}")
if stats["errors"]:
print("\n错误列表:")
for err in stats["errors"][:10]: # 只显示前10条
print(f" - {err}")
finally:
# 关闭Neo4j连接
await neo4j_client.close()
if __name__ == "__main__":
asyncio.run(main())
+129 -3
View File
@@ -53,14 +53,22 @@ class ITHealthService:
Returns:
Dict: 包含 current_device / other_devices / data_source / health_score
"""
# 尝试从联软获取真实设备信息
# 尝试从联软获取真实设备信息(总部员工优先)
device_info = await self._get_device_from_lianruan(employee_id)
# 联软不可用 → 尝试从火绒获取设备信息(分公司员工兜底)
if device_info is None:
# 联软不可用 → 返回 Mock 数据
device_info = await self._get_device_from_huorong(employee_id)
# 火绒不可用 → 尝试从设备清单数据库查询
if device_info is None:
device_info = await self._get_device_from_inventory(employee_id)
if device_info is None:
# 联软/火绒/设备清单都不可用 → 返回 Mock 数据
return self._get_mock_data(employee_id)
# 联软数据可用,尝试从火绒获取安全状态
# 已有设备信息,尝试从火绒获取安全状态
security_info = await self._get_security_from_huorong(
device_info.get("device_name", "")
)
@@ -170,6 +178,124 @@ class ITHealthService:
logger.warning(f"联软获取设备信息失败: {e}")
return None
async def _get_device_from_inventory(self, employee_id: str) -> Optional[Dict[str, Any]]:
"""从设备清单数据库查询设备信息(联软/火绒不可用时的兜底方案)。
匹配逻辑:
1. employee_account = employee_id(员工企微账号)
2. employee_name = employee_id(员工姓名)
3. computer_name 包含 employee_id
Args:
employee_id: 员工企微 UserID 或姓名
Returns:
Dict: 设备信息字典,未匹配时返回 None
"""
try:
# 直接用 SQL 查询
result = await self.db.fetch("""
SELECT computer_name, ip_address, mac_address,
employee_account, employee_name, asset_tag, department, source
FROM device_inventory
WHERE employee_account = $1
OR employee_name LIKE $2
OR computer_name ILIKE $3
LIMIT 1
""", employee_id, f"%{employee_id}%", f"%{employee_id}%")
if not result:
logger.info(f"设备清单中未找到员工 {employee_id} 的设备")
return None
row = result[0]
logger.info(f"从设备清单找到员工 {employee_id} 的设备: {row['computer_name']}")
return {
"device_name": row["computer_name"],
"ip_address": row["ip_address"],
"mac_address": row["mac_address"],
"employee_account": row["employee_account"],
"employee_name": row["employee_name"],
"asset_tag": row["asset_tag"],
"department": row["department"],
"source": row["source"],
}
except Exception as e:
logger.warning(f"从设备清单查询失败: {e}")
return None
async def _get_device_from_huorong(self, employee_id: str) -> Optional[Dict[str, Any]]:
"""从火绒查终端设备信息(联软不可用时的兜底方案)。
通过以下方式匹配员工设备:
1. 计算机名匹配:员工账号作为 strusername 查询
2. IP 匹配:查询所有终端,匹配员工当前 IP
Args:
employee_id: 员工账号
Returns:
Dict: 设备信息字典,火绒不可用时返回 None
"""
try:
from app.integrations.huorong.config import get_huorong_client
client = await get_huorong_client(self.db)
if not client:
logger.warning("火绒客户端未配置")
return None
# 方式1:尝试用员工账号匹配计算机名
# 火绒终端的 computer_name 通常是 hostname
result = await client.list_terminals(per_page=200)
terminals = result.get("items", [])
matched_terminal = None
# 优先尝试精确匹配:计算机名包含员工账号
for t in terminals:
if t.computer_name and employee_id.lower() in t.computer_name.lower():
matched_terminal = t
logger.info(f"火绒精确匹配: {t.computer_name} (employee={employee_id})")
break
# 如果没有精确匹配,返回第一个在线终端(兜底策略)
if not matched_terminal:
for t in terminals:
if t.is_online:
matched_terminal = t
logger.info(f"火绒兜底匹配: {t.computer_name} (employee={employee_id}, 在线终端)")
break
if not matched_terminal:
logger.info(f"火绒未找到任何终端")
return None
# 获取终端详细信息
detail = await client.get_terminal_detail(matched_terminal.client_id)
# 构建设备信息字典
device = {
"device_name": matched_terminal.computer_name or "",
"is_online": matched_terminal.is_online,
"ip_address": matched_terminal.local_ip or "",
"mac": getattr(matched_terminal, "mac", "") or "",
"os": detail.os_name or "",
"location": "",
"department": "",
"uptime": "",
"last_online_time": "",
}
logger.info(f"火绒获取设备成功: {matched_terminal.computer_name} (employee={employee_id})")
return device
except Exception as e:
logger.warning(f"火绒获取设备信息失败: {e}")
return None
async def _get_other_devices(
self, employee_id: str, exclude_device: str
) -> List[Dict[str, Any]]:
+1
View File
@@ -392,6 +392,7 @@ class MessageRouter:
"urgency_score": conversation.urgency_score,
"tags": conversation.tags,
"ai_replied": True,
"extra_data": extra_data if extra_data else {},
}
})
except Exception as e:
+114 -12
View File
@@ -30,6 +30,26 @@ from app.models.neo4j_schema import ActionNode, IssueNode, RelationEdge
logger = logging.getLogger(__name__)
def _to_python_datetime(value: Any) -> Optional[datetime]:
"""将 Neo4j 返回的 datetime 转换为 Python datetime。
处理 Neo4j 的 neo4j.time.DateTime 类型,转换为 Python datetime。
如果无法转换或值为 None,返回 None。
"""
if value is None:
return None
# Neo4j 返回的是 neo4j.time.DateTime 对象
if hasattr(value, 'to_native'): # neo4j.time.DateTime
try:
return value.to_native()
except Exception:
return None
# 已经是 Python datetime
if isinstance(value, datetime):
return value
return None
class Neo4jClient:
"""Neo4j 图数据库异步客户端。
@@ -167,9 +187,9 @@ class Neo4jClient:
if self._driver is None:
return False
try:
# execute_read_query 已经返回数据列表,不需要再迭代
result = await self.execute_read_query("RETURN 1 AS ok")
records = [record async for record in result]
return len(records) > 0 and records[0].get("ok") == 1
return len(result) > 0 and result[0].get("ok") == 1
except Exception as e:
logger.warning(f"Neo4j 健康检查失败: {e}")
return False
@@ -290,8 +310,8 @@ class Neo4jClient:
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
created_at=_to_python_datetime(record["created_at"]),
updated_at=_to_python_datetime(record["updated_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
@@ -344,8 +364,8 @@ class Neo4jClient:
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
created_at=_to_python_datetime(record["created_at"]),
updated_at=_to_python_datetime(record["updated_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
@@ -375,8 +395,8 @@ class Neo4jClient:
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
created_at=_to_python_datetime(record["created_at"]),
updated_at=_to_python_datetime(record["updated_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
@@ -409,8 +429,55 @@ class Neo4jClient:
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=record["created_at"],
updated_at=record["updated_at"],
created_at=_to_python_datetime(record["created_at"]),
updated_at=_to_python_datetime(record["updated_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
for record in data
]
async def find_issues_by_keyword(
self, keyword: str, limit: int = 10
) -> List[IssueNode]:
"""根据关键词模糊搜索Issue节点(图谱查询核心方法)
使用 Cypher CONTAINS 进行模糊匹配,支持中文关键词搜索。
Args:
keyword: 搜索关键词
limit: 返回结果数量限制,默认10
Returns:
List[IssueNode]: 匹配的Issue节点列表
"""
if not keyword or not keyword.strip():
return []
# 修复:只使用正确的 CONTAINS 语法
# Neo4j CONTAINS: i.name CONTAINS $keyword 表示 Issue名称 包含 关键词
keywords = keyword.strip()
# 单向匹配:Issue名称包含关键词("打印机驱动" 匹配 "打印机驱动安装")
cypher_where = "i.name CONTAINS $keyword"
data = await self.execute_read_query(
f"""
MATCH (i:Issue)
WHERE {cypher_where}
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
i.created_at AS created_at, i.updated_at AS updated_at,
i.source_suggestion_id AS source_suggestion_id
LIMIT $limit
""",
params={"keyword": keywords, "limit": limit},
)
return [
IssueNode(
uuid=record["uuid"],
name=record["name"],
category=record["category"],
created_at=_to_python_datetime(record["created_at"]),
updated_at=_to_python_datetime(record["updated_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
for record in data
@@ -450,7 +517,7 @@ class Neo4jClient:
uuid=record["uuid"],
name=record["name"],
description=record.get("description", ""),
created_at=record["created_at"],
created_at=_to_python_datetime(record["created_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
@@ -494,10 +561,45 @@ class Neo4jClient:
uuid=record["uuid"],
name=record["name"],
description=record.get("description", ""),
created_at=record["created_at"],
created_at=_to_python_datetime(record["created_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
async def find_actions_by_issue(
self, issue_uuid: str, limit: int = 5
) -> List[ActionNode]:
"""根据Issue UUID查找关联的Action解决方案(图谱查询核心方法)
查询与指定Issue节点通过LEADS_TO关系关联的Action解决方案。
Args:
issue_uuid: Issue节点的uuid
limit: 返回结果数量限制,默认5
Returns:
List[ActionNode]: 关联的Action节点列表
"""
data = await self.execute_read_query(
"""
MATCH (i:Issue {uuid: $issue_uuid})-[r:HAS_ACTION|LEADS_TO]->(a:Action)
RETURN a.uuid AS uuid, a.name AS name,
a.description AS description, a.created_at AS created_at,
a.source_suggestion_id AS source_suggestion_id
LIMIT $limit
""",
params={"issue_uuid": issue_uuid, "limit": limit},
)
return [
ActionNode(
uuid=record["uuid"],
name=record["name"],
description=record.get("description", ""),
created_at=_to_python_datetime(record["created_at"]),
source_suggestion_id=record.get("source_suggestion_id"),
)
for record in data
]
# --------------------------------------------------------------------------
# 图关系 CRUD — RelationEdge
# --------------------------------------------------------------------------
+11 -8
View File
@@ -18,6 +18,7 @@ from uuid import UUID
from sqlalchemy import and_, case, desc, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.services.avatar_service import clean_avatar_url, wrap_avatar_url
@@ -1138,9 +1139,9 @@ class SessionService:
if not (is_primary_agent or is_creator or is_participant):
raise AppException(3030, "只有会话参与者才能邀请人员加入会话")
# 3. 校验:会话必须是服务中状态
if conversation.status != "serving":
raise AppException(3031, f"只有服务中的会话才能邀请,当前状态: {conversation.status}")
# 3. 校验:会话必须是活跃状态(AI处理中或服务中
if conversation.status not in ("serving", "ai_handling"):
raise AppException(3031, f"只有活跃状态的会话才能邀请AI处理中或服务中),当前状态: {conversation.status}")
# 4. 合并参与者(去重),同时补充头像
existing_participants = list(conversation.participants or [])
@@ -1182,9 +1183,11 @@ class SessionService:
if p.get("type") == "employee":
try:
# 生成邀请链接:H5 端加入会话的 URL
# 格式:https://itsupport.servyou.com.cn/itdesk/?invite={conv_id}&eid={employee_id}
# 格式:https://itsupport.servyou.com.cn/h5/?invite={conv_id}&eid={employee_id}
# Bug 修复:使用 settings.wecom_sso_callback_base 构建绝对路径,
# 企微卡片消息要求 URL 必须以 https:// 开头
invite_url = (
f"{getattr(self, '_h5_base_url', '')}/itdesk/"
f"{settings.wecom_sso_callback_base.rstrip('/')}/h5/"
f"?invite={conversation.id}&eid={p['id']}"
)
await self.wecom_service.send_card_message(
@@ -1236,9 +1239,9 @@ class SessionService:
# 1. 校验会话
conversation = await self._get_conversation(conversation_id)
# 2. 校验:会话必须是服务中状态
if conversation.status != "serving":
raise AppException(3033, "该会话已结束,无法加入")
# 2. 校验:会话必须是活跃状态(AI处理中或服务中
if conversation.status not in ("serving", "ai_handling"):
raise AppException(3033, "该会话当前状态不允许加入")
# 3. 校验:该员工必须在 participants 列表中(被邀请过才能加入)
participants = list(conversation.participants or [])
+74
View File
@@ -244,6 +244,80 @@ class TokenService:
logger.info(f"Token 已失效: {token[:10]}...")
async def record_token_ip(self, token: str, ip_address: str) -> None:
"""记录Token使用的IP地址。
用于后续的异常检测(如同一Token多IP使用)。
Args:
token: Token 字符串
ip_address: 客户端IP地址
"""
import hashlib
import uuid
# 计算token hash,避免存储明文token
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
# Redis Key: token_ip:token_hash -> 逗号分隔的IP列表
key = f"token_ip:{token_hash}"
timestamp = datetime.now().isoformat()
# 追加新IP和访问时间
# 格式: "ip1@2026-07-14T10:00:00,ip2@2026-07-14T10:05:00"
existing = await self.redis.get(key)
if existing:
existing_str = existing.decode("utf-8") if isinstance(existing, bytes) else existing
# 只保留最近1小时的记录
entries = existing_str.split(",")
from datetime import timedelta
one_hour_ago = datetime.now() - timedelta(hours=1)
filtered = []
for entry in entries:
if "@" in entry:
ip, ts_str = entry.rsplit("@", 1)
try:
ts = datetime.fromisoformat(ts_str)
if ts > one_hour_ago:
filtered.append(entry)
except ValueError:
pass
filtered.append(f"{ip_address}@{timestamp}")
new_value = ",".join(filtered[-50:]) # 最多保留50条
else:
new_value = f"{ip_address}@{timestamp}"
# 保留1小时
await self.redis.setex(key, 3600, new_value)
logger.debug(f"记录Token IP: token_hash={token_hash}, ip={ip_address}")
async def get_token_ips(self, token: str) -> List[Dict]:
"""获取Token使用的IP列表。
Args:
token: Token 字符串
Returns:
List[Dict]: [{"ip": "x.x.x.x", "timestamp": "..."}]
"""
import hashlib
token_hash = hashlib.sha256(token.encode()).hexdigest()[:16]
key = f"token_ip:{token_hash}"
data = await self.redis.get(key)
if not data:
return []
data_str = data.decode("utf-8") if isinstance(data, bytes) else data
result = []
for entry in data_str.split(","):
if "@" in entry:
ip, ts_str = entry.rsplit("@", 1)
result.append({"ip": ip, "timestamp": ts_str})
return result
def _get_default_role(self, roles: List[str]) -> str:
"""获取默认角色。
+48
View File
@@ -1325,6 +1325,54 @@ class WecomService:
logger.error(f"上传临时素材网络错误: type={media_type}, error={e}")
raise Exception(f"上传临时素材网络错误: {e}") from e
# --------------------------------------------------------------------------
# 下载临时素材
# --------------------------------------------------------------------------
async def download_temp_media(self, media_id: str) -> bytes:
"""下载临时素材(图片/文件/语音),返回二进制数据。
对应企微API:
GET https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=TOKEN&media_id=MEDIA_ID
用于将企微回调中的图片/文件下载到本地服务器保存。
Args:
media_id: 媒体文件ID(企微回调中的 MediaId)
Returns:
bytes: 媒体文件的二进制数据
Raises:
Exception: 下载失败
"""
access_token = await self.get_access_token()
url = "https://qyapi.weixin.qq.com/cgi-bin/media/get"
params = {
"access_token": access_token,
"media_id": media_id,
}
try:
logger.info(f"开始下载临时素材: media_id={media_id}")
response = await self.client.get(url, params=params)
# 检查返回的是否是JSON错误响应
content_type = response.headers.get("content-type", "")
if "application/json" in content_type:
result = response.json()
if result.get("errcode") != 0:
errmsg = result.get("errmsg", "未知错误")
logger.error(f"下载临时素材失败: media_id={media_id}, errcode={result.get('errcode')}, errmsg={errmsg}")
raise Exception(f"下载临时素材失败: {errmsg}")
# 返回二进制数据
logger.info(f"下载临时素材成功: media_id={media_id}, size={len(response.content)} bytes")
return response.content
except httpx.HTTPError as e:
logger.error(f"下载临时素材网络错误: media_id={media_id}, error={e}")
raise Exception(f"下载临时素材网络错误: {e}") from e
# --------------------------------------------------------------------------
# OAuth2 授权换算用户身份
# --------------------------------------------------------------------------
+431 -2
View File
@@ -54,6 +54,59 @@ class WingmanService:
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
)
# --------------------------------------------------------------------------
# AI 辅助消息框 Prompt4 个功能,与 PRD v1.0 对齐)
# --------------------------------------------------------------------------
_AUTOCOMPLETE_SYSTEM_PROMPT: str = (
"你是一个IT服务坐席输入助手。根据坐席当前正在输入的内容和对话上下文,补齐下一句话。\n"
"要求:\n"
"1. 补齐内容自然衔接当前文字,不要重复已有内容\n"
"2. 长度控制在1-2个短句,不超过80字\n"
"3. 语气专业、简洁,符合IT服务规范\n"
"4. 只返回补齐的文字,不要加引号或其他标记"
)
_TONE_ADJUST_SYSTEM_PROMPT: str = (
"你是一个IT服务话术改写助手。将坐席选中的文字改写为指定风格。\n\n"
"语气定义:\n"
"- 专业(professional):使用准确的技术术语,结构化表达,去除口语化内容\n"
"- 友好(friendly):适当增加问候和关心语,语气更亲和\n"
"- 简洁(concise):去除冗余,直奔主题,控制字数\n\n"
"要求:\n"
"1. 保持原意不变,只调整语气和表达方式\n"
"2. 改写后的文字长度与原文相近(±30%\n"
"3. 符合IT服务坐席的专业规范\n"
"4. 以JSON格式输出,包含 rewritten_text、tone、changes_summary 三个字段\n"
"5. changes_summary 简述所做的修改"
)
_POLISH_SYSTEM_PROMPT: str = (
"你是一个IT服务文字精修助手。对坐席输入的文字进行指定操作的处理。\n\n"
"操作定义:\n"
"- 扩写(expand):在原文基础上增加操作步骤、注意事项、解释说明,使回复更完整\n"
"- 压缩(compress):精简表达,去除重复和冗余,保留核心信息,控制字数\n"
"- 纠错(correct):检查并修正错别字、语法错误、标点符号、格式问题\n\n"
"要求:\n"
"1. 保持原意不变\n"
"2. 以JSON格式输出,包含 polished_text、action、changes_summary 三个字段\n"
"3. changes_summary 简述所做的修改"
)
_REWRITE_SYSTEM_PROMPT: str = (
"你是一个IT服务智能回复助手。基于对话上下文和知识库,为坐席生成3个不同风格的备选回复。\n\n"
"版本要求:\n"
"- 版本1:简洁直接,一句话说明问题和解决方案\n"
"- 版本2:详细带步骤,包含操作步骤和注意事项\n"
"- 版本3:带知识库引用,引用相关文档并给出权威解决方案\n\n"
"要求:\n"
"1. 3个版本内容不重复,各有侧重\n"
"2. 每个版本独立成段,用 --- 分隔(三个减号,独占一行)\n"
"3. 只返回回复内容,不要加额外解释\n\n"
"输出格式:\n"
"版本1简洁内容\n---\n版本2详细内容\n---\n版本3知识库引用内容"
)
# --------------------------------------------------------------------------
# 知识建议生成专用 PromptTier0 / T03 — 通道 A/B 复用)
# --------------------------------------------------------------------------
@@ -82,11 +135,14 @@ class WingmanService:
"\"parent_issue\": \"网络问题\"}"
)
def __init__(self):
def __init__(self, redis_client=None):
"""初始化 Wingman 服务。
从配置读取 Wingman Agent 的 API 地址和认证信息。
独立于 AIService,使用自己的 httpx 客户端。
Args:
redis_client: Redis 异步客户端(可选,用于补齐结果缓存)
"""
# Wingman Agent 专用 API 端点
self.api_url = settings.dify_wingman_api_url
@@ -98,6 +154,9 @@ class WingmanService:
# httpx 异步客户端(复用连接池)
self._client: Optional[httpx.AsyncClient] = None
# Redis 客户端(可选,用于补齐结果缓存)
self._redis = redis_client
async def _get_client(self) -> httpx.AsyncClient:
"""获取或创建 httpx 异步客户端(懒加载)。
@@ -292,6 +351,270 @@ class WingmanService:
logger.error(f"Wingman 标签建议失败: {e}")
return default_tags
# --------------------------------------------------------------------------
# AI 辅助消息框:4 个核心方法
# --------------------------------------------------------------------------
async def generate_completion(
self,
conversation_id: str,
current_text: str,
messages: List[Dict[str, Any]],
max_length: int = 80,
) -> Dict[str, Any]:
"""自动补齐。
根据坐席当前输入和对话上下文,生成下一句的补齐建议。
使用 Redis 缓存(TTL=30s)减少重复请求。
Args:
conversation_id: 会话ID
current_text: 坐席当前输入的文本
messages: 会话消息历史列表
max_length: 补齐建议最大长度(默认 80 字符)
Returns:
Dict: {"completion": str, "confidence": float}
"""
# 1. 检查 Redis 缓存
cache_key = self._make_cache_key(current_text, conversation_id)
cached = await self._get_cache(cache_key)
if cached:
logger.info(f"自动补齐缓存命中: key={cache_key}")
return cached
# 2. 构建上下文消息
context = self._build_context_messages(
messages, self._AUTOCOMPLETE_SYSTEM_PROMPT
)
context.append({
"role": "user",
"content": (
f"坐席正在输入:{current_text}\n"
f"请补齐下一句话(不超过{max_length}字)"
),
})
# 3. 调用 Dify(降低 temperature 提高准确性)
try:
result = await self._call_wingman_api(context, temperature=0.2)
if result is None:
return {"completion": "", "confidence": 0.0}
completion = result.strip()
confidence = self._estimate_confidence(completion)
response = {"completion": completion, "confidence": confidence}
# 4. 写入 Redis 缓存(TTL=30s
if self._redis:
await self._set_cache(cache_key, response, ttl=30)
return response
except Exception as e:
logger.error(f"自动补齐失败: {e}")
return {"completion": "", "confidence": 0.0}
async def adjust_tone(
self,
conversation_id: str,
selected_text: str,
full_text: str,
tone: str,
messages: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""语气调整。
将坐席选中的文字改写为指定语气风格(专业/友好/简洁)。
Args:
conversation_id: 会话ID
selected_text: 选中的文字
full_text: 输入框完整内容(供 AI 理解上下文)
tone: 目标语气:professional/friendly/concise
messages: 会话消息历史列表
Returns:
Dict: {"rewritten_text": str, "tone": str, "changes_summary": str}
"""
tone_label = {"professional": "专业", "friendly": "友好", "concise": "简洁"}
context = self._build_context_messages(
messages, self._TONE_ADJUST_SYSTEM_PROMPT
)
context.append({
"role": "user",
"content": (
f"目标语气:{tone_label.get(tone, tone)}\n"
f"原文:{selected_text}\n"
f"完整输入框内容(供上下文参考):{full_text}\n"
f"请将原文改写为{tone_label.get(tone, tone)}风格。"
),
})
try:
result = await self._call_wingman_api(context, temperature=0.3)
if result is None:
return {
"rewritten_text": "",
"tone": tone,
"changes_summary": "AI 服务暂不可用",
}
parsed = self._parse_json_response(result, {})
return {
"rewritten_text": parsed.get("rewritten_text", result.strip()),
"tone": tone,
"changes_summary": parsed.get("changes_summary", "已完成语气调整"),
}
except Exception as e:
logger.error(f"语气调整失败: {e}")
return {
"rewritten_text": "",
"tone": tone,
"changes_summary": "AI 服务暂不可用",
}
async def polish_text(
self,
conversation_id: str,
text: str,
action: str,
messages: List[Dict[str, Any]],
) -> Dict[str, Any]:
"""文字润色。
对坐席输入的文字进行扩写/压缩/纠错处理。
Args:
conversation_id: 会话ID
text: 待润色文字
action: 润色操作:expand(扩写)/compress(压缩)/correct(纠错)
messages: 会话消息历史列表
Returns:
Dict: {"polished_text": str, "action": str, "changes_summary": str}
"""
action_label = {"expand": "扩写", "compress": "压缩", "correct": "纠错"}
context = self._build_context_messages(
messages, self._POLISH_SYSTEM_PROMPT
)
context.append({
"role": "user",
"content": (
f"操作类型:{action_label.get(action, action)}\n"
f"原文:{text}\n"
f"请对原文进行{action_label.get(action, action)}处理。"
),
})
try:
result = await self._call_wingman_api(context, temperature=0.3)
if result is None:
return {
"polished_text": "",
"action": action,
"changes_summary": "AI 服务暂不可用",
}
parsed = self._parse_json_response(result, {})
return {
"polished_text": parsed.get("polished_text", result.strip()),
"action": action,
"changes_summary": parsed.get("changes_summary", "已完成润色"),
}
except Exception as e:
logger.error(f"文字润色失败: {e}")
return {
"polished_text": "",
"action": action,
"changes_summary": "AI 服务暂不可用",
}
async def rewrite_versions(
self,
conversation_id: str,
current_text: str,
messages: List[Dict[str, Any]],
generate_count: int = 3,
include_knowledge: bool = True,
) -> Dict[str, Any]:
"""智能改写。
基于对话上下文和知识库,生成多个不同风格的备选回复。
版本1: 简洁直接 / 版本2: 详细带步骤 / 版本3: 带知识库引用。
Args:
conversation_id: 会话ID
current_text: 当前输入文本(可为空)
messages: 会话消息历史列表
generate_count: 生成版本数(默认 3
include_knowledge: 是否包含知识库引用版本(默认 True)
Returns:
Dict: {"versions": [{"text": str, "style": str, "source": str}, ...]}
"""
context = self._build_context_messages(
messages, self._REWRITE_SYSTEM_PROMPT
)
# 知识库检索(版本3
knowledge_snippets = ""
if include_knowledge:
query = current_text or " ".join(
[m.get("content", "") for m in messages[-3:]
])
kb_result = await self._search_knowledge(query)
if kb_result:
knowledge_snippets = kb_result
logger.info(f"改写知识库检索成功: {len(kb_result)} 字符")
else:
logger.info("改写知识库检索为空,降级为仅对话上下文")
user_content = (
f"当前输入:{current_text or '无(请基于对话上下文生成)'}\n"
)
if knowledge_snippets:
user_content += f"\n知识库参考资料:\n{knowledge_snippets}\n"
user_content += "\n请生成3个不同风格的备选回复。"
context.append({"role": "user", "content": user_content})
try:
result = await self._call_wingman_api(context, temperature=0.6)
if result is None:
return {"versions": []}
# 按 --- 分割版本
parts = [p.strip() for p in result.split("\n---\n") if p.strip()]
style_map = [
("简洁直接", "对话上下文"),
("详细带步骤", "对话上下文"),
(
"带知识库引用",
"RAGFlow知识库" if knowledge_snippets else "对话上下文",
),
]
versions = []
for i, text in enumerate(parts[:generate_count]):
if i < len(style_map):
style, source = style_map[i]
else:
style, source = f"版本{i+1}", "对话上下文"
versions.append({"text": text, "style": style, "source": source})
return {"versions": versions}
except Exception as e:
logger.error(f"智能改写失败: {e}")
return {"versions": []}
# --------------------------------------------------------------------------
# 核心方法 4:生成知识库优化建议(Tier0 / T03 — 复用现有范式)
# --------------------------------------------------------------------------
@@ -435,11 +758,14 @@ class WingmanService:
async def _call_wingman_api(
self,
context_messages: List[Dict[str, str]],
temperature: float = 0.3,
) -> Optional[str]:
"""调用 Wingman Agent API(非流式)。
Args:
context_messages: OpenAI 格式的消息列表
temperature: 温度参数(0-1),默认 0.3。不同功能使用不同值:
补齐 0.2 / 语气调整 0.3 / 润色 0.3 / 改写 0.6
Returns:
Optional[str]: AI 回复内容,失败时返回 None
@@ -448,7 +774,7 @@ class WingmanService:
"model": "Chat",
"messages": context_messages,
"stream": False,
"temperature": 0.3, # 适中的温度,保证准确性同时有一定灵活性
"temperature": temperature, # 参数化温度,保持向后兼容(默认 0.3
}
try:
@@ -566,3 +892,106 @@ class WingmanService:
# 限制在 0.0 - 1.0 范围内
return max(0.0, min(1.0, confidence))
# --------------------------------------------------------------------------
# AI 辅助消息框:私有辅助方法
# --------------------------------------------------------------------------
def _make_cache_key(self, text: str, conversation_id: str) -> str:
"""生成 Redis 缓存 key。
格式: wingman:autocomplete:{md5(text + conversation_id)}
对输入文本 + 会话ID 做 MD5 哈希,避免 key 过长。
Args:
text: 输入文本
conversation_id: 会话ID
Returns:
str: Redis 缓存 key
"""
import hashlib
raw = f"{text}:{conversation_id}"
digest = hashlib.md5(raw.encode()).hexdigest()
return f"wingman:autocomplete:{digest}"
async def _get_cache(self, key: str) -> Optional[Dict[str, Any]]:
"""读取 Redis 缓存。
Redis 不可用时返回 None(降级),不阻塞主流程。
Args:
key: 缓存 key
Returns:
Optional[Dict]: 缓存的补齐结果,不存在或失败时返回 None
"""
if self._redis is None:
return None
try:
data = await self._redis.get(key)
if data:
return json.loads(data)
except Exception as e:
logger.warning(f"Redis 缓存读取失败: {e}")
return None
async def _set_cache(
self, key: str, value: Dict[str, Any], ttl: int = 30
) -> None:
"""写入 Redis 缓存。
Redis 不可用时静默跳过(降级),不阻塞主流程。
Args:
key: 缓存 key
value: 缓存值(会序列化为 JSON)
ttl: 过期时间(秒),默认 30 秒
"""
if self._redis is None:
return
try:
await self._redis.setex(
key, ttl, json.dumps(value, ensure_ascii=False)
)
except Exception as e:
logger.warning(f"Redis 缓存写入失败: {e}")
async def _search_knowledge(self, query: str) -> Optional[str]:
"""调用 RAGFlow 检索知识库。
用于改写版本3,从知识库获取参考资料。
检索失败时返回 None(降级),不影响其他版本生成。
Args:
query: 检索查询(基于对话上下文)
Returns:
Optional[str]: 合并后的知识片段文本,失败或无结果时返回 None
"""
try:
from app.integrations.factory import build_ragflow_client
client = await build_ragflow_client()
if client is None:
logger.warning("RAGFlow 客户端不可用")
return None
# 使用默认知识库检索(不指定 dataset_ids 则使用全局)
result = await client.retrieval(
question=query,
similarity_threshold=0.2,
top_k=3,
)
if result and result.chunks:
snippets = "\n\n".join([
f"[{c.document_keyword or '未知文档'}] {c.content[:500]}"
for c in result.chunks[:3]
])
return snippets
except Exception as e:
logger.error(f"RAGFlow 检索失败: {e}")
return None