fix(game): require coin balance for room rps
This commit is contained in:
parent
2a488daeed
commit
d9433647d3
@ -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 ""
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user