修正整体留存成熟度与查询性能
This commit is contained in:
parent
e1844e26a0
commit
f79abb496b
@ -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)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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 ||
|
||||
|
||||
@ -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"},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user