package service import ( "context" "log/slog" "slices" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/logx" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/state" ) const ( roomPresenceStatusActive = "active" roomPresenceStatusLeft = "left" ) // GetCurrentRoom 返回当前用户可恢复房间的只读探测结果。 // 该方法先查用户级 presence 读模型,再用 Room Cell 快照二次校验,避免读模型滞后导致恢复已关闭或已踢出的房间。 func (s *Service) GetCurrentRoom(ctx context.Context, req *roomv1.GetCurrentRoomRequest) (*roomv1.GetCurrentRoomResponse, error) { ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode()) userID := req.GetUserId() if userID <= 0 { return nil, xerr.New(xerr.InvalidArgument, "user_id is required") } serverTimeMS := s.clock.Now().UnixMilli() presence, exists, err := s.repository.GetCurrentRoomPresence(ctx, userID) if err != nil { return nil, err } if !exists { // 没有用户当前房间投影时直接返回空结果,不扫描房间列表或所有 Room Cell。 return emptyCurrentRoomResponse(serverTimeMS), nil } // presence 只是恢复入口的索引,必须用 Room Cell 快照确认房间仍 active 且用户仍在房间内。 snapshot, err := s.currentSnapshot(ctx, presence.RoomID) if err != nil { return nil, err } if !currentRoomSnapshotRestorable(snapshot, userID, serverTimeMS) { // 查询语义必须保持只读;读模型滞后由后续命令投影或专项补偿修复。 return emptyCurrentRoomResponse(serverTimeMS), nil } roomUser := findProtoUser(snapshot, userID) seat := protoSeatByUser(snapshot, userID) role := presence.Role if roomUser != nil { role = roomUser.GetRole() } publishState := "" micSessionID := "" needRTCToken := false if seat != nil { // 上麦用户回前台需要 RTC token 恢复媒体链路;普通房间用户只需要重新加 IM 群。 publishState = seat.GetPublishState() micSessionID = seat.GetMicSessionId() needRTCToken = publishState == state.MicPublishPending || publishState == state.MicPublishPublishing } return &roomv1.GetCurrentRoomResponse{ HasCurrentRoom: true, RoomId: snapshot.GetRoomId(), RoomVersion: snapshot.GetVersion(), Role: role, MicSessionId: micSessionID, PublishState: publishState, NeedJoinImGroup: true, NeedRtcToken: needRTCToken, ServerTimeMs: serverTimeMS, }, nil } // emptyCurrentRoomResponse 保留 server_time_ms,方便客户端用同一套时钟校正逻辑处理空结果。 func emptyCurrentRoomResponse(serverTimeMS int64) *roomv1.GetCurrentRoomResponse { return &roomv1.GetCurrentRoomResponse{ServerTimeMs: serverTimeMS} } // currentRoomSnapshotRestorable 判定读模型指向的房间是否仍可被客户端恢复。 func currentRoomSnapshotRestorable(snapshot *roomv1.RoomSnapshot, userID int64, nowMS int64) bool { if snapshot == nil || snapshot.GetRoomId() == "" || snapshot.GetStatus() != state.RoomStatusActive { // 非 active 房间不能恢复,即使用户级 presence 投影还没被清理。 return false } if snapshotUserBanned(snapshot, userID, nowMS) { // ban 优先于旧 presence,避免被踢用户通过本地恢复入口重新收消息。 return false } return findProtoUser(snapshot, userID) != nil } // projectRoomPresenceBestEffort 用最新快照刷新“用户当前房间”读模型。 // 读模型失败不能影响命令成功语义,但必须记录日志,避免恢复入口长期缺失无人感知。 func (s *Service) projectRoomPresenceBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot) { projection := roomPresenceProjectionFromSnapshot(snapshot, s.clock.Now().UnixMilli()) if projection.RoomID == "" { return } if err := s.repository.ProjectRoomPresence(ctx, projection); err != nil { logx.Error(ctx, "room_presence_project_failed", err, slog.String("component", "room_presence_projector"), slog.String("room_id", projection.RoomID), slog.Int64("room_version", projection.RoomVersion), ) } } // projectRoomPresenceForCommandBestEffort 按命令类型选择最小投影;Join/Leave 热路径不能再重写整房间在线集合。 func (s *Service) projectRoomPresenceForCommandBestEffort(ctx context.Context, cmd command.Command, snapshot *roomv1.RoomSnapshot, joinedUser *roomv1.RoomUser) { switch cmd.Type() { case (command.JoinRoom{}).Type(): s.upsertJoinedRoomPresenceBestEffort(ctx, snapshot, cmd.ActorUserID(), joinedUser) case (command.LeaveRoom{}).Type(): s.markLeftRoomPresenceBestEffort(ctx, snapshot, cmd.ActorUserID()) default: // Kick/Close/SystemEvict/Mic 变更仍先保留全量投影,确保复杂状态收敛不因本次 Join/Leave 优化改变语义。 s.projectRoomPresenceBestEffort(ctx, snapshot) } } func (s *Service) upsertJoinedRoomPresenceBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot, userID int64, joinedUser *roomv1.RoomUser) { projection := roomPresenceProjectionForUser(snapshot, userID, joinedUser, s.clock.Now().UnixMilli()) if projection.RoomID == "" || projection.UserID <= 0 { return } if err := s.repository.UpsertRoomUserPresence(ctx, projection); err != nil { logx.Error(ctx, "room_presence_join_project_failed", err, slog.String("component", "room_presence_projector"), slog.String("room_id", projection.RoomID), slog.Int64("user_id", projection.UserID), slog.Int64("room_version", projection.RoomVersion), ) } } func (s *Service) markLeftRoomPresenceBestEffort(ctx context.Context, snapshot *roomv1.RoomSnapshot, userID int64) { projection := roomPresenceLeaveProjection(snapshot, userID, s.clock.Now().UnixMilli()) if projection.RoomID == "" || projection.UserID <= 0 { return } if err := s.repository.MarkRoomUserPresenceLeft(ctx, projection); err != nil { logx.Error(ctx, "room_presence_leave_project_failed", err, slog.String("component", "room_presence_projector"), slog.String("room_id", projection.RoomID), slog.Int64("user_id", projection.UserID), slog.Int64("room_version", projection.RoomVersion), ) } } // roomPresenceProjectionFromSnapshot 把 Room Cell 快照压缩成用户当前房间读模型。 // 读模型只保存恢复入口需要的字段,不复制完整 RoomState,也不作为权限权威来源。 func roomPresenceProjectionFromSnapshot(snapshot *roomv1.RoomSnapshot, updatedAtMS int64) RoomPresenceSnapshot { if snapshot == nil || snapshot.GetRoomId() == "" { return RoomPresenceSnapshot{} } // 先按 user_id 建立麦位索引,投影用户时能补齐发流状态而不做双层循环。 seatsByUser := make(map[int64]*roomv1.SeatState, len(snapshot.GetMicSeats())) for _, seat := range snapshot.GetMicSeats() { if seat.GetUserId() > 0 { seatsByUser[seat.GetUserId()] = seat } } users := make([]RoomPresence, 0, len(snapshot.GetOnlineUsers())) if snapshot.GetStatus() == state.RoomStatusActive { // 只有 active 房间才投影 active 用户;关闭房间通过空 users 让 repository 收敛旧 presence。 for _, user := range snapshot.GetOnlineUsers() { if user.GetUserId() <= 0 { continue } entry := RoomPresence{ AppCode: snapshot.GetAppCode(), UserID: user.GetUserId(), RoomID: snapshot.GetRoomId(), Role: user.GetRole(), RoomVersion: snapshot.GetVersion(), Status: roomPresenceStatusActive, JoinedAtMS: user.GetJoinedAtMs(), LastSeenAtMS: user.GetLastSeenAtMs(), UpdatedAtMS: updatedAtMS, } if seat := seatsByUser[user.GetUserId()]; seat != nil { // 发流状态只对当前上麦用户有意义,非上麦用户保持空字段。 entry.PublishState = seat.GetPublishState() entry.MicSessionID = seat.GetMicSessionId() } users = append(users, entry) } } return RoomPresenceSnapshot{ AppCode: snapshot.GetAppCode(), RoomID: snapshot.GetRoomId(), RoomVersion: snapshot.GetVersion(), RoomStatus: snapshot.GetStatus(), Users: users, UpdatedAtMS: updatedAtMS, } } func roomPresenceProjectionForUser(snapshot *roomv1.RoomSnapshot, userID int64, user *roomv1.RoomUser, updatedAtMS int64) RoomPresence { if snapshot == nil || snapshot.GetRoomId() == "" || snapshot.GetStatus() != state.RoomStatusActive || userID <= 0 { return RoomPresence{} } if user == nil || user.GetUserId() != userID { user = findProtoUser(snapshot, userID) } if user == nil || user.GetUserId() <= 0 { return RoomPresence{} } entry := RoomPresence{ AppCode: snapshot.GetAppCode(), UserID: user.GetUserId(), RoomID: snapshot.GetRoomId(), Role: user.GetRole(), RoomVersion: snapshot.GetVersion(), Status: roomPresenceStatusActive, JoinedAtMS: user.GetJoinedAtMs(), LastSeenAtMS: user.GetLastSeenAtMs(), UpdatedAtMS: updatedAtMS, } for _, seat := range snapshot.GetMicSeats() { if seat.GetUserId() != user.GetUserId() { continue } // JoinRoom 可能是重连到已在麦用户;单用户投影仍要保留麦上发流状态。 entry.PublishState = seat.GetPublishState() entry.MicSessionID = seat.GetMicSessionId() break } return entry } func roomPresenceLeaveProjection(snapshot *roomv1.RoomSnapshot, userID int64, updatedAtMS int64) RoomPresence { if snapshot == nil || snapshot.GetRoomId() == "" || userID <= 0 { return RoomPresence{} } return RoomPresence{ AppCode: snapshot.GetAppCode(), UserID: userID, RoomID: snapshot.GetRoomId(), RoomVersion: snapshot.GetVersion(), Status: roomPresenceStatusLeft, UpdatedAtMS: updatedAtMS, } } // containsUserID 是当前房间恢复入口的本地集合查询,独立保留便于后续替换为二分查找。 func containsUserID(values []int64, target int64) bool { return slices.Contains(values, target) }