feat: add social BI requirements stats
This commit is contained in:
parent
ce9a8c2d22
commit
dd20dbcd4d
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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"`
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user