feat: 心跳修复 + 接口测试代理命令(GET_APIS/HTTP_PROXY)
- ws_client: start() 连接改为独立线程,修复心跳线程被阻塞导致 B 端'暂无心跳数据' - dispatcher: 新增 GET_APIS(返回主程序接口清单) + HTTP_PROXY(代理调用前置机接口) - main_app_client: 新增 get_apis/http_proxy,自动按 path 加鉴权(/order→Basic, /api/admin→X-Admin-Token) - config: 新增 order_basic 配置项 - process_guard: 看门狗支持 nssm restart 拉起服务(优先于 start.cmd) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -36,6 +36,7 @@ class AgentConfig:
|
|||||||
heartbeat_sec: int
|
heartbeat_sec: int
|
||||||
guard_enabled: bool
|
guard_enabled: bool
|
||||||
log_dir: str
|
log_dir: str
|
||||||
|
order_basic: str
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def load(cls, conf_path: str = "conf/agent.properties") -> "AgentConfig":
|
def load(cls, conf_path: str = "conf/agent.properties") -> "AgentConfig":
|
||||||
@@ -68,6 +69,7 @@ class AgentConfig:
|
|||||||
heartbeat_sec=int(_get(props, "heartbeat.sec", "15")),
|
heartbeat_sec=int(_get(props, "heartbeat.sec", "15")),
|
||||||
guard_enabled=_get(props, "guard.enable", "true").lower() == "true",
|
guard_enabled=_get(props, "guard.enable", "true").lower() == "true",
|
||||||
log_dir=_get(props, "log.dir", "logs"),
|
log_dir=_get(props, "log.dir", "logs"),
|
||||||
|
order_basic=_get(props, "order.basic", ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ B 端通过 WebSocket 下发 JSON 命令:{"type":"CMD_TYPE","payload":{...}}
|
|||||||
- GET_CONFIG 读取医院配置文件(payload: hospital)
|
- GET_CONFIG 读取医院配置文件(payload: hospital)
|
||||||
- SAVE_CONFIG 写回医院配置文件(payload: hospital, content)
|
- SAVE_CONFIG 写回医院配置文件(payload: hospital, content)
|
||||||
- UPGRADE 升级主程序
|
- UPGRADE 升级主程序
|
||||||
|
- GET_APIS 查询主程序 /order 接口清单
|
||||||
|
- HTTP_PROXY 代理调用前置机任意接口(穿透白名单)
|
||||||
|
|
||||||
所有命令执行结果通过 AgentClient 上行(CMD_RESULT)。
|
所有命令执行结果通过 AgentClient 上行(CMD_RESULT)。
|
||||||
"""
|
"""
|
||||||
@@ -135,6 +137,21 @@ class CommandDispatcher:
|
|||||||
).start()
|
).start()
|
||||||
result = {"ok": True, "msg": "升级已开始(异步执行,结果将通过 UPGRADE_RESULT 上报)"}
|
result = {"ok": True, "msg": "升级已开始(异步执行,结果将通过 UPGRADE_RESULT 上报)"}
|
||||||
|
|
||||||
|
elif msg_type == "GET_APIS":
|
||||||
|
# 返回主程序 /order 接口清单(供 B 端接口测试工具加载)
|
||||||
|
result = {"ok": True, "data": self.main_app_client.get_apis()}
|
||||||
|
|
||||||
|
elif msg_type == "HTTP_PROXY":
|
||||||
|
# 代理调用前置机任意接口(穿透白名单,本机环回)
|
||||||
|
p = payload or {}
|
||||||
|
result = self.main_app_client.http_proxy(
|
||||||
|
method=p.get("method") or "GET",
|
||||||
|
path=p.get("path") or "/",
|
||||||
|
query=p.get("query"),
|
||||||
|
body=p.get("body"),
|
||||||
|
extra_headers=p.get("headers"),
|
||||||
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
result = {"ok": False, "msg": f"未知命令:{msg_type}"}
|
result = {"ok": False, "msg": f"未知命令:{msg_type}"}
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,68 @@ class MainAppClient:
|
|||||||
)
|
)
|
||||||
return body is not None
|
return body is not None
|
||||||
|
|
||||||
|
def get_apis(self) -> str | None:
|
||||||
|
"""获取主程序 /order 接口清单(原始 JSON 文本)。"""
|
||||||
|
return self._get_text(self._url("/api/admin/apis"))
|
||||||
|
|
||||||
|
def http_proxy(self, method: str, path: str, query: dict | None = None,
|
||||||
|
body: str | None = None, extra_headers: dict | None = None) -> dict:
|
||||||
|
"""
|
||||||
|
代理调用前置机任意接口(本机环回,穿透白名单)。
|
||||||
|
|
||||||
|
自动按 path 加鉴权头:
|
||||||
|
- /order/** → Authorization: Basic(读 config.order_basic)
|
||||||
|
- /api/admin/** → X-Admin-Token(读 config.main_admin_token)
|
||||||
|
|
||||||
|
:return {"ok":bool, "statusCode":int, "body":str, "headers":dict, "error":str}
|
||||||
|
"""
|
||||||
|
base = self.config.main_health_url.split("/api/")[0]
|
||||||
|
url = base + path
|
||||||
|
# 拼 query
|
||||||
|
if query:
|
||||||
|
qs = "&".join(f"{quote(k)}={quote(str(v))}" for k, v in query.items() if v is not None)
|
||||||
|
if qs:
|
||||||
|
url += ("&" if "?" in url else "?") + qs
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
method_upper = method.upper()
|
||||||
|
if method_upper == "GET":
|
||||||
|
resp = self._session.get(url, headers=headers, timeout=(5, 60))
|
||||||
|
elif method_upper == "POST":
|
||||||
|
ct = headers.get("Content-Type", "application/json")
|
||||||
|
headers.setdefault("Content-Type", ct)
|
||||||
|
resp = self._session.post(url, headers=headers, data=body.encode("utf-8") if body else None, timeout=(5, 60))
|
||||||
|
elif method_upper in ("PUT", "DELETE"):
|
||||||
|
resp = self._session.request(method_upper, url, headers=headers,
|
||||||
|
data=body.encode("utf-8") if body else None, timeout=(5, 60))
|
||||||
|
else:
|
||||||
|
return {"ok": False, "error": f"不支持的 method:{method}"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"statusCode": resp.status_code,
|
||||||
|
"body": resp.text,
|
||||||
|
"headers": dict(resp.headers),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("HTTP_PROXY %s %s 失败:%s", method, path, e)
|
||||||
|
return {"ok": False, "error": str(e)}
|
||||||
|
|
||||||
# ================= 基础 HTTP =================
|
# ================= 基础 HTTP =================
|
||||||
|
|
||||||
def _url(self, path: str) -> str:
|
def _url(self, path: str) -> str:
|
||||||
|
|||||||
+12
-5
@@ -36,11 +36,18 @@ class AgentClient:
|
|||||||
# ================= 生命周期 =================
|
# ================= 生命周期 =================
|
||||||
|
|
||||||
def start(self):
|
def start(self):
|
||||||
"""启动:连接 + 心跳线程(首次心跳立即发送,避免网关空闲超时在首个周期内掐断连接)。"""
|
"""启动:连接线程 + 心跳线程。
|
||||||
self._connect()
|
|
||||||
t = threading.Thread(target=self._heartbeat_loop, name="heartbeat", daemon=True)
|
注意:_connect 内部 run_forever 永久阻塞(断线重连也在其中递归),
|
||||||
t.start()
|
必须放独立线程,否则心跳线程永远起不来(B 端会显示"暂无心跳数据")。
|
||||||
self._threads.append(t)
|
首次心跳立即发送,避免网关空闲超时在首个周期内掐断连接。
|
||||||
|
"""
|
||||||
|
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.start()
|
||||||
|
self._threads.append(t_connect)
|
||||||
|
|
||||||
def stop(self):
|
def stop(self):
|
||||||
"""停止并释放资源。"""
|
"""停止并释放资源。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user