| @ -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<String, Semaphore> nvrSemaphoreMap = new ConcurrentHashMap<>(); | |||
| // 每个(ip_chanel)对应一个锁,确保同通道串行 | |||
| final ConcurrentHashMap<String, ReentrantLock> 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/"; | |||
| } | |||
| } | |||
| } | |||
| @ -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://<host>/cgi-bin/snapshot.cgi?channel=<channel>&subtype=<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<String, CompletableFuture<byte[]>> 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<byte[]> 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<byte[]> 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)); | |||
| } | |||
| } | |||
| @ -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<String, LLong> 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; | |||
| } | |||
| } | |||
| @ -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://<host>/ISAPI/Streaming/channels/<channel><subtype>/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)); | |||
| } | |||
| } | |||
| @ -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<String, Integer> 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; | |||
| } | |||
| } | |||