diff --git a/pom.xml b/pom.xml
index 64ff9ca..f05eb89 100644
--- a/pom.xml
+++ b/pom.xml
@@ -24,12 +24,6 @@
spring-boot-starter-web
-
- org.springframework.boot
- spring-boot-starter-test
- test
-
-
org.springframework.boot
spring-boot-starter-test
@@ -40,11 +34,6 @@
lombok
1.18.24
-
- org.apache.commons
- commons-lang3
- 3.12.0
-
com.squareup.retrofit2
retrofit
@@ -142,11 +131,6 @@
commons-imaging
1.0-alpha3
-
- com.google.code.gson
- gson
- 2.10.1
-
cn.hutool
hutool-all
@@ -177,16 +161,6 @@
commons-net
3.9.0
-
- commons-net
- commons-net
- 3.9.0
-
-
- commons-io
- commons-io
- 2.11.0
-
commons-fileupload
commons-fileupload
@@ -197,16 +171,12 @@
commons-compress
1.19
-
-
-
org.openpnp
opencv
4.5.5-1
-
org.apache.commons
@@ -232,7 +202,18 @@
commons-io
2.11.0
-
+
+
+ com.github.ben-manes.caffeine
+ caffeine
+ 2.8.8
+
+
+
+ org.apache.httpcomponents.client5
+ httpclient5
+ 5.3.1
+
diff --git a/src/main/java/com/inspect/nvr/service/BaseCaptureService.java b/src/main/java/com/inspect/nvr/service/BaseCaptureService.java
new file mode 100644
index 0000000..31f4870
--- /dev/null
+++ b/src/main/java/com/inspect/nvr/service/BaseCaptureService.java
@@ -0,0 +1,171 @@
+package com.inspect.nvr.service;
+
+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.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.springframework.core.io.ClassPathResource;
+import org.springframework.util.StreamUtils;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 抓图基础类
+ */
+@Slf4j
+public class BaseCaptureService {
+ // 兜底图路径
+ static final String IMAGE_CAPTURE_FAILED = "images\\imageCaptureFailed.jpg";
+ // 抓图保存目录
+ static final String FILE_DIR = determineCaptureDirectory();
+ // 抓图失败重试次数
+ static final int DEFAULT_MAX_RETRIES = 20;
+ // 每个NVR最多4个并发抓图任务
+ static final int MAX_CONCURRENT_PER_NVR = 4;
+ // 每个NVR对应一个信号量,控制并发数
+ final ConcurrentHashMap nvrSemaphoreMap = new ConcurrentHashMap<>();
+ // 每个(ip_chanel)对应一个锁,确保同通道串行
+ final ConcurrentHashMap channelLockMap = new ConcurrentHashMap<>();
+
+ /**
+ * 抓图失败时,写入兜底图
+ * 可考虑返回byte[]
+ */
+ static void WriteCaptureFailedImage(Path fullPath) {
+ ClassPathResource imgFile = new ClassPathResource(IMAGE_CAPTURE_FAILED);
+ try (InputStream inputStream = imgFile.getInputStream();
+ FileOutputStream out = new FileOutputStream(fullPath.toFile())) {
+ byte[] bytes = StreamUtils.copyToByteArray(inputStream);
+ out.write(bytes);
+ } catch (IOException e) {
+ log.error("兜底图写入失败: {}", e.getMessage());
+ }
+ }
+
+ /**
+ * Digest认证抓图
+ *
+ * @param nvrInfo 设备相关信息
+ * @param urlTemplate 抓图URL模板
+ * @param fullPath 图片保存全路径
+ */
+ public static byte[] captureDigest(NvrInfo nvrInfo, int channel, String urlTemplate, Path fullPath) {
+ String ip = nvrInfo.getNvrIp();
+ String username = nvrInfo.getAccount();
+ String password = nvrInfo.getPassword();
+ String url = String.format(urlTemplate, ip, channel);
+
+ BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
+ credentialsProvider.setCredentials(new AuthScope(null, -1),
+ new UsernamePasswordCredentials(username, password.toCharArray()));
+
+ try (CloseableHttpClient httpClient = HttpClients.custom()
+ .setDefaultCredentialsProvider(credentialsProvider)
+ .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):既能显著减少系统调用次数,又不会占用过多内存。
+ byte[] buffer = new byte[8192];
+ int len;
+ while ((len = in.read(buffer)) != -1) {
+ out.write(buffer, 0, len);
+ }
+ log.info("Digest认证抓图成功:图片地址{}", fullPath.toString());
+ return Files.readAllBytes(fullPath);
+ }
+ } else {
+ String errorBody = EntityUtils.toString(httpResponse.getEntity());
+ throw new RuntimeException("状态码: " + statusCode + ",响应体: " + errorBody);
+ }
+ });
+ } catch (Exception e) {
+ log.error("Digest认证抓图失败:{}", e.getMessage());
+ }
+ return null;
+ }
+
+ /**
+ * 创建目录(如果不存在)
+ */
+ void ensureDirectoryExists(Path dir) {
+ if (!Files.exists(dir)) {
+ try {
+ Files.createDirectories(dir);
+ } catch (IOException e) {
+ throw new RuntimeException("无法创建目录: " + dir, e);
+ }
+ }
+ }
+
+ Semaphore getOrCreateSemaphore(String ip) {
+ // fair=true 避免饥饿
+ return nvrSemaphoreMap.computeIfAbsent(ip, k -> new Semaphore(MAX_CONCURRENT_PER_NVR, true));
+ }
+
+ ReentrantLock getOrCreateLock(String ip, int channel) {
+ String key = ip + "_" + channel;
+ return channelLockMap.computeIfAbsent(key, k -> new ReentrantLock());
+ }
+
+ /**
+ * 简单校验是否为有效 JPEG(检查文件头)
+ */
+ boolean isValidJPEG(Path file) {
+ try {
+ byte[] header = Files.readAllBytes(file);
+ return header.length >= 2 &&
+ (header[0] & 0xFF) == 0xFF &&
+ (header[1] & 0xFF) == 0xD8;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /**
+ * 获取文件Path,并创建目录
+ */
+ 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 saveDir = FILE_DIR + LocalDate.now();
+ Path fullPath = Paths.get(saveDir, fileName).toAbsolutePath();
+ ensureDirectoryExists(fullPath.getParent());
+ return fullPath;
+ }
+
+ /**
+ * 抓图保存目录 - 跨平台兼容
+ * 注意路径避免包含中文,否则可能会导致文件保存失败或乱码
+ */
+ private static String determineCaptureDirectory() {
+ String os = System.getProperty("os.name").toLowerCase();
+ String userDir = System.getProperty("user.dir");
+
+ if (os.contains("win")) {
+ return "D:/captures/";
+ } else {
+ // Linux/Unix/Mac 系统使用项目同级目录
+ return userDir + "/captures/";
+ }
+ }
+}
diff --git a/src/main/java/com/inspect/nvr/service/DahuaCaptureService.java b/src/main/java/com/inspect/nvr/service/DahuaCaptureService.java
new file mode 100644
index 0000000..32da245
--- /dev/null
+++ b/src/main/java/com/inspect/nvr/service/DahuaCaptureService.java
@@ -0,0 +1,172 @@
+package com.inspect.nvr.service;
+
+import com.inspect.nvr.daHuaCarme.jna.NetSDKLib;
+import com.inspect.nvr.daHuaCarme.jna.dahua.ToolKits;
+import com.inspect.nvr.domain.Infrared.NvrInfo;
+import com.sun.jna.Pointer;
+import com.sun.jna.ptr.IntByReference;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 大华设备抓图服务
+ * 同一个IP,同一个通道,串行抓图
+ * 同一个IP,不同通道,最多4个线程并发抓图
+ * 不同IP,并发抓图,不限制
+ */
+@Slf4j
+@Service
+public class DahuaCaptureService extends BaseCaptureService {
+ /**
+ * Digest认证抓图URL:
+ * http:///cgi-bin/snapshot.cgi?channel=&subtype=
+ * subtype: 0-主码流, 1-子码流
+ */
+ private static final String DIGEST_URL_TEMPLATE = "http://%s/cgi-bin/snapshot.cgi?channel=%d&subtype=1";
+ // Key: loginId+CmdSerial (登录句柄+流水号), Value: CompletableFuture (用于通知调用线程)
+ private static final ConcurrentHashMap> PENDING_REQUESTS = new ConcurrentHashMap<>();
+ private static final fCaptureReceiveCB CAPTURE_RECEIVE_CB = new fCaptureReceiveCB();
+ // 1. [新增]全局流水号生成器
+ private static final AtomicInteger SERIAL_COUNTER = new AtomicInteger(1);
+ @Resource
+ private NetSDKLib dhNetSDK;
+ @Resource
+ private DahuaLoginService dahuaLoginService;
+
+ // CmdSerial请求序列号,有效值范围 0~65535,超过范围会被截断
+ public static int nextSerial() {
+ return SERIAL_COUNTER.updateAndGet(current -> (current + 1) & 0xFFFF);
+ }
+
+ public byte[] capture(NvrInfo nvrInfo, int channel) {
+ String ip = nvrInfo.getNvrIp();
+ Semaphore nvrSemaphore = getOrCreateSemaphore(ip);
+ ReentrantLock channelLock = getOrCreateLock(ip, channel);
+ // 1.先获取该NVR的全局并发许可
+ nvrSemaphore.acquireUninterruptibly();
+ try {
+ // 2.再获取该通道的独占锁(保证同一通道串行)
+ channelLock.lock();
+ try {
+ return captureWithRetry(nvrInfo, channel);
+ } finally {
+ // 必须先unlock通道锁,再释放NVR全局许可
+ if (channelLock.isHeldByCurrentThread()) {
+ channelLock.unlock();
+ }
+ }
+ } finally {
+ // 3.释放NVR全局许可
+ nvrSemaphore.release();
+ }
+ }
+
+ private byte[] captureWithRetry(NvrInfo nvrInfo, int channel) {
+ Path fullPath = getFullPath("dh", nvrInfo.getNvrIp(), channel);
+ int retryCount = 0;
+ int maxRetries = DEFAULT_MAX_RETRIES;
+ while (retryCount < maxRetries) {
+ try {
+ byte[] imageBytes = snapPictureEx(nvrInfo, channel, fullPath);
+ log.info("[大华]抓图成功:第{}次,文件地址:{}", retryCount + 1, fullPath);
+ return imageBytes;
+ } catch (Exception e) {
+ log.error("[大华]抓图异常:第{}次,{}", retryCount + 1, e.getMessage());
+ } finally {
+ retryCount++;
+ }
+ }
+ // 当SDK抓图失败时,尝试使用Digest认证抓图
+ byte[] imageBytes = captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, fullPath);
+ if (imageBytes == null) {
+ // 当所有抓图方式均失败时,记录失败图片
+ WriteCaptureFailedImage(fullPath);
+ log.error("[大华]所有抓图方式均失败,图片地址:{}", fullPath);
+ return new byte[0];
+ }
+ log.info("[大华]digest抓图成功,图片地址:{}", fullPath);
+ return imageBytes;
+ }
+
+ /**
+ * 大华SDK抓图具体实现(异步抓图)
+ * 通过CompletableFuture实现异步回调通知
+ */
+ private byte[] snapPictureEx(NvrInfo nvrInfo, int channel, Path fullPath) throws Exception {
+ NetSDKLib.LLong loginID = dahuaLoginService.login(nvrInfo);
+
+ NetSDKLib.SNAP_PARAMS snapParams = new NetSDKLib.SNAP_PARAMS();
+ snapParams.Channel = channel - 1; // 通道号从0开始
+ snapParams.mode = 0; // 抓图模式:0-单次抓
+ snapParams.Quality = 3;
+ snapParams.InterSnap = 0;
+ int mySerialId = nextSerial();
+ snapParams.CmdSerial = mySerialId;
+ IntByReference reference = new IntByReference(0);
+ // 设置异步抓图回调函数
+ dhNetSDK.CLIENT_SetSnapRevCallBack(CAPTURE_RECEIVE_CB, null);
+
+ String requestKey = loginID.longValue() + ":" + mySerialId;
+ CompletableFuture future = new CompletableFuture<>();
+ PENDING_REQUESTS.put(requestKey, future);
+ final int TIMEOUT_SEC = 5;
+ try {
+ log.info("[大华]开始抓图,LoginID={},IP={},Channel={},Serial={}", loginID, nvrInfo.getNvrIp(), channel, mySerialId);
+ boolean isCaptured = dhNetSDK.CLIENT_SnapPictureEx(loginID, snapParams, reference);
+ if (!isCaptured) {
+ PENDING_REQUESTS.remove(requestKey);
+ String errorMsg = ToolKits.getErrorCodePrint(dhNetSDK.CLIENT_GetLastError());
+ throw new RuntimeException("SDK抓图失败:" + errorMsg);
+ }
+ byte[] byteArray = future.get(TIMEOUT_SEC, TimeUnit.SECONDS);
+ Files.write(fullPath, byteArray);
+ return byteArray;
+ } catch (TimeoutException e) {
+ // 超时移除
+ PENDING_REQUESTS.remove(requestKey);
+ throw new TimeoutException("[大华]抓图超时:在 " + TIMEOUT_SEC + " 秒内未收到设备回调");
+ } catch (Exception e) {
+ PENDING_REQUESTS.remove(requestKey);
+ throw e;
+ }
+ }
+
+ /**
+ * CLIENT_SnapPictureEx异步抓图回调函数重写
+ */
+ public static class fCaptureReceiveCB implements NetSDKLib.fSnapRev {
+ @Override
+ public void invoke(NetSDKLib.LLong lLoginID, Pointer pBuf, int RevLen, int EncodeType, int CmdSerial, Pointer dwUser) {
+ // 1. 检查是否有等待该流水号的请求
+ String requestKey = lLoginID.longValue() + ":" + CmdSerial;
+ CompletableFuture future = PENDING_REQUESTS.remove(requestKey);
+ if (future != null) {
+ if (pBuf != null && RevLen > 0) {
+ // 2. 读取图片数据
+ byte[] data = pBuf.getByteArray(0, RevLen);
+ // 3. 完成Future,通知主线程
+ future.complete(data);
+ } else {
+ future.completeExceptionally(new RuntimeException("Empty image data"));
+ }
+ } else {
+ // 可能是由于超时已经被移除了,或者是其他类型的抓图
+ log.error("[大华] 收到未匹配的抓图回调,LoginID={}, Serial={}", lLoginID, CmdSerial);
+ }
+ }
+ }
+
+ /**
+ * Digest认证抓图
+ */
+ public byte[] captureDigest(NvrInfo nvrInfo, int channel) {
+ return captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, getFullPath("dh_digest", nvrInfo.getNvrIp(), channel));
+ }
+}
diff --git a/src/main/java/com/inspect/nvr/service/DahuaLoginService.java b/src/main/java/com/inspect/nvr/service/DahuaLoginService.java
new file mode 100644
index 0000000..502ab8a
--- /dev/null
+++ b/src/main/java/com/inspect/nvr/service/DahuaLoginService.java
@@ -0,0 +1,126 @@
+package com.inspect.nvr.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.inspect.nvr.daHuaCarme.jna.NetSDKLib;
+import com.inspect.nvr.daHuaCarme.jna.NetSDKLib.LLong;
+import com.inspect.nvr.domain.Infrared.NvrInfo;
+import com.inspect.nvr.utils.redis.RedisService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 大华登录服务
+ */
+@Slf4j
+@Service
+public class DahuaLoginService {
+ private static final String ERROR_LOGOUT_KEY = "dahua:error";
+
+ @Autowired
+ private NetSDKLib dhNetSDK;
+ @Resource
+ private RedisService redisService;
+
+ // 使用 Caffeine 缓存,10分钟未访问自动移除并登出
+ private final Cache sessionCache = Caffeine.newBuilder()
+ // 10分钟未被get()就过期
+ .expireAfterAccess(10, TimeUnit.MINUTES)
+ .removalListener((String ip, LLong loginID, RemovalCause cause) -> {
+ if (loginID != null && (cause == RemovalCause.EXPIRED || cause == RemovalCause.SIZE)) {
+ log.info("[大华]会话超时自动登出,ip: {},loginID: {}", ip, loginID);
+ doLogout(ip, loginID);
+ }
+ }).build();
+
+ public synchronized LLong login(NvrInfo nvrInfo) {
+ String ip = nvrInfo.getNvrIp();
+ LLong existLoginID = sessionCache.getIfPresent(ip);
+ if (existLoginID != null) {
+ log.info("[大华]登录命中缓存,ip: {},loginID: {}", ip, existLoginID);
+ return existLoginID;
+ }
+ // 执行登录
+ NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY pstInParam = new NetSDKLib.NET_IN_LOGIN_WITH_HIGHLEVEL_SECURITY();
+ pstInParam.szIP = nvrInfo.getNvrIp().getBytes();
+ pstInParam.nPort = nvrInfo.getServerPort();
+ pstInParam.szUserName = nvrInfo.getAccount().getBytes();
+ pstInParam.szPassword = nvrInfo.getPassword().getBytes();
+ NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY pstOutParam = new NetSDKLib.NET_OUT_LOGIN_WITH_HIGHLEVEL_SECURITY();
+ LLong loginID = dhNetSDK.CLIENT_LoginWithHighLevelSecurity(pstInParam, pstOutParam);
+ if (loginID.intValue() == 0) {
+ int errorCode = dhNetSDK.CLIENT_GetLastError();
+ throw new RuntimeException("登录失败,错误码:" + errorCode);
+ }
+ // 放入缓存,自动开始计时10分钟
+ sessionCache.put(nvrInfo.getNvrIp(), loginID);
+ log.info("[大华]登录成功,ip:{},loginID:{}", ip, loginID);
+ return loginID;
+ }
+
+ /**
+ * 记录注销失败信息到 Redis
+ */
+ private void recordLogoutError(String ip, LLong loginID, int errorCode) {
+ JSONObject json = new JSONObject();
+ json.put("ip", ip);
+ json.put("userId", loginID);
+ json.put("errorCode", errorCode);
+ json.put("time", System.currentTimeMillis());
+ redisService.redisTemplate.opsForZSet().add(ERROR_LOGOUT_KEY, json.toJSONString(), System.currentTimeMillis());
+ }
+
+ /**
+ * 登出具体实现
+ */
+ public void doLogout(String ip, LLong loginID) {
+ if (loginID != null) {
+ // 执行登出操作
+ boolean isLogout = dhNetSDK.CLIENT_Logout(loginID);
+ if (isLogout) {
+ log.info("[大华]自动注销成功,ip: {},loginID: {}", ip, loginID.longValue());
+ } else {
+ int errorCode = dhNetSDK.CLIENT_GetLastError();
+ log.error("[大华]自动注销失败,ip: {},loginID: {},错误码: {}", ip, loginID.longValue(), errorCode);
+ // 记录失败日志到 Redis
+ recordLogoutError(ip, loginID, errorCode);
+ }
+ }
+ }
+
+ /**
+ * 登出
+ */
+ public synchronized void logout(String ip) {
+ LLong loginID = sessionCache.getIfPresent(ip);
+ if (loginID != null) {
+ sessionCache.invalidate(ip);
+ }
+ }
+
+ /**
+ * 登出所有用户
+ */
+ public void logoutAll() {
+ // 获取所有缓存的IP和userID
+ sessionCache.asMap().forEach((ip, loginID) -> {
+ doLogout(ip, loginID);
+ });
+ // 清空整个缓存
+ sessionCache.invalidateAll();
+ log.info("[大华]所有用户已登出");
+ }
+
+ /**
+ * 检查是否已登录(同时刷新过期时间)
+ */
+ public boolean isLoggedIn(String ip) {
+ return sessionCache.getIfPresent(ip) != null;
+ }
+}
diff --git a/src/main/java/com/inspect/nvr/service/HikCaptureService.java b/src/main/java/com/inspect/nvr/service/HikCaptureService.java
new file mode 100644
index 0000000..1709cf7
--- /dev/null
+++ b/src/main/java/com/inspect/nvr/service/HikCaptureService.java
@@ -0,0 +1,162 @@
+package com.inspect.nvr.service;
+
+import com.inspect.nvr.domain.Infrared.NvrInfo;
+import com.inspect.nvr.hikVision.utils.jna.HCNetSDK;
+import com.sun.jna.ptr.IntByReference;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StreamUtils;
+
+import javax.annotation.Resource;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.locks.ReentrantLock;
+
+/**
+ * 海康设备抓图服务
+ * 同一个IP,同一个通道,串行抓图
+ * 同一个IP,不同通道,最多4个线程并发抓图
+ * 不同IP,并发抓图,不限制
+ */
+@Slf4j
+@Service
+public class HikCaptureService extends BaseCaptureService {
+ /**
+ * Digest认证抓图URL:
+ * http:///ISAPI/Streaming/channels//picture
+ * subtype: 01-主码流, 02-子码流
+ */
+ private static final String DIGEST_URL_TEMPLATE = "http://%s/ISAPI/Streaming/channels/%d02/picture";
+ @Resource
+ private HikLoginService hikLoginService;
+ @Resource
+ private HCNetSDK hcNetSDK;
+
+ public byte[] capture(NvrInfo nvrInfo, int channel) {
+ String ip = nvrInfo.getNvrIp();
+ Semaphore nvrSemaphore = getOrCreateSemaphore(ip);
+ ReentrantLock channelLock = getOrCreateLock(ip, channel);
+ // 1.先获取该NVR的全局并发许可
+ nvrSemaphore.acquireUninterruptibly();
+ try {
+ // 2.再获取该通道的独占锁(保证同一通道串行)
+ channelLock.lock();
+ try {
+ return captureWithRetry(nvrInfo, channel);
+ } finally {
+ // 必须先unlock通道锁,再释放NVR全局许可
+ if (channelLock.isHeldByCurrentThread()) {
+ channelLock.unlock();
+ }
+ }
+ } finally {
+ // 3.释放NVR全局许可
+ nvrSemaphore.release();
+ }
+ }
+
+ private byte[] captureWithRetry(NvrInfo nvrInfo, int channel) {
+ Path fullPath = getFullPath("hk", nvrInfo.getNvrIp(), channel);
+ ensureDirectoryExists(fullPath.getParent());
+ int retryCount = 0;
+ int maxRetries = DEFAULT_MAX_RETRIES;
+ while (retryCount < maxRetries) {
+ try {
+// byte[] imageBytes = captureJPEGPicture(nvrInfo, channel, fullPath);
+ byte[] imageBytes = captureJPEGPictureNew(nvrInfo, channel, fullPath);
+ if (imageBytes == null) {
+ int errorCode = hcNetSDK.NET_DVR_GetLastError();
+ 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;
+ }
+ } finally {
+ retryCount++;
+ }
+ }
+ // 当SDK抓图失败时,尝试使用Digest认证抓图
+ byte[] imageBytes = captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, fullPath);
+ if (imageBytes == null) {
+ // 当所有抓图方式均失败时,记录失败图片
+ WriteCaptureFailedImage(fullPath);
+ log.info("[海康]所有抓图方式均失败,图片地址:{}", fullPath);
+ return new byte[0];
+ }
+ log.info("[海康]digest抓图成功,图片地址:{}", fullPath);
+ return imageBytes;
+ }
+
+ /**
+ * 旧版抓图,备用
+ */
+ @Deprecated
+ private byte[] captureJPEGPicture(NvrInfo nvrInfo, int channel, Path fullPath) throws Exception {
+ int userId = hikLoginService.login(nvrInfo);
+ // 兼容 C/C++ 编写的本地库, C 语言用 \0 标记字符串结束
+ HCNetSDK.NET_DVR_JPEGPARA jpegpara = new HCNetSDK.NET_DVR_JPEGPARA();
+ jpegpara.wPicSize = 0xff;
+ jpegpara.wPicQuality = 1;
+ jpegpara.write();
+ byte[] filePathBytes = (fullPath.toString() + "\0").getBytes("GBK");
+ boolean isCaptured = hcNetSDK.NET_DVR_CaptureJPEGPicture(userId, channel, jpegpara, filePathBytes);
+ if (isCaptured) {
+ // 验证文件是否有效
+ if (!isValidJPEG(fullPath)) {
+ // 文件乱码,修改文件名
+ log.info("[海康]文件无效,图片地址:{}", fullPath);
+ String dirName = fullPath.getParent().toString();
+ String fileName = fullPath.getFileName().toString();
+ File dir = new File(dirName);
+ File[] files = dir.listFiles((file, name) -> name.startsWith(fileName));
+ if (files != null) {
+ for (File file : files) {
+ file.renameTo(new File(fullPath.toString()));
+ }
+ }
+ }
+ // 读取文件并返回字节数组
+ try (InputStream inputStream = Files.newInputStream(fullPath)) {
+ return StreamUtils.copyToByteArray(inputStream);
+ }
+ }
+ return null;
+ }
+
+ private byte[] captureJPEGPictureNew(NvrInfo nvrInfo, int channel, Path fullPath) throws Exception {
+ int userId = hikLoginService.login(nvrInfo);
+ 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);
+ 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) {
+ byteArray.read();
+ byte[] imageBytes = byteArray.byValue;
+ // 图片写入本地
+ try (FileOutputStream fos = new FileOutputStream(fullPath.toString())) {
+ fos.write(imageBytes, 0, ret.getValue());
+ }
+ return imageBytes;
+ }
+ return null;
+ }
+
+ public byte[] captureDigest(NvrInfo nvrInfo, int channel) {
+ return captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, getFullPath("hk_digest", nvrInfo.getNvrIp(), channel));
+ }
+}
diff --git a/src/main/java/com/inspect/nvr/service/HikLoginService.java b/src/main/java/com/inspect/nvr/service/HikLoginService.java
new file mode 100644
index 0000000..813c92c
--- /dev/null
+++ b/src/main/java/com/inspect/nvr/service/HikLoginService.java
@@ -0,0 +1,125 @@
+package com.inspect.nvr.service;
+
+import com.alibaba.fastjson.JSONObject;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
+import com.github.benmanes.caffeine.cache.RemovalCause;
+import com.inspect.nvr.domain.Infrared.NvrInfo;
+import com.inspect.nvr.hikVision.utils.jna.HCNetSDK;
+import com.inspect.nvr.hikVision.utils.jna.HikVisionUtils;
+import com.inspect.nvr.utils.redis.RedisService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 海康登录服务统一管理
+ */
+@Slf4j
+@Service
+public class HikLoginService {
+ private static final String ERROR_LOGOUT_KEY = "hik:error";
+ @Resource
+ private RedisService redisService;
+
+ @Autowired
+ private HCNetSDK hcNetSDK;
+
+ // 使用 Caffeine 缓存,10分钟未访问自动移除并登出
+ private final Cache sessionCache = Caffeine.newBuilder()
+ // 10分钟未被get()就过期
+ .expireAfterAccess(10, TimeUnit.MINUTES)
+ .removalListener((String ip, Integer userID, RemovalCause cause) -> {
+ if (userID != null && (cause == RemovalCause.EXPIRED || cause == RemovalCause.SIZE)) {
+ log.info("[海康]会话超时自动登出,ip: {},userID: {}", ip, userID);
+ doLogout(ip, userID);
+ }
+ }).build();
+
+ /**
+ * 登录
+ */
+ public synchronized int login(NvrInfo nvrInfo) {
+ String ip = nvrInfo.getNvrIp();
+ Integer existUserId = sessionCache.getIfPresent(ip);
+ if (existUserId != null) {
+ log.info("[海康]登录命中缓存,ip: {},userID: {}", ip, existUserId);
+ return existUserId;
+ }
+ // 执行登录
+ HCNetSDK.NET_DVR_USER_LOGIN_INFO m_strLoginInfo = HikVisionUtils.login_V40(nvrInfo.getNvrIp(), nvrInfo.getServerPort().shortValue(), nvrInfo.getAccount(), nvrInfo.getPassword());
+ HCNetSDK.NET_DVR_DEVICEINFO_V40 m_strDeviceInfo = new HCNetSDK.NET_DVR_DEVICEINFO_V40();
+ int userID = hcNetSDK.NET_DVR_Login_V40(m_strLoginInfo, m_strDeviceInfo);
+ if (userID < 0) {
+ int errorCode = hcNetSDK.NET_DVR_GetLastError();
+ throw new RuntimeException("登录失败,错误码:" + errorCode);
+ }
+ // 放入缓存,自动开始计时10分钟
+ sessionCache.put(nvrInfo.getNvrIp(), userID);
+ log.info("[海康]登录成功,ip:{},userID:{}", ip, userID);
+ return userID;
+ }
+
+ /**
+ * 记录注销失败信息到 Redis
+ */
+ private void recordLogoutError(String ip, Integer userID, int errorCode) {
+ JSONObject json = new JSONObject();
+ json.put("ip", ip);
+ json.put("userID", userID);
+ json.put("errorCode", errorCode);
+ json.put("time", System.currentTimeMillis());
+ redisService.redisTemplate.opsForZSet().add(ERROR_LOGOUT_KEY, json.toJSONString(), System.currentTimeMillis());
+ }
+
+ /**
+ * 登出具体实现
+ */
+ public void doLogout(String ip, Integer userID) {
+ if (userID != null) {
+ // 调用登出SDK
+ boolean isLogout = hcNetSDK.NET_DVR_Logout(userID);
+ if (isLogout) {
+ log.info("[海康]登出成功,ip: {},userID: {}", ip, userID);
+ } else {
+ int errorCode = hcNetSDK.NET_DVR_GetLastError();
+ log.error("[海康]登出失败,ip: {},userID: {},错误码: {}", ip, userID, errorCode);
+ // 登出失败日志记录到Redis中
+ recordLogoutError(ip, userID, errorCode);
+ }
+ }
+ }
+
+ /**
+ * 登出
+ */
+ public synchronized void logout(String ip) {
+ Integer userID = sessionCache.getIfPresent(ip);
+ if (userID != null) {
+ sessionCache.invalidate(ip);
+ }
+ }
+
+ /**
+ * 登出所有用户
+ */
+ public void logoutAll() {
+ // 获取所有缓存的IP和userID
+ sessionCache.asMap().forEach((ip, userID) -> {
+ doLogout(ip, userID);
+ });
+ // 清空整个缓存
+ sessionCache.invalidateAll();
+ log.info("[海康]所有用户已登出");
+ }
+
+ /**
+ * 检查是否已登录(同时刷新过期时间)
+ */
+ public boolean isLoggedIn(String ip) {
+ return sessionCache.getIfPresent(ip) != null;
+ }
+}
diff --git a/src/main/resources/images/imageCaptureFailed.jpg b/src/main/resources/images/imageCaptureFailed.jpg
new file mode 100644
index 0000000..571c2f4
Binary files /dev/null and b/src/main/resources/images/imageCaptureFailed.jpg differ