356 lines
12 KiB
Python
356 lines
12 KiB
Python
# -*- 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
|