feat: implement V2 alert center and scoped alert governance

This commit is contained in:
Yvan
2026-11-05 20:08:29 +08:00
parent f8e26c5baf
commit 999c9a6315
57 changed files with 2711 additions and 216 deletions
+1 -1
View File
@@ -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)
+194
View File
@@ -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
}
+43 -2
View File
@@ -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 {
+2 -2
View File
@@ -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("已处理预警应只读")
}
}
+59 -4
View File
@@ -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) + " 天未登录时触发预警"
}
+118 -18
View File
@@ -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(&copy) == 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
+46 -12
View File
@@ -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)
}
}
+89
View File
@@ -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)
}
}