174 lines
5.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 deadlinerefs 由 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
}
}