package business import ( "context" "encoding/json" "errors" "sort" "strconv" "time" "gorm.io/gorm" "gorm.io/gorm/clause" "kra/internal/biz/business" ) type alertRecordRepo struct{ data Provider } func NewAlertRecordRepo(data Provider) business.AlertRecordRepo { return &alertRecordRepo{data: data} } func (r *alertRecordRepo) Platforms(ctx context.Context, scope business.AlertRecordScope) ([]string, error) { rows := []string{} if scope.Owner.Empty { return rows, nil } err := alertBindingQuery(r.data.DB().WithContext(ctx), scope.Viewer).Distinct().Order("b.source_db").Pluck("b.source_db", &rows).Error return rows, storageError(err) } func toBizAlertRecord(po alertRecordPO) *business.AlertRecord { return &business.AlertRecord{ID: po.ID, TotalPlayers: po.TotalPlayers, TotalPay: po.TotalPay, WarningColor: po.WarningColor, RuleID: po.RuleID, PlatformCode: po.PlatformCode, PlatformName: po.PlatformName, RuleName: po.RuleName, RuleType: po.RuleType, MonitorMetric: po.MonitorMetric, TargetPromoteID: po.TargetPromoteID, TargetPromoteName: po.TargetPromoteName, BusinessID: po.BusinessID, BusinessName: po.BusinessName, AlertContent: po.AlertContent, Status: po.Status, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt} } func alertBindingQuery(db *gorm.DB, scope business.UserScope) *gorm.DB { q := db.Table("sys_user_bind_platform b").Where("b.status = ? AND b.source_db IN ? AND b.platform_busier_id IS NOT NULL", "0", []string{"lx", "hw", "yq"}) if !scope.All { q = q.Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL") q = applyUserScope(q, scope, "u.") } return q } func alertChannelScopes(scope business.AlertRecordScope) []business.UserScope { var scopes []business.UserScope if !scope.Viewer.All { scopes = append(scopes, scope.Viewer) } if scope.Selected != nil { scopes = append(scopes, *scope.Selected) } return scopes } // 先判断没有绑定的确定空集,再检查渠道表;缺表不能放宽权限或伪造统计。 func prepareAlertScope(db *gorm.DB, scope business.AlertRecordScope) (empty, available bool, err error) { if scope.Owner.Empty { return true, true, nil } scopes := alertChannelScopes(scope) for _, s := range scopes { if s.Empty { return true, true, nil } var bindings []struct{ UserID uint } if err := alertBindingQuery(db, s).Select("b.user_id").Limit(1).Scan(&bindings).Error; err != nil { return false, false, business.ErrStorage } if len(bindings) == 0 { return true, true, nil } } if len(scopes) == 0 { return false, true, nil } available, err = businessTableAvailable(db, "tab_promote") return false, available, err } func alertScopedQuery(db *gorm.DB, scope business.AlertRecordScope) *gorm.DB { q := applyOwnerScope(db.Table("sys_alert_record r"), scope.Owner, "r.business_id") for _, s := range alertChannelScopes(scope) { bindings := alertBindingQuery(db, s).Select("1").Where("b.source_db=p.source_db AND b.platform_busier_id=p.busier_id") // 两个范围独立取交集;只限制一级渠道,停用渠道的历史预警仍可见。 channels := db.Table("tab_promote p").Select("1").Where("p.id=r.target_promote_id AND p.source_db=r.platform_code AND p.promote_level = ?", 1).Where("EXISTS (?)", bindings) q = q.Where("EXISTS (?)", channels) } return q } func (r *alertRecordRepo) List(ctx context.Context, scope business.AlertRecordScope, filter business.AlertRecordFilter, options ...business.ListOption) (*business.AlertRecordPage, error) { db := r.data.DB().WithContext(ctx) page := &business.AlertRecordPage{Rows: []*business.AlertRecord{}, StatusCount: map[int]int64{}, Available: true} empty, available, err := prepareAlertScope(db, scope) if err != nil { return nil, err } page.Available = available if empty || !available { return page, nil } if filter.PlatformBusierID != nil { available, err := businessTableAvailable(db, "tab_promote") if err != nil { return nil, err } if !available { page.Available = false return page, nil } } lossAvailable, err := businessTablesAvailable(db, "tab_promote", "tab_spend") if err != nil { return nil, err } if filter.Lost != nil && !lossAvailable { return nil, business.ErrAlertChannelsUnavailable } lossWindow := business.AlertLossAt(time.Now()) base := func() *gorm.DB { q := alertScopedQuery(db, scope) if len(filter.PlatformCodes) > 0 { q = q.Where("r.platform_code IN ?", filter.PlatformCodes) } if filter.TargetPromoteID != nil { q = q.Where("r.target_promote_id = ?", *filter.TargetPromoteID) } if filter.PlatformBusierID != nil { q = q.Where("EXISTS (?)", db.Table("tab_promote bp").Select("1").Where("bp.id=r.target_promote_id AND bp.source_db=r.platform_code AND bp.busier_id=?", *filter.PlatformBusierID)) } return q } filtered := func() *gorm.DB { q := base() for _, f := range []struct{ column, value string }{{"target_promote_name", filter.TargetPromoteName}, {"rule_name", filter.RuleName}, {"business_name", filter.BusinessName}} { if f.value != "" { q = q.Where("r."+f.column+" LIKE ?", "%"+f.value+"%") } } if filter.RuleType != "" { q = q.Where("r.rule_type = ?", filter.RuleType) } if filter.MonitorMetric != nil { q = q.Where("r.monitor_metric = ?", *filter.MonitorMetric) } if filter.AlertContent != "" { q = q.Where("r.alert_content LIKE ?", "%"+filter.AlertContent+"%") } if filter.Lost != nil { q = q.Where("EXISTS (?)", db.Table("tab_promote known").Select("1").Where("known.source_db=r.platform_code AND known.id=r.target_promote_id")) q = q.Where("EXISTS (?) = ?", alertLostQuery(db, "r.platform_code", "r.target_promote_id", lossWindow), *filter.Lost) } return q } q := filtered() if filter.Status != nil { q = q.Where("r.status = ?", *filter.Status) } // 六态卡片继承业务筛选,忽略状态自身,使切换状态时其他计数仍可见。 var counts []struct { Status int Count int64 } if err := filtered().Select("r.status,COUNT(*) AS count").Group("r.status").Scan(&counts).Error; err != nil { return nil, business.ErrStorage } for _, c := range counts { page.StatusCount[c.Status] = c.Count } for status, count := range page.StatusCount { if filter.Status == nil || status == *filter.Status { page.Total += count } } o := business.NewListOptions(options...) var pos []alertRecordPO if err := q.Select("r.*").Order("r.created_at DESC,r.id DESC").Offset(o.Offset).Limit(o.Limit).Scan(&pos).Error; err != nil { return nil, business.ErrStorage } for _, po := range pos { page.Rows = append(page.Rows, toBizAlertRecord(po)) } if lossAvailable && len(pos) > 0 { keys := make([]business.ChannelKey, 0, len(pos)) for _, p := range pos { keys = append(keys, business.ChannelKey{SourceDB: p.PlatformCode, ID: p.TargetPromoteID}) } states, err := loadAlertLoss(db, keys, lossWindow) if err != nil { return nil, err } for _, row := range page.Rows { row.Lost = states[business.ChannelKey{SourceDB: row.PlatformCode, ID: row.TargetPromoteID}] } } return page, nil } func (r *alertRecordRepo) Ignore(ctx context.Context, scope business.AlertRecordScope, ids []uint64, log *business.AlertLog) error { ids = append([]uint64(nil), ids...) sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { empty, available, err := prepareAlertScope(tx, scope) if err != nil { return err } if empty { return business.ErrAlertRecordScope } if !available { return business.ErrAlertChannelsUnavailable } var rows []alertRecordPO if err := alertScopedQuery(tx, scope).Select("r.*").Where("r.id IN ?", ids).Order("r.id").Clauses(clause.Locking{Strength: "UPDATE"}).Scan(&rows).Error; err != nil { return err } unique := map[uint64]bool{} for _, id := range ids { unique[id] = true } if len(rows) != len(unique) { return business.ErrAlertRecordScope } now := time.Now() logs := []alertLogPO{} changed := []uint64{} for _, row := range rows { if row.Status == business.AlertIgnored { continue } changed = append(changed, row.ID) logs = append(logs, alertLogPO{AlertID: row.ID, OperatorID: log.OperatorID, OperatorName: log.OperatorName, LogType: log.LogType, ActionDetail: log.ActionDetail, CreatedAt: now}) } if len(changed) == 0 { return nil } if err := tx.Model(&alertRecordPO{}).Where("id IN ?", changed).Updates(map[string]any{"status": business.AlertIgnored, "updated_at": now}).Error; err != nil { return err } return tx.CreateInBatches(logs, 100).Error })) } func findScopedAlert(db *gorm.DB, scope business.AlertRecordScope, id uint64, lock bool) (*alertRecordPO, error) { empty, available, err := prepareAlertScope(db, scope) if err != nil { return nil, err } if empty { return nil, business.ErrAlertRecordScope } if !available { return nil, business.ErrAlertChannelsUnavailable } // 锁定读取本身带权限条件,避免先读旧快照的权限再锁到已换归属的记录。 q := alertScopedQuery(db, scope).Select("r.*") if lock { q = q.Clauses(clause.Locking{Strength: "UPDATE"}) } var po alertRecordPO if err := q.Where("r.id = ?", id).Take(&po).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { var count int64 if err := db.Model(&alertRecordPO{}).Where("id = ?", id).Count(&count).Error; err != nil { return nil, business.ErrStorage } if count > 0 { return nil, business.ErrAlertRecordScope } return nil, business.ErrAlertRecordNotFound } return nil, business.ErrStorage } return &po, nil } func (r *alertRecordRepo) Detail(ctx context.Context, scope business.AlertRecordScope, id uint64) (*business.AlertRecordDetail, error) { var detail *business.AlertRecordDetail err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { po, err := findScopedAlert(tx, scope, id, false) if err != nil { return err } logs, err := alertLogs(tx, id) if err != nil { return err } detail = &business.AlertRecordDetail{Record: toBizAlertRecord(*po), Logs: logs} return nil }) return detail, storageError(err) } func (r *alertRecordRepo) WithRecord(ctx context.Context, scope business.AlertRecordScope, id uint64, fn func(*business.AlertRecord) ([]*business.AlertLog, error)) error { return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { po, err := findScopedAlert(tx, scope, id, true) if err != nil { return err } record := toBizAlertRecord(*po) logs, err := fn(record) if err != nil { return err } now := time.Now() if record.Status != po.Status { result := tx.Model(&alertRecordPO{}).Where("id = ? AND status = ?", id, po.Status).Updates(map[string]any{"status": record.Status, "updated_at": now}) if result.Error != nil { return result.Error } if result.RowsAffected != 1 { return business.ErrAlertRecordChanged } } for _, log := range logs { attachments := encodeAlertAttachments(log.AttachmentIDs) row := alertLogPO{AlertID: id, OperatorID: log.OperatorID, OperatorName: log.OperatorName, LogType: log.LogType, ActionDetail: log.ActionDetail, NoteContent: log.NoteContent, AttachmentIDs: attachments, CreatedAt: now} if err := tx.Create(&row).Error; err != nil { return err } } return nil })) } func encodeAlertAttachments(values []string) *string { if len(values) == 0 { return nil } ids := make([]int64, 0, len(values)) for _, value := range values { id, err := strconv.ParseInt(value, 10, 64) if err != nil { ids = nil break } ids = append(ids, id) } var raw []byte if ids != nil { raw, _ = json.Marshal(ids) } else { raw, _ = json.Marshal(values) } value := string(raw) return &value } func decodeAlertAttachments(raw *string) (values []string, ids []int64) { if raw == nil { return nil, nil } var entries []json.RawMessage if json.Unmarshal([]byte(*raw), &entries) != nil { return nil, nil } allNumeric := true for _, entry := range entries { var value string if json.Unmarshal(entry, &value) != nil { var n json.Number if json.Unmarshal(entry, &n) != nil || n == "" { return nil, nil } value = n.String() } values = append(values, value) id, err := strconv.ParseInt(value, 10, 64) if err != nil { allNumeric = false } else { ids = append(ids, id) } } if !allNumeric { ids = nil } return values, ids } func alertLogs(db *gorm.DB, alertID uint64) ([]*business.AlertLog, error) { var pos []alertLogPO if err := db.Where("alert_id = ?", alertID).Order("created_at DESC,id DESC").Find(&pos).Error; err != nil { return nil, business.ErrStorage } logs := make([]*business.AlertLog, 0, len(pos)) idsByLog := map[uint64][]int64{} ids := map[int64]bool{} for _, po := range pos { values, numeric := decodeAlertAttachments(po.AttachmentIDs) log := &business.AlertLog{ID: po.ID, AlertID: po.AlertID, OperatorID: po.OperatorID, OperatorName: po.OperatorName, LogType: po.LogType, ActionDetail: po.ActionDetail, NoteContent: po.NoteContent, CreatedAt: po.CreatedAt, Attachments: []string{}} if len(numeric) > 0 { idsByLog[po.ID] = numeric for _, id := range numeric { ids[id] = true } } else if len(values) > 0 { log.Attachments = values } logs = append(logs, log) } if len(ids) == 0 { return logs, nil } keys := make([]int64, 0, len(ids)) for id := range ids { keys = append(keys, id) } // 一次批量查询代替旧实现逐日志、逐附件查询。 urls := map[int64]string{} for start := 0; start < len(keys); start += 500 { var files []struct { ID int64 URL string } if err := db.Table("media_file_upload_and_downloads").Select("id,url").Where("id IN ? AND deleted_at IS NULL", keys[start:min(start+500, len(keys))]).Scan(&files).Error; err != nil { return nil, business.ErrStorage } for _, file := range files { urls[file.ID] = file.URL } } for _, log := range logs { seen := map[int64]bool{} for _, id := range idsByLog[log.ID] { if url, exists := urls[id]; exists && !seen[id] { log.Attachments = append(log.Attachments, url) seen[id] = true } } } return logs, nil }