| @ -1,112 +0,0 @@ | |||
| package com.inspect.nvr.controller; | |||
| import com.inspect.nvr.ivs.service.IvsCameraSyncService; | |||
| import com.inspect.nvr.ivs.service.IvsOpenApiService; | |||
| import com.inspect.nvr.view.IvsPlatformSnapshotView; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.test.web.servlet.MockMvc; | |||
| import java.util.Collections; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.Mockito.when; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |||
| import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; | |||
| /** | |||
| * 验证 IVS 抓图列表接口返回的图片地址格式。 | |||
| */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class IvsServerControllerTest { | |||
| @Mock | |||
| private IvsCameraSyncService ivsCameraSyncService; | |||
| @Mock | |||
| private IvsOpenApiService ivsOpenApiService; | |||
| private MockMvc mockMvc; | |||
| @Before | |||
| public void setUp() { | |||
| // 使用独立控制器测试,避免启动数据库和厂商 SDK 环境。 | |||
| IvsServerController controller = new IvsServerController( | |||
| ivsCameraSyncService, ivsOpenApiService); | |||
| mockMvc = standaloneSetup(controller).build(); | |||
| } | |||
| @Test | |||
| public void expandsRelativeSnapshotUrlsToCurrentRequestAddress() throws Exception { | |||
| // 模拟适配器返回 IVS 文档中的相对抓图地址。 | |||
| IvsPlatformSnapshotView.SnapshotInfo info = IvsPlatformSnapshotView.SnapshotInfo | |||
| .builder() | |||
| .previewUrl("/platform/snapshot/task-001") | |||
| .pictureUrl("/platform/snapshot/task-001") | |||
| .build(); | |||
| IvsPlatformSnapshotView response = IvsPlatformSnapshotView.builder() | |||
| .resultCode("0") | |||
| .snapshotInfoList(IvsPlatformSnapshotView.SnapshotInfoList.builder() | |||
| .total(1) | |||
| .snapshotInfos(Collections.singletonList(info)) | |||
| .build()) | |||
| .build(); | |||
| when(ivsOpenApiService.snapshotList(any())).thenReturn(response); | |||
| // 设置与用户调用示例一致的协议、主机和 8081 端口。 | |||
| mockMvc.perform(post("/platform/snapshotList") | |||
| .with(request -> { | |||
| request.setScheme("http"); | |||
| request.setServerName("127.0.0.1"); | |||
| request.setServerPort(8081); | |||
| return request; | |||
| }) | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"taskID\":\"task-001\"}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.snapshotInfoList.snapshotInfos[0].previewUrl") | |||
| .value("http://127.0.0.1:8081/platform/snapshot/task-001")) | |||
| .andExpect(jsonPath("$.snapshotInfoList.snapshotInfos[0].pictureUrl") | |||
| .value("http://127.0.0.1:8081/platform/snapshot/task-001")); | |||
| } | |||
| @Test | |||
| public void keepsExistingAbsoluteSnapshotUrls() throws Exception { | |||
| // 已经是绝对地址时不应再次拼接当前服务地址。 | |||
| IvsPlatformSnapshotView.SnapshotInfo info = IvsPlatformSnapshotView.SnapshotInfo | |||
| .builder() | |||
| .previewUrl("https://cdn.example.com/task-001.jpg") | |||
| .pictureUrl("https://cdn.example.com/task-001.jpg") | |||
| .build(); | |||
| IvsPlatformSnapshotView response = IvsPlatformSnapshotView.builder() | |||
| .resultCode("0") | |||
| .snapshotInfoList(IvsPlatformSnapshotView.SnapshotInfoList.builder() | |||
| .total(1) | |||
| .snapshotInfos(Collections.singletonList(info)) | |||
| .build()) | |||
| .build(); | |||
| when(ivsOpenApiService.snapshotList(any())).thenReturn(response); | |||
| // 通过另一个主机调用,确认外部绝对地址仍保持原样。 | |||
| mockMvc.perform(post("/platform/snapshotList") | |||
| .with(request -> { | |||
| request.setScheme("http"); | |||
| request.setServerName("192.168.1.10"); | |||
| request.setServerPort(8081); | |||
| return request; | |||
| }) | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"taskID\":\"task-001\"}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.snapshotInfoList.snapshotInfos[0].previewUrl") | |||
| .value("https://cdn.example.com/task-001.jpg")) | |||
| .andExpect(jsonPath("$.snapshotInfoList.snapshotInfos[0].pictureUrl") | |||
| .value("https://cdn.example.com/task-001.jpg")); | |||
| } | |||
| } | |||
| @ -1,87 +0,0 @@ | |||
| package com.inspect.nvr.dahua; | |||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.domain.device.IvsPresetListView; | |||
| import com.inspect.nvr.hik.domain.HikNvrCameraTarget; | |||
| import com.inspect.nvr.hik.service.HikNvrCameraTargetService; | |||
| import com.inspect.nvr.service.DahuaLoginService; | |||
| import com.sun.jna.ptr.IntByReference; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.nio.charset.StandardCharsets; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.anyInt; | |||
| import static org.mockito.ArgumentMatchers.anyString; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.ArgumentMatchers.isNull; | |||
| import static org.mockito.Mockito.when; | |||
| /** 验证大华 IVS 适配器按真实固件 JSON 读取多通道预置位。 */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class DahuaIvsVendorServiceTest { | |||
| /** 模拟大华原生 SDK。 */ | |||
| @Mock | |||
| private NetSDKLib sdk; | |||
| /** 模拟大华登录缓存。 */ | |||
| @Mock | |||
| private DahuaLoginService loginService; | |||
| /** 模拟完整 cameraCode 目标解析。 */ | |||
| @Mock | |||
| private HikNvrCameraTargetService cameraTargetService; | |||
| /** 待验证的大华厂商适配器。 */ | |||
| private DahuaIvsVendorService service; | |||
| /** 注入最小依赖并固定目标为第三路大华摄像机。 */ | |||
| @Before | |||
| public void setUp() { | |||
| service = new DahuaIvsVendorService(); | |||
| ReflectionTestUtils.setField(service, "sdk", sdk); | |||
| ReflectionTestUtils.setField(service, "loginService", loginService); | |||
| ReflectionTestUtils.setField(service, "cameraTargetService", cameraTargetService); | |||
| NvrInfo nvr = new NvrInfo(); | |||
| nvr.setNvrCompany("DAHUA"); | |||
| nvr.setNvrIp("192.168.1.134"); | |||
| HikNvrCameraTarget target = new HikNvrCameraTarget( | |||
| "32078710632508667003#domain-a", 3, nvr); | |||
| when(cameraTargetService.resolve(anyString())).thenReturn(target); | |||
| when(loginService.login(nvr)).thenReturn(new NetSDKLib.LLong(88)); | |||
| } | |||
| /** SDK 返回全部通道嵌套 table 时只能读取第三路启用的预置位。 */ | |||
| @Test | |||
| public void readsEnabledPresetsFromTargetChannelJson() { | |||
| String json = "{\"id\":1,\"params\":{\"table\":[[],[],[" | |||
| + "{\"Enable\":true,\"Index\":1,\"Name\":\"预置位1\",\"Position\":[0.0,0.0,0.0]}," | |||
| + "{\"Enable\":false,\"Index\":2,\"Name\":\"Preset2\",\"Position\":[0.0,0.0,0.0]}," | |||
| + "{\"Enable\":true,\"Index\":29,\"Name\":\"Gate\",\"Position\":[1.0,2.0,3.0]}" | |||
| + "]]}}"; | |||
| // 模拟 CLIENT_GetNewDevConfig 把真实固件 JSON 写入调用方缓冲区。 | |||
| when(sdk.CLIENT_GetNewDevConfig( | |||
| any(), eq(NetSDKLib.CFG_CMD_PTZ_PRESET), eq(-1), | |||
| any(byte[].class), anyInt(), any(IntByReference.class), | |||
| anyInt(), isNull())).thenAnswer(invocation -> { | |||
| byte[] buffer = invocation.getArgument(3); | |||
| byte[] source = json.getBytes(StandardCharsets.UTF_8); | |||
| System.arraycopy(source, 0, buffer, 0, source.length); | |||
| return true; | |||
| }); | |||
| IvsPresetListView result = service.presetList( | |||
| "32078710632508667003", "domain-a"); | |||
| assertEquals(2, result.getPtzPresetNum()); | |||
| assertEquals(1, result.getPtzPresetInfoList().getPtzPresetInfo().get(0).getPresetIndex()); | |||
| assertEquals("预置位1", result.getPtzPresetInfoList().getPtzPresetInfo().get(0).getPresetName()); | |||
| assertEquals(29, result.getPtzPresetInfoList().getPtzPresetInfo().get(1).getPresetIndex()); | |||
| assertEquals("Gate", result.getPtzPresetInfoList().getPtzPresetInfo().get(1).getPresetName()); | |||
| } | |||
| } | |||
| @ -1,156 +0,0 @@ | |||
| package com.inspect.nvr.dahua; | |||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib; | |||
| import com.inspect.nvr.daHuaCarme.jna.dahua.ToolKits; | |||
| import com.inspect.nvr.ivs.pojo.IvsNvr; | |||
| import com.inspect.nvr.ivs.util.IvsNvrChannelClient; | |||
| import com.inspect.nvr.service.DahuaLoginService; | |||
| import com.sun.jna.Pointer; | |||
| import com.sun.jna.ptr.IntByReference; | |||
| import org.junit.Test; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.nio.charset.Charset; | |||
| import java.util.List; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.anyInt; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.ArgumentMatchers.isNull; | |||
| import static org.mockito.Mockito.mock; | |||
| import static org.mockito.Mockito.when; | |||
| /** | |||
| * 验证大华 NetSDK RemoteDevice 配置和在线状态能够转换为 IVS 同步通道。 | |||
| */ | |||
| public class DahuaNvrChannelClientTest { | |||
| /** 大华设备文本测试使用与生产代码一致的 GBK 编码。 */ | |||
| private static final Charset DAHUA_CHARSET = Charset.forName("GBK"); | |||
| /** | |||
| * NetSDK 的零基统一通道、远程 IP、名称和在线状态必须映射为一基同步通道。 | |||
| */ | |||
| @Test | |||
| public void mapsAllConfiguredRemoteCamerasAndConnectionStates() { | |||
| NetSDKLib sdk = mock(NetSDKLib.class); | |||
| NetSDKLib configSdk = mock(NetSDKLib.class); | |||
| DahuaLoginService loginService = mock(DahuaLoginService.class); | |||
| NetSDKLib.LLong loginHandle = new NetSDKLib.LLong(19); | |||
| when(loginService.login(any())).thenReturn(loginHandle); | |||
| // 模拟 NetSDK 成功返回 RemoteDevice 原始配置。 | |||
| when(sdk.CLIENT_GetNewDevConfig( | |||
| eq(loginHandle), | |||
| eq(NetSDKLib.CFG_CMD_REMOTEDEVICE), | |||
| eq(-1), | |||
| any(byte[].class), | |||
| anyInt(), | |||
| any(IntByReference.class), | |||
| anyInt(), | |||
| isNull())).thenAnswer(invocation -> { | |||
| put(invocation.getArgument(3), "{\"params\":{\"table\":{}}}"); | |||
| return true; | |||
| }); | |||
| // 模拟 dhconfigsdk 将原始 JSON 解析成两路连续 RemoteDevice 结构体。 | |||
| when(configSdk.CLIENT_ParseData( | |||
| eq(NetSDKLib.CFG_CMD_REMOTEDEVICE), | |||
| any(byte[].class), | |||
| any(Pointer.class), | |||
| anyInt(), | |||
| isNull())).thenAnswer(invocation -> { | |||
| Pointer output = invocation.getArgument(2); | |||
| NetSDKLib.AV_CFG_RemoteDevice first = remoteDevice( | |||
| 0, "192.168.1.220", "东门球机", 37777); | |||
| NetSDKLib.AV_CFG_RemoteDevice second = remoteDevice( | |||
| 1, "192.168.1.221", "西门枪机", 8000); | |||
| ToolKits.SetStructDataToPointer(first, output, 0); | |||
| ToolKits.SetStructDataToPointer(second, output, first.size()); | |||
| return true; | |||
| }); | |||
| // 模拟 QueryDevInfo 返回第一路已连接、第二路未连接。 | |||
| when(sdk.CLIENT_QueryDevInfo( | |||
| eq(loginHandle), | |||
| eq(NetSDKLib.NET_QUERY_GET_CAMERA_STATE), | |||
| any(Pointer.class), | |||
| any(Pointer.class), | |||
| any(), | |||
| anyInt())).thenAnswer(invocation -> { | |||
| Pointer outputPointer = invocation.getArgument(3); | |||
| NetSDKLib.NET_OUT_GET_CAMERA_STATEINFO output = | |||
| new NetSDKLib.NET_OUT_GET_CAMERA_STATEINFO(); | |||
| ToolKits.GetPointerData(outputPointer, output); | |||
| NetSDKLib.NET_CAMERA_STATE_INFO first = new NetSDKLib.NET_CAMERA_STATE_INFO(); | |||
| first.nChannel = 0; | |||
| first.emConnectionState = | |||
| NetSDKLib.EM_CAMERA_STATE_TYPE.EM_CAMERA_STATE_TYPE_CONNECTED; | |||
| ToolKits.SetStructDataToPointer(first, output.pCameraStateInfo, 0); | |||
| NetSDKLib.NET_CAMERA_STATE_INFO second = new NetSDKLib.NET_CAMERA_STATE_INFO(); | |||
| second.nChannel = 1; | |||
| second.emConnectionState = | |||
| NetSDKLib.EM_CAMERA_STATE_TYPE.EM_CAMERA_STATE_TYPE_UNCONNECT; | |||
| ToolKits.SetStructDataToPointer( | |||
| second, output.pCameraStateInfo, first.size()); | |||
| output.nValidNum = 2; | |||
| ToolKits.SetStructDataToPointer(output, outputPointer, 0); | |||
| return true; | |||
| }); | |||
| DahuaNvrChannelClient client = new DahuaNvrChannelClient(); | |||
| ReflectionTestUtils.setField(client, "dhNetSDK", sdk); | |||
| ReflectionTestUtils.setField(client, "dhConfigSDK", configSdk); | |||
| ReflectionTestUtils.setField(client, "dahuaLoginService", loginService); | |||
| // 调用实际转换链路,确保数据库参数经登录和两次 SDK 查询后生成统一 Channel。 | |||
| List<IvsNvrChannelClient.Channel> channels = client.listChannels(nvr()); | |||
| assertEquals(2, channels.size()); | |||
| IvsNvrChannelClient.Channel first = channels.get(0); | |||
| assertEquals(Integer.valueOf(1), first.getId()); | |||
| assertEquals("东门球机", first.getName()); | |||
| assertEquals("DAHUA", first.getProtocol()); | |||
| assertEquals("192.168.1.220", first.getIp()); | |||
| assertEquals(Integer.valueOf(37777), first.getPort()); | |||
| assertEquals(Integer.valueOf(1), first.getSourceChannel()); | |||
| assertTrue(first.isOnline()); | |||
| IvsNvrChannelClient.Channel second = channels.get(1); | |||
| assertEquals(Integer.valueOf(2), second.getId()); | |||
| assertEquals("西门枪机", second.getName()); | |||
| assertFalse(second.isOnline()); | |||
| } | |||
| /** 构造大华同步所需的最小数据库 NVR 记录。 */ | |||
| private IvsNvr nvr() { | |||
| IvsNvr nvr = new IvsNvr(); | |||
| nvr.setNvrIp("192.168.1.134"); | |||
| nvr.setSdkPort(37777); | |||
| nvr.setUsername("admin"); | |||
| nvr.setPassword("password"); | |||
| nvr.setVendor("Dahua"); | |||
| return nvr; | |||
| } | |||
| /** 构造 config SDK 返回的最小远程设备结构体。 */ | |||
| private static NetSDKLib.AV_CFG_RemoteDevice remoteDevice( | |||
| int channel, String ip, String name, int port) { | |||
| NetSDKLib.AV_CFG_RemoteDevice device = new NetSDKLib.AV_CFG_RemoteDevice(); | |||
| device.bEnable = 1; | |||
| put(device.szID, "uuid:System_CONFIG_NETCAMERA_INFO_" + channel); | |||
| put(device.szIP, ip); | |||
| put(device.szName, name); | |||
| device.nPort = port; | |||
| return device; | |||
| } | |||
| /** 将测试文本按大华设备编码写入固定长度的零结尾字节数组。 */ | |||
| private static void put(byte[] target, String value) { | |||
| byte[] bytes = value.getBytes(DAHUA_CHARSET); | |||
| System.arraycopy(bytes, 0, target, 0, Math.min(bytes.length, target.length - 1)); | |||
| } | |||
| } | |||
| @ -1,148 +0,0 @@ | |||
| package com.inspect.nvr.dahua; | |||
| import com.inspect.nvr.daHuaCarme.jna.NetSDKLib; | |||
| import com.inspect.nvr.daHuaCarme.jna.dahua.LastError; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackResponse; | |||
| import com.inspect.nvr.service.DahuaLoginService; | |||
| import com.sun.jna.ptr.IntByReference; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.anyBoolean; | |||
| import static org.mockito.ArgumentMatchers.anyInt; | |||
| import static org.mockito.ArgumentMatchers.isNull; | |||
| import static org.mockito.Mockito.doAnswer; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| /** 大华录像查询的最小 SDK 映射回归测试。 */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class DahuaNvrPlaybackServiceTest { | |||
| /** 大华原生 SDK 模拟对象。 */ | |||
| @Mock | |||
| private NetSDKLib sdk; | |||
| /** 大华登录服务模拟对象。 */ | |||
| @Mock | |||
| private DahuaLoginService loginService; | |||
| /** 待验证的大华录像服务。 */ | |||
| private DahuaNvrPlaybackService service; | |||
| /** 注入模拟依赖并准备固定登录句柄。 */ | |||
| @Before | |||
| public void setUp() { | |||
| service = new DahuaNvrPlaybackService(); | |||
| ReflectionTestUtils.setField(service, "sdk", sdk); | |||
| ReflectionTestUtils.setField(service, "loginService", loginService); | |||
| when(loginService.login(any())).thenReturn(new NetSDKLib.LLong(88)); | |||
| when(sdk.CLIENT_SetDeviceMode(any(), anyInt(), any())).thenReturn(true); | |||
| } | |||
| /** | |||
| * 设备返回相邻录像文件时应合并成一个连续区间,并生成大华 RTSP 地址。 | |||
| */ | |||
| @Test | |||
| public void mergesAdjacentRecordFilesAndBuildsDahuaPlaybackUrl() { | |||
| // 模拟大华 SDK 返回两段首尾相接的主码流录像。 | |||
| doAnswer(invocation -> { | |||
| NetSDKLib.NET_RECORDFILE_INFO[] files = invocation.getArgument(6); | |||
| setTime(files[0].starttime, 10, 0, 0); | |||
| setTime(files[0].endtime, 10, 5, 0); | |||
| setTime(files[1].starttime, 10, 5, 0); | |||
| setTime(files[1].endtime, 10, 10, 0); | |||
| IntByReference count = invocation.getArgument(8); | |||
| count.setValue(2); | |||
| return true; | |||
| }).when(sdk).CLIENT_QueryRecordFile( | |||
| any(), | |||
| anyInt(), | |||
| anyInt(), | |||
| any(NetSDKLib.NET_TIME.class), | |||
| any(NetSDKLib.NET_TIME.class), | |||
| isNull(), | |||
| any(NetSDKLib.NET_RECORDFILE_INFO[].class), | |||
| anyInt(), | |||
| any(IntByReference.class), | |||
| anyInt(), | |||
| anyBoolean()); | |||
| HikNvrPlaybackResponse response = service.search(request()); | |||
| // 相邻文件必须合并,避免 HLS 创建被误判为多个不连续录像段。 | |||
| assertTrue(response.isHasRecord()); | |||
| assertEquals(1, response.getRecords().size()); | |||
| assertEquals("20260811100000", response.getRecords().get(0).getTimeRange().get(0)); | |||
| assertEquals("20260811101000", response.getRecords().get(0).getTimeRange().get(1)); | |||
| assertEquals( | |||
| "rtsp://admin:p%40ss@192.168.1.108:554/cam/playback" | |||
| + "?channel=2&subtype=0&starttime=2026_08_11_10_00_00" | |||
| + "&endtime=2026_08_11_10_10_00", | |||
| response.getRecords().get(0).getRtspUrl()); | |||
| // 页面2号通道必须转换为大华0基SDK通道1。 | |||
| verify(sdk).CLIENT_QueryRecordFile( | |||
| any(), | |||
| org.mockito.ArgumentMatchers.eq(1), | |||
| anyInt(), | |||
| any(NetSDKLib.NET_TIME.class), | |||
| any(NetSDKLib.NET_TIME.class), | |||
| isNull(), | |||
| any(NetSDKLib.NET_RECORDFILE_INFO[].class), | |||
| anyInt(), | |||
| any(IntByReference.class), | |||
| anyInt(), | |||
| anyBoolean()); | |||
| } | |||
| /** 大华“未找到录像”错误码属于正常空结果,不能转换成 HTTP 502。 */ | |||
| @Test | |||
| public void returnsEmptyResultWhenDeviceHasNoRecord() { | |||
| // 模拟真实 NVR:查询方法返回 false,同时最后错误码为 NET_NO_RECORD_FOUND。 | |||
| when(sdk.CLIENT_QueryRecordFile( | |||
| any(), anyInt(), anyInt(), any(NetSDKLib.NET_TIME.class), | |||
| any(NetSDKLib.NET_TIME.class), isNull(), | |||
| any(NetSDKLib.NET_RECORDFILE_INFO[].class), anyInt(), | |||
| any(IntByReference.class), anyInt(), anyBoolean())) | |||
| .thenReturn(false); | |||
| when(sdk.CLIENT_GetLastError()).thenReturn(LastError.NET_NO_RECORD_FOUND); | |||
| HikNvrPlaybackResponse response = service.search(request()); | |||
| assertFalse(response.isHasRecord()); | |||
| assertTrue(response.getRecords().isEmpty()); | |||
| } | |||
| /** 创建一条使用数据库大华凭据的录像请求。 */ | |||
| private HikNvrPlaybackRequest request() { | |||
| return HikNvrPlaybackRequest.builder() | |||
| .vendor("DAHUA") | |||
| .cameraCode("22078710632508667002#domain-a") | |||
| .nvrIp("192.168.1.108") | |||
| .sdkPort(37777) | |||
| .rtspPort(554) | |||
| .channel(2) | |||
| .streamType(1) | |||
| .username("admin") | |||
| .password("p@ss") | |||
| .startTime("2026-08-11 10:00:00") | |||
| .endTime("2026-08-11 10:10:00") | |||
| .build(); | |||
| } | |||
| /** 填充固定日期下的大华 SDK 时间结构。 */ | |||
| private void setTime( | |||
| NetSDKLib.NET_TIME time, | |||
| int hour, | |||
| int minute, | |||
| int second) { | |||
| time.setTime(2026, 8, 11, hour, minute, second); | |||
| } | |||
| } | |||
| @ -1,35 +0,0 @@ | |||
| package com.inspect.nvr.domain.ivs; | |||
| import com.fasterxml.jackson.databind.JsonNode; | |||
| import com.fasterxml.jackson.databind.ObjectMapper; | |||
| import org.junit.Test; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| /** | |||
| * 校验平台抓图任务创建响应的 IVS 顶层 JSON 结构。 | |||
| */ | |||
| public class IvsSnapshotCommandResponseTest { | |||
| @Test | |||
| public void serializesResultCodeAndTaskIdAtTopLevel() throws Exception { | |||
| // 构造一个成功的抓图任务响应,模拟海康适配器返回的任务编号。 | |||
| IvsSnapshotCommandResponse response = IvsSnapshotCommandResponse.builder() | |||
| .resultCode(0) | |||
| .taskID("0BE0B566938F11EC80008CE5EF7F1DD0") | |||
| .build(); | |||
| // 使用项目实际使用的 Jackson 序列化响应,验证接口输出而不是 Java 对象字段。 | |||
| JsonNode json = new ObjectMapper().readTree( | |||
| new ObjectMapper().writeValueAsString(response)); | |||
| // resultCode 必须是顶层数字,taskID 必须是顶层字符串。 | |||
| assertEquals(0, json.get("resultCode").asInt()); | |||
| assertEquals("0BE0B566938F11EC80008CE5EF7F1DD0", | |||
| json.get("taskID").asText()); | |||
| // 响应只能包含文档定义的两个顶层字段,不能出现 resultBody 包装层。 | |||
| assertFalse(json.has("resultBody")); | |||
| assertEquals(2, json.size()); | |||
| } | |||
| } | |||
| @ -1,179 +0,0 @@ | |||
| package com.inspect.nvr.hik.controller; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackClipRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRecord; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackResponse; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.hik.service.HikNvrPlaybackClipFile; | |||
| import com.inspect.nvr.hik.service.HikNvrPlaybackClipService; | |||
| import com.inspect.nvr.hik.service.HikNvrPlaybackService; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.http.ResponseEntity; | |||
| import org.springframework.test.web.servlet.MockMvc; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody; | |||
| import java.io.ByteArrayOutputStream; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.nio.file.Paths; | |||
| import java.time.LocalDateTime; | |||
| import java.util.Arrays; | |||
| import java.util.Collections; | |||
| import static org.junit.Assert.assertArrayEquals; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.Mockito.doAnswer; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |||
| import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrPlaybackControllerTest { | |||
| private static final String REQUEST_JSON = "{" | |||
| + "\"nvrIp\":\"192.168.1.100\"," | |||
| + "\"sdkPort\":8000," | |||
| + "\"rtspPort\":554," | |||
| + "\"channel\":1," | |||
| + "\"streamType\":1," | |||
| + "\"username\":\"admin\"," | |||
| + "\"password\":\"123456\"," | |||
| + "\"startTime\":\"2026-07-13 12:00:00\"," | |||
| + "\"endTime\":\"2026-07-13 13:00:00\"" | |||
| + "}"; | |||
| @Mock | |||
| private HikNvrPlaybackService playbackService; | |||
| @Mock | |||
| private HikNvrPlaybackClipService playbackClipService; | |||
| private MockMvc mockMvc; | |||
| private HikNvrPlaybackController controller; | |||
| /** | |||
| * 创建控制器并注入录像查询及片段下载服务替身。 | |||
| */ | |||
| @Before | |||
| public void setUp() { | |||
| controller = new HikNvrPlaybackController(); | |||
| ReflectionTestUtils.setField(controller, "playbackService", playbackService); | |||
| ReflectionTestUtils.setField( | |||
| controller, "playbackClipService", playbackClipService); | |||
| mockMvc = standaloneSetup(controller) | |||
| .setControllerAdvice(new HikNvrPlaybackExceptionHandler()) | |||
| .build(); | |||
| } | |||
| @Test | |||
| public void returnsActualTimeRangeAndPlaybackUrl() throws Exception { | |||
| String expectedUrl = "rtsp://admin:123456@192.168.1.100:554/Streaming/tracks/101" | |||
| + "?starttime=20260713T043000Z&endtime=20260713T050000Z"; | |||
| HikNvrPlaybackRecord record = HikNvrPlaybackRecord.builder() | |||
| .timeRange(Arrays.asList("20260713123000", "20260713130000")) | |||
| .rtspUrl(expectedUrl) | |||
| .build(); | |||
| when(playbackService.search(any())) | |||
| .thenReturn(HikNvrPlaybackResponse.success(Collections.singletonList(record))); | |||
| mockMvc.perform(post("/hik/nvr/playback/search") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content(REQUEST_JSON)) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.code").value(0)) | |||
| .andExpect(jsonPath("$.hasRecord").value(true)) | |||
| .andExpect(jsonPath("$.records[0].timeRange[0]").value("20260713123000")) | |||
| .andExpect(jsonPath("$.records[0].timeRange[1]").value("20260713130000")) | |||
| .andExpect(jsonPath("$.records[0].rtspUrl").value(expectedUrl)); | |||
| } | |||
| @Test | |||
| public void mapsInvalidRequestToHttp400() throws Exception { | |||
| when(playbackService.search(any())).thenThrow( | |||
| HikNvrPlaybackException.badRequest("结束时间必须晚于开始时间")); | |||
| mockMvc.perform(post("/hik/nvr/playback/search") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content(REQUEST_JSON)) | |||
| .andExpect(status().isBadRequest()) | |||
| .andExpect(jsonPath("$.code").value(-1)) | |||
| .andExpect(jsonPath("$.message").value("结束时间必须晚于开始时间")); | |||
| } | |||
| @Test | |||
| public void mapsSdkFailureToHttp502WithSdkCode() throws Exception { | |||
| when(playbackService.search(any())).thenThrow( | |||
| HikNvrPlaybackException.sdkFailure(7, "连接海康NVR失败,SDK错误码:7")); | |||
| mockMvc.perform(post("/hik/nvr/playback/search") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content(REQUEST_JSON)) | |||
| .andExpect(status().isBadGateway()) | |||
| .andExpect(jsonPath("$.code").value(7)) | |||
| .andExpect(jsonPath("$.message").value("连接海康NVR失败,SDK错误码:7")); | |||
| } | |||
| @Test | |||
| public void mapsUnreadableJsonToDocumentedHttp400Body() throws Exception { | |||
| mockMvc.perform(post("/hik/nvr/playback/search") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"sdkPort\":\"not-a-number\"}")) | |||
| .andExpect(status().isBadRequest()) | |||
| .andExpect(jsonPath("$.code").value(-1)) | |||
| .andExpect(jsonPath("$.message") | |||
| .value("请求体格式错误,请检查JSON字段类型和格式")); | |||
| } | |||
| /** | |||
| * 验证片段下载响应包含MP4类型、附件文件名、长度并流式写出文件。 | |||
| */ | |||
| @Test | |||
| public void returnsPreparedClipAsMp4Attachment() throws Exception { | |||
| // 下载接口只接收设备树返回的完整 cameraCode,不接收页面提交的 NVR IP 和通道。 | |||
| HikNvrPlaybackClipRequest request = HikNvrPlaybackClipRequest.builder() | |||
| .cameraCode("22078710632508667002#domain-a") | |||
| .centerTime("2026-07-28 11:55:00") | |||
| .build(); | |||
| HikNvrPlaybackClipFile clipFile = new HikNvrPlaybackClipFile( | |||
| Paths.get("clip.mp4"), | |||
| "hik-22078710632508667002-domain-a-20260728-115430_20260728-115530.mp4", | |||
| 9L, | |||
| LocalDateTime.of(2026, 7, 28, 11, 54, 30), | |||
| LocalDateTime.of(2026, 7, 28, 11, 55, 30)); | |||
| when(playbackClipService.createClip(request)).thenReturn(clipFile); | |||
| // 用固定字节模拟服务把临时MP4写入HTTP响应。 | |||
| doAnswer(invocation -> { | |||
| ByteArrayOutputStream output = | |||
| (ByteArrayOutputStream) invocation.getArgument(1); | |||
| output.write("final-mp4".getBytes(StandardCharsets.UTF_8)); | |||
| return null; | |||
| }).when(playbackClipService).writeAndDelete( | |||
| eq(clipFile), any(ByteArrayOutputStream.class)); | |||
| // 直接调用控制器并执行流式响应体,验证响应头和写入委托。 | |||
| ResponseEntity<StreamingResponseBody> response = | |||
| controller.downloadClip(request); | |||
| ByteArrayOutputStream output = new ByteArrayOutputStream(); | |||
| response.getBody().writeTo(output); | |||
| assertEquals("video/mp4", | |||
| response.getHeaders().getContentType().toString()); | |||
| assertEquals(9L, response.getHeaders().getContentLength()); | |||
| assertTrue(response.getHeaders().getFirst("Content-Disposition") | |||
| .contains(clipFile.getDownloadFileName())); | |||
| assertArrayEquals( | |||
| "final-mp4".getBytes(StandardCharsets.UTF_8), | |||
| output.toByteArray()); | |||
| verify(playbackClipService).writeAndDelete(clipFile, output); | |||
| } | |||
| } | |||
| @ -1,117 +0,0 @@ | |||
| package com.inspect.nvr.hik.controller; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsSession; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsStatus; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.hik.service.HikNvrPlaybackHlsService; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.test.web.servlet.MockMvc; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.util.Arrays; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.Mockito.when; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |||
| import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrPlaybackHlsControllerTest { | |||
| private static final String REQUEST_JSON = "{" | |||
| + "\"nvrIp\":\"192.168.1.100\"," | |||
| + "\"sdkPort\":8000," | |||
| + "\"rtspPort\":554," | |||
| + "\"channel\":33," | |||
| + "\"streamType\":1," | |||
| + "\"username\":\"admin\"," | |||
| + "\"password\":\"123456\"," | |||
| + "\"startTime\":\"2026-07-13 12:00:00\"," | |||
| + "\"endTime\":\"2026-07-13 13:00:00\"" | |||
| + "}"; | |||
| @Mock | |||
| private HikNvrPlaybackHlsService playbackHlsService; | |||
| private MockMvc mockMvc; | |||
| @Before | |||
| public void setUp() { | |||
| HikNvrPlaybackHlsController controller = new HikNvrPlaybackHlsController(); | |||
| ReflectionTestUtils.setField(controller, "playbackHlsService", playbackHlsService); | |||
| mockMvc = standaloneSetup(controller) | |||
| .setControllerAdvice(new HikNvrPlaybackExceptionHandler()) | |||
| .build(); | |||
| } | |||
| @Test | |||
| public void createsHlsSessionWithoutExposingRtspCredentials() throws Exception { | |||
| when(playbackHlsService.create(any())).thenReturn(session(1, 1, 1)); | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/sessions") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content(REQUEST_JSON)) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.hlsUrl").value( | |||
| "http://127.0.0.1:18081/playback/a1-1/hls.m3u8")) | |||
| .andExpect(jsonPath("$.rtspUrl").doesNotExist()) | |||
| .andExpect(jsonPath("$.speed").value(1)); | |||
| } | |||
| @Test | |||
| public void sendsSeekAndNativeSpeedControlToExistingSession() throws Exception { | |||
| when(playbackHlsService.control(eq("a1"), any())).thenReturn(session(2, 8, 2)); | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/sessions/a1/controls") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"action\":\"SET_SPEED\",\"speed\":8}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.speed").value(8)); | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/sessions/a1/controls") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"action\":\"SEEK\",\"seekTime\":\"2026-07-13 12:45:00\"}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.generation").value(2)) | |||
| .andExpect(jsonPath("$.hlsUrl").value( | |||
| "http://127.0.0.1:18081/playback/a1-2/hls.m3u8")); | |||
| } | |||
| @Test | |||
| public void exposesSessionLookupDeleteAndDocumentedErrors() throws Exception { | |||
| when(playbackHlsService.get("a1")).thenReturn(session(1, 1, 1)); | |||
| when(playbackHlsService.get("missing")) | |||
| .thenThrow(HikNvrPlaybackException.notFound("回放会话不存在或已停止")); | |||
| mockMvc.perform(get("/hik/nvr/playback/hls/sessions/a1")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.sessionId").value("a1")); | |||
| mockMvc.perform(delete("/hik/nvr/playback/hls/sessions/a1")) | |||
| .andExpect(status().isNoContent()); | |||
| mockMvc.perform(get("/hik/nvr/playback/hls/sessions/missing")) | |||
| .andExpect(status().isNotFound()) | |||
| .andExpect(jsonPath("$.code").value(-4)); | |||
| } | |||
| private HikNvrPlaybackHlsSession session(int generation, int speed, int urlGeneration) { | |||
| return HikNvrPlaybackHlsSession.builder() | |||
| .sessionId("a1") | |||
| .status(HikNvrPlaybackHlsStatus.PLAYING) | |||
| .hlsUrl("http://127.0.0.1:18081/playback/a1-" + urlGeneration + "/hls.m3u8") | |||
| .generation(generation) | |||
| .speed(speed) | |||
| .actualTimeRange(Arrays.asList("20260713123000", "20260713130000")) | |||
| .currentTime("20260713124500") | |||
| .build(); | |||
| } | |||
| } | |||
| @ -1,254 +0,0 @@ | |||
| package com.inspect.nvr.hik.controller; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.domain.HikNvrCameraTarget; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsControlAction; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsControlRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsSession; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsStatus; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackUiConfig; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.hik.service.HikNvrCameraTargetService; | |||
| import com.inspect.nvr.hik.service.HikNvrDeviceTreeService; | |||
| import com.inspect.nvr.hik.service.HikNvrPlaybackHlsService; | |||
| import com.inspect.nvr.hik.service.HikNvrPlaybackRequestFactory; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import org.springframework.test.web.servlet.MockMvc; | |||
| import java.util.Arrays; | |||
| import java.util.Collections; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |||
| import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; | |||
| /** | |||
| * 验证回放控制台严格使用数据库设备树和数据库 NVR 连接参数。 | |||
| */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrPlaybackUiControllerTest { | |||
| /** 测试设备树与页面请求共同使用的 IVS 完整摄像机编码。 */ | |||
| private static final String CAMERA_CODE = | |||
| "22078710632508667003#domain-a"; | |||
| /** 模拟浏览器 HLS 会话创建服务。 */ | |||
| @Mock | |||
| private HikNvrPlaybackHlsService playbackHlsService; | |||
| /** 模拟数据库设备树服务。 */ | |||
| @Mock | |||
| private HikNvrDeviceTreeService deviceTreeService; | |||
| /** 模拟 cameraCode 到数据库 NVR 与通道目标的解析服务。 */ | |||
| @Mock | |||
| private HikNvrCameraTargetService cameraTargetService; | |||
| /** 独立控制器测试使用的 MockMvc 客户端。 */ | |||
| private MockMvc mockMvc; | |||
| /** 初始化控制器数据库依赖,避免测试访问真实设备或数据库。 */ | |||
| @Before | |||
| public void setUp() { | |||
| // 控制器只注入数据库服务和纯请求工厂,不再创建固定 NVR 配置对象。 | |||
| HikNvrPlaybackUiController controller = new HikNvrPlaybackUiController(); | |||
| ReflectionTestUtils.setField(controller, "playbackHlsService", playbackHlsService); | |||
| ReflectionTestUtils.setField(controller, "deviceTreeService", deviceTreeService); | |||
| ReflectionTestUtils.setField( | |||
| controller, "cameraTargetService", cameraTargetService); | |||
| ReflectionTestUtils.setField( | |||
| controller, "playbackRequestFactory", | |||
| new HikNvrPlaybackRequestFactory()); | |||
| // 注册统一异常处理器,保持响应格式与生产环境一致。 | |||
| mockMvc = standaloneSetup(controller) | |||
| .setControllerAdvice(new HikNvrPlaybackExceptionHandler()) | |||
| .build(); | |||
| } | |||
| /** | |||
| * 配置接口必须原样返回数据库设备树,同时禁止暴露 NVR 密码。 | |||
| * | |||
| * @throws Exception MockMvc 请求或断言失败时抛出 | |||
| */ | |||
| @Test | |||
| public void configDoesNotExposePassword() throws Exception { | |||
| // 构造一台数据库 NVR 及其 ivs_camera 子节点。 | |||
| HikNvrPlaybackUiConfig.Channel channel = | |||
| HikNvrPlaybackUiConfig.Channel.builder() | |||
| .channel(3) | |||
| .cameraCode(CAMERA_CODE) | |||
| .nvrId(2L) | |||
| .nvrIp("192.168.1.250") | |||
| .nvrName("二号 NVR") | |||
| .name("数据库摄像机") | |||
| .group("东门") | |||
| .build(); | |||
| HikNvrPlaybackUiConfig databaseConfig = | |||
| HikNvrPlaybackUiConfig.builder() | |||
| .deviceName("二号 NVR") | |||
| .timezone("Asia/Shanghai") | |||
| .configured(true) | |||
| .nvrIp("192.168.1.250") | |||
| .defaultNvrIp("192.168.1.250") | |||
| .defaultChannel(3) | |||
| .defaultCameraCode(CAMERA_CODE) | |||
| .channels(Collections.singletonList(channel)) | |||
| .nvrs(Collections.singletonList( | |||
| HikNvrPlaybackUiConfig.Nvr.builder() | |||
| .nvrId(2L) | |||
| .name("二号 NVR") | |||
| .nvrIp("192.168.1.250") | |||
| .configured(true) | |||
| .channels(Collections.singletonList(channel)) | |||
| .build())) | |||
| .build(); | |||
| when(deviceTreeService.loadConfig()).thenReturn(databaseConfig); | |||
| // 请求页面配置并校验 NVR 层级、摄像机归属和敏感字段边界。 | |||
| mockMvc.perform(get("/hik/nvr/playback/hls/ui/config")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.deviceName").value("二号 NVR")) | |||
| .andExpect(jsonPath("$.defaultNvrIp").value("192.168.1.250")) | |||
| .andExpect(jsonPath("$.defaultCameraCode").value(CAMERA_CODE)) | |||
| .andExpect(jsonPath("$.nvrs[0].nvrId").value(2)) | |||
| .andExpect(jsonPath("$.nvrs[0].nvrIp").value("192.168.1.250")) | |||
| .andExpect(jsonPath("$.channels[0].channel").value(3)) | |||
| .andExpect(jsonPath("$.channels[0].cameraCode").value(CAMERA_CODE)) | |||
| .andExpect(jsonPath("$.channels[0].nvrIp").value("192.168.1.250")) | |||
| .andExpect(jsonPath("$.channels[0].name").value("数据库摄像机")) | |||
| .andExpect(jsonPath("$.password").doesNotExist()); | |||
| verify(deviceTreeService).loadConfig(); | |||
| } | |||
| /** | |||
| * 页面选择 cameraCode 后,内部回放请求必须使用其数据库 NVR 参数与通道。 | |||
| * | |||
| * @throws Exception MockMvc 请求或断言失败时抛出 | |||
| */ | |||
| @Test | |||
| public void pageRequestUsesSelectedDatabaseNvr() throws Exception { | |||
| // 模拟从 cameraCode 精确解析数据库 NVR、通道和服务端凭据。 | |||
| when(cameraTargetService.resolve(CAMERA_CODE)).thenReturn( | |||
| new HikNvrCameraTarget(CAMERA_CODE, 3, databaseNvrInfo())); | |||
| when(playbackHlsService.create(any(HikNvrPlaybackRequest.class))) | |||
| .thenReturn(session()); | |||
| // 页面只提交设备树中的 cameraCode、码流和时间范围。 | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/ui/sessions") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"cameraCode\":\"" + CAMERA_CODE + "\"," | |||
| + "\"streamType\":1," | |||
| + "\"startTime\":\"2026-07-13 12:00:00\"," | |||
| + "\"endTime\":\"2026-07-13 13:00:00\"}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.sessionId").value("ui-session")); | |||
| // 捕获内部请求,确认所有设备连接字段都来自数据库服务。 | |||
| ArgumentCaptor<HikNvrPlaybackRequest> captor = | |||
| ArgumentCaptor.forClass(HikNvrPlaybackRequest.class); | |||
| verify(cameraTargetService).resolve(CAMERA_CODE); | |||
| verify(playbackHlsService).create(captor.capture()); | |||
| HikNvrPlaybackRequest request = captor.getValue(); | |||
| assertEquals(CAMERA_CODE, request.getCameraCode()); | |||
| assertEquals("192.168.1.250", request.getNvrIp()); | |||
| assertEquals(Integer.valueOf(9000), request.getSdkPort()); | |||
| assertEquals(Integer.valueOf(8554), request.getRtspPort()); | |||
| assertEquals(Integer.valueOf(3), request.getChannel()); | |||
| assertEquals("database-admin", request.getUsername()); | |||
| assertEquals("database-secret", request.getPassword()); | |||
| } | |||
| /** | |||
| * 未提交 cameraCode 时必须拒绝请求,不得选择第一台或固定设备。 | |||
| * | |||
| * @throws Exception MockMvc 请求或断言失败时抛出 | |||
| */ | |||
| @Test | |||
| public void pageRequestFailsWhenCameraCodeIsMissing() throws Exception { | |||
| // 解析服务负责统一返回缺少 cameraCode 的参数错误。 | |||
| when(cameraTargetService.resolve(null)).thenThrow( | |||
| HikNvrPlaybackException.badRequest("cameraCode不能为空")); | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/ui/sessions") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"streamType\":1," | |||
| + "\"startTime\":\"2026-07-13 12:00:00\"," | |||
| + "\"endTime\":\"2026-07-13 13:00:00\"}")) | |||
| .andExpect(status().isBadRequest()) | |||
| .andExpect(jsonPath("$.code").value(-1)) | |||
| .andExpect(jsonPath("$.message").value("cameraCode不能为空")); | |||
| verify(cameraTargetService).resolve(null); | |||
| } | |||
| /** | |||
| * 页面暂停和恢复必须把 cameraCode 交给会话服务做绑定校验。 | |||
| * | |||
| * @throws Exception MockMvc 请求或断言失败时抛出 | |||
| */ | |||
| @Test | |||
| public void controlDelegatesCameraCodeAndAction() throws Exception { | |||
| // 控制服务返回固定会话,便于验证控制器只负责传递 cameraCode 和动作。 | |||
| when(playbackHlsService.controlByCameraCode( | |||
| any(String.class), any(HikNvrPlaybackHlsControlRequest.class))) | |||
| .thenReturn(session()); | |||
| mockMvc.perform(post( | |||
| "/hik/nvr/playback/hls/ui/sessions/ui-session/controls") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"cameraCode\":\"" + CAMERA_CODE | |||
| + "\",\"action\":\"PAUSE\"}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.sessionId").value("ui-session")); | |||
| // 捕获控制请求,确认前端设备标识没有退回 NVR IP 或通道号。 | |||
| ArgumentCaptor<HikNvrPlaybackHlsControlRequest> captor = | |||
| ArgumentCaptor.forClass(HikNvrPlaybackHlsControlRequest.class); | |||
| verify(playbackHlsService).controlByCameraCode( | |||
| org.mockito.ArgumentMatchers.eq("ui-session"), captor.capture()); | |||
| assertEquals(CAMERA_CODE, captor.getValue().getCameraCode()); | |||
| assertEquals(HikNvrPlaybackHlsControlAction.PAUSE, | |||
| captor.getValue().getAction()); | |||
| } | |||
| /** 创建模拟数据库返回的完整 NVR 连接信息。 */ | |||
| private NvrInfo databaseNvrInfo() { | |||
| // 端口使用非默认值,证明请求工厂没有使用代码中的 8000/554 常量。 | |||
| NvrInfo nvrInfo = new NvrInfo(); | |||
| nvrInfo.setNvrName("二号 NVR"); | |||
| nvrInfo.setNvrIp("192.168.1.250"); | |||
| nvrInfo.setServerPort(9000); | |||
| nvrInfo.setRtspPort(8554); | |||
| nvrInfo.setHttpPort(8080); | |||
| nvrInfo.setAccount("database-admin"); | |||
| nvrInfo.setPassword("database-secret"); | |||
| nvrInfo.setStartChannel(3); | |||
| return nvrInfo; | |||
| } | |||
| /** 构造 HLS 服务成功返回的最小会话对象。 */ | |||
| private HikNvrPlaybackHlsSession session() { | |||
| // 返回固定会话字段,便于断言控制器没有修改服务结果。 | |||
| return HikNvrPlaybackHlsSession.builder() | |||
| .sessionId("ui-session") | |||
| .cameraCode(CAMERA_CODE) | |||
| .status(HikNvrPlaybackHlsStatus.STARTING) | |||
| .hlsUrl("http://127.0.0.1:18081/playback/ui-session-1/hls.m3u8") | |||
| .generation(1) | |||
| .speed(1) | |||
| .actualTimeRange(Arrays.asList( | |||
| "20260713120000", "20260713130000")) | |||
| .currentTime("20260713120000") | |||
| .build(); | |||
| } | |||
| } | |||
| @ -1,152 +0,0 @@ | |||
| package com.inspect.nvr.hik.controller; | |||
| import com.inspect.nvr.dahua.DahuaIvsVendorService; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.domain.HikNvrLiveStreamResponse; | |||
| import com.inspect.nvr.hik.domain.HikNvrUiActionResponse; | |||
| import com.inspect.nvr.hik.service.HikNvrCredentialService; | |||
| import com.inspect.nvr.hik.service.HikNvrLiveStreamService; | |||
| import com.inspect.nvr.hik.service.HikNvrUiActionService; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.MediaType; | |||
| import org.springframework.test.web.servlet.MockMvc; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.never; | |||
| import static org.mockito.Mockito.when; | |||
| import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; | |||
| import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; | |||
| import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrUiActionControllerTest { | |||
| /** 实时预览测试使用的 IVS 完整摄像机编码。 */ | |||
| private static final String CAMERA_CODE = | |||
| "22078710632508667002#domain-a"; | |||
| @Mock | |||
| private HikNvrUiActionService actionService; | |||
| @Mock | |||
| private HikNvrLiveStreamService liveStreamService; | |||
| @Mock | |||
| private HikNvrCredentialService credentialService; | |||
| /** 大华预置位服务模拟对象。 */ | |||
| @Mock | |||
| private DahuaIvsVendorService dahuaVendorService; | |||
| private MockMvc mockMvc; | |||
| @Before | |||
| public void setUp() { | |||
| HikNvrUiActionController controller = new HikNvrUiActionController(); | |||
| ReflectionTestUtils.setField(controller, "actionService", actionService); | |||
| ReflectionTestUtils.setField(controller, "liveStreamService", liveStreamService); | |||
| ReflectionTestUtils.setField(controller, "credentialService", credentialService); | |||
| ReflectionTestUtils.setField(controller, "dahuaVendorService", dahuaVendorService); | |||
| mockMvc = standaloneSetup(controller) | |||
| .setControllerAdvice(new HikNvrPlaybackExceptionHandler()) | |||
| .build(); | |||
| } | |||
| /** 数据库厂商为大华时,现有工作台保存接口必须切换到 NetSDK 实现。 */ | |||
| @Test | |||
| public void createPresetRoutesDahuaNvrByDatabaseVendor() throws Exception { | |||
| NvrInfo nvrInfo = new NvrInfo(); | |||
| nvrInfo.setNvrCompany("DAHUA"); | |||
| nvrInfo.setNvrIp("192.168.1.108"); | |||
| when(credentialService.resolveByIp("192.168.1.108")).thenReturn(nvrInfo); | |||
| when(dahuaVendorService.createPreset(any())).thenReturn( | |||
| HikNvrUiActionResponse.builder() | |||
| .success(true) | |||
| .message("已保存大华预置位 6") | |||
| .build()); | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/ui/actions/preset/create") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"cameraCode\":\"" + CAMERA_CODE + "\"," | |||
| + "\"nvrIp\":\"192.168.1.108\"," | |||
| + "\"channel\":2,\"preset\":6,\"presetName\":\"大门\"}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.success").value(true)) | |||
| .andExpect(jsonPath("$.message").value("已保存大华预置位 6")); | |||
| // 大华目标不能再进入海康预置位服务。 | |||
| verify(dahuaVendorService).createPreset(any()); | |||
| verify(actionService, never()).createPreset(any()); | |||
| } | |||
| @Test | |||
| public void createPresetDelegatesChannelNumberAndName() throws Exception { | |||
| // 页面所选 NVR 必须先由数据库凭据服务解析,再创建绑定该设备的操作服务。 | |||
| NvrInfo nvrInfo = new NvrInfo(); | |||
| nvrInfo.setNvrIp("192.168.1.250"); | |||
| nvrInfo.setServerPort(8000); | |||
| nvrInfo.setRtspPort(554); | |||
| nvrInfo.setHttpPort(80); | |||
| nvrInfo.setAccount("admin"); | |||
| nvrInfo.setPassword("secret"); | |||
| when(credentialService.resolveByIp("192.168.1.250")).thenReturn(nvrInfo); | |||
| when(actionService.forNvr(nvrInfo)).thenReturn(actionService); | |||
| when(actionService.createPreset(any())).thenReturn(HikNvrUiActionResponse.builder() | |||
| .success(true) | |||
| .message("已保存预置位 5(ISAPI),名称:大门") | |||
| .build()); | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/ui/actions/preset/create") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"nvrIp\":\"192.168.1.250\"," | |||
| + "\"channel\":2,\"preset\":5,\"presetName\":\"大门\"}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.success").value(true)) | |||
| .andExpect(jsonPath("$.message").value("已保存预置位 5(ISAPI),名称:大门")); | |||
| ArgumentCaptor<com.inspect.nvr.hik.domain.HikNvrUiActionRequest> captor = | |||
| ArgumentCaptor.forClass(com.inspect.nvr.hik.domain.HikNvrUiActionRequest.class); | |||
| verify(actionService).createPreset(captor.capture()); | |||
| assertEquals("192.168.1.250", captor.getValue().getNvrIp()); | |||
| assertEquals(Integer.valueOf(2), captor.getValue().getChannel()); | |||
| assertEquals(Integer.valueOf(5), captor.getValue().getPreset()); | |||
| assertEquals("大门", captor.getValue().getPresetName()); | |||
| } | |||
| /** | |||
| * 实时预览入口必须只把 cameraCode 和码流类型交给 IVS 实时拉流服务。 | |||
| * | |||
| * @throws Exception MockMvc 请求或断言失败时抛出 | |||
| */ | |||
| @Test | |||
| public void liveStartDelegatesCameraCodeAndStreamType() throws Exception { | |||
| // 模拟实时服务返回已由 IVS RTSP 地址启动的浏览器播放会话。 | |||
| when(liveStreamService.start(CAMERA_CODE, 1)).thenReturn( | |||
| HikNvrLiveStreamResponse.builder() | |||
| .cameraCode(CAMERA_CODE) | |||
| .streamType(1) | |||
| .status("PLAYING") | |||
| .hlsUrl("http://127.0.0.1/live/camera/hls.m3u8") | |||
| .build()); | |||
| mockMvc.perform(post("/hik/nvr/playback/hls/ui/actions/live/start") | |||
| .contentType(MediaType.APPLICATION_JSON) | |||
| .content("{\"cameraCode\":\"" + CAMERA_CODE | |||
| + "\",\"streamType\":1}")) | |||
| .andExpect(status().isOk()) | |||
| .andExpect(jsonPath("$.cameraCode").value(CAMERA_CODE)) | |||
| .andExpect(jsonPath("$.status").value("PLAYING")); | |||
| // 控制器不得再解析或传递页面提交的 NVR IP 与通道号。 | |||
| verify(liveStreamService).start(CAMERA_CODE, 1); | |||
| } | |||
| } | |||
| @ -1,19 +0,0 @@ | |||
| package com.inspect.nvr.hik.domain; | |||
| import org.junit.Test; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| public class HikNvrPlaybackHlsControlRequestTest { | |||
| @Test | |||
| public void acceptsOnlyNativeNvrPlaybackSpeeds() { | |||
| assertTrue(HikNvrPlaybackHlsControlRequest.isSupportedSpeed(1)); | |||
| assertTrue(HikNvrPlaybackHlsControlRequest.isSupportedSpeed(2)); | |||
| assertTrue(HikNvrPlaybackHlsControlRequest.isSupportedSpeed(4)); | |||
| assertTrue(HikNvrPlaybackHlsControlRequest.isSupportedSpeed(8)); | |||
| assertFalse(HikNvrPlaybackHlsControlRequest.isSupportedSpeed(3)); | |||
| assertFalse(HikNvrPlaybackHlsControlRequest.isSupportedSpeed(null)); | |||
| } | |||
| } | |||
| @ -1,253 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import org.junit.Test; | |||
| import java.io.ByteArrayInputStream; | |||
| import java.io.ByteArrayOutputStream; | |||
| import java.io.IOException; | |||
| import java.io.InputStream; | |||
| import java.io.OutputStream; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.List; | |||
| import java.util.concurrent.CountDownLatch; | |||
| import java.util.concurrent.TimeUnit; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| public class HikFfmpegH264PublisherTest { | |||
| @Test | |||
| public void usesPipeInputAndPublishesToSessionRtmpUrl() { | |||
| CapturingProcessFactory processFactory = new CapturingProcessFactory(new TestProcess()); | |||
| HikFfmpegH264Publisher publisher = new HikFfmpegH264Publisher( | |||
| processFactory, | |||
| "C:/tools/ffmpeg/bin/ffmpeg.exe", | |||
| "rtmp://127.0.0.1:1935", | |||
| 4); | |||
| publisher.start("a1-1"); | |||
| assertTrue(processFactory.command().contains("pipe:0")); | |||
| assertTrue(processFactory.command().contains("rtmp://127.0.0.1:1935/playback/a1-1")); | |||
| publisher.close(); | |||
| } | |||
| @Test | |||
| public void doesNotUseArrivalWallClockForRawH264Timestamps() { | |||
| CapturingProcessFactory processFactory = new CapturingProcessFactory(new TestProcess()); | |||
| HikFfmpegH264Publisher publisher = new HikFfmpegH264Publisher( | |||
| processFactory, | |||
| "ffmpeg", | |||
| "rtmp://127.0.0.1:1935", | |||
| 4); | |||
| publisher.start("a1-1"); | |||
| assertFalse(processFactory.command().contains("-use_wallclock_as_timestamps")); | |||
| publisher.close(); | |||
| } | |||
| @Test | |||
| public void forwardsRawH264WithoutRequiringDecoderSupport() { | |||
| CapturingProcessFactory processFactory = new CapturingProcessFactory(new TestProcess()); | |||
| HikFfmpegH264Publisher publisher = new HikFfmpegH264Publisher( | |||
| processFactory, | |||
| "ffmpeg", | |||
| "rtmp://127.0.0.1:1935", | |||
| 4); | |||
| publisher.start("a1-1"); | |||
| // 原码流转发兼容海康部分通道使用的H264数据分区帧。 | |||
| assertTrue(processFactory.command().contains("copy")); | |||
| assertTrue(processFactory.command().contains("-framerate")); | |||
| assertTrue(processFactory.command().contains("25")); | |||
| assertFalse(processFactory.command().contains("libx264")); | |||
| publisher.close(); | |||
| } | |||
| @Test | |||
| public void scalesRawH264FrameRateForNativeFastPlayback() { | |||
| CapturingProcessFactory processFactory = new CapturingProcessFactory(new TestProcess()); | |||
| HikFfmpegH264Publisher publisher = new HikFfmpegH264Publisher( | |||
| processFactory, | |||
| "ffmpeg", | |||
| "rtmp://127.0.0.1:1935", | |||
| 4); | |||
| publisher.start("a1-4", 4); | |||
| int frameRateOption = processFactory.command().indexOf("-framerate"); | |||
| // 四倍速NVR帧必须按100fps生成时间戳,避免HLS窗口被浏览器快速耗尽。 | |||
| assertEquals("100", processFactory.command().get(frameRateOption + 1)); | |||
| int timestampFilterOption = processFactory.command().indexOf("-bsf:v"); | |||
| // 原始H264没有PTS/DTS,流复制时必须显式给每个包补100fps连续时间戳。 | |||
| assertEquals( | |||
| "setts=pts=N/(100*TB):dts=N/(100*TB):duration=1/(100*TB)", | |||
| processFactory.command().get(timestampFilterOption + 1)); | |||
| publisher.close(); | |||
| } | |||
| /** | |||
| * 验证历史回放仅保留 FFmpeg 错误且关闭进度输出,避免媒体包调试内容进入 Docker 日志。 | |||
| */ | |||
| @Test | |||
| public void limitsFfmpegOutputToErrorsWithoutProgressStats() { | |||
| CapturingProcessFactory processFactory = new CapturingProcessFactory(new TestProcess()); | |||
| HikFfmpegH264Publisher publisher = new HikFfmpegH264Publisher( | |||
| processFactory, | |||
| "ffmpeg", | |||
| "rtmp://127.0.0.1:1935", | |||
| 4); | |||
| // 启动发布器以捕获实际传给 FFmpeg 的命令行。 | |||
| publisher.start("a1-1"); | |||
| // 日志等级必须为 error,确保 RTMP 逐包调试和十六进制转储不会写入容器日志。 | |||
| int logLevelOption = processFactory.command().indexOf("-loglevel"); | |||
| assertEquals("error", processFactory.command().get(logLevelOption + 1)); | |||
| // 长时间回放还需关闭进度行,避免无错误时仍持续输出统计信息。 | |||
| assertTrue(processFactory.command().contains("-nostats")); | |||
| assertFalse(processFactory.command().contains("debug")); | |||
| publisher.close(); | |||
| } | |||
| @Test | |||
| public void preservesIFrameWhenQueueIsFull() throws Exception { | |||
| BlockingOutputStream output = new BlockingOutputStream(); | |||
| CapturingProcessFactory processFactory = new CapturingProcessFactory(new TestProcess(output)); | |||
| HikFfmpegH264Publisher publisher = new HikFfmpegH264Publisher( | |||
| processFactory, | |||
| "ffmpeg", | |||
| "rtmp://127.0.0.1:1935", | |||
| 2); | |||
| publisher.start("a1-1"); | |||
| assertTrue(publisher.offer(frameOfType(2))); | |||
| assertTrue(output.awaitFirstWrite()); | |||
| assertTrue(publisher.offer(frameOfType(2))); | |||
| assertTrue(publisher.offer(frameOfType(3))); | |||
| assertTrue(publisher.offer(frameOfType(1))); | |||
| assertTrue(publisher.hasQueuedIFrame()); | |||
| output.release(); | |||
| publisher.close(); | |||
| } | |||
| private HikPlaybackFrame frameOfType(int packetType) { | |||
| return new HikPlaybackFrame(packetType, new byte[]{0, 0, 0, 1, 9}); | |||
| } | |||
| private static final class CapturingProcessFactory extends HikFfmpegProcessFactory { | |||
| private final Process process; | |||
| private List<String> command = Collections.emptyList(); | |||
| private CapturingProcessFactory(Process process) { | |||
| this.process = process; | |||
| } | |||
| @Override | |||
| public Process start(List<String> command) { | |||
| this.command = new ArrayList<>(command); | |||
| return process; | |||
| } | |||
| private List<String> command() { | |||
| return command; | |||
| } | |||
| } | |||
| private static final class TestProcess extends Process { | |||
| private final OutputStream output; | |||
| private TestProcess() { | |||
| this(new ByteArrayOutputStream()); | |||
| } | |||
| private TestProcess(OutputStream output) { | |||
| this.output = output; | |||
| } | |||
| @Override | |||
| public OutputStream getOutputStream() { | |||
| return output; | |||
| } | |||
| @Override | |||
| public InputStream getInputStream() { | |||
| return new ByteArrayInputStream(new byte[0]); | |||
| } | |||
| @Override | |||
| public InputStream getErrorStream() { | |||
| return new ByteArrayInputStream(new byte[0]); | |||
| } | |||
| @Override | |||
| public int waitFor() { | |||
| return 0; | |||
| } | |||
| @Override | |||
| public boolean waitFor(long timeout, TimeUnit unit) { | |||
| return true; | |||
| } | |||
| @Override | |||
| public int exitValue() { | |||
| return 0; | |||
| } | |||
| @Override | |||
| public void destroy() { | |||
| } | |||
| @Override | |||
| public Process destroyForcibly() { | |||
| return this; | |||
| } | |||
| } | |||
| private static final class BlockingOutputStream extends OutputStream { | |||
| private final CountDownLatch firstWrite = new CountDownLatch(1); | |||
| private final CountDownLatch release = new CountDownLatch(1); | |||
| @Override | |||
| public void write(int value) throws IOException { | |||
| block(); | |||
| } | |||
| @Override | |||
| public void write(byte[] bytes, int offset, int length) throws IOException { | |||
| block(); | |||
| } | |||
| @Override | |||
| public void close() { | |||
| release(); | |||
| } | |||
| private void block() throws IOException { | |||
| firstWrite.countDown(); | |||
| try { | |||
| if (!release.await(5, TimeUnit.SECONDS)) { | |||
| throw new IOException("test writer timed out"); | |||
| } | |||
| } catch (InterruptedException exception) { | |||
| Thread.currentThread().interrupt(); | |||
| throw new IOException("test writer interrupted", exception); | |||
| } | |||
| } | |||
| private boolean awaitFirstWrite() throws InterruptedException { | |||
| return firstWrite.await(2, TimeUnit.SECONDS); | |||
| } | |||
| private void release() { | |||
| release.countDown(); | |||
| } | |||
| } | |||
| } | |||
| @ -1,124 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.domain.HikNvrCameraTarget; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.ivs.mapper.IvsCameraMapper; | |||
| import com.inspect.nvr.ivs.mapper.IvsNvrMapper; | |||
| import com.inspect.nvr.ivs.pojo.IvsCamera; | |||
| import com.inspect.nvr.ivs.pojo.IvsNvr; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.util.Collections; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertSame; | |||
| import static org.junit.Assert.fail; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.verifyNoInteractions; | |||
| import static org.mockito.Mockito.when; | |||
| /** | |||
| * 验证页面完整 cameraCode 只会解析到数据库中真实存在的摄像机、NVR 和通道。 | |||
| */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrCameraTargetServiceTest { | |||
| /** 测试使用的完整 IVS 摄像机编码,最后三位表示通道 2。 */ | |||
| private static final String CAMERA_CODE = | |||
| "22078710632508667002#domain-a"; | |||
| /** 从测试 cameraCode 去掉末三位通道后得到的所属 NVR 编码。 */ | |||
| private static final String NVR_CODE = | |||
| "22078710632508667#domain-a"; | |||
| /** 模拟 ivs_camera 精确编码查询。 */ | |||
| @Mock | |||
| private IvsCameraMapper ivsCameraMapper; | |||
| /** 模拟 basedata_eqpbook_nvr 设备查询。 */ | |||
| @Mock | |||
| private IvsNvrMapper ivsNvrMapper; | |||
| /** 模拟服务端数据库 NVR 凭据解析。 */ | |||
| @Mock | |||
| private HikNvrCredentialService credentialService; | |||
| /** 待测试的 cameraCode 解析服务。 */ | |||
| private HikNvrCameraTargetService service; | |||
| /** 创建服务并注入数据库查询替身。 */ | |||
| @Before | |||
| public void setUp() { | |||
| service = new HikNvrCameraTargetService(); | |||
| // 手工注入三个替身,确保测试不会访问真实数据库或设备。 | |||
| ReflectionTestUtils.setField(service, "ivsCameraMapper", ivsCameraMapper); | |||
| ReflectionTestUtils.setField(service, "ivsNvrMapper", ivsNvrMapper); | |||
| ReflectionTestUtils.setField( | |||
| service, "credentialService", credentialService); | |||
| } | |||
| /** 完整 cameraCode 应精确解析所属启用 NVR、通道号和服务端凭据。 */ | |||
| @Test | |||
| public void resolvesCameraCodeToDatabaseNvrAndChannel() { | |||
| // ivs_camera 必须以完整编码精确命中真实摄像机。 | |||
| IvsCamera camera = new IvsCamera(); | |||
| camera.setCode(CAMERA_CODE); | |||
| when(ivsCameraMapper.selectByCode(CAMERA_CODE)).thenReturn(camera); | |||
| // NVR 编码与 cameraCode 去掉末三位通道后的结果完全一致。 | |||
| IvsNvr nvr = new IvsNvr(); | |||
| nvr.setNvrCode(NVR_CODE); | |||
| nvr.setNvrIp("192.168.1.249"); | |||
| nvr.setEnabled(1); | |||
| when(ivsNvrMapper.selectAll()).thenReturn(Collections.singletonList(nvr)); | |||
| // 完整连接参数仍由既有凭据服务从数据库读取,浏览器无法传入密码。 | |||
| NvrInfo nvrInfo = new NvrInfo(); | |||
| nvrInfo.setNvrIp("192.168.1.249"); | |||
| when(credentialService.resolveByIp("192.168.1.249")) | |||
| .thenReturn(nvrInfo); | |||
| HikNvrCameraTarget target = service.resolve(" " + CAMERA_CODE + " "); | |||
| assertEquals(CAMERA_CODE, target.getCameraCode()); | |||
| assertEquals(Integer.valueOf(2), target.getChannel()); | |||
| assertSame(nvrInfo, target.getNvrInfo()); | |||
| verify(ivsCameraMapper).selectByCode(CAMERA_CODE); | |||
| verify(credentialService).resolveByIp("192.168.1.249"); | |||
| } | |||
| /** 缺少 cameraCode 时必须在访问数据库前立即返回参数错误。 */ | |||
| @Test | |||
| public void rejectsMissingCameraCodeBeforeDatabaseLookup() { | |||
| try { | |||
| // 空白输入既不能唯一定位摄像机,也不允许回退到第一台设备。 | |||
| service.resolve(" "); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); | |||
| assertEquals("cameraCode不能为空", exception.getMessage()); | |||
| } | |||
| // 参数结构无效时不应触发摄像机、NVR 或凭据数据库查询。 | |||
| verifyNoInteractions( | |||
| ivsCameraMapper, ivsNvrMapper, credentialService); | |||
| } | |||
| /** 数据库没有精确 cameraCode 时不得仅凭编码末三位推断设备。 */ | |||
| @Test | |||
| public void rejectsCameraCodeMissingFromIvsCamera() { | |||
| // 精确查询无结果,模拟页面提交已经删除或伪造的摄像机编码。 | |||
| when(ivsCameraMapper.selectByCode(CAMERA_CODE)).thenReturn(null); | |||
| try { | |||
| service.resolve(CAMERA_CODE); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); | |||
| } | |||
| // 摄像机不存在时不能继续扫描 NVR 或读取任何设备凭据。 | |||
| verifyNoInteractions(ivsNvrMapper, credentialService); | |||
| } | |||
| } | |||
| @ -1,119 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.ivs.mapper.IvsNvrMapper; | |||
| import com.inspect.nvr.ivs.pojo.IvsNvr; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.util.Arrays; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.fail; | |||
| import static org.mockito.Mockito.when; | |||
| /** | |||
| * 验证 NVR 连接信息严格来自 basedata_eqpbook_nvr,不使用代码默认端口。 | |||
| */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrCredentialServiceTest { | |||
| /** 模拟 basedata_eqpbook_nvr Mapper。 */ | |||
| @Mock | |||
| private IvsNvrMapper ivsNvrMapper; | |||
| /** 待验证的数据库凭据服务。 */ | |||
| private HikNvrCredentialService service; | |||
| /** 创建服务并注入数据库 Mapper Mock。 */ | |||
| @Before | |||
| public void setUp() { | |||
| service = new HikNvrCredentialService(); | |||
| ReflectionTestUtils.setField(service, "ivsNvrMapper", ivsNvrMapper); | |||
| } | |||
| /** 精确 IP 查询必须原样返回数据库中的非标准端口和凭据。 */ | |||
| @Test | |||
| public void resolveByIpUsesExactDatabaseConnectionValues() { | |||
| IvsNvr databaseNvr = completeNvr( | |||
| "192.168.1.250", 9000, 8554, 8080); | |||
| when(ivsNvrMapper.selectEnabledByNvrIp("192.168.1.250")) | |||
| .thenReturn(databaseNvr); | |||
| NvrInfo result = service.resolveByIp(" 192.168.1.250 "); | |||
| assertEquals("192.168.1.250", result.getNvrIp()); | |||
| assertEquals(Integer.valueOf(9000), result.getServerPort()); | |||
| assertEquals(Integer.valueOf(8554), result.getRtspPort()); | |||
| assertEquals(Integer.valueOf(8080), result.getHttpPort()); | |||
| assertEquals("database-user", result.getAccount()); | |||
| assertEquals("database-password", result.getPassword()); | |||
| } | |||
| /** 任一数据库端口为空时必须明确报错,不能自动补成 8000、554 或 80。 */ | |||
| @Test | |||
| public void resolveByIpRejectsMissingDatabasePort() { | |||
| IvsNvr incomplete = completeNvr( | |||
| "192.168.1.250", 9000, 8554, null); | |||
| when(ivsNvrMapper.selectEnabledByNvrIp("192.168.1.250")) | |||
| .thenReturn(incomplete); | |||
| try { | |||
| service.resolveByIp("192.168.1.250"); | |||
| fail("缺少数据库 HTTP 端口时应拒绝连接"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals("数据库中NVR连接参数未配置完整:192.168.1.250", | |||
| exception.getMessage()); | |||
| } | |||
| } | |||
| /** 默认数据库目标应跳过禁用记录,并返回下一台连接参数完整的启用 NVR。 */ | |||
| @Test | |||
| public void resolveDefaultSelectsEnabledDatabaseRecord() { | |||
| IvsNvr disabled = completeNvr( | |||
| "192.168.1.249", 8000, 554, 80); | |||
| disabled.setEnabled(0); | |||
| IvsNvr enabled = completeNvr( | |||
| "192.168.1.250", 9000, 8554, 8080); | |||
| when(ivsNvrMapper.selectAll()) | |||
| .thenReturn(Arrays.asList(disabled, enabled)); | |||
| NvrInfo result = service.resolveDefault(); | |||
| assertEquals("192.168.1.250", result.getNvrIp()); | |||
| assertEquals(Integer.valueOf(9000), result.getServerPort()); | |||
| } | |||
| /** | |||
| * 创建连接参数完整的数据库 NVR。 | |||
| * | |||
| * @param ip NVR IP | |||
| * @param sdkPort SDK 端口 | |||
| * @param rtspPort RTSP 端口 | |||
| * @param httpPort HTTP 端口 | |||
| * @return basedata_eqpbook_nvr 模拟记录 | |||
| */ | |||
| private IvsNvr completeNvr( | |||
| String ip, Integer sdkPort, Integer rtspPort, Integer httpPort) { | |||
| // 所有连接字段均显式设置,便于测试缺失任意字段时的严格校验。 | |||
| IvsNvr nvr = new IvsNvr(); | |||
| nvr.setNvrId(2L); | |||
| nvr.setPatrolDeviceName("数据库 NVR"); | |||
| nvr.setVendor("HIKVISION"); | |||
| nvr.setNvrIp(ip); | |||
| nvr.setUsername("database-user"); | |||
| nvr.setPassword("database-password"); | |||
| nvr.setSdkPort(sdkPort); | |||
| nvr.setRtspPort(rtspPort); | |||
| nvr.setHttpPort(httpPort); | |||
| nvr.setStartChannel(1); | |||
| nvr.setChannelCount(8); | |||
| nvr.setEnabled(1); | |||
| return nvr; | |||
| } | |||
| } | |||
| @ -1,184 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackUiConfig; | |||
| import com.inspect.nvr.ivs.mapper.IvsCameraMapper; | |||
| import com.inspect.nvr.ivs.mapper.IvsNvrMapper; | |||
| import com.inspect.nvr.ivs.pojo.IvsCamera; | |||
| import com.inspect.nvr.ivs.pojo.IvsNvr; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.util.Arrays; | |||
| import java.util.Collections; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertNull; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.verifyNoInteractions; | |||
| import static org.mockito.Mockito.when; | |||
| /** | |||
| * 验证设备树只使用 basedata_eqpbook_nvr 和 ivs_camera 构建多 NVR 层级。 | |||
| */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrDeviceTreeServiceTest { | |||
| /** 模拟 ivs_camera 数据访问接口,测试不连接真实数据库。 */ | |||
| @Mock | |||
| private IvsCameraMapper ivsCameraMapper; | |||
| /** 模拟 basedata_eqpbook_nvr 数据访问接口。 */ | |||
| @Mock | |||
| private IvsNvrMapper ivsNvrMapper; | |||
| /** 待验证的设备树转换服务。 */ | |||
| private HikNvrDeviceTreeService service; | |||
| /** 创建服务并注入两个数据库 Mapper Mock。 */ | |||
| @Before | |||
| public void setUp() { | |||
| // 手动注入 Mapper,确保测试只验证数据库查询和设备树转换规则。 | |||
| service = new HikNvrDeviceTreeService(); | |||
| ReflectionTestUtils.setField(service, "ivsCameraMapper", ivsCameraMapper); | |||
| ReflectionTestUtils.setField(service, "ivsNvrMapper", ivsNvrMapper); | |||
| } | |||
| /** 两台数据库 NVR 应分别读取自己的 ivs_camera,并提供完整 cameraCode。 */ | |||
| @Test | |||
| public void loadConfigBuildsMultipleDatabaseNvrNodes() { | |||
| // 第一台 NVR 的 start_channel=1 不在其摄像机结果中,默认应回退到第一路通道 2。 | |||
| IvsNvr firstNvr = nvr( | |||
| 1L, "一号 NVR", "192.168.1.249", 1); | |||
| // 第二台 NVR 使用相同数据库字段结构,证明设备树不再只查询固定 IP。 | |||
| IvsNvr secondNvr = nvr( | |||
| 2L, "二号 NVR", "192.168.1.250", 7); | |||
| when(ivsNvrMapper.selectAll()) | |||
| .thenReturn(Arrays.asList(firstNvr, secondNvr)); | |||
| IvsCamera entrance = camera( | |||
| "22078710632508667002#domain-a", | |||
| "大门入口摄像机", "东门", null); | |||
| IvsCamera dome = camera( | |||
| "22078710632508668007#domain-b", | |||
| "球机", "", "球机"); | |||
| when(ivsCameraMapper.selectByNvrIp("192.168.1.249")) | |||
| .thenReturn(Collections.singletonList(entrance)); | |||
| when(ivsCameraMapper.selectByNvrIp("192.168.1.250")) | |||
| .thenReturn(Collections.singletonList(dome)); | |||
| // 加载公开配置,所有 NVR 与摄像机字段都应来自数据库 Mock。 | |||
| HikNvrPlaybackUiConfig config = service.loadConfig(); | |||
| // 两台启用 NVR 都必须发起各自的摄像机查询。 | |||
| verify(ivsCameraMapper).selectByNvrIp("192.168.1.249"); | |||
| verify(ivsCameraMapper).selectByNvrIp("192.168.1.250"); | |||
| assertEquals(2, config.getNvrs().size()); | |||
| assertEquals(2, config.getChannels().size()); | |||
| // 默认设备和名称来自数据库排序中的第一台有摄像机 NVR。 | |||
| assertEquals("一号 NVR", config.getDeviceName()); | |||
| assertEquals("192.168.1.249", config.getDefaultNvrIp()); | |||
| assertEquals(Integer.valueOf(2), config.getDefaultChannel()); | |||
| assertEquals("22078710632508667002#domain-a", | |||
| config.getDefaultCameraCode()); | |||
| // 扁平通道携带所属 NVR,避免两台设备的同号通道发生碰撞。 | |||
| assertEquals(Long.valueOf(1L), config.getChannels().get(0).getNvrId()); | |||
| assertEquals("192.168.1.249", config.getChannels().get(0).getNvrIp()); | |||
| assertEquals("22078710632508667002#domain-a", | |||
| config.getChannels().get(0).getCameraCode()); | |||
| assertEquals("一号 NVR", config.getChannels().get(0).getNvrName()); | |||
| assertEquals("大门入口摄像机", config.getChannels().get(0).getName()); | |||
| assertEquals("东门", config.getChannels().get(0).getGroup()); | |||
| assertEquals("192.168.1.250", config.getNvrs().get(1).getNvrIp()); | |||
| assertEquals(Integer.valueOf(7), | |||
| config.getNvrs().get(1).getChannels().get(0).getChannel()); | |||
| } | |||
| /** NVR 表为空时必须返回真实空树,且不能扫描或伪造 ivs_camera。 */ | |||
| @Test | |||
| public void loadConfigKeepsEmptyTreeWhenNvrTableIsEmpty() { | |||
| // 数据库没有 NVR 记录时设备树没有任何安全归属条件。 | |||
| when(ivsNvrMapper.selectAll()).thenReturn(Collections.<IvsNvr>emptyList()); | |||
| HikNvrPlaybackUiConfig config = service.loadConfig(); | |||
| // 不允许在没有 NVR 归属时读取摄像机全表。 | |||
| verifyNoInteractions(ivsCameraMapper); | |||
| assertTrue(config.getNvrs().isEmpty()); | |||
| assertTrue(config.getChannels().isEmpty()); | |||
| assertNull(config.getNvrIp()); | |||
| assertNull(config.getDefaultChannel()); | |||
| assertNull(config.getDefaultCameraCode()); | |||
| } | |||
| /** NVR 存在但没有摄像机时应保留真实 NVR 节点,不得生成静态通道。 */ | |||
| @Test | |||
| public void loadConfigKeepsDatabaseNvrWhenCameraTableIsEmpty() { | |||
| // 数据库 NVR 明确保存起始通道 4,设备树为空时可作为页面非摄像机默认提示值。 | |||
| IvsNvr databaseNvr = nvr( | |||
| 2L, "现场 NVR", "192.168.1.250", 4); | |||
| when(ivsNvrMapper.selectAll()) | |||
| .thenReturn(Collections.singletonList(databaseNvr)); | |||
| when(ivsCameraMapper.selectByNvrIp("192.168.1.250")) | |||
| .thenReturn(Collections.<IvsCamera>emptyList()); | |||
| HikNvrPlaybackUiConfig config = service.loadConfig(); | |||
| // 响应保留数据库 NVR,但摄像机列表严格保持为空。 | |||
| assertEquals(1, config.getNvrs().size()); | |||
| assertEquals("现场 NVR", config.getNvrs().get(0).getName()); | |||
| assertTrue(config.getNvrs().get(0).getChannels().isEmpty()); | |||
| assertTrue(config.getChannels().isEmpty()); | |||
| assertEquals(Integer.valueOf(4), config.getDefaultChannel()); | |||
| assertNull(config.getDefaultCameraCode()); | |||
| } | |||
| /** | |||
| * 创建连接参数完整的数据库 NVR。 | |||
| * | |||
| * @param id NVR 主键 | |||
| * @param name 数据库设备名称 | |||
| * @param ip 数据库设备 IP | |||
| * @param startChannel 数据库起始通道 | |||
| * @return 可进入设备树并执行操作的 NVR 记录 | |||
| */ | |||
| private IvsNvr nvr(Long id, String name, String ip, Integer startChannel) { | |||
| // 所有连接字段都显式填入数据库对象,测试不依赖任何代码默认值。 | |||
| IvsNvr nvr = new IvsNvr(); | |||
| nvr.setNvrId(id); | |||
| nvr.setPatrolDeviceName(name); | |||
| nvr.setNvrIp(ip); | |||
| nvr.setNvrCode("2207871063250866" + id + "#domain-" + id); | |||
| nvr.setUsername("admin"); | |||
| nvr.setPassword("secret"); | |||
| nvr.setSdkPort(8000); | |||
| nvr.setRtspPort(554); | |||
| nvr.setHttpPort(80); | |||
| nvr.setStartChannel(startChannel); | |||
| nvr.setEnabled(1); | |||
| return nvr; | |||
| } | |||
| /** | |||
| * 创建用于设备树转换测试的 IVS 摄像机记录。 | |||
| * | |||
| * @param code IVS 完整摄像机编码 | |||
| * @param name 数据库摄像机名称 | |||
| * @param location 数据库安装位置 | |||
| * @param model 数据库设备型号 | |||
| * @return 可由设备树服务转换的摄像机对象 | |||
| */ | |||
| private IvsCamera camera( | |||
| String code, String name, String location, String model) { | |||
| // 填充设备树实际读取的字段,其他 IVS 字段与本测试无关。 | |||
| IvsCamera camera = new IvsCamera(); | |||
| camera.setCode(code); | |||
| camera.setName(name); | |||
| camera.setCameraLocation(location); | |||
| camera.setDevModelType(model); | |||
| return camera; | |||
| } | |||
| } | |||
| @ -1,101 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.domain.ivs.IvsRtspUrlResponse; | |||
| import com.inspect.nvr.domain.ivs.IvsVideoRtspVo; | |||
| import com.inspect.nvr.ivs.service.IvsOpenApiService; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| /** | |||
| * 验证浏览器实时转码服务通过现有 IVS 门面按 cameraCode 取得 RTSP 地址。 | |||
| */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrLiveStreamServiceTest { | |||
| /** 实时预览测试使用的 IVS 完整摄像机编码。 */ | |||
| private static final String CAMERA_CODE = | |||
| "22078710632508667002#domain-a"; | |||
| /** 模拟现有 IVS 北向能力门面,不启动真实 NVR 或 FFmpeg。 */ | |||
| @Mock | |||
| private IvsOpenApiService ivsOpenApiService; | |||
| /** 待验证的实时转码服务。 */ | |||
| private HikNvrLiveStreamService service; | |||
| /** 创建服务并只注入既有 IVS 门面替身。 */ | |||
| @Before | |||
| public void setUp() { | |||
| service = new HikNvrLiveStreamService(); | |||
| // 本测试只验证取得 RTSP 的边界,不进入 FFmpeg 和媒体网关启动逻辑。 | |||
| ReflectionTestUtils.setField( | |||
| service, "ivsOpenApiService", ivsOpenApiService); | |||
| ReflectionTestUtils.setField(service, "ffmpegPath", "ffmpeg"); | |||
| } | |||
| /** cameraCode 和页面码流类型必须原样进入现有 IVS RTSP 请求对象。 */ | |||
| @Test | |||
| public void resolvesRealtimeRtspThroughExistingIvsFacade() { | |||
| // IVS 门面返回固定 RTSP 地址,避免测试访问真实设备。 | |||
| when(ivsOpenApiService.rtspUrl(any(IvsVideoRtspVo.class))) | |||
| .thenReturn(IvsRtspUrlResponse.builder() | |||
| .resultCode(0) | |||
| .rtspURL("rtsp://192.168.1.249/live") | |||
| .build()); | |||
| String rtspUrl = ReflectionTestUtils.invokeMethod( | |||
| service, "resolveLiveRtspUrl", CAMERA_CODE, 2); | |||
| assertEquals("rtsp://192.168.1.249/live", rtspUrl); | |||
| // 捕获请求,确认没有回退到 NVR IP 和页面通道参数。 | |||
| ArgumentCaptor<IvsVideoRtspVo> captor = | |||
| ArgumentCaptor.forClass(IvsVideoRtspVo.class); | |||
| verify(ivsOpenApiService).rtspUrl(captor.capture()); | |||
| assertEquals(CAMERA_CODE, captor.getValue().getCameraCode()); | |||
| assertEquals(1, captor.getValue().getMediaURLParam().getServiceType()); | |||
| assertEquals(2, captor.getValue().getMediaURLParam().getStreamType()); | |||
| } | |||
| /** 大华 realmonitor 的 HEVC 实时流必须转成最高 1080p 的兼容 H.264。 */ | |||
| @Test | |||
| public void transcodesDahuaRealtimeStreamToH264() { | |||
| java.util.List<String> command = ReflectionTestUtils.invokeMethod( | |||
| service, | |||
| "buildFfmpegCommand", | |||
| "rtsp://192.168.1.134/cam/realmonitor?channel=3&subtype=0", | |||
| "rtmp://127.0.0.1:1935/live/camera-3"); | |||
| assertTrue(command.contains("libx264")); | |||
| // 固定验证缩放、Profile 和 Level,防止后续改动重新生成旧浏览器无法解码的 2K Level 5 视频。 | |||
| assertTrue(command.contains("scale=w='min(1920,iw)':h=-2")); | |||
| assertTrue(command.contains("baseline")); | |||
| assertTrue(command.contains("4.1")); | |||
| assertTrue(command.contains("ultrafast")); | |||
| assertFalse(command.contains("copy")); | |||
| } | |||
| /** 海康现有 H.264 实时路径继续原码流转发,避免增加多画面 CPU 占用。 */ | |||
| @Test | |||
| public void keepsHikvisionRealtimeStreamAsCopy() { | |||
| java.util.List<String> command = ReflectionTestUtils.invokeMethod( | |||
| service, | |||
| "buildFfmpegCommand", | |||
| "rtsp://192.168.1.249/Streaming/Channels/301", | |||
| "rtmp://127.0.0.1:1935/live/camera-3"); | |||
| assertTrue(command.contains("copy")); | |||
| assertFalse(command.contains("libx264")); | |||
| } | |||
| } | |||
| @ -1,175 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import com.inspect.nvr.hik.vo.HikNvrPlaybackClipConfigVo; | |||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||
| import com.inspect.nvr.service.HikLoginService; | |||
| import com.inspect.nvr.service.HikLoginSession; | |||
| import com.sun.jna.Pointer; | |||
| import com.sun.jna.ptr.IntByReference; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.nio.file.Files; | |||
| import java.nio.file.Path; | |||
| import java.nio.file.Paths; | |||
| import java.time.LocalDateTime; | |||
| import java.util.List; | |||
| import java.util.concurrent.TimeUnit; | |||
| import static org.junit.Assert.assertArrayEquals; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.anyList; | |||
| import static org.mockito.ArgumentMatchers.anyLong; | |||
| import static org.mockito.ArgumentMatchers.anyString; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.ArgumentMatchers.isNull; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrPlaybackClipExporterTest { | |||
| /** 模拟海康登录和通道映射。 */ | |||
| @Mock | |||
| private HikLoginService hikLoginService; | |||
| /** 模拟海康原生录像下载接口。 */ | |||
| @Mock | |||
| private HCNetSDK sdk; | |||
| /** 模拟 FFmpeg 进程创建并生成最终文件。 */ | |||
| @Mock | |||
| private HikFfmpegProcessFactory processFactory; | |||
| /** 模拟已经成功退出的 FFmpeg 子进程。 */ | |||
| @Mock | |||
| private Process process; | |||
| /** 待测试的 SDK 下载和 MP4 封装器。 */ | |||
| private HikNvrPlaybackClipExporter exporter; | |||
| /** | |||
| * 配置SDK下载立即完成,并让FFmpeg替身生成固定MP4字节。 | |||
| */ | |||
| @Before | |||
| public void setUp() throws Exception { | |||
| HikNvrPlaybackClipConfigVo config = new HikNvrPlaybackClipConfigVo(); | |||
| config.setFfmpegPath("ffmpeg"); | |||
| config.setDownloadTimeoutMillis(5_000L); | |||
| config.setPollIntervalMillis(10L); | |||
| config.setFfmpegTimeoutMillis(5_000L); | |||
| exporter = new HikNvrPlaybackClipExporter(); | |||
| // 注入测试替身,避免连接真实NVR和启动本机FFmpeg。 | |||
| ReflectionTestUtils.setField(exporter, "hikLoginService", hikLoginService); | |||
| ReflectionTestUtils.setField(exporter, "sdk", sdk); | |||
| ReflectionTestUtils.setField(exporter, "ffmpegProcessFactory", processFactory); | |||
| ReflectionTestUtils.setField(exporter, "config", config); | |||
| // 逻辑通道2映射为海康SDK实际通道34。 | |||
| when(hikLoginService.loginSession(any(NvrInfo.class))) | |||
| .thenReturn(new HikLoginSession(7, 0, 1, 8, 33)); | |||
| // SDK创建下载任务时向其目标路径写入模拟设备文件。 | |||
| when(sdk.NET_DVR_GetFileByTime_V40( | |||
| eq(7), anyString(), any(HCNetSDK.NET_DVR_PLAYCOND.class))) | |||
| .thenAnswer(invocation -> { | |||
| Path source = Paths.get((String) invocation.getArgument(1)); | |||
| Files.write(source, "nvr-source".getBytes(StandardCharsets.UTF_8)); | |||
| return 91; | |||
| }); | |||
| when(sdk.NET_DVR_PlayBackControl_V40( | |||
| eq(91), | |||
| eq(HCNetSDK.NET_DVR_PLAYSTART), | |||
| any(Pointer.class), | |||
| eq(Integer.BYTES), | |||
| isNull(), | |||
| any(IntByReference.class))) | |||
| .thenReturn(true); | |||
| when(sdk.NET_DVR_GetDownloadPos(91)).thenReturn(100); | |||
| when(sdk.NET_DVR_StopGetFile(91)).thenReturn(true); | |||
| // FFmpeg替身把固定字节写入命令最后一个输出路径。 | |||
| when(processFactory.start(anyList())).thenAnswer(invocation -> { | |||
| List<String> command = invocation.getArgument(0); | |||
| Path output = Paths.get(command.get(command.size() - 1)); | |||
| Files.write(output, "final-mp4".getBytes(StandardCharsets.UTF_8)); | |||
| return process; | |||
| }); | |||
| when(process.waitFor(anyLong(), eq(TimeUnit.MILLISECONDS))) | |||
| .thenReturn(true); | |||
| when(process.exitValue()).thenReturn(0); | |||
| when(process.isAlive()).thenReturn(false); | |||
| } | |||
| /** | |||
| * 验证SDK按精确一分钟下载,并由FFmpeg生成最终MP4。 | |||
| */ | |||
| @Test | |||
| @SuppressWarnings({"rawtypes", "unchecked"}) | |||
| public void downloadsExactRangeAndRemuxesToMp4() throws Exception { | |||
| LocalDateTime start = LocalDateTime.of(2026, 7, 28, 11, 54, 30); | |||
| LocalDateTime end = LocalDateTime.of(2026, 7, 28, 11, 55, 30); | |||
| // 执行设备下载和MP4封装。 | |||
| Path output = exporter.export(request(), start, end); | |||
| assertArrayEquals( | |||
| "final-mp4".getBytes(StandardCharsets.UTF_8), | |||
| Files.readAllBytes(output)); | |||
| // 捕获SDK条件,确认通道映射、主码流和前后30秒时间范围。 | |||
| ArgumentCaptor<HCNetSDK.NET_DVR_PLAYCOND> conditionCaptor = | |||
| ArgumentCaptor.forClass(HCNetSDK.NET_DVR_PLAYCOND.class); | |||
| verify(sdk).NET_DVR_GetFileByTime_V40( | |||
| eq(7), anyString(), conditionCaptor.capture()); | |||
| HCNetSDK.NET_DVR_PLAYCOND condition = conditionCaptor.getValue(); | |||
| assertEquals(34, condition.dwChannel); | |||
| assertEquals(0, condition.byStreamType); | |||
| assertEquals(11, condition.struStartTime.dwHour); | |||
| assertEquals(54, condition.struStartTime.dwMinute); | |||
| assertEquals(30, condition.struStartTime.dwSecond); | |||
| assertEquals(55, condition.struStopTime.dwMinute); | |||
| assertEquals(30, condition.struStopTime.dwSecond); | |||
| verify(sdk).NET_DVR_StopGetFile(91); | |||
| // 捕获FFmpeg命令,确认视频原码流、可选音频和faststart封装已启用。 | |||
| ArgumentCaptor<List<String>> commandCaptor = ArgumentCaptor.forClass(List.class); | |||
| verify(processFactory).start(commandCaptor.capture()); | |||
| List<String> command = commandCaptor.getValue(); | |||
| assertTrue(command.contains("0:a?")); | |||
| assertTrue(command.contains("copy")); | |||
| assertTrue(command.contains("+faststart")); | |||
| Path directory = output.getParent(); | |||
| // 调用生产清理逻辑后,最终文件和受控独立目录都应被删除。 | |||
| exporter.cleanup(output); | |||
| assertFalse(Files.exists(output)); | |||
| assertFalse(Files.exists(directory)); | |||
| } | |||
| /** | |||
| * 构造数据库已经补齐凭据的内部下载请求。 | |||
| * | |||
| * @return 主码流、逻辑通道2的内部请求 | |||
| */ | |||
| private HikNvrPlaybackRequest request() { | |||
| return HikNvrPlaybackRequest.builder() | |||
| .nvrIp("192.168.1.249") | |||
| .sdkPort(8000) | |||
| .rtspPort(554) | |||
| .channel(2) | |||
| .streamType(1) | |||
| .username("operator") | |||
| .password("secret") | |||
| .startTime("2026-07-28 11:54:30") | |||
| .endTime("2026-07-28 11:55:30") | |||
| .build(); | |||
| } | |||
| } | |||
| @ -1,281 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.domain.HikNvrCameraTarget; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackClipRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRecord; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackResponse; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackUiSessionRequest; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import org.junit.After; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.io.ByteArrayOutputStream; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.nio.file.Files; | |||
| import java.nio.file.Path; | |||
| import java.time.LocalDateTime; | |||
| import java.util.Arrays; | |||
| import java.util.Collections; | |||
| import static org.junit.Assert.assertArrayEquals; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.junit.Assert.fail; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.Mockito.never; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrPlaybackClipServiceTest { | |||
| /** 片段下载页面与服务端共同使用的完整摄像机编码。 */ | |||
| private static final String CAMERA_CODE = | |||
| "22078710632508667002#domain-a"; | |||
| /** 模拟 cameraCode 到数据库 NVR 与通道的解析。 */ | |||
| @Mock | |||
| private HikNvrCameraTargetService cameraTargetService; | |||
| /** 模拟页面条件到内部回放请求的安全转换。 */ | |||
| @Mock | |||
| private HikNvrPlaybackRequestFactory playbackRequestFactory; | |||
| /** 模拟海康录像区间查询。 */ | |||
| @Mock | |||
| private HikNvrPlaybackService playbackService; | |||
| /** 模拟海康下载和 FFmpeg 封装。 */ | |||
| @Mock | |||
| private HikNvrPlaybackClipExporter clipExporter; | |||
| /** 待测试的一分钟录像片段编排服务。 */ | |||
| private HikNvrPlaybackClipService service; | |||
| /** 测试使用的临时目录。 */ | |||
| private Path temporaryDirectory; | |||
| /** 测试使用的已生成 MP4 文件。 */ | |||
| private Path clipPath; | |||
| /** 数据库返回的 NVR 连接对象。 */ | |||
| private NvrInfo nvrInfo; | |||
| /** cameraCode 解析得到的内部摄像机目标。 */ | |||
| private HikNvrCameraTarget cameraTarget; | |||
| /** 统一工厂生成的内部回放请求。 */ | |||
| private HikNvrPlaybackRequest playbackRequest; | |||
| /** | |||
| * 准备完整连续录像和一个小型临时 MP4,供各测试复用。 | |||
| */ | |||
| @Before | |||
| public void setUp() throws Exception { | |||
| service = new HikNvrPlaybackClipService(); | |||
| // 注入测试替身,隔离真实数据库、NVR SDK和FFmpeg。 | |||
| ReflectionTestUtils.setField( | |||
| service, "cameraTargetService", cameraTargetService); | |||
| ReflectionTestUtils.setField(service, "playbackRequestFactory", playbackRequestFactory); | |||
| ReflectionTestUtils.setField(service, "playbackService", playbackService); | |||
| ReflectionTestUtils.setField(service, "clipExporter", clipExporter); | |||
| // 创建本测试独立临时文件,验证长度和流式复制逻辑。 | |||
| temporaryDirectory = Files.createTempDirectory("clip-service-test-"); | |||
| clipPath = temporaryDirectory.resolve("clip.mp4"); | |||
| Files.write(clipPath, "mp4-data".getBytes(StandardCharsets.UTF_8)); | |||
| // 构造数据库NVR对象,服务只验证它被传给统一请求工厂。 | |||
| nvrInfo = new NvrInfo(); | |||
| nvrInfo.setNvrIp("192.168.1.249"); | |||
| cameraTarget = new HikNvrCameraTarget(CAMERA_CODE, 2, nvrInfo); | |||
| playbackRequest = HikNvrPlaybackRequest.builder() | |||
| .cameraCode(CAMERA_CODE) | |||
| .nvrIp("192.168.1.249") | |||
| .sdkPort(8000) | |||
| .rtspPort(554) | |||
| .channel(2) | |||
| .streamType(1) | |||
| .username("operator") | |||
| .password("secret") | |||
| .startTime("2026-07-28 11:54:30") | |||
| .endTime("2026-07-28 11:55:30") | |||
| .build(); | |||
| // 默认让数据库、请求工厂、录像查询和导出链路全部成功。 | |||
| when(cameraTargetService.resolve(CAMERA_CODE)).thenReturn(cameraTarget); | |||
| when(playbackRequestFactory.create( | |||
| any(HikNvrPlaybackUiSessionRequest.class), eq(cameraTarget))) | |||
| .thenReturn(playbackRequest); | |||
| when(playbackService.search(playbackRequest)).thenReturn( | |||
| response("20260728115430", "20260728115530")); | |||
| when(clipExporter.export( | |||
| eq(playbackRequest), | |||
| eq(LocalDateTime.of(2026, 7, 28, 11, 54, 30)), | |||
| eq(LocalDateTime.of(2026, 7, 28, 11, 55, 30)))) | |||
| .thenReturn(clipPath); | |||
| } | |||
| /** | |||
| * 删除测试自己创建的精确临时文件和目录。 | |||
| */ | |||
| @After | |||
| public void tearDown() throws Exception { | |||
| // 测试替身不会真正执行清理,因此这里删除已知文件和独立目录。 | |||
| Files.deleteIfExists(clipPath); | |||
| Files.deleteIfExists(temporaryDirectory); | |||
| } | |||
| /** | |||
| * 验证未传秒数时生成中心时刻前后各30秒的完整一分钟。 | |||
| */ | |||
| @Test | |||
| public void defaultsToThirtySecondsBeforeAndAfterCenterTime() { | |||
| // 使用最小页面参数创建录像片段。 | |||
| HikNvrPlaybackClipFile clipFile = service.createClip(request()); | |||
| // 捕获交给统一工厂的页面范围,确认是11:54:30到11:55:30。 | |||
| ArgumentCaptor<HikNvrPlaybackUiSessionRequest> requestCaptor = | |||
| ArgumentCaptor.forClass(HikNvrPlaybackUiSessionRequest.class); | |||
| verify(cameraTargetService).resolve(CAMERA_CODE); | |||
| verify(playbackRequestFactory).create( | |||
| requestCaptor.capture(), eq(cameraTarget)); | |||
| assertEquals(CAMERA_CODE, requestCaptor.getValue().getCameraCode()); | |||
| assertEquals("2026-07-28 11:54:30", requestCaptor.getValue().getStartTime()); | |||
| assertEquals("2026-07-28 11:55:30", requestCaptor.getValue().getEndTime()); | |||
| assertEquals(Integer.valueOf(1), requestCaptor.getValue().getStreamType()); | |||
| assertEquals(8L, clipFile.getContentLength()); | |||
| assertTrue(clipFile.getDownloadFileName().contains( | |||
| "20260728-115430_20260728-115530.mp4")); | |||
| } | |||
| /** | |||
| * 验证目标一分钟开头缺少录像时拒绝生成不完整片段。 | |||
| */ | |||
| @Test | |||
| public void rejectsIncompleteMinuteBeforeCallingExporter() { | |||
| // 模拟设备实际录像晚10秒开始,无法覆盖前30秒。 | |||
| when(playbackService.search(playbackRequest)).thenReturn( | |||
| response("20260728115440", "20260728115530")); | |||
| try { | |||
| // 服务应在SDK下载前识别录像不完整。 | |||
| service.createClip(request()); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HttpStatus.CONFLICT, exception.getStatus()); | |||
| assertTrue(exception.getMessage().contains("录像不足30秒")); | |||
| } | |||
| // 录像不完整时不得创建临时设备下载任务。 | |||
| verify(clipExporter, never()).export(any(), any(), any()); | |||
| } | |||
| /** | |||
| * 验证目标时间没有任何录像时返回可供页面弹窗展示的明确原因。 | |||
| */ | |||
| @Test | |||
| public void rejectsCenterTimeWithoutAnyRecord() { | |||
| // 模拟NVR查询成功但目标时间范围没有任何录像记录。 | |||
| when(playbackService.search(playbackRequest)).thenReturn( | |||
| HikNvrPlaybackResponse.success(Collections.emptyList())); | |||
| try { | |||
| // 服务应在创建SDK下载任务前返回404。 | |||
| service.createClip(request()); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HttpStatus.NOT_FOUND, exception.getStatus()); | |||
| assertTrue(exception.getMessage().contains( | |||
| "目标时间没有对应录像")); | |||
| } | |||
| // 没有录像时不能调用SDK和FFmpeg生成空文件。 | |||
| verify(clipExporter, never()).export(any(), any(), any()); | |||
| } | |||
| /** | |||
| * 验证单侧秒数超过上限时返回参数错误。 | |||
| */ | |||
| @Test | |||
| public void rejectsContextLongerThanFiveMinutesPerSide() { | |||
| HikNvrPlaybackClipRequest request = request(); | |||
| request.setBeforeSeconds(301); | |||
| try { | |||
| // 超大下载范围必须在访问数据库和NVR前被拒绝。 | |||
| service.createClip(request); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); | |||
| assertTrue(exception.getMessage().contains("beforeSeconds")); | |||
| } | |||
| // 参数校验失败时不得读取NVR连接参数。 | |||
| verify(cameraTargetService, never()).resolve(any()); | |||
| } | |||
| /** | |||
| * 验证未来中心时刻在访问数据库和NVR前被后端拒绝。 | |||
| */ | |||
| @Test | |||
| public void rejectsCenterTimeThatHasNotArrived() { | |||
| HikNvrPlaybackClipRequest request = request(); | |||
| request.setCenterTime("2999-12-31 23:59:59"); | |||
| try { | |||
| // 未来录像尚未产生,接口应返回可供页面弹窗展示的400错误。 | |||
| service.createClip(request); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HttpStatus.BAD_REQUEST, exception.getStatus()); | |||
| assertTrue(exception.getMessage().contains("尚未到达")); | |||
| } | |||
| // 未来时间校验失败时不得读取NVR连接参数。 | |||
| verify(cameraTargetService, never()).resolve(any()); | |||
| } | |||
| /** | |||
| * 验证响应流复制完成后一定调用导出器清理临时目录。 | |||
| */ | |||
| @Test | |||
| public void streamsPreparedFileAndCleansTemporaryDirectory() throws Exception { | |||
| HikNvrPlaybackClipFile clipFile = new HikNvrPlaybackClipFile( | |||
| clipPath, | |||
| "clip.mp4", | |||
| Files.size(clipPath), | |||
| LocalDateTime.of(2026, 7, 28, 11, 54, 30), | |||
| LocalDateTime.of(2026, 7, 28, 11, 55, 30)); | |||
| ByteArrayOutputStream output = new ByteArrayOutputStream(); | |||
| // 将临时文件流式写入内存输出流,模拟HTTP客户端下载。 | |||
| service.writeAndDelete(clipFile, output); | |||
| assertArrayEquals( | |||
| "mp4-data".getBytes(StandardCharsets.UTF_8), | |||
| output.toByteArray()); | |||
| verify(clipExporter).cleanup(clipPath); | |||
| } | |||
| /** | |||
| * 创建默认的中心时刻下载请求。 | |||
| * | |||
| * @return 前后秒数和码流均留空的页面请求 | |||
| */ | |||
| private HikNvrPlaybackClipRequest request() { | |||
| return HikNvrPlaybackClipRequest.builder() | |||
| .cameraCode(CAMERA_CODE) | |||
| .centerTime("2026-07-28 11:55:00") | |||
| .build(); | |||
| } | |||
| /** | |||
| * 创建一个只包含单段连续录像的查询响应。 | |||
| * | |||
| * @param start 紧凑格式开始时间 | |||
| * @param end 紧凑格式结束时间 | |||
| * @return 海康录像查询成功响应 | |||
| */ | |||
| private HikNvrPlaybackResponse response(String start, String end) { | |||
| return HikNvrPlaybackResponse.success(Collections.singletonList( | |||
| HikNvrPlaybackRecord.builder() | |||
| .timeRange(Arrays.asList(start, end)) | |||
| .rtspUrl("rtsp://redacted") | |||
| .build())); | |||
| } | |||
| } | |||
| @ -1,398 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.fasterxml.jackson.databind.ObjectMapper; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsControlAction; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsControlRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsSession; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackHlsStatus; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRecord; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackResponse; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.hik.vo.HikNvrPlaybackHlsConfigVo; | |||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.http.HttpStatus; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.io.ByteArrayInputStream; | |||
| import java.io.ByteArrayOutputStream; | |||
| import java.io.InputStream; | |||
| import java.io.OutputStream; | |||
| import java.time.LocalDateTime; | |||
| import java.util.Arrays; | |||
| import java.util.ArrayList; | |||
| import java.util.Collections; | |||
| import java.util.List; | |||
| import java.util.concurrent.TimeUnit; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.junit.Assert.fail; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.Mockito.doThrow; | |||
| import static org.mockito.Mockito.never; | |||
| import static org.mockito.Mockito.times; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrPlaybackHlsServiceTest { | |||
| /** UI 回放会话绑定的完整 IVS 摄像机编码。 */ | |||
| private static final String CAMERA_CODE = | |||
| "22078710632508667033#domain-a"; | |||
| @Mock | |||
| private HikNvrPlaybackService playbackService; | |||
| @Mock | |||
| private HikSdkPlaybackGateway gateway; | |||
| @Mock | |||
| private HikSdkPlaybackHandle handle; | |||
| @Mock | |||
| private HikPlaybackHlsReadinessProbe readinessProbe; | |||
| private HikNvrPlaybackHlsService service; | |||
| private TestProcessFactory processFactory; | |||
| @Before | |||
| public void setUp() { | |||
| HikPlaybackHlsUrlBuilder urlBuilder = new HikPlaybackHlsUrlBuilder(); | |||
| ReflectionTestUtils.invokeMethod(urlBuilder, "initialize"); | |||
| HikNvrPlaybackHlsConfigVo config = new HikNvrPlaybackHlsConfigVo(); | |||
| config.setFfmpegPath("ffmpeg"); | |||
| config.setRtmpBaseUrl("rtmp://127.0.0.1:1935"); | |||
| config.setQueueCapacity(4); | |||
| config.setSessionGraceMillis(60_000L); | |||
| config.setIdleSessionMillis(120_000L); | |||
| config.setStartupTimeoutMillis(30_000L); | |||
| config.setNoDataTimeoutMillis(20_000L); | |||
| config.setMaxConcurrentSessions(1); | |||
| service = new HikNvrPlaybackHlsService(); | |||
| ReflectionTestUtils.setField(service, "playbackService", playbackService); | |||
| ReflectionTestUtils.setField(service, "sdkPlaybackGateway", gateway); | |||
| ReflectionTestUtils.setField(service, "sessionRegistry", | |||
| new HikNvrPlaybackHlsSessionRegistry()); | |||
| ReflectionTestUtils.setField(service, "hlsUrlBuilder", urlBuilder); | |||
| ReflectionTestUtils.setField(service, "readinessProbe", readinessProbe); | |||
| processFactory = new TestProcessFactory(); | |||
| ReflectionTestUtils.setField(service, "ffmpegProcessFactory", processFactory); | |||
| ReflectionTestUtils.setField(service, "config", config); | |||
| service.initialize(); | |||
| when(readinessProbe.isReady(any(String.class))).thenReturn(true); | |||
| when(playbackService.search(any())).thenReturn(HikNvrPlaybackResponse.success( | |||
| Collections.singletonList(HikNvrPlaybackRecord.builder() | |||
| .timeRange(Arrays.asList("20260713123000", "20260713130000")) | |||
| .rtspUrl("rtsp://operator:secret@192.168.1.100/Streaming/tracks/101") | |||
| .build()))); | |||
| when(gateway.open(any(), any(LocalDateTime.class), any(LocalDateTime.class), any())) | |||
| .thenReturn(handle); | |||
| } | |||
| @Test | |||
| public void createsSessionFromOneActualRecordingRangeWithoutReturningRtspUrl() throws Exception { | |||
| HikNvrPlaybackHlsSession session = service.create(request()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PLAYING, session.getStatus()); | |||
| assertEquals(1, session.getGeneration()); | |||
| assertEquals(Arrays.asList("20260713123000", "20260713130000"), | |||
| session.getActualTimeRange()); | |||
| assertTrue(session.getHlsUrl().contains("/playback/")); | |||
| assertTrue(session.getHlsUrl().endsWith("-1/hls.m3u8")); | |||
| assertFalse(new ObjectMapper().writeValueAsString(session).contains("rtsp://")); | |||
| } | |||
| @Test | |||
| public void reportsStartingUntilZlmHasGeneratedAnHlsPlaylist() { | |||
| when(readinessProbe.isReady(any(String.class))).thenReturn(false); | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.STARTING, created.getStatus()); | |||
| when(readinessProbe.isReady(any(String.class))).thenReturn(true); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PLAYING, | |||
| service.get(created.getSessionId()).getStatus()); | |||
| } | |||
| @Test | |||
| public void seekCreatesNextGenerationAndReturnsNewHlsUrl() { | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| HikNvrPlaybackHlsSession moved = service.control( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .action(HikNvrPlaybackHlsControlAction.SEEK) | |||
| .seekTime("2026-07-13 12:45:00") | |||
| .build()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PLAYING, moved.getStatus()); | |||
| assertEquals(2, moved.getGeneration()); | |||
| assertTrue(moved.getHlsUrl().contains("-2/hls.m3u8")); | |||
| ArgumentCaptor<LocalDateTime> target = ArgumentCaptor.forClass(LocalDateTime.class); | |||
| verify(gateway, times(2)).open( | |||
| any(), target.capture(), any(LocalDateTime.class), any()); | |||
| assertEquals(LocalDateTime.of(2026, 7, 13, 12, 45), target.getAllValues().get(1)); | |||
| verify(handle).close(); | |||
| } | |||
| @Test | |||
| public void appliesNativeSpeedToSameSdkPlaybackHandle() { | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| HikNvrPlaybackHlsSession changed = service.control( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .action(HikNvrPlaybackHlsControlAction.SET_SPEED) | |||
| .speed(8) | |||
| .build()); | |||
| assertEquals(8, changed.getSpeed()); | |||
| assertEquals(2, changed.getGeneration()); | |||
| assertTrue(changed.getHlsUrl().endsWith("-2/hls.m3u8")); | |||
| verify(handle).setSpeed(8); | |||
| verify(handle).setFrameSink(any(HikPlaybackFrameSink.class)); | |||
| // 八倍速新代次必须按200fps生成时间戳,同时保持同一个NVR SDK连接。 | |||
| assertEquals("200", processFactory.frameRateAt(1)); | |||
| verify(gateway, times(1)).open( | |||
| any(), any(LocalDateTime.class), any(LocalDateTime.class), any()); | |||
| } | |||
| @Test | |||
| public void reportsCurrentTimeFromLatestSdkPlaybackPacket() { | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| when(handle.getLastPlaybackTime()).thenReturn(LocalDateTime.of(2026, 7, 13, 12, 45, 12)); | |||
| HikNvrPlaybackHlsSession session = service.get(created.getSessionId()); | |||
| assertEquals("20260713124512", session.getCurrentTime()); | |||
| } | |||
| @Test | |||
| public void recreatesPausedPlaybackWhenPauseFailsAfterSdkConnectionEnded() { | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| HikSdkPlaybackHandle replacement = org.mockito.Mockito.mock(HikSdkPlaybackHandle.class); | |||
| LocalDateTime lastPlaybackTime = LocalDateTime.of(2026, 7, 13, 12, 45, 12); | |||
| when(handle.getLastPlaybackTime()).thenReturn(lastPlaybackTime); | |||
| doThrow(HikNvrPlaybackException.sdkFailure( | |||
| HCNetSDK.NET_DVR_NETWORK_SEND_ERROR, | |||
| "连接已结束")) | |||
| .when(handle).pause(); | |||
| when(gateway.open(any(), any(LocalDateTime.class), any(LocalDateTime.class), any())) | |||
| .thenReturn(replacement); | |||
| HikNvrPlaybackHlsSession paused = service.control( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .action(HikNvrPlaybackHlsControlAction.PAUSE) | |||
| .build()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PAUSED, paused.getStatus()); | |||
| assertEquals(2, paused.getGeneration()); | |||
| assertEquals("20260713124512", paused.getCurrentTime()); | |||
| assertTrue(paused.getHlsUrl().endsWith("-2/hls.m3u8")); | |||
| verify(handle).close(); | |||
| verify(replacement).pause(); | |||
| } | |||
| @Test | |||
| public void recreatesPlayingPlaybackWhenResumeFailsAfterSdkConnectionEnded() { | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| service.control(created.getSessionId(), HikNvrPlaybackHlsControlRequest.builder() | |||
| .action(HikNvrPlaybackHlsControlAction.PAUSE) | |||
| .build()); | |||
| HikSdkPlaybackHandle replacement = org.mockito.Mockito.mock(HikSdkPlaybackHandle.class); | |||
| LocalDateTime lastPlaybackTime = LocalDateTime.of(2026, 7, 13, 12, 45, 12); | |||
| when(handle.getLastPlaybackTime()).thenReturn(lastPlaybackTime); | |||
| doThrow(HikNvrPlaybackException.sdkFailure( | |||
| HCNetSDK.NET_DVR_NETWORK_SEND_ERROR, | |||
| "连接已结束")) | |||
| .when(handle).resume(); | |||
| when(gateway.open(any(), any(LocalDateTime.class), any(LocalDateTime.class), any())) | |||
| .thenReturn(replacement); | |||
| HikNvrPlaybackHlsSession resumed = service.control( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .action(HikNvrPlaybackHlsControlAction.RESUME) | |||
| .build()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PLAYING, resumed.getStatus()); | |||
| assertEquals(2, resumed.getGeneration()); | |||
| assertEquals("20260713124512", resumed.getCurrentTime()); | |||
| assertTrue(resumed.getHlsUrl().endsWith("-2/hls.m3u8")); | |||
| verify(handle).close(); | |||
| verify(replacement).setSpeed(1); | |||
| } | |||
| @Test | |||
| public void recreatesPlaybackAtRequestedSpeedWhenSpeedCommandFindsDisconnectedSdkHandle() { | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| HikSdkPlaybackHandle replacement = org.mockito.Mockito.mock(HikSdkPlaybackHandle.class); | |||
| when(handle.getLastPlaybackTime()).thenReturn(LocalDateTime.of(2026, 7, 13, 12, 45, 12)); | |||
| doThrow(HikNvrPlaybackException.sdkFailure( | |||
| HCNetSDK.NET_DVR_NETWORK_SEND_ERROR, | |||
| "连接已结束")) | |||
| .when(handle).setSpeed(8); | |||
| when(gateway.open(any(), any(LocalDateTime.class), any(LocalDateTime.class), any())) | |||
| .thenReturn(replacement); | |||
| HikNvrPlaybackHlsSession changed = service.control( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .action(HikNvrPlaybackHlsControlAction.SET_SPEED) | |||
| .speed(8) | |||
| .build()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PLAYING, changed.getStatus()); | |||
| assertEquals(8, changed.getSpeed()); | |||
| assertEquals(2, changed.getGeneration()); | |||
| verify(handle).close(); | |||
| verify(replacement).setSpeed(8); | |||
| } | |||
| @Test | |||
| public void recreatesPlaybackForSeekWithoutCallingTheExistingSdkHandle() { | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| HikSdkPlaybackHandle replacement = org.mockito.Mockito.mock(HikSdkPlaybackHandle.class); | |||
| when(gateway.open(any(), any(LocalDateTime.class), any(LocalDateTime.class), any())) | |||
| .thenReturn(replacement); | |||
| HikNvrPlaybackHlsSession moved = service.control( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .action(HikNvrPlaybackHlsControlAction.SEEK) | |||
| .seekTime("2026-07-13 12:45:00") | |||
| .build()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PLAYING, moved.getStatus()); | |||
| assertEquals(2, moved.getGeneration()); | |||
| assertEquals("20260713124500", moved.getCurrentTime()); | |||
| assertTrue(moved.getHlsUrl().endsWith("-2/hls.m3u8")); | |||
| verify(handle).close(); | |||
| verify(handle, never()).seek(any(LocalDateTime.class)); | |||
| verify(handle, never()).pause(); | |||
| verify(replacement).setSpeed(1); | |||
| } | |||
| /** UI 暂停动作携带匹配 cameraCode 时应控制当前绑定会话。 */ | |||
| @Test | |||
| public void controlsUiSessionWhenCameraCodeMatches() { | |||
| // 创建已经绑定 cameraCode 的 UI 会话。 | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| HikNvrPlaybackHlsSession paused = service.controlByCameraCode( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .cameraCode(CAMERA_CODE) | |||
| .action(HikNvrPlaybackHlsControlAction.PAUSE) | |||
| .build()); | |||
| assertEquals(CAMERA_CODE, paused.getCameraCode()); | |||
| assertEquals(HikNvrPlaybackHlsStatus.PAUSED, paused.getStatus()); | |||
| verify(handle).pause(); | |||
| } | |||
| /** UI 控制携带其他 cameraCode 时必须拒绝,不能操作旧摄像机会话。 */ | |||
| @Test | |||
| public void rejectsUiControlWhenCameraCodeDoesNotMatch() { | |||
| // 先创建目标会话,再模拟页面已经切换到其他摄像机。 | |||
| HikNvrPlaybackHlsSession created = service.create(request()); | |||
| try { | |||
| service.controlByCameraCode( | |||
| created.getSessionId(), | |||
| HikNvrPlaybackHlsControlRequest.builder() | |||
| .cameraCode("22078710632508667034#domain-a") | |||
| .action(HikNvrPlaybackHlsControlAction.PAUSE) | |||
| .build()); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HttpStatus.CONFLICT, exception.getStatus()); | |||
| assertTrue(exception.getMessage().contains("cameraCode")); | |||
| } | |||
| // 编码校验失败必须发生在调用海康 SDK 暂停命令之前。 | |||
| verify(handle, never()).pause(); | |||
| } | |||
| private HikNvrPlaybackRequest request() { | |||
| // 内部请求显式绑定 cameraCode,供 UI 控制路径进行目标一致性校验。 | |||
| return HikNvrPlaybackRequest.builder() | |||
| .cameraCode(CAMERA_CODE) | |||
| .nvrIp("192.168.1.100") | |||
| .sdkPort(8000) | |||
| .rtspPort(554) | |||
| .channel(33) | |||
| .streamType(1) | |||
| .username("admin") | |||
| .password("123456") | |||
| .startTime("2026-07-13 12:00:00") | |||
| .endTime("2026-07-13 13:00:00") | |||
| .build(); | |||
| } | |||
| private static final class TestProcessFactory extends HikFfmpegProcessFactory { | |||
| private final List<List<String>> commands = new ArrayList<>(); | |||
| @Override | |||
| public Process start(java.util.List<String> command) { | |||
| commands.add(new ArrayList<>(command)); | |||
| return new TestProcess(); | |||
| } | |||
| private String frameRateAt(int commandIndex) { | |||
| List<String> command = commands.get(commandIndex); | |||
| int frameRateOption = command.indexOf("-framerate"); | |||
| return command.get(frameRateOption + 1); | |||
| } | |||
| } | |||
| private static final class TestProcess extends Process { | |||
| private final ByteArrayOutputStream output = new ByteArrayOutputStream(); | |||
| @Override | |||
| public OutputStream getOutputStream() { | |||
| return output; | |||
| } | |||
| @Override | |||
| public InputStream getInputStream() { | |||
| return new ByteArrayInputStream(new byte[0]); | |||
| } | |||
| @Override | |||
| public InputStream getErrorStream() { | |||
| return new ByteArrayInputStream(new byte[0]); | |||
| } | |||
| @Override | |||
| public int waitFor() { | |||
| return 0; | |||
| } | |||
| @Override | |||
| public boolean waitFor(long timeout, TimeUnit unit) { | |||
| return true; | |||
| } | |||
| @Override | |||
| public int exitValue() { | |||
| return 0; | |||
| } | |||
| @Override | |||
| public void destroy() { | |||
| } | |||
| @Override | |||
| public Process destroyForcibly() { | |||
| return this; | |||
| } | |||
| } | |||
| } | |||
| @ -1,323 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackResponse; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||
| import com.inspect.nvr.service.HikLoginService; | |||
| import com.inspect.nvr.service.HikLoginSession; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.invocation.InvocationOnMock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.mockito.stubbing.Answer; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.time.LocalDateTime; | |||
| import java.util.Arrays; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.junit.Assert.fail; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.anyInt; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.Mockito.doAnswer; | |||
| import static org.mockito.Mockito.doReturn; | |||
| import static org.mockito.Mockito.never; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrPlaybackServiceTest { | |||
| @Mock | |||
| private HikLoginService hikLoginService; | |||
| @Mock | |||
| private HCNetSDK hcNetSDK; | |||
| private HikNvrPlaybackService service; | |||
| @Before | |||
| public void setUp() { | |||
| service = new HikNvrPlaybackService(); | |||
| ReflectionTestUtils.setField(service, "hikLoginService", hikLoginService); | |||
| ReflectionTestUtils.setField(service, "hcNetSDK", hcNetSDK); | |||
| ReflectionTestUtils.setField(service, "urlBuilder", new HikPlaybackUrlBuilder()); | |||
| } | |||
| @Test | |||
| public void returnsClippedMergedRangesAndKeepsRealGaps() { | |||
| prepareSearch(); | |||
| doAnswer(file("2026-07-13T12:20:00", "2026-07-13T12:40:00")) | |||
| .doAnswer(file("2026-07-13T12:30:00", "2026-07-13T12:50:00")) | |||
| .doAnswer(file("2026-07-13T12:55:00", "2026-07-13T13:10:00")) | |||
| .doReturn(HCNetSDK.NET_DVR_NOMOREFILE) | |||
| .when(hcNetSDK).NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class)); | |||
| HikNvrPlaybackResponse response = service.search(request()); | |||
| assertTrue(response.isHasRecord()); | |||
| assertEquals(2, response.getRecords().size()); | |||
| assertEquals(Arrays.asList("20260713122000", "20260713125000"), | |||
| response.getRecords().get(0).getTimeRange()); | |||
| assertEquals(Arrays.asList("20260713125500", "20260713130000"), | |||
| response.getRecords().get(1).getTimeRange()); | |||
| assertTrue(response.getRecords().get(1).getRtspUrl() | |||
| .contains("starttime=20260713T125500Z&endtime=20260713T130000Z")); | |||
| verify(hcNetSDK).NET_DVR_FindClose_V30(88); | |||
| ArgumentCaptor<HCNetSDK.NET_DVR_FILECOND_V40> conditionCaptor = | |||
| ArgumentCaptor.forClass(HCNetSDK.NET_DVR_FILECOND_V40.class); | |||
| verify(hcNetSDK).NET_DVR_FindFile_V40(eq(7), conditionCaptor.capture()); | |||
| HCNetSDK.NET_DVR_FILECOND_V40 condition = conditionCaptor.getValue(); | |||
| assertEquals(33, condition.lChannel); | |||
| assertEquals(0xff, condition.dwFileType); | |||
| assertEquals(0xff, condition.dwIsLocked); | |||
| assertEquals(3, condition.byStreamType); | |||
| assertEquals(12, condition.struStartTime.dwHour); | |||
| assertEquals(13, condition.struStopTime.dwHour); | |||
| } | |||
| @Test | |||
| public void keepsSdkChannelThirtyThreeAndBuildsRtspTrackOne() { | |||
| prepareSearch(); | |||
| doAnswer(file("2026-07-13T12:00:00", "2026-07-13T13:00:00")) | |||
| .doReturn(HCNetSDK.NET_DVR_NOMOREFILE) | |||
| .when(hcNetSDK).NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class)); | |||
| HikNvrPlaybackRequest request = request(); | |||
| request.setChannel(33); | |||
| HikNvrPlaybackResponse response = service.search(request); | |||
| assertTrue(response.getRecords().get(0).getRtspUrl() | |||
| .contains("/Streaming/tracks/101?starttime=20260713T120000Z" | |||
| + "&endtime=20260713T130000Z")); | |||
| ArgumentCaptor<HCNetSDK.NET_DVR_FILECOND_V40> conditionCaptor = | |||
| ArgumentCaptor.forClass(HCNetSDK.NET_DVR_FILECOND_V40.class); | |||
| verify(hcNetSDK).NET_DVR_FindFile_V40(eq(7), conditionCaptor.capture()); | |||
| assertEquals(33, conditionCaptor.getValue().lChannel); | |||
| } | |||
| @Test | |||
| public void returnsEmptyResultWhenNvrHasNoRecordings() { | |||
| prepareSearch(); | |||
| when(hcNetSDK.NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class))) | |||
| .thenReturn(HCNetSDK.NET_DVR_FILE_NOFIND); | |||
| HikNvrPlaybackResponse response = service.search(request()); | |||
| assertFalse(response.isHasRecord()); | |||
| assertTrue(response.getRecords().isEmpty()); | |||
| verify(hcNetSDK).NET_DVR_FindClose_V30(88); | |||
| } | |||
| @Test | |||
| public void mergesFilesThatMeetAtExactlyTheSameSecond() { | |||
| prepareSearch(); | |||
| doAnswer(file("2026-07-13T12:30:00", "2026-07-13T12:45:00")) | |||
| .doAnswer(file("2026-07-13T12:45:00", "2026-07-13T13:00:00")) | |||
| .doReturn(HCNetSDK.NET_DVR_NOMOREFILE) | |||
| .when(hcNetSDK).NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class)); | |||
| HikNvrPlaybackResponse response = service.search(request()); | |||
| assertEquals(1, response.getRecords().size()); | |||
| assertEquals(Arrays.asList("20260713123000", "20260713130000"), | |||
| response.getRecords().get(0).getTimeRange()); | |||
| } | |||
| @Test | |||
| public void clipsRecordingThatStartsBeforeRequestedRange() { | |||
| prepareSearch(); | |||
| doAnswer(file("2026-07-13T11:50:00", "2026-07-13T12:30:00")) | |||
| .doReturn(HCNetSDK.NET_DVR_NOMOREFILE) | |||
| .when(hcNetSDK).NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class)); | |||
| HikNvrPlaybackResponse response = service.search(request()); | |||
| assertEquals(Arrays.asList("20260713120000", "20260713123000"), | |||
| response.getRecords().get(0).getTimeRange()); | |||
| } | |||
| @Test | |||
| public void mapsSubStreamRequestToSdkSubStreamSearchType() { | |||
| prepareSearch(); | |||
| when(hcNetSDK.NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class))) | |||
| .thenReturn(HCNetSDK.NET_DVR_FILE_NOFIND); | |||
| HikNvrPlaybackRequest request = request(); | |||
| request.setStreamType(2); | |||
| service.search(request); | |||
| ArgumentCaptor<HCNetSDK.NET_DVR_FILECOND_V40> conditionCaptor = | |||
| ArgumentCaptor.forClass(HCNetSDK.NET_DVR_FILECOND_V40.class); | |||
| verify(hcNetSDK).NET_DVR_FindFile_V40(eq(7), conditionCaptor.capture()); | |||
| assertEquals(1, conditionCaptor.getValue().byStreamType); | |||
| } | |||
| @Test | |||
| public void retriesFindingStatusBeforeReturningRecord() { | |||
| prepareSearch(); | |||
| doReturn(HCNetSDK.NET_DVR_ISFINDING) | |||
| .doAnswer(file("2026-07-13T12:30:00", "2026-07-13T13:00:00")) | |||
| .doReturn(HCNetSDK.NET_DVR_NOMOREFILE) | |||
| .when(hcNetSDK).NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class)); | |||
| HikNvrPlaybackResponse response = service.search(request()); | |||
| assertTrue(response.isHasRecord()); | |||
| assertEquals(Arrays.asList("20260713123000", "20260713130000"), | |||
| response.getRecords().get(0).getTimeRange()); | |||
| verify(hcNetSDK).NET_DVR_FindClose_V30(88); | |||
| } | |||
| @Test | |||
| public void propagatesSdkErrorAndStillClosesFindHandle() { | |||
| prepareSearch(); | |||
| when(hcNetSDK.NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class))) | |||
| .thenReturn(HCNetSDK.NET_DVR_FILE_EXCEPTION); | |||
| when(hcNetSDK.NET_DVR_GetLastError()).thenReturn(7); | |||
| try { | |||
| service.search(request()); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(7, exception.getCode()); | |||
| } | |||
| verify(hcNetSDK).NET_DVR_FindClose_V30(88); | |||
| } | |||
| @Test | |||
| public void returnsFindFileErrorWithoutClosingInvalidHandle() { | |||
| when(hikLoginService.loginSession(any(NvrInfo.class))).thenReturn(loginSession()); | |||
| when(hcNetSDK.NET_DVR_FindFile_V40( | |||
| eq(7), any(HCNetSDK.NET_DVR_FILECOND_V40.class))).thenReturn(-1); | |||
| when(hcNetSDK.NET_DVR_GetLastError()).thenReturn(4); | |||
| try { | |||
| service.search(request()); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(4, exception.getCode()); | |||
| } | |||
| verify(hcNetSDK, never()).NET_DVR_FindClose_V30(anyInt()); | |||
| } | |||
| @Test | |||
| public void wrapsLoginFailureWithCurrentSdkError() { | |||
| when(hikLoginService.loginSession(any(NvrInfo.class))) | |||
| .thenThrow(new RuntimeException("login failed")); | |||
| when(hcNetSDK.NET_DVR_GetLastError()).thenReturn(7); | |||
| try { | |||
| service.search(request()); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(7, exception.getCode()); | |||
| } | |||
| verify(hcNetSDK, never()).NET_DVR_FindFile_V40( | |||
| anyInt(), any(HCNetSDK.NET_DVR_FILECOND_V40.class)); | |||
| } | |||
| @Test | |||
| public void restoresInterruptFlagAndClosesHandleWhenWaitIsInterrupted() { | |||
| prepareSearch(); | |||
| when(hcNetSDK.NET_DVR_FindNextFile_V40( | |||
| eq(88), any(HCNetSDK.NET_DVR_FINDDATA_V40.class))) | |||
| .thenReturn(HCNetSDK.NET_DVR_ISFINDING); | |||
| Thread.currentThread().interrupt(); | |||
| try { | |||
| service.search(request()); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(-2, exception.getCode()); | |||
| assertTrue(Thread.currentThread().isInterrupted()); | |||
| } finally { | |||
| Thread.interrupted(); | |||
| } | |||
| verify(hcNetSDK).NET_DVR_FindClose_V30(88); | |||
| } | |||
| @Test | |||
| public void rejectsEndTimeBeforeStartWithoutLoggingIntoNvr() { | |||
| HikNvrPlaybackRequest request = request(); | |||
| request.setEndTime("2026-07-13 11:59:59"); | |||
| try { | |||
| service.search(request); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(-1, exception.getCode()); | |||
| } | |||
| verify(hikLoginService, never()).loginSession(any(NvrInfo.class)); | |||
| verify(hcNetSDK, never()).NET_DVR_FindFile_V40( | |||
| anyInt(), any(HCNetSDK.NET_DVR_FILECOND_V40.class)); | |||
| } | |||
| private void prepareSearch() { | |||
| when(hikLoginService.loginSession(any(NvrInfo.class))).thenReturn(loginSession()); | |||
| when(hcNetSDK.NET_DVR_FindFile_V40( | |||
| eq(7), any(HCNetSDK.NET_DVR_FILECOND_V40.class))).thenReturn(88); | |||
| when(hcNetSDK.NET_DVR_FindClose_V30(88)).thenReturn(true); | |||
| } | |||
| private HikLoginSession loginSession() { | |||
| return new HikLoginSession(7, 0, 1, 8, 33); | |||
| } | |||
| private HikNvrPlaybackRequest request() { | |||
| return HikNvrPlaybackRequest.builder() | |||
| .nvrIp("192.168.1.100") | |||
| .sdkPort(8000) | |||
| .rtspPort(554) | |||
| .channel(1) | |||
| .streamType(1) | |||
| .username("admin") | |||
| .password("123456") | |||
| .startTime("2026-07-13 12:00:00") | |||
| .endTime("2026-07-13 13:00:00") | |||
| .build(); | |||
| } | |||
| private Answer<Integer> file(String start, String end) { | |||
| LocalDateTime startTime = LocalDateTime.parse(start); | |||
| LocalDateTime endTime = LocalDateTime.parse(end); | |||
| return (InvocationOnMock invocation) -> { | |||
| HCNetSDK.NET_DVR_FINDDATA_V40 data = invocation.getArgument(1); | |||
| data.struStartTime = sdkTime(startTime); | |||
| data.struStopTime = sdkTime(endTime); | |||
| return HCNetSDK.NET_DVR_FILE_SUCCESS; | |||
| }; | |||
| } | |||
| private HCNetSDK.NET_DVR_TIME sdkTime(LocalDateTime value) { | |||
| HCNetSDK.NET_DVR_TIME time = new HCNetSDK.NET_DVR_TIME(); | |||
| time.dwYear = value.getYear(); | |||
| time.dwMonth = value.getMonthValue(); | |||
| time.dwDay = value.getDayOfMonth(); | |||
| time.dwHour = value.getHour(); | |||
| time.dwMinute = value.getMinute(); | |||
| time.dwSecond = value.getSecond(); | |||
| return time; | |||
| } | |||
| } | |||
| @ -1,337 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.hik.domain.HikNvrPtzMode; | |||
| import com.inspect.nvr.hik.domain.HikNvrUiActionRequest; | |||
| import com.inspect.nvr.hik.domain.HikNvrUiActionResponse; | |||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.ivs.mapper.IvsCameraMapper; | |||
| import com.inspect.nvr.ivs.pojo.IvsCamera; | |||
| import com.inspect.nvr.service.HikCameraService; | |||
| import com.inspect.nvr.service.HikLoginService; | |||
| import com.inspect.nvr.service.HikLoginSession; | |||
| import com.sun.jna.Pointer; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.io.ByteArrayOutputStream; | |||
| import java.nio.charset.Charset; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.util.Map; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.anyInt; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.Mockito.doReturn; | |||
| import static org.mockito.Mockito.doNothing; | |||
| import static org.mockito.Mockito.spy; | |||
| import static org.mockito.Mockito.times; | |||
| import static org.mockito.Mockito.timeout; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikNvrUiActionServiceTest { | |||
| @Mock | |||
| private HikLoginService loginService; | |||
| @Mock | |||
| private HikCameraService cameraService; | |||
| @Mock | |||
| private HCNetSDK sdk; | |||
| @Mock | |||
| private IvsCameraMapper ivsCameraMapper; | |||
| private HikNvrUiActionService service; | |||
| @Before | |||
| public void setUp() { | |||
| // 构造完全来自数据库的 NVR 连接对象,服务不再依赖固定页面配置。 | |||
| NvrInfo databaseNvr = new NvrInfo(); | |||
| databaseNvr.setNvrName("测试 NVR"); | |||
| databaseNvr.setNvrIp("192.168.1.249"); | |||
| databaseNvr.setServerPort(8000); | |||
| databaseNvr.setRtspPort(554); | |||
| databaseNvr.setHttpPort(80); | |||
| databaseNvr.setAccount("admin"); | |||
| databaseNvr.setPassword("secret"); | |||
| databaseNvr.setStartChannel(1); | |||
| service = spy(new HikNvrUiActionService()); | |||
| ReflectionTestUtils.setField(service, "boundNvrInfo", databaseNvr); | |||
| ReflectionTestUtils.setField(service, "loginService", loginService); | |||
| ReflectionTestUtils.setField(service, "cameraService", cameraService); | |||
| ReflectionTestUtils.setField(service, "sdk", sdk); | |||
| ReflectionTestUtils.setField(service, "ivsCameraMapper", ivsCameraMapper); | |||
| // 清理静态通道状态,避免不同测试之间复用上一次 PTZ 动作的停止命令。 | |||
| Map<?, ?> channelStates = (Map<?, ?>) ReflectionTestUtils.getField( | |||
| HikNvrUiActionService.class, "PTZ_CHANNEL_STATES"); | |||
| if (channelStates != null) { | |||
| channelStates.clear(); | |||
| } | |||
| when(loginService.loginSession(any())).thenReturn( | |||
| new HikLoginSession(7, 0, 1, 8, 33)); | |||
| } | |||
| @Test | |||
| public void callsSdkPresetWithoutIsapiRoundTrip() { | |||
| when(sdk.NET_DVR_PTZPreset_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.GOTO_PRESET), eq(5))).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.preset(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .preset(5) | |||
| .build()); | |||
| assertTrue(response.isSuccess()); | |||
| assertEquals("已调用预置位 5(SDK)", response.getMessage()); | |||
| assertEquals(Integer.valueOf(40), response.getSdkChannel()); | |||
| verify(sdk).NET_DVR_PTZPreset_Other(7, 40, HCNetSDK.GOTO_PRESET, 5); | |||
| } | |||
| @Test | |||
| public void sendsPtzThroughSdkWithConfiguredSpeed() { | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.PAN_LEFT), eq(0), eq(6))).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.ptz(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .action("LEFT") | |||
| .speed(6) | |||
| .build()); | |||
| assertTrue(response.isSuccess()); | |||
| assertEquals(Integer.valueOf(40), response.getSdkChannel()); | |||
| verify(sdk).NET_DVR_PTZControlWithSpeed_Other(7, 40, HCNetSDK.PAN_LEFT, 0, 6); | |||
| } | |||
| @Test | |||
| public void sendsPointPtzStartAndAutoStopAfterOneSecond() throws Exception { | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.PAN_LEFT), eq(0), eq(6))).thenReturn(true); | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.PAN_LEFT), eq(1), eq(6))).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.ptz( | |||
| HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .action("LEFT") | |||
| .speed(6) | |||
| .build(), | |||
| HikNvrPtzMode.POINT); | |||
| assertTrue(response.isSuccess()); | |||
| assertTrue(response.getMessage().contains("1秒后自动停止")); | |||
| verify(sdk).NET_DVR_PTZControlWithSpeed_Other(7, 40, HCNetSDK.PAN_LEFT, 0, 6); | |||
| verify(sdk, timeout(2500)).NET_DVR_PTZControlWithSpeed_Other( | |||
| 7, 40, HCNetSDK.PAN_LEFT, 1, 6); | |||
| } | |||
| @Test | |||
| public void explicitStopCancelsPendingPointStop() throws Exception { | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.PAN_LEFT), eq(0), eq(6))).thenReturn(true); | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.PAN_LEFT), eq(1), eq(6))).thenReturn(true); | |||
| service.ptz(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .action("LEFT") | |||
| .speed(6) | |||
| .build(), HikNvrPtzMode.POINT); | |||
| service.ptz(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .action("STOP") | |||
| .speed(6) | |||
| .build()); | |||
| Thread.sleep(1200L); | |||
| verify(sdk, times(1)).NET_DVR_PTZControlWithSpeed_Other( | |||
| 7, 40, HCNetSDK.PAN_LEFT, 1, 6); | |||
| } | |||
| /** 验证光圈动作会映射为海康光圈放大命令并携带设备速度。 */ | |||
| @Test | |||
| public void sendsLensCommandThroughSdkWithMappedCommand() { | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.IRIS_OPEN), eq(0), eq(7))).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.ptz(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .action("IRIS_OPEN") | |||
| .speed(7) | |||
| .build()); | |||
| assertTrue(response.isSuccess()); | |||
| verify(sdk).NET_DVR_PTZControlWithSpeed_Other( | |||
| 7, 40, HCNetSDK.IRIS_OPEN, 0, 7); | |||
| } | |||
| /** 验证停止请求会复用最近一次启动的聚焦命令,而不是使用方向命令占位值。 */ | |||
| @Test | |||
| public void stopsThePreviouslyStartedLensCommand() { | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.FOCUS_NEAR), eq(0), eq(5))).thenReturn(true); | |||
| when(sdk.NET_DVR_PTZControlWithSpeed_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.FOCUS_NEAR), eq(1), eq(5))).thenReturn(true); | |||
| service.ptz(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .action("FOCUS_NEAR") | |||
| .speed(5) | |||
| .build()); | |||
| HikNvrUiActionResponse response = service.ptz(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .action("STOP") | |||
| .speed(4) | |||
| .build()); | |||
| assertTrue(response.isSuccess()); | |||
| verify(sdk).NET_DVR_PTZControlWithSpeed_Other( | |||
| 7, 40, HCNetSDK.FOCUS_NEAR, 1, 5); | |||
| } | |||
| /** 验证雨刷打开请求会调用海康辅助设备控制接口。 */ | |||
| @Test | |||
| public void controlsWiperThroughAuxiliarySdkCommand() { | |||
| when(sdk.NET_DVR_PTZControl_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.WIPER_PWRON), eq(0))).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.auxiliaryControl(8, 1, true); | |||
| assertTrue(response.isSuccess()); | |||
| verify(sdk).NET_DVR_PTZControl_Other(7, 40, HCNetSDK.WIPER_PWRON, 0); | |||
| } | |||
| /** 验证起始位调用会构造远程控制结构体并发送给海康 SDK。 */ | |||
| @Test | |||
| public void callsConfiguredInitialPositionThroughRemoteControl() { | |||
| when(sdk.NET_DVR_RemoteControl( | |||
| eq(7), eq(HCNetSDK.NET_DVR_PTZ_INITIALPOSITIONCTRL), | |||
| any(Pointer.class), anyInt())).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.gotoInitialPosition(8); | |||
| assertTrue(response.isSuccess()); | |||
| assertEquals(Integer.valueOf(40), response.getSdkChannel()); | |||
| verify(sdk).NET_DVR_RemoteControl( | |||
| eq(7), eq(HCNetSDK.NET_DVR_PTZ_INITIALPOSITIONCTRL), | |||
| any(Pointer.class), anyInt()); | |||
| } | |||
| @Test | |||
| public void savesUnnamedPresetThroughSdkWithoutHttpNameSync() { | |||
| when(sdk.NET_DVR_PTZPreset_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.SET_PRESET), eq(6))).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.createPreset(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .preset(6) | |||
| .build()); | |||
| assertTrue(response.isSuccess()); | |||
| assertEquals("已保存预置位 6(SDK)", response.getMessage()); | |||
| assertEquals(Integer.valueOf(40), response.getSdkChannel()); | |||
| verify(sdk).NET_DVR_PTZPreset_Other(7, 40, HCNetSDK.SET_PRESET, 6); | |||
| } | |||
| @Test | |||
| public void savesPresetNameThroughCameraSdkAndNvrIsapi() { | |||
| when(sdk.NET_DVR_PTZPreset_Other( | |||
| eq(7), eq(40), eq(HCNetSDK.SET_PRESET), eq(6))).thenReturn(true); | |||
| when(sdk.NET_DVR_SetDVRConfig( | |||
| eq(7), eq(HCNetSDK.NET_DVR_SET_PRESET_NAME), eq(40), | |||
| any(Pointer.class), anyInt())).thenReturn(true); | |||
| doNothing().when(service).setPresetByIsapi(eq(8), eq(6), eq("测试大门")); | |||
| HikNvrUiActionResponse response = service.createPreset(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .preset(6) | |||
| .presetName("测试大门") | |||
| .build()); | |||
| assertTrue(response.isSuccess()); | |||
| assertEquals("已保存预置位 6(SDK),名称:测试大门", response.getMessage()); | |||
| verify(sdk).NET_DVR_SetDVRConfig( | |||
| eq(7), eq(HCNetSDK.NET_DVR_SET_PRESET_NAME), eq(40), | |||
| any(Pointer.class), anyInt()); | |||
| } | |||
| @Test | |||
| public void prefersClientDemoDirectCameraTargetWhenConfigured() { | |||
| // 直连目标只从当前 NVR 的 ivs_camera 记录读取。 | |||
| IvsCamera camera = new IvsCamera(); | |||
| camera.setCode("22078710632508667008#database-domain"); | |||
| camera.setDevIP("192.168.1.254"); | |||
| when(ivsCameraMapper.selectByNvrIp("192.168.1.249")) | |||
| .thenReturn(java.util.Collections.singletonList(camera)); | |||
| doReturn(true).when(service).isCameraReachable(any(IvsCamera.class)); | |||
| when(loginService.loginSession(any(NvrInfo.class))).thenAnswer(invocation -> { | |||
| NvrInfo info = invocation.getArgument(0); | |||
| if ("192.168.1.254".equals(info.getNvrIp())) { | |||
| return new HikLoginSession(9, 1, 1, 0, 0); | |||
| } | |||
| return new HikLoginSession(7, 0, 1, 8, 33); | |||
| }); | |||
| when(sdk.NET_DVR_PTZPreset_Other( | |||
| eq(9), eq(1), eq(HCNetSDK.GOTO_PRESET), eq(5))).thenReturn(true); | |||
| HikNvrUiActionResponse response = service.preset(HikNvrUiActionRequest.builder() | |||
| .channel(8) | |||
| .preset(5) | |||
| .build()); | |||
| assertTrue(response.isSuccess()); | |||
| assertEquals(Integer.valueOf(1), response.getSdkChannel()); | |||
| verify(sdk).NET_DVR_PTZPreset_Other(9, 1, HCNetSDK.GOTO_PRESET, 5); | |||
| verify(loginService).loginSession(any(NvrInfo.class)); | |||
| } | |||
| @Test | |||
| public void decodesPresetNameUsingItsActualEncoding() { | |||
| String utf8Name = ReflectionTestUtils.invokeMethod( | |||
| service, "decodePresetName", "正确".getBytes(StandardCharsets.UTF_8)); | |||
| String gbkName = ReflectionTestUtils.invokeMethod( | |||
| service, "decodePresetName", "预置点 3".getBytes(Charset.forName("GBK"))); | |||
| assertEquals("正确", utf8Name); | |||
| assertEquals("预置点 3", gbkName); | |||
| } | |||
| @Test | |||
| public void normalizesMixedPresetXmlWithoutChangingUtf8Names() throws Exception { | |||
| ByteArrayOutputStream xml = new ByteArrayOutputStream(); | |||
| xml.write(("<?xml version=\"1.0\" encoding=\"UTF-8\"?><PTZPresets>" | |||
| + "<PTZPreset><id>1</id><presetName>").getBytes(StandardCharsets.UTF_8)); | |||
| xml.write("正确".getBytes(StandardCharsets.UTF_8)); | |||
| xml.write(("</presetName></PTZPreset><PTZPreset><id>3</id><presetName>") | |||
| .getBytes(StandardCharsets.UTF_8)); | |||
| xml.write("预置点 3".getBytes(Charset.forName("GBK"))); | |||
| xml.write("</presetName></PTZPreset></PTZPresets>" | |||
| .getBytes(StandardCharsets.UTF_8)); | |||
| String normalized = ReflectionTestUtils.invokeMethod( | |||
| service, "normalizePresetXml", xml.toByteArray()); | |||
| assertTrue(normalized.contains("<presetName>正确</presetName>")); | |||
| assertTrue(normalized.contains("<presetName>预置点 3</presetName>")); | |||
| } | |||
| @Test | |||
| public void buildsPresetNamePayloadAsUtf8() { | |||
| String xml = ReflectionTestUtils.invokeMethod( | |||
| service, "buildPresetXml", 6, "测试大门"); | |||
| byte[] wireBytes = xml.getBytes(StandardCharsets.UTF_8); | |||
| assertTrue(new String(wireBytes, StandardCharsets.UTF_8) | |||
| .contains("<presetName>测试大门</presetName>")); | |||
| assertEquals(-1, new String(wireBytes, Charset.forName("GBK")) | |||
| .indexOf("<presetName>测试大门</presetName>")); | |||
| } | |||
| } | |||
| @ -1,18 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import org.junit.Test; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import static org.junit.Assert.assertEquals; | |||
| public class HikPlaybackHlsUrlBuilderTest { | |||
| @Test | |||
| public void buildsLocalHlsUrlForStreamGeneration() { | |||
| HikPlaybackHlsUrlBuilder builder = new HikPlaybackHlsUrlBuilder(); | |||
| ReflectionTestUtils.invokeMethod(builder, "initialize"); | |||
| assertEquals("http://127.0.0.1:18081/playback/a1-2/hls.m3u8", | |||
| builder.hlsUrl("a1", 2)); | |||
| } | |||
| } | |||
| @ -1,82 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import org.junit.Test; | |||
| import java.time.LocalDateTime; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertTrue; | |||
| public class HikPlaybackUrlBuilderTest { | |||
| private final HikPlaybackUrlBuilder builder = new HikPlaybackUrlBuilder(); | |||
| @Test | |||
| public void buildsMainStreamPlaybackUrlUsingResolvedChannelAndBeijingTimes() { | |||
| HikNvrPlaybackRequest request = request("admin", "p@ss word", 33, 1); | |||
| String url = builder.build( | |||
| request, | |||
| 1, | |||
| LocalDateTime.of(2026, 7, 13, 12, 30, 0), | |||
| LocalDateTime.of(2026, 7, 13, 13, 0, 0)); | |||
| assertEquals("rtsp://admin:p%40ss%20word@192.168.1.100:554/Streaming/tracks/101" | |||
| + "?starttime=20260713T123000Z&endtime=20260713T130000Z", url); | |||
| } | |||
| @Test | |||
| public void buildsSubStreamTrackFromResolvedRtspChannel() { | |||
| HikNvrPlaybackRequest request = request("operator", "secret", 33, 2); | |||
| String url = builder.build( | |||
| request, | |||
| 1, | |||
| LocalDateTime.of(2026, 7, 13, 12, 0, 0), | |||
| LocalDateTime.of(2026, 7, 13, 13, 0, 0)); | |||
| assertTrue(url.contains("/Streaming/tracks/102?")); | |||
| } | |||
| @Test | |||
| public void buildsLiveUrlUsingTheSameResolvedChannelAndCredentials() { | |||
| HikNvrPlaybackRequest request = request("admin", "p@ss word", 33, 1); | |||
| String url = builder.buildLive(request, 1); | |||
| assertEquals("rtsp://admin:p%40ss%20word@192.168.1.100:554/Streaming/Channels/101", url); | |||
| } | |||
| @Test | |||
| public void percentEncodesUnicodeAndReservedCredentialCharacters() { | |||
| HikNvrPlaybackRequest request = request("用:户", "p%/密", 1, 1); | |||
| String url = builder.build( | |||
| request, | |||
| 1, | |||
| LocalDateTime.of(2026, 7, 13, 12, 0, 0), | |||
| LocalDateTime.of(2026, 7, 13, 13, 0, 0)); | |||
| assertTrue(url.startsWith( | |||
| "rtsp://%E7%94%A8%3A%E6%88%B7:p%25%2F%E5%AF%86@192.168.1.100:554/")); | |||
| } | |||
| private HikNvrPlaybackRequest request( | |||
| String username, | |||
| String password, | |||
| int channel, | |||
| int streamType) { | |||
| return HikNvrPlaybackRequest.builder() | |||
| .nvrIp("192.168.1.100") | |||
| .sdkPort(8000) | |||
| .rtspPort(554) | |||
| .channel(channel) | |||
| .streamType(streamType) | |||
| .username(username) | |||
| .password(password) | |||
| .startTime("2026-07-13 12:00:00") | |||
| .endTime("2026-07-13 13:00:00") | |||
| .build(); | |||
| } | |||
| } | |||
| @ -1,314 +0,0 @@ | |||
| package com.inspect.nvr.hik.service; | |||
| import com.inspect.nvr.dahua.DahuaNvrPlaybackService; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hik.domain.HikNvrPlaybackRequest; | |||
| import com.inspect.nvr.hik.exception.HikNvrPlaybackException; | |||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||
| import com.inspect.nvr.service.HikLoginService; | |||
| import com.inspect.nvr.service.HikLoginSession; | |||
| import com.inspect.nvr.playback.NvrSdkPlaybackHandle; | |||
| import com.sun.jna.Memory; | |||
| import com.sun.jna.Pointer; | |||
| import com.sun.jna.ptr.IntByReference; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.ArgumentCaptor; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.time.LocalDateTime; | |||
| import java.util.ArrayList; | |||
| import java.util.List; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.junit.Assert.fail; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.anyInt; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.ArgumentMatchers.isNull; | |||
| import static org.mockito.Mockito.atLeastOnce; | |||
| import static org.mockito.Mockito.clearInvocations; | |||
| import static org.mockito.Mockito.never; | |||
| import static org.mockito.Mockito.times; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikSdkPlaybackGatewayTest { | |||
| @Mock | |||
| private HikLoginService hikLoginService; | |||
| @Mock | |||
| private HCNetSDK sdk; | |||
| /** 大华回放服务模拟对象。 */ | |||
| @Mock | |||
| private DahuaNvrPlaybackService dahuaPlaybackService; | |||
| private HikSdkPlaybackGateway gateway; | |||
| @Before | |||
| public void setUp() { | |||
| gateway = new HikSdkPlaybackGatewayImpl(); | |||
| ReflectionTestUtils.setField(gateway, "hikLoginService", hikLoginService); | |||
| ReflectionTestUtils.setField(gateway, "sdk", sdk); | |||
| ReflectionTestUtils.setField(gateway, "dahuaPlaybackService", dahuaPlaybackService); | |||
| when(hikLoginService.loginSession(any(NvrInfo.class))) | |||
| .thenReturn(new HikLoginSession(7, 0, 1, 8, 33)); | |||
| when(sdk.NET_DVR_PlayBackByTime_V40(eq(7), any(HCNetSDK.NET_DVR_VOD_PARA.class))) | |||
| .thenReturn(91); | |||
| when(sdk.NET_DVR_SetPlayBackESCallBack(eq(91), any(HCNetSDK.FPlayESCallBack.class), isNull())) | |||
| .thenReturn(true); | |||
| when(sdk.NET_DVR_PlayBackControl_V40( | |||
| eq(91), anyInt(), any(), anyInt(), any(), any(IntByReference.class))) | |||
| .thenReturn(true); | |||
| } | |||
| /** 数据库厂商为大华时,公共 HLS 网关必须直接返回 NetSDK 句柄。 */ | |||
| @Test | |||
| public void routesDahuaPlaybackWithoutCallingHikSdk() { | |||
| HikNvrPlaybackRequest request = request(); | |||
| request.setVendor("DAHUA"); | |||
| NvrSdkPlaybackHandle dahuaHandle = | |||
| org.mockito.Mockito.mock(NvrSdkPlaybackHandle.class); | |||
| when(dahuaPlaybackService.supports(request)).thenReturn(true); | |||
| when(dahuaPlaybackService.open(eq(request), eq(start()), eq(end()), any())) | |||
| .thenReturn(dahuaHandle); | |||
| NvrSdkPlaybackHandle result = gateway.open( | |||
| request, start(), end(), frame -> true); | |||
| assertEquals(dahuaHandle, result); | |||
| verify(dahuaPlaybackService).open(eq(request), eq(start()), eq(end()), any()); | |||
| verify(sdk, never()).NET_DVR_PlayBackByTime_V40( | |||
| anyInt(), any(HCNetSDK.NET_DVR_VOD_PARA.class)); | |||
| } | |||
| @Test | |||
| public void exposesAbsoluteSeekControlAndStructuredPacketLayout() { | |||
| assertEquals(26, HCNetSDK.NET_DVR_PLAYSETTIME); | |||
| assertTrue(new HCNetSDK.NET_DVR_PACKET_INFO_EX().size() > 80); | |||
| } | |||
| @Test | |||
| public void startsSdkPlaybackWithOriginalSdkChannelAndActualRange() { | |||
| gateway.open(request(), start(), end(), frame -> true); | |||
| ArgumentCaptor<HCNetSDK.NET_DVR_VOD_PARA> vodCaptor = | |||
| ArgumentCaptor.forClass(HCNetSDK.NET_DVR_VOD_PARA.class); | |||
| verify(sdk).NET_DVR_PlayBackByTime_V40(eq(7), vodCaptor.capture()); | |||
| HCNetSDK.NET_DVR_VOD_PARA vod = vodCaptor.getValue(); | |||
| assertEquals(33, vod.struIDInfo.dwChannel); | |||
| assertEquals(0, vod.byStreamType); | |||
| assertEquals(12, vod.struBeginTime.dwHour); | |||
| assertEquals(13, vod.struEndTime.dwHour); | |||
| verify(sdk).NET_DVR_PlayBackControl_V40(eq(91), eq(HCNetSDK.NET_DVR_PLAYSTART), | |||
| isNull(), eq(0), isNull(), any(IntByReference.class)); | |||
| verify(sdk).NET_DVR_PlayBackControl_V40(eq(91), eq(HCNetSDK.NET_DVR_PLAYNORMAL), | |||
| isNull(), eq(0), isNull(), any(IntByReference.class)); | |||
| } | |||
| @Test | |||
| public void explainsWhenNvrHasReachedItsMaximumClientConnections() { | |||
| when(sdk.NET_DVR_PlayBackByTime_V40(eq(7), any(HCNetSDK.NET_DVR_VOD_PARA.class))) | |||
| .thenReturn(-1); | |||
| when(sdk.NET_DVR_GetLastError()).thenReturn(HCNetSDK.NET_DVR_OVER_MAXLINK); | |||
| try { | |||
| gateway.open(request(), start(), end(), frame -> true); | |||
| fail("Expected HikNvrPlaybackException"); | |||
| } catch (HikNvrPlaybackException exception) { | |||
| assertEquals(HCNetSDK.NET_DVR_OVER_MAXLINK, exception.getCode()); | |||
| assertTrue(exception.getMessage().contains("NVR客户端连接数已达到上限")); | |||
| } | |||
| } | |||
| @Test | |||
| public void mapsLogicalChannelToSdkChannelWhenOpeningPlayback() { | |||
| HikNvrPlaybackRequest request = request(); | |||
| request.setChannel(1); | |||
| gateway.open(request, start(), end(), frame -> true); | |||
| ArgumentCaptor<HCNetSDK.NET_DVR_VOD_PARA> vodCaptor = | |||
| ArgumentCaptor.forClass(HCNetSDK.NET_DVR_VOD_PARA.class); | |||
| verify(sdk).NET_DVR_PlayBackByTime_V40(eq(7), vodCaptor.capture()); | |||
| assertEquals(33, vodCaptor.getValue().struIDInfo.dwChannel); | |||
| } | |||
| @Test | |||
| public void mapsSpeedEightToNormalThenThreeFastCommands() { | |||
| HikSdkPlaybackHandle handle = (HikSdkPlaybackHandle) | |||
| gateway.open(request(), start(), end(), frame -> true); | |||
| clearInvocations(sdk); | |||
| handle.setSpeed(8); | |||
| verify(sdk, times(1)).NET_DVR_PlayBackControl_V40(eq(91), eq(HCNetSDK.NET_DVR_PLAYNORMAL), | |||
| isNull(), eq(0), isNull(), any(IntByReference.class)); | |||
| verify(sdk, times(3)).NET_DVR_PlayBackControl_V40(eq(91), eq(HCNetSDK.NET_DVR_PLAYFAST), | |||
| isNull(), eq(0), isNull(), any(IntByReference.class)); | |||
| } | |||
| @Test | |||
| public void seeksUsingNetDvrTimePointer() { | |||
| HikSdkPlaybackHandle handle = (HikSdkPlaybackHandle) | |||
| gateway.open(request(), start(), end(), frame -> true); | |||
| handle.seek(LocalDateTime.of(2026, 7, 13, 12, 45)); | |||
| verify(sdk).NET_DVR_PlayBackControl_V40(eq(91), eq(HCNetSDK.NET_DVR_PLAYSETTIME), | |||
| any(Pointer.class), eq(new HCNetSDK.NET_DVR_TIME().size()), | |||
| isNull(), any(IntByReference.class)); | |||
| } | |||
| @Test | |||
| public void forwardsAnnexBCodecHeaderBeforeVideoFrames() { | |||
| List<HikPlaybackFrame> frames = new ArrayList<>(); | |||
| HikSdkPlaybackHandle handle = (HikSdkPlaybackHandle) | |||
| gateway.open(request(), start(), end(), frames::add); | |||
| ArgumentCaptor<HCNetSDK.FPlayESCallBack> callback = | |||
| ArgumentCaptor.forClass(HCNetSDK.FPlayESCallBack.class); | |||
| verify(sdk).NET_DVR_SetPlayBackESCallBack(eq(91), callback.capture(), isNull()); | |||
| byte[] codecHeader = new byte[]{0, 0, 0, 1, 0x67, 0x42, 0, 0x1f}; | |||
| Memory memory = new Memory(codecHeader.length); | |||
| memory.write(0, codecHeader, 0, codecHeader.length); | |||
| HCNetSDK.NET_DVR_PACKET_INFO_EX packet = new HCNetSDK.NET_DVR_PACKET_INFO_EX(); | |||
| packet.dwPacketType = 0; | |||
| packet.dwPacketSize = codecHeader.length; | |||
| packet.pPacketBuffer = memory; | |||
| callback.getValue().invoke(91, packet, null); | |||
| assertEquals(1, frames.size()); | |||
| assertEquals(0, frames.get(0).getPacketType()); | |||
| List<HikPlaybackFrame> nextSinkFrames = new ArrayList<>(); | |||
| handle.setFrameSink(nextSinkFrames::add); | |||
| assertEquals(1, nextSinkFrames.size()); | |||
| assertEquals(0, nextSinkFrames.get(0).getPacketType()); | |||
| } | |||
| @Test | |||
| public void replaysParameterSetsEmbeddedInAnIFrameWhenSinkChanges() { | |||
| List<HikPlaybackFrame> frames = new ArrayList<>(); | |||
| HikSdkPlaybackHandle handle = (HikSdkPlaybackHandle) | |||
| gateway.open(request(), start(), end(), frames::add); | |||
| ArgumentCaptor<HCNetSDK.FPlayESCallBack> callback = | |||
| ArgumentCaptor.forClass(HCNetSDK.FPlayESCallBack.class); | |||
| verify(sdk).NET_DVR_SetPlayBackESCallBack(eq(91), callback.capture(), isNull()); | |||
| byte[] iFrameWithParameters = new byte[]{ | |||
| 0, 0, 0, 1, 0x67, 0x42, 0, 0x1f, | |||
| 0, 0, 0, 1, 0x68, (byte) 0xce, 0x06, | |||
| 0, 0, 0, 1, 0x65, (byte) 0x88, (byte) 0x84}; | |||
| Memory memory = new Memory(iFrameWithParameters.length); | |||
| memory.write(0, iFrameWithParameters, 0, iFrameWithParameters.length); | |||
| HCNetSDK.NET_DVR_PACKET_INFO_EX packet = new HCNetSDK.NET_DVR_PACKET_INFO_EX(); | |||
| packet.dwPacketType = 1; | |||
| packet.dwPacketSize = iFrameWithParameters.length; | |||
| packet.pPacketBuffer = memory; | |||
| callback.getValue().invoke(91, packet, null); | |||
| List<HikPlaybackFrame> nextSinkFrames = new ArrayList<>(); | |||
| handle.setFrameSink(nextSinkFrames::add); | |||
| assertEquals(1, nextSinkFrames.size()); | |||
| assertEquals(0, nextSinkFrames.get(0).getPacketType()); | |||
| assertTrue(nextSinkFrames.get(0).getPayload().length < iFrameWithParameters.length); | |||
| } | |||
| @Test | |||
| public void recordsSourcePlaybackTimeFromVideoPackets() { | |||
| HikSdkPlaybackHandle handle = (HikSdkPlaybackHandle) | |||
| gateway.open(request(), start(), end(), frame -> true); | |||
| ArgumentCaptor<HCNetSDK.FPlayESCallBack> callback = | |||
| ArgumentCaptor.forClass(HCNetSDK.FPlayESCallBack.class); | |||
| verify(sdk).NET_DVR_SetPlayBackESCallBack(eq(91), callback.capture(), isNull()); | |||
| byte[] payload = new byte[]{0, 0, 0, 1, 0x65}; | |||
| Memory memory = new Memory(payload.length); | |||
| memory.write(0, payload, 0, payload.length); | |||
| HCNetSDK.NET_DVR_PACKET_INFO_EX packet = new HCNetSDK.NET_DVR_PACKET_INFO_EX(); | |||
| packet.dwPacketType = 1; | |||
| packet.dwPacketSize = payload.length; | |||
| packet.pPacketBuffer = memory; | |||
| packet.dwYear = 2026; | |||
| packet.dwMonth = 7; | |||
| packet.dwDay = 13; | |||
| packet.dwHour = 12; | |||
| packet.dwMinute = 45; | |||
| packet.dwSecond = 12; | |||
| callback.getValue().invoke(91, packet, null); | |||
| assertEquals(LocalDateTime.of(2026, 7, 13, 12, 45, 12), handle.getLastPlaybackTime()); | |||
| } | |||
| @Test | |||
| public void pacesNativePlaybackOutsideEsCallbackWhenSourceTimeRunsAhead() throws Exception { | |||
| HikSdkPlaybackHandle handle = (HikSdkPlaybackHandle) | |||
| gateway.open(request(), start(), end(), frame -> true); | |||
| ArgumentCaptor<HCNetSDK.FPlayESCallBack> callback = | |||
| ArgumentCaptor.forClass(HCNetSDK.FPlayESCallBack.class); | |||
| verify(sdk).NET_DVR_SetPlayBackESCallBack(eq(91), callback.capture(), isNull()); | |||
| byte[] payload = new byte[]{0, 0, 0, 1, 0x65}; | |||
| Memory memory = new Memory(payload.length); | |||
| memory.write(0, payload, 0, payload.length); | |||
| HCNetSDK.NET_DVR_PACKET_INFO_EX packet = new HCNetSDK.NET_DVR_PACKET_INFO_EX(); | |||
| packet.dwPacketType = 1; | |||
| packet.dwPacketSize = payload.length; | |||
| packet.pPacketBuffer = memory; | |||
| setPacketTime(packet, LocalDateTime.of(2026, 7, 13, 12, 0)); | |||
| callback.getValue().invoke(91, packet, null); | |||
| setPacketTime(packet, LocalDateTime.of(2026, 7, 13, 12, 0, 10)); | |||
| callback.getValue().invoke(91, packet, null); | |||
| try { | |||
| Thread.sleep(250L); | |||
| verify(sdk, atLeastOnce()).NET_DVR_PlayBackControl_V40( | |||
| eq(91), eq(HCNetSDK.NET_DVR_PLAYPAUSE), | |||
| isNull(), eq(0), isNull(), any(IntByReference.class)); | |||
| } finally { | |||
| handle.close(); | |||
| } | |||
| } | |||
| private HikNvrPlaybackRequest request() { | |||
| return HikNvrPlaybackRequest.builder() | |||
| .nvrIp("192.168.1.100") | |||
| .sdkPort(8000) | |||
| .rtspPort(554) | |||
| .channel(33) | |||
| .streamType(1) | |||
| .username("admin") | |||
| .password("123456") | |||
| .startTime("2026-07-13 12:00:00") | |||
| .endTime("2026-07-13 13:00:00") | |||
| .build(); | |||
| } | |||
| private LocalDateTime start() { | |||
| return LocalDateTime.of(2026, 7, 13, 12, 0); | |||
| } | |||
| private LocalDateTime end() { | |||
| return LocalDateTime.of(2026, 7, 13, 13, 0); | |||
| } | |||
| private void setPacketTime(HCNetSDK.NET_DVR_PACKET_INFO_EX packet, LocalDateTime time) { | |||
| packet.dwYear = time.getYear(); | |||
| packet.dwMonth = time.getMonthValue(); | |||
| packet.dwDay = time.getDayOfMonth(); | |||
| packet.dwHour = time.getHour(); | |||
| packet.dwMinute = time.getMinute(); | |||
| packet.dwSecond = time.getSecond(); | |||
| packet.dwMillisecond = time.getNano() / 1_000_000; | |||
| } | |||
| } | |||
| @ -1,176 +0,0 @@ | |||
| package com.inspect.nvr.hik.ui; | |||
| import org.junit.Test; | |||
| import org.springframework.util.StreamUtils; | |||
| import java.io.IOException; | |||
| import java.io.InputStream; | |||
| import java.nio.charset.StandardCharsets; | |||
| import static org.junit.Assert.assertFalse; | |||
| import static org.junit.Assert.assertNotNull; | |||
| import static org.junit.Assert.assertTrue; | |||
| /** | |||
| * 校验历史回放静态页面与一分钟录像下载接口之间的关键集成约定。 | |||
| */ | |||
| public class HikNvrPlaybackPageContractTest { | |||
| /** Spring Boot 历史回放页面在测试类路径中的资源位置。 */ | |||
| private static final String PLAYBACK_PAGE_RESOURCE = | |||
| "/static/playback.html"; | |||
| /** Spring Boot 监控首页在测试类路径中的资源位置。 */ | |||
| private static final String MONITOR_PAGE_RESOURCE = | |||
| "/static/index.html"; | |||
| /** | |||
| * 页面必须向用户提供目标时刻输入和一分钟下载按钮。 | |||
| * | |||
| * @throws IOException 静态页面无法读取时抛出 | |||
| */ | |||
| @Test | |||
| public void shouldExposeOneMinuteDownloadControls() throws IOException { | |||
| // 读取最终随JAR发布的历史回放页面,避免只验证未打包的开发文件。 | |||
| String page = readPlaybackPage(); | |||
| // 控件标识和提示文本共同保证用户能够在顶部查询区找到下载入口。 | |||
| assertTrue(page.contains("id=\"clipTime\"")); | |||
| assertTrue(page.contains("id=\"downloadClipBtn\"")); | |||
| assertTrue(page.contains("id=\"downloadClipText\"")); | |||
| assertTrue(page.contains("下载1分钟")); | |||
| assertTrue(page.contains("前30秒和后30秒")); | |||
| // 下载控件必须出现在视频舞台之前,避免再次隐藏到播放器底部。 | |||
| assertTrue(page.indexOf("id=\"clipTime\"") | |||
| < page.indexOf("class=\"stage-card\"")); | |||
| } | |||
| /** | |||
| * 页面下载请求必须复用共用设备树选择,并固定提交前后各三十秒。 | |||
| * | |||
| * @throws IOException 静态页面无法读取时抛出 | |||
| */ | |||
| @Test | |||
| public void shouldReuseSelectionAndSubmitThirtySecondsOnEachSide() | |||
| throws IOException { | |||
| // 读取页面脚本并核对接口地址、设备选择和前后秒数的稳定契约。 | |||
| String page = readPlaybackPage(); | |||
| assertTrue(page.contains( | |||
| "const CLIP_API = '/hik/nvr/playback/clips/download'")); | |||
| assertTrue(page.contains("cameraCode: requestedCameraCode")); | |||
| assertTrue(page.contains( | |||
| "const requestedCameraCode = String(")); | |||
| assertTrue(page.contains( | |||
| "const selectedNvrConfig = selectedNvr()")); | |||
| assertFalse(page.contains("nvrIp: requestedNvrIp")); | |||
| assertFalse(page.contains("channel: requestedChannel")); | |||
| assertTrue(page.contains("beforeSeconds: CLIP_CONTEXT_SECONDS")); | |||
| assertTrue(page.contains("afterSeconds: CLIP_CONTEXT_SECONDS")); | |||
| assertTrue(page.contains("const CLIP_CONTEXT_SECONDS = 30")); | |||
| // 目标输入留空时必须能够使用当前回放时刻,支持边看边下载。 | |||
| assertTrue(page.contains( | |||
| "state.session && state.session.currentTime")); | |||
| } | |||
| /** | |||
| * 回放创建、暂停、恢复和跳转必须共同提交当前完整 cameraCode。 | |||
| * | |||
| * @throws IOException 静态页面无法读取时抛出 | |||
| */ | |||
| @Test | |||
| public void shouldUseCameraCodeForPlaybackSessionAndControls() | |||
| throws IOException { | |||
| // 读取页面脚本,核对创建与控制均绑定同一个页面 cameraCode 状态。 | |||
| String page = readPlaybackPage(); | |||
| assertTrue(page.contains("cameraCode: requestedCameraCode")); | |||
| assertTrue(page.contains( | |||
| "/sessions/${encodeURIComponent(state.session.sessionId)}/controls")); | |||
| assertTrue(page.contains("cameraCode: state.selectedCameraCode")); | |||
| assertTrue(page.contains("cameraCode: boundCameraCode")); | |||
| assertTrue(page.contains("action: 'STOP'")); | |||
| assertTrue(page.contains("url.searchParams.set('cameraCode'")); | |||
| assertFalse(page.contains("url.searchParams.set('nvrIp'")); | |||
| assertFalse(page.contains("url.searchParams.set('channel'")); | |||
| } | |||
| /** | |||
| * 监控首页的实时、云台和抓图必须携带 cameraCode 并复用现有 IVS 地址。 | |||
| * | |||
| * @throws IOException 静态页面无法读取时抛出 | |||
| */ | |||
| @Test | |||
| public void shouldRouteLivePtzAndSnapshotByCameraCodeThroughIvsContracts() | |||
| throws IOException { | |||
| // 读取最终首页,确认设备树标识和现有 IVS 能力地址已连通。 | |||
| String page = readResource(MONITOR_PAGE_RESOURCE, "监控首页必须存在"); | |||
| assertTrue(page.contains("data-camera-code")); | |||
| assertTrue(page.contains("cameraCode: targetCameraCode")); | |||
| assertTrue(page.contains("const IVS_PTZ_API = '/device/ptzcontrol'")); | |||
| assertTrue(page.contains( | |||
| "const IVS_PRESET_LIST_API = '/device/ptzpresetlist'")); | |||
| assertTrue(page.contains( | |||
| "const IVS_SNAPSHOT_API = '/platform/platformSnapshot'")); | |||
| assertTrue(page.contains("cameraCode,")); | |||
| assertTrue(page.contains("data.ptzPresetInfoList.ptzPresetInfo")); | |||
| assertTrue(page.contains("item.presetIndex")); | |||
| assertTrue(page.contains("item.presetName")); | |||
| assertFalse(page.contains("API + '/actions/ptz'")); | |||
| assertFalse(page.contains("API + '/actions/snapshot'")); | |||
| assertFalse(page.contains("API + '/actions/presets?nvrIp='")); | |||
| } | |||
| /** | |||
| * 页面必须用弹窗提示未来时间、无录像和录像不完整等下载错误。 | |||
| * | |||
| * @throws IOException 静态页面无法读取时抛出 | |||
| */ | |||
| @Test | |||
| public void shouldShowDialogForFutureOrMissingRecordErrors() | |||
| throws IOException { | |||
| // 读取页面并校验错误弹窗结构和未来时间拦截逻辑。 | |||
| String page = readPlaybackPage(); | |||
| assertTrue(page.contains("id=\"clipErrorBackdrop\"")); | |||
| assertTrue(page.contains("role=\"alertdialog\"")); | |||
| assertTrue(page.contains("function showClipError(")); | |||
| assertTrue(page.contains("function isFutureClipCenter(")); | |||
| assertTrue(page.contains("目标时间尚未到达")); | |||
| assertTrue(page.contains("目标时间没有录像")); | |||
| assertTrue(page.contains("目标时间录像不完整")); | |||
| } | |||
| /** | |||
| * 以UTF-8读取历史回放页面,确保中文提示不会在测试过程中发生编码替换。 | |||
| * | |||
| * @return 完整HTML文本 | |||
| * @throws IOException 页面资源读取失败时抛出 | |||
| */ | |||
| private String readPlaybackPage() throws IOException { | |||
| // 使用统一资源读取方法并保留历史回放页面的明确缺失提示。 | |||
| return readResource( | |||
| PLAYBACK_PAGE_RESOURCE, "历史回放页面必须存在"); | |||
| } | |||
| /** | |||
| * 以 UTF-8 读取一个随 JAR 发布的静态页面资源。 | |||
| * | |||
| * @param resourcePath 类路径资源位置 | |||
| * @param missingMessage 资源缺失时的断言提示 | |||
| * @return 完整 HTML 文本 | |||
| * @throws IOException 页面资源读取失败时抛出 | |||
| */ | |||
| private String readResource( | |||
| String resourcePath, String missingMessage) throws IOException { | |||
| // 从类加载器取得 Maven 已复制到 target/classes 的静态资源。 | |||
| InputStream inputStream = getClass().getResourceAsStream(resourcePath); | |||
| // 页面未进入类路径时立即失败,避免后续断言给出误导性的空指针错误。 | |||
| assertNotNull(missingMessage, inputStream); | |||
| try (InputStream closableInputStream = inputStream) { | |||
| // 严格按项目统一的 UTF-8 读取页面内容供契约断言使用。 | |||
| return StreamUtils.copyToString( | |||
| closableInputStream, StandardCharsets.UTF_8); | |||
| } | |||
| } | |||
| } | |||
| @ -1,103 +0,0 @@ | |||
| package com.inspect.nvr.ivs; | |||
| import com.inspect.nvr.ivs.mapper.IvsCameraMapper; | |||
| import com.inspect.nvr.ivs.mapper.IvsNvrMapper; | |||
| import com.inspect.nvr.ivs.pojo.IvsCamera; | |||
| import com.inspect.nvr.ivs.pojo.IvsNvr; | |||
| import com.inspect.nvr.ivs.service.IvsCameraSyncService; | |||
| import com.inspect.nvr.ivs.util.IvsNvrChannelClient; | |||
| import com.inspect.nvr.ivs.vo.IvsCameraSyncResult; | |||
| import org.junit.Test; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.util.Collections; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertNotNull; | |||
| import static org.junit.Assert.assertTrue; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.ArgumentMatchers.eq; | |||
| import static org.mockito.Mockito.mock; | |||
| import static org.mockito.Mockito.never; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| public class IvsCameraSyncServiceTest { | |||
| @Test | |||
| public void syncUsesNvrCodeSuffixAndThreeDigitChannelCode() { | |||
| IvsNvrMapper nvrMapper = mock(IvsNvrMapper.class); | |||
| IvsCameraMapper cameraMapper = mock(IvsCameraMapper.class); | |||
| IvsNvrChannelClient channelClient = mock(IvsNvrChannelClient.class); | |||
| IvsNvr nvr = nvr("05199871611126810#d4b895b7110b4e6ca1dbdf30ed41826f"); | |||
| when(nvrMapper.selectAll()).thenReturn(Collections.singletonList(nvr)); | |||
| when(channelClient.listChannels(nvr)).thenReturn(Collections.singletonList( | |||
| new IvsNvrChannelClient.Channel(1, "Camera 01", "HIKVISION", | |||
| "192.168.1.244", 8000, 1, true))); | |||
| IvsCameraSyncService service = new IvsCameraSyncService(); | |||
| ReflectionTestUtils.setField(service, "ivsNvrMapper", nvrMapper); | |||
| ReflectionTestUtils.setField(service, "ivsCameraMapper", cameraMapper); | |||
| ReflectionTestUtils.setField(service, "channelClient", channelClient); | |||
| IvsCameraSyncResult result = service.syncAll(); | |||
| assertEquals(1, result.getSuccessNvrCount()); | |||
| assertEquals(1, result.getCameraCount()); | |||
| assertTrue(result.getErrors().isEmpty()); | |||
| org.mockito.ArgumentCaptor<IvsCamera> captor = org.mockito.ArgumentCaptor.forClass(IvsCamera.class); | |||
| verify(cameraMapper).upsert(captor.capture()); | |||
| IvsCamera camera = captor.getValue(); | |||
| assertEquals("05199871611126810001#d4b895b7110b4e6ca1dbdf30ed41826f", camera.getCode()); | |||
| assertEquals("05199871611126810000", camera.getParentCode()); | |||
| assertEquals("0#d4b895b7110b4e6ca1dbdf30ed41826f", camera.getDevGroupCode()); | |||
| assertEquals("d4b895b7110b4e6ca1dbdf30ed41826f", camera.getDomainCode()); | |||
| assertEquals(Integer.valueOf(1), camera.getCameraStatus()); | |||
| // 同步发现的实际通道数必须回填 NVR 台账,供大华整机录像状态数组使用。 | |||
| verify(nvrMapper).updateChannelCount(7L, 1); | |||
| assertEquals(Integer.valueOf(1), nvr.getChannelCount()); | |||
| verify(nvrMapper, never()).updateNvrCodeIfAbsent(any(Long.class), any(String.class)); | |||
| } | |||
| @Test | |||
| public void syncGeneratesAndPersistsMissingNvrCode() { | |||
| IvsNvrMapper nvrMapper = mock(IvsNvrMapper.class); | |||
| IvsCameraMapper cameraMapper = mock(IvsCameraMapper.class); | |||
| IvsNvrChannelClient channelClient = mock(IvsNvrChannelClient.class); | |||
| IvsNvr nvr = nvr(null); | |||
| when(nvrMapper.selectAll()).thenReturn(Collections.singletonList(nvr)); | |||
| when(nvrMapper.updateNvrCodeIfAbsent(eq(7L), any(String.class))).thenReturn(1); | |||
| when(channelClient.listChannels(any(IvsNvr.class))).thenReturn(Collections.singletonList( | |||
| new IvsNvrChannelClient.Channel(12, null, "HIKVISION", "10.0.0.12", | |||
| 8000, 1, false))); | |||
| IvsCameraSyncService service = new IvsCameraSyncService(); | |||
| ReflectionTestUtils.setField(service, "ivsNvrMapper", nvrMapper); | |||
| ReflectionTestUtils.setField(service, "ivsCameraMapper", cameraMapper); | |||
| ReflectionTestUtils.setField(service, "channelClient", channelClient); | |||
| IvsCameraSyncResult result = service.syncAll(); | |||
| assertEquals(1, result.getSuccessNvrCount()); | |||
| assertEquals(1, result.getCameraCount()); | |||
| assertNotNull(nvr.getNvrCode()); | |||
| assertTrue(nvr.getNvrCode().matches("\\d{17}#[0-9a-f]{32}")); | |||
| org.mockito.ArgumentCaptor<IvsCamera> captor = org.mockito.ArgumentCaptor.forClass(IvsCamera.class); | |||
| verify(cameraMapper).upsert(captor.capture()); | |||
| assertTrue(captor.getValue().getCode().matches("\\d{17}012#[0-9a-f]{32}")); | |||
| assertEquals(Integer.valueOf(0), captor.getValue().getStatus()); | |||
| } | |||
| private IvsNvr nvr(String nvrCode) { | |||
| IvsNvr nvr = new IvsNvr(); | |||
| nvr.setNvrId(7L); | |||
| nvr.setNvrIp("192.168.1.249"); | |||
| nvr.setHttpPort(80); | |||
| nvr.setUsername("admin"); | |||
| nvr.setPassword("password"); | |||
| nvr.setVendor("HIKVISION"); | |||
| nvr.setNvrCode(nvrCode); | |||
| return nvr; | |||
| } | |||
| } | |||
| @ -1,88 +0,0 @@ | |||
| package com.inspect.nvr.ivs; | |||
| import com.inspect.nvr.domain.ivs.IvsChanSnapVo; | |||
| import com.inspect.nvr.domain.ivs.IvsDevChanSnapVo; | |||
| import com.inspect.nvr.domain.ivs.IvsSnapshotCommandResponse; | |||
| import com.inspect.nvr.ivs.mapper.IvsNvrMapper; | |||
| import com.inspect.nvr.ivs.pojo.IvsNvr; | |||
| import com.inspect.nvr.ivs.service.IvsOpenApiService; | |||
| import com.inspect.nvr.ivs.service.IvsVendorAdapter; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.util.Arrays; | |||
| import java.util.Collections; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.Mockito.never; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| /** 验证混合厂商部署中的任务和句柄路由。 */ | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class IvsOpenApiServiceTest { | |||
| /** 模拟 NVR 台账查询。 */ | |||
| @Mock | |||
| private IvsNvrMapper nvrMapper; | |||
| /** 模拟海康厂商适配器。 */ | |||
| @Mock | |||
| private IvsVendorAdapter hikAdapter; | |||
| /** 模拟大华厂商适配器。 */ | |||
| @Mock | |||
| private IvsVendorAdapter dahuaAdapter; | |||
| /** 待验证的统一 IVS 门面。 */ | |||
| private IvsOpenApiService service; | |||
| /** 注册海康和大华适配器,并配置一个大华 NVR 域。 */ | |||
| @Before | |||
| public void setUp() { | |||
| when(hikAdapter.vendorCode()).thenReturn("HIKVISION"); | |||
| when(dahuaAdapter.vendorCode()).thenReturn("DAHUA"); | |||
| IvsNvr nvr = new IvsNvr(); | |||
| nvr.setVendor("DAHUA"); | |||
| nvr.setNvrCode("32078710632508667#domain-a"); | |||
| when(nvrMapper.selectAll()).thenReturn(Collections.singletonList(nvr)); | |||
| service = new IvsOpenApiService(); | |||
| ReflectionTestUtils.setField(service, "nvrMapper", nvrMapper); | |||
| ReflectionTestUtils.setField( | |||
| service, "vendorAdapters", Arrays.asList(hikAdapter, dahuaAdapter)); | |||
| service.initialize(); | |||
| } | |||
| /** 抓图创建后只传 taskID 查询列表仍必须命中原大华适配器。 */ | |||
| @Test | |||
| public void routesSnapshotListByRememberedTaskVendor() { | |||
| IvsDevChanSnapVo create = IvsDevChanSnapVo.builder() | |||
| .cameraCode("32078710632508667003") | |||
| .domainCode("domain-a") | |||
| .build(); | |||
| when(dahuaAdapter.platformSnapshot(create)).thenReturn( | |||
| IvsSnapshotCommandResponse.builder().resultCode(0).taskID("task-dh").build()); | |||
| service.platformSnapshot(create); | |||
| IvsChanSnapVo query = IvsChanSnapVo.builder().taskID("task-dh").build(); | |||
| service.snapshotList(query); | |||
| verify(dahuaAdapter).snapshotList(query); | |||
| verify(hikAdapter, never()).snapshotList(any(IvsChanSnapVo.class)); | |||
| } | |||
| /** 携带大华 cameraCode 的停止请求不能再把原生句柄交给海康 SDK。 */ | |||
| @Test | |||
| public void routesPlaybackStopByCameraVendor() { | |||
| when(dahuaAdapter.stopPlatformPlayback(99L)).thenReturn(0); | |||
| int result = service.stopPlatformPlayback( | |||
| 99L, "32078710632508667003", "domain-a"); | |||
| assertEquals(0, result); | |||
| verify(dahuaAdapter).stopPlatformPlayback(99L); | |||
| verify(hikAdapter, never()).stopPlatformPlayback(99L); | |||
| } | |||
| } | |||
| @ -1,77 +0,0 @@ | |||
| package com.inspect.nvr.ivs.util; | |||
| import com.inspect.nvr.dahua.DahuaNvrChannelClient; | |||
| import com.inspect.nvr.ivs.pojo.IvsNvr; | |||
| import org.junit.Test; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import java.nio.charset.Charset; | |||
| import java.nio.charset.StandardCharsets; | |||
| import java.util.Collections; | |||
| import java.util.List; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.junit.Assert.assertSame; | |||
| import static org.mockito.Mockito.mock; | |||
| import static org.mockito.Mockito.verify; | |||
| import static org.mockito.Mockito.when; | |||
| /** | |||
| * 验证 NVR ISAPI 响应的 UTF-8 解码规则,防止自动同步写入乱码摄像机名称。 | |||
| */ | |||
| public class IvsNvrChannelClientTest { | |||
| /** | |||
| * vendor 为 Dahua 时必须转交大华 NetSDK 客户端,不能再执行海康 ISAPI 请求。 | |||
| */ | |||
| @Test | |||
| public void routesDahuaVendorToDahuaClient() { | |||
| // 构造大华台账记录和预期通道,模拟同步接口从数据库读取 vendor=Dahua。 | |||
| IvsNvr nvr = new IvsNvr(); | |||
| nvr.setVendor("Dahua"); | |||
| IvsNvrChannelClient.Channel expected = new IvsNvrChannelClient.Channel( | |||
| 1, "大华通道", "DAHUA", "192.168.1.20", 37777, 1, true); | |||
| DahuaNvrChannelClient dahuaClient = mock(DahuaNvrChannelClient.class); | |||
| when(dahuaClient.listChannels(nvr)).thenReturn(Collections.singletonList(expected)); | |||
| // 注入大华实现后调用统一入口,验证厂商路由不改变同步服务的调用方式。 | |||
| IvsNvrChannelClient client = new IvsNvrChannelClient(); | |||
| ReflectionTestUtils.setField(client, "dahuaChannelClient", dahuaClient); | |||
| List<IvsNvrChannelClient.Channel> channels = client.listChannels(nvr); | |||
| // 统一入口应原样返回大华实现的结果,且实际调用一次 NetSDK 客户端。 | |||
| assertEquals(1, channels.size()); | |||
| assertSame(expected, channels.get(0)); | |||
| verify(dahuaClient).listChannels(nvr); | |||
| } | |||
| /** | |||
| * UTF-8 中文名称必须保持原文,不能再按“汉字数量”误选 GBK 解码结果。 | |||
| */ | |||
| @Test | |||
| public void decodeKeepsUtf8CameraName() { | |||
| // 构造包含现场常见中文摄像机名称的 UTF-8 XML 响应。 | |||
| String xml = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>" | |||
| + "<InputProxyChannel><name>大门入口摄像机</name></InputProxyChannel>"; | |||
| // 调用实际响应解码方法,验证传给 XML 解析器的文本没有发生二次转码。 | |||
| String decoded = ReflectionTestUtils.invokeMethod( | |||
| new IvsNvrChannelClient(), "decode", xml.getBytes(StandardCharsets.UTF_8)); | |||
| // 完整 XML 应逐字保持一致,从而保证摄像机名称原样写入 ivs_camera.name。 | |||
| assertEquals(xml, decoded); | |||
| } | |||
| /** | |||
| * 非 UTF-8 字节必须明确失败,避免使用替换字符或错误编码污染摄像机名称。 | |||
| */ | |||
| @Test(expected = IllegalStateException.class) | |||
| public void decodeRejectsNonUtf8Response() { | |||
| // 使用 GBK 构造非法 UTF-8 响应,模拟设备或链路违反统一编码约定。 | |||
| byte[] bytes = "<name>大门入口摄像机</name>".getBytes(Charset.forName("GBK")); | |||
| // 严格解码应抛出异常,由同步任务记录具体 NVR,而不是继续保存乱码。 | |||
| ReflectionTestUtils.invokeMethod(new IvsNvrChannelClient(), "decode", bytes); | |||
| } | |||
| } | |||
| @ -1,61 +0,0 @@ | |||
| package com.inspect.nvr.service; | |||
| import com.inspect.nvr.domain.Infrared.NvrInfo; | |||
| import com.inspect.nvr.hikVision.utils.jna.HCNetSDK; | |||
| import org.junit.Before; | |||
| import org.junit.Test; | |||
| import org.junit.runner.RunWith; | |||
| import org.mockito.Mock; | |||
| import org.mockito.junit.MockitoJUnitRunner; | |||
| import org.springframework.test.util.ReflectionTestUtils; | |||
| import static org.junit.Assert.assertEquals; | |||
| import static org.mockito.ArgumentMatchers.any; | |||
| import static org.mockito.Mockito.when; | |||
| @RunWith(MockitoJUnitRunner.class) | |||
| public class HikLoginServiceTest { | |||
| @Mock | |||
| private HCNetSDK hcNetSDK; | |||
| private HikLoginService service; | |||
| @Before | |||
| public void setUp() { | |||
| service = new HikLoginService(); | |||
| ReflectionTestUtils.setField(service, "hcNetSDK", hcNetSDK); | |||
| } | |||
| @Test | |||
| public void capturesDeviceChannelLayoutInLoginSession() { | |||
| when(hcNetSDK.NET_DVR_Login_V40( | |||
| any(HCNetSDK.NET_DVR_USER_LOGIN_INFO.class), | |||
| any(HCNetSDK.NET_DVR_DEVICEINFO_V40.class))) | |||
| .thenAnswer(invocation -> { | |||
| HCNetSDK.NET_DVR_DEVICEINFO_V40 deviceInfo = invocation.getArgument(1); | |||
| deviceInfo.struDeviceV30.byChanNum = 0; | |||
| deviceInfo.struDeviceV30.byStartChan = 1; | |||
| deviceInfo.struDeviceV30.byIPChanNum = 8; | |||
| deviceInfo.struDeviceV30.byHighDChanNum = 0; | |||
| deviceInfo.struDeviceV30.byStartDChan = 33; | |||
| deviceInfo.write(); | |||
| return 7; | |||
| }); | |||
| HikLoginSession session = service.loginSession(nvrInfo()); | |||
| assertEquals(7, session.getUserId()); | |||
| assertEquals(1, session.toRtspChannel(33)); | |||
| assertEquals(2, session.toRtspChannel(34)); | |||
| } | |||
| private NvrInfo nvrInfo() { | |||
| NvrInfo nvrInfo = new NvrInfo(); | |||
| nvrInfo.setNvrIp("192.168.1.249"); | |||
| nvrInfo.setServerPort(8000); | |||
| nvrInfo.setAccount("admin"); | |||
| nvrInfo.setPassword("secret"); | |||
| return nvrInfo; | |||
| } | |||
| } | |||
| @ -1,48 +0,0 @@ | |||
| package com.inspect.nvr.service; | |||
| import org.junit.Test; | |||
| import static org.junit.Assert.assertEquals; | |||
| public class HikLoginSessionTest { | |||
| @Test | |||
| public void mapsPureNvrDigitalChannelsToOneBasedRtspChannels() { | |||
| HikLoginSession session = new HikLoginSession(7, 0, 1, 8, 33); | |||
| assertEquals(1, session.toRtspChannel(33)); | |||
| assertEquals(2, session.toRtspChannel(34)); | |||
| } | |||
| @Test | |||
| public void placesHybridDigitalChannelsAfterAnalogChannels() { | |||
| HikLoginSession session = new HikLoginSession(7, 16, 1, 8, 33); | |||
| assertEquals(17, session.toRtspChannel(33)); | |||
| } | |||
| @Test | |||
| public void normalizesAnalogSdkChannelsToOneBasedRtspChannels() { | |||
| HikLoginSession session = new HikLoginSession(7, 4, 5, 0, 0); | |||
| assertEquals(1, session.toRtspChannel(5)); | |||
| assertEquals(4, session.toRtspChannel(8)); | |||
| } | |||
| @Test | |||
| public void mapsLogicalChannelsToSdkChannelsWhileKeepingExplicitSdkChannels() { | |||
| HikLoginSession session = new HikLoginSession(7, 0, 1, 8, 33); | |||
| assertEquals(33, session.toSdkChannel(1)); | |||
| assertEquals(40, session.toSdkChannel(8)); | |||
| assertEquals(33, session.toSdkChannel(33)); | |||
| } | |||
| @Test | |||
| public void keepsOriginalChannelWhenDeviceLayoutDoesNotCoverIt() { | |||
| HikLoginSession session = new HikLoginSession(7, 0, 0, 8, 33); | |||
| assertEquals(99, session.toRtspChannel(99)); | |||
| } | |||
| } | |||