feat: implement V2 alert center and scoped alert governance
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
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 {
|
||||
return db.Table("tab_promote root").Joins("JOIN tab_promote p ON p.source_db=root.source_db AND (p.id=root.id OR p.parent_id=root.id OR p.top_promote_id=root.id)").
|
||||
Where("(root.source_db,root.id) IN ?", channelPairs(roots))
|
||||
}
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user