You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

261 lines
11 KiB

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