From 139518e29ab65f2cdaf8a5eca4deb774b346262d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=AF=85?= <97163845@qq.com> Date: Tue, 13 Oct 2026 17:01:57 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=A3=E5=86=B3=E6=8A=93=E6=8B=8D=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/inspect/nvr/config/HikLibConfig.java | 24 +++ .../nvr/dahua/DahuaIvsVendorService.java | 21 +-- .../nvr/hik/service/HikIvsVendorService.java | 36 +--- .../hik/service/HikNvrLiveStreamService.java | 17 +- .../hik/service/HikNvrUiActionService.java | 29 ++- .../nvr/hikVision/utils/jna/HCNetSDK.java | 14 ++ .../nvr/service/CommonCameraService.java | 95 +++++++--- .../inspect/nvr/service/HikCameraService.java | 138 ++++++++++++-- .../inspect/nvr/service/HikLoginService.java | 86 ++++++++- .../inspect/nvr/service/HikLoginSession.java | 14 ++ src/main/resources/static/index.html | 176 +++++++++++++++--- src/main/resources/static/playback.html | 3 +- 12 files changed, 529 insertions(+), 124 deletions(-) diff --git a/src/main/java/com/inspect/nvr/config/HikLibConfig.java b/src/main/java/com/inspect/nvr/config/HikLibConfig.java index c2835817..4e246ba2 100644 --- a/src/main/java/com/inspect/nvr/config/HikLibConfig.java +++ b/src/main/java/com/inspect/nvr/config/HikLibConfig.java @@ -23,6 +23,10 @@ public class HikLibConfig { @Value("${hik.sdk.connect-timeout-millis:1000}") private int sdkConnectTimeoutMillis; + /** SDK 其他模块的接收超时,默认五秒,限制抓图和健康查询的设备响应等待。 */ + @Value("${hik.sdk.receive-timeout-millis:5000}") + private int sdkReceiveTimeoutMillis; + /** 海康 Linux64 SDK 6.1.11.5 随包提供的 OpenSSL 加密库文件名。 */ private static final String LINUX_CRYPTO_LIBRARY_NAME = "libcrypto.so.3"; @@ -105,6 +109,8 @@ public class HikLibConfig { //设置连接时间与重连时间 // 将 SDK 单次建连限制在可配置的短超时内,避免断网时占满 Tomcat 请求线程。 hcNetSDK.NET_DVR_SetConnectTime(Math.max(500, sdkConnectTimeoutMillis), 1); + // Java Future 超时不能停止原生网络等待,必须同时设置 SDK 接收超时。 + configureReceiveTimeout(); hcNetSDK.NET_DVR_SetReconnect(100000,true); //连接所有nvr @@ -113,6 +119,24 @@ public class HikLibConfig { return hcNetSDK; } + /** 为 SDK 其他模块设置有限接收等待;设置失败时阻止无保护的 SDK 进入业务流程。 */ + void configureReceiveTimeout() { + // 禁止零值恢复默认长等待,且单次接收不能超过抓图请求总预算的一半。 + if (sdkReceiveTimeoutMillis < 1_000 || sdkReceiveTimeoutMillis > 10_000) { + throw new IllegalArgumentException("海康 SDK 接收超时必须在 1000~10000 毫秒之间"); + } + HCNetSDK.NET_DVR_LOCAL_MODULE_RECV_TIMEOUT_CFG timeout = + new HCNetSDK.NET_DVR_LOCAL_MODULE_RECV_TIMEOUT_CFG(); + // 仅指定其他模块的时间,其余三个模块保留 SDK 默认值。 + timeout.dwElse = sdkReceiveTimeoutMillis; + timeout.write(); + // 类型 3 的结构体布局来自海康官方 SDK 本地参数接口定义。 + if (!hcNetSDK.NET_DVR_SetSDKLocalCfg(3, timeout.getPointer())) { + throw new IllegalStateException("海康 SDK 接收超时设置失败,错误码:" + + hcNetSDK.NET_DVR_GetLastError()); + } + } + /** * 动态库加载 diff --git a/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java b/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java index e3e30239..60a53d5a 100644 --- a/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java +++ b/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java @@ -66,6 +66,7 @@ import java.time.format.ResolverStyle; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Set; @@ -135,8 +136,15 @@ public class DahuaIvsVendorService implements IvsVendorAdapter { @Resource private NetSDKLib sdk; - /** 已完成的大华抓图任务。 */ - private final Map snapshotRecords = new ConcurrentHashMap<>(); + /** 按完成顺序保留最近 256 个大华任务,插入和淘汰原子执行。 */ + private final Map snapshotRecords = Collections.synchronizedMap( + new LinkedHashMap() { + /** 只删除最早完成的任务,确保刚创建的任务可以立即取图。 */ + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_SNAPSHOT_CACHE_SIZE; + } + }); /** 按 NVR 和通道维护手动录像自动停止任务。 */ private final Map activeRecords = new ConcurrentHashMap<>(); /** 按 NVR 和通道维护当前连续 PTZ 动作。 */ @@ -302,7 +310,6 @@ public class DahuaIvsVendorService implements IvsVendorAdapter { domainOfCameraCode(target.getCameraCode()), snapTime, image.clone())); - trimSnapshotCache(); return IvsSnapshotCommandResponse.builder() .resultCode(0) .taskID(taskId) @@ -1349,14 +1356,6 @@ public class DahuaIvsVendorService implements IvsVendorAdapter { || record.domainCode.equalsIgnoreCase(request.getDomainCode().trim())); } - /** 超出抓图缓存上限时删除一个最早迭代到的任务。 */ - private void trimSnapshotCache() { - while (snapshotRecords.size() > MAX_SNAPSHOT_CACHE_SIZE) { - String first = snapshotRecords.keySet().iterator().next(); - snapshotRecords.remove(first); - } - } - /** 从完整 cameraCode 提取 # 前设备部分。 */ private String devicePart(String cameraCode) { int hash = cameraCode.indexOf('#'); diff --git a/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java b/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java index 2f058fc2..06734547 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java @@ -48,12 +48,12 @@ import java.time.format.DateTimeParseException; import java.time.format.ResolverStyle; import java.util.ArrayList; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; /** * 海康厂商的 IVS 北向适配器。 @@ -130,8 +130,15 @@ public class HikIvsVendorService implements IvsVendorAdapter { @Resource private HikNvrUiActionService uiActionService; - /** 已完成的抓图任务缓存,任务列表和图片下载都从这里读取。 */ - private final Map snapshotRecords = new ConcurrentHashMap<>(); + /** 按完成顺序保留最近 256 个任务,插入和淘汰在同一把锁下完成。 */ + private final Map snapshotRecords = Collections.synchronizedMap( + new LinkedHashMap() { + /** 只淘汰最早完成的任务,避免新任务刚返回就被删除。 */ + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > MAX_SNAPSHOT_CACHE_SIZE; + } + }); /** * 返回海康厂商在 IVS 路由表中的统一编码。 @@ -293,8 +300,6 @@ public class HikIvsVendorService implements IvsVendorAdapter { snapshotRecords.put(taskId, new SnapshotRecord( taskId, request.getCameraCode(), request.getDomainCode(), snapTime, image == null ? new byte[0] : image.clone())); - // 超过缓存上限时删除一个迭代到的任务,避免长期运行导致内存持续增长。 - trimSnapshotCache(); // 按 IVS 4.1.12.1 结构把 resultCode 和 taskID 直接放在响应顶层。 return IvsSnapshotCommandResponse.builder() .resultCode(0) @@ -379,27 +384,6 @@ public class HikIvsVendorService implements IvsVendorAdapter { return cameraMatches && domainMatches; } - /** - * 将抓图缓存控制在固定大小,避免长时间运行的进程无限保留图片。 - */ - private void trimSnapshotCache() { - // 缓存未超过上限时不做任何删除,保证正常查询路径不产生额外开销。 - while (snapshotRecords.size() > MAX_SNAPSHOT_CACHE_SIZE) { - String firstKey = null; - // 选取一个当前可见的任务键,ConcurrentHashMap 不保证键的插入顺序。 - for (String key : snapshotRecords.keySet()) { - firstKey = key; - break; - } - // 并发清空缓存时直接结束清理,避免对空迭代器调用 next。 - if (firstKey == null) { - break; - } - // 删除成功或失败都重新检查大小,直到缓存回到上限以内。 - snapshotRecords.remove(firstKey); - } - } - /** * 调用海康回放查询服务,并将录像结果转换为 IVS 录像列表结构。 */ 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 4ee3e5e5..98901e3c 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrLiveStreamService.java @@ -1872,12 +1872,25 @@ public class HikNvrLiveStreamService { /** 返回当前流的 HLS 浏览器地址。 */ private String hlsUrl(String streamId, String baseUrl) { - return "/live/" + streamId + "/hls.m3u8"; + return absoluteMediaUrl(baseUrl, "/live/" + streamId + "/hls.m3u8"); } /** 返回当前流的 HTTP-FLV 浏览器地址。 */ private String flvUrl(String streamId, String baseUrl) { - return "/live/" + streamId + ".live.flv"; + return absoluteMediaUrl(baseUrl, "/live/" + streamId + ".live.flv"); + } + + /** + * 拼接浏览器可达的绝对媒体地址。 + * + *

页面会对"与本页面同源且以 /live/ 开头"的相对地址强制改写媒体端口(现场约定), + * 因此只要解析出了基础地址就必须返回绝对地址;仅在完全无基础地址时保留相对路径。

+ */ + private String absoluteMediaUrl(String baseUrl, String path) { + if (!hasText(baseUrl)) { + return path; + } + return trimTrailingSlash(baseUrl.trim()) + path; } /** diff --git a/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java b/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java index e6553006..12c19004 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java @@ -54,6 +54,7 @@ import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -628,6 +629,8 @@ public class HikNvrUiActionService { * @return JPEG 图片字节 */ public byte[] snapshot(HikNvrUiActionRequest request, String preferredCameraIp) { + // 直连、排队和 SDK 回退共用 20 秒预算,留出页面下载图片的时间。 + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(HikCameraService.CAPTURE_TIMEOUT_MILLIS); // 使用请求通道或默认通道,并在真正抓图前校验通道号。 // 页面通道缺失时只允许使用数据库 NVR 明确保存的 start_channel。 int channel = requestedChannel(request); @@ -647,8 +650,8 @@ public class HikNvrUiActionService { cameraInfo.setNvrIp(cameraIp); // 相机本机主码流 101 String template = "http://%s/ISAPI/Streaming/channels/101/picture"; - // 调用公共 Digest 抓图能力读取相机主码流图片。 - byte[] image = CommonCameraService.captureDigest(cameraInfo, 1, template, savePath); + // 直连也进入海康抓图服务的设备/通道锁,避免 HTTP 路径绕过并发保护。 + byte[] image = cameraService.captureDigestToPath(cameraInfo, 1, template, savePath); // 仅当响应是有效 JPEG 时才认为直连抓图成功。 if (image != null && image.length > 0 && isJpeg(image)) { log.info("[snapshot] Digest 抓图成功 channel={} cameraIp={} path={} size={}", @@ -668,14 +671,18 @@ public class HikNvrUiActionService { HikLoginSession session = loginSession(); // 计算海康 SDK 通道编号。 int sdkChannel = session.toSdkChannel(channel); - // 调用已有海康相机抓图服务获取 NVR 转发的图片。 - byte[] image = cameraService.capture(toNvrInfo(), sdkChannel); + // SDK 只使用直连之后的剩余时间,避免回退重新开始完整超时周期。 + long remainingMillis = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()); + byte[] image = cameraService.capture(toNvrInfo(), sdkChannel, remainingMillis); // 校验 SDK 返回的图片内容,避免把错误 XML 当成图片返回。 if (image != null && image.length > 0 && isJpeg(image)) { - // 确保目标目录存在并持久化图片文件。 - Files.createDirectories(savePath.getParent()); - // 将 SDK 图片写入前面生成的保存路径。 - Files.write(savePath, image); + // 图片留档失败不能把已经成功的 SDK 抓图变成设备失败。 + try { + Files.createDirectories(savePath.getParent()); + Files.write(savePath, image); + } catch (java.io.IOException saveFailure) { + log.warn("[snapshot] 图片留档失败 path={}:{}", savePath, saveFailure.getMessage()); + } log.info("[snapshot] SDK 抓图成功 channel={} sdkChannel={} path={} size={}", channel, sdkChannel, savePath, image.length); return image; @@ -889,8 +896,10 @@ public class HikNvrUiActionService { /** 根据日期和通道生成抓图文件保存路径。 */ private Path buildCapturePath(int channel) { String day = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); - String name = "ui_ch" + channel + "_" + LocalDateTime.now().format(CAPTURE_TIME) + ".jpg"; - Path dir = Paths.get("D:\\captures", day); + // 使用随机后缀隔离同毫秒请求,保存根目录统一复用公共服务的跨平台可写目录。 + String name = "ui_ch" + channel + "_" + LocalDateTime.now().format(CAPTURE_TIME) + + "_" + UUID.randomUUID() + ".jpg"; + Path dir = Paths.get(CommonCameraService.FILE_DIR, day); try { Files.createDirectories(dir); } catch (Exception ex) { diff --git a/src/main/java/com/inspect/nvr/hikVision/utils/jna/HCNetSDK.java b/src/main/java/com/inspect/nvr/hikVision/utils/jna/HCNetSDK.java index 27a24595..d866eb62 100644 --- a/src/main/java/com/inspect/nvr/hikVision/utils/jna/HCNetSDK.java +++ b/src/main/java/com/inspect/nvr/hikVision/utils/jna/HCNetSDK.java @@ -7809,6 +7809,20 @@ DVR实现巡航数据结构 public byte[] byRes1 = new byte[32]; } + /** SDK 各模块的接收等待时间,供 NET_DVR_SetSDKLocalCfg 类型 3 使用。 */ + public static class NET_DVR_LOCAL_MODULE_RECV_TIMEOUT_CFG extends HCStructure { + /** 预览模块接收超时,单位毫秒;0 使用 SDK 默认值。 */ + public int dwPreviewTime; + /** 报警模块接收超时,单位毫秒;0 使用 SDK 默认值。 */ + public int dwAlarmTime; + /** 回放模块接收超时,单位毫秒;0 使用 SDK 默认值。 */ + public int dwVodTime; + /** 其他模块接收超时,单位毫秒,用于限制设备命令的响应等待。 */ + public int dwElse; + /** 协议保留字段,必须全部置零。 */ + public byte[] byRes = new byte[512]; + } + public static class NET_DVR_LOCAL_GENERAL_CFG extends HCStructure { public byte byExceptionCbDirectly; //0-通过线程池异常回调,1-直接异常回调给上层 public byte byNotSplitRecordFile; //回放和预览中保存到本地录像文件不切片 0-默认切片,1-不切片 diff --git a/src/main/java/com/inspect/nvr/service/CommonCameraService.java b/src/main/java/com/inspect/nvr/service/CommonCameraService.java index a360b743..caaedc80 100644 --- a/src/main/java/com/inspect/nvr/service/CommonCameraService.java +++ b/src/main/java/com/inspect/nvr/service/CommonCameraService.java @@ -4,15 +4,17 @@ import com.inspect.nvr.domain.Infrared.NvrInfo; import lombok.extern.slf4j.Slf4j; import org.apache.hc.client5.http.auth.AuthScope; import org.apache.hc.client5.http.auth.UsernamePasswordCredentials; +import org.apache.hc.client5.http.config.RequestConfig; import org.apache.hc.client5.http.classic.methods.HttpGet; import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; -import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.util.Timeout; import org.springframework.core.io.ClassPathResource; import org.springframework.util.StreamUtils; import java.io.FileOutputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.file.FileAlreadyExistsException; @@ -25,7 +27,9 @@ import java.time.format.DateTimeFormatter; import java.util.concurrent.Callable; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; +import java.util.UUID; /** * 通用摄像头服务类 @@ -40,6 +44,12 @@ public class CommonCameraService { static final int DEFAULT_MAX_RETRIES = 10; /** 单台 NVR 允许同时执行的最大 SDK 任务数。 */ static final int MAX_CONCURRENT_PER_NVR = 4; + /** 设备和通道排队的总等待上限,单位为毫秒。 */ + static final long CAPTURE_QUEUE_TIMEOUT_MILLIS = 2_000L; + /** HTTP 抓图读取预算,单位为毫秒;每次读阻塞另受响应超时约束。 */ + static final long CAPTURE_HTTP_TIMEOUT_MILLIS = 10_000L; + /** JPEG 响应允许的最大字节数,与海康 SDK 缓冲区容量保持一致。 */ + static final int MAX_CAPTURE_BYTES = 10 * 1024 * 1024; /** 按 NVR IP 维护的信号量,用于限制单台设备的总并发数。 */ final ConcurrentHashMap nvrSemaphoreMap = new ConcurrentHashMap<>(); /** 按 NVR IP 和通道维护的独占锁,确保同一通道的 SDK 操作串行执行。 */ @@ -61,27 +71,38 @@ public class CommonCameraService { // 获取设备级信号量和通道级独占锁,建立“设备限流+通道串行”的两级保护。 Semaphore nvrSemaphore = getOrCreateSemaphore(ip); ReentrantLock channelLock = getOrCreateLock(ip, channel); - // 先获取 NVR 的全局并发许可,避免超出设备 SDK 的并发承受能力。 - nvrSemaphore.acquireUninterruptibly(); + // 用单一截止时间限制排队,避免超时请求永久占据 Web 或工作线程。 + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(CAPTURE_QUEUE_TIMEOUT_MILLIS); try { - // 再获取通道独占锁,防止同一通道同时抓图或测温导致 SDK 状态冲突。 - channelLock.lock(); - try { - // 在两级并发约束生效期间执行真实的厂商 SDK 调用。 - return task.call(); - } catch (Exception e) { - // 将各厂商 SDK 异常统一记录并转换为 null,保持原有服务返回约定。 - log.info("任务执行异常:", e); + // 先等通道锁,避免同通道排队耗尽 NVR 许可而阻塞其他通道。 + if (!channelLock.tryLock(CAPTURE_QUEUE_TIMEOUT_MILLIS, TimeUnit.MILLISECONDS)) { + log.warn("设备通道任务排队超时,ip={},channel={}", ip, channel); return null; - } finally { - // 先释放通道锁,再由外层释放 NVR 许可,避免其他任务拿到许可后仍被旧通道锁阻塞。 - if (channelLock.isHeldByCurrentThread()) { - channelLock.unlock(); + } + try { + // 获取设备许可只使用剩余排队时间,等待失败后不继续执行设备操作。 + if (!nvrSemaphore.tryAcquire(Math.max(0L, deadline - System.nanoTime()), TimeUnit.NANOSECONDS)) { + log.warn("设备任务排队超时,ip={},channel={}", ip, channel); + return null; } + try { + // 两级保护同时生效时才调用设备,并在完成或异常后归还许可。 + return task.call(); + } finally { + nvrSemaphore.release(); + } + } finally { + // 所有退出路径均释放已获得的通道锁。 + channelLock.unlock(); } - } finally { - // 无论 SDK 调用是否成功都释放 NVR 许可,防止异常路径造成永久限流。 - nvrSemaphore.release(); + } catch (InterruptedException interrupted) { + // 请求已取消时停止排队并保留中断标记,不再发出设备命令。 + Thread.currentThread().interrupt(); + return null; + } catch (Exception failure) { + // 保持厂商服务失败返回 null 的约定,由入口转换成业务错误。 + log.warn("设备任务执行失败,ip={},channel={}:{}", ip, channel, failure.getMessage()); + return null; } } @@ -117,27 +138,52 @@ public class CommonCameraService { credentialsProvider.setCredentials(new AuthScope(null, -1), new UsernamePasswordCredentials(username, password.toCharArray())); + // 显式限制连接和借用连接等待,主码流 JPEG 响应允许等待五秒。 + RequestConfig requestConfig = RequestConfig.custom() + .setConnectTimeout(Timeout.ofMilliseconds(1_000)) + .setConnectionRequestTimeout(Timeout.ofMilliseconds(1_000)) + .setResponseTimeout(Timeout.ofMilliseconds(5_000)) + .build(); + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(CAPTURE_HTTP_TIMEOUT_MILLIS); + // 关闭自动重试,确保 HTTP 失败后及时进入上层 SDK 回退。 try (CloseableHttpClient httpClient = HttpClients.custom() .setDefaultCredentialsProvider(credentialsProvider) + .setDefaultRequestConfig(requestConfig) + .disableAutomaticRetries() .build()) { HttpGet httpGet = new HttpGet(url); return httpClient.execute(httpGet, httpResponse -> { int statusCode = httpResponse.getCode(); if (statusCode == 200) { try (InputStream in = httpResponse.getEntity().getContent(); - FileOutputStream out = new FileOutputStream(fullPath.toString())) { - // 8KB 是一个经过大量实践验证的甜点(sweet spot):既能显著减少系统调用次数,又不会占用过多内存。 + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + // 在当前请求自己的内存中读取图片,不从可能被并发覆盖的文件回读。 byte[] buffer = new byte[8192]; int len; + // 限制慢速持续响应的总读取时间和大小,防止无限流耗尽内存。 while ((len = in.read(buffer)) != -1) { + if (System.nanoTime() >= deadline || out.size() + len > MAX_CAPTURE_BYTES) { + // 中止连接,防止关闭响应流时继续排空超时或超大的响应体。 + httpGet.cancel(); + throw new IOException("抓图响应超时或超过最大图片大小"); + } out.write(buffer, 0, len); } + byte[] image = out.toByteArray(); + // 落盘只作留档,写文件失败不丢弃已经成功取得的设备图片。 + try { + Files.createDirectories(fullPath.getParent()); + Files.write(fullPath, image); + } catch (IOException saveFailure) { + log.warn("抓图图片留档失败,path={}:{}", fullPath, saveFailure.getMessage()); + } log.info("Digest认证抓图成功:图片地址{}", fullPath); - return Files.readAllBytes(fullPath); + return image; } } else { - String errorBody = EntityUtils.toString(httpResponse.getEntity()); - throw new RuntimeException("状态码: " + statusCode + ",响应体: " + errorBody); + // 非成功响应立即中止连接,不无限读取设备返回的错误正文。 + httpGet.cancel(); + throw new IOException("抓图 HTTP 状态码:" + statusCode); } }); } catch (Exception e) { @@ -201,7 +247,8 @@ public class CommonCameraService { */ Path getFullPath(String flag, String ip, int channel) { String timeStr = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss_SSS")); - String fileName = flag + "_" + ip + "_" + channel + "_" + timeStr + ".jpg"; + // 随机任务后缀保证同设备、同通道、同毫秒的并发请求也不会覆盖文件。 + String fileName = flag + "_" + ip + "_" + channel + "_" + timeStr + "_" + UUID.randomUUID() + ".jpg"; Path fullPath = Paths.get(FILE_DIR) .resolve(LocalDate.now().toString()) .resolve(fileName) diff --git a/src/main/java/com/inspect/nvr/service/HikCameraService.java b/src/main/java/com/inspect/nvr/service/HikCameraService.java index 585c95c3..eafa3446 100644 --- a/src/main/java/com/inspect/nvr/service/HikCameraService.java +++ b/src/main/java/com/inspect/nvr/service/HikCameraService.java @@ -13,11 +13,13 @@ import javax.annotation.Resource; import java.io.File; import java.io.FileOutputStream; import java.io.InputStream; +import java.io.IOException; import java.nio.ByteBuffer; import java.nio.file.Files; import java.nio.file.Path; import java.text.SimpleDateFormat; import java.util.Date; +import java.util.Arrays; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicLong; @@ -25,7 +27,7 @@ import java.util.concurrent.atomic.AtomicLong; * 海康设备SDK服务 * 同一个IP,同一个通道,串行 * 同一个IP,不同通道,最多4个线程并发 - * 不同IP,并发抓图,不限制 + * 不同 IP 分别限流,原生抓图另受有限工作线程池保护 */ @Slf4j @Service @@ -46,6 +48,23 @@ public class HikCameraService extends CommonCameraService { /** 海康本地 SDK 映射,用于抓图、测温和错误码查询。 */ @Resource private HCNetSDK hcNetSDK; + /** SDK 抓图的默认请求预算,单位为毫秒,低于页面 25 秒等待上限。 */ + public static final long CAPTURE_TIMEOUT_MILLIS = 20_000L; + /** SDK 最多调用两次,永久性能力错误不重试。 */ + private static final int CAPTURE_MAX_ATTEMPTS = 2; + /** 短暂设备忙时的重试间隔,单位为毫秒。 */ + private static final long CAPTURE_RETRY_DELAY_MILLIS = 200L; + /** 全局 SDK 工作线程数,允许四台 NVR 各执行四个并发任务。 */ + private static final int CAPTURE_WORKER_COUNT = 16; + /** 隔离可能无法响应中断的原生 SDK,工作线程和等待队列均有容量上限。 */ + // ponytail: 全局最多 16 个 SDK 工作线程和 16 个排队任务,更多 NVR 同时满载时再调整线程数。 + private static final ThreadPoolExecutor CAPTURE_EXECUTOR = new ThreadPoolExecutor( + CAPTURE_WORKER_COUNT, CAPTURE_WORKER_COUNT, 0L, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(16), runnable -> { + Thread thread = new Thread(runnable, "hik-capture"); + thread.setDaemon(true); + return thread; + }); /** * 在 NVR 和通道两级并发控制下执行海康抓图。 @@ -55,8 +74,46 @@ public class HikCameraService extends CommonCameraService { * @return JPEG 图片字节;抓图失败时按底层约定返回空数组或 null */ public byte[] capture(NvrInfo nvrInfo, int channel) { - // 复用父类的公共并发控制,避免海康服务再维护一套重复锁状态。 - return withConcurrencyControl(nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel)); + // 独立调用使用默认预算,平台入口可以传入直连失败后的剩余时间。 + return capture(nvrInfo, channel, CAPTURE_TIMEOUT_MILLIS); + } + + /** + * 在指定预算内获取 SDK 图片;超时后停止等待并取消排队或后续重试。 + * + * @param nvrInfo 数据库中的 NVR 连接信息 + * @param channel SDK 通道号 + * @param timeoutMillis 本次请求剩余预算,单位为毫秒 + * @return JPEG 图片;超时、容量不足或设备失败时返回 null + */ + public byte[] capture(NvrInfo nvrInfo, int channel, long timeoutMillis) { + // 已无预算或请求已被取消时不再提交设备调用。 + if (timeoutMillis <= 0 || Thread.currentThread().isInterrupted()) { + return null; + } + Future pending = null; + try { + // 有限工作线程隔离原生调用,继续复用现有 NVR 和通道锁。 + pending = CAPTURE_EXECUTOR.submit(() -> withConcurrencyControl( + nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel))); + return pending.get(timeoutMillis, TimeUnit.MILLISECONDS); + } catch (InterruptedException interrupted) { + // 上层取消请求时保留中断状态,并在 finally 中取消设备任务。 + Thread.currentThread().interrupt(); + return null; + } catch (TimeoutException | RejectedExecutionException | ExecutionException failure) { + // 预算耗尽或工作池繁忙时及时返回,避免持续占据 Web 请求线程。 + log.warn("[海康]抓图等待失败,IP={},Channel={}:{}", + nvrInfo.getNvrIp(), channel, failure.toString()); + return null; + } finally { + // Future 已完成时无需取消;原生 SDK 不支持中断时最多占用固定数量的后台线程。 + if (pending != null && !pending.isDone()) { + pending.cancel(true); + // 立即移除已取消的排队请求,不让失效任务继续占用队列容量。 + CAPTURE_EXECUTOR.remove((Runnable) pending); + } + } } /** @@ -67,8 +124,16 @@ public class HikCameraService extends CommonCameraService { * @return Digest 接口返回的 JPEG 图片字节,失败时返回 null */ public byte[] captureDigest(NvrInfo nvrInfo, int channel) { - // 复用父类公共限流后调用 Digest 抓图,保证同通道的 SDK 和 HTTP 抓图不并发冲突。 - return withConcurrencyControl(nvrInfo, channel, () -> captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, getFullPath("hk_digest", nvrInfo.getNvrIp(), channel))); + // 将旧入口转交给同一限流方法,避免平台直连和普通 Digest 调用维护不同的锁。 + return captureDigestToPath(nvrInfo, channel, DIGEST_URL_TEMPLATE, + getFullPath("hk_digest", nvrInfo.getNvrIp(), channel)); + } + + /** 在设备和通道限流下执行指定 HTTP 抓图接口,并留档到请求独占路径。 */ + public byte[] captureDigestToPath(NvrInfo nvrInfo, int channel, String template, Path fullPath) { + // 平台直连也必须经过公共锁,避免同相机同通道同时抓图。 + return withConcurrencyControl(nvrInfo, channel, + () -> CommonCameraService.captureDigest(nvrInfo, channel, template, fullPath)); } /** @@ -84,7 +149,7 @@ public class HikCameraService extends CommonCameraService { } /** - * 按配置重试海康摄像机抓图,SDK 连续失败后回退到 Digest 抓图。 + * 最多尝试两次海康 SDK 抓图,不支持抓图时直接回退到 Digest。 * * @param nvrInfo 海康 NVR 连接和认证信息 * @param channel 抓图通道号 @@ -94,31 +159,50 @@ public class HikCameraService extends CommonCameraService { Path fullPath = getFullPath("hk", nvrInfo.getNvrIp(), channel); ensureDirectoryExists(fullPath.getParent()); int retryCount = 0; - int maxRetries = DEFAULT_MAX_RETRIES; + int maxRetries = CAPTURE_MAX_ATTEMPTS; while (retryCount < maxRetries) { + // 超时或取消后不得继续重试,也不得再发出 HTTP 兜底请求。 + if (Thread.currentThread().isInterrupted()) { + return null; + } try { -// byte[] imageBytes = captureJPEGPicture(nvrInfo, channel, fullPath); byte[] imageBytes = captureJPEGPictureNew(nvrInfo, channel, fullPath); if (imageBytes == null) { int errorCode = hcNetSDK.NET_DVR_GetLastError(); + // 23/29 属于设备抓图能力或参数不支持,重复同一请求无法恢复。 + if (errorCode == 23 || errorCode == 29) { + log.warn("[海康]SDK 不支持当前抓图请求,错误码={},转 HTTP 兜底", errorCode); + break; + } throw new RuntimeException("SDK抓图失败,错误码" + errorCode); } log.info("[海康]抓图成功(第{}次):{}", retryCount + 1, fullPath); return imageBytes; } catch (Exception e) { log.error("[海康]抓图异常(第{}次):{}", retryCount + 1, e.getMessage()); - try { - Thread.sleep(2000); - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - break; + // 只有还会再次尝试时短暂等待,最后一次失败不额外休眠。 + if (retryCount + 1 < maxRetries) { + try { + Thread.sleep(CAPTURE_RETRY_DELAY_MILLIS); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return null; + } } } finally { retryCount++; } } + // 原生调用结束时可能已经超过请求预算,取消后不能继续产生设备负载。 + if (Thread.currentThread().isInterrupted()) { + return null; + } // 当SDK抓图失败时,尝试使用Digest认证抓图 - byte[] imageBytes = captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, fullPath); + // ISAPI 使用页面逻辑通道而非 33 起的 SDK 数字通道,同时采用数据库 HTTP 端口。 + int httpChannel = hikLoginService.loginSession(nvrInfo).toRtspChannel(channel); + String httpPort = nvrInfo.getHttpPort() == null ? "" : ":" + nvrInfo.getHttpPort(); + String template = "http://%s" + httpPort + "/ISAPI/Streaming/channels/%d02/picture"; + byte[] imageBytes = captureDigest(nvrInfo, httpChannel, template, fullPath); if (imageBytes == null) { // 当所有抓图方式均失败时,记录失败图片 writeCaptureFailedImage(fullPath); @@ -180,24 +264,40 @@ public class HikCameraService extends CommonCameraService { * @throws Exception 登录、本地文件或 SDK 调用异常 */ private byte[] captureJPEGPictureNew(NvrInfo nvrInfo, int channel, Path fullPath) throws Exception { - int userId = hikLoginService.login(nvrInfo); + // 保留会话对象身份,故障时只能失效本次调用使用的旧句柄。 + HikLoginSession session = hikLoginService.loginSession(nvrInfo); + int userId = session.getUserId(); HCNetSDK.NET_DVR_JPEGPARA jpegpara = new HCNetSDK.NET_DVR_JPEGPARA(); jpegpara.wPicSize = 0xff; jpegpara.wPicQuality = 0; jpegpara.write(); - HCNetSDK.BYTE_ARRAY byteArray = new HCNetSDK.BYTE_ARRAY(10 * 1024 * 1024); + HCNetSDK.BYTE_ARRAY byteArray = new HCNetSDK.BYTE_ARRAY(MAX_CAPTURE_BYTES); IntByReference ret = new IntByReference(0); log.info("[海康]开始抓图,UserID={},IP={},Channel={}", userId, nvrInfo.getNvrIp(), channel); boolean isCaptured = hcNetSDK.NET_DVR_CaptureJPEGPicture_NEW(userId, channel, jpegpara, byteArray.getPointer(), byteArray.size(), ret); if (isCaptured) { + // SDK 成功标记仍需配合有效长度,防止空数据或越界长度进入图片缓存。 + int pictureSize = ret.getValue(); + if (pictureSize <= 0 || pictureSize > byteArray.byValue.length) { + throw new IOException("SDK 返回的图片长度无效:" + pictureSize); + } byteArray.read(); - byte[] imageBytes = byteArray.byValue; - // 图片写入本地 + // 只复制实际图片,禁止将 10MB 缓冲区尾部空白作为图片返回。 + byte[] imageBytes = Arrays.copyOf(byteArray.byValue, pictureSize); + // 留档失败只记录本地存储错误,仍返回成功抓取的设备图片。 try (FileOutputStream fos = new FileOutputStream(fullPath.toString())) { - fos.write(imageBytes, 0, ret.getValue()); + fos.write(imageBytes); + } catch (IOException saveFailure) { + log.warn("[海康]图片留档失败,path={}:{}", fullPath, saveFailure.getMessage()); } return imageBytes; } + // 用户不存在表示句柄已经不可用,下一次重试必须重新登录,不能重复使用缓存。 + int errorCode = hcNetSDK.NET_DVR_GetLastError(); + if (errorCode == HCNetSDK.NET_DVR_USERNOTEXIST) { + hikLoginService.invalidateSession(nvrInfo, session); + throw new IOException("SDK抓图句柄失效,错误码:" + errorCode); + } return null; } diff --git a/src/main/java/com/inspect/nvr/service/HikLoginService.java b/src/main/java/com/inspect/nvr/service/HikLoginService.java index 72bf4c16..1a2ef9b8 100644 --- a/src/main/java/com/inspect/nvr/service/HikLoginService.java +++ b/src/main/java/com/inspect/nvr/service/HikLoginService.java @@ -29,6 +29,9 @@ public class HikLoginService { /** Redis 中记录 SDK 登出失败事件的有序集合键。 */ private static final String ERROR_LOGOUT_KEY = "hik:error"; + /** 连续两次健康探测失败才重建会话,避免一次网络抖动中断在途操作。 */ + private static final int MAX_HEALTH_FAILURES = 2; + /** 登出失败时记录原始 SDK 错误,Redis 不可用不能阻断会话释放。 */ @Resource private RedisService redisService; @@ -40,6 +43,9 @@ public class HikLoginService { /** 保存已登录 NVR 的服务端连接参数,供健康检查失败后自动重登。 */ private final Map loginTargets = new ConcurrentHashMap<>(); + /** SDK 暂时拒绝登出的旧句柄,保留到后续健康检查重试,避免只删缓存而遗留连接。 */ + private final Map pendingLogouts = new ConcurrentHashMap<>(); + /** * 按 NVR IP 缓存登录句柄;同一个 key 的加载由 Caffeine 原子串行, * 不同 NVR 的首次登录可以并行。 @@ -109,23 +115,42 @@ public class HikLoginService { return loginSession(nvrInfo); } - /** 定时用轻量工作状态接口检查缓存句柄,失败时自动注销并重登。 */ + /** 定时释放待注销句柄,并在连续两次健康检查失败后重建对应的旧会话。 */ @Scheduled(fixedDelayString = "${hik.login.health-check-interval-millis:60000}") public void healthCheckSessions() { + // 上轮设备忙或断网导致的登出失败必须继续追踪,不能永久丢弃旧句柄。 + retryPendingLogouts(); // 遍历已知登录目标而非仅遍历会话缓存,断网重登失败后仍会在下一轮继续恢复。 for (Map.Entry entry : new ArrayList<>(loginTargets.entrySet())) { String ip = entry.getKey(); NvrInfo target = entry.getValue(); HikLoginSession session = sessionCache.getIfPresent(ip); - // 当前存在且健康的句柄继续复用,不制造额外登录连接。 - if (session != null && isSessionHealthy(ip, session)) { - continue; + // 只统计当前会话的连续失败,旧检查不能影响并发业务已经建立的新会话。 + if (session != null) { + boolean healthy = isSessionHealthy(ip, session); + // 探测期间缓存已经替换时,本轮检查立即作废,不累加新会话的失败次数。 + if (sessionCache.asMap().get(ip) != session) { + continue; + } + // 成功后清零,两个被成功探测隔开的失败不能触发重登。 + if (healthy) { + session.resetHealthFailures(); + continue; + } + // 首次失败仍保留句柄,让网络抖动后下一轮检查有机会恢复。 + if (session.recordHealthFailure() < MAX_HEALTH_FAILURES) { + log.warn("[海康]会话首次健康检查失败,保留句柄等待复查,ip: {},userID: {}", + ip, session.getUserId()); + continue; + } + // 条件移除必须确认仍是刚才检查的对象,防止注销并发重登后的新句柄。 + if (!invalidateSession(target, session)) { + continue; + } } log.warn("[海康]登录会话健康检查失败,准备自动重登,ip: {},userID: {}", ip, session == null ? null : session.getUserId()); - // 先释放缓存和设备端旧句柄,确保后续登录不会再次命中失效对象。 - logout(ip); // 没有可用数据库连接参数时只能完成失效,等待下一次业务请求重新提供参数。 if (target == null) { continue; @@ -142,6 +167,45 @@ public class HikLoginService { } } + /** + * 仅失效指定的旧会话,供健康检查和 SDK 明确报告句柄无效时使用。 + * + * @param nvrInfo 当前 NVR 的连接参数,恢复目标继续保留 + * @param expectedSession 发生故障的会话对象 + * @return 旧会话仍在缓存且由本次调用成功移除时返回 true + */ + public boolean invalidateSession(NvrInfo nvrInfo, HikLoginSession expectedSession) { + // 缺少目标或旧会话时不能安全选择要注销的句柄。 + if (nvrInfo == null || !hasText(nvrInfo.getNvrIp()) || expectedSession == null) { + return false; + } + String ip = nvrInfo.getNvrIp().trim(); + // 原子比较对象身份并移除,较晚到达的旧请求不得删除新会话。 + if (!sessionCache.asMap().remove(ip, expectedSession)) { + return false; + } + // 保留登录目标供下一次请求或健康检查自动恢复,仅释放旧对象的 SDK 句柄。 + doLogout(ip, expectedSession.getUserId()); + return true; + } + + /** 重试之前未释放的 SDK 句柄,当前缓存正在使用的句柄不得被旧清理任务注销。 */ + private void retryPendingLogouts() { + // 使用快照迭代,登出结果可以安全更新待清理集合。 + for (Map.Entry pending : new ArrayList<>(pendingLogouts.entrySet())) { + int userId = pending.getKey(); + // SDK 可能复用用户编号,新会话已使用同一编号时放弃旧清理记录。 + boolean reused = sessionCache.asMap().values().stream() + .anyMatch(session -> session.getUserId() == userId); + if (reused) { + pendingLogouts.remove(userId, pending.getValue()); + continue; + } + // 设备忙或网络异常解除后补做登出,失败时记录仍保留到下一轮。 + doLogout(pending.getValue(), userId); + } + } + /** * 对指定缓存会话调用海康轻量工作状态接口。 * @@ -259,6 +323,8 @@ public class HikLoginService { // 调用海康 SDK 断开设备端登录连接。 logoutSucceeded = hcNetSDK.NET_DVR_Logout(userId); } catch (RuntimeException sdkFailure) { + // 原生异常时也保留待清理句柄,下一轮继续尝试释放。 + pendingLogouts.put(userId, ip); // 原生注销异常不能阻断缓存失效或后续重新登录。 log.error("[海康]登出调用异常,ip: {},userID: {},原因: {}", ip, userId, sdkFailure.getMessage()); @@ -266,10 +332,18 @@ public class HikLoginService { } // SDK 确认成功时记录完整会话生命周期。 if (logoutSucceeded) { + pendingLogouts.remove(userId); log.info("[海康]登出成功,ip: {},userID: {}", ip, userId); return; } int errorCode = hcNetSDK.NET_DVR_GetLastError(); + // SDK 明确表示用户不存在时已无资源可释放,停止重复注销。 + if (errorCode == HCNetSDK.NET_DVR_USERNOTEXIST) { + pendingLogouts.remove(userId); + return; + } + // 特别是用户正在操作(错误 74)时,要等在途 SDK 调用结束后再次释放。 + pendingLogouts.put(userId, ip); log.error("[海康]登出失败,ip: {},userID: {},错误码: {}", ip, userId, errorCode); // 保留设备端登出失败证据,但 Redis 故障不影响当前调用返回。 diff --git a/src/main/java/com/inspect/nvr/service/HikLoginSession.java b/src/main/java/com/inspect/nvr/service/HikLoginSession.java index b32387a7..3d137256 100644 --- a/src/main/java/com/inspect/nvr/service/HikLoginSession.java +++ b/src/main/java/com/inspect/nvr/service/HikLoginSession.java @@ -1,5 +1,7 @@ package com.inspect.nvr.service; +import java.util.concurrent.atomic.AtomicInteger; + /** * 海康登录会话及设备通道布局。 */ @@ -10,6 +12,8 @@ public final class HikLoginSession { private final int analogStartChannel; private final int digitalChannelCount; private final int digitalStartChannel; + /** 当前会话连续健康检查失败次数,新会话独立计数,避免旧失败影响重登结果。 */ + private final AtomicInteger healthFailures = new AtomicInteger(); /** 保存海康登录句柄及模拟、数字通道布局信息。 */ public HikLoginSession( @@ -30,6 +34,16 @@ public final class HikLoginSession { return userId; } + /** 当前会话探测失败时递增计数,供登录服务判断是否需要重登。 */ + int recordHealthFailure() { + return healthFailures.incrementAndGet(); + } + + /** 一次健康探测成功即可结束连续失败序列。 */ + void resetHealthFailures() { + healthFailures.set(0); + } + /** * 将调用方传入的通道号转换为海康SDK通道号。 * diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index f9989bb8..04d5c21b 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -585,8 +585,9 @@ - - + + + + +