فهرست منبع

Merge remote-tracking branch 'origin/master'

zhangcl 1 هفته پیش
والد
کامیت
68a5e7f20c

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

@@ -0,0 +1,88 @@
+package cn.iocoder.yudao.module.pms.controller.admin.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);
+    }
+
+}

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

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

+ 56 - 0
yudao-module-pms/yudao-module-pms-biz/src/main/java/cn/iocoder/yudao/module/pms/controller/admin/hikvision/vo/HikvisionPtzControlReqVO.java

@@ -0,0 +1,56 @@
+package cn.iocoder.yudao.module.pms.controller.admin.hikvision.vo;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import javax.validation.constraints.AssertTrue;
+import javax.validation.constraints.Max;
+import javax.validation.constraints.Min;
+import javax.validation.constraints.NotBlank;
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Pattern;
+
+/**
+ * 海康监控点云台控制请求。
+ */
+@Schema(description = "管理后台 - Hikvision 监控点云台控制 Request VO")
+@Data
+public class HikvisionPtzControlReqVO {
+
+    private static final String COMMAND_PATTERN = "(?i)^(LEFT|RIGHT|UP|DOWN|ZOOM_IN|ZOOM_OUT|LEFT_UP|"
+            + "LEFT_DOWN|RIGHT_UP|RIGHT_DOWN|FOCUS_NEAR|FOCUS_FAR|IRIS_ENLARGE|IRIS_REDUCE|WIPER_SWITCH|"
+            + "START_RECORD_TRACK|STOP_RECORD_TRACK|START_TRACK|STOP_TRACK|GOTO_PRESET)$";
+
+    @Schema(description = "监控点唯一标识", requiredMode = Schema.RequiredMode.REQUIRED,
+            example = "748d84750e3a4a5bbad3cd4af9ed5101")
+    @NotBlank(message = "监控点编号不能为空")
+    private String cameraIndexCode;
+
+    @Schema(description = "操作类型:0 开始,1 停止", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
+    @NotNull(message = "操作类型不能为空")
+    @Min(value = 0, message = "操作类型只能为 0 或 1")
+    @Max(value = 1, message = "操作类型只能为 0 或 1")
+    private Integer action;
+
+    @Schema(description = "云台命令,不区分大小写", requiredMode = Schema.RequiredMode.REQUIRED, example = "LEFT")
+    @NotBlank(message = "云台命令不能为空")
+    @Pattern(regexp = COMMAND_PATTERN, message = "云台命令不受支持")
+    private String command;
+
+    @Schema(description = "云台速度,范围 1-100;不传时由海康平台使用默认值", example = "50")
+    @Min(value = 1, message = "云台速度不能小于 1")
+    @Max(value = 100, message = "云台速度不能大于 100")
+    private Integer speed;
+
+    @Schema(description = "预置点编号;command=GOTO_PRESET 时必填,通常不超过 300", example = "1")
+    @Min(value = 1, message = "预置点编号不能小于 1")
+    private Integer presetIndex;
+
+    @AssertTrue(message = "调用预置点时预置点编号不能为空")
+    @JsonIgnore
+    public boolean isPresetIndexValid() {
+        return command == null || !"GOTO_PRESET".equalsIgnoreCase(command) || presetIndex != null;
+    }
+
+}

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

@@ -0,0 +1,77 @@
+package cn.iocoder.yudao.module.pms.controller.admin.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/controller/admin/hikvision/vo/MonitorPointsPageReqVO.java

@@ -1,18 +0,0 @@
-package cn.iocoder.yudao.module.pms.controller.admin.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;
-
-}

+ 123 - 19
yudao-server/src/main/resources/application-test.yaml

@@ -1,13 +1,22 @@
 server:
-  port: 48080
-
+  port: 58080
+  broker:
+    enabled: true           # mqttBroker类型选择, true: 基于netty的mqttBroker和webSocket  false: emq的mqttBroker
+    broker-node: node1       # 服务器集群节点
+    port: 1883
+    openws: true             # 控制webSocket是否开启
+    websocket-port: 8083
+    websocket-path: /mqtt
+    keep-alive: 70
+  tomcat:
+    connection-timeout: 120000
 --- #################### 数据库相关配置 ####################
 
 spring:
-#  autoconfigure:
-#    exclude:
-#      - org.springframework.ai.autoconfigure.vectorstore.qdrant.QdrantVectorStoreAutoConfiguration # 禁用 AI 模块的 Qdrant,手动创建
-#      - org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus,手动创建
+  #  autoconfigure:
+  #    exclude:
+  #      - org.springframework.ai.autoconfigure.vectorstore.qdrant.QdrantVectorStoreAutoConfiguration # 禁用 AI 模块的 Qdrant,手动创建
+  #      - org.springframework.ai.autoconfigure.vectorstore.milvus.MilvusVectorStoreAutoConfiguration # 禁用 AI 模块的 Milvus,手动创建
   # 数据源配置项
   autoconfigure:
     exclude:
@@ -52,20 +61,19 @@ spring:
           password: .N_Mdq!BR1W4
         slave: # 模拟从库,可根据自己需要修改 # 模拟从库,可根据自己需要修改
           lazy: true # 开启懒加载,保证启动速度
-#          url: jdbc:mysql://1.94.244.160:3306/rqiot?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
+          #          url: jdbc:mysql://1.94.244.160:3306/rqiot?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true&rewriteBatchedStatements=true # MySQL Connector/J 8.X 连接的示例
           url: jdbc:mysql://172.21.20.20:3306/yanfan?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=true&serverTimezone=GMT%2B8
           username: ruiqi
           password: .N_Mdq!BR1W4
-        # TDengine数据库
         tdengine:
           enabled: true
           url: jdbc:TAOS-RS://172.21.10.65:6041/iot_log?allowMultiQueries=true
           username: root
           password: taosdata
           driver-class-name: com.taosdata.jdbc.rs.RestfulDriver
-            #driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver
+          #driver-class-name: com.taosdata.jdbc.ws.WebSocketDriver
           jpa:
-            show-sql: true #JPA是否显示sql语句
+            show-sql: false #JPA是否显示sql语句
             # 使用mysql的方言,否则报错
             database-platform: org.hibernate.dialect.MySQLDialect
         yanfan:
@@ -77,8 +85,13 @@ spring:
   redis:
     host: localhost # 地址
     port: 6379 # 端口
-    database: 0  # 数据库索引
-    password: 123456
+    username: default
+    database: 1 # 数据库索引
+    password: r1q6IrEp@4s # 密码,建议生产环境开启
+
+mybatis-plus:
+  configuration:
+    log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl
 
 --- #################### 定时任务相关配置 ####################
 
@@ -164,9 +177,9 @@ logging:
 
 --- #################### 钉微应用相关配置 ####################
 dingtalk:
-  AGENT_ID: 2474111366  # DeepOil 微应用 agent_id
-  APP_KEY: dingh3nuuagvbkahgvjh # 钉钉微应用 appkey
-  APP_SECRET: iZ6sPUELzmwMBIldGzvoBhE8SNhbDzmFxhQOcStClxfZR-qAN_PJ_8qRxiC1kQW8 # 钉钉微应用 appkey
+  AGENT_ID: 3687646006  # DeepOil 微应用 agent_id
+  APP_KEY: dingmr9ez0ecgbmscfeb # 钉钉微应用 appkey
+  APP_SECRET: VhG_zMdTvIBwA_0Ef8FJ0foH3VYYo5T-kw0ukX_PBA8Ah1xl7AjDw5RVYCU0DTpe # 钉钉微应用 appkey
   GET_ACCESS_TOKEN_URL: https://oapi.dingtalk.com/gettoken  # 获取access_token
   URL_GET_USERINFO_BYCODE: https://oapi.dingtalk.com/sns/getuserinfo_bycode # 通过二维码扫码获取UNIONID
   URL_GET_USERINFO_BYUNIONID: https://oapi.dingtalk.com/topapi/user/getbyunionid # 通过UNIONID获取用户信息
@@ -187,6 +200,13 @@ sap:
     pool_capacity: 10
     peak_limit: 20
 
+--- #################### hikvision 相关配置 ####################
+hikvision:
+  appKey: 24712355
+  appSecret: TcNp8k8UX80X99sXaOds
+  host: vms.deepoil.cc    # vms.deepoil.cc:1443
+  artemisPath: /artemis   #监控点列表v2
+
 --- #################### 微信公众号相关配置 ####################
 wx: # 参见 https://github.com/Wechat-Group/WxJava/blob/develop/spring-boot-starters/wx-java-mp-spring-boot-starter/README.md 文档
   mp:
@@ -223,8 +243,8 @@ justauth:
   enabled: true
   type:
     DINGTALK: # 钉钉
-      client-id: dingh3nuuagvbkahgvjh
-      client-secret: iZ6sPUELzmwMBIldGzvoBhE8SNhbDzmFxhQOcStClxfZR-qAN_PJ_8qRxiC1kQW8
+      client-id: dingmr9ez0ecgbmscfeb
+      client-secret: VhG_zMdTvIBwA_0Ef8FJ0foH3VYYo5T-kw0ukX_PBA8Ah1xl7AjDw5RVYCU0DTpe
       ignore-check-redirect-uri: true
       ignore-check-state: true
     WECHAT_ENTERPRISE: # 企业微信
@@ -269,12 +289,96 @@ iot:
 yanfan:
   url: http://172.21.10.65
 
+portal:
+  secret: cc99d802-ce5c-5f62-b037-9a00726e7109
+
 # 插件配置
 pf4j:
   pluginsDir: ${user.home}/plugins # 插件目录
-
 system:
-  url: https://iot.deepoil.cc/
+  url: https://aims.deepoil.cc/
+oa:
+  register: https://yfoa.keruioil.com/api/ec/dev/auth/regist
+  gettoken: https://yfoa.keruioil.com/api/ec/dev/auth/applytoken
+  outMaintain: https://yfoa.keruioil.com/api/workflow/paService/doCreateRequest
+  companyUrl: https://yfoa.keruioil.com/api/hrm/resful/getHrmsubcompanyWithPage
+  departmentUrl: https://yfoa.keruioil.com/api/hrm/resful/getHrmdepartmentWithPage
+  userUrl: https://yfoa.keruioil.com/api/hrm/resful/getHrmUserInfoWithPage
+  workflowId: "640"
+  requestName: "设备委外维修申请流程"
+  appid: TW
+  cpk: MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApmtQUp9M82/z22P7am5owQCknjQnjF4U4ckEh7XVtJVQZrZx7d1lCPfoYrwOKEM4DEV7khW6++4Zv5caJ/9nqPn4QFwCqJWVmCEm9vC1BA6i2yfa4bmTxdR1/oeU/Af9pDFlvv5GC9XyilO7CIKu19Ce50v7aN6h1Tjix+h5Ba8e12XAEpEZk9pFroEYfR4lrecvi1pQOwRw8YzDRC4lhGNOo5Cen1rGjk7dwzzgs4uEv9ZyPZoVJnty5P9JE/ctboEf3x4jbqIliuCRgOyXYsLlp+N282CKcWZ35URkGw2orKyG1U6L1hNoj7kkpvAo8Zagf97SdZ0nYdRBIHv6PQIDAQAB
+  oaSecret: kryfoa20250905@szh
+  user: OAuser
+  userId: cLPEaFs9moW6b3xZMl1kNNWAAo7bp61ZRRTKmpiJUe56hSxQvrC2vWtY5ogj7g5FAnUOlzYjYg9MRktKXcseh/nsvZCQGa3BAlYixlDJruV19y4Omx5dYnqu/qv2rJAqTzUS71sOwuB1M2nKlLVsphw1GF74UhGP4xsjpZP7mC8=
+
+file:
+  upload-path: /iotfiles
 
+sip:
+  enabled: false                            # 是否启用视频监控SIP,true为启用
+  ## 本地调试时,绑定网卡局域网IP,设备在同一局域网,设备接入IP填写绑定IP
+  ## 部署服务端时,默认绑定容器IP,设备接入IP填写服务器公网IP
+  ip: 172.21.10.129
+  port: 5061                                # SIP端口(保持默认)
+  domain: 3402000000                        # 由省级、市级、区级、基层编号组成
+  id: 34020000002000000001                  # 同上,另外增加编号,(可保持默认)
+  password: 12345678
 
+hikvision:
+  # ISAPI协议配置
+  isapi:
+    # 超脑设备配置
+    devices:
+      - id: "nvr-001"
+        name: "超脑巡检设备1"
+        ip: "172.26.0.52"
+        port: 80
+        username: "admin"
+        password: "rqny@szh2026"
+        protocol: "http"
+        timeout: 10000
+        # ISAPI接口路径
+        paths:
+          event-notification: "/ISAPI/Event/notification/"
+          intelligence-analysis: "/ISAPI/Intelligent/Analysis/"
+          system-status: "/ISAPI/System/status"
+          device-info: "/ISAPI/System/deviceInfo"
+          sip-info: "/ISAPI/System/Network/SIP/1/SIPInfo"
+        # 订阅配置
+        subscription:
+          heartbeat-interval: 30
+          event-types: "All"
+    # 回调配置
+    callback:
+      enabled: true
+      # 本地接收告警的接口地址(超脑会调用这个地址)
+      local-url: "http://172.21.10.129:58080/admin-api/hikvision/alarm/callback"
+      # 用于接收图片的接口
+      image-url: "http://172.21.10.129:58080/admin-api/hikvision/image/callback"
+      retry-times: 3
+      retry-interval: 1000
+    # 存储配置
+    storage:
+      save-alarm: true
+      save-image: false
+      image-path: "./alarm-images/"
 
+  # 告警事件处理配置
+  alarm:
+    event-types:
+      - name: "入侵检测"
+        code: "IntrusionDetection"
+        level: "HIGH"
+      - name: "人员聚集"
+        code: "CrowdDetection"
+        level: "MEDIUM"
+      - name: "安全帽检测"
+        code: "HelmetDetection"
+        level: "MEDIUM"
+      - name: "区域入侵"
+        code: "RegionIntrusion"
+        level: "HIGH"
+      - name: "设备离线"
+        code: "DeviceOffline"
+        level: "CRITICAL"