2026-04-27 02:29:42 +08:00

214 lines
7.2 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/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"
)
func (s *Service) ensureCell(ctx context.Context, roomID string) (*cell.RoomCell, error) {
// 每次写命令先刷新或接管 lease确保同一房间同一时刻只有一个执行节点。
lease, err := s.directory.EnsureOwner(ctx, roomID, s.nodeID, s.clock.Now(), s.leaseTTL)
if err != nil {
return nil, err
}
if lease.NodeID != s.nodeID {
// 仍有其他有效 owner 时当前节点不能执行该房间命令。
return nil, xerr.New(xerr.Conflict, fmt.Sprintf("room is owned by %s", lease.NodeID))
}
if roomCell := s.loadCell(roomID); roomCell != nil {
// 本节点已经装载该房间,直接复用已有 Room Cell。
return roomCell, nil
}
// 没有内存 Cell 时从持久化恢复,支持 lease 过期后的新节点接管。
roomMeta, exists, err := s.repository.GetRoomMeta(ctx, roomID)
if err != nil {
return nil, err
}
if !exists {
// 没有 meta 说明房间从未创建,不能凭请求临时造状态。
return nil, xerr.New(xerr.NotFound, "room not found")
}
restoredState, err := s.recoverRoom(ctx, roomMeta)
if err != nil {
// 恢复失败时返回 unavailable调用方应重试或等待节点修复而不是返回 room not found。
return nil, 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(roomID, roomCell)
return roomCell, nil
}
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)
fromVersion = snapshot.GetVersion()
} else {
// 没有快照时从 rooms meta 重建初始状态,再从版本 0 回放命令。
recovered = state.NewRoomState(roomMeta.RoomID, roomMeta.OwnerUserID, roomMeta.HostUserID, roomMeta.SeatCount, roomMeta.Mode)
recovered.Status = roomMeta.Status
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 cmd == nil {
// 未知命令类型跳过,保留兼容未来命令的恢复容错。
continue
}
if err := replay(recovered, cmd); err != nil {
// replay 失败说明命令日志和当前状态不兼容,不能继续装载错误 Cell。
return nil, err
}
}
return recovered, nil
}
func replay(current *state.RoomState, cmd command.Command) error {
// replay 只重建房间内状态,不重复写 outbox、不重复调用 wallet、不重复同步腾讯云 IM。
switch typed := cmd.(type) {
case *command.CreateRoom:
// 创建命令的 meta 已由 RoomMeta 或快照恢复,这里只保证版本进入初始提交态。
current.Version = 1
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.LeaveRoom:
// 离房回放只删除业务 presence不涉及腾讯云 IM 连接态。
delete(current.OnlineUsers, typed.ActorUserID())
if seat, exists := current.SeatByUser(typed.ActorUserID()); exists {
// 离房恢复也必须释放麦位,避免 snapshot 落后时重建出幽灵占位。
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].UserID = 0
}
current.Version++
case *command.MicUp:
index := current.SeatIndex(typed.SeatNo)
if index >= 0 {
// 回放对不存在麦位保持容错,避免旧命令阻塞新结构恢复。
current.MicSeats[index].UserID = typed.ActorUserID()
current.Version++
}
case *command.MicDown:
if seat, exists := current.SeatByUser(typed.TargetUserID); exists {
// 只有目标仍在麦上时才清空,重复或乱序异常不会制造负状态。
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].UserID = 0
current.Version++
}
case *command.ChangeMicSeat:
if from, exists := current.SeatByUser(typed.TargetUserID); exists {
// 换位回放必须同时找到源麦位和目标麦位。
fromIndex := current.SeatIndex(from.SeatNo)
toIndex := current.SeatIndex(typed.SeatNo)
if fromIndex >= 0 && toIndex >= 0 {
current.MicSeats[fromIndex].UserID = 0
current.MicSeats[toIndex].UserID = 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.MicSeats[index].UserID = 0
}
delete(current.OnlineUsers, typed.TargetUserID)
current.BanUsers[typed.TargetUserID] = true
current.Version++
case *command.SendGift:
// 送礼回放不能再次调用 wallet只使用命令中记录的礼物价值重建展示态。
total := typed.GiftUnitValue * int64(typed.GiftCount)
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,
})
}
current.Version++
}
return nil
}