package business import ( "context" "fmt" "kra/pkg/task" "log/slog" "math/big" "sort" "strings" "time" ) type AlertTriggerChannel struct { ChannelKey Account string BusierID uint64 LastLoginUnix, CreatedUnix int64 } type AlertTriggerInput struct { Exemptions map[AlertExemptionKey]bool Gaps map[uint]*AlertGap Dedup map[uint]bool Rules []*AlertRule Accounts []*Account Bindings []*Binding Channels []*AlertTriggerChannel } type AlertExemptionKey struct { ChannelKey OwnerID uint } type AlertSnapshot struct { Players int64 Pay string } type AlertComparison struct { FirstUnix int64 Current, Previous string } type AlertTriggerRepo interface { FilterWritable(context.Context, []*AlertRecord, []int, func(*AlertRecord) bool) ([]*AlertRecord, error) Snapshots(context.Context, []ChannelKey, time.Time, bool) (map[ChannelKey]AlertSnapshot, error) Input(context.Context) (*AlertTriggerInput, error) Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error) // 仓储在渠道/分类键锁及事务内读取未关闭记录,由领域决定是否刷新。 StoreHit(context.Context, *AlertRecord, []int, func(*AlertRecord) bool) (bool, error) } type AlertTriggerResult struct{ Rules, Written, Skipped int } type AlertTriggerUsecase struct { repo AlertTriggerRepo governance *AlertGovernanceUsecase } func NewAlertTriggerUsecase(repo AlertTriggerRepo, governance *AlertGovernanceUsecase) *AlertTriggerUsecase { return &AlertTriggerUsecase{repo: repo, governance: governance} } func (uc *AlertTriggerUsecase) Run(ctx context.Context) error { _, err := uc.Detect(ctx, time.Now()) return err } func validTriggerRule(rule *AlertRule) bool { if rule == nil || rule.CreatorID == 0 || strings.TrimSpace(rule.Type) == "" { return false } if rule.Metric == 2 && rule.ConditionMode == 2 { copy := *rule return validateAlertRule(©) == nil } if rule.Metric == 3 { return rule.UnloginDays != nil && *rule.UnloginDays > 0 } if rule.Metric != 1 && rule.Metric != 2 { return false } if rule.StatPeriodDays == nil || *rule.StatPeriodDays <= 0 || rule.MomDirection == nil || (*rule.MomDirection != 1 && *rule.MomDirection != 2) || rule.MomThreshold == nil { return false } threshold, ok := new(big.Rat).SetString(*rule.MomThreshold) // 历史规则检测允许零阈值,保持与旧任务一致;新增规则另有边界校验。 return ok && threshold.Sign() >= 0 } func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]*Account) { accounts := map[uint]*Account{} for _, account := range input.Accounts { if account != nil { accounts[account.ID] = account } } bindings := map[PlatformKey]uint{} conflicts := map[PlatformKey]bool{} for _, binding := range input.Bindings { if binding == nil || binding.Status != "0" || binding.UserID == 0 || binding.BusierID == 0 { continue } source, err := NormalizePlatform(binding.SourceDB) if err != nil { continue } key := PlatformKey{SourceDB: source, BusierID: binding.BusierID} if existing, ok := bindings[key]; ok && existing != binding.UserID { conflicts[key] = true } bindings[key] = binding.UserID } owners := map[ChannelKey]*Account{} for _, channel := range input.Channels { if channel == nil || channel.ID == 0 { continue } source, err := NormalizePlatform(channel.SourceDB) if err != nil { continue } channel.SourceDB = source key := PlatformKey{SourceDB: source, BusierID: channel.BusierID} account := accounts[bindings[key]] if conflicts[key] || account == nil || account.Level < 1 || account.Level > 3 { continue } scope := LevelOneOwnerForAccount(account) owner := accounts[scope.UserID] if scope.Empty || owner == nil || owner.Level != 1 { continue } owners[channel.ChannelKey] = owner } return owners, accounts } // 一次执行固定上海自然日,避免跨午夜时同一任务的各规则使用不同的比较窗口。 func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*AlertTriggerResult, error) { result := &AlertTriggerResult{} if uc.governance != nil { if err := uc.governance.Reconcile(ctx, now); err != nil { return result, err } } input, err := uc.repo.Input(ctx) if err != nil { return result, err } if input == nil { return result, ErrStorage } task.Logf(ctx, "预警检测:规则=%d;候选一级渠道=%d;商务账号=%d", len(input.Rules), len(input.Channels), len(input.Accounts)) defer func() { task.Logf(ctx, "预警检测结果:已检测规则=%d;跳过规则=%d;写入预警=%d", result.Rules, result.Skipped, result.Written) }() zone := time.FixedZone("Asia/Shanghai", 8*3600) now = now.In(zone) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, zone) owners, accounts := triggerOwners(input) type snapshotKey struct { creator uint metric, days int } snapshots := map[snapshotKey]map[ChannelKey]AlertComparison{} var failures []string pending := map[uint][]*AlertRecord{} for _, rule := range input.Rules { if err := ctx.Err(); err != nil { return result, err } if !validTriggerRule(rule) { result.Skipped++ continue } creator := accounts[rule.CreatorID] if creator == nil || (creator.AuthorityID != 888 && creator.Level != 1) { result.Skipped++ continue } channels := []*AlertTriggerChannel{} keys := []ChannelKey{} for _, channel := range input.Channels { if channel == nil { continue } owner := owners[channel.ChannelKey] if owner != nil && (creator.AuthorityID == 888 || owner.ID == creator.ID) { if input.Exemptions[AlertExemptionKey{ChannelKey: channel.ChannelKey, OwnerID: owner.ID}] { continue } channels = append(channels, channel) keys = append(keys, channel.ChannelKey) } } if len(channels) == 0 { continue } result.Rules++ var snapshot map[ChannelKey]AlertComparison if rule.Metric != 3 { key := snapshotKey{creator.ID, rule.Metric, *rule.StatPeriodDays} var exists bool snapshot, exists = snapshots[key] if !exists { snapshot, err = uc.repo.Comparison(ctx, rule.Metric, keys, today, key.days) if err != nil { failures = append(failures, fmt.Sprintf("规则 %d 读取指标失败: %v", rule.ID, err)) continue } snapshots[key] = snapshot } } for _, channel := range channels { if err := ctx.Err(); err != nil { return result, err } content, hit, err := triggerContent(rule, channel, snapshot[channel.ChannelKey], today) if err != nil { failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d: %v", rule.ID, channel.SourceDB, channel.ID, err)) continue } if !hit { continue } owner := owners[channel.ChannelKey] record := &AlertRecord{RuleID: rule.ID, RuleName: rule.Name, RuleType: strings.TrimSpace(rule.Type), MonitorMetric: &rule.Metric, PlatformCode: channel.SourceDB, PlatformName: PlatformName(channel.SourceDB), TargetPromoteID: channel.ID, TargetPromoteName: channel.Account, BusinessID: owner.ID, BusinessName: owner.Username, AlertContent: content, Status: AlertUnprocessed, CreatedAt: now, UpdatedAt: now} record.WarningColor = &rule.WarningColor record.IndependentRule = rule.Metric == 2 && rule.ConditionMode == 2 ownerScope := alertUserScope(owner) record.OwnerScope = &ownerScope if gap := input.Gaps[creator.ID]; gap != nil && gap.Enabled { record.GapDays = gap.Days } pending[creator.ID] = append(pending[creator.ID], record) } } creators := make([]uint, 0, len(pending)) for creator := range pending { creators = append(creators, creator) } sort.Slice(creators, func(i, j int) bool { return creators[i] < creators[j] }) for _, creator := range creators { hits := pending[creator] openStatuses := []int{AlertUnprocessed, AlertProcessing, AlertPendingAudit, AlertAuditRejected} canWrite := func(current *AlertRecord) bool { return current == nil || current.Status == AlertUnprocessed } hits, err = uc.repo.FilterWritable(ctx, hits, openStatuses, canWrite) if err != nil { failures = append(failures, fmt.Sprintf("商务 %d 读取在途预警失败: %v", creator, err)) continue } if len(hits) == 0 { continue } keys := []ChannelKey{} seen := map[ChannelKey]bool{} for _, hit := range hits { key := ChannelKey{SourceDB: hit.PlatformCode, ID: hit.TargetPromoteID} if !seen[key] { keys = append(keys, key) seen[key] = true } } dedup, ok := input.Dedup[creator] if !ok { dedup = true } totals, err := uc.repo.Snapshots(ctx, keys, now, dedup) if err != nil { failures = append(failures, fmt.Sprintf("商务 %d 读取预警快照失败: %v", creator, err)) continue } for _, record := range hits { total, ok := totals[ChannelKey{SourceDB: record.PlatformCode, ID: record.TargetPromoteID}] if !ok { failures = append(failures, "预警快照缺失") continue } record.TotalPlayers = &total.Players record.TotalPay = &total.Pay written, err := uc.repo.StoreHit(ctx, record, openStatuses, canWrite) if err != nil { failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d 写入失败: %v", record.RuleID, record.PlatformCode, record.TargetPromoteID, err)) continue } if written { result.Written++ } } } if len(failures) > 0 { for _, failure := range failures { slog.ErrorContext(ctx, "预警检测失败", "detail", failure) } return result, fmt.Errorf("预警规则检测存在 %d 项失败:%s", len(failures), strings.Join(failures[:min(5, len(failures))], ";")) } return result, nil } func triggerContent(rule *AlertRule, channel *AlertTriggerChannel, sample AlertComparison, today time.Time) (string, bool, error) { if rule.Metric == 3 { stamp := channel.LastLoginUnix if stamp <= 0 { stamp = channel.CreatedUnix } if stamp <= 0 { return "", false, nil } day := time.Unix(stamp, 0).In(today.Location()) day = time.Date(day.Year(), day.Month(), day.Day(), 0, 0, 0, 0, today.Location()) days := int(today.Sub(day) / (24 * time.Hour)) if days < *rule.UnloginDays { return "", false, nil } return fmt.Sprintf("连续%d天未登录", days), true, nil } if rule.Metric == 2 && rule.ConditionMode == 2 { amount, err := portraitNumber(sample.Current) if err != nil { return "", false, err } threshold, ok := new(big.Rat).SetString(*rule.AmountThreshold) if !ok { return "", false, ErrAlertRuleInput } reached := amount.Cmp(threshold) >= 0 if (rule.AmountDirection == 1 && !reached) || (rule.AmountDirection == 2 && reached) { return "", false, nil } direction := "满" if rule.AmountDirection == 2 { direction = "未满" } return fmt.Sprintf("截至昨日%d个完整自然日充值¥%s,%s¥%s(汇总子渠道)", *rule.StatPeriodDays, portraitDecimal(amount, 2), direction, *rule.AmountThreshold), true, nil } days := *rule.StatPeriodDays if sample.FirstUnix <= 0 || time.Unix(sample.FirstUnix, 0).In(today.Location()).Format("2006-01-02") > today.AddDate(0, 0, -2*days).Format("2006-01-02") { return "", false, nil } previous, err := portraitNumber(sample.Previous) if err != nil { return "", false, err } if previous.Sign() <= 0 { return "", false, nil } current, err := portraitNumber(sample.Current) if err != nil { return "", false, err } rate := new(big.Rat).Quo(new(big.Rat).Sub(current, previous), previous) // 先对比值保留四位再转百分比,与旧 BigDecimal HALF_UP 的阈值边界一致。 rate, _ = new(big.Rat).SetString(portraitDecimal(rate, 4)) rate.Mul(rate, big.NewRat(100, 1)) threshold, _ := new(big.Rat).SetString(*rule.MomThreshold) if (*rule.MomDirection == 1 && rate.Cmp(threshold) < 0) || (*rule.MomDirection == 2 && rate.Cmp(new(big.Rat).Neg(threshold)) > 0) { return "", false, nil } direction := "上升" if rate.Sign() < 0 { direction = "下降" } percent := portraitDecimal(new(big.Rat).Abs(rate), 2) if rule.Metric == 1 { return fmt.Sprintf("截至昨日%d个完整自然日注册%s人,较此前%d个完整自然日(%s人)环比%s%s%%", days, portraitDecimal(current, 0), days, portraitDecimal(previous, 0), direction, percent), true, nil } yuan := big.NewRat(10000, 1) return fmt.Sprintf("截至昨日%d个完整自然日充值¥%s万,较此前%d个完整自然日(¥%s万)环比%s%s%%", days, portraitDecimal(new(big.Rat).Quo(current, yuan), 2), days, portraitDecimal(new(big.Rat).Quo(previous, yuan), 2), direction, percent), true, nil }