feat: 升级流程增强 + 网关化配置 + 重连循环化

- 配置化:gateway.url/socket.path/download.prefix(网关基础地址统一推导 ws 与下载地址)、
  main.home 主目录(jar/pid 相对它推导)、order.basic;properties 中文 \uXXXX 转义自动还原
- 升级流程:下载 PK 魔数检查与诊断日志;SHA 校验;替换改「改名腾位」绕开 Windows 文件锁延迟;
  nssm stop/start 驱动停止与拉起(pid 强杀回退);升级中标志暂停看门狗拉起避免抢占 jar 锁
- ws_client:重连改循环实现修复递归栈溢出(长时间断线后 maximum recursion depth);
  退避封顶延长至 5 分钟;心跳线程独立修复「暂无心跳数据」
- 新增命令:GET_APIS/HTTP_PROXY(接口测试代理,纯透传鉴权)、GET_LOG_DOWNLOAD(日志下载备用)
- 诊断增强:taskkill/进程退出/替换重试逐次日志

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-03 16:52:34 +08:00
co-authored by Claude
parent b572697e17
commit 0b13de14eb
7 changed files with 366 additions and 152 deletions
+51 -3
View File
@@ -4,12 +4,21 @@
从 conf/agent.properties 读取(key=value),支持环境变量覆盖(AGENT_ 前缀大写)。
配置项:
- server.url B 端 WebSocket 地址(经网关转发,须带 /medical 服务前缀)
- gateway.url B 端网关基础地址(如 http://10.10.10.228:19901)。
配置后:WebSocket 地址 = 网关地址(scheme转ws) + socket.path + 设备号;
升级下载地址 = 网关地址 + download.prefix + B端下发的文件相对路径。
- socket.path WebSocket 路由相对路径(默认 /medical/agentSocket/,配合 gateway.url 用)
- download.prefix 网关文件下载路由前缀(默认 /file/down,如
http://网关:端口/file/down/hf-upgrade/20260902/xxx.jar
- server.url B 端 WebSocket 完整地址(未配置 gateway.url 时的回退方式,
以 /agentSocket/ 结尾,连接时自动拼接设备号)
- 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.batLinux: bin/start.sh
- main.home 主程序主目录(默认 ".",即与 Agent 同目录;分目录部署时如 ../main
- main.jar.path 主程序 jar 相对 main.home 的路径(默认 bin/hospital-front.jar
- main.pid.file 主程序 PID 文件相对 main.home 的路径(默认 pid/hospital-front.pid
- nssm.path NSSM 可执行文件路径(配置 main.service.name 时用于拉起服务)
- main.service.name 主程序注册的 Windows 服务名(配置后看门狗用 nssm restart 拉起,优先于 main.start.cmd
- heartbeat.sec 心跳间隔(默认 15 秒,须小于网关空闲超时)
@@ -18,6 +27,7 @@
"""
import os
import re
from dataclasses import dataclass
@@ -25,12 +35,28 @@ from dataclasses import dataclass
class AgentConfig:
"""Agent 全量配置(不可变)。"""
gateway_url: str
socket_path: str
download_prefix: str
server_url: str
device_id: str
device_token: str
main_health_url: str
main_admin_token: str
main_start_cmd: str
main_home: str
main_jar_path: str
main_pid_file: str
@property
def main_jar(self) -> str:
"""主程序 jar 完整路径(main.home + main.jar.path)。"""
return os.path.join(self.main_home, self.main_jar_path)
@property
def main_pid(self) -> str:
"""主程序 PID 文件完整路径(main.home + main.pid.file)。"""
return os.path.join(self.main_home, self.main_pid_file)
nssm_path: str
main_service_name: str
heartbeat_sec: int
@@ -52,18 +78,24 @@ class AgentConfig:
continue
if "=" in line:
key, _, value = line.partition("=")
props[key.strip()] = value.strip()
props[key.strip()] = _unescape(value.strip())
except Exception:
# 读取失败时按空配置处理,交由各配置项默认值兜底
pass
return cls(
gateway_url=_get(props, "gateway.url", ""),
socket_path=_get(props, "socket.path", "/medical/agentSocket/"),
download_prefix=_get(props, "download.prefix", "/file/down"),
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"),
main_home=_get(props, "main.home", "."),
main_jar_path=_get(props, "main.jar.path", "bin/hospital-front.jar"),
main_pid_file=_get(props, "main.pid.file", "pid/hospital-front.pid"),
nssm_path=_get(props, "nssm.path", ""),
main_service_name=_get(props, "main.service.name", ""),
heartbeat_sec=int(_get(props, "heartbeat.sec", "15")),
@@ -73,6 +105,22 @@ class AgentConfig:
)
def _unescape(value: str) -> str:
"""还原 Java properties 的 \\uXXXX 中文转义(兼容前置的双反斜杠)。
IDEA 等编辑器保存 .properties 时会把非 ASCII 字符(如中文路径)自动转为
\\uXXXX(有时前置双反斜杠);不还原的话路径按字面反斜杠序列处理,找不到目录。
仅替换「反斜杠+u+4位十六进制」序列,Windows 路径分隔符(\\Users 等)不受影响。
"""
# 「N 个反斜杠 + u + 4 位十六进制」:解码为字符并保留 N-1 个反斜杠
# (双反斜杠时前一个是 Windows 路径分隔符,不能吃掉)
return re.sub(
r"(\\+)u([0-9a-fA-F]{4})",
lambda m: "\\" * (len(m.group(1)) - 1) + chr(int(m.group(2), 16)),
value,
)
def _get(props: dict, key: str, default: str) -> str:
"""读取单个配置项:环境变量 > properties > 默认值。"""
env_key = "AGENT_" + key.replace(".", "_").upper()
+6
View File
@@ -141,6 +141,12 @@ class CommandDispatcher:
# 返回主程序 /order 接口清单(供 B 端接口测试工具加载)
result = {"ok": True, "data": self.main_app_client.get_apis()}
elif msg_type == "GET_LOG_DOWNLOAD":
# 日志全量下载(供 B 端浏览器下载,Agent 自动带 admin token 调主程序)
file = (payload or {}).get("file") or "info.log"
content = self.main_app_client.log_download(file)
result = {"ok": content is not None, "data": content}
elif msg_type == "HTTP_PROXY":
# 代理调用前置机任意接口(穿透白名单,本机环回)
p = payload or {}
+9 -14
View File
@@ -57,6 +57,10 @@ class MainAppClient:
"""日志文件列表(原始 JSON 文本)。"""
return self._get_text(self._url("/api/admin/log/files"))
def log_download(self, file: str) -> str | None:
"""日志全量下载(文本,自带 X-Admin-Token 鉴权)。"""
return self._get_text(self._url(f"/api/admin/log/download?file={quote(file)}"))
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}"), "")
@@ -86,9 +90,8 @@ class MainAppClient:
"""
代理调用前置机任意接口(本机环回,穿透白名单)。
自动按 path 加鉴权头:
- /order/** → Authorization: Basic(读 config.order_basic
- /api/admin/** → X-Admin-Token(读 config.main_admin_token
纯透传:鉴权头由调用方(B 端测试页面)通过 headers 传入,
Agent 不自动加认证,便于测试不同账号。
:return {"ok":bool, "statusCode":int, "body":str, "headers":dict, "error":str}
"""
@@ -100,18 +103,10 @@ class MainAppClient:
if qs:
url += ("&" if "?" in url else "?") + qs
# 纯透传 headers(鉴权由 B 端填写,不自动加)
headers = {}
# 按路径自动加鉴权
if path.startswith("/order"):
if self.config.order_basic:
b64 = __import__("base64").b64encode(
self.config.order_basic.encode("utf-8")
).decode("ascii")
headers["Authorization"] = f"Basic {b64}"
elif path.startswith("/api/admin"):
if self.config.main_admin_token:
headers["X-Admin-Token"] = self.config.main_admin_token
# 合并外部传入的 headers(覆盖默认)
if extra_headers:
headers.update(extra_headers)
if extra_headers:
headers.update(extra_headers)
+62 -1
View File
@@ -12,6 +12,11 @@ import threading
log = logging.getLogger(__name__)
# 模块级「升级中」标志:升级流程开始时置 True,结束后置 False。
# 看门狗探测到主程序不健康时若此标志为 True,说明是升级主动停的,跳过拉起,
# 避免升级替换 jar 时被看门狗拉起的新进程抢占文件锁。
_upgrading = threading.Event()
class ProcessGuard:
"""看门狗:周期健康探测 + 宕机拉起 + 告警上报。"""
@@ -52,12 +57,18 @@ class ProcessGuard:
log.warning("守护探测异常:%s", e)
def _check(self):
"""单次探测:健康则清零计数;连续 3 次失败则拉起。"""
"""单次探测:健康则清零计数;连续 3 次失败则拉起(升级期间跳过)"""
health = self.main_app_client.health()
if health.get("ok") is True:
self._fail_count = 0
return
# 升级流程主动停的主程序,看门狗不接管,避免抢占 jar 锁
if _upgrading.is_set():
log.info("主程序不可达,但升级流程进行中,看门狗跳过拉起")
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:
@@ -129,6 +140,56 @@ def restart_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]:
return False, str(e)
def stop_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]:
"""
通过 NSSM 停止主程序服务(nssm stop 会等待服务进程退出后返回)。
用于升级流程:停止成功即文件锁释放,替换 jar 不再被 WinError 32 挡住。
nssm_path 为 nssm.exe 完整路径;service_name 为注册的 Windows 服务名。
返回 (是否成功, 输出/原因)。
"""
try:
proc = subprocess.run(
[nssm_path, "stop", service_name],
capture_output=True,
timeout=120,
)
out = ((proc.stdout or b"") + (proc.stderr or b"")).decode("utf-16", errors="replace").strip()
ok = proc.returncode == 0
if ok:
log.info("nssm stop %s 成功:%s", service_name, out or "(无输出)")
else:
log.warning("nssm stop %s 失败(exit=%s):%s", service_name, proc.returncode, out)
return ok, out
except Exception as e:
log.warning("nssm stop %s 执行异常:%s", service_name, e)
return False, str(e)
def start_main_service(nssm_path: str, service_name: str) -> tuple[bool, str]:
"""
通过 NSSM 启动主程序服务(nssm start 触发服务进入运行态,应用初始化由健康检查轮询确认)。
返回 (是否成功, 输出/原因)。
"""
try:
proc = subprocess.run(
[nssm_path, "start", service_name],
capture_output=True,
timeout=60,
)
out = ((proc.stdout or b"") + (proc.stderr or b"")).decode("utf-16", errors="replace").strip()
ok = proc.returncode == 0
if ok:
log.info("nssm start %s 成功:%s", service_name, out or "(无输出)")
else:
log.warning("nssm start %s 失败(exit=%s):%s", service_name, proc.returncode, out)
return ok, out
except Exception as e:
log.warning("nssm start %s 执行异常:%s", service_name, e)
return False, str(e)
def start_main_app(start_cmd: str) -> subprocess.Popen | None:
"""
拉起主程序。
+101 -26
View File
@@ -24,7 +24,7 @@ import time
import requests
from agent.process_guard import start_main_app
from agent.process_guard import start_main_app, stop_main_service, start_main_service, _upgrading
log = logging.getLogger(__name__)
@@ -48,10 +48,27 @@ class UpgradeCommand:
执行升级。
:param version: 新版本号(仅用于日志与上报)
:param download_url: B 端提供的 jar 下载地址(A 主动出站拉取)
:param download_url: B 端提供的下载地址:相对路径(以 / 开头,经网关下载)或完整 URL
:param sha256: 可选校验值
"""
with self._upgrade_lock:
# 置「升级中」标志:升级主动停主程序期间,看门狗跳过拉起,
# 避免新进程抢占 jar 文件锁导致替换失败
_upgrading.set()
try:
return self._do_upgrade(version, download_url, sha256)
finally:
_upgrading.clear()
def _do_upgrade(self, version: str, download_url: str, sha256: str | None) -> dict:
"""升级实际执行(由 upgrade 加锁 + 置标志后调用)。"""
# 相对路径(B 端下发的文件路径):拼 网关基础地址 + 下载路由前缀 + 文件路径
# 例:http://网关:19901 + /file/down + /hf-upgrade/20260902/xxx.jar
if download_url.startswith("/"):
if not self.config.gateway_url:
return self._fail(version, "B 端下发了相对下载路径,但 Agent 未配置 gateway.url")
prefix = "/" + self.config.download_prefix.strip("/")
download_url = self.config.gateway_url.rstrip("/") + prefix + "/" + download_url.strip("/")
log.info("开始升级到版本 %s%s", version, download_url)
self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "download"})
@@ -62,12 +79,33 @@ class UpgradeCommand:
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}")
content_type = resp.headers.get("Content-Type", "")
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))
size = os.path.getsize(tmp_jar)
# 诊断:打印响应类型与文件头(下载到错误页面/JSON 而非 jar 时一眼可见)
with open(tmp_jar, "rb") as f:
head = f.read(64)
head_preview = head[:32].decode("utf-8", errors="replace")
log.info("下载完成:%s%d 字节,Content-Type=%s,文件头=%r",
os.path.basename(tmp_jar), size, content_type, head_preview)
# jar 为 zip 格式(PK 魔数)。文件服务器下载失败常返回 200+错误 JSON/HTML
# 此处提前拦截并给出明确原因,避免误报 SHA-256 校验失败
if not head.startswith(b"PK"):
# 疑似鉴权/路径错误,读出响应体前 200 字符帮助定位
with open(tmp_jar, "rb") as f:
body_preview = f.read(200).decode("utf-8", errors="replace")
os.remove(tmp_jar)
return self._fail(
version,
f"下载内容不是 jar(PK 魔数缺失):大小={size}Content-Type={content_type}"
f"响应开头={body_preview!r}(疑似下载地址需鉴权或路径错误)",
)
# ===== 2. 校验 =====
if sha256:
@@ -81,7 +119,9 @@ class UpgradeCommand:
log.info("SHA-256 校验通过")
# ===== 3. 备份 =====
jar_file = os.path.join("bin", "hospital-front.jar")
# jar 路径来自配置(main.jar.path),适配不同部署结构
#(如 Agent 与主程序分目录、jar 文件名带版本号等)
jar_file = self.config.main_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):
@@ -110,10 +150,7 @@ class UpgradeCommand:
# ===== 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._start_main_app()
self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "healthcheck"})
if self._wait_healthy(90):
@@ -125,10 +162,7 @@ class UpgradeCommand:
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)
self._start_main_app()
if self._wait_healthy(90):
return self._fail(version, f"新版本启动失败,已自动回滚到 {os.path.basename(backup_file)} 并恢复服务")
@@ -141,8 +175,15 @@ class UpgradeCommand:
# ================= 内部步骤 =================
def _stop_main_app(self):
"""停止主程序:优先优雅(admin API),超时后按 PID 强杀;确保进程完全退出(jar 锁释放)。"""
pid = read_pid_file(os.path.join("pid", "hospital-front.pid"))
"""停止主程序:优先 nssm stop(等进程退出再返回,文件锁释放可靠);
未配置服务名时回退 admin API + PID 强杀。"""
if self.config.main_service_name:
# nssm 管理的服务:nssm stop 等待服务进程退出后返回,文件锁释放可靠
stop_main_service(self.config.nssm_path, self.config.main_service_name)
time.sleep(1) # 保险等锁释放
return
# 回退:admin API 触发优雅退出 + PID 强杀
pid = read_pid_file(self.config.main_pid)
if self.main_app_client.restart(100):
# 等待健康探测失败(最长 30 秒)
deadline = time.time() + 30
@@ -151,12 +192,23 @@ class UpgradeCommand:
break
time.sleep(1)
# 兜底:按 PID 文件强杀
kill_by_pid_file(os.path.join("pid", "hospital-front.pid"))
kill_by_pid_file(self.config.main_pid)
# 等待进程真正退出(Windows 下进程退出后才释放 jar 文件锁)
if pid is not None:
wait_process_exit(pid, 15)
time.sleep(1)
def _start_main_app(self):
"""拉起主程序:优先 nssm start(服务模式),否则回退启动命令。"""
if self.config.main_service_name:
start_main_service(self.config.nssm_path, self.config.main_service_name)
else:
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)
def _wait_healthy(self, timeout_sec: int) -> bool:
"""轮询等待主程序健康。"""
deadline = time.time() + timeout_sec
@@ -167,11 +219,10 @@ class UpgradeCommand:
return False
def _rollback(self, backup_file: str):
"""回滚:备份 jar 覆盖回 bin"""
"""回滚:备份 jar 覆盖回主程序 jar(路径来自配置)"""
try:
if os.path.exists(backup_file):
jar_file = os.path.join("bin", "hospital-front.jar")
copy_with_retry(backup_file, jar_file, 10)
copy_with_retry(backup_file, self.config.main_jar, 10)
log.info("已回滚到 %s", os.path.basename(backup_file))
except Exception as e:
log.error("回滚失败:%s", e)
@@ -209,14 +260,20 @@ def kill_by_pid_file(pid_file: str):
"""按 PID 文件强杀进程(Windows taskkill / Linux kill -9)。"""
pid = read_pid_file(pid_file)
if not pid:
log.warning("强杀跳过:PID 文件 %s 无内容或不存在", pid_file)
return
log.info("按 PID 强杀:%s(来自 %s", pid, pid_file)
try:
if os.name == "nt":
subprocess.run(["taskkill", "/PID", pid, "/T", "/F"],
r = subprocess.run(["taskkill", "/PID", pid, "/T", "/F"],
capture_output=True, timeout=15)
out = (r.stdout or b"") + (r.stderr or b"")
log.info("taskkill 退出码=%s,输出=%s", r.returncode,
out.decode("gbk", errors="replace").strip())
else:
subprocess.run(["kill", "-9", pid],
r = subprocess.run(["kill", "-9", pid],
capture_output=True, timeout=15)
log.info("kill -9 退出码=%s", r.returncode)
except Exception as e:
log.warning("PID 强杀失败:%s", e)
@@ -231,31 +288,49 @@ def wait_process_exit(pid: str, timeout_sec: int):
out = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True, timeout=10,
).stdout.decode("utf-8", errors="ignore")
).stdout.decode("gbk", 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:
log.info("进程 %s 已退出", pid)
return
time.sleep(0.5)
log.warning("等待进程 %s 退出超时(%ds),可能仍占用 jar 锁", pid, timeout_sec)
def copy_with_retry(src: str, dst: str, retries: int):
"""带重试的文件覆盖(Windows 下进程退出有延迟,锁释放需等待)。"""
"""带重试的文件覆盖(Windows 下用「改名腾位」策略绕开文件锁延迟)。
进程退出后 Windows 对 jar 的内存映射句柄释放有滞后,直接 os.remove 会持续
WinError 32。改为:先把旧 jar 改名为 .old(Windows 允许重命名正在使用的文件),
腾出原文件名写入新 jar;.old 留待进程完全退出后清理。
"""
last_error = None
for _ in range(retries):
old_name = dst + ".old"
for i in range(retries):
try:
# 清理上一次残留的 .old(若进程已退出可删;删不动也无妨,继续)
if os.path.exists(old_name):
try:
os.remove(old_name)
except Exception:
pass
# 把占用中的旧 jar 改名腾位(关键:即使文件被锁,重命名通常也能成功)
if os.path.exists(dst):
os.remove(dst)
if not os.path.exists(dst):
os.replace(dst, old_name)
# 原文件名已腾空,写入新 jar
shutil.copyfile(src, dst)
return
except Exception as e:
last_error = e
log.warning("替换重试 %d/%d 失败:%sdst=%s", i + 1, retries, e, dst)
time.sleep(1)
raise RuntimeError(f"文件替换失败(重试 {retries} 次):{os.path.basename(dst)}") from last_error
raise RuntimeError(
f"文件替换失败(重试 {retries} 次):{os.path.basename(dst)},最后错误:{last_error}"
) from last_error
def sha256_file(path: str) -> str:
+32 -18
View File
@@ -36,16 +36,16 @@ class AgentClient:
# ================= 生命周期 =================
def start(self):
"""启动:连接线程 + 心跳线程。
"""启动:连接循环线程 + 心跳线程。
注意:_connect 内部 run_forever 永久阻塞(断线重连也在其中递归),
必须放独立线程,否则心跳线程永远起不来(B 端会显示"暂无心跳数据")。
连接线程跑 _connect_loop(循环重连,非递归——递归实现会在长时间断线后
撑爆 Python 调用栈,出现 maximum recursion depth exceeded)。
首次心跳立即发送,避免网关空闲超时在首个周期内掐断连接。
"""
t_heartbeat = threading.Thread(target=self._heartbeat_loop, name="heartbeat", daemon=True)
t_heartbeat.start()
self._threads.append(t_heartbeat)
t_connect = threading.Thread(target=self._connect, name="ws-connect", daemon=True)
t_connect = threading.Thread(target=self._connect_loop, name="ws-connect", daemon=True)
t_connect.start()
self._threads.append(t_connect)
@@ -63,9 +63,33 @@ class AgentClient:
# ================= 连接管理 =================
def _connect(self):
"""建立 WebSocket 连接(阻塞直到连接断开,内部自行调度重连)。"""
# server.url 为通道基地址(以 /medical/agentSocket/ 结尾),设备号统一由 device.id 拼接
def _connect_loop(self):
"""连接循环:断线后指数退避重连(循环代替递归,避免长时间断线栈溢出)。"""
while not self._heartbeat_stop.is_set():
try:
self._connect_once()
except Exception as e:
log.warning("连接过程异常:%s", e)
# 指数退避:1s 起翻倍至 5 分钟封顶(连接成功后 on_open 清零计数)
self._reconnect_attempts += 1
attempt = self._reconnect_attempts
delay = min(1000 * (2 ** min(attempt - 1, 9)), 300_000) / 1000.0
log.info("%.0fs 后进行第 %d 次重连", delay, attempt)
if self._heartbeat_stop.wait(delay):
return
def _connect_once(self):
"""建立一次 WebSocket 连接(阻塞直到连接断开;重连由 _connect_loop 负责)。"""
# 优先用网关基础地址推导:gateway.url(http→ws) + socket.path + 设备号
# 未配置 gateway.url 时回退 server.url(以 /agentSocket/ 结尾)+ 设备号
if self.config.gateway_url:
ws_base = self.config.gateway_url.rstrip("/")
if ws_base.startswith("http://"):
ws_base = "ws://" + ws_base[len("http://"):]
elif ws_base.startswith("https://"):
ws_base = "wss://" + ws_base[len("https://"):]
url = ws_base + "/" + self.config.socket_path.strip("/") + "/" + self.config.device_id
else:
url = self.config.server_url.rstrip("/") + "/" + self.config.device_id
log.info("连接管理服务器:%s", url)
@@ -95,13 +119,13 @@ class AgentClient:
def on_close(ws, code, reason):
self._connected = False
# 区分失败类型:1000/1001 为正常关闭;其他关闭码(如 1008 策略拒绝)多为设备未注册或 token 错误
# 重连由 _connect_loop 统一调度(on_close 不再递归调度)
if code in (1000, 1001):
log.info("连接关闭:code=%s, reason=%s", code, reason)
elif reason:
log.warning("连接被服务器拒绝(设备未注册或 token 错误):code=%s, reason=%s", code, reason)
else:
log.warning("连接被服务器关闭:code=%s(设备未注册或 token 错误)", code)
self._schedule_reconnect()
def on_error(ws, error):
self._connected = False
@@ -127,16 +151,6 @@ class AgentClient:
# 阻塞运行(跳过协议层 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):
+35 -20
View File
@@ -4,19 +4,33 @@
# > \u672C\u6587\u4EF6 > \u4EE3\u7801\u5185\u7F6E\u9ED8\u8BA4\u503C
# \u670D\u52A1\u811A\u672C\u81EA\u52A8\u52A0\u8F7D conf/.env \u8BBE\u7F6E\u73AF\u5883\u53D8\u91CF\uFF1BNSSM \u7528 AppEnvironmentExtra \u8BBE\u7F6E
# ============================================================
# B \u7AEF\u7F51\u5173\u57FA\u7840\u5730\u5740\uFF08\u914D\u7F6E\u540E\uFF1AWS \u8FDE\u63A5\u5730\u5740 = \u7F51\u5173\u5730\u5740\uFF08http\u2192ws\uFF09+ socket.path + \u8BBE\u5907\u53F7\uFF1B
# \u5347\u7EA7\u4E0B\u8F7D\u5730\u5740 = \u7F51\u5173\u5730\u5740 + B \u7AEF\u4E0B\u53D1\u7684\u76F8\u5BF9\u8DEF\u5F84\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_GATEWAY_URL
gateway.url=http://10.10.10.228:19901
# WebSocket \u8DEF\u7531\u76F8\u5BF9\u8DEF\u5F84\uFF08\u914D\u5408 gateway.url \u4F7F\u7528\uFF0C\u9ED8\u8BA4 /medical/agentSocket/\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_SOCKET_PATH
socket.path=/medical/agentSocket/
# \u7F51\u5173\u6587\u4EF6\u4E0B\u8F7D\u8DEF\u7531\u524D\u7F00\uFF08\u5347\u7EA7\u5305\u4E0B\u8F7D\u5730\u5740 = gateway.url + \u6B64\u524D\u7F00 + B \u7AEF\u4E0B\u53D1\u7684\u6587\u4EF6\u8DEF\u5F84\uFF0C
# \u5982 http://10.10.10.228:19901/file/down/hf-upgrade/20260902/xxx.jar\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_DOWNLOAD_PREFIX
download.prefix=/file/down
# B \u7AEF\u7BA1\u7406\u670D\u52A1\u5668 WebSocket \u901A\u9053\u57FA\u5730\u5740\uFF08\u7ECF\u7F51\u5173\u8F6C\u53D1\uFF0C\u987B\u5E26 /medical \u670D\u52A1\u524D\u7F00\uFF0C\u4EE5 /agentSocket/ \u7ED3\u5C3E\uFF09
# \u6CE8\u610F\uFF1A\u8BBE\u5907\u53F7\u4E0D\u5199\u5728\u8FD9\u91CC\uFF0C\u8FDE\u63A5\u65F6\u81EA\u52A8\u62FC\u63A5 device.id
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_SERVER_URL
server.url=ws://10.10.10.228:19901/medical/agentSocket/
# \u53EF\u4E0D\u914D\u7F6E\uFF0C\u6709\u4E0A\u9762\u7684\u62FC\u63A5
# server.url=ws://10.10.10.228:19901/medical/agentSocket/
# \u8BBE\u5907\u7F16\u53F7\uFF08\u533B\u9662\u552F\u4E00\uFF0C\u5982 jinghe-01\uFF0C\u9700\u5148\u5728 B \u7AEF\u6CE8\u518C\uFF0C\u5426\u5219\u8FDE\u63A5\u4F1A\u88AB\u62D2\u7EDD\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_DEVICE_ID
device.id=jinghe-01
device.id=xinglongyuan-01
# \u8BBE\u5907\u9274\u6743 Token\uFF08B \u7AEF\u6CE8\u518C\u8BBE\u5907\u65F6\u4E0B\u53D1\uFF0C\u4E3A\u7A7A\u5219\u4E0D\u643A\u5E26\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_DEVICE_TOKEN
device.token=dccbd1eca07511f181d5a0369fe0bc61
device.token=bccbd1eca0232343423a0369fe0bc61
# \u4E3B\u7A0B\u5E8F\u5065\u5EB7\u68C0\u67E5\u5730\u5740\uFF08\u770B\u95E8\u72D7\u6309\u6B64\u5730\u5740\u63A2\u6D4B\u4E3B\u7A0B\u5E8F\u662F\u5426\u5B58\u6D3B\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_MAIN_HEALTH_URL
@@ -24,31 +38,32 @@ main.health.url=http://127.0.0.1:8082/api/admin/health
# \u4E3B\u7A0B\u5E8F\u7BA1\u7406\u63A5\u53E3 Token\uFF08\u987B\u4E0E\u4E3B\u7A0B\u5E8F\u914D\u7F6E\u4E2D\u7684 admin.token \u4E00\u81F4\uFF0C\u73AF\u56DE\u8C03\u7528 /api/admin \u65F6\u9274\u6743\u7528\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_MAIN_ADMIN_TOKEN
main.admin.token=
main.admin.token=a0232343423a0369fe0bc61
main.home=C:/Users/lianl/Desktop/\u524D\u7F6E\u673A\u6D4B\u8BD5/main
# \u4E3B\u7A0B\u5E8F jar \u8DEF\u5F84\uFF08\u76F8\u5BF9 Agent \u5DE5\u4F5C\u76EE\u5F55\uFF1B\u5347\u7EA7\u65F6\u5907\u4EFD/\u66FF\u6362/\u56DE\u6EDA\u90FD\u7528\u6B64\u8DEF\u5F84\u3002
# jar \u4EA7\u7269\u540D\u5DF2\u7EDF\u4E00\u4E0D\u5E26\u7248\u672C\u53F7\uFF08hospital-front.jar\uFF09\uFF0C\u6587\u4EF6\u540D\u8DE8\u7248\u672C\u4E0D\u53D8\uFF0C\u6B64\u914D\u7F6E\u4E00\u6B21\u914D\u597D\u65E0\u9700\u968F\u7248\u672C\u8C03\u6574\uFF1B
# \u5206\u76EE\u5F55\u90E8\u7F72\u65F6\u6307\u5411\u4E3B\u7A0B\u5E8F\u76EE\u5F55\uFF0C\u5982 ../main/hospital-front.jar\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_MAIN_JAR_PATH
main.jar.path=hospital-front.jar
# \u4E3B\u7A0B\u5E8F PID \u6587\u4EF6\u8DEF\u5F84\uFF08\u76F8\u5BF9 Agent \u5DE5\u4F5C\u76EE\u5F55\uFF0C\u987B\u6307\u5411\u4E3B\u7A0B\u5E8F\u5B9E\u9645\u5199\u5165\u7684 pid \u6587\u4EF6\uFF0C
# \u5347\u7EA7\u505C\u6B62\u4E3B\u7A0B\u5E8F\u65F6\u7528\u4E8E\u515C\u5E95\u5F3A\u6740\uFF1B\u5206\u76EE\u5F55\u90E8\u7F72\u65F6\u5982 ../main/pid/hospital-front.pid\uFF09
# \u5BF9\u5E94\u73AF\u5883\u53D8\u91CF\uFF1AAGENT_MAIN_PID_FILE
main.pid.file=pid/hospital-front.pid
# \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
# \u53EF\u4E0D\u914D\u7F6E
# 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
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
# \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
main.service.name=HospitalFront3