package business import ( "context" "gorm.io/gorm" "kra/internal/biz/business" "math/big" "strings" "time" ) func alertTree(db *gorm.DB, roots []business.ChannelKey) *gorm.DB { base := func() *gorm.DB { return db.Table("tab_promote r").Where("(r.source_db,r.id) IN ?", channelPairs(roots)) } self := base().Select("r.source_db,r.id,r.id AS leaf_id") children := base().Joins("JOIN tab_promote child ON child.source_db=r.source_db AND child.parent_id=r.id").Select("r.source_db,r.id,child.id AS leaf_id") descendants := base().Joins("JOIN tab_promote child ON child.source_db=r.source_db AND child.top_promote_id=r.id").Select("r.source_db,r.id,child.id AS leaf_id") // Each branch can use its own equality index. UNION removes channels reached // through both parent and top-level relationships without an OR join scan. return db.Table("(? UNION ? UNION ?) root", self, children, descendants). Joins("JOIN tab_promote p ON p.source_db=root.source_db AND p.id=root.leaf_id") } func alertMetricSpec(db *gorm.DB, metric int) (table, stamp, condition, amount string) { if metric == 2 { return "tab_spend", "m.pay_time", "m.pay_status=1", "m.pay_amount" } stamp = "CAST(m.register_time AS INTEGER)" if db.Dialector.Name() == "mysql" { stamp = "m.sw_register_time_unix" } return "tab_user", stamp, "(m.puid=0 OR m.puid IS NULL)", "1" } func (r *alertTriggerRepo) Comparison(ctx context.Context, metric int, channels []business.ChannelKey, today time.Time, days int) (map[business.ChannelKey]business.AlertComparison, error) { db := r.data.DB().WithContext(ctx) if metric != 1 && metric != 2 { return nil, business.ErrAlertRuleInput } table, stamp, condition, amount := alertMetricSpec(db, metric) available, err := businessTablesAvailable(db, table, "tab_promote") if err != nil { return nil, err } if !available { return nil, business.ErrReportBuildUnavailable } result := map[business.ChannelKey]business.AlertComparison{} current, previous := today.AddDate(0, 0, -days).Unix(), today.AddDate(0, 0, -2*days).Unix() // 先取小规模渠道映射,再按固定平台及渠道 ID 批量聚合;避免 MySQL 5.7 // 将动态 JOIN 的时间范围退化为 ref 扫描,读取每个渠道的全部历史明细。 owners := map[business.ChannelKey][]business.ChannelKey{} bySource := map[string][]uint64{} for start := 0; start < len(channels); start += 100 { var pairs []struct { SourceDB string RootID, PromoteID uint64 } if err := alertTree(db, channels[start:min(start+100, len(channels))]).Select("root.source_db,root.id AS root_id,p.id AS promote_id").Scan(&pairs).Error; err != nil { return nil, business.ErrStorage } for _, p := range pairs { leaf := business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID} root := business.ChannelKey{SourceDB: p.SourceDB, ID: p.RootID} if len(owners[leaf]) == 0 { bySource[p.SourceDB] = append(bySource[p.SourceDB], p.PromoteID) } owners[leaf] = append(owners[leaf], root) result[root] = business.AlertComparison{Current: "0", Previous: "0"} } } for source, ids := range bySource { for start := 0; start < len(ids); start += 300 { batch := ids[start:min(start+300, len(ids))] firstQueries := []string{} args := []any{} for _, id := range batch { // MIN 的三个等值前缀命中覆盖索引,MySQL 可直接读取首个索引项。 firstQueries = append(firstQueries, "SELECT ? AS promote_id,(SELECT MIN("+stamp+") FROM "+table+" m WHERE m.source_db=? AND m.promote_id=? AND "+condition+" AND "+stamp+">0) AS first_unix") args = append(args, id, source, id) } var first []struct { PromoteID uint64 FirstUnix *int64 } if err := db.Raw(strings.Join(firstQueries, " UNION ALL "), args...).Scan(&first).Error; err != nil { return nil, business.ErrStorage } for _, row := range first { if row.FirstUnix == nil { continue } for _, root := range owners[business.ChannelKey{SourceDB: source, ID: row.PromoteID}] { v := result[root] if v.FirstUnix == 0 || *row.FirstUnix < v.FirstUnix { v.FirstUnix = *row.FirstUnix } result[root] = v } } var rows []struct { PromoteID uint64 CurrentValue, PreviousValue string } selection := "m.promote_id,COALESCE(SUM(CASE WHEN " + stamp + ">=? THEN " + amount + " ELSE 0 END),0) AS current_value,COALESCE(SUM(CASE WHEN " + stamp + "=? AND "+stamp+"'' THEN 'card:' || TRIM(m.idcard) ELSE 'user:' || " + identity + " END" if db.Dialector.Name() == "mysql" { identity = "CASE WHEN TRIM(COALESCE(m.idcard,''))<>'' THEN CONCAT('card:',TRIM(m.idcard)) ELSE CONCAT('user:',m.id) END" } } q := alertTree(db, batch).Joins("JOIN tab_user m ON m.source_db=p.source_db AND m.promote_id=p.id").Where(condition).Where(stamp+"<=?", now.Unix()) if err := q.Select("root.source_db,root.id AS promote_id,COUNT(DISTINCT " + identity + ") AS players").Group("root.source_db,root.id").Scan(&players).Error; err != nil { return nil, business.ErrStorage } for _, row := range players { key := business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID} v := result[key] v.Players = row.Players result[key] = v } var payments []struct { SourceDB string PromoteID uint64 Amount string } if err := alertTree(db, batch).Joins("JOIN tab_spend m ON m.source_db=p.source_db AND m.promote_id=p.id").Where("m.pay_status=1 AND m.pay_time<=?", now.Unix()).Select("root.source_db,root.id AS promote_id,COALESCE(SUM(m.pay_amount),0) AS amount").Group("root.source_db,root.id").Scan(&payments).Error; err != nil { return nil, business.ErrStorage } for _, row := range payments { key := business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID} v := result[key] v.Pay = row.Amount result[key] = v } } return result, nil }