665 lines
23 KiB
Go
665 lines
23 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"
|
||
// 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,
|
||
}
|
||
}
|