首充修复

This commit is contained in:
zhx 2026-06-11 19:24:51 +08:00
parent eea564ac56
commit cc84e14860
22 changed files with 1897 additions and 17 deletions

View File

@ -11,6 +11,14 @@ const (
MessageTypeGameOutboxEvent = "game_outbox_event"
TagGameOutboxEvent = "game_outbox_event"
EventTypeGameOrderSettled = "GameOrderSettled"
EventTypeSelfGameMatchCreated = "SelfGameMatchCreated"
EventTypeSelfGameMatchReady = "SelfGameMatchReady"
EventTypeSelfGameMatchSettled = "SelfGameMatchSettled"
EventTypeSelfGameMatchCanceled = "SelfGameMatchCanceled"
EventTypeSelfGameMatchFailed = "SelfGameMatchFailed"
EventTypeSelfGamePoolAdjusted = "SelfGamePoolAdjusted"
)
// GameOutboxMessage is the MQ representation of one committed game_outbox fact.
@ -35,14 +43,16 @@ func EncodeGameOutboxMessage(message GameOutboxMessage) ([]byte, error) {
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.GameID) == "" ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0 ||
message.OccurredAtMS <= 0 {
return nil, errors.New("game outbox message is incomplete")
}
if message.EventType == EventTypeGameOrderSettled && (strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0) {
return nil, errors.New("game order outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}
@ -61,14 +71,16 @@ func DecodeGameOutboxMessage(body []byte) (GameOutboxMessage, error) {
if strings.TrimSpace(message.AppCode) == "" ||
strings.TrimSpace(message.EventID) == "" ||
strings.TrimSpace(message.EventType) == "" ||
strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.GameID) == "" ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0 ||
message.OccurredAtMS <= 0 {
return GameOutboxMessage{}, errors.New("game outbox message is incomplete")
}
if message.EventType == EventTypeGameOrderSettled && (strings.TrimSpace(message.OrderID) == "" ||
message.UserID <= 0 ||
strings.TrimSpace(message.OpType) == "" ||
message.CoinAmount <= 0) {
return GameOutboxMessage{}, errors.New("game order outbox message is incomplete")
}
if strings.TrimSpace(message.PayloadJSON) == "" {
message.PayloadJSON = "{}"
}

View File

@ -4,6 +4,7 @@ import (
"context"
cryptorand "crypto/rand"
"database/sql"
"encoding/json"
"errors"
"fmt"
"math/big"
@ -11,6 +12,7 @@ import (
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/gamemq"
"hyapp/pkg/xerr"
dicedomain "hyapp/services/game-service/internal/domain/dice"
)
@ -252,6 +254,13 @@ func (r *Repository) CancelDiceMatch(ctx context.Context, appCode string, matchI
); err != nil {
return dicedomain.Match{}, err
}
match.Status = dicedomain.MatchStatusCanceled
match.CanceledAtMS = nowMs
match.UpdatedAtMS = nowMs
match.Participants = participants
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchCanceled, match, nowMs); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
@ -385,6 +394,34 @@ func (r *Repository) AdjustDicePool(ctx context.Context, adjustment dicedomain.P
return dicedomain.PoolAdjustment{}, err
}
}
payload, err := json.Marshal(map[string]any{
"adjustment_id": adjustment.AdjustmentID,
"game_id": adjustment.GameID,
"match_id": adjustment.MatchID,
"user_id": adjustment.UserID,
"direction": adjustment.Direction,
"amount_coin": adjustment.AmountCoin,
"reason": adjustment.Reason,
"balance_after": adjustment.BalanceAfter,
"created_by": adjustment.CreatedBy,
"created_at_ms": adjustment.CreatedAtMS,
})
if err != nil {
return dicedomain.PoolAdjustment{}, err
}
// 奖池调整和奖池流水同事务写入 outbox统计服务消费 owner fact 后才能同时得到入池、出池和余额快照。
if _, err := tx.ExecContext(ctx, `
INSERT INTO game_outbox (
app_code, event_id, event_type, order_id, user_id, platform_code, game_id,
op_type, coin_amount, payload_json, status, attempts, next_retry_at_ms,
created_at_ms, updated_at_ms, published_at_ms
) VALUES (?, ?, ?, '', ?, 'self', ?, '', 0, CAST(? AS JSON), 'pending', 0, 0, ?, ?, 0)
ON DUPLICATE KEY UPDATE updated_at_ms = VALUES(updated_at_ms)`,
adjustment.AppCode, gamemq.EventTypeSelfGamePoolAdjusted+":"+adjustment.AdjustmentID, gamemq.EventTypeSelfGamePoolAdjusted,
adjustment.UserID, adjustment.GameID, string(payload), adjustment.CreatedAtMS, adjustment.CreatedAtMS,
); err != nil {
return dicedomain.PoolAdjustment{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.PoolAdjustment{}, err
}
@ -672,7 +709,19 @@ func updateDiceMatchAfterJoinLocked(ctx context.Context, tx *sql.Tx, match diced
match.CurrentPlayers, nextStatus, readyAtMS, strings.TrimSpace(matchMode), strings.TrimSpace(forcedResult), nowMs,
match.AppCode, match.MatchID,
)
return err
if err != nil {
return err
}
match.Status = nextStatus
match.ReadyAtMS = readyAtMS
match.MatchMode = strings.TrimSpace(matchMode)
match.ForcedResult = strings.TrimSpace(forcedResult)
match.UpdatedAtMS = nowMs
if nextStatus == dicedomain.MatchStatusReady {
// ready 事件只用 INSERT IGNORE 写一次;真人 join 和机器人补位可能都走这里,幂等键保证不会重复统计匹配成功局。
return insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchReady, match, nowMs)
}
return nil
}
func normalizeDiceGameID(gameID string) string {

View File

@ -8,6 +8,7 @@ import (
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/gamemq"
"hyapp/pkg/xerr"
dicedomain "hyapp/services/game-service/internal/domain/dice"
)
@ -55,6 +56,9 @@ func (r *Repository) CreateDiceMatch(ctx context.Context, match dicedomain.Match
return dicedomain.Match{}, err
}
}
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchCreated, match, match.CreatedAtMS); err != nil {
return dicedomain.Match{}, err
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
@ -139,6 +143,20 @@ func (r *Repository) JoinDiceMatch(ctx context.Context, appCode string, matchID
); err != nil {
return dicedomain.Match{}, err
}
match.CurrentPlayers = nextPlayers
match.Status = nextStatus
match.ReadyAtMS = readyAtMS
match.UpdatedAtMS = nowMs
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, false)
if err != nil {
return dicedomain.Match{}, err
}
match.Participants = participants
if nextStatus == dicedomain.MatchStatusReady {
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchReady, match, nowMs); err != nil {
return dicedomain.Match{}, err
}
}
if err := tx.Commit(); err != nil {
return dicedomain.Match{}, err
}
@ -394,19 +412,176 @@ func (r *Repository) MarkDiceMatchSettled(ctx context.Context, appCode string, m
); err != nil {
return err
}
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
if err != nil {
return err
}
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
if err != nil {
return err
}
match.Participants = participants
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchSettled, match, nowMs); err != nil {
return err
}
return tx.Commit()
}
func (r *Repository) MarkDiceMatchFailed(ctx context.Context, appCode string, matchID string, result string, nowMs int64) error {
_, err := r.db.ExecContext(ctx,
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
app := appcode.Normalize(appCode)
if _, err := tx.ExecContext(ctx,
`UPDATE game_dice_matches
SET status = ?, result = ?, updated_at_ms = ?
WHERE app_code = ? AND match_id = ?`,
dicedomain.MatchStatusFailed, strings.TrimSpace(result), nowMs, appcode.Normalize(appCode), strings.TrimSpace(matchID),
dicedomain.MatchStatusFailed, strings.TrimSpace(result), nowMs, app, strings.TrimSpace(matchID),
); err != nil {
return err
}
match, err := queryDiceMatch(ctx, tx, app, strings.TrimSpace(matchID), true)
if err != nil {
return err
}
participants, err := queryDiceParticipants(ctx, tx, app, match.MatchID, true)
if err != nil {
return err
}
match.Participants = participants
if err := insertSelfGameMatchOutbox(ctx, tx, gamemq.EventTypeSelfGameMatchFailed, match, nowMs); err != nil {
return err
}
return tx.Commit()
}
func insertSelfGameMatchOutbox(ctx context.Context, tx *sql.Tx, eventType string, match dicedomain.Match, nowMs int64) error {
match.AppCode = appcode.Normalize(match.AppCode)
match.GameID = strings.TrimSpace(match.GameID)
if match.AppCode == "" || match.MatchID == "" || match.GameID == "" || nowMs <= 0 {
return xerr.New(xerr.InvalidArgument, "self game match event is incomplete")
}
payload := selfGameMatchOutboxPayload(match, nowMs)
raw, err := json.Marshal(payload)
if err != nil {
return err
}
userID := int64(0)
if len(match.Participants) > 0 {
userID = match.Participants[0].UserID
}
// 自研局级事件没有单笔订单语义order_id/op_type/coin_amount 只保留空值;统计服务按 event_type 解析 payload。
_, 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,
coin_amount, payload_json, status, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, '', ?, ?, ?, '', 0, CAST(? AS JSON), 'pending', ?, ?)`,
match.AppCode, eventType+":"+match.MatchID, eventType, userID, strings.TrimSpace(match.PlatformCode),
match.GameID, string(raw), nowMs, nowMs,
)
return err
}
func selfGameMatchOutboxPayload(match dicedomain.Match, nowMs int64) map[string]any {
participants := make([]map[string]any, 0, len(match.Participants))
userStakeCoin, robotStakeCoin, payoutCoin, refundCoin := int64(0), int64(0), int64(0), int64(0)
userParticipants, robotParticipants, winners, losers, draws := int64(0), int64(0), int64(0), int64(0), int64(0)
for _, participant := range match.Participants {
isRobot := participant.ParticipantType == dicedomain.ParticipantTypeRobot
if isRobot {
robotParticipants++
robotStakeCoin += participant.StakeCoin
} else {
userParticipants++
userStakeCoin += participant.StakeCoin
}
switch participant.Result {
case dicedomain.ParticipantResultWin:
winners++
case dicedomain.ParticipantResultLose:
losers++
case dicedomain.ParticipantResultDraw:
draws++
if !isRobot {
refundCoin += participant.PayoutCoin
}
}
if !isRobot && participant.Result != dicedomain.ParticipantResultDraw {
payoutCoin += participant.PayoutCoin
}
netWinCoin := participant.PayoutCoin
if !isRobot {
netWinCoin -= participant.StakeCoin
}
participants = append(participants, map[string]any{
"user_id": participant.UserID,
"participant_type": participant.ParticipantType,
"seat_no": participant.SeatNo,
"status": participant.Status,
"stake_coin": participant.StakeCoin,
"dice_points": participant.DicePoints,
"rps_gesture": strings.TrimSpace(participant.RPSGesture),
"result": strings.TrimSpace(participant.Result),
"payout_coin": participant.PayoutCoin,
"net_win_coin": netWinCoin,
"joined_at_ms": participant.JoinedAtMS,
"updated_at_ms": participant.UpdatedAtMS,
})
}
waitMS, durationMS := int64(0), int64(0)
if match.ReadyAtMS > 0 && match.CreatedAtMS > 0 {
waitMS = match.ReadyAtMS - match.CreatedAtMS
}
finishedAtMS := match.SettledAtMS
if finishedAtMS == 0 {
finishedAtMS = match.CanceledAtMS
}
if finishedAtMS > 0 && match.CreatedAtMS > 0 {
durationMS = finishedAtMS - match.CreatedAtMS
}
return map[string]any{
"match_id": match.MatchID,
"game_id": strings.TrimSpace(match.GameID),
"platform_code": strings.TrimSpace(match.PlatformCode),
"provider_game_id": strings.TrimSpace(match.ProviderGameID),
"room_id": strings.TrimSpace(match.RoomID),
"region_id": match.RegionID,
"stake_coin": match.StakeCoin,
"min_players": match.MinPlayers,
"max_players": match.MaxPlayers,
"current_players": match.CurrentPlayers,
"status": strings.TrimSpace(match.Status),
"result": strings.TrimSpace(match.Result),
"match_mode": strings.TrimSpace(match.MatchMode),
"forced_result": strings.TrimSpace(match.ForcedResult),
"fee_bps": match.FeeBPS,
"pool_bps": match.PoolBPS,
"pool_delta_coin": match.PoolDeltaCoin,
"user_participants": userParticipants,
"robot_participants": robotParticipants,
"winner_count": winners,
"loser_count": losers,
"draw_count": draws,
"user_stake_coin": userStakeCoin,
"robot_stake_coin": robotStakeCoin,
"payout_coin": payoutCoin,
"refund_coin": refundCoin,
"platform_profit_coin": userStakeCoin - payoutCoin - refundCoin,
"join_deadline_ms": match.JoinDeadlineMS,
"ready_at_ms": match.ReadyAtMS,
"created_at_ms": match.CreatedAtMS,
"updated_at_ms": match.UpdatedAtMS,
"settled_at_ms": match.SettledAtMS,
"canceled_at_ms": match.CanceledAtMS,
"wait_ms": waitMS,
"duration_ms": durationMS,
"participants": participants,
"event_recorded_at_ms": nowMs,
}
}
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,

View File

@ -34,6 +34,9 @@ user_service_addr: "user-service:13005"
wallet_service_addr: "wallet-service:13004"
activity_service_addr: "activity-service:13006"
game_service_addr: "game-service:13008"
statistics:
base_url: "http://statistics-service:13010"
timeout: "2s"
grpc_client:
# Docker 本地也使用生产同形态的 deadline、keepalive 和窄重试策略。
default_timeout: "5s"

View File

@ -38,6 +38,9 @@ user_service_addr: "user-service.internal:13005"
wallet_service_addr: "wallet-service.internal:13004"
activity_service_addr: "activity-service.internal:13006"
game_service_addr: "game-service.internal:13008"
statistics:
base_url: "http://statistics-service.internal:13010"
timeout: "2s"
grpc_client:
# 线上通过内网 CLB/DNS 访问内部服务;重试只覆盖瞬时 UNAVAILABLE避免放大业务副作用。
default_timeout: "5s"

View File

@ -34,6 +34,9 @@ user_service_addr: "127.0.0.1:13005"
wallet_service_addr: "127.0.0.1:13004"
activity_service_addr: "127.0.0.1:13006"
game_service_addr: "127.0.0.1:13008"
statistics:
base_url: "http://127.0.0.1:13010"
timeout: "2s"
grpc_client:
# gateway 内部 gRPC 默认 deadline 包含一次 UNAVAILABLE 重试;业务长调用应在 handler 上下文显式收紧或放宽。
default_timeout: "5s"

View File

@ -109,6 +109,7 @@ func New(cfg config.Config) (*App, error) {
var weeklyStarClient client.WeeklyStarClient = client.NewGRPCWeeklyStarClient(activityConn)
var broadcastClient client.BroadcastClient = client.NewGRPCBroadcastClient(activityConn)
var gameClient client.GameClient = client.NewGRPCGameClient(gameConn)
var statisticsClient client.StatisticsClient = client.NewHTTPStatisticsClient(cfg.Statistics.BaseURL, cfg.Statistics.Timeout)
appConfigReader, err := openAppConfigReader(cfg.AppConfig)
if err != nil {
_ = roomConn.Close()
@ -179,6 +180,7 @@ func New(cfg config.Config) (*App, error) {
handler.SetWeeklyStarClient(weeklyStarClient)
handler.SetBroadcastClient(broadcastClient)
handler.SetGameClient(gameClient)
handler.SetStatisticsClient(statisticsClient)
handler.SetLeaderboardWalletDB(leaderboardWalletDB)
if appConfigReader != nil {
handler.SetAppConfigReader(appConfigReader)

View File

@ -0,0 +1,81 @@
package client
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
type SelfGameH5Event struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventName string `json:"event_name"`
GameID string `json:"game_id"`
Page string `json:"page"`
SessionID string `json:"session_id"`
MatchID string `json:"match_id"`
UserID int64 `json:"user_id"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
Language string `json:"language"`
ClientVersion string `json:"client_version"`
H5Version string `json:"h5_version"`
EntrySource string `json:"entry_source"`
DurationMS int64 `json:"duration_ms"`
Success bool `json:"success"`
ErrorCode string `json:"error_code"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
type StatisticsClient interface {
ReportSelfGameH5Event(ctx context.Context, event SelfGameH5Event) error
}
type HTTPStatisticsClient struct {
baseURL string
httpClient *http.Client
}
func NewHTTPStatisticsClient(baseURL string, timeout time.Duration) *HTTPStatisticsClient {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil
}
if timeout <= 0 {
timeout = 2 * time.Second
}
return &HTTPStatisticsClient{
baseURL: baseURL,
httpClient: &http.Client{Timeout: timeout},
}
}
func (c *HTTPStatisticsClient) ReportSelfGameH5Event(ctx context.Context, event SelfGameH5Event) error {
if c == nil || c.httpClient == nil || c.baseURL == "" {
return fmt.Errorf("statistics client is not configured")
}
body, err := json.Marshal(event)
if err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/internal/v1/statistics/self-game/events", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
payload, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("statistics report failed: status=%d body=%s", resp.StatusCode, strings.TrimSpace(string(payload)))
}

View File

@ -26,6 +26,7 @@ type Config struct {
WalletServiceAddr string `yaml:"wallet_service_addr"`
ActivityServiceAddr string `yaml:"activity_service_addr"`
GameServiceAddr string `yaml:"game_service_addr"`
Statistics StatisticsConfig `yaml:"statistics"`
GRPCClient GRPCClientConfig `yaml:"grpc_client"`
AuthRateLimit AuthRateLimitConfig `yaml:"auth_rate_limit"`
LoginRisk LoginRiskConfig `yaml:"login_risk"`
@ -122,6 +123,12 @@ type LeaderboardConfig struct {
WalletMySQLDSN string `yaml:"wallet_mysql_dsn"`
}
// StatisticsConfig 描述 gateway 转发客户端 H5 埋点到 statistics-service 的内部 HTTP 入口。
type StatisticsConfig struct {
BaseURL string `yaml:"base_url"`
Timeout time.Duration `yaml:"timeout"`
}
// AuthRateLimitConfig 控制 gateway public auth 入口的 Redis 频控。
type AuthRateLimitConfig struct {
// Enabled 为 false 时关闭 gateway auth 入口频控。
@ -273,6 +280,10 @@ func Default() Config {
WalletServiceAddr: "127.0.0.1:13004",
ActivityServiceAddr: "127.0.0.1:13006",
GameServiceAddr: "127.0.0.1:13008",
Statistics: StatisticsConfig{
BaseURL: "http://127.0.0.1:13010",
Timeout: 2 * time.Second,
},
GRPCClient: GRPCClientConfig{
DefaultTimeout: 5 * time.Second,
ConnectTimeout: 2 * time.Second,
@ -384,6 +395,7 @@ func (cfg *Config) Normalize() error {
cfg.WalletServiceAddr = strings.TrimSpace(cfg.WalletServiceAddr)
cfg.ActivityServiceAddr = strings.TrimSpace(cfg.ActivityServiceAddr)
cfg.GameServiceAddr = strings.TrimSpace(cfg.GameServiceAddr)
cfg.Statistics.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.Statistics.BaseURL), "/")
if cfg.WalletServiceAddr == "" {
cfg.WalletServiceAddr = "127.0.0.1:13004"
}
@ -393,6 +405,12 @@ func (cfg *Config) Normalize() error {
if cfg.GameServiceAddr == "" {
cfg.GameServiceAddr = "127.0.0.1:13008"
}
if cfg.Statistics.BaseURL == "" {
cfg.Statistics.BaseURL = "http://127.0.0.1:13010"
}
if cfg.Statistics.Timeout <= 0 {
cfg.Statistics.Timeout = 2 * time.Second
}
runtimeGRPC := cfg.GRPCClient.Runtime()
cfg.GRPCClient = GRPCClientConfig{
DefaultTimeout: runtimeGRPC.DefaultTimeout,

View File

@ -30,6 +30,7 @@ type firstRechargeRewardItemData struct {
ResourceType string `json:"resource_type"`
ResourceName string `json:"resource_name"`
CoverURL string `json:"cover_url"`
AssetURL string `json:"asset_url"`
EffectURL string `json:"effect_url"`
DurationMS int64 `json:"duration_ms"`
Quantity int64 `json:"quantity"`
@ -196,8 +197,11 @@ func firstRechargeRewardItemsFromGroup(group checkinResourceGroupData) []firstRe
if item.Resource != nil {
reward.ResourceType = item.Resource.ResourceType
reward.ResourceName = item.Resource.Name
reward.CoverURL = firstRechargeFirstNonEmpty(item.Resource.AssetURL, item.Resource.PreviewURL, item.Resource.AnimationURL)
reward.EffectURL = firstRechargeFirstNonEmpty(item.Resource.AnimationURL, item.Resource.PreviewURL, item.Resource.AssetURL)
// 首充弹窗格子只展示静态封面,点击后才播放资源资产;因此优先把 preview_url 下发为 cover_url
// asset_url 保持资源主资产原值effect_url 继续兜底旧客户端使用的动画预览字段。
reward.CoverURL = firstRechargeFirstNonEmpty(item.Resource.PreviewURL, item.Resource.AssetURL, item.Resource.AnimationURL)
reward.AssetURL = firstRechargeFirstNonEmpty(item.Resource.AssetURL, item.Resource.AnimationURL)
reward.EffectURL = firstRechargeFirstNonEmpty(item.Resource.AnimationURL, item.Resource.AssetURL, item.Resource.PreviewURL)
} else {
reward.ResourceType = firstRechargeWalletAssetType(item.WalletAssetType)
reward.ResourceName = firstRechargeWalletAssetType(item.WalletAssetType)

View File

@ -0,0 +1,33 @@
package activityapi
import "testing"
func TestFirstRechargeRewardItemsSplitCoverImageAndPreviewAsset(t *testing.T) {
items := firstRechargeRewardItemsFromGroup(checkinResourceGroupData{
Items: []checkinResourceGroupItemData{{
ResourceID: 88001,
Resource: &checkinResourceData{
ResourceID: 88001,
ResourceType: "vehicle",
Name: "Rocket Car",
PreviewURL: "https://cdn.example.test/first-recharge/rocket-cover.png",
AssetURL: "https://cdn.example.test/first-recharge/rocket-preview.mp4",
},
Quantity: 1,
DurationMS: 604800000,
}},
})
if len(items) != 1 {
t.Fatalf("reward item count = %d", len(items))
}
if items[0].CoverURL != "https://cdn.example.test/first-recharge/rocket-cover.png" {
t.Fatalf("cover_url should stay static image, got %q", items[0].CoverURL)
}
if items[0].AssetURL != "https://cdn.example.test/first-recharge/rocket-preview.mp4" {
t.Fatalf("asset_url should carry mp4 preview asset, got %q", items[0].AssetURL)
}
if items[0].EffectURL != "https://cdn.example.test/first-recharge/rocket-preview.mp4" {
t.Fatalf("effect_url should keep old preview fallback, got %q", items[0].EffectURL)
}
}

View File

@ -14,6 +14,7 @@ type Handler struct {
userAuthClient client.UserAuthClient
userProfileClient client.UserProfileClient
walletClient client.WalletClient
statisticsClient client.StatisticsClient
// 热游固定回调地址先进入 hyapp gatewaychatapp3 老 token 需要原样转给 chatapp3hyapp token 继续落到 game-service。
hotgameCompatHTTPClient *http.Client
}
@ -23,6 +24,7 @@ type Config struct {
UserAuthClient client.UserAuthClient
UserProfileClient client.UserProfileClient
WalletClient client.WalletClient
StatisticsClient client.StatisticsClient
}
func New(config Config) *Handler {
@ -31,6 +33,7 @@ func New(config Config) *Handler {
userAuthClient: config.UserAuthClient,
userProfileClient: config.UserProfileClient,
walletClient: config.WalletClient,
statisticsClient: config.StatisticsClient,
hotgameCompatHTTPClient: hotgameCompatHTTPClient(),
}
}
@ -56,6 +59,7 @@ func (h *Handler) Handlers() httproutes.GameHandlers {
GetRPSMatch: h.getRPSMatch,
RollRPSMatch: h.rollRPSMatch,
CancelRPSMatch: h.cancelRPSMatch,
ReportSelfGameH5Event: h.reportSelfGameH5Event,
GetRoomRPSConfig: h.getRoomRPSConfig,
ListRoomRPSChallenges: h.listRoomRPSChallenges,
CreateRoomRPSChallenge: h.createRoomRPSChallenge,

View File

@ -0,0 +1,95 @@
package gameapi
import (
"net/http"
"strings"
"time"
userv1 "hyapp.local/api/proto/user/v1"
"hyapp/pkg/appcode"
"hyapp/services/gateway-service/internal/auth"
"hyapp/services/gateway-service/internal/client"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
type selfGameH5EventRequest struct {
EventID string `json:"event_id"`
EventName string `json:"event_name"`
GameID string `json:"game_id"`
Page string `json:"page"`
SessionID string `json:"session_id"`
MatchID string `json:"match_id"`
Language string `json:"language"`
ClientVersion string `json:"client_version"`
H5Version string `json:"h5_version"`
EntrySource string `json:"entry_source"`
DurationMS int64 `json:"duration_ms"`
Success bool `json:"success"`
ErrorCode string `json:"error_code"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
func (h *Handler) reportSelfGameH5Event(writer http.ResponseWriter, request *http.Request) {
if h.statisticsClient == nil || h.userProfileClient == nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
var body selfGameH5EventRequest
if err := httpkit.DecodeJSON(request.Body, &body); err != nil {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidJSON, "invalid request body")
return
}
body.EventID = strings.TrimSpace(body.EventID)
body.EventName = strings.TrimSpace(body.EventName)
body.GameID = strings.TrimSpace(body.GameID)
if body.EventID == "" || body.EventName == "" || body.GameID == "" {
httpkit.WriteError(writer, request, http.StatusBadRequest, httpkit.CodeInvalidArgument, "invalid argument")
return
}
userID := auth.UserIDFromContext(request.Context())
userResp, err := h.userProfileClient.GetUser(request.Context(), &userv1.GetUserRequest{
Meta: httpkit.UserMeta(request, ""),
UserId: userID,
})
if err != nil {
httpkit.WriteRPCError(writer, request, err)
return
}
if body.OccurredAtMS <= 0 {
body.OccurredAtMS = time.Now().UTC().UnixMilli()
}
language := strings.TrimSpace(body.Language)
if language == "" {
language = requestLanguage(request)
}
clientVersion := strings.TrimSpace(body.ClientVersion)
if clientVersion == "" {
clientVersion = httpkit.FirstHeader(request, "X-App-Version", "X-Client-Version")
}
// H5 上报只信任客户端的行为事实和耗时用户、App、国家和区域由 gateway 根据登录态补齐,避免前端伪造维度污染统计口径。
err = h.statisticsClient.ReportSelfGameH5Event(request.Context(), client.SelfGameH5Event{
AppCode: appcode.FromContext(request.Context()),
EventID: body.EventID,
EventName: body.EventName,
GameID: body.GameID,
Page: body.Page,
SessionID: body.SessionID,
MatchID: body.MatchID,
UserID: userID,
CountryID: userResp.GetUser().GetCountryId(),
RegionID: userResp.GetUser().GetRegionId(),
Language: language,
ClientVersion: clientVersion,
H5Version: body.H5Version,
EntrySource: body.EntrySource,
DurationMS: body.DurationMS,
Success: body.Success,
ErrorCode: body.ErrorCode,
OccurredAtMS: body.OccurredAtMS,
})
if err != nil {
httpkit.WriteError(writer, request, http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
httpkit.WriteOK(writer, request, map[string]any{"ok": true})
}

View File

@ -50,6 +50,7 @@ type Handler struct {
weeklyStar client.WeeklyStarClient
broadcastClient client.BroadcastClient
gameClient client.GameClient
statisticsClient client.StatisticsClient
appConfigReader appapi.ConfigReader
leaderboardWalletDB *sql.DB
tencentIM TencentIMConfig
@ -268,6 +269,11 @@ func (h *Handler) SetGameClient(gameClient client.GameClient) {
h.gameClient = gameClient
}
// SetStatisticsClient 注入 statistics-service 内部 HTTP clientgateway 只转发 H5 埋点,不在入口层做统计聚合。
func (h *Handler) SetStatisticsClient(statisticsClient client.StatisticsClient) {
h.statisticsClient = statisticsClient
}
// SetLeaderboardWalletDB 注入 App 榜单只读钱包库连接。
func (h *Handler) SetLeaderboardWalletDB(db *sql.DB) {
h.leaderboardWalletDB = db

View File

@ -272,6 +272,7 @@ type GameHandlers struct {
GetRPSMatch http.HandlerFunc
RollRPSMatch http.HandlerFunc
CancelRPSMatch http.HandlerFunc
ReportSelfGameH5Event http.HandlerFunc
GetRoomRPSConfig http.HandlerFunc
ListRoomRPSChallenges http.HandlerFunc
CreateRoomRPSChallenge http.HandlerFunc
@ -593,6 +594,7 @@ func (r routes) registerGameRoutes() {
r.profile("/games/rps/matches/{match_id}", http.MethodGet, h.GetRPSMatch)
r.profile("/games/rps/matches/{match_id}/roll", http.MethodPost, h.RollRPSMatch)
r.profile("/games/rps/matches/{match_id}/cancel", http.MethodPost, h.CancelRPSMatch)
r.profile("/games/self/report-events", http.MethodPost, h.ReportSelfGameH5Event)
r.profile("/games/room-rps/config", http.MethodGet, h.GetRoomRPSConfig)
r.profile("/games/room-rps/challenges", http.MethodGet, h.ListRoomRPSChallenges)
// 当前 ServeMux 包装按 path 注册,不能把 GET 列表和 POST 创建同时挂在 /challenges创建动作单独放 /create避免路由覆盖。

View File

@ -90,6 +90,7 @@ func (h *Handler) Routes(jwtVerifier *auth.Verifier) http.Handler {
UserAuthClient: h.userClient,
UserProfileClient: h.userProfileClient,
WalletClient: h.walletClient,
StatisticsClient: h.statisticsClient,
})
resourceAPI := resourceapi.New(resourceapi.Config{
WalletClient: h.walletClient,

View File

@ -134,3 +134,126 @@ CREATE TABLE IF NOT EXISTS stat_game_day_players (
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id, user_id),
KEY idx_stat_game_player_region (app_code, stat_day, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏日参与用户去重表';
CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_day (
app_code VARCHAR(32) NOT NULL,
stat_day DATE NOT NULL,
country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0,
game_id VARCHAR(96) NOT NULL,
event_name VARCHAR(64) NOT NULL,
language VARCHAR(16) NOT NULL DEFAULT '',
client_version VARCHAR(64) NOT NULL DEFAULT '',
h5_version VARCHAR(64) NOT NULL DEFAULT '',
entry_source VARCHAR(64) NOT NULL DEFAULT '',
event_count BIGINT NOT NULL DEFAULT 0,
user_count BIGINT NOT NULL DEFAULT 0,
success_count BIGINT NOT NULL DEFAULT 0,
error_count BIGINT NOT NULL DEFAULT 0,
duration_ms_sum BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source),
KEY idx_stat_self_game_h5_day (app_code, stat_day, game_id, event_name),
KEY idx_stat_self_game_h5_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏 H5 事件日统计表';
CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_users (
app_code VARCHAR(32) NOT NULL,
stat_day DATE NOT NULL,
game_id VARCHAR(96) NOT NULL,
event_name VARCHAR(64) NOT NULL,
user_id BIGINT NOT NULL,
first_seen_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, game_id, event_name, user_id),
KEY idx_stat_self_game_h5_users_day (app_code, stat_day, game_id, event_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏 H5 事件用户去重表';
CREATE TABLE IF NOT EXISTS stat_self_game_match_day (
app_code VARCHAR(32) NOT NULL,
stat_day DATE NOT NULL,
country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0,
game_id VARCHAR(96) NOT NULL,
stake_coin BIGINT NOT NULL DEFAULT 0,
created_matches BIGINT NOT NULL DEFAULT 0,
matched_matches BIGINT NOT NULL DEFAULT 0,
settled_matches BIGINT NOT NULL DEFAULT 0,
canceled_matches BIGINT NOT NULL DEFAULT 0,
failed_matches BIGINT NOT NULL DEFAULT 0,
human_matches BIGINT NOT NULL DEFAULT 0,
robot_matches BIGINT NOT NULL DEFAULT 0,
human_participants BIGINT NOT NULL DEFAULT 0,
robot_participants BIGINT NOT NULL DEFAULT 0,
winner_count BIGINT NOT NULL DEFAULT 0,
loser_count BIGINT NOT NULL DEFAULT 0,
draw_count BIGINT NOT NULL DEFAULT 0,
user_stake_coin BIGINT NOT NULL DEFAULT 0,
robot_stake_coin BIGINT NOT NULL DEFAULT 0,
payout_coin BIGINT NOT NULL DEFAULT 0,
refund_coin BIGINT NOT NULL DEFAULT 0,
platform_profit_coin BIGINT NOT NULL DEFAULT 0,
pool_delta_coin BIGINT NOT NULL DEFAULT 0,
forced_player_lose_matches BIGINT NOT NULL DEFAULT 0,
wait_ms_sum BIGINT NOT NULL DEFAULT 0,
duration_ms_sum BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin),
KEY idx_stat_self_game_match_day (app_code, stat_day, game_id),
KEY idx_stat_self_game_match_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏对局日统计表';
CREATE TABLE IF NOT EXISTS stat_self_game_pool_day (
app_code VARCHAR(32) NOT NULL,
stat_day DATE NOT NULL,
game_id VARCHAR(96) NOT NULL,
direction VARCHAR(16) NOT NULL DEFAULT '',
reason VARCHAR(64) NOT NULL DEFAULT '',
transaction_count BIGINT NOT NULL DEFAULT 0,
in_coin BIGINT NOT NULL DEFAULT 0,
out_coin BIGINT NOT NULL DEFAULT 0,
balance_after_coin BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, game_id, direction, reason),
KEY idx_stat_self_game_pool_day (app_code, stat_day, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏奖池日统计表';
CREATE TABLE IF NOT EXISTS stat_self_game_participant_day (
app_code VARCHAR(32) NOT NULL,
stat_day DATE NOT NULL,
country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0,
game_id VARCHAR(96) NOT NULL,
stake_coin BIGINT NOT NULL DEFAULT 0,
participant_type VARCHAR(32) NOT NULL DEFAULT 'user',
result VARCHAR(32) NOT NULL DEFAULT '',
rps_gesture VARCHAR(32) NOT NULL DEFAULT '',
dice_point INT NOT NULL DEFAULT 0,
participant_count BIGINT NOT NULL DEFAULT 0,
payout_coin BIGINT NOT NULL DEFAULT 0,
net_win_coin BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point),
KEY idx_stat_self_game_participant_day (app_code, stat_day, game_id),
KEY idx_stat_self_game_participant_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏参与者结果分布日统计表';
CREATE TABLE IF NOT EXISTS stat_self_game_user_day (
app_code VARCHAR(32) NOT NULL,
stat_day DATE NOT NULL,
country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0,
game_id VARCHAR(96) NOT NULL,
user_id BIGINT NOT NULL,
match_count BIGINT NOT NULL DEFAULT 0,
win_count BIGINT NOT NULL DEFAULT 0,
lose_count BIGINT NOT NULL DEFAULT 0,
draw_count BIGINT NOT NULL DEFAULT 0,
cancel_count BIGINT NOT NULL DEFAULT 0,
stake_coin BIGINT NOT NULL DEFAULT 0,
payout_coin BIGINT NOT NULL DEFAULT 0,
net_win_coin BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, game_id, user_id),
KEY idx_stat_self_game_user_win (app_code, stat_day, game_id, net_win_coin),
KEY idx_stat_self_game_user_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='自研游戏用户风险日统计表';

View File

@ -196,11 +196,21 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
return nil, err
}
if err := add(cfg.RocketMQ.GameOutbox, gamemq.TagGameOutboxEvent, func(ctx context.Context, message rocketmqx.ConsumedMessage) error {
event, ok, err := gameOrderEvent(message.Body)
if err != nil || !ok {
return err
if event, ok, err := gameOrderEvent(message.Body); err != nil || ok {
if err != nil || !ok {
return err
}
return repo.ConsumeGameOrder(appcode.WithContext(ctx, event.AppCode), event)
}
return repo.ConsumeGameOrder(appcode.WithContext(ctx, event.AppCode), event)
match, ok, err := selfGameMatchEvent(message.Body)
if err != nil || !ok {
pool, poolOK, poolErr := selfGamePoolAdjustmentEvent(message.Body)
if poolErr != nil || !poolOK {
return poolErr
}
return repo.ConsumeSelfGamePoolAdjustment(appcode.WithContext(ctx, pool.AppCode), pool)
}
return repo.ConsumeSelfGameMatch(appcode.WithContext(ctx, match.AppCode), match)
}); err != nil {
shutdownConsumers(consumers)
return nil, err
@ -431,6 +441,119 @@ func gameOrderEvent(body []byte) (mysqlstorage.GameOrderEvent, bool, error) {
}, true, nil
}
func selfGameMatchEvent(body []byte) (mysqlstorage.SelfGameMatchEvent, bool, error) {
message, err := gamemq.DecodeGameOutboxMessage(body)
if err != nil {
return mysqlstorage.SelfGameMatchEvent{}, false, err
}
switch message.EventType {
case gamemq.EventTypeSelfGameMatchCreated,
gamemq.EventTypeSelfGameMatchReady,
gamemq.EventTypeSelfGameMatchSettled,
gamemq.EventTypeSelfGameMatchCanceled,
gamemq.EventTypeSelfGameMatchFailed:
default:
return mysqlstorage.SelfGameMatchEvent{}, false, nil
}
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
event := mysqlstorage.SelfGameMatchEvent{
AppCode: message.AppCode,
EventID: message.EventID,
EventType: message.EventType,
MatchID: firstNonEmpty(mysqlstorage.String(payload, "match_id"), message.OrderID),
GameID: firstNonEmpty(mysqlstorage.String(payload, "game_id"), message.GameID),
PlatformCode: firstNonEmpty(mysqlstorage.String(payload, "platform_code"), message.PlatformCode),
RoomID: mysqlstorage.String(payload, "room_id"),
RegionID: mysqlstorage.Int64(payload, "region_id"),
StakeCoin: mysqlstorage.Int64(payload, "stake_coin"),
Status: mysqlstorage.String(payload, "status"),
Result: mysqlstorage.String(payload, "result"),
MatchMode: mysqlstorage.String(payload, "match_mode"),
ForcedResult: mysqlstorage.String(payload, "forced_result"),
UserParticipants: mysqlstorage.Int64(payload, "user_participants"),
RobotParticipants: mysqlstorage.Int64(payload, "robot_participants"),
WinnerCount: mysqlstorage.Int64(payload, "winner_count"),
LoserCount: mysqlstorage.Int64(payload, "loser_count"),
DrawCount: mysqlstorage.Int64(payload, "draw_count"),
UserStakeCoin: mysqlstorage.Int64(payload, "user_stake_coin"),
RobotStakeCoin: mysqlstorage.Int64(payload, "robot_stake_coin"),
PayoutCoin: mysqlstorage.Int64(payload, "payout_coin"),
RefundCoin: mysqlstorage.Int64(payload, "refund_coin"),
PlatformProfitCoin: mysqlstorage.Int64(payload, "platform_profit_coin"),
PoolDeltaCoin: mysqlstorage.Int64(payload, "pool_delta_coin"),
WaitMS: mysqlstorage.Int64(payload, "wait_ms"),
DurationMS: mysqlstorage.Int64(payload, "duration_ms"),
Participants: selfGameParticipants(payload["participants"]),
OccurredAtMS: message.OccurredAtMS,
}
return event, true, nil
}
func selfGamePoolAdjustmentEvent(body []byte) (mysqlstorage.SelfGamePoolAdjustmentEvent, bool, error) {
message, err := gamemq.DecodeGameOutboxMessage(body)
if err != nil {
return mysqlstorage.SelfGamePoolAdjustmentEvent{}, false, err
}
if message.EventType != gamemq.EventTypeSelfGamePoolAdjusted {
return mysqlstorage.SelfGamePoolAdjustmentEvent{}, false, nil
}
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
return mysqlstorage.SelfGamePoolAdjustmentEvent{
AppCode: message.AppCode,
EventID: message.EventID,
GameID: firstNonEmpty(mysqlstorage.String(payload, "game_id"), message.GameID),
Direction: mysqlstorage.String(payload, "direction"),
Reason: mysqlstorage.String(payload, "reason"),
AmountCoin: mysqlstorage.Int64(payload, "amount_coin"),
BalanceAfter: mysqlstorage.Int64(payload, "balance_after"),
OccurredAtMS: message.OccurredAtMS,
}, true, nil
}
func selfGameParticipants(raw any) []mysqlstorage.SelfGameMatchParticipantEvent {
values, ok := raw.([]any)
if !ok {
return nil
}
participants := make([]mysqlstorage.SelfGameMatchParticipantEvent, 0, len(values))
for _, value := range values {
payload, ok := value.(map[string]any)
if !ok {
continue
}
participants = append(participants, mysqlstorage.SelfGameMatchParticipantEvent{
UserID: mysqlstorage.Int64(payload, "user_id"),
ParticipantType: mysqlstorage.String(payload, "participant_type"),
StakeCoin: mysqlstorage.Int64(payload, "stake_coin"),
DicePoints: int64Slice(payload["dice_points"]),
RPSGesture: mysqlstorage.String(payload, "rps_gesture"),
Result: mysqlstorage.String(payload, "result"),
PayoutCoin: mysqlstorage.Int64(payload, "payout_coin"),
NetWinCoin: mysqlstorage.Int64(payload, "net_win_coin"),
})
}
return participants
}
func int64Slice(raw any) []int64 {
values, ok := raw.([]any)
if !ok {
return nil
}
out := make([]int64, 0, len(values))
for _, value := range values {
switch typed := value.(type) {
case float64:
out = append(out, int64(typed))
case int64:
out = append(out, typed)
case int:
out = append(out, int64(typed))
}
}
return out
}
func startConsumers(consumers []*rocketmqx.Consumer) error {
for _, consumer := range consumers {
if err := consumer.Start(); err != nil {

View File

@ -10,6 +10,7 @@ import (
"google.golang.org/protobuf/proto"
roomeventsv1 "hyapp.local/api/proto/events/room/v1"
"hyapp/internal/testutil/mysqlschema"
"hyapp/pkg/appcode"
"hyapp/pkg/gamemq"
"hyapp/pkg/roommq"
"hyapp/pkg/walletmq"
@ -201,6 +202,166 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
}
}
func TestLocalSelfGameStatisticsFlow(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
schema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "../../deploy/mysql/initdb/001_statistics_service.sql"),
DatabasePrefix: "hy_self_game_stats",
})
repo, err := mysqlstorage.Open(ctx, schema.DSN)
if err != nil {
t.Fatalf("open statistics repository failed: %v", err)
}
t.Cleanup(func() { _ = repo.Close() })
occurredAt := time.Date(2026, 6, 11, 12, 0, 0, 0, time.UTC).UnixMilli()
dayStart := time.Date(2026, 6, 11, 0, 0, 0, 0, time.UTC).UnixMilli()
const (
appCode = "lalu"
regionID = int64(210)
userID = int64(42)
)
appCtx := appcode.WithContext(ctx, appCode)
for _, eventName := range []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"} {
if err := repo.ConsumeSelfGameH5Event(appCtx, mysqlstorage.SelfGameH5Event{
AppCode: appCode,
EventID: "h5:" + eventName,
EventName: eventName,
GameID: "dice",
UserID: userID,
RegionID: regionID,
H5Version: "dice-h5-test",
EntrySource: "room",
DurationMS: 20,
Success: true,
OccurredAtMS: occurredAt,
}); err != nil {
t.Fatalf("consume h5 event %s failed: %v", eventName, err)
}
}
body, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
AppCode: appCode,
EventID: "SelfGameMatchSettled:dice_match_1",
EventType: gamemq.EventTypeSelfGameMatchSettled,
OrderID: "",
UserID: userID,
PlatformCode: "self",
GameID: "dice",
PayloadJSON: mustJSON(t, map[string]any{
"match_id": "dice_match_1",
"game_id": "dice",
"platform_code": "self",
"region_id": regionID,
"stake_coin": int64(500),
"status": "settled",
"result": "win",
"match_mode": "robot",
"forced_result": "",
"user_participants": int64(1),
"robot_participants": int64(1),
"winner_count": int64(1),
"loser_count": int64(1),
"draw_count": int64(0),
"user_stake_coin": int64(500),
"robot_stake_coin": int64(500),
"payout_coin": int64(900),
"refund_coin": int64(0),
"platform_profit_coin": int64(100),
"pool_delta_coin": int64(50),
"wait_ms": int64(1200),
"duration_ms": int64(4800),
"participants": []map[string]any{
{
"user_id": userID,
"participant_type": "user",
"stake_coin": int64(500),
"dice_points": []int64{6},
"result": "win",
"payout_coin": int64(900),
"net_win_coin": int64(400),
},
{
"user_id": int64(0),
"participant_type": "robot",
"stake_coin": int64(500),
"dice_points": []int64{2},
"result": "lose",
"payout_coin": int64(0),
"net_win_coin": int64(-500),
},
},
}),
OccurredAtMS: occurredAt,
})
if err != nil {
t.Fatalf("encode self game match failed: %v", err)
}
match, ok, err := selfGameMatchEvent(body)
if err != nil || !ok {
t.Fatalf("decode self game match failed: ok=%v err=%v", ok, err)
}
if err := repo.ConsumeSelfGameMatch(appCtx, match); err != nil {
t.Fatalf("consume self game match failed: %v", err)
}
poolBody, err := gamemq.EncodeGameOutboxMessage(gamemq.GameOutboxMessage{
AppCode: appCode,
EventID: "SelfGamePoolAdjusted:pool_adjust_1",
EventType: gamemq.EventTypeSelfGamePoolAdjusted,
UserID: userID,
PlatformCode: "self",
GameID: "dice",
PayloadJSON: mustJSON(t, map[string]any{
"adjustment_id": "pool_adjust_1",
"game_id": "dice",
"match_id": "dice_match_1",
"user_id": userID,
"direction": "in",
"amount_coin": int64(50),
"reason": "match_pool_in",
"balance_after": int64(1050),
}),
OccurredAtMS: occurredAt,
})
if err != nil {
t.Fatalf("encode self game pool failed: %v", err)
}
pool, ok, err := selfGamePoolAdjustmentEvent(poolBody)
if err != nil || !ok {
t.Fatalf("decode self game pool failed: ok=%v err=%v", ok, err)
}
if err := repo.ConsumeSelfGamePoolAdjustment(appCtx, pool); err != nil {
t.Fatalf("consume self game pool failed: %v", err)
}
overview, err := repo.QuerySelfGameOverview(ctx, mysqlstorage.SelfGameOverviewQuery{
AppCode: appCode,
StartMS: dayStart,
EndMS: dayStart + int64(24*time.Hour/time.Millisecond),
GameID: "dice",
RegionID: regionID,
})
if err != nil {
t.Fatalf("query self game overview failed: %v", err)
}
if overview.Conversion.PageToMatchClickRate != 1 || overview.Conversion.MatchClickToSuccessRate != 1 || overview.Conversion.SuccessToSettlementRate != 1 || overview.Conversion.SettlementToPlayAgainRate != 1 {
t.Fatalf("self game funnel mismatch: %+v", overview.Conversion)
}
if overview.MatchTotals.SettledMatches != 1 || overview.MatchTotals.RobotMatches != 1 || overview.MatchTotals.UserStakeCoin != 500 || overview.MatchTotals.PayoutCoin != 900 || overview.MatchTotals.PlatformProfitCoin != 100 || overview.MatchTotals.PoolDeltaCoin != 50 {
t.Fatalf("self game match totals mismatch: %+v", overview.MatchTotals)
}
if len(overview.StakeBreakdown) != 1 || overview.StakeBreakdown[0].StakeCoin != 500 || overview.StakeBreakdown[0].PlatformProfitCoin != 100 {
t.Fatalf("self game stake breakdown mismatch: %+v", overview.StakeBreakdown)
}
if len(overview.PoolStats) != 1 || overview.PoolStats[0].InCoin != 50 || overview.PoolStats[0].BalanceAfterCoin != 1050 {
t.Fatalf("self game pool stats mismatch: %+v", overview.PoolStats)
}
if len(overview.UserRisk) != 1 || overview.UserRisk[0].UserID != userID || overview.UserRisk[0].WinCount != 1 || overview.UserRisk[0].NetWinCoin != 400 {
t.Fatalf("self game user risk mismatch: %+v", overview.UserRisk)
}
}
func walletMessage(t *testing.T, message walletmq.WalletOutboxMessage) []byte {
t.Helper()
body, err := walletmq.EncodeWalletOutboxMessage(message)

View File

@ -10,6 +10,7 @@ import (
"strings"
"time"
"hyapp/pkg/appcode"
mysqlstorage "hyapp/services/statistics-service/internal/storage/mysql"
)
@ -31,6 +32,8 @@ func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPS
s := &queryHTTPServer{repo: repo, listener: listener}
mux := http.NewServeMux()
mux.HandleFunc("/internal/v1/statistics/overview", s.overview)
mux.HandleFunc("/internal/v1/statistics/self-game/events", s.selfGameEvents)
mux.HandleFunc("/internal/v1/statistics/self-games/overview", s.selfGameOverview)
s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}
return s, nil
}
@ -80,6 +83,105 @@ func (s *queryHTTPServer) overview(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, overview)
}
type selfGameH5EventRequest struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`
EventName string `json:"event_name"`
GameID string `json:"game_id"`
Page string `json:"page"`
SessionID string `json:"session_id"`
MatchID string `json:"match_id"`
UserID int64 `json:"user_id"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
Language string `json:"language"`
ClientVersion string `json:"client_version"`
H5Version string `json:"h5_version"`
EntrySource string `json:"entry_source"`
DurationMS int64 `json:"duration_ms"`
Success bool `json:"success"`
ErrorCode string `json:"error_code"`
OccurredAtMS int64 `json:"occurred_at_ms"`
}
func (s *queryHTTPServer) selfGameEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
return
}
defer r.Body.Close()
var body selfGameH5EventRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid json"})
return
}
body.EventID = strings.TrimSpace(body.EventID)
body.EventName = strings.TrimSpace(body.EventName)
if body.EventID == "" || body.EventName == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "event_id and event_name are required"})
return
}
if body.OccurredAtMS <= 0 {
body.OccurredAtMS = time.Now().UTC().UnixMilli()
}
app := appcode.Normalize(body.AppCode)
if app == "" {
app = "lalu"
}
// 内部 ingest 只承接 gateway 已解析过的用户、App 和维度字段statistics-service 只做幂等聚合,不反查业务库。
err := s.repo.ConsumeSelfGameH5Event(appcode.WithContext(r.Context(), app), mysqlstorage.SelfGameH5Event{
AppCode: app,
EventID: body.EventID,
EventName: body.EventName,
GameID: body.GameID,
Page: body.Page,
SessionID: body.SessionID,
MatchID: body.MatchID,
UserID: body.UserID,
CountryID: body.CountryID,
RegionID: body.RegionID,
Language: body.Language,
ClientVersion: body.ClientVersion,
H5Version: body.H5Version,
EntrySource: body.EntrySource,
DurationMS: body.DurationMS,
Success: body.Success,
ErrorCode: body.ErrorCode,
OccurredAtMS: body.OccurredAtMS,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (s *queryHTTPServer) selfGameOverview(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
startMS := parseInt64(query.Get("start_ms"))
endMS := parseInt64(query.Get("end_ms"))
if startMS <= 0 {
now := time.Now().UTC()
startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
}
if endMS <= startMS {
endMS = startMS + 24*time.Hour.Milliseconds()
}
overview, err := s.repo.QuerySelfGameOverview(r.Context(), mysqlstorage.SelfGameOverviewQuery{
AppCode: query.Get("app_code"),
StartMS: startMS,
EndMS: endMS,
GameID: query.Get("game_id"),
CountryID: parseInt64(query.Get("country_id")),
RegionID: parseInt64(query.Get("region_id")),
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, overview)
}
func parseInt64(value string) int64 {
parsed, _ := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
return parsed

View File

@ -155,6 +155,144 @@ type CountryBreakdown struct {
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
}
type SelfGameOverviewQuery struct {
AppCode string
StartMS int64
EndMS int64
GameID string
CountryID int64
RegionID int64
}
type SelfGameOverview struct {
AppCode string `json:"app_code"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
GameID string `json:"game_id"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
Events []SelfGameEventStat `json:"events"`
MatchTotals SelfGameMatchTotals `json:"match_totals"`
StakeBreakdown []SelfGameStakeStat `json:"stake_breakdown"`
PoolStats []SelfGamePoolStat `json:"pool_stats"`
ParticipantDistribution []SelfGameParticipantStat `json:"participant_distribution"`
UserRisk []SelfGameUserRiskStat `json:"user_risk"`
Funnel []SelfGameFunnelStep `json:"funnel"`
Conversion SelfGameConversion `json:"conversion"`
}
type SelfGameEventStat struct {
GameID string `json:"game_id"`
EventName string `json:"event_name"`
EventCount int64 `json:"event_count"`
UserCount int64 `json:"user_count"`
SuccessCount int64 `json:"success_count"`
ErrorCount int64 `json:"error_count"`
DurationMSSum int64 `json:"duration_ms_sum"`
AvgDurationMS int64 `json:"avg_duration_ms"`
}
type SelfGameMatchTotals struct {
CreatedMatches int64 `json:"created_matches"`
MatchedMatches int64 `json:"matched_matches"`
SettledMatches int64 `json:"settled_matches"`
CanceledMatches int64 `json:"canceled_matches"`
FailedMatches int64 `json:"failed_matches"`
HumanMatches int64 `json:"human_matches"`
RobotMatches int64 `json:"robot_matches"`
HumanParticipants int64 `json:"human_participants"`
RobotParticipants int64 `json:"robot_participants"`
WinnerCount int64 `json:"winner_count"`
LoserCount int64 `json:"loser_count"`
DrawCount int64 `json:"draw_count"`
UserStakeCoin int64 `json:"user_stake_coin"`
RobotStakeCoin int64 `json:"robot_stake_coin"`
PayoutCoin int64 `json:"payout_coin"`
RefundCoin int64 `json:"refund_coin"`
PlatformProfitCoin int64 `json:"platform_profit_coin"`
PoolDeltaCoin int64 `json:"pool_delta_coin"`
ForcedPlayerLoseMatches int64 `json:"forced_player_lose_matches"`
WaitMSSum int64 `json:"wait_ms_sum"`
DurationMSSum int64 `json:"duration_ms_sum"`
AvgWaitMS int64 `json:"avg_wait_ms"`
AvgDurationMS int64 `json:"avg_duration_ms"`
CancelRate float64 `json:"cancel_rate"`
FailRate float64 `json:"fail_rate"`
RobotMatchRate float64 `json:"robot_match_rate"`
PlatformProfitRate float64 `json:"platform_profit_rate"`
}
type SelfGameStakeStat struct {
GameID string `json:"game_id"`
StakeCoin int64 `json:"stake_coin"`
CreatedMatches int64 `json:"created_matches"`
MatchedMatches int64 `json:"matched_matches"`
SettledMatches int64 `json:"settled_matches"`
CanceledMatches int64 `json:"canceled_matches"`
FailedMatches int64 `json:"failed_matches"`
HumanParticipants int64 `json:"human_participants"`
RobotParticipants int64 `json:"robot_participants"`
UserStakeCoin int64 `json:"user_stake_coin"`
RobotStakeCoin int64 `json:"robot_stake_coin"`
PayoutCoin int64 `json:"payout_coin"`
RefundCoin int64 `json:"refund_coin"`
PlatformProfitCoin int64 `json:"platform_profit_coin"`
PoolDeltaCoin int64 `json:"pool_delta_coin"`
PlatformProfitRate float64 `json:"platform_profit_rate"`
}
type SelfGamePoolStat struct {
GameID string `json:"game_id"`
Direction string `json:"direction"`
Reason string `json:"reason"`
TransactionCount int64 `json:"transaction_count"`
InCoin int64 `json:"in_coin"`
OutCoin int64 `json:"out_coin"`
BalanceAfterCoin int64 `json:"balance_after_coin"`
}
type SelfGameParticipantStat struct {
GameID string `json:"game_id"`
StakeCoin int64 `json:"stake_coin"`
ParticipantType string `json:"participant_type"`
Result string `json:"result"`
RPSGesture string `json:"rps_gesture"`
DicePoint int64 `json:"dice_point"`
ParticipantCount int64 `json:"participant_count"`
PayoutCoin int64 `json:"payout_coin"`
NetWinCoin int64 `json:"net_win_coin"`
WinRate float64 `json:"win_rate"`
}
type SelfGameUserRiskStat struct {
GameID string `json:"game_id"`
UserID int64 `json:"user_id"`
MatchCount int64 `json:"match_count"`
WinCount int64 `json:"win_count"`
LoseCount int64 `json:"lose_count"`
DrawCount int64 `json:"draw_count"`
CancelCount int64 `json:"cancel_count"`
StakeCoin int64 `json:"stake_coin"`
PayoutCoin int64 `json:"payout_coin"`
NetWinCoin int64 `json:"net_win_coin"`
WinRate float64 `json:"win_rate"`
CancelRate float64 `json:"cancel_rate"`
}
type SelfGameFunnelStep struct {
EventName string `json:"event_name"`
EventCount int64 `json:"event_count"`
UserCount int64 `json:"user_count"`
}
type SelfGameConversion struct {
PageToMatchClickRate float64 `json:"page_to_match_click_rate"`
MatchClickToSuccessRate float64 `json:"match_click_to_success_rate"`
SuccessToSettlementRate float64 `json:"success_to_settlement_rate"`
SettlementToPlayAgainRate float64 `json:"settlement_to_play_again_rate"`
PageToSettlementRate float64 `json:"page_to_settlement_rate"`
}
func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) {
app := appcode.Normalize(query.AppCode)
if app == "" {
@ -233,6 +371,57 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
return overview, nil
}
func (r *Repository) QuerySelfGameOverview(ctx context.Context, query SelfGameOverviewQuery) (SelfGameOverview, error) {
startMS, endMS := normalizeRange(query.StartMS, query.EndMS)
app := appcode.Normalize(query.AppCode)
if app == "" {
app = "lalu"
}
startDay := statDay(startMS)
endDay := statDay(endMS - 1)
gameID := normalizeShortText(query.GameID)
overview := SelfGameOverview{
AppCode: app,
StartMS: startMS,
EndMS: endMS,
GameID: gameID,
CountryID: query.CountryID,
RegionID: query.RegionID,
}
events, err := r.querySelfGameEvents(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.Events = events
overview.Funnel, overview.Conversion = buildSelfGameFunnel(events)
totals, err := r.querySelfGameMatchTotals(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.MatchTotals = totals
stakes, err := r.querySelfGameStakeBreakdown(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.StakeBreakdown = stakes
pools, err := r.querySelfGamePools(ctx, app, startDay, endDay, gameID)
if err != nil {
return SelfGameOverview{}, err
}
overview.PoolStats = pools
participants, err := r.querySelfGameParticipantDistribution(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.ParticipantDistribution = participants
risk, err := r.querySelfGameUserRisk(ctx, app, startDay, endDay, gameID, query.CountryID, query.RegionID)
if err != nil {
return SelfGameOverview{}, err
}
overview.UserRisk = risk
return overview, nil
}
func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
args := []any{app, startDay, endDay}
filter := ""
@ -701,6 +890,232 @@ func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
return turnover, payout
}
func (r *Repository) querySelfGameEvents(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameEventStat, error) {
filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, event_name, COALESCE(SUM(event_count),0), COALESCE(SUM(user_count),0),
COALESCE(SUM(success_count),0), COALESCE(SUM(error_count),0), COALESCE(SUM(duration_ms_sum),0)
FROM stat_self_game_h5_event_day
WHERE `+filter+`
GROUP BY game_id, event_name
ORDER BY game_id, event_name`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGameEventStat{}
for rows.Next() {
var item SelfGameEventStat
if err := rows.Scan(&item.GameID, &item.EventName, &item.EventCount, &item.UserCount, &item.SuccessCount, &item.ErrorCount, &item.DurationMSSum); err != nil {
return nil, err
}
item.AvgDurationMS = div(item.DurationMSSum, item.EventCount)
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) querySelfGameMatchTotals(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) (SelfGameMatchTotals, error) {
filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID)
row := r.db.QueryRowContext(ctx, `
SELECT COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0), COALESCE(SUM(settled_matches),0),
COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0), COALESCE(SUM(human_matches),0),
COALESCE(SUM(robot_matches),0), COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0),
COALESCE(SUM(winner_count),0), COALESCE(SUM(loser_count),0), COALESCE(SUM(draw_count),0),
COALESCE(SUM(user_stake_coin),0), COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0),
COALESCE(SUM(refund_coin),0), COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0),
COALESCE(SUM(forced_player_lose_matches),0), COALESCE(SUM(wait_ms_sum),0), COALESCE(SUM(duration_ms_sum),0)
FROM stat_self_game_match_day
WHERE `+filter, args...)
var totals SelfGameMatchTotals
if err := row.Scan(&totals.CreatedMatches, &totals.MatchedMatches, &totals.SettledMatches, &totals.CanceledMatches, &totals.FailedMatches, &totals.HumanMatches, &totals.RobotMatches, &totals.HumanParticipants, &totals.RobotParticipants, &totals.WinnerCount, &totals.LoserCount, &totals.DrawCount, &totals.UserStakeCoin, &totals.RobotStakeCoin, &totals.PayoutCoin, &totals.RefundCoin, &totals.PlatformProfitCoin, &totals.PoolDeltaCoin, &totals.ForcedPlayerLoseMatches, &totals.WaitMSSum, &totals.DurationMSSum); err != nil {
return SelfGameMatchTotals{}, err
}
applySelfGameMatchDerived(&totals)
return totals, nil
}
func (r *Repository) querySelfGameStakeBreakdown(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameStakeStat, error) {
filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, stake_coin, COALESCE(SUM(created_matches),0), COALESCE(SUM(matched_matches),0),
COALESCE(SUM(settled_matches),0), COALESCE(SUM(canceled_matches),0), COALESCE(SUM(failed_matches),0),
COALESCE(SUM(human_participants),0), COALESCE(SUM(robot_participants),0), COALESCE(SUM(user_stake_coin),0),
COALESCE(SUM(robot_stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(refund_coin),0),
COALESCE(SUM(platform_profit_coin),0), COALESCE(SUM(pool_delta_coin),0)
FROM stat_self_game_match_day
WHERE `+filter+`
GROUP BY game_id, stake_coin
ORDER BY game_id, stake_coin`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGameStakeStat{}
for rows.Next() {
var item SelfGameStakeStat
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.CreatedMatches, &item.MatchedMatches, &item.SettledMatches, &item.CanceledMatches, &item.FailedMatches, &item.HumanParticipants, &item.RobotParticipants, &item.UserStakeCoin, &item.RobotStakeCoin, &item.PayoutCoin, &item.RefundCoin, &item.PlatformProfitCoin, &item.PoolDeltaCoin); err != nil {
return nil, err
}
item.PlatformProfitRate = ratio(item.PlatformProfitCoin, item.UserStakeCoin+item.RobotStakeCoin)
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) querySelfGamePools(ctx context.Context, app, startDay, endDay, gameID string) ([]SelfGamePoolStat, error) {
filter := "app_code = ? AND stat_day BETWEEN ? AND ?"
args := []any{app, startDay, endDay}
if gameID != "" {
filter += " AND game_id = ?"
args = append(args, gameID)
}
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, direction, reason, COALESCE(SUM(transaction_count),0),
COALESCE(SUM(in_coin),0), COALESCE(SUM(out_coin),0), COALESCE(MAX(balance_after_coin),0)
FROM stat_self_game_pool_day
WHERE `+filter+`
GROUP BY game_id, direction, reason
ORDER BY game_id, direction, reason`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGamePoolStat{}
for rows.Next() {
var item SelfGamePoolStat
if err := rows.Scan(&item.GameID, &item.Direction, &item.Reason, &item.TransactionCount, &item.InCoin, &item.OutCoin, &item.BalanceAfterCoin); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func (r *Repository) querySelfGameParticipantDistribution(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameParticipantStat, error) {
filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, stake_coin, participant_type, result, rps_gesture, dice_point,
COALESCE(SUM(participant_count),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
FROM stat_self_game_participant_day
WHERE `+filter+`
GROUP BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point
ORDER BY game_id, stake_coin, participant_type, result, rps_gesture, dice_point`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SelfGameParticipantStat{}
totals := map[string]int64{}
for rows.Next() {
var item SelfGameParticipantStat
if err := rows.Scan(&item.GameID, &item.StakeCoin, &item.ParticipantType, &item.Result, &item.RPSGesture, &item.DicePoint, &item.ParticipantCount, &item.PayoutCoin, &item.NetWinCoin); err != nil {
return nil, err
}
key := item.GameID + "\x00" + item.ParticipantType + "\x00" + item.RPSGesture
if item.DicePoint > 0 {
key = item.GameID + "\x00" + item.ParticipantType + "\x00" + string(rune(item.DicePoint))
}
totals[key] += item.ParticipantCount
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
for index := range items {
key := items[index].GameID + "\x00" + items[index].ParticipantType + "\x00" + items[index].RPSGesture
if items[index].DicePoint > 0 {
key = items[index].GameID + "\x00" + items[index].ParticipantType + "\x00" + string(rune(items[index].DicePoint))
}
if items[index].Result == "win" {
items[index].WinRate = ratio(items[index].ParticipantCount, totals[key])
}
}
return items, nil
}
func (r *Repository) querySelfGameUserRisk(ctx context.Context, app, startDay, endDay, gameID string, countryID int64, regionID int64) ([]SelfGameUserRiskStat, error) {
filter, args := selfGameFilter(app, startDay, endDay, gameID, countryID, regionID)
rows, err := r.db.QueryContext(ctx, `
SELECT game_id, user_id, COALESCE(SUM(match_count),0), COALESCE(SUM(win_count),0),
COALESCE(SUM(lose_count),0), COALESCE(SUM(draw_count),0), COALESCE(SUM(cancel_count),0),
COALESCE(SUM(stake_coin),0), COALESCE(SUM(payout_coin),0), COALESCE(SUM(net_win_coin),0)
FROM stat_self_game_user_day
WHERE `+filter+`
GROUP BY game_id, user_id
ORDER BY SUM(match_count) DESC, SUM(net_win_coin) DESC, SUM(cancel_count) DESC
LIMIT 100`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []SelfGameUserRiskStat{}
for rows.Next() {
var item SelfGameUserRiskStat
if err := rows.Scan(&item.GameID, &item.UserID, &item.MatchCount, &item.WinCount, &item.LoseCount, &item.DrawCount, &item.CancelCount, &item.StakeCoin, &item.PayoutCoin, &item.NetWinCoin); err != nil {
return nil, err
}
item.WinRate = ratio(item.WinCount, item.MatchCount)
item.CancelRate = ratio(item.CancelCount, item.MatchCount+item.CancelCount)
out = append(out, item)
}
return out, rows.Err()
}
func selfGameFilter(app, startDay, endDay, gameID string, countryID int64, regionID int64) (string, []any) {
filter := "app_code = ? AND stat_day BETWEEN ? AND ?"
args := []any{app, startDay, endDay}
if gameID != "" {
filter += " AND game_id = ?"
args = append(args, gameID)
}
if countryID > 0 {
filter += " AND country_id = ?"
args = append(args, countryID)
}
if regionID > 0 {
filter += " AND region_id = ?"
args = append(args, regionID)
}
return filter, args
}
func applySelfGameMatchDerived(totals *SelfGameMatchTotals) {
if totals == nil {
return
}
totals.AvgWaitMS = div(totals.WaitMSSum, totals.MatchedMatches+totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
totals.AvgDurationMS = div(totals.DurationMSSum, totals.SettledMatches+totals.CanceledMatches+totals.FailedMatches)
totals.CancelRate = ratio(totals.CanceledMatches, totals.CreatedMatches)
totals.FailRate = ratio(totals.FailedMatches, totals.CreatedMatches)
totals.RobotMatchRate = ratio(totals.RobotMatches, totals.SettledMatches)
totals.PlatformProfitRate = ratio(totals.PlatformProfitCoin, totals.UserStakeCoin+totals.RobotStakeCoin)
}
func buildSelfGameFunnel(events []SelfGameEventStat) ([]SelfGameFunnelStep, SelfGameConversion) {
order := []string{"page_open", "match_click", "match_success", "settlement_show", "play_again"}
byEvent := map[string]SelfGameFunnelStep{}
for _, event := range events {
step := byEvent[event.EventName]
step.EventName = event.EventName
step.EventCount += event.EventCount
step.UserCount += event.UserCount
byEvent[event.EventName] = step
}
funnel := make([]SelfGameFunnelStep, 0, len(order))
for _, name := range order {
funnel = append(funnel, byEvent[name])
funnel[len(funnel)-1].EventName = name
}
return funnel, SelfGameConversion{
PageToMatchClickRate: ratio(byEvent["match_click"].UserCount, byEvent["page_open"].UserCount),
MatchClickToSuccessRate: ratio(byEvent["match_success"].UserCount, byEvent["match_click"].UserCount),
SuccessToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["match_success"].UserCount),
SettlementToPlayAgainRate: ratio(byEvent["play_again"].UserCount, byEvent["settlement_show"].UserCount),
PageToSettlementRate: ratio(byEvent["settlement_show"].UserCount, byEvent["page_open"].UserCount),
}
}
func normalizeRange(startMS, endMS int64) (int64, int64) {
if startMS <= 0 {
now := time.Now().UTC()

View File

@ -18,6 +18,7 @@ const (
SourceWallet = "wallet"
SourceRoom = "room"
SourceGame = "game"
SourceH5 = "h5"
)
// Repository owns the statistics read model. Online queries must use aggregate
@ -171,6 +172,74 @@ func (r *Repository) Migrate(ctx context.Context) error {
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id, user_id),
KEY idx_stat_game_player_region (app_code, stat_day, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_day (
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, event_name VARCHAR(64) NOT NULL,
language VARCHAR(16) NOT NULL DEFAULT '', client_version VARCHAR(64) NOT NULL DEFAULT '',
h5_version VARCHAR(64) NOT NULL DEFAULT '', entry_source VARCHAR(64) NOT NULL DEFAULT '',
event_count BIGINT NOT NULL DEFAULT 0, user_count BIGINT NOT NULL DEFAULT 0,
success_count BIGINT NOT NULL DEFAULT 0, error_count BIGINT NOT NULL DEFAULT 0,
duration_ms_sum BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source),
KEY idx_stat_self_game_h5_day (app_code, stat_day, game_id, event_name),
KEY idx_stat_self_game_h5_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_self_game_h5_event_users (
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL,
event_name VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, first_seen_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, game_id, event_name, user_id),
KEY idx_stat_self_game_h5_users_day (app_code, stat_day, game_id, event_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_self_game_match_day (
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, stake_coin BIGINT NOT NULL DEFAULT 0,
created_matches BIGINT NOT NULL DEFAULT 0, matched_matches BIGINT NOT NULL DEFAULT 0,
settled_matches BIGINT NOT NULL DEFAULT 0, canceled_matches BIGINT NOT NULL DEFAULT 0,
failed_matches BIGINT NOT NULL DEFAULT 0, human_matches BIGINT NOT NULL DEFAULT 0,
robot_matches BIGINT NOT NULL DEFAULT 0, human_participants BIGINT NOT NULL DEFAULT 0,
robot_participants BIGINT NOT NULL DEFAULT 0, winner_count BIGINT NOT NULL DEFAULT 0,
loser_count BIGINT NOT NULL DEFAULT 0, draw_count BIGINT NOT NULL DEFAULT 0,
user_stake_coin BIGINT NOT NULL DEFAULT 0, robot_stake_coin BIGINT NOT NULL DEFAULT 0,
payout_coin BIGINT NOT NULL DEFAULT 0, refund_coin BIGINT NOT NULL DEFAULT 0,
platform_profit_coin BIGINT NOT NULL DEFAULT 0, pool_delta_coin BIGINT NOT NULL DEFAULT 0,
forced_player_lose_matches BIGINT NOT NULL DEFAULT 0, wait_ms_sum BIGINT NOT NULL DEFAULT 0,
duration_ms_sum BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin),
KEY idx_stat_self_game_match_day (app_code, stat_day, game_id),
KEY idx_stat_self_game_match_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_self_game_pool_day (
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, game_id VARCHAR(96) NOT NULL,
direction VARCHAR(16) NOT NULL DEFAULT '', reason VARCHAR(64) NOT NULL DEFAULT '',
transaction_count BIGINT NOT NULL DEFAULT 0, in_coin BIGINT NOT NULL DEFAULT 0,
out_coin BIGINT NOT NULL DEFAULT 0, balance_after_coin BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, game_id, direction, reason),
KEY idx_stat_self_game_pool_day (app_code, stat_day, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_self_game_participant_day (
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, stake_coin BIGINT NOT NULL DEFAULT 0,
participant_type VARCHAR(32) NOT NULL DEFAULT 'user', result VARCHAR(32) NOT NULL DEFAULT '',
rps_gesture VARCHAR(32) NOT NULL DEFAULT '', dice_point INT NOT NULL DEFAULT 0,
participant_count BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0,
net_win_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point),
KEY idx_stat_self_game_participant_day (app_code, stat_day, game_id),
KEY idx_stat_self_game_participant_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_self_game_user_day (
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0, game_id VARCHAR(96) NOT NULL, user_id BIGINT NOT NULL,
match_count BIGINT NOT NULL DEFAULT 0, win_count BIGINT NOT NULL DEFAULT 0,
lose_count BIGINT NOT NULL DEFAULT 0, draw_count BIGINT NOT NULL DEFAULT 0,
cancel_count BIGINT NOT NULL DEFAULT 0, stake_coin BIGINT NOT NULL DEFAULT 0,
payout_coin BIGINT NOT NULL DEFAULT 0, net_win_coin BIGINT NOT NULL DEFAULT 0,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_day, game_id, user_id),
KEY idx_stat_self_game_user_win (app_code, stat_day, game_id, net_win_coin),
KEY idx_stat_self_game_user_region (app_code, stat_day, region_id, game_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
}
for _, statement := range statements {
if _, err := r.db.ExecContext(ctx, statement); err != nil {
@ -340,6 +409,80 @@ type GameOrderEvent struct {
OccurredAtMS int64
}
type SelfGameH5Event struct {
AppCode string
EventID string
EventName string
GameID string
Page string
SessionID string
MatchID string
UserID int64
CountryID int64
RegionID int64
Language string
ClientVersion string
H5Version string
EntrySource string
DurationMS int64
Success bool
ErrorCode string
OccurredAtMS int64
}
type SelfGameMatchParticipantEvent struct {
UserID int64
ParticipantType string
StakeCoin int64
DicePoints []int64
RPSGesture string
Result string
PayoutCoin int64
NetWinCoin int64
}
type SelfGameMatchEvent struct {
AppCode string
EventID string
EventType string
MatchID string
GameID string
PlatformCode string
RoomID string
RegionID int64
StakeCoin int64
Status string
Result string
MatchMode string
ForcedResult string
UserParticipants int64
RobotParticipants int64
WinnerCount int64
LoserCount int64
DrawCount int64
UserStakeCoin int64
RobotStakeCoin int64
PayoutCoin int64
RefundCoin int64
PlatformProfitCoin int64
PoolDeltaCoin int64
WaitMS int64
DurationMS int64
Participants []SelfGameMatchParticipantEvent
OccurredAtMS int64
}
type SelfGamePoolAdjustmentEvent struct {
AppCode string
EventID string
GameID string
Direction string
Reason string
AmountCoin int64
BalanceAfter int64
OccurredAtMS int64
}
func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
return r.withEvent(ctx, SourceUser, event.EventID, "UserRegistered", func(tx *sql.Tx, nowMS int64) error {
day := statDay(event.OccurredAtMS)
@ -558,6 +701,274 @@ func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent)
})
}
func (r *Repository) ConsumeSelfGameH5Event(ctx context.Context, event SelfGameH5Event) error {
eventName := normalizeShortText(event.EventName)
if eventName == "" {
eventName = "unknown"
}
return r.withEvent(ctx, SourceH5, event.EventID, eventName, func(tx *sql.Tx, nowMS int64) error {
day := statDay(event.OccurredAtMS)
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
gameID := normalizeShortText(event.GameID)
if gameID == "" {
gameID = "unknown"
}
language := normalizeShortText(event.Language)
clientVersion := normalizeShortText(event.ClientVersion)
h5Version := normalizeShortText(event.H5Version)
entrySource := normalizeShortText(event.EntrySource)
if err := ensureSelfGameH5EventDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource, nowMS); err != nil {
return err
}
userDelta := int64(0)
if event.UserID > 0 {
affected, err := insertUnique(ctx, tx, `
INSERT IGNORE INTO stat_self_game_h5_event_users (app_code, stat_day, game_id, event_name, user_id, first_seen_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
`, appcode.Normalize(event.AppCode), day, gameID, eventName, event.UserID, event.OccurredAtMS)
if err != nil {
return err
}
userDelta = affected
}
successDelta, errorDelta := int64(0), int64(0)
if event.Success {
successDelta = 1
}
if normalizeShortText(event.ErrorCode) != "" {
errorDelta = 1
}
// H5 事件按 event_id 做幂等PV 用事件次数UV 用同日同玩法同事件的用户去重,接口耗时只累加客户端实际传入的 duration。
_, err := tx.ExecContext(ctx, `
UPDATE stat_self_game_h5_event_day
SET event_count = event_count + 1,
user_count = user_count + ?,
success_count = success_count + ?,
error_count = error_count + ?,
duration_ms_sum = duration_ms_sum + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ?
AND game_id = ? AND event_name = ? AND language = ? AND client_version = ?
AND h5_version = ? AND entry_source = ?
`, userDelta, successDelta, errorDelta, normalizeID(event.DurationMS), nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource)
return err
})
}
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)
gameID := normalizeShortText(event.GameID)
if gameID == "" {
gameID = "unknown"
}
stakeCoin := normalizeID(event.StakeCoin)
if err := ensureSelfGameMatchDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil {
return err
}
created, matched, settled, canceled, failed := selfGameMatchCounters(event.EventType)
humanMatch, robotMatch := int64(0), int64(0)
humanParticipants, robotParticipants := int64(0), int64(0)
userStake, robotStake, payout, refund, platformProfit, poolDelta := int64(0), int64(0), int64(0), int64(0), int64(0), int64(0)
winners, losers, draws := int64(0), int64(0), int64(0)
forcedLose, waitMS, durationMS := int64(0), int64(0), int64(0)
if settled > 0 {
if event.UserParticipants > 0 {
humanMatch = 1
}
if event.RobotParticipants > 0 {
robotMatch = 1
}
humanParticipants = normalizeID(event.UserParticipants)
robotParticipants = normalizeID(event.RobotParticipants)
winners = normalizeID(event.WinnerCount)
losers = normalizeID(event.LoserCount)
draws = normalizeID(event.DrawCount)
userStake = normalizeID(event.UserStakeCoin)
robotStake = normalizeID(event.RobotStakeCoin)
payout = normalizeID(event.PayoutCoin)
refund = normalizeID(event.RefundCoin)
platformProfit = event.PlatformProfitCoin
poolDelta = event.PoolDeltaCoin
waitMS = normalizeID(event.WaitMS)
durationMS = normalizeID(event.DurationMS)
if strings.EqualFold(strings.TrimSpace(event.ForcedResult), "player_lose") {
forcedLose = 1
}
}
if canceled > 0 || failed > 0 || matched > 0 {
waitMS = normalizeID(event.WaitMS)
durationMS = normalizeID(event.DurationMS)
}
// 对局状态事件只按 match 级事实累加一次;明细用户维度只在结算/取消时落表,避免创建和 ready 阶段重复计算下注与输赢。
if _, err := tx.ExecContext(ctx, `
UPDATE stat_self_game_match_day
SET created_matches = created_matches + ?,
matched_matches = matched_matches + ?,
settled_matches = settled_matches + ?,
canceled_matches = canceled_matches + ?,
failed_matches = failed_matches + ?,
human_matches = human_matches + ?,
robot_matches = robot_matches + ?,
human_participants = human_participants + ?,
robot_participants = robot_participants + ?,
winner_count = winner_count + ?,
loser_count = loser_count + ?,
draw_count = draw_count + ?,
user_stake_coin = user_stake_coin + ?,
robot_stake_coin = robot_stake_coin + ?,
payout_coin = payout_coin + ?,
refund_coin = refund_coin + ?,
platform_profit_coin = platform_profit_coin + ?,
pool_delta_coin = pool_delta_coin + ?,
forced_player_lose_matches = forced_player_lose_matches + ?,
wait_ms_sum = wait_ms_sum + ?,
duration_ms_sum = duration_ms_sum + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ?
AND game_id = ? AND stake_coin = ?
`, created, matched, settled, canceled, failed, humanMatch, robotMatch, humanParticipants, robotParticipants, winners, losers, draws, userStake, robotStake, payout, refund, platformProfit, poolDelta, forcedLose, waitMS, durationMS, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, gameID, stakeCoin); err != nil {
return err
}
if settled > 0 {
for _, participant := range event.Participants {
if err := consumeSelfGameSettledParticipant(ctx, tx, event, participant, day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil {
return err
}
}
}
if canceled > 0 {
for _, participant := range event.Participants {
if participant.UserID <= 0 || !strings.EqualFold(strings.TrimSpace(participant.ParticipantType), "user") {
continue
}
if err := ensureSelfGameUserDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, participant.UserID, nowMS); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE stat_self_game_user_day
SET cancel_count = cancel_count + 1, updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND game_id = ? AND user_id = ?
`, nowMS, appcode.Normalize(event.AppCode), day, gameID, participant.UserID); err != nil {
return err
}
}
}
return nil
})
}
func (r *Repository) ConsumeSelfGamePoolAdjustment(ctx context.Context, event SelfGamePoolAdjustmentEvent) error {
return r.withEvent(ctx, SourceGame, event.EventID, "SelfGamePoolAdjusted", func(tx *sql.Tx, nowMS int64) error {
day := statDay(event.OccurredAtMS)
gameID := normalizeShortText(event.GameID)
if gameID == "" {
gameID = "unknown"
}
direction := normalizeShortText(event.Direction)
reason := normalizeShortText(event.Reason)
if err := ensureSelfGamePoolDay(ctx, tx, event.AppCode, day, gameID, direction, reason, nowMS); err != nil {
return err
}
inCoin, outCoin := int64(0), int64(0)
switch direction {
case "in":
inCoin = normalizeID(event.AmountCoin)
case "out":
outCoin = normalizeID(event.AmountCoin)
}
_, err := tx.ExecContext(ctx, `
UPDATE stat_self_game_pool_day
SET transaction_count = transaction_count + 1,
in_coin = in_coin + ?,
out_coin = out_coin + ?,
balance_after_coin = ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND game_id = ? AND direction = ? AND reason = ?
`, inCoin, outCoin, event.BalanceAfter, nowMS, appcode.Normalize(event.AppCode), day, gameID, direction, reason)
return err
})
}
func consumeSelfGameSettledParticipant(ctx context.Context, tx *sql.Tx, event SelfGameMatchEvent, participant SelfGameMatchParticipantEvent, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, nowMS int64) error {
participantType := normalizeShortText(participant.ParticipantType)
if participantType == "" {
participantType = "user"
}
result := normalizeShortText(participant.Result)
gesture := normalizeShortText(participant.RPSGesture)
points := participant.DicePoints
if len(points) == 0 {
points = []int64{0}
}
for _, point := range points {
dicePoint := int64(0)
if point > 0 {
dicePoint = point
}
if err := ensureSelfGameParticipantDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint, nowMS); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE stat_self_game_participant_day
SET participant_count = participant_count + 1,
payout_coin = payout_coin + ?,
net_win_coin = net_win_coin + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND country_id = ? AND region_id = ?
AND game_id = ? AND stake_coin = ? AND participant_type = ? AND result = ?
AND rps_gesture = ? AND dice_point = ?
`, normalizeID(participant.PayoutCoin), participant.NetWinCoin, nowMS, appcode.Normalize(event.AppCode), day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint); err != nil {
return err
}
}
if participant.UserID <= 0 || participantType != "user" {
return nil
}
if err := ensureSelfGameUserDay(ctx, tx, event.AppCode, day, countryID, regionID, gameID, participant.UserID, nowMS); err != nil {
return err
}
win, lose, draw := int64(0), int64(0), int64(0)
switch result {
case "win":
win = 1
case "lose":
lose = 1
case "draw":
draw = 1
}
_, err := tx.ExecContext(ctx, `
UPDATE stat_self_game_user_day
SET match_count = match_count + 1,
win_count = win_count + ?,
lose_count = lose_count + ?,
draw_count = draw_count + ?,
stake_coin = stake_coin + ?,
payout_coin = payout_coin + ?,
net_win_coin = net_win_coin + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_day = ? AND game_id = ? AND user_id = ?
`, win, lose, draw, normalizeID(participant.StakeCoin), normalizeID(participant.PayoutCoin), participant.NetWinCoin, nowMS, appcode.Normalize(event.AppCode), day, gameID, participant.UserID)
return err
}
func selfGameMatchCounters(eventType string) (created, matched, settled, canceled, failed int64) {
switch strings.TrimSpace(eventType) {
case "SelfGameMatchCreated":
created = 1
case "SelfGameMatchReady":
matched = 1
case "SelfGameMatchSettled":
settled = 1
case "SelfGameMatchCanceled":
canceled = 1
case "SelfGameMatchFailed":
failed = 1
}
return created, matched, settled, canceled, failed
}
func (r *Repository) withEvent(ctx context.Context, source string, eventID string, eventType string, apply func(tx *sql.Tx, nowMS int64) error) error {
var lastErr error
for attempt := 0; attempt < 5; attempt++ {
@ -685,6 +1096,56 @@ func ensureLuckyGiftPoolDay(ctx context.Context, tx *sql.Tx, app string, day str
return err
}
func ensureSelfGameH5EventDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, eventName string, language string, clientVersion string, h5Version string, entrySource string, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO stat_self_game_h5_event_day (app_code, stat_day, country_id, region_id, game_id, event_name, language, client_version, h5_version, entry_source, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
updated_at_ms = VALUES(updated_at_ms)
`, appcode.Normalize(app), day, countryID, regionID, gameID, eventName, language, clientVersion, h5Version, entrySource, nowMS)
return err
}
func ensureSelfGameMatchDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO stat_self_game_match_day (app_code, stat_day, country_id, region_id, game_id, stake_coin, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
updated_at_ms = VALUES(updated_at_ms)
`, appcode.Normalize(app), day, countryID, regionID, gameID, stakeCoin, nowMS)
return err
}
func ensureSelfGamePoolDay(ctx context.Context, tx *sql.Tx, app string, day string, gameID string, direction string, reason string, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO stat_self_game_pool_day (app_code, stat_day, game_id, direction, reason, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
updated_at_ms = VALUES(updated_at_ms)
`, appcode.Normalize(app), day, gameID, direction, reason, nowMS)
return err
}
func ensureSelfGameParticipantDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, stakeCoin int64, participantType string, result string, gesture string, dicePoint int64, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO stat_self_game_participant_day (app_code, stat_day, country_id, region_id, game_id, stake_coin, participant_type, result, rps_gesture, dice_point, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
updated_at_ms = VALUES(updated_at_ms)
`, appcode.Normalize(app), day, countryID, regionID, gameID, stakeCoin, participantType, result, gesture, dicePoint, nowMS)
return err
}
func ensureSelfGameUserDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, gameID string, userID int64, nowMS int64) error {
_, err := tx.ExecContext(ctx, `
INSERT INTO stat_self_game_user_day (app_code, stat_day, country_id, region_id, game_id, user_id, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
updated_at_ms = VALUES(updated_at_ms)
`, appcode.Normalize(app), day, countryID, regionID, gameID, userID, nowMS)
return err
}
func insertUnique(ctx context.Context, tx *sql.Tx, query string, args ...any) (int64, error) {
result, err := tx.ExecContext(ctx, query, args...)
if err != nil {
@ -708,6 +1169,10 @@ func normalizeID(id int64) int64 {
return id
}
func normalizeShortText(value string) string {
return strings.TrimSpace(value)
}
func normalizeDimension(countryID int64, regionID int64) (int64, int64) {
return normalizeID(countryID), normalizeID(regionID)
}