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:
+51
-3
@@ -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.bat;Linux: 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()
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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 失败:%s(dst=%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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user