diff --git a/src/main/java/com/genersoft/iot/vmp/common/enums/ChannelDataType.java b/src/main/java/com/genersoft/iot/vmp/common/enums/ChannelDataType.java index 06bf7e40b..71cd2123a 100644 --- a/src/main/java/com/genersoft/iot/vmp/common/enums/ChannelDataType.java +++ b/src/main/java/com/genersoft/iot/vmp/common/enums/ChannelDataType.java @@ -16,6 +16,7 @@ public class ChannelDataType { public final static String DOWNLOAD_SERVICE = "sourceChannelDownloadService"; public final static String PTZ_SERVICE = "sourceChannelPTZService"; public final static String OTHER_SERVICE = "sourceChannelOtherService"; + public final static String BROADCAST_SERVICE = "sourceChannelBroadcastService"; public static String getDateTypeDesc(Integer dataType) { diff --git a/src/main/java/com/genersoft/iot/vmp/gb28181/controller/ChannelController.java b/src/main/java/com/genersoft/iot/vmp/gb28181/controller/ChannelController.java index 647a679e2..3cbe2fbc4 100755 --- a/src/main/java/com/genersoft/iot/vmp/gb28181/controller/ChannelController.java +++ b/src/main/java/com/genersoft/iot/vmp/gb28181/controller/ChannelController.java @@ -12,6 +12,7 @@ import com.genersoft.iot.vmp.gb28181.utils.VectorTileCatch; import com.genersoft.iot.vmp.service.bean.ErrorCallback; import com.genersoft.iot.vmp.service.bean.InviteErrorCode; import com.genersoft.iot.vmp.utils.DateUtil; +import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult; import com.genersoft.iot.vmp.vmanager.bean.ErrorCode; import com.genersoft.iot.vmp.vmanager.bean.StreamContent; import com.genersoft.iot.vmp.vmanager.bean.WVPResult; @@ -352,6 +353,42 @@ public class ChannelController { channelPlayService.stopPlay(channel); } + @Operation(summary = "开始对讲", security = @SecurityRequirement(name = JwtUtils.HEADER)) + @GetMapping("/talk/start") + public AudioTalkResult startTalk(Integer channelId){ + Assert.notNull(channelId,"参数异常"); + CommonGBChannel channel = channelService.getOne(channelId); + Assert.notNull(channel, "通道不存在"); + return channelPlayService.startTalk(channel); + } + + @Operation(summary = "停止对讲", security = @SecurityRequirement(name = JwtUtils.HEADER)) + @GetMapping("/talk/stop") + public void stopTalk(Integer channelId){ + Assert.notNull(channelId,"参数异常"); + CommonGBChannel channel = channelService.getOne(channelId); + Assert.notNull(channel, "通道不存在"); + channelPlayService.stopTalk(channel); + } + + @Operation(summary = "开始喊话", security = @SecurityRequirement(name = JwtUtils.HEADER)) + @GetMapping("/broadcast/start") + public AudioTalkResult startBroadcast(Integer channelId){ + Assert.notNull(channelId,"参数异常"); + CommonGBChannel channel = channelService.getOne(channelId); + Assert.notNull(channel, "通道不存在"); + return channelPlayService.startBroadcast(channel); + } + + @Operation(summary = "停止喊话", security = @SecurityRequirement(name = JwtUtils.HEADER)) + @GetMapping("/broadcast/stop") + public void stopBroadcast(Integer channelId){ + Assert.notNull(channelId,"参数异常"); + CommonGBChannel channel = channelService.getOne(channelId); + Assert.notNull(channel, "通道不存在"); + channelPlayService.stopBroadcast(channel); + } + @Operation(summary = "录像查询", security = @SecurityRequirement(name = JwtUtils.HEADER)) @Parameter(name = "channelId", description = "通道ID", required = true) @Parameter(name = "startTime", description = "开始时间", required = true) diff --git a/src/main/java/com/genersoft/iot/vmp/gb28181/service/IGbChannelPlayService.java b/src/main/java/com/genersoft/iot/vmp/gb28181/service/IGbChannelPlayService.java index 3ffade223..233b93d24 100644 --- a/src/main/java/com/genersoft/iot/vmp/gb28181/service/IGbChannelPlayService.java +++ b/src/main/java/com/genersoft/iot/vmp/gb28181/service/IGbChannelPlayService.java @@ -7,6 +7,7 @@ import com.genersoft.iot.vmp.gb28181.bean.CommonRecordInfo; import com.genersoft.iot.vmp.gb28181.bean.InviteMessageInfo; import com.genersoft.iot.vmp.gb28181.bean.Platform; import com.genersoft.iot.vmp.service.bean.ErrorCallback; +import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult; import java.util.List; @@ -41,4 +42,12 @@ public interface IGbChannelPlayService { void getSnap(CommonGBChannel channel, ErrorCallback callback); + + AudioTalkResult startTalk(CommonGBChannel channel); + + void stopTalk(CommonGBChannel channel); + + AudioTalkResult startBroadcast(CommonGBChannel channel); + + void stopBroadcast(CommonGBChannel channel); } diff --git a/src/main/java/com/genersoft/iot/vmp/gb28181/service/ISourceBroadcastService.java b/src/main/java/com/genersoft/iot/vmp/gb28181/service/ISourceBroadcastService.java new file mode 100644 index 000000000..ed1a16a5e --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/gb28181/service/ISourceBroadcastService.java @@ -0,0 +1,18 @@ +package com.genersoft.iot.vmp.gb28181.service; + +import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel; +import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult; + +/** + * 资源能力接入-语音对讲 + */ +public interface ISourceBroadcastService { + + AudioTalkResult startTalk(CommonGBChannel channel); + + void stopTalk(CommonGBChannel channel); + + AudioTalkResult startBroadcast(CommonGBChannel channel); + + void stopBroadcast(CommonGBChannel channel); +} diff --git a/src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/GbChannelPlayServiceImpl.java b/src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/GbChannelPlayServiceImpl.java index 8e887dbf1..568968df0 100644 --- a/src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/GbChannelPlayServiceImpl.java +++ b/src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/GbChannelPlayServiceImpl.java @@ -7,9 +7,11 @@ import com.genersoft.iot.vmp.conf.UserSetting; import com.genersoft.iot.vmp.gb28181.bean.*; import com.genersoft.iot.vmp.gb28181.dao.CommonGBChannelMapper; import com.genersoft.iot.vmp.gb28181.service.IGbChannelPlayService; +import com.genersoft.iot.vmp.gb28181.service.ISourceBroadcastService; import com.genersoft.iot.vmp.gb28181.service.ISourceDownloadService; import com.genersoft.iot.vmp.gb28181.service.ISourcePlayService; import com.genersoft.iot.vmp.gb28181.service.ISourcePlaybackService; +import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult; import com.genersoft.iot.vmp.service.bean.ErrorCallback; import com.genersoft.iot.vmp.service.bean.InviteErrorCode; import lombok.extern.slf4j.Slf4j; @@ -39,6 +41,9 @@ public class GbChannelPlayServiceImpl implements IGbChannelPlayService { @Autowired private Map sourceDownloadServiceMap; + @Autowired + private Map sourceBroadcastServiceMap; + @Override public void startInvite(CommonGBChannel channel, InviteMessageInfo inviteInfo, Platform platform, ErrorCallback callback) { @@ -247,4 +252,52 @@ public class GbChannelPlayServiceImpl implements IGbChannelPlayService { } sourceChannelPlayService.getSnap(channel, callback); } + + @Override + public AudioTalkResult startTalk(CommonGBChannel channel) { + log.info("[通用通道] 开始对讲, 类型: {}, 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId()); + Integer dataType = channel.getDataType(); + ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType); + if (broadcastService == null) { + log.error("[通用通道] 类型编号: {} 不支持对讲", dataType); + throw new PlayException(Response.BUSY_HERE, "channel not support"); + } + return broadcastService.startTalk(channel); + } + + @Override + public void stopTalk(CommonGBChannel channel) { + log.info("[通用通道] 停止对讲, 类型: {}, 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId()); + Integer dataType = channel.getDataType(); + ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType); + if (broadcastService == null) { + log.error("[通用通道] 类型编号: {} 不支持对讲", dataType); + throw new PlayException(Response.BUSY_HERE, "channel not support"); + } + broadcastService.stopTalk(channel); + } + + @Override + public AudioTalkResult startBroadcast(CommonGBChannel channel) { + log.info("[通用通道] 开始喊话, 类型: {}, 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId()); + Integer dataType = channel.getDataType(); + ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType); + if (broadcastService == null) { + log.error("[通用通道] 类型编号: {} 不支持喊话", dataType); + throw new PlayException(Response.BUSY_HERE, "channel not support"); + } + return broadcastService.startBroadcast(channel); + } + + @Override + public void stopBroadcast(CommonGBChannel channel) { + log.info("[通用通道] 停止喊话, 类型: {}, 编号:{}", ChannelDataType.getDateTypeDesc(channel.getDataType()), channel.getGbDeviceId()); + Integer dataType = channel.getDataType(); + ISourceBroadcastService broadcastService = sourceBroadcastServiceMap.get(ChannelDataType.BROADCAST_SERVICE + dataType); + if (broadcastService == null) { + log.error("[通用通道] 类型编号: {} 不支持喊话", dataType); + throw new PlayException(Response.BUSY_HERE, "channel not support"); + } + broadcastService.stopBroadcast(channel); + } } diff --git a/src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/SourceBroadcastServiceForGbImpl.java b/src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/SourceBroadcastServiceForGbImpl.java new file mode 100644 index 000000000..32990c89a --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/gb28181/service/impl/SourceBroadcastServiceForGbImpl.java @@ -0,0 +1,101 @@ +package com.genersoft.iot.vmp.gb28181.service.impl; + +import com.genersoft.iot.vmp.common.enums.ChannelDataType; +import com.genersoft.iot.vmp.common.enums.MediaStreamUtil; +import com.genersoft.iot.vmp.conf.UserSetting; +import com.genersoft.iot.vmp.conf.exception.ControllerException; +import com.genersoft.iot.vmp.gb28181.bean.CommonGBChannel; +import com.genersoft.iot.vmp.gb28181.bean.Device; +import com.genersoft.iot.vmp.gb28181.bean.DeviceChannel; +import com.genersoft.iot.vmp.gb28181.service.IDeviceChannelService; +import com.genersoft.iot.vmp.gb28181.service.IDeviceService; +import com.genersoft.iot.vmp.gb28181.service.IPlayService; +import com.genersoft.iot.vmp.gb28181.service.ISourceBroadcastService; +import com.genersoft.iot.vmp.media.bean.MediaServer; +import com.genersoft.iot.vmp.media.service.IMediaServerService; +import com.genersoft.iot.vmp.vmanager.bean.AudioBroadcastResult; +import com.genersoft.iot.vmp.vmanager.bean.AudioTalkResult; +import com.genersoft.iot.vmp.vmanager.bean.ErrorCode; +import com.genersoft.iot.vmp.vmanager.bean.StreamContent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Slf4j +@Service(ChannelDataType.BROADCAST_SERVICE + ChannelDataType.GB28181) +public class SourceBroadcastServiceForGbImpl implements ISourceBroadcastService { + + @Autowired + private IPlayService playService; + + @Autowired + private IDeviceService deviceService; + + @Autowired + private IDeviceChannelService deviceChannelService; + + @Autowired + private IMediaServerService mediaServerService; + + @Autowired + private UserSetting userSetting; + + @Override + public AudioTalkResult startBroadcast(CommonGBChannel channel) { + Device device = deviceService.getDevice(channel.getDataDeviceId()); + if (device == null) { + throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到设备"); + } + DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId()); + if (deviceChannel == null) { + throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到通道"); + } + AudioBroadcastResult abResult = playService.audioBroadcast( + device.getDeviceId(), deviceChannel.getDeviceId(), true); + AudioTalkResult result = new AudioTalkResult(); + result.setPushStream(abResult.getStreamInfo()); + result.setPlayStream(null); + return result; + } + + @Override + public void stopBroadcast(CommonGBChannel channel) { + Device device = deviceService.getDevice(channel.getDataDeviceId()); + if (device == null) return; + DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId()); + if (deviceChannel == null) return; + playService.stopAudioBroadcast(device, deviceChannel); + } + + @Override + public AudioTalkResult startTalk(CommonGBChannel channel) { + Device device = deviceService.getDevice(channel.getDataDeviceId()); + if (device == null) { + throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到设备"); + } + DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId()); + if (deviceChannel == null) { + throw new ControllerException(ErrorCode.ERROR400.getCode(), "未找到通道"); + } + AudioBroadcastResult abResult = playService.audioBroadcast( + device.getDeviceId(), deviceChannel.getDeviceId(), false); + MediaServer mediaServer = mediaServerService.getMediaServerForMinimumLoad(null); + StreamContent playStream = new StreamContent( + mediaServerService.getStreamInfoByAppAndStream(mediaServer, + MediaStreamUtil.GB28181_TALK, abResult.getStream() + "_talk", + null, null, null, false)); + AudioTalkResult result = new AudioTalkResult(); + result.setPushStream(abResult.getStreamInfo()); + result.setPlayStream(playStream); + return result; + } + + @Override + public void stopTalk(CommonGBChannel channel) { + Device device = deviceService.getDevice(channel.getDataDeviceId()); + if (device == null) return; + DeviceChannel deviceChannel = deviceChannelService.getOneForSourceById(channel.getGbId()); + if (deviceChannel == null) return; + playService.stopTalk(device, deviceChannel, null); + } +} diff --git a/src/main/java/com/genersoft/iot/vmp/vmanager/bean/AudioTalkResult.java b/src/main/java/com/genersoft/iot/vmp/vmanager/bean/AudioTalkResult.java new file mode 100644 index 000000000..57f1e7097 --- /dev/null +++ b/src/main/java/com/genersoft/iot/vmp/vmanager/bean/AudioTalkResult.java @@ -0,0 +1,15 @@ +package com.genersoft.iot.vmp.vmanager.bean; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +@Data +@Schema(description = "对讲信息") +public class AudioTalkResult { + + @Schema(description = "推流地址(浏览器 WebRTC推流到ZLM)") + private StreamContent pushStream; + + @Schema(description = "播放地址(设备音频通过ZLM播放给浏览器),喊话时为null") + private StreamContent playStream; +} diff --git a/src/test/java/com/genersoft/iot/vmp/gb28181/bean/DeviceAlarmNotifyTest.java b/src/test/java/com/genersoft/iot/vmp/gb28181/bean/DeviceAlarmNotifyTest.java new file mode 100644 index 000000000..1c1ac8365 --- /dev/null +++ b/src/test/java/com/genersoft/iot/vmp/gb28181/bean/DeviceAlarmNotifyTest.java @@ -0,0 +1,100 @@ +package com.genersoft.iot.vmp.gb28181.bean; + +import org.dom4j.DocumentHelper; +import org.dom4j.Element; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class DeviceAlarmNotifyTest { + + @Test + void fromXml_withoutAlarmType_shouldNotThrowNpe() throws Exception { + String xml = """ + + + 55123456781381000010 + 1 + 7 + 2026-06-05T09:46:05 + 1001,1780623964994529058,55123456781381000010,25123456781381000050,55LCPCweb10 + 0.0 + 0.0 + + """; + + Element root = DocumentHelper.parseText(xml).getRootElement(); + DeviceAlarmNotify notify = DeviceAlarmNotify.fromXml(root); + + assertNotNull(notify); + assertEquals(Integer.valueOf(7), notify.getAlarmMethod()); + assertNull(notify.getAlarmType(), "AlarmType should be null when not present in XML"); + + // Simulate the exact code path from AlarmNotifyMessageHandler.executeTaskQueue lines 131-141 + // which was causing the NPE + AlarmChannelMessage alarmChannelMessage = new AlarmChannelMessage(); + assertDoesNotThrow(() -> { + alarmChannelMessage.setAlarmType(notify.getAlarmType()); + alarmChannelMessage.setAlarmSn(notify.getAlarmMethod()); + alarmChannelMessage.setAlarmDescription(notify.getAlarmDescription()); + alarmChannelMessage.setGbId(notify.getChannelId()); + }, "setAlarmType(null) should not throw NPE when field type is Integer"); + assertNull(alarmChannelMessage.getAlarmType()); + assertEquals(Integer.valueOf(7), alarmChannelMessage.getAlarmSn()); + } + + @Test + void fromXml_withAlarmType_shouldParseCorrectly() throws Exception { + String xml = """ + + + 34020000001320000001 + 1 + 2 + 2026-06-05T10:30:00 + Video loss alarm + 116.397 + 39.908 + 1 + + """; + + Element root = DocumentHelper.parseText(xml).getRootElement(); + DeviceAlarmNotify notify = DeviceAlarmNotify.fromXml(root); + + assertNotNull(notify); + assertEquals(Integer.valueOf(2), notify.getAlarmMethod()); + assertEquals(Integer.valueOf(1), notify.getAlarmType()); + + AlarmChannelMessage msg = new AlarmChannelMessage(); + assertDoesNotThrow(() -> msg.setAlarmType(notify.getAlarmType())); + assertEquals(Integer.valueOf(1), msg.getAlarmType()); + } + + @Test + void fromXml_withAlarmTypeInInfo_shouldUseInfoValue() throws Exception { + String xml = """ + + + 34020000001320000001 + 1 + 5 + 2026-06-05T10:30:00 + Motion detection + 116.397 + 39.908 + 9 + + 2 + + + """; + + Element root = DocumentHelper.parseText(xml).getRootElement(); + DeviceAlarmNotify notify = DeviceAlarmNotify.fromXml(root); + + assertNotNull(notify); + assertEquals(Integer.valueOf(2), notify.getAlarmType(), + "AlarmType should use Info/AlarmType value when present"); + } +} diff --git a/src/test/java/com/genersoft/iot/vmp/jt1078/dao/provider/JTChannelProviderTest.java b/src/test/java/com/genersoft/iot/vmp/jt1078/dao/provider/JTChannelProviderTest.java new file mode 100644 index 000000000..8ec7d1139 --- /dev/null +++ b/src/test/java/com/genersoft/iot/vmp/jt1078/dao/provider/JTChannelProviderTest.java @@ -0,0 +1,66 @@ +package com.genersoft.iot.vmp.jt1078.dao.provider; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class JTChannelProviderTest { + + private final JTChannelProvider provider = new JTChannelProvider(); + + @Test + void selectAll_withQuery_shouldUseBindVariable() { + Map params = new HashMap<>(); + params.put("terminalDbId", 1); + params.put("query", "test-channel"); + String sql = provider.selectAll(params); + assertTrue(sql.contains("#{query}"), "should use #{query} bind variable"); + assertFalse(sql.contains("test-channel"), "should not contain raw query value"); + assertTrue(sql.contains("concat('%',#{query},'%')"), "should use concat with bind variable"); + assertTrue(sql.contains("#{terminalDbId}"), "should use #{terminalDbId} bind variable"); + } + + @Test + void selectAll_withoutQuery_shouldNotContainLike() { + Map params = new HashMap<>(); + params.put("terminalDbId", 1); + String sql = provider.selectAll(params); + assertFalse(sql.contains("LIKE"), "should not contain LIKE clause when no query"); + assertTrue(sql.contains("#{terminalDbId}"), "should still have terminalDbId condition"); + } + + @Test + void selectChannelByChannelId_shouldUseBindVariables() { + Map params = new HashMap<>(); + params.put("terminalDbId", 5); + params.put("channelId", 100); + String sql = provider.selectChannelByChannelId(params); + assertTrue(sql.contains("#{terminalDbId}"), "should use #{terminalDbId}"); + assertTrue(sql.contains("#{channelId}"), "should use #{channelId}"); + } + + @Test + void selectChannelById_shouldUseBindVariable() { + Map params = new HashMap<>(); + params.put("id", 42); + String sql = provider.selectChannelById(params); + assertTrue(sql.contains("#{id}"), "should use #{id} bind variable"); + } + + @Test + void selectAll_shouldOrderByChannelId() { + Map params = new HashMap<>(); + params.put("terminalDbId", 1); + String sql = provider.selectAll(params); + assertTrue(sql.contains("ORDER BY jc.channel_id"), "should order by channel_id"); + } + + @Test + void baseSql_shouldHaveJoins() { + assertTrue(JTChannelProvider.BASE_SQL.contains("LEFT join wvp_device_channel"), "should have LEFT JOIN"); + assertTrue(JTChannelProvider.BASE_SQL.contains("wvp_jt_channel"), "should query from jt_channel"); + } +} diff --git a/src/test/java/com/genersoft/iot/vmp/streamProxy/dao/provider/StreamProxyProviderTest.java b/src/test/java/com/genersoft/iot/vmp/streamProxy/dao/provider/StreamProxyProviderTest.java new file mode 100644 index 000000000..ecda9f754 --- /dev/null +++ b/src/test/java/com/genersoft/iot/vmp/streamProxy/dao/provider/StreamProxyProviderTest.java @@ -0,0 +1,95 @@ +package com.genersoft.iot.vmp.streamProxy.dao.provider; + +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class StreamProxyProviderTest { + + private final StreamProxyProvider provider = new StreamProxyProvider(); + + @Test + void select_shouldUseBindVariable() { + Map params = new HashMap<>(); + params.put("id", 123); + String sql = provider.select(params); + assertTrue(sql.contains("#{id}"), "should use #{id} bind variable"); + assertFalse(sql.contains("123"), "should not contain raw value"); + assertTrue(sql.contains("WHERE st.id = #{id}"), "should have proper WHERE clause"); + } + + @Test + void selectOneByAppAndStream_shouldUseBindVariables() { + Map params = new HashMap<>(); + params.put("app", "testApp"); + params.put("stream", "testStream"); + String sql = provider.selectOneByAppAndStream(params); + assertTrue(sql.contains("#{app}"), "should use #{app} bind variable"); + assertTrue(sql.contains("#{stream}"), "should use #{stream} bind variable"); + assertFalse(sql.contains("testApp"), "should not contain raw app value"); + assertFalse(sql.contains("testStream"), "should not contain raw stream value"); + } + + @Test + void selectForPushingInMediaServer_shouldUseBindVariable() { + Map params = new HashMap<>(); + params.put("mediaServerId", "server-001"); + String sql = provider.selectForPushingInMediaServer(params); + assertTrue(sql.contains("#{mediaServerId}"), "should use #{mediaServerId} bind variable"); + } + + @Test + void selectAll_withQuery_shouldUseBindVariable() { + Map params = new HashMap<>(); + params.put("query", "test-query"); + String sql = provider.selectAll(params); + assertTrue(sql.contains("#{query}"), "should use #{query} bind variable"); + assertFalse(sql.contains("test-query"), "should not contain raw query value"); + assertTrue(sql.contains("LIKE concat('%',#{query},'%')"), "should use concat with bind variable"); + } + + @Test + void selectAll_withMediaServerId_shouldUseBindVariable() { + Map params = new HashMap<>(); + params.put("mediaServerId", "server-001"); + String sql = provider.selectAll(params); + assertTrue(sql.contains("#{mediaServerId}"), "should use #{mediaServerId} bind variable"); + assertFalse(sql.contains("server-001"), "should not contain raw server id"); + } + + @Test + void selectAll_withPullingTrue() { + Map params = new HashMap<>(); + params.put("pulling", true); + String sql = provider.selectAll(params); + assertTrue(sql.contains("st.pulling=1"), "should filter by pulling=1"); + } + + @Test + void selectAll_withPullingFalse() { + Map params = new HashMap<>(); + params.put("pulling", false); + String sql = provider.selectAll(params); + assertTrue(sql.contains("st.pulling=0"), "should filter by pulling=0"); + } + + @Test + void selectAll_withoutParams_shouldReturnBaseQuery() { + Map params = new HashMap<>(); + String sql = provider.selectAll(params); + assertTrue(sql.contains("FROM wvp_stream_proxy"), "should have FROM clause"); + assertTrue(sql.contains("LEFT join wvp_device_channel"), "should have JOIN clause"); + assertTrue(sql.contains("order by"), "should have ORDER BY"); + } + + @Test + void getBaseSelectSql_shouldReturnValidSql() { + String sql = provider.getBaseSelectSql(); + assertTrue(sql.contains("SELECT"), "should start with SELECT"); + assertTrue(sql.contains("FROM wvp_stream_proxy"), "should have FROM"); + assertTrue(sql.contains("LEFT join wvp_device_channel"), "should have LEFT JOIN"); + } +} diff --git a/web/src/api/commonChannel.js b/web/src/api/commonChannel.js index 50e6c7c3e..58d41305f 100644 --- a/web/src/api/commonChannel.js +++ b/web/src/api/commonChannel.js @@ -270,6 +270,38 @@ export function stopPlayChannel(channelId) { }) } +export function talkStart(channelId) { + return request({ + method: 'get', + url: '/api/common/channel/talk/start', + params: { channelId } + }) +} + +export function talkStop(channelId) { + return request({ + method: 'get', + url: '/api/common/channel/talk/stop', + params: { channelId } + }) +} + +export function broadcastStart(channelId) { + return request({ + method: 'get', + url: '/api/common/channel/broadcast/start', + params: { channelId } + }) +} + +export function broadcastStop(channelId) { + return request({ + method: 'get', + url: '/api/common/channel/broadcast/stop', + params: { channelId } + }) +} + // 前端控制 diff --git a/web/src/store/modules/commonChanel.js b/web/src/store/modules/commonChanel.js index d52ce218b..4684c3cc9 100644 --- a/web/src/store/modules/commonChanel.js +++ b/web/src/store/modules/commonChanel.js @@ -49,7 +49,8 @@ import { pausePlayback, resumePlayback, seekPlayback, speedPlayback, getAllForMap, test, saveLevel, resetLevel, clearThin, thinProgress, drawThin, saveThin, - dragZoomIn, dragZoomOut + dragZoomIn, dragZoomOut, + talkStart, talkStop, broadcastStart, broadcastStop } from '@/api/commonChannel' const actions = { @@ -283,6 +284,46 @@ const actions = { }) }) }, + talkStart({ commit }, channelId) { + return new Promise((resolve, reject) => { + talkStart(channelId).then(response => { + const { data } = response + resolve(data) + }).catch(error => { + reject(error) + }) + }) + }, + talkStop({ commit }, channelId) { + return new Promise((resolve, reject) => { + talkStop(channelId).then(response => { + const { data } = response + resolve(data) + }).catch(error => { + reject(error) + }) + }) + }, + broadcastStart({ commit }, channelId) { + return new Promise((resolve, reject) => { + broadcastStart(channelId).then(response => { + const { data } = response + resolve(data) + }).catch(error => { + reject(error) + }) + }) + }, + broadcastStop({ commit }, channelId) { + return new Promise((resolve, reject) => { + broadcastStop(channelId).then(response => { + const { data } = response + resolve(data) + }).catch(error => { + reject(error) + }) + }) + }, getList({ commit }, param) { return new Promise((resolve, reject) => { getList(param).then(response => { diff --git a/web/src/views/channel/audioTalk.vue b/web/src/views/channel/audioTalk.vue index 83a5ec41b..c18120225 100644 --- a/web/src/views/channel/audioTalk.vue +++ b/web/src/views/channel/audioTalk.vue @@ -3,7 +3,7 @@ - 喊话 - 对讲 + 喊话 + 对讲

{{ talkMode ? '双向语音交互,可听到设备声音' : '单向喊话,仅向设备发送语音' }} @@ -53,7 +53,17 @@ 正在释放资源 点击开始{{ talkMode ? '对讲' : '喊话' }} 等待接通中... - 请说话 + 喊话中 + 等待接通中... + 对讲中 +

+

+ 重试音频

@@ -127,7 +137,10 @@ export default { if (this.talkStatus === -2) return 'primary' if (this.talkStatus === -1) return 'primary' if (this.talkStatus === 0) return 'warning' - if (this.talkStatus === 1) return 'danger' + if (this.talkStatus === 1) { + if (this.talkMode && !this.playConnected) return 'warning' + return 'danger' + } }, async talkButtonClick() { if (this.talkStatus === -1) { @@ -137,54 +150,230 @@ export default { } }, async startTalk() { - this.talkStatus = 0 try { - const data = await this.$store.dispatch('play/broadcastStart', [this.channelId, this.channelId, this.talkMode]) - const si = data.streamInfo - const url = document.location.protocol.includes('https') ? si.rtcs : si.rtc - this.startWebrtcPush(url) + await this.checkMicrophoneAvailability() + } catch (e) { + this.$message({ showClose: true, message: this.getMicrophoneErrorMessage(e), type: 'error' }) + return + } + + this.talkStatus = 0 + + try { + const storeName = 'commonChanel' + const actionName = this.talkMode ? 'talkStart' : 'broadcastStart' + const data = await this.$store.dispatch(storeName + '/' + actionName, this.channelId) + + const pushStream = data?.pushStream + const playStream = data?.playStream + + if (this.talkMode && playStream) { + this.talkAudioPlayStream = playStream + this.startTalkAudioPlay(playStream) + this.muteVideoPlayer() + } + + this.startWebrtcPush(pushStream) } catch (e) { this.$message({ showClose: true, message: e, type: 'error' }) this.talkStatus = -1 } }, - startWebrtcPush(url) { - this.$store.dispatch('user/getUserInfo') - .then((data) => { - if (data === null) { this.talkStatus = -1; return } - const pushKey = data.pushKey - url += '&sign=' + pushKey + muteVideoPlayer() { + const player = this.$refs.playerTabs + if (!player) return + if (player.mute) { + player.mute() + } + }, + unmuteVideoPlayer() { + const player = this.$refs.playerTabs + if (!player) return + if (player.cancelMute) { + player.cancelMute() + } + }, + getMicrophoneErrorMessage(error) { + if (!error || !error.name) return '本地麦克风检测失败,请检查浏览器音频采集权限' + if (error.name === 'NotAllowedError' || error.name === 'PermissionDeniedError' || error.name === 'SecurityError') { + return '未授予浏览器麦克风权限,无法发起语音对讲' + } + if (error.name === 'NotFoundError' || error.name === 'DevicesNotFoundError') { + return '未检测到可用麦克风,无法发起语音对讲' + } + if (error.name === 'NotReadableError' || error.name === 'TrackStartError' || error.name === 'AbortError') { + return '本地麦克风被占用或暂不可用,请检查后重试' + } + if (error.name === 'OverconstrainedError' || error.name === 'ConstraintNotSatisfiedError') { + return '当前麦克风不满足采集条件,无法发起语音对讲' + } + return '本地麦克风检测失败: ' + (error.message || error.name) + }, + async checkMicrophoneAvailability() { + if (!window.isSecureContext && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') { + throw new Error('当前页面不是安全上下文,浏览器无法采集麦克风音频') + } + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + throw new Error('当前浏览器不支持麦克风采集') + } + let stream = null + try { + stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }) + const audioTracks = stream.getAudioTracks() + if (!audioTracks.length) throw new Error('未检测到有效的麦克风音轨') + if (audioTracks.every(track => track.readyState === 'ended')) { + throw new Error('麦克风已断开或不可用') + } + } finally { + if (stream) stream.getTracks().forEach(t => t.stop()) + } + }, + startWebrtcPush(pushStream) { + if (!pushStream) return + let url = pushStream.rtc || pushStream.rtcs + if (!url) { + console.warn('[ChAudioTalk] 未找到RTC推流地址') + return + } - if (this.broadcastRtc) { - this.broadcastRtc.close() - } - this.broadcastRtc = new ZLMRTCClient.Endpoint({ - debug: true, - zlmsdpUrl: url, - simulecast: false, - useCamera: false, - audioEnable: true, - videoEnable: false, - recvOnly: false - }) - this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_NOT_SUPPORT, () => { this.talkStatus = -1 }) - this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_ICE_CANDIDATE_ERROR, () => { this.talkStatus = -1 }) - this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_OFFER_ANWSER_EXCHANGE_FAILED, () => { this.talkStatus = -1 }) - this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_ON_CONNECTION_STATE_CHANGE, (e) => { - if (e === 'connecting') this.talkStatus = 0 - else if (e === 'connected') this.talkStatus = 1 - else if (e === 'disconnected') this.talkStatus = -1 - }) - this.broadcastRtc.on(ZLMRTCClient.Events.CAPTURE_STREAM_FAILED, () => { this.talkStatus = -1 }) + this.$store.dispatch('user/getUserInfo').then(user => { + if (user && user.pushKey) { + url += '&sign=' + user.pushKey + } else { + console.warn('[ChAudioTalk] 未获取到pushKey,推流鉴权可能失败') + } + + if (this.broadcastRtc) { + this.broadcastRtc.close() + } + + this.broadcastRtc = new ZLMRTCClient.Endpoint({ + debug: true, + zlmsdpUrl: url, + simulecast: false, + useCamera: false, + audioEnable: true, + videoEnable: false, + recvOnly: false }) - .catch(() => { this.talkStatus = -1 }) + + this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_NOT_SUPPORT, () => { + this.$message({ showClose: true, message: '不支持WebRTC, 无法进行语音对讲', type: 'error' }) + this.talkStatus = -1 + }) + this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_ICE_CANDIDATE_ERROR, () => { + this.$message({ showClose: true, message: 'ICE协商出错', type: 'error' }) + this.talkStatus = -1 + }) + this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_OFFER_ANWSER_EXCHANGE_FAILED, () => { + this.$message({ showClose: true, message: 'offer/answer交换失败', type: 'error' }) + this.talkStatus = -1 + }) + this.broadcastRtc.on(ZLMRTCClient.Events.WEBRTC_ON_CONNECTION_STATE_CHANGE, (e) => { + if (e === 'connecting') { + this.talkStatus = 0 + } else if (e === 'connected') { + this.talkStatus = 1 + } else if (e === 'disconnected') { + this.talkStatus = -1 + } + }) + }).catch(e => { + console.warn('[ChAudioTalk] 获取用户pushKey失败', e) + this.talkStatus = -1 + }) + }, + startTalkAudioPlay(playStream) { + if (this.talkAudioRtc) { + this.talkAudioRtc.close() + } + if (this.talkAudioRetryTimer) { + clearTimeout(this.talkAudioRetryTimer) + } + + const url = location.protocol === 'https:' ? playStream.rtcs : playStream.rtc + if (!url) { + console.warn('[ChAudioTalk] 无可用的设备音频播放地址') + return + } + this.talkAudioRetryTimer = setTimeout(() => { + this.pollMediaInfoAndPlay(playStream) + }, 800) + }, + async pollMediaInfoAndPlay(playStream) { + try { + const data = await this.$store.dispatch('server/getMediaInfo', { + app: playStream.app, + stream: playStream.stream, + mediaServerId: playStream.mediaServerId + }) + if (data) { + const url = location.protocol === 'https:' ? playStream.rtcs : playStream.rtc + this.startTalkAudioByRtc(url) + } else { + throw new Error('no data') + } + } catch (e) { + if (this.talkStatus === 1 || this.talkStatus === 0) { + this.talkAudioRetryTimer = setTimeout(() => { + this.pollMediaInfoAndPlay(playStream) + }, 800) + } + } + }, + startTalkAudioByRtc(url) { + this.talkAudioFailed = false + this.talkAudioRtc = new ZLMRTCClient.Endpoint({ + debug: false, + element: document.getElementById('audioTalkVideo'), + zlmsdpUrl: url, + simulecast: false, + useCamera: false, + audioEnable: true, + videoEnable: false, + recvOnly: true, + usedatachannel: false + }) + + this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_OFFER_ANWSER_EXCHANGE_FAILED, (e) => { + console.warn('[ChAudioTalk] 播放流offer失败:', e?.code, e?.msg) + if (e && e.code == -400 && e.msg == '流不存在') { + this.talkAudioRetryTimer = setTimeout(() => { + this.startTalkAudioByRtc(url) + }, 1000) + } + }) + + this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_ON_REMOTE_STREAMS, () => { + console.warn('[ChAudioTalk] 设备音频流到达') + this.playConnected = true + }) + + this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_ICE_CANDIDATE_ERROR, () => { + console.error('[ChAudioTalk] 音频播放ICE协商失败') + }) + + this.talkAudioRtc.on(ZLMRTCClient.Events.WEBRTC_ON_CONNECTION_STATE_CHANGE, (s) => { + console.warn('[ChAudioTalk] 音频播放连接状态:', s) + if (s === 'disconnected' || s === 'failed' || s === 'closed') { + this.playConnected = false + this.talkAudioFailed = true + if (this.talkStatus === 1) { + this.talkAudioRetryTimer = setTimeout(() => { + this.startTalkAudioByRtc(url) + }, 2000) + } + } + }) }, async stopTalk() { this.talkStatus = -2 + if (this.broadcastRtc) { this.broadcastRtc.close() this.broadcastRtc = null } + if (this.talkAudioRtc) { this.talkAudioRtc.close() this.talkAudioRtc = null @@ -193,16 +382,27 @@ export default { clearTimeout(this.talkAudioRetryTimer) this.talkAudioRetryTimer = null } + this.talkAudioFailed = false this.talkAudioPlayStream = null this.playConnected = false + this.unmuteVideoPlayer() + + const storeName = 'commonChanel' + const actionName = this.talkMode ? 'talkStop' : 'broadcastStop' try { - await this.$store.dispatch('play/broadcastStop', [this.channelId, this.channelId]) + await this.$store.dispatch(storeName + '/' + actionName, this.channelId) } catch (e) { console.warn('停止对讲失败', e) } + this.talkStatus = -1 }, + retryTalkAudio() { + if (this.talkAudioPlayStream) { + this.startTalkAudioPlay(this.talkAudioPlayStream) + } + }, close() { if (this.showPlayer && this.$refs.playerTabs) { this.$refs.playerTabs.stop() diff --git a/web/src/views/device/dialog/audioTalk.vue b/web/src/views/device/dialog/audioTalk.vue index f791f6517..86f9b03c3 100644 --- a/web/src/views/device/dialog/audioTalk.vue +++ b/web/src/views/device/dialog/audioTalk.vue @@ -37,7 +37,7 @@ 对讲

- {{ talkMode ? '双向语音交互,可听到设备声音' : '单向喊话,仅向设备发送语音' }} + {{ talkMode ? '单向喊话,仅向设备发送语音' : '双向语音交互,可听到设备声音' }}

@@ -77,7 +77,7 @@ export default { channelId: null, hasAudio: false, streamInfo: null, - talkMode: false, + talkMode: true, talkStatus: -1, broadcastRtc: null, talkAudioRtc: null,