1318 lines
57 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
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
}