package service_test import ( "context" "sync/atomic" "testing" "time" luckygiftv1 "hyapp.local/api/proto/luckygift/v1" roomv1 "hyapp.local/api/proto/room/v1" walletv1 "hyapp.local/api/proto/wallet/v1" "hyapp/pkg/appcode" "hyapp/pkg/xerr" "hyapp/services/room-service/internal/integration" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/testutil/mysqltest" ) type concurrentV2Wallet struct { followTestWallet calls atomic.Int32 started chan struct{} release chan struct{} } func (w *concurrentV2Wallet) DebitGift(ctx context.Context, _ *walletv1.DebitGiftRequest) (*walletv1.DebitGiftResponse, error) { if w.calls.Add(1) == 1 && w.started != nil { close(w.started) } if w.release != nil { select { case <-ctx.Done(): return nil, ctx.Err() case <-w.release: } } return &walletv1.DebitGiftResponse{ BillingReceiptId: "receipt-v2-stable", TransactionId: "wallet-tx-v2-stable", CoinSpent: 100, ChargeAmount: 100, HeatValue: 100, BalanceAfter: 900, BalanceVersion: 17, GiftIncomeBalanceAfter: 25, GiftTypeCode: "normal", }, nil } func TestSendGiftConcurrentCommandReplaysOriginalV2AndKeepsV1Fields(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := mysqltest.NewRepository(t) wallet := &concurrentV2Wallet{started: make(chan struct{}), release: make(chan struct{})} svc := roomservice.New(roomservice.Config{ NodeID: "node-gift-v2-race", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-gift-v2-race" senderID, targetID := int64(101), int64(202) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) req := &roomv1.SendGiftRequest{ Meta: &roomv1.RequestMeta{RequestId: "req-v2-race", CommandId: "combo-v2-race:1", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, TargetType: "user", TargetUserId: targetID, GiftId: "rose", GiftCount: 5, ComboSessionId: "combo-v2-race", BatchSeq: 1, } responses := make(chan *roomv1.SendGiftResponse, 2) errs := make(chan error, 2) go func() { resp, err := svc.SendGift(ctx, req) responses <- resp errs <- err }() <-wallet.started go func() { resp, err := svc.SendGift(ctx, req) responses <- resp errs <- err }() close(wallet.release) var got []*roomv1.SendGiftResponse for range 2 { if err := <-errs; err != nil { t.Fatalf("concurrent SendGift failed: %v", err) } got = append(got, <-responses) } if calls := wallet.calls.Load(); calls != 1 { t.Fatalf("same command_id must debit once, got %d wallet calls", calls) } replays := 0 for _, resp := range got { if resp.GetV2() == nil { t.Fatalf("V2 result is missing: %+v", resp) } if resp.GetV2().GetIdempotentReplay() { replays++ } if resp.GetV2().GetBillingReceiptId() != "receipt-v2-stable" || resp.GetV2().GetCoinBalanceAfter() != 900 || resp.GetV2().GetBalanceVersion() != 17 || resp.GetV2().GetSettledGiftCount() != 5 || resp.GetV2().GetCommittedRoomVersion() <= 0 { t.Fatalf("V2 stable result mismatch: %+v", resp.GetV2()) } // 旧 Flutter 仍读取顶层字段;首次响应和幂等重放都必须回填相同回执、余额和热度。 if resp.GetBillingReceiptId() != resp.GetV2().GetBillingReceiptId() || resp.GetCoinBalanceAfter() != resp.GetV2().GetCoinBalanceAfter() || resp.GetRoomHeat() != resp.GetV2().GetRoomHeat() { t.Fatalf("V1 compatibility fields drifted: response=%+v v2=%+v", resp, resp.GetV2()) } } if replays != 1 { t.Fatalf("exactly one overlapping request should be replayed, got %d", replays) } if heat := got[0].GetRoom().GetHeat(); heat != 100 { t.Fatalf("same command_id changed room heat more than once: %d", heat) } } type recoverableLuckyClient struct { calls atomic.Int32 } func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) { return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool"}, nil } func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) { if c.calls.Add(1) == 1 { return nil, xerr.New(xerr.Unavailable, "temporary lucky outage") } meta := req.GetLuckyGift() return &luckygiftv1.ExecuteLuckyGiftDrawResponse{Result: &luckygiftv1.LuckyGiftDrawResult{ DrawId: "draw-recovered", CommandId: meta.GetCommandId(), GiftId: meta.GetGiftId(), PoolId: meta.GetPoolId(), MultiplierPpm: 2_000_000, EffectiveRewardCoins: 200, RewardStatus: "granted", CoinBalanceAfter: 1100, Hits: []*luckygiftv1.LuckyGiftHit{{GiftIndex: 1, DrawId: "draw-recovered", SelectedTierId: "2x", MultiplierPpm: 2_000_000, RewardCoins: 200}}, }}, nil } func (c *recoverableLuckyClient) BatchExecuteLuckyGiftDraw(ctx context.Context, req *luckygiftv1.BatchExecuteLuckyGiftDrawRequest) (*luckygiftv1.BatchExecuteLuckyGiftDrawResponse, error) { results := make([]*luckygiftv1.LuckyGiftDrawResult, 0, len(req.GetLuckyGifts())) for _, meta := range req.GetLuckyGifts() { resp, err := c.ExecuteLuckyGiftDraw(ctx, &luckygiftv1.ExecuteLuckyGiftDrawRequest{LuckyGift: meta}) if err != nil { return nil, err } results = append(results, resp.GetResult()) } return &luckygiftv1.BatchExecuteLuckyGiftDrawResponse{Results: results}, nil } func TestGiftOperationWorkerFinishesDebitedRequestWithoutClientRetry(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := mysqltest.NewRepository(t) wallet := &concurrentV2Wallet{} lucky := &recoverableLuckyClient{} svc := roomservice.New(roomservice.Config{ NodeID: "node-gift-v2-recovery", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1, }, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky) roomID := "room-gift-v2-recovery" senderID, targetID := int64(301), int64(302) createRocketRoom(t, ctx, svc, roomID, senderID, 9001) joinRocketRoom(t, ctx, svc, roomID, targetID) req := &roomv1.SendGiftRequest{ Meta: &roomv1.RequestMeta{RequestId: "req-v2-recovery", CommandId: "cmd-v2-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, TargetType: "user", TargetUserId: targetID, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool", } if _, err := svc.SendGift(ctx, req); !xerr.IsCode(err, xerr.Unavailable) { t.Fatalf("first draw should fail after debit, got %v", err) } operation, exists, err := repository.GetGiftOperation(ctx, roomID, req.GetMeta().GetCommandId()) if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited { t.Fatalf("debited saga was not persisted: exists=%v operation=%+v err=%v", exists, operation, err) } if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{ Meta: &roomv1.RequestMeta{CommandId: "close-during-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, }); !xerr.IsCode(err, xerr.Conflict) { t.Fatalf("room close must wait for debited gift recovery, got %v", err) } workerCtx, cancel := context.WithCancel(ctx) defer cancel() go svc.RunGiftOperationRecoveryWorker(workerCtx, 10*time.Millisecond) deadline := time.Now().Add(3 * time.Second) for { record, committed, err := repository.GetCommand(ctx, roomID, req.GetMeta().GetCommandId()) if err != nil { t.Fatalf("read recovered command: %v", err) } if committed { if len(record.ResultPayload) == 0 { t.Fatal("recovered command did not persist V2 result") } break } if time.Now().After(deadline) { t.Fatal("server recovery worker did not commit debited gift") } time.Sleep(20 * time.Millisecond) } replayed, err := svc.SendGift(ctx, req) if err != nil { t.Fatalf("replay recovered gift failed: %v", err) } // HTTP 送礼回执只返回 wallet debit 的稳定账后余额;幸运返奖由异步钱包/IM 事实随后推进, // 不能把 lucky-service 的临时 coin_balance_after 冒充同一 sender 版本的扣费余额。 if !replayed.GetV2().GetIdempotentReplay() || replayed.GetLuckyGift().GetDrawId() != "draw-recovered" || replayed.GetCoinBalanceAfter() != 900 || len(replayed.GetV2().GetLuckyHits()) != 1 || replayed.GetV2().GetLuckyHits()[0].GetGiftIndex() != 1 { t.Fatalf("recovered original result was not replayed: %+v", replayed) } if _, err := svc.CloseRoom(ctx, &roomv1.CloseRoomRequest{ Meta: &roomv1.RequestMeta{CommandId: "close-after-gift-recovery", ActorUserId: senderID, RoomId: roomID, AppCode: appcode.Default}, }); err != nil { t.Fatalf("room close should proceed after gift saga commits: %v", err) } } func TestSendGiftOwnerRejectsExcessiveUnitsBeforeWallet(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := mysqltest.NewRepository(t) wallet := &concurrentV2Wallet{} svc := roomservice.New(roomservice.Config{NodeID: "node-gift-v2-limit", LeaseTTL: 10 * time.Second, SnapshotEveryN: 1}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) roomID := "room-gift-v2-limit" createRocketRoom(t, ctx, svc, roomID, 401, 9001) targets := []int64{402, 403, 404, 405, 406, 407} for _, targetID := range targets { joinRocketRoom(t, ctx, svc, roomID, targetID) } _, err := svc.SendGift(ctx, &roomv1.SendGiftRequest{ Meta: &roomv1.RequestMeta{CommandId: "cmd-v2-limit", ActorUserId: 401, RoomId: roomID, AppCode: appcode.Default}, TargetType: "user", TargetUserIds: targets, GiftId: "rose", GiftCount: 999, }) if !xerr.IsCode(err, xerr.InvalidArgument) { t.Fatalf("owner total unit limit should reject request, got %v", err) } if wallet.calls.Load() != 0 { t.Fatalf("rejected request reached wallet %d times", wallet.calls.Load()) } } var _ integration.WalletClient = (*concurrentV2Wallet)(nil) var _ integration.LuckyGiftClient = (*recoverableLuckyClient)(nil)