feat: add social BI requirements stats

This commit is contained in:
zhx 2026-07-09 16:17:00 +08:00
parent ce9a8c2d22
commit dd20dbcd4d
11 changed files with 2034 additions and 23 deletions

View File

@ -54,6 +54,19 @@ type AppTrackingFunnelQuery struct {
CountryID int64
}
type SocialRequirementsQuery struct {
AppCode string
StatTZ string
StartMS int64
EndMS int64
RegionID int64
RegionIDs []int64
CountryID int64
Section string
UserRole string
PayerType string
}
type PlatformGrantStatisticsQuery struct {
AppCode string
StatTZ string
@ -208,6 +221,47 @@ func (s *DashboardService) AppTrackingFunnel(ctx context.Context, query AppTrack
return s.statisticsGET(ctx, "/internal/v1/statistics/app/funnel", values)
}
func (s *DashboardService) SocialRequirements(ctx context.Context, query SocialRequirementsQuery) (map[string]any, error) {
values := url.Values{}
values.Set("app_code", query.AppCode)
if statTZ := strings.TrimSpace(query.StatTZ); statTZ != "" {
values.Set("stat_tz", statTZ)
}
if query.StartMS > 0 {
values.Set("start_ms", strconv.FormatInt(query.StartMS, 10))
}
if query.EndMS > 0 {
values.Set("end_ms", strconv.FormatInt(query.EndMS, 10))
}
if query.RegionID > 0 {
values.Set("region_id", strconv.FormatInt(query.RegionID, 10))
} else if len(query.RegionIDs) > 0 {
parts := make([]string, 0, len(query.RegionIDs))
for _, regionID := range query.RegionIDs {
if regionID > 0 {
parts = append(parts, strconv.FormatInt(regionID, 10))
}
}
if len(parts) > 0 {
values.Set("region_ids", strings.Join(parts, ","))
}
}
if query.CountryID > 0 {
values.Set("country_id", strconv.FormatInt(query.CountryID, 10))
}
if section := strings.TrimSpace(query.Section); section != "" {
values.Set("section", section)
}
if userRole := strings.TrimSpace(query.UserRole); userRole != "" {
values.Set("user_role", userRole)
}
if payerType := strings.TrimSpace(query.PayerType); payerType != "" {
values.Set("payer_type", payerType)
}
// Social BI 数据需求只读 statistics-service 聚合投影admin 不直接拼 owner service 明细或跨库查询。
return s.statisticsGET(ctx, "/internal/v1/statistics/social/requirements", values)
}
func (s *DashboardService) PlatformGrantUsers(ctx context.Context, query PlatformGrantStatisticsQuery) (map[string]any, error) {
page, err := s.statisticsGET(ctx, "/internal/v1/statistics/platform-grants/users", platformGrantValues(query, false))
if err != nil {

View File

@ -72,6 +72,29 @@ func (h *Handler) Funnel(c *gin.Context) {
response.OK(c, result)
}
func (h *Handler) Requirements(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
return
}
result, err := h.service.Requirements(c.Request.Context(), access, RequirementsQuery{
StatTZ: c.Query("stat_tz"),
StartMS: queryInt64(c, "start_ms"),
EndMS: queryInt64(c, "end_ms"),
AppCodes: splitCSV(c.Query("app_codes")),
RegionID: queryInt64(c, "region_id"),
RegionIDs: queryInt64CSV(c.Query("region_ids")),
Section: c.Query("section"),
UserRole: c.Query("user_role"),
PayerType: c.Query("payer_type"),
})
if err != nil {
response.ServerError(c, "获取数据需求指标失败")
return
}
response.OK(c, result)
}
func (h *Handler) Kpi(c *gin.Context) {
access, ok := h.moneyAccess(c)
if !ok {
@ -131,3 +154,15 @@ func splitCSV(value string) []string {
}
return out
}
func queryInt64CSV(value string) []int64 {
parts := strings.Split(value, ",")
out := make([]int64, 0, len(parts))
for _, part := range parts {
parsed, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err == nil && parsed > 0 {
out = append(out, parsed)
}
}
return out
}

View File

@ -10,5 +10,6 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
protected.GET("/admin/databi/social/master", middleware.RequirePermission("overview:view"), h.Master)
protected.GET("/admin/databi/social/overview", middleware.RequirePermission("overview:view"), h.Overview)
protected.GET("/admin/databi/social/funnel", middleware.RequirePermission("overview:view"), h.Funnel)
protected.GET("/admin/databi/social/requirements", middleware.RequirePermission("overview:view"), h.Requirements)
protected.GET("/admin/databi/social/kpi", middleware.RequirePermission("overview:view"), h.Kpi)
}

View File

@ -297,6 +297,34 @@ type FunnelResult struct {
Access AccessInfo `json:"access"`
}
type RequirementsQuery struct {
StatTZ string
StartMS int64
EndMS int64
AppCodes []string
RegionID int64
RegionIDs []int64
Section string
UserRole string
PayerType string
}
type AppRequirements struct {
AppCode string `json:"app_code"`
AppName string `json:"app_name"`
Kind string `json:"kind"`
Restricted bool `json:"restricted"`
AllowedRegionIDs []int64 `json:"allowed_region_ids,omitempty"`
Sections []map[string]any `json:"sections"`
UpdatedAtMS int64 `json:"updated_at_ms"`
Error string `json:"error,omitempty"`
}
type RequirementsResult struct {
Apps []AppRequirements `json:"apps"`
Access AccessInfo `json:"access"`
}
// Overview 服务端并发聚合各 App 的统计总览hyapp App 走 statistics-service
// legacy App 走 dashboard 外部源;国家行按区域目录卷积出大区行,并按用户数据范围裁剪。
func (s *Service) Overview(ctx context.Context, access repository.MoneyAccess, query OverviewQuery) (*OverviewResult, error) {
@ -360,6 +388,103 @@ func (s *Service) Funnel(ctx context.Context, access repository.MoneyAccess, que
}, nil
}
func (s *Service) Requirements(ctx context.Context, access repository.MoneyAccess, query RequirementsQuery) (*RequirementsResult, error) {
apps, err := s.listAllowedApps(ctx, query.AppCodes, access)
if err != nil {
return nil, err
}
catalog, err := s.regionCatalog(ctx, apps)
if err != nil {
return nil, err
}
results := make([]AppRequirements, len(apps))
var wg sync.WaitGroup
semaphore := make(chan struct{}, overviewConcurrency)
for index, app := range apps {
wg.Add(1)
go func(index int, app AppInfo) {
defer wg.Done()
semaphore <- struct{}{}
defer func() { <-semaphore }()
results[index] = s.appRequirements(ctx, app, catalog[app.AppCode], access, query)
}(index, app)
}
wg.Wait()
return &RequirementsResult{
Apps: results,
Access: AccessInfo{All: access.All, Scopes: scopeInfos(access.Scopes)},
}, nil
}
func (s *Service) appRequirements(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query RequirementsQuery) AppRequirements {
out := AppRequirements{
AppCode: app.AppCode,
AppName: app.AppName,
Kind: app.Kind,
Sections: []map[string]any{},
}
if app.Kind != appKindHyapp {
out.Error = "该 App 暂未接入数据需求宽表"
return out
}
allowAll, allowedIDs := allowedRegions(access, app.AppCode)
allowedIDs = normalizeRegionIDs(regions, allowedIDs)
if !allowAll && len(allowedIDs) == 0 {
out.Error = "没有该 App 的数据权限"
return out
}
requestRegionIDs := normalizeRegionIDs(regions, query.RegionIDs)
if len(query.RegionIDs) > 0 && len(requestRegionIDs) == 0 {
out.Error = "该 App 未配置所选区域"
return out
}
if query.RegionID > 0 && !access.Allows(app.AppCode, query.RegionID) {
out.Error = "没有该区域的数据权限"
return out
}
for _, regionID := range requestRegionIDs {
if !access.Allows(app.AppCode, regionID) {
out.Error = "没有该区域的数据权限"
return out
}
}
if query.RegionID > 0 && !regionInCatalog(regions, query.RegionID) {
out.Error = "该 App 未配置此区域"
return out
}
restricted := !allowAll && query.RegionID == 0 && len(requestRegionIDs) == 0
regionIDs := []int64(nil)
if restricted {
// 用户没有全局范围时admin 只把允许的大区 ID 传给 statistics-service查询层仍只读聚合投影不回扫业务库补权限。
out.Restricted = true
out.AllowedRegionIDs = allowedIDs
regionIDs = allowedIDs
} else if len(requestRegionIDs) > 0 {
// 顶栏多选区域时保留多区域筛选;单区域仍走 region_id 以兼容旧统计入口。
regionIDs = requestRegionIDs
}
requirements, err := s.dashboards.SocialRequirements(ctx, dashboard.SocialRequirementsQuery{
AppCode: app.AppCode,
StatTZ: query.StatTZ,
StartMS: query.StartMS,
EndMS: query.EndMS,
RegionID: query.RegionID,
RegionIDs: regionIDs,
Section: query.Section,
UserRole: query.UserRole,
PayerType: query.PayerType,
})
if err != nil {
out.Error = fmt.Sprintf("获取数据需求指标失败: %v", err)
return out
}
out.Sections = anyRowSlice(requirements["sections"])
out.UpdatedAtMS = time.Now().UnixMilli()
return out
}
func (s *Service) appFunnel(ctx context.Context, app AppInfo, regions []RegionInfo, access repository.MoneyAccess, query FunnelQuery) AppFunnel {
out := AppFunnel{
AppCode: app.AppCode,

View File

@ -139,6 +139,67 @@ CREATE TABLE IF NOT EXISTS stat_user_day_mic (
KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户每日麦上时长去重表';
CREATE TABLE IF NOT EXISTS stat_social_user_day (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区UTC 或 Asia/Shanghai',
stat_day DATE NOT NULL COMMENT '统计日,随 stat_tz 切换',
subject_key VARCHAR(160) NOT NULL COMMENT '用户或设备维度去重键u:<user_id> 或 d:<device_id>',
user_id BIGINT NOT NULL DEFAULT 0 COMMENT '登录用户 ID匿名为 0',
device_id VARCHAR(128) NOT NULL DEFAULT '' COMMENT '匿名设备 ID',
country_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时国家维度',
region_id BIGINT NOT NULL DEFAULT 0 COMMENT '事件发生时区域维度',
user_role VARCHAR(16) NOT NULL DEFAULT 'user' COMMENT '当日用户身份快照user 或 host',
app_first_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '首次打开 App 去重标记',
registered_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '注册成功去重标记',
active_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当日活跃去重标记',
room_join_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '进房成功去重标记',
room_join_fail TINYINT(1) NOT NULL DEFAULT 0 COMMENT '进房失败去重标记',
mic_up_success TINYINT(1) NOT NULL DEFAULT 0 COMMENT '上麦成功去重标记',
gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '送礼成功去重标记',
normal_gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '普通礼物送礼去重标记',
lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '幸运礼物送礼去重标记',
super_lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '超级幸运礼物送礼去重标记',
recharge_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '当日充值用户去重标记',
first_recharge_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '首充用户去重标记',
repeat_recharge_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '复购用户去重标记',
game_bet_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '游戏押注用户去重标记',
self_game_bet_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '自研游戏押注用户去重标记',
probability_game_bet_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '概率游戏押注用户去重标记',
chat_sent TINYINT(1) NOT NULL DEFAULT 0 COMMENT '公屏聊天去重标记',
follow_user TINYINT(1) NOT NULL DEFAULT 0 COMMENT '关注用户去重标记',
follow_room TINYINT(1) NOT NULL DEFAULT 0 COMMENT '关注房间去重标记',
gift_panel_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '礼物面板打开去重标记',
probability_game_panel_open TINYINT(1) NOT NULL DEFAULT 0 COMMENT '概率游戏面板打开去重标记',
app_stay_ms BIGINT NOT NULL DEFAULT 0 COMMENT 'App 停留时长毫秒',
room_stay_ms BIGINT NOT NULL DEFAULT 0 COMMENT '房间停留时长毫秒',
mic_stay_ms BIGINT NOT NULL DEFAULT 0 COMMENT '麦上停留时长毫秒',
recharge_count BIGINT NOT NULL DEFAULT 0 COMMENT '充值次数',
recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '总充值 USD 最小单位',
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT 'Google 充值 USD 最小单位',
third_party_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '三方充值 USD 最小单位',
coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '币商充值 USD 最小单位',
first_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '首充 USD 最小单位',
repeat_recharge_usd_minor BIGINT NOT NULL DEFAULT 0 COMMENT '复购 USD 最小单位',
normal_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '普通礼物金币流水',
lucky_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物金币流水',
super_lucky_gift_coin BIGINT NOT NULL DEFAULT 0 COMMENT '超级幸运礼物金币流水',
lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '幸运礼物返奖金币',
super_lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0 COMMENT '超级幸运礼物返奖金币',
game_bet_coin BIGINT NOT NULL DEFAULT 0 COMMENT '游戏押注金币',
game_bet_count BIGINT NOT NULL DEFAULT 0 COMMENT '游戏押注次数',
self_game_bet_coin BIGINT NOT NULL DEFAULT 0 COMMENT '自研游戏押注金币',
self_game_bet_count BIGINT NOT NULL DEFAULT 0 COMMENT '自研游戏押注次数',
probability_game_bet_coin BIGINT NOT NULL DEFAULT 0 COMMENT '概率游戏押注金币',
probability_game_bet_count BIGINT NOT NULL DEFAULT 0 COMMENT '概率游戏押注次数',
other_consume_coin BIGINT NOT NULL DEFAULT 0 COMMENT '其他消费金币',
output_coin BIGINT NOT NULL DEFAULT 0 COMMENT '产出金币',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, stat_tz, stat_day, subject_key),
KEY idx_stat_social_user_day_user (app_code, stat_tz, stat_day, user_id),
KEY idx_stat_social_user_day_region (app_code, stat_tz, stat_day, region_id),
KEY idx_stat_social_user_day_filter (app_code, stat_tz, stat_day, user_role, recharge_user)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Social BI P0-P5 用户日级读模型';
CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
app_code VARCHAR(32) NOT NULL,
stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC' COMMENT '统计时区UTC 或 Asia/Shanghai',

View File

@ -284,6 +284,7 @@ func userRegisteredEvent(body []byte) (mysqlstorage.UserRegisteredEvent, bool, e
AppCode: message.AppCode,
EventID: message.EventID,
UserID: message.AggregateID,
Role: firstNonEmpty(mysqlstorage.String(payload, "role"), mysqlstorage.String(payload, "user_role")),
CountryID: firstNonZeroInt64(mysqlstorage.Int64(payload, "country_id"), mysqlstorage.Int64(payload, "target_country_id")),
RegionID: mysqlstorage.Int64(payload, "region_id"),
OccurredAtMS: message.OccurredAtMS,
@ -601,6 +602,7 @@ func roomJoinActiveEvent(body []byte) (mysqlstorage.UserActiveEvent, bool, error
EventID: envelope.GetEventId(),
EventType: envelope.GetEventType(),
UserID: joined.GetUserId(),
Role: joined.GetRole(),
CountryID: joined.GetCountryId(),
RegionID: firstNonZeroInt64(joined.GetRegionId(), joined.GetVisibleRegionId()),
OccurredAtMS: envelope.GetOccurredAtMs(),

View File

@ -36,6 +36,7 @@ func newQueryHTTPServer(addr string, repo *mysqlstorage.Repository) (*queryHTTPS
mux.HandleFunc("/internal/v1/statistics/platform-grants/records", s.platformGrantRecords)
mux.HandleFunc("/internal/v1/statistics/app/events", s.appEvents)
mux.HandleFunc("/internal/v1/statistics/app/funnel", s.appFunnel)
mux.HandleFunc("/internal/v1/statistics/social/requirements", s.socialRequirements)
mux.HandleFunc("/internal/v1/statistics/self-game/events", s.selfGameEvents)
mux.HandleFunc("/internal/v1/statistics/self-games/overview", s.selfGameOverview)
s.server = &http.Server{Handler: mux, ReadHeaderTimeout: 2 * time.Second}
@ -171,6 +172,36 @@ func (s *queryHTTPServer) appFunnel(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, funnel)
}
func (s *queryHTTPServer) socialRequirements(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query()
startMS := parseInt64(query.Get("start_ms"))
endMS := parseInt64(query.Get("end_ms"))
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()
}
requirements, err := s.repo.QuerySocialRequirements(r.Context(), mysqlstorage.SocialRequirementsQuery{
AppCode: query.Get("app_code"),
StatTZ: query.Get("stat_tz"),
StartMS: startMS,
EndMS: endMS,
CountryID: parseInt64(query.Get("country_id")),
RegionID: parseInt64(query.Get("region_id")),
RegionIDs: parseInt64CSV(query.Get("region_ids")),
Section: query.Get("section"),
UserRole: query.Get("user_role"),
PayerType: query.Get("payer_type"),
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, requirements)
}
type selfGameH5EventRequest struct {
AppCode string `json:"app_code"`
EventID string `json:"event_id"`

View File

@ -258,6 +258,195 @@ func TestQueryAppTrackingFunnelAggregatesStepsAndD1Cohorts(t *testing.T) {
}
}
func TestQuerySocialRequirementsAggregatesP0ToP5(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)
statsSchema := mysqlschema.New(t, mysqlschema.Config{
InitDBPath: mysqlschema.InitDBPath(t, file, "..", "..", "..", "deploy", "mysql", "initdb", "001_statistics_service.sql"),
DatabasePrefix: "hy_stats_social_requirements_test",
})
repository, err := Open(ctx, statsSchema.DSN)
if err != nil {
t.Fatalf("open repository: %v", err)
}
t.Cleanup(func() { _ = repository.Close() })
start := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
user1, user2 := int64(1001), int64(1002)
for _, event := range []UserRegisteredEvent{
{AppCode: "lalu", EventID: "social:user:1001", UserID: user1, Role: "host", CountryID: 86, RegionID: 210, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:user:1002", UserID: user2, Role: "user", CountryID: 86, RegionID: 210, OccurredAtMS: start.Add(time.Minute).UnixMilli()},
} {
if err := repository.ConsumeUserRegistered(ctx, event); err != nil {
t.Fatalf("consume registration %d: %v", event.UserID, err)
}
}
for _, event := range []AppTrackingEvent{
{AppCode: "lalu", EventID: "social:first-open:1001", EventName: "app_first_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:first-open:1002", EventName: "app_first_open", UserID: user2, DeviceID: "dev-1002", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.UnixMilli()},
{AppCode: "lalu", EventID: "social:app-session:1001", EventName: "app_session", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 600_000, Success: true, OccurredAtMS: start.Add(2 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:room-stay:1001", EventName: "room_stay", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, DurationMS: 300_000, Success: true, OccurredAtMS: start.Add(3 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:gift-panel:1001", EventName: "gift_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(4 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:prob-panel:1001", EventName: "probability_game_panel_open", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(5 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:chat:1001", EventName: "send_message", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(6 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:follow-user:1001", EventName: "follow_host", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(7 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:follow-room:1001", EventName: "follow_room", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(8 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:d1-active:1001", EventName: "home_view", UserID: user1, DeviceID: "dev-1001", CountryID: 86, RegionID: 210, Success: true, OccurredAtMS: start.Add(24*time.Hour + time.Minute).UnixMilli()},
} {
if _, err := repository.ConsumeAppTrackingEvents(appcode.WithContext(ctx, "lalu"), []AppTrackingEvent{event}); err != nil {
t.Fatalf("consume app event %s: %v", event.EventName, err)
}
}
if err := repository.ConsumeUserActive(ctx, UserActiveEvent{AppCode: "lalu", EventID: "social:room-join:1001", EventType: "RoomUserJoined", UserID: user1, Role: "host", CountryID: 86, RegionID: 210, OccurredAtMS: start.Add(9 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume room join: %v", err)
}
if err := repository.ConsumeUserMicDailyStatsUpdated(ctx, UserMicDailyStatsUpdatedEvent{AppCode: "lalu", EventID: "social:mic:1001", UserID: user1, StatDate: "2026-07-01", MicOnlineDeltaMS: 120_000, OccurredAtMS: start.Add(10 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume mic: %v", err)
}
for _, event := range []RechargeEvent{
{AppCode: "lalu", EventID: "social:recharge:first", EventType: "WalletRechargeRecorded", UserID: user1, USDMinor: 500, RechargeSequence: 1, TargetCountryID: 86, TargetRegionID: 210, RechargeType: "google", OccurredAtMS: start.Add(11 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:recharge:repeat", EventType: "WalletRechargeRecorded", UserID: user1, USDMinor: 700, RechargeSequence: 2, TargetCountryID: 86, TargetRegionID: 210, RechargeType: "mifapay", OccurredAtMS: start.Add(12 * time.Minute).UnixMilli()},
} {
if err := repository.ConsumeRecharge(ctx, event); err != nil {
t.Fatalf("consume recharge %s: %v", event.EventID, err)
}
}
for _, event := range []RoomGiftEvent{
{AppCode: "lalu", EventID: "social:gift:normal", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 100, CoinSpent: 100, GiftTypeCode: "normal", OccurredAtMS: start.Add(13 * time.Minute).UnixMilli()},
{AppCode: "lalu", EventID: "social:gift:lucky", SenderUserID: user1, CountryID: 86, VisibleRegionID: 210, GiftValue: 200, CoinSpent: 200, PoolID: "lucky", GiftTypeCode: "lucky", OccurredAtMS: start.Add(14 * time.Minute).UnixMilli()},
} {
if err := repository.ConsumeRoomGift(ctx, event); err != nil {
t.Fatalf("consume gift %s: %v", event.EventID, err)
}
}
if err := repository.ConsumeLuckyGiftReward(ctx, LuckyGiftRewardEvent{AppCode: "lalu", EventID: "social:lucky:payout", UserID: user1, CountryID: 86, RegionID: 210, PoolID: "lucky", Amount: 50, OccurredAtMS: start.Add(15 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume lucky payout: %v", err)
}
if err := repository.ConsumeGameOrder(ctx, GameOrderEvent{AppCode: "lalu", EventID: "social:probability:bet", UserID: user1, CountryID: 86, RegionID: 210, PlatformCode: "vendor", GameID: "slot", OpType: "bet", CoinAmount: 300, OccurredAtMS: start.Add(16 * time.Minute).UnixMilli()}); err != nil {
t.Fatalf("consume probability game: %v", err)
}
if err := repository.ConsumeSelfGameMatch(ctx, SelfGameMatchEvent{
AppCode: "lalu", EventID: "social:self-game:settled", EventType: "SelfGameMatchSettled", GameID: "dice", CountryID: 86, RegionID: 210,
StakeCoin: 400, UserParticipants: 1, UserStakeCoin: 400, Participants: []SelfGameMatchParticipantEvent{{UserID: user1, ParticipantType: "user", StakeCoin: 400, Result: "win"}},
OccurredAtMS: start.Add(17 * time.Minute).UnixMilli(),
}); err != nil {
t.Fatalf("consume self game: %v", err)
}
query := SocialRequirementsQuery{
AppCode: "lalu",
StartMS: start.UnixMilli(),
EndMS: start.Add(24 * time.Hour).UnixMilli(),
RegionIDs: []int64{210},
}
result, err := repository.QuerySocialRequirements(ctx, query)
if err != nil {
t.Fatalf("query social requirements: %v", err)
}
newUsers := socialTestSection(t, result, SocialSectionNewUsers)
if socialMetricInt(t, newUsers.Total, "app_download_users") != 2 ||
socialMetricInt(t, newUsers.Total, "registered_users") != 2 ||
socialMetricInt(t, newUsers.Total, "new_host_users") != 1 ||
socialMetricInt(t, newUsers.Total, "new_normal_users") != 1 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "room_join_success_rate")-0.5) > 0.0001 ||
math.Abs(socialMetricFloat(t, newUsers.Total, "new_host_d1_retention_rate")-1) > 0.0001 {
t.Fatalf("P0 metrics mismatch: %+v", newUsers.Total.Metrics)
}
revenue, err := repository.QuerySocialRequirements(ctx, SocialRequirementsQuery{AppCode: "lalu", StartMS: query.StartMS, EndMS: query.EndMS, Section: SocialSectionRevenue, PayerType: "paid"})
if err != nil {
t.Fatalf("query revenue requirements: %v", err)
}
revenueSection := socialTestSection(t, revenue, SocialSectionRevenue)
if socialMetricInt(t, revenueSection.Total, "recharge_usd_minor") != 1200 ||
socialMetricInt(t, revenueSection.Total, "google_recharge_usd_minor") != 500 ||
socialMetricInt(t, revenueSection.Total, "third_party_recharge_usd_minor") != 700 ||
socialMetricInt(t, revenueSection.Total, "recharge_count") != 2 ||
socialMetricInt(t, revenueSection.Total, "first_recharge_users") != 1 ||
socialMetricInt(t, revenueSection.Total, "repeat_recharge_users") != 1 ||
math.Abs(socialMetricFloat(t, revenueSection.Total, "paid_rate")-1) > 0.0001 {
t.Fatalf("P1 metrics mismatch: %+v", revenueSection.Total.Metrics)
}
retention := socialTestSection(t, result, SocialSectionRetention)
if math.Abs(socialMetricFloat(t, retention.Total, "registered_d1_active_rate")-0.5) > 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)
}
active := socialTestSection(t, result, SocialSectionActive)
if socialMetricInt(t, active.Total, "active_users") != 2 ||
socialMetricInt(t, active.Total, "room_join_success_users") != 1 ||
socialMetricInt(t, active.Total, "mic_up_users") != 1 ||
math.Abs(socialMetricFloat(t, active.Total, "avg_app_stay_min")-5) > 0.0001 {
t.Fatalf("P2 active metrics mismatch: %+v", active.Total.Metrics)
}
gift := socialTestSection(t, result, SocialSectionGift)
if socialMetricInt(t, gift.Total, "normal_gift_coin") != 100 ||
socialMetricInt(t, gift.Total, "lucky_gift_coin") != 200 ||
socialMetricInt(t, gift.Total, "lucky_gift_payout_coin") != 50 ||
math.Abs(socialMetricFloat(t, gift.Total, "gift_panel_conversion_rate")-1) > 0.0001 ||
math.Abs(socialMetricFloat(t, gift.Total, "lucky_gift_rtp")-0.25) > 0.0001 {
t.Fatalf("P4 metrics mismatch: %+v", gift.Total.Metrics)
}
game := socialTestSection(t, result, SocialSectionGame)
if socialMetricInt(t, game.Total, "self_game_bet_coin") != 400 ||
socialMetricInt(t, game.Total, "probability_game_bet_coin") != 300 ||
socialMetricInt(t, game.Total, "probability_game_bet_count") != 1 ||
math.Abs(socialMetricFloat(t, game.Total, "self_game_conversion_rate")-0.5) > 0.0001 ||
math.Abs(socialMetricFloat(t, game.Total, "probability_game_bet_arppu_coin")-300) > 0.0001 {
t.Fatalf("P5 metrics mismatch: %+v", game.Total.Metrics)
}
}
func socialTestSection(t *testing.T, result SocialRequirements, key string) SocialRequirementSection {
t.Helper()
for _, section := range result.Sections {
if section.Key == key {
return section
}
}
t.Fatalf("missing social section %s in %+v", key, result.Sections)
return SocialRequirementSection{}
}
func socialMetricInt(t *testing.T, row SocialRequirementRow, key string) int64 {
t.Helper()
value, ok := row.Metrics[key]
if !ok {
t.Fatalf("missing metric %s in %+v", key, row.Metrics)
}
switch typed := value.(type) {
case int64:
return typed
case int:
return int64(typed)
case float64:
return int64(typed)
default:
t.Fatalf("metric %s has unexpected type %T", key, value)
return 0
}
}
func socialMetricFloat(t *testing.T, row SocialRequirementRow, key string) float64 {
t.Helper()
value, ok := row.Metrics[key]
if !ok {
t.Fatalf("missing metric %s in %+v", key, row.Metrics)
}
switch typed := value.(type) {
case float64:
return typed
case int64:
return float64(typed)
case int:
return float64(typed)
default:
t.Fatalf("metric %s has unexpected type %T", key, value)
return 0
}
}
func TestConsumeAppTrackingEventsRejectsInvalidProperties(t *testing.T) {
ctx := context.Background()
_, file, _, _ := runtime.Caller(0)

View File

@ -20,6 +20,7 @@ const (
SourceRoom = "room"
SourceGame = "game"
SourceH5 = "h5"
SourceFinance = "finance"
)
const (
@ -181,15 +182,42 @@ func (r *Repository) Migrate(ctx context.Context) error {
KEY idx_stat_user_dimension_region (app_code, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_user_day_mic (
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL,
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL, mic_online_ms BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_tz, stat_day, user_id),
KEY idx_stat_user_day_mic_country (app_code, stat_tz, stat_day, country_id),
KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL,
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL, mic_online_ms BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_tz, stat_day, user_id),
KEY idx_stat_user_day_mic_country (app_code, stat_tz, stat_day, country_id),
KEY idx_stat_user_day_mic_region (app_code, stat_tz, stat_day, region_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_social_user_day (
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL,
subject_key VARCHAR(160) NOT NULL, user_id BIGINT NOT NULL DEFAULT 0, device_id VARCHAR(128) NOT NULL DEFAULT '',
country_id BIGINT NOT NULL DEFAULT 0, region_id BIGINT NOT NULL DEFAULT 0, user_role VARCHAR(16) NOT NULL DEFAULT 'user',
app_first_open TINYINT(1) NOT NULL DEFAULT 0, registered_success TINYINT(1) NOT NULL DEFAULT 0,
active_user TINYINT(1) NOT NULL DEFAULT 0, room_join_success TINYINT(1) NOT NULL DEFAULT 0, room_join_fail TINYINT(1) NOT NULL DEFAULT 0,
mic_up_success TINYINT(1) NOT NULL DEFAULT 0, gift_sent TINYINT(1) NOT NULL DEFAULT 0, normal_gift_sent TINYINT(1) NOT NULL DEFAULT 0,
lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0, super_lucky_gift_sent TINYINT(1) NOT NULL DEFAULT 0,
recharge_user TINYINT(1) NOT NULL DEFAULT 0, first_recharge_user TINYINT(1) NOT NULL DEFAULT 0, repeat_recharge_user TINYINT(1) NOT NULL DEFAULT 0,
game_bet_user TINYINT(1) NOT NULL DEFAULT 0, self_game_bet_user TINYINT(1) NOT NULL DEFAULT 0, probability_game_bet_user TINYINT(1) NOT NULL DEFAULT 0,
chat_sent TINYINT(1) NOT NULL DEFAULT 0, follow_user TINYINT(1) NOT NULL DEFAULT 0, follow_room TINYINT(1) NOT NULL DEFAULT 0,
gift_panel_open TINYINT(1) NOT NULL DEFAULT 0, probability_game_panel_open TINYINT(1) NOT NULL DEFAULT 0,
app_stay_ms BIGINT NOT NULL DEFAULT 0, room_stay_ms BIGINT NOT NULL DEFAULT 0, mic_stay_ms BIGINT NOT NULL DEFAULT 0,
recharge_count BIGINT NOT NULL DEFAULT 0, recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
google_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, third_party_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, coin_seller_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
first_recharge_usd_minor BIGINT NOT NULL DEFAULT 0, repeat_recharge_usd_minor BIGINT NOT NULL DEFAULT 0,
normal_gift_coin BIGINT NOT NULL DEFAULT 0, lucky_gift_coin BIGINT NOT NULL DEFAULT 0, super_lucky_gift_coin BIGINT NOT NULL DEFAULT 0,
lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0, super_lucky_gift_payout_coin BIGINT NOT NULL DEFAULT 0,
game_bet_coin BIGINT NOT NULL DEFAULT 0, game_bet_count BIGINT NOT NULL DEFAULT 0,
self_game_bet_coin BIGINT NOT NULL DEFAULT 0, self_game_bet_count BIGINT NOT NULL DEFAULT 0,
probability_game_bet_coin BIGINT NOT NULL DEFAULT 0, probability_game_bet_count BIGINT NOT NULL DEFAULT 0,
other_consume_coin BIGINT NOT NULL DEFAULT 0, output_coin BIGINT NOT NULL DEFAULT 0, updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_tz, stat_day, subject_key),
KEY idx_stat_social_user_day_user (app_code, stat_tz, stat_day, user_id),
KEY idx_stat_social_user_day_region (app_code, stat_tz, stat_day, region_id),
KEY idx_stat_social_user_day_filter (app_code, stat_tz, stat_day, user_role, recharge_user)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci`,
`CREATE TABLE IF NOT EXISTS stat_recharge_day_payers (
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
app_code VARCHAR(32) NOT NULL, stat_tz VARCHAR(64) NOT NULL DEFAULT 'UTC', stat_day DATE NOT NULL, country_id BIGINT NOT NULL DEFAULT 0,
region_id BIGINT NOT NULL DEFAULT 0,
user_id BIGINT NOT NULL, first_paid_at_ms BIGINT NOT NULL,
PRIMARY KEY (app_code, stat_tz, stat_day, user_id), KEY idx_stat_recharge_payer_country (app_code, stat_tz, stat_day, country_id),
@ -329,6 +357,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
"stat_user_day_activity",
"stat_user_registration",
"stat_user_day_mic",
"stat_social_user_day",
"stat_recharge_day_payers",
"stat_lucky_gift_day_payers",
"stat_lucky_gift_pool_day_country",
@ -394,6 +423,7 @@ func (r *Repository) Migrate(ctx context.Context) error {
"stat_user_registration": {"app_code", "stat_tz", "user_id"},
"stat_user_dimension": {"app_code", "user_id"},
"stat_user_day_mic": {"app_code", "stat_tz", "stat_day", "user_id"},
"stat_social_user_day": {"app_code", "stat_tz", "stat_day", "subject_key"},
"stat_recharge_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"},
"stat_lucky_gift_day_payers": {"app_code", "stat_tz", "stat_day", "user_id"},
"stat_platform_grant_coin_events": {"app_code", "stat_tz", "event_id"},
@ -430,6 +460,9 @@ func (r *Repository) Migrate(ctx context.Context) error {
{"stat_user_dimension", "idx_stat_user_dimension_region", []string{"app_code", "region_id"}},
{"stat_user_day_mic", "idx_stat_user_day_mic_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
{"stat_user_day_mic", "idx_stat_user_day_mic_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
{"stat_social_user_day", "idx_stat_social_user_day_user", []string{"app_code", "stat_tz", "stat_day", "user_id"}},
{"stat_social_user_day", "idx_stat_social_user_day_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
{"stat_social_user_day", "idx_stat_social_user_day_filter", []string{"app_code", "stat_tz", "stat_day", "user_role", "recharge_user"}},
{"stat_recharge_day_payers", "idx_stat_recharge_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
{"stat_recharge_day_payers", "idx_stat_recharge_payer_region", []string{"app_code", "stat_tz", "stat_day", "region_id"}},
{"stat_lucky_gift_day_payers", "idx_stat_lucky_payer_country", []string{"app_code", "stat_tz", "stat_day", "country_id"}},
@ -541,6 +574,7 @@ type UserRegisteredEvent struct {
AppCode string
EventID string
UserID int64
Role string
CountryID int64
RegionID int64
OccurredAtMS int64
@ -580,6 +614,19 @@ type CoinSellerStockEvent struct {
OccurredAtMS int64
}
type FinanceCoinSellerRechargeOrderEvent struct {
AppCode string
EventID string
VerifyStatus string
TargetUserID int64
USDMinor int64
CoinAmount int64
CountryID int64
RegionID int64
CreatedAtMS int64
OccurredAtMS int64
}
type RoomGiftEvent struct {
AppCode string
EventID string
@ -659,6 +706,7 @@ type UserActiveEvent struct {
EventID string
EventType string
UserID int64
Role string
CountryID int64
RegionID int64
OccurredAtMS int64
@ -818,6 +866,20 @@ func (r *Repository) ConsumeUserRegistered(ctx context.Context, event UserRegist
if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil {
return err
}
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.UserID,
CountryID: countryID,
RegionID: regionID,
UserRole: event.Role,
UpdatedAt: nowMS,
Registered: 1,
Active: 1,
}); err != nil {
return err
}
}
return nil
})
@ -876,6 +938,75 @@ func (r *Repository) ConsumeRecharge(ctx context.Context, event RechargeEvent) e
`, event.USDMinor, newUserUSD, coinSellerUSD, mifapayUSD, googleUSD, coinSellerTransferCoin, paidUsers, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
return err
}
googleUSD, thirdPartyUSD, coinSellerUSD := int64(0), int64(0), int64(0)
switch strings.ToLower(strings.TrimSpace(event.RechargeType)) {
case "google", "google_play":
googleUSD = event.USDMinor
case "coin_seller_transfer", "coin_seller":
coinSellerUSD = event.USDMinor
}
firstRecharge, repeatRecharge, firstUSD, repeatUSD := int64(0), int64(0), int64(0), int64(0)
if event.RechargeSequence == 1 {
firstRecharge, firstUSD = 1, event.USDMinor
} else if event.RechargeSequence > 1 {
repeatRecharge, repeatUSD = 1, event.USDMinor
}
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.UserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
Recharge: 1,
FirstRecharge: firstRecharge,
RepeatRecharge: repeatRecharge,
RechargeCount: 1,
RechargeUSDMinor: event.USDMinor,
GoogleRechargeUSDMinor: googleUSD,
ThirdPartyRechargeUSDMinor: thirdPartyUSD,
CoinSellerRechargeUSDMinor: coinSellerUSD,
FirstRechargeUSDMinor: firstUSD,
RepeatRechargeUSDMinor: repeatUSD,
}); err != nil {
return err
}
}
return nil
})
}
func (r *Repository) ConsumeFinanceCoinSellerRechargeOrder(ctx context.Context, event FinanceCoinSellerRechargeOrderEvent) error {
return r.withEvent(ctx, SourceFinance, event.EventID, "FinanceCoinSellerRechargeOrderVerified", func(tx *sql.Tx, nowMS int64) error {
if !strings.EqualFold(strings.TrimSpace(event.VerifyStatus), "verified") {
return nil
}
if event.TargetUserID <= 0 || event.USDMinor <= 0 {
return nil
}
occurredAtMS := event.CreatedAtMS
if occurredAtMS <= 0 {
occurredAtMS = event.OccurredAtMS
}
countryID, regionID := normalizeDimension(event.CountryID, event.RegionID)
for _, scope := range statDayScopesFromContext(ctx, occurredAtMS) {
// Social BI 的第三方充值口径绑定 finance 工作台 verified 币商订单:金额和次数进入营收,
// 但不置 recharge_user/first/repeat 标记,避免财务补单把付费率和 ARPPU 分母放大。
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.TargetUserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
RechargeCount: 1,
RechargeUSDMinor: event.USDMinor,
ThirdPartyRechargeUSDMinor: event.USDMinor,
}); err != nil {
return err
}
}
return nil
})
@ -954,13 +1085,34 @@ func (r *Repository) ConsumeRoomGift(ctx context.Context, event RoomGiftEvent) e
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE stat_app_day_country
SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?,
UPDATE stat_app_day_country
SET gift_coin_spent = gift_coin_spent + ?, lucky_gift_turnover = lucky_gift_turnover + ?,
lucky_gift_payers = lucky_gift_payers + ?,
consumed_coin = consumed_coin + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
`, event.CoinSpent, luckyTurnover, luckyPayers, event.CoinSpent, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
`, event.CoinSpent, luckyTurnover, luckyPayers, event.CoinSpent, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
return err
}
normalFlag, luckyFlag, superFlag, normalCoin, luckyCoin, superCoin := socialGiftKind(event)
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.SenderUserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
Active: 1,
Gift: 1,
NormalGift: normalFlag,
LuckyGift: luckyFlag,
SuperGift: superFlag,
NormalGiftCoin: normalCoin,
LuckyGiftCoin: luckyCoin,
SuperGiftCoin: superCoin,
OtherConsumeCoin: 0,
}); err != nil {
return err
}
}
@ -1023,12 +1175,31 @@ func (r *Repository) ConsumeLuckyGiftReward(ctx context.Context, event LuckyGift
}
}
if _, err := tx.ExecContext(ctx, `
UPDATE stat_app_day_country
SET lucky_gift_payout = lucky_gift_payout + ?,
UPDATE stat_app_day_country
SET lucky_gift_payout = lucky_gift_payout + ?,
output_coin = output_coin + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
`, amount, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
`, amount, amount, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
return err
}
luckyPayout, superPayout := amount, int64(0)
if strings.Contains(strings.ToLower(poolID), "super_lucky") {
luckyPayout, superPayout = 0, amount
}
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.UserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
Active: 1,
LuckyGiftPayoutCoin: luckyPayout,
SuperGiftPayoutCoin: superPayout,
OutputCoin: amount,
}); err != nil {
return err
}
}
@ -1157,8 +1328,8 @@ func (r *Repository) ConsumeWalletBalanceChanged(ctx context.Context, event Wall
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE stat_app_day_country
SET coin_total = coin_total + ?,
UPDATE stat_app_day_country
SET coin_total = coin_total + ?,
consumed_coin = consumed_coin + ?,
output_coin = output_coin + ?,
platform_grant_coin = platform_grant_coin + ?,
@ -1167,9 +1338,29 @@ func (r *Repository) ConsumeWalletBalanceChanged(ctx context.Context, event Wall
salary_transfer_coin = salary_transfer_coin + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
`, coinTotal, consumed, outputCoin, platformGrant, manualGrant, salaryUSD, salaryTransferCoin, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
`, coinTotal, consumed, outputCoin, platformGrant, manualGrant, salaryUSD, salaryTransferCoin, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
return err
}
otherConsume := consumed
if bizType == "direct_gift_debit" {
otherConsume = 0
}
if otherConsume > 0 || outputCoin > 0 {
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: dimensionUserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
Active: 1,
OtherConsumeCoin: otherConsume,
OutputCoin: outputCoin,
}); err != nil {
return err
}
}
if platformGrant > 0 {
// resource_grant 类入账也是平台发放金币,写入同一事件投影,确保总计、用户列表和来源明细的统计口径一致。
if err := insertPlatformGrantCoinEvent(ctx, tx, event.AppCode, scope.tz, scope.day, event.EventID, event.EventType, event.UserID, countryID, regionID, platformGrant, event.OccurredAtMS, nowMS); err != nil {
@ -1220,14 +1411,31 @@ func (r *Repository) ConsumeUserMicDailyStatsUpdated(ctx context.Context, event
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE stat_app_day_country
SET mic_online_ms = mic_online_ms + ?,
UPDATE stat_app_day_country
SET mic_online_ms = mic_online_ms + ?,
mic_online_users = GREATEST(mic_online_users + ?, 0),
updated_at_ms = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
`, after-before, userDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
`, after-before, userDelta, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
return err
}
micDelta := after - before
if micDelta > 0 {
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.UserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
Active: 1,
MicUp: 1,
MicStayMS: micDelta,
}); err != nil {
return err
}
}
}
return nil
})
@ -1240,6 +1448,24 @@ func (r *Repository) ConsumeUserActive(ctx context.Context, event UserActiveEven
if err := applyActive(ctx, tx, event.AppCode, scope.tz, scope.day, countryID, regionID, event.UserID, event.OccurredAtMS, nowMS); err != nil {
return err
}
roomJoinOK := int64(0)
if strings.EqualFold(strings.TrimSpace(event.EventType), "RoomUserJoined") {
roomJoinOK = 1
}
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.UserID,
CountryID: countryID,
RegionID: regionID,
UserRole: event.Role,
UpdatedAt: nowMS,
Active: 1,
RoomJoinOK: roomJoinOK,
}); err != nil {
return err
}
}
return nil
})
@ -1287,16 +1513,36 @@ func (r *Repository) ConsumeGameOrder(ctx context.Context, event GameOrderEvent)
return err
}
if _, err := tx.ExecContext(ctx, `
UPDATE stat_app_day_country
SET game_turnover = game_turnover + ?, game_payout = game_payout + ?, game_refund = game_refund + ?,
UPDATE stat_app_day_country
SET game_turnover = game_turnover + ?, game_payout = game_payout + ?, game_refund = game_refund + ?,
game_players = game_players + ?,
consumed_coin = consumed_coin + ?,
output_coin = output_coin + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND country_id = ? AND region_id = ?
`, turnover, payout, refund, playerDelta, turnover, payout, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
`, turnover, payout, refund, playerDelta, turnover, payout, nowMS, appcode.Normalize(event.AppCode), scope.tz, scope.day, countryID, regionID); err != nil {
return err
}
if turnover > 0 {
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.UserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
Active: 1,
GameBet: 1,
ProbabilityBet: 1,
GameBetCoin: turnover,
GameBetCount: 1,
ProbabilityBetCoin: turnover,
ProbabilityBetCount: 1,
}); err != nil {
return err
}
}
}
return nil
})
@ -1395,6 +1641,15 @@ func (r *Repository) ConsumeAppTrackingEvents(ctx context.Context, events []AppT
}
if affected > 0 {
result.Stored++
for _, scope := range statDayScopesFromContext(ctx, normalized.OccurredAtMS) {
delta, ok := socialDeltaForAppTrackingEvent(normalized, scope, nowMS)
if !ok {
continue
}
if err := applySocialUserDayDelta(ctx, tx, delta); err != nil {
return AppTrackingReportResult{}, err
}
}
} else {
result.Duplicated++
}
@ -1486,6 +1741,31 @@ func (r *Repository) ConsumeSelfGameMatch(ctx context.Context, event SelfGameMat
if err := consumeSelfGameSettledParticipant(ctx, tx, event, participant, scope.tz, scope.day, countryID, regionID, gameID, stakeCoin, nowMS); err != nil {
return err
}
if participant.UserID <= 0 || !strings.EqualFold(strings.TrimSpace(participant.ParticipantType), "user") {
continue
}
stake := normalizeID(participant.StakeCoin)
if stake == 0 {
stake = stakeCoin
}
if err := applySocialUserDayDelta(ctx, tx, socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: participant.UserID,
CountryID: countryID,
RegionID: regionID,
UpdatedAt: nowMS,
Active: 1,
GameBet: 1,
SelfGameBet: 1,
GameBetCoin: stake,
GameBetCount: 1,
SelfGameBetCoin: stake,
SelfGameBetCount: 1,
}); err != nil {
return err
}
}
}
if canceled > 0 {

View File

@ -0,0 +1,967 @@
package mysql
import (
"context"
"encoding/json"
"sort"
"strconv"
"strings"
"time"
"hyapp/pkg/appcode"
)
const (
SocialSectionNewUsers = "new_users"
SocialSectionRevenue = "revenue"
SocialSectionRetention = "retention"
SocialSectionActive = "active"
SocialSectionGift = "gift"
SocialSectionGame = "game"
)
type SocialRequirementsQuery struct {
AppCode string
StatTZ string
StartMS int64
EndMS int64
CountryID int64
RegionID int64
RegionIDs []int64
Section string
UserRole string
PayerType string
}
type SocialRequirements struct {
AppCode string `json:"app_code"`
StatTZ string `json:"stat_tz"`
StartMS int64 `json:"start_ms"`
EndMS int64 `json:"end_ms"`
CountryID int64 `json:"country_id"`
RegionID int64 `json:"region_id"`
RegionIDs []int64 `json:"region_ids,omitempty"`
Sections []SocialRequirementSection `json:"sections"`
}
type SocialRequirementSection struct {
Key string `json:"key"`
Label string `json:"label"`
Filters SocialRequirementFilter `json:"filters"`
Columns []SocialRequirementColumn `json:"columns"`
MetricDefinitions []MetricDefinition `json:"metric_definitions"`
Total SocialRequirementRow `json:"total"`
DailySeries []SocialRequirementRow `json:"daily_series"`
}
type SocialRequirementColumn struct {
Key string `json:"key"`
Label string `json:"label"`
Type string `json:"type"`
Tooltip string `json:"tooltip,omitempty"`
}
type SocialRequirementFilter struct {
UserRole string `json:"user_role"`
PayerType string `json:"payer_type"`
}
type SocialRequirementRow struct {
StatDay string `json:"stat_day"`
Label string `json:"label"`
Metrics map[string]any `json:"metrics"`
}
func (row SocialRequirementRow) MarshalJSON() ([]byte, error) {
out := make(map[string]any, len(row.Metrics)+2)
out["stat_day"] = row.StatDay
out["label"] = row.Label
for key, value := range row.Metrics {
out[key] = value
}
return json.Marshal(out)
}
type socialRequirementUserDay struct {
StatDay string
Subject string
UserID int64
RegionID int64
UserRole string
AppFirstOpen int64
Registered int64
Active int64
RoomJoinOK int64
RoomJoinNG int64
MicUp int64
Gift int64
NormalGift int64
LuckyGift int64
SuperGift int64
Recharge int64
FirstRecharge int64
RepeatRecharge int64
GameBet int64
SelfGameBet int64
ProbabilityBet int64
Chat int64
FollowUser int64
FollowRoom int64
GiftPanel int64
ProbabilityPanel int64
AppStayMS int64
RoomStayMS int64
MicStayMS int64
RechargeCount int64
RechargeUSDMinor int64
GoogleRechargeUSDMinor int64
ThirdPartyRechargeUSDMinor int64
CoinSellerRechargeUSDMinor int64
FirstRechargeUSDMinor int64
RepeatRechargeUSDMinor int64
NormalGiftCoin int64
LuckyGiftCoin int64
SuperGiftCoin int64
LuckyGiftPayoutCoin int64
SuperGiftPayoutCoin int64
GameBetCoin int64
GameBetCount int64
SelfGameBetCoin int64
SelfGameBetCount int64
ProbabilityBetCoin int64
ProbabilityBetCount int64
OtherConsumeCoin int64
OutputCoin int64
}
type socialRequirementAggregate struct {
AppFirstOpen int64
Registered int64
NewHostUsers int64
NewNormalUsers int64
Active int64
RoomJoinOK int64
RoomJoinNG int64
RegisteredRoomJoinOK int64
RegisteredRoomJoinNG int64
RegisteredMicUp int64
RegisteredRoomGift int64
RegisteredRecharge int64
RegisteredGame int64
MicUp int64
Gift int64
NormalGift int64
LuckyGift int64
SuperGift int64
Recharge int64
FirstRecharge int64
RepeatRecharge int64
GameBet int64
SelfGameBet int64
ProbabilityBet int64
Chat int64
FollowUser int64
FollowRoom int64
GiftPanel int64
ProbabilityPanel int64
AppStayMS int64
RoomStayMS int64
MicStayMS int64
RechargeCount int64
RechargeUSDMinor int64
GoogleRechargeUSDMinor int64
ThirdPartyRechargeUSDMinor int64
CoinSellerRechargeUSDMinor int64
FirstRechargeUSDMinor int64
RepeatRechargeUSDMinor int64
NormalGiftCoin int64
LuckyGiftCoin int64
SuperGiftCoin int64
LuckyGiftPayoutCoin int64
SuperGiftPayoutCoin int64
GameBetCoin int64
GameBetCount int64
SelfGameBetCoin int64
SelfGameBetCount int64
ProbabilityBetCoin int64
ProbabilityBetCount int64
OtherConsumeCoin int64
OutputCoin int64
}
type retentionMetric struct {
Base int64
Users int64
}
func (r *Repository) QuerySocialRequirements(ctx context.Context, query SocialRequirementsQuery) (SocialRequirements, error) {
app := appcode.Normalize(query.AppCode)
if app == "" {
app = "lalu"
}
statTZ := normalizeStatTZ(query.StatTZ)
startMS, endMS := query.StartMS, query.EndMS
if startMS <= 0 {
now := time.Now().UTC()
startMS = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC).UnixMilli()
}
if endMS <= startMS {
endMS = startMS + 24*time.Hour.Milliseconds()
}
startDay := statDayIn(startMS, statTZ)
endDay := statDayIn(endMS-1, statTZ)
days := socialDayRange(startDay, endDay)
// 留存指标需要读取 cohort 日之后的 D30 行;这些仍是本服务已沉淀的用户日宽表,不回查 owner service。
rows, err := r.querySocialUserDays(ctx, app, statTZ, startDay, addStatDays(endDay, 30), query.CountryID, query.RegionID, query.RegionIDs)
if err != nil {
return SocialRequirements{}, err
}
byDay, bySubjectDay := indexSocialRows(rows)
sections := socialRequestedSections(query.Section)
out := SocialRequirements{
AppCode: app,
StatTZ: statTZ,
StartMS: startMS,
EndMS: endMS,
CountryID: query.CountryID,
RegionID: query.RegionID,
RegionIDs: normalizePositiveIDs(query.RegionIDs),
Sections: make([]SocialRequirementSection, 0, len(sections)),
}
for _, section := range sections {
sectionRows := make([]SocialRequirementRow, 0, len(days))
totalAgg := socialRequirementAggregate{}
totalRetention := map[string]retentionMetric{}
for _, day := range days {
agg := aggregateSocialRows(byDay[day], section, query.UserRole, query.PayerType)
totalAgg.add(agg)
retention := socialRetentionMetrics(day, byDay[day], bySubjectDay, section, query.UserRole, query.PayerType)
mergeRetentionMetrics(totalRetention, retention)
sectionRows = append(sectionRows, SocialRequirementRow{
StatDay: day,
Label: day,
Metrics: socialSectionMetrics(section, agg, retention),
})
}
out.Sections = append(out.Sections, SocialRequirementSection{
Key: section,
Label: socialSectionLabel(section),
Filters: SocialRequirementFilter{UserRole: normalizeSocialRequirementRole(query.UserRole), PayerType: normalizeSocialPayerType(query.PayerType)},
Columns: socialRequirementColumns(section),
MetricDefinitions: socialRequirementMetricDefinitions(section),
Total: SocialRequirementRow{StatDay: "total", Label: "汇总", Metrics: socialSectionMetrics(section, totalAgg, totalRetention)},
DailySeries: sectionRows,
})
}
return out, nil
}
func (r *Repository) querySocialUserDays(ctx context.Context, app string, statTZ string, startDay string, endDay string, countryID int64, regionID int64, regionIDs []int64) ([]socialRequirementUserDay, error) {
args := []any{appcode.Normalize(app), normalizeStatTZ(statTZ), startDay, endDay}
conditions := []string{"app_code = ?", "stat_tz = ?", "stat_day BETWEEN ? AND ?"}
if countryID > 0 {
conditions = append(conditions, "country_id = ?")
args = append(args, countryID)
}
if regionID > 0 {
conditions = append(conditions, "region_id = ?")
args = append(args, regionID)
} else if ids := normalizePositiveIDs(regionIDs); len(ids) > 0 {
placeholders := make([]string, 0, len(ids))
for _, id := range ids {
placeholders = append(placeholders, "?")
args = append(args, id)
}
conditions = append(conditions, "region_id IN ("+strings.Join(placeholders, ",")+")")
}
rows, err := r.db.QueryContext(ctx, `
SELECT DATE_FORMAT(stat_day, '%Y-%m-%d'), subject_key, user_id, region_id, user_role,
app_first_open, registered_success, active_user, room_join_success, room_join_fail,
mic_up_success, gift_sent, normal_gift_sent, lucky_gift_sent, super_lucky_gift_sent,
recharge_user, first_recharge_user, repeat_recharge_user, game_bet_user, self_game_bet_user,
probability_game_bet_user, chat_sent, follow_user, follow_room, gift_panel_open, probability_game_panel_open,
app_stay_ms, room_stay_ms, mic_stay_ms, recharge_count, recharge_usd_minor,
google_recharge_usd_minor, third_party_recharge_usd_minor, coin_seller_recharge_usd_minor,
first_recharge_usd_minor, repeat_recharge_usd_minor, normal_gift_coin, lucky_gift_coin, super_lucky_gift_coin,
lucky_gift_payout_coin, super_lucky_gift_payout_coin, game_bet_coin, game_bet_count,
self_game_bet_coin, self_game_bet_count, probability_game_bet_coin, probability_game_bet_count,
other_consume_coin, output_coin
FROM stat_social_user_day
WHERE `+strings.Join(conditions, " AND ")+`
ORDER BY stat_day, subject_key
`, args...)
if err != nil {
return nil, err
}
defer rows.Close()
out := []socialRequirementUserDay{}
for rows.Next() {
var item socialRequirementUserDay
if err := rows.Scan(&item.StatDay, &item.Subject, &item.UserID, &item.RegionID, &item.UserRole,
&item.AppFirstOpen, &item.Registered, &item.Active, &item.RoomJoinOK, &item.RoomJoinNG,
&item.MicUp, &item.Gift, &item.NormalGift, &item.LuckyGift, &item.SuperGift,
&item.Recharge, &item.FirstRecharge, &item.RepeatRecharge, &item.GameBet, &item.SelfGameBet,
&item.ProbabilityBet, &item.Chat, &item.FollowUser, &item.FollowRoom, &item.GiftPanel, &item.ProbabilityPanel,
&item.AppStayMS, &item.RoomStayMS, &item.MicStayMS, &item.RechargeCount, &item.RechargeUSDMinor,
&item.GoogleRechargeUSDMinor, &item.ThirdPartyRechargeUSDMinor, &item.CoinSellerRechargeUSDMinor,
&item.FirstRechargeUSDMinor, &item.RepeatRechargeUSDMinor, &item.NormalGiftCoin, &item.LuckyGiftCoin, &item.SuperGiftCoin,
&item.LuckyGiftPayoutCoin, &item.SuperGiftPayoutCoin, &item.GameBetCoin, &item.GameBetCount,
&item.SelfGameBetCoin, &item.SelfGameBetCount, &item.ProbabilityBetCoin, &item.ProbabilityBetCount,
&item.OtherConsumeCoin, &item.OutputCoin); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func indexSocialRows(rows []socialRequirementUserDay) (map[string][]socialRequirementUserDay, map[string]map[string]socialRequirementUserDay) {
byDay := map[string][]socialRequirementUserDay{}
bySubjectDay := map[string]map[string]socialRequirementUserDay{}
for _, row := range rows {
byDay[row.StatDay] = append(byDay[row.StatDay], row)
if bySubjectDay[row.Subject] == nil {
bySubjectDay[row.Subject] = map[string]socialRequirementUserDay{}
}
bySubjectDay[row.Subject][row.StatDay] = row
}
return byDay, bySubjectDay
}
func aggregateSocialRows(rows []socialRequirementUserDay, section string, role string, payerType string) socialRequirementAggregate {
var agg socialRequirementAggregate
for _, row := range rows {
if !socialRowMatchesSection(row, section, role, payerType) {
continue
}
agg.AppFirstOpen += flagValue(row.AppFirstOpen)
agg.Registered += flagValue(row.Registered)
if flagValue(row.Registered) > 0 && normalizeSocialUserRole(row.UserRole) == "host" {
agg.NewHostUsers++
} else if flagValue(row.Registered) > 0 {
agg.NewNormalUsers++
}
agg.Active += flagValue(row.Active)
agg.RoomJoinOK += flagValue(row.RoomJoinOK)
agg.RoomJoinNG += flagValue(row.RoomJoinNG)
if row.Registered > 0 && row.RoomJoinOK > 0 {
agg.RegisteredRoomJoinOK++
}
if row.Registered > 0 && row.RoomJoinNG > 0 {
agg.RegisteredRoomJoinNG++
}
if row.Registered > 0 && row.RoomJoinOK > 0 && row.MicUp > 0 {
agg.RegisteredMicUp++
}
if row.Registered > 0 && row.RoomJoinOK > 0 && row.Gift > 0 {
agg.RegisteredRoomGift++
}
if row.Registered > 0 && row.Recharge > 0 {
agg.RegisteredRecharge++
}
if row.Registered > 0 && row.GameBet > 0 {
agg.RegisteredGame++
}
agg.MicUp += flagValue(row.MicUp)
agg.Gift += flagValue(row.Gift)
agg.NormalGift += flagValue(row.NormalGift)
agg.LuckyGift += flagValue(row.LuckyGift)
agg.SuperGift += flagValue(row.SuperGift)
agg.Recharge += flagValue(row.Recharge)
agg.FirstRecharge += flagValue(row.FirstRecharge)
agg.RepeatRecharge += flagValue(row.RepeatRecharge)
agg.GameBet += flagValue(row.GameBet)
agg.SelfGameBet += flagValue(row.SelfGameBet)
agg.ProbabilityBet += flagValue(row.ProbabilityBet)
agg.Chat += flagValue(row.Chat)
agg.FollowUser += flagValue(row.FollowUser)
agg.FollowRoom += flagValue(row.FollowRoom)
agg.GiftPanel += flagValue(row.GiftPanel)
agg.ProbabilityPanel += flagValue(row.ProbabilityPanel)
agg.AppStayMS += row.AppStayMS
agg.RoomStayMS += row.RoomStayMS
agg.MicStayMS += row.MicStayMS
agg.RechargeCount += row.RechargeCount
agg.RechargeUSDMinor += row.RechargeUSDMinor
agg.GoogleRechargeUSDMinor += row.GoogleRechargeUSDMinor
agg.ThirdPartyRechargeUSDMinor += row.ThirdPartyRechargeUSDMinor
agg.CoinSellerRechargeUSDMinor += row.CoinSellerRechargeUSDMinor
agg.FirstRechargeUSDMinor += row.FirstRechargeUSDMinor
agg.RepeatRechargeUSDMinor += row.RepeatRechargeUSDMinor
agg.NormalGiftCoin += row.NormalGiftCoin
agg.LuckyGiftCoin += row.LuckyGiftCoin
agg.SuperGiftCoin += row.SuperGiftCoin
agg.LuckyGiftPayoutCoin += row.LuckyGiftPayoutCoin
agg.SuperGiftPayoutCoin += row.SuperGiftPayoutCoin
agg.GameBetCoin += row.GameBetCoin
agg.GameBetCount += row.GameBetCount
agg.SelfGameBetCoin += row.SelfGameBetCoin
agg.SelfGameBetCount += row.SelfGameBetCount
agg.ProbabilityBetCoin += row.ProbabilityBetCoin
agg.ProbabilityBetCount += row.ProbabilityBetCount
agg.OtherConsumeCoin += row.OtherConsumeCoin
agg.OutputCoin += row.OutputCoin
}
return agg
}
func (a *socialRequirementAggregate) add(other socialRequirementAggregate) {
a.AppFirstOpen += other.AppFirstOpen
a.Registered += other.Registered
a.NewHostUsers += other.NewHostUsers
a.NewNormalUsers += other.NewNormalUsers
a.Active += other.Active
a.RoomJoinOK += other.RoomJoinOK
a.RoomJoinNG += other.RoomJoinNG
a.RegisteredRoomJoinOK += other.RegisteredRoomJoinOK
a.RegisteredRoomJoinNG += other.RegisteredRoomJoinNG
a.RegisteredMicUp += other.RegisteredMicUp
a.RegisteredRoomGift += other.RegisteredRoomGift
a.RegisteredRecharge += other.RegisteredRecharge
a.RegisteredGame += other.RegisteredGame
a.MicUp += other.MicUp
a.Gift += other.Gift
a.NormalGift += other.NormalGift
a.LuckyGift += other.LuckyGift
a.SuperGift += other.SuperGift
a.Recharge += other.Recharge
a.FirstRecharge += other.FirstRecharge
a.RepeatRecharge += other.RepeatRecharge
a.GameBet += other.GameBet
a.SelfGameBet += other.SelfGameBet
a.ProbabilityBet += other.ProbabilityBet
a.Chat += other.Chat
a.FollowUser += other.FollowUser
a.FollowRoom += other.FollowRoom
a.GiftPanel += other.GiftPanel
a.ProbabilityPanel += other.ProbabilityPanel
a.AppStayMS += other.AppStayMS
a.RoomStayMS += other.RoomStayMS
a.MicStayMS += other.MicStayMS
a.RechargeCount += other.RechargeCount
a.RechargeUSDMinor += other.RechargeUSDMinor
a.GoogleRechargeUSDMinor += other.GoogleRechargeUSDMinor
a.ThirdPartyRechargeUSDMinor += other.ThirdPartyRechargeUSDMinor
a.CoinSellerRechargeUSDMinor += other.CoinSellerRechargeUSDMinor
a.FirstRechargeUSDMinor += other.FirstRechargeUSDMinor
a.RepeatRechargeUSDMinor += other.RepeatRechargeUSDMinor
a.NormalGiftCoin += other.NormalGiftCoin
a.LuckyGiftCoin += other.LuckyGiftCoin
a.SuperGiftCoin += other.SuperGiftCoin
a.LuckyGiftPayoutCoin += other.LuckyGiftPayoutCoin
a.SuperGiftPayoutCoin += other.SuperGiftPayoutCoin
a.GameBetCoin += other.GameBetCoin
a.GameBetCount += other.GameBetCount
a.SelfGameBetCoin += other.SelfGameBetCoin
a.SelfGameBetCount += other.SelfGameBetCount
a.ProbabilityBetCoin += other.ProbabilityBetCoin
a.ProbabilityBetCount += other.ProbabilityBetCount
a.OtherConsumeCoin += other.OtherConsumeCoin
a.OutputCoin += other.OutputCoin
}
func socialRetentionMetrics(day string, rows []socialRequirementUserDay, bySubjectDay map[string]map[string]socialRequirementUserDay, section string, role string, payerType string) map[string]retentionMetric {
out := map[string]retentionMetric{}
for _, row := range rows {
if !socialRowMatchesSection(row, section, role, payerType) {
continue
}
for _, offset := range []int{1, 3, 7, 15, 30} {
if row.Registered > 0 {
addSocialRetention(out, "registered_d"+intLabel(offset)+"_active", row, bySubjectDay, offset, func(target socialRequirementUserDay) bool { return target.Active > 0 })
}
}
if row.RoomJoinOK > 0 {
addSocialRetention(out, "after_room_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
addSocialRetention(out, "after_room_d1_room", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.RoomJoinOK > 0 })
}
if row.MicUp > 0 {
addSocialRetention(out, "after_mic_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
addSocialRetention(out, "after_mic_d1_mic", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.MicUp > 0 })
}
if row.Gift > 0 {
addSocialRetention(out, "after_gift_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
addSocialRetention(out, "after_gift_d1_gift", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Gift > 0 })
}
if row.GameBet > 0 {
addSocialRetention(out, "after_game_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
addSocialRetention(out, "after_game_d1_game", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.GameBet > 0 })
}
if row.Recharge > 0 {
addSocialRetention(out, "after_recharge_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
addSocialRetention(out, "after_recharge_d1_recharge", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Recharge > 0 })
}
if row.Registered > 0 && normalizeSocialUserRole(row.UserRole) == "host" {
addSocialRetention(out, "new_host_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
}
if row.Registered > 0 && normalizeSocialUserRole(row.UserRole) != "host" {
addSocialRetention(out, "new_user_d1_active", row, bySubjectDay, 1, func(target socialRequirementUserDay) bool { return target.Active > 0 })
}
}
return out
}
func addSocialRetention(out map[string]retentionMetric, key string, base socialRequirementUserDay, bySubjectDay map[string]map[string]socialRequirementUserDay, offset int, retained func(socialRequirementUserDay) bool) {
item := out[key]
item.Base++
if target, ok := bySubjectDay[base.Subject][addStatDays(base.StatDay, offset)]; ok && retained(target) {
item.Users++
}
out[key] = item
}
func mergeRetentionMetrics(total map[string]retentionMetric, next map[string]retentionMetric) {
for key, item := range next {
current := total[key]
current.Base += item.Base
current.Users += item.Users
total[key] = current
}
}
func socialSectionMetrics(section string, agg socialRequirementAggregate, retention map[string]retentionMetric) map[string]any {
switch section {
case SocialSectionRevenue:
totalConsume := agg.GameBetCoin + agg.NormalGiftCoin + agg.LuckyGiftCoin + agg.SuperGiftCoin + agg.OtherConsumeCoin
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(agg.RechargeUSDMinor, agg.Recharge),
"consumption_penetration_rate": ratio(agg.Gift+agg.GameBet, agg.Active),
"game_consumption_ratio": ratio(agg.GameBetCoin, totalConsume),
"lucky_gift_consumption_ratio": ratio(agg.LuckyGiftCoin+agg.SuperGiftCoin, totalConsume),
"normal_gift_consumption_ratio": ratio(agg.NormalGiftCoin, totalConsume),
"other_consumption_ratio": ratio(agg.OtherConsumeCoin, totalConsume),
}
case SocialSectionRetention:
out := map[string]any{}
for _, key := range sortedRetentionKeys(retention) {
item := retention[key]
out[key+"_base_users"] = item.Base
out[key+"_users"] = item.Users
out[key+"_rate"] = ratio(item.Users, item.Base)
}
return out
case SocialSectionActive:
return map[string]any{
"active_users": agg.Active,
"room_join_success_users": agg.RoomJoinOK,
"room_join_fail_users": agg.RoomJoinNG,
"room_join_success_rate": ratio(agg.RoomJoinOK, agg.RoomJoinOK+agg.RoomJoinNG),
"room_join_fail_rate": ratio(agg.RoomJoinNG, agg.RoomJoinOK+agg.RoomJoinNG),
"mic_up_users": agg.MicUp,
"mic_up_rate": ratio(agg.MicUp, agg.RoomJoinOK),
"gift_users": agg.Gift,
"gift_rate": ratio(agg.Gift, agg.RoomJoinOK),
"game_users": agg.GameBet,
"game_conversion_rate": ratio(agg.GameBet, agg.Active),
"chat_users": agg.Chat,
"chat_conversion_rate": ratio(agg.Chat, agg.Active),
"avg_app_stay_min": minutesPerUser(agg.AppStayMS, agg.Active),
}
case SocialSectionGift:
return map[string]any{
"gift_panel_open_users": agg.GiftPanel,
"gift_panel_conversion_rate": ratio(agg.GiftPanel, agg.RoomJoinOK),
"room_gift_users": agg.Gift,
"room_gift_conversion_rate": ratio(agg.Gift, agg.RoomJoinOK),
"normal_gift_users": agg.NormalGift,
"normal_gift_coin": agg.NormalGiftCoin,
"normal_gift_arppu_coin": div(agg.NormalGiftCoin, agg.NormalGift),
"lucky_gift_users": agg.LuckyGift,
"lucky_gift_coin": agg.LuckyGiftCoin,
"lucky_gift_payout_coin": agg.LuckyGiftPayoutCoin,
"lucky_gift_rtp": ratio(agg.LuckyGiftPayoutCoin, agg.LuckyGiftCoin),
"lucky_gift_arppu_coin": div(agg.LuckyGiftCoin, agg.LuckyGift),
"super_lucky_gift_users": agg.SuperGift,
"super_lucky_gift_coin": agg.SuperGiftCoin,
"super_lucky_gift_payout_coin": agg.SuperGiftPayoutCoin,
"super_lucky_gift_rtp": ratio(agg.SuperGiftPayoutCoin, agg.SuperGiftCoin),
"super_lucky_gift_arppu_coin": div(agg.SuperGiftCoin, agg.SuperGift),
}
case SocialSectionGame:
return map[string]any{
"active_users": agg.Active,
"room_join_success_users": agg.RoomJoinOK,
"self_game_conversion_rate": ratio(agg.SelfGameBet, agg.Active),
"self_game_bet_users": agg.SelfGameBet,
"self_game_bet_count": agg.SelfGameBetCount,
"self_game_bet_count_per_user": ratio(agg.SelfGameBetCount, agg.SelfGameBet),
"self_game_bet_coin": agg.SelfGameBetCoin,
"self_game_bet_arppu_coin": div(agg.SelfGameBetCoin, agg.SelfGameBet),
"probability_game_panel_open_users": agg.ProbabilityPanel,
"probability_game_panel_conversion_rate": ratio(agg.ProbabilityPanel, agg.RoomJoinOK),
"probability_game_bet_users": agg.ProbabilityBet,
"probability_game_conversion_rate": ratio(agg.ProbabilityBet, agg.RoomJoinOK),
"probability_game_bet_count": agg.ProbabilityBetCount,
"probability_game_bet_count_per_user": ratio(agg.ProbabilityBetCount, agg.ProbabilityBet),
"probability_game_bet_coin": agg.ProbabilityBetCoin,
"probability_game_bet_arppu_coin": div(agg.ProbabilityBetCoin, agg.ProbabilityBet),
}
default:
return map[string]any{
"app_download_users": agg.AppFirstOpen,
"registered_users": agg.Registered,
"new_host_users": agg.NewHostUsers,
"new_normal_users": agg.NewNormalUsers,
"registration_success_rate": ratio(agg.Registered, agg.AppFirstOpen),
"room_join_success_users": agg.RegisteredRoomJoinOK,
"room_join_fail_users": agg.RegisteredRoomJoinNG,
"room_join_success_rate": ratio(agg.RegisteredRoomJoinOK, agg.Registered),
"room_join_fail_rate": ratio(agg.RegisteredRoomJoinNG, agg.Registered),
"mic_up_users": agg.RegisteredMicUp,
"mic_up_rate": ratio(agg.RegisteredMicUp, agg.RegisteredRoomJoinOK),
"gift_users": agg.RegisteredRoomGift,
"gift_rate": ratio(agg.RegisteredRoomGift, agg.RegisteredRoomJoinOK),
"recharge_users": agg.RegisteredRecharge,
"recharge_rate": ratio(agg.RegisteredRecharge, agg.Registered),
"game_users": agg.RegisteredGame,
"game_conversion_rate": ratio(agg.RegisteredGame, agg.Registered),
"avg_app_stay_min": minutesPerUser(agg.AppStayMS, agg.Active),
"avg_room_stay_min": minutesPerUser(agg.RoomStayMS, agg.RoomJoinOK),
"avg_mic_stay_min": minutesPerUser(agg.MicStayMS, agg.MicUp),
"new_user_recharge_usd_minor": agg.FirstRechargeUSDMinor,
"follow_user_users": agg.FollowUser,
"follow_room_users": agg.FollowRoom,
"chat_users": agg.Chat,
"new_host_d1_retention_rate": retentionRate(retention, "new_host_d1_active"),
"new_user_d1_retention_rate": retentionRate(retention, "new_user_d1_active"),
}
}
}
func socialRowMatchesSection(row socialRequirementUserDay, section string, role string, payerType string) bool {
switch section {
case SocialSectionNewUsers:
return true
case SocialSectionGift, SocialSectionGame:
return socialRoleMatches(row, role)
default:
return socialRoleMatches(row, role) && socialPayerMatches(row, payerType)
}
}
func socialRoleMatches(row socialRequirementUserDay, role string) bool {
switch normalizeSocialRequirementRole(role) {
case "host":
return normalizeSocialUserRole(row.UserRole) == "host"
case "user":
return normalizeSocialUserRole(row.UserRole) != "host"
default:
return true
}
}
func socialPayerMatches(row socialRequirementUserDay, payerType string) bool {
switch normalizeSocialPayerType(payerType) {
case "paid":
return row.Recharge > 0
case "unpaid":
return row.Recharge == 0
default:
return true
}
}
func normalizeSocialRequirementRole(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "host":
return "host"
case "user":
return "user"
default:
return "all"
}
}
func normalizeSocialPayerType(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "paid":
return "paid"
case "unpaid":
return "unpaid"
default:
return "all"
}
}
func socialRequestedSections(value string) []string {
allowed := map[string]bool{
SocialSectionNewUsers: true,
SocialSectionRevenue: true,
SocialSectionRetention: true,
SocialSectionActive: true,
SocialSectionGift: true,
SocialSectionGame: true,
}
parts := strings.Split(value, ",")
out := []string{}
for _, part := range parts {
part = strings.ToLower(strings.TrimSpace(part))
if allowed[part] {
out = append(out, part)
}
}
if len(out) > 0 {
return out
}
return []string{SocialSectionNewUsers, SocialSectionRevenue, SocialSectionRetention, SocialSectionActive, SocialSectionGift, SocialSectionGame}
}
func socialSectionLabel(section string) string {
switch section {
case SocialSectionRevenue:
return "P1 营收"
case SocialSectionRetention:
return "P2 留存"
case SocialSectionActive:
return "P2 活跃"
case SocialSectionGift:
return "P4 送礼"
case SocialSectionGame:
return "P5 游戏"
default:
return "P0 新用户"
}
}
func socialRequirementMetricDefinitions(section string) []MetricDefinition {
switch section {
case SocialSectionRevenue:
return []MetricDefinition{
{Metric: "recharge_usd_minor", Definition: "服务端 WalletRechargeRecorded 充值事实汇总,单位 USD minor。"},
{Metric: "repurchase_rate", Definition: "复购充值用户 / 当日充值用户。"},
{Metric: "paid_rate", Definition: "充值用户 / DAU支持用户身份和当日付费身份筛选。"},
{Metric: "arpu_usd_minor", Definition: "充值金额 / DAU。"},
{Metric: "arppu_usd_minor", Definition: "充值金额 / 充值用户。"},
}
case SocialSectionRetention:
return []MetricDefinition{
{Metric: "registered_d1_active_rate", Definition: "注册 cohort 在第 1 个统计日后的活跃留存。D3/D7/D15/D30 同理。"},
{Metric: "after_room_d1_room_rate", Definition: "进房 cohort 在次日再次进房的留存。"},
{Metric: "after_recharge_d1_recharge_rate", Definition: "充值 cohort 在次日再次充值的留存。"},
}
case SocialSectionActive:
return []MetricDefinition{
{Metric: "active_users", Definition: "服务端活跃事实和关键客户端行为汇总后的当日活跃用户。"},
{Metric: "room_join_success_rate", Definition: "进房成功用户 / 进房尝试用户。"},
{Metric: "avg_app_stay_min", Definition: "客户端 app_session 时长 / DAU单位分钟。"},
}
case SocialSectionGift:
return []MetricDefinition{
{Metric: "gift_panel_conversion_rate", Definition: "打开礼物面板用户 / 进房成功用户。"},
{Metric: "room_gift_conversion_rate", Definition: "送礼用户 / 进房成功用户。"},
{Metric: "lucky_gift_rtp", Definition: "幸运礼物返奖 / 幸运礼物流水。"},
}
case SocialSectionGame:
return []MetricDefinition{
{Metric: "self_game_conversion_rate", Definition: "自研游戏押注用户 / DAU。"},
{Metric: "probability_game_panel_conversion_rate", Definition: "概率游戏面板打开用户 / 进房成功用户。"},
{Metric: "probability_game_bet_arppu_coin", Definition: "概率游戏押注金币 / 概率游戏押注用户。"},
}
default:
return []MetricDefinition{
{Metric: "app_download_users", Definition: "客户端 app_first_open 去重人数;不代表应用商店真实下载量。"},
{Metric: "registered_users", Definition: "UserRegistered 服务端事实去重人数。"},
{Metric: "new_user_d1_retention_rate", Definition: "新普通用户次日活跃留存。"},
}
}
}
func socialRequirementColumns(section string) []SocialRequirementColumn {
switch section {
case SocialSectionRevenue:
return []SocialRequirementColumn{
{Key: "recharge_usd_minor", Label: "充值金额", Type: "money_minor"},
{Key: "google_recharge_usd_minor", Label: "Google充值", Type: "money_minor"},
{Key: "third_party_recharge_usd_minor", Label: "三方充值", Type: "money_minor"},
{Key: "coin_seller_recharge_usd_minor", Label: "币商充值", Type: "money_minor"},
{Key: "first_recharge_usd_minor", Label: "首充金额", Type: "money_minor"},
{Key: "repeat_recharge_usd_minor", Label: "复购金额", Type: "money_minor"},
{Key: "recharge_users", Label: "充值用户", Type: "count"},
{Key: "recharge_count", Label: "充值次数", Type: "count"},
{Key: "first_recharge_users", Label: "首充用户", Type: "count"},
{Key: "repeat_recharge_users", Label: "复购用户", Type: "count"},
{Key: "repurchase_rate", Label: "复购率", Type: "ratio"},
{Key: "paid_rate", Label: "付费率", Type: "ratio"},
{Key: "arpu_usd_minor", Label: "ARPU", Type: "money_minor"},
{Key: "arppu_usd_minor", Label: "ARPPU", Type: "money_minor"},
{Key: "consumption_penetration_rate", Label: "消费渗透率", Type: "ratio"},
{Key: "game_consumption_ratio", Label: "游戏消费占比", Type: "ratio"},
{Key: "lucky_gift_consumption_ratio", Label: "幸运礼物消费占比", Type: "ratio"},
{Key: "normal_gift_consumption_ratio", Label: "普通礼物消费占比", Type: "ratio"},
{Key: "other_consumption_ratio", Label: "其他消费占比", Type: "ratio"},
}
case SocialSectionRetention:
return []SocialRequirementColumn{
{Key: "registered_d1_active_base_users", Label: "新用户D1基数", Type: "count"},
{Key: "registered_d1_active_users", Label: "新用户D1留存", Type: "count"},
{Key: "registered_d1_active_rate", Label: "新用户D1留存率", Type: "ratio"},
{Key: "registered_d3_active_rate", Label: "新用户D3留存率", Type: "ratio"},
{Key: "registered_d7_active_rate", Label: "新用户D7留存率", Type: "ratio"},
{Key: "registered_d15_active_rate", Label: "新用户D15留存率", Type: "ratio"},
{Key: "registered_d30_active_rate", Label: "新用户D30留存率", Type: "ratio"},
{Key: "after_room_d1_active_rate", Label: "进房后D1活跃留存", Type: "ratio"},
{Key: "after_room_d1_room_rate", Label: "进房后D1进房留存", Type: "ratio"},
{Key: "after_mic_d1_active_rate", Label: "上麦后D1活跃留存", Type: "ratio"},
{Key: "after_mic_d1_mic_rate", Label: "上麦后D1上麦留存", Type: "ratio"},
{Key: "after_gift_d1_active_rate", Label: "送礼后D1活跃留存", Type: "ratio"},
{Key: "after_gift_d1_gift_rate", Label: "送礼后D1送礼留存", Type: "ratio"},
{Key: "after_game_d1_active_rate", Label: "游戏后D1活跃留存", Type: "ratio"},
{Key: "after_game_d1_game_rate", Label: "游戏后D1游戏留存", Type: "ratio"},
{Key: "after_recharge_d1_active_rate", Label: "充值后D1活跃留存", Type: "ratio"},
{Key: "after_recharge_d1_recharge_rate", Label: "充值后D1充值留存", Type: "ratio"},
}
case SocialSectionActive:
return []SocialRequirementColumn{
{Key: "active_users", Label: "DAU", Type: "count"},
{Key: "room_join_success_users", Label: "进房成功用户", Type: "count"},
{Key: "room_join_fail_users", Label: "进房失败用户", Type: "count"},
{Key: "room_join_success_rate", Label: "进房成功率", Type: "ratio"},
{Key: "room_join_fail_rate", Label: "进房失败率", Type: "ratio"},
{Key: "mic_up_users", Label: "上麦用户", Type: "count"},
{Key: "mic_up_rate", Label: "上麦率", Type: "ratio"},
{Key: "gift_users", Label: "送礼用户", Type: "count"},
{Key: "gift_rate", Label: "送礼率", Type: "ratio"},
{Key: "game_users", Label: "游戏用户", Type: "count"},
{Key: "game_conversion_rate", Label: "游戏转化率", Type: "ratio"},
{Key: "chat_users", Label: "发言用户", Type: "count"},
{Key: "chat_conversion_rate", Label: "发言转化率", Type: "ratio"},
{Key: "avg_app_stay_min", Label: "人均App停留(分钟)", Type: "number"},
}
case SocialSectionGift:
return []SocialRequirementColumn{
{Key: "gift_panel_open_users", Label: "礼物面板用户", Type: "count"},
{Key: "gift_panel_conversion_rate", Label: "礼物面板转化率", Type: "ratio"},
{Key: "room_gift_users", Label: "送礼用户", Type: "count"},
{Key: "room_gift_conversion_rate", Label: "房间送礼转化率", Type: "ratio"},
{Key: "normal_gift_users", Label: "普通礼物用户", Type: "count"},
{Key: "normal_gift_coin", Label: "普通礼物流水", Type: "coin"},
{Key: "normal_gift_arppu_coin", Label: "普通礼物ARPPU", Type: "coin"},
{Key: "lucky_gift_users", Label: "幸运礼物用户", Type: "count"},
{Key: "lucky_gift_coin", Label: "幸运礼物流水", Type: "coin"},
{Key: "lucky_gift_payout_coin", Label: "幸运礼物返奖", Type: "coin"},
{Key: "lucky_gift_rtp", Label: "幸运礼物RTP", Type: "ratio"},
{Key: "lucky_gift_arppu_coin", Label: "幸运礼物ARPPU", Type: "coin"},
{Key: "super_lucky_gift_users", Label: "超级幸运用户", Type: "count"},
{Key: "super_lucky_gift_coin", Label: "超级幸运流水", Type: "coin"},
{Key: "super_lucky_gift_payout_coin", Label: "超级幸运返奖", Type: "coin"},
{Key: "super_lucky_gift_rtp", Label: "超级幸运RTP", Type: "ratio"},
{Key: "super_lucky_gift_arppu_coin", Label: "超级幸运ARPPU", Type: "coin"},
}
case SocialSectionGame:
return []SocialRequirementColumn{
{Key: "active_users", Label: "DAU", Type: "count"},
{Key: "room_join_success_users", Label: "进房成功用户", Type: "count"},
{Key: "self_game_conversion_rate", Label: "自研游戏转化率", Type: "ratio"},
{Key: "self_game_bet_users", Label: "自研押注用户", Type: "count"},
{Key: "self_game_bet_count", Label: "自研押注次数", Type: "count"},
{Key: "self_game_bet_count_per_user", Label: "自研人均押注次数", Type: "number"},
{Key: "self_game_bet_coin", Label: "自研押注金币", Type: "coin"},
{Key: "self_game_bet_arppu_coin", Label: "自研押注ARPPU", Type: "coin"},
{Key: "probability_game_panel_open_users", Label: "概率游戏面板用户", Type: "count"},
{Key: "probability_game_panel_conversion_rate", Label: "概率游戏面板转化率", Type: "ratio"},
{Key: "probability_game_bet_users", Label: "概率游戏押注用户", Type: "count"},
{Key: "probability_game_conversion_rate", Label: "概率游戏转化率", Type: "ratio"},
{Key: "probability_game_bet_count", Label: "概率游戏押注次数", Type: "count"},
{Key: "probability_game_bet_count_per_user", Label: "概率游戏人均押注次数", Type: "number"},
{Key: "probability_game_bet_coin", Label: "概率游戏押注金币", Type: "coin"},
{Key: "probability_game_bet_arppu_coin", Label: "概率游戏押注ARPPU", Type: "coin"},
}
default:
return []SocialRequirementColumn{
{Key: "app_download_users", Label: "下载APP人数", Type: "count", Tooltip: "客户端 app_first_open 去重人数"},
{Key: "registered_users", Label: "注册成功用户", Type: "count"},
{Key: "new_host_users", Label: "新主播用户", Type: "count"},
{Key: "new_normal_users", Label: "新普通用户", Type: "count"},
{Key: "registration_success_rate", Label: "注册成功率", Type: "ratio"},
{Key: "room_join_success_users", Label: "新用户进房成功", Type: "count"},
{Key: "room_join_fail_users", Label: "新用户进房失败", Type: "count"},
{Key: "room_join_success_rate", Label: "新用户进房成功率", Type: "ratio"},
{Key: "room_join_fail_rate", Label: "新用户进房失败率", Type: "ratio"},
{Key: "mic_up_users", Label: "新用户上麦", Type: "count"},
{Key: "mic_up_rate", Label: "新用户上麦率", Type: "ratio"},
{Key: "gift_users", Label: "新用户送礼", Type: "count"},
{Key: "gift_rate", Label: "新用户送礼率", Type: "ratio"},
{Key: "recharge_users", Label: "新用户充值", Type: "count"},
{Key: "recharge_rate", Label: "新用户充值率", Type: "ratio"},
{Key: "game_users", Label: "新用户游戏", Type: "count"},
{Key: "game_conversion_rate", Label: "新用户游戏转化率", Type: "ratio"},
{Key: "avg_app_stay_min", Label: "人均App停留(分钟)", Type: "number"},
{Key: "avg_room_stay_min", Label: "人均房间停留(分钟)", Type: "number"},
{Key: "avg_mic_stay_min", Label: "人均麦上停留(分钟)", Type: "number"},
{Key: "new_user_recharge_usd_minor", Label: "新用户充值金额", Type: "money_minor"},
{Key: "follow_user_users", Label: "关注用户人数", Type: "count"},
{Key: "follow_room_users", Label: "关注房间人数", Type: "count"},
{Key: "chat_users", Label: "聊天用户", Type: "count"},
{Key: "new_host_d1_retention_rate", Label: "新主播D1留存", Type: "ratio"},
{Key: "new_user_d1_retention_rate", Label: "新普通用户D1留存", Type: "ratio"},
}
}
}
func retentionRate(values map[string]retentionMetric, key string) float64 {
item := values[key]
return ratio(item.Users, item.Base)
}
func minutesPerUser(durationMS int64, users int64) float64 {
if users <= 0 {
return 0
}
return float64(durationMS) / float64(users) / 60000
}
func sortedRetentionKeys(values map[string]retentionMetric) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func intLabel(value int) string {
return strconv.Itoa(value)
}
func socialDayRange(startDay string, endDay string) []string {
start, err := time.Parse("2006-01-02", startDay)
if err != nil {
return []string{startDay}
}
end, err := time.Parse("2006-01-02", endDay)
if err != nil || end.Before(start) {
return []string{startDay}
}
days := []string{}
for current := start; !current.After(end); current = current.AddDate(0, 0, 1) {
days = append(days, current.Format("2006-01-02"))
}
return days
}
func normalizePositiveIDs(values []int64) []int64 {
seen := map[int64]bool{}
out := []int64{}
for _, value := range values {
if value <= 0 || seen[value] {
continue
}
seen[value] = true
out = append(out, value)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}

View File

@ -0,0 +1,266 @@
package mysql
import (
"context"
"database/sql"
"strconv"
"strings"
"hyapp/pkg/appcode"
)
type socialUserDayDelta struct {
AppCode string
StatTZ string
StatDay string
UserID int64
DeviceID string
CountryID int64
RegionID int64
UserRole string
UpdatedAt int64
FirstOpen int64
Registered int64
Active int64
RoomJoinOK int64
RoomJoinNG int64
MicUp int64
Gift int64
NormalGift int64
LuckyGift int64
SuperGift int64
Recharge int64
FirstRecharge int64
RepeatRecharge int64
GameBet int64
SelfGameBet int64
ProbabilityBet int64
Chat int64
FollowUser int64
FollowRoom int64
GiftPanel int64
ProbabilityPanel int64
AppStayMS int64
RoomStayMS int64
MicStayMS int64
RechargeCount int64
RechargeUSDMinor int64
GoogleRechargeUSDMinor int64
ThirdPartyRechargeUSDMinor int64
CoinSellerRechargeUSDMinor int64
FirstRechargeUSDMinor int64
RepeatRechargeUSDMinor int64
NormalGiftCoin int64
LuckyGiftCoin int64
SuperGiftCoin int64
LuckyGiftPayoutCoin int64
SuperGiftPayoutCoin int64
GameBetCoin int64
GameBetCount int64
SelfGameBetCoin int64
SelfGameBetCount int64
ProbabilityBetCoin int64
ProbabilityBetCount int64
OtherConsumeCoin int64
OutputCoin int64
}
func applySocialUserDayDelta(ctx context.Context, tx *sql.Tx, delta socialUserDayDelta) error {
subjectKey := socialSubjectKey(delta.UserID, delta.DeviceID)
if subjectKey == "" {
return nil
}
app := appcode.Normalize(delta.AppCode)
statTZ := normalizeStatTZ(delta.StatTZ)
day := strings.TrimSpace(delta.StatDay)
if app == "" || day == "" {
return nil
}
countryID, regionID := normalizeDimension(delta.CountryID, delta.RegionID)
role := normalizeSocialUserRole(delta.UserRole)
// 这张表是 P0-P5 的唯一宽表投影。外层 withEvent 或 app_tracking_events 的 INSERT IGNORE 已经完成事件级幂等;
// 这里允许同一事务内分步骤 upsert+update保证角色和维度快照可以随任意事实补齐但金额类字段只随已幂等的事实累加一次。
if _, err := tx.ExecContext(ctx, `
INSERT INTO stat_social_user_day (
app_code, stat_tz, stat_day, subject_key, user_id, device_id,
country_id, region_id, user_role, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
user_id = CASE WHEN VALUES(user_id) > 0 THEN VALUES(user_id) ELSE user_id END,
device_id = CASE WHEN VALUES(device_id) <> '' THEN VALUES(device_id) ELSE device_id END,
country_id = CASE WHEN VALUES(country_id) > 0 THEN VALUES(country_id) ELSE country_id END,
region_id = CASE WHEN VALUES(region_id) > 0 THEN VALUES(region_id) ELSE region_id END,
user_role = CASE WHEN VALUES(user_role) = 'host' OR user_role = 'host' THEN 'host' ELSE 'user' END,
updated_at_ms = VALUES(updated_at_ms)
`, app, statTZ, day, subjectKey, normalizeID(delta.UserID), strings.TrimSpace(delta.DeviceID), countryID, regionID, role, delta.UpdatedAt); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `
UPDATE stat_social_user_day
SET app_first_open = GREATEST(app_first_open, ?),
registered_success = GREATEST(registered_success, ?),
active_user = GREATEST(active_user, ?),
room_join_success = GREATEST(room_join_success, ?),
room_join_fail = GREATEST(room_join_fail, ?),
mic_up_success = GREATEST(mic_up_success, ?),
gift_sent = GREATEST(gift_sent, ?),
normal_gift_sent = GREATEST(normal_gift_sent, ?),
lucky_gift_sent = GREATEST(lucky_gift_sent, ?),
super_lucky_gift_sent = GREATEST(super_lucky_gift_sent, ?),
recharge_user = GREATEST(recharge_user, ?),
first_recharge_user = GREATEST(first_recharge_user, ?),
repeat_recharge_user = GREATEST(repeat_recharge_user, ?),
game_bet_user = GREATEST(game_bet_user, ?),
self_game_bet_user = GREATEST(self_game_bet_user, ?),
probability_game_bet_user = GREATEST(probability_game_bet_user, ?),
chat_sent = GREATEST(chat_sent, ?),
follow_user = GREATEST(follow_user, ?),
follow_room = GREATEST(follow_room, ?),
gift_panel_open = GREATEST(gift_panel_open, ?),
probability_game_panel_open = GREATEST(probability_game_panel_open, ?),
app_stay_ms = app_stay_ms + ?,
room_stay_ms = room_stay_ms + ?,
mic_stay_ms = mic_stay_ms + ?,
recharge_count = recharge_count + ?,
recharge_usd_minor = recharge_usd_minor + ?,
google_recharge_usd_minor = google_recharge_usd_minor + ?,
third_party_recharge_usd_minor = third_party_recharge_usd_minor + ?,
coin_seller_recharge_usd_minor = coin_seller_recharge_usd_minor + ?,
first_recharge_usd_minor = first_recharge_usd_minor + ?,
repeat_recharge_usd_minor = repeat_recharge_usd_minor + ?,
normal_gift_coin = normal_gift_coin + ?,
lucky_gift_coin = lucky_gift_coin + ?,
super_lucky_gift_coin = super_lucky_gift_coin + ?,
lucky_gift_payout_coin = lucky_gift_payout_coin + ?,
super_lucky_gift_payout_coin = super_lucky_gift_payout_coin + ?,
game_bet_coin = game_bet_coin + ?,
game_bet_count = game_bet_count + ?,
self_game_bet_coin = self_game_bet_coin + ?,
self_game_bet_count = self_game_bet_count + ?,
probability_game_bet_coin = probability_game_bet_coin + ?,
probability_game_bet_count = probability_game_bet_count + ?,
other_consume_coin = other_consume_coin + ?,
output_coin = output_coin + ?,
updated_at_ms = ?
WHERE app_code = ? AND stat_tz = ? AND stat_day = ? AND subject_key = ?
`, flagValue(delta.FirstOpen), flagValue(delta.Registered), flagValue(delta.Active), flagValue(delta.RoomJoinOK), flagValue(delta.RoomJoinNG),
flagValue(delta.MicUp), flagValue(delta.Gift), flagValue(delta.NormalGift), flagValue(delta.LuckyGift), flagValue(delta.SuperGift),
flagValue(delta.Recharge), flagValue(delta.FirstRecharge), flagValue(delta.RepeatRecharge), flagValue(delta.GameBet), flagValue(delta.SelfGameBet),
flagValue(delta.ProbabilityBet), flagValue(delta.Chat), flagValue(delta.FollowUser), flagValue(delta.FollowRoom), flagValue(delta.GiftPanel),
flagValue(delta.ProbabilityPanel), normalizeID(delta.AppStayMS), normalizeID(delta.RoomStayMS), normalizeID(delta.MicStayMS),
normalizeID(delta.RechargeCount), normalizeID(delta.RechargeUSDMinor), normalizeID(delta.GoogleRechargeUSDMinor), normalizeID(delta.ThirdPartyRechargeUSDMinor),
normalizeID(delta.CoinSellerRechargeUSDMinor), normalizeID(delta.FirstRechargeUSDMinor), normalizeID(delta.RepeatRechargeUSDMinor),
normalizeID(delta.NormalGiftCoin), normalizeID(delta.LuckyGiftCoin), normalizeID(delta.SuperGiftCoin), normalizeID(delta.LuckyGiftPayoutCoin),
normalizeID(delta.SuperGiftPayoutCoin), normalizeID(delta.GameBetCoin), normalizeID(delta.GameBetCount), normalizeID(delta.SelfGameBetCoin),
normalizeID(delta.SelfGameBetCount), normalizeID(delta.ProbabilityBetCoin), normalizeID(delta.ProbabilityBetCount), normalizeID(delta.OtherConsumeCoin),
normalizeID(delta.OutputCoin), delta.UpdatedAt, app, statTZ, day, subjectKey)
return err
}
func socialSubjectKey(userID int64, deviceID string) string {
if userID > 0 {
return "u:" + strconv.FormatInt(userID, 10)
}
deviceID = strings.TrimSpace(deviceID)
if deviceID == "" {
return ""
}
return "d:" + deviceID
}
func flagValue(value int64) int64 {
if value > 0 {
return 1
}
return 0
}
func normalizeSocialUserRole(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "host", "anchor", "owner", "room_owner", "agency", "guild", "guild_leader":
return "host"
default:
return "user"
}
}
func socialGiftKind(event RoomGiftEvent) (normalFlag, luckyFlag, superFlag int64, normalCoin, luckyCoin, superCoin int64) {
coin := normalizeID(event.CoinSpent)
if coin == 0 {
coin = normalizeID(event.GiftValue)
}
switch realRoomRobotGiftKind(event) {
case "super_lucky":
return 0, 0, 1, 0, 0, coin
case "lucky":
return 0, 1, 0, 0, coin, 0
default:
return 1, 0, 0, coin, 0, 0
}
}
func socialDeltaForAppTrackingEvent(event AppTrackingEvent, scope statDayScope, nowMS int64) (socialUserDayDelta, bool) {
delta := socialUserDayDelta{
AppCode: event.AppCode,
StatTZ: scope.tz,
StatDay: scope.day,
UserID: event.UserID,
DeviceID: event.DeviceID,
CountryID: event.CountryID,
RegionID: event.RegionID,
UpdatedAt: nowMS,
}
switch strings.ToLower(strings.TrimSpace(event.EventName)) {
case "app_first_open":
delta.FirstOpen = 1
delta.Active = 1
case "home_view":
delta.Active = 1
case "app_session":
delta.Active = 1
delta.AppStayMS = event.DurationMS
case "room_join_success":
delta.Active = 1
delta.RoomJoinOK = 1
case "room_join_fail":
delta.Active = 1
delta.RoomJoinNG = 1
case "room_stay":
delta.Active = 1
delta.RoomJoinOK = 1
delta.RoomStayMS = event.DurationMS
case "mic_up":
if !event.Success && strings.TrimSpace(event.ErrorCode) != "" {
return socialUserDayDelta{}, false
}
delta.Active = 1
delta.MicUp = 1
case "send_message":
if !event.Success && strings.TrimSpace(event.ErrorCode) != "" {
return socialUserDayDelta{}, false
}
delta.Active = 1
delta.Chat = 1
case "follow_host", "add_friend":
if !event.Success && strings.TrimSpace(event.ErrorCode) != "" {
return socialUserDayDelta{}, false
}
delta.Active = 1
delta.FollowUser = 1
case "follow_room":
if !event.Success && strings.TrimSpace(event.ErrorCode) != "" {
return socialUserDayDelta{}, false
}
delta.Active = 1
delta.FollowRoom = 1
case "gift_panel_open":
delta.Active = 1
delta.GiftPanel = 1
case "probability_game_panel_open":
delta.Active = 1
delta.ProbabilityPanel = 1
default:
return socialUserDayDelta{}, false
}
return delta, true
}