|
|
@@ -1,65 +1,670 @@
|
|
|
-//package cn.iocoder.yudao.module.pms.controller.admin.hikvision;
|
|
|
-//
|
|
|
-//import cn.hutool.core.util.StrUtil;
|
|
|
-//import cn.iocoder.yudao.module.pms.controller.admin.hikvision.vo.MonitorPointsPageReqVO;
|
|
|
-//import com.alibaba.fastjson.JSON;
|
|
|
-//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.tags.Tag;
|
|
|
-//import org.springframework.beans.factory.annotation.Value;
|
|
|
-//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.RestController;
|
|
|
-//
|
|
|
-//import java.util.HashMap;
|
|
|
-//import java.util.Map;
|
|
|
-//
|
|
|
-//@Tag(name = "管理后台 - hikvision 实时视频 录像相关接口")
|
|
|
-//@RequestMapping("/vms")
|
|
|
-//@RestController
|
|
|
-//@Validated
|
|
|
-//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;
|
|
|
-// try {
|
|
|
-// result = ArtemisHttpUtil.doPostStringArtemis(config, path, body, null, null, "application/json");
|
|
|
-// } catch (Exception e) {
|
|
|
-// result = "接口异常";
|
|
|
-// }
|
|
|
-// return result;
|
|
|
-// }
|
|
|
-//
|
|
|
-//}
|
|
|
+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;
|
|
|
+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 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.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestBody;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestParam;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+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;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 海康 Artemis 区域和监控点接口。
|
|
|
+ *
|
|
|
+ * <p>
|
|
|
+ * 树形列表采用懒加载方式:前端首次传入 {@code -1},管理员获取根区域,普通用户获取所属公司区域;
|
|
|
+ * 用户继续展开某个区域时,传入该区域的 {@code indexCode}。后端分别查询该区域的直接子区域和直属监控点,
|
|
|
+ * 并将两种资源转换成统一的 {@link HikvisionTreeNodeRespVO} 返回给前端。
|
|
|
+ * </p>
|
|
|
+ */
|
|
|
+@Tag(name = "管理后台 - hikvision 实时视频 录像相关接口")
|
|
|
+@RequestMapping("/vms")
|
|
|
+@RestController
|
|
|
+@Validated
|
|
|
+@Slf4j
|
|
|
+@RequiredArgsConstructor
|
|
|
+public class HikvisionController {
|
|
|
+
|
|
|
+ /** 获取根区域信息。 */
|
|
|
+ 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 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";
|
|
|
+ /** 获取监控点对讲地址。 */
|
|
|
+ private static final String TALK_URL_ADDRESS = "/api/video/v1/cameras/talkURLs";
|
|
|
+ /** 根据监控点编号进行云台控制。 */
|
|
|
+ private static final String PTZ_CONTROL_ADDRESS = "/api/video/v1/ptzs/controlling";
|
|
|
+ /** Web 页面通过海康 H5player 使用 WebSocket Secure 协议。 */
|
|
|
+ private static final String H5PLAYER_PROTOCOL = "wss";
|
|
|
+ /** 返回给前端的区域节点类型。 */
|
|
|
+ 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;
|
|
|
+ private final DeptApi deptApi;
|
|
|
+ private final PermissionApi permissionApi;
|
|
|
+ /** 负责将环境配置的 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 {
|
|
|
+ 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()));
|
|
|
+ }
|
|
|
+ return success(queryTreeChildren(regionIndexCode));
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("查询海康区域树节点失败,regionIndexCode: {}", regionIndexCode, e);
|
|
|
+ return error(500, "查询海康区域树节点失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 搜索监控点并返回完整的匹配树,不依赖前端已经展开的节点。
|
|
|
+ * 仅匹配监控点名称;目录只用于保留路径,公共目录合并,不带无关监控点。
|
|
|
+ * 搜索结果已填充全部 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 实时预览地址。
|
|
|
+ *
|
|
|
+ * <p>
|
|
|
+ * 海康接口除 {@code cameraIndexCode} 外的参数均为可选参数。这里固定传入
|
|
|
+ * {@code protocol=wss},并将前端选择的 {@code streamType} 原样传给海康平台。
|
|
|
+ * </p>
|
|
|
+ *
|
|
|
+ * @param cameraIndexCode 监控点唯一标识
|
|
|
+ * @param streamType 码流类型;0 为主码流,1 为子码流,默认主码流
|
|
|
+ * @return WSS 实时预览地址
|
|
|
+ */
|
|
|
+ @GetMapping("/preview-url")
|
|
|
+ @Operation(summary = "获取海康监控点 WSS 实时预览地址")
|
|
|
+ public CommonResult<String> getPreviewUrl(
|
|
|
+ @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);
|
|
|
+ params.put("protocol", H5PLAYER_PROTOCOL);
|
|
|
+ JSONObject data = parseArtemisData(invokeArtemis(PREVIEW_URL_ADDRESS, params));
|
|
|
+ String url = data.getString("url");
|
|
|
+ if (StrUtil.isBlank(url)) {
|
|
|
+ throw new IllegalStateException("海康预览地址响应缺少 url");
|
|
|
+ }
|
|
|
+ return success(url);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取海康监控点预览地址失败,cameraIndexCode: {}, streamType: {}",
|
|
|
+ cameraIndexCode, streamType, e);
|
|
|
+ return error(500, "获取海康监控点预览地址失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取监控点的 WSS 对讲地址。
|
|
|
+ *
|
|
|
+ * <p>
|
|
|
+ * 海康对讲地址有效期为 5 分钟。接口固定传入 {@code protocol=wss},传输协议使用平台默认的
|
|
|
+ * TCP;前端应在开始对讲时调用本接口获取最新地址。
|
|
|
+ * </p>
|
|
|
+ *
|
|
|
+ * @param cameraIndexCode 监控点唯一标识
|
|
|
+ * @return WSS 对讲地址
|
|
|
+ */
|
|
|
+ @GetMapping("/talk-url")
|
|
|
+ @Operation(summary = "获取海康监控点 WSS 对讲地址")
|
|
|
+ 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);
|
|
|
+ JSONObject data = parseArtemisData(invokeArtemis(TALK_URL_ADDRESS, params));
|
|
|
+ String url = data.getString("url");
|
|
|
+ if (StrUtil.isBlank(url)) {
|
|
|
+ throw new IllegalStateException("海康对讲地址响应缺少 url");
|
|
|
+ }
|
|
|
+ return success(url);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("获取海康监控点对讲地址失败,cameraIndexCode: {}", cameraIndexCode, e);
|
|
|
+ return error(500, "获取海康监控点对讲地址失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 控制监控点云台。
|
|
|
+ *
|
|
|
+ * <p>
|
|
|
+ * speed 和 presetIndex 是可选参数,仅在前端传值时转发给海康平台,避免不支持这些参数的命令
|
|
|
+ * 收到无意义字段。持续动作由前端先发送 action=0 开始,再发送 action=1 停止。
|
|
|
+ * </p>
|
|
|
+ *
|
|
|
+ * @param reqVO 云台控制参数
|
|
|
+ * @return 是否控制成功
|
|
|
+ */
|
|
|
+ @PostMapping("/ptz-control")
|
|
|
+ @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());
|
|
|
+ params.put("command", reqVO.getCommand());
|
|
|
+ if (reqVO.getSpeed() != null) {
|
|
|
+ params.put("speed", reqVO.getSpeed());
|
|
|
+ }
|
|
|
+ if (reqVO.getPresetIndex() != null) {
|
|
|
+ params.put("presetIndex", reqVO.getPresetIndex());
|
|
|
+ }
|
|
|
+ parseArtemisData(invokeArtemis(PTZ_CONTROL_ADDRESS, params));
|
|
|
+ return success(true);
|
|
|
+ } catch (Exception e) {
|
|
|
+ log.error("控制海康监控点云台失败,cameraIndexCode: {}, action: {}, command: {}",
|
|
|
+ reqVO.getCameraIndexCode(), reqVO.getAction(), reqVO.getCommand(), e);
|
|
|
+ return error(500, "控制海康监控点云台失败");
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 超级管理员以系统角色判断,不能通过组织名称获得管理员权限。 */
|
|
|
+ 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。
|
|
|
+ *
|
|
|
+ * @return 带有下一层节点的根区域
|
|
|
+ * @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");
|
|
|
+ }
|
|
|
+ return HikvisionTreeNodeRespVO.builder()
|
|
|
+ .indexCode(rootIndexCode)
|
|
|
+ .name(root.getString("name"))
|
|
|
+ .nodeType(NODE_TYPE_REGION)
|
|
|
+ // 根区域接口在部分版本中不返回 leaf,此时根据实际 children 判断。
|
|
|
+ .leaf(root.getBoolean("leaf"))
|
|
|
+ .parentIndexCode(root.getString("parentIndexCode"))
|
|
|
+ // 根区域接口在部分版本中不返回 available;能够成功查询到根区域时默认可用。
|
|
|
+ .available(getBooleanOrDefault(root, "available", true))
|
|
|
+ .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(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();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 自动分页查询海康列表接口,确保返回当前层的全部数据。
|
|
|
+ *
|
|
|
+ * <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 请求异常
|
|
|
+ */
|
|
|
+ String invokeArtemis(String address, Object body) throws Exception {
|
|
|
+ return ArtemisHttpUtil.doPostStringArtemis(
|
|
|
+ artemisConfig,
|
|
|
+ artemisConfiguration.buildArtemisPath(address),
|
|
|
+ JSON.toJSONString(body), null, null, "application/json");
|
|
|
+ }
|
|
|
+
|
|
|
+}
|