feat: Python 版前置机 Agent 完整实现

- WebSocket 连接 B 端(注册/心跳/指数退避重连)
- 命令分发(10 种远程运维命令)
- 看门狗守护、日志实时流、在线升级含自动回滚
- 修复 main.py 日志格式 style 与占位符不匹配的问题
- 补充 README、配置示例、依赖清单

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-27 11:58:31 +08:00
co-authored by Claude
parent cc35a41ea4
commit 68751c2354
13 changed files with 1188 additions and 3 deletions
+117
View File
@@ -0,0 +1,117 @@
# -*- coding: utf-8 -*-
"""主程序 IPC 客户端(环回 HTTP)。
Agent 通过本机 HTTP 调用主程序被控端 API/api/admin)。
"""
import logging
from urllib.parse import quote
import requests
log = logging.getLogger(__name__)
class MainAppClient:
"""主程序管理接口客户端。"""
def __init__(self, config):
self.config = config
self._session = requests.Session()
# 连接失败快速返回(探测场景多,不能长时间阻塞)
self._session.mount("http://", requests.adapters.HTTPAdapter())
# ================= 业务接口 =================
def health(self) -> dict:
"""主程序健康检查:返回解析后的 dict;不可达返回 {"ok":False,"error":"..."}"""
body = self._get_json(self.config.main_health_url)
if body is None:
return {"ok": False, "error": "主程序不可达"}
try:
node = body.json()
return {
"ok": True,
"code": node.get("code"),
"data": node.get("data"),
}
except Exception as e:
return {"ok": False, "error": f"健康响应解析失败:{e}"}
def version(self) -> dict:
"""主程序版本。"""
body = self._get_json(self._url("/api/admin/version"))
if body is None:
return {"ok": False}
try:
node = body.json()
return {"ok": True, "data": node.get("data")}
except Exception as e:
return {"ok": False, "error": str(e)}
def log_tail(self, file: str, lines: int) -> str | None:
"""日志尾部(原始 JSON 文本)。"""
return self._get_text(self._url(f"/api/admin/log/tail?file={quote(file)}&lines={lines}"))
def log_files(self) -> str | None:
"""日志文件列表(原始 JSON 文本)。"""
return self._get_text(self._url("/api/admin/log/files"))
def restart(self, delay_ms: int) -> bool:
"""触发主程序优雅重启。"""
return self._post_text(self._url(f"/api/admin/restart?delayMs={delay_ms}"), "") is not None
def config_file(self, hospital: str) -> str | None:
"""读取医院配置文件(原始 JSON 文本)。"""
return self._get_text(self._url(f"/api/admin/config/file?hospital={quote(hospital)}"))
def save_config_file(self, hospital: str, content: str) -> bool:
"""写回医院配置文件。"""
body = self._post_text(
self._url("/api/admin/config/file"),
f"hospital={quote(hospital)}&content={quote(content)}",
)
return body is not None
# ================= 基础 HTTP =================
def _url(self, path: str) -> str:
"""基于健康地址推导 /api/admin 基地址。"""
idx = self.config.main_health_url.find("/api/")
base = self.config.main_health_url[:idx] if idx >= 0 else self.config.main_health_url
return base + path
def _get_json(self, url: str):
"""GET 并返回 Response 对象;失败返回 None。"""
try:
resp = self._session.get(
url,
headers={"X-Admin-Token": self.config.main_admin_token},
timeout=(5, 30),
)
return resp if resp.ok else None
except Exception as e:
log.debug("GET %s 失败:%s", url, e)
return None
def _get_text(self, url: str) -> str | None:
"""GET 并返回文本;失败返回 None。"""
resp = self._get_json(url)
return resp.text if resp is not None else None
def _post_text(self, url: str, form_body: str) -> str | None:
"""POST 表单并返回文本;失败返回 None。"""
try:
resp = self._session.post(
url,
headers={
"X-Admin-Token": self.config.main_admin_token,
"Content-Type": "application/x-www-form-urlencoded",
},
data=form_body.encode("utf-8"),
timeout=(5, 30),
)
return resp.text if resp.ok else None
except Exception as e:
log.debug("POST %s 失败:%s", url, e)
return None