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_time0 AND COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)?", 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