Browse Source

fix: 实时预览flvUrl/hlsUrl按请求来源IP生成,支持内外网双IP访问

master
wangguangyuan 2 days ago
parent
commit
fa1c32894a
3 changed files with 99 additions and 57 deletions
  1. +7
    -1
      .env.example
  2. +17
    -4
      src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java
  3. +75
    -52
      src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java

+ 7
- 1
.env.example View File

@ -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


+ 17
- 4
src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java View File

@ -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<HikNvrLiveStreamResponse> startLive(@RequestBody HikNvrUiActionRequest request) {
public ResponseEntity<HikNvrLiveStreamResponse> 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<HikNvrLiveStreamResponse> 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)));
}
/**


+ 75
- 52
src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java View File

@ -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);
// 启动前校验 FFmpegZLM 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 基础地址
*
* <p>内网/外网两个 IP 共用一套服务时播放地址必须跟随本次请求的来源地址
* 请求解析出的地址优先其次是创建排队任务时记录的地址都没有时回退固定配置</p>
*
* @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<HikNvrLiveStreamResponse> 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;
}
}


Loading…
Cancel
Save