2026-07-21 18:57:09 +08:00

163 lines
5.0 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 (
// forbid_send_msg 官方单接口上限为 200 QPS双节点各 100 QPS避免共享 SDKAppID 被单服务打满。
imMuteProjectionInterval = 10 * time.Millisecond
// 云 API 执行期间若 Room Cell 状态继续变化,最多在同一门闩内追赶三次,之后交给 outbox 退避重试。
imMuteProjectionMaxAttempts = 3
// REST client 单次上限为 5s总预算额外覆盖进程内门闩、限速和最新快照复核。
imMuteProjectionTimeout = 6 * time.Second
)
type imMuteProjectionKey struct {
appCode string
roomID string
userID int64
}
// imMuteProjectionGate 让同一 app/room/user 的旧 mute 和新 unmute 在单进程内串行,
// 同时允许等待者遵守 context deadlineRoom Cell 最新状态仍是每次云端写入的唯一输入。
type imMuteProjectionGate struct {
token chan struct{}
refs int
}
// syncIMUserMuteFromOutbox 只消费已经随 Room Cell 命令原子提交的 RoomUserMuted 事实。
// 普通群消息不会调用该路径,因此腾讯 IM 回调关闭后,共享 SDKAppID 的其它项目不再依赖本服务延迟或可用性。
func (s *Service) syncIMUserMuteFromOutbox(ctx context.Context, envelope *roomeventsv1.EventEnvelope) error {
if s == nil || s.imUserMuter == 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 im mute outbox event: %w", err)
}
if event.GetTargetUserId() <= 0 || strings.TrimSpace(envelope.GetRoomId()) == "" {
return fmt.Errorf("im mute outbox event is incomplete")
}
return s.reconcileIMUserMute(ctx, envelope.GetRoomId(), event.GetTargetUserId())
}
// reconcileIMUserMute 在每次 forbid_send_msg 前后重读 Room Cell。
// outbox 可能因网络失败重放旧 mute 事件,不能直接采用历史 payload.muted否则会覆盖较新的 unmute。
func (s *Service) reconcileIMUserMute(parent context.Context, roomID string, userID int64) error {
if s == nil || s.imUserMuter == nil {
return nil
}
roomID = strings.TrimSpace(roomID)
if roomID == "" || userID <= 0 {
return fmt.Errorf("im mute reconcile target is incomplete")
}
ctx, cancel := context.WithTimeout(parent, imMuteProjectionTimeout)
defer cancel()
release, err := s.acquireIMMuteProjection(ctx, imMuteProjectionKey{
appCode: appcode.FromContext(ctx),
roomID: roomID,
userID: userID,
})
if err != nil {
return err
}
defer release()
for attempt := 1; attempt <= imMuteProjectionMaxAttempts; attempt++ {
before, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return err
}
if before == nil {
// 房间已被物理清理时群也不再是活跃投递通道,历史禁言投影可以直接结束。
return nil
}
desiredMuted := containsID(before.GetMuteUserIds(), userID)
if err := s.waitIMMuteProjectionRate(ctx); err != nil {
return err
}
if err := s.imUserMuter.SetRoomGroupMemberMuted(ctx, roomID, userID, desiredMuted); err != nil {
return err
}
after, err := s.currentSnapshot(ctx, roomID)
if err != nil {
return err
}
if after == nil || containsID(after.GetMuteUserIds(), userID) == desiredMuted {
return nil
}
// 状态在腾讯请求飞行期间变化;保持同一 keyed gate 并立即投影最新值,避免本轮留下旧云端状态。
}
return fmt.Errorf("im mute state kept changing during %d reconcile attempts", imMuteProjectionMaxAttempts)
}
func (s *Service) acquireIMMuteProjection(ctx context.Context, key imMuteProjectionKey) (func(), error) {
s.imMuteGateMu.Lock()
if s.imMuteGates == nil {
s.imMuteGates = make(map[imMuteProjectionKey]*imMuteProjectionGate)
}
gate := s.imMuteGates[key]
if gate == nil {
gate = &imMuteProjectionGate{token: make(chan struct{}, 1)}
gate.token <- struct{}{}
s.imMuteGates[key] = gate
}
gate.refs++
s.imMuteGateMu.Unlock()
select {
case <-ctx.Done():
s.releaseIMMuteProjectionRef(key, gate)
return nil, ctx.Err()
case <-gate.token:
return func() {
gate.token <- struct{}{}
s.releaseIMMuteProjectionRef(key, gate)
}, nil
}
}
func (s *Service) releaseIMMuteProjectionRef(key imMuteProjectionKey, gate *imMuteProjectionGate) {
s.imMuteGateMu.Lock()
defer s.imMuteGateMu.Unlock()
gate.refs--
if gate.refs == 0 && s.imMuteGates[key] == gate {
delete(s.imMuteGates, key)
}
}
func (s *Service) waitIMMuteProjectionRate(ctx context.Context) error {
s.imMuteRateMu.Lock()
now := time.Now()
slot := now
if s.imMuteNextCall.After(slot) {
slot = s.imMuteNextCall
}
s.imMuteNextCall = slot.Add(imMuteProjectionInterval)
s.imMuteRateMu.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
}
}