Browse Source

refactor: 重构海康/大华登录和抓图(复用登录句柄、规范抓图文件规范、新增Digest认证抓图、优化并发抓图、增加SDK资源释放)

master
yinhuaiwei 2 months ago
parent
commit
ef4ae0d33e
7 changed files with 768 additions and 31 deletions
  1. +12
    -31
      pom.xml
  2. +171
    -0
      src/main/java/com/inspect/nvr/service/BaseCaptureService.java
  3. +172
    -0
      src/main/java/com/inspect/nvr/service/DahuaCaptureService.java
  4. +126
    -0
      src/main/java/com/inspect/nvr/service/DahuaLoginService.java
  5. +162
    -0
      src/main/java/com/inspect/nvr/service/HikCaptureService.java
  6. +125
    -0
      src/main/java/com/inspect/nvr/service/HikLoginService.java
  7. BIN
      src/main/resources/images/imageCaptureFailed.jpg

+ 12
- 31
pom.xml View File

@ -24,12 +24,6 @@
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
@ -40,11 +34,6 @@
<artifactId>lombok</artifactId> <artifactId>lombok</artifactId>
<version>1.18.24</version> <version>1.18.24</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.12.0</version>
</dependency>
<dependency> <dependency>
<groupId>com.squareup.retrofit2</groupId> <groupId>com.squareup.retrofit2</groupId>
<artifactId>retrofit</artifactId> <artifactId>retrofit</artifactId>
@ -142,11 +131,6 @@
<artifactId>commons-imaging</artifactId> <artifactId>commons-imaging</artifactId>
<version>1.0-alpha3</version> <version>1.0-alpha3</version>
</dependency> </dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.10.1</version>
</dependency>
<dependency> <dependency>
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
@ -177,16 +161,6 @@
<artifactId>commons-net</artifactId> <artifactId>commons-net</artifactId>
<version>3.9.0</version> <version>3.9.0</version>
</dependency> </dependency>
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.9.0</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
<dependency> <dependency>
<groupId>commons-fileupload</groupId> <groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId> <artifactId>commons-fileupload</artifactId>
@ -197,16 +171,12 @@
<artifactId>commons-compress</artifactId> <artifactId>commons-compress</artifactId>
<version>1.19</version> <version>1.19</version>
</dependency> </dependency>
<!-- OpenCV --> <!-- OpenCV -->
<dependency> <dependency>
<groupId>org.openpnp</groupId> <groupId>org.openpnp</groupId>
<artifactId>opencv</artifactId> <artifactId>opencv</artifactId>
<version>4.5.5-1</version> <version>4.5.5-1</version>
</dependency> </dependency>
<!-- Apache Commons for file operations --> <!-- Apache Commons for file operations -->
<dependency> <dependency>
<groupId>org.apache.commons</groupId> <groupId>org.apache.commons</groupId>
@ -232,7 +202,18 @@
<artifactId>commons-io</artifactId> <artifactId>commons-io</artifactId>
<version>2.11.0</version> <version>2.11.0</version>
</dependency> </dependency>
<!-- For caching -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.8.8</version>
</dependency>
<!-- Apache HTTP Client 5 -->
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
<version>5.3.1</version>
</dependency>
</dependencies> </dependencies>
<dependencyManagement> <dependencyManagement>
<dependencies> <dependencies>


+ 171
- 0
src/main/java/com/inspect/nvr/service/BaseCaptureService.java View File

@ -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/";
}
}
}

+ 172
- 0
src/main/java/com/inspect/nvr/service/DahuaCaptureService.java View File

@ -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));
}
}

+ 126
- 0
src/main/java/com/inspect/nvr/service/DahuaLoginService.java View File

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

+ 162
- 0
src/main/java/com/inspect/nvr/service/HikCaptureService.java View File

@ -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));
}
}

+ 125
- 0
src/main/java/com/inspect/nvr/service/HikLoginService.java View File

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

BIN
src/main/resources/images/imageCaptureFailed.jpg View File

Before After
Width: 640  |  Height: 360  |  Size: 4.4 KiB

Loading…
Cancel
Save