package service import ( "context" "fmt" "google.golang.org/protobuf/proto" roomv1 "hyapp.local/api/proto/room/v1" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/room/cell" "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/rank" "hyapp/services/room-service/internal/room/state" "hyapp/services/room-service/internal/router" ) // ensureCell 确保当前节点拥有房间执行权,并在本地缺少 Cell 时从 MySQL 恢复。 func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, router.Lease, error) { // 每次写命令先刷新或接管 lease,确保同一房间同一时刻只有一个执行节点。 lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, s.clock.Now(), s.leaseTTL) if err != nil { return nil, router.Lease{}, err } if lease.NodeID != s.nodeID { // 仍有其他有效 owner 时当前节点不能执行该房间命令。 return nil, router.Lease{}, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID)) } if roomCell := s.loadCell(ctx, roomID); roomCell != nil { // 本节点已经装载该房间,直接复用已有 Room Cell。 return roomCell, lease, nil } if s.isDraining() { return nil, router.Lease{}, xerr.New(xerr.Unavailable, "room service is draining") } // 没有内存 Cell 时从持久化恢复,支持 lease 过期后的新节点接管。 roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID) if err != nil { return nil, router.Lease{}, err } if !exists { // 没有 meta 说明房间从未创建,不能凭请求临时造状态。 return nil, router.Lease{}, xerr.New(xerr.NotFound, "room not found") } restoredState, err := s.recoverRoom(ctx, roomMeta) if err != nil { // 恢复失败时返回 unavailable,调用方应重试或等待节点修复,而不是返回 room not found。 return nil, router.Lease{}, xerr.New(xerr.Unavailable, fmt.Sprintf("recover room failed: %v", err)) } // 恢复完成后创建新的 Room Cell,并从恢复后的 GiftRank 重建 LocalRank 索引。 roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit)) s.installCell(ctx, roomID, roomCell) return roomCell, lease, nil } // recoverRoom 按“最新快照 + 之后的可回放命令”重建 Room Cell 状态。 func (s *Service) recoverRoom(ctx context.Context, roomMeta RoomMeta) (*state.RoomState, error) { // 恢复优先从最新快照开始,再回放快照版本之后的 replayable command log。 snapshotRecord, hasSnapshot, err := s.repository.GetLatestSnapshot(ctx, roomMeta.RoomID) if err != nil { return nil, err } var recovered *state.RoomState var fromVersion int64 if hasSnapshot { // 快照 payload 是 roomv1.RoomSnapshot 的 protobuf 序列化结果。 var snapshot roomv1.RoomSnapshot if err := proto.Unmarshal(snapshotRecord.Payload, &snapshot); err != nil { return nil, err } recovered = state.FromProto(&snapshot) recovered.VisibleRegionID = roomMeta.VisibleRegionID // 密码哈希不进入对外 protobuf 快照,最新快照恢复时从 rooms 元数据补齐。 recovered.RoomPasswordHash = roomMeta.RoomPasswordHash fromVersion = snapshot.GetVersion() } else { // 没有快照时从 rooms meta 重建初始状态,再从版本 0 回放命令。 recovered = state.NewRoomState(roomMeta.RoomID, roomMeta.OwnerUserID, roomMeta.SeatCount, roomMeta.Mode) recovered.Status = roomMeta.Status recovered.VisibleRegionID = roomMeta.VisibleRegionID recovered.RoomPasswordHash = roomMeta.RoomPasswordHash fromVersion = 0 } records, err := s.repository.ListCommandsAfter(ctx, roomMeta.RoomID, fromVersion) if err != nil { return nil, err } for _, record := range records { if !record.Replayable { // 不可回放命令保留审计价值,但不参与状态重建。 continue } cmd, err := command.Deserialize(record.CommandType, record.Payload) if err != nil { return nil, err } if err := replay(recovered, cmd, record.CreatedAtMS); err != nil { // replay 失败说明命令日志无法被当前状态机解释,不能继续装载错误 Cell。 return nil, err } } return recovered, nil } // replay 只重放房间内确定性状态变更,不重复执行钱包扣费或腾讯云 IM 投递。 func replay(current *state.RoomState, cmd command.Command, committedAtMS int64) error { // replay 只重建房间内状态,不重复写 outbox、不重复调用 wallet、不重复同步腾讯云 IM。 switch typed := cmd.(type) { case *command.CreateRoom: current.VisibleRegionID = typed.VisibleRegionID // 无快照恢复时,创建命令负责把列表/详情展示资料还原到 RoomExt。 applyRoomProfileExt(current, roomProfileInput{ Name: typed.RoomName, Avatar: typed.RoomAvatar, Description: typed.RoomDescription, ShortID: typed.RoomShortID, }) // 创建命令的 owner/mode 等基础 meta 已由 RoomMeta 或快照恢复,这里只保证版本进入初始提交态。 current.Version = 1 case *command.UpdateRoomProfile: // 房间资料和麦位总数都属于 Room Cell 状态;恢复时只重放已提交的确定性结果。 applyProfilePatch(current, *typed) if _, err := applySeatCountPatch(current, typed.SeatCount); err != nil { return err } current.Version++ case *command.SetRoomBackground: if current.RoomExt == nil { current.RoomExt = make(map[string]string) } current.RoomExt[roomExtBackgroundKey] = typed.BackgroundURL current.Version++ case *command.JoinRoom: if existing, exists := current.OnlineUsers[typed.ActorUserID()]; exists { // 重复 JoinRoom 回放只刷新 last_seen,不能重置首次进房时间或降级 owner 角色。 existing.LastSeenAtMS = typed.SentAtMS } else { // presence 使用命令发送时间恢复,真实 IM 连接由客户端重新登录腾讯云 IM。 current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{ UserID: typed.ActorUserID(), Role: typed.Role, JoinedAtMS: typed.SentAtMS, LastSeenAtMS: typed.SentAtMS, } } current.Version++ case *command.RoomHeartbeat: existing, exists := current.OnlineUsers[typed.ActorUserID()] if !exists { return fmt.Errorf("room_heartbeat replay actor is not in room: %d", typed.ActorUserID()) } // 心跳只刷新 last_seen,不产生用户进房事件。 existing.LastSeenAtMS = typed.SentAtMS current.Version++ case *command.LeaveRoom: // 离房回放只删除业务 presence,不涉及腾讯云 IM 连接态。 delete(current.OnlineUsers, typed.ActorUserID()) if seat, exists := current.SeatByUser(typed.ActorUserID()); exists { // 离房恢复也必须释放麦位,避免 snapshot 落后时重建出幽灵占位。 index := current.SeatIndex(seat.SeatNo) current.ClearMicSession(index) } current.Version++ case *command.CloseRoom: current.Status = state.RoomStatusClosed current.OnlineUsers = map[int64]*state.RoomUserState{} for index := range current.MicSeats { current.ClearMicSession(index) } current.Version++ case *command.AdminUpdateRoom: applyAdminRoomPatch(current, *typed) if typed.Status != nil && (*typed.Status == state.RoomStatusClosed || *typed.Status == state.RoomStatusDeleted) { current.OnlineUsers = map[int64]*state.RoomUserState{} for index := range current.MicSeats { current.ClearMicSession(index) } } current.Version++ case *command.AdminDeleteRoom: current.Status = state.RoomStatusDeleted current.OnlineUsers = map[int64]*state.RoomUserState{} for index := range current.MicSeats { current.ClearMicSession(index) } current.Version++ case *command.MicUp: index := current.SeatIndex(typed.SeatNo) if index < 0 { return fmt.Errorf("mic_up replay references missing seat: %d", typed.SeatNo) } current.Version++ current.MicSeats[index].UserID = typed.ActorUserID() current.MicSeats[index].PublishState = state.MicPublishPending current.MicSeats[index].MicSessionID = typed.MicSessionID current.MicSeats[index].PublishDeadlineMS = typed.PublishDeadlineMS current.MicSeats[index].MicSessionRoomVersion = current.Version // pending_publish 还没有接受 RTC/客户端事件,恢复时保持 0,避免客户端时间慢于服务端命令时间时无法确认。 current.MicSeats[index].LastPublishEventTimeMS = 0 case *command.MicDown: seat, exists := current.SeatByUser(typed.TargetUserID) if !exists { return fmt.Errorf("mic_down replay target is not on seat: %d", typed.TargetUserID) } index := current.SeatIndex(seat.SeatNo) current.ClearMicSession(index) current.Version++ case *command.ConfirmMicPublishing: seat, exists := current.SeatByUser(typed.TargetUserID) if !exists { return fmt.Errorf("confirm_mic_publishing replay target is not on seat: %d", typed.TargetUserID) } index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].PublishState = state.MicPublishPublishing current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS current.Version++ case *command.MicHeartbeat: seat, exists := current.SeatByUser(typed.TargetUserID) if !exists { return fmt.Errorf("mic_heartbeat replay target is not on seat: %d", typed.TargetUserID) } if seat.MicSessionID != typed.MicSessionID || seat.PublishState != state.MicPublishPublishing { return fmt.Errorf("mic_heartbeat replay target session is not publishing: %d", typed.TargetUserID) } if existing, ok := current.OnlineUsers[typed.TargetUserID]; ok { // 麦上心跳回放要恢复同步刷新的 presence 时间,避免 snapshot 落后时重启后又被 stale worker 清理。 existing.LastSeenAtMS = typed.SentAtMS } index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].MicHeartbeatAtMS = typed.SentAtMS current.Version++ case *command.ApplyRTCEvent: switch typed.EventType { case rtcEventAudioStarted: seat, exists := current.SeatByUser(typed.TargetUserID) if !exists { return fmt.Errorf("apply_rtc_event audio_started replay target is not on seat: %d", typed.TargetUserID) } index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].PublishState = state.MicPublishPublishing current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS current.Version++ case rtcEventAudioStopped: seat, exists := current.SeatByUser(typed.TargetUserID) if !exists { return fmt.Errorf("apply_rtc_event audio_stopped replay target is not on seat: %d", typed.TargetUserID) } index := current.SeatIndex(seat.SeatNo) current.ClearMicSession(index) current.Version++ case rtcEventRoomExited: // room_exited 是 RTC 媒体频道退出事实,不是 App 业务离房事实。 // 实时路径只释放麦位,业务 presence 只能由 LeaveRoom、KickUser、CloseRoom 或 stale worker 清理; // replay 必须保持相同语义,否则无最新 snapshot 的恢复会把仍在房间页的用户误删。 if seat, exists := current.SeatByUser(typed.TargetUserID); exists { index := current.SeatIndex(seat.SeatNo) current.ClearMicSession(index) } current.Version++ default: return fmt.Errorf("apply_rtc_event replay unsupported event type: %s", typed.EventType) } case *command.ChangeMicSeat: from, exists := current.SeatByUser(typed.TargetUserID) if !exists { return fmt.Errorf("change_mic_seat replay target is not on seat: %d", typed.TargetUserID) } fromIndex := current.SeatIndex(from.SeatNo) toIndex := current.SeatIndex(typed.SeatNo) if fromIndex < 0 || toIndex < 0 { return fmt.Errorf("change_mic_seat replay references missing seat: from=%d to=%d", from.SeatNo, typed.SeatNo) } current.MicSeats[fromIndex].UserID = 0 movedSeat := current.MicSeats[fromIndex] current.MicSeats[fromIndex].PublishState = "" current.MicSeats[fromIndex].MicSessionID = "" current.MicSeats[fromIndex].PublishDeadlineMS = 0 current.MicSeats[fromIndex].MicSessionRoomVersion = 0 current.MicSeats[fromIndex].LastPublishEventTimeMS = 0 current.MicSeats[fromIndex].MicMuted = false current.MicSeats[toIndex].UserID = typed.TargetUserID current.MicSeats[toIndex].PublishState = movedSeat.PublishState current.MicSeats[toIndex].MicSessionID = movedSeat.MicSessionID current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted current.Version++ case *command.SetMicMute: seat, exists := current.SeatByUser(typed.TargetUserID) if !exists { return fmt.Errorf("set_mic_mute replay target is not on seat: %d", typed.TargetUserID) } index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].MicMuted = typed.Muted current.Version++ case *command.SetMicSeatLock: index := current.SeatIndex(typed.SeatNo) if index < 0 { return fmt.Errorf("set_mic_seat_lock replay references missing seat: %d", typed.SeatNo) } // 锁麦恢复只还原锁状态,不隐式清空已占用麦位。 current.MicSeats[index].Locked = typed.Locked current.Version++ case *command.SetChatEnabled: // 公屏开关恢复后直接影响 CheckSpeakPermission 的 chat_disabled 判断。 current.ChatEnabled = typed.Enabled current.Version++ case *command.SetRoomPassword: // 锁房恢复只还原服务端哈希状态;明文密码从不进入 command log。 if typed.Locked { current.RoomPasswordHash = typed.PasswordHash } else { current.RoomPasswordHash = "" } current.Version++ case *command.SetRoomAdmin: if typed.TargetUserID == current.OwnerUserID { // owner 不是管理员;旧命令或异常数据回放时不把 owner 写入 admin 集合。 } else if typed.Enabled { // 管理员集合是持久权限,恢复后用户重新进房即可继续具备 admin 身份。 current.AdminUsers[typed.TargetUserID] = true } else { delete(current.AdminUsers, typed.TargetUserID) } current.Version++ case *command.MuteUser: if typed.Muted { // 回放禁言集合,供 CheckSpeakPermission 使用。 current.MuteUsers[typed.TargetUserID] = true } else { delete(current.MuteUsers, typed.TargetUserID) } current.Version++ case *command.KickUser: if seat, exists := current.SeatByUser(typed.TargetUserID); exists { // 被踢用户恢复后不能继续占麦位。 index := current.SeatIndex(seat.SeatNo) current.ClearMicSession(index) } delete(current.OnlineUsers, typed.TargetUserID) delete(current.AdminUsers, typed.TargetUserID) createdAtMS := committedAtMS if createdAtMS <= 0 { createdAtMS = typed.SentAtMS } expiresAtMS := int64(0) if typed.DurationMS > 0 && createdAtMS > 0 { expiresAtMS = createdAtMS + typed.DurationMS } current.BanUsers[typed.TargetUserID] = state.UserModerationState{ UserID: typed.TargetUserID, ActorUserID: typed.ActorUserID(), CreatedAtMS: createdAtMS, ExpiresAtMS: expiresAtMS, } current.Version++ case *command.UnbanUser: // Unban 只解除 ban,不恢复被 KickUser 删除的 presence、麦位或管理员身份。 delete(current.BanUsers, typed.TargetUserID) current.Version++ case *command.SendGift: // 送礼回放不能再次调用 wallet,只使用命令中记录的钱包结算热度重建展示态。 total := typed.HeatValue current.Heat += total applied := false for index := range current.GiftRank { if current.GiftRank[index].UserID == typed.ActorUserID() { current.GiftRank[index].Score += total current.GiftRank[index].GiftValue += total current.GiftRank[index].UpdatedAtMS = typed.SentAtMS applied = true break } } if !applied { // 用户首次出现在榜单时追加榜单项,排序由 LocalRank/快照展示阶段处理。 current.GiftRank = append(current.GiftRank, state.RankItem{ UserID: typed.ActorUserID(), Score: total, GiftValue: total, UpdatedAtMS: typed.SentAtMS, }) } if typed.RocketTouched { current.Rocket = rocketStateFromSettledGift(typed) } current.Version++ case *command.LaunchRoomRocket: if current.Rocket == nil || !rocketPendingLaunchExists(current.Rocket.PendingLaunches, typed.RocketID, typed.Level) { // 发射命令必须对应待发射队列中的火箭;如果快照已包含移除后的状态,回放保持幂等。 return nil } current.Rocket = rocketStateFromLaunchCommand(typed) current.Version++ } return nil } func rocketStateFromSettledGift(cmd *command.SendGift) *state.RocketState { return &state.RocketState{ CurrentLevel: cmd.RocketLevel, CurrentFuel: cmd.RocketFuel, FuelThreshold: cmd.RocketFuelThreshold, Status: cmd.RocketStatus, IgnitedAtMS: cmd.RocketIgnitedAtMS, LaunchAtMS: cmd.RocketLaunchAtMS, ResetAtMS: cmd.RocketResetAtMS, Top1UserID: cmd.RocketTop1UserID, IgniterUserID: cmd.RocketIgniterUserID, RocketID: cmd.RocketID, ConfigVersion: cmd.RocketConfigVersion, PendingLaunches: rocketPendingLaunchesFromCommand(cmd.RocketPendingLaunches), } } func rocketStateFromLaunchCommand(cmd *command.LaunchRoomRocket) *state.RocketState { return &state.RocketState{ CurrentLevel: cmd.CurrentLevel, CurrentFuel: cmd.CurrentFuel, FuelThreshold: cmd.CurrentFuelThreshold, Status: cmd.CurrentStatus, LaunchedAtMS: cmd.LaunchedAtMS, ResetAtMS: cmd.ResetAtMS, RocketID: cmd.CurrentRocketID, ConfigVersion: cmd.ConfigVersion, PendingLaunches: rocketPendingLaunchesFromCommand(cmd.PendingLaunches), LastRewards: rocketRewardsFromCommand(cmd.Rewards), } } func rocketPendingLaunchExists(input []state.RocketPendingLaunch, rocketID string, level int32) bool { for _, launch := range input { if launch.RocketID == rocketID && launch.Level == level { return true } } return false } func rocketPendingLaunchesFromCommand(input []command.RocketPendingLaunch) []state.RocketPendingLaunch { launches := make([]state.RocketPendingLaunch, 0, len(input)) for _, launch := range input { launches = append(launches, state.RocketPendingLaunch{ RocketID: launch.RocketID, Level: launch.Level, FuelThreshold: launch.FuelThreshold, IgnitedAtMS: launch.IgnitedAtMS, LaunchAtMS: launch.LaunchAtMS, ResetAtMS: launch.ResetAtMS, Top1UserID: launch.Top1UserID, IgniterUserID: launch.IgniterUserID, ConfigVersion: launch.ConfigVersion, CoverURL: launch.CoverURL, }) } return launches } func rocketRewardsFromCommand(input []command.RocketRewardGrant) []state.RocketRewardGrant { rewards := make([]state.RocketRewardGrant, 0, len(input)) for _, reward := range input { rewards = append(rewards, state.RocketRewardGrant{ RewardRole: reward.RewardRole, UserID: reward.UserID, RewardItemID: reward.RewardItemID, ResourceGroupID: reward.ResourceGroupID, DisplayName: reward.DisplayName, IconURL: reward.IconURL, GrantID: reward.GrantID, Status: reward.Status, }) } return rewards }