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