统计修改

This commit is contained in:
zhx 2026-06-22 14:42:08 +08:00
parent af1f6da657
commit 338fc140d8
29 changed files with 4697 additions and 4496 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1665,6 +1665,8 @@ message LuckyGiftMeta {
int32 gift_count = 12; int32 gift_count = 12;
// visible_region_id room-service IM // visible_region_id room-service IM
int64 visible_region_id = 13; int64 visible_region_id = 13;
// country_id
int64 country_id = 14;
} }
message LuckyGiftTier { message LuckyGiftTier {

File diff suppressed because it is too large Load Diff

View File

@ -140,6 +140,8 @@ message LaunchGameRequest {
// access_token is the same bearer token used by the App request. // access_token is the same bearer token used by the App request.
// Provider adapters pass it through as the game token instead of minting a separate game token. // Provider adapters pass it through as the game token instead of minting a separate game token.
string access_token = 7; string access_token = 7;
// country_id gateway provider
int64 country_id = 8;
} }
message LaunchGameResponse { message LaunchGameResponse {
@ -194,6 +196,8 @@ message DiceMatch {
int64 ready_at_ms = 24; int64 ready_at_ms = 24;
int64 canceled_at_ms = 25; int64 canceled_at_ms = 25;
int64 pool_delta_coin = 26; int64 pool_delta_coin = 26;
// country_id
int64 country_id = 27;
} }
message DiceStakeOption { message DiceStakeOption {
@ -364,6 +368,8 @@ message CreateDiceMatchRequest {
int32 min_players = 7; int32 min_players = 7;
int32 max_players = 8; int32 max_players = 8;
string rps_gesture = 9; string rps_gesture = 9;
// country_id gateway
int64 country_id = 10;
} }
message JoinDiceMatchRequest { message JoinDiceMatchRequest {
@ -397,6 +403,8 @@ message MatchDiceRequest {
int64 region_id = 5; int64 region_id = 5;
int64 stake_coin = 6; int64 stake_coin = 6;
string rps_gesture = 7; string rps_gesture = 7;
// country_id gateway
int64 country_id = 8;
} }
message CancelDiceMatchRequest { message CancelDiceMatchRequest {

File diff suppressed because it is too large Load Diff

View File

@ -1577,6 +1577,8 @@ message CreditLuckyGiftRewardRequest {
string pool_id = 8; string pool_id = 8;
string reason = 9; string reason = 9;
int64 visible_region_id = 10; int64 visible_region_id = 10;
// country_id outbox
int64 country_id = 11;
} }
// CreditLuckyGiftRewardResponse COIN // CreditLuckyGiftRewardResponse COIN

View File

@ -152,6 +152,7 @@ type DrawCommand struct {
CoinSpent int64 CoinSpent int64
PaidAtMS int64 PaidAtMS int64
VisibleRegionID int64 VisibleRegionID int64
CountryID int64
} }
type CheckResult struct { type CheckResult struct {

View File

@ -326,6 +326,7 @@ func (s *Service) creditLuckyGiftRewardFastPath(ctx context.Context, cmd domain.
SenderUserID: cmd.UserID, SenderUserID: cmd.UserID,
TargetUserID: cmd.TargetUserID, TargetUserID: cmd.TargetUserID,
VisibleRegionID: cmd.VisibleRegionID, VisibleRegionID: cmd.VisibleRegionID,
CountryID: cmd.CountryID,
CoinSpent: cmd.CoinSpent, CoinSpent: cmd.CoinSpent,
RuleVersion: result.RuleVersion, RuleVersion: result.RuleVersion,
ExperiencePool: result.ExperiencePool, ExperiencePool: result.ExperiencePool,
@ -395,6 +396,7 @@ func (s *Service) creditLuckyGiftReward(ctx context.Context, appCode string, pay
PoolId: payload.PoolID, PoolId: payload.PoolID,
Reason: "lucky_gift_reward", Reason: "lucky_gift_reward",
VisibleRegionId: payload.VisibleRegionID, VisibleRegionId: payload.VisibleRegionID,
CountryId: payload.CountryID,
}) })
if err != nil { if err != nil {
return luckyGiftRewardReceipt{}, err return luckyGiftRewardReceipt{}, err
@ -545,6 +547,7 @@ type luckyGiftDrawnPayload struct {
SenderUserID int64 `json:"sender_user_id"` SenderUserID int64 `json:"sender_user_id"`
TargetUserID int64 `json:"target_user_id"` TargetUserID int64 `json:"target_user_id"`
VisibleRegionID int64 `json:"visible_region_id"` VisibleRegionID int64 `json:"visible_region_id"`
CountryID int64 `json:"country_id"`
CoinSpent int64 `json:"coin_spent"` CoinSpent int64 `json:"coin_spent"`
RuleVersion int64 `json:"rule_version"` RuleVersion int64 `json:"rule_version"`
ExperiencePool string `json:"experience_pool"` ExperiencePool string `json:"experience_pool"`

View File

@ -72,6 +72,7 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
PoolID: "super_lucky", PoolID: "super_lucky",
UserID: 42, UserID: 42,
TargetUserID: 99, TargetUserID: 99,
CountryID: 15,
DeviceID: "device-1", DeviceID: "device-1",
RoomID: "room-1", RoomID: "room-1",
AnchorID: "99", AnchorID: "99",
@ -83,7 +84,7 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Draw failed: %v", err) t.Fatalf("Draw failed: %v", err)
} }
if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 { if wallet.last == nil || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_fast_1" || wallet.last.GetAmount() != 300 || wallet.last.GetTargetUserId() != 42 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet fast path request mismatch: %+v", wallet.last) t.Fatalf("wallet fast path request mismatch: %+v", wallet.last)
} }
if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 { if result.RewardStatus != domain.StatusGranted || result.WalletTransactionID != "wallet_tx_lucky" || result.CoinBalanceAfter != 1300 {
@ -138,6 +139,7 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
"user_id": 42, "user_id": 42,
"sender_user_id": 42, "sender_user_id": 42,
"target_user_id": 99, "target_user_id": 99,
"country_id": 15,
"visible_region_id": 210, "visible_region_id": 210,
"room_id": "room-1", "room_id": "room-1",
"gift_id": "rose", "gift_id": "rose",
@ -169,7 +171,7 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
if processed != 1 { if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed) t.Fatalf("processed count mismatch: %d", processed)
} }
if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 { if wallet.last == nil || wallet.last.GetDrawId() != "lucky_draw_test" || wallet.last.GetTargetUserId() != 42 || wallet.last.GetAmount() != 300 || wallet.last.GetCountryId() != 15 {
t.Fatalf("wallet reward request mismatch: %+v", wallet.last) t.Fatalf("wallet reward request mismatch: %+v", wallet.last)
} }
if publisher.last.EventID != "" || broadcaster.last.EventID != "" { if publisher.last.EventID != "" || broadcaster.last.EventID != "" {
@ -192,6 +194,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
"user_id": 42, "user_id": 42,
"sender_user_id": 42, "sender_user_id": 42,
"target_user_id": 99, "target_user_id": 99,
"country_id": 15,
"room_id": "room-1", "room_id": "room-1",
"gift_id": "rose", "gift_id": "rose",
"gift_count": 3, "gift_count": 3,
@ -222,7 +225,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
if processed != 1 { if processed != 1 {
t.Fatalf("processed count mismatch: %d", processed) t.Fatalf("processed count mismatch: %d", processed)
} }
if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetVisibleRegionId() != 210 { if wallet.last == nil || wallet.last.GetAmount() != 800 || wallet.last.GetDrawId() != "lucky_draw_batch_1" || wallet.last.GetCommandId() != "lucky_reward:lucky_draw_batch_1" || wallet.last.GetCountryId() != 15 || wallet.last.GetVisibleRegionId() != 210 {
t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last) t.Fatalf("wallet should receive one aggregate reward request: %+v", wallet.last)
} }
if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" { if got := strings.Join(repository.grantedDrawIDs, ","); got != "lucky_draw_batch_1,lucky_draw_batch_2,lucky_draw_batch_3" {

View File

@ -1130,6 +1130,7 @@ func (r *Repository) insertLuckyAggregateRewardSettlementOutbox(ctx context.Cont
"sender_user_id": cmd.UserID, "sender_user_id": cmd.UserID,
"target_user_id": cmd.TargetUserID, "target_user_id": cmd.TargetUserID,
"visible_region_id": cmd.VisibleRegionID, "visible_region_id": cmd.VisibleRegionID,
"country_id": cmd.CountryID,
"room_id": cmd.RoomID, "room_id": cmd.RoomID,
"gift_id": cmd.GiftID, "gift_id": cmd.GiftID,
"gift_count": cmd.GiftCount, "gift_count": cmd.GiftCount,
@ -1568,6 +1569,7 @@ func (r *Repository) applyLuckyDraw(ctx context.Context, tx *sql.Tx, input lucky
"sender_user_id": input.Command.UserID, "sender_user_id": input.Command.UserID,
"target_user_id": input.Command.TargetUserID, "target_user_id": input.Command.TargetUserID,
"visible_region_id": input.Command.VisibleRegionID, "visible_region_id": input.Command.VisibleRegionID,
"country_id": input.Command.CountryID,
"room_id": input.Command.RoomID, "room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID, "gift_id": input.Command.GiftID,
"gift_count": input.Command.GiftCount, "gift_count": input.Command.GiftCount,

View File

@ -60,6 +60,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
CoinSpent: meta.GetCoinSpent(), CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(), PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(), VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(),
}) })
if err != nil { if err != nil {
return nil, xerr.ToGRPCError(err) return nil, xerr.ToGRPCError(err)

View File

@ -69,6 +69,7 @@ CREATE TABLE IF NOT EXISTS game_launch_sessions (
display_user_id VARCHAR(32) NOT NULL COMMENT '展示用户 ID', display_user_id VARCHAR(32) NOT NULL COMMENT '展示用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID', room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的国家 ID',
scene VARCHAR(64) NOT NULL COMMENT '场景', scene VARCHAR(64) NOT NULL COMMENT '场景',
platform_code VARCHAR(64) NOT NULL COMMENT '平台编码', platform_code VARCHAR(64) NOT NULL COMMENT '平台编码',
game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID', game_id VARCHAR(96) NOT NULL COMMENT '游戏 ID',
@ -95,6 +96,7 @@ CREATE TABLE IF NOT EXISTS game_orders (
user_id BIGINT NOT NULL COMMENT '用户 ID', user_id BIGINT NOT NULL COMMENT '用户 ID',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID', room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的区域 ID',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户启动游戏时的国家 ID',
op_type VARCHAR(32) NOT NULL COMMENT 'op类型', op_type VARCHAR(32) NOT NULL COMMENT 'op类型',
coin_amount BIGINT NOT NULL COMMENT '金币数量', coin_amount BIGINT NOT NULL COMMENT '金币数量',
status VARCHAR(32) NOT NULL COMMENT '业务状态', status VARCHAR(32) NOT NULL COMMENT '业务状态',
@ -120,6 +122,7 @@ CREATE TABLE IF NOT EXISTS game_dice_matches (
provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID 快照', provider_game_id VARCHAR(128) NOT NULL COMMENT '提供方游戏 ID 快照',
room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID', room_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '房间 ID',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户区域 ID', region_id BIGINT NOT NULL DEFAULT 0 COMMENT '用户区域 ID',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建局用户国家 ID',
min_players INT NOT NULL COMMENT '最少开局人数', min_players INT NOT NULL COMMENT '最少开局人数',
max_players INT NOT NULL COMMENT '最多参与人数', max_players INT NOT NULL COMMENT '最多参与人数',
current_players INT NOT NULL DEFAULT 0 COMMENT '当前参与人数', current_players INT NOT NULL DEFAULT 0 COMMENT '当前参与人数',

View File

@ -165,6 +165,7 @@ type Match struct {
ProviderGameID string ProviderGameID string
RoomID string RoomID string
RegionID int64 RegionID int64
CountryID int64
MinPlayers int32 MinPlayers int32
MaxPlayers int32 MaxPlayers int32
CurrentPlayers int32 CurrentPlayers int32

View File

@ -117,6 +117,7 @@ type LaunchSession struct {
DisplayUserID string DisplayUserID string
RoomID string RoomID string
RegionID int64 RegionID int64
CountryID int64
Scene string Scene string
PlatformCode string PlatformCode string
GameID string GameID string
@ -143,6 +144,7 @@ type GameOrder struct {
UserID int64 UserID int64
RoomID string RoomID string
RegionID int64 RegionID int64
CountryID int64
// OpType 只允许钱包语义 debit/credit厂商枚举在适配器层转换。 // OpType 只允许钱包语义 debit/credit厂商枚举在适配器层转换。
OpType string OpType string
CoinAmount int64 CoinAmount int64

View File

@ -145,6 +145,7 @@ type CreateMatchCommand struct {
GameID string GameID string
RoomID string RoomID string
RegionID int64 RegionID int64
CountryID int64
StakeCoin int64 StakeCoin int64
MinPlayers int32 MinPlayers int32
MaxPlayers int32 MaxPlayers int32
@ -183,6 +184,7 @@ type MatchCommand struct {
GameID string GameID string
RoomID string RoomID string
RegionID int64 RegionID int64
CountryID int64
StakeCoin int64 StakeCoin int64
RPSGesture string RPSGesture string
} }
@ -291,6 +293,7 @@ func (s *Service) CreateMatch(ctx context.Context, command CreateMatchCommand) (
ProviderGameID: strings.TrimSpace(game.ProviderGameID), ProviderGameID: strings.TrimSpace(game.ProviderGameID),
RoomID: strings.TrimSpace(command.RoomID), RoomID: strings.TrimSpace(command.RoomID),
RegionID: command.RegionID, RegionID: command.RegionID,
CountryID: command.CountryID,
MinPlayers: minPlayers, MinPlayers: minPlayers,
MaxPlayers: maxPlayers, MaxPlayers: maxPlayers,
CurrentPlayers: 1, CurrentPlayers: 1,
@ -650,6 +653,7 @@ func (s *Service) Match(ctx context.Context, command MatchCommand) (dicedomain.M
GameID: config.GameID, GameID: config.GameID,
RoomID: "", RoomID: "",
RegionID: command.RegionID, RegionID: command.RegionID,
CountryID: command.CountryID,
StakeCoin: command.StakeCoin, StakeCoin: command.StakeCoin,
MinPlayers: config.MinPlayers, MinPlayers: config.MinPlayers,
MaxPlayers: config.MaxPlayers, MaxPlayers: config.MaxPlayers,
@ -1569,6 +1573,7 @@ func (s *Service) applyGameCoinChange(ctx context.Context, requestID string, mat
UserID: participant.UserID, UserID: participant.UserID,
RoomID: match.RoomID, RoomID: match.RoomID,
RegionID: match.RegionID, RegionID: match.RegionID,
CountryID: match.CountryID,
OpType: opType, OpType: opType,
CoinAmount: amount, CoinAmount: amount,
Status: gamedomain.OrderStatusWalletApplying, Status: gamedomain.OrderStatusWalletApplying,

View File

@ -130,6 +130,7 @@ type LaunchCommand struct {
Scene string Scene string
RoomID string RoomID string
RegionID int64 RegionID int64
CountryID int64
AccessToken string AccessToken string
} }
@ -275,13 +276,20 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive { if game.PlatformStatus != gamedomain.StatusActive || game.Status != gamedomain.StatusActive {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable") return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
} }
regionID := command.RegionID regionID, countryID := command.RegionID, command.CountryID
if regionID <= 0 { if regionID <= 0 || countryID <= 0 {
var err error var err error
regionID, err = s.resolveUserRegion(ctx, command.AppCode, command.RequestID, command.UserID) // 游戏订单的统计事件只能用启动时的用户维度快照,不能在回调时靠区域桶或当前资料反推国家。
resolvedRegionID, resolvedCountryID, err := s.resolveUserDimensions(ctx, command.AppCode, command.RequestID, command.UserID)
if err != nil { if err != nil {
return LaunchResult{}, err return LaunchResult{}, err
} }
if regionID <= 0 {
regionID = resolvedRegionID
}
if countryID <= 0 {
countryID = resolvedCountryID
}
} }
now := s.now() now := s.now()
@ -316,6 +324,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
DisplayUserID: command.DisplayUserID, DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID), RoomID: strings.TrimSpace(command.RoomID),
RegionID: regionID, RegionID: regionID,
CountryID: countryID,
Scene: command.Scene, Scene: command.Scene,
PlatformCode: game.PlatformCode, PlatformCode: game.PlatformCode,
GameID: game.GameID, GameID: game.GameID,
@ -515,8 +524,15 @@ func (s *Service) getCallbackUserInfo(ctx context.Context, app string, req *game
} }
func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID string, userID int64) (int64, error) { func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID string, userID int64) (int64, error) {
regionID, _, err := s.resolveUserDimensions(ctx, app, requestID, userID)
return regionID, err
}
// resolveUserDimensions 只在入口没有携带完整维度时兜底读取 user-service
// 统计事实随后持久化快照,避免后续用户换国家导致历史订单归因漂移。
func (s *Service) resolveUserDimensions(ctx context.Context, app string, requestID string, userID int64) (int64, int64, error) {
if userID <= 0 || s.user == nil { if userID <= 0 || s.user == nil {
return 0, nil return 0, 0, nil
} }
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{ userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{ Meta: &userv1.RequestMeta{
@ -528,9 +544,10 @@ func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID s
UserId: userID, UserId: userID,
}) })
if err != nil { if err != nil {
return 0, err return 0, 0, err
} }
return userResp.GetUser().GetRegionId(), nil user := userResp.GetUser()
return user.GetRegionId(), user.GetCountryId(), nil
} }
func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) { func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *gamev1.CallbackRequest, requestHash string) (map[string]any, error) {
@ -563,7 +580,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
} }
body.ProviderGameID = game.ProviderGameID body.ProviderGameID = game.ProviderGameID
} }
regionID, err := s.resolveUserRegion(ctx, app, req.GetMeta().GetRequestId(), body.UserID) regionID, countryID, err := s.resolveUserDimensions(ctx, app, req.GetMeta().GetRequestId(), body.UserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -578,6 +595,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
UserID: body.UserID, UserID: body.UserID,
RoomID: strings.TrimSpace(body.RoomID), RoomID: strings.TrimSpace(body.RoomID),
RegionID: regionID, RegionID: regionID,
CountryID: countryID,
OpType: body.OpType, OpType: body.OpType,
CoinAmount: body.CoinAmount, CoinAmount: body.CoinAmount,
Status: gamedomain.OrderStatusWalletApplying, Status: gamedomain.OrderStatusWalletApplying,

View File

@ -309,6 +309,7 @@ func (s *Service) applyYomiCoinChange(ctx context.Context, app string, req *game
UserID: session.UserID, UserID: session.UserID,
RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)), RoomID: strings.TrimSpace(firstNonEmpty(roomID, session.RoomID)),
RegionID: session.RegionID, RegionID: session.RegionID,
CountryID: session.CountryID,
OpType: opType, OpType: opType,
CoinAmount: amount, CoinAmount: amount,
Status: gamedomain.OrderStatusWalletApplying, Status: gamedomain.OrderStatusWalletApplying,

View File

@ -30,12 +30,12 @@ func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match
// match 和首个 participant 必须同事务提交;否则创建成功但参与者缺失会导致后续 roll 永远人数不足。 // match 和首个 participant 必须同事务提交;否则创建成功但参与者缺失会导致后续 roll 永远人数不足。
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO game_dice_matches ( `INSERT INTO game_dice_matches (
app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, country_id,
min_players, max_players, current_players, stake_coin, round_no, status, result, min_players, max_players, current_players, stake_coin, round_no, status, result,
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms, join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID, match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID, match.CountryID,
match.MinPlayers, match.MaxPlayers, match.CurrentPlayers, match.StakeCoin, match.RoundNo, match.Status, match.Result, match.MinPlayers, match.MaxPlayers, match.CurrentPlayers, match.StakeCoin, match.RoundNo, match.Status, match.Result,
match.JoinDeadlineMS, match.ReadyAtMS, match.CreatedAtMS, match.UpdatedAtMS, match.SettledAtMS, match.CanceledAtMS, match.JoinDeadlineMS, match.ReadyAtMS, match.CreatedAtMS, match.UpdatedAtMS, match.SettledAtMS, match.CanceledAtMS,
match.FeeBPS, match.PoolBPS, match.MatchMode, match.ForcedResult, match.PoolDeltaCoin, match.FeeBPS, match.PoolBPS, match.MatchMode, match.ForcedResult, match.PoolDeltaCoin,
@ -548,6 +548,7 @@ func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]
"provider_game_id": strings.TrimSpace(match.ProviderGameID), "provider_game_id": strings.TrimSpace(match.ProviderGameID),
"room_id": strings.TrimSpace(match.RoomID), "room_id": strings.TrimSpace(match.RoomID),
"region_id": match.RegionID, "region_id": match.RegionID,
"country_id": match.CountryID,
"stake_coin": match.StakeCoin, "stake_coin": match.StakeCoin,
"min_players": match.MinPlayers, "min_players": match.MinPlayers,
"max_players": match.MaxPlayers, "max_players": match.MaxPlayers,
@ -584,7 +585,7 @@ func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]
func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) (dicedomain.Match, error) { func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID string, forUpdate bool) (dicedomain.Match, error) {
// forUpdate 只给状态推进路径使用;读路径不加锁,避免普通刷新阻塞 join/roll。 // forUpdate 只给状态推进路径使用;读路径不加锁,避免普通刷新阻塞 join/roll。
query := `SELECT app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, query := `SELECT app_code, match_id, game_id, platform_code, provider_game_id, room_id, region_id, country_id,
min_players, max_players, current_players, stake_coin, round_no, status, result, min_players, max_players, current_players, stake_coin, round_no, status, result,
join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms, join_deadline_ms, ready_at_ms, created_at_ms, updated_at_ms, settled_at_ms, canceled_at_ms,
fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin fee_bps, pool_bps, match_mode, forced_result, pool_delta_coin
@ -595,7 +596,7 @@ func queryDiceMatch(ctx context.Context, q diceQuerier, appCode string, matchID
} }
row := q.QueryRowContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID)) row := q.QueryRowContext(ctx, query, appcode.Normalize(appCode), strings.TrimSpace(matchID))
var match dicedomain.Match var match dicedomain.Match
if err := row.Scan(&match.AppCode, &match.MatchID, &match.GameID, &match.PlatformCode, &match.ProviderGameID, &match.RoomID, &match.RegionID, &match.MinPlayers, &match.MaxPlayers, &match.CurrentPlayers, &match.StakeCoin, &match.RoundNo, &match.Status, &match.Result, &match.JoinDeadlineMS, &match.ReadyAtMS, &match.CreatedAtMS, &match.UpdatedAtMS, &match.SettledAtMS, &match.CanceledAtMS, &match.FeeBPS, &match.PoolBPS, &match.MatchMode, &match.ForcedResult, &match.PoolDeltaCoin); err != nil { if err := row.Scan(&match.AppCode, &match.MatchID, &match.GameID, &match.PlatformCode, &match.ProviderGameID, &match.RoomID, &match.RegionID, &match.CountryID, &match.MinPlayers, &match.MaxPlayers, &match.CurrentPlayers, &match.StakeCoin, &match.RoundNo, &match.Status, &match.Result, &match.JoinDeadlineMS, &match.ReadyAtMS, &match.CreatedAtMS, &match.UpdatedAtMS, &match.SettledAtMS, &match.CanceledAtMS, &match.FeeBPS, &match.PoolBPS, &match.MatchMode, &match.ForcedResult, &match.PoolDeltaCoin); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found") return dicedomain.Match{}, xerr.New(xerr.NotFound, "dice match not found")
} }

View File

@ -120,6 +120,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
display_user_id VARCHAR(32) NOT NULL, display_user_id VARCHAR(32) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '', room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
scene VARCHAR(64) NOT NULL, scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) NOT NULL, platform_code VARCHAR(64) NOT NULL,
game_id VARCHAR(96) NOT NULL, game_id VARCHAR(96) NOT NULL,
@ -144,6 +145,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '', room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
op_type VARCHAR(32) NOT NULL, op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT NOT NULL, coin_amount BIGINT NOT NULL,
status VARCHAR(32) NOT NULL, status VARCHAR(32) NOT NULL,
@ -168,6 +170,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
provider_game_id VARCHAR(128) NOT NULL, provider_game_id VARCHAR(128) NOT NULL,
room_id VARCHAR(96) NOT NULL DEFAULT '', room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
min_players INT NOT NULL, min_players INT NOT NULL,
max_players INT NOT NULL, max_players INT NOT NULL,
current_players INT NOT NULL DEFAULT 0, current_players INT NOT NULL DEFAULT 0,
@ -520,6 +523,15 @@ func (r *Repository) Migrate(ctx context.Context) error {
if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "black_pool_robot_force_win_enabled", "black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER normal_phase_target_win_rate_percent"); err != nil { if err := r.ensureColumn(ctx, "game_self_game_new_user_policies", "black_pool_robot_force_win_enabled", "black_pool_robot_force_win_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER normal_phase_target_win_rate_percent"); err != nil {
return err return err
} }
if err := r.ensureColumn(ctx, "game_orders", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_launch_sessions", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_dice_matches", "country_id", "country_id BIGINT NOT NULL DEFAULT 0 AFTER region_id"); err != nil {
return err
}
if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil { if err := r.ensureColumn(ctx, "game_outbox", "worker_id", "worker_id VARCHAR(128) NOT NULL DEFAULT '' AFTER status"); err != nil {
return err return err
} }
@ -823,10 +835,10 @@ func (r *Repository) GetPlatform(ctx context.Context, appCode string, platformCo
func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error { func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain.LaunchSession) error {
_, err := r.db.ExecContext(ctx, _, err := r.db.ExecContext(ctx,
`INSERT INTO game_launch_sessions ( `INSERT INTO game_launch_sessions (
app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id, app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID, appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID, session.CountryID,
session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status, session.Scene, session.PlatformCode, session.GameID, session.ProviderGameID, session.LaunchTokenHash, session.Status,
session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS, session.ExpiresAtMS, session.CreatedAtMS, session.UpdatedAtMS,
) )
@ -835,14 +847,14 @@ func (r *Repository) CreateLaunchSession(ctx context.Context, session gamedomain
func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) { func (r *Repository) GetLaunchSessionByToken(ctx context.Context, appCode string, token string) (gamedomain.LaunchSession, error) {
row := r.db.QueryRowContext(ctx, row := r.db.QueryRowContext(ctx,
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id, `SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
FROM game_launch_sessions FROM game_launch_sessions
WHERE app_code = ? AND launch_token_hash = ?`, WHERE app_code = ? AND launch_token_hash = ?`,
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
) )
var item gamedomain.LaunchSession var item gamedomain.LaunchSession
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.CountryID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid") return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
} }
@ -855,7 +867,7 @@ func (r *Repository) GetLaunchSessionByTokenScope(ctx context.Context, appCode s
// 同一个 App access token 会被多个厂商和多个游戏复用,回调校验必须把 token、平台和可选游戏 ID 一起收窄。 // 同一个 App access token 会被多个厂商和多个游戏复用,回调校验必须把 token、平台和可选游戏 ID 一起收窄。
// 这里按 created_at_ms 倒序取最新一条,避免同用户重复打开同一游戏时拿到旧房间或旧 session。 // 这里按 created_at_ms 倒序取最新一条,避免同用户重复打开同一游戏时拿到旧房间或旧 session。
row := r.db.QueryRowContext(ctx, row := r.db.QueryRowContext(ctx,
`SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, scene, platform_code, game_id, `SELECT app_code, session_id, user_id, display_user_id, room_id, region_id, country_id, scene, platform_code, game_id,
provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms provider_game_id, launch_token_hash, status, expires_at_ms, created_at_ms, updated_at_ms
FROM game_launch_sessions FROM game_launch_sessions
WHERE app_code = ? WHERE app_code = ?
@ -867,7 +879,7 @@ func (r *Repository) GetLaunchSessionByTokenScope(ctx context.Context, appCode s
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), strings.TrimSpace(platformCode), strings.TrimSpace(providerGameID), strings.TrimSpace(providerGameID), appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)), strings.TrimSpace(platformCode), strings.TrimSpace(providerGameID), strings.TrimSpace(providerGameID),
) )
var item gamedomain.LaunchSession var item gamedomain.LaunchSession
if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil { if err := row.Scan(&item.AppCode, &item.SessionID, &item.UserID, &item.DisplayUserID, &item.RoomID, &item.RegionID, &item.CountryID, &item.Scene, &item.PlatformCode, &item.GameID, &item.ProviderGameID, &item.LaunchTokenHash, &item.Status, &item.ExpiresAtMS, &item.CreatedAtMS, &item.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid") return gamedomain.LaunchSession{}, xerr.New(xerr.Unauthorized, "game session token invalid")
} }
@ -922,10 +934,10 @@ func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameO
if _, err := tx.ExecContext(ctx, if _, err := tx.ExecContext(ctx,
`INSERT INTO game_orders ( `INSERT INTO game_orders (
app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id, app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms user_id, room_id, region_id, country_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID, order.AppCode, order.OrderID, order.PlatformCode, order.ProviderOrderID, order.ProviderRoundID, order.GameID, order.ProviderGameID,
order.UserID, order.RoomID, order.RegionID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS, order.UserID, order.RoomID, order.RegionID, order.CountryID, order.OpType, order.CoinAmount, order.Status, order.RequestHash, order.CreatedAtMS, order.UpdatedAtMS,
); err != nil { ); err != nil {
return gamedomain.GameOrder{}, false, err return gamedomain.GameOrder{}, false, err
} }
@ -938,7 +950,7 @@ func (r *Repository) CreateGameOrder(ctx context.Context, order gamedomain.GameO
func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) { func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platformCode string, providerOrderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx, row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id, `SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after, user_id, room_id, region_id, country_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders FROM game_orders
WHERE app_code = ? AND platform_code = ? AND provider_order_id = ? WHERE app_code = ? AND platform_code = ? AND provider_order_id = ?
@ -946,7 +958,7 @@ func queryOrderForUpdate(ctx context.Context, tx *sql.Tx, appCode string, platfo
appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID), appcode.Normalize(appCode), strings.TrimSpace(platformCode), strings.TrimSpace(providerOrderID),
) )
var order gamedomain.GameOrder var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil { if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.CountryID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil return gamedomain.GameOrder{}, false, nil
} }
@ -1044,7 +1056,7 @@ func (r *Repository) getRepairOrderByProvider(ctx context.Context, appCode strin
func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) { func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, orderID string) (gamedomain.GameOrder, bool, error) {
row := tx.QueryRowContext(ctx, row := tx.QueryRowContext(ctx,
`SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id, `SELECT app_code, order_id, platform_code, provider_order_id, provider_round_id, game_id, provider_game_id,
user_id, room_id, region_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after, user_id, room_id, region_id, country_id, op_type, coin_amount, status, wallet_transaction_id, wallet_balance_after,
request_hash, failure_code, failure_message, created_at_ms, updated_at_ms request_hash, failure_code, failure_message, created_at_ms, updated_at_ms
FROM game_orders FROM game_orders
WHERE app_code = ? AND order_id = ? WHERE app_code = ? AND order_id = ?
@ -1052,7 +1064,7 @@ func queryOrderByIDForUpdate(ctx context.Context, tx *sql.Tx, appCode string, or
appcode.Normalize(appCode), strings.TrimSpace(orderID), appcode.Normalize(appCode), strings.TrimSpace(orderID),
) )
var order gamedomain.GameOrder var order gamedomain.GameOrder
if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil { if err := row.Scan(&order.AppCode, &order.OrderID, &order.PlatformCode, &order.ProviderOrderID, &order.ProviderRoundID, &order.GameID, &order.ProviderGameID, &order.UserID, &order.RoomID, &order.RegionID, &order.CountryID, &order.OpType, &order.CoinAmount, &order.Status, &order.WalletTransactionID, &order.WalletBalanceAfter, &order.RequestHash, &order.FailureCode, &order.FailureMessage, &order.CreatedAtMS, &order.UpdatedAtMS); err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return gamedomain.GameOrder{}, false, nil return gamedomain.GameOrder{}, false, nil
} }
@ -1087,6 +1099,7 @@ func insertGameLevelEventOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, balanceAfter int64, nowMs int64) error { func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomain.GameOrder, walletTransactionID string, balanceAfter int64, nowMs int64) error {
eventID := "GameOrderSettled:" + order.OrderID eventID := "GameOrderSettled:" + order.OrderID
// 国家必须来自订单快照region_id 只能表达区域分桶,不能替代国家维度。
_, err := tx.ExecContext(ctx, ` _, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO game_outbox ( INSERT IGNORE INTO game_outbox (
app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type, app_code, event_id, event_type, order_id, user_id, platform_code, game_id, op_type,
@ -1102,6 +1115,7 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
'room_id', ?, 'room_id', ?,
'visible_region_id', ?, 'visible_region_id', ?,
'region_id', ?, 'region_id', ?,
'country_id', ?,
'op_type', ?, 'op_type', ?,
'coin_amount', ?, 'coin_amount', ?,
'wallet_transaction_id', ?, 'wallet_transaction_id', ?,
@ -1109,7 +1123,7 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
), 'pending', ?, ?)`, ), 'pending', ?, ?)`,
order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount, order.AppCode, eventID, order.OrderID, order.UserID, order.PlatformCode, order.GameID, order.OpType, order.CoinAmount,
order.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID, order.OrderID, order.PlatformCode, order.GameID, order.ProviderGameID, order.ProviderOrderID, order.ProviderRoundID,
order.UserID, order.RoomID, order.RegionID, order.RegionID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter, order.UserID, order.RoomID, order.RegionID, order.RegionID, order.CountryID, order.OpType, order.CoinAmount, walletTransactionID, balanceAfter,
nowMs, nowMs, nowMs, nowMs,
) )
return err return err

View File

@ -130,6 +130,7 @@ func (s *Server) LaunchGame(ctx context.Context, req *gamev1.LaunchGameRequest)
GameID: req.GetGameId(), GameID: req.GetGameId(),
Scene: req.GetScene(), Scene: req.GetScene(),
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
CountryID: req.GetCountryId(),
AccessToken: req.GetAccessToken(), AccessToken: req.GetAccessToken(),
}) })
if err != nil { if err != nil {
@ -169,6 +170,7 @@ func (s *Server) MatchDice(ctx context.Context, req *gamev1.MatchDiceRequest) (*
GameID: req.GetGameId(), GameID: req.GetGameId(),
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(), RegionID: req.GetRegionId(),
CountryID: req.GetCountryId(),
StakeCoin: req.GetStakeCoin(), StakeCoin: req.GetStakeCoin(),
RPSGesture: req.GetRpsGesture(), RPSGesture: req.GetRpsGesture(),
}) })
@ -191,6 +193,7 @@ func (s *Server) CreateDiceMatch(ctx context.Context, req *gamev1.CreateDiceMatc
GameID: req.GetGameId(), GameID: req.GetGameId(),
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
RegionID: req.GetRegionId(), RegionID: req.GetRegionId(),
CountryID: req.GetCountryId(),
StakeCoin: req.GetStakeCoin(), StakeCoin: req.GetStakeCoin(),
MinPlayers: req.GetMinPlayers(), MinPlayers: req.GetMinPlayers(),
MaxPlayers: req.GetMaxPlayers(), MaxPlayers: req.GetMaxPlayers(),
@ -765,6 +768,7 @@ func diceMatchToProtoAt(match dicedomain.Match, nowMS int64) *gamev1.DiceMatch {
GameId: match.GameID, GameId: match.GameID,
RoomId: match.RoomID, RoomId: match.RoomID,
RegionId: match.RegionID, RegionId: match.RegionID,
CountryId: match.CountryID,
MinPlayers: match.MinPlayers, MinPlayers: match.MinPlayers,
MaxPlayers: match.MaxPlayers, MaxPlayers: match.MaxPlayers,
CurrentPlayers: match.CurrentPlayers, CurrentPlayers: match.CurrentPlayers,

View File

@ -155,6 +155,7 @@ func (h *Handler) launchGame(writer http.ResponseWriter, request *http.Request)
Scene: body.Scene, Scene: body.Scene,
RoomId: body.RoomID, RoomId: body.RoomID,
AccessToken: bearerAccessToken(request), AccessToken: bearerAccessToken(request),
CountryId: userResp.GetUser().GetCountryId(),
}) })
httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err) httpkit.Write(writer, request, gameLaunchDataFromProto(resp), err)
} }
@ -237,6 +238,7 @@ func (h *Handler) matchSelfGame(writer http.ResponseWriter, request *http.Reques
GameId: gameID, GameId: gameID,
RoomId: strings.TrimSpace(body.RoomID), RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(), RegionId: userResp.GetUser().GetRegionId(),
CountryId: userResp.GetUser().GetCountryId(),
StakeCoin: body.StakeCoin, StakeCoin: body.StakeCoin,
RpsGesture: rpsGesture, RpsGesture: rpsGesture,
}) })
@ -297,6 +299,7 @@ func (h *Handler) createSelfGameMatch(writer http.ResponseWriter, request *http.
GameId: gameID, GameId: gameID,
RoomId: strings.TrimSpace(body.RoomID), RoomId: strings.TrimSpace(body.RoomID),
RegionId: userResp.GetUser().GetRegionId(), RegionId: userResp.GetUser().GetRegionId(),
CountryId: userResp.GetUser().GetCountryId(),
StakeCoin: body.StakeCoin, StakeCoin: body.StakeCoin,
MinPlayers: body.MinPlayers, MinPlayers: body.MinPlayers,
MaxPlayers: body.MaxPlayers, MaxPlayers: body.MaxPlayers,

View File

@ -569,6 +569,7 @@ func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.Reques
PaidAtMs: now.UTC().UnixMilli(), PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID, PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID, VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
}, },
}) })
if err != nil { if err != nil {

View File

@ -470,6 +470,7 @@ func selfGameMatchEvent(body []byte) (mysqlstorage.SelfGameMatchEvent, bool, err
GameID: firstNonEmpty(mysqlstorage.String(payload, "game_id"), message.GameID), GameID: firstNonEmpty(mysqlstorage.String(payload, "game_id"), message.GameID),
PlatformCode: firstNonEmpty(mysqlstorage.String(payload, "platform_code"), message.PlatformCode), PlatformCode: firstNonEmpty(mysqlstorage.String(payload, "platform_code"), message.PlatformCode),
RoomID: mysqlstorage.String(payload, "room_id"), RoomID: mysqlstorage.String(payload, "room_id"),
CountryID: mysqlstorage.Int64(payload, "country_id"),
RegionID: mysqlstorage.Int64(payload, "region_id"), RegionID: mysqlstorage.Int64(payload, "region_id"),
StakeCoin: mysqlstorage.Int64(payload, "stake_coin"), StakeCoin: mysqlstorage.Int64(payload, "stake_coin"),
Status: mysqlstorage.String(payload, "status"), Status: mysqlstorage.String(payload, "status"),

View File

@ -65,6 +65,49 @@ func TestQueryOverviewUsesActivityLuckyGiftDayStats(t *testing.T) {
} }
} }
func TestConsumeUserRegisteredCountsOnlyNewRegistration(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_registration_idempotent_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
event := UserRegisteredEvent{
AppCode: "lalu",
EventID: "user_registered:327300172394536960",
UserID: 327300172394536960,
CountryID: 15,
RegionID: 10,
OccurredAtMS: time.Date(2026, 6, 22, 1, 2, 3, 0, time.UTC).UnixMilli(),
}
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration first time: %v", err)
}
// 生产环境会遇到 MQ 重投或历史补偿;注册 cohort 表去重后,国家日新增只能按真实新行累加。
event.EventID = "user_registered:327300172394536960:replay"
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration replay: %v", err)
}
var newUsers int64
if err := repository.db.QueryRowContext(ctx, `
SELECT new_users
FROM stat_app_day_country
WHERE app_code = 'lalu' AND stat_day = '2026-06-22' AND country_id = 15 AND region_id = 10
`).Scan(&newUsers); err != nil {
t.Fatalf("query app day country: %v", err)
}
if newUsers != 1 {
t.Fatalf("registration replay must not duplicate new users, got %d", newUsers)
}
}
func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) { func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, file, _, _ := runtime.Caller(0) _, file, _, _ := runtime.Caller(0)

View File

@ -463,6 +463,7 @@ type SelfGameMatchEvent struct {
GameID string GameID string
PlatformCode string PlatformCode string
RoomID string RoomID string
CountryID int64
RegionID int64 RegionID int64
StakeCoin int64 StakeCoin int64
Status string Status string
@ -504,17 +505,20 @@ func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegist
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil { if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
return err return err
} }
if _, err := tx.ExecContext(ctx, ` inserted, err := insertUnique(ctx, tx, `
INSERT IGNORE INTO stat_user_registration (app_code, user_id, registered_day, country_id, region_id, registered_at_ms) INSERT IGNORE INTO stat_user_registration (app_code, user_id, registered_day, country_id, region_id, registered_at_ms)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?)
`, appcode.Normalize(event.AppCode), event.UserID, day, countryID, regionID, event.OccurredAtMS); err != nil { `, appcode.Normalize(event.AppCode), event.UserID, day, countryID, regionID, event.OccurredAtMS)
if err != nil {
return err return err
} }
_, err := tx.ExecContext(ctx, ` // MQ 重投和补偿任务可能带来新的 event_id但同一用户注册 cohort 只能入库一次;
// new_users 必须按 INSERT IGNORE 的真实插入行数累加,避免回放把国家新增人数重复放大。
_, err = tx.ExecContext(ctx, `
UPDATE stat_app_day_country UPDATE stat_app_day_country
SET new_users = new_users + 1, updated_at_ms = ? SET new_users = new_users + ?, updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ? WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ?
`, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID) `, inserted, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID)
return err return err
}) })
} }
@ -816,7 +820,7 @@ func (r *Repository) ConsumeSelfGameH5Event(ctx context.Context, event SelfGameH
func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMatchEvent) error { func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMatchEvent) error {
return r.withEvent(ctx, SourceGame, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error { return r.withEvent(ctx, SourceGame, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
day := statDay(event.OccurredAtMS) day := statDay(event.OccurredAtMS)
countryID, regionID := normalizeDimension(0, event.RegionID) countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
gameID := normalizeShortText(event.GameID) gameID := normalizeShortText(event.GameID)
if gameID == "" { if gameID == "" {
gameID = "unknown" gameID = "unknown"

View File

@ -965,6 +965,7 @@ type LuckyGiftRewardCommand struct {
DrawID string DrawID string
RoomID string RoomID string
VisibleRegionID int64 VisibleRegionID int64
CountryID int64
GiftID string GiftID string
PoolID string PoolID string
Reason string Reason string

View File

@ -1667,14 +1667,16 @@ func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
repository := mysqltest.NewRepository(t) repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository) svc := walletservice.New(repository)
command := ledger.LuckyGiftRewardCommand{ command := ledger.LuckyGiftRewardCommand{
CommandID: "lucky_reward:lucky_draw_1", CommandID: "lucky_reward:lucky_draw_1",
TargetUserID: 21002, TargetUserID: 21002,
Amount: 188, Amount: 188,
DrawID: "lucky_draw_1", DrawID: "lucky_draw_1",
RoomID: "room-1", RoomID: "room-1",
GiftID: "rose", GiftID: "rose",
PoolID: "super_lucky", PoolID: "super_lucky",
Reason: "lucky_gift_reward", CountryID: 15,
VisibleRegionID: 10,
Reason: "lucky_gift_reward",
} }
first, err := svc.CreditLuckyGiftReward(context.Background(), command) first, err := svc.CreditLuckyGiftReward(context.Background(), command)
@ -1697,6 +1699,9 @@ func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 { if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ?", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 {
t.Fatalf("lucky reward should write one reward fact event, got %d", got) t.Fatalf("lucky reward should write one reward fact event, got %d", got)
} }
if got := repository.CountRows("wallet_outbox", "transaction_id = ? AND event_type = ? AND JSON_EXTRACT(payload_json, '$.country_id') = 15 AND JSON_EXTRACT(payload_json, '$.region_id') = 10", first.TransactionID, "WalletLuckyGiftRewardCredited"); got != 1 {
t.Fatalf("lucky reward fact should carry country and region dimensions, got %d", got)
}
} }
// TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。 // TestTransferCoinFromSellerMovesDedicatedAssetToPlayerCoin 验证币商专用金币和玩家普通金币不会混账。

View File

@ -1391,6 +1391,7 @@ func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.L
DrawID: command.DrawID, DrawID: command.DrawID,
RoomID: command.RoomID, RoomID: command.RoomID,
VisibleRegionID: command.VisibleRegionID, VisibleRegionID: command.VisibleRegionID,
CountryID: command.CountryID,
GiftID: command.GiftID, GiftID: command.GiftID,
PoolID: command.PoolID, PoolID: command.PoolID,
Reason: command.Reason, Reason: command.Reason,
@ -3388,6 +3389,7 @@ type luckyGiftRewardMetadata struct {
DrawID string `json:"draw_id"` DrawID string `json:"draw_id"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
VisibleRegionID int64 `json:"visible_region_id"` VisibleRegionID int64 `json:"visible_region_id"`
CountryID int64 `json:"country_id"`
GiftID string `json:"gift_id"` GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"` PoolID string `json:"pool_id"`
Reason string `json:"reason"` Reason string `json:"reason"`
@ -4102,6 +4104,7 @@ func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metada
"room_id": metadata.RoomID, "room_id": metadata.RoomID,
"visible_region_id": metadata.VisibleRegionID, "visible_region_id": metadata.VisibleRegionID,
"region_id": metadata.VisibleRegionID, "region_id": metadata.VisibleRegionID,
"country_id": metadata.CountryID,
"gift_id": metadata.GiftID, "gift_id": metadata.GiftID,
"pool_id": metadata.PoolID, "pool_id": metadata.PoolID,
"amount": metadata.Amount, "amount": metadata.Amount,
@ -4613,12 +4616,14 @@ func taskRewardRequestHash(command ledger.TaskRewardCommand) string {
} }
func luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string { func luckyGiftRewardRequestHash(command ledger.LuckyGiftRewardCommand) string {
return stableHash(fmt.Sprintf("lucky_gift_reward|%s|%d|%d|%s|%s|%s|%s|%s", return stableHash(fmt.Sprintf("lucky_gift_reward|%s|%d|%d|%s|%s|%d|%d|%s|%s|%s",
appcode.Normalize(command.AppCode), appcode.Normalize(command.AppCode),
command.TargetUserID, command.TargetUserID,
command.Amount, command.Amount,
strings.TrimSpace(command.DrawID), strings.TrimSpace(command.DrawID),
strings.TrimSpace(command.RoomID), strings.TrimSpace(command.RoomID),
command.VisibleRegionID,
command.CountryID,
strings.TrimSpace(command.GiftID), strings.TrimSpace(command.GiftID),
strings.TrimSpace(command.PoolID), strings.TrimSpace(command.PoolID),
strings.TrimSpace(command.Reason), strings.TrimSpace(command.Reason),

View File

@ -1046,6 +1046,7 @@ func (s *Server) CreditLuckyGiftReward(ctx context.Context, req *walletv1.Credit
DrawID: req.GetDrawId(), DrawID: req.GetDrawId(),
RoomID: req.GetRoomId(), RoomID: req.GetRoomId(),
VisibleRegionID: req.GetVisibleRegionId(), VisibleRegionID: req.GetVisibleRegionId(),
CountryID: req.GetCountryId(),
GiftID: req.GetGiftId(), GiftID: req.GetGiftId(),
PoolID: req.GetPoolId(), PoolID: req.GetPoolId(),
Reason: req.GetReason(), Reason: req.GetReason(),