407 lines
13 KiB
Go
407 lines
13 KiB
Go
// 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
|
||
}
|