From fa1c32894a7a3ebc2830db5e37f85803d51ad1ad Mon Sep 17 00:00:00 2001 From: wangguangyuan Date: Tue, 22 Sep 2026 16:04:42 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=9E=E6=97=B6=E9=A2=84=E8=A7=88flvU?= =?UTF-8?q?rl/hlsUrl=E6=8C=89=E8=AF=B7=E6=B1=82=E6=9D=A5=E6=BA=90IP?= =?UTF-8?q?=E7=94=9F=E6=88=90=EF=BC=8C=E6=94=AF=E6=8C=81=E5=86=85=E5=A4=96?= =?UTF-8?q?=E7=BD=91=E5=8F=8CIP=E8=AE=BF=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 +- .../controller/HikNvrUiActionController.java | 21 ++- .../hik/service/HikNvrLiveStreamService.java | 127 +++++++++++------- 3 files changed, 99 insertions(+), 57 deletions(-) diff --git a/.env.example b/.env.example index 35d90672..f25efa4d 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,13 @@ TZ=Asia/Shanghai BIND_ADDRESS=0.0.0.0 APP_PORT=8081 -# Remote ZLMediaKit. RTMP is used by the container to publish; HTTP is returned to browsers. +# Remote ZLMediaKit. RTMP is used by the container to publish; HTTP is the fallback for browsers. +# Live preview play addresses (flvUrl/hlsUrl) follow the address the browser actually used: the +# host of X-Forwarded-Host/Host replaces the host below, while scheme and port stay as configured +# here. So one deployment can serve both an intranet IP and an internet IP, e.g. +# ZLM_HTTP_BASE_URL=http://192.168.20.210:18891 also returns http://192.168.134.10:18891/live/... +# for requests that reach the service through 192.168.134.10. This value is returned only when the +# request carries no usable host (internal calls, background threads). ZLM_RTMP_BASE_URL=rtmp://192.168.1.200:1935 ZLM_HTTP_BASE_URL=http://192.168.1.200:18081 diff --git a/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java b/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java index 3540d3b4..67b0c687 100644 --- a/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java +++ b/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java @@ -9,6 +9,7 @@ import com.inspect.nvr.hik.domain.HikNvrUiActionRequest; import com.inspect.nvr.hik.domain.HikNvrUiActionResponse; import com.inspect.nvr.hik.exception.HikNvrPlaybackException; import com.inspect.nvr.hik.service.HikNvrCredentialService; +import com.inspect.nvr.hik.service.HikNvrLivePublicBaseUrlResolver; import com.inspect.nvr.hik.service.HikNvrLiveStreamService; import com.inspect.nvr.hik.service.HikNvrUiActionService; import org.springframework.http.HttpHeaders; @@ -23,6 +24,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; @RestController @CrossOrigin @@ -35,6 +37,9 @@ public class HikNvrUiActionController { /** 海康实时拉流服务。 */ @Resource private HikNvrLiveStreamService liveStreamService; + /** 按请求来源地址生成实时预览对外播放地址。 */ + @Resource + private HikNvrLivePublicBaseUrlResolver publicBaseUrlResolver; /** 按页面选择的 NVR IP 从数据库读取服务端凭据。 */ @Resource private HikNvrCredentialService credentialService; @@ -116,12 +121,17 @@ public class HikNvrUiActionController { */ @PostMapping("/live/start") /** 代理实时拉流启动请求。 */ - public ResponseEntity startLive(@RequestBody HikNvrUiActionRequest request) { + public ResponseEntity startLive( + @RequestBody HikNvrUiActionRequest request, + HttpServletRequest httpRequest) { // 从页面请求提取设备树完整 cameraCode 和可选码流类型。 String cameraCode = request == null ? null : request.getCameraCode(); Integer streamType = request == null ? null : request.getStreamType(); + // 内网与外网两个 IP 共用本服务时,flvUrl/hlsUrl 必须指向本次请求实际访问的地址。 + String publicBaseUrl = publicBaseUrlResolver.resolve(httpRequest); // 实时服务通过现有 IVS 媒体接口解析 cameraCode 并取得 RTSP 源地址。 - return ResponseEntity.ok(liveStreamService.start(cameraCode, streamType)); + return ResponseEntity.ok( + liveStreamService.start(cameraCode, streamType, publicBaseUrl)); } /** @@ -151,9 +161,12 @@ public class HikNvrUiActionController { */ @GetMapping("/live/status") public ResponseEntity liveStatus( - @RequestParam("cameraCode") String cameraCode) { + @RequestParam("cameraCode") String cameraCode, + HttpServletRequest httpRequest) { // 按设备树完整 cameraCode 查询唯一实时拉流状态。 - return ResponseEntity.ok(liveStreamService.status(cameraCode)); + // 状态轮询同样按请求来源返回播放地址,保证与外网/内网访问方一致。 + return ResponseEntity.ok(liveStreamService.status( + cameraCode, publicBaseUrlResolver.resolve(httpRequest))); } /** diff --git a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java index 2a251279..22da5639 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java @@ -119,7 +119,7 @@ public class HikNvrLiveStreamService { private String ffmpegPath; /** ZLMediaKit RTMP 基础地址。 */ private String rtmpBaseUrl; - /** 返回给浏览器的 ZLMediaKit HTTP 基础地址。 */ + /** 固定的 ZLMediaKit HTTP 基础地址,作为请求无法提供对外地址时的回退值。 */ private String httpBaseUrl; /** 服务端访问 ZLMediaKit API 的 HTTP 基础地址。 */ private String zlmApiBaseUrl; @@ -227,9 +227,11 @@ public class HikNvrLiveStreamService { * * @param cameraCode 页面设备树提供的 IVS 完整摄像机编码 * @param streamType 主码流 1 或子码流 2 + * @param publicBaseUrl 本次请求解析出的浏览器可达基础地址;为空时回退固定配置 * @return 已播放、正在排队或正在启动的实时流状态 */ - public HikNvrLiveStreamResponse start(String cameraCode, Integer streamType) { + public HikNvrLiveStreamResponse start( + String cameraCode, Integer streamType, String publicBaseUrl) { // cameraCode 是实时预览唯一设备标识,NVR 与通道由服务端数据库解析。 String targetCameraCode = requireCameraCode(cameraCode); // 启动前校验 FFmpeg、ZLM API 和媒体网关配置,缺项时立即返回明确错误。 @@ -253,8 +255,7 @@ public class HikNvrLiveStreamService { String sessionKey = sessionKey(targetCameraCode); String streamId = buildStreamId(targetCameraCode); - PendingStart pending = new PendingStart( - target, type, streamId, hlsUrl(streamId), flvUrl(streamId)); + PendingStart pending = new PendingStart(target, type, streamId, publicBaseUrl); // 相同摄像机已有任务时复用;码流切换则取消旧任务并用新任务替换。 while (true) { @@ -265,7 +266,8 @@ public class HikNvrLiveStreamService { } // 相同码流正在排队或启动时直接返回现有任务,避免重复拉流。 if (previous.streamType == type) { - return toPendingResponse(previous, "实时预览正在排队或启动"); + return toPendingResponse( + previous, "实时预览正在排队或启动", publicBaseUrl); } // 码流类型变化时取消旧任务,旧任务会在下一探测点释放已启动的 FFmpeg。 previous.cancelled = true; @@ -303,7 +305,7 @@ public class HikNvrLiveStreamService { // 非首个任务已安全进入队列,立即返回 STARTING 让前端轮询状态。 if (!firstInLane) { - return toPendingResponse(pending, "实时预览排队中"); + return toPendingResponse(pending, "实时预览排队中", publicBaseUrl); } try { // 首个任务最多等待总启动时限加少量调度余量,避免 Tomcat 请求长时间挂死。 @@ -311,11 +313,11 @@ public class HikNvrLiveStreamService { startupTimeoutMillis + 500L, TimeUnit.MILLISECONDS); } catch (TimeoutException timeout) { // 原生 SDK 若超出预期仍在工作,HTTP 先返回 STARTING,后台结果由状态接口提供。 - return toPendingResponse(pending, "实时预览启动中"); + return toPendingResponse(pending, "实时预览启动中", publicBaseUrl); } catch (InterruptedException interrupted) { // 请求线程被中断时保留后台任务,并恢复线程中断标志。 Thread.currentThread().interrupt(); - return toPendingResponse(pending, "实时预览启动中"); + return toPendingResponse(pending, "实时预览启动中", publicBaseUrl); } catch (ExecutionException execution) { Throwable cause = execution.getCause(); // 起流任务已返回统一业务异常时保持原始字符串错误码和 HTTP 状态。 @@ -395,7 +397,7 @@ public class HikNvrLiveStreamService { && existing.isAlive() && existing.streamType == pending.streamType && queryZlmStream(existing.streamId) == ZlmProbeResult.REGISTERED) { - return toResponse(existing, "PLAYING", "实时预览已在播放"); + return toResponse(existing, "PLAYING", "实时预览已在播放", pending.publicBaseUrl); } // 旧进程已退出或码流类型变化时先释放旧流,再启动本次请求。 if (existing != null) { @@ -520,9 +522,7 @@ public class HikNvrLiveStreamService { pending.target.getChannel(), pending.streamType, pending.streamId, - process, - pending.hlsUrl, - pending.flvUrl); + process); // 注册本地会话后,停止和状态请求即可立即定位并释放正在启动的进程。 sessions.put(sessionKey, session); log.info("[live] started cameraCode={} nvrIp={} host={} cameraChannel={} source={} streamType={} streamId={} attempt={} log={}", @@ -541,7 +541,7 @@ public class HikNvrLiveStreamService { // ZLM 已确认同名 RTMP 流注册时才认为起流成功。 if (probe == ZlmProbeResult.REGISTERED) { failedStarts.remove(sessionKey); - return toResponse(session, "PLAYING", "实时预览已启动"); + return toResponse(session, "PLAYING", "实时预览已启动", pending.publicBaseUrl); } // 未注册或进程退出时立即销毁 FFmpeg,快速断开 NVR RTSP 会话。 @@ -644,9 +644,10 @@ public class HikNvrLiveStreamService { * 查询指定 cameraCode 当前排队、播放或失败状态。 * * @param cameraCode 页面设备树提供的 IVS 完整摄像机编码 + * @param publicBaseUrl 本次请求解析出的浏览器可达基础地址;为空时回退固定配置 * @return 实时流状态 */ - public HikNvrLiveStreamResponse status(String cameraCode) { + public HikNvrLiveStreamResponse status(String cameraCode, String publicBaseUrl) { // 规范化 cameraCode,确保状态查询读取唯一摄像机会话。 String targetCameraCode = requireCameraCode(cameraCode); String sessionKey = sessionKey(targetCameraCode); @@ -656,14 +657,14 @@ public class HikNvrLiveStreamService { ZlmProbeResult probe = queryZlmStream(session.streamId); // ZLM 已注册说明前端现在可以安全拉取 FLV/HLS。 if (probe == ZlmProbeResult.REGISTERED) { - return toResponse(session, "PLAYING", "实时预览播放中"); + return toResponse(session, "PLAYING", "实时预览播放中", publicBaseUrl); } // 进程仍在时统一返回 STARTING;即使 ZLM API 短暂不可用,也不能误报为 STOPPED。 if (session.isAlive()) { String message = probe == ZlmProbeResult.UNAVAILABLE ? "ZLMediaKit状态暂时无法确认" : "实时预览启动中"; - return toResponse(session, "STARTING", message); + return toResponse(session, "STARTING", message, publicBaseUrl); } // 进程已经退出时移除旧映射,后面返回异步失败或停止状态。 if (!session.isAlive()) { @@ -674,7 +675,7 @@ public class HikNvrLiveStreamService { PendingStart pending = pendingStarts.get(sessionKey); // 排队任务尚未产生进程时向前端返回地址和 STARTING,供统一轮询。 if (pending != null) { - return toPendingResponse(pending, "实时预览排队中"); + return toPendingResponse(pending, "实时预览排队中", publicBaseUrl); } HikNvrLiveStreamResponse failure = failedStarts.get(sessionKey); // 异步任务已经失败时返回稳定错误码和具体消息,避免前端只显示通用超时。 @@ -685,12 +686,13 @@ public class HikNvrLiveStreamService { String streamId = buildStreamId(targetCameraCode); // 本地状态丢失但 ZLM 仍有同名流时覆盖容器重启或其他入口创建的流复用场景。 if (queryZlmStream(streamId) == ZlmProbeResult.REGISTERED) { + String baseUrl = browserBaseUrl(publicBaseUrl, null); return HikNvrLiveStreamResponse.builder() .cameraCode(targetCameraCode) .streamId(streamId) .status("PLAYING") - .hlsUrl(hlsUrl(streamId)) - .flvUrl(flvUrl(streamId)) + .hlsUrl(hlsUrl(streamId, baseUrl)) + .flvUrl(flvUrl(streamId, baseUrl)) .message("ZLMediaKit中已有同名实时流") .build(); } @@ -1748,11 +1750,17 @@ public class HikNvrLiveStreamService { return builder.start(); } - /** 将本地会话转换为接口响应。 */ + /** + * 将本地会话转换为接口响应。 + * + * @param publicBaseUrl 当前请求解析出的对外基础地址;为空时回退固定配置 + */ private HikNvrLiveStreamResponse toResponse( LiveSession session, String status, - String message) { + String message, + String publicBaseUrl) { + String baseUrl = browserBaseUrl(publicBaseUrl, null); // 返回服务端解析出的 NVR 和通道,但绝不回传账号密码或 RTSP 地址。 return HikNvrLiveStreamResponse.builder() .cameraCode(session.cameraCode) @@ -1761,16 +1769,22 @@ public class HikNvrLiveStreamService { .streamType(session.streamType) .streamId(session.streamId) .status(status) - .hlsUrl(session.hlsUrl) - .flvUrl(session.flvUrl) + .hlsUrl(hlsUrl(session.streamId, baseUrl)) + .flvUrl(flvUrl(session.streamId, baseUrl)) .message(message) .build(); } - /** 将排队任务转换为 STARTING 响应。 */ + /** + * 将排队任务转换为 STARTING 响应。 + * + * @param publicBaseUrl 当前请求解析出的对外基础地址;为空时沿用创建任务时的地址 + */ private HikNvrLiveStreamResponse toPendingResponse( PendingStart pending, - String message) { + String message, + String publicBaseUrl) { + String baseUrl = browserBaseUrl(publicBaseUrl, pending.publicBaseUrl); return HikNvrLiveStreamResponse.builder() .cameraCode(pending.target.getCameraCode()) .nvrIp(pending.target.getNvrInfo().getNvrIp()) @@ -1778,8 +1792,8 @@ public class HikNvrLiveStreamService { .streamType(pending.streamType) .streamId(pending.streamId) .status("STARTING") - .hlsUrl(pending.hlsUrl) - .flvUrl(pending.flvUrl) + .hlsUrl(hlsUrl(pending.streamId, baseUrl)) + .flvUrl(flvUrl(pending.streamId, baseUrl)) .message(message) .build(); } @@ -1821,6 +1835,7 @@ public class HikNvrLiveStreamService { PendingStart pending, String status, String message) { + String baseUrl = browserBaseUrl(pending.publicBaseUrl, null); return HikNvrLiveStreamResponse.builder() .cameraCode(pending.target.getCameraCode()) .nvrIp(pending.target.getNvrInfo().getNvrIp()) @@ -1828,8 +1843,8 @@ public class HikNvrLiveStreamService { .streamType(pending.streamType) .streamId(pending.streamId) .status(status) - .hlsUrl(pending.hlsUrl) - .flvUrl(pending.flvUrl) + .hlsUrl(hlsUrl(pending.streamId, baseUrl)) + .flvUrl(flvUrl(pending.streamId, baseUrl)) .message(message) .build(); } @@ -1856,13 +1871,33 @@ public class HikNvrLiveStreamService { } /** 返回当前流的 HLS 浏览器地址。 */ - private String hlsUrl(String streamId) { - return httpBaseUrl + "/live/" + streamId + "/hls.m3u8"; + private String hlsUrl(String streamId, String baseUrl) { + return baseUrl + "/live/" + streamId + "/hls.m3u8"; } /** 返回当前流的 HTTP-FLV 浏览器地址。 */ - private String flvUrl(String streamId) { - return httpBaseUrl + "/live/" + streamId + ".live.flv"; + private String flvUrl(String streamId, String baseUrl) { + return baseUrl + "/live/" + streamId + ".live.flv"; + } + + /** + * 选取返回给浏览器的 ZLMediaKit 基础地址。 + * + *

内网/外网两个 IP 共用一套服务时,播放地址必须跟随本次请求的来源地址: + * 请求解析出的地址优先,其次是创建排队任务时记录的地址,都没有时回退固定配置。

+ * + * @param requestBaseUrl 当前请求解析出的地址,可为空 + * @param taskBaseUrl 创建排队任务时记录的地址,可为空 + * @return 已去除末尾斜杠的基础地址 + */ + private String browserBaseUrl(String requestBaseUrl, String taskBaseUrl) { + if (hasText(requestBaseUrl)) { + return trimTrailingSlash(requestBaseUrl.trim()); + } + if (hasText(taskBaseUrl)) { + return trimTrailingSlash(taskBaseUrl.trim()); + } + return httpBaseUrl; } /** 返回当前流保存原始 FFmpeg stderr 的日志文件。 */ @@ -1879,7 +1914,7 @@ public class HikNvrLiveStreamService { ZLM_PUSH_FAILED, ZLM_PUSH_FAILED + ":未找到FFmpeg:" + ffmpegPath); } - // RTMP、浏览器地址、API 地址和 secret 任一缺失都无法完成可靠就绪确认。 + // RTMP、回退播放地址、API 地址和 secret 任一缺失都无法完成可靠就绪确认。 if (!hasText(rtmpBaseUrl) || !hasText(httpBaseUrl) || !hasText(zlmApiBaseUrl) @@ -1978,10 +2013,6 @@ public class HikNvrLiveStreamService { private final String streamId; /** 持有 RTSP 会话的 FFmpeg 进程。 */ private final Process process; - /** HLS 浏览器地址。 */ - private final String hlsUrl; - /** HTTP-FLV 浏览器地址。 */ - private final String flvUrl; /** 连续无人观看的起始时间;0 表示当前有人观看或无法确认。 */ private volatile long idleSinceMillis; @@ -1994,9 +2025,7 @@ public class HikNvrLiveStreamService { Integer channel, int streamType, String streamId, - Process process, - String hlsUrl, - String flvUrl) { + Process process) { this.cameraCode = cameraCode; this.nvrIp = nvrIp; this.sourceHost = sourceHost; @@ -2005,8 +2034,6 @@ public class HikNvrLiveStreamService { this.streamType = streamType; this.streamId = streamId; this.process = process; - this.hlsUrl = hlsUrl; - this.flvUrl = flvUrl; } /** 判断 FFmpeg 拉流进程是否仍在运行。 */ @@ -2033,28 +2060,24 @@ public class HikNvrLiveStreamService { private final int streamType; /** ZLMediaKit 流标识。 */ private final String streamId; - /** HLS 浏览器地址。 */ - private final String hlsUrl; - /** HTTP-FLV 浏览器地址。 */ - private final String flvUrl; + /** 创建该任务时请求解析出的浏览器可达基础地址;为空时回退固定配置。 */ + private final String publicBaseUrl; /** 首个请求等待结果,排队请求通过状态接口读取相同结果。 */ private final CompletableFuture future = new CompletableFuture<>(); /** 停止或码流切换设置的取消标志。 */ private volatile boolean cancelled; - /** 保存起流目标和预先可返回给前端的流地址。 */ + /** 保存起流目标和本次请求的播放地址基础前缀。 */ private PendingStart( HikNvrCameraTarget target, int streamType, String streamId, - String hlsUrl, - String flvUrl) { + String publicBaseUrl) { this.target = target; this.streamType = streamType; this.streamId = streamId; - this.hlsUrl = hlsUrl; - this.flvUrl = flvUrl; + this.publicBaseUrl = publicBaseUrl; } }