163 lines
5.0 KiB
Go
163 lines
5.0 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 (
|
||
// 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 deadline;Room 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
|
||
}
|
||
}
|