package broadcast import ( "context" "encoding/json" "errors" "testing" "time" "google.golang.org/protobuf/proto" luckygifteventsv1 "hyapp.local/api/proto/events/luckygift/v1" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/imgroup" "hyapp/pkg/luckygiftmq" "hyapp/pkg/tencentim" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" ) type crossAppBroadcastRepository struct { Repository claimed map[string]bool deliveredApps []string } func (r *crossAppBroadcastRepository) ListBroadcastOutboxAppCodes(context.Context) ([]string, error) { return []string{"huwaa", "fami"}, nil } func (r *crossAppBroadcastRepository) ClaimPendingBroadcastOutbox(ctx context.Context, _ string, _ int64, _ int64, _ int, _ int) ([]broadcastdomain.OutboxRecord, error) { appCode := appcode.FromContext(ctx) if r.claimed == nil { r.claimed = map[string]bool{} } if r.claimed[appCode] { return nil, nil } r.claimed[appCode] = true return []broadcastdomain.OutboxRecord{{ AppCode: appCode, EventID: "cross-app:" + appCode, Scope: broadcastdomain.ScopeRoom, GroupID: "room-" + appCode, BroadcastType: broadcastdomain.TypeLuckyGiftBigWin, PayloadJSON: `{}`, }}, nil } func (r *crossAppBroadcastRepository) MarkBroadcastOutboxDelivered(ctx context.Context, _ string, _ int64) error { r.deliveredApps = append(r.deliveredApps, appcode.FromContext(ctx)) return nil } func (r *crossAppBroadcastRepository) MarkBroadcastOutboxFailed(context.Context, string, string, int64, int64, int, string) error { return nil } func TestProcessPendingBroadcastsUsesEachRecordAppContext(t *testing.T) { repository := &crossAppBroadcastRepository{} service := New(Config{NodeID: "cross-app-broadcast"}, repository, &fakePublisher{}, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000).UTC() }) result, err := service.ProcessPendingBroadcasts(context.Background(), WorkerOptions{WorkerID: "worker", BatchSize: 2, LockTTL: time.Second, MaxRetry: 8}) if err != nil { t.Fatalf("process cross-app broadcasts: %v", err) } if result.SuccessCount != 2 || len(repository.deliveredApps) != 2 { t.Fatalf("cross-app broadcasts were not delivered: result=%+v apps=%v", result, repository.deliveredApps) } want := map[string]bool{"fami": true, "huwaa": true} for _, appCode := range repository.deliveredApps { if !want[appCode] { t.Fatalf("unexpected delivered app context %q", appCode) } delete(want, appCode) } if len(want) != 0 { t.Fatalf("missing delivered app contexts: %v", want) } } func TestHandleRoomGiftSentCreatesRegionBroadcast(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a", SuperGiftMinValue: 100}, repository, nil, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_000_000) }) service.SetSenderProfileSource(fakeSenderProfileSource{profiles: map[int64]SenderProfile{ 42: {UserID: 42, Account: "160042", Nickname: "Sender", Avatar: "https://cdn.example/sender.png", AvatarFrameURL: "https://cdn.example/sender-frame.png"}, 43: {UserID: 43, Account: "160043", Nickname: "Receiver", Avatar: "https://cdn.example/receiver.png", AvatarFrameURL: "https://cdn.example/receiver-frame.png"}, }}) envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{ SenderUserId: 42, TargetUserId: 43, GiftId: "rocket", GiftCount: 2, GiftValue: 999, VisibleRegionId: 1001, CommandId: "cmd-send-gift-1", GiftName: "Rocket", GiftIconUrl: "https://cdn.example/rocket.png", GiftEffectTypes: []string{"animation", "global_broadcast"}, }) result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope) if err != nil { t.Fatalf("HandleRoomEvent failed: %v", err) } if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "gift_broadcast:room-1001:cmd-send-gift-1" { t.Fatalf("unexpected result: %+v", result) } record := repository.records[result.BroadcastEventID] if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift { t.Fatalf("unexpected broadcast record: %+v", record) } var payload map[string]any if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { t.Fatalf("payload is not json: %v", err) } if payload["room_id"] != "room-1001" || payload["gift_id"] != "rocket" || payload["scope"] != "region" || payload["gift_icon_url"] != "https://cdn.example/rocket.png" { t.Fatalf("payload mismatch: %+v", payload) } sender, _ := payload["sender"].(map[string]any) receiver, _ := payload["receiver"].(map[string]any) if sender["nickname"] != "Sender" || sender["avatar_frame_url"] != "https://cdn.example/sender-frame.png" || receiver["nickname"] != "Receiver" { t.Fatalf("payload user profile mismatch: sender=%+v receiver=%+v", sender, receiver) } again, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope) if err != nil { t.Fatalf("second HandleRoomEvent failed: %v", err) } if again.BroadcastCreated { t.Fatalf("second consume must be idempotent: %+v", again) } } func TestHandleLuckyGiftEventCreatesTenTimesRegionBroadcastIdempotently(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a"}, repository, nil, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) }) // owner fact 已带送礼时展示快照;即使当前 user-service 不可用,也不应回查并阻断这条事实。 service.SetSenderProfileSource(fakeSenderProfileSource{err: errors.New("user service unavailable")}) envelope := mustLuckyGiftEnvelope(t, &luckygifteventsv1.LuckyGiftDrawn{ DrawId: "lucky_draw_10x", CommandId: "cmd-lucky-10x", RoomId: "room-210", PoolId: "super_lucky", GiftId: "43", GiftCount: 1, SenderUserId: 42, TargetUserId: 99, SenderName: "Real Sender", SenderAvatar: "https://cdn.example/sender.png", SenderDisplayUserId: "160042", SenderPrettyDisplayUserId: "888042", VisibleRegionId: 210, CountryId: 15, CoinSpent: 100, RuleVersion: 7, ExperiencePool: "advanced", SelectedTierId: "10x", MultiplierPpm: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM, BaseRewardCoins: 1000, EffectiveRewardCoins: 1000, HighMultiplier: true, RewardStatus: "granted", WalletTransactionId: "wallet-tx-10x", DrawCreatedAtMs: 1_700_000_000_000, RewardGrantedAtMs: 1_700_000_000_500, }) result, err := service.HandleLuckyGiftEvent(context.Background(), envelope) if err != nil { t.Fatalf("HandleLuckyGiftEvent failed: %v", err) } if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "lucky_gift_big_win:lucky_draw_10x" { t.Fatalf("unexpected lucky gift consume result: %+v", result) } record := repository.records[result.BroadcastEventID] if record.GroupID != "hy_lalu_bc_r_v2_210" || record.BroadcastType != broadcastdomain.TypeLuckyGiftBigWin { t.Fatalf("unexpected lucky gift broadcast record: %+v", record) } var payload map[string]any if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { t.Fatalf("lucky gift payload is not json: %v", err) } if payload["event_type"] != "lucky_gift_drawn" || payload["reward_status"] != "granted" || payload["sender_name"] != "Real Sender" || payload["sender_pretty_display_user_id"] != "888042" { t.Fatalf("lucky gift payload identity/profile mismatch: %+v", payload) } if payload["room_id"] != "room-210" || payload["draw_id"] != "lucky_draw_10x" || payload["multiplier_ppm"] != float64(luckygiftmq.LuckyGiftDisplayMinMultiplierPPM) || payload["effective_reward_coins"] != float64(1000) || payload["sent_at_ms"] != float64(1_700_000_001_000) { t.Fatalf("lucky gift payload reward mismatch: %+v", payload) } if _, exists := payload["gift_name"]; exists { t.Fatalf("region payload must let Flutter resolve localized gift name: %+v", payload) } action, _ := payload["action"].(map[string]any) if action["type"] != "enter_room" || action["room_id"] != "room-210" { t.Fatalf("lucky gift action mismatch: %+v", action) } sender, _ := payload["sender"].(map[string]any) if sender["nickname"] != "Real Sender" || sender["display_user_id"] != "160042" || sender["pretty_display_user_id"] != "888042" { t.Fatalf("nested lucky gift sender mismatch: %+v", sender) } again, err := service.HandleLuckyGiftEvent(context.Background(), envelope) if err != nil { t.Fatalf("duplicate HandleLuckyGiftEvent failed: %v", err) } if again.BroadcastCreated || len(repository.records) != 1 { t.Fatalf("duplicate lucky owner fact must reuse one outbox: result=%+v records=%d", again, len(repository.records)) } } func TestHandleLuckyGiftEventSkipsNonBigWinFacts(t *testing.T) { tests := []struct { name string mutate func(*luckygifteventsv1.LuckyGiftDrawn) }{ {name: "below ten times", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.MultiplierPpm = luckygiftmq.LuckyGiftDisplayMinMultiplierPPM - 1 }}, {name: "pending reward", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.RewardStatus = "pending" }}, {name: "zero reward", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.EffectiveRewardCoins = 0 }}, {name: "unknown region", mutate: func(event *luckygifteventsv1.LuckyGiftDrawn) { event.VisibleRegionId = 0 }}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a"}, repository, nil, nil) event := validLuckyGiftDrawn() test.mutate(event) result, err := service.HandleLuckyGiftEvent(context.Background(), mustLuckyGiftEnvelope(t, event)) if err != nil { t.Fatalf("HandleLuckyGiftEvent failed: %v", err) } if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" || len(repository.records) != 0 { t.Fatalf("non-big-win fact must be skipped: result=%+v records=%+v", result, repository.records) } }) } } func TestHandleLuckyGiftEventReturnsProfileFailureForMQRetry(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a"}, repository, nil, nil) service.SetSenderProfileSource(fakeSenderProfileSource{err: errors.New("user service timeout")}) event := validLuckyGiftDrawn() // 旧 pending outbox 可能没有展示快照;activity 必须回查资料,失败时不能确认 MQ 或写不完整播报。 event.SenderName = "" event.SenderDisplayUserId = "" if _, err := service.HandleLuckyGiftEvent(context.Background(), mustLuckyGiftEnvelope(t, event)); err == nil { t.Fatal("missing snapshot with profile failure should return an error") } if len(repository.records) != 0 { t.Fatalf("profile failure must not persist incomplete broadcast: %+v", repository.records) } } func TestHandleRoomGiftSentCreatesRegionBroadcastForTaggedGiftBelowThreshold(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a", SuperGiftMinValue: 1000}, repository, nil, nil) envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{ SenderUserId: 42, TargetUserId: 43, GiftId: "tagged-gift", GiftCount: 1, GiftValue: 10, VisibleRegionId: 1001, CommandId: "cmd-tagged-gift", GiftEffectTypes: []string{"animation", "global_broadcast"}, }) result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope) if err != nil { t.Fatalf("HandleRoomEvent tagged gift failed: %v", err) } if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated { t.Fatalf("tagged gift should create broadcast: %+v", result) } record := repository.records[result.BroadcastEventID] if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.BroadcastType != broadcastdomain.TypeSuperGift { t.Fatalf("unexpected tagged gift broadcast record: %+v", record) } } func TestHandleRoomPasswordChangedCreatesRegionBroadcast(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a"}, repository, nil, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_001_000) }) envelope := mustPasswordChangedEnvelope(t, &roomeventsv1.RoomPasswordChanged{ ActorUserId: 42, Locked: true, VisibleRegionId: 1001, }) result, err := service.HandleRoomEvent(appcode.WithContext(context.Background(), "lalu"), envelope) if err != nil { t.Fatalf("HandleRoomEvent password changed failed: %v", err) } if result.Status != broadcastdomain.StatusPending || !result.BroadcastCreated || result.BroadcastEventID != "room_password_changed:"+envelope.GetEventId() { t.Fatalf("unexpected password broadcast result: %+v", result) } record := repository.records[result.BroadcastEventID] if record.GroupID != "hy_lalu_bc_r_v2_1001" || record.Scope != broadcastdomain.ScopeRegion || record.BroadcastType != broadcastdomain.TypeRoomPasswordChanged { t.Fatalf("unexpected password broadcast record: %+v", record) } var payload map[string]any if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { t.Fatalf("password payload is not json: %v", err) } if payload["room_id"] != "room-1001" || payload["scope"] != "region" || payload["locked"] != true || payload["actor_user_id"] != float64(42) { t.Fatalf("password payload mismatch: %+v", payload) } action, ok := payload["action"].(map[string]any) if !ok || action["type"] != "refresh_room" || action["room_id"] != "room-1001" { t.Fatalf("password payload action mismatch: %+v", payload["action"]) } } func TestHandleRoomGiftSentSkipsNonBroadcastCases(t *testing.T) { service := New(Config{SuperGiftMinValue: 1000}, newFakeRepository(), nil, nil) belowThreshold := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 999, VisibleRegionId: 1001}) result, err := service.HandleRoomEvent(context.Background(), belowThreshold) if err != nil { t.Fatalf("below threshold failed: %v", err) } if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" { t.Fatalf("below threshold should skip: %+v", result) } globalRegion := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{GiftValue: 1000, VisibleRegionId: 0}) result, err = service.HandleRoomEvent(context.Background(), globalRegion) if err != nil { t.Fatalf("global region failed: %v", err) } if result.Status != broadcastdomain.StatusSkipped || result.BroadcastEventID != "" { t.Fatalf("global region should skip: %+v", result) } } func TestProcessPendingBroadcastsPublishesCustomMessage(t *testing.T) { repository := newFakeRepository() publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, repository, publisher, nil) repository.records["evt-1"] = broadcastdomain.OutboxRecord{ AppCode: "lalu", EventID: "evt-1", GroupID: "hy_lalu_bc_g", BroadcastType: broadcastdomain.TypeRedPacket, PayloadJSON: `{"event_id":"evt-1","broadcast_type":"red_packet"}`, Status: broadcastdomain.StatusPending, } result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10}) if err != nil { t.Fatalf("ProcessPendingBroadcasts failed: %v", err) } if result.SuccessCount != 1 || len(publisher.messages) != 1 { t.Fatalf("publish result mismatch: result=%+v messages=%d", result, len(publisher.messages)) } if repository.records["evt-1"].Status != broadcastdomain.StatusDelivered { t.Fatalf("record should be delivered: %+v", repository.records["evt-1"]) } if publisher.messages[0].GroupID != "hy_lalu_bc_g" || publisher.messages[0].Desc != broadcastdomain.TypeRedPacket { t.Fatalf("message mismatch: %+v", publisher.messages[0]) } } func TestVIPOnlineNoticeUsesGlobalOutboxAndStableEnvelope(t *testing.T) { repository := newFakeRepository() publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, repository, publisher, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_800_000_000_000) }) ctx := appcode.WithContext(context.Background(), "fami") input := PublishInput{ EventID: "vip_online_notice:42:digest", BroadcastType: broadcastdomain.TypeVIPOnlineNotice, PayloadJSON: `{"event_id":"forged","broadcast_type":"forged","scope":"region","app_code":"lalu","region_id":999,"user_id":42,"vip_level":9}`, } result, err := service.PublishGlobalBroadcast(ctx, input) if err != nil { t.Fatalf("PublishGlobalBroadcast failed: %v", err) } if !result.Created || result.GroupID != "hy_fami_bc_g_v2" || result.Status != broadcastdomain.StatusPending { t.Fatalf("global publish result mismatch: %+v", result) } record := repository.records[input.EventID] if record.Scope != broadcastdomain.ScopeGlobal || record.GroupID != "hy_fami_bc_g_v2" || record.BroadcastType != broadcastdomain.TypeVIPOnlineNotice { t.Fatalf("global outbox mismatch: %+v", record) } var payload map[string]any if err := json.Unmarshal([]byte(record.PayloadJSON), &payload); err != nil { t.Fatalf("decode global payload: %v", err) } if payload["event_id"] != input.EventID || payload["broadcast_type"] != broadcastdomain.TypeVIPOnlineNotice || payload["scope"] != broadcastdomain.ScopeGlobal || payload["app_code"] != "fami" || payload["sent_at_ms"] != float64(1_800_000_000_000) { t.Fatalf("forced global envelope mismatch: %+v", payload) } if _, exists := payload["region_id"]; exists { t.Fatalf("global payload must not carry region_id: %+v", payload) } // 同一个 event_id 的 HTTP 重试只返回已有 outbox,不能覆盖首个用户资料快照或重复发送。 retry, err := service.PublishGlobalBroadcast(ctx, PublishInput{EventID: input.EventID, BroadcastType: broadcastdomain.TypeVIPOnlineNotice, PayloadJSON: `{"user_id":99}`}) if err != nil || retry.Created || repository.records[input.EventID].PayloadJSON != record.PayloadJSON { t.Fatalf("global retry must be idempotent: retry=%+v record=%+v err=%v", retry, repository.records[input.EventID], err) } processed, err := service.ProcessPendingBroadcasts(ctx, WorkerOptions{WorkerID: "worker-vip", BatchSize: 10}) if err != nil || processed.SuccessCount != 1 || len(publisher.messages) != 2 { // 全局 v2 成功后会向 v1 旧群补发一份,两个 TIMCustomElem 共享同一业务 payload。 t.Fatalf("VIP online IM publish mismatch: result=%+v messages=%+v err=%v", processed, publisher.messages, err) } if publisher.messages[0].Desc != broadcastdomain.TypeVIPOnlineNotice || publisher.messages[0].Ext != "im_broadcast" || publisher.messages[0].EventID != input.EventID { t.Fatalf("VIP online TIMCustomElem mismatch: %+v", publisher.messages[0]) } } func TestConsumeRedPacketCreatedBuildsFullIMPayload(t *testing.T) { repository := newFakeRepository() service := New(Config{NodeID: "node-a"}, repository, nil, nil) service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{ UserID: 10001, Account: "10001", Nickname: "Tom", Avatar: "https://example.com/avatar.png", RegionCode: "AR", }}) event := broadcastdomain.RedPacketWalletEvent{ AppCode: "lalu", EventID: "wallet-red-packet-created-1", EventType: "WalletRedPacketCreated", PacketID: "VRRP2026052712340001", PacketType: "delayed", RoomID: "9001", RegionID: 310, SenderUserID: 10001, TotalAmount: 15000, TotalCount: 5, RemainingAmount: 15000, RemainingCount: 5, Status: "waiting_open", OpenAtMS: 1_780_000_000_000, ExpiresAtMS: 1_780_086_400_000, CreatedAtMS: 1_779_999_700_000, } if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil { t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err) } record := repository.records[event.EventID] data := redPacketPayloadData(t, record.PayloadJSON) if data["packetNo"] != event.PacketID || data["packetId"] != event.PacketID || data["scope"] != "ROOM" { t.Fatalf("packet identity mismatch: %+v", data) } if data["senderUserId"] != "10001" || data["actualAccount"] != "10001" || data["userNickname"] != "Tom" || data["userAvatar"] != "https://example.com/avatar.png" { t.Fatalf("sender display fields mismatch: %+v", data) } if data["totalAmount"] != float64(15000) || data["totalCount"] != float64(5) || data["remainAmount"] != float64(15000) || data["remainCount"] != float64(5) { t.Fatalf("red packet amount/count fields mismatch: %+v", data) } if data["packetMode"] != "DELAYED" || data["status"] != "WAITING_OPEN" || data["delaySeconds"] != float64(300) || data["regionCode"] != "AR" { t.Fatalf("red packet display state mismatch: %+v", data) } } func TestDelayedRedPacketCreatedSendsRegionAndSchedulesOpenDueIM(t *testing.T) { nowMS := int64(1_779_999_700_000) openAtMS := int64(1_780_000_000_000) repository := newFakeRepository() publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, repository, publisher, nil) service.SetClock(func() time.Time { return time.UnixMilli(nowMS) }) service.SetSenderProfileSource(fakeSenderProfileSource{profile: SenderProfile{ UserID: 10001, Account: "10001", Nickname: "Tom", Avatar: "https://example.com/avatar.png", RegionCode: "AR", }}) event := broadcastdomain.RedPacketWalletEvent{ AppCode: "lalu", EventID: "wallet-red-packet-created-1", EventType: "WalletRedPacketCreated", PacketID: "VRRP2026052712340001", PacketType: "delayed", RoomID: "9001", RegionID: 310, SenderUserID: 10001, TotalAmount: 15000, TotalCount: 5, RemainingAmount: 15000, RemainingCount: 5, Status: "waiting_open", OpenAtMS: openAtMS, ExpiresAtMS: 1_780_086_400_000, CreatedAtMS: nowMS, } if err := service.ConsumeRedPacketWalletEvent(appcode.WithContext(context.Background(), "lalu"), event); err != nil { t.Fatalf("ConsumeRedPacketWalletEvent failed: %v", err) } roomCreated := repository.records[event.EventID] regionCreated := repository.records[event.EventID+":region_created"] openDue := repository.records[event.EventID+":open_due"] if roomCreated.GroupID != "9001" || roomCreated.Scope != broadcastdomain.ScopeRoom || roomCreated.NextRetryAtMS != nowMS { t.Fatalf("room created outbox mismatch: %+v", roomCreated) } if regionCreated.GroupID != "hy_lalu_bc_r_v2_310" || regionCreated.Scope != broadcastdomain.ScopeRegion || regionCreated.NextRetryAtMS != nowMS { t.Fatalf("region created outbox mismatch: %+v", regionCreated) } if openDue.GroupID != "9001" || openDue.Scope != broadcastdomain.ScopeRoom || openDue.NextRetryAtMS != openAtMS { t.Fatalf("open due outbox mismatch: %+v", openDue) } regionData := redPacketPayloadData(t, regionCreated.PayloadJSON) if regionData["event"] != "CREATED" || regionData["scope"] != "REGION" || regionData["packetMode"] != "DELAYED" { t.Fatalf("region created payload mismatch: %+v", regionData) } openDueData := redPacketPayloadData(t, openDue.PayloadJSON) if openDueData["event"] != "OPEN_DUE" || openDueData["scope"] != "ROOM" || openDueData["claimStartTime"] != float64(openAtMS) { t.Fatalf("open due payload mismatch: %+v", openDueData) } result, err := service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10}) if err != nil { t.Fatalf("ProcessPendingBroadcasts before open failed: %v", err) } // v2 区域群投递成功后过渡期会向 v1 旧群补发一条 _legacy 副本,所以消息比 outbox 记录多一条。 if result.SuccessCount != 2 || len(publisher.messages) != 3 || containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") { t.Fatalf("before open only room/region CREATED should be sent: result=%+v messages=%+v", result, publisher.messages) } legacyCopy := findCustomMessageEvent(publisher.messages, event.EventID+":region_created_legacy") if legacyCopy == nil || legacyCopy.GroupID != "hy_lalu_bc_r_310" { t.Fatalf("region broadcast should dual-send to legacy v1 group: %+v", publisher.messages) } service.SetClock(func() time.Time { return time.UnixMilli(openAtMS) }) result, err = service.ProcessPendingBroadcasts(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker-a", BatchSize: 10}) if err != nil { t.Fatalf("ProcessPendingBroadcasts after open failed: %v", err) } if result.SuccessCount != 1 || !containsCustomMessageEvent(publisher.messages, event.EventID+":open_due") { t.Fatalf("after open should send OPEN_DUE: result=%+v messages=%+v", result, publisher.messages) } } func TestRemoveRegionBroadcastMemberDeletesOnlyUserMembership(t *testing.T) { publisher := &fakePublisher{} service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil) groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001) if err != nil { t.Fatalf("RemoveRegionBroadcastMember failed: %v", err) } if !removed || groupID != "hy_lalu_bc_r_v2_1001" { t.Fatalf("unexpected remove result: group_id=%s removed=%t", groupID, removed) } // 切区必须同时清 v2 主群和过渡期 v1 旧群的成员关系。 if len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "hy_lalu_bc_r_1001" || publisher.deletedUserID != 42 { t.Fatalf("unexpected deleted member: group_ids=%v user_id=%d", publisher.deletedGroupIDs, publisher.deletedUserID) } groupID, removed, err = service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 0) if err != nil { t.Fatalf("zero region should be a no-op, got: %v", err) } if removed || groupID != "" { t.Fatalf("zero region should not delete any group member: group_id=%s removed=%t", groupID, removed) } } func TestRemoveRegionBroadcastMemberAcknowledgesAVChatKickRestriction(t *testing.T) { publisher := &fakePublisher{deleteError: tencentim.RESTError{Code: 10007, Info: "this group disable kick member"}} service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, nil) groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001) if err != nil { t.Fatalf("AVChat kick restriction must be acknowledged without retry: %v", err) } if removed || groupID != "hy_lalu_bc_r_v2_1001" { t.Fatalf("AVChat restriction result mismatch: group_id=%s removed=%t", groupID, removed) } if len(publisher.deletedGroupIDs) != 1 { t.Fatalf("unsupported v2 removal must not retry the legacy AVChat group: %v", publisher.deletedGroupIDs) } // 10007 还可能表示其他群业务错误;只能降级腾讯明确返回的 disable-kick 语义。 publisher.deleteError = tencentim.RESTError{Code: 10007, Info: "another group error"} if _, _, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001); err == nil { t.Fatal("unrelated Tencent IM 10007 error must remain retryable") } } func TestRegionBroadcastUsesConfiguredGroupPrefix(t *testing.T) { repository := newFakeRepository() publisher := &fakePublisher{} service := New(Config{NodeID: "node-a", GroupIDPrefix: "test_"}, repository, publisher, nil) result, err := service.PublishRegionBroadcast(appcode.WithContext(context.Background(), "lalu"), PublishInput{ EventID: "evt-prefixed", BroadcastType: broadcastdomain.TypeSuperGift, RegionID: 1001, PayloadJSON: `{"ok":true}`, }) if err != nil { t.Fatalf("PublishRegionBroadcast failed: %v", err) } if result.GroupID != "test_hy_lalu_bc_r_v2_1001" { t.Fatalf("prefixed publish group mismatch: %+v", result) } if record := repository.records["evt-prefixed"]; record.GroupID != "test_hy_lalu_bc_r_v2_1001" { t.Fatalf("prefixed outbox group mismatch: %+v", record) } groupID, removed, err := service.RemoveRegionBroadcastMember(appcode.WithContext(context.Background(), "lalu"), 42, 1001) if err != nil { t.Fatalf("RemoveRegionBroadcastMember failed: %v", err) } if !removed || groupID != "test_hy_lalu_bc_r_v2_1001" || len(publisher.deletedGroupIDs) != 2 || publisher.deletedGroupIDs[0] != "test_hy_lalu_bc_r_v2_1001" || publisher.deletedGroupIDs[1] != "test_hy_lalu_bc_r_1001" { t.Fatalf("prefixed remove mismatch: group_id=%s removed=%t deleted=%v", groupID, removed, publisher.deletedGroupIDs) } } func TestEnsureBroadcastGroupsUsesAVChatRoomAndSurvivesGlobalFailure(t *testing.T) { publisher := &fakePublisher{ensureErrors: map[string]error{"hy_lalu_bc_g_v2": errors.New("quota exceeded")}} service := New(Config{NodeID: "node-a"}, newFakeRepository(), publisher, fakeRegionSource{regionIDs: []int64{310, 320}}) count, err := service.EnsureBroadcastGroups(appcode.WithContext(context.Background(), "lalu")) if err == nil { t.Fatal("global group failure must be reported") } // 全局群失败不再阻断区域群补偿:两个区域群仍应建成。 if count != 2 { t.Fatalf("region groups must still be ensured after global failure: count=%d", count) } if len(publisher.ensuredGroups) != 3 { t.Fatalf("expected global+2 region ensure calls: %+v", publisher.ensuredGroups) } for _, spec := range publisher.ensuredGroups[1:] { // 区域群一律生成 v2 并使用 AVChatRoom(无 ApplyJoinOption),不再受 ChatRoom 成员上限约束。 if spec.Type != tencentim.BroadcastGroupType || spec.ApplyJoinOption != "" { t.Fatalf("region group must be AVChatRoom without apply_join_option: %+v", spec) } } if publisher.ensuredGroups[1].GroupID != "hy_lalu_bc_r_v2_310" || publisher.ensuredGroups[2].GroupID != "hy_lalu_bc_r_v2_320" { t.Fatalf("region group ids mismatch: %+v", publisher.ensuredGroups) } } func TestReconcileBroadcastGroupsIncludesConfiguredAppWithoutOutbox(t *testing.T) { publisher := &fakePublisher{} service := New(Config{NodeID: "multi-app-reconcile", AppCodes: []string{"lalu", "fami"}}, newFakeRepository(), publisher, fakeRegionSource{regionIDs: []int64{1001}}) service.reconcileBroadcastGroups(appcode.WithContext(context.Background(), "lalu"), WorkerOptions{WorkerID: "worker"}) wantGroups := map[string]bool{ "hy_lalu_bc_g_v2": true, "hy_lalu_bc_r_v2_1001": true, "hy_fami_bc_g_v2": true, "hy_fami_bc_r_v2_1001": true, } for index, spec := range publisher.ensuredGroups { if !wantGroups[spec.GroupID] { t.Fatalf("unexpected configured app group: %+v", spec) } delete(wantGroups, spec.GroupID) parsed := imgroup.Parse(spec.GroupID) if parsed.AppCode != publisher.ensuredAppCodes[index] { t.Fatalf("group %s reconciled with wrong app context %s", spec.GroupID, publisher.ensuredAppCodes[index]) } } if len(wantGroups) != 0 { t.Fatalf("configured app groups were not reconciled: %+v", wantGroups) } } func mustGiftEnvelope(t *testing.T, gift *roomeventsv1.RoomGiftSent) *roomeventsv1.EventEnvelope { t.Helper() body, err := proto.Marshal(gift) if err != nil { t.Fatalf("marshal gift failed: %v", err) } return &roomeventsv1.EventEnvelope{ EventId: "evt-room-gift-1", RoomId: "room-1001", EventType: "RoomGiftSent", RoomVersion: 7, OccurredAtMs: 1_700_000_000_000, AppCode: "lalu", Body: body, } } func mustPasswordChangedEnvelope(t *testing.T, event *roomeventsv1.RoomPasswordChanged) *roomeventsv1.EventEnvelope { t.Helper() body, err := proto.Marshal(event) if err != nil { t.Fatalf("marshal password changed failed: %v", err) } return &roomeventsv1.EventEnvelope{ EventId: "evt-room-password-1", RoomId: "room-1001", EventType: "RoomPasswordChanged", RoomVersion: 8, OccurredAtMs: 1_700_000_001_000, AppCode: "lalu", Body: body, } } func redPacketPayloadData(t *testing.T, payloadJSON string) map[string]any { t.Helper() var payload struct { Type string `json:"type"` Data map[string]any `json:"data"` } if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { t.Fatalf("payload is not json: %v", err) } if payload.Type != "ROOM_RED_PACKET" || payload.Data == nil { t.Fatalf("unexpected red packet payload: %+v", payload) } return payload.Data } func validLuckyGiftDrawn() *luckygifteventsv1.LuckyGiftDrawn { return &luckygifteventsv1.LuckyGiftDrawn{ DrawId: "lucky_draw_valid", CommandId: "cmd-lucky-valid", RoomId: "room-210", PoolId: "super_lucky", GiftId: "43", GiftCount: 1, SenderUserId: 42, TargetUserId: 99, SenderName: "Real Sender", SenderAvatar: "https://cdn.example/sender.png", SenderDisplayUserId: "160042", VisibleRegionId: 210, CoinSpent: 100, MultiplierPpm: luckygiftmq.LuckyGiftDisplayMinMultiplierPPM, BaseRewardCoins: 1000, EffectiveRewardCoins: 1000, RewardStatus: "granted", WalletTransactionId: "wallet-tx-valid", DrawCreatedAtMs: 1_700_000_000_000, RewardGrantedAtMs: 1_700_000_000_500, } } func mustLuckyGiftEnvelope(t *testing.T, event *luckygifteventsv1.LuckyGiftDrawn) *luckygifteventsv1.EventEnvelope { t.Helper() body, err := proto.Marshal(event) if err != nil { t.Fatalf("marshal lucky gift drawn: %v", err) } return &luckygifteventsv1.EventEnvelope{ EventId: "lucky_gift_drawn:" + event.GetDrawId(), EventType: luckygiftmq.EventTypeLuckyGiftDrawn, AppCode: "lalu", DrawId: event.GetDrawId(), OccurredAtMs: event.GetDrawCreatedAtMs(), Body: body, } } func containsCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) bool { return findCustomMessageEvent(messages, eventID) != nil } func findCustomMessageEvent(messages []tencentim.CustomGroupMessage, eventID string) *tencentim.CustomGroupMessage { for index := range messages { if messages[index].EventID == eventID { return &messages[index] } } return nil } type fakeRepository struct { records map[string]broadcastdomain.OutboxRecord } func newFakeRepository() *fakeRepository { return &fakeRepository{records: map[string]broadcastdomain.OutboxRecord{}} } func (r *fakeRepository) SaveBroadcastOutbox(_ context.Context, record broadcastdomain.OutboxRecord) (bool, broadcastdomain.OutboxRecord, error) { if existing, ok := r.records[record.EventID]; ok { return false, existing, nil } r.records[record.EventID] = record return true, record, nil } func (r *fakeRepository) ClaimPendingBroadcastOutbox(_ context.Context, workerID string, nowMS int64, lockUntilMS int64, limit int, _ int) ([]broadcastdomain.OutboxRecord, error) { records := make([]broadcastdomain.OutboxRecord, 0, limit) for eventID, record := range r.records { if len(records) >= limit { break } if record.Status != broadcastdomain.StatusPending && record.Status != broadcastdomain.StatusRetryable { continue } if record.NextRetryAtMS > nowMS { continue } record.Status = broadcastdomain.StatusDelivering record.LockedBy = workerID record.LockedUntilMS = lockUntilMS r.records[eventID] = record records = append(records, record) } return records, nil } func (r *fakeRepository) MarkBroadcastOutboxDelivered(_ context.Context, eventID string, nowMS int64) error { record, ok := r.records[eventID] if !ok { return errors.New("record not found") } record.Status = broadcastdomain.StatusDelivered record.UpdatedAtMS = nowMS r.records[eventID] = record return nil } func (r *fakeRepository) MarkBroadcastOutboxFailed(_ context.Context, eventID string, _ string, nowMS int64, nextRetryAtMS int64, _ int, lastError string) error { record, ok := r.records[eventID] if !ok { return errors.New("record not found") } record.Status = broadcastdomain.StatusRetryable record.AttemptCount++ record.NextRetryAtMS = nextRetryAtMS record.LastError = lastError record.UpdatedAtMS = nowMS r.records[eventID] = record return nil } type fakePublisher struct { messages []tencentim.CustomGroupMessage ensuredGroups []tencentim.GroupSpec ensuredAppCodes []string ensureErrors map[string]error deletedGroupIDs []string deletedUserID int64 deleteError error } func (p *fakePublisher) EnsureGroup(ctx context.Context, spec tencentim.GroupSpec) error { p.ensuredGroups = append(p.ensuredGroups, spec) p.ensuredAppCodes = append(p.ensuredAppCodes, appcode.FromContext(ctx)) return p.ensureErrors[spec.GroupID] } func (p *fakePublisher) PublishGroupCustomMessage(_ context.Context, message tencentim.CustomGroupMessage) error { p.messages = append(p.messages, message) return nil } func (p *fakePublisher) DeleteGroupMember(_ context.Context, groupID string, userID int64) error { p.deletedGroupIDs = append(p.deletedGroupIDs, groupID) p.deletedUserID = userID return p.deleteError } type fakeRegionSource struct { regionIDs []int64 } func (s fakeRegionSource) ListActiveRegionIDs(context.Context) ([]int64, error) { return s.regionIDs, nil } type fakeSenderProfileSource struct { profile SenderProfile profiles map[int64]SenderProfile err error } func (s fakeSenderProfileSource) GetSenderProfile(_ context.Context, userID int64) (SenderProfile, error) { if s.profiles != nil { if profile, ok := s.profiles[userID]; ok { return profile, s.err } } profile := s.profile if profile.UserID <= 0 { profile.UserID = userID } return profile, s.err }