From 587ae6f0889719b1d6587541794296b2888014d2 Mon Sep 17 00:00:00 2001
From: wangcong <2+wangcong@noreply.192.168.50.94>
Date: Tue, 20 Oct 2026 08:34:12 +0800
Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E8=9E=8D=E5=90=88=E8=BD=AF=E4=BB=B6=E4=BB=A3=E7=A0=81=E4=BB=93?=
=?UTF-8?q?=E5=BA=93?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 14 +
README.md | 195 +++
config/service.example.properties | 13 +
docs/图片直传协商与准备取图.md | 69 +
docs/开发板图片协商联调记录.md | 86 ++
docs/本机验证记录.md | 155 +++
.../系统融合软件原型阶段建设范围及完成标准.md | 110 ++
docs/系统融合软件页面与采集接入.md | 38 +
docs/系统融合软件页面接入验证.md | 31 +
frontend/design-qa.md | 37 +
frontend/index.html | 11 +
frontend/package.json | 20 +
frontend/pnpm-lock.yaml | 1124 +++++++++++++++++
frontend/public/assets/brand.svg | 5 +
frontend/src/App.jsx | 38 +
frontend/src/ReceivePages.jsx | 18 +
frontend/src/SecondaryPages.jsx | 210 +++
frontend/src/data.mjs | 28 +
frontend/src/main.jsx | 6 +
frontend/src/secondary.css | 137 ++
frontend/src/styles.css | 10 +
frontend/src/ui.jsx | 39 +
frontend/tests/data.test.mjs | 21 +
frontend/vite.config.mjs | 9 +
pom.xml | 14 +
scripts/Activate-ConsoleCollection.ps1 | 43 +
scripts/Activate-EmbeddedImageRoute.py | 215 ++++
scripts/Apply-CollectionConsoleBridge.ps1 | 31 +
scripts/Build-Console.ps1 | 20 +
scripts/Build-Xtrh.ps1 | 18 +
scripts/Common.ps1 | 69 +
scripts/Connect-Pxhj.ps1 | 32 +
scripts/Image-Verification.Common.ps1 | 89 ++
scripts/Install-Xtrh.ps1 | 69 +
scripts/Measure-Comments.ps1 | 26 +
scripts/New-ClusterCredential.ps1 | 19 +
scripts/Observe-OriginalImage.ps1 | 207 +++
scripts/Open-Console.ps1 | 12 +
scripts/Register-XtrhStartup.ps1 | 24 +
scripts/Rollback-EmbeddedImageRoute.py | 125 ++
scripts/Stage-EmbeddedImageRoute.py | 443 +++++++
scripts/Start-Xtrh.ps1 | 40 +
scripts/Stop-Xtrh.ps1 | 11 +
scripts/Wait-OriginalImageMigration.ps1 | 44 +
.../test_embedded_image_route_staging.py | 199 +++
scripts/嵌入式图片协商部署步骤.md | 60 +
src/main/java/com/xtrh/Application.java | 18 +
.../common/config/SchedulerConfiguration.java | 20 +
.../java/com/xtrh/common/config/Settings.java | 98 ++
.../com/xtrh/common/controller/ApiErrors.java | 27 +
.../common/controller/HealthController.java | 32 +
.../com/xtrh/common/dao/NodeIdentityDao.java | 25 +
.../java/com/xtrh/common/dao/StateStore.java | 65 +
src/main/java/com/xtrh/common/util/Jsons.java | 69 +
.../xtrh/communication/client/PeerClient.java | 58 +
.../security/ServiceSecurity.java | 132 ++
.../communication/security/Signatures.java | 54 +
.../ConsoleCollectionController.java | 20 +
.../console/controller/ConsoleController.java | 46 +
.../security/ConsoleRequestPolicy.java | 34 +
.../service/ConsoleCollectionStore.java | 158 +++
.../controller/CoordinatorController.java | 31 +
.../control/service/CoordinatorService.java | 394 ++++++
.../control/service/ImageSourceRegistry.java | 65 +
.../discovery/service/CoordinatorLocator.java | 137 ++
.../com/xtrh/discovery/service/Discovery.java | 189 +++
.../com/xtrh/discovery/util/NodeAddress.java | 87 ++
.../gateway/controller/GatewayController.java | 27 +
.../xtrh/gateway/service/GatewayService.java | 432 +++++++
.../node/config/AsyncMediaConfiguration.java | 36 +
.../config/WorkerCapabilityCondition.java | 17 +
.../node/controller/NodeImageController.java | 50 +
.../node/controller/WorkerController.java | 70 +
.../xtrh/node/protocol/ImageWireDecoder.java | 76 ++
.../node/service/ImageIngressService.java | 488 +++++++
.../com/xtrh/node/service/WorkerService.java | 335 +++++
src/main/resources/application.properties | 16 +
.../resources/static/console/assets/brand.svg | 5 +
.../static/console/assets/index-CGh3g9P7.css | 1 +
.../static/console/assets/index-DHXeX6-t.js | 214 ++++
src/main/resources/static/console/index.html | 13 +
.../communication/client/PeerClientTest.java | 69 +
.../security/ServiceSecurityTest.java | 97 ++
.../controller/ConsoleControllerTest.java | 70 +
.../console/security/ConsoleSecurityTest.java | 86 ++
.../service/ConsoleCollectionStoreTest.java | 112 ++
.../xtrh/control/service/CoordinatorTest.java | 343 +++++
.../com/xtrh/gateway/service/GatewayTest.java | 230 ++++
.../node/config/WorkerCapabilityTest.java | 86 ++
.../CoordinatorWorkerIntegrationTest.java | 147 +++
.../xtrh/node/service/ImageIngressTest.java | 193 +++
.../service/OriginalImageIntegrationTest.java | 130 ++
.../com/xtrh/node/service/PxhjWireOracle.java | 87 ++
.../com/xtrh/node/service/WorkerTest.java | 152 +++
启动系统融合软件.cmd | 11 +
总控与多机融合服务开发方案.md | 644 ++++++++++
96 files changed, 10230 insertions(+)
create mode 100644 .gitignore
create mode 100644 README.md
create mode 100644 config/service.example.properties
create mode 100644 docs/图片直传协商与准备取图.md
create mode 100644 docs/开发板图片协商联调记录.md
create mode 100644 docs/本机验证记录.md
create mode 100644 docs/系统融合软件原型阶段建设范围及完成标准.md
create mode 100644 docs/系统融合软件页面与采集接入.md
create mode 100644 docs/系统融合软件页面接入验证.md
create mode 100644 frontend/design-qa.md
create mode 100644 frontend/index.html
create mode 100644 frontend/package.json
create mode 100644 frontend/pnpm-lock.yaml
create mode 100644 frontend/public/assets/brand.svg
create mode 100644 frontend/src/App.jsx
create mode 100644 frontend/src/ReceivePages.jsx
create mode 100644 frontend/src/SecondaryPages.jsx
create mode 100644 frontend/src/data.mjs
create mode 100644 frontend/src/main.jsx
create mode 100644 frontend/src/secondary.css
create mode 100644 frontend/src/styles.css
create mode 100644 frontend/src/ui.jsx
create mode 100644 frontend/tests/data.test.mjs
create mode 100644 frontend/vite.config.mjs
create mode 100644 pom.xml
create mode 100644 scripts/Activate-ConsoleCollection.ps1
create mode 100644 scripts/Activate-EmbeddedImageRoute.py
create mode 100644 scripts/Apply-CollectionConsoleBridge.ps1
create mode 100644 scripts/Build-Console.ps1
create mode 100644 scripts/Build-Xtrh.ps1
create mode 100644 scripts/Common.ps1
create mode 100644 scripts/Connect-Pxhj.ps1
create mode 100644 scripts/Image-Verification.Common.ps1
create mode 100644 scripts/Install-Xtrh.ps1
create mode 100644 scripts/Measure-Comments.ps1
create mode 100644 scripts/New-ClusterCredential.ps1
create mode 100644 scripts/Observe-OriginalImage.ps1
create mode 100644 scripts/Open-Console.ps1
create mode 100644 scripts/Register-XtrhStartup.ps1
create mode 100644 scripts/Rollback-EmbeddedImageRoute.py
create mode 100644 scripts/Stage-EmbeddedImageRoute.py
create mode 100644 scripts/Start-Xtrh.ps1
create mode 100644 scripts/Stop-Xtrh.ps1
create mode 100644 scripts/Wait-OriginalImageMigration.ps1
create mode 100644 scripts/tests/test_embedded_image_route_staging.py
create mode 100644 scripts/嵌入式图片协商部署步骤.md
create mode 100644 src/main/java/com/xtrh/Application.java
create mode 100644 src/main/java/com/xtrh/common/config/SchedulerConfiguration.java
create mode 100644 src/main/java/com/xtrh/common/config/Settings.java
create mode 100644 src/main/java/com/xtrh/common/controller/ApiErrors.java
create mode 100644 src/main/java/com/xtrh/common/controller/HealthController.java
create mode 100644 src/main/java/com/xtrh/common/dao/NodeIdentityDao.java
create mode 100644 src/main/java/com/xtrh/common/dao/StateStore.java
create mode 100644 src/main/java/com/xtrh/common/util/Jsons.java
create mode 100644 src/main/java/com/xtrh/communication/client/PeerClient.java
create mode 100644 src/main/java/com/xtrh/communication/security/ServiceSecurity.java
create mode 100644 src/main/java/com/xtrh/communication/security/Signatures.java
create mode 100644 src/main/java/com/xtrh/console/controller/ConsoleCollectionController.java
create mode 100644 src/main/java/com/xtrh/console/controller/ConsoleController.java
create mode 100644 src/main/java/com/xtrh/console/security/ConsoleRequestPolicy.java
create mode 100644 src/main/java/com/xtrh/console/service/ConsoleCollectionStore.java
create mode 100644 src/main/java/com/xtrh/control/controller/CoordinatorController.java
create mode 100644 src/main/java/com/xtrh/control/service/CoordinatorService.java
create mode 100644 src/main/java/com/xtrh/control/service/ImageSourceRegistry.java
create mode 100644 src/main/java/com/xtrh/discovery/service/CoordinatorLocator.java
create mode 100644 src/main/java/com/xtrh/discovery/service/Discovery.java
create mode 100644 src/main/java/com/xtrh/discovery/util/NodeAddress.java
create mode 100644 src/main/java/com/xtrh/gateway/controller/GatewayController.java
create mode 100644 src/main/java/com/xtrh/gateway/service/GatewayService.java
create mode 100644 src/main/java/com/xtrh/node/config/AsyncMediaConfiguration.java
create mode 100644 src/main/java/com/xtrh/node/config/WorkerCapabilityCondition.java
create mode 100644 src/main/java/com/xtrh/node/controller/NodeImageController.java
create mode 100644 src/main/java/com/xtrh/node/controller/WorkerController.java
create mode 100644 src/main/java/com/xtrh/node/protocol/ImageWireDecoder.java
create mode 100644 src/main/java/com/xtrh/node/service/ImageIngressService.java
create mode 100644 src/main/java/com/xtrh/node/service/WorkerService.java
create mode 100644 src/main/resources/application.properties
create mode 100644 src/main/resources/static/console/assets/brand.svg
create mode 100644 src/main/resources/static/console/assets/index-CGh3g9P7.css
create mode 100644 src/main/resources/static/console/assets/index-DHXeX6-t.js
create mode 100644 src/main/resources/static/console/index.html
create mode 100644 src/test/java/com/xtrh/communication/client/PeerClientTest.java
create mode 100644 src/test/java/com/xtrh/communication/security/ServiceSecurityTest.java
create mode 100644 src/test/java/com/xtrh/console/controller/ConsoleControllerTest.java
create mode 100644 src/test/java/com/xtrh/console/security/ConsoleSecurityTest.java
create mode 100644 src/test/java/com/xtrh/console/service/ConsoleCollectionStoreTest.java
create mode 100644 src/test/java/com/xtrh/control/service/CoordinatorTest.java
create mode 100644 src/test/java/com/xtrh/gateway/service/GatewayTest.java
create mode 100644 src/test/java/com/xtrh/node/config/WorkerCapabilityTest.java
create mode 100644 src/test/java/com/xtrh/node/service/CoordinatorWorkerIntegrationTest.java
create mode 100644 src/test/java/com/xtrh/node/service/ImageIngressTest.java
create mode 100644 src/test/java/com/xtrh/node/service/OriginalImageIntegrationTest.java
create mode 100644 src/test/java/com/xtrh/node/service/PxhjWireOracle.java
create mode 100644 src/test/java/com/xtrh/node/service/WorkerTest.java
create mode 100644 启动系统融合软件.cmd
create mode 100644 总控与多机融合服务开发方案.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..eeb650d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,14 @@
+/target/
+/data/
+/runtime/
+/output/
+/frontend/dist/
+node_modules/
+__pycache__/
+*.py[cod]
+*.log
+*.local.properties
+.env
+.env.*
+!.env.example
+/deployment/*.zip
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bb8ddfa
--- /dev/null
+++ b/README.md
@@ -0,0 +1,195 @@
+# XTRH 总控、采集转发与多机节点
+
+这是接入 PXHJ 正式试验流程的系统融合软件,包含 Java 后台服务和独立的只读监测页面。PXHJ 负责试验组织和业务控制;本软件通过 `/console/` 按装备查看真实采集数据,并保留同步质量、虚拟装备、融合视频、融合指标及节点状态的查看入口。未接入的指标和功能显示空状态,不提供演示数据。
+
+采集页面复用 PXHJ 已完成解密、校验、冻结装备绑定及质量判定的数据,经现有服务签名通道全量同步;不增加 UDP 接收器,不修改采集器配置。使用方式和接入边界见 [系统融合软件页面与采集接入](docs/系统融合软件页面与采集接入.md)。主节点部署更新后,可双击 `启动系统融合软件.cmd` 打开页面,默认地址为 `http://127.0.0.1:18100/console/`。
+
+底层原有演示视频任务仍用于独立验证节点调度、迁移和 PXHJ 播放链路,不作为新页面的融合视频源。采集器上传地址协商和节点原图接入已实现;仿真数据协议、仿真目标接入、图像融合和录制尚未实现。
+
+同一个 JAR 通过配置运行三种角色:
+
+| 角色 | 功能 | 安装脚本默认 HTTP 端口 |
+|---|---|---:|
+| `coordinator` | 总控并默认兼任执行节点,保存上下文与路由、分配任务、处理本机视频、汇总播放地址 | 18100 |
+| `gateway` | 接收采集器原始 UDP 字节,按登记来源转发,反向转送 PXHJ 的回执 | 18110 |
+| `worker` | 自动发现并注册到总控,领取多个任务,接收原图或生成演示视频 | 18120 |
+
+采集网关的默认 UDP 入口是 `19002`。PXHJ 的现有采集入口 `19001` 保留;同机部署时两个接收端口必须不同。每个采集器使用一个固定 UDP 转发源端口,PXHJ 中对应采集器的可信来源也必须同步更新。
+
+总控的控制与执行在**同一个 Java 进程、同一个 18100 端口、同一个稳定身份**中运行,不需要再为主电脑启动额外 worker 进程。主节点默认容量 2 路,普通子节点仍可部署在其他电脑。总控快照用 `nodeRole=COORDINATOR` 标识主节点、`WORKER` 标识子节点,PXHJ 可统一配置两种节点的路数。
+
+## 已实现的运行规则
+
+- 工作者启动后通过局域网 mDNS 发现同集群总控,认证后自动注册,不需手动添加节点。
+- 主执行节点等待自身 HTTP 端口就绪,签名校验自家总控后自动注册;同一主身份不会重复登记成普通子节点。
+- 每个部署目录持久保存节点身份;重启不会产生重复节点,启动会话变化会使旧回执失效。
+- 每台节点的容量为 0~64 路,PXHJ 可以调整;总控把准备任务也计入容量,超额任务等待。
+- 动态迁移先准备新节点,待新画面可用后切换播放地址;无空余容量时保留原输出并报告等待调整。
+- 任务来自 PXHJ 保存的试验、装备绑定和真实轮次;轮次结束撤销该轮任务,UDP 转发独立持续运行。
+- 总控使用 H2 持久化。总控重启后提高调度代次、撤销旧许可,等待 PXHJ 重新确认上下文。
+- 节点心跳失联后任务许可到期;页面不会永久保留失效播放地址。
+- 内部 HTTP 请求和响应使用共享凭证生成 HMAC 签名,含时间和一次性随机数校验;发现到的候选地址不会收到明文凭证。
+- UDP 原始字节不改变,不依赖报文文本或自造标记。未知/歧义来源丢弃,队列和内存有上限。
+- 网关保存最后确认的路由;总控暂时失联或网关重启后继续按这些路由转发。
+- 原图接入在试验准备时就可建立,不依赖正式轮次开始;采集器通过网关协商后直接上传接收节点。
+- 原图迁移先发布新旧两个目标,等新节点收到完整首帧才切换 PXHJ 取图;许可到期回退原图片上传地址。
+
+当前调度依据 PXHJ 配置的容量和节点在线状态,不会测量 CPU/GPU 负载后自动修改容量。网关现阶段按采集器来源转发不透明字节,正式协议解析仍由 PXHJ 完成;网关内的数据解析、装备状态标准化、订阅分发和独立图片分片组装尚未实现。
+
+图片由执行节点认证和组装,网关只转发协商。接入流程、配置和边界见 [图片直传协商与准备取图](docs/图片直传协商与准备取图.md)。原图接入与演示任务各计一路;新部署的协商开关默认关闭。本次已按授权为 RK3588 开启协商,实际部署与联调见 [开发板记录](docs/开发板图片协商联调记录.md)。
+
+## 代码结构
+
+按业务划分包,控制层只负责 HTTP 转接,服务层处理任务和网络业务,DAO 层只负责持久化。
+
+| 业务包 | 当前主要类与职责 |
+|---|---|
+| `control.controller` / `control.service` | `CoordinatorController` 接口转接,`CoordinatorService` 节点、上下文、任务、容量和版本调度 |
+| `gateway.controller` / `gateway.service` | `GatewayController` 网关状态接口,`GatewayService` 双向原字节 UDP、路由应用和统计 |
+| `node.controller` / `node.service` / `node.config` | 媒体和原图接口、任务执行与图片认证重组、主/子执行能力与播放线程配置 |
+| `discovery.service` / `discovery.util` | `Discovery`、`CoordinatorLocator` 发现与总控身份核验,`NodeAddress` 对外地址选择 |
+| `communication.client` / `communication.security` | `PeerClient` 签名 HTTP 通信,`Signatures` 与 `ServiceSecurity` 签名和防重放 |
+| `common.config` / `common.controller` / `common.dao` / `common.util` | `Settings`、健康和错误接口、`StateStore` H2 持久化、`NodeIdentityDao` 身份文件持久化与 `Jsons` 公共辅助 |
+
+应用入口为 `com.xtrh.Application`。测试也放在对应业务的 `service`、`security` 或 `client` 包中。节点会话、限时许可、任务切换、路由缓存及签名校验等关键逻辑保留中文注释。
+
+`StateStore.open(Path, ObjectMapper)` 在 DAO 内统一组装文件数据库地址,业务服务只指定本角色的数据文件;`NodeIdentityDao` 自动生成并复用部署实例身份,配置层不再直接维护身份文件。发现服务的广播只提供地址线索;逻辑总控冲突在远端签名认证后按 `serviceId` 判断,同一总控的多个网卡地址不会算成多个总控。
+
+注释比例按 `scripts/Measure-Comments.ps1` 统计:分母为 Java 非空源码行,分子为以注释标记开头的完整注释行,包含 Javadoc 和块注释边界,代码末尾的行内注释不计入。当前全部 XTRH 主 Java 源码为 **21.53%(594/2759)**,PXHJ 新增 `fusion` 业务源码为 **28.83%(501/1738)**。
+
+PXHJ 的新增业务同样采用 `fusion.controller`、`fusion.service`、`fusion.dao`、`fusion.config`、`fusion.security` 分层,`FusionDao` 承接 SQL,接口、业务和数据访问职责分开。
+
+## 构建和测试
+
+要求 Java 17 或更新版本、Maven 3.9。Windows 示例:
+
+```powershell
+.\scripts\Build-Xtrh.ps1 -JavaHome 'C:\Program Files\Java\jdk-25.0.2' -MavenPath 'D:\software\apache-maven-3.9.16\bin\mvn.cmd'
+```
+
+产物为 `target/xtrh-services-1.0.0.jar`。脚本先执行测试,测试失败不会返回成功。
+
+`CoordinatorTest` 验证容量预留、超额等待、迁移准备和正式切换、容量不足保留输出、会话/版本隔离、轮次结束、离线租约及总控重启后的上下文确认。`GatewayTest` 使用真实 UDP 套接字验证二进制字节保持、固定转发源端口、反向回复、未知来源拒绝、总控失联及路由恢复。`ServiceSecurityTest` 和 `PeerClientTest` 验证请求篡改、重放、过期请求、伪造响应及凭证不出现在 HTTP 头中。这些是本机自动化验证,不能替代多台物理电脑、真实采集器与网络环境验收。
+
+增加主节点执行能力后,XTRH 全部 **24 项测试通过**:原有 19 项,加 4 项角色能力条件与 1 项随机端口主进程执行集成测试。后者真实读取主端口 JPEG,检查自动注册、唯一主身份、任务就绪及结束撤销。上一阶段 PXHJ 融合后端 17 项和 1 项真实业务串联测试通过;业务串联使用真实 Spring 业务服务、正式接口层登录会话和 CSRF、隔离 H2,连接实际总控和两个子节点,验证保存绑定、动态迁移和正式轮次操作。真实 UDP 加密数据、心跳认证及中转配置回程也有针对性测试。详细范围见 [本机验证记录](docs/本机验证记录.md)。
+
+PXHJ 全后端回归和打包已完成:报告总计 392 项测试,失败 0、错误 0、跳过 30 项;跳过项包含默认不启用的实时外部服务和达梦环境测试,前述真实业务串联已另外显式运行。独立构建输出位于 `runtime/pxhj-build`,新版后端产物为 [pxhj-server-0.1.0.jar](E:/Codex/xtrh/runtime/pxhj-build/pxhj-server-0.1.0.jar)。
+
+本机多进程验证不替代多台物理电脑、真实采集器或真实融合的现场验收;上述 H2 业务串联也不代表已升级正在运行的达梦主实例。
+
+## 开发板图片直传联调(2026-10-15)
+
+已在 `192.168.7.165` RK3588 构建、部署完整 ARM 程序并开启协商,真实 PXHJ 准备页面已经取得节点原图。板上保留原采集功能、原启动器和程序内 1 GiB 日志预算。主、A、B 对外图片地址均为 `192.168.7.222`,当前容量 2/3/3。视频源是已有文件经 RTP 输入开发板,尚未进行真实摄像机或物理多机验收。
+
+联调同时修复了公开帧号被节点近期缓存淘汰后取图 404:PXHJ 先取得验签通过的完整 JPEG,才发布公开编号;短期快照限制 30 秒、4096 张和 64 MiB,预算不足可提前淘汰。后端完整测试 **411 项,失败 0、错误 0、跳过 30**,修复版已加载;详细实测与迁移状态见 [开发板图片协商联调记录](docs/开发板图片协商联调记录.md)。
+
+主节点到 A、A 到 B 两次实际原图迁移均完成。修复后的 A 到 B 页面窗口 45/45 次取得新图,浏览器 HTTP 错误为 0;旧 A 私有许可撤销后,已公布的原图编号仍可返回相同字节。当前由 B 接收原图,节点容量已恢复 2/3/3,现有准备轮次、运行轮次和冻结绑定前后核对一致。
+
+### 板上部署前的软件验证记录
+
+上传地址协商、节点原图接收与 PXHJ 准备取图代理已经实现,本机四个 XTRH 实例及 PXHJ 后端已加载新版。执行节点仍自动注册,主节点也接收原图;图片 UDP 入口实际为 19100、19120、19121,原 PXHJ 6020 保留作回退。三个执行实例在同一台电脑上,物理多机验收尚未进行。
+
+本次完整测试:XTRH 38 项全部通过;PXHJ 后端 403 项,失败和错误均为 0,跳过 30 项;嵌入式独立 Qt 验证 7 个业务用例通过,另含 init/cleanup 共 9 项,实际 C++ 与 PXHJ Java WireCodec 双向加解密通过。PXHJ 构建产物在 `runtime/pxhj-image-build`。前端构建及融合页面既有交互回归通过,页面将原图与演示画面明确区分。
+
+当前全部 XTRH Java 主源码注释 **21.53%(594/2759)**,PXHJ `fusion` 主源码 **29.46%(476/1616)**。图片许可及缓存为短期内存状态,设备密钥不会持久化。
+
+该轮软件部署后只读确认三个执行节点、一个网关在线,PXHJ 达梦缓存已同步,运行轮次数在升级前后均为 1。当时尚未构建和部署目标 ARM 板;后续板上部署已完成,见本节上方最新记录。流程和启用配置见 [图片直传协商与准备取图](docs/图片直传协商与准备取图.md)。
+
+## 上一阶段本机部署记录(2026-10-14)
+
+主总控执行能力版本已部署:`runtime/coordinator` 在 HTTP 18100 同时控制和执行,主节点容量 2;`runtime/gateway` HTTP 18110 / UDP 19002;子节点 A HTTP 18120 / 容量 2,B HTTP 18121 / 容量 3。只读签名快照确认 **3 个在线执行节点、1 个在线网关,总容量 7 路**;主节点只有一条 `COORDINATOR` 记录,身份与总控一致,子节点为 `WORKER`。子节点和网关未手工配置总控 URL。
+
+PXHJ 达梦主后端已通过私有连接配置接入总控。本次主节点执行能力升级只重启了 18100 主进程,保持原稳定身份、数据目录和配置;PXHJ 后端、前端、网关和两个子节点均未再次重启。实际 `biz_fusion_cache` 已自动更新为三个在线执行节点和一个在线网关,检查时缓存年龄 64 毫秒。PXHJ UDP 19001、图片 UDP 6020 与网关 UDP 19002 持续运行。
+
+原有一轮 `RUNNING` 保持运行,没有自动结束或修改现有试验;未给该正式试验新增演示视频配置,真实采集器上传地址和接收路径也未切换。前端进程持续运行。用户可在 PXHJ 的融合节点管理和试验组织中的节点/视频区域统一操作。隔离验证的四个 182xx 实例已按身份停止,默认端口的四个新角色继续在线。
+
+当前主、子节点均没有正式视频任务运行,实际主端口 JPEG 已在隔离随机端口集成测试中验证;未为证明功能而操作正在运行的正式试验。
+
+## 部署
+
+### 1. 为同一个集群生成一次私有凭证
+
+```powershell
+.\scripts\New-ClusterCredential.ps1 -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties'
+```
+
+凭证由安全随机数生成,并限制文件访问;脚本不会输出凭证内容。通过可信方式把这个私有文件复制给同集群的部署电脑。所有电脑使用同一份凭证,节点身份则由各自部署目录自动生成。不要将私有文件提交到仓库或普通共享目录。
+
+### 2. 安装三种角色
+
+总控和网关可以部署在同一台电脑,不同融合电脑安装 `worker`。下面以同机三个实例说明参数:
+
+```powershell
+.\scripts\Install-Xtrh.ps1 -Role coordinator -InstallRoot 'E:\Codex\xtrh\runtime\coordinator' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties'
+.\scripts\Install-Xtrh.ps1 -Role gateway -InstallRoot 'E:\Codex\xtrh\runtime\gateway' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties'
+.\scripts\Install-Xtrh.ps1 -Role worker -InstallRoot 'E:\Codex\xtrh\runtime\worker-a' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' -Capacity 2 -NodeName '融合电脑A'
+```
+
+节点默认配置中没有固定总控 URL。启动后发现总控并注册。复制整个节点的 `data` 目录到另一台电脑会复制节点身份,应每台电脑重新安装并保留各自的 `data/identity.txt`。
+
+每个安装目录包含 JAR、私有集群配置、角色配置、节点身份与 H2 数据、日志和启停脚本。重复安装时需先停止该部署实例,数据和身份不会自动删除。
+
+### 总控是否参与执行
+
+默认安装总控会写入 `fusion.coordinator-worker-enabled=true`,容量使用 `-Capacity` 参数,默认 2 路。要让某台总控只做调度,在安装命令中追加 `-CoordinatorOnly`,生成 `fusion.coordinator-worker-enabled=false`:
+
+```powershell
+.\scripts\Install-Xtrh.ps1 -Role coordinator -InstallRoot 'E:\Codex\xtrh\runtime\coordinator' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' -CoordinatorOnly
+```
+
+只调度模式保留全部控制接口,同时不加载执行服务、媒体控制器和媒体线程池。此开关只影响 coordinator,独立 worker 仍有执行能力,gateway 始终不执行视频任务。已有总控重新安装前应先停止本实例,保留原数据目录和身份,避免新建第二个主节点。
+
+### 3. 启动、停止和自动启动
+
+```powershell
+.\scripts\Start-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\coordinator' -JavaHome 'C:\Program Files\Java\jdk-25.0.2'
+.\scripts\Start-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\gateway' -JavaHome 'C:\Program Files\Java\jdk-25.0.2'
+.\scripts\Start-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\worker-a' -JavaHome 'C:\Program Files\Java\jdk-25.0.2'
+
+.\scripts\Stop-Xtrh.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\worker-a'
+
+.\scripts\Register-XtrhStartup.ps1 -InstallRoot 'E:\Codex\xtrh\runtime\worker-a' -JavaHome 'C:\Program Files\Java\jdk-25.0.2'
+```
+
+启动脚本后台隐藏运行,通过本实例的身份和 `/health` 校验启动结果。停止脚本同时核对 PID、启动时间、Java 路径和 JAR 路径,只结束本部署实例。自动启动脚本注册“当前用户登录后启动”的计划任务;使用 `-Remove` 删除该实例的自动启动。无人登录也要运行的部署,需按实际运维账号另行配置 Windows 服务或系统启动任务。
+
+### 4. 网络条件
+
+同网段需要允许 mDNS UDP `5353`,以及总控、网关和节点所用 HTTP 端口;节点播放地址必须能被 PXHJ 观看电脑访问。采集器还需能访问网关 UDP 入口,网关需能访问 PXHJ 的采集入口和路由指定的反向目标。
+
+多网卡时可以在安装参数中指定 `-DiscoveryAddress` 选择发现网卡、`-AdvertisedHost` 固定本机可访问地址。跨网段不保证组播发现,可在统一安装配置中使用 `-CoordinatorUrl 'http://固定总控域名:18100'` 兜底,仍无需在 PXHJ 中逐个添加节点。
+
+## 与 PXHJ 的对接
+
+PXHJ 后端连接总控,使用服务凭证进行内部认证;浏览器不接触服务凭证。PXHJ 试验组织页面展示自动注册的节点、配置路数、任务状态、UDP 路由和演示视频,所有操作仍遵循 PXHJ 的登录权限和试验数据范围。
+
+播放信息的当前同步流程是:节点注册对外服务地址并上报任务就绪结果 → 总控按有效任务版本生成、登记播放地址 → PXHJ 后端约每 1.5 秒通过签名 HTTP 请求读取总控快照,将播放信息和状态保存到 PXHJ 缓存表 → PXHJ 页面查询本项目后端并连接对应节点播放。迁移后任务版本和播放地址随快照更新,PXHJ 会过滤旧代次或旧修订的快照。暂未实现总控主动推送视频地址或上报回调接口。
+
+PXHJ 集成代码已接入现有正式试验页面和后端,并通过隔离 H2 中真实业务服务与实际总控、节点串联的验证。当前达梦主实例已加载新版且实际同步总控状态;RK3588 图片直传及节点迁移已有实测记录,物理多机和真实融合验收尚未完成。
+
+PXHJ 已通过 `optional:file:../config/fusion.local.properties` 加载私有连接配置。可由共享凭证生成这份文件,避免手工复制密钥:
+
+```powershell
+.\scripts\Connect-Pxhj.ps1 -PxhjRoot 'E:\pxhj' -CredentialFile 'E:\Codex\xtrh\runtime\cluster.local.properties' -CoordinatorUrl 'http://127.0.0.1:18100' -UdpTargetHost '127.0.0.1'
+```
+
+此脚本只写桥接配置,重启 PXHJ 后端后生效。跨电脑部署时,`UdpTargetHost` 填写网关能访问的 PXHJ 后端地址,观看电脑也必须能访问节点的 HTTP 地址。已有配置需确认覆盖范围后使用 `-Force`;私有配置不能提交仓库。
+
+总控控制接口包括:
+
+| 接口 | 用途 |
+|---|---|
+| `GET /control/info` | 校验总控身份、集群与调度代次 |
+| `GET /control/snapshot` | PXHJ 汇总查询节点、任务、网关、路由 |
+| `POST /control/nodes/register` | 节点自动注册 |
+| `POST /control/nodes/{id}/heartbeat` | 节点报告准备结果并领取当前任务 |
+| `PATCH /control/nodes/{id}/capacity` | PXHJ 修改容量和启用状态 |
+| `POST /control/contexts/sync` | PXHJ 下发已保存的试验及真实轮次上下文 |
+| `POST /control/image-sources/register` | PXHJ 经设备认证后请求短期原图上传许可 |
+| `POST /control/image-sources/resolve` | PXHJ 查询原图任务当前有效接收节点 |
+| `PUT /control/routes` | 下发登记的采集来源及转发目标 |
+| `POST /control/gateways/heartbeat` | 网关报告统计并领取最新路由 |
+
+媒体入口为 `/media/{taskId}/{version}/stream`,任务版本和临时媒体许可用于阻止旧任务播放;按任务类型输出演示帧或接收到的原图,真实融合输出后续接入。原图私有元数据和 JPEG 接口为 `/node/images/{taskId}/{version}/latest`、`/node/images/{taskId}/{version}/frames/{frameId}`,由 PXHJ 后端签名访问。
+
+软件安装不会自动开启现场设备协商。启用每个采集器前需核对原图片回退地址、网关端口及 PXHJ 来源登记;本次已按用户授权完成当前 RK3588 的配置和联调。
diff --git a/config/service.example.properties b/config/service.example.properties
new file mode 100644
index 0000000..37b5ae5
--- /dev/null
+++ b/config/service.example.properties
@@ -0,0 +1,13 @@
+# 示例不包含私有集群凭证。正式部署使用 scripts/Install-Xtrh.ps1 生成角色配置。
+server.address=0.0.0.0
+server.port=18120
+fusion.role=worker
+fusion.data-dir=./data
+fusion.capacity=2
+fusion.discovery-enabled=true
+# 多网卡时可指定用于 mDNS 的网卡地址。
+# fusion.discovery-address=192.168.10.21
+# 浏览器无法访问自动选择的节点地址时,可固定本机局域网地址。
+# fusion.advertised-host=192.168.10.21
+# 同网段默认自动发现总控;跨网段可由统一部署配置预置域名。
+# fusion.coordinator-url=http://fusion-control.example.internal:18100
diff --git a/docs/图片直传协商与准备取图.md b/docs/图片直传协商与准备取图.md
new file mode 100644
index 0000000..e6983a6
--- /dev/null
+++ b/docs/图片直传协商与准备取图.md
@@ -0,0 +1,69 @@
+# 图片直传协商与准备取图
+
+PXHJ 仍负责操作,采集网关负责传递协商,接收节点负责收原图。准备阶段不需要先启动正式试验,也能从接收节点取图做配置和标定。真实融合算法另行实现。
+
+```mermaid
+sequenceDiagram
+ participant D as 嵌入式采集器
+ participant G as 采集网关
+ participant P as PXHJ 后端
+ participant C as 总控服务
+ participant N as 接收节点
+ D->>G: 请求图片上传地址(加密协商)
+ G->>P: 原字节转发
+ P->>P: 按原登记来源和密钥认证
+ P->>C: 登记短期原图接入任务
+ C->>N: 心跳计划下发接收许可
+ N->>C: 图片入口已就绪
+ C-->>P: 接收节点的 IP、UDP 端口和许可期限
+ P-->>G: 加密地址回复
+ G-->>D: 回到专用协商套接字
+ D->>N: 原有加密 JPG 分片直接上传
+ P->>N: 准备页面查询原图(内部签名请求)
+ N-->>P: 原图元数据、JPEG
+ P->>P: 缓存完整验签 JPEG 后发布图片编号
+ P-->>P: 沿原 image-frames 接口供页面使用
+```
+
+## 使用流程
+
+1. 主服务、网关和节点按现有方式部署。节点自动注册时同时上报图片入口,总控的主电脑也可以接收。
+2. PXHJ 保存采集器来源和原密钥,现有 UDP 网关映射继续负责数据、心跳与协商。采集器的专用协商套接字发送地址请求。
+3. 总控把原图接入算作一路任务,和其他任务一起受每台机器配置路数限制。没有空位时回复等待,采集器继续用原图片地址。
+4. 节点准备好端口和认证许可后,下一次协商返回上传目标。采集器直接向节点上传,完整图片不经过网关。
+5. PXHJ 的准备页面继续使用原图片查询入口。后端先向当前节点取齐精确帧并验签,再发布临时图片编号;后续按编号返回同一份快照。页面不接触内部服务凭证或设备密钥。
+6. 路数调整触发迁移时,短暂给采集器两个上传目标。PXHJ 继续读旧节点;新节点收到首帧后切换,旧目标撤销。
+
+原图接入与正式试验启停独立。后续融合任务可读取这份原图接入结果,避免为准备页面再上传完整的第二份视频。目前演示任务与原图接入是两类任务,各自占用配置路数;未实现跨任务复用融合处理槽位。
+
+## 配置与端口
+
+| 项目 | 默认值 | 用途 |
+|---|---:|---|
+| 网关 UDP | 19002 | 数据转发及短小协商报文 |
+| PXHJ 采集 UDP | 19001 | 原协议解析与协商认证 |
+| PXHJ 图片 UDP | 6020 | 原图片接收、协商失败回退 |
+| 主节点图片 UDP | 19100 | 主节点 HTTP 18100 + 1000 |
+| 子节点图片 UDP | HTTP 端口 + 1000 | 各实例独立原图接收 |
+
+节点可用 `fusion.image-port` 或安装参数 `-ImagePort` 单独指定图片端口。`fusion.image-port=0` 为自动化测试的系统分配端口。多网卡环境使用 `-AdvertisedHost` 指定采集器和 PXHJ 均可访问的 IPv4,不能发布仅本机可见的地址。需要允许对应 UDP 和节点 HTTP 端口。
+
+嵌入式新增 `[image_negotiation]`:`enabled`、`gateway_host`、`gateway_port`。默认关闭,已有设备保持原上传方式;启用前确认网关映射、节点地址和网络连通。关闭或协商失败时回到 `[image_upload]` 的原上传目标。
+
+如果固定采集数据的 `fixed_json.udp_host` 改为网关,必须单独把 `image_upload.udp_host` 设置为 PXHJ 图片接收机器的 IP。它不能继续继承固定数据的网关地址,否则回退图片会投向网关的 6020,而该入口并不接收图片。
+
+## 协议与边界
+
+- 在原 PXHJ-FND/1 中加入 `0x14` 地址请求、`0x15` 地址回复;沿用原 SM4、HMAC-SM3、CRC 与采集器编号。图片 `0x12` 分片格式不变。
+- 请求携带协议版本、设备启动号、递增序号、请求号与时间;回复回显本次请求并附总控身份、代次、任务版本、有效期和最多两个目标。
+- 地址许可 8 秒,采集器约每 1.5 秒请求续期;有效 WAITING 立即回退,没有许可或旧许可到期时使用原图片上传地址。无效回复被忽略,不能延长既有许可。连续 30 秒没有认证协商释放来源任务。
+- 节点只接受当前任务许可的来源 IP、采集器编号和密钥。先验签解密,再组装图片;不完整、过期、CRC 或 JPEG 校验失败的帧不供页面使用。
+- 原密钥只在经过签名认证的后端请求和执行计划中传递,不写总控快照、数据库或浏览器响应。
+- UDP 缺片仍会丢帧;本阶段没有图片分片重传。直接上传分散了网关压力,但节点和链路的实际容量仍需用现场画面进行压测。
+- 当前每个登记采集器对应一路原图。原协议没有独立相机通道号,多相机标识需要后续明确协议扩展。
+- PXHJ 原图快照最长保存 30 秒,总计不超过 4096 张或 64 MiB,达到预算时可提前淘汰。快照权限沿用原图片入口;旧编号不会被替换成新图片。节点私有接口仍在任务撤销后拒绝旧版本。
+- 准备取图的路由查询、元数据、精确 JPEG 和一次允许的重试共享 1800 ms 预算,完整 HTTP 正文也受超时限制。仅已验签的精确帧 404 可刷新元数据重取一次。
+
+## 验证范围
+
+本机验证覆盖真实加密分片、节点收图、准备取图代理、容量和迁移、认证失败与许可撤销。2026-10-15 已在实际 RK3588 构建完整 ARM 程序、部署并开启协商,通过现有 PXHJ 准备页面取到直传原图;视频输入为已有文件经 RTP 送入真实板卡。当前结果见 [开发板图片协商联调记录](开发板图片协商联调记录.md)。真实摄像机、真实融合和多台物理电脑性能验收另行进行。
diff --git a/docs/开发板图片协商联调记录.md b/docs/开发板图片协商联调记录.md
new file mode 100644
index 0000000..301b2b8
--- /dev/null
+++ b/docs/开发板图片协商联调记录.md
@@ -0,0 +1,86 @@
+# 开发板图片协商联调记录
+
+日期:2026-10-15。本文记录实际 RK3588 开发板与现有 PXHJ 的联调。
+
+## 实际部署
+
+| 位置 | 服务 | 入口或配置 |
+|---|---|---|
+| 开发板 192.168.7.165 | ARM64 `px_JKGL` 完整程序 | 原有 RTP 视频入口 UDP 9200 |
+| 电脑 192.168.7.222 | 总控兼执行节点 | HTTP 18100 / 图片 UDP 19100,容量 2 |
+| 同一电脑 | 子节点 A | HTTP 18120 / 图片 UDP 19120,容量 3 |
+| 同一电脑 | 子节点 B | HTTP 18121 / 图片 UDP 19121,容量 3 |
+| 同一电脑 | 网关 | HTTP 18110 / 协商 UDP 19002 |
+| 同一电脑 | 原 PXHJ | 前端 5178、后端 8088、采集 UDP 19001、备用图片 UDP 6020 |
+
+板上已开启 `image_negotiation.enabled=true`,协商指向 `192.168.7.222:19002`;图片回退地址明确为 `192.168.7.222:6020`。固定数据和心跳继续使用原来的 `19001`。完整原图经协商后直接发给接收节点,网关只中转短协商报文。
+
+三个 Java 执行实例运行在同一台电脑,使用原稳定身份和数据目录;实际路数配置是 2/3/3。开发板是真实硬件,本次视频输入是已有 `1.mp4` 经 FFmpeg/RTP 送入开发板,经过板上程序解码、压缩、加密和分片。尚未进行物理摄像机、多台物理电脑、带宽容量或真实融合验收。
+
+## 程序部署与回退材料
+
+ARM 程序在目标板隔离目录构建后才激活,保留原有串口、RTP、启动器和程序内 1 GiB 日志保留功能。ARM 日志测试 9 项通过;图片协商测试 8 项通过,1 项可选 Java 互通夹具跳过。Windows 的 Java/C++ 互通验证已另行通过,板上实际协商和原图链路也已跑通。
+
+- 当前 ARM 二进制 SHA256:`6d28db56f9cf20cde843d432dcf148e7a72a3d54e91c37996a18463b0dc00972`。
+- 板上构建、候选和原文件备份目录:`/home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-20261015-013802-b11a5ace`。
+- 本机状态证据:`runtime/board-image-route/deployment-state.json`、`live-board-check.json`、`arm-artifacts/activation-result.json`。
+- 激活与回退工具见 [嵌入式部署步骤](../scripts/嵌入式图片协商部署步骤.md)。回退需要显式运行工具,未因验证通过而自动回退。
+
+没有更改设备密钥、重置节点身份或删除采集数据。PXHJ 网关登记通过已登录的正式配置接口加入当前板卡,保留原选项。
+
+## 准备阶段取图
+
+使用现有试验 `SY_20261012_000005` 的第一轮准备页,轮次仍为 `PENDING`,无需启动正式试验。现有视频采集模块为 `03`。
+
+首次主节点收图后,真实页面取得 `remote-` 图片编号,原图接口返回 JPEG 200,页面解码尺寸为 **480 × 853**。已核对来源连接、采样时间、接收时间、长度和 SHA256,排除了旧 6020 历史图片。证据在 `runtime/board-image-real-verification/preparation-main.json` 及同名截图。
+
+本次只观察准备页,没有保存视频确认、标定、试验配置或开始/结束试验。现有另一轮 `RUNNING` 保持原状态。
+
+## 主节点切换到 A
+
+通过临时调整各节点容量触发正常迁移,没有直接修改任务归属;完成后恢复 2/3/3。
+
+北京时间 **09:49:44—09:50:09** 的节点观测记录了 110 张不同新帧:主节点版本 1 为 17 张,A 节点版本 2 为 93 张。A 的第一帧接收时间为 **09:49:48.389**。新旧节点交接的两条成功帧记录相隔 232 ms,全窗口成功新帧记录最大间隔 438 ms;这是观察间隔,不是端到端延迟指标。
+
+切换后旧主节点/版本 1 的私有图片接口返回经过签名核验的 404,证明旧许可已撤销。另一个三分钟基线窗口记录了 345 张主节点与 A 的原图。两组共 455 张记录已独立核对 JPEG 解码、尺寸、字节数和 SHA256,全部通过。独立审计见 `runtime/board-image-observation/audit-first-migration.json`。
+
+首次页面 30 秒检查启动于这次切换完成之后,30/30 取得新图,只作为 A 稳定期证据;不把它当成覆盖主节点到 A 全过程的页面证据。节点观察工具会重查短暂无帧,记录并未保存每一次 HTTP 尝试,因此也不以工具退出 0 声称所有尝试都返回 200。
+
+## 联调发现的取图窗口
+
+后续 A 稳定期精确取图检查出现过 **1/30 次 404**:页面刚取得帧号,节点仅保留的最近三帧就已淘汰那一帧。该时段没有发生 A 到 B 迁移;旧文件名中含 `a-to-b` 的记录不代表第二次迁移通过。
+
+PXHJ 已改为先取得完整、验签通过的原图快照,再公布这个图片编号。缓存中的每个编号对应固定字节,节点淘汰和任务切换不再影响这份快照。缓存最长保存 30 秒,同时限制 4096 张和 64 MiB,达到预算时可以提前淘汰;权限沿用原图片入口。完整 HTTP 正文受查询预算限制;批量取图结束时也会去掉本批已被预算淘汰的编号,避免公布悬空地址。
+
+修复相关 24 项测试全部通过;全后端 **411 项,失败 0、错误 0、跳过 30 项**,已以达梦依赖打包并替换实际 PXHJ 后端。跳过项仍是原有外部环境测试,不能视为现场验收通过。构建日志为 `runtime/board-image-route/pxhj-snapshot-package.log`,测试报告在 `runtime/pxhj-image-build/surefire-reports`。修改仍位于原 `fusion.service` / `fusion.client` 分层,XTRH Java 注释占比 21.53%,PXHJ fusion 业务 28.83%。
+
+重启后重新经正式登录恢复准备页。北京时间 **10:13:52—10:14:22**,30/30 次取得新鲜远程原图及精确 JPEG 200,30 个不同 SHA;601 次 DOM 采样均有已解码图片,浏览器 HTTP 错误、页面 JS 错误和试验业务写入均为 0。20 秒前的同一公开编号重取返回 200,SHA 与当时完全相同。证据为 `preparation-snapshot-stable-a*`,旧失败窗口原样保留。
+
+## 修复后复测与 A 到 B
+
+使用随机请求号和数字 Unix 毫秒门闩联动浏览器采样与正常容量操作,避免人工启动先后差使页面漏掉切换瞬间。实际页面窗口为北京时间 **10:16:18.043—10:17:03.051**,包含以下完整交接:
+
+| 事件 | 北京时间 | 结果 |
+|---|---|---|
+| 浏览器先确认 A 原图,再启动门闩 | 10:16:18.043 | A / 版本 2;随后执行已授权容量调整 |
+| B 收到完整首帧 | 10:16:21.255 | B / 实际版本 4,图片已就绪 |
+| 观察到 B 已接管 | 10:16:21.306 | 当前原图路由切到 B |
+| 旧 A 私有接口核验 | 10:16:21.530 | 版本 2 返回已验签 404,旧许可撤销 |
+| 重新读取切换前公开图片编号 | 10:16:30.058 | 200,SHA 与切换前字节完全相同 |
+| 浏览器窗口结束 | 10:17:03.051 | 持续取得 B 新图 |
+
+45 次页面检查均为新鲜远程原图、精确 JPEG 200,45 个不同 SHA,接收时间距检查最大 357 ms。899 次 DOM 采样没有观察到空图或未解码图片,实际最大采样间隔 197 ms;浏览器 HTTP 错误为 0。这是该观察窗口的结果,不承诺任意负载和网络条件下绝对不丢帧。新证据前缀为 `runtime/board-image-real-verification/preparation-snapshot-migration-a-to-b`,包含数字门闩、逐次数据、DOM、HTTP、旧图重取及汇总。
+
+同时节点 35 秒窗口保存 157 张不同新帧:A 13 张、B 144 张。全部独立完成 JPEG 解码、尺寸、长度和 SHA256 核对;交接两条成功帧记录相隔 227 ms,全窗口最大成功新帧记录间隔 644 ms。接收和采样时间存在板卡/电脑钟差,不能用这些数值宣称网络延迟。节点证据为 `runtime/board-image-observation/real-a-to-b/summary.json`、`frames.jsonl` 和逐帧 JPG。
+
+## 最终状态
+
+- 板上协商保持开启,当前原图由 **B:192.168.7.222:19121** 接收,原图任务状态 `READY`、版本 4。
+- 主节点、A、B 和网关全部在线;容量恢复 **2/3/3**,B 占用 1 路,其他占用 0,预留为 0;没有为验证创建额外正式视频任务。
+- PXHJ 仍在现有第一轮准备页,截图见 [preparation-b.png](../runtime/board-image-real-verification/preparation-b.png)。页面原图 480 × 853,正式图片接口 200,已解码。
+- 达梦只读前后比较确认:当前准备轮次及冻结装备/采集模块绑定未变,原运行中轮次及绑定未变,仍有 1 轮运行中。正式网关路由保留为当前板卡的 31001 转发源端口,没有清除采集记录。
+- 板上程序与当前 FFmpeg 文件输入保持运行,便于继续从 PXHJ 查看;真实融合、仿真目标叠加和录像仍未实现。
+
+轮次和绑定终检在 `runtime/board-image-real-verification/trial-integrity-final.json` 与 `trial-business-invariants.json`。旧帧私有接口的 404 是许可撤销验证,和此前已修复的公开原图偶发 404 含义不同。
+
+网关最后约 73 秒只读增量检查中,接收、转发和回包各增加 48,错误增量、队列丢弃和未知来源均为 0;整个进程累计错误为 5,记录未给出逐条原因,因此不声称全程网关零错误。B 最终图片接收拒绝和队列丢弃均为 0。详细计数见 `real-a-to-b/gateway-post-migration-delta.json` 与 `node-final.json`,没有重置计数来隐藏历史记录。
diff --git a/docs/本机验证记录.md b/docs/本机验证记录.md
new file mode 100644
index 0000000..1003f70
--- /dev/null
+++ b/docs/本机验证记录.md
@@ -0,0 +1,155 @@
+# Java 后台服务本机验证记录
+
+## 图片直传协商版本:2026-10-15
+
+本次增加采集器地址协商、节点直接认证接收原 JPG 分片,以及 PXHJ 原图片 API 的私有回源代理。正式试验未开始时原图任务也可用。网关仅转发短小协商报文,图片直接上传当前接收节点;动态迁移在候选端口就绪后短暂双发,候选收到完整首帧后切换取图。
+
+| 验证 | 结果及范围 |
+|---|---|
+| XTRH 全部 Java 测试与打包 | 38 项,失败 0、错误 0、跳过 0 |
+| PXHJ 后端全回归与达梦配置打包 | 403 项,失败 0、错误 0、跳过 30;产物在 `runtime/pxhj-image-build`,测试数据使用隔离 H2 |
+| 节点原图集成 | 随机 HTTP/UDP 端口的真实主服务;未启动正式试验即可接收独立 PXHJ WireCodec oracle 生成的加密分片,HTTP JPEG 与原字节一致;匿名 401、无帧或旧版本为签名 404 |
+| PXHJ 协商和准备取图 | 新增 11 项;真实 UDP、原设备密钥及来源认证、可信中转、防重放、会话权限和 opaque 原图回源;总控/节点使用随机端口签名 HTTP 替身,此项属于分段验证 |
+| Windows 嵌入式独立组件 | Qt 5.15.2 / MSVC / OpenSSL,7 个业务用例,另含 init/cleanup 共 9 项通过;C++ 与实际 PXHJ Java WireCodec 双向互操作 |
+| 前端 | 构建和既有 `fusion-operations-ui.mjs` 回归通过;使用正式组件及 API 拦截,原图与演示预览按类型显示 |
+| 中文注释统计 | XTRH 主 Java 21.53%(594/2759);PXHJ fusion 主 Java 29.46%(476/1616) |
+
+安装脚本还在两个未启动的隔离目录验证:HTTP 18900 默认生成图片端口 19900;worker 显式 `-ImagePort 29920` 正确写入配置。部署产物 SHA256 已与本次构建逐一核对,四个 XTRH 实例及 PXHJ 均一致;匿名图片 API 返回 401,后端会话入口和前端页面返回 200。
+
+已按原稳定身份、数据目录及配置升级本机总控 18100、网关 18110/19002、节点 18120/18121 与 PXHJ 8088 后端。图片 UDP 实际绑定 19100、19120、19121。只读签名状态确认三个执行节点、一个网关在线;升级后 PXHJ 达梦缓存年龄核对值为 955 ms,在线节点 3、网关 1。原有 RUNNING 轮次升级前后都是 1;前端进程 5178 持续运行。
+
+当前节点配置容量实际为主节点 2、A 3、B 3,合计 8;这是核对时保存的管理员配置,升级没有用安装默认值覆盖。当前原图和视频运行任务为 0,设备上传路径尚未切换。实际端口和容量可以随后续操作变化。
+
+没有连接或部署 ARM 板子,也没有进行物理多机、真实图片并发容量或融合算法验收。嵌入式协商默认 disabled;启用前必须独立保留 PXHJ 原图片回退地址。Windows 独立组件及本机分段协议验证不能替代目标板和现场整链联调。
+
+变更前 XTRH 源码、原前端文件、部署前 JAR 和发布脚本备份在 `E:\CodexHome\backups\xtrh-image-negotiation-20261015`;PXHJ 本次实施后源码快照标记为 `pxhj-image-implementation`,不能当作实施前源码备份。
+
+以下为 2026-10-14 阶段的历史记录,测试数量和进程状态以本节新版本为准。
+
+验证日期:2026-10-14。工作空间:`E:\Codex\xtrh`。
+
+这份记录覆盖本项目后台服务和部署脚本。验证使用一台 Windows 电脑上的多个独立 Java 进程,以及本机 UDP 套接字。真实采集器、多台物理电脑、仿真系统和真实视频融合不在这份记录的验收范围内。
+
+当前正式视频融合、仿真数据协议与录制未实现。播放信息由节点先提交对外地址和任务就绪结果,总控登记有效播放地址,再由 PXHJ 后台通过签名轮询同步登记;主动推送回调未实现。真实 PXHJ 业务服务与实际总控、节点的业务串联使用隔离 H2 验证;经用户确认,主达梦后端现已加载新版并接通总控,但真实采集来源未切换。
+
+## 自动化测试
+
+使用 Java 25.0.2 执行 Maven,项目按 Java 17 目标编译。增加主节点执行能力后完整 `test` 共 24 项通过,失败 0、错误 0;新版已经打包并升级正式本机总控,部署结果见文末。
+
+| 测试类 | 数量 | 主要验证 |
+|---|---:|---|
+| `CoordinatorTest` | 10 | 容量预留、超额排队、准备完成后切换、容量不足保留输出、迁移目标掉线、旧会话/版本拒绝、轮次结束与 UDP 解耦、租约到期、系统时间回退、持久化恢复和上下文重确认、未确认的空路由不作为清空指令 |
+| `GatewayTest` | 3 | 1~60000 字节不透明二进制 UDP 转发、独立转发源端口、反向回执、未知来源丢弃、总控失联继续转发、重启恢复路由、未确认路由不覆盖缓存、同版本号但明确确认的空路由仍撤销原转发 |
+| `ServiceSecurityTest` | 3 | 完整请求/响应签名、一次性随机数防重放、方法/地址/参数/正文篡改拒绝、过期请求拒绝 |
+| `PeerClientTest` | 1 | HTTP 头不发送明文凭证,拒绝无签名、错误密钥、错误随机数、正文和状态不匹配的响应 |
+| `WorkerTest` | 2 | 准备转为运行后播放连接读取当前任务,延迟到达的计划不能延长过期授权 |
+| `WorkerCapabilityTest` | 4 | 总控默认有执行能力、可关闭执行能力及媒体接口/线程池、独立 worker 不受总控开关影响、gateway 无执行能力 |
+| `CoordinatorWorkerIntegrationTest` | 1 | 随机端口主进程签名自注册、唯一主身份、任务 READY、同端口实际 JPEG 解码、结束撤销、拒绝伪造主身份 |
+
+测试报告保存在构建目录 `target/surefire-reports`,该目录不提交仓库。
+
+代码已按业务及 controller / service / dao 分层,原 `Coordinator`、`Gateway`、`Worker` 对应当前的 `control.service.CoordinatorService`、`gateway.service.GatewayService`、`node.service.WorkerService`。测试放在对应 service、config、security 或 client 包内。当前报告已确认原 19 项与新增 5 项全部通过。
+
+## 主总控兼任执行节点
+
+新增集成测试在随机 HTTP 端口启动真实 Spring Boot 总控,容量 1,关闭 mDNS,使用独立临时目录和测试凭证。总控进程自身自动通过签名请求注册为唯一 `COORDINATOR` 执行节点,节点 ID 等于总控服务 ID,不需要额外执行进程。下发一条隔离的 PENDING 上下文后任务进入 READY,播放地址使用同一个实际随机端口,实际 HTTP MJPEG 首帧能由 ImageIO 解码;结束上下文后播放地址撤销。主身份冒充普通 WORKER、另一个身份登记 COORDINATOR 均被拒绝。
+
+四项能力条件测试确认:总控默认同时有执行服务、媒体控制器和媒体线程池;关闭 `fusion.coordinator-worker-enabled` 后这些组件都不加载,控制服务保留;普通 worker 即使该开关为 false 也保持执行能力;gateway 始终无视频执行组件。
+
+安装脚本在独立、未启动的测试安装目录中验证:默认总控输出执行开关 true,`-CoordinatorOnly` 输出 false,worker 使用该参数被拒绝。未启动这些安装目录的进程,也未修改正式节点容量、任务或路由。
+
+PXHJ 新增业务已按 controller / service / dao / config / security 分层,针对性验证如下:
+
+| 测试类 | 数量 | 结果与范围 |
+|---|---:|---|
+| `FusionServiceTest` | 10 | 通过;权威试验配置、节点容量、状态同步和恢复等业务规则 |
+| `FusionAccessTest` | 5 | 通过;正式接口权限、会话、CSRF 与允许访问的范围 |
+| `FusionRelayUdpIntegrationTest` | 2 | 通过;真实 UDP 接收中的加密采集报文与心跳认证,以及动态配置的中转回程地址 |
+| `FusionBusinessIntegrationTest` | 1 | 通过;真实 PXHJ 业务服务和正式接口层连接实际总控、节点的业务串联 |
+
+前三类合计 17 项,业务串联另 1 项。业务串联以显式启用方式运行,与默认常规回归分开核对。
+
+PXHJ 全后端回归和打包已在独立目录 `runtime/pxhj-build` 完成。已读取该目录的 Surefire XML 汇总:392 项测试,失败 0、错误 0、跳过 30 项。常规回归默认跳过实时外部服务和达梦等 opt-in 测试;不能把跳过项算成已验证的真实环境能力。新版产物 [pxhj-server-0.1.0.jar](E:/Codex/xtrh/runtime/pxhj-build/pxhj-server-0.1.0.jar) 已生成。
+
+回归过程中修正了已有报告测试的比较对象:对比 DAO 的原始报告事件,而不是执行 DTO 派生的 `eventParameters`;产品业务代码没有因此改动。修正后完整回归通过。
+
+## PXHJ 真实业务串联
+
+`FusionBusinessIntegrationTest` 显式连接隔离验证总控及两个独立 Java 节点。PXHJ 使用实际 Spring 业务服务与 MockMvc 正式接口层,执行登录、会话及 CSRF 校验;业务数据写入独立 H2,正式试验由既有业务服务创建,使用保存的固定方案、装备和冻结绑定。
+
+验证过程通过:按已绑定装备配置五路演示视频,准备阶段由 A 处理两路、B 处理三路;各路返回实际 MJPEG 流,读到完整 JPEG 首帧并确认首尾标记。通过正式轮次开始接口后五路进入运行状态;在 PXHJ 接口中调整为 A 一路、B 四路,一路自动迁移,版本递增,迁移后各路仍能读取 JPEG。正式结束轮次后撤销旧播放地址,远端任务也结束。清理时恢复验证节点容量和路由。
+
+这验证了真实业务实现到后台节点的链路,未启动浏览器走人工页面流程,不属于物理多机或真实视频融合验收。
+
+## 结构与注释核对
+
+节点身份文件操作已下沉到 `common.dao.NodeIdentityDao`;H2 文件地址由 `common.dao.StateStore.open(Path, ObjectMapper)` 组装。`common` 和 `discovery` 已整理格式与关键中文注释,解释身份与启动会话、持久化失败、未认证广播、多网卡地址选择、认证后总控去重以及服务根地址校验。本项为源码与统计核对,不等同于重构后的运行回归。
+
+按 `scripts/Measure-Comments.ps1 -SourceRoots @(...) -MinimumPercent 20` 运行,当前统计如下:
+
+| 目录 | 完整注释行 | 非空源码行 | 比例 |
+|---|---:|---:|---:|
+| `common` | 69 | 290 | 23.79% |
+| `discovery` | 80 | 374 | 21.39% |
+| 全部 XTRH 主 Java | 429 | 1964 | 21.84% |
+| 当前 PXHJ 新增 fusion 业务 | 370 | 1101 | 33.61% |
+
+统计包含以注释标记开头的整行注释、Javadoc 及块注释边界,不包含空行或代码末尾的行内注释。XTRH 全部主 Java 和 PXHJ 新增 fusion 业务均达到约 20% 的要求;这些数字不包含两项目的测试文件和前端文件。
+
+## 独立进程与自动发现
+
+通过部署脚本启动以下四个真实 Java 进程:
+
+| 实例 | 角色 | HTTP 端口 | 其他配置 |
+|---|---|---:|---|
+| coordinator | 总控 | 18200 | 隔离验证集群 |
+| gateway | 采集网关 | 18210 | UDP 接收入口 19202 |
+| worker-a | 节点 A | 18220 | 初始容量 2 路 |
+| worker-b | 节点 B | 18221 | 初始容量 3 路 |
+
+网关和两个节点配置中均没有 `fusion.coordinator-url`。通过 mDNS 自动发现总控,完成带签名的身份校验和自动注册;总控快照确认两个节点、一个网关在线。中文节点名称保存与上报正确。
+
+在隔离验证集群中下发五路后台演示视频任务,节点 A 处理两路、节点 B 处理三路。通过实际 HTTP 播放连接读取到 MJPEG 帧。运行中将 B 容量修改为四路、A 修改为一路,总控自动迁移任务,最终保持五路可播放,无需重启 Java 进程。
+
+上述初始验证使用明确标记的本机测试上下文;后续已由前述 PXHJ 真实业务串联补充正式权限、保存绑定和轮次关联的验证,业务数据仍限于隔离 H2。
+
+## UDP 与轮次结束
+
+在隔离网关中配置一条本机验证路由,目的端为临时 UDP 套接字,未向 PXHJ 正式采集入口发送测试报文。
+
+- 实际网关进程转发 4096 字节随机二进制报文,目的端收到的字节完全一致。
+- 目的端看到固定的独立转发源端口。
+- 目的端回复的二进制字节返回原发送套接字,返回源端口为网关入口 19202。
+- 将该测试轮次同步为 `SAVED` 后,五路媒体任务均结束;同一采集路由继续正常转发。
+
+另外,`FusionRelayUdpIntegrationTest` 在隔离 PXHJ 应用中开启真实 UDP 套接字,使用正式 `WireCodec` 生成加密采集报文与加密心跳。未登记中转来源先被拒绝;登记映射后报文被接受、样本落入 H2,原采集器连接记录与密钥关联保持不变;未知来源和错误密钥被拒绝,加密心跳独立确认在线。该类第二项验证动态配置目的地址使用已登记的中转回程,原设备地址不被修改。此处验证的是实际协议实现和本机测试套接字,未使用真实硬件采集器。
+
+## 新版本本机角色启动后的只读确认
+
+新版本已部署到本机默认端口:总控 18100、网关 HTTP 18110 / UDP 19002、节点 A 18120 / 容量 2、节点 B 18121 / 容量 3。在主后端升级前,只读签名请求 `/control/snapshot` 的响应验证通过,两个节点和一个网关在线;网关与节点配置中均没有固定总控 URL。当时节点没有运行任务,网关路由状态为等待,尚未接管真实采集来源。主后端后续接通结果见下一节。
+
+本次确认只读取状态,不下发任务、路由,不修改试验、采集器或服务进程。该默认端口集群与 `runtime/validation` 隔离验证集群使用不同私有凭证。
+
+## 达梦主实例实际接通
+
+用户明确允许立即重启后,仅重启 PXHJ 主后端并加载新版 JAR,前端进程保持运行。实际主后端 PID 为 38344,前端 PID 为 33616;后端 8088 的 `/api/auth/csrf` 与前端 5178 页面 HTTP 检查均返回 200,未登录 `/api/fusion/status` 返回 401。PXHJ UDP 19001 和图片 UDP 6020 恢复,网关 UDP 19002 继续运行。
+
+只读查询实际达梦 `biz_fusion_cache`,检查时缓存年龄 1104 毫秒,内容包含两个在线节点和一个在线网关,证明主实例已实际签名同步总控状态。原有 `RUNNING` 轮次数仍为 1,没有自动结束或修改现有试验;原端点上传和采集接收路径未切换,也没有为正在运行的正式试验新增演示视频配置。
+
+用户从 PXHJ 融合节点管理和试验组织中的节点/视频区域统一操作。主实例连接已经接通,隔离 H2 中的正式业务流程和演示视频串联已验证;真实硬件迁移、真实融合和多台物理电脑验收仍未完成。
+
+## 主总控执行能力正式部署
+
+本次仅重启总控 18100,新 PID 为 41276,保留原稳定身份、H2 状态和私有配置。PXHJ 后端 PID 38344、前端 PID 33616、网关及两个子节点均未再次重启。总控健康信息确认执行能力已开启。
+
+实际签名快照复核通过:主节点 `nodeRole=COORDINATOR`,节点 ID 等于总控服务 ID,仅有一条主记录;主节点播放基础地址为 `http://192.168.8.237:18100`,在线、容量 2。A/B 保持 `WORKER`、容量 2/3。当前合计三个在线执行节点和一个在线网关,总容量 7,运行任务和等待任务均为 0。
+
+只读达梦缓存检查时年龄 64 毫秒,在线执行节点数 3、在线网关数 1,证明 PXHJ 未重启也能同步新的主节点记录。原正式 `RUNNING` 轮次数仍为 1,未新增或变更正式试验视频任务,未调整 A/B 容量,未切换采集器上传或路由。本机主端口实际 JPEG 播放使用随机端口集成测试验证,生产总控当前没有新视频任务运行。
+
+前述两节点和 1104 毫秒缓存记录属于加入主执行能力之前的阶段;当前运行状态以本节三个执行节点为准。
+
+## 部署脚本
+
+PowerShell 脚本语法解析通过。集群凭证生成、私有文件访问限制、已有实例重新安装、四个实例启动及健康身份校验、中文 properties 转义、按进程身份停止均已实际执行。
+
+隔离验证目录 `runtime/validation` 中保留本机身份、数据库、日志与私有配置,便于复查;该目录被 `.gitignore` 排除。业务联调结束后,四个 182xx 验证实例已按身份停止;只读端口查询确认 18100、18110、18120、18121 继续监听,182xx 验证端口已释放。未验证注册登录自启任务的实际重登录行为。
diff --git a/docs/系统融合软件原型阶段建设范围及完成标准.md b/docs/系统融合软件原型阶段建设范围及完成标准.md
new file mode 100644
index 0000000..e148b94
--- /dev/null
+++ b/docs/系统融合软件原型阶段建设范围及完成标准.md
@@ -0,0 +1,110 @@
+# 系统融合软件原型阶段建设范围及完成标准
+
+## 1 原型建设目标
+
+本阶段在尚未与外部设备建立通信的条件下,围绕合同提出的外部数据处理、虚拟同步、视景融合及显示要求,建设基于模拟数据和本地视频素材的交互演示原型。原型重点完成主要功能页面、示例信息展示、基本操作流程和视频播放,用于确认界面布局、信息组织及显示方式,为后续设备接入和功能开发提供依据。
+
+原型以“页面可操作、模拟信息可查看、本地视频可播放、展示流程可演示”为阶段目标。演示不依赖外部设备连接:装备、定位、姿态、动作及状态信息使用预置样例,视频使用本地录制或测试素材,页面明确标识“原型演示”“模拟数据”或“演示视频”。
+
+本阶段范围限定为页面与本地视频展示,不包含设备通信、真实数据采集、实时视频流接入、三维场景、三维模型展示及模型动画,也不包含多传感器位姿融合、真实数据驱动的虚拟模型同步、实装与仿真画面的自动对齐及融合渲染。相关能力列入后续建设范围。
+
+## 2 原型建设内容
+
+### 2.1 外部数据处理页面演示
+
+建设装备及数据来源展示页面,以预置样例体现无人装备定位、姿态,车载探测设备姿态,以及侦察、打击设备动作状态等信息的展示位置和关联关系。
+
+页面支持选择示例装备,查看对应的模拟参数和状态。需要说明正常、异常或未接入等显示效果时,通过预设演示状态切换呈现;这些状态不来源于设备检测。数据来源显示为“模拟数据”,设备接入情况显示为“未接入”。
+
+**完成程度:** 可以选择示例装备、查看对应信息并切换预设状态;展示内容有明确的模拟标识,不依赖设备通信。
+
+### 2.2 虚拟同步功能展示
+
+建设虚拟同步相关信息的展示区域,以列表、表格和状态标签演示装备与虚拟实体对应关系,以及同步延迟、数据丢包率、实体映射状态等监控信息的页面布局。对应关系使用示例名称和关联信息,不加载或渲染三维模型。
+
+同步延迟、数据丢包率等指标默认显示“待接入”或“—”;为展示界面效果使用样例数值时,应在该区域明确标注“模拟数据”。本阶段不进行真实指标计算,也不完成统一时间基准构建、跨节点时空同步及模型实时驱动。
+
+**完成程度:** 可以演示装备选择、示例映射关系查看和监控信息查看流程,且不会将样例数值呈现为实际监测结果。
+
+### 2.3 视景与视频播放
+
+建设视景展示页面,将预置本地视频素材与示例通道关联,提供通道列表、播放、暂停、通道切换、播放状态提示及全屏查看功能,演示从选择通道到画面呈现的基本流程。
+
+页面标注“演示视频”或“本地回放”,播放状态仅反映本地素材是否正常播放。本阶段不连接摄像机或操控终端,不将本地视频播放表述为实时视频流接入或视景融合。
+
+**完成程度:** 在未连接外部设备的演示环境中,可以播放和暂停预置视频、切换示例通道并全屏查看;素材缺失或无法播放时有明确提示。
+
+### 2.4 操控席与综合态势展示
+
+操控席视景以单路演示视频为主,显示示例装备、通道及播放状态,支持全屏查看。综合态势页面采用示例装备列表、模拟状态信息和视频区域组合布局,支持选择装备后查看对应信息与本地视频。
+
+两个显示场景可复用同一本地视频素材,在演示计算机上切换布局,体现后续使用方式。本阶段不要求连接真实操控席或综合态势显示设备,其专用接口与现场显示联调列入后续建设。
+
+**完成程度:** 能够分别演示操控席视景布局和综合态势布局,选中装备与展示内容对应一致。
+
+## 3 页面及基本交互
+
+管理页面纳入现有 PXHJ 操作入口。原型拟设置以下四类页面或视图,可根据现有页面合并组织。
+
+| 页面或视图 | 主要展示内容 | 基本交互 |
+|---|---|---|
+| 运行总览 | 示例任务、装备概况、未接入标识、本地视频可用状态 | 进入装备详情或视频页面 |
+| 数据与同步状态 | 模拟定位姿态、示例实体关系、监控信息展示区域 | 选择示例装备、查看详情、切换预设状态 |
+| 视景展示 | 示例通道、本地视频画面、演示标识、播放状态 | 选择通道、播放、暂停、切换、全屏;全屏布局用于操控席演示 |
+| 综合态势展示 | 示例装备列表、模拟状态、选中装备关联的本地视频 | 选择装备、联动查看对应信息与视频、全屏展示 |
+
+页面重点展示业务信息,不要求填写真实设备地址或通信参数。各页面持续保留原型演示标识,避免切换页面或全屏后丢失素材来源说明。
+
+本阶段所有展示均采用常规页面、列表、表格、状态标签和视频区域。综合态势页面以装备信息与视频的组合展示为完成范围,三维场景、三维模型及模型动画均不纳入本阶段交付和演示。
+
+## 4 与合同功能的阶段对应关系
+
+| 合同功能 | 本阶段做到的程度 | 后续建设内容 |
+|---|---|---|
+| 外部数据处理 | 采用预置样例完成装备、定位姿态及动作状态的页面演示 | 设备通信、真实数据接收、协议适配、校验及标准化处理 |
+| 虚拟同步 | 展示示例实体关系及监控信息布局,演示查看与切换流程 | 统一时间基准、模型实时驱动、节点时空同步、真实延迟和丢包统计 |
+| 视景融合 | 使用本地视频素材完成播放和视景页面演示 | 实时视频流接入、多传感器位姿融合、仿真相机绑定、画面配准及融合渲染 |
+| 操控席及综合态势显示 | 在演示计算机上完成两类页面布局及本地视频呈现 | 正式融合结果输出、目标显示设备接口联调及显示性能验证 |
+
+上述对应关系用于明确原型阶段的建设深度。页面和播放流程的完成情况,与合同全部功能的最终实现情况分别记录。
+
+## 5 原型演示流程
+
+1. 在未连接外部设备的环境中打开原型,查看示例任务、装备和演示标识。
+2. 选择示例装备,查看模拟数据及示例实体对应关系。
+3. 切换预设状态,查看监控区域的信息布局和异常提示样式。
+4. 进入视景页面,选择关联本地素材的示例通道,演示播放、暂停、切换及全屏查看。
+5. 切换到综合态势页面,选择装备,查看对应模拟状态与演示视频。
+6. 使用素材不可用的测试通道展示播放异常提示,再切换到有效素材恢复播放。
+
+演示以页面操作和视频展示为主,不安排尚未实现的融合算法或虚实自动配准演示。
+
+## 6 阶段完成标准
+
+| 检查项 | 完成标准 |
+|---|---|
+| 页面完整性 | 约定页面或视图均可打开,主要展示区域与导航可用 |
+| 演示条件 | 未连接外部设备时,仍可完成约定的页面操作和本地视频播放 |
+| 操作流程 | 示例装备选择、详情查看、预设状态切换、视频播放与暂停、通道切换和全屏显示可以完成 |
+| 信息对应 | 所选示例装备、模拟数据和本地视频的关联一致 |
+| 数据来源标识 | 页面及全屏视图明确标识模拟数据和演示视频;真实设备接入状态为未接入 |
+| 视频播放 | 指定本地素材可播放,切换后显示对应素材;不可用时显示原因或状态 |
+| 两类显示 | 可以分别展示操控席视景布局及综合态势布局 |
+| 范围说明 | 原型功能清单明确已完成、演示展示和后续建设内容 |
+
+检查记录应注明模拟数据范围、本地视频素材、演示环境和操作结果。本阶段的完成依据为界面与交互验证,不包含设备连通性、实时采集、融合精度、同步延迟或多节点处理能力验证。
+
+## 7 原型交付内容
+
+- 原型阶段设计说明,明确建设内容及完成程度。
+- 可运行的交互演示页面和本地视频播放功能。
+- 预置模拟数据及演示视频素材清单。
+- 页面截图及基本操作说明。
+- 原型演示记录,注明模拟数据和本地视频来源。
+- 后续功能建设清单,列明完整数据处理、虚拟同步和真实视景融合等尚未实现的内容。
+
+## 8 后续建设衔接
+
+后续在已确认的页面、示例关系和显示方式基础上,先开展设备通信、真实数据与实时视频流接入,再逐步实现数据处理、统一时间基准、仿真模型驱动、传感器位姿融合、仿真相机绑定以及画面对齐和融合渲染,形成合同要求的完整功能链路。
+
+本阶段仅明确后续功能需要展示的信息项和操作入口。设备协议、接口实现及联调结果列为后续建设内容,不将页面预留位置表述为已经完成接口对接。
diff --git a/docs/系统融合软件页面与采集接入.md b/docs/系统融合软件页面与采集接入.md
new file mode 100644
index 0000000..22ead47
--- /dev/null
+++ b/docs/系统融合软件页面与采集接入.md
@@ -0,0 +1,38 @@
+# 系统融合软件页面与采集接入
+
+本次将已确认的六页设计实现为原生系统融合软件控制台。页面源码位于 `frontend/`,构建产物放入 `src/main/resources/static/console/`,随 `xtrh-services` JAR 一起部署。
+
+## 页面
+
+| 页面 | 当前数据行为 |
+|---|---|
+| 数据接收 | 按试验冻结的实装选择,查看真实采集参数、来源、采样和接收时间及质量 |
+| 同步质量监控 | 装备列表与查看框架已实现,尚未接入同步质量指标时显示空状态 |
+| 虚拟数据下发 | 虚拟装备血量、位置等只读框架;尚无虚拟装备源时为空 |
+| 融合视频展示 | 按实装选择,画面右上角保留六项融合指标;尚无真正融合源时为空 |
+| 融合指标展示 | 按实装逐行展示,未接入指标为 `—` |
+| 主从控制 | 展示已注册节点的身份、角色、心跳在线状态;未上报 CPU、内存、网络速率为 `—` |
+
+页面不会将原图、演示视频或采集参数推测成融合输出,不内置示例装备、位置、血量或曲线。
+
+## 真实采集链路
+
+`采集器 → 既有网关/采集入口 → PXHJ 认证、解密、协议解析 → 冻结装备与采集模块绑定 → 实时质量判定 → 签名快照推送 → 系统融合软件页面`
+
+PXHJ 的 `TrialFusionCollectionSnapshot` 只读取未完成的正式试验及冻结绑定。`TrialCollectionService.realLiveForFusion` 沿用原采集服务,在计算质量前排除模拟报文,固定读取实时 60 秒窗口;公开用户采集接口原有权限检查保持不变。
+
+每两秒由独立单线程 `FusionCollectionPublisher` 复用 `FusionImageClient` 和原 `app.fusion.*` 私有配置,调用签名 `POST /control/collection/sync`。共享凭证只保留在后台。新快照整体替换内存中的旧快照,空列表也同步,因此结束试验后不会残留装备。
+
+接收端使用大小、数量、字段、标量值及时间边界校验,保留真实零值,缺失使用 null。保留 `GOOD/MISSING/STALE/INVALID_TIME/INVALID_VALUE/AMBIGUOUS` 等质量;超过 15 秒无有效推送,最后值标记为过期。断开时不将旧值显示成正常接收。
+
+## 构建和使用
+
+1. 在 `frontend` 安装依赖:`pnpm install` 或 `npm install`。
+2. 运行 `scripts/Build-Xtrh.ps1`,将先测试并构建页面,再执行 Java 测试和打包。已有前端构建产物时可使用 `-SkipFrontend`。
+3. 将新 JAR 部署到既有总控,保留身份、数据和私有配置。
+4. PXHJ 桥接源码已按基线校验应用;重新打包并加载 PXHJ 后端,既有 `app.fusion.enabled` 和总控配置生效后自动推送。
+5. 从总控所在电脑打开 `http://127.0.0.1:18100/console/`。页面每两秒刷新,可搜索和选择装备;存在多个试验时可在页头切换。
+
+浏览器 API 只有同源本机 GET/HEAD 查看能力。原 `/control/*` 仍需 HMAC 签名;页面没有节点容量修改、试验控制或采集器重配置操作。
+
+PXHJ 桥接补丁与原文件校验清单保存在 `runtime/console-pxhj-staging/`;应用前会核对基线并备份原文件,避免覆盖其他改动。源码修改不会自动更新运行中的进程。
diff --git a/docs/系统融合软件页面接入验证.md b/docs/系统融合软件页面接入验证.md
new file mode 100644
index 0000000..30969be
--- /dev/null
+++ b/docs/系统融合软件页面接入验证.md
@@ -0,0 +1,31 @@
+# 系统融合软件页面接入验证
+
+日期:2026-10-16。
+
+原生页面已实现并启用,地址为 `http://127.0.0.1:18100/console/`。六页为数据接收、同步质量监控、虚拟数据下发、融合视频展示、融合指标展示、主从控制。
+
+## 软件验证
+
+- XTRH 完整回归 48 项通过,失败、错误和跳过均为 0。
+- PXHJ 完整回归 445 项,失败 0、错误 0、跳过 30 项外部环境测试。
+- 前端数据规则 3 项通过,生产构建通过。
+- 独立测试集群的真实 HTTP 签名同步、响应验签及页面联动检查 13 项通过。测试数据已清空,测试实例已关闭,没有向正式系统写入测试样本。
+- 六页的 1586px 和 1024px 桌面布局、导航、搜索、选择及空状态通过;不使用原图、DEMO 或生成图冒充融合视频。
+
+## 已加载程序
+
+获得用户“现在重启并启用”的明确授权后,PXHJ 后端 8088 与融合总控 18100 已重新加载;原有 5178 前端未停止。替换前的 JAR 保存在 `runtime/console-activation-backup-20261016-193802/`,身份、H2 状态及达梦数据保留。
+
+启动前错误地使用了 PXHJ 不提供的 `/health` 地址;部署脚本的端口保护阻止了替换。随后以 `/api/auth/csrf` 和进程 JAR 路径确认真实服务并取得明确重启授权,没有在未获准时停止 PXHJ。
+
+PXHJ 采集桥接源码已按基线哈希保护应用,原修改文件备份在 `runtime/console-bridge-backup-20261016-192330/`。部署后的两个 JAR 与已验证构建产物进行 SHA-256 一致性核对。
+
+## 正式只读观察
+
+正式 `/console/api/snapshot` 中 collection.state 为 CONNECTED,识别 5 个试验、6 台绑定装备、286 个参数,SIMULATED 数量为 0。显示的装备和参数来自正式冻结绑定,没有添加示例数据。
+
+原采集网关原先未运行,现已启动;HTTP 18110 / UDP 19002,唯一既有登记路由的目标仍为 PXHJ UDP 19001,路由状态 APPLIED。不修改采集器配置或来源匹配。
+
+检查窗口内网关未收到新报文,全部参数为 MISSING。软件链路已启用,新的有效采集样本到达后会经原解密与绑定流程刷新页面;当前没有把空值补成零或其他装备的数据。此记录不替代真实采集器运行、物理多机或真实融合视频验收。
+
+其他五页仅保留查看功能。真实节点身份与心跳可见,CPU、内存、网络速率和未接入的虚拟/融合数据均为 `—` 或空列表。
diff --git a/frontend/design-qa.md b/frontend/design-qa.md
new file mode 100644
index 0000000..3c5fbd5
--- /dev/null
+++ b/frontend/design-qa.md
@@ -0,0 +1,37 @@
+# 系统融合软件原生页面验收
+
+final result: passed
+
+日期:2026-10-16。
+
+## 视觉依据与状态
+
+设计依据为 `E:\Codex\xtrh\output\system-fusion-design-v2\01-数据接收.png` 至 `06-主从控制.png` 六张确认稿,像素均为 1586×992。当前实现遵循用户新增要求:数据接收接真实采集,其他功能先保留查看框架,不使用演示数据。因此当前无数据时的空列表、空画面、空曲线与 `—` 是预期状态,不能以设计稿内的示例值补齐。
+
+原生页面随 Java JAR 运行。最终修正版浏览器截图在 `E:\Codex\xtrh\runtime\console-ui-qa\revised\`。CSS 视口为 1586×992,deviceScaleFactor=1;截图为完整页面,不拉伸。超出一屏的空状态页面保留原始高度。另逐页检查 1024×768 视口,无整页横向溢出。
+
+## 对比与修正
+
+六页设计图与实现已经放在同一张比较图中,路径为 `runtime/console-ui-qa/comparison/*-compare.png`;另有 `receive-focus.png` 和 `nodes-focus.png` 检查品牌、字体、搜索和密集表头。
+
+第一轮发现 [P2] 侧页的实装列表列宽与确认稿不一致、部分面板内容贴边、品牌图标缺少蓝色底面。已统一桌面列宽、补齐 20px 内边距并使用原品牌 SVG 的蓝色容器。最终修正截图已重拍并重新对比;未发现待修复 P0/P1/P2。
+
+## 五项视觉检查
+
+- 字体:Microsoft YaHei / Segoe UI 回退,中文标题、表头、状态与时间可读;使用界面层级而非图片文字。
+- 布局:深蓝侧栏、白色顶栏、浅灰工作区及六项导航保持一致;装备选择与详情分区明确,指标列表为整表。
+- 色彩:使用 #172c46、#276cc0、#f4f6fa 和语义状态色;未知或未接入信息使用灰色,不显示为正常。
+- 图像:使用已提供品牌资源与图标库;无真实融合视频源时为空,不把原图、DEMO 或生成画面作为业务视频。
+- 内容:品牌统一为系统融合软件;没有旧的接收统计、输出任务配置、模拟切换或下发演示操作。
+
+## 浏览器和数据验证
+
+实际浏览器逐页验证六页导航、空状态、1024px 布局及控制台;异常数为 0。独立测试集群上验证 13 项完整链路检查:HTTP 签名推送及响应验签、无签名拒绝、模拟采样过滤、真实零值和 false、过期标记、装备筛选/选择、参数详情、融合功能为空、全量空替换。测试数据只写入独立测试集群,已清空;没有写入正式总控或 PXHJ。
+
+记录:`runtime/console-ui-qa/browser-report.json`、`revised/browser-report.json`、`interaction-report.json`。
+
+Java 回归:XTRH 48/48 通过;PXHJ 445 项,失败 0、错误 0、跳过 30。前端数据规则 3/3 通过。静态资源与两个服务 JAR 均构建通过。
+
+## 运行生效边界
+
+已在用户明确授权后重启 PXHJ 后端和融合总控并加载新版本,正式签名同步状态为 CONNECTED。当前真实冻结上下文为 5 个试验、6 台装备、286 个参数;模拟采样数量为 0。正式页面已重新逐页检查。原有采集网关已启动,登记路由保持不变,处于 APPLIED/在线状态。当前未收到新的有效采集样本,参数质量为 MISSING,页面按原值显示为空。不能据此宣称完成真实设备采样验收。CPU、内存、网络速率与其他功能数据源仍为空。
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..5e6f90e
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+ 系统融合软件
+
+
+
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..a24360a
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "xtrh-console",
+ "version": "1.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "test": "node --test tests/*.test.mjs"
+ },
+ "dependencies": {
+ "lucide-react": "1.57.0",
+ "react": "19.2.0",
+ "react-dom": "19.2.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-react": "5.0.4",
+ "vite": "6.4.2"
+ }
+}
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
new file mode 100644
index 0000000..6167de3
--- /dev/null
+++ b/frontend/pnpm-lock.yaml
@@ -0,0 +1,1124 @@
+lockfileVersion: '9.0'
+
+settings:
+ autoInstallPeers: true
+ excludeLinksFromLockfile: false
+
+importers:
+
+ .:
+ dependencies:
+ lucide-react:
+ specifier: 1.57.0
+ version: 1.57.0(react@19.2.0)
+ react:
+ specifier: 19.2.0
+ version: 19.2.0
+ react-dom:
+ specifier: 19.2.0
+ version: 19.2.0(react@19.2.0)
+ devDependencies:
+ '@vitejs/plugin-react':
+ specifier: 5.0.4
+ version: 5.0.4(vite@6.4.2)
+ vite:
+ specifier: 6.4.2
+ version: 6.4.2
+
+packages:
+
+ '@babel/code-frame@7.29.7':
+ resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/compat-data@7.29.7':
+ resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/core@7.29.7':
+ resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/generator@7.29.8':
+ resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-compilation-targets@7.29.7':
+ resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-globals@7.29.7':
+ resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-imports@7.29.7':
+ resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-module-transforms@7.29.7':
+ resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0
+
+ '@babel/helper-plugin-utils@7.29.7':
+ resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-string-parser@7.29.7':
+ resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-identifier@7.29.7':
+ resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helper-validator-option@7.29.7':
+ resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/helpers@7.29.10':
+ resolution: {integrity: sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/parser@7.29.9':
+ resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7':
+ resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7':
+ resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==}
+ engines: {node: '>=6.9.0'}
+ peerDependencies:
+ '@babel/core': ^7.0.0-0
+
+ '@babel/template@7.29.7':
+ resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/traverse@7.29.10':
+ resolution: {integrity: sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==}
+ engines: {node: '>=6.9.0'}
+
+ '@babel/types@7.29.8':
+ resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==}
+ engines: {node: '>=6.9.0'}
+
+ '@esbuild/aix-ppc64@0.25.12':
+ resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@esbuild/android-arm64@0.25.12':
+ resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [android]
+
+ '@esbuild/android-arm@0.25.12':
+ resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [android]
+
+ '@esbuild/android-x64@0.25.12':
+ resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [android]
+
+ '@esbuild/darwin-arm64@0.25.12':
+ resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@esbuild/darwin-x64@0.25.12':
+ resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [darwin]
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@esbuild/freebsd-x64@0.25.12':
+ resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@esbuild/linux-arm64@0.25.12':
+ resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [linux]
+
+ '@esbuild/linux-arm@0.25.12':
+ resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==}
+ engines: {node: '>=18'}
+ cpu: [arm]
+ os: [linux]
+
+ '@esbuild/linux-ia32@0.25.12':
+ resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [linux]
+
+ '@esbuild/linux-loong64@0.25.12':
+ resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==}
+ engines: {node: '>=18'}
+ cpu: [loong64]
+ os: [linux]
+
+ '@esbuild/linux-mips64el@0.25.12':
+ resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==}
+ engines: {node: '>=18'}
+ cpu: [mips64el]
+ os: [linux]
+
+ '@esbuild/linux-ppc64@0.25.12':
+ resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==}
+ engines: {node: '>=18'}
+ cpu: [ppc64]
+ os: [linux]
+
+ '@esbuild/linux-riscv64@0.25.12':
+ resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==}
+ engines: {node: '>=18'}
+ cpu: [riscv64]
+ os: [linux]
+
+ '@esbuild/linux-s390x@0.25.12':
+ resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==}
+ engines: {node: '>=18'}
+ cpu: [s390x]
+ os: [linux]
+
+ '@esbuild/linux-x64@0.25.12':
+ resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [linux]
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [netbsd]
+
+ '@esbuild/netbsd-x64@0.25.12':
+ resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [netbsd]
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openbsd]
+
+ '@esbuild/openbsd-x64@0.25.12':
+ resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@esbuild/sunos-x64@0.25.12':
+ resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [sunos]
+
+ '@esbuild/win32-arm64@0.25.12':
+ resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==}
+ engines: {node: '>=18'}
+ cpu: [arm64]
+ os: [win32]
+
+ '@esbuild/win32-ia32@0.25.12':
+ resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==}
+ engines: {node: '>=18'}
+ cpu: [ia32]
+ os: [win32]
+
+ '@esbuild/win32-x64@0.25.12':
+ resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==}
+ engines: {node: '>=18'}
+ cpu: [x64]
+ os: [win32]
+
+ '@jridgewell/gen-mapping@0.3.13':
+ resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
+
+ '@jridgewell/remapping@2.3.5':
+ resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==}
+
+ '@jridgewell/resolve-uri@3.1.2':
+ resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
+ engines: {node: '>=6.0.0'}
+
+ '@jridgewell/sourcemap-codec@1.6.0':
+ resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
+ engines: {node: ^22.20 || ^24.12 || >=25}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/pluginutils@1.0.0-beta.38':
+ resolution: {integrity: sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==}
+
+ '@rollup/rollup-android-arm-eabi@4.64.7':
+ resolution: {integrity: sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==}
+ cpu: [arm]
+ os: [android]
+
+ '@rollup/rollup-android-arm64@4.64.7':
+ resolution: {integrity: sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==}
+ cpu: [arm64]
+ os: [android]
+
+ '@rollup/rollup-darwin-arm64@4.64.7':
+ resolution: {integrity: sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rollup/rollup-darwin-x64@4.64.7':
+ resolution: {integrity: sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rollup/rollup-freebsd-arm64@4.64.7':
+ resolution: {integrity: sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==}
+ cpu: [arm64]
+ os: [freebsd]
+
+ '@rollup/rollup-freebsd-x64@4.64.7':
+ resolution: {integrity: sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.7':
+ resolution: {integrity: sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==}
+ cpu: [arm]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.7':
+ resolution: {integrity: sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==}
+ cpu: [arm]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.7':
+ resolution: {integrity: sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-arm64-musl@4.64.7':
+ resolution: {integrity: sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.7':
+ resolution: {integrity: sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-loong64-musl@4.64.7':
+ resolution: {integrity: sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==}
+ cpu: [loong64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.7':
+ resolution: {integrity: sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.7':
+ resolution: {integrity: sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.7':
+ resolution: {integrity: sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.7':
+ resolution: {integrity: sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==}
+ cpu: [riscv64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.7':
+ resolution: {integrity: sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-gnu@4.64.7':
+ resolution: {integrity: sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rollup/rollup-linux-x64-musl@4.64.7':
+ resolution: {integrity: sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rollup/rollup-openbsd-x64@4.64.7':
+ resolution: {integrity: sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==}
+ cpu: [x64]
+ os: [openbsd]
+
+ '@rollup/rollup-openharmony-arm64@4.64.7':
+ resolution: {integrity: sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.7':
+ resolution: {integrity: sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.7':
+ resolution: {integrity: sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==}
+ cpu: [ia32]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-gnu@4.64.7':
+ resolution: {integrity: sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==}
+ cpu: [x64]
+ os: [win32]
+
+ '@rollup/rollup-win32-x64-msvc@4.64.7':
+ resolution: {integrity: sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==}
+ cpu: [x64]
+ os: [win32]
+
+ '@types/babel__core@7.20.5':
+ resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
+
+ '@types/babel__generator@7.27.0':
+ resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==}
+
+ '@types/babel__template@7.4.4':
+ resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==}
+
+ '@types/babel__traverse@7.28.0':
+ resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
+
+ '@types/estree@1.0.9':
+ resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
+
+ '@vitejs/plugin-react@5.0.4':
+ resolution: {integrity: sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
+
+ baseline-browser-mapping@2.11.28:
+ resolution: {integrity: sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==}
+ engines: {node: '>=6.0.0'}
+ hasBin: true
+
+ browserslist@4.29.3:
+ resolution: {integrity: sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==}
+ engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
+ hasBin: true
+
+ caniuse-lite@1.0.30001816:
+ resolution: {integrity: sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==}
+
+ convert-source-map@2.0.0:
+ resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
+
+ debug@4.4.3:
+ resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+ engines: {node: '>=6.0'}
+ peerDependencies:
+ supports-color: '*'
+ peerDependenciesMeta:
+ supports-color:
+ optional: true
+
+ electron-to-chromium@1.5.456:
+ resolution: {integrity: sha512-8hboqI6xesozteFb8aa7OGknvxUtTeRJLz0FoePysUEh/oWbddykIGdgpEJRbPRg/nIMfBf2DFos+H2MtvJl2A==}
+
+ esbuild@0.25.12:
+ resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ escalade@3.2.0:
+ resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
+ engines: {node: '>=6'}
+
+ fdir@6.5.0:
+ resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
+ engines: {node: '>=12.0.0'}
+ peerDependencies:
+ picomatch: ^3 || ^4
+ peerDependenciesMeta:
+ picomatch:
+ optional: true
+
+ fsevents@2.3.3:
+ resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
+ gensync@1.0.0-beta.2:
+ resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
+ engines: {node: '>=6.9.0'}
+
+ js-tokens@4.0.0:
+ resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+
+ jsesc@3.1.0:
+ resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ json5@2.2.3:
+ resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
+ engines: {node: '>=6'}
+ hasBin: true
+
+ lru-cache@5.1.1:
+ resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
+
+ lucide-react@1.57.0:
+ resolution: {integrity: sha512-bXFZEucYhAzLD62Jse76AbTR6LiSLTn3Tqir2f1n8rZrWAdLodYy7jnRZSPIT89dRLk/IseMOUWG82wJtTyTTw==}
+ peerDependencies:
+ '@types/react': '*'
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+
+ ms@2.1.3:
+ resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+ nanoid@3.3.20:
+ resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==}
+ engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
+ hasBin: true
+
+ node-releases@2.0.59:
+ resolution: {integrity: sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==}
+ engines: {node: '>=18'}
+
+ picocolors@1.1.1:
+ resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
+
+ picomatch@4.0.7:
+ resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
+ engines: {node: '>=12'}
+
+ postcss@8.5.30:
+ resolution: {integrity: sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==}
+ engines: {node: ^10 || ^12 || >=14}
+
+ react-dom@19.2.0:
+ resolution: {integrity: sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==}
+ peerDependencies:
+ react: ^19.2.0
+
+ react-refresh@0.17.0:
+ resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==}
+ engines: {node: '>=0.10.0'}
+
+ react@19.2.0:
+ resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==}
+ engines: {node: '>=0.10.0'}
+
+ rollup@4.64.7:
+ resolution: {integrity: sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==}
+ engines: {node: '>=18.0.0', npm: '>=8.0.0'}
+ hasBin: true
+
+ scheduler@0.27.0:
+ resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
+
+ semver@6.3.1:
+ resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
+ hasBin: true
+
+ source-map-js@1.2.2:
+ resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==}
+ engines: {node: '>=0.10.0'}
+
+ tinyglobby@0.2.17:
+ resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
+ engines: {node: '>=12.0.0'}
+
+ update-browserslist-db@1.3.4:
+ resolution: {integrity: sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==}
+ hasBin: true
+ peerDependencies:
+ browserslist: '>= 4.21.0'
+
+ vite@6.4.2:
+ resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ jiti: '>=1.21.0'
+ less: '*'
+ lightningcss: ^1.21.0
+ sass: '*'
+ sass-embedded: '*'
+ stylus: '*'
+ sugarss: '*'
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ lightningcss:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ yallist@3.1.1:
+ resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
+
+snapshots:
+
+ '@babel/code-frame@7.29.7':
+ dependencies:
+ '@babel/helper-validator-identifier': 7.29.7
+ js-tokens: 4.0.0
+ picocolors: 1.1.1
+
+ '@babel/compat-data@7.29.7': {}
+
+ '@babel/core@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-compilation-targets': 7.29.7
+ '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7)
+ '@babel/helpers': 7.29.10
+ '@babel/parser': 7.29.9
+ '@babel/template': 7.29.7
+ '@babel/traverse': 7.29.10
+ '@babel/types': 7.29.8
+ '@jridgewell/remapping': 2.3.5
+ convert-source-map: 2.0.0
+ debug: 4.4.3
+ gensync: 1.0.0-beta.2
+ json5: 2.2.3
+ semver: 6.3.1
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/generator@7.29.8':
+ dependencies:
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+ jsesc: 3.1.0
+
+ '@babel/helper-compilation-targets@7.29.7':
+ dependencies:
+ '@babel/compat-data': 7.29.7
+ '@babel/helper-validator-option': 7.29.7
+ browserslist: 4.29.3
+ lru-cache: 5.1.1
+ semver: 6.3.1
+
+ '@babel/helper-globals@7.29.7': {}
+
+ '@babel/helper-module-imports@7.29.7':
+ dependencies:
+ '@babel/traverse': 7.29.10
+ '@babel/types': 7.29.8
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-module-imports': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+ '@babel/traverse': 7.29.10
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/helper-plugin-utils@7.29.7': {}
+
+ '@babel/helper-string-parser@7.29.7': {}
+
+ '@babel/helper-validator-identifier@7.29.7': {}
+
+ '@babel/helper-validator-option@7.29.7': {}
+
+ '@babel/helpers@7.29.10':
+ dependencies:
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+
+ '@babel/parser@7.29.9':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/helper-plugin-utils': 7.29.7
+
+ '@babel/template@7.29.7':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+
+ '@babel/traverse@7.29.10':
+ dependencies:
+ '@babel/code-frame': 7.29.7
+ '@babel/generator': 7.29.8
+ '@babel/helper-globals': 7.29.7
+ '@babel/parser': 7.29.9
+ '@babel/template': 7.29.7
+ '@babel/types': 7.29.8
+ debug: 4.4.3
+ transitivePeerDependencies:
+ - supports-color
+
+ '@babel/types@7.29.8':
+ dependencies:
+ '@babel/helper-string-parser': 7.29.7
+ '@babel/helper-validator-identifier': 7.29.7
+
+ '@esbuild/aix-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/android-arm@0.25.12':
+ optional: true
+
+ '@esbuild/android-x64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/darwin-x64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/freebsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-arm@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/linux-loong64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-mips64el@0.25.12':
+ optional: true
+
+ '@esbuild/linux-ppc64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-riscv64@0.25.12':
+ optional: true
+
+ '@esbuild/linux-s390x@0.25.12':
+ optional: true
+
+ '@esbuild/linux-x64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/netbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/openbsd-x64@0.25.12':
+ optional: true
+
+ '@esbuild/openharmony-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/sunos-x64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-arm64@0.25.12':
+ optional: true
+
+ '@esbuild/win32-ia32@0.25.12':
+ optional: true
+
+ '@esbuild/win32-x64@0.25.12':
+ optional: true
+
+ '@jridgewell/gen-mapping@0.3.13':
+ dependencies:
+ '@jridgewell/sourcemap-codec': 1.6.0
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/remapping@2.3.5':
+ dependencies:
+ '@jridgewell/gen-mapping': 0.3.13
+ '@jridgewell/trace-mapping': 0.3.31
+
+ '@jridgewell/resolve-uri@3.1.2': {}
+
+ '@jridgewell/sourcemap-codec@1.6.0': {}
+
+ '@jridgewell/trace-mapping@0.3.31':
+ dependencies:
+ '@jridgewell/resolve-uri': 3.1.2
+ '@jridgewell/sourcemap-codec': 1.6.0
+
+ '@napi-rs/lzma-linux-x64-gnu@1.5.1':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-beta.38': {}
+
+ '@rollup/rollup-android-arm-eabi@4.64.7':
+ optional: true
+
+ '@rollup/rollup-android-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-darwin-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-darwin-x64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-freebsd-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-freebsd-x64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm-gnueabihf@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm-musleabihf@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-arm64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-loong64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-ppc64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-riscv64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-s390x-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-x64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-linux-x64-musl@4.64.7':
+ optional: true
+
+ '@rollup/rollup-openbsd-x64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-openharmony-arm64@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-arm64-msvc@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-ia32-msvc@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-x64-gnu@4.64.7':
+ optional: true
+
+ '@rollup/rollup-win32-x64-msvc@4.64.7':
+ optional: true
+
+ '@types/babel__core@7.20.5':
+ dependencies:
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+ '@types/babel__generator': 7.27.0
+ '@types/babel__template': 7.4.4
+ '@types/babel__traverse': 7.28.0
+
+ '@types/babel__generator@7.27.0':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@types/babel__template@7.4.4':
+ dependencies:
+ '@babel/parser': 7.29.9
+ '@babel/types': 7.29.8
+
+ '@types/babel__traverse@7.28.0':
+ dependencies:
+ '@babel/types': 7.29.8
+
+ '@types/estree@1.0.9': {}
+
+ '@vitejs/plugin-react@5.0.4(vite@6.4.2)':
+ dependencies:
+ '@babel/core': 7.29.7
+ '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7)
+ '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7)
+ '@rolldown/pluginutils': 1.0.0-beta.38
+ '@types/babel__core': 7.20.5
+ react-refresh: 0.17.0
+ vite: 6.4.2
+ transitivePeerDependencies:
+ - supports-color
+
+ baseline-browser-mapping@2.11.28: {}
+
+ browserslist@4.29.3:
+ dependencies:
+ baseline-browser-mapping: 2.11.28
+ caniuse-lite: 1.0.30001816
+ electron-to-chromium: 1.5.456
+ node-releases: 2.0.59
+ update-browserslist-db: 1.3.4(browserslist@4.29.3)
+
+ caniuse-lite@1.0.30001816: {}
+
+ convert-source-map@2.0.0: {}
+
+ debug@4.4.3:
+ dependencies:
+ ms: 2.1.3
+
+ electron-to-chromium@1.5.456: {}
+
+ esbuild@0.25.12:
+ optionalDependencies:
+ '@esbuild/aix-ppc64': 0.25.12
+ '@esbuild/android-arm': 0.25.12
+ '@esbuild/android-arm64': 0.25.12
+ '@esbuild/android-x64': 0.25.12
+ '@esbuild/darwin-arm64': 0.25.12
+ '@esbuild/darwin-x64': 0.25.12
+ '@esbuild/freebsd-arm64': 0.25.12
+ '@esbuild/freebsd-x64': 0.25.12
+ '@esbuild/linux-arm': 0.25.12
+ '@esbuild/linux-arm64': 0.25.12
+ '@esbuild/linux-ia32': 0.25.12
+ '@esbuild/linux-loong64': 0.25.12
+ '@esbuild/linux-mips64el': 0.25.12
+ '@esbuild/linux-ppc64': 0.25.12
+ '@esbuild/linux-riscv64': 0.25.12
+ '@esbuild/linux-s390x': 0.25.12
+ '@esbuild/linux-x64': 0.25.12
+ '@esbuild/netbsd-arm64': 0.25.12
+ '@esbuild/netbsd-x64': 0.25.12
+ '@esbuild/openbsd-arm64': 0.25.12
+ '@esbuild/openbsd-x64': 0.25.12
+ '@esbuild/openharmony-arm64': 0.25.12
+ '@esbuild/sunos-x64': 0.25.12
+ '@esbuild/win32-arm64': 0.25.12
+ '@esbuild/win32-ia32': 0.25.12
+ '@esbuild/win32-x64': 0.25.12
+
+ escalade@3.2.0: {}
+
+ fdir@6.5.0(picomatch@4.0.7):
+ optionalDependencies:
+ picomatch: 4.0.7
+
+ fsevents@2.3.3:
+ optional: true
+
+ gensync@1.0.0-beta.2: {}
+
+ js-tokens@4.0.0: {}
+
+ jsesc@3.1.0: {}
+
+ json5@2.2.3: {}
+
+ lru-cache@5.1.1:
+ dependencies:
+ yallist: 3.1.1
+
+ lucide-react@1.57.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+
+ ms@2.1.3: {}
+
+ nanoid@3.3.20: {}
+
+ node-releases@2.0.59: {}
+
+ picocolors@1.1.1: {}
+
+ picomatch@4.0.7: {}
+
+ postcss@8.5.30:
+ dependencies:
+ nanoid: 3.3.20
+ picocolors: 1.1.1
+ source-map-js: 1.2.2
+
+ react-dom@19.2.0(react@19.2.0):
+ dependencies:
+ react: 19.2.0
+ scheduler: 0.27.0
+
+ react-refresh@0.17.0: {}
+
+ react@19.2.0: {}
+
+ rollup@4.64.7:
+ dependencies:
+ '@types/estree': 1.0.9
+ optionalDependencies:
+ '@napi-rs/lzma-linux-x64-gnu': 1.5.1
+ '@rollup/rollup-android-arm-eabi': 4.64.7
+ '@rollup/rollup-android-arm64': 4.64.7
+ '@rollup/rollup-darwin-arm64': 4.64.7
+ '@rollup/rollup-darwin-x64': 4.64.7
+ '@rollup/rollup-freebsd-arm64': 4.64.7
+ '@rollup/rollup-freebsd-x64': 4.64.7
+ '@rollup/rollup-linux-arm-gnueabihf': 4.64.7
+ '@rollup/rollup-linux-arm-musleabihf': 4.64.7
+ '@rollup/rollup-linux-arm64-gnu': 4.64.7
+ '@rollup/rollup-linux-arm64-musl': 4.64.7
+ '@rollup/rollup-linux-loong64-gnu': 4.64.7
+ '@rollup/rollup-linux-loong64-musl': 4.64.7
+ '@rollup/rollup-linux-ppc64-gnu': 4.64.7
+ '@rollup/rollup-linux-ppc64-musl': 4.64.7
+ '@rollup/rollup-linux-riscv64-gnu': 4.64.7
+ '@rollup/rollup-linux-riscv64-musl': 4.64.7
+ '@rollup/rollup-linux-s390x-gnu': 4.64.7
+ '@rollup/rollup-linux-x64-gnu': 4.64.7
+ '@rollup/rollup-linux-x64-musl': 4.64.7
+ '@rollup/rollup-openbsd-x64': 4.64.7
+ '@rollup/rollup-openharmony-arm64': 4.64.7
+ '@rollup/rollup-win32-arm64-msvc': 4.64.7
+ '@rollup/rollup-win32-ia32-msvc': 4.64.7
+ '@rollup/rollup-win32-x64-gnu': 4.64.7
+ '@rollup/rollup-win32-x64-msvc': 4.64.7
+ fsevents: 2.3.3
+
+ scheduler@0.27.0: {}
+
+ semver@6.3.1: {}
+
+ source-map-js@1.2.2: {}
+
+ tinyglobby@0.2.17:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+
+ update-browserslist-db@1.3.4(browserslist@4.29.3):
+ dependencies:
+ browserslist: 4.29.3
+ escalade: 3.2.0
+ picocolors: 1.1.1
+
+ vite@6.4.2:
+ dependencies:
+ esbuild: 0.25.12
+ fdir: 6.5.0(picomatch@4.0.7)
+ picomatch: 4.0.7
+ postcss: 8.5.30
+ rollup: 4.64.7
+ tinyglobby: 0.2.17
+ optionalDependencies:
+ fsevents: 2.3.3
+
+ yallist@3.1.1: {}
diff --git a/frontend/public/assets/brand.svg b/frontend/public/assets/brand.svg
new file mode 100644
index 0000000..2019af3
--- /dev/null
+++ b/frontend/public/assets/brand.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
new file mode 100644
index 0000000..535aa07
--- /dev/null
+++ b/frontend/src/App.jsx
@@ -0,0 +1,38 @@
+import React,{useCallback,useEffect,useMemo,useState} from 'react';
+import {Radio,Activity,Send,MonitorPlay,ChartNoAxesColumn,Network,ChevronRight,RefreshCw,PanelLeftClose,Gauge,AlertCircle} from 'lucide-react';
+import {Status} from './ui';
+import {realEquipment,collectionState,effectiveEquipment} from './data.mjs';
+import {ReceivePage,SyncPage} from './ReceivePages';
+import {VirtualPage,VideoPage,MetricsPage,NodesPage} from './SecondaryPages';
+const PAGES=[
+ {id:'receive',name:'数据接收',description:'按装备查看接收数据与当前状态',icon:Radio},
+ {id:'sync',name:'同步质量监控',description:'按装备查看同步状态与质量指标',icon:Activity},
+ {id:'virtual',name:'虚拟数据下发',description:'查看虚拟装备的血量、位置与运行状态',icon:Send},
+ {id:'video',name:'融合视频展示',description:'按实装查看融合视频及当前融合指标',icon:MonitorPlay},
+ {id:'metrics',name:'融合指标展示',description:'以装备列表查看各实装的融合指标',icon:ChartNoAxesColumn},
+ {id:'nodes',name:'主从控制',description:'查看各节点的 CPU、内存和网络运行状态',icon:Network},
+];
+const emptySnapshot={collection:{state:'WAITING',contexts:[]},nodes:[]};
+const currentRoute=()=>PAGES.find(item=>item.id===location.hash.slice(1))?.id||'receive';
+
+export function App(){
+ const [pageId,setPageId]=useState(currentRoute),[snapshot,setSnapshot]=useState(emptySnapshot);
+ const [contextId,setContextId]=useState(''),[selectedId,setSelectedId]=useState('');
+ const [failed,setFailed]=useState(false),[loading,setLoading]=useState(true),[refreshKey,setRefreshKey]=useState(0),[collapsed,setCollapsed]=useState(false),[now,setNow]=useState(Date.now());
+ useEffect(()=>{const handler=()=>{setPageId(currentRoute());window.scrollTo(0,0);};window.addEventListener('hashchange',handler);return()=>window.removeEventListener('hashchange',handler);},[]);
+ useEffect(()=>{const timer=setInterval(()=>setNow(Date.now()),1000);return()=>clearInterval(timer);},[]);
+ useEffect(()=>{let closed=false,timer,controller;
+ const poll=async()=>{controller=new AbortController();const timeout=setTimeout(()=>controller.abort(),6000);try{const response=await fetch('/console/api/snapshot',{signal:controller.signal,credentials:'same-origin',cache:'no-store',headers:{Accept:'application/json'}});if(!response.ok)throw new Error('snapshot');const body=await response.json();if(!body||!Array.isArray(body.collection?.contexts)||!Array.isArray(body.nodes))throw new Error('shape');if(!closed){setSnapshot(body);setFailed(false);}}catch{if(!closed)setFailed(true);}finally{clearTimeout(timeout);if(!closed){setLoading(false);timer=setTimeout(poll,2000);}}};poll();return()=>{closed=true;clearTimeout(timer);controller?.abort();};
+ },[refreshKey]);
+ const state=collectionState(snapshot.collection,failed);
+ const contexts=snapshot.collection.contexts||[];
+ const context=contexts.find(item=>item.id===contextId)||contexts[0];
+ const equipments=useMemo(()=>realEquipment(context).map(item=>effectiveEquipment(item,state)),[context,state]);
+ const selected=equipments.find(item=>item.id===selectedId)?.id||equipments[0]?.id||'';
+ const page=PAGES.find(item=>item.id===pageId);
+ useEffect(()=>{document.title=`${page.name} · 系统融合软件`;},[page.name]);
+ const onSelect=useCallback(id=>setSelectedId(id),[]);
+ const props={equipments,selectedId:selected,onSelect};
+ const date=new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(new Date(now));
+ return {page.name}
{page.description}
{contexts.length>0&&}
{(failed||state==='STALE')&&{failed?'服务暂时不可用,当前显示最后一次收到的数据。':'采集数据同步已超时,当前显示最后一次收到的数据。'} }{page.id==='receive'&&}{page.id==='sync'&&}{page.id==='virtual'&&}{page.id==='video'&&}{page.id==='metrics'&&}{page.id==='nodes'&&} ;
+}
diff --git a/frontend/src/ReceivePages.jsx b/frontend/src/ReceivePages.jsx
new file mode 100644
index 0000000..0997cb4
--- /dev/null
+++ b/frontend/src/ReceivePages.jsx
@@ -0,0 +1,18 @@
+import React,{useEffect,useState} from 'react';
+import {Activity,Radio} from 'lucide-react';
+import {Panel,Empty,EquipmentList,EquipmentHeader,Status,formatTime} from './ui';
+import {displayValue} from './data.mjs';
+
+const typeNames={NUMBER:'数值数据',STATE:'状态数据',IMAGE:'图像数据'};
+export function ReceivePage({equipments,selectedId,onSelect}) {
+ const equipment=equipments.find(item=>item.id===selectedId);
+ const items=equipment?.items||[];
+ const [itemKey,setItemKey]=useState('');
+ useEffect(()=>setItemKey(''),[selectedId]);
+ const selected=items.find(item=>item.key===itemKey)||items[0];
+ return {equipment?<>
{equipment.participantCode||''}}>| 数据类型 / 参数 | 最新数据 | 接收时间 | 接收状态 |
{items.map((item,index)=>setItemKey(item.key)} tabIndex={0} aria-selected={selected===item} onKeyDown={event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();setItemKey(item.key);}}}>| {item.label||item.key}{typeNames[item.type]||item.type||'采集数据'} | {displayValue(item.value,item.unit)} | | |
)}
{!items.length&&}{selected?- 当前值
- {displayValue(selected.value,selected.unit)}
- 数据来源
- {selected.sourceName||'—'}
- 采样时间
- {formatTime(selected.sampleTime)}
- 接收时间
- {formatTime(selected.receivedAt)}
- 数据质量
:}>:} ;
+}
+export function SyncPage({equipments,selectedId,onSelect}) {
+ const equipment=equipments.find(item=>item.id===selectedId);
+ return {equipment&&
}
{[['同步延迟','ms'],['数据丢包率','%'],['同步偏差','ms']].map(([name,unit])=>
)}
;
+}
diff --git a/frontend/src/SecondaryPages.jsx b/frontend/src/SecondaryPages.jsx
new file mode 100644
index 0000000..bb379be
--- /dev/null
+++ b/frontend/src/SecondaryPages.jsx
@@ -0,0 +1,210 @@
+import React, { useMemo, useState } from 'react';
+import {
+ Activity, ArrowDownToLine, ArrowUpFromLine, Cpu, HeartPulse,
+ Maximize, MonitorPlay, Network, Pause, Search, Server, Volume2,
+} from 'lucide-react';
+import { Empty, EquipmentHeader, EquipmentIcon, EquipmentList, Panel } from './ui';
+import './secondary.css';
+
+const FUSION_METRICS = [
+ ['帧率', 'fps'], ['码率', 'Mbps'], ['丢帧率', '%'],
+ ['同步偏差', 'ms'], ['网络带宽', 'Mbps'], ['运行时长', 'min'],
+];
+
+function equipmentId(equipment) {
+ return String(equipment?.equipmentId ?? equipment?.id ?? equipment?.code ?? '');
+}
+
+function equipmentName(equipment) {
+ return equipment?.equipmentName || equipment?.name || equipment?.code || equipmentId(equipment) || '未命名装备';
+}
+
+function equipmentCode(equipment) {
+ return equipment?.equipmentCode || equipment?.code || equipmentId(equipment) || '—';
+}
+
+function SearchField({ value, onChange, placeholder, label }) {
+ return ;
+}
+
+function BlankValue({ unit }) {
+ return —{unit && {unit}};
+}
+
+function UnavailableTag({ children }) {
+ return {children};
+}
+
+function TableEmpty({ colSpan, title, description, icon }) {
+ return |
+
+ |
;
+}
+
+export function VirtualPage() {
+ const [query, setQuery] = useState('');
+
+ return
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {[
+ ['经度', '°'], ['速度', 'm/s'],
+ ['纬度', '°'], ['航向角', '°'],
+ ['高程', 'm'], ['动作状态', ''],
+ ].map(([label, unit]) =>
+ {label}
+
)}
+
+
+
+
+
+
+
+ | 虚拟装备 | 血量 | 位置(经度 / 纬度) | 高程 | 状态 |
+
+
+
+
+
+
;
+}
+
+export function VideoPage({ equipments = [], selectedId, onSelect }) {
+ const selected = equipments.find((equipment) => equipmentId(equipment) === String(selectedId ?? ''));
+
+ return
+
+
+ {selected
+ ?
+ :
}
+
+
+
+
+
{selected ? '暂无融合视频' : '请选择实装'}
+
{selected ? '当前装备尚无可用的融合视频源。' : '选择装备后查看对应的融合画面。'}
+
+
+
+
{selected ? '暂无画面' : '未选择装备'}
+
+
+
+
+
分辨率 —
+
+
+
+
+
+
;
+}
+
+export function MetricsPage({ equipments = [] }) {
+ const [query, setQuery] = useState('');
+ const filtered = useMemo(() => {
+ const value = query.trim().toLocaleLowerCase();
+ return value ? equipments.filter((equipment) => `${equipmentName(equipment)} ${equipmentCode(equipment)}`.toLocaleLowerCase().includes(value)) : equipments;
+ }, [equipments, query]);
+
+ return
+
+
+
+
+
+
+ | 装备名称 | {FUSION_METRICS.map(([label, unit]) => {label}({unit}) | )}状态 |
+
+ {filtered.length ? filtered.map((equipment, index) =>
+ {equipmentName(equipment)}{equipmentCode(equipment)} |
+ {FUSION_METRICS.map(([label]) => | )}
+ 暂无指标 |
+
) : }
+
+
+
+ 未接入融合指标的装备显示为 —。
+
+
可在融合视频展示页查看对应装备的画面与融合指标。
+
;
+}
+
+function NodeStatus({ node }) {
+ const state = node.online === true ? 'online' : node.online === false ? 'offline' : 'unknown';
+ return {state === 'online' ? '在线' : state === 'offline' ? '离线' : '状态未知'};
+}
+
+function nodeRole(node) {
+ return node.nodeRole === 'COORDINATOR' ? '主节点' : node.nodeRole === 'WORKER' ? '从节点' : '—';
+}
+
+export function NodesPage({ nodes = [] }) {
+ const [query, setQuery] = useState('');
+ const filtered = useMemo(() => {
+ const value = query.trim().toLocaleLowerCase();
+ return value ? nodes.filter((node) => `${node.name || ''} ${node.nodeId || ''}`.toLocaleLowerCase().includes(value)) : nodes;
+ }, [nodes, query]);
+
+ return
+
+
+
+
+
+ | 节点名称 | 角色 | CPU 使用率 | 内存使用情况 | 网络接收 | 网络发送 | 运行状态 |
+ {filtered.length ? filtered.map((node, index) =>
+ {node.name || node.nodeId || '未命名节点'}{node.nodeId || '—'} |
+ {nodeRole(node)} |
+ | | | |
+ |
+
) : }
+
+
+
+ 节点心跳连接状态
+ {filtered.length ? {filtered.map((node, index) =>
+
{node.name || node.nodeId || '未命名节点'}{node.online === true ? '心跳在线' : node.online === false ? '心跳中断' : '暂无心跳状态'}
+
)}
: 暂无节点连接信息
}
+
+ 资源使用情况与网络速率未上报时显示为 —。
+ ;
+}
diff --git a/frontend/src/data.mjs b/frontend/src/data.mjs
new file mode 100644
index 0000000..0b97d90
--- /dev/null
+++ b/frontend/src/data.mjs
@@ -0,0 +1,28 @@
+const simulated = item => item?.simulated === true || /SIMULATED|模拟|演示/i.test([item?.quality,item?.sourceType,item?.sourceName].join(' '));
+
+export function realEquipment(context) {
+ return (Array.isArray(context?.equipment) ? context.equipment : []).filter(item => !simulated(item)).map(item => ({
+ ...item,
+ id: String(item.id || item.equipmentId || ''),
+ items: (Array.isArray(item.items) ? item.items : []).filter(value => !simulated(value)),
+ })).filter(item => item.id);
+}
+
+export function displayValue(value, unit = '') {
+ if (value === null || value === undefined || value === '') return '—';
+ if (typeof value === 'object') return '—';
+ const text = typeof value === 'boolean' ? (value ? '是' : '否') : String(value);
+ return unit ? `${text} ${unit}` : text;
+}
+
+export function collectionState(collection, failed) {
+ if (failed) return 'ERROR';
+ return ['CONNECTED','STALE','WAITING'].includes(collection?.state) ? collection.state : 'WAITING';
+}
+
+export function effectiveEquipment(equipment, state) {
+ if (!equipment) return undefined;
+ if (state === 'CONNECTED') return equipment;
+ return {...equipment, state: equipment.lastReceivedAt ? 'STALE' : 'NOT_RECEIVED',
+ items:(equipment.items||[]).map(item=>['GOOD','VALID'].includes(item.quality)?{...item,quality:'STALE'}:item)};
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..9d45f25
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -0,0 +1,6 @@
+import React from 'react';
+import {createRoot} from 'react-dom/client';
+import {App} from './App';
+import './styles.css';
+
+createRoot(document.getElementById('root')).render();
diff --git a/frontend/src/secondary.css b/frontend/src/secondary.css
new file mode 100644
index 0000000..eb2024c
--- /dev/null
+++ b/frontend/src/secondary.css
@@ -0,0 +1,137 @@
+.secondary-equipment-layout { display: grid; grid-template-columns: 296px minmax(0, 1fr); gap: 18px; align-items: stretch; }
+.secondary-detail-stack { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
+.secondary-search { display: flex; align-items: center; gap: 10px; width: 330px; max-width: 100%; min-height: 40px; padding: 0 12px; color: #647e9f; border: 1px solid #d6e2f0; border-radius: 6px; background: #fff; }
+.secondary-search:focus-within { border-color: #3280d4; box-shadow: 0 0 0 3px #2b7bd316; }
+.secondary-search input { width: 100%; min-width: 0; height: 38px; padding: 0; color: #203753; font: inherit; font-size: 13px; border: 0; outline: 0; background: transparent; }
+.secondary-search input::placeholder { color: #7d91ab; }
+.secondary-list-search { padding: 0 18px 18px; }
+.secondary-list-search .secondary-search { width: auto; }
+.secondary-panel-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 0 0 18px; }
+.secondary-muted, .secondary-blank { color: #8294ac; }
+.secondary-blank { white-space: nowrap; font-variant-numeric: tabular-nums; }
+.secondary-blank small { margin-left: 8px; color: #8294ac; font-size: 12px; }
+.secondary-unavailable { display: inline-flex; align-items: center; gap: 7px; color: #8393a9; white-space: nowrap; font-size: 13px; }
+.secondary-unavailable i, .node-state i { width: 8px; height: 8px; flex: 0 0 8px; border-radius: 50%; background: currentColor; }
+.secondary-table-scroll { width: 100%; overflow-x: auto; }
+.secondary-table { width: 100%; border-spacing: 0; border-collapse: collapse; table-layout: fixed; color: #294360; font-size: 13px; }
+.secondary-table th { height: 48px; padding: 12px 14px; color: #5c7798; background: #f4f8fc; border: 1px solid #e1eaf4; font-weight: 500; text-align: left; }
+.secondary-table td { height: 83px; padding: 17px 14px; border: 1px solid #e1eaf4; vertical-align: middle; }
+.secondary-table th svg { display: inline-block; margin: 0 6px -2px 0; }
+.secondary-table tbody tr:hover { background: #f9fbfe; }
+.secondary-table .secondary-empty-cell { height: 230px; padding: 30px 16px; text-align: center; }
+.secondary-table .secondary-empty-cell:hover { background: #fff; }
+.secondary-column-unit { white-space: nowrap; }
+.secondary-panel-note { margin: 20px 0 0; color: #7f94ae; font-size: 12px; line-height: 1.6; }
+
+.virtual-list-panel { min-height: 650px; }
+.virtual-list-empty { display: grid; place-items: center; padding: 80px 18px 28px; }
+.virtual-unselected-header { display: flex; align-items: center; gap: 18px; padding: 23px; min-height: 100px; border: 1px solid #dfe8f3; border-radius: 8px; background: #fff; }
+.virtual-equipment-icon { display: grid; place-items: center; width: 48px; height: 48px; color: #a2b5cc; background: #f2f6fb; border-radius: 9px; }
+.virtual-unselected-header h2, .video-unselected-header h2 { margin: 0 0 8px; font-size: 21px; font-weight: 600; color: #566d89; }
+.virtual-unselected-header p, .video-unselected-header p { margin: 0; color: #8396ae; font-size: 13px; }
+.virtual-unselected-header p span { margin-left: 12px; }
+.virtual-unselected-header > .secondary-unavailable { margin-left: auto; }
+.virtual-state-body { padding: 0; }
+.virtual-health-row { display: flex; align-items: center; gap: 20px; padding: 20px 22px; background: #f9fbfe; border: 1px solid #e5edf6; border-radius: 5px; }
+.virtual-health-row > svg { color: #9badc2; }
+.virtual-health-label { color: #66809f; font-size: 14px; }
+.virtual-health-row strong { color: #8195ad; font-size: 23px; font-weight: 500; white-space: nowrap; }
+.virtual-health-track { flex: 1; height: 10px; margin-left: 8px; border-radius: 6px; background: #e7edf5; }
+.virtual-position-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 19px 36px; padding: 23px 12px 4px; }
+.virtual-position-item { display: grid; grid-template-columns: 84px 1fr; gap: 12px; font-size: 14px; }
+.virtual-position-item > span:first-child { color: #6783a3; }
+.virtual-overview-table { min-width: 620px; }
+.virtual-overview-table th:first-child { width: 23%; }
+.virtual-overview-table th:nth-child(3) { width: 33%; }
+
+.video-main-panel { min-width: 0; overflow: hidden; }
+.video-unselected-header { display: flex; align-items: center; gap: 18px; min-height: 91px; color: #91a6bf; }
+.video-main-panel > .panel-header:empty { display: none; }
+.video-stage { position: relative; display: flex; flex-direction: column; min-height: 480px; aspect-ratio: 16 / 10; overflow: hidden; background: #101d2c; border-radius: 6px; }
+.video-empty-frame { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; padding: 180px 24px 36px; color: #5f7893; text-align: center; }
+.video-empty-frame h3 { margin: 16px 0 9px; color: #c1cede; font-size: 19px; font-weight: 500; }
+.video-empty-frame p { margin: 0; color: #7d92a9; font-size: 13px; line-height: 1.7; }
+.video-metrics-overlay { position: absolute; top: 18px; right: 18px; width: 222px; padding: 15px 17px; color: #dfe7f0; border: 1px solid #52667c50; border-radius: 7px; background: #223246e8; }
+.video-overlay-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 11px; border-bottom: 1px solid #a4b4c12b; }
+.video-overlay-heading strong { font-size: 14px; }
+.video-overlay-heading > span { max-width: 102px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #9fb2c7; font-size: 11px; }
+.video-metrics-overlay dl { display: flex; flex-direction: column; gap: 10px; margin: 12px 0 0; font-size: 12px; }
+.video-metrics-overlay dl > div { display: flex; justify-content: space-between; align-items: center; gap: 15px; }
+.video-metrics-overlay dt { color: #c1cede; }
+.video-metrics-overlay dd { margin: 0; color: #a9bbcf; white-space: nowrap; }
+.video-metrics-overlay dd small { margin-left: 6px; color: #8098b3; font-size: 10px; }
+.video-control-bar { display: flex; align-items: center; gap: 16px; min-height: 48px; padding: 0 16px; color: #8195ad; background: #1c2a3a; }
+.video-control-bar .secondary-unavailable { color: #879bb2; font-size: 12px; }
+.video-control-divider { height: 21px; width: 1px; background: #47596d; }
+.video-control-bar button { display: grid; place-items: center; min-width: 25px; padding: 5px 0; border: 0; color: #768b9f; background: none; }
+.video-control-bar button:disabled { cursor: not-allowed; opacity: .55; }
+.video-volume-track { width: 96px; height: 3px; border-radius: 3px; background: #3f5369; }
+.video-resolution { margin-left: auto; font-size: 11px; white-space: nowrap; }
+
+.fusion-metrics-panel { min-height: 550px; }
+.fusion-metrics-table { min-width: 1000px; }
+.fusion-metrics-table th:first-child { width: 215px; }
+.fusion-metrics-table th:last-child { width: 105px; }
+.fusion-metrics-table td:not(:first-child), .fusion-metrics-table th:not(:first-child) { text-align: center; }
+.fusion-metrics-table td { height: 93px; }
+.fusion-equipment-name { display: flex; align-items: center; gap: 14px; }
+.fusion-equipment-name > svg { color: #287dcb; flex-shrink: 0; }
+.fusion-equipment-name > div { min-width: 0; }
+.fusion-equipment-name strong { display: block; color: #1b395c; font-size: 15px; font-weight: 600; word-break: break-word; }
+.fusion-equipment-name span { display: block; margin-top: 7px; color: #7e94b0; font-size: 12px; word-break: break-all; }
+.fusion-metrics-footer { margin: 22px 0 0; color: #7b93b0; font-size: 13px; }
+
+.node-status-table { min-width: 1020px; }
+.node-status-table th:first-child { width: 250px; }
+.node-status-table th:nth-child(2) { width: 90px; }
+.node-status-table th:nth-child(4) { width: 180px; }
+.node-status-table th:last-child { width: 115px; }
+.node-status-table td { height: 96px; }
+.node-identity { display: flex; align-items: center; gap: 15px; }
+.node-identity > svg { flex-shrink: 0; color: #267bd1; }
+.node-identity > div { min-width: 0; }
+.node-identity strong { display: block; color: #193a5c; font-size: 15px; font-weight: 600; word-break: break-word; }
+.node-identity span { display: block; margin-top: 7px; color: #7a91ad; font-size: 11px; overflow-wrap: anywhere; }
+.node-state { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; white-space: nowrap; }
+.node-state-online { color: #0da76a; }
+.node-state-offline, .node-state-unknown { color: #8092aa; }
+.node-network-panel { margin-top: 24px; padding: 18px; border: 1px solid #e0e9f4; border-radius: 6px; }
+.node-network-panel h3 { display: flex; align-items: center; gap: 9px; margin: 0 0 18px; padding-bottom: 14px; border-bottom: 1px solid #e6edf6; font-size: 15px; font-weight: 600; color: #234363; }
+.node-network-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 18px; }
+.node-network-item { display: flex; align-items: center; gap: 11px; min-width: 0; padding: 7px 0; }
+.node-network-item > svg { flex-shrink: 0; color: #287dc9; }
+.node-network-item > div { min-width: 0; }
+.node-network-item strong { display: block; color: #294664; font-size: 13px; font-weight: 600; word-break: break-word; }
+.node-network-item > div > span { display: block; margin-top: 6px; color: #8294aa; font-size: 11px; }
+.node-network-item > .node-state { margin-left: auto; }
+.node-network-empty { margin: 28px 0; color: #8b9bb0; text-align: center; font-size: 13px; }
+
+@media (max-width: 1220px) {
+ .secondary-equipment-layout { grid-template-columns: 260px minmax(0, 1fr); }
+ .video-stage { min-height: 490px; }
+ .video-empty-frame { padding-top: 230px; }
+ .node-network-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
+ .virtual-health-row { gap: 13px; padding: 18px 14px; }
+ .virtual-health-row strong { font-size: 19px; }
+}
+@media (max-width: 950px) {
+ .secondary-equipment-layout { grid-template-columns: 1fr; }
+ .virtual-list-panel { min-height: 240px; }
+ .virtual-list-empty { padding: 25px 18px; }
+ .video-stage { aspect-ratio: 16 / 10; min-height: 440px; }
+ .video-empty-frame { padding-top: 180px; }
+}
+@media (max-width: 650px) {
+ .virtual-unselected-header { gap: 12px; padding: 17px; flex-wrap: wrap; }
+ .virtual-unselected-header > .secondary-unavailable { margin-left: 60px; }
+ .virtual-position-grid { grid-template-columns: 1fr; }
+ .virtual-health-row { flex-wrap: wrap; }
+ .virtual-health-track { min-width: 70px; }
+ .video-stage { min-height: 480px; }
+ .video-metrics-overlay { width: 200px; right: 12px; top: 12px; }
+ .video-empty-frame { padding-top: 250px; }
+ .video-control-bar { gap: 12px; padding: 0 12px; }
+ .video-volume-track, .video-resolution { display: none; }
+ .video-control-bar button:last-child { margin-left: auto; }
+ .node-network-grid { grid-template-columns: 1fr; }
+}
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
new file mode 100644
index 0000000..ac16881
--- /dev/null
+++ b/frontend/src/styles.css
@@ -0,0 +1,10 @@
+@import './secondary.css';
+:root{font-family:'Microsoft YaHei','Segoe UI',sans-serif;color:#263e5b;background:#f4f6fa;font-synthesis:none;font-size:14px;font-weight:400;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}
+*{box-sizing:border-box}body{margin:0;min-width:360px}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.45}a{color:inherit;text-decoration:none}h1,h2,h3,p{margin:0}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,tr:focus-visible{outline:2px solid #276cc0;outline-offset:3px}button{border:0}input,select{color:#314d70}input::placeholder{color:#8d9bb1}svg{flex-shrink:0}time,.mono{font-variant-numeric:tabular-nums}.muted{color:#8797ac;font-size:12px}
+.sidebar{width:246px;background:#172c46;color:#becddd;position:fixed;inset:0 auto 0 0;display:flex;flex-direction:column;padding:27px 13px 24px;z-index:10}.brand{display:flex;align-items:center;gap:12px;padding:0 12px 30px;color:#fff;position:relative;white-space:nowrap}.brand img{width:43px;height:43px}.brand strong{font-size:18px;font-weight:600}.collapse-toggle{display:grid;place-items:center;width:23px;height:25px;background:#ffffff06;color:#9eb3cc;border:1px solid #ffffff20;border-radius:4px;position:absolute;right:0;bottom:9px}.sidebar nav{padding-top:16px}.nav-link{display:flex;align-items:center;gap:20px;padding:16px 16px;margin:6px 0 12px;border-radius:7px;font-size:15px;white-space:nowrap;min-height:54px}.nav-link:hover{background:#ffffff0b}.nav-link.active{background:#2c5d97;color:#fff;font-weight:600}.nav-link svg{color:#a5bdd6}.nav-link.active svg{color:#f2f8ff}.sidebar-foot{margin-top:auto;display:flex;gap:19px;align-items:center;border-top:1px solid #ffffff14;padding:25px 17px 3px;font-size:14px;color:#bdcede}.main-shell{margin-left:246px;min-height:100vh;display:flex;flex-direction:column;min-width:0}.topbar{height:64px;min-height:64px;padding:0 29px;display:flex;align-items:center;justify-content:space-between;gap:18px;background:#fff;border-bottom:1px solid #e1e8f0;font-size:13px}.breadcrumb{display:flex;align-items:center;gap:13px;color:#7e90a9;white-space:nowrap}.breadcrumb strong{color:#344c6b;font-weight:600}.service-state{display:flex;gap:22px;align-items:center;white-space:nowrap}.service-state time{border-left:1px solid #d7e1ed;padding-left:22px;color:#6e84a3;font-size:12px}main{padding:28px 29px 0;flex:1;min-width:0;max-width:1900px;width:100%;margin:auto}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px;min-height:70px}.page-heading h1{font-size:27px;line-height:1.35;font-weight:650;color:#1f3653;letter-spacing:.2px}.page-heading p{margin-top:8px;color:#7b8fa8;line-height:1.5;font-size:14px}.heading-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end}.refresh-button{display:flex;gap:7px;align-items:center;padding:9px 12px;min-height:38px;background:#fff;border:1px solid #dce5ef;border-radius:5px;color:#6b80a0;font-size:12px}.refresh-button:hover{border-color:#276cc0;color:#276cc0}.context-select{display:flex;align-items:center;gap:10px;color:#7a8ca4;font-size:12px}.context-select select{max-width:260px;border:1px solid #d9e3ef;border-radius:5px;background:#fff;padding:10px 28px 10px 11px;min-width:120px}.panel{background:#fff;border:1px solid #dfe7f1;border-radius:7px;min-width:0;overflow:hidden}.panel-title{padding:19px 20px 15px;display:flex;align-items:center;justify-content:space-between;gap:15px;min-height:56px}.panel-title h2{font-size:17px;color:#253f5f;font-weight:600}.panel-extra{font-size:12px;color:#8193ab}.equipment-layout{display:grid;grid-template-columns:290px minmax(0,1fr);gap:12px;min-height:650px;align-items:stretch}.equipment-list{min-height:650px}.equipment-list .panel-title{padding-bottom:14px}.search-input{display:flex;align-items:center;gap:10px;border:1px solid #cfdbeb;border-radius:5px;min-height:42px;padding:0 12px;margin:4px 20px 20px;color:#708baa;min-width:0}.search-input input{background:transparent;width:100%;min-width:0;border:0;outline:none!important;padding:10px 0;font-size:13px}.search-input:focus-within{border-color:#4384c7;box-shadow:0 0 0 2px #276cc014}.equipment-rows{border-top:1px solid #e5ecf4}.equipment-row{display:flex;align-items:center;gap:14px;min-height:92px;width:100%;padding:18px 18px 18px 23px;background:#fff;color:#2873bf;text-align:left;border-bottom:1px solid #e3ebf4;border-left:3px solid transparent}.equipment-row:hover{background:#f6faff}.equipment-row.selected{background:#eaf3ff;border-left-color:#3284df}.equipment-row-copy{min-width:0;flex:1}.equipment-row-copy strong{display:block;font-weight:600;color:#273f61;font-size:15px;overflow-wrap:anywhere}.equipment-row-copy small{display:block;color:#8198b4;font-size:12px;margin-top:5px;overflow-wrap:anywhere}.equipment-row .status{font-size:11px;gap:5px;max-width:86px;white-space:normal;line-height:1.45}.status{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;line-height:1.45}.status i{width:7px;height:7px;flex:none;background:currentColor;border-radius:50%}.status-good{color:#14965c}.status-warn{color:#bf881c}.status-bad{color:#c25b48}.status-muted{color:#879bb6}.detail-stack{display:flex;flex-direction:column;gap:12px;min-width:0}.equipment-header{display:flex;align-items:center;justify-content:space-between;padding:24px 25px;gap:18px;min-height:106px}.equipment-heading{display:flex;align-items:center;gap:23px;min-width:0;color:#2778c2}.equipment-heading h2{font-size:24px;color:#213b60;font-weight:600;overflow-wrap:anywhere}.equipment-heading p{font-size:12px;color:#8297b4;margin-top:7px;line-height:1.5}.equipment-heading p span{margin:0 11px}.equipment-heading>.status{background:#eaf7f0;padding:5px 9px;border-radius:3px;margin-left:3px}.equipment-updated{font-size:12px;color:#8b9db5;white-space:nowrap}.equipment-updated time{color:#607c9f;margin-left:9px}.table-scroll{overflow:auto;max-width:100%}.data-table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}.data-table th{background:#f6f8fc;color:#7c91ad;font-size:12px;font-weight:500;padding:14px 19px;border-block:1px solid #e5ecf4;white-space:nowrap}.data-table td{border-bottom:1px solid #e5ecf4;padding:19px;vertical-align:middle;color:#426185;font-variant-numeric:tabular-nums}.data-table tr:last-child td{border-bottom:0}.data-table tr.selected{background:#f3f8ff}.data-table td strong{font-weight:500;color:#334f72}.data-table td small{display:block;font-size:11px;color:#8d9eb3;margin-top:5px}.receive-table tbody tr{cursor:pointer}.receive-table tbody tr:hover{background:#f6faff}.receive-table td.parameter-value{overflow-wrap:anywhere;min-width:130px;max-width:450px}.receive-table td:first-child{min-width:145px}.receive-table td time{white-space:nowrap}.value-details{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px 20px;margin:0;padding:14px 24px 25px}.value-details>div{min-width:0;border-left:1px solid #e2ebf6;padding-left:16px}.value-details dt{color:#8095b1;font-size:12px;margin-bottom:11px}.value-details dd{margin:0;font-size:16px;color:#2e4c73;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px;min-height:190px;padding:34px 24px;color:#94a9c0}.empty-state strong{font-size:15px;font-weight:500;color:#6d88a7}.empty-state p{max-width:450px;font-size:12px;line-height:1.8;color:#8c9fb6}.equipment-list .empty-state{min-height:240px;padding-inline:18px}.unselected-panel{min-height:650px;display:flex;align-items:center;justify-content:center}.unselected-panel .empty-state{min-height:400px}.sync-values{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;padding:0 20px 22px}.sync-values>div{border:1px solid #e1eaf5;padding:17px 18px}.sync-values span{font-size:13px;color:#7289a8}.sync-values strong{display:block;font-size:27px;font-weight:600;margin-top:12px;color:#5d7ba0}.sync-values small{font-size:12px;font-weight:400}.sync-values p{margin-top:8px;font-size:11px;color:#91a3ba}.source-notice{display:flex;align-items:center;gap:8px;background:#fff7e8;border:1px solid #f2e3c5;color:#a27f3f;font-size:12px;padding:11px 15px;border-radius:5px;margin-bottom:16px;line-height:1.7}.app-footer{padding:23px 29px;font-size:11px;color:#92a5be;display:flex;align-items:center;justify-content:space-between;gap:12px}.nav-collapsed .sidebar{width:76px;padding-inline:10px}.nav-collapsed .main-shell{margin-left:76px}.nav-collapsed .brand{padding:0 6px 28px}.nav-collapsed .brand img{width:43px;height:43px}.nav-collapsed .brand strong,.nav-collapsed .nav-link span,.nav-collapsed .sidebar-foot span{display:none}.nav-collapsed .nav-link{padding:15px;justify-content:center}.nav-collapsed .sidebar-foot{padding:24px 17px 3px}.nav-collapsed .collapse-toggle{bottom:-4px;right:16px}
+.brand img{background:#4b92d5;border-radius:10px;padding:5px}.sidebar nav{padding-top:3px}.secondary-equipment-layout{grid-template-columns:290px minmax(0,1fr);gap:12px}.video-unselected-header{padding:23px}.virtual-state-body{padding:0 20px 20px}.virtual-overview-panel>.secondary-table-scroll,.fusion-metrics-panel>.secondary-table-scroll,.node-page>.secondary-table-scroll{width:calc(100% - 40px);margin-inline:20px}.secondary-panel-toolbar{margin-inline:20px}.node-page>.node-network-panel{margin:24px 20px 0}.secondary-panel-note{margin:20px}.fusion-metrics-panel,.node-page{padding-bottom:18px}
+@media(min-width:1550px){.equipment-layout,.secondary-equipment-layout{grid-template-columns:350px minmax(0,1fr)}.equipment-row{padding-block:22px}.equipment-row .status{font-size:12px}.equipment-header{min-height:111px}.equipment-list,.equipment-layout{min-height:730px}.nav-link{font-size:16px}.brand strong{font-size:19px}.page-heading h1{font-size:29px}.data-table{font-size:14px}}
+@media(max-width:1280px){.sidebar{width:210px;padding-inline:10px}.main-shell{margin-left:210px}.brand{gap:9px;padding-inline:8px}.brand img{width:36px;height:36px}.brand strong{font-size:16px}.nav-link{font-size:13px;gap:15px;padding-inline:12px}.equipment-layout{grid-template-columns:250px minmax(0,1fr)}.equipment-row{gap:10px;padding-inline:14px}.equipment-row .status{font-size:10px;max-width:65px}.equipment-row-copy strong{font-size:13px}.equipment-row-copy small{font-size:11px}.equipment-heading{gap:15px}.equipment-heading h2{font-size:21px}.equipment-header{padding:20px;flex-wrap:wrap;gap:12px}.equipment-updated{width:100%;padding-left:61px}.topbar{padding-inline:22px}main{padding:24px 22px 0}.data-table td,.data-table th{padding-inline:14px}.service-state{gap:12px}.service-state time{padding-left:12px}.value-details{grid-template-columns:repeat(2,minmax(0,1fr))}}
+@media(max-width:1000px){.service-state time{display:none}.topbar{gap:10px}.equipment-layout{grid-template-columns:220px minmax(0,1fr)}.equipment-row{flex-wrap:wrap;column-gap:9px;padding-block:14px;min-height:86px}.equipment-row .status{margin-left:35px;max-width:none;width:100%;font-size:10px}.equipment-list .panel-title h2{font-size:15px}.search-input{margin-inline:14px;padding-inline:9px}.equipment-heading>.status{margin-left:0}.equipment-heading{flex-wrap:wrap;gap:12px}.equipment-heading>svg{width:34px}.equipment-updated{padding-left:46px}.panel-title h2{font-size:15px}.heading-actions{flex-wrap:wrap}.context-select>span{display:none}.context-select select{max-width:180px}.refresh-button span{display:none}.sync-values{gap:9px;padding-inline:14px}.sync-values>div{padding:13px 10px}.sync-values span{font-size:11px}}
+@media(max-width:800px){.sidebar{width:68px;padding-inline:8px}.main-shell{margin-left:68px}.brand{padding:0 7px 30px}.brand img{width:37px;height:37px}.brand strong,.nav-link span,.sidebar-foot span{display:none}.collapse-toggle{display:none}.nav-link{padding:14px;justify-content:center}.sidebar-foot{justify-content:center;padding-inline:0}.equipment-layout{grid-template-columns:210px minmax(0,1fr)}main{padding:20px 17px 0}.topbar{padding-inline:17px}.page-heading h1{font-size:24px}.page-heading p{font-size:12px}.page-heading{align-items:flex-start;gap:12px}.equipment-heading h2{font-size:19px}.equipment-heading p{font-size:11px}.equipment-header{padding:18px 16px}.value-details{gap:20px 12px;padding-inline:15px}.value-details dd{font-size:14px}}
+@media(max-width:650px){.equipment-layout{grid-template-columns:1fr;min-height:0}.equipment-list{min-height:0}.equipment-list .equipment-rows{display:flex;overflow:auto}.equipment-row{min-width:220px;width:220px;border-left:0;border-top:3px solid transparent}.equipment-row.selected{border-top-color:#3284df}.equipment-list .empty-state{min-height:140px}.unselected-panel{min-height:320px}.page-heading{flex-direction:column}.heading-actions{width:100%;justify-content:flex-end}.context-select{flex:1}.context-select select{max-width:100%;width:100%}.service-state{display:none}.breadcrumb{font-size:12px}.topbar{height:56px;min-height:56px}.detail-stack{gap:12px}.equipment-updated{padding-left:0}.sync-values{grid-template-columns:1fr}.sync-values>div{display:flex;align-items:center;justify-content:space-between;gap:10px}.sync-values strong,.sync-values p{margin:0}.app-footer{padding:20px 17px}.app-footer span:first-child{display:none}.nav-collapsed .sidebar{width:68px}.nav-collapsed .main-shell{margin-left:68px}}
diff --git a/frontend/src/ui.jsx b/frontend/src/ui.jsx
new file mode 100644
index 0000000..48e1954
--- /dev/null
+++ b/frontend/src/ui.jsx
@@ -0,0 +1,39 @@
+import React, {useMemo, useState} from 'react';
+import {Search, Truck, Plane, Inbox} from 'lucide-react';
+
+const labels = {
+ RECEIVING:'接收正常',GOOD:'正常',CONNECTED:'服务连接正常',STALE:'数据已过期',
+ NOT_RECEIVED:'未接收',MISSING:'未采到',ERROR:'数据异常',WAITING:'等待数据同步',
+ INVALID_TIME:'时间异常',INVALID_VALUE:'数值异常',AMBIGUOUS:'来源不明确',
+ online:'在线',offline:'离线',UNKNOWN:'未接入',
+};
+export function Status({state='UNKNOWN', label}) {
+ const tone = ['RECEIVING','GOOD','CONNECTED','online'].includes(state) ? 'good' : ['STALE','INVALID_TIME','AMBIGUOUS'].includes(state) ? 'warn' : ['ERROR','INVALID_VALUE'].includes(state) ? 'bad' : 'muted';
+ return {label || labels[state] || labels.UNKNOWN};
+}
+export function Panel({title,children,extra,className=''}) {
+ return {(title||extra)&&}{children};
+}
+export function Empty({title='暂无数据',description,icon:Icon=Inbox}) {
+ return {title}{description&&{description}
} ;
+}
+export function EquipmentIcon({equipment,size=25}) {
+ const air=/无人机|航空|飞行|UAV/i.test(`${equipment?.name||''} ${equipment?.code||''} ${equipment?.type||''}`);
+ const Icon=air?Plane:Truck;
+ return ;
+}
+export function EquipmentList({equipments=[],selectedId,onSelect,title='实装列表',emptyText='暂无已绑定装备'}) {
+ const [search,setSearch]=useState('');
+ const filtered=useMemo(()=>equipments.filter(item=>`${item.name||''} ${item.code||''}`.toLowerCase().includes(search.trim().toLowerCase())),[equipments,search]);
+ return {filtered.map(item=>)}
{!filtered.length&&};
+}
+export function EquipmentHeader({equipment,statusLabel}) {
+ if(!equipment)return null;
+ return {equipment.name||equipment.code||'未命名装备'}
{equipment.code||'—'}·{equipment.type||'实装装备'}
最近接收
;
+}
+export function formatTime(value) {
+ if(value===null||value===undefined||value==='')return '—';
+ const date=new Date(value);
+ if(Number.isNaN(date.getTime()))return '—';
+ return new Intl.DateTimeFormat('zh-CN',{timeZone:'Asia/Shanghai',hour:'2-digit',minute:'2-digit',second:'2-digit',hour12:false}).format(date);
+}
diff --git a/frontend/tests/data.test.mjs b/frontend/tests/data.test.mjs
new file mode 100644
index 0000000..681f2dd
--- /dev/null
+++ b/frontend/tests/data.test.mjs
@@ -0,0 +1,21 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {realEquipment,displayValue,effectiveEquipment,collectionState} from '../src/data.mjs';
+
+test('real zero is preserved and missing values never become zero',()=>{
+ assert.equal(displayValue(0,'m'),'0 m');
+ assert.equal(displayValue(null,'m'),'—');
+ assert.equal(displayValue(false),'否');
+ assert.equal(displayValue({value:15}),'—');
+});
+test('simulated samples are excluded without borrowing another equipment value',()=>{
+ const rows=realEquipment({equipment:[{id:'real',items:[{key:'a',value:0,quality:'GOOD'},{key:'b',value:15,quality:'SIMULATED'},{key:'c',value:7,sourceName:'模拟采集'},{key:'d',value:8,simulated:true}]},{id:'demo',sourceType:'SIMULATED',items:[]}]});
+ assert.equal(rows.length,1);
+ assert.deepEqual(rows[0].items.map(item=>item.key),['a']);
+ assert.equal(rows[0].items[0].value,0);
+});
+test('bridge failure stops showing last equipment data as fresh',()=>{
+ assert.equal(collectionState({state:'CONNECTED'},true),'ERROR');
+ assert.equal(effectiveEquipment({id:'e',state:'RECEIVING',lastReceivedAt:123},'ERROR').state,'STALE');
+ assert.equal(effectiveEquipment({id:'e',state:'RECEIVING',lastReceivedAt:null},'STALE').state,'NOT_RECEIVED');
+});
diff --git a/frontend/vite.config.mjs b/frontend/vite.config.mjs
new file mode 100644
index 0000000..a9077e4
--- /dev/null
+++ b/frontend/vite.config.mjs
@@ -0,0 +1,9 @@
+import {defineConfig} from 'vite';
+import react from '@vitejs/plugin-react';
+
+export default defineConfig({
+ base: '/console/',
+ plugins: [react()],
+ build: {outDir: '../src/main/resources/static/console', emptyOutDir: true},
+ server: {host: '127.0.0.1', port: 4178, strictPort: true},
+});
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..eceed87
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,14 @@
+
+ 4.0.0
+ org.springframework.bootspring-boot-starter-parent3.5.11
+ com.xtrhxtrh-services1.0.0
+ 17
+
+ org.springframework.bootspring-boot-starter-web
+ com.h2databaseh2runtime
+ org.jmdnsjmdns3.5.12
+ org.bouncycastlebcprov-jdk18on1.80
+ org.springframework.bootspring-boot-starter-testtest
+
+ org.springframework.bootspring-boot-maven-plugin
+
diff --git a/scripts/Activate-ConsoleCollection.ps1 b/scripts/Activate-ConsoleCollection.ps1
new file mode 100644
index 0000000..f32fc19
--- /dev/null
+++ b/scripts/Activate-ConsoleCollection.ps1
@@ -0,0 +1,43 @@
+[CmdletBinding()]
+param([switch]$RestartRunningServices)
+$ErrorActionPreference='Stop'
+if(-not $RestartRunningServices){throw '此步骤会短暂停止PXHJ采集并刷新融合总控。需明确确认后使用 -RestartRunningServices。'}
+$projectRoot=[IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$pxhjRoot='E:\pxhj'
+$coordinatorRoot=Join-Path $projectRoot 'runtime/coordinator'
+$sourcePxhj=Join-Path $projectRoot 'runtime/console-pxhj-build/pxhj-server-0.1.0.jar'
+$targetPxhj=Join-Path $pxhjRoot 'backend/target/pxhj-server-0.1.0.jar'
+$sourceXtrh=Join-Path $projectRoot 'target/xtrh-services-1.0.0.jar'
+foreach($file in @($sourcePxhj,$sourceXtrh)){if(-not(Test-Path -LiteralPath $file)){throw '缺少已验证构建产物。'}}
+$listeners=@(Get-NetTCPConnection -State Listen -LocalPort 8088 -ErrorAction SilentlyContinue)
+$backendPid=0
+$backendTicks=0
+if($listeners.Count){
+ $backendPid=[int]$listeners[0].OwningProcess
+ $process=Get-Process -Id $backendPid
+ $backendTicks=$process.StartTime.ToUniversalTime().Ticks
+ $command=Get-CimInstance Win32_Process -Filter ('ProcessId='+$backendPid)
+ $jar=[regex]::Match([string]$command.CommandLine,'(?i)-jar\s+(?:"(?[^"]+)"|(?
\S+))').Groups['p'].Value
+ if([IO.Path]::GetFullPath($jar) -ne [IO.Path]::GetFullPath($targetPxhj)){throw '8088不是目标PXHJ进程,未停止任何程序。'}
+}
+$backup=Join-Path $projectRoot ('runtime/console-activation-backup-'+(Get-Date -Format 'yyyyMMdd-HHmmss'))
+New-Item -ItemType Directory -Force -Path $backup | Out-Null
+Copy-Item -LiteralPath $targetPxhj -Destination (Join-Path $backup 'pxhj-server.jar')
+Copy-Item -LiteralPath (Join-Path $coordinatorRoot 'xtrh-services.jar') -Destination (Join-Path $backup 'xtrh-services.jar')
+if($backendPid){
+ $current=Get-Process -Id $backendPid -ErrorAction Stop
+ if($current.StartTime.ToUniversalTime().Ticks -ne $backendTicks){throw 'PXHJ进程身份已变化,未停止。'}
+ Stop-Process -Id $backendPid
+ Wait-Process -Id $backendPid -Timeout 15 -ErrorAction SilentlyContinue
+}
+& (Join-Path $projectRoot 'scripts/Stop-Xtrh.ps1') -InstallRoot $coordinatorRoot
+Copy-Item -LiteralPath $sourcePxhj -Destination $targetPxhj
+Copy-Item -LiteralPath $sourceXtrh -Destination (Join-Path $coordinatorRoot 'xtrh-services.jar')
+foreach($pair in @(@{source=$sourcePxhj;target=$targetPxhj},@{source=$sourceXtrh;target=(Join-Path $coordinatorRoot 'xtrh-services.jar')})){
+ if((Get-FileHash -LiteralPath $pair.source).Hash -ne (Get-FileHash -LiteralPath $pair.target).Hash){throw '加载前JAR校验失败。'}
+}
+$env:JAVA_HOME='C:\Program Files\Java\jdk-25.0.2'
+$env:PATH=$env:JAVA_HOME+'\bin;'+$env:PATH
+& (Join-Path $projectRoot 'scripts/Start-Xtrh.ps1') -InstallRoot $coordinatorRoot -JavaHome $env:JAVA_HOME
+& (Join-Path $pxhjRoot 'scripts/start-dev.ps1') -SkipBuild -BackendOnly
+Write-Output ('新页面和真实采集桥接已加载;原包备份:'+$backup)
diff --git a/scripts/Activate-EmbeddedImageRoute.py b/scripts/Activate-EmbeddedImageRoute.py
new file mode 100644
index 0000000..e55c818
--- /dev/null
+++ b/scripts/Activate-EmbeddedImageRoute.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+"""核验并切换已构建图片协商候选;默认预览,--apply执行既已授权的板端切换。"""
+import argparse
+import contextlib
+import importlib.util
+import io
+import json
+import os
+from pathlib import Path
+import re
+import shutil
+import signal
+import subprocess
+import sys
+import time
+import uuid
+
+
+# 操作者负责SSH接入,此工具没有网络配置、口令或自动连接行为。
+loader = importlib.util.spec_from_file_location("collector_stage", Path(__file__).with_name("Stage-EmbeddedImageRoute.py"))
+helper = importlib.util.module_from_spec(loader)
+loader.loader.exec_module(helper)
+
+
+def test_result(path, minimum, optional_interop=False):
+ # 不根据built布尔值省略原始QtTest结果;失败或其他跳过不能进入现网。
+ text = path.read_text(encoding="utf-8")
+ result = re.search(r"Totals:\s+(\d+) passed,\s+(\d+) failed,\s+(\d+) skipped", text)
+ if not result:
+ raise RuntimeError("QtTest result summary is missing: " + path.name)
+ passed, failed, skipped = map(int, result.groups())
+ if passed < minimum or failed or skipped > (1 if optional_interop else 0):
+ raise RuntimeError("QtTest results did not meet deployment requirements: " + path.name)
+ # ARM没有Windows生成的Java夹具时仅允许这一项跳过,原图/租约等业务不能跳过。
+ if skipped and "javaAndCppWireCodecInteroperateWithRealEncryptedFrames" not in text:
+ raise RuntimeError("Unexpected skipped test: " + path.name)
+ return {"passed": passed, "failed": failed, "skipped": skipped}
+
+
+def preflight(args):
+ root, directory, record = helper.stage_record(args)
+ if not record.get("built"):
+ raise RuntimeError("Full ARM candidate build has not passed")
+ # 先确认在用版本从stage后没有被别的部署修改,完整核验在停止进程前完成。
+ with contextlib.redirect_stdout(io.StringIO()):
+ helper.verify(args)
+ # 源码/pro也必须与完整构建时的记录一致,避免把未构建的改动一起装入现网。
+ checks = dict(record["candidatePatchHashes"])
+ checks["px_Collect.pro"] = record["candidateProjectSha256"]
+ checks["bin/px_JKGL"] = record["candidateBinarySha256"]
+ for relative, checksum in checks.items():
+ candidate = directory / "source" / relative
+ # 只能从本stage取候选,路径和内容均受校验,不能替换为任意二进制文件。
+ if candidate.is_symlink() or not candidate.resolve().is_relative_to(directory) \
+ or not candidate.is_file() or helper.digest(candidate) != checksum:
+ raise RuntimeError("Candidate hash or path mismatch: " + relative)
+ config = directory / "candidate-config.ini"
+ if config.is_symlink() or helper.digest(config) != record["candidateConfigSha256"]:
+ raise RuntimeError("Candidate configuration hash mismatch")
+ # 这里只采信stage内本次两套结果,不借用历史Windows或旧板的测试报告。
+ tests = {"logging": test_result(directory / "logging-tests.txt", 9),
+ "imageRoute": test_result(directory / "image_route-tests.txt", 8, True)}
+ launcher = root / "start-network-simulation.sh"
+ # 保留当前启动器:它维护串口PTY和collector.pid,不能用历史拷贝替换。
+ if launcher.is_symlink() or not launcher.is_file() or not os.access(str(launcher), os.X_OK):
+ raise RuntimeError("Original network simulation launcher is invalid")
+ if re.search(r">>\s*logs/run\.log", launcher.read_text(encoding="utf-8")):
+ # 已有内置日志不能回退到旧的无限run.log重定向策略。
+ raise RuntimeError("Original launcher still contains legacy unlimited run.log redirection")
+ # 配置不是源码白名单的一项,但目的路径同样必须留在当前collector根目录内。
+ for relative in checks.keys() | {"config.ini"}:
+ destination = root / relative
+ if destination.is_symlink() or not destination.parent.resolve().is_relative_to(root):
+ raise RuntimeError("Unsafe live destination: " + relative)
+ return root, directory, record, checks, tests, helper.digest(launcher)
+
+
+def stop_collector(root):
+ # 只停止真实exe属于该工程的采集程序;串口桥、平台与其他同名进程保留。
+ original = helper.process_ids(root)
+ for pid in original:
+ os.kill(pid, signal.SIGTERM)
+ # 使用单调期限,让系统校时不会延长停止等待或误触发提前强制退出。
+ deadline = time.monotonic() + 5
+ while set(original) & set(helper.process_ids(root)) and time.monotonic() < deadline:
+ time.sleep(0.1)
+ for pid in set(original) & set(helper.process_ids(root)):
+ os.kill(pid, signal.SIGKILL)
+ time.sleep(0.1)
+ if helper.process_ids(root):
+ raise RuntimeError("Collector has another restart owner; no files were replaced")
+
+
+def install(source, destination):
+ # 同目录临时文件+原子替换,避免正在部署时出现截断二进制或半写INI。
+ # copy2保留已校验候选的权限;生产父目录与路径已在preflight核对。
+ destination.parent.mkdir(parents=True, exist_ok=True)
+ temporary = destination.with_name(destination.name + ".image-route-" + uuid.uuid4().hex)
+ try:
+ shutil.copy2(source, temporary)
+ os.replace(str(temporary), str(destination))
+ finally:
+ if temporary.exists():
+ # 只清理本次创建的单个临时文件,不递归处理任何目录。
+ temporary.unlink()
+
+
+def launch(root, directory, launcher_hash):
+ launcher = root / "start-network-simulation.sh"
+ if helper.digest(launcher) != launcher_hash:
+ raise RuntimeError("Original launcher changed during activation")
+ # 启动器原样运行,自行管理串口桥与collector.pid;不修改原配置或网络。
+ with (directory / "activation-launcher.log").open("wb") as output:
+ subprocess.run([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL, stdout=output,
+ stderr=subprocess.STDOUT, check=True, timeout=15)
+
+
+def healthy(root, since, previous_status, timeout):
+ status_path = root / "runtime/native-log-status.json"
+ deadline = time.monotonic() + timeout
+ # 每次健康失效重置稳定窗口,短暂启动后立即崩溃不会算成功。
+ stable_since = None
+ while time.monotonic() < deadline:
+ pids = helper.process_ids(root)
+ try:
+ stamp = status_path.stat()
+ status = json.loads(status_path.read_text(encoding="utf-8"))
+ # 必须是本次进程新写且持续刷新的状态,不能把旧启动遗留JSON当成健康。
+ valid = pids and stamp.st_mtime_ns != previous_status and stamp.st_mtime >= since - 0.5 \
+ and -2 <= time.time() - stamp.st_mtime <= 3 \
+ and status.get("owner") == "px_JKGL" and status.get("limitBytes") == 1024 ** 3 \
+ and status.get("failedWrites") == 0 and status.get("withinLimit") is True
+ except (FileNotFoundError, json.JSONDecodeError):
+ valid = False
+ if valid:
+ if stable_since is None:
+ stable_since = time.monotonic()
+ # 观察至少两秒稳定状态,不仅依据启动脚本退出码或瞬时PID。
+ if time.monotonic() - stable_since >= 2:
+ return {"collectorPids": pids, "nativeLogging": {name: status[name]
+ for name in ("owner", "limitBytes", "failedWrites", "withinLimit")}}
+ else:
+ stable_since = None
+ time.sleep(0.25)
+ # 图片接收/迁移是后续真实业务验证,此处不把进程健康当作图片已经到达。
+ raise RuntimeError("Candidate collector did not establish stable native log health")
+
+
+def activate(args):
+ root, directory, record, checks, tests, launcher_hash = preflight(args)
+ if not args.apply:
+ # 默认仅给核验结果,不发送信号、不启动程序、不安装源码或配置。
+ print(json.dumps({"activationReady": True, "apply": False, "stage": str(directory),
+ "candidateBinarySha256": record["candidateBinarySha256"], "tests": tests}))
+ return
+ # 原日志状态文件保持原样,靠新写入校验;无需删除历史状态或日志文件。
+ status_path = root / "runtime/native-log-status.json"
+ old_status = status_path.stat().st_mtime_ns if status_path.exists() else None
+ changed = False
+ try:
+ stop_collector(root)
+ # changed在首次写入前置位,任何部分安装失败都进入对应stage自动回退。
+ changed = True
+ for relative in checks:
+ install(directory / "source" / relative, root / relative)
+ install(directory / "candidate-config.ini", root / "config.ini")
+ # 状态文件写入时间与启动前时刻同时比较,防止沿用上次进程的健康JSON。
+ since = time.time()
+ launch(root, directory, launcher_hash)
+ result = healthy(root, since, old_status, args.health_timeout)
+ result.update({"activated": True, "stage": str(directory), "tests": tests,
+ "candidateBinarySha256": record["candidateBinarySha256"]})
+ # 激活结果不包含配置正文或密钥;原图及迁移业务验收仍需PXHJ另行验证。
+ helper.write_json(directory / "activation-result.json", result)
+ print(json.dumps(result))
+ except BaseException as failure:
+ # 安装失败与业务切换失败都留下独立记录,root可辨别回退是否真正成功。
+ restored = False
+ try:
+ if changed:
+ # 回退工具先校验候选/原文件归属,不会覆盖无关后续变更。
+ command = [sys.executable, str(Path(__file__).with_name("Rollback-EmbeddedImageRoute.py")),
+ "--root", str(root), "--stage", str(directory), "--launcher", "start-network-simulation.sh", "--apply"]
+ with (directory / "activation-rollback.log").open("wb") as output:
+ subprocess.run(command, stdout=output, stderr=subprocess.STDOUT, check=True, timeout=45)
+ restored = True
+ elif not helper.process_ids(root):
+ # 尚未写文件时只启动原程序,不能为预检失败修改任何生产文件。
+ launch(root, directory, launcher_hash)
+ restored = True
+ except BaseException:
+ # 保留独立回退日志供root处理,不能把回退失败伪装成成功。
+ restored = False
+ helper.write_json(directory / "activation-error.json", {"activationFailed": True,
+ "rollbackSucceeded": restored, "reason": str(failure)})
+ raise RuntimeError("Activation failed; rollbackSucceeded=" + str(restored)
+ + "; inspect private stage activation logs") from None
+
+
+def main():
+ # --apply承接用户已经给出的部署授权,默认预览便于操作者核对具体stage。
+ # 不需要再次登录设备或改变SSH策略,所有执行仍在已确认板端本地进行。
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect"))
+ parser.add_argument("--stage", type=Path, required=True)
+ parser.add_argument("--apply", action="store_true")
+ parser.add_argument("--health-timeout", type=int, default=15)
+ args = parser.parse_args()
+ if not 3 <= args.health_timeout <= 60:
+ parser.error("health-timeout must be between 3 and 60 seconds")
+ activate(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/Apply-CollectionConsoleBridge.ps1 b/scripts/Apply-CollectionConsoleBridge.ps1
new file mode 100644
index 0000000..c6d5eaa
--- /dev/null
+++ b/scripts/Apply-CollectionConsoleBridge.ps1
@@ -0,0 +1,31 @@
+[CmdletBinding()]
+param([string]$PxhjRoot = 'E:\pxhj')
+$ErrorActionPreference = 'Stop'
+$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$staging = Join-Path $projectRoot 'runtime/console-pxhj-staging'
+$manifest = Get-Content -LiteralPath (Join-Path $staging 'baseline-manifest.json') -Raw | ConvertFrom-Json
+$targetRoot = [IO.Path]::GetFullPath($PxhjRoot).TrimEnd('\')
+$backupRoot = Join-Path $projectRoot ('runtime/console-bridge-backup-' + (Get-Date -Format 'yyyyMMdd-HHmmss'))
+foreach ($entry in $manifest.files) {
+ $target = [IO.Path]::GetFullPath((Join-Path $targetRoot $entry.path))
+ if (-not $target.StartsWith($targetRoot + '\', [StringComparison]::OrdinalIgnoreCase)) { throw '补丁路径超出目标项目。' }
+ $staged = Join-Path $staging $entry.path
+ if ((Get-FileHash -LiteralPath $staged -Algorithm SHA256).Hash -ne $entry.sha256) { throw ('补丁文件校验失败:' + $entry.path) }
+ if (Test-Path -LiteralPath $target) {
+ $currentHash = (Get-FileHash -LiteralPath $target -Algorithm SHA256).Hash
+ $baseline = @($manifest.modifiedBaseline | Where-Object path -eq $entry.path)
+ if ($currentHash -ne $entry.sha256 -and ($baseline.Count -eq 0 -or $currentHash -ne $baseline[0].sha256)) { throw ('目标文件已变化,未覆盖:' + $entry.path) }
+ }
+}
+foreach ($entry in $manifest.files) {
+ $target = Join-Path $targetRoot $entry.path
+ if (Test-Path -LiteralPath $target) {
+ $backup = Join-Path $backupRoot $entry.path
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $backup) | Out-Null
+ Copy-Item -LiteralPath $target -Destination $backup
+ }
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $target) | Out-Null
+ Copy-Item -LiteralPath (Join-Path $staging $entry.path) -Destination $target
+}
+Write-Output ('采集桥接源码已应用。备份目录:' + $backupRoot)
+Write-Output '运行中的后端不会自动加载新源码。'
diff --git a/scripts/Build-Console.ps1 b/scripts/Build-Console.ps1
new file mode 100644
index 0000000..4450bc8
--- /dev/null
+++ b/scripts/Build-Console.ps1
@@ -0,0 +1,20 @@
+[CmdletBinding()]
+param([string]$NodePath)
+$ErrorActionPreference = 'Stop'
+$projectRoot = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$frontendRoot = Join-Path $projectRoot 'frontend'
+if (-not $NodePath) {
+ $nodeCommand = Get-Command node.exe -ErrorAction SilentlyContinue
+ if ($nodeCommand) { $NodePath = $nodeCommand.Source }
+}
+if (-not $NodePath -or -not (Test-Path -LiteralPath $NodePath)) { throw '请安装 Node.js 20.19 或更新版本,或使用 -NodePath 指定 node.exe。' }
+$vite = Join-Path $frontendRoot 'node_modules/vite/bin/vite.js'
+if (-not (Test-Path -LiteralPath $vite)) { throw '缺少页面构建依赖。请先在 frontend 目录运行 pnpm install 或 npm install。' }
+Push-Location $frontendRoot
+try {
+ & $NodePath '--test' 'tests/data.test.mjs'
+ if ($LASTEXITCODE -ne 0) { throw '页面数据规则测试失败。' }
+ & $NodePath $vite 'build'
+ if ($LASTEXITCODE -ne 0) { throw '页面构建失败。' }
+} finally { Pop-Location }
+Write-Output '系统融合软件页面已构建到原生 JAR 静态资源目录。'
diff --git a/scripts/Build-Xtrh.ps1 b/scripts/Build-Xtrh.ps1
new file mode 100644
index 0000000..dfc2171
--- /dev/null
+++ b/scripts/Build-Xtrh.ps1
@@ -0,0 +1,18 @@
+[CmdletBinding()]
+param([string]$JavaHome, [string]$MavenPath, [string]$NodePath, [switch]$SkipFrontend)
+. (Join-Path $PSScriptRoot 'Common.ps1')
+$projectRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
+$java = Get-XtrhJava -JavaHome $JavaHome
+$env:JAVA_HOME = Split-Path -Parent (Split-Path -Parent $java)
+if (-not $MavenPath) {
+ $maven = Get-Command mvn.cmd -ErrorAction SilentlyContinue
+ if ($maven) { $MavenPath = $maven.Source }
+}
+if (-not $MavenPath -or -not (Test-Path -LiteralPath $MavenPath)) { throw '请通过 -MavenPath 指定 mvn.cmd,或将 Maven 加入 PATH。' }
+if (-not $SkipFrontend) { & (Join-Path $PSScriptRoot 'Build-Console.ps1') -NodePath $NodePath }
+Push-Location $projectRoot
+try {
+ & $MavenPath '-B' 'verify'
+ if ($LASTEXITCODE -ne 0) { throw 'Java 构建或测试失败。' }
+} finally { Pop-Location }
+Write-Output ('构建完成:' + (Join-Path $projectRoot 'target/xtrh-services-1.0.0.jar'))
diff --git a/scripts/Common.ps1 b/scripts/Common.ps1
new file mode 100644
index 0000000..477f28f
--- /dev/null
+++ b/scripts/Common.ps1
@@ -0,0 +1,69 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Get-XtrhJava {
+ param([string]$JavaHome)
+ if (-not $JavaHome) { $JavaHome = $env:JAVA_HOME }
+ if ($JavaHome) {
+ $candidate = Join-Path $JavaHome 'bin/java.exe'
+ if (Test-Path -LiteralPath $candidate) { return (Resolve-Path -LiteralPath $candidate).Path }
+ throw 'JAVA_HOME 中没有找到 bin/java.exe。'
+ }
+ $command = Get-Command java.exe -ErrorAction SilentlyContinue
+ if ($command) { return $command.Source }
+ throw '请先安装 Java 17 或更新版本,并设置 JAVA_HOME。'
+}
+
+function Protect-XtrhPrivateFile {
+ param([Parameter(Mandatory)][string]$Path)
+ # 私有凭证只允许当前部署用户、SYSTEM 和本机管理员读取,避免共享目录泄露。
+ # 读取现有对象后仅修改访问规则;不改所有者和审计规则,普通部署用户也可执行。
+ $access = New-Object System.Security.AccessControl.FileSecurity($Path, [System.Security.AccessControl.AccessControlSections]::Access)
+ $access.SetAccessRuleProtection($true, $false)
+ foreach ($rule in $access.GetAccessRules($true, $false, [System.Security.Principal.SecurityIdentifier])) {
+ [void]$access.RemoveAccessRuleSpecific($rule)
+ }
+ $identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
+ foreach ($sid in @($identity, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')), (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))) {
+ $access.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'Allow')))
+ }
+ $file = Get-Item -LiteralPath $Path
+ if ($file.PSObject.Methods['SetAccessControl']) { $file.SetAccessControl($access) }
+ else { [System.IO.FileSystemAclExtensions]::SetAccessControl($file, $access) }
+}
+
+function Write-XtrhText {
+ param([Parameter(Mandatory)][string]$Path, [Parameter(Mandatory)][string]$Content)
+ [System.IO.File]::WriteAllText($Path, $Content, (New-Object System.Text.UTF8Encoding($false)))
+}
+
+function ConvertTo-XtrhPropertyValue {
+ param([Parameter(Mandatory)][string]$Value)
+ # Java properties 的字符编码可能因加载器不同而变化,中文节点名用 Unicode 转义保存。
+ $builder = New-Object System.Text.StringBuilder
+ foreach ($character in $Value.ToCharArray()) {
+ if ([int]$character -gt 127) { [void]$builder.Append(('\u{0:x4}' -f [int]$character)) }
+ elseif ($character -eq '\') { [void]$builder.Append('\\') }
+ else { [void]$builder.Append($character) }
+ }
+ return $builder.ToString()
+}
+
+function Get-XtrhOwnedProcess {
+ param([Parameter(Mandatory)][string]$InstallRoot, [Parameter(Mandatory)]$Record)
+ $process = Get-Process -Id ([int]$Record.pid) -ErrorAction SilentlyContinue
+ if (-not $process) { return $null }
+ $live = Get-CimInstance Win32_Process -Filter ('ProcessId=' + [int]$Record.pid)
+ $expectedJar = [System.IO.Path]::GetFullPath((Join-Path $InstallRoot 'xtrh-services.jar'))
+ $javaPath = [System.IO.Path]::GetFullPath([string]$Record.javaPath)
+ # PID 会被操作系统复用;同时校验启动时间、Java 路径和 -jar 参数才能结束进程。
+ if (-not $live -or $process.StartTime.ToUniversalTime().Ticks -ne [long]$Record.startTicks -or
+ [System.IO.Path]::GetFullPath([string]$live.ExecutablePath) -ne $javaPath) {
+ throw '进程身份不匹配;已拒绝结束该 PID。'
+ }
+ $match = [regex]::Match([string]$live.CommandLine, '(?i)(?:^|\s)-jar\s+(?:"(?[^"]+)"|(?\S+))')
+ if (-not $match.Success -or [System.IO.Path]::GetFullPath($match.Groups['jar'].Value) -ne $expectedJar) {
+ throw '进程启动文件与本部署目录不一致;已拒绝结束该 PID。'
+ }
+ return $process
+}
diff --git a/scripts/Connect-Pxhj.ps1 b/scripts/Connect-Pxhj.ps1
new file mode 100644
index 0000000..d22f7a4
--- /dev/null
+++ b/scripts/Connect-Pxhj.ps1
@@ -0,0 +1,32 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][string]$PxhjRoot,
+ [Parameter(Mandatory)][string]$CredentialFile,
+ [string]$CoordinatorUrl = 'http://127.0.0.1:18100',
+ [string]$UdpTargetHost = '127.0.0.1',
+ [switch]$Force
+)
+. (Join-Path $PSScriptRoot 'Common.ps1')
+$address = $null
+if (-not [Uri]::TryCreate($CoordinatorUrl, [UriKind]::Absolute, [ref]$address) -or $address.Scheme -notin @('http', 'https') -or
+ -not $address.Host -or $address.UserInfo -or $address.Query -or $address.Fragment -or $address.AbsolutePath -ne '/') {
+ throw 'CoordinatorUrl 必须是完整的总控根地址。'
+}
+if ($UdpTargetHost -notmatch '^[a-zA-Z0-9_.:-]{1,200}$') { throw 'UdpTargetHost 必须是网关能够访问的 PXHJ 接收地址。' }
+$root = [System.IO.Path]::GetFullPath($PxhjRoot)
+if (-not (Test-Path -LiteralPath (Join-Path $root 'backend/src/main/resources/application.yml'))) { throw 'PxhjRoot 中没有找到 PXHJ 后端配置。' }
+$credentialContent = Get-Content -LiteralPath $CredentialFile -Raw
+$tokenMatch = [regex]::Match($credentialContent, '(?m)^fusion\.token=([^\r\n]{24,})\r?$')
+$clusterMatch = [regex]::Match($credentialContent, '(?m)^fusion\.cluster-id=([a-zA-Z0-9_-]{1,64})\r?$')
+if (-not $tokenMatch.Success -or -not $clusterMatch.Success) { throw '集群凭证文件格式不正确。' }
+$configFolder = Join-Path $root 'config'
+$privateFile = Join-Path $configFolder 'fusion.local.properties'
+if ((Test-Path -LiteralPath $privateFile) -and -not $Force) { throw 'PXHJ 私有融合配置已存在;需要覆盖时显式使用 -Force。' }
+[System.IO.Directory]::CreateDirectory($configFolder) | Out-Null
+$content = @('app.fusion.enabled=true', ('app.fusion.coordinator-url=' + $CoordinatorUrl.TrimEnd('/')),
+ ('app.fusion.cluster-id=' + $clusterMatch.Groups[1].Value), ('app.fusion.token=' + $tokenMatch.Groups[1].Value),
+ ('app.fusion.udp-target-host=' + $UdpTargetHost)) -join "`n"
+Write-XtrhText -Path $privateFile -Content ($content + "`n")
+Protect-XtrhPrivateFile -Path $privateFile
+# 只生成私有桥接配置,不擅自重启正在运行的 PXHJ 或修改采集器上传地址。
+Write-Output ('PXHJ 总控连接配置已写入:' + $privateFile + '。后端重启后加载。')
diff --git a/scripts/Image-Verification.Common.ps1 b/scripts/Image-Verification.Common.ps1
new file mode 100644
index 0000000..9012da5
--- /dev/null
+++ b/scripts/Image-Verification.Common.ps1
@@ -0,0 +1,89 @@
+Set-StrictMode -Version Latest
+$ErrorActionPreference = 'Stop'
+
+function Initialize-XtrhImageVerification {
+ param([Parameter(Mandatory)][string]$CredentialFile)
+ # 私有文件只读入内存。日志、stdout、命令参数和联调记录都不携带凭证正文。
+ $privateText = [IO.File]::ReadAllText([IO.Path]::GetFullPath($CredentialFile))
+ $match = [regex]::Match($privateText, '(?m)^fusion\.token\s*=\s*([^\r\n]+)')
+ if (-not $match.Success -or $match.Groups[1].Value.Trim().Length -lt 16) { throw '集群私有凭证文件无效。' }
+ $script:imageVerificationCredential = $match.Groups[1].Value.Trim()
+ $handler = [Net.Http.HttpClientHandler]::new()
+ $handler.AllowAutoRedirect = $false
+ $handler.UseCookies = $false
+ $script:imageVerificationClient = [Net.Http.HttpClient]::new($handler, $true)
+ $script:imageVerificationClient.Timeout = [TimeSpan]::FromSeconds(5)
+ $script:imageVerificationClient.MaxResponseContentBufferSize = 8 * 1024 * 1024
+}
+
+function Get-XtrhImageDigest {
+ param([Parameter(Mandatory)][AllowEmptyCollection()][byte[]]$Bytes)
+ $sha = [Security.Cryptography.SHA256]::Create()
+ try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-', '').ToLowerInvariant() }
+ finally { $sha.Dispose() }
+}
+
+function Get-XtrhImageSignature {
+ param([Parameter(Mandatory)][string]$Canonical)
+ $mac = [Security.Cryptography.HMACSHA256]::new([Text.Encoding]::UTF8.GetBytes($script:imageVerificationCredential))
+ try { return [BitConverter]::ToString($mac.ComputeHash([Text.Encoding]::UTF8.GetBytes($Canonical))).Replace('-', '').ToLowerInvariant() }
+ finally { $mac.Dispose() }
+}
+
+function Invoke-XtrhImageSigned {
+ param([Parameter(Mandatory)][string]$BaseUrl, [Parameter(Mandatory)][string]$Path,
+ [ValidateSet('GET','POST','PATCH')][string]$Method = 'GET', $Payload = $null)
+ $base = [Uri]$BaseUrl
+ if ($base.Scheme -notin @('http','https') -or $base.UserInfo -or $base.Query -or $base.Fragment -or $base.AbsolutePath -ne '/') {
+ throw '服务根地址格式无效。'
+ }
+ if ($Path -notmatch '^/' -or $Path -match '[\r\n]') { throw '签名接口路径无效。' }
+ # 空数组不能通过 PowerShell 表达式管道返回,否则会被展开为 null。
+ [byte[]]$body = [byte[]]::new(0)
+ if ($null -ne $Payload) { $body = [Text.Encoding]::UTF8.GetBytes(($Payload | ConvertTo-Json -Depth 12 -Compress)) }
+ $nonce = [Guid]::NewGuid().ToString()
+ $time = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds().ToString()
+ $digest = Get-XtrhImageDigest -Bytes $body
+ $signature = Get-XtrhImageSignature -Canonical "request`n$Method`n$Path`n$time`n$nonce`n$digest"
+ $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), $BaseUrl.TrimEnd('/') + $Path)
+ try {
+ [void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Time', $time)
+ [void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Nonce', $nonce)
+ [void]$request.Headers.TryAddWithoutValidation('X-Xtrh-Signature', $signature)
+ $request.Content = [Net.Http.ByteArrayContent]::new($body)
+ $request.Content.Headers.ContentType = [Net.Http.Headers.MediaTypeHeaderValue]::new('application/json')
+ try { $response = $script:imageVerificationClient.SendAsync($request).GetAwaiter().GetResult() }
+ catch { throw '服务请求失败,未输出私有请求头或响应内容。' }
+ try {
+ [byte[]]$bytes = $response.Content.ReadAsByteArrayAsync().GetAwaiter().GetResult()
+ $status = [int]$response.StatusCode
+ $expected = Get-XtrhImageSignature -Canonical ("response`n$nonce`n$status`n" + (Get-XtrhImageDigest -Bytes $bytes))
+ $headers = [Collections.Generic.IEnumerable[string]]$null
+ if (-not $response.Headers.TryGetValues('X-Xtrh-Response-Signature', [ref]$headers)) { throw '响应缺少服务身份签名。' }
+ $actual = @($headers)[0]
+ # 固定长度与逐字节累计比较;JPEG 必须签其原始字节,不能按文本转换。
+ $different = $actual.Length -bxor $expected.Length
+ if ($actual.Length -eq $expected.Length) {
+ for ($i = 0; $i -lt $expected.Length; $i++) { $different = $different -bor ([int]$actual[$i] -bxor [int]$expected[$i]) }
+ }
+ if ($different -ne 0) { throw '响应服务身份认证失败。' }
+ return [pscustomobject]@{ Status = $status; Bytes = $bytes; ContentType = [string]$response.Content.Headers.ContentType }
+ } finally { $response.Dispose() }
+ } finally { $request.Dispose() }
+}
+
+function ConvertFrom-XtrhImageJson {
+ param([Parameter(Mandatory)]$Response)
+ if ($Response.Status -ne 200) { throw ('签名服务接口返回 HTTP ' + $Response.Status) }
+ return [Text.Encoding]::UTF8.GetString($Response.Bytes) | ConvertFrom-Json
+}
+
+function Get-XtrhImageSnapshot {
+ param([Parameter(Mandatory)][string]$CoordinatorUrl)
+ return ConvertFrom-XtrhImageJson (Invoke-XtrhImageSigned -BaseUrl $CoordinatorUrl -Path '/control/snapshot')
+}
+
+function Close-XtrhImageVerification {
+ if ($null -ne $script:imageVerificationClient) { $script:imageVerificationClient.Dispose() }
+ $script:imageVerificationCredential = ''
+}
diff --git a/scripts/Install-Xtrh.ps1 b/scripts/Install-Xtrh.ps1
new file mode 100644
index 0000000..4615b89
--- /dev/null
+++ b/scripts/Install-Xtrh.ps1
@@ -0,0 +1,69 @@
+[CmdletBinding()]
+param(
+ [Parameter(Mandatory)][ValidateSet('coordinator', 'gateway', 'worker')][string]$Role,
+ [Parameter(Mandatory)][string]$InstallRoot,
+ [Parameter(Mandatory)][string]$CredentialFile,
+ [string]$JarPath,
+ [int]$Port = 0,
+ [int]$Capacity = 2,
+ [int]$UdpPort = 19002,
+ [int]$ImagePort = 0,
+ [string]$NodeName = '',
+ [string]$AdvertisedHost = '',
+ [string]$DiscoveryAddress = '',
+ [string]$CoordinatorUrl = '',
+ [switch]$CoordinatorOnly
+)
+. (Join-Path $PSScriptRoot 'Common.ps1')
+if ($CoordinatorOnly -and $Role -ne 'coordinator') { throw 'CoordinatorOnly 只能用于 coordinator 角色。' }
+if ($Capacity -lt 0 -or $Capacity -gt 64) { throw 'Capacity 必须在 0 到 64 之间。' }
+if ($UdpPort -lt 1025 -or $UdpPort -gt 65535) { throw 'UdpPort 必须在 1025 到 65535 之间。' }
+if (-not $Port) { $Port = @{coordinator = 18100; gateway = 18110; worker = 18120}[$Role] }
+if ($Port -lt 1025 -or $Port -gt 65535) { throw 'Port 必须在 1025 到 65535 之间。' }
+if ($Role -ne 'gateway' -and -not $CoordinatorOnly) {
+ # 图片直达执行节点;同机多实例采用不同入口,避免争抢 PXHJ 原有 6020。
+ if (-not $ImagePort) { $ImagePort = $Port + 1000 }
+ if ($ImagePort -lt 1025 -or $ImagePort -gt 65535) { throw 'ImagePort 必须在 1025 到 65535 之间。' }
+}
+foreach ($value in @($NodeName, $AdvertisedHost, $DiscoveryAddress, $CoordinatorUrl)) {
+ if ($value -match '[\r\n]') { throw '配置值不能包含换行。' }
+}
+if ($CoordinatorUrl -and $CoordinatorUrl -notmatch '^https?://[^\s/?#]+/?$') { throw 'CoordinatorUrl 必须为服务根地址。' }
+if (-not $JarPath) { $JarPath = Join-Path (Split-Path -Parent $PSScriptRoot) 'target/xtrh-services-1.0.0.jar' }
+if (-not (Test-Path -LiteralPath $JarPath)) { throw '未找到构建产物,请先运行 Build-Xtrh.ps1。' }
+if (-not (Test-Path -LiteralPath $CredentialFile)) { throw '未找到私有集群凭证文件。' }
+$root = [System.IO.Path]::GetFullPath($InstallRoot)
+$processFile = Join-Path $root 'service-process.json'
+if (Test-Path -LiteralPath $processFile) {
+ $record = Get-Content -LiteralPath $processFile -Raw | ConvertFrom-Json
+ if (Get-XtrhOwnedProcess -InstallRoot $root -Record $record) { throw '部署实例正在运行,请先运行 Stop-Xtrh.ps1。' }
+}
+$credentialContent = Get-Content -LiteralPath $CredentialFile -Raw
+if ($credentialContent -notmatch '(?m)^fusion\.token=.{24,}$' -or $credentialContent -notmatch '(?m)^fusion\.cluster-id=[a-zA-Z0-9_-]{1,64}\r?$') {
+ throw '集群凭证文件内容不符合格式。'
+}
+foreach ($folder in @($root, (Join-Path $root 'config'), (Join-Path $root 'logs'), (Join-Path $root 'data'), (Join-Path $root 'scripts'))) {
+ [System.IO.Directory]::CreateDirectory($folder) | Out-Null
+}
+Copy-Item -LiteralPath $JarPath -Destination (Join-Path $root 'xtrh-services.jar') -Force
+$privateFile = Join-Path $root 'config/cluster.local.properties'
+Write-XtrhText -Path $privateFile -Content $credentialContent
+Protect-XtrhPrivateFile -Path $privateFile
+$lines = @('server.address=0.0.0.0', "server.port=$Port", "fusion.role=$Role", 'fusion.data-dir=./data',
+ "fusion.capacity=$Capacity", "fusion.gateway-port=$UdpPort", 'fusion.discovery-enabled=true')
+if ($Role -eq 'coordinator') {
+ # 总控默认在同一个进程中执行任务;只调度模式需显式关闭执行能力。
+ $lines += ('fusion.coordinator-worker-enabled=' + (-not $CoordinatorOnly).ToString().ToLowerInvariant())
+}
+if ($Role -ne 'gateway' -and -not $CoordinatorOnly) { $lines += "fusion.image-port=$ImagePort" }
+if ($NodeName) { $lines += ('fusion.node-name=' + (ConvertTo-XtrhPropertyValue -Value $NodeName)) }
+if ($AdvertisedHost) { $lines += "fusion.advertised-host=$AdvertisedHost" }
+if ($DiscoveryAddress) { $lines += "fusion.discovery-address=$DiscoveryAddress" }
+# 默认没有固定总控地址,节点在局域网自动发现;跨网段时才使用统一域名兜底。
+if ($CoordinatorUrl) { $lines += "fusion.coordinator-url=$CoordinatorUrl" }
+Write-XtrhText -Path (Join-Path $root 'config/service.local.properties') -Content (($lines -join "`n") + "`n")
+Write-XtrhText -Path (Join-Path $root 'deployment.json') -Content (@{role = $Role; port = $Port} | ConvertTo-Json)
+foreach ($name in @('Common.ps1', 'Start-Xtrh.ps1', 'Stop-Xtrh.ps1', 'Register-XtrhStartup.ps1')) {
+ Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination (Join-Path $root "scripts/$name") -Force
+}
+Write-Output ('部署完成:' + $root + ',角色:' + $Role + ',HTTP 端口:' + $Port)
diff --git a/scripts/Measure-Comments.ps1 b/scripts/Measure-Comments.ps1
new file mode 100644
index 0000000..28b2227
--- /dev/null
+++ b/scripts/Measure-Comments.ps1
@@ -0,0 +1,26 @@
+[CmdletBinding()]
+param(
+ [string[]]$SourceRoots = @((Join-Path (Split-Path -Parent $PSScriptRoot) 'src/main/java')),
+ [double]$MinimumPercent = 20,
+ [switch]$Detailed
+)
+$ErrorActionPreference = 'Stop'
+$rows = foreach ($root in $SourceRoots) {
+ foreach ($file in Get-ChildItem -LiteralPath $root -Filter '*.java' -File -Recurse) {
+ $lines = [IO.File]::ReadAllLines($file.FullName)
+ $nonEmpty = @($lines | Where-Object { $_.Trim().Length -gt 0 })
+ $comments = @($nonEmpty | Where-Object { $_ -match '^\s*(//|/\*|\*|\*/)' })
+ [pscustomobject]@{
+ File = $file.FullName
+ NonEmpty = $nonEmpty.Count
+ CommentLines = $comments.Count
+ Percent = if ($nonEmpty.Count) { [Math]::Round(100.0 * $comments.Count / $nonEmpty.Count, 2) } else { 0 }
+ }
+ }
+}
+$total = ($rows | Measure-Object NonEmpty -Sum).Sum
+$commentTotal = ($rows | Measure-Object CommentLines -Sum).Sum
+$percent = if ($total) { 100.0 * $commentTotal / $total } else { 0 }
+if ($Detailed) { $rows | Format-Table -AutoSize }
+Write-Output ('Java 注释占比:{0:N2}%({1} 注释行 / {2} 非空源码行)' -f $percent, $commentTotal, $total)
+if ($percent -lt $MinimumPercent) { throw ('尚未达到约定的 {0}% 注释行要求。' -f $MinimumPercent) }
diff --git a/scripts/New-ClusterCredential.ps1 b/scripts/New-ClusterCredential.ps1
new file mode 100644
index 0000000..39ce186
--- /dev/null
+++ b/scripts/New-ClusterCredential.ps1
@@ -0,0 +1,19 @@
+[CmdletBinding()]
+param([Parameter(Mandatory)][string]$CredentialFile, [string]$ClusterId = 'pxhj-fusion')
+. (Join-Path $PSScriptRoot 'Common.ps1')
+if ($ClusterId -notmatch '^[a-zA-Z0-9_-]{1,64}$') { throw 'ClusterId 仅允许字母、数字、下划线和短横线。' }
+$credentialPath = [System.IO.Path]::GetFullPath($CredentialFile)
+if (Test-Path -LiteralPath $credentialPath) { throw '凭证文件已存在;不会覆盖现有集群凭证。' }
+[System.IO.Directory]::CreateDirectory((Split-Path -Parent $credentialPath)) | Out-Null
+$bytes = New-Object byte[] 32
+$random = [System.Security.Cryptography.RandomNumberGenerator]::Create()
+try { $random.GetBytes($bytes) } finally { $random.Dispose() }
+$credential = [Convert]::ToBase64String($bytes)
+$stream = [System.IO.File]::Open($credentialPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)
+try {
+ $content = [System.Text.Encoding]::UTF8.GetBytes("fusion.cluster-id=$ClusterId`nfusion.token=$credential`n")
+ $stream.Write($content, 0, $content.Length)
+} finally { $stream.Dispose() }
+Protect-XtrhPrivateFile -Path $credentialPath
+# 凭证内容不输出、不放进命令行;将此私有文件经可信方式复制到同一集群的部署电脑。
+Write-Output ('集群凭证已生成并限制访问:' + $credentialPath)
diff --git a/scripts/Observe-OriginalImage.ps1 b/scripts/Observe-OriginalImage.ps1
new file mode 100644
index 0000000..222748a
--- /dev/null
+++ b/scripts/Observe-OriginalImage.ps1
@@ -0,0 +1,207 @@
+[CmdletBinding()]
+param(
+ [string]$CoordinatorUrl = 'http://127.0.0.1:18100',
+ [string]$CredentialFile = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/cluster.local.properties'),
+ [ValidatePattern('^[0-9a-fA-F-]{36}$')][string]$SourceEndpointId,
+ [ValidateRange(1,3600)][int]$DurationSeconds = 30,
+ [ValidateRange(100,5000)][int]$PollMilliseconds = 500,
+ [string]$OutputDirectory = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/image-observation'),
+ [switch]$InventoryOnly,
+ [switch]$AllowPending,
+ [switch]$QuietFrames,
+ [switch]$PrepareMigration,
+ [switch]$Migrate,
+ [string]$FromNodeId,
+ [string]$ToNodeId
+)
+. (Join-Path $PSScriptRoot 'Image-Verification.Common.ps1')
+
+function Get-ImageTask {
+ param($Snapshot)
+ $matches = @($Snapshot.tasks | Where-Object { $_.mediaKind -eq 'ORIGINAL_IMAGE' -and $_.sourceEndpointId -eq $SourceEndpointId })
+ if ($matches.Count -gt 1) { throw '来源出现多个原图任务,已停止联调。' }
+ if ($matches.Count -eq 0) { return $null }
+ return $matches[0]
+}
+
+function Write-ImageRecord {
+ param($Record)
+ # 白名单记录不含 playUrl/mediaKey、私有设备密钥、集群凭证或 PXHJ 会话。
+ [IO.File]::AppendAllText($script:journalFile, ($Record | ConvertTo-Json -Depth 5 -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
+}
+
+function Write-ImageTaskState {
+ param($Snapshot)
+ $task = Get-ImageTask -Snapshot $Snapshot
+ $status = if ($null -eq $task) { 'NOT_REGISTERED' } else { [string]$task.status }
+ $nodeId = if ($null -ne $task -and $task.PSObject.Properties['nodeId']) { [string]$task.nodeId } else { '' }
+ $version = if ($null -ne $task -and $task.PSObject.Properties['version']) { [long]$task.version } else { 0L }
+ $ready = $null -ne $task -and $task.PSObject.Properties['imageReady'] -and [bool]$task.imageReady
+ $stateKey = $status + '/' + $nodeId + '/' + $version + '/' + $ready
+ if ($stateKey -eq $script:lastTaskState) { return }
+ $record = [ordered]@{ event = 'task-state'; observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
+ endpointId = $SourceEndpointId; status = $status; nodeId = $nodeId; version = $version; imageReady = $ready }
+ [IO.File]::AppendAllText((Join-Path $script:observationRoot 'task-states.jsonl'),
+ ($record | ConvertTo-Json -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
+ Write-Information ('原图任务状态:' + $status + ',node=' + $nodeId + ',version=' + $version + ',imageReady=' + $ready) -InformationAction Continue
+ $script:lastTaskState = $stateKey
+}
+
+function Read-CurrentOriginalFrame {
+ param($Snapshot)
+ $task = Get-ImageTask -Snapshot $Snapshot
+ if ($null -eq $task -or -not $task.PSObject.Properties['nodeId'] -or -not $task.PSObject.Properties['version']) { return $null }
+ $node = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $task.nodeId })
+ if ($node.Count -ne 1 -or -not $node[0].online) { return $null }
+ $path = '/node/images/' + [Uri]::EscapeDataString([string]$task.taskId) + '/' + [long]$task.version
+ $metadataResponse = Invoke-XtrhImageSigned -BaseUrl $node[0].baseUrl -Path ($path + '/latest')
+ if ($metadataResponse.Status -eq 404) { return $null }
+ $metadata = ConvertFrom-XtrhImageJson -Response $metadataResponse
+ if ($metadata.endpointId -ne $SourceEndpointId -or [int]$metadata.bytes -lt 4 -or [int]$metadata.bytes -gt 2097152 -or
+ [int]$metadata.width -lt 1 -or [int]$metadata.width -gt 4096 -or [int]$metadata.height -lt 1 -or [int]$metadata.height -gt 4096) {
+ throw '原图元数据与请求来源或大小限制不一致。'
+ }
+ $frameResponse = Invoke-XtrhImageSigned -BaseUrl $node[0].baseUrl -Path ($path + '/frames/' + [Uri]::EscapeDataString([string]$metadata.frameId))
+ # 迁移或近期帧淘汰可使取图返回 404,重新查询即可,不能借用其他版本的图片。
+ if ($frameResponse.Status -eq 404) { return $null }
+ if ($frameResponse.Status -ne 200 -or $frameResponse.ContentType -notmatch '^image/jpeg(?:;|$)' -or
+ $frameResponse.Bytes.Length -ne [int]$metadata.bytes -or $frameResponse.Bytes[0] -ne 255 -or $frameResponse.Bytes[1] -ne 216 -or
+ $frameResponse.Bytes[-2] -ne 255 -or $frameResponse.Bytes[-1] -ne 217) { throw '节点未返回一致的签名 JPEG。' }
+ $now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
+ $record = [ordered]@{
+ observedTime = $now; endpointId = $SourceEndpointId; taskId = $task.taskId; version = [long]$task.version
+ nodeId = $task.nodeId; frameId = $metadata.frameId; sourceTime = [long]$metadata.sourceTime
+ receivedTime = [long]$metadata.receivedTime; ageMs = $now - [long]$metadata.sourceTime
+ width = [int]$metadata.width; height = [int]$metadata.height; bytes = $frameResponse.Bytes.Length
+ sha256 = Get-XtrhImageDigest -Bytes $frameResponse.Bytes; responseAuthenticated = $true
+ }
+ # 图像按帧号缓存,只落业务图片与白名单元数据,绝不保存原始总控完整快照。
+ if ($record.frameId -ne $script:lastFrameId -or $record.nodeId -ne $script:lastNodeId -or $record.version -ne $script:lastVersion) {
+ [IO.File]::WriteAllBytes((Join-Path $script:observationRoot ('frame-' + [Guid]::Parse($record.frameId).ToString() + '.jpg')), $frameResponse.Bytes)
+ Write-ImageRecord -Record $record
+ if (-not $QuietFrames) {
+ Write-Information ('原图已验证:node=' + $record.nodeId + ',version=' + $record.version + ',sourceTime=' + $record.sourceTime +
+ ',receivedTime=' + $record.receivedTime + ',ageMs=' + $record.ageMs + ',bytes=' + $record.bytes) -InformationAction Continue
+ }
+ $script:lastFrameId = $record.frameId; $script:lastNodeId = $record.nodeId; $script:lastVersion = $record.version
+ $script:verifiedFrames++
+ if ($record.ageMs -ge -5000 -and $record.ageMs -le 5000) { $script:freshFrames++ }
+ }
+ return [pscustomobject]$record
+}
+
+function Get-MigrationPlan {
+ param($Snapshot)
+ if (-not $SourceEndpointId -or -not $FromNodeId -or -not $ToNodeId -or $FromNodeId -eq $ToNodeId) {
+ throw '迁移准备需提供来源 endpoint、不同的起点和终点 nodeId。'
+ }
+ $source = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $FromNodeId })
+ $target = @($Snapshot.nodes | Where-Object { $_.nodeId -eq $ToNodeId })
+ if ($source.Count -ne 1 -or $target.Count -ne 1 -or -not $source[0].online -or -not $target[0].online -or
+ -not $target[0].enabled -or [int]$target[0].imageInputPort -lt 1) { throw '迁移节点未在线或目标原图接入未就绪。' }
+ # 强制指定目标需要临时限制其他节点,因此只允许当前来源这一项活任务存在。
+ # 用户正在运行的其他试验或其他原图通道存在时拒绝操作,避免改变无关调度。
+ $foreign = @($Snapshot.tasks | Where-Object { $_.status -ne 'ENDED' -and
+ (-not $_.PSObject.Properties['sourceEndpointId'] -or $_.sourceEndpointId -ne $SourceEndpointId) })
+ if ($foreign.Count -gt 0) { throw '存在其他活任务,不能通过全节点容量执行定向迁移。' }
+ $changes = @($Snapshot.nodes | Where-Object online | ForEach-Object {
+ [pscustomobject]@{ nodeId = $_.nodeId; savedCapacity = [int]$_.capacity
+ temporaryCapacity = $(if ($_.nodeId -eq $ToNodeId) { [Math]::Max(1, [int]$_.capacity) } else { 0 }) }
+ })
+ return [pscustomobject]@{ coordinatorId = $Snapshot.serviceId; endpointId = $SourceEndpointId
+ fromNodeId = $FromNodeId; toNodeId = $ToNodeId; capacities = $changes }
+}
+
+function Set-TemporaryCapacity {
+ param([string]$NodeId, [int]$Capacity)
+ $response = Invoke-XtrhImageSigned -BaseUrl $CoordinatorUrl -Path ('/control/nodes/' + [Uri]::EscapeDataString($NodeId) + '/capacity') `
+ -Method PATCH -Payload @{ capacity = $Capacity }
+ if ($response.Status -ne 200) { throw ('节点容量变更失败,HTTP ' + $response.Status) }
+}
+
+$script:observationRoot = [IO.Path]::GetFullPath($OutputDirectory)
+[void][IO.Directory]::CreateDirectory($script:observationRoot)
+$script:journalFile = Join-Path $script:observationRoot 'frames.jsonl'
+$script:lastFrameId = ''; $script:lastNodeId = ''; $script:lastVersion = 0L; $script:verifiedFrames = 0; $script:freshFrames = 0
+$script:lastTaskState = ''
+$changed = [Collections.Generic.List[object]]::new()
+$plan = $null
+Initialize-XtrhImageVerification -CredentialFile $CredentialFile
+try {
+ $initial = Get-XtrhImageSnapshot -CoordinatorUrl $CoordinatorUrl
+ $inventory = [ordered]@{ observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds(); epoch = $initial.epoch
+ nodes = @($initial.nodes | Select-Object nodeId,name,nodeRole,online,baseUrl,imageHost,imageInputPort,capacity,running,reserved)
+ routes = @($initial.routes | Select-Object endpointId,sourceHost,sourcePort,relayPort,targetHost,targetPort)
+ tasks = @($initial.tasks | Select-Object taskId,mediaKind,sourceEndpointId,collectorId,desired,status,nodeId,version,imageReady,lastFrameTime) }
+ [IO.File]::WriteAllText((Join-Path $script:observationRoot 'inventory.json'), ($inventory | ConvertTo-Json -Depth 8), [Text.UTF8Encoding]::new($false))
+ if ($InventoryOnly) {
+ Write-Output ('只读库存已保存:在线节点 ' + @($initial.nodes | Where-Object online).Count + ',任务 ' + @($initial.tasks).Count + ',UDP 路由 ' + @($initial.routes).Count)
+ return
+ }
+ if (-not $SourceEndpointId) { throw '持续观测需提供正式采集连接的 source endpointId。' }
+ if ($PrepareMigration -or $Migrate) {
+ $plan = Get-MigrationPlan -Snapshot $initial
+ [IO.File]::WriteAllText((Join-Path $script:observationRoot 'migration-plan.json'), ($plan | ConvertTo-Json -Depth 6), [Text.UTF8Encoding]::new($false))
+ if ($PrepareMigration -and -not $Migrate) { Write-Output '迁移计划与原容量已保存,未执行任何变更。'; return }
+ }
+ if ($Migrate) {
+ $active = Get-ImageTask -Snapshot $initial
+ if ($null -eq $active -or -not $active.PSObject.Properties['nodeId'] -or $active.nodeId -ne $FromNodeId) {
+ throw '来源任务当前不在指定起点,拒绝执行迁移。'
+ }
+ # 先给目标留足接管空间,再限制其他节点;旧接收端等目标收到新鲜图后才撤销。
+ foreach ($change in @($plan.capacities | Sort-Object { if ($_.nodeId -eq $ToNodeId) { 0 } else { 1 } })) {
+ if ($change.savedCapacity -eq $change.temporaryCapacity) { continue }
+ $changed.Add($change)
+ Set-TemporaryCapacity -NodeId $change.nodeId -Capacity $change.temporaryCapacity
+ }
+ }
+ $deadline = [DateTimeOffset]::UtcNow.AddSeconds($DurationSeconds)
+ $migrationObserved = $false
+ $oldVersionRevoked = $false
+ while ([DateTimeOffset]::UtcNow -lt $deadline) {
+ $snapshot = Get-XtrhImageSnapshot -CoordinatorUrl $CoordinatorUrl
+ Write-ImageTaskState -Snapshot $snapshot
+ $frame = Read-CurrentOriginalFrame -Snapshot $snapshot
+ if ($Migrate -and $null -ne $frame -and $frame.nodeId -eq $ToNodeId -and $frame.ageMs -ge -5000 -and $frame.ageMs -le 5000) {
+ $migrationObserved = $true
+ }
+ if ($Migrate -and $migrationObserved -and -not $oldVersionRevoked) {
+ # 新节点接管后旧节点需及时释放许可;首个心跳之前可能仍可读,窗口内持续检查。
+ $oldNode = @($initial.nodes | Where-Object { $_.nodeId -eq $FromNodeId })[0]
+ $oldPath = '/node/images/' + [Uri]::EscapeDataString([string]$active.taskId) + '/' + [long]$active.version + '/latest'
+ $oldResponse = Invoke-XtrhImageSigned -BaseUrl $oldNode.baseUrl -Path $oldPath
+ if ($oldResponse.Status -eq 404) {
+ $oldVersionRevoked = $true
+ $revokedRecord = [ordered]@{ event = 'old-version-revoked'; observedTime = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
+ endpointId = $SourceEndpointId; nodeId = $FromNodeId; version = [long]$active.version; signedHttpStatus = 404 }
+ [IO.File]::AppendAllText((Join-Path $script:observationRoot 'migration-events.jsonl'),
+ ($revokedRecord | ConvertTo-Json -Compress) + [Environment]::NewLine, [Text.UTF8Encoding]::new($false))
+ Write-Information '旧节点分配已撤销:私有原图查询返回签名 404。' -InformationAction Continue
+ } elseif ($oldResponse.Status -ne 200) { throw '旧节点未明确返回有效图片或已撤销状态。' }
+ }
+ Start-Sleep -Milliseconds $PollMilliseconds
+ }
+ if ($script:verifiedFrames -eq 0) {
+ # 部署前允许记录尚未登记,不将空窗口宣称为取图验收通过;迁移必须取得新图。
+ if ($AllowPending -and -not $Migrate -and $script:lastTaskState.StartsWith('NOT_REGISTERED/')) {
+ Write-Output '观测窗口结束:来源尚未登记,未得到原图,原图接入尚未验证。'
+ return
+ }
+ throw '观测期间没有取得经过签名验证的原图。'
+ }
+ if ($script:freshFrames -eq 0) { throw '只读到了旧缓存,观测期间没有收到五秒内的新鲜原图。' }
+ if ($Migrate -and -not $migrationObserved) { throw '观测期间未证明目标节点已接管新鲜原图。' }
+ if ($Migrate -and -not $oldVersionRevoked) { throw '目标已接管,但未证明旧节点版本已撤销。' }
+ Write-Output ('原图观测完成:验证 ' + $script:verifiedFrames + ' 个新帧,记录已保存。')
+} finally {
+ # 仅 -Migrate 会进入恢复分支;默认观测与 -PrepareMigration 都没有容量写操作。
+ # 目标/原节点先恢复,再恢复其余节点,使用保存的正式原值,不推断历史默认容量。
+ $restoreFailed = $false
+ foreach ($change in @($changed | Sort-Object { if ($_.nodeId -eq $FromNodeId) { 0 } else { 1 } })) {
+ try { Set-TemporaryCapacity -NodeId $change.nodeId -Capacity $change.savedCapacity }
+ catch { $restoreFailed = $true; Write-Warning ('容量恢复失败:node=' + $change.nodeId + ',原值=' + $change.savedCapacity + ';请使用 migration-plan.json 恢复。') }
+ }
+ Close-XtrhImageVerification
+ if ($restoreFailed) { throw '部分原容量未恢复,请按保存的迁移计划处理。' }
+}
diff --git a/scripts/Open-Console.ps1 b/scripts/Open-Console.ps1
new file mode 100644
index 0000000..39937f8
--- /dev/null
+++ b/scripts/Open-Console.ps1
@@ -0,0 +1,12 @@
+[CmdletBinding()]
+param([string]$InstallRoot = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/coordinator'), [string]$JavaHome, [switch]$OpenBrowser)
+$ErrorActionPreference = 'Stop'
+$root = [IO.Path]::GetFullPath($InstallRoot)
+$deployment = Get-Content -LiteralPath (Join-Path $root 'deployment.json') -Raw | ConvertFrom-Json
+if ($deployment.role -ne 'coordinator') { throw '页面入口需要总控部署目录。' }
+& (Join-Path $PSScriptRoot 'Start-Xtrh.ps1') -InstallRoot $root -JavaHome $JavaHome
+$url = 'http://127.0.0.1:' + $deployment.port + '/console/#receive'
+$page = Invoke-WebRequest -UseBasicParsing -Uri $url.Split('#')[0] -TimeoutSec 5
+if ([int]$page.StatusCode -ne 200) { throw '页面未就绪,请先部署带页面的新版本。' }
+Write-Output ('系统融合软件:' + $url)
+if ($OpenBrowser) { Start-Process $url }
diff --git a/scripts/Register-XtrhStartup.ps1 b/scripts/Register-XtrhStartup.ps1
new file mode 100644
index 0000000..be03a16
--- /dev/null
+++ b/scripts/Register-XtrhStartup.ps1
@@ -0,0 +1,24 @@
+[CmdletBinding()]
+param([string]$InstallRoot = (Split-Path -Parent $PSScriptRoot), [string]$JavaHome, [switch]$Remove)
+. (Join-Path $PSScriptRoot 'Common.ps1')
+$root = [System.IO.Path]::GetFullPath($InstallRoot)
+$sha = [System.Security.Cryptography.SHA256]::Create()
+try { $hash = [BitConverter]::ToString($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($root.ToLowerInvariant()))).Replace('-', '').Substring(0, 12) }
+finally { $sha.Dispose() }
+$taskName = 'PXHJ-XTRH-' + $hash
+if ($Remove) {
+ if (Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue) { Unregister-ScheduledTask -TaskName $taskName -Confirm:$false }
+ Write-Output ('已移除此部署实例的自动启动:' + $taskName)
+ return
+}
+$java = Get-XtrhJava -JavaHome $JavaHome
+$resolvedJavaHome = Split-Path -Parent (Split-Path -Parent $java)
+$startScript = Join-Path $root 'scripts/Start-Xtrh.ps1'
+$arguments = '-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + $startScript + '" -InstallRoot "' + $root + '" -JavaHome "' + $resolvedJavaHome + '"'
+$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments -WorkingDirectory $root
+$user = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
+$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
+$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Limited
+$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit ([TimeSpan]::Zero)
+Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
+Write-Output ('已配置当前用户登录后后台自动启动:' + $taskName)
diff --git a/scripts/Rollback-EmbeddedImageRoute.py b/scripts/Rollback-EmbeddedImageRoute.py
new file mode 100644
index 0000000..d98b749
--- /dev/null
+++ b/scripts/Rollback-EmbeddedImageRoute.py
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+"""回退指定图片协商stage;默认只检查,只有显式--apply才由操作者在板上执行。"""
+import argparse
+import importlib.util
+import json
+import os
+from pathlib import Path
+import shutil
+import signal
+import subprocess
+import time
+import uuid
+
+
+# 复用相同路径/哈希校验,不引入SSH、密码或远端自动连接。
+loader = importlib.util.spec_from_file_location("collector_stage", Path(__file__).with_name("Stage-EmbeddedImageRoute.py"))
+helper = importlib.util.module_from_spec(loader)
+loader.loader.exec_module(helper)
+
+
+def atomic_restore(source, destination, root):
+ # 临时文件必须和目的文件同目录,os.replace不会跨文件系统失去原子性。
+ # 禁止符号链接目的地或借父目录链接逃出当前采集工程。
+ if destination.is_symlink() or not destination.parent.resolve().is_relative_to(root):
+ raise RuntimeError("Unsafe rollback target")
+ temporary = destination.with_name(destination.name + ".rollback-" + uuid.uuid4().hex)
+ shutil.copy2(source, temporary)
+ os.replace(str(temporary), str(destination))
+
+
+def main():
+ # 回退授权来自本次部署操作,--apply是动作入口,不是新的用户审批。
+ # root可以先审阅默认检查输出,再按既有授权直接执行。
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect"))
+ parser.add_argument("--stage", type=Path, required=True)
+ parser.add_argument("--launcher", choices=("start-network-simulation.sh", "start.sh"), default="start-network-simulation.sh")
+ parser.add_argument("--apply", action="store_true")
+ args = parser.parse_args()
+ root, directory, record = helper.stage_record(args)
+ # 使用板上当前原启动器,不重写串口桥或PID管理逻辑。
+ launcher = root / args.launcher
+ if launcher.is_symlink() or not launcher.is_file() or not os.access(str(launcher), os.X_OK):
+ raise RuntimeError("Original collector launcher is invalid")
+ if not record.get("built"):
+ raise RuntimeError("Stage has no successfully built candidate")
+ # 完整原配置快照恢复JPEG质量、帧率及其他原设置,不只把协商开关关掉。
+ # 设备密钥不在此备份范围,回退不能替换或改写密钥。
+ backups = dict(record["previousPatchHashes"])
+ backups.update({"px_Collect.pro": record["previousProjectSha256"],
+ "bin/px_JKGL": record["previousBinarySha256"], "config.ini": record["previousConfigSha256"]})
+ # 不凭文件名认为当前版本属于本次部署,每项都要匹配原版或候选哈希。
+ candidates = dict(record["candidatePatchHashes"])
+ candidates.update({"px_Collect.pro": record["candidateProjectSha256"],
+ "bin/px_JKGL": record["candidateBinarySha256"], "config.ini": record["candidateConfigSha256"]})
+ # 只允许回退本stage候选或原版本;其他人后续更新的版本不能被历史备份覆盖。
+ # 先校验全部文件和备份,再停止进程,不能边校验边执行部分回退。
+ for relative, previous in backups.items():
+ live = root / relative
+ if live.is_symlink() or not live.parent.resolve().is_relative_to(root):
+ raise RuntimeError("Unsafe live path: " + relative)
+ current = helper.digest(live) if live.is_file() else None
+ if current not in (previous, candidates[relative]):
+ raise RuntimeError("Unrelated live update detected: " + relative)
+ preserved = directory / "backup" / relative
+ if previous is not None and (preserved.is_symlink() or not preserved.resolve().is_relative_to(directory)
+ or not preserved.is_file() or helper.digest(preserved) != previous):
+ raise RuntimeError("Rollback backup hash mismatch: " + relative)
+ for relative, checksum in record["preservedHashes"].items():
+ if helper.digest(root / relative) != checksum:
+ raise RuntimeError("Unrelated preserved source changed: " + relative)
+ # 默认只输出非敏感回退范围,审阅时不会停进程或读取密钥。
+ if not args.apply:
+ print(json.dumps({"rollbackReady": True, "apply": False, "stage": str(directory),
+ "collectorPids": helper.process_ids(root), "files": list(backups)}))
+ return
+ # 进程列表使用真实exe路径,当前串口桥即使同属工程也不会被停止。
+ original_pids = helper.process_ids(root)
+ # 仅停止真实exe路径指向本工程的采集程序,不动串口桥、系统服务或其他同名进程。
+ for pid in original_pids:
+ os.kill(pid, signal.SIGTERM)
+ deadline = time.monotonic() + 5
+ while set(original_pids) & set(helper.process_ids(root)) and time.monotonic() < deadline:
+ time.sleep(0.1)
+ for pid in set(original_pids) & set(helper.process_ids(root)):
+ os.kill(pid, signal.SIGKILL)
+ time.sleep(0.1)
+ if helper.process_ids(root):
+ # 自动守护重启需要操作者处理其owner,工具不擅自停systemd或改变开机项。
+ raise RuntimeError("Collector restarted by another owner; live files were not replaced")
+ # 先确认原进程全部退出,再原子替换文件,避免恢复过程中旧程序继续发包。
+ for relative, previous in backups.items():
+ live = root / relative
+ if previous is None:
+ # 只移除本stage新增且哈希匹配的源码文件;不递归删除任何目录。
+ if live.exists():
+ live.unlink()
+ else:
+ live.parent.mkdir(parents=True, exist_ok=True)
+ atomic_restore(directory / "backup" / relative, live, root)
+ # 使用当前已核对的启动入口,保留原native日志策略,不恢复旧run.log重定向脚本。
+ # 原启动脚本负责库路径和offscreen参数,回退不修改任何启动脚本内容。
+ # stdout/stderr交给原native日志接管,禁止再次追加到无界run.log。
+ child = None
+ if args.launcher == "start.sh":
+ child = subprocess.Popen([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
+ else:
+ # 网络模拟启动器自管collector.pid,不能把短命shell的PID写成采集PID。
+ subprocess.run([str(launcher)], cwd=str(root), stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True, timeout=15)
+ time.sleep(2)
+ running = helper.process_ids(root)
+ if (child is not None and child.poll() is not None) or not running:
+ raise RuntimeError("Original collector could not restart; backups remain available")
+ # 原网络模拟启动器识别PID文件,避免下一次调用误启动第二个采集进程。
+ pid_file = root / "logs/collector.pid"
+ if child is not None and pid_file.exists() and not pid_file.is_symlink():
+ pid_file.write_text(str(child.pid) + "\n", encoding="ascii")
+ # 此处只确认原进程重新出现,真实图片/采集/试验链路由root继续验证。
+ print(json.dumps({"rolledBack": True, "collectorPids": running, "stage": str(directory)}))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/Stage-EmbeddedImageRoute.py b/scripts/Stage-EmbeddedImageRoute.py
new file mode 100644
index 0000000..e5b5c61
--- /dev/null
+++ b/scripts/Stage-EmbeddedImageRoute.py
@@ -0,0 +1,443 @@
+#!/usr/bin/env python3
+"""准备嵌入式图片协商候选版本;本工具不停止进程、不替换在用程序、不修改在用配置。"""
+import argparse
+import configparser
+import hashlib
+import io
+import ipaddress
+import json
+import os
+from pathlib import Path
+import platform
+import re
+import shutil
+import subprocess
+import tarfile
+import time
+import uuid
+
+
+# 本次仅交付图片地址协商代码;其他业务源码必须以当前开发板为基线。
+# 不整份上传历史工程,避免覆盖最新日志保留、串口或RTP收流修改。
+PATCH_FILES = (
+ "util/PlatformWireCodec.h", "util/PlatformWireCodec.cpp",
+ "util/PlatformImageRouteClient.h", "util/PlatformImageRouteClient.cpp",
+ "util/PlatformImageSender.h", "util/PlatformImageSender.cpp",
+)
+# 测试只运行临时目录与loopback,不使用实际设备密钥和采集端口。
+TEST_FILES = (
+ "tests/image_route/native_image_route_check.pro",
+ "tests/image_route/native_image_route_check.cpp",
+ "tests/logging/native_logging_check.pro",
+ "tests/logging/native_logging_check.cpp",
+)
+ALLOWED = set(PATCH_FILES + TEST_FILES)
+# 这些文件只记录哈希,以验证部署保留现有功能;本工具不会覆盖它们。
+PRESERVED = (
+ "main.cpp", "util/log.cpp", "util/log.h", "util/CollectorLogRetention.cpp",
+ "util/CollectorLogRetention.h", "start.sh",
+ "Go2Controller/network/rtpreceiver.cpp", "Fly2Controller/network/RtpReceiver.cpp",
+ "util/ModuleVideoReceiver.cpp", "CJ2Controller/serialworker.cpp", "mainwindow.cpp",
+)
+
+
+def digest(path):
+ # 分块计算,避免相机SDK等大文件一次读入内存。
+ value = hashlib.sha256()
+ with path.open("rb") as stream:
+ for block in iter(lambda: stream.read(1024 * 1024), b""):
+ value.update(block)
+ return value.hexdigest()
+
+
+def write_json(path, value):
+ # 部署记录留在私有stage;不输出配置全文、密钥文件或进程环境。
+ path.write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
+
+
+def package(args):
+ # 使用当前工作空间源码,禁止调用旧prepare-board-deployment脚本重建全量包。
+ # 包只携带本次代码与测试,不携带平台IP、上传配置、密钥或旧可执行文件。
+ # 压缩包不含旧可执行文件,必须经实际ARM工具链重新构建。
+ # 不读取本机现网INI,候选IP必须在板端stage时显式指定。
+ source = args.source.resolve(strict=True)
+ # 同名产物不覆盖,方便将每次候选与原始校验记录一一对应。
+ if args.package.exists():
+ raise RuntimeError("Package already exists; choose a new output filename")
+ args.package.parent.mkdir(parents=True, exist_ok=True)
+ manifest = {"protocol": "COLLECTOR_IMAGE_ROUTE", "format": 1, "files": []}
+ # 精确白名单不包含任何ini/密钥/运行目录,也不递归打包未知文件。
+ with tarfile.open(str(args.package), "w:gz") as archive:
+ for relative in PATCH_FILES + TEST_FILES:
+ path = source / relative
+ if path.is_symlink() or not path.resolve(strict=True).is_relative_to(source):
+ raise RuntimeError("Unexpected source path: " + relative)
+ data = path.read_bytes()
+ item = tarfile.TarInfo(relative)
+ item.size = len(data)
+ item.mode = 0o644
+ archive.addfile(item, io.BytesIO(data))
+ manifest["files"].append({"path": relative, "sha256": hashlib.sha256(data).hexdigest()})
+ data = json.dumps(manifest, ensure_ascii=False, indent=2).encode("utf-8")
+ item = tarfile.TarInfo("image-route-manifest.json")
+ item.size = len(data)
+ item.mode = 0o644
+ archive.addfile(item, io.BytesIO(data))
+ print(json.dumps({"package": str(args.package.resolve()), "files": len(ALLOWED),
+ "sha256": digest(args.package)}, ensure_ascii=False))
+
+
+def live_root(args):
+ # 路径必须是已存在工程,禁止根目录及符号链接冒充部署目录。
+ if args.root.is_symlink():
+ raise RuntimeError("Collector root cannot be a symbolic link")
+ # 所有后续路径由规范化根目录拼接,不接收任意远端目标路径。
+ root = args.root.resolve(strict=True)
+ if root == Path(root.anchor) or not (root / "px_Collect.pro").is_file():
+ raise RuntimeError("Collector project root is invalid")
+ return root
+
+
+def process_ids(root):
+ target = str(root / "bin/px_JKGL")
+ # 没有/proc时只用于本地安全测试,不声称发现了实际板端进程。
+ matches = []
+ # 仅核对可执行文件真实路径,不用进程名字匹配其他同名程序。
+ for entry in Path("/proc").iterdir() if Path("/proc").is_dir() else []:
+ if not entry.name.isdigit():
+ continue
+ try:
+ if os.readlink(str(entry / "exe")).replace(" (deleted)", "") == target:
+ matches.append(int(entry.name))
+ except (FileNotFoundError, PermissionError):
+ pass
+ return sorted(matches)
+
+
+def inspect(args):
+ # 不主动建立SSH,操作者负责将工具上传到已确认的目标板再运行。
+ # inspect不改网络、时钟、配置、库或进程,输出可供root比对的哈希。
+ # 输出源码哈希供当前板与本机差异核对,不能把历史记录当成现网事实。
+ root = live_root(args)
+ # 地址和目标路径来自root确认,inspect只给非敏感状态与源码哈希。
+ print(json.dumps({"root": str(root), "architecture": platform.machine(),
+ "collectorPids": process_ids(root), "binaryPresent": (root / "bin/px_JKGL").is_file(),
+ "qmake": shutil.which("qmake"), "make": shutil.which("make"),
+ "preservedSources": {name: digest(root / name) for name in PRESERVED if (root / name).is_file()},
+ "sourceHashes": {name: digest(root / name) if (root / name).is_file() else None for name in PATCH_FILES},
+ "projectSha256": digest(root / "px_Collect.pro")}, ensure_ascii=False, indent=2))
+
+
+def ignore_files(directory, names):
+ # 不在stage复制在用配置和密钥;测试自行生成临时密钥。
+ # 对bin/runtime/logs/build的排除也避免复制采集历史和巨量日志。
+ ignored = []
+ for name in names:
+ lower = name.lower()
+ if name in {"bin", "runtime", "logs", "build", "debug", "release", ".git", "__pycache__"} \
+ or name.startswith(("Makefile", "moc_", ".qmake")) \
+ or lower.endswith((".ini", ".o", ".obj", ".user", ".pyc")) \
+ or ("key" in lower and lower.endswith((".json", ".properties"))):
+ ignored.append(name)
+ return ignored
+
+
+def payload(args):
+ # 逐个读取白名单文件,不调用tar.extract,拒绝目录穿越、链接或重复条目。
+ # 文件总数和每项大小均受限,未知条目即使名称看似安全也不接受。
+ with tarfile.open(str(args.package), "r:gz") as archive:
+ names = archive.getnames()
+ if len(names) != len(set(names)) or set(names) != ALLOWED | {"image-route-manifest.json"}:
+ raise RuntimeError("Unexpected archive contents")
+ content = {}
+ for name in names:
+ item = archive.getmember(name)
+ if not item.isfile() or item.size <= 0 or item.size > 2 * 1024 * 1024:
+ raise RuntimeError("Invalid archive entry: " + name)
+ content[name] = archive.extractfile(item).read()
+ # manifest也受大小和类型约束,只有白名单完整且逐文件哈希匹配才继续。
+ manifest = json.loads(content.pop("image-route-manifest.json"))
+ if manifest.get("protocol") != "COLLECTOR_IMAGE_ROUTE" or manifest.get("format") != 1:
+ raise RuntimeError("Unknown package format")
+ if len(manifest.get("files", [])) != len(ALLOWED):
+ raise RuntimeError("Manifest entry count is invalid")
+ checks = {item["path"]: item["sha256"] for item in manifest["files"]}
+ if set(checks) != ALLOWED or any(hashlib.sha256(data).hexdigest() != checks[name]
+ for name, data in content.items()):
+ raise RuntimeError("Package hash validation failed")
+ return content
+
+
+def project_append(project):
+ text = project.read_text(encoding="utf-8")
+ # 保留板上已有依赖/DEFINES,尤其GST、日志保留与hiredis真实库位置。
+ # 仅补缺少的HEADERS/SOURCES,不拿本机历史pro覆盖当前ARM工程。
+ # 注释提到文件名不算真实注册;追加动作仅发生在stage副本。
+ uncommented = "\n".join(line.split("#", 1)[0] for line in text.splitlines())
+ # 六个编译单元的类型由白名单后缀决定,不接受manifest提供qmake指令。
+ additions = []
+ for relative in PATCH_FILES:
+ if not re.search(r"(?
+python3 Stage-EmbeddedImageRoute.py verify --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
+```
+
+`stage`输出真实候选路径。stage目录权限700,配置及其备份权限600。构建必须在ARM64目标环境,完整Qt应用、GStreamer、相机SDK、OpenSSL和hiredis依赖全部保留;不存在去掉依赖勉强通过的分支。`make -o Makefile`仅防止板时钟引起Makefile重复生成,不省略正常编译。新目录使用自己的qmake缓存。
+
+完整程序构建成功后检查动态库,并执行内置日志和图片协商隔离QtTest。只有全部成功才记为 `built=true`。候选主程序不会在此阶段启动,不抢真实6000/9200端口。跨语言加解密已在Windows独立验证;ARM默认缺少Java互操作夹具时该可选测试会标记跳过,其他业务断言仍执行。
+
+候选程序在 `/source/bin/px_JKGL`,候选配置在 `/candidate-config.ini`,完整回退材料在 `/backup`,日志和哈希在该stage内。`verify`若发现现网源码、程序或配置在stage后变化会停止,root应重新核对,不能覆盖新修改。
+
+## 切换与回退
+
+root先核对真实PXHJ网关路由已经授权该采集端,再切换。候选配置只新增/启用地址协商以及独立PXHJ原图fallback IP;不修改fixed_json、heartbeat、串口/RTP或密钥文件。图片目的地址不能从fixed_json网关地址推断。保持原图片帧率、质量和尺寸。
+
+实际切换前,运行 `Activate-EmbeddedImageRoute.py` 默认预览。它核验live未变、候选bin/config/源码/pro哈希及两套QtTest结果;显式 `--apply` 才停止本工程采集进程、原子安装候选并调用当前 `start-network-simulation.sh`。原串口桥和PID管理方式保留,禁止恢复旧的无限增长 `run.log` 重定向。启动后核对新写入且连续稳定的native日志状态(owner、1GiB预算、failedWrites=0、withinLimit=true)。失败自动调用该stage回退。准备原图可见、来源身份不变、迁移首帧与租约回退由真实PXHJ继续验收。
+
+```sh
+python3 Activate-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
+python3 Activate-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> --apply
+```
+
+回退先只检查,所有路径和哈希通过后由root显式执行:
+
+```sh
+python3 Rollback-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
+python3 Rollback-EmbeddedImageRoute.py --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID> --apply
+```
+
+回退仅恢复本stage拥有的6份源码、pro、程序和config;默认无写操作。检测到其他部署后的更新、备份哈希改变或未知进程守护重启时停止。默认重用当前 `start-network-simulation.sh`,串口桥与collector.pid由原启动器管理;显式 `--launcher start.sh` 才选择直接启动。不会修改网络、systemd、设备密钥、原始采集数据或试验记录。
+
+此文档和候选包属于部署准备;实际ARM构建、切换及准备/迁移验收需要分别记录真实结果,不能用本机测试替代。
diff --git a/src/main/java/com/xtrh/Application.java b/src/main/java/com/xtrh/Application.java
new file mode 100644
index 0000000..23fee42
--- /dev/null
+++ b/src/main/java/com/xtrh/Application.java
@@ -0,0 +1,18 @@
+package com.xtrh;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+/**
+ * 同一份 Java 程序按配置运行总控、网关或节点,业务操作统一由 PXHJ 发起。
+ * 启动入口保留在根包,使 Spring 能扫描各业务模块中的组件。
+ */
+@SpringBootApplication
+@EnableScheduling
+public class Application {
+ public static void main(String[] args) {
+ System.setProperty("java.awt.headless", "true");
+ SpringApplication.run(Application.class, args);
+ }
+}
diff --git a/src/main/java/com/xtrh/common/config/SchedulerConfiguration.java b/src/main/java/com/xtrh/common/config/SchedulerConfiguration.java
new file mode 100644
index 0000000..874e300
--- /dev/null
+++ b/src/main/java/com/xtrh/common/config/SchedulerConfiguration.java
@@ -0,0 +1,20 @@
+package com.xtrh.common.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
+
+/**
+ * 主节点同时管理集群与执行视频,心跳、调度和画面生成不能占用同一条定时线程。
+ * 定时任务仍使用各自的固定延迟,业务状态同步由对应服务的锁保护。
+ */
+@Configuration
+public class SchedulerConfiguration {
+ @Bean(name = "taskScheduler")
+ public ThreadPoolTaskScheduler taskScheduler() {
+ ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
+ scheduler.setPoolSize(4);
+ scheduler.setThreadNamePrefix("xtrh-scheduled-");
+ return scheduler;
+ }
+}
diff --git a/src/main/java/com/xtrh/common/config/Settings.java b/src/main/java/com/xtrh/common/config/Settings.java
new file mode 100644
index 0000000..af1cda3
--- /dev/null
+++ b/src/main/java/com/xtrh/common/config/Settings.java
@@ -0,0 +1,98 @@
+package com.xtrh.common.config;
+
+import com.xtrh.common.dao.NodeIdentityDao;
+import org.springframework.core.env.Environment;
+import org.springframework.stereotype.Component;
+import org.springframework.boot.web.context.WebServerInitializedEvent;
+import org.springframework.context.event.EventListener;
+
+import java.net.InetAddress;
+import java.nio.file.Path;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * 将部署配置集中转换为服务启动参数,业务层不需要重复读取环境变量。
+ * 配置不完整时直接拒绝启动,避免节点以错误集群身份接收任务。
+ * 本类不依赖总控或发现服务,网关也能独立加载已保存的转发配置。
+ */
+@Component
+public class Settings {
+ public final String role, cluster, token, id, bootId, name, coordinator, advertisedHost, bindHost;
+ public final Path directory;
+ public final int capacity, gatewayPort, targetPort;
+ // HTTP 就绪事件发布实际端口,随机端口部署不能继续上报配置中的 0。
+ public volatile int port;
+ private volatile boolean serverReady;
+ public final long leaseMs;
+ public final boolean discoveryEnabled;
+ public final boolean supportsWorker;
+ public final String discoveryAddress;
+
+ public Settings(Environment env) throws Exception {
+ // 三种角色共用一个 JAR;安装脚本分别指定角色、端口和独立数据目录。
+ role = env.getProperty("fusion.role", "coordinator");
+ if (!Set.of("coordinator", "worker", "gateway").contains(role)) {
+ throw new IllegalArgumentException("Unknown service role");
+ }
+ // 主节点默认兼任执行节点;仅总控部署可关闭执行能力,子节点仍保持执行职责。
+ supportsWorker = workerEnabled(env);
+
+ // 集群名称用于筛选发现线索,凭证则用于请求和响应签名,二者用途不同。
+ // 凭证从私有部署配置读取,不能通过公共健康接口或节点状态返回。
+ cluster = env.getProperty("fusion.cluster-id", "pxhj-fusion");
+ token = env.getProperty("fusion.token", "");
+ if (token.length() < 24) {
+ throw new IllegalArgumentException("Configure a shared service credential with at least 24 characters");
+ }
+
+ // 稳定身份由 DAO 保存;每次进程启动重新生成 bootId,以隔离旧进程回执。
+ // 不在配置层执行文件读写细节,身份文件损坏或无法保存时让启动失败。
+ directory = Path.of(env.getProperty("fusion.data-dir", "./data/" + role))
+ .toAbsolutePath().normalize();
+ id = NodeIdentityDao.loadOrCreate(directory);
+ bootId = UUID.randomUUID().toString();
+
+ name = env.getProperty("fusion.node-name", InetAddress.getLocalHost().getHostName());
+ // 同网段部署默认不填写总控 URL,保留为空以使用自动发现。
+ coordinator = env.getProperty("fusion.coordinator-url", "").replaceAll("/+$", "");
+ advertisedHost = env.getProperty("fusion.advertised-host", "");
+ bindHost = env.getProperty("server.address", "0.0.0.0");
+
+ // 容量是管理员确认的最大并行路数,不代表程序已经测量过显卡能力。
+ capacity = env.getProperty("fusion.capacity", Integer.class, 2);
+ if (capacity < 0 || capacity > 64) {
+ throw new IllegalArgumentException("Capacity must be between 0 and 64");
+ }
+
+ // HTTP 管理、网关 UDP 接入和 PXHJ 接收端口各自配置,不能混为同一入口。
+ port = env.getProperty("server.port", Integer.class, 18100);
+ gatewayPort = env.getProperty("fusion.gateway-port", Integer.class, 19002);
+ targetPort = env.getProperty("fusion.forward-target-port", Integer.class, 19001);
+
+ // 授权有效期交给业务服务用单调时钟计时;这里仅校验部署值范围。
+ leaseMs = env.getProperty("fusion.lease-ms", Long.class, 8000L);
+ if (leaseMs < 3000 || leaseMs > 120000) {
+ throw new IllegalArgumentException("Invalid lease duration");
+ }
+
+ // 多网卡环境可限定发现网卡;不指定时由发现服务尝试可用组播网卡。
+ discoveryEnabled = env.getProperty("fusion.discovery-enabled", Boolean.class, true);
+ discoveryAddress = env.getProperty("fusion.discovery-address", "");
+ }
+
+ /** 能力判断由配置与条件组件共用,避免启动了媒体接口却没有执行服务。 */
+ public static boolean workerEnabled(Environment env) {
+ String role = env.getProperty("fusion.role", "coordinator");
+ return role.equals("worker") || (role.equals("coordinator")
+ && env.getProperty("fusion.coordinator-worker-enabled", Boolean.class, true));
+ }
+
+ /** 本机执行端等待总控 HTTP 已就绪,再按实际端口自注册。 */
+ @EventListener public void serverStarted(WebServerInitializedEvent event) {
+ port = event.getWebServer().getPort();
+ serverReady = true;
+ }
+
+ public boolean httpReady() { return serverReady; }
+}
diff --git a/src/main/java/com/xtrh/common/controller/ApiErrors.java b/src/main/java/com/xtrh/common/controller/ApiErrors.java
new file mode 100644
index 0000000..6141c7b
--- /dev/null
+++ b/src/main/java/com/xtrh/common/controller/ApiErrors.java
@@ -0,0 +1,27 @@
+package com.xtrh.common.controller;
+
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+import java.util.Map;
+
+/**
+ * 将已知业务异常转换为现有 HTTP 契约,业务判断仍由服务层完成。
+ * 错误响应不能伪装成成功,调用方据状态码决定是否修正配置或重新注册。
+ */
+@RestControllerAdvice
+public class ApiErrors {
+ // 参数、容量或地址不合法时返回 400,避免客户端反复重试同一错误输入。
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ResponseEntity> invalid(IllegalArgumentException e) {
+ return ResponseEntity.badRequest().body(Map.of("message", e.getMessage()));
+ }
+
+ // 旧会话或当前状态冲突返回 409,节点需要重新核对总控状态。
+ // 此处保留错误消息契约,不输出异常堆栈、凭证或请求头。
+ @ExceptionHandler(IllegalStateException.class)
+ public ResponseEntity> conflict(IllegalStateException e) {
+ return ResponseEntity.status(409).body(Map.of("message", e.getMessage()));
+ }
+}
diff --git a/src/main/java/com/xtrh/common/controller/HealthController.java b/src/main/java/com/xtrh/common/controller/HealthController.java
new file mode 100644
index 0000000..735250a
--- /dev/null
+++ b/src/main/java/com/xtrh/common/controller/HealthController.java
@@ -0,0 +1,32 @@
+package com.xtrh.common.controller;
+
+import com.xtrh.common.config.Settings;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.Map;
+
+/**
+ * 提供无凭证的进程身份查询,供安装脚本确认启动的是本部署实例。
+ * 不返回集群凭证、设备密钥、试验配置或播放许可。
+ * 健康响应只代表进程存在,任务就绪和总控存储状态需查询正式控制接口。
+ */
+@RestController
+public class HealthController {
+ private final Settings settings;
+
+ public HealthController(Settings settings) {
+ this.settings = settings;
+ }
+
+ // 控制层只组装公开的身份信息,不触发注册、调度或数据库更新。
+ @GetMapping("/health")
+ public Object health() {
+ return Map.of(
+ "service", "xtrh-services",
+ "role", settings.role,
+ "workerEnabled", settings.supportsWorker,
+ "clusterId", settings.cluster,
+ "serviceId", settings.id);
+ }
+}
diff --git a/src/main/java/com/xtrh/common/dao/NodeIdentityDao.java b/src/main/java/com/xtrh/common/dao/NodeIdentityDao.java
new file mode 100644
index 0000000..b1fce0d
--- /dev/null
+++ b/src/main/java/com/xtrh/common/dao/NodeIdentityDao.java
@@ -0,0 +1,25 @@
+package com.xtrh.common.dao;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.UUID;
+
+/**
+ * 负责部署实例身份的文件持久化。
+ * 每台电脑使用独立数据目录,重启复用原身份,重新安装也无需手动登记节点。
+ */
+public final class NodeIdentityDao {
+ private NodeIdentityDao() {}
+
+ /** 首次安装生成身份;已有文件格式错误时停止启动,避免错误身份覆盖集群中的其他节点。 */
+ public static String loadOrCreate(Path directory) throws Exception {
+ Files.createDirectories(directory);
+ Path identity = directory.resolve("identity.txt");
+ // CREATE_NEW 避免并发启动时覆盖另一个进程刚生成的身份。
+ if (!Files.exists(identity)) Files.writeString(identity, UUID.randomUUID().toString(), StandardOpenOption.CREATE_NEW);
+ String value = Files.readString(identity).trim();
+ if (!value.matches("[a-zA-Z0-9_-]{8,64}")) throw new IllegalArgumentException("Invalid persisted service identity");
+ return value;
+ }
+}
diff --git a/src/main/java/com/xtrh/common/dao/StateStore.java b/src/main/java/com/xtrh/common/dao/StateStore.java
new file mode 100644
index 0000000..f3155c2
--- /dev/null
+++ b/src/main/java/com/xtrh/common/dao/StateStore.java
@@ -0,0 +1,65 @@
+package com.xtrh.common.dao;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.common.util.Jsons;
+
+import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+
+/**
+ * 仅负责读取和保存完整状态快照,不在 DAO 中决定任务分配或轮次状态。
+ * H2 文件锁阻止两个进程同时占用同一状态文件,避免并行写入产生双总控。
+ */
+public final class StateStore implements AutoCloseable {
+ private final Connection connection;
+ private final ObjectMapper mapper;
+
+ // 文件数据库的 JDBC 地址由 DAO 统一组装,业务服务只选择本角色的数据文件。
+ // 使用绝对规范路径,避免工作目录变化后误建另一份总控或网关状态。
+ public static StateStore open(Path databaseFile, ObjectMapper mapper) throws Exception {
+ String databasePath = databaseFile.toAbsolutePath().normalize().toString().replace('\\', '/');
+ return new StateStore("jdbc:h2:file:" + databasePath + ";DB_CLOSE_ON_EXIT=FALSE", mapper);
+ }
+
+ public StateStore(String url, ObjectMapper mapper) throws Exception {
+ this.mapper = mapper;
+ connection = DriverManager.getConnection(url, "sa", "");
+
+ // 固定主键只保存一个当前快照;初始化失败向上传递,不降级到临时内存状态。
+ try (var statement = connection.createStatement()) {
+ statement.execute(
+ "CREATE TABLE IF NOT EXISTS fusion_state (id INT PRIMARY KEY, payload CLOB NOT NULL)");
+ }
+ }
+
+ // 新数据库由业务服务补齐结构;已有内容读取失败不能当成首次部署。
+ public ObjectNode load() throws Exception {
+ try (var statement = connection.createStatement();
+ var result = statement.executeQuery("SELECT payload FROM fusion_state WHERE id=1")) {
+ return result.next()
+ ? (ObjectNode) mapper.readTree(result.getString(1))
+ : Jsons.object();
+ }
+ }
+
+ // 一条 MERGE 原子替换快照,避免分别保存节点与任务造成不一致。
+ // 写入失败交由总控服务标记存储异常并停止续发任务许可。
+ public void save(ObjectNode value) {
+ try (var statement = connection.prepareStatement(
+ "MERGE INTO fusion_state (id,payload) KEY(id) VALUES(1,?)")) {
+ // 使用参数绑定保存 JSON,配置内容不会被拼接成 SQL。
+ statement.setString(1, mapper.writeValueAsString(value));
+ statement.executeUpdate();
+ } catch (Exception e) {
+ // 保留底层原因供服务端排查,不能静默跳过持久化后继续宣称保存成功。
+ throw new IllegalStateException("Cannot persist coordinator state", e);
+ }
+ }
+
+ @Override
+ public void close() throws Exception {
+ connection.close();
+ }
+}
diff --git a/src/main/java/com/xtrh/common/util/Jsons.java b/src/main/java/com/xtrh/common/util/Jsons.java
new file mode 100644
index 0000000..7fe14a0
--- /dev/null
+++ b/src/main/java/com/xtrh/common/util/Jsons.java
@@ -0,0 +1,69 @@
+package com.xtrh.common.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 公共 JSON 构造及输入校验工具,不依赖数据库或 Spring 容器。
+ * 统一保留字段和错误消息规则,控制层与后台通信共用相同校验口径。
+ */
+public final class Jsons {
+ private Jsons() {}
+
+ public static ObjectNode object() {
+ return JsonNodeFactory.instance.objectNode();
+ }
+
+ public static ArrayNode array() {
+ return JsonNodeFactory.instance.arrayNode();
+ }
+
+ // 参数错误向上传递,交由统一 HTTP 异常处理器返回失败。
+ public static void require(boolean valid, String message) {
+ if (!valid) {
+ throw new IllegalArgumentException(message);
+ }
+ }
+
+ /**
+ * 必填标识去除首尾空白并限制长度;不截断输入,避免两个标识意外变成同一值。
+ * 字段是否允许空值仍由业务服务决定,不能在工具中猜测试验或设备关系。
+ */
+ public static String required(JsonNode node, String key) {
+ String value = node.path(key).asText("").trim();
+ require(!value.isEmpty() && value.length() <= 200, "Invalid " + key);
+ return value;
+ }
+
+ /**
+ * 来源服务地址必须是 HTTP/HTTPS 根地址,具体控制或媒体路径由程序追加。
+ * 拒绝嵌入用户凭证、查询参数和片段,避免缓存地址夹带身份或改变请求签名路径。
+ * 地址合法不等于可信,发现服务仍需验证远端响应签名、集群和总控身份。
+ */
+ public static String baseUrl(String value) {
+ URI uri = URI.create(value);
+ require(
+ Set.of("http", "https").contains(uri.getScheme())
+ && uri.getHost() != null
+ && uri.getUserInfo() == null
+ && uri.getRawQuery() == null
+ && uri.getFragment() == null
+ && (uri.getPath().isEmpty() || uri.getPath().equals("/")),
+ "Invalid service address");
+ return value.replaceAll("/+$", "");
+ }
+
+ // 返回当前对象的引用,服务层须在自己的同步范围内修改,向外展示时另做快照复制。
+ public static List rows(ObjectNode root, String key) {
+ List result = new ArrayList<>();
+ root.path(key).elements().forEachRemaining(n -> result.add((ObjectNode) n));
+ return result;
+ }
+}
diff --git a/src/main/java/com/xtrh/communication/client/PeerClient.java b/src/main/java/com/xtrh/communication/client/PeerClient.java
new file mode 100644
index 0000000..1a0e237
--- /dev/null
+++ b/src/main/java/com/xtrh/communication/client/PeerClient.java
@@ -0,0 +1,58 @@
+package com.xtrh.communication.client;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xtrh.common.config.Settings;
+import com.xtrh.common.util.Jsons;
+import com.xtrh.communication.security.Signatures;
+import org.springframework.stereotype.Component;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.time.Duration;
+import java.util.UUID;
+
+/**
+ * 后台服务之间的签名 HTTP 客户端,供自动发现、注册与领取任务共用。
+ * 发送方和接收方都证明持有集群凭证,网络请求不传输明文密钥。
+ * 浏览器通过 PXHJ 会话操作,不能直接获得这个服务客户端的凭证。
+ */
+@Component
+public class PeerClient {
+ private final Settings settings;
+ private final ObjectMapper mapper;
+ // 连接和请求都有时限,总控不可用时尽快进入重试,不占住心跳线程。
+ private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
+
+ public PeerClient(Settings settings, ObjectMapper mapper) {
+ this.settings = settings;
+ this.mapper = mapper;
+ }
+
+ /**
+ * 签名必须使用实际发送的原始字节,不能签完以后再重新序列化 JSON。
+ * nonce 将响应绑定到本次请求,旧响应不能冒充新的任务计划。
+ */
+ public JsonNode request(String base, String path, String method, JsonNode body) throws Exception {
+ byte[] bytes = body == null ? new byte[0] : mapper.writeValueAsBytes(body);
+ String nonce = UUID.randomUUID().toString();
+ String time = Long.toString(System.currentTimeMillis());
+ // 根地址先校验;查询参数、HTTP 方法与完整正文随后一起进入签名。
+ var builder = HttpRequest.newBuilder(URI.create(Jsons.baseUrl(base) + path))
+ .timeout(Duration.ofSeconds(3))
+ .header("X-Xtrh-Time", time)
+ .header("X-Xtrh-Nonce", nonce)
+ .header("X-Xtrh-Signature", Signatures.request(settings.token, method, path, time, nonce, bytes))
+ .header("Content-Type", "application/json");
+ builder.method(method, HttpRequest.BodyPublishers.ofByteArray(bytes));
+ var response = client.send(builder.build(), HttpResponse.BodyHandlers.ofByteArray());
+ // 先验证响应来源再读取业务内容,发现广播中的伪造服务无法下发有效任务。
+ String expected = Signatures.response(settings.token, nonce, response.statusCode(), response.body());
+ String actual = response.headers().firstValue("X-Xtrh-Response-Signature").orElse(null);
+ if (!Signatures.equal(expected, actual)) throw new IllegalStateException("Peer response authentication failed");
+ // 非成功状态不作为计划使用;由调用服务执行重注册或延后重试。
+ if (response.statusCode() / 100 != 2) throw new IllegalStateException("Peer request failed: HTTP " + response.statusCode());
+ return mapper.readTree(response.body());
+ }
+}
diff --git a/src/main/java/com/xtrh/communication/security/ServiceSecurity.java b/src/main/java/com/xtrh/communication/security/ServiceSecurity.java
new file mode 100644
index 0000000..c7264a2
--- /dev/null
+++ b/src/main/java/com/xtrh/communication/security/ServiceSecurity.java
@@ -0,0 +1,132 @@
+package com.xtrh.communication.security;
+
+import com.xtrh.common.config.Settings;
+import com.xtrh.console.security.ConsoleRequestPolicy;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ReadListener;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.ServletInputStream;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletRequestWrapper;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.core.env.Environment;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.ContentCachingResponseWrapper;
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 为所有内部控制接口统一校验签名、防止重放并签署响应。
+ * 这套凭证只用于后台服务互认;PXHJ 用户登录和权限仍由原平台负责。
+ * 媒体流由节点服务单独检查任务版本及临时媒体许可。
+ */
+@Component
+public class ServiceSecurity extends OncePerRequestFilter {
+ private final Settings settings;
+ private final boolean consoleEnabled;
+ // 验证成功的 nonce 才进入缓存,原子插入确保并发重放最多接受一次。
+ private final ConcurrentHashMap nonces = new ConcurrentHashMap<>();
+
+ public ServiceSecurity(Settings settings) { this(settings, true); }
+
+ @Autowired
+ public ServiceSecurity(Settings settings, Environment environment) {
+ this(settings, environment.getProperty("fusion.console-enabled", Boolean.class, true));
+ }
+
+ public ServiceSecurity(Settings settings, boolean consoleEnabled) {
+ this.settings = settings;
+ this.consoleEnabled = consoleEnabled;
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
+ String path = req.getRequestURI();
+ if (consoleEnabled && settings.role.equals("coordinator")
+ && (path.equals("/console") || path.startsWith("/console/"))) {
+ res.setHeader("Cache-Control", "no-store");
+ res.setHeader("X-Content-Type-Options", "nosniff");
+ res.setHeader("X-Frame-Options", "DENY");
+ boolean read = req.getMethod().equals("GET") || req.getMethod().equals("HEAD");
+ boolean safePath = !path.contains("..") && !path.contains("\\") && !path.contains("%") && !path.contains(";");
+ boolean api = path.equals("/console/api") || path.startsWith("/console/api/");
+ if (!read || !safePath || (api && !ConsoleRequestPolicy.localRead(req))) {
+ res.setStatus(403);
+ res.setContentType("application/json");
+ res.getWriter().write("{\"message\":\"Console read access denied\"}");
+ return;
+ }
+ chain.doFilter(req, res);
+ return;
+ }
+ // 健康入口只返回身份;媒体入口在业务层验证许可,错误分发不重复认证。
+ if (path.equals("/health") || path.startsWith("/media/") || path.equals("/error")) {
+ chain.doFilter(req, res);
+ return;
+ }
+ String supplied = req.getHeader("X-Xtrh-Signature");
+ String time = req.getHeader("X-Xtrh-Time");
+ String nonce = req.getHeader("X-Xtrh-Nonce");
+ long timestamp;
+ try { timestamp = Long.parseLong(time); } catch (Exception e) { timestamp = 0; }
+ // 先检查格式和一分钟时间窗,再读取正文,尽早拒绝不符合协议的控制请求。
+ if (supplied == null || !supplied.matches("[0-9a-f]{64}") || nonce == null
+ || !nonce.matches("[a-zA-Z0-9-]{16,64}") || Math.abs(System.currentTimeMillis() - timestamp) > 60000) {
+ reject(res, "Service authentication required");
+ return;
+ }
+ // 有界读取保护内存;签名绑定方法、路径、查询参数和实际收到的全部正文。
+ byte[] body = req.getInputStream().readNBytes(2 * 1024 * 1024 + 1);
+ String requestPath = path + (req.getQueryString() == null ? "" : "?" + req.getQueryString());
+ String expected = Signatures.request(settings.token, req.getMethod(), requestPath, time, nonce, body);
+ if (body.length > 2 * 1024 * 1024 || !Signatures.equal(expected, supplied)
+ || nonces.putIfAbsent(nonce, timestamp) != null) {
+ reject(res, "Invalid service signature");
+ return;
+ }
+ // 超出认证时间窗的 nonce 已不能再次成功使用,可以移除以避免无限积累。
+ long cutoff = System.currentTimeMillis() - 60000;
+ nonces.entrySet().removeIf(entry -> entry.getValue() < cutoff);
+ // 验签已经读过输入流;提供同一份字节给 Spring,避免控制层收到空正文。
+ HttpServletRequest wrapped = replayable(req, body);
+ ContentCachingResponseWrapper response = new ContentCachingResponseWrapper(res);
+ chain.doFilter(wrapped, response);
+ // 发现广播只提供地址线索;只有验证响应签名才能确认服务属于本集群。
+ response.setHeader("X-Xtrh-Response-Signature", Signatures.response(settings.token, nonce, response.getStatus(), response.getContentAsByteArray()));
+ response.copyBodyToResponse();
+ }
+
+ /** 失败只返回固定原因,绝不把密钥或待签名的正文写入响应。 */
+ private void reject(HttpServletResponse response, String message) throws IOException {
+ response.setStatus(401);
+ response.setContentType("application/json");
+ response.getWriter().write("{\"message\":\"" + message + "\"}");
+ }
+
+ /**
+ * 每次获取输入流时复用已认证正文,业务层解析的数据与签名校验的数据一致。
+ * 内部控制接口采用同步 JSON 读取,不支持非阻塞流监听。
+ */
+ private HttpServletRequest replayable(HttpServletRequest request, byte[] body) {
+ return new HttpServletRequestWrapper(request) {
+ @Override public ServletInputStream getInputStream() {
+ ByteArrayInputStream input = new ByteArrayInputStream(body);
+ return new ServletInputStream() {
+ public int read() { return input.read(); }
+ public boolean isFinished() { return input.available() == 0; }
+ public boolean isReady() { return true; }
+ public void setReadListener(ReadListener listener) { throw new UnsupportedOperationException(); }
+ };
+ }
+ @Override public BufferedReader getReader() {
+ return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
+ }
+ };
+ }
+}
diff --git a/src/main/java/com/xtrh/communication/security/Signatures.java b/src/main/java/com/xtrh/communication/security/Signatures.java
new file mode 100644
index 0000000..93f5f24
--- /dev/null
+++ b/src/main/java/com/xtrh/communication/security/Signatures.java
@@ -0,0 +1,54 @@
+package com.xtrh.communication.security;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.util.HexFormat;
+
+/**
+ * 服务请求与响应的签名规则,必须与 PXHJ 后端保持一致。
+ * 请求和响应使用不同前缀,避免把某一方向的签名直接复用到另一方向。
+ * 正文只取字节摘要参与签名,不会改变被传输的数据。
+ */
+public final class Signatures {
+ private Signatures() {}
+
+ /** 空正文也计算摘要,使 GET 与有正文的请求遵循同一套规则。 */
+ public static String digest(byte[] bytes) {
+ try {
+ return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /**
+ * 每次创建独立 Mac,避免多个心跳线程并发使用非线程安全的算法实例。
+ * 密钥只存在于进程私有配置中,HTTP 头仅发送计算结果。
+ */
+ private static String hmac(String token, String canonical) {
+ try {
+ Mac mac = Mac.getInstance("HmacSHA256");
+ mac.init(new SecretKeySpec(token.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
+ return HexFormat.of().formatHex(mac.doFinal(canonical.getBytes(StandardCharsets.UTF_8)));
+ } catch (Exception e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ /** 字段之间以换行分隔,双方按照相同顺序生成待签名内容。 */
+ public static String request(String token, String method, String path, String time, String nonce, byte[] body) {
+ return hmac(token, "request\n" + method + "\n" + path + "\n" + time + "\n" + nonce + "\n" + digest(body));
+ }
+
+ /** 响应绑定原请求的随机数、状态码和正文,错误响应不能冒充成功响应。 */
+ public static String response(String token, String nonce, int status, byte[] body) {
+ return hmac(token, "response\n" + nonce + "\n" + status + "\n" + digest(body));
+ }
+
+ /** 使用固定时间比较原语,避免直接字符串比较泄露匹配前缀信息。 */
+ public static boolean equal(String expected, String actual) {
+ return actual != null && MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), actual.getBytes(StandardCharsets.US_ASCII));
+ }
+}
diff --git a/src/main/java/com/xtrh/console/controller/ConsoleCollectionController.java b/src/main/java/com/xtrh/console/controller/ConsoleCollectionController.java
new file mode 100644
index 0000000..ab46b3c
--- /dev/null
+++ b/src/main/java/com/xtrh/console/controller/ConsoleCollectionController.java
@@ -0,0 +1,20 @@
+package com.xtrh.console.controller;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.xtrh.console.service.ConsoleCollectionStore;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+/** 签名服务入口仅更新内存查看快照,与总控任务、路由和节点许可解耦。 */
+@RestController
+@ConditionalOnProperty(name = "fusion.role", havingValue = "coordinator", matchIfMissing = true)
+public class ConsoleCollectionController {
+ private final ConsoleCollectionStore collection;
+
+ public ConsoleCollectionController(ConsoleCollectionStore collection) { this.collection = collection; }
+
+ @PostMapping("/control/collection/sync")
+ public Object sync(@RequestBody JsonNode input) { return collection.sync(input); }
+}
diff --git a/src/main/java/com/xtrh/console/controller/ConsoleController.java b/src/main/java/com/xtrh/console/controller/ConsoleController.java
new file mode 100644
index 0000000..ef458a0
--- /dev/null
+++ b/src/main/java/com/xtrh/console/controller/ConsoleController.java
@@ -0,0 +1,46 @@
+package com.xtrh.console.controller;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.console.service.ConsoleCollectionStore;
+import com.xtrh.control.service.CoordinatorService;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
+import org.springframework.stereotype.Controller;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ResponseBody;
+
+import static com.xtrh.common.util.Jsons.object;
+
+/** 原生控制台仅查看状态,不能创建任务、修改容量或操作采集设备。 */
+@Controller
+@ConditionalOnExpression("'${fusion.role:coordinator}' == 'coordinator' && ${fusion.console-enabled:true}")
+public class ConsoleController {
+ private final ConsoleCollectionStore collection;
+ private final CoordinatorService coordinator;
+
+ public ConsoleController(ConsoleCollectionStore collection, CoordinatorService coordinator) {
+ this.collection = collection;
+ this.coordinator = coordinator;
+ }
+
+ @GetMapping("/console") public String redirect() { return "redirect:/console/"; }
+ @GetMapping("/console/") public String index() { return "forward:/console/index.html"; }
+
+ @ResponseBody
+ @GetMapping("/console/api/snapshot")
+ public ObjectNode snapshot() {
+ ObjectNode result = object().put("serverTime", System.currentTimeMillis());
+ result.set("collection", collection.snapshot());
+ ArrayNode nodes = result.putArray("nodes");
+ for (JsonNode source : coordinator.snapshot().path("nodes")) {
+ ObjectNode node = object();
+ for (String field : new String[]{"nodeId", "name", "nodeRole", "online", "lastHeartbeat"})
+ if (source.has(field)) node.set(field, source.path(field).deepCopy());
+ // 旧节点尚未上报资源遥测;在线心跳不能推算 CPU、内存或网络利用率。
+ node.putNull("cpu").putNull("memory").putNull("network");
+ nodes.add(node);
+ }
+ return result;
+ }
+}
diff --git a/src/main/java/com/xtrh/console/security/ConsoleRequestPolicy.java b/src/main/java/com/xtrh/console/security/ConsoleRequestPolicy.java
new file mode 100644
index 0000000..1e2708a
--- /dev/null
+++ b/src/main/java/com/xtrh/console/security/ConsoleRequestPolicy.java
@@ -0,0 +1,34 @@
+package com.xtrh.console.security;
+
+import jakarta.servlet.http.HttpServletRequest;
+import java.net.URI;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/** 本机只读查看入口不接受跨站请求或可用于 DNS 重绑定的任意 Host。 */
+public final class ConsoleRequestPolicy {
+ private static final Pattern HOST = Pattern.compile("(?i)(localhost|127\\.0\\.0\\.1|\\[::1\\])(?::([0-9]{1,5}))?");
+ private ConsoleRequestPolicy() {}
+
+ public static boolean localRead(HttpServletRequest request) {
+ if (!Set.of("GET", "HEAD").contains(request.getMethod())) return false;
+ if (!Set.of("127.0.0.1", "::1", "0:0:0:0:0:0:0:1").contains(request.getRemoteAddr())) return false;
+ String host = request.getHeader("Host");
+ if (host == null) return false;
+ Matcher matcher = HOST.matcher(host);
+ if (!matcher.matches()) return false;
+ int port = matcher.group(2) == null ? (request.isSecure() ? 443 : 80) : Integer.parseInt(matcher.group(2));
+ if (port < 1 || port > 65535 || port != request.getLocalPort()) return false;
+ if ("cross-site".equals(request.getHeader("Sec-Fetch-Site"))) return false;
+ String origin = request.getHeader("Origin");
+ if (origin == null) return true;
+ try {
+ URI uri = URI.create(origin);
+ return uri.getScheme().equals(request.getScheme()) && uri.getRawAuthority() != null
+ && uri.getRawAuthority().equalsIgnoreCase(host) && uri.getRawUserInfo() == null
+ && (uri.getRawPath() == null || uri.getRawPath().isEmpty())
+ && uri.getRawQuery() == null && uri.getRawFragment() == null;
+ } catch (IllegalArgumentException | NullPointerException failure) { return false; }
+ }
+}
diff --git a/src/main/java/com/xtrh/console/service/ConsoleCollectionStore.java b/src/main/java/com/xtrh/console/service/ConsoleCollectionStore.java
new file mode 100644
index 0000000..6e88770
--- /dev/null
+++ b/src/main/java/com/xtrh/console/service/ConsoleCollectionStore.java
@@ -0,0 +1,158 @@
+package com.xtrh.console.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.stereotype.Service;
+
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.function.LongSupplier;
+
+import static com.xtrh.common.util.Jsons.*;
+
+/**
+ * 接收 PXHJ 已解析的只读采集快照,与任务调度、许可和 H2 状态完全分开。
+ * 只保留白名单标量;模拟数据、凭证及媒体许可不会进入浏览器数据集合。
+ */
+@Service
+@ConditionalOnProperty(name = "fusion.role", havingValue = "coordinator", matchIfMissing = true)
+public class ConsoleCollectionStore {
+ private static final int MAX_BYTES = 1_500_000;
+ private static final long STALE_MS = 15_000;
+ private static final Set QUALITIES = Set.of("GOOD", "VALID", "STALE", "MISSING", "AMBIGUOUS",
+ "INVALID_TIME", "INVALID_VALUE", "WAITING", "OFFLINE", "UNKNOWN", "ERROR", "UNAVAILABLE");
+ private final ObjectMapper mapper;
+ private final LongSupplier clock;
+ private ArrayNode contexts = array();
+ private long observedAt;
+ private long lastSyncedAt;
+
+ @Autowired
+ public ConsoleCollectionStore(ObjectMapper mapper) { this(mapper, System::currentTimeMillis); }
+
+ public ConsoleCollectionStore(ObjectMapper mapper, LongSupplier clock) {
+ this.mapper = mapper;
+ this.clock = clock;
+ }
+
+ /** 整体校验成功后原子替换;失败与迟到快照不会破坏上次有效数据。 */
+ public synchronized ObjectNode sync(JsonNode input) {
+ require(input != null && input.isObject(), "Invalid collection snapshot");
+ require("PXHJ".equals(text(input, "source", 16, true)), "Invalid collection source");
+ require(!simulated(input), "Simulated collection is not accepted");
+ try { require(mapper.writeValueAsBytes(input).length <= MAX_BYTES, "Collection snapshot too large"); }
+ catch (java.io.IOException failure) { throw new IllegalArgumentException("Invalid collection snapshot"); }
+ long received = clock.getAsLong();
+ long observed = timestamp(input, "observedAt", true);
+ require(observed <= received + 60_000, "Collection time is in the future");
+ require(observed >= observedAt, "Outdated collection snapshot");
+ JsonNode sourceContexts = boundedArray(input, "contexts", 32);
+ ArrayNode accepted = array();
+ int equipmentCount = 0, itemCount = 0;
+ Set contextIds = new HashSet<>();
+ for (JsonNode context : sourceContexts) {
+ require(context.isObject(), "Invalid context");
+ String contextId = text(context, "id", 200, true);
+ require(contextIds.add(contextId), "Duplicate collection context");
+ ObjectNode copy = object().put("id", contextId).put("name", text(context, "name", 200, false))
+ .put("roundId", text(context, "roundId", 200, false));
+ ArrayNode equipment = copy.putArray("equipment");
+ Set equipmentIds = new HashSet<>();
+ for (JsonNode row : boundedArray(context, "equipment", 128)) {
+ require(row.isObject() && ++equipmentCount <= 512, "Too many collection equipment");
+ String id = text(row, "id", 200, true);
+ require(equipmentIds.add(id), "Duplicate collection equipment");
+ if (simulated(row)) continue;
+ ObjectNode target = object().put("id", id)
+ .put("participantCode", text(row, "participantCode", 200, false))
+ .put("code", text(row, "code", 200, false)).put("name", text(row, "name", 200, false))
+ .put("type", text(row, "type", 200, false)).put("state", text(row, "state", 64, false));
+ nullableTime(target, row, "lastReceivedAt");
+ ArrayNode items = target.putArray("items");
+ Set keys = new HashSet<>();
+ for (JsonNode item : boundedArray(row, "items", 256)) {
+ require(item.isObject() && ++itemCount <= 4096, "Too many collection items");
+ String key = text(item, "key", 200, true);
+ require(keys.add(key), "Duplicate collection item");
+ if (simulated(item) || sensitive(key)) continue;
+ ObjectNode value = object().put("key", key).put("label", text(item, "label", 200, false))
+ .put("type", text(item, "type", 64, false)).put("unit", text(item, "unit", 32, false))
+ .put("sourceName", text(item, "sourceName", 200, false))
+ .put("sourceType", text(item, "sourceType", 64, false));
+ String quality = text(item, "quality", 64, false).toUpperCase(Locale.ROOT);
+ value.put("quality", QUALITIES.contains(quality) ? quality : "UNKNOWN");
+ JsonNode sample = item.get("value");
+ require(sample == null || sample.isNull() || sample.isNumber() || sample.isTextual() || sample.isBoolean(), "Collection values must be scalar");
+ require(sample == null || !sample.isTextual() || sample.textValue().length() <= 4096, "Collection value too long");
+ require(sample == null || !sample.isFloatingPointNumber() || Double.isFinite(sample.doubleValue()), "Invalid collection number");
+ if (sample == null || sample.isNull()) value.putNull("value"); else value.set("value", sample.deepCopy());
+ nullableTime(value, item, "receivedAt");
+ nullableTime(value, item, "sampleTime");
+ items.add(value);
+ }
+ equipment.add(target);
+ }
+ accepted.add(copy);
+ }
+ contexts = accepted;
+ observedAt = observed;
+ lastSyncedAt = received;
+ return object().put("accepted", true).put("lastSyncedAt", lastSyncedAt).put("observedAt", observedAt);
+ }
+
+ /** 失联后保留最后值并标记过期,不以空值或假在线状态覆盖真实采集记录。 */
+ public synchronized ObjectNode snapshot() {
+ long now = clock.getAsLong();
+ String state = lastSyncedAt == 0 ? "WAITING"
+ : now - lastSyncedAt > STALE_MS || now - observedAt > STALE_MS ? "STALE" : "CONNECTED";
+ ObjectNode out = object().put("state", state);
+ if (lastSyncedAt == 0) out.putNull("lastSyncedAt").putNull("observedAt");
+ else out.put("lastSyncedAt", lastSyncedAt).put("observedAt", observedAt);
+ out.set("contexts", contexts.deepCopy());
+ return out;
+ }
+
+ private static JsonNode boundedArray(JsonNode node, String key, int limit) {
+ JsonNode value = node.path(key);
+ require(value.isArray() && value.size() <= limit, "Invalid or excessive " + key);
+ return value;
+ }
+
+ private static String text(JsonNode node, String key, int limit, boolean required) {
+ JsonNode value = node.get(key);
+ require(value == null || value.isNull() || value.isTextual(), "Invalid " + key);
+ String result = value == null || value.isNull() ? "" : value.textValue().trim();
+ require(result.length() <= limit && (!required || !result.isEmpty()), "Invalid " + key);
+ return result;
+ }
+
+ private static long timestamp(JsonNode node, String key, boolean required) {
+ JsonNode value = node.get(key);
+ if (value == null || value.isNull()) { require(!required, "Missing " + key); return 0; }
+ require(value.isIntegralNumber() && value.canConvertToLong() && value.longValue() > 0, "Invalid " + key);
+ return value.longValue();
+ }
+
+ private static void nullableTime(ObjectNode target, JsonNode source, String key) {
+ long value = timestamp(source, key, false);
+ if (value == 0) target.putNull(key); else target.put(key, value);
+ }
+
+ private static boolean simulated(JsonNode row) {
+ if (row.path("simulated").asBoolean(false)) return true;
+ for (String key : Set.of("quality", "sourceName", "sourceType", "state")) {
+ String value = row.path(key).asText("").toUpperCase(Locale.ROOT);
+ if (value.contains("SIMULAT") || value.contains("DEMO") || value.contains("模拟") || value.contains("演示")) return true;
+ }
+ return false;
+ }
+
+ private static boolean sensitive(String key) {
+ return key.toLowerCase(Locale.ROOT).matches(".*(password|passwd|secret|credential|token|mediakey|privatekey|sm4key|hmackey|sessionid|bootid).*");
+ }
+}
diff --git a/src/main/java/com/xtrh/control/controller/CoordinatorController.java b/src/main/java/com/xtrh/control/controller/CoordinatorController.java
new file mode 100644
index 0000000..18cfbfb
--- /dev/null
+++ b/src/main/java/com/xtrh/control/controller/CoordinatorController.java
@@ -0,0 +1,31 @@
+package com.xtrh.control.controller;
+
+import com.xtrh.control.service.CoordinatorService;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.*;
+
+/**
+ * 总控 HTTP 入口:只接收参数和返回结果,调度与持久化由服务层处理。
+ * 内部接口认证统一交给服务签名过滤器,避免各入口出现不同认证规则。
+ */
+@RestController
+@RequestMapping("/control")
+@ConditionalOnProperty(name="fusion.role", havingValue="coordinator", matchIfMissing=true)
+public class CoordinatorController {
+ private final CoordinatorService service;
+ public CoordinatorController(CoordinatorService service){this.service=service;}
+ @GetMapping("/info") public Object info(){return service.info();}
+ @GetMapping("/snapshot") public Object snapshot(){return service.snapshot();}
+ @PostMapping("/nodes/register") public Object register(@RequestBody JsonNode input){return service.register(input);}
+ @PostMapping("/nodes/{id}/heartbeat") public Object heartbeat(@PathVariable String id,@RequestBody JsonNode input){return service.heartbeat(id,input);}
+ @PatchMapping("/nodes/{id}/capacity") public Object capacity(@PathVariable String id,@RequestBody JsonNode input){return service.capacity(id,input);}
+ // 试验和轮次状态以 PXHJ 保存的数据为准,浏览器不直接访问总控接口。
+ @PostMapping("/contexts/sync") public Object context(@RequestBody JsonNode input){return service.context(input);}
+ // 原图接入不依赖试验启动;密钥只经过内部签名接口进入短期内存许可。
+ @PostMapping("/image-sources/register") public Object imageSource(@RequestBody JsonNode input){return service.imageSource(input);}
+ @PostMapping("/image-sources/resolve") public Object imageRoutes(@RequestBody JsonNode input){return service.imageRoutes(input);}
+ @PutMapping("/routes") public Object routes(@RequestBody JsonNode input){return service.routes(input);}
+ @PostMapping("/gateways/heartbeat") public Object gateway(@RequestBody JsonNode input){return service.gateway(input);}
+}
diff --git a/src/main/java/com/xtrh/control/service/CoordinatorService.java b/src/main/java/com/xtrh/control/service/CoordinatorService.java
new file mode 100644
index 0000000..fe758b2
--- /dev/null
+++ b/src/main/java/com/xtrh/control/service/CoordinatorService.java
@@ -0,0 +1,394 @@
+package com.xtrh.control.service;
+
+import com.xtrh.common.util.Jsons;
+import com.xtrh.common.config.Settings;
+import com.xtrh.common.dao.StateStore;
+
+import com.fasterxml.jackson.databind.*;
+import com.fasterxml.jackson.databind.node.*;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+import jakarta.annotation.PreDestroy;
+import java.nio.charset.StandardCharsets;
+import java.util.*;
+import java.util.function.LongSupplier;
+import static com.xtrh.common.util.Jsons.*;
+
+/**
+ * 总控业务服务:根据 PXHJ 的正式试验状态、节点许可和配置路数组织任务。
+ * 所有状态操作在同一把锁内执行,保证两个并发心跳不会重复占用同一个空位。
+ * controller 不决定分配结果;DAO 只负责完整状态的保存和读取。
+ */
+@Service
+@ConditionalOnProperty(name="fusion.role", havingValue="coordinator", matchIfMissing=true)
+public class CoordinatorService {
+ private final StateStore store;
+ private final ObjectNode state;
+ // 展示时间与授权计时分开:墙上时钟用于日志,单调时钟用于判断节点是否失联。
+ private final LongSupplier clock;
+ private final LongSupplier leaseClock;
+ private final Map leaseRefresh=new HashMap<>();
+ private final long leaseMs;
+ private final String cluster;
+ // 来源许可与试验快照隔离,采集器密钥不会写入 StateStore 或公开节点列表。
+ private final ImageSourceRegistry imageSources = new ImageSourceRegistry();
+ // 保存失败后停止继续派发,避免内存中的新授权与磁盘中的旧状态发生分歧。
+ private boolean storageHealthy = true;
+
+ @org.springframework.beans.factory.annotation.Autowired
+ public CoordinatorService(Settings settings, ObjectMapper mapper) throws Exception {
+ this(StateStore.open(settings.directory.resolve("coordinator"), mapper),
+ System::currentTimeMillis, ()->System.nanoTime()/1_000_000, settings.leaseMs, settings.cluster);
+ state.put("serviceId",settings.id);persist();
+ }
+
+ /** 测试可以提供可控时钟;实际部署使用系统单调时钟,不依赖设备报来的时间。 */
+ CoordinatorService(StateStore store, LongSupplier clock, long leaseMs, String cluster) throws Exception {
+ this(store,clock,clock,leaseMs,cluster);
+ }
+ CoordinatorService(StateStore store,LongSupplier clock,LongSupplier leaseClock,long leaseMs,String cluster) throws Exception {
+ this.store=store; this.clock=clock;this.leaseClock=leaseClock; this.leaseMs=leaseMs; this.cluster=cluster;
+ state=store.load();
+ // 五类业务状态共同保存为原子快照,恢复时先补齐首次部署缺失的集合。
+ for(String key:List.of("nodes","contexts","tasks","gateways","routes")) if(!state.path(key).isObject()) state.putObject(key);
+ // 重启后提升总控代次并撤销旧授权,等 PXHJ 重新确认正式试验后再派发,防止复活已结束任务。
+ state.put("epoch", state.path("epoch").asLong()+1);
+ if(!state.hasNonNull("serviceId"))state.put("serviceId",UUID.randomUUID().toString());
+ if(!state.has("routesConfirmed"))state.put("routesConfirmed",state.has("routeVersion"));
+ // 保留节点容量和身份,重新生成会话,让重启前迟到的准备回执无法重新获得授权。
+ for(ObjectNode n:rows(state,"nodes")) n.put("lastHeartbeat",0).put("sessionId",UUID.randomUUID().toString());
+ for(ObjectNode t:rows(state,"tasks")) {t.putNull("active");t.putNull("candidate");t.put("reason","RECONCILING");}
+ // 原磁盘记录只说明曾经运行过;没有 PXHJ 的当前确认不能推断试验仍在进行。
+ for(ObjectNode c:rows(state,"contexts")) c.put("confirmed",false);
+ // 原图是短期接入任务;重启后等下一次认证协商重新创建,避免遗留接收槽位。
+ for(ObjectNode t:rows(state,"tasks"))if(original(t))t.put("desired","STOPPED");
+ for(ObjectNode c:rows(state,"contexts"))if(c.path("kind").asText().equals("ORIGINAL_IMAGE"))map("contexts").remove(c.path("trialId").asText());
+ for(ObjectNode g:rows(state,"gateways")) g.put("lastHeartbeat",0);
+ persist();
+ }
+
+ private long now(){return clock.getAsLong();}
+ private ObjectNode map(String key){return (ObjectNode)state.path(key);}
+ private ObjectNode node(String id){var n=map("nodes").get(id);if(n==null)throw new IllegalArgumentException("Unknown node");return (ObjectNode)n;}
+ // 授权按单调时钟到期;系统校时只影响展示时间,不延长失联节点的许可。
+ private boolean live(ObjectNode n){Long last=leaseRefresh.get(n.path("nodeId").asText());long age=last==null?Long.MAX_VALUE:leaseClock.getAsLong()-last;return age>=0&&age=0&&suggested<=64,"Invalid capacity");
+ ObjectNode n=map("nodes").has(id)?node(id):object().put("nodeId",id).put("capacity",suggested).put("enabled",true);
+ // 注册可重复调用;同一启动号沿用会话,避免网络重试生成重复节点或打断在途任务。
+ if(!boot.equals(n.path("bootId").asText())) n.put("sessionId",UUID.randomUUID().toString());
+ n.put("bootId",boot).put("name",required(input,"name")).put("nodeRole",nodeRole).put("baseUrl",url).put("lastHeartbeat",now());
+ // 旧版本节点仍可执行演示任务,只有明确上报有效图片入口的节点才能接原图。
+ int imagePort=input.path("imageInputPort").asInt(0);
+ String imageHost=input.path("imageHost").asText("");
+ require(imagePort==0||(imagePort>0&&imagePort<=65535&&ImageSourceRegistry.ipv4(imageHost)),"Invalid node image input");
+ n.put("imageInputPort",imagePort).put("imageHost",imageHost);
+ map("nodes").set(id,n);leaseRefresh.put(id,leaseClock.getAsLong());schedule();persist();
+ return object().put("nodeId",id).put("sessionId",n.path("sessionId").asText()).put("epoch",state.path("epoch").asLong()).put("leaseMs",leaseMs);
+ }
+
+ /**
+ * 心跳既续期节点许可,也提交候选任务的准备结果。
+ * 回复仅包含当前节点、当前会话仍有效的任务,节点以此撤销不再属于自己的任务。
+ */
+ public synchronized ObjectNode heartbeat(String id,JsonNode input){
+ ObjectNode n=node(id);
+ if(!required(input,"sessionId").equals(n.path("sessionId").asText())||!required(input,"bootId").equals(n.path("bootId").asText()))
+ throw new IllegalStateException("Node session superseded; register again");
+ n.put("lastHeartbeat",now());leaseRefresh.put(id,leaseClock.getAsLong());schedule();
+ // 先移除失效分配,再检查回执;旧节点的迟到结果不能覆盖迁移后的新版本。
+ for(JsonNode report:input.path("reports")){
+ var entry=map("tasks").get(report.path("taskId").asText());if(entry==null)continue;
+ ObjectNode t=(ObjectNode)entry,candidate=assignment(t,"candidate");
+ // 原图首帧状态属于具体分配版本;旧节点的迟到图片不能宣布新版本就绪。
+ if(original(t))for(String key:List.of("active","candidate")){
+ ObjectNode a=assignment(t,key);
+ if(a!=null&&valid(a)&&a.path("nodeId").asText().equals(id)&&a.path("version").asLong()==report.path("version").asLong()){
+ a.put("imageReady",report.path("imageReady").asBoolean());
+ a.put("lastFrameTime",report.path("lastFrameTime").asLong());
+ }
+ }
+ if(t.path("desired").asText().equals("STOPPED")||candidate==null||!candidate.path("nodeId").asText().equals(id)
+ ||candidate.path("version").asLong()!=report.path("version").asLong()||!valid(candidate))continue;
+ // 新节点已经生成可播放画面后才切换 active;准备期间旧节点继续承担播放。
+ if(report.path("ready").asBoolean()){
+ candidate.put("ready",true);
+ // 首次接入先开放上传地址;迁移则继续双发,收到新节点首帧后才切换 PXHJ 取图。
+ if(!original(t)||assignment(t,"active")==null||report.path("imageReady").asBoolean()){
+ candidate.put("activatedAt",now());t.set("active",candidate.deepCopy());t.putNull("candidate");t.put("reason","");
+ }
+ }else if(report.hasNonNull("error")){
+ // 准备失败只撤销候选;旧 active 保留,冷却后重试,防止失败任务占住预留容量。
+ t.putNull("candidate");t.put("reason","MEDIA_PREPARE_FAILED").put("lastFailure",now());
+ }
+ }
+ schedule();persist();
+ var result=object().put("leaseMs",leaseMs).put("epoch",state.path("epoch").asLong());var plans=result.putArray("tasks");
+ // 迁移期间两个节点分别拿到运行和预热计划,完成接管后旧节点不再收到该版本。
+ for(ObjectNode t:rows(state,"tasks"))for(String key:List.of("active","candidate")){
+ ObjectNode a=assignment(t,key);if(a==null||!a.path("nodeId").asText().equals(id)||!valid(a))continue;
+ ObjectNode plan=t.deepCopy();plan.remove(List.of("active","candidate"));plan.set("assignment",a.deepCopy());plan.put("preparing",key.equals("candidate"));plans.add(plan);
+ // 密钥仅发送给当前有效的执行节点,不进入任务快照、持久化或浏览器 API。
+ if(original(t))plan.set("imageAccess",imageSources.access(t.path("sourceEndpointId").asText()));
+ }
+ return result;
+ }
+
+ /** 路数是管理员配置的最大并发任务数;当前阶段不根据 CPU/GPU 自动推算。 */
+ public synchronized ObjectNode capacity(String id,JsonNode input){
+ require(input.path("capacity").isIntegralNumber(),"Capacity must be an integer");int value=input.path("capacity").asInt();
+ require(value>=0&&value<=64,"Capacity must be between 0 and 64");
+ var n=node(id);n.put("capacity",value);if(input.has("enabled"))n.put("enabled",input.path("enabled").asBoolean());
+ schedule();persist();return snapshot();
+ }
+
+ /** 只接收通过签名认证的 PXHJ 后台提供的已保存试验与正式轮次状态。 */
+ public synchronized ObjectNode context(JsonNode input){
+ String trialId=required(input,"trialId"),roundId=required(input,"roundId"),name=required(input,"name");
+ String phase=required(input,"roundStatus");require(Set.of("PENDING","RUNNING","SAVED").contains(phase),"Invalid round state");
+ require(input.path("channels").isArray()&&input.path("channels").size()<=128,"Invalid channels");
+ // 通道身份必须唯一,同一装备可以对应多路,但重复通道不能形成重复调度任务。
+ var seen=new HashSet();
+ for(JsonNode channel:input.path("channels")){require(seen.add(required(channel,"channelId")),"Duplicate channel");required(channel,"equipmentId");required(channel,"name");}
+ String revision=input.path("configurationVersion").asText("0");
+ var c=map("contexts").has(trialId)?(ObjectNode)map("contexts").get(trialId):object();
+ boolean enabled=input.path("enabled").asBoolean(true);
+ c.put("trialId",trialId).put("roundId",roundId).put("name",name).put("roundStatus",phase).put("configurationVersion",revision)
+ .put("enabled",enabled).put("confirmed",true).put("confirmedAt",now());
+ c.set("channels",input.path("channels").deepCopy());c.set("bindings",input.path("bindings").deepCopy());map("contexts").set(trialId,c);
+ Set wanted=new HashSet<>();
+ // 稳定任务号绑定试验、轮次和通道;同步重试复用任务,新一轮生成独立任务。
+ if(enabled&&!phase.equals("SAVED"))for(JsonNode channel:input.path("channels")){
+ String id=UUID.nameUUIDFromBytes((trialId+"/"+roundId+"/"+channel.path("channelId").asText()).getBytes(StandardCharsets.UTF_8)).toString();wanted.add(id);
+ var t=map("tasks").has(id)?(ObjectNode)map("tasks").get(id):object().put("taskId",id).put("versionCounter",0).put("createdAt",now());
+ t.put("trialId",trialId).put("roundId",roundId).put("channelId",channel.path("channelId").asText()).put("equipmentId",channel.path("equipmentId").asText())
+ .put("name",channel.path("name").asText()).put("trialName",name).put("desired",phase.equals("RUNNING")?"RUNNING":"READY").put("mediaKind","DEMO_VIDEO");
+ map("tasks").set(id,t);
+ }
+ // 正式结束、关闭功能或删除通道都会撤销旧任务;此处不触碰独立的采集网关路由。
+ for(ObjectNode t:rows(state,"tasks"))if(t.path("trialId").asText().equals(trialId)&&!wanted.contains(t.path("taskId").asText())){
+ t.put("desired","STOPPED");t.putNull("active");t.putNull("candidate");t.put("reason","");
+ }
+ schedule();persist();return snapshot();
+ }
+
+ // 运行任务和预热任务都占用路数,迁移时不能把新节点的容量重复分给其他任务。
+ private int load(String id){int count=0;for(ObjectNode t:rows(state,"tasks"))for(String key:List.of("active","candidate")){
+ ObjectNode a=assignment(t,key);if(a!=null&&a.path("nodeId").asText().equals(id))count++;
+ }return count;}
+ /** 空位按已占用比例选择,容量不同的电脑可承担不同路数;迁移排除原节点。 */
+ private ObjectNode available(String exclude,ObjectNode task){
+ return rows(state,"nodes").stream().filter(n->live(n)&&n.path("enabled").asBoolean()&&!n.path("nodeId").asText().equals(exclude)
+ &&load(n.path("nodeId").asText())0&&ImageSourceRegistry.ipv4(n.path("imageHost").asText()))))
+ .min(Comparator.comparingDouble((ObjectNode n)->(double)load(n.path("nodeId").asText())/Math.max(1,n.path("capacity").asInt()))
+ .thenComparing(n->n.path("nodeId").asText())).orElse(null);
+ }
+ /**
+ * 每次分配提升任务版本并生成新的媒体许可。
+ * 候选准备完成之前不替换 active,因此 PXHJ 可以继续播放已就绪的旧地址。
+ */
+ private void assign(ObjectNode t,ObjectNode n){
+ long version=t.path("versionCounter").asLong()+1;t.put("versionCounter",version);
+ t.set("candidate",object().put("nodeId",n.path("nodeId").asText()).put("sessionId",n.path("sessionId").asText())
+ .put("version",version).put("mediaKey",UUID.randomUUID().toString()).put("assignedAt",now()));
+ t.put("reason",assignment(t,"active")==null?"PREPARING":"MIGRATING");
+ }
+ /**
+ * 调度顺序:撤销失效许可、处理容量缩减,再为等待任务寻找空位。
+ * 同一次调度把所有候选预热任务计入容量,避免并发抢占和超发。
+ */
+ private void schedule(){
+ expireImageSources();
+ // 同等条件下先处理较早创建的任务,让容量不足时的等待顺序可预测。
+ List tasks=rows(state,"tasks");tasks.sort(Comparator.comparingLong(t->t.path("createdAt").asLong()));
+ for(ObjectNode t:tasks){
+ var context=map("contexts").get(t.path("trialId").asText());
+ if(t.path("desired").asText().equals("STOPPED")||context==null||!context.path("confirmed").asBoolean()){t.putNull("active");t.putNull("candidate");continue;}
+ for(String key:List.of("active","candidate")){ObjectNode a=assignment(t,key);if(a!=null&&!valid(a)){t.putNull(key);t.put("reason","NODE_OFFLINE");}}
+ ObjectNode candidate=assignment(t,"candidate");
+ // 预热超时释放预留槽位,已有输出仍然保留;错误状态向 PXHJ 汇总展示。
+ if(candidate!=null&&now()-candidate.path("assignedAt").asLong()>30000){t.putNull("candidate");t.put("reason","PREPARE_TIMEOUT").put("lastFailure",now());}
+ }
+ for(ObjectNode n:rows(state,"nodes")){
+ String id=n.path("nodeId").asText();int effective=n.path("enabled").asBoolean()?n.path("capacity").asInt():0;
+ int active=(int)tasks.stream().filter(t->t.path("active").path("nodeId").asText().equals(id)).count();
+ var incoming=tasks.stream().filter(t->t.path("candidate").path("nodeId").asText().equals(id)).toList();
+ // 管理员减少容量后先撤销多余的预热任务,再逐步迁出正在播放的任务。
+ for(int i=Math.max(0,effective-active);it.path("active").path("nodeId").asText().equals(id)).toList();
+ // 降低路数时先寻找接管节点;集群没有空位则标记调整中,保留现有视频直到能迁出。
+ int surplus=Math.max(0,owned.size()-effective);
+ for(int i=owned.size()-1;i>=0&&surplus>0;i--,surplus--){var t=owned.get(i);if(assignment(t,"candidate")!=null)continue;
+ ObjectNode target=available(id,t);if(target==null)t.put("reason","CAPACITY_WAIT_KEEP_RUNNING");else assign(t,target);
+ }
+ }
+ // 没有 active/candidate 的任务才参与新分配;容量不足保持排队,不强行一机一路。
+ for(ObjectNode t:tasks)if(!t.path("desired").asText().equals("STOPPED")&&assignment(t,"active")==null&&assignment(t,"candidate")==null){
+ var c=map("contexts").get(t.path("trialId").asText());if(c==null||!c.path("confirmed").asBoolean())continue;
+ if(now()-t.path("lastFailure").asLong()>3000){ObjectNode target=available(null,t);if(target==null)t.put("reason","CAPACITY_WAIT");else assign(t,target);}
+ }
+ }
+
+ private static boolean original(JsonNode task){return task.path("mediaKind").asText().equals("ORIGINAL_IMAGE");}
+ private static String imageTaskId(String endpoint){return UUID.nameUUIDFromBytes(("original-image/"+endpoint).getBytes(StandardCharsets.UTF_8)).toString();}
+
+ /** 采集器在线时原图随时可接入,不以正式轮次启动作为试验准备取图的前提。 */
+ public synchronized ObjectNode imageSource(JsonNode input){
+ expireImageSources();
+ String endpoint=required(input,"endpointId"),name=required(input,"name");
+ ObjectNode oldAccess=imageSources.access(endpoint);
+ imageSources.register(input,leaseClock.getAsLong());
+ ObjectNode access=imageSources.access(endpoint);
+ String contextId="collector-image:"+endpoint,id=imageTaskId(endpoint);
+ map("contexts").set(contextId,object().put("kind","ORIGINAL_IMAGE").put("trialId",contextId)
+ .put("roundId","standby").put("name","采集器原图接入").put("roundStatus","PENDING").put("confirmed",true));
+ ObjectNode t=map("tasks").has(id)?(ObjectNode)map("tasks").get(id):object().put("taskId",id).put("versionCounter",0).put("createdAt",now());
+ // 原连接或密钥变化时撤销旧接收许可,再分配新版本,防止继续使用旧设备凭据。
+ if(oldAccess!=null&&!oldAccess.equals(access)){t.putNull("active");t.putNull("candidate");}
+ t.put("trialId",contextId).put("roundId","standby").put("channelId","original-image").put("equipmentId",endpoint)
+ .put("name",name+"原图").put("trialName","试验准备原图").put("desired","READY").put("mediaKind","ORIGINAL_IMAGE")
+ .put("sourceEndpointId",endpoint).put("sourceHost",access.path("sourceHost").asText()).put("collectorId",access.path("collectorId").asInt());
+ map("tasks").set(id,t);schedule();persist();return imageRoute(t);
+ }
+
+ /** PXHJ 在已有图片 API 后面查询有效接收节点,浏览器无需知道集群内部地址。 */
+ public synchronized ObjectNode imageRoutes(JsonNode input){
+ require(input.path("endpointIds").isArray()&&input.path("endpointIds").size()<=64,"Invalid image endpoints");
+ schedule();ObjectNode result=object();ArrayNode routes=result.putArray("routes");
+ Set seen=new HashSet<>();
+ for(JsonNode value:input.path("endpointIds")){
+ String endpoint=value.asText();require(endpoint.matches("[0-9a-fA-F-]{36}"),"Invalid image endpoint");
+ if(!seen.add(endpoint))continue;JsonNode entry=map("tasks").get(imageTaskId(endpoint));if(entry==null)continue;
+ ObjectNode a=assignment((ObjectNode)entry,"active");if(!valid(a))continue;
+ routes.add(object().put("endpointId",endpoint).put("taskId",entry.path("taskId").asText())
+ .put("version",a.path("version").asLong()).put("nodeBaseUrl",node(a.path("nodeId").asText()).path("baseUrl").asText()));
+ }
+ return result;
+ }
+
+ // 迁移期间最多发布两个地址;bulk 图片不经过网关,网关只运送短小的协商报文。
+ private ObjectNode imageRoute(ObjectNode t){
+ ObjectNode result=object().put("status","WAITING").put("coordinatorId",state.path("serviceId").asText())
+ .put("epoch",state.path("epoch").asLong()).put("version",t.path("versionCounter").asLong()).put("leaseMs",8000);
+ ArrayNode targets=result.putArray("targets");
+ for(String key:List.of("active","candidate")){
+ ObjectNode a=assignment(t,key);if(!valid(a)||!a.path("ready").asBoolean())continue;
+ ObjectNode n=node(a.path("nodeId").asText());
+ if(n.path("imageInputPort").asInt()>0&&ImageSourceRegistry.ipv4(n.path("imageHost").asText()))
+ targets.add(object().put("host",n.path("imageHost").asText()).put("port",n.path("imageInputPort").asInt()));
+ }
+ if(!targets.isEmpty())result.put("status","READY");return result;
+ }
+
+ /** 连续三十秒没有经过认证的协商就释放原图任务,独立试验和 UDP 路由保持原状。 */
+ private void expireImageSources(){
+ for(String endpoint:imageSources.expire(leaseClock.getAsLong())){
+ // 保留不含密钥的版本计数,采集器恢复后不可能回到同一总控代次中的旧许可。
+ JsonNode entry=map("tasks").get(imageTaskId(endpoint));
+ if(entry instanceof ObjectNode task){task.put("desired","STOPPED");task.putNull("active");task.putNull("candidate");}
+ map("contexts").remove("collector-image:"+endpoint);
+ }
+ }
+
+ /**
+ * 登记的是原始报文的来源与转发地址,总控不解析采集模块专有协议。
+ * 稳定的中转源端口供 PXHJ 关联原连接,保留原采集器登记和原密钥。
+ */
+ public synchronized ObjectNode routes(JsonNode input){
+ require(input.path("routes").isArray()&&input.path("routes").size()<=255,"Invalid routes");
+ var updated=object();Set ports=new HashSet<>();Set ids=new HashSet<>();List approved=new ArrayList<>();
+ for(JsonNode route:input.path("routes")){
+ String id=required(route,"endpointId");required(route,"sourceHost");required(route,"targetHost");
+ require(ids.add(id),"Duplicate endpoint identity");
+ int source=route.path("sourcePort").asInt(),relay=route.path("relayPort").asInt(),target=route.path("targetPort").asInt();
+ require(source>=0&&source<=65535&&relay>1024&&relay<=65535&&target>0&&target<=65535&&ports.add(relay),"Invalid or duplicate route ports");
+ require(relay!=19002,"Relay port conflicts with the default gateway input");
+ // 不允许把仍在使用的源端口换给另一采集器,否则旧回执可能发到错误设备。
+ for(ObjectNode old:rows(state,"routes"))require(old.path("relayPort").asInt()!=relay||old.path("endpointId").asText().equals(id),"Relay port belongs to another endpoint; allocate a different port");
+ // 来源端口为 0 表示该地址的任意端口,不能与同地址的另一登记同时匹配。
+ for(JsonNode previous:approved)require(!previous.path("sourceHost").asText().equals(route.path("sourceHost").asText())
+ ||(source!=0&&previous.path("sourcePort").asInt()!=0&&source!=previous.path("sourcePort").asInt()),"Overlapping source mappings");
+ approved.add(route);
+ updated.set(id,route.deepCopy());
+ }
+ state.set("routes",updated);state.put("routeVersion",state.path("routeVersion").asLong()+1).put("routesConfirmed",true);persist();return snapshot();
+ }
+
+ /** 网关上报实际 UDP 出口和统计;总控仅返回已由 PXHJ 确认的路由版本。 */
+ public synchronized ObjectNode gateway(JsonNode input){
+ require(cluster.equals(required(input,"clusterId")),"Cluster mismatch");String id=required(input,"gatewayId");
+ var report=(ObjectNode)input.deepCopy();report.remove("clusterId");report.put("lastHeartbeat",now());map("gateways").set(id,report);
+ var result=object().put("routeVersion",state.path("routeVersion").asLong()).put("routesConfirmed",state.path("routesConfirmed").asBoolean());result.set("routes",array().addAll(rows(state,"routes")));persist();return result;
+ }
+
+ /**
+ * PXHJ 使用的业务快照,隐藏内部节点会话,提供当前任务状态及可用播放地址。
+ * 地址由已注册节点和有效任务构造,不能直接相信节点自行提交的任意播放 URL。
+ */
+ public synchronized ObjectNode snapshot(){
+ ObjectNode result=object().put("clusterId",cluster).put("serverTime",now()).put("serviceId",state.path("serviceId").asText()).put("epoch",state.path("epoch").asLong()).put("revision",state.path("revision").asLong()).put("storageHealthy",storageHealthy);
+ ArrayNode nodes=result.putArray("nodes"),tasks=result.putArray("tasks"),gateways=result.putArray("gateways");
+ int capacity=0,active=0,queued=0;
+ for(ObjectNode n:rows(state,"nodes")){
+ ObjectNode copy=n.deepCopy();copy.remove(List.of("sessionId","bootId"));copy.put("online",live(n));
+ // 兼容升级前的子节点记录;角色由稳定身份确认,前端无需猜测名称或端口。
+ copy.put("nodeRole",n.path("nodeId").asText().equals(state.path("serviceId").asText())?"COORDINATOR":"WORKER");
+ int used=0,reserved=0;for(ObjectNode t:rows(state,"tasks")){if(t.path("active").path("nodeId").asText().equals(n.path("nodeId").asText()))used++;if(t.path("candidate").path("nodeId").asText().equals(n.path("nodeId").asText()))reserved++;}
+ copy.put("running",used).put("reserved",reserved).put("adjusting",used+reserved>(n.path("enabled").asBoolean()?n.path("capacity").asInt():0));nodes.add(copy);
+ if(live(n)&&n.path("enabled").asBoolean())capacity+=n.path("capacity").asInt();
+ }
+ for(ObjectNode t:rows(state,"tasks")){
+ if(original(t)&&t.path("desired").asText().equals("STOPPED"))continue;
+ ObjectNode copy=t.deepCopy(),a=assignment(t,"active");copy.remove(List.of("active","candidate"));
+ String status=t.path("desired").asText().equals("STOPPED")?"ENDED":assignment(t,"candidate")!=null?(a==null?"PREPARING":"MIGRATING")
+ :a!=null?(t.path("desired").asText().equals("RUNNING")?"RUNNING":"READY"):"QUEUED";
+ copy.put("status",status);
+ // MIGRATING 状态也保留旧 active 的地址,只有接管成功才对外切换版本。
+ if(a!=null&&valid(a)){
+ String url=node(a.path("nodeId").asText()).path("baseUrl").asText()+"/media/"+t.path("taskId").asText()+"/"+a.path("version").asLong()+"/stream?key="+a.path("mediaKey").asText();
+ copy.put("nodeId",a.path("nodeId").asText()).put("version",a.path("version").asLong())
+ .put("playProtocol",original(t)?"MJPEG_ORIGINAL":"MJPEG_DEMO").put("playUrl",url);active++;
+ if(original(t))copy.put("imageReady",a.path("imageReady").asBoolean()).put("lastFrameTime",a.path("lastFrameTime").asLong());
+ }
+ if(status.equals("QUEUED"))queued++;tasks.add(copy);
+ }
+ for(ObjectNode g:rows(state,"gateways")){var copy=g.deepCopy();copy.put("online",now()-g.path("lastHeartbeat").asLong() entries = new HashMap<>();
+ private record Entry(ObjectNode access, long refreshed) {}
+
+ /** 来源 IP 与采集器编号取自 PXHJ 保存的连接,浏览器不能自行指定密钥。 */
+ String register(JsonNode input, long monotonic) {
+ String endpoint = required(input,"endpointId");
+ require(endpoint.matches("[0-9a-fA-F-]{36}") && UUID.fromString(endpoint).toString().equalsIgnoreCase(endpoint), "Invalid endpoint identity");
+ String host = required(input,"sourceHost");
+ require(ipv4(host), "An IPv4 image source is required");
+ require(input.path("collectorId").isIntegralNumber()&&input.path("collectorId").canConvertToInt(), "Invalid collector identity");
+ int collector = input.path("collectorId").asInt();
+ require(collector > 0 && collector <= 255, "Invalid collector identity");
+ String sm4 = required(input,"sm4Key"), hmac = required(input,"hmacKey");
+ // 校验长度后重新编码,拒绝不完整密钥;异常消息始终不包含提交的密钥内容。
+ try {
+ byte[] a=Base64.getDecoder().decode(sm4), b=Base64.getDecoder().decode(hmac);
+ require(a.length == 16 && b.length >= 32 && b.length <= 64, "Invalid collector key lengths");
+ sm4=Base64.getEncoder().encodeToString(a);hmac=Base64.getEncoder().encodeToString(b);
+ } catch (IllegalArgumentException ex) { throw new IllegalArgumentException("Invalid collector keys"); }
+ // FND 的采集器编号只有一个字节,同时限制来源缓存数量,避免无限注册占用内存。
+ require(entries.containsKey(endpoint) || entries.size() < 255, "Image source capacity exceeded");
+ // Base64 仅用于 JSON 携带二进制密钥,传输身份校验仍由内部签名接口负责。
+ ObjectNode access=object().put("endpointId",endpoint).put("sourceHost",host).put("collectorId",collector)
+ .put("sm4Key",sm4).put("hmacKey",hmac);
+ entries.put(endpoint,new Entry(access,monotonic));
+ return endpoint;
+ }
+
+ /** 仅返回副本,避免生成节点计划时意外修改后续协商使用的凭据。 */
+ ObjectNode access(String endpoint) {
+ Entry e=entries.get(endpoint);return e==null?null:e.access.deepCopy();
+ }
+
+ // 使用单调时钟回收失联来源,停止长期占用可配置的节点路数。
+ Set expire(long monotonic) {
+ Set expired=new HashSet<>();
+ // 非法时钟倒退同样撤销许可,不能靠回退计时给旧上传目标延长寿命。
+ entries.entrySet().removeIf(e->{long age=monotonic-e.getValue().refreshed;
+ boolean old=age<0||age>=SOURCE_LEASE_MS;if(old)expired.add(e.getKey());return old;});
+ return expired;
+ }
+
+ /** 协商协议只发布数值 IPv4,避免把未验证域名变成采集器新的外联地址。 */
+ static boolean ipv4(String value) {
+ if(value==null)return false;String[] parts=value.split("\\.",-1);if(parts.length!=4)return false;
+ for(String part:parts){if(!part.matches("0|[1-9][0-9]{0,2}"))return false;
+ int n=Integer.parseInt(part);if(n>255)return false;}
+ int first=Integer.parseInt(parts[0]);
+ return first>0&&first<224&&!value.equals("255.255.255.255");
+ }
+}
diff --git a/src/main/java/com/xtrh/discovery/service/CoordinatorLocator.java b/src/main/java/com/xtrh/discovery/service/CoordinatorLocator.java
new file mode 100644
index 0000000..455db34
--- /dev/null
+++ b/src/main/java/com/xtrh/discovery/service/CoordinatorLocator.java
@@ -0,0 +1,137 @@
+package com.xtrh.discovery.service;
+
+import com.xtrh.common.config.Settings;
+import com.xtrh.common.util.Jsons;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.discovery.util.NodeAddress;
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 将配置、历史地址和 mDNS 线索转换为已认证的总控连接。
+ * 与发现服务分开,避免未经认证的广播记录直接改变当前任务归属。
+ * 重连间隔由调用方负责,本类只判断一次查找是否成功或存在冲突。
+ * 磁盘缓存只保存地址,远端公开的逻辑身份用于当次验证和去重。
+ */
+@Component
+public class CoordinatorLocator {
+ private final Settings settings;
+ private final Discovery discovery;
+ private final PeerClient peer;
+ private volatile String connected = "";
+ private volatile String status = "WAITING";
+
+ public CoordinatorLocator(Settings settings, Discovery discovery, PeerClient peer) {
+ this.settings = settings;
+ this.discovery = discovery;
+ this.peer = peer;
+ }
+
+ public synchronized String locate() throws Exception {
+ // 主执行端只向自家总控注册;使用本机控制入口,不依赖总控监听自己的发现广播。
+ if (settings.role.equals("coordinator")) {
+ if (!connected.isEmpty()) return connected;
+ String local = NodeAddress.controlUrl(settings);
+ var info = peer.request(local, "/control/info", "GET", null);
+ if (!settings.cluster.equals(info.path("clusterId").asText())
+ || !settings.id.equals(info.path("serviceId").asText())
+ || !info.path("role").asText().equals("coordinator")
+ || !info.path("storageHealthy").asBoolean()) {
+ throw new IllegalStateException("Local coordinator identity mismatch");
+ }
+ connected = local;
+ status = "CONNECTED";
+ return connected;
+ }
+ // 复用地址不跳过后续心跳的响应签名校验,也不自动延长任务执行许可。
+ if (!connected.isEmpty()) {
+ return connected;
+ }
+
+ // 显式部署入口和上次成功地址优先,但缓存不免除每次重连时的签名验证。
+ // 顺序集合避免重复请求同一地址,也保持固定入口的优先级。
+ Set candidates = new LinkedHashSet<>();
+ if (!settings.coordinator.isBlank()) {
+ candidates.add(settings.coordinator);
+ }
+ Path cache = settings.directory.resolve("coordinator-url.txt");
+ if (Files.exists(cache)) {
+ // 历史地址可能随 IP 调整失效,读取它不会直接把状态改为已连接。
+ candidates.add(Files.readString(cache).trim());
+ }
+
+ for (String url : candidates) {
+ try {
+ // 固定入口或缓存成功时优先恢复,不再根据未认证广播随机选择其他地址。
+ // PeerClient 校验响应签名后才返回内容,私有凭证不会发送给候选服务器。
+ var info = peer.request(url, "/control/info", "GET", null);
+ if (!settings.cluster.equals(info.path("clusterId").asText())
+ || info.path("serviceId").asText().isBlank()
+ || !info.path("storageHealthy").asBoolean()) {
+ continue;
+ }
+
+ // 只缓存合法根地址;总控存储失败时不接受新连接,避免继续领取无持久化保障的许可。
+ connected = Jsons.baseUrl(url);
+ Files.writeString(cache, connected);
+ status = "CONNECTED";
+ return connected;
+ } catch (Exception ignored) {
+ // 固定或缓存地址暂时不可用时继续自动发现,不把连接失败当成已注册。
+ }
+ }
+
+ // 发现线索全部验证后再按认证得到的 serviceId 去重。
+ // 同一总控多网卡的多个 IP 可以合并;两个不同有效总控不能随机选择。
+ Map verified = new LinkedHashMap<>();
+ // 同一名称的公告也可能来自不同服务,冲突判断使用签名响应中的逻辑 ID。
+ // 无有效签名的伪造公告不能挤占正常总控,也不能触发误报双总控。
+ for (String url : discovery.candidates()) {
+ try {
+ var info = peer.request(url, "/control/info", "GET", null);
+ if (settings.cluster.equals(info.path("clusterId").asText())
+ && !info.path("serviceId").asText().isBlank()
+ && info.path("storageHealthy").asBoolean()) {
+ verified.putIfAbsent(info.path("serviceId").asText(), Jsons.baseUrl(url));
+ }
+ } catch (Exception ignored) {
+ // 未认证或不可达的公告不进入冲突集合,避免陌生广播阻止正常注册。
+ }
+ }
+
+ if (verified.size() > 1) {
+ // 尚未绑定时发现两个已认证总控,保持冲突状态,不保存任意一个候选。
+ status = "CONFLICT";
+ throw new IllegalStateException("Multiple authenticated coordinators discovered");
+ }
+ if (verified.size() == 1) {
+ // 唯一有效身份可以对应多个 IP,只缓存本次验证成功的服务根地址。
+ connected = verified.values().iterator().next();
+ Files.writeString(cache, connected);
+ status = "CONNECTED";
+ return connected;
+ }
+
+ // 找不到总控时不自建试验上下文,网关可独立沿用已确认路由继续转发。
+ status = "WAITING";
+ // 失败返回异常,由网关或节点按各自重试周期再次调用。
+ throw new IllegalStateException("Waiting for authenticated coordinator discovery");
+ }
+
+ public synchronized void disconnect() {
+ // 调用方发现请求失败后清掉连接,下次重新验证固定地址、缓存和发现线索。
+ // 保留历史地址文件,短暂网络故障恢复后仍可先验证原入口。
+ connected = "";
+ status = "WAITING";
+ }
+
+ public String status() {
+ return status;
+ }
+}
diff --git a/src/main/java/com/xtrh/discovery/service/Discovery.java b/src/main/java/com/xtrh/discovery/service/Discovery.java
new file mode 100644
index 0000000..435b2bb
--- /dev/null
+++ b/src/main/java/com/xtrh/discovery/service/Discovery.java
@@ -0,0 +1,189 @@
+package com.xtrh.discovery.service;
+
+import com.xtrh.common.config.Settings;
+import jakarta.annotation.PreDestroy;
+import org.springframework.boot.web.context.WebServerInitializedEvent;
+import org.springframework.context.event.EventListener;
+import org.springframework.stereotype.Component;
+
+import javax.jmdns.JmDNS;
+import javax.jmdns.ServiceEvent;
+import javax.jmdns.ServiceInfo;
+import javax.jmdns.ServiceListener;
+import java.net.Inet4Address;
+import java.net.InetAddress;
+import java.net.NetworkInterface;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * 通过局域网广播获取总控候选地址,不在发现层建立信任或下发任务。
+ * mDNS 的集群名称和 serviceId 均可能被伪造,最终由 Locator 验证签名响应。
+ * 多块网卡发现同一总控时按逻辑身份收集地址,不把不同 IP 当成不同总控。
+ * 服务类型与协议版本只用于缩小候选范围,不构成服务认证。
+ * 候选缓存不承载试验、节点容量或执行许可,业务状态仍以总控为准。
+ */
+@Component
+public class Discovery {
+ public static final String TYPE = "_xtrh-control._tcp.local.";
+
+ private final Settings settings;
+ // 回调与关闭操作可能同时访问引擎列表,使用可安全遍历的并发容器。
+ private final List engines = new CopyOnWriteArrayList<>();
+ // 地址按公告身份汇集;真正的逻辑身份仍需从已签名的控制响应中确认。
+ private final Map> discovered = new ConcurrentHashMap<>();
+ private volatile String status = "STARTING";
+ private volatile boolean closed;
+
+ public Discovery(Settings settings) {
+ this.settings = settings;
+ }
+
+ @EventListener
+ public void started(WebServerInitializedEvent event) {
+ // 禁用发现只关闭组播路径,Locator 仍可尝试统一配置的入口和历史地址。
+ if (!settings.discoveryEnabled) {
+ status = "DISABLED";
+ return;
+ }
+
+ // 等 HTTP 服务确定真实端口再发布;发现初始化不阻塞服务启动或 UDP 转发。
+ // 后台线程不领取任务,注册必须等候选地址通过正式 HTTP 身份校验。
+ // 安装时 HTTP 端口可以改动,公告使用事件中的实际监听端口。
+ Thread thread = new Thread(
+ () -> start(event.getWebServer().getPort()), "xtrh-discovery");
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ private void start(int port) {
+ try {
+ // 发现只覆盖组播能够到达的网段;跨 VLAN 部署由统一入口配置兜底。
+ // 不同网卡可以进入不同网络,因此不能只尝试主机名解析出的一个地址。
+ List addresses = new ArrayList<>();
+ if (!settings.discoveryAddress.isBlank()) {
+ // 多网卡部署可以指定一块网卡,避免发现报文发送到不相关网络。
+ // 显式地址仍由 JmDNS 检查可用性,失败保持未发现状态。
+ addresses.add(InetAddress.getByName(settings.discoveryAddress));
+ } else {
+ // 自动选择已启用、非回环且支持组播的 IPv4 地址,不假定第一块网卡可用。
+ // 当前协议公告使用 IPv4,链路本地地址不作为跨电脑服务入口。
+ for (NetworkInterface card : Collections.list(NetworkInterface.getNetworkInterfaces())) {
+ if (!card.isUp() || card.isLoopback() || !card.supportsMulticast()) {
+ continue;
+ }
+ for (InetAddress address : Collections.list(card.getInetAddresses())) {
+ if (address instanceof Inet4Address && !address.isLinkLocalAddress()) {
+ addresses.add(address);
+ }
+ }
+ }
+ }
+
+ // 无合适组播网卡时保留主机地址尝试;网络隔离部署仍可使用固定总控入口。
+ if (addresses.isEmpty()) {
+ addresses.add(InetAddress.getLocalHost());
+ }
+
+ for (InetAddress address : addresses) {
+ // 服务关闭后不再为尚未处理的网卡建立发现套接字。
+ if (closed) {
+ return;
+ }
+ try {
+ // 每个地址建立发现引擎;并发回调使用线程安全集合收集候选信息。
+ // 引擎名称由部署实例身份派生,重启后仍能对应同一台节点。
+ JmDNS dns = JmDNS.create(address, "xtrh-" + settings.id.substring(0, 8));
+ engines.add(dns);
+ if (settings.role.equals("coordinator")) {
+ // 广播只有公开线索,不包含私有凭证、试验配置或播放许可。
+ // 身份字段只便于关联公告,不能用它替代签名响应认证。
+ dns.registerService(ServiceInfo.create(
+ TYPE, "PXHJ-" + settings.id, port, 0, 0,
+ Map.of(
+ "clusterId", settings.cluster,
+ "serviceId", settings.id,
+ "protocol", "1")));
+ } else {
+ dns.addServiceListener(TYPE, new ServiceListener() {
+ public void serviceAdded(ServiceEvent event) {
+ // 新服务先解析完整记录,不能直接使用仅包含名称的公告。
+ dns.requestServiceInfo(event.getType(), event.getName());
+ }
+
+ public void serviceRemoved(ServiceEvent event) {
+ // 注销公告只清除发现线索;当前控制连接是否失效由心跳确认。
+ // 缓存入口仍可在重连时尝试,避免单次组播事件终止正式业务。
+ String id = event.getInfo().getPropertyString("serviceId");
+ if (id != null) {
+ discovered.remove(id);
+ }
+ }
+
+ public void serviceResolved(ServiceEvent event) {
+ ServiceInfo info = event.getInfo();
+ // 先筛除其他集群和接口版本;通过此筛选仍然不代表已经认证。
+ // 接收公告不执行配置、不修改任务归属,也不发送服务凭证。
+ if (!settings.cluster.equals(info.getPropertyString("clusterId"))
+ || !"1".equals(info.getPropertyString("protocol"))) {
+ return;
+ }
+
+ Set urls = ConcurrentHashMap.newKeySet();
+ // 一个公告可能含多个可达 IP,留给 Locator 逐个验证。
+ for (Inet4Address host : info.getInet4Addresses()) {
+ urls.add("http://" + host.getHostAddress() + ":" + info.getPort());
+ }
+ String id = info.getPropertyString("serviceId");
+ if (id != null && !urls.isEmpty()) {
+ // 多网卡回调合并地址,避免相同总控因重复公告被请求多次。
+ // 公告中的身份只作为缓存键,认证去重在 Locator 完成。
+ discovered.computeIfAbsent(id, key -> ConcurrentHashMap.newKeySet())
+ .addAll(urls);
+ }
+ }
+ });
+ }
+ } catch (Exception ignored) {
+ // 某块网卡失败时继续尝试其他网卡,不停止已经建立的发现引擎。
+ }
+ }
+ // ACTIVE 只表示发现引擎存在,并不宣称节点已注册或总控已经可信。
+ status = engines.isEmpty() ? "UNAVAILABLE" : "ACTIVE";
+ } catch (Exception e) {
+ status = "UNAVAILABLE";
+ }
+ }
+
+ // 未认证广播只能给线索;多个有效总控的判断必须放在响应签名验证之后。
+ // 返回去重后的稳定顺序,避免同一轮查找重复试探相同服务地址。
+ public List candidates() {
+ return discovered.values().stream()
+ .flatMap(Set::stream)
+ .distinct()
+ .sorted()
+ .toList();
+ }
+
+ public String status() {
+ return status;
+ }
+
+ @PreDestroy
+ public void close() {
+ // 发现引擎只负责组播套接字,任务与数据库生命周期由各业务服务管理。
+ closed = true;
+ for (JmDNS dns : engines) {
+ try {
+ dns.close();
+ } catch (Exception ignored) {
+ // 逐个关闭网卡引擎,单个关闭错误不能阻止其他组播套接字释放。
+ }
+ }
+ }
+}
diff --git a/src/main/java/com/xtrh/discovery/util/NodeAddress.java b/src/main/java/com/xtrh/discovery/util/NodeAddress.java
new file mode 100644
index 0000000..92d4795
--- /dev/null
+++ b/src/main/java/com/xtrh/discovery/util/NodeAddress.java
@@ -0,0 +1,87 @@
+package com.xtrh.discovery.util;
+
+import com.xtrh.common.config.Settings;
+
+import java.net.InetSocketAddress;
+import java.net.InetAddress;
+import java.net.Inet4Address;
+import java.net.NetworkInterface;
+import java.net.Socket;
+import java.net.URI;
+import java.util.Collections;
+
+/**
+ * 选择节点对外登记的地址,不把本机回环地址默认当成跨电脑播放地址。
+ * 多网卡时根据到已认证总控的实际网络路由选择本机地址,而非主机名的首个解析结果。
+ */
+public final class NodeAddress {
+ private NodeAddress() {}
+
+ public static String host(Settings settings, String coordinator) throws Exception {
+ if (!settings.advertisedHost.isBlank()) {
+ // 特殊网络或播放域名由部署配置覆盖,最终注册仍执行服务根地址校验。
+ return settings.advertisedHost;
+ }
+
+ // 主执行端通过本机入口领取任务,对外登记地址必须另外选择,供其他电脑播放。
+ if (settings.role.equals("coordinator")) return localHost(settings);
+ URI uri = URI.create(coordinator);
+ try (Socket route = new Socket()) {
+ // 连接只用于查询操作系统选出的本机接口,关闭后不会持续占用控制连接。
+ // HTTPS 未显式写端口时按 443 查询路由,HTTP 按 80。
+ route.connect(new InetSocketAddress(
+ uri.getHost(),
+ uri.getPort() > 0 ? uri.getPort() : (uri.getScheme().equals("https") ? 443 : 80)),
+ 1000);
+ InetAddress local = route.getLocalAddress();
+ // 同电脑的独立子节点可能连接 localhost,同样不能把回环默认发布给远端观看者。
+ return local.isLoopbackAddress() ? localHost(settings) : local.getHostAddress();
+ }
+ }
+
+ /** 自注册只访问本实例已绑定的控制入口,网络发送仍走正常签名认证。 */
+ public static String controlUrl(Settings settings) {
+ if (!settings.httpReady()) throw new IllegalStateException("Waiting for local HTTP server");
+ String host = wildcard(settings.bindHost) ? "127.0.0.1" : settings.bindHost;
+ return httpUrl(host, settings.port);
+ }
+
+ // 发布实际 HTTP 端口;主节点的控制和媒体入口共用同一端口,不增开第二个执行进程。
+ public static String url(Settings settings, String coordinator) throws Exception {
+ return httpUrl(host(settings, coordinator), settings.port);
+ }
+
+ /** 对外地址优先尊重显式绑定与发现网卡,多网卡部署仍可用 advertised-host 指定。 */
+ private static String localHost(Settings settings) throws Exception {
+ if (!wildcard(settings.bindHost)) return settings.bindHost;
+ if (!settings.discoveryAddress.isBlank()) {
+ InetAddress configured = InetAddress.getByName(settings.discoveryAddress);
+ if (!configured.isLoopbackAddress() && !configured.isAnyLocalAddress()) return configured.getHostAddress();
+ }
+ // 只考虑启用的真实 IPv4 网卡,避免默认发布回环、链路本地或虚拟接口的地址。
+ var interfaces = Collections.list(NetworkInterface.getNetworkInterfaces());
+ InetAddress preferred = null;
+ try { preferred = InetAddress.getLocalHost(); } catch (Exception ignored) { }
+ String first = null;
+ for (NetworkInterface card : interfaces) {
+ if (!card.isUp() || card.isLoopback() || card.isVirtual()) continue;
+ for (InetAddress address : Collections.list(card.getInetAddresses())) {
+ if (!(address instanceof Inet4Address) || address.isLoopbackAddress() || address.isLinkLocalAddress()) continue;
+ if (address.equals(preferred)) return address.getHostAddress();
+ if (first == null) first = address.getHostAddress();
+ }
+ }
+ // 无局域网网卡的纯本机环境仍可运行;跨电脑使用须发布观看端可访问的地址。
+ return first == null ? "127.0.0.1" : first;
+ }
+
+ private static boolean wildcard(String host) {
+ return host.isBlank() || host.equals("0.0.0.0") || host.equals("::") || host.equals("[::]");
+ }
+
+ // IPv6 字面量需要方括号,确保绑定特定网卡时仍生成合法 URL。
+ private static String httpUrl(String host, int port) {
+ String authority = host.contains(":") && !host.startsWith("[") ? "[" + host + "]" : host;
+ return "http://" + authority + ":" + port;
+ }
+}
diff --git a/src/main/java/com/xtrh/gateway/controller/GatewayController.java b/src/main/java/com/xtrh/gateway/controller/GatewayController.java
new file mode 100644
index 0000000..0c6ba06
--- /dev/null
+++ b/src/main/java/com/xtrh/gateway/controller/GatewayController.java
@@ -0,0 +1,27 @@
+package com.xtrh.gateway.controller;
+
+import com.xtrh.gateway.service.GatewayService;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+/**
+ * 网关 HTTP 入口仅返回转发状态与统计信息。
+ * 路由由经过认证的总控计划同步,不能从此入口任意修改。
+ * UDP socket、队列与双向转发均封装在业务服务中。
+ */
+@RestController
+@ConditionalOnProperty(name = "fusion.role", havingValue = "gateway")
+public class GatewayController {
+ private final GatewayService gateway;
+
+ public GatewayController(GatewayService gateway) {
+ this.gateway = gateway;
+ }
+
+ // 状态快照不含采集报文与共享凭证,供总控和运行检查读取。
+ @GetMapping("/gateway/status")
+ public Object status() {
+ return gateway.status();
+ }
+}
diff --git a/src/main/java/com/xtrh/gateway/service/GatewayService.java b/src/main/java/com/xtrh/gateway/service/GatewayService.java
new file mode 100644
index 0000000..e085053
--- /dev/null
+++ b/src/main/java/com/xtrh/gateway/service/GatewayService.java
@@ -0,0 +1,432 @@
+package com.xtrh.gateway.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.common.config.Settings;
+import com.xtrh.common.dao.StateStore;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.discovery.service.CoordinatorLocator;
+import com.xtrh.discovery.util.NodeAddress;
+import jakarta.annotation.PreDestroy;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.SocketTimeoutException;
+import java.nio.ByteBuffer;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static com.xtrh.common.util.Jsons.array;
+import static com.xtrh.common.util.Jsons.object;
+import static com.xtrh.common.util.Jsons.require;
+import static com.xtrh.common.util.Jsons.required;
+
+/**
+ * 独立采集接入服务:接收原始 UDP 报文、识别来源并双向转送。
+ * 总控只负责同步路由,视频轮次结束或总控失联不停止已确认的转发。
+ * 不解密或重组采集协议,正式协议认证及业务解析仍由 PXHJ 完成。
+ */
+@Service
+@ConditionalOnProperty(name = "fusion.role", havingValue = "gateway")
+public class GatewayService {
+ // 采用 IPv4 UDP 的最大有效负载上限,不截取其中的采集协议字段。
+ private static final int MAX_DATAGRAM_BYTES = 65507;
+ // 字节限额与报文数限额配合,大报文不能绕过队列长度限制。
+ private static final long MAX_BUFFERED_BYTES = 16L * 1024 * 1024;
+
+ // 收包时保留来源及匹配的路由,发送线程无需重复猜测采集器身份。
+ // 字节数组专属这一报文,与接收循环复用的缓冲区不共享。
+ private record Packet(byte[] bytes, InetSocketAddress source, Relay relay) {}
+
+ private final Settings settings;
+ private final CoordinatorLocator locator;
+ private final PeerClient peer;
+ // 路由持久化经 DAO 完成;业务服务不包含表结构或 SQL 语句。
+ private final StateStore store;
+ private final DatagramSocket ingress;
+ // 上行接收与发送解耦,避免目标发送迟缓阻塞所有采集器的接收。
+ private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(2048);
+ // 计数描述本次进程运行,重启恢复的是路由,而不是历史报文统计。
+ // 原子计数可同时由上行线程和各采集器回传线程更新。
+ // 未登记、队列丢弃和发送错误分别计数,PXHJ 可区分数据缺失原因。
+ private final AtomicLong received = new AtomicLong(), forwarded = new AtomicLong();
+ private final AtomicLong unknown = new AtomicLong(), overflow = new AtomicLong();
+ private final AtomicLong errors = new AtomicLong(), buffered = new AtomicLong();
+ // 发布只读列表快照,收包线程遍历期间不受路由增删影响。
+ private volatile List relays = List.of();
+ private volatile boolean closed;
+ private volatile long routeVersion = -1;
+ private volatile JsonNode appliedRoutes = array();
+ // connection 描述总控连接;routeState 描述转发配置,二者不互相替代。
+ // WAITING 不代表数据线程停止,已经确认的转发可以继续运行。
+ private volatile String connection = "WAITING", routeState = "WAITING";
+ private long retryAfter;
+
+ /**
+ * 固定采集入口与各采集器的独立转发端口分别绑定。
+ * 启动先恢复最后确认路由,再开启收发线程,支持断网重启后的转发。
+ * 路由文件锁由 DAO 管理,防止两个网关误用同一个数据目录。
+ */
+ public GatewayService(Settings settings, CoordinatorLocator locator,
+ PeerClient peer, ObjectMapper mapper) throws Exception {
+ this.settings = settings;
+ this.locator = locator;
+ this.peer = peer;
+ store = StateStore.open(settings.directory.resolve("gateway"), mapper);
+ ingress = new DatagramSocket(null);
+ // 增大内核收包缓冲区以应对短时突发,实际可用大小仍由操作系统决定。
+ // 内核缓冲区和业务队列是两层容量,业务队列满时会显式累计丢弃数。
+ ingress.setReceiveBufferSize(4 * 1024 * 1024);
+ ingress.bind(new InetSocketAddress(settings.gatewayPort));
+ ObjectNode saved = store.load();
+ if (saved.path("routes").isArray()) {
+ apply(saved.path("routes"), saved.path("routeVersion").asLong(), false);
+ }
+ daemon("xtrh-udp-receive", this::receive);
+ daemon("xtrh-udp-forward", this::forward);
+ }
+
+ // UDP 循环独立于总控心跳,关闭服务时由 closed 标记和 socket 关闭退出。
+ private static void daemon(String name, Runnable runnable) {
+ Thread thread = new Thread(runnable, name);
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ /**
+ * 协商分类只识别完整 FND 外层,普通二进制报文碰巧含类型字节仍透明转发。
+ * 帧边界校验用于避免误分类,不能代替 PXHJ 的 CRC、HMAC 和密钥校验。
+ * 使用报文实际长度校验帧尾,接收循环的复用缓冲区尾部不属于本次报文。
+ */
+ private static boolean frameType(byte[] bytes, int length, int type) {
+ if (length < 109 || length > 1133 || Byte.toUnsignedInt(bytes[8]) != type) {
+ return false;
+ }
+ ByteBuffer header = ByteBuffer.wrap(bytes, 0, length);
+ return header.getInt(0) == 0x5AA5FAFB && header.getInt(4) == length
+ && header.getInt(length - 4) == 0xA55AFCFD;
+ }
+
+ /**
+ * 仅接收唯一匹配的登记来源;未登记和歧义来源统一丢弃并计数。
+ * 接收缓冲区循环复用,入队前复制有效字节,防止下一报文覆盖旧内容。
+ * 队列同时限制报文数和累计字节数,避免高频或大报文耗尽内存。
+ */
+ private void receive() {
+ byte[] buffer = new byte[MAX_DATAGRAM_BYTES];
+ while (!closed) {
+ try {
+ DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
+ ingress.receive(packet);
+ received.incrementAndGet();
+ InetSocketAddress source = (InetSocketAddress) packet.getSocketAddress();
+ Relay match = matchSource(source);
+ if (match == null) {
+ unknown.incrementAndGet();
+ continue;
+ }
+ // 不改原始密文、报文长度和采样时间,PXHJ 继续按既有协议解析。
+ byte[] bytes = Arrays.copyOf(buffer, packet.getLength());
+ long reserved = buffered.addAndGet(bytes.length);
+ if (reserved > MAX_BUFFERED_BYTES || !queue.offer(new Packet(bytes, source, match))) {
+ buffered.addAndGet(-bytes.length);
+ overflow.incrementAndGet();
+ }
+ } catch (Exception failure) {
+ if (!closed) {
+ errors.incrementAndGet();
+ }
+ }
+ }
+ }
+
+ // 同一来源若匹配多条路由就拒绝转发,防止把数据归入错误采集器。
+ private Relay matchSource(InetSocketAddress source) {
+ // 匹配依据来源 IP 与登记端口,内容中的模块编号仍交给 PXHJ 校验。
+ // 动态源端口由各 Relay 的 sourcePort=0 明确允许,不放宽来源 IP。
+ Relay match = null;
+ for (Relay relay : relays) {
+ if (relay.matches(source)) {
+ if (match != null) {
+ return null;
+ }
+ match = relay;
+ }
+ }
+ return match;
+ }
+
+ /**
+ * 每个采集器通过自己的稳定源端口转发,PXHJ 可保持独立身份映射。
+ * 单个报文发送失败只增加错误计数,后续报文及其他路由继续执行。
+ * 出队报文无论成功与否都释放字节额度,避免错误路径泄漏缓存容量。
+ */
+ private void forward() {
+ // 上行保持单个发送循环的出队顺序,不为每个报文创建临时线程。
+ // 空队列等待有时间上限,便于在关闭标记变化后退出循环。
+ while (!closed) {
+ try {
+ Packet packet = queue.poll(1, TimeUnit.SECONDS);
+ if (packet == null) {
+ continue;
+ }
+ try {
+ packet.relay.send(packet.bytes, packet.source);
+ forwarded.incrementAndGet();
+ } catch (Exception failure) {
+ errors.incrementAndGet();
+ } finally {
+ buffered.addAndGet(-packet.bytes.length);
+ }
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+
+ /**
+ * 路由变更先校验地址并准备新 socket,再发布新的列表快照。
+ * 相同采集器及相同转发端口复用 socket,保留回传线程与端口身份。
+ * 准备失败时关闭本次新开的端口,不关闭仍在服务的旧路由。
+ */
+ private synchronized void apply(JsonNode routes, long version, boolean save) throws Exception {
+ Map existing = new HashMap<>();
+ for (Relay relay : relays) {
+ existing.put(relay.id, relay);
+ }
+ List next = new ArrayList<>(), opened = new ArrayList<>();
+ Map updates = new HashMap<>();
+ try {
+ for (JsonNode route : routes) {
+ // 转发端口不能占用采集入口,否则会破坏收包与回传的端口分工。
+ require(route.path("relayPort").asInt() != ingress.getLocalPort(),
+ "Relay port conflicts with gateway input");
+ String id = required(route, "endpointId");
+ ObjectNode config = (ObjectNode) route.deepCopy();
+ InetAddress.getByName(required(route, "sourceHost"));
+ InetAddress.getByName(required(route, "targetHost"));
+ Relay old = existing.get(id);
+ if (old != null && old.route.path("relayPort").asInt() == route.path("relayPort").asInt()) {
+ next.add(old);
+ updates.put(old, config);
+ } else {
+ Relay fresh = new Relay(config);
+ opened.add(fresh);
+ next.add(fresh);
+ }
+ }
+ // 保存确认内容与版本,重启恢复时无需依赖总控马上在线。
+ ObjectNode snapshot = object().put("routeVersion", version);
+ snapshot.set("routes", routes.deepCopy());
+ if (save) {
+ store.save(snapshot);
+ }
+ for (Map.Entry entry : updates.entrySet()) {
+ entry.getKey().update(entry.getValue());
+ }
+ List previous = relays;
+ // 旧列表不再修改;新列表发布后才关闭已撤销的旧 socket。
+ relays = List.copyOf(next);
+ routeVersion = version;
+ appliedRoutes = routes.deepCopy();
+ routeState = "APPLIED";
+ for (Relay old : previous) {
+ if (!next.contains(old)) {
+ old.close();
+ }
+ }
+ } catch (Exception failure) {
+ for (Relay relay : opened) {
+ relay.close();
+ }
+ routeState = "APPLY_FAILED";
+ throw failure;
+ }
+ }
+
+ /**
+ * 自动发现总控后以稳定网关标识和本次启动标识报告状态。
+ * 只有 PXHJ 已确认的计划才替换本地路由,未确认的空计划不是撤销。
+ * 版本及内容同时比较,避免总控存储重建后复用版本号导致漏更新。
+ */
+ @Scheduled(fixedDelay = 1000)
+ public void heartbeat() {
+ // 重试退避约束发现与 HTTP 通信频率,不占用 UDP 收发线程。
+ if (System.currentTimeMillis() < retryAfter) {
+ return;
+ }
+ try {
+ String coordinator = locator.locate();
+ ObjectNode input = status().put("clusterId", settings.cluster)
+ .put("gatewayId", settings.id).put("bootId", settings.bootId)
+ .put("name", settings.name).put("baseUrl", NodeAddress.url(settings, coordinator));
+ JsonNode plan = peer.request(coordinator, "/control/gateways/heartbeat", "POST", input);
+ // 认证及响应验签由通信客户端统一完成,业务层只处理可信计划。
+ long version = plan.path("routeVersion").asLong();
+ if (plan.path("routesConfirmed").asBoolean()
+ && (version != routeVersion || !appliedRoutes.equals(plan.path("routes")))) {
+ apply(plan.path("routes"), version, true);
+ }
+ connection = "CONNECTED";
+ } catch (Exception failure) {
+ // 控制连接故障仅触发重新发现,已经确认的 UDP 线程继续服务。
+ locator.disconnect();
+ connection = "WAITING";
+ retryAfter = System.currentTimeMillis() + 1500;
+ }
+ }
+
+ // 控制层读取统计快照,不接触 socket、队列或可变路由对象。
+ public ObjectNode status() {
+ ObjectNode result = object().put("connection", connection).put("routeState", routeState)
+ .put("routeVersion", routeVersion).put("inputPort", ingress.getLocalPort())
+ .put("received", received.get()).put("forwarded", forwarded.get())
+ .put("unknownSource", unknown.get()).put("queueDropped", overflow.get())
+ .put("errors", errors.get()).put("bufferedBytes", buffered.get());
+ ArrayNode entries = result.putArray("routeStatus");
+ for (Relay relay : relays) {
+ entries.add(relay.status());
+ }
+ return result;
+ }
+
+ // 先关闭 socket 唤醒阻塞接收,再关闭持久化连接。
+ @PreDestroy
+ public void close() throws Exception {
+ closed = true;
+ ingress.close();
+ for (Relay relay : relays) {
+ relay.close();
+ }
+ store.close();
+ }
+
+ /**
+ * 单个采集器的转发会话,负责稳定端口、目标连接和独立回传线程。
+ * 上行经统一队列,下行从该 socket 直接回到原采集器入口。
+ * 私有会话仅供服务使用,HTTP 控制器无法访问其可变状态。
+ */
+ private final class Relay {
+ private final String id;
+ private final DatagramSocket socket;
+ private volatile ObjectNode route;
+ private volatile InetAddress sourceAddress;
+ private volatile InetSocketAddress lastSource;
+ // 图片地址协商使用稳定 socket;采集数据和心跳可能来自另一个临时端口。
+ // 只按外层类型区分回程,不在网关解密;身份认证仍由 PXHJ 完成。
+ private volatile InetSocketAddress lastNegotiationSource;
+ private final AtomicLong sent = new AtomicLong(), replies = new AtomicLong();
+
+ Relay(ObjectNode config) throws Exception {
+ id = config.path("endpointId").asText();
+ socket = new DatagramSocket(null);
+ try {
+ // 专属转发端口是 PXHJ 识别原采集器的映射,不随视频任务迁移。
+ socket.bind(new InetSocketAddress(config.path("relayPort").asInt()));
+ socket.setSoTimeout(1000);
+ update(config);
+ daemon("xtrh-relay-" + id, this::reply);
+ } catch (Exception failure) {
+ socket.close();
+ throw failure;
+ }
+ }
+
+ /**
+ * UDP connect 限定回传来源为配置的 PXHJ 地址及端口。
+ * 来源配置变化时清空最近设备地址,避免回执发到旧采集器。
+ * 更新和发送共用对象锁,避免发送期间切换目标 socket 连接。
+ */
+ synchronized void update(ObjectNode config) throws Exception {
+ sourceAddress = InetAddress.getByName(config.path("sourceHost").asText());
+ if (route == null || !route.path("sourceHost").equals(config.path("sourceHost"))
+ || !route.path("sourcePort").equals(config.path("sourcePort"))) {
+ lastSource = null;
+ lastNegotiationSource = null;
+ }
+ socket.disconnect();
+ socket.connect(new InetSocketAddress(config.path("targetHost").asText(),
+ config.path("targetPort").asInt()));
+ route = config;
+ }
+
+ // sourcePort 为零允许采集器临时源端口,来源 IP 仍必须匹配。
+ boolean matches(InetSocketAddress source) {
+ return source.getAddress().equals(sourceAddress)
+ && (route.path("sourcePort").asInt() == 0
+ || route.path("sourcePort").asInt() == source.getPort());
+ }
+
+ // 记住最近真实来源,动态源端口的配置及回执才能准确返回。
+ synchronized void send(byte[] bytes, InetSocketAddress source) throws Exception {
+ // 类型字节只用于选择回程 socket,不能据此新增路由或认定报文可信。
+ // 协商请求不能覆盖既有配置回执的设备端口,普通上行也不能覆盖协商端口。
+ if (frameType(bytes, bytes.length, 0x14)) {
+ lastNegotiationSource = source;
+ } else {
+ lastSource = source;
+ }
+ socket.send(new DatagramPacket(bytes, bytes.length));
+ sent.incrementAndGet();
+ }
+
+ /**
+ * 回传保持原字节,覆盖采集器配置下发及接收回执等双向报文。
+ * 优先使用最近来源,没有来源记录时仅使用明确登记的设备端口。
+ * 各采集器独立等待回复,单路异常不会阻塞其他采集器的回传。
+ */
+ void reply() {
+ byte[] buffer = new byte[MAX_DATAGRAM_BYTES];
+ while (!closed && !socket.isClosed()) {
+ try {
+ DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
+ socket.receive(packet);
+ // 协商回复只能回最近一次协商请求来源,不能误发给心跳临时端口。
+ // 尚未收到协商请求时丢弃该类回传;设备端仍需校验密文、请求ID和租约。
+ boolean negotiation = frameType(buffer, packet.getLength(), 0x15);
+ InetSocketAddress destination = negotiation ? lastNegotiationSource : lastSource;
+ if (!negotiation && destination == null && route.path("sourcePort").asInt() > 0) {
+ destination = new InetSocketAddress(sourceAddress, route.path("sourcePort").asInt());
+ }
+ if (destination != null) {
+ // 配置请求和回执与数据报文同样透明转送,不重新编码协议。
+ // 经固定入口回传,采集器看到的回复端口与其上传目标一致。
+ ingress.send(new DatagramPacket(buffer, packet.getLength(), destination));
+ replies.incrementAndGet();
+ }
+ } catch (SocketTimeoutException ignored) {
+ // 周期醒来检查关闭标记,空闲等待不属于转发错误。
+ } catch (Exception failure) {
+ if (!socket.isClosed()) {
+ errors.incrementAndGet();
+ }
+ }
+ }
+ }
+
+ // 每路分别统计上行与回传,便于定位异常而不暴露报文内容。
+ ObjectNode status() {
+ return object().put("endpointId", id).put("relayPort", socket.getLocalPort())
+ .put("relaySourceHost", socket.getLocalAddress().getHostAddress())
+ .put("forwarded", sent.get()).put("replies", replies.get());
+ }
+
+ void close() {
+ socket.close();
+ }
+ }
+}
diff --git a/src/main/java/com/xtrh/node/config/AsyncMediaConfiguration.java b/src/main/java/com/xtrh/node/config/AsyncMediaConfiguration.java
new file mode 100644
index 0000000..f433a85
--- /dev/null
+++ b/src/main/java/com/xtrh/node/config/AsyncMediaConfiguration.java
@@ -0,0 +1,36 @@
+package com.xtrh.node.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 为视频长连接配置独立的异步输出线程池。
+ * 流输出不占用普通 HTTP 请求线程,状态与管理接口仍可响应。
+ * 播放连接数与任务路数分别管理,一路视频可能有多个观看者。
+ */
+@Configuration
+@Conditional(WorkerCapabilityCondition.class)
+public class AsyncMediaConfiguration implements WebMvcConfigurer {
+ // 不设置等待队列,观看线程达到上限时避免长连接无限积压。
+ @Bean
+ public ThreadPoolTaskExecutor mediaExecutor() {
+ ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+ executor.setCorePoolSize(2);
+ executor.setMaxPoolSize(64);
+ executor.setQueueCapacity(0);
+ executor.setThreadNamePrefix("xtrh-media-");
+ executor.initialize();
+ return executor;
+ }
+
+ // 连接寿命由任务许可与客户端控制,禁用 MVC 默认请求超时。
+ // 实际授权仍在服务层逐帧校验,关闭超时不会延长任务许可。
+ @Override
+ public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
+ configurer.setTaskExecutor(mediaExecutor()).setDefaultTimeout(0);
+ }
+}
diff --git a/src/main/java/com/xtrh/node/config/WorkerCapabilityCondition.java b/src/main/java/com/xtrh/node/config/WorkerCapabilityCondition.java
new file mode 100644
index 0000000..b637571
--- /dev/null
+++ b/src/main/java/com/xtrh/node/config/WorkerCapabilityCondition.java
@@ -0,0 +1,17 @@
+package com.xtrh.node.config;
+
+import com.xtrh.common.config.Settings;
+import org.springframework.context.annotation.Condition;
+import org.springframework.context.annotation.ConditionContext;
+import org.springframework.core.type.AnnotatedTypeMetadata;
+
+/**
+ * 执行能力可同时存在于主节点和子节点,UDP 网关保持独立转发职责。
+ * 服务与控制器共用条件,主节点关闭执行能力时不会留下无服务支持的媒体入口。
+ */
+public final class WorkerCapabilityCondition implements Condition {
+ @Override
+ public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
+ return Settings.workerEnabled(context.getEnvironment());
+ }
+}
diff --git a/src/main/java/com/xtrh/node/controller/NodeImageController.java b/src/main/java/com/xtrh/node/controller/NodeImageController.java
new file mode 100644
index 0000000..31aac32
--- /dev/null
+++ b/src/main/java/com/xtrh/node/controller/NodeImageController.java
@@ -0,0 +1,50 @@
+package com.xtrh.node.controller;
+
+import com.xtrh.node.config.WorkerCapabilityCondition;
+import com.xtrh.node.service.ImageIngressService;
+import com.xtrh.node.service.WorkerService;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.http.CacheControl;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import static com.xtrh.common.util.Jsons.object;
+
+/**
+ * 节点原图私有查询入口,供 PXHJ 后台取图,不向浏览器分发集群凭证。
+ * ServiceSecurity 对请求和包括 JPEG 在内的原始响应字节执行 HMAC 校验。
+ * 没有原图、分配过期、版本迁移或帧已淘汰统一返回签名 JSON 404。
+ */
+@RestController
+@Conditional(WorkerCapabilityCondition.class)
+public class NodeImageController {
+ private final WorkerService worker;
+
+ public NodeImageController(WorkerService worker) { this.worker = worker; }
+
+ // 控制器不保存图片状态,任务租约、帧缓存和撤销均交由服务层统一判断。
+
+ // 元数据只包含业务来源和图片尺寸;设备密钥、服务凭证和许可均不返回。
+ @GetMapping("/node/images/{taskId}/{version}/latest")
+ public ResponseEntity> latest(@PathVariable String taskId, @PathVariable long version) {
+ ImageIngressService.Frame frame = worker.originalFrame(taskId, version, null);
+ return frame == null ? missing() : ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(frame.metadata());
+ }
+
+ // 帧号锁定最近缓存中的一张图,HTTP 输出不重编码,保留设备 JPEG 原字节。
+ @GetMapping("/node/images/{taskId}/{version}/frames/{frameId}")
+ public ResponseEntity> frame(@PathVariable String taskId, @PathVariable long version, @PathVariable String frameId) {
+ ImageIngressService.Frame frame = worker.originalFrame(taskId, version, frameId);
+ return frame == null ? missing() : ResponseEntity.ok().cacheControl(CacheControl.noStore())
+ .contentType(MediaType.IMAGE_JPEG).body(frame.jpeg());
+ }
+
+ // 固定错误不暴露任务密钥,也不允许浏览器缓存暂时无图的结果。
+ private ResponseEntity> missing() {
+ return ResponseEntity.status(404).cacheControl(CacheControl.noStore())
+ .body(object().put("message", "Original image is not available"));
+ }
+}
diff --git a/src/main/java/com/xtrh/node/controller/WorkerController.java b/src/main/java/com/xtrh/node/controller/WorkerController.java
new file mode 100644
index 0000000..335a3de
--- /dev/null
+++ b/src/main/java/com/xtrh/node/controller/WorkerController.java
@@ -0,0 +1,70 @@
+package com.xtrh.node.controller;
+
+import com.xtrh.node.service.WorkerService;
+import com.xtrh.node.config.WorkerCapabilityCondition;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.http.CacheControl;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 节点 HTTP 入口只负责查询与流输出,业务许可由服务层校验。
+ * 播放地址包含任务号和版本,迁移时由总控向 PXHJ 回传新地址。
+ * 主节点兼任执行端时在同一端口提供媒体入口,UDP 网关不承担视频任务。
+ */
+@RestController
+@Conditional(WorkerCapabilityCondition.class)
+public class WorkerController {
+ private final WorkerService worker;
+
+ public WorkerController(WorkerService worker) {
+ this.worker = worker;
+ }
+
+ // 状态快照由服务生成,控制层不读取或修改内部任务集合。
+ @GetMapping("/worker/status")
+ public Object status() {
+ return worker.status();
+ }
+
+ /**
+ * 输出浏览器可直接读取的 MJPEG 流,每个分段是一张完整 JPEG。
+ * 先检查授权再返回成功响应,避免无效地址建立空的长连接。
+ * 不缓存实时画面,防止浏览器复用结束或迁移前的内容。
+ */
+ @GetMapping("/media/{taskId}/{version}/stream")
+ public ResponseEntity stream(
+ @PathVariable String taskId, @PathVariable long version, @RequestParam String key) {
+ worker.frame(taskId, version, key);
+ StreamingResponseBody body = output -> {
+ try {
+ while (true) {
+ // 每帧重新取得当前状态,准备转运行后不再引用旧任务对象。
+ byte[] frame = worker.frame(taskId, version, key);
+ if (frame != null) {
+ String header = "--frame\r\nContent-Type: image/jpeg\r\nContent-Length: "
+ + frame.length + "\r\n\r\n";
+ output.write(header.getBytes(StandardCharsets.US_ASCII));
+ output.write(frame);
+ output.write("\r\n".getBytes(StandardCharsets.US_ASCII));
+ output.flush();
+ }
+ Thread.sleep(250);
+ }
+ } catch (InterruptedException interrupted) {
+ // 容器关闭线程时保留中断标记,流循环随之结束。
+ Thread.currentThread().interrupt();
+ } catch (IllegalArgumentException inactiveTask) {
+ // 迁移、结束或许可到期后停止旧流,不能继续发送缓存画面。
+ }
+ };
+ return ResponseEntity.ok().cacheControl(CacheControl.noStore())
+ .header("Content-Type", "multipart/x-mixed-replace; boundary=frame").body(body);
+ }
+}
diff --git a/src/main/java/com/xtrh/node/protocol/ImageWireDecoder.java b/src/main/java/com/xtrh/node/protocol/ImageWireDecoder.java
new file mode 100644
index 0000000..28176e1
--- /dev/null
+++ b/src/main/java/com/xtrh/node/protocol/ImageWireDecoder.java
@@ -0,0 +1,76 @@
+package com.xtrh.node.protocol;
+
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.MessageDigest;
+import java.security.Security;
+import java.util.Arrays;
+import java.util.zip.CRC32;
+
+/**
+ * 按既有 PXHJ-FND/1 图片协议解码,仅允许消息类型 0x12。
+ * 先校验边界、时间、CRC 和 HMAC,再解密 JPG1 分片;协议不是自定义演示格式。
+ * 设备密钥由有效私有任务许可提供,本类不读取配置文件或记录明文密钥。
+ */
+public final class ImageWireDecoder {
+ static {
+ if (Security.getProvider("BC") == null) Security.addProvider(new BouncyCastleProvider());
+ }
+
+ private ImageWireDecoder() {}
+
+ // 头部 collectorId 是未认证线索,只可用于缩小候选许可,不能据此接受报文。
+ public static int collectorId(byte[] frame) {
+ return frame.length > 9 ? Byte.toUnsignedInt(frame[9]) : -1;
+ }
+
+ /**
+ * 固定头长、密文偏移与 MAC 范围和 PXHJ WireCodec 保持一致。
+ * 不扩展原协议的 1024 字节明文限制,超长 UDP 即使被截断也会拒绝。
+ */
+ public static byte[] decode(byte[] frame, byte[] sm4, byte[] hmac, int collectorId, long now) throws Exception {
+ require(frame.length >= 109 && frame.length <= 1133);
+ ByteBuffer input = ByteBuffer.wrap(frame).order(ByteOrder.BIG_ENDIAN);
+ require(input.getInt() == 0x5AA5FAFB && input.getInt() == frame.length);
+ require(Byte.toUnsignedInt(input.get()) == 0x12);
+ require(Byte.toUnsignedInt(input.get()) == collectorId);
+ input.get();
+ long time = input.getLong();
+ input.getLong();
+ // 使用上下界而不是 abs 差值,异常 Long 值不能通过溢出绕过时间窗口。
+ require(time >= now - 60_000 && time <= now + 60_000);
+ require(input.get() == 1 && input.get() == 1 && input.getInt() == 1);
+ byte[] iv = new byte[16];
+ input.get(iv);
+ int length = input.getInt();
+ require(length > 0 && length % 16 == 0 && length + 93 == frame.length);
+ int macStart = 53 + length;
+ // CRC 覆盖 MAC 但不含 CRC 自身和帧尾,保持现有设备发送端的精确字节范围。
+ CRC32 crc = new CRC32();
+ crc.update(frame, 0, macStart + 32);
+ require((int) crc.getValue() == ByteBuffer.wrap(frame, macStart + 32, 4).getInt());
+ require(ByteBuffer.wrap(frame, frame.length - 4, 4).getInt() == 0xA55AFCFD);
+
+ // 认证覆盖长度后的协议头、IV 和密文;CRC 只检错,不能替代身份认证。
+ Mac authentication = Mac.getInstance("HMACSM3", "BC");
+ authentication.init(new SecretKeySpec(hmac, "HMACSM3"));
+ authentication.update(frame, 4, macStart - 4);
+ require(MessageDigest.isEqual(authentication.doFinal(), Arrays.copyOfRange(frame, macStart, macStart + 32)));
+ Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", "BC");
+ cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(sm4, "SM4"), new IvParameterSpec(iv));
+ byte[] plain = cipher.doFinal(Arrays.copyOfRange(frame, 53, macStart));
+ require(plain.length <= 1024);
+ return plain;
+ }
+
+ // 固定错误消息不含输入报文、密钥或失败的认证摘要。
+ private static void require(boolean valid) {
+ if (!valid) throw new IllegalArgumentException("Invalid authenticated image packet");
+ }
+}
diff --git a/src/main/java/com/xtrh/node/service/ImageIngressService.java b/src/main/java/com/xtrh/node/service/ImageIngressService.java
new file mode 100644
index 0000000..26c4122
--- /dev/null
+++ b/src/main/java/com/xtrh/node/service/ImageIngressService.java
@@ -0,0 +1,488 @@
+package com.xtrh.node.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.common.config.Settings;
+import com.xtrh.node.config.WorkerCapabilityCondition;
+import com.xtrh.node.protocol.ImageWireDecoder;
+import jakarta.annotation.PreDestroy;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.core.env.Environment;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.io.ByteArrayInputStream;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.MessageDigest;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.BitSet;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.HexFormat;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.LongSupplier;
+import java.util.zip.CRC32;
+
+import static com.xtrh.common.util.Jsons.object;
+
+/**
+ * 节点直接接收设备原图,总控和网关只负责控制,不中转图片字节。
+ * 私有许可只在内存中保存,按任务版本和单调时钟到期,不写入 H2 或公共快照。
+ * 接收队列、分片重组和近期帧都有独立上限;无首帧时不生成替代演示图。
+ */
+@Service
+@Conditional(WorkerCapabilityCondition.class)
+public class ImageIngressService implements AutoCloseable {
+ // 一个 FND 帧最多承载 1024 字节明文,其中 36 字节属于 JPG1 分片头。
+ // 重组最多 32 帧、64MiB;完整帧缓存另有 64MiB 预算,不随设备持续上传而增长。
+ // 图片解码串行执行,实际尺寸也受限制,压缩 JPEG 不能扩大到任意像素数。
+ public static final int CHUNK_SIZE = 988, MAX_IMAGE_BYTES = 2 * 1024 * 1024;
+ private static final long ASSEMBLY_TTL = TimeUnit.SECONDS.toNanos(5);
+ private static final long MAX_BUFFER_BYTES = 64L * 1024 * 1024;
+ private final Settings settings;
+ private final Integer configuredPort;
+ private final LongSupplier clock, nanos;
+ // 接收线程只投递受限报文,耗时认证、重组和 ImageIO 解码在单个处理线程执行。
+ // 队列最多约 2.2MiB 密文;过载时计数丢弃,让设备后续完整帧恢复画面。
+ private final ArrayBlockingQueue queue = new ArrayBlockingQueue<>(2048);
+ private final Map permits = new HashMap<>();
+ private final Map accessErrors = new HashMap<>();
+ private final Map pending = new HashMap<>();
+ // 最近帧按任务版本隔离;同来源的多项任务可以引用同一份不可变 JPEG 字节。
+ // 元数据查询后还有两帧容错空间,防止下一次上传立刻淘汰正在取图的帧号。
+ private final Map> recent = new HashMap<>();
+ private final LinkedHashMap completed = new LinkedHashMap<>();
+ private final AtomicLong accepted = new AtomicLong(), rejected = new AtomicLong(), dropped = new AtomicLong();
+ private volatile DatagramSocket socket;
+ private volatile boolean closed;
+ private volatile String error = "";
+ private long retryAfter, pendingBytes;
+
+ // 来源地址由 UDP socket 提供,不能从设备载荷中的自报字段推导。
+ // Permit 包含集群私有计划给出的设备密钥,不能直接序列化为对外状态。
+ // Assembly 的 group 含来源与密钥摘要,避免密钥轮换后拼接旧许可的在途分片。
+ private record Packet(byte[] bytes, InetSocketAddress source) {}
+ private record Permit(String task, String endpoint, InetAddress source, int collector,
+ byte[] sm4, byte[] hmac, String group, long deadline) {}
+ private record Assembly(String group, long frame, long time, int size, int count, int crc,
+ int width, int height, long created, byte[] data, BitSet seen) {}
+
+ /** 原图不可变视图;公开元数据不包含设备认证材料或网络路由许可。 */
+ public record Frame(String frameId, String endpointId, long sourceTime, long receivedTime,
+ int width, int height, byte[] jpeg, long receivedNanos) {
+ public ObjectNode metadata() {
+ return object().put("frameId", frameId).put("endpointId", endpointId)
+ .put("sourceTime", sourceTime).put("receivedTime", receivedTime)
+ .put("width", width).put("height", height).put("bytes", jpeg.length);
+ }
+ }
+
+ @Autowired
+ public ImageIngressService(Settings settings, Environment environment) {
+ this(settings, environment.getProperty("fusion.image-port", Integer.class), System::currentTimeMillis, System::nanoTime);
+ }
+
+ public ImageIngressService(Settings settings, int imagePort) {
+ this(settings, imagePort, System::currentTimeMillis, System::nanoTime);
+ }
+
+ ImageIngressService(Settings settings, Integer port, LongSupplier clock, LongSupplier nanos) {
+ // 注入两种时钟只为确定性测试;生产授权始终以单调时钟判断到期。
+ // 墙上时间负责设备拍摄时间窗,不能用于延长任务或在途分片的寿命。
+ this.settings = settings;
+ this.configuredPort = port;
+ this.clock = clock;
+ this.nanos = nanos;
+ }
+
+ /**
+ * 默认使用真实 HTTP 端口+1000;等待 HTTP 就绪,不能把配置中的随机端口 0 当实际端口。
+ * 显式图片端口 0 由操作系统分配,便于隔离测试,也会登记实际绑定结果。
+ */
+ public synchronized boolean ensureStarted() {
+ if (closed || socket != null) return socket != null && !socket.isClosed();
+ if (configuredPort == null && !settings.httpReady()) return false;
+ if (clock.getAsLong() < retryAfter) return false;
+ int port = configuredPort == null ? settings.port + 1000 : configuredPort;
+ DatagramSocket opened = null;
+ try {
+ if (port < 0 || port > 65535) throw new IllegalArgumentException();
+ // 不启用复用地址,部署端口冲突应明确失败,不能让多个节点抢收同一批分片。
+ // 绑定地址遵守该角色原有配置,登记时另行选择设备可达的 IPv4 网卡地址。
+ opened = new DatagramSocket(null);
+ opened.setReceiveBufferSize(4 * 1024 * 1024);
+ opened.bind(new InetSocketAddress(settings.bindHost, port));
+ socket = opened;
+ daemon("xtrh-original-receive", this::receive);
+ daemon("xtrh-original-assemble", this::process);
+ error = "";
+ return true;
+ } catch (Exception failure) {
+ // 失败保持未就绪并有限重试;错误信息不带异常报文、设备密钥或明文图片。
+ // 未成功绑定的 socket 及时释放,启动失败不会泄漏后续可重试的系统资源。
+ if (opened != null) opened.close();
+ error = "原图监听未就绪,请检查图片端口和绑定地址";
+ retryAfter = clock.getAsLong() + 1500;
+ return false;
+ }
+ }
+
+ /**
+ * 接收与组装只在成功绑定后启动,重试监听不会重复建立工作线程。
+ * 使用守护线程并配合 closed/socket.close,让服务退出不等待设备关闭连接。
+ */
+ private static void daemon(String name, Runnable body) {
+ Thread thread = new Thread(body, name);
+ thread.setDaemon(true);
+ thread.start();
+ }
+
+ public int inputPort() {
+ // 登记的是操作系统实际分配端口,显式配置 0 也不能上报为未知目的端口。
+ // 服务关闭后返回 0,后续心跳不能继续向总控承诺已失效的接入能力。
+ DatagramSocket current = socket;
+ return current == null || current.isClosed() ? 0 : current.getLocalPort();
+ }
+
+ // 多余一字节用于拒绝超长包;队列满时计数丢弃,不能无限缓存密文。
+ private void receive() {
+ while (!closed) {
+ try {
+ DatagramPacket packet = new DatagramPacket(new byte[1134], 1134);
+ socket.receive(packet);
+ // 复制准确报文长度,不能把复用缓冲区剩余字节带入 CRC 和 HMAC 校验。
+ Packet work = new Packet(Arrays.copyOf(packet.getData(), packet.getLength()),
+ (InetSocketAddress) packet.getSocketAddress());
+ if (!queue.offer(work)) dropped.incrementAndGet();
+ } catch (Exception failure) {
+ if (!closed) rejected.incrementAndGet();
+ }
+ }
+ }
+
+ // 单个组装线程使图片解码并发和临时内存有界;失败不终止其他通道接收。
+ private void process() {
+ // 接收线程不等待 JPEG 解码;独立队列隔离慢图片,避免 UDP socket 长时间无人读取。
+ // 停机关闭 socket 后接收线程退出;处理线程最多等待一次受限 poll 即结束。
+ while (!closed) {
+ try {
+ Packet packet = queue.poll(1, TimeUnit.SECONDS);
+ if (packet != null) accept(packet);
+ } catch (InterruptedException stopped) {
+ Thread.currentThread().interrupt();
+ return;
+ }
+ }
+ }
+
+ /**
+ * 完整计划替换许可;未再列出的版本立即删除帧和在途分片。
+ * 同版本状态由准备变为运行时保留原图,避免元数据查询后 JPEG 被状态切换清除。
+ */
+ public synchronized void applyPlans(JsonNode entries, long deadline) {
+ // 总控发送完整而非增量计划;任何未列出的许可都应撤销,不能靠旧缓存继续授权。
+ // 新表构建完成后一次替换,状态查询、组装和许可回收共享此同步边界。
+ Map next = new HashMap<>();
+ accessErrors.clear();
+ for (JsonNode entry : entries) {
+ if (!entry.path("mediaKind").asText().equals("ORIGINAL_IMAGE")) continue;
+ String task = taskKey(entry.path("taskId").asText(), entry.path("assignment").path("version").asLong());
+ try {
+ if (next.size() >= 128) throw new IllegalArgumentException();
+ JsonNode access = entry.path("imageAccess");
+ String endpoint = access.path("endpointId").asText();
+ if (endpoint.isBlank() || endpoint.length() > 200) throw new IllegalArgumentException();
+ if (entry.path("taskId").asText().isBlank() || !entry.path("assignment").path("version").isIntegralNumber()
+ || !entry.path("assignment").path("version").canConvertToLong() || entry.path("assignment").path("version").asLong() < 1) throw new IllegalArgumentException();
+ InetAddress source = numericIpv4(access.path("sourceHost").asText());
+ // 编号严格要求整数和单字节范围,禁止大整数截断后命中另一个采集器。
+ // 同一条私有许可还需与公共来源字段一致,防止内部上下文混配到其他装备。
+ JsonNode collectorValue = access.path("collectorId");
+ if (!collectorValue.isIntegralNumber() || !collectorValue.canConvertToInt()) throw new IllegalArgumentException();
+ int collector = collectorValue.asInt();
+ byte[] sm4 = Base64.getDecoder().decode(access.path("sm4Key").asText());
+ byte[] hmac = Base64.getDecoder().decode(access.path("hmacKey").asText());
+ // HMAC 密钥长度与既有采集端兼容为 32..64 字节,SM4 固定 128 位。
+ // Base64 只是私有 JSON 传输编码,不能被当作脱敏或公开发布依据。
+ if (sm4.length != 16 || hmac.length < 32 || hmac.length > 64 || collector < 1 || collector > 255) throw new IllegalArgumentException();
+ if (entry.hasNonNull("sourceEndpointId") && !endpoint.equals(entry.path("sourceEndpointId").asText())) throw new IllegalArgumentException();
+ if (entry.has("collectorId") && (!entry.path("collectorId").isIntegralNumber()
+ || !entry.path("collectorId").canConvertToInt() || collector != entry.path("collectorId").asInt())) throw new IllegalArgumentException();
+ if (entry.hasNonNull("sourceHost") && !source.equals(numericIpv4(entry.path("sourceHost").asText()))) throw new IllegalArgumentException();
+ MessageDigest digest = MessageDigest.getInstance("SHA-256");
+ // 分组摘要只作内部缓存键,不作为网络认证或向外返回的设备身份。
+ // 同 endpoint/IP/collector/密钥的多个任务共享重组,仍分别检查自己的租约。
+ digest.update(sm4);
+ String group = endpoint + "/" + source.getHostAddress() + "/" + collector + "/"
+ + HexFormat.of().formatHex(digest.digest(hmac));
+ Permit permit = new Permit(task, endpoint, source, collector, sm4, hmac, group, deadline);
+ // 同版本的授权来源变化也清除旧帧,不能把前一台设备的缓存交给新的连接。
+ Permit old = permits.get(task);
+ if (old != null && !old.group.equals(group)) recent.remove(task);
+ next.put(task, permit);
+ } catch (Exception invalid) {
+ // 只暴露固定原因,Base64 解码器或异常对象中的密钥不进入日志、状态或快照。
+ accessErrors.put(task, "原图接入许可无效");
+ }
+ }
+ permits.clear();
+ permits.putAll(next);
+ cleanup();
+ }
+
+ public static String taskKey(String id, long version) { return id + "/" + version; }
+
+ // 接入许可只接受字面 IPv4,空字符串和域名不能借 DNS 解析改变授权来源。
+ private static InetAddress numericIpv4(String host) throws Exception {
+ // 手工核对四个十进制段,拒绝 DNS、缩写地址、八进制和超出单字节范围的值。
+ // 回环地址可用于隔离测试;泛地址、组播和广播不属于可授权的设备来源。
+ String[] parts = host.split("\\.", -1);
+ require(parts.length == 4);
+ byte[] address = new byte[4];
+ for (int index = 0; index < 4; index++) {
+ require(parts[index].matches("0|[1-9][0-9]{0,2}"));
+ int value = Integer.parseInt(parts[index]);
+ require(value <= 255);
+ address[index] = (byte) value;
+ }
+ InetAddress parsed = InetAddress.getByAddress(address);
+ require(!parsed.isAnyLocalAddress() && !parsed.isMulticastAddress() && !host.equals("255.255.255.255"));
+ return parsed;
+ }
+
+ /** 端口与许可先就绪,不等首帧,以免设备等待地址、节点等待首帧形成协商循环。 */
+ public synchronized boolean ready(String task) {
+ cleanup();
+ return inputPort() > 0 && permits.containsKey(task);
+ }
+
+ public synchronized String accessError(String task) { return accessErrors.getOrDefault(task, ""); }
+
+ /** 新鲜度同时检查拍摄时间和单调接收时间,延迟或重放旧图不能完成迁移接管。 */
+ public synchronized boolean imageReady(String task) {
+ // 新图只在来源时间和接收时间都新鲜时可完成迁移,旧图不能靠再次查询恢复活性。
+ // 最近帧缓存并不等同于视频在线,业务界面应使用 lastFrameTime 判断拍摄时刻。
+ Frame frame = latest(task);
+ return frame != null && clock.getAsLong() - frame.sourceTime <= 5000
+ && clock.getAsLong() >= frame.sourceTime - 5000
+ && nanos.getAsLong() - frame.receivedNanos <= ASSEMBLY_TTL;
+ }
+
+ public synchronized Frame latest(String task) {
+ // 最新元数据与具体帧内容分开取,但始终在每次读取之前回收失效许可。
+ // 许可有效时保留近期原图;imageReady 另行表达是否五秒内有新鲜画面。
+ cleanup();
+ List frames = recent.get(task);
+ return frames == null || frames.isEmpty() ? null : frames.get(0);
+ }
+
+ public synchronized Frame frame(String task, String id) {
+ // 帧号只在所属任务的近期缓存中查找,不能跨任务或分配版本借用其他装备原图。
+ cleanup();
+ return recent.getOrDefault(task, List.of()).stream().filter(frame -> frame.frameId.equals(id)).findFirst().orElse(null);
+ }
+
+ // 许可到期和分片超时用单调时钟;校时不延长执行或组装寿命。
+ private void cleanup() {
+ long now = nanos.getAsLong();
+ permits.values().removeIf(permit -> now >= permit.deadline);
+ recent.keySet().removeIf(task -> !permits.containsKey(task));
+ Set groups = new HashSet<>();
+ // 仅当同来源分组仍有有效任务时保留在途分片,撤销一路不会误停共享的其他任务。
+ // 任务全部撤销或超时后立即归还预算,不需要等待设备再发包才释放内存。
+ permits.values().forEach(permit -> groups.add(permit.group));
+ var iterator = pending.values().iterator();
+ while (iterator.hasNext()) {
+ Assembly assembly = iterator.next();
+ if (!groups.contains(assembly.group) || now - assembly.created > ASSEMBLY_TTL) {
+ pendingBytes -= assembly.size;
+ iterator.remove();
+ }
+ }
+ completed.entrySet().removeIf(entry -> now - entry.getValue() > ASSEMBLY_TTL);
+ }
+
+ private void accept(Packet packet) {
+ try {
+ // 来源 IP 与 collectorId 先匹配许可,再执行密钥认证,未授权来源不尝试解密。
+ List matches;
+ synchronized (this) {
+ cleanup();
+ matches = permits.values().stream().filter(permit -> permit.source.equals(packet.source.getAddress())
+ && permit.collector == ImageWireDecoder.collectorId(packet.bytes)).toList();
+ }
+ Map groups = new LinkedHashMap<>();
+ // 密钥轮换期间允许尝试多个明确授权的候选;同一分组只认证一次。
+ // 只有唯一来源成功认证才组装,不能用未知来源的 packet header 直接发布图片。
+ matches.forEach(permit -> groups.putIfAbsent(permit.group, permit));
+ Permit authenticated = null;
+ byte[] plain = null;
+ for (Permit permit : groups.values()) {
+ // 逐个候选先验 MAC 再解密;报文 CRC 正确也不足以证明持有设备密钥。
+ try {
+ byte[] candidate = ImageWireDecoder.decode(packet.bytes, permit.sm4, permit.hmac, permit.collector, clock.getAsLong());
+ if (authenticated != null) throw new IllegalStateException("Ambiguous image source");
+ authenticated = permit;
+ plain = candidate;
+ } catch (IllegalStateException ambiguity) { throw ambiguity; }
+ catch (Exception wrongCredential) { /* 同来源密钥轮换时可尝试另一个已授权候选。 */ }
+ }
+ if (authenticated == null) throw new IllegalArgumentException();
+ assemble(authenticated, plain);
+ accepted.incrementAndGet();
+ } catch (Exception invalid) {
+ rejected.incrementAndGet();
+ }
+ }
+
+ /**
+ * JPG1 头为36字节,988字节分片;严格核对同帧参数和重复片内容。
+ * 完整 CRC、JPEG 标记、实际解码尺寸均通过后才发布,缺片永远不会成为可见图片。
+ */
+ private synchronized void assemble(Permit authenticated, byte[] plain) throws Exception {
+ cleanup();
+ // 包从队列取出到完成认证之间可能已撤销任务,组装前再次确认来源许可仍存在。
+ if (permits.values().stream().noneMatch(permit -> permit.group.equals(authenticated.group))) throw new IllegalArgumentException();
+ require(plain.length > 36 && plain.length <= 1024);
+ ByteBuffer input = ByteBuffer.wrap(plain).order(ByteOrder.BIG_ENDIAN);
+ require(input.getInt() == 0x4A504731);
+ long wireFrame = input.getLong(), time = input.getLong();
+ int size = input.getInt(), index = Short.toUnsignedInt(input.getShort()), count = Short.toUnsignedInt(input.getShort());
+ int crc = input.getInt(), width = Short.toUnsignedInt(input.getShort()), height = Short.toUnsignedInt(input.getShort());
+ long now = clock.getAsLong();
+ require(time >= now - 60_000 && time <= now + 60_000);
+ require(size > 3 && size <= MAX_IMAGE_BYTES && count == (size + CHUNK_SIZE - 1) / CHUNK_SIZE && index < count);
+ require(width > 0 && width <= 4096 && height > 0 && height <= 4096);
+ // 每片实际长度必须与图片大小推导结果一致;缺片、尾片越界和额外字节均拒绝。
+ // 不通过扩容补齐异常分片,固定一次分配的 buffer 使每帧预算可准确核算。
+ int offset = index * CHUNK_SIZE, bytes = Math.min(CHUNK_SIZE, size - offset);
+ require(input.remaining() == bytes);
+ String key = authenticated.group + "/" + Long.toUnsignedString(wireFrame) + "/" + time;
+ if (completed.containsKey(key)) return;
+ // 已发布帧的短期重复上传只作幂等确认,不生成新帧号或刷新图片新鲜度。
+ Assembly assembly = pending.get(key);
+ if (assembly == null) {
+ require(pending.size() < 32 && pendingBytes + size <= MAX_BUFFER_BYTES);
+ assembly = new Assembly(authenticated.group, wireFrame, time, size, count, crc, width, height,
+ nanos.getAsLong(), new byte[size], new BitSet(count));
+ pending.put(key, assembly);
+ pendingBytes += size;
+ }
+ require(assembly.time == time && assembly.size == size && assembly.count == count
+ && assembly.crc == crc && assembly.width == width && assembly.height == height);
+ if (assembly.seen.get(index)) {
+ // 正常 UDP 重复包允许;同索引携带不同内容属于冲突,不能覆盖已接收的部分图。
+ for (int i = 0; i < bytes; i++) require(assembly.data[offset + i] == plain[36 + i]);
+ return;
+ }
+ input.get(assembly.data, offset, bytes);
+ assembly.seen.set(index);
+ if (assembly.seen.cardinality() != count) return;
+ pending.remove(key);
+ pendingBytes -= size;
+ // 接收所有片只是必要条件,完整 JPEG 的 CRC 与图片格式仍需独立验证。
+ // 丢弃完整但损坏的图片之后释放其重组预算,后续帧继续正常接入。
+ CRC32 checksum = new CRC32();
+ checksum.update(assembly.data);
+ require((int) checksum.getValue() == crc);
+ require(Byte.toUnsignedInt(assembly.data[0]) == 255 && Byte.toUnsignedInt(assembly.data[1]) == 216
+ && Byte.toUnsignedInt(assembly.data[size - 2]) == 255 && Byte.toUnsignedInt(assembly.data[size - 1]) == 217);
+ validateJpeg(assembly.data, width, height);
+
+ // 同来源多任务共享只读帧对象;任务版本仍各有许可,撤销其中一路不影响其他有效任务。
+ Frame frame = new Frame(java.util.UUID.randomUUID().toString(), authenticated.endpoint, time, now,
+ width, height, assembly.data, nanos.getAsLong());
+ for (Permit permit : permits.values()) {
+ if (!permit.group.equals(authenticated.group)) continue;
+ List frames = new ArrayList<>(recent.getOrDefault(permit.task, List.of()));
+ frames.add(frame);
+ frames.sort(Comparator.comparingLong(Frame::sourceTime).thenComparingLong(Frame::receivedTime).reversed());
+ if (frames.size() > 3) frames = new ArrayList<>(frames.subList(0, 3));
+ recent.put(permit.task, frames);
+ }
+ completed.put(key, nanos.getAsLong());
+ // 去重索引本身也受数量限制,长期上传不会累积无限历史帧号。
+ while (completed.size() > 1024) completed.remove(completed.keySet().iterator().next());
+ trimFrames();
+ }
+
+ // 先读取实际尺寸再解码,不能相信分片头伪造的小尺寸放任解压大图。
+ private static void validateJpeg(byte[] data, int width, int height) throws Exception {
+ try (var input = ImageIO.createImageInputStream(new ByteArrayInputStream(data))) {
+ // ImageReader 先读取 JPEG 内真实尺寸,再作完整解码,头部谎报小图不能绕过像素限制。
+ // 正确标记和 CRC 不能证明压缩数据可解码,因此仍需要解码确认图片结构完整。
+ var readers = ImageIO.getImageReaders(input);
+ require(readers.hasNext());
+ var reader = readers.next();
+ try {
+ reader.setInput(input);
+ require(reader.getFormatName().equalsIgnoreCase("JPEG") && reader.getWidth(0) == width && reader.getHeight(0) == height);
+ require(reader.read(0) != null);
+ } finally { reader.dispose(); }
+ }
+ }
+
+ // 每任务保留最近3帧供元数据/取图竞争;总缓存预算不足时淘汰最旧引用并返回明确404。
+ private void trimFrames() {
+ // 预算按缓存引用计数,共享 JPEG 也保守计入;实际保留内存不会大于计算结果。
+ // 容量不足允许提前淘汰旧图,HTTP 接口用 404 明确表达帧已不在缓存。
+ long bytes = recent.values().stream().flatMap(List::stream).mapToLong(frame -> frame.jpeg.length).sum();
+ while (bytes > MAX_BUFFER_BYTES) {
+ // 淘汰按单调接收顺序选择,系统校时不会颠倒缓存内存的释放顺序。
+ String owner = null;
+ Frame oldest = null;
+ for (var entry : recent.entrySet()) for (Frame frame : entry.getValue()) {
+ if (oldest == null || frame.receivedNanos < oldest.receivedNanos) { owner = entry.getKey(); oldest = frame; }
+ }
+ if (oldest == null) return;
+ recent.get(owner).remove(oldest);
+ bytes -= oldest.jpeg.length;
+ }
+ }
+
+ private static void require(boolean valid) { if (!valid) throw new IllegalArgumentException("Invalid original image fragment"); }
+
+ @Scheduled(fixedDelay = 250)
+ // 独立定时清理保证设备静默时也释放到期任务,不依赖下一次 UDP 包触发回收。
+ public synchronized void tick() { ensureStarted(); cleanup(); }
+
+ public synchronized ObjectNode status() {
+ // 仅输出计数和接入能力,permit、source keys 和私有计划不进入工作节点状态。
+ cleanup();
+ return object().put("imageInputPort", inputPort()).put("running", inputPort() > 0).put("error", error)
+ .put("accepted", accepted.get()).put("rejected", rejected.get()).put("queueDrops", dropped.get())
+ .put("pending", pending.size()).put("bufferedBytes", pendingBytes).put("authorizedTasks", permits.size());
+ }
+
+ @PreDestroy
+ @Override
+ public synchronized void close() {
+ // 停机立即撤销所有接入授权与缓存,不持久化恢复设备密钥或已结束任务的原图。
+ // close 可重复调用,测试和 Spring 容器都可以可靠释放动态 UDP 端口。
+ closed = true;
+ if (socket != null) socket.close();
+ queue.clear();
+ permits.clear();
+ accessErrors.clear();
+ pending.clear();
+ recent.clear();
+ completed.clear();
+ pendingBytes = 0;
+ }
+}
diff --git a/src/main/java/com/xtrh/node/service/WorkerService.java b/src/main/java/com/xtrh/node/service/WorkerService.java
new file mode 100644
index 0000000..97a69b3
--- /dev/null
+++ b/src/main/java/com/xtrh/node/service/WorkerService.java
@@ -0,0 +1,335 @@
+package com.xtrh.node.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.common.config.Settings;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.discovery.service.CoordinatorLocator;
+import com.xtrh.discovery.util.NodeAddress;
+import com.xtrh.node.config.WorkerCapabilityCondition;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Conditional;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Service;
+
+import javax.imageio.ImageIO;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.GradientPaint;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.nio.charset.StandardCharsets;
+import java.net.InetAddress;
+import java.net.Inet4Address;
+import java.security.MessageDigest;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.concurrent.TimeUnit;
+
+import static com.xtrh.common.util.Jsons.object;
+
+/**
+ * 融合节点的业务服务:自动注册、领取许可、准备画面并报告就绪。
+ * 节点只执行总控分配的任务,不自行决定试验轮次或占用路数。
+ * 演示任务生成验证画面;原图任务只提供设备上传的真实 JPEG。
+ */
+@Service
+@Conditional(WorkerCapabilityCondition.class)
+public class WorkerService {
+ /**
+ * 一个分配版本的本地运行状态,集中保存在服务内部。
+ * 同一任务迁移后使用新版本,旧播放地址不能访问新任务。
+ * 画面与许可由定时处理更新,volatile 保证播放线程及时可见。
+ */
+ public static final class MediaTask {
+ final String taskId, key, name, trialName, desired, mediaKind;
+ final long version;
+ volatile byte[] jpeg;
+ volatile long deadlineNanos;
+
+ MediaTask(JsonNode plan) {
+ taskId = plan.path("taskId").asText();
+ version = plan.path("assignment").path("version").asLong();
+ key = plan.path("assignment").path("mediaKey").asText();
+ name = plan.path("name").asText();
+ trialName = plan.path("trialName").asText();
+ desired = plan.path("desired").asText();
+ mediaKind = plan.path("mediaKind").asText("DEMO_VIDEO");
+ }
+
+ // 使用单调时钟,电脑调整系统时间不会延长任务执行许可。
+ boolean valid() {
+ return System.nanoTime() < deadlineNanos;
+ }
+ }
+
+ private final Settings settings;
+ private final CoordinatorLocator locator;
+ private final PeerClient peer;
+ private final ImageIngressService images;
+ // 心跳、画面生成和 HTTP 播放会同时访问任务,键包含任务号及分配版本。
+ private final ConcurrentMap tasks = new ConcurrentHashMap<>();
+ private volatile String session = "", connection = "WAITING";
+ // 发现失败后的重试间隔只影响联网操作,不改变已经领取的许可期限。
+ private long retryAfter;
+ private int registeredImagePort = -1;
+
+ public WorkerService(Settings settings, CoordinatorLocator locator, PeerClient peer) {
+ this(settings, locator, peer, null);
+ }
+
+ @Autowired
+ public WorkerService(Settings settings, CoordinatorLocator locator, PeerClient peer, ImageIngressService images) {
+ // 正式服务注入原图接入端;兼容构造保留现有演示测试,避免额外启动 UDP。
+ // 演示和原图分别有画面来源,不能因为接入失败向 PXHJ 返回伪造的替代图片。
+ this.settings = settings;
+ this.locator = locator;
+ this.peer = peer;
+ this.images = images;
+ }
+
+ /**
+ * 先移除过期任务,再发现总控、注册并领取最新分配。
+ * 原图先报告接入就绪供设备协商地址,再单独报告新鲜首帧决定迁移接管。
+ * 心跳异常时保留尚未到期的任务,超过许可期限后自动停止播放。
+ */
+ @Scheduled(fixedDelay = 700)
+ public void heartbeat() {
+ tasks.entrySet().removeIf(entry -> !entry.getValue().valid());
+ if (System.currentTimeMillis() < retryAfter) {
+ return;
+ }
+ try {
+ if (images != null) images.ensureStarted();
+ String coordinator = locator.locate();
+ registerIfNeeded(coordinator);
+ ObjectNode input = readinessReport();
+ // 从发出请求的时刻计算许可,不能把网络等待时间额外算作授权。
+ long requestedAt = System.nanoTime();
+ JsonNode plan = peer.request(coordinator,
+ "/control/nodes/" + settings.id + "/heartbeat", "POST", input);
+ long deadline = requestedAt + TimeUnit.MILLISECONDS.toNanos(plan.path("leaseMs").asLong());
+ if (System.nanoTime() >= deadline) {
+ throw new IllegalStateException("Expired task plan");
+ }
+ applyPlan(plan, deadline);
+ connection = "CONNECTED";
+ } catch (Exception failure) {
+ // 下次重新发现与注册;旧会话不会跨总控重启或连接故障继续复用。
+ session = "";
+ locator.disconnect();
+ connection = "WAITING";
+ retryAfter = System.currentTimeMillis() + 1500;
+ }
+ }
+
+ /**
+ * 持久节点标识区分电脑,本次 bootId 区分同一电脑的不同启动。
+ * 会话由总控返回,后续心跳继续证明当前启动的身份。
+ * 对外地址按与总控通信的网卡确定,避免上报不可达的回环地址。
+ */
+ private void registerIfNeeded(String coordinator) throws Exception {
+ int imagePort = images == null ? 0 : images.inputPort();
+ if (!session.isEmpty() && registeredImagePort == imagePort) {
+ return;
+ }
+ ObjectNode registration = object().put("clusterId", settings.cluster)
+ .put("nodeId", settings.id).put("bootId", settings.bootId)
+ // 主执行端复用总控身份和同一 HTTP 端口,加入同一调度池而不额外复制节点。
+ .put("nodeRole", settings.role.equals("coordinator") ? "COORDINATOR" : "WORKER")
+ .put("name", settings.name).put("capacity", settings.capacity)
+ .put("baseUrl", NodeAddress.url(settings, coordinator));
+ // 图片直达节点 UDP 端口;控制与浏览器媒体接口仍复用现有 HTTP 端口。
+ // 广播和注册中只有地址与能力,设备 SM4/HMAC 密钥只存在私有任务计划。
+ if (images != null && imagePort > 0) {
+ String host = NodeAddress.host(settings, coordinator);
+ String ipv4 = java.util.Arrays.stream(InetAddress.getAllByName(host))
+ .filter(address -> address instanceof Inet4Address).map(InetAddress::getHostAddress)
+ .findFirst().orElseThrow(() -> new IllegalStateException("Image input requires IPv4"));
+ registration.put("imageHost", ipv4).put("imageInputPort", imagePort);
+ }
+ JsonNode result = peer.request(coordinator, "/control/nodes/register", "POST", registration);
+ session = result.path("sessionId").asText();
+ registeredImagePort = imagePort;
+ }
+
+ // 没有生成首帧或许可已经过期时,不向总控承诺该任务可播放。
+ // 就绪报告带分配版本,旧节点的迟到报告不能确认新版本的迁移。
+ private ObjectNode readinessReport() {
+ // 每个仍有效分配都报告接入和画面状态,包括迁移中的 active 与 candidate。
+ ObjectNode input = object().put("sessionId", session).put("bootId", settings.bootId);
+ ArrayNode reports = input.putArray("reports");
+ for (MediaTask task : tasks.values()) {
+ if (!task.valid()) continue;
+ ObjectNode report = object().put("taskId", task.taskId).put("version", task.version);
+ if (task.mediaKind.equals("ORIGINAL_IMAGE")) {
+ String id = ImageIngressService.taskKey(task.taskId, task.version);
+ report.put("ready", images != null && images.ready(id))
+ .put("imageReady", images != null && images.imageReady(id));
+ if (images != null) {
+ ImageIngressService.Frame frame = images.latest(id);
+ if (frame != null) report.put("lastFrameId", frame.frameId()).put("lastFrameTime", frame.sourceTime());
+ if (!images.accessError(id).isEmpty()) report.put("error", images.accessError(id));
+ }
+ reports.add(report);
+ } else if (task.jpeg != null) reports.add(report.put("ready", true));
+ }
+ return input;
+ }
+
+ /**
+ * 完整计划决定本机应保留哪些分配,未列出的任务及时释放。
+ * 首次领取或准备转运行时,先生成首帧再发布新的任务对象。
+ * 同版本播放连接按键读取当前对象,状态切换不会绑死在旧对象上。
+ */
+ private void applyPlan(JsonNode plan, long deadline) throws Exception {
+ // 完整计划先替换接入许可,撤销任务时连同在途分片和旧图一并释放。
+ // 原图接入不等待上传,否则设备将拿不到目标地址而无法发送首帧。
+ if (images != null) images.applyPlans(plan.path("tasks"), deadline);
+ Set present = new HashSet<>();
+ for (JsonNode entry : plan.path("tasks")) {
+ String id = entry.path("taskId").asText() + "/"
+ + entry.path("assignment").path("version").asLong();
+ present.add(id);
+ MediaTask old = tasks.get(id);
+ if (old == null || !old.desired.equals(entry.path("desired").asText())
+ || !old.mediaKind.equals(entry.path("mediaKind").asText("DEMO_VIDEO"))) {
+ MediaTask fresh = new MediaTask(entry);
+ fresh.deadlineNanos = deadline;
+ if (!fresh.mediaKind.equals("ORIGINAL_IMAGE")) fresh.jpeg = render(fresh);
+ tasks.put(id, fresh);
+ } else {
+ old.deadlineNanos = deadline;
+ }
+ }
+ tasks.keySet().removeIf(id -> !present.contains(id));
+ }
+
+ // 一路画面生成失败保留其上次成功帧,其他通道仍继续刷新。
+ // 许可检查独立于画面生成;缓存帧不能让已过期任务继续提供视频。
+ @Scheduled(fixedDelay = 250)
+ public void frames() {
+ for (MediaTask task : tasks.values()) {
+ if (task.valid() && !task.mediaKind.equals("ORIGINAL_IMAGE")) {
+ try {
+ task.jpeg = render(task);
+ } catch (Exception ignored) {
+ // 保持最后一帧,等待下一次定时生成重试。
+ }
+ }
+ }
+ }
+
+ /**
+ * 播放必须同时满足任务号、分配版本、访问密钥及有效许可。
+ * 密钥采用固定时间比较,避免直接字符串比较泄露匹配前缀。
+ * 该校验每帧执行,任务撤销或许可失效可终止已经打开的连接。
+ */
+ public MediaTask playable(String taskId, long version, String key) {
+ MediaTask task = tasks.get(taskId + "/" + version);
+ if (task == null || !task.valid() || key == null
+ || !MessageDigest.isEqual(task.key.getBytes(StandardCharsets.UTF_8),
+ key.getBytes(StandardCharsets.UTF_8))) {
+ throw new IllegalArgumentException("Video task is not active");
+ }
+ return task;
+ }
+
+ // 控制层只取得当前授权帧,任务对象与其可变状态由服务层管理。
+ public byte[] frame(String taskId, long version, String key) {
+ MediaTask task = playable(taskId, version, key);
+ if (!task.mediaKind.equals("ORIGINAL_IMAGE")) return task.jpeg;
+ ImageIngressService.Frame frame = originalFrame(taskId, version, null);
+ return frame == null ? null : frame.jpeg();
+ }
+
+ /**
+ * 私有原图接口由服务 HMAC 鉴权,另逐次检查分配版本和租约。
+ * 元数据和 JPEG 之间允许短暂并发,用不透明帧号锁定近期真实帧。
+ * 无图返回 null,不能回退到演示画面或其他装备的图片。
+ */
+ public ImageIngressService.Frame originalFrame(String taskId, long version, String frameId) {
+ // 服务认证只证明调用者身份,不能代替本次任务的版本与执行许可检查。
+ MediaTask task = tasks.get(ImageIngressService.taskKey(taskId, version));
+ if (task == null || !task.valid() || !task.mediaKind.equals("ORIGINAL_IMAGE") || images == null) return null;
+ String id = ImageIngressService.taskKey(taskId, version);
+ return frameId == null ? images.latest(id) : images.frame(id, frameId);
+ }
+
+ // 对外状态不包含会话、播放密钥或采集业务数据。
+ public ObjectNode status() {
+ ObjectNode result = object().put("nodeId", settings.id).put("connection", connection).put("tasks", tasks.size());
+ if (images != null) result.set("imageInput", images.status());
+ return result;
+ }
+
+ /**
+ * 合成带通道名、试验名、节点名及版本号的演示帧。
+ * 移动图形用于观察画面是否持续刷新,不代表真实装备运动。
+ * 不读取或伪造采集数据,未来可在此服务接入真正的融合算法。
+ */
+ private byte[] render(MediaTask task) throws Exception {
+ int width = 640, height = 360;
+ BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ Graphics2D graphics = image.createGraphics();
+ try {
+ graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ drawScene(graphics, width, height);
+ drawLabels(graphics, task);
+ } finally {
+ // 每次生成均释放图形上下文,避免长期运行积累本地图形资源。
+ graphics.dispose();
+ }
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ ImageIO.write(image, "jpg", buffer);
+ return buffer.toByteArray();
+ }
+
+ // 场景只负责可见运动与背景绘制,业务身份文字单独输出。
+ private void drawScene(Graphics2D graphics, int width, int height) {
+ graphics.setPaint(new GradientPaint(0, 0, new Color(35, 64, 89),
+ 0, height, new Color(9, 22, 38)));
+ graphics.fillRect(0, 0, width, height);
+ graphics.setColor(new Color(74, 102, 121));
+ for (int x = 0; x < width; x += 40) {
+ graphics.drawLine(x, 95, x, height);
+ }
+ for (int y = 95; y < height; y += 35) {
+ graphics.drawLine(0, y, width, y);
+ }
+ graphics.setColor(new Color(79, 112, 129));
+ graphics.fillRect(0, 220, width, 65);
+ graphics.setColor(new Color(170, 185, 190));
+ for (int x = 0; x < width; x += 65) {
+ graphics.fillRect(x, 250, 35, 3);
+ }
+ int x = (int) ((System.currentTimeMillis() / 30) % 730) - 65;
+ graphics.setColor(new Color(38, 161, 217));
+ graphics.fillRoundRect(x, 221, 72, 33, 7, 7);
+ graphics.setColor(new Color(163, 216, 238));
+ graphics.fillRect(x + 18, 226, 25, 11);
+ graphics.setColor(new Color(5, 14, 23));
+ graphics.fillOval(x + 10, 247, 15, 15);
+ graphics.fillOval(x + 49, 247, 15, 15);
+ }
+
+ // 持续标出演示性质,避免界面把节点演示误当成真实视频融合结果。
+ private void drawLabels(Graphics2D graphics, MediaTask task) {
+ graphics.setColor(Color.WHITE);
+ graphics.setFont(new Font("Microsoft YaHei", Font.BOLD, 18));
+ graphics.drawString(task.name, 22, 33);
+ graphics.setFont(new Font("Microsoft YaHei", Font.PLAIN, 13));
+ graphics.drawString(task.trialName, 22, 58);
+ graphics.setColor(new Color(255, 206, 108));
+ graphics.drawString("演示视频 · 尚未接入真实视频融合", 22, 83);
+ graphics.setColor(new Color(214, 229, 240));
+ graphics.drawString(settings.name + " / "
+ + (task.desired.equals("RUNNING") ? "本轮运行中" : "准备预览"), 22, 326);
+ graphics.drawString("任务版本 " + task.version, 510, 326);
+ }
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
new file mode 100644
index 0000000..ed4da75
--- /dev/null
+++ b/src/main/resources/application.properties
@@ -0,0 +1,16 @@
+server.address=0.0.0.0
+server.port=18100
+spring.config.import=optional:file:./config/cluster.local.properties
+spring.jackson.default-property-inclusion=non_null
+spring.mvc.async.request-timeout=0
+fusion.role=coordinator
+fusion.coordinator-worker-enabled=true
+fusion.cluster-id=pxhj-fusion
+fusion.token=${XTRH_CLUSTER_TOKEN:}
+fusion.capacity=2
+fusion.lease-ms=8000
+fusion.discovery-enabled=true
+fusion.gateway-port=19002
+fusion.forward-target-port=19001
+logging.level.javax.jmdns=WARN
+server.error.include-message=never
diff --git a/src/main/resources/static/console/assets/brand.svg b/src/main/resources/static/console/assets/brand.svg
new file mode 100644
index 0000000..2019af3
--- /dev/null
+++ b/src/main/resources/static/console/assets/brand.svg
@@ -0,0 +1,5 @@
+
\ No newline at end of file
diff --git a/src/main/resources/static/console/assets/index-CGh3g9P7.css b/src/main/resources/static/console/assets/index-CGh3g9P7.css
new file mode 100644
index 0000000..149b3af
--- /dev/null
+++ b/src/main/resources/static/console/assets/index-CGh3g9P7.css
@@ -0,0 +1 @@
+.secondary-equipment-layout{display:grid;grid-template-columns:296px minmax(0,1fr);gap:18px;align-items:stretch}.secondary-detail-stack{display:flex;flex-direction:column;gap:14px;min-width:0}.secondary-search{display:flex;align-items:center;gap:10px;width:330px;max-width:100%;min-height:40px;padding:0 12px;color:#647e9f;border:1px solid #d6e2f0;border-radius:6px;background:#fff}.secondary-search:focus-within{border-color:#3280d4;box-shadow:0 0 0 3px #2b7bd316}.secondary-search input{width:100%;min-width:0;height:38px;padding:0;color:#203753;font:inherit;font-size:13px;border:0;outline:0;background:transparent}.secondary-search input::placeholder{color:#7d91ab}.secondary-list-search{padding:0 18px 18px}.secondary-list-search .secondary-search{width:auto}.secondary-panel-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px}.secondary-muted,.secondary-blank{color:#8294ac}.secondary-blank{white-space:nowrap;font-variant-numeric:tabular-nums}.secondary-blank small{margin-left:8px;color:#8294ac;font-size:12px}.secondary-unavailable{display:inline-flex;align-items:center;gap:7px;color:#8393a9;white-space:nowrap;font-size:13px}.secondary-unavailable i,.node-state i{width:8px;height:8px;flex:0 0 8px;border-radius:50%;background:currentColor}.secondary-table-scroll{width:100%;overflow-x:auto}.secondary-table{width:100%;border-spacing:0;border-collapse:collapse;table-layout:fixed;color:#294360;font-size:13px}.secondary-table th{height:48px;padding:12px 14px;color:#5c7798;background:#f4f8fc;border:1px solid #e1eaf4;font-weight:500;text-align:left}.secondary-table td{height:83px;padding:17px 14px;border:1px solid #e1eaf4;vertical-align:middle}.secondary-table th svg{display:inline-block;margin:0 6px -2px 0}.secondary-table tbody tr:hover{background:#f9fbfe}.secondary-table .secondary-empty-cell{height:230px;padding:30px 16px;text-align:center}.secondary-table .secondary-empty-cell:hover{background:#fff}.secondary-column-unit{white-space:nowrap}.secondary-panel-note{margin:20px 0 0;color:#7f94ae;font-size:12px;line-height:1.6}.virtual-list-panel{min-height:650px}.virtual-list-empty{display:grid;place-items:center;padding:80px 18px 28px}.virtual-unselected-header{display:flex;align-items:center;gap:18px;padding:23px;min-height:100px;border:1px solid #dfe8f3;border-radius:8px;background:#fff}.virtual-equipment-icon{display:grid;place-items:center;width:48px;height:48px;color:#a2b5cc;background:#f2f6fb;border-radius:9px}.virtual-unselected-header h2,.video-unselected-header h2{margin:0 0 8px;font-size:21px;font-weight:600;color:#566d89}.virtual-unselected-header p,.video-unselected-header p{margin:0;color:#8396ae;font-size:13px}.virtual-unselected-header p span{margin-left:12px}.virtual-unselected-header>.secondary-unavailable{margin-left:auto}.virtual-state-body{padding:0}.virtual-health-row{display:flex;align-items:center;gap:20px;padding:20px 22px;background:#f9fbfe;border:1px solid #e5edf6;border-radius:5px}.virtual-health-row>svg{color:#9badc2}.virtual-health-label{color:#66809f;font-size:14px}.virtual-health-row strong{color:#8195ad;font-size:23px;font-weight:500;white-space:nowrap}.virtual-health-track{flex:1;height:10px;margin-left:8px;border-radius:6px;background:#e7edf5}.virtual-position-grid{display:grid;grid-template-columns:1fr 1fr;gap:19px 36px;padding:23px 12px 4px}.virtual-position-item{display:grid;grid-template-columns:84px 1fr;gap:12px;font-size:14px}.virtual-position-item>span:first-child{color:#6783a3}.virtual-overview-table{min-width:620px}.virtual-overview-table th:first-child{width:23%}.virtual-overview-table th:nth-child(3){width:33%}.video-main-panel{min-width:0;overflow:hidden}.video-unselected-header{display:flex;align-items:center;gap:18px;min-height:91px;color:#91a6bf}.video-main-panel>.panel-header:empty{display:none}.video-stage{position:relative;display:flex;flex-direction:column;min-height:480px;aspect-ratio:16 / 10;overflow:hidden;background:#101d2c;border-radius:6px}.video-empty-frame{display:flex;flex:1;flex-direction:column;align-items:center;justify-content:center;padding:180px 24px 36px;color:#5f7893;text-align:center}.video-empty-frame h3{margin:16px 0 9px;color:#c1cede;font-size:19px;font-weight:500}.video-empty-frame p{margin:0;color:#7d92a9;font-size:13px;line-height:1.7}.video-metrics-overlay{position:absolute;top:18px;right:18px;width:222px;padding:15px 17px;color:#dfe7f0;border:1px solid #52667c50;border-radius:7px;background:#223246e8}.video-overlay-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;padding-bottom:11px;border-bottom:1px solid #a4b4c12b}.video-overlay-heading strong{font-size:14px}.video-overlay-heading>span{max-width:102px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#9fb2c7;font-size:11px}.video-metrics-overlay dl{display:flex;flex-direction:column;gap:10px;margin:12px 0 0;font-size:12px}.video-metrics-overlay dl>div{display:flex;justify-content:space-between;align-items:center;gap:15px}.video-metrics-overlay dt{color:#c1cede}.video-metrics-overlay dd{margin:0;color:#a9bbcf;white-space:nowrap}.video-metrics-overlay dd small{margin-left:6px;color:#8098b3;font-size:10px}.video-control-bar{display:flex;align-items:center;gap:16px;min-height:48px;padding:0 16px;color:#8195ad;background:#1c2a3a}.video-control-bar .secondary-unavailable{color:#879bb2;font-size:12px}.video-control-divider{height:21px;width:1px;background:#47596d}.video-control-bar button{display:grid;place-items:center;min-width:25px;padding:5px 0;border:0;color:#768b9f;background:none}.video-control-bar button:disabled{cursor:not-allowed;opacity:.55}.video-volume-track{width:96px;height:3px;border-radius:3px;background:#3f5369}.video-resolution{margin-left:auto;font-size:11px;white-space:nowrap}.fusion-metrics-panel{min-height:550px}.fusion-metrics-table{min-width:1000px}.fusion-metrics-table th:first-child{width:215px}.fusion-metrics-table th:last-child{width:105px}.fusion-metrics-table td:not(:first-child),.fusion-metrics-table th:not(:first-child){text-align:center}.fusion-metrics-table td{height:93px}.fusion-equipment-name{display:flex;align-items:center;gap:14px}.fusion-equipment-name>svg{color:#287dcb;flex-shrink:0}.fusion-equipment-name>div{min-width:0}.fusion-equipment-name strong{display:block;color:#1b395c;font-size:15px;font-weight:600;word-break:break-word}.fusion-equipment-name span{display:block;margin-top:7px;color:#7e94b0;font-size:12px;word-break:break-all}.fusion-metrics-footer{margin:22px 0 0;color:#7b93b0;font-size:13px}.node-status-table{min-width:1020px}.node-status-table th:first-child{width:250px}.node-status-table th:nth-child(2){width:90px}.node-status-table th:nth-child(4){width:180px}.node-status-table th:last-child{width:115px}.node-status-table td{height:96px}.node-identity{display:flex;align-items:center;gap:15px}.node-identity>svg{flex-shrink:0;color:#267bd1}.node-identity>div{min-width:0}.node-identity strong{display:block;color:#193a5c;font-size:15px;font-weight:600;word-break:break-word}.node-identity span{display:block;margin-top:7px;color:#7a91ad;font-size:11px;overflow-wrap:anywhere}.node-state{display:inline-flex;align-items:center;gap:7px;font-size:13px;white-space:nowrap}.node-state-online{color:#0da76a}.node-state-offline,.node-state-unknown{color:#8092aa}.node-network-panel{margin-top:24px;padding:18px;border:1px solid #e0e9f4;border-radius:6px}.node-network-panel h3{display:flex;align-items:center;gap:9px;margin:0 0 18px;padding-bottom:14px;border-bottom:1px solid #e6edf6;font-size:15px;font-weight:600;color:#234363}.node-network-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.node-network-item{display:flex;align-items:center;gap:11px;min-width:0;padding:7px 0}.node-network-item>svg{flex-shrink:0;color:#287dc9}.node-network-item>div{min-width:0}.node-network-item strong{display:block;color:#294664;font-size:13px;font-weight:600;word-break:break-word}.node-network-item>div>span{display:block;margin-top:6px;color:#8294aa;font-size:11px}.node-network-item>.node-state{margin-left:auto}.node-network-empty{margin:28px 0;color:#8b9bb0;text-align:center;font-size:13px}@media(max-width:1220px){.secondary-equipment-layout{grid-template-columns:260px minmax(0,1fr)}.video-stage{min-height:490px}.video-empty-frame{padding-top:230px}.node-network-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.virtual-health-row{gap:13px;padding:18px 14px}.virtual-health-row strong{font-size:19px}}@media(max-width:950px){.secondary-equipment-layout{grid-template-columns:1fr}.virtual-list-panel{min-height:240px}.virtual-list-empty{padding:25px 18px}.video-stage{aspect-ratio:16 / 10;min-height:440px}.video-empty-frame{padding-top:180px}}@media(max-width:650px){.virtual-unselected-header{gap:12px;padding:17px;flex-wrap:wrap}.virtual-unselected-header>.secondary-unavailable{margin-left:60px}.virtual-position-grid{grid-template-columns:1fr}.virtual-health-row{flex-wrap:wrap}.virtual-health-track{min-width:70px}.video-stage{min-height:480px}.video-metrics-overlay{width:200px;right:12px;top:12px}.video-empty-frame{padding-top:250px}.video-control-bar{gap:12px;padding:0 12px}.video-volume-track,.video-resolution{display:none}.video-control-bar button:last-child{margin-left:auto}.node-network-grid{grid-template-columns:1fr}}:root{font-family:Microsoft YaHei,Segoe UI,sans-serif;color:#263e5b;background:#f4f6fa;font-size:14px;font-weight:400;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased}*{box-sizing:border-box}body{margin:0;min-width:360px}button,input,select{font:inherit}button,a,input,select{-webkit-tap-highlight-color:transparent}button{cursor:pointer}button:disabled{cursor:default;opacity:.45}a{color:inherit;text-decoration:none}h1,h2,h3,p{margin:0}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,tr:focus-visible{outline:2px solid #276cc0;outline-offset:3px}button{border:0}input,select{color:#314d70}input::placeholder{color:#8d9bb1}svg{flex-shrink:0}time,.mono{font-variant-numeric:tabular-nums}.muted{color:#8797ac;font-size:12px}.sidebar{width:246px;background:#172c46;color:#becddd;position:fixed;inset:0 auto 0 0;display:flex;flex-direction:column;padding:27px 13px 24px;z-index:10}.brand{display:flex;align-items:center;gap:12px;padding:0 12px 30px;color:#fff;position:relative;white-space:nowrap}.brand img{width:43px;height:43px}.brand strong{font-size:18px;font-weight:600}.collapse-toggle{display:grid;place-items:center;width:23px;height:25px;background:#ffffff06;color:#9eb3cc;border:1px solid #ffffff20;border-radius:4px;position:absolute;right:0;bottom:9px}.sidebar nav{padding-top:16px}.nav-link{display:flex;align-items:center;gap:20px;padding:16px;margin:6px 0 12px;border-radius:7px;font-size:15px;white-space:nowrap;min-height:54px}.nav-link:hover{background:#ffffff0b}.nav-link.active{background:#2c5d97;color:#fff;font-weight:600}.nav-link svg{color:#a5bdd6}.nav-link.active svg{color:#f2f8ff}.sidebar-foot{margin-top:auto;display:flex;gap:19px;align-items:center;border-top:1px solid #ffffff14;padding:25px 17px 3px;font-size:14px;color:#bdcede}.main-shell{margin-left:246px;min-height:100vh;display:flex;flex-direction:column;min-width:0}.topbar{height:64px;min-height:64px;padding:0 29px;display:flex;align-items:center;justify-content:space-between;gap:18px;background:#fff;border-bottom:1px solid #e1e8f0;font-size:13px}.breadcrumb{display:flex;align-items:center;gap:13px;color:#7e90a9;white-space:nowrap}.breadcrumb strong{color:#344c6b;font-weight:600}.service-state{display:flex;gap:22px;align-items:center;white-space:nowrap}.service-state time{border-left:1px solid #d7e1ed;padding-left:22px;color:#6e84a3;font-size:12px}main{padding:28px 29px 0;flex:1;min-width:0;max-width:1900px;width:100%;margin:auto}.page-heading{display:flex;align-items:center;justify-content:space-between;gap:18px;margin-bottom:22px;min-height:70px}.page-heading h1{font-size:27px;line-height:1.35;font-weight:650;color:#1f3653;letter-spacing:.2px}.page-heading p{margin-top:8px;color:#7b8fa8;line-height:1.5;font-size:14px}.heading-actions{display:flex;gap:12px;align-items:center;justify-content:flex-end}.refresh-button{display:flex;gap:7px;align-items:center;padding:9px 12px;min-height:38px;background:#fff;border:1px solid #dce5ef;border-radius:5px;color:#6b80a0;font-size:12px}.refresh-button:hover{border-color:#276cc0;color:#276cc0}.context-select{display:flex;align-items:center;gap:10px;color:#7a8ca4;font-size:12px}.context-select select{max-width:260px;border:1px solid #d9e3ef;border-radius:5px;background:#fff;padding:10px 28px 10px 11px;min-width:120px}.panel{background:#fff;border:1px solid #dfe7f1;border-radius:7px;min-width:0;overflow:hidden}.panel-title{padding:19px 20px 15px;display:flex;align-items:center;justify-content:space-between;gap:15px;min-height:56px}.panel-title h2{font-size:17px;color:#253f5f;font-weight:600}.panel-extra{font-size:12px;color:#8193ab}.equipment-layout{display:grid;grid-template-columns:290px minmax(0,1fr);gap:12px;min-height:650px;align-items:stretch}.equipment-list{min-height:650px}.equipment-list .panel-title{padding-bottom:14px}.search-input{display:flex;align-items:center;gap:10px;border:1px solid #cfdbeb;border-radius:5px;min-height:42px;padding:0 12px;margin:4px 20px 20px;color:#708baa;min-width:0}.search-input input{background:transparent;width:100%;min-width:0;border:0;outline:none!important;padding:10px 0;font-size:13px}.search-input:focus-within{border-color:#4384c7;box-shadow:0 0 0 2px #276cc014}.equipment-rows{border-top:1px solid #e5ecf4}.equipment-row{display:flex;align-items:center;gap:14px;min-height:92px;width:100%;padding:18px 18px 18px 23px;background:#fff;color:#2873bf;text-align:left;border-bottom:1px solid #e3ebf4;border-left:3px solid transparent}.equipment-row:hover{background:#f6faff}.equipment-row.selected{background:#eaf3ff;border-left-color:#3284df}.equipment-row-copy{min-width:0;flex:1}.equipment-row-copy strong{display:block;font-weight:600;color:#273f61;font-size:15px;overflow-wrap:anywhere}.equipment-row-copy small{display:block;color:#8198b4;font-size:12px;margin-top:5px;overflow-wrap:anywhere}.equipment-row .status{font-size:11px;gap:5px;max-width:86px;white-space:normal;line-height:1.45}.status{display:inline-flex;align-items:center;gap:7px;font-size:12px;white-space:nowrap;line-height:1.45}.status i{width:7px;height:7px;flex:none;background:currentColor;border-radius:50%}.status-good{color:#14965c}.status-warn{color:#bf881c}.status-bad{color:#c25b48}.status-muted{color:#879bb6}.detail-stack{display:flex;flex-direction:column;gap:12px;min-width:0}.equipment-header{display:flex;align-items:center;justify-content:space-between;padding:24px 25px;gap:18px;min-height:106px}.equipment-heading{display:flex;align-items:center;gap:23px;min-width:0;color:#2778c2}.equipment-heading h2{font-size:24px;color:#213b60;font-weight:600;overflow-wrap:anywhere}.equipment-heading p{font-size:12px;color:#8297b4;margin-top:7px;line-height:1.5}.equipment-heading p span{margin:0 11px}.equipment-heading>.status{background:#eaf7f0;padding:5px 9px;border-radius:3px;margin-left:3px}.equipment-updated{font-size:12px;color:#8b9db5;white-space:nowrap}.equipment-updated time{color:#607c9f;margin-left:9px}.table-scroll{overflow:auto;max-width:100%}.data-table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}.data-table th{background:#f6f8fc;color:#7c91ad;font-size:12px;font-weight:500;padding:14px 19px;border-block:1px solid #e5ecf4;white-space:nowrap}.data-table td{border-bottom:1px solid #e5ecf4;padding:19px;vertical-align:middle;color:#426185;font-variant-numeric:tabular-nums}.data-table tr:last-child td{border-bottom:0}.data-table tr.selected{background:#f3f8ff}.data-table td strong{font-weight:500;color:#334f72}.data-table td small{display:block;font-size:11px;color:#8d9eb3;margin-top:5px}.receive-table tbody tr{cursor:pointer}.receive-table tbody tr:hover{background:#f6faff}.receive-table td.parameter-value{overflow-wrap:anywhere;min-width:130px;max-width:450px}.receive-table td:first-child{min-width:145px}.receive-table td time{white-space:nowrap}.value-details{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px 20px;margin:0;padding:14px 24px 25px}.value-details>div{min-width:0;border-left:1px solid #e2ebf6;padding-left:16px}.value-details dt{color:#8095b1;font-size:12px;margin-bottom:11px}.value-details dd{margin:0;font-size:16px;color:#2e4c73;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.empty-state{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:12px;min-height:190px;padding:34px 24px;color:#94a9c0}.empty-state strong{font-size:15px;font-weight:500;color:#6d88a7}.empty-state p{max-width:450px;font-size:12px;line-height:1.8;color:#8c9fb6}.equipment-list .empty-state{min-height:240px;padding-inline:18px}.unselected-panel{min-height:650px;display:flex;align-items:center;justify-content:center}.unselected-panel .empty-state{min-height:400px}.sync-values{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:15px;padding:0 20px 22px}.sync-values>div{border:1px solid #e1eaf5;padding:17px 18px}.sync-values span{font-size:13px;color:#7289a8}.sync-values strong{display:block;font-size:27px;font-weight:600;margin-top:12px;color:#5d7ba0}.sync-values small{font-size:12px;font-weight:400}.sync-values p{margin-top:8px;font-size:11px;color:#91a3ba}.source-notice{display:flex;align-items:center;gap:8px;background:#fff7e8;border:1px solid #f2e3c5;color:#a27f3f;font-size:12px;padding:11px 15px;border-radius:5px;margin-bottom:16px;line-height:1.7}.app-footer{padding:23px 29px;font-size:11px;color:#92a5be;display:flex;align-items:center;justify-content:space-between;gap:12px}.nav-collapsed .sidebar{width:76px;padding-inline:10px}.nav-collapsed .main-shell{margin-left:76px}.nav-collapsed .brand{padding:0 6px 28px}.nav-collapsed .brand img{width:43px;height:43px}.nav-collapsed .brand strong,.nav-collapsed .nav-link span,.nav-collapsed .sidebar-foot span{display:none}.nav-collapsed .nav-link{padding:15px;justify-content:center}.nav-collapsed .sidebar-foot{padding:24px 17px 3px}.nav-collapsed .collapse-toggle{bottom:-4px;right:16px}.brand img{background:#4b92d5;border-radius:10px;padding:5px}.sidebar nav{padding-top:3px}.secondary-equipment-layout{grid-template-columns:290px minmax(0,1fr);gap:12px}.video-unselected-header{padding:23px}.virtual-state-body{padding:0 20px 20px}.virtual-overview-panel>.secondary-table-scroll,.fusion-metrics-panel>.secondary-table-scroll,.node-page>.secondary-table-scroll{width:calc(100% - 40px);margin-inline:20px}.secondary-panel-toolbar{margin-inline:20px}.node-page>.node-network-panel{margin:24px 20px 0}.secondary-panel-note{margin:20px}.fusion-metrics-panel,.node-page{padding-bottom:18px}@media(min-width:1550px){.equipment-layout,.secondary-equipment-layout{grid-template-columns:350px minmax(0,1fr)}.equipment-row{padding-block:22px}.equipment-row .status{font-size:12px}.equipment-header{min-height:111px}.equipment-list,.equipment-layout{min-height:730px}.nav-link{font-size:16px}.brand strong{font-size:19px}.page-heading h1{font-size:29px}.data-table{font-size:14px}}@media(max-width:1280px){.sidebar{width:210px;padding-inline:10px}.main-shell{margin-left:210px}.brand{gap:9px;padding-inline:8px}.brand img{width:36px;height:36px}.brand strong{font-size:16px}.nav-link{font-size:13px;gap:15px;padding-inline:12px}.equipment-layout{grid-template-columns:250px minmax(0,1fr)}.equipment-row{gap:10px;padding-inline:14px}.equipment-row .status{font-size:10px;max-width:65px}.equipment-row-copy strong{font-size:13px}.equipment-row-copy small{font-size:11px}.equipment-heading{gap:15px}.equipment-heading h2{font-size:21px}.equipment-header{padding:20px;flex-wrap:wrap;gap:12px}.equipment-updated{width:100%;padding-left:61px}.topbar{padding-inline:22px}main{padding:24px 22px 0}.data-table td,.data-table th{padding-inline:14px}.service-state{gap:12px}.service-state time{padding-left:12px}.value-details{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:1000px){.service-state time{display:none}.topbar{gap:10px}.equipment-layout{grid-template-columns:220px minmax(0,1fr)}.equipment-row{flex-wrap:wrap;column-gap:9px;padding-block:14px;min-height:86px}.equipment-row .status{margin-left:35px;max-width:none;width:100%;font-size:10px}.equipment-list .panel-title h2{font-size:15px}.search-input{margin-inline:14px;padding-inline:9px}.equipment-heading>.status{margin-left:0}.equipment-heading{flex-wrap:wrap;gap:12px}.equipment-heading>svg{width:34px}.equipment-updated{padding-left:46px}.panel-title h2{font-size:15px}.heading-actions{flex-wrap:wrap}.context-select>span{display:none}.context-select select{max-width:180px}.refresh-button span{display:none}.sync-values{gap:9px;padding-inline:14px}.sync-values>div{padding:13px 10px}.sync-values span{font-size:11px}}@media(max-width:800px){.sidebar{width:68px;padding-inline:8px}.main-shell{margin-left:68px}.brand{padding:0 7px 30px}.brand img{width:37px;height:37px}.brand strong,.nav-link span,.sidebar-foot span,.collapse-toggle{display:none}.nav-link{padding:14px;justify-content:center}.sidebar-foot{justify-content:center;padding-inline:0}.equipment-layout{grid-template-columns:210px minmax(0,1fr)}main{padding:20px 17px 0}.topbar{padding-inline:17px}.page-heading h1{font-size:24px}.page-heading p{font-size:12px}.page-heading{align-items:flex-start;gap:12px}.equipment-heading h2{font-size:19px}.equipment-heading p{font-size:11px}.equipment-header{padding:18px 16px}.value-details{gap:20px 12px;padding-inline:15px}.value-details dd{font-size:14px}}@media(max-width:650px){.equipment-layout{grid-template-columns:1fr;min-height:0}.equipment-list{min-height:0}.equipment-list .equipment-rows{display:flex;overflow:auto}.equipment-row{min-width:220px;width:220px;border-left:0;border-top:3px solid transparent}.equipment-row.selected{border-top-color:#3284df}.equipment-list .empty-state{min-height:140px}.unselected-panel{min-height:320px}.page-heading{flex-direction:column}.heading-actions{width:100%;justify-content:flex-end}.context-select{flex:1}.context-select select{max-width:100%;width:100%}.service-state{display:none}.breadcrumb{font-size:12px}.topbar{height:56px;min-height:56px}.detail-stack{gap:12px}.equipment-updated{padding-left:0}.sync-values{grid-template-columns:1fr}.sync-values>div{display:flex;align-items:center;justify-content:space-between;gap:10px}.sync-values strong,.sync-values p{margin:0}.app-footer{padding:20px 17px}.app-footer span:first-child{display:none}.nav-collapsed .sidebar{width:68px}.nav-collapsed .main-shell{margin-left:68px}}
diff --git a/src/main/resources/static/console/assets/index-DHXeX6-t.js b/src/main/resources/static/console/assets/index-DHXeX6-t.js
new file mode 100644
index 0000000..027d47b
--- /dev/null
+++ b/src/main/resources/static/console/assets/index-DHXeX6-t.js
@@ -0,0 +1,214 @@
+(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const _ of document.querySelectorAll('link[rel="modulepreload"]'))o(_);new MutationObserver(_=>{for(const D of _)if(D.type==="childList")for(const w of D.addedNodes)w.tagName==="LINK"&&w.rel==="modulepreload"&&o(w)}).observe(document,{childList:!0,subtree:!0});function N(_){const D={};return _.integrity&&(D.integrity=_.integrity),_.referrerPolicy&&(D.referrerPolicy=_.referrerPolicy),_.crossOrigin==="use-credentials"?D.credentials="include":_.crossOrigin==="anonymous"?D.credentials="omit":D.credentials="same-origin",D}function o(_){if(_.ep)return;_.ep=!0;const D=N(_);fetch(_.href,D)}})();function ky(s){return s&&s.__esModule&&Object.prototype.hasOwnProperty.call(s,"default")?s.default:s}var rf={exports:{}},Au={};/**
+ * @license React
+ * react-jsx-runtime.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var Yh;function $y(){if(Yh)return Au;Yh=1;var s=Symbol.for("react.transitional.element"),S=Symbol.for("react.fragment");function N(o,_,D){var w=null;if(D!==void 0&&(w=""+D),_.key!==void 0&&(w=""+_.key),"key"in _){D={};for(var J in _)J!=="key"&&(D[J]=_[J])}else D=_;return _=D.ref,{$$typeof:s,type:o,key:w,ref:_!==void 0?_:null,props:D}}return Au.Fragment=S,Au.jsx=N,Au.jsxs=N,Au}var qh;function Wy(){return qh||(qh=1,rf.exports=$y()),rf.exports}var f=Wy(),vf={exports:{}},X={};/**
+ * @license React
+ * react.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var Gh;function Fy(){if(Gh)return X;Gh=1;var s=Symbol.for("react.transitional.element"),S=Symbol.for("react.portal"),N=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),_=Symbol.for("react.profiler"),D=Symbol.for("react.consumer"),w=Symbol.for("react.context"),J=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),x=Symbol.for("react.memo"),V=Symbol.for("react.lazy"),B=Symbol.for("react.activity"),ul=Symbol.iterator;function Tl(y){return y===null||typeof y!="object"?null:(y=ul&&y[ul]||y["@@iterator"],typeof y=="function"?y:null)}var nl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},gl=Object.assign,Rl={};function _l(y,A,O){this.props=y,this.context=A,this.refs=Rl,this.updater=O||nl}_l.prototype.isReactComponent={},_l.prototype.setState=function(y,A){if(typeof y!="object"&&typeof y!="function"&&y!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,y,A,"setState")},_l.prototype.forceUpdate=function(y){this.updater.enqueueForceUpdate(this,y,"forceUpdate")};function Cl(){}Cl.prototype=_l.prototype;function bl(y,A,O){this.props=y,this.context=A,this.refs=Rl,this.updater=O||nl}var Nl=bl.prototype=new Cl;Nl.constructor=bl,gl(Nl,_l.prototype),Nl.isPureReactComponent=!0;var Xl=Array.isArray;function Bl(){}var Y={H:null,A:null,T:null,S:null},Yl=Object.prototype.hasOwnProperty;function Il(y,A,O){var C=O.ref;return{$$typeof:s,type:y,key:A,ref:C!==void 0?C:null,props:O}}function Pt(y,A){return Il(y.type,A,y.props)}function ct(y){return typeof y=="object"&&y!==null&&y.$$typeof===s}function Ll(y){var A={"=":"=0",":":"=2"};return"$"+y.replace(/[=:]/g,function(O){return A[O]})}var Q=/\/+/g;function gt(y,A){return typeof y=="object"&&y!==null&&y.key!=null?Ll(""+y.key):A.toString(36)}function ql(y){switch(y.status){case"fulfilled":return y.value;case"rejected":throw y.reason;default:switch(typeof y.status=="string"?y.then(Bl,Bl):(y.status="pending",y.then(function(A){y.status==="pending"&&(y.status="fulfilled",y.value=A)},function(A){y.status==="pending"&&(y.status="rejected",y.reason=A)})),y.status){case"fulfilled":return y.value;case"rejected":throw y.reason}}throw y}function E(y,A,O,C,L){var k=typeof y;(k==="undefined"||k==="boolean")&&(y=null);var fl=!1;if(y===null)fl=!0;else switch(k){case"bigint":case"string":case"number":fl=!0;break;case"object":switch(y.$$typeof){case s:case S:fl=!0;break;case V:return fl=y._init,E(fl(y._payload),A,O,C,L)}}if(fl)return L=L(y),fl=C===""?"."+gt(y,0):C,Xl(L)?(O="",fl!=null&&(O=fl.replace(Q,"$&/")+"/"),E(L,A,O,"",function(De){return De})):L!=null&&(ct(L)&&(L=Pt(L,O+(L.key==null||y&&y.key===L.key?"":(""+L.key).replace(Q,"$&/")+"/")+fl)),A.push(L)),1;fl=0;var $l=C===""?".":C+":";if(Xl(y))for(var Al=0;Al>>1,ll=E[cl];if(0<_(ll,M))E[cl]=M,E[q]=ll,q=cl;else break l}}function N(E){return E.length===0?null:E[0]}function o(E){if(E.length===0)return null;var M=E[0],q=E.pop();if(q!==M){E[0]=q;l:for(var cl=0,ll=E.length,y=ll>>>1;cl_(O,q))C_(L,O)?(E[cl]=L,E[C]=q,cl=C):(E[cl]=O,E[A]=q,cl=A);else if(C_(L,q))E[cl]=L,E[C]=q,cl=C;else break l}}return M}function _(E,M){var q=E.sortIndex-M.sortIndex;return q!==0?q:E.id-M.id}if(s.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var D=performance;s.unstable_now=function(){return D.now()}}else{var w=Date,J=w.now();s.unstable_now=function(){return w.now()-J}}var p=[],x=[],V=1,B=null,ul=3,Tl=!1,nl=!1,gl=!1,Rl=!1,_l=typeof setTimeout=="function"?setTimeout:null,Cl=typeof clearTimeout=="function"?clearTimeout:null,bl=typeof setImmediate<"u"?setImmediate:null;function Nl(E){for(var M=N(x);M!==null;){if(M.callback===null)o(x);else if(M.startTime<=E)o(x),M.sortIndex=M.expirationTime,S(p,M);else break;M=N(x)}}function Xl(E){if(gl=!1,Nl(E),!nl)if(N(p)!==null)nl=!0,Bl||(Bl=!0,Ll());else{var M=N(x);M!==null&&ql(Xl,M.startTime-E)}}var Bl=!1,Y=-1,Yl=5,Il=-1;function Pt(){return Rl?!0:!(s.unstable_now()-IlE&&Pt());){var cl=B.callback;if(typeof cl=="function"){B.callback=null,ul=B.priorityLevel;var ll=cl(B.expirationTime<=E);if(E=s.unstable_now(),typeof ll=="function"){B.callback=ll,Nl(E),M=!0;break t}B===N(p)&&o(p),Nl(E)}else o(p);B=N(p)}if(B!==null)M=!0;else{var y=N(x);y!==null&&ql(Xl,y.startTime-E),M=!1}}break l}finally{B=null,ul=q,Tl=!1}M=void 0}}finally{M?Ll():Bl=!1}}}var Ll;if(typeof bl=="function")Ll=function(){bl(ct)};else if(typeof MessageChannel<"u"){var Q=new MessageChannel,gt=Q.port2;Q.port1.onmessage=ct,Ll=function(){gt.postMessage(null)}}else Ll=function(){_l(ct,0)};function ql(E,M){Y=_l(function(){E(s.unstable_now())},M)}s.unstable_IdlePriority=5,s.unstable_ImmediatePriority=1,s.unstable_LowPriority=4,s.unstable_NormalPriority=3,s.unstable_Profiling=null,s.unstable_UserBlockingPriority=2,s.unstable_cancelCallback=function(E){E.callback=null},s.unstable_forceFrameRate=function(E){0>E||125cl?(E.sortIndex=q,S(x,E),N(p)===null&&E===N(x)&&(gl?(Cl(Y),Y=-1):gl=!0,ql(Xl,q-cl))):(E.sortIndex=ll,S(p,E),nl||Tl||(nl=!0,Bl||(Bl=!0,Ll()))),E},s.unstable_shouldYield=Pt,s.unstable_wrapCallback=function(E){var M=ul;return function(){var q=ul;ul=M;try{return E.apply(this,arguments)}finally{ul=q}}}})(Sf)),Sf}var Lh;function lr(){return Lh||(Lh=1,gf.exports=Py()),gf.exports}var bf={exports:{}},kl={};/**
+ * @license React
+ * react-dom.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var Zh;function tr(){if(Zh)return kl;Zh=1;var s=Nf();function S(p){var x="https://react.dev/errors/"+p;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(s)}catch(S){console.error(S)}}return s(),bf.exports=tr(),bf.exports}/**
+ * @license React
+ * react-dom-client.production.js
+ *
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ */var Kh;function er(){if(Kh)return xu;Kh=1;var s=lr(),S=Nf(),N=ar();function o(l){var t="https://react.dev/errors/"+l;if(1ll||(l.current=cl[ll],cl[ll]=null,ll--)}function O(l,t){ll++,cl[ll]=l.current,l.current=t}var C=y(null),L=y(null),k=y(null),fl=y(null);function $l(l,t){switch(O(k,t),O(L,l),O(C,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?ch(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=ch(t),l=ih(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}A(C),O(C,l)}function Al(){A(C),A(L),A(k)}function De(l){l.memoizedState!==null&&O(fl,l);var t=C.current,a=ih(t,l.type);t!==a&&(O(L,l),O(C,a))}function Nu(l){L.current===l&&(A(C),A(L)),fl.current===l&&(A(fl),zu._currentValue=q)}var $n,Rf;function pa(l){if($n===void 0)try{throw Error()}catch(a){var t=a.stack.trim().match(/\n( *(at )?)/);$n=t&&t[1]||"",Rf=-1)":-1u||d[e]!==m[u]){var z=`
+`+d[e].replace(" at new "," at ");return l.displayName&&z.includes("")&&(z=z.replace("",l.displayName)),z}while(1<=e&&0<=u);break}}}finally{Wn=!1,Error.prepareStackTrace=a}return(a=l?l.displayName||l.name:"")?pa(a):""}function x0(l,t){switch(l.tag){case 26:case 27:case 5:return pa(l.type);case 16:return pa("Lazy");case 13:return l.child!==t&&t!==null?pa("Suspense Fallback"):pa("Suspense");case 19:return pa("SuspenseList");case 0:case 15:return Fn(l.type,!1);case 11:return Fn(l.type.render,!1);case 1:return Fn(l.type,!0);case 31:return pa("Activity");default:return""}}function Bf(l){try{var t="",a=null;do t+=x0(l,a),a=l,l=l.return;while(l);return t}catch(e){return`
+Error generating stack: `+e.message+`
+`+e.stack}}var In=Object.prototype.hasOwnProperty,Pn=s.unstable_scheduleCallback,lc=s.unstable_cancelCallback,p0=s.unstable_shouldYield,_0=s.unstable_requestPaint,it=s.unstable_now,N0=s.unstable_getCurrentPriorityLevel,Yf=s.unstable_ImmediatePriority,qf=s.unstable_UserBlockingPriority,Mu=s.unstable_NormalPriority,M0=s.unstable_LowPriority,Gf=s.unstable_IdlePriority,O0=s.log,D0=s.unstable_setDisableYieldValue,Ue=null,ft=null;function la(l){if(typeof O0=="function"&&D0(l),ft&&typeof ft.setStrictMode=="function")try{ft.setStrictMode(Ue,l)}catch{}}var st=Math.clz32?Math.clz32:H0,U0=Math.log,C0=Math.LN2;function H0(l){return l>>>=0,l===0?32:31-(U0(l)/C0|0)|0}var Ou=256,Du=262144,Uu=4194304;function _a(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function Cu(l,t,a){var e=l.pendingLanes;if(e===0)return 0;var u=0,n=l.suspendedLanes,c=l.pingedLanes;l=l.warmLanes;var i=e&134217727;return i!==0?(e=i&~n,e!==0?u=_a(e):(c&=i,c!==0?u=_a(c):a||(a=i&~l,a!==0&&(u=_a(a))))):(i=e&~n,i!==0?u=_a(i):c!==0?u=_a(c):a||(a=e&~l,a!==0&&(u=_a(a)))),u===0?0:t!==0&&t!==u&&(t&n)===0&&(n=u&-u,a=t&-t,n>=a||n===32&&(a&4194048)!==0)?t:u}function Ce(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function R0(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Qf(){var l=Uu;return Uu<<=1,(Uu&62914560)===0&&(Uu=4194304),l}function tc(l){for(var t=[],a=0;31>a;a++)t.push(l);return t}function He(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function B0(l,t,a,e,u,n){var c=l.pendingLanes;l.pendingLanes=a,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=a,l.entangledLanes&=a,l.errorRecoveryDisabledLanes&=a,l.shellSuspendCounter=0;var i=l.entanglements,d=l.expirationTimes,m=l.hiddenUpdates;for(a=c&~a;0"u")return null;try{return l.activeElement||l.body}catch{return l.body}}var L0=/[\n"\\]/g;function bt(l){return l.replace(L0,function(t){return"\\"+t.charCodeAt(0).toString(16)+" "})}function ic(l,t,a,e,u,n,c,i){l.name="",c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"?l.type=c:l.removeAttribute("type"),t!=null?c==="number"?(t===0&&l.value===""||l.value!=t)&&(l.value=""+St(t)):l.value!==""+St(t)&&(l.value=""+St(t)):c!=="submit"&&c!=="reset"||l.removeAttribute("value"),t!=null?fc(l,c,St(t)):a!=null?fc(l,c,St(a)):e!=null&&l.removeAttribute("value"),u==null&&n!=null&&(l.defaultChecked=!!n),u!=null&&(l.checked=u&&typeof u!="function"&&typeof u!="symbol"),i!=null&&typeof i!="function"&&typeof i!="symbol"&&typeof i!="boolean"?l.name=""+St(i):l.removeAttribute("name")}function Pf(l,t,a,e,u,n,c,i){if(n!=null&&typeof n!="function"&&typeof n!="symbol"&&typeof n!="boolean"&&(l.type=n),t!=null||a!=null){if(!(n!=="submit"&&n!=="reset"||t!=null)){cc(l);return}a=a!=null?""+St(a):"",t=t!=null?""+St(t):a,i||t===l.value||(l.value=t),l.defaultValue=t}e=e??u,e=typeof e!="function"&&typeof e!="symbol"&&!!e,l.checked=i?l.checked:!!e,l.defaultChecked=!!e,c!=null&&typeof c!="function"&&typeof c!="symbol"&&typeof c!="boolean"&&(l.name=c),cc(l)}function fc(l,t,a){t==="number"&&Bu(l.ownerDocument)===l||l.defaultValue===""+a||(l.defaultValue=""+a)}function Fa(l,t,a,e){if(l=l.options,t){t={};for(var u=0;u"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),yc=!1;if(Yt)try{var qe={};Object.defineProperty(qe,"passive",{get:function(){yc=!0}}),window.addEventListener("test",qe,qe),window.removeEventListener("test",qe,qe)}catch{yc=!1}var aa=null,rc=null,qu=null;function cs(){if(qu)return qu;var l,t=rc,a=t.length,e,u="value"in aa?aa.value:aa.textContent,n=u.length;for(l=0;l=Xe),hs=" ",ys=!1;function rs(l,t){switch(l){case"keyup":return m1.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function vs(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var te=!1;function S1(l,t){switch(l){case"compositionend":return vs(t);case"keypress":return t.which!==32?null:(ys=!0,hs);case"textInput":return l=t.data,l===hs&&ys?null:l;default:return null}}function b1(l,t){if(te)return l==="compositionend"||!bc&&rs(l,t)?(l=cs(),qu=rc=aa=null,te=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:a,offset:t-l};l=e}l:{for(;a;){if(a.nextSibling){a=a.nextSibling;break l}a=a.parentNode}a=void 0}a=Ts(a)}}function xs(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?xs(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function ps(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=Bu(l.document);t instanceof l.HTMLIFrameElement;){try{var a=typeof t.contentWindow.location.href=="string"}catch{a=!1}if(a)l=t.contentWindow;else break;t=Bu(l.document)}return t}function jc(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var _1=Yt&&"documentMode"in document&&11>=document.documentMode,ae=null,Tc=null,Ke=null,Ac=!1;function _s(l,t,a){var e=a.window===a?a.document:a.nodeType===9?a:a.ownerDocument;Ac||ae==null||ae!==Bu(e)||(e=ae,"selectionStart"in e&&jc(e)?e={start:e.selectionStart,end:e.selectionEnd}:(e=(e.ownerDocument&&e.ownerDocument.defaultView||window).getSelection(),e={anchorNode:e.anchorNode,anchorOffset:e.anchorOffset,focusNode:e.focusNode,focusOffset:e.focusOffset}),Ke&&Ve(Ke,e)||(Ke=e,e=Un(Tc,"onSelect"),0>=c,u-=c,Dt=1<<32-st(t)+u|a<K?(I=H,H=null):I=H.sibling;var al=g(r,H,v[K],j);if(al===null){H===null&&(H=I);break}l&&H&&al.alternate===null&&t(r,H),h=n(al,h,K),tl===null?R=al:tl.sibling=al,tl=al,H=I}if(K===v.length)return a(r,H),P&&Gt(r,K),R;if(H===null){for(;KK?(I=H,H=null):I=H.sibling;var Aa=g(r,H,al.value,j);if(Aa===null){H===null&&(H=I);break}l&&H&&Aa.alternate===null&&t(r,H),h=n(Aa,h,K),tl===null?R=Aa:tl.sibling=Aa,tl=Aa,H=I}if(al.done)return a(r,H),P&&Gt(r,K),R;if(H===null){for(;!al.done;K++,al=v.next())al=T(r,al.value,j),al!==null&&(h=n(al,h,K),tl===null?R=al:tl.sibling=al,tl=al);return P&&Gt(r,K),R}for(H=e(H);!al.done;K++,al=v.next())al=b(H,r,K,al.value,j),al!==null&&(l&&al.alternate!==null&&H.delete(al.key===null?K:al.key),h=n(al,h,K),tl===null?R=al:tl.sibling=al,tl=al);return l&&H.forEach(function(wy){return t(r,wy)}),P&&Gt(r,K),R}function yl(r,h,v,j){if(typeof v=="object"&&v!==null&&v.type===gl&&v.key===null&&(v=v.props.children),typeof v=="object"&&v!==null){switch(v.$$typeof){case Tl:l:{for(var R=v.key;h!==null;){if(h.key===R){if(R=v.type,R===gl){if(h.tag===7){a(r,h.sibling),j=u(h,v.props.children),j.return=r,r=j;break l}}else if(h.elementType===R||typeof R=="object"&&R!==null&&R.$$typeof===Yl&&qa(R)===h.type){a(r,h.sibling),j=u(h,v.props),Fe(j,v),j.return=r,r=j;break l}a(r,h);break}else t(r,h);h=h.sibling}v.type===gl?(j=Ca(v.props.children,r.mode,j,v.key),j.return=r,r=j):(j=ku(v.type,v.key,v.props,null,r.mode,j),Fe(j,v),j.return=r,r=j)}return c(r);case nl:l:{for(R=v.key;h!==null;){if(h.key===R)if(h.tag===4&&h.stateNode.containerInfo===v.containerInfo&&h.stateNode.implementation===v.implementation){a(r,h.sibling),j=u(h,v.children||[]),j.return=r,r=j;break l}else{a(r,h);break}else t(r,h);h=h.sibling}j=Dc(v,r.mode,j),j.return=r,r=j}return c(r);case Yl:return v=qa(v),yl(r,h,v,j)}if(ql(v))return U(r,h,v,j);if(Ll(v)){if(R=Ll(v),typeof R!="function")throw Error(o(150));return v=R.call(v),G(r,h,v,j)}if(typeof v.then=="function")return yl(r,h,tn(v),j);if(v.$$typeof===bl)return yl(r,h,Fu(r,v),j);an(r,v)}return typeof v=="string"&&v!==""||typeof v=="number"||typeof v=="bigint"?(v=""+v,h!==null&&h.tag===6?(a(r,h.sibling),j=u(h,v),j.return=r,r=j):(a(r,h),j=Oc(v,r.mode,j),j.return=r,r=j),c(r)):a(r,h)}return function(r,h,v,j){try{We=0;var R=yl(r,h,v,j);return ye=null,R}catch(H){if(H===he||H===Pu)throw H;var tl=ot(29,H,null,r.mode);return tl.lanes=j,tl.return=r,tl}finally{}}}var Qa=Ws(!0),Fs=Ws(!1),ia=!1;function Zc(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Vc(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function fa(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function sa(l,t,a){var e=l.updateQueue;if(e===null)return null;if(e=e.shared,(il&2)!==0){var u=e.pending;return u===null?t.next=t:(t.next=u.next,u.next=t),e.pending=t,t=wu(l),Hs(l,null,a),t}return Ju(l,e,t,a),wu(l)}function Ie(l,t,a){if(t=t.updateQueue,t!==null&&(t=t.shared,(a&4194048)!==0)){var e=t.lanes;e&=l.pendingLanes,a|=e,t.lanes=a,Lf(l,a)}}function Kc(l,t){var a=l.updateQueue,e=l.alternate;if(e!==null&&(e=e.updateQueue,a===e)){var u=null,n=null;if(a=a.firstBaseUpdate,a!==null){do{var c={lane:a.lane,tag:a.tag,payload:a.payload,callback:null,next:null};n===null?u=n=c:n=n.next=c,a=a.next}while(a!==null);n===null?u=n=t:n=n.next=t}else u=n=t;a={baseState:e.baseState,firstBaseUpdate:u,lastBaseUpdate:n,shared:e.shared,callbacks:e.callbacks},l.updateQueue=a;return}l=a.lastBaseUpdate,l===null?a.firstBaseUpdate=t:l.next=t,a.lastBaseUpdate=t}var Jc=!1;function Pe(){if(Jc){var l=oe;if(l!==null)throw l}}function lu(l,t,a,e){Jc=!1;var u=l.updateQueue;ia=!1;var n=u.firstBaseUpdate,c=u.lastBaseUpdate,i=u.shared.pending;if(i!==null){u.shared.pending=null;var d=i,m=d.next;d.next=null,c===null?n=m:c.next=m,c=d;var z=l.alternate;z!==null&&(z=z.updateQueue,i=z.lastBaseUpdate,i!==c&&(i===null?z.firstBaseUpdate=m:i.next=m,z.lastBaseUpdate=d))}if(n!==null){var T=u.baseState;c=0,z=m=d=null,i=n;do{var g=i.lane&-536870913,b=g!==i.lane;if(b?(F&g)===g:(e&g)===g){g!==0&&g===de&&(Jc=!0),z!==null&&(z=z.next={lane:0,tag:i.tag,payload:i.payload,callback:null,next:null});l:{var U=l,G=i;g=t;var yl=a;switch(G.tag){case 1:if(U=G.payload,typeof U=="function"){T=U.call(yl,T,g);break l}T=U;break l;case 3:U.flags=U.flags&-65537|128;case 0:if(U=G.payload,g=typeof U=="function"?U.call(yl,T,g):U,g==null)break l;T=B({},T,g);break l;case 2:ia=!0}}g=i.callback,g!==null&&(l.flags|=64,b&&(l.flags|=8192),b=u.callbacks,b===null?u.callbacks=[g]:b.push(g))}else b={lane:g,tag:i.tag,payload:i.payload,callback:i.callback,next:null},z===null?(m=z=b,d=T):z=z.next=b,c|=g;if(i=i.next,i===null){if(i=u.shared.pending,i===null)break;b=i,i=b.next,b.next=null,u.lastBaseUpdate=b,u.shared.pending=null}}while(!0);z===null&&(d=T),u.baseState=d,u.firstBaseUpdate=m,u.lastBaseUpdate=z,n===null&&(u.shared.lanes=0),ra|=c,l.lanes=c,l.memoizedState=T}}function Is(l,t){if(typeof l!="function")throw Error(o(191,l));l.call(t)}function Ps(l,t){var a=l.callbacks;if(a!==null)for(l.callbacks=null,l=0;ln?n:8;var c=E.T,i={};E.T=i,oi(l,!1,t,a);try{var d=u(),m=E.S;if(m!==null&&m(i,d),d!==null&&typeof d=="object"&&typeof d.then=="function"){var z=B1(d,e);eu(l,t,z,mt(l))}else eu(l,t,e,mt(l))}catch(T){eu(l,t,{then:function(){},status:"rejected",reason:T},mt())}finally{M.p=n,c!==null&&i.types!==null&&(c.types=i.types),E.T=c}}function L1(){}function si(l,t,a,e){if(l.tag!==5)throw Error(o(476));var u=Dd(l).queue;Od(l,u,t,q,a===null?L1:function(){return Ud(l),a(e)})}function Dd(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:q,baseState:q,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zt,lastRenderedState:q},next:null};var a={};return t.next={memoizedState:a,baseState:a,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Zt,lastRenderedState:a},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function Ud(l){var t=Dd(l);t.next===null&&(t=l.alternate.memoizedState),eu(l,t.next.queue,{},mt())}function di(){return Kl(zu)}function Cd(){return pl().memoizedState}function Hd(){return pl().memoizedState}function Z1(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var a=mt();l=fa(a);var e=sa(t,l,a);e!==null&&(nt(e,t,a),Ie(e,t,a)),t={cache:Gc()},l.payload=t;return}t=t.return}}function V1(l,t,a){var e=mt();a={lane:e,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null},yn(l)?Bd(t,a):(a=Nc(l,t,a,e),a!==null&&(nt(a,l,e),Yd(a,t,e)))}function Rd(l,t,a){var e=mt();eu(l,t,a,e)}function eu(l,t,a,e){var u={lane:e,revertLane:0,gesture:null,action:a,hasEagerState:!1,eagerState:null,next:null};if(yn(l))Bd(t,u);else{var n=l.alternate;if(l.lanes===0&&(n===null||n.lanes===0)&&(n=t.lastRenderedReducer,n!==null))try{var c=t.lastRenderedState,i=n(c,a);if(u.hasEagerState=!0,u.eagerState=i,dt(i,c))return Ju(l,t,u,0),rl===null&&Ku(),!1}catch{}finally{}if(a=Nc(l,t,u,e),a!==null)return nt(a,l,e),Yd(a,t,e),!0}return!1}function oi(l,t,a,e){if(e={lane:2,revertLane:Zi(),gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},yn(l)){if(t)throw Error(o(479))}else t=Nc(l,a,e,2),t!==null&&nt(t,l,2)}function yn(l){var t=l.alternate;return l===Z||t!==null&&t===Z}function Bd(l,t){ve=nn=!0;var a=l.pending;a===null?t.next=t:(t.next=a.next,a.next=t),l.pending=t}function Yd(l,t,a){if((a&4194048)!==0){var e=t.lanes;e&=l.pendingLanes,a|=e,t.lanes=a,Lf(l,a)}}var uu={readContext:Kl,use:sn,useCallback:El,useContext:El,useEffect:El,useImperativeHandle:El,useLayoutEffect:El,useInsertionEffect:El,useMemo:El,useReducer:El,useRef:El,useState:El,useDebugValue:El,useDeferredValue:El,useTransition:El,useSyncExternalStore:El,useId:El,useHostTransitionStatus:El,useFormState:El,useActionState:El,useOptimistic:El,useMemoCache:El,useCacheRefresh:El};uu.useEffectEvent=El;var qd={readContext:Kl,use:sn,useCallback:function(l,t){return Wl().memoizedState=[l,t===void 0?null:t],l},useContext:Kl,useEffect:Ed,useImperativeHandle:function(l,t,a){a=a!=null?a.concat([l]):null,on(4194308,4,xd.bind(null,t,l),a)},useLayoutEffect:function(l,t){return on(4194308,4,l,t)},useInsertionEffect:function(l,t){on(4,2,l,t)},useMemo:function(l,t){var a=Wl();t=t===void 0?null:t;var e=l();if(Xa){la(!0);try{l()}finally{la(!1)}}return a.memoizedState=[e,t],e},useReducer:function(l,t,a){var e=Wl();if(a!==void 0){var u=a(t);if(Xa){la(!0);try{a(t)}finally{la(!1)}}}else u=t;return e.memoizedState=e.baseState=u,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:u},e.queue=l,l=l.dispatch=V1.bind(null,Z,l),[e.memoizedState,l]},useRef:function(l){var t=Wl();return l={current:l},t.memoizedState=l},useState:function(l){l=ui(l);var t=l.queue,a=Rd.bind(null,Z,t);return t.dispatch=a,[l.memoizedState,a]},useDebugValue:ii,useDeferredValue:function(l,t){var a=Wl();return fi(a,l,t)},useTransition:function(){var l=ui(!1);return l=Od.bind(null,Z,l.queue,!0,!1),Wl().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,a){var e=Z,u=Wl();if(P){if(a===void 0)throw Error(o(407));a=a()}else{if(a=t(),rl===null)throw Error(o(349));(F&127)!==0||nd(e,t,a)}u.memoizedState=a;var n={value:a,getSnapshot:t};return u.queue=n,Ed(id.bind(null,e,n,l),[l]),e.flags|=2048,ge(9,{destroy:void 0},cd.bind(null,e,n,a,t),null),a},useId:function(){var l=Wl(),t=rl.identifierPrefix;if(P){var a=Ut,e=Dt;a=(e&~(1<<32-st(e)-1)).toString(32)+a,t="_"+t+"R_"+a,a=cn++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof e.is=="string"?c.createElement("select",{is:e.is}):c.createElement("select"),e.multiple?n.multiple=!0:e.size&&(n.size=e.size);break;default:n=typeof e.is=="string"?c.createElement(u,{is:e.is}):c.createElement(u)}}n[Zl]=t,n[Pl]=e;l:for(c=t.child;c!==null;){if(c.tag===5||c.tag===6)n.appendChild(c.stateNode);else if(c.tag!==4&&c.tag!==27&&c.child!==null){c.child.return=c,c=c.child;continue}if(c===t)break l;for(;c.sibling===null;){if(c.return===null||c.return===t)break l;c=c.return}c.sibling.return=c.return,c=c.sibling}t.stateNode=n;l:switch(wl(n,u,e),u){case"button":case"input":case"select":case"textarea":e=!!e.autoFocus;break l;case"img":e=!0;break l;default:e=!1}e&&Kt(t)}}return ml(t),xi(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,a),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==e&&Kt(t);else{if(typeof e!="string"&&t.stateNode===null)throw Error(o(166));if(l=k.current,fe(t)){if(l=t.stateNode,a=t.memoizedProps,e=null,u=Vl,u!==null)switch(u.tag){case 27:case 5:e=u.memoizedProps}l[Zl]=t,l=!!(l.nodeValue===a||e!==null&&e.suppressHydrationWarning===!0||uh(l.nodeValue,a)),l||na(t,!0)}else l=Cn(l).createTextNode(e),l[Zl]=t,t.stateNode=l}return ml(t),null;case 31:if(a=t.memoizedState,l===null||l.memoizedState!==null){if(e=fe(t),a!==null){if(l===null){if(!e)throw Error(o(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(o(557));l[Zl]=t}else Ha(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;ml(t),l=!1}else a=Rc(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=a),l=!0;if(!l)return t.flags&256?(yt(t),t):(yt(t),null);if((t.flags&128)!==0)throw Error(o(558))}return ml(t),null;case 13:if(e=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(u=fe(t),e!==null&&e.dehydrated!==null){if(l===null){if(!u)throw Error(o(318));if(u=t.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(o(317));u[Zl]=t}else Ha(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;ml(t),u=!1}else u=Rc(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=u),u=!0;if(!u)return t.flags&256?(yt(t),t):(yt(t),null)}return yt(t),(t.flags&128)!==0?(t.lanes=a,t):(a=e!==null,l=l!==null&&l.memoizedState!==null,a&&(e=t.child,u=null,e.alternate!==null&&e.alternate.memoizedState!==null&&e.alternate.memoizedState.cachePool!==null&&(u=e.alternate.memoizedState.cachePool.pool),n=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),n!==u&&(e.flags|=2048)),a!==l&&a&&(t.child.flags|=8192),Sn(t,t.updateQueue),ml(t),null);case 4:return Al(),l===null&&wi(t.stateNode.containerInfo),ml(t),null;case 10:return Xt(t.type),ml(t),null;case 19:if(A(xl),e=t.memoizedState,e===null)return ml(t),null;if(u=(t.flags&128)!==0,n=e.rendering,n===null)if(u)cu(e,!1);else{if(jl!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(n=un(l),n!==null){for(t.flags|=128,cu(e,!1),l=n.updateQueue,t.updateQueue=l,Sn(t,l),t.subtreeFlags=0,l=a,a=t.child;a!==null;)Rs(a,l),a=a.sibling;return O(xl,xl.current&1|2),P&&Gt(t,e.treeForkCount),t.child}l=l.sibling}e.tail!==null&&it()>Tn&&(t.flags|=128,u=!0,cu(e,!1),t.lanes=4194304)}else{if(!u)if(l=un(n),l!==null){if(t.flags|=128,u=!0,l=l.updateQueue,t.updateQueue=l,Sn(t,l),cu(e,!0),e.tail===null&&e.tailMode==="hidden"&&!n.alternate&&!P)return ml(t),null}else 2*it()-e.renderingStartTime>Tn&&a!==536870912&&(t.flags|=128,u=!0,cu(e,!1),t.lanes=4194304);e.isBackwards?(n.sibling=t.child,t.child=n):(l=e.last,l!==null?l.sibling=n:t.child=n,e.last=n)}return e.tail!==null?(l=e.tail,e.rendering=l,e.tail=l.sibling,e.renderingStartTime=it(),l.sibling=null,a=xl.current,O(xl,u?a&1|2:a&1),P&&Gt(t,e.treeForkCount),l):(ml(t),null);case 22:case 23:return yt(t),kc(),e=t.memoizedState!==null,l!==null?l.memoizedState!==null!==e&&(t.flags|=8192):e&&(t.flags|=8192),e?(a&536870912)!==0&&(t.flags&128)===0&&(ml(t),t.subtreeFlags&6&&(t.flags|=8192)):ml(t),a=t.updateQueue,a!==null&&Sn(t,a.retryQueue),a=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(a=l.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==a&&(t.flags|=2048),l!==null&&A(Ya),null;case 24:return a=null,l!==null&&(a=l.memoizedState.cache),t.memoizedState.cache!==a&&(t.flags|=2048),Xt(Ml),ml(t),null;case 25:return null;case 30:return null}throw Error(o(156,t.tag))}function $1(l,t){switch(Cc(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return Xt(Ml),Al(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return Nu(t),null;case 31:if(t.memoizedState!==null){if(yt(t),t.alternate===null)throw Error(o(340));Ha()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(yt(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(o(340));Ha()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return A(xl),null;case 4:return Al(),null;case 10:return Xt(t.type),null;case 22:case 23:return yt(t),kc(),l!==null&&A(Ya),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return Xt(Ml),null;case 25:return null;default:return null}}function fo(l,t){switch(Cc(t),t.tag){case 3:Xt(Ml),Al();break;case 26:case 27:case 5:Nu(t);break;case 4:Al();break;case 31:t.memoizedState!==null&&yt(t);break;case 13:yt(t);break;case 19:A(xl);break;case 10:Xt(t.type);break;case 22:case 23:yt(t),kc(),l!==null&&A(Ya);break;case 24:Xt(Ml)}}function iu(l,t){try{var a=t.updateQueue,e=a!==null?a.lastEffect:null;if(e!==null){var u=e.next;a=u;do{if((a.tag&l)===l){e=void 0;var n=a.create,c=a.inst;e=n(),c.destroy=e}a=a.next}while(a!==u)}}catch(i){dl(t,t.return,i)}}function ha(l,t,a){try{var e=t.updateQueue,u=e!==null?e.lastEffect:null;if(u!==null){var n=u.next;e=n;do{if((e.tag&l)===l){var c=e.inst,i=c.destroy;if(i!==void 0){c.destroy=void 0,u=t;var d=a,m=i;try{m()}catch(z){dl(u,d,z)}}}e=e.next}while(e!==n)}}catch(z){dl(t,t.return,z)}}function so(l){var t=l.updateQueue;if(t!==null){var a=l.stateNode;try{Ps(t,a)}catch(e){dl(l,l.return,e)}}}function oo(l,t,a){a.props=La(l.type,l.memoizedProps),a.state=l.memoizedState;try{a.componentWillUnmount()}catch(e){dl(l,t,e)}}function fu(l,t){try{var a=l.ref;if(a!==null){switch(l.tag){case 26:case 27:case 5:var e=l.stateNode;break;case 30:e=l.stateNode;break;default:e=l.stateNode}typeof a=="function"?l.refCleanup=a(e):a.current=e}}catch(u){dl(l,t,u)}}function Ct(l,t){var a=l.ref,e=l.refCleanup;if(a!==null)if(typeof e=="function")try{e()}catch(u){dl(l,t,u)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof a=="function")try{a(null)}catch(u){dl(l,t,u)}else a.current=null}function ho(l){var t=l.type,a=l.memoizedProps,e=l.stateNode;try{l:switch(t){case"button":case"input":case"select":case"textarea":a.autoFocus&&e.focus();break l;case"img":a.src?e.src=a.src:a.srcSet&&(e.srcset=a.srcSet)}}catch(u){dl(l,l.return,u)}}function pi(l,t,a){try{var e=l.stateNode;gy(e,l.type,a,t),e[Pl]=t}catch(u){dl(l,l.return,u)}}function yo(l){return l.tag===5||l.tag===3||l.tag===26||l.tag===27&&ba(l.type)||l.tag===4}function _i(l){l:for(;;){for(;l.sibling===null;){if(l.return===null||yo(l.return))return null;l=l.return}for(l.sibling.return=l.return,l=l.sibling;l.tag!==5&&l.tag!==6&&l.tag!==18;){if(l.tag===27&&ba(l.type)||l.flags&2||l.child===null||l.tag===4)continue l;l.child.return=l,l=l.child}if(!(l.flags&2))return l.stateNode}}function Ni(l,t,a){var e=l.tag;if(e===5||e===6)l=l.stateNode,t?(a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a).insertBefore(l,t):(t=a.nodeType===9?a.body:a.nodeName==="HTML"?a.ownerDocument.body:a,t.appendChild(l),a=a._reactRootContainer,a!=null||t.onclick!==null||(t.onclick=Bt));else if(e!==4&&(e===27&&ba(l.type)&&(a=l.stateNode,t=null),l=l.child,l!==null))for(Ni(l,t,a),l=l.sibling;l!==null;)Ni(l,t,a),l=l.sibling}function bn(l,t,a){var e=l.tag;if(e===5||e===6)l=l.stateNode,t?a.insertBefore(l,t):a.appendChild(l);else if(e!==4&&(e===27&&ba(l.type)&&(a=l.stateNode),l=l.child,l!==null))for(bn(l,t,a),l=l.sibling;l!==null;)bn(l,t,a),l=l.sibling}function ro(l){var t=l.stateNode,a=l.memoizedProps;try{for(var e=l.type,u=t.attributes;u.length;)t.removeAttributeNode(u[0]);wl(t,e,a),t[Zl]=l,t[Pl]=a}catch(n){dl(l,l.return,n)}}var Jt=!1,Ul=!1,Mi=!1,vo=typeof WeakSet=="function"?WeakSet:Set,Ql=null;function W1(l,t){if(l=l.containerInfo,Wi=Qn,l=ps(l),jc(l)){if("selectionStart"in l)var a={start:l.selectionStart,end:l.selectionEnd};else l:{a=(a=l.ownerDocument)&&a.defaultView||window;var e=a.getSelection&&a.getSelection();if(e&&e.rangeCount!==0){a=e.anchorNode;var u=e.anchorOffset,n=e.focusNode;e=e.focusOffset;try{a.nodeType,n.nodeType}catch{a=null;break l}var c=0,i=-1,d=-1,m=0,z=0,T=l,g=null;t:for(;;){for(var b;T!==a||u!==0&&T.nodeType!==3||(i=c+u),T!==n||e!==0&&T.nodeType!==3||(d=c+e),T.nodeType===3&&(c+=T.nodeValue.length),(b=T.firstChild)!==null;)g=T,T=b;for(;;){if(T===l)break t;if(g===a&&++m===u&&(i=c),g===n&&++z===e&&(d=c),(b=T.nextSibling)!==null)break;T=g,g=T.parentNode}T=b}a=i===-1||d===-1?null:{start:i,end:d}}else a=null}a=a||{start:0,end:0}}else a=null;for(Fi={focusedElem:l,selectionRange:a},Qn=!1,Ql=t;Ql!==null;)if(t=Ql,l=t.child,(t.subtreeFlags&1028)!==0&&l!==null)l.return=t,Ql=l;else for(;Ql!==null;){switch(t=Ql,n=t.alternate,l=t.flags,t.tag){case 0:if((l&4)!==0&&(l=t.updateQueue,l=l!==null?l.events:null,l!==null))for(a=0;a title"))),wl(n,e,a),n[Zl]=l,Gl(n),e=n;break l;case"link":var c=Eh("link","href",u).get(e+(a.href||""));if(c){for(var i=0;iyl&&(c=yl,yl=G,G=c);var r=As(i,G),h=As(i,yl);if(r&&h&&(b.rangeCount!==1||b.anchorNode!==r.node||b.anchorOffset!==r.offset||b.focusNode!==h.node||b.focusOffset!==h.offset)){var v=T.createRange();v.setStart(r.node,r.offset),b.removeAllRanges(),G>yl?(b.addRange(v),b.extend(h.node,h.offset)):(v.setEnd(h.node,h.offset),b.addRange(v))}}}}for(T=[],b=i;b=b.parentNode;)b.nodeType===1&&T.push({element:b,left:b.scrollLeft,top:b.scrollTop});for(typeof i.focus=="function"&&i.focus(),i=0;ia?32:a,E.T=null,a=Bi,Bi=null;var n=ma,c=Ft;if(Hl=0,je=ma=null,Ft=0,(il&6)!==0)throw Error(o(331));var i=il;if(il|=4,po(n.current),To(n,n.current,c,a),il=i,ru(0,!1),ft&&typeof ft.onPostCommitFiberRoot=="function")try{ft.onPostCommitFiberRoot(Ue,n)}catch{}return!0}finally{M.p=u,E.T=e,Vo(l,t)}}function Jo(l,t,a){t=Et(a,t),t=vi(l.stateNode,t,2),l=sa(l,t,2),l!==null&&(He(l,2),Ht(l))}function dl(l,t,a){if(l.tag===3)Jo(l,l,a);else for(;t!==null;){if(t.tag===3){Jo(t,l,a);break}else if(t.tag===1){var e=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof e.componentDidCatch=="function"&&(va===null||!va.has(e))){l=Et(a,l),a=Jd(2),e=sa(t,a,2),e!==null&&(wd(a,e,t,l),He(e,2),Ht(e));break}}t=t.return}}function Qi(l,t,a){var e=l.pingCache;if(e===null){e=l.pingCache=new P1;var u=new Set;e.set(t,u)}else u=e.get(t),u===void 0&&(u=new Set,e.set(t,u));u.has(a)||(Ui=!0,u.add(a),l=uy.bind(null,l,t,a),t.then(l,l))}function uy(l,t,a){var e=l.pingCache;e!==null&&e.delete(t),l.pingedLanes|=l.suspendedLanes&a,l.warmLanes&=~a,rl===l&&(F&a)===a&&(jl===4||jl===3&&(F&62914560)===F&&300>it()-jn?(il&2)===0&&Te(l,0):Ci|=a,Ee===F&&(Ee=0)),Ht(l)}function wo(l,t){t===0&&(t=Qf()),l=Ua(l,t),l!==null&&(He(l,t),Ht(l))}function ny(l){var t=l.memoizedState,a=0;t!==null&&(a=t.retryLane),wo(l,a)}function cy(l,t){var a=0;switch(l.tag){case 31:case 13:var e=l.stateNode,u=l.memoizedState;u!==null&&(a=u.retryLane);break;case 19:e=l.stateNode;break;case 22:e=l.stateNode._retryCache;break;default:throw Error(o(314))}e!==null&&e.delete(t),wo(l,a)}function iy(l,t){return Pn(l,t)}var Mn=null,xe=null,Xi=!1,On=!1,Li=!1,Sa=0;function Ht(l){l!==xe&&l.next===null&&(xe===null?Mn=xe=l:xe=xe.next=l),On=!0,Xi||(Xi=!0,sy())}function ru(l,t){if(!Li&&On){Li=!0;do for(var a=!1,e=Mn;e!==null;){if(l!==0){var u=e.pendingLanes;if(u===0)var n=0;else{var c=e.suspendedLanes,i=e.pingedLanes;n=(1<<31-st(42|l)+1)-1,n&=u&~(c&~i),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(a=!0,Fo(e,n))}else n=F,n=Cu(e,e===rl?n:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),(n&3)===0||Ce(e,n)||(a=!0,Fo(e,n));e=e.next}while(a);Li=!1}}function fy(){ko()}function ko(){On=Xi=!1;var l=0;Sa!==0&&by()&&(l=Sa);for(var t=it(),a=null,e=Mn;e!==null;){var u=e.next,n=$o(e,t);n===0?(e.next=null,a===null?Mn=u:a.next=u,u===null&&(xe=a)):(a=e,(l!==0||(n&3)!==0)&&(On=!0)),e=u}Hl!==0&&Hl!==5||ru(l),Sa!==0&&(Sa=0)}function $o(l,t){for(var a=l.suspendedLanes,e=l.pingedLanes,u=l.expirationTimes,n=l.pendingLanes&-62914561;0i)break;var z=d.transferSize,T=d.initiatorType;z&&nh(T)&&(d=d.responseEnd,c+=z*(d"u"?null:document;function gh(l,t,a){var e=pe;if(e&&typeof t=="string"&&t){var u=bt(t);u='link[rel="'+l+'"][href="'+u+'"]',typeof a=="string"&&(u+='[crossorigin="'+a+'"]'),mh.has(u)||(mh.add(u),l={rel:l,crossOrigin:a,href:t},e.querySelector(u)===null&&(t=e.createElement("link"),wl(t,"link",l),Gl(t),e.head.appendChild(t)))}}function Ny(l){It.D(l),gh("dns-prefetch",l,null)}function My(l,t){It.C(l,t),gh("preconnect",l,t)}function Oy(l,t,a){It.L(l,t,a);var e=pe;if(e&&l&&t){var u='link[rel="preload"][as="'+bt(t)+'"]';t==="image"&&a&&a.imageSrcSet?(u+='[imagesrcset="'+bt(a.imageSrcSet)+'"]',typeof a.imageSizes=="string"&&(u+='[imagesizes="'+bt(a.imageSizes)+'"]')):u+='[href="'+bt(l)+'"]';var n=u;switch(t){case"style":n=_e(l);break;case"script":n=Ne(l)}_t.has(n)||(l=B({rel:"preload",href:t==="image"&&a&&a.imageSrcSet?void 0:l,as:t},a),_t.set(n,l),e.querySelector(u)!==null||t==="style"&&e.querySelector(Su(n))||t==="script"&&e.querySelector(bu(n))||(t=e.createElement("link"),wl(t,"link",l),Gl(t),e.head.appendChild(t)))}}function Dy(l,t){It.m(l,t);var a=pe;if(a&&l){var e=t&&typeof t.as=="string"?t.as:"script",u='link[rel="modulepreload"][as="'+bt(e)+'"][href="'+bt(l)+'"]',n=u;switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=Ne(l)}if(!_t.has(n)&&(l=B({rel:"modulepreload",href:l},t),_t.set(n,l),a.querySelector(u)===null)){switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(a.querySelector(bu(n)))return}e=a.createElement("link"),wl(e,"link",l),Gl(e),a.head.appendChild(e)}}}function Uy(l,t,a){It.S(l,t,a);var e=pe;if(e&&l){var u=$a(e).hoistableStyles,n=_e(l);t=t||"default";var c=u.get(n);if(!c){var i={loading:0,preload:null};if(c=e.querySelector(Su(n)))i.loading=5;else{l=B({rel:"stylesheet",href:l,"data-precedence":t},a),(a=_t.get(n))&&uf(l,a);var d=c=e.createElement("link");Gl(d),wl(d,"link",l),d._p=new Promise(function(m,z){d.onload=m,d.onerror=z}),d.addEventListener("load",function(){i.loading|=1}),d.addEventListener("error",function(){i.loading|=2}),i.loading|=4,Rn(c,t,e)}c={type:"stylesheet",instance:c,count:1,state:i},u.set(n,c)}}}function Cy(l,t){It.X(l,t);var a=pe;if(a&&l){var e=$a(a).hoistableScripts,u=Ne(l),n=e.get(u);n||(n=a.querySelector(bu(u)),n||(l=B({src:l,async:!0},t),(t=_t.get(u))&&nf(l,t),n=a.createElement("script"),Gl(n),wl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(u,n))}}function Hy(l,t){It.M(l,t);var a=pe;if(a&&l){var e=$a(a).hoistableScripts,u=Ne(l),n=e.get(u);n||(n=a.querySelector(bu(u)),n||(l=B({src:l,async:!0,type:"module"},t),(t=_t.get(u))&&nf(l,t),n=a.createElement("script"),Gl(n),wl(n,"link",l),a.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(u,n))}}function Sh(l,t,a,e){var u=(u=k.current)?Hn(u):null;if(!u)throw Error(o(446));switch(l){case"meta":case"title":return null;case"style":return typeof a.precedence=="string"&&typeof a.href=="string"?(t=_e(a.href),a=$a(u).hoistableStyles,e=a.get(t),e||(e={type:"style",instance:null,count:0,state:null},a.set(t,e)),e):{type:"void",instance:null,count:0,state:null};case"link":if(a.rel==="stylesheet"&&typeof a.href=="string"&&typeof a.precedence=="string"){l=_e(a.href);var n=$a(u).hoistableStyles,c=n.get(l);if(c||(u=u.ownerDocument||u,c={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(l,c),(n=u.querySelector(Su(l)))&&!n._p&&(c.instance=n,c.state.loading=5),_t.has(l)||(a={rel:"preload",as:"style",href:a.href,crossOrigin:a.crossOrigin,integrity:a.integrity,media:a.media,hrefLang:a.hrefLang,referrerPolicy:a.referrerPolicy},_t.set(l,a),n||Ry(u,l,a,c.state))),t&&e===null)throw Error(o(528,""));return c}if(t&&e!==null)throw Error(o(529,""));return null;case"script":return t=a.async,a=a.src,typeof a=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(t=Ne(a),a=$a(u).hoistableScripts,e=a.get(t),e||(e={type:"script",instance:null,count:0,state:null},a.set(t,e)),e):{type:"void",instance:null,count:0,state:null};default:throw Error(o(444,l))}}function _e(l){return'href="'+bt(l)+'"'}function Su(l){return'link[rel="stylesheet"]['+l+"]"}function bh(l){return B({},l,{"data-precedence":l.precedence,precedence:null})}function Ry(l,t,a,e){l.querySelector('link[rel="preload"][as="style"]['+t+"]")?e.loading=1:(t=l.createElement("link"),e.preload=t,t.addEventListener("load",function(){return e.loading|=1}),t.addEventListener("error",function(){return e.loading|=2}),wl(t,"link",a),Gl(t),l.head.appendChild(t))}function Ne(l){return'[src="'+bt(l)+'"]'}function bu(l){return"script[async]"+l}function zh(l,t,a){if(t.count++,t.instance===null)switch(t.type){case"style":var e=l.querySelector('style[data-href~="'+bt(a.href)+'"]');if(e)return t.instance=e,Gl(e),e;var u=B({},a,{"data-href":a.href,"data-precedence":a.precedence,href:null,precedence:null});return e=(l.ownerDocument||l).createElement("style"),Gl(e),wl(e,"style",u),Rn(e,a.precedence,l),t.instance=e;case"stylesheet":u=_e(a.href);var n=l.querySelector(Su(u));if(n)return t.state.loading|=4,t.instance=n,Gl(n),n;e=bh(a),(u=_t.get(u))&&uf(e,u),n=(l.ownerDocument||l).createElement("link"),Gl(n);var c=n;return c._p=new Promise(function(i,d){c.onload=i,c.onerror=d}),wl(n,"link",e),t.state.loading|=4,Rn(n,a.precedence,l),t.instance=n;case"script":return n=Ne(a.src),(u=l.querySelector(bu(n)))?(t.instance=u,Gl(u),u):(e=a,(u=_t.get(n))&&(e=B({},a),nf(e,u)),l=l.ownerDocument||l,u=l.createElement("script"),Gl(u),wl(u,"link",e),l.head.appendChild(u),t.instance=u);case"void":return null;default:throw Error(o(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(e=t.instance,t.state.loading|=4,Rn(e,a.precedence,l));return t.instance}function Rn(l,t,a){for(var e=a.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),u=e.length?e[e.length-1]:null,n=u,c=0;c title"):null)}function By(l,t,a){if(a===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;switch(t.rel){case"stylesheet":return l=t.disabled,typeof t.precedence=="string"&&l==null;default:return!0}case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function Th(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function Yy(l,t,a,e){if(a.type==="stylesheet"&&(typeof e.media!="string"||matchMedia(e.media).matches!==!1)&&(a.state.loading&4)===0){if(a.instance===null){var u=_e(e.href),n=t.querySelector(Su(u));if(n){t=n._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=Yn.bind(l),t.then(l,l)),a.state.loading|=4,a.instance=n,Gl(n);return}n=t.ownerDocument||t,e=bh(e),(u=_t.get(u))&&uf(e,u),n=n.createElement("link"),Gl(n);var c=n;c._p=new Promise(function(i,d){c.onload=i,c.onerror=d}),wl(n,"link",e),a.instance=n}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(a,t),(t=a.state.preload)&&(a.state.loading&3)===0&&(l.count++,a=Yn.bind(l),t.addEventListener("load",a),t.addEventListener("error",a))}}var cf=0;function qy(l,t){return l.stylesheets&&l.count===0&&Gn(l,l.stylesheets),0cf?50:800)+t);return l.unsuspend=a,function(){l.unsuspend=null,clearTimeout(e),clearTimeout(u)}}:null}function Yn(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Gn(this,this.stylesheets);else if(this.unsuspend){var l=this.unsuspend;this.unsuspend=null,l()}}}var qn=null;function Gn(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,qn=new Map,t.forEach(Gy,l),qn=null,Yn.call(l))}function Gy(l,t){if(!(t.state.loading&4)){var a=qn.get(l);if(a)var e=a.get(null);else{a=new Map,qn.set(l,a);for(var u=l.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(s)}catch(S){console.error(S)}}return s(),mf.exports=er(),mf.exports}var nr=ur();/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const cr=s=>s==null?void 0:s.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */function ir(s,S,N=[]){if(S==null)throw new Error("[lucide]: iconNode is required when icon name is used");return{name:cr(s),size:24,node:S,...N.length>0?{aliases:N}:{}}}/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const fr=s=>{let S="",N=!1;for(const o of s){if(o==="-"||o==="_"||o<=" "){N=S.length>0;continue}S.length===0?S+=o.toLowerCase():S+=N?o.toUpperCase():o,N=!1}return S};/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const sr=s=>{const S=fr(s);return S.charAt(0).toUpperCase()+S.slice(1)};/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const jf=(...s)=>s.filter((S,N,o)=>!!S&&S.trim()!==""&&o.indexOf(S)===N).join(" ").trim();/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const Ka={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor","stroke-width":2,"stroke-linecap":"round","stroke-linejoin":"round"};/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */function zf(s){return s!=null}function dr(s,S={}){var ul,Tl;const N=S.attributeNames??{},o=nl=>N[nl]??nl,_=s.size??s.width??Ka.width,D=s.size??s.height??Ka.height,w=((ul=s.aliases)==null?void 0:ul.filter(nl=>typeof nl=="string"&&nl.trim()!=="").map(nl=>`lucide-${nl}`))??[],J=[...s.name?[`lucide-${s.name}`]:[],...w],p=((Tl=S.className)==null?void 0:Tl.split(" ").filter(Boolean))??[],x=S.includeDefaultClasses===!1?jf(...p):jf("lucide",...J,...p),V=S.absoluteStrokeWidth?Number(S.strokeWidth??Ka["stroke-width"])*Number(s.size??s.width??Ka.width)/Number(S.size??S.width??Ka.width):S.strokeWidth??Ka["stroke-width"];return["svg",{...Object.entries(Ka).reduce((nl,[gl,Rl])=>(nl[o(gl)]=Rl,nl),{}),..."color"in S&&S.color&&{[o("stroke")]:S.color},..."size"in S&&zf(S.size)&&{[o("width")]:S.size,[o("height")]:S.size},..."width"in S&&zf(S.width)&&{[o("width")]:S.width},..."height"in S&&zf(S.height)&&{[o("height")]:S.height},[o("stroke-width")]:V,...x&&{[o("class")]:x},[o("viewBox")]:`0 0 ${_} ${D}`,...S.hasA11yProp===!1?{[o("aria-hidden")]:"true"}:{},..."attributes"in S&&S.attributes},s.node.map(nl=>{const[gl,Rl,_l]=nl,Cl=S.nonScalingStroke?{[o("vector-effect")]:"non-scaling-stroke",...Rl}:Rl;return _l?[gl,Cl,_l]:[gl,Cl]})]}/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */function or(s,S={}){return dr(s,{...S,attributeNames:{...S.attributeNames,class:"className","stroke-width":"strokeWidth","stroke-linecap":"strokeLinecap","stroke-linejoin":"strokeLinejoin","vector-effect":"vectorEffect"}})}/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const hr=s=>{for(const S in s)if(S.startsWith("aria-")||S==="role"||S==="title")return!0;return!1},yr=el.createContext({}),rr=()=>el.useContext(yr),vr=el.forwardRef(({color:s,size:S,width:N,height:o,strokeWidth:_,absoluteStrokeWidth:D,nonScalingStroke:w,className:J="",children:p,iconNode:x=[],icon:V={node:x,aliases:[],size:24},...B},ul)=>{const{size:Tl=24,strokeWidth:nl=2,absoluteStrokeWidth:gl=!1,nonScalingStroke:Rl=!1,color:_l="currentColor",className:Cl=""}=rr()??{},bl=!!p||hr(B),[Nl,Xl,Bl=[]]=or(V,{color:s??_l,width:N??S??Tl,height:o??S??Tl,strokeWidth:_??nl,absoluteStrokeWidth:D??gl,nonScalingStroke:w??Rl,className:jf(Cl,J),hasA11yProp:bl,attributes:B});return el.createElement(Nl,{ref:ul,...Xl},[...Bl.map(([Y,Yl])=>el.createElement(Y,Yl)),...Array.isArray(p)?p:[p]])});/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */function zl(s,S=[],N=[]){const o=typeof s=="string"?ir(s,S,N):s,_=el.forwardRef(({className:D,...w},J)=>el.createElement(vr,{ref:J,icon:o,className:D,...w}));return o.name&&(_.displayName=sr(o.name)),_}/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const l0={name:"activity",size:24,node:[["path",{d:"M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2",key:"169zse"}]]};l0.node;const pu=zl(l0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const t0={name:"arrow-down-to-line",size:24,node:[["path",{d:"M12 17V3",key:"1cwfxf"}],["path",{d:"m6 11 6 6 6-6",key:"12ii2o"}],["path",{d:"M19 21H5",key:"150jfl"}]]};t0.node;const mr=zl(t0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const a0={name:"arrow-up-from-line",size:24,node:[["path",{d:"m18 9-6-6-6 6",key:"kcunyi"}],["path",{d:"M12 3v14",key:"7cf3v8"}],["path",{d:"M5 21h14",key:"11awu3"}]]};a0.node;const gr=zl(a0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const e0={name:"chart-no-axes-column",size:24,node:[["path",{d:"M5 21v-6",key:"1hz6c0"}],["path",{d:"M12 21V3",key:"1lcnhd"}],["path",{d:"M19 21V9",key:"unv183"}]],aliases:["bar-chart-2"]};e0.node;const Sr=zl(e0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const u0={name:"chevron-right",size:24,node:[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]]};u0.node;const br=zl(u0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const n0={name:"circle-alert",size:24,node:[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["line",{x1:"12",x2:"12",y1:"8",y2:"12",key:"1pkeuh"}],["line",{x1:"12",x2:"12.01",y1:"16",y2:"16",key:"4dfq90"}]],aliases:["alert-circle"]};n0.node;const zr=zl(n0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const c0={name:"cpu",size:24,node:[["path",{d:"M12 20v2",key:"1lh1kg"}],["path",{d:"M12 2v2",key:"tus03m"}],["path",{d:"M17 20v2",key:"1rnc9c"}],["path",{d:"M17 2v2",key:"11trls"}],["path",{d:"M2 12h2",key:"1t8f8n"}],["path",{d:"M2 17h2",key:"7oei6x"}],["path",{d:"M2 7h2",key:"asdhe0"}],["path",{d:"M20 12h2",key:"1q8mjw"}],["path",{d:"M20 17h2",key:"1fpfkl"}],["path",{d:"M20 7h2",key:"1o8tra"}],["path",{d:"M7 20v2",key:"4gnj0m"}],["path",{d:"M7 2v2",key:"1i4yhu"}],["rect",{x:"4",y:"4",width:"16",height:"16",rx:"2",key:"1vbyd7"}],["rect",{x:"8",y:"8",width:"8",height:"8",rx:"1",key:"z9xiuo"}]]};c0.node;const Er=zl(c0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const i0={name:"gauge",size:24,node:[["path",{d:"m12 14 4-4",key:"9kzdfg"}],["path",{d:"M3.34 19a10 10 0 1 1 17.32 0",key:"19p75a"}]]};i0.node;const jr=zl(i0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const f0={name:"heart-pulse",size:24,node:[["path",{d:"M2 9.5a5.5 5.5 0 0 1 9.591-3.676.56.56 0 0 0 .818 0A5.49 5.49 0 0 1 22 9.5c0 2.29-1.5 4-3 5.5l-5.492 5.313a2 2 0 0 1-3 .019L5 15c-1.5-1.5-3-3.2-3-5.5",key:"mvr1a0"}],["path",{d:"M3.22 13H9.5l.5-1 2 4.5 2-7 1.5 3.5h5.27",key:"auskq0"}]]};f0.node;const wh=zl(f0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const s0={name:"inbox",size:24,node:[["polyline",{points:"22 12 16 12 14 15 10 15 8 12 2 12",key:"o97t9d"}],["path",{d:"M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z",key:"oot6mr"}]]};s0.node;const Tr=zl(s0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const d0={name:"maximize",size:24,node:[["path",{d:"M8 3H5a2 2 0 0 0-2 2v3",key:"1dcmit"}],["path",{d:"M21 8V5a2 2 0 0 0-2-2h-3",key:"1e4gt3"}],["path",{d:"M3 16v3a2 2 0 0 0 2 2h3",key:"wsl5sc"}],["path",{d:"M16 21h3a2 2 0 0 0 2-2v-3",key:"18trek"}]]};d0.node;const Ar=zl(d0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const o0={name:"monitor-play",size:24,node:[["path",{d:"M15.033 9.44a.647.647 0 0 1 0 1.12l-4.065 2.352a.645.645 0 0 1-.968-.56V7.648a.645.645 0 0 1 .967-.56z",key:"vbtd3f"}],["path",{d:"M12 17v4",key:"1riwvh"}],["path",{d:"M8 21h8",key:"1ev6f3"}],["rect",{x:"2",y:"3",width:"20",height:"14",rx:"2",key:"x3v2xh"}]]};o0.node;const Tf=zl(o0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const h0={name:"network",size:24,node:[["rect",{x:"16",y:"16",width:"6",height:"6",rx:"1",key:"4q2zg0"}],["rect",{x:"2",y:"16",width:"6",height:"6",rx:"1",key:"8cvhb9"}],["rect",{x:"9",y:"2",width:"6",height:"6",rx:"1",key:"1egb70"}],["path",{d:"M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3",key:"1jsf9p"}],["path",{d:"M12 12V8",key:"2874zd"}]]};h0.node;const y0=zl(h0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const r0={name:"panel-left-close",size:24,node:[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M9 3v18",key:"fh3hqa"}],["path",{d:"m16 15-3-3 3-3",key:"14y99z"}]],aliases:["sidebar-close"]};r0.node;const xr=zl(r0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const v0={name:"pause",size:24,node:[["rect",{x:"14",y:"3",width:"5",height:"18",rx:"1",key:"kaeet6"}],["rect",{x:"5",y:"3",width:"5",height:"18",rx:"1",key:"1wsw3u"}]]};v0.node;const pr=zl(v0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const m0={name:"plane",size:24,node:[["path",{d:"M17.8 19.2 16 11l3.5-3.5C21 6 21.5 4 21 3c-1-.5-3 0-4.5 1.5L13 8 4.8 6.2c-.5-.1-.9.1-1.1.5l-.3.5c-.2.5-.1 1 .3 1.3L9 12l-2 3H4l-1 1 3 2 2 3 1-1v-3l3-2 3.5 5.3c.3.4.8.5 1.3.3l.5-.2c.4-.3.6-.7.5-1.2z",key:"1v9wt8"}]]};m0.node;const _r=zl(m0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const g0={name:"radio",size:24,node:[["path",{d:"M16.247 7.761a6 6 0 0 1 0 8.478",key:"1fwjs5"}],["path",{d:"M19.075 4.933a10 10 0 0 1 0 14.134",key:"ehdyv1"}],["path",{d:"M4.925 19.067a10 10 0 0 1 0-14.134",key:"1q22gi"}],["path",{d:"M7.753 16.239a6 6 0 0 1 0-8.478",key:"r2q7qm"}],["circle",{cx:"12",cy:"12",r:"2",key:"1c9p78"}]]};g0.node;const Af=zl(g0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const S0={name:"refresh-cw",size:24,node:[["path",{d:"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",key:"v9h5vc"}],["path",{d:"M21 3v5h-5",key:"1q7to0"}],["path",{d:"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",key:"3uifl3"}],["path",{d:"M8 16H3v5",key:"1cv678"}]]};S0.node;const Nr=zl(S0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const b0={name:"search",size:24,node:[["path",{d:"m21 21-4.34-4.34",key:"14j7rj"}],["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}]]};b0.node;const z0=zl(b0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const E0={name:"send",size:24,node:[["path",{d:"M14.536 21.686a.5.5 0 0 0 .937-.024l6.5-19a.496.496 0 0 0-.635-.635l-19 6.5a.5.5 0 0 0-.024.937l7.93 3.18a2 2 0 0 1 1.112 1.11z",key:"1ffxy3"}],["path",{d:"m21.854 2.147-10.94 10.939",key:"12cjpa"}]]};E0.node;const Mr=zl(E0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const j0={name:"server",size:24,node:[["rect",{width:"20",height:"8",x:"2",y:"2",rx:"2",ry:"2",key:"ngkwjq"}],["rect",{width:"20",height:"8",x:"2",y:"14",rx:"2",ry:"2",key:"iecqi9"}],["line",{x1:"6",x2:"6.01",y1:"6",y2:"6",key:"16zg32"}],["line",{x1:"6",x2:"6.01",y1:"18",y2:"18",key:"nzw8ys"}]]};j0.node;const Ef=zl(j0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const T0={name:"truck",size:24,node:[["path",{d:"M14 18V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v11a1 1 0 0 0 1 1h2",key:"wrbu53"}],["path",{d:"M15 18H9",key:"1lyqi6"}],["path",{d:"M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.624l-3.48-4.35A1 1 0 0 0 17.52 8H14",key:"lysw3i"}],["circle",{cx:"17",cy:"18",r:"2",key:"332jqn"}],["circle",{cx:"7",cy:"18",r:"2",key:"19iecd"}]]};T0.node;const Or=zl(T0);/**
+ * @license lucide-react v1.57.0 - ISC
+ *
+ * This source code is licensed under the ISC license.
+ * See the LICENSE file in the root directory of this source tree.
+ */const A0={name:"volume-2",size:24,node:[["path",{d:"M11 4.702a.705.705 0 0 0-1.203-.498L6.413 7.587A1.4 1.4 0 0 1 5.416 8H3a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2.416a1.4 1.4 0 0 1 .997.413l3.383 3.384A.705.705 0 0 0 11 19.298z",key:"uqj9uw"}],["path",{d:"M16 9a5 5 0 0 1 0 6",key:"1q6k2b"}],["path",{d:"M19.364 18.364a9 9 0 0 0 0-12.728",key:"ijwkga"}]]};A0.node;const Dr=zl(A0),kh={RECEIVING:"接收正常",GOOD:"正常",CONNECTED:"服务连接正常",STALE:"数据已过期",NOT_RECEIVED:"未接收",MISSING:"未采到",ERROR:"数据异常",WAITING:"等待数据同步",INVALID_TIME:"时间异常",INVALID_VALUE:"数值异常",AMBIGUOUS:"来源不明确",online:"在线",offline:"离线",UNKNOWN:"未接入"};function _u({state:s="UNKNOWN",label:S}){const N=["RECEIVING","GOOD","CONNECTED","online"].includes(s)?"good":["STALE","INVALID_TIME","AMBIGUOUS"].includes(s)?"warn":["ERROR","INVALID_VALUE"].includes(s)?"bad":"muted";return f.jsxs("span",{className:`status status-${N}`,children:[f.jsx("i",{}),S||kh[s]||kh.UNKNOWN]})}function Fl({title:s,children:S,extra:N,className:o=""}){return f.jsxs("section",{className:`panel ${o}`,children:[(s||N)&&f.jsxs("div",{className:"panel-title",children:[f.jsx("h2",{children:s}),N&&f.jsx("div",{className:"panel-extra",children:N})]}),S]})}function xa({title:s="暂无数据",description:S,icon:N=Tr}){return f.jsxs("div",{className:"empty-state",children:[f.jsx(N,{size:35,strokeWidth:1.4}),f.jsx("strong",{children:s}),S&&f.jsx("p",{children:S})]})}function Mf({equipment:s,size:S=25}){const o=/无人机|航空|飞行|UAV/i.test(`${(s==null?void 0:s.name)||""} ${(s==null?void 0:s.code)||""} ${(s==null?void 0:s.type)||""}`)?_r:Or;return f.jsx(o,{size:S,strokeWidth:1.7})}function Of({equipments:s=[],selectedId:S,onSelect:N,title:o="实装列表",emptyText:_="暂无已绑定装备"}){const[D,w]=el.useState(""),J=el.useMemo(()=>s.filter(p=>`${p.name||""} ${p.code||""}`.toLowerCase().includes(D.trim().toLowerCase())),[s,D]);return f.jsxs(Fl,{title:o,className:"equipment-list",children:[f.jsxs("label",{className:"search-input",children:[f.jsx(z0,{size:17}),f.jsx("input",{type:"search","aria-label":"搜索装备名称或编号",placeholder:"搜索装备名称或编号",value:D,onChange:p=>w(p.target.value)})]}),f.jsx("div",{className:"equipment-rows",children:J.map(p=>f.jsxs("button",{type:"button",className:`equipment-row ${S===p.id?"selected":""}`,onClick:()=>N==null?void 0:N(p.id),"aria-pressed":S===p.id,children:[f.jsx(Mf,{equipment:p}),f.jsxs("span",{className:"equipment-row-copy",children:[f.jsx("strong",{children:p.name||p.code||"未命名装备"}),f.jsx("small",{children:p.code||p.participantCode||"—"})]}),f.jsx(_u,{state:p.state})]},p.id))}),!J.length&&f.jsx(xa,{title:D?"无匹配装备":_,description:D?"请调整名称或编号。":"绑定装备后将在此显示。"})]})}function Df({equipment:s,statusLabel:S}){return s?f.jsxs(Fl,{className:"equipment-header",children:[f.jsxs("div",{className:"equipment-heading",children:[f.jsx(Mf,{equipment:s,size:46}),f.jsxs("div",{children:[f.jsx("h2",{children:s.name||s.code||"未命名装备"}),f.jsxs("p",{children:[s.code||"—",f.jsx("span",{children:"·"}),s.type||"实装装备"]})]}),f.jsx(_u,{state:s.state,label:S})]}),f.jsxs("div",{className:"equipment-updated",children:["最近接收 ",f.jsx("time",{children:wn(s.lastReceivedAt)})]})]}):null}function wn(s){if(s==null||s==="")return"—";const S=new Date(s);return Number.isNaN(S.getTime())?"—":new Intl.DateTimeFormat("zh-CN",{timeZone:"Asia/Shanghai",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}).format(S)}const $h=s=>(s==null?void 0:s.simulated)===!0||/SIMULATED|模拟|演示/i.test([s==null?void 0:s.quality,s==null?void 0:s.sourceType,s==null?void 0:s.sourceName].join(" "));function Ur(s){return(Array.isArray(s==null?void 0:s.equipment)?s.equipment:[]).filter(S=>!$h(S)).map(S=>({...S,id:String(S.id||S.equipmentId||""),items:(Array.isArray(S.items)?S.items:[]).filter(N=>!$h(N))})).filter(S=>S.id)}function Wh(s,S=""){if(s==null||s===""||typeof s=="object")return"—";const N=typeof s=="boolean"?s?"是":"否":String(s);return S?`${N} ${S}`:N}function Cr(s,S){return S?"ERROR":["CONNECTED","STALE","WAITING"].includes(s==null?void 0:s.state)?s.state:"WAITING"}function Hr(s,S){if(s)return S==="CONNECTED"?s:{...s,state:s.lastReceivedAt?"STALE":"NOT_RECEIVED",items:(s.items||[]).map(N=>["GOOD","VALID"].includes(N.quality)?{...N,quality:"STALE"}:N)}}const Rr={NUMBER:"数值数据",STATE:"状态数据",IMAGE:"图像数据"};function Br({equipments:s,selectedId:S,onSelect:N}){const o=s.find(p=>p.id===S),_=(o==null?void 0:o.items)||[],[D,w]=el.useState("");el.useEffect(()=>w(""),[S]);const J=_.find(p=>p.key===D)||_[0];return f.jsxs("div",{className:"equipment-layout",children:[f.jsx(Of,{equipments:s,selectedId:S,onSelect:N}),f.jsx("div",{className:"detail-stack",children:o?f.jsxs(f.Fragment,{children:[f.jsx(Df,{equipment:o}),f.jsxs(Fl,{title:"数据接收情况",extra:f.jsx("span",{className:"muted",children:o.participantCode||""}),children:[f.jsx("div",{className:"table-scroll",children:f.jsxs("table",{className:"data-table receive-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{children:"数据类型 / 参数"}),f.jsx("th",{children:"最新数据"}),f.jsx("th",{children:"接收时间"}),f.jsx("th",{children:"接收状态"})]})}),f.jsx("tbody",{children:_.map((p,x)=>f.jsxs("tr",{className:J===p?"selected":"",onClick:()=>w(p.key),tabIndex:0,"aria-selected":J===p,onKeyDown:V=>{(V.key==="Enter"||V.key===" ")&&(V.preventDefault(),w(p.key))},children:[f.jsxs("td",{children:[f.jsx("strong",{children:p.label||p.key}),f.jsx("small",{children:Rr[p.type]||p.type||"采集数据"})]}),f.jsx("td",{className:"parameter-value",children:Wh(p.value,p.unit)}),f.jsx("td",{children:f.jsx("time",{children:wn(p.receivedAt)})}),f.jsx("td",{children:f.jsx(_u,{state:p.quality})})]},`${p.key}-${x}`))})]})}),!_.length&&f.jsx(xa,{title:"尚未收到该装备的数据",description:"采集数据到达后将在此显示。",icon:Af})]}),f.jsx(Fl,{title:J?`${J.label||J.key}详情`:"数据详情",children:J?f.jsxs("dl",{className:"value-details",children:[f.jsxs("div",{children:[f.jsx("dt",{children:"当前值"}),f.jsx("dd",{children:Wh(J.value,J.unit)})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"数据来源"}),f.jsx("dd",{children:J.sourceName||"—"})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"采样时间"}),f.jsx("dd",{children:wn(J.sampleTime)})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"接收时间"}),f.jsx("dd",{children:wn(J.receivedAt)})]}),f.jsxs("div",{children:[f.jsx("dt",{children:"数据质量"}),f.jsx("dd",{children:f.jsx(_u,{state:J.quality})})]})]}):f.jsx(xa,{title:"暂无参数详情",description:"选择已接收参数查看其来源和时间。"})})]}):f.jsx(Fl,{className:"unselected-panel",children:f.jsx(xa,{title:"等待装备采集数据",description:"已绑定的实装及其采集数据同步后将在此显示。",icon:Af})})})]})}function Yr({equipments:s,selectedId:S,onSelect:N}){const o=s.find(_=>_.id===S);return f.jsxs("div",{className:"equipment-layout",children:[f.jsx(Of,{equipments:s,selectedId:S,onSelect:N}),f.jsxs("div",{className:"detail-stack",children:[o&&f.jsx(Df,{equipment:{...o,state:"NOT_RECEIVED"},statusLabel:"同步指标未接入"}),f.jsx(Fl,{title:"当前同步指标",children:f.jsx("div",{className:"sync-values",children:[["同步延迟","ms"],["数据丢包率","%"],["同步偏差","ms"]].map(([_,D])=>f.jsxs("div",{children:[f.jsx("span",{children:_}),f.jsxs("strong",{children:["— ",f.jsx("small",{children:D})]}),f.jsx("p",{children:"未接入"})]},_))})}),f.jsx(Fl,{title:"同步质量趋势",children:f.jsx(xa,{title:"暂无同步质量数据",description:"同步质量数据接入后,可查看所选装备的指标与趋势。",icon:pu})}),f.jsxs(Fl,{title:"数据同步状态",children:[f.jsx("div",{className:"table-scroll",children:f.jsx("table",{className:"data-table",children:f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{children:"数据类型"}),f.jsx("th",{children:"数据时间"}),f.jsx("th",{children:"同步偏差"}),f.jsx("th",{children:"同步状态"})]})})})}),f.jsx(xa,{title:"暂无同步状态"})]})]})]})}const xf=[["帧率","fps"],["码率","Mbps"],["丢帧率","%"],["同步偏差","ms"],["网络带宽","Mbps"],["运行时长","min"]];function kn(s){return String((s==null?void 0:s.equipmentId)??(s==null?void 0:s.id)??(s==null?void 0:s.code)??"")}function Fh(s){return(s==null?void 0:s.equipmentName)||(s==null?void 0:s.name)||(s==null?void 0:s.code)||kn(s)||"未命名装备"}function pf(s){return(s==null?void 0:s.equipmentCode)||(s==null?void 0:s.code)||kn(s)||"—"}function Uf({value:s,onChange:S,placeholder:N,label:o}){return f.jsxs("label",{className:"secondary-search",children:[f.jsx(z0,{size:17,"aria-hidden":"true"}),f.jsx("input",{"aria-label":o||N,value:s,onChange:_=>S(_.target.value),placeholder:N,type:"search"})]})}function Oe({unit:s}){return f.jsxs("span",{className:"secondary-blank",children:["—",s&&f.jsx("small",{children:s})]})}function Cf({children:s}){return f.jsxs("span",{className:"secondary-unavailable",children:[f.jsx("i",{}),s]})}function Hf({colSpan:s,title:S,description:N,icon:o}){return f.jsx("tr",{children:f.jsx("td",{colSpan:s,className:"secondary-empty-cell",children:f.jsx(xa,{title:S,description:N,icon:o})})})}function qr(){const[s,S]=el.useState("");return f.jsxs("div",{className:"secondary-equipment-layout virtual-page",children:[f.jsxs(Fl,{title:"虚拟装备列表",className:"virtual-list-panel",children:[f.jsx("div",{className:"secondary-list-search",children:f.jsx(Uf,{value:s,onChange:S,placeholder:"搜索虚拟装备名称或编号"})}),f.jsx("div",{className:"virtual-list-empty",children:f.jsx(xa,{title:s.trim()?"未找到匹配的虚拟装备":"暂无虚拟装备",description:"接入虚拟装备后,可在此选择查看。",icon:pu})})]}),f.jsxs("div",{className:"secondary-detail-stack",children:[f.jsxs("div",{className:"virtual-unselected-header",children:[f.jsx("span",{className:"virtual-equipment-icon",children:f.jsx(pu,{size:32,"aria-hidden":"true"})}),f.jsxs("div",{children:[f.jsx("h2",{children:"未选择虚拟装备"}),f.jsxs("p",{children:["装备编号 ",f.jsx("span",{children:"—"})]})]}),f.jsx(Cf,{children:"暂无状态"})]}),f.jsx(Fl,{title:"当前装备状态",className:"virtual-state-panel",children:f.jsxs("div",{className:"virtual-state-body",children:[f.jsxs("div",{className:"virtual-health-row",children:[f.jsx(wh,{size:31,"aria-hidden":"true"}),f.jsx("span",{className:"virtual-health-label",children:"血量"}),f.jsx("strong",{children:"— / —"}),f.jsx("div",{className:"virtual-health-track","aria-label":"暂无血量数据"}),f.jsx("span",{className:"secondary-muted",children:"—"})]}),f.jsx("div",{className:"virtual-position-grid",children:[["经度","°"],["速度","m/s"],["纬度","°"],["航向角","°"],["高程","m"],["动作状态",""]].map(([N,o])=>f.jsxs("div",{className:"virtual-position-item",children:[f.jsx("span",{children:N}),f.jsx(Oe,{unit:o})]},N))})]})}),f.jsx(Fl,{title:"虚拟装备状态一览",className:"virtual-overview-panel",children:f.jsx("div",{className:"secondary-table-scroll",children:f.jsxs("table",{className:"secondary-table virtual-overview-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{scope:"col",children:"虚拟装备"}),f.jsx("th",{scope:"col",children:"血量"}),f.jsx("th",{scope:"col",children:"位置(经度 / 纬度)"}),f.jsx("th",{scope:"col",children:"高程"}),f.jsx("th",{scope:"col",children:"状态"})]})}),f.jsx("tbody",{children:f.jsx(Hf,{colSpan:5,title:"暂无虚拟装备状态",description:"装备接入后显示血量、位置与运行状态。",icon:wh})})]})})})]})]})}function Gr({equipments:s=[],selectedId:S,onSelect:N}){const o=s.find(_=>kn(_)===String(S??""));return f.jsxs("div",{className:"secondary-equipment-layout video-page",children:[f.jsx(Of,{equipments:s,selectedId:S,onSelect:N,title:"实装列表",emptyText:"暂无已接入的实装"}),f.jsxs("div",{className:"secondary-detail-stack video-main-column",children:[o?f.jsx(Df,{equipment:{...o,state:"NOT_RECEIVED"},statusLabel:"暂无融合视频"}):f.jsx(Fl,{children:f.jsxs("div",{className:"video-unselected-header",children:[f.jsx(Tf,{size:30,"aria-hidden":"true"}),f.jsxs("div",{children:[f.jsx("h2",{children:"未选择实装"}),f.jsx("p",{children:"从实装列表选择装备查看融合视频。"})]})]})}),f.jsx(Fl,{className:"video-main-panel",children:f.jsxs("div",{className:"video-stage",children:[f.jsxs("div",{className:"video-empty-frame",children:[f.jsx(Tf,{size:54,strokeWidth:1.5,"aria-hidden":"true"}),f.jsx("h3",{children:o?"暂无融合视频":"请选择实装"}),f.jsx("p",{children:o?"当前装备尚无可用的融合视频源。":"选择装备后查看对应的融合画面。"})]}),f.jsxs("aside",{className:"video-metrics-overlay","aria-label":"当前装备融合指标",children:[f.jsxs("div",{className:"video-overlay-heading",children:[f.jsx("strong",{children:"融合指标"}),f.jsx("span",{children:o?pf(o):"—"})]}),f.jsx("dl",{children:xf.map(([_,D])=>f.jsxs("div",{children:[f.jsx("dt",{children:_}),f.jsxs("dd",{children:["—",f.jsx("small",{children:D})]})]},_))})]}),f.jsxs("div",{className:"video-control-bar","aria-label":"视频播放控制",children:[f.jsx(Cf,{children:o?"暂无画面":"未选择装备"}),f.jsx("span",{className:"video-control-divider"}),f.jsx("button",{type:"button",disabled:!0,"aria-label":"暂停视频",title:"暂无可播放的融合视频",children:f.jsx(pr,{size:19})}),f.jsx("button",{type:"button",disabled:!0,"aria-label":"视频音量",title:"暂无可播放的融合视频",children:f.jsx(Dr,{size:20})}),f.jsx("div",{className:"video-volume-track","aria-hidden":"true"}),f.jsx("span",{className:"video-resolution",children:"分辨率 —"}),f.jsx("button",{type:"button",disabled:!0,"aria-label":"视频全屏",title:"暂无可播放的融合视频",children:f.jsx(Ar,{size:20})})]})]})})]})]})}function Qr({equipments:s=[]}){const[S,N]=el.useState(""),o=el.useMemo(()=>{const _=S.trim().toLocaleLowerCase();return _?s.filter(D=>`${Fh(D)} ${pf(D)}`.toLocaleLowerCase().includes(_)):s},[s,S]);return f.jsxs("div",{className:"fusion-metrics-page",children:[f.jsxs(Fl,{title:"装备融合指标",className:"fusion-metrics-panel",children:[f.jsx("div",{className:"secondary-panel-toolbar",children:f.jsx(Uf,{value:S,onChange:N,placeholder:"搜索装备名称或编号"})}),f.jsx("div",{className:"secondary-table-scroll",children:f.jsxs("table",{className:"secondary-table fusion-metrics-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{scope:"col",children:"装备名称"}),xf.map(([_,D])=>f.jsxs("th",{scope:"col",children:[_,f.jsxs("span",{className:"secondary-column-unit",children:["(",D,")"]})]},_)),f.jsx("th",{scope:"col",children:"状态"})]})}),f.jsx("tbody",{children:o.length?o.map((_,D)=>f.jsxs("tr",{children:[f.jsx("td",{children:f.jsxs("div",{className:"fusion-equipment-name",children:[f.jsx(Mf,{equipment:_,size:26}),f.jsxs("div",{children:[f.jsx("strong",{children:Fh(_)}),f.jsx("span",{children:pf(_)})]})]})}),xf.map(([w])=>f.jsx("td",{children:f.jsx(Oe,{})},w)),f.jsx("td",{children:f.jsx(Cf,{children:"暂无指标"})})]},`${kn(_)}-${D}`)):f.jsx(Hf,{colSpan:8,title:S.trim()?"未找到匹配的装备":"暂无实装",description:S.trim()?"请调整名称或编号后重试。":"装备接入后,将在此显示对应的融合指标。",icon:pu})})]})}),f.jsx("p",{className:"secondary-panel-note",children:"未接入融合指标的装备显示为 —。"})]}),f.jsx("p",{className:"fusion-metrics-footer",children:"可在融合视频展示页查看对应装备的画面与融合指标。"})]})}function Ih({node:s}){const S=s.online===!0?"online":s.online===!1?"offline":"unknown";return f.jsxs("span",{className:`node-state node-state-${S}`,children:[f.jsx("i",{}),S==="online"?"在线":S==="offline"?"离线":"状态未知"]})}function Xr(s){return s.nodeRole==="COORDINATOR"?"主节点":s.nodeRole==="WORKER"?"从节点":"—"}function Lr({nodes:s=[]}){const[S,N]=el.useState(""),o=el.useMemo(()=>{const _=S.trim().toLocaleLowerCase();return _?s.filter(D=>`${D.name||""} ${D.nodeId||""}`.toLocaleLowerCase().includes(_)):s},[s,S]);return f.jsxs(Fl,{title:"节点运行状态",className:"node-page",children:[f.jsx("div",{className:"secondary-panel-toolbar",children:f.jsx(Uf,{value:S,onChange:N,placeholder:"搜索节点名称或编号"})}),f.jsx("div",{className:"secondary-table-scroll",children:f.jsxs("table",{className:"secondary-table node-status-table",children:[f.jsx("thead",{children:f.jsxs("tr",{children:[f.jsx("th",{scope:"col",children:"节点名称"}),f.jsx("th",{scope:"col",children:"角色"}),f.jsxs("th",{scope:"col",children:[f.jsx(Er,{size:15,"aria-hidden":"true"}),"CPU 使用率"]}),f.jsx("th",{scope:"col",children:"内存使用情况"}),f.jsxs("th",{scope:"col",children:[f.jsx(mr,{size:15,"aria-hidden":"true"}),"网络接收"]}),f.jsxs("th",{scope:"col",children:[f.jsx(gr,{size:15,"aria-hidden":"true"}),"网络发送"]}),f.jsx("th",{scope:"col",children:"运行状态"})]})}),f.jsx("tbody",{children:o.length?o.map((_,D)=>f.jsxs("tr",{children:[f.jsx("td",{children:f.jsxs("div",{className:"node-identity",children:[f.jsx(Ef,{size:28,"aria-hidden":"true"}),f.jsxs("div",{children:[f.jsx("strong",{children:_.name||_.nodeId||"未命名节点"}),f.jsx("span",{title:_.nodeId||"",children:_.nodeId||"—"})]})]})}),f.jsx("td",{children:Xr(_)}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Oe,{})}),f.jsx("td",{children:f.jsx(Ih,{node:_})})]},_.nodeId||D)):f.jsx(Hf,{colSpan:7,title:S.trim()?"未找到匹配的节点":"暂无已注册节点",description:S.trim()?"请调整名称或编号后重试。":"节点注册后,可在此查看运行状态。",icon:Ef})})]})}),f.jsxs("section",{className:"node-network-panel","aria-label":"节点心跳连接状态",children:[f.jsxs("h3",{children:[f.jsx(y0,{size:18,"aria-hidden":"true"}),"节点心跳连接状态"]}),o.length?f.jsx("div",{className:"node-network-grid",children:o.map((_,D)=>f.jsxs("div",{className:"node-network-item",children:[f.jsx(Ef,{size:25,"aria-hidden":"true"}),f.jsxs("div",{children:[f.jsx("strong",{children:_.name||_.nodeId||"未命名节点"}),f.jsx("span",{children:_.online===!0?"心跳在线":_.online===!1?"心跳中断":"暂无心跳状态"})]}),f.jsx(Ih,{node:_})]},_.nodeId||D))}):f.jsx("p",{className:"node-network-empty",children:"暂无节点连接信息"})]}),f.jsx("p",{className:"secondary-panel-note",children:"资源使用情况与网络速率未上报时显示为 —。"})]})}const _f=[{id:"receive",name:"数据接收",description:"按装备查看接收数据与当前状态",icon:Af},{id:"sync",name:"同步质量监控",description:"按装备查看同步状态与质量指标",icon:pu},{id:"virtual",name:"虚拟数据下发",description:"查看虚拟装备的血量、位置与运行状态",icon:Mr},{id:"video",name:"融合视频展示",description:"按实装查看融合视频及当前融合指标",icon:Tf},{id:"metrics",name:"融合指标展示",description:"以装备列表查看各实装的融合指标",icon:Sr},{id:"nodes",name:"主从控制",description:"查看各节点的 CPU、内存和网络运行状态",icon:y0}],Zr={collection:{state:"WAITING",contexts:[]},nodes:[]},Ph=()=>{var s;return((s=_f.find(S=>S.id===location.hash.slice(1)))==null?void 0:s.id)||"receive"};function Vr(){var ct,Ll;const[s,S]=el.useState(Ph),[N,o]=el.useState(Zr),[_,D]=el.useState(""),[w,J]=el.useState(""),[p,x]=el.useState(!1),[V,B]=el.useState(!0),[ul,Tl]=el.useState(0),[nl,gl]=el.useState(!1),[Rl,_l]=el.useState(Date.now());el.useEffect(()=>{const Q=()=>{S(Ph()),window.scrollTo(0,0)};return window.addEventListener("hashchange",Q),()=>window.removeEventListener("hashchange",Q)},[]),el.useEffect(()=>{const Q=setInterval(()=>_l(Date.now()),1e3);return()=>clearInterval(Q)},[]),el.useEffect(()=>{let Q=!1,gt,ql;const E=async()=>{var q;ql=new AbortController;const M=setTimeout(()=>ql.abort(),6e3);try{const cl=await fetch("/console/api/snapshot",{signal:ql.signal,credentials:"same-origin",cache:"no-store",headers:{Accept:"application/json"}});if(!cl.ok)throw new Error("snapshot");const ll=await cl.json();if(!ll||!Array.isArray((q=ll.collection)==null?void 0:q.contexts)||!Array.isArray(ll.nodes))throw new Error("shape");Q||(o(ll),x(!1))}catch{Q||x(!0)}finally{clearTimeout(M),Q||(B(!1),gt=setTimeout(E,2e3))}};return E(),()=>{Q=!0,clearTimeout(gt),ql==null||ql.abort()}},[ul]);const Cl=Cr(N.collection,p),bl=N.collection.contexts||[],Nl=bl.find(Q=>Q.id===_)||bl[0],Xl=el.useMemo(()=>Ur(Nl).map(Q=>Hr(Q,Cl)),[Nl,Cl]),Bl=((ct=Xl.find(Q=>Q.id===w))==null?void 0:ct.id)||((Ll=Xl[0])==null?void 0:Ll.id)||"",Y=_f.find(Q=>Q.id===s);el.useEffect(()=>{document.title=`${Y.name} · 系统融合软件`},[Y.name]);const Yl=el.useCallback(Q=>J(Q),[]),Il={equipments:Xl,selectedId:Bl,onSelect:Yl},Pt=new Intl.DateTimeFormat("zh-CN",{timeZone:"Asia/Shanghai",year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}).format(new Date(Rl));return f.jsxs("div",{className:`app ${nl?"nav-collapsed":""}`,children:[f.jsxs("aside",{className:"sidebar",children:[f.jsxs("div",{className:"brand",children:[f.jsx("img",{src:"/console/assets/brand.svg",alt:""}),f.jsx("strong",{children:"系统融合软件"}),f.jsx("button",{className:"collapse-toggle",type:"button","aria-label":"切换侧栏",onClick:()=>gl(Q=>!Q),children:f.jsx(xr,{size:16})})]}),f.jsx("nav",{"aria-label":"功能导航",children:_f.map(Q=>f.jsxs("a",{href:`#${Q.id}`,className:`nav-link ${Q.id===Y.id?"active":""}`,title:Q.name,"aria-current":Q.id===Y.id?"page":void 0,children:[f.jsx(Q.icon,{size:23,strokeWidth:1.6}),f.jsx("span",{children:Q.name})]},Q.id))}),f.jsxs("div",{className:"sidebar-foot",children:[f.jsx(jr,{size:22}),f.jsx("span",{children:"运行监测"})]})]}),f.jsxs("div",{className:"main-shell",children:[f.jsxs("header",{className:"topbar",children:[f.jsxs("div",{className:"breadcrumb",children:[f.jsx("span",{children:"系统融合软件"}),f.jsx(br,{size:14}),f.jsx("strong",{children:Y.name})]}),f.jsxs("div",{className:"service-state",children:[f.jsx(_u,{state:p?"ERROR":V?"WAITING":"CONNECTED",label:p?"服务连接中断":V?"正在连接":"服务连接正常"}),f.jsx("time",{children:Pt})]})]}),f.jsxs("main",{children:[f.jsxs("div",{className:"page-heading",children:[f.jsxs("div",{children:[f.jsx("h1",{children:Y.name}),f.jsx("p",{children:Y.description})]}),f.jsxs("div",{className:"heading-actions",children:[bl.length>0&&f.jsxs("label",{className:"context-select",children:[f.jsx("span",{children:"试验"}),f.jsx("select",{"aria-label":"选择试验上下文",value:(Nl==null?void 0:Nl.id)||"",onChange:Q=>{D(Q.target.value),J("")},children:bl.map(Q=>f.jsx("option",{value:Q.id,children:Q.name||Q.id},Q.id))})]}),f.jsxs("button",{className:"refresh-button",type:"button",onClick:()=>Tl(Q=>Q+1),"aria-label":"刷新数据",title:"刷新数据",children:[f.jsx(Nr,{size:17}),f.jsx("span",{children:"刷新"})]})]})]}),(p||Cl==="STALE")&&f.jsxs("div",{className:"source-notice",role:"status",children:[f.jsx(zr,{size:17}),p?"服务暂时不可用,当前显示最后一次收到的数据。":"采集数据同步已超时,当前显示最后一次收到的数据。"]}),Y.id==="receive"&&f.jsx(Br,{...Il}),Y.id==="sync"&&f.jsx(Yr,{...Il}),Y.id==="virtual"&&f.jsx(qr,{}),Y.id==="video"&&f.jsx(Gr,{...Il}),Y.id==="metrics"&&f.jsx(Qr,{equipments:Xl}),Y.id==="nodes"&&f.jsx(Lr,{nodes:N.nodes||[]})]}),f.jsxs("footer",{className:"app-footer",children:[f.jsx("span",{children:"系统融合软件"}),f.jsx("span",{children:Cl==="WAITING"?"等待采集数据接入":Cl==="CONNECTED"?"采集数据自动更新":"采集数据同步中断"})]})]})]})}nr.createRoot(document.getElementById("root")).render(f.jsx(Iy.StrictMode,{children:f.jsx(Vr,{})}));
diff --git a/src/main/resources/static/console/index.html b/src/main/resources/static/console/index.html
new file mode 100644
index 0000000..c4ad3f2
--- /dev/null
+++ b/src/main/resources/static/console/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+ 系统融合软件
+
+
+
+
+
diff --git a/src/test/java/com/xtrh/communication/client/PeerClientTest.java b/src/test/java/com/xtrh/communication/client/PeerClientTest.java
new file mode 100644
index 0000000..43b8d82
--- /dev/null
+++ b/src/test/java/com/xtrh/communication/client/PeerClientTest.java
@@ -0,0 +1,69 @@
+package com.xtrh.communication.client;
+
+import com.xtrh.discovery.service.Discovery;
+import com.xtrh.common.util.Jsons;
+import com.xtrh.common.config.Settings;
+import com.xtrh.communication.security.Signatures;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.env.MockEnvironment;
+
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static com.xtrh.common.util.Jsons.object;
+import static org.junit.jupiter.api.Assertions.*;
+
+class PeerClientTest {
+ private static final String TOKEN = "peer-unit-test-credential-not-deployed";
+ @TempDir Path directory;
+
+ @Test void signsRequestsWithoutDisclosingCredentialAndRejectsForgedResponses() throws Exception {
+ Settings settings = new Settings(new MockEnvironment().withProperty("fusion.token", TOKEN)
+ .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false"));
+ PeerClient peer = new PeerClient(settings, new ObjectMapper());
+ AtomicReference mode = new AtomicReference<>("valid");
+ AtomicBoolean verifiedRequest = new AtomicBoolean();
+ AtomicBoolean leakedCredential = new AtomicBoolean();
+ HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/control/info", exchange -> {
+ byte[] requestBody = exchange.getRequestBody().readAllBytes();
+ String nonce = exchange.getRequestHeaders().getFirst("X-Xtrh-Nonce");
+ String timestamp = exchange.getRequestHeaders().getFirst("X-Xtrh-Time");
+ verifiedRequest.set(Signatures.equal(Signatures.request(TOKEN, exchange.getRequestMethod(), exchange.getRequestURI().toString(),
+ timestamp, nonce, requestBody), exchange.getRequestHeaders().getFirst("X-Xtrh-Signature")));
+ leakedCredential.set(exchange.getRequestHeaders().values().stream().flatMap(v -> v.stream()).anyMatch(v -> v.contains(TOKEN)));
+ byte[] body = "{\"clusterId\":\"test-cluster\",\"storageHealthy\":true}".getBytes(StandardCharsets.UTF_8);
+ String signature = switch (mode.get()) {
+ case "wrong-token" -> Signatures.response("forged-server-credential", nonce, 200, body);
+ case "wrong-nonce" -> Signatures.response(TOKEN, "nonce-from-previous-response", 200, body);
+ case "wrong-body" -> Signatures.response(TOKEN, nonce, 200, new byte[0]);
+ case "wrong-status" -> Signatures.response(TOKEN, nonce, 500, body);
+ default -> Signatures.response(TOKEN, nonce, 200, body);
+ };
+ if (!mode.get().equals("unsigned")) exchange.getResponseHeaders().set("X-Xtrh-Response-Signature", signature);
+ exchange.sendResponseHeaders(200, body.length);
+ exchange.getResponseBody().write(body);
+ exchange.close();
+ });
+ server.start();
+ try {
+ String baseUrl = "http://127.0.0.1:" + server.getAddress().getPort();
+ assertEquals("test-cluster", peer.request(baseUrl, "/control/info", "POST", object().put("value", "中文正文")).path("clusterId").asText());
+ assertTrue(verifiedRequest.get());
+ assertFalse(leakedCredential.get());
+ for (String forgery : new String[]{"unsigned", "wrong-token", "wrong-nonce", "wrong-body", "wrong-status"}) {
+ mode.set(forgery);
+ IllegalStateException failure = assertThrows(IllegalStateException.class,
+ () -> peer.request(baseUrl, "/control/info", "GET", null), forgery);
+ assertEquals("Peer response authentication failed", failure.getMessage());
+ }
+ } finally { server.stop(0); }
+ }
+}
diff --git a/src/test/java/com/xtrh/communication/security/ServiceSecurityTest.java b/src/test/java/com/xtrh/communication/security/ServiceSecurityTest.java
new file mode 100644
index 0000000..7c79da6
--- /dev/null
+++ b/src/test/java/com/xtrh/communication/security/ServiceSecurityTest.java
@@ -0,0 +1,97 @@
+package com.xtrh.communication.security;
+
+import com.xtrh.discovery.service.Discovery;
+import com.xtrh.common.config.Settings;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ServiceSecurityTest {
+ private static final String TOKEN = "security-unit-test-credential-only";
+ @TempDir Path directory;
+
+ @Test void authenticatedRequestPreservesBodyAndSignsExactResponseBytes() throws Exception {
+ ServiceSecurity security = security();
+ byte[] input = "{\"name\":\"融合电脑A\",\"capacity\":4}".getBytes(StandardCharsets.UTF_8);
+ MockHttpServletRequest request = signed("POST", "/control/nodes/register", input, UUID.randomUUID().toString());
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ byte[] output = "{\"result\":\"已登记\"}".getBytes(StandardCharsets.UTF_8);
+ security.doFilter(request, response, (req, res) -> {
+ assertArrayEquals(input, req.getInputStream().readAllBytes());
+ ((HttpServletResponse) res).setStatus(201);
+ res.getOutputStream().write(output);
+ });
+ assertEquals(201, response.getStatus());
+ assertArrayEquals(output, response.getContentAsByteArray());
+ assertTrue(Signatures.equal(Signatures.response(TOKEN, request.getHeader("X-Xtrh-Nonce"), 201, output),
+ response.getHeader("X-Xtrh-Response-Signature")));
+ assertNull(request.getHeader("X-Xtrh-Token"));
+ }
+
+ @Test void replayOfAnAcceptedNonceIsRejectedBeforeExecutingOperation() throws Exception {
+ ServiceSecurity security = security();
+ String nonce = UUID.randomUUID().toString();
+ AtomicInteger operations = new AtomicInteger();
+ FilterChain chain = (req, res) -> operations.incrementAndGet();
+ MockHttpServletResponse first = new MockHttpServletResponse();
+ security.doFilter(signed("PATCH", "/control/nodes/node-aaaa/capacity", new byte[]{1, 2}, nonce), first, chain);
+ assertEquals(200, first.getStatus());
+ MockHttpServletResponse replay = new MockHttpServletResponse();
+ security.doFilter(signed("PATCH", "/control/nodes/node-aaaa/capacity", new byte[]{1, 2}, nonce), replay, chain);
+ assertEquals(401, replay.getStatus());
+ assertEquals(1, operations.get());
+ }
+
+ @Test void alteredBodyPathQueryMethodAndExpiredTimeAreRejected() throws Exception {
+ ServiceSecurity security = security();
+ byte[] input = "{\"capacity\":2}".getBytes(StandardCharsets.UTF_8);
+ for (String alteration : new String[]{"body", "path", "query", "method", "expired"}) {
+ MockHttpServletRequest request = signed("PATCH", "/control/nodes/node-aaaa/capacity", input, UUID.randomUUID().toString());
+ switch (alteration) {
+ case "body" -> request.setContent("{\"capacity\":64}".getBytes(StandardCharsets.UTF_8));
+ case "path" -> request.setRequestURI("/control/nodes/node-bbbb/capacity");
+ case "query" -> request.setQueryString("override=true");
+ case "method" -> request.setMethod("PUT");
+ case "expired" -> {
+ String oldTime = Long.toString(System.currentTimeMillis() - 120_000);
+ request.removeHeader("X-Xtrh-Time");
+ request.removeHeader("X-Xtrh-Signature");
+ request.addHeader("X-Xtrh-Time", oldTime);
+ request.addHeader("X-Xtrh-Signature", Signatures.request(TOKEN, request.getMethod(), request.getRequestURI(),
+ oldTime, request.getHeader("X-Xtrh-Nonce"), input));
+ }
+ }
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ security.doFilter(request, response, (req, res) -> fail("Tampered request executed: " + alteration));
+ assertEquals(401, response.getStatus(), alteration);
+ }
+ }
+
+ private ServiceSecurity security() throws Exception {
+ return new ServiceSecurity(new Settings(new MockEnvironment().withProperty("fusion.token", TOKEN)
+ .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false")));
+ }
+
+ private MockHttpServletRequest signed(String method, String path, byte[] bytes, String nonce) {
+ String timestamp = Long.toString(System.currentTimeMillis());
+ MockHttpServletRequest request = new MockHttpServletRequest(method, path);
+ request.setContent(bytes);
+ request.addHeader("X-Xtrh-Time", timestamp);
+ request.addHeader("X-Xtrh-Nonce", nonce);
+ request.addHeader("X-Xtrh-Signature", Signatures.request(TOKEN, method, path, timestamp, nonce, bytes));
+ return request;
+ }
+}
diff --git a/src/test/java/com/xtrh/console/controller/ConsoleControllerTest.java b/src/test/java/com/xtrh/console/controller/ConsoleControllerTest.java
new file mode 100644
index 0000000..f4d261f
--- /dev/null
+++ b/src/test/java/com/xtrh/console/controller/ConsoleControllerTest.java
@@ -0,0 +1,70 @@
+package com.xtrh.console.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.console.service.ConsoleCollectionStore;
+import com.xtrh.common.config.Settings;
+import com.xtrh.control.service.CoordinatorService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+import static com.xtrh.common.util.Jsons.*;
+import static org.junit.jupiter.api.Assertions.*;
+import java.nio.file.Path;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+class ConsoleControllerTest {
+ @TempDir Path directory;
+ private CoordinatorService coordinator;
+
+ @AfterEach void close() throws Exception { if (coordinator != null) coordinator.close(); }
+
+ @Test void exposesOnlyWhitelistedNodeFieldsAndKeepsUnavailableResourceTelemetryNull() throws Exception {
+ ObjectNode node = object().put("nodeId", "node-a").put("name", "节点 A").put("nodeRole", "WORKER")
+ .put("online", true).put("lastHeartbeat", 1000).put("sessionId", "must-not-leak")
+ .put("baseUrl", "http://private-node:18120").put("capacity", 3).put("mediaKey", "must-not-leak");
+ ObjectNode internalSnapshot = object().set("nodes", array().add(node));
+ coordinator = new CoordinatorService(settings(), new ObjectMapper()) {
+ @Override public ObjectNode snapshot() { return internalSnapshot; }
+ };
+ ConsoleController controller = new ConsoleController(new ConsoleCollectionStore(new ObjectMapper()), coordinator);
+ ObjectNode result = controller.snapshot();
+ assertEquals("WAITING", result.path("collection").path("state").asText());
+ assertEquals(0, result.path("collection").path("contexts").size());
+ assertEquals("node-a", result.path("nodes").get(0).path("nodeId").asText());
+ assertTrue(result.path("nodes").get(0).path("cpu").isNull());
+ assertTrue(result.path("nodes").get(0).path("memory").isNull());
+ assertTrue(result.path("nodes").get(0).path("network").isNull());
+ assertFalse(result.toString().contains("must-not-leak"));
+ assertFalse(result.toString().contains("private-node"));
+ assertFalse(result.toString().contains("capacity"));
+ }
+
+ @Test void collectionPushDoesNotCallCoordinatorBusinessOperationsAndRootRoutesToNativeResources() throws Exception {
+ coordinator = new CoordinatorService(settings(), new ObjectMapper());
+ ObjectNode before = coordinator.snapshot();
+ ConsoleCollectionStore collection = new ConsoleCollectionStore(new ObjectMapper());
+ MockMvc mvc = MockMvcBuilders.standaloneSetup(new ConsoleController(collection, coordinator),
+ new ConsoleCollectionController(collection)).build();
+ mvc.perform(post("/control/collection/sync").contentType("application/json")
+ .content(object().put("source", "PXHJ").put("observedAt", System.currentTimeMillis()).set("contexts", array()).toString()))
+ .andExpect(status().isOk()).andExpect(jsonPath("$.accepted").value(true));
+ assertEquals("CONNECTED", collection.snapshot().path("state").asText());
+ ObjectNode after = coordinator.snapshot();
+ before.remove("serverTime");
+ after.remove("serverTime");
+ assertEquals(before, after, "Collection display must not alter persisted scheduling state");
+ mvc.perform(get("/console")).andExpect(status().is3xxRedirection()).andExpect(redirectedUrl("/console/"));
+ mvc.perform(get("/console/")).andExpect(status().isOk()).andExpect(forwardedUrl("/console/index.html"));
+ }
+
+ private Settings settings() throws Exception {
+ return new Settings(new MockEnvironment().withProperty("fusion.token", "console-controller-unit-test-credential")
+ .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false"));
+ }
+}
diff --git a/src/test/java/com/xtrh/console/security/ConsoleSecurityTest.java b/src/test/java/com/xtrh/console/security/ConsoleSecurityTest.java
new file mode 100644
index 0000000..9afbf69
--- /dev/null
+++ b/src/test/java/com/xtrh/console/security/ConsoleSecurityTest.java
@@ -0,0 +1,86 @@
+package com.xtrh.console.security;
+
+import com.xtrh.common.config.Settings;
+import com.xtrh.communication.security.ServiceSecurity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.env.MockEnvironment;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+import java.nio.file.Path;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ConsoleSecurityTest {
+ @TempDir Path directory;
+
+ @Test void localSameOriginGetAndHeadAreReadOnlyAndDoNotExposeServiceCredential() throws Exception {
+ ServiceSecurity security = security("coordinator", true);
+ for (String method : new String[]{"GET", "HEAD"}) {
+ MockHttpServletRequest request = request(method, "/console/api/snapshot");
+ request.addHeader("Origin", "http://127.0.0.1:18100");
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ AtomicInteger calls = new AtomicInteger();
+ security.doFilter(request, response, (req, res) -> calls.incrementAndGet());
+ assertEquals(1, calls.get());
+ assertEquals(200, response.getStatus());
+ assertEquals("no-store", response.getHeader("Cache-Control"));
+ assertNull(response.getHeader("X-Xtrh-Response-Signature"));
+ }
+ }
+
+ @Test void remoteHostSpoofCrossOriginAndWriteRequestsAreRejectedBeforeController() throws Exception {
+ ServiceSecurity security = security("coordinator", true);
+ for (String attack : new String[]{"remote", "host", "port", "origin", "null-origin", "fetch-site", "write", "encoded", "traversal"}) {
+ MockHttpServletRequest request = request("GET", "/console/api/snapshot");
+ switch (attack) {
+ case "remote" -> request.setRemoteAddr("192.168.1.25");
+ case "host" -> { request.removeHeader("Host"); request.addHeader("Host", "attacker.example:18100"); }
+ case "port" -> { request.removeHeader("Host"); request.addHeader("Host", "127.0.0.1:9999"); }
+ case "origin" -> request.addHeader("Origin", "http://localhost:18100");
+ case "null-origin" -> request.addHeader("Origin", "null");
+ case "fetch-site" -> request.addHeader("Sec-Fetch-Site", "cross-site");
+ case "write" -> request.setMethod("POST");
+ case "encoded" -> request.setRequestURI("/console/%2e%2e/control/snapshot");
+ case "traversal" -> request.setRequestURI("/console/../control/snapshot");
+ }
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ security.doFilter(request, response, (req, res) -> fail("Attack reached controller: " + attack));
+ assertEquals(403, response.getStatus(), attack);
+ }
+ }
+
+ @Test void staticConsoleReadsDoNotOpenInternalControlOrDisabledRoles() throws Exception {
+ ServiceSecurity security = security("coordinator", true);
+ MockHttpServletResponse page = new MockHttpServletResponse();
+ security.doFilter(request("GET", "/console/index.html"), page, (req, res) -> res.getWriter().write("page"));
+ assertEquals("page", page.getContentAsString());
+ for (String path : new String[]{"/control/snapshot", "/control/collection/sync", "/consolex/api/snapshot"}) {
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ security.doFilter(request("GET", path), response, (req, res) -> fail("Internal API bypassed HMAC"));
+ assertEquals(401, response.getStatus());
+ }
+ for (ServiceSecurity closed : new ServiceSecurity[]{security("worker", true), security("coordinator", false)}) {
+ MockHttpServletResponse response = new MockHttpServletResponse();
+ closed.doFilter(request("GET", "/console/api/snapshot"), response, (req, res) -> fail("Console unexpectedly enabled"));
+ assertEquals(401, response.getStatus());
+ }
+ }
+
+ private ServiceSecurity security(String role, boolean enabled) throws Exception {
+ return new ServiceSecurity(new Settings(new MockEnvironment()
+ .withProperty("fusion.token", "console-security-unit-test-credential")
+ .withProperty("fusion.role", role).withProperty("fusion.discovery-enabled", "false")
+ .withProperty("fusion.data-dir", directory.resolve(role).toString())), enabled);
+ }
+
+ private MockHttpServletRequest request(String method, String path) {
+ MockHttpServletRequest request = new MockHttpServletRequest(method, path);
+ request.setRemoteAddr("127.0.0.1");
+ request.setLocalPort(18100);
+ request.addHeader("Host", "127.0.0.1:18100");
+ return request;
+ }
+}
diff --git a/src/test/java/com/xtrh/console/service/ConsoleCollectionStoreTest.java b/src/test/java/com/xtrh/console/service/ConsoleCollectionStoreTest.java
new file mode 100644
index 0000000..6eff5c1
--- /dev/null
+++ b/src/test/java/com/xtrh/console/service/ConsoleCollectionStoreTest.java
@@ -0,0 +1,112 @@
+package com.xtrh.console.service;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+import static com.xtrh.common.util.Jsons.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ConsoleCollectionStoreTest {
+ private final AtomicLong time = new AtomicLong(1_000_000);
+ private final ConsoleCollectionStore store = new ConsoleCollectionStore(new ObjectMapper(), time::get);
+
+ @Test void startsEmptyAndBecomesStaleWithoutReplacingLastRealValues() {
+ assertEquals("WAITING", store.snapshot().path("state").asText());
+ assertTrue(store.snapshot().path("lastSyncedAt").isNull());
+ store.sync(input());
+ assertEquals("CONNECTED", store.snapshot().path("state").asText());
+ assertEquals(0, firstItem().path("value").asInt());
+ time.addAndGet(15_001);
+ assertEquals("STALE", store.snapshot().path("state").asText());
+ assertEquals("speed", firstItem().path("key").asText());
+ assertEquals(1_000_000, store.snapshot().path("lastSyncedAt").asLong());
+ ObjectNode fresh = input();
+ fresh.put("observedAt", time.get());
+ store.sync(fresh);
+ assertEquals("CONNECTED", store.snapshot().path("state").asText());
+ }
+
+ @Test void stripsPrivateFieldsAndFiltersSimulatedAndCredentialItems() {
+ ObjectNode input = input().put("token", "must-not-leak");
+ ObjectNode equipment = (ObjectNode) input.path("contexts").get(0).path("equipment").get(0);
+ equipment.put("mediaKey", "must-not-leak");
+ ArrayNode items = (ArrayNode) equipment.path("items");
+ ((ObjectNode) items.get(0)).put("keyAlias", "must-not-leak");
+ items.add(item("fake", 50).put("quality", "SIMULATED"));
+ items.add(item("fake2", 50).put("sourceName", "本地模拟"));
+ items.add(item("fake3", 50).put("sourceType", "DEMO"));
+ items.add(item("fake4", 50).put("simulated", true));
+ items.add(item("mediaKey", 123));
+ store.sync(input);
+ assertEquals(1, store.snapshot().path("contexts").get(0).path("equipment").get(0).path("items").size());
+ assertFalse(store.snapshot().toString().contains("must-not-leak"));
+ assertFalse(store.snapshot().toString().contains("mediaKey"));
+ ObjectNode copy = store.snapshot();
+ ((ArrayNode) copy.path("contexts")).removeAll();
+ assertEquals(1, store.snapshot().path("contexts").size());
+ }
+
+ @Test void invalidOrOutOfOrderPayloadDoesNotEraseAcceptedSnapshot() {
+ store.sync(input());
+ ObjectNode old = input().put("observedAt", time.get() - 1);
+ assertThrows(IllegalArgumentException.class, () -> store.sync(old));
+ ObjectNode future = input().put("observedAt", time.get() + 60_001);
+ assertThrows(IllegalArgumentException.class, () -> store.sync(future));
+ ObjectNode nested = input();
+ ((ObjectNode) nested.path("contexts").get(0).path("equipment").get(0).path("items").get(0))
+ .set("value", object().put("secret", "hidden"));
+ assertThrows(IllegalArgumentException.class, () -> store.sync(nested));
+ assertThrows(IllegalArgumentException.class, () -> store.sync(input().put("source", "OTHER")));
+ assertThrows(IllegalArgumentException.class, () -> store.sync(input().put("simulated", true)));
+ assertEquals("speed", firstItem().path("key").asText());
+ }
+
+ @Test void rejectsExcessiveFieldsCountsAndSizeBeforeReplacingState() {
+ store.sync(input());
+ ObjectNode longName = input();
+ ((ObjectNode) longName.path("contexts").get(0)).put("name", "x".repeat(201));
+ assertThrows(IllegalArgumentException.class, () -> store.sync(longName));
+ ObjectNode excessive = input();
+ ArrayNode contexts = (ArrayNode) excessive.path("contexts");
+ for (int i = 0; i < 32; i++) contexts.add(object().put("id", "context-" + i).set("equipment", array()));
+ assertThrows(IllegalArgumentException.class, () -> store.sync(excessive));
+ ObjectNode large = input().put("privateExtra", "x".repeat(1_500_000));
+ assertThrows(IllegalArgumentException.class, () -> store.sync(large));
+ assertEquals(1, store.snapshot().path("contexts").size());
+ }
+
+ @Test void nullUnknownQualityAndMissingSignalRemainUnavailableRatherThanBecomingZero() {
+ ObjectNode input = input();
+ ObjectNode item = (ObjectNode) input.path("contexts").get(0).path("equipment").get(0).path("items").get(0);
+ item.putNull("value").putNull("receivedAt").put("quality", "UNRECOGNIZED");
+ store.sync(input);
+ assertTrue(firstItem().path("value").isNull());
+ assertTrue(firstItem().path("receivedAt").isNull());
+ assertEquals("UNKNOWN", firstItem().path("quality").asText());
+ }
+
+ private ObjectNode input() {
+ ObjectNode result = object().put("source", "PXHJ").put("observedAt", time.get());
+ ObjectNode equipment = object().put("id", "equipment-a").put("participantCode", "p-1")
+ .put("name", "真实装备").put("code", "E-01").put("state", "ONLINE").put("lastReceivedAt", time.get());
+ equipment.set("items", array().add(item("speed", 0)));
+ ObjectNode context = object().put("id", "trial-1").put("name", "试验").put("roundId", "round-1");
+ context.set("equipment", array().add(equipment));
+ result.set("contexts", array().add(context));
+ return result;
+ }
+
+ private ObjectNode item(String key, int value) {
+ return object().put("key", key).put("label", "速度").put("type", "NUMBER").put("value", value)
+ .put("unit", "m/s").put("receivedAt", time.get()).put("sampleTime", time.get())
+ .put("quality", "GOOD").put("sourceName", "采集模块").put("sourceType", "FIXED_MODULE");
+ }
+
+ private com.fasterxml.jackson.databind.JsonNode firstItem() {
+ return store.snapshot().path("contexts").get(0).path("equipment").get(0).path("items").get(0);
+ }
+}
diff --git a/src/test/java/com/xtrh/control/service/CoordinatorTest.java b/src/test/java/com/xtrh/control/service/CoordinatorTest.java
new file mode 100644
index 0000000..7258462
--- /dev/null
+++ b/src/test/java/com/xtrh/control/service/CoordinatorTest.java
@@ -0,0 +1,343 @@
+package com.xtrh.control.service;
+
+import com.xtrh.common.util.Jsons;
+import com.xtrh.common.dao.StateStore;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ArrayNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicLong;
+
+import static com.xtrh.common.util.Jsons.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class CoordinatorTest {
+ private static final String CLUSTER = "test-cluster";
+ private static final String NODE_A = "node-aaaa";
+ private static final String NODE_B = "node-bbbb";
+ private final AtomicLong time = new AtomicLong(100_000);
+ private final Map sessions = new HashMap<>();
+ private final Map boots = new HashMap<>();
+ private CoordinatorService coordinator;
+
+ @TempDir Path directory;
+
+ @BeforeEach void setup() throws Exception {
+ coordinator = new CoordinatorService(new StateStore("jdbc:h2:mem:" + UUID.randomUUID(), new ObjectMapper()), time::get, 8_000, CLUSTER);
+ }
+
+ @AfterEach void close() throws Exception { if (coordinator != null) coordinator.close(); }
+
+ @Test void reservesCapacityBeforeAWorkerReportsReadyAndQueuesExcessTasks() {
+ register(NODE_A, 2);
+ coordinator.context(context(5, "RUNNING"));
+ JsonNode snapshot = coordinator.snapshot();
+ assertEquals(2, snapshot.path("nodes").get(0).path("reserved").asInt());
+ assertEquals(3, snapshot.path("summary").path("queued").asInt());
+ assertEquals(0, snapshot.path("summary").path("active").asInt());
+
+ activate(NODE_A);
+ snapshot = coordinator.snapshot();
+ assertEquals(2, snapshot.path("summary").path("active").asInt());
+ assertEquals(3, snapshot.path("summary").path("queued").asInt());
+ assertEquals(2, heartbeat(NODE_A, array()).path("tasks").size());
+
+ // 同一启动实例重复注册不能产生新节点,也不能覆盖 PXHJ 设置的容量。
+ register(NODE_A, 64);
+ assertEquals(1, coordinator.snapshot().path("nodes").size());
+ assertEquals(2, coordinator.snapshot().path("nodes").get(0).path("capacity").asInt());
+ }
+
+ @Test void migrationKeepsOldPlaybackUntilDestinationIsActuallyReady() {
+ register(NODE_A, 2);
+ register(NODE_B, 0);
+ coordinator.context(context(2, "RUNNING"));
+ activate(NODE_A);
+ Map initialUrls = playbackUrls();
+
+ coordinator.capacity(NODE_B, object().put("capacity", 2));
+ coordinator.capacity(NODE_A, object().put("capacity", 0));
+ JsonNode migrating = coordinator.snapshot();
+ assertEquals(2, migrating.path("summary").path("active").asInt());
+ for (JsonNode task : migrating.path("tasks")) {
+ assertEquals("MIGRATING", task.path("status").asText());
+ assertEquals(NODE_A, task.path("nodeId").asText());
+ assertEquals(initialUrls.get(task.path("taskId").asText()), task.path("playUrl").asText());
+ }
+ assertEquals(2, heartbeat(NODE_A, array()).path("tasks").size());
+ assertEquals(2, heartbeat(NODE_B, array()).path("tasks").size());
+
+ activate(NODE_B);
+ assertEquals(0, heartbeat(NODE_A, array()).path("tasks").size());
+ for (JsonNode task : coordinator.snapshot().path("tasks")) {
+ assertEquals("RUNNING", task.path("status").asText());
+ assertEquals(NODE_B, task.path("nodeId").asText());
+ assertNotEquals(initialUrls.get(task.path("taskId").asText()), task.path("playUrl").asText());
+ }
+ }
+
+ @Test void loweringCapacityWithoutSpareCapacityPreservesExistingOutput() {
+ register(NODE_A, 2);
+ coordinator.context(context(2, "RUNNING"));
+ activate(NODE_A);
+ Map urls = playbackUrls();
+
+ coordinator.capacity(NODE_A, object().put("capacity", 0));
+ assertEquals(urls, playbackUrls());
+ assertTrue(coordinator.snapshot().path("nodes").get(0).path("adjusting").asBoolean());
+ for (JsonNode task : coordinator.snapshot().path("tasks"))
+ assertEquals("CAPACITY_WAIT_KEEP_RUNNING", task.path("reason").asText());
+ }
+
+ @Test void destinationFailureDuringMigrationKeepsSourceUntilAnotherDestinationIsReady() {
+ register(NODE_A, 1);
+ register(NODE_B, 0);
+ coordinator.context(context(1, "RUNNING"));
+ activate(NODE_A);
+ Map original = playbackUrls();
+ coordinator.capacity(NODE_B, object().put("capacity", 1));
+ coordinator.capacity(NODE_A, object().put("capacity", 0));
+ assertEquals("MIGRATING", coordinator.snapshot().path("tasks").get(0).path("status").asText());
+
+ time.addAndGet(8_001);
+ heartbeat(NODE_A, array());
+ assertEquals(original, playbackUrls());
+ assertEquals("CAPACITY_WAIT_KEEP_RUNNING", coordinator.snapshot().path("tasks").get(0).path("reason").asText());
+ assertEquals("RUNNING", coordinator.snapshot().path("tasks").get(0).path("status").asText());
+
+ register(NODE_B, 1);
+ assertEquals(original, playbackUrls());
+ activate(NODE_B);
+ assertEquals(NODE_B, coordinator.snapshot().path("tasks").get(0).path("nodeId").asText());
+ }
+
+ @Test void staleSessionsAndTaskVersionsCannotPublishOldPlayback() {
+ register(NODE_A, 1);
+ coordinator.context(context(1, "RUNNING"));
+ JsonNode originalPlan = heartbeat(NODE_A, array()).path("tasks").get(0);
+ String oldSession = sessions.get(NODE_A), oldBoot = boots.get(NODE_A);
+
+ boots.put(NODE_A, "new-boot");
+ register(NODE_A, 1);
+ assertThrows(IllegalStateException.class, () -> coordinator.heartbeat(NODE_A,
+ object().put("sessionId", oldSession).put("bootId", oldBoot).set("reports", array())));
+
+ // 新会话中迟到的旧任务回执也不能把旧版本变成可播放任务。
+ heartbeat(NODE_A, reports(originalPlan, true));
+ JsonNode task = coordinator.snapshot().path("tasks").get(0);
+ assertEquals("PREPARING", task.path("status").asText());
+ assertFalse(task.has("playUrl"));
+ activate(NODE_A);
+ assertTrue(coordinator.snapshot().path("tasks").get(0).path("version").asLong()
+ > originalPlan.path("assignment").path("version").asLong());
+ }
+
+ @Test void endingTheAuthoritativeRoundRevokesTasksButKeepsUdpRoutes() {
+ register(NODE_A, 2);
+ ObjectNode configuredRoutes = object();
+ configuredRoutes.set("routes", array().add(object().put("endpointId", "collector-a")
+ .put("sourceHost", "127.0.0.1").put("sourcePort", 35000).put("relayPort", 35001)
+ .put("targetHost", "127.0.0.1").put("targetPort", 19001)));
+ coordinator.routes(configuredRoutes);
+ coordinator.context(context(2, "RUNNING"));
+ activate(NODE_A);
+ JsonNode before = coordinator.snapshot();
+
+ coordinator.context(context(2, "SAVED"));
+ JsonNode ended = coordinator.snapshot();
+ assertEquals(before.path("routes"), ended.path("routes"));
+ assertEquals(0, heartbeat(NODE_A, array()).path("tasks").size());
+ assertEquals(0, ended.path("summary").path("active").asInt());
+ for (JsonNode task : ended.path("tasks")) {
+ assertEquals("ENDED", task.path("status").asText());
+ assertFalse(task.has("playUrl"));
+ }
+ }
+
+ @Test void nodeLeaseExpiryStopsAdvertisingStaleMedia() {
+ register(NODE_A, 1);
+ coordinator.context(context(1, "RUNNING"));
+ activate(NODE_A);
+ time.addAndGet(8_001);
+ coordinator.tick();
+ JsonNode snapshot = coordinator.snapshot();
+ assertFalse(snapshot.path("nodes").get(0).path("online").asBoolean());
+ assertEquals(0, snapshot.path("summary").path("active").asInt());
+ assertFalse(snapshot.path("tasks").get(0).has("playUrl"));
+ }
+
+ @Test void wallClockMovingBackwardsDoesNotExtendDisconnectedNodeLease() throws Exception {
+ coordinator.close();
+ AtomicLong monotonic = new AtomicLong(10_000);
+ coordinator = new CoordinatorService(new StateStore("jdbc:h2:mem:" + UUID.randomUUID(), new ObjectMapper()),
+ time::get, monotonic::get, 8_000, CLUSTER);
+ register(NODE_A, 1);
+ coordinator.context(context(1, "RUNNING"));
+ activate(NODE_A);
+ time.addAndGet(-600_000);
+ monotonic.addAndGet(8_001);
+ coordinator.tick();
+ assertFalse(coordinator.snapshot().path("nodes").get(0).path("online").asBoolean());
+ assertTrue(playbackUrls().isEmpty());
+ }
+
+ @Test void freshCoordinatorDoesNotTreatEmptyRoutesAsAnAuthoritativeClearCommand() {
+ ObjectNode heartbeat = object().put("clusterId", CLUSTER).put("gatewayId", "gateway-test");
+ assertFalse(coordinator.gateway(heartbeat).path("routesConfirmed").asBoolean());
+ ObjectNode clear = object();
+ clear.putArray("routes");
+ coordinator.routes(clear);
+ assertTrue(coordinator.gateway(heartbeat).path("routesConfirmed").asBoolean());
+ }
+
+ @Test void restartPersistsCapacityButFencesOldSessionsAndRequiresPxhjReconfirmation() throws Exception {
+ coordinator.close();
+ String url = "jdbc:h2:file:" + directory.resolve("restart").toString().replace('\\', '/') + ";DB_CLOSE_ON_EXIT=FALSE";
+ coordinator = new CoordinatorService(new StateStore(url, new ObjectMapper()), time::get, 8_000, CLUSTER);
+ register(NODE_A, 3);
+ coordinator.capacity(NODE_A, object().put("capacity", 2));
+ coordinator.context(context(2, "RUNNING"));
+ activate(NODE_A);
+ JsonNode oldPlan = heartbeat(NODE_A, array()).path("tasks").get(0);
+ String oldSession = sessions.get(NODE_A), boot = boots.get(NODE_A);
+ long oldEpoch = coordinator.info().path("epoch").asLong();
+ coordinator.close();
+
+ coordinator = new CoordinatorService(new StateStore(url, new ObjectMapper()), time::get, 8_000, CLUSTER);
+ assertEquals(oldEpoch + 1, coordinator.info().path("epoch").asLong());
+ assertEquals(2, coordinator.snapshot().path("nodes").get(0).path("capacity").asInt());
+ assertFalse(coordinator.snapshot().path("contexts").get(0).path("confirmed").asBoolean());
+ assertThrows(IllegalStateException.class, () -> coordinator.heartbeat(NODE_A,
+ object().put("sessionId", oldSession).put("bootId", boot).set("reports", reports(oldPlan, true))));
+
+ register(NODE_A, 10);
+ assertEquals(0, heartbeat(NODE_A, reports(oldPlan, true)).path("tasks").size());
+ assertTrue(playbackUrls().isEmpty());
+ coordinator.context(context(2, "RUNNING"));
+ activate(NODE_A);
+ assertEquals(2, playbackUrls().size());
+ assertEquals(2, coordinator.snapshot().path("nodes").get(0).path("capacity").asInt());
+ }
+
+ @Test void preparationImagesUseConfiguredCapacityAndCredentialsNeverEnterPublicOrStoredState() throws Exception {
+ coordinator.close();
+ StateStore store=new StateStore("jdbc:h2:mem:"+UUID.randomUUID(),new ObjectMapper());
+ coordinator=new CoordinatorService(store,time::get,8_000,CLUSTER);
+ registerImageNode(NODE_A,1,19101);
+ ObjectNode source=imageSource(UUID.randomUUID().toString());
+ assertEquals("WAITING",coordinator.imageSource(source).path("status").asText());
+ JsonNode plan=heartbeat(NODE_A,array()).path("tasks").get(0);
+ assertEquals("ORIGINAL_IMAGE",plan.path("mediaKind").asText());
+ assertEquals(source.path("sm4Key"),plan.path("imageAccess").path("sm4Key"));
+ // 没有任何正式试验上下文,也能先准备原图入口;未收到首帧不会伪造图像就绪。
+ heartbeat(NODE_A,reports(plan,true));
+ JsonNode route=coordinator.imageSource(source);
+ assertEquals("READY",route.path("status").asText());
+ assertEquals(19101,route.path("targets").get(0).path("port").asInt());
+ assertFalse(coordinator.snapshot().path("tasks").get(0).path("imageReady").asBoolean());
+ assertEquals("MJPEG_ORIGINAL",coordinator.snapshot().path("tasks").get(0).path("playProtocol").asText());
+ assertFalse(coordinator.snapshot().toString().contains("sm4Key"));
+ assertFalse(store.load().toString().contains("hmacKey"));
+ assertEquals("WAITING",coordinator.imageSource(imageSource(UUID.randomUUID().toString())).path("status").asText());
+ assertEquals(1,coordinator.snapshot().path("summary").path("queued").asInt());
+ }
+
+ @Test void imageMigrationBrieflyPublishesTwoTargetsAndKeepsPreparationReadingOldNodeUntilFirstFrame() {
+ registerImageNode(NODE_A,1,19101);registerImageNode(NODE_B,0,19102);
+ ObjectNode source=imageSource(UUID.randomUUID().toString());
+ coordinator.imageSource(source);activate(NODE_A);
+ coordinator.capacity(NODE_B,object().put("capacity",1));
+ coordinator.capacity(NODE_A,object().put("capacity",0));
+ JsonNode target=heartbeat(NODE_B,array()).path("tasks").get(0);
+ heartbeat(NODE_B,reports(target,true));
+ JsonNode route=coordinator.imageSource(source);
+ assertEquals(2,route.path("targets").size());
+ ObjectNode query=object();query.putArray("endpointIds").add(source.path("endpointId").asText());
+ assertEquals("http://127.0.0.1:18101",coordinator.imageRoutes(query).path("routes").get(0).path("nodeBaseUrl").asText());
+ // 接收端口就绪不代表原图已经送达;新节点拿到完整帧后才成为准备页面的来源。
+ ArrayNode firstFrame=reports(target,true);((ObjectNode)firstFrame.get(0)).put("imageReady",true).put("lastFrameTime",time.get());
+ heartbeat(NODE_B,firstFrame);
+ route=coordinator.imageSource(source);
+ assertEquals(1,route.path("targets").size());assertEquals(19102,route.path("targets").get(0).path("port").asInt());
+ assertEquals("http://127.0.0.1:18102",coordinator.imageRoutes(query).path("routes").get(0).path("nodeBaseUrl").asText());
+ assertEquals(0,heartbeat(NODE_A,array()).path("tasks").size());
+ }
+
+ @Test void disconnectedSourceReleasesCapacityAndResumingCannotReuseAnOldRouteVersion() {
+ registerImageNode(NODE_A,1,19101);
+ ObjectNode source=imageSource(UUID.randomUUID().toString());
+ coordinator.imageSource(source);activate(NODE_A);
+ long old=coordinator.imageSource(source).path("version").asLong();
+ time.addAndGet(30_001);registerImageNode(NODE_A,1,19101);coordinator.tick();
+ assertTrue(coordinator.snapshot().path("tasks").isEmpty());
+ assertEquals(0,heartbeat(NODE_A,array()).path("tasks").size());
+ JsonNode resumed=coordinator.imageSource(source);assertTrue(resumed.path("version").asLong()>old);
+ assertEquals("WAITING",resumed.path("status").asText());
+ }
+
+ private ObjectNode imageSource(String endpoint){
+ return object().put("endpointId",endpoint).put("name","采集模块")
+ .put("sourceHost","127.0.0.1").put("collectorId",3)
+ .put("sm4Key",java.util.Base64.getEncoder().encodeToString(new byte[16]))
+ .put("hmacKey",java.util.Base64.getEncoder().encodeToString(new byte[32]));
+ }
+
+ private void registerImageNode(String id,int capacity,int imagePort){
+ String boot=boots.computeIfAbsent(id,k->UUID.randomUUID().toString());
+ JsonNode result=coordinator.register(object().put("clusterId",CLUSTER).put("nodeId",id)
+ .put("bootId",boot).put("name",id).put("baseUrl","http://127.0.0.1:"+(id.equals(NODE_A)?18101:18102))
+ .put("capacity",capacity).put("imageHost","127.0.0.1").put("imageInputPort",imagePort));
+ sessions.put(id,result.path("sessionId").asText());
+ }
+
+ private void register(String id, int capacity) {
+ String boot = boots.computeIfAbsent(id, k -> UUID.randomUUID().toString());
+ JsonNode result = coordinator.register(object().put("clusterId", CLUSTER).put("nodeId", id)
+ .put("bootId", boot).put("name", id).put("baseUrl", "http://127.0.0.1:" + (id.equals(NODE_A) ? 18101 : 18102))
+ .put("capacity", capacity));
+ sessions.put(id, result.path("sessionId").asText());
+ }
+
+ private ObjectNode context(int channels, String phase) {
+ ObjectNode result = object().put("trialId", "trial-real-a").put("roundId", "round-real-a")
+ .put("name", "真实试验上下文").put("roundStatus", phase).put("configurationVersion", "revision-a");
+ ArrayNode list = result.putArray("channels");
+ for (int i = 0; i < channels; i++) list.add(object().put("channelId", "channel-" + i)
+ .put("equipmentId", "equipment-" + i).put("name", "装备视频" + i));
+ result.putArray("bindings");
+ return result;
+ }
+
+ private JsonNode heartbeat(String id, ArrayNode reports) {
+ return coordinator.heartbeat(id, object().put("sessionId", sessions.get(id)).put("bootId", boots.get(id)).set("reports", reports));
+ }
+
+ private void activate(String id) {
+ ArrayNode ready = array();
+ for (JsonNode plan : heartbeat(id, array()).path("tasks"))
+ ready.add(object().put("taskId", plan.path("taskId").asText()).put("version", plan.path("assignment").path("version").asLong()).put("ready", true));
+ heartbeat(id, ready);
+ }
+
+ private ArrayNode reports(JsonNode plan, boolean ready) {
+ return array().add(object().put("taskId", plan.path("taskId").asText())
+ .put("version", plan.path("assignment").path("version").asLong()).put("ready", ready));
+ }
+
+ private Map playbackUrls() {
+ Map result = new HashMap<>();
+ for (JsonNode task : coordinator.snapshot().path("tasks")) if (task.has("playUrl"))
+ result.put(task.path("taskId").asText(), task.path("playUrl").asText());
+ return result;
+ }
+}
diff --git a/src/test/java/com/xtrh/gateway/service/GatewayTest.java b/src/test/java/com/xtrh/gateway/service/GatewayTest.java
new file mode 100644
index 0000000..187a9cb
--- /dev/null
+++ b/src/test/java/com/xtrh/gateway/service/GatewayTest.java
@@ -0,0 +1,230 @@
+package com.xtrh.gateway.service;
+
+import com.xtrh.discovery.service.CoordinatorLocator;
+import com.xtrh.discovery.service.Discovery;
+import com.xtrh.common.util.Jsons;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.common.config.Settings;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.env.MockEnvironment;
+
+import java.net.*;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Random;
+
+import static com.xtrh.common.util.Jsons.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class GatewayTest {
+ @TempDir Path directory;
+
+ @Test void confirmedEmptyRoutePlanRevokesOldRouteEvenWhenVersionNumberIsUnchanged() throws Exception {
+ Settings settings = settings(directory.resolve("same-version-gateway"));
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ try (DatagramSocket collector = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) {
+ when(peer.request(anyString(), anyString(), anyString(), any()))
+ .thenReturn(routes(collector.getLocalPort(), freePort(), pxhj.getLocalPort()));
+ GatewayService gateway = new GatewayService(settings, locator, peer, new ObjectMapper());
+ try {
+ gateway.heartbeat();
+ int ingressPort = gateway.status().path("inputPort").asInt();
+ byte[] bytes = opaqueBytes(64);
+ collector.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), ingressPort));
+ assertArrayEquals(bytes, payload(receive(pxhj)));
+
+ // 总控数据库重新初始化后可能复用旧版本号;明确确认的路由内容仍然必须生效。
+ ObjectNode confirmedClear = object().put("routeVersion", 1).put("routesConfirmed", true);
+ confirmedClear.putArray("routes");
+ when(peer.request(anyString(), anyString(), anyString(), any())).thenReturn(confirmedClear);
+ gateway.heartbeat();
+ assertEquals(1, gateway.status().path("routeVersion").asLong());
+ assertEquals(0, gateway.status().path("routeStatus").size());
+ collector.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), ingressPort));
+ pxhj.setSoTimeout(250);
+ assertThrows(SocketTimeoutException.class, () -> receive(pxhj));
+ assertEquals(1, gateway.status().path("unknownSource").asLong());
+ } finally { gateway.close(); }
+ }
+ }
+
+ @Test void forwardsOpaqueDatagramBytesAndReverseRepliesWithoutChangingSourceIdentity() throws Exception {
+ Settings settings = settings(directory.resolve("gateway"));
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ try (DatagramSocket collector = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) {
+ int relayPort = freePort();
+ ObjectNode plan = routes(collector.getLocalPort(), relayPort, pxhj.getLocalPort());
+ when(peer.request(anyString(), eq("/control/gateways/heartbeat"), eq("POST"), any())).thenReturn(plan);
+ try (AutoCloseableGateway managed = new AutoCloseableGateway(settings, locator, peer, new ObjectMapper())) {
+ GatewayService gateway = managed.gateway;
+ gateway.heartbeat();
+ assertEquals("APPLIED", gateway.status().path("routeState").asText());
+ int ingressPort = gateway.status().path("inputPort").asInt();
+
+ // 网关不能依赖报文文本或专有标记;使用包含零字节和高位字节的任意密文验证透明转发。
+ for (int length : new int[]{1, 32, 512, 4096, 60000}) {
+ byte[] encryptedBytes = opaqueBytes(length);
+ collector.send(new DatagramPacket(encryptedBytes, encryptedBytes.length, InetAddress.getLoopbackAddress(), ingressPort));
+ DatagramPacket received = receive(pxhj);
+ assertArrayEquals(encryptedBytes, payload(received));
+ assertEquals(relayPort, received.getPort());
+
+ byte[] reply = opaqueBytes(47);
+ pxhj.send(new DatagramPacket(reply, reply.length, received.getSocketAddress()));
+ DatagramPacket returned = receive(collector);
+ assertArrayEquals(reply, payload(returned));
+ assertEquals(ingressPort, returned.getPort());
+ }
+ assertEquals(5, gateway.status().path("forwarded").asLong());
+
+ // 总控失联时,已确认路由仍然继续转发,不影响 PXHJ 采集数据显示。
+ when(locator.locate()).thenThrow(new IllegalStateException("coordinator offline"));
+ gateway.heartbeat();
+ byte[] afterDisconnect = opaqueBytes(77);
+ collector.send(new DatagramPacket(afterDisconnect, afterDisconnect.length, InetAddress.getLoopbackAddress(), ingressPort));
+ assertArrayEquals(afterDisconnect, payload(receive(pxhj)));
+ }
+ }
+ }
+
+ @Test void unknownSourcesAreDroppedAndLastConfirmedRoutesSurviveRestart() throws Exception {
+ Settings settings = settings(directory.resolve("persisted-gateway"));
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ try (DatagramSocket collector = loopbackSocket(); DatagramSocket unknown = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) {
+ when(peer.request(anyString(), anyString(), anyString(), any())).thenReturn(routes(collector.getLocalPort(), freePort(), pxhj.getLocalPort()));
+ GatewayService gateway = new GatewayService(settings, locator, peer, new ObjectMapper());
+ try {
+ gateway.heartbeat();
+ byte[] bytes = opaqueBytes(96);
+ unknown.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), gateway.status().path("inputPort").asInt()));
+ pxhj.setSoTimeout(250);
+ assertThrows(SocketTimeoutException.class, () -> receive(pxhj));
+ assertEquals(1, gateway.status().path("unknownSource").asLong());
+ } finally { gateway.close(); }
+
+ gateway = new GatewayService(settings, locator, peer, new ObjectMapper());
+ try {
+ assertEquals("APPLIED", gateway.status().path("routeState").asText());
+ ObjectNode unconfirmed = object().put("routeVersion", 0).put("routesConfirmed", false);
+ unconfirmed.putArray("routes");
+ when(peer.request(anyString(), anyString(), anyString(), any())).thenReturn(unconfirmed);
+ gateway.heartbeat();
+ assertEquals(1, gateway.status().path("routeVersion").asLong());
+ pxhj.setSoTimeout(3000);
+ byte[] bytes = opaqueBytes(160);
+ collector.send(new DatagramPacket(bytes, bytes.length, InetAddress.getLoopbackAddress(), gateway.status().path("inputPort").asInt()));
+ assertArrayEquals(bytes, payload(receive(pxhj)));
+ } finally { gateway.close(); }
+ }
+ }
+
+ @Test void imageNegotiationRepliesUseTheirOwnSocketWithoutChangingLegacyReplyDestination() throws Exception {
+ Settings settings = settings(directory.resolve("image-negotiation-gateway"));
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ try (DatagramSocket negotiation = loopbackSocket(); DatagramSocket data = loopbackSocket();
+ DatagramSocket heartbeat = loopbackSocket(); DatagramSocket pxhj = loopbackSocket()) {
+ // 同一采集器允许动态源端口,协商与采集上传必须各自记住回程地址。
+ when(peer.request(anyString(), anyString(), anyString(), any()))
+ .thenReturn(routes(0, freePort(), pxhj.getLocalPort()));
+ GatewayService gateway = new GatewayService(settings, locator, peer, new ObjectMapper());
+ try {
+ gateway.heartbeat();
+ InetSocketAddress ingress = new InetSocketAddress(InetAddress.getLoopbackAddress(),
+ gateway.status().path("inputPort").asInt());
+ byte[] hello = typedBytes(0x14), sample = typedBytes(0x11);
+ negotiation.send(new DatagramPacket(hello, hello.length, ingress));
+ DatagramPacket upstream = receive(pxhj);
+ assertArrayEquals(hello, payload(upstream));
+ data.send(new DatagramPacket(sample, sample.length, ingress));
+ assertArrayEquals(sample, payload(receive(pxhj)));
+
+ byte[] answer = typedBytes(0x15);
+ pxhj.send(new DatagramPacket(answer, answer.length, upstream.getSocketAddress()));
+ DatagramPacket returned = receive(negotiation);
+ assertArrayEquals(answer, payload(returned));
+ assertEquals(ingress.getPort(), returned.getPort());
+ data.setSoTimeout(100);
+ assertThrows(SocketTimeoutException.class, () -> receive(data));
+
+ byte[] legacyReply = typedBytes(0x81);
+ pxhj.send(new DatagramPacket(legacyReply, legacyReply.length, upstream.getSocketAddress()));
+ data.setSoTimeout(3000);
+ assertArrayEquals(legacyReply, payload(receive(data)));
+
+ // 非 FND 的普通二进制回执即使第8字节为0x15,也不能误回协商端口。
+ byte[] rawReply = opaqueBytes(128);
+ rawReply[0] = 0; rawReply[8] = (byte) 0x15;
+ pxhj.send(new DatagramPacket(rawReply, rawReply.length, upstream.getSocketAddress()));
+ assertArrayEquals(rawReply, payload(receive(data)));
+ negotiation.setSoTimeout(100);
+ assertThrows(SocketTimeoutException.class, () -> receive(negotiation));
+ negotiation.setSoTimeout(3000);
+
+ // 心跳更换临时源端口后,图片回复仍回最初的协商 socket。
+ byte[] pulse = typedBytes(0x13);
+ heartbeat.send(new DatagramPacket(pulse, pulse.length, ingress));
+ assertArrayEquals(pulse, payload(receive(pxhj)));
+ pxhj.send(new DatagramPacket(answer, answer.length, upstream.getSocketAddress()));
+ assertArrayEquals(answer, payload(receive(negotiation)));
+ pxhj.send(new DatagramPacket(legacyReply, legacyReply.length, upstream.getSocketAddress()));
+ assertArrayEquals(legacyReply, payload(receive(heartbeat)));
+ } finally { gateway.close(); }
+ }
+ }
+
+ private Settings settings(Path data) throws Exception {
+ return new Settings(new MockEnvironment().withProperty("fusion.role", "gateway")
+ .withProperty("fusion.token", "unit-test-credential-not-deployed")
+ .withProperty("fusion.data-dir", data.toString()).withProperty("fusion.gateway-port", "0")
+ .withProperty("fusion.advertised-host", "127.0.0.1").withProperty("fusion.discovery-enabled", "false"));
+ }
+
+ private ObjectNode routes(int source, int relay, int target) {
+ ObjectNode plan = object().put("routeVersion", 1).put("routesConfirmed", true);
+ plan.set("routes", array().add(object().put("endpointId", "collector-a").put("sourceHost", "127.0.0.1")
+ .put("sourcePort", source).put("relayPort", relay).put("targetHost", "127.0.0.1").put("targetPort", target)));
+ return plan;
+ }
+
+ private DatagramSocket loopbackSocket() throws Exception {
+ DatagramSocket socket = new DatagramSocket(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0));
+ socket.setSoTimeout(3000);
+ return socket;
+ }
+
+ private int freePort() throws Exception { try (DatagramSocket socket = loopbackSocket()) { return socket.getLocalPort(); } }
+ private DatagramPacket receive(DatagramSocket socket) throws Exception {
+ DatagramPacket packet = new DatagramPacket(new byte[65507], 65507); socket.receive(packet); return packet;
+ }
+ private byte[] payload(DatagramPacket packet) { return Arrays.copyOfRange(packet.getData(), packet.getOffset(), packet.getOffset() + packet.getLength()); }
+ private byte[] opaqueBytes(int length) { byte[] bytes = new byte[length]; new Random(length).nextBytes(bytes); return bytes; }
+ private byte[] typedBytes(int type) {
+ byte[] bytes = opaqueBytes(109); bytes[8] = (byte) type;
+ ByteBuffer.wrap(bytes).putInt(0, 0x5AA5FAFB).putInt(4, bytes.length)
+ .putInt(bytes.length - 4, 0xA55AFCFD);
+ return bytes;
+ }
+
+ private static final class AutoCloseableGateway implements AutoCloseable {
+ final GatewayService gateway;
+ AutoCloseableGateway(Settings settings, CoordinatorLocator locator, PeerClient peer, ObjectMapper mapper) throws Exception {
+ gateway = new GatewayService(settings, locator, peer, mapper);
+ }
+ @Override public void close() throws Exception { gateway.close(); }
+ }
+}
diff --git a/src/test/java/com/xtrh/node/config/WorkerCapabilityTest.java b/src/test/java/com/xtrh/node/config/WorkerCapabilityTest.java
new file mode 100644
index 0000000..efbd3ae
--- /dev/null
+++ b/src/test/java/com/xtrh/node/config/WorkerCapabilityTest.java
@@ -0,0 +1,86 @@
+package com.xtrh.node.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.xtrh.common.config.Settings;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.control.controller.CoordinatorController;
+import com.xtrh.control.service.CoordinatorService;
+import com.xtrh.discovery.service.CoordinatorLocator;
+import com.xtrh.node.controller.WorkerController;
+import com.xtrh.node.service.WorkerService;
+import com.xtrh.node.service.ImageIngressService;
+import com.xtrh.node.controller.NodeImageController;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.context.annotation.Bean;
+import org.springframework.core.env.Environment;
+
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+class WorkerCapabilityTest {
+ @TempDir Path directory;
+
+ @Test void coordinatorEnablesExecutionByDefaultInTheSameContext() {
+ runner("coordinator").run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CoordinatorService.class)
+ .hasSingleBean(CoordinatorController.class).hasSingleBean(WorkerService.class)
+ .hasSingleBean(WorkerController.class).hasSingleBean(AsyncMediaConfiguration.class);
+ assertThat(context.getBean(Settings.class).supportsWorker).isTrue();
+ });
+ }
+
+ @Test void coordinatorOnlyRetainsControlAndDoesNotExposeWorkerController() {
+ runner("coordinator").withPropertyValues("fusion.coordinator-worker-enabled=false")
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(CoordinatorService.class)
+ .hasSingleBean(CoordinatorController.class).doesNotHaveBean(WorkerService.class)
+ .doesNotHaveBean(WorkerController.class).doesNotHaveBean(AsyncMediaConfiguration.class);
+ assertThat(context.getBean(Settings.class).supportsWorker).isFalse();
+ assertThat(context.getBean(CoordinatorService.class).info().path("role").asText())
+ .isEqualTo("coordinator");
+ });
+ }
+
+ @Test void standaloneWorkerIsIndependentOfTheCoordinatorExecutionSwitch() {
+ runner("worker").withPropertyValues("fusion.coordinator-worker-enabled=false")
+ .run(context -> {
+ assertThat(context).hasNotFailed().hasSingleBean(WorkerService.class)
+ .hasSingleBean(WorkerController.class).doesNotHaveBean(CoordinatorService.class)
+ .doesNotHaveBean(CoordinatorController.class).hasSingleBean(AsyncMediaConfiguration.class);
+ assertThat(context.getBean(Settings.class).supportsWorker).isTrue();
+ });
+ }
+
+ @Test void gatewayNeverStartsAnExecutionService() {
+ runner("gateway").withPropertyValues("fusion.coordinator-worker-enabled=true")
+ .run(context -> {
+ assertThat(context).hasNotFailed().doesNotHaveBean(WorkerService.class)
+ .doesNotHaveBean(WorkerController.class).doesNotHaveBean(CoordinatorService.class)
+ .doesNotHaveBean(AsyncMediaConfiguration.class);
+ assertThat(context.getBean(Settings.class).supportsWorker).isFalse();
+ });
+ }
+
+ private ApplicationContextRunner runner(String role) {
+ // 条件测试不启用调度和网络;真实自注册与播放由随机端口集成测试覆盖。
+ return new ApplicationContextRunner().withUserConfiguration(Infrastructure.class,
+ CoordinatorService.class, CoordinatorController.class, WorkerService.class,
+ WorkerController.class, NodeImageController.class, ImageIngressService.class, AsyncMediaConfiguration.class)
+ .withPropertyValues("fusion.role=" + role, "fusion.discovery-enabled=false",
+ "fusion.token=capability-test-credential-not-deployed", "server.port=0", "fusion.image-port=0",
+ "fusion.data-dir=" + directory.resolve(role));
+ }
+
+ @TestConfiguration(proxyBeanMethods = false)
+ static class Infrastructure {
+ @Bean Settings settings(Environment environment) throws Exception { return new Settings(environment); }
+ @Bean ObjectMapper objectMapper() { return new ObjectMapper(); }
+ @Bean CoordinatorLocator coordinatorLocator() { return mock(CoordinatorLocator.class); }
+ @Bean PeerClient peerClient() { return mock(PeerClient.class); }
+ }
+}
diff --git a/src/test/java/com/xtrh/node/service/CoordinatorWorkerIntegrationTest.java b/src/test/java/com/xtrh/node/service/CoordinatorWorkerIntegrationTest.java
new file mode 100644
index 0000000..c8463e8
--- /dev/null
+++ b/src/test/java/com/xtrh/node/service/CoordinatorWorkerIntegrationTest.java
@@ -0,0 +1,147 @@
+package com.xtrh.node.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.common.config.Settings;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.node.controller.WorkerController;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.ApplicationContext;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import javax.imageio.ImageIO;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+
+import static com.xtrh.common.util.Jsons.object;
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
+ "fusion.role=coordinator", "fusion.capacity=1", "fusion.discovery-enabled=false",
+ "fusion.advertised-host=127.0.0.1", "fusion.token=combined-master-test-credential-only", "fusion.image-port=0"
+})
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class CoordinatorWorkerIntegrationTest {
+ private static final Path DATA_DIRECTORY = temporaryDirectory();
+ @LocalServerPort int port;
+ @Autowired Settings settings;
+ @Autowired PeerClient peer;
+ @Autowired ApplicationContext application;
+
+ @DynamicPropertySource static void storage(DynamicPropertyRegistry registry) {
+ registry.add("fusion.data-dir", DATA_DIRECTORY::toString);
+ }
+
+ @Test
+ @Timeout(45)
+ void masterRegistersOneExecutionIdentityAndServesItsOwnAssignedJpegOnTheMasterPort() throws Exception {
+ assertTrue(settings.supportsWorker);
+ assertTrue(application.containsBean("workerService"));
+ assertNotNull(application.getBean(WorkerController.class));
+ assertEquals(port, settings.port, "媒体登记必须使用随机分配的实际 HTTP 端口");
+ String base = "http://127.0.0.1:" + port;
+
+ JsonNode initial = waitFor(base, snapshot -> snapshot.path("nodes").size() == 1
+ && snapshot.path("nodes").get(0).path("online").asBoolean());
+ JsonNode node = initial.path("nodes").get(0);
+ assertEquals(settings.id, node.path("nodeId").asText());
+ assertEquals("COORDINATOR", node.path("nodeRole").asText());
+ assertEquals(port, URI.create(node.path("baseUrl").asText()).getPort());
+ assertEquals(settings.id, peer.request(base, "/control/info", "GET", null).path("serviceId").asText());
+
+ // 主身份不能伪装成普通节点,也不能由另一个身份额外登记第二个主执行节点。
+ ObjectNode registration = object().put("clusterId", settings.cluster).put("bootId", settings.bootId)
+ .put("name", "非法主身份验证").put("baseUrl", base).put("capacity", 1);
+ registration.put("nodeId", settings.id).put("nodeRole", "WORKER");
+ assertThrows(IllegalStateException.class, () -> peer.request(base, "/control/nodes/register", "POST", registration));
+ registration.put("nodeId", "another-main-identity").put("nodeRole", "COORDINATOR");
+ assertThrows(IllegalStateException.class, () -> peer.request(base, "/control/nodes/register", "POST", registration));
+ assertEquals(1, peer.request(base, "/control/snapshot", "GET", null).path("nodes").size());
+
+ // 使用隔离总控的正式上下文接口;不访问正在运行的 PXHJ、采集器或其他节点。
+ ObjectNode context = object().put("trialId", "combined-test-trial").put("roundId", "combined-test-round")
+ .put("name", "主进程执行验证").put("roundStatus", "PENDING").put("configurationVersion", "1");
+ context.putArray("channels").add(object().put("channelId", "combined-test-channel")
+ .put("equipmentId", "combined-test-equipment").put("name", "主节点演示画面"));
+ context.putArray("bindings");
+ peer.request(base, "/control/contexts/sync", "POST", context);
+ JsonNode ready = waitFor(base, snapshot -> snapshot.path("tasks").size() == 1
+ && snapshot.path("tasks").get(0).path("status").asText().equals("READY")
+ && snapshot.path("tasks").get(0).hasNonNull("playUrl"));
+ JsonNode task = ready.path("tasks").get(0);
+ assertEquals(1, ready.path("nodes").size(), "主控制与执行必须共用一个节点身份");
+ assertEquals(settings.id, task.path("nodeId").asText());
+ assertEquals(1, ready.path("nodes").get(0).path("running").asInt());
+ assertEquals(port, URI.create(task.path("playUrl").asText()).getPort());
+ assertActualJpeg(task.path("playUrl").asText());
+
+ context.put("roundStatus", "SAVED");
+ peer.request(base, "/control/contexts/sync", "POST", context);
+ JsonNode ended = waitFor(base, snapshot -> snapshot.path("tasks").get(0).path("status").asText().equals("ENDED"));
+ assertFalse(ended.path("tasks").get(0).has("playUrl"));
+ }
+
+ private JsonNode waitFor(String base, Predicate expected) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
+ while (System.nanoTime() < deadline) {
+ JsonNode snapshot = peer.request(base, "/control/snapshot", "GET", null);
+ if (expected.test(snapshot)) return snapshot;
+ Thread.sleep(100);
+ }
+ throw new AssertionError("隔离主节点自动注册或任务准备超时");
+ }
+
+ private void assertActualJpeg(String url) throws Exception {
+ HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build();
+ HttpResponse response = client.send(HttpRequest.newBuilder(URI.create(url))
+ .timeout(Duration.ofSeconds(3)).GET().build(), HttpResponse.BodyHandlers.ofInputStream());
+ try (InputStream input = response.body()) {
+ assertEquals(200, response.statusCode());
+ assertTrue(response.headers().firstValue("Content-Type").orElse("").startsWith("multipart/x-mixed-replace"));
+ assertEquals("--frame", line(input));
+ int length = -1;
+ for (String header = line(input); !header.isEmpty(); header = line(input)) {
+ if (header.toLowerCase(java.util.Locale.ROOT).startsWith("content-length:"))
+ length = Integer.parseInt(header.substring(header.indexOf(':') + 1).trim());
+ }
+ assertTrue(length > 4 && length < 500_000);
+ byte[] jpeg = input.readNBytes(length);
+ assertEquals(length, jpeg.length);
+ assertNotNull(ImageIO.read(new ByteArrayInputStream(jpeg)), "实际 HTTP 帧必须能解码为图片");
+ }
+ }
+
+ private static String line(InputStream input) throws IOException {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ for (int count = 0; count < 1024; count++) {
+ int value = input.read();
+ if (value < 0) throw new IOException("媒体流提前结束");
+ if (value == '\n') return bytes.toString(StandardCharsets.US_ASCII).trim();
+ bytes.write(value);
+ }
+ throw new IOException("媒体分段头超过限定长度");
+ }
+
+ private static Path temporaryDirectory() {
+ try { return Files.createTempDirectory("xtrh-combined-master-test-"); }
+ catch (IOException e) { throw new IllegalStateException("无法创建隔离测试目录", e); }
+ }
+}
diff --git a/src/test/java/com/xtrh/node/service/ImageIngressTest.java b/src/test/java/com/xtrh/node/service/ImageIngressTest.java
new file mode 100644
index 0000000..9a1127a
--- /dev/null
+++ b/src/test/java/com/xtrh/node/service/ImageIngressTest.java
@@ -0,0 +1,193 @@
+package com.xtrh.node.service;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.common.config.Settings;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.env.MockEnvironment;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.BooleanSupplier;
+import java.util.zip.CRC32;
+import static com.xtrh.common.util.Jsons.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class ImageIngressTest {
+ @TempDir Path directory;
+ private static final String TASK = "image-task/1";
+
+ @Test void existingPxhjEncryptedFragmentsReassembleOutOfOrderAndPublishTheExactJpeg() throws Exception {
+ try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1")) {
+ permit(ingress);
+ assertTrue(ingress.ready(TASK), "地址协商不能等待首帧");
+ assertFalse(ingress.imageReady(TASK));
+ byte[] jpeg = PxhjWireOracle.jpeg(1);
+ List packets = new ArrayList<>(PxhjWireOracle.chunks(jpeg, 1, System.currentTimeMillis(), 80, 60));
+ assertTrue(packets.size() > 1);
+ Collections.reverse(packets);
+ packets.add(packets.get(0));
+ PxhjWireOracle.send(sender, ingress.inputPort(), packets);
+ await(() -> ingress.latest(TASK) != null);
+ ImageIngressService.Frame frame = ingress.latest(TASK);
+ assertArrayEquals(jpeg, frame.jpeg());
+ assertEquals(80, frame.metadata().path("width").asInt());
+ assertEquals(jpeg.length, frame.metadata().path("bytes").asInt());
+ assertTrue(ingress.imageReady(TASK));
+ assertFalse(ingress.status().toString().contains("sm4Key"));
+ }
+ }
+
+ @Test void wrongSourceCollectorKeyAndTamperedAuthenticationNeverPublishAFrame() throws Exception {
+ try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1"); DatagramSocket other = sender("127.0.0.2")) {
+ permit(ingress);
+ List good = PxhjWireOracle.chunks(PxhjWireOracle.jpeg(2), 2, System.currentTimeMillis(), 80, 60);
+ PxhjWireOracle.send(other, ingress.inputPort(), good);
+ await(() -> ingress.status().path("rejected").asLong() >= good.size());
+ List collector = mutate(good, 9), cipher = mutate(good, 53);
+ PxhjWireOracle.send(sender, ingress.inputPort(), collector);
+ PxhjWireOracle.send(sender, ingress.inputPort(), cipher);
+ ObjectNode wrong = entry().deepCopy();
+ wrong.withObject("imageAccess").put("hmacKey", Base64.getEncoder().encodeToString(new byte[32]));
+ ingress.applyPlans(array().add(wrong), System.nanoTime() + TimeUnit.SECONDS.toNanos(30));
+ PxhjWireOracle.send(sender, ingress.inputPort(), good);
+ await(() -> ingress.status().path("rejected").asLong() >= good.size() * 4L);
+ assertNull(ingress.latest(TASK));
+ }
+ }
+
+ @Test void incompleteImageExpiresWithoutBecomingAFrameAndReleasesItsBudget() throws Exception {
+ AtomicLong nanos = new AtomicLong(System.nanoTime());
+ try (ImageIngressService ingress = new ImageIngressService(settings(), 0, System::currentTimeMillis, nanos::get); DatagramSocket sender = sender("127.0.0.1")) {
+ assertTrue(ingress.ensureStarted());
+ ingress.applyPlans(array().add(entry()), nanos.get() + TimeUnit.SECONDS.toNanos(30));
+ List chunks = PxhjWireOracle.chunks(PxhjWireOracle.jpeg(3), 3, System.currentTimeMillis(), 80, 60);
+ PxhjWireOracle.send(sender, ingress.inputPort(), chunks.subList(0, 1));
+ await(() -> ingress.status().path("pending").asInt() == 1);
+ assertNull(ingress.latest(TASK));
+ nanos.addAndGet(TimeUnit.SECONDS.toNanos(6));
+ assertEquals(0, ingress.status().path("pending").asInt());
+ assertEquals(0, ingress.status().path("bufferedBytes").asLong());
+ }
+ }
+
+ @Test void metadataFrameReferenceSurvivesNewFramesUntilTheBoundedThreeFrameCacheEvictsIt() throws Exception {
+ try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1")) {
+ permit(ingress);
+ long now = System.currentTimeMillis();
+ PxhjWireOracle.send(sender, ingress.inputPort(), PxhjWireOracle.chunks(PxhjWireOracle.jpeg(4), 4, now, 80, 60));
+ await(() -> ingress.latest(TASK) != null);
+ String oldest = ingress.latest(TASK).frameId();
+ for (int count = 1; count <= 2; count++) {
+ String previous = ingress.latest(TASK).frameId();
+ PxhjWireOracle.send(sender, ingress.inputPort(), PxhjWireOracle.chunks(PxhjWireOracle.jpeg(4 + count), 4 + count, now + count, 80, 60));
+ await(() -> !ingress.latest(TASK).frameId().equals(previous));
+ }
+ assertNotNull(ingress.frame(TASK, oldest));
+ PxhjWireOracle.send(sender, ingress.inputPort(), PxhjWireOracle.chunks(PxhjWireOracle.jpeg(7), 7, now + 3, 80, 60));
+ await(() -> ingress.frame(TASK, oldest) == null);
+ }
+ }
+
+ @Test void RevocationAndMonotonicLeaseExpiryRemoveCachedImagesAndPreventMoreUploads() throws Exception {
+ AtomicLong nanos = new AtomicLong(System.nanoTime());
+ try (ImageIngressService ingress = new ImageIngressService(settings(), 0, System::currentTimeMillis, nanos::get); DatagramSocket sender = sender("127.0.0.1")) {
+ assertTrue(ingress.ensureStarted());
+ ingress.applyPlans(array().add(entry()), nanos.get() + TimeUnit.SECONDS.toNanos(30));
+ List chunks = PxhjWireOracle.chunks(PxhjWireOracle.jpeg(8), 8, System.currentTimeMillis(), 80, 60);
+ PxhjWireOracle.send(sender, ingress.inputPort(), chunks);
+ await(() -> ingress.latest(TASK) != null);
+ assertTrue(ingress.imageReady(TASK));
+ nanos.addAndGet(TimeUnit.SECONDS.toNanos(6));
+ assertFalse(ingress.imageReady(TASK), "缓存存在不表示新鲜原图仍可迁移接管");
+ assertNotNull(ingress.latest(TASK), "有效许可内仍可明确取得最后一张真实原图");
+ ingress.applyPlans(array(), nanos.get() + TimeUnit.SECONDS.toNanos(30));
+ assertNull(ingress.latest(TASK));
+ assertFalse(ingress.ready(TASK));
+ PxhjWireOracle.send(sender, ingress.inputPort(), chunks);
+ await(() -> ingress.status().path("rejected").asInt() >= chunks.size());
+ ingress.applyPlans(array().add(entry()), nanos.get() + TimeUnit.SECONDS.toNanos(1));
+ nanos.addAndGet(TimeUnit.SECONDS.toNanos(2));
+ assertFalse(ingress.ready(TASK));
+ }
+ }
+
+ @Test void ImageCrcDimensionsAndCaptureTimeAreValidatedAfterSuccessfulWireAuthentication() throws Exception {
+ try (ImageIngressService ingress = ingress(); DatagramSocket sender = sender("127.0.0.1")) {
+ permit(ingress);
+ byte[] jpeg = PxhjWireOracle.jpeg(9), corrupted = jpeg.clone();
+ corrupted[0] = 0;
+ List badMarker = PxhjWireOracle.chunks(corrupted, 9, System.currentTimeMillis(), 80, 60);
+ List badDimensions = PxhjWireOracle.chunks(jpeg, 10, System.currentTimeMillis(), 1, 1);
+ List stale = PxhjWireOracle.chunks(jpeg, 11, System.currentTimeMillis() - 120_000, 80, 60);
+ List badCrc = PxhjWireOracle.chunks(jpeg, 12, System.currentTimeMillis(), 80, 60, true);
+ PxhjWireOracle.send(sender, ingress.inputPort(), badMarker);
+ PxhjWireOracle.send(sender, ingress.inputPort(), badDimensions);
+ PxhjWireOracle.send(sender, ingress.inputPort(), stale);
+ PxhjWireOracle.send(sender, ingress.inputPort(), badCrc);
+ await(() -> ingress.status().path("accepted").asInt() + ingress.status().path("rejected").asInt()
+ >= badMarker.size() + badDimensions.size() + stale.size() + badCrc.size());
+ assertNull(ingress.latest(TASK));
+ assertFalse(ingress.imageReady(TASK));
+ }
+ }
+
+ @Test void InvalidSourceAndTruncatedCollectorPermitFailClosedWithoutExposingKeys() throws Exception {
+ try (ImageIngressService ingress = ingress()) {
+ for (String source : List.of("", "localhost", "0.0.0.0", "127.0.0.999")) {
+ ObjectNode entry = entry(); entry.withObject("imageAccess").put("sourceHost", source);
+ ingress.applyPlans(array().add(entry), System.nanoTime() + TimeUnit.SECONDS.toNanos(30));
+ assertFalse(ingress.ready(TASK));
+ }
+ ObjectNode entry = entry(); entry.withObject("imageAccess").put("collectorId", 4294967299L);
+ ingress.applyPlans(array().add(entry), System.nanoTime() + TimeUnit.SECONDS.toNanos(30));
+ assertFalse(ingress.ready(TASK));
+ assertEquals("原图接入许可无效", ingress.accessError(TASK));
+ assertFalse(ingress.status().toString().contains(Base64.getEncoder().encodeToString(PxhjWireOracle.HMAC)));
+ }
+ }
+
+ private ImageIngressService ingress() throws Exception {
+ ImageIngressService ingress = new ImageIngressService(settings(), 0);
+ assertTrue(ingress.ensureStarted()); return ingress;
+ }
+ private Settings settings() throws Exception {
+ return new Settings(new MockEnvironment().withProperty("fusion.role", "worker")
+ .withProperty("fusion.token", "image-test-credential-only").withProperty("server.address", "127.0.0.1")
+ .withProperty("fusion.data-dir", directory.toString()).withProperty("fusion.discovery-enabled", "false"));
+ }
+ private void permit(ImageIngressService ingress) { ingress.applyPlans(array().add(entry()), System.nanoTime() + TimeUnit.SECONDS.toNanos(30)); }
+ static ObjectNode entry() {
+ ObjectNode entry = object().put("taskId", "image-task").put("name", "原图").put("trialName", "试验准备")
+ .put("desired", "READY").put("mediaKind", "ORIGINAL_IMAGE").put("sourceEndpointId", "endpoint-test")
+ .put("sourceHost", "127.0.0.1").put("collectorId", 3);
+ entry.set("assignment", object().put("version", 1).put("mediaKey", "image-play-key"));
+ entry.set("imageAccess", object().put("endpointId", "endpoint-test").put("sourceHost", "127.0.0.1").put("collectorId", 3)
+ .put("sm4Key", Base64.getEncoder().encodeToString(PxhjWireOracle.SM4))
+ .put("hmacKey", Base64.getEncoder().encodeToString(PxhjWireOracle.HMAC)));
+ return entry;
+ }
+ static void await(BooleanSupplier predicate) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (System.nanoTime() < deadline) { if (predicate.getAsBoolean()) return; Thread.sleep(10); }
+ fail("原图 UDP 接收超时");
+ }
+ private static DatagramSocket sender(String address) throws Exception { return new DatagramSocket(0, InetAddress.getByName(address)); }
+ private List mutate(List packets, int offset) {
+ List result = new ArrayList<>();
+ for (byte[] packet : packets) {
+ byte[] changed = packet.clone(); changed[offset] ^= 1;
+ CRC32 crc = new CRC32(); crc.update(changed, 0, changed.length - 8);
+ ByteBuffer.wrap(changed, changed.length - 8, 4).putInt((int) crc.getValue());
+ result.add(changed);
+ }
+ return result;
+ }
+}
diff --git a/src/test/java/com/xtrh/node/service/OriginalImageIntegrationTest.java b/src/test/java/com/xtrh/node/service/OriginalImageIntegrationTest.java
new file mode 100644
index 0000000..f36b4b8
--- /dev/null
+++ b/src/test/java/com/xtrh/node/service/OriginalImageIntegrationTest.java
@@ -0,0 +1,130 @@
+package com.xtrh.node.service;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import com.xtrh.common.config.Settings;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.communication.security.Signatures;
+import com.xtrh.control.service.CoordinatorService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.Timeout;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.context.ApplicationContext;
+import org.springframework.test.annotation.DirtiesContext;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+
+import javax.imageio.ImageIO;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.net.URI;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Predicate;
+import java.util.Base64;
+import java.util.UUID;
+
+import static com.xtrh.common.util.Jsons.object;
+import static org.junit.jupiter.api.Assertions.*;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = {
+ "fusion.role=coordinator", "fusion.capacity=1", "fusion.discovery-enabled=false",
+ "fusion.advertised-host=127.0.0.1", "fusion.token=original-image-integration-test-only", "fusion.image-port=0"
+})
+@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
+class OriginalImageIntegrationTest {
+ private static final Path DATA_DIRECTORY = temporaryDirectory();
+ @LocalServerPort int port;
+ @Autowired Settings settings;
+ @Autowired PeerClient peer;
+ @Autowired ApplicationContext application;
+
+ @DynamicPropertySource static void storage(DynamicPropertyRegistry registry) {
+ registry.add("fusion.data-dir", DATA_DIRECTORY::toString);
+ }
+
+ @Test
+ @Timeout(45)
+ void realEncryptedOriginalImageIsAvailableDuringPreparationWithAuthenticatedBinaryHttp() throws Exception {
+ String base = "http://127.0.0.1:" + port;
+ waitFor(base, snapshot -> snapshot.path("nodes").size() == 1 && snapshot.path("nodes").get(0).path("online").asBoolean());
+ CoordinatorService control = application.getBean(CoordinatorService.class);
+ String endpoint = UUID.randomUUID().toString();
+ ObjectNode source = object().put("endpointId", endpoint).put("name", "准备原图设备")
+ .put("sourceHost", "127.0.0.1").put("collectorId", 3)
+ .put("sm4Key", Base64.getEncoder().encodeToString(PxhjWireOracle.SM4))
+ .put("hmacKey", Base64.getEncoder().encodeToString(PxhjWireOracle.HMAC));
+ control.imageSource(source);
+ JsonNode ready = waitFor(base, snapshot -> {
+ for (JsonNode task : snapshot.path("tasks")) if (task.path("sourceEndpointId").asText().equals(endpoint))
+ return task.path("status").asText().equals("READY");
+ return false;
+ });
+ JsonNode task = null;
+ for (JsonNode candidate : ready.path("tasks")) if (candidate.path("sourceEndpointId").asText().equals(endpoint)) task = candidate;
+ assertNotNull(task);
+ assertEquals("READY", task.path("desired").asText(), "原图接入不得启动正式轮次");
+ String taskId = task.path("taskId").asText();
+ JsonNode route = control.imageRoutes(object().set("endpointIds", com.xtrh.common.util.Jsons.array().add(endpoint))).path("routes").get(0);
+ long version = route.path("version").asLong();
+ String path = "/node/images/" + taskId + "/" + version;
+ HttpResponse noFrame = signedGet(base, path + "/latest");
+ assertEquals(404, noFrame.statusCode());
+ JsonNode imageRoute = control.imageSource(source);
+ assertEquals("READY", imageRoute.path("status").asText());
+ int udpPort = imageRoute.path("targets").get(0).path("port").asInt();
+ assertTrue(udpPort > 0);
+ byte[] jpeg = PxhjWireOracle.jpeg(99);
+ try (DatagramSocket sender = new DatagramSocket(0, InetAddress.getByName("127.0.0.1"))) {
+ PxhjWireOracle.send(sender, udpPort, PxhjWireOracle.chunks(jpeg, 99, System.currentTimeMillis(), 80, 60));
+ }
+ ImageIngressTest.await(() -> application.getBean(WorkerService.class).originalFrame(taskId, version, null) != null);
+ JsonNode metadata = new com.fasterxml.jackson.databind.ObjectMapper().readTree(signedGet(base, path + "/latest").body());
+ assertEquals(endpoint, metadata.path("endpointId").asText());
+ assertEquals(jpeg.length, metadata.path("bytes").asInt());
+ HttpResponse frame = signedGet(base, path + "/frames/" + metadata.path("frameId").asText());
+ assertEquals(200, frame.statusCode());
+ assertEquals("image/jpeg", frame.headers().firstValue("Content-Type").orElseThrow());
+ assertArrayEquals(jpeg, frame.body(), "节点输出必须保留设备 JPEG 原字节");
+ assertNotNull(ImageIO.read(new ByteArrayInputStream(frame.body())));
+ assertEquals(404, signedGet(base, "/node/images/" + taskId + "/" + (version + 1) + "/latest").statusCode());
+ HttpResponse anonymous = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(base + path + "/latest")).GET().build(), HttpResponse.BodyHandlers.ofByteArray());
+ assertEquals(401, anonymous.statusCode());
+ assertFalse(peer.request(base, "/control/snapshot", "GET", null).toString().contains("sm4Key"));
+ }
+
+ private HttpResponse signedGet(String base, String path) throws Exception {
+ String time = Long.toString(System.currentTimeMillis()), nonce = UUID.randomUUID().toString();
+ HttpRequest request = HttpRequest.newBuilder(URI.create(base + path)).timeout(Duration.ofSeconds(3))
+ .header("X-Xtrh-Time", time).header("X-Xtrh-Nonce", nonce)
+ .header("X-Xtrh-Signature", Signatures.request(settings.token, "GET", path, time, nonce, new byte[0])).GET().build();
+ HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofByteArray());
+ assertEquals(Signatures.response(settings.token, nonce, response.statusCode(), response.body()),
+ response.headers().firstValue("X-Xtrh-Response-Signature").orElse(null));
+ return response;
+ }
+
+ private JsonNode waitFor(String base, Predicate expected) throws Exception {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
+ while (System.nanoTime() < deadline) {
+ JsonNode snapshot = peer.request(base, "/control/snapshot", "GET", null);
+ if (expected.test(snapshot)) return snapshot;
+ Thread.sleep(100);
+ }
+ throw new AssertionError("隔离主节点自动注册或任务准备超时");
+ }
+
+ private static Path temporaryDirectory() {
+ try { return Files.createTempDirectory("xtrh-combined-master-test-"); }
+ catch (IOException e) { throw new IllegalStateException("无法创建隔离测试目录", e); }
+ }
+}
diff --git a/src/test/java/com/xtrh/node/service/PxhjWireOracle.java b/src/test/java/com/xtrh/node/service/PxhjWireOracle.java
new file mode 100644
index 0000000..17c51ed
--- /dev/null
+++ b/src/test/java/com/xtrh/node/service/PxhjWireOracle.java
@@ -0,0 +1,87 @@
+package com.xtrh.node.service;
+
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import javax.crypto.Cipher;
+import javax.crypto.Mac;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import javax.imageio.ImageIO;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.net.DatagramPacket;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.security.SecureRandom;
+import java.security.Security;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Random;
+import java.util.zip.CRC32;
+
+/**
+ * 测试发送方以现有 E:/pxhj/backend/.../telemetry/codec/WireCodec.encode 为独立协议基准。
+ * 这里保留该发送端的字段顺序、MAC/CRC 范围和 SM4 参数,不调用 XTRH 解码器或重组服务。
+ * JPG1 头对应现有 ImageFrameStore 与嵌入式 PlatformImageSender 的 36 字节规范。
+ */
+final class PxhjWireOracle {
+ static final byte[] SM4 = new byte[16], HMAC = new byte[48];
+ private static final SecureRandom RANDOM = new SecureRandom();
+ static {
+ if (Security.getProvider("BC") == null) Security.addProvider(new BouncyCastleProvider());
+ new Random(441).nextBytes(SM4);
+ new Random(442).nextBytes(HMAC);
+ }
+
+ static byte[] encode(int type, int module, byte[] payload, byte[] sm4, byte[] hmac, long now) throws Exception {
+ if (payload.length > 1024) throw new IllegalArgumentException();
+ byte[] iv = new byte[16]; RANDOM.nextBytes(iv);
+ Cipher cipher = Cipher.getInstance("SM4/CBC/PKCS7Padding", "BC");
+ cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(sm4, "SM4"), new IvParameterSpec(iv));
+ byte[] encrypted = cipher.doFinal(payload);
+ var buffer = ByteBuffer.allocate(93 + encrypted.length).order(ByteOrder.BIG_ENDIAN);
+ buffer.putInt(0x5AA5FAFB).putInt(buffer.capacity()).put((byte) type).put((byte) module).put((byte) RANDOM.nextInt(256));
+ buffer.putLong(now).putLong(RANDOM.nextLong());
+ buffer.put((byte) 1).put((byte) 1).putInt(1).put(iv).putInt(encrypted.length).put(encrypted);
+ Mac mac = Mac.getInstance("HMACSM3", "BC");
+ mac.init(new SecretKeySpec(hmac, "HMACSM3"));
+ mac.update(buffer.array(), 4, buffer.position() - 4);
+ buffer.put(mac.doFinal());
+ CRC32 crc = new CRC32(); crc.update(buffer.array(), 0, buffer.position());
+ buffer.putInt((int) crc.getValue()).putInt(0xA55AFCFD);
+ return buffer.array();
+ }
+
+ static List chunks(byte[] jpeg, long frame, long sourceTime, int width, int height) throws Exception {
+ return chunks(jpeg, frame, sourceTime, width, height, false);
+ }
+
+ static List chunks(byte[] jpeg, long frame, long sourceTime, int width, int height, boolean wrongImageCrc) throws Exception {
+ int count = (jpeg.length + 987) / 988;
+ CRC32 crc = new CRC32(); crc.update(jpeg);
+ List packets = new ArrayList<>();
+ for (int index = 0; index < count; index++) {
+ int size = Math.min(988, jpeg.length - index * 988);
+ ByteBuffer payload = ByteBuffer.allocate(36 + size).order(ByteOrder.BIG_ENDIAN);
+ payload.putInt(0x4A504731).putLong(frame).putLong(sourceTime).putInt(jpeg.length)
+ .putShort((short) index).putShort((short) count).putInt((int) crc.getValue() ^ (wrongImageCrc ? 1 : 0))
+ .putShort((short) width).putShort((short) height).put(jpeg, index * 988, size);
+ packets.add(encode(0x12, 3, payload.array(), SM4, HMAC, System.currentTimeMillis()));
+ }
+ return packets;
+ }
+
+ static byte[] jpeg(int seed) throws Exception {
+ BufferedImage image = new BufferedImage(80, 60, BufferedImage.TYPE_INT_RGB);
+ Random random = new Random(seed);
+ for (int y = 0; y < image.getHeight(); y++) for (int x = 0; x < image.getWidth(); x++) image.setRGB(x, y, random.nextInt());
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ ImageIO.write(image, "JPEG", bytes);
+ return bytes.toByteArray();
+ }
+
+ static void send(DatagramSocket socket, int port, List packets) throws Exception {
+ for (byte[] bytes : packets) socket.send(new DatagramPacket(bytes, bytes.length, InetAddress.getByName("127.0.0.1"), port));
+ }
+}
diff --git a/src/test/java/com/xtrh/node/service/WorkerTest.java b/src/test/java/com/xtrh/node/service/WorkerTest.java
new file mode 100644
index 0000000..8acd49e
--- /dev/null
+++ b/src/test/java/com/xtrh/node/service/WorkerTest.java
@@ -0,0 +1,152 @@
+package com.xtrh.node.service;
+
+import com.xtrh.discovery.service.CoordinatorLocator;
+import com.xtrh.discovery.service.Discovery;
+import com.xtrh.common.util.Jsons;
+import com.xtrh.communication.client.PeerClient;
+import com.xtrh.common.config.Settings;
+import com.xtrh.node.controller.WorkerController;
+
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.springframework.mock.env.MockEnvironment;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Path;
+import java.net.DatagramSocket;
+import java.net.InetAddress;
+import java.util.concurrent.atomic.AtomicReference;
+
+import static com.xtrh.common.util.Jsons.*;
+import static org.junit.jupiter.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.Mockito.*;
+
+class WorkerTest {
+ @TempDir Path directory;
+
+ @Test void runningTransitionKeepsExistingStreamConnectedToCurrentTaskObject() throws Exception {
+ Settings settings = settings();
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any()))
+ .thenReturn(object().put("sessionId", "test-session"));
+ when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any()))
+ .thenReturn(plan("READY", 8000), plan("RUNNING", 8000));
+ WorkerService worker = new WorkerService(settings, locator, peer);
+ worker.heartbeat();
+ WorkerService.MediaTask original = worker.playable("task-a", 1, "media-key-a");
+ var stream = new WorkerController(worker).stream("task-a", 1, "media-key-a");
+ worker.heartbeat();
+ WorkerService.MediaTask replacement = worker.playable("task-a", 1, "media-key-a");
+ assertNotSame(original, replacement);
+ assertEquals("RUNNING", replacement.desired);
+ original.deadlineNanos = 0;
+
+ // 播放连接必须每帧查询当前任务;准备状态对象到期不能断掉已经开始的同版本视频。
+ ByteArrayOutputStream written = new ByteArrayOutputStream();
+ OutputStream firstFrameOnly = new OutputStream() {
+ @Override public void write(int value) throws IOException { written.write(value); throw new IOException("test stream closed"); }
+ @Override public void write(byte[] bytes, int offset, int length) throws IOException {
+ written.write(bytes, offset, length); throw new IOException("test stream closed");
+ }
+ };
+ assertThrows(IOException.class, () -> stream.getBody().writeTo(firstFrameOnly));
+ assertTrue(written.toString(java.nio.charset.StandardCharsets.US_ASCII).startsWith("--frame"));
+ }
+
+ @Test void delayedPlanCannotExtendExpiredExecutionLeaseFromResponseArrivalTime() throws Exception {
+ Settings settings = settings();
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any()))
+ .thenReturn(object().put("sessionId", "test-session"));
+ when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any())).thenAnswer(invocation -> {
+ Thread.sleep(20);
+ return plan("RUNNING", 1);
+ });
+ WorkerService worker = new WorkerService(settings, locator, peer);
+ worker.heartbeat();
+ assertEquals("WAITING", worker.status().path("connection").asText());
+ assertEquals(0, worker.status().path("tasks").asInt());
+ assertThrows(IllegalArgumentException.class, () -> worker.playable("task-a", 1, "media-key-a"));
+ }
+
+ @Test void originalImageIsIntakeReadyBeforeFirstFrameAndNeverFallsBackToDemoJpeg() throws Exception {
+ Settings settings = settings();
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ AtomicReference report = new AtomicReference<>(), registration = new AtomicReference<>();
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any())).thenAnswer(call -> {
+ registration.set(((ObjectNode) call.getArgument(3)).deepCopy());
+ return object().put("sessionId", "image-session");
+ });
+ ObjectNode plan = object().put("leaseMs", 8000);
+ plan.set("tasks", array().add(ImageIngressTest.entry()));
+ when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any())).thenAnswer(call -> {
+ report.set(((ObjectNode) call.getArgument(3)).deepCopy()); return plan;
+ });
+ try (ImageIngressService images = new ImageIngressService(settings, 0);
+ DatagramSocket sender = new DatagramSocket(0, InetAddress.getByName("127.0.0.1"))) {
+ WorkerService worker = new WorkerService(settings, locator, peer, images);
+ worker.heartbeat(); worker.frames(); worker.heartbeat();
+ assertEquals(images.inputPort(), registration.get().path("imageInputPort").asInt());
+ assertEquals("127.0.0.1", registration.get().path("imageHost").asText());
+ assertTrue(report.get().path("reports").get(0).path("ready").asBoolean());
+ assertFalse(report.get().path("reports").get(0).path("imageReady").asBoolean());
+ assertNull(worker.frame("image-task", 1, "image-play-key"));
+ byte[] jpeg = PxhjWireOracle.jpeg(30);
+ PxhjWireOracle.send(sender, images.inputPort(), PxhjWireOracle.chunks(jpeg, 30, System.currentTimeMillis(), 80, 60));
+ ImageIngressTest.await(() -> worker.originalFrame("image-task", 1, null) != null);
+ worker.frames(); worker.heartbeat();
+ assertArrayEquals(jpeg, worker.frame("image-task", 1, "image-play-key"));
+ assertTrue(report.get().path("reports").get(0).path("imageReady").asBoolean());
+ assertNotNull(report.get().path("reports").get(0).get("lastFrameId"));
+ WorkerService.MediaTask active = worker.playable("image-task", 1, "image-play-key");
+ active.deadlineNanos = 0;
+ assertNull(worker.originalFrame("image-task", 1, null), "私有接口不能在任务租约结束后借用近期缓存");
+ assertThrows(IllegalArgumentException.class, () -> worker.frame("image-task", 1, "image-play-key"));
+ assertNull(worker.originalFrame("image-task", 2, null));
+ assertFalse(worker.status().toString().contains("imageAccess"));
+ }
+ }
+
+ @Test void revokedOriginalAssignmentStopsExistingMediaAndPrivateFrameLookups() throws Exception {
+ Settings settings = settings();
+ CoordinatorLocator locator = mock(CoordinatorLocator.class);
+ PeerClient peer = mock(PeerClient.class);
+ when(locator.locate()).thenReturn("http://127.0.0.1:18100");
+ when(peer.request(anyString(), eq("/control/nodes/register"), eq("POST"), any()))
+ .thenReturn(object().put("sessionId", "image-session"));
+ ObjectNode assigned = object().put("leaseMs", 8000), revoked = object().put("leaseMs", 8000);
+ assigned.set("tasks", array().add(ImageIngressTest.entry())); revoked.set("tasks", array());
+ when(peer.request(anyString(), contains("/heartbeat"), eq("POST"), any())).thenReturn(assigned, revoked);
+ try (ImageIngressService images = new ImageIngressService(settings, 0)) {
+ WorkerService worker = new WorkerService(settings, locator, peer, images);
+ worker.heartbeat(); assertTrue(images.ready("image-task/1"));
+ worker.heartbeat(); assertFalse(images.ready("image-task/1"));
+ assertNull(worker.originalFrame("image-task", 1, null));
+ assertThrows(IllegalArgumentException.class, () -> worker.frame("image-task", 1, "image-play-key"));
+ }
+ }
+
+ private Settings settings() throws Exception {
+ return new Settings(new MockEnvironment().withProperty("fusion.role", "worker")
+ .withProperty("fusion.token", "worker-unit-test-credential-only").withProperty("fusion.data-dir", directory.toString())
+ .withProperty("fusion.advertised-host", "127.0.0.1").withProperty("fusion.discovery-enabled", "false"));
+ }
+
+ private ObjectNode plan(String phase, long leaseMs) {
+ ObjectNode task = object().put("taskId", "task-a").put("name", "演示画面").put("trialName", "测试试验").put("desired", phase);
+ task.set("assignment", object().put("version", 1).put("mediaKey", "media-key-a"));
+ ObjectNode result = object().put("leaseMs", leaseMs);
+ result.set("tasks", array().add(task));
+ return result;
+ }
+}
diff --git a/启动系统融合软件.cmd b/启动系统融合软件.cmd
new file mode 100644
index 0000000..1eb6479
--- /dev/null
+++ b/启动系统融合软件.cmd
@@ -0,0 +1,11 @@
+@echo off
+setlocal
+chcp 65001 >nul
+cd /d "%~dp0"
+where pwsh.exe >nul 2>nul
+if %errorlevel%==0 (
+ pwsh.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Open-Console.ps1" -OpenBrowser %*
+) else (
+ powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\Open-Console.ps1" -OpenBrowser %*
+)
+if errorlevel 1 pause
diff --git a/总控与多机融合服务开发方案.md b/总控与多机融合服务开发方案.md
new file mode 100644
index 0000000..1dea88b
--- /dev/null
+++ b/总控与多机融合服务开发方案.md
@@ -0,0 +1,644 @@
+# 总控与多机融合服务开发方案
+
+日期:2026-10-15
+项目工作空间:`E:\Codex\xtrh`
+文档状态:本阶段实现说明与后续设计。第 0 节列出当前已实现范围和验证证据,其余涉及真实媒体、仿真和录像的内容属于后续设计,不能视为已实现或通过设备验收。
+
+## 0. 最新开发范围与验证状态
+
+2026-10-15 已加入上传地址协商与节点原图接收:嵌入式通过网关请求接收地址,PXHJ 按原设备密钥认证,再由总控按节点容量分配;图片原分片直接上传节点,准备页面沿已有图片 API 向当前节点取原图,不以正式试验开始为前提。迁移期间最多向两个节点上传,候选收到完整首帧后切换。许可到期或有效等待回复使设备回到已配置的 PXHJ 原图片地址。
+
+Java 服务已在本机加载新版。XTRH 38 项测试全部通过;加入 PXHJ 原图快照缓存、完整正文超时和批量缓存淘汰修复后,PXHJ 411 项回归无失败、错误,跳过 30 项。Windows 独立嵌入式组件与实际 C++ / PXHJ Java 加密报文双向互操作通过;RK3588 已完成完整 ARM 构建、部署并开启协商,现有 PXHJ 准备页面已取得原图。板上日志测试 9 项通过,图片协商 8 项通过、1 项可选夹具跳过。当前视频输入为已有文件经 RTP 输入真实板卡,尚未验收物理摄像机、多台物理电脑或真实融合。流程见 [图片直传协商与准备取图](E:/Codex/xtrh/docs/图片直传协商与准备取图.md),实际配置和迁移证据见 [开发板联调记录](E:/Codex/xtrh/docs/开发板图片协商联调记录.md)。
+
+### 0.1 上一阶段范围与验证(2026-10-14)
+
+按用户最新要求,本阶段使用 Java 实现总控、节点自动发现注册、动态路数任务分发和原始 UDP 转发,并接入 PXHJ 的正式试验页面统一操作。真实视频融合暂不开发,只提供明确标记的演示 MJPEG 画面。
+
+| 功能 | 当前状态 |
+|---|---|
+| 总控、网关、多个 Worker | 同一个 Java JAR 的三种部署角色;总控默认在同一进程兼任执行节点,网关独立转发 |
+| 主节点执行能力 | 默认开启、默认容量 2 路,与控制接口共用 18100 端口和稳定身份;可配置只调度模式 |
+| 自动发现与注册 | mDNS / DNS-SD,发现后验证签名与总控身份;节点不需要预填总控地址 |
+| 动态路数分配 | 每节点 0~64 路,准备任务计入容量;先准备新节点,再切换正式输出;容量不足保留原输出 |
+| UDP 转发 | 按登记来源识别采集器,不解析、不改写原始字节,使用独立转发源端口;支持反向原字节回执 |
+| 重启与失联保护 | H2 持久化、启动会话、任务版本和限时许可;总控重启后要求 PXHJ 重确认试验上下文 |
+| PXHJ 统一操作 | 已通过真实业务服务、会话/CSRF、隔离 H2 与实际总控/节点串联验证;主达梦实例已加载新版并实际同步总控状态 |
+| 视频地址同步 | 节点注册对外地址并回报就绪,总控登记有效播放地址;PXHJ 后台签名轮询快照并保存播放信息;没有主动推送回调 |
+| 真实视频与仿真 | 尚未实现真实输入、仿真数据协议、目标图层、标定、时间匹配、视频融合和录制 |
+| 性能感知调度及高可用 | 尚未实现 CPU/GPU 负载测量自动调整容量、总控/网关主备切换 |
+
+2026-10-14 分层后的 XTRH 原有 19 项测试及新增主节点执行能力 5 项测试全部通过,共 **24 项**。新增随机端口测试验证同一主进程自动注册唯一主身份、执行一条 READY 任务,并从该主端口实际读取、解码 JPEG。总控只调度模式、普通 worker 与 gateway 的组件条件也已验证。主执行能力版本已经打包并升级正式本机总控。
+
+PXHJ 上一阶段新增融合后端 17 项针对性测试和 1 项真实业务串联测试通过。业务串联在隔离 H2 中通过正式业务服务创建试验,经登录会话/CSRF 和正式接口层连接实际总控、子节点,验证了保存装备绑定的五路任务、2+3 分配、1+4 动态迁移、正式轮次开始/结束,以及各路 MJPEG 的 JPEG 首帧。
+
+PXHJ 全后端回归和打包已完成,报告共 392 项测试,失败 0、错误 0、跳过 30 项。独立构建目录为 `runtime/pxhj-build`,产物为 [pxhj-server-0.1.0.jar](E:/Codex/xtrh/runtime/pxhj-build/pxhj-server-0.1.0.jar)。跳过的实时外部服务和达梦 opt-in 项不代表现场验证已通过;真实业务串联已另行显式运行。
+
+实际在一台 Windows 电脑上启动总控、网关和两个节点,未配置节点总控 URL,验证了自动发现注册和原字节双向 UDP;结束测试轮次后 UDP 仍正常转发。PXHJ 另有两项中转 UDP 测试,覆盖实际加密数据与心跳接收、来源及密钥校验和动态配置回程地址。
+
+这些结果是本机多进程、隔离 H2 和正式业务实现的软件验证,不能替代多台物理电脑、真实采集器或达梦主实例的现场验收。详细证据见 [本机验证记录](E:/Codex/xtrh/docs/本机验证记录.md),构建和部署方式见 [README](E:/Codex/xtrh/README.md)。
+
+目前新版默认端口角色已经运行:主总控 18100 同时控制和执行、主容量 2,网关 HTTP 18110 / UDP 19002,子节点 A 18120 / 容量 2、B 18121 / 容量 3。只读签名快照确认 **三个在线执行节点、一个在线网关,总容量 7 路**。主节点只有一条 `COORDINATOR` 记录,主身份与总控一致,子节点为 `WORKER`;子角色配置均无总控 URL。隔离验证的四个 182xx 实例已按身份停止。
+
+PXHJ 达梦主后端已接通总控。本次升级只重启 18100 主进程,原身份、数据和配置保留,PXHJ 后端、前端、网关及两个子节点未再次重启。实际缓存表自动同步三个在线执行节点和一个在线网关,检查时年龄 64 毫秒;UDP 19001、图片 UDP 6020 与网关 UDP 19002 均在运行。原有一轮 `RUNNING` 保持不变,未新增或修改正式试验视频配置,未调整子节点容量,真实设备采集路径也未切换。当前生产视频任务为 0,主端口实际 JPEG 已由隔离随机端口测试验证。
+
+## 1. 要做成什么
+
+做一套可部署在多台电脑上的后台软件,由 **PXHJ 统一操作**。PXHJ 负责设计、试验组织和数据查看,新软件负责采集接入、UDP 转发、任务分发、仿真交互和视频融合。
+
+用户日常只使用 PXHJ。新软件部署好后自动运行,不再设置一套独立的试验操作界面。
+
+核心要求如下:
+
+| 要求 | 设计结果 |
+|---|---|
+| PXHJ 统一操作 | 配置、准备、开始、结束、节点路数调整及视频查看均在 PXHJ 中完成 |
+| 多台电脑参与融合 | 每台电脑运行一个融合节点,一个节点可以同时处理多路视频 |
+| 部署后自动加入 | 节点启动后自动发现本项目总控、完成认证及注册,PXHJ 自动出现节点记录 |
+| 路数动态配置 | PXHJ 修改各节点的最大并行路数,总控据此分配和调整任务 |
+| 采集数据接入迁移 | 采集器向新软件的统一入口发送数据 |
+| PXHJ 继续查看采集数据 | 新软件保持原始加密报文内容,通过 UDP 转发给 PXHJ |
+| 仿真交互 | 将当前试验所需的装备状态和相机信息发送给仿真系统 |
+| 准确融合 | 仿真目标与真实视频的坐标、相机视角和拍摄时间一致 |
+| 视频自动同步 | 节点将服务地址和就绪结果报给总控,PXHJ 后台签名轮询总控并登记播放信息,页面自动加载 |
+
+## 2. 总体架构
+
+新软件分成三个后台角色,它们属于同一个项目,可以分别启动和部署。
+
+```mermaid
+flowchart TB
+ P["PXHJ 后端与页面
试验组织、统一操作、数据和视频查看"]
+ M["主节点:同一总控进程
配置管理、任务分配、状态汇总
默认也执行本机视频任务,端口18100"]
+ G["采集接入网关
已实现原始报文双向转发
协议解析与数据分发后续实现"]
+ D["已登记采集器与采集模块"]
+ C["真实摄像头或视频源"]
+ S["仿真推演系统
后续接入:统一场景状态、多相机视角输出"]
+ W["多台融合节点
接收采集器原图、处理多路演示视频"]
+
+ P -->|试验配置与操作指令| M
+ M -->|已确认的来源与转发路由| G
+ M -->|视频任务与路数限制| W
+ D -->|采集报文、心跳与协商| G
+ G -->|原始报文 UDP 转发| P
+ D -->|协商后原 JPG 分片直传| W
+ W -->|准备阶段原图、内部签名 HTTP| P
+ P -->|原字节配置与回执| G
+ G -.->|后续:位置、姿态等带时间的数据| W
+ G -.->|后续:当前试验装备状态| S
+ C -.->|后续:真实视频输入| W
+ W -.->|后续:相机视角与渲染请求| S
+ S -.->|后续:目标图层、遮罩及帧标识| W
+ W -->|对外地址、心跳与任务就绪结果| M
+ P -->|签名轮询总控快照并登记播放信息| M
+ W -->|当前演示 MJPEG 直接供页面播放| P
+```
+
+图中实线表示当前链路,虚线表示后续真实视频与仿真链路。“直接供页面播放”是 PXHJ 浏览器连接融合节点的播放服务。总控和 PXHJ 业务后端主要处理控制与地址信息,避免所有视频都经它们转码或转运。
+
+### 三个后台角色的职责
+
+| 角色 | 主要职责 | 部署建议 |
+|---|---|---|
+| 总控服务 Coordinator | 当前接收 PXHJ 上下文、登记节点、分配任务、调整路数、管理版本、汇总状态与播放地址,供 PXHJ 轮询 | 第一版一个有效总控实例 |
+| 采集接入网关 Gateway | 当前接收已登记来源的原始 UDP 字节并双向转发;解析装备状态、时间缓存和节点数据分发为后续项 | 第一版一个网关实例,与总控可以同机,进程独立 |
+| 融合节点 Worker | 当前自动登记、领取多个任务、发布演示画面;真实视频、仿真、融合及录像为后续项 | 每台融合电脑部署一个节点,可运行多个媒体任务 |
+
+主节点在同一个总控进程中加载 `WorkerService` 和媒体控制器,自动向自身总控进行签名身份校验与注册,控制和播放共用默认端口 18100;不需要另外启动主电脑的 worker 进程。主节点使用总控已有的稳定 ID,快照标记 `nodeRole=COORDINATOR`;普通子节点标记 `WORKER`。PXHJ 统一配置主、子节点路数,主身份不会重复登记为第二个普通节点。
+
+**总控或某个融合任务发生异常时,采集接入网关的接收与转发仍独立运行。** 网关使用最近一次确认的来源及转发配置,总控恢复后再同步变化。
+
+## 3. 部署方式
+
+### 单机起步,多机扩展
+
+单机调试时,三个角色可以部署在一台电脑上,使用不同监听端口。正式扩展时,把 Worker 部署到其他电脑,网关和总控可以继续在原来的接入电脑上运行。
+
+| 电脑 | 部署内容 | 作用 |
+|---|---|---|
+| PXHJ 服务器 | 现有 PXHJ 后端、前端 | 业务数据及统一操作入口 |
+| 接入与总控电脑 | Coordinator、Gateway | 收数据、转发、管理节点和任务 |
+| 融合电脑 A | Worker、媒体处理程序、播放服务 | 按配置处理多路视频 |
+| 融合电脑 B | Worker、媒体处理程序、播放服务 | 按配置处理多路视频 |
+| 后续新增电脑 | 同样的 Worker 部署包 | 启动后自动发现总控并登记,满足容量和能力要求后接收任务 |
+
+统一部署配置预置项目集群标识、注册凭证或受信任证书、默认容量策略及媒体程序位置。同一局域网内,新增节点不要求逐台填写总控 IP,也不要求在 PXHJ 中手动添加节点。
+
+节点首次启动自动生成并保存独立节点 ID,自动选择可达网卡并登记服务地址。多网卡或特殊网络需要指定网卡、对外播放地址时,可通过统一部署配置覆盖。后续任务和路数配置均由 PXHJ 管理。
+
+### 自动发现与自动注册
+
+本方案明确包含两个功能:**先自动找到总控,再自动向总控注册**。
+
+这里的局域网发现适用于子节点和网关。主进程的执行组件等待自身 HTTP 服务就绪后,使用本机实际端口访问并验证自家总控,不依赖监听自己的 mDNS 广播。对外播放地址独立选择可访问网卡,测试或单机部署可显式指定地址。
+
+```mermaid
+flowchart TD
+ A["安装节点服务并启动"] --> B["寻找本项目总控服务"]
+ B --> C["验证集群身份及服务认证"]
+ C --> D["自动注册节点身份、能力和地址"]
+ D --> E["PXHJ 自动出现该节点"]
+ E --> F["持续心跳并领取任务"]
+ F -->|总控失联| B
+```
+
+局域网发现已采用 mDNS / DNS-SD。总控启动时发布项目专用服务类型 `_xtrh-control._tcp.local.`,携带集群标识、逻辑总控身份、接口版本、服务端口等发现信息。节点浏览该服务类型,解析可达地址后发起正式注册。mDNS 用于本地链路发现,DNS-SD 描述服务类型、地址和端口。[mDNS 规范](https://www.rfc-editor.org/rfc/rfc6762.html)、[DNS-SD 规范](https://www.rfc-editor.org/rfc/rfc6763.html)
+
+发现地址的顺序如下:
+
+1. 如统一部署包预置了固定入口或域名,先尝试该入口。
+2. 验证上次成功连接的总控地址,成功则直接恢复连接。
+3. 前两项不可用时,自动发现同一局域网内本项目的总控。
+4. 暂未找到时保持等待,当前采用约 1.5 秒固定退避,按各角色的心跳周期继续重试;总控稍后启动也能自动加入。指数退避和随机抖动属于后续优化。
+
+发现信息只用于寻找地址,正式注册仍校验项目身份和服务凭证。同网内其他项目的总控不会被误选;本项目出现多个相互冲突的有效总控时,上报总控冲突状态,避免随机切换任务归属。
+
+注册与恢复规则如下:
+
+- `nodeId` 首次启动生成并持久化,部署模板不包含所有电脑共用的节点 ID。
+- `bootId` 每次节点进程启动重新生成,用于区分旧进程和本次启动。
+- 重复注册更新同一节点记录,电脑 IP 改变后仍保持原节点身份。
+- 重启后自动注册和核对任务,旧启动会话的回报及授权失效。
+- 总控不可用时自动重连及重新发现,节点仍遵守当前任务的授权有效期。
+- 首次注册使用部署配置中的容量,后续以 PXHJ 保存的容量为准;重复注册不覆盖已调整的路数。当前尚未进行显卡能力检测或自动容量测量。
+
+Gateway 可使用同一套发现机制,通过网关登记接口加入总控;其角色与 Worker 区分,不领取视频融合任务。
+
+**发现范围:** 同一局域网、允许发现报文通过时,可以自动找到总控。跨路由或 VLAN、网络隔离或禁用组播时,统一部署配置预置可达的总控域名或入口;节点仍自动注册,不需要在 PXHJ 中逐台添加。基础网络与服务凭证由项目部署时统一配置。
+
+### 端口规划
+
+下面是当前安装脚本使用的默认值和后续接入预留值,部署时检查实际占用情况。直接运行 JAR 时应显式配置角色和端口。
+
+| 用途 | 端口 | 说明 |
+|---|---|---|
+| 总控 HTTP 接口 | 18100 | PXHJ 后端和节点访问 |
+| 局域网服务发现 | UDP 5353 | mDNS 组件按标准共享使用,与采集数据及心跳入口独立 |
+| 网关 HTTP 接口 | 18110 | 当前接入状态查询;节点数据订阅后续实现 |
+| 主节点演示播放接口 | 18100 | 同一个总控进程、身份和端口,无需额外主执行进程 |
+| 子节点管理与演示播放接口 | 18120 | 每台不同电脑可以使用相同端口 |
+| 新网关采集 UDP 入口 | 19002 | 采集器上传目标;与 PXHJ 原入口区分 |
+| PXHJ 采集 UDP 入口 | 现有配置,默认 19001 | 接收网关转发的原始报文 |
+| 新网关图片 UDP 入口 | 6021,后续预留 | 当前未实现独立图片分片组装入口;原字节路由如需转送图片须单独配置和验证 |
+| PXHJ 原图片 UDP 入口 | 现有配置,默认 6020 | 是否保留原始图片转发由项目配置决定 |
+| 原采集器 RTP 输入 | 原有 9200 配置 | 属于采集器视频输入链路,不能当成平台采集入口 |
+| 视频播放端口 | 随播放服务配置 | 地址必须从 PXHJ 浏览器所在电脑可访问 |
+
+采集器上传地址应指向稳定的网关入口。调整某台电脑的视频处理路数,不修改采集器上传地址。
+
+## 4. 功能清单
+
+本节保留最终建设目标,包含后续功能。当前可用范围以第 0 节为准:标定资料管理、仿真接口、装备状态解析与订阅、真实媒体、资源感知调度及录像未实现。
+
+### 4.1 PXHJ 需要增加或接入的功能
+
+- 在正式试验中启用采集与融合,使用该试验已保存的装备、实体模块及安装关系。
+- 配置视频通道、对应装备与模块、相机标定资料、仿真接口信息。
+- 查看节点在线状态、配置路数、当前运行路数、待分配路数和任务异常。
+- 修改节点最大并行路数,启用或停用节点;停用采用迁出任务后退出的方式。
+- 复用正式试验的准备、轮次开始和轮次结束操作,驱动后台服务。
+- 继续显示采集数据,增加融合视频观看及录像索引。
+
+普通试验页面显示业务信息;服务地址、节点部署、协议和相机标定放在有权限的配置入口。
+
+### 4.2 总控服务需要具备的功能
+
+- 接收并校验试验上下文,保存配置版本、装备与模块关系、视频通道及轮次信息。
+- 发布本项目的发现信息,支持节点自动找到总控并完成认证登记。
+- 管理节点自动登记、启动会话、心跳、能力、并行路数和任务运行状态。
+- 为每路视频创建任务,并自动分配到具备空余容量的节点。
+- 在路数变化、节点加入、持续过载、节点退出或失联时调整分配。
+- 管理任务版本和限时执行授权,过滤旧节点、旧轮次的迟到状态和播放地址。
+- 当前汇总网关与演示视频状态,供 PXHJ 后台签名轮询;仿真状态及主动推送为后续项。
+- 保存必要状态,重启后核对 PXHJ 当前执行状态,再恢复有效任务。
+- 对重复操作去重;失败的指令和上报可重试,PXHJ 能看到实际执行结果。
+
+### 4.3 采集接入网关需要具备的功能
+
+- 接收全部已登记采集器的原始数据和心跳,识别来源及模块身份。
+- 保持原始加密报文内容,通过独立转发通道发送给 PXHJ。
+- 复用现有协议规则进行校验和解析,为仿真与融合生成标准化数据。
+- 根据试验绑定关系,将模块数据对应到装备和安装部位。
+- 缓存带采样时间的有效数据,向节点分发所需装备数据。
+- 分别统计采集器心跳、模块有效数据、转发数量、接收异常和队列丢弃情况。
+- 需要使用图片上传时,独立接收、校验、组装图片分片,按通道分发。
+- 当接入的设备使用配置下发与回执时,提供对应的双向转送能力。
+
+### 4.4 融合节点需要具备的功能
+
+- 自动发现总控、注册及重连,接收任务配置,同一台电脑同时执行多路媒体任务。
+- 根据分配获取视频源、装备数据、相机参数和仿真目标图层。
+- 按帧时间匹配位置与姿态,完成图像校正、目标合成、编码及发布。
+- 上报真实运行状态、播放入口、帧率、处理延迟及错误。
+- 任务迁移、撤销或执行授权到期时,停止旧任务的有效输出。
+- 按试验、轮次和通道保存分段录像,结束后返回录像索引。
+- 管理媒体进程异常、日志轮转和磁盘保留上限。
+
+## 5. PXHJ 操作到后台执行的流程
+
+以下含真实媒体的步骤是后续目标。本阶段由 PXHJ 同步保存的试验、绑定和轮次,节点准备演示画面;尚不检查真实视频输入、标定或仿真接口,也不启动录像。
+
+### 5.1 试验准备
+
+1. 用户在 PXHJ 中选择正式试验并进入准备。
+2. PXHJ 后端读取该试验保存的装备与模块绑定,组织融合上下文并下发给总控。
+3. 总控配置网关的数据对应关系,检查节点容量、视频输入、标定资料和仿真接口。
+4. 网关持续接收与转发数据,PXHJ 可以检查模块状态。
+5. 节点建立媒体输入和预览链路,确认能取得画面及所需数据后,上报准备结果。
+6. PXHJ 显示准备情况以及缺少的条件。
+
+仅打开查看其他试验不应重新绑定正在运行的后台任务。任务切换由明确的准备或执行操作触发。
+
+### 5.2 开始与结束轮次
+
+```mermaid
+sequenceDiagram
+ participant P as PXHJ 后端
+ participant M as 总控服务
+ participant G as 接入网关
+ participant W as 融合节点
+ P->>M: 下发试验配置和准备指令
+ M->>G: 来源配置与数据分发关系
+ M->>W: 分配任务并准备媒体链路
+ W-->>M: 实际准备结果
+ M-->>P: 准备状态
+ P->>P: 确认正式轮次开始,保存开始时间
+ P->>M: 开始指令:试验、轮次、时间、操作ID
+ M->>W: 启用该轮融合与录像
+ W-->>M: 有效任务状态与播放入口
+ P->>M: 签名轮询快照
+ M-->>P: 返回视频地址和执行状态
+ P->>P: 保存正式轮次结束时间
+ P->>M: 结束该轮指令
+ M->>W: 撤销任务授权,结束该轮输出与录像
+ W-->>M: 结束结果与录像索引
+ M-->>P: 汇总结束结果
+ Note over G: 轮次结束后,采集接收与原始转发继续运行
+```
+
+试验和轮次状态以 PXHJ 为准。PXHJ 正式轮次状态与“融合启动中、运行中、异常、停止中”等后台执行状态分别记录。
+
+建议 PXHJ 在保存正式开始或结束操作时,同时保存待发送指令,由后台可靠重试发送。总控按操作 ID 去重。正式轮次已经开始而融合启动失败时,页面明确显示融合异常,按同一轮次重试,不能显示为已成功融合或另建一轮。
+
+## 6. 每台电脑的路数配置与任务分发
+
+### 6.1 配置含义
+
+主节点同样参与路数配置,默认最大 2 路,与子节点使用相同的容量预留、任务许可和迁移规则。部署参数 `fusion.coordinator-worker-enabled=false` 或安装脚本 `-CoordinatorOnly` 可关闭主执行能力,仅保留总控;关闭后不加载执行服务、媒体控制器和媒体线程池。此开关不关闭独立 worker 的执行能力,gateway 始终只承担转发。
+
+每个节点配置一个“最大同时处理路数”。实际运行路数由总控分配,不能超过已认可的运行容量;任务准备和迁移预热也需要计入资源占用。
+
+| 节点 | 最大并行路数 | 当前运行路数 | 待准备任务 | 状态 |
+|---|---:|---:|---:|---|
+| A | 4 | 4 | 0 | 正常 |
+| B | 6 | 6 | 0 | 正常 |
+| C | 2 | 2 | 0 | 正常 |
+
+这只是 12 路任务的分配示例,不表示实际电脑一定能达到这些容量。容量应以指定分辨率、帧率和实际融合处理的测试结果配置;采用 NVIDIA 硬件编码时,还需计入编码资源和并发限制。[NVENC 官方说明](https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-application-note/index.html)
+
+### 6.2 分配规则
+
+一个任务对应“一个试验、一个轮次、一条视频通道”,每条通道的连续帧由同一个当前有效节点处理。
+
+总控分配时依次检查:
+
+1. 节点在线且允许接收新任务。
+2. 节点支持该输入格式、输出格式和融合处理方式。
+3. 配置路数内存在空余位置,实际处理资源也可用。
+4. 优先使用已有有效分配,减少运行中的迁移。
+5. 新任务优先分配给空余容量充足、运行表现稳定的节点。
+
+总容量不足时,任务进入“等待分配”,PXHJ 显示待分配数量和原因。持续过载时,可以在配置上限内迁移任务;瞬时负载波动不触发频繁搬迁。
+
+### 6.3 动态调整示例
+
+原来 A / B / C 分别跑 4 / 6 / 2 路,修改为 2 / 6 / 4 路:
+
+1. 总控先确认 C 的新增容量可用,将变更显示为“调整中”。
+2. 为 A 上需迁出的两路任务建立 C 上的候选执行版本;切换成功前,A 的原版本仍是当前有效版本。
+3. 当前 C 生成演示画面并确认可播放;后续真实融合时再获取视频源、标定参数、采集数据缓存和仿真图层。
+4. 总控切换该通道的有效版本及播放入口,PXHJ 下一次签名轮询获取更新。
+5. 撤销 A 上的旧版本,释放其资源,完成路数调整。
+
+配置上限下降后,迁出期间实际运行路数可能暂时高于新目标,必须显示调整进度。没有可接管容量时,不宣称变更已完成;由 PXHJ 选择增加容量、保留原任务或明确停止部分任务。
+
+迁移需要源节点和目标节点的资源配合。候选版本获得准备许可,完成切换后才获得正式输出许可;准备失败时取消候选版本,不误撤销仍有效的原版本。没有预热余量时采用先停后启,允许短暂断画;是否需要接近连续的切换,应在实际链路上单独验证。
+
+## 7. 所有采集模块的数据接收与 UDP 转发
+
+当前网关只执行来源匹配、原始字节双向转发和统计。后面的协议解析、采样时间缓存、装备数据分发及独立图片组装是后续设计;当前协议认证与业务样本解析仍在 PXHJ 的正式接收入口执行。
+
+### 7.1 接收一次,按用途分发
+
+```text
+已登记采集器
+ → 网关识别来源并校验报文
+ → 保留原始加密报文字节,转发给 PXHJ
+ → 解析模块数据,按试验绑定对应到装备
+ → 仿真系统所需的装备状态
+ → 各融合节点所需的带时间的位置和姿态
+```
+
+已登记来源的原始数据完整转发给 PXHJ。当前试验名单决定哪些解析数据参与仿真和融合,不用于删改一个原始包内的模块数据。
+
+网关保存采样时间、启动会话及原有序号,不为了中转重新生成采集时间或修改原始加密内容。同一装备的数据可以分发给多个视频任务。
+
+UDP 转发、数据解析、节点分发分别使用有界队列和独立处理流程。慢节点与仿真异常不阻塞原始转发;队列满时按通道记录丢弃数量和原因。
+
+UDP 本身不保证送达、有序或自动补发,因此必须区分“网关已发送”和“PXHJ 实际接受”。需要无损记录时,应另行设计确认、缓存及补发规则,并兼容现有的去重和时间窗口。[UDP 使用指南](https://www.rfc-editor.org/rfc/rfc8085.html)
+
+### 7.2 保持 PXHJ 的采集器身份对应
+
+现有 PXHJ 会检查来源 IP、来源端口及协议身份,再按登记密钥校验解码。中转后,网络来源变为网关,必须同步维护身份映射。
+
+建议每个采集器分配独立、固定的转发源端口:
+
+| 原采集器 | 网关转发源端口,示例 | PXHJ 中对应的连接 |
+|---|---:|---|
+| 采集器 A | 31001 | 保留采集器 A 原连接 ID,登记网关 IP 和 31001 |
+| 采集器 B | 31002 | 保留采集器 B 原连接 ID,登记网关 IP 和 31002 |
+
+保持原连接 ID、协议身份与密钥关联,避免已有试验绑定失效。多个采集器经同一网关转发时,PXHJ 来源端口必须明确,避免多个端口通配配置造成来源歧义。
+
+切换前保存原始来源配置,逐个采集器验证:原始字节一致、PXHJ 来源校验通过、数据归属正确、心跳正常。失败时恢复该采集器的上传目标及 PXHJ 来源配置。
+
+### 7.3 协议接入范围
+
+- 优先覆盖当前固定模块数据及采集器心跳,沿用现有加密协议。
+- 图片分片采用独立接入链路,按采集器和帧标识组装及分发。
+- 当试验使用动态配置模块时,配置下发、回执及数据上报要作为一组链路适配并验证,再切换该来源。
+- 当前网关不需要设备解密密钥,协议认证和解析仍由 PXHJ 完成。后续如在网关内启用解析,再增加受控密钥配置或引用,密钥不放入页面、源码、任务日志或公开接口返回。
+
+网关持续接收全部已登记来源,模块数据以正式实体身份识别,并兼容已有采集器来源及模块编号组合。采集器有心跳、模块有有效样本、视频有新帧分别报告。
+
+### 7.4 数据接收的后续扩容
+
+第一版使用一个采集接入网关。若实测接收量超出单机能力,再按采集器分组增加网关实例,每个来源归一个当前有效接入节点。
+
+网关按采集器分组扩容,融合节点按视频路数扩容。两种分配关系独立,调整视频路数不改变来源接入归属。
+
+## 8. 仿真交互与真实视频融合
+
+本节全部为后续设计,本阶段尚未实现。演示 MJPEG 不包含真实相机或仿真目标,不用于评估融合精度、编码性能或正式录像能力。
+
+### 8.1 正确融合需要的信息
+
+| 信息 | 作用 |
+|---|---|
+| 统一空间基准、单位和目标尺寸 | 真实装备、相机和仿真目标的位置及大小能够对应 |
+| 相机镜头参数和畸变参数 | 生成与真实镜头一致的投影,并校正图像 |
+| 相机安装位置、朝向和云台角度 | 从装备姿态计算实际相机视角 |
+| 真实拍摄时间、视频帧标识 | 对应同一时刻的装备数据和仿真图层 |
+| 仿真目标透明图层或目标遮罩 | 在真实画面上合成目标 |
+| 可选真实场景深度或遮挡区域 | 实现真实树木、建筑等前景对目标的遮挡 |
+
+相机标定资料按具体相机、镜头和使用分辨率管理。变焦、安装改变或图像裁剪影响投影时,更新相应参数。[OpenCV 相机标定说明](https://docs.opencv.org/4.13.0/dc/dbb/tutorial_py_calibration.html)
+
+仅有车体角度和车辆经纬度,还需要校准相机安装关系并核对测量精度。数据精度不足时,应增加定位或视觉校正措施,再验证融合误差。
+
+### 8.2 媒体处理流程
+
+```text
+读取真实视频帧及拍摄时间
+ → 查找或插值该时刻的有效装备位置与姿态
+ → 得到实际相机位置、朝向及投影参数
+ → 请求仿真系统输出对应帧的目标图层
+ → 匹配帧标识、校正画面、合成目标
+ → 编码、发布实时视频、按需分段录像
+```
+
+网关和节点保留可配置时长的数据短缓存,用于按拍摄时间匹配。采集器、视频源和仿真系统应使用统一时间基准;只有接收时间的输入需要测量链路延迟并补齐拍摄时间关系。
+
+仿真图层延迟、所需姿态过期或定位无效时,节点上报融合状态异常,停止使用失效图层;可配置继续显示真实视频。不能将冻结的目标持续展示为有效实时融合。
+
+### 8.3 多节点与仿真系统的关系
+
+同一试验的仿真场景状态统一维护,各节点按各自的视频通道获取相机视角图层。不能因为有多个节点,就各自独立推进一个互不一致的试验世界。
+
+仿真侧需要支持多相机输出;渲染本身成为瓶颈时,需要支持多实例或多机渲染,并共享一致的场景状态和时间。单纯增加融合节点不会自动增加仿真端的渲染能力。
+
+仿真产品的具体协议、透明图层能力、坐标定义和渲染并行方式,在接入资料和联调中确定,通过适配器封装。
+
+## 9. 融合视频地址如何上报给 PXHJ
+
+```text
+Worker 注册对外服务地址,并在心跳中回报当前任务版本已就绪
+ → 总控验证节点会话、任务归属和当前有效版本
+ → 总控生成并登记对应任务的完整 MJPEG 播放地址
+ → PXHJ 后台约每 1.5 秒用签名 HTTP 请求轮询总控快照
+ → PXHJ 将快照持久保存到 biz_fusion_cache,并按试验、轮次筛选视频
+ → 页面查询 PXHJ 后端,直接连接对应节点播放
+```
+
+**当前采用签名轮询同步,尚未实现总控主动调用 PXHJ 的推送回调。** 节点当前上报的是对外基础地址与任务就绪结果,完整播放路径由总控依据任务 ID、版本和媒体许可生成。
+
+当前总控快照中的视频任务包含:
+
+| 字段 | 含义 |
+|---|---|
+| `trialId`、`roundId` | 试验与轮次 |
+| `equipmentId`、`channelId` | 装备与固定视频通道 |
+| `taskId`、`version` | 媒体任务及当前有效执行版本 |
+| `nodeId` | 当前执行节点 |
+| `playProtocol`、`playUrl` | 播放方式及对应入口 |
+| `status`、快照 `serverTime` | 任务准备、运行、迁移或结束状态,以及该快照的时间 |
+
+演示任务使用 `MJPEG_DEMO`,由 Java Worker 产生演示画面;原图任务使用 `MJPEG_ORIGINAL`,展示采集器实际上传图片,准备页面经 PXHJ 原图片 API 读取。后续正式融合视频可以增加 WebRTC 或 HLS 输出,这些流媒体输出尚未实现。WebRTC 入口由对应播放器完成连接协商,不能按普通 MP4 文件地址直接使用。[MediaMTX 浏览器播放说明](https://mediamtx.org/docs/read/web-browsers)
+
+地址使用观看电脑可访问的 IP 或域名。注册时核验节点对外地址,不能用节点自己的回环地址或本地文件路径代替网络播放入口。
+
+任务迁移后,`channelId` 保持不变,`version` 递增,目标就绪后更新播放入口。PXHJ 根据总控身份、调度代次和修订号过滤旧快照,页面随查询结果更新连接。旧节点迟到的回执不能重新登记旧地址。
+
+录像和录像索引尚未实现。后续若增加录制,需要单独管理实时播放入口与分段录像索引,迁移前后的片段归属同一试验、轮次和视频通道。
+
+## 10. PXHJ 集成与当前接口
+
+### 10.1 可以利用的现有基础
+
+已核对现有 PXHJ:
+
+- `GET /api/trials` 提供试验概要列表。
+- `GET /api/trials/{id}` 提供正式试验详情、保存的绑定关系和轮次。
+- 现有轮次开始、结束接口作为业务执行的统一入口。
+- 现有 UDP 接入包含来源校验、加密校验和固定模块解析。
+- 现有视频展示包含图片帧查询,融合流需要增加对应播放器及登记能力。
+
+集成时由 PXHJ 后端组织权威上下文,通过受控接口下发。现有用户会话接口不能直接当成无鉴权的后台集成接口。
+
+本阶段接入正式试验页面、后端接口和权限体系,不建设独立演示操作界面。正式业务服务与实际后台节点的 H2 串联测试已通过,主达梦后端也已升级并实际同步节点状态;真实设备采集切换、正式试验现场融合和物理多机验收仍需要另外完成。
+
+### 10.2 PXHJ 当前新增接口
+
+以下名称与当前 `FusionController` 一致。
+
+| 接口 | 用途 |
+|---|---|
+| `GET /api/fusion/status` | 查询节点、容量、任务与网关汇总状态 |
+| `GET /api/trials/{id}/fusion?roundId=...` | 查询该正式试验当前配置及指定轮次视频任务 |
+| `PUT /api/trials/{id}/fusion` | 保存该试验的通道配置并同步权威上下文 |
+| `PATCH /api/fusion/nodes/{id}/capacity` | 修改最大并行路数与启用状态 |
+| `GET /api/fusion/relay-sources` | 查询正式采集器来源及中转配置 |
+| `PUT /api/fusion/relay-sources` | 保存所选正式采集器的中转来源和目标 |
+
+开始和结束融合由现有正式轮次操作触发,后端向总控发送对应指令,页面不增加另一套独立的“开始试验”流程。
+
+本阶段没有 `video-reports` 或 `status-reports` 推送回调接口;PXHJ 后台签名读取 `/control/snapshot` 后完成状态和播放信息同步。
+
+### 10.3 新软件当前内部接口
+
+| 方向 | 接口或通道 | 用途 |
+|---|---|---|
+| PXHJ 后端 → 总控 | `GET /control/info`、`GET /control/snapshot` | 校验总控身份并同步当前状态、任务及播放地址 |
+| PXHJ 后端 → 总控 | `POST /control/contexts/sync` | 统一下发权威试验上下文及 PENDING、RUNNING 或 SAVED 轮次状态 |
+| PXHJ 后端 → 总控 | `PATCH /control/nodes/{id}/capacity` | 下发容量变更 |
+| Worker → 总控 | `POST /control/nodes/register` | 自动登记节点 ID、启动会话、能力和对外地址;重复登记更新原记录 |
+| Worker → 总控 | `POST /control/nodes/{id}/heartbeat` | 汇报运行情况,获取更新后的任务计划和执行授权 |
+| PXHJ 后端 → 总控 | `PUT /control/routes` | 保存完整来源和转发映射 |
+| Gateway → 总控 | `POST /control/gateways/heartbeat` | 心跳即自动登记,汇报网关统计并获取已确认的版本化路由 |
+
+节点就绪结果包含在心跳中,当前没有独立任务回报接口。网关到 Worker 的采集数据订阅尚未实现。
+
+当前上下文携带配置版本,任务计划携带任务 ID、执行版本、节点会话和授权有效期。PXHJ 持久保存期望配置并定时重新核对正式轮次;尚未实现单独的操作 ID 事件流水。用户操作由 PXHJ 权限控制,后台服务间使用凭证生成请求和响应签名,不在请求头发送明文凭证。
+
+## 11. 状态、重启与异常处理
+
+### 11.1 需要保存的状态
+
+当前总控用 H2 保存节点配置、试验上下文版本、任务目标状态、分配、会话和任务版本。播放状态通过快照供 PXHJ 查询,没有待推送回调队列,也没有独立操作 ID 去重流水。
+
+网关保存来源及转发映射、已确认配置版本。实时数据采用有界缓存;需要留存原始报文时,单独配置容量和保留策略。
+
+当前节点持久保存自身身份和成功连接的总控地址,签名响应中的总控逻辑身份用于当次认证和地址去重,任务计划保存在内存中,录像索引未实现。总控重启后先核对 PXHJ 的轮次状态,对旧任务重新确认并发放新版本授权,再恢复有效输出。不能仅加载磁盘记录就宣称旧视频仍在正常运行。
+
+### 11.2 异常处理规则
+
+| 情况 | 处理方式 |
+|---|---|
+| 节点容量不足 | 任务等待,PXHJ 显示原因 |
+| 节点失联或媒体进程崩溃 | 作废旧任务有效版本;有容量时重建任务,记录中断 |
+| 节点因网络分区收不到控制信息 | 原授权到期后停止有效发布,等待重新确认 |
+| 旧节点迟到上报 | 按任务版本和执行授权拒绝,不覆盖新地址 |
+| 路数调整过程中目标节点准备失败 | 保留仍有效的原任务,变更返回失败或待处理状态 |
+| 总控暂时不可用 | 网关继续按已确认配置转发;节点按授权规则处理当前任务 |
+| PXHJ 暂时不可用 | 总控保留当前状态;PXHJ 恢复后后台重新轮询并核对正式上下文;当前没有推送回调重试队列 |
+| 视频或姿态数据过期 | 单独报告数据异常,停止使用过期图层 |
+| 原始转发队列满 | 记录数量和原因,PXHJ 显示接入异常 |
+| 录像磁盘不足 | 后续实现录像时报告录像失败;本阶段未实现录制 |
+
+执行授权是一段有限时间内有效的任务许可,节点心跳正常时续期。任务迁移或失联重建后,旧许可失效;业务上同一通道只有一个当前有效输出版本。预热过程可以临时存在两个媒体任务,但不能把两个版本同时登记为当前有效视频。
+
+第一版采用单有效总控和单接入网关,支持持久化重启恢复。总控与网关的主备切换需要另行实现、测试,不能将多个 Worker 等同于接入服务已经具备高可用。
+
+## 12. 技术实现思路
+
+| 层 | 当前实现与后续方向 | 原因 |
+|---|---|---|
+| 总控及网关 | Java 17、Spring Boot;总控默认在同一进程兼任执行节点 | 控制和执行共享主身份,网关保持独立转发 |
+| 总控状态保存 | 第一版 H2 文件数据库及事务记录 | 避免仅靠内存或临时 JSON 保存任务状态 |
+| Worker 控制与演示视频 | Java 后台服务,Java2D / ImageIO 产生 MJPEG | 验证任务生命周期、迁移和实际播放 |
+| 真实媒体处理 | 后续在 Java 项目内增加真实输入、仿真和合成适配;具体编解码/GPU 库待接入资料确定 | 本阶段尚未实现校正、合成或正式编码 |
+| 正式流媒体发布 | 后续提供 WebRTC 或等效播放服务 | 当前只提供演示 MJPEG,正式低延迟输出待实现 |
+| PXHJ 页面 | 在现有 Vue 试验页面接入管理及播放器 | 统一用户操作和权限 |
+
+协议公共模块只抽取编解码、校验和标准数据结构,不把 PXHJ 的数据库访问和试验组织逻辑整体搬入新软件。
+
+媒体执行层建议封装视频输入、仿真接入、时间匹配、图像合成、流发布和录像六个部件。缺少真实仿真或标定条件时,明确记录该环节未验证,不能以透传普通视频代替准确融合验收。
+
+当前代码结构如下。现阶段保持一个 Maven 项目,通过角色配置运行三个服务,不提前拆出尚未实现的媒体和协议子模块:
+
+```text
+xtrh/
+├─ pom.xml
+├─ src/main/java/com/xtrh/
+│ ├─ Application.java Spring Boot 入口
+│ ├─ control/
+│ │ ├─ controller/ CoordinatorController:总控 HTTP 转接
+│ │ └─ service/ CoordinatorService:节点、上下文、容量、任务与版本调度
+│ ├─ gateway/
+│ │ ├─ controller/ GatewayController:网关状态接口
+│ │ └─ service/ GatewayService:双向 UDP、路由应用与统计
+│ ├─ node/
+│ │ ├─ controller/ WorkerController:媒体播放和节点状态接口
+│ │ ├─ service/ WorkerService:领取任务、授权及演示视频
+│ │ └─ config/ WorkerCapabilityCondition:主/子执行条件;AsyncMediaConfiguration:媒体线程
+│ ├─ discovery/
+│ │ ├─ service/ Discovery、CoordinatorLocator:发现与身份核验
+│ │ └─ util/ NodeAddress:对外地址选择
+│ ├─ communication/
+│ │ ├─ client/ PeerClient:签名 HTTP 通信
+│ │ └─ security/ Signatures、ServiceSecurity:签名及防重放
+│ └─ common/
+│ ├─ config/ Settings:角色与部署配置;SchedulerConfiguration:并发调度线程
+│ ├─ controller/ HealthController、ApiErrors:健康与错误接口
+│ ├─ dao/ StateStore:H2 状态;NodeIdentityDao:部署身份文件
+│ └─ util/ Jsons:公共 JSON 辅助
+├─ src/main/resources/ Spring Boot 默认配置
+├─ src/test/java/com/xtrh/ 调度、恢复、真实 UDP 和签名测试
+├─ scripts/ 构建、私有凭证、安装、启停、自启及 PXHJ 连接配置
+├─ config/ 无密钥的配置示例
+├─ docs/ 本机验证记录
+├─ README.md 构建、部署、范围与接口说明
+└─ runtime/ 本机部署产物、身份、数据库和私有配置;不提交仓库
+```
+
+控制层只完成 HTTP 参数和服务转接,不承载调度或数据库业务;服务层处理当前业务规则;DAO 层专门保存和读取状态。关键中文注释解释节点会话、旧版本隔离、限时许可、迁移准备、路由缓存及签名校验的原因。测试按对应业务包放置,保留既有测试名称。
+
+`StateStore.open(Path, ObjectMapper)` 将 H2 文件 JDBC 地址组装下沉到 DAO,保留 String URL 构造器供测试使用。`NodeIdentityDao` 只维护节点身份文件,不承担网络注册或调度。PXHJ 新增业务采用 `fusion.controller/service/dao/config/security` 分层,`FusionDao` 承接 SQL。
+
+注释按 `scripts/Measure-Comments.ps1` 统计完整注释行占非空源码行的比例,包含 Javadoc 与块注释边界,不计空行和代码末尾的行内注释。当前全部 XTRH 主 Java 为 **21.53%(594/2759)**,PXHJ 新增 fusion 业务为 **28.83%(501/1738)**。
+
+## 13. 开发顺序与各阶段交付
+
+第一、二阶段的服务骨架与 PXHJ 集成代码已完成本机和 H2 正式业务链路验证,主达梦实例已实际接通总控;实际 RK3588 原图准备取图和接收节点迁移也已完成联调,当前证据见第 0 节。下面列的是完整阶段验收目标,真实摄像机和跨物理电脑验收仍未完成。第三阶段真实融合与录制暂不实施。
+
+| 阶段 | 开发内容 | 可检查的交付结果 |
+|---|---|---|
+| 第一阶段:接入及服务骨架 | 总控、网关、Worker 部署;自动发现及注册;容量配置;原始 UDP 转发及身份映射 | 三个可部署后台角色,新增节点自动出现,真实协议报文被 PXHJ 接受,节点能领取和汇报任务 |
+| 第二阶段:任务分发及正式集成 | PXHJ 正式试验上下文、轮次操作、持久化指令、动态路数、任务版本、视频信息上报 | 从真实 PXHJ 试验发起,多节点任务分配与状态展示可用 |
+| 第三阶段:真实融合 | 相机标定、时间匹配、仿真目标图层接入、合成编码、实时播放与录像 | 真实视频中的目标位置、大小和视角按实际设备验证 |
+| 第四阶段:多机与稳定性 | 跨机部署、容量测试、任务迁移、失联重建、重启恢复、资源和日志管理 | 至少两台实际融合电脑协同,形成容量及故障测试记录 |
+
+开发中使用的隔离协议样本和测试视频仅用于相应功能验证。正式交付需要真实 PXHJ 试验、实际采集器和跨机部署证据;准确融合另需真实相机及仿真接口的验证。
+
+优先完成第一、二阶段的基础链路,同时确定仿真和相机资料,使第三阶段有可实现、可验收的输入条件。
+
+## 14. 验收重点
+
+1. 同一局域网新部署的节点能自动发现、认证及注册,PXHJ 自动出现记录;总控晚启动、地址变化和节点重启后能恢复,重复注册不新增重复节点。
+2. 用户在 PXHJ 内完成配置及试验操作,后台节点自动执行,任务与正式试验、轮次一致。
+3. 多个采集器经网关转发后,原始加密报文字节一致,PXHJ 正确识别来源与模块,实际样本接收正常。
+4. 一个节点可同时执行多个视频任务,最大并行路数可在 PXHJ 修改。
+5. 配置不足时明确等待;容量降低时能显示迁移过程,任务版本和实际占用一致。
+6. 任务跨电脑迁移后,相应数据订阅和视频播放入口同步更新,旧版本回报不能覆盖新版本。
+7. 轮次结束、节点调整及仿真异常均不会停止独立的采集接收与原始转发。
+8. 总控、网关和节点重启后按正式状态恢复;网络分区及授权过期有可观察结果。
+9. 仿真与真实相机使用一致的坐标、投影和时间,按静止、转动和移动场景验证融合位置误差。
+10. 在指定分辨率、帧率、算法和网络条件下测试每节点实际容量、端到端延迟、掉帧、带宽和编码资源。
+11. 形成真实跨机测试记录;同机多进程测试只证明对应的软件行为,不替代跨机验收。
+
+融合误差、允许中断时长、目标帧率及端到端延迟的数值,需要结合相机、定位精度、仿真能力和电脑配置确定。此设计稿不预先承诺未经测量的性能指标。
+
+## 15. 接入实施前需要明确的资料
+
+| 资料 | 用于确定什么 |
+|---|---|
+| 真实视频通道数、分辨率、帧率、输入格式 | 节点处理容量及媒体输入适配 |
+| 各电脑配置、显卡、网络及操作系统 | 编解码、合成能力和部署包 |
+| 采集器身份、模块编号、协议和时间字段 | 来源映射、数据解析和按帧同步 |
+| 相机型号、安装关系、云台及变焦方式 | 标定与实际相机位姿 |
+| 仿真接口、坐标定义、目标图层和多视角能力 | 正确渲染及多节点融合 |
+| 实时观看和录像要求 | 发布格式、存储策略及容量 |
+
+这些资料逐项落实到配置和适配器。服务骨架、UDP 转发和调度部分可以先开发,资料缺失的媒体环节保留明确的待接入状态。
+
+## 16. 已核对的 PXHJ 代码依据
+
+- [TrialController.java](E:/pxhj/backend/src/main/java/com/pxhj/trial/controller/TrialController.java):试验列表、详情及正式创建接口。
+- [TrialService.java](E:/pxhj/backend/src/main/java/com/pxhj/trial/service/TrialService.java):试验详情包含保存的绑定信息及轮次,列表为概要。
+- [UdpFoundation.java](E:/pxhj/backend/src/main/java/com/pxhj/telemetry/service/UdpFoundation.java):来源、消息类型和加密校验,固定数据及心跳处理。
+- [FixedModuleService.java](E:/pxhj/backend/src/main/java/com/pxhj/telemetry/fixed/FixedModuleService.java):来源与认证身份一致性、模块样本识别及去重保存。
+- [ModuleVideoDialog.vue](E:/pxhj/frontend/src/equipment/ModuleVideoDialog.vue):当前模块视频展示采用图片帧方式,融合流需要对应的播放接入。
+- [FusionController.java](E:/pxhj/backend/src/main/java/com/pxhj/fusion/controller/FusionController.java):新增融合配置、节点容量和中转来源的正式接口。
+- [FusionService.java](E:/pxhj/backend/src/main/java/com/pxhj/fusion/service/FusionService.java):权威试验上下文、签名轮询及播放信息同步。
+- [FusionDao.java](E:/pxhj/backend/src/main/java/com/pxhj/fusion/dao/FusionDao.java):融合配置、状态缓存与中转登记的数据库访问。
+
+实施时以当前代码和正式接入资料再次核对,不直接用旧演示中的虚构试验或模拟接口替代以上业务能力。