2026-07-09 18:15:57 +08:00

970 lines
42 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}