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.
 
 

424 lines
20 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;
/**
* 按 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;
}
}