package service import ( "context" "strings" "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" ) const defaultSystemEvictReason = "platform_moderation" // SystemEvictUser 把平台封禁、风控命中或后台治理用户从当前 Room Cell 驱逐。 // 该命令绕过房间管理员权限矩阵,但仍必须经过 Room Cell、command log、outbox 和快照链路。 func (s *Service) SystemEvictUser(ctx context.Context, req *roomv1.SystemEvictUserRequest) (*roomv1.SystemEvictUserResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) targetUserID := req.GetTargetUserId() if targetUserID <= 0 { // 系统驱逐一定要有明确目标用户,不能依赖 actor 兜底误踢触发人。 return nil, xerr.New(xerr.InvalidArgument, "target_user_id is required") } roomID := strings.TrimSpace(req.GetMeta().GetRoomId()) if roomID == "" { // 上游不知道房间时从当前房间读模型定位;该读模型来自 RoomSnapshot 投影,不扫描所有 Cell。 presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, targetUserID) if err != nil { return nil, err } if !exists { return &roomv1.SystemEvictUserResponse{ HadCurrentRoom: false, Result: commandResult(false, 0, s.clock.Now()), }, nil } // presence 只提供候选房间,后续 mutateRoom 仍会恢复并校验 Room Cell 权威状态。 roomID = presence.RoomID } base := baseFromMeta(req.GetMeta()) base.Room = roomID if base.ActorID <= 0 { // mutateRoom 需要非零 actor 作为命令元信息;系统驱逐不使用它做房间权限判断。 base.ActorID = targetUserID } cmd := command.SystemEvictUser{ Base: base, TargetUserID: targetUserID, OperatorUserID: req.GetOperatorUserId(), Reason: normalizeSystemEvictReason(req.GetReason()), BanFromRoom: req.GetBanFromRoom(), } 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 } _, inRoom := current.OnlineUsers[cmd.TargetUserID] _, onSeat := current.SeatByUser(cmd.TargetUserID) _, alreadyBanned := current.BanUsers[cmd.TargetUserID] if !inRoom && !onSeat && !current.AdminUsers[cmd.TargetUserID] && (!cmd.BanFromRoom || alreadyBanned) { // 重复系统驱逐保持幂等;没有残留状态时只返回当前快照。 return mutationResult{snapshot: current.ToProto()}, nil, nil } // 只有用户确实还在业务房间或麦位上,才需要额外调用 RTC 服务端踢出媒体连接。 shouldKickRTC = inRoom || onSeat var releasedSeatNo int32 var releasedMicSessionID string if seat, exists := current.SeatByUser(cmd.TargetUserID); exists { // 系统驱逐释放麦位时同样写 mic down 事件,麦上时长统计不能只看 kick 事件。 index := current.SeatIndex(seat.SeatNo) releasedSeatNo = seat.SeatNo releasedMicSessionID = current.MicSeats[index].MicSessionID current.ClearMicSession(index) } delete(current.OnlineUsers, cmd.TargetUserID) delete(current.AdminUsers, cmd.TargetUserID) if cmd.BanFromRoom { // BanFromRoom 由治理入口决定;只驱逐不封房间时,用户后续可按产品策略重新进房。 current.BanUsers[cmd.TargetUserID] = state.UserModerationState{ UserID: cmd.TargetUserID, ActorUserID: 0, CreatedAtMS: now.UnixMilli(), } } current.Version++ // 复用 RoomUserKicked 事实类型,让客户端和活动消费者不用区分管理踢人与系统驱逐的清场语义。 evictEvent, err := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{ ActorUserId: 0, TargetUserId: cmd.TargetUserID, }) if err != nil { return mutationResult{}, nil, err } records := make([]outbox.Record, 0, 2) if releasedMicSessionID != "" { micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, 0, cmd.TargetUserID, releasedSeatNo, releasedMicSessionID, cmd.Reason) if err != nil { return mutationResult{}, nil, err } records = append(records, micEvent) } records = append(records, evictEvent) return mutationResult{ snapshot: current.ToProto(), syncEvent: &tencentim.RoomEvent{ EventID: evictEvent.EventID, RoomID: current.RoomID, EventType: "room_user_kicked", TargetUserID: cmd.TargetUserID, RoomVersion: current.Version, Attributes: map[string]string{"reason": cmd.Reason}, }, }, records, nil }) if err != nil { return nil, err } // Room Cell 提交后再做 RTC 踢人:RTC 是房间外副作用,失败不能回滚已经落盘的治理事实。 response := &roomv1.SystemEvictUserResponse{ HadCurrentRoom: true, Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, RoomId: roomID, } if result.applied && shouldKickRTC && s.rtcUserRemover != nil { if err := s.rtcUserRemover.RemoveUserByStrRoomID(ctx, roomID, targetUserID); err != nil { response.RtcKickError = err.Error() } else { response.RtcKicked = true } } return response, nil } // normalizeSystemEvictReason 固定治理事件默认原因,避免空 reason 进入 outbox 和 IM 属性。 func normalizeSystemEvictReason(reason string) string { reason = strings.TrimSpace(reason) if reason == "" { return defaultSystemEvictReason } return reason }