214 lines
7.2 KiB
Go
214 lines
7.2 KiB
Go
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
|
||
}
|