From c59317c6d2ed2290ea81a8bc63781cee5c7f8f81 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 17 Jun 2026 15:52:00 +0800 Subject: [PATCH] fix(game): settle room rps stake coins --- .../internal/service/roomrps/service.go | 228 +++++++++++++++--- .../internal/service/roomrps/service_test.go | 129 +++++++++- 2 files changed, 324 insertions(+), 33 deletions(-) diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 6e685f6b..d72ea84a 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -3,6 +3,7 @@ package roomrps import ( "context" "crypto/rand" + "crypto/sha256" "encoding/hex" "encoding/json" "fmt" @@ -50,6 +51,10 @@ const ( eventRevealCountdown = "room_rps_reveal_countdown" eventFinished = "room_rps_finished" eventExpired = "room_rps_expired" + + roomRPSWalletPlatform = "room_rps" + roomRPSOpDebit = "debit" + roomRPSOpRefund = "refund" ) // UserClient 是 room-rps 唯一需要的 user-service 能力;这里只取展示资料,不把用户领域模型引入游戏用例层。 @@ -62,9 +67,9 @@ type GiftCatalogClient interface { ListGiftConfigs(ctx context.Context, req *walletv1.ListGiftConfigsRequest) (*walletv1.ListGiftConfigsResponse, error) } -// BalanceClient 只暴露 room-rps 入场前需要的钱包余额查询;PK 状态仍由 game-service 管,金币事实仍由 wallet-service 管。 -type BalanceClient interface { - GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) +// WalletClient 只暴露 room-rps 需要的钱包能力;PK 状态仍由 game-service 管,金币事实和幂等改账由 wallet-service 管。 +type WalletClient interface { + ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) } // ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。 @@ -88,7 +93,7 @@ type Service struct { config Config user UserClient gifts GiftCatalogClient - wallet BalanceClient + wallet WalletClient pub Publisher store ConfigStore @@ -110,15 +115,15 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config.RevealCountdown = 3 * time.Second } var gifts GiftCatalogClient - var wallet BalanceClient + var wallet WalletClient var store ConfigStore for _, extra := range extras { if typed, ok := extra.(GiftCatalogClient); ok { // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 gifts = typed } - if typed, ok := extra.(BalanceClient); ok { - // 发起和应战都必须在进入状态机前查 COIN 余额;不做余额查询时会让 0 金币用户进入 PK 链路。 + if typed, ok := extra.(WalletClient); ok { + // 发起和应战都必须进入 wallet-service 改账;只做余额查询会让用户看到 PK 成功但金币事实没有变化。 wallet = typed } if typed, ok := extra.(ConfigStore); ok { @@ -317,7 +322,8 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS if stakeCoin <= 0 { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") } - initiatorBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin) + challengeID := newChallengeID(app) + initiatorBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) if err != nil { return nil, 0, err } @@ -327,7 +333,7 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS nowMS := now.UnixMilli() challenge := &gamev1.RoomRPSChallenge{ AppCode: app, - ChallengeId: newChallengeID(app), + ChallengeId: challengeID, RoomId: roomID, RegionId: req.GetRegionId(), Status: StatusPending, @@ -382,6 +388,13 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() + refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if refundErr != nil { + s.markSettlementFailed(app, challengeID, refundErr) + return nil, 0, refundErr + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") } @@ -391,9 +404,10 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } stakeCoin := challenge.GetStakeCoin() + roomID := challenge.GetRoomId() s.mu.Unlock() - challengerBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin) + challengerBalance, err := s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpDebit) if err != nil { return nil, 0, err } @@ -402,28 +416,39 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS challenge, ok = s.challenges[challengeID] if !ok || challenge.GetAppCode() != app { s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.NotFound, "room rps challenge not found") } now = s.now() nowMS = now.UnixMilli() if challenge.GetStatus() != StatusPending { - // 余额查询期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准。 + // 钱包扣款期间可能已有其他用户抢先应战;状态机必须以锁内二次校验为准,失败时立即退回刚扣的挑战方本金。 s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") } if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { - // 二次校验也要收敛超时单,避免余额查询耗时跨过 timeout 后仍然匹配成功。 + // 二次校验也要收敛超时单,避免扣款耗时跨过 timeout 后仍然匹配成功。 challenge.Status = StatusTimeout challenge.SettlementStatus = SettlementRefunded challenge.UpdatedAtMs = nowMS cloned := cloneChallenge(challenge) s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) + refunded, refundErr := s.refundPendingChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if refundErr != nil { + s.markSettlementFailed(app, challengeID, refundErr) + return nil, 0, refundErr + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded _ = s.publishChallengeEvent(ctx, eventExpired, cloned) return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is timeout") } if challenge.GetInitiator().GetUserId() == req.GetUserId() { // 二次校验保留同一条权限边界,防止测试或未来接口在第一次检查后篡改挑战归属。 s.mu.Unlock() + _, _ = s.applyCoinChange(ctx, req.GetMeta().GetRequestId(), app, challengeID, roomID, req.GetUserId(), stakeCoin, roomRPSOpRefund) return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } @@ -437,6 +462,32 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS cloned := cloneChallenge(challenge) s.mu.Unlock() + settled, err := s.settleChallenge(ctx, req.GetMeta().GetRequestId(), cloned) + if err != nil { + s.mu.Lock() + if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { + current.Status = StatusSettlementFailed + current.SettlementStatus = SettlementFailed + current.FailureReason = err.Error() + current.UpdatedAtMs = s.now().UnixMilli() + } + s.mu.Unlock() + return nil, 0, err + } + s.mu.Lock() + if current := s.challenges[challengeID]; current != nil && current.GetAppCode() == app { + current.Initiator.BalanceAfter = settled.GetInitiator().GetBalanceAfter() + current.Challenger.BalanceAfter = settled.GetChallenger().GetBalanceAfter() + current.SettlementStatus = settled.GetSettlementStatus() + current.FailureReason = "" + current.SettledAtMs = s.now().UnixMilli() + current.UpdatedAtMs = current.GetSettledAtMs() + cloned = cloneChallenge(current) + } else { + cloned = settled + } + s.mu.Unlock() + if err := s.publishChallengeEvent(ctx, eventChallengeAccepted, cloned); err != nil { return nil, 0, err } @@ -536,6 +587,14 @@ func (s *Service) ExpireChallenge(ctx context.Context, app string, challengeID s cloned := cloneChallenge(challenge) s.mu.Unlock() + refunded, err := s.refundPendingChallenge(ctx, "room-rps-expire:"+challengeID, cloned) + if err != nil { + s.markSettlementFailed(app, challengeID, err) + return nil, 0, err + } + s.replaceChallengeSnapshot(app, challengeID, refunded) + cloned = refunded + if err := s.publishChallengeEvent(ctx, eventExpired, cloned); err != nil { return nil, 0, err } @@ -801,32 +860,141 @@ func (s *Service) roomRPSGiftCatalog(ctx context.Context, app string, giftIDs [] return result, nil } -func (s *Service) ensureCoinBalance(ctx context.Context, requestID string, app string, userID int64, stakeCoin int64) (int64, error) { - if stakeCoin <= 0 { - return 0, xerr.New(xerr.InvalidArgument, "room rps stake coin is invalid") +func (s *Service) settleChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { + if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetChallenger().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "room rps settlement challenge is incomplete") + } + settled := cloneChallenge(challenge) + app := settled.GetAppCode() + challengeID := settled.GetChallengeId() + roomID := settled.GetRoomId() + stakeCoin := settled.GetStakeCoin() + + if settled.GetInitiator().GetResult() == ResultDraw && settled.GetChallenger().GetResult() == ResultDraw { + // 平局没有赢家,也不应产生任何礼物流;双方前置扣款都按原金额退款,客户端只展示 refunded 事实。 + initiatorBalance, initiatorErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) + if initiatorErr == nil { + settled.Initiator.BalanceAfter = initiatorBalance + } + challengerBalance, challengerErr := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) + if challengerErr == nil { + settled.Challenger.BalanceAfter = challengerBalance + } + if initiatorErr != nil { + return settled, initiatorErr + } + if challengerErr != nil { + return settled, challengerErr + } + settled.SettlementStatus = SettlementRefunded + return settled, nil + } + + switch settled.GetWinnerUserId() { + case settled.GetInitiator().GetUserId(): + // 胜者本金必须退回;败者在点击 PK 时已经扣款,这笔扣款保留为败者成本,不再追加第二次扣费。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetInitiator().GetUserId(), stakeCoin, roomRPSOpRefund) + if err != nil { + return settled, err + } + settled.Initiator.BalanceAfter = balanceAfter + settled.SettlementStatus = SettlementSettled + return settled, nil + case settled.GetChallenger().GetUserId(): + // 只退赢家本金;输家的余额保持前置扣款后的余额,避免“输了但金币没少”的账务错误。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, app, challengeID, roomID, settled.GetChallenger().GetUserId(), stakeCoin, roomRPSOpRefund) + if err != nil { + return settled, err + } + settled.Challenger.BalanceAfter = balanceAfter + settled.SettlementStatus = SettlementSettled + return settled, nil + default: + return settled, xerr.New(xerr.InvalidArgument, "room rps winner is invalid") + } +} + +func (s *Service) refundPendingChallenge(ctx context.Context, requestID string, challenge *gamev1.RoomRPSChallenge) (*gamev1.RoomRPSChallenge, error) { + if challenge == nil || challenge.GetInitiator().GetUserId() <= 0 || challenge.GetStakeCoin() <= 0 { + return nil, xerr.New(xerr.InvalidArgument, "room rps pending refund challenge is incomplete") + } + refunded := cloneChallenge(challenge) + // 无人应战或已超时的 pending 单只存在发起人前置扣款;过期时退回发起人,不产生礼物或赢家收益。 + balanceAfter, err := s.applyCoinChange(ctx, requestID, refunded.GetAppCode(), refunded.GetChallengeId(), refunded.GetRoomId(), refunded.GetInitiator().GetUserId(), refunded.GetStakeCoin(), roomRPSOpRefund) + if err != nil { + return refunded, err + } + refunded.Initiator.BalanceAfter = balanceAfter + refunded.SettlementStatus = SettlementRefunded + refunded.FailureReason = "" + refunded.UpdatedAtMs = s.now().UnixMilli() + return refunded, nil +} + +func (s *Service) replaceChallengeSnapshot(app string, challengeID string, snapshot *gamev1.RoomRPSChallenge) { + if s == nil || snapshot == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + current := s.challenges[strings.TrimSpace(challengeID)] + if current == nil || current.GetAppCode() != appcode.Normalize(app) { + return + } + s.challenges[strings.TrimSpace(challengeID)] = cloneChallenge(snapshot) +} + +func (s *Service) markSettlementFailed(app string, challengeID string, cause error) { + if s == nil || cause == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + current := s.challenges[strings.TrimSpace(challengeID)] + if current == nil || current.GetAppCode() != appcode.Normalize(app) { + return + } + current.Status = StatusSettlementFailed + current.SettlementStatus = SettlementFailed + current.FailureReason = cause.Error() + current.UpdatedAtMs = s.now().UnixMilli() +} + +func (s *Service) applyCoinChange(ctx context.Context, requestID string, app string, challengeID string, roomID string, userID int64, amount int64, opType string) (int64, error) { + if amount <= 0 || userID <= 0 || strings.TrimSpace(challengeID) == "" { + return 0, xerr.New(xerr.InvalidArgument, "room rps coin command is incomplete") } if s.wallet == nil { - // 没有 wallet-service 余额事实时不能放用户进入 PK;否则测试服/线上配置缺依赖会退化成免费 PK。 + // 钱包不可用时必须拒绝进入 PK;否则房间状态会成功但 COIN 事实无法同步落账。 return 0, xerr.New(xerr.Unavailable, "room rps wallet client is not configured") } - resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{ - RequestId: strings.TrimSpace(requestID), - UserId: userID, - AppCode: appcode.Normalize(app), - AssetTypes: []string{"COIN"}, + app = appcode.Normalize(app) + opType = strings.TrimSpace(opType) + providerOrderID := fmt.Sprintf("room_rps:%s:%d:%s", strings.TrimSpace(challengeID), userID, opType) + requestHash := roomRPSStableHash(fmt.Sprintf("%s|%s|%s|%s|%d|%d", app, strings.TrimSpace(challengeID), strings.TrimSpace(roomID), opType, userID, amount)) + resp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{ + RequestId: strings.TrimSpace(requestID), + AppCode: app, + CommandId: "game:" + providerOrderID, + UserId: userID, + PlatformCode: roomRPSWalletPlatform, + GameId: GameID, + ProviderOrderId: providerOrderID, + ProviderRoundId: strings.TrimSpace(challengeID), + OpType: opType, + CoinAmount: amount, + RoomId: strings.TrimSpace(roomID), + RequestHash: requestHash, }) if err != nil { return 0, err } - for _, balance := range resp.GetBalances() { - if strings.EqualFold(balance.GetAssetType(), "COIN") { - if balance.GetAvailableAmount() >= stakeCoin { - return balance.GetAvailableAmount(), nil - } - break - } - } - return 0, xerr.New(xerr.InsufficientBalance, "coin balance is insufficient") + return resp.GetBalanceAfter(), nil +} + +func roomRPSStableHash(value string) string { + sum := sha256.Sum256([]byte(value)) + return hex.EncodeToString(sum[:]) } func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string { diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index fe5b7b0a..614c8475 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -2,6 +2,7 @@ package roomrps import ( "context" + "fmt" "testing" "time" @@ -162,7 +163,7 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { if challenge.GetStakeGiftIdText() != "Gifi-3" || challenge.GetStakeGiftId() != 0 || challenge.GetStakeCoin() != 1 { t.Fatalf("string gift challenge mismatch: %+v", challenge) } - if challenge.GetInitiator().GetBalanceAfter() != 10 { + if challenge.GetInitiator().GetBalanceAfter() != 9 || wallet.balances[42] != 9 { t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator()) } } @@ -206,6 +207,9 @@ func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) { if len(svc.challenges) != 0 { t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges) } + if wallet.balances[42] != 99 || len(wallet.applies) != 1 { + t.Fatalf("insufficient create must not mutate wallet balance: balance=%d applies=%d", wallet.balances[42], len(wallet.applies)) + } } func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) { @@ -276,6 +280,9 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { if challenge.GetStakeGiftIdText() != "db-gift" || challenge.GetStakeCoin() != 222 { t.Fatalf("challenge did not use persistent config: %+v", challenge) } + if challenge.GetInitiator().GetBalanceAfter() != 778 || wallet.balances[42] != 778 { + t.Fatalf("persistent config challenge did not debit stake: challenge=%+v wallet=%d", challenge, wallet.balances[42]) + } } func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) { @@ -310,6 +317,9 @@ func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) { if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 { t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current) } + if wallet.balances[41] != 900 || wallet.balances[42] != 99 { + t.Fatalf("accept failure should keep initiator debit and avoid challenger debit: balances=%+v", wallet.balances) + } } func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) { @@ -337,9 +347,88 @@ func TestAcceptChallengeRecordsChallengerCoinBalance(t *testing.T) { if err != nil { t.Fatalf("AcceptChallenge() error = %v", err) } - if accepted.GetStatus() != StatusFinished || accepted.GetChallenger().GetBalanceAfter() != 500 { + if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementSettled || accepted.GetInitiator().GetBalanceAfter() != 900 || accepted.GetChallenger().GetBalanceAfter() != 500 { t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted) } + if wallet.balances[41] != 900 || wallet.balances[42] != 500 { + t.Fatalf("winner refund and loser debit mismatch: balances=%+v", wallet.balances) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund]" { + t.Fatalf("winner settlement should only debit both and refund winner, got ops=%v", got) + } +} + +func TestAcceptChallengeDrawRefundsBothPlayers(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 500}) + svc := New(Config{}, nil, nil, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-draw"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + + accepted, _, err := svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-draw"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "rock", + }) + if err != nil { + t.Fatalf("AcceptChallenge() error = %v", err) + } + if accepted.GetStatus() != StatusFinished || accepted.GetSettlementStatus() != SettlementRefunded || accepted.GetWinnerUserId() != 0 { + t.Fatalf("draw challenge settlement mismatch: %+v", accepted) + } + if accepted.GetInitiator().GetBalanceAfter() != 1000 || accepted.GetChallenger().GetBalanceAfter() != 500 { + t.Fatalf("draw must refund both players in response: %+v", accepted) + } + if wallet.balances[41] != 1000 || wallet.balances[42] != 500 { + t.Fatalf("draw must refund both players in wallet: balances=%+v", wallet.balances) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit debit refund refund]" { + t.Fatalf("draw should only debit both and refund both without gift op, got ops=%v", got) + } +} + +func TestExpireChallengeRefundsInitiatorDebit(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000}) + svc := New(Config{}, nil, nil, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + created, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-expire"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + if wallet.balances[41] != 900 || created.GetInitiator().GetBalanceAfter() != 900 { + t.Fatalf("create should debit initiator before pending challenge: challenge=%+v wallet=%d", created, wallet.balances[41]) + } + + expired, _, err := svc.ExpireChallenge(context.Background(), "lalu", created.GetChallengeId()) + if err != nil { + t.Fatalf("ExpireChallenge() error = %v", err) + } + if expired.GetStatus() != StatusTimeout || expired.GetSettlementStatus() != SettlementRefunded || expired.GetInitiator().GetBalanceAfter() != 1000 { + t.Fatalf("expired challenge should refund initiator: %+v", expired) + } + if wallet.balances[41] != 1000 { + t.Fatalf("expired challenge wallet balance mismatch: %d", wallet.balances[41]) + } + if got := wallet.ops(); fmt.Sprint(got) != "[debit refund]" { + t.Fatalf("expire should only debit then refund initiator, got ops=%v", got) + } } type fakeGiftCatalog struct { @@ -362,10 +451,12 @@ func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListG type fakeRoomRPSWallet struct { balances map[int64]int64 requests []*walletv1.GetBalancesRequest + applies []*walletv1.ApplyGameCoinChangeRequest + replays map[string]*walletv1.ApplyGameCoinChangeResponse } func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet { - return &fakeRoomRPSWallet{balances: balances} + return &fakeRoomRPSWallet{balances: balances, replays: make(map[string]*walletv1.ApplyGameCoinChangeResponse)} } func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { @@ -377,6 +468,38 @@ func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBala }}}, nil } +func (f *fakeRoomRPSWallet) ApplyGameCoinChange(_ context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error) { + f.applies = append(f.applies, req) + if replay := f.replays[req.GetCommandId()]; replay != nil { + return replay, nil + } + switch req.GetOpType() { + case roomRPSOpDebit: + if f.balances[req.GetUserId()] < req.GetCoinAmount() { + return nil, xerr.New(xerr.InsufficientBalance, "insufficient balance") + } + f.balances[req.GetUserId()] -= req.GetCoinAmount() + case roomRPSOpRefund: + f.balances[req.GetUserId()] += req.GetCoinAmount() + default: + return nil, xerr.New(xerr.InvalidArgument, "unsupported op_type") + } + resp := &walletv1.ApplyGameCoinChangeResponse{ + WalletTransactionId: "wtx-" + req.GetCommandId(), + BalanceAfter: f.balances[req.GetUserId()], + } + f.replays[req.GetCommandId()] = resp + return resp, nil +} + +func (f *fakeRoomRPSWallet) ops() []string { + out := make([]string, 0, len(f.applies)) + for _, req := range f.applies { + out = append(out, req.GetOpType()) + } + return out +} + func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig { return &walletv1.GiftConfig{ AppCode: "lalu",