301 lines
13 KiB
Go
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
|
|
}
|