package com.inspect.nvr.controller; import com.alibaba.fastjson.JSONObject; import com.google.gson.Gson; import com.inspect.nvr.domain.ivs.*; import com.inspect.nvr.ivs.service.IvsCameraSyncService; import com.inspect.nvr.ivs.service.IvsOpenApiService; import com.inspect.nvr.ivs.vo.IvsCameraSyncPage; import com.inspect.nvr.hik.exception.HikNvrPlaybackException; import com.inspect.nvr.view.IvsPlatformSnapshotView; import com.inspect.nvr.view.IvsPlatformRecordListView; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.system.ApplicationHome; import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.util.ClassUtils; import org.springframework.util.ResourceUtils; import org.springframework.web.bind.annotation.*; import retrofit2.Call; import retrofit2.Response; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.net.URLDecoder; import java.nio.file.Files; import java.util.*; import java.util.List; @RestController public class IvsServerController { private final Logger log = LoggerFactory.getLogger(this.getClass()); private final IvsCameraSyncService ivsCameraSyncService; private final IvsOpenApiService ivsOpenApiService; /** 注入 IVS 摄像机同步服务和厂商路由门面。 */ public IvsServerController( IvsCameraSyncService ivsCameraSyncService, IvsOpenApiService ivsOpenApiService) { this.ivsCameraSyncService = ivsCameraSyncService; this.ivsOpenApiService = ivsOpenApiService; } private final String TASK_CODE = "1234567890"; private final String PIC_URL = "https://image.baidu.com/search/detail?ct=503316480&z=0&ipn=d&word=songbird&step_word=&hs=0&pn=15&spn=0&di=7360350738658099201&pi=0&rn=1&tn=baiduimagedetail&is=0%2C0&istype=2&ie=utf-8&oe=utf-8&in=&cl=2&lm=&st=-1&cs=1060316073%2C1951231255&os=2156611125%2C946097664&simid=60689125%2C716595069&adpicid=0&lpn=0&ln=1080&fr=&fmq=1718353459191_R&fm=result&ic=0&s=undefined&hd=&latest=©right=&se=&sme=&tab=0&width=&height=&face=undefined&ist=&jit=&cg=&bdtype=0&oriquery=&objurl=https%3A%2F%2Fup.sc.enterdesk.com%2Fedpic%2Ff4%2F4a%2Fee%2Ff44aeeb12debd4e56faf34d72890d76d.jpg&fromurl=ippr_z2C%24qAzdH3FAzdH3F4_z%26e3Bfv_z%26e3Bjgpj61jfh_z%26e3Bv54AzdH3F_jgAzdH3Ff7vwtAzdH3Fnbn8lb_z%26e3Bip4s&gsm=1e&rpstart=0&rpnum=0&islist=&querylist=&nojc=undefined&lid=6977254189663851692"; @Value("${platform.url:http://localhost}") private String platFormUrl; @Value("${rtsp.testUrl:rtsp://admin:Zenap_qweasdzxc@192.168.1.217:554/h264/ch3/main/av_stream}") private String rtspTestUrl; /** * 处理 IVS 心跳请求,保持北向会话处于可用状态。 */ @GetMapping({"/common/keepAlive2", "/common/keepAlive"}) public ResponseEntity keepAlive() { log.info("[GAB] keep alive"); return ResponseEntity .ok() .body(IvsOperationResultView.builder().resultCode(0).build()); } /** * 处理 IVS 登录请求并返回兼容 IVS 的会话 Cookie。 */ @PostMapping({"/loginInfo/login2/v1.0", "/loginInfo/login/v1.0"}) public ResponseEntity login( @RequestBody Login login) { validateLoginRequest(login); log.info("[GAB] login : {}", login.toString()); HttpHeaders responseHeaders = new HttpHeaders(); responseHeaders.set("Set-Cookie", "JSESSIONID=1234567890"); return ResponseEntity .ok() .headers(responseHeaders) .body(IvsOperationResultView.builder().resultCode(0).build()); } /** * 校验并处理 IVS 预置账号密码修改请求。 */ @PostMapping("/loginInfo/modifyPresetUserPwd/v1.0") public ResponseEntity modifyPresetUserPwd( @RequestBody IvsPresetUserPasswordRequest request) { // 先校验账号和密码长度,保证返回结果符合 IVS 入参约束。 validatePresetUserPasswordRequest(request); // 返回 IVS 标准成功结果;当前接口只做协议兼容,不修改本地账号。 return ResponseEntity.ok(IvsOperationResultView.builder().resultCode(0).build()); } /** * 返回旧版 IVS 预置位查询接口的兼容结果。 */ @GetMapping("/device/ptzpresetlist2/{cameraCode}/{domainCode}") public ResponseEntity ptzPresetList( @PathVariable("cameraCode") String cameraCode, @PathVariable("domainCode") String domainCode ) { log.info("[GAB] cameraCode : {}, domainCode: {}", cameraCode, domainCode); List list = new ArrayList<>(); list.add(IvsPresetListView.PtzPresetInfo .builder().presetName("preset").presetIndex(0).focusSwitch(1).reserve("reserve") .build()); return ResponseEntity .ok() .body(IvsPresetListView .builder() .resultCode(0) .ptzPresetNum(1) .ptzPresetInfoList(IvsPresetListView.PtzPresetInfoList.builder().ptzPresetInfo(list).build()) .build()); } /** * 返回旧版 RTSP 模拟接口配置的测试地址。 */ @PostMapping({"/video/rtspurl2/v1.0"}) public ResponseEntity videoRtspUrl( @RequestBody IvsVideoRtspVo rtspVo) { log.info("[GAB] videoRtspUrl : {}, rtspTestUrl: {}", rtspVo.toString(), rtspTestUrl); return ResponseEntity .ok() .body(IvsVideoRtspView .builder() .resultCode(0) //.rtspURL("rtsp://admin:htjc2018@192.168.1.249:554/h264/ch39/main/av_stream") .rtspURL(rtspTestUrl) .build() ); } /** * 根据摄像机编码和媒体参数生成实时预览或录像回放地址。 */ @PostMapping("/video/rtspurl/v1.0") public ResponseEntity videoRtspUrlV1( @RequestBody IvsVideoRtspVo rtspVo) { // 调用 IVS 门面,由厂商路由生成实时或回放 RTSP 地址。 return ResponseEntity.ok(ivsOpenApiService.rtspUrl(rtspVo)); } /** * 生成相机直连 RTSP 地址 * *

入参和出参沿用 v1.0;区别是只返回相机自身地址,不返回 NVR/IVS * 转发地址,也不改变 v1.0 的既有行为。

*/ @PostMapping("/video/rtspurl/v2.0") public ResponseEntity videoRtspUrlV2( @RequestBody IvsVideoRtspVo rtspVo) { return ResponseEntity.ok(ivsOpenApiService.directRtspUrl(rtspVo)); } /** * 创建 IVS 平台抓图任务,对应 IVS3800 文档 4.1.12.1。 * * @param cameraCode 路径中的摄像机编码(不包含 # 后的域编码) * @param domainCode 路径中的域编码 * @param request IVS 抓图查询参数,路径编码会覆盖同名查询参数 * @return 包含 taskID 的 IVS 标准响应 */ @CrossOrigin @GetMapping("/platform/platformSnapshot/{cameraCode}/{domainCode}") public ResponseEntity platformSnapshot( @PathVariable("cameraCode") String cameraCode, @PathVariable("domainCode") String domainCode, @ModelAttribute IvsDevChanSnapVo request) { // Spring 会把 GET 查询参数绑定到 IVS 请求对象,路径参数始终作为最终目标编码。 IvsDevChanSnapVo normalized = request == null ? new IvsDevChanSnapVo() : request; // 写入路径编码,避免客户端通过查询参数改变抓图目标。 normalized.setCameraCode(cameraCode); normalized.setDomainCode(domainCode); // 交给 IVS 门面路由到海康适配器,由适配器复用 /actions/snapshot 抓图实现。 return ResponseEntity.ok(ivsOpenApiService.platformSnapshot(normalized)); } /** * 查询 IVS 平台抓图结果,对应 IVS3800 文档 4.1.12.3。 * * @param request 抓图任务、摄像机和域编码查询参数 * @return IVS 标准抓图列表响应 */ @PostMapping("/platform/snapshotList") public ResponseEntity snapshotList( @RequestBody IvsChanSnapVo request, HttpServletRequest httpRequest) { // 交给 IVS 门面,由厂商适配器根据任务标识读取抓图元数据。 IvsPlatformSnapshotView response = ivsOpenApiService.snapshotList(request); // 将适配器返回的相对图片地址补齐为当前服务的绝对访问地址。 normalizeSnapshotUrls(response, httpRequest); return ResponseEntity.ok(response); } /** * 下载平台抓图任务对应的 JPEG 图片。 * *

该地址由 4.1.12.3 返回的 pictureUrl/previewUrl 使用,图片内容仍由厂商适配器提供。

* * @param taskId 抓图任务标识 * @return JPEG 图片响应 */ @CrossOrigin @GetMapping("/platform/snapshot/{taskId}") public ResponseEntity snapshotImage( @PathVariable("taskId") String taskId) { // 通过 IVS 门面读取图片,控制器不直接依赖海康 SDK 或 NVR 客户端。 byte[] image = ivsOpenApiService.snapshotImage(taskId); // 设置 JPEG 响应类型,确保浏览器和 IVS 客户端按图片处理返回内容。 return ResponseEntity.ok().contentType(MediaType.IMAGE_JPEG).body(image); } /** * 保存摄像机当前位置为新的 IVS 预置位。 */ @PostMapping("/ptz/presetposition/v1.0") public ResponseEntity addPreset( @RequestBody IvsPresetAddRequest request) { // 调用 IVS 门面,由海康适配器保存当前位置并写入设备预置位。 return ResponseEntity.ok(ivsOpenApiService.addPreset(request)); } /** * 修改 IVS 预置位名称,并同步到厂商设备。 */ @PutMapping("/ptz/presetposition/v1.0") public ResponseEntity modifyPreset( @RequestBody IvsPresetModifyRequest request) { // 调用 IVS 门面,由厂商适配器把预置位名称同步到设备端。 return ResponseEntity.ok(ivsOpenApiService.modifyPreset(request)); } /** * 兼容历史调用方式,生成录像回放媒体地址。 */ @RequestMapping(value = "/api/record/url", method = {RequestMethod.GET, RequestMethod.POST}) public ResponseEntity recordUrl( @RequestBody(required = false) IvsVideoRtspVo request, @RequestParam(value = "cameraCode", required = false) String cameraCode, @RequestParam(value = "startTime", required = false) String startTime, @RequestParam(value = "endTime", required = false) String endTime, @RequestParam(value = "streamType", required = false, defaultValue = "1") Integer streamType) { IvsVideoRtspVo normalized = request; // GET 调用没有请求体时,按查询参数组装标准 IVS 回放请求对象。 if (normalized == null) { normalized = new IvsVideoRtspVo(); normalized.setCameraCode(cameraCode); RealMediaURLParam media = new RealMediaURLParam(); media.setServiceType(4); media.setStreamType(streamType == null ? 1 : streamType); media.setTimeSpan(TimeSpan.builder().startTime(startTime).endTime(endTime).build()); normalized.setMediaURLParam(media); // POST 请求体存在但缺少媒体参数时,补齐兼容接口所需的回放参数。 } else if (normalized.getMediaURLParam() == null) { RealMediaURLParam media = new RealMediaURLParam(); media.setServiceType(4); media.setStreamType(streamType == null ? 1 : streamType); media.setTimeSpan(TimeSpan.builder().startTime(startTime).endTime(endTime).build()); normalized.setMediaURLParam(media); } // 统一调用 IVS RTSP 门面生成录像地址。 return ResponseEntity.ok(ivsOpenApiService.rtspUrl(normalized)); } /** * 分页查询指定摄像机的录像记录。 */ @GetMapping("/platform/recordlist/{recordMethod}/{cameraCode}/{domainCode}/{startTime}/{endTime}/{fromIndex}/{toIndex}") public ResponseEntity recordList( @PathVariable("recordMethod") int recordMethod, @PathVariable("cameraCode") String cameraCode, @PathVariable("domainCode") String domainCode, @PathVariable("startTime") String startTime, @PathVariable("endTime") String endTime, @PathVariable("fromIndex") int fromIndex, @PathVariable("toIndex") int toIndex) { // 将录像列表查询交给 IVS 门面,由厂商适配器查询并转换录像记录。 return ResponseEntity.ok(ivsOpenApiService.recordList( recordMethod, cameraCode, domainCode, startTime, endTime, fromIndex, toIndex)); } /** * 启动指定摄像机的平台手动录像任务。 */ @GetMapping("/platform/startplatformrecord/{recordTime}/{cameraCode}/{domainCode}") public ResponseEntity startPlatformRecord( @PathVariable("recordTime") int recordTime, @PathVariable("cameraCode") String cameraCode, @PathVariable("domainCode") String domainCode) { // 调用 IVS 门面启动目标摄像机的平台手动录像。 return ResponseEntity.ok(IvsOperationResultView.builder() .resultCode(ivsOpenApiService.startPlatformRecord( recordTime, cameraCode, domainCode)) .build()); } /** * 停止指定摄像机的平台手动录像任务。 */ @GetMapping("/platform/stopplatformrecord/{cameraCode}/{domainCode}") public ResponseEntity stopPlatformRecord( @PathVariable("cameraCode") String cameraCode, @PathVariable("domainCode") String domainCode) { // 调用 IVS 门面停止目标摄像机的平台手动录像。 return ResponseEntity.ok(IvsOperationResultView.builder() .resultCode(ivsOpenApiService.stopPlatformRecord( cameraCode, domainCode)) .build()); } /** * 释放指定的厂商回放句柄。 */ @GetMapping("/playback/stopplatformplaybackbyip/{handle}") public ResponseEntity stopPlatformPlayback( @PathVariable("handle") long handle, @RequestParam(value = "cameraCode", required = false) String cameraCode, @RequestParam(value = "domainCode", required = false) String domainCode) { // 新调用携带摄像机上下文供 IVS 按 vendor 分流;旧调用仍兼容默认厂商。 return ResponseEntity.ok(IvsOperationResultView.builder() .resultCode(ivsOpenApiService.stopPlatformPlayback( handle, cameraCode, domainCode)) .build()); } /** * 删除指定摄像机上的 IVS 预置位。 */ @DeleteMapping("/ptz/presetposition/{cameraCode}/{domainCode}/{presetIndex}/v1.0") public ResponseEntity deletePreset( @PathVariable("cameraCode") String cameraCode, @PathVariable("domainCode") String domainCode, @PathVariable("presetIndex") String presetIndex) { // 先把路径文本转换为整数并校验范围,再调用预置位删除能力。 int index = parsePresetIndex(presetIndex); // 调用 IVS 门面,由厂商适配器删除设备端预置位。 return ResponseEntity.ok(ivsOpenApiService.deletePreset( cameraCode, domainCode, index)); } /** * 同步所有 NVR 下的摄像机并按请求范围分页返回子设备列表。 */ @GetMapping("/device/deviceList/v1.0") public ResponseEntity getDeviceList( IvsDevChanListVo chanListVo) { log.info("[GAB] deviceList request : {}", chanListVo); // 校验设备类型、分页范围和设备组模式,避免无效查询触发全量同步。 validateDeviceListRequest(chanListVo); // 同步所有 NVR 下的摄像机及在线状态,并获取当前分页结果。 IvsCameraSyncPage page = ivsCameraSyncService.syncAndPage( chanListVo.getFromIndex(), chanListVo.getToIndex()); IvsDeviceListResponse.CameraBriefExInfos cameraBriefExInfos = IvsDeviceListResponse.CameraBriefExInfos.builder() .total(page.getTotal()) .indexRange(IndexRange.builder() .fromIndex(chanListVo.getFromIndex()) .toIndex(chanListVo.getToIndex()) .build()) .cameraBriefInfoExList( IvsDeviceListResponse.CameraBriefInfoExList.builder() .cameraBriefInfoExes(page.getCameras()) .build()) .build(); // 只要有 NVR 同步失败就返回非零结果码,同时仍保留成功同步的摄像机数据。 int resultCode = page.getSyncResult().getFailedNvrCount() == 0 ? 0 : 1; // 组装 IVS 子设备列表响应,包含分页范围、总数和摄像机扩展信息。 return ResponseEntity.ok(IvsDeviceListResponse.builder() .resultCode(resultCode) .cameraBriefExInfos(cameraBriefExInfos) .build()); } /** * 校验子设备列表查询的设备类型、分页范围和设备组模式。 */ private void validateDeviceListRequest(IvsDevChanListVo request) { // 请求体为空时无法读取 deviceType 和分页参数。 if (request == null) { throw HikNvrPlaybackException.badRequest("设备列表查询参数不能为空"); } int deviceType = request.getDeviceType(); // 只接受 IVS 文档定义的摄像机、告警和影子摄像机类型。 if (deviceType != 2 && deviceType != 4 && deviceType != 30 && deviceType != 32 && deviceType != 33 && deviceType != 35) { throw HikNvrPlaybackException.badRequest( "deviceType只能为2、4、30、32、33或35"); } // 分页索引必须从 1 开始且结束索引不能小于开始索引。 if (request.getFromIndex() < 1 || request.getToIndex() < request.getFromIndex()) { throw HikNvrPlaybackException.badRequest( "fromIndex/toIndex必须满足1<=fromIndex<=toIndex"); } // 单次最多返回 2 万个设备,避免一次同步和序列化过大。 if ((long) request.getToIndex() - request.getFromIndex() + 1 > 20000L) { throw HikNvrPlaybackException.badRequest("单次设备列表查询不能超过20000条"); } // groupMode 只允许本地模式 0 或统一设备组模式 1。 if (request.getGroupMode() != 0 && request.getGroupMode() != 1) { throw HikNvrPlaybackException.badRequest("groupMode只能为0或1"); } // IVS 文档规定 deviceType=4 告警设备只能使用本地设备组模式。 if (deviceType == 4 && request.getGroupMode() == 1) { throw HikNvrPlaybackException.badRequest( "deviceType=4时groupMode只能为0"); } } /** * 校验 IVS 登录参数的必填项、长度和登录位置取值。 */ private void validateLoginRequest(Login request) { // 登录请求必须同时提供非空用户名和密码。 if (request == null || !hasText(request.getUsername()) || !hasText(request.getPassword())) { throw HikNvrPlaybackException.badRequest("userName和password不能为空"); } validateLength(request.getUsername(), 512, "userName"); validateLength(request.getPassword(), 512, "password"); // loginLocation 如果传入,只允许 IVS 规定的 0 或 1。 if (request.getLoginLocation() != null && !request.getLoginLocation().isEmpty() && !"0".equals(request.getLoginLocation()) && !"1".equals(request.getLoginLocation())) { throw HikNvrPlaybackException.badRequest("loginLocation只能为0或1"); } } /** * 校验预置账号密码修改请求。 */ private void validatePresetUserPasswordRequest(IvsPresetUserPasswordRequest request) { // 账号密码修改请求必须提供非空用户名和密码。 if (request == null || !hasText(request.getUserName()) || !hasText(request.getPassword())) { throw HikNvrPlaybackException.badRequest("userName和password不能为空"); } validateLength(request.getUserName(), 512, "userName"); validateLength(request.getPassword(), 512, "password"); } /** * 校验字符串字段长度是否超过接口限制。 */ private void validateLength(String value, int maxLength, String fieldName) { // 字段长度超过接口限制时直接返回参数错误。 if (value.length() > maxLength) { throw HikNvrPlaybackException.badRequest( fieldName + "长度不能超过" + maxLength + "个字符"); } } /** * 判断字符串是否包含有效的非空白内容。 */ private boolean hasText(String value) { return value != null && !value.trim().isEmpty(); } /** * 将平台抓图列表中的图片地址转换为当前服务可访问的绝对地址。 * * @param response 抓图列表响应对象 * @param httpRequest 当前 HTTP 请求,用于获取协议、主机和端口 */ private void normalizeSnapshotUrls(IvsPlatformSnapshotView response, HttpServletRequest httpRequest) { // 响应或分页容器为空时没有可转换的图片地址,直接保持原响应。 if (response == null || response.getSnapshotInfoList() == null) { return; } // 根据当前请求生成服务根地址,避免把 127.0.0.1 或固定端口写死在响应中。 String baseUrl = buildCurrentServerBaseUrl(httpRequest); // 内部调用或单元测试可能没有 HTTP 请求上下文,此时保留适配器返回的原地址。 if (!hasText(baseUrl)) { return; } List snapshotInfos = response.getSnapshotInfoList().getSnapshotInfos(); // 没有抓图明细时无需遍历转换 URL。 if (snapshotInfos == null) { return; } for (IvsPlatformSnapshotView.SnapshotInfo snapshotInfo : snapshotInfos) { // 列表中出现空明细时跳过该条数据,避免影响其余抓图结果。 if (snapshotInfo == null) { continue; } // 预览地址和下载地址都指向本服务的抓图资源,分别补齐为绝对地址。 snapshotInfo.setPreviewUrl(toAbsoluteSnapshotUrl(snapshotInfo.getPreviewUrl(), baseUrl)); snapshotInfo.setPictureUrl(toAbsoluteSnapshotUrl(snapshotInfo.getPictureUrl(), baseUrl)); } } /** * 根据当前 HTTP 请求拼出服务协议、主机和端口。 * * @param httpRequest 当前 HTTP 请求 * @return 服务根地址;无法从请求获取时返回空字符串 */ private String buildCurrentServerBaseUrl(HttpServletRequest httpRequest) { // 非 HTTP 调用没有请求上下文,无法安全推断对外访问地址。 if (httpRequest == null || !hasText(httpRequest.getScheme()) || !hasText(httpRequest.getServerName())) { return ""; } String scheme = httpRequest.getScheme().trim(); String serverName = httpRequest.getServerName().trim(); int serverPort = httpRequest.getServerPort(); StringBuilder baseUrl = new StringBuilder(scheme) .append("://") .append(serverName); // HTTP 和 HTTPS 使用默认端口时不重复拼接端口号。 boolean defaultPort = ("http".equalsIgnoreCase(scheme) && serverPort == 80) || ("https".equalsIgnoreCase(scheme) && serverPort == 443); // 服务使用非默认端口(例如 8081)时必须保留端口,确保链接可以直接访问。 if (!defaultPort && serverPort > 0) { baseUrl.append(':').append(serverPort); } return baseUrl.toString(); } /** * 将相对抓图地址拼接到服务根地址;适配器已返回绝对地址时保持原值。 * * @param url 适配器返回的抓图地址 * @param baseUrl 当前服务根地址 * @return 可直接访问的绝对抓图地址 */ private String toAbsoluteSnapshotUrl(String url, String baseUrl) { // 地址为空或基础地址不可用时不改变适配器的返回结果。 if (!hasText(url) || !hasText(baseUrl)) { return url; } String normalizedUrl = url.trim(); // 适配器已经返回 http、https 等绝对地址时避免重复拼接。 if (normalizedUrl.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*$")) { return normalizedUrl; } // 以斜杠开头的路径直接拼接,其余相对路径先补一个斜杠。 return normalizedUrl.startsWith("/") ? baseUrl + normalizedUrl : baseUrl + "/" + normalizedUrl; } /** * 将路径中的预置位编号解析为合法整数。 */ private int parsePresetIndex(String value) { // 路径编号只能由数字组成,拒绝负号和其他字符。 if (value == null || !value.matches("\\d+")) { throw HikNvrPlaybackException.badRequest("presetIndex必须是非负整数"); } try { long parsed = Long.parseLong(value); // 海康预置位编号最大为 255,超过后不再转换为 int。 if (parsed > 255) { throw HikNvrPlaybackException.badRequest("presetIndex不能超过255"); } return (int) parsed; } catch (NumberFormatException ex) { throw HikNvrPlaybackException.badRequest("presetIndex超出整数范围"); } } }