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

301 lines
13 KiB
Go

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
}
// Login expiry already establishes loss; only the remaining channels
// need the potentially expensive payment scan on a cold database.
paymentIDs := make([]uint64, 0, len(channels))
for _, channel := range channels {
if !window.LoginExpired(channel.LastLoginTime, channel.CreateTime) {
paymentIDs = append(paymentIDs, channel.ID)
}
}
var amounts []struct {
PromoteID uint64
RecentAmount string
}
if len(paymentIDs) > 0 {
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, paymentIDs, 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
}