直接发im,去掉 outbox一层
This commit is contained in:
parent
2e061101b3
commit
a1e038442d
File diff suppressed because it is too large
Load Diff
@ -1067,6 +1067,10 @@ message SendGiftResponse {
|
|||||||
LuckyGiftDrawResult lucky_gift = 7;
|
LuckyGiftDrawResult lucky_gift = 7;
|
||||||
// lucky_gifts 是每个收礼目标的独立抽奖结果;多目标客户端用 target_user_id 对齐明细表现。
|
// lucky_gifts 是每个收礼目标的独立抽奖结果;多目标客户端用 target_user_id 对齐明细表现。
|
||||||
repeated LuckyGiftDrawResult lucky_gifts = 8;
|
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 在公屏前同步问房间业务态。
|
// CheckSpeakPermissionRequest 让腾讯云 IM 发言回调或 gateway 在公屏前同步问房间业务态。
|
||||||
|
|||||||
@ -3,7 +3,7 @@ package room
|
|||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
// EventHandler 是 room outbox 事件进入 activity-service 后的处理边界。
|
// EventHandler 是 room outbox 事件进入 activity-service 后的处理边界。
|
||||||
// 消费来源可以是 MySQL outbox、Redis Stream 或 MQ,但处理侧必须保持幂等。
|
// 消费来源可以是 MySQL outbox 或 MQ,但处理侧必须保持幂等。
|
||||||
type EventHandler interface {
|
type EventHandler interface {
|
||||||
HandleRoomEvent(ctx context.Context, eventID string, eventType string, payload []byte) error
|
HandleRoomEvent(ctx context.Context, eventID string, eventType string, payload []byte) error
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2597,6 +2597,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
|||||||
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
|
Result: &roomv1.CommandResult{Applied: true, RoomVersion: 14, ServerTimeMs: 1_779_258_000_000},
|
||||||
BillingReceiptId: "receipt-lucky",
|
BillingReceiptId: "receipt-lucky",
|
||||||
RoomHeat: 100,
|
RoomHeat: 100,
|
||||||
|
CoinBalanceAfter: 8800,
|
||||||
LuckyGift: &roomv1.LuckyGiftDrawResult{
|
LuckyGift: &roomv1.LuckyGiftDrawResult{
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
DrawId: "lucky_draw_test",
|
DrawId: "lucky_draw_test",
|
||||||
@ -2644,6 +2645,7 @@ func TestSendGiftResponseIncludesLuckyGiftDraw(t *testing.T) {
|
|||||||
MultiplierPPM int64 `json:"multiplier_ppm"`
|
MultiplierPPM int64 `json:"multiplier_ppm"`
|
||||||
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
|
EffectiveRewardCoins int64 `json:"effective_reward_coins"`
|
||||||
} `json:"lucky_gifts"`
|
} `json:"lucky_gifts"`
|
||||||
|
CoinBalanceAfter int64 `json:"coin_balance_after"`
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
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 {
|
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)
|
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) {
|
func TestSendGiftInjectsActiveHostPeriodScope(t *testing.T) {
|
||||||
|
|||||||
33
services/room-service/README.md
Normal file
33
services/room-service/README.md
Normal 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.
|
||||||
@ -67,11 +67,6 @@ rocketmq:
|
|||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
producer_group: "hyapp-room-outbox-producer"
|
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:
|
rocket_launch:
|
||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_rocket_launch"
|
topic: "hyapp_room_rocket_launch"
|
||||||
|
|||||||
@ -77,11 +77,6 @@ rocketmq:
|
|||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
producer_group: "hyapp-room-outbox-producer"
|
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:
|
rocket_launch:
|
||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_rocket_launch"
|
topic: "hyapp_room_rocket_launch"
|
||||||
|
|||||||
@ -70,11 +70,6 @@ rocketmq:
|
|||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_outbox"
|
topic: "hyapp_room_outbox"
|
||||||
producer_group: "hyapp-room-outbox-producer"
|
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:
|
rocket_launch:
|
||||||
enabled: true
|
enabled: true
|
||||||
topic: "hyapp_room_rocket_launch"
|
topic: "hyapp_room_rocket_launch"
|
||||||
|
|||||||
@ -13,7 +13,6 @@ import (
|
|||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
roomv1 "hyapp.local/api/proto/room/v1"
|
roomv1 "hyapp.local/api/proto/room/v1"
|
||||||
"hyapp/pkg/appcode"
|
|
||||||
"hyapp/pkg/healthhttp"
|
"hyapp/pkg/healthhttp"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
"hyapp/pkg/rocketmqx"
|
"hyapp/pkg/rocketmqx"
|
||||||
@ -182,9 +181,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
|
activityPublisher := integration.NewActivityOutboxPublisher(activityv1.NewRoomEventConsumerServiceClient(activityConn), time.Second)
|
||||||
outboxPublishers = append(outboxPublishers, activityPublisher)
|
outboxPublishers = append(outboxPublishers, activityPublisher)
|
||||||
}
|
}
|
||||||
if tencentPublisher != nil {
|
|
||||||
outboxPublishers = append(outboxPublishers, tencentPublisher)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeMQ || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
if cfg.OutboxWorker.PublishMode == config.OutboxPublishModeMQ || cfg.OutboxWorker.PublishMode == config.OutboxPublishModeDual {
|
||||||
outboxProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ProducerGroup))
|
outboxProducer, err := rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ, cfg.RocketMQ.RoomOutbox.ProducerGroup))
|
||||||
@ -227,6 +223,7 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
RTCUserRemover: rtcUserRemover,
|
RTCUserRemover: rtcUserRemover,
|
||||||
RoomRocketLaunchScheduler: rocketLaunchScheduler,
|
RoomRocketLaunchScheduler: rocketLaunchScheduler,
|
||||||
RobotDisplayPublisher: tencentPublisher,
|
RobotDisplayPublisher: tencentPublisher,
|
||||||
|
RoomDisplayPublisher: tencentPublisher,
|
||||||
HumanRobotPoolProvider: integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn),
|
HumanRobotPoolProvider: integration.NewGRPCHumanRoomRobotPoolProvider(robotConn, userConn),
|
||||||
RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
|
RoomGiftLeaderboard: roomservice.NewRedisRoomGiftLeaderboardStore(redisClient),
|
||||||
RoomListCache: roomservice.NewRedisRoomListCacheStore(redisClient),
|
RoomListCache: roomservice.NewRedisRoomListCacheStore(redisClient),
|
||||||
@ -257,38 +254,6 @@ func New(cfg config.Config) (*App, error) {
|
|||||||
}
|
}
|
||||||
mqConsumers = append(mqConsumers, rocketConsumer)
|
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 {
|
if cfg.RocketMQ.UserOutbox.Enabled {
|
||||||
userConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.UserOutbox.ConsumerGroup, cfg.RocketMQ.UserOutbox.ConsumerMaxReconsumeTimes, 0))
|
userConsumer, err := rocketmqx.NewConsumer(rocketMQConsumerConfig(cfg.RocketMQ, cfg.RocketMQ.UserOutbox.ConsumerGroup, cfg.RocketMQ.UserOutbox.ConsumerMaxReconsumeTimes, 0))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -137,15 +137,16 @@ type RocketMQConfig struct {
|
|||||||
|
|
||||||
// RoomOutboxMQConfig 控制 room_outbox fanout topic。
|
// RoomOutboxMQConfig 控制 room_outbox fanout topic。
|
||||||
type RoomOutboxMQConfig struct {
|
type RoomOutboxMQConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Topic string `yaml:"topic"`
|
Topic string `yaml:"topic"`
|
||||||
ProducerGroup string `yaml:"producer_group"`
|
ProducerGroup string `yaml:"producer_group"`
|
||||||
|
// TencentIMConsumerEnabled 已废弃:房间 UI 消息由 Room Cell 提交后直发腾讯 IM,room_outbox 只保留业务事实 fanout。
|
||||||
TencentIMConsumerEnabled bool `yaml:"tencent_im_consumer_enabled"`
|
TencentIMConsumerEnabled bool `yaml:"tencent_im_consumer_enabled"`
|
||||||
TencentIMConsumerGroup string `yaml:"tencent_im_consumer_group"`
|
TencentIMConsumerGroup string `yaml:"tencent_im_consumer_group"`
|
||||||
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
ConsumerMaxReconsumeTimes int32 `yaml:"consumer_max_reconsume_times"`
|
||||||
// ConsumerGoroutines 控制 RocketMQ push consumer 的并发回调数;同房间顺序仍由 IM publish 分片锁保证。
|
// ConsumerGoroutines 控制 room_outbox RocketMQ push consumer 的并发回调数,消费方仍必须自行做业务幂等。
|
||||||
ConsumerGoroutines int `yaml:"consumer_goroutines"`
|
ConsumerGoroutines int `yaml:"consumer_goroutines"`
|
||||||
// IMPublishConcurrency 控制 MQ -> Tencent IM 桥接按 room_id 分片后的最大并行房间数。
|
// IMPublishConcurrency 已废弃,仅兼容旧配置文件;房间 IM 不再由 MQ consumer 桥接。
|
||||||
IMPublishConcurrency int `yaml:"im_publish_concurrency"`
|
IMPublishConcurrency int `yaml:"im_publish_concurrency"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -321,7 +322,7 @@ func defaultRocketMQConfig() RocketMQConfig {
|
|||||||
Topic: "hyapp_room_outbox",
|
Topic: "hyapp_room_outbox",
|
||||||
ProducerGroup: "hyapp-room-outbox-producer",
|
ProducerGroup: "hyapp-room-outbox-producer",
|
||||||
TencentIMConsumerEnabled: false,
|
TencentIMConsumerEnabled: false,
|
||||||
TencentIMConsumerGroup: "hyapp-room-im-bridge",
|
TencentIMConsumerGroup: "",
|
||||||
ConsumerMaxReconsumeTimes: 16,
|
ConsumerMaxReconsumeTimes: 16,
|
||||||
IMPublishConcurrency: 1,
|
IMPublishConcurrency: 1,
|
||||||
},
|
},
|
||||||
@ -519,6 +520,8 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
|
|||||||
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
if cfg.RoomOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
cfg.RoomOutbox.ConsumerMaxReconsumeTimes = defaults.RoomOutbox.ConsumerMaxReconsumeTimes
|
||||||
}
|
}
|
||||||
|
// 旧配置可能仍把 MQ -> Tencent IM bridge 打开;当前设计要求 UI 在 Room Cell 提交后直接 IM,outbox worker 不再承担 IM 投递。
|
||||||
|
cfg.RoomOutbox.TencentIMConsumerEnabled = false
|
||||||
if cfg.RoomOutbox.ConsumerGoroutines < 0 {
|
if cfg.RoomOutbox.ConsumerGoroutines < 0 {
|
||||||
cfg.RoomOutbox.ConsumerGoroutines = 0
|
cfg.RoomOutbox.ConsumerGoroutines = 0
|
||||||
}
|
}
|
||||||
@ -546,7 +549,7 @@ func normalizeRocketMQConfig(cfg RocketMQConfig, publishMode string) (RocketMQCo
|
|||||||
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
if cfg.UserOutbox.ConsumerMaxReconsumeTimes <= 0 {
|
||||||
cfg.UserOutbox.ConsumerMaxReconsumeTimes = defaults.UserOutbox.ConsumerMaxReconsumeTimes
|
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
|
cfg.Enabled = true
|
||||||
}
|
}
|
||||||
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {
|
if publishMode == OutboxPublishModeMQ || publishMode == OutboxPublishModeDual {
|
||||||
|
|||||||
@ -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 {
|
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)
|
t.Fatalf("unexpected outbox worker config: %+v", cfg.OutboxWorker)
|
||||||
}
|
}
|
||||||
// 本地默认也走 room_outbox MQ、IM bridge 和火箭延迟唤醒,避免本地直投和线上 MQ 语义分叉。
|
// 本地默认仍走 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 {
|
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)
|
t.Fatalf("local config must enable room MQ fanout and disable MQ 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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +57,7 @@ func TestLoadTencentExample(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !cfg.TencentIM.Enabled {
|
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 == "" {
|
if cfg.TencentIM.SDKAppID == 0 || cfg.TencentIM.SecretKey == "" {
|
||||||
t.Fatalf("sdk_app_id and secret_key templates must be present")
|
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 {
|
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)
|
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")
|
cfg, err := Load("../../configs/config.docker.yaml")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Load docker config failed: %v", err)
|
t.Fatalf("Load docker config failed: %v", err)
|
||||||
@ -106,17 +100,14 @@ func TestLoadDockerConfigStartsRoomIMBridgeWhenOutboxUsesMQ(t *testing.T) {
|
|||||||
if !cfg.RocketMQ.RoomOutbox.Enabled {
|
if !cfg.RocketMQ.RoomOutbox.Enabled {
|
||||||
t.Fatalf("docker config must enable room outbox MQ")
|
t.Fatalf("docker config must enable room outbox MQ")
|
||||||
}
|
}
|
||||||
if !cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled {
|
if cfg.RocketMQ.RoomOutbox.TencentIMConsumerEnabled {
|
||||||
t.Fatalf("docker mq room outbox must start Tencent IM bridge consumer")
|
t.Fatalf("docker mq room outbox must not 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.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.AdminIdentifier != "administrator" {
|
if cfg.TencentIM.SDKAppID != 20040101 || cfg.TencentIM.AdminIdentifier != "administrator" {
|
||||||
t.Fatalf("docker config must use test IM account: im=%+v", cfg.TencentIM)
|
t.Fatalf("docker config must use test IM account: im=%+v", cfg.TencentIM)
|
||||||
}
|
}
|
||||||
if !cfg.TencentIM.Enabled {
|
if !cfg.TencentIM.Enabled {
|
||||||
t.Fatalf("Tencent IM bridge consumer requires tencent_im.enabled")
|
t.Fatalf("direct room IM publishing requires tencent_im.enabled")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
117
services/room-service/internal/room/service/direct_im.go
Normal file
117
services/room-service/internal/room/service/direct_im.go
Normal 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)
|
||||||
|
}
|
||||||
@ -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)
|
firstTargetDisplayProfile := giftDisplayProfileForUser(cmd.TargetDisplayProfiles, cmd.TargetUserID)
|
||||||
result := mutationResult{
|
result := mutationResult{
|
||||||
snapshot: current.ToProto(),
|
snapshot: current.ToProto(),
|
||||||
billingReceiptID: billing.GetBillingReceiptId(),
|
billingReceiptID: billing.GetBillingReceiptId(),
|
||||||
roomHeat: current.Heat,
|
coinBalanceAfter: finalCoinBalanceAfter,
|
||||||
giftRank: cloneProtoRank(current.GiftRank),
|
giftIncomeBalanceAfter: billing.GetGiftIncomeBalanceAfter(),
|
||||||
luckyGift: luckyGift,
|
roomHeat: current.Heat,
|
||||||
luckyGifts: luckyGifts,
|
giftRank: cloneProtoRank(current.GiftRank),
|
||||||
commandPayload: commandPayload,
|
luckyGift: luckyGift,
|
||||||
walletDebitMS: walletDebitMS,
|
luckyGifts: luckyGifts,
|
||||||
robotDisplayRecords: robotDisplayRecords,
|
commandPayload: commandPayload,
|
||||||
roomRocketProgress: rocketProgress,
|
walletDebitMS: walletDebitMS,
|
||||||
|
robotDisplayRecords: robotDisplayRecords,
|
||||||
|
roomRocketProgress: rocketProgress,
|
||||||
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
|
roomGiftLeaderboard: &RoomGiftLeaderboardIncrement{
|
||||||
AppCode: appcode.FromContext(ctx),
|
AppCode: appcode.FromContext(ctx),
|
||||||
RoomID: current.RoomID,
|
RoomID: current.RoomID,
|
||||||
@ -474,14 +477,16 @@ func (s *Service) sendGift(ctx context.Context, req *roomv1.SendGiftRequest, rob
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &roomv1.SendGiftResponse{
|
return &roomv1.SendGiftResponse{
|
||||||
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
Result: commandResult(result.applied, result.snapshot.GetVersion(), s.clock.Now()),
|
||||||
BillingReceiptId: result.billingReceiptID,
|
BillingReceiptId: result.billingReceiptID,
|
||||||
RoomHeat: result.roomHeat,
|
RoomHeat: result.roomHeat,
|
||||||
GiftRank: result.giftRank,
|
GiftRank: result.giftRank,
|
||||||
Room: result.snapshot,
|
Room: result.snapshot,
|
||||||
Rocket: result.snapshot.GetRocket(),
|
Rocket: result.snapshot.GetRocket(),
|
||||||
LuckyGift: result.luckyGift,
|
LuckyGift: result.luckyGift,
|
||||||
LuckyGifts: result.luckyGifts,
|
LuckyGifts: result.luckyGifts,
|
||||||
|
CoinBalanceAfter: result.coinBalanceAfter,
|
||||||
|
GiftIncomeBalanceAfter: result.giftIncomeBalanceAfter,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -766,6 +771,17 @@ func aggregateLuckyGiftResults(commandID string, results []*roomv1.LuckyGiftDraw
|
|||||||
return aggregate
|
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 {
|
func normalizeGiftTargetType(raw string) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
switch raw {
|
switch raw {
|
||||||
|
|||||||
@ -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 {
|
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 {
|
if err := s.scheduleRoomRocketLaunchFromEnvelope(ctx, record.Envelope); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -79,6 +79,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
}
|
}
|
||||||
// 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
|
// 机器人展示只是 App 视觉补偿,提交前按短窗口降采样;命令日志、房间状态和主 outbox 不受影响。
|
||||||
robotDisplayRecords := s.sampleRobotDisplayRecords(result.robotDisplayRecords)
|
robotDisplayRecords := s.sampleRobotDisplayRecords(result.robotDisplayRecords)
|
||||||
|
durableOutboxRecords, directIMRecords := splitRoomDirectIMRecords(outboxRecords)
|
||||||
saveStartedAt := time.Now()
|
saveStartedAt := time.Now()
|
||||||
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
if err := s.repository.SaveMutation(ctx, MutationCommit{
|
||||||
Command: CommandRecord{
|
Command: CommandRecord{
|
||||||
@ -93,7 +94,7 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
LeaseToken: lease.LeaseToken,
|
LeaseToken: lease.LeaseToken,
|
||||||
CreatedAtMS: now.UnixMilli(),
|
CreatedAtMS: now.UnixMilli(),
|
||||||
},
|
},
|
||||||
OutboxRecords: outboxRecords,
|
OutboxRecords: durableOutboxRecords,
|
||||||
RoomStatus: result.roomStatus,
|
RoomStatus: result.roomStatus,
|
||||||
RoomSeatCount: result.roomSeatCount,
|
RoomSeatCount: result.roomSeatCount,
|
||||||
RoomPasswordHash: result.roomPasswordHash,
|
RoomPasswordHash: result.roomPasswordHash,
|
||||||
@ -106,11 +107,14 @@ func (s *Service) mutateRoom(ctx context.Context, cmd command.Command, replayabl
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
saveMutationMS += elapsedMS(saveStartedAt)
|
saveMutationMS += elapsedMS(saveStartedAt)
|
||||||
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 IM/activity,避免外部慢调用占住房间串行执行队列。
|
// 保存已提交 outbox 记录,出 Cell 锁后由 outbox worker 异步投递 activity/MQ 业务事实,避免外部慢调用占住房间串行执行队列。
|
||||||
result.outboxRecords = outboxRecords
|
result.outboxRecords = durableOutboxRecords
|
||||||
result.robotDisplayRecords = robotDisplayRecords
|
result.robotDisplayRecords = robotDisplayRecords
|
||||||
|
result.directIMRecords = directIMRecords
|
||||||
// MySQL 事务成功提交后再发本进程唤醒信号;信号丢失只影响延迟,不影响后续周期扫描补偿。
|
// MySQL 事务成功提交后再发本进程唤醒信号;信号丢失只影响延迟,不影响后续周期扫描补偿。
|
||||||
s.notifyOutboxCommitted(outboxRecords)
|
s.notifyOutboxCommitted(durableOutboxRecords)
|
||||||
|
// 直接 IM lane 只承载展示事件;RoomRocketIgnited 同时保留 durable outbox,让延迟发射调度继续可重试。
|
||||||
|
s.publishDirectIMRecordsBestEffort(ctx, directIMRecords)
|
||||||
// 机器人展示事件允许极小概率丢失,不写 MySQL outbox;提交成功后后台直发 IM,失败只记录日志。
|
// 机器人展示事件允许极小概率丢失,不写 MySQL outbox;提交成功后后台直发 IM,失败只记录日志。
|
||||||
s.publishRobotDisplayRecordsBestEffort(ctx, robotDisplayRecords)
|
s.publishRobotDisplayRecordsBestEffort(ctx, robotDisplayRecords)
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/logx"
|
"hyapp/pkg/logx"
|
||||||
|
"hyapp/services/room-service/internal/integration"
|
||||||
"hyapp/services/room-service/internal/room/outbox"
|
"hyapp/services/room-service/internal/room/outbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -45,15 +46,15 @@ type roomRocketProgressEntry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type roomRocketProgressCoalescer struct {
|
type roomRocketProgressCoalescer struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
repository Repository
|
publisher integration.OutboxPublisher
|
||||||
pending map[roomRocketProgressKey]roomRocketProgressEntry
|
pending map[roomRocketProgressKey]roomRocketProgressEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRoomRocketProgressCoalescer(repository Repository) *roomRocketProgressCoalescer {
|
func newRoomRocketProgressCoalescer(publisher integration.OutboxPublisher) *roomRocketProgressCoalescer {
|
||||||
return &roomRocketProgressCoalescer{
|
return &roomRocketProgressCoalescer{
|
||||||
repository: repository,
|
publisher: publisher,
|
||||||
pending: make(map[roomRocketProgressKey]roomRocketProgressEntry),
|
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) {
|
func (c *roomRocketProgressCoalescer) flushDue(ctx context.Context, nowMS int64) ([]outbox.Record, error) {
|
||||||
if c == nil || c.repository == nil {
|
if c == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
due := c.takeDue(nowMS)
|
due := c.takeDue(nowMS)
|
||||||
@ -103,8 +104,7 @@ func (c *roomRocketProgressCoalescer) flushDue(ctx context.Context, nowMS int64)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
normalEntries, robotRecords := splitRoomRocketProgressDue(due)
|
normalEntries, robotRecords := splitRoomRocketProgressDue(due)
|
||||||
if err := c.saveNormalDue(ctx, normalEntries); err != nil {
|
if err := c.publishDue(ctx, normalEntries); err != nil {
|
||||||
// 主 outbox 保存失败时只把主链路进度放回队列;机器人展示允许丢失,仍交给直发 IM best-effort 处理。
|
|
||||||
c.requeue(normalEntries, nowMS+roomRocketProgressFlushInterval.Milliseconds())
|
c.requeue(normalEntries, nowMS+roomRocketProgressFlushInterval.Milliseconds())
|
||||||
return robotRecords, err
|
return robotRecords, err
|
||||||
}
|
}
|
||||||
@ -156,25 +156,24 @@ func splitRoomRocketProgressDue(entries []roomRocketProgressEntry) ([]roomRocket
|
|||||||
robotRecords = append(robotRecords, record)
|
robotRecords = append(robotRecords, record)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
entry.record = record
|
||||||
normal = append(normal, entry)
|
normal = append(normal, entry)
|
||||||
}
|
}
|
||||||
return normal, robotRecords
|
return normal, robotRecords
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *roomRocketProgressCoalescer) saveNormalDue(ctx context.Context, entries []roomRocketProgressEntry) error {
|
func (c *roomRocketProgressCoalescer) publishDue(ctx context.Context, entries []roomRocketProgressEntry) error {
|
||||||
normal := make(map[string][]outbox.Record)
|
if c.publisher == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
record := entry.record
|
record := entry.record
|
||||||
record.AppCode = appcode.Normalize(record.AppCode)
|
if record.Envelope == nil {
|
||||||
if record.Envelope != nil {
|
continue
|
||||||
record.Envelope.AppCode = record.AppCode
|
|
||||||
}
|
}
|
||||||
normal[record.AppCode] = append(normal[record.AppCode], record)
|
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 {
|
||||||
for scopedApp, records := range normal {
|
|
||||||
// repository 会按 context app_code 二次归一,分 app 批量写避免跨租户记录被同一个上下文覆盖。
|
|
||||||
if err := c.repository.SaveOutbox(appcode.WithContext(ctx, scopedApp), records); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -199,7 +198,7 @@ func roomRocketProgressWindowStartMS(createdAtMS int64) int64 {
|
|||||||
return createdAtMS / windowMS * windowMS
|
return createdAtMS / windowMS * windowMS
|
||||||
}
|
}
|
||||||
|
|
||||||
// FlushRoomRocketProgress 把已经超过 1 秒窗口的火箭进度写回主 outbox;机器人进度只 best-effort 直发 IM。
|
// FlushRoomRocketProgress 把已经超过 1 秒窗口的火箭进度直接发腾讯 IM;机器人 lane 保持原展示直发路径。
|
||||||
func (s *Service) FlushRoomRocketProgress(ctx context.Context) error {
|
func (s *Service) FlushRoomRocketProgress(ctx context.Context) error {
|
||||||
if s == nil || s.roomRocketProgressCoalescer == nil {
|
if s == nil || s.roomRocketProgressCoalescer == nil {
|
||||||
return nil
|
return nil
|
||||||
@ -209,7 +208,7 @@ func (s *Service) FlushRoomRocketProgress(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunRoomRocketProgressFlushWorker 周期性把内存合并后的火箭进度落入主 outbox;机器人展示进度不经过 MySQL。
|
// RunRoomRocketProgressFlushWorker 周期性把内存合并后的普通火箭进度直接投递到房间 IM。
|
||||||
func (s *Service) RunRoomRocketProgressFlushWorker(ctx context.Context, interval time.Duration) {
|
func (s *Service) RunRoomRocketProgressFlushWorker(ctx context.Context, interval time.Duration) {
|
||||||
if interval <= 0 {
|
if interval <= 0 {
|
||||||
interval = roomRocketProgressFlushInterval
|
interval = roomRocketProgressFlushInterval
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -36,6 +37,37 @@ type rocketTestWallet struct {
|
|||||||
lastBatch *walletv1.BatchDebitGiftRequest
|
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) {
|
func (w *rocketTestWallet) DebitGift(_ context.Context, req *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) {
|
||||||
w.lastDebit = req
|
w.lastDebit = req
|
||||||
w.debitRequests = append(w.debitRequests, 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-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},
|
{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"
|
roomID := "room-gift-multi-target"
|
||||||
ownerID := int64(101)
|
ownerID := int64(101)
|
||||||
@ -142,6 +175,9 @@ func TestSendGiftBatchDebitsMultipleTargets(t *testing.T) {
|
|||||||
if resp.GetRoomHeat() != 30 || resp.GetBillingReceiptId() != "receipt-202,receipt-303" {
|
if resp.GetRoomHeat() != 30 || resp.GetBillingReceiptId() != "receipt-202,receipt-303" {
|
||||||
t.Fatalf("multi-target gift response must aggregate wallet results: %+v", resp)
|
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 {
|
if firstUser := onlineUserByID(resp.GetRoom(), firstTargetID); firstUser == nil || firstUser.GetGiftValue() != 11 {
|
||||||
t.Fatalf("first target must receive only its own heat value: %+v", firstUser)
|
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) {
|
func (w *rocketTestWallet) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest) (*walletv1.ResourceGrantResponse, error) {
|
||||||
w.grants = append(w.grants, req)
|
w.grants = append(w.grants, req)
|
||||||
return &walletv1.ResourceGrantResponse{
|
return &walletv1.ResourceGrantResponse{
|
||||||
@ -212,7 +301,8 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
|
|||||||
{BillingReceiptId: "receipt-fill", GiftPointAdded: 250, HeatValue: 250, GiftTypeCode: "normal"},
|
{BillingReceiptId: "receipt-fill", GiftPointAdded: 250, HeatValue: 250, GiftTypeCode: "normal"},
|
||||||
{BillingReceiptId: "receipt-next", GiftPointAdded: 50, HeatValue: 50, 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))
|
writeRocketConfig(t, ctx, repository, rocketConfigForTest(100, 1_000))
|
||||||
|
|
||||||
roomID := "room-rocket-flow"
|
roomID := "room-rocket-flow"
|
||||||
@ -250,15 +340,16 @@ func TestRoomRocketGiftOverflowAndImmediateNextLevel(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushRocketProgress(t, ctx, svc, now)
|
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 {
|
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)
|
t.Fatalf("progress event must expose effective fuel and discarded overflow: %+v", progressEvents)
|
||||||
}
|
}
|
||||||
if progressEvents[0].GetLevel() != 1 || progressEvents[0].GetStatus() != state.RocketStatusIgnited {
|
if progressEvents[0].GetLevel() != 1 || progressEvents[0].GetStatus() != state.RocketStatusIgnited {
|
||||||
t.Fatalf("progress event must describe the filled level, got %+v", progressEvents[0])
|
t.Fatalf("progress event must describe the filled level, got %+v", progressEvents[0])
|
||||||
}
|
}
|
||||||
if got := countRocketIgnitedEvents(t, ctx, repository); got != 1 {
|
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketIgnited": 1})
|
||||||
t.Fatalf("fill gift must emit one ignited event, got %d", got)
|
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{
|
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())
|
t.Fatalf("new progress must keep the previous pending launch only: %+v", nextResp.GetRocket())
|
||||||
}
|
}
|
||||||
flushRocketProgress(t, ctx, svc, now)
|
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 {
|
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)
|
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)
|
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-20", GiftPointAdded: 20, HeatValue: 20, GiftTypeCode: "normal"},
|
||||||
{BillingReceiptId: "receipt-progress-30", GiftPointAdded: 30, HeatValue: 30, 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))
|
writeRocketConfig(t, ctx, repository, rocketConfigForTest(1000, 1_000))
|
||||||
|
|
||||||
roomID := "room-rocket-progress-coalesce"
|
roomID := "room-rocket-progress-coalesce"
|
||||||
@ -319,9 +411,12 @@ func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
beforeFlush := outboxEventCounts(t, ctx, repository)
|
beforeFlush := outboxEventCounts(t, ctx, repository)
|
||||||
for _, eventType := range []string{"RoomGiftSent", "RoomHeatChanged", "RoomRankChanged"} {
|
if delta := beforeFlush["RoomGiftSent"] - before["RoomGiftSent"]; delta != 3 {
|
||||||
if delta := beforeFlush[eventType] - before[eventType]; delta != 3 {
|
t.Fatalf("RoomGiftSent must stay durable per gift, delta=%d counts=%+v", delta, beforeFlush)
|
||||||
t.Fatalf("%s must stay per gift before progress flush, delta=%d counts=%+v", eventType, 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 {
|
if delta := beforeFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
|
||||||
@ -330,13 +425,10 @@ func TestRoomRocketProgressCoalescesOneSecondWindow(t *testing.T) {
|
|||||||
|
|
||||||
flushRocketProgress(t, ctx, svc, now)
|
flushRocketProgress(t, ctx, svc, now)
|
||||||
afterFlush := outboxEventCounts(t, ctx, repository)
|
afterFlush := outboxEventCounts(t, ctx, repository)
|
||||||
if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 1 {
|
if delta := afterFlush["RoomRocketFuelChanged"] - before["RoomRocketFuelChanged"]; delta != 0 {
|
||||||
t.Fatalf("rocket progress must coalesce to one outbox row, delta=%d counts=%+v", delta, afterFlush)
|
t.Fatalf("rocket progress must flush to direct IM instead of durable outbox, 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)
|
|
||||||
}
|
}
|
||||||
|
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomRocketFuelChanged": 1})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRoomRocketIgnitedOutboxSchedulesDelayedLaunch(t *testing.T) {
|
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 {
|
func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repository, wallet *rocketTestWallet, clock *fixedRoomRocketClock, scheduler integration.RoomRocketLaunchScheduler) *roomservice.Service {
|
||||||
t.Helper()
|
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{
|
return roomservice.New(roomservice.Config{
|
||||||
NodeID: "node-rocket-test",
|
NodeID: "node-rocket-test",
|
||||||
@ -796,9 +893,34 @@ func newRocketTestServiceWithScheduler(t *testing.T, repository *mysqltest.Repos
|
|||||||
SnapshotEveryN: 1,
|
SnapshotEveryN: 1,
|
||||||
Clock: clock,
|
Clock: clock,
|
||||||
RoomRocketLaunchScheduler: scheduler,
|
RoomRocketLaunchScheduler: scheduler,
|
||||||
|
RoomDisplayPublisher: directIM,
|
||||||
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher())
|
}, 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) {
|
func flushRocketProgress(t testing.TB, ctx context.Context, svc *roomservice.Service, clock *fixedRoomRocketClock) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@ -921,6 +1043,25 @@ func rocketProgressEvents(t *testing.T, ctx context.Context, repository *mysqlte
|
|||||||
return events
|
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 {
|
func roomGiftSentEvents(t *testing.T, ctx context.Context, repository *mysqltest.Repository) []*roomeventsv1.RoomGiftSent {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,8 @@ type Config struct {
|
|||||||
RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
|
RoomRocketLaunchScheduler integration.RoomRocketLaunchScheduler
|
||||||
// RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。
|
// RobotDisplayPublisher 专门 best-effort 投递机器人房间展示事件;失败只打日志,不进入 MySQL outbox。
|
||||||
RobotDisplayPublisher integration.OutboxPublisher
|
RobotDisplayPublisher integration.OutboxPublisher
|
||||||
|
// RoomDisplayPublisher 在 Room Cell 提交后直接投递腾讯 IM 展示事件;失败不回滚已提交房间命令。
|
||||||
|
RoomDisplayPublisher integration.OutboxPublisher
|
||||||
// HumanRobotPoolProvider 自动读取全站机器人账号和用户国家,用于真人房间机器人运行时按国家分组。
|
// HumanRobotPoolProvider 自动读取全站机器人账号和用户国家,用于真人房间机器人运行时按国家分组。
|
||||||
HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider
|
HumanRobotPoolProvider integration.HumanRoomRobotPoolProvider
|
||||||
// RoomGiftLeaderboard 是跨房间贡献榜读模型,SendGift 提交后 best-effort 写入。
|
// RoomGiftLeaderboard 是跨房间贡献榜读模型,SendGift 提交后 best-effort 写入。
|
||||||
@ -81,6 +83,8 @@ type Service struct {
|
|||||||
outboxPublisher integration.OutboxPublisher
|
outboxPublisher integration.OutboxPublisher
|
||||||
// robotDisplayPublisher 只服务机器人房间展示事件,和主 room_outbox 持久事实隔离。
|
// robotDisplayPublisher 只服务机器人房间展示事件,和主 room_outbox 持久事实隔离。
|
||||||
robotDisplayPublisher integration.OutboxPublisher
|
robotDisplayPublisher integration.OutboxPublisher
|
||||||
|
// roomDisplayPublisher 只承载客户端可由快照修复的直接 IM 展示事件,不作为业务事实源。
|
||||||
|
roomDisplayPublisher integration.OutboxPublisher
|
||||||
// outboxWake 提交成功后唤醒主 outbox worker;它只是低延迟提示,事实源仍然是 MySQL room_outbox。
|
// outboxWake 提交成功后唤醒主 outbox worker;它只是低延迟提示,事实源仍然是 MySQL room_outbox。
|
||||||
outboxWake chan struct{}
|
outboxWake chan struct{}
|
||||||
// humanRobotPoolProvider 运行时自动获取全站机器人池,避免后台手工维护国家机器人池。
|
// humanRobotPoolProvider 运行时自动获取全站机器人池,避免后台手工维护国家机器人池。
|
||||||
@ -150,6 +154,10 @@ type mutationResult struct {
|
|||||||
micHeartbeatAtMS int64
|
micHeartbeatAtMS int64
|
||||||
// billingReceiptID 是 SendGift 从 wallet-service 得到的账务回执。
|
// billingReceiptID 是 SendGift 从 wallet-service 得到的账务回执。
|
||||||
billingReceiptID string
|
billingReceiptID string
|
||||||
|
// coinBalanceAfter 是送礼人可展示的最终 COIN 余额;幸运礼物返奖成功时优先使用返奖后的余额。
|
||||||
|
coinBalanceAfter int64
|
||||||
|
// giftIncomeBalanceAfter 是收礼人返币后的 COIN 余额;单目标返币可同步返回给发起方确认。
|
||||||
|
giftIncomeBalanceAfter int64
|
||||||
// roomHeat 是 SendGift 后的房间热度。
|
// roomHeat 是 SendGift 后的房间热度。
|
||||||
roomHeat int64
|
roomHeat int64
|
||||||
// giftRank 是 SendGift 后的本地礼物榜投影。
|
// giftRank 是 SendGift 后的本地礼物榜投影。
|
||||||
@ -160,12 +168,14 @@ type mutationResult struct {
|
|||||||
luckyGifts []*roomv1.LuckyGiftDrawResult
|
luckyGifts []*roomv1.LuckyGiftDrawResult
|
||||||
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。
|
// commandPayload 允许少数命令把外部依赖的结算快照写入 command log,用于恢复。
|
||||||
commandPayload []byte
|
commandPayload []byte
|
||||||
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;实际投递只由 outbox worker 异步执行。
|
// syncEvent 标记该命令存在腾讯云 IM 房间消息形态;保留旧字段兼容命令结果,room UI 现在由提交后 direct IM 发送。
|
||||||
syncEvent *tencentim.RoomEvent
|
syncEvent *tencentim.RoomEvent
|
||||||
// outboxRecords 是本次命令已经持久化的房间事实,提交后可投给 activity 等外部消费者。
|
// outboxRecords 是本次命令已经持久化的房间事实,提交后可投给 activity 等外部消费者。
|
||||||
outboxRecords []outbox.Record
|
outboxRecords []outbox.Record
|
||||||
// robotDisplayRecords 是机器人房间展示事实,提交成功后 best-effort 直发 Tencent IM,不占主 room_outbox。
|
// robotDisplayRecords 是机器人房间展示事实,提交成功后 best-effort 直发 Tencent IM,不占主 room_outbox。
|
||||||
robotDisplayRecords []outbox.Record
|
robotDisplayRecords []outbox.Record
|
||||||
|
// directIMRecords 是提交成功后直接发腾讯 IM 的展示事件;失败不回滚已提交房间命令。
|
||||||
|
directIMRecords []outbox.Record
|
||||||
// roomRocketProgress 是提交成功后才进入内存合并器的火箭进度展示事件,不和送礼命令事务逐笔写 outbox。
|
// roomRocketProgress 是提交成功后才进入内存合并器的火箭进度展示事件,不和送礼命令事务逐笔写 outbox。
|
||||||
roomRocketProgress *roomRocketProgressPending
|
roomRocketProgress *roomRocketProgressPending
|
||||||
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
// roomStatus 非空时代表命令提交时需要同步更新 rooms 和列表投影生命周期状态。
|
||||||
@ -243,10 +253,11 @@ func New(cfg Config, directory router.Directory, repository Repository, wallet i
|
|||||||
syncPublisher: syncPublisher,
|
syncPublisher: syncPublisher,
|
||||||
outboxPublisher: outboxPublisher,
|
outboxPublisher: outboxPublisher,
|
||||||
robotDisplayPublisher: cfg.RobotDisplayPublisher,
|
robotDisplayPublisher: cfg.RobotDisplayPublisher,
|
||||||
|
roomDisplayPublisher: cfg.RoomDisplayPublisher,
|
||||||
outboxWake: make(chan struct{}, 1024),
|
outboxWake: make(chan struct{}, 1024),
|
||||||
humanRobotPoolProvider: cfg.HumanRobotPoolProvider,
|
humanRobotPoolProvider: cfg.HumanRobotPoolProvider,
|
||||||
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
|
roomRocketLaunchScheduler: cfg.RoomRocketLaunchScheduler,
|
||||||
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(repository),
|
roomRocketProgressCoalescer: newRoomRocketProgressCoalescer(cfg.RoomDisplayPublisher),
|
||||||
robotDisplaySampler: newRobotDisplaySampler(),
|
robotDisplaySampler: newRobotDisplaySampler(),
|
||||||
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
roomGiftLeaderboard: cfg.RoomGiftLeaderboard,
|
||||||
roomListCache: cfg.RoomListCache,
|
roomListCache: cfg.RoomListCache,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user