幽灵麦介绍
“幽灵麦”(又称“炸麦”或“黑麦”)是指用户不在业务侧麦位列表中,但仍能够在 RTC 房间内发言,且其他用户仍可听到其声音的异常现象。通常幽灵麦特指由未授权用户通过外挂、破解等方式恶意上麦发言,制造噪音、违规内容、刷屏等扰乱语聊房秩序的行为。
幽灵麦问题的本质在于业务侧麦位状态与 RTC 实际音频上行状态不一致,或业务权限控制被绕过。常见原因可归纳为以下三类:
麦位状态同步异常
用户下麦后,业务侧虽已更新麦位状态,但由于信令回调未正常触达、消息被拦截、状态同步异常等原因,客户端未及时执行切换观众角色、关闭麦克风或停止推流等操作,导致用户实际仍在进行音频上行。
RTC 状态切换失败
客户端已正确收到下麦通知,并开始执行 TRTC 角色切换或停止推流操作,但由于接口调用失败、状态机异常、网络问题等原因,导致 RTC 实际推流状态未成功关闭,从而出现“已下麦但仍能发言”的现象。
权限控制被绕过或恶意攻击
攻击者通过 App 破解、外挂、Hook、协议模拟、UserSig 泄露等方式,绕过业务侧上麦鉴权或客户端状态控制,伪造主播身份进入房间并强行进行音频上行,进而实施噪音干扰、违规内容传播、恶意刷屏等破坏房间秩序的行为。
针对幽灵麦问题,可通过主动检测 RTC 实际音频状态与业务侧麦位状态的一致性,实现幽灵麦的快速识别与处理。下面将介绍几种常用的幽灵麦预防和处理方案,建议根据业务需求自行选择合适的方案。
预防及处理方案
手动订阅音频流方案
方案原理:通过 TRTC SDK
setDefaultStreamRecvMode 设置音频流手动订阅模式,当收到远端用户发布音频流的回调,先通过对比业务侧麦位列表,再决策是否订阅并播放该用户的音频流。该方案具备较好的幽灵麦预防效果,参考示例代码如下。// 进房前:关闭自动订阅,改为手动按需拉流trtcCloud.setDefaultStreamRecvMode(false, false)trtcCloud.enterRoom(params, TRTCCloudDef.TRTC_APP_SCENE_LIVE)// 麦位列表:业务侧自行维护,决定哪些用户的音频需要被订阅private val seatUserSet = mutableSetOf<String>()// SDK 回调:远端用户发布/取消音频流 → 对比麦位列表决定是否订阅override fun onUserAudioAvailable(userId: String, available: Boolean) {if (available && userId in seatUserSet) {trtcCloud.muteRemoteAudio(userId, false)}}// 麦位变更补偿:防止音频回调与麦位信令时序不一致导致漏订或多订fun onSeatListUpdated(newSet: Set<String>) {(newSet - seatUserSet).forEach { trtcCloud.muteRemoteAudio(it, false) }(seatUserSet - newSet).forEach { trtcCloud.muteRemoteAudio(it, true) }seatUserSet.clear(); seatUserSet.addAll(newSet)}
// 进房前:关闭自动订阅,改为手动按需拉流trtcCloud.setDefaultStreamRecvMode(false, video: false)trtcCloud.enterRoom(params, appScene: .LIVE)// 麦位列表:业务侧自行维护,决定哪些用户的音频需要被订阅private var seatUserSet: Set<String> = []// SDK 回调:远端用户发布/取消音频流 → 对比麦位列表决定是否订阅func onUserAudioAvailable(_ userId: String, available: Bool) {if available && seatUserSet.contains(userId) {trtcCloud.muteRemoteAudio(userId, mute: false)}}// 麦位变更补偿:防止音频回调与麦位信令时序不一致导致漏订或多订func onSeatListUpdated(_ newSet: Set<String>) {newSet.subtracting(seatUserSet).forEach { trtcCloud.muteRemoteAudio($0, mute: false) }seatUserSet.subtracting(newSet).forEach { trtcCloud.muteRemoteAudio($0, mute: true) }seatUserSet = newSet}
// 进房前:关闭自动订阅,改为手动按需拉流await trtcCloud.setDefaultStreamRecvMode(false, false);await trtcCloud.enterRoom(params, TRTCAppScene.LIVE);// 麦位列表:业务侧自行维护,决定哪些用户的音频需要被订阅final _seatUserSet = <String>{};// SDK 回调:远端用户发布/取消音频流 → 对比麦位列表决定是否订阅void _onUserAudioAvailable(String userId, bool available) {if (available && _seatUserSet.contains(userId)) {trtcCloud.muteRemoteAudio(userId, false);}}// 麦位变更补偿:防止音频回调与麦位信令时序不一致导致漏订或多订void onSeatListUpdated(Set<String> newSet) {newSet.difference(_seatUserSet).forEach((id) => trtcCloud.muteRemoteAudio(id, false));_seatUserSet.difference(newSet).forEach((id) => trtcCloud.muteRemoteAudio(id, true));_seatUserSet..clear()..addAll(newSet);}
注意:
建议增加补偿机制:麦位列表变更时主动补订阅/退订阅,解决“音频回调先于麦位信令到达”的时序问题。
手动订阅音频流方案在幽灵麦预防方面表现良好,但同时也可能影响最佳的“秒开体验”。
音量大小回调方案
方案原理:通过 TRTC SDK
enableAudioVolumeEvaluation 启用音量大小提示,当收到远端用户的音量大小回调,比对该音频推流用户和业务侧麦位列表,从而识别出不在麦位上却有音频推流的幽灵麦。整体实现流程如下图所示。
注意:
建议检测到幽灵麦后等待累计一定次数后再处理,避免弱网环境下 RTC 音频上行状态和业务侧麦位状态的更新存在短暂时差,导致误判幽灵麦。
若采用该方案,建议音量大小回调时间间隔
interval 不要设置太短,防止频繁的幽灵麦判断操作占用设备性能,导致音频卡顿。业务侧进房校验方案
方案原理:幽灵麦用户通常会通过非法入侵手段获取 TRTC 鉴权凭证 UserSig,然后利用 Web 等平台直接绕过业务逻辑,通过 TRTC SDK 进入任意房间实施干扰或攻击。这种情况下,可以通过在 TRTC 服务端进房事件回调中增加业务侧校验逻辑,检查用户是否同时登录了业务房间,若仅进入了 TRTC 房间则视为幽灵麦用户并进行禁言处理或踢出房间。
1. 实时音视频 TRTC 控制台支持自助配置回调信息,配置完成后即可接收事件回调通知。详情请参见 回调配置。
2. 接收并解析回调事件包体,监听103: 进入房间事件,并在事件回调中检查用户是否同时登录了业务房间。详情请参见 事件回调。
3. 如果检测出用户仅进入 TRTC 房间并未登录业务房间,则将该用户视为幽灵麦用户并进行禁言或踢出房间 RemoveUser。
app.post('/trtc/callback', async (req, res) => {const { EventType, EventInfo } = req.body;// 仅处理 103 进房事件if (EventType === 103) {const { RoomId, UserId } = EventInfo;// 查询业务侧:该用户是否在业务房间中const inBusinessRoom = await db.checkUserInRoom(RoomId, UserId);if (!inBusinessRoom) {// 幽灵麦 → 踢出房间await trtcApi.removeUser(RoomId, UserId);}}res.json({ code: 0 });});
func handleCallback(w http.ResponseWriter, r *http.Request) {var cb CallbackBodyjson.NewDecoder(r.Body).Decode(&cb)// 仅处理 103 进房事件if cb.EventType == 103 {info := cb.EventInfo// 查询业务侧:该用户是否在业务房间中if !db.CheckUserInRoom(info.RoomId, info.UserId) {// 幽灵麦 → 踢出房间trtcApi.RemoveUser(info.RoomId, info.UserId)}}w.Write([]byte(`{"code":0}`))}
@app.route('/trtc/callback', methods=['POST'])def trtc_callback():data = request.get_json()# 仅处理 103 进房事件if data['EventType'] == 103:info = data['EventInfo']# 查询业务侧:该用户是否在业务房间中if not db.check_user_in_room(info['RoomId'], info['UserId']):# 幽灵麦 → 踢出房间trtc_api.remove_user(info['RoomId'], info['UserId'])return jsonify(code=0)