package service import ( "context" "fmt" "google.golang.org/protobuf/proto" roomv1 "hyapp/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" ) func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, error) { // 每次写命令先刷新或接管 lease,确保同一房间同一时刻只有一个执行节点。 lease, err := s.directory.EnsureOwner(ctx, roomID, s.nodeID, s.clock.Now(), s.leaseTTL) if err != nil { return nil, err } if lease.NodeID != s.nodeID { // 仍有其他有效 owner 时当前节点不能执行该房间命令。 return nil, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID)) } if roomCell := s.loadCell(roomID); roomCell != nil { // 本节点已经装载该房间,直接复用已有 Room Cell。 return roomCell, nil } // 没有内存 Cell 时从持久化恢复,支持 lease 过期后的新节点接管。 roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID) if err != nil { return nil, err } if !exists { // 没有 meta 说明房间从未创建,不能凭请求临时造状态。 return nil, xerr.New(xerr.NotFound, "room not found") } restoredState, err := s.recoverRoom(ctx, roomMeta) if err != nil { // 恢复失败时返回 unavailable,调用方应重试或等待节点修复,而不是返回 room not found。 return nil, 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(roomID, roomCell) return roomCell, nil } 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) fromVersion = snapshot.GetVersion() } else { // 没有快照时从 rooms meta 重建初始状态,再从版本 0 回放命令。 recovered = state.NewRoomState(roomMeta.RoomID, roomMeta.OwnerUserID, roomMeta.HostUserID, roomMeta.SeatCount, roomMeta.Mode) recovered.Status = roomMeta.Status 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 cmd == nil { // 未知命令类型跳过,保留兼容未来命令的恢复容错。 continue } if err := replay(recovered, cmd); err != nil { // replay 失败说明命令日志和当前状态不兼容,不能继续装载错误 Cell。 return nil, err } } return recovered, nil } func replay(current *state.RoomState, cmd command.Command) error { // replay 只重建房间内状态,不重复写 outbox、不重复调用 wallet、不重复同步腾讯云 IM。 switch typed := cmd.(type) { case *command.CreateRoom: // 创建命令的 meta 已由 RoomMeta 或快照恢复,这里只保证版本进入初始提交态。 current.Version = 1 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.LeaveRoom: // 离房回放只删除业务 presence,不涉及腾讯云 IM 连接态。 delete(current.OnlineUsers, typed.ActorUserID()) if seat, exists := current.SeatByUser(typed.ActorUserID()); exists { // 离房恢复也必须释放麦位,避免 snapshot 落后时重建出幽灵占位。 index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].UserID = 0 } current.Version++ case *command.MicUp: index := current.SeatIndex(typed.SeatNo) if index >= 0 { // 回放对不存在麦位保持容错,避免旧命令阻塞新结构恢复。 current.MicSeats[index].UserID = typed.ActorUserID() current.Version++ } case *command.MicDown: if seat, exists := current.SeatByUser(typed.TargetUserID); exists { // 只有目标仍在麦上时才清空,重复或乱序异常不会制造负状态。 index := current.SeatIndex(seat.SeatNo) current.MicSeats[index].UserID = 0 current.Version++ } case *command.ChangeMicSeat: if from, exists := current.SeatByUser(typed.TargetUserID); exists { // 换位回放必须同时找到源麦位和目标麦位。 fromIndex := current.SeatIndex(from.SeatNo) toIndex := current.SeatIndex(typed.SeatNo) if fromIndex >= 0 && toIndex >= 0 { current.MicSeats[fromIndex].UserID = 0 current.MicSeats[toIndex].UserID = 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.MicSeats[index].UserID = 0 } delete(current.OnlineUsers, typed.TargetUserID) current.BanUsers[typed.TargetUserID] = true current.Version++ case *command.SendGift: // 送礼回放不能再次调用 wallet,只使用命令中记录的礼物价值重建展示态。 total := typed.GiftUnitValue * int64(typed.GiftCount) 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, }) } current.Version++ } return nil }