Files
kra-new/internal/data/business/alert_metrics.go
T

204 lines
7.9 KiB
Go

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 + "<? THEN " + amount + " ELSE 0 END),0) AS previous_value"
q := db.Table(table+" m").Where("m.source_db=? AND m.promote_id IN ?", source, batch).Where(condition).Where(stamp+">=? AND "+stamp+"<?", previous, today.Unix())
if err := q.Select(selection, current, current).Group("m.promote_id").Scan(&rows).Error; err != nil {
return nil, business.ErrStorage
}
scale := 0
if metric == 2 {
scale = 2
}
for _, row := range rows {
for _, root := range owners[business.ChannelKey{SourceDB: source, ID: row.PromoteID}] {
v := result[root]
var err error
v.Current, err = addAlertMetric(v.Current, row.CurrentValue, scale)
if err != nil {
return nil, err
}
v.Previous, err = addAlertMetric(v.Previous, row.PreviousValue, scale)
if err != nil {
return nil, err
}
result[root] = v
}
}
}
}
return result, nil
}
func addAlertMetric(a, b string, scale int) (string, error) {
left, ok := new(big.Rat).SetString(a)
if !ok {
return "", business.ErrStorage
}
right, ok := new(big.Rat).SetString(b)
if !ok {
return "", business.ErrStorage
}
return left.Add(left, right).FloatString(scale), nil
}
func (r *alertTriggerRepo) Snapshots(ctx context.Context, channels []business.ChannelKey, now time.Time, dedup bool) (map[business.ChannelKey]business.AlertSnapshot, error) {
db := r.data.DB().WithContext(ctx)
available, err := businessTablesAvailable(db, "tab_user", "tab_spend", "tab_promote")
if err != nil {
return nil, err
}
if !available {
return nil, business.ErrReportBuildUnavailable
}
result := map[business.ChannelKey]business.AlertSnapshot{}
for _, key := range channels {
result[key] = business.AlertSnapshot{Pay: "0.00"}
}
for start := 0; start < len(channels); start += 100 {
batch := channels[start:min(start+100, len(channels))]
var players []struct {
SourceDB string
PromoteID uint64
Players int64
}
_, stamp, condition, _ := alertMetricSpec(db, 1)
identity := "CAST(m.id AS TEXT)"
if db.Dialector.Name() == "mysql" {
identity = "CAST(m.id AS CHAR)"
}
if dedup {
identity = "CASE WHEN TRIM(COALESCE(m.idcard,''))<>'' 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
}