package com.inspect.nvr.ivs.util;
|
|
|
|
import com.inspect.nvr.dahua.DahuaNvrChannelClient;
|
|
import com.inspect.nvr.ivs.pojo.IvsNvr;
|
|
|
|
import lombok.AllArgsConstructor;
|
|
import lombok.Data;
|
|
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.config.RequestConfig;
|
|
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.apache.hc.core5.util.Timeout;
|
|
import org.springframework.stereotype.Service;
|
|
import org.w3c.dom.Document;
|
|
import org.w3c.dom.Element;
|
|
import org.w3c.dom.NodeList;
|
|
|
|
import javax.xml.parsers.DocumentBuilderFactory;
|
|
import javax.annotation.Resource;
|
|
import java.io.ByteArrayInputStream;
|
|
import java.nio.ByteBuffer;
|
|
import java.nio.charset.CharacterCodingException;
|
|
import java.nio.charset.CodingErrorAction;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.Collections;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.ArrayList;
|
|
|
|
/**
|
|
* 按 NVR 厂商读取摄像机通道;海康复用 ISAPI,大华转交 NetSDK 客户端。
|
|
*/
|
|
@Slf4j
|
|
@Service
|
|
public class IvsNvrChannelClient {
|
|
|
|
/** NVR ISAPI 建立连接超时时间。 */
|
|
private static final Timeout CONNECT_TIMEOUT = Timeout.ofSeconds(2);
|
|
/** NVR ISAPI 响应读取超时时间。 */
|
|
private static final Timeout RESPONSE_TIMEOUT = Timeout.ofSeconds(5);
|
|
/** 海康云台能力查询路径,通道号由调用方填入。 */
|
|
private static final String PTZ_CAPABILITIES_PATH = "/ISAPI/PTZCtrl/channels/%d/capabilities";
|
|
/** 云台能力结构标签:出现即代表该通道具备云台能力。 */
|
|
private static final String PTZ_CAPABILITY_TAG = "<PTZCap";
|
|
/** ISAPI 标准错误体标签:设备明确拒绝时出现。 */
|
|
private static final String ISAPI_ERROR_TAG = "<ResponseStatus";
|
|
/** 鉴权失败标签:用于把"没权限"与"不支持"区分开。 */
|
|
private static final String UNAUTHORIZED_TAG = "Unauthorized";
|
|
|
|
/** 大华 NetSDK 通道读取客户端,仅在 vendor 为 DAHUA 时调用。 */
|
|
@Resource
|
|
private DahuaNvrChannelClient dahuaChannelClient;
|
|
|
|
/**
|
|
* 根据台账厂商选择通道读取实现,避免把海康 ISAPI 请求发送给大华设备。
|
|
*
|
|
* @param nvr 数据库中的 NVR 连接与厂商信息
|
|
* @return 统一的摄像机通道列表
|
|
*/
|
|
public List<Channel> listChannels(IvsNvr nvr) {
|
|
// NVR 记录为空时无法判断厂商和连接目标,直接按参数错误处理。
|
|
if (nvr == null) {
|
|
throw new IllegalArgumentException("NVR 记录不能为空");
|
|
}
|
|
// 统一厂商大小写和中英文别名,确保数据库 Dahua/大华 均进入 NetSDK 分支。
|
|
String vendor = normalizeVendor(nvr.getVendor());
|
|
// 大华设备必须使用 NetSDK,不能继续请求海康专用的 InputProxy ISAPI。
|
|
if ("DAHUA".equals(vendor)) {
|
|
return dahuaChannelClient.listChannels(nvr);
|
|
}
|
|
// 海康及历史空厂商记录保持原有 ISAPI 同步行为,兼容现有部署数据。
|
|
if ("HIKVISION".equals(vendor)) {
|
|
return listHikChannels(nvr);
|
|
}
|
|
// 未知厂商不允许静默走海康分支,避免再次出现对错误设备调用 ISAPI 的情况。
|
|
throw new IllegalArgumentException("不支持的 NVR 厂商:" + nvr.getVendor());
|
|
}
|
|
|
|
/** 读取海康 NVR 的 InputProxy 通道及在线状态。 */
|
|
private List<Channel> listHikChannels(IvsNvr nvr) {
|
|
// 读取 NVR 通道基本信息 XML。
|
|
String channelsXml = request(nvr, "/ISAPI/ContentMgmt/InputProxy/channels");
|
|
Map<Integer, Boolean> statuses = Collections.emptyMap();
|
|
try {
|
|
// 尝试读取通道在线状态 XML;部分固件不支持该接口。
|
|
statuses = parseStatuses(request(nvr, "/ISAPI/ContentMgmt/InputProxy/channels/status"));
|
|
} catch (Exception ex) {
|
|
// 某些固件没有 status 接口,通道本身仍然可以同步。
|
|
log.warn("读取 NVR 通道在线状态失败,继续同步通道信息,nvrIp={}:{}",
|
|
nvr.getNvrIp(), ex.getMessage());
|
|
}
|
|
// 合并通道信息和在线状态,转换为同步服务使用的对象列表。
|
|
return parseChannels(channelsXml, statuses);
|
|
}
|
|
|
|
/** 将数据库中的厂商名称归一化为同步路由使用的编码。 */
|
|
private String normalizeVendor(String vendor) {
|
|
// 历史记录未填写厂商时按项目原有海康行为兼容。
|
|
if (isBlank(vendor)) {
|
|
return "HIKVISION";
|
|
}
|
|
String normalized = vendor.trim().toUpperCase(java.util.Locale.ROOT);
|
|
// 识别大华的英文缩写和中文名称。
|
|
if ("DAHUA".equals(normalized) || "DH".equals(normalized) || "大华".equals(vendor.trim())) {
|
|
return "DAHUA";
|
|
}
|
|
// 识别海康的常见英文缩写和中文名称。
|
|
if ("HIKVISION".equals(normalized) || "HIK".equals(normalized)
|
|
|| "海康".equals(vendor.trim()) || "海康威视".equals(vendor.trim())) {
|
|
return "HIKVISION";
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
/** 使用 Digest/Basic 兼容认证请求 NVR 的 ISAPI XML 接口。 */
|
|
private String request(IvsNvr nvr, String path) {
|
|
IsapiResponse response = exchange(nvr, path);
|
|
// 非 2xx 状态表示 NVR 通道接口调用失败。
|
|
if (response.status < 200 || response.status >= 300) {
|
|
throw new IllegalStateException("NVR ISAPI 返回 HTTP " + response.status
|
|
+ ",IP=" + nvr.getNvrIp());
|
|
}
|
|
return response.body;
|
|
}
|
|
|
|
/**
|
|
* 使用 Digest/Basic 兼容认证请求 NVR 的 ISAPI 接口,并返回状态码与响应体。
|
|
*
|
|
* <p>云台能力探测需要区分"设备明确拒绝"与"接口本身不可用",因此不能像
|
|
* {@link #request} 那样把非 2xx 一律当异常抛出。</p>
|
|
*
|
|
* @param nvr NVR 连接与凭据
|
|
* @param path ISAPI 路径(含前导斜杠)
|
|
* @return 状态码与解码后的响应体
|
|
*/
|
|
private IsapiResponse exchange(IvsNvr nvr, String path) {
|
|
// 请求必须提供 NVR IP。
|
|
if (nvr == null || isBlank(nvr.getNvrIp())) {
|
|
throw new IllegalArgumentException("NVR IP 不能为空");
|
|
}
|
|
// Digest/Basic 认证必须具备账号密码。
|
|
if (isBlank(nvr.getUsername()) || nvr.getPassword() == null) {
|
|
throw new IllegalArgumentException("NVR 登录账号或密码为空,NVR IP=" + nvr.getNvrIp());
|
|
}
|
|
// 未配置 HTTP 端口时使用 ISAPI 默认端口 80。
|
|
int port = nvr.getHttpPort() == null || nvr.getHttpPort() <= 0 ? 80 : nvr.getHttpPort();
|
|
String url = "http://" + nvr.getNvrIp().trim() + ":" + port + path;
|
|
|
|
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
|
|
credentialsProvider.setCredentials(
|
|
new AuthScope(null, -1),
|
|
new UsernamePasswordCredentials(
|
|
nvr.getUsername().trim(), nvr.getPassword().toCharArray()));
|
|
RequestConfig requestConfig = RequestConfig.custom()
|
|
.setConnectTimeout(CONNECT_TIMEOUT)
|
|
.setConnectionRequestTimeout(CONNECT_TIMEOUT)
|
|
.setResponseTimeout(RESPONSE_TIMEOUT)
|
|
.build();
|
|
try (CloseableHttpClient client = HttpClients.custom()
|
|
.setDefaultCredentialsProvider(credentialsProvider)
|
|
.setDefaultRequestConfig(requestConfig)
|
|
.build()) {
|
|
// 发起带认证和超时的 GET 请求。
|
|
return client.execute(new HttpGet(url), response -> {
|
|
int status = response.getCode();
|
|
// 读取响应体原始字节,避免 HTTP 客户端使用平台默认字符集破坏中文名称。
|
|
byte[] bytes = response.getEntity() == null
|
|
? new byte[0] : EntityUtils.toByteArray(response.getEntity());
|
|
// 将设备响应解码成 XML 文本;非 2xx 的响应体同样解码,交由调用方判定。
|
|
return new IsapiResponse(status, decode(bytes));
|
|
});
|
|
} catch (Exception ex) {
|
|
// 保留已知业务异常,其他异常包装为通道读取失败。
|
|
if (ex instanceof IllegalStateException) {
|
|
throw (IllegalStateException) ex;
|
|
}
|
|
throw new IllegalStateException("读取 NVR 通道失败,IP=" + nvr.getNvrIp()
|
|
+ ",原因=" + ex.getMessage(), ex);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 向 NVR 探测指定通道是否支持云台控制。
|
|
*
|
|
* <p>海康 ISAPI 的云台能力接口只对可云台通道返回设备能力结构;固定摄像机(枪机、
|
|
* 半球等)由设备明确拒绝。因此判定分三种结果,其中"未探测"绝不能当成"不支持",
|
|
* 否则接口本身不可用或凭据异常时会把整台 NVR 的通道全部置灰。</p>
|
|
*
|
|
* @param nvr NVR 连接与凭据
|
|
* @param channelId NVR 通道号,空值或无意义通道号直接返回未探测
|
|
* @return 1 支持,0 不支持,null 未探测(接口不可用、鉴权失败或响应无法判定)
|
|
*/
|
|
public Integer probePtzSupported(IvsNvr nvr, Integer channelId) {
|
|
// 云台能力接口由海康 ISAPI 提供,其他厂商保持"未探测",不发无效请求。
|
|
if (nvr == null || !"HIKVISION".equals(normalizeVendor(nvr.getVendor()))) {
|
|
log.info("该厂商暂不支持云台能力探测,nvrIp={},vendor={}",
|
|
nvr == null ? null : nvr.getNvrIp(),
|
|
nvr == null ? null : nvr.getVendor());
|
|
return null;
|
|
}
|
|
// 通道号缺失时无法拼出能力查询地址,保持"未探测"而不是让同步任务失败。
|
|
if (channelId == null || channelId <= 0) {
|
|
log.info("云台能力探测缺少有效通道号,保持未探测,nvrIp={},channel={}",
|
|
nvr.getNvrIp(), channelId);
|
|
return null;
|
|
}
|
|
IsapiResponse response;
|
|
try {
|
|
response = exchange(nvr, String.format(PTZ_CAPABILITIES_PATH, channelId));
|
|
} catch (Exception ex) {
|
|
// 网络异常或参数异常只记录原因,保持"未探测",下一轮同步重试。
|
|
log.error("云台能力探测失败,nvrIp={},channel={}:{}",
|
|
nvr.getNvrIp(), channelId, ex.getMessage());
|
|
return null;
|
|
}
|
|
String body = response.body == null ? "" : response.body;
|
|
// 能力接口返回设备能力结构即代表该通道可云台控制。
|
|
if ((response.status >= 200 && response.status < 300)
|
|
|| body.contains(PTZ_CAPABILITY_TAG)) {
|
|
log.info("云台能力探测:支持云台,nvrIp={},channel={}",
|
|
nvr.getNvrIp(), channelId);
|
|
return 1;
|
|
}
|
|
// 设备用标准 ISAPI 错误体明确拒绝时判为不支持;鉴权失败必须排除在外。
|
|
if (response.status != 401 && !body.contains(UNAUTHORIZED_TAG)
|
|
&& body.contains(ISAPI_ERROR_TAG)) {
|
|
log.info("云台能力探测:不支持云台,nvrIp={},channel={},http={},statusString={}",
|
|
nvr.getNvrIp(), channelId, response.status, extractStatusString(body));
|
|
return 0;
|
|
}
|
|
// 其余情况(接口不存在、网关错误、空响应等)无法判定,保持"未探测"。
|
|
log.info("云台能力探测结果无法判定,保持未探测,nvrIp={},channel={},http={},body={}",
|
|
nvr.getNvrIp(), channelId, response.status, abbreviate(body));
|
|
return null;
|
|
}
|
|
|
|
/** 提取 ISAPI 错误体中的 statusString,便于日志直接看出设备拒绝原因。 */
|
|
private String extractStatusString(String body) {
|
|
int start = body.indexOf("<statusString>");
|
|
int end = body.indexOf("</statusString>");
|
|
if (start < 0 || end <= start) {
|
|
return "";
|
|
}
|
|
return body.substring(start + "<statusString>".length(), end).trim();
|
|
}
|
|
|
|
/** 截断过长响应体,避免无法判定的设备响应把日志刷爆。 */
|
|
private String abbreviate(String body) {
|
|
String singleLine = body.replaceAll("\\s+", " ").trim();
|
|
return singleLine.length() <= 200 ? singleLine : singleLine.substring(0, 200) + "...";
|
|
}
|
|
|
|
/** ISAPI 响应:状态码与解码后的响应体。 */
|
|
private static final class IsapiResponse {
|
|
|
|
/** HTTP 状态码。 */
|
|
private final int status;
|
|
/** 解码后的响应体,空响应体为空串。 */
|
|
private final String body;
|
|
|
|
private IsapiResponse(int status, String body) {
|
|
this.status = status;
|
|
this.body = body == null ? "" : body;
|
|
}
|
|
}
|
|
|
|
/** 解析通道 XML,并合并通道在线状态。 */
|
|
private List<Channel> parseChannels(String xml, Map<Integer, Boolean> statuses) {
|
|
Document document = parseXml(xml);
|
|
NodeList nodes = document.getElementsByTagName("InputProxyChannel");
|
|
List<Channel> channels = new ArrayList<>();
|
|
// 遍历 NVR 返回的 InputProxyChannel 节点。
|
|
for (int i = 0; i < nodes.getLength(); i++) {
|
|
Element element = (Element) nodes.item(i);
|
|
Integer id = integerOf(element, "id");
|
|
// 缺少有效通道号的节点直接跳过。
|
|
if (id == null || id <= 0) {
|
|
continue;
|
|
}
|
|
Element source = firstElement(element, "sourceInputPortDescriptor");
|
|
String name = textOf(element, "name");
|
|
String protocol = textOf(source, "proxyProtocol");
|
|
String ip = textOf(source, "ipAddress");
|
|
Integer port = integerOf(source, "managePortNo");
|
|
Integer sourceChannel = integerOf(source, "srcInputPort");
|
|
Boolean online = statuses.get(id);
|
|
channels.add(new Channel(id, name, protocol, ip, port, sourceChannel,
|
|
online == null || online));
|
|
}
|
|
return channels;
|
|
}
|
|
|
|
/** 解析 NVR 通道状态 XML,生成通道号到在线状态的映射。 */
|
|
private Map<Integer, Boolean> parseStatuses(String xml) {
|
|
Document document = parseXml(xml);
|
|
NodeList nodes = document.getElementsByTagName("InputProxyChannelStatus");
|
|
Map<Integer, Boolean> statuses = new HashMap<>();
|
|
// 遍历通道状态节点并建立通道号到在线状态映射。
|
|
for (int i = 0; i < nodes.getLength(); i++) {
|
|
Element element = (Element) nodes.item(i);
|
|
Integer id = integerOf(element, "id");
|
|
// 只有有效通道号才记录状态。
|
|
if (id != null && id > 0) {
|
|
String online = textOf(element, "online");
|
|
statuses.put(id, "true".equalsIgnoreCase(online)
|
|
|| "1".equals(online));
|
|
}
|
|
}
|
|
return statuses;
|
|
}
|
|
|
|
/** 安全解析设备返回的 XML,并关闭外部实体能力。 */
|
|
private Document parseXml(String xml) {
|
|
// 空 XML 无法解析为通道列表。
|
|
if (isBlank(xml)) {
|
|
throw new IllegalStateException("NVR 返回空通道列表");
|
|
}
|
|
try {
|
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
|
factory.setNamespaceAware(false);
|
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
|
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
|
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
|
factory.setExpandEntityReferences(false);
|
|
return factory.newDocumentBuilder().parse(
|
|
new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8)));
|
|
} catch (Exception ex) {
|
|
throw new IllegalStateException("解析 NVR 通道 XML 失败:" + ex.getMessage(), ex);
|
|
}
|
|
}
|
|
|
|
/** 获取父元素下指定标签的第一个子元素。 */
|
|
private Element firstElement(Element parent, String tagName) {
|
|
// 没有父元素时无法查找子标签。
|
|
if (parent == null) {
|
|
return null;
|
|
}
|
|
NodeList list = parent.getElementsByTagName(tagName);
|
|
return list.getLength() == 0 ? null : (Element) list.item(0);
|
|
}
|
|
|
|
/** 读取 XML 元素的文本内容并去除首尾空白。 */
|
|
private String textOf(Element parent, String tagName) {
|
|
Element element = firstElement(parent, tagName);
|
|
// 子元素不存在或没有文本时返回空。
|
|
if (element == null || element.getTextContent() == null) {
|
|
return null;
|
|
}
|
|
String value = element.getTextContent().trim();
|
|
// 空白文本按缺失字段处理。
|
|
return value.isEmpty() ? null : value;
|
|
}
|
|
|
|
/** 读取 XML 标签并转换为整数,非法值返回空。 */
|
|
private Integer integerOf(Element parent, String tagName) {
|
|
String value = textOf(parent, tagName);
|
|
// 标签没有文本时不转换整数。
|
|
if (value == null) {
|
|
return null;
|
|
}
|
|
try {
|
|
// 将设备文本转换为通道号或管理端口。
|
|
return Integer.valueOf(value);
|
|
} catch (NumberFormatException ex) {
|
|
// 非数字内容返回空,由上层跳过该字段。
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 严格按 UTF-8 解码 NVR ISAPI 响应,防止把正常中文误判成 GBK 后写入乱码名称。
|
|
*
|
|
* @param bytes NVR 返回的 XML 原始字节
|
|
* @return UTF-8 解码后的 XML 文本
|
|
* @throws IllegalStateException 响应包含非法 UTF-8 字节时抛出,避免静默替换并污染数据库
|
|
*/
|
|
private String decode(byte[] bytes) {
|
|
// 空响应直接返回空文本。
|
|
if (bytes == null || bytes.length == 0) {
|
|
return "";
|
|
}
|
|
try {
|
|
// 使用 REPORT 模式校验每个字节,非 UTF-8 响应必须显式失败,不能生成替换字符后继续入库。
|
|
return StandardCharsets.UTF_8.newDecoder()
|
|
.onMalformedInput(CodingErrorAction.REPORT)
|
|
.onUnmappableCharacter(CodingErrorAction.REPORT)
|
|
.decode(ByteBuffer.wrap(bytes))
|
|
.toString();
|
|
} catch (CharacterCodingException ex) {
|
|
// 现场链路统一使用 UTF-8,发现其他编码时终止本次同步并暴露设备响应问题。
|
|
throw new IllegalStateException("NVR ISAPI 响应不是有效的 UTF-8", ex);
|
|
}
|
|
}
|
|
|
|
/** 判断字符串为空或只包含空白字符。 */
|
|
private boolean isBlank(String value) {
|
|
return value == null || value.trim().isEmpty();
|
|
}
|
|
|
|
@Data
|
|
@AllArgsConstructor
|
|
public static class Channel {
|
|
/** NVR 通道编号。 */
|
|
private Integer id;
|
|
/** 通道名称。 */
|
|
private String name;
|
|
/** 代理协议。 */
|
|
private String protocol;
|
|
/** 前端摄像机 IP。 */
|
|
private String ip;
|
|
/** 前端管理端口。 */
|
|
private Integer port;
|
|
/** 前端源通道号。 */
|
|
private Integer sourceChannel;
|
|
/** 是否在线。 */
|
|
private boolean online;
|
|
}
|
|
}
|