package service import ( "context" "sort" "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" ) func (s *Service) requireRoomOpenForEntry(ctx context.Context, roomID string) error { closed, err := s.isRoomClosed(ctx, roomID) if err != nil || !closed { return err } // 进房入口使用专门错误码,gateway 可以稳定映射成客户端房间关闭态。 return xerr.New(xerr.RoomClosed, "room closed") } func (s *Service) isRoomClosed(ctx context.Context, roomID string) (bool, error) { // 关闭状态先读 rooms 元数据,避免为了拒绝进房恢复完整 Room Cell。 roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID) if err != nil || !exists { return false, err } return isClosedRoomStatus(roomMeta.Status), nil } func isClosedRoomStatus(status string) bool { value := strings.TrimSpace(status) // 空状态兼容测试构造;非 active 一律按不可进入处理,closing 也不接收新用户。 return value != "" && !strings.EqualFold(value, state.RoomStatusActive) } // CloseRoom 关闭房间,统一清理 presence、麦位、读模型状态和房间外事件。 func (s *Service) CloseRoom(ctx context.Context, req *roomv1.CloseRoomRequest) (*roomv1.CloseRoomResponse, error) { ctx = contextFromMeta(ctx, req.GetMeta()) cmd := command.CloseRoom{ Base: baseFromMeta(req.GetMeta()), Reason: strings.TrimSpace(req.GetReason()), } if cmd.Reason == "" { cmd.Reason = "closed_by_owner" } if isAdminReopenReason(cmd.Reason) { return s.reopenRoom(ctx, cmd) } rtcKickTargets := make([]int64, 0) result, err := s.mutateRoom(ctx, cmd, true, func(now time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if current == nil { return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found") } if current.Status == state.RoomStatusClosed { // 关房命令幂等,重复请求只返回当前关闭快照,不重复写 RoomClosed 事件。 return mutationResult{snapshot: current.ToProto()}, nil, nil } if current.Status != state.RoomStatusActive && current.Status != state.RoomStatusClosing { // creating 等非开放状态不允许用 CloseRoom 直接跳过创建收尾语义。 return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not active") } if !isAdminCloseReason(cmd.Reason) { if err := requireOwnerPresent(current, cmd.ActorUserID()); err != nil { return mutationResult{}, nil, err } } kickTargets := closeRoomKickTargets(current) if len(kickTargets) > 0 { rtcKickTargets = append(rtcKickTargets[:0], kickTargets...) } type occupiedMicSeat struct { userID int64 seatNo int32 micSessionID string } occupiedSeats := make([]occupiedMicSeat, 0, len(current.MicSeats)) for _, seat := range current.MicSeats { if seat.UserID != 0 && seat.MicSessionID != "" { // 清场前先保存占麦会话,后续 ClearMicSession 会抹掉 mic_session_id。 occupiedSeats = append(occupiedSeats, occupiedMicSeat{ userID: seat.UserID, seatNo: seat.SeatNo, micSessionID: seat.MicSessionID, }) } } // 关房是 Room Cell 的终态变更,presence 和麦位都在同一版本下清空。 current.Status = state.RoomStatusClosed current.OnlineUsers = map[int64]*state.RoomUserState{} for index := range current.MicSeats { current.ClearMicSession(index) } current.Version++ records := make([]outbox.Record, 0, len(occupiedSeats)+len(kickTargets)+1) closedEvent, err := outbox.Build(current.RoomID, "RoomClosed", current.Version, now, &roomeventsv1.RoomClosed{ ActorUserId: cmd.ActorUserID(), Reason: cmd.Reason, }) if err != nil { return mutationResult{}, nil, err } records = append(records, closedEvent) for _, targetUserID := range kickTargets { // 关房清场复用 RoomUserKicked 事实,notice-service 和腾讯 IM 群成员移除可沿用同一条消费链路。 kickEvent, err := outbox.Build(current.RoomID, "RoomUserKicked", current.Version, now, &roomeventsv1.RoomUserKicked{ ActorUserId: cmd.ActorUserID(), TargetUserId: targetUserID, }) if err != nil { return mutationResult{}, nil, err } records = append(records, kickEvent) } for _, seat := range occupiedSeats { // 关房是 Room Cell 统一清场,给每个占麦 session 单独写 down 事件,保证用户时长能闭合。 micEvent, err := buildMicDownEventForSeat(current.RoomID, current.Version, now, cmd.ActorUserID(), seat.userID, seat.seatNo, seat.micSessionID, "room_closed") if err != nil { return mutationResult{}, nil, err } records = append(records, micEvent) } return mutationResult{ snapshot: current.ToProto(), roomStatus: state.RoomStatusClosed, syncEvent: &tencentim.RoomEvent{ EventID: closedEvent.EventID, RoomID: current.RoomID, EventType: "room_closed", ActorUserID: cmd.ActorUserID(), RoomVersion: current.Version, Attributes: map[string]string{ "reason": cmd.Reason, }, }, }, records, nil }) if err != nil { return nil, err } response := &roomv1.CloseRoomResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, } if result.applied && s.rtcUserRemover != nil { for _, targetUserID := range rtcKickTargets { // RTC 是房间外连接态:关房事实已提交后再逐个移除,失败不回滚 Room Cell。 _ = s.rtcUserRemover.RemoveUserByStrRoomID(ctx, cmd.RoomID(), targetUserID) } } return response, nil } func isAdminCloseReason(reason string) bool { return strings.EqualFold(strings.TrimSpace(reason), "admin_closed") } func isAdminReopenReason(reason string) bool { return strings.EqualFold(strings.TrimSpace(reason), "admin_reopen") } func (s *Service) reopenRoom(ctx context.Context, cmd command.CloseRoom) (*roomv1.CloseRoomResponse, error) { result, err := s.mutateRoom(ctx, cmd, true, func(_ time.Time, current *state.RoomState, _ *rank.LocalRank) (mutationResult, []outbox.Record, error) { if current == nil { return mutationResult{}, nil, xerr.New(xerr.NotFound, "room not found") } if current.Status == state.RoomStatusActive { return mutationResult{snapshot: current.ToProto()}, nil, nil } if current.Status != state.RoomStatusClosed { return mutationResult{}, nil, xerr.New(xerr.Conflict, "room is not closed") } current.Status = state.RoomStatusActive current.Version++ return mutationResult{ snapshot: current.ToProto(), roomStatus: state.RoomStatusActive, }, nil, nil }) if err != nil { return nil, err } return &roomv1.CloseRoomResponse{ Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()), Room: result.snapshot, }, nil } func closeRoomKickTargets(current *state.RoomState) []int64 { if current == nil { return nil } seen := make(map[int64]struct{}, len(current.OnlineUsers)+len(current.MicSeats)) for userID := range current.OnlineUsers { if userID > 0 { seen[userID] = struct{}{} } } for _, seat := range current.MicSeats { if seat.UserID > 0 { seen[seat.UserID] = struct{}{} } } targets := make([]int64, 0, len(seen)) for userID := range seen { targets = append(targets, userID) } sort.Slice(targets, func(left, right int) bool { return targets[left] < targets[right] }) return targets }