From dd20dbcd4d3ad232f5979827de6ee7cedc03fe36 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 9 Jul 2026 16:17:00 +0800 Subject: [PATCH] feat: add social BI requirements stats --- .../internal/modules/dashboard/service.go | 54 + .../admin/internal/modules/databi/handler.go | 35 + .../admin/internal/modules/databi/routes.go | 1 + .../admin/internal/modules/databi/service.go | 125 +++ .../mysql/initdb/001_statistics_service.sql | 61 ++ .../statistics-service/internal/app/app.go | 2 + .../internal/app/query_http.go | 31 + .../internal/storage/mysql/query_test.go | 189 ++++ .../internal/storage/mysql/repository.go | 326 +++++- .../mysql/social_requirements_query.go | 967 ++++++++++++++++++ .../internal/storage/mysql/social_user_day.go | 266 +++++ 11 files changed, 2034 insertions(+), 23 deletions(-) create mode 100644 services/statistics-service/internal/storage/mysql/social_requirements_query.go create mode 100644 services/statistics-service/internal/storage/mysql/social_user_day.go diff --git a/server/admin/internal/modules/dashboard/service.go b/server/admin/internal/modules/dashboard/service.go index 9b035c9a..2722f55d 100644 --- a/server/admin/internal/modules/dashboard/service.go +++ b/server/admin/internal/modules/dashboard/service.go @@ -54,6 +54,19 @@ type AppTrackingFunnelQuery struct { CountryID int64 } +type SocialRequirementsQuery struct { + AppCode string + StatTZ string + StartMS int64 + EndMS int64 + RegionID int64 + RegionIDs []int64 + CountryID int64 + Section string + UserRole string + PayerType string +} + type PlatformGrantStatisticsQuery struct { AppCode string StatTZ string @@ -208,6 +221,47 @@ func (s *DashboardService) AppTrackingFunnel(ctx context.Context, query AppTrack return s.statisticsGET(ctx, "/internal/v1/statistics/app/funnel", values) } +func (s *DashboardService) SocialRequirements(ctx context.Context, query SocialRequirementsQuery) (map[string]any, error) { + values := url.Values{} + values.Set("app_code", query.AppCode) + if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" { + values.Set("stat_tz", statTZ) + } + if query.StartMS > 0 { + values.Set("start_ms", strconv.FormatInt(query.StartMS, 10)) + } + if query.EndMS > 0 { + values.Set("end_ms", strconv.FormatInt(query.EndMS, 10)) + } + if query.RegionID > 0 { + values.Set("region_id", strconv.FormatInt(query.RegionID, 10)) + } else if len(query.RegionIDs) > 0 { + parts := make([]string, 0, len(query.RegionIDs)) + for _, regionID := range query.RegionIDs { + if regionID > 0 { + parts = append(parts, strconv.FormatInt(regionID, 10)) + } + } + if len(parts) > 0 { + values.Set("region_ids", strings.Join(parts, ",")) + } + } + if query.CountryID > 0 { + values.Set("country_id", strconv.FormatInt(query.CountryID, 10)) + } + if section := strings.TrimSpace(query.Section); section != "" { + values.Set("section", section) + } + if userRole := strings.TrimSpace(query.UserRole); userRole != "" { + values.Set("user_role", userRole) + } + if payerType := strings.TrimSpace(query.PayerType); payerType != "" { + values.Set("payer_type", payerType) + } + // Social BI 数据需求只读 statistics-service 聚合投影;admin 不直接拼 owner service 明细或跨库查询。 + return s.statisticsGET(ctx, "/internal/v1/statistics/social/requirements", values) +} + func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) { page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false)) if err != nil { diff --git a/server/admin/internal/modules/databi/handler.go b/server/admin/internal/modules/databi/handler.go index 318264cf..ed517d0d 100644 --- a/server/admin/internal/modules/databi/handler.go +++ b/server/admin/internal/modules/databi/handler.go @@ -72,6 +72,29 @@ func (h *Handler) Funnel(c *gin.Context) { response.OK(c, result) } +func (h *Handler) Requirements(c *gin.Context) { + access, ok := h.moneyAccess(c) + if !ok { + return + } + result, err := h.service.Requirements(c.Request.Context(), access, RequirementsQuery{ + StatTZ: c.Query("stat_tz"), + StartMS: queryInt64(c, "start_ms"), + EndMS: queryInt64(c, "end_ms"), + AppCodes: splitCSV(c.Query("app_codes")), + RegionID: queryInt64(c, "region_id"), + RegionIDs: queryInt64CSV(c.Query("region_ids")), + Section: c.Query("section"), + UserRole: c.Query("user_role"), + PayerType: c.Query("payer_type"), + }) + if err != nil { + response.ServerError(c, "获取数据需求指标失败") + return + } + response.OK(c, result) +} + func (h *Handler) Kpi(c *gin.Context) { access, ok := h.moneyAccess(c) if !ok { @@ -131,3 +154,15 @@ func splitCSV(value string) []string { } return out } + +func queryInt64CSV(value string) []int64 { + parts := strings.Split(value, ",") + out := make([]int64, 0, len(parts)) + for _, part := range parts { + parsed, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err == nil && parsed > 0 { + out = append(out, parsed) + } + } + return out +} diff --git a/server/admin/internal/modules/databi/routes.go b/server/admin/internal/modules/databi/routes.go index ed519bfc..30fda8ab 100644 --- a/server/admin/internal/modules/databi/routes.go +++ b/server/admin/internal/modules/databi/routes.go @@ -10,5 +10,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) { protected.GET("/admin/databi/social/master", middleware.RequirePermission("overview:view"), h.Master) protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview) protected.GET("/admin/databi/social/funnel", middleware.RequirePermission("overview:view"), h.Funnel) + protected.GET("/admin/databi/social/requirements", middleware.RequirePermission("overview:view"), h.Requirements) protected.GET("/admin/databi/social/kpi", middleware.RequirePermission("overview:view"), h.Kpi) } diff --git a/server/admin/internal/modules/databi/service.go b/server/admin/internal/modules/databi/service.go index 864a2ca5..d8d310e7 100644 --- a/server/admin/internal/modules/databi/service.go +++ b/server/admin/internal/modules/databi/service.go @@ -297,6 +297,34 @@ type FunnelResult struct { Access AccessInfo `json:"access"` } +type RequirementsQuery struct { + StatTZ string + StartMS int64 + EndMS int64 + AppCodes []string + RegionID int64 + RegionIDs []int64 + Section string + UserRole string + PayerType string +} + +type AppRequirements struct { + AppCode string `json:"app_code"` + AppName string `json:"app_name"` + Kind string `json:"kind"` + Restricted bool `json:"restricted"` + AllowedRegionIDs []int64 `json:"allowed_region_ids,omitempty"` + Sections []map[string]any `json:"sections"` + UpdatedAtMS int64 `json:"updated_at_ms"` + Error string `json:"error,omitempty"` +} + +type RequirementsResult struct { + Apps []AppRequirements `json:"apps"` + Access AccessInfo `json:"access"` +} + // Overview 服务端并发聚合各 App 的统计总览:hyapp App 走 statistics-service, // legacy App 走 dashboard 外部源;国家行按区域目录卷积出大区行,并按用户数据范围裁剪。 func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, query OverviewQuery) (*OverviewResult, error) { @@ -360,6 +388,103 @@ func (s *Service) Funnel(ctx context.Context, access repository.MoneyAccess, que }, nil } +func (s *Service) Requirements(ctx context.Context, access repository.MoneyAccess, query RequirementsQuery) (*RequirementsResult, error) { + apps, err := s.listAllowedApps(ctx, query.AppCodes, access) + if err != nil { + return nil, err + } + catalog, err := s.regionCatalog(ctx, apps) + if err != nil { + return nil, err + } + + results := make([]AppRequirements, len(apps)) + var wg sync.WaitGroup + semaphore := make(chan struct{}, overviewConcurrency) + for index, app := range apps { + wg.Add(1) + go func(index int, app AppInfo) { + defer wg.Done() + semaphore <- struct{}{} + defer func() { <-semaphore }() + results[index] = s.appRequirements(ctx, app, catalog[app.AppCode], access, query) + }(index, app) + } + wg.Wait() + + return &RequirementsResult{ + Apps: results, + Access: AccessInfo{All: access.All, Scopes: scopeInfos(access.Scopes)}, + }, nil +} + +func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query RequirementsQuery) AppRequirements { + out := AppRequirements{ + AppCode: app.AppCode, + AppName: app.AppName, + Kind: app.Kind, + Sections: []map[string]any{}, + } + if app.Kind != appKindHyapp { + out.Error = "该 App 暂未接入数据需求宽表" + return out + } + allowAll, allowedIDs := allowedRegions(access, app.AppCode) + allowedIDs = normalizeRegionIDs(regions, allowedIDs) + if !allowAll && len(allowedIDs) == 0 { + out.Error = "没有该 App 的数据权限" + return out + } + requestRegionIDs := normalizeRegionIDs(regions, query.RegionIDs) + if len(query.RegionIDs) > 0 && len(requestRegionIDs) == 0 { + out.Error = "该 App 未配置所选区域" + return out + } + if query.RegionID > 0 && !access.Allows(app.AppCode, query.RegionID) { + out.Error = "没有该区域的数据权限" + return out + } + for _, regionID := range requestRegionIDs { + if !access.Allows(app.AppCode, regionID) { + out.Error = "没有该区域的数据权限" + return out + } + } + if query.RegionID > 0 && !regionInCatalog(regions, query.RegionID) { + out.Error = "该 App 未配置此区域" + return out + } + restricted := !allowAll && query.RegionID == 0 && len(requestRegionIDs) == 0 + regionIDs := []int64(nil) + if restricted { + // 用户没有全局范围时,admin 只把允许的大区 ID 传给 statistics-service;查询层仍只读聚合投影,不回扫业务库补权限。 + out.Restricted = true + out.AllowedRegionIDs = allowedIDs + regionIDs = allowedIDs + } else if len(requestRegionIDs) > 0 { + // 顶栏多选区域时保留多区域筛选;单区域仍走 region_id 以兼容旧统计入口。 + regionIDs = requestRegionIDs + } + requirements, err := s.dashboards.SocialRequirements(ctx, dashboard.SocialRequirementsQuery{ + AppCode: app.AppCode, + StatTZ: query.StatTZ, + StartMS: query.StartMS, + EndMS: query.EndMS, + RegionID: query.RegionID, + RegionIDs: regionIDs, + Section: query.Section, + UserRole: query.UserRole, + PayerType: query.PayerType, + }) + if err != nil { + out.Error = fmt.Sprintf("获取数据需求指标失败: %v", err) + return out + } + out.Sections = anyRowSlice(requirements["sections"]) + out.UpdatedAtMS = time.Now().UnixMilli() + return out +} + func (s *Service) appFunnel(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query FunnelQuery) AppFunnel { out := AppFunnel{ AppCode: app.AppCode, diff --git a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql index ac2d4ae8..0a7704ad 100644 --- a/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql +++ b/services/statistics-service/deploy/mysql/initdb/001_statistics_service.sql @@ -139,6 +139,67 @@ CREATE TABLE IF NOT EXISTS stat_user_day_mic ( KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户每日麦上时长去重表'; +CREATE TABLE IF NOT EXISTS stat_social_user_day ( + app_code VARCHAR(32) NOT NULL COMMENT '应用编码', + stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', + stat_day DATE NOT NULL COMMENT '统计日,随 stat_tz 切换', + subject_key VARCHAR(160) NOT NULL COMMENT '用户或设备维度去重键,u: 或 d:', + user_id BIGINT NOT NULL DEFAULT 0 COMMENT '登录用户 ID,匿名为 0', + device_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '匿名设备 ID', + country_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时国家维度', + region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时区域维度', + user_role VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '当日用户身份快照,user 或 host', + app_first_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '首次打开 App 去重标记', + registered_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '注册成功去重标记', + active_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当日活跃去重标记', + room_join_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '进房成功去重标记', + room_join_fail TINYINT(1) NOT NULL DEFAULT 0 COMMENT '进房失败去重标记', + mic_up_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '上麦成功去重标记', + gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '送礼成功去重标记', + normal_gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通礼物送礼去重标记', + lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '幸运礼物送礼去重标记', + super_lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '超级幸运礼物送礼去重标记', + recharge_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当日充值用户去重标记', + first_recharge_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '首充用户去重标记', + repeat_recharge_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '复购用户去重标记', + game_bet_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '游戏押注用户去重标记', + self_game_bet_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '自研游戏押注用户去重标记', + probability_game_bet_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '概率游戏押注用户去重标记', + chat_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '公屏聊天去重标记', + follow_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '关注用户去重标记', + follow_room TINYINT(1) NOT NULL DEFAULT 0 COMMENT '关注房间去重标记', + gift_panel_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '礼物面板打开去重标记', + probability_game_panel_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '概率游戏面板打开去重标记', + app_stay_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'App 停留时长毫秒', + room_stay_ms BIGINT NOT NULL DEFAULT 0 COMMENT '房间停留时长毫秒', + mic_stay_ms BIGINT NOT NULL DEFAULT 0 COMMENT '麦上停留时长毫秒', + recharge_count BIGINT NOT NULL DEFAULT 0 COMMENT '充值次数', + recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '总充值 USD 最小单位', + google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值 USD 最小单位', + third_party_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方充值 USD 最小单位', + coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '币商充值 USD 最小单位', + first_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '首充 USD 最小单位', + repeat_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '复购 USD 最小单位', + normal_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '普通礼物金币流水', + lucky_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物金币流水', + super_lucky_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '超级幸运礼物金币流水', + lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物返奖金币', + super_lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '超级幸运礼物返奖金币', + game_bet_coin BIGINT NOT NULL DEFAULT 0 COMMENT '游戏押注金币', + game_bet_count BIGINT NOT NULL DEFAULT 0 COMMENT '游戏押注次数', + self_game_bet_coin BIGINT NOT NULL DEFAULT 0 COMMENT '自研游戏押注金币', + self_game_bet_count BIGINT NOT NULL DEFAULT 0 COMMENT '自研游戏押注次数', + probability_game_bet_coin BIGINT NOT NULL DEFAULT 0 COMMENT '概率游戏押注金币', + probability_game_bet_count BIGINT NOT NULL DEFAULT 0 COMMENT '概率游戏押注次数', + other_consume_coin BIGINT NOT NULL DEFAULT 0 COMMENT '其他消费金币', + output_coin BIGINT NOT NULL DEFAULT 0 COMMENT '产出金币', + updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms', + PRIMARY KEY (app_code, stat_tz, stat_day, subject_key), + KEY idx_stat_social_user_day_user (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_social_user_day_region (app_code, stat_tz, stat_day, region_id), + KEY idx_stat_social_user_day_filter (app_code, stat_tz, stat_day, user_role, recharge_user) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Social BI P0-P5 用户日级读模型'; + CREATE TABLE IF NOT EXISTS stat_recharge_day_payers ( app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区,UTC 或 Asia/Shanghai', diff --git a/services/statistics-service/internal/app/app.go b/services/statistics-service/internal/app/app.go index 2881c931..058ed454 100644 --- a/services/statistics-service/internal/app/app.go +++ b/services/statistics-service/internal/app/app.go @@ -284,6 +284,7 @@ func userRegisteredEvent(body []byte) (mysqlstorage.UserRegisteredEvent, bool, e AppCode: message.AppCode, EventID: message.EventID, UserID: message.AggregateID, + Role: firstNonEmpty(mysqlstorage.String(payload, "role"), mysqlstorage.String(payload, "user_role")), CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "target_country_id")), RegionID: mysqlstorage.Int64(payload, "region_id"), OccurredAtMS: message.OccurredAtMS, @@ -601,6 +602,7 @@ func roomJoinActiveEvent(body []byte) (mysqlstorage.UserActiveEvent, bool, error EventID: envelope.GetEventId(), EventType: envelope.GetEventType(), UserID: joined.GetUserId(), + Role: joined.GetRole(), CountryID: joined.GetCountryId(), RegionID: firstNonZeroInt64(joined.GetRegionId(), joined.GetVisibleRegionId()), OccurredAtMS: envelope.GetOccurredAtMs(), diff --git a/services/statistics-service/internal/app/query_http.go b/services/statistics-service/internal/app/query_http.go index e6adc2e6..8b995b1e 100644 --- a/services/statistics-service/internal/app/query_http.go +++ b/services/statistics-service/internal/app/query_http.go @@ -36,6 +36,7 @@ func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPS mux.HandleFunc("/internal/v1/statistics/platform-grants/records", s.platformGrantRecords) mux.HandleFunc("/internal/v1/statistics/app/events", s.appEvents) mux.HandleFunc("/internal/v1/statistics/app/funnel", s.appFunnel) + mux.HandleFunc("/internal/v1/statistics/social/requirements", s.socialRequirements) mux.HandleFunc("/internal/v1/statistics/self-game/events", s.selfGameEvents) mux.HandleFunc("/internal/v1/statistics/self-games/overview", s.selfGameOverview) s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second} @@ -171,6 +172,36 @@ func (s *queryHTTPServer) appFunnel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, funnel) } +func (s *queryHTTPServer) socialRequirements(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query() + startMS := parseInt64(query.Get("start_ms")) + endMS := parseInt64(query.Get("end_ms")) + if startMS <= 0 { + now := time.Now().UTC() + startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() + } + if endMS <= startMS { + endMS = startMS + 24*time.Hour.Milliseconds() + } + requirements, err := s.repo.QuerySocialRequirements(r.Context(), mysqlstorage.SocialRequirementsQuery{ + AppCode: query.Get("app_code"), + StatTZ: query.Get("stat_tz"), + StartMS: startMS, + EndMS: endMS, + CountryID: parseInt64(query.Get("country_id")), + RegionID: parseInt64(query.Get("region_id")), + RegionIDs: parseInt64CSV(query.Get("region_ids")), + Section: query.Get("section"), + UserRole: query.Get("user_role"), + PayerType: query.Get("payer_type"), + }) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, requirements) +} + type selfGameH5EventRequest struct { AppCode string `json:"app_code"` EventID string `json:"event_id"` diff --git a/services/statistics-service/internal/storage/mysql/query_test.go b/services/statistics-service/internal/storage/mysql/query_test.go index 41c03254..2cd0869e 100644 --- a/services/statistics-service/internal/storage/mysql/query_test.go +++ b/services/statistics-service/internal/storage/mysql/query_test.go @@ -258,6 +258,195 @@ func TestQueryAppTrackingFunnelAggregatesStepsAndD1Cohorts(t *testing.T) { } } +func TestQuerySocialRequirementsAggregatesP0ToP5(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_social_requirements_test", + }) + repository, err := Open(ctx, statsSchema.DSN) + if err != nil { + t.Fatalf("open repository: %v", err) + } + t.Cleanup(func() { _ = repository.Close() }) + + start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + user1, user2 := int64(1001), int64(1002) + for _, event := range []UserRegisteredEvent{ + {AppCode: "lalu", EventID: "social:user:1001", UserID: user1, Role: "host", CountryID: 86, RegionID: 210, OccurredAtMS: start.UnixMilli()}, + {AppCode: "lalu", EventID: "social:user:1002", UserID: user2, Role: "user", CountryID: 86, RegionID: 210, OccurredAtMS: start.Add(time.Minute).UnixMilli()}, + } { + if err := repository.ConsumeUserRegistered(ctx, event); err != nil { + t.Fatalf("consume registration %d: %v", event.UserID, err) + } + } + for _, event := range []AppTrackingEvent{ + {AppCode: "lalu", EventID: "social:first-open:1001", EventName: "app_first_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()}, + {AppCode: "lalu", EventID: "social:first-open:1002", EventName: "app_first_open", UserID: user2, DeviceID: "dev-1002", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()}, + {AppCode: "lalu", EventID: "social:app-session:1001", EventName: "app_session", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 600_000, Success: true, OccurredAtMS: start.Add(2 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:room-stay:1001", EventName: "room_stay", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 300_000, Success: true, OccurredAtMS: start.Add(3 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:gift-panel:1001", EventName: "gift_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(4 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:prob-panel:1001", EventName: "probability_game_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(5 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:chat:1001", EventName: "send_message", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(6 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:follow-user:1001", EventName: "follow_host", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(7 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:follow-room:1001", EventName: "follow_room", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(8 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:d1-active:1001", EventName: "home_view", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(24*time.Hour + time.Minute).UnixMilli()}, + } { + if _, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{event}); err != nil { + t.Fatalf("consume app event %s: %v", event.EventName, err) + } + } + if err := repository.ConsumeUserActive(ctx, UserActiveEvent{AppCode: "lalu", EventID: "social:room-join:1001", EventType: "RoomUserJoined", UserID: user1, Role: "host", CountryID: 86, RegionID: 210, OccurredAtMS: start.Add(9 * time.Minute).UnixMilli()}); err != nil { + t.Fatalf("consume room join: %v", err) + } + if err := repository.ConsumeUserMicDailyStatsUpdated(ctx, UserMicDailyStatsUpdatedEvent{AppCode: "lalu", EventID: "social:mic:1001", UserID: user1, StatDate: "2026-07-01", MicOnlineDeltaMS: 120_000, OccurredAtMS: start.Add(10 * time.Minute).UnixMilli()}); err != nil { + t.Fatalf("consume mic: %v", err) + } + for _, event := range []RechargeEvent{ + {AppCode: "lalu", EventID: "social:recharge:first", EventType: "WalletRechargeRecorded", UserID: user1, USDMinor: 500, RechargeSequence: 1, TargetCountryID: 86, TargetRegionID: 210, RechargeType: "google", OccurredAtMS: start.Add(11 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:recharge:repeat", EventType: "WalletRechargeRecorded", UserID: user1, USDMinor: 700, RechargeSequence: 2, TargetCountryID: 86, TargetRegionID: 210, RechargeType: "mifapay", OccurredAtMS: start.Add(12 * time.Minute).UnixMilli()}, + } { + if err := repository.ConsumeRecharge(ctx, event); err != nil { + t.Fatalf("consume recharge %s: %v", event.EventID, err) + } + } + for _, event := range []RoomGiftEvent{ + {AppCode: "lalu", EventID: "social:gift:normal", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 100, CoinSpent: 100, GiftTypeCode: "normal", OccurredAtMS: start.Add(13 * time.Minute).UnixMilli()}, + {AppCode: "lalu", EventID: "social:gift:lucky", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 200, CoinSpent: 200, PoolID: "lucky", GiftTypeCode: "lucky", OccurredAtMS: start.Add(14 * time.Minute).UnixMilli()}, + } { + if err := repository.ConsumeRoomGift(ctx, event); err != nil { + t.Fatalf("consume gift %s: %v", event.EventID, err) + } + } + if err := repository.ConsumeLuckyGiftReward(ctx, LuckyGiftRewardEvent{AppCode: "lalu", EventID: "social:lucky:payout", UserID: user1, CountryID: 86, RegionID: 210, PoolID: "lucky", Amount: 50, OccurredAtMS: start.Add(15 * time.Minute).UnixMilli()}); err != nil { + t.Fatalf("consume lucky payout: %v", err) + } + if err := repository.ConsumeGameOrder(ctx, GameOrderEvent{AppCode: "lalu", EventID: "social:probability:bet", UserID: user1, CountryID: 86, RegionID: 210, PlatformCode: "vendor", GameID: "slot", OpType: "bet", CoinAmount: 300, OccurredAtMS: start.Add(16 * time.Minute).UnixMilli()}); err != nil { + t.Fatalf("consume probability game: %v", err) + } + if err := repository.ConsumeSelfGameMatch(ctx, SelfGameMatchEvent{ + AppCode: "lalu", EventID: "social:self-game:settled", EventType: "SelfGameMatchSettled", GameID: "dice", CountryID: 86, RegionID: 210, + StakeCoin: 400, UserParticipants: 1, UserStakeCoin: 400, Participants: []SelfGameMatchParticipantEvent{{UserID: user1, ParticipantType: "user", StakeCoin: 400, Result: "win"}}, + OccurredAtMS: start.Add(17 * time.Minute).UnixMilli(), + }); err != nil { + t.Fatalf("consume self game: %v", err) + } + + query := SocialRequirementsQuery{ + AppCode: "lalu", + StartMS: start.UnixMilli(), + EndMS: start.Add(24 * time.Hour).UnixMilli(), + RegionIDs: []int64{210}, + } + result, err := repository.QuerySocialRequirements(ctx, query) + if err != nil { + t.Fatalf("query social requirements: %v", err) + } + newUsers := socialTestSection(t, result, SocialSectionNewUsers) + if socialMetricInt(t, newUsers.Total, "app_download_users") != 2 || + socialMetricInt(t, newUsers.Total, "registered_users") != 2 || + socialMetricInt(t, newUsers.Total, "new_host_users") != 1 || + socialMetricInt(t, newUsers.Total, "new_normal_users") != 1 || + math.Abs(socialMetricFloat(t, newUsers.Total, "room_join_success_rate")-0.5) > 0.0001 || + math.Abs(socialMetricFloat(t, newUsers.Total, "new_host_d1_retention_rate")-1) > 0.0001 { + t.Fatalf("P0 metrics mismatch: %+v", newUsers.Total.Metrics) + } + + revenue, err := repository.QuerySocialRequirements(ctx, SocialRequirementsQuery{AppCode: "lalu", StartMS: query.StartMS, EndMS: query.EndMS, Section: SocialSectionRevenue, PayerType: "paid"}) + if err != nil { + t.Fatalf("query revenue requirements: %v", err) + } + revenueSection := socialTestSection(t, revenue, SocialSectionRevenue) + if socialMetricInt(t, revenueSection.Total, "recharge_usd_minor") != 1200 || + socialMetricInt(t, revenueSection.Total, "google_recharge_usd_minor") != 500 || + socialMetricInt(t, revenueSection.Total, "third_party_recharge_usd_minor") != 700 || + socialMetricInt(t, revenueSection.Total, "recharge_count") != 2 || + socialMetricInt(t, revenueSection.Total, "first_recharge_users") != 1 || + socialMetricInt(t, revenueSection.Total, "repeat_recharge_users") != 1 || + math.Abs(socialMetricFloat(t, revenueSection.Total, "paid_rate")-1) > 0.0001 { + t.Fatalf("P1 metrics mismatch: %+v", revenueSection.Total.Metrics) + } + + retention := socialTestSection(t, result, SocialSectionRetention) + if math.Abs(socialMetricFloat(t, retention.Total, "registered_d1_active_rate")-0.5) > 0.0001 || + math.Abs(socialMetricFloat(t, retention.Total, "after_room_d1_room_rate")-1) > 0.0001 { + t.Fatalf("P2 retention metrics mismatch: %+v", retention.Total.Metrics) + } + active := socialTestSection(t, result, SocialSectionActive) + if socialMetricInt(t, active.Total, "active_users") != 2 || + socialMetricInt(t, active.Total, "room_join_success_users") != 1 || + socialMetricInt(t, active.Total, "mic_up_users") != 1 || + math.Abs(socialMetricFloat(t, active.Total, "avg_app_stay_min")-5) > 0.0001 { + t.Fatalf("P2 active metrics mismatch: %+v", active.Total.Metrics) + } + gift := socialTestSection(t, result, SocialSectionGift) + if socialMetricInt(t, gift.Total, "normal_gift_coin") != 100 || + socialMetricInt(t, gift.Total, "lucky_gift_coin") != 200 || + socialMetricInt(t, gift.Total, "lucky_gift_payout_coin") != 50 || + math.Abs(socialMetricFloat(t, gift.Total, "gift_panel_conversion_rate")-1) > 0.0001 || + math.Abs(socialMetricFloat(t, gift.Total, "lucky_gift_rtp")-0.25) > 0.0001 { + t.Fatalf("P4 metrics mismatch: %+v", gift.Total.Metrics) + } + game := socialTestSection(t, result, SocialSectionGame) + if socialMetricInt(t, game.Total, "self_game_bet_coin") != 400 || + socialMetricInt(t, game.Total, "probability_game_bet_coin") != 300 || + socialMetricInt(t, game.Total, "probability_game_bet_count") != 1 || + math.Abs(socialMetricFloat(t, game.Total, "self_game_conversion_rate")-0.5) > 0.0001 || + math.Abs(socialMetricFloat(t, game.Total, "probability_game_bet_arppu_coin")-300) > 0.0001 { + t.Fatalf("P5 metrics mismatch: %+v", game.Total.Metrics) + } +} + +func socialTestSection(t *testing.T, result SocialRequirements, key string) SocialRequirementSection { + t.Helper() + for _, section := range result.Sections { + if section.Key == key { + return section + } + } + t.Fatalf("missing social section %s in %+v", key, result.Sections) + return SocialRequirementSection{} +} + +func socialMetricInt(t *testing.T, row SocialRequirementRow, key string) int64 { + t.Helper() + value, ok := row.Metrics[key] + if !ok { + t.Fatalf("missing metric %s in %+v", key, row.Metrics) + } + switch typed := value.(type) { + case int64: + return typed + case int: + return int64(typed) + case float64: + return int64(typed) + default: + t.Fatalf("metric %s has unexpected type %T", key, value) + return 0 + } +} + +func socialMetricFloat(t *testing.T, row SocialRequirementRow, key string) float64 { + t.Helper() + value, ok := row.Metrics[key] + if !ok { + t.Fatalf("missing metric %s in %+v", key, row.Metrics) + } + switch typed := value.(type) { + case float64: + return typed + case int64: + return float64(typed) + case int: + return float64(typed) + default: + t.Fatalf("metric %s has unexpected type %T", key, value) + return 0 + } +} + func TestConsumeAppTrackingEventsRejectsInvalidProperties(t *testing.T) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) diff --git a/services/statistics-service/internal/storage/mysql/repository.go b/services/statistics-service/internal/storage/mysql/repository.go index 0cfc1062..56fcc8b5 100644 --- a/services/statistics-service/internal/storage/mysql/repository.go +++ b/services/statistics-service/internal/storage/mysql/repository.go @@ -20,6 +20,7 @@ const ( SourceRoom = "room" SourceGame = "game" SourceH5 = "h5" + SourceFinance = "finance" ) const ( @@ -181,15 +182,42 @@ func (r *Repository) Migrate(ctx context.Context) error { KEY idx_stat_user_dimension_region (app_code, region_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_user_day_mic ( - app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, - country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, - user_id BIGINT NOT NULL, mic_online_ms BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, - PRIMARY KEY (app_code, stat_tz, stat_day, user_id), - KEY idx_stat_user_day_mic_country (app_code, stat_tz, stat_day, country_id), - KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, + country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, + user_id BIGINT NOT NULL, mic_online_ms BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_user_day_mic_country (app_code, stat_tz, stat_day, country_id), + KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, + `CREATE TABLE IF NOT EXISTS stat_social_user_day ( + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, + subject_key VARCHAR(160) NOT NULL, user_id BIGINT NOT NULL DEFAULT 0, device_id VARCHAR(128) NOT NULL DEFAULT '', + country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_role VARCHAR(16) NOT NULL DEFAULT 'user', + app_first_open TINYINT(1) NOT NULL DEFAULT 0, registered_success TINYINT(1) NOT NULL DEFAULT 0, + active_user TINYINT(1) NOT NULL DEFAULT 0, room_join_success TINYINT(1) NOT NULL DEFAULT 0, room_join_fail TINYINT(1) NOT NULL DEFAULT 0, + mic_up_success TINYINT(1) NOT NULL DEFAULT 0, gift_sent TINYINT(1) NOT NULL DEFAULT 0, normal_gift_sent TINYINT(1) NOT NULL DEFAULT 0, + lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0, super_lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0, + recharge_user TINYINT(1) NOT NULL DEFAULT 0, first_recharge_user TINYINT(1) NOT NULL DEFAULT 0, repeat_recharge_user TINYINT(1) NOT NULL DEFAULT 0, + game_bet_user TINYINT(1) NOT NULL DEFAULT 0, self_game_bet_user TINYINT(1) NOT NULL DEFAULT 0, probability_game_bet_user TINYINT(1) NOT NULL DEFAULT 0, + chat_sent TINYINT(1) NOT NULL DEFAULT 0, follow_user TINYINT(1) NOT NULL DEFAULT 0, follow_room TINYINT(1) NOT NULL DEFAULT 0, + gift_panel_open TINYINT(1) NOT NULL DEFAULT 0, probability_game_panel_open TINYINT(1) NOT NULL DEFAULT 0, + app_stay_ms BIGINT NOT NULL DEFAULT 0, room_stay_ms BIGINT NOT NULL DEFAULT 0, mic_stay_ms BIGINT NOT NULL DEFAULT 0, + recharge_count BIGINT NOT NULL DEFAULT 0, recharge_usd_minor BIGINT NOT NULL DEFAULT 0, + google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, third_party_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, + first_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, repeat_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, + normal_gift_coin BIGINT NOT NULL DEFAULT 0, lucky_gift_coin BIGINT NOT NULL DEFAULT 0, super_lucky_gift_coin BIGINT NOT NULL DEFAULT 0, + lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0, super_lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0, + game_bet_coin BIGINT NOT NULL DEFAULT 0, game_bet_count BIGINT NOT NULL DEFAULT 0, + self_game_bet_coin BIGINT NOT NULL DEFAULT 0, self_game_bet_count BIGINT NOT NULL DEFAULT 0, + probability_game_bet_coin BIGINT NOT NULL DEFAULT 0, probability_game_bet_count BIGINT NOT NULL DEFAULT 0, + other_consume_coin BIGINT NOT NULL DEFAULT 0, output_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL, + PRIMARY KEY (app_code, stat_tz, stat_day, subject_key), + KEY idx_stat_social_user_day_user (app_code, stat_tz, stat_day, user_id), + KEY idx_stat_social_user_day_region (app_code, stat_tz, stat_day, region_id), + KEY idx_stat_social_user_day_filter (app_code, stat_tz, stat_day, user_role, recharge_user) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`, `CREATE TABLE IF NOT EXISTS stat_recharge_day_payers ( - app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0, + app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', 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, PRIMARY KEY (app_code, stat_tz, stat_day, user_id), KEY idx_stat_recharge_payer_country (app_code, stat_tz, stat_day, country_id), @@ -329,6 +357,7 @@ func (r *Repository) Migrate(ctx context.Context) error { "stat_user_day_activity", "stat_user_registration", "stat_user_day_mic", + "stat_social_user_day", "stat_recharge_day_payers", "stat_lucky_gift_day_payers", "stat_lucky_gift_pool_day_country", @@ -394,6 +423,7 @@ func (r *Repository) Migrate(ctx context.Context) error { "stat_user_registration": {"app_code", "stat_tz", "user_id"}, "stat_user_dimension": {"app_code", "user_id"}, "stat_user_day_mic": {"app_code", "stat_tz", "stat_day", "user_id"}, + "stat_social_user_day": {"app_code", "stat_tz", "stat_day", "subject_key"}, "stat_recharge_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"}, "stat_lucky_gift_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"}, "stat_platform_grant_coin_events": {"app_code", "stat_tz", "event_id"}, @@ -430,6 +460,9 @@ func (r *Repository) Migrate(ctx context.Context) error { {"stat_user_dimension", "idx_stat_user_dimension_region", []string{"app_code", "region_id"}}, {"stat_user_day_mic", "idx_stat_user_day_mic_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}}, {"stat_user_day_mic", "idx_stat_user_day_mic_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_social_user_day", "idx_stat_social_user_day_user", []string{"app_code", "stat_tz", "stat_day", "user_id"}}, + {"stat_social_user_day", "idx_stat_social_user_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, + {"stat_social_user_day", "idx_stat_social_user_day_filter", []string{"app_code", "stat_tz", "stat_day", "user_role", "recharge_user"}}, {"stat_recharge_day_payers", "idx_stat_recharge_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}}, {"stat_recharge_day_payers", "idx_stat_recharge_payer_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}}, {"stat_lucky_gift_day_payers", "idx_stat_lucky_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}}, @@ -541,6 +574,7 @@ type UserRegisteredEvent struct { AppCode string EventID string UserID int64 + Role string CountryID int64 RegionID int64 OccurredAtMS int64 @@ -580,6 +614,19 @@ type CoinSellerStockEvent struct { OccurredAtMS int64 } +type FinanceCoinSellerRechargeOrderEvent struct { + AppCode string + EventID string + VerifyStatus string + TargetUserID int64 + USDMinor int64 + CoinAmount int64 + CountryID int64 + RegionID int64 + CreatedAtMS int64 + OccurredAtMS int64 +} + type RoomGiftEvent struct { AppCode string EventID string @@ -659,6 +706,7 @@ type UserActiveEvent struct { EventID string EventType string UserID int64 + Role string CountryID int64 RegionID int64 OccurredAtMS int64 @@ -818,6 +866,20 @@ func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegist if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil { return err } + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.UserID, + CountryID: countryID, + RegionID: regionID, + UserRole: event.Role, + UpdatedAt: nowMS, + Registered: 1, + Active: 1, + }); err != nil { + return err + } } return nil }) @@ -876,6 +938,75 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e `, event.USDMinor, newUserUSD, coinSellerUSD, mifapayUSD, googleUSD, coinSellerTransferCoin, paidUsers, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { return err } + googleUSD, thirdPartyUSD, coinSellerUSD := int64(0), int64(0), int64(0) + switch strings.ToLower(strings.TrimSpace(event.RechargeType)) { + case "google", "google_play": + googleUSD = event.USDMinor + case "coin_seller_transfer", "coin_seller": + coinSellerUSD = event.USDMinor + } + firstRecharge, repeatRecharge, firstUSD, repeatUSD := int64(0), int64(0), int64(0), int64(0) + if event.RechargeSequence == 1 { + firstRecharge, firstUSD = 1, event.USDMinor + } else if event.RechargeSequence > 1 { + repeatRecharge, repeatUSD = 1, event.USDMinor + } + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.UserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + Recharge: 1, + FirstRecharge: firstRecharge, + RepeatRecharge: repeatRecharge, + RechargeCount: 1, + RechargeUSDMinor: event.USDMinor, + GoogleRechargeUSDMinor: googleUSD, + ThirdPartyRechargeUSDMinor: thirdPartyUSD, + CoinSellerRechargeUSDMinor: coinSellerUSD, + FirstRechargeUSDMinor: firstUSD, + RepeatRechargeUSDMinor: repeatUSD, + }); err != nil { + return err + } + } + return nil + }) +} + +func (r *Repository) ConsumeFinanceCoinSellerRechargeOrder(ctx context.Context, event FinanceCoinSellerRechargeOrderEvent) error { + return r.withEvent(ctx, SourceFinance, event.EventID, "FinanceCoinSellerRechargeOrderVerified", func(tx *sql.Tx, nowMS int64) error { + if !strings.EqualFold(strings.TrimSpace(event.VerifyStatus), "verified") { + return nil + } + if event.TargetUserID <= 0 || event.USDMinor <= 0 { + return nil + } + occurredAtMS := event.CreatedAtMS + if occurredAtMS <= 0 { + occurredAtMS = event.OccurredAtMS + } + countryID, regionID := normalizeDimension(event.CountryID, event.RegionID) + for _, scope := range statDayScopesFromContext(ctx, occurredAtMS) { + // Social BI 的第三方充值口径绑定 finance 工作台 verified 币商订单:金额和次数进入营收, + // 但不置 recharge_user/first/repeat 标记,避免财务补单把付费率和 ARPPU 分母放大。 + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.TargetUserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + RechargeCount: 1, + RechargeUSDMinor: event.USDMinor, + ThirdPartyRechargeUSDMinor: event.USDMinor, + }); err != nil { + return err + } } return nil }) @@ -954,13 +1085,34 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e } } if _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?, + UPDATE stat_app_day_country + SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?, lucky_gift_payers = lucky_gift_payers + ?, consumed_coin = consumed_coin + ?, updated_at_ms = ? WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, event.CoinSpent, luckyTurnover, luckyPayers, event.CoinSpent, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + `, event.CoinSpent, luckyTurnover, luckyPayers, event.CoinSpent, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + return err + } + normalFlag, luckyFlag, superFlag, normalCoin, luckyCoin, superCoin := socialGiftKind(event) + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.SenderUserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + Active: 1, + Gift: 1, + NormalGift: normalFlag, + LuckyGift: luckyFlag, + SuperGift: superFlag, + NormalGiftCoin: normalCoin, + LuckyGiftCoin: luckyCoin, + SuperGiftCoin: superCoin, + OtherConsumeCoin: 0, + }); err != nil { return err } } @@ -1023,12 +1175,31 @@ func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGift } } if _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET lucky_gift_payout = lucky_gift_payout + ?, + UPDATE stat_app_day_country + SET lucky_gift_payout = lucky_gift_payout + ?, output_coin = output_coin + ?, updated_at_ms = ? WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, amount, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + `, amount, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + return err + } + luckyPayout, superPayout := amount, int64(0) + if strings.Contains(strings.ToLower(poolID), "super_lucky") { + luckyPayout, superPayout = 0, amount + } + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.UserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + Active: 1, + LuckyGiftPayoutCoin: luckyPayout, + SuperGiftPayoutCoin: superPayout, + OutputCoin: amount, + }); err != nil { return err } } @@ -1157,8 +1328,8 @@ func (r *Repository) ConsumeWalletBalanceChanged(ctx context.Context, event Wall return err } if _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET coin_total = coin_total + ?, + UPDATE stat_app_day_country + SET coin_total = coin_total + ?, consumed_coin = consumed_coin + ?, output_coin = output_coin + ?, platform_grant_coin = platform_grant_coin + ?, @@ -1167,9 +1338,29 @@ func (r *Repository) ConsumeWalletBalanceChanged(ctx context.Context, event Wall salary_transfer_coin = salary_transfer_coin + ?, updated_at_ms = ? WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, coinTotal, consumed, outputCoin, platformGrant, manualGrant, salaryUSD, salaryTransferCoin, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + `, coinTotal, consumed, outputCoin, platformGrant, manualGrant, salaryUSD, salaryTransferCoin, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { return err } + otherConsume := consumed + if bizType == "direct_gift_debit" { + otherConsume = 0 + } + if otherConsume > 0 || outputCoin > 0 { + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: dimensionUserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + Active: 1, + OtherConsumeCoin: otherConsume, + OutputCoin: outputCoin, + }); err != nil { + return err + } + } if platformGrant > 0 { // resource_grant 类入账也是平台发放金币,写入同一事件投影,确保总计、用户列表和来源明细的统计口径一致。 if err := insertPlatformGrantCoinEvent(ctx, tx, event.AppCode, scope.tz, scope.day, event.EventID, event.EventType, event.UserID, countryID, regionID, platformGrant, event.OccurredAtMS, nowMS); err != nil { @@ -1220,14 +1411,31 @@ func (r *Repository) ConsumeUserMicDailyStatsUpdated(ctx context.Context, event return err } if _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET mic_online_ms = mic_online_ms + ?, + UPDATE stat_app_day_country + SET mic_online_ms = mic_online_ms + ?, mic_online_users = GREATEST(mic_online_users + ?, 0), updated_at_ms = ? WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, after-before, userDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + `, after-before, userDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { return err } + micDelta := after - before + if micDelta > 0 { + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.UserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + Active: 1, + MicUp: 1, + MicStayMS: micDelta, + }); err != nil { + return err + } + } } return nil }) @@ -1240,6 +1448,24 @@ func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEven if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil { return err } + roomJoinOK := int64(0) + if strings.EqualFold(strings.TrimSpace(event.EventType), "RoomUserJoined") { + roomJoinOK = 1 + } + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.UserID, + CountryID: countryID, + RegionID: regionID, + UserRole: event.Role, + UpdatedAt: nowMS, + Active: 1, + RoomJoinOK: roomJoinOK, + }); err != nil { + return err + } } return nil }) @@ -1287,16 +1513,36 @@ func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent) return err } if _, err := tx.ExecContext(ctx, ` - UPDATE stat_app_day_country - SET game_turnover = game_turnover + ?, game_payout = game_payout + ?, game_refund = game_refund + ?, + UPDATE stat_app_day_country + SET game_turnover = game_turnover + ?, game_payout = game_payout + ?, game_refund = game_refund + ?, game_players = game_players + ?, consumed_coin = consumed_coin + ?, output_coin = output_coin + ?, updated_at_ms = ? WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ? - `, turnover, payout, refund, playerDelta, turnover, payout, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { + `, turnover, payout, refund, playerDelta, turnover, payout, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil { return err } + if turnover > 0 { + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.UserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + Active: 1, + GameBet: 1, + ProbabilityBet: 1, + GameBetCoin: turnover, + GameBetCount: 1, + ProbabilityBetCoin: turnover, + ProbabilityBetCount: 1, + }); err != nil { + return err + } + } } return nil }) @@ -1395,6 +1641,15 @@ func (r *Repository) ConsumeAppTrackingEvents(ctx context.Context, events []AppT } if affected > 0 { result.Stored++ + for _, scope := range statDayScopesFromContext(ctx, normalized.OccurredAtMS) { + delta, ok := socialDeltaForAppTrackingEvent(normalized, scope, nowMS) + if !ok { + continue + } + if err := applySocialUserDayDelta(ctx, tx, delta); err != nil { + return AppTrackingReportResult{}, err + } + } } else { result.Duplicated++ } @@ -1486,6 +1741,31 @@ func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMat if err := consumeSelfGameSettledParticipant(ctx, tx, event, participant, scope.tz, scope.day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil { return err } + if participant.UserID <= 0 || !strings.EqualFold(strings.TrimSpace(participant.ParticipantType), "user") { + continue + } + stake := normalizeID(participant.StakeCoin) + if stake == 0 { + stake = stakeCoin + } + if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: participant.UserID, + CountryID: countryID, + RegionID: regionID, + UpdatedAt: nowMS, + Active: 1, + GameBet: 1, + SelfGameBet: 1, + GameBetCoin: stake, + GameBetCount: 1, + SelfGameBetCoin: stake, + SelfGameBetCount: 1, + }); err != nil { + return err + } } } if canceled > 0 { diff --git a/services/statistics-service/internal/storage/mysql/social_requirements_query.go b/services/statistics-service/internal/storage/mysql/social_requirements_query.go new file mode 100644 index 00000000..b7e8268c --- /dev/null +++ b/services/statistics-service/internal/storage/mysql/social_requirements_query.go @@ -0,0 +1,967 @@ +package mysql + +import ( + "context" + "encoding/json" + "sort" + "strconv" + "strings" + "time" + + "hyapp/pkg/appcode" +) + +const ( + SocialSectionNewUsers = "new_users" + SocialSectionRevenue = "revenue" + SocialSectionRetention = "retention" + SocialSectionActive = "active" + SocialSectionGift = "gift" + SocialSectionGame = "game" +) + +type SocialRequirementsQuery struct { + AppCode string + StatTZ string + StartMS int64 + EndMS int64 + CountryID int64 + RegionID int64 + RegionIDs []int64 + Section string + UserRole string + PayerType string +} + +type SocialRequirements struct { + AppCode string `json:"app_code"` + StatTZ string `json:"stat_tz"` + StartMS int64 `json:"start_ms"` + EndMS int64 `json:"end_ms"` + CountryID int64 `json:"country_id"` + RegionID int64 `json:"region_id"` + RegionIDs []int64 `json:"region_ids,omitempty"` + Sections []SocialRequirementSection `json:"sections"` +} + +type SocialRequirementSection struct { + Key string `json:"key"` + Label string `json:"label"` + Filters SocialRequirementFilter `json:"filters"` + Columns []SocialRequirementColumn `json:"columns"` + MetricDefinitions []MetricDefinition `json:"metric_definitions"` + Total SocialRequirementRow `json:"total"` + DailySeries []SocialRequirementRow `json:"daily_series"` +} + +type SocialRequirementColumn struct { + Key string `json:"key"` + Label string `json:"label"` + Type string `json:"type"` + Tooltip string `json:"tooltip,omitempty"` +} + +type SocialRequirementFilter struct { + UserRole string `json:"user_role"` + PayerType string `json:"payer_type"` +} + +type SocialRequirementRow struct { + StatDay string `json:"stat_day"` + Label string `json:"label"` + Metrics map[string]any `json:"metrics"` +} + +func (row SocialRequirementRow) MarshalJSON() ([]byte, error) { + out := make(map[string]any, len(row.Metrics)+2) + out["stat_day"] = row.StatDay + out["label"] = row.Label + for key, value := range row.Metrics { + out[key] = value + } + return json.Marshal(out) +} + +type socialRequirementUserDay struct { + StatDay string + Subject string + UserID int64 + RegionID int64 + UserRole string + AppFirstOpen int64 + Registered int64 + Active int64 + RoomJoinOK int64 + RoomJoinNG int64 + MicUp int64 + Gift int64 + NormalGift int64 + LuckyGift int64 + SuperGift int64 + Recharge int64 + FirstRecharge int64 + RepeatRecharge int64 + GameBet int64 + SelfGameBet int64 + ProbabilityBet int64 + Chat int64 + FollowUser int64 + FollowRoom int64 + GiftPanel int64 + ProbabilityPanel int64 + AppStayMS int64 + RoomStayMS int64 + MicStayMS int64 + RechargeCount int64 + RechargeUSDMinor int64 + GoogleRechargeUSDMinor int64 + ThirdPartyRechargeUSDMinor int64 + CoinSellerRechargeUSDMinor int64 + FirstRechargeUSDMinor int64 + RepeatRechargeUSDMinor int64 + NormalGiftCoin int64 + LuckyGiftCoin int64 + SuperGiftCoin int64 + LuckyGiftPayoutCoin int64 + SuperGiftPayoutCoin int64 + GameBetCoin int64 + GameBetCount int64 + SelfGameBetCoin int64 + SelfGameBetCount int64 + ProbabilityBetCoin int64 + ProbabilityBetCount int64 + OtherConsumeCoin int64 + OutputCoin int64 +} + +type socialRequirementAggregate struct { + AppFirstOpen int64 + Registered int64 + NewHostUsers int64 + NewNormalUsers int64 + Active int64 + RoomJoinOK int64 + RoomJoinNG int64 + RegisteredRoomJoinOK int64 + RegisteredRoomJoinNG int64 + RegisteredMicUp int64 + RegisteredRoomGift int64 + RegisteredRecharge int64 + RegisteredGame int64 + MicUp int64 + Gift int64 + NormalGift int64 + LuckyGift int64 + SuperGift int64 + Recharge int64 + FirstRecharge int64 + RepeatRecharge int64 + GameBet int64 + SelfGameBet int64 + ProbabilityBet int64 + Chat int64 + FollowUser int64 + FollowRoom int64 + GiftPanel int64 + ProbabilityPanel int64 + AppStayMS int64 + RoomStayMS int64 + MicStayMS int64 + RechargeCount int64 + RechargeUSDMinor int64 + GoogleRechargeUSDMinor int64 + ThirdPartyRechargeUSDMinor int64 + CoinSellerRechargeUSDMinor int64 + FirstRechargeUSDMinor int64 + RepeatRechargeUSDMinor int64 + NormalGiftCoin int64 + LuckyGiftCoin int64 + SuperGiftCoin int64 + LuckyGiftPayoutCoin int64 + SuperGiftPayoutCoin int64 + GameBetCoin int64 + GameBetCount int64 + SelfGameBetCoin int64 + SelfGameBetCount int64 + ProbabilityBetCoin int64 + ProbabilityBetCount int64 + OtherConsumeCoin int64 + OutputCoin int64 +} + +type retentionMetric struct { + Base int64 + Users int64 +} + +func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRequirementsQuery) (SocialRequirements, error) { + app := appcode.Normalize(query.AppCode) + if app == "" { + app = "lalu" + } + statTZ := normalizeStatTZ(query.StatTZ) + startMS, endMS := query.StartMS, query.EndMS + if startMS <= 0 { + now := time.Now().UTC() + startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli() + } + if endMS <= startMS { + endMS = startMS + 24*time.Hour.Milliseconds() + } + startDay := statDayIn(startMS, statTZ) + endDay := statDayIn(endMS-1, statTZ) + days := socialDayRange(startDay, endDay) + // 留存指标需要读取 cohort 日之后的 D30 行;这些仍是本服务已沉淀的用户日宽表,不回查 owner service。 + rows, err := r.querySocialUserDays(ctx, app, statTZ, startDay, addStatDays(endDay, 30), query.CountryID, query.RegionID, query.RegionIDs) + if err != nil { + return SocialRequirements{}, err + } + byDay, bySubjectDay := indexSocialRows(rows) + sections := socialRequestedSections(query.Section) + out := SocialRequirements{ + AppCode: app, + StatTZ: statTZ, + StartMS: startMS, + EndMS: endMS, + CountryID: query.CountryID, + RegionID: query.RegionID, + RegionIDs: normalizePositiveIDs(query.RegionIDs), + Sections: make([]SocialRequirementSection, 0, len(sections)), + } + for _, section := range sections { + sectionRows := make([]SocialRequirementRow, 0, len(days)) + totalAgg := socialRequirementAggregate{} + totalRetention := map[string]retentionMetric{} + for _, day := range days { + agg := aggregateSocialRows(byDay[day], section, query.UserRole, query.PayerType) + totalAgg.add(agg) + retention := socialRetentionMetrics(day, byDay[day], bySubjectDay, section, query.UserRole, query.PayerType) + mergeRetentionMetrics(totalRetention, retention) + sectionRows = append(sectionRows, SocialRequirementRow{ + StatDay: day, + Label: day, + Metrics: socialSectionMetrics(section, agg, retention), + }) + } + out.Sections = append(out.Sections, SocialRequirementSection{ + Key: section, + Label: socialSectionLabel(section), + Filters: SocialRequirementFilter{UserRole: normalizeSocialRequirementRole(query.UserRole), PayerType: normalizeSocialPayerType(query.PayerType)}, + Columns: socialRequirementColumns(section), + MetricDefinitions: socialRequirementMetricDefinitions(section), + Total: SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialSectionMetrics(section, totalAgg, totalRetention)}, + DailySeries: sectionRows, + }) + } + return out, nil +} + +func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ string, startDay string, endDay string, countryID int64, regionID int64, regionIDs []int64) ([]socialRequirementUserDay, error) { + args := []any{appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, endDay} + conditions := []string{"app_code = ?", "stat_tz = ?", "stat_day BETWEEN ? AND ?"} + if countryID > 0 { + conditions = append(conditions, "country_id = ?") + args = append(args, countryID) + } + if regionID > 0 { + conditions = append(conditions, "region_id = ?") + args = append(args, regionID) + } else if ids := normalizePositiveIDs(regionIDs); len(ids) > 0 { + placeholders := make([]string, 0, len(ids)) + for _, id := range ids { + placeholders = append(placeholders, "?") + args = append(args, id) + } + conditions = append(conditions, "region_id IN ("+strings.Join(placeholders, ",")+")") + } + rows, err := r.db.QueryContext(ctx, ` + SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, region_id, user_role, + app_first_open, registered_success, active_user, room_join_success, room_join_fail, + mic_up_success, gift_sent, normal_gift_sent, lucky_gift_sent, super_lucky_gift_sent, + recharge_user, first_recharge_user, repeat_recharge_user, game_bet_user, self_game_bet_user, + probability_game_bet_user, chat_sent, follow_user, follow_room, gift_panel_open, probability_game_panel_open, + app_stay_ms, room_stay_ms, mic_stay_ms, recharge_count, recharge_usd_minor, + google_recharge_usd_minor, third_party_recharge_usd_minor, coin_seller_recharge_usd_minor, + first_recharge_usd_minor, repeat_recharge_usd_minor, normal_gift_coin, lucky_gift_coin, super_lucky_gift_coin, + lucky_gift_payout_coin, super_lucky_gift_payout_coin, game_bet_coin, game_bet_count, + self_game_bet_coin, self_game_bet_count, probability_game_bet_coin, probability_game_bet_count, + other_consume_coin, output_coin + FROM stat_social_user_day + WHERE `+strings.Join(conditions, " AND ")+` + ORDER BY stat_day, subject_key + `, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := []socialRequirementUserDay{} + for rows.Next() { + var item socialRequirementUserDay + if err := rows.Scan(&item.StatDay, &item.Subject, &item.UserID, &item.RegionID, &item.UserRole, + &item.AppFirstOpen, &item.Registered, &item.Active, &item.RoomJoinOK, &item.RoomJoinNG, + &item.MicUp, &item.Gift, &item.NormalGift, &item.LuckyGift, &item.SuperGift, + &item.Recharge, &item.FirstRecharge, &item.RepeatRecharge, &item.GameBet, &item.SelfGameBet, + &item.ProbabilityBet, &item.Chat, &item.FollowUser, &item.FollowRoom, &item.GiftPanel, &item.ProbabilityPanel, + &item.AppStayMS, &item.RoomStayMS, &item.MicStayMS, &item.RechargeCount, &item.RechargeUSDMinor, + &item.GoogleRechargeUSDMinor, &item.ThirdPartyRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor, + &item.FirstRechargeUSDMinor, &item.RepeatRechargeUSDMinor, &item.NormalGiftCoin, &item.LuckyGiftCoin, &item.SuperGiftCoin, + &item.LuckyGiftPayoutCoin, &item.SuperGiftPayoutCoin, &item.GameBetCoin, &item.GameBetCount, + &item.SelfGameBetCoin, &item.SelfGameBetCount, &item.ProbabilityBetCoin, &item.ProbabilityBetCount, + &item.OtherConsumeCoin, &item.OutputCoin); err != nil { + return nil, err + } + out = append(out, item) + } + return out, rows.Err() +} + +func indexSocialRows(rows []socialRequirementUserDay) (map[string][]socialRequirementUserDay, map[string]map[string]socialRequirementUserDay) { + byDay := map[string][]socialRequirementUserDay{} + bySubjectDay := map[string]map[string]socialRequirementUserDay{} + for _, row := range rows { + byDay[row.StatDay] = append(byDay[row.StatDay], row) + if bySubjectDay[row.Subject] == nil { + bySubjectDay[row.Subject] = map[string]socialRequirementUserDay{} + } + bySubjectDay[row.Subject][row.StatDay] = row + } + return byDay, bySubjectDay +} + +func aggregateSocialRows(rows []socialRequirementUserDay, section string, role string, payerType string) socialRequirementAggregate { + var agg socialRequirementAggregate + for _, row := range rows { + if !socialRowMatchesSection(row, section, role, payerType) { + continue + } + agg.AppFirstOpen += flagValue(row.AppFirstOpen) + agg.Registered += flagValue(row.Registered) + if flagValue(row.Registered) > 0 && normalizeSocialUserRole(row.UserRole) == "host" { + agg.NewHostUsers++ + } else if flagValue(row.Registered) > 0 { + agg.NewNormalUsers++ + } + agg.Active += flagValue(row.Active) + agg.RoomJoinOK += flagValue(row.RoomJoinOK) + agg.RoomJoinNG += flagValue(row.RoomJoinNG) + if row.Registered > 0 && row.RoomJoinOK > 0 { + agg.RegisteredRoomJoinOK++ + } + if row.Registered > 0 && row.RoomJoinNG > 0 { + agg.RegisteredRoomJoinNG++ + } + if row.Registered > 0 && row.RoomJoinOK > 0 && row.MicUp > 0 { + agg.RegisteredMicUp++ + } + if row.Registered > 0 && row.RoomJoinOK > 0 && row.Gift > 0 { + agg.RegisteredRoomGift++ + } + if row.Registered > 0 && row.Recharge > 0 { + agg.RegisteredRecharge++ + } + if row.Registered > 0 && row.GameBet > 0 { + agg.RegisteredGame++ + } + agg.MicUp += flagValue(row.MicUp) + agg.Gift += flagValue(row.Gift) + agg.NormalGift += flagValue(row.NormalGift) + agg.LuckyGift += flagValue(row.LuckyGift) + agg.SuperGift += flagValue(row.SuperGift) + agg.Recharge += flagValue(row.Recharge) + agg.FirstRecharge += flagValue(row.FirstRecharge) + agg.RepeatRecharge += flagValue(row.RepeatRecharge) + agg.GameBet += flagValue(row.GameBet) + agg.SelfGameBet += flagValue(row.SelfGameBet) + agg.ProbabilityBet += flagValue(row.ProbabilityBet) + agg.Chat += flagValue(row.Chat) + agg.FollowUser += flagValue(row.FollowUser) + agg.FollowRoom += flagValue(row.FollowRoom) + agg.GiftPanel += flagValue(row.GiftPanel) + agg.ProbabilityPanel += flagValue(row.ProbabilityPanel) + agg.AppStayMS += row.AppStayMS + agg.RoomStayMS += row.RoomStayMS + agg.MicStayMS += row.MicStayMS + agg.RechargeCount += row.RechargeCount + agg.RechargeUSDMinor += row.RechargeUSDMinor + agg.GoogleRechargeUSDMinor += row.GoogleRechargeUSDMinor + agg.ThirdPartyRechargeUSDMinor += row.ThirdPartyRechargeUSDMinor + agg.CoinSellerRechargeUSDMinor += row.CoinSellerRechargeUSDMinor + agg.FirstRechargeUSDMinor += row.FirstRechargeUSDMinor + agg.RepeatRechargeUSDMinor += row.RepeatRechargeUSDMinor + agg.NormalGiftCoin += row.NormalGiftCoin + agg.LuckyGiftCoin += row.LuckyGiftCoin + agg.SuperGiftCoin += row.SuperGiftCoin + agg.LuckyGiftPayoutCoin += row.LuckyGiftPayoutCoin + agg.SuperGiftPayoutCoin += row.SuperGiftPayoutCoin + agg.GameBetCoin += row.GameBetCoin + agg.GameBetCount += row.GameBetCount + agg.SelfGameBetCoin += row.SelfGameBetCoin + agg.SelfGameBetCount += row.SelfGameBetCount + agg.ProbabilityBetCoin += row.ProbabilityBetCoin + agg.ProbabilityBetCount += row.ProbabilityBetCount + agg.OtherConsumeCoin += row.OtherConsumeCoin + agg.OutputCoin += row.OutputCoin + } + return agg +} + +func (a *socialRequirementAggregate) add(other socialRequirementAggregate) { + a.AppFirstOpen += other.AppFirstOpen + a.Registered += other.Registered + a.NewHostUsers += other.NewHostUsers + a.NewNormalUsers += other.NewNormalUsers + a.Active += other.Active + a.RoomJoinOK += other.RoomJoinOK + a.RoomJoinNG += other.RoomJoinNG + a.RegisteredRoomJoinOK += other.RegisteredRoomJoinOK + a.RegisteredRoomJoinNG += other.RegisteredRoomJoinNG + a.RegisteredMicUp += other.RegisteredMicUp + a.RegisteredRoomGift += other.RegisteredRoomGift + a.RegisteredRecharge += other.RegisteredRecharge + a.RegisteredGame += other.RegisteredGame + a.MicUp += other.MicUp + a.Gift += other.Gift + a.NormalGift += other.NormalGift + a.LuckyGift += other.LuckyGift + a.SuperGift += other.SuperGift + a.Recharge += other.Recharge + a.FirstRecharge += other.FirstRecharge + a.RepeatRecharge += other.RepeatRecharge + a.GameBet += other.GameBet + a.SelfGameBet += other.SelfGameBet + a.ProbabilityBet += other.ProbabilityBet + a.Chat += other.Chat + a.FollowUser += other.FollowUser + a.FollowRoom += other.FollowRoom + a.GiftPanel += other.GiftPanel + a.ProbabilityPanel += other.ProbabilityPanel + a.AppStayMS += other.AppStayMS + a.RoomStayMS += other.RoomStayMS + a.MicStayMS += other.MicStayMS + a.RechargeCount += other.RechargeCount + a.RechargeUSDMinor += other.RechargeUSDMinor + a.GoogleRechargeUSDMinor += other.GoogleRechargeUSDMinor + a.ThirdPartyRechargeUSDMinor += other.ThirdPartyRechargeUSDMinor + a.CoinSellerRechargeUSDMinor += other.CoinSellerRechargeUSDMinor + a.FirstRechargeUSDMinor += other.FirstRechargeUSDMinor + a.RepeatRechargeUSDMinor += other.RepeatRechargeUSDMinor + a.NormalGiftCoin += other.NormalGiftCoin + a.LuckyGiftCoin += other.LuckyGiftCoin + a.SuperGiftCoin += other.SuperGiftCoin + a.LuckyGiftPayoutCoin += other.LuckyGiftPayoutCoin + a.SuperGiftPayoutCoin += other.SuperGiftPayoutCoin + a.GameBetCoin += other.GameBetCoin + a.GameBetCount += other.GameBetCount + a.SelfGameBetCoin += other.SelfGameBetCoin + a.SelfGameBetCount += other.SelfGameBetCount + a.ProbabilityBetCoin += other.ProbabilityBetCoin + a.ProbabilityBetCount += other.ProbabilityBetCount + a.OtherConsumeCoin += other.OtherConsumeCoin + a.OutputCoin += other.OutputCoin +} + +func socialRetentionMetrics(day string, rows []socialRequirementUserDay, bySubjectDay map[string]map[string]socialRequirementUserDay, section string, role string, payerType string) map[string]retentionMetric { + out := map[string]retentionMetric{} + for _, row := range rows { + if !socialRowMatchesSection(row, section, role, payerType) { + continue + } + for _, offset := range []int{1, 3, 7, 15, 30} { + if row.Registered > 0 { + addSocialRetention(out, "registered_d"+intLabel(offset)+"_active", row, bySubjectDay, offset, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + } + } + if row.RoomJoinOK > 0 { + addSocialRetention(out, "after_room_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + addSocialRetention(out, "after_room_d1_room", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.RoomJoinOK > 0 }) + } + if row.MicUp > 0 { + addSocialRetention(out, "after_mic_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + addSocialRetention(out, "after_mic_d1_mic", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.MicUp > 0 }) + } + if row.Gift > 0 { + addSocialRetention(out, "after_gift_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + addSocialRetention(out, "after_gift_d1_gift", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Gift > 0 }) + } + if row.GameBet > 0 { + addSocialRetention(out, "after_game_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + addSocialRetention(out, "after_game_d1_game", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.GameBet > 0 }) + } + if row.Recharge > 0 { + addSocialRetention(out, "after_recharge_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + addSocialRetention(out, "after_recharge_d1_recharge", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Recharge > 0 }) + } + if row.Registered > 0 && normalizeSocialUserRole(row.UserRole) == "host" { + addSocialRetention(out, "new_host_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + } + if row.Registered > 0 && normalizeSocialUserRole(row.UserRole) != "host" { + addSocialRetention(out, "new_user_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) + } + } + return out +} + +func addSocialRetention(out map[string]retentionMetric, key string, base socialRequirementUserDay, bySubjectDay map[string]map[string]socialRequirementUserDay, offset int, retained func(socialRequirementUserDay) bool) { + item := out[key] + item.Base++ + if target, ok := bySubjectDay[base.Subject][addStatDays(base.StatDay, offset)]; ok && retained(target) { + item.Users++ + } + out[key] = item +} + +func mergeRetentionMetrics(total map[string]retentionMetric, next map[string]retentionMetric) { + for key, item := range next { + current := total[key] + current.Base += item.Base + current.Users += item.Users + total[key] = current + } +} + +func socialSectionMetrics(section string, agg socialRequirementAggregate, retention map[string]retentionMetric) map[string]any { + switch section { + case SocialSectionRevenue: + totalConsume := agg.GameBetCoin + agg.NormalGiftCoin + agg.LuckyGiftCoin + agg.SuperGiftCoin + agg.OtherConsumeCoin + return map[string]any{ + "recharge_usd_minor": agg.RechargeUSDMinor, + "google_recharge_usd_minor": agg.GoogleRechargeUSDMinor, + "third_party_recharge_usd_minor": agg.ThirdPartyRechargeUSDMinor, + "coin_seller_recharge_usd_minor": agg.CoinSellerRechargeUSDMinor, + "first_recharge_usd_minor": agg.FirstRechargeUSDMinor, + "repeat_recharge_usd_minor": agg.RepeatRechargeUSDMinor, + "recharge_users": agg.Recharge, + "recharge_count": agg.RechargeCount, + "first_recharge_users": agg.FirstRecharge, + "repeat_recharge_users": agg.RepeatRecharge, + "repurchase_rate": ratio(agg.RepeatRecharge, agg.Recharge), + "paid_rate": ratio(agg.Recharge, agg.Active), + "arpu_usd_minor": div(agg.RechargeUSDMinor, agg.Active), + "arppu_usd_minor": div(agg.RechargeUSDMinor, agg.Recharge), + "consumption_penetration_rate": ratio(agg.Gift+agg.GameBet, agg.Active), + "game_consumption_ratio": ratio(agg.GameBetCoin, totalConsume), + "lucky_gift_consumption_ratio": ratio(agg.LuckyGiftCoin+agg.SuperGiftCoin, totalConsume), + "normal_gift_consumption_ratio": ratio(agg.NormalGiftCoin, totalConsume), + "other_consumption_ratio": ratio(agg.OtherConsumeCoin, totalConsume), + } + case SocialSectionRetention: + out := map[string]any{} + for _, key := range sortedRetentionKeys(retention) { + item := retention[key] + out[key+"_base_users"] = item.Base + out[key+"_users"] = item.Users + out[key+"_rate"] = ratio(item.Users, item.Base) + } + return out + case SocialSectionActive: + return map[string]any{ + "active_users": agg.Active, + "room_join_success_users": agg.RoomJoinOK, + "room_join_fail_users": agg.RoomJoinNG, + "room_join_success_rate": ratio(agg.RoomJoinOK, agg.RoomJoinOK+agg.RoomJoinNG), + "room_join_fail_rate": ratio(agg.RoomJoinNG, agg.RoomJoinOK+agg.RoomJoinNG), + "mic_up_users": agg.MicUp, + "mic_up_rate": ratio(agg.MicUp, agg.RoomJoinOK), + "gift_users": agg.Gift, + "gift_rate": ratio(agg.Gift, agg.RoomJoinOK), + "game_users": agg.GameBet, + "game_conversion_rate": ratio(agg.GameBet, agg.Active), + "chat_users": agg.Chat, + "chat_conversion_rate": ratio(agg.Chat, agg.Active), + "avg_app_stay_min": minutesPerUser(agg.AppStayMS, agg.Active), + } + case SocialSectionGift: + return map[string]any{ + "gift_panel_open_users": agg.GiftPanel, + "gift_panel_conversion_rate": ratio(agg.GiftPanel, agg.RoomJoinOK), + "room_gift_users": agg.Gift, + "room_gift_conversion_rate": ratio(agg.Gift, agg.RoomJoinOK), + "normal_gift_users": agg.NormalGift, + "normal_gift_coin": agg.NormalGiftCoin, + "normal_gift_arppu_coin": div(agg.NormalGiftCoin, agg.NormalGift), + "lucky_gift_users": agg.LuckyGift, + "lucky_gift_coin": agg.LuckyGiftCoin, + "lucky_gift_payout_coin": agg.LuckyGiftPayoutCoin, + "lucky_gift_rtp": ratio(agg.LuckyGiftPayoutCoin, agg.LuckyGiftCoin), + "lucky_gift_arppu_coin": div(agg.LuckyGiftCoin, agg.LuckyGift), + "super_lucky_gift_users": agg.SuperGift, + "super_lucky_gift_coin": agg.SuperGiftCoin, + "super_lucky_gift_payout_coin": agg.SuperGiftPayoutCoin, + "super_lucky_gift_rtp": ratio(agg.SuperGiftPayoutCoin, agg.SuperGiftCoin), + "super_lucky_gift_arppu_coin": div(agg.SuperGiftCoin, agg.SuperGift), + } + case SocialSectionGame: + return map[string]any{ + "active_users": agg.Active, + "room_join_success_users": agg.RoomJoinOK, + "self_game_conversion_rate": ratio(agg.SelfGameBet, agg.Active), + "self_game_bet_users": agg.SelfGameBet, + "self_game_bet_count": agg.SelfGameBetCount, + "self_game_bet_count_per_user": ratio(agg.SelfGameBetCount, agg.SelfGameBet), + "self_game_bet_coin": agg.SelfGameBetCoin, + "self_game_bet_arppu_coin": div(agg.SelfGameBetCoin, agg.SelfGameBet), + "probability_game_panel_open_users": agg.ProbabilityPanel, + "probability_game_panel_conversion_rate": ratio(agg.ProbabilityPanel, agg.RoomJoinOK), + "probability_game_bet_users": agg.ProbabilityBet, + "probability_game_conversion_rate": ratio(agg.ProbabilityBet, agg.RoomJoinOK), + "probability_game_bet_count": agg.ProbabilityBetCount, + "probability_game_bet_count_per_user": ratio(agg.ProbabilityBetCount, agg.ProbabilityBet), + "probability_game_bet_coin": agg.ProbabilityBetCoin, + "probability_game_bet_arppu_coin": div(agg.ProbabilityBetCoin, agg.ProbabilityBet), + } + default: + return map[string]any{ + "app_download_users": agg.AppFirstOpen, + "registered_users": agg.Registered, + "new_host_users": agg.NewHostUsers, + "new_normal_users": agg.NewNormalUsers, + "registration_success_rate": ratio(agg.Registered, agg.AppFirstOpen), + "room_join_success_users": agg.RegisteredRoomJoinOK, + "room_join_fail_users": agg.RegisteredRoomJoinNG, + "room_join_success_rate": ratio(agg.RegisteredRoomJoinOK, agg.Registered), + "room_join_fail_rate": ratio(agg.RegisteredRoomJoinNG, agg.Registered), + "mic_up_users": agg.RegisteredMicUp, + "mic_up_rate": ratio(agg.RegisteredMicUp, agg.RegisteredRoomJoinOK), + "gift_users": agg.RegisteredRoomGift, + "gift_rate": ratio(agg.RegisteredRoomGift, agg.RegisteredRoomJoinOK), + "recharge_users": agg.RegisteredRecharge, + "recharge_rate": ratio(agg.RegisteredRecharge, agg.Registered), + "game_users": agg.RegisteredGame, + "game_conversion_rate": ratio(agg.RegisteredGame, agg.Registered), + "avg_app_stay_min": minutesPerUser(agg.AppStayMS, agg.Active), + "avg_room_stay_min": minutesPerUser(agg.RoomStayMS, agg.RoomJoinOK), + "avg_mic_stay_min": minutesPerUser(agg.MicStayMS, agg.MicUp), + "new_user_recharge_usd_minor": agg.FirstRechargeUSDMinor, + "follow_user_users": agg.FollowUser, + "follow_room_users": agg.FollowRoom, + "chat_users": agg.Chat, + "new_host_d1_retention_rate": retentionRate(retention, "new_host_d1_active"), + "new_user_d1_retention_rate": retentionRate(retention, "new_user_d1_active"), + } + } +} + +func socialRowMatchesSection(row socialRequirementUserDay, section string, role string, payerType string) bool { + switch section { + case SocialSectionNewUsers: + return true + case SocialSectionGift, SocialSectionGame: + return socialRoleMatches(row, role) + default: + return socialRoleMatches(row, role) && socialPayerMatches(row, payerType) + } +} + +func socialRoleMatches(row socialRequirementUserDay, role string) bool { + switch normalizeSocialRequirementRole(role) { + case "host": + return normalizeSocialUserRole(row.UserRole) == "host" + case "user": + return normalizeSocialUserRole(row.UserRole) != "host" + default: + return true + } +} + +func socialPayerMatches(row socialRequirementUserDay, payerType string) bool { + switch normalizeSocialPayerType(payerType) { + case "paid": + return row.Recharge > 0 + case "unpaid": + return row.Recharge == 0 + default: + return true + } +} + +func normalizeSocialRequirementRole(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "host": + return "host" + case "user": + return "user" + default: + return "all" + } +} + +func normalizeSocialPayerType(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "paid": + return "paid" + case "unpaid": + return "unpaid" + default: + return "all" + } +} + +func socialRequestedSections(value string) []string { + allowed := map[string]bool{ + SocialSectionNewUsers: true, + SocialSectionRevenue: true, + SocialSectionRetention: true, + SocialSectionActive: true, + SocialSectionGift: true, + SocialSectionGame: true, + } + parts := strings.Split(value, ",") + out := []string{} + for _, part := range parts { + part = strings.ToLower(strings.TrimSpace(part)) + if allowed[part] { + out = append(out, part) + } + } + if len(out) > 0 { + return out + } + return []string{SocialSectionNewUsers, SocialSectionRevenue, SocialSectionRetention, SocialSectionActive, SocialSectionGift, SocialSectionGame} +} + +func socialSectionLabel(section string) string { + switch section { + case SocialSectionRevenue: + return "P1 营收" + case SocialSectionRetention: + return "P2 留存" + case SocialSectionActive: + return "P2 活跃" + case SocialSectionGift: + return "P4 送礼" + case SocialSectionGame: + return "P5 游戏" + default: + return "P0 新用户" + } +} + +func socialRequirementMetricDefinitions(section string) []MetricDefinition { + switch section { + case SocialSectionRevenue: + return []MetricDefinition{ + {Metric: "recharge_usd_minor", Definition: "服务端 WalletRechargeRecorded 充值事实汇总,单位 USD minor。"}, + {Metric: "repurchase_rate", Definition: "复购充值用户 / 当日充值用户。"}, + {Metric: "paid_rate", Definition: "充值用户 / DAU,支持用户身份和当日付费身份筛选。"}, + {Metric: "arpu_usd_minor", Definition: "充值金额 / DAU。"}, + {Metric: "arppu_usd_minor", Definition: "充值金额 / 充值用户。"}, + } + case SocialSectionRetention: + return []MetricDefinition{ + {Metric: "registered_d1_active_rate", Definition: "注册 cohort 在第 1 个统计日后的活跃留存。D3/D7/D15/D30 同理。"}, + {Metric: "after_room_d1_room_rate", Definition: "进房 cohort 在次日再次进房的留存。"}, + {Metric: "after_recharge_d1_recharge_rate", Definition: "充值 cohort 在次日再次充值的留存。"}, + } + case SocialSectionActive: + return []MetricDefinition{ + {Metric: "active_users", Definition: "服务端活跃事实和关键客户端行为汇总后的当日活跃用户。"}, + {Metric: "room_join_success_rate", Definition: "进房成功用户 / 进房尝试用户。"}, + {Metric: "avg_app_stay_min", Definition: "客户端 app_session 时长 / DAU,单位分钟。"}, + } + case SocialSectionGift: + return []MetricDefinition{ + {Metric: "gift_panel_conversion_rate", Definition: "打开礼物面板用户 / 进房成功用户。"}, + {Metric: "room_gift_conversion_rate", Definition: "送礼用户 / 进房成功用户。"}, + {Metric: "lucky_gift_rtp", Definition: "幸运礼物返奖 / 幸运礼物流水。"}, + } + case SocialSectionGame: + return []MetricDefinition{ + {Metric: "self_game_conversion_rate", Definition: "自研游戏押注用户 / DAU。"}, + {Metric: "probability_game_panel_conversion_rate", Definition: "概率游戏面板打开用户 / 进房成功用户。"}, + {Metric: "probability_game_bet_arppu_coin", Definition: "概率游戏押注金币 / 概率游戏押注用户。"}, + } + default: + return []MetricDefinition{ + {Metric: "app_download_users", Definition: "客户端 app_first_open 去重人数;不代表应用商店真实下载量。"}, + {Metric: "registered_users", Definition: "UserRegistered 服务端事实去重人数。"}, + {Metric: "new_user_d1_retention_rate", Definition: "新普通用户次日活跃留存。"}, + } + } +} + +func socialRequirementColumns(section string) []SocialRequirementColumn { + switch section { + case SocialSectionRevenue: + return []SocialRequirementColumn{ + {Key: "recharge_usd_minor", Label: "充值金额", Type: "money_minor"}, + {Key: "google_recharge_usd_minor", Label: "Google充值", Type: "money_minor"}, + {Key: "third_party_recharge_usd_minor", Label: "三方充值", Type: "money_minor"}, + {Key: "coin_seller_recharge_usd_minor", Label: "币商充值", Type: "money_minor"}, + {Key: "first_recharge_usd_minor", Label: "首充金额", Type: "money_minor"}, + {Key: "repeat_recharge_usd_minor", Label: "复购金额", Type: "money_minor"}, + {Key: "recharge_users", Label: "充值用户", Type: "count"}, + {Key: "recharge_count", Label: "充值次数", Type: "count"}, + {Key: "first_recharge_users", Label: "首充用户", Type: "count"}, + {Key: "repeat_recharge_users", Label: "复购用户", Type: "count"}, + {Key: "repurchase_rate", Label: "复购率", Type: "ratio"}, + {Key: "paid_rate", Label: "付费率", Type: "ratio"}, + {Key: "arpu_usd_minor", Label: "ARPU", Type: "money_minor"}, + {Key: "arppu_usd_minor", Label: "ARPPU", Type: "money_minor"}, + {Key: "consumption_penetration_rate", Label: "消费渗透率", Type: "ratio"}, + {Key: "game_consumption_ratio", Label: "游戏消费占比", Type: "ratio"}, + {Key: "lucky_gift_consumption_ratio", Label: "幸运礼物消费占比", Type: "ratio"}, + {Key: "normal_gift_consumption_ratio", Label: "普通礼物消费占比", Type: "ratio"}, + {Key: "other_consumption_ratio", Label: "其他消费占比", Type: "ratio"}, + } + case SocialSectionRetention: + return []SocialRequirementColumn{ + {Key: "registered_d1_active_base_users", Label: "新用户D1基数", Type: "count"}, + {Key: "registered_d1_active_users", Label: "新用户D1留存", Type: "count"}, + {Key: "registered_d1_active_rate", Label: "新用户D1留存率", Type: "ratio"}, + {Key: "registered_d3_active_rate", Label: "新用户D3留存率", Type: "ratio"}, + {Key: "registered_d7_active_rate", Label: "新用户D7留存率", Type: "ratio"}, + {Key: "registered_d15_active_rate", Label: "新用户D15留存率", Type: "ratio"}, + {Key: "registered_d30_active_rate", Label: "新用户D30留存率", Type: "ratio"}, + {Key: "after_room_d1_active_rate", Label: "进房后D1活跃留存", Type: "ratio"}, + {Key: "after_room_d1_room_rate", Label: "进房后D1进房留存", Type: "ratio"}, + {Key: "after_mic_d1_active_rate", Label: "上麦后D1活跃留存", Type: "ratio"}, + {Key: "after_mic_d1_mic_rate", Label: "上麦后D1上麦留存", Type: "ratio"}, + {Key: "after_gift_d1_active_rate", Label: "送礼后D1活跃留存", Type: "ratio"}, + {Key: "after_gift_d1_gift_rate", Label: "送礼后D1送礼留存", Type: "ratio"}, + {Key: "after_game_d1_active_rate", Label: "游戏后D1活跃留存", Type: "ratio"}, + {Key: "after_game_d1_game_rate", Label: "游戏后D1游戏留存", Type: "ratio"}, + {Key: "after_recharge_d1_active_rate", Label: "充值后D1活跃留存", Type: "ratio"}, + {Key: "after_recharge_d1_recharge_rate", Label: "充值后D1充值留存", Type: "ratio"}, + } + case SocialSectionActive: + return []SocialRequirementColumn{ + {Key: "active_users", Label: "DAU", Type: "count"}, + {Key: "room_join_success_users", Label: "进房成功用户", Type: "count"}, + {Key: "room_join_fail_users", Label: "进房失败用户", Type: "count"}, + {Key: "room_join_success_rate", Label: "进房成功率", Type: "ratio"}, + {Key: "room_join_fail_rate", Label: "进房失败率", Type: "ratio"}, + {Key: "mic_up_users", Label: "上麦用户", Type: "count"}, + {Key: "mic_up_rate", Label: "上麦率", Type: "ratio"}, + {Key: "gift_users", Label: "送礼用户", Type: "count"}, + {Key: "gift_rate", Label: "送礼率", Type: "ratio"}, + {Key: "game_users", Label: "游戏用户", Type: "count"}, + {Key: "game_conversion_rate", Label: "游戏转化率", Type: "ratio"}, + {Key: "chat_users", Label: "发言用户", Type: "count"}, + {Key: "chat_conversion_rate", Label: "发言转化率", Type: "ratio"}, + {Key: "avg_app_stay_min", Label: "人均App停留(分钟)", Type: "number"}, + } + case SocialSectionGift: + return []SocialRequirementColumn{ + {Key: "gift_panel_open_users", Label: "礼物面板用户", Type: "count"}, + {Key: "gift_panel_conversion_rate", Label: "礼物面板转化率", Type: "ratio"}, + {Key: "room_gift_users", Label: "送礼用户", Type: "count"}, + {Key: "room_gift_conversion_rate", Label: "房间送礼转化率", Type: "ratio"}, + {Key: "normal_gift_users", Label: "普通礼物用户", Type: "count"}, + {Key: "normal_gift_coin", Label: "普通礼物流水", Type: "coin"}, + {Key: "normal_gift_arppu_coin", Label: "普通礼物ARPPU", Type: "coin"}, + {Key: "lucky_gift_users", Label: "幸运礼物用户", Type: "count"}, + {Key: "lucky_gift_coin", Label: "幸运礼物流水", Type: "coin"}, + {Key: "lucky_gift_payout_coin", Label: "幸运礼物返奖", Type: "coin"}, + {Key: "lucky_gift_rtp", Label: "幸运礼物RTP", Type: "ratio"}, + {Key: "lucky_gift_arppu_coin", Label: "幸运礼物ARPPU", Type: "coin"}, + {Key: "super_lucky_gift_users", Label: "超级幸运用户", Type: "count"}, + {Key: "super_lucky_gift_coin", Label: "超级幸运流水", Type: "coin"}, + {Key: "super_lucky_gift_payout_coin", Label: "超级幸运返奖", Type: "coin"}, + {Key: "super_lucky_gift_rtp", Label: "超级幸运RTP", Type: "ratio"}, + {Key: "super_lucky_gift_arppu_coin", Label: "超级幸运ARPPU", Type: "coin"}, + } + case SocialSectionGame: + return []SocialRequirementColumn{ + {Key: "active_users", Label: "DAU", Type: "count"}, + {Key: "room_join_success_users", Label: "进房成功用户", Type: "count"}, + {Key: "self_game_conversion_rate", Label: "自研游戏转化率", Type: "ratio"}, + {Key: "self_game_bet_users", Label: "自研押注用户", Type: "count"}, + {Key: "self_game_bet_count", Label: "自研押注次数", Type: "count"}, + {Key: "self_game_bet_count_per_user", Label: "自研人均押注次数", Type: "number"}, + {Key: "self_game_bet_coin", Label: "自研押注金币", Type: "coin"}, + {Key: "self_game_bet_arppu_coin", Label: "自研押注ARPPU", Type: "coin"}, + {Key: "probability_game_panel_open_users", Label: "概率游戏面板用户", Type: "count"}, + {Key: "probability_game_panel_conversion_rate", Label: "概率游戏面板转化率", Type: "ratio"}, + {Key: "probability_game_bet_users", Label: "概率游戏押注用户", Type: "count"}, + {Key: "probability_game_conversion_rate", Label: "概率游戏转化率", Type: "ratio"}, + {Key: "probability_game_bet_count", Label: "概率游戏押注次数", Type: "count"}, + {Key: "probability_game_bet_count_per_user", Label: "概率游戏人均押注次数", Type: "number"}, + {Key: "probability_game_bet_coin", Label: "概率游戏押注金币", Type: "coin"}, + {Key: "probability_game_bet_arppu_coin", Label: "概率游戏押注ARPPU", Type: "coin"}, + } + default: + return []SocialRequirementColumn{ + {Key: "app_download_users", Label: "下载APP人数", Type: "count", Tooltip: "客户端 app_first_open 去重人数"}, + {Key: "registered_users", Label: "注册成功用户", Type: "count"}, + {Key: "new_host_users", Label: "新主播用户", Type: "count"}, + {Key: "new_normal_users", Label: "新普通用户", Type: "count"}, + {Key: "registration_success_rate", Label: "注册成功率", Type: "ratio"}, + {Key: "room_join_success_users", Label: "新用户进房成功", Type: "count"}, + {Key: "room_join_fail_users", Label: "新用户进房失败", Type: "count"}, + {Key: "room_join_success_rate", Label: "新用户进房成功率", Type: "ratio"}, + {Key: "room_join_fail_rate", Label: "新用户进房失败率", Type: "ratio"}, + {Key: "mic_up_users", Label: "新用户上麦", Type: "count"}, + {Key: "mic_up_rate", Label: "新用户上麦率", Type: "ratio"}, + {Key: "gift_users", Label: "新用户送礼", Type: "count"}, + {Key: "gift_rate", Label: "新用户送礼率", Type: "ratio"}, + {Key: "recharge_users", Label: "新用户充值", Type: "count"}, + {Key: "recharge_rate", Label: "新用户充值率", Type: "ratio"}, + {Key: "game_users", Label: "新用户游戏", Type: "count"}, + {Key: "game_conversion_rate", Label: "新用户游戏转化率", Type: "ratio"}, + {Key: "avg_app_stay_min", Label: "人均App停留(分钟)", Type: "number"}, + {Key: "avg_room_stay_min", Label: "人均房间停留(分钟)", Type: "number"}, + {Key: "avg_mic_stay_min", Label: "人均麦上停留(分钟)", Type: "number"}, + {Key: "new_user_recharge_usd_minor", Label: "新用户充值金额", Type: "money_minor"}, + {Key: "follow_user_users", Label: "关注用户人数", Type: "count"}, + {Key: "follow_room_users", Label: "关注房间人数", Type: "count"}, + {Key: "chat_users", Label: "聊天用户", Type: "count"}, + {Key: "new_host_d1_retention_rate", Label: "新主播D1留存", Type: "ratio"}, + {Key: "new_user_d1_retention_rate", Label: "新普通用户D1留存", Type: "ratio"}, + } + } +} + +func retentionRate(values map[string]retentionMetric, key string) float64 { + item := values[key] + return ratio(item.Users, item.Base) +} + +func minutesPerUser(durationMS int64, users int64) float64 { + if users <= 0 { + return 0 + } + return float64(durationMS) / float64(users) / 60000 +} + +func sortedRetentionKeys(values map[string]retentionMetric) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func intLabel(value int) string { + return strconv.Itoa(value) +} + +func socialDayRange(startDay string, endDay string) []string { + start, err := time.Parse("2006-01-02", startDay) + if err != nil { + return []string{startDay} + } + end, err := time.Parse("2006-01-02", endDay) + if err != nil || end.Before(start) { + return []string{startDay} + } + days := []string{} + for current := start; !current.After(end); current = current.AddDate(0, 0, 1) { + days = append(days, current.Format("2006-01-02")) + } + return days +} + +func normalizePositiveIDs(values []int64) []int64 { + seen := map[int64]bool{} + out := []int64{} + for _, value := range values { + if value <= 0 || seen[value] { + continue + } + seen[value] = true + out = append(out, value) + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} diff --git a/services/statistics-service/internal/storage/mysql/social_user_day.go b/services/statistics-service/internal/storage/mysql/social_user_day.go new file mode 100644 index 00000000..42602195 --- /dev/null +++ b/services/statistics-service/internal/storage/mysql/social_user_day.go @@ -0,0 +1,266 @@ +package mysql + +import ( + "context" + "database/sql" + "strconv" + "strings" + + "hyapp/pkg/appcode" +) + +type socialUserDayDelta struct { + AppCode string + StatTZ string + StatDay string + UserID int64 + DeviceID string + CountryID int64 + RegionID int64 + UserRole string + UpdatedAt int64 + FirstOpen int64 + Registered int64 + Active int64 + RoomJoinOK int64 + RoomJoinNG int64 + MicUp int64 + Gift int64 + NormalGift int64 + LuckyGift int64 + SuperGift int64 + Recharge int64 + FirstRecharge int64 + RepeatRecharge int64 + GameBet int64 + SelfGameBet int64 + ProbabilityBet int64 + Chat int64 + FollowUser int64 + FollowRoom int64 + GiftPanel int64 + ProbabilityPanel int64 + AppStayMS int64 + RoomStayMS int64 + MicStayMS int64 + RechargeCount int64 + RechargeUSDMinor int64 + GoogleRechargeUSDMinor int64 + ThirdPartyRechargeUSDMinor int64 + CoinSellerRechargeUSDMinor int64 + FirstRechargeUSDMinor int64 + RepeatRechargeUSDMinor int64 + NormalGiftCoin int64 + LuckyGiftCoin int64 + SuperGiftCoin int64 + LuckyGiftPayoutCoin int64 + SuperGiftPayoutCoin int64 + GameBetCoin int64 + GameBetCount int64 + SelfGameBetCoin int64 + SelfGameBetCount int64 + ProbabilityBetCoin int64 + ProbabilityBetCount int64 + OtherConsumeCoin int64 + OutputCoin int64 +} + +func applySocialUserDayDelta(ctx context.Context, tx *sql.Tx, delta socialUserDayDelta) error { + subjectKey := socialSubjectKey(delta.UserID, delta.DeviceID) + if subjectKey == "" { + return nil + } + app := appcode.Normalize(delta.AppCode) + statTZ := normalizeStatTZ(delta.StatTZ) + day := strings.TrimSpace(delta.StatDay) + if app == "" || day == "" { + return nil + } + countryID, regionID := normalizeDimension(delta.CountryID, delta.RegionID) + role := normalizeSocialUserRole(delta.UserRole) + // 这张表是 P0-P5 的唯一宽表投影。外层 withEvent 或 app_tracking_events 的 INSERT IGNORE 已经完成事件级幂等; + // 这里允许同一事务内分步骤 upsert+update,保证角色和维度快照可以随任意事实补齐,但金额类字段只随已幂等的事实累加一次。 + if _, err := tx.ExecContext(ctx, ` + INSERT INTO stat_social_user_day ( + app_code, stat_tz, stat_day, subject_key, user_id, device_id, + country_id, region_id, user_role, updated_at_ms + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + user_id = CASE WHEN VALUES(user_id) > 0 THEN VALUES(user_id) ELSE user_id END, + device_id = CASE WHEN VALUES(device_id) <> '' THEN VALUES(device_id) ELSE device_id END, + country_id = CASE WHEN VALUES(country_id) > 0 THEN VALUES(country_id) ELSE country_id END, + region_id = CASE WHEN VALUES(region_id) > 0 THEN VALUES(region_id) ELSE region_id END, + user_role = CASE WHEN VALUES(user_role) = 'host' OR user_role = 'host' THEN 'host' ELSE 'user' END, + updated_at_ms = VALUES(updated_at_ms) + `, app, statTZ, day, subjectKey, normalizeID(delta.UserID), strings.TrimSpace(delta.DeviceID), countryID, regionID, role, delta.UpdatedAt); err != nil { + return err + } + _, err := tx.ExecContext(ctx, ` + UPDATE stat_social_user_day + SET app_first_open = GREATEST(app_first_open, ?), + registered_success = GREATEST(registered_success, ?), + active_user = GREATEST(active_user, ?), + room_join_success = GREATEST(room_join_success, ?), + room_join_fail = GREATEST(room_join_fail, ?), + mic_up_success = GREATEST(mic_up_success, ?), + gift_sent = GREATEST(gift_sent, ?), + normal_gift_sent = GREATEST(normal_gift_sent, ?), + lucky_gift_sent = GREATEST(lucky_gift_sent, ?), + super_lucky_gift_sent = GREATEST(super_lucky_gift_sent, ?), + recharge_user = GREATEST(recharge_user, ?), + first_recharge_user = GREATEST(first_recharge_user, ?), + repeat_recharge_user = GREATEST(repeat_recharge_user, ?), + game_bet_user = GREATEST(game_bet_user, ?), + self_game_bet_user = GREATEST(self_game_bet_user, ?), + probability_game_bet_user = GREATEST(probability_game_bet_user, ?), + chat_sent = GREATEST(chat_sent, ?), + follow_user = GREATEST(follow_user, ?), + follow_room = GREATEST(follow_room, ?), + gift_panel_open = GREATEST(gift_panel_open, ?), + probability_game_panel_open = GREATEST(probability_game_panel_open, ?), + app_stay_ms = app_stay_ms + ?, + room_stay_ms = room_stay_ms + ?, + mic_stay_ms = mic_stay_ms + ?, + recharge_count = recharge_count + ?, + recharge_usd_minor = recharge_usd_minor + ?, + google_recharge_usd_minor = google_recharge_usd_minor + ?, + third_party_recharge_usd_minor = third_party_recharge_usd_minor + ?, + coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?, + first_recharge_usd_minor = first_recharge_usd_minor + ?, + repeat_recharge_usd_minor = repeat_recharge_usd_minor + ?, + normal_gift_coin = normal_gift_coin + ?, + lucky_gift_coin = lucky_gift_coin + ?, + super_lucky_gift_coin = super_lucky_gift_coin + ?, + lucky_gift_payout_coin = lucky_gift_payout_coin + ?, + super_lucky_gift_payout_coin = super_lucky_gift_payout_coin + ?, + game_bet_coin = game_bet_coin + ?, + game_bet_count = game_bet_count + ?, + self_game_bet_coin = self_game_bet_coin + ?, + self_game_bet_count = self_game_bet_count + ?, + probability_game_bet_coin = probability_game_bet_coin + ?, + probability_game_bet_count = probability_game_bet_count + ?, + other_consume_coin = other_consume_coin + ?, + output_coin = output_coin + ?, + updated_at_ms = ? + WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND subject_key = ? + `, flagValue(delta.FirstOpen), flagValue(delta.Registered), flagValue(delta.Active), flagValue(delta.RoomJoinOK), flagValue(delta.RoomJoinNG), + flagValue(delta.MicUp), flagValue(delta.Gift), flagValue(delta.NormalGift), flagValue(delta.LuckyGift), flagValue(delta.SuperGift), + flagValue(delta.Recharge), flagValue(delta.FirstRecharge), flagValue(delta.RepeatRecharge), flagValue(delta.GameBet), flagValue(delta.SelfGameBet), + flagValue(delta.ProbabilityBet), flagValue(delta.Chat), flagValue(delta.FollowUser), flagValue(delta.FollowRoom), flagValue(delta.GiftPanel), + flagValue(delta.ProbabilityPanel), normalizeID(delta.AppStayMS), normalizeID(delta.RoomStayMS), normalizeID(delta.MicStayMS), + normalizeID(delta.RechargeCount), normalizeID(delta.RechargeUSDMinor), normalizeID(delta.GoogleRechargeUSDMinor), normalizeID(delta.ThirdPartyRechargeUSDMinor), + normalizeID(delta.CoinSellerRechargeUSDMinor), normalizeID(delta.FirstRechargeUSDMinor), normalizeID(delta.RepeatRechargeUSDMinor), + normalizeID(delta.NormalGiftCoin), normalizeID(delta.LuckyGiftCoin), normalizeID(delta.SuperGiftCoin), normalizeID(delta.LuckyGiftPayoutCoin), + normalizeID(delta.SuperGiftPayoutCoin), normalizeID(delta.GameBetCoin), normalizeID(delta.GameBetCount), normalizeID(delta.SelfGameBetCoin), + normalizeID(delta.SelfGameBetCount), normalizeID(delta.ProbabilityBetCoin), normalizeID(delta.ProbabilityBetCount), normalizeID(delta.OtherConsumeCoin), + normalizeID(delta.OutputCoin), delta.UpdatedAt, app, statTZ, day, subjectKey) + return err +} + +func socialSubjectKey(userID int64, deviceID string) string { + if userID > 0 { + return "u:" + strconv.FormatInt(userID, 10) + } + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" { + return "" + } + return "d:" + deviceID +} + +func flagValue(value int64) int64 { + if value > 0 { + return 1 + } + return 0 +} + +func normalizeSocialUserRole(value string) string { + switch strings.ToLower(strings.TrimSpace(value)) { + case "host", "anchor", "owner", "room_owner", "agency", "guild", "guild_leader": + return "host" + default: + return "user" + } +} + +func socialGiftKind(event RoomGiftEvent) (normalFlag, luckyFlag, superFlag int64, normalCoin, luckyCoin, superCoin int64) { + coin := normalizeID(event.CoinSpent) + if coin == 0 { + coin = normalizeID(event.GiftValue) + } + switch realRoomRobotGiftKind(event) { + case "super_lucky": + return 0, 0, 1, 0, 0, coin + case "lucky": + return 0, 1, 0, 0, coin, 0 + default: + return 1, 0, 0, coin, 0, 0 + } +} + +func socialDeltaForAppTrackingEvent(event AppTrackingEvent, scope statDayScope, nowMS int64) (socialUserDayDelta, bool) { + delta := socialUserDayDelta{ + AppCode: event.AppCode, + StatTZ: scope.tz, + StatDay: scope.day, + UserID: event.UserID, + DeviceID: event.DeviceID, + CountryID: event.CountryID, + RegionID: event.RegionID, + UpdatedAt: nowMS, + } + switch strings.ToLower(strings.TrimSpace(event.EventName)) { + case "app_first_open": + delta.FirstOpen = 1 + delta.Active = 1 + case "home_view": + delta.Active = 1 + case "app_session": + delta.Active = 1 + delta.AppStayMS = event.DurationMS + case "room_join_success": + delta.Active = 1 + delta.RoomJoinOK = 1 + case "room_join_fail": + delta.Active = 1 + delta.RoomJoinNG = 1 + case "room_stay": + delta.Active = 1 + delta.RoomJoinOK = 1 + delta.RoomStayMS = event.DurationMS + case "mic_up": + if !event.Success && strings.TrimSpace(event.ErrorCode) != "" { + return socialUserDayDelta{}, false + } + delta.Active = 1 + delta.MicUp = 1 + case "send_message": + if !event.Success && strings.TrimSpace(event.ErrorCode) != "" { + return socialUserDayDelta{}, false + } + delta.Active = 1 + delta.Chat = 1 + case "follow_host", "add_friend": + if !event.Success && strings.TrimSpace(event.ErrorCode) != "" { + return socialUserDayDelta{}, false + } + delta.Active = 1 + delta.FollowUser = 1 + case "follow_room": + if !event.Success && strings.TrimSpace(event.ErrorCode) != "" { + return socialUserDayDelta{}, false + } + delta.Active = 1 + delta.FollowRoom = 1 + case "gift_panel_open": + delta.Active = 1 + delta.GiftPanel = 1 + case "probability_game_panel_open": + delta.Active = 1 + delta.ProbabilityPanel = 1 + default: + return socialUserDayDelta{}, false + } + return delta, true +}