From d9433647d301a1d1074370725409e1bfcb1a3252 Mon Sep 17 00:00:00 2001 From: zhx Date: Wed, 17 Jun 2026 15:36:34 +0800 Subject: [PATCH] fix(game): require coin balance for room rps --- .../internal/service/roomrps/service.go | 96 ++++++++++++- .../internal/service/roomrps/service_test.go | 132 +++++++++++++++++- 2 files changed, 220 insertions(+), 8 deletions(-) diff --git a/services/game-service/internal/service/roomrps/service.go b/services/game-service/internal/service/roomrps/service.go index 64a0e318..6e685f6b 100644 --- a/services/game-service/internal/service/roomrps/service.go +++ b/services/game-service/internal/service/roomrps/service.go @@ -62,6 +62,11 @@ 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) +} + // ConfigStore 是 room-rps 配置的持久化边界;service 只关心完整配置快照,表结构和 JSON 编码留在 MySQL 仓储层。 type ConfigStore interface { GetRoomRPSConfig(ctx context.Context, appCode string, gameID string) (*gamev1.RoomRPSConfig, error) @@ -83,6 +88,7 @@ type Service struct { config Config user UserClient gifts GiftCatalogClient + wallet BalanceClient pub Publisher store ConfigStore @@ -104,13 +110,18 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config.RevealCountdown = 3 * time.Second } var gifts GiftCatalogClient + var wallet BalanceClient var store ConfigStore for _, extra := range extras { - switch typed := extra.(type) { - case GiftCatalogClient: + if typed, ok := extra.(GiftCatalogClient); ok { // room-rps 运行配置只保存四个 gift_id;后台不再携带展示字段时,必须从 wallet-service 读取真实礼物快照。 gifts = typed - case ConfigStore: + } + if typed, ok := extra.(BalanceClient); ok { + // 发起和应战都必须在进入状态机前查 COIN 余额;不做余额查询时会让 0 金币用户进入 PK 链路。 + wallet = typed + } + if typed, ok := extra.(ConfigStore); ok { // MySQL 是配置事实源;没有仓储的测试场景才落回进程内 map,避免把运行态差异写成前端兜底。 store = typed } @@ -119,6 +130,7 @@ func New(config Config, user UserClient, publisher Publisher, extras ...any) *Se config: config, user: user, gifts: gifts, + wallet: wallet, pub: publisher, store: store, configs: make(map[string]*gamev1.RoomRPSConfig), @@ -301,6 +313,14 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS if !ok || !gift.GetEnabled() { return nil, 0, xerr.New(xerr.InvalidArgument, "room rps gift is not enabled") } + stakeCoin := gift.GetGiftPriceCoin() + 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) + if err != nil { + return nil, 0, err + } s.mu.Lock() now := s.now() @@ -313,8 +333,8 @@ func (s *Service) CreateChallenge(ctx context.Context, req *gamev1.CreateRoomRPS Status: StatusPending, StakeGiftId: legacyRoomRPSGiftID(giftID), StakeGiftIdText: giftID, - StakeCoin: gift.GetGiftPriceCoin(), - Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS}, + StakeCoin: stakeCoin, + Initiator: &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: initiatorBalance, JoinedAtMs: nowMS}, SettlementStatus: SettlementPending, TimeoutAtMs: now.Add(s.config.ChallengeTimeout).UnixMilli(), CreatedAtMs: nowMS, @@ -370,9 +390,45 @@ func (s *Service) AcceptChallenge(ctx context.Context, req *gamev1.AcceptRoomRPS s.mu.Unlock() return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") } + stakeCoin := challenge.GetStakeCoin() + s.mu.Unlock() + + challengerBalance, err := s.ensureCoinBalance(ctx, req.GetMeta().GetRequestId(), app, req.GetUserId(), stakeCoin) + if err != nil { + return nil, 0, err + } + + s.mu.Lock() + challenge, ok = s.challenges[challengeID] + if !ok || challenge.GetAppCode() != app { + s.mu.Unlock() + 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() + return nil, 0, xerr.New(xerr.Conflict, "room rps challenge is not pending") + } + if challenge.GetTimeoutAtMs() > 0 && nowMS > challenge.GetTimeoutAtMs() { + // 二次校验也要收敛超时单,避免余额查询耗时跨过 timeout 后仍然匹配成功。 + challenge.Status = StatusTimeout + challenge.SettlementStatus = SettlementRefunded + challenge.UpdatedAtMs = nowMS + cloned := cloneChallenge(challenge) + s.mu.Unlock() + _ = 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() + return nil, 0, xerr.New(xerr.Conflict, "room rps initiator can not accept self challenge") + } challenge.Status = StatusFinished - challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, JoinedAtMs: nowMS} + challenge.Challenger = &gamev1.RoomRPSPlayer{UserId: req.GetUserId(), Gesture: gesture, BalanceAfter: challengerBalance, JoinedAtMs: nowMS} challenge.MatchedAtMs = nowMS challenge.RevealAtMs = now.Add(s.config.RevealCountdown).UnixMilli() challenge.SettledAtMs = nowMS @@ -745,6 +801,34 @@ 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") + } + if s.wallet == nil { + // 没有 wallet-service 余额事实时不能放用户进入 PK;否则测试服/线上配置缺依赖会退化成免费 PK。 + 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"}, + }) + 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") +} + func roomRPSGiftDisplayName(gift *walletv1.GiftConfig) string { if gift == nil { return "" diff --git a/services/game-service/internal/service/roomrps/service_test.go b/services/game-service/internal/service/roomrps/service_test.go index f628dab0..fe5b7b0a 100644 --- a/services/game-service/internal/service/roomrps/service_test.go +++ b/services/game-service/internal/service/roomrps/service_test.go @@ -129,7 +129,8 @@ func TestCreateChallengeMatchesStringGiftID(t *testing.T) { "Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 1), "lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 111), }) - svc := New(Config{}, nil, nil, catalog) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 10}) + svc := New(Config{}, nil, nil, catalog, wallet) svc.now = func() time.Time { return time.UnixMilli(1770000000000) } _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ @@ -161,6 +162,50 @@ 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 { + t.Fatalf("initiator balance snapshot mismatch: %+v", challenge.GetInitiator()) + } +} + +func TestCreateChallengeRejectsInsufficientCoinBalance(t *testing.T) { + catalog := newFakeGiftCatalog(map[string]*walletv1.GiftConfig{ + "Gifi-3": roomRPSWalletGift("Gifi-3", "Tiger", "https://cdn.example.test/tiger.png", "", "", 100), + "Gifi-2": roomRPSWalletGift("Gifi-2", "Cowboy", "https://cdn.example.test/cowboy.png", "", "", 200), + "Gifi-1": roomRPSWalletGift("Gifi-1", "Warrior", "https://cdn.example.test/warrior.png", "", "", 300), + "lw1": roomRPSWalletGift("lw1", "Lucky Wheel", "https://cdn.example.test/lw1.png", "", "", 400), + }) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 99}) + svc := New(Config{}, nil, nil, catalog, wallet) + svc.now = func() time.Time { return time.UnixMilli(1770000000000) } + + _, _, err := svc.UpdateConfig(context.Background(), "lalu", &gamev1.RoomRPSConfig{ + Status: "active", + ChallengeTimeoutMs: 600000, + RevealCountdownMs: 3000, + StakeGifts: []*gamev1.RoomRPSStakeGift{ + {GiftIdText: "Gifi-3", Enabled: true, SortOrder: 1}, + {GiftIdText: "Gifi-2", Enabled: true, SortOrder: 2}, + {GiftIdText: "Gifi-1", Enabled: true, SortOrder: 3}, + {GiftIdText: "lw1", Enabled: true, SortOrder: 4}, + }, + }) + if err != nil { + t.Fatalf("UpdateConfig() error = %v", err) + } + + _, _, err = svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-create-low-balance"}, + UserId: 42, + RoomId: "room-1", + GiftIdText: "Gifi-3", + Gesture: "rock", + }) + if !xerr.IsCode(err, xerr.InsufficientBalance) { + t.Fatalf("CreateChallenge() error = %v, want InsufficientBalance", err) + } + if len(svc.challenges) != 0 { + t.Fatalf("insufficient balance must not create pending challenge: %+v", svc.challenges) + } } func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) { @@ -214,7 +259,8 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { CreatedAtMs: 1000, UpdatedAtMs: 2000, } - svc := New(Config{}, nil, nil, store) + wallet := newFakeRoomRPSWallet(map[int64]int64{42: 1000}) + svc := New(Config{}, nil, nil, store, wallet) svc.now = func() time.Time { return time.UnixMilli(1770000000000) } challenge, _, err := svc.CreateChallenge(context.Background(), &gamev1.CreateRoomRPSChallengeRequest{ @@ -232,6 +278,70 @@ func TestCreateChallengeUsesPersistentRoomRPSConfig(t *testing.T) { } } +func TestAcceptChallengeRejectsInsufficientCoinBalance(t *testing.T) { + wallet := newFakeRoomRPSWallet(map[int64]int64{41: 1000, 42: 99}) + 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"}, + UserId: 41, + RoomId: "room-1", + GiftId: 10001, + Gesture: "rock", + }) + if err != nil { + t.Fatalf("CreateChallenge() error = %v", err) + } + + _, _, err = svc.AcceptChallenge(context.Background(), &gamev1.AcceptRoomRPSChallengeRequest{ + Meta: &gamev1.RequestMeta{AppCode: "lalu", RequestId: "req-accept-low-balance"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + if !xerr.IsCode(err, xerr.InsufficientBalance) { + t.Fatalf("AcceptChallenge() error = %v, want InsufficientBalance", err) + } + current, _, err := svc.GetChallenge(context.Background(), "lalu", created.GetChallengeId()) + if err != nil { + t.Fatalf("GetChallenge() error = %v", err) + } + if current.GetStatus() != StatusPending || current.GetChallenger().GetUserId() != 0 { + t.Fatalf("insufficient balance must leave challenge pending without challenger: %+v", current) + } +} + +func TestAcceptChallengeRecordsChallengerCoinBalance(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"}, + 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"}, + UserId: 42, + ChallengeId: created.GetChallengeId(), + Gesture: "paper", + }) + if err != nil { + t.Fatalf("AcceptChallenge() error = %v", err) + } + if accepted.GetStatus() != StatusFinished || accepted.GetChallenger().GetBalanceAfter() != 500 { + t.Fatalf("accepted challenge balance snapshot mismatch: %+v", accepted) + } +} + type fakeGiftCatalog struct { gifts map[string]*walletv1.GiftConfig requests []*walletv1.ListGiftConfigsRequest @@ -249,6 +359,24 @@ func (f *fakeGiftCatalog) ListGiftConfigs(_ context.Context, req *walletv1.ListG return &walletv1.ListGiftConfigsResponse{Gifts: []*walletv1.GiftConfig{}, Total: 0}, nil } +type fakeRoomRPSWallet struct { + balances map[int64]int64 + requests []*walletv1.GetBalancesRequest +} + +func newFakeRoomRPSWallet(balances map[int64]int64) *fakeRoomRPSWallet { + return &fakeRoomRPSWallet{balances: balances} +} + +func (f *fakeRoomRPSWallet) GetBalances(_ context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error) { + f.requests = append(f.requests, req) + return &walletv1.GetBalancesResponse{Balances: []*walletv1.AssetBalance{{ + AppCode: req.GetAppCode(), + AssetType: "COIN", + AvailableAmount: f.balances[req.GetUserId()], + }}}, nil +} + func roomRPSWalletGift(giftID string, name string, assetURL string, previewURL string, animationURL string, coinPrice int64) *walletv1.GiftConfig { return &walletv1.GiftConfig{ AppCode: "lalu",