修复短房号入房和麦心跳恢复
This commit is contained in:
parent
5ad7160033
commit
8d8fb77df9
@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
@ -201,6 +202,49 @@ func baseFromMeta(meta *roomv1.RequestMeta) command.Base {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) canonicalRoomCommandMeta(ctx context.Context, meta *roomv1.RequestMeta) (*roomv1.RequestMeta, error) {
|
||||
if meta == nil {
|
||||
return nil, nil
|
||||
}
|
||||
requestedRoomID := strings.TrimSpace(meta.GetRoomId())
|
||||
if requestedRoomID == "" {
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
if roomMeta, exists, err := s.repository.GetRoomMeta(ctx, requestedRoomID); err != nil {
|
||||
return nil, err
|
||||
} else if exists {
|
||||
if roomMeta.RoomID == meta.GetRoomId() {
|
||||
return meta, nil
|
||||
}
|
||||
// 精确 room_id 命中时也用数据库里的规范值,避免首尾空白进入 lease key 或 command log。
|
||||
return requestMetaWithRoomID(meta, roomMeta.RoomID), nil
|
||||
}
|
||||
|
||||
roomMeta, exists, err := s.repository.GetRoomMetaByShortID(ctx, requestedRoomID)
|
||||
if err != nil || !exists {
|
||||
return meta, err
|
||||
}
|
||||
// 客户端可能只知道展示短房号;进入 Room Cell 前必须收敛到内部 room_id,保证 lease、command log、snapshot 分区一致。
|
||||
return requestMetaWithRoomID(meta, roomMeta.RoomID), nil
|
||||
}
|
||||
|
||||
func requestMetaWithRoomID(meta *roomv1.RequestMeta, roomID string) *roomv1.RequestMeta {
|
||||
if meta == nil {
|
||||
return nil
|
||||
}
|
||||
return &roomv1.RequestMeta{
|
||||
RequestId: meta.GetRequestId(),
|
||||
CommandId: meta.GetCommandId(),
|
||||
ActorUserId: meta.GetActorUserId(),
|
||||
RoomId: roomID,
|
||||
GatewayNodeId: meta.GetGatewayNodeId(),
|
||||
SessionId: meta.GetSessionId(),
|
||||
SentAtMs: meta.GetSentAtMs(),
|
||||
AppCode: meta.GetAppCode(),
|
||||
}
|
||||
}
|
||||
|
||||
func contextFromMeta(ctx context.Context, meta *roomv1.RequestMeta) context.Context {
|
||||
if meta == nil {
|
||||
return appcode.WithContext(ctx, "")
|
||||
|
||||
@ -24,10 +24,14 @@ const joinRoomResponseModeLite = "lite"
|
||||
// JoinRoom 把用户业务态接入房间。
|
||||
func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*roomv1.JoinRoomResponse, error) {
|
||||
ctx = contextFromMeta(ctx, req.GetMeta())
|
||||
meta, err := s.canonicalRoomCommandMeta(ctx, req.GetMeta())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
password := strings.TrimSpace(req.GetPassword())
|
||||
// JoinRoom 只维护 room-service presence,不创建任何本地长连接订阅。
|
||||
cmd := command.JoinRoom{
|
||||
Base: baseFromMeta(req.GetMeta()),
|
||||
Base: baseFromMeta(meta),
|
||||
Role: req.GetRole(),
|
||||
ActorCountryID: req.GetActorCountryId(),
|
||||
ActorRegionID: req.GetActorRegionId(),
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
package service_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
roomv1 "hyapp.local/api/proto/room/v1"
|
||||
"hyapp/pkg/appcode"
|
||||
"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 TestJoinRoomAcceptsRoomShortIDWithoutWritingShortIDCommandLog(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
svc := roomservice.New(roomservice.Config{
|
||||
NodeID: "node-short-id-join-test",
|
||||
LeaseTTL: 10 * time.Second,
|
||||
RankLimit: 20,
|
||||
SnapshotEveryN: 1,
|
||||
}, router.NewMemoryDirectory(), repository, followTestWallet{}, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
||||
|
||||
roomID := "room-short-id-join"
|
||||
roomShortID := "167822"
|
||||
ownerID := int64(6101)
|
||||
viewerID := int64(6102)
|
||||
if _, err := svc.CreateRoom(ctx, &roomv1.CreateRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, ownerID),
|
||||
SeatCount: 10,
|
||||
Mode: "voice",
|
||||
VisibleRegionId: 6101,
|
||||
RoomName: "Short ID Join",
|
||||
RoomAvatar: testRoomCoverURL,
|
||||
RoomShortId: roomShortID,
|
||||
}); err != nil {
|
||||
t.Fatalf("create room: %v", err)
|
||||
}
|
||||
|
||||
joinMeta := roomservice.NewRequestMeta(roomShortID, viewerID)
|
||||
joinMeta.CommandId = "cmd-join-by-room-short-id"
|
||||
resp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: joinMeta,
|
||||
Role: "audience",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("join room by short id: %v", err)
|
||||
}
|
||||
if resp.GetRoom().GetRoomId() != roomID || resp.GetRoom().GetRoomShortId() != roomShortID {
|
||||
t.Fatalf("join response must expose canonical room id and short id, got %+v", resp.GetRoom())
|
||||
}
|
||||
|
||||
repoCtx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
if _, exists, err := repository.GetCommand(repoCtx, roomID, joinMeta.GetCommandId()); err != nil || !exists {
|
||||
t.Fatalf("join command must be written under canonical room id, exists=%v err=%v", exists, err)
|
||||
}
|
||||
if _, exists, err := repository.GetCommand(repoCtx, roomShortID, joinMeta.GetCommandId()); err != nil || exists {
|
||||
t.Fatalf("short id must not create an independent command log partition, exists=%v err=%v", exists, err)
|
||||
}
|
||||
}
|
||||
@ -259,10 +259,12 @@ func replay(current *state.RoomState, cmd command.Command, committedAtMS int64)
|
||||
case *command.MicHeartbeat:
|
||||
seat, exists := current.SeatByUser(typed.TargetUserID)
|
||||
if !exists {
|
||||
return fmt.Errorf("mic_heartbeat replay target is not on seat: %d", typed.TargetUserID)
|
||||
// 麦上心跳只是当前会话的 liveness 刷新;旧心跳晚于离房/下麦落库时,不能重新占麦,也不能阻断房间恢复。
|
||||
return nil
|
||||
}
|
||||
if seat.MicSessionID != typed.MicSessionID || seat.PublishState != state.MicPublishPublishing {
|
||||
return fmt.Errorf("mic_heartbeat replay target session is not publishing: %d", typed.TargetUserID)
|
||||
// 会话已被新一轮上麦或 pending 状态替换时,旧心跳不再代表当前麦位事实,恢复时按过期事件跳过。
|
||||
return nil
|
||||
}
|
||||
if existing, ok := current.OnlineUsers[typed.TargetUserID]; ok {
|
||||
// 麦上心跳回放要恢复同步刷新的 presence 时间,避免 snapshot 落后时重启后又被 stale worker 清理。
|
||||
|
||||
43
services/room-service/internal/room/service/recovery_test.go
Normal file
43
services/room-service/internal/room/service/recovery_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"hyapp/services/room-service/internal/room/command"
|
||||
"hyapp/services/room-service/internal/room/state"
|
||||
)
|
||||
|
||||
func TestReplayMicHeartbeatSkipsStaleMissingSeat(t *testing.T) {
|
||||
current := state.NewRoomState("room-replay-stale-heartbeat", 7101, 8, "voice")
|
||||
current.OnlineUsers[7101] = &state.RoomUserState{UserID: 7101, Role: "owner", JoinedAtMS: 1000, LastSeenAtMS: 1000}
|
||||
|
||||
if err := replay(current, &command.MicHeartbeat{
|
||||
Base: command.Base{ActorID: 7101, Room: current.RoomID, SentAtMS: 2000},
|
||||
TargetUserID: 7101,
|
||||
MicSessionID: "mic-old",
|
||||
}, 2000); err != nil {
|
||||
t.Fatalf("stale heartbeat without seat must be skipped: %v", err)
|
||||
}
|
||||
if current.Version != 0 || current.OnlineUsers[7101].LastSeenAtMS != 1000 {
|
||||
t.Fatalf("stale heartbeat must not mutate state, version=%d user=%+v", current.Version, current.OnlineUsers[7101])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplayMicHeartbeatSkipsStaleSession(t *testing.T) {
|
||||
current := state.NewRoomState("room-replay-stale-session", 7201, 8, "voice")
|
||||
current.OnlineUsers[7201] = &state.RoomUserState{UserID: 7201, Role: "owner", JoinedAtMS: 1000, LastSeenAtMS: 1000}
|
||||
current.MicSeats[0].UserID = 7201
|
||||
current.MicSeats[0].PublishState = state.MicPublishPending
|
||||
current.MicSeats[0].MicSessionID = "mic-new"
|
||||
|
||||
if err := replay(current, &command.MicHeartbeat{
|
||||
Base: command.Base{ActorID: 7201, Room: current.RoomID, SentAtMS: 2000},
|
||||
TargetUserID: 7201,
|
||||
MicSessionID: "mic-old",
|
||||
}, 2000); err != nil {
|
||||
t.Fatalf("stale heartbeat for replaced session must be skipped: %v", err)
|
||||
}
|
||||
if current.Version != 0 || current.MicSeats[0].MicHeartbeatAtMS != 0 || current.OnlineUsers[7201].LastSeenAtMS != 1000 {
|
||||
t.Fatalf("stale heartbeat must not refresh pending/replaced session, version=%d seat=%+v user=%+v", current.Version, current.MicSeats[0], current.OnlineUsers[7201])
|
||||
}
|
||||
}
|
||||
@ -715,6 +715,8 @@ type RoomMetaStore interface {
|
||||
SaveRoomMeta(ctx context.Context, meta RoomMeta) error
|
||||
// GetRoomMeta 读取房间基础元数据,恢复无内存 Cell 的房间依赖它。
|
||||
GetRoomMeta(ctx context.Context, roomID string) (RoomMeta, bool, error)
|
||||
// GetRoomMetaByShortID 用客户端展示短房号解析内部 room_id;命令链路必须先 canonicalize 后再取 lease 和写 command log。
|
||||
GetRoomMetaByShortID(ctx context.Context, roomShortID string) (RoomMeta, bool, error)
|
||||
// GetRoomMetaByOwner 查询 owner 已创建的房间;产品规则要求一个用户只能创建一个房间。
|
||||
GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (RoomMeta, bool, error)
|
||||
// SaveRoomBackground 保存房间 owner 上传过的背景图 URL;相同房间相同 URL 幂等返回同一条素材。
|
||||
|
||||
@ -60,6 +60,30 @@ func (r *Repository) GetRoomMeta(ctx context.Context, roomID string) (roomservic
|
||||
return meta, true, nil
|
||||
}
|
||||
|
||||
// GetRoomMetaByShortID 通过展示短房号解析内部房间 ID。
|
||||
func (r *Repository) GetRoomMetaByShortID(ctx context.Context, roomShortID string) (roomservice.RoomMeta, bool, error) {
|
||||
// rooms.uk_rooms_short_id 保证同 app 下短房号唯一;这里只做解析,不改变 GetRoomMeta 的精确 room_id 语义。
|
||||
row := r.db.QueryRowContext(ctx,
|
||||
`SELECT app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, room_password_hash, visible_region_id
|
||||
FROM rooms
|
||||
WHERE app_code = ? AND room_short_id = ?
|
||||
LIMIT 1`,
|
||||
appcode.FromContext(ctx),
|
||||
strings.TrimSpace(roomShortID),
|
||||
)
|
||||
|
||||
var meta roomservice.RoomMeta
|
||||
if err := row.Scan(&meta.AppCode, &meta.RoomID, &meta.RoomShortID, &meta.OwnerUserID, &meta.SeatCount, &meta.Mode, &meta.Status, &meta.RoomPasswordHash, &meta.VisibleRegionID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return roomservice.RoomMeta{}, false, nil
|
||||
}
|
||||
|
||||
return roomservice.RoomMeta{}, false, err
|
||||
}
|
||||
|
||||
return meta, true, nil
|
||||
}
|
||||
|
||||
// GetRoomMetaByOwner 查询用户已经创建的房间。
|
||||
func (r *Repository) GetRoomMetaByOwner(ctx context.Context, ownerUserID int64) (roomservice.RoomMeta, bool, error) {
|
||||
// rooms.owner_user_id 有唯一约束,查询最多返回一条;该读路径用于 CreateRoom 前置失败。
|
||||
|
||||
@ -0,0 +1,25 @@
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
)
|
||||
|
||||
func TestGetRoomMetaByShortIDReturnsCanonicalRoomID(t *testing.T) {
|
||||
ctx := appcode.WithContext(context.Background(), appcode.Default)
|
||||
repo := newCommandLogTestRepository(t, ctx)
|
||||
insertCommandLogTestRoom(t, ctx, repo, "room-meta-short")
|
||||
|
||||
meta, exists, err := repo.GetRoomMetaByShortID(ctx, " room-meta-short-short ")
|
||||
if err != nil {
|
||||
t.Fatalf("get room meta by short id: %v", err)
|
||||
}
|
||||
if !exists {
|
||||
t.Fatal("room short id should resolve existing room meta")
|
||||
}
|
||||
if meta.RoomID != "room-meta-short" || meta.RoomShortID != "room-meta-short-short" {
|
||||
t.Fatalf("short id must return canonical room meta, got %+v", meta)
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user