Files
wecom_it_smart_desk/backend/app/services/employee_profile_service.py
T

356 lines
12 KiB
Python
Raw Normal View History

# -*- 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