2026-06-11 18:14:04 +08:00

1154 lines
47 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_idwallet-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_gestureroll 阶段只负责扣款后把固定结果落库。
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 展示,局保持 ready2 秒后客户端继续调用 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 ownergame-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))
}