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 nvrSemaphoreMap = new ConcurrentHashMap<>(); /** 按 NVR IP 和通道维护的独占锁,确保同一通道的 SDK 操作串行执行。 */ final ConcurrentHashMap channelLockMap = new ConcurrentHashMap<>(); /** * 按 NVR 和通道为厂商 SDK 任务施加两级并发控制。 * 同一 NVR 最多并发四个任务,同一通道始终串行,供海康、大华和朗驰服务统一复用。 * * @param nvrInfo 提供 NVR IP 及认证信息的设备参数 * @param channel 当前 SDK 操作的设备通道号 * @param task 需要在并发限制下执行的厂商 SDK 任务 * @param SDK 任务返回值类型 * @return SDK 任务结果;任务抛出异常时返回 null */ T withConcurrencyControl(NvrInfo nvrInfo, int channel, Callable 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); } }