From f07bbbc49d16ba6c73735eb8fb0c425c9d1b6384 Mon Sep 17 00:00:00 2001 From: lianlonggang Date: Thu, 27 Aug 2026 17:24:17 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20RESTART=20=E8=B5=B0=20NSSM=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E9=87=8D=E5=90=AF=20+=20=E5=A4=B1=E8=B4=A5=E5=8E=9F?= =?UTF-8?q?=E5=9B=A0=E5=8F=AF=E8=A7=82=E6=B5=8B=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RESTART 命令优先通过 nssm restart 重启主程序服务(假死场景可用),未配置服务名回退环回 HTTP - nssm 输出按 UTF-16 解码并记录日志,权限不足时提示需以服务方式运行 - 环回管理接口 code=3001 时明确报鉴权失败,不再误报不可达 - 新增 nssm.path / main.service.name / log.dir 配置项 - 增加文件日志(RotatingFileHandler 5MB×5),服务方式运行可查日志 - HTTP 调用失败与状态码异常统一 WARNING 记录(原 DEBUG 不可见) - 断线日志区分「网络不通」与「被服务器拒绝(未注册/token 错误)」 - 连接 URL 自动拼接 device.id,消除双处配置不一致 Co-Authored-By: Claude --- agent/config.py | 9 ++++++++ agent/dispatcher.py | 20 ++++++++++++----- agent/main.py | 40 +++++++++++++++++++++++++++------- agent/main_app_client.py | 32 ++++++++++++++++++++------- agent/process_guard.py | 47 ++++++++++++++++++++++++++++++++++++---- agent/ws_client.py | 7 +++++- conf/agent.properties | 15 +++++++++++++ 7 files changed, 144 insertions(+), 26 deletions(-) diff --git a/agent/config.py b/agent/config.py index 6d30f55..b8c2724 100644 --- a/agent/config.py +++ b/agent/config.py @@ -10,8 +10,11 @@ - 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) +- nssm.path NSSM 可执行文件路径(配置 main.service.name 时用于拉起服务) +- main.service.name 主程序注册的 Windows 服务名(配置后看门狗用 nssm restart 拉起,优先于 main.start.cmd) - heartbeat.sec 心跳间隔(默认 15 秒,须小于网关空闲超时) - guard.enable 是否守护主程序(默认 true) +- log.dir 日志目录(默认 logs,相对部署根目录) """ import os @@ -28,8 +31,11 @@ class AgentConfig: main_health_url: str main_admin_token: str main_start_cmd: str + nssm_path: str + main_service_name: str heartbeat_sec: int guard_enabled: bool + log_dir: str @classmethod def load(cls, conf_path: str = "conf/agent.properties") -> "AgentConfig": @@ -57,8 +63,11 @@ class AgentConfig: 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"), + nssm_path=_get(props, "nssm.path", ""), + main_service_name=_get(props, "main.service.name", ""), heartbeat_sec=int(_get(props, "heartbeat.sec", "15")), guard_enabled=_get(props, "guard.enable", "true").lower() == "true", + log_dir=_get(props, "log.dir", "logs"), ) diff --git a/agent/dispatcher.py b/agent/dispatcher.py index 76526c1..2060ce6 100644 --- a/agent/dispatcher.py +++ b/agent/dispatcher.py @@ -93,11 +93,21 @@ class CommandDispatcher: 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 "重启请求失败", - } + # 优先走 NSSM 服务重启(主程序假死时 HTTP 不可达也能重启成功); + # 未配置服务名时回退环回 HTTP 触发主程序优雅重启 + if self.config.main_service_name: + from agent.process_guard import restart_main_service + ok, reason = restart_main_service(self.config.nssm_path, self.config.main_service_name) + result = { + "ok": ok, + "msg": "已通过 NSSM 重启主程序服务" if ok else f"NSSM 重启失败:{reason}", + } + else: + ok, reason = self.main_app_client.restart(1000) + result = { + "ok": ok, + "msg": "已触发优雅重启,等待 ProcessGuard 拉起" if ok else f"重启请求失败:{reason}", + } elif msg_type == "GET_CONFIG": hospital = (payload or {}).get("hospital") or "" diff --git a/agent/main.py b/agent/main.py index 4ac88e9..6b1b058 100644 --- a/agent/main.py +++ b/agent/main.py @@ -13,6 +13,8 @@ hospital-agent.exe (PyInstaller 打包后) """ import logging +import logging.handlers +import os import signal import sys import threading @@ -29,18 +31,13 @@ _client = None def run(): - """主流程:加载配置 → 写 PID → 组装组件 → 启动 → 主线程保活。""" + """主流程:加载配置 → 组装组件 → 启动 → 主线程保活。""" global _dispatcher, _guard, _client - logging.basicConfig( - level=logging.INFO, - format="{asctime} {levelname} [{name}] {message}", - style="{", - ) - # 源码运行时根目录即工作目录,PyInstaller 打包后同理(服务管理器负责设置工作目录) config = AgentConfig.load() + _setup_logging(config.log_dir) log.info("Agent 启动:deviceId=%s, server=%s", config.device_id, config.server_url) - + # 源码运行时根目录即工作目录,PyInstaller 打包后同理(服务管理器负责设置工作目录) # 组装组件(延迟导入避免环) from agent.dispatcher import CommandDispatcher from agent.process_guard import ProcessGuard @@ -64,6 +61,33 @@ def run(): threading.Event().wait() +def _setup_logging(log_dir: str): + """日志输出到控制台 + 文件(服务方式运行时靠文件留存,按大小轮转)。""" + fmt = logging.Formatter( + "{asctime} {levelname} [{name}] {message}", + style="{", + ) + root = logging.getLogger() + root.setLevel(logging.INFO) + + console = logging.StreamHandler() + console.setFormatter(fmt) + root.addHandler(console) + + try: + os.makedirs(log_dir, exist_ok=True) + file_handler = logging.handlers.RotatingFileHandler( + os.path.join(log_dir, "agent.log"), + maxBytes=5 * 1024 * 1024, # 单文件 5MB + backupCount=5, # 保留 5 个轮转备份 + encoding="utf-8", + ) + file_handler.setFormatter(fmt) + root.addHandler(file_handler) + except Exception as e: + root.warning("日志文件初始化失败(仅控制台输出):%s", e) + + def _shutdown(signum, frame): """优雅停机:释放日志流 → 停守护 → 断连接 → 退出。""" log.info("Agent 停机中...(signal=%s)", signum) diff --git a/agent/main_app_client.py b/agent/main_app_client.py index 21f2f44..fc5d14f 100644 --- a/agent/main_app_client.py +++ b/agent/main_app_client.py @@ -57,9 +57,13 @@ class MainAppClient: """日志文件列表(原始 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 restart(self, delay_ms: int) -> tuple[bool, str]: + """触发主程序优雅重启。返回 (是否成功, 失败原因)。""" + body, reason = self._post_text_with_reason(self._url(f"/api/admin/restart?delayMs={delay_ms}"), "") + # 主程序管理接口鉴权失败时返回 code=3001 的 JSON(HTTP 层可能为 200) + if body is not None and "3001" in body: + return False, "管理接口鉴权失败(main.admin.token 与主程序不一致)" + return (body is not None), reason def config_file(self, hospital: str) -> str | None: """读取医院配置文件(原始 JSON 文本)。""" @@ -89,9 +93,12 @@ class MainAppClient: headers={"X-Admin-Token": self.config.main_admin_token}, timeout=(5, 30), ) - return resp if resp.ok else None + if not resp.ok: + log.warning("GET %s 失败:HTTP %s", url, resp.status_code) + return None + return resp except Exception as e: - log.debug("GET %s 失败:%s", url, e) + log.warning("GET %s 失败:%s", url, e) return None def _get_text(self, url: str) -> str | None: @@ -101,6 +108,11 @@ class MainAppClient: def _post_text(self, url: str, form_body: str) -> str | None: """POST 表单并返回文本;失败返回 None。""" + body, _ = self._post_text_with_reason(url, form_body) + return body + + def _post_text_with_reason(self, url: str, form_body: str) -> tuple[str | None, str]: + """POST 表单并返回 (文本, 失败原因);成功时原因为空串。""" try: resp = self._session.post( url, @@ -111,7 +123,11 @@ class MainAppClient: data=form_body.encode("utf-8"), timeout=(5, 30), ) - return resp.text if resp.ok else None + if not resp.ok: + reason = f"HTTP {resp.status_code}" + log.warning("POST %s 失败:%s", url, reason) + return None, reason + return resp.text, "" except Exception as e: - log.debug("POST %s 失败:%s", url, e) - return None + log.warning("POST %s 失败:%s", url, e) + return None, str(e) diff --git a/agent/process_guard.py b/agent/process_guard.py index 302904d..a428b2f 100644 --- a/agent/process_guard.py +++ b/agent/process_guard.py @@ -76,20 +76,59 @@ class ProcessGuard: self._restart_count = 3 return - log.error("主程序宕机,执行第 %d 次拉起:%s", attempts, self.config.main_start_cmd) + # 拉起方式:配置了服务名则走 nssm restart(幂等),否则回退启动命令 + if self.config.main_service_name: + start_desc = f"nssm restart {self.config.main_service_name}" + else: + start_desc = self.config.main_start_cmd + log.error("主程序宕机,执行第 %d 次拉起:%s", attempts, start_desc) 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) + if self.config.main_service_name: + ok, reason = restart_main_service(self.config.nssm_path, self.config.main_service_name) + if not ok: + log.error("nssm 拉起失败:%s", reason) + else: + 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) + log.error("拉起失败:%s", e) # ================= 公共工具(upgrade.py 复用) ================= +def restart_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]: + """ + 通过 NSSM 重启主程序服务(幂等:无论服务处于停止/运行态均到运行态)。 + + nssm_path 为 nssm.exe 完整路径;service_name 为注册的 Windows 服务名。 + 返回 (是否成功, 输出/原因)。nssm 输出打印到日志,权限不足(拒绝访问)等 + 失败原因一眼可见。 + """ + try: + proc = subprocess.run( + [nssm_path, "restart", service_name], + capture_output=True, + timeout=60, + ) + # nssm 输出为 UTF-16 编码(宽字符),需按 utf-16 解码 + out = ((proc.stdout or b"") + (proc.stderr or b"")).decode("utf-16", errors="replace").strip() + ok = proc.returncode == 0 + if ok: + log.info("nssm restart %s 成功:%s", service_name, out or "(无输出)") + else: + log.warning("nssm restart %s 失败(exit=%s):%s", service_name, proc.returncode, out) + if "拒绝访问" in out or "Access" in out: + out += "(提示:重启服务需要管理员权限,请将 Agent 以 Windows 服务方式运行)" + return ok, out + except Exception as e: + log.warning("nssm restart %s 执行异常:%s", service_name, e) + return False, str(e) + + def start_main_app(start_cmd: str) -> subprocess.Popen | None: """ 拉起主程序。 diff --git a/agent/ws_client.py b/agent/ws_client.py index da50f69..4b667d5 100644 --- a/agent/ws_client.py +++ b/agent/ws_client.py @@ -98,7 +98,12 @@ class AgentClient: def on_error(ws, error): self._connected = False - log.warning("网络不通(无法连接管理服务器):%s,将重连", error) + # websocket-client 收到服务器关闭帧时会先回调本方法(特征:fin=1 opcode=8), + # 这不是网络问题,而是被服务器拒绝(设备未注册 / token 错误) + if "opcode=8" in str(error): + log.warning("连接被服务器拒绝(设备未注册或 token 错误):%s", error) + else: + log.warning("网络不通(无法连接管理服务器):%s,将重连", error) # websocket-client 出错后必定回调 on_close,重连由 on_close 统一调度 # 此处不重复调度,避免双重重连 diff --git a/conf/agent.properties b/conf/agent.properties index 985d927..48582a4 100644 --- a/conf/agent.properties +++ b/conf/agent.properties @@ -27,9 +27,19 @@ main.health.url=http://127.0.0.1:8082/api/admin/health main.admin.token= # \u4E3B\u7A0B\u5E8F\u542F\u52A8\u547D\u4EE4\uFF08\u770B\u95E8\u72D7\u68C0\u6D4B\u5230\u4E3B\u7A0B\u5E8F\u5B95\u673A\u540E\u6267\u884C\u6B64\u547D\u4EE4\u62C9\u8D77\uFF09 +# \u6CE8\u610F\uFF1A\u82E5\u914D\u7F6E\u4E86 main.service.name\uFF0C\u5219\u4F18\u5148\u7528 NSSM \u91CD\u542F\u670D\u52A1\uFF0C\u672C\u9879\u4E0D\u751F\u6548 # \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_MAIN_START_CMD main.start.cmd=bin/service.bat main start +# NSSM \u53EF\u6267\u884C\u6587\u4EF6\u5B8C\u6574\u8DEF\u5F84\uFF08\u914D\u7F6E main.service.name \u65F6\u5FC5\u586B\uFF09 +# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_NSSM_PATH +nssm.path=D:\tools\nssm-2.24\win64\nssm.exe + +# \u4E3B\u7A0B\u5E8F\u6CE8\u518C\u7684 Windows \u670D\u52A1\u540D\uFF08\u914D\u7F6E\u540E\u770B\u95E8\u72D7\u901A\u8FC7 nssm restart <\u670D\u52A1\u540D> \u62C9\u8D77\u4E3B\u7A0B\u5E8F\uFF09 +# \u7559\u7A7A\u5219\u56DE\u9000\u4F7F\u7528 main.start.cmd +# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_MAIN_SERVICE_NAME +main.service.name=HospitalFront + # \u5FC3\u8DF3\u95F4\u9694\uFF0C\u5355\u4F4D\u79D2\uFF08\u9ED8\u8BA4 15\uFF0C\u987B\u5C0F\u4E8E\u7F51\u5173\u7A7A\u95F2\u8D85\u65F6\uFF0C\u5426\u5219\u8FDE\u63A5\u4F1A\u88AB\u7F51\u5173\u65AD\u5F00\uFF09 # \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_HEARTBEAT_SEC heartbeat.sec=15 @@ -37,3 +47,8 @@ heartbeat.sec=15 # \u662F\u5426\u542F\u7528\u770B\u95E8\u72D7\u5B88\u62A4\u4E3B\u7A0B\u5E8F\uFF08true/false\uFF0C\u9ED8\u8BA4 true\uFF1Bfalse \u65F6\u4EC5\u505A\u8FD0\u7EF4\u547D\u4EE4\u901A\u9053\uFF09 # \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_GUARD_ENABLE guard.enable=true + +# \u65E5\u5FD7\u76EE\u5F55\uFF08\u76F8\u5BF9\u90E8\u7F72\u6839\u76EE\u5F55\uFF0C\u9ED8\u8BA4 logs\uFF1B\u6587\u4EF6 agent.log\uFF0C\u5355\u6587\u4EF6 5MB \u8F6E\u8F6C\u3001\u4FDD\u7559 5 \u4E2A\u5907\u4EFD\uFF09 +# \u670D\u52A1\u65B9\u5F0F\u8FD0\u884C\u65F6\u63A7\u5236\u53F0\u65E5\u5FD7\u4E0D\u53EF\u89C1\uFF0C\u8BF7\u4F9D\u8D56\u6B64\u6587\u4EF6\u65E5\u5FD7 +# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_LOG_DIR +log.dir=logs