feat: Python 版前置机 Agent 完整实现
- WebSocket 连接 B 端(注册/心跳/指数退避重连) - 命令分发(10 种远程运维命令) - 看门狗守护、日志实时流、在线升级含自动回滚 - 修复 main.py 日志格式 style 与占位符不匹配的问题 - 补充 README、配置示例、依赖清单 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -166,7 +166,7 @@ cython_debug/
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
@@ -1,3 +1,68 @@
|
||||
# hospital-front-agent
|
||||
# hospital-front-agent(Python 版)
|
||||
|
||||
前置机代理
|
||||
医院前置机被控端 Agent,从 [hospital-front](../hospital-front)(Kotlin/Java)拆出的独立 Python 实现。
|
||||
|
||||
## 职责
|
||||
|
||||
1. **WebSocket 主动连接 B 端管理服务器**(注册 / 心跳 / 接收命令)
|
||||
2. **守护主程序**(健康探测 + 宕机自动拉起 + 告警)
|
||||
3. **远程运维命令**:健康查询、版本查询、日志查看(尾部 + 实时流)、配置读写、优雅重启、**在线升级(含自动回滚)**
|
||||
|
||||
与 Java 版的**消息协议完全一致**,B 端无需任何修改。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
hospital-front-agent/
|
||||
├── agent/
|
||||
│ ├── main.py # 入口:组装、PID 文件、信号处理
|
||||
│ ├── config.py # conf/agent.properties + AGENT_ 环境变量
|
||||
│ ├── ws_client.py # WS 客户端:注册/心跳/指数退避重连
|
||||
│ ├── dispatcher.py # 命令分发(10 种命令)
|
||||
│ ├── main_app_client.py # 环回 HTTP 调主程序 /api/admin
|
||||
│ ├── process_guard.py # 看门狗:3 次失败拉起、告警
|
||||
│ ├── log_stream.py # 日志实时流:2s 增量轮询、滚动检测
|
||||
│ └── upgrade.py # 升级 SOP:下载→校验→备份→停→替换→拉起→健康检查→回滚
|
||||
├── conf/agent.properties # 配置示例
|
||||
└── requirements.txt
|
||||
```
|
||||
|
||||
## 运行
|
||||
|
||||
依赖 Python 3.10+。
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
python -m agent.main
|
||||
```
|
||||
|
||||
工作目录须与主程序部署根目录一致(`bin/`、`pid/`、`tmp/`、`backup/` 所在目录),
|
||||
配置默认从 `conf/agent.properties` 读取。
|
||||
|
||||
## 配置
|
||||
|
||||
见 `conf/agent.properties`。优先级:环境变量(`AGENT_SERVER_URL` 等) > properties > 默认值。
|
||||
|
||||
## 打包(PyInstaller 单 exe)
|
||||
|
||||
```bash
|
||||
pip install pyinstaller
|
||||
pyinstaller --onefile --name hospital-agent --paths . agent/main.py
|
||||
```
|
||||
|
||||
产物 `dist/hospital-agent.exe`,拷贝到部署根目录 `bin/` 下即可。
|
||||
|
||||
## Windows 服务注册(NSSM 示例)
|
||||
|
||||
```bat
|
||||
nssm install HospitalAgent "C:\hospital\bin\hospital-agent.exe"
|
||||
nssm set HospitalAgent AppDirectory "C:\hospital"
|
||||
nssm set HospitalAgent AppStdout "C:\hospital\logs\agent-out.log"
|
||||
nssm set HospitalAgent AppStderr "C:\hospital\logs\agent-err.log"
|
||||
```
|
||||
|
||||
## 与 Java 版的差异
|
||||
|
||||
- Agent 为独立 Python 进程,**不再占用 `bin/hospital-front.jar`**,
|
||||
升级替换 jar 时无需维护 Agent jar 副本(Java 版遗留的 `hospital-agent.jar` 复制逻辑已移除)。
|
||||
- 其余命令协议、升级 SOP、看门狗策略、日志流算法均与 Java 版等价。
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""hospital-front-agent 包。"""
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Agent 配置加载。
|
||||
|
||||
从 conf/agent.properties 读取(key=value),支持环境变量覆盖(AGENT_ 前缀大写)。
|
||||
|
||||
配置项:
|
||||
- server.url B 端 WebSocket 地址(经网关转发,须带 /medical 服务前缀)
|
||||
- device.id 设备编号(医院唯一,如 jinghe-01)
|
||||
- device.token 设备鉴权 Token
|
||||
- main.health.url 主程序健康检查地址(http://127.0.0.1:8082/api/admin/health)
|
||||
- main.admin.token 主程序管理接口 Token(与主程序 admin.token 一致)
|
||||
- main.start.cmd 主程序启动命令(Windows: bin\\start.bat;Linux: bin/start.sh)
|
||||
- heartbeat.sec 心跳间隔(默认 15 秒,须小于网关空闲超时)
|
||||
- guard.enable 是否守护主程序(默认 true)
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""Agent 全量配置(不可变)。"""
|
||||
|
||||
server_url: str
|
||||
device_id: str
|
||||
device_token: str
|
||||
main_health_url: str
|
||||
main_admin_token: str
|
||||
main_start_cmd: str
|
||||
heartbeat_sec: int
|
||||
guard_enabled: bool
|
||||
|
||||
@classmethod
|
||||
def load(cls, conf_path: str = "conf/agent.properties") -> "AgentConfig":
|
||||
"""从指定路径加载配置,环境变量优先。"""
|
||||
props = {}
|
||||
if os.path.exists(conf_path):
|
||||
try:
|
||||
with open(conf_path, "r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# 跳过空行与注释
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
props[key.strip()] = value.strip()
|
||||
except Exception:
|
||||
# 读取失败时按空配置处理,交由各配置项默认值兜底
|
||||
pass
|
||||
|
||||
return cls(
|
||||
server_url=_get(props, "server.url", "ws://127.0.0.1:19901/medical/agentSocket/unknown-device"),
|
||||
device_id=_get(props, "device.id", "unknown-device"),
|
||||
device_token=_get(props, "device.token", ""),
|
||||
main_health_url=_get(props, "main.health.url", "http://127.0.0.1:8082/api/admin/health"),
|
||||
main_admin_token=_get(props, "main.admin.token", ""),
|
||||
main_start_cmd=_get(props, "main.start.cmd", "bin/start.bat"),
|
||||
heartbeat_sec=int(_get(props, "heartbeat.sec", "15")),
|
||||
guard_enabled=_get(props, "guard.enable", "true").lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
def _get(props: dict, key: str, default: str) -> str:
|
||||
"""读取单个配置项:环境变量 > properties > 默认值。"""
|
||||
env_key = "AGENT_" + key.replace(".", "_").upper()
|
||||
env = os.environ.get(env_key)
|
||||
if env is not None:
|
||||
return env
|
||||
return props.get(key, default)
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""下行命令分发器。
|
||||
|
||||
B 端通过 WebSocket 下发 JSON 命令:{"type":"CMD_TYPE","payload":{...}}
|
||||
|
||||
已支持命令:
|
||||
- PING 探活(立即回 pong)
|
||||
- GET_HEALTH 查询主程序健康
|
||||
- GET_VERSION 查询主程序版本
|
||||
- GET_LOG_FILES 日志文件列表
|
||||
- LOG_TAIL 日志尾部(payload: file, lines)
|
||||
- LOG_TAIL_CTRL 日志实时流开关(payload: action, file)
|
||||
- RESTART 重启主程序(优雅停机后由 ProcessGuard 拉起)
|
||||
- GET_CONFIG 读取医院配置文件(payload: hospital)
|
||||
- SAVE_CONFIG 写回医院配置文件(payload: hospital, content)
|
||||
- UPGRADE 升级主程序
|
||||
|
||||
所有命令执行结果通过 AgentClient 上行(CMD_RESULT)。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CommandDispatcher:
|
||||
"""命令分发:解析 type → 执行 → 回执 CMD_RESULT(透传 cmdId)。"""
|
||||
|
||||
def __init__(self, config, main_app_client):
|
||||
self.config = config
|
||||
self.main_app_client = main_app_client
|
||||
|
||||
self._client = None # 由 main 注入(避免构造环)
|
||||
self._upgrade_command = None # 升级命令(懒加载)
|
||||
self._log_stream = None # 日志实时流管理(懒加载)
|
||||
self._log_stream_lock = threading.Lock()
|
||||
self._upgrade_lock = threading.Lock()
|
||||
|
||||
def set_client(self, client):
|
||||
"""注入 AgentClient(main 组装阶段调用,避免构造环)。"""
|
||||
self._client = client
|
||||
|
||||
def _get_upgrade_command(self):
|
||||
"""懒加载升级命令。"""
|
||||
with self._upgrade_lock:
|
||||
if self._upgrade_command is None:
|
||||
from agent.upgrade import UpgradeCommand
|
||||
self._upgrade_command = UpgradeCommand(self.config, self.main_app_client, self._client)
|
||||
return self._upgrade_command
|
||||
|
||||
def _get_log_stream(self):
|
||||
"""懒加载日志流管理器。"""
|
||||
with self._log_stream_lock:
|
||||
if self._log_stream is None:
|
||||
from agent.log_stream import LogStreamManager
|
||||
self._log_stream = LogStreamManager(self.config, self.main_app_client, self._client)
|
||||
return self._log_stream
|
||||
|
||||
def shutdown_log_stream(self):
|
||||
"""日志实时流(供 main 停机时统一释放)。"""
|
||||
self._get_log_stream().stop_all()
|
||||
|
||||
# ================= 分发 =================
|
||||
|
||||
def dispatch(self, msg_type, payload):
|
||||
"""分发并执行命令。payload 为 dict 或 None。"""
|
||||
log.info("收到命令:%s", msg_type)
|
||||
payload = payload if isinstance(payload, dict) else None
|
||||
|
||||
if msg_type == "PING":
|
||||
# 命令协议:B 端下发的 payload 内可带 cmdId,回执时透传以便并发匹配
|
||||
result = {"pong": True, "ts": int(time.time())}
|
||||
|
||||
elif msg_type == "GET_HEALTH":
|
||||
result = self.main_app_client.health()
|
||||
|
||||
elif msg_type == "GET_VERSION":
|
||||
result = self.main_app_client.version()
|
||||
|
||||
elif msg_type == "GET_LOG_FILES":
|
||||
result = {"ok": True, "data": self.main_app_client.log_files()}
|
||||
|
||||
elif msg_type == "LOG_TAIL":
|
||||
file = (payload or {}).get("file") or "info.log"
|
||||
lines = (payload or {}).get("lines") or 200
|
||||
result = {"ok": True, "data": self.main_app_client.log_tail(file, lines)}
|
||||
|
||||
elif msg_type == "LOG_TAIL_CTRL":
|
||||
action = (payload or {}).get("action") or "start"
|
||||
file = (payload or {}).get("file") or "info.log"
|
||||
result = self._get_log_stream().control(action, file)
|
||||
|
||||
elif msg_type == "RESTART":
|
||||
ok = self.main_app_client.restart(1000)
|
||||
result = {
|
||||
"ok": ok,
|
||||
"msg": "已触发优雅重启,等待 ProcessGuard 拉起" if ok else "重启请求失败",
|
||||
}
|
||||
|
||||
elif msg_type == "GET_CONFIG":
|
||||
hospital = (payload or {}).get("hospital") or ""
|
||||
content = self.main_app_client.config_file(hospital)
|
||||
result = {"ok": content is not None, "data": content}
|
||||
|
||||
elif msg_type == "SAVE_CONFIG":
|
||||
hospital = (payload or {}).get("hospital") or ""
|
||||
content = (payload or {}).get("content") or ""
|
||||
result = {"ok": self.main_app_client.save_config_file(hospital, content)}
|
||||
|
||||
elif msg_type == "UPGRADE":
|
||||
# 异步执行(升级耗时长,避免阻塞 WS 消息循环)
|
||||
version = (payload or {}).get("version") or "unknown"
|
||||
download_url = (payload or {}).get("downloadUrl")
|
||||
sha256 = (payload or {}).get("sha256")
|
||||
if not download_url or not download_url.strip():
|
||||
result = {"ok": False, "msg": "缺少 downloadUrl"}
|
||||
else:
|
||||
threading.Thread(
|
||||
target=self._run_upgrade,
|
||||
args=(version, download_url, sha256),
|
||||
name="upgrade",
|
||||
daemon=True,
|
||||
).start()
|
||||
result = {"ok": True, "msg": "升级已开始(异步执行,结果将通过 UPGRADE_RESULT 上报)"}
|
||||
|
||||
else:
|
||||
result = {"ok": False, "msg": f"未知命令:{msg_type}"}
|
||||
|
||||
envelope = {"cmd": msg_type}
|
||||
# 透传 B 端下发的 cmdId(并发命令匹配用)
|
||||
if payload is not None and payload.get("cmdId") is not None:
|
||||
envelope["cmdId"] = payload.get("cmdId")
|
||||
envelope["result"] = result
|
||||
self._client.send("CMD_RESULT", envelope)
|
||||
|
||||
def _run_upgrade(self, version, download_url, sha256):
|
||||
"""升级线程体:执行并上报 UPGRADE_RESULT。"""
|
||||
r = self._get_upgrade_command().upgrade(version, download_url, sha256)
|
||||
self._client.send("UPGRADE_RESULT", r)
|
||||
@@ -0,0 +1,141 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""日志实时推送管理器。
|
||||
|
||||
B 端通过 LOG_TAIL_CTRL 命令开启/停止某设备某日志文件的实时流:
|
||||
- 开启后每 2 秒从主程序 /api/admin/log/tail 拉取增量行
|
||||
- 新行打包为 LOG_BATCH 上报 B 端(B 端转发给浏览器展示)
|
||||
- 每个文件独立游标(已发送行数),支持多文件并行
|
||||
|
||||
增量算法:tail(max(offset+FETCH, 400)) 行,取 offset 之后的部分;
|
||||
日志滚动(文件变小/重置)时自动重置游标。
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 轮询间隔(秒)
|
||||
POLL_SEC = 2
|
||||
# 单次拉取上限(行)
|
||||
FETCH_MAX = 400
|
||||
|
||||
|
||||
class LogStreamManager:
|
||||
"""日志实时流:单线程轮询全部活跃文件,增量上报 LOG_BATCH。"""
|
||||
|
||||
def __init__(self, config, main_app_client, client):
|
||||
self.config = config
|
||||
self.main_app_client = main_app_client
|
||||
self.client = client
|
||||
|
||||
self._cursors: dict[str, int] = {} # 文件名 → 已发送行数
|
||||
self._active: dict[str, bool] = {} # 文件名 → 是否激活
|
||||
self._lock = threading.Lock()
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
# ================= 控制入口 =================
|
||||
|
||||
def control(self, action: str, file: str) -> dict:
|
||||
"""
|
||||
控制命令入口。
|
||||
|
||||
:param action: start / stop
|
||||
:param file: 日志文件名(info.log / error.log)
|
||||
"""
|
||||
if action == "start":
|
||||
with self._lock:
|
||||
if self._active.get(file):
|
||||
return {"ok": True, "msg": f"流已存在:{file}"}
|
||||
self._active[file] = True
|
||||
self._cursors.pop(file, None)
|
||||
self._ensure_thread()
|
||||
log.info("日志流开启:%s", file)
|
||||
return {"ok": True, "msg": f"日志流已开启:{file}"}
|
||||
|
||||
if action == "stop":
|
||||
with self._lock:
|
||||
self._active.pop(file, None)
|
||||
self._cursors.pop(file, None)
|
||||
log.info("日志流停止:%s", file)
|
||||
return {"ok": True, "msg": f"日志流已停止:{file}"}
|
||||
|
||||
return {"ok": False, "msg": f"未知 action:{action}(支持 start/stop)"}
|
||||
|
||||
def stop_all(self):
|
||||
"""停止全部流(Agent 停机时调用)。"""
|
||||
self._stop.set()
|
||||
with self._lock:
|
||||
self._active.clear()
|
||||
self._cursors.clear()
|
||||
|
||||
def _ensure_thread(self):
|
||||
"""确保轮询线程在运行(首个流开启时启动)。"""
|
||||
if self._thread is not None and self._thread.is_alive():
|
||||
return
|
||||
self._thread = threading.Thread(target=self._poll_loop, name="log-stream", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
# ================= 轮询 =================
|
||||
|
||||
def _poll_loop(self):
|
||||
"""轮询主循环:每 POLL_SEC 秒拉一轮全部活跃文件。"""
|
||||
while not self._stop.wait(POLL_SEC):
|
||||
with self._lock:
|
||||
files = list(self._active.keys())
|
||||
for file in files:
|
||||
if self._stop.is_set():
|
||||
return
|
||||
try:
|
||||
self._poll_one(file)
|
||||
except Exception as e:
|
||||
log.warning("日志流轮询失败(%s):%s", file, e)
|
||||
|
||||
def _poll_one(self, file: str):
|
||||
"""单轮增量拉取:取尾部行,比对游标,新行打包上报。"""
|
||||
with self._lock:
|
||||
if not self._active.get(file):
|
||||
return
|
||||
offset = self._cursors.get(file, 0)
|
||||
|
||||
# 多拉一段,保证能覆盖到 offset 之前的已有内容
|
||||
fetch = max(offset + 100, FETCH_MAX)
|
||||
raw = self.main_app_client.log_tail(file, fetch)
|
||||
log.debug("日志流轮询:file=%s, offset=%s, fetch=%s, rawLen=%s",
|
||||
file, offset, fetch, len(raw) if raw else -1)
|
||||
if raw is None:
|
||||
return
|
||||
try:
|
||||
node = json.loads(raw)
|
||||
except Exception:
|
||||
return
|
||||
if node.get("code") != 0:
|
||||
return
|
||||
content = node.get("data")
|
||||
if not content:
|
||||
return
|
||||
|
||||
all_lines = content.split("\n")
|
||||
|
||||
# 日志滚动检测:行数比游标还少很多 → 文件已重置
|
||||
if len(all_lines) < offset:
|
||||
with self._lock:
|
||||
self._cursors[file] = 0
|
||||
return
|
||||
if len(all_lines) <= offset:
|
||||
return # 无新行
|
||||
|
||||
fresh = [line for line in all_lines[offset:] if line and line.strip()]
|
||||
with self._lock:
|
||||
self._cursors[file] = len(all_lines)
|
||||
if not fresh:
|
||||
return
|
||||
|
||||
self.client.send("LOG_BATCH", {
|
||||
"file": file,
|
||||
"lines": fresh,
|
||||
"ts": int(time.time()),
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Agent 启动入口。
|
||||
|
||||
启动方式:
|
||||
python -m agent.main (源码运行)
|
||||
hospital-agent.exe (PyInstaller 打包后)
|
||||
|
||||
职责:
|
||||
1. WebSocket 主动连接 B 端管理服务器(注册/心跳/命令)
|
||||
2. 守护主程序(健康探测 + 宕机拉起)
|
||||
3. 写 PID 文件(pid/hospital-agent.pid)
|
||||
|
||||
由 WinSW/NSSM Windows 服务守护本进程。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
|
||||
from agent.config import AgentConfig
|
||||
from agent.main_app_client import MainAppClient
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 组装阶段先声明,供信号处理器引用
|
||||
_dispatcher = None
|
||||
_guard = None
|
||||
_client = None
|
||||
|
||||
|
||||
def run():
|
||||
"""主流程:加载配置 → 写 PID → 组装组件 → 启动 → 主线程保活。"""
|
||||
global _dispatcher, _guard, _client
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="{asctime} {levelname} [{name}] {message}",
|
||||
style="{",
|
||||
)
|
||||
# 源码运行时根目录即工作目录,PyInstaller 打包后同理(服务管理器负责设置工作目录)
|
||||
config = AgentConfig.load()
|
||||
log.info("Agent 启动:deviceId=%s, server=%s", config.device_id, config.server_url)
|
||||
|
||||
# 写 PID 文件
|
||||
_write_pid_file()
|
||||
|
||||
# 组装组件(延迟导入避免环)
|
||||
from agent.dispatcher import CommandDispatcher
|
||||
from agent.process_guard import ProcessGuard
|
||||
from agent.ws_client import AgentClient
|
||||
|
||||
main_app_client = MainAppClient(config)
|
||||
dispatcher = CommandDispatcher(config, main_app_client)
|
||||
client = AgentClient(config, dispatcher)
|
||||
dispatcher.set_client(client)
|
||||
guard = ProcessGuard(config, main_app_client, client)
|
||||
_dispatcher, _guard, _client = dispatcher, guard, client
|
||||
|
||||
# 优雅停机信号处理
|
||||
signal.signal(signal.SIGINT, _shutdown)
|
||||
signal.signal(signal.SIGTERM, _shutdown)
|
||||
|
||||
client.start()
|
||||
guard.start()
|
||||
|
||||
# 主线程保活
|
||||
threading.Event().wait()
|
||||
|
||||
|
||||
def _shutdown(signum, frame):
|
||||
"""优雅停机:释放日志流 → 停守护 → 断连接 → 退出。"""
|
||||
log.info("Agent 停机中...(signal=%s)", signum)
|
||||
if _dispatcher is not None:
|
||||
_dispatcher.shutdown_log_stream()
|
||||
if _guard is not None:
|
||||
_guard.stop()
|
||||
if _client is not None:
|
||||
_client.stop()
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def _write_pid_file():
|
||||
"""写 PID 文件(pid/hospital-agent.pid)。"""
|
||||
try:
|
||||
os.makedirs("pid", exist_ok=True)
|
||||
with open(os.path.join("pid", "hospital-agent.pid"), "w", encoding="utf-8") as f:
|
||||
f.write(str(os.getpid()))
|
||||
except Exception as e:
|
||||
log.warning("PID 文件写入失败:%s", e)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
@@ -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
|
||||
@@ -0,0 +1,110 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""主程序进程守护(看门狗)。
|
||||
|
||||
周期探测主程序健康:
|
||||
- 连续 3 次失败 → 认为主程序宕机 → 强杀残留 → 调启动命令拉起
|
||||
- 拉起后等待健康恢复;连续 3 次拉起失败 → 上报告警(ALERT)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProcessGuard:
|
||||
"""看门狗:周期健康探测 + 宕机拉起 + 告警上报。"""
|
||||
|
||||
def __init__(self, config, main_app_client, client):
|
||||
self.config = config
|
||||
self.main_app_client = main_app_client
|
||||
self.client = client
|
||||
|
||||
self._fail_count = 0 # 连续健康探测失败次数
|
||||
self._restart_count = 0 # 连续拉起次数
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self):
|
||||
"""启动守护(首检延迟 30 秒,之后每 15 秒探测一次)。"""
|
||||
if not self.config.guard_enabled:
|
||||
log.info("进程守护未启用(guard.enable=false)")
|
||||
return
|
||||
self._thread = threading.Thread(target=self._loop, name="process-guard", daemon=True)
|
||||
self._thread.start()
|
||||
log.info("进程守护已启动:每 15 秒探测 %s", self.config.main_health_url)
|
||||
|
||||
def stop(self):
|
||||
"""停止守护。"""
|
||||
self._stop.set()
|
||||
|
||||
# ================= 探测循环 =================
|
||||
|
||||
def _loop(self):
|
||||
"""探测主循环:先等 30 秒(给主程序启动时间),再周期探测。"""
|
||||
if self._stop.wait(30):
|
||||
return
|
||||
while not self._stop.wait(15):
|
||||
try:
|
||||
self._check()
|
||||
except Exception as e:
|
||||
log.warning("守护探测异常:%s", e)
|
||||
|
||||
def _check(self):
|
||||
"""单次探测:健康则清零计数;连续 3 次失败则拉起。"""
|
||||
health = self.main_app_client.health()
|
||||
if health.get("ok") is True:
|
||||
self._fail_count = 0
|
||||
return
|
||||
|
||||
self._fail_count += 1
|
||||
log.warning("主程序健康探测失败(%d/3):%s", self._fail_count, health.get("error"))
|
||||
if self._fail_count < 3:
|
||||
return
|
||||
|
||||
# 连续 3 次失败 → 拉起
|
||||
self._fail_count = 0
|
||||
self._restart_count += 1
|
||||
attempts = self._restart_count
|
||||
if attempts > 3:
|
||||
self.client.send("ALERT", {
|
||||
"level": "CRITICAL",
|
||||
"msg": f"主程序连续 {attempts} 次拉起失败,请人工介入",
|
||||
})
|
||||
# 重置计数,之后继续尝试(降频可后续优化)
|
||||
self._restart_count = 3
|
||||
return
|
||||
|
||||
log.error("主程序宕机,执行第 %d 次拉起:%s", attempts, self.config.main_start_cmd)
|
||||
self.client.send("ALERT", {
|
||||
"level": "WARN",
|
||||
"msg": f"主程序宕机,Agent 正在执行第 {attempts} 次拉起",
|
||||
})
|
||||
try:
|
||||
proc = start_main_app(self.config.main_start_cmd)
|
||||
log.info("启动命令已执行,pid=%s", proc.pid if proc else None)
|
||||
except Exception as e:
|
||||
log.error("启动命令执行失败:%s", e)
|
||||
|
||||
|
||||
# ================= 公共工具(upgrade.py 复用) =================
|
||||
|
||||
def start_main_app(start_cmd: str) -> subprocess.Popen | None:
|
||||
"""
|
||||
拉起主程序。
|
||||
|
||||
Windows 下 .bat 走 cmd /c(路径转反斜杠);其余走 sh。
|
||||
返回 Popen 对象(不等待进程结束)。
|
||||
"""
|
||||
cmd = start_cmd.replace("/", "\\") # cmd/c 要求反斜杠路径
|
||||
if cmd.lower().endswith(".bat"):
|
||||
args = ["cmd", "/c", cmd, "start"]
|
||||
else:
|
||||
args = ["sh", start_cmd, "start"]
|
||||
return subprocess.Popen(
|
||||
args,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.STDOUT,
|
||||
# 不设 cwd,继承 Agent 工作目录(与主程序同根目录部署)
|
||||
)
|
||||
@@ -0,0 +1,267 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""主程序升级命令。
|
||||
|
||||
流程(见 05-部署运维.md SOP):
|
||||
1. 从 B 端下载新 jar 到 tmp(SHA-256 校验,防传输损坏)
|
||||
2. 备份当前 bin/hospital-front.jar → backup/hospital-front-{时间戳}.jar(保留最近 5 个)
|
||||
3. 优雅停止主程序(调 /api/admin/restart;超时强杀)
|
||||
4. 替换 jar
|
||||
5. 调启动命令拉起主程序
|
||||
6. 轮询健康检查(最长 90 秒)
|
||||
7. 成功 → 上报;失败 → 用备份 jar 自动回滚并重启 → 上报
|
||||
|
||||
期间整个流程串行(upgrade_lock 防并发升级)。
|
||||
"""
|
||||
|
||||
import glob
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from agent.process_guard import start_main_app
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# 备份保留数量
|
||||
KEEP_BACKUPS = 5
|
||||
|
||||
|
||||
class UpgradeCommand:
|
||||
"""升级 SOP 执行器。"""
|
||||
|
||||
def __init__(self, config, main_app_client, client):
|
||||
self.config = config
|
||||
self.main_app_client = main_app_client
|
||||
self.client = client
|
||||
|
||||
# 升级互斥锁(同一时刻只允许一个升级在跑)
|
||||
self._upgrade_lock = threading.Lock()
|
||||
|
||||
def upgrade(self, version: str, download_url: str, sha256: str | None) -> dict:
|
||||
"""
|
||||
执行升级。
|
||||
|
||||
:param version: 新版本号(仅用于日志与上报)
|
||||
:param download_url: B 端提供的 jar 下载地址(A 主动出站拉取)
|
||||
:param sha256: 可选校验值
|
||||
"""
|
||||
with self._upgrade_lock:
|
||||
log.info("开始升级到版本 %s:%s", version, download_url)
|
||||
self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "download"})
|
||||
|
||||
# ===== 1. 下载 =====
|
||||
tmp_jar = os.path.join("tmp", f"upgrade-{version}.jar")
|
||||
os.makedirs(os.path.dirname(tmp_jar), exist_ok=True)
|
||||
try:
|
||||
with requests.get(download_url, stream=True, timeout=(10, 120)) as resp:
|
||||
if resp.status_code != 200:
|
||||
return self._fail(version, f"下载失败:HTTP {resp.status_code}")
|
||||
with open(tmp_jar, "wb") as f:
|
||||
for chunk in resp.iter_content(8192):
|
||||
f.write(chunk)
|
||||
except Exception as e:
|
||||
return self._fail(version, f"下载异常:{e}")
|
||||
log.info("下载完成:%s(%d 字节)", os.path.basename(tmp_jar), os.path.getsize(tmp_jar))
|
||||
|
||||
# ===== 2. 校验 =====
|
||||
if sha256:
|
||||
try:
|
||||
actual = sha256_file(tmp_jar)
|
||||
except Exception as e:
|
||||
return self._fail(version, f"SHA-256 计算异常:{e}")
|
||||
if actual.lower() != sha256.lower():
|
||||
os.remove(tmp_jar)
|
||||
return self._fail(version, f"SHA-256 校验失败:expected={sha256}, actual={actual}")
|
||||
log.info("SHA-256 校验通过")
|
||||
|
||||
# ===== 3. 备份 =====
|
||||
jar_file = os.path.join("bin", "hospital-front.jar")
|
||||
backup_file = os.path.join("backup", f"hospital-front-{int(time.time() * 1000)}.jar")
|
||||
os.makedirs(os.path.dirname(backup_file), exist_ok=True)
|
||||
if os.path.exists(jar_file):
|
||||
try:
|
||||
shutil.copyfile(jar_file, backup_file)
|
||||
except Exception as e:
|
||||
return self._fail(version, f"备份失败:{e}")
|
||||
log.info("已备份当前版本 → %s", os.path.basename(backup_file))
|
||||
self._clean_old_backups()
|
||||
|
||||
# ===== 4. 停止主程序 =====
|
||||
self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "stop"})
|
||||
self._stop_main_app()
|
||||
log.info("主程序已停止")
|
||||
|
||||
# ===== 5. 替换 =====
|
||||
# 注意:Agent 为独立 Python 进程,不占用 bin/hospital-front.jar,
|
||||
# 可安全替换;无需再维护 Agent jar 副本(Java 版遗留逻辑)。
|
||||
try:
|
||||
copy_with_retry(tmp_jar, jar_file, 10)
|
||||
os.remove(tmp_jar)
|
||||
except Exception as e:
|
||||
self._rollback(backup_file)
|
||||
return self._fail(version, f"替换 jar 失败,已回滚:{e}")
|
||||
log.info("jar 已替换")
|
||||
|
||||
# ===== 6. 拉起 + 健康检查 =====
|
||||
self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "restart"})
|
||||
try:
|
||||
start_main_app(self.config.main_start_cmd)
|
||||
except Exception as e:
|
||||
log.error("主程序拉起失败:%s", e)
|
||||
|
||||
self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "healthcheck"})
|
||||
if self._wait_healthy(90):
|
||||
log.info("升级成功:%s", version)
|
||||
return {"ok": True, "version": version, "msg": "升级成功"}
|
||||
|
||||
# ===== 7. 健康检查失败 → 回滚 =====
|
||||
log.error("新版本健康检查失败,自动回滚到 %s", os.path.basename(backup_file))
|
||||
self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "rollback"})
|
||||
self._stop_main_app()
|
||||
self._rollback(backup_file)
|
||||
try:
|
||||
start_main_app(self.config.main_start_cmd)
|
||||
except Exception as e:
|
||||
log.error("回滚后拉起失败:%s", e)
|
||||
if self._wait_healthy(90):
|
||||
return self._fail(version, f"新版本启动失败,已自动回滚到 {os.path.basename(backup_file)} 并恢复服务")
|
||||
|
||||
self.client.send("ALERT", {
|
||||
"level": "CRITICAL",
|
||||
"msg": f"升级失败且回滚后仍不健康,请人工介入!版本={version}",
|
||||
})
|
||||
return self._fail(version, "升级失败且回滚后仍不健康,请人工介入")
|
||||
|
||||
# ================= 内部步骤 =================
|
||||
|
||||
def _stop_main_app(self):
|
||||
"""停止主程序:优先优雅(admin API),超时后按 PID 强杀;确保进程完全退出(jar 锁释放)。"""
|
||||
pid = read_pid_file(os.path.join("pid", "hospital-front.pid"))
|
||||
if self.main_app_client.restart(100):
|
||||
# 等待健康探测失败(最长 30 秒)
|
||||
deadline = time.time() + 30
|
||||
while time.time() < deadline:
|
||||
if self.main_app_client.health().get("ok") is not True:
|
||||
break
|
||||
time.sleep(1)
|
||||
# 兜底:按 PID 文件强杀
|
||||
kill_by_pid_file(os.path.join("pid", "hospital-front.pid"))
|
||||
# 等待进程真正退出(Windows 下进程退出后才释放 jar 文件锁)
|
||||
if pid is not None:
|
||||
wait_process_exit(pid, 15)
|
||||
time.sleep(1)
|
||||
|
||||
def _wait_healthy(self, timeout_sec: int) -> bool:
|
||||
"""轮询等待主程序健康。"""
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
if self.main_app_client.health().get("ok") is True:
|
||||
return True
|
||||
time.sleep(3)
|
||||
return False
|
||||
|
||||
def _rollback(self, backup_file: str):
|
||||
"""回滚:备份 jar 覆盖回 bin。"""
|
||||
try:
|
||||
if os.path.exists(backup_file):
|
||||
jar_file = os.path.join("bin", "hospital-front.jar")
|
||||
copy_with_retry(backup_file, jar_file, 10)
|
||||
log.info("已回滚到 %s", os.path.basename(backup_file))
|
||||
except Exception as e:
|
||||
log.error("回滚失败:%s", e)
|
||||
|
||||
def _clean_old_backups(self):
|
||||
"""清理旧备份(按文件名倒序保留最近 N 个)。"""
|
||||
backups = sorted(glob.glob(os.path.join("backup", "hospital-front-*")), reverse=True)
|
||||
for old in backups[KEEP_BACKUPS:]:
|
||||
try:
|
||||
os.remove(old)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _fail(self, version: str, msg: str) -> dict:
|
||||
"""统一失败上报。"""
|
||||
log.error("升级失败:%s", msg)
|
||||
return {"ok": False, "version": version, "msg": msg}
|
||||
|
||||
|
||||
# ================= 模块级工具函数 =================
|
||||
|
||||
def read_pid_file(pid_file: str) -> str | None:
|
||||
"""读 PID 文件,无或为空返回 None。"""
|
||||
try:
|
||||
if not os.path.exists(pid_file):
|
||||
return None
|
||||
with open(pid_file, "r", encoding="utf-8") as f:
|
||||
s = f.read().strip()
|
||||
return s or None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def kill_by_pid_file(pid_file: str):
|
||||
"""按 PID 文件强杀进程(Windows taskkill / Linux kill -9)。"""
|
||||
pid = read_pid_file(pid_file)
|
||||
if not pid:
|
||||
return
|
||||
try:
|
||||
if os.name == "nt":
|
||||
subprocess.run(["taskkill", "/PID", pid, "/T", "/F"],
|
||||
capture_output=True, timeout=15)
|
||||
else:
|
||||
subprocess.run(["kill", "-9", pid],
|
||||
capture_output=True, timeout=15)
|
||||
except Exception as e:
|
||||
log.warning("PID 强杀失败:%s", e)
|
||||
|
||||
|
||||
def wait_process_exit(pid: str, timeout_sec: int):
|
||||
"""轮询等待进程退出(tasklist 查不到即退出)。"""
|
||||
deadline = time.time() + timeout_sec
|
||||
while time.time() < deadline:
|
||||
alive = False
|
||||
try:
|
||||
if os.name == "nt":
|
||||
out = subprocess.run(
|
||||
["tasklist", "/FI", f"PID eq {pid}"],
|
||||
capture_output=True, timeout=10,
|
||||
).stdout.decode("utf-8", errors="ignore")
|
||||
alive = pid in out
|
||||
else:
|
||||
alive = subprocess.run(["kill", "-0", pid], capture_output=True).returncode == 0
|
||||
except Exception:
|
||||
alive = False
|
||||
if not alive:
|
||||
return
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def copy_with_retry(src: str, dst: str, retries: int):
|
||||
"""带重试的文件覆盖(Windows 下进程退出有延迟,锁释放需等待)。"""
|
||||
last_error = None
|
||||
for _ in range(retries):
|
||||
try:
|
||||
if os.path.exists(dst):
|
||||
os.remove(dst)
|
||||
if not os.path.exists(dst):
|
||||
shutil.copyfile(src, dst)
|
||||
return
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
time.sleep(1)
|
||||
raise RuntimeError(f"文件替换失败(重试 {retries} 次):{os.path.basename(dst)}") from last_error
|
||||
|
||||
|
||||
def sha256_file(path: str) -> str:
|
||||
"""文件 SHA-256(分块读取,支持大文件)。"""
|
||||
digest = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
@@ -0,0 +1,161 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Agent WebSocket 客户端。
|
||||
|
||||
- 主动出站连接 B 端(穿透白名单,无需 A 端开放入站端口)
|
||||
- 断线指数退避重连(1s → 2s → 4s → ... 最大 60s)
|
||||
- 周期心跳:拉取主程序 /api/admin/health 并转发给 B 端
|
||||
- 收到下行命令交给 CommandDispatcher 处理
|
||||
|
||||
上行消息格式:{"type":"HEARTBEAT","deviceId":"...","ts":...,"payload":{...}}
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
import websocket # websocket-client
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentClient:
|
||||
"""WebSocket 长连接客户端(连接管理 + 心跳 + 重连)。"""
|
||||
|
||||
def __init__(self, config, dispatcher):
|
||||
self.config = config
|
||||
self.dispatcher = dispatcher
|
||||
|
||||
self._ws = None # 当前 WebSocket 连接
|
||||
self._connected = False # 是否已连接
|
||||
self._reconnect_attempts = 0 # 连续重连次数(成功后清零)
|
||||
self._lock = threading.Lock() # 保护 _ws/_connected 的并发访问
|
||||
self._heartbeat_stop = threading.Event()
|
||||
self._threads: list[threading.Thread] = []
|
||||
|
||||
# ================= 生命周期 =================
|
||||
|
||||
def start(self):
|
||||
"""启动:连接 + 心跳线程(首次心跳立即发送,避免网关空闲超时在首个周期内掐断连接)。"""
|
||||
self._connect()
|
||||
t = threading.Thread(target=self._heartbeat_loop, name="heartbeat", daemon=True)
|
||||
t.start()
|
||||
self._threads.append(t)
|
||||
|
||||
def stop(self):
|
||||
"""停止并释放资源。"""
|
||||
self._heartbeat_stop.set()
|
||||
with self._lock:
|
||||
if self._ws is not None:
|
||||
try:
|
||||
self._ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._ws = None
|
||||
self._connected = False
|
||||
|
||||
# ================= 连接管理 =================
|
||||
|
||||
def _connect(self):
|
||||
"""建立 WebSocket 连接(阻塞直到连接断开,内部自行调度重连)。"""
|
||||
url = self.config.server_url
|
||||
log.info("连接管理服务器:%s", url)
|
||||
|
||||
headers = [
|
||||
f"X-Device-Id: {self.config.device_id}",
|
||||
f"X-Device-Token: {self.config.device_token}",
|
||||
]
|
||||
|
||||
def on_open(ws):
|
||||
self._connected = True
|
||||
self._reconnect_attempts = 0
|
||||
log.info("已连接管理服务器")
|
||||
self.send("REGISTER", {
|
||||
"deviceId": self.config.device_id,
|
||||
"version": "1.0.0",
|
||||
})
|
||||
|
||||
def on_message(ws, text):
|
||||
try:
|
||||
node = json.loads(text)
|
||||
msg_type = node.get("type")
|
||||
payload = node.get("payload")
|
||||
self.dispatcher.dispatch(msg_type, payload)
|
||||
except Exception as e:
|
||||
log.warning("命令处理异常:%s", e)
|
||||
|
||||
def on_close(ws, code, reason):
|
||||
self._connected = False
|
||||
log.info("连接关闭:code=%s, reason=%s", code, reason)
|
||||
self._schedule_reconnect()
|
||||
|
||||
def on_error(ws, error):
|
||||
self._connected = False
|
||||
log.warning("连接失败:%s,将重连", error)
|
||||
# websocket-client 出错后必定回调 on_close,重连由 on_close 统一调度
|
||||
# 此处不重复调度,避免双重重连
|
||||
|
||||
ws = websocket.WebSocketApp(
|
||||
url,
|
||||
header=headers,
|
||||
on_open=on_open,
|
||||
on_message=on_message,
|
||||
on_close=on_close,
|
||||
on_error=on_error,
|
||||
)
|
||||
with self._lock:
|
||||
self._ws = ws
|
||||
# 阻塞运行(跳过协议层 ping,应用层心跳已足够;关掉默认 ping 避免双重心跳)
|
||||
ws.run_forever(ping_interval=25, ping_timeout=10)
|
||||
|
||||
def _schedule_reconnect(self):
|
||||
"""指数退避重连:1s 起,翻倍至 60s 封顶。"""
|
||||
self._reconnect_attempts += 1
|
||||
attempt = self._reconnect_attempts
|
||||
delay = min(1000 * (2 ** min(attempt - 1, 6)), 60_000) / 1000.0
|
||||
log.info("%.0fs 后进行第 %d 次重连", delay, attempt)
|
||||
time.sleep(delay)
|
||||
if not self._heartbeat_stop.is_set():
|
||||
self._connect()
|
||||
|
||||
# ================= 心跳 =================
|
||||
|
||||
def _heartbeat_loop(self):
|
||||
"""周期心跳:拉主程序健康数据转发。"""
|
||||
while not self._heartbeat_stop.wait(self.config.heartbeat_sec):
|
||||
try:
|
||||
self._heartbeat_once()
|
||||
except Exception as e:
|
||||
log.warning("心跳发送异常:%s", e)
|
||||
|
||||
def _heartbeat_once(self):
|
||||
main_health = self.dispatcher.main_app_client.health()
|
||||
self.send("HEARTBEAT", {
|
||||
"deviceId": self.config.device_id,
|
||||
"mainApp": main_health,
|
||||
})
|
||||
|
||||
# ================= 上行消息 =================
|
||||
|
||||
def send(self, msg_type: str, payload):
|
||||
"""发送上行消息(未连接时丢弃并记日志)。"""
|
||||
with self._lock:
|
||||
ws = self._ws
|
||||
connected = self._connected
|
||||
if ws is None or not connected:
|
||||
log.debug("未连接,丢弃上行消息:%s", msg_type)
|
||||
return
|
||||
envelope = {
|
||||
"type": msg_type,
|
||||
"deviceId": self.config.device_id,
|
||||
"ts": int(time.time()),
|
||||
"payload": payload,
|
||||
}
|
||||
try:
|
||||
ws.send(json.dumps(envelope, ensure_ascii=False))
|
||||
except Exception as e:
|
||||
log.warning("上行消息发送失败(%s):%s", msg_type, e)
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
"""当前是否已连接(供健康状态展示)。"""
|
||||
return self._connected
|
||||
@@ -0,0 +1,13 @@
|
||||
# Agent 配置
|
||||
# 值优先从环境变量读取(AGENT_ 前缀 + 大写 + 下划线,如 AGENT_SERVER_URL)
|
||||
# 服务脚本自动加载 conf/.env 设置环境变量;NSSM 用 AppEnvironmentExtra 设置
|
||||
#
|
||||
# 以下是 fallback 默认值(环境变量未设置时使用):
|
||||
server.url=ws://10.10.10.228:19901/medical/agentSocket/unknown-device
|
||||
device.id=unknown-device
|
||||
device.token=
|
||||
main.health.url=http://127.0.0.1:8082/api/admin/health
|
||||
main.admin.token=
|
||||
main.start.cmd=bin/service.bat main start
|
||||
heartbeat.sec=15
|
||||
guard.enable=true
|
||||
@@ -0,0 +1,2 @@
|
||||
websocket-client>=1.6.0
|
||||
requests>=2.31.0
|
||||
Reference in New Issue
Block a user