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 NewUserType 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"` Average SocialRequirementRow `json:"average"` DailySeries []SocialRequirementRow `json:"daily_series"` // P5 游戏 section 附带的按游戏明细:来自 stat_self_game_user_day / stat_game_day_players 投影, // 不受用户身份/付费身份/是否新用户筛选影响(厂商游戏无按用户金额,无法一致地过滤)。 GameColumns []SocialRequirementColumn `json:"game_columns,omitempty"` Games []SocialRequirementGame `json:"games,omitempty"` } type SocialRequirementGame struct { GameKey string `json:"game_key"` GameSource string `json:"game_source"` GameName string `json:"game_name"` 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"` NewUserType string `json:"new_user_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 RoomOnlineMS 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 RegisteredFollowUser int64 RegisteredFollowRoom int64 RegisteredChat int64 RegisteredAppStayMS int64 RegisteredRoomStayMS int64 RegisteredMicStayMS int64 RegisteredRechargeUSDMinor int64 ConsumeUsers 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) var gameBreakdown []SocialRequirementGame for _, section := range sections { if section == SocialSectionGame { gameBreakdown, err = r.querySocialGameBreakdown(ctx, app, statTZ, days, query.CountryID, query.RegionID, query.RegionIDs) if err != nil { return SocialRequirements{}, err } break } } 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, query.NewUserType) totalAgg.add(agg) retention := socialRetentionMetrics(day, byDay[day], bySubjectDay, section, query.UserRole, query.PayerType, query.NewUserType) mergeRetentionMetrics(totalRetention, retention) sectionRows = append(sectionRows, SocialRequirementRow{ StatDay: day, Label: day, Metrics: socialSectionMetrics(section, agg, retention), }) } sectionOut := SocialRequirementSection{ Key: section, Label: socialSectionLabel(section), Filters: SocialRequirementFilter{ UserRole: normalizeSocialRequirementRole(query.UserRole), PayerType: normalizeSocialPayerType(query.PayerType), NewUserType: normalizeSocialNewUserType(query.NewUserType), }, Columns: socialRequirementColumns(section), MetricDefinitions: socialRequirementMetricDefinitions(section), Total: SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialSectionMetrics(section, totalAgg, totalRetention)}, Average: socialAverageRow(sectionRows), DailySeries: sectionRows, } if section == SocialSectionGame { sectionOut.GameColumns = socialGameBreakdownColumns() sectionOut.Games = gameBreakdown } out.Sections = append(out.Sections, sectionOut) } return out, nil } // socialAverageRow 输出按天算术平均的"平均值"行:每个指标对有值的天求均值。 // 计数/金额类是"日均量",比例类是"日均率"(与汇总行的整段合并口径互补)。 func socialAverageRow(rows []SocialRequirementRow) SocialRequirementRow { sums := map[string]float64{} counts := map[string]int{} for _, row := range rows { for key, value := range row.Metrics { switch v := value.(type) { case int64: sums[key] += float64(v) counts[key]++ case float64: sums[key] += v counts[key]++ } } } metrics := make(map[string]any, len(sums)) for key, sum := range sums { if counts[key] > 0 { metrics[key] = sum / float64(counts[key]) } } return SocialRequirementRow{StatDay: "average", Label: "平均值", Metrics: metrics} } 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, room_online_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.RoomOnlineMS, &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 } // 在房时长口径:客户端 room_stay 埋点(杀进程/闪退整段丢失)和服务端进退房事实 room_online_ms // 双轨并存,取 GREATEST 避免切换期数据断层;埋点按 app 版本灰度下线后该口径自然收敛到服务端事实。 if item.RoomStayMS < item.RoomOnlineMS { item.RoomStayMS = item.RoomOnlineMS } // 在房时长下界修正:上麦必在房内,麦时长(服务端事实,权威)是房时长的天然下界。 // 服务端在房事实上线后保留该钳制作为兜底(如回灌历史区间尚无服务端事实)。 if item.RoomStayMS < item.MicStayMS { item.RoomStayMS = item.MicStayMS } 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, newUserType string) socialRequirementAggregate { var agg socialRequirementAggregate for _, row := range rows { if !socialRowMatchesSection(row, section, role, payerType, newUserType) { 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++ } // P0 的时长/关注/聊天/充值金额按文档限定「当天注册的新用户」,不能混入全体用户。 if row.Registered > 0 { if row.FollowUser > 0 { agg.RegisteredFollowUser++ } if row.FollowRoom > 0 { agg.RegisteredFollowRoom++ } if row.Chat > 0 { agg.RegisteredChat++ } agg.RegisteredAppStayMS += row.AppStayMS agg.RegisteredRoomStayMS += row.RoomStayMS agg.RegisteredMicStayMS += row.MicStayMS agg.RegisteredRechargeUSDMinor += row.RechargeUSDMinor } // 消费渗透率的分子是「有任一金币消耗行为」的去重用户(送礼/游戏/其他消费),不能把各行为人数直接相加。 if row.Gift > 0 || row.GameBet > 0 || row.OtherConsumeCoin > 0 { agg.ConsumeUsers++ } 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.RegisteredFollowUser += other.RegisteredFollowUser a.RegisteredFollowRoom += other.RegisteredFollowRoom a.RegisteredChat += other.RegisteredChat a.RegisteredAppStayMS += other.RegisteredAppStayMS a.RegisteredRoomStayMS += other.RegisteredRoomStayMS a.RegisteredMicStayMS += other.RegisteredMicStayMS a.RegisteredRechargeUSDMinor += other.RegisteredRechargeUSDMinor a.ConsumeUsers += other.ConsumeUsers 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, newUserType string) map[string]retentionMetric { out := map[string]retentionMetric{} for _, row := range rows { if !socialRowMatchesSection(row, section, role, payerType, newUserType) { continue } // 文档口径:用户次留/3/7/15/30日留以「当日全体活跃用户」为基数,次日继续活跃算留存; // 新用户(注册 cohort)留存只保留在 P0 的分身份次留里。 for _, offset := range []int{1, 3, 7, 15, 30} { if row.Active > 0 { addSocialRetention(out, "active_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 ordinaryRechargeUSDMinor := agg.FirstRechargeUSDMinor + agg.RepeatRechargeUSDMinor 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(ordinaryRechargeUSDMinor, agg.Recharge), "consumption_penetration_rate": ratio(agg.ConsumeUsers, 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, // 文档口径:进房成功率/失败率的分母都是 DAU,不是进房尝试数。 "room_join_success_rate": ratio(agg.RoomJoinOK, agg.Active), "room_join_fail_rate": ratio(agg.RoomJoinNG, agg.Active), "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.RoomJoinOK), "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), // P0 全部限定当天注册的新用户:时长按新用户累计、除以对应行为的新用户数。 "avg_app_stay_min": minutesPerUser(agg.RegisteredAppStayMS, agg.Registered), "avg_room_stay_min": minutesPerUser(agg.RegisteredRoomStayMS, agg.RegisteredRoomJoinOK), "avg_mic_stay_min": minutesPerUser(agg.RegisteredMicStayMS, agg.RegisteredMicUp), "new_user_recharge_usd_minor": agg.RegisteredRechargeUSDMinor, "follow_user_users": agg.RegisteredFollowUser, "follow_room_users": agg.RegisteredFollowRoom, "chat_users": agg.RegisteredChat, "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, newUserType string) bool { switch section { case SocialSectionNewUsers: return true case SocialSectionGift, SocialSectionGame: return socialRoleMatches(row, role) && socialNewUserMatches(row, newUserType) default: return socialRoleMatches(row, role) && socialPayerMatches(row, payerType) && socialNewUserMatches(row, newUserType) } } // 是否新用户筛选:新用户 = 当天注册成功的用户(registered_success 标志按行的 stat_day 判定)。 func socialNewUserMatches(row socialRequirementUserDay, newUserType string) bool { switch normalizeSocialNewUserType(newUserType) { case "new": return row.Registered > 0 case "old": return row.Registered == 0 default: return true } } func normalizeSocialNewUserType(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "new": return "new" case "old", "existing", "non_new": return "old" default: return "all" } } 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 用户充值和 finance verified 币商充值订单汇总,单位 USD minor;总充值 = Google + 第三方 + 币商。"}, {Metric: "third_party_recharge_usd_minor", Definition: "MiFaPay/V5Pay/USDT 等非 Google、非币商支付渠道的用户充值金额。"}, {Metric: "coin_seller_recharge_usd_minor", Definition: "finance 工作台币商充值订单 verify_status=verified 后按 created_at_ms 计入。"}, {Metric: "repurchase_rate", Definition: "复购充值用户 / 当日充值用户。"}, {Metric: "paid_rate", Definition: "充值用户 / DAU,支持用户身份和当日付费身份筛选。"}, {Metric: "arpu_usd_minor", Definition: "充值金额 / DAU。"}, {Metric: "arppu_usd_minor", Definition: "普通充值金额 / 普通充值用户;finance 币商订单只进入收入金额和订单次数,不污染用户付费均值。"}, } case SocialSectionRetention: return []MetricDefinition{ {Metric: "active_d1_active_rate", Definition: "当日全体活跃用户(进入 app)在次日继续活跃的比例;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: "active_d1_active_base_users", Label: "活跃用户基数", Type: "count", Tooltip: "当日进入 app 的全体活跃用户数(不限新用户)"}, {Key: "active_d1_active_users", Label: "次留人数", Type: "count", Tooltip: "当日活跃用户中,次日继续进入 app 的人数"}, {Key: "active_d1_active_rate", Label: "用户次留", Type: "ratio", Tooltip: "昨日活跃用户中,今日继续活跃的比例"}, {Key: "active_d3_active_rate", Label: "用户3日留", Type: "ratio"}, {Key: "active_d7_active_rate", Label: "用户7日留", Type: "ratio"}, {Key: "active_d15_active_rate", Label: "用户15日留", Type: "ratio"}, {Key: "active_d30_active_rate", Label: "用户30日留", Type: "ratio"}, {Key: "after_room_d1_active_rate", Label: "进房后次留(活跃)", Type: "ratio", Tooltip: "当天进房后,次日继续登录"}, {Key: "after_room_d1_room_rate", Label: "进房后次留(进房)", Type: "ratio", Tooltip: "当天进房后,次日继续进房"}, {Key: "after_mic_d1_active_rate", Label: "上麦后次留(活跃)", Type: "ratio", Tooltip: "当天上麦后,次日继续登录"}, {Key: "after_mic_d1_mic_rate", Label: "上麦后次留(上麦)", Type: "ratio", Tooltip: "当天上麦后,次日继续上麦"}, {Key: "after_gift_d1_active_rate", Label: "送礼后次留(活跃)", Type: "ratio", Tooltip: "当天送礼后,次日继续登录"}, {Key: "after_gift_d1_gift_rate", Label: "送礼后次留(送礼)", Type: "ratio", Tooltip: "当天送礼后,次日继续送礼"}, {Key: "after_game_d1_active_rate", Label: "游戏次留(活跃)", Type: "ratio", Tooltip: "当天押注后,次日继续登录"}, {Key: "after_game_d1_game_rate", Label: "游戏次留(游戏)", Type: "ratio", Tooltip: "当天押注后,次日继续押注"}, {Key: "after_recharge_d1_active_rate", Label: "充值后次留(活跃)", Type: "ratio", Tooltip: "当天充值后,次日继续活跃"}, {Key: "after_recharge_d1_recharge_rate", Label: "充值后次留(充值)", Type: "ratio", Tooltip: "当天充值后,次日继续充值"}, } 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: "新用户平均停留(分钟)", Type: "number", Tooltip: "当天注册的新用户 App 停留总时长 / 新用户注册成功人数"}, {Key: "avg_room_stay_min", Label: "新用户平均进房时长(分钟)", Type: "number", Tooltip: "当天注册的新用户房间停留总时长 / 新用户进房成功人数"}, {Key: "avg_mic_stay_min", Label: "新用户平均上麦时长(分钟)", Type: "number", Tooltip: "当天注册的新用户麦上总时长 / 新用户上麦人数"}, {Key: "new_user_recharge_usd_minor", Label: "新用户充值金额", Type: "money_minor", Tooltip: "当天注册的新用户全渠道充值金额"}, {Key: "follow_user_users", Label: "新用户关注行为人数", Type: "count", Tooltip: "当天注册的新用户中发生过关注用户行为的人数"}, {Key: "follow_room_users", Label: "新用户关注房间人数", Type: "count", Tooltip: "当天注册的新用户中发生过关注房间行为的人数"}, {Key: "chat_users", Label: "新用户聊天人数", Type: "count", Tooltip: "当天注册的新用户中发送过 IM 消息(房间或私聊)的人数"}, {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 } type socialGameKey struct { Source string Platform string GameID string } type socialGameDayData struct { Users map[string]map[int64]bool // stat_day -> 押注用户集合 Coin map[string]int64 // stat_day -> 押注金币 } // querySocialGameBreakdown 输出 P5 的按游戏明细:押注用户、同游戏次留、ARPPU、押注金币。 // 自研游戏读 stat_self_game_user_day(用户级、含金额);厂商/概率游戏用户集读 stat_game_day_players、 // 金额读 stat_game_day_country(该表无用户级金额)。次留需要展示末日的次日数据,成员集多读 1 天。 func (r *Repository) querySocialGameBreakdown(ctx context.Context, app string, statTZ string, days []string, countryID int64, regionID int64, regionIDs []int64) ([]SocialRequirementGame, error) { if len(days) == 0 { return nil, nil } startDay := days[0] memberEndDay := addStatDays(days[len(days)-1], 1) games := map[socialGameKey]*socialGameDayData{} ensure := func(key socialGameKey) *socialGameDayData { if games[key] == nil { games[key] = &socialGameDayData{Users: map[string]map[int64]bool{}, Coin: map[string]int64{}} } return games[key] } scopeConditions := func(base []string, args []any) ([]string, []any) { if countryID > 0 { base = append(base, "country_id = ?") args = append(args, countryID) } if regionID > 0 { base = append(base, "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) } base = append(base, "region_id IN ("+strings.Join(placeholders, ",")+")") } return base, args } selfConditions, selfArgs := scopeConditions( []string{"app_code = ?", "stat_tz = ?", "stat_day BETWEEN ? AND ?", "match_count > 0"}, []any{appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, memberEndDay}, ) selfRows, err := r.db.QueryContext(ctx, ` SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), game_id, user_id, stake_coin FROM stat_self_game_user_day WHERE `+strings.Join(selfConditions, " AND "), selfArgs...) if err != nil { return nil, err } defer selfRows.Close() for selfRows.Next() { var day, gameID string var userID, stakeCoin int64 if err := selfRows.Scan(&day, &gameID, &userID, &stakeCoin); err != nil { return nil, err } data := ensure(socialGameKey{Source: "self", GameID: gameID}) if data.Users[day] == nil { data.Users[day] = map[int64]bool{} } data.Users[day][userID] = true data.Coin[day] += stakeCoin } if err := selfRows.Err(); err != nil { return nil, err } vendorConditions, vendorArgs := scopeConditions( []string{"app_code = ?", "stat_tz = ?", "stat_day BETWEEN ? AND ?"}, []any{appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, memberEndDay}, ) vendorRows, err := r.db.QueryContext(ctx, ` SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), platform_code, game_id, user_id FROM stat_game_day_players WHERE `+strings.Join(vendorConditions, " AND "), vendorArgs...) if err != nil { return nil, err } defer vendorRows.Close() for vendorRows.Next() { var day, platform, gameID string var userID int64 if err := vendorRows.Scan(&day, &platform, &gameID, &userID); err != nil { return nil, err } data := ensure(socialGameKey{Source: "vendor", Platform: platform, GameID: gameID}) if data.Users[day] == nil { data.Users[day] = map[int64]bool{} } data.Users[day][userID] = true } if err := vendorRows.Err(); err != nil { return nil, err } turnoverRows, err := r.db.QueryContext(ctx, ` SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), platform_code, game_id, SUM(turnover_coin) FROM stat_game_day_country WHERE `+strings.Join(vendorConditions, " AND ")+` GROUP BY stat_day, platform_code, game_id`, vendorArgs...) if err != nil { return nil, err } defer turnoverRows.Close() for turnoverRows.Next() { var day, platform, gameID string var turnover int64 if err := turnoverRows.Scan(&day, &platform, &gameID, &turnover); err != nil { return nil, err } key := socialGameKey{Source: "vendor", Platform: platform, GameID: gameID} if games[key] == nil { // 只有金额没有用户投影的历史脏数据不单独成行,避免出现"有流水无用户"的幽灵游戏。 continue } games[key].Coin[day] += turnover } if err := turnoverRows.Err(); err != nil { return nil, err } out := make([]SocialRequirementGame, 0, len(games)) for key, data := range games { daily := make([]SocialRequirementRow, 0, len(days)) var totalUsers, totalCoin int64 totalRetention := retentionMetric{} for _, day := range days { users := int64(len(data.Users[day])) coin := data.Coin[day] retention := retentionMetric{Base: users} nextDay := data.Users[addStatDays(day, 1)] for userID := range data.Users[day] { if nextDay[userID] { retention.Users++ } } totalUsers += users totalCoin += coin totalRetention.Base += retention.Base totalRetention.Users += retention.Users daily = append(daily, SocialRequirementRow{ StatDay: day, Label: day, Metrics: socialGameRowMetrics(users, coin, retention), }) } if totalUsers == 0 && totalCoin == 0 { continue } keyParts := []string{key.Source} if strings.TrimSpace(key.Platform) != "" { keyParts = append(keyParts, strings.TrimSpace(key.Platform)) } keyParts = append(keyParts, strings.TrimSpace(key.GameID)) out = append(out, SocialRequirementGame{ GameKey: strings.Join(keyParts, ":"), GameSource: key.Source, GameName: socialGameDisplayName(key), Total: SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialGameRowMetrics(totalUsers, totalCoin, totalRetention)}, DailySeries: daily, }) } sort.Slice(out, func(i, j int) bool { left := out[i].Total.Metrics["game_bet_coin"].(int64) right := out[j].Total.Metrics["game_bet_coin"].(int64) if left != right { return left > right } return out[i].GameName < out[j].GameName }) return out, nil } func socialGameRowMetrics(users int64, coin int64, retention retentionMetric) map[string]any { return map[string]any{ "game_bet_users": users, "game_bet_coin": coin, "game_arppu_coin": div(coin, users), "game_d1_retention_base_users": retention.Base, "game_d1_retention_users": retention.Users, "game_d1_retention_rate": ratio(retention.Users, retention.Base), } } func socialGameDisplayName(key socialGameKey) string { name := strings.TrimSpace(key.GameID) if name == "" { name = "unknown" } if key.Source == "vendor" && strings.TrimSpace(key.Platform) != "" { return name + " (" + strings.TrimSpace(key.Platform) + ")" } return name } func socialGameBreakdownColumns() []SocialRequirementColumn { return []SocialRequirementColumn{ {Key: "game_bet_users", Label: "押注用户数", Type: "count", Tooltip: "当日押注过该游戏的去重用户数"}, {Key: "game_d1_retention_rate", Label: "游戏次留", Type: "ratio", Tooltip: "当日押注该游戏的用户中,次日继续押注同一游戏的比例"}, {Key: "game_arppu_coin", Label: "ARPPU(金币)", Type: "coin", Tooltip: "该游戏押注金币 / 押注用户数"}, {Key: "game_bet_coin", Label: "押注金币", Type: "coin"}, } } 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 }