// 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" // RoomStatusDeleted 表示后台删除后的运营终态;保留 command log/snapshot 作为恢复和审计来源。 RoomStatusDeleted = "deleted" // TreasureStatusIdle 表示当前等级宝箱还在积攒能量。 TreasureStatusIdle = "idle" // TreasureStatusCountdown 表示当前等级已经满能量,等待后台 worker 到点开箱。 TreasureStatusCountdown = "countdown" // MicPublishIdle 表示麦位没有正在确认或已确认的 RTC 发流会话。 MicPublishIdle = "idle" // MicPublishPending 表示业务上麦成功,但客户端/RTC 尚未确认音频发布成功。 MicPublishPending = "pending_publish" // MicPublishPublishing 表示当前 mic_session 已确认在 RTC 侧发布音频。 MicPublishPublishing = "publishing" // MicSeatStatusEmpty 表示麦位无人占用且未锁定。 MicSeatStatusEmpty = "empty" // MicSeatStatusLocked 表示麦位锁定且不能上麦。 MicSeatStatusLocked = "locked" // MicSeatStatusOccupied 表示用户已业务占麦,但还没有确认可听的上行音频。 MicSeatStatusOccupied = "occupied" // MicSeatStatusPublishing 表示用户已占麦且当前 mic_session 已确认发流。 MicSeatStatusPublishing = "publishing" // MicSeatStatusMuted 表示用户已占麦,但服务端可见麦克风处于静音态。 MicSeatStatusMuted = "muted" ) // 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 // MicHeartbeatAtMS 是当前 mic_session 最近一次服务端接受麦上心跳的时间;它不参与 RTC 事件新旧判断。 MicHeartbeatAtMS int64 // MicMuted 是服务端可见的麦克风静音状态,用于同步其他用户 UI。 MicMuted bool } // SeatStatus 返回面向客户端展示的派生麦位状态。 // // PublishState 继续表达 RTC 会话确认阶段,SeatStatus 只负责把“空/锁/占麦/发流/静音” // 收敛成一个展示字段,避免客户端把 pending_publish 误当作可听发流。 func (seat MicSeat) SeatStatus() string { if seat.UserID <= 0 { if seat.Locked { return MicSeatStatusLocked } return MicSeatStatusEmpty } if seat.MicMuted { return MicSeatStatusMuted } if seat.PublishState == MicPublishPublishing { return MicSeatStatusPublishing } return MicSeatStatusOccupied } // RoomUserState 只保存 room-service 真正需要的轻量用户态。 type RoomUserState struct { // UserID 是房间 presence 的用户标识。 UserID int64 // Role 是房间内轻量角色,不承载完整用户资料。 Role string // JoinedAtMS 是进入房间业务态的时间。 JoinedAtMS int64 // LastSeenAtMS 是最后一次房间业务操作时间,不等同于腾讯云 IM 心跳。 LastSeenAtMS int64 } // UserModerationState 保存房间内 ban/mute 这类治理状态的时间边界。 type UserModerationState struct { // UserID 是被治理用户。 UserID int64 // ActorUserID 是触发治理命令的房间内操作者;系统治理入口使用 0。 ActorUserID int64 // CreatedAtMS 是治理状态写入 Room Cell 的 UTC epoch milliseconds。 CreatedAtMS int64 // ExpiresAtMS 为 0 表示永久有效;非 0 表示该 UTC 毫秒后自动失效。 ExpiresAtMS int64 } // ActiveAt 判断治理状态在指定 UTC 毫秒是否仍然生效。 func (m UserModerationState) ActiveAt(nowMS int64) bool { return m.UserID > 0 && (m.ExpiresAtMS == 0 || m.ExpiresAtMS > nowMS) } // RankItem 表达房间内本地礼物榜的单个用户累计值。 type RankItem struct { // UserID 是榜单归属用户。 UserID int64 // Score 是展示排序分值,当前版本等于 GiftValue。 Score int64 // GiftValue 是房间内礼物价值累计。 GiftValue int64 // UpdatedAtMS 是榜单项最后更新时间,分数相同时用于排序。 UpdatedAtMS int64 } // TreasureRewardGrant 记录一次宝箱开箱时给单个用户结算出的奖励。 type TreasureRewardGrant struct { // RewardRole 区分 in_room、top1、igniter 三种奖励归因,客户端据此分组展示。 RewardRole string // UserID 是奖励归属用户;top1 和 igniter 即使离房也必须用满能量时锁定的用户。 UserID int64 // RewardItemID 是后台奖励池内的稳定项 ID,用于排障和客户端去重。 RewardItemID string // ResourceGroupID 是 wallet-service 发放资源组 ID。 ResourceGroupID int64 // DisplayName/IconURL 是开箱 IM 展示快照,避免客户端为历史消息回查后台配置。 DisplayName string IconURL string // GrantID 是 wallet-service 资源发放回执;空值表示该奖励未实际发放。 GrantID string // Status 表达发放结果,当前成功路径写 succeeded。 Status string } // TreasureState 保存 Room Cell 内当前宝箱进度和最近一次开箱结果。 type TreasureState struct { // CurrentLevel 是当前正在积攒的宝箱等级,UTC 日重置后回到 1。 CurrentLevel int32 // CurrentProgress 是当前等级已累计能量,满级后的溢出不跨等级继承。 CurrentProgress int64 // EnergyThreshold 是当前等级阈值快照,便于客户端不依赖配置二次计算。 EnergyThreshold int64 // Status 区分 idle/countdown,倒计时期间送礼能量无效。 Status string // CountdownStartedAtMS/OpenAtMS/OpenedAtMS 描述最近一次满能量到开箱的时间线。 CountdownStartedAtMS int64 OpenAtMS int64 OpenedAtMS int64 // ResetAtMS 是下一次 UTC 自然日重置毫秒时间。 ResetAtMS int64 // Top1UserID 和 IgniterUserID 在满能量瞬间锁定,用户离房或下线仍按这里发奖。 Top1UserID int64 IgniterUserID int64 // BoxID 是单次宝箱从积攒到开箱的幂等标识,奖励抽取和 wallet command_id 都依赖它。 BoxID string // ConfigVersion 记录本轮宝箱使用的后台配置版本,开箱时不被中途配置变更影响。 ConfigVersion int64 // LastRewards 保存最近一次开箱发奖结果,用于断线重连后补 UI。 LastRewards []TreasureRewardGrant } // Clone 深拷贝宝箱状态,避免 Room Cell 失败命令污染当前状态。 func (t *TreasureState) Clone() *TreasureState { if t == nil { return nil } cloned := *t cloned.LastRewards = append([]TreasureRewardGrant(nil), t.LastRewards...) return &cloned } // RoomState 是 Room Cell 的核心业务状态。 // 这里刻意不放钱包余额、完整用户资料和跨房活动状态。 type RoomState struct { // RoomID 是该状态所属房间。 RoomID string // OwnerUserID 是房间所有者;owner 权限由专门字段派生,不写入 AdminUsers。 OwnerUserID int64 // Mode 是房间模式,当前只作为状态字段透出。 Mode string // VisibleRegionID 是房间礼物、列表和区域可见性策略使用的区域桶。 VisibleRegionID int64 // Status 是房间生命周期状态。 Status string // ChatEnabled 控制腾讯云 IM 公屏发言守卫。 ChatEnabled bool // RoomPasswordHash 非空表示房间开启入房密码;只保存哈希,明文只存在于单次 JoinRoom 请求。 RoomPasswordHash string // MicSeats 是麦位占用状态,所有麦位变更必须在 Room Cell 内完成。 MicSeats []MicSeat // OnlineUsers 是 room-service 业务 presence,不保存真实 socket。 OnlineUsers map[int64]*RoomUserState // AdminUsers 是房间管理权限集合。 AdminUsers map[int64]bool // BanUsers 是被踢或封禁的房间用户状态,过期时间由 Room Cell 持久化和恢复。 BanUsers map[int64]UserModerationState // MuteUsers 是被禁言用户集合。 MuteUsers map[int64]bool // Heat 是房间热度,当前主要由送礼累计。 Heat int64 // GiftRank 是房间内本地礼物榜快照结果。 GiftRank []RankItem // RoomExt 预留少量房间扩展字段,避免 v1 频繁改结构。 RoomExt map[string]string // Treasure 是语音房宝箱状态,送礼能量和开箱必须跟随 Room Cell 串行提交。 Treasure *TreasureState // Version 是房间状态版本,成功变更后递增,快照和 command log 都依赖它。 Version int64 } // NewRoomState 初始化一个空房间状态。 func NewRoomState(roomID string, ownerUserID 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, Mode: mode, Status: RoomStatusActive, ChatEnabled: true, MicSeats: seats, OnlineUsers: make(map[int64]*RoomUserState), AdminUsers: make(map[int64]bool), BanUsers: make(map[int64]UserModerationState), 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, Mode: s.Mode, VisibleRegionID: s.VisibleRegionID, Status: s.Status, ChatEnabled: s.ChatEnabled, RoomPasswordHash: s.RoomPasswordHash, 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]UserModerationState, 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)), Treasure: cloneTreasureState(s.Treasure), 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, MicMuted: seat.MicMuted, SeatStatus: seat.SeatStatus(), MicHeartbeatAtMs: seat.MicHeartbeatAtMS, }) } 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, Mode: s.Mode, VisibleRegionId: s.VisibleRegionID, Status: s.Status, ChatEnabled: s.ChatEnabled, MicSeats: seats, OnlineUsers: users, AdminUserIds: sortedSetIDsExcept(s.AdminUsers, s.OwnerUserID), BanUserIds: sortedModerationIDs(s.BanUsers), MuteUserIds: sortedSetIDs(s.MuteUsers), GiftRank: rankItems, RoomExt: cloneStringMap(s.RoomExt), Heat: s.Heat, Version: s.Version, RoomShortId: s.RoomExt["room_short_id"], Locked: s.RoomPasswordHash != "", Treasure: treasureStateToProto(s.Treasure), BanStates: sortedModerationStates(s.BanUsers), } } // FromProto 把持久化快照恢复回内部房间态。 func FromProto(snapshot *roomv1.RoomSnapshot) *RoomState { if snapshot == nil { // 没有快照时由调用方基于 RoomMeta 新建初始状态。 return nil } // 所有集合重新分配,恢复后的状态可以直接交给 Room Cell 独占使用。 restored := &RoomState{ RoomID: snapshot.GetRoomId(), OwnerUserID: snapshot.GetOwnerUserId(), Mode: snapshot.GetMode(), VisibleRegionID: snapshot.GetVisibleRegionId(), 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]UserModerationState, len(snapshot.GetBanStates())+len(snapshot.GetBanUserIds())), MuteUsers: make(map[int64]bool, len(snapshot.GetMuteUserIds())), GiftRank: make([]RankItem, 0, len(snapshot.GetGiftRank())), RoomExt: cloneStringMap(snapshot.GetRoomExt()), Treasure: treasureStateFromProto(snapshot.GetTreasure()), 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(), MicHeartbeatAtMS: seat.GetMicHeartbeatAtMs(), MicMuted: seat.GetMicMuted(), }) } 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。 if userID == restored.OwnerUserID { // owner 不是管理员;旧快照里如果残留 owner,恢复时过滤掉。 continue } restored.AdminUsers[userID] = true } for _, ban := range snapshot.GetBanStates() { if ban.GetUserId() <= 0 { continue } restored.BanUsers[ban.GetUserId()] = UserModerationState{ UserID: ban.GetUserId(), ActorUserID: ban.GetActorUserId(), CreatedAtMS: ban.GetCreatedAtMs(), ExpiresAtMS: ban.GetExpiresAtMs(), } } for _, userID := range snapshot.GetBanUserIds() { if userID <= 0 { continue } if _, exists := restored.BanUsers[userID]; !exists { // 旧快照只有 ban_user_ids 时按永久 ban 恢复,避免恢复后放宽权限。 restored.BanUsers[userID] = UserModerationState{UserID: userID} } } 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 sortedModerationIDs(values map[int64]UserModerationState) []int64 { ids := make([]int64, 0, len(values)) for userID, moderation := range values { if moderation.UserID > 0 { ids = append(ids, userID) } } slices.Sort(ids) return ids } func sortedModerationStates(values map[int64]UserModerationState) []*roomv1.RoomBanState { ids := sortedModerationIDs(values) states := make([]*roomv1.RoomBanState, 0, len(ids)) for _, userID := range ids { moderation := values[userID] states = append(states, &roomv1.RoomBanState{ UserId: moderation.UserID, ActorUserId: moderation.ActorUserID, CreatedAtMs: moderation.CreatedAtMS, ExpiresAtMs: moderation.ExpiresAtMS, }) } return states } func sortedSetIDsExcept(values map[int64]bool, excluded int64) []int64 { ids := make([]int64, 0, len(values)) for userID, enabled := range values { if enabled && userID != excluded { 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 } func cloneTreasureState(input *TreasureState) *TreasureState { if input == nil { return nil } cloned := *input cloned.LastRewards = append([]TreasureRewardGrant(nil), input.LastRewards...) return &cloned } func treasureStateToProto(input *TreasureState) *roomv1.RoomTreasureState { if input == nil { return nil } rewards := make([]*roomv1.RoomTreasureRewardGrant, 0, len(input.LastRewards)) for _, reward := range input.LastRewards { rewards = append(rewards, &roomv1.RoomTreasureRewardGrant{ 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 &roomv1.RoomTreasureState{ CurrentLevel: input.CurrentLevel, CurrentProgress: input.CurrentProgress, EnergyThreshold: input.EnergyThreshold, Status: input.Status, CountdownStartedAtMs: input.CountdownStartedAtMS, OpenAtMs: input.OpenAtMS, OpenedAtMs: input.OpenedAtMS, ResetAtMs: input.ResetAtMS, Top1UserId: input.Top1UserID, IgniterUserId: input.IgniterUserID, BoxId: input.BoxID, ConfigVersion: input.ConfigVersion, LastRewards: rewards, } } func treasureStateFromProto(input *roomv1.RoomTreasureState) *TreasureState { if input == nil { return nil } rewards := make([]TreasureRewardGrant, 0, len(input.GetLastRewards())) for _, reward := range input.GetLastRewards() { rewards = append(rewards, TreasureRewardGrant{ RewardRole: reward.GetRewardRole(), UserID: reward.GetUserId(), RewardItemID: reward.GetRewardItemId(), ResourceGroupID: reward.GetResourceGroupId(), DisplayName: reward.GetDisplayName(), IconURL: reward.GetIconUrl(), GrantID: reward.GetGrantId(), Status: reward.GetStatus(), }) } return &TreasureState{ CurrentLevel: input.GetCurrentLevel(), CurrentProgress: input.GetCurrentProgress(), EnergyThreshold: input.GetEnergyThreshold(), Status: input.GetStatus(), CountdownStartedAtMS: input.GetCountdownStartedAtMs(), OpenAtMS: input.GetOpenAtMs(), OpenedAtMS: input.GetOpenedAtMs(), ResetAtMS: input.GetResetAtMs(), Top1UserID: input.GetTop1UserId(), IgniterUserID: input.GetIgniterUserId(), BoxID: input.GetBoxId(), ConfigVersion: input.GetConfigVersion(), LastRewards: rewards, } }