303 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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{}
walletPaidAtMS int64
omitPaidAt bool
}
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:
}
}
paidAtMS := w.walletPaidAtMS
if paidAtMS <= 0 && !w.omitPaidAt {
paidAtMS = 1_700_000_000_000
}
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",
PaidAtMs: paidAtMS,
}, nil
}
func TestDynamicLuckyGiftRejectsMissingWalletPaidAtAfterDebit(t *testing.T) {
ctx := appcode.WithContext(context.Background(), appcode.Default)
repository := mysqltest.NewRepository(t)
wallet := &concurrentV2Wallet{omitPaidAt: true}
lucky := &recoverableLuckyClient{}
svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-missing-paid-at", LeaseTTL: 10 * time.Second, RankLimit: 20, SnapshotEveryN: 1,
}, router.NewMemoryDirectory(), repository, wallet, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher(), lucky)
roomID := "room-gift-v2-missing-paid-at"
createRocketRoom(t, ctx, svc, roomID, 351, 9001)
joinRocketRoom(t, ctx, svc, roomID, 352)
request := &roomv1.SendGiftRequest{
Meta: &roomv1.RequestMeta{CommandId: "cmd-v2-missing-paid-at", ActorUserId: 351, RoomId: roomID, AppCode: appcode.Default, DeviceId: "device-v2-missing-paid-at"},
TargetType: "user", TargetUserId: 352, GiftId: "lucky-rose", GiftCount: 1, PoolId: "lucky-pool",
}
if _, err := svc.SendGift(ctx, request); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms should fail closed, got %v", err)
}
if lucky.calls.Load() != 0 {
t.Fatalf("dynamic_v3 missing wallet paid_at_ms reached lucky random path %d times", lucky.calls.Load())
}
operation, exists, err := repository.GetGiftOperation(ctx, roomID, request.GetMeta().GetCommandId())
if err != nil || !exists || operation.Status != roomservice.GiftOperationStatusDebited {
t.Fatalf("already debited saga must remain recoverable: exists=%v operation=%+v err=%v", exists, operation, err)
}
}
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
paidAtMS [2]atomic.Int64
}
func (*recoverableLuckyClient) CheckLuckyGift(context.Context, *luckygiftv1.CheckLuckyGiftRequest) (*luckygiftv1.CheckLuckyGiftResponse, error) {
return &luckygiftv1.CheckLuckyGiftResponse{Enabled: true, RuleVersion: 1, PoolId: "lucky-pool", StrategyVersion: "dynamic_v3"}, nil
}
func (c *recoverableLuckyClient) ExecuteLuckyGiftDraw(_ context.Context, req *luckygiftv1.ExecuteLuckyGiftDrawRequest) (*luckygiftv1.ExecuteLuckyGiftDrawResponse, error) {
meta := req.GetLuckyGift()
call := c.calls.Add(1)
if call <= 2 {
c.paidAtMS[call-1].Store(meta.GetPaidAtMs())
}
if call == 1 {
return nil, xerr.New(xerr.Unavailable, "temporary lucky outage")
}
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)
walletPaidAtMS := time.Date(2026, 7, 12, 23, 59, 59, 700_000_000, time.UTC).UnixMilli()
wallet := &concurrentV2Wallet{walletPaidAtMS: walletPaidAtMS}
lucky := &recoverableLuckyClient{}
clock := &fixedRoomRocketClock{now: time.Date(2026, 7, 12, 23, 59, 59, 800_000_000, time.UTC)}
svc := roomservice.New(roomservice.Config{
NodeID: "node-gift-v2-recovery",
LeaseTTL: 48 * time.Hour,
RankLimit: 20,
SnapshotEveryN: 1,
Clock: clock,
}, 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, DeviceId: "device-v2-recovery"},
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)
}
// 恢复跨过五分钟加权窗和 UTC 切日后lucky-service 仍必须收到 wallet 首次交易时间;
// saga created_at 与 wallet paid_at 即使只差 100ms 也不是同一个 owner 事实,不能相互替代。
clock.now = clock.now.Add(6 * time.Minute)
if clock.Now().Sub(time.UnixMilli(operation.CreatedAtMS)) <= 5*time.Minute || clock.Now().UTC().Day() == time.UnixMilli(operation.CreatedAtMS).UTC().Day() {
t.Fatalf("test clock did not cross both recovery boundaries: created=%s recovery=%s", time.UnixMilli(operation.CreatedAtMS).UTC(), clock.Now())
}
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)
}
if operation.CreatedAtMS == walletPaidAtMS {
t.Fatalf("test must distinguish room saga time from wallet transaction time: %d", walletPaidAtMS)
}
if firstPaidAt, recoveredPaidAt := lucky.paidAtMS[0].Load(), lucky.paidAtMS[1].Load(); firstPaidAt != walletPaidAtMS || recoveredPaidAt != walletPaidAtMS {
t.Fatalf("lucky paid_at_ms drifted from wallet fact across recovery: wallet=%d operation=%d first=%d recovered=%d", walletPaidAtMS, operation.CreatedAtMS, firstPaidAt, recoveredPaidAt)
}
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)