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 = " 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()); // 非 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 字符串。 * *

海康 ISAPI 的云台能力接口只对可云台通道返回能力文档;固定摄像机(枪机、半球等) * 由设备以标准 ISAPI 错误体明确拒绝。能力文档中只有部分能力有明确取值,其余能力 * 不写入 JSON,由前端按"未知即可用"处理——宁可少置灰,也不能把可用的按钮误灰。

* * @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。 * *

ptz:能返回能力文档即为支持。wiper:只在设备明确表示支持时写入 true, * false 可能只是"不支持上报雨刷状态"而非"没有雨刷",因此不写入(保持未知)。 * position3d:true/false 语义明确,只要有该节点就写入。

* * @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*") .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 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; } }