package broadcast import ( "context" "encoding/json" "testing" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" "hyapp/pkg/tencentim" broadcastdomain "hyapp/services/activity-service/internal/domain/broadcast" "hyapp/services/activity-service/internal/testutil/mysqltest" ) func TestBroadcastServiceUsesRealMySQLOutbox(t *testing.T) { // 这个测试使用生产 initdb 创建隔离 MySQL schema,验证真实 JSON 列、主键幂等、claim 锁和 delivered 更新。 repository := mysqltest.NewRepository(t) publisher := &fakePublisher{} service := New(Config{NodeID: "node-real", SuperGiftMinValue: 100}, repository.Repository, publisher, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_123_456) }) ctx := appcode.WithContext(context.Background(), "lalu") envelope := mustGiftEnvelope(t, &roomeventsv1.RoomGiftSent{ SenderUserId: 42, TargetUserId: 43, GiftId: "super_rocket", GiftCount: 3, GiftValue: 300000, VisibleRegionId: 210, CommandId: "cmd-real-send-gift-1", }) created, err := service.HandleRoomEvent(ctx, envelope) if err != nil { t.Fatalf("HandleRoomEvent with real mysql failed: %v", err) } if !created.BroadcastCreated || created.BroadcastEventID != "gift_broadcast:room-1001:cmd-real-send-gift-1" { t.Fatalf("unexpected first consume result: %+v", created) } t.Logf("real mysql enqueue: room_id=%s region_id=%d gift_value=%d broadcast_event_id=%s", envelope.GetRoomId(), int64(210), int64(300000), created.BroadcastEventID) // 同一个 room outbox 事实再次消费必须命中 MySQL 主键幂等,不能产生第二条播报。 again, err := service.HandleRoomEvent(ctx, envelope) if err != nil { t.Fatalf("second HandleRoomEvent with real mysql failed: %v", err) } if again.BroadcastCreated { t.Fatalf("duplicate room event should not create another broadcast: %+v", again) } record, exists, err := repository.Repository.GetBroadcastOutbox(ctx, created.BroadcastEventID) if err != nil { t.Fatalf("read real mysql outbox failed: %v", err) } if !exists || record.Status != broadcastdomain.StatusPending || record.GroupID != "hy_lalu_bc_r_v2_210" { t.Fatalf("unexpected pending outbox record: exists=%v record=%+v", exists, record) } t.Logf("real mysql pending record: event_id=%s group_id=%s status=%s", record.EventID, record.GroupID, record.Status) assertJSONField(t, record.PayloadJSON, "room_id", "room-1001") assertJSONField(t, record.PayloadJSON, "gift_id", "super_rocket") assertJSONField(t, record.PayloadJSON, "scope", broadcastdomain.ScopeRegion) result, err := service.ProcessPendingBroadcasts(ctx, WorkerOptions{WorkerID: "worker-real", BatchSize: 1, PollInterval: time.Second}) if err != nil { t.Fatalf("ProcessPendingBroadcasts with real mysql failed: %v", err) } if result.ClaimedCount != 1 || result.SuccessCount != 1 || result.FailureCount != 0 { t.Fatalf("unexpected process result: %+v", result) } // v2 区域群投递成功后过渡期会向 v1 旧群补发一条 _legacy 副本。 if len(publisher.messages) != 2 { t.Fatalf("expected v2 message plus legacy copy, got %d", len(publisher.messages)) } assertPublishedMessage(t, publisher.messages[0], "hy_lalu_bc_r_v2_210", created.BroadcastEventID) assertPublishedMessage(t, publisher.messages[1], "hy_lalu_bc_r_210", created.BroadcastEventID+"_legacy") delivered, exists, err := repository.Repository.GetBroadcastOutbox(ctx, created.BroadcastEventID) if err != nil { t.Fatalf("read delivered real mysql outbox failed: %v", err) } if !exists || delivered.Status != broadcastdomain.StatusDelivered || delivered.LockedBy != "" || delivered.LockedUntilMS != 0 { t.Fatalf("unexpected delivered outbox record: exists=%v record=%+v", exists, delivered) } t.Logf("real mysql delivered record: event_id=%s group_id=%s status=%s", delivered.EventID, delivered.GroupID, delivered.Status) } func TestRedPacketCreatedWalletEventUsesRealMySQLOutbox(t *testing.T) { repository := mysqltest.NewRepository(t) service := New(Config{NodeID: "node-real", SuperGiftMinValue: 100}, repository.Repository, nil, nil) service.SetClock(func() time.Time { return time.UnixMilli(1_700_000_456_000) }) ctx := appcode.WithContext(context.Background(), "lalu") // 只有 delayed 且带 room_id/region_id 的红包事实才会产生房间 CREATED 加区域飘屏两条记录。 event := broadcastdomain.RedPacketCreatedEvent{ AppCode: "lalu", EventID: "wallet-red-packet-created-real", TransactionID: "wallet-tx-red-packet-real", CommandID: "cmd-red-packet-real", PacketID: "rp-real", PacketType: "delayed", RoomID: "room-310", RegionID: 310, PayloadJSON: `{"packet_id":"rp-real","room_id":"room-310","region_id":310}`, OpenAtMS: 1_700_000_755_000, CreatedAtMS: 1_700_000_455_000, } if err := service.ConsumeRedPacketCreatedEvent(ctx, event); err != nil { t.Fatalf("ConsumeRedPacketCreatedEvent failed: %v", err) } if err := service.ConsumeRedPacketCreatedEvent(ctx, event); err != nil { t.Fatalf("duplicate ConsumeRedPacketCreatedEvent failed: %v", err) } roomRecord, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID) if err != nil { t.Fatalf("read red packet room broadcast outbox failed: %v", err) } if !exists || roomRecord.BroadcastType != broadcastdomain.TypeRedPacket || roomRecord.GroupID != "room-310" || roomRecord.Status != broadcastdomain.StatusPending { t.Fatalf("unexpected red packet room broadcast record: exists=%v record=%+v", exists, roomRecord) } record, exists, err := repository.Repository.GetBroadcastOutbox(ctx, event.EventID+":region_created") if err != nil { t.Fatalf("read red packet region broadcast outbox failed: %v", err) } if !exists || record.BroadcastType != broadcastdomain.TypeRedPacket || record.GroupID != "hy_lalu_bc_r_v2_310" || record.Status != broadcastdomain.StatusPending { t.Fatalf("unexpected red packet region broadcast record: exists=%v record=%+v", exists, record) } assertJSONField(t, record.PayloadJSON, "region_id", float64(310)) } func assertJSONField(t *testing.T, payloadJSON string, key string, want any) { t.Helper() var payload map[string]any if err := json.Unmarshal([]byte(payloadJSON), &payload); err != nil { t.Fatalf("payload is not valid json: %v\npayload=%s", err, payloadJSON) } if got := payload[key]; got != want { t.Fatalf("json field %s mismatch: got=%v want=%v payload=%+v", key, got, want, payload) } } func assertPublishedMessage(t *testing.T, message tencentim.CustomGroupMessage, groupID string, eventID string) { t.Helper() if message.GroupID != groupID || message.EventID != eventID || message.Desc != broadcastdomain.TypeSuperGift || message.Ext != "im_broadcast" { t.Fatalf("unexpected Tencent custom message: %+v", message) } assertJSONField(t, string(message.PayloadJSON), "event_id", eventID) }