# 联软LV7000 API客户端 """ 联软LV7000终端安全管理系统 API 客户端。 认证流程: 1. 第一层:IP白名单(在联软后台配置,调用时自动生效) 2. 第二层:账号密码(ApiAccount + ApiPassword) 3. 第三层:Token(先调getToken获取,30分钟有效,自动缓存+刷新) 接口调用方式: - GET请求:参数通过query string传递 - POST请求:参数通过form-data传递 - 统一携带 token + apiAccount + apiPassword + validatekey 使用示例: client = LianruanClient(base_url, api_account, api_password, validate_key) terminals = await client.query_dev_by_params(strusername="songxian") detail = await client.get_dev_all_info(strdevname="IT-SONGXIAN") """ import time import logging from typing import Optional import httpx from app.integrations.lianruan.exceptions import ( LianruanApiError, LianruanAuthError, LianruanConnectionError, ) from app.integrations.lianruan.models import ( TerminalBasicInfo, TerminalAllInfo, UserInfo, OrgDeptInfo, OnlineStatus, TerminalSoftwareInfo, ) logger = logging.getLogger(__name__) class LianruanClient: """联软LV7000 API客户端。 Attributes: base_url: 联软API地址,如 http://192.168.x.x:30098 api_account: API账号 api_password: API密码 validate_key: 验证密钥 _token: 缓存的Token _token_expire: Token过期时间戳 """ def __init__( self, base_url: str, api_account: str, api_password: str, validate_key: str = "", timeout: float = 30.0, ): self.base_url = base_url.rstrip("/") self.api_account = api_account self.api_password = api_password self.validate_key = validate_key self.timeout = timeout # Token缓存(30分钟有效,提前5分钟刷新) self._token: str = "" self._token_expire: float = 0.0 # httpx异步客户端(连接池复用) self._client: Optional[httpx.AsyncClient] = None async def _get_client(self) -> httpx.AsyncClient: """获取或创建httpx异步客户端(懒初始化+连接池复用)""" if self._client is None or self._client.is_closed: self._client = httpx.AsyncClient( timeout=self.timeout, verify=False, # 内网自签证书 ) return self._client async def close(self) -> None: """关闭httpx客户端,释放连接池""" if self._client and not self._client.is_closed: await self._client.aclose() # ========================================================================== # Token管理 # ========================================================================== async def _ensure_token(self) -> str: """确保Token有效,过期则自动刷新。 联软Token默认30分钟有效,提前5分钟刷新。 Returns: str: 有效的Token字符串 """ now = time.time() # Token还有5分钟以上有效期,直接复用 if self._token and now < self._token_expire - 300: return self._token # 重新获取Token logger.info("联软Token过期或为空,正在刷新...") try: client = await self._get_client() url = f"{self.base_url}/token" params = { "act": "getToken", "apiAccount": self.api_account, "apiPassword": self.api_password, } if self.validate_key: params["validatekey"] = self.validate_key resp = await client.get(url, params=params) resp.raise_for_status() data = resp.json() if data.get("status") != "SUCCESS": raise LianruanAuthError( f"获取Token失败: {data.get('msg', '未知错误')}", detail=str(data), ) self._token = data.get("data", data.get("rows", "")) if not self._token: # 有些版本返回格式不同 self._token = str(data.get("token", "")) # 30分钟有效期 self._token_expire = now + 1800 logger.info("联软Token刷新成功,有效期至 %s", time.strftime("%H:%M:%S", time.localtime(self._token_expire))) return self._token except httpx.ConnectError as e: raise LianruanConnectionError( f"无法连接联软服务器 {self.base_url}: {e}", detail=str(e), ) except httpx.TimeoutException as e: raise LianruanConnectionError( f"连接联软服务器超时: {e}", detail=str(e), ) # ========================================================================== # 通用请求方法 # ========================================================================== async def _request( self, path: str, act: str, params: Optional[dict] = None, method: str = "GET", ) -> dict: """发送请求到联软API。 自动携带认证参数(token + apiAccount + apiPassword)。 Args: path: API路径,如 /terminal 或 /querydeptuser act: 操作类型,如 queryDevByParams params: 额外业务参数 method: 请求方法(GET/POST) Returns: dict: 联软API返回的JSON数据 Raises: LianruanAuthError: 认证失败 LianruanApiError: 业务错误 LianruanConnectionError: 网络错误 """ token = await self._ensure_token() client = await self._get_client() # 构建完整参数:认证参数 + 业务参数 full_params = { "act": act, "apiAccount": self.api_account, "apiPassword": self.api_password, "token": token, } if self.validate_key: full_params["validatekey"] = self.validate_key if params: full_params.update(params) url = f"{self.base_url}{path}" try: if method.upper() == "POST": resp = await client.post(url, data=full_params) else: resp = await client.get(url, params=full_params) resp.raise_for_status() data = resp.json() except httpx.ConnectError as e: raise LianruanConnectionError( f"无法连接联软服务器: {e}", detail=str(e), ) except httpx.TimeoutException as e: raise LianruanConnectionError( f"请求联软超时: {e}", detail=str(e), ) except httpx.HTTPStatusError as e: raise LianruanApiError( f"联软HTTP错误 {e.response.status_code}", status=str(e.response.status_code), detail=str(e), ) # 检查联软业务状态码 status = data.get("status", "") if status == "INVALID": # Token可能过期,清除缓存重试一次 self._token = "" self._token_expire = 0 raise LianruanAuthError( f"联软认证失败(IP不在白名单或Token无效): {data.get('msg', '')}", detail=str(data), ) elif status == "ERROR": raise LianruanApiError( f"联软API错误: {data.get('msg', '')}", status=status, detail=str(data), ) elif status == "Exceed": raise LianruanApiError( f"联软数据量超限: {data.get('msg', '')}", status=status, detail=str(data), ) elif status != "SUCCESS": raise LianruanApiError( f"联软未知状态: {status} - {data.get('msg', '')}", status=status, detail=str(data), ) return data # ========================================================================== # P0接口 — 终端设备查询 # ========================================================================== async def query_dev_by_params( self, strusername: str = "", strdevname: str = "", strdevip: str = "", strmac: str = "", page: int = 1, per_page: int = 20, ) -> dict: """查询终端设备(核心映射接口)。 ⭐ strusername 参数可直接按员工账号查终端,这是联软最大的优势! Args: strusername: 员工账号(映射金钥匙) strdevname: 计算机名 strdevip: IP地址 strmac: MAC地址 page: 页码(从1开始) per_page: 每页条数 Returns: dict: {"items": [TerminalBasicInfo], "total": int} """ params: dict = {} if strusername: params["strusername"] = strusername if strdevname: params["strdevname"] = strdevname if strdevip: params["strdevip"] = strdevip if strmac: params["strmac"] = strmac # 联软分页参数 params["page"] = str(page) params["rows"] = str(per_page) data = await self._request("/terminal", "queryDevByParams", params) rows = data.get("rows", []) total = data.get("total", len(rows)) items = [TerminalBasicInfo(**row) for row in rows] return {"items": items, "total": total} async def get_dev_all_info( self, strdevname: str = "", strdevip: str = "", ) -> TerminalAllInfo: """查询终端详细信息(极详细硬件+软件+资产+网络)。 比火绒_info2更丰富,包含逻辑磁盘使用率、显示器信息、内存条详情。 Args: strdevname: 计算机名(二选一) strdevip: IP地址(二选一) Returns: TerminalAllInfo: 终端详细信息 """ params: dict = {} if strdevname: params["strdevname"] = strdevname if strdevip: params["strdevip"] = strdevip data = await self._request( "/devallinfoshowwithpaging", "getDevAllInfo", params ) # 返回格式:data.equipment + data.equipmentdetail equipment = data.get("equipment", data.get("rows", [{}])) if isinstance(equipment, list) and equipment: equipment = equipment[0] equipment_detail = data.get("equipmentdetail", {}) if isinstance(equipment_detail, list) and equipment_detail: equipment_detail = equipment_detail[0] dev_detail = equipment_detail.get("devdetail", equipment_detail) # 解析硬件详情 result = TerminalAllInfo( strdevname=equipment.get("strdevname", ""), strip1=equipment.get("strip1", ""), strmac=equipment.get("strmac", ""), strdeptname=equipment.get("strdeptname", ""), strusername=equipment.get("strusername", ""), struserdes=equipment.get("struserdes", ""), stros=equipment.get("stros", ""), strdomain=equipment.get("strdomain", ""), istatus=dev_detail.get("istatus", equipment.get("istatus", "")), strverofuaagent=dev_detail.get("strverofuaagent", ""), devassetno=dev_detail.get("devassetno", ""), devgroup=dev_detail.get("devgroup", ""), ) # 解析硬件列表 self._parse_hardware_list(dev_detail, "CPUInformation", result.cpu) self._parse_hardware_list(dev_detail, "MemoryInformation", result.memory) self._parse_hardware_list(dev_detail, "HardDiskInformation", result.hard_disk) self._parse_hardware_list(dev_detail, "GraphicsCardInformation", result.graphics_card) self._parse_hardware_list(dev_detail, "MainboardInformation", result.mainboard) # 解析逻辑磁盘(含使用率) for ld in dev_detail.get("LogicalDiskInformation", []): result.logical_disk.append(LogicalDiskInfo( name=ld.get("strlogicaldiskname", ""), file_system=ld.get("strfilesystem", ""), total_size=ld.get("strtotalsize", ""), free_space=ld.get("strfreespace", ""), usage_percent=ld.get("strusagepercent", ""), )) # 解析网卡 for nc in dev_detail.get("NetworkCardInformation", []): result.network_card.append(NetworkCardInfo( name=nc.get("strnetcardname", ""), is_wireless=nc.get("iswireless", ""), vendor=nc.get("strnetcardvendor", ""), mac=nc.get("strnetcardmac", ""), )) # 解析显示器 for d in dev_detail.get("DisplayInformation", []): result.display.append(DisplayInfo( vendor=d.get("strdisplayvendor", ""), model=d.get("strdisplaymodel", ""), serial=d.get("strdisplayserial", ""), size=d.get("strdisplaysize", ""), )) return result def _parse_hardware_list( self, dev_detail: dict, key: str, target_list: list ) -> None: """解析硬件信息列表(CPU/内存/硬盘等)""" from app.integrations.lianruan.models import HardwareInfo for item in dev_detail.get(key, []): target_list.append(HardwareInfo( name=item.get("strcpuname", item.get("strmemname", item.get("strdiskname", ""))), model=item.get("strcpumodel", item.get("strmemmodel", item.get("strdiskmodel", ""))), vendor=item.get("strcpuvendor", item.get("strmemvendor", item.get("strdiskvendor", ""))), capacity=item.get("strcpufrequency", item.get("strmemcapacity", item.get("strdiskcapacity", ""))), serial=item.get("strcpuserial", item.get("strmemserial", item.get("strdiskserial", ""))), )) # ========================================================================== # P0接口 — 组织架构/用户 # ========================================================================== async def get_user_info_by_account(self, useraccount: str) -> Optional[UserInfo]: """按账号查询用户信息。 Args: useraccount: 用户账号 Returns: UserInfo或None """ data = await self._request( "/querydeptuser", "getUserInfoByAccount", {"useraccount": useraccount}, ) rows = data.get("rows", data.get("row", [])) if rows: row = rows[0] if isinstance(rows, list) else rows return UserInfo(**row) return None async def get_all_org_info(self) -> list[OrgDeptInfo]: """获取全量组织架构(部门+用户)。 用于定时同步,构建组织架构映射。 Returns: list[OrgDeptInfo]: 部门列表,每个部门含用户列表 """ data = await self._request("/querydeptuser", "getAllOrgInfo") rows = data.get("rows", []) result = [] for dept_data in rows: users = [] for u in dept_data.get("users", []): users.append(UserInfo(**u)) result.append(OrgDeptInfo( deptid=dept_data.get("deptid", ""), deptname=dept_data.get("deptname", ""), parentid=dept_data.get("parentid", ""), users=users, )) return result # ========================================================================== # P1接口 — 准入控制 # ========================================================================== async def exist_online_user( self, username: str, strdevip: str = "" ) -> OnlineStatus: """查询终端用户是否在线。 可精确判断某员工在某IP是否当前在线。 Args: username: 用户名 strdevip: IP地址(可选) Returns: OnlineStatus: 在线状态 """ params = {"username": username} if strdevip: params["strdevip"] = strdevip data = await self._request( "/access/onlineUser", "existOnlineUser", params ) is_online = data.get("data", "0") == "1" return OnlineStatus( username=username, ip=strdevip, is_online=is_online, ) # ========================================================================== # P1接口 — 终端操作 # ========================================================================== async def notice_agent_msg( self, strdevip: str, message: str ) -> bool: """向终端推送弹窗消息。 Args: strdevip: 终端IP message: 消息内容 Returns: bool: 是否成功 """ data = await self._request( "/terminal", "noticeAgentMsg", {"strdevip": strdevip, "msg": message}, ) return data.get("status") == "SUCCESS" async def remote_wake_up( self, strdevip: str, strmac: str ) -> bool: """远程唤醒终端。 Args: strdevip: 终端IP strmac: 终端MAC地址 Returns: bool: 是否成功 """ data = await self._request( "/terminal", "remoteWakeUp", {"strdevip": strdevip, "strmac": strmac}, ) return data.get("status") == "SUCCESS" async def query_software_by_dev( self, strdevname: str = "", strdevip: str = "" ) -> Optional[TerminalSoftwareInfo]: """查询终端安装软件。 Args: strdevname: 计算机名 strdevip: IP地址 Returns: TerminalSoftwareInfo或None """ params: dict = {} if strdevname: params["strdevname"] = strdevname if strdevip: params["strdevip"] = strdevip data = await self._request("/software", "querysoftwarebydev", params) rows = data.get("rows", []) if not rows: return None row = rows[0] if isinstance(rows, list) else rows softwares = [] for s in row.get("softwares", []): softwares.append(SoftwareInfo( name=s.get("strsoftware", ""), version=s.get("strversion", ""), vendor=s.get("strvendor", ""), install_date=s.get("installdate", ""), )) return TerminalSoftwareInfo( strdevname=row.get("strdevname", ""), strdevip=row.get("strdevip", ""), strmac=row.get("strmac", ""), strusername=row.get("strusername", ""), softwares=softwares, ) # ========================================================================== # 测试连接 # ========================================================================== async def test_connection(self) -> dict: """测试联软API连接。 使用getToken接口验证: 1. 网络连通性 2. IP白名单 3. 账号密码正确性 4. Token获取成功 Returns: dict: {"success": bool, "message": str} """ try: token = await self._ensure_token() if token: return { "success": True, "message": "联软API连接成功,Token获取正常", } else: return { "success": False, "message": "Token获取失败,返回为空", } except LianruanAuthError as e: return {"success": False, "message": e.message} except LianruanConnectionError as e: return {"success": False, "message": e.message} except Exception as e: return {"success": False, "message": f"未知错误: {str(e)}"}