|
|
@ -13,11 +13,13 @@ import javax.annotation.Resource; |
|
|
import java.io.File; |
|
|
import java.io.File; |
|
|
import java.io.FileOutputStream; |
|
|
import java.io.FileOutputStream; |
|
|
import java.io.InputStream; |
|
|
import java.io.InputStream; |
|
|
|
|
|
import java.io.IOException; |
|
|
import java.nio.ByteBuffer; |
|
|
import java.nio.ByteBuffer; |
|
|
import java.nio.file.Files; |
|
|
import java.nio.file.Files; |
|
|
import java.nio.file.Path; |
|
|
import java.nio.file.Path; |
|
|
import java.text.SimpleDateFormat; |
|
|
import java.text.SimpleDateFormat; |
|
|
import java.util.Date; |
|
|
import java.util.Date; |
|
|
|
|
|
import java.util.Arrays; |
|
|
import java.util.concurrent.*; |
|
|
import java.util.concurrent.*; |
|
|
import java.util.concurrent.atomic.AtomicLong; |
|
|
import java.util.concurrent.atomic.AtomicLong; |
|
|
|
|
|
|
|
|
@ -25,7 +27,7 @@ import java.util.concurrent.atomic.AtomicLong; |
|
|
* 海康设备SDK服务 |
|
|
* 海康设备SDK服务 |
|
|
* 同一个IP,同一个通道,串行 |
|
|
* 同一个IP,同一个通道,串行 |
|
|
* 同一个IP,不同通道,最多4个线程并发 |
|
|
* 同一个IP,不同通道,最多4个线程并发 |
|
|
* 不同IP,并发抓图,不限制 |
|
|
|
|
|
|
|
|
* 不同 IP 分别限流,原生抓图另受有限工作线程池保护 |
|
|
*/ |
|
|
*/ |
|
|
@Slf4j |
|
|
@Slf4j |
|
|
@Service |
|
|
@Service |
|
|
@ -46,6 +48,23 @@ public class HikCameraService extends CommonCameraService { |
|
|
/** 海康本地 SDK 映射,用于抓图、测温和错误码查询。 */ |
|
|
/** 海康本地 SDK 映射,用于抓图、测温和错误码查询。 */ |
|
|
@Resource |
|
|
@Resource |
|
|
private HCNetSDK hcNetSDK; |
|
|
private HCNetSDK hcNetSDK; |
|
|
|
|
|
/** SDK 抓图的默认请求预算,单位为毫秒,低于页面 25 秒等待上限。 */ |
|
|
|
|
|
public static final long CAPTURE_TIMEOUT_MILLIS = 20_000L; |
|
|
|
|
|
/** SDK 最多调用两次,永久性能力错误不重试。 */ |
|
|
|
|
|
private static final int CAPTURE_MAX_ATTEMPTS = 2; |
|
|
|
|
|
/** 短暂设备忙时的重试间隔,单位为毫秒。 */ |
|
|
|
|
|
private static final long CAPTURE_RETRY_DELAY_MILLIS = 200L; |
|
|
|
|
|
/** 全局 SDK 工作线程数,允许四台 NVR 各执行四个并发任务。 */ |
|
|
|
|
|
private static final int CAPTURE_WORKER_COUNT = 16; |
|
|
|
|
|
/** 隔离可能无法响应中断的原生 SDK,工作线程和等待队列均有容量上限。 */ |
|
|
|
|
|
// ponytail: 全局最多 16 个 SDK 工作线程和 16 个排队任务,更多 NVR 同时满载时再调整线程数。 |
|
|
|
|
|
private static final ThreadPoolExecutor CAPTURE_EXECUTOR = new ThreadPoolExecutor( |
|
|
|
|
|
CAPTURE_WORKER_COUNT, CAPTURE_WORKER_COUNT, 0L, TimeUnit.MILLISECONDS, |
|
|
|
|
|
new ArrayBlockingQueue<>(16), runnable -> { |
|
|
|
|
|
Thread thread = new Thread(runnable, "hik-capture"); |
|
|
|
|
|
thread.setDaemon(true); |
|
|
|
|
|
return thread; |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 在 NVR 和通道两级并发控制下执行海康抓图。 |
|
|
* 在 NVR 和通道两级并发控制下执行海康抓图。 |
|
|
@ -55,8 +74,46 @@ public class HikCameraService extends CommonCameraService { |
|
|
* @return JPEG 图片字节;抓图失败时按底层约定返回空数组或 null |
|
|
* @return JPEG 图片字节;抓图失败时按底层约定返回空数组或 null |
|
|
*/ |
|
|
*/ |
|
|
public byte[] capture(NvrInfo nvrInfo, int channel) { |
|
|
public byte[] capture(NvrInfo nvrInfo, int channel) { |
|
|
// 复用父类的公共并发控制,避免海康服务再维护一套重复锁状态。 |
|
|
|
|
|
return withConcurrencyControl(nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel)); |
|
|
|
|
|
|
|
|
// 独立调用使用默认预算,平台入口可以传入直连失败后的剩余时间。 |
|
|
|
|
|
return capture(nvrInfo, channel, CAPTURE_TIMEOUT_MILLIS); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 在指定预算内获取 SDK 图片;超时后停止等待并取消排队或后续重试。 |
|
|
|
|
|
* |
|
|
|
|
|
* @param nvrInfo 数据库中的 NVR 连接信息 |
|
|
|
|
|
* @param channel SDK 通道号 |
|
|
|
|
|
* @param timeoutMillis 本次请求剩余预算,单位为毫秒 |
|
|
|
|
|
* @return JPEG 图片;超时、容量不足或设备失败时返回 null |
|
|
|
|
|
*/ |
|
|
|
|
|
public byte[] capture(NvrInfo nvrInfo, int channel, long timeoutMillis) { |
|
|
|
|
|
// 已无预算或请求已被取消时不再提交设备调用。 |
|
|
|
|
|
if (timeoutMillis <= 0 || Thread.currentThread().isInterrupted()) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
|
|
|
Future<byte[]> pending = null; |
|
|
|
|
|
try { |
|
|
|
|
|
// 有限工作线程隔离原生调用,继续复用现有 NVR 和通道锁。 |
|
|
|
|
|
pending = CAPTURE_EXECUTOR.submit(() -> withConcurrencyControl( |
|
|
|
|
|
nvrInfo, channel, () -> captureWithRetry(nvrInfo, channel))); |
|
|
|
|
|
return pending.get(timeoutMillis, TimeUnit.MILLISECONDS); |
|
|
|
|
|
} catch (InterruptedException interrupted) { |
|
|
|
|
|
// 上层取消请求时保留中断状态,并在 finally 中取消设备任务。 |
|
|
|
|
|
Thread.currentThread().interrupt(); |
|
|
|
|
|
return null; |
|
|
|
|
|
} catch (TimeoutException | RejectedExecutionException | ExecutionException failure) { |
|
|
|
|
|
// 预算耗尽或工作池繁忙时及时返回,避免持续占据 Web 请求线程。 |
|
|
|
|
|
log.warn("[海康]抓图等待失败,IP={},Channel={}:{}", |
|
|
|
|
|
nvrInfo.getNvrIp(), channel, failure.toString()); |
|
|
|
|
|
return null; |
|
|
|
|
|
} finally { |
|
|
|
|
|
// Future 已完成时无需取消;原生 SDK 不支持中断时最多占用固定数量的后台线程。 |
|
|
|
|
|
if (pending != null && !pending.isDone()) { |
|
|
|
|
|
pending.cancel(true); |
|
|
|
|
|
// 立即移除已取消的排队请求,不让失效任务继续占用队列容量。 |
|
|
|
|
|
CAPTURE_EXECUTOR.remove((Runnable) pending); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -67,8 +124,16 @@ public class HikCameraService extends CommonCameraService { |
|
|
* @return Digest 接口返回的 JPEG 图片字节,失败时返回 null |
|
|
* @return Digest 接口返回的 JPEG 图片字节,失败时返回 null |
|
|
*/ |
|
|
*/ |
|
|
public byte[] captureDigest(NvrInfo nvrInfo, int channel) { |
|
|
public byte[] captureDigest(NvrInfo nvrInfo, int channel) { |
|
|
// 复用父类公共限流后调用 Digest 抓图,保证同通道的 SDK 和 HTTP 抓图不并发冲突。 |
|
|
|
|
|
return withConcurrencyControl(nvrInfo, channel, () -> captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, getFullPath("hk_digest", nvrInfo.getNvrIp(), channel))); |
|
|
|
|
|
|
|
|
// 将旧入口转交给同一限流方法,避免平台直连和普通 Digest 调用维护不同的锁。 |
|
|
|
|
|
return captureDigestToPath(nvrInfo, channel, DIGEST_URL_TEMPLATE, |
|
|
|
|
|
getFullPath("hk_digest", nvrInfo.getNvrIp(), channel)); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 在设备和通道限流下执行指定 HTTP 抓图接口,并留档到请求独占路径。 */ |
|
|
|
|
|
public byte[] captureDigestToPath(NvrInfo nvrInfo, int channel, String template, Path fullPath) { |
|
|
|
|
|
// 平台直连也必须经过公共锁,避免同相机同通道同时抓图。 |
|
|
|
|
|
return withConcurrencyControl(nvrInfo, channel, |
|
|
|
|
|
() -> CommonCameraService.captureDigest(nvrInfo, channel, template, fullPath)); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
@ -84,7 +149,7 @@ public class HikCameraService extends CommonCameraService { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 按配置重试海康摄像机抓图,SDK 连续失败后回退到 Digest 抓图。 |
|
|
|
|
|
|
|
|
* 最多尝试两次海康 SDK 抓图,不支持抓图时直接回退到 Digest。 |
|
|
* |
|
|
* |
|
|
* @param nvrInfo 海康 NVR 连接和认证信息 |
|
|
* @param nvrInfo 海康 NVR 连接和认证信息 |
|
|
* @param channel 抓图通道号 |
|
|
* @param channel 抓图通道号 |
|
|
@ -94,31 +159,50 @@ public class HikCameraService extends CommonCameraService { |
|
|
Path fullPath = getFullPath("hk", nvrInfo.getNvrIp(), channel); |
|
|
Path fullPath = getFullPath("hk", nvrInfo.getNvrIp(), channel); |
|
|
ensureDirectoryExists(fullPath.getParent()); |
|
|
ensureDirectoryExists(fullPath.getParent()); |
|
|
int retryCount = 0; |
|
|
int retryCount = 0; |
|
|
int maxRetries = DEFAULT_MAX_RETRIES; |
|
|
|
|
|
|
|
|
int maxRetries = CAPTURE_MAX_ATTEMPTS; |
|
|
while (retryCount < maxRetries) { |
|
|
while (retryCount < maxRetries) { |
|
|
|
|
|
// 超时或取消后不得继续重试,也不得再发出 HTTP 兜底请求。 |
|
|
|
|
|
if (Thread.currentThread().isInterrupted()) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
try { |
|
|
try { |
|
|
// byte[] imageBytes = captureJPEGPicture(nvrInfo, channel, fullPath); |
|
|
|
|
|
byte[] imageBytes = captureJPEGPictureNew(nvrInfo, channel, fullPath); |
|
|
byte[] imageBytes = captureJPEGPictureNew(nvrInfo, channel, fullPath); |
|
|
if (imageBytes == null) { |
|
|
if (imageBytes == null) { |
|
|
int errorCode = hcNetSDK.NET_DVR_GetLastError(); |
|
|
int errorCode = hcNetSDK.NET_DVR_GetLastError(); |
|
|
|
|
|
// 23/29 属于设备抓图能力或参数不支持,重复同一请求无法恢复。 |
|
|
|
|
|
if (errorCode == 23 || errorCode == 29) { |
|
|
|
|
|
log.warn("[海康]SDK 不支持当前抓图请求,错误码={},转 HTTP 兜底", errorCode); |
|
|
|
|
|
break; |
|
|
|
|
|
} |
|
|
throw new RuntimeException("SDK抓图失败,错误码" + errorCode); |
|
|
throw new RuntimeException("SDK抓图失败,错误码" + errorCode); |
|
|
} |
|
|
} |
|
|
log.info("[海康]抓图成功(第{}次):{}", retryCount + 1, fullPath); |
|
|
log.info("[海康]抓图成功(第{}次):{}", retryCount + 1, fullPath); |
|
|
return imageBytes; |
|
|
return imageBytes; |
|
|
} catch (Exception e) { |
|
|
} catch (Exception e) { |
|
|
log.error("[海康]抓图异常(第{}次):{}", retryCount + 1, e.getMessage()); |
|
|
log.error("[海康]抓图异常(第{}次):{}", retryCount + 1, e.getMessage()); |
|
|
try { |
|
|
|
|
|
Thread.sleep(2000); |
|
|
|
|
|
} catch (InterruptedException ignored) { |
|
|
|
|
|
Thread.currentThread().interrupt(); |
|
|
|
|
|
break; |
|
|
|
|
|
|
|
|
// 只有还会再次尝试时短暂等待,最后一次失败不额外休眠。 |
|
|
|
|
|
if (retryCount + 1 < maxRetries) { |
|
|
|
|
|
try { |
|
|
|
|
|
Thread.sleep(CAPTURE_RETRY_DELAY_MILLIS); |
|
|
|
|
|
} catch (InterruptedException interrupted) { |
|
|
|
|
|
Thread.currentThread().interrupt(); |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} finally { |
|
|
} finally { |
|
|
retryCount++; |
|
|
retryCount++; |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
// 原生调用结束时可能已经超过请求预算,取消后不能继续产生设备负载。 |
|
|
|
|
|
if (Thread.currentThread().isInterrupted()) { |
|
|
|
|
|
return null; |
|
|
|
|
|
} |
|
|
// 当SDK抓图失败时,尝试使用Digest认证抓图 |
|
|
// 当SDK抓图失败时,尝试使用Digest认证抓图 |
|
|
byte[] imageBytes = captureDigest(nvrInfo, channel, DIGEST_URL_TEMPLATE, fullPath); |
|
|
|
|
|
|
|
|
// ISAPI 使用页面逻辑通道而非 33 起的 SDK 数字通道,同时采用数据库 HTTP 端口。 |
|
|
|
|
|
int httpChannel = hikLoginService.loginSession(nvrInfo).toRtspChannel(channel); |
|
|
|
|
|
String httpPort = nvrInfo.getHttpPort() == null ? "" : ":" + nvrInfo.getHttpPort(); |
|
|
|
|
|
String template = "http://%s" + httpPort + "/ISAPI/Streaming/channels/%d02/picture"; |
|
|
|
|
|
byte[] imageBytes = captureDigest(nvrInfo, httpChannel, template, fullPath); |
|
|
if (imageBytes == null) { |
|
|
if (imageBytes == null) { |
|
|
// 当所有抓图方式均失败时,记录失败图片 |
|
|
// 当所有抓图方式均失败时,记录失败图片 |
|
|
writeCaptureFailedImage(fullPath); |
|
|
writeCaptureFailedImage(fullPath); |
|
|
@ -180,24 +264,40 @@ public class HikCameraService extends CommonCameraService { |
|
|
* @throws Exception 登录、本地文件或 SDK 调用异常 |
|
|
* @throws Exception 登录、本地文件或 SDK 调用异常 |
|
|
*/ |
|
|
*/ |
|
|
private byte[] captureJPEGPictureNew(NvrInfo nvrInfo, int channel, Path fullPath) throws Exception { |
|
|
private byte[] captureJPEGPictureNew(NvrInfo nvrInfo, int channel, Path fullPath) throws Exception { |
|
|
int userId = hikLoginService.login(nvrInfo); |
|
|
|
|
|
|
|
|
// 保留会话对象身份,故障时只能失效本次调用使用的旧句柄。 |
|
|
|
|
|
HikLoginSession session = hikLoginService.loginSession(nvrInfo); |
|
|
|
|
|
int userId = session.getUserId(); |
|
|
HCNetSDK.NET_DVR_JPEGPARA jpegpara = new HCNetSDK.NET_DVR_JPEGPARA(); |
|
|
HCNetSDK.NET_DVR_JPEGPARA jpegpara = new HCNetSDK.NET_DVR_JPEGPARA(); |
|
|
jpegpara.wPicSize = 0xff; |
|
|
jpegpara.wPicSize = 0xff; |
|
|
jpegpara.wPicQuality = 0; |
|
|
jpegpara.wPicQuality = 0; |
|
|
jpegpara.write(); |
|
|
jpegpara.write(); |
|
|
HCNetSDK.BYTE_ARRAY byteArray = new HCNetSDK.BYTE_ARRAY(10 * 1024 * 1024); |
|
|
|
|
|
|
|
|
HCNetSDK.BYTE_ARRAY byteArray = new HCNetSDK.BYTE_ARRAY(MAX_CAPTURE_BYTES); |
|
|
IntByReference ret = new IntByReference(0); |
|
|
IntByReference ret = new IntByReference(0); |
|
|
log.info("[海康]开始抓图,UserID={},IP={},Channel={}", userId, nvrInfo.getNvrIp(), channel); |
|
|
log.info("[海康]开始抓图,UserID={},IP={},Channel={}", userId, nvrInfo.getNvrIp(), channel); |
|
|
boolean isCaptured = hcNetSDK.NET_DVR_CaptureJPEGPicture_NEW(userId, channel, jpegpara, byteArray.getPointer(), byteArray.size(), ret); |
|
|
boolean isCaptured = hcNetSDK.NET_DVR_CaptureJPEGPicture_NEW(userId, channel, jpegpara, byteArray.getPointer(), byteArray.size(), ret); |
|
|
if (isCaptured) { |
|
|
if (isCaptured) { |
|
|
|
|
|
// SDK 成功标记仍需配合有效长度,防止空数据或越界长度进入图片缓存。 |
|
|
|
|
|
int pictureSize = ret.getValue(); |
|
|
|
|
|
if (pictureSize <= 0 || pictureSize > byteArray.byValue.length) { |
|
|
|
|
|
throw new IOException("SDK 返回的图片长度无效:" + pictureSize); |
|
|
|
|
|
} |
|
|
byteArray.read(); |
|
|
byteArray.read(); |
|
|
byte[] imageBytes = byteArray.byValue; |
|
|
|
|
|
// 图片写入本地 |
|
|
|
|
|
|
|
|
// 只复制实际图片,禁止将 10MB 缓冲区尾部空白作为图片返回。 |
|
|
|
|
|
byte[] imageBytes = Arrays.copyOf(byteArray.byValue, pictureSize); |
|
|
|
|
|
// 留档失败只记录本地存储错误,仍返回成功抓取的设备图片。 |
|
|
try (FileOutputStream fos = new FileOutputStream(fullPath.toString())) { |
|
|
try (FileOutputStream fos = new FileOutputStream(fullPath.toString())) { |
|
|
fos.write(imageBytes, 0, ret.getValue()); |
|
|
|
|
|
|
|
|
fos.write(imageBytes); |
|
|
|
|
|
} catch (IOException saveFailure) { |
|
|
|
|
|
log.warn("[海康]图片留档失败,path={}:{}", fullPath, saveFailure.getMessage()); |
|
|
} |
|
|
} |
|
|
return imageBytes; |
|
|
return imageBytes; |
|
|
} |
|
|
} |
|
|
|
|
|
// 用户不存在表示句柄已经不可用,下一次重试必须重新登录,不能重复使用缓存。 |
|
|
|
|
|
int errorCode = hcNetSDK.NET_DVR_GetLastError(); |
|
|
|
|
|
if (errorCode == HCNetSDK.NET_DVR_USERNOTEXIST) { |
|
|
|
|
|
hikLoginService.invalidateSession(nvrInfo, session); |
|
|
|
|
|
throw new IOException("SDK抓图句柄失效,错误码:" + errorCode); |
|
|
|
|
|
} |
|
|
return null; |
|
|
return null; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|