package service import ( "context" "fmt" "strings" "time" "google.golang.org/protobuf/proto" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" ) const ( // SetUserBlockedByStrRoomId 官方限频为 20 QPS;全进程每 50ms 最多发起一次。 rtcMuteProjectionInterval = 50 * time.Millisecond // 一次入口最多追赶三个并发状态版本;持续颠簸时返回可重试错误, // 交给新命令的 post-commit 调用或 durable outbox 继续收敛,不无界占用 worker。 rtcMuteProjectionMaxAttempts = 3 // REST client 单次默认上限为 5s;总预算只多留 1s 给门闩、限速和快照复核。 rtcMuteProjectionTimeout = 6 * time.Second ) type rtcMuteProjectionKey struct { appCode string roomID string userID int64 } // rtcMuteProjectionGate 用 channel 而不是 sync.Mutex,让等待同一用户投影的请求 // 能遵守 context deadline;refs 由 Service.rtcMuteGateMu 保护,最后一个使用者退出即删除门闩。 type rtcMuteProjectionGate struct { token chan struct{} refs int } // 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") } return s.reconcileRTCUserAudio(ctx, envelope.GetRoomId(), event.GetTargetUserId(), false) } // reconcileRTCUserAudio 只把调用时的最新 Room Cell 状态投影到 RTC,从不信任历史 // command result 或 outbox payload 中的 muted 快照。onlyIfBlocked 只用于 audio_started // webhook:用户本来未被禁言时无需多发一次 unmute,但如果本轮已经 block 后 // 又观测到新 unmute,仍必须追赶并发出 unblock,否则会留下本轮自己的旧副作用。 func (s *Service) reconcileRTCUserAudio(parent context.Context, roomID string, userID int64, onlyIfBlocked bool) error { if s == nil || s.rtcUserAudioBlocker == nil { return nil } roomID = strings.TrimSpace(roomID) if roomID == "" || userID <= 0 { return fmt.Errorf("rtc mute reconcile target is incomplete") } ctx, cancel := context.WithTimeout(parent, rtcMuteProjectionTimeout) defer cancel() release, err := s.acquireRTCMuteProjection(ctx, rtcMuteProjectionKey{ appCode: appcode.FromContext(ctx), roomID: roomID, userID: userID, }) if err != nil { return err } defer release() projected := false for attempt := 1; attempt <= rtcMuteProjectionMaxAttempts; attempt++ { before, err := s.currentSnapshot(ctx, roomID) if err != nil { return err } if before == nil { // 房间已被物理清理时 RTC 连接也不再存在,历史禁言投影可直接收敛。 return nil } desiredBlocked := containsID(before.GetMuteUserIds(), userID) if onlyIfBlocked && !projected && !desiredBlocked { return nil } if err := s.waitRTCMuteProjectionRate(ctx); err != nil { return err } if err := s.rtcUserAudioBlocker.SetUserAudioBlockedByStrRoomID(ctx, roomID, userID, desiredBlocked); err != nil { return err } projected = true after, err := s.currentSnapshot(ctx, roomID) if err != nil { return err } if after == nil || containsID(after.GetMuteUserIds(), userID) == desiredBlocked { return nil } // 状态在云 API 请求期间变化;保持同一 keyed gate 并重读最新快照, // 让本轮亲自纠正可能已落地的旧云端状态。 } return fmt.Errorf("rtc mute state kept changing during %d reconcile attempts", rtcMuteProjectionMaxAttempts) } func (s *Service) acquireRTCMuteProjection(ctx context.Context, key rtcMuteProjectionKey) (func(), error) { s.rtcMuteGateMu.Lock() if s.rtcMuteGates == nil { s.rtcMuteGates = make(map[rtcMuteProjectionKey]*rtcMuteProjectionGate) } gate := s.rtcMuteGates[key] if gate == nil { gate = &rtcMuteProjectionGate{token: make(chan struct{}, 1)} gate.token <- struct{}{} s.rtcMuteGates[key] = gate } gate.refs++ s.rtcMuteGateMu.Unlock() select { case <-ctx.Done(): s.releaseRTCMuteProjectionRef(key, gate) return nil, ctx.Err() case <-gate.token: return func() { gate.token <- struct{}{} s.releaseRTCMuteProjectionRef(key, gate) }, nil } } func (s *Service) releaseRTCMuteProjectionRef(key rtcMuteProjectionKey, gate *rtcMuteProjectionGate) { s.rtcMuteGateMu.Lock() defer s.rtcMuteGateMu.Unlock() gate.refs-- if gate.refs == 0 && s.rtcMuteGates[key] == gate { delete(s.rtcMuteGates, key) } } func (s *Service) waitRTCMuteProjectionRate(ctx context.Context) error { s.rtcMuteRateMu.Lock() now := time.Now() slot := now if s.rtcMuteNextCall.After(slot) { slot = s.rtcMuteNextCall } s.rtcMuteNextCall = slot.Add(rtcMuteProjectionInterval) s.rtcMuteRateMu.Unlock() wait := time.Until(slot) if wait <= 0 { return nil } timer := time.NewTimer(wait) defer timer.Stop() select { case <-ctx.Done(): return ctx.Err() case <-timer.C: return nil } }