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 = " 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 listHikChannels(IvsNvr nvr) { // 读取 NVR 通道基本信息 XML。 String channelsXml = request(nvr, "/ISAPI/ContentMgmt/InputProxy/channels"); Map 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 接口,并返回状态码与响应体。 * *

云台能力探测需要区分"设备明确拒绝"与"接口本身不可用",因此不能像 * {@link #request} 那样把非 2xx 一律当异常抛出。

* * @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 探测指定通道是否支持云台控制。 * *

海康 ISAPI 的云台能力接口只对可云台通道返回设备能力结构;固定摄像机(枪机、 * 半球等)由设备明确拒绝。因此判定分三种结果,其中"未探测"绝不能当成"不支持", * 否则接口本身不可用或凭据异常时会把整台 NVR 的通道全部置灰。

* * @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(""); int end = body.indexOf(""); if (start < 0 || end <= start) { return ""; } return body.substring(start + "".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 parseChannels(String xml, Map statuses) { Document document = parseXml(xml); NodeList nodes = document.getElementsByTagName("InputProxyChannel"); List 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 parseStatuses(String xml) { Document document = parseXml(xml); NodeList nodes = document.getElementsByTagName("InputProxyChannelStatus"); Map 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; } }