package service import ( "context" "log/slog" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/logx" "hyapp/pkg/tencentim" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" ) // MuteUser 修改用户禁言状态。 func (s *Service) MuteUser(ctx context.Context, req *roomv1.MuteUserRequest) (*roomv1.MuteUserResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // 禁言状态由 room-service 持有:腾讯云 IM 发言前回调查 CheckSpeakPermission,RTC 麦位则同步为服务端静音态。 cmd := command.MuteUser{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), Muted: req.GetMuted(), } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required") } if cmd.TargetUserID == cmd.ActorUserID() { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "cannot mute self") } if err := requireActorPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil { return mutationResult{}, nil, err } if cmd.Muted { if err := s.requireTargetWithoutVIPBenefit(ctx, req.GetMeta().GetRequestId(), cmd.TargetUserID, vipBenefitAntiMute); err != nil { // anti_mute 只阻止普通房主/管理员新增禁言;解除既有禁言必须始终允许。 return mutationResult{}, nil, err } } if current.MuteUsers[cmd.TargetUserID] == cmd.Muted { // 重复设置同一禁言状态是业务 no-op,仍由 pipeline 写 command log 做幂等保护。 return mutationResult{snapshot: current.ToProto()}, nil, nil } var mutedSeat *state.MicSeat if cmd.Muted { // map set 是公屏、上麦和开麦权限的统一事实;麦位静音只是这个事实的 RTC 投影。 current.MuteUsers[cmd.TargetUserID] = true if seat, exists := current.SeatByUser(cmd.TargetUserID); exists && !seat.MicMuted { // 房间禁言不能只依赖目标客户端执行 muteLocalAudio;先把当前麦位标成 muted, // 让快照和 room_mic_mute_changed 都保持静音,目标用户解除房间禁言前也不能自行开麦。 index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].MicMuted = true updated := current.MicSeats[index] mutedSeat = &updated } } else { // 解除房间禁言只恢复“允许开麦”;不替用户自动打开本地麦克风,当前麦位仍需显式 SetMicMute(false)。 delete(current.MuteUsers, cmd.TargetUserID) } current.Version++ // 禁言变更进入 outbox,外部系统可以做审核和操作审计。 muteEvent, err := outbox.Build(current.RoomID, "RoomUserMuted", current.Version, now, &roomeventsv1.RoomUserMuted{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, Muted: cmd.Muted, }) if err != nil { return mutationResult{}, nil, err } records := make([]outbox.Record, 0, 2) if mutedSeat != nil { micChanged := roomMicChangedEvent(&roomeventsv1.RoomMicChanged{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, FromSeat: mutedSeat.SeatNo, ToSeat: mutedSeat.SeatNo, Action: "mic_mute", MicSessionId: mutedSeat.MicSessionID, PublishState: mutedSeat.PublishState, PublishDeadlineMs: mutedSeat.PublishDeadlineMS, MicMuted: true, SeatStatus: mutedSeat.SeatStatus(), TargetGiftValue: roomUserGiftValue(current, cmd.TargetUserID), }, command.UserDisplayProfile{}) micEvent, err := outbox.Build(current.RoomID, "RoomMicChanged", current.Version, now, micChanged) if err != nil { return mutationResult{}, nil, err } records = append(records, micEvent) } records = append(records, muteEvent) return mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: muteEvent.EventID, RoomID: current.RoomID, EventType: "room_user_muted", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, RoomVersion: current.Version, }, }, records, nil }) if err != nil { return nil, err } if s.rtcUserAudioBlocker != nil { // 低延迟路径在 Room Cell 提交后立即更新 RTC;失败不能回滚已经落盘的禁言事实, // 同一条 RoomUserMuted outbox 会按最新快照继续补偿,避免客户端暂时断线造成永久漏禁。 blocked := containsID(result.snapshot.GetMuteUserIds(), cmd.TargetUserID) if err := s.rtcUserAudioBlocker.SetUserAudioBlockedByStrRoomID(ctx, cmd.RoomID(), cmd.TargetUserID, blocked); err != nil { logx.Warn(ctx, "rtc_user_audio_block_sync_failed", slog.String("room_id", cmd.RoomID()), slog.Int64("target_user_id", cmd.TargetUserID), slog.Bool("blocked", blocked), slog.String("error", err.Error()), ) } } return &roomv1.MuteUserResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, }, nil } // KickUser 把目标用户踢出房间并移除其麦位与 presence。 func (s *Service) KickUser(ctx context.Context, req *roomv1.KickUserRequest) (*roomv1.KickUserResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) // KickUser 同时修改 presence、麦位和 ban 集合,是典型 Room Cell 强关联命令。 cmd := command.KickUser{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), DurationMS: req.GetDurationMs(), } shouldKickRTC := false result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required") } if cmd.TargetUserID == cmd.ActorUserID() { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "cannot kick self") } if cmd.DurationMS < 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "duration_ms must be non-negative") } if err := requireActorPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } if err := canManageTarget(current, cmd.ActorUserID(), cmd.TargetUserID); err != nil { return mutationResult{}, nil, err } if err := s.requireTargetWithoutVIPBenefit(ctx, req.GetMeta().GetRequestId(), cmd.TargetUserID, vipBenefitAntiKick); err != nil { // 平台封禁、风控和后台治理使用独立 SystemEvictUser;只有普通房间治理受 anti_kick 保护。 return mutationResult{}, nil, err } _, inRoom := current.OnlineUsers[cmd.TargetUserID] _, onSeat := current.SeatByUser(cmd.TargetUserID) // 只有目标确实还在业务房间或麦位上,才需要请求 TRTC 服务端移除实时音频连接。 shouldKickRTC = inRoom || onSeat var releasedSeatNo int32 var releasedMicSessionID string if seat, exists := current.SeatByUser(cmd.TargetUserID); exists { // 被踢用户如果在麦上,必须先释放麦位,避免恢复后出现幽灵占位。 index := current.SeatIndex(seat.SeatNo) releasedSeatNo = seat.SeatNo releasedMicSessionID = current.MicSeats[index].MicSessionID current.ClearMicSession(index) } // ban 集合会让后续 JoinRoom 和 VerifyRoomPresence 都失败。 delete(current.OnlineUsers, cmd.TargetUserID) delete(current.AdminUsers, cmd.TargetUserID) expiresAtMS := int64(0) nowMS := now.UnixMilli() if cmd.DurationMS > 0 { expiresAtMS = nowMS + cmd.DurationMS if expiresAtMS <= nowMS { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "duration_ms is too large") } } current.BanUsers[cmd.TargetUserID] = state.UserModerationState{ UserID: cmd.TargetUserID, ActorUserID: cmd.ActorUserID(), CreatedAtMS: nowMS, ExpiresAtMS: expiresAtMS, } current.Version++ kickEvent, err := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, }) if err != nil { return mutationResult{}, nil, err } records := make([]outbox.Record, 0, 2) if releasedMicSessionID != "" { // 踢人释放麦位必须也生成 down 事实,时长聚合不能从 RoomUserKicked 反推麦位结束。 micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), cmd.TargetUserID, releasedSeatNo, releasedMicSessionID, "kick") if err != nil { return mutationResult{}, nil, err } records = append(records, micEvent) } records = append(records, kickEvent) return mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: kickEvent.EventID, RoomID: current.RoomID, EventType: "room_user_kicked", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, RoomVersion: current.Version, }, }, records, nil }) if err != nil { return nil, err } resp := &roomv1.KickUserResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, } if result.applied && shouldKickRTC && s.rtcUserRemover != nil { // RTC 是房间外连接态:踢人事实已提交后再调用 TRTC,失败不回滚 Room Cell,但要回传给调用方做告警。 if err := s.rtcUserRemover.RemoveUserByStrRoomID(ctx, cmd.RoomID(), cmd.TargetUserID); err != nil { resp.RtcKickError = err.Error() } else { resp.RtcKicked = true } } return resp, nil } // UnbanUser 解除房间 ban;不会恢复 presence、管理员、麦位或 RTC 推流事实。 func (s *Service) UnbanUser(ctx context.Context, req *roomv1.UnbanUserRequest) (*roomv1.UnbanUserResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) cmd := command.UnbanUser{ Base: baseFromMeta(req.GetMeta()), TargetUserID: req.GetTargetUserId(), } result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if err := requireActiveRoom(current); err != nil { return mutationResult{}, nil, err } if cmd.TargetUserID <= 0 { return mutationResult{}, nil, xerr.New(xerr.InvalidArgument, "target_user_id is required") } if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } if _, exists := current.BanUsers[cmd.TargetUserID]; !exists { // 重复 unban 是 no-op,仍写 command log 确保 command_id 幂等可判定。 return mutationResult{snapshot: current.ToProto()}, nil, nil } delete(current.BanUsers, cmd.TargetUserID) current.Version++ unbanEvent, err := outbox.Build(current.RoomID, "RoomUserUnbanned", current.Version, now, &roomeventsv1.RoomUserUnbanned{ ActorUserId: cmd.ActorUserID(), TargetUserId: cmd.TargetUserID, }) if err != nil { return mutationResult{}, nil, err } return mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: unbanEvent.EventID, RoomID: current.RoomID, EventType: "room_user_unbanned", ActorUserID: cmd.ActorUserID(), TargetUserID: cmd.TargetUserID, RoomVersion: current.Version, }, }, []outbox.Record{unbanEvent}, nil }) if err != nil { return nil, err } return &roomv1.UnbanUserResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, }, nil }