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;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
/**
|
|
* 按 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 CAPABILITIES_PATH = "/ISAPI/PTZCtrl/channels/%d/capabilities";
|
|
/** 云台能力文档根节点:新固件为 PTZChanelCap,旧固件为 PTZCap。 */
|
|
private static final String CAP_ROOT = "<PTZChanelCap";
|
|
/** 旧固件的云台能力文档根节点。 */
|
|
private static final String CAP_ROOT_LEGACY = "<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());
|
|
// 非 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 探测指定通道的功能能力,返回 JSON 字符串。
|
|
*
|
|
* <p>海康 ISAPI 的云台能力接口只对可云台通道返回能力文档;固定摄像机(枪机、半球等)
|
|
* 由设备以标准 ISAPI 错误体明确拒绝。能力文档中只有部分能力有明确取值,其余能力
|
|
* 不写入 JSON,由前端按"未知即可用"处理——宁可少置灰,也不能把可用的按钮误灰。</p>
|
|
*
|
|
* @param nvr NVR 连接与凭据
|
|
* @param channelId NVR 通道号,空值直接返回 null
|
|
* @return 能力 JSON,如 {"ptz":true,"wiper":true,"position3d":false};无法判定返回 null
|
|
*/
|
|
public String probeCapabilities(IvsNvr nvr, Integer channelId) {
|
|
// 云台能力接口由海康 ISAPI 提供,其他厂商保持未知,不发无效请求。
|
|
if (nvr == null || !"HIKVISION".equals(normalizeVendor(nvr.getVendor()))) {
|
|
return null;
|
|
}
|
|
// 通道号缺失时无法拼出能力查询地址,保持未知而不是让同步任务失败。
|
|
if (channelId == null || channelId <= 0) {
|
|
log.warn("能力探测缺少有效通道号,保持未知,nvrIp={}", nvr.getNvrIp());
|
|
return null;
|
|
}
|
|
IsapiResponse response;
|
|
try {
|
|
response = exchange(nvr, String.format(CAPABILITIES_PATH, channelId));
|
|
} catch (Exception ex) {
|
|
// 网络异常或参数异常只记录原因,保持未知,下一轮同步重试。
|
|
log.info("功能能力探测失败,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(CAP_ROOT) || body.contains(CAP_ROOT_LEGACY)) {
|
|
String capabilities = buildCapabilities(body);
|
|
log.info("功能能力探测:nvrIp={},channel={},capabilities={}",
|
|
nvr.getNvrIp(), channelId, capabilities);
|
|
return capabilities;
|
|
}
|
|
// 设备用标准 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, readXmlText(body, "statusString"));
|
|
return "{\"ptz\":false}";
|
|
}
|
|
// 其余情况(接口不存在、网关错误、空响应等)无法判定,保持未知。
|
|
log.info("功能能力探测结果无法判定,保持未知,nvrIp={},channel={},http={}",
|
|
nvr.getNvrIp(), channelId, response.status);
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 从云台能力文档中提取可判定的能力,拼成 JSON。
|
|
*
|
|
* <p>ptz:能返回能力文档即为支持。wiper:只在设备明确表示支持时写入 true,
|
|
* false 可能只是"不支持上报雨刷状态"而非"没有雨刷",因此不写入(保持未知)。
|
|
* position3d:true/false 语义明确,只要有该节点就写入。</p>
|
|
*
|
|
* @param xml 能力文档
|
|
* @return 定长顺序的 JSON 字符串,便于按字符串比对变化
|
|
*/
|
|
private String buildCapabilities(String xml) {
|
|
StringBuilder json = new StringBuilder(64);
|
|
json.append("{\"ptz\":true");
|
|
// 雨刷:不同固件字段名不同,按候选标签依次查找。
|
|
if (Boolean.TRUE.equals(readXmlBoolean(xml,
|
|
"wiperStatusSupport", "supportWiper", "wiperSupport"))) {
|
|
json.append(",\"wiper\":true");
|
|
}
|
|
// 3D 定位:设备给出明确布尔值时写入。
|
|
Boolean position3d = readXmlBoolean(xml,
|
|
"isSupportPosition3D", "supportPosition3D", "position3d");
|
|
if (position3d != null) {
|
|
json.append(",\"position3d\":").append(position3d.booleanValue());
|
|
}
|
|
return json.append('}').toString();
|
|
}
|
|
|
|
/** 按候选标签名读取 XML 中的布尔值,全部缺失返回 null。 */
|
|
private Boolean readXmlBoolean(String xml, String... tags) {
|
|
for (String tag : tags) {
|
|
String value = readXmlText(xml, tag);
|
|
if ("true".equalsIgnoreCase(value)) {
|
|
return Boolean.TRUE;
|
|
}
|
|
if ("false".equalsIgnoreCase(value)) {
|
|
return Boolean.FALSE;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 读取 XML 中指定标签的文本内容,标签不存在返回空串。 */
|
|
private String readXmlText(String xml, String tag) {
|
|
Matcher matcher = Pattern.compile("<" + tag + "\\s*>\\s*([^<]*?)\\s*</" + tag + ">")
|
|
.matcher(xml);
|
|
return matcher.find() ? matcher.group(1) : "";
|
|
}
|
|
|
|
/** 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;
|
|
}
|
|
}
|