376 lines
12 KiB
Go
376 lines
12 KiB
Go
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
|
||
}
|