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 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.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 用户充值和 finance verified 币商充值订单汇总,单位 USD minor。"}, {Metric: "third_party_recharge_usd_minor", Definition: "finance 工作台币商充值订单 verify_status=verified 后按 created_at_ms 计入;普通 MifaPay/V5Pay/USDT 用户充值不进入此列。"}, {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: "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 }