1154 lines
47 KiB
Go
1154 lines
47 KiB
Go
package dice
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"math/big"
|
||
"strings"
|
||
"time"
|
||
|
||
walletv1 "hyapp.local/api/proto/wallet/v1"
|
||
"hyapp/pkg/appcode"
|
||
"hyapp/pkg/xerr"
|
||
dicedomain "hyapp/services/game-service/internal/domain/dice"
|
||
gamedomain "hyapp/services/game-service/internal/domain/game"
|
||
)
|
||
|
||
// Repository 是骰子用例层需要的持久化边界;局表只保存游戏事实,金币流水仍复用 game_orders。
|
||
type Repository interface {
|
||
GetLaunchableGame(ctx context.Context, appCode string, gameID string) (gamedomain.LaunchableGame, error)
|
||
CreateGameOrder(ctx context.Context, order gamedomain.GameOrder) (gamedomain.GameOrder, bool, error)
|
||
MarkOrderSucceeded(ctx context.Context, appCode string, orderID string, walletTransactionID string, balanceAfter int64, nowMs int64) error
|
||
MarkOrderFailed(ctx context.Context, appCode string, orderID string, status string, code string, message string, nowMs int64) error
|
||
|
||
CreateDiceMatch(ctx context.Context, match dicedomain.Match) (dicedomain.Match, error)
|
||
JoinDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, rpsGesture string, nowMs int64) (dicedomain.Match, error)
|
||
GetDiceMatch(ctx context.Context, appCode string, matchID string) (dicedomain.Match, error)
|
||
ClaimDiceMatchForRoll(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error)
|
||
SaveDiceRolls(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error)
|
||
SaveDiceDrawForReroll(ctx context.Context, match dicedomain.Match, nowMs int64) (dicedomain.Match, error)
|
||
MarkDiceParticipantDebitSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
|
||
MarkDiceParticipantDebitFailed(ctx context.Context, appCode string, matchID string, userID int64, orderID string, nowMs int64) error
|
||
MarkDiceParticipantPayoutSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
|
||
MarkDiceParticipantRefundSucceeded(ctx context.Context, appCode string, matchID string, userID int64, orderID string, balanceAfter int64, nowMs int64) error
|
||
MarkDiceMatchSettled(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error
|
||
MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error
|
||
GetDiceConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error)
|
||
ListSelfGameConfigs(ctx context.Context, appCode string) ([]dicedomain.Config, error)
|
||
UpsertDiceConfig(ctx context.Context, config dicedomain.Config, nowMs int64) (dicedomain.Config, error)
|
||
FindAndJoinWaitingDiceMatch(ctx context.Context, appCode string, gameID string, userID int64, stakeCoin int64, rpsGesture string, nowMs int64) (dicedomain.Match, error)
|
||
JoinDiceRobot(ctx context.Context, appCode string, matchID string, robotUserID int64, forcedResult string, rpsGesture string, nowMs int64) (dicedomain.Match, error)
|
||
CancelDiceMatch(ctx context.Context, appCode string, matchID string, userID int64, nowMs int64) (dicedomain.Match, error)
|
||
PickActiveDiceRobot(ctx context.Context, appCode string, gameID string, matchID string) (dicedomain.Robot, error)
|
||
AdjustDicePool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, error)
|
||
ListDiceRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, error)
|
||
RegisterDiceRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64, nowMs int64) ([]dicedomain.Robot, error)
|
||
SetDiceRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string, nowMs int64) (dicedomain.Robot, error)
|
||
DeleteDiceRobot(ctx context.Context, appCode string, gameID string, userID int64) error
|
||
RecordExploreWinner(ctx context.Context, winner dicedomain.ExploreWinner) error
|
||
ListExploreWinners(ctx context.Context, appCode string, pageSize int32) ([]dicedomain.ExploreWinner, error)
|
||
}
|
||
|
||
// WalletClient 是 game-service 调 wallet-service 的游戏改账入口。
|
||
type WalletClient interface {
|
||
ApplyGameCoinChange(ctx context.Context, req *walletv1.ApplyGameCoinChangeRequest) (*walletv1.ApplyGameCoinChangeResponse, error)
|
||
GetBalances(ctx context.Context, req *walletv1.GetBalancesRequest) (*walletv1.GetBalancesResponse, error)
|
||
}
|
||
|
||
type Config struct {
|
||
JoinTTL time.Duration
|
||
FeeBPS int64
|
||
MaxPlayers int32
|
||
}
|
||
|
||
type Service struct {
|
||
config Config
|
||
repository Repository
|
||
wallet WalletClient
|
||
// now/randomInt 作为依赖注入点,保证测试能锁定时间和骰子点数,不需要碰全局随机源。
|
||
now func() time.Time
|
||
randomInt func(max int) (int, error)
|
||
}
|
||
|
||
type CreateMatchCommand struct {
|
||
AppCode string
|
||
RequestID string
|
||
UserID int64
|
||
GameID string
|
||
RoomID string
|
||
RegionID int64
|
||
StakeCoin int64
|
||
MinPlayers int32
|
||
MaxPlayers int32
|
||
RPSGesture string
|
||
}
|
||
|
||
type JoinMatchCommand struct {
|
||
AppCode string
|
||
RequestID string
|
||
UserID int64
|
||
MatchID string
|
||
GameID string
|
||
RPSGesture string
|
||
}
|
||
|
||
type GetMatchCommand struct {
|
||
AppCode string
|
||
UserID int64
|
||
MatchID string
|
||
GameID string
|
||
}
|
||
|
||
type RollMatchCommand struct {
|
||
AppCode string
|
||
RequestID string
|
||
UserID int64
|
||
MatchID string
|
||
GameID string
|
||
RPSGesture string
|
||
}
|
||
|
||
type MatchCommand struct {
|
||
AppCode string
|
||
RequestID string
|
||
UserID int64
|
||
GameID string
|
||
RoomID string
|
||
RegionID int64
|
||
StakeCoin int64
|
||
RPSGesture string
|
||
}
|
||
|
||
type CancelMatchCommand struct {
|
||
AppCode string
|
||
RequestID string
|
||
UserID int64
|
||
MatchID string
|
||
GameID string
|
||
}
|
||
|
||
// New 创建骰子游戏用例层;随机数、时间和费率都集中在这里,便于测试和后续按房间配置扩展。
|
||
func New(config Config, repository Repository, wallet WalletClient) *Service {
|
||
// 默认值在构造阶段一次性收口,后面的业务分支只看规范化后的配置,避免每个入口重复判断。
|
||
if config.JoinTTL <= 0 {
|
||
config.JoinTTL = time.Duration(dicedomain.DefaultJoinTTLMillis) * time.Millisecond
|
||
}
|
||
if config.FeeBPS <= 0 {
|
||
config.FeeBPS = dicedomain.DefaultFeeBPS
|
||
}
|
||
if config.MaxPlayers <= 0 || config.MaxPlayers > dicedomain.HardMaxPlayers {
|
||
config.MaxPlayers = dicedomain.HardMaxPlayers
|
||
}
|
||
return &Service{
|
||
config: config,
|
||
repository: repository,
|
||
wallet: wallet,
|
||
now: time.Now,
|
||
randomInt: cryptoRandomInt,
|
||
}
|
||
}
|
||
|
||
// CreateMatch 只创建局和首个参与者,不提前扣金币;真正扣款在 roll 阶段统一串到钱包和 outbox。
|
||
func (s *Service) CreateMatch(ctx context.Context, command CreateMatchCommand) (dicedomain.Match, int64, error) {
|
||
// 创建局必须先校验 game_catalog/platform 当前可用,防止下架或维护中的游戏继续生成可结算局。
|
||
if s.repository == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" || command.StakeCoin <= 0 {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice match command is incomplete")
|
||
}
|
||
minPlayers, maxPlayers, err := dicedomain.NormalizePlayerBounds(command.MinPlayers, command.MaxPlayers)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if maxPlayers > s.config.MaxPlayers {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large")
|
||
}
|
||
app := appcode.Normalize(command.AppCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
|
||
game, err := s.repository.GetLaunchableGame(ctx, app, strings.TrimSpace(command.GameID))
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game is not active")
|
||
}
|
||
config, err := s.configForMatch(ctx, app, game.GameID)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
rpsGesture, err := normalizeRPSGestureForGame(game.GameID, command.RPSGesture, true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if config.Status != dicedomain.ConfigStatusActive {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game config is disabled")
|
||
}
|
||
if !dicedomain.StakeEnabled(config, command.StakeCoin) {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice stake_coin is not configured")
|
||
}
|
||
if command.MinPlayers <= 0 {
|
||
command.MinPlayers = config.MinPlayers
|
||
}
|
||
if command.MaxPlayers <= 0 {
|
||
command.MaxPlayers = config.MaxPlayers
|
||
}
|
||
minPlayers, maxPlayers, err = dicedomain.NormalizePlayerBounds(command.MinPlayers, command.MaxPlayers)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if maxPlayers > s.config.MaxPlayers {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice max_players is too large")
|
||
}
|
||
|
||
now := s.now()
|
||
nowMS := now.UnixMilli()
|
||
// match_id 用 app/user/time/request 混合生成;真正防重靠数据库主键,hash 只负责长度稳定和不暴露业务输入。
|
||
matchID := dicedomain.MatchIDPrefix + "_" + stableHash(fmt.Sprintf("%s|%d|%d|%s", app, command.UserID, now.UnixNano(), command.RequestID))[:20]
|
||
match := dicedomain.Match{
|
||
AppCode: app,
|
||
MatchID: matchID,
|
||
GameID: game.GameID,
|
||
PlatformCode: strings.TrimSpace(game.PlatformCode),
|
||
ProviderGameID: strings.TrimSpace(game.ProviderGameID),
|
||
RoomID: strings.TrimSpace(command.RoomID),
|
||
RegionID: command.RegionID,
|
||
MinPlayers: minPlayers,
|
||
MaxPlayers: maxPlayers,
|
||
CurrentPlayers: 1,
|
||
StakeCoin: command.StakeCoin,
|
||
RoundNo: 1,
|
||
Status: dicedomain.MatchStatusCreated,
|
||
FeeBPS: config.FeeBPS,
|
||
PoolBPS: config.PoolBPS,
|
||
MatchMode: dicedomain.MatchModeHuman,
|
||
JoinDeadlineMS: now.Add(s.config.JoinTTL).UnixMilli(),
|
||
CreatedAtMS: nowMS,
|
||
UpdatedAtMS: nowMS,
|
||
Participants: []dicedomain.Participant{{
|
||
// 创建人天然占 1 号座位;后续 join 在仓储行锁内按 max(seat_no)+1 分配,避免并发抢座冲突。
|
||
AppCode: app,
|
||
MatchID: matchID,
|
||
UserID: command.UserID,
|
||
ParticipantType: dicedomain.ParticipantTypeUser,
|
||
SeatNo: 1,
|
||
Status: dicedomain.ParticipantStatusJoined,
|
||
StakeCoin: command.StakeCoin,
|
||
RPSGesture: rpsGesture,
|
||
JoinedAtMS: nowMS,
|
||
UpdatedAtMS: nowMS,
|
||
}},
|
||
}
|
||
if match.PlatformCode == "" {
|
||
// 自研骰子理论上仍应在 catalog 配好平台;这里兜底是为了订单唯一键和统计 outbox 字段始终非空。
|
||
match.PlatformCode = dicedomain.DefaultPlatformCode
|
||
}
|
||
if match.ProviderGameID == "" {
|
||
match.ProviderGameID = dicedomain.DefaultProviderGameID
|
||
}
|
||
saved, err := s.repository.CreateDiceMatch(ctx, match)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
return saved, nowMS, nil
|
||
}
|
||
|
||
// GetConfig 返回 H5 大厅需要的骰子配置;没有后台配置时返回内置默认,不阻断新环境联调。
|
||
func (s *Service) GetConfig(ctx context.Context, appCode string, gameID string) (dicedomain.Config, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
app := appcode.Normalize(appCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
config, err := s.configForMatch(ctx, app, strings.TrimSpace(gameID))
|
||
if err != nil {
|
||
return dicedomain.Config{}, 0, err
|
||
}
|
||
return config, s.now().UnixMilli(), nil
|
||
}
|
||
|
||
func (s *Service) ListSelfGames(ctx context.Context, appCode string) ([]dicedomain.Config, int64, error) {
|
||
if s.repository == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
app := appcode.Normalize(appCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
configs, err := s.repository.ListSelfGameConfigs(ctx, app)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
if len(configs) == 0 {
|
||
config, err := s.configForMatch(ctx, app, dicedomain.DefaultGameID)
|
||
if err != nil {
|
||
return nil, 0, err
|
||
}
|
||
configs = append(configs, config)
|
||
}
|
||
return configs, s.now().UnixMilli(), nil
|
||
}
|
||
|
||
func (s *Service) UpdateConfig(ctx context.Context, config dicedomain.Config) (dicedomain.Config, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
config.AppCode = appcode.Normalize(config.AppCode)
|
||
ctx = appcode.WithContext(ctx, config.AppCode)
|
||
nowMS := s.now().UnixMilli()
|
||
saved, err := s.repository.UpsertDiceConfig(ctx, config, nowMS)
|
||
return saved, nowMS, err
|
||
}
|
||
|
||
func (s *Service) AdjustPool(ctx context.Context, adjustment dicedomain.PoolAdjustment) (dicedomain.PoolAdjustment, dicedomain.Config, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.PoolAdjustment{}, dicedomain.Config{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
adjustment.AppCode = appcode.Normalize(adjustment.AppCode)
|
||
ctx = appcode.WithContext(ctx, adjustment.AppCode)
|
||
nowMS := s.now().UnixMilli()
|
||
adjustment.CreatedAtMS = nowMS
|
||
if adjustment.AdjustmentID == "" {
|
||
adjustment.AdjustmentID = "dpool_admin_" + stableHash(fmt.Sprintf("%s|%s|%s|%d|%d|%s", adjustment.AppCode, adjustment.GameID, adjustment.Direction, adjustment.AmountCoin, adjustment.CreatedBy, adjustment.Reason))[:20]
|
||
}
|
||
saved, err := s.repository.AdjustDicePool(ctx, adjustment)
|
||
if err != nil {
|
||
return dicedomain.PoolAdjustment{}, dicedomain.Config{}, 0, err
|
||
}
|
||
config, err := s.configForMatch(ctx, adjustment.AppCode, adjustment.GameID)
|
||
return saved, config, nowMS, err
|
||
}
|
||
|
||
func (s *Service) ListRobots(ctx context.Context, appCode string, gameID string, status string, pageSize int32, cursor string) ([]dicedomain.Robot, string, int64, error) {
|
||
if s.repository == nil {
|
||
return nil, "", 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
app := appcode.Normalize(appCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
items, next, err := s.repository.ListDiceRobots(ctx, app, gameID, status, pageSize, cursor)
|
||
return items, next, s.now().UnixMilli(), err
|
||
}
|
||
|
||
// ListExploreWinners 给 Explore Square 提供真实获胜播报列表;读接口只读已有结算事实,不生成默认 seed 数据。
|
||
func (s *Service) ListExploreWinners(ctx context.Context, appCode string, pageSize int32) ([]dicedomain.ExploreWinner, int64, error) {
|
||
if s.repository == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
app := appcode.Normalize(appCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
if pageSize <= 0 {
|
||
pageSize = dicedomain.DefaultExploreWinnerPageSize
|
||
}
|
||
if pageSize > 50 {
|
||
pageSize = 50
|
||
}
|
||
items, err := s.repository.ListExploreWinners(ctx, app, pageSize)
|
||
return items, nowMS, err
|
||
}
|
||
|
||
func (s *Service) RegisterRobots(ctx context.Context, appCode string, gameID string, userIDs []int64, adminID int64) ([]dicedomain.Robot, int64, error) {
|
||
if s.repository == nil {
|
||
return nil, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
app := appcode.Normalize(appCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
items, err := s.repository.RegisterDiceRobots(ctx, app, gameID, userIDs, adminID, nowMS)
|
||
return items, nowMS, err
|
||
}
|
||
|
||
func (s *Service) SetRobotStatus(ctx context.Context, appCode string, gameID string, userID int64, status string) (dicedomain.Robot, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Robot{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
app := appcode.Normalize(appCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
item, err := s.repository.SetDiceRobotStatus(ctx, app, gameID, userID, status, nowMS)
|
||
return item, nowMS, err
|
||
}
|
||
|
||
// DeleteRobot 删除当前 app 和游戏下的机器人登记,真实用户资料仍由 user-service 保留用于历史数据追溯。
|
||
func (s *Service) DeleteRobot(ctx context.Context, appCode string, gameID string, userID int64) (int64, error) {
|
||
if s.repository == nil {
|
||
return 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
if userID <= 0 {
|
||
return 0, xerr.New(xerr.InvalidArgument, "dice robot user id is invalid")
|
||
}
|
||
app := appcode.Normalize(appCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
err := s.repository.DeleteDiceRobot(ctx, app, gameID, userID)
|
||
return nowMS, err
|
||
}
|
||
|
||
// Match 按全服 app_code + game_id + stake_coin 匹配;没有等待真人时创建等待局,不提前扣金币。
|
||
func (s *Service) Match(ctx context.Context, command MatchCommand) (dicedomain.Match, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
if s.wallet == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
if command.UserID <= 0 || strings.TrimSpace(command.GameID) == "" || command.StakeCoin <= 0 {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice match command is incomplete")
|
||
}
|
||
app := appcode.Normalize(command.AppCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
config, err := s.configForMatch(ctx, app, strings.TrimSpace(command.GameID))
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if config.Status != dicedomain.ConfigStatusActive {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice game config is disabled")
|
||
}
|
||
if !dicedomain.StakeEnabled(config, command.StakeCoin) {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice stake_coin is not configured")
|
||
}
|
||
rpsGesture, err := normalizeRPSGestureForGame(config.GameID, command.RPSGesture, true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if err := s.ensureCoinBalance(ctx, command.RequestID, app, command.UserID, command.StakeCoin); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
nowMS := s.now().UnixMilli()
|
||
match, err := s.repository.FindAndJoinWaitingDiceMatch(ctx, app, config.GameID, command.UserID, command.StakeCoin, rpsGesture, nowMS)
|
||
if err == nil {
|
||
return s.attachRobotIfDue(ctx, command.RequestID, match, nowMS)
|
||
}
|
||
if !xerr.IsCode(err, xerr.NotFound) {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
// 没有可加入的真人局时创建新局;room_id 仅落字段预留,首版全服匹配强制不按房间隔离。
|
||
return s.CreateMatch(ctx, CreateMatchCommand{
|
||
AppCode: app,
|
||
RequestID: command.RequestID,
|
||
UserID: command.UserID,
|
||
GameID: config.GameID,
|
||
RoomID: "",
|
||
RegionID: command.RegionID,
|
||
StakeCoin: command.StakeCoin,
|
||
MinPlayers: config.MinPlayers,
|
||
MaxPlayers: config.MaxPlayers,
|
||
RPSGesture: rpsGesture,
|
||
})
|
||
}
|
||
|
||
// JoinMatch 在 match 行锁内分配座位;钱包不在这里扣款,避免未开局匹配占用用户余额。
|
||
func (s *Service) JoinMatch(ctx context.Context, command JoinMatchCommand) (dicedomain.Match, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice join command is incomplete")
|
||
}
|
||
app := appcode.Normalize(command.AppCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
matchForJoin, err := s.expectedMatch(ctx, app, command.MatchID, command.GameID)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
rpsGesture, err := normalizeRPSGestureForGame(matchForJoin.GameID, command.RPSGesture, true)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if err := validateRPSParticipantGesture(matchForJoin, command.UserID, rpsGesture); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
// 加入只修改参与者事实;如果用户重复点击 join,仓储按唯一参与者返回已有局快照。
|
||
match, err := s.repository.JoinDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, rpsGesture, nowMS)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
return match, nowMS, nil
|
||
}
|
||
|
||
// GetMatch 只读取 MySQL 当前事实;如果后续加 Redis,也只能作为这个结果的旁路缓存。
|
||
func (s *Service) GetMatch(ctx context.Context, command GetMatchCommand) (dicedomain.Match, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice get command is incomplete")
|
||
}
|
||
app := appcode.Normalize(command.AppCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
match, err := s.repository.GetDiceMatch(ctx, app, strings.TrimSpace(command.MatchID))
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if err := expectedGameIDMatches(command.GameID, match); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
return s.attachRobotIfDue(ctx, "", match, nowMS)
|
||
}
|
||
|
||
// CancelMatch 只允许等待中的创建人撤销局;没有扣款,因此取消不会触发钱包补偿。
|
||
func (s *Service) CancelMatch(ctx context.Context, command CancelMatchCommand) (dicedomain.Match, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice cancel command is incomplete")
|
||
}
|
||
app := appcode.Normalize(command.AppCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
if err := s.ensureMatchGame(ctx, app, command.MatchID, command.GameID); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
match, err := s.repository.CancelDiceMatch(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
|
||
return match, nowMS, err
|
||
}
|
||
|
||
// RollMatch 先用 MySQL 抢占结算权,再串行调用钱包;钱包幂等键保证重试不会重复扣款或派奖。
|
||
func (s *Service) RollMatch(ctx context.Context, command RollMatchCommand) (dicedomain.Match, int64, error) {
|
||
if s.repository == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "game repository is not configured")
|
||
}
|
||
if s.wallet == nil {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
|
||
}
|
||
if command.UserID <= 0 || strings.TrimSpace(command.MatchID) == "" {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.InvalidArgument, "dice roll command is incomplete")
|
||
}
|
||
app := appcode.Normalize(command.AppCode)
|
||
ctx = appcode.WithContext(ctx, app)
|
||
nowMS := s.now().UnixMilli()
|
||
matchForRoll, err := s.expectedMatch(ctx, app, command.MatchID, command.GameID)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
rpsGesture, err := normalizeRPSGestureForGame(matchForRoll.GameID, command.RPSGesture, false)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if err := validateOptionalRPSRollGesture(matchForRoll, command.UserID, rpsGesture); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
|
||
// ClaimDiceMatchForRoll 用 match 行锁把局切到 settling;锁提交后才调用钱包,减少数据库锁持有时间。
|
||
match, err := s.repository.ClaimDiceMatchForRoll(ctx, app, strings.TrimSpace(command.MatchID), command.UserID, nowMS)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if match.Status == dicedomain.MatchStatusSettled {
|
||
// roll 重试命中已结算局时直接返回结果,客户端可以安全重复请求。
|
||
return match, nowMS, nil
|
||
}
|
||
if len(match.Participants) < int(match.MinPlayers) {
|
||
return dicedomain.Match{}, 0, xerr.New(xerr.Conflict, "dice match does not have enough participants")
|
||
}
|
||
if err := validateOptionalRPSRollGesture(match, command.UserID, rpsGesture); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
normalizeMatchEconomicSnapshot(&match)
|
||
|
||
for index := range match.Participants {
|
||
if match.Participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {
|
||
// 机器人是真实 App 用户资料,但对局资金来自共享奖池;不能对机器人用户钱包扣本金。
|
||
match.Participants[index].Status = dicedomain.ParticipantStatusDebitSucceeded
|
||
continue
|
||
}
|
||
if alreadyDebited(match.Participants[index].Status) {
|
||
// 补偿或重试进入这里时,已扣款/已派奖/已退款的参与者不能再次扣本金。
|
||
continue
|
||
}
|
||
// 每个参与者独立生成 provider_order_id;wallet-service 用 command_id/request_hash 做最终账务幂等。
|
||
order, balanceAfter, err := s.applyGameCoinChange(ctx, command.RequestID, match, match.Participants[index], "debit", match.Participants[index].StakeCoin)
|
||
if err != nil {
|
||
// 任一扣款失败,本局不能继续开奖;已扣成功的参与者尽量立即退款,失败细节留在 game_orders 和 match failed 状态里。
|
||
_ = s.repository.MarkDiceParticipantDebitFailed(ctx, app, match.MatchID, match.Participants[index].UserID, order.OrderID, nowMS)
|
||
_ = s.refundDebitedParticipants(ctx, command.RequestID, match, nowMS)
|
||
_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "debit_failed", nowMS)
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
match.Participants[index].Status = dicedomain.ParticipantStatusDebitSucceeded
|
||
match.Participants[index].DebitOrderID = order.OrderID
|
||
match.Participants[index].BalanceAfter = balanceAfter
|
||
if err := s.repository.MarkDiceParticipantDebitSucceeded(ctx, app, match.MatchID, match.Participants[index].UserID, order.OrderID, balanceAfter, nowMS); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
}
|
||
|
||
if dicedomain.IsRPSGameID(match.GameID) {
|
||
if !dicedomain.HasRolls(match.Participants) {
|
||
// rock 的胜负只来自 match/create/join 阶段已经保存的 rps_gesture;roll 阶段只负责扣款后把固定结果落库。
|
||
settledParticipants, result, err := dicedomain.SettleRPSByGestureWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
match.Participants = settledParticipants
|
||
match.Result = result
|
||
match, err = s.repository.SaveDiceRolls(ctx, match, nowMS)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
} else if match.Result == "" {
|
||
// 兼容 rps 结果点数已写入但 match.result 空的半完成状态,仍按手势重新推导同一份派奖/退款金额。
|
||
settledParticipants, result, err := dicedomain.SettleRPSByGestureWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
match.Participants = settledParticipants
|
||
match.Result = result
|
||
}
|
||
} else if !dicedomain.HasRolls(match.Participants) {
|
||
// 只有首次结算可以随机点数;点数一旦落库,所有后续重试必须复用旧点数,避免改变输赢结果。
|
||
if err := s.rollParticipants(match.Participants); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if match.ForcedResult == dicedomain.ForcedResultPlayerLose {
|
||
// 奖池不足的机器人局在匹配完成时已经冻结强制结果;roll 阶段只负责生成与结果一致的点数。
|
||
s.forceHumanLose(match.Participants)
|
||
}
|
||
settledParticipants, result, err := dicedomain.SettleByHighestScoreWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if result == dicedomain.ParticipantResultDraw {
|
||
// 和局不退本金也不派奖;保存本轮点数给 H5 展示,局保持 ready,2 秒后客户端继续调用 roll。
|
||
match.Participants = settledParticipants
|
||
match.Result = result
|
||
saved, err := s.repository.SaveDiceDrawForReroll(ctx, match, nowMS)
|
||
return saved, nowMS, err
|
||
}
|
||
// SaveDiceRolls 原子写入每个参与者点数、结果和 payout_coin,同时把局推进到 payout_applying。
|
||
match.Participants = settledParticipants
|
||
match.Result = result
|
||
match, err = s.repository.SaveDiceRolls(ctx, match, nowMS)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
} else if match.Result == "" {
|
||
// 兼容“点数已落库但 match.result 未写入”的半完成状态,按旧点数重新推导派彩金额。
|
||
settledParticipants, result, err := dicedomain.SettleByHighestScoreWithPool(match.Participants, int64(match.FeeBPS), int64(match.PoolBPS))
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
match.Participants = settledParticipants
|
||
match.Result = result
|
||
}
|
||
|
||
for index := range match.Participants {
|
||
participant := match.Participants[index]
|
||
if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
|
||
// 机器人输赢只影响奖池,不走用户钱包 credit/refund。
|
||
continue
|
||
}
|
||
if participant.PayoutCoin <= 0 || participant.Status == dicedomain.ParticipantStatusPayoutSucceeded || participant.Status == dicedomain.ParticipantStatusRefundSucceeded || participant.Status == dicedomain.ParticipantStatusSettled {
|
||
// 输家 payout_coin 为 0;已处理过的赢家或和局用户不重复调用钱包。
|
||
continue
|
||
}
|
||
opType := "credit"
|
||
if participant.Result == dicedomain.ParticipantResultDraw {
|
||
// 和局走 refund 而不是 credit,账务语义上明确这是返还本金,不算中奖派奖。
|
||
opType = "refund"
|
||
}
|
||
order, balanceAfter, err := s.applyGameCoinChange(ctx, command.RequestID, match, participant, opType, participant.PayoutCoin)
|
||
if err != nil {
|
||
// 点数已经固定后派奖失败不能重投,只标记 failed 交给后续补偿继续按同一 provider_order_id 处理。
|
||
_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "payout_failed", nowMS)
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
match.Participants[index].BalanceAfter = balanceAfter
|
||
if opType == "refund" {
|
||
match.Participants[index].Status = dicedomain.ParticipantStatusRefundSucceeded
|
||
if err := s.repository.MarkDiceParticipantRefundSucceeded(ctx, app, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
continue
|
||
}
|
||
match.Participants[index].Status = dicedomain.ParticipantStatusPayoutSucceeded
|
||
if err := s.repository.MarkDiceParticipantPayoutSucceeded(ctx, app, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
}
|
||
|
||
if err := s.applyPoolDelta(ctx, command.RequestID, match, nowMS); err != nil {
|
||
// 钱包账务已经按固定点数推进后,奖池流水失败不能重投;把局留在 failed 便于人工按 match_id 对账补偿。
|
||
_ = s.repository.MarkDiceMatchFailed(ctx, app, match.MatchID, "pool_failed", nowMS)
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
|
||
// 所有应付账务都成功后才把 match 和 participants 统一标记 settled,客户端看到 settled 即可认为结果不可变。
|
||
if err := s.repository.MarkDiceMatchSettled(ctx, app, match.MatchID, match.Result, nowMS); err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
// Square 播报是结算后的附属展示事实,只记录真人赢家且用 match_id+user_id 做幂等键;写失败不能让已经完成的钱包结算回滚成失败局。
|
||
_ = s.recordExploreWinners(ctx, match, nowMS)
|
||
saved, err := s.repository.GetDiceMatch(ctx, app, match.MatchID)
|
||
return saved, nowMS, err
|
||
}
|
||
|
||
func (s *Service) recordExploreWinners(ctx context.Context, match dicedomain.Match, nowMS int64) error {
|
||
gameCode := exploreGameCodeForMatch(match.GameID)
|
||
if gameCode == "" {
|
||
return nil
|
||
}
|
||
for _, participant := range match.Participants {
|
||
if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
|
||
// 机器人只是匹配补位和奖池对手,不应该出现在 Explore Square 的“用户获胜”播报里。
|
||
continue
|
||
}
|
||
if participant.Result != dicedomain.ParticipantResultWin || participant.PayoutCoin <= 0 {
|
||
// 输家、和局和没有派奖金额的半完成状态都不能生成获胜播报,避免客户端展示假的中奖金额。
|
||
continue
|
||
}
|
||
winner := dicedomain.ExploreWinner{
|
||
AppCode: match.AppCode,
|
||
WinID: fmt.Sprintf("self_%s_%d", match.MatchID, participant.UserID),
|
||
GameCode: gameCode,
|
||
GameID: strings.TrimSpace(match.GameID),
|
||
UserID: participant.UserID,
|
||
CoinAmount: participant.PayoutCoin,
|
||
WonAtMS: nowMS,
|
||
CreatedAtMS: nowMS,
|
||
UpdatedAtMS: nowMS,
|
||
}
|
||
if err := s.repository.RecordExploreWinner(ctx, winner); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func exploreGameCodeForMatch(gameID string) string {
|
||
switch strings.TrimSpace(gameID) {
|
||
case "", dicedomain.DefaultGameID:
|
||
return dicedomain.ExploreGameCodeDice
|
||
case dicedomain.SelfGameIDRock:
|
||
return dicedomain.ExploreGameCodeRock
|
||
default:
|
||
return ""
|
||
}
|
||
}
|
||
|
||
func (s *Service) ensureMatchGame(ctx context.Context, appCode string, matchID string, expectedGameID string) error {
|
||
_, err := s.expectedMatch(ctx, appCode, matchID, expectedGameID)
|
||
return err
|
||
}
|
||
|
||
func (s *Service) expectedMatch(ctx context.Context, appCode string, matchID string, expectedGameID string) (dicedomain.Match, error) {
|
||
expectedGameID = strings.TrimSpace(expectedGameID)
|
||
if expectedGameID == "" {
|
||
return s.repository.GetDiceMatch(ctx, appCode, strings.TrimSpace(matchID))
|
||
}
|
||
match, err := s.repository.GetDiceMatch(ctx, appCode, strings.TrimSpace(matchID))
|
||
if err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
if err := expectedGameIDMatches(expectedGameID, match); err != nil {
|
||
return dicedomain.Match{}, err
|
||
}
|
||
return match, nil
|
||
}
|
||
|
||
func expectedGameIDMatches(expectedGameID string, match dicedomain.Match) error {
|
||
expectedGameID = strings.TrimSpace(expectedGameID)
|
||
if expectedGameID == "" {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(match.GameID) == expectedGameID {
|
||
return nil
|
||
}
|
||
// dice 和 rps 共用自研 match 表,HTTP 路径必须用 game_id 再切一刀;返回 not found,避免向客户端泄露其它游戏局存在。
|
||
return xerr.New(xerr.NotFound, "self game match not found")
|
||
}
|
||
|
||
func normalizeRPSGestureForGame(gameID string, rawGesture string, required bool) (string, error) {
|
||
if !dicedomain.IsRPSGameID(gameID) {
|
||
return "", nil
|
||
}
|
||
if strings.TrimSpace(rawGesture) == "" && !required {
|
||
return "", nil
|
||
}
|
||
gesture, ok := dicedomain.NormalizeRPSGesture(rawGesture)
|
||
if !ok {
|
||
return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
|
||
}
|
||
return gesture, nil
|
||
}
|
||
|
||
func validateRPSParticipantGesture(match dicedomain.Match, userID int64, gesture string) error {
|
||
if !dicedomain.IsRPSGameID(match.GameID) {
|
||
return nil
|
||
}
|
||
if strings.TrimSpace(gesture) == "" {
|
||
return xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
|
||
}
|
||
for _, participant := range match.Participants {
|
||
if participant.UserID != userID {
|
||
continue
|
||
}
|
||
selected, ok := dicedomain.NormalizeRPSGesture(participant.RPSGesture)
|
||
if !ok {
|
||
return xerr.New(xerr.Conflict, "rps selected gesture is missing")
|
||
}
|
||
if selected != gesture {
|
||
// 同一个用户重复 join/roll 只能确认原手势,不能通过重试把已经排队的拳换掉。
|
||
return xerr.New(xerr.Conflict, "rps gesture does not match selected gesture")
|
||
}
|
||
return nil
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func validateOptionalRPSRollGesture(match dicedomain.Match, userID int64, gesture string) error {
|
||
if !dicedomain.IsRPSGameID(match.GameID) || strings.TrimSpace(gesture) == "" {
|
||
return nil
|
||
}
|
||
return validateRPSParticipantGesture(match, userID, gesture)
|
||
}
|
||
|
||
func (s *Service) configForMatch(ctx context.Context, appCode string, gameID string) (dicedomain.Config, error) {
|
||
config, err := s.repository.GetDiceConfig(ctx, appCode, strings.TrimSpace(gameID))
|
||
if err == nil {
|
||
return config, nil
|
||
}
|
||
if !xerr.IsCode(err, xerr.NotFound) {
|
||
return dicedomain.Config{}, err
|
||
}
|
||
// 没有后台配置时把默认配置写成 MySQL 事实,再重新读取;这样 H5、大厅后台和机器人奖池判断都使用同一份配置/奖池快照。
|
||
normalized, err := dicedomain.NormalizeConfig(dicedomain.Config{
|
||
AppCode: appcode.Normalize(appCode),
|
||
GameID: strings.TrimSpace(gameID),
|
||
Status: dicedomain.ConfigStatusActive,
|
||
FeeBPS: int32(dicedomain.DefaultFeeBPS),
|
||
PoolBPS: int32(dicedomain.DefaultPoolBPS),
|
||
MinPlayers: dicedomain.DefaultMinPlayers,
|
||
MaxPlayers: dicedomain.DefaultMaxPlayers,
|
||
RobotEnabled: true,
|
||
RobotMatchWaitMS: dicedomain.DefaultRobotWaitMS,
|
||
})
|
||
if err != nil {
|
||
return dicedomain.Config{}, err
|
||
}
|
||
return s.repository.UpsertDiceConfig(ctx, normalized, s.now().UnixMilli())
|
||
}
|
||
|
||
func (s *Service) ensureCoinBalance(ctx context.Context, requestID string, appCode string, userID int64, stakeCoin int64) error {
|
||
resp, err := s.wallet.GetBalances(ctx, &walletv1.GetBalancesRequest{
|
||
RequestId: strings.TrimSpace(requestID),
|
||
UserId: userID,
|
||
AppCode: appCode,
|
||
AssetTypes: []string{"COIN"},
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, balance := range resp.GetBalances() {
|
||
if strings.EqualFold(balance.GetAssetType(), "COIN") && balance.GetAvailableAmount() >= stakeCoin {
|
||
return nil
|
||
}
|
||
}
|
||
return xerr.New(xerr.InsufficientBalance, "coin balance is insufficient")
|
||
}
|
||
|
||
func (s *Service) attachRobotIfDue(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) (dicedomain.Match, int64, error) {
|
||
if match.Status != dicedomain.MatchStatusCreated && match.Status != dicedomain.MatchStatusJoining {
|
||
return match, nowMS, nil
|
||
}
|
||
if match.CurrentPlayers >= match.MinPlayers {
|
||
return match, nowMS, nil
|
||
}
|
||
config, err := s.configForMatch(ctx, match.AppCode, match.GameID)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
if !config.RobotEnabled || nowMS < match.CreatedAtMS+config.RobotMatchWaitMS {
|
||
return match, nowMS, nil
|
||
}
|
||
robot, err := s.repository.PickActiveDiceRobot(ctx, match.AppCode, match.GameID, match.MatchID)
|
||
if err != nil {
|
||
if xerr.IsCode(err, xerr.NotFound) {
|
||
return match, nowMS, nil
|
||
}
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
forcedResult := ""
|
||
requiredPool := robotRequiredPoolCoin(match)
|
||
if config.PoolBalanceCoin < requiredPool {
|
||
forcedResult = dicedomain.ForcedResultPlayerLose
|
||
}
|
||
robotRPSGesture := ""
|
||
if dicedomain.IsRPSGameID(match.GameID) {
|
||
robotRPSGesture, err = s.robotRPSGestureForMatch(match, forcedResult)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
}
|
||
joined, err := s.repository.JoinDiceRobot(ctx, match.AppCode, match.MatchID, robot.UserID, forcedResult, robotRPSGesture, nowMS)
|
||
if err != nil {
|
||
return dicedomain.Match{}, 0, err
|
||
}
|
||
return joined, nowMS, nil
|
||
}
|
||
|
||
func (s *Service) robotRPSGestureForMatch(match dicedomain.Match, forcedResult string) (string, error) {
|
||
var humanGesture string
|
||
for _, participant := range match.Participants {
|
||
if participant.ParticipantType == dicedomain.ParticipantTypeRobot {
|
||
continue
|
||
}
|
||
gesture, ok := dicedomain.NormalizeRPSGesture(participant.RPSGesture)
|
||
if !ok {
|
||
return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
|
||
}
|
||
humanGesture = gesture
|
||
break
|
||
}
|
||
if humanGesture == "" {
|
||
return "", xerr.New(xerr.Conflict, "rps match does not have human participant")
|
||
}
|
||
if forcedResult == dicedomain.ForcedResultPlayerLose {
|
||
// 奖池不足时不能随机出玩家赢的结果;机器人只选择能击败真人预选手势的那一拳。
|
||
gesture, ok := dicedomain.RPSWinningGestureFor(humanGesture)
|
||
if !ok {
|
||
return "", xerr.New(xerr.InvalidArgument, "rps gesture is invalid")
|
||
}
|
||
return gesture, nil
|
||
}
|
||
index, err := s.randomInt(3)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
gestures := []string{dicedomain.RPSGestureRock, dicedomain.RPSGesturePaper, dicedomain.RPSGestureScissors}
|
||
if index < 0 || index >= len(gestures) {
|
||
return "", xerr.New(xerr.InvalidArgument, "random rps gesture is invalid")
|
||
}
|
||
return gestures[index], nil
|
||
}
|
||
|
||
func (s *Service) forceHumanLose(participants []dicedomain.Participant) {
|
||
for index := range participants {
|
||
if participants[index].ParticipantType == dicedomain.ParticipantTypeRobot {
|
||
// 奖池不足时仍然落真实单骰格式;机器人最大点数、真人最小点数,保证结算规则自然判定玩家输。
|
||
participants[index].DicePoints = []int32{6}
|
||
continue
|
||
}
|
||
participants[index].DicePoints = []int32{1}
|
||
}
|
||
}
|
||
|
||
func (s *Service) applyPoolDelta(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) error {
|
||
direction, amount, userID := dicePoolDelta(match)
|
||
if amount <= 0 {
|
||
return nil
|
||
}
|
||
_, err := s.repository.AdjustDicePool(ctx, dicedomain.PoolAdjustment{
|
||
AdjustmentID: "dpool_" + stableHash(fmt.Sprintf("%s|%s|%s|%d|%s", match.AppCode, match.MatchID, direction, amount, requestID))[:24],
|
||
AppCode: match.AppCode,
|
||
GameID: match.GameID,
|
||
MatchID: match.MatchID,
|
||
UserID: userID,
|
||
Direction: direction,
|
||
AmountCoin: amount,
|
||
Reason: "dice_settlement",
|
||
CreatedAtMS: nowMS,
|
||
})
|
||
return err
|
||
}
|
||
|
||
func normalizeMatchEconomicSnapshot(match *dicedomain.Match) {
|
||
if match.FeeBPS == 0 {
|
||
match.FeeBPS = int32(dicedomain.DefaultFeeBPS)
|
||
}
|
||
if match.PoolBPS == 0 {
|
||
match.PoolBPS = int32(dicedomain.DefaultPoolBPS)
|
||
}
|
||
for index := range match.Participants {
|
||
if strings.TrimSpace(match.Participants[index].ParticipantType) == "" {
|
||
match.Participants[index].ParticipantType = dicedomain.ParticipantTypeUser
|
||
}
|
||
}
|
||
}
|
||
|
||
func robotRequiredPoolCoin(match dicedomain.Match) int64 {
|
||
normalizeMatchEconomicSnapshot(&match)
|
||
robotNetStake := match.StakeCoin * (10_000 - int64(match.FeeBPS) - int64(match.PoolBPS)) / 10_000
|
||
if robotNetStake < 0 {
|
||
return 0
|
||
}
|
||
return robotNetStake
|
||
}
|
||
|
||
func dicePoolDelta(match dicedomain.Match) (string, int64, int64) {
|
||
normalizeMatchEconomicSnapshot(&match)
|
||
var totalStake int64
|
||
var firstHuman dicedomain.Participant
|
||
for _, participant := range match.Participants {
|
||
totalStake += participant.StakeCoin
|
||
if participant.ParticipantType != dicedomain.ParticipantTypeRobot && firstHuman.UserID == 0 {
|
||
firstHuman = participant
|
||
}
|
||
}
|
||
if match.Result == dicedomain.ParticipantResultDraw {
|
||
return "", 0, firstHuman.UserID
|
||
}
|
||
if match.MatchMode != dicedomain.MatchModeRobot {
|
||
amount := totalStake * int64(match.PoolBPS) / 10_000
|
||
return dicedomain.PoolDirectionIn, amount, firstHuman.UserID
|
||
}
|
||
if firstHuman.Result == dicedomain.ParticipantResultWin {
|
||
netOut := firstHuman.PayoutCoin - firstHuman.StakeCoin
|
||
if netOut <= 0 {
|
||
return "", 0, firstHuman.UserID
|
||
}
|
||
return dicedomain.PoolDirectionOut, netOut, firstHuman.UserID
|
||
}
|
||
if firstHuman.Result == dicedomain.ParticipantResultLose {
|
||
amount := firstHuman.StakeCoin * (10_000 - int64(match.FeeBPS)) / 10_000
|
||
return dicedomain.PoolDirectionIn, amount, firstHuman.UserID
|
||
}
|
||
return "", 0, firstHuman.UserID
|
||
}
|
||
|
||
func (s *Service) rollParticipants(participants []dicedomain.Participant) error {
|
||
for index := range participants {
|
||
points := make([]int32, 0, dicedomain.PointsPerParticipant)
|
||
for roll := 0; roll < dicedomain.PointsPerParticipant; roll++ {
|
||
// crypto/rand 返回 0-5,这里加 1 映射成真实骰子点数 1-6。
|
||
value, err := s.randomInt(6)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
points = append(points, int32(value+1))
|
||
}
|
||
participants[index].DicePoints = points
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) refundDebitedParticipants(ctx context.Context, requestID string, match dicedomain.Match, nowMS int64) error {
|
||
var firstErr error
|
||
for _, participant := range match.Participants {
|
||
if !alreadyDebited(participant.Status) {
|
||
continue
|
||
}
|
||
// 扣款阶段失败时的退款同样走 game_orders + wallet 幂等,重复补偿不会多退。
|
||
order, balanceAfter, err := s.applyGameCoinChange(ctx, requestID, match, participant, "refund", participant.StakeCoin)
|
||
if err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
continue
|
||
}
|
||
if err := s.repository.MarkDiceParticipantRefundSucceeded(ctx, match.AppCode, match.MatchID, participant.UserID, order.OrderID, balanceAfter, nowMS); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
return firstErr
|
||
}
|
||
|
||
func (s *Service) applyGameCoinChange(ctx context.Context, requestID string, match dicedomain.Match, participant dicedomain.Participant, opType string, amount int64) (gamedomain.GameOrder, int64, error) {
|
||
if amount <= 0 {
|
||
return gamedomain.GameOrder{}, participant.BalanceAfter, xerr.New(xerr.InvalidArgument, "coin_amount must be positive")
|
||
}
|
||
// provider_order_id 按 match/user/op 固定,确保同一参与者同一动作在 game_orders 和 wallet-service 两边都可幂等重放。
|
||
providerOrderID := diceProviderOrderID(match.MatchID, participant.UserID, opType)
|
||
// request_hash 覆盖会影响账务结果的字段;同一个 provider_order_id 携带不同金额或方向会被订单层拒绝。
|
||
requestHash := stableHash(fmt.Sprintf("%s|%s|%s|%d|%d|%d", match.AppCode, match.MatchID, opType, participant.UserID, amount, match.RoundNo))
|
||
order := gamedomain.GameOrder{
|
||
AppCode: match.AppCode,
|
||
OrderID: "gord_" + stableHash(match.AppCode+"|"+match.PlatformCode+"|"+providerOrderID),
|
||
PlatformCode: match.PlatformCode,
|
||
ProviderOrderID: providerOrderID,
|
||
ProviderRoundID: match.MatchID,
|
||
GameID: match.GameID,
|
||
ProviderGameID: match.ProviderGameID,
|
||
UserID: participant.UserID,
|
||
RoomID: match.RoomID,
|
||
RegionID: match.RegionID,
|
||
OpType: opType,
|
||
CoinAmount: amount,
|
||
Status: gamedomain.OrderStatusWalletApplying,
|
||
RequestHash: requestHash,
|
||
}
|
||
order, exists, err := s.repository.CreateGameOrder(ctx, order)
|
||
if err != nil {
|
||
return gamedomain.GameOrder{}, 0, err
|
||
}
|
||
if exists && order.Status == gamedomain.OrderStatusSucceeded {
|
||
// game_orders 已成功说明 wallet 改账和 outbox 都已经落库;直接返回旧账后余额,避免再次触碰钱包。
|
||
return order, order.WalletBalanceAfter, nil
|
||
}
|
||
|
||
// 钱包是 COIN owner;game-service 只提交游戏改账命令,余额版本、资产账户和账本分录都由 wallet-service 保证。
|
||
walletResp, err := s.wallet.ApplyGameCoinChange(ctx, &walletv1.ApplyGameCoinChangeRequest{
|
||
RequestId: strings.TrimSpace(requestID),
|
||
AppCode: match.AppCode,
|
||
CommandId: "game:" + providerOrderID,
|
||
UserId: participant.UserID,
|
||
PlatformCode: match.PlatformCode,
|
||
GameId: match.GameID,
|
||
ProviderOrderId: providerOrderID,
|
||
ProviderRoundId: match.MatchID,
|
||
OpType: opType,
|
||
CoinAmount: amount,
|
||
RoomId: match.RoomID,
|
||
RequestHash: requestHash,
|
||
})
|
||
nowMS := s.now().UnixMilli()
|
||
if err != nil {
|
||
status := gamedomain.OrderStatusFailed
|
||
if xerr.IsCode(err, xerr.IdempotencyConflict) {
|
||
// 钱包侧幂等冲突代表 command_id 重放但 payload 不一致,订单状态必须标 conflict 便于排查。
|
||
status = gamedomain.OrderStatusConflict
|
||
}
|
||
_ = s.repository.MarkOrderFailed(ctx, match.AppCode, order.OrderID, status, walletFailureCode(err), err.Error(), nowMS)
|
||
return order, 0, err
|
||
}
|
||
// MarkOrderSucceeded 会同步写 game_outbox,并且 debit 订单额外写 game_level_event_outbox。
|
||
if err := s.repository.MarkOrderSucceeded(ctx, match.AppCode, order.OrderID, walletResp.GetWalletTransactionId(), walletResp.GetBalanceAfter(), nowMS); err != nil {
|
||
return order, 0, err
|
||
}
|
||
order.WalletTransactionID = walletResp.GetWalletTransactionId()
|
||
order.WalletBalanceAfter = walletResp.GetBalanceAfter()
|
||
order.Status = gamedomain.OrderStatusSucceeded
|
||
return order, walletResp.GetBalanceAfter(), nil
|
||
}
|
||
|
||
func alreadyDebited(status string) bool {
|
||
switch status {
|
||
case dicedomain.ParticipantStatusDebitSucceeded, dicedomain.ParticipantStatusPayoutSucceeded, dicedomain.ParticipantStatusRefundSucceeded, dicedomain.ParticipantStatusSettled:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func diceProviderOrderID(matchID string, userID int64, opType string) string {
|
||
return fmt.Sprintf("%s:%s:%d:%s", dicedomain.ProviderOrderPrefix, strings.TrimSpace(matchID), userID, strings.TrimSpace(opType))
|
||
}
|
||
|
||
func stableHash(value string) string {
|
||
sum := sha256.Sum256([]byte(value))
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
func cryptoRandomInt(max int) (int, error) {
|
||
if max <= 0 {
|
||
return 0, xerr.New(xerr.InvalidArgument, "random max is invalid")
|
||
}
|
||
value, err := rand.Int(rand.Reader, big.NewInt(int64(max)))
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return int(value.Int64()), nil
|
||
}
|
||
|
||
func walletFailureCode(err error) string {
|
||
if code := xerr.CodeOf(err); code != "" {
|
||
return string(code)
|
||
}
|
||
return string(xerr.ReasonFromGRPC(err))
|
||
}
|