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.FileAlreadyExistsException;
|
|
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.Callable;
|
|
import java.util.concurrent.ConcurrentHashMap;
|
|
import java.util.concurrent.Semaphore;
|
|
import java.util.concurrent.locks.ReentrantLock;
|
|
|
|
/**
|
|
* 通用摄像头服务类
|
|
*/
|
|
@Slf4j
|
|
public class CommonCameraService {
|
|
/** 摄像机抓图失败时写入的兜底图片路径。 */
|
|
static final String IMAGE_CAPTURE_FAILED = "images\\imageCaptureFailed.jpg";
|
|
/** 抓图保存根目录,可由 INSPECT_NVR_CAPTURE_DIR 指定。 */
|
|
public static final String FILE_DIR = determineCaptureDirectory();
|
|
/** 厂商 SDK 抓图失败时的默认最大重试次数。 */
|
|
static final int DEFAULT_MAX_RETRIES = 10;
|
|
/** 单台 NVR 允许同时执行的最大 SDK 任务数。 */
|
|
static final int MAX_CONCURRENT_PER_NVR = 4;
|
|
/** 按 NVR IP 维护的信号量,用于限制单台设备的总并发数。 */
|
|
final ConcurrentHashMap<String, Semaphore> nvrSemaphoreMap = new ConcurrentHashMap<>();
|
|
/** 按 NVR IP 和通道维护的独占锁,确保同一通道的 SDK 操作串行执行。 */
|
|
final ConcurrentHashMap<String, ReentrantLock> channelLockMap = new ConcurrentHashMap<>();
|
|
|
|
/**
|
|
* 按 NVR 和通道为厂商 SDK 任务施加两级并发控制。
|
|
* 同一 NVR 最多并发四个任务,同一通道始终串行,供海康、大华和朗驰服务统一复用。
|
|
*
|
|
* @param nvrInfo 提供 NVR IP 及认证信息的设备参数
|
|
* @param channel 当前 SDK 操作的设备通道号
|
|
* @param task 需要在并发限制下执行的厂商 SDK 任务
|
|
* @param <T> SDK 任务返回值类型
|
|
* @return SDK 任务结果;任务抛出异常时返回 null
|
|
*/
|
|
<T> T withConcurrencyControl(NvrInfo nvrInfo, int channel, Callable<T> task) {
|
|
// 以 NVR IP 作为设备级并发键,不同 NVR 之间互不阻塞。
|
|
String ip = nvrInfo.getNvrIp();
|
|
// 获取设备级信号量和通道级独占锁,建立“设备限流+通道串行”的两级保护。
|
|
Semaphore nvrSemaphore = getOrCreateSemaphore(ip);
|
|
ReentrantLock channelLock = getOrCreateLock(ip, channel);
|
|
// 先获取 NVR 的全局并发许可,避免超出设备 SDK 的并发承受能力。
|
|
nvrSemaphore.acquireUninterruptibly();
|
|
try {
|
|
// 再获取通道独占锁,防止同一通道同时抓图或测温导致 SDK 状态冲突。
|
|
channelLock.lock();
|
|
try {
|
|
// 在两级并发约束生效期间执行真实的厂商 SDK 调用。
|
|
return task.call();
|
|
} catch (Exception e) {
|
|
// 将各厂商 SDK 异常统一记录并转换为 null,保持原有服务返回约定。
|
|
log.error("任务执行异常:", e);
|
|
return null;
|
|
} finally {
|
|
// 先释放通道锁,再由外层释放 NVR 许可,避免其他任务拿到许可后仍被旧通道锁阻塞。
|
|
if (channelLock.isHeldByCurrentThread()) {
|
|
channelLock.unlock();
|
|
}
|
|
}
|
|
} finally {
|
|
// 无论 SDK 调用是否成功都释放 NVR 许可,防止异常路径造成永久限流。
|
|
nvrSemaphore.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 抓图失败时,写入兜底图
|
|
* 可考虑返回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);
|
|
return Files.readAllBytes(fullPath);
|
|
}
|
|
} else {
|
|
String errorBody = EntityUtils.toString(httpResponse.getEntity());
|
|
throw new RuntimeException("状态码: " + statusCode + ",响应体: " + errorBody);
|
|
}
|
|
});
|
|
} catch (Exception e) {
|
|
log.error("Digest认证抓图失败:", e);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 创建目录(如果不存在)
|
|
*/
|
|
void ensureDirectoryExists(Path dir) {
|
|
if (!Files.exists(dir)) {
|
|
try {
|
|
Files.createDirectories(dir);
|
|
} catch (IOException e) {
|
|
throw new RuntimeException("无法创建目录: " + dir, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 获取指定 NVR 的公平信号量,首次访问时按默认并发上限创建。
|
|
*
|
|
* @param ip NVR IP,作为设备级并发键
|
|
* @return 该 NVR 共享的公平信号量
|
|
*/
|
|
Semaphore getOrCreateSemaphore(String ip) {
|
|
// fair=true 避免饥饿
|
|
return nvrSemaphoreMap.computeIfAbsent(ip, k -> new Semaphore(MAX_CONCURRENT_PER_NVR, true));
|
|
}
|
|
|
|
/**
|
|
* 获取指定 NVR 通道的独占锁,首次访问时按 IP 和通道号创建。
|
|
*
|
|
* @param ip NVR IP
|
|
* @param channel SDK 操作通道号
|
|
* @return 该 NVR 通道共享的可重入锁
|
|
*/
|
|
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";
|
|
Path fullPath = Paths.get(FILE_DIR)
|
|
.resolve(LocalDate.now().toString())
|
|
.resolve(fileName)
|
|
.toAbsolutePath();
|
|
ensureDirectoryExists(fullPath.getParent());
|
|
return fullPath;
|
|
}
|
|
|
|
/**
|
|
* 抓图保存目录 - 跨平台兼容
|
|
* 注意路径避免包含中文,否则可能会导致文件保存失败或乱码
|
|
*/
|
|
private static String determineCaptureDirectory() {
|
|
String configuredDirectory = System.getenv("INSPECT_NVR_CAPTURE_DIR");
|
|
if (configuredDirectory == null || configuredDirectory.trim().isEmpty()) {
|
|
configuredDirectory = System.getProperty("inspect.nvr.capture-dir");
|
|
}
|
|
|
|
Path preferredDirectory;
|
|
if (configuredDirectory != null && !configuredDirectory.trim().isEmpty()) {
|
|
preferredDirectory = Paths.get(configuredDirectory.trim()).toAbsolutePath();
|
|
} else if (System.getProperty("os.name", "").toLowerCase().contains("win")) {
|
|
preferredDirectory = Paths.get("D:/captures").toAbsolutePath();
|
|
} else {
|
|
preferredDirectory = Paths.get(System.getProperty("user.dir"), "captures").toAbsolutePath();
|
|
}
|
|
|
|
try {
|
|
ensureWritableDirectory(preferredDirectory);
|
|
log.info("抓图目录已就绪: {}", preferredDirectory);
|
|
return preferredDirectory.toString() + java.io.File.separator;
|
|
} catch (IOException primaryFailure) {
|
|
Path fallbackDirectory = Paths.get(System.getProperty("java.io.tmpdir"), "inspect-nvr-captures")
|
|
.toAbsolutePath();
|
|
try {
|
|
ensureWritableDirectory(fallbackDirectory);
|
|
log.warn("抓图目录 {} 不可写,已回退到 {}:{}",
|
|
preferredDirectory, fallbackDirectory, primaryFailure.toString());
|
|
return fallbackDirectory.toString() + java.io.File.separator;
|
|
} catch (IOException fallbackFailure) {
|
|
fallbackFailure.addSuppressed(primaryFailure);
|
|
throw new IllegalStateException("抓图目录不可写,主目录=" + preferredDirectory
|
|
+ ",临时目录=" + fallbackDirectory, fallbackFailure);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** 创建目录并通过真实文件写入确认运行用户拥有写权限。 */
|
|
private static void ensureWritableDirectory(Path directory) throws IOException {
|
|
Files.createDirectories(directory);
|
|
if (!Files.isDirectory(directory)) {
|
|
throw new FileAlreadyExistsException(directory.toString(), null, "目标路径不是目录");
|
|
}
|
|
Path probe = Files.createTempFile(directory, ".inspect-nvr-write-check-", ".tmp");
|
|
Files.delete(probe);
|
|
}
|
|
}
|