feat: RESTART 走 NSSM 服务重启 + 失败原因可观测化

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 17:24:17 +08:00
co-authored by Claude
parent 86bbe8ef5a
commit f07bbbc49d
7 changed files with 144 additions and 26 deletions
+9
View File
@@ -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.batLinux: 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"),
)
+12 -2
View File
@@ -93,10 +93,20 @@ class CommandDispatcher:
result = self._get_log_stream().control(action, file)
elif msg_type == "RESTART":
ok = self.main_app_client.restart(1000)
# 优先走 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": "触发优雅重启,等待 ProcessGuard 拉起" if ok else "重启请求失败",
"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":
+32 -8
View File
@@ -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)
+24 -8
View File
@@ -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 的 JSONHTTP 层可能为 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)
+41 -2
View File
@@ -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:
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:
"""
拉起主程序。
+5
View File
@@ -98,6 +98,11 @@ class AgentClient:
def on_error(ws, error):
self._connected = False
# 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 统一调度
# 此处不重复调度,避免双重重连
+15
View File
@@ -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