2026-07-12 00:47:20 +08:00

248 lines
10 KiB
Go

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)