diff --git a/agent/config.py b/agent/config.py index 0978a83..c8499cf 100644 --- a/agent/config.py +++ b/agent/config.py @@ -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() diff --git a/agent/dispatcher.py b/agent/dispatcher.py index 3fa1747..b2a191f 100644 --- a/agent/dispatcher.py +++ b/agent/dispatcher.py @@ -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 {} diff --git a/agent/main_app_client.py b/agent/main_app_client.py index 853c842..5e9546c 100644 --- a/agent/main_app_client.py +++ b/agent/main_app_client.py @@ -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) diff --git a/agent/process_guard.py b/agent/process_guard.py index a428b2f..a8b7bf2 100644 --- a/agent/process_guard.py +++ b/agent/process_guard.py @@ -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: """ 拉起主程序。 diff --git a/agent/upgrade.py b/agent/upgrade.py index 1402683..8767553 100644 --- a/agent/upgrade.py +++ b/agent/upgrade.py @@ -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,101 +48,142 @@ class UpgradeCommand: 执行升级。 :param version: 新版本号(仅用于日志与上报) - :param download_url: B 端提供的 jar 下载地址(A 主动出站拉取) + :param download_url: B 端提供的下载地址:相对路径(以 / 开头,经网关下载)或完整 URL :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) + # 置「升级中」标志:升级主动停主程序期间,看门狗跳过拉起, + # 避免新进程抢占 jar 文件锁导致替换失败 + _upgrading.set() 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) + 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"}) + + # ===== 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}") + 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}") + + 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: + try: + actual = sha256_file(tmp_jar) 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) + return self._fail(version, f"SHA-256 计算异常:{e}") + if actual.lower() != sha256.lower(): os.remove(tmp_jar) - except Exception as e: - self._rollback(backup_file) - return self._fail(version, f"替换 jar 失败,已回滚:{e}") - log.info("jar 已替换") + return self._fail(version, f"SHA-256 校验失败:expected={sha256}, actual={actual}") + log.info("SHA-256 校验通过") - # ===== 6. 拉起 + 健康检查 ===== - self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "restart"}) + # ===== 3. 备份 ===== + # 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): try: - start_main_app(self.config.main_start_cmd) + shutil.copyfile(jar_file, backup_file) except Exception as e: - log.error("主程序拉起失败:%s", e) + return self._fail(version, f"备份失败:{e}") + log.info("已备份当前版本 → %s", os.path.basename(backup_file)) + self._clean_old_backups() - self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "healthcheck"}) - if self._wait_healthy(90): - log.info("升级成功:%s", version) - return {"ok": True, "version": version, "msg": "升级成功"} + # ===== 4. 停止主程序 ===== + self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "stop"}) + self._stop_main_app() + log.info("主程序已停止") - # ===== 7. 健康检查失败 → 回滚 ===== - log.error("新版本健康检查失败,自动回滚到 %s", os.path.basename(backup_file)) - self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "rollback"}) - self._stop_main_app() + # ===== 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) - 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)} 并恢复服务") + return self._fail(version, f"替换 jar 失败,已回滚:{e}") + log.info("jar 已替换") - self.client.send("ALERT", { - "level": "CRITICAL", - "msg": f"升级失败且回滚后仍不健康,请人工介入!版本={version}", - }) - return self._fail(version, "升级失败且回滚后仍不健康,请人工介入") + # ===== 6. 拉起 + 健康检查 ===== + self.client.send("UPGRADE_PROGRESS", {"version": version, "stage": "restart"}) + self._start_main_app() + + 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) + self._start_main_app() + 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")) + """停止主程序:优先 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"], - capture_output=True, timeout=15) + 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], - capture_output=True, timeout=15) + 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): - shutil.copyfile(src, dst) - return + 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: diff --git a/agent/ws_client.py b/agent/ws_client.py index 3b23cca..fca3f8d 100644 --- a/agent/ws_client.py +++ b/agent/ws_client.py @@ -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,10 +63,34 @@ class AgentClient: # ================= 连接管理 ================= - def _connect(self): - """建立 WebSocket 连接(阻塞直到连接断开,内部自行调度重连)。""" - # server.url 为通道基地址(以 /medical/agentSocket/ 结尾),设备号统一由 device.id 拼接 - url = self.config.server_url.rstrip("/") + "/" + self.config.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) headers = [ @@ -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): diff --git a/conf/agent.properties b/conf/agent.properties index 48582a4..91e5bfa 100644 --- a/conf/agent.properties +++ b/conf/agent.properties @@ -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