chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统集成模块
# =============================================================================
# 提供统一的适配层,让联软/火绒/aTrust/eHR用同一套接口规范接入。
# 上层业务只依赖 ExternalSystemService 统一门面,不直接调用任何Adapter。
#
# 使用方式:
# from app.services.external import ExternalSystemService, get_external_service
# svc = get_external_service()
# terminal = await svc.find_user_terminal("songxian")
# =============================================================================
from app.services.external.base import (
ExternalSystemAdapter,
TerminalInfo,
SecurityStatus,
VpnSession,
)
from app.services.external.config import ExternalSystemConfig
from app.services.external.cache import ExternalSystemCache
from app.services.external.mock import MockAdapter
from app.services.external.service import ExternalSystemService
__all__ = [
"ExternalSystemAdapter",
"TerminalInfo",
"SecurityStatus",
"VpnSession",
"ExternalSystemConfig",
"ExternalSystemCache",
"MockAdapter",
"ExternalSystemService",
]
+312
View File
@@ -0,0 +1,312 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统适配器抽象基类 + 统一数据模型
# =============================================================================
# 说明:
# 1. 定义所有外部系统共用的抽象接口(ABC)
# 2. 定义统一的DTO模型(TerminalInfo/SecurityStatus/VpnSession
# 3. 每个外部系统实现此接口,上层业务只依赖抽象接口
#
# 设计原则:
# - 默认返回None/空 — 子类按需覆写自己支持的方法
# - 不支持的能力不报错,返回None让调用方走降级逻辑
# - raw_data字段保留原始响应,调试用,生产环境可关闭
# =============================================================================
import logging
from abc import ABC, abstractmethod
from datetime import datetime
from typing import Dict, List, Optional
from pydantic import BaseModel, Field
logger = logging.getLogger(__name__)
# =============================================================================
# 统一数据模型(DTO
# =============================================================================
class TerminalInfo(BaseModel):
"""统一终端信息模型 — 所有Adapter返回同一结构
做什么:把联软/火绒/aTrust不同格式的终端数据映射到统一结构
为什么:上层业务代码不需要关心数据来自哪个系统
"""
# ── 来源标识 ──
source_system: str = Field(..., description="数据来源系统标识: lianruan/huorong/atrust/ehr")
# ── 基础标识 ──
terminal_id: Optional[str] = Field(None, description="终端在来源系统中的唯一ID")
computer_name: str = Field(..., description="计算机名")
# ── 网络信息 ──
ip_addresses: List[str] = Field(default_factory=list, description="IP地址列表(含VPN虚拟IP")
mac_addresses: List[str] = Field(default_factory=list, description="MAC地址列表")
# ── 系统信息 ──
os_version: Optional[str] = Field(None, description="操作系统版本")
is_online: bool = Field(False, description="是否在线")
# ── 用户映射(核心字段)──
logged_in_user: Optional[str] = Field(None, description="当前登录用户账号 — 映射核心字段")
logged_in_user_name: Optional[str] = Field(None, description="用户姓名")
department: Optional[str] = Field(None, description="所属部门")
# ── 硬件摘要 ──
hardware_summary: Optional[Dict] = Field(None, description="硬件摘要(CPU/内存/磁盘使用率等)")
# ── 时间信息 ──
last_seen: Optional[datetime] = Field(None, description="最后在线时间")
# ── 调试用 ──
raw_data: Optional[Dict] = Field(None, description="原始响应数据(调试用,生产可关闭)")
class VulnerabilityItem(BaseModel):
"""漏洞条目"""
name: str = Field(..., description="漏洞名称")
level: str = Field("info", description="严重程度: critical/high/medium/low/info")
description: Optional[str] = Field(None, description="漏洞描述")
publish_time: Optional[str] = Field(None, description="发布时间")
class SecurityStatus(BaseModel):
"""统一安全状态模型
做什么:聚合火绒的病毒/漏洞/隔离数据
为什么:坐席需要一目了然看到终端安全全貌
"""
source_system: str = Field(..., description="数据来源系统标识")
terminal_id: str = Field(..., description="终端ID")
computer_name: Optional[str] = Field(None, description="计算机名")
# ── 安全指标 ──
virus_total: int = Field(0, description="病毒事件总数")
virus_uncleaned: int = Field(0, description="未处理病毒数")
vulnerabilities: List[VulnerabilityItem] = Field(default_factory=list, description="高危漏洞列表")
high_vuln_count: int = Field(0, description="高危漏洞数量")
# ── 隔离状态 ──
is_isolated: bool = Field(False, description="是否被隔离")
isolation_source: Optional[str] = Field(None, description="隔离来源系统")
# ── 检查时间 ──
checked_at: datetime = Field(default_factory=datetime.now, description="检查时间")
class VpnSession(BaseModel):
"""VPN会话模型(仅aTrust
做什么:描述一个aTrust VPN在线会话
为什么:坐席需要知道远程员工是否通过VPN在线、VPN IP是什么
"""
source_system: str = "atrust"
session_id: Optional[str] = Field(None, description="会话ID(用于踢出操作)")
username: str = Field(..., description="用户名(登录名)")
display_name: Optional[str] = Field(None, description="显示名")
remote_ip: str = Field(..., description="接入IP(公网IP或'内网IP'")
vpn_ip: Optional[str] = Field(None, description="VPN虚拟内网IP — 火绒交叉匹配关键字段")
is_trusted: bool = Field(False, description="终端是否已授信")
os: Optional[str] = Field(None, description="接入终端操作系统")
last_login: Optional[datetime] = Field(None, description="最后登录时间")
domain: Optional[str] = Field(None, description="登录域")
# =============================================================================
# 适配器抽象基类
# =============================================================================
class ExternalSystemAdapter(ABC):
"""外部系统适配器抽象基类
做什么:定义所有外部系统共用的接口规范
为什么:让上层业务代码只依赖抽象接口,不感知底层系统差异
设计原则:
- 默认方法返回None/空列表/False,子类按需覆写自己支持的能力
- 不支持的能力不报错,让调用方走降级逻辑
- 每个Adapter只负责一个外部系统的对接
"""
@property
@abstractmethod
def system_name(self) -> str:
"""系统标识名称
返回值: 'lianruan' / 'huorong' / 'atrust' / 'ehr' / 'mock'
"""
...
@property
@abstractmethod
def is_available(self) -> bool:
"""当前系统是否可用(凭证已配置+网络可达)
做什么:检查配置是否完整,不实际发起网络请求
为什么:调用方可据此决定是否跳过本系统
"""
...
@abstractmethod
async def health_check(self) -> bool:
"""健康检查 — 验证凭证和网络连通性
做什么:实际发起一次轻量级API调用,确认系统可达
为什么:定期健康检查可提前发现连接问题
"""
...
# =========================================================================
# 终端查询能力
# =========================================================================
async def get_terminal_by_user(self, username: str) -> Optional[TerminalInfo]:
"""通过员工账号查询终端信息(映射核心方法)
做什么:输入员工账号,返回该员工使用的终端信息
为什么:这是员工→终端映射的核心入口
各系统实现方式:
- 联软:queryDevByParams(strusername=xxx) — 精确匹配
- 火绒:_list(ip=xxx) — 需配合联软IP交叉匹配
- aTrustqueryAll(bindUserList) — 终端绑定用户
- eHR:不提供终端数据,返回None
Args:
username: 员工账号(如 'songxian'
Returns:
TerminalInfo 或 None(系统不支持或未找到)
"""
return None
async def get_terminal_by_computer(self, computer_name: str) -> Optional[TerminalInfo]:
"""通过计算机名查询终端信息
Args:
computer_name: 计算机名(如 'IT-SONGXIAN'
"""
return None
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
"""查询终端详细信息(硬件/软件/网络配置)
做什么:返回比 get_terminal_by_user 更详细的信息
为什么:排查时需要硬件配置、磁盘使用率、已安装软件等
各系统实现方式:
- 联软:getDevAllInfo — 极详细(主板/CPU/内存/硬盘/网卡/显示器)
- 火绒:_info2 — 中等详细(硬件/软件/网络配置)
- aTrust/eHR:不支持
Args:
terminal_id: 终端在来源系统中的唯一ID
"""
return None
# =========================================================================
# 安全能力
# =========================================================================
async def get_security_status(self, terminal_id: str) -> Optional[SecurityStatus]:
"""获取终端安全状态(病毒/漏洞/隔离状态)
做什么:聚合安全指标,坐席一目了然
为什么:安全问题通常需要紧急处理
仅火绒支持此接口。
Args:
terminal_id: 终端ID(火绒的client_id
"""
return None
async def isolate_terminal(self, terminal_id: str, reason: str) -> bool:
"""隔离终端(断网)
做什么:调用火绒 _create(type=netctrl) 隔离终端
为什么:安全事件紧急处理,阻断威胁扩散
仅火绒支持。调用前必须二次确认+审计日志记录。
Args:
terminal_id: 终端ID
reason: 隔离原因(记入审计日志)
Returns:
True=成功, False=失败
Raises:
NotImplementedError: 本系统不支持隔离操作
"""
raise NotImplementedError(f"{self.system_name} 不支持终端隔离")
async def unisolate_terminal(self, terminal_id: str) -> bool:
"""解除终端隔离(恢复网络)
仅火绒支持。
Args:
terminal_id: 终端ID
Returns:
True=成功, False=失败
"""
raise NotImplementedError(f"{self.system_name} 不支持解除隔离")
# =========================================================================
# VPN/在线状态能力
# =========================================================================
async def get_vpn_sessions(self, username: Optional[str] = None) -> List[VpnSession]:
"""查询VPN在线会话
做什么:获取当前通过aTrust在线的VPN会话
为什么:坐席需要知道远程员工VPN状态和IP
仅aTrust支持。
Args:
username: 可选,过滤指定用户
Returns:
VPN会话列表
"""
return []
async def get_online_status(self, username: str) -> bool:
"""查询用户是否在线
做什么:检查用户终端是否当前在线
为什么:坐席需要知道用户是否可达
各系统实现方式:
- 联软:existOnlineUser
- 火绒:_list(is_online=True) + IP交叉匹配
- aTrustgetUserStatus
Args:
username: 员工账号
Returns:
True=在线, False=离线或未知
"""
return False
# =========================================================================
# 辅助方法
# =========================================================================
def _log_not_implemented(self, method_name: str) -> None:
"""记录未实现方法的调试日志
做什么:当子类未覆写某个方法时记录DEBUG级日志
为什么:开发期帮助发现调用链路问题,生产环境可关闭DEBUG
"""
logger.debug(
f"[{self.system_name}] {method_name} 未实现,"
f"将走降级逻辑"
)
+176
View File
@@ -0,0 +1,176 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统数据缓存层
# =============================================================================
# 说明:
# 1. 封装外部系统数据的缓存读写逻辑
# 2. 统一缓存key格式:ext:{system}:{method}:{param_hash}
# 3. 不同数据类型使用不同TTL(终端映射30分钟、安全状态5分钟等)
# 4. Redis不可用时自动降级(不缓存,直接透传)
#
# 与 CacheService 的关系:
# CacheService 是全局Redis客户端封装,ExternalSystemCache 基于它
# 添加外部系统专用的缓存策略(TTL、key格式、刷新机制)
# =============================================================================
import hashlib
import json
import logging
from datetime import datetime
from typing import Any, Dict, Optional
from app.services.cache_service import CacheService
logger = logging.getLogger(__name__)
# =============================================================================
# 缓存TTL配置(秒)
# =============================================================================
CACHE_TTL = {
# 终端映射(员工→终端)— 映射关系不常变,缓存较长
"terminal_mapping": 30 * 60, # 30分钟
# 终端详情(硬件/软件)— 硬件配置极少变,缓存最长
"terminal_detail": 60 * 60, # 60分钟
# 安全状态(漏洞/病毒)— 安全状态需近实时,缓存短
"security_status": 5 * 60, # 5分钟
# VPN在线状态 — 在线状态变化快,缓存最短
"vpn_status": 1 * 60, # 1分钟
# eHR员工信息 — 静态数据,缓存最长
"employee_info": 24 * 60 * 60, # 24小时
}
class ExternalSystemCache:
"""外部系统数据缓存
做什么:为外部系统查询结果提供统一缓存读写
为什么:减少外部API调用频率,降低延迟和出错率
降级策略:Redis不可用时,缓存读写均跳过,直接透传到外部系统
"""
def __init__(self, cache_service: Optional[CacheService] = None):
"""初始化缓存层
Args:
cache_service: Redis缓存服务实例。None时降级为无缓存模式
"""
self._cache = cache_service
@staticmethod
def _make_key(system: str, method: str, param: str) -> str:
"""生成缓存key
做什么:按统一格式生成缓存key
为什么:避免不同系统/方法的key冲突
Args:
system: 系统标识(lianruan/huorong/atrust/ehr
method: 方法名(terminal_mapping/terminal_detail/...
param: 查询参数(用户名/计算机名等)
Returns:
缓存key,格式: ext:lianruan:terminal_mapping:abc123
"""
# 对参数做哈希,避免特殊字符问题
param_hash = hashlib.md5(param.encode()).hexdigest()[:12]
return f"ext:{system}:{method}:{param_hash}"
async def get(self, system: str, method: str, param: str) -> Optional[Dict]:
"""从缓存读取数据
做什么:按系统+方法+参数查找缓存
为什么:命中缓存可避免一次外部API调用
Args:
system: 系统标识
method: 方法名
param: 查询参数
Returns:
缓存的字典数据,或 None(未命中/Redis不可用)
"""
if not self._cache or not self._cache.redis:
return None
key = self._make_key(system, method, param)
try:
data = await self._cache.get(key)
if data:
logger.debug(f"缓存命中: {key}")
return json.loads(data) if isinstance(data, str) else data
return None
except Exception as e:
# Redis错误不阻断业务,降级为无缓存
logger.warning(f"缓存读取失败(降级为无缓存): {key}, error={e}")
return None
async def set(
self,
system: str,
method: str,
param: str,
data: Dict,
ttl_override: Optional[int] = None,
) -> bool:
"""写入缓存
做什么:将外部系统查询结果存入缓存
为什么:后续相同查询可直接命中缓存
Args:
system: 系统标识
method: 方法名
param: 查询参数
data: 要缓存的数据
ttl_override: 自定义TTL(秒),None则使用默认TTL
Returns:
True=成功, False=失败/Redis不可用
"""
if not self._cache or not self._cache.redis:
return False
key = self._make_key(system, method, param)
ttl = ttl_override or CACHE_TTL.get(method, 5 * 60) # 默认5分钟
try:
# 添加缓存时间戳,便于判断数据新鲜度
data_with_meta = {
**data,
"_cached_at": datetime.now().isoformat(),
"_source_system": system,
}
await self._cache.set(key, json.dumps(data_with_meta, default=str), ex=ttl)
logger.debug(f"缓存写入: {key}, TTL={ttl}s")
return True
except Exception as e:
logger.warning(f"缓存写入失败: {key}, error={e}")
return False
async def invalidate(self, system: str, method: str, param: str) -> bool:
"""主动失效缓存
做什么:删除指定缓存条目
为什么:外部数据变更时(如终端隔离后),需主动失效缓存
Args:
system: 系统标识
method: 方法名
param: 查询参数
Returns:
True=成功, False=失败/Redis不可用
"""
if not self._cache or not self._cache.redis:
return False
key = self._make_key(system, method, param)
try:
await self._cache.delete(key)
logger.debug(f"缓存失效: {key}")
return True
except Exception as e:
logger.warning(f"缓存失效失败: {key}, error={e}")
return False
+166
View File
@@ -0,0 +1,166 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统连接配置管理
# =============================================================================
# 说明:
# 1. 统一管理联软/火绒/aTrust/eHR四个系统的连接配置
# 2. 支持从环境变量或 .env 文件读取
# 3. 支持运行时切换 Mock 模式(所有请求走 MockAdapter
#
# 配置优先级:环境变量 > .env 文件 > 默认值
# =============================================================================
import os
from typing import Optional
from pydantic import BaseModel, Field
class ExternalSystemConfig(BaseModel):
"""外部系统连接配置
做什么:集中管理所有外部系统的连接参数
为什么:避免在代码中硬编码,支持环境隔离(开发/测试/生产)
"""
# ── 联软LV7000 ──
lianruan_base_url: str = Field(
default="http://192.168.3.200:30098",
description="联软API基础地址(端口30098",
)
lianruan_api_account: Optional[str] = Field(
default=None,
description="联软API账号(ApiAccount参数)",
)
lianruan_api_password: Optional[str] = Field(
default=None,
description="联软API密码(ApiPassword参数)",
)
lianruan_enabled: bool = Field(
default=False,
description="联软适配器是否启用(凭证配置后自动启用)",
)
# ── 火绒企业版 ──
huorong_base_url: str = Field(
default="http://huorong.oa.servyou-it.com:8080",
description="火绒API基础地址(内网地址)",
)
huorong_access_key_id: Optional[str] = Field(
default=None,
description="火绒AccessKey ID",
)
huorong_access_key_secret: Optional[str] = Field(
default=None,
description="火绒AccessKey SecretHMAC-SHA1签名用)",
)
huorong_enabled: bool = Field(
default=False,
description="火绒适配器是否启用",
)
# ── aTrust零信任 ──
atrust_base_url: str = Field(
default="https://atrust.servyou-it.com:4433",
description="aTrust API基础地址(HTTPS端口4433",
)
atrust_api_id: Optional[str] = Field(
default=None,
description="aTrust API IDx-ca-key Header",
)
atrust_api_secret: Optional[str] = Field(
default=None,
description="aTrust API SecretHMAC-SHA256签名密钥)",
)
atrust_directory_domain: Optional[str] = Field(
default=None,
description="aTrust用户目录域名(V3 API需要此参数)",
)
atrust_enabled: bool = Field(
default=False,
description="aTrust适配器是否启用",
)
# ── 北森eHR ──
ehr_base_url: Optional[str] = Field(
default=None,
description="eHR API基础地址",
)
ehr_client_id: Optional[str] = Field(
default=None,
description="eHR OAuth2.0 Client ID",
)
ehr_client_secret: Optional[str] = Field(
default=None,
description="eHR OAuth2.0 Client Secret",
)
ehr_enabled: bool = Field(
default=False,
description="eHR适配器是否启用",
)
# ── 全局配置 ──
cache_enabled: bool = Field(
default=True,
description="是否启用外部数据缓存(Redis",
)
mock_mode: bool = Field(
default=False,
description="Mock模式 — True时所有请求走MockAdapter,不调真实API",
)
class Config:
env_prefix = "EXT_" # 环境变量前缀,如 EXT_LIANRUAN_BASE_URL
def load_external_config() -> ExternalSystemConfig:
"""从环境变量加载外部系统配置
做什么:读取 EXT_ 前缀的环境变量,构建配置对象
为什么:生产环境通过环境变量注入敏感配置,不写入代码或文件
Returns:
ExternalSystemConfig 实例
"""
config_dict = {}
# 映射关系:环境变量名 → 配置字段名
env_mapping = {
"EXT_LIANRUAN_BASE_URL": "lianruan_base_url",
"EXT_LIANRUAN_API_ACCOUNT": "lianruan_api_account",
"EXT_LIANRUAN_API_PASSWORD": "lianruan_api_password",
"EXT_HUORONG_BASE_URL": "huorong_base_url",
"EXT_HUORONG_ACCESS_KEY_ID": "huorong_access_key_id",
"EXT_HUORONG_ACCESS_KEY_SECRET": "huorong_access_key_secret",
"EXT_ATRUST_BASE_URL": "atrust_base_url",
"EXT_ATRUST_API_ID": "atrust_api_id",
"EXT_ATRUST_API_SECRET": "atrust_api_secret",
"EXT_ATRUST_DIRECTORY_DOMAIN": "atrust_directory_domain",
"EXT_EHR_BASE_URL": "ehr_base_url",
"EXT_EHR_CLIENT_ID": "ehr_client_id",
"EXT_EHR_CLIENT_SECRET": "ehr_client_secret",
"EXT_CACHE_ENABLED": "cache_enabled",
"EXT_MOCK_MODE": "mock_mode",
}
for env_key, field_name in env_mapping.items():
value = os.environ.get(env_key)
if value is not None:
# 布尔类型特殊处理
if field_name in ("cache_enabled", "mock_mode"):
config_dict[field_name] = value.lower() in ("true", "1", "yes")
else:
config_dict[field_name] = value
config = ExternalSystemConfig(**config_dict)
# 自动启用已有凭证的系统
if config.lianruan_api_account and config.lianruan_api_password:
config.lianruan_enabled = True
if config.huorong_access_key_id and config.huorong_access_key_secret:
config.huorong_enabled = True
if config.atrust_api_id and config.atrust_api_secret:
config.atrust_enabled = True
if config.ehr_client_id and config.ehr_client_secret:
config.ehr_enabled = True
return config
+223
View File
@@ -0,0 +1,223 @@
# =============================================================================
# 企微IT智能服务台 — Mock适配器(开发期使用)
# =============================================================================
# 说明:
# 1. 在 Mock 模式下(EXT_MOCK_MODE=True),所有外部系统查询
# 返回预置的 Mock 数据,不调用任何真实 API
# 2. Mock 数据覆盖 P0 场景(终端查询、安全状态、VPN在线)
# 3. 凭证未配置时自动降级到 MockAdapter,保证开发期无外部依赖
#
# 使用方式:
# EXT_MOCK_MODE=True → 所有系统走 Mock
# 某系统凭证未配置 → 单个系统自动降级到 Mock(在 service.py 中处理)
# =============================================================================
import logging
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from app.services.external.base import (
ExternalSystemAdapter,
TerminalInfo,
SecurityStatus,
VpnSession,
)
logger = logging.getLogger(__name__)
# =============================================================================
# Mock 数据工厂
# =============================================================================
def _make_mock_terminal(username: str) -> TerminalInfo:
"""生成 Mock 终端信息
做什么:为指定用户生成一个逼真的模拟终端数据
为什么:开发期没有真实凭证时需要终端数据支撑会话排查流程
"""
return TerminalInfo(
source_system="mock",
terminal_id=f"mock-terminal-{username}",
computer_name=f"{username.upper()}-PC01",
ip_addresses=[f"192.168.{hash(username) % 255}.{100 + hash(username) % 155}"],
mac_addresses=[f"00:16:3E:{hash(username) % 256:02X}:{hash(username + 'a') % 256:02X}:{hash(username + 'b') % 256:02X}"],
os_version="Windows 11 专业版 23H2",
is_online=True,
logged_in_user=username,
logged_in_user_name=_username_to_display_name(username),
department=_guess_department(username),
hardware_summary={
"cpu": "Intel Core i7-12700",
"memory_total_gb": 16,
"memory_used_gb": 8,
"disk_total_gb": 512,
"disk_free_gb": 128,
"disk_usage_pct": 75, # 模拟磁盘使用率较高
},
last_seen=datetime.now() - timedelta(minutes=5),
raw_data=None, # Mock 数据不保留原始响应
)
def _make_mock_security_status(terminal_id: str) -> SecurityStatus:
"""生成 Mock 安全状态
做什么:生成一个模拟的安全状态数据
为什么:开发期需要验证安全状态卡片、漏洞警告等UI渲染
"""
return SecurityStatus(
source_system="mock",
terminal_id=terminal_id,
computer_name=f"MOCK-PC01",
virus_total=2,
virus_uncleaned=1,
vulnerabilities=[
{
"name": "Microsoft Windows 安全更新 (CVE-2025-12345)",
"level": "high",
"description": "远程代码执行漏洞,需立即修补",
"publish_time": (datetime.now() - timedelta(days=7)).isoformat(),
},
{
"name": "火绒安全漏洞扫描:弱密码检测",
"level": "medium",
"description": "账户密码强度不足,建议修改",
"publish_time": (datetime.now() - timedelta(days=3)).isoformat(),
},
],
high_vuln_count=1,
is_isolated=False,
isolation_source=None,
checked_at=datetime.now(),
)
def _make_mock_vpn_session(username: str) -> VpnSession:
"""生成 Mock VPN 会话"""
return VpnSession(
source_system="mock",
session_id=f"mock-session-{username}",
username=username,
display_name=_username_to_display_name(username),
remote_ip=f"1{hash(username) % 100}.{hash(username + 'r') % 256}.{hash(username + 's') % 256}.{hash(username + 't') % 256}",
vpn_ip=f"10.200.{hash(username) % 255}.{100 + hash(username) % 155}",
is_trusted=True,
os="Windows 11",
last_login=datetime.now() - timedelta(minutes=30),
domain="servyou.local",
)
def _username_to_display_name(username: str) -> str:
"""Mock 用户名转换(简单映射)"""
name_map = {
"songxian": "宋献",
"zhangsan": "张三",
"lisi": "李四",
"wangwu": "王五",
}
return name_map.get(username, username)
def _guess_department(username: str) -> str:
"""Mock 部门推断"""
dept_map = {
"songxian": "IT支持组",
"zhangsan": "财务部",
"lisi": "人力资源部",
"wangwu": "研发部",
}
return dept_map.get(username, "未知部门")
# =============================================================================
# MockAdapter 实现
# =============================================================================
class MockAdapter(ExternalSystemAdapter):
"""Mock 适配器 — 开发期替代所有外部系统
做什么:提供逼真的模拟数据,让开发期可以不依赖任何外部系统
为什么:阶段一MVP验证、前端开发、单元测试都需要稳定的数据来源
降级规则:
- 所有方法均返回 Mock 数据
- 支持常用测试用户:songxian / zhangsan / lisi / wangwu
- is_available 固定返回 TrueMock 永远可用)
"""
@property
def system_name(self) -> str:
return "mock"
@property
def is_available(self) -> bool:
"""Mock 永远可用"""
return True
async def health_check(self) -> bool:
"""Mock 健康检查永远通过"""
logger.debug("[MockAdapter] 健康检查 → OKMock模式)")
return True
# ── 终端查询能力 ──
async def get_terminal_by_user(self, username: str) -> Optional[TerminalInfo]:
"""Mock:通过账号查询终端
做什么:返回预置的 Mock 终端信息
为什么:开发期坐席打开会话时需要看到终端画像
"""
logger.info(f"[MockAdapter] get_terminal_by_user({username}) → Mock数据")
return _make_mock_terminal(username)
async def get_terminal_by_computer(self, computer_name: str) -> Optional[TerminalInfo]:
"""Mock:通过计算机名查询终端"""
logger.info(f"[MockAdapter] get_terminal_by_computer({computer_name}) → Mock数据")
# 从计算机名反推用户名(简单逻辑)
username = computer_name.split("-")[0].lower() if "-" in computer_name else "songxian"
return _make_mock_terminal(username)
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
"""Mock:查询终端详细信息"""
logger.info(f"[MockAdapter] get_terminal_detail({terminal_id}) → Mock数据")
return _make_mock_terminal("songxian")
# ── 安全能力 ──
async def get_security_status(self, terminal_id: str) -> Optional[SecurityStatus]:
"""Mock:获取安全状态"""
logger.info(f"[MockAdapter] get_security_status({terminal_id}) → Mock数据")
return _make_mock_security_status(terminal_id)
async def isolate_terminal(self, terminal_id: str, reason: str) -> bool:
"""Mock:隔离终端(Mock 模式仅记录日志)"""
logger.warning(
f"[MockAdapter] 隔离终端(Mock,不真实执行): "
f"terminal={terminal_id}, reason={reason}"
)
return True # Mock 永远返回成功
async def unisolate_terminal(self, terminal_id: str) -> bool:
"""Mock:解除隔离"""
logger.warning(
f"[MockAdapter] 解除隔离(Mock,不真实执行): terminal={terminal_id}"
)
return True
# ── VPN/在线状态 ──
async def get_vpn_sessions(self, username: Optional[str] = None) -> List[VpnSession]:
"""Mock:查询VPN在线会话"""
if username:
return [_make_mock_vpn_session(username)]
# 返回多个 Mock 会话
return [
_make_mock_vpn_session("songxian"),
_make_mock_vpn_session("zhangsan"),
]
async def get_online_status(self, username: str) -> bool:
"""Mock:查询在线状态(Mock 永远返回 True)"""
return True
+491
View File
@@ -0,0 +1,491 @@
# =============================================================================
# 企微IT智能服务台 — 外部系统统一门面服务
# =============================================================================
# 说明:
# 1. 上层业务代码(AI Wingman、会话管理等)只依赖此类
# 2. 按优先级链式查询(联软 → aTrust → eHR
# 3. 自动处理降级(系统不可用时跳到下一个)
# 4. 所有方法均有详细行内注释(做什么 + 为什么)
#
# 使用方式:
# from app.services.external import get_external_service
# svc = get_external_service()
# terminal = await svc.find_user_terminal("songxian")
# =============================================================================
import logging
from typing import Any, Dict, List, Optional
from app.services.cache_service import CacheService
from app.services.external.base import (
ExternalSystemAdapter,
TerminalInfo,
SecurityStatus,
VpnSession,
)
from app.services.external.config import ExternalSystemConfig
from app.services.external.cache import ExternalSystemCache
from app.services.external.mock import MockAdapter
logger = logging.getLogger(__name__)
# =============================================================================
# 全局单例(懒加载)
# =============================================================================
_external_service_instance: Optional["ExternalSystemService"] = None
def get_external_service() -> "ExternalSystemService":
"""获取外部系统服务单例
做什么:返回全局唯一的 ExternalSystemService 实例
为什么:避免重复初始化 Adapter,节省连接资源
"""
global _external_service_instance
if _external_service_instance is None:
raise RuntimeError(
"ExternalSystemService 尚未初始化,"
"请在应用启动时调用 init_external_service()"
)
return _external_service_instance
def init_external_service(
config: Optional[ExternalSystemConfig] = None,
cache_service: Optional[Any] = None,
) -> ExternalSystemService:
"""初始化外部系统服务(应用启动时调用一次)
做什么:根据配置创建所有 Adapter,组装成 ExternalSystemService
为什么:集中初始化,避免分散在各处创建 Adapter 实例
Args:
config: 外部系统配置,None 时自动从环境变量加载
cache_service: Redis 缓存服务实例,None 时降级为无缓存
Returns:
初始化完成的 ExternalSystemService 实例
"""
global _external_service_instance
if config is None:
from app.services.external.config import load_external_config
config = load_external_config()
# 创建缓存层
cache = ExternalSystemCache(cache_service) if cache_service else None
# 按优先级组装 Adapter 字典
adapters: Dict[str, ExternalSystemAdapter] = {}
if config.mock_mode:
# Mock 模式:所有系统走 MockAdapter
logger.info("[External] Mock模式已启用,所有外部系统查询走Mock数据")
mock = MockAdapter()
adapters = {
"lianruan": mock,
"huorong": mock,
"atrust": mock,
"ehr": mock,
}
else:
# ── 联软(主映射源P0)─────────────────────────────
# 做什么:创建联软 Adapter(凭证已配置时启用)
# 为什么:联软的 strusername 字段是员工→终端映射最可靠来源
if config.lianruan_enabled:
from app.services.external.lianruan_adapter import LianruanAdapter
adapters["lianruan"] = LianruanAdapter(config)
logger.info("[External] 联软适配器已启用")
else:
logger.warning(
"[External] 联软适配器未启用(凭证未配置),"
"终端映射功能将降级"
)
# ── 火绒(安全源P0)─────────────────────────────────
# 做什么:创建火绒 Adapter(凭证已配置时启用)
# 为什么:火绒提供终端安全状态(病毒/漏洞/隔离),不参与映射
if config.huorong_enabled:
from app.services.external.huorong_adapter import HuorongAdapter
adapters["huorong"] = HuorongAdapter(config)
logger.info("[External] 火绒适配器已启用")
else:
logger.info("[External] 火绒适配器未启用(凭证未配置)")
# ── aTrust(VPN源P1)────────────────────────────────
# 做什么:创建 aTrust Adapter(凭证已配置时启用)
# 为什么:aTrust 提供 VPN 在线状态和虚拟IP,用于远程员工排查
if config.atrust_enabled:
from app.services.external.atrust_adapter import ATrustAdapter
adapters["atrust"] = ATrustAdapter(config)
logger.info("[External] aTrust适配器已启用")
else:
logger.info("[External] aTrust适配器未启用(凭证未配置)")
# ── eHR(辅助静态数据P2)───────────────────────────
# 做什么:创建 eHR Adapter(凭证已配置时启用)
# 为什么:eHR 提供员工基础信息和任职信息,作为静态数据补充
if config.ehr_enabled:
from app.services.external.ehr_adapter import EHRAdapter
adapters["ehr"] = EHRAdapter(config)
logger.info("[External] eHR适配器已启用")
else:
logger.info("[External] eHR适配器未启用(凭证未配置)")
_external_service_instance = ExternalSystemService(adapters, cache)
logger.info(
f"[External] 服务初始化完成,已加载适配器: "
f"{list(adapters.keys())}"
)
return _external_service_instance
# =============================================================================
# 统一门面服务
# =============================================================================
class ExternalSystemService:
"""外部系统统一门面 — 上层业务唯一依赖的入口
做什么:按优先级链式查询外部系统,对上层屏蔽底层差异
为什么:上层代码不需要知道终端数据来自联软还是火绒
查询优先级(映射场景):
1. 联软(主源,strusername 精确匹配)
2. aTrustVPN源,bindUserList 匹配)
3. eHR(静态辅助,无终端数据,返回None)
安全能力(仅火绒):
- 获取安全状态(病毒/漏洞)
- 隔离/解除终端(需admin角色+二次确认)
VPN能力(仅aTrust):
- 查询在线会话
- 踢出用户
"""
def __init__(
self,
adapters: Dict[str, ExternalSystemAdapter],
cache: Optional[ExternalSystemCache] = None,
):
"""初始化统一门面
Args:
adapters: 系统标识 → Adapter 实例 的字典
cache: 外部数据缓存层(可为None,降级为无缓存)
"""
self._adapters = adapters
self._cache = cache
# =========================================================================
# 终端查询(映射核心)
# =========================================================================
async def find_user_terminal(self, username: str) -> Optional[TerminalInfo]:
"""查找用户终端 — 按优先级链式查询
做什么:根据员工账号查找其使用的终端信息
为什么:这是员工→终端映射的核心入口,坐席排查时首先需要知道
员工用哪台电脑
查询顺序:
1. 联软 queryDevByParams(strusername=xxx) — 最精确
2. aTrust queryAll(bindUserList) — VPN 场景补充
3. eHR — 无终端数据,返回 None
降级:某系统不可用时自动跳过,不影响整体结果。
Args:
username: 员工账号(如 'songxian'
Returns:
TerminalInfo 或 None(所有系统均未找到)
"""
logger.info(f"[External] 查找用户终端: username={username}")
# ── 第1优先级:联软(主映射源)────────────────────
# 做什么:优先用联软查,它有 strusername 精确字段
# 为什么:联软直接建立员工账号→终端的映射,比IP交叉匹配可靠
lianruan = self._adapters.get("lianruan")
if lianruan and lianruan.is_available:
try:
result = await self._query_with_cache(
"lianruan", "get_terminal_by_user", username
)
if result:
logger.info(
f"[External] 联软命中: username={username}, "
f"computer={result.computer_name}"
)
return result
except Exception as e:
# 联软不可用 → 降级到 aTrust,不阻断
logger.warning(
f"[External] 联软查询失败(降级到aTrust): {e}"
)
else:
logger.debug("[External] 联软不可用或未启用,跳过")
# ── 第2优先级:aTrust(VPN源)─────────────────────
# 做什么:联软未命中时,用 aTrust 查 VPN 终端
# 为什么:远程办公员工可能不在联软覆盖范围内
atrust = self._adapters.get("atrust")
if atrust and atrust.is_available:
try:
result = await self._query_with_cache(
"atrust", "get_terminal_by_user", username
)
if result:
logger.info(
f"[External] aTrust命中: username={username}, "
f"vpn_ip={result.ip_addresses}"
)
return result
except Exception as e:
logger.warning(
f"[External] aTrust查询失败(降级到eHR: {e}"
)
else:
logger.debug("[External] aTrust不可用或未启用,跳过")
# ── 第3优先级:eHR(辅助静态数据)────────────────
# 做什么:eHR 不提供终端数据,此方法返回 None
# 为什么:保留接口一致性,未来可能扩展
ehr = self._adapters.get("ehr")
if ehr and ehr.is_available:
result = await self._query_with_cache(
"ehr", "get_terminal_by_user", username
)
if result:
return result
# 所有系统均未命中
logger.info(f"[External] 所有系统均未找到用户终端: username={username}")
return None
async def get_terminal_detail(self, terminal_id: str) -> Optional[TerminalInfo]:
"""查询终端详细信息(硬件/软件/网络配置)
做什么:获取比 find_user_terminal 更详细的终端信息
为什么:排查硬件故障(卡慢)时需要CPU/内存/磁盘使用率等数据
优先联软(getDevAllInfo 比火绒 _info2 更详细)。
Args:
terminal_id: 终端在来源系统中的唯一ID
Returns:
TerminalInfo(含 hardware_summary)或 None
"""
# 联软详细信息最全(含主板/CPU/内存/硬盘/显示器)
lianruan = self._adapters.get("lianruan")
if lianruan and lianruan.is_available:
try:
return await lianruan.get_terminal_detail(terminal_id)
except Exception as e:
logger.warning(f"[External] 联软详细信息查询失败: {e}")
# 火绒作为备选(_info2 含硬件/软件/网络配置)
huorong = self._adapters.get("huorong")
if huorong and huorong.is_available:
try:
return await huorong.get_terminal_detail(terminal_id)
except Exception as e:
logger.warning(f"[External] 火绒详细信息查询失败: {e}")
return None
# =========================================================================
# 安全能力(仅火绒)
# =========================================================================
async def get_terminal_security(self, terminal_id: str) -> Optional[SecurityStatus]:
"""获取终端安全状态
做什么:查询终端的病毒事件、高危漏洞、隔离状态
为什么:坐席排查安全问题时需要一目了然
仅火绒支持此能力。
Args:
terminal_id: 火绒的 client_id
Returns:
SecurityStatus 或 None(火绒不可用)
"""
huorong = self._adapters.get("huorong")
if not huorong or not huorong.is_available:
logger.warning("[External] 火绒不可用,无法获取安全状态")
return None
try:
return await self._query_with_cache(
"huorong", "get_security_status", terminal_id
)
except Exception as e:
logger.error(f"[External] 获取安全状态失败: {e}")
return None
async def isolate_terminal(
self, terminal_id: str, reason: str, operator: str
) -> bool:
"""隔离终端(断网)
做什么:调用火绒 _create(type=netctrl) 隔离终端
为什么:安全事件紧急处理,阻断威胁扩散
仅火绒支持。调用前必须在上层做:
1. 操作者角色校验(仅 admin 可操作)
2. 二次确认弹窗
3. 审计日志记录
Args:
terminal_id: 火绒的 client_id
reason: 隔离原因(记入审计日志)
operator: 操作者账号
Returns:
True=成功, False=失败
"""
huorong = self._adapters.get("huorong")
if not huorong or not huorong.is_available:
logger.error("[External] 火绒不可用,无法执行隔离")
return False
logger.warning(
f"[External] 执行终端隔离: terminal={terminal_id}, "
f"operator={operator}, reason={reason}"
)
try:
return await huorong.isolate_terminal(terminal_id, reason)
except Exception as e:
logger.error(f"[External] 隔离失败: {e}")
return False
async def unisolate_terminal(self, terminal_id: str) -> bool:
"""解除终端隔离(恢复网络)"""
huorong = self._adapters.get("huorong")
if not huorong or not huorong.is_available:
return False
try:
return await huorong.unisolate_terminal(terminal_id)
except Exception as e:
logger.error(f"[External] 解除隔离失败: {e}")
return False
# =========================================================================
# VPN/在线状态(仅aTrust
# =========================================================================
async def get_vpn_sessions(
self, username: Optional[str] = None
) -> List[VpnSession]:
"""查询VPN在线会话
做什么:获取当前通过aTrust在线的VPN会话列表
为什么:坐席需要知道远程员工是否在线、VPN IP是什么
仅aTrust支持。
Args:
username: 可选,过滤指定用户的会话
Returns:
VPN会话列表(可能为空)
"""
atrust = self._adapters.get("atrust")
if not atrust or not atrust.is_available:
return []
try:
return await atrust.get_vpn_sessions(username)
except Exception as e:
logger.warning(f"[External] 查询VPN会话失败: {e}")
return []
async def get_online_status(self, username: str) -> bool:
"""查询用户是否在线
做什么:检查用户当前是否在线(任何方式接入)
为什么:坐席发起协作或推送消息前需要知道用户是否可达
Args:
username: 员工账号
Returns:
True=在线, False=离线或未知
"""
# 优先联软(内网接入)
lianruan = self._adapters.get("lianruan")
if lianruan and lianruan.is_available:
try:
if await lianruan.get_online_status(username):
return True
except Exception:
pass
# 再查 aTrustVPN接入)
atrust = self._adapters.get("atrust")
if atrust and atrust.is_available:
try:
if await atrust.get_online_status(username):
return True
except Exception:
pass
return False
# =========================================================================
# 内部方法
# =========================================================================
async def _query_with_cache(
self, system: str, method: str, param: str
) -> Any:
"""带缓存的查询(内部方法)
做什么:先查缓存,未命中则调Adapter,结果写回缓存
为什么:减少外部API调用频率,降低延迟
Args:
system: 系统标识
method: 方法名(用于缓存key
param: 查询参数(用于缓存key)
Returns:
Adapter返回的数据(可能经缓存)
"""
# 步骤1:尝试从缓存读取
if self._cache:
cached = await self._cache.get(system, method, param)
if cached:
return cached
# 步骤2:缓存未命中,调用Adapter
adapter = self._adapters.get(system)
if not adapter:
raise RuntimeError(f"Adapter不存在: {system}")
method_map = {
"get_terminal_by_user": adapter.get_terminal_by_user,
"get_terminal_by_computer": adapter.get_terminal_by_computer,
"get_terminal_detail": adapter.get_terminal_detail,
"get_security_status": adapter.get_security_status,
"get_vpn_sessions": adapter.get_vpn_sessions,
"get_online_status": adapter.get_online_status,
}
if method not in method_map:
raise RuntimeError(f"未知方法: {method}")
result = await method_map[method](param)
# 步骤3:写入缓存(仅非None结果)
if result and self._cache:
# 转成可序列化的字典
data = result.dict() if hasattr(result, "dict") else result
await self._cache.set(system, method, param, data)
return result