429 lines
16 KiB
Go
429 lines
16 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
|
||
"google.golang.org/protobuf/proto"
|
||
roomv1 "hyapp.local/api/proto/room/v1"
|
||
"hyapp/pkg/xerr"
|
||
"hyapp/services/room-service/internal/room/cell"
|
||
"hyapp/services/room-service/internal/room/command"
|
||
"hyapp/services/room-service/internal/room/rank"
|
||
"hyapp/services/room-service/internal/room/state"
|
||
"hyapp/services/room-service/internal/router"
|
||
)
|
||
|
||
// ensureCell 确保当前节点拥有房间执行权,并在本地缺少 Cell 时从 MySQL 恢复。
|
||
func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, router.Lease, error) {
|
||
// 每次写命令先刷新或接管 lease,确保同一房间同一时刻只有一个执行节点。
|
||
lease, err := s.directory.EnsureOwner(ctx, runtimeRoomKeyFromContext(ctx, roomID), s.nodeID, s.clock.Now(), s.leaseTTL)
|
||
if err != nil {
|
||
return nil, router.Lease{}, err
|
||
}
|
||
|
||
if lease.NodeID != s.nodeID {
|
||
// 仍有其他有效 owner 时当前节点不能执行该房间命令。
|
||
return nil, router.Lease{}, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID))
|
||
}
|
||
|
||
if roomCell := s.loadCell(ctx, roomID); roomCell != nil {
|
||
// 本节点已经装载该房间,直接复用已有 Room Cell。
|
||
return roomCell, lease, nil
|
||
}
|
||
|
||
if s.isDraining() {
|
||
return nil, router.Lease{}, xerr.New(xerr.Unavailable, "room service is draining")
|
||
}
|
||
|
||
// 没有内存 Cell 时从持久化恢复,支持 lease 过期后的新节点接管。
|
||
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
|
||
if err != nil {
|
||
return nil, router.Lease{}, err
|
||
}
|
||
|
||
if !exists {
|
||
// 没有 meta 说明房间从未创建,不能凭请求临时造状态。
|
||
return nil, router.Lease{}, xerr.New(xerr.NotFound, "room not found")
|
||
}
|
||
|
||
restoredState, err := s.recoverRoom(ctx, roomMeta)
|
||
if err != nil {
|
||
// 恢复失败时返回 unavailable,调用方应重试或等待节点修复,而不是返回 room not found。
|
||
return nil, router.Lease{}, xerr.New(xerr.Unavailable, fmt.Sprintf("recover room failed: %v", err))
|
||
}
|
||
|
||
// 恢复完成后创建新的 Room Cell,并从恢复后的 GiftRank 重建 LocalRank 索引。
|
||
roomCell := cell.New(restoredState, rank.FromState(restoredState.GiftRank, s.rankLimit))
|
||
s.installCell(ctx, roomID, roomCell)
|
||
|
||
return roomCell, lease, nil
|
||
}
|
||
|
||
// recoverRoom 按“最新快照 + 之后的可回放命令”重建 Room Cell 状态。
|
||
func (s *Service) recoverRoom(ctx context.Context, roomMeta RoomMeta) (*state.RoomState, error) {
|
||
// 恢复优先从最新快照开始,再回放快照版本之后的 replayable command log。
|
||
snapshotRecord, hasSnapshot, err := s.repository.GetLatestSnapshot(ctx, roomMeta.RoomID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
var recovered *state.RoomState
|
||
var fromVersion int64
|
||
|
||
if hasSnapshot {
|
||
// 快照 payload 是 roomv1.RoomSnapshot 的 protobuf 序列化结果。
|
||
var snapshot roomv1.RoomSnapshot
|
||
if err := proto.Unmarshal(snapshotRecord.Payload, &snapshot); err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
recovered = state.FromProto(&snapshot)
|
||
recovered.VisibleRegionID = roomMeta.VisibleRegionID
|
||
// 密码哈希不进入对外 protobuf 快照,最新快照恢复时从 rooms 元数据补齐。
|
||
recovered.RoomPasswordHash = roomMeta.RoomPasswordHash
|
||
fromVersion = snapshot.GetVersion()
|
||
} else {
|
||
// 没有快照时从 rooms meta 重建初始状态,再从版本 0 回放命令。
|
||
recovered = state.NewRoomState(roomMeta.RoomID, roomMeta.OwnerUserID, roomMeta.SeatCount, roomMeta.Mode)
|
||
recovered.Status = roomMeta.Status
|
||
recovered.VisibleRegionID = roomMeta.VisibleRegionID
|
||
recovered.RoomPasswordHash = roomMeta.RoomPasswordHash
|
||
fromVersion = 0
|
||
}
|
||
|
||
records, err := s.repository.ListCommandsAfter(ctx, roomMeta.RoomID, fromVersion)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
for _, record := range records {
|
||
if !record.Replayable {
|
||
// 不可回放命令保留审计价值,但不参与状态重建。
|
||
continue
|
||
}
|
||
|
||
cmd, err := command.Deserialize(record.CommandType, record.Payload)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
if err := replay(recovered, cmd); err != nil {
|
||
// replay 失败说明命令日志无法被当前状态机解释,不能继续装载错误 Cell。
|
||
return nil, err
|
||
}
|
||
}
|
||
|
||
return recovered, nil
|
||
}
|
||
|
||
// replay 只重放房间内确定性状态变更,不重复执行钱包扣费或腾讯云 IM 投递。
|
||
func replay(current *state.RoomState, cmd command.Command) error {
|
||
// replay 只重建房间内状态,不重复写 outbox、不重复调用 wallet、不重复同步腾讯云 IM。
|
||
switch typed := cmd.(type) {
|
||
case *command.CreateRoom:
|
||
current.VisibleRegionID = typed.VisibleRegionID
|
||
// 无快照恢复时,创建命令负责把列表/详情展示资料还原到 RoomExt。
|
||
applyRoomProfileExt(current, roomProfileInput{
|
||
Name: typed.RoomName,
|
||
Avatar: typed.RoomAvatar,
|
||
Description: typed.RoomDescription,
|
||
ShortID: typed.RoomShortID,
|
||
})
|
||
// 创建命令的 owner/mode 等基础 meta 已由 RoomMeta 或快照恢复,这里只保证版本进入初始提交态。
|
||
current.Version = 1
|
||
case *command.UpdateRoomProfile:
|
||
// 房间资料和麦位总数都属于 Room Cell 状态;恢复时只重放已提交的确定性结果。
|
||
applyProfilePatch(current, *typed)
|
||
if _, err := applySeatCountPatch(current, typed.SeatCount); err != nil {
|
||
return err
|
||
}
|
||
current.Version++
|
||
case *command.JoinRoom:
|
||
if existing, exists := current.OnlineUsers[typed.ActorUserID()]; exists {
|
||
// 重复 JoinRoom 回放只刷新 last_seen,不能重置首次进房时间或降级 owner 角色。
|
||
existing.LastSeenAtMS = typed.SentAtMS
|
||
} else {
|
||
// presence 使用命令发送时间恢复,真实 IM 连接由客户端重新登录腾讯云 IM。
|
||
current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
|
||
UserID: typed.ActorUserID(),
|
||
Role: typed.Role,
|
||
JoinedAtMS: typed.SentAtMS,
|
||
LastSeenAtMS: typed.SentAtMS,
|
||
}
|
||
}
|
||
current.Version++
|
||
case *command.RoomHeartbeat:
|
||
existing, exists := current.OnlineUsers[typed.ActorUserID()]
|
||
if !exists {
|
||
return fmt.Errorf("room_heartbeat replay actor is not in room: %d", typed.ActorUserID())
|
||
}
|
||
// 心跳只刷新 last_seen,不产生用户进房事件。
|
||
existing.LastSeenAtMS = typed.SentAtMS
|
||
current.Version++
|
||
case *command.LeaveRoom:
|
||
// 离房回放只删除业务 presence,不涉及腾讯云 IM 连接态。
|
||
delete(current.OnlineUsers, typed.ActorUserID())
|
||
if seat, exists := current.SeatByUser(typed.ActorUserID()); exists {
|
||
// 离房恢复也必须释放麦位,避免 snapshot 落后时重建出幽灵占位。
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.ClearMicSession(index)
|
||
}
|
||
current.Version++
|
||
case *command.CloseRoom:
|
||
current.Status = state.RoomStatusClosed
|
||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||
for index := range current.MicSeats {
|
||
current.ClearMicSession(index)
|
||
}
|
||
current.Version++
|
||
case *command.AdminUpdateRoom:
|
||
applyAdminRoomPatch(current, *typed)
|
||
if typed.Status != nil && (*typed.Status == state.RoomStatusClosed || *typed.Status == state.RoomStatusDeleted) {
|
||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||
for index := range current.MicSeats {
|
||
current.ClearMicSession(index)
|
||
}
|
||
}
|
||
current.Version++
|
||
case *command.AdminDeleteRoom:
|
||
current.Status = state.RoomStatusDeleted
|
||
current.OnlineUsers = map[int64]*state.RoomUserState{}
|
||
for index := range current.MicSeats {
|
||
current.ClearMicSession(index)
|
||
}
|
||
current.Version++
|
||
case *command.MicUp:
|
||
index := current.SeatIndex(typed.SeatNo)
|
||
if index < 0 {
|
||
return fmt.Errorf("mic_up replay references missing seat: %d", typed.SeatNo)
|
||
}
|
||
current.Version++
|
||
current.MicSeats[index].UserID = typed.ActorUserID()
|
||
current.MicSeats[index].PublishState = state.MicPublishPending
|
||
current.MicSeats[index].MicSessionID = typed.MicSessionID
|
||
current.MicSeats[index].PublishDeadlineMS = typed.PublishDeadlineMS
|
||
current.MicSeats[index].MicSessionRoomVersion = current.Version
|
||
// pending_publish 还没有接受 RTC/客户端事件,恢复时保持 0,避免客户端时间慢于服务端命令时间时无法确认。
|
||
current.MicSeats[index].LastPublishEventTimeMS = 0
|
||
case *command.MicDown:
|
||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||
if !exists {
|
||
return fmt.Errorf("mic_down replay target is not on seat: %d", typed.TargetUserID)
|
||
}
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.ClearMicSession(index)
|
||
current.Version++
|
||
case *command.ConfirmMicPublishing:
|
||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||
if !exists {
|
||
return fmt.Errorf("confirm_mic_publishing replay target is not on seat: %d", typed.TargetUserID)
|
||
}
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.MicSeats[index].PublishState = state.MicPublishPublishing
|
||
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
|
||
current.Version++
|
||
case *command.ApplyRTCEvent:
|
||
switch typed.EventType {
|
||
case rtcEventAudioStarted:
|
||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||
if !exists {
|
||
return fmt.Errorf("apply_rtc_event audio_started replay target is not on seat: %d", typed.TargetUserID)
|
||
}
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.MicSeats[index].PublishState = state.MicPublishPublishing
|
||
current.MicSeats[index].LastPublishEventTimeMS = typed.EventTimeMS
|
||
current.Version++
|
||
case rtcEventAudioStopped:
|
||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||
if !exists {
|
||
return fmt.Errorf("apply_rtc_event audio_stopped replay target is not on seat: %d", typed.TargetUserID)
|
||
}
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.ClearMicSession(index)
|
||
current.Version++
|
||
case rtcEventRoomExited:
|
||
// room_exited 是 RTC 媒体频道退出事实,不是 App 业务离房事实。
|
||
// 实时路径只释放麦位,业务 presence 只能由 LeaveRoom、KickUser、CloseRoom 或 stale worker 清理;
|
||
// replay 必须保持相同语义,否则无最新 snapshot 的恢复会把仍在房间页的用户误删。
|
||
if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.ClearMicSession(index)
|
||
}
|
||
current.Version++
|
||
default:
|
||
return fmt.Errorf("apply_rtc_event replay unsupported event type: %s", typed.EventType)
|
||
}
|
||
case *command.ChangeMicSeat:
|
||
from, exists := current.SeatByUser(typed.TargetUserID)
|
||
if !exists {
|
||
return fmt.Errorf("change_mic_seat replay target is not on seat: %d", typed.TargetUserID)
|
||
}
|
||
fromIndex := current.SeatIndex(from.SeatNo)
|
||
toIndex := current.SeatIndex(typed.SeatNo)
|
||
if fromIndex < 0 || toIndex < 0 {
|
||
return fmt.Errorf("change_mic_seat replay references missing seat: from=%d to=%d", from.SeatNo, typed.SeatNo)
|
||
}
|
||
current.MicSeats[fromIndex].UserID = 0
|
||
movedSeat := current.MicSeats[fromIndex]
|
||
current.MicSeats[fromIndex].PublishState = ""
|
||
current.MicSeats[fromIndex].MicSessionID = ""
|
||
current.MicSeats[fromIndex].PublishDeadlineMS = 0
|
||
current.MicSeats[fromIndex].MicSessionRoomVersion = 0
|
||
current.MicSeats[fromIndex].LastPublishEventTimeMS = 0
|
||
current.MicSeats[fromIndex].MicMuted = false
|
||
current.MicSeats[toIndex].UserID = typed.TargetUserID
|
||
current.MicSeats[toIndex].PublishState = movedSeat.PublishState
|
||
current.MicSeats[toIndex].MicSessionID = movedSeat.MicSessionID
|
||
current.MicSeats[toIndex].PublishDeadlineMS = movedSeat.PublishDeadlineMS
|
||
current.MicSeats[toIndex].MicSessionRoomVersion = movedSeat.MicSessionRoomVersion
|
||
current.MicSeats[toIndex].LastPublishEventTimeMS = movedSeat.LastPublishEventTimeMS
|
||
current.MicSeats[toIndex].MicMuted = movedSeat.MicMuted
|
||
current.Version++
|
||
case *command.SetMicMute:
|
||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||
if !exists {
|
||
return fmt.Errorf("set_mic_mute replay target is not on seat: %d", typed.TargetUserID)
|
||
}
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.MicSeats[index].MicMuted = typed.Muted
|
||
current.Version++
|
||
case *command.SetMicSeatLock:
|
||
index := current.SeatIndex(typed.SeatNo)
|
||
if index < 0 {
|
||
return fmt.Errorf("set_mic_seat_lock replay references missing seat: %d", typed.SeatNo)
|
||
}
|
||
// 锁麦恢复只还原锁状态,不隐式清空已占用麦位。
|
||
current.MicSeats[index].Locked = typed.Locked
|
||
current.Version++
|
||
case *command.SetChatEnabled:
|
||
// 公屏开关恢复后直接影响 CheckSpeakPermission 的 chat_disabled 判断。
|
||
current.ChatEnabled = typed.Enabled
|
||
current.Version++
|
||
case *command.SetRoomPassword:
|
||
// 锁房恢复只还原服务端哈希状态;明文密码从不进入 command log。
|
||
if typed.Locked {
|
||
current.RoomPasswordHash = typed.PasswordHash
|
||
} else {
|
||
current.RoomPasswordHash = ""
|
||
}
|
||
current.Version++
|
||
case *command.SetRoomAdmin:
|
||
if typed.TargetUserID == current.OwnerUserID {
|
||
// owner 不是管理员;旧命令或异常数据回放时不把 owner 写入 admin 集合。
|
||
} else if typed.Enabled {
|
||
// 管理员集合是持久权限,恢复后用户重新进房即可继续具备 admin 身份。
|
||
current.AdminUsers[typed.TargetUserID] = true
|
||
} else {
|
||
delete(current.AdminUsers, typed.TargetUserID)
|
||
}
|
||
current.Version++
|
||
case *command.MuteUser:
|
||
if typed.Muted {
|
||
// 回放禁言集合,供 CheckSpeakPermission 使用。
|
||
current.MuteUsers[typed.TargetUserID] = true
|
||
} else {
|
||
delete(current.MuteUsers, typed.TargetUserID)
|
||
}
|
||
current.Version++
|
||
case *command.KickUser:
|
||
if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
|
||
// 被踢用户恢复后不能继续占麦位。
|
||
index := current.SeatIndex(seat.SeatNo)
|
||
current.ClearMicSession(index)
|
||
}
|
||
delete(current.OnlineUsers, typed.TargetUserID)
|
||
delete(current.AdminUsers, typed.TargetUserID)
|
||
current.BanUsers[typed.TargetUserID] = true
|
||
current.Version++
|
||
case *command.UnbanUser:
|
||
// Unban 只解除 ban,不恢复被 KickUser 删除的 presence、麦位或管理员身份。
|
||
delete(current.BanUsers, typed.TargetUserID)
|
||
current.Version++
|
||
case *command.SendGift:
|
||
// 送礼回放不能再次调用 wallet,只使用命令中记录的钱包结算热度重建展示态。
|
||
total := typed.HeatValue
|
||
current.Heat += total
|
||
applied := false
|
||
for index := range current.GiftRank {
|
||
if current.GiftRank[index].UserID == typed.ActorUserID() {
|
||
current.GiftRank[index].Score += total
|
||
current.GiftRank[index].GiftValue += total
|
||
current.GiftRank[index].UpdatedAtMS = typed.SentAtMS
|
||
applied = true
|
||
break
|
||
}
|
||
}
|
||
|
||
if !applied {
|
||
// 用户首次出现在榜单时追加榜单项,排序由 LocalRank/快照展示阶段处理。
|
||
current.GiftRank = append(current.GiftRank, state.RankItem{
|
||
UserID: typed.ActorUserID(),
|
||
Score: total,
|
||
GiftValue: total,
|
||
UpdatedAtMS: typed.SentAtMS,
|
||
})
|
||
}
|
||
|
||
if typed.TreasureTouched {
|
||
current.Treasure = treasureStateFromSettledGift(typed)
|
||
}
|
||
|
||
current.Version++
|
||
case *command.OpenRoomTreasure:
|
||
if current.Treasure == nil || current.Treasure.BoxID != typed.BoxID {
|
||
// 开箱命令必须对应当前倒计时宝箱;如果快照已包含更新后的下一轮状态,回放保持幂等。
|
||
return nil
|
||
}
|
||
current.Treasure = treasureStateFromOpenCommand(typed)
|
||
current.Version++
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func treasureStateFromSettledGift(cmd *command.SendGift) *state.TreasureState {
|
||
return &state.TreasureState{
|
||
CurrentLevel: cmd.TreasureLevel,
|
||
CurrentProgress: cmd.TreasureProgress,
|
||
EnergyThreshold: cmd.TreasureEnergyThreshold,
|
||
Status: cmd.TreasureStatus,
|
||
CountdownStartedAtMS: cmd.TreasureCountdownStartedAtMS,
|
||
OpenAtMS: cmd.TreasureOpenAtMS,
|
||
ResetAtMS: cmd.TreasureResetAtMS,
|
||
Top1UserID: cmd.TreasureTop1UserID,
|
||
IgniterUserID: cmd.TreasureIgniterUserID,
|
||
BoxID: cmd.TreasureBoxID,
|
||
ConfigVersion: cmd.TreasureConfigVersion,
|
||
}
|
||
}
|
||
|
||
func treasureStateFromOpenCommand(cmd *command.OpenRoomTreasure) *state.TreasureState {
|
||
return &state.TreasureState{
|
||
CurrentLevel: cmd.NextLevel,
|
||
EnergyThreshold: cmd.NextEnergyThreshold,
|
||
Status: state.TreasureStatusIdle,
|
||
OpenedAtMS: cmd.SentAtMS,
|
||
ResetAtMS: cmd.ResetAtMS,
|
||
ConfigVersion: cmd.ConfigVersion,
|
||
LastRewards: treasureRewardsFromCommand(cmd.Rewards),
|
||
}
|
||
}
|
||
|
||
func treasureRewardsFromCommand(input []command.TreasureRewardGrant) []state.TreasureRewardGrant {
|
||
rewards := make([]state.TreasureRewardGrant, 0, len(input))
|
||
for _, reward := range input {
|
||
rewards = append(rewards, state.TreasureRewardGrant{
|
||
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 rewards
|
||
}
|