fix(game): require coin balance for room rps

This commit is contained in:
zhx 2026-06-17 15:36:34 +08:00
parent 2a488daeed
commit d9433647d3
2 changed files with 220 additions and 8 deletions

View File

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

View File

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