rtc禁言

This commit is contained in:
zhx 2026-07-21 13:11:11 +08:00
parent a844c7b738
commit cf431eb8d7
10 changed files with 295 additions and 2 deletions

View File

@ -8,6 +8,7 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@ -25,6 +26,9 @@ const (
trtcServiceName = "trtc" trtcServiceName = "trtc"
trtcAPIVersion = "2019-07-22" trtcAPIVersion = "2019-07-22"
removeUserByStrRoomAction = "RemoveUserByStrRoomId" removeUserByStrRoomAction = "RemoveUserByStrRoomId"
setUserBlockedByStrAction = "SetUserBlockedByStrRoomId"
trtcUserMediaEnabled = 0
trtcUserAudioDisabled = 2
tencentCloudTC3Algorithm = "TC3-HMAC-SHA256" tencentCloudTC3Algorithm = "TC3-HMAC-SHA256"
tencentCloudTC3RequestType = "tc3_request" tencentCloudTC3RequestType = "tc3_request"
) )
@ -95,6 +99,38 @@ func (c *RESTClient) RemoveUserByStrRoomID(ctx context.Context, roomID string, u
return c.post(ctx, removeUserByStrRoomAction, payload) return c.post(ctx, removeUserByStrRoomAction, payload)
} }
// SetUserAudioBlockedByStrRoomID uses TRTC's server-side media gate instead of
// trusting the target client to stop publishing. Room-service remains the mute
// state owner; this call only projects that committed state onto the live RTC
// data plane and deliberately leaves receiving audio enabled.
func (c *RESTClient) SetUserAudioBlockedByStrRoomID(ctx context.Context, roomID string, userID int64, blocked bool) error {
roomID = strings.TrimSpace(roomID)
if roomID == "" || userID <= 0 {
return fmt.Errorf("rtc set user audio blocked is incomplete")
}
muteMode := trtcUserMediaEnabled
if blocked {
// IsMute=2 disables only upstream audio. IsMute=1 would also disable
// video and would silently broaden the semantics of room mute.
muteMode = trtcUserAudioDisabled
}
payload := setUserBlockedByStrRoomRequest{
SDKAppID: c.cfg.SDKAppID,
RoomID: roomID,
UserID: strconv.FormatInt(userID, 10),
IsMute: muteMode,
}
err := c.post(ctx, setUserBlockedByStrAction, payload)
if IsRESTErrorCode(err, "FailedOperation.RoomNotExist", "FailedOperation.UserNotExist", "InternalError.UserNotExist") {
// The desired media state is already satisfied when either the room or
// user has gone. Retrying these terminal absences would only poison the
// durable room outbox until it reaches the dead-letter threshold.
return nil
}
return err
}
func (c *RESTClient) post(ctx context.Context, action string, payload any) error { func (c *RESTClient) post(ctx context.Context, action string, payload any) error {
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
@ -131,11 +167,40 @@ func (c *RESTClient) post(ctx context.Context, action string, payload any) error
return err return err
} }
if cloudResp.Response.Error != nil { if cloudResp.Response.Error != nil {
return fmt.Errorf("tencent rtc rest failed: code=%s message=%s", cloudResp.Response.Error.Code, cloudResp.Response.Error.Message) return &RESTError{Code: cloudResp.Response.Error.Code, Message: cloudResp.Response.Error.Message}
} }
return nil return nil
} }
// RESTError preserves Tencent's stable business code so callers can classify
// terminal idempotent outcomes without matching a human-readable message.
type RESTError struct {
Code string
Message string
}
func (e *RESTError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("tencent rtc rest failed: code=%s message=%s", e.Code, e.Message)
}
// IsRESTErrorCode reports whether err is a Tencent RTC business error with one
// of the supplied codes. Wrapped errors are supported for service-level context.
func IsRESTErrorCode(err error, codes ...string) bool {
var restErr *RESTError
if !errors.As(err, &restErr) {
return false
}
for _, code := range codes {
if restErr.Code == code {
return true
}
}
return false
}
func (c *RESTClient) sign(request *http.Request, body []byte, action string) { func (c *RESTClient) sign(request *http.Request, body []byte, action string) {
now := c.now().UTC() now := c.now().UTC()
timestamp := now.Unix() timestamp := now.Unix()
@ -198,6 +263,13 @@ type removeUserByStrRoomRequest struct {
UserIDs []string `json:"UserIds"` UserIDs []string `json:"UserIds"`
} }
type setUserBlockedByStrRoomRequest struct {
SDKAppID int64 `json:"SdkAppId"`
RoomID string `json:"StrRoomId"`
UserID string `json:"UserId"`
IsMute int `json:"IsMute"`
}
type cloudAPIResponse struct { type cloudAPIResponse struct {
Response struct { Response struct {
RequestID string `json:"RequestId"` RequestID string `json:"RequestId"`

View File

@ -56,6 +56,76 @@ func TestRESTClientRemoveUserByStrRoomIDBuildsTencentCloudRequest(t *testing.T)
} }
} }
// TestRESTClientSetUserAudioBlockedByStrRoomIDBuildsAudioOnlyRequest 锁定服务端禁言只关闭上行音频,
// 解除时恢复音视频能力,避免误用 IsMute=1 把未来的视频房能力一并关闭。
func TestRESTClientSetUserAudioBlockedByStrRoomIDBuildsAudioOnlyRequest(t *testing.T) {
var bodies []map[string]any
var actions []string
client, err := NewRESTClient(RESTConfig{
SDKAppID: 1400000001,
SecretID: "AKIDEXAMPLE",
SecretKey: "secret",
Region: "ap-singapore",
HTTPClient: &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) {
var body map[string]any
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatalf("decode request body failed: %v", err)
}
bodies = append(bodies, body)
actions = append(actions, request.Header.Get("X-TC-Action"))
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"Response":{"RequestId":"req-block"}}`))),
Header: make(http.Header),
}, nil
})},
Now: func() time.Time { return time.Unix(1_700_000_000, 0).UTC() },
})
if err != nil {
t.Fatalf("NewRESTClient failed: %v", err)
}
if err := client.SetUserAudioBlockedByStrRoomID(context.Background(), "room_1001", 10002, true); err != nil {
t.Fatalf("block user audio failed: %v", err)
}
if err := client.SetUserAudioBlockedByStrRoomID(context.Background(), "room_1001", 10002, false); err != nil {
t.Fatalf("unblock user audio failed: %v", err)
}
if len(bodies) != 2 || len(actions) != 2 {
t.Fatalf("unexpected request count: bodies=%d actions=%d", len(bodies), len(actions))
}
for index, expectedMute := range []float64{trtcUserAudioDisabled, trtcUserMediaEnabled} {
if actions[index] != setUserBlockedByStrAction || bodies[index]["SdkAppId"] != float64(1400000001) || bodies[index]["StrRoomId"] != "room_1001" || bodies[index]["UserId"] != "10002" || bodies[index]["IsMute"] != expectedMute {
t.Fatalf("unexpected set user blocked request %d: action=%s body=%+v", index, actions[index], bodies[index])
}
}
}
// TestRESTClientSetUserAudioBlockedTreatsAbsentRTCUserAsConverged 防止用户先退 RTC 后的历史
// RoomUserMuted 事件把 durable outbox 永久卡在无意义重试上。
func TestRESTClientSetUserAudioBlockedTreatsAbsentRTCUserAsConverged(t *testing.T) {
client, err := NewRESTClient(RESTConfig{
SDKAppID: 1400000001,
SecretID: "AKIDEXAMPLE",
SecretKey: "secret",
Region: "ap-singapore",
HTTPClient: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(bytes.NewReader([]byte(`{"Response":{"Error":{"Code":"FailedOperation.UserNotExist","Message":"user absent"},"RequestId":"req-absent"}}`))),
Header: make(http.Header),
}, nil
})},
})
if err != nil {
t.Fatalf("NewRESTClient failed: %v", err)
}
if err := client.SetUserAudioBlockedByStrRoomID(context.Background(), "room_1001", 10002, true); err != nil {
t.Fatalf("absent RTC user must be treated as converged: %v", err)
}
}
type roundTripFunc func(*http.Request) (*http.Response, error) type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { func (f roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {

View File

@ -165,6 +165,7 @@ func New(cfg config.Config) (*App, error) {
roomPublisher := integration.NewNoopRoomEventPublisher() roomPublisher := integration.NewNoopRoomEventPublisher()
outboxPublishers := make([]integration.OutboxPublisher, 0, 3) outboxPublishers := make([]integration.OutboxPublisher, 0, 3)
var rtcUserRemover integration.RTCUserRemover var rtcUserRemover integration.RTCUserRemover
var rtcUserAudioBlocker integration.RTCUserAudioBlocker
var tencentPublisher integration.OutboxPublisher var tencentPublisher integration.OutboxPublisher
if cfg.TencentIM.Enabled { if cfg.TencentIM.Enabled {
// 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。 // 腾讯云 IM 替代自研 IMroom-service 只负责服务端 REST 群消息桥接。
@ -197,6 +198,7 @@ func New(cfg config.Config) (*App, error) {
return nil, err return nil, err
} }
rtcUserRemover = rtcClient rtcUserRemover = rtcClient
rtcUserAudioBlocker = rtcClient
} }
mqProducers := make([]*rocketmqx.Producer, 0, 2) mqProducers := make([]*rocketmqx.Producer, 0, 2)
@ -248,6 +250,7 @@ func New(cfg config.Config) (*App, error) {
PresenceStaleAfter: cfg.PresenceStaleAfter, PresenceStaleAfter: cfg.PresenceStaleAfter,
MicPublishTimeout: cfg.MicPublishTimeout, MicPublishTimeout: cfg.MicPublishTimeout,
RTCUserRemover: rtcUserRemover, RTCUserRemover: rtcUserRemover,
RTCUserAudioBlocker: rtcUserAudioBlocker,
RoomRocketLaunchScheduler: rocketLaunchScheduler, RoomRocketLaunchScheduler: rocketLaunchScheduler,
RobotDisplayPublisher: tencentPublisher, RobotDisplayPublisher: tencentPublisher,
RoomDisplayPublisher: tencentPublisher, RoomDisplayPublisher: tencentPublisher,

View File

@ -61,6 +61,12 @@ type RTCUserRemover interface {
RemoveUserByStrRoomID(ctx context.Context, roomID string, userID int64) error RemoveUserByStrRoomID(ctx context.Context, roomID string, userID int64) error
} }
// RTCUserAudioBlocker 抽象腾讯 RTC 媒体平面的服务端音频开关。
// Room Cell 仍是禁言事实 owner实现只能投影当前状态不能自行持有禁言集合。
type RTCUserAudioBlocker interface {
SetUserAudioBlockedByStrRoomID(ctx context.Context, roomID string, userID int64, blocked bool) error
}
// OutboxPublisher 抽象 outbox worker 对外部消费者的异步投递。 // OutboxPublisher 抽象 outbox worker 对外部消费者的异步投递。
type OutboxPublisher interface { type OutboxPublisher interface {
// PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。 // PublishOutboxEvent 投递 protobuf outbox 信封,失败由 room_outbox 转为 retryable 状态重试。

View File

@ -3,21 +3,51 @@ package service_test
import ( import (
"context" "context"
"fmt" "fmt"
"sync"
"testing" "testing"
"time" "time"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode" "hyapp/pkg/appcode"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/command"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest" "hyapp/services/room-service/internal/testutil/mysqltest"
) )
type recordingRTCUserAudioBlocker struct {
mu sync.Mutex
calls []bool
}
func (b *recordingRTCUserAudioBlocker) SetUserAudioBlockedByStrRoomID(_ context.Context, _ string, _ int64, blocked bool) error {
b.mu.Lock()
defer b.mu.Unlock()
b.calls = append(b.calls, blocked)
return nil
}
func (b *recordingRTCUserAudioBlocker) snapshot() []bool {
b.mu.Lock()
defer b.mu.Unlock()
return append([]bool(nil), b.calls...)
}
func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) { func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
ctx := context.Background() ctx := context.Background()
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)} now := &fixedRoomRocketClock{now: time.Date(2026, 7, 21, 8, 0, 0, 0, time.UTC)}
svc := newRocketTestService(t, repository, &rocketTestWallet{}, now) rtcBlocker := &recordingRTCUserAudioBlocker{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-muted-rtc-test",
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: now,
RTCUserAudioBlocker: rtcBlocker,
}, router.NewMemoryDirectory(), repository, &rocketTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
roomID := "room-muted-user-guard" roomID := "room-muted-user-guard"
ownerID := int64(8481) ownerID := int64(8481)
@ -113,6 +143,20 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
}); !xerr.IsCode(err, xerr.PermissionDenied) { }); !xerr.IsCode(err, xerr.PermissionDenied) {
t.Fatalf("room mute between MicUp and RTC confirmation must reject publishing: %v", err) t.Fatalf("room mute between MicUp and RTC confirmation must reject publishing: %v", err)
} }
rtcEventResp, err := svc.ApplyRTCEvent(ctx, &roomv1.ApplyRTCEventRequest{
Meta: rocketMeta(roomID, mutedUserID, "muted-rtc-audio-started"),
TargetUserId: mutedUserID,
EventType: "audio_started",
EventTimeMs: now.Now().UnixMilli(),
Source: "tencent_rtc_callback",
ExternalEventId: "rtc-event-muted-audio-started",
})
if err != nil {
t.Fatalf("muted RTC audio callback must be acknowledged after cloud re-block: %v", err)
}
if rtcEventResp.GetResult().GetApplied() {
t.Fatalf("muted RTC audio callback must not advance the seat to publishing: %+v", rtcEventResp)
}
if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{ if _, err := svc.MuteUser(ctx, &roomv1.MuteUserRequest{
Meta: rocketMeta(roomID, ownerID, "unmute-current-rtc-seat"), Meta: rocketMeta(roomID, ownerID, "unmute-current-rtc-seat"),
@ -128,6 +172,28 @@ func TestMutedUserCannotSpeakOrMicUpUntilUnmuted(t *testing.T) {
}); err != nil { }); err != nil {
t.Fatalf("room unmute must restore explicit RTC mic control: %v", err) t.Fatalf("room unmute must restore explicit RTC mic control: %v", err)
} }
directCalls := rtcBlocker.snapshot()
if len(directCalls) != 5 || !directCalls[0] || directCalls[1] || !directCalls[2] || !directCalls[3] || directCalls[4] {
// 四次 MuteUser 分别投影 true/false/true/false中间被篡改客户端触发的
// audio_started 必须额外 re-block=true因此按调用顺序断言服务端防线完整。
t.Fatalf("unexpected direct RTC audio block sequence: %+v", directCalls)
}
if err := svc.ProcessPendingOutbox(ctx, roomservice.OutboxWorkerOptions{PublishTimeout: time.Second, BatchSize: 100}); err != nil {
t.Fatalf("process RTC mute outbox failed: %v", err)
}
allCalls := rtcBlocker.snapshot()
if len(allCalls)-len(directCalls) != 4 {
t.Fatalf("each durable RoomUserMuted event must reach RTC compensation: direct=%+v all=%+v", directCalls, allCalls)
}
for index, blocked := range allCalls[len(directCalls):] {
// outbox 中既有 mute 也有 unmute 历史事件;补偿必须读取当前已解除的 Room Cell
// 状态并全部收敛到 false不能按旧事件载荷重新把用户禁言。
if blocked {
t.Fatalf("stale RTC mute outbox call %d rolled current state backward", index)
}
}
} }
func TestMicUpReturnsExistingSeatForDuplicateUserCommand(t *testing.T) { func TestMicUpReturnsExistingSeatForDuplicateUserCommand(t *testing.T) {

View File

@ -2,10 +2,12 @@ package service
import ( import (
"context" "context"
"log/slog"
"time" "time"
roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1"
roomv1 "hyapp.local/api/proto/room/v1" roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/logx"
"hyapp/pkg/tencentim" "hyapp/pkg/tencentim"
"hyapp/pkg/xerr" "hyapp/pkg/xerr"
"hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/command"
@ -118,6 +120,20 @@ func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*r
return nil, err return nil, err
} }
if s.rtcUserAudioBlocker != nil {
// 低延迟路径在 Room Cell 提交后立即更新 RTC失败不能回滚已经落盘的禁言事实
// 同一条 RoomUserMuted outbox 会按最新快照继续补偿,避免客户端暂时断线造成永久漏禁。
blocked := containsID(result.snapshot.GetMuteUserIds(), cmd.TargetUserID)
if err := s.rtcUserAudioBlocker.SetUserAudioBlockedByStrRoomID(ctx, cmd.RoomID(), cmd.TargetUserID, blocked); err != nil {
logx.Warn(ctx, "rtc_user_audio_block_sync_failed",
slog.String("room_id", cmd.RoomID()),
slog.Int64("target_user_id", cmd.TargetUserID),
slog.Bool("blocked", blocked),
slog.String("error", err.Error()),
)
}
}
return &roomv1.MuteUserResponse{ return &roomv1.MuteUserResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
Room: result.snapshot, Room: result.snapshot,

View File

@ -413,6 +413,11 @@ func (s *Service) publishOutboxRecord(ctx context.Context, record outbox.Record)
} }
return s.roomDisplayPublisher.PublishOutboxEvent(ctx, record.Envelope) return s.roomDisplayPublisher.PublishOutboxEvent(ctx, record.Envelope)
} }
// RTC 禁言是 RoomUserMuted 的外部媒体投影。先按当前 Room Cell 状态收敛,再发布通用
// MQ/活动消费者;任何一侧失败都会保留同一 outbox 记录重试,且 RTC 调用本身幂等。
if err := s.syncRTCUserMuteFromOutbox(ctx, record.Envelope); err != nil {
return err
}
// Ignited 在 outbox worker 内安排延迟发射,保证 MQ 调度失败能通过 room_outbox retry客户端 IM 展示已在 Room Cell 提交后直发。 // Ignited 在 outbox worker 内安排延迟发射,保证 MQ 调度失败能通过 room_outbox retry客户端 IM 展示已在 Room Cell 提交后直发。
if err := s.scheduleRoomRocketLaunchFromEnvelope(ctx, record.Envelope); err != nil { if err := s.scheduleRoomRocketLaunchFromEnvelope(ctx, record.Envelope); err != nil {
return err return err

View File

@ -66,6 +66,13 @@ func (s *Service) ApplyRTCEvent(ctx context.Context, req *roomv1.ApplyRTCEventRe
if err != nil { if err != nil {
return nil, err return nil, err
} }
if cmd.EventType == rtcEventAudioStarted && s.rtcUserAudioBlocker != nil && containsID(result.snapshot.GetMuteUserIds(), cmd.TargetUserID) {
// 被禁言用户绕过客户端直接发流时,腾讯 RTC webhook 是第二道服务端防线。
// 失败向 gateway 返回错误让腾讯重试;幂等 command replay 仍会再次执行本段外部投影。
if err := s.rtcUserAudioBlocker.SetUserAudioBlockedByStrRoomID(ctx, cmd.RoomID(), cmd.TargetUserID, true); err != nil {
return nil, err
}
}
return &roomv1.ApplyRTCEventResponse{ return &roomv1.ApplyRTCEventResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
@ -84,6 +91,11 @@ func (s *Service) applyRTCAudioStarted(now time.Time, current *state.RoomState,
// RTC 事件不拥有业务进房事实;用户不在业务 presence 时只忽略旧事件。 // RTC 事件不拥有业务进房事实;用户不在业务 presence 时只忽略旧事件。
return mutationResult{snapshot: current.ToProto()}, nil, nil return mutationResult{snapshot: current.ToProto()}, nil, nil
} }
if current.MuteUsers[cmd.TargetUserID] {
// 房间禁言是音频发布的权威权限;即使篡改客户端已经触发 RTC audio_started
// 也不能把麦位推进 publishing外层会同步调用 TRTC 服务端音频封禁并要求失败重试。
return mutationResult{snapshot: current.ToProto()}, nil, nil
}
seat, exists := current.SeatByUser(cmd.TargetUserID) seat, exists := current.SeatByUser(cmd.TargetUserID)
if !exists { if !exists {

View File

@ -0,0 +1,38 @@
package service
import (
"context"
"fmt"
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
)
// syncRTCUserMuteFromOutbox 把持久化的房间禁言事实补偿到腾讯 RTC 媒体平面。
// 重试时必须读取 Room Cell 的最新禁言集合,而不能直接重放旧事件里的 muted否则旧 mute
// 在 newer unmute 之后重试会重新关掉用户音频,形成跨系统状态回退。
func (s *Service) syncRTCUserMuteFromOutbox(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
if s == nil || s.rtcUserAudioBlocker == nil || envelope == nil || envelope.GetEventType() != "RoomUserMuted" {
return nil
}
var event roomeventsv1.RoomUserMuted
if err := proto.Unmarshal(envelope.GetBody(), &event); err != nil {
return fmt.Errorf("decode rtc mute outbox event: %w", err)
}
if event.GetTargetUserId() <= 0 || envelope.GetRoomId() == "" {
return fmt.Errorf("rtc mute outbox event is incomplete")
}
snapshot, err := s.currentSnapshot(ctx, envelope.GetRoomId())
if err != nil {
return err
}
if snapshot == nil {
// 房间已被物理清理时 RTC 连接也不再存在;把历史禁言事件视为终态成功,
// 避免一条不可执行的外部副作用永久阻塞同一 outbox worker。
return nil
}
blocked := containsID(snapshot.GetMuteUserIds(), event.GetTargetUserId())
return s.rtcUserAudioBlocker.SetUserAudioBlockedByStrRoomID(ctx, envelope.GetRoomId(), event.GetTargetUserId(), blocked)
}

View File

@ -33,6 +33,8 @@ type Config struct {
MicPublishTimeout time.Duration MicPublishTimeout time.Duration
// RTCUserRemover 是平台级封禁后把用户从腾讯 RTC 房间移出的外部边界。 // RTCUserRemover 是平台级封禁后把用户从腾讯 RTC 房间移出的外部边界。
RTCUserRemover integration.RTCUserRemover RTCUserRemover integration.RTCUserRemover
// RTCUserAudioBlocker 把 Room Cell 禁言事实投影到腾讯 RTC 服务端音频上行闸门。
RTCUserAudioBlocker integration.RTCUserAudioBlocker
// RoomRocketLaunchScheduler 把倒计时发射唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。 // RoomRocketLaunchScheduler 把倒计时发射唤醒交给外部延迟消息,避免只靠已加载 Cell 扫描。
RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
// RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。 // RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。
@ -117,6 +119,8 @@ type Service struct {
luckyGiftSendLockTTL time.Duration luckyGiftSendLockTTL time.Duration
// rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。 // rtcUserRemover 在 Room Cell 提交驱逐后同步移除实时音频房连接。
rtcUserRemover integration.RTCUserRemover rtcUserRemover integration.RTCUserRemover
// rtcUserAudioBlocker 在房间禁言提交后关闭目标用户的 RTC 上行音频;状态仍只保存在 Room Cell。
rtcUserAudioBlocker integration.RTCUserAudioBlocker
// draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。 // draining 表示当前节点正在下线,只允许已经进入执行链路的命令收尾。
draining atomic.Bool draining atomic.Bool
@ -299,6 +303,7 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
luckyGiftSendLocker: cfg.LuckyGiftSendLocker, luckyGiftSendLocker: cfg.LuckyGiftSendLocker,
luckyGiftSendLockTTL: luckyGiftSendLockTTL, luckyGiftSendLockTTL: luckyGiftSendLockTTL,
rtcUserRemover: cfg.RTCUserRemover, rtcUserRemover: cfg.RTCUserRemover,
rtcUserAudioBlocker: cfg.RTCUserAudioBlocker,
cells: make(map[string]*loadedCell), cells: make(map[string]*loadedCell),
robotRuntimes: make(map[string]robotRoomRuntime), robotRuntimes: make(map[string]robotRoomRuntime),
humanRobotRuntimes: make(map[string]robotRoomRuntime), humanRobotRuntimes: make(map[string]robotRoomRuntime),