Files
wecom_it_smart_desk/backend/app/integrations/lianruan/client.py
T

605 lines
20 KiB
Python
Raw Normal View History

# 联软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)}"}