修复麦事件恢复旧事件跳过

This commit is contained in:
zhx 2026-07-07 19:55:29 +08:00
parent 8d8fb77df9
commit 967e1912f3
2 changed files with 85 additions and 6 deletions

View File

@ -248,7 +248,12 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
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)
// 确认事件可能晚于下麦/离房落库;运行态对此按 no-op 处理,恢复也不能让旧 RTC/客户端事件卡死房间。
return nil
}
if seat.MicSessionID != typed.MicSessionID || seat.PublishState != state.MicPublishPending {
// 只允许当前 pending mic_session 被确认;旧会话、重复确认或已被替换的会话都按过期事件跳过。
return nil
}
index := current.SeatIndex(seat.SeatNo)
acceptedEventTimeMS := confirmMicPublishingAcceptedEventTimeMS(typed)
@ -278,7 +283,11 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
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)
return nil
}
if seat.PublishState != state.MicPublishPending || typed.EventTimeMS <= seat.LastPublishEventTimeMS || (seat.PublishDeadlineMS > 0 && typed.EventTimeMS > seat.PublishDeadlineMS) {
// RTC 开始发布只确认当前 pending 会话;乱序、重复或超时事件恢复时保持 no-op。
return nil
}
index := current.SeatIndex(seat.SeatNo)
current.MicSeats[index].PublishState = state.MicPublishPublishing
@ -287,7 +296,11 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
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)
return nil
}
if seat.MicSessionID == "" || (seat.LastPublishEventTimeMS > 0 && typed.EventTimeMS < seat.LastPublishEventTimeMS) {
// 停止发布只能关闭当前或更新的媒体会话;旧 stop 事件不能释放后续重上麦会话。
return nil
}
index := current.SeatIndex(seat.SeatNo)
current.ClearMicSession(index)
@ -296,10 +309,12 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
// 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)
seat, exists := current.SeatByUser(typed.TargetUserID)
if !exists {
return nil
}
index := current.SeatIndex(seat.SeatNo)
current.ClearMicSession(index)
current.Version++
default:
return fmt.Errorf("apply_rtc_event replay unsupported event type: %s", typed.EventType)

View File

@ -41,3 +41,67 @@ func TestReplayMicHeartbeatSkipsStaleSession(t *testing.T) {
t.Fatalf("stale heartbeat must not refresh pending/replaced session, version=%d seat=%+v user=%+v", current.Version, current.MicSeats[0], current.OnlineUsers[7201])
}
}
func TestReplayConfirmMicPublishingSkipsStaleMissingSeat(t *testing.T) {
current := state.NewRoomState("room-replay-stale-confirm", 7301, 8, "voice")
current.OnlineUsers[7301] = &state.RoomUserState{UserID: 7301, Role: "owner", JoinedAtMS: 1000, LastSeenAtMS: 1000}
if err := replay(current, &command.ConfirmMicPublishing{
Base: command.Base{ActorID: 7301, Room: current.RoomID, SentAtMS: 2000},
TargetUserID: 7301,
MicSessionID: "mic-old",
RoomVersion: 1,
EventTimeMS: 1900,
}, 2000); err != nil {
t.Fatalf("stale confirm without seat must be skipped: %v", err)
}
if current.Version != 0 {
t.Fatalf("stale confirm must not mutate version, got %d", current.Version)
}
}
func TestReplayConfirmMicPublishingSkipsStaleSession(t *testing.T) {
current := state.NewRoomState("room-replay-stale-confirm-session", 7401, 8, "voice")
current.OnlineUsers[7401] = &state.RoomUserState{UserID: 7401, Role: "owner", JoinedAtMS: 1000, LastSeenAtMS: 1000}
current.MicSeats[0].UserID = 7401
current.MicSeats[0].PublishState = state.MicPublishPending
current.MicSeats[0].MicSessionID = "mic-new"
if err := replay(current, &command.ConfirmMicPublishing{
Base: command.Base{ActorID: 7401, Room: current.RoomID, SentAtMS: 2000},
TargetUserID: 7401,
MicSessionID: "mic-old",
RoomVersion: 1,
EventTimeMS: 1900,
}, 2000); err != nil {
t.Fatalf("stale confirm for replaced session must be skipped: %v", err)
}
if current.Version != 0 || current.MicSeats[0].PublishState != state.MicPublishPending {
t.Fatalf("stale confirm must not publish replaced session, version=%d seat=%+v", current.Version, current.MicSeats[0])
}
}
func TestReplayApplyRTCEventSkipsStaleMissingSeat(t *testing.T) {
current := state.NewRoomState("room-replay-stale-rtc", 7501, 8, "voice")
current.OnlineUsers[7501] = &state.RoomUserState{UserID: 7501, Role: "owner", JoinedAtMS: 1000, LastSeenAtMS: 1000}
if err := replay(current, &command.ApplyRTCEvent{
Base: command.Base{ActorID: 7501, Room: current.RoomID, SentAtMS: 2000},
TargetUserID: 7501,
EventType: rtcEventAudioStarted,
EventTimeMS: 1900,
}, 2000); err != nil {
t.Fatalf("stale rtc start without seat must be skipped: %v", err)
}
if err := replay(current, &command.ApplyRTCEvent{
Base: command.Base{ActorID: 7501, Room: current.RoomID, SentAtMS: 2100},
TargetUserID: 7501,
EventType: rtcEventAudioStopped,
EventTimeMS: 2050,
}, 2100); err != nil {
t.Fatalf("stale rtc stop without seat must be skipped: %v", err)
}
if current.Version != 0 {
t.Fatalf("stale rtc events must not mutate version, got %d", current.Version)
}
}