直接发im,去掉 outbox一层

This commit is contained in:
zhx 2026-06-27 00:40:30 +08:00
parent 2e061101b3
commit a1e038442d
18 changed files with 1087 additions and 789 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1067,6 +1067,10 @@ message SendGiftResponse {
LuckyGiftDrawResult lucky_gift = 7;
// lucky_gifts target_user_id
repeated LuckyGiftDrawResult lucky_gifts = 8;
// coin_balance_after COIN
int64 coin_balance_after = 9;
// gift_income_balance_after COIN 0
int64 gift_income_balance_after = 10;
}
// CheckSpeakPermissionRequest IM gateway

View File

@ -3,7 +3,7 @@ package room
import "context"
// EventHandler 是 room outbox 事件进入 activity-service 后的处理边界。
// 消费来源可以是 MySQL outbox、Redis Stream 或 MQ但处理侧必须保持幂等。
// 消费来源可以是 MySQL outbox 或 MQ但处理侧必须保持幂等。
type EventHandler interface {
HandleRoomEvent(ctx context.Context, eventID string, eventType string, payload []byte) error
}

View File

@ -2597,6 +2597,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
BillingReceiptId: "receipt-lucky",
RoomHeat: 100,
CoinBalanceAfter: 8800,
LuckyGift: &roomv1.LuckyGiftDrawResult{
Enabled: true,
DrawId: "lucky_draw_test",
@ -2644,6 +2645,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
MultiplierPPM int64 `json:"multiplier_ppm"`
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
} `json:"lucky_gifts"`
CoinBalanceAfter int64 `json:"coin_balance_after"`
} `json:"data"`
}
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
@ -2655,6 +2657,9 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
if len(envelope.Data.LuckyGifts) != 2 || envelope.Data.LuckyGifts[0].TargetUserID != 43 || envelope.Data.LuckyGifts[0].MultiplierPPM != 2_000_000 || envelope.Data.LuckyGifts[1].TargetUserID != 44 || envelope.Data.LuckyGifts[1].MultiplierPPM != 3_000_000 {
t.Fatalf("lucky_gifts response mismatch: %+v", envelope.Data.LuckyGifts)
}
if envelope.Data.CoinBalanceAfter != 8800 {
t.Fatalf("send gift response must expose sender coin_balance_after: %+v", envelope.Data)
}
}
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {

View File

@ -0,0 +1,33 @@
# room-service
`room-service` owns Room Cell state, room snapshots, command log, durable room facts, Redis lease, and Tencent IM room message bridging.
## Direct IM UI
Room UI events are sent to Tencent IM directly after the Room Cell command commits. The direct IM lane is low latency and best-effort; it is not the owner of recoverable room state.
Direct-IM-only event types do not write `room_outbox` because snapshot or query APIs can repair missed display state:
- `RoomHeatChanged`: heat display; snapshot can repair missed values.
- `RoomRankChanged`: mic/rank display; direct IM display is preferred.
- `RoomRocketFuelChanged`: coalesced in memory and flushed directly to Tencent IM.
- `RoomRocketIgnited`: sent directly to Tencent IM; launch scheduling is still created after command commit.
- `RoomRocketLaunched`: launch display is sent directly to Tencent IM.
Durable facts with UI display are sent directly to Tencent IM after the Room Cell command commits, but they still write `room_outbox`:
- `RoomGiftSent`: room gift animation, public screen, and regional display use direct IM; statistics, activity, growth, task, and CP relation consumers still use durable outbox.
- `RoomUserJoined`: entry animation, vehicle, and online display use direct IM; active-user statistics still use durable outbox.
- `RoomUserLeft`: leave UI uses direct IM; recovery still comes from Room Cell command log and snapshot.
- `RoomMicChanged`: mic UI uses direct IM; mic duration consumers still use durable outbox.
- `RoomMicSeatLocked`, `RoomClosed`, `RoomUserKicked`, `RoomUserMuted`, `RoomUserUnbanned`, `RoomProfileUpdated`, `RoomBackgroundChanged`, `RoomChatEnabledChanged`, and `RoomAdminChanged`: UI updates use direct IM; state changes, side effects, audit, or notification recovery stay on command log/outbox.
## Durable Outbox
Business facts that downstream services must not miss still write `room_outbox` and are published by the outbox worker. Sending a display event directly to Tencent IM does not change command log, snapshot, room lease, wallet debit, or room state ownership.
The room outbox worker no longer publishes room UI IM. It only fans out durable facts to MQ/activity consumers. Gift sender balance is not included in room IM because room messages are broadcast to all room users. `SendGiftResponse.coin_balance_after` returns the sender's final COIN balance synchronously, including lucky-gift reward balance when a reward is granted.
`RoomRocketIgnited` keeps a reliability check outside direct IM: after the room command commits, room-service still schedules the delayed launch wakeup, and the launch worker revalidates rocket id, level, launch time, status, reset time, and room lease before creating `RoomRocketLaunched`.
Robot display events keep their original best-effort display publisher path in this pass.

View File

@ -67,11 +67,6 @@ rocketmq:
enabled: true
topic: "hyapp_room_outbox"
producer_group: "hyapp-room-outbox-producer"
tencent_im_consumer_enabled: true
tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16
consumer_goroutines: 16
im_publish_concurrency: 16
rocket_launch:
enabled: true
topic: "hyapp_room_rocket_launch"

View File

@ -77,11 +77,6 @@ rocketmq:
enabled: true
topic: "hyapp_room_outbox"
producer_group: "hyapp-room-outbox-producer"
tencent_im_consumer_enabled: true
tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16
consumer_goroutines: 16
im_publish_concurrency: 16
rocket_launch:
enabled: true
topic: "hyapp_room_rocket_launch"

View File

@ -70,11 +70,6 @@ rocketmq:
enabled: true
topic: "hyapp_room_outbox"
producer_group: "hyapp-room-outbox-producer"
tencent_im_consumer_enabled: true
tencent_im_consumer_group: "hyapp-room-im-bridge"
consumer_max_reconsume_times: 16
consumer_goroutines: 16
im_publish_concurrency: 16
rocket_launch:
enabled: true
topic: "hyapp_room_rocket_launch"

View File

@ -13,7 +13,6 @@ import (
"google.golang.org/grpc/credentials/insecure"
activityv1 "hyapp.local/api/proto/activity/v1"
roomv1 "hyapp.local/api/proto/room/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
@ -182,9 +181,6 @@ func New(cfg config.Config) (*App, error) {
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
outboxPublishers = append(outboxPublishers, activityPublisher)
}
if tencentPublisher != nil {
outboxPublishers = append(outboxPublishers, tencentPublisher)
}
}
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeMQ || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
outboxProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ProducerGroup))
@ -227,6 +223,7 @@ func New(cfg config.Config) (*App, error) {
RTCUserRemover: rtcUserRemover,
RoomRocketLaunchScheduler: rocketLaunchScheduler,
RobotDisplayPublisher: tencentPublisher,
RoomDisplayPublisher: tencentPublisher,
HumanRobotPoolProvider: integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn),
RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
RoomListCache: roomservice.NewRedisRoomListCacheStore(redisClient),
@ -257,38 +254,6 @@ func New(cfg config.Config) (*App, error) {
}
mqConsumers = append(mqConsumers, rocketConsumer)
}
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled && tencentPublisher == nil {
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, errors.New("rocketmq room_outbox tencent_im consumer requires tencent_im.enabled")
}
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled && tencentPublisher != nil {
imPublisher := integration.NewShardedOutboxPublisher(tencentPublisher, cfg.RocketMQ.RoomOutbox.IMPublishConcurrency)
imConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.TencentIMConsumerGroup, cfg.RocketMQ.RoomOutbox.ConsumerMaxReconsumeTimes, cfg.RocketMQ.RoomOutbox.ConsumerGoroutines))
if err != nil {
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
if err := imConsumer.Subscribe(cfg.RocketMQ.RoomOutbox.Topic, roommq.TagRoomOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
envelope, _, err := roommq.DecodeRoomOutboxMessage(message.Body)
if err != nil {
return err
}
return imPublisher.PublishOutboxEvent(appcode.WithContext(ctx, envelope.GetAppCode()), envelope)
}); err != nil {
_ = repository.Close()
closeClientConn(activityConn)
_ = walletConn.Close()
_ = redisClient.Close()
return nil, err
}
mqConsumers = append(mqConsumers, imConsumer)
}
if cfg.RocketMQ.UserOutbox.Enabled {
userConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.UserOutbox.ConsumerGroup, cfg.RocketMQ.UserOutbox.ConsumerMaxReconsumeTimes, 0))
if err != nil {

View File

@ -137,15 +137,16 @@ type RocketMQConfig struct {
// RoomOutboxMQConfig 控制 room_outbox fanout topic。
type RoomOutboxMQConfig struct {
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ProducerGroup string `yaml:"producer_group"`
Enabled bool `yaml:"enabled"`
Topic string `yaml:"topic"`
ProducerGroup string `yaml:"producer_group"`
// TencentIMConsumerEnabled 已废弃:房间 UI 消息由 Room Cell 提交后直发腾讯 IMroom_outbox 只保留业务事实 fanout。
TencentIMConsumerEnabled bool `yaml:"tencent_im_consumer_enabled"`
TencentIMConsumerGroup string `yaml:"tencent_im_consumer_group"`
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
// ConsumerGoroutines 控制 RocketMQ push consumer 的并发回调数;同房间顺序仍由 IM publish 分片锁保证
// ConsumerGoroutines 控制 room_outbox RocketMQ push consumer 的并发回调数,消费方仍必须自行做业务幂等
ConsumerGoroutines int `yaml:"consumer_goroutines"`
// IMPublishConcurrency 控制 MQ -> Tencent IM 桥接按 room_id 分片后的最大并行房间数
// IMPublishConcurrency 已废弃,仅兼容旧配置文件;房间 IM 不再由 MQ consumer 桥接
IMPublishConcurrency int `yaml:"im_publish_concurrency"`
}
@ -321,7 +322,7 @@ func defaultRocketMQConfig() RocketMQConfig {
Topic: "hyapp_room_outbox",
ProducerGroup: "hyapp-room-outbox-producer",
TencentIMConsumerEnabled: false,
TencentIMConsumerGroup: "hyapp-room-im-bridge",
TencentIMConsumerGroup: "",
ConsumerMaxReconsumeTimes: 16,
IMPublishConcurrency: 1,
},
@ -519,6 +520,8 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
}
// 旧配置可能仍把 MQ -> Tencent IM bridge 打开;当前设计要求 UI 在 Room Cell 提交后直接 IMoutbox worker 不再承担 IM 投递。
cfg.RoomOutbox.TencentIMConsumerEnabled = false
if cfg.RoomOutbox.ConsumerGoroutines < 0 {
cfg.RoomOutbox.ConsumerGoroutines = 0
}
@ -546,7 +549,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
}
if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled || cfg.RoomOutbox.TencentIMConsumerEnabled {
if cfg.RoomOutbox.Enabled || cfg.RocketLaunch.Enabled || cfg.UserOutbox.Enabled {
cfg.Enabled = true
}
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {

View File

@ -43,12 +43,9 @@ func TestLoad(t *testing.T) {
if !cfg.OutboxWorker.Enabled || cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || cfg.OutboxWorker.PollInterval != 200*time.Millisecond || cfg.OutboxWorker.BatchSize != 50 || cfg.OutboxWorker.Concurrency != 16 || cfg.OutboxWorker.PublishTimeout != 15*time.Second || cfg.OutboxWorker.RetryStrategy != "exponential_backoff" || cfg.OutboxWorker.MaxRetryCount != 10 || cfg.OutboxWorker.InitialBackoff != 5*time.Second || cfg.OutboxWorker.MaxBackoff != 5*time.Minute {
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
}
// 本地默认也走 room_outbox MQ、IM bridge 和火箭延迟唤醒,避免本地直投和线上 MQ 语义分叉。
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled {
t.Fatalf("local config must enable room MQ fanout and IM bridge: %+v", cfg.RocketMQ)
}
if cfg.RocketMQ.RoomOutbox.ConsumerGoroutines != 16 || cfg.RocketMQ.RoomOutbox.IMPublishConcurrency != 16 {
t.Fatalf("local IM bridge concurrency mismatch: room=%+v", cfg.RocketMQ.RoomOutbox)
// 本地默认仍走 room_outbox MQ 和火箭延迟唤醒;房间 UI IM 已改为 Room Cell 提交后直发,不再启 MQ IM bridge。
if !cfg.RocketMQ.Enabled || !cfg.RocketMQ.RoomOutbox.Enabled || cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled || !cfg.RocketMQ.RocketLaunch.Enabled || !cfg.RocketMQ.UserOutbox.Enabled {
t.Fatalf("local config must enable room MQ fanout and disable MQ IM bridge: %+v", cfg.RocketMQ)
}
}
@ -60,7 +57,7 @@ func TestLoadTencentExample(t *testing.T) {
}
if !cfg.TencentIM.Enabled {
t.Fatalf("tencent example should enable Tencent IM bridge")
t.Fatalf("tencent example should enable Tencent IM direct publisher")
}
if cfg.TencentIM.SDKAppID == 0 || cfg.TencentIM.SecretKey == "" {
t.Fatalf("sdk_app_id and secret_key templates must be present")
@ -86,12 +83,9 @@ func TestLoadTencentExample(t *testing.T) {
if cfg.OutboxWorker.PublishMode != OutboxPublishModeMQ || !cfg.RocketMQ.RoomOutbox.Enabled || !cfg.RocketMQ.RocketLaunch.Enabled {
t.Fatalf("tencent example must route room outbox and rocket launch through RocketMQ: mode=%s mq=%+v", cfg.OutboxWorker.PublishMode, cfg.RocketMQ)
}
if cfg.RocketMQ.RoomOutbox.ConsumerGoroutines != 16 || cfg.RocketMQ.RoomOutbox.IMPublishConcurrency != 16 {
t.Fatalf("prod IM bridge concurrency mismatch: room=%+v", cfg.RocketMQ.RoomOutbox)
}
}
func TestLoadDockerConfigStartsRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
func TestLoadDockerConfigDoesNotStartRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
cfg, err := Load("../../configs/config.docker.yaml")
if err != nil {
t.Fatalf("Load docker config failed: %v", err)
@ -106,17 +100,14 @@ func TestLoadDockerConfigStartsRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
if !cfg.RocketMQ.RoomOutbox.Enabled {
t.Fatalf("docker config must enable room outbox MQ")
}
if !cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled {
t.Fatalf("docker mq room outbox must start Tencent IM bridge consumer")
}
if cfg.RocketMQ.RoomOutbox.ConsumerGoroutines != 16 || cfg.RocketMQ.RoomOutbox.IMPublishConcurrency != 16 {
t.Fatalf("docker room IM bridge concurrency mismatch: %+v", cfg.RocketMQ.RoomOutbox)
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled {
t.Fatalf("docker mq room outbox must not start Tencent IM bridge consumer")
}
if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.AdminIdentifier != "administrator" {
t.Fatalf("docker config must use test IM account: im=%+v", cfg.TencentIM)
}
if !cfg.TencentIM.Enabled {
t.Fatalf("Tencent IM bridge consumer requires tencent_im.enabled")
t.Fatalf("direct room IM publishing requires tencent_im.enabled")
}
}

View File

@ -0,0 +1,117 @@
package service
import (
"context"
"log/slog"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/room-service/internal/room/outbox"
)
const roomDirectIMPublishTimeout = 2 * time.Second
func splitRoomDirectIMRecords(records []outbox.Record) ([]outbox.Record, []outbox.Record) {
if len(records) == 0 {
return nil, nil
}
durable := make([]outbox.Record, 0, len(records))
directIM := make([]outbox.Record, 0)
for _, record := range records {
if roomDirectIMOnlyEventType(record.EventType) {
directIM = append(directIM, record)
continue
}
durable = append(durable, record)
if roomDirectIMMirrorEventType(record.EventType) {
// 这类事件同时服务 UI 和业务事实消费MySQL outbox 保证统计、任务、通知等可补偿,直接 IM 只给客户端低延迟展示。
directIM = append(directIM, record)
}
}
return durable, directIM
}
func roomDirectIMOnlyEventType(eventType string) bool {
switch eventType {
case "RoomHeatChanged",
"RoomRankChanged",
"RoomRocketFuelChanged",
"RoomRocketLaunched":
return true
default:
return false
}
}
func roomDirectIMMirrorEventType(eventType string) bool {
switch eventType {
case "RoomGiftSent",
"RoomUserJoined",
"RoomUserLeft",
"RoomMicChanged",
"RoomMicSeatLocked",
"RoomClosed",
"RoomUserKicked",
"RoomUserMuted",
"RoomUserUnbanned",
"RoomProfileUpdated",
"RoomBackgroundChanged",
"RoomChatEnabledChanged",
"RoomAdminChanged",
"RoomRocketIgnited":
return true
default:
return false
}
}
func (s *Service) publishDirectIMRecordsBestEffort(ctx context.Context, records []outbox.Record) {
if s == nil || len(records) == 0 {
return
}
if s.roomDisplayPublisher == nil {
logx.Warn(ctx, "room_direct_im_publish_skipped",
slog.String("node_id", s.nodeID),
slog.Int("record_count", len(records)),
slog.String("reason", "publisher_not_configured"),
)
return
}
scopedApp := appcode.FromContext(ctx)
copied := append([]outbox.Record(nil), records...)
for index := range copied {
copied[index].AppCode = appcode.Normalize(firstNonEmpty(copied[index].AppCode, scopedApp))
if copied[index].Envelope != nil {
copied[index].Envelope.AppCode = copied[index].AppCode
}
}
sortOutboxRecords(copied)
go func(records []outbox.Record) {
for _, record := range records {
if record.Envelope == nil {
logx.Warn(ctx, "room_direct_im_publish_skipped",
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.String("reason", "nil_envelope"),
)
continue
}
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), roomDirectIMPublishTimeout)
err := s.roomDisplayPublisher.PublishOutboxEvent(publishCtx, record.Envelope)
cancel()
if err != nil {
logx.Warn(ctx, "room_direct_im_publish_failed",
slog.String("node_id", s.nodeID),
slog.String("event_id", record.EventID),
slog.String("event_type", record.EventType),
slog.String("room_id", record.RoomID),
slog.String("error", trimOutboxError(err.Error())),
)
}
}
}(copied)
}

View File

@ -388,18 +388,21 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
}
}
finalCoinBalanceAfter := giftFinalCoinBalanceAfter(billing, luckyGift)
firstTargetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, cmd.TargetUserID)
result := mutationResult{
snapshot: current.ToProto(),
billingReceiptID: billing.GetBillingReceiptId(),
roomHeat: current.Heat,
giftRank: cloneProtoRank(current.GiftRank),
luckyGift: luckyGift,
luckyGifts: luckyGifts,
commandPayload: commandPayload,
walletDebitMS: walletDebitMS,
robotDisplayRecords: robotDisplayRecords,
roomRocketProgress: rocketProgress,
snapshot: current.ToProto(),
billingReceiptID: billing.GetBillingReceiptId(),
coinBalanceAfter: finalCoinBalanceAfter,
giftIncomeBalanceAfter: billing.GetGiftIncomeBalanceAfter(),
roomHeat: current.Heat,
giftRank: cloneProtoRank(current.GiftRank),
luckyGift: luckyGift,
luckyGifts: luckyGifts,
commandPayload: commandPayload,
walletDebitMS: walletDebitMS,
robotDisplayRecords: robotDisplayRecords,
roomRocketProgress: rocketProgress,
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
AppCode: appcode.FromContext(ctx),
RoomID: current.RoomID,
@ -474,14 +477,16 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
}
return &roomv1.SendGiftResponse{
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
BillingReceiptId: result.billingReceiptID,
RoomHeat: result.roomHeat,
GiftRank: result.giftRank,
Room: result.snapshot,
Rocket: result.snapshot.GetRocket(),
LuckyGift: result.luckyGift,
LuckyGifts: result.luckyGifts,
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
BillingReceiptId: result.billingReceiptID,
RoomHeat: result.roomHeat,
GiftRank: result.giftRank,
Room: result.snapshot,
Rocket: result.snapshot.GetRocket(),
LuckyGift: result.luckyGift,
LuckyGifts: result.luckyGifts,
CoinBalanceAfter: result.coinBalanceAfter,
GiftIncomeBalanceAfter: result.giftIncomeBalanceAfter,
}, nil
}
@ -766,6 +771,17 @@ func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDraw
return aggregate
}
func giftFinalCoinBalanceAfter(billing *walletv1.DebitGiftResponse, luckyGift *roomv1.LuckyGiftDrawResult) int64 {
if luckyGift != nil && luckyGift.GetCoinBalanceAfter() > 0 {
// 幸运礼物返奖会在扣费后追加一笔入账;同步响应返回最终余额,避免客户端先显示扣费余额再等待中奖回写。
return luckyGift.GetCoinBalanceAfter()
}
if billing == nil {
return 0
}
return billing.GetBalanceAfter()
}
func normalizeGiftTargetType(raw string) string {
raw = strings.TrimSpace(raw)
switch raw {

View File

@ -326,7 +326,7 @@ func (s *Service) markOutboxDead(ctx context.Context, eventID string, lastErr st
}
func (s *Service) publishOutboxRecord(ctx context.Context, record outbox.Record) error {
// Ignited 先安排延迟发射,再对外广播事件;如果 MQ 延迟调度不可用,本条 outbox 保持 retryable
// Ignited 在 outbox worker 内安排延迟发射,保证 MQ 调度失败能通过 room_outbox retry客户端 IM 展示已在 Room Cell 提交后直发
if err := s.scheduleRoomRocketLaunchFromEnvelope(ctx, record.Envelope); err != nil {
return err
}

View File

@ -79,6 +79,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
}
// 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
robotDisplayRecords := s.sampleRobotDisplayRecords(result.robotDisplayRecords)
durableOutboxRecords, directIMRecords := splitRoomDirectIMRecords(outboxRecords)
saveStartedAt := time.Now()
if err := s.repository.SaveMutation(ctx, MutationCommit{
Command: CommandRecord{
@ -93,7 +94,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
LeaseToken: lease.LeaseToken,
CreatedAtMS: now.UnixMilli(),
},
OutboxRecords: outboxRecords,
OutboxRecords: durableOutboxRecords,
RoomStatus: result.roomStatus,
RoomSeatCount: result.roomSeatCount,
RoomPasswordHash: result.roomPasswordHash,
@ -106,11 +107,14 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
return nil, err
}
saveMutationMS += elapsedMS(saveStartedAt)
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity避免外部慢调用占住房间串行执行队列。
result.outboxRecords = outboxRecords
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 activity/MQ 业务事实,避免外部慢调用占住房间串行执行队列。
result.outboxRecords = durableOutboxRecords
result.robotDisplayRecords = robotDisplayRecords
result.directIMRecords = directIMRecords
// MySQL 事务成功提交后再发本进程唤醒信号;信号丢失只影响延迟,不影响后续周期扫描补偿。
s.notifyOutboxCommitted(outboxRecords)
s.notifyOutboxCommitted(durableOutboxRecords)
// 直接 IM lane 只承载展示事件RoomRocketIgnited 同时保留 durable outbox让延迟发射调度继续可重试。
s.publishDirectIMRecordsBestEffort(ctx, directIMRecords)
// 机器人展示事件允许极小概率丢失,不写 MySQL outbox提交成功后后台直发 IM失败只记录日志。
s.publishRobotDisplayRecordsBestEffort(ctx, robotDisplayRecords)

View File

@ -8,6 +8,7 @@ import (
"hyapp/pkg/appcode"
"hyapp/pkg/logx"
"hyapp/services/room-service/internal/integration"
"hyapp/services/room-service/internal/room/outbox"
)
@ -45,15 +46,15 @@ type roomRocketProgressEntry struct {
}
type roomRocketProgressCoalescer struct {
mu sync.Mutex
repository Repository
pending map[roomRocketProgressKey]roomRocketProgressEntry
mu sync.Mutex
publisher integration.OutboxPublisher
pending map[roomRocketProgressKey]roomRocketProgressEntry
}
func newRoomRocketProgressCoalescer(repository Repository) *roomRocketProgressCoalescer {
func newRoomRocketProgressCoalescer(publisher integration.OutboxPublisher) *roomRocketProgressCoalescer {
return &roomRocketProgressCoalescer{
repository: repository,
pending: make(map[roomRocketProgressKey]roomRocketProgressEntry),
publisher: publisher,
pending: make(map[roomRocketProgressKey]roomRocketProgressEntry),
}
}
@ -95,7 +96,7 @@ func (c *roomRocketProgressCoalescer) enqueue(progress roomRocketProgressPending
}
func (c *roomRocketProgressCoalescer) flushDue(ctx context.Context, nowMS int64) ([]outbox.Record, error) {
if c == nil || c.repository == nil {
if c == nil {
return nil, nil
}
due := c.takeDue(nowMS)
@ -103,8 +104,7 @@ func (c *roomRocketProgressCoalescer) flushDue(ctx context.Context, nowMS int64)
return nil, nil
}
normalEntries, robotRecords := splitRoomRocketProgressDue(due)
if err := c.saveNormalDue(ctx, normalEntries); err != nil {
// 主 outbox 保存失败时只把主链路进度放回队列;机器人展示允许丢失,仍交给直发 IM best-effort 处理。
if err := c.publishDue(ctx, normalEntries); err != nil {
c.requeue(normalEntries, nowMS+roomRocketProgressFlushInterval.Milliseconds())
return robotRecords, err
}
@ -156,25 +156,24 @@ func splitRoomRocketProgressDue(entries []roomRocketProgressEntry) ([]roomRocket
robotRecords = append(robotRecords, record)
continue
}
entry.record = record
normal = append(normal, entry)
}
return normal, robotRecords
}
func (c *roomRocketProgressCoalescer) saveNormalDue(ctx context.Context, entries []roomRocketProgressEntry) error {
normal := make(map[string][]outbox.Record)
func (c *roomRocketProgressCoalescer) publishDue(ctx context.Context, entries []roomRocketProgressEntry) error {
if c.publisher == nil {
return nil
}
for _, entry := range entries {
record := entry.record
record.AppCode = appcode.Normalize(record.AppCode)
if record.Envelope != nil {
record.Envelope.AppCode = record.AppCode
if record.Envelope == nil {
continue
}
normal[record.AppCode] = append(normal[record.AppCode], record)
}
for scopedApp, records := range normal {
// repository 会按 context app_code 二次归一,分 app 批量写避免跨租户记录被同一个上下文覆盖。
if err := c.repository.SaveOutbox(appcode.WithContext(ctx, scopedApp), records); err != nil {
record.AppCode = appcode.Normalize(record.AppCode)
record.Envelope.AppCode = record.AppCode
if err := c.publisher.PublishOutboxEvent(appcode.WithContext(ctx, record.AppCode), record.Envelope); err != nil {
return err
}
}
@ -199,7 +198,7 @@ func roomRocketProgressWindowStartMS(createdAtMS int64) int64 {
return createdAtMS / windowMS * windowMS
}
// FlushRoomRocketProgress 把已经超过 1 秒窗口的火箭进度写回主 outbox机器人进度只 best-effort 直发 IM
// FlushRoomRocketProgress 把已经超过 1 秒窗口的火箭进度直接发腾讯 IM机器人 lane 保持原展示直发路径
func (s *Service) FlushRoomRocketProgress(ctx context.Context) error {
if s == nil || s.roomRocketProgressCoalescer == nil {
return nil
@ -209,7 +208,7 @@ func (s *Service) FlushRoomRocketProgress(ctx context.Context) error {
return err
}
// RunRoomRocketProgressFlushWorker 周期性把内存合并后的火箭进度落入主 outbox机器人展示进度不经过 MySQL
// RunRoomRocketProgressFlushWorker 周期性把内存合并后的普通火箭进度直接投递到房间 IM
func (s *Service) RunRoomRocketProgressFlushWorker(ctx context.Context, interval time.Duration) {
if interval <= 0 {
interval = roomRocketProgressFlushInterval

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"slices"
"sync"
"testing"
"time"
@ -36,6 +37,37 @@ type rocketTestWallet struct {
lastBatch *walletv1.BatchDebitGiftRequest
}
type recordingRoomDirectIMPublisher struct {
mu sync.Mutex
wake chan struct{}
records []*roomeventsv1.EventEnvelope
}
func newRecordingRoomDirectIMPublisher() *recordingRoomDirectIMPublisher {
return &recordingRoomDirectIMPublisher{wake: make(chan struct{}, 128)}
}
func (p *recordingRoomDirectIMPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error {
p.mu.Lock()
p.records = append(p.records, proto.Clone(envelope).(*roomeventsv1.EventEnvelope))
p.mu.Unlock()
select {
case p.wake <- struct{}{}:
default:
}
return nil
}
func (p *recordingRoomDirectIMPublisher) counts() map[string]int {
p.mu.Lock()
defer p.mu.Unlock()
counts := make(map[string]int, len(p.records))
for _, record := range p.records {
counts[record.GetEventType()]++
}
return counts
}
func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
w.lastDebit = req
w.debitRequests = append(w.debitRequests, req)
@ -108,7 +140,8 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
{BillingReceiptId: "receipt-202", CoinSpent: 10, ChargeAmount: 10, GiftPointAdded: 5, HeatValue: 11, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 90},
{BillingReceiptId: "receipt-303", CoinSpent: 20, ChargeAmount: 20, GiftPointAdded: 7, HeatValue: 19, GiftTypeCode: "normal", PriceVersion: "v1", BalanceAfter: 70},
}}
svc := newRocketTestService(t, repository, wallet, now)
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-gift-multi-target"
ownerID := int64(101)
@ -142,6 +175,9 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
if resp.GetRoomHeat() != 30 || resp.GetBillingReceiptId() != "receipt-202,receipt-303" {
t.Fatalf("multi-target gift response must aggregate wallet results: %+v", resp)
}
if resp.GetCoinBalanceAfter() != 70 {
t.Fatalf("multi-target gift must return latest sender balance: %d", resp.GetCoinBalanceAfter())
}
if firstUser := onlineUserByID(resp.GetRoom(), firstTargetID); firstUser == nil || firstUser.GetGiftValue() != 11 {
t.Fatalf("first target must receive only its own heat value: %+v", firstUser)
}
@ -184,6 +220,59 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
}
}
func TestSendGiftMirrorsGiftDisplayToDirectIMAndKeepsDurableFact(t *testing.T) {
ctx := context.Background()
repository := mysqltest.NewRepository(t)
now := &fixedRoomRocketClock{now: time.Date(2026, 6, 26, 9, 0, 0, 0, time.UTC)}
wallet := &rocketTestWallet{debits: []*walletv1.DebitGiftResponse{{
BillingReceiptId: "receipt-direct-im-gift",
CoinSpent: 25,
ChargeAmount: 25,
HeatValue: 25,
GiftTypeCode: "normal",
BalanceAfter: 975,
GiftIncomeBalanceAfter: 120,
}}}
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
roomID := "room-gift-direct-im"
ownerID := int64(701)
targetID := int64(702)
createRocketRoom(t, ctx, svc, roomID, ownerID, 9001)
joinRocketRoom(t, ctx, svc, roomID, targetID)
before := outboxEventCounts(t, ctx, repository)
resp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
Meta: rocketMeta(roomID, ownerID, "gift-direct-im"),
TargetType: "user",
TargetUserId: targetID,
GiftId: "rose",
GiftCount: 1,
})
if err != nil {
t.Fatalf("send direct IM gift failed: %v", err)
}
if resp.GetCoinBalanceAfter() != 975 || resp.GetGiftIncomeBalanceAfter() != 120 {
t.Fatalf("gift response must carry balance snapshots: %+v", resp)
}
after := outboxEventCounts(t, ctx, repository)
if delta := after["RoomGiftSent"] - before["RoomGiftSent"]; delta != 1 {
t.Fatalf("RoomGiftSent durable fact must stay in room_outbox, delta=%d counts=%+v", delta, after)
}
for _, eventType := range []string{"RoomHeatChanged", "RoomRankChanged"} {
if delta := after[eventType] - before[eventType]; delta != 0 {
t.Fatalf("%s must not write room_outbox after directIM split, delta=%d counts=%+v", eventType, delta, after)
}
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{
"RoomGiftSent": 1,
"RoomHeatChanged": 1,
"RoomRankChanged": 1,
})
}
func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
w.grants = append(w.grants, req)
return &walletv1.ResourceGrantResponse{
@ -212,7 +301,8 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
{BillingReceiptId: "receipt-fill", GiftPointAdded: 250, HeatValue: 250, GiftTypeCode: "normal"},
{BillingReceiptId: "receipt-next", GiftPointAdded: 50, HeatValue: 50, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
roomID := "room-rocket-flow"
@ -250,15 +340,16 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
}
flushRocketProgress(t, ctx, svc, now)
progressEvents := rocketProgressEvents(t, ctx, repository)
progressEvents := roomDirectIMRocketProgressEvents(t, directIM)
if len(progressEvents) != 1 || progressEvents[0].GetAddedFuel() != 250 || progressEvents[0].GetEffectiveAddedFuel() != 100 || progressEvents[0].GetOverflowFuel() != 150 {
t.Fatalf("progress event must expose effective fuel and discarded overflow: %+v", progressEvents)
}
if progressEvents[0].GetLevel() != 1 || progressEvents[0].GetStatus() != state.RocketStatusIgnited {
t.Fatalf("progress event must describe the filled level, got %+v", progressEvents[0])
}
if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 {
t.Fatalf("fill gift must emit one ignited event, got %d", got)
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1})
if got := outboxEventCounts(t, ctx, repository)["RoomRocketIgnited"]; got != 1 {
t.Fatalf("fill gift must keep one RoomRocketIgnited outbox for launch scheduling, got %d", got)
}
nextResp, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{
@ -278,11 +369,11 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket())
}
flushRocketProgress(t, ctx, svc, now)
progressEvents = rocketProgressEvents(t, ctx, repository)
progressEvents = roomDirectIMRocketProgressEvents(t, directIM)
if len(progressEvents) != 2 || progressEvents[1].GetLevel() != 2 || progressEvents[1].GetEffectiveAddedFuel() != 50 || progressEvents[1].GetOverflowFuel() != 0 {
t.Fatalf("second progress event must target level 2: %+v", progressEvents)
}
if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 {
if got := directIM.counts()["RoomRocketIgnited"]; got != 1 {
t.Fatalf("next-level partial gift must not emit another ignited event, got %d", got)
}
}
@ -296,7 +387,8 @@ func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
{BillingReceiptId: "receipt-progress-20", GiftPointAdded: 20, HeatValue: 20, GiftTypeCode: "normal"},
{BillingReceiptId: "receipt-progress-30", GiftPointAdded: 30, HeatValue: 30, GiftTypeCode: "normal"},
}}
svc := newRocketTestService(t, repository, wallet, now)
directIM := newRecordingRoomDirectIMPublisher()
svc := newRocketTestServiceWithDirectIM(t, repository, wallet, now, nil, directIM)
writeRocketConfig(t, ctx, repository, rocketConfigForTest(1000, 1_000))
roomID := "room-rocket-progress-coalesce"
@ -319,9 +411,12 @@ func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
}
beforeFlush := outboxEventCounts(t, ctx, repository)
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged"} {
if delta := beforeFlush[eventType] - before[eventType]; delta != 3 {
t.Fatalf("%s must stay per gift before progress flush, delta=%d counts=%+v", eventType, delta, beforeFlush)
if delta := beforeFlush["RoomGiftSent"] - before["RoomGiftSent"]; delta != 3 {
t.Fatalf("RoomGiftSent must stay durable per gift, delta=%d counts=%+v", delta, beforeFlush)
}
for _, eventType := range []string{"RoomHeatChanged", "RoomRankChanged"} {
if delta := beforeFlush[eventType] - before[eventType]; delta != 0 {
t.Fatalf("%s must move to direct IM instead of durable outbox, delta=%d counts=%+v", eventType, delta, beforeFlush)
}
}
if delta := beforeFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
@ -330,13 +425,10 @@ func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
flushRocketProgress(t, ctx, svc, now)
afterFlush := outboxEventCounts(t, ctx, repository)
if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 1 {
t.Fatalf("rocket progress must coalesce to one outbox row, delta=%d counts=%+v", delta, afterFlush)
}
progressEvents := rocketProgressEvents(t, ctx, repository)
if len(progressEvents) != 1 || progressEvents[0].GetCurrentFuel() != 60 || progressEvents[0].GetEffectiveAddedFuel() != 30 {
t.Fatalf("coalesced progress must keep latest max fuel event: %+v", progressEvents)
if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
t.Fatalf("rocket progress must flush to direct IM instead of durable outbox, delta=%d counts=%+v", delta, afterFlush)
}
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketFuelChanged": 1})
}
func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) {
@ -788,6 +880,11 @@ func newRocketTestService(t *testing.T, repository *mysqltest.Repository, wallet
func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, scheduler integration.RoomRocketLaunchScheduler) *roomservice.Service {
t.Helper()
return newRocketTestServiceWithDirectIM(t, repository, wallet, clock, scheduler, nil)
}
func newRocketTestServiceWithDirectIM(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, scheduler integration.RoomRocketLaunchScheduler, directIM integration.OutboxPublisher) *roomservice.Service {
t.Helper()
return roomservice.New(roomservice.Config{
NodeID: "node-rocket-test",
@ -796,9 +893,34 @@ func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repos
SnapshotEveryN: 1,
Clock: clock,
RoomRocketLaunchScheduler: scheduler,
RoomDisplayPublisher: directIM,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
}
func waitForRoomDirectIMCounts(t testing.TB, publisher *recordingRoomDirectIMPublisher, want map[string]int) {
t.Helper()
deadline := time.NewTimer(2 * time.Second)
defer deadline.Stop()
for {
counts := publisher.counts()
matched := true
for eventType, expected := range want {
if counts[eventType] < expected {
matched = false
break
}
}
if matched {
return
}
select {
case <-publisher.wake:
case <-deadline.C:
t.Fatalf("room direct IM events mismatch: want at least %+v got %+v", want, counts)
}
}
}
func flushRocketProgress(t testing.TB, ctx context.Context, svc *roomservice.Service, clock *fixedRoomRocketClock) {
t.Helper()
@ -921,6 +1043,25 @@ func rocketProgressEvents(t *testing.T, ctx context.Context, repository *mysqlte
return events
}
func roomDirectIMRocketProgressEvents(t *testing.T, publisher *recordingRoomDirectIMPublisher) []*roomeventsv1.RoomRocketFuelChanged {
t.Helper()
publisher.mu.Lock()
records := append([]*roomeventsv1.EventEnvelope(nil), publisher.records...)
publisher.mu.Unlock()
events := make([]*roomeventsv1.RoomRocketFuelChanged, 0)
for _, record := range records {
if record.GetEventType() != "RoomRocketFuelChanged" {
continue
}
var event roomeventsv1.RoomRocketFuelChanged
if err := proto.Unmarshal(record.GetBody(), &event); err != nil {
t.Fatalf("decode directIM rocket progress failed: %v", err)
}
events = append(events, &event)
}
return events
}
func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
t.Helper()

View File

@ -37,6 +37,8 @@ type Config struct {
RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
// RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。
RobotDisplayPublisher integration.OutboxPublisher
// RoomDisplayPublisher 在 Room Cell 提交后直接投递腾讯 IM 展示事件;失败不回滚已提交房间命令。
RoomDisplayPublisher integration.OutboxPublisher
// HumanRobotPoolProvider 自动读取全站机器人账号和用户国家,用于真人房间机器人运行时按国家分组。
HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider
// RoomGiftLeaderboard 是跨房间贡献榜读模型SendGift 提交后 best-effort 写入。
@ -81,6 +83,8 @@ type Service struct {
outboxPublisher integration.OutboxPublisher
// robotDisplayPublisher 只服务机器人房间展示事件,和主 room_outbox 持久事实隔离。
robotDisplayPublisher integration.OutboxPublisher
// roomDisplayPublisher 只承载客户端可由快照修复的直接 IM 展示事件,不作为业务事实源。
roomDisplayPublisher integration.OutboxPublisher
// outboxWake 提交成功后唤醒主 outbox worker它只是低延迟提示事实源仍然是 MySQL room_outbox。
outboxWake chan struct{}
// humanRobotPoolProvider 运行时自动获取全站机器人池,避免后台手工维护国家机器人池。
@ -150,6 +154,10 @@ type mutationResult struct {
micHeartbeatAtMS int64
// billingReceiptID 是 SendGift 从 wallet-service 得到的账务回执。
billingReceiptID string
// coinBalanceAfter 是送礼人可展示的最终 COIN 余额;幸运礼物返奖成功时优先使用返奖后的余额。
coinBalanceAfter int64
// giftIncomeBalanceAfter 是收礼人返币后的 COIN 余额;单目标返币可同步返回给发起方确认。
giftIncomeBalanceAfter int64
// roomHeat 是 SendGift 后的房间热度。
roomHeat int64
// giftRank 是 SendGift 后的本地礼物榜投影。
@ -160,12 +168,14 @@ type mutationResult struct {
luckyGifts []*roomv1.LuckyGiftDrawResult
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log用于恢复。
commandPayload []byte
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;保留旧字段兼容命令结果room UI 现在由提交后 direct IM 发送
syncEvent *tencentim.RoomEvent
// outboxRecords 是本次命令已经持久化的房间事实,提交后可投给 activity 等外部消费者。
outboxRecords []outbox.Record
// robotDisplayRecords 是机器人房间展示事实,提交成功后 best-effort 直发 Tencent IM不占主 room_outbox。
robotDisplayRecords []outbox.Record
// directIMRecords 是提交成功后直接发腾讯 IM 的展示事件;失败不回滚已提交房间命令。
directIMRecords []outbox.Record
// roomRocketProgress 是提交成功后才进入内存合并器的火箭进度展示事件,不和送礼命令事务逐笔写 outbox。
roomRocketProgress *roomRocketProgressPending
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
@ -243,10 +253,11 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
syncPublisher: syncPublisher,
outboxPublisher: outboxPublisher,
robotDisplayPublisher: cfg.RobotDisplayPublisher,
roomDisplayPublisher: cfg.RoomDisplayPublisher,
outboxWake: make(chan struct{}, 1024),
humanRobotPoolProvider: cfg.HumanRobotPoolProvider,
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(repository),
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(cfg.RoomDisplayPublisher),
robotDisplaySampler: newRobotDisplaySampler(),
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
roomListCache: cfg.RoomListCache,