174 lines
5.4 KiB
Go
174 lines
5.4 KiB
Go
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
|
||
}
|
||
}
|