Add regional statistics breakdown
This commit is contained in:
parent
44e38ab9bb
commit
1b6c77c6dc
@ -28,10 +28,13 @@ func (h *Handler) DashboardOverview(c *gin.Context) {
|
|||||||
|
|
||||||
func (h *Handler) StatisticsOverview(c *gin.Context) {
|
func (h *Handler) StatisticsOverview(c *gin.Context) {
|
||||||
overview, err := h.service.StatisticsOverview(c.Request.Context(), StatisticsQuery{
|
overview, err := h.service.StatisticsOverview(c.Request.Context(), StatisticsQuery{
|
||||||
AppCode: c.DefaultQuery("app_code", "lalu"),
|
AppCode: c.DefaultQuery("app_code", "lalu"),
|
||||||
StartMS: parseInt64(c.Query("start_ms")),
|
StartMS: parseInt64(c.Query("start_ms")),
|
||||||
EndMS: parseInt64(c.Query("end_ms")),
|
EndMS: parseInt64(c.Query("end_ms")),
|
||||||
CountryID: parseInt64(c.Query("country_id")),
|
SeriesStartMS: parseInt64(c.Query("series_start_ms")),
|
||||||
|
SeriesEndMS: parseInt64(c.Query("series_end_ms")),
|
||||||
|
RegionID: parseInt64(c.Query("region_id")),
|
||||||
|
CountryID: parseInt64(c.Query("country_id")),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.ServerError(c, "获取统计总览失败")
|
response.ServerError(c, "获取统计总览失败")
|
||||||
|
|||||||
@ -19,10 +19,13 @@ type DashboardService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type StatisticsQuery struct {
|
type StatisticsQuery struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
StartMS int64
|
StartMS int64
|
||||||
EndMS int64
|
EndMS int64
|
||||||
CountryID int64
|
SeriesStartMS int64
|
||||||
|
SeriesEndMS int64
|
||||||
|
RegionID int64
|
||||||
|
CountryID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(store *repository.Store, cfg config.Config) *DashboardService {
|
func NewService(store *repository.Store, cfg config.Config) *DashboardService {
|
||||||
@ -47,6 +50,15 @@ func (s *DashboardService) StatisticsOverview(ctx context.Context, query Statist
|
|||||||
if query.EndMS > 0 {
|
if query.EndMS > 0 {
|
||||||
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
|
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
|
||||||
}
|
}
|
||||||
|
if query.SeriesStartMS > 0 {
|
||||||
|
values.Set("series_start_ms", strconv.FormatInt(query.SeriesStartMS, 10))
|
||||||
|
}
|
||||||
|
if query.SeriesEndMS > 0 {
|
||||||
|
values.Set("series_end_ms", strconv.FormatInt(query.SeriesEndMS, 10))
|
||||||
|
}
|
||||||
|
if query.RegionID > 0 {
|
||||||
|
values.Set("region_id", strconv.FormatInt(query.RegionID, 10))
|
||||||
|
}
|
||||||
if query.CountryID > 0 {
|
if query.CountryID > 0 {
|
||||||
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
|
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,7 +20,8 @@ CREATE TABLE IF NOT EXISTS statistics_event_consumption (
|
|||||||
CREATE TABLE IF NOT EXISTS stat_app_day_country (
|
CREATE TABLE IF NOT EXISTS stat_app_day_country (
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||||
stat_day DATE NOT NULL COMMENT 'UTC 统计日',
|
stat_day DATE NOT NULL COMMENT 'UTC 统计日',
|
||||||
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家或区域维度 ID,0 表示未知或全局',
|
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家维度 ID,0 表示未知或全局',
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域维度 ID,0 表示未知或全局',
|
||||||
new_users BIGINT NOT NULL DEFAULT 0 COMMENT '新增用户数',
|
new_users BIGINT NOT NULL DEFAULT 0 COMMENT '新增用户数',
|
||||||
active_users BIGINT NOT NULL DEFAULT 0 COMMENT '当日活跃去重用户数',
|
active_users BIGINT NOT NULL DEFAULT 0 COMMENT '当日活跃去重用户数',
|
||||||
paid_users BIGINT NOT NULL DEFAULT 0 COMMENT '当日充值去重用户数',
|
paid_users BIGINT NOT NULL DEFAULT 0 COMMENT '当日充值去重用户数',
|
||||||
@ -39,17 +40,20 @@ CREATE TABLE IF NOT EXISTS stat_app_day_country (
|
|||||||
game_payout BIGINT NOT NULL DEFAULT 0 COMMENT '游戏返奖',
|
game_payout BIGINT NOT NULL DEFAULT 0 COMMENT '游戏返奖',
|
||||||
game_refund BIGINT NOT NULL DEFAULT 0 COMMENT '游戏退款',
|
game_refund BIGINT NOT NULL DEFAULT 0 COMMENT '游戏退款',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, stat_day, country_id)
|
PRIMARY KEY (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_app_day_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App 国家日统计聚合表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='App 国家日统计聚合表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_user_day_activity (
|
CREATE TABLE IF NOT EXISTS stat_user_day_activity (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
stat_day DATE NOT NULL,
|
stat_day DATE NOT NULL,
|
||||||
country_id BIGINT NOT NULL DEFAULT 0,
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
first_active_at_ms BIGINT NOT NULL,
|
first_active_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, user_id),
|
PRIMARY KEY (app_code, stat_day, user_id),
|
||||||
KEY idx_stat_user_day_country (app_code, stat_day, country_id)
|
KEY idx_stat_user_day_country (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_user_day_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户日活跃去重表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户日活跃去重表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_user_registration (
|
CREATE TABLE IF NOT EXISTS stat_user_registration (
|
||||||
@ -57,47 +61,56 @@ CREATE TABLE IF NOT EXISTS stat_user_registration (
|
|||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
registered_day DATE NOT NULL,
|
registered_day DATE NOT NULL,
|
||||||
country_id BIGINT NOT NULL DEFAULT 0,
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
registered_at_ms BIGINT NOT NULL,
|
registered_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, user_id),
|
PRIMARY KEY (app_code, user_id),
|
||||||
KEY idx_stat_user_registration_day (app_code, registered_day, country_id)
|
KEY idx_stat_user_registration_day (app_code, registered_day, country_id),
|
||||||
|
KEY idx_stat_user_registration_region (app_code, registered_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户注册日留存 cohort 表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户注册日留存 cohort 表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
stat_day DATE NOT NULL,
|
stat_day DATE NOT NULL,
|
||||||
country_id BIGINT NOT NULL DEFAULT 0,
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
first_paid_at_ms BIGINT NOT NULL,
|
first_paid_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, user_id),
|
PRIMARY KEY (app_code, stat_day, user_id),
|
||||||
KEY idx_stat_recharge_payer_country (app_code, stat_day, country_id)
|
KEY idx_stat_recharge_payer_country (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_recharge_payer_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值日付费用户去重表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='充值日付费用户去重表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers (
|
CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
stat_day DATE NOT NULL,
|
stat_day DATE NOT NULL,
|
||||||
country_id BIGINT NOT NULL DEFAULT 0,
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
first_paid_at_ms BIGINT NOT NULL,
|
first_paid_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, user_id),
|
PRIMARY KEY (app_code, stat_day, user_id),
|
||||||
KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id)
|
KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_lucky_payer_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物日付费去重表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物日付费去重表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country (
|
CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country (
|
||||||
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
|
||||||
stat_day DATE NOT NULL COMMENT 'UTC 统计日',
|
stat_day DATE NOT NULL COMMENT 'UTC 统计日',
|
||||||
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家或区域维度 ID,0 表示未知或全局',
|
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '国家维度 ID,0 表示未知或全局',
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '区域维度 ID,0 表示未知或全局',
|
||||||
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
pool_id VARCHAR(96) NOT NULL COMMENT '幸运礼物奖池 ID',
|
||||||
turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物流水金币',
|
turnover_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物流水金币',
|
||||||
payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物返奖金币',
|
payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '奖池幸运礼物返奖金币',
|
||||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||||
PRIMARY KEY (app_code, stat_day, country_id, pool_id),
|
PRIMARY KEY (app_code, stat_day, country_id, pool_id),
|
||||||
KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id)
|
KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id),
|
||||||
|
KEY idx_stat_lucky_pool_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物奖池国家日统计聚合表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='幸运礼物奖池国家日统计聚合表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_game_day_country (
|
CREATE TABLE IF NOT EXISTS stat_game_day_country (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
stat_day DATE NOT NULL,
|
stat_day DATE NOT NULL,
|
||||||
country_id BIGINT NOT NULL DEFAULT 0,
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
platform_code VARCHAR(64) NOT NULL DEFAULT '',
|
platform_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
game_id VARCHAR(96) NOT NULL,
|
game_id VARCHAR(96) NOT NULL,
|
||||||
turnover_coin BIGINT NOT NULL DEFAULT 0,
|
turnover_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
@ -105,16 +118,19 @@ CREATE TABLE IF NOT EXISTS stat_game_day_country (
|
|||||||
refund_coin BIGINT NOT NULL DEFAULT 0,
|
refund_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
player_count BIGINT NOT NULL DEFAULT 0,
|
player_count BIGINT NOT NULL DEFAULT 0,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id)
|
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id),
|
||||||
|
KEY idx_stat_game_day_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏国家日统计聚合表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏国家日统计聚合表';
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stat_game_day_players (
|
CREATE TABLE IF NOT EXISTS stat_game_day_players (
|
||||||
app_code VARCHAR(32) NOT NULL,
|
app_code VARCHAR(32) NOT NULL,
|
||||||
stat_day DATE NOT NULL,
|
stat_day DATE NOT NULL,
|
||||||
country_id BIGINT NOT NULL DEFAULT 0,
|
country_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
region_id BIGINT NOT NULL DEFAULT 0,
|
||||||
platform_code VARCHAR(64) NOT NULL DEFAULT '',
|
platform_code VARCHAR(64) NOT NULL DEFAULT '',
|
||||||
game_id VARCHAR(96) NOT NULL,
|
game_id VARCHAR(96) NOT NULL,
|
||||||
user_id BIGINT NOT NULL,
|
user_id BIGINT NOT NULL,
|
||||||
first_played_at_ms BIGINT NOT NULL,
|
first_played_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id, user_id)
|
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='游戏日参与用户去重表';
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='游戏日参与用户去重表';
|
||||||
|
|||||||
@ -152,6 +152,13 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
|
|||||||
if ok {
|
if ok {
|
||||||
return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event)
|
return repo.ConsumeRecharge(appcode.WithContext(ctx, event.AppCode), event)
|
||||||
}
|
}
|
||||||
|
stock, ok, err := coinSellerStockEvent(message.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
return repo.ConsumeCoinSellerStock(appcode.WithContext(ctx, stock.AppCode), stock)
|
||||||
|
}
|
||||||
reward, ok, err := luckyGiftRewardEvent(message.Body)
|
reward, ok, err := luckyGiftRewardEvent(message.Body)
|
||||||
if err != nil || !ok {
|
if err != nil || !ok {
|
||||||
return err
|
return err
|
||||||
@ -174,7 +181,8 @@ func newConsumers(cfg config.Config, repo *mysqlstorage.Repository) ([]*rocketmq
|
|||||||
EventID: gift.EventID + ":active",
|
EventID: gift.EventID + ":active",
|
||||||
EventType: "RoomGiftSentActive",
|
EventType: "RoomGiftSentActive",
|
||||||
UserID: gift.SenderUserID,
|
UserID: gift.SenderUserID,
|
||||||
CountryID: gift.VisibleRegionID,
|
CountryID: gift.CountryID,
|
||||||
|
RegionID: gift.VisibleRegionID,
|
||||||
OccurredAtMS: gift.OccurredAtMS,
|
OccurredAtMS: gift.OccurredAtMS,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -213,6 +221,7 @@ func userRegisteredEvent(body []byte) (mysqlstorage.UserRegisteredEvent, bool, e
|
|||||||
AppCode: message.AppCode,
|
AppCode: message.AppCode,
|
||||||
EventID: message.EventID,
|
EventID: message.EventID,
|
||||||
UserID: message.AggregateID,
|
UserID: message.AggregateID,
|
||||||
|
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "target_country_id")),
|
||||||
RegionID: mysqlstorage.Int64(payload, "region_id"),
|
RegionID: mysqlstorage.Int64(payload, "region_id"),
|
||||||
OccurredAtMS: message.OccurredAtMS,
|
OccurredAtMS: message.OccurredAtMS,
|
||||||
}, true, nil
|
}, true, nil
|
||||||
@ -243,12 +252,41 @@ func rechargeEvent(body []byte) (mysqlstorage.RechargeEvent, bool, error) {
|
|||||||
USDMinor: usdMinor,
|
USDMinor: usdMinor,
|
||||||
CoinAmount: coinAmount,
|
CoinAmount: coinAmount,
|
||||||
RechargeSequence: mysqlstorage.Int64(payload, "recharge_sequence"),
|
RechargeSequence: mysqlstorage.Int64(payload, "recharge_sequence"),
|
||||||
|
TargetCountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "target_country_id"), mysqlstorage.Int64(payload, "country_id")),
|
||||||
TargetRegionID: regionID,
|
TargetRegionID: regionID,
|
||||||
RechargeType: rechargeType,
|
RechargeType: rechargeType,
|
||||||
OccurredAtMS: message.OccurredAtMS,
|
OccurredAtMS: message.OccurredAtMS,
|
||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func coinSellerStockEvent(body []byte) (mysqlstorage.CoinSellerStockEvent, bool, error) {
|
||||||
|
message, err := walletmq.DecodeWalletOutboxMessage(body)
|
||||||
|
if err != nil {
|
||||||
|
return mysqlstorage.CoinSellerStockEvent{}, false, err
|
||||||
|
}
|
||||||
|
if message.EventType != "WalletCoinSellerStockPurchased" {
|
||||||
|
return mysqlstorage.CoinSellerStockEvent{}, false, nil
|
||||||
|
}
|
||||||
|
payload := mysqlstorage.DecodeJSON(message.PayloadJSON)
|
||||||
|
if !boolValue(payload, "counts_as_seller_recharge") {
|
||||||
|
return mysqlstorage.CoinSellerStockEvent{}, false, nil
|
||||||
|
}
|
||||||
|
usdMinor := amountMicroToUSDMinor(mysqlstorage.Int64(payload, "paid_amount_micro"))
|
||||||
|
if usdMinor <= 0 {
|
||||||
|
return mysqlstorage.CoinSellerStockEvent{}, false, nil
|
||||||
|
}
|
||||||
|
return mysqlstorage.CoinSellerStockEvent{
|
||||||
|
AppCode: message.AppCode,
|
||||||
|
EventID: message.EventID,
|
||||||
|
SellerUserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "seller_user_id"), message.UserID),
|
||||||
|
USDMinor: usdMinor,
|
||||||
|
CoinAmount: firstNonZeroInt64(mysqlstorage.Int64(payload, "coin_amount"), mysqlstorage.Int64(payload, "available_delta")),
|
||||||
|
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "seller_country_id")),
|
||||||
|
RegionID: firstNonZeroInt64(mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "seller_region_id")),
|
||||||
|
OccurredAtMS: message.OccurredAtMS,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
func rechargeUSDMinorFromPayload(payload map[string]any, rechargeType string) int64 {
|
func rechargeUSDMinorFromPayload(payload map[string]any, rechargeType string) int64 {
|
||||||
// 币商向用户转账是普通金币到账事实,只用于充值用户和金币金额统计;即使历史 payload 携带 recharge_usd_minor,也不能进入用户 USDT/USD 充值。
|
// 币商向用户转账是普通金币到账事实,只用于充值用户和金币金额统计;即使历史 payload 携带 recharge_usd_minor,也不能进入用户 USDT/USD 充值。
|
||||||
if isCoinSellerRechargeType(rechargeType) {
|
if isCoinSellerRechargeType(rechargeType) {
|
||||||
@ -303,7 +341,8 @@ func luckyGiftRewardEvent(body []byte) (mysqlstorage.LuckyGiftRewardEvent, bool,
|
|||||||
EventID: message.EventID,
|
EventID: message.EventID,
|
||||||
EventType: message.EventType,
|
EventType: message.EventType,
|
||||||
UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.UserID),
|
UserID: firstNonZeroInt64(mysqlstorage.Int64(payload, "user_id"), message.UserID),
|
||||||
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "target_region_id")),
|
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "target_country_id")),
|
||||||
|
RegionID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id"), mysqlstorage.Int64(payload, "target_region_id")),
|
||||||
PoolID: mysqlstorage.String(payload, "pool_id"),
|
PoolID: mysqlstorage.String(payload, "pool_id"),
|
||||||
Amount: mysqlstorage.Int64(payload, "amount"),
|
Amount: mysqlstorage.Int64(payload, "amount"),
|
||||||
OccurredAtMS: message.OccurredAtMS,
|
OccurredAtMS: message.OccurredAtMS,
|
||||||
@ -326,6 +365,7 @@ func roomGiftEvent(body []byte) (mysqlstorage.RoomGiftEvent, bool, error) {
|
|||||||
AppCode: envelope.GetAppCode(),
|
AppCode: envelope.GetAppCode(),
|
||||||
EventID: envelope.GetEventId(),
|
EventID: envelope.GetEventId(),
|
||||||
SenderUserID: gift.GetSenderUserId(),
|
SenderUserID: gift.GetSenderUserId(),
|
||||||
|
CountryID: 0,
|
||||||
VisibleRegionID: gift.GetVisibleRegionId(),
|
VisibleRegionID: gift.GetVisibleRegionId(),
|
||||||
GiftValue: gift.GetGiftValue(),
|
GiftValue: gift.GetGiftValue(),
|
||||||
CoinSpent: roomGiftCoinSpent(&gift),
|
CoinSpent: roomGiftCoinSpent(&gift),
|
||||||
@ -362,7 +402,8 @@ func roomJoinActiveEvent(body []byte) (mysqlstorage.UserActiveEvent, bool, error
|
|||||||
EventID: envelope.GetEventId(),
|
EventID: envelope.GetEventId(),
|
||||||
EventType: envelope.GetEventType(),
|
EventType: envelope.GetEventType(),
|
||||||
UserID: joined.GetUserId(),
|
UserID: joined.GetUserId(),
|
||||||
CountryID: joined.GetVisibleRegionId(),
|
CountryID: 0,
|
||||||
|
RegionID: joined.GetVisibleRegionId(),
|
||||||
OccurredAtMS: envelope.GetOccurredAtMs(),
|
OccurredAtMS: envelope.GetOccurredAtMs(),
|
||||||
}, true, nil
|
}, true, nil
|
||||||
}
|
}
|
||||||
@ -380,7 +421,8 @@ func gameOrderEvent(body []byte) (mysqlstorage.GameOrderEvent, bool, error) {
|
|||||||
AppCode: message.AppCode,
|
AppCode: message.AppCode,
|
||||||
EventID: message.EventID,
|
EventID: message.EventID,
|
||||||
UserID: message.UserID,
|
UserID: message.UserID,
|
||||||
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id")),
|
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "target_country_id")),
|
||||||
|
RegionID: firstNonZeroInt64(mysqlstorage.Int64(payload, "visible_region_id"), mysqlstorage.Int64(payload, "region_id")),
|
||||||
PlatformCode: message.PlatformCode,
|
PlatformCode: message.PlatformCode,
|
||||||
GameID: message.GameID,
|
GameID: message.GameID,
|
||||||
OpType: message.OpType,
|
OpType: message.OpType,
|
||||||
@ -443,3 +485,18 @@ func firstNonZeroInt64(values ...int64) int64 {
|
|||||||
}
|
}
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func boolValue(payload map[string]any, key string) bool {
|
||||||
|
switch value := payload[key].(type) {
|
||||||
|
case bool:
|
||||||
|
return value
|
||||||
|
case string:
|
||||||
|
return strings.EqualFold(strings.TrimSpace(value), "true") || strings.TrimSpace(value) == "1"
|
||||||
|
case float64:
|
||||||
|
return value != 0
|
||||||
|
case int64:
|
||||||
|
return value != 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -32,9 +32,10 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
occurredAt := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC).UnixMilli()
|
occurredAt := time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
dayStart := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC).UnixMilli()
|
dayStart := time.Date(2026, 5, 31, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
const (
|
const (
|
||||||
appCode = "lalu"
|
appCode = "lalu"
|
||||||
regionID = int64(210)
|
countryID = int64(86)
|
||||||
userID = int64(42)
|
regionID = int64(210)
|
||||||
|
userID = int64(42)
|
||||||
)
|
)
|
||||||
|
|
||||||
googleRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
|
googleRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
|
||||||
@ -46,6 +47,7 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
UserID: userID,
|
UserID: userID,
|
||||||
PayloadJSON: mustJSON(t, map[string]any{
|
PayloadJSON: mustJSON(t, map[string]any{
|
||||||
"amount_micro": int64(1_500_000),
|
"amount_micro": int64(1_500_000),
|
||||||
|
"country_id": countryID,
|
||||||
"region_id": regionID,
|
"region_id": regionID,
|
||||||
"channel": "google",
|
"channel": "google",
|
||||||
"recharge_sequence": int64(1),
|
"recharge_sequence": int64(1),
|
||||||
@ -60,6 +62,32 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
t.Fatalf("consume google recharge failed: %v", err)
|
t.Fatalf("consume google recharge failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
coinSellerStock := walletMessage(t, walletmq.WalletOutboxMessage{
|
||||||
|
AppCode: appCode,
|
||||||
|
EventID: "WalletCoinSellerStockPurchased:1",
|
||||||
|
EventType: "WalletCoinSellerStockPurchased",
|
||||||
|
TransactionID: "wallet_tx_coin_seller_stock_1",
|
||||||
|
CommandID: "coin_seller_stock_1",
|
||||||
|
UserID: userID + 10,
|
||||||
|
PayloadJSON: mustJSON(t, map[string]any{
|
||||||
|
"seller_user_id": userID + 10,
|
||||||
|
"country_id": countryID,
|
||||||
|
"region_id": regionID,
|
||||||
|
"coin_amount": int64(400_000),
|
||||||
|
"paid_amount_micro": int64(4_000_000),
|
||||||
|
"counts_as_seller_recharge": true,
|
||||||
|
"stock_type": "usdt_purchase",
|
||||||
|
}),
|
||||||
|
OccurredAtMS: occurredAt,
|
||||||
|
})
|
||||||
|
stock, ok, err := coinSellerStockEvent(coinSellerStock)
|
||||||
|
if err != nil || !ok {
|
||||||
|
t.Fatalf("decode coin seller stock failed: ok=%v err=%v", ok, err)
|
||||||
|
}
|
||||||
|
if err := repo.ConsumeCoinSellerStock(ctx, stock); err != nil {
|
||||||
|
t.Fatalf("consume coin seller stock failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
coinSellerRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
|
coinSellerRecharge := walletMessage(t, walletmq.WalletOutboxMessage{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
EventID: "WalletRechargeRecorded:coin_seller:1",
|
EventID: "WalletRechargeRecorded:coin_seller:1",
|
||||||
@ -70,6 +98,7 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
PayloadJSON: mustJSON(t, map[string]any{
|
PayloadJSON: mustJSON(t, map[string]any{
|
||||||
"amount": int64(160_000),
|
"amount": int64(160_000),
|
||||||
"recharge_type": "coin_seller_transfer",
|
"recharge_type": "coin_seller_transfer",
|
||||||
|
"target_country_id": countryID,
|
||||||
"target_region_id": regionID,
|
"target_region_id": regionID,
|
||||||
"recharge_sequence": int64(1),
|
"recharge_sequence": int64(1),
|
||||||
}),
|
}),
|
||||||
@ -115,6 +144,7 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
"room_id": "room_1",
|
"room_id": "room_1",
|
||||||
"pool_id": "lucky_100",
|
"pool_id": "lucky_100",
|
||||||
"amount": int64(300),
|
"amount": int64(300),
|
||||||
|
"country_id": countryID,
|
||||||
"visible_region_id": regionID,
|
"visible_region_id": regionID,
|
||||||
}),
|
}),
|
||||||
OccurredAtMS: occurredAt,
|
OccurredAtMS: occurredAt,
|
||||||
@ -128,8 +158,8 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, message := range []gamemq.GameOutboxMessage{
|
for _, message := range []gamemq.GameOutboxMessage{
|
||||||
gameMessage(appCode, "game_order_debit_1", userID, "debit", 700, regionID, occurredAt),
|
gameMessage(appCode, "game_order_debit_1", userID, "debit", 700, countryID, regionID, occurredAt),
|
||||||
gameMessage(appCode, "game_order_credit_1", userID, "credit", 200, regionID, occurredAt),
|
gameMessage(appCode, "game_order_credit_1", userID, "credit", 200, countryID, regionID, occurredAt),
|
||||||
} {
|
} {
|
||||||
body, err := gamemq.EncodeGameOutboxMessage(message)
|
body, err := gamemq.EncodeGameOutboxMessage(message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -145,15 +175,15 @@ func TestLocalStatisticsDataScreenFlow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
|
overview, err := repo.QueryOverview(ctx, mysqlstorage.OverviewQuery{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
StartMS: dayStart,
|
StartMS: dayStart,
|
||||||
EndMS: dayStart + int64(24*time.Hour/time.Millisecond),
|
EndMS: dayStart + int64(24*time.Hour/time.Millisecond),
|
||||||
CountryID: regionID,
|
RegionID: regionID,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("query overview failed: %v", err)
|
t.Fatalf("query overview failed: %v", err)
|
||||||
}
|
}
|
||||||
if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 0 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 150 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 {
|
if overview.GoogleRechargeUSDMinor != 150 || overview.CoinSellerRechargeUSDMinor != 400 || overview.CoinSellerTransferCoin != 160_000 || overview.RechargeUSDMinor != 550 || overview.NewUserRechargeUSDMinor != 150 || overview.PaidUsers != 2 {
|
||||||
t.Fatalf("google recharge metrics mismatch: %+v", overview)
|
t.Fatalf("google recharge metrics mismatch: %+v", overview)
|
||||||
}
|
}
|
||||||
if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 {
|
if overview.LuckyGiftTurnover != 1_250 || overview.LuckyGiftPayout != 300 || overview.LuckyGiftProfit != 950 || overview.LuckyGiftPayers != 1 {
|
||||||
@ -201,7 +231,7 @@ func roomGiftMessage(t *testing.T, appCode string, eventID string, roomID string
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func gameMessage(appCode string, orderID string, userID int64, opType string, amount int64, regionID int64, occurredAt int64) gamemq.GameOutboxMessage {
|
func gameMessage(appCode string, orderID string, userID int64, opType string, amount int64, countryID int64, regionID int64, occurredAt int64) gamemq.GameOutboxMessage {
|
||||||
return gamemq.GameOutboxMessage{
|
return gamemq.GameOutboxMessage{
|
||||||
AppCode: appCode,
|
AppCode: appCode,
|
||||||
EventID: "GameOrderSettled:" + orderID,
|
EventID: "GameOrderSettled:" + orderID,
|
||||||
@ -219,6 +249,7 @@ func gameMessage(appCode string, orderID string, userID int64, opType string, am
|
|||||||
"user_id": userID,
|
"user_id": userID,
|
||||||
"room_id": "room_1",
|
"room_id": "room_1",
|
||||||
"visible_region_id": regionID,
|
"visible_region_id": regionID,
|
||||||
|
"country_id": countryID,
|
||||||
"region_id": regionID,
|
"region_id": regionID,
|
||||||
"op_type": opType,
|
"op_type": opType,
|
||||||
"coin_amount": amount,
|
"coin_amount": amount,
|
||||||
|
|||||||
@ -65,10 +65,13 @@ func (s *queryHTTPServer) overview(w http.ResponseWriter, r *http.Request) {
|
|||||||
endMS = startMS + 24*time.Hour.Milliseconds()
|
endMS = startMS + 24*time.Hour.Milliseconds()
|
||||||
}
|
}
|
||||||
overview, err := s.repo.QueryOverview(r.Context(), mysqlstorage.OverviewQuery{
|
overview, err := s.repo.QueryOverview(r.Context(), mysqlstorage.OverviewQuery{
|
||||||
AppCode: query.Get("app_code"),
|
AppCode: query.Get("app_code"),
|
||||||
StartMS: startMS,
|
StartMS: startMS,
|
||||||
EndMS: endMS,
|
EndMS: endMS,
|
||||||
CountryID: parseInt64(query.Get("country_id")),
|
SeriesStartMS: parseInt64(query.Get("series_start_ms")),
|
||||||
|
SeriesEndMS: parseInt64(query.Get("series_end_ms")),
|
||||||
|
CountryID: parseInt64(query.Get("country_id")),
|
||||||
|
RegionID: parseInt64(query.Get("region_id")),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
|
||||||
|
|||||||
@ -4,16 +4,20 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
)
|
)
|
||||||
|
|
||||||
type OverviewQuery struct {
|
type OverviewQuery struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
StartMS int64
|
StartMS int64
|
||||||
EndMS int64
|
EndMS int64
|
||||||
CountryID int64
|
SeriesStartMS int64
|
||||||
|
SeriesEndMS int64
|
||||||
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Overview struct {
|
type Overview struct {
|
||||||
@ -24,6 +28,7 @@ type Overview struct {
|
|||||||
NewUsers int64 `json:"new_users"`
|
NewUsers int64 `json:"new_users"`
|
||||||
ActiveUsers int64 `json:"active_users"`
|
ActiveUsers int64 `json:"active_users"`
|
||||||
PaidUsers int64 `json:"paid_users"`
|
PaidUsers int64 `json:"paid_users"`
|
||||||
|
NewPaidUsers int64 `json:"new_paid_users"`
|
||||||
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||||||
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||||||
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||||||
@ -50,6 +55,7 @@ type Overview struct {
|
|||||||
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
|
NewUserRechargeDeltaRate float64 `json:"new_user_recharge_delta_rate"`
|
||||||
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
|
ActiveUsersDeltaRate float64 `json:"active_users_delta_rate"`
|
||||||
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
|
PaidUsersDeltaRate float64 `json:"paid_users_delta_rate"`
|
||||||
|
NewPaidUsersDeltaRate float64 `json:"new_paid_users_delta_rate"`
|
||||||
ARPUDeltaRate float64 `json:"arpu_delta_rate"`
|
ARPUDeltaRate float64 `json:"arpu_delta_rate"`
|
||||||
ARPPUDeltaRate float64 `json:"arppu_delta_rate"`
|
ARPPUDeltaRate float64 `json:"arppu_delta_rate"`
|
||||||
GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"`
|
GiftCoinSpentDeltaRate float64 `json:"gift_coin_spent_delta_rate"`
|
||||||
@ -60,8 +66,34 @@ type Overview struct {
|
|||||||
GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"`
|
GameTurnoverDeltaRate float64 `json:"game_turnover_delta_rate"`
|
||||||
GameProfitDeltaRate float64 `json:"game_profit_delta_rate"`
|
GameProfitDeltaRate float64 `json:"game_profit_delta_rate"`
|
||||||
Retention Retention `json:"retention"`
|
Retention Retention `json:"retention"`
|
||||||
|
DailySeries []DailySeriesItem `json:"daily_series"`
|
||||||
GameRanking []GameRank `json:"game_ranking"`
|
GameRanking []GameRank `json:"game_ranking"`
|
||||||
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
|
LuckyGiftPools []LuckyGiftPoolStat `json:"lucky_gift_pools"`
|
||||||
|
CountryBreakdown []CountryBreakdown `json:"country_breakdown"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DailySeriesItem struct {
|
||||||
|
StatDay string `json:"stat_day"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
NewUsers int64 `json:"new_users"`
|
||||||
|
ActiveUsers int64 `json:"active_users"`
|
||||||
|
PaidUsers int64 `json:"paid_users"`
|
||||||
|
NewPaidUsers int64 `json:"new_paid_users"`
|
||||||
|
RechargeUsers int64 `json:"recharge_users"`
|
||||||
|
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||||||
|
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||||||
|
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||||||
|
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||||||
|
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||||||
|
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||||||
|
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||||||
|
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||||||
|
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||||||
|
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
||||||
|
GameTurnover int64 `json:"game_turnover"`
|
||||||
|
GameProfit int64 `json:"game_profit"`
|
||||||
|
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||||||
|
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Retention struct {
|
type Retention struct {
|
||||||
@ -94,6 +126,35 @@ type LuckyGiftPoolStat struct {
|
|||||||
ProfitRate float64 `json:"profit_rate"`
|
ProfitRate float64 `json:"profit_rate"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CountryBreakdown struct {
|
||||||
|
CountryID int64 `json:"country_id"`
|
||||||
|
RegionID int64 `json:"region_id"`
|
||||||
|
NewUsers int64 `json:"new_users"`
|
||||||
|
ActiveUsers int64 `json:"active_users"`
|
||||||
|
PaidUsers int64 `json:"paid_users"`
|
||||||
|
RechargeUSDMinor int64 `json:"recharge_usd_minor"`
|
||||||
|
NewUserRechargeUSDMinor int64 `json:"new_user_recharge_usd_minor"`
|
||||||
|
CoinSellerRechargeUSDMinor int64 `json:"coin_seller_recharge_usd_minor"`
|
||||||
|
MifaPayRechargeUSDMinor int64 `json:"mifapay_recharge_usd_minor"`
|
||||||
|
GoogleRechargeUSDMinor int64 `json:"google_recharge_usd_minor"`
|
||||||
|
CoinSellerTransferCoin int64 `json:"coin_seller_transfer_coin"`
|
||||||
|
GiftCoinSpent int64 `json:"gift_coin_spent"`
|
||||||
|
LuckyGiftTurnover int64 `json:"lucky_gift_turnover"`
|
||||||
|
LuckyGiftPayout int64 `json:"lucky_gift_payout"`
|
||||||
|
LuckyGiftPayers int64 `json:"lucky_gift_payers"`
|
||||||
|
LuckyGiftProfit int64 `json:"lucky_gift_profit"`
|
||||||
|
LuckyGiftPayoutRate float64 `json:"lucky_gift_payout_rate"`
|
||||||
|
LuckyGiftProfitRate float64 `json:"lucky_gift_profit_rate"`
|
||||||
|
GameTurnover int64 `json:"game_turnover"`
|
||||||
|
GamePayout int64 `json:"game_payout"`
|
||||||
|
GameRefund int64 `json:"game_refund"`
|
||||||
|
GamePlayers int64 `json:"game_players"`
|
||||||
|
GameProfit int64 `json:"game_profit"`
|
||||||
|
GameProfitRate float64 `json:"game_profit_rate"`
|
||||||
|
ARPUUSDMinor int64 `json:"arpu_usd_minor"`
|
||||||
|
ARPPUUSDMinor int64 `json:"arppu_usd_minor"`
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) {
|
func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Overview, error) {
|
||||||
app := appcode.Normalize(query.AppCode)
|
app := appcode.Normalize(query.AppCode)
|
||||||
if app == "" {
|
if app == "" {
|
||||||
@ -102,31 +163,38 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
|||||||
startDay := statDay(query.StartMS)
|
startDay := statDay(query.StartMS)
|
||||||
endDay := statDay(query.EndMS - 1)
|
endDay := statDay(query.EndMS - 1)
|
||||||
previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
|
previousStartDay, previousEndDay := previousStatDayRange(startDay, endDay)
|
||||||
overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID)
|
overview, err := r.queryAppDayOverviewTotals(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
previous, err := r.queryAppDayOverviewTotals(ctx, app, previousStartDay, previousEndDay, query.CountryID)
|
previous, err := r.queryAppDayOverviewTotals(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID
|
overview.AppCode, overview.StartMS, overview.EndMS, overview.CountryID = app, query.StartMS, query.EndMS, query.CountryID
|
||||||
previous.AppCode, previous.CountryID = app, query.CountryID
|
previous.AppCode, previous.CountryID = app, query.CountryID
|
||||||
|
// 新增付费用户来自日充值用户去重表和注册表的交集;只读聚合小表,避免 Databi 页面回扫钱包或订单明细。
|
||||||
|
if overview.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
|
||||||
|
return Overview{}, err
|
||||||
|
}
|
||||||
|
if previous.NewPaidUsers, err = r.queryNewPaidUsers(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
|
||||||
|
return Overview{}, err
|
||||||
|
}
|
||||||
// Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
|
// Pool 明细和顶部汇总来自同一个统计窗口;Databi 用它来下钻展示每个 pool_id 的流水、返奖和利润。
|
||||||
pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID)
|
pools, err := r.queryLuckyGiftPools(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
overview.LuckyGiftPools = pools
|
overview.LuckyGiftPools = pools
|
||||||
// 幸运礼物流水和返奖以 activity-service 的抽奖事实增量日汇总为准;这里仅读小表,避免 Databi 页面扫描 lucky_draw_records。
|
// 幸运礼物流水和返奖以 activity-service 的抽奖事实增量日汇总为准;这里仅读小表,避免 Databi 页面扫描 lucky_draw_records。
|
||||||
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID); err != nil {
|
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, startDay, endDay, query.CountryID, query.RegionID); err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
overview.LuckyGiftPools = activityPools
|
overview.LuckyGiftPools = activityPools
|
||||||
overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
overview.LuckyGiftTurnover, overview.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
||||||
}
|
}
|
||||||
// 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。
|
// 上一周期也使用相同的日聚合口径,保证“与昨日相比/前周期相比”不额外查询明细表。
|
||||||
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID); err != nil {
|
if activityPools, ok, err := r.queryLuckyGiftPoolsFromActivityStats(ctx, app, previousStartDay, previousEndDay, query.CountryID, query.RegionID); err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
} else if ok {
|
} else if ok {
|
||||||
previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
previous.LuckyGiftTurnover, previous.LuckyGiftPayout = sumLuckyGiftPools(activityPools)
|
||||||
@ -134,26 +202,49 @@ func (r *Repository) QueryOverview(ctx context.Context, query OverviewQuery) (Ov
|
|||||||
applyOverviewDerivedMetrics(&overview)
|
applyOverviewDerivedMetrics(&overview)
|
||||||
applyOverviewDerivedMetrics(&previous)
|
applyOverviewDerivedMetrics(&previous)
|
||||||
overview.applyDeltaRates(previous)
|
overview.applyDeltaRates(previous)
|
||||||
retention, err := r.queryRetention(ctx, app, startDay, endDay, query.CountryID)
|
retention, err := r.queryRetention(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
overview.Retention = retention
|
overview.Retention = retention
|
||||||
ranking, err := r.queryGameRanking(ctx, app, startDay, endDay, query.CountryID)
|
ranking, err := r.queryGameRanking(ctx, app, startDay, endDay, query.CountryID, query.RegionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
}
|
}
|
||||||
overview.GameRanking = ranking
|
overview.GameRanking = ranking
|
||||||
|
if query.CountryID <= 0 {
|
||||||
|
breakdown, err := r.queryCountryBreakdown(ctx, app, startDay, endDay, query.RegionID)
|
||||||
|
if err != nil {
|
||||||
|
return Overview{}, err
|
||||||
|
}
|
||||||
|
overview.CountryBreakdown = breakdown
|
||||||
|
}
|
||||||
|
seriesStartDay, seriesEndDay := startDay, endDay
|
||||||
|
if query.SeriesStartMS > 0 && query.SeriesEndMS > query.SeriesStartMS {
|
||||||
|
seriesStartDay = statDay(query.SeriesStartMS)
|
||||||
|
seriesEndDay = statDay(query.SeriesEndMS - 1)
|
||||||
|
}
|
||||||
|
// daily_series 服务卡片内 sparkline 和弹窗折线图;一条 GROUP BY stat_day 查询返回全部指标,避免前端逐日请求。
|
||||||
|
dailySeries, err := r.queryDailySeries(ctx, app, seriesStartDay, seriesEndDay, query.CountryID, query.RegionID)
|
||||||
|
if err != nil {
|
||||||
|
return Overview{}, err
|
||||||
|
}
|
||||||
|
overview.DailySeries = dailySeries
|
||||||
return overview, nil
|
return overview, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64) (Overview, error) {
|
func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Overview, error) {
|
||||||
args := []any{app, startDay, endDay}
|
args := []any{app, startDay, endDay}
|
||||||
countryFilter := ""
|
filter := ""
|
||||||
if countryID > 0 {
|
if countryID > 0 {
|
||||||
countryFilter = " AND country_id = ?"
|
filter += " AND country_id = ?"
|
||||||
args = append(args, countryID)
|
args = append(args, countryID)
|
||||||
}
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
// 旧统计行曾把 region_id 写入 country_id;兼容分支只服务历史读数,新事件写入后会走显式 region_id。
|
||||||
|
filter += " AND (region_id = ? OR (region_id = 0 AND country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
SELECT COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||||||
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||||||
@ -164,7 +255,7 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDa
|
|||||||
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||||||
COALESCE(SUM(game_players),0)
|
COALESCE(SUM(game_players),0)
|
||||||
FROM stat_app_day_country
|
FROM stat_app_day_country
|
||||||
WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+countryFilter, args...)
|
WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter, args...)
|
||||||
var overview Overview
|
var overview Overview
|
||||||
if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil {
|
if err := row.Scan(&overview.NewUsers, &overview.ActiveUsers, &overview.PaidUsers, &overview.RechargeUSDMinor, &overview.NewUserRechargeUSDMinor, &overview.CoinSellerRechargeUSDMinor, &overview.MifaPayRechargeUSDMinor, &overview.GoogleRechargeUSDMinor, &overview.CoinSellerTransferCoin, &overview.GiftCoinSpent, &overview.LuckyGiftTurnover, &overview.LuckyGiftPayout, &overview.LuckyGiftPayers, &overview.GameTurnover, &overview.GamePayout, &overview.GameRefund, &overview.GamePlayers); err != nil {
|
||||||
return Overview{}, err
|
return Overview{}, err
|
||||||
@ -172,12 +263,144 @@ func (r *Repository) queryAppDayOverviewTotals(ctx context.Context, app, startDa
|
|||||||
return overview, nil
|
return overview, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) queryNewPaidUsers(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (int64, error) {
|
||||||
|
args := []any{app, startDay, endDay, startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if countryID > 0 {
|
||||||
|
filter += " AND p.country_id = ?"
|
||||||
|
args = append(args, countryID)
|
||||||
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
// p 表和 r 表都已经是统计小表,这里只按充值发生地过滤,保证新增付费用户口径和付费用户卡片一致。
|
||||||
|
filter += " AND (p.region_id = ? OR (p.region_id = 0 AND p.country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
|
row := r.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COUNT(DISTINCT p.user_id)
|
||||||
|
FROM stat_recharge_day_payers p
|
||||||
|
JOIN stat_user_registration r ON r.app_code = p.app_code AND r.user_id = p.user_id
|
||||||
|
WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ?
|
||||||
|
AND r.registered_day BETWEEN ? AND ?`+filter, args...)
|
||||||
|
var count int64
|
||||||
|
if err := row.Scan(&count); err != nil && err != sql.ErrNoRows {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) queryDailySeries(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]DailySeriesItem, error) {
|
||||||
|
args := []any{app, startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if countryID > 0 {
|
||||||
|
filter += " AND country_id = ?"
|
||||||
|
args = append(args, countryID)
|
||||||
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
// 和总览一致兼容历史 region_id 写到 country_id 的聚合行;新数据优先读显式 region_id。
|
||||||
|
filter += " AND (region_id = ? OR (region_id = 0 AND country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'),
|
||||||
|
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||||||
|
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||||||
|
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||||||
|
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||||||
|
COALESCE(SUM(gift_coin_spent),0),
|
||||||
|
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0),
|
||||||
|
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0)
|
||||||
|
FROM stat_app_day_country
|
||||||
|
WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||||||
|
GROUP BY stat_day
|
||||||
|
ORDER BY stat_day ASC`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []DailySeriesItem{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item DailySeriesItem
|
||||||
|
var gamePayout, gameRefund int64
|
||||||
|
if err := rows.Scan(&item.StatDay, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.GameTurnover, &gamePayout, &gameRefund); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Label = item.StatDay
|
||||||
|
item.GameProfit = item.GameTurnover - gamePayout - gameRefund
|
||||||
|
applyDailySeriesDerivedMetrics(&item)
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
newPaidUsersByDay, err := r.queryDailyNewPaidUsers(ctx, app, startDay, endDay, countryID, regionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index := range out {
|
||||||
|
out[index].NewPaidUsers = newPaidUsersByDay[out[index].StatDay]
|
||||||
|
}
|
||||||
|
if activityDaily, ok, err := r.queryLuckyGiftDailyFromActivityStats(ctx, app, startDay, endDay, countryID, regionID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
} else if ok {
|
||||||
|
out = mergeActivityLuckyGiftDaily(out, activityDaily)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) queryDailyNewPaidUsers(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]int64, error) {
|
||||||
|
args := []any{app, startDay, endDay, startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if countryID > 0 {
|
||||||
|
filter += " AND p.country_id = ?"
|
||||||
|
args = append(args, countryID)
|
||||||
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
filter += " AND (p.region_id = ? OR (p.region_id = 0 AND p.country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT DATE_FORMAT(p.stat_day, '%Y-%m-%d'), COUNT(DISTINCT p.user_id)
|
||||||
|
FROM stat_recharge_day_payers p
|
||||||
|
JOIN stat_user_registration r ON r.app_code = p.app_code AND r.user_id = p.user_id
|
||||||
|
WHERE p.app_code = ? AND p.stat_day BETWEEN ? AND ?
|
||||||
|
AND r.registered_day BETWEEN ? AND ?`+filter+`
|
||||||
|
GROUP BY p.stat_day`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]int64{}
|
||||||
|
for rows.Next() {
|
||||||
|
var day string
|
||||||
|
var count int64
|
||||||
|
if err := rows.Scan(&day, &count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[day] = count
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyDailySeriesDerivedMetrics(item *DailySeriesItem) {
|
||||||
|
if item == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
|
||||||
|
if channelRechargeUSDMinor > item.RechargeUSDMinor {
|
||||||
|
item.RechargeUSDMinor = channelRechargeUSDMinor
|
||||||
|
}
|
||||||
|
item.RechargeUsers = item.PaidUsers
|
||||||
|
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
||||||
|
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
||||||
|
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
||||||
|
}
|
||||||
|
|
||||||
func applyOverviewDerivedMetrics(overview *Overview) {
|
func applyOverviewDerivedMetrics(overview *Overview) {
|
||||||
if overview == nil {
|
if overview == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor;查询时用渠道合计兜底,但币商转普通金币只展示金币卡片,不进入用户 USDT 充值。
|
// 历史聚合行可能只写了真实 USD 渠道列、没有同步修正 recharge_usd_minor;查询时用渠道合计兜底。币商进货是 USDT 充值,币商转普通金币仍只展示金币卡片。
|
||||||
channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor
|
channelRechargeUSDMinor := overview.MifaPayRechargeUSDMinor + overview.GoogleRechargeUSDMinor + overview.CoinSellerRechargeUSDMinor
|
||||||
if channelRechargeUSDMinor > overview.RechargeUSDMinor {
|
if channelRechargeUSDMinor > overview.RechargeUSDMinor {
|
||||||
overview.RechargeUSDMinor = channelRechargeUSDMinor
|
overview.RechargeUSDMinor = channelRechargeUSDMinor
|
||||||
}
|
}
|
||||||
@ -199,6 +422,7 @@ func (overview *Overview) applyDeltaRates(previous Overview) {
|
|||||||
overview.NewUserRechargeDeltaRate = deltaRate(overview.NewUserRechargeUSDMinor, previous.NewUserRechargeUSDMinor)
|
overview.NewUserRechargeDeltaRate = deltaRate(overview.NewUserRechargeUSDMinor, previous.NewUserRechargeUSDMinor)
|
||||||
overview.ActiveUsersDeltaRate = deltaRate(overview.ActiveUsers, previous.ActiveUsers)
|
overview.ActiveUsersDeltaRate = deltaRate(overview.ActiveUsers, previous.ActiveUsers)
|
||||||
overview.PaidUsersDeltaRate = deltaRate(overview.PaidUsers, previous.PaidUsers)
|
overview.PaidUsersDeltaRate = deltaRate(overview.PaidUsers, previous.PaidUsers)
|
||||||
|
overview.NewPaidUsersDeltaRate = deltaRate(overview.NewPaidUsers, previous.NewPaidUsers)
|
||||||
overview.ARPUDeltaRate = deltaRate(overview.ARPUUSDMinor, previous.ARPUUSDMinor)
|
overview.ARPUDeltaRate = deltaRate(overview.ARPUUSDMinor, previous.ARPUUSDMinor)
|
||||||
overview.ARPPUDeltaRate = deltaRate(overview.ARPPUUSDMinor, previous.ARPPUUSDMinor)
|
overview.ARPPUDeltaRate = deltaRate(overview.ARPPUUSDMinor, previous.ARPPUUSDMinor)
|
||||||
overview.GiftCoinSpentDeltaRate = deltaRate(overview.GiftCoinSpent, previous.GiftCoinSpent)
|
overview.GiftCoinSpentDeltaRate = deltaRate(overview.GiftCoinSpent, previous.GiftCoinSpent)
|
||||||
@ -210,13 +434,17 @@ func (overview *Overview) applyDeltaRates(previous Overview) {
|
|||||||
overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit)
|
overview.GameProfitDeltaRate = deltaRate(overview.GameProfit, previous.GameProfit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay string, countryID int64) (Retention, error) {
|
func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (Retention, error) {
|
||||||
filter := ""
|
filter := ""
|
||||||
args := []any{app, startDay, endDay}
|
args := []any{app, startDay, endDay}
|
||||||
if countryID > 0 {
|
if countryID > 0 {
|
||||||
filter = " AND r.country_id = ?"
|
filter = " AND r.country_id = ?"
|
||||||
args = append(args, countryID)
|
args = append(args, countryID)
|
||||||
}
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
filter += " AND (r.region_id = ? OR (r.region_id = 0 AND r.country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
row := r.db.QueryRowContext(ctx, `
|
row := r.db.QueryRowContext(ctx, `
|
||||||
SELECT COUNT(*),
|
SELECT COUNT(*),
|
||||||
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0),
|
COALESCE(SUM(EXISTS(SELECT 1 FROM stat_user_day_activity a WHERE a.app_code=r.app_code AND a.user_id=r.user_id AND a.stat_day=DATE_ADD(r.registered_day, INTERVAL 1 DAY))),0),
|
||||||
@ -234,13 +462,17 @@ func (r *Repository) queryRetention(ctx context.Context, app, startDay, endDay s
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay string, countryID int64) ([]GameRank, error) {
|
func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]GameRank, error) {
|
||||||
args := []any{app, startDay, endDay}
|
args := []any{app, startDay, endDay}
|
||||||
filter := ""
|
filter := ""
|
||||||
if countryID > 0 {
|
if countryID > 0 {
|
||||||
filter = " AND country_id = ?"
|
filter = " AND country_id = ?"
|
||||||
args = append(args, countryID)
|
args = append(args, countryID)
|
||||||
}
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
filter += " AND (region_id = ? OR (region_id = 0 AND country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT platform_code, game_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0),
|
SELECT platform_code, game_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0),
|
||||||
COALESCE(SUM(refund_coin),0), COALESCE(SUM(player_count),0)
|
COALESCE(SUM(refund_coin),0), COALESCE(SUM(player_count),0)
|
||||||
@ -266,13 +498,72 @@ func (r *Repository) queryGameRanking(ctx context.Context, app, startDay, endDay
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, endDay string, countryID int64) ([]LuckyGiftPoolStat, error) {
|
func (r *Repository) queryCountryBreakdown(ctx context.Context, app, startDay, endDay string, regionID int64) ([]CountryBreakdown, error) {
|
||||||
|
args := []any{app, startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if regionID > 0 {
|
||||||
|
filter = " AND (region_id = ? OR (region_id = 0 AND country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
|
SELECT country_id, COALESCE(MAX(region_id),0),
|
||||||
|
COALESCE(SUM(new_users),0), COALESCE(SUM(active_users),0), COALESCE(SUM(paid_users),0),
|
||||||
|
COALESCE(SUM(recharge_usd_minor),0), COALESCE(SUM(new_user_recharge_usd_minor),0),
|
||||||
|
COALESCE(SUM(coin_seller_recharge_usd_minor),0), COALESCE(SUM(mifapay_recharge_usd_minor),0),
|
||||||
|
COALESCE(SUM(google_recharge_usd_minor),0), COALESCE(SUM(coin_seller_transfer_coin),0),
|
||||||
|
COALESCE(SUM(gift_coin_spent),0),
|
||||||
|
COALESCE(SUM(lucky_gift_turnover),0), COALESCE(SUM(lucky_gift_payout),0), COALESCE(SUM(lucky_gift_payers),0),
|
||||||
|
COALESCE(SUM(game_turnover),0), COALESCE(SUM(game_payout),0), COALESCE(SUM(game_refund),0),
|
||||||
|
COALESCE(SUM(game_players),0)
|
||||||
|
FROM stat_app_day_country
|
||||||
|
WHERE app_code = ? AND stat_day BETWEEN ? AND ?`+filter+`
|
||||||
|
GROUP BY country_id
|
||||||
|
ORDER BY COALESCE(SUM(recharge_usd_minor),0) DESC, COALESCE(SUM(active_users),0) DESC, country_id ASC
|
||||||
|
LIMIT 500`, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []CountryBreakdown{}
|
||||||
|
for rows.Next() {
|
||||||
|
var item CountryBreakdown
|
||||||
|
if err := rows.Scan(&item.CountryID, &item.RegionID, &item.NewUsers, &item.ActiveUsers, &item.PaidUsers, &item.RechargeUSDMinor, &item.NewUserRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, &item.MifaPayRechargeUSDMinor, &item.GoogleRechargeUSDMinor, &item.CoinSellerTransferCoin, &item.GiftCoinSpent, &item.LuckyGiftTurnover, &item.LuckyGiftPayout, &item.LuckyGiftPayers, &item.GameTurnover, &item.GamePayout, &item.GameRefund, &item.GamePlayers); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
applyCountryBreakdownDerivedMetrics(&item)
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCountryBreakdownDerivedMetrics(item *CountryBreakdown) {
|
||||||
|
if item == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
channelRechargeUSDMinor := item.MifaPayRechargeUSDMinor + item.GoogleRechargeUSDMinor + item.CoinSellerRechargeUSDMinor
|
||||||
|
if channelRechargeUSDMinor > item.RechargeUSDMinor {
|
||||||
|
item.RechargeUSDMinor = channelRechargeUSDMinor
|
||||||
|
}
|
||||||
|
item.GameProfit = item.GameTurnover - item.GamePayout - item.GameRefund
|
||||||
|
item.GameProfitRate = ratio(item.GameProfit, item.GameTurnover)
|
||||||
|
item.ARPUUSDMinor = div(item.RechargeUSDMinor, item.ActiveUsers)
|
||||||
|
item.ARPPUUSDMinor = div(item.RechargeUSDMinor, item.PaidUsers)
|
||||||
|
item.LuckyGiftProfit = item.LuckyGiftTurnover - item.LuckyGiftPayout
|
||||||
|
item.LuckyGiftPayoutRate = ratio(item.LuckyGiftPayout, item.LuckyGiftTurnover)
|
||||||
|
item.LuckyGiftProfitRate = ratio(item.LuckyGiftProfit, item.LuckyGiftTurnover)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, error) {
|
||||||
args := []any{app, startDay, endDay}
|
args := []any{app, startDay, endDay}
|
||||||
filter := ""
|
filter := ""
|
||||||
if countryID > 0 {
|
if countryID > 0 {
|
||||||
filter = " AND country_id = ?"
|
filter = " AND country_id = ?"
|
||||||
args = append(args, countryID)
|
args = append(args, countryID)
|
||||||
}
|
}
|
||||||
|
if regionID > 0 {
|
||||||
|
filter += " AND (region_id = ? OR (region_id = 0 AND country_id = ?))"
|
||||||
|
args = append(args, regionID, regionID)
|
||||||
|
}
|
||||||
rows, err := r.db.QueryContext(ctx, `
|
rows, err := r.db.QueryContext(ctx, `
|
||||||
SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
SELECT pool_id, COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||||||
FROM stat_lucky_gift_pool_day_country
|
FROM stat_lucky_gift_pool_day_country
|
||||||
@ -297,13 +588,16 @@ func (r *Repository) queryLuckyGiftPools(ctx context.Context, app, startDay, end
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) queryLuckyGiftPoolsFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64) ([]LuckyGiftPoolStat, bool, error) {
|
func (r *Repository) queryLuckyGiftPoolsFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) ([]LuckyGiftPoolStat, bool, error) {
|
||||||
if r == nil || r.activityDB == nil {
|
if r == nil || r.activityDB == nil {
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
args := []any{app, startDay, endDay}
|
args := []any{app, startDay, endDay}
|
||||||
filter := ""
|
filter := ""
|
||||||
if countryID > 0 {
|
if regionID > 0 {
|
||||||
|
filter = " AND visible_region_id = ?"
|
||||||
|
args = append(args, regionID)
|
||||||
|
} else if countryID > 0 {
|
||||||
filter = " AND visible_region_id = ?"
|
filter = " AND visible_region_id = ?"
|
||||||
args = append(args, countryID)
|
args = append(args, countryID)
|
||||||
}
|
}
|
||||||
@ -337,6 +631,69 @@ func (r *Repository) queryLuckyGiftPoolsFromActivityStats(ctx context.Context, a
|
|||||||
return out, len(out) > 0, nil
|
return out, len(out) > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) queryLuckyGiftDailyFromActivityStats(ctx context.Context, app, startDay, endDay string, countryID int64, regionID int64) (map[string]LuckyGiftPoolStat, bool, error) {
|
||||||
|
if r == nil || r.activityDB == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
args := []any{app, startDay, endDay}
|
||||||
|
filter := ""
|
||||||
|
if regionID > 0 {
|
||||||
|
filter = " AND visible_region_id = ?"
|
||||||
|
args = append(args, regionID)
|
||||||
|
} else if countryID > 0 {
|
||||||
|
filter = " AND visible_region_id = ?"
|
||||||
|
args = append(args, countryID)
|
||||||
|
}
|
||||||
|
rows, err := r.activityDB.QueryContext(ctx, `
|
||||||
|
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), COALESCE(SUM(turnover_coin),0), COALESCE(SUM(payout_coin),0)
|
||||||
|
FROM lucky_draw_pool_day_stats
|
||||||
|
WHERE app_code = ? AND stat_day BETWEEN ? AND ? AND gift_id = ''`+filter+`
|
||||||
|
GROUP BY stat_day`, args...)
|
||||||
|
if err != nil {
|
||||||
|
if isMissingTableError(err) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]LuckyGiftPoolStat{}
|
||||||
|
for rows.Next() {
|
||||||
|
var day string
|
||||||
|
var item LuckyGiftPoolStat
|
||||||
|
if err := rows.Scan(&day, &item.TurnoverCoin, &item.PayoutCoin); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
item.ProfitCoin = item.TurnoverCoin - item.PayoutCoin
|
||||||
|
out[day] = item
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return out, len(out) > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeActivityLuckyGiftDaily(items []DailySeriesItem, activityDaily map[string]LuckyGiftPoolStat) []DailySeriesItem {
|
||||||
|
byDay := make(map[string]int, len(items))
|
||||||
|
for index := range items {
|
||||||
|
byDay[items[index].StatDay] = index
|
||||||
|
}
|
||||||
|
for day, activity := range activityDaily {
|
||||||
|
index, exists := byDay[day]
|
||||||
|
if !exists {
|
||||||
|
items = append(items, DailySeriesItem{StatDay: day, Label: day})
|
||||||
|
index = len(items) - 1
|
||||||
|
byDay[day] = index
|
||||||
|
}
|
||||||
|
items[index].LuckyGiftTurnover = activity.TurnoverCoin
|
||||||
|
items[index].LuckyGiftPayout = activity.PayoutCoin
|
||||||
|
applyDailySeriesDerivedMetrics(&items[index])
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, func(left, right int) bool {
|
||||||
|
return items[left].StatDay < items[right].StatDay
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
|
func sumLuckyGiftPools(pools []LuckyGiftPoolStat) (int64, int64) {
|
||||||
var turnover, payout int64
|
var turnover, payout int64
|
||||||
for _, pool := range pools {
|
for _, pool := range pools {
|
||||||
|
|||||||
@ -107,3 +107,43 @@ func TestQueryOverviewReturnsPreviousPeriodDeltaRates(t *testing.T) {
|
|||||||
t.Fatalf("game delta rates mismatch: %+v", overview)
|
t.Fatalf("game delta rates mismatch: %+v", overview)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryOverviewReturnsCountryBreakdownAndIncludesCoinSellerRecharge(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_query_country_test",
|
||||||
|
})
|
||||||
|
repository, err := Open(ctx, statsSchema.DSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repository: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = repository.Close() })
|
||||||
|
|
||||||
|
if _, err := repository.db.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_app_day_country (
|
||||||
|
app_code, stat_day, country_id, region_id, active_users, paid_users, recharge_usd_minor,
|
||||||
|
coin_seller_recharge_usd_minor, google_recharge_usd_minor, updated_at_ms
|
||||||
|
) VALUES
|
||||||
|
('lalu', '2026-06-06', 86, 210, 10, 2, 150, 400, 150, 1),
|
||||||
|
('lalu', '2026-06-06', 66, 210, 5, 1, 100, 0, 100, 1),
|
||||||
|
('lalu', '2026-06-06', 99, 220, 3, 1, 900, 0, 900, 1)`); err != nil {
|
||||||
|
t.Fatalf("seed country statistics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
start := time.Date(2026, 6, 6, 0, 0, 0, 0, time.UTC).UnixMilli()
|
||||||
|
overview, err := repository.QueryOverview(ctx, OverviewQuery{AppCode: "lalu", StartMS: start, EndMS: start + int64(24*time.Hour/time.Millisecond), RegionID: 210})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query overview: %v", err)
|
||||||
|
}
|
||||||
|
if overview.RechargeUSDMinor != 650 || overview.CoinSellerRechargeUSDMinor != 400 {
|
||||||
|
t.Fatalf("overview recharge mismatch: %+v", overview)
|
||||||
|
}
|
||||||
|
if len(overview.CountryBreakdown) != 2 {
|
||||||
|
t.Fatalf("country breakdown should come from aggregate query: %+v", overview.CountryBreakdown)
|
||||||
|
}
|
||||||
|
if overview.CountryBreakdown[0].CountryID != 86 || overview.CountryBreakdown[0].RechargeUSDMinor != 550 || overview.CountryBreakdown[0].ARPPUUSDMinor != 275 {
|
||||||
|
t.Fatalf("first country breakdown mismatch: %+v", overview.CountryBreakdown[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -104,6 +104,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_app_day_country (
|
`CREATE TABLE IF NOT EXISTS stat_app_day_country (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
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,
|
||||||
new_users BIGINT NOT NULL DEFAULT 0, active_users BIGINT NOT NULL DEFAULT 0, paid_users BIGINT NOT NULL DEFAULT 0,
|
new_users BIGINT NOT NULL DEFAULT 0, active_users BIGINT NOT NULL DEFAULT 0, paid_users BIGINT NOT NULL DEFAULT 0,
|
||||||
recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
recharge_usd_minor BIGINT NOT NULL DEFAULT 0, new_user_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, mifapay_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
|
||||||
@ -112,49 +113,63 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payout BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0,
|
lucky_gift_turnover BIGINT NOT NULL DEFAULT 0, lucky_gift_payout BIGINT NOT NULL DEFAULT 0, lucky_gift_payers BIGINT NOT NULL DEFAULT 0,
|
||||||
game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0,
|
game_turnover BIGINT NOT NULL DEFAULT 0, game_players BIGINT NOT NULL DEFAULT 0,
|
||||||
game_payout BIGINT NOT NULL DEFAULT 0, game_refund BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
game_payout BIGINT NOT NULL DEFAULT 0, game_refund BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, country_id)
|
PRIMARY KEY (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_app_day_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_user_day_activity (
|
`CREATE TABLE IF NOT EXISTS stat_user_day_activity (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
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,
|
||||||
user_id BIGINT NOT NULL, first_active_at_ms BIGINT NOT NULL,
|
user_id BIGINT NOT NULL, first_active_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, user_id),
|
PRIMARY KEY (app_code, stat_day, user_id),
|
||||||
KEY idx_stat_user_day_country (app_code, stat_day, country_id)
|
KEY idx_stat_user_day_country (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_user_day_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_user_registration (
|
`CREATE TABLE IF NOT EXISTS stat_user_registration (
|
||||||
app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, registered_day DATE NOT NULL,
|
app_code VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, registered_day DATE NOT NULL,
|
||||||
country_id BIGINT NOT NULL DEFAULT 0, registered_at_ms BIGINT NOT NULL,
|
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, registered_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, user_id), KEY idx_stat_user_registration_day (app_code, registered_day, country_id)
|
PRIMARY KEY (app_code, user_id), KEY idx_stat_user_registration_day (app_code, registered_day, country_id),
|
||||||
|
KEY idx_stat_user_registration_region (app_code, registered_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
`CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
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,
|
||||||
user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, user_id), KEY idx_stat_recharge_payer_country (app_code, stat_day, country_id)
|
PRIMARY KEY (app_code, stat_day, user_id), KEY idx_stat_recharge_payer_country (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_recharge_payer_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers (
|
`CREATE TABLE IF NOT EXISTS stat_lucky_gift_day_payers (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
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,
|
||||||
user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, user_id),
|
PRIMARY KEY (app_code, stat_day, user_id),
|
||||||
KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id)
|
KEY idx_stat_lucky_payer_country (app_code, stat_day, country_id),
|
||||||
|
KEY idx_stat_lucky_payer_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country (
|
`CREATE TABLE IF NOT EXISTS stat_lucky_gift_pool_day_country (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
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,
|
||||||
pool_id VARCHAR(96) NOT NULL, turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0,
|
pool_id VARCHAR(96) NOT NULL, turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
updated_at_ms BIGINT NOT NULL,
|
updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, country_id, pool_id),
|
PRIMARY KEY (app_code, stat_day, country_id, pool_id),
|
||||||
KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id)
|
KEY idx_stat_lucky_pool_overview (app_code, stat_day, pool_id),
|
||||||
|
KEY idx_stat_lucky_pool_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_game_day_country (
|
`CREATE TABLE IF NOT EXISTS stat_game_day_country (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
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,
|
||||||
platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL,
|
platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL,
|
||||||
turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, refund_coin BIGINT NOT NULL DEFAULT 0,
|
turnover_coin BIGINT NOT NULL DEFAULT 0, payout_coin BIGINT NOT NULL DEFAULT 0, refund_coin BIGINT NOT NULL DEFAULT 0,
|
||||||
player_count BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
player_count BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id)
|
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id),
|
||||||
|
KEY idx_stat_game_day_region (app_code, stat_day, region_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
`CREATE TABLE IF NOT EXISTS stat_game_day_players (
|
`CREATE TABLE IF NOT EXISTS stat_game_day_players (
|
||||||
app_code VARCHAR(32) NOT NULL, stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
|
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,
|
||||||
platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL,
|
platform_code VARCHAR(64) NOT NULL DEFAULT '', game_id VARCHAR(96) NOT NULL,
|
||||||
user_id BIGINT NOT NULL, first_played_at_ms BIGINT NOT NULL,
|
user_id BIGINT NOT NULL, first_played_at_ms BIGINT NOT NULL,
|
||||||
PRIMARY KEY (app_code, stat_day, country_id, platform_code, game_id, user_id)
|
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`,
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
|
||||||
}
|
}
|
||||||
for _, statement := range statements {
|
for _, statement := range statements {
|
||||||
@ -168,9 +183,25 @@ func (r *Repository) Migrate(ctx context.Context) error {
|
|||||||
`ALTER TABLE stat_app_day_country ADD COLUMN lucky_gift_payout BIGINT NOT NULL DEFAULT 0 AFTER lucky_gift_turnover`,
|
`ALTER TABLE stat_app_day_country ADD COLUMN lucky_gift_payout BIGINT NOT NULL DEFAULT 0 AFTER lucky_gift_turnover`,
|
||||||
`ALTER TABLE stat_app_day_country ADD COLUMN coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0 AFTER google_recharge_usd_minor`,
|
`ALTER TABLE stat_app_day_country ADD COLUMN coin_seller_transfer_coin BIGINT NOT NULL DEFAULT 0 AFTER google_recharge_usd_minor`,
|
||||||
`ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`,
|
`ALTER TABLE stat_game_day_country ADD COLUMN refund_coin BIGINT NOT NULL DEFAULT 0 AFTER payout_coin`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_user_day_activity ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_user_registration ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_recharge_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_lucky_gift_day_payers ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_lucky_gift_pool_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_game_day_country ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_game_day_players ADD COLUMN region_id BIGINT NOT NULL DEFAULT 0 AFTER country_id`,
|
||||||
|
`ALTER TABLE stat_app_day_country ADD KEY idx_stat_app_day_region (app_code, stat_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_user_day_activity ADD KEY idx_stat_user_day_region (app_code, stat_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_user_registration ADD KEY idx_stat_user_registration_region (app_code, registered_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_recharge_day_payers ADD KEY idx_stat_recharge_payer_region (app_code, stat_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_lucky_gift_day_payers ADD KEY idx_stat_lucky_payer_region (app_code, stat_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_lucky_gift_pool_day_country ADD KEY idx_stat_lucky_pool_region (app_code, stat_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_game_day_country ADD KEY idx_stat_game_day_region (app_code, stat_day, region_id)`,
|
||||||
|
`ALTER TABLE stat_game_day_players ADD KEY idx_stat_game_player_region (app_code, stat_day, region_id)`,
|
||||||
}
|
}
|
||||||
for _, statement := range alterStatements {
|
for _, statement := range alterStatements {
|
||||||
if _, err := r.db.ExecContext(ctx, statement); err != nil && !isDuplicateColumnError(err) {
|
if _, err := r.db.ExecContext(ctx, statement); err != nil && !isDuplicateSchemaError(err) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -181,6 +212,7 @@ type UserRegisteredEvent struct {
|
|||||||
AppCode string
|
AppCode string
|
||||||
EventID string
|
EventID string
|
||||||
UserID int64
|
UserID int64
|
||||||
|
CountryID int64
|
||||||
RegionID int64
|
RegionID int64
|
||||||
OccurredAtMS int64
|
OccurredAtMS int64
|
||||||
}
|
}
|
||||||
@ -193,15 +225,28 @@ type RechargeEvent struct {
|
|||||||
USDMinor int64
|
USDMinor int64
|
||||||
CoinAmount int64
|
CoinAmount int64
|
||||||
RechargeSequence int64
|
RechargeSequence int64
|
||||||
|
TargetCountryID int64
|
||||||
TargetRegionID int64
|
TargetRegionID int64
|
||||||
RechargeType string
|
RechargeType string
|
||||||
OccurredAtMS int64
|
OccurredAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CoinSellerStockEvent struct {
|
||||||
|
AppCode string
|
||||||
|
EventID string
|
||||||
|
SellerUserID int64
|
||||||
|
USDMinor int64
|
||||||
|
CoinAmount int64
|
||||||
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
|
OccurredAtMS int64
|
||||||
|
}
|
||||||
|
|
||||||
type RoomGiftEvent struct {
|
type RoomGiftEvent struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
EventID string
|
EventID string
|
||||||
SenderUserID int64
|
SenderUserID int64
|
||||||
|
CountryID int64
|
||||||
VisibleRegionID int64
|
VisibleRegionID int64
|
||||||
GiftValue int64
|
GiftValue int64
|
||||||
CoinSpent int64
|
CoinSpent int64
|
||||||
@ -215,6 +260,7 @@ type LuckyGiftRewardEvent struct {
|
|||||||
EventType string
|
EventType string
|
||||||
UserID int64
|
UserID int64
|
||||||
CountryID int64
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
PoolID string
|
PoolID string
|
||||||
Amount int64
|
Amount int64
|
||||||
OccurredAtMS int64
|
OccurredAtMS int64
|
||||||
@ -226,6 +272,7 @@ type UserActiveEvent struct {
|
|||||||
EventType string
|
EventType string
|
||||||
UserID int64
|
UserID int64
|
||||||
CountryID int64
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
OccurredAtMS int64
|
OccurredAtMS int64
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -234,6 +281,7 @@ type GameOrderEvent struct {
|
|||||||
EventID string
|
EventID string
|
||||||
UserID int64
|
UserID int64
|
||||||
CountryID int64
|
CountryID int64
|
||||||
|
RegionID int64
|
||||||
PlatformCode string
|
PlatformCode string
|
||||||
GameID string
|
GameID string
|
||||||
OpType string
|
OpType string
|
||||||
@ -244,14 +292,14 @@ type GameOrderEvent struct {
|
|||||||
func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegisteredEvent) error {
|
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 {
|
return r.withEvent(ctx, SourceUser, event.EventID, "UserRegistered", func(tx *sql.Tx, nowMS int64) error {
|
||||||
day := statDay(event.OccurredAtMS)
|
day := statDay(event.OccurredAtMS)
|
||||||
countryID := normalizeID(event.RegionID)
|
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||||||
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
INSERT IGNORE INTO stat_user_registration (app_code, user_id, registered_day, country_id, registered_at_ms)
|
INSERT IGNORE INTO stat_user_registration (app_code, user_id, registered_day, country_id, region_id, registered_at_ms)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`, appcode.Normalize(event.AppCode), event.UserID, day, countryID, event.OccurredAtMS); err != nil {
|
`, appcode.Normalize(event.AppCode), event.UserID, day, countryID, regionID, event.OccurredAtMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
@ -266,14 +314,14 @@ func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegist
|
|||||||
func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) error {
|
func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) error {
|
||||||
return r.withEvent(ctx, SourceWallet, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
return r.withEvent(ctx, SourceWallet, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
||||||
day := statDay(event.OccurredAtMS)
|
day := statDay(event.OccurredAtMS)
|
||||||
countryID := normalizeID(event.TargetRegionID)
|
countryID, regionID := normalizeDimension(event.TargetCountryID, event.TargetRegionID)
|
||||||
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
paidUsers, err := insertUnique(ctx, tx, `
|
paidUsers, err := insertUnique(ctx, tx, `
|
||||||
INSERT IGNORE INTO stat_recharge_day_payers (app_code, stat_day, country_id, user_id, first_paid_at_ms)
|
INSERT IGNORE INTO stat_recharge_day_payers (app_code, stat_day, country_id, region_id, user_id, first_paid_at_ms)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`, appcode.Normalize(event.AppCode), day, countryID, event.UserID, event.OccurredAtMS)
|
`, appcode.Normalize(event.AppCode), day, countryID, regionID, event.UserID, event.OccurredAtMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -308,11 +356,30 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ConsumeCoinSellerStock(ctx context.Context, event CoinSellerStockEvent) error {
|
||||||
|
return r.withEvent(ctx, SourceWallet, event.EventID, "WalletCoinSellerStockPurchased", func(tx *sql.Tx, nowMS int64) error {
|
||||||
|
day := statDay(event.OccurredAtMS)
|
||||||
|
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||||||
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 币商进货是后台 USDT 入金事实,只进入总充值和币商充值列;它不是普通用户到账,所以不增加用户 USDT 首充和付费用户。
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE stat_app_day_country
|
||||||
|
SET recharge_usd_minor = recharge_usd_minor + ?,
|
||||||
|
coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?,
|
||||||
|
updated_at_ms = ?
|
||||||
|
WHERE app_code = ? AND stat_day = ? AND country_id = ?
|
||||||
|
`, event.USDMinor, event.USDMinor, nowMS, appcode.Normalize(event.AppCode), day, countryID)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) error {
|
func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) error {
|
||||||
return r.withEvent(ctx, SourceRoom, event.EventID, "RoomGiftSent", func(tx *sql.Tx, nowMS int64) error {
|
return r.withEvent(ctx, SourceRoom, event.EventID, "RoomGiftSent", func(tx *sql.Tx, nowMS int64) error {
|
||||||
day := statDay(event.OccurredAtMS)
|
day := statDay(event.OccurredAtMS)
|
||||||
countryID := normalizeID(event.VisibleRegionID)
|
countryID, regionID := normalizeDimension(event.CountryID, event.VisibleRegionID)
|
||||||
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
luckyTurnover, luckyPayers := int64(0), int64(0)
|
luckyTurnover, luckyPayers := int64(0), int64(0)
|
||||||
@ -320,15 +387,15 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e
|
|||||||
if poolID != "" {
|
if poolID != "" {
|
||||||
luckyTurnover = event.CoinSpent
|
luckyTurnover = event.CoinSpent
|
||||||
affected, err := insertUnique(ctx, tx, `
|
affected, err := insertUnique(ctx, tx, `
|
||||||
INSERT IGNORE INTO stat_lucky_gift_day_payers (app_code, stat_day, country_id, user_id, first_paid_at_ms)
|
INSERT IGNORE INTO stat_lucky_gift_day_payers (app_code, stat_day, country_id, region_id, user_id, first_paid_at_ms)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`, appcode.Normalize(event.AppCode), day, countryID, event.SenderUserID, event.OccurredAtMS)
|
`, appcode.Normalize(event.AppCode), day, countryID, regionID, event.SenderUserID, event.OccurredAtMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
luckyPayers = affected
|
luckyPayers = affected
|
||||||
// Pool 明细是 Databi 点击下钻的真实口径:每笔带 pool_id 的幸运礼物送礼先按 UTC 日、国家和奖池累加流水,返奖事件稍后用相同维度补齐 payout。
|
// Pool 明细是 Databi 点击下钻的真实口径:每笔带 pool_id 的幸运礼物送礼先按 UTC 日、国家和奖池累加流水,返奖事件稍后用相同维度补齐 payout。
|
||||||
if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, poolID, nowMS); err != nil {
|
if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, regionID, poolID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
@ -352,14 +419,14 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e
|
|||||||
func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGiftRewardEvent) error {
|
func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGiftRewardEvent) error {
|
||||||
return r.withEvent(ctx, SourceWallet, event.EventID, "WalletLuckyGiftRewardCredited", func(tx *sql.Tx, nowMS int64) error {
|
return r.withEvent(ctx, SourceWallet, event.EventID, "WalletLuckyGiftRewardCredited", func(tx *sql.Tx, nowMS int64) error {
|
||||||
day := statDay(event.OccurredAtMS)
|
day := statDay(event.OccurredAtMS)
|
||||||
countryID := normalizeID(event.CountryID)
|
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||||||
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
poolID := strings.TrimSpace(event.PoolID)
|
poolID := strings.TrimSpace(event.PoolID)
|
||||||
if poolID != "" {
|
if poolID != "" {
|
||||||
// 钱包返奖事件带回 pool_id 后,按同一 UTC 日和国家写入奖池返奖;利润不落表,查询时用 turnover-payout 实时计算,避免双写漂移。
|
// 钱包返奖事件带回 pool_id 后,按同一 UTC 日和国家写入奖池返奖;利润不落表,查询时用 turnover-payout 实时计算,避免双写漂移。
|
||||||
if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, poolID, nowMS); err != nil {
|
if err := ensureLuckyGiftPoolDay(ctx, tx, event.AppCode, day, countryID, regionID, poolID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
@ -381,18 +448,19 @@ func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGift
|
|||||||
|
|
||||||
func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error {
|
func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEvent) error {
|
||||||
return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
return r.withEvent(ctx, SourceRoom, event.EventID, event.EventType, func(tx *sql.Tx, nowMS int64) error {
|
||||||
return applyActive(ctx, tx, event.AppCode, statDay(event.OccurredAtMS), normalizeID(event.CountryID), event.UserID, event.OccurredAtMS, nowMS)
|
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||||||
|
return applyActive(ctx, tx, event.AppCode, statDay(event.OccurredAtMS), countryID, regionID, event.UserID, event.OccurredAtMS, nowMS)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent) error {
|
func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent) error {
|
||||||
return r.withEvent(ctx, SourceGame, event.EventID, "GameOrderSettled", func(tx *sql.Tx, nowMS int64) error {
|
return r.withEvent(ctx, SourceGame, event.EventID, "GameOrderSettled", func(tx *sql.Tx, nowMS int64) error {
|
||||||
day := statDay(event.OccurredAtMS)
|
day := statDay(event.OccurredAtMS)
|
||||||
countryID := normalizeID(event.CountryID)
|
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
|
||||||
if err := applyActive(ctx, tx, event.AppCode, day, countryID, event.UserID, event.OccurredAtMS, nowMS); err != nil {
|
if err := applyActive(ctx, tx, event.AppCode, day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, nowMS); err != nil {
|
if err := ensureAppDay(ctx, tx, event.AppCode, day, countryID, regionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
turnover, payout, refund := int64(0), int64(0), int64(0)
|
turnover, payout, refund := int64(0), int64(0), int64(0)
|
||||||
@ -407,15 +475,15 @@ func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent)
|
|||||||
playerDelta := int64(0)
|
playerDelta := int64(0)
|
||||||
if turnover > 0 {
|
if turnover > 0 {
|
||||||
affected, err := insertUnique(ctx, tx, `
|
affected, err := insertUnique(ctx, tx, `
|
||||||
INSERT IGNORE INTO stat_game_day_players (app_code, stat_day, country_id, platform_code, game_id, user_id, first_played_at_ms)
|
INSERT IGNORE INTO stat_game_day_players (app_code, stat_day, country_id, region_id, platform_code, game_id, user_id, first_played_at_ms)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`, appcode.Normalize(event.AppCode), day, countryID, event.PlatformCode, event.GameID, event.UserID, event.OccurredAtMS)
|
`, appcode.Normalize(event.AppCode), day, countryID, regionID, event.PlatformCode, event.GameID, event.UserID, event.OccurredAtMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
playerDelta = affected
|
playerDelta = affected
|
||||||
}
|
}
|
||||||
if err := ensureGameDay(ctx, tx, event.AppCode, day, countryID, event.PlatformCode, event.GameID, nowMS); err != nil {
|
if err := ensureGameDay(ctx, tx, event.AppCode, day, countryID, regionID, event.PlatformCode, event.GameID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
@ -501,9 +569,9 @@ func isRetryableMySQLError(err error) bool {
|
|||||||
return mysqlErr.Number == 1213 || mysqlErr.Number == 1205
|
return mysqlErr.Number == 1213 || mysqlErr.Number == 1205
|
||||||
}
|
}
|
||||||
|
|
||||||
func isDuplicateColumnError(err error) bool {
|
func isDuplicateSchemaError(err error) bool {
|
||||||
var mysqlErr *mysqlerr.MySQLError
|
var mysqlErr *mysqlerr.MySQLError
|
||||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1060
|
return errors.As(err, &mysqlErr) && (mysqlErr.Number == 1060 || mysqlErr.Number == 1061)
|
||||||
}
|
}
|
||||||
|
|
||||||
func isMissingTableError(err error) bool {
|
func isMissingTableError(err error) bool {
|
||||||
@ -511,14 +579,14 @@ func isMissingTableError(err error) bool {
|
|||||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1146
|
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1146
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyActive(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, userID int64, occurredAtMS int64, nowMS int64) error {
|
func applyActive(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, userID int64, occurredAtMS int64, nowMS int64) error {
|
||||||
if err := ensureAppDay(ctx, tx, app, day, countryID, nowMS); err != nil {
|
if err := ensureAppDay(ctx, tx, app, day, countryID, regionID, nowMS); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
affected, err := insertUnique(ctx, tx, `
|
affected, err := insertUnique(ctx, tx, `
|
||||||
INSERT IGNORE INTO stat_user_day_activity (app_code, stat_day, country_id, user_id, first_active_at_ms)
|
INSERT IGNORE INTO stat_user_day_activity (app_code, stat_day, country_id, region_id, user_id, first_active_at_ms)
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
`, appcode.Normalize(app), day, countryID, userID, occurredAtMS)
|
`, appcode.Normalize(app), day, countryID, regionID, userID, occurredAtMS)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -533,27 +601,36 @@ func applyActive(ctx context.Context, tx *sql.Tx, app string, day string, countr
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func ensureAppDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, nowMS int64) error {
|
func ensureAppDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, nowMS int64) error {
|
||||||
_, err := tx.ExecContext(ctx, `
|
_, err := tx.ExecContext(ctx, `
|
||||||
INSERT IGNORE INTO stat_app_day_country (app_code, stat_day, country_id, updated_at_ms)
|
INSERT INTO stat_app_day_country (app_code, stat_day, country_id, region_id, updated_at_ms)
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`, appcode.Normalize(app), day, countryID, nowMS)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureGameDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, platformCode string, gameID string, nowMS int64) error {
|
|
||||||
_, err := tx.ExecContext(ctx, `
|
|
||||||
INSERT IGNORE INTO stat_game_day_country (app_code, stat_day, country_id, platform_code, game_id, updated_at_ms)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`, appcode.Normalize(app), day, countryID, platformCode, gameID, nowMS)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
func ensureLuckyGiftPoolDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, poolID string, nowMS int64) error {
|
|
||||||
_, err := tx.ExecContext(ctx, `
|
|
||||||
INSERT IGNORE INTO stat_lucky_gift_pool_day_country (app_code, stat_day, country_id, pool_id, updated_at_ms)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?)
|
||||||
`, appcode.Normalize(app), day, countryID, strings.TrimSpace(poolID), nowMS)
|
ON DUPLICATE KEY UPDATE
|
||||||
|
region_id = IF(region_id = 0 AND VALUES(region_id) > 0, VALUES(region_id), region_id),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
|
`, appcode.Normalize(app), day, countryID, regionID, nowMS)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureGameDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, platformCode string, gameID string, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_game_day_country (app_code, stat_day, country_id, region_id, platform_code, game_id, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
region_id = IF(region_id = 0 AND VALUES(region_id) > 0, VALUES(region_id), region_id),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
|
`, appcode.Normalize(app), day, countryID, regionID, platformCode, gameID, nowMS)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureLuckyGiftPoolDay(ctx context.Context, tx *sql.Tx, app string, day string, countryID int64, regionID int64, poolID string, nowMS int64) error {
|
||||||
|
_, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO stat_lucky_gift_pool_day_country (app_code, stat_day, country_id, region_id, pool_id, updated_at_ms)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
region_id = IF(region_id = 0 AND VALUES(region_id) > 0, VALUES(region_id), region_id),
|
||||||
|
updated_at_ms = VALUES(updated_at_ms)
|
||||||
|
`, appcode.Normalize(app), day, countryID, regionID, strings.TrimSpace(poolID), nowMS)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -580,6 +657,10 @@ func normalizeID(id int64) int64 {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeDimension(countryID int64, regionID int64) (int64, int64) {
|
||||||
|
return normalizeID(countryID), normalizeID(regionID)
|
||||||
|
}
|
||||||
|
|
||||||
func DecodeJSON(payload string) map[string]any {
|
func DecodeJSON(payload string) map[string]any {
|
||||||
decoded := map[string]any{}
|
decoded := map[string]any{}
|
||||||
_ = json.Unmarshal([]byte(payload), &decoded)
|
_ = json.Unmarshal([]byte(payload), &decoded)
|
||||||
|
|||||||
@ -158,6 +158,7 @@ func insertUserRegisteredOutbox(ctx context.Context, tx *sql.Tx, user userdomain
|
|||||||
"user_id": user.UserID,
|
"user_id": user.UserID,
|
||||||
"default_display_user_id": user.DefaultDisplayUserID,
|
"default_display_user_id": user.DefaultDisplayUserID,
|
||||||
"country": user.Country,
|
"country": user.Country,
|
||||||
|
"country_id": user.CountryID,
|
||||||
"country_by_ip": user.CountryByIP,
|
"country_by_ip": user.CountryByIP,
|
||||||
"region_id": user.RegionID,
|
"region_id": user.RegionID,
|
||||||
"invite_code": user.InviteCode,
|
"invite_code": user.InviteCode,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user