修复用户上麦心跳

This commit is contained in:
zhx 2026-07-13 12:22:30 +08:00
parent b9603832fc
commit 8715b19f28
5 changed files with 362 additions and 10 deletions

View File

@ -54,7 +54,10 @@ func TestRobotRoomStalePresenceKeepsConfiguredRobots(t *testing.T) {
}
}
func TestReplayRoomHeartbeatAfterLeaveIsIgnored(t *testing.T) {
func TestReplayRoomHeartbeatWithoutPresenceRecreatesIt(t *testing.T) {
// 线上心跳会对丢失的 presence 静默恢复后才提交命令日志;回放遇到"用户不存在"的
// 已提交心跳必须重现恢复(含 Version++),并且绝不能让整个 Room Cell 恢复失败。
// 旧语义的"忽略跳过"会让恢复出的 OnlineUsers/版本与快照谱系分叉。
current := &state.RoomState{
OnlineUsers: map[int64]*state.RoomUserState{},
}
@ -67,13 +70,14 @@ func TestReplayRoomHeartbeatAfterLeaveIsIgnored(t *testing.T) {
},
}, 1781675913212)
if err != nil {
t.Fatalf("stale heartbeat after leave must not break recovery: %v", err)
t.Fatalf("heartbeat without presence must not break recovery: %v", err)
}
if len(current.OnlineUsers) != 0 {
t.Fatalf("stale heartbeat must not recreate presence: %+v", current.OnlineUsers)
recovered, exists := current.OnlineUsers[123456]
if !exists || recovered.Role != "audience" || recovered.LastSeenAtMS != 1781675912713 {
t.Fatalf("replayed heartbeat heal must recreate presence from SentAtMS: %+v", current.OnlineUsers)
}
if current.Version != 0 {
t.Fatalf("ignored heartbeat must not advance room version, got %d", current.Version)
if current.Version != 1 {
t.Fatalf("replayed heartbeat heal must advance room version exactly once, got %d", current.Version)
}
}

View File

@ -360,8 +360,31 @@ func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRe
existing, exists := current.OnlineUsers[cmd.ActorUserID()]
if !exists {
// 心跳只刷新已存在 presence不能把已离房用户重新加入房间。
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
// presence 已丢失但客户端仍在按房间节奏心跳,说明发生过 stale 清理或 owner
// 迁移这类服务端单方面清理;不恢复的话该用户会陷入 MicUp 404、RTC token 403
// 的粘性失败,只能退房重进。这里按"心跳即在房"原地恢复。
// 安全边界与 JoinRoom 对齐room active 已在上方校验,活跃 ban踢人/封禁)
// 已在上方拦截;锁房进入需要密码而心跳不携带密码,非房主不得借心跳绕过锁房,
// 维持原 NotFound 契约让客户端走完整 JoinRoom 补交密码。
if current.RoomPasswordHash != "" && current.OwnerUserID != cmd.ActorUserID() {
return mutationResult{}, nil, xerr.New(xerr.NotFound, "user not in room")
}
// 与 JoinRoom presence_recovery 相同的静默语义:只补 Room Cell 状态,不发
// sync IM/direct IM/durable outbox避免重复入房横幅与统计重复计数。
// LeaveRoom 后仍在途的迟到心跳会短暂恢复 presence由 stale worker 在下一个
// 清理窗口自然移除,期间同样不产生任何对外事件。
current.OnlineUsers[cmd.ActorUserID()] = &state.RoomUserState{
UserID: cmd.ActorUserID(),
Role: recoveredPresenceRole(current, cmd.ActorUserID()),
JoinedAtMS: now.UnixMilli(),
LastSeenAtMS: now.UnixMilli(),
}
current.Version++
snapshot := current.ToProto()
return mutationResult{
snapshot: snapshot,
user: findProtoUser(snapshot, cmd.ActorUserID()),
}, nil, nil
}
existing.LastSeenAtMS = now.UnixMilli()
@ -384,6 +407,20 @@ func (s *Service) RoomHeartbeat(ctx context.Context, req *roomv1.RoomHeartbeatRe
}, nil
}
// recoveredPresenceRole 推导心跳恢复 presence 时的展示角色。房间权限判定始终以
// OwnerUserID/AdminUsers 权威字段为准(见 actorRole这里只保证在线列表的房主/
// 管理员徽标不因恢复而丢失;无法从权威字段证明的身份一律回落 audience不存在提权。
func recoveredPresenceRole(current *state.RoomState, userID int64) string {
switch {
case current.OwnerUserID == userID:
return "owner"
case current.AdminUsers[userID]:
return "admin"
default:
return "audience"
}
}
// LeaveRoom 把用户从房间业务态移出。
func (s *Service) LeaveRoom(ctx context.Context, req *roomv1.LeaveRoomRequest) (*roomv1.LeaveRoomResponse, error) {
ctx = contextFromMeta(ctx, req.GetMeta())

View File

@ -0,0 +1,227 @@
package service_test
import (
"context"
"testing"
"time"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/xerr"
"hyapp/services/room-service/internal/integration"
roomservice "hyapp/services/room-service/internal/room/service"
"hyapp/services/room-service/internal/router"
"hyapp/services/room-service/internal/testutil/mysqltest"
)
func newHeartbeatRecoveryService(t *testing.T, repository *mysqltest.Repository, nodeID string, clk *fixedRoomRocketClock) *roomservice.Service {
t.Helper()
cfg := roomservice.Config{
NodeID: nodeID,
LeaseTTL: 10 * time.Second,
RankLimit: 20,
SnapshotEveryN: 1,
}
if clk != nil {
cfg.Clock = clk
}
return roomservice.New(cfg, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
}
func createHeartbeatRecoveryRoom(t *testing.T, svc *roomservice.Service, roomID string, ownerID int64, shortID string) {
t.Helper()
if _, err := svc.CreateRoom(context.Background(), &roomv1.CreateRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
SeatCount: 10,
Mode: "voice",
VisibleRegionId: 8101,
RoomName: "Heartbeat Recovery",
RoomAvatar: testRoomCoverURL,
RoomShortId: shortID,
}); err != nil {
t.Fatalf("create heartbeat recovery room fixture failed: %v", err)
}
}
func TestRoomHeartbeatRecoversClearedPresence(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newHeartbeatRecoveryService(t, repository, "node-hb-recover-test", nil)
roomID := "room-hb-recover"
ownerID := int64(7501)
viewerID := int64(7601)
createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-recover")
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join viewer failed: %v", err)
}
// LeaveRoom 与 stale sweep 走同一条 presence 删除路径,等价模拟"服务端单方面清掉 presence"。
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
}); err != nil {
t.Fatalf("leave viewer failed: %v", err)
}
outboxBefore := repository.CountRowsByRoom("room_outbox", roomID)
heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
})
if err != nil {
t.Fatalf("heartbeat must recover cleared presence: %v", err)
}
recovered := findRoomUser(heartbeatResp.GetRoom(), viewerID)
if recovered == nil || recovered.GetRole() != "audience" {
t.Fatalf("recovered presence must be back online as audience: %+v", heartbeatResp.GetRoom())
}
if outboxAfter := repository.CountRowsByRoom("room_outbox", roomID); outboxAfter != outboxBefore {
// 恢复必须静默:不发 RoomUserJoined 等 durable outbox 事件,避免重复入房横幅与统计重复计数。
t.Fatalf("silent recovery must not emit outbox events: before=%d after=%d", outboxBefore, outboxAfter)
}
if _, err := svc.MicUp(ctx, &roomv1.MicUpRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
SeatNo: 1,
}); err != nil {
t.Fatalf("mic up must succeed right after heartbeat recovery: %v", err)
}
}
func TestRoomHeartbeatKeepsRejectingKickedUser(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newHeartbeatRecoveryService(t, repository, "node-hb-kick-guard-test", nil)
roomID := "room-hb-kick-guard"
ownerID := int64(7701)
viewerID := int64(7801)
createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-kick-guard")
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join viewer failed: %v", err)
}
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: viewerID,
}); err != nil {
t.Fatalf("kick viewer failed: %v", err)
}
_, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
})
if err == nil || xerr.CodeOf(err) != xerr.PermissionDenied {
t.Fatalf("kicked user heartbeat must stay PermissionDenied, got: %v", err)
}
presenceResp, err := svc.VerifyRoomPresence(ctx, &roomv1.VerifyRoomPresenceRequest{
AppCode: appcode.Default,
RoomId: roomID,
UserId: viewerID,
})
if err != nil {
t.Fatalf("verify presence after rejected heartbeat failed: %v", err)
}
if presenceResp.GetPresent() || presenceResp.GetReason() != "user_banned" {
t.Fatalf("kicked user must not regain presence through heartbeat: %+v", presenceResp)
}
}
func TestRoomHeartbeatRecoveryAfterKickExpires(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
clk := &fixedRoomRocketClock{now: time.Date(2026, 7, 13, 8, 0, 0, 0, time.UTC)}
svc := newHeartbeatRecoveryService(t, repository, "node-hb-kick-expire-test", clk)
roomID := "room-hb-kick-expire"
ownerID := int64(7901)
viewerID := int64(8001)
createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-kick-expire")
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
}); err != nil {
t.Fatalf("join viewer failed: %v", err)
}
if _, err := svc.KickUser(ctx, &roomv1.KickUserRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
TargetUserId: viewerID,
DurationMs: 1000,
}); err != nil {
t.Fatalf("duration kick failed: %v", err)
}
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
}); err == nil || xerr.CodeOf(err) != xerr.PermissionDenied {
t.Fatalf("heartbeat inside ban window must stay PermissionDenied, got: %v", err)
}
clk.now = clk.now.Add(2 * time.Second)
heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
})
if err != nil {
t.Fatalf("heartbeat after ban expiry must recover presence: %v", err)
}
if findRoomUser(heartbeatResp.GetRoom(), viewerID) == nil {
t.Fatalf("expired ban user must be back online: %+v", heartbeatResp.GetRoom())
}
}
func TestRoomHeartbeatDoesNotBypassRoomPassword(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
svc := newHeartbeatRecoveryService(t, repository, "node-hb-password-test", nil)
roomID := "room-hb-password"
ownerID := int64(8101)
viewerID := int64(8201)
createHeartbeatRecoveryRoom(t, svc, roomID, ownerID, "hb-password")
if _, err := svc.SetRoomPassword(ctx, &roomv1.SetRoomPasswordRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
Locked: true,
Password: "4321",
}); err != nil {
t.Fatalf("set room password failed: %v", err)
}
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
Role: "audience",
Password: "4321",
}); err != nil {
t.Fatalf("join locked room with password failed: %v", err)
}
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
}); err != nil {
t.Fatalf("leave viewer failed: %v", err)
}
// 心跳不携带密码,非房主不得借心跳绕过锁房,必须维持原 NotFound 契约走完整 JoinRoom。
if _, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
Meta: roomservice.NewRequestMeta(roomID, viewerID),
}); err == nil || xerr.CodeOf(err) != xerr.NotFound {
t.Fatalf("locked room heartbeat recovery must stay NotFound for non-owner, got: %v", err)
}
// 房主不受锁房密码约束(与 JoinRoom 一致),恢复后角色收敛为 owner。
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
}); err != nil {
t.Fatalf("leave owner failed: %v", err)
}
heartbeatResp, err := svc.RoomHeartbeat(ctx, &roomv1.RoomHeartbeatRequest{
Meta: roomservice.NewRequestMeta(roomID, ownerID),
})
if err != nil {
t.Fatalf("owner heartbeat recovery in locked room failed: %v", err)
}
owner := findRoomUser(heartbeatResp.GetRoom(), ownerID)
if owner == nil || owner.GetRole() != "owner" {
t.Fatalf("owner recovery must keep owner role: %+v", heartbeatResp.GetRoom())
}
}

View File

@ -0,0 +1,74 @@
package service
import (
"testing"
"hyapp/services/room-service/internal/room/command"
"hyapp/services/room-service/internal/room/state"
)
func replayHeartbeat(t *testing.T, current *state.RoomState, userID int64, sentAtMS int64) {
t.Helper()
cmd := &command.RoomHeartbeat{Base: command.Base{
AppCode: "lalu",
RequestID: "req-replay-heartbeat",
CommandID: "cmd-replay-heartbeat",
ActorID: userID,
Room: current.RoomID,
SentAtMS: sentAtMS,
}}
if err := replay(current, cmd, sentAtMS); err != nil {
t.Fatalf("replay heartbeat for user %d failed: %v", userID, err)
}
}
// 线上心跳会静默恢复丢失的 presence 后才提交命令日志;回放必须重现恢复(含 Version++
// 否则恢复出的 OnlineUsers/版本与快照谱系分叉。
func TestReplayRoomHeartbeatRecreatesMissingPresence(t *testing.T) {
ownerID := int64(11)
adminID := int64(22)
viewerID := int64(33)
current := state.NewRoomState("room-replay-heartbeat", ownerID, 4, "voice")
current.AdminUsers[adminID] = true
baseVersion := current.Version
replayHeartbeat(t, current, viewerID, 1_000)
viewer, exists := current.OnlineUsers[viewerID]
if !exists || viewer.Role != "audience" || viewer.JoinedAtMS != 1_000 || viewer.LastSeenAtMS != 1_000 {
t.Fatalf("replayed heal must recreate audience presence from SentAtMS: %+v", viewer)
}
if current.Version != baseVersion+1 {
t.Fatalf("replayed heal must advance version exactly once: base=%d now=%d", baseVersion, current.Version)
}
// 角色从权威字段推导owner/admin 恢复后徽标不丢失。
replayHeartbeat(t, current, ownerID, 2_000)
if owner := current.OnlineUsers[ownerID]; owner == nil || owner.Role != "owner" {
t.Fatalf("replayed heal must restore owner role: %+v", owner)
}
replayHeartbeat(t, current, adminID, 3_000)
if admin := current.OnlineUsers[adminID]; admin == nil || admin.Role != "admin" {
t.Fatalf("replayed heal must restore admin role: %+v", admin)
}
}
func TestReplayRoomHeartbeatRefreshesExistingPresence(t *testing.T) {
viewerID := int64(44)
current := state.NewRoomState("room-replay-heartbeat-refresh", 11, 4, "voice")
current.OnlineUsers[viewerID] = &state.RoomUserState{
UserID: viewerID,
Role: "audience",
JoinedAtMS: 500,
LastSeenAtMS: 500,
}
baseVersion := current.Version
replayHeartbeat(t, current, viewerID, 9_000)
viewer := current.OnlineUsers[viewerID]
if viewer.LastSeenAtMS != 9_000 || viewer.JoinedAtMS != 500 {
t.Fatalf("replayed refresh must only update last_seen: %+v", viewer)
}
if current.Version != baseVersion+1 {
t.Fatalf("replayed refresh must advance version exactly once: base=%d now=%d", baseVersion, current.Version)
}
}

View File

@ -187,8 +187,18 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
case *command.RoomHeartbeat:
existing, exists := current.OnlineUsers[typed.ActorUserID()]
if !exists {
// 心跳和离房可能在同一房间版本附近并发落库;如果离房先被重放,随后到达的旧心跳
// 不能重新创建 presence也不能让整个 Room Cell 恢复失败。
// 线上心跳对丢失的 presence 会执行静默恢复后才提交命令日志;回放遇到
// "用户不存在"的已提交心跳,就是当时恢复成功的事实,必须重现恢复
// (含 Version++),否则恢复出的 OnlineUsers/版本会与快照谱系分叉。
// 守卫room active/ban/锁房)在线上提交前已把关,被拒的心跳不会落库,
// 回放无需也不能重复判断——回放看到的 BanUsers 时点可能晚于命令时点。
current.OnlineUsers[typed.ActorUserID()] = &state.RoomUserState{
UserID: typed.ActorUserID(),
Role: recoveredPresenceRole(current, typed.ActorUserID()),
JoinedAtMS: typed.SentAtMS,
LastSeenAtMS: typed.SentAtMS,
}
current.Version++
return nil
}
// 心跳只刷新 last_seen不产生用户进房事件。