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