初始化系统融合软件代码仓库
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
||||
/target/
|
||||
/data/
|
||||
/runtime/
|
||||
/output/
|
||||
/frontend/dist/
|
||||
node_modules/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
*.local.properties
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/deployment/*.zip
|
||||
@@ -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 的配置和联调。
|
||||
@@ -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
|
||||
@@ -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)。真实摄像机、真实融合和多台物理电脑性能验收另行进行。
|
||||
@@ -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
@@ -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、内存、网络速率和未接入的虚拟/融合数据均为 `—` 或空列表。
|
||||
@@ -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、内存、网络速率与其他功能数据源仍为空。
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+1124
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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)};
|
||||
}
|
||||
@@ -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>);
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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},
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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 '运行中的后端不会自动加载新源码。'
|
||||
@@ -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 静态资源目录。'
|
||||
@@ -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'))
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 + '。后端重启后加载。')
|
||||
@@ -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 = ''
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
@@ -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 '部分原容量未恢复,请按保存的迁移计划处理。' }
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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 '此部署实例已停止;数据、身份和配置均保留。'
|
||||
@@ -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构建、切换及准备/迁移验收需要分别记录真实结果,不能用本机测试替代。
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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):融合配置、状态缓存与中转登记的数据库访问。
|
||||
|
||||
实施时以当前代码和正式接入资料再次核对,不直接用旧演示中的虚构试验或模拟接口替代以上业务能力。
|
||||
Reference in New Issue
Block a user