Просмотр исходного кода

pms 海康区域和监控点懒加载树接口

- 统一 Artemis SDK 配置及接口地址拼接
- 支持按区域懒加载子区域和直属监控点
- 规范区域与监控点树节点返回字段
- 移除旧监控点分页接口及请求 VO
Zimo 1 неделя назад
Родитель
Сommit
a1e61636db

+ 1 - 0
.gitignore

@@ -52,3 +52,4 @@ application-my.yaml
 
 /yudao-ui-app/unpackage/
 **/.DS_Store
+.vscode

+ 79 - 0
yudao-module-pms/yudao-module-pms-biz/src/main/java/cn/iocoder/yudao/module/pms/hikvision/HikvisionArtemisConfiguration.java

@@ -0,0 +1,79 @@
+package cn.iocoder.yudao.module.pms.hikvision;
+
+import com.hikvision.artemis.sdk.config.ArtemisConfig;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.Collections;
+import java.util.Map;
+
+/**
+ * 海康 Artemis SDK 配置。
+ *
+ * <p>该配置类完成两件事:</p>
+ * <ol>
+ *     <li>根据环境配置创建全局复用的 {@link ArtemisConfig} Bean;</li>
+ *     <li>将环境中的 {@code hikvision.artemisPath} 与具体 API 地址拼接成 SDK 要求的请求路径 Map。</li>
+ * </ol>
+ */
+@Configuration(proxyBeanMethods = false)
+public class HikvisionArtemisConfiguration {
+
+    /**
+     * Artemis 网关公共路径,例如 {@code /artemis}。
+     * 该值从当前 Spring Profile 对应的配置文件中读取。
+     */
+    private final String artemisPath;
+
+    /**
+     * 通过构造器注入 Artemis 公共路径,配置缺失时应用会在启动阶段直接报告错误。
+     *
+     * @param artemisPath Artemis 网关公共路径
+     */
+    public HikvisionArtemisConfiguration(@Value("${hikvision.artemisPath}") String artemisPath) {
+        this.artemisPath = artemisPath;
+    }
+
+    /**
+     * 创建海康 SDK 的全局单例配置。
+     *
+     * <p>host、appKey 和 appSecret 在应用启动时从当前环境配置注入,之后由所有海康接口共同复用,
+     * 避免每次请求都重新创建和填写 {@link ArtemisConfig}。</p>
+     *
+     * @param host Artemis 网关主机和端口,不包含协议和接口路径
+     * @param appKey 海康开放平台应用 Key
+     * @param appSecret 海康开放平台应用 Secret
+     * @return 配置完成的 Artemis SDK 对象
+     */
+    @Bean
+    public ArtemisConfig artemisConfig(
+            @Value("${hikvision.host}") String host,
+            @Value("${hikvision.appKey}") String appKey,
+            @Value("${hikvision.appSecret}") String appSecret) {
+        ArtemisConfig config = new ArtemisConfig();
+        config.setHost(host);
+        config.setAppKey(appKey);
+        config.setAppSecret(appSecret);
+        return config;
+    }
+
+    /**
+     * 构建海康 SDK 请求所需的路径 Map。
+     *
+     * <p>例如环境配置为 {@code artemisPath=/artemis},调用方传入
+     * {@code /api/resource/v2/regions/subRegions},最终得到:</p>
+     * <pre>{@code
+     * {"https://": "/artemis/api/resource/v2/regions/subRegions"}
+     * }</pre>
+     *
+     * <p>调用方传入的 address 必须以 {@code /} 开头,否则两个路径直接拼接后会缺少分隔符。</p>
+     *
+     * @param address 不包含 artemisPath 的海康 API 地址
+     * @return 以 {@code https://} 为 key、完整 Artemis 请求路径为 value 的不可变 Map
+     */
+    public Map<String, String> buildArtemisPath(String address) {
+        return Collections.singletonMap("https://", artemisPath + address);
+    }
+
+}

+ 270 - 37
yudao-module-pms/yudao-module-pms-biz/src/main/java/cn/iocoder/yudao/module/pms/hikvision/HikvisionController.java

@@ -1,65 +1,298 @@
 package cn.iocoder.yudao.module.pms.hikvision;
 
 import cn.hutool.core.util.StrUtil;
-import cn.iocoder.yudao.module.pms.hikvision.vo.MonitorPointsPageReqVO;
+import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.module.pms.hikvision.vo.HikvisionTreeNodeRespVO;
 import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
 import com.hikvision.artemis.sdk.ArtemisHttpUtil;
 import com.hikvision.artemis.sdk.config.ArtemisConfig;
 import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
 import io.swagger.v3.oas.annotations.tags.Tag;
-import org.springframework.beans.factory.annotation.Value;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.validation.annotation.Validated;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
+import javax.validation.constraints.NotBlank;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.error;
+import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
+
+/**
+ * 海康 Artemis 区域和监控点接口。
+ *
+ * <p>
+ * 树形列表采用懒加载方式:前端首次传入 {@code -1},后端先获取根区域,再加载根区域的下一层节点;
+ * 用户继续展开某个区域时,传入该区域的 {@code indexCode}。后端分别查询该区域的直接子区域和直属监控点,
+ * 并将两种资源转换成统一的 {@link HikvisionTreeNodeRespVO} 返回给前端。
+ * </p>
+ */
 @Tag(name = "管理后台 - hikvision 实时视频 录像相关接口")
 @RequestMapping("/vms")
 @RestController
 @Validated
+@Slf4j
+@RequiredArgsConstructor
 public class HikvisionController {
 
-    @Value("${hikvision.appKey}")
-    private String appKey;
-
-    @Value("${hikvision.appSecret}")
-    private String appSecret;
-
-    @Value("${hikvision.host}")
-    private String host;
-
-    @Value("${hikvision.artemisPath}")
-    private String artemisPath;
-
-    @Value("${hikvision.monitorPoints}")
-    private String monitorPoints;
-
-    @GetMapping("/monitorPoints")
-    @Operation(summary = "查询监控点列表v2")
-    public String monitorPoints(MonitorPointsPageReqVO pageReqVO) {
-        ArtemisConfig config = new ArtemisConfig();
-        config.setHost(host); // 代理API网关nginx服务器ip端口
-        config.setAppKey(appKey);  // 秘钥appkey
-        config.setAppSecret(appSecret);// 秘钥appSecret
-        Map<String, String> paramMap = new HashMap<String, String>();// post请求Form表单参数
-        paramMap.put("pageNo", pageReqVO.getPageNo().toString());
-        paramMap.put("pageSize", pageReqVO.getPageSize().toString());
-        String body = JSON.toJSON(paramMap).toString();
-        Map<String, String> path = new HashMap<String, String>(2) {
-            {
-                put("https://", monitorPoints);
-            }
-        };
-        String result = StrUtil.EMPTY;
+    /** 获取根区域信息。 */
+    private static final String ROOT_ADDRESS = "/api/resource/v1/regions/root";
+    /** 根据父区域编号查询直接子区域,不会返回更深层的孙区域。 */
+    private static final String SUB_REGIONS_ADDRESS = "/api/resource/v2/regions/subRegions";
+    /** 根据区域编号查询直属资源,不会查询子区域中的资源。 */
+    private static final String SUB_RESOURCES_ADDRESS = "/api/irds/v2/resource/subResources";
+    /** 返回给前端的区域节点类型。 */
+    private static final String NODE_TYPE_REGION = "region";
+    /** 返回给前端的监控点节点类型。 */
+    private static final String NODE_TYPE_CAMERA = "camera";
+    /** 海康接口用于筛选监控点资源及其所属区域树的资源类型。 */
+    private static final String RESOURCE_TYPE_CAMERA = "camera";
+    /** 海康接口单页允许查询的最大数量。 */
+    private static final int MAX_PAGE_SIZE = 1000;
+
+    /** 包含 host、appKey、appSecret 的海康 SDK 单例配置。 */
+    private final ArtemisConfig artemisConfig;
+    /** 负责将环境配置的 artemisPath 与具体接口地址拼接成 SDK 请求路径。 */
+    private final HikvisionArtemisConfiguration artemisConfiguration;
+
+    /**
+     * 获取树形列表节点。
+     *
+     * <p>
+     * 当 {@code regionIndexCode=-1} 时,先调用根区域接口获得真实的根区域 {@code indexCode},
+     * 再查询根区域的直接子区域和直属监控点,最终返回一个带有 {@code children} 的根节点。
+     * </p>
+     *
+     * <p>
+     * 当传入真实区域编号时,只返回该区域的下一层节点,供前端在用户展开区域时追加到对应节点。
+     * </p>
+     *
+     * @param regionIndexCode 当前展开区域的唯一标识;传 {@code -1} 表示加载根区域
+     * @return 根节点及其下一层,或者指定区域的下一层节点
+     */
+    @GetMapping("/tree")
+    @Operation(summary = "获取海康区域和监控点懒加载树")
+    public CommonResult<List<HikvisionTreeNodeRespVO>> treeChildren(
+            @Parameter(description = "区域唯一标识;传 -1 加载根节点及其下一层", required = true)
+            @RequestParam("regionIndexCode")
+            @NotBlank(message = "区域编号不能为空") String regionIndexCode) {
         try {
-            result = ArtemisHttpUtil.doPostStringArtemis(config, path, body, null, null, "application/json");
+            if ("-1".equals(regionIndexCode)) {
+                // 首次加载时保留根节点,并提前填充根节点的下一层 children。
+                return success(Collections.singletonList(queryRootWithChildren()));
+            }
+            return success(queryTreeChildren(regionIndexCode));
         } catch (Exception e) {
-            result = "接口异常";
+            log.error("查询海康区域树节点失败,regionIndexCode: {}", regionIndexCode, e);
+            return error(500, "查询海康区域树节点失败");
+        }
+    }
+
+    /**
+     * 获取根区域,并将根区域的下一层节点放入 children。
+     *
+     * @return 带有下一层节点的根区域
+     * @throws Exception Artemis SDK 请求异常,或根区域响应缺少 indexCode
+     */
+    private HikvisionTreeNodeRespVO queryRootWithChildren() throws Exception {
+        JSONObject root = parseArtemisData(invokeArtemis(ROOT_ADDRESS, Collections.emptyMap()));
+        String rootIndexCode = root.getString("indexCode");
+        if (StrUtil.isBlank(rootIndexCode)) {
+            throw new IllegalStateException("海康根区域响应缺少 indexCode");
+        }
+        List<HikvisionTreeNodeRespVO> children = queryTreeChildren(rootIndexCode);
+        return HikvisionTreeNodeRespVO.builder()
+                .indexCode(rootIndexCode)
+                .name(root.getString("name"))
+                .nodeType(NODE_TYPE_REGION)
+                // 根区域接口在部分版本中不返回 leaf,此时根据实际 children 判断。
+                .leaf(getBooleanOrDefault(root, "leaf", children.isEmpty()))
+                .parentIndexCode(root.getString("parentIndexCode"))
+                // 根区域接口在部分版本中不返回 available;能够成功查询到根区域时默认可用。
+                .available(getBooleanOrDefault(root, "available", true))
+                .children(children)
+                .build();
+    }
+
+    /**
+     * 查询指定区域的下一层节点,返回顺序为“直接子区域在前、直属监控点在后”。
+     *
+     * @param regionIndexCode 当前区域唯一标识
+     * @return 当前区域的下一层树节点
+     * @throws Exception Artemis SDK 请求异常
+     */
+    private List<HikvisionTreeNodeRespVO> queryTreeChildren(String regionIndexCode) throws Exception {
+        List<HikvisionTreeNodeRespVO> nodes = new ArrayList<>();
+        nodes.addAll(querySubRegions(regionIndexCode));
+        nodes.addAll(querySubCameras(regionIndexCode));
+        return nodes;
+    }
+
+    /**
+     * 查询父区域的所有直接子区域,并转换成统一树节点。
+     *
+     * @param parentIndexCode 父区域唯一标识
+     * @return 直接子区域节点
+     * @throws Exception Artemis SDK 请求异常
+     */
+    private List<HikvisionTreeNodeRespVO> querySubRegions(String parentIndexCode) throws Exception {
+        Map<String, Object> paramMap = new HashMap<>();
+        paramMap.put("parentIndexCode", parentIndexCode);
+        // resourceType=camera 表示只返回当前账号具有监控点相关权限的区域树。
+        paramMap.put("resourceType", RESOURCE_TYPE_CAMERA);
+        List<JSONObject> regions = queryAllPages(SUB_REGIONS_ADDRESS, paramMap);
+        List<HikvisionTreeNodeRespVO> nodes = new ArrayList<>(regions.size());
+        for (JSONObject region : regions) {
+            nodes.add(HikvisionTreeNodeRespVO.builder()
+                    .indexCode(region.getString("indexCode"))
+                    .name(region.getString("name"))
+                    .nodeType(NODE_TYPE_REGION)
+                    .leaf(region.getBoolean("leaf"))
+                    .parentIndexCode(region.getString("parentIndexCode"))
+                    .available(region.getBoolean("available"))
+                    .build());
+        }
+        return nodes;
+    }
+
+    /**
+     * 查询直接挂在指定区域下的所有监控点,并转换成统一树节点。
+     *
+     * <p>
+     * 使用 subResources 接口而不是包含子区域的查询接口,避免把孙区域中的监控点提前挂到当前区域,
+     * 从而保证懒加载树的父子层级正确且不会重复显示监控点。
+     * </p>
+     *
+     * @param regionIndexCode 当前区域唯一标识
+     * @return 当前区域的直属监控点节点
+     * @throws Exception Artemis SDK 请求异常
+     */
+    private List<HikvisionTreeNodeRespVO> querySubCameras(String regionIndexCode) throws Exception {
+        Map<String, Object> paramMap = new HashMap<>();
+        paramMap.put("regionIndexCode", regionIndexCode);
+        paramMap.put("resourceType", RESOURCE_TYPE_CAMERA);
+        List<JSONObject> cameras = queryAllPages(SUB_RESOURCES_ADDRESS, paramMap);
+        List<HikvisionTreeNodeRespVO> nodes = new ArrayList<>(cameras.size());
+        for (JSONObject camera : cameras) {
+            nodes.add(HikvisionTreeNodeRespVO.builder()
+                    .indexCode(camera.getString("indexCode"))
+                    .name(camera.getString("name"))
+                    .nodeType(NODE_TYPE_CAMERA)
+                    .parentIndexCode(camera.getString("regionIndexCode"))
+                    // 某些平台版本会扩展返回 available;没有该字段时,已查询到的监控点默认可用。
+                    .available(getBooleanOrDefault(camera, "available", true))
+                    // 保留平台实际返回的在线状态;未返回时保持 null,不伪造在线或离线状态。
+                    .onlineStatus(camera.getBoolean("onlineStatus"))
+                    // camera 不允许继续懒加载,响应中不需要 children 字段。
+                    .children(null)
+                    .build());
+        }
+        return nodes;
+    }
+
+    /**
+     * 自动分页查询海康列表接口,确保返回当前层的全部数据。
+     *
+     * <p>
+     * 调用方只需要提供业务查询条件,本方法负责补充 {@code pageNo} 和 {@code pageSize}。
+     * 每次使用海康允许的最大分页数量 1000;只要已累计数量仍小于响应中的 {@code total},
+     * 就继续查询下一页。这样即使某个区域下超过 1000 个节点,也不会漏数据。
+     * </p>
+     *
+     * @param address     海康接口相对地址
+     * @param queryParams 不包含分页字段的业务查询条件
+     * @return 所有分页中的对象列表
+     * @throws Exception Artemis SDK 请求异常,或海康接口返回失败
+     */
+    private List<JSONObject> queryAllPages(String address, Map<String, Object> queryParams) throws Exception {
+        List<JSONObject> result = new ArrayList<>();
+        int pageNo = 1;
+        while (true) {
+            // 每页复制一份参数,避免 pageNo/pageSize 修改调用方传入的原始 Map。
+            Map<String, Object> pageParams = new HashMap<>(queryParams);
+            pageParams.put("pageNo", pageNo);
+            pageParams.put("pageSize", MAX_PAGE_SIZE);
+            JSONObject data = parseArtemisData(invokeArtemis(address, pageParams));
+            JSONArray list = data.getJSONArray("list");
+            // 没有列表或列表为空,说明当前页及后续页均无可追加数据。
+            if (list == null || list.isEmpty()) {
+                break;
+            }
+            for (int i = 0; i < list.size(); i++) {
+                result.add(list.getJSONObject(i));
+            }
+            Integer total = data.getInteger("total");
+            // 部分接口可能不返回 total,此时以当前页作为最终结果,避免无限请求。
+            if (total == null || result.size() >= total) {
+                break;
+            }
+            pageNo++;
         }
         return result;
     }
 
-}
+    /**
+     * 读取海康响应中的布尔字段,并在当前接口版本未返回该字段时使用业务默认值。
+     *
+     * <p>{@link JSONObject#getBoolean(String)} 在字段不存在时返回 {@code null}。这里先保留接口的真实值,
+     * 只有返回值为 {@code null} 时才使用默认值,避免把接口明确返回的 {@code false} 错误覆盖为
+     * {@code true}。</p>
+     *
+     * @param source       海康接口返回的单个对象
+     * @param fieldName    要读取的字段名称
+     * @param defaultValue 字段不存在时采用的默认值
+     * @return 接口字段值,或业务默认值
+     */
+    private boolean getBooleanOrDefault(JSONObject source, String fieldName, boolean defaultValue) {
+        Boolean value = source.getBoolean(fieldName);
+        return value != null ? value : defaultValue;
+    }
+
+    /**
+     * 校验海康统一响应并取出 data 对象。
+     *
+     * @param response 海康接口原始 JSON 字符串
+     * @return 响应中的 data;接口未返回 data 时返回空对象
+     * @throws IllegalStateException 海康业务返回码不是 0
+     */
+    private JSONObject parseArtemisData(String response) {
+        JSONObject responseObject = JSON.parseObject(response);
+        // 海康成功码是字符串 "0";其他返回码都交由上层统一转换为本系统错误响应。
+        if (!"0".equals(responseObject.getString("code"))) {
+            throw new IllegalStateException("海康接口返回失败: " + responseObject.getString("msg"));
+        }
+        JSONObject data = responseObject.getJSONObject("data");
+        return data != null ? data : new JSONObject();
+    }
+
+    /**
+     * 执行最底层 Artemis POST 请求。
+     *
+     * <p>
+     * 此方法不吞掉异常,由公开的树形接口统一记录日志并转换为 {@link CommonResult} 错误响应。
+     * </p>
+     *
+     * @param address 不包含环境配置 artemisPath 的接口地址
+     * @param body    将被序列化为 JSON 的请求体
+     * @return 海康接口原始响应字符串
+     * @throws Exception Artemis SDK 请求异常
+     */
+    private String invokeArtemis(String address, Object body) throws Exception {
+        return ArtemisHttpUtil.doPostStringArtemis(
+                artemisConfig,
+                artemisConfiguration.buildArtemisPath(address),
+                JSON.toJSONString(body), null, null, "application/json");
+    }
+
+}

+ 75 - 0
yudao-module-pms/yudao-module-pms-biz/src/main/java/cn/iocoder/yudao/module/pms/hikvision/vo/HikvisionTreeNodeRespVO.java

@@ -0,0 +1,75 @@
+package cn.iocoder.yudao.module.pms.hikvision.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 区域树懒加载的统一节点结构。
+ *
+ * <p>海康的区域接口和资源接口返回的数据结构不同,该 VO 将两类数据统一为前端树组件可识别的格式。
+ * 前端通过 {@link #nodeType} 区分区域和监控点:区域节点可以继续请求子节点,监控点节点用于触发视频播放。</p>
+ */
+@Schema(description = "管理后台 - Hikvision 懒加载树节点 Response VO")
+@JsonInclude(JsonInclude.Include.NON_NULL)
+@Data
+@Builder
+public class HikvisionTreeNodeRespVO {
+
+    /**
+     * 海康资源唯一标识。
+     * 区域节点对应区域 indexCode,监控点节点对应摄像机 indexCode。
+     */
+    @Schema(description = "海康资源唯一标识", example = "root000000")
+    private String indexCode;
+
+    /** 区域名称或监控点名称,用于树节点文本展示。 */
+    @Schema(description = "节点名称", example = "C座算力中心")
+    private String name;
+
+    /** 节点类型:region 表示区域,camera 表示监控点。 */
+    @Schema(description = "节点类型:region-区域,camera-监控点", example = "region")
+    private String nodeType;
+
+    /**
+     * 是否为最终叶子节点。
+     * 该字段只用于区域节点;监控点节点不返回该字段。
+     */
+    @Schema(description = "区域是否为叶子节点;监控点节点不返回该字段")
+    private Boolean leaf;
+
+    /**
+     * 节点所属的直接父区域编号。
+     * 前端可使用该字段校验或重建父子关系。
+     */
+    @Schema(description = "父区域唯一标识", example = "root000000")
+    private String parentIndexCode;
+
+    /**
+     * 当前账号是否有权限操作该节点。
+     * 优先取海康接口返回的 available;当前接口版本未返回该字段时由后端提供默认值。
+     */
+    @Schema(description = "是否有权限操作该节点", example = "true")
+    private Boolean available;
+
+    /**
+     * 监控点在线状态,直接使用海康资源接口返回的 onlineStatus。
+     * 区域节点或当前平台版本未返回该字段时为 null。
+     */
+    @Schema(description = "监控点在线状态;区域节点不返回该值", example = "true")
+    private Boolean onlineStatus;
+
+    /**
+     * 已加载的直接子节点。
+     * 首次传入 -1 时,根节点会携带已加载的数据;尚未展开或没有子节点的区域返回空数组;
+     * 监控点节点不返回该字段。
+     */
+    @Schema(description = "已加载的直接子区域和直属监控点")
+    @Builder.Default
+    private List<HikvisionTreeNodeRespVO> children = Collections.emptyList();
+
+}

+ 0 - 18
yudao-module-pms/yudao-module-pms-biz/src/main/java/cn/iocoder/yudao/module/pms/hikvision/vo/MonitorPointsPageReqVO.java

@@ -1,18 +0,0 @@
-package cn.iocoder.yudao.module.pms.hikvision.vo;
-
-import cn.iocoder.yudao.framework.common.pojo.PageParam;
-import io.swagger.v3.oas.annotations.media.Schema;
-import lombok.Data;
-import lombok.EqualsAndHashCode;
-import lombok.ToString;
-
-@Schema(description = "管理后台 - hikvision Request VO")
-@Data
-@EqualsAndHashCode(callSuper = true)
-@ToString(callSuper = true)
-public class MonitorPointsPageReqVO extends PageParam {
-
-    @Schema(description = "日报名称", example = "名称,模糊搜索,最大长度32,若包含中文,最大长度指不超过按照指定编码的字节长度,即getBytes(\"utf-8\").length")
-    private String name;
-
-}