// Package state 定义 Room Cell 内部持有的房间强关联状态和 protobuf 快照转换。 package state import ( "maps" "slices" roomv1 "hyapp.local/api/proto/room/v1" ) const ( // RoomStatusCreating 表示房间正在创建外部依赖,尚未对客户端开放。 RoomStatusCreating = "creating" // RoomStatusActive 表示房间可进入、可管理、可发麦位和礼物命令。 RoomStatusActive = "active" // RoomStatusClosing 表示房间正在收尾,不再接受新业务命令。 RoomStatusClosing = "closing" // RoomStatusClosed 表示房间已经关闭,guard 和进房都必须拒绝。 RoomStatusClosed = "closed" // MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。 MicPublishIdle = "idle" // MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。 MicPublishPending = "pending_publish" // MicPublishPublishing 表示当前 mic_session 已确认在 RTC 侧发布音频。 MicPublishPublishing = "publishing" ) // MicSeat 表达房间内单个麦位的占用状态。 type MicSeat struct { // SeatNo 是房间内稳定麦位编号,不使用切片下标作为对外标识。 SeatNo int32 // UserID 为 0 表示麦位空闲,非 0 表示当前占用用户。 UserID int64 // Locked 表示麦位被锁定,锁定麦位不能上麦或换入。 Locked bool // PublishState 区分业务占麦和 RTC 发流确认状态,避免把 MicUp 当作已发流。 PublishState string // MicSessionID 标识单次上麦发流会话,确认和超时清理都必须匹配它。 MicSessionID string // PublishDeadlineMS 是 pending_publish 自动释放麦位的截止时间。 PublishDeadlineMS int64 // MicSessionRoomVersion 是创建该会话的房间版本,用于丢弃旧版本 RTC 事件。 MicSessionRoomVersion int64 // LastPublishEventTimeMS 是已接受的最新 RTC/客户端发布事件时间。 LastPublishEventTimeMS int64 } // RoomUserState 只保存 room-service 真正需要的轻量用户态。 type RoomUserState struct { // UserID 是房间 presence 的用户标识。 UserID int64 // Role 是房间内轻量角色,不承载完整用户资料。 Role string // JoinedAtMS 是进入房间业务态的时间。 JoinedAtMS int64 // LastSeenAtMS 是最后一次房间业务操作时间,不等同于腾讯云 IM 心跳。 LastSeenAtMS int64 } // RankItem 表达房间内本地礼物榜的单个用户累计值。 type RankItem struct { // UserID 是榜单归属用户。 UserID int64 // Score 是展示排序分值,当前版本等于 GiftValue。 Score int64 // GiftValue 是房间内礼物价值累计。 GiftValue int64 // UpdatedAtMS 是榜单项最后更新时间,分数相同时用于排序。 UpdatedAtMS int64 } // RoomState 是 Room Cell 的核心业务状态。 // 这里刻意不放钱包余额、完整用户资料和跨房活动状态。 type RoomState struct { // RoomID 是该状态所属房间。 RoomID string // OwnerUserID 是房间所有者,进入 AdminUsers。 OwnerUserID int64 // HostUserID 是主持人,进入 AdminUsers。 HostUserID int64 // Mode 是房间模式,当前只作为状态字段透出。 Mode string // Status 是房间生命周期状态。 Status string // ChatEnabled 控制腾讯云 IM 公屏发言守卫。 ChatEnabled bool // MicSeats 是麦位占用状态,所有麦位变更必须在 Room Cell 内完成。 MicSeats []MicSeat // OnlineUsers 是 room-service 业务 presence,不保存真实 socket。 OnlineUsers map[int64]*RoomUserState // AdminUsers 是房间管理权限集合。 AdminUsers map[int64]bool // BanUsers 是被踢或封禁的房间用户集合。 BanUsers map[int64]bool // MuteUsers 是被禁言用户集合。 MuteUsers map[int64]bool // Heat 是房间热度,当前主要由送礼累计。 Heat int64 // GiftRank 是房间内本地礼物榜快照结果。 GiftRank []RankItem // RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。 RoomExt map[string]string // Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。 Version int64 } // NewRoomState 初始化一个空房间状态。 func NewRoomState(roomID string, ownerUserID int64, hostUserID int64, seatCount int32, mode string) *RoomState { // 麦位编号从 1 开始,避免对外暴露 0 号麦位和切片下标。 seats := make([]MicSeat, 0, seatCount) for seatNo := int32(1); seatNo <= seatCount; seatNo++ { seats = append(seats, MicSeat{SeatNo: seatNo}) } return &RoomState{ RoomID: roomID, OwnerUserID: ownerUserID, HostUserID: hostUserID, Mode: mode, Status: RoomStatusActive, ChatEnabled: true, MicSeats: seats, OnlineUsers: make(map[int64]*RoomUserState), AdminUsers: map[int64]bool{ // owner 和 host 默认具备管理权限,后续可以扩展管理员命令。 ownerUserID: true, hostUserID: true, }, BanUsers: make(map[int64]bool), MuteUsers: make(map[int64]bool), RoomExt: make(map[string]string), } } // Clone 深拷贝当前房间状态,供命令流水线在副本上计算新状态。 func (s *RoomState) Clone() *RoomState { if s == nil { // nil 输入保持 nil,便于防御性快照调用。 return nil } // 所有 map 和 slice 都重新分配,避免 mutate 失败时污染当前 Cell 状态。 cloned := &RoomState{ RoomID: s.RoomID, OwnerUserID: s.OwnerUserID, HostUserID: s.HostUserID, Mode: s.Mode, Status: s.Status, ChatEnabled: s.ChatEnabled, MicSeats: append([]MicSeat(nil), s.MicSeats...), OnlineUsers: make(map[int64]*RoomUserState, len(s.OnlineUsers)), AdminUsers: make(map[int64]bool, len(s.AdminUsers)), BanUsers: make(map[int64]bool, len(s.BanUsers)), MuteUsers: make(map[int64]bool, len(s.MuteUsers)), Heat: s.Heat, GiftRank: append([]RankItem(nil), s.GiftRank...), RoomExt: make(map[string]string, len(s.RoomExt)), Version: s.Version, } for userID, userState := range s.OnlineUsers { // 用户态用指针保存,必须逐项复制结构体。 copied := *userState cloned.OnlineUsers[userID] = &copied } maps.Copy(cloned.AdminUsers, s.AdminUsers) maps.Copy(cloned.BanUsers, s.BanUsers) maps.Copy(cloned.MuteUsers, s.MuteUsers) maps.Copy(cloned.RoomExt, s.RoomExt) return cloned } // ReplaceWith 用新状态整体替换当前状态。 // 这里用整对象替换,是为了避免部分字段更新时残留旧引用。 func (s *RoomState) ReplaceWith(next *RoomState) { // Clone 后再整体赋值,避免调用方把 next 的 map/slice 引用留在当前状态里。 *s = *next.Clone() } // SeatByUser 返回用户当前所在麦位。 func (s *RoomState) SeatByUser(userID int64) (MicSeat, bool) { // 麦位数量较小,线性扫描比维护额外索引更简单可靠。 for _, seat := range s.MicSeats { if seat.UserID == userID { return seat, true } } return MicSeat{}, false } // ClearMicSession 清空麦位占用和 RTC 发流会话字段。 func (s *RoomState) ClearMicSession(index int) { if index < 0 || index >= len(s.MicSeats) { return } seatNo := s.MicSeats[index].SeatNo locked := s.MicSeats[index].Locked s.MicSeats[index] = MicSeat{ SeatNo: seatNo, Locked: locked, } } // SeatIndex 返回指定麦位在切片中的下标。 func (s *RoomState) SeatIndex(seatNo int32) int { // 返回 -1 表示外部传入的麦位编号不存在。 for index, seat := range s.MicSeats { if seat.SeatNo == seatNo { return index } } return -1 } // ToProto 把当前内部房间态投影成对外使用的 protobuf 快照。 func (s *RoomState) ToProto() *roomv1.RoomSnapshot { if s == nil { // nil 状态没有可投影快照,调用方按房间不存在处理。 return nil } // OnlineUsers 是 map,先排序 user_id,保证快照输出稳定,便于测试和排障。 users := make([]*roomv1.RoomUser, 0, len(s.OnlineUsers)) userIDs := make([]int64, 0, len(s.OnlineUsers)) for userID := range s.OnlineUsers { userIDs = append(userIDs, userID) } slices.Sort(userIDs) for _, userID := range userIDs { // protobuf 快照只暴露轻量 room user,不包含 user-service 主资料。 userState := s.OnlineUsers[userID] users = append(users, &roomv1.RoomUser{ UserId: userState.UserID, Role: userState.Role, JoinedAtMs: userState.JoinedAtMS, LastSeenAtMs: userState.LastSeenAtMS, }) } seats := make([]*roomv1.SeatState, 0, len(s.MicSeats)) for _, seat := range s.MicSeats { // 麦位顺序沿用内部切片顺序,即初始化的 SeatNo 升序和后续原位修改。 seats = append(seats, &roomv1.SeatState{ SeatNo: seat.SeatNo, UserId: seat.UserID, Locked: seat.Locked, PublishState: seat.PublishState, MicSessionId: seat.MicSessionID, PublishDeadlineMs: seat.PublishDeadlineMS, MicSessionRoomVersion: seat.MicSessionRoomVersion, LastPublishEventTimeMs: seat.LastPublishEventTimeMS, }) } rankItems := make([]*roomv1.RankItem, 0, len(s.GiftRank)) for _, item := range s.GiftRank { // GiftRank 已由 LocalRank 排好序,这里只做结构转换。 rankItems = append(rankItems, &roomv1.RankItem{ UserId: item.UserID, Score: item.Score, GiftValue: item.GiftValue, UpdatedAtMs: item.UpdatedAtMS, }) } return &roomv1.RoomSnapshot{ RoomId: s.RoomID, OwnerUserId: s.OwnerUserID, HostUserId: s.HostUserID, Mode: s.Mode, Status: s.Status, ChatEnabled: s.ChatEnabled, MicSeats: seats, OnlineUsers: users, AdminUserIds: sortedSetIDs(s.AdminUsers), BanUserIds: sortedSetIDs(s.BanUsers), MuteUserIds: sortedSetIDs(s.MuteUsers), GiftRank: rankItems, RoomExt: cloneStringMap(s.RoomExt), Heat: s.Heat, Version: s.Version, RoomShortId: s.RoomExt["room_short_id"], } } // FromProto 把持久化快照恢复回内部房间态。 func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState { if snapshot == nil { // 没有快照时由调用方基于 RoomMeta 新建初始状态。 return nil } // 所有集合重新分配,恢复后的状态可以直接交给 Room Cell 独占使用。 restored := &RoomState{ RoomID: snapshot.GetRoomId(), OwnerUserID: snapshot.GetOwnerUserId(), HostUserID: snapshot.GetHostUserId(), Mode: snapshot.GetMode(), Status: snapshot.GetStatus(), ChatEnabled: snapshot.GetChatEnabled(), MicSeats: make([]MicSeat, 0, len(snapshot.GetMicSeats())), OnlineUsers: make(map[int64]*RoomUserState, len(snapshot.GetOnlineUsers())), AdminUsers: make(map[int64]bool, len(snapshot.GetAdminUserIds())), BanUsers: make(map[int64]bool, len(snapshot.GetBanUserIds())), MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())), GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())), RoomExt: cloneStringMap(snapshot.GetRoomExt()), Heat: snapshot.GetHeat(), Version: snapshot.GetVersion(), } for _, seat := range snapshot.GetMicSeats() { // protobuf 空字段会映射成零值,UserID=0 仍表示空麦位。 restored.MicSeats = append(restored.MicSeats, MicSeat{ SeatNo: seat.GetSeatNo(), UserID: seat.GetUserId(), Locked: seat.GetLocked(), PublishState: seat.GetPublishState(), MicSessionID: seat.GetMicSessionId(), PublishDeadlineMS: seat.GetPublishDeadlineMs(), MicSessionRoomVersion: seat.GetMicSessionRoomVersion(), LastPublishEventTimeMS: seat.GetLastPublishEventTimeMs(), }) } for _, user := range snapshot.GetOnlineUsers() { // 快照中的 online users 是业务 presence,重启后腾讯云 IM 连接由客户端重新登录进群。 restored.OnlineUsers[user.GetUserId()] = &RoomUserState{ UserID: user.GetUserId(), Role: user.GetRole(), JoinedAtMS: user.GetJoinedAtMs(), LastSeenAtMS: user.GetLastSeenAtMs(), } } for _, userID := range snapshot.GetAdminUserIds() { // set 在 protobuf 中用数组表达,恢复时重新转为 map。 restored.AdminUsers[userID] = true } // owner 和 host 是派生管理员身份,快照恢复后必须重新压实该不变量。 restored.AdminUsers[restored.OwnerUserID] = true restored.AdminUsers[restored.HostUserID] = true for _, userID := range snapshot.GetBanUserIds() { restored.BanUsers[userID] = true } for _, userID := range snapshot.GetMuteUserIds() { restored.MuteUsers[userID] = true } for _, item := range snapshot.GetGiftRank() { restored.GiftRank = append(restored.GiftRank, RankItem{ UserID: item.GetUserId(), Score: item.GetScore(), GiftValue: item.GetGiftValue(), UpdatedAtMS: item.GetUpdatedAtMs(), }) } return restored } func sortedSetIDs(values map[int64]bool) []int64 { // map 转数组时只导出 value=true 的成员,false 等价于不存在。 ids := make([]int64, 0, len(values)) for userID, enabled := range values { if enabled { ids = append(ids, userID) } } slices.Sort(ids) return ids } func cloneStringMap(input map[string]string) map[string]string { if input == nil { // protobuf map 缺省按空 map 处理,调用方可以安全写入。 return map[string]string{} } // RoomExt 是扩展字段集合,转换时必须复制,避免共享 map 引用。 cloned := make(map[string]string, len(input)) maps.Copy(cloned, input) return cloned }