修正整体留存成熟度与查询性能

This commit is contained in:
zhx 2026-07-16 14:27:42 +08:00
parent e1844e26a0
commit f79abb496b
4 changed files with 275 additions and 17 deletions

View File

@ -47,6 +47,20 @@ func TestActivityTemplateDataHTTPRejectsPartialUTCDay(t *testing.T) {
} }
} }
func TestSocialRequirementsHTTPRejectsBaseRangeOver31Days(t *testing.T) {
start := time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC)
request := httptest.NewRequest(http.MethodGet, fmt.Sprintf(
"/internal/v1/statistics/social/requirements?app_code=lalu&section=retention&start_ms=%d&end_ms=%d",
start.UnixMilli(), start.AddDate(0, 0, 32).UnixMilli(),
), nil)
response := httptest.NewRecorder()
// nil repo proves the range guard rejects the request before any database query.
(&queryHTTPServer{}).socialRequirements(response, request)
if response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "at most 31 days") {
t.Fatalf("unexpected over-range response: status=%d body=%s", response.Code, response.Body.String())
}
}
func TestActivityTemplateMQProjectionAndQueryHTTPAreIdempotent(t *testing.T) { func TestActivityTemplateMQProjectionAndQueryHTTPAreIdempotent(t *testing.T) {
ctx := context.Background() ctx := context.Background()
_, file, _, _ := runtime.Caller(0) _, file, _, _ := runtime.Caller(0)

View File

@ -21,6 +21,8 @@ type queryHTTPServer struct {
listener net.Listener listener net.Listener
} }
const socialRequirementsMaxBaseRange = 31 * 24 * time.Hour
func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPServer, error) { func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPServer, error) {
addr = strings.TrimSpace(addr) addr = strings.TrimSpace(addr)
if addr == "" { if addr == "" {
@ -225,6 +227,12 @@ func (s *queryHTTPServer) socialRequirements(w http.ResponseWriter, r *http.Requ
if endMS <= startMS { if endMS <= startMS {
endMS = startMS + 24*time.Hour.Milliseconds() endMS = startMS + 24*time.Hour.Milliseconds()
} }
// 留存最多还会向后读取 30 天观察窗;限制基准区间可以把单次扫描稳定
// 控制在最多 61 个用户日分区,避免自定义超长时间范围压垮线上 MySQL。
if endMS-startMS > socialRequirementsMaxBaseRange.Milliseconds() {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "start_ms and end_ms must span at most 31 days"})
return
}
requirements, err := s.repo.QuerySocialRequirements(r.Context(), mysqlstorage.SocialRequirementsQuery{ requirements, err := s.repo.QuerySocialRequirements(r.Context(), mysqlstorage.SocialRequirementsQuery{
AppCode: query.Get("app_code"), AppCode: query.Get("app_code"),
StatTZ: query.Get("stat_tz"), StatTZ: query.Get("stat_tz"),

View File

@ -57,6 +57,135 @@ func TestCanonicalizeSocialUserDaysMergesIdentityWithoutLosingDeviceCount(t *tes
} }
} }
func TestSocialRetentionUserDayProjectionStaysNarrow(t *testing.T) {
for _, required := range []string{
"stat_day", "subject_key", "user_id", "device_id", "country_id", "region_id", "user_role",
"registered_success", "active_user", "room_join_success", "mic_up_success", "gift_sent", "recharge_user", "game_bet_user",
} {
if !strings.Contains(socialRetentionUserDayProjection, required) {
t.Fatalf("retention projection misses %s: %s", required, socialRetentionUserDayProjection)
}
}
for _, forbidden := range []string{
"app_stay_ms", "room_stay_ms", "recharge_usd_minor", "normal_gift_coin", "game_bet_coin", "output_coin",
} {
if strings.Contains(socialRetentionUserDayProjection, forbidden) {
t.Fatalf("retention projection unexpectedly includes wide metric %s: %s", forbidden, socialRetentionUserDayProjection)
}
}
}
func TestSocialRetentionMetricsExcludeUnobservableCohorts(t *testing.T) {
day := "2026-07-01"
host := socialRequirementUserDay{
StatDay: day, Subject: "u:1", UserID: 1, UserRole: "host", Registered: 1, Active: 1,
RoomJoinOK: 1, MicUp: 1, Gift: 1, GameBet: 1, Recharge: 1,
}
user := socialRequirementUserDay{StatDay: day, Subject: "u:2", UserID: 2, UserRole: "user", Registered: 1, Active: 1}
bySubjectDay := map[string]map[string]socialRequirementUserDay{
host.Subject: {},
user.Subject: {},
}
for _, offset := range []int{1, 3, 7, 15, 30} {
targetDay := addStatDays(day, offset)
bySubjectDay[host.Subject][targetDay] = socialRequirementUserDay{
StatDay: targetDay, Subject: host.Subject, UserID: host.UserID, Active: 1,
RoomJoinOK: 1, MicUp: 1, Gift: 1, GameBet: 1, Recharge: 1,
}
bySubjectDay[user.Subject][targetDay] = socialRequirementUserDay{
StatDay: targetDay, Subject: user.Subject, UserID: user.UserID, Active: 1,
}
}
// 即使未来观察行已经存在于索引D+N 超过可观察日也必须保持字段缺失,
// 证明成熟度由统计日期控制,而不是把“查不到目标行”误当作唯一判断。
immature := socialRetentionMetrics(day, day, []socialRequirementUserDay{host, user}, bySubjectDay, SocialSectionRetention, "all", "all", "all")
if len(immature) != 0 {
t.Fatalf("unobservable retention must be omitted: %+v", immature)
}
immatureMetrics := socialSectionMetrics(SocialSectionRetention, socialRequirementAggregate{}, immature)
for _, key := range []string{"active_d1_active_users", "active_d1_active_rate"} {
if _, ok := immatureMetrics[key]; ok {
t.Fatalf("unobservable metric %s must be absent: %+v", key, immatureMetrics)
}
}
immatureDailyMetrics := socialRetentionDailyMetrics(immatureMetrics, []socialRequirementUserDay{host, user}, "all", "all", "all")
for _, offset := range []int{1, 3, 7, 15, 30} {
key := "active_d" + strconv.Itoa(offset) + "_active_base_users"
if immatureDailyMetrics[key] != int64(2) {
t.Fatalf("unobservable daily base %s must expose canonical DAU: %+v", key, immatureDailyMetrics)
}
}
if _, ok := immatureDailyMetrics["active_d1_active_users"]; ok {
t.Fatalf("unobservable daily users must remain absent: %+v", immatureDailyMetrics)
}
if filtered := socialRetentionDailyMetrics(immatureMetrics, []socialRequirementUserDay{host, user}, "host", "paid", "new"); filtered["active_d1_active_base_users"] != int64(1) {
t.Fatalf("daily base must apply role/payer/new-user filters after canonicalization: %+v", filtered)
}
d1 := socialRetentionMetrics(day, addStatDays(day, 1), []socialRequirementUserDay{host, user}, bySubjectDay, SocialSectionRetention, "all", "all", "all")
if d1["active_d1_active"].Base != 2 || d1["active_d1_active"].Users != 2 {
t.Fatalf("mature active D1 mismatch: %+v", d1["active_d1_active"])
}
for _, key := range []string{
"after_room_d1_active", "after_room_d1_room",
"after_mic_d1_active", "after_mic_d1_mic",
"after_gift_d1_active", "after_gift_d1_gift",
"after_game_d1_active", "after_game_d1_game",
"after_recharge_d1_active", "after_recharge_d1_recharge",
} {
if d1[key].Base != 1 || d1[key].Users != 1 {
t.Fatalf("mature behavior D1 %s mismatch: %+v", key, d1[key])
}
}
for _, key := range []string{"active_d3_active", "active_d7_active", "active_d15_active", "active_d30_active"} {
if _, ok := d1[key]; ok {
t.Fatalf("future offset %s must remain absent: %+v", key, d1)
}
}
allMature := socialRetentionMetrics(day, addStatDays(day, 30), []socialRequirementUserDay{host, user}, bySubjectDay, SocialSectionRetention, "all", "all", "all")
for _, offset := range []int{1, 3, 7, 15, 30} {
key := "active_d" + strconv.Itoa(offset) + "_active"
if allMature[key].Base != 2 || allMature[key].Users != 2 {
t.Fatalf("mature active D%d mismatch: %+v", offset, allMature[key])
}
}
newUserImmature := socialRetentionMetrics(day, day, []socialRequirementUserDay{host, user}, bySubjectDay, SocialSectionNewUsers, "all", "all", "all")
newUserImmatureMetrics := socialSectionMetrics(SocialSectionNewUsers, socialRequirementAggregate{}, newUserImmature)
for _, key := range []string{"new_host_d1_retention_rate", "new_user_d1_retention_rate"} {
if _, ok := newUserImmatureMetrics[key]; ok {
t.Fatalf("unobservable new-user metric %s must be absent: %+v", key, newUserImmatureMetrics)
}
}
newUserD1 := socialRetentionMetrics(day, addStatDays(day, 1), []socialRequirementUserDay{host, user}, bySubjectDay, SocialSectionNewUsers, "all", "all", "all")
for _, key := range []string{"new_host_d1_active", "new_user_d1_active"} {
if newUserD1[key].Base != 1 || newUserD1[key].Users != 1 {
t.Fatalf("mature new-user D1 %s mismatch: %+v", key, newUserD1[key])
}
}
// 汇总只合并成熟 cohort平均值只对实际存在该指标的日行求均值。
total := map[string]retentionMetric{}
mergeRetentionMetrics(total, d1)
mergeRetentionMetrics(total, immature)
totalMetrics := socialSectionMetrics(SocialSectionRetention, socialRequirementAggregate{}, total)
average := socialAverageRow([]SocialRequirementRow{
{StatDay: day, Metrics: socialSectionMetrics(SocialSectionRetention, socialRequirementAggregate{}, d1)},
{StatDay: addStatDays(day, 1), Metrics: immatureMetrics},
})
if totalMetrics["active_d1_active_base_users"] != int64(2) || totalMetrics["active_d1_active_users"] != int64(2) || average.Metrics["active_d1_active_users"] != float64(2) {
t.Fatalf("unobservable cohort polluted total/average: total=%+v average=%+v", totalMetrics, average.Metrics)
}
for _, column := range socialRequirementColumns(SocialSectionRetention) {
if column.Key == "active_d1_active_rate" && column.Tooltip != "当日活跃用户中,次日继续进入 app 的比例" {
t.Fatalf("D1 tooltip mismatch: %q", column.Tooltip)
}
}
}
func TestCanonicalOverviewActiveOverlaysReplaceLegacyDAUAndFillDimensions(t *testing.T) { func TestCanonicalOverviewActiveOverlaysReplaceLegacyDAUAndFillDimensions(t *testing.T) {
cube := canonicalOverviewActiveCube{ cube := canonicalOverviewActiveCube{
Total: 11, Total: 11,
@ -690,6 +819,21 @@ func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
math.Abs(socialMetricFloat(t, retention.Total, "after_room_d1_room_rate")-1) > 0.0001 { math.Abs(socialMetricFloat(t, retention.Total, "after_room_d1_room_rate")-1) > 0.0001 {
t.Fatalf("P2 retention metrics mismatch: %+v", retention.Total.Metrics) t.Fatalf("P2 retention metrics mismatch: %+v", retention.Total.Metrics)
} }
// 单独请求 retention 必须走窄列投影,同时保持与全 section 查询相同的
// canonical 身份归并、区域过滤和留存结果。
retentionOnly, err := repository.QuerySocialRequirements(ctx, SocialRequirementsQuery{
AppCode: "lalu", StartMS: query.StartMS, EndMS: query.EndMS,
RegionIDs: []int64{210}, Section: SocialSectionRetention,
})
if err != nil {
t.Fatalf("query narrow retention requirements: %v", err)
}
retentionOnlySection := socialTestSection(t, retentionOnly, SocialSectionRetention)
if socialMetricInt(t, retentionOnlySection.Total, "active_d1_active_base_users") != 2 ||
socialMetricInt(t, retentionOnlySection.Total, "active_d1_active_users") != 1 ||
math.Abs(socialMetricFloat(t, retentionOnlySection.Total, "active_d1_active_rate")-0.5) > 0.0001 {
t.Fatalf("narrow retention projection changed canonical result: %+v", retentionOnlySection.Total.Metrics)
}
active := socialTestSection(t, result, SocialSectionActive) active := socialTestSection(t, result, SocialSectionActive)
if socialMetricInt(t, active.Total, "active_users") != 2 || if socialMetricInt(t, active.Total, "active_users") != 2 ||
socialMetricInt(t, active.Total, "room_join_success_users") != 1 || socialMetricInt(t, active.Total, "room_join_success_users") != 1 ||

View File

@ -221,6 +221,12 @@ type retentionMetric struct {
Users int64 Users int64
} }
// retention-only 请求只需要这些身份、筛选和留存行为列。保持投影集中定义,
// 避免后续无意把金额、时长等约 50 列重新带回最长 61 天的留存扫描。
const socialRetentionUserDayProjection = `DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, device_id,
country_id, region_id, user_role, registered_success, active_user,
room_join_success, mic_up_success, gift_sent, recharge_user, game_bet_user`
func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRequirementsQuery) (SocialRequirements, error) { func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRequirementsQuery) (SocialRequirements, error) {
app := appcode.Normalize(query.AppCode) app := appcode.Normalize(query.AppCode)
if app == "" { if app == "" {
@ -237,12 +243,20 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe
} }
startDay := statDayIn(startMS, statTZ) startDay := statDayIn(startMS, statTZ)
endDay := statDayIn(endMS-1, statTZ) endDay := statDayIn(endMS-1, statTZ)
observableDay := statDayIn(time.Now().UnixMilli(), statTZ)
days := socialDayRange(startDay, endDay) days := socialDayRange(startDay, endDay)
sections := socialRequestedSections(query.Section) sections := socialRequestedSections(query.Section)
lookaheadDays := socialRetentionLookaheadDays(sections) lookaheadDays := socialRetentionLookaheadDays(sections)
// 只有 P0 次留或 P2 留存需要读取查询结束日之后的观察行;单独查看活跃、 // 只有 P0 次留或 P2 留存需要读取查询结束日之后的观察行;单独查看活跃、
// 营收、送礼、游戏时不再固定多扫 30 天的 50 列用户日宽表。 // 营收、送礼、游戏时不再固定多扫 30 天的 50 列用户日宽表。
allRows, err := r.querySocialUserDays(ctx, app, statTZ, startDay, addStatDays(endDay, lookaheadDays), 0, 0, nil) queryEndDay := addStatDays(endDay, lookaheadDays)
var allRows []socialRequirementUserDay
var err error
if len(sections) == 1 && sections[0] == SocialSectionRetention {
allRows, err = r.querySocialRetentionUserDays(ctx, app, statTZ, startDay, queryEndDay)
} else {
allRows, err = r.querySocialUserDays(ctx, app, statTZ, startDay, queryEndDay, 0, 0, nil)
}
if err != nil { if err != nil {
return SocialRequirements{}, err return SocialRequirements{}, err
} }
@ -273,6 +287,7 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe
} }
for _, section := range sections { for _, section := range sections {
sectionRows := make([]SocialRequirementRow, 0, len(days)) sectionRows := make([]SocialRequirementRow, 0, len(days))
averageRows := make([]SocialRequirementRow, 0, len(days))
totalAgg := socialRequirementAggregate{} totalAgg := socialRequirementAggregate{}
totalRetention := map[string]retentionMetric{} totalRetention := map[string]retentionMetric{}
for _, day := range days { for _, day := range days {
@ -280,13 +295,20 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe
totalAgg.add(agg) totalAgg.add(agg)
retention := map[string]retentionMetric{} retention := map[string]retentionMetric{}
if socialSectionNeedsRetention(section) { if socialSectionNeedsRetention(section) {
retention = socialRetentionMetrics(day, byDay[day], bySubjectDay, section, query.UserRole, query.PayerType, query.NewUserType) retention = socialRetentionMetrics(day, observableDay, byDay[day], bySubjectDay, section, query.UserRole, query.PayerType, query.NewUserType)
} }
mergeRetentionMetrics(totalRetention, retention) mergeRetentionMetrics(totalRetention, retention)
metrics := socialSectionMetrics(section, agg, retention)
averageRows = append(averageRows, SocialRequirementRow{StatDay: day, Label: day, Metrics: metrics})
// 日行始终展示基准日 canonical DAU方便当前日判断 cohort 规模;
// 汇总和平均仍使用注入前的 metrics只合并已经到达观察日的 cohort。
if section == SocialSectionRetention {
metrics = socialRetentionDailyMetrics(metrics, byDay[day], query.UserRole, query.PayerType, query.NewUserType)
}
sectionRows = append(sectionRows, SocialRequirementRow{ sectionRows = append(sectionRows, SocialRequirementRow{
StatDay: day, StatDay: day,
Label: day, Label: day,
Metrics: socialSectionMetrics(section, agg, retention), Metrics: metrics,
}) })
} }
sectionOut := SocialRequirementSection{ sectionOut := SocialRequirementSection{
@ -300,7 +322,7 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe
Columns: socialRequirementColumns(section), Columns: socialRequirementColumns(section),
MetricDefinitions: socialRequirementMetricDefinitions(section), MetricDefinitions: socialRequirementMetricDefinitions(section),
Total: SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialSectionMetrics(section, totalAgg, totalRetention)}, Total: SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialSectionMetrics(section, totalAgg, totalRetention)},
Average: socialAverageRow(sectionRows), Average: socialAverageRow(averageRows),
DailySeries: sectionRows, DailySeries: sectionRows,
} }
if section == SocialSectionGame { if section == SocialSectionGame {
@ -410,6 +432,46 @@ func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ
return canonicalizeSocialUserDays(out, identityByDeviceDay, countryID, regionID, regionIDs), nil return canonicalizeSocialUserDays(out, identityByDeviceDay, countryID, regionID, regionIDs), nil
} }
// querySocialRetentionUserDays keeps the full identity population until after
// d:/u: canonicalization. Country/region and role/payer/new-user filters cannot
// be pushed into SQL because the authoritative dimensions may live only on the
// logged-in u: row while the anonymous d: row carries part of the activity.
func (r *Repository) querySocialRetentionUserDays(ctx context.Context, app, statTZ, startDay, endDay string) ([]socialRequirementUserDay, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT `+socialRetentionUserDayProjection+`
FROM stat_social_user_day
WHERE app_code = ? AND stat_tz = ? AND stat_day BETWEEN ? AND ?
ORDER BY stat_day, subject_key
`, appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, endDay)
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.DeviceID,
&item.CountryID, &item.RegionID, &item.UserRole, &item.Registered, &item.Active,
&item.RoomJoinOK, &item.MicUp, &item.Gift, &item.Recharge, &item.GameBet,
); err != nil {
return nil, err
}
item.DirectUser = item.UserID > 0
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
identityByDeviceDay, err := r.querySocialIdentityDays(ctx, app, statTZ, startDay, endDay)
if err != nil {
return nil, err
}
return canonicalizeSocialUserDays(out, identityByDeviceDay, 0, 0, nil), nil
}
type socialIdentityDeviceDayKey struct { type socialIdentityDeviceDayKey struct {
StatDay string StatDay string
DeviceID string DeviceID string
@ -869,8 +931,9 @@ func (a *socialRequirementAggregate) add(other socialRequirementAggregate) {
a.OutputCoin += other.OutputCoin 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 { func socialRetentionMetrics(day string, observableDay string, rows []socialRequirementUserDay, bySubjectDay map[string]map[string]socialRequirementUserDay, section string, role string, payerType string, newUserType string) map[string]retentionMetric {
out := map[string]retentionMetric{} out := map[string]retentionMetric{}
d1Observable := addStatDays(day, 1) <= observableDay
for _, row := range rows { for _, row := range rows {
if !socialRowMatchesSection(row, section, role, payerType, newUserType) { if !socialRowMatchesSection(row, section, role, payerType, newUserType) {
continue continue
@ -878,34 +941,36 @@ func socialRetentionMetrics(day string, rows []socialRequirementUserDay, bySubje
// 文档口径:用户次留/3/7/15/30日留以「当日全体活跃用户」为基数次日继续活跃算留存 // 文档口径:用户次留/3/7/15/30日留以「当日全体活跃用户」为基数次日继续活跃算留存
// 新用户(注册 cohort留存只保留在 P0 的分身份次留里。 // 新用户(注册 cohort留存只保留在 P0 的分身份次留里。
for _, offset := range []int{1, 3, 7, 15, 30} { for _, offset := range []int{1, 3, 7, 15, 30} {
if row.Active > 0 { // D+N 尚未到达时,缺少目标日事实表示“不可观察”,不能当成 0 人回访。
// 不创建 metric key 可让日行显示空值,并让汇总/平均自然排除未成熟 cohort。
if row.Active > 0 && addStatDays(day, offset) <= observableDay {
addSocialRetention(out, "active_d"+intLabel(offset)+"_active", row, bySubjectDay, offset, func(target socialRequirementUserDay) bool { return target.Active > 0 }) addSocialRetention(out, "active_d"+intLabel(offset)+"_active", row, bySubjectDay, offset, func(target socialRequirementUserDay) bool { return target.Active > 0 })
} }
} }
if row.RoomJoinOK > 0 { if d1Observable && 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_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 }) addSocialRetention(out, "after_room_d1_room", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.RoomJoinOK > 0 })
} }
if row.MicUp > 0 { if d1Observable && 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_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 }) addSocialRetention(out, "after_mic_d1_mic", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.MicUp > 0 })
} }
if row.Gift > 0 { if d1Observable && 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_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 }) addSocialRetention(out, "after_gift_d1_gift", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Gift > 0 })
} }
if row.GameBet > 0 { if d1Observable && 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_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 }) addSocialRetention(out, "after_game_d1_game", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.GameBet > 0 })
} }
if row.Recharge > 0 { if d1Observable && 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_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 }) 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" { if d1Observable && row.Registered > 0 && normalizeSocialUserRole(row.UserRole) == "host" {
addSocialRetention(out, "new_host_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) 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" { if d1Observable && row.Registered > 0 && normalizeSocialUserRole(row.UserRole) != "host" {
addSocialRetention(out, "new_user_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 }) addSocialRetention(out, "new_user_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
} }
} }
@ -930,6 +995,26 @@ func mergeRetentionMetrics(total map[string]retentionMetric, next map[string]ret
} }
} }
// socialRetentionDailyMetrics adds the cohort size independently from the
// observation result. This lets an immature day expose its canonical DAU base
// while users/rate remain absent; aggregate rows keep using only mature metrics.
func socialRetentionDailyMetrics(mature map[string]any, rows []socialRequirementUserDay, role, payerType, newUserType string) map[string]any {
out := make(map[string]any, len(mature)+5)
for key, value := range mature {
out[key] = value
}
var base int64
for _, row := range rows {
if row.Active > 0 && socialRowMatchesSection(row, SocialSectionRetention, role, payerType, newUserType) {
base++
}
}
for _, offset := range []int{1, 3, 7, 15, 30} {
out["active_d"+intLabel(offset)+"_active_base_users"] = base
}
return out
}
func socialSectionMetrics(section string, agg socialRequirementAggregate, retention map[string]retentionMetric) map[string]any { func socialSectionMetrics(section string, agg socialRequirementAggregate, retention map[string]retentionMetric) map[string]any {
switch section { switch section {
case SocialSectionRevenue: case SocialSectionRevenue:
@ -1023,7 +1108,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
"probability_game_bet_arppu_coin": div(agg.ProbabilityBetCoin, agg.ProbabilityBet), "probability_game_bet_arppu_coin": div(agg.ProbabilityBetCoin, agg.ProbabilityBet),
} }
default: default:
return map[string]any{ out := map[string]any{
"app_download_users": agg.AppFirstOpen, "app_download_users": agg.AppFirstOpen,
"registered_users": agg.Registered, "registered_users": agg.Registered,
"new_host_users": agg.NewHostUsers, "new_host_users": agg.NewHostUsers,
@ -1049,9 +1134,16 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent
"follow_user_users": agg.RegisteredFollowUser, "follow_user_users": agg.RegisteredFollowUser,
"follow_room_users": agg.RegisteredFollowRoom, "follow_room_users": agg.RegisteredFollowRoom,
"chat_users": agg.RegisteredChat, "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"),
} }
// 未成熟的 D1 cohort 不会产生 retention key此时省略字段让前端显示“--”,
// 同时避免总计和日均把尚未发生的观察日按 0% 计算。
if _, ok := retention["new_host_d1_active"]; ok {
out["new_host_d1_retention_rate"] = retentionRate(retention, "new_host_d1_active")
}
if _, ok := retention["new_user_d1_active"]; ok {
out["new_user_d1_retention_rate"] = retentionRate(retention, "new_user_d1_active")
}
return out
} }
} }
@ -1246,7 +1338,7 @@ func socialRequirementColumns(section string) []SocialRequirementColumn {
return []SocialRequirementColumn{ return []SocialRequirementColumn{
{Key: "active_d1_active_base_users", Label: "活跃用户基数", Type: "count", Tooltip: "当日进入 app 的全体活跃用户数(不限新用户)"}, {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_users", Label: "次留人数", Type: "count", Tooltip: "当日活跃用户中,次日继续进入 app 的人数"},
{Key: "active_d1_active_rate", Label: "用户次留", Type: "ratio", Tooltip: "昨日活跃用户中,今日继续活跃的比例"}, {Key: "active_d1_active_rate", Label: "用户次留", Type: "ratio", Tooltip: "当日活跃用户中,次日继续进入 app 的比例"},
{Key: "active_d3_active_rate", Label: "用户3日留", Type: "ratio"}, {Key: "active_d3_active_rate", Label: "用户3日留", Type: "ratio"},
{Key: "active_d7_active_rate", Label: "用户7日留", Type: "ratio"}, {Key: "active_d7_active_rate", Label: "用户7日留", Type: "ratio"},
{Key: "active_d15_active_rate", Label: "用户15日留", Type: "ratio"}, {Key: "active_d15_active_rate", Label: "用户15日留", Type: "ratio"},