2026-05-12 09:53:20 +08:00

407 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}