fix(game): settle room rps stake coins

This commit is contained in:
zhx 2026-06-17 15:52:00 +08:00
parent d9433647d3
commit c59317c6d2
2 changed files with 324 additions and 33 deletions

View File

@ -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 {

View File

@ -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",