Files
kra-new/internal/biz/business/alert_trigger.go
T

376 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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(&copy) == 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
}