Browse Source

feat: 新增云台控制权锁与框选3D定位接口

- 云台控制权锁:/device/ptzcontrol 请求体携带 username 时按 cameraCode 抢占 Redis 锁,
  角色由 ExtRoleService 解析(sys_config 控制外部角色接口开关/地址,关闭时降级 mock 角色;
  sys_dict_data 的 ptz_role_level 字典维护角色等级),同用户续期、等级数值更小可抢占、
  同级先到先得,锁被占用返回 HTTP 423;北向调用不传 username 不受锁约束。
  新增 security 模块(PtzLockService/ExtRoleService/PermissionProperties)及
  sys_config/sys_dict Mapper,application.yml 增加 permission 配置项。
- 框选3D定位:新增 /device/position3d 接口,统一 0~255 归一化坐标契约,
  海康走 NET_DVR_PTZSelZoomIn_EX 原生区域选择,大华走 HTTP moveDirectly(换算 0~8192),
  锁占用时与云台控制共用同一把 cameraCode 锁。
- 前端页面:支持鼠标/触摸框选放大缩小(黑边剔除与方向判定)、新增雨刮器一键擦拭按钮,
  入口 URL 透传 username/joinbright-token 并随云台及3D定位请求携带。
- 其他:Docker 配置 ZLM 推流服务名改为 inspect-nvr-zlmedia、HTTP 端口调整为 33381。
master
王寅 3 weeks ago
parent
commit
502832f5fd
20 changed files with 995 additions and 8 deletions
  1. +29
    -0
      src/main/java/com/inspect/nvr/controller/IvsCameraController.java
  2. +98
    -0
      src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java
  3. +40
    -0
      src/main/java/com/inspect/nvr/domain/ivs/IvsPosition3dRequest.java
  4. +6
    -0
      src/main/java/com/inspect/nvr/domain/ivs/IvsPtzControlRequest.java
  5. +0
    -1
      src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java
  6. +7
    -0
      src/main/java/com/inspect/nvr/hik/exception/HikNvrPlaybackException.java
  7. +26
    -0
      src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java
  8. +44
    -0
      src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java
  9. +60
    -0
      src/main/java/com/inspect/nvr/ivs/service/IvsOpenApiService.java
  10. +9
    -0
      src/main/java/com/inspect/nvr/ivs/service/IvsVendorAdapter.java
  11. +196
    -0
      src/main/java/com/inspect/nvr/security/ExtRoleService.java
  12. +36
    -0
      src/main/java/com/inspect/nvr/security/PermissionProperties.java
  13. +136
    -0
      src/main/java/com/inspect/nvr/security/PtzLockService.java
  14. +14
    -0
      src/main/java/com/inspect/nvr/sys/mapper/SysConfigMapper.java
  15. +14
    -0
      src/main/java/com/inspect/nvr/sys/mapper/SysDictMapper.java
  16. +3
    -3
      src/main/resources/application-docker.yml
  17. +9
    -0
      src/main/resources/application.yml
  18. +14
    -0
      src/main/resources/mapper/sys/SysConfigMapper.xml
  19. +16
    -0
      src/main/resources/mapper/sys/SysDictMapper.xml
  20. +238
    -4
      src/main/resources/static/index.html

+ 29
- 0
src/main/java/com/inspect/nvr/controller/IvsCameraController.java View File

@ -2,9 +2,11 @@ package com.inspect.nvr.controller;
import com.inspect.nvr.aop.TimeTrace;
import com.inspect.nvr.domain.device.*;
import com.inspect.nvr.domain.ivs.IvsPosition3dRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlResponse;
import com.inspect.nvr.ivs.service.IvsOpenApiService;
import com.inspect.nvr.security.PtzLockService;
import com.inspect.nvr.service.IvsCameraService;
import com.inspect.nvr.utils.StringUtils;
import lombok.extern.slf4j.Slf4j;
@ -26,6 +28,9 @@ public class IvsCameraController {
private IvsCameraService ivsCameraService;
@Resource
private IvsOpenApiService ivsOpenApiService;
/** 云台控制权锁:body 携带角色字段的调用按 cameraCode 抢锁,北向机器调用跳过。 */
@Resource
private PtzLockService ptzLockService;
private static final Object captureLock = new Object();
private static final Object captureLock_pic = new Object();
/**
@ -50,9 +55,33 @@ public class IvsCameraController {
@PostMapping({"/device/ptzcontrol"})
public ResponseEntity<IvsPtzControlResponse> ptzControl( @RequestBody IvsPtzControlRequest param) {
log.info("Ptz control request param: {}", param);
// 请求体携带 username 时按 cameraCode 校验控制权锁被更高优先持有时抛 HTTP 423
ptzLockService.checkLock(
param == null ? null : param.getCameraCode(),
param == null ? null : param.getUsername(),
param == null ? null : param.getJoinbrightToken());
return ResponseEntity.ok().body(ivsOpenApiService.ptzControl(param));
}
/**
* 接收前端视频框选坐标并按框选方向执行 3D 定位放大或缩小
*
* @param param cameraCode 0255 归一化起止坐标
* @return 厂商设备的执行结果
*/
@CrossOrigin
@PostMapping("/device/position3d")
public ResponseEntity<IvsPtzControlResponse> position3d(
@RequestBody IvsPosition3dRequest param) {
// 3D 定位与普通云台控制共用同一把 cameraCode 控制权锁
ptzLockService.checkLock(
param == null ? null : param.getCameraCode(),
param == null ? null : param.getUsername(),
param == null ? null : param.getJoinbrightToken());
// IVS 门面按 cameraCode 选择厂商并在厂商层完成坐标校验和 SDK 调用
return ResponseEntity.ok().body(ivsOpenApiService.position3d(param));
}
/**
* 创建指定摄像机的平台抓图任务
*/


+ 98
- 0
src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java View File

@ -14,6 +14,7 @@ import com.inspect.nvr.domain.ivs.IvsOperationResultView;
import com.inspect.nvr.domain.ivs.IvsPresetAddRequest;
import com.inspect.nvr.domain.ivs.IvsPresetModifyRequest;
import com.inspect.nvr.domain.ivs.IvsPresetOperationView;
import com.inspect.nvr.domain.ivs.IvsPosition3dRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlResponse;
import com.inspect.nvr.domain.ivs.IvsRtspUrlResponse;
@ -39,6 +40,16 @@ import com.inspect.nvr.view.IvsPlatformSnapshotView;
import com.sun.jna.Memory;
import com.sun.jna.ptr.IntByReference;
import lombok.extern.slf4j.Slf4j;
import org.apache.hc.client5.http.auth.AuthScope;
import org.apache.hc.client5.http.auth.UsernamePasswordCredentials;
import org.apache.hc.client5.http.classic.methods.HttpGet;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.impl.auth.BasicCredentialsProvider;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.net.URIBuilder;
import org.apache.hc.core5.util.Timeout;
import org.springframework.stereotype.Service;
import javax.annotation.PreDestroy;
@ -472,6 +483,93 @@ public class DahuaIvsVendorService implements IvsVendorAdapter {
return ptzSuccess();
}
/**
* 通过大华 HTTP API moveDirectly 动作执行框选 3D 定位
*
* @param request cameraCode 和统一的 0255 归一化坐标
* @return IVS 标准云台控制响应
*/
@Override
public IvsPtzControlResponse position3d(IvsPosition3dRequest request) {
// 根据 cameraCode 从数据库解析大华 NVR 和目标通道凭据不返回浏览器
HikNvrCameraTarget target = resolveTarget(request.getCameraCode(), null);
NvrInfo nvr = target.getNvrInfo();
// 大华 3D 定位走 NVR HTTP 管理口缺少连接或认证参数时直接拒绝调用
if (nvr == null || !hasText(nvr.getNvrIp())
|| nvr.getHttpPort() == null || nvr.getHttpPort() <= 0
|| !hasText(nvr.getAccount()) || nvr.getPassword() == null) {
throw HikNvrPlaybackException.badRequest("数据库中的大华NVR HTTP连接信息不完整");
}
// 大华协议使用 08192将统一坐标等比例换算且保留原始拖动方向
int startX = toDahuaPositionCoordinate(request.getStartX());
int startY = toDahuaPositionCoordinate(request.getStartY());
int endX = toDahuaPositionCoordinate(request.getEndX());
int endY = toDahuaPositionCoordinate(request.getEndY());
try {
// 使用 URIBuilder 编码数组式参数名避免方括号破坏请求 URI
java.net.URI uri = new URIBuilder("http://" + nvr.getNvrIp().trim()
+ ":" + nvr.getHttpPort() + "/cgi-bin/ptzBase.cgi")
.addParameter("action", "moveDirectly")
.addParameter("channel", String.valueOf(target.getChannel()))
.addParameter("startPoint[0]", String.valueOf(startX))
.addParameter("startPoint[1]", String.valueOf(startY))
.addParameter("endPoint[0]", String.valueOf(endX))
.addParameter("endPoint[1]", String.valueOf(endY))
.build();
// 配置数据库账号密码Apache 客户端会完成大华 HTTP Digest 认证握手
BasicCredentialsProvider credentials = new BasicCredentialsProvider();
credentials.setCredentials(
new AuthScope(null, -1),
new UsernamePasswordCredentials(
nvr.getAccount(), nvr.getPassword().toCharArray()));
// 与现有 SDK 同步操作共用五秒超时避免设备离线时长期占住请求线程
Timeout timeout = Timeout.ofMilliseconds(SDK_WAIT_MILLIS);
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(timeout)
.setConnectionRequestTimeout(timeout)
.setResponseTimeout(timeout)
.build();
try (CloseableHttpClient client = HttpClients.custom()
.setDefaultCredentialsProvider(credentials)
.setDefaultRequestConfig(requestConfig)
.build()) {
// 调用大华官方 moveDirectly 接口成功响应体必须为 OK
client.execute(new HttpGet(uri), response -> {
String body = response.getEntity() == null
? "" : EntityUtils.toString(
response.getEntity(), StandardCharsets.UTF_8).trim();
// HTTP 状态或业务响应异常时返回真实设备错误不伪造 PTZ 成功
if (response.getCode() != 200 || !"OK".equalsIgnoreCase(body)) {
throw HikNvrPlaybackException.sdkFailure(
response.getCode(),
"大华3D定位失败,HTTP " + response.getCode() + ":" + body);
}
return null;
});
}
} catch (HikNvrPlaybackException knownFailure) {
// 保留已经转换好的 HTTP 状态和设备响应便于页面显示准确原因
throw knownFailure;
} catch (Exception failure) {
// 网络认证或 URI 异常统一转换为设备调用失败
throw HikNvrPlaybackException.sdkFailure(
-1, "大华3D定位失败:" + failure.getMessage(), failure);
}
// 大华设备确认返回 OK 后复用已有 IVS PTZ 成功结构
return ptzSuccess();
}
/**
* 将统一的 0255 坐标等比例换算为大华 08192 坐标
*
* @param coordinate 统一归一化坐标
* @return 大华 moveDirectly 坐标
*/
private int toDahuaPositionCoordinate(int coordinate) {
// 使用整数四舍五入保证 0 255 精确映射到协议两端点
return (coordinate * 8192 + 127) / 255;
}
/** 查询大华通道的预置位列表。 */
@Override
public IvsPresetListView presetList(String cameraCode, String nvrCode) {


+ 40
- 0
src/main/java/com/inspect/nvr/domain/ivs/IvsPosition3dRequest.java View File

@ -0,0 +1,40 @@
package com.inspect.nvr.domain.ivs;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/** 前端视频框选触发的 3D 定位请求。 */
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IvsPosition3dRequest implements Serializable {
/** Java 序列化版本号,保证请求对象在序列化时的兼容性。 */
private static final long serialVersionUID = 1L;
/** IVS 摄像机完整编码,由服务端解析所属 NVR 和通道。 */
private String cameraCode;
/** 框选起点横坐标,按视频有效画面归一化到 0~255。 */
private Integer startX;
/** 框选起点纵坐标,按视频有效画面归一化到 0~255。 */
private Integer startY;
/** 框选终点横坐标;大于起点表示放大,小于起点表示缩小。 */
private Integer endX;
/** 框选终点纵坐标,按视频有效画面归一化到 0~255。 */
private Integer endY;
/** 操作用户名;用于复用前端云台控制权锁。 */
private String username;
/** 上级系统透传的 joinbright-token,用于外部角色接口鉴权。 */
private String joinbrightToken;
}

+ 6
- 0
src/main/java/com/inspect/nvr/domain/ivs/IvsPtzControlRequest.java View File

@ -28,4 +28,10 @@ public class IvsPtzControlRequest implements Serializable {
/** 云台控制参数2,按控制码表示速度或空参数。 */
private String controlPara2;
/** 操作用户名;云台控制锁的操作者标识,北向调用不传。 */
private String username;
/** 上级系统透传的 joinbright-token,外部角色接口鉴权用;北向调用不传。 */
private String joinbrightToken;
}

+ 0
- 1
src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java View File

@ -41,7 +41,6 @@ public class HikNvrUiActionController {
/** 大华工作台预置位保存能力。 */
@Resource
private DahuaIvsVendorService dahuaVendorService;
/**
* 接收海康 NVR 云台控制指令
*/


+ 7
- 0
src/main/java/com/inspect/nvr/hik/exception/HikNvrPlaybackException.java View File

@ -64,6 +64,13 @@ public class HikNvrPlaybackException extends RuntimeException {
return new HikNvrPlaybackException(-5, HttpStatus.CONFLICT, message, null);
}
/** 创建云台控制锁被占用异常,提示持锁角色和剩余秒数。 */
public static HikNvrPlaybackException operationLocked(String holderRoleName, int remainSeconds) {
// 创建 HTTP 423 控制权被占错误
return new HikNvrPlaybackException(-423, HttpStatus.LOCKED,
holderRoleName + "正在进行操作,操作锁定剩余时间" + Math.max(remainSeconds, 0) + "秒,请稍后重试", null);
}
/** 返回业务错误码。 */
public int getCode() {
// 返回业务错误码


+ 26
- 0
src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java View File

@ -9,6 +9,7 @@ import com.inspect.nvr.domain.ivs.IvsOperationResultView;
import com.inspect.nvr.domain.ivs.IvsPresetAddRequest;
import com.inspect.nvr.domain.ivs.IvsPresetModifyRequest;
import com.inspect.nvr.domain.ivs.IvsPresetOperationView;
import com.inspect.nvr.domain.ivs.IvsPosition3dRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlResponse;
import com.inspect.nvr.domain.ivs.IvsRtspUrlResponse;
@ -600,6 +601,31 @@ public class HikIvsVendorService implements IvsVendorAdapter {
return IvsPtzControlResponse.builder().resultCode(0).lockStatus(0).build();
}
/**
* 使用海康球机原生区域选择接口执行 3D 定位
*
* @param request cameraCode 0255 归一化起止坐标
* @return IVS 标准云台控制响应
*/
@Override
public IvsPtzControlResponse position3d(IvsPosition3dRequest request) {
// 校验摄像机编码格式后解析数据库 NVR 和实际通道
validatePtzCameraCode(request.getCameraCode());
Target target = resolveTarget(request.getCameraCode(), null);
// 复用海康操作服务的直连相机优先失败回退 NVR SDK 调用链
HikNvrUiActionResponse response = uiActionService
.forNvr(toNvrInfo(target.nvr))
.position3d(
target.channel,
request.getStartX(), request.getStartY(),
request.getEndX(), request.getEndY());
// 将海康执行结果转换为已有 IVS PTZ 响应结构
return IvsPtzControlResponse.builder()
.resultCode(response.isSuccess() ? 0 : 1)
.lockStatus(0)
.build();
}
/**
* 查询海康摄像机的预置位列表并转换为 IVS 返回结构
*/


+ 44
- 0
src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java View File

@ -320,6 +320,50 @@ public class HikNvrUiActionService {
}
}
/**
* 按视频框选坐标调用海康原生 3D 定位左到右放大右到左缩小
*
* @param channel 页面逻辑通道号
* @param startX 起点横坐标范围 0255
* @param startY 起点纵坐标范围 0255
* @param endX 终点横坐标范围 0255
* @param endY 终点纵坐标范围 0255
* @return 海康 SDK 执行结果
*/
public HikNvrUiActionResponse position3d(
int channel, int startX, int startY, int endX, int endY) {
// 复用页面通道校验避免无效通道进入直连相机或 NVR 回退链路
int pageChannel = requestedChannel(
HikNvrUiActionRequest.builder().channel(channel).build());
// 通过统一 SDK 执行器优先控制直连球机失败时再由同一调用回退 NVR 通道
SdkCallResult sdkResult = executeSdk(pageChannel, (userId, sdkChannel) -> {
// 海康原生结构直接接收 0255 坐标横向拖动方向决定放大或缩小
HCNetSDK.NET_DVR_POINT_FRAME frame = new HCNetSDK.NET_DVR_POINT_FRAME();
frame.xTop = startX;
frame.yTop = startY;
frame.xBottom = endX;
frame.yBottom = endY;
// 官方结构中 bCounter 为保留字段必须保持零值
frame.bCounter = 0;
// Java 字段写入 JNA 原生内存后调用无需实时预览句柄的 EX 接口
frame.write();
return sdk.NET_DVR_PTZSelZoomIn_EX(userId, sdkChannel, frame);
});
// SDK 调用失败时返回真实错误码不能让页面误以为设备已经完成定位
if (!sdkResult.success) {
throw HikNvrPlaybackException.sdkFailure(
sdkResult.errorCode,
"海康3D定位失败:" + sdkResult.failureMessage);
}
// 根据横向框选方向生成可读结果供上层日志和后续调用复用
String action = startX < endX ? "放大" : "缩小";
return HikNvrUiActionResponse.builder()
.success(true)
.message("海康3D定位" + action + "指令已发送")
.sdkChannel(sdkResult.sdkChannel)
.build();
}
/**
* 调用海康设备已经配置的云台起始位零方位角
*


+ 60
- 0
src/main/java/com/inspect/nvr/ivs/service/IvsOpenApiService.java View File

@ -7,11 +7,13 @@ import com.inspect.nvr.domain.ivs.IvsOperationResultView;
import com.inspect.nvr.domain.ivs.IvsPresetAddRequest;
import com.inspect.nvr.domain.ivs.IvsPresetModifyRequest;
import com.inspect.nvr.domain.ivs.IvsPresetOperationView;
import com.inspect.nvr.domain.ivs.IvsPosition3dRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlResponse;
import com.inspect.nvr.domain.ivs.IvsRtspUrlResponse;
import com.inspect.nvr.domain.ivs.IvsSnapshotCommandResponse;
import com.inspect.nvr.domain.ivs.IvsVideoRtspVo;
import com.inspect.nvr.hik.exception.HikNvrPlaybackException;
import com.inspect.nvr.ivs.mapper.IvsNvrMapper;
import com.inspect.nvr.ivs.pojo.IvsNvr;
import com.inspect.nvr.ivs.vo.IvsVendorRoutingContextVo;
@ -156,6 +158,54 @@ public class IvsOpenApiService {
return vendorAdapter.ptzControl(request);
}
/**
* 将视频框选 3D 定位请求转交给匹配的厂商适配器
*
* @param request 摄像机编码和归一化框选坐标
* @return IVS 标准云台控制响应
*/
public IvsPtzControlResponse position3d(IvsPosition3dRequest request) {
// 在厂商路由前统一校验请求保证海康和大华使用完全相同的坐标契约
validatePosition3d(request);
// 3D 定位仍按 cameraCode 路由浏览器无需接触 NVR 地址通道或凭据
return vendorAdapter.position3d(request);
}
/**
* 校验 3D 定位的完整摄像机编码和 0255 归一化矩形
*
* @param request 前端框选请求
*/
private void validatePosition3d(IvsPosition3dRequest request) {
// 缺少 cameraCode 时无法选择厂商或解析实际设备通道
if (request == null || request.getCameraCode() == null
|| request.getCameraCode().trim().isEmpty()) {
throw HikNvrPlaybackException.badRequest("cameraCode不能为空");
}
// 四个坐标必须成对提供避免把不完整矩形发送到设备
if (request.getStartX() == null || request.getStartY() == null
|| request.getEndX() == null || request.getEndY() == null) {
throw HikNvrPlaybackException.badRequest(
"startX、startY、endX、endY不能为空");
}
// 前端和两个厂商适配器统一使用 0255厂商层只负责换算自己的坐标范围
int[] coordinates = {
request.getStartX(), request.getStartY(),
request.getEndX(), request.getEndY()
};
// 逐项拒绝越界值防止设备 SDK 接收到无效原生结构体
for (int coordinate : coordinates) {
if (coordinate < 0 || coordinate > 255) {
throw HikNvrPlaybackException.badRequest("3D定位坐标必须在0到255之间");
}
}
// 小于三个归一化单位的拖动视为误触不向真实云台发送动作
if (Math.abs(request.getStartX() - request.getEndX()) <= 2
|| Math.abs(request.getStartY() - request.getEndY()) <= 2) {
throw HikNvrPlaybackException.badRequest("3D定位框选区域过小");
}
}
/**
* 将预置位查询请求转交给匹配的厂商适配器
*/
@ -335,6 +385,16 @@ public class IvsOpenApiService {
return adapter.ptzControl(request);
}
/** 按摄像机编码选择厂商并执行 3D 定位。 */
@Override
public IvsPtzControlResponse position3d(IvsPosition3dRequest request) {
// 使用请求中的 cameraCode 查询 NVR 厂商避免把坐标交给错误的 SDK
String cameraCode = request == null ? null : request.getCameraCode();
IvsVendorAdapter adapter = byCamera(cameraCode, null);
// 将原始归一化坐标交给厂商实现路由层不改变框选方向
return adapter.position3d(request);
}
@Override
/** 按摄像机和 NVR 编码选择厂商并查询预置位。 */
public IvsPresetListView presetList(String cameraCode, String nvrCode) {


+ 9
- 0
src/main/java/com/inspect/nvr/ivs/service/IvsVendorAdapter.java View File

@ -7,6 +7,7 @@ import com.inspect.nvr.domain.ivs.IvsDevChanSnapVo;
import com.inspect.nvr.domain.ivs.IvsPresetAddRequest;
import com.inspect.nvr.domain.ivs.IvsPresetModifyRequest;
import com.inspect.nvr.domain.ivs.IvsPresetOperationView;
import com.inspect.nvr.domain.ivs.IvsPosition3dRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlRequest;
import com.inspect.nvr.domain.ivs.IvsPtzControlResponse;
import com.inspect.nvr.domain.ivs.IvsRtspUrlResponse;
@ -102,6 +103,14 @@ public interface IvsVendorAdapter {
*/
IvsPtzControlResponse ptzControl(IvsPtzControlRequest request);
/**
* 根据前端框选坐标执行 3D 定位放大或缩小
*
* @param request 摄像机编码和 0255 归一化坐标
* @return IVS 标准云台控制响应
*/
IvsPtzControlResponse position3d(IvsPosition3dRequest request);
/**
* 查询指定摄像机的预置位列表
*/


+ 196
- 0
src/main/java/com/inspect/nvr/security/ExtRoleService.java View File

@ -0,0 +1,196 @@
package com.inspect.nvr.security;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.inspect.nvr.sys.mapper.SysConfigMapper;
import com.inspect.nvr.sys.mapper.SysDictMapper;
import cn.hutool.http.HttpUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* 按用户名解析操作角色roleName + roleLevel
*
* <p>角色名单来自 joinbright 网关 /ext_auth/getRoleByUser 接口接口开关和地址维护在
* sys_configext.auth.getRoleByUser.enabled / .url开关关闭或接口不可用时降级</p>
* <ul>
* <li>开关为 false返回内置 mock 角色业务配置员</li>
* <li>开关为 true 但调用失败未授权用户等级 9 参与锁竞争</li>
* </ul>
* <p>roleName roleLevel 映射维护在 sys_dict_datadict_type=ptz_role_level
* dict_label=角色名dict_value=等级数值越小权限越高未配置的角色按默认等级 9</p>
*/
@Service
public class ExtRoleService {
private static final Logger logger = LoggerFactory.getLogger(ExtRoleService.class);
/** sys_config:外部角色接口开关,false 时使用内置 mock 角色。 */
public static final String CONFIG_KEY_ENABLED = "ext.auth.getRoleByUser.enabled";
/** sys_config:外部角色接口完整地址。 */
public static final String CONFIG_KEY_URL = "ext.auth.getRoleByUser.url";
/** sys_dict_data:角色等级字典类型。 */
public static final String DICT_TYPE_ROLE_LEVEL = "ptz_role_level";
/** 开关关闭时的内置 mock 角色。 */
static final String MOCK_ROLE_NAME = "业务配置员";
/** 字典未配置的角色名/接口失败时使用的默认等级(最低权限)。 */
static final int DEFAULT_ROLE_LEVEL = 9;
/** 接口失败时的降级角色名。 */
static final String UNKNOWN_ROLE_NAME = "未授权用户";
/** 外部接口和配置读取的统一超时(毫秒)。 */
private static final int HTTP_TIMEOUT_MILLIS = 3000;
/** 用户角色解析结果缓存时长。 */
private static final long ROLE_CACHE_SECONDS = 300;
/** sys_config 开关缓存时长,修改数据库配置最迟此时长后生效。 */
private static final long CONFIG_CACHE_SECONDS = 60;
private final SysConfigMapper sysConfigMapper;
private final SysDictMapper sysDictMapper;
/** username → 解析后的角色,5 分钟过期。 */
private final Cache<String, RoleInfo> roleCache =
Caffeine.newBuilder().expireAfterWrite(ROLE_CACHE_SECONDS, TimeUnit.SECONDS).build();
/** 角色名 → 字典等级,5 分钟过期。 */
private final Cache<String, Integer> dictLevelCache =
Caffeine.newBuilder().expireAfterWrite(ROLE_CACHE_SECONDS, TimeUnit.SECONDS).build();
/** sys_config 读取结果缓存(volatile + 时间戳,60 秒)。 */
private volatile long configLoadedAtEpochMillis = 0L;
private volatile boolean extEnabled = false;
private volatile String extUrl = "";
public ExtRoleService(SysConfigMapper sysConfigMapper, SysDictMapper sysDictMapper) {
this.sysConfigMapper = sysConfigMapper;
this.sysDictMapper = sysDictMapper;
}
/** 解析结果:角色名 + 参与锁竞争的等级。 */
public static class RoleInfo {
private final String roleName;
private final int roleLevel;
RoleInfo(String roleName, int roleLevel) {
this.roleName = roleName;
this.roleLevel = roleLevel;
}
public String getRoleName() {
return roleName;
}
public int getRoleLevel() {
return roleLevel;
}
}
/**
* 按用户名解析角色结果按用户名缓存 5 分钟云台连续操作不会反复调用外部接口
*
* @param username 操作用户名
* @param joinbrightToken 上级系统透传的网关 token作为外部接口请求头
*/
public RoleInfo resolveRole(String username, String joinbrightToken) {
return roleCache.get(username, u -> loadRole(u, joinbrightToken));
}
/** 真实加载:取角色名列表 → 逐个查字典等级 → 取权限最高(等级数值最小)的一个。 */
private RoleInfo loadRole(String username, String joinbrightToken) {
List<String> roleNames = fetchRoleNames(username, joinbrightToken);
int bestLevel = DEFAULT_ROLE_LEVEL;
String bestName = null;
for (String roleName : roleNames) {
Integer level = dictLevelCache.get(roleName, this::loadDictLevel);
if (level != null && level < bestLevel) {
bestLevel = level;
bestName = roleName;
}
}
if (bestName == null) {
bestName = roleNames.isEmpty() ? UNKNOWN_ROLE_NAME : roleNames.get(0);
}
return new RoleInfo(bestName, bestLevel);
}
/** 调外部接口取角色名列表;开关关闭返回内置 mock,失败返回空列表走降级。 */
private List<String> fetchRoleNames(String username, String joinbrightToken) {
refreshConfigIfNeeded();
if (!extEnabled) {
return Collections.singletonList(MOCK_ROLE_NAME);
}
try {
String url = extUrl + (extUrl.contains("?") ? "&" : "?")
+ "username=" + URLEncoder.encode(username, "UTF-8");
String body = HttpUtil.createGet(url)
.header("joinbright-token", joinbrightToken == null ? "" : joinbrightToken)
.timeout(HTTP_TIMEOUT_MILLIS)
.execute()
.body();
JSONObject json = JSON.parseObject(body);
if (json == null || !json.getBooleanValue("success")) {
logger.warn("外部角色接口返回失败 username={} body={}", username, body);
return Collections.emptyList();
}
JSONArray data = json.getJSONArray("data");
List<String> roleNames = new ArrayList<>();
if (data != null) {
for (int i = 0; i < data.size(); i++) {
JSONObject item = data.getJSONObject(i);
if (item != null && item.getString("roleName") != null) {
roleNames.add(item.getString("roleName"));
}
}
}
return roleNames;
} catch (Exception e) {
logger.warn("外部角色接口调用异常 username={}", username, e);
return Collections.emptyList();
}
}
/** 查字典 roleName → roleLevel;未配置或非数字返回 null。 */
private Integer loadDictLevel(String roleName) {
try {
String value = sysDictMapper.selectValueByLabel(DICT_TYPE_ROLE_LEVEL, roleName);
return value == null ? null : Integer.valueOf(value.trim());
} catch (Exception e) {
logger.warn("角色等级字典查询失败 roleName={}", roleName, e);
return null;
}
}
/** 60 秒内复用上一次 sys_config 读取结果。 */
private void refreshConfigIfNeeded() {
long now = System.currentTimeMillis();
if (now - configLoadedAtEpochMillis < CONFIG_CACHE_SECONDS * 1000L) {
return;
}
synchronized (this) {
if (now - configLoadedAtEpochMillis < CONFIG_CACHE_SECONDS * 1000L) {
return;
}
try {
String enabledValue = sysConfigMapper.selectValueByKey(CONFIG_KEY_ENABLED);
extEnabled = Boolean.parseBoolean(enabledValue == null ? "false" : enabledValue.trim());
String urlValue = sysConfigMapper.selectValueByKey(CONFIG_KEY_URL);
extUrl = urlValue == null ? "" : urlValue.trim();
} catch (Exception e) {
// 数据库异常时保持上一次开关状态首次异常默认关闭mock
logger.warn("sys_config 读取失败,沿用上次外部角色接口配置", e);
}
configLoadedAtEpochMillis = now;
}
}
}

+ 36
- 0
src/main/java/com/inspect/nvr/security/PermissionProperties.java View File

@ -0,0 +1,36 @@
package com.inspect.nvr.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 云台控制锁配置
*
* <p>接口不做等级门槛仅对携带角色字段的云台控制调用按 cameraCode 抢占控制锁</p>
*/
@Component
@ConfigurationProperties(prefix = "permission")
public class PermissionProperties {
/** 总开关,关闭后锁校验直接跳过。 */
private boolean enabled = true;
/** 控制锁的自动释放时间(秒)。 */
private int lockTtlSeconds = 300;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public int getLockTtlSeconds() {
return lockTtlSeconds;
}
public void setLockTtlSeconds(int lockTtlSeconds) {
this.lockTtlSeconds = lockTtlSeconds;
}
}

+ 136
- 0
src/main/java/com/inspect/nvr/security/PtzLockService.java View File

@ -0,0 +1,136 @@
package com.inspect.nvr.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.inspect.nvr.hik.exception.HikNvrPlaybackException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 基于 Redis 的云台控制权锁 cameraCode 独立加锁
*
* <p>调用方在请求体携带 username可选 joinbrightToken参与锁
* 北向上位平台的请求体不含 username直接跳过锁校验
* 角色信息由 {@link ExtRoleService} 按用户名解析</p>
* <ul>
* <li>锁空闲直接拿锁TTL 到期自动释放</li>
* <li> username视为同一操作者续期后放行</li>
* <li>请求等级数值更小权限更高覆盖持锁人完成抢占</li>
* <li>同级或更低拒绝并返回持锁用户角色与剩余秒数</li>
* </ul>
*/
@Service
public class PtzLockService {
private static final Logger logger = LoggerFactory.getLogger(PtzLockService.class);
private static final String LOCK_KEY_PREFIX = "lock:ptz:";
/**
* KEYS[1] keyARGV 依次为持锁人 JSONTTL 请求 username请求 roleLevel
* 返回 [结果码, 剩余秒数, 持锁username, 持锁roleName]1 拿锁 / 2 续期 / 3 抢占 / 0 拒绝
*/
private static final String ACQUIRE_LUA =
"local raw = redis.call('GET', KEYS[1])\n"
+ "if not raw then\n"
+ " redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))\n"
+ " return {1, tonumber(ARGV[2]), '', ''}\n"
+ "end\n"
+ "local holder = cjson.decode(raw)\n"
+ "if tostring(holder.username) == ARGV[3] then\n"
+ " redis.call('EXPIRE', KEYS[1], tonumber(ARGV[2]))\n"
+ " return {2, tonumber(ARGV[2]), '', ''}\n"
+ "end\n"
+ "if tonumber(ARGV[4]) < tonumber(holder.roleLevel) then\n"
+ " redis.call('SET', KEYS[1], ARGV[1], 'EX', tonumber(ARGV[2]))\n"
+ " return {3, tonumber(ARGV[2]), '', ''}\n"
+ "end\n"
+ "return {0, redis.call('TTL', KEYS[1]), tostring(holder.username or ''), tostring(holder.roleName or '')}\n";
private static final DefaultRedisScript<List> ACQUIRE_SCRIPT =
new DefaultRedisScript<>(ACQUIRE_LUA, List.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private final StringRedisTemplate stringRedisTemplate;
private final PermissionProperties properties;
private final ExtRoleService extRoleService;
public PtzLockService(StringRedisTemplate stringRedisTemplate,
PermissionProperties properties,
ExtRoleService extRoleService) {
this.stringRedisTemplate = stringRedisTemplate;
this.properties = properties;
this.extRoleService = extRoleService;
}
/**
* 校验当前请求对指定摄像机的控制权
*
* @param cameraCode 完整摄像机编码为空时跳过由业务层做参数校验
* @param username 操作用户名为空视为北向调用跳过锁
* @param joinbrightToken 上级透传的网关 token供外部角色接口鉴权
* @throws HikNvrPlaybackException 锁被同级或更高优先持有时抛出 HTTP 423
*/
public void checkLock(String cameraCode, String username, String joinbrightToken) {
if (!properties.isEnabled()) {
return;
}
if (username == null || username.trim().isEmpty()
|| cameraCode == null || cameraCode.trim().isEmpty()) {
// 请求体未携带用户名北向机器调用或缺少 cameraCode不做锁校验
return;
}
String operator = username.trim();
ExtRoleService.RoleInfo role = extRoleService.resolveRole(operator, joinbrightToken);
String key = LOCK_KEY_PREFIX + cameraCode.trim();
try {
Map<String, Object> holder = new HashMap<>();
holder.put("username", operator);
holder.put("roleName", role.getRoleName());
holder.put("roleLevel", role.getRoleLevel());
holder.put("acquireAt", System.currentTimeMillis());
List<?> result = stringRedisTemplate.execute(
ACQUIRE_SCRIPT,
Collections.singletonList(key),
OBJECT_MAPPER.writeValueAsString(holder),
String.valueOf(properties.getLockTtlSeconds()),
operator,
String.valueOf(role.getRoleLevel()));
if (result == null || result.isEmpty() || asInt(result.get(0)) != 0) {
return;
}
// 提示文案admin_yy业务配置员正在进行操作操作锁定剩余时间xx秒请稍后重试
throw HikNvrPlaybackException.operationLocked(
asString(result.get(2)) + "(" + asString(result.get(3)) + ")",
asInt(result.get(1)));
} catch (HikNvrPlaybackException locked) {
throw locked;
} catch (Exception redisFailure) {
// Redis 不可用时按无锁放行避免锁组件故障导致云台完全不可用
logger.warn("云台控制锁校验失败,按无锁放行 cameraCode={}", cameraCode, redisFailure);
}
}
private int asInt(Object value) {
if (value instanceof Number) {
return ((Number) value).intValue();
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (NumberFormatException ignored) {
return 0;
}
}
private String asString(Object value) {
return value == null ? "" : String.valueOf(value);
}
}

+ 14
- 0
src/main/java/com/inspect/nvr/sys/mapper/SysConfigMapper.java View File

@ -0,0 +1,14 @@
package com.inspect.nvr.sys.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* sys_config 系统参数表的 MyBatis Mapper
*/
@Mapper
public interface SysConfigMapper {
/** 按 config_key 读取 config_value;不存在时返回 null。 */
String selectValueByKey(@Param("configKey") String configKey);
}

+ 14
- 0
src/main/java/com/inspect/nvr/sys/mapper/SysDictMapper.java View File

@ -0,0 +1,14 @@
package com.inspect.nvr.sys.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* sys_dict_data 字典数据表的 MyBatis Mapper
*/
@Mapper
public interface SysDictMapper {
/** 按字典类型和标签读取字典值;不存在时返回 null。 */
String selectValueByLabel(@Param("dictType") String dictType, @Param("dictLabel") String dictLabel);
}

+ 3
- 3
src/main/resources/application-docker.yml View File

@ -20,10 +20,10 @@ spring:
hik:
playback:
hls:
# 镜像内已安装 FFmpeg;推流走 Compose 内部网络,HLS 地址则返回给浏览器。
# 镜像内已安装 FFmpeg;推流固定走项目专用媒体服务名,HLS 地址则返回给浏览器。
ffmpeg-path: ${FFMPEG_PATH:/usr/bin/ffmpeg}
rtmp-base-url: ${ZLM_RTMP_BASE_URL:rtmp://zlmediakit:1935}
http-base-url: ${ZLM_HTTP_BASE_URL:http://host.docker.internal:18081}
rtmp-base-url: ${ZLM_RTMP_BASE_URL:rtmp://inspect-nvr-zlmedia:1935}
http-base-url: ${ZLM_HTTP_BASE_URL:http://host.docker.internal:33381}
clip:
# 录像片段直接复用镜像内FFmpeg,不依赖ZLMediaKit。
ffmpeg-path: ${FFMPEG_PATH:/usr/bin/ffmpeg}


+ 9
- 0
src/main/resources/application.yml View File

@ -60,3 +60,12 @@ hik:
download-timeout-millis: ${HIK_NVR_CLIP_DOWNLOAD_TIMEOUT_MILLIS:120000}
poll-interval-millis: ${HIK_NVR_CLIP_POLL_INTERVAL_MILLIS:250}
ffmpeg-timeout-millis: ${HIK_NVR_CLIP_FFMPEG_TIMEOUT_MILLIS:60000}
# 云台控制锁:/device/ptzcontrol 请求体携带 username 时按 cameraCode 抢占控制锁,
# 角色由 ExtRoleService 解析(外部接口开关/地址见 sys_config,等级映射见 sys_dict_data 的 ptz_role_level);
# 同 username 续期、更高等级(roleLevel 数值更小)可抢占、同级先到先得;
# 不携带 username 的调用(北向上位平台)不受锁约束。
permission:
enabled: ${PERMISSION_ENABLED:true}
lock-ttl-seconds: ${PERMISSION_LOCK_TTL_SECONDS:300}

+ 14
- 0
src/main/resources/mapper/sys/SysConfigMapper.xml View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.inspect.nvr.sys.mapper.SysConfigMapper">
<select id="selectValueByKey" resultType="java.lang.String">
SELECT config_value
FROM sys_config
WHERE config_key = #{configKey}
LIMIT 1
</select>
</mapper>

+ 16
- 0
src/main/resources/mapper/sys/SysDictMapper.xml View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.inspect.nvr.sys.mapper.SysDictMapper">
<select id="selectValueByLabel" resultType="java.lang.String">
SELECT dict_value
FROM sys_dict_data
WHERE dict_type = #{dictType}
AND dict_label = #{dictLabel}
AND status = '0'
LIMIT 1
</select>
</mapper>

+ 238
- 4
src/main/resources/static/index.html View File

@ -229,7 +229,18 @@
.feed::after { content: ""; position: absolute; inset: 0; pointer-events: none; background: linear-gradient(180deg, rgba(7, 20, 36, .04), transparent 50%, rgba(6, 15, 28, .65)); }
.feed video {
position: absolute; inset: 0; z-index: 1; width: 100%; height: 100%;
object-fit: contain; background: #0a1522;
object-fit: contain; background: #0a1522; touch-action: none;
}
.feed.has-video video { cursor: crosshair; }
.position3d-selection {
position: absolute; z-index: 3; border: 2px solid #38a3ff; border-radius: 3px;
background: rgba(56, 163, 255, .14); box-shadow: 0 0 0 1px rgba(255,255,255,.55) inset;
pointer-events: none;
}
.position3d-selection.zoom-out { border-color: #ffb238; background: rgba(255, 178, 56, .14); }
.position3d-selection::after {
content: attr(data-action); position: absolute; left: 0; top: -23px; padding: 3px 6px;
border-radius: 4px; color: #fff; background: rgba(8, 25, 43, .78); font-size: 10px; white-space: nowrap;
}
.feed.has-video .feed-scene,
.feed.has-video .feed-connection { display: none; }
@ -518,6 +529,7 @@
</div>
<div class="control-row"><label>变倍</label><input id="zoomRange" type="range" min="1" max="7" value="4"><output id="zoomValue">4</output></div>
<div class="control-row"><label>聚焦</label><input id="focusRange" type="range" min="1" max="7" value="4"><output id="focusValue">4</output></div>
<button id="wiperBtn" class="action-btn" type="button" style="width:100%;margin-top:9px" title="雨刮器擦拭一次,5秒后自动关闭"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M4.5 17a7.5 7.5 0 0 1 15 0"/><path d="m12 17 4.2-6.6"/><circle cx="12" cy="17" r="1.3" fill="currentColor" stroke="none"/></svg><span>雨刮器</span></button>
</section>
<section class="side-section">
<div class="side-section-head"><strong>预置位</strong><button type="button" title="收起"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="m6 9 6 6 6-6"/></svg></button></div>
@ -582,6 +594,8 @@
const API = '/hik/nvr/playback/hls/ui';
// 云台控制直接调用现有 IVS 北向接口,接口契约保持不变。
const IVS_PTZ_API = '/device/ptzcontrol';
// 视频框选使用独立 3D 定位接口,坐标按有效画面统一归一化到 0~255。
const IVS_POSITION_3D_API = '/device/position3d';
// 预置位列表直接调用现有 IVS 查询接口,不再经过海康 UI 兼容接口。
const IVS_PRESET_LIST_API = '/device/ptzpresetlist';
// 平台抓图直接复用现有 IVS 任务创建与图片下载接口。
@ -593,6 +607,30 @@
IRIS_OPEN: 21, IRIS_CLOSE: 22, ZOOM_IN: 23, ZOOM_OUT: 24,
FOCUS_NEAR: 25, FOCUS_FAR: 26
};
// 用户身份由入口 URL 参数(上位系统跳转)传入:username 与 joinbright-token(备用参数名 token),
// 缺失时回退 sessionStorage。云台请求体携带 username/joinbrightToken,后端据此解析角色并做控制锁抢占。
const userInfo = (() => {
const params = new URLSearchParams(window.location.search);
const stored = {};
try {
stored.username = sessionStorage.getItem('inspectUsername') || '';
stored.joinbrightToken = sessionStorage.getItem('inspectJoinbrightToken') || '';
} catch (ignored) { /* 隐私模式下 sessionStorage 可能不可用 */ }
const username = params.get('username') || stored.username || 'default';
const joinbrightToken = params.get('joinbright-token') || params.get('token')
|| stored.joinbrightToken || '';
try {
sessionStorage.setItem('inspectUsername', username);
sessionStorage.setItem('inspectJoinbrightToken', joinbrightToken);
} catch (ignored) { /* 存不进去也不影响本次请求 */ }
return { username, joinbrightToken };
})();
function userBodyFields() {
return {
username: userInfo.username,
joinbrightToken: userInfo.joinbrightToken
};
}
const state = {
config: null,
channels: [],
@ -887,9 +925,152 @@
renderCameras();
}
/**
* 计算 object-fit: contain 后的真实视频内容区,排除上下或左右黑边。
*/
function liveVideoContentRect(video) {
const rect = video.getBoundingClientRect();
// 浏览器尚未读取流分辨率或元素没有尺寸时不能可靠换算框选坐标。
if (!video.videoWidth || !video.videoHeight || !rect.width || !rect.height) return null;
const scale = Math.min(rect.width / video.videoWidth, rect.height / video.videoHeight);
const width = video.videoWidth * scale;
const height = video.videoHeight * scale;
return {
left: rect.left + (rect.width - width) / 2,
top: rect.top + (rect.height - height) / 2,
width,
height
};
}
/**
* 将鼠标或触摸点限制在有效视频区,并换算为厂商无关的 0~255 坐标。
*/
function position3dPoint(event, contentRect, clamp = false) {
let clientX = Number(event.clientX);
let clientY = Number(event.clientY);
const right = contentRect.left + contentRect.width;
const bottom = contentRect.top + contentRect.height;
// 起点落在黑边时拒绝框选;拖动过程则由调用方选择钳制到画面边界。
if (!clamp && (clientX < contentRect.left || clientX > right
|| clientY < contentRect.top || clientY > bottom)) return null;
clientX = Math.max(contentRect.left, Math.min(right, clientX));
clientY = Math.max(contentRect.top, Math.min(bottom, clientY));
return {
x: Math.round((clientX - contentRect.left) * 255 / contentRect.width),
y: Math.round((clientY - contentRect.top) * 255 / contentRect.height),
clientX,
clientY
};
}
/**
* 向当前框选视频所属的 cameraCode 发送 3D 定位请求。
*/
async function sendPosition3d(slotIndex, start, end) {
const slot = state.liveSlots[Number(slotIndex)];
// 槽位已关闭或缺少 cameraCode 时无法确定真实控制目标。
if (!slot || !String(slot.cameraCode || '').trim()) {
showToast('当前视频缺少 cameraCode', true);
return;
}
try {
const result = await requestJson(IVS_POSITION_3D_API, {
method: 'POST',
body: JSON.stringify({
cameraCode: String(slot.cameraCode).trim(),
startX: start.x,
startY: start.y,
endX: end.x,
endY: end.y,
...userBodyFields()
})
});
// 设备未返回 IVS 成功码时不能提示已经完成定位。
if (!result || Number(result.resultCode) !== 0) {
throw new Error('3D定位控制失败');
}
showToast('已发送3D定位' + (start.x < end.x ? '放大' : '缩小') + '指令');
} catch (error) {
// 将后端参数、认证和设备错误统一显示在当前页面,不隐藏真实失败原因。
showToast(error.message || '3D定位控制失败', true);
}
}
/**
* 在视频上绑定框选手势:左向右放大,右向左缩小。
*/
function bindPosition3dSelection(video, slotIndex) {
// 同一播放器节点只绑定一次,避免流重连后重复发送定位请求。
if (!video || video.dataset.position3dBound === 'true') return;
video.dataset.position3dBound = 'true';
let drag = null;
video.addEventListener('pointerdown', (event) => {
// 只接受鼠标主键或单指触摸,避免右键菜单误触真实云台。
if (event.button !== 0 || !state.liveSlots[Number(slotIndex)]) return;
const contentRect = liveVideoContentRect(video);
// 视频元数据尚未就绪时提示用户等待,不能按整个卡片尺寸猜坐标。
if (!contentRect) {
showToast('视频尺寸尚未就绪,请稍候再框选', true);
return;
}
const start = position3dPoint(event, contentRect);
// 在 object-fit 黑边上按下时不创建定位框。
if (!start) return;
const feed = video.closest('.feed');
const selection = document.createElement('div');
selection.className = 'position3d-selection';
selection.hidden = true;
feed.appendChild(selection);
drag = { pointerId: event.pointerId, contentRect, start, selection, feed };
video.setPointerCapture(event.pointerId);
event.preventDefault();
});
video.addEventListener('pointermove', (event) => {
// 忽略不属于当前指针的移动,保证多点触摸不会篡改定位框。
if (!drag || drag.pointerId !== event.pointerId) return;
const current = position3dPoint(event, drag.contentRect, true);
const feedRect = drag.feed.getBoundingClientRect();
const left = Math.min(drag.start.clientX, current.clientX) - feedRect.left;
const top = Math.min(drag.start.clientY, current.clientY) - feedRect.top;
const width = Math.abs(current.clientX - drag.start.clientX);
const height = Math.abs(current.clientY - drag.start.clientY);
drag.selection.hidden = width < 2 && height < 2;
drag.selection.style.left = left + 'px';
drag.selection.style.top = top + 'px';
drag.selection.style.width = width + 'px';
drag.selection.style.height = height + 'px';
const zoomOut = current.x < drag.start.x;
drag.selection.classList.toggle('zoom-out', zoomOut);
drag.selection.dataset.action = zoomOut ? '反选缩小' : '框选放大';
event.preventDefault();
});
const finish = (event, cancelled = false) => {
// 当前没有框选或事件来自其他指针时不处理结束动作。
if (!drag || drag.pointerId !== event.pointerId) return;
const active = drag;
drag = null;
const end = position3dPoint(event, active.contentRect, true);
active.selection.remove();
// 取消手势或框选过小时只清理覆盖层,不发送任何设备动作。
if (cancelled || Math.abs(active.start.x - end.x) <= 2
|| Math.abs(active.start.y - end.y) <= 2) return;
event.preventDefault();
event.stopPropagation();
void sendPosition3d(slotIndex, active.start, end);
};
video.addEventListener('pointerup', (event) => finish(event));
video.addEventListener('pointercancel', (event) => finish(event, true));
}
function bindLiveVideo(video, slotIndex) {
if (!video || video.dataset.liveFocusBound === 'true') return;
video.dataset.liveFocusBound = 'true';
// 同一视频节点复用框选能力,播放器切换或放大预览时无需重复绑定。
bindPosition3dSelection(video, slotIndex);
// 浏览器原生 video 控件会把双击解释为全屏;先拦截默认行为,统一进入应用内放大层。
video.addEventListener('dblclick', (event) => {
event.preventDefault();
@ -1265,7 +1446,7 @@
+ '" data-nvr-ip="' + escapeHtml(liveSlot.nvrIp)
+ '" data-channel="' + Number(channel.channel)
+ '" data-camera-code="' + escapeHtml(liveSlot.cameraCode || '')
+ '" data-slot-index="' + index + '" title="双击打开实时预览">' +
+ '" data-slot-index="' + index + '" title="双击放大预览;左向右框选定位放大,右向左框选缩小">' +
'<div class="feed' + (isLive ? ' has-video' : '') + '">' +
'<div class="feed-scene ' + scene + '"></div>' +
'<div class="feed-label"><span class="camera-led"></span>' + cameraName + '</div>' +
@ -1559,7 +1740,8 @@
cameraCode,
controlCode,
controlPara1,
controlPara2
controlPara2,
...userBodyFields()
})
});
// IVS接口HTTP成功但业务结果非0时仍按控制失败提示。
@ -1570,6 +1752,56 @@
} catch (error) { showToast(error.message, true); }
}
/**
* 雨刮器属于 IVS 辅助设备控制:controlCode=27 打开、28 关闭,controlPara1=1 表示雨刷。
*/
async function wiperControl(controlCode) {
const cameraCode = String(state.selectedCameraCode || '').trim();
if (!cameraCode) {
throw new Error('请先选择设备树中的摄像机');
}
const result = await requestJson(IVS_PTZ_API, {
method: 'POST',
body: JSON.stringify({
cameraCode,
controlCode,
// 辅助设备类型固定为 1(雨刷),第二个参数按协议传空。
controlPara1: '1',
controlPara2: '',
...userBodyFields()
})
});
if (!result || Number(result.resultCode) !== 0) {
throw new Error('IVS雨刮器控制失败');
}
}
/**
* 按“擦拭一次”语义使用雨刮器:打开 5 秒后自动关闭,避免长期运转损伤雨刮。
*/
async function wiperOnce() {
const button = $('#wiperBtn');
if (button.disabled) return;
button.disabled = true;
try {
await wiperControl(27);
showToast('雨刮器已启动,5秒后自动关闭');
setTimeout(async () => {
try {
await wiperControl(28);
showToast('雨刮器已关闭');
} catch (error) {
showToast(error.message || '雨刮器关闭失败', true);
} finally {
button.disabled = false;
}
}, 5000);
} catch (error) {
button.disabled = false;
showToast(error.message, true);
}
}
function bindPtz() {
$$('.ptz-btn').forEach((button) => {
const action = button.dataset.ptz;
@ -1623,7 +1855,8 @@
cameraCode,
controlCode: 11,
controlPara1: String(preset),
controlPara2: ''
controlPara2: '',
...userBodyFields()
})
});
if (!result || Number(result.resultCode) !== 0) {
@ -1893,6 +2126,7 @@
syncHistorySelection();
});
$('#snapshotBtn').addEventListener('click', () => capture());
$('#wiperBtn').addEventListener('click', () => wiperOnce());
$('#callPresetBtn').addEventListener('click', callPreset);
$('#savePresetBtn').addEventListener('click', savePreset);
$('#readPresetBtn').addEventListener('click', () => readPresets());


Loading…
Cancel
Save