feat: implement V2 alert center and scoped alert governance
This commit is contained in:
+1
-1
@@ -11,4 +11,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertGovernanceUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/system"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlertGovernanceLevel = errors.Forbidden("SW_ALERT_GOVERNANCE_LEVEL", "仅一级商务或超管可操作预警治理")
|
||||
ErrAlertExemptExists = errors.Conflict("SW_ALERT_EXEMPT_EXISTS", "渠道已在免预警名单中")
|
||||
ErrAlertExemptNotFound = errors.NotFound("SW_ALERT_EXEMPT_NOT_FOUND", "免预警记录不存在或已移出")
|
||||
)
|
||||
|
||||
type AlertGap struct {
|
||||
OwnerID uint
|
||||
Enabled bool
|
||||
Days int
|
||||
}
|
||||
type AlertExemption struct {
|
||||
ID uint64
|
||||
ChannelKey
|
||||
ChannelName string
|
||||
OwnerID, AddedBy uint
|
||||
AddedName, Reason string
|
||||
AddedAt time.Time
|
||||
RemovedBy uint
|
||||
RemovedName, RemoveReason string
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
type AlertExemptionFilter struct {
|
||||
Name string
|
||||
Active *bool
|
||||
}
|
||||
type AlertExemptionTarget struct {
|
||||
Name string
|
||||
Account *Account
|
||||
}
|
||||
type AlertChannelOption struct {
|
||||
ChannelKey
|
||||
Name string
|
||||
}
|
||||
type AlertLossWindow struct {
|
||||
LoginBefore, PayBegin, PayEnd int64
|
||||
MinPay string
|
||||
}
|
||||
|
||||
func AlertLossAt(now time.Time) AlertLossWindow {
|
||||
now = now.In(time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
return AlertLossWindow{LoginBefore: now.AddDate(0, 0, -15).Unix(), PayBegin: now.AddDate(0, 0, -30).Unix(), PayEnd: now.Unix(), MinPay: "500"}
|
||||
}
|
||||
|
||||
func (w AlertLossWindow) IsLost(lastLogin, created int64, pay string) (bool, error) {
|
||||
if lastLogin <= 0 {
|
||||
lastLogin = created
|
||||
}
|
||||
if lastLogin > 0 && lastLogin < w.LoginBefore {
|
||||
return true, nil
|
||||
}
|
||||
amount, err := portraitNumber(pay)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
minimum, err := portraitNumber(w.MinPay)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return amount.Cmp(minimum) < 0, nil
|
||||
}
|
||||
|
||||
type AlertGovernanceRepo interface {
|
||||
Candidates(context.Context, UserScope, string, int) ([]*AlertChannelOption, error)
|
||||
Gap(context.Context, uint) (*AlertGap, error)
|
||||
SaveGap(context.Context, *AlertGap) error
|
||||
Exemptions(context.Context, LevelOneScope, AlertExemptionFilter, ...ListOption) ([]*AlertExemption, int64, error)
|
||||
// 在渠道锁及事务内读取当前归属,由领域回调构造豁免记录后写入。
|
||||
AddExemption(context.Context, UserScope, ChannelKey, func(*AlertExemptionTarget) (*AlertExemption, error)) error
|
||||
RemoveExemption(context.Context, LevelOneScope, uint64, uint, string, string, time.Time) error
|
||||
LostExemptions(context.Context, AlertLossWindow) ([]*AlertExemption, error)
|
||||
}
|
||||
|
||||
func (uc *AlertGovernanceUsecase) Candidates(ctx context.Context, keyword string, limit int) ([]*AlertChannelOption, error) {
|
||||
scope, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewer := alertUserScope(a)
|
||||
if scope.All {
|
||||
viewer = UserScope{All: true}
|
||||
}
|
||||
return uc.repo.Candidates(ctx, viewer, strings.TrimSpace(keyword), limit)
|
||||
}
|
||||
|
||||
type AlertGovernanceUsecase struct {
|
||||
repo AlertGovernanceRepo
|
||||
accounts *AccountUsecase
|
||||
}
|
||||
|
||||
func NewAlertGovernanceUsecase(repo AlertGovernanceRepo, accounts *AccountUsecase) *AlertGovernanceUsecase {
|
||||
return &AlertGovernanceUsecase{repo, accounts}
|
||||
}
|
||||
|
||||
func (uc *AlertGovernanceUsecase) owner(ctx context.Context, write bool) (LevelOneScope, *Account, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return LevelOneScope{}, nil, err
|
||||
}
|
||||
if write && actor.AuthorityID != 888 && account.Level != 1 {
|
||||
return LevelOneScope{}, nil, ErrAlertGovernanceLevel
|
||||
}
|
||||
return LevelOneOwner(actor, account), account, nil
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Gap(ctx context.Context) (*AlertGap, error) {
|
||||
scope, a, err := uc.owner(ctx, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Empty {
|
||||
return nil, ErrAlertRuleScope
|
||||
}
|
||||
owner := scope.UserID
|
||||
if scope.All {
|
||||
owner = a.ID
|
||||
}
|
||||
return uc.repo.Gap(ctx, owner)
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) SaveGap(ctx context.Context, enabled bool, days int) error {
|
||||
_, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if days < 1 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
return uc.repo.SaveGap(ctx, &AlertGap{OwnerID: a.ID, Enabled: enabled, Days: days})
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) List(ctx context.Context, filter AlertExemptionFilter, opts ...ListOption) ([]*AlertExemption, int64, error) {
|
||||
scope, _, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return uc.repo.Exemptions(ctx, scope, filter, opts...)
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Add(ctx context.Context, key ChannelKey, reason string) error {
|
||||
scope, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if key.ID == 0 || reason == "" {
|
||||
return ErrAlertRecordInput
|
||||
}
|
||||
key.SourceDB, err = NormalizePlatform(key.SourceDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
viewer := alertUserScope(a)
|
||||
if scope.All {
|
||||
viewer = UserScope{All: true}
|
||||
}
|
||||
return uc.repo.AddExemption(ctx, viewer, key, func(target *AlertExemptionTarget) (*AlertExemption, error) {
|
||||
owner := LevelOneOwnerForAccount(target.Account)
|
||||
if owner.Empty {
|
||||
return nil, ErrAlertRecordScope
|
||||
}
|
||||
return &AlertExemption{ChannelKey: key, ChannelName: target.Name, OwnerID: owner.UserID, AddedBy: a.ID, AddedName: a.Username, Reason: reason, AddedAt: time.Now()}, nil
|
||||
})
|
||||
}
|
||||
func LevelOneOwnerForAccount(a *Account) LevelOneScope {
|
||||
// 系统账号特权只用于授权,不用于渠道的商务归属。
|
||||
return LevelOneOwner(system.Actor{}, a)
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Remove(ctx context.Context, id uint64) error {
|
||||
scope, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.RemoveExemption(ctx, scope, id, a.ID, a.Username, "手动移出", time.Now())
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Reconcile(ctx context.Context, now time.Time) error {
|
||||
rows, err := uc.repo.LostExemptions(ctx, AlertLossAt(now))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := uc.repo.RemoveExemption(ctx, LevelOneScope{All: true}, row.ID, 0, "系统(自动移出)", "进入激活池 · 自动移出", now); err != nil && !errors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,6 +19,13 @@ var (
|
||||
)
|
||||
|
||||
type AlertRecord struct {
|
||||
OwnerScope *UserScope
|
||||
// 检测写入策略,仅供任务与仓储使用,不向 HTTP 返回。
|
||||
GapDays int
|
||||
IndependentRule bool
|
||||
TotalPlayers *int64
|
||||
TotalPay, WarningColor *string
|
||||
Lost *bool
|
||||
ID, RuleID, TargetPromoteID uint64
|
||||
PlatformCode, PlatformName, RuleName, RuleType string
|
||||
MonitorMetric *int
|
||||
@@ -47,6 +54,8 @@ type AlertRecordScope struct {
|
||||
}
|
||||
|
||||
type AlertRecordFilter struct {
|
||||
AlertContent string
|
||||
Lost *bool
|
||||
PlatformCodes []string
|
||||
TargetPromoteID *uint64
|
||||
PlatformBusierID *uint64
|
||||
@@ -69,12 +78,22 @@ type AlertRecordDetail struct {
|
||||
}
|
||||
|
||||
type AlertRecordRepo interface {
|
||||
Platforms(context.Context, AlertRecordScope) ([]string, error)
|
||||
Ignore(context.Context, AlertRecordScope, []uint64, *AlertLog) error
|
||||
List(context.Context, AlertRecordScope, AlertRecordFilter, ...ListOption) (*AlertRecordPage, error)
|
||||
Detail(context.Context, AlertRecordScope, uint64) (*AlertRecordDetail, error)
|
||||
// 在同一事务和行锁内校验当前记录、执行业务回调、写入状态与日志。
|
||||
WithRecord(context.Context, AlertRecordScope, uint64, func(*AlertRecord) ([]*AlertLog, error)) error
|
||||
}
|
||||
|
||||
func (uc *AlertRecordUsecase) Platforms(ctx context.Context) ([]string, error) {
|
||||
scope, _, err := uc.scope(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.Platforms(ctx, scope)
|
||||
}
|
||||
|
||||
type AlertAction string
|
||||
|
||||
const (
|
||||
@@ -83,6 +102,7 @@ const (
|
||||
AlertPendingAudit = 3
|
||||
AlertAuditRejected = 4
|
||||
AlertProcessed = 5
|
||||
AlertIgnored = 6
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -205,13 +225,34 @@ func (uc *AlertRecordUsecase) AddNote(ctx context.Context, id uint64, note strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.WithRecord(ctx, scope, id, func(_ *AlertRecord) ([]*AlertLog, error) {
|
||||
return uc.repo.WithRecord(ctx, scope, id, func(record *AlertRecord) ([]*AlertLog, error) {
|
||||
if record.Status == AlertProcessed || record.Status == AlertIgnored {
|
||||
return nil, ErrAlertRecordChanged
|
||||
}
|
||||
return []*AlertLog{{AlertID: id, OperatorID: account.ID, OperatorName: account.Username, LogType: 2, NoteContent: note, AttachmentIDs: attachments}}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func AlertStatusLabel(status int) string {
|
||||
return map[int]string{1: "未处理", 2: "处理中", 3: "待审核", 4: "审核驳回", 5: "已处理"}[status]
|
||||
return map[int]string{1: "未处理", 2: "处理中", 3: "待审核", 4: "审核驳回", 5: "已处理", 6: "已忽略"}[status]
|
||||
}
|
||||
|
||||
func (uc *AlertRecordUsecase) Ignore(ctx context.Context, ids []uint64) error {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actor.AuthorityID != 888 && account.Level != 1 {
|
||||
return ErrAlertGovernanceLevel
|
||||
}
|
||||
if len(ids) == 0 || len(ids) > 500 {
|
||||
return ErrAlertRecordInput
|
||||
}
|
||||
scope, _, err := uc.scope(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.Ignore(ctx, scope, ids, &AlertLog{OperatorID: account.ID, OperatorName: account.Username, LogType: 1, ActionDetail: "ignore"})
|
||||
}
|
||||
|
||||
func AlertLogTypeLabel(logType int) string {
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestAlertRecordIndependentNotePreservesClosedState(t *testing.T) {
|
||||
if err := uc.AddNote(accountContext(8, 902), 100, " ", []string{"1"}); err != ErrAlertNoteEmpty {
|
||||
t.Fatal("空备注不应绕过校验")
|
||||
}
|
||||
if err := uc.AddNote(accountContext(8, 902), 100, "后续补充", []string{"1"}); err != nil || repo.status != 5 || len(repo.logs) != 1 || repo.logs[0].LogType != 2 {
|
||||
t.Fatal("独立备注改变了状态")
|
||||
if err := uc.AddNote(accountContext(8, 902), 100, "后续补充", []string{"1"}); err != ErrAlertRecordChanged || repo.status != 5 || len(repo.logs) != 0 {
|
||||
t.Fatal("已处理预警应只读")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ import (
|
||||
"fmt"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/biz/system"
|
||||
"math"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
var alertDecimal = regexp.MustCompile(`^[0-9]{1,12}(\.[0-9]{1,2})?$`)
|
||||
|
||||
var (
|
||||
ErrAlertRuleNotFound = errors.NotFound("SW_ALERT_RULE_NOT_FOUND", "规则不存在")
|
||||
ErrAlertRuleScope = errors.Forbidden("SW_ALERT_RULE_SCOPE", "无权限操作该规则")
|
||||
@@ -53,6 +56,9 @@ func LevelOneOwner(actor system.Actor, a *Account) LevelOneScope {
|
||||
}
|
||||
|
||||
type AlertRule struct {
|
||||
ConditionMode int
|
||||
AmountDirection int
|
||||
AmountThreshold *string
|
||||
ID uint64
|
||||
Name, Type string
|
||||
Metric int
|
||||
@@ -134,6 +140,48 @@ func validateAlertRule(r *AlertRule) error {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
r.Type = strings.TrimSpace(r.Type)
|
||||
if r.ConditionMode == 0 {
|
||||
r.ConditionMode = 1
|
||||
}
|
||||
if r.ConditionMode != 1 && r.ConditionMode != 2 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
// 非当前指标的输入不参与业务配置,避免隐藏字段绕过校验写入存储。
|
||||
if r.Metric == 3 {
|
||||
r.StatPeriodDays = nil
|
||||
r.MomDirection = nil
|
||||
r.MomThreshold = nil
|
||||
} else {
|
||||
r.UnloginDays = nil
|
||||
}
|
||||
if r.ConditionMode == 2 {
|
||||
r.MomDirection = nil
|
||||
r.MomThreshold = nil
|
||||
} else {
|
||||
r.AmountThreshold = nil
|
||||
r.AmountDirection = 1
|
||||
}
|
||||
if r.ConditionMode == 2 {
|
||||
if r.Metric != 2 || r.StatPeriodDays == nil || *r.StatPeriodDays <= 0 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
if r.AmountDirection != 1 && r.AmountDirection != 2 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
if r.AmountThreshold == nil || !alertDecimal.MatchString(*r.AmountThreshold) {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
n, ok := new(big.Rat).SetString(*r.AmountThreshold)
|
||||
if !ok || n.Sign() <= 0 || n.Cmp(big.NewRat(99999999999999, 100)) > 0 || !new(big.Rat).Mul(n, big.NewRat(100, 1)).IsInt() {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
amount := n.FloatString(2)
|
||||
r.AmountThreshold = &amount
|
||||
if r.Type == "" || len(utf16.Encode([]rune(r.Type))) > 50 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if r.Type == "" || len(utf16.Encode([]rune(r.Type))) > 50 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
@@ -145,11 +193,11 @@ func validateAlertRule(r *AlertRule) error {
|
||||
if r.MomDirection == nil || (*r.MomDirection != 1 && *r.MomDirection != 2) {
|
||||
return ErrAlertRuleDirection
|
||||
}
|
||||
if r.MomThreshold == nil {
|
||||
if r.MomThreshold == nil || !alertDecimal.MatchString(*r.MomThreshold) {
|
||||
return ErrAlertRuleThreshold
|
||||
}
|
||||
n, err := strconv.ParseFloat(*r.MomThreshold, 64)
|
||||
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n <= 0 || n > 9999 {
|
||||
n, ok := new(big.Rat).SetString(*r.MomThreshold)
|
||||
if !ok || n.Sign() <= 0 || n.Cmp(big.NewRat(9999, 1)) > 0 {
|
||||
return ErrAlertRuleThreshold
|
||||
}
|
||||
case 3:
|
||||
@@ -269,6 +317,13 @@ func optionalInt(value *int) string {
|
||||
return strconv.Itoa(*value)
|
||||
}
|
||||
func (r *AlertRule) Description() string {
|
||||
if r.Metric == 2 && r.ConditionMode == 2 && r.AmountThreshold != nil {
|
||||
direction := "满"
|
||||
if r.AmountDirection == 2 {
|
||||
direction = "未满"
|
||||
}
|
||||
return fmt.Sprintf("渠道近 %s 天充值金额%s %s 元时触发预警(汇总子渠道)", optionalInt(r.StatPeriodDays), direction, *r.AmountThreshold)
|
||||
}
|
||||
if r.Metric == 3 {
|
||||
return "渠道连续 " + optionalInt(r.UnloginDays) + " 天未登录时触发预警"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -18,10 +18,22 @@ type AlertTriggerChannel struct {
|
||||
}
|
||||
|
||||
type AlertTriggerInput struct {
|
||||
Rules []*AlertRule
|
||||
Accounts []*Account
|
||||
Bindings []*Binding
|
||||
Channels []*AlertTriggerChannel
|
||||
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 {
|
||||
@@ -30,6 +42,8 @@ type AlertComparison struct {
|
||||
}
|
||||
|
||||
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)
|
||||
// 仓储在渠道/分类键锁及事务内读取未关闭记录,由领域决定是否刷新。
|
||||
@@ -38,10 +52,13 @@ type AlertTriggerRepo interface {
|
||||
|
||||
type AlertTriggerResult struct{ Rules, Written, Skipped int }
|
||||
|
||||
type AlertTriggerUsecase struct{ repo AlertTriggerRepo }
|
||||
type AlertTriggerUsecase struct {
|
||||
repo AlertTriggerRepo
|
||||
governance *AlertGovernanceUsecase
|
||||
}
|
||||
|
||||
func NewAlertTriggerUsecase(repo AlertTriggerRepo) *AlertTriggerUsecase {
|
||||
return &AlertTriggerUsecase{repo: repo}
|
||||
func NewAlertTriggerUsecase(repo AlertTriggerRepo, governance *AlertGovernanceUsecase) *AlertTriggerUsecase {
|
||||
return &AlertTriggerUsecase{repo: repo, governance: governance}
|
||||
}
|
||||
|
||||
func (uc *AlertTriggerUsecase) Run(ctx context.Context) error {
|
||||
@@ -53,6 +70,10 @@ 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
|
||||
}
|
||||
@@ -102,18 +123,15 @@ func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]
|
||||
channel.SourceDB = source
|
||||
key := PlatformKey{SourceDB: source, BusierID: channel.BusierID}
|
||||
account := accounts[bindings[key]]
|
||||
if conflicts[key] || account == nil || account.Level != 3 {
|
||||
if conflicts[key] || account == nil || account.Level < 1 || account.Level > 3 {
|
||||
continue
|
||||
}
|
||||
path := strings.Split(account.IDPath, ",")
|
||||
if len(path) < 2 {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(path[1], 10, 64)
|
||||
owner := accounts[uint(id)]
|
||||
if err != nil || owner == nil || owner.Level != 1 {
|
||||
scope := LevelOneOwnerForAccount(account)
|
||||
owner := accounts[scope.UserID]
|
||||
if scope.Empty || owner == nil || owner.Level != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
owners[channel.ChannelKey] = owner
|
||||
}
|
||||
return owners, accounts
|
||||
@@ -122,6 +140,11 @@ func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]
|
||||
// 一次执行固定上海自然日,避免跨午夜时同一任务的各规则使用不同的比较窗口。
|
||||
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
|
||||
@@ -139,6 +162,7 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
}
|
||||
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
|
||||
@@ -160,6 +184,9 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -198,9 +225,62 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
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}
|
||||
written, err := uc.repo.StoreHit(ctx, record, []int{AlertUnprocessed, AlertProcessing, AlertPendingAudit, AlertAuditRejected}, func(current *AlertRecord) bool { return current == nil || current.Status == AlertUnprocessed })
|
||||
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", rule.ID, channel.SourceDB, channel.ID, err))
|
||||
failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d 写入失败: %v", record.RuleID, record.PlatformCode, record.TargetPromoteID, err))
|
||||
continue
|
||||
}
|
||||
if written {
|
||||
@@ -208,6 +288,7 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
for _, failure := range failures {
|
||||
slog.ErrorContext(ctx, "预警检测失败", "detail", failure)
|
||||
@@ -234,6 +315,25 @@ func triggerContent(rule *AlertRule, channel *AlertTriggerChannel, sample AlertC
|
||||
}
|
||||
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
|
||||
|
||||
@@ -9,14 +9,21 @@ import (
|
||||
)
|
||||
|
||||
type triggerRepoFake struct {
|
||||
input *AlertTriggerInput
|
||||
metrics map[ChannelKey]AlertComparison
|
||||
hits []*AlertRecord
|
||||
metricCalls int
|
||||
current *AlertRecord
|
||||
failure error
|
||||
input *AlertTriggerInput
|
||||
metrics map[ChannelKey]AlertComparison
|
||||
hits []*AlertRecord
|
||||
metricCalls int
|
||||
snapshotCalls int
|
||||
current *AlertRecord
|
||||
failure error
|
||||
}
|
||||
|
||||
func (r *triggerRepoFake) FilterWritable(_ context.Context, hits []*AlertRecord, _ []int, allow func(*AlertRecord) bool) ([]*AlertRecord, error) {
|
||||
if !allow(r.current) {
|
||||
return nil, nil
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
func (r *triggerRepoFake) Input(context.Context) (*AlertTriggerInput, error) { return r.input, nil }
|
||||
func (r *triggerRepoFake) Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error) {
|
||||
r.metricCalls++
|
||||
@@ -52,7 +59,7 @@ func TestAlertTriggerSeparatesOwnerPlatformAndConflictingBindings(t *testing.T)
|
||||
r, now := triggerFixture()
|
||||
days := 3
|
||||
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "未登录", Metric: 3, UnloginDays: &days}, {ID: 2, CreatorID: 20, Type: "越级", Metric: 3, UnloginDays: &days}}
|
||||
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
result, err := NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 1 || result.Skipped != 1 || len(r.hits) != 1 {
|
||||
t.Fatalf("检测结果 %+v %v", result, err)
|
||||
}
|
||||
@@ -62,8 +69,8 @@ func TestAlertTriggerSeparatesOwnerPlatformAndConflictingBindings(t *testing.T)
|
||||
}
|
||||
r.hits = nil
|
||||
r.current = &AlertRecord{Status: 2}
|
||||
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 0 || len(r.hits) != 0 {
|
||||
result, err = NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 0 || len(r.hits) != 0 || r.snapshotCalls != 1 {
|
||||
t.Fatalf("在途记录不应刷新: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +82,7 @@ func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) {
|
||||
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "充值", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold},
|
||||
{ID: 2, CreatorID: 10, Type: "另一个充值规则", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}}
|
||||
r.metrics = map[ChannelKey]AlertComparison{{"lx", 10}: {FirstUnix: now.AddDate(0, 0, -4).Unix(), Current: "110000", Previous: "100000"}}
|
||||
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
result, err := NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 2 || r.metricCalls != 1 {
|
||||
t.Fatalf("同窗口应共享指标: %+v calls=%d %v", result, r.metricCalls, err)
|
||||
}
|
||||
@@ -84,12 +91,12 @@ func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) {
|
||||
}
|
||||
r.metrics[ChannelKey{"lx", 10}] = AlertComparison{FirstUnix: now.AddDate(0, 0, -3).Unix(), Current: "110000", Previous: "100000"}
|
||||
r.hits = nil
|
||||
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
result, err = NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 0 {
|
||||
t.Fatalf("不足双周期不应触发: %+v %v", result, err)
|
||||
}
|
||||
r.failure = errors.New("指标不可用")
|
||||
_, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
_, err = NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err == nil || !strings.Contains(err.Error(), "2 项失败") {
|
||||
t.Fatalf("读取失败不能报告成功: %v", err)
|
||||
}
|
||||
@@ -110,3 +117,30 @@ func TestAlertTriggerRoundedThresholdAndNoBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *triggerRepoFake) Snapshots(_ context.Context, keys []ChannelKey, _ time.Time, _ bool) (map[ChannelKey]AlertSnapshot, error) {
|
||||
r.snapshotCalls++
|
||||
out := map[ChannelKey]AlertSnapshot{}
|
||||
for _, key := range keys {
|
||||
out[key] = AlertSnapshot{Pay: "0"}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestAlertExemptionIsLimitedToItsBusinessOwner(t *testing.T) {
|
||||
r, now := triggerFixture()
|
||||
days := 3
|
||||
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "未登录", Metric: 3, UnloginDays: &days}}
|
||||
key := ChannelKey{SourceDB: "lx", ID: 10}
|
||||
r.input.Exemptions = map[AlertExemptionKey]bool{{ChannelKey: key, OwnerID: 11}: true}
|
||||
uc := NewAlertTriggerUsecase(r, nil)
|
||||
first, err := uc.Detect(context.Background(), now)
|
||||
if err != nil || first.Written != 1 {
|
||||
t.Fatalf("其他商务的豁免影响了当前商务: %+v %v", first, err)
|
||||
}
|
||||
r.input.Exemptions[AlertExemptionKey{ChannelKey: key, OwnerID: 10}] = true
|
||||
second, err := uc.Detect(context.Background(), now)
|
||||
if err != nil || second.Written != 0 || r.snapshotCalls != 1 {
|
||||
t.Fatalf("当前商务的豁免未在统计前生效: %+v %v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAlertAbsoluteThresholdBoundaries(t *testing.T) {
|
||||
days := 30
|
||||
threshold := "2000"
|
||||
rule := &AlertRule{Type: "充值", Metric: 2, ConditionMode: 2, AmountDirection: 1, AmountThreshold: &threshold, StatPeriodDays: &days}
|
||||
if err := validateAlertRule(rule); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
direction int
|
||||
amount string
|
||||
want bool
|
||||
}{{1, "1999.99", false}, {1, "2000", true}, {1, "3000", true}, {2, "1999.99", true}, {2, "2000", false}} {
|
||||
rule.AmountDirection = tc.direction
|
||||
_, hit, err := triggerContent(rule, &AlertTriggerChannel{}, AlertComparison{Current: tc.amount}, time.Now())
|
||||
if err != nil || hit != tc.want {
|
||||
t.Fatalf("%+v hit=%v err=%v", tc, hit, err)
|
||||
}
|
||||
}
|
||||
bad := "0.001"
|
||||
rule.AmountThreshold = &bad
|
||||
if validateAlertRule(rule) == nil {
|
||||
t.Fatal("不足一分的金额不能入库后被四舍五入为零")
|
||||
}
|
||||
rule.Metric = 1
|
||||
if validateAlertRule(rule) == nil {
|
||||
t.Fatal("注册量不能使用充值绝对值条件")
|
||||
}
|
||||
}
|
||||
|
||||
type governanceFake struct {
|
||||
AlertGovernanceRepo
|
||||
gap *AlertGap
|
||||
adds int
|
||||
target *AlertExemptionTarget
|
||||
}
|
||||
|
||||
func (r *governanceFake) SaveGap(_ context.Context, g *AlertGap) error { r.gap = g; return nil }
|
||||
func (r *governanceFake) AddExemption(_ context.Context, _ UserScope, _ ChannelKey, build func(*AlertExemptionTarget) (*AlertExemption, error)) error {
|
||||
if _, err := build(r.target); err != nil {
|
||||
return err
|
||||
}
|
||||
r.adds++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAlertGovernanceChecksLevelAndKeepsTenantGap(t *testing.T) {
|
||||
records, _ := alertRecordFixture()
|
||||
r := &governanceFake{target: &AlertExemptionTarget{Name: "渠道", Account: &Account{ID: 9, Level: 3, IDPath: "0,7,8"}}}
|
||||
uc := NewAlertGovernanceUsecase(r, records.accounts)
|
||||
if err := uc.SaveGap(accountContext(9, 903), true, 7); err != ErrAlertGovernanceLevel {
|
||||
t.Fatalf("越级间隔配置: %v", err)
|
||||
}
|
||||
if err := uc.SaveGap(accountContext(7, 901), true, 1000); err != nil || r.gap.OwnerID != 7 || r.gap.Days != 1000 {
|
||||
t.Fatal("间隔应归属当前一级商务且不限制为365天")
|
||||
}
|
||||
if err := uc.Add(accountContext(8, 902), ChannelKey{"lx", 1}, "备注"); err != ErrAlertGovernanceLevel {
|
||||
t.Fatal("下级不能加入免预警")
|
||||
}
|
||||
if err := uc.Add(accountContext(7, 901), ChannelKey{"lx", 1}, " "); err != ErrAlertRecordInput {
|
||||
t.Fatal("加入原因不能为空")
|
||||
}
|
||||
if err := uc.Add(accountContext(7, 901), ChannelKey{"lx", 1}, "长期合作"); err != nil || r.adds != 1 {
|
||||
t.Fatalf("加入失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRejectsUnboundedDecimalAndIgnoresInactiveInputs(t *testing.T) {
|
||||
days := 7
|
||||
for _, value := range []string{"1e10000000", "0.001", "NaN", "999999999999999999999999"} {
|
||||
rule := &AlertRule{Type: "充值", Metric: 2, ConditionMode: 2, StatPeriodDays: &days, AmountDirection: 1, AmountThreshold: &value}
|
||||
if validateAlertRule(rule) == nil {
|
||||
t.Fatalf("非法金额通过校验: %s", value)
|
||||
}
|
||||
}
|
||||
huge := "1e10000000"
|
||||
direction := 999999
|
||||
rule := &AlertRule{Type: "登录", Metric: 3, UnloginDays: &days, MomDirection: &direction, MomThreshold: &huge, AmountThreshold: &huge}
|
||||
if err := validateAlertRule(rule); err != nil || rule.MomThreshold != nil || rule.MomDirection != nil || rule.AmountThreshold != nil {
|
||||
t.Fatalf("未使用字段不应绕过校验写入数据库: %+v %v", rule, err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user