154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微IT智能服务台 — 阶段5 自动化 员工→终端映射
|
|||
|
|
# =============================================================================
|
|||
|
|
# 说明:把员工(企微 UserID)解析为终端信息,供 virus_dispose 等场景使用。
|
|||
|
|
# 主源:联软(支持 strusername 直接映射)
|
|||
|
|
# 兜底:北森 EHR(仅给资产/部门 hint,无法提供火绒 client_id)
|
|||
|
|
# 结果缓存在 auto_mapping_cache(TTL),降低外部系统压力。
|
|||
|
|
# =============================================================================
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import logging
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
from typing import Any, Dict, Optional
|
|||
|
|
|
|||
|
|
from sqlalchemy import select
|
|||
|
|
|
|||
|
|
from app.constants import MAPPING_SOURCE_PRIORITY
|
|||
|
|
from app.integrations.factory import build_ehr_client, build_lianruan_client
|
|||
|
|
from app.models.automation import MappingCache
|
|||
|
|
|
|||
|
|
logger = logging.getLogger(__name__)
|
|||
|
|
|
|||
|
|
# 映射缓存 TTL(秒):联软数据 10 分钟内复用以降低外部压力
|
|||
|
|
MAPPING_CACHE_TTL_SECONDS = 600
|
|||
|
|
|
|||
|
|
|
|||
|
|
class MappingResolver:
|
|||
|
|
"""员工→终端映射解析器。"""
|
|||
|
|
|
|||
|
|
def __init__(self, db: Any = None, audit: Any = None):
|
|||
|
|
self.db = db
|
|||
|
|
self.audit = audit
|
|||
|
|
|
|||
|
|
async def resolve(self, employee_id: str, scenario_key: str = "") -> Dict[str, Any]:
|
|||
|
|
"""解析员工→终端映射。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Dict: {
|
|||
|
|
"employee_id", "source"(lianruan/ehr/None),
|
|||
|
|
"terminals": [...], "client_ids": [...](仅联软可给)
|
|||
|
|
}
|
|||
|
|
"""
|
|||
|
|
# 1. 查缓存(联软结果优先复用)
|
|||
|
|
cached = await self._load_cache(employee_id)
|
|||
|
|
if cached is not None:
|
|||
|
|
logger.info(f"命中映射缓存 employee={employee_id}, source={cached.get('source')}")
|
|||
|
|
return cached
|
|||
|
|
|
|||
|
|
terminals: list = []
|
|||
|
|
source: Optional[str] = None
|
|||
|
|
client_ids: list = []
|
|||
|
|
|
|||
|
|
# 2. 主源:联软(按员工账号直接映射)
|
|||
|
|
lianruan = None
|
|||
|
|
try:
|
|||
|
|
lianruan = await build_lianruan_client(self.db, audit=self.audit)
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
logger.warning(f"构建联软客户端失败: {e}")
|
|||
|
|
|
|||
|
|
if lianruan is not None:
|
|||
|
|
try:
|
|||
|
|
data = await lianruan.query_dev_by_params(strusername=employee_id)
|
|||
|
|
items = data.get("items", []) if isinstance(data, dict) else []
|
|||
|
|
if items:
|
|||
|
|
terminals = [
|
|||
|
|
{
|
|||
|
|
"strdevname": getattr(t, "strdevname", ""),
|
|||
|
|
"strdevip": getattr(t, "strdevip", ""),
|
|||
|
|
"strusername": getattr(t, "strusername", ""),
|
|||
|
|
"strdeptname": getattr(t, "strdeptname", ""),
|
|||
|
|
}
|
|||
|
|
for t in items
|
|||
|
|
]
|
|||
|
|
# 火绒隔离以「终端标识」为目标;联软返回的是 hostname/ip,
|
|||
|
|
# 真实环境需按 hostname 做跨系统资产对齐(见交付说明假设)。
|
|||
|
|
client_ids = [
|
|||
|
|
(t.get("strdevname") or t.get("strdevip"))
|
|||
|
|
for t in terminals
|
|||
|
|
if (t.get("strdevname") or t.get("strdevip"))
|
|||
|
|
]
|
|||
|
|
source = "lianruan"
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
logger.warning(f"联软映射失败 employee={employee_id}: {e}")
|
|||
|
|
|
|||
|
|
# 3. 兜底:北森 EHR(仅 hint,无火绒 client_id)
|
|||
|
|
if not source:
|
|||
|
|
ehr = None
|
|||
|
|
try:
|
|||
|
|
ehr = await build_ehr_client(audit=self.audit)
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
logger.warning(f"构建 EHR 客户端失败: {e}")
|
|||
|
|
if ehr is not None:
|
|||
|
|
try:
|
|||
|
|
hint = await ehr.get_terminal_by_employee(employee_id)
|
|||
|
|
if hint:
|
|||
|
|
terminals = [hint]
|
|||
|
|
source = "ehr"
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
logger.warning(f"EHR 映射兜底失败 employee={employee_id}: {e}")
|
|||
|
|
|
|||
|
|
result: Dict[str, Any] = {
|
|||
|
|
"employee_id": employee_id,
|
|||
|
|
"source": source,
|
|||
|
|
"terminals": terminals,
|
|||
|
|
"client_ids": client_ids,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 4. 写缓存(仅联软结果值得缓存,EHR hint 不长期缓存)
|
|||
|
|
if source == "lianruan":
|
|||
|
|
await self._save_cache(employee_id, result)
|
|||
|
|
|
|||
|
|
return result
|
|||
|
|
|
|||
|
|
async def _load_cache(self, employee_id: str) -> Optional[Dict[str, Any]]:
|
|||
|
|
"""读取未过期的映射缓存。"""
|
|||
|
|
if self.db is None:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
stmt = select(MappingCache).where(MappingCache.employee_id == employee_id)
|
|||
|
|
row = (await self.db.execute(stmt)).scalar_one_or_none()
|
|||
|
|
if row is None:
|
|||
|
|
return None
|
|||
|
|
if row.expires_at is not None and row.expires_at < datetime.now(timezone.utc):
|
|||
|
|
return None
|
|||
|
|
return row.mapped_data
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
logger.debug(f"读映射缓存失败: {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
async def _save_cache(self, employee_id: str, data: Dict[str, Any]) -> None:
|
|||
|
|
"""写入映射缓存。"""
|
|||
|
|
if self.db is None:
|
|||
|
|
return
|
|||
|
|
try:
|
|||
|
|
stmt = select(MappingCache).where(MappingCache.employee_id == employee_id)
|
|||
|
|
row = (await self.db.execute(stmt)).scalar_one_or_none()
|
|||
|
|
now = datetime.now(timezone.utc)
|
|||
|
|
if row is None:
|
|||
|
|
row = MappingCache(
|
|||
|
|
employee_id=employee_id,
|
|||
|
|
source=data.get("source", "lianruan"),
|
|||
|
|
mapped_data=data,
|
|||
|
|
expires_at=now + timedelta(seconds=MAPPING_CACHE_TTL_SECONDS),
|
|||
|
|
)
|
|||
|
|
self.db.add(row)
|
|||
|
|
else:
|
|||
|
|
row.mapped_data = data
|
|||
|
|
row.source = data.get("source", row.source)
|
|||
|
|
row.expires_at = now + timedelta(seconds=MAPPING_CACHE_TTL_SECONDS)
|
|||
|
|
await self.db.flush()
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
logger.warning(f"写映射缓存失败: {e}")
|