package business import ( "database/sql" "math/big" "sort" "gorm.io/gorm" "kra/internal/biz/business" ) // Build the same per-channel membership as Channel.IncludesStat. UNION removes // descendants reached through both parent_id and top_promote_id. func channelMetricMembership(db, roots *gorm.DB, includeSub bool) *gorm.DB { self := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,p.id AS leaf_id") if !includeSub { return db.Table("(?) cm", self) } direct := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id"). Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.parent_id=p.id"). Where("p.promote_level<3 AND (p.promote_level<>2 OR leaf.promote_level=3)") top := roots.Session(&gorm.Session{}).Select("p.source_db,p.id AS root_id,leaf.id AS leaf_id"). Joins("JOIN tab_promote leaf ON leaf.source_db=p.source_db AND leaf.top_promote_id=p.id"). Where("p.promote_level<3 AND p.promote_level<>2") return db.Table("(? UNION ? UNION ?) cm", self, direct, top) } // Only keys and scores for visible rows are retained. Identity rows are streamed // one channel at a time, using the same normalization as the displayed count. func channelMetricPage(db, roots *gorm.DB, f business.ChannelFilter, o business.ListOptions) ([]business.ChannelKey, error) { var channels []channelPO if err := roots.Session(&gorm.Session{}).Select("p.id,p.source_db,p.create_time").Scan(&channels).Error; err != nil { return nil, business.ErrStorage } if len(channels) == 0 { return nil, nil } table := "tab_user" if f.SortField == "totalPayAmount" { table = "tab_spend" } available, err := businessTableAvailable(db, table) if err != nil { return nil, err } if !available { return nil, business.ErrChannelUnavailable } scores := map[business.ChannelKey]*big.Rat{} q := channelMetricMembership(db, roots, f.IncludeSub) // MySQL 5.7 may choose the narrow pay_status index and then fetch millions // of full records. Prefer the existing covering index for this full scan. metricTable := table index := "idx_sw_user_report_identity" if table == "tab_spend" { index = "idx_sw_spend_promote_summary" } else if !f.IDCardDedup { // Its source/channel prefix supports both filtering and grouped counting, // without sorting the much wider identity index into a temporary table. index = "idx_alert_user_window" } if db.Dialector.Name() == "mysql" && db.Migrator().HasIndex(table, index) { metricTable += " FORCE INDEX (" + index + ")" } leaves := db.Table(metricTable) if len(f.Sources) > 0 { leaves = leaves.Where("source_db IN ?", f.Sources) } if f.SortField == "totalPayAmount" { leaves = leaves.Where("pay_status=1") } else { leaves = leaves.Where("puid=0 OR puid IS NULL") } if f.SortField == "playerCount" && f.IDCardDedup { join := "STRAIGHT_JOIN" if db.Dialector.Name() == "sqlite" { join = "JOIN" } // Stream the covering user index once, then look up its channel roots. // Grouping raw identity strings first creates a large disk temporary table // and still cannot apply the domain's exact whitespace/X normalization. leaves = leaves.Select("source_db,promote_id,idcard") members := q.Select("cm.source_db,cm.root_id,cm.leaf_id") rows, err := db.Table("(?) u", leaves).Joins(join+" (?) cm ON cm.source_db=u.source_db AND cm.leaf_id=u.promote_id", members). Select("cm.source_db,cm.root_id,u.idcard").Order("cm.source_db,cm.root_id").Rows() if err != nil { return nil, business.ErrStorage } defer rows.Close() var previous business.ChannelKey identities := map[string]struct{}{} flush := func() { scores[previous] = big.NewRat(int64(len(identities)), 1) } for rows.Next() { var key business.ChannelKey var card sql.NullString if err := rows.Scan(&key.SourceDB, &key.ID, &card); err != nil { return nil, business.ErrStorage } if key != previous { flush() identities = map[string]struct{}{} previous = key } if identity := business.ChannelIdentity(card.String); identity != "" { identities[identity] = struct{}{} } } if err := rows.Err(); err != nil { return nil, business.ErrStorage } flush() } else { var values []struct { SourceDB string RootID uint64 Score string } if f.SortField == "totalPayAmount" { leaves = leaves.Select("source_db,promote_id AS root_id,promote_id,COALESCE(SUM(pay_amount),0) AS score") } else { leaves = leaves.Select("source_db,promote_id AS root_id,promote_id,COUNT(*) AS score") } leaves = leaves.Group("source_db,promote_id") if f.IncludeSub { q = q.Joins("JOIN (?) leaf ON leaf.source_db=cm.source_db AND leaf.promote_id=cm.leaf_id", leaves). Select("cm.source_db,cm.root_id,SUM(leaf.score) AS score").Group("cm.source_db,cm.root_id") } else { // A channel's own score needs no membership join or second grouping. // Scores remain internal; pagination below only returns visible roots. q = leaves } if err := q.Scan(&values).Error; err != nil { return nil, business.ErrStorage } for _, value := range values { score, ok := new(big.Rat).SetString(value.Score) if !ok { return nil, business.ErrStorage } scores[business.ChannelKey{SourceDB: value.SourceDB, ID: value.RootID}] = score } } zero := new(big.Rat) score := func(c channelPO) *big.Rat { if n := scores[business.ChannelKey{SourceDB: c.SourceDB, ID: c.ID}]; n != nil { return n } return zero } sort.Slice(channels, func(i, j int) bool { a, b := channels[i], channels[j] if compared := score(a).Cmp(score(b)); compared != 0 { return (compared < 0) == f.Ascending } if a.CreateTime == nil || b.CreateTime == nil { if (a.CreateTime == nil) != (b.CreateTime == nil) { return a.CreateTime != nil } } else if *a.CreateTime != *b.CreateTime { return *a.CreateTime > *b.CreateTime } if a.ID != b.ID { return a.ID > b.ID } return a.SourceDB < b.SourceDB }) begin := min(max(0, o.Offset), len(channels)) end := len(channels) if o.Limit > 0 && o.Limit < end-begin { end = begin + o.Limit } keys := make([]business.ChannelKey, 0, end-begin) for _, row := range channels[begin:end] { keys = append(keys, business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}) } return keys, nil }