Bläddra i källkod

feat(pms): 增加海康监控目录公司权限及搜索树

- 按超级管理员角色和所属公司限制目录、预览、对讲及云台访问
- 支持按监控点名称搜索,补齐并合并所属目录路径
- 增加名称二次过滤,避免返回不匹配的监控点
- 补充公司权限、搜索分页和目录组装测试
Zimo 6 dagar sedan
förälder
incheckning
c0b419c8c3

+ 281 - 21
yudao-module-pms/yudao-module-pms-biz/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/hikvision/HikvisionController.java

@@ -2,8 +2,13 @@ package cn.iocoder.yudao.module.pms.controller.admin.hikvision;
 
 import cn.hutool.core.util.StrUtil;
 import cn.iocoder.yudao.framework.common.pojo.CommonResult;
+import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
 import cn.iocoder.yudao.module.pms.controller.admin.hikvision.vo.HikvisionPtzControlReqVO;
 import cn.iocoder.yudao.module.pms.controller.admin.hikvision.vo.HikvisionTreeNodeRespVO;
+import cn.iocoder.yudao.module.system.api.dept.DeptApi;
+import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
+import cn.iocoder.yudao.module.system.api.permission.PermissionApi;
+import cn.iocoder.yudao.module.system.enums.permission.RoleCodeEnum;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
@@ -22,13 +27,21 @@ import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RequestParam;
 import org.springframework.web.bind.annotation.RestController;
 
-import javax.validation.Valid;
-import javax.validation.constraints.NotBlank;
+import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Deque;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Locale;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.nio.charset.StandardCharsets;
 import java.util.Map;
+import java.util.Set;
+import javax.validation.Valid;
+import javax.validation.constraints.NotBlank;
 
 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.error;
 import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -37,7 +50,7 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
  * 海康 Artemis 区域和监控点接口。
  *
  * <p>
- * 树形列表采用懒加载方式:前端首次传入 {@code -1},后端先获取根区域,再加载根区域的下一层节点
+ * 树形列表采用懒加载方式:前端首次传入 {@code -1},管理员获取根区域,普通用户获取所属公司区域
  * 用户继续展开某个区域时,传入该区域的 {@code indexCode}。后端分别查询该区域的直接子区域和直属监控点,
  * 并将两种资源转换成统一的 {@link HikvisionTreeNodeRespVO} 返回给前端。
  * </p>
@@ -56,6 +69,10 @@ public class HikvisionController {
     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 CAMERA_SEARCH_ADDRESS = "/api/resource/v2/camera/search";
+    /** 批量获取区域详情,用 parentIndexCode 还原监控点的完整目录。 */
+    private static final String REGION_INFO_ADDRESS = "/api/resource/v1/region/regionCatalog/regionInfo";
     /** 获取监控点实时预览地址。 */
     private static final String PREVIEW_URL_ADDRESS = "/api/video/v2/cameras/previewURLs";
     /** 获取监控点对讲地址。 */
@@ -75,6 +92,8 @@ public class HikvisionController {
 
     /** 包含 host、appKey、appSecret 的海康 SDK 单例配置。 */
     private final ArtemisConfig artemisConfig;
+    private final DeptApi deptApi;
+    private final PermissionApi permissionApi;
     /** 负责将环境配置的 artemisPath 与具体接口地址拼接成 SDK 请求路径。 */
     private final HikvisionArtemisConfiguration artemisConfiguration;
 
@@ -83,11 +102,13 @@ public class HikvisionController {
      *
      * <p>
      * 当 {@code regionIndexCode=-1} 时,先调用根区域接口获得真实的根区域 {@code indexCode},
-     * 再查询根区域的直接子区域和直属监控点,最终返回一个带有 {@code children} 的根节点。
+     * 管理员返回带有 {@code children} 的根节点;普通用户仅返回名称匹配的公司节点及其下一层,
+     * 未匹配到授权公司时返回空列表。
      * </p>
      *
      * <p>
-     * 当传入真实区域编号时,只返回该区域的下一层节点,供前端在用户展开区域时追加到对应节点。
+     * 当传入真实区域编号时,先校验该区域位于授权公司子树内,再返回下一层节点。
+     * 超级管理员不受公司范围限制。
      * </p>
      *
      * @param regionIndexCode 当前展开区域的唯一标识;传 {@code -1} 表示加载根区域
@@ -98,6 +119,20 @@ public class HikvisionController {
     public CommonResult<List<HikvisionTreeNodeRespVO>> treeChildren(
             @Parameter(description = "区域唯一标识;传 -1 加载根节点及其下一层", required = true) @RequestParam("regionIndexCode") @NotBlank(message = "区域编号不能为空") String regionIndexCode) {
         try {
+            if (!isSuperAdmin()) {
+                HikvisionTreeNodeRespVO company = queryAllowedCompany();
+                if (company == null) {
+                    return "-1".equals(regionIndexCode) ? success(Collections.emptyList())
+                            : error(403, "无权查看该监控目录");
+                }
+                if ("-1".equals(regionIndexCode)) {
+                    company.setChildren(queryTreeChildren(company.getIndexCode()));
+                    return success(Collections.singletonList(company));
+                }
+                if (!containsResource(company.getIndexCode(), regionIndexCode, false)) {
+                    return error(403, "无权查看该监控目录");
+                }
+            }
             if ("-1".equals(regionIndexCode)) {
                 // 首次加载时保留根节点,并提前填充根节点的下一层 children。
                 return success(Collections.singletonList(queryRootWithChildren()));
@@ -109,6 +144,129 @@ public class HikvisionController {
         }
     }
 
+    /**
+     * 搜索监控点并返回完整的匹配树,不依赖前端已经展开的节点。
+     * 仅匹配监控点名称;目录只用于保留路径,公共目录合并,不带无关监控点。
+     * 搜索结果已填充全部 children,前端应直接展示,清空关键字后恢复普通懒加载树。
+     */
+    @GetMapping("/tree/search")
+    @Operation(summary = "按监控点名称搜索并返回所属目录树")
+    public CommonResult<List<HikvisionTreeNodeRespVO>> searchTree(
+            @Parameter(description = "监控点名称,模糊匹配,最多32个UTF-8字节;空值恢复初始树")
+            @RequestParam(value = "keyword", required = false) String keyword) {
+        String name = StrUtil.trim(keyword);
+        if (StrUtil.isBlank(name)) {
+            return treeChildren("-1");
+        }
+        if (name.getBytes(StandardCharsets.UTF_8).length > 32) {
+            return error(400, "监控点名称不能超过32个UTF-8字节");
+        }
+        try {
+            HikvisionTreeNodeRespVO root = isSuperAdmin() ? queryRoot() : queryAllowedCompany();
+            if (root == null) {
+                return success(Collections.emptyList());
+            }
+            Map<String, Object> params = new HashMap<>();
+            params.put("name", name);
+            params.put("regionIndexCodes", Collections.singletonList(root.getIndexCode()));
+            params.put("isSubRegion", true);
+            List<JSONObject> cameras = queryAllPages(CAMERA_SEARCH_ADDRESS, params);
+            // 海康返回结果仍需按显示名称核对,避免平台模糊搜索返回无关监控点。
+            // 在全部分页读取完成后过滤,不能因为某一页没有命中而漏掉后续页。
+            String keywordLowerCase = name.toLowerCase(Locale.ROOT);
+            cameras.removeIf(camera -> camera == null || camera.getString("name") == null
+                    || !camera.getString("name").toLowerCase(Locale.ROOT).contains(keywordLowerCase));
+            return success(buildSearchTree(root, cameras));
+        } catch (Exception e) {
+            log.error("搜索海康监控点目录树失败", e);
+            return error(500, "搜索海康监控点目录树失败");
+        }
+    }
+
+    /** 批量补齐祖先区域,并仅挂载最终能追溯到授权根节点的匹配监控点。 */
+    private List<HikvisionTreeNodeRespVO> buildSearchTree(HikvisionTreeNodeRespVO root,
+                                                        List<JSONObject> cameras) throws Exception {
+        Map<String, HikvisionTreeNodeRespVO> regions = new LinkedHashMap<>();
+        root.setChildren(new ArrayList<>());
+        regions.put(root.getIndexCode(), root);
+        Set<String> pending = new LinkedHashSet<>();
+        for (JSONObject camera : cameras) {
+            String regionCode = camera.getString("regionIndexCode");
+            if (StrUtil.isNotBlank(regionCode)) {
+                pending.add(regionCode);
+            }
+        }
+        Set<String> queried = new HashSet<>();
+        queried.add(root.getIndexCode());
+        // 每轮批量查询一层祖先。已查询集合同时阻止缺失节点重试和异常目录环路。
+        while (!pending.isEmpty()) {
+            pending.removeAll(queried);
+            if (pending.isEmpty()) {
+                break;
+            }
+            List<String> ids = new ArrayList<>(pending);
+            pending.clear();
+            queried.addAll(ids);
+            for (int start = 0; start < ids.size(); start += MAX_PAGE_SIZE) {
+                List<String> batch = ids.subList(start, Math.min(start + MAX_PAGE_SIZE, ids.size()));
+                JSONObject data = parseArtemisData(invokeArtemis(REGION_INFO_ADDRESS,
+                        Collections.singletonMap("indexCodes", batch)));
+                JSONArray list = data.getJSONArray("list");
+                if (list == null) {
+                    continue;
+                }
+                for (int i = 0; i < list.size(); i++) {
+                    JSONObject region = list.getJSONObject(i);
+                    String code = region.getString("indexCode");
+                    if (!batch.contains(code) || regions.containsKey(code)) {
+                        continue;
+                    }
+                    String parentCode = region.getString("parentIndexCode");
+                    regions.put(code, HikvisionTreeNodeRespVO.builder()
+                            .indexCode(code).name(region.getString("name"))
+                            .nodeType(NODE_TYPE_REGION).parentIndexCode(parentCode)
+                            .available(getBooleanOrDefault(region, "available", true))
+                            .children(new ArrayList<>()).build());
+                    if (StrUtil.isNotBlank(parentCode) && !"-1".equals(parentCode)) {
+                        pending.add(parentCode);
+                    }
+                }
+            }
+        }
+        Set<String> attachedRegions = new HashSet<>();
+        Set<String> attachedCameras = new HashSet<>();
+        for (JSONObject camera : cameras) {
+            String cameraCode = camera.getString("indexCode");
+            if (StrUtil.isBlank(cameraCode) || attachedCameras.contains(cameraCode)) {
+                continue;
+            }
+            HikvisionTreeNodeRespVO region = regions.get(camera.getString("regionIndexCode"));
+            List<HikvisionTreeNodeRespVO> path = new ArrayList<>();
+            Set<String> visited = new HashSet<>();
+            while (region != null && !root.getIndexCode().equals(region.getIndexCode())
+                    && visited.add(region.getIndexCode())) {
+                path.add(region);
+                region = regions.get(region.getParentIndexCode());
+            }
+            if (region == null || !root.getIndexCode().equals(region.getIndexCode())) {
+                continue; // 缺失路径、环路或其他公司的监控点均不得出现在响应里。
+            }
+            HikvisionTreeNodeRespVO parent = root;
+            for (int i = path.size() - 1; i >= 0; i--) {
+                HikvisionTreeNodeRespVO child = path.get(i);
+                if (attachedRegions.add(child.getIndexCode())) {
+                    parent.getChildren().add(child);
+                    parent.setLeaf(false);
+                }
+                parent = child;
+            }
+            parent.getChildren().add(toCameraNode(camera));
+            parent.setLeaf(false);
+            attachedCameras.add(cameraCode);
+        }
+        return root.getChildren().isEmpty() ? Collections.emptyList() : Collections.singletonList(root);
+    }
+
     /**
      * 获取监控点的 WSS 实时预览地址。
      *
@@ -127,6 +285,9 @@ public class HikvisionController {
             @Parameter(description = "监控点唯一标识", required = true) @RequestParam("cameraIndexCode") @NotBlank(message = "监控点编号不能为空") String cameraIndexCode,
             @Parameter(description = "码流类型:0 主码流,1 子码流", example = "0") @RequestParam(value = "streamType", defaultValue = "0") Integer streamType) {
         try {
+            if (!canAccessCamera(cameraIndexCode)) {
+                return error(403, "无权访问该监控点");
+            }
             Map<String, Object> params = new HashMap<>();
             params.put("cameraIndexCode", cameraIndexCode);
             params.put("streamType", streamType);
@@ -160,6 +321,9 @@ public class HikvisionController {
     public CommonResult<String> getTalkUrl(
             @Parameter(description = "监控点唯一标识", required = true) @RequestParam("cameraIndexCode") @NotBlank(message = "监控点编号不能为空") String cameraIndexCode) {
         try {
+            if (!canAccessCamera(cameraIndexCode)) {
+                return error(403, "无权访问该监控点");
+            }
             Map<String, Object> params = new HashMap<>();
             params.put("cameraIndexCode", cameraIndexCode);
             params.put("protocol", H5PLAYER_PROTOCOL);
@@ -190,6 +354,9 @@ public class HikvisionController {
     @Operation(summary = "控制海康监控点云台")
     public CommonResult<Boolean> controlPtz(@Valid @RequestBody HikvisionPtzControlReqVO reqVO) {
         try {
+            if (!canAccessCamera(reqVO.getCameraIndexCode())) {
+                return error(403, "无权访问该监控点");
+            }
             Map<String, Object> params = new HashMap<>();
             params.put("cameraIndexCode", reqVO.getCameraIndexCode());
             params.put("action", reqVO.getAction());
@@ -209,6 +376,89 @@ public class HikvisionController {
         }
     }
 
+    /** 超级管理员以系统角色判断,不能通过组织名称获得管理员权限。 */
+    private boolean isSuperAdmin() {
+        Long userId = SecurityFrameworkUtils.getLoginUserId();
+        return userId != null && permissionApi.hasAnyRoles(userId, RoleCodeEnum.SUPER_ADMIN.getCode());
+    }
+
+    /** 从登录组织向上找到最近的公司;不匹配、组织缺失或组织成环时拒绝授权。 */
+    private HikvisionTreeNodeRespVO queryAllowedCompany() throws Exception {
+        if (SecurityFrameworkUtils.getLoginUserId() == null) {
+            return null;
+        }
+        Long deptId = SecurityFrameworkUtils.getLoginUserDeptId();
+        Set<Long> visited = new HashSet<>();
+        while (deptId != null && deptId != 0L && visited.add(deptId)) {
+            DeptRespDTO dept = deptApi.getDeptNoPermission(deptId);
+            if (dept == null) {
+                return null;
+            }
+            if ("1".equals(dept.getType())) {
+                String companyName = dept.getName();
+                if (!"四川瑞都".equals(companyName) && !"瑞恒兴域".equals(companyName)) {
+                    return null;
+                }
+                JSONObject root = parseArtemisData(invokeArtemis(ROOT_ADDRESS, Collections.emptyMap()));
+                String rootCode = root.getString("indexCode");
+                if (StrUtil.isBlank(rootCode)) {
+                    throw new IllegalStateException("海康根区域响应缺少 indexCode");
+                }
+                for (HikvisionTreeNodeRespVO region : querySubRegions(rootCode)) {
+                    if (companyName.equals(region.getName()) && StrUtil.isNotBlank(region.getIndexCode())) {
+                        return region;
+                    }
+                }
+                return null;
+            }
+            deptId = dept.getParentId();
+        }
+        return null;
+    }
+
+    private boolean canAccessCamera(String cameraIndexCode) throws Exception {
+        if (isSuperAdmin()) {
+            return true;
+        }
+        HikvisionTreeNodeRespVO company = queryAllowedCompany();
+        return company != null && containsResource(company.getIndexCode(), cameraIndexCode, true);
+    }
+
+    /**
+     * 只遍历已授权公司的子树,校验客户端传入的区域或监控点编号。
+     * 每次从海康读取归属,避免目录移动后继续使用过期授权;visited 防止异常环路。
+     */
+    private boolean containsResource(String companyCode, String targetCode, boolean camera) throws Exception {
+        if (StrUtil.isBlank(targetCode)) {
+            return false;
+        }
+        Deque<String> pending = new ArrayDeque<>();
+        Set<String> visited = new HashSet<>();
+        pending.add(companyCode);
+        while (!pending.isEmpty()) {
+            String regionCode = pending.removeFirst();
+            if (!visited.add(regionCode)) {
+                continue;
+            }
+            if (!camera && regionCode.equals(targetCode)) {
+                return true;
+            }
+            if (camera) {
+                for (HikvisionTreeNodeRespVO node : querySubCameras(regionCode)) {
+                    if (targetCode.equals(node.getIndexCode())) {
+                        return true;
+                    }
+                }
+            }
+            for (HikvisionTreeNodeRespVO region : querySubRegions(regionCode)) {
+                if (StrUtil.isNotBlank(region.getIndexCode())) {
+                    pending.addLast(region.getIndexCode());
+                }
+            }
+        }
+        return false;
+    }
+
     /**
      * 获取根区域,并将根区域的下一层节点放入 children。
      *
@@ -216,22 +466,30 @@ public class HikvisionController {
      * @throws Exception Artemis SDK 请求异常,或根区域响应缺少 indexCode
      */
     private HikvisionTreeNodeRespVO queryRootWithChildren() throws Exception {
+        HikvisionTreeNodeRespVO root = queryRoot();
+        List<HikvisionTreeNodeRespVO> children = queryTreeChildren(root.getIndexCode());
+        root.setChildren(children);
+        if (root.getLeaf() == null) {
+            root.setLeaf(children.isEmpty());
+        }
+        return root;
+    }
+
+    private HikvisionTreeNodeRespVO queryRoot() 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()))
+                .leaf(root.getBoolean("leaf"))
                 .parentIndexCode(root.getString("parentIndexCode"))
                 // 根区域接口在部分版本中不返回 available;能够成功查询到根区域时默认可用。
                 .available(getBooleanOrDefault(root, "available", true))
-                .children(children)
                 .build();
     }
 
@@ -295,22 +553,24 @@ public class HikvisionController {
         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());
+            nodes.add(toCameraNode(camera));
         }
         return nodes;
     }
 
+    private HikvisionTreeNodeRespVO toCameraNode(JSONObject camera) {
+        return HikvisionTreeNodeRespVO.builder()
+                .indexCode(camera.getString("indexCode"))
+                .name(camera.getString("name"))
+                .nodeType(NODE_TYPE_CAMERA)
+                .parentIndexCode(camera.getString("regionIndexCode"))
+                .available(getBooleanOrDefault(camera, "available", true))
+                // 搜索接口未提供在线状态时保持 null。
+                .onlineStatus(camera.getBoolean("onlineStatus"))
+                .children(null)
+                .build();
+    }
+
     /**
      * 自动分页查询海康列表接口,确保返回当前层的全部数据。
      *
@@ -400,7 +660,7 @@ public class HikvisionController {
      * @return 海康接口原始响应字符串
      * @throws Exception Artemis SDK 请求异常
      */
-    private String invokeArtemis(String address, Object body) throws Exception {
+    String invokeArtemis(String address, Object body) throws Exception {
         return ArtemisHttpUtil.doPostStringArtemis(
                 artemisConfig,
                 artemisConfiguration.buildArtemisPath(address),