package service import ( "context" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" roomv1 "hyapp.local/api/proto/room/v1" "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。 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 current.MuteUsers[cmd.TargetUserID] == cmd.Muted { // 重复设置同一禁言状态是业务 no-op,仍由 pipeline 写 command log 做幂等保护。 return mutationResult{snapshot: current.ToProto()}, nil, nil } if cmd.Muted { // map set 表达禁言集合,true 才会被快照导出。 current.MuteUsers[cmd.TargetUserID] = true } else { // 解除禁言直接删除集合成员。 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 } 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, }, }, []outbox.Record{muteEvent}, nil }) if err != nil { return nil, err } 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 } _, 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 }