248 lines
10 KiB
Go
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)
|