You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

572 lines
27 KiB

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=&copyright=&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<IvsOperationResultView> 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<IvsOperationResultView> 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<IvsOperationResultView> modifyPresetUserPwd(
@RequestBody IvsPresetUserPasswordRequest request) {
// 先校验账号和密码长度,保证返回结果符合 IVS 入参约束。
validatePresetUserPasswordRequest(request);
// 返回 IVS 标准成功结果;当前接口只做协议兼容,不修改本地账号。
return ResponseEntity.ok(IvsOperationResultView.builder().resultCode(0).build());
}
/**
* 返回旧版 IVS 预置位查询接口的兼容结果。
*/
@GetMapping("/device/ptzpresetlist2/{cameraCode}/{domainCode}")
public ResponseEntity<IvsPresetListView> ptzPresetList(
@PathVariable("cameraCode") String cameraCode,
@PathVariable("domainCode") String domainCode
) {
log.info("[GAB] cameraCode : {}, domainCode: {}", cameraCode, domainCode);
List<IvsPresetListView.PtzPresetInfo> 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<IvsVideoRtspView> 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<IvsRtspUrlResponse> videoRtspUrlV1(
@RequestBody IvsVideoRtspVo rtspVo) {
// 调用 IVS 门面,由厂商路由生成实时或回放 RTSP 地址。
return ResponseEntity.ok(ivsOpenApiService.rtspUrl(rtspVo));
}
/**
* 生成相机直连 RTSP 地址
*
* <p>入参和出参沿用 v1.0;区别是只返回相机自身地址,不返回 NVR/IVS
* 转发地址,也不改变 v1.0 的既有行为。</p>
*/
@PostMapping("/video/rtspurl/v2.0")
public ResponseEntity<IvsRtspUrlResponse> 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<IvsSnapshotCommandResponse> 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<IvsPlatformSnapshotView> snapshotList(
@RequestBody IvsChanSnapVo request,
HttpServletRequest httpRequest) {
// 交给 IVS 门面,由厂商适配器根据任务标识读取抓图元数据。
IvsPlatformSnapshotView response = ivsOpenApiService.snapshotList(request);
// 将适配器返回的相对图片地址补齐为当前服务的绝对访问地址。
normalizeSnapshotUrls(response, httpRequest);
return ResponseEntity.ok(response);
}
/**
* 下载平台抓图任务对应的 JPEG 图片。
*
* <p>该地址由 4.1.12.3 返回的 pictureUrl/previewUrl 使用,图片内容仍由厂商适配器提供。</p>
*
* @param taskId 抓图任务标识
* @return JPEG 图片响应
*/
@CrossOrigin
@GetMapping("/platform/snapshot/{taskId}")
public ResponseEntity<byte[]> 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<IvsPresetOperationView> addPreset(
@RequestBody IvsPresetAddRequest request) {
// 调用 IVS 门面,由海康适配器保存当前位置并写入设备预置位。
return ResponseEntity.ok(ivsOpenApiService.addPreset(request));
}
/**
* 修改 IVS 预置位名称,并同步到厂商设备。
*/
@PutMapping("/ptz/presetposition/v1.0")
public ResponseEntity<IvsOperationResultView> modifyPreset(
@RequestBody IvsPresetModifyRequest request) {
// 调用 IVS 门面,由厂商适配器把预置位名称同步到设备端。
return ResponseEntity.ok(ivsOpenApiService.modifyPreset(request));
}
/**
* 兼容历史调用方式,生成录像回放媒体地址。
*/
@RequestMapping(value = "/api/record/url", method = {RequestMethod.GET, RequestMethod.POST})
public ResponseEntity<IvsRtspUrlResponse> 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<IvsPlatformRecordListView> 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<IvsOperationResultView> 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<IvsOperationResultView> 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<IvsOperationResultView> 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<IvsOperationResultView> 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<IvsDeviceListResponse> 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<IvsPlatformSnapshotView.SnapshotInfo> 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超出整数范围");
}
}
}