package business import ( "context" "errors" "strings" "testing" "time" ) type triggerRepoFake struct { 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++ return r.metrics, r.failure } func (r *triggerRepoFake) StoreHit(_ context.Context, hit *AlertRecord, _ []int, decide func(*AlertRecord) bool) (bool, error) { if !decide(r.current) { return false, nil } r.hits = append(r.hits, hit) return true, r.failure } func triggerFixture() (*triggerRepoFake, time.Time) { now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) r := &triggerRepoFake{input: &AlertTriggerInput{Accounts: []*Account{ {ID: 1, AuthorityID: 888}, {ID: 10, Level: 1, Username: "一级甲"}, {ID: 11, Level: 1, Username: "一级乙"}, {ID: 20, Level: 2, IDPath: "0,10"}, {ID: 30, Level: 3, IDPath: "0,10,20"}, {ID: 31, Level: 3, IDPath: "0,11,21"}, }, Bindings: []*Binding{ {UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 7}, Status: "0"}, {UserID: 31, PlatformKey: PlatformKey{SourceDB: "hw", BusierID: 7}, Status: "0"}, {UserID: 30, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"}, {UserID: 31, PlatformKey: PlatformKey{SourceDB: "lx", BusierID: 8}, Status: "0"}, }, Channels: []*AlertTriggerChannel{ {ChannelKey: ChannelKey{"lx", 10}, BusierID: 7, Account: "甲渠道", CreatedUnix: now.AddDate(0, 0, -3).Unix()}, {ChannelKey: ChannelKey{"hw", 10}, BusierID: 7, Account: "乙渠道", LastLoginUnix: now.AddDate(0, 0, -10).Unix()}, {ChannelKey: ChannelKey{"lx", 11}, BusierID: 8, Account: "冲突渠道", CreatedUnix: now.AddDate(0, 0, -10).Unix()}, }}} return r, now } 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, nil).Detect(context.Background(), now) if err != nil || result.Written != 1 || result.Skipped != 1 || len(r.hits) != 1 { t.Fatalf("检测结果 %+v %v", result, err) } hit := r.hits[0] if hit.BusinessID != 10 || hit.PlatformCode != "lx" || hit.TargetPromoteID != 10 || hit.AlertContent != "连续3天未登录" { t.Fatalf("归属或自然日错误: %+v", hit) } r.hits = nil r.current = &AlertRecord{Status: 2} 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) } } func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) { r, now := triggerFixture() days, direction := 2, 1 threshold := "10" 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, nil).Detect(context.Background(), now) if err != nil || result.Written != 2 || r.metricCalls != 1 { t.Fatalf("同窗口应共享指标: %+v calls=%d %v", result, r.metricCalls, err) } if r.hits[0].AlertContent != "截至昨日2个完整自然日充值¥11.00万,较此前2个完整自然日(¥10.00万)环比上升10.00%" { t.Fatal(r.hits[0].AlertContent) } r.metrics[ChannelKey{"lx", 10}] = AlertComparison{FirstUnix: now.AddDate(0, 0, -3).Unix(), Current: "110000", Previous: "100000"} r.hits = nil 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, nil).Detect(context.Background(), now) if err == nil || !strings.Contains(err.Error(), "2 项失败") { t.Fatalf("读取失败不能报告成功: %v", err) } } func TestAlertTriggerRoundedThresholdAndNoBaseline(t *testing.T) { days, direction := 1, 1 threshold := "10" rule := &AlertRule{Metric: 1, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold} _, now := triggerFixture() for _, test := range []struct { current, previous string hit bool }{{"109.995", "100", true}, {"109.994", "100", false}, {"1", "0", false}, {"1", "-1", false}} { _, hit, err := triggerContent(rule, &AlertTriggerChannel{}, AlertComparison{FirstUnix: now.AddDate(0, 0, -2).Unix(), Current: test.current, Previous: test.previous}, now) if err != nil || hit != test.hit { t.Fatalf("%+v: hit=%v err=%v", test, hit, err) } } } 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) } }