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.
 
 

457 lines
21 KiB

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