diff --git a/services/room-service/internal/storage/mysql/command_log.go b/services/room-service/internal/storage/mysql/command_log.go index c39eb120..3d4f6b82 100644 --- a/services/room-service/internal/storage/mysql/command_log.go +++ b/services/room-service/internal/storage/mysql/command_log.go @@ -10,6 +10,7 @@ import ( "google.golang.org/protobuf/proto" "hyapp/pkg/appcode" + "hyapp/services/room-service/internal/room/command" "hyapp/services/room-service/internal/room/outbox" roomservice "hyapp/services/room-service/internal/room/service" ) @@ -76,7 +77,7 @@ func (r *Repository) SaveMutation(ctx context.Context, commit roomservice.Mutati return err } - if commit.Command.Replayable { + if commit.Command.Replayable && enforceUniqueReplayableRoomVersion(commit.Command.CommandType) { var existingCommandID string err := tx.QueryRowContext(ctx, `SELECT command_id @@ -367,6 +368,18 @@ func (r *Repository) lockRoomMutation(ctx context.Context, tx *sql.Tx, appCode s return nil } +func enforceUniqueReplayableRoomVersion(commandType string) bool { + switch strings.TrimSpace(commandType) { + case command.SendGift{}.Type(): + // SendGift 是账务成功后的 additive 房间表现:热度、榜单和展示事件都可以按自增 id 顺序补偿。 + // 历史机器人房间已经存在同版本礼物命令时,继续拦截会让 Cell 永久卡在旧版本,反而阻止后续快照推进。 + return false + default: + // presence、麦位、房间资料和生命周期命令会改变恢复结构;同版本双写必须 fail-closed。 + return true + } +} + func (r *Repository) deleteRoomRows(ctx context.Context, tx *sql.Tx, appCode string, roomID string) error { // 删除覆盖所有能让房间重新出现在列表、恢复链路或 owner 唯一约束里的本服务表。 // room_outbox 也一起清理,避免已删除房间继续补偿投递旧的展示事件。 diff --git a/services/room-service/internal/storage/mysql/command_log_test.go b/services/room-service/internal/storage/mysql/command_log_test.go index 938ccebd..0607f13c 100644 --- a/services/room-service/internal/storage/mysql/command_log_test.go +++ b/services/room-service/internal/storage/mysql/command_log_test.go @@ -109,6 +109,29 @@ func TestSaveMutationRejectsDuplicateReplayableRoomVersion(t *testing.T) { } } +func TestSaveMutationAllowsDuplicateReplayableGiftVersion(t *testing.T) { + ctx := appcode.WithContext(context.Background(), appcode.Default) + repo := newCommandLogTestRepository(t, ctx) + roomID := "room-gift-version" + + for _, commandID := range []string{"cmd-gift-first", "cmd-gift-second"} { + if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{ + AppCode: appcode.Default, + RoomID: roomID, + RoomVersion: 40, + CommandID: commandID, + CommandType: "send_gift", + Payload: []byte(`{"command_id":"` + commandID + `"}`), + Replayable: true, + OwnerNodeID: "node-a", + LeaseToken: "lease-a", + CreatedAtMS: 5000, + }}); err != nil { + t.Fatalf("same-version send_gift should remain appendable for additive recovery facts: %v", err) + } + } +} + func TestSaveMutationSerializesConcurrentReplayableRoomVersion(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repo := newCommandLogTestRepository(t, ctx)