初始化系统融合软件代码仓库

This commit is contained in:
2026-10-20 08:34:12 +08:00
commit 587ae6f088
96 changed files with 10230 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
/target/
/data/
/runtime/
/output/
/frontend/dist/
node_modules/
__pycache__/
*.py[cod]
*.log
*.local.properties
.env
.env.*
!.env.example
/deployment/*.zip
+195
View File
@@ -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 的配置和联调。
+13
View File
@@ -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
+69
View File
@@ -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)。真实摄像机、真实融合和多台物理电脑性能验收另行进行。
+86
View File
@@ -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`,没有重置计数来隐藏历史记录。
+155
View File
@@ -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 验证端口已释放。未验证注册登录自启任务的实际重登录行为。
@@ -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 后续建设衔接
后续在已确认的页面、示例关系和显示方式基础上,先开展设备通信、真实数据与实时视频流接入,再逐步实现数据处理、统一时间基准、仿真模型驱动、传感器位姿融合、仿真相机绑定以及画面对齐和融合渲染,形成合同要求的完整功能链路。
本阶段仅明确后续功能需要展示的信息项和操作入口。设备协议、接口实现及联调结果列为后续建设内容,不将页面预留位置表述为已经完成接口对接。
@@ -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/`;应用前会核对基线并备份原文件,避免覆盖其他改动。源码修改不会自动更新运行中的进程。
@@ -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、内存、网络速率和未接入的虚拟/融合数据均为 `—` 或空列表。
+37
View File
@@ -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、内存、网络速率与其他功能数据源仍为空。
+11
View File
@@ -0,0 +1,11 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="same-origin" />
<link rel="icon" type="image/svg+xml" href="/console/assets/brand.svg" />
<title>系统融合软件</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
+20
View File
@@ -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"
}
}
+1124
View File
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none" focusable="false">
<circle cx="14" cy="14" r="10" stroke="#ffffff" stroke-width="1.5" opacity=".35"/>
<path d="M4 14h5l2.5-6 5 12 2.5-6h5" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="21.5" cy="6.5" r="2" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

+38
View File
@@ -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 <div className={`app ${collapsed?'nav-collapsed':''}`}><aside className="sidebar"><div className="brand"><img src="/console/assets/brand.svg" alt=""/><strong>系统融合软件</strong><button className="collapse-toggle" type="button" aria-label="切换侧栏" onClick={()=>setCollapsed(value=>!value)}><PanelLeftClose size={16}/></button></div><nav aria-label="功能导航">{PAGES.map(item=><a href={`#${item.id}`} className={`nav-link ${item.id===page.id?'active':''}`} key={item.id} title={item.name} aria-current={item.id===page.id?'page':undefined}><item.icon size={23} strokeWidth={1.6}/><span>{item.name}</span></a>)}</nav><div className="sidebar-foot"><Gauge size={22}/><span>运行监测</span></div></aside><div className="main-shell"><header className="topbar"><div className="breadcrumb"><span>系统融合软件</span><ChevronRight size={14}/><strong>{page.name}</strong></div><div className="service-state"><Status state={failed?'ERROR':loading?'WAITING':'CONNECTED'} label={failed?'服务连接中断':loading?'正在连接':'服务连接正常'}/><time>{date}</time></div></header><main><div className="page-heading"><div><h1>{page.name}</h1><p>{page.description}</p></div><div className="heading-actions">{contexts.length>0&&<label className="context-select"><span>试验</span><select aria-label="选择试验上下文" value={context?.id||''} onChange={event=>{setContextId(event.target.value);setSelectedId('');}}>{contexts.map(item=><option key={item.id} value={item.id}>{item.name||item.id}</option>)}</select></label>}<button className="refresh-button" type="button" onClick={()=>setRefreshKey(key=>key+1)} aria-label="刷新数据" title="刷新数据"><RefreshCw size={17}/><span>刷新</span></button></div></div>{(failed||state==='STALE')&&<div className="source-notice" role="status"><AlertCircle size={17}/>{failed?'服务暂时不可用,当前显示最后一次收到的数据。':'采集数据同步已超时,当前显示最后一次收到的数据。'}</div>}{page.id==='receive'&&<ReceivePage {...props}/ >}{page.id==='sync'&&<SyncPage {...props}/ >}{page.id==='virtual'&&<VirtualPage/>}{page.id==='video'&&<VideoPage {...props}/ >}{page.id==='metrics'&&<MetricsPage equipments={equipments}/ >}{page.id==='nodes'&&<NodesPage nodes={snapshot.nodes||[]}/ >}</main><footer className="app-footer"><span>系统融合软件</span><span>{state==='WAITING'?'等待采集数据接入':state==='CONNECTED'?'采集数据自动更新':'采集数据同步中断'}</span></footer></div></div>;
}
+18
View File
@@ -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 <div className="equipment-layout"><EquipmentList equipments={equipments} selectedId={selectedId} onSelect={onSelect}/><div className="detail-stack">{equipment?<><EquipmentHeader equipment={equipment}/><Panel title="数据接收情况" extra={<span className="muted">{equipment.participantCode||''}</span>}><div className="table-scroll"><table className="data-table receive-table"><thead><tr><th>数据类型 / 参数</th><th>最新数据</th><th>接收时间</th><th>接收状态</th></tr></thead><tbody>{items.map((item,index)=><tr key={`${item.key}-${index}`} className={selected===item?'selected':''} onClick={()=>setItemKey(item.key)} tabIndex={0} aria-selected={selected===item} onKeyDown={event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();setItemKey(item.key);}}}><td><strong>{item.label||item.key}</strong><small>{typeNames[item.type]||item.type||'采集数据'}</small></td><td className="parameter-value">{displayValue(item.value,item.unit)}</td><td><time>{formatTime(item.receivedAt)}</time></td><td><Status state={item.quality}/></td></tr>)}</tbody></table></div>{!items.length&&<Empty title="尚未收到该装备的数据" description="采集数据到达后将在此显示。" icon={Radio}/>}</Panel><Panel title={selected?`${selected.label||selected.key}详情`:'数据详情'}>{selected?<dl className="value-details"><div><dt>当前值</dt><dd>{displayValue(selected.value,selected.unit)}</dd></div><div><dt>数据来源</dt><dd>{selected.sourceName||'—'}</dd></div><div><dt>采样时间</dt><dd>{formatTime(selected.sampleTime)}</dd></div><div><dt>接收时间</dt><dd>{formatTime(selected.receivedAt)}</dd></div><div><dt>数据质量</dt><dd><Status state={selected.quality}/></dd></div></dl>:<Empty title="暂无参数详情" description="选择已接收参数查看其来源和时间。"/>}</Panel></>:<Panel className="unselected-panel"><Empty title="等待装备采集数据" description="已绑定的实装及其采集数据同步后将在此显示。" icon={Radio}/></Panel>}</div></div>;
}
export function SyncPage({equipments,selectedId,onSelect}) {
const equipment=equipments.find(item=>item.id===selectedId);
return <div className="equipment-layout"><EquipmentList equipments={equipments} selectedId={selectedId} onSelect={onSelect}/><div className="detail-stack">{equipment&&<EquipmentHeader equipment={{...equipment,state:'NOT_RECEIVED'}} statusLabel="同步指标未接入"/>}<Panel title="当前同步指标"><div className="sync-values">{[['同步延迟','ms'],['数据丢包率','%'],['同步偏差','ms']].map(([name,unit])=><div key={name}><span>{name}</span><strong>— <small>{unit}</small></strong><p>未接入</p></div>)}</div></Panel><Panel title="同步质量趋势"><Empty title="暂无同步质量数据" description="同步质量数据接入后,可查看所选装备的指标与趋势。" icon={Activity}/></Panel><Panel title="数据同步状态"><div className="table-scroll"><table className="data-table"><thead><tr><th>数据类型</th><th>数据时间</th><th>同步偏差</th><th>同步状态</th></tr></thead></table></div><Empty title="暂无同步状态"/></Panel></div></div>;
}
+210
View File
@@ -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 <label className="secondary-search">
<Search size={17} aria-hidden="true" />
<input
aria-label={label || placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
placeholder={placeholder}
type="search"
/>
</label>;
}
function BlankValue({ unit }) {
return <span className="secondary-blank">—{unit && <small>{unit}</small>}</span>;
}
function UnavailableTag({ children }) {
return <span className="secondary-unavailable"><i />{children}</span>;
}
function TableEmpty({ colSpan, title, description, icon }) {
return <tr><td colSpan={colSpan} className="secondary-empty-cell">
<Empty title={title} description={description} icon={icon} />
</td></tr>;
}
export function VirtualPage() {
const [query, setQuery] = useState('');
return <div className="secondary-equipment-layout virtual-page">
<Panel title="虚拟装备列表" className="virtual-list-panel">
<div className="secondary-list-search">
<SearchField value={query} onChange={setQuery} placeholder="搜索虚拟装备名称或编号" />
</div>
<div className="virtual-list-empty">
<Empty title={query.trim() ? '未找到匹配的虚拟装备' : '暂无虚拟装备'} description="接入虚拟装备后,可在此选择查看。" icon={Activity} />
</div>
</Panel>
<div className="secondary-detail-stack">
<div className="virtual-unselected-header">
<span className="virtual-equipment-icon"><Activity size={32} aria-hidden="true" /></span>
<div><h2>未选择虚拟装备</h2><p>装备编号 <span>—</span></p></div>
<UnavailableTag>暂无状态</UnavailableTag>
</div>
<Panel title="当前装备状态" className="virtual-state-panel">
<div className="virtual-state-body">
<div className="virtual-health-row">
<HeartPulse size={31} aria-hidden="true" />
<span className="virtual-health-label">血量</span>
<strong>— / —</strong>
<div className="virtual-health-track" aria-label="暂无血量数据" />
<span className="secondary-muted">—</span>
</div>
<div className="virtual-position-grid">
{[
['经度', '°'], ['速度', 'm/s'],
['纬度', '°'], ['航向角', '°'],
['高程', 'm'], ['动作状态', ''],
].map(([label, unit]) => <div className="virtual-position-item" key={label}>
<span>{label}</span><BlankValue unit={unit} />
</div>)}
</div>
</div>
</Panel>
<Panel title="虚拟装备状态一览" className="virtual-overview-panel">
<div className="secondary-table-scroll">
<table className="secondary-table virtual-overview-table">
<thead><tr><th scope="col">虚拟装备</th><th scope="col">血量</th><th scope="col">位置(经度 / 纬度)</th><th scope="col">高程</th><th scope="col">状态</th></tr></thead>
<tbody><TableEmpty colSpan={5} title="暂无虚拟装备状态" description="装备接入后显示血量、位置与运行状态。" icon={HeartPulse} /></tbody>
</table>
</div>
</Panel>
</div>
</div>;
}
export function VideoPage({ equipments = [], selectedId, onSelect }) {
const selected = equipments.find((equipment) => equipmentId(equipment) === String(selectedId ?? ''));
return <div className="secondary-equipment-layout video-page">
<EquipmentList equipments={equipments} selectedId={selectedId} onSelect={onSelect} title="实装列表" emptyText="暂无已接入的实装" />
<div className="secondary-detail-stack video-main-column">
{selected
? <EquipmentHeader equipment={{ ...selected, state: 'NOT_RECEIVED' }} statusLabel="暂无融合视频" />
: <Panel><div className="video-unselected-header"><MonitorPlay size={30} aria-hidden="true" /><div><h2>未选择实装</h2><p>从实装列表选择装备查看融合视频。</p></div></div></Panel>}
<Panel className="video-main-panel">
<div className="video-stage">
<div className="video-empty-frame">
<MonitorPlay size={54} strokeWidth={1.5} aria-hidden="true" />
<h3>{selected ? '暂无融合视频' : '请选择实装'}</h3>
<p>{selected ? '当前装备尚无可用的融合视频源。' : '选择装备后查看对应的融合画面。'}</p>
</div>
<aside className="video-metrics-overlay" aria-label="当前装备融合指标">
<div className="video-overlay-heading"><strong>融合指标</strong><span>{selected ? equipmentCode(selected) : '—'}</span></div>
<dl>{FUSION_METRICS.map(([label, unit]) => <div key={label}><dt>{label}</dt><dd>—<small>{unit}</small></dd></div>)}</dl>
</aside>
<div className="video-control-bar" aria-label="视频播放控制">
<UnavailableTag>{selected ? '暂无画面' : '未选择装备'}</UnavailableTag>
<span className="video-control-divider" />
<button type="button" disabled aria-label="暂停视频" title="暂无可播放的融合视频"><Pause size={19} /></button>
<button type="button" disabled aria-label="视频音量" title="暂无可播放的融合视频"><Volume2 size={20} /></button>
<div className="video-volume-track" aria-hidden="true" />
<span className="video-resolution">分辨率 —</span>
<button type="button" disabled aria-label="视频全屏" title="暂无可播放的融合视频"><Maximize size={20} /></button>
</div>
</div>
</Panel>
</div>
</div>;
}
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 <div className="fusion-metrics-page">
<Panel title="装备融合指标" className="fusion-metrics-panel">
<div className="secondary-panel-toolbar">
<SearchField value={query} onChange={setQuery} placeholder="搜索装备名称或编号" />
</div>
<div className="secondary-table-scroll">
<table className="secondary-table fusion-metrics-table">
<thead><tr><th scope="col">装备名称</th>{FUSION_METRICS.map(([label, unit]) => <th scope="col" key={label}>{label}<span className="secondary-column-unit">({unit})</span></th>)}<th scope="col">状态</th></tr></thead>
<tbody>
{filtered.length ? filtered.map((equipment, index) => <tr key={`${equipmentId(equipment)}-${index}`}>
<td><div className="fusion-equipment-name"><EquipmentIcon equipment={equipment} size={26} /><div><strong>{equipmentName(equipment)}</strong><span>{equipmentCode(equipment)}</span></div></div></td>
{FUSION_METRICS.map(([label]) => <td key={label}><BlankValue /></td>)}
<td><UnavailableTag>暂无指标</UnavailableTag></td>
</tr>) : <TableEmpty colSpan={8} title={query.trim() ? '未找到匹配的装备' : '暂无实装'} description={query.trim() ? '请调整名称或编号后重试。' : '装备接入后,将在此显示对应的融合指标。'} icon={Activity} />}
</tbody>
</table>
</div>
<p className="secondary-panel-note">未接入融合指标的装备显示为 —。</p>
</Panel>
<p className="fusion-metrics-footer">可在融合视频展示页查看对应装备的画面与融合指标。</p>
</div>;
}
function NodeStatus({ node }) {
const state = node.online === true ? 'online' : node.online === false ? 'offline' : 'unknown';
return <span className={`node-state node-state-${state}`}><i />{state === 'online' ? '在线' : state === 'offline' ? '离线' : '状态未知'}</span>;
}
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 <Panel title="节点运行状态" className="node-page">
<div className="secondary-panel-toolbar">
<SearchField value={query} onChange={setQuery} placeholder="搜索节点名称或编号" />
</div>
<div className="secondary-table-scroll">
<table className="secondary-table node-status-table">
<thead><tr><th scope="col">节点名称</th><th scope="col">角色</th><th scope="col"><Cpu size={15} aria-hidden="true" />CPU 使用率</th><th scope="col">内存使用情况</th><th scope="col"><ArrowDownToLine size={15} aria-hidden="true" />网络接收</th><th scope="col"><ArrowUpFromLine size={15} aria-hidden="true" />网络发送</th><th scope="col">运行状态</th></tr></thead>
<tbody>{filtered.length ? filtered.map((node, index) => <tr key={node.nodeId || index}>
<td><div className="node-identity"><Server size={28} aria-hidden="true" /><div><strong>{node.name || node.nodeId || '未命名节点'}</strong><span title={node.nodeId || ''}>{node.nodeId || '—'}</span></div></div></td>
<td>{nodeRole(node)}</td>
<td><BlankValue /></td><td><BlankValue /></td><td><BlankValue /></td><td><BlankValue /></td>
<td><NodeStatus node={node} /></td>
</tr>) : <TableEmpty colSpan={7} title={query.trim() ? '未找到匹配的节点' : '暂无已注册节点'} description={query.trim() ? '请调整名称或编号后重试。' : '节点注册后,可在此查看运行状态。'} icon={Server} />}</tbody>
</table>
</div>
<section className="node-network-panel" aria-label="节点心跳连接状态">
<h3><Network size={18} aria-hidden="true" />节点心跳连接状态</h3>
{filtered.length ? <div className="node-network-grid">{filtered.map((node, index) => <div className="node-network-item" key={node.nodeId || index}>
<Server size={25} aria-hidden="true" /><div><strong>{node.name || node.nodeId || '未命名节点'}</strong><span>{node.online === true ? '心跳在线' : node.online === false ? '心跳中断' : '暂无心跳状态'}</span></div><NodeStatus node={node} />
</div>)}</div> : <p className="node-network-empty">暂无节点连接信息</p>}
</section>
<p className="secondary-panel-note">资源使用情况与网络速率未上报时显示为 —。</p>
</Panel>;
}
+28
View File
@@ -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)};
}
+6
View File
@@ -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(<React.StrictMode><App /></React.StrictMode>);
+137
View File
@@ -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; }
}
File diff suppressed because one or more lines are too long
+39
View File
@@ -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 <span className={`status status-${tone}`}><i />{label || labels[state] || labels.UNKNOWN}</span>;
}
export function Panel({title,children,extra,className=''}) {
return <section className={`panel ${className}`}>{(title||extra)&&<div className="panel-title"><h2>{title}</h2>{extra&&<div className="panel-extra">{extra}</div>}</div>}{children}</section>;
}
export function Empty({title='暂无数据',description,icon:Icon=Inbox}) {
return <div className="empty-state"><Icon size={35} strokeWidth={1.4}/><strong>{title}</strong>{description&&<p>{description}</p>}</div>;
}
export function EquipmentIcon({equipment,size=25}) {
const air=/无人机|航空|飞行|UAV/i.test(`${equipment?.name||''} ${equipment?.code||''} ${equipment?.type||''}`);
const Icon=air?Plane:Truck;
return <Icon size={size} strokeWidth={1.7}/>;
}
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 <Panel title={title} className="equipment-list"><label className="search-input"><Search size={17}/><input type="search" aria-label="搜索装备名称或编号" placeholder="搜索装备名称或编号" value={search} onChange={event=>setSearch(event.target.value)}/></label><div className="equipment-rows">{filtered.map(item=><button type="button" className={`equipment-row ${selectedId===item.id?'selected':''}`} key={item.id} onClick={()=>onSelect?.(item.id)} aria-pressed={selectedId===item.id}><EquipmentIcon equipment={item}/><span className="equipment-row-copy"><strong>{item.name||item.code||'未命名装备'}</strong><small>{item.code||item.participantCode||'—'}</small></span><Status state={item.state}/></button>)}</div>{!filtered.length&&<Empty title={search?'无匹配装备':emptyText} description={search?'请调整名称或编号。':'绑定装备后将在此显示。'}/>}</Panel>;
}
export function EquipmentHeader({equipment,statusLabel}) {
if(!equipment)return null;
return <Panel className="equipment-header"><div className="equipment-heading"><EquipmentIcon equipment={equipment} size={46}/><div><h2>{equipment.name||equipment.code||'未命名装备'}</h2><p>{equipment.code||'—'}<span>·</span>{equipment.type||'实装装备'}</p></div><Status state={equipment.state} label={statusLabel}/></div><div className="equipment-updated">最近接收 <time>{formatTime(equipment.lastReceivedAt)}</time></div></Panel>;
}
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);
}
+21
View File
@@ -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');
});
+9
View File
@@ -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},
});
+14
View File
@@ -0,0 +1,14 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.5.11</version><relativePath/></parent>
<groupId>com.xtrh</groupId><artifactId>xtrh-services</artifactId><version>1.0.0</version>
<properties><java.version>17</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.jmdns</groupId><artifactId>jmdns</artifactId><version>3.5.12</version></dependency>
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcprov-jdk18on</artifactId><version>1.80</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
+43
View File
@@ -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+(?:"(?<p>[^"]+)"|(?<p>\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)
+215
View File
@@ -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()
+31
View File
@@ -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 '运行中的后端不会自动加载新源码。'
+20
View File
@@ -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 静态资源目录。'
+18
View File
@@ -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'))
+69
View File
@@ -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+(?:"(?<jar>[^"]+)"|(?<jar>\S+))')
if (-not $match.Success -or [System.IO.Path]::GetFullPath($match.Groups['jar'].Value) -ne $expectedJar) {
throw '进程启动文件与本部署目录不一致;已拒绝结束该 PID。'
}
return $process
}
+32
View File
@@ -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 + '。后端重启后加载。')
+89
View File
@@ -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 = ''
}
+69
View File
@@ -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)
+26
View File
@@ -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) }
+19
View File
@@ -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)
+207
View File
@@ -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 '部分原容量未恢复,请按保存的迁移计划处理。' }
}
+12
View File
@@ -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 }
+24
View File
@@ -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)
+125
View File
@@ -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()
+443
View File
@@ -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"(?<![A-Za-z0-9_])" + re.escape(relative) + r"(?![A-Za-z0-9_.])", uncommented):
additions.append(("HEADERS" if relative.endswith(".h") else "SOURCES") + " += " + relative)
if additions:
text += "\n# 图片上传地址协商:仅追加编译单元,保留当前板端全部原有依赖。\n"
text += "\n".join(additions) + "\n"
project.write_text(text, encoding="utf-8")
def candidate_config(original, gateway, gateway_port, fallback, fallback_port):
# 使用严格解析检查重复节/键,再只编辑指定键,保留其他值、注释和大小写。
parser = configparser.ConfigParser(interpolation=None)
try:
parser.read_string(original)
except configparser.Error:
# INI解析错误可能包含原配置行,禁止把潜在凭证写入部署日志。
raise RuntimeError("Existing collector INI has unsupported or duplicate entries") from None
if not parser.getboolean("image_upload", "enabled", fallback=False):
raise RuntimeError("Existing image_upload must already be enabled")
# 协商target由可信回复动态决定,此处不写节点IP,也不固定每台节点的路数。
# 独立fallback_ip是部署必填项,避免fixed_json指向网关后继承错误地址。
changes = {"image_upload": {"udp_host": fallback, "udp_port": str(fallback_port)},
"image_negotiation": {"enabled": "true", "gateway_host": gateway,
"gateway_port": str(gateway_port)}}
current = None
result = []
# seen区分已有节与真正缺失的节,避免生成会被QSettings错误解释的重复配置。
seen = set()
def missing():
# 原节结束前补缺项,不追加重复节;不会重写fixed_json、heartbeat或RTP。
if current in changes:
for key, value in changes[current].items():
result.append(key + "=" + value)
changes[current].clear()
# 逐行保留未修改设置,避免ConfigParser整份重写丢失现网注释和自定义字段。
for line in original.splitlines():
section = re.match(r"^\s*\[([^]]+)\]\s*(?:[;#].*)?$", line)
if section:
missing()
current = section.group(1)
seen.add(current)
entry = re.match(r"^\s*([^=;#]+?)\s*=", line)
key = entry.group(1).strip() if entry else None
if current in changes and key in changes[current]:
line = key + "=" + changes[current].pop(key)
result.append(line)
missing()
for section, values in changes.items():
if values and section not in seen:
result += ["", "; 节点地址失效回退PXHJ原图入口,不能借用网关作为fallback。", "[" + section + "]"]
result += [key + "=" + value for key, value in values.items()]
# 配置只返回字符串,不在此函数触碰生产config.ini。
return "\n".join(result) + "\n"
def stage(args):
root = live_root(args)
archive = payload(args)
# 默认stage落在工程runtime内且使用唯一ID;已有目录绝不覆盖或递归删除。
parent = root / "runtime"
parent.mkdir(exist_ok=True)
if parent.is_symlink():
raise RuntimeError("Runtime directory cannot be a symbolic link")
directory = parent / ("image-route-stage-" + time.strftime("%Y%m%d-%H%M%S") + "-" + uuid.uuid4().hex[:8])
# stage私有权限保护原配置备份;候选config另设600权限。
directory.mkdir(mode=0o700)
source = directory / "source"
# 由当前板端源码和SDK创建隔离副本,不在生产源目录做增量make。
# 保留相对库链接;后续所有写入路径还会核对没有经过链接逃出stage。
shutil.copytree(root, source, symlinks=True, ignore=ignore_files)
required = {"main.cpp": "CollectorLogRetention::install",
"px_Collect.pro": "COLLECTOR_WITH_GST",
"Go2Controller/network/rtpreceiver.cpp": "PlatformImageSender",
"Fly2Controller/network/RtpReceiver.cpp": "PlatformImageSender"}
# 发现旧工程缺少既有内置日志或JPG收流链路时停止,不能静默退回旧功能。
# 操作者需先审阅差异并合并当前源码,不能让本工具盲目覆盖main或RTP文件。
for relative, marker in required.items():
path = source / relative
if not path.is_file() or marker not in path.read_text(encoding="utf-8"):
raise RuntimeError("Required existing feature missing: " + relative)
# 记录当前板的受保留源码,build再次确认副本没有被覆盖为旧版本。
baseline = {name: digest(root / name) for name in PRESERVED if (root / name).is_file()}
# stage失败时留存证据目录,不自动删除备份或任何生产文件。
previous = {name: digest(root / name) if (root / name).is_file() else None for name in PATCH_FILES}
backup = directory / "backup"
backup.mkdir(mode=0o700)
# 切换前回退材料提前保存;备份config为私有文件,不打印其中任何内容。
for relative in PATCH_FILES + ("px_Collect.pro", "bin/px_JKGL", "config.ini"):
live = root / relative
if live.is_symlink() or not live.parent.resolve().is_relative_to(root):
raise RuntimeError("Unexpected live symbolic link: " + relative)
if live.is_file():
destination = backup / relative
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(live, destination)
os.chmod(str(backup / "config.ini"), 0o600)
# 仅写副本白名单;在用util源码保持原样直到root执行已授权切换。
for name, data in archive.items():
destination = source / name
if destination.is_symlink() or not destination.parent.resolve().is_relative_to(source.resolve()):
raise RuntimeError("Unexpected stage symbolic link: " + name)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(data)
project_append(source / "px_Collect.pro")
# 图片fallback单独指定PXHJ,不读取或改变固定采集目标来猜测原图目的地。
config = candidate_config((root / "config.ini").read_text(encoding="utf-8-sig"),
args.gateway_host, args.gateway_port, args.fallback_ip, args.fallback_port)
candidate = directory / "candidate-config.ini"
# 协商enabled=true只进入候选配置,此时当前程序仍读取原配置。
candidate.write_text(config, encoding="utf-8")
os.chmod(str(candidate), 0o600)
# 元数据只含文件哈希和路径,回退材料与候选config均由stage目录权限保护。
# 原来不存在的新文件以None记录,切换时不会把新增与覆盖混为一谈。
metadata = {"format": 1, "root": str(root), "stage": str(directory),
"packageSha256": digest(args.package), "previousPatchHashes": previous,
"preservedHashes": baseline, "previousProjectSha256": digest(root / "px_Collect.pro"),
"previousBinarySha256": digest(root / "bin/px_JKGL"),
"previousConfigSha256": digest(root / "config.ini"),
"candidateConfigSha256": digest(candidate),
"candidatePatchHashes": {name: digest(source / name) for name in PATCH_FILES},
"candidateProjectSha256": digest(source / "px_Collect.pro"), "built": False}
write_json(directory / "stage.json", metadata)
print(json.dumps({"stage": str(directory), "liveChanged": False,
"backup": str(backup), "candidateConfig": str(candidate)}, ensure_ascii=False))
def stage_record(args):
root = live_root(args)
# 构建与回退都必须绑定创建时的root/stage身份,不能复用别的工程记录。
if args.stage.is_symlink():
raise RuntimeError("Stage directory cannot be a symbolic link")
directory = args.stage.resolve(strict=True)
if not directory.is_relative_to((root / "runtime").resolve()) or directory == root / "runtime":
raise RuntimeError("Stage must stay inside the collector runtime directory")
# 不根据文件名猜测stage所属,记录内的绝对路径必须和当前解析结果相同。
record = json.loads((directory / "stage.json").read_text(encoding="utf-8"))
if record.get("root") != str(root) or record.get("stage") != str(directory):
raise RuntimeError("Stage record does not match its target root")
# 返回规范化路径供build/verify/rollback共用,防止作用范围逐步漂移。
return root, directory, record
def verify(args):
root, directory, record = stage_record(args)
# 任一在用源码/程序/配置从stage以后改变,就停止,不能覆盖更新后的版本。
# 新文件原本不存在也要记录:出现外部新增版本时同样不能覆盖。
expected = dict(record["preservedHashes"])
expected.update(record["previousPatchHashes"])
expected.update({"px_Collect.pro": record["previousProjectSha256"],
"bin/px_JKGL": record["previousBinarySha256"], "config.ini": record["previousConfigSha256"]})
# 不打印任何差异内容,仅报告路径;例如config变化不能泄漏其中的凭证。
changed = [name for name, value in expected.items()
if (digest(root / name) if (root / name).is_file() else None) != value]
if changed:
raise RuntimeError("Live files changed since staging: " + ", ".join(changed))
print(json.dumps({"stage": str(directory), "liveUnchanged": True, "built": record["built"]}))
def run_logged(command, cwd, log):
# 不通过shell拼接命令,参数中的路径作为独立argv传递。
# check=True保证失败停止后续步骤,不把半编译候选标成built。
# 构建输出只写stage日志,失败保留全部证据;不占用原run.log预算。
with log.open("wb") as stream:
subprocess.run(command, cwd=str(cwd), stdout=stream, stderr=subprocess.STDOUT, check=True)
def build(args):
# 本机Windows测试不能代替全ARM工程,本模式明确限定aarch64环境。
# 当前代码不提供任何去掉相机/GStreamer/日志依赖来勉强构建的降级分支。
root, directory, record = stage_record(args)
if platform.machine() not in {"aarch64", "arm64"}:
raise RuntimeError("Full application build must run on the ARM64 target environment")
# build前再次核对生产文件没被其他部署修改,避免基于陈旧源生成候选。
verify(args)
source = directory / "source"
# 源码保留核对发生在任何qmake/make之前,避免事后才发现构建了旧main。
for name in PRESERVED:
if name in record["preservedHashes"] and digest(source / name) != record["preservedHashes"][name]:
raise RuntimeError("Preserved staged source changed: " + name)
build_dir = directory / "build"
build_dir.mkdir(exist_ok=True)
# 空本地stash防止继承祖先缓存;不修改板上已用工程的.qmake.stash。
(build_dir / ".qmake.stash").touch()
run_logged(["qmake", str(source / "px_Collect.pro"), "-after", "INCLUDEPATH+=" + str(source)],
build_dir, directory / "qmake.log")
# 已知板时钟可能早于Qt依赖,阻止Makefile自身反复重生成,但正常编译依赖仍保留。
run_logged(["make", "-o", "Makefile", "-j" + str(args.jobs)], build_dir, directory / "build.log")
# qmake的PWD是隔离source,DESTDIR因此只写stage/source/bin,生产bin不变。
executable = source / "bin/px_JKGL"
if not executable.is_file() or not os.access(str(executable), os.X_OK):
raise RuntimeError("Full build did not produce an executable")
# ldd只检查候选库可解析;LD_LIBRARY_PATH沿用当前启动脚本的SDK/hiredis路径。
environment = dict(os.environ)
environment["LD_LIBRARY_PATH"] = str(root / "3rdparty/libs") + ":/usr/local/lib/pt_libs:" + environment.get("LD_LIBRARY_PATH", "")
# 子进程环境不输出到日志,只保留ldd给出的库路径和解析结果。
linked = subprocess.run(["ldd", str(executable)], env=environment, capture_output=True, text=True, check=True)
(directory / "ldd.log").write_text(linked.stdout, encoding="utf-8")
# 可执行文件存在不代表能启动,未解析的相机/Qt/hiredis库必须在切换前处理。
if "not found" in linked.stdout:
raise RuntimeError("Candidate has unresolved shared libraries; inspect staged ldd.log")
# 原日志测试证明预算与入口不退化;协商测试证明回程、租约和原图协议。
for name, project, executable_name in (
("logging", "native_logging_check", "native_logging_check"),
("image_route", "native_image_route_check", "native_image_route_check"),
):
test_dir = directory / ("test-" + name)
test_dir.mkdir(exist_ok=True)
(test_dir / ".qmake.stash").touch()
run_logged(["qmake", str(source / "tests" / name / (project + ".pro"))],
test_dir, directory / (name + "-qmake.log"))
# 测试也遵守目标板时钟限制,不改系统时间来规避工具链问题。
run_logged(["make", "-o", "Makefile", "-j" + str(args.jobs)],
test_dir, directory / (name + "-build.log"))
# 只运行隔离QtTest,不启动候选主程序,所以不会抢6000/9200或产生真实数据。
run_logged([str(test_dir / executable_name), "-o", str(directory / (name + "-tests.txt")) + ",txt"],
test_dir, directory / (name + "-run.log"))
# 只有完整程序、动态库检查和两套隔离测试都成功才记录built。
# 准备原图与迁移的业务验收仍需root通过真实PXHJ执行,不能由此标为完成。
record.update({"built": True, "candidateBinary": str(executable),
"candidateBinarySha256": digest(executable)})
# built标记仅描述候选准备,实际部署和业务验收还未由本工具执行。
write_json(directory / "stage.json", record)
print(json.dumps({"stage": str(directory), "buildPassed": True, "liveChanged": False,
"candidateBinary": str(executable), "candidateBinarySha256": digest(executable)}))
def arguments():
# 默认inspect,防止操作者漏写模式便执行任何准备或构建写入。
# 所有stage参数都显式给出,不沿用历史部署的IP猜测当前网卡状态。
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("mode", choices=("package", "inspect", "stage", "build", "verify"), nargs="?", default="inspect")
parser.add_argument("--source", type=Path, default=Path("E:/Codex/qrs/SY_26_PX_Collect"))
parser.add_argument("--root", type=Path, default=Path("/home/Tronlong/SY26-PX-Collect"))
parser.add_argument("--package", type=Path)
parser.add_argument("--stage", type=Path)
parser.add_argument("--gateway-host")
parser.add_argument("--gateway-port", type=int, default=19002)
parser.add_argument("--fallback-ip")
parser.add_argument("--fallback-port", type=int, default=6020)
parser.add_argument("--jobs", type=int, default=4)
# mode本身就是明确操作入口;本脚本没有activate或自动启动功能。
args = parser.parse_args()
if args.mode in {"package", "stage"} and args.package is None:
parser.error("--package is required")
if args.mode in {"build", "verify"} and args.stage is None:
parser.error("--stage is required")
if not 1 <= args.jobs <= 16 or any(not 1 <= port <= 65535 for port in (args.gateway_port, args.fallback_port)):
parser.error("Invalid build jobs or UDP port")
if args.mode == "stage":
# 禁止DNS猜测和广播,gateway及fallback必须由root明确给出当前部署IP。
for name, value in (("gateway-host", args.gateway_host), ("fallback-ip", args.fallback_ip)):
try:
address = ipaddress.IPv4Address(value)
if address.is_unspecified or address.is_multicast or int(address) == 0xffffffff:
raise ValueError()
except (ValueError, TypeError):
parser.error("--" + name + " must be an explicit unicast IPv4 address")
return args
if __name__ == "__main__":
options = arguments()
{"package": package, "inspect": inspect, "stage": stage, "build": build, "verify": verify}[options.mode](options)
+40
View File
@@ -0,0 +1,40 @@
[CmdletBinding()]
param([string]$InstallRoot = (Split-Path -Parent $PSScriptRoot), [string]$JavaHome)
. (Join-Path $PSScriptRoot 'Common.ps1')
$root = [System.IO.Path]::GetFullPath($InstallRoot)
$processFile = Join-Path $root 'service-process.json'
$deployment = Get-Content -LiteralPath (Join-Path $root 'deployment.json') -Raw | ConvertFrom-Json
if (Test-Path -LiteralPath $processFile) {
$record = Get-Content -LiteralPath $processFile -Raw | ConvertFrom-Json
if (Get-XtrhOwnedProcess -InstallRoot $root -Record $record) {
Write-Output '本部署实例已在运行。'
return
}
}
$java = Get-XtrhJava -JavaHome $JavaHome
$jar = Join-Path $root 'xtrh-services.jar'
if (-not (Test-Path -LiteralPath $jar)) { throw '未找到部署 JAR。' }
$arguments = @('-Djava.awt.headless=true', '-jar', ('"' + $jar + '"'), '--spring.config.additional-location=file:./config/service.local.properties')
$process = Start-Process -FilePath $java -ArgumentList $arguments -WorkingDirectory $root -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $root 'logs/stdout.log') -RedirectStandardError (Join-Path $root 'logs/stderr.log')
$record = @{pid = $process.Id; startTicks = $process.StartTime.ToUniversalTime().Ticks.ToString(); javaPath = $java; jarPath = $jar}
Write-XtrhText -Path $processFile -Content ($record | ConvertTo-Json)
$ready = $false
$deadline = [DateTime]::UtcNow.AddSeconds(40)
while ([DateTime]::UtcNow -lt $deadline) {
$process.Refresh()
if ($process.HasExited) { break }
try {
$health = Invoke-RestMethod -Uri ('http://127.0.0.1:' + $deployment.port + '/health') -TimeoutSec 1
$identityFile = Join-Path $root 'data/identity.txt'
if ((Test-Path -LiteralPath $identityFile) -and $health.service -eq 'xtrh-services' -and $health.role -eq $deployment.role -and
$health.serviceId -eq (Get-Content -LiteralPath $identityFile -Raw).Trim()) { $ready = $true; break }
} catch { }
Start-Sleep -Milliseconds 500
}
if (-not $ready) {
$owned = Get-XtrhOwnedProcess -InstallRoot $root -Record $record
if ($owned) { Stop-Process -Id $owned.Id -Force }
throw ('服务未通过启动校验;请查看 ' + (Join-Path $root 'logs'))
}
Write-Output ('服务已启动并通过身份校验:' + $deployment.role + ',端口 ' + $deployment.port)
+11
View File
@@ -0,0 +1,11 @@
[CmdletBinding()]
param([string]$InstallRoot = (Split-Path -Parent $PSScriptRoot))
. (Join-Path $PSScriptRoot 'Common.ps1')
$root = [System.IO.Path]::GetFullPath($InstallRoot)
$processFile = Join-Path $root 'service-process.json'
if (-not (Test-Path -LiteralPath $processFile)) { Write-Output '此部署目录没有启动记录。'; return }
$record = Get-Content -LiteralPath $processFile -Raw | ConvertFrom-Json
$process = Get-XtrhOwnedProcess -InstallRoot $root -Record $record
if ($process) { Stop-Process -Id $process.Id -Force }
Remove-Item -LiteralPath $processFile -Force
Write-Output '此部署实例已停止;数据、身份和配置均保留。'
+44
View File
@@ -0,0 +1,44 @@
[CmdletBinding()]
param(
[Parameter(Mandatory)][ValidatePattern('^[0-9a-fA-F-]{36}$')][string]$SourceEndpointId,
[Parameter(Mandatory)][string]$FromNodeId,
[Parameter(Mandatory)][string]$ToNodeId,
[Parameter(Mandatory)][string]$ReadyMarker,
[Parameter(Mandatory)][string]$StartMarker,
[Parameter(Mandatory)][string]$OutputDirectory,
[string]$CoordinatorUrl = 'http://127.0.0.1:18100',
[string]$CredentialFile = (Join-Path (Split-Path -Parent $PSScriptRoot) 'runtime/cluster.local.properties'),
[ValidateRange(1,300)][int]$WaitSeconds = 90,
[ValidateRange(1,3600)][int]$DurationSeconds = 25
)
$ErrorActionPreference = 'Stop'
$readyPath = [IO.Path]::GetFullPath($ReadyMarker)
$startPath = [IO.Path]::GetFullPath($StartMarker)
[void][IO.Directory]::CreateDirectory([IO.Path]::GetDirectoryName($readyPath))
$readyAt = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
$requestId = [Guid]::NewGuid().ToString()
# 只保存随机门闩与公开业务标识,文件中没有集群密钥、浏览器会话或图片访问地址。
$ready = [ordered]@{ requestId = $requestId; endpointId = $SourceEndpointId; fromNodeId = $FromNodeId
toNodeId = $ToNodeId; readyAtMs = $readyAt }
[IO.File]::WriteAllText($readyPath, ($ready | ConvertTo-Json -Compress), [Text.UTF8Encoding]::new($false))
Write-Output '迁移脚本已等待前端采样启动信号,尚未改容量。'
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($WaitSeconds)
$started = $false
while ([DateTimeOffset]::UtcNow -lt $deadline) {
if (Test-Path -LiteralPath $startPath) {
try {
$marker = [IO.File]::ReadAllText($startPath) | ConvertFrom-Json
$now = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
# 匹配本次随机门闩,并要求浏览器采样已开始、时间新鲜,旧文件不能触发迁移。
if ($marker.requestId -eq $requestId -and $marker.endpointId -eq $SourceEndpointId -and
[long]$marker.startedAtMs -ge $readyAt -and [long]$marker.startedAtMs -le $now + 1000 -and
$now - [long]$marker.startedAtMs -le 5000) { $started = $true; break }
} catch { }
}
Start-Sleep -Milliseconds 100
}
if (-not $started) { throw '前端采样启动信号等待超时,未修改容量。' }
Write-Output '前端采样启动信号已匹配,开始已授权的原图迁移。'
& (Join-Path $PSScriptRoot 'Observe-OriginalImage.ps1') -CoordinatorUrl $CoordinatorUrl -CredentialFile $CredentialFile `
-SourceEndpointId $SourceEndpointId -FromNodeId $FromNodeId -ToNodeId $ToNodeId -Migrate -QuietFrames `
-DurationSeconds $DurationSeconds -PollMilliseconds 200 -OutputDirectory $OutputDirectory
@@ -0,0 +1,199 @@
import contextlib
import importlib.util
import io
import json
from pathlib import Path
import tarfile
import tempfile
import subprocess
import sys
from types import SimpleNamespace
import unittest
from unittest.mock import patch
path = Path(__file__).resolve().parents[1] / "Stage-EmbeddedImageRoute.py"
spec = importlib.util.spec_from_file_location("route_stage", path)
stage = importlib.util.module_from_spec(spec)
spec.loader.exec_module(stage)
class ImageRouteStagingTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.work = Path(self.temporary.name)
self.root = self.work / "board"
self.root.mkdir()
for relative in stage.PRESERVED:
target = self.root / relative
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("board latest source: CollectorLogRetention::install PlatformImageSender\n", encoding="utf-8")
(self.root / "px_Collect.pro").write_text("DEFINES += COLLECTOR_WITH_GST\nLIBS += -L/board/vendor -lhiredis\n", encoding="utf-8")
(self.root / "bin").mkdir()
(self.root / "bin/px_JKGL").write_bytes(b"original-test-binary")
(self.root / "config.ini").write_text("; original comment\n[fixed_json]\nudp_host=198.51.100.1\nudp_port=19001\n"
"[heartbeat]\ninterval_ms=3000\n[image_upload]\nenabled=true\ninterval_ms=100\n", encoding="utf-8")
(self.root / "module_keys.json").write_text("not a real credential", encoding="utf-8")
launcher = self.root / "start-network-simulation.sh"
launcher.write_text("#!/bin/sh\n# current private board launcher\n", encoding="utf-8")
launcher.chmod(0o755)
self.package = self.work / "route.tar.gz"
content = {}
manifest = {"format": 1, "protocol": "COLLECTOR_IMAGE_ROUTE", "files": []}
import hashlib
for name in stage.PATCH_FILES + stage.TEST_FILES:
data = b"isolated test source\n"
content[name] = data
manifest["files"].append({"path": name, "sha256": hashlib.sha256(data).hexdigest()})
content["image-route-manifest.json"] = json.dumps(manifest).encode()
with tarfile.open(str(self.package), "w:gz") as archive:
for name, data in content.items():
item = tarfile.TarInfo(name); item.size = len(data)
archive.addfile(item, io.BytesIO(data))
self.args = SimpleNamespace(root=self.root, package=self.package, gateway_host="192.0.2.10",
gateway_port=19002, fallback_ip="192.0.2.11", fallback_port=6020)
def create_stage(self):
output = io.StringIO()
with contextlib.redirect_stdout(output):
stage.stage(self.args)
return Path(json.loads(output.getvalue())["stage"])
def test_stage_keeps_live_sources_binary_config_and_keys_untouched(self):
original = (self.root / "config.ini").read_bytes()
directory = self.create_stage()
self.assertEqual(original, (self.root / "config.ini").read_bytes())
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
self.assertFalse((self.root / stage.PATCH_FILES[0]).exists())
self.assertFalse((directory / "source/module_keys.json").exists())
self.assertFalse((directory / "source/config.ini").exists())
self.assertEqual((self.root / "main.cpp").read_bytes(), (directory / "source/main.cpp").read_bytes())
text = (directory / "source/px_Collect.pro").read_text(encoding="utf-8")
self.assertIn("LIBS += -L/board/vendor -lhiredis", text)
self.assertIn("SOURCES += util/PlatformImageRouteClient.cpp", text)
def test_candidate_config_sets_independent_fallback_and_preserves_other_settings(self):
directory = self.create_stage()
text = (directory / "candidate-config.ini").read_text(encoding="utf-8")
self.assertIn("; original comment", text)
self.assertIn("[fixed_json]\nudp_host=198.51.100.1\nudp_port=19001", text)
self.assertIn("[heartbeat]\ninterval_ms=3000", text)
self.assertIn("interval_ms=100", text)
self.assertIn("udp_host=192.0.2.11", text)
self.assertIn("gateway_host=192.0.2.10", text)
def test_verify_rejects_source_changed_after_stage(self):
directory = self.create_stage()
args = SimpleNamespace(root=self.root, stage=directory)
with contextlib.redirect_stdout(io.StringIO()):
stage.verify(args)
(self.root / "main.cpp").write_text("new board modification", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "main.cpp"):
stage.verify(args)
def test_archive_path_traversal_is_rejected_without_writing(self):
with tarfile.open(str(self.package), "w:gz") as archive:
item = tarfile.TarInfo("../outside.cpp"); item.size = 1
archive.addfile(item, io.BytesIO(b"x"))
with self.assertRaisesRegex(RuntimeError, "archive contents"):
stage.payload(self.args)
self.assertFalse((self.work / "outside.cpp").exists())
def test_missing_native_log_feature_cannot_be_silently_downgraded(self):
(self.root / "main.cpp").write_text("old Qt application without native log", encoding="utf-8")
with self.assertRaisesRegex(RuntimeError, "Required existing feature missing: main.cpp"):
self.create_stage()
def test_bad_config_error_does_not_echo_original_line(self):
with self.assertRaisesRegex(RuntimeError, "unsupported or duplicate entries") as error:
stage.candidate_config("[image_upload]\nenabled=true\nCONFIDENTIAL-TEST-LINE", "192.0.2.10", 19002, "192.0.2.11", 6020)
self.assertNotIn("CONFIDENTIAL", str(error.exception))
def test_rollback_preview_validates_current_ownership_without_changing_live_files(self):
directory = self.create_stage()
metadata_file = directory / "stage.json"
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
metadata.update(built=True, candidateBinarySha256="synthetic-unused-candidate-hash")
metadata_file.write_text(json.dumps(metadata), encoding="utf-8")
command = [sys.executable, str(path.with_name("Rollback-EmbeddedImageRoute.py")),
"--root", str(self.root), "--stage", str(directory)]
original = (self.root / "config.ini").read_bytes()
completed = subprocess.run(command, capture_output=True, text=True, check=True)
self.assertTrue(json.loads(completed.stdout)["rollbackReady"])
self.assertEqual(original, (self.root / "config.ini").read_bytes())
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
(self.root / "config.ini").write_text("unrelated newer configuration", encoding="utf-8")
completed = subprocess.run(command, capture_output=True, text=True)
self.assertNotEqual(completed.returncode, 0)
self.assertIn("Unrelated live update detected: config.ini", completed.stderr)
def built_stage(self):
directory = self.create_stage()
binary = directory / "source/bin/px_JKGL"
binary.parent.mkdir(); binary.write_bytes(b"isolated-candidate-binary"); binary.chmod(0o755)
metadata_file = directory / "stage.json"
metadata = json.loads(metadata_file.read_text(encoding="utf-8"))
metadata.update(built=True, candidateBinarySha256=stage.digest(binary), candidateBinary=str(binary))
metadata_file.write_text(json.dumps(metadata), encoding="utf-8")
(directory / "logging-tests.txt").write_text("Totals: 9 passed, 0 failed, 0 skipped", encoding="utf-8")
(directory / "image_route-tests.txt").write_text("SKIP: javaAndCppWireCodecInteroperateWithRealEncryptedFrames\n"
"Totals: 8 passed, 0 failed, 1 skipped", encoding="utf-8")
return directory
def load_script(self, name):
spec = importlib.util.spec_from_file_location(name, path.with_name(name + ".py"))
module = importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
return module
def test_activation_preview_and_success_replace_only_owned_files(self):
activate = self.load_script("Activate-EmbeddedImageRoute")
directory = self.built_stage()
args = SimpleNamespace(root=self.root, stage=directory, apply=False, health_timeout=15)
with contextlib.redirect_stdout(io.StringIO()):
activate.activate(args)
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
args.apply = True
health = {"collectorPids": [4321], "nativeLogging": {"owner": "px_JKGL", "limitBytes": 1024 ** 3,
"withinLimit": True, "failedWrites": 0}}
with patch.object(activate, "stop_collector"), patch.object(activate, "launch") as launch, \
patch.object(activate, "healthy", return_value=health), contextlib.redirect_stdout(io.StringIO()):
activate.activate(args)
launch.assert_called_once()
self.assertEqual(b"isolated-candidate-binary", (self.root / "bin/px_JKGL").read_bytes())
self.assertEqual((directory / "candidate-config.ini").read_bytes(), (self.root / "config.ini").read_bytes())
self.assertEqual((directory / "source/main.cpp").read_bytes(), (self.root / "main.cpp").read_bytes())
self.assertTrue(json.loads((directory / "activation-result.json").read_text())["activated"])
def test_failed_activation_invokes_real_rollback_logic_with_simulated_processes(self):
activate = self.load_script("Activate-EmbeddedImageRoute")
rollback = self.load_script("Rollback-EmbeddedImageRoute")
directory = self.built_stage()
original_config = (self.root / "config.ini").read_bytes()
args = SimpleNamespace(root=self.root, stage=directory, apply=True, health_timeout=15)
launched = {"old": False}
def simulated_run(command, **kwargs):
if command[0] == sys.executable:
with patch.object(sys, "argv", command[1:]):
rollback.main()
else:
self.assertEqual(command, [str(self.root / "start-network-simulation.sh")])
launched["old"] = True
return subprocess.CompletedProcess(command, 0)
with patch.object(activate, "stop_collector"), patch.object(activate, "launch", side_effect=RuntimeError("simulated launch failed")), \
patch.object(activate.subprocess, "run", side_effect=simulated_run), \
patch.object(rollback.helper, "process_ids", side_effect=lambda root: [4321] if launched["old"] else []), \
patch.object(rollback.time, "sleep"), contextlib.redirect_stdout(io.StringIO()):
with self.assertRaisesRegex(RuntimeError, "rollbackSucceeded=True"):
activate.activate(args)
self.assertTrue(launched["old"])
self.assertEqual(b"original-test-binary", (self.root / "bin/px_JKGL").read_bytes())
self.assertEqual(original_config, (self.root / "config.ini").read_bytes())
self.assertFalse((self.root / "util/PlatformImageRouteClient.cpp").exists())
self.assertTrue(json.loads((directory / "activation-error.json").read_text())["rollbackSucceeded"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,60 @@
# 嵌入式图片协商候选部署
该工具准备可审阅的候选版本,默认仅检查。创建stage和构建不会停止采集程序、修改在用配置或替换在用可执行文件;实际SSH、切换、业务联调由总任务执行。
## 已核对的边界
历史部署及本次根任务SSH只读确认:采集板 `root@192.168.7.165`、ARM64 RK3588,工程 `/home/Tronlong/SY26-PX-Collect`,启动入口 `start.sh`,程序 `bin/px_JKGL`。本机平台地址 `192.168.7.222`;本次网卡路由需SSH显式 `-b 192.168.7.222`。主机密钥使用现有 `E:/pxhj/runtime/board-known-hosts` 严格核验,不自动忽略未知主机密钥。
先由root核对板时钟,当前发现其严重落后。采集协议要求时间差不超过60秒,不能只修复make循环而保留错误时钟。SSH接入和校时由root负责,此工具不配置网络、系统时间或服务。
本次包只含6份图片协商相关C++源码和两套隔离QtTest。板上当前源码及SDK作为构建基线;`main.cpp`、内置1GiB日志、RTP收流、串口桥、现有启动脚本和其他源码均保留。`px_Collect.pro`只追加缺少的编译单元。旧 `runtime/embedded-log-retention/deploy.py` 会修改日志服务及旧启动重定向,不能直接用于本次。
## 生成和传输
在Windows运行:
```powershell
python E:\Codex\xtrh\scripts\Stage-EmbeddedImageRoute.py package --source E:\Codex\qrs\SY_26_PX_Collect --package E:\Codex\xtrh\runtime\board-image-route\collector-image-route.tar.gz
```
同名包不会覆盖。命令输出文件数和SHA256,包不含INI、设备密钥、运行日志或旧程序。root通过已确认SSH连接上传该包以及两个 `.py` 工具;不在命令或日志中放密码/密钥。
## 板端stage和构建
在已确认的板端目录执行下列命令,包路径按实际上传位置填入:
```sh
python3 Stage-EmbeddedImageRoute.py inspect
python3 Stage-EmbeddedImageRoute.py stage --package /path/collector-image-route.tar.gz --gateway-host 192.168.7.222 --gateway-port 19002 --fallback-ip 192.168.7.222 --fallback-port 6020
python3 Stage-EmbeddedImageRoute.py build --stage /home/Tronlong/SY26-PX-Collect/runtime/image-route-stage-<实际ID>
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互操作夹具时该可选测试会标记跳过,其他业务断言仍执行。
候选程序在 `<stage>/source/bin/px_JKGL`,候选配置在 `<stage>/candidate-config.ini`,完整回退材料在 `<stage>/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构建、切换及准备/迁移验收需要分别记录真实结果,不能用本机测试替代。
+18
View File
@@ -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);
}
}
@@ -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;
}
}
@@ -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; }
}
@@ -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()));
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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<ObjectNode> rows(ObjectNode root, String key) {
List<ObjectNode> result = new ArrayList<>();
root.path(key).elements().forEachRemaining(n -> result.add((ObjectNode) n));
return result;
}
}
@@ -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());
}
}
@@ -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<String, Long> 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));
}
};
}
}
@@ -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));
}
}
@@ -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); }
}
@@ -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;
}
}
@@ -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; }
}
}
@@ -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<String> 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<String> 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<String> 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<String> 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).*");
}
}
@@ -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);}
}
@@ -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<String,Long> 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<leaseMs;}
/** 许可同时绑定节点身份和本次启动会话,节点重新启动后旧任务立即失效。 */
private boolean valid(ObjectNode assignment){
if(assignment==null)return false;
var n=map("nodes").get(assignment.path("nodeId").asText());
return n!=null&&live((ObjectNode)n)&&n.path("sessionId").asText().equals(assignment.path("sessionId").asText());
}
private ObjectNode assignment(ObjectNode task,String key){return task.path(key).isObject()?(ObjectNode)task.path(key):null;}
private void persist(){
if(!storageHealthy)throw new IllegalStateException("CoordinatorService persistence unavailable");
// 修订号用于 PXHJ 拒绝迟到快照,同一总控代次中只能向更新的状态推进。
state.put("revision",state.path("revision").asLong()+1);
try{store.save(state);}catch(RuntimeException e){storageHealthy=false;throw e;}
}
/** 发现阶段的身份凭据:客户端验证响应签名后,再比较集群与稳定服务身份。 */
public synchronized ObjectNode info(){return object().put("role","coordinator").put("clusterId",cluster).put("serviceId",state.path("serviceId").asText()).put("epoch",state.path("epoch").asLong()).put("storageHealthy",storageHealthy);}
/**
* 自动注册复用持久化节点身份;本次启动号变化时更新会话。
* 已经由 PXHJ 调整过的容量优先,重新注册不能用安装默认值覆盖管理员配置。
*/
public synchronized ObjectNode register(JsonNode input){
require(cluster.equals(required(input,"clusterId")),"Cluster mismatch");
String id=required(input,"nodeId"),boot=required(input,"bootId"),url=baseUrl(required(input,"baseUrl"));
require(id.matches("[a-zA-Z0-9_-]{8,64}"),"Invalid node identity");
String nodeRole=input.path("nodeRole").asText("WORKER");
require(Set.of("COORDINATOR","WORKER").contains(nodeRole),"Invalid execution node role");
// 主执行行复用总控的稳定身份,普通子节点不能占用它或自称另一个主节点。
require(nodeRole.equals("COORDINATOR")==id.equals(state.path("serviceId").asText()),"Coordinator worker identity mismatch");
int suggested=input.path("capacity").asInt(2);
require(suggested>=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<String>();
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<String> 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())<n.path("capacity").asInt()
&&(!original(task)||(n.path("imageInputPort").asInt()>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<ObjectNode> 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);i<incoming.size();i++){incoming.get(i).putNull("candidate");incoming.get(i).put("reason","CAPACITY_CHANGED");}
var owned=tasks.stream().filter(t->t.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<String> 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<Integer> ports=new HashSet<>();Set<String> ids=new HashSet<>();List<JsonNode> 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()<leaseMs);gateways.add(copy);}
result.set("routes",array().addAll(rows(state,"routes")));result.set("contexts",array().addAll(rows(state,"contexts")));
result.set("summary",object().put("capacity",capacity).put("active",active).put("queued",queued));return result;
}
/** 没有新心跳时也定期撤销到期许可,避免失联任务一直显示在线。 */
@Scheduled(fixedDelay=1000) public synchronized void tick(){if(!storageHealthy)return;schedule();persist();}
@PreDestroy public void close() throws Exception{store.close();}
}
@@ -0,0 +1,65 @@
package com.xtrh.control.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.*;
import static com.xtrh.common.util.Jsons.*;
/**
* 原图来源的短期许可:密钥只停留在内存中,不属于可持久化的试验状态。
* 总控重启后必须由经过 PXHJ 认证的采集器重新协商,不能恢复旧上传授权。
*/
final class ImageSourceRegistry {
// 由 CoordinatorService 的同步边界调用,注册、续期与回收不会与调度交叉修改。
static final long SOURCE_LEASE_MS = 30_000;
private final Map<String, Entry> 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<String> expire(long monotonic) {
Set<String> 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");
}
}
@@ -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<String> 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<String, String> 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;
}
}
@@ -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<JmDNS> engines = new CopyOnWriteArrayList<>();
// 地址按公告身份汇集;真正的逻辑身份仍需从已签名的控制响应中确认。
private final Map<String, Set<String>> 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<InetAddress> 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<String> 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<String> 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) {
// 逐个关闭网卡引擎,单个关闭错误不能阻止其他组播套接字释放。
}
}
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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<Packet> 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<Relay> 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<String, Relay> existing = new HashMap<>();
for (Relay relay : relays) {
existing.put(relay.id, relay);
}
List<Relay> next = new ArrayList<>(), opened = new ArrayList<>();
Map<Relay, ObjectNode> 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<Relay, ObjectNode> entry : updates.entrySet()) {
entry.getKey().update(entry.getValue());
}
List<Relay> 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();
}
}
}
@@ -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);
}
}
@@ -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());
}
}
@@ -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"));
}
}
@@ -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<StreamingResponseBody> 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);
}
}
@@ -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");
}
}
@@ -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<Packet> queue = new ArrayBlockingQueue<>(2048);
private final Map<String, Permit> permits = new HashMap<>();
private final Map<String, String> accessErrors = new HashMap<>();
private final Map<String, Assembly> pending = new HashMap<>();
// 最近帧按任务版本隔离;同来源的多项任务可以引用同一份不可变 JPEG 字节。
// 元数据查询后还有两帧容错空间,防止下一次上传立刻淘汰正在取图的帧号。
private final Map<String, List<Frame>> recent = new HashMap<>();
private final LinkedHashMap<String, Long> 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<String, Permit> 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<Frame> 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<String> 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<Permit> matches;
synchronized (this) {
cleanup();
matches = permits.values().stream().filter(permit -> permit.source.equals(packet.source.getAddress())
&& permit.collector == ImageWireDecoder.collectorId(packet.bytes)).toList();
}
Map<String, Permit> 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<Frame> 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;
}
}
@@ -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<String, MediaTask> 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<String> 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);
}
}
+16
View File
@@ -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
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="none" focusable="false">
<circle cx="14" cy="14" r="10" stroke="#ffffff" stroke-width="1.5" opacity=".35"/>
<path d="M4 14h5l2.5-6 5 12 2.5-6h5" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="21.5" cy="6.5" r="2" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 372 B

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="same-origin" />
<link rel="icon" type="image/svg+xml" href="/console/assets/brand.svg" />
<title>系统融合软件</title>
<script type="module" crossorigin src="/console/assets/index-DHXeX6-t.js"></script>
<link rel="stylesheet" crossorigin href="/console/assets/index-CGh3g9P7.css">
</head>
<body><div id="root"></div></body>
</html>
@@ -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<String> 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); }
}
}
@@ -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;
}
}
@@ -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"));
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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<String, String> sessions = new HashMap<>();
private final Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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<String, String> playbackUrls() {
Map<String, String> 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;
}
}
@@ -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(); }
}
}
@@ -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); }
}
}
@@ -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<JsonNode> 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<InputStream> 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); }
}
}
@@ -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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> badMarker = PxhjWireOracle.chunks(corrupted, 9, System.currentTimeMillis(), 80, 60);
List<byte[]> badDimensions = PxhjWireOracle.chunks(jpeg, 10, System.currentTimeMillis(), 1, 1);
List<byte[]> stale = PxhjWireOracle.chunks(jpeg, 11, System.currentTimeMillis() - 120_000, 80, 60);
List<byte[]> 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<byte[]> mutate(List<byte[]> packets, int offset) {
List<byte[]> 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;
}
}
@@ -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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<byte[]> 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<JsonNode> 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); }
}
}
@@ -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<byte[]> chunks(byte[] jpeg, long frame, long sourceTime, int width, int height) throws Exception {
return chunks(jpeg, frame, sourceTime, width, height, false);
}
static List<byte[]> 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<byte[]> 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<byte[]> packets) throws Exception {
for (byte[] bytes : packets) socket.send(new DatagramPacket(bytes, bytes.length, InetAddress.getByName("127.0.0.1"), port));
}
}
@@ -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<ObjectNode> 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;
}
}
+11
View File
@@ -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
+644
View File
@@ -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 后端与页面<br/>试验组织、统一操作、数据和视频查看"]
M["主节点:同一总控进程<br/>配置管理、任务分配、状态汇总<br/>默认也执行本机视频任务,端口18100"]
G["采集接入网关<br/>已实现原始报文双向转发<br/>协议解析与数据分发后续实现"]
D["已登记采集器与采集模块"]
C["真实摄像头或视频源"]
S["仿真推演系统<br/>后续接入:统一场景状态、多相机视角输出"]
W["多台融合节点<br/>接收采集器原图、处理多路演示视频"]
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):融合配置、状态缓存与中转登记的数据库访问。
实施时以当前代码和正式接入资料再次核对,不直接用旧演示中的虚构试验或模拟接口替代以上业务能力。