WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 核心模块包
|
||||
# =============================================================================
|
||||
# 说明:存放自动化引擎的「基础设施层」代码(外部客户端、审计、重试等)。
|
||||
# 与 app/integrations/* 的区别:
|
||||
# - app/integrations/* 由管理后台在 system_configs 配置,供管理端功能使用;
|
||||
# - app/core/clients/* 由环境变量 AUTOMATION_* 配置,供自动化闭环引擎使用,
|
||||
# 未配置时返回 None,引擎自动降级(关键词兜底 / EHR 兜底 / 转人工)。
|
||||
# =============================================================================
|
||||
@@ -0,0 +1,289 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 外部客户端基类
|
||||
# =============================================================================
|
||||
# 说明:定义自动化引擎所有外部系统客户端的统一基类 BaseClient 与异常体系。
|
||||
#
|
||||
# 设计要点:
|
||||
# 1. 异步:基于 httpx.AsyncClient,所有方法均为 coroutine。
|
||||
# 2. 超时:统一超时(默认 30s),避免单点阻塞。
|
||||
# 3. 重试:优先使用 tenacity 做指数退避重试(仅对网络层超时/连接错误重试);
|
||||
# 若运行环境未安装 tenacity,则降级为单次直连(结构不变,便于单元测试 mock)。
|
||||
# 4. 审计:可选 audit 回调,记录出入参、状态、耗时,供 ActionLog 落表。
|
||||
# 5. 异常:统一抛出 BaseClientError 子类,executor 捕获后转 EXTERNAL_CALL_FAILED。
|
||||
#
|
||||
# 统一 _request 骨架:
|
||||
# async _request(method, path, *, params=None, json=None, headers=None) -> dict
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
# tenacity 为可选依赖:未安装时降级为单次直连,不影响结构与可测性。
|
||||
try: # pragma: no cover - 依赖可选
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
_HAS_TENACITY = True
|
||||
except Exception: # noqa: BLE001 pragma: no cover
|
||||
_HAS_TENACITY = False
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 审计回调签名:接收关键字参数,落 ActionLog 表。
|
||||
AuditFn = Optional[Callable[..., Awaitable[None]]]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 异常体系
|
||||
# --------------------------------------------------------------------------
|
||||
class BaseClientError(Exception):
|
||||
"""外部客户端统一异常基类。
|
||||
|
||||
Attributes:
|
||||
code: 细分子错误码(自动化 ActionLog 记录用)
|
||||
message: 错误消息
|
||||
data: 附加数据
|
||||
"""
|
||||
|
||||
code: int = 5000
|
||||
|
||||
def __init__(self, message: str = "", code: int = 0, data: Any = None):
|
||||
self.code = code or self.code
|
||||
self.message = message or self.__class__.__name__
|
||||
self.data = data
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class ClientConfigError(BaseClientError):
|
||||
"""客户端配置缺失(密钥/基址未配置)。"""
|
||||
|
||||
code = 5001
|
||||
|
||||
|
||||
class ClientConnectionError(BaseClientError):
|
||||
"""网络层错误(超时 / 连接失败)。"""
|
||||
|
||||
code = 5002
|
||||
|
||||
|
||||
class ClientAuthError(BaseClientError):
|
||||
"""认证/鉴权失败(401 / 签名无效 / Token 失效)。"""
|
||||
|
||||
code = 5003
|
||||
|
||||
|
||||
class ClientAPIError(BaseClientError):
|
||||
"""外部系统返回业务错误。"""
|
||||
|
||||
code = 5004
|
||||
|
||||
def __init__(self, message: str = "", code: int = 0, status: int = 0, data: Any = None):
|
||||
self.status = status
|
||||
super().__init__(message, code, data)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 基类
|
||||
# --------------------------------------------------------------------------
|
||||
class BaseClient:
|
||||
"""外部系统客户端统一基类。
|
||||
|
||||
子类只需实现具体接口的签名/入参,并通过 self._request(...) 发送请求;
|
||||
统一的超时、重试、审计、异常处理由基类完成。
|
||||
|
||||
Attributes:
|
||||
system: 外部系统标识(huorong/lianruan/dify/ragflow/ehr),用于审计。
|
||||
base_url: 基址(不含尾部斜杠)。
|
||||
timeout: 请求超时(秒)。
|
||||
audit: 可选审计回调。
|
||||
max_retries: 网络层最大重试次数(仅 tenacity 可用时生效)。
|
||||
"""
|
||||
|
||||
# 子类覆盖:外部系统标识
|
||||
system: str = "internal"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
timeout: float = 30.0,
|
||||
audit: AuditFn = None,
|
||||
max_retries: int = 2,
|
||||
):
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.audit = audit
|
||||
self.max_retries = max_retries
|
||||
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)
|
||||
return self._client
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭连接池,释放资源。"""
|
||||
if self._client is not None and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 审计记录(出入参全量,脱敏截断)
|
||||
# ----------------------------------------------------------------------
|
||||
@staticmethod
|
||||
def _safe(obj: Any, limit: int = 4000) -> Any:
|
||||
"""将出入参转为可序列化字符串(截断,避免审计过大)。"""
|
||||
try:
|
||||
s = json.dumps(obj, ensure_ascii=False, default=str)
|
||||
except Exception: # noqa: BLE001
|
||||
s = str(obj)
|
||||
return s if len(s) <= limit else s[:limit] + "...(truncated)"
|
||||
|
||||
async def _audit(
|
||||
self,
|
||||
event: str,
|
||||
direction: str,
|
||||
request: Any,
|
||||
response: Any,
|
||||
status: str,
|
||||
latency_ms: int,
|
||||
error: Optional[str] = None,
|
||||
) -> None:
|
||||
"""记录一次调用的审计信息(audit 回调为空时跳过)。"""
|
||||
if self.audit is None:
|
||||
return
|
||||
try:
|
||||
await self.audit(
|
||||
event=event,
|
||||
direction=direction,
|
||||
system=self.system,
|
||||
request=self._safe(request),
|
||||
response=self._safe(response),
|
||||
status=status,
|
||||
latency_ms=latency_ms,
|
||||
error=error,
|
||||
)
|
||||
except Exception: # noqa: BLE001 审计失败不应影响主流程
|
||||
logger.debug(f"审计回调异常 system={self.system} event={event}")
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 网络层发送(含 tenacity 重试)
|
||||
# ----------------------------------------------------------------------
|
||||
async def _send_with_retry(
|
||||
self,
|
||||
client: httpx.AsyncClient,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]],
|
||||
json_body: Optional[Dict[str, Any]],
|
||||
headers: Optional[Dict[str, str]],
|
||||
) -> httpx.Response:
|
||||
"""发送 HTTP 请求(网络层错误时按 max_retries 重试)。"""
|
||||
if _HAS_TENACITY:
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(self.max_retries + 1),
|
||||
wait=wait_exponential(multiplier=0.5, min=0.5, max=3),
|
||||
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
return await client.request(
|
||||
method, url, params=params, json=json_body, headers=headers
|
||||
)
|
||||
# 无 tenacity 时单次直连
|
||||
return await client.request(
|
||||
method, url, params=params, json=json_body, headers=headers
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 统一请求骨架
|
||||
# ----------------------------------------------------------------------
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[Dict[str, Any]] = None,
|
||||
json_body: Optional[Dict[str, Any]] = None,
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
timeout: Optional[float] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""统一请求入口:超时 + 重试 + 审计 + 异常归一。
|
||||
|
||||
Args:
|
||||
method: HTTP 方法(GET/POST/...)
|
||||
path: 接口路径(如 /api/clnts/_list)
|
||||
params: query 参数
|
||||
json_body: JSON 请求体
|
||||
headers: 额外请求头
|
||||
timeout: 覆盖默认超时
|
||||
|
||||
Returns:
|
||||
Dict: 解析后的 JSON 响应(统一为 dict)
|
||||
|
||||
Raises:
|
||||
ClientConnectionError: 网络层错误
|
||||
ClientAPIError: 业务错误(由子类在覆盖方法中抛出)
|
||||
"""
|
||||
if not self.base_url:
|
||||
raise ClientConfigError(f"{self.system} 未配置 base_url")
|
||||
|
||||
url = f"{self.base_url}{path}"
|
||||
event = f"{self.system}.{path.strip('/').replace('/', '.') or 'root'}"
|
||||
request_payload = json_body if json_body is not None else params
|
||||
start = time.monotonic()
|
||||
|
||||
# 临时调整超时(仅本次请求)
|
||||
saved_timeout = None
|
||||
client = await self._get_client()
|
||||
if timeout is not None and client.timeout is not None:
|
||||
saved_timeout = client.timeout
|
||||
client.timeout = httpx.Timeout(timeout)
|
||||
|
||||
try:
|
||||
response = await self._send_with_retry(
|
||||
client, method, url, params=params, json_body=json_body, headers=headers
|
||||
)
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
data: Dict[str, Any] = response.json() if response.content else {}
|
||||
await self._audit(event, "out", request_payload, data, "success", latency)
|
||||
return data
|
||||
except (ClientConfigError, ClientConnectionError, ClientAuthError, ClientAPIError):
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
await self._audit(event, "out", request_payload, None, "error", latency,
|
||||
error="client_error")
|
||||
raise
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
await self._audit(event, "out", request_payload, None, "error", latency,
|
||||
error=str(e))
|
||||
raise ClientConnectionError(f"{self.system} 网络错误: {e}")
|
||||
except Exception as e: # noqa: BLE001
|
||||
latency = int((time.monotonic() - start) * 1000)
|
||||
await self._audit(event, "out", request_payload, None, "error", latency,
|
||||
error=str(e))
|
||||
raise ClientConnectionError(f"{self.system} 请求异常: {e}")
|
||||
finally:
|
||||
if saved_timeout is not None:
|
||||
client.timeout = saved_timeout
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 连接测试(子类可覆盖)
|
||||
# ----------------------------------------------------------------------
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""默认连接测试:子类应覆盖为各自的轻量探测。"""
|
||||
return {"success": True, "message": f"{self.system} 客户端已配置"}
|
||||
@@ -0,0 +1,247 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 火绒终端安全客户端
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎使用的火绒客户端(环境变量 AUTOMATION_HUORONG_* 驱动)。
|
||||
# 与 app/integrations/huorong(管理后台 DB 配置)区分:本客户端由自动化引擎
|
||||
# 专用,未配置时 get_huorong_client() 返回 None,引擎降级转人工。
|
||||
#
|
||||
# 签名算法(火绒官方 HRESS Authorization Header):
|
||||
# Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature
|
||||
# Signature = urlencode(base64(hmac-sha1(AccessKeySecret,
|
||||
# AccessKeyId + "\n" + Expires + "\n" + METHOD + "\n"
|
||||
# + Content-MD5 + "\n" + CanonicalizedResource)))
|
||||
#
|
||||
# 自动化动作适配器调用的方法(方法签名必须与 action_registry/rollback 一致):
|
||||
# - create_scan_task(client_ids, scan_type) 病毒扫描(low)
|
||||
# - isolate_terminal(client_ids) 终端隔离(high,需审批)
|
||||
# - unisolate_terminal(client_ids) 解除隔离(回滚补偿)
|
||||
# 审计/查询方法(mapping/排障用):
|
||||
# - list_terminals / get_terminal_detail / list_terminal_leaks(_leak)
|
||||
# - get_virus_events(_virus_events) / send_notification
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.config import settings
|
||||
from app.core.clients.base import (
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientAuthError,
|
||||
ClientConfigError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 签名有效期(秒)
|
||||
_SIGN_EXPIRES_SECONDS = 300
|
||||
# 默认请求超时(秒)
|
||||
_DEFAULT_TIMEOUT = 10.0
|
||||
# 默认分页大小
|
||||
_DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
|
||||
class HuorongClient(BaseClient):
|
||||
"""火绒终端安全客户端(自动化引擎专用)。
|
||||
|
||||
使用 HRESS HMAC-SHA1 签名,POST JSON 调用火绒 API。
|
||||
"""
|
||||
|
||||
system = "huorong"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
access_key_id: str,
|
||||
access_key_secret: str,
|
||||
base_url: str,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
audit: Any = None,
|
||||
max_retries: int = 2,
|
||||
):
|
||||
if not access_key_id or not access_key_secret:
|
||||
raise ClientConfigError("火绒 AccessKey ID / Secret 未配置")
|
||||
if not base_url:
|
||||
raise ClientConfigError("火绒 base_url 未配置")
|
||||
super().__init__(base_url=base_url, timeout=timeout, audit=audit, max_retries=max_retries)
|
||||
self.access_key_id = access_key_id
|
||||
self.access_key_secret = access_key_secret
|
||||
|
||||
# ======================================================================
|
||||
# 签名
|
||||
# ======================================================================
|
||||
def _compute_content_md5(self, body_bytes: bytes) -> str:
|
||||
"""计算请求体 Content-MD5(RFC2616: MD5 二进制摘要 → base64)。"""
|
||||
return base64.b64encode(hashlib.md5(body_bytes).digest()).decode("utf-8")
|
||||
|
||||
def _sign_request(self, method: str, path: str, body_bytes: bytes) -> Dict[str, str]:
|
||||
"""生成 HRESS Authorization Header 签名。"""
|
||||
expires = str(int(time.time()) + _SIGN_EXPIRES_SECONDS)
|
||||
content_md5 = self._compute_content_md5(body_bytes) if body_bytes else ""
|
||||
canonicalized_resource = path.lstrip("/")
|
||||
string_to_sign = (
|
||||
self.access_key_id + "\n"
|
||||
+ expires + "\n"
|
||||
+ method + "\n"
|
||||
+ content_md5 + "\n"
|
||||
+ canonicalized_resource
|
||||
)
|
||||
signature_raw = hmac.new(
|
||||
self.access_key_secret.encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
signature_b64 = base64.b64encode(signature_raw).decode("utf-8")
|
||||
signature_encoded = quote(signature_b64, safe="")
|
||||
authorization = f"HRESS{self.access_key_id}:{expires}:{signature_encoded}"
|
||||
return {
|
||||
"Authorization": authorization,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
}
|
||||
|
||||
async def _post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
||||
"""火绒统一 POST(带签名 + 业务错误码处理)。"""
|
||||
body_bytes = json.dumps(body or {}, separators=(",", ":")).encode("utf-8")
|
||||
headers = self._sign_request("POST", path, body_bytes)
|
||||
try:
|
||||
data = await self._request("POST", path, json_body=body or {}, headers=headers)
|
||||
except BaseClientError:
|
||||
raise
|
||||
# 火绒业务错误码:errno=0 成功
|
||||
errcode = data.get("errno", data.get("errcode", 0))
|
||||
if errcode != 0:
|
||||
msg = data.get("errmsg", data.get("msg", "未知错误"))
|
||||
if errcode in (1, 401, 403):
|
||||
raise ClientAuthError(f"火绒认证/权限失败: {msg}")
|
||||
raise ClientAPIError(message=f"火绒业务错误: {msg}", status=errcode, data=data)
|
||||
return data
|
||||
|
||||
# ======================================================================
|
||||
# 查询能力(_leak / _virus_events)
|
||||
# ======================================================================
|
||||
async def list_terminals(
|
||||
self, page: int = 1, per_page: int = _DEFAULT_PAGE_SIZE
|
||||
) -> Dict[str, Any]:
|
||||
"""查询终端列表。POST /api/clnts/_list。"""
|
||||
limit = min(per_page, 200)
|
||||
body = {"limit": limit, "offset": (page - 1) * limit}
|
||||
resp = await self._post("/api/clnts/_list", body)
|
||||
data = resp.get("data", {}) or {}
|
||||
return {"total": data.get("total", 0), "items": data.get("list", [])}
|
||||
|
||||
async def get_terminal_detail(self, client_id: str) -> Dict[str, Any]:
|
||||
"""查询终端详情。POST /api/clnts/_info2。"""
|
||||
resp = await self._post("/api/clnts/_info2", {"client_id": client_id})
|
||||
return resp.get("data", {}) or {}
|
||||
|
||||
async def list_terminal_leaks(
|
||||
self, page: int = 1, per_page: int = _DEFAULT_PAGE_SIZE
|
||||
) -> Dict[str, Any]:
|
||||
"""查询高危漏洞终端(_leak)。POST /api/clnts/_leak。"""
|
||||
limit = min(per_page, 200)
|
||||
body = {"limit": limit, "offset": (page - 1) * limit}
|
||||
resp = await self._post("/api/clnts/_leak", body)
|
||||
data = resp.get("data", {}) or {}
|
||||
return {
|
||||
"total": data.get("risk_client", 0),
|
||||
"all_client": data.get("all_client", 0),
|
||||
"risk_client": data.get("risk_client", 0),
|
||||
"items": data.get("list", []),
|
||||
}
|
||||
|
||||
async def get_virus_events(
|
||||
self,
|
||||
query_type: int = 2,
|
||||
client_id: Optional[str] = None,
|
||||
group_id: Optional[str] = None,
|
||||
page: int = 1,
|
||||
per_page: int = _DEFAULT_PAGE_SIZE,
|
||||
) -> Dict[str, Any]:
|
||||
"""查询病毒事件(_virus_events)。POST /api/clnts/_virus_events。"""
|
||||
limit = min(per_page, 200)
|
||||
body: Dict[str, Any] = {"type": query_type, "limit": limit, "offset": (page - 1) * limit}
|
||||
if query_type == 0 and client_id:
|
||||
body["client_id"] = client_id
|
||||
if query_type in (0, 1) and group_id:
|
||||
body["group_id"] = int(group_id)
|
||||
resp = await self._post("/api/clnts/_virus_events", body)
|
||||
data = resp.get("data", {}) or {}
|
||||
return {"total": data.get("total", 0), "items": data.get("list", [])}
|
||||
|
||||
# ======================================================================
|
||||
# 控制能力(自动化动作适配器调用)
|
||||
# ======================================================================
|
||||
async def create_scan_task(
|
||||
self, client_ids: List[str], scan_type: str = "quick_scan"
|
||||
) -> Dict[str, Any]:
|
||||
"""创建病毒扫描任务(low,自动执行)。POST /api/task/_create。"""
|
||||
body = {"type": scan_type, "clients": client_ids}
|
||||
resp = await self._post("/api/task/_create", body)
|
||||
logger.info(f"火绒扫描任务: type={scan_type}, client_ids={client_ids}")
|
||||
return resp.get("data", {}) or {}
|
||||
|
||||
async def isolate_terminal(self, client_ids: List[str]) -> Dict[str, Any]:
|
||||
"""隔离终端(断网,high,需审批)。POST /api/task/_create netctrl。"""
|
||||
body = {"type": "netctrl", "net_isolation": True, "clients": client_ids}
|
||||
resp = await self._post("/api/task/_create", body)
|
||||
logger.warning(f"火绒终端隔离: client_ids={client_ids}")
|
||||
return resp.get("data", {}) or {}
|
||||
|
||||
async def unisolate_terminal(self, client_ids: List[str]) -> Dict[str, Any]:
|
||||
"""解除终端隔离(回滚补偿)。POST /api/task/_create netctrl。"""
|
||||
body = {"type": "netctrl", "net_isolation": False, "clients": client_ids}
|
||||
resp = await self._post("/api/task/_create", body)
|
||||
logger.info(f"火绒解除隔离: client_ids={client_ids}")
|
||||
return resp.get("data", {}) or {}
|
||||
|
||||
async def send_notification(self, client_ids: List[str], content: str) -> Dict[str, Any]:
|
||||
"""向终端推送通知。POST /api/task/_create message。"""
|
||||
body = {"type": "message", "clients": client_ids, "content": content}
|
||||
resp = await self._post("/api/task/_create", body)
|
||||
return resp.get("data", {}) or {}
|
||||
|
||||
# ======================================================================
|
||||
# 连接测试
|
||||
# ======================================================================
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""轻量连接测试(_list 单条)。"""
|
||||
try:
|
||||
result = await self.list_terminals(page=1, per_page=1)
|
||||
return {"success": True, "message": f"连接成功,共 {result.get('total', 0)} 终端"}
|
||||
except BaseClientError as e:
|
||||
return {"success": False, "message": e.message}
|
||||
|
||||
|
||||
async def get_huorong_client(audit: Any = None) -> Optional[HuorongClient]:
|
||||
"""构建火绒客户端(环境变量 AUTOMATION_HUORONG_* 驱动)。
|
||||
|
||||
任一必填项为空 → 返回 None(引擎降级转人工,不抛异常)。
|
||||
|
||||
Returns:
|
||||
Optional[HuorongClient]: 配置完整时返回客户端,否则 None。
|
||||
"""
|
||||
base_url = getattr(settings, "automation_huorong_base_url", "") or ""
|
||||
access_key_id = getattr(settings, "automation_huorong_access_key_id", "") or ""
|
||||
access_key_secret = getattr(settings, "automation_huorong_access_key_secret", "") or ""
|
||||
if not (base_url and access_key_id and access_key_secret):
|
||||
logger.debug("火绒未配置(AUTOMATION_HUORONG_*),返回 None")
|
||||
return None
|
||||
try:
|
||||
return HuorongClient(
|
||||
access_key_id=access_key_id,
|
||||
access_key_secret=access_key_secret,
|
||||
base_url=base_url,
|
||||
audit=audit,
|
||||
)
|
||||
except ClientConfigError as e:
|
||||
logger.warning(f"火绒客户端构建失败: {e.message}")
|
||||
return None
|
||||
Reference in New Issue
Block a user