diff --git a/src/main/java/com/inspect/nvr/controller/IvsCameraController.java b/src/main/java/com/inspect/nvr/controller/IvsCameraController.java index 3c137f85..62b79f52 100644 --- a/src/main/java/com/inspect/nvr/controller/IvsCameraController.java +++ b/src/main/java/com/inspect/nvr/controller/IvsCameraController.java @@ -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 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 和 0~255 归一化起止坐标 + * @return 厂商设备的执行结果 + */ + @CrossOrigin + @PostMapping("/device/position3d") + public ResponseEntity 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)); + } + /** * 创建指定摄像机的平台抓图任务。 */ diff --git a/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java b/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java index 550a2d2a..b19b2e80 100644 --- a/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java +++ b/src/main/java/com/inspect/nvr/dahua/DahuaIvsVendorService.java @@ -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 和统一的 0~255 归一化坐标 + * @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连接信息不完整"); + } + // 大华协议使用 0~8192,将统一坐标等比例换算且保留原始拖动方向。 + 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(); + } + + /** + * 将统一的 0~255 坐标等比例换算为大华 0~8192 坐标。 + * + * @param coordinate 统一归一化坐标 + * @return 大华 moveDirectly 坐标 + */ + private int toDahuaPositionCoordinate(int coordinate) { + // 使用整数四舍五入,保证 0 和 255 精确映射到协议两端点。 + return (coordinate * 8192 + 127) / 255; + } + /** 查询大华通道的预置位列表。 */ @Override public IvsPresetListView presetList(String cameraCode, String nvrCode) { diff --git a/src/main/java/com/inspect/nvr/domain/ivs/IvsPosition3dRequest.java b/src/main/java/com/inspect/nvr/domain/ivs/IvsPosition3dRequest.java new file mode 100644 index 00000000..f37c1955 --- /dev/null +++ b/src/main/java/com/inspect/nvr/domain/ivs/IvsPosition3dRequest.java @@ -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; +} diff --git a/src/main/java/com/inspect/nvr/domain/ivs/IvsPtzControlRequest.java b/src/main/java/com/inspect/nvr/domain/ivs/IvsPtzControlRequest.java index 995dd90e..51189f24 100644 --- a/src/main/java/com/inspect/nvr/domain/ivs/IvsPtzControlRequest.java +++ b/src/main/java/com/inspect/nvr/domain/ivs/IvsPtzControlRequest.java @@ -28,4 +28,10 @@ public class IvsPtzControlRequest implements Serializable { /** 云台控制参数2,按控制码表示速度或空参数。 */ private String controlPara2; + + /** 操作用户名;云台控制锁的操作者标识,北向调用不传。 */ + private String username; + + /** 上级系统透传的 joinbright-token,外部角色接口鉴权用;北向调用不传。 */ + private String joinbrightToken; } diff --git a/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java b/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java index 5474d744..aa771abe 100644 --- a/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java +++ b/src/main/java/com/inspect/nvr/hik/controller/HikNvrUiActionController.java @@ -41,7 +41,6 @@ public class HikNvrUiActionController { /** 大华工作台预置位保存能力。 */ @Resource private DahuaIvsVendorService dahuaVendorService; - /** * 接收海康 NVR 云台控制指令。 */ diff --git a/src/main/java/com/inspect/nvr/hik/exception/HikNvrPlaybackException.java b/src/main/java/com/inspect/nvr/hik/exception/HikNvrPlaybackException.java index f61db8eb..44039c25 100644 --- a/src/main/java/com/inspect/nvr/hik/exception/HikNvrPlaybackException.java +++ b/src/main/java/com/inspect/nvr/hik/exception/HikNvrPlaybackException.java @@ -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() { // 返回业务错误码。 diff --git a/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java b/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java index f1cf3a21..8488e118 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikIvsVendorService.java @@ -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 和 0~255 归一化起止坐标 + * @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 返回结构。 */ diff --git a/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java b/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java index 4903d3de..361850dd 100644 --- a/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java +++ b/src/main/java/com/inspect/nvr/hik/service/HikNvrUiActionService.java @@ -320,6 +320,50 @@ public class HikNvrUiActionService { } } + /** + * 按视频框选坐标调用海康原生 3D 定位;左到右放大,右到左缩小。 + * + * @param channel 页面逻辑通道号 + * @param startX 起点横坐标,范围 0~255 + * @param startY 起点纵坐标,范围 0~255 + * @param endX 终点横坐标,范围 0~255 + * @param endY 终点纵坐标,范围 0~255 + * @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) -> { + // 海康原生结构直接接收 0~255 坐标,横向拖动方向决定放大或缩小。 + 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(); + } + /** * 调用海康设备已经配置的云台起始位(零方位角)。 * diff --git a/src/main/java/com/inspect/nvr/ivs/service/IvsOpenApiService.java b/src/main/java/com/inspect/nvr/ivs/service/IvsOpenApiService.java index bad62734..13686229 100644 --- a/src/main/java/com/inspect/nvr/ivs/service/IvsOpenApiService.java +++ b/src/main/java/com/inspect/nvr/ivs/service/IvsOpenApiService.java @@ -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 定位的完整摄像机编码和 0~255 归一化矩形。 + * + * @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不能为空"); + } + // 前端和两个厂商适配器统一使用 0~255,厂商层只负责换算自己的坐标范围。 + 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) { diff --git a/src/main/java/com/inspect/nvr/ivs/service/IvsVendorAdapter.java b/src/main/java/com/inspect/nvr/ivs/service/IvsVendorAdapter.java index 32df122e..cf25ba9c 100644 --- a/src/main/java/com/inspect/nvr/ivs/service/IvsVendorAdapter.java +++ b/src/main/java/com/inspect/nvr/ivs/service/IvsVendorAdapter.java @@ -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 摄像机编码和 0~255 归一化坐标 + * @return IVS 标准云台控制响应 + */ + IvsPtzControlResponse position3d(IvsPosition3dRequest request); + /** * 查询指定摄像机的预置位列表。 */ diff --git a/src/main/java/com/inspect/nvr/security/ExtRoleService.java b/src/main/java/com/inspect/nvr/security/ExtRoleService.java new file mode 100644 index 00000000..475bd08d --- /dev/null +++ b/src/main/java/com/inspect/nvr/security/ExtRoleService.java @@ -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)。 + * + *

角色名单来自 joinbright 网关 /ext_auth/getRoleByUser 接口;接口开关和地址维护在 + * sys_config(ext.auth.getRoleByUser.enabled / .url)。开关关闭或接口不可用时降级:

+ * + *

roleName → roleLevel 映射维护在 sys_dict_data(dict_type=ptz_role_level, + * dict_label=角色名,dict_value=等级,数值越小权限越高),未配置的角色按默认等级 9。

+ */ +@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 roleCache = + Caffeine.newBuilder().expireAfterWrite(ROLE_CACHE_SECONDS, TimeUnit.SECONDS).build(); + /** 角色名 → 字典等级,5 分钟过期。 */ + private final Cache 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 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 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 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; + } + } +} diff --git a/src/main/java/com/inspect/nvr/security/PermissionProperties.java b/src/main/java/com/inspect/nvr/security/PermissionProperties.java new file mode 100644 index 00000000..0e67a054 --- /dev/null +++ b/src/main/java/com/inspect/nvr/security/PermissionProperties.java @@ -0,0 +1,36 @@ +package com.inspect.nvr.security; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 云台控制锁配置。 + * + *

接口不做等级门槛,仅对携带角色字段的云台控制调用按 cameraCode 抢占控制锁。

+ */ +@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; + } +} diff --git a/src/main/java/com/inspect/nvr/security/PtzLockService.java b/src/main/java/com/inspect/nvr/security/PtzLockService.java new file mode 100644 index 00000000..039b4cfe --- /dev/null +++ b/src/main/java/com/inspect/nvr/security/PtzLockService.java @@ -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 独立加锁)。 + * + *

调用方在请求体携带 username(可选 joinbrightToken)参与锁; + * 北向上位平台的请求体不含 username,直接跳过锁校验。 + * 角色信息由 {@link ExtRoleService} 按用户名解析。

+ *
    + *
  • 锁空闲:直接拿锁,TTL 到期自动释放;
  • + *
  • 同 username:视为同一操作者,续期后放行;
  • + *
  • 请求等级数值更小(权限更高):覆盖持锁人完成抢占;
  • + *
  • 同级或更低:拒绝并返回持锁用户(角色)与剩余秒数。
  • + *
+ */ +@Service +public class PtzLockService { + + private static final Logger logger = LoggerFactory.getLogger(PtzLockService.class); + + private static final String LOCK_KEY_PREFIX = "lock:ptz:"; + + /** + * KEYS[1] 锁 key;ARGV 依次为:持锁人 JSON、TTL 秒、请求 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 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 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); + } +} diff --git a/src/main/java/com/inspect/nvr/sys/mapper/SysConfigMapper.java b/src/main/java/com/inspect/nvr/sys/mapper/SysConfigMapper.java new file mode 100644 index 00000000..851c3382 --- /dev/null +++ b/src/main/java/com/inspect/nvr/sys/mapper/SysConfigMapper.java @@ -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); +} diff --git a/src/main/java/com/inspect/nvr/sys/mapper/SysDictMapper.java b/src/main/java/com/inspect/nvr/sys/mapper/SysDictMapper.java new file mode 100644 index 00000000..9d62eb10 --- /dev/null +++ b/src/main/java/com/inspect/nvr/sys/mapper/SysDictMapper.java @@ -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); +} diff --git a/src/main/resources/application-docker.yml b/src/main/resources/application-docker.yml index 00938c35..81311363 100644 --- a/src/main/resources/application-docker.yml +++ b/src/main/resources/application-docker.yml @@ -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} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 25854070..2b58e7c2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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} + diff --git a/src/main/resources/mapper/sys/SysConfigMapper.xml b/src/main/resources/mapper/sys/SysConfigMapper.xml new file mode 100644 index 00000000..afe7101a --- /dev/null +++ b/src/main/resources/mapper/sys/SysConfigMapper.xml @@ -0,0 +1,14 @@ + + + + + + + diff --git a/src/main/resources/mapper/sys/SysDictMapper.xml b/src/main/resources/mapper/sys/SysDictMapper.xml new file mode 100644 index 00000000..73645073 --- /dev/null +++ b/src/main/resources/mapper/sys/SysDictMapper.xml @@ -0,0 +1,16 @@ + + + + + + + diff --git a/src/main/resources/static/index.html b/src/main/resources/static/index.html index fb70180d..31cf99d7 100644 --- a/src/main/resources/static/index.html +++ b/src/main/resources/static/index.html @@ -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 @@
4
4
+
预置位
@@ -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="双击放大预览;左向右框选定位放大,右向左框选缩小">' + '
' + '
' + '
' + cameraName + '
' + @@ -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());