统计修改

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;
// visible_region_id room-service IM
int64 visible_region_id = 13;
// country_id
int64 country_id = 14;
}
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.
// Provider adapters pass it through as the game token instead of minting a separate game token.
string access_token = 7;
// country_id gateway provider
int64 country_id = 8;
}
message LaunchGameResponse {
@ -194,6 +196,8 @@ message DiceMatch {
int64 ready_at_ms = 24;
int64 canceled_at_ms = 25;
int64 pool_delta_coin = 26;
// country_id
int64 country_id = 27;
}
message DiceStakeOption {
@ -364,6 +368,8 @@ message CreateDiceMatchRequest {
int32 min_players = 7;
int32 max_players = 8;
string rps_gesture = 9;
// country_id gateway
int64 country_id = 10;
}
message JoinDiceMatchRequest {
@ -397,6 +403,8 @@ message MatchDiceRequest {
int64 region_id = 5;
int64 stake_coin = 6;
string rps_gesture = 7;
// country_id gateway
int64 country_id = 8;
}
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 reason = 9;
int64 visible_region_id = 10;
// country_id outbox
int64 country_id = 11;
}
// CreditLuckyGiftRewardResponse COIN

View File

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

View File

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

View File

@ -72,6 +72,7 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
PoolID: "super_lucky",
UserID: 42,
TargetUserID: 99,
CountryID: 15,
DeviceID: "device-1",
RoomID: "room-1",
AnchorID: "99",
@ -83,7 +84,7 @@ func TestDrawCreditsLuckyGiftRewardFastPath(t *testing.T) {
if err != nil {
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)
}
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,
"sender_user_id": 42,
"target_user_id": 99,
"country_id": 15,
"visible_region_id": 210,
"room_id": "room-1",
"gift_id": "rose",
@ -169,7 +171,7 @@ func TestProcessPendingDrawOutboxCreditsWalletOnly(t *testing.T) {
if processed != 1 {
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)
}
if publisher.last.EventID != "" || broadcaster.last.EventID != "" {
@ -192,6 +194,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
"user_id": 42,
"sender_user_id": 42,
"target_user_id": 99,
"country_id": 15,
"room_id": "room-1",
"gift_id": "rose",
"gift_count": 3,
@ -222,7 +225,7 @@ func TestProcessPendingBatchDrawOutboxCreditsWalletOnceAndGrantsAllDraws(t *test
if processed != 1 {
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)
}
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,
"target_user_id": cmd.TargetUserID,
"visible_region_id": cmd.VisibleRegionID,
"country_id": cmd.CountryID,
"room_id": cmd.RoomID,
"gift_id": cmd.GiftID,
"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,
"target_user_id": input.Command.TargetUserID,
"visible_region_id": input.Command.VisibleRegionID,
"country_id": input.Command.CountryID,
"room_id": input.Command.RoomID,
"gift_id": input.Command.GiftID,
"gift_count": input.Command.GiftCount,

View File

@ -60,6 +60,7 @@ func (s *LuckyGiftServer) ExecuteLuckyGiftDraw(ctx context.Context, req *activit
CoinSpent: meta.GetCoinSpent(),
PaidAtMS: meta.GetPaidAtMs(),
VisibleRegionID: meta.GetVisibleRegionId(),
CountryID: meta.GetCountryId(),
})
if err != nil {
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',
room_id VARCHAR(96) NOT NULL DEFAULT '' 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 '场景',
platform_code VARCHAR(64) NOT NULL COMMENT '平台编码',
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',
room_id VARCHAR(96) NOT NULL DEFAULT '' 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类型',
coin_amount BIGINT 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 快照',
room_id VARCHAR(96) NOT NULL DEFAULT '' 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 '最少开局人数',
max_players INT NOT NULL COMMENT '最多参与人数',
current_players INT NOT NULL DEFAULT 0 COMMENT '当前参与人数',

View File

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

View File

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

View File

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

View File

@ -130,6 +130,7 @@ type LaunchCommand struct {
Scene string
RoomID string
RegionID int64
CountryID int64
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 {
return LaunchResult{}, xerr.New(xerr.Conflict, "game is not launchable")
}
regionID := command.RegionID
if regionID <= 0 {
regionID, countryID := command.RegionID, command.CountryID
if regionID <= 0 || countryID <= 0 {
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 {
return LaunchResult{}, err
}
if regionID <= 0 {
regionID = resolvedRegionID
}
if countryID <= 0 {
countryID = resolvedCountryID
}
}
now := s.now()
@ -316,6 +324,7 @@ func (s *Service) LaunchGame(ctx context.Context, command LaunchCommand) (Launch
DisplayUserID: command.DisplayUserID,
RoomID: strings.TrimSpace(command.RoomID),
RegionID: regionID,
CountryID: countryID,
Scene: command.Scene,
PlatformCode: game.PlatformCode,
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) {
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 {
return 0, nil
return 0, 0, nil
}
userResp, err := s.user.GetUser(ctx, &userv1.GetUserRequest{
Meta: &userv1.RequestMeta{
@ -528,9 +544,10 @@ func (s *Service) resolveUserRegion(ctx context.Context, app string, requestID s
UserId: userID,
})
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) {
@ -563,7 +580,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
}
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 {
return nil, err
}
@ -578,6 +595,7 @@ func (s *Service) applyCallbackCoinChange(ctx context.Context, app string, req *
UserID: body.UserID,
RoomID: strings.TrimSpace(body.RoomID),
RegionID: regionID,
CountryID: countryID,
OpType: body.OpType,
CoinAmount: body.CoinAmount,
Status: gamedomain.OrderStatusWalletApplying,

View File

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

View File

@ -30,12 +30,12 @@ func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match
// match 和首个 participant 必须同事务提交;否则创建成功但参与者缺失会导致后续 roll 永远人数不足。
if _, err := tx.ExecContext(ctx,
`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,
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
match.AppCode, match.MatchID, match.GameID, match.PlatformCode, match.ProviderGameID, match.RoomID, match.RegionID,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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,
@ -548,6 +548,7 @@ func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]
"provider_game_id": strings.TrimSpace(match.ProviderGameID),
"room_id": strings.TrimSpace(match.RoomID),
"region_id": match.RegionID,
"country_id": match.CountryID,
"stake_coin": match.StakeCoin,
"min_players": match.MinPlayers,
"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) {
// 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,
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
@ -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))
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) {
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,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
scene VARCHAR(64) NOT NULL,
platform_code VARCHAR(64) 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,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
op_type VARCHAR(32) NOT NULL,
coin_amount BIGINT 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,
room_id VARCHAR(96) NOT NULL DEFAULT '',
region_id BIGINT NOT NULL DEFAULT 0,
country_id BIGINT NOT NULL DEFAULT 0,
min_players INT NOT NULL,
max_players INT NOT NULL,
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 {
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 {
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 {
_, err := r.db.ExecContext(ctx,
`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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
appcode.Normalize(session.AppCode), session.SessionID, session.UserID, session.DisplayUserID, session.RoomID, session.RegionID,
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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.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) {
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
FROM game_launch_sessions
WHERE app_code = ? AND launch_token_hash = ?`,
appcode.Normalize(appCode), stableHash(strings.TrimSpace(token)),
)
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) {
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 一起收窄。
// 这里按 created_at_ms 倒序取最新一条,避免同用户重复打开同一游戏时拿到旧房间或旧 session。
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
FROM game_launch_sessions
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),
)
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) {
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,
`INSERT INTO game_orders (
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
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user_id, room_id, region_id, country_id, op_type, coin_amount, status, request_hash, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 {
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) {
row := tx.QueryRowContext(ctx,
`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
FROM game_orders
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),
)
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) {
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) {
row := tx.QueryRowContext(ctx,
`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
FROM game_orders
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),
)
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) {
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 {
eventID := "GameOrderSettled:" + order.OrderID
// 国家必须来自订单快照region_id 只能表达区域分桶,不能替代国家维度。
_, err := tx.ExecContext(ctx, `
INSERT IGNORE INTO game_outbox (
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', ?,
'visible_region_id', ?,
'region_id', ?,
'country_id', ?,
'op_type', ?,
'coin_amount', ?,
'wallet_transaction_id', ?,
@ -1109,7 +1123,7 @@ func insertGameStatisticsOutbox(ctx context.Context, tx *sql.Tx, order gamedomai
), 'pending', ?, ?)`,
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.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,
)
return err

View File

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

View File

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

View File

@ -569,6 +569,7 @@ func (s *Service) executeLuckyGiftDraws(ctx context.Context, meta *roomv1.Reques
PaidAtMs: now.UTC().UnixMilli(),
PoolId: cmd.PoolID,
VisibleRegionId: roomMeta.VisibleRegionID,
CountryId: cmd.SenderCountryID,
},
})
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),
PlatformCode: firstNonEmpty(mysqlstorage.String(payload, "platform_code"), message.PlatformCode),
RoomID: mysqlstorage.String(payload, "room_id"),
CountryID: mysqlstorage.Int64(payload, "country_id"),
RegionID: mysqlstorage.Int64(payload, "region_id"),
StakeCoin: mysqlstorage.Int64(payload, "stake_coin"),
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) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)

View File

@ -463,6 +463,7 @@ type SelfGameMatchEvent struct {
GameID string
PlatformCode string
RoomID string
CountryID int64
RegionID int64
StakeCoin int64
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 {
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)
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
}
_, err := tx.ExecContext(ctx, `
// MQ 重投和补偿任务可能带来新的 event_id但同一用户注册 cohort 只能入库一次;
// new_users 必须按 INSERT IGNORE 的真实插入行数累加,避免回放把国家新增人数重复放大。
_, err = tx.ExecContext(ctx, `
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 = ?
`, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID)
`, inserted, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID)
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 {
return r.withEvent(ctx, SourceGame, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
day := statDay(event.OccurredAtMS)
countryID, regionID := normalizeDimension(0, event.RegionID)
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
gameID := normalizeShortText(event.GameID)
if gameID == "" {
gameID = "unknown"

View File

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

View File

@ -1667,14 +1667,16 @@ func TestCreditLuckyGiftRewardIsIdempotent(t *testing.T) {
repository := mysqltest.NewRepository(t)
svc := walletservice.New(repository)
command := ledger.LuckyGiftRewardCommand{
CommandID: "lucky_reward:lucky_draw_1",
TargetUserID: 21002,
Amount: 188,
DrawID: "lucky_draw_1",
RoomID: "room-1",
GiftID: "rose",
PoolID: "super_lucky",
Reason: "lucky_gift_reward",
CommandID: "lucky_reward:lucky_draw_1",
TargetUserID: 21002,
Amount: 188,
DrawID: "lucky_draw_1",
RoomID: "room-1",
GiftID: "rose",
PoolID: "super_lucky",
CountryID: 15,
VisibleRegionID: 10,
Reason: "lucky_gift_reward",
}
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 {
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 验证币商专用金币和玩家普通金币不会混账。

View File

@ -1391,6 +1391,7 @@ func (r *Repository) CreditLuckyGiftReward(ctx context.Context, command ledger.L
DrawID: command.DrawID,
RoomID: command.RoomID,
VisibleRegionID: command.VisibleRegionID,
CountryID: command.CountryID,
GiftID: command.GiftID,
PoolID: command.PoolID,
Reason: command.Reason,
@ -3388,6 +3389,7 @@ type luckyGiftRewardMetadata struct {
DrawID string `json:"draw_id"`
RoomID string `json:"room_id"`
VisibleRegionID int64 `json:"visible_region_id"`
CountryID int64 `json:"country_id"`
GiftID string `json:"gift_id"`
PoolID string `json:"pool_id"`
Reason string `json:"reason"`
@ -4102,6 +4104,7 @@ func luckyGiftRewardCreditedEvent(transactionID string, commandID string, metada
"room_id": metadata.RoomID,
"visible_region_id": metadata.VisibleRegionID,
"region_id": metadata.VisibleRegionID,
"country_id": metadata.CountryID,
"gift_id": metadata.GiftID,
"pool_id": metadata.PoolID,
"amount": metadata.Amount,
@ -4613,12 +4616,14 @@ func taskRewardRequestHash(command ledger.TaskRewardCommand) 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),
command.TargetUserID,
command.Amount,
strings.TrimSpace(command.DrawID),
strings.TrimSpace(command.RoomID),
command.VisibleRegionID,
command.CountryID,
strings.TrimSpace(command.GiftID),
strings.TrimSpace(command.PoolID),
strings.TrimSpace(command.Reason),

View File

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