From f79abb496bf979ae7358c8625a85286e4792dbc0 Mon Sep 17 00:00:00 2001 From: zhx Date: Thu, 16 Jul 2026 14:27:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=95=B4=E4=BD=93=E7=95=99?= =?UTF-8?q?=E5=AD=98=E6=88=90=E7=86=9F=E5=BA=A6=E4=B8=8E=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E6=80=A7=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/activity_template_flow_test.go | 14 ++ .../internal/app/query_http.go | 8 + .../internal/storage/mysql/query_test.go | 144 ++++++++++++++++++ .../mysql/social_requirements_query.go | 126 ++++++++++++--- 4 files changed, 275 insertions(+), 17 deletions(-) diff --git a/services/statistics-service/internal/app/activity_template_flow_test.go b/services/statistics-service/internal/app/activity_template_flow_test.go index 42a3c49c..1bc95a56 100644 --- a/services/statistics-service/internal/app/activity_template_flow_test.go +++ b/services/statistics-service/internal/app/activity_template_flow_test.go @@ -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§ion=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) { ctx := context.Background() _, file, _, _ := runtime.Caller(0) diff --git a/services/statistics-service/internal/app/query_http.go b/services/statistics-service/internal/app/query_http.go index 4e011096..46c00cb9 100644 --- a/services/statistics-service/internal/app/query_http.go +++ b/services/statistics-service/internal/app/query_http.go @@ -21,6 +21,8 @@ type queryHTTPServer struct { listener net.Listener } +const socialRequirementsMaxBaseRange = 31 * 24 * time.Hour + func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPServer, error) { addr = strings.TrimSpace(addr) if addr == "" { @@ -225,6 +227,12 @@ func (s *queryHTTPServer) socialRequirements(w http.ResponseWriter, r *http.Requ if endMS <= startMS { 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{ AppCode: query.Get("app_code"), StatTZ: query.Get("stat_tz"), diff --git a/services/statistics-service/internal/storage/mysql/query_test.go b/services/statistics-service/internal/storage/mysql/query_test.go index 092a6001..0ce9bd09 100644 --- a/services/statistics-service/internal/storage/mysql/query_test.go +++ b/services/statistics-service/internal/storage/mysql/query_test.go @@ -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) { cube := canonicalOverviewActiveCube{ 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 { 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) if socialMetricInt(t, active.Total, "active_users") != 2 || socialMetricInt(t, active.Total, "room_join_success_users") != 1 || diff --git a/services/statistics-service/internal/storage/mysql/social_requirements_query.go b/services/statistics-service/internal/storage/mysql/social_requirements_query.go index 4dbb5307..f7ba4451 100644 --- a/services/statistics-service/internal/storage/mysql/social_requirements_query.go +++ b/services/statistics-service/internal/storage/mysql/social_requirements_query.go @@ -221,6 +221,12 @@ type retentionMetric struct { 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) { app := appcode.Normalize(query.AppCode) if app == "" { @@ -237,12 +243,20 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe } startDay := statDayIn(startMS, statTZ) endDay := statDayIn(endMS-1, statTZ) + observableDay := statDayIn(time.Now().UnixMilli(), statTZ) days := socialDayRange(startDay, endDay) sections := socialRequestedSections(query.Section) lookaheadDays := socialRetentionLookaheadDays(sections) // 只有 P0 次留或 P2 留存需要读取查询结束日之后的观察行;单独查看活跃、 // 营收、送礼、游戏时不再固定多扫 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 { return SocialRequirements{}, err } @@ -273,6 +287,7 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe } for _, section := range sections { sectionRows := make([]SocialRequirementRow, 0, len(days)) + averageRows := make([]SocialRequirementRow, 0, len(days)) totalAgg := socialRequirementAggregate{} totalRetention := map[string]retentionMetric{} for _, day := range days { @@ -280,13 +295,20 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe totalAgg.add(agg) retention := map[string]retentionMetric{} 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) + 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{ StatDay: day, Label: day, - Metrics: socialSectionMetrics(section, agg, retention), + Metrics: metrics, }) } sectionOut := SocialRequirementSection{ @@ -300,7 +322,7 @@ func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRe Columns: socialRequirementColumns(section), MetricDefinitions: socialRequirementMetricDefinitions(section), Total: SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialSectionMetrics(section, totalAgg, totalRetention)}, - Average: socialAverageRow(sectionRows), + Average: socialAverageRow(averageRows), DailySeries: sectionRows, } 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 } +// 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 { StatDay string DeviceID string @@ -869,8 +931,9 @@ func (a *socialRequirementAggregate) add(other socialRequirementAggregate) { 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{} + d1Observable := addStatDays(day, 1) <= observableDay for _, row := range rows { if !socialRowMatchesSection(row, section, role, payerType, newUserType) { continue @@ -878,34 +941,36 @@ func socialRetentionMetrics(day string, rows []socialRequirementUserDay, bySubje // 文档口径:用户次留/3/7/15/30日留以「当日全体活跃用户」为基数,次日继续活跃算留存; // 新用户(注册 cohort)留存只保留在 P0 的分身份次留里。 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 }) } } - 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_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_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_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_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_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 }) } - 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 }) } } @@ -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 { switch section { case SocialSectionRevenue: @@ -1023,7 +1108,7 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent "probability_game_bet_arppu_coin": div(agg.ProbabilityBetCoin, agg.ProbabilityBet), } default: - return map[string]any{ + out := map[string]any{ "app_download_users": agg.AppFirstOpen, "registered_users": agg.Registered, "new_host_users": agg.NewHostUsers, @@ -1049,9 +1134,16 @@ func socialSectionMetrics(section string, agg socialRequirementAggregate, retent "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"), } + // 未成熟的 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{ {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_d1_active_rate", Label: "用户次留", Type: "ratio", Tooltip: "当日活跃用户中,次日继续进入 app 的比例"}, {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"},