429 lines
16 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 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
}