feat: implement V2 alert center and scoped alert governance
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"kra/internal/biz/business"
|
||||
)
|
||||
|
||||
type alertGapPO struct {
|
||||
OwnerID uint `gorm:"primaryKey;autoIncrement:false"`
|
||||
Enabled bool
|
||||
Days int
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (alertGapPO) TableName() string { return "sys_alert_gap" }
|
||||
|
||||
type alertExemptionPO struct {
|
||||
ID uint64 `gorm:"primaryKey"`
|
||||
SourceDB string `gorm:"size:50;index:idx_alert_exempt_channel,priority:1"`
|
||||
PromoteID uint64 `gorm:"index:idx_alert_exempt_channel,priority:2"`
|
||||
ChannelName string `gorm:"size:100"`
|
||||
OwnerID uint `gorm:"index:idx_alert_exempt_owner,priority:1"`
|
||||
AddedBy uint
|
||||
AddedName string `gorm:"size:100"`
|
||||
Reason string `gorm:"size:500"`
|
||||
AddedAt time.Time
|
||||
RemovedBy uint
|
||||
RemovedName string `gorm:"size:100"`
|
||||
RemoveReason string `gorm:"size:100"`
|
||||
RemovedAt *time.Time `gorm:"index:idx_alert_exempt_owner,priority:2;index:idx_alert_exempt_channel,priority:3"`
|
||||
}
|
||||
|
||||
func (alertExemptionPO) TableName() string { return "sys_alert_exemption" }
|
||||
|
||||
// 所有渠道豁免变更和预警生成共用渠道锁,防止已加入豁免后仍插入新预警。
|
||||
type alertChannelLockPO struct {
|
||||
SourceDB string `gorm:"primaryKey;size:50"`
|
||||
PromoteID uint64 `gorm:"primaryKey;autoIncrement:false"`
|
||||
}
|
||||
|
||||
func (alertChannelLockPO) TableName() string { return "sys_alert_channel_lock" }
|
||||
func lockAlertChannel(tx *gorm.DB, source string, id uint64) error {
|
||||
key := alertChannelLockPO{SourceDB: source, PromoteID: id}
|
||||
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&key).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("source_db=? AND promote_id=?", source, id).Take(&key).Error
|
||||
}
|
||||
func toBizAlertExemption(p alertExemptionPO) *business.AlertExemption {
|
||||
return &business.AlertExemption{ID: p.ID, ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}, ChannelName: p.ChannelName, OwnerID: p.OwnerID, AddedBy: p.AddedBy, AddedName: p.AddedName, Reason: p.Reason, AddedAt: p.AddedAt, RemovedBy: p.RemovedBy, RemovedName: p.RemovedName, RemoveReason: p.RemoveReason, RemovedAt: p.RemovedAt}
|
||||
}
|
||||
|
||||
type alertGovernanceRepo struct{ data Provider }
|
||||
|
||||
func NewAlertGovernanceRepo(data Provider) business.AlertGovernanceRepo {
|
||||
return &alertGovernanceRepo{data}
|
||||
}
|
||||
|
||||
func (r *alertGovernanceRepo) Candidates(ctx context.Context, scope business.UserScope, keyword string, limit int) ([]*business.AlertChannelOption, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
bindings := db.Table("sys_user_bind_platform b").Select("1").Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL").Where("b.status='0' AND b.source_db=p.source_db AND b.platform_busier_id=p.busier_id")
|
||||
bindings = applyUserScope(bindings, scope, "u.")
|
||||
q := db.Table("tab_promote p").Where("p.promote_level=1 AND p.source_db IN ?", []string{"lx", "hw", "yq"}).Where("EXISTS (?)", bindings)
|
||||
if keyword != "" {
|
||||
q = q.Where("p.account LIKE ?", "%"+keyword+"%")
|
||||
}
|
||||
var rows []struct {
|
||||
ID uint64
|
||||
SourceDB, Name string
|
||||
}
|
||||
if err := q.Select("p.id,p.source_db,p.account AS name").Order("p.source_db,p.account,p.id").Limit(limit).Scan(&rows).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
result := make([]*business.AlertChannelOption, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
result = append(result, &business.AlertChannelOption{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}, Name: row.Name})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (r *alertGovernanceRepo) Gap(ctx context.Context, id uint) (*business.AlertGap, error) {
|
||||
var p alertGapPO
|
||||
err := r.data.DB().WithContext(ctx).Where("owner_id=?", id).Take(&p).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &business.AlertGap{OwnerID: id, Days: 1}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
return &business.AlertGap{OwnerID: p.OwnerID, Enabled: p.Enabled, Days: p.Days}, nil
|
||||
}
|
||||
func (r *alertGovernanceRepo) SaveGap(ctx context.Context, gap *business.AlertGap) error {
|
||||
p := alertGapPO{OwnerID: gap.OwnerID, Enabled: gap.Enabled, Days: gap.Days, UpdatedAt: time.Now()}
|
||||
return storageError(r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "owner_id"}}, DoUpdates: clause.AssignmentColumns([]string{"enabled", "days", "updated_at"})}).Create(&p).Error)
|
||||
}
|
||||
func (r *alertGovernanceRepo) Exemptions(ctx context.Context, scope business.LevelOneScope, filter business.AlertExemptionFilter, opts ...business.ListOption) ([]*business.AlertExemption, int64, error) {
|
||||
q := applyOwnerScope(r.data.DB().WithContext(ctx).Model(&alertExemptionPO{}), scope, "owner_id")
|
||||
if filter.Name != "" {
|
||||
q = q.Where("channel_name LIKE ?", "%"+filter.Name+"%")
|
||||
}
|
||||
if filter.Active != nil {
|
||||
if *filter.Active {
|
||||
q = q.Where("removed_at IS NULL")
|
||||
} else {
|
||||
q = q.Where("removed_at IS NOT NULL")
|
||||
}
|
||||
}
|
||||
var total int64
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, business.ErrStorage
|
||||
}
|
||||
o := business.NewListOptions(opts...)
|
||||
var pos []alertExemptionPO
|
||||
if err := q.Order("id DESC").Offset(o.Offset).Limit(o.Limit).Find(&pos).Error; err != nil {
|
||||
return nil, 0, business.ErrStorage
|
||||
}
|
||||
rows := make([]*business.AlertExemption, 0, len(pos))
|
||||
for _, p := range pos {
|
||||
rows = append(rows, toBizAlertExemption(p))
|
||||
}
|
||||
return rows, total, nil
|
||||
}
|
||||
func alertExemptionTarget(db *gorm.DB, scope business.UserScope, key business.ChannelKey) (*business.AlertExemptionTarget, error) {
|
||||
q := db.Table("tab_promote p").Joins("JOIN sys_user_bind_platform b ON b.source_db=p.source_db AND b.platform_busier_id=p.busier_id AND b.status='0'").Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL").Where("p.source_db=? AND p.id=? AND p.promote_level=1", key.SourceDB, key.ID)
|
||||
allowed := applyUserScope(db.Table("sys_users viewer").Select("1").Where("viewer.id=u.id"), scope, "viewer.")
|
||||
var pos []struct {
|
||||
Name string
|
||||
UserID uint
|
||||
Level int
|
||||
IDPath string
|
||||
Allowed bool
|
||||
}
|
||||
if err := q.Clauses(clause.Locking{Strength: "UPDATE"}).Select("p.account AS name,u.id AS user_id,u.level,u.id_path,EXISTS (?) AS allowed", allowed).Distinct().Limit(2).Scan(&pos).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if len(pos) != 1 || !pos[0].Allowed {
|
||||
return nil, business.ErrAlertRecordScope
|
||||
}
|
||||
p := pos[0]
|
||||
return &business.AlertExemptionTarget{Name: p.Name, Account: &business.Account{ID: p.UserID, Level: p.Level, IDPath: p.IDPath}}, nil
|
||||
}
|
||||
func (r *alertGovernanceRepo) AddExemption(ctx context.Context, scope business.UserScope, key business.ChannelKey, build func(*business.AlertExemptionTarget) (*business.AlertExemption, error)) error {
|
||||
return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := lockAlertChannel(tx, key.SourceDB, key.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
target, err := alertExemptionTarget(tx, scope, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
value, err := build(target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var count int64
|
||||
if err := tx.Model(&alertExemptionPO{}).Where("source_db=? AND promote_id=? AND owner_id=? AND removed_at IS NULL", value.SourceDB, value.ChannelKey.ID, value.OwnerID).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return business.ErrAlertExemptExists
|
||||
}
|
||||
p := alertExemptionPO{SourceDB: value.SourceDB, PromoteID: value.ChannelKey.ID, ChannelName: value.ChannelName, OwnerID: value.OwnerID, AddedBy: value.AddedBy, AddedName: value.AddedName, Reason: value.Reason, AddedAt: value.AddedAt}
|
||||
return tx.Create(&p).Error
|
||||
}))
|
||||
}
|
||||
func (r *alertGovernanceRepo) RemoveExemption(ctx context.Context, scope business.LevelOneScope, id uint64, operator uint, name, reason string, now time.Time) error {
|
||||
return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var p alertExemptionPO
|
||||
if err := applyOwnerScope(tx.Model(&alertExemptionPO{}), scope, "owner_id").Where("id=? AND removed_at IS NULL", id).Take(&p).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return business.ErrAlertExemptNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := lockAlertChannel(tx, p.SourceDB, p.PromoteID); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Model(&alertExemptionPO{}).Where("id=? AND removed_at IS NULL", id).Updates(map[string]any{"removed_at": now, "removed_by": operator, "removed_name": name, "remove_reason": reason})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return business.ErrAlertExemptNotFound
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
}
|
||||
|
||||
// 按渠道等值、成功支付及时间范围索引读取,避免为每条预警全表统计历史流水。
|
||||
func alertLostQuery(db *gorm.DB, sourceColumn, idColumn string, window business.AlertLossWindow) *gorm.DB {
|
||||
pay := db.Table("tab_spend ls").Select("COALESCE(SUM(ls.pay_amount),0)").Where("ls.source_db=lp.source_db AND ls.promote_id=lp.id AND ls.pay_status=1 AND ls.pay_time>=? AND ls.pay_time<?", window.PayBegin, window.PayEnd)
|
||||
return db.Table("tab_promote lp").Select("1").Where("lp.source_db="+sourceColumn+" AND lp.id="+idColumn).
|
||||
Where("(COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)>0 AND COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)<?) OR (?) < CAST(? AS DECIMAL(20,2))", window.LoginBefore, pay, window.MinPay)
|
||||
}
|
||||
func (r *alertGovernanceRepo) LostExemptions(ctx context.Context, window business.AlertLossWindow) ([]*business.AlertExemption, error) {
|
||||
db := r.data.DB().WithContext(ctx)
|
||||
var pos []alertExemptionPO
|
||||
// 没有在名单渠道时无需读取平台明细。
|
||||
if err := db.Select("id").Where("removed_at IS NULL").Limit(1).Find(&pos).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if len(pos) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
available, err := businessTablesAvailable(db, "tab_promote", "tab_spend")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !available {
|
||||
return nil, business.ErrAlertChannelsUnavailable
|
||||
}
|
||||
result := []*business.AlertExemption{}
|
||||
var cursor uint64
|
||||
for {
|
||||
var batch []alertExemptionPO
|
||||
if err := db.Where("removed_at IS NULL AND id>?", cursor).Order("id").Limit(500).Find(&batch).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
if len(batch) == 0 {
|
||||
break
|
||||
}
|
||||
keys := make([]business.ChannelKey, 0, len(batch))
|
||||
for _, p := range batch {
|
||||
keys = append(keys, business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID})
|
||||
}
|
||||
states, err := loadAlertLoss(db, keys, window)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range batch {
|
||||
if lost := states[business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}]; lost != nil && *lost {
|
||||
result = append(result, toBizAlertExemption(p))
|
||||
}
|
||||
}
|
||||
cursor = batch[len(batch)-1].ID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// 同一页的重复渠道只计算一次;按固定平台、渠道集合和时间范围扫描覆盖索引。
|
||||
func loadAlertLoss(db *gorm.DB, keys []business.ChannelKey, window business.AlertLossWindow) (map[business.ChannelKey]*bool, error) {
|
||||
bySource := map[string][]uint64{}
|
||||
seen := map[business.ChannelKey]bool{}
|
||||
for _, key := range keys {
|
||||
if !seen[key] {
|
||||
bySource[key.SourceDB] = append(bySource[key.SourceDB], key.ID)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
result := map[business.ChannelKey]*bool{}
|
||||
for source, ids := range bySource {
|
||||
for start := 0; start < len(ids); start += 500 {
|
||||
batch := ids[start:min(start+500, len(ids))]
|
||||
var channels []struct {
|
||||
ID uint64
|
||||
LastLoginTime, CreateTime int64
|
||||
}
|
||||
if err := db.Table("tab_promote").Select("id,last_login_time,create_time").Where("source_db=? AND id IN ?", source, batch).Scan(&channels).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
var amounts []struct {
|
||||
PromoteID uint64
|
||||
RecentAmount string
|
||||
}
|
||||
if err := db.Table("tab_spend").Select("promote_id,COALESCE(SUM(pay_amount),0) AS recent_amount").Where("source_db=? AND promote_id IN ? AND pay_status=1 AND pay_time>=? AND pay_time<?", source, batch, window.PayBegin, window.PayEnd).Group("promote_id").Scan(&amounts).Error; err != nil {
|
||||
return nil, business.ErrStorage
|
||||
}
|
||||
payments := map[uint64]string{}
|
||||
for _, amount := range amounts {
|
||||
payments[amount.PromoteID] = amount.RecentAmount
|
||||
}
|
||||
for _, channel := range channels {
|
||||
amount := payments[channel.ID]
|
||||
if amount == "" {
|
||||
amount = "0"
|
||||
}
|
||||
lost, err := window.IsLost(channel.LastLoginTime, channel.CreateTime, amount)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[business.ChannelKey{SourceDB: source, ID: channel.ID}] = &lost
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
Reference in New Issue
Block a user