diff --git a/cmd/alert_record_http_test.go b/cmd/alert_record_http_test.go index 6fae0e7..0780fdc 100644 --- a/cmd/alert_record_http_test.go +++ b/cmd/alert_record_http_test.go @@ -40,6 +40,7 @@ func TestAlertRecordHTTPCompleteWorkflowAndVisibility(t *testing.T) { "INSERT INTO tab_promote VALUES(100,'lx',14,1,0),(100,'hw',14,1,1),(100,'yq',14,1,1)", "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(9,'lx',14,'0'),(10,'hw',14,'0'),(99,'yq',14,'0')", "INSERT INTO sys_alert_record(id,platform_code,target_promote_id,business_id,rule_name,status,created_at,updated_at) VALUES(9007199254740993,'lx',100,7,'目标规则',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(2,'hw',100,7,'平级规则',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(3,'lx',100,99,'归属错误',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(4,'yq',100,99,'其他规则',1,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(5,'lx',100,7,'关闭规则',5,'2026-11-03 08:00:00','2026-11-03 08:00:00'),(6,'lx',100,7,'待审规则',3,'2026-11-03 08:00:00','2026-11-03 08:00:00')", + "ALTER TABLE tab_promote ADD COLUMN account TEXT DEFAULT '测试渠道'", "INSERT INTO media_file_upload_and_downloads(id,url) VALUES(1,'/files/one.png'),(2,'/files/two.png')", } { if err := db.Exec(sql).Error; err != nil { @@ -60,6 +61,7 @@ func TestAlertRecordHTTPCompleteWorkflowAndVisibility(t *testing.T) { c.Next() }) router.RegisterAlertRecord(engine.Group(""), handler.NewAlertRecord(service)) + router.RegisterAlertGovernance(engine.Group(""), handler.NewAlertGovernance(businessservice.NewAlertGovernanceService(businessbiz.NewAlertGovernanceUsecase(businessdata.NewAlertGovernanceRepo(provider), accounts)))) server := httptest.NewServer(engine) defer server.Close() call := func(user int, method, path, body string, expected int) map[string]any { @@ -94,6 +96,10 @@ func TestAlertRecordHTTPCompleteWorkflowAndVisibility(t *testing.T) { } list(1, "", 6) list(7, "", 4) + platforms := call(8, "GET", "/sw/alertRecord/platforms", "", 0)["data"].([]any) + if len(platforms) != 1 || platforms[0] != "lx" { + t.Fatal("平台下拉必须按当前可见绑定过滤") + } list(8, "", 3) list(9, "", 3) list(7, "?businessId=8", 3) @@ -101,8 +107,8 @@ func TestAlertRecordHTTPCompleteWorkflowAndVisibility(t *testing.T) { list(1, "?businessId=8", 4) list(8, "?platformCodes=hw", 0) data := list(8, "?status=1&ruleName=%E7%9B%AE%E6%A0%87", 1) - if len(data["statusCount"].(map[string]any)) != 3 { - t.Fatal("汇总错误继承列表筛选") + if len(data["statusCount"].(map[string]any)) != 1 { + t.Fatal("汇总应继承名称筛选") } const id = "9007199254740993" detailPath := "/sw/alertRecord/detail/" + id @@ -139,15 +145,61 @@ func TestAlertRecordHTTPCompleteWorkflowAndVisibility(t *testing.T) { t.Fatalf("完整处理审核日志错误: %+v", data) } call(9, "POST", "/sw/alertRecord/addNote", `{"alertId":"`+id+`","noteContent":" ","attachmentIds":[1]}`, 7) - call(9, "POST", "/sw/alertRecord/addNote", `{"alertId":"`+id+`","noteContent":"关闭后补充","attachmentIds":[2,"1"]}`, 0) - data = call(9, "GET", detailPath, "", 0)["data"].(map[string]any) + call(9, "POST", "/sw/alertRecord/addNote", `{"alertId":"`+id+`","noteContent":"关闭后补充","attachmentIds":[2,"1"]}`, 7) + call(9, "POST", "/sw/alertRecord/addNote", `{"alertId":"6","noteContent":"待审核补充","attachmentIds":[2,"1"]}`, 0) + data = call(9, "GET", "/sw/alertRecord/detail/6", "", 0)["data"].(map[string]any) logs := data["operationLogs"].([]any) latest := logs[0].(map[string]any) - if len(logs) != 9 || latest["operatorName"] != "leaf" || latest["logType"] != float64(2) || latest["attachments"].([]any)[0] != "/files/two.png" || data["alertRecord"].(map[string]any)["status"] != float64(5) { - t.Fatalf("备注附件或状态错误: %+v", data) + if len(logs) != 1 || latest["attachments"].([]any)[0] != "/files/two.png" || data["alertRecord"].(map[string]any)["status"] != float64(3) { + t.Fatalf("独立备注或附件错误: %+v", data) } + call(9, "POST", "/sw/alertRecord/ignore", `{"ids":["6"]}`, 7) + call(7, "POST", "/sw/alertRecord/ignore", `{"ids":["6","4"]}`, 7) + data = call(7, "GET", "/sw/alertRecord/detail/6", "", 0)["data"].(map[string]any) + if data["alertRecord"].(map[string]any)["status"] != float64(3) { + t.Fatal("混入越权 ID 时不能部分忽略") + } + call(7, "POST", "/sw/alertRecord/ignore", `{"ids":["6","6"]}`, 0) + call(7, "POST", "/sw/alertRecord/ignore", `{"ids":["6"]}`, 0) + data = call(7, "GET", "/sw/alertRecord/detail/6", "", 0)["data"].(map[string]any) + if data["alertRecord"].(map[string]any)["status"] != float64(6) || len(data["operationLogs"].([]any)) != 2 { + t.Fatal("忽略必须幂等留痕") + } + call(99, "POST", "/sw/alertRecord/addNote", `{"alertId":"`+id+`","noteContent":"越权"}`, 7) call(1, "POST", "/sw/alertRecord/startProcess", `{"alertId":0}`, 7) + call(9, "PUT", "/sw/alertRule/gap", `{"enabled":true,"days":7}`, 7) + call(7, "PUT", "/sw/alertRule/gap", `{"enabled":true,"days":0}`, 7) + call(7, "PUT", "/sw/alertRule/gap", `{"enabled":true,"days":1000}`, 0) + gap := call(9, "GET", "/sw/alertRule/gap", "", 0)["data"].(map[string]any) + if gap["days"] != float64(1000) || gap["enabled"] != true { + t.Fatal("下级应看到其一级商务设置的统一间隔") + } + call(9, "GET", "/sw/alertExemption/candidates?keyword=test", "", 7) + choices := call(7, "GET", "/sw/alertExemption/candidates", "", 0)["data"].([]any) + if len(choices) != 2 { + t.Fatalf("候选渠道越权或丢失: %+v", choices) + } + for _, choice := range choices { + if _, ok := choice.(map[string]any)["id"].(string); !ok { + t.Fatal("渠道 ID 应保持字符串精度") + } + } + call(9, "POST", "/sw/alertExemption/add", `{"sourceDb":"lx","promoteId":"100","reason":"测试"}`, 7) + call(7, "POST", "/sw/alertExemption/add", `{"sourceDb":"yq","promoteId":"100","reason":"越权"}`, 7) + call(7, "POST", "/sw/alertExemption/add", `{"sourceDb":"lx","promoteId":"100","reason":"测试免预警"}`, 0) + call(7, "POST", "/sw/alertExemption/add", `{"sourceDb":"lx","promoteId":"100","reason":"重复"}`, 7) + call(9, "GET", "/sw/alertExemption/list", "", 7) + ex := call(7, "GET", "/sw/alertExemption/list?active=true", "", 0)["data"].(map[string]any) + entry := ex["rows"].([]any)[0].(map[string]any) + eid := entry["id"].(string) + call(99, "PUT", "/sw/alertExemption/remove/"+eid, "", 7) + call(7, "PUT", "/sw/alertExemption/remove/"+eid, "", 0) + ex = call(7, "GET", "/sw/alertExemption/list?active=false", "", 0)["data"].(map[string]any) + entry = ex["rows"].([]any)[0].(map[string]any) + if entry["removedName"] != "owner" || entry["removeReason"] != "手动移出" || entry["reason"] != "测试免预警" { + t.Fatal("移出后应保留完整审计记录") + } if err := db.Exec("DROP TABLE tab_promote").Error; err != nil { t.Fatal(err) } diff --git a/cmd/alert_rule_http_test.go b/cmd/alert_rule_http_test.go index bbfe44f..6c13381 100644 --- a/cmd/alert_rule_http_test.go +++ b/cmd/alert_rule_http_test.go @@ -71,18 +71,26 @@ func TestAlertRuleHTTPScopesAndLifecycle(t *testing.T) { } return result } + if err := db.Exec("INSERT INTO sys_alert_rule(id,rule_name,rule_type,monitor_metric,warning_color,creator_id,is_deleted) VALUES(9007199254740992,'旧规则','旧分类',3,'red',7,1)").Error; err != nil { + t.Fatal(err) + } payload := `{"ruleName":"注册异常","ruleType":"自定义分类","monitorMetric":1,"statPeriodDays":7,"momDirection":2,"momThreshold":"9999","warningColor":"#f56c6c","creatorId":99,"monitorScope":99}` created := call(7, "POST", "/sw/alertRule/add", payload, 0)["data"].(map[string]any) - id := uint64(created["id"].(float64)) + id, err := strconv.ParseUint(created["id"].(string), 10, 64) + if err != nil || created["id"] != "9007199254740993" { + t.Fatalf("大 ID 丢失精度: %+v", created) + } path := fmt.Sprintf("/sw/alertRule/%d", id) value := call(8, "GET", path, "", 0)["data"].(map[string]any) - if value["creatorId"] != float64(7) || value["monitorScope"] != float64(1) || value["momThreshold"] != float64(9999) { + if value["id"] != "9007199254740993" || value["creatorId"] != float64(7) || value["monitorScope"] != float64(1) || value["momThreshold"] != float64(9999) { t.Fatalf("规则参数、归属或阈值不正确: %+v", value) } call(99, "GET", path, "", 7) call(8, "POST", "/sw/alertRule/add", payload, 7) call(7, "PUT", fmt.Sprintf("/sw/alertRule/toggle/%d?enabled=0", id), "", 0) - call(7, "PUT", "/sw/alertRule/edit", fmt.Sprintf(`{"id":%d,"ruleName":"登录规则","ruleType":"新分类","monitorMetric":3,"unloginDays":7,"warningColor":"red"}`, id), 0) + call(7, "POST", "/sw/alertRule/add", fmt.Sprintf(`{"id":%d,"ruleName":"绕过编辑权限","ruleType":"分类","monitorMetric":3,"unloginDays":7,"warningColor":"red"}`, id), 7) + call(7, "PUT", "/sw/alertRule/edit", `{"ruleName":"绕过新建权限","ruleType":"分类","monitorMetric":3,"unloginDays":7,"warningColor":"red"}`, 7) + call(7, "PUT", "/sw/alertRule/edit", fmt.Sprintf(`{"id":"%d","ruleName":"登录规则","ruleType":"新分类","monitorMetric":3,"unloginDays":7,"warningColor":"red"}`, id), 0) value = call(7, "GET", path, "", 0)["data"].(map[string]any) if value["enabled"] != float64(0) || value["statPeriodDays"] != float64(7) || value["momThreshold"] != float64(9999) { t.Fatal("编辑覆盖启停状态或未传入的历史参数") diff --git a/cmd/alert_trigger_http_test.go b/cmd/alert_trigger_http_test.go index 39a89f7..7517be1 100644 --- a/cmd/alert_trigger_http_test.go +++ b/cmd/alert_trigger_http_test.go @@ -38,11 +38,13 @@ func TestAlertTriggerThroughNativeTaskHTTP(t *testing.T) { } } for _, sql := range []string{ + "CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,register_time TEXT,idcard TEXT)", + "CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_status INTEGER,pay_time INTEGER,pay_amount DECIMAL(20,2))", "CREATE TABLE sys_users(id INTEGER,username TEXT,level INTEGER,parent_id INTEGER,id_path TEXT,authority_id INTEGER,deleted_at DATETIME)", - "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER)", + "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER,parent_id INTEGER DEFAULT 0,top_promote_id INTEGER DEFAULT 0)", "INSERT INTO sys_users VALUES(10,'一级',1,0,'0',901,NULL),(20,'二级',2,10,'0,10',902,NULL),(30,'三级',3,20,'0,10,20',903,NULL)", "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(30,'lx',7,'绑定','0')", - "INSERT INTO tab_promote VALUES(10,'lx',7,'测试渠道',1,1,0,1)", + "INSERT INTO tab_promote(id,source_db,busier_id,account,promote_level,status,last_login_time,create_time) VALUES(10,'lx',7,'测试渠道',1,1,0,1)", "INSERT INTO sys_alert_rule(rule_name,rule_type,monitor_metric,creator_id,unlogin_days,warning_color,enabled,is_deleted) VALUES('未登录规则','未登录',3,10,1,'red',1,0)", } { if err := db.Exec(sql).Error; err != nil { @@ -52,7 +54,7 @@ func TestAlertTriggerThroughNativeTaskHTTP(t *testing.T) { provider := businessHTTPData{db} registry := platformtask.NewRegistry() tasks := taskbiz.NewTaskUsecaseWithRegistry(taskdata.NewTaskRepo(reportTaskHTTPData{provider}), registry) - alerts := businessbiz.NewAlertTriggerUsecase(businessdata.NewAlertTriggerRepo(provider)) + alerts := businessbiz.NewAlertTriggerUsecase(businessdata.NewAlertTriggerRepo(provider), nil) worker.NewTaskMethods(tasks, nil, nil, nil, nil, alerts, nil).RegisterTasks(registry) scheduler := worker.NewTaskScheduler(tasks, nil, worker.NewTaskExecutorWithRegistry(tasks, registry), nil) defer func() { diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index d311e4f..e40ef90 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -82,9 +82,13 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg auditRecorderUsecase := system2.NewAuditRecorderUsecase(auditRecordRepo) auditRecorder := system3.NewAuditRecorder(auditRecorderUsecase) registry := taskRegistry() - teamRepo := business.NewTeamRepo(dataData) + alertGovernanceRepo := business.NewAlertGovernanceRepo(dataData) accountRepo := business.NewAccountRepo(dataData) accountUsecase := business2.NewAccountUsecase(accountRepo) + alertGovernanceUsecase := business2.NewAlertGovernanceUsecase(alertGovernanceRepo, accountUsecase) + alertGovernanceService := business3.NewAlertGovernanceService(alertGovernanceUsecase) + alertGovernance := handler.NewAlertGovernance(alertGovernanceService) + teamRepo := business.NewTeamRepo(dataData) teamUsecase := business2.NewTeamUsecase(teamRepo, accountUsecase) performanceRepo := business.NewPerformanceRepo(dataData) effectivenessRepo := business.NewEffectivenessRepo(dataData) @@ -263,6 +267,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg integrationConfigService := integration2.NewIntegrationConfigService(integrationConfigUsecase) integrationConfig := handler.NewIntegrationConfig(integrationConfigService) set := &handler.Set{ + AlertGovernance: alertGovernance, BusinessPerformance: businessPerformance, OtherSettings: otherSettings, Ranking: ranking, @@ -309,7 +314,7 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg reportBuildRepo := business.NewReportBuildRepo(dataData) reportBuildUsecase := business2.NewReportBuildUsecase(reportBuildRepo) alertTriggerRepo := business.NewAlertTriggerRepo(dataData) - alertTriggerUsecase := business2.NewAlertTriggerUsecase(alertTriggerRepo) + alertTriggerUsecase := business2.NewAlertTriggerUsecase(alertTriggerRepo, alertGovernanceUsecase) taskMethods := worker.NewTaskMethods(taskUsecase, maintenanceUsecase, mediaUsecase, store, reportBuildUsecase, alertTriggerUsecase, effectivenessUsecase) routeRegistrar := buildRuntime(registry, routes, taskMethods) websocketServer, cleanup2, err := websocket.New(runtimeconfigStore) diff --git a/internal/biz/biz.go b/internal/biz/biz.go index e225f69..ef33cd5 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -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) diff --git a/internal/biz/business/alert_governance.go b/internal/biz/business/alert_governance.go new file mode 100644 index 0000000..3ecbf02 --- /dev/null +++ b/internal/biz/business/alert_governance.go @@ -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 +} diff --git a/internal/biz/business/alert_record.go b/internal/biz/business/alert_record.go index 75ddc39..e38dcf6 100644 --- a/internal/biz/business/alert_record.go +++ b/internal/biz/business/alert_record.go @@ -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 { diff --git a/internal/biz/business/alert_record_test.go b/internal/biz/business/alert_record_test.go index 7906a2c..33273e0 100644 --- a/internal/biz/business/alert_record_test.go +++ b/internal/biz/business/alert_record_test.go @@ -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("已处理预警应只读") } } diff --git a/internal/biz/business/alert_rule.go b/internal/biz/business/alert_rule.go index f40d0f6..1cffb1f 100644 --- a/internal/biz/business/alert_rule.go +++ b/internal/biz/business/alert_rule.go @@ -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) + " 天未登录时触发预警" } diff --git a/internal/biz/business/alert_trigger.go b/internal/biz/business/alert_trigger.go index 5765077..ddf3986 100644 --- a/internal/biz/business/alert_trigger.go +++ b/internal/biz/business/alert_trigger.go @@ -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 diff --git a/internal/biz/business/alert_trigger_test.go b/internal/biz/business/alert_trigger_test.go index 68b82d3..bc99ebd 100644 --- a/internal/biz/business/alert_trigger_test.go +++ b/internal/biz/business/alert_trigger_test.go @@ -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) + } +} diff --git a/internal/biz/business/alert_v2_test.go b/internal/biz/business/alert_v2_test.go new file mode 100644 index 0000000..9aaa146 --- /dev/null +++ b/internal/biz/business/alert_v2_test.go @@ -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) + } +} diff --git a/internal/data/business/alert_governance.go b/internal/data/business/alert_governance.go new file mode 100644 index 0000000..58506f2 --- /dev/null +++ b/internal/data/business/alert_governance.go @@ -0,0 +1,290 @@ +package business + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + "kra/internal/biz/business" +) + +type alertGapPO struct { + OwnerID uint `gorm:"primaryKey;autoIncrement:false"` + Enabled bool + Days int + UpdatedAt time.Time +} + +func (alertGapPO) TableName() string { return "sys_alert_gap" } + +type alertExemptionPO struct { + ID uint64 `gorm:"primaryKey"` + SourceDB string `gorm:"size:50;index:idx_alert_exempt_channel,priority:1"` + PromoteID uint64 `gorm:"index:idx_alert_exempt_channel,priority:2"` + ChannelName string `gorm:"size:100"` + OwnerID uint `gorm:"index:idx_alert_exempt_owner,priority:1"` + AddedBy uint + AddedName string `gorm:"size:100"` + Reason string `gorm:"size:500"` + AddedAt time.Time + RemovedBy uint + RemovedName string `gorm:"size:100"` + RemoveReason string `gorm:"size:100"` + RemovedAt *time.Time `gorm:"index:idx_alert_exempt_owner,priority:2;index:idx_alert_exempt_channel,priority:3"` +} + +func (alertExemptionPO) TableName() string { return "sys_alert_exemption" } + +// 所有渠道豁免变更和预警生成共用渠道锁,防止已加入豁免后仍插入新预警。 +type alertChannelLockPO struct { + SourceDB string `gorm:"primaryKey;size:50"` + PromoteID uint64 `gorm:"primaryKey;autoIncrement:false"` +} + +func (alertChannelLockPO) TableName() string { return "sys_alert_channel_lock" } +func lockAlertChannel(tx *gorm.DB, source string, id uint64) error { + key := alertChannelLockPO{SourceDB: source, PromoteID: id} + if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&key).Error; err != nil { + return err + } + return tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("source_db=? AND promote_id=?", source, id).Take(&key).Error +} +func toBizAlertExemption(p alertExemptionPO) *business.AlertExemption { + return &business.AlertExemption{ID: p.ID, ChannelKey: business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}, ChannelName: p.ChannelName, OwnerID: p.OwnerID, AddedBy: p.AddedBy, AddedName: p.AddedName, Reason: p.Reason, AddedAt: p.AddedAt, RemovedBy: p.RemovedBy, RemovedName: p.RemovedName, RemoveReason: p.RemoveReason, RemovedAt: p.RemovedAt} +} + +type alertGovernanceRepo struct{ data Provider } + +func NewAlertGovernanceRepo(data Provider) business.AlertGovernanceRepo { + return &alertGovernanceRepo{data} +} + +func (r *alertGovernanceRepo) Candidates(ctx context.Context, scope business.UserScope, keyword string, limit int) ([]*business.AlertChannelOption, error) { + db := r.data.DB().WithContext(ctx) + bindings := db.Table("sys_user_bind_platform b").Select("1").Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL").Where("b.status='0' AND b.source_db=p.source_db AND b.platform_busier_id=p.busier_id") + bindings = applyUserScope(bindings, scope, "u.") + q := db.Table("tab_promote p").Where("p.promote_level=1 AND p.source_db IN ?", []string{"lx", "hw", "yq"}).Where("EXISTS (?)", bindings) + if keyword != "" { + q = q.Where("p.account LIKE ?", "%"+keyword+"%") + } + var rows []struct { + ID uint64 + SourceDB, Name string + } + if err := q.Select("p.id,p.source_db,p.account AS name").Order("p.source_db,p.account,p.id").Limit(limit).Scan(&rows).Error; err != nil { + return nil, business.ErrStorage + } + result := make([]*business.AlertChannelOption, 0, len(rows)) + for _, row := range rows { + result = append(result, &business.AlertChannelOption{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}, Name: row.Name}) + } + return result, nil +} +func (r *alertGovernanceRepo) Gap(ctx context.Context, id uint) (*business.AlertGap, error) { + var p alertGapPO + err := r.data.DB().WithContext(ctx).Where("owner_id=?", id).Take(&p).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return &business.AlertGap{OwnerID: id, Days: 1}, nil + } + if err != nil { + return nil, business.ErrStorage + } + return &business.AlertGap{OwnerID: p.OwnerID, Enabled: p.Enabled, Days: p.Days}, nil +} +func (r *alertGovernanceRepo) SaveGap(ctx context.Context, gap *business.AlertGap) error { + p := alertGapPO{OwnerID: gap.OwnerID, Enabled: gap.Enabled, Days: gap.Days, UpdatedAt: time.Now()} + return storageError(r.data.DB().WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "owner_id"}}, DoUpdates: clause.AssignmentColumns([]string{"enabled", "days", "updated_at"})}).Create(&p).Error) +} +func (r *alertGovernanceRepo) Exemptions(ctx context.Context, scope business.LevelOneScope, filter business.AlertExemptionFilter, opts ...business.ListOption) ([]*business.AlertExemption, int64, error) { + q := applyOwnerScope(r.data.DB().WithContext(ctx).Model(&alertExemptionPO{}), scope, "owner_id") + if filter.Name != "" { + q = q.Where("channel_name LIKE ?", "%"+filter.Name+"%") + } + if filter.Active != nil { + if *filter.Active { + q = q.Where("removed_at IS NULL") + } else { + q = q.Where("removed_at IS NOT NULL") + } + } + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, business.ErrStorage + } + o := business.NewListOptions(opts...) + var pos []alertExemptionPO + if err := q.Order("id DESC").Offset(o.Offset).Limit(o.Limit).Find(&pos).Error; err != nil { + return nil, 0, business.ErrStorage + } + rows := make([]*business.AlertExemption, 0, len(pos)) + for _, p := range pos { + rows = append(rows, toBizAlertExemption(p)) + } + return rows, total, nil +} +func alertExemptionTarget(db *gorm.DB, scope business.UserScope, key business.ChannelKey) (*business.AlertExemptionTarget, error) { + q := db.Table("tab_promote p").Joins("JOIN sys_user_bind_platform b ON b.source_db=p.source_db AND b.platform_busier_id=p.busier_id AND b.status='0'").Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL").Where("p.source_db=? AND p.id=? AND p.promote_level=1", key.SourceDB, key.ID) + allowed := applyUserScope(db.Table("sys_users viewer").Select("1").Where("viewer.id=u.id"), scope, "viewer.") + var pos []struct { + Name string + UserID uint + Level int + IDPath string + Allowed bool + } + if err := q.Clauses(clause.Locking{Strength: "UPDATE"}).Select("p.account AS name,u.id AS user_id,u.level,u.id_path,EXISTS (?) AS allowed", allowed).Distinct().Limit(2).Scan(&pos).Error; err != nil { + return nil, business.ErrStorage + } + if len(pos) != 1 || !pos[0].Allowed { + return nil, business.ErrAlertRecordScope + } + p := pos[0] + return &business.AlertExemptionTarget{Name: p.Name, Account: &business.Account{ID: p.UserID, Level: p.Level, IDPath: p.IDPath}}, nil +} +func (r *alertGovernanceRepo) AddExemption(ctx context.Context, scope business.UserScope, key business.ChannelKey, build func(*business.AlertExemptionTarget) (*business.AlertExemption, error)) error { + return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := lockAlertChannel(tx, key.SourceDB, key.ID); err != nil { + return err + } + target, err := alertExemptionTarget(tx, scope, key) + if err != nil { + return err + } + value, err := build(target) + if err != nil { + return err + } + var count int64 + if err := tx.Model(&alertExemptionPO{}).Where("source_db=? AND promote_id=? AND owner_id=? AND removed_at IS NULL", value.SourceDB, value.ChannelKey.ID, value.OwnerID).Count(&count).Error; err != nil { + return err + } + if count > 0 { + return business.ErrAlertExemptExists + } + p := alertExemptionPO{SourceDB: value.SourceDB, PromoteID: value.ChannelKey.ID, ChannelName: value.ChannelName, OwnerID: value.OwnerID, AddedBy: value.AddedBy, AddedName: value.AddedName, Reason: value.Reason, AddedAt: value.AddedAt} + return tx.Create(&p).Error + })) +} +func (r *alertGovernanceRepo) RemoveExemption(ctx context.Context, scope business.LevelOneScope, id uint64, operator uint, name, reason string, now time.Time) error { + return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var p alertExemptionPO + if err := applyOwnerScope(tx.Model(&alertExemptionPO{}), scope, "owner_id").Where("id=? AND removed_at IS NULL", id).Take(&p).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return business.ErrAlertExemptNotFound + } + return err + } + if err := lockAlertChannel(tx, p.SourceDB, p.PromoteID); err != nil { + return err + } + result := tx.Model(&alertExemptionPO{}).Where("id=? AND removed_at IS NULL", id).Updates(map[string]any{"removed_at": now, "removed_by": operator, "removed_name": name, "remove_reason": reason}) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return business.ErrAlertExemptNotFound + } + return nil + })) +} + +// 按渠道等值、成功支付及时间范围索引读取,避免为每条预警全表统计历史流水。 +func alertLostQuery(db *gorm.DB, sourceColumn, idColumn string, window business.AlertLossWindow) *gorm.DB { + pay := db.Table("tab_spend ls").Select("COALESCE(SUM(ls.pay_amount),0)").Where("ls.source_db=lp.source_db AND ls.promote_id=lp.id AND ls.pay_status=1 AND ls.pay_time>=? AND ls.pay_time0 AND COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)?", cursor).Order("id").Limit(500).Find(&batch).Error; err != nil { + return nil, business.ErrStorage + } + if len(batch) == 0 { + break + } + keys := make([]business.ChannelKey, 0, len(batch)) + for _, p := range batch { + keys = append(keys, business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}) + } + states, err := loadAlertLoss(db, keys, window) + if err != nil { + return nil, err + } + for _, p := range batch { + if lost := states[business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}]; lost != nil && *lost { + result = append(result, toBizAlertExemption(p)) + } + } + cursor = batch[len(batch)-1].ID + } + return result, nil +} + +// 同一页的重复渠道只计算一次;按固定平台、渠道集合和时间范围扫描覆盖索引。 +func loadAlertLoss(db *gorm.DB, keys []business.ChannelKey, window business.AlertLossWindow) (map[business.ChannelKey]*bool, error) { + bySource := map[string][]uint64{} + seen := map[business.ChannelKey]bool{} + for _, key := range keys { + if !seen[key] { + bySource[key.SourceDB] = append(bySource[key.SourceDB], key.ID) + seen[key] = true + } + } + result := map[business.ChannelKey]*bool{} + for source, ids := range bySource { + for start := 0; start < len(ids); start += 500 { + batch := ids[start:min(start+500, len(ids))] + var channels []struct { + ID uint64 + LastLoginTime, CreateTime int64 + } + if err := db.Table("tab_promote").Select("id,last_login_time,create_time").Where("source_db=? AND id IN ?", source, batch).Scan(&channels).Error; err != nil { + return nil, business.ErrStorage + } + var amounts []struct { + PromoteID uint64 + RecentAmount string + } + if err := db.Table("tab_spend").Select("promote_id,COALESCE(SUM(pay_amount),0) AS recent_amount").Where("source_db=? AND promote_id IN ? AND pay_status=1 AND pay_time>=? AND pay_time0) AS first_unix") + args = append(args, id, source, id) + } + var first []struct { + PromoteID uint64 + FirstUnix *int64 + } + if err := db.Raw(strings.Join(firstQueries, " UNION ALL "), args...).Scan(&first).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range first { + if row.FirstUnix == nil { + continue + } + for _, root := range owners[business.ChannelKey{SourceDB: source, ID: row.PromoteID}] { + v := result[root] + if v.FirstUnix == 0 || *row.FirstUnix < v.FirstUnix { + v.FirstUnix = *row.FirstUnix + } + result[root] = v + } + } + var rows []struct { + PromoteID uint64 + CurrentValue, PreviousValue string + } + selection := "m.promote_id,COALESCE(SUM(CASE WHEN " + stamp + ">=? THEN " + amount + " ELSE 0 END),0) AS current_value,COALESCE(SUM(CASE WHEN " + stamp + "=? AND "+stamp+"'' THEN 'card:' || TRIM(m.idcard) ELSE 'user:' || " + identity + " END" + if db.Dialector.Name() == "mysql" { + identity = "CASE WHEN TRIM(COALESCE(m.idcard,''))<>'' THEN CONCAT('card:',TRIM(m.idcard)) ELSE CONCAT('user:',m.id) END" + } + } + q := alertTree(db, batch).Joins("JOIN tab_user m ON m.source_db=p.source_db AND m.promote_id=p.id").Where(condition).Where(stamp+"<=?", now.Unix()) + if err := q.Select("root.source_db,root.id AS promote_id,COUNT(DISTINCT " + identity + ") AS players").Group("root.source_db,root.id").Scan(&players).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range players { + key := business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID} + v := result[key] + v.Players = row.Players + result[key] = v + } + var payments []struct { + SourceDB string + PromoteID uint64 + Amount string + } + if err := alertTree(db, batch).Joins("JOIN tab_spend m ON m.source_db=p.source_db AND m.promote_id=p.id").Where("m.pay_status=1 AND m.pay_time<=?", now.Unix()).Select("root.source_db,root.id AS promote_id,COALESCE(SUM(m.pay_amount),0) AS amount").Group("root.source_db,root.id").Scan(&payments).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range payments { + key := business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID} + v := result[key] + v.Pay = row.Amount + result[key] = v + } + } + return result, nil +} diff --git a/internal/data/business/alert_models.go b/internal/data/business/alert_models.go index 5811ca0..a736103 100644 --- a/internal/data/business/alert_models.go +++ b/internal/data/business/alert_models.go @@ -3,6 +3,9 @@ package business import "time" type alertRulePO struct { + ConditionMode int `gorm:"not null;default:1"` + AmountDirection int `gorm:"not null;default:1"` + AmountThreshold *string `gorm:"type:decimal(14,2)"` ID uint64 `gorm:"primaryKey"` RuleName string `gorm:"size:100;not null"` RuleType string `gorm:"size:50;not null"` @@ -22,8 +25,12 @@ type alertRulePO struct { func (alertRulePO) TableName() string { return "sys_alert_rule" } type alertRecordPO struct { - ID uint64 `gorm:"primaryKey"` - PlatformCode, PlatformName string `gorm:"size:50"` + IndependentRule bool `gorm:"not null;default:0"` + TotalPlayers *int64 + TotalPay *string `gorm:"type:decimal(20,2)"` + WarningColor *string `gorm:"size:20"` + ID uint64 `gorm:"primaryKey"` + PlatformCode, PlatformName string `gorm:"size:50"` RuleID uint64 RuleName string `gorm:"size:100"` RuleType string `gorm:"size:50"` diff --git a/internal/data/business/alert_mysql_test.go b/internal/data/business/alert_mysql_test.go new file mode 100644 index 0000000..dad0fb1 --- /dev/null +++ b/internal/data/business/alert_mysql_test.go @@ -0,0 +1,133 @@ +package business + +import ( + "context" + "strings" + "testing" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/logger" + "kra/internal/biz/business" +) + +type alertSQLCapture struct { + logger.Interface + queries []string + elapsed []time.Duration +} + +func (l *alertSQLCapture) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + sql, _ := fc() + if strings.Contains(sql, "AS current_value") || strings.Contains(sql, "AS first_unix") || strings.Contains(sql, "AS recent_amount") { + l.queries = append(l.queries, sql) + l.elapsed = append(l.elapsed, time.Since(begin)) + } + l.Interface.Trace(ctx, begin, fc, err) +} + +func TestMySQLAlertV2MigrationAndRangePlans(t *testing.T) { + db := openRehearsalMySQL(t) + now := prepareAlertV2(t, db) + // 模拟尚未升级的真实旧表,覆盖普通列合并迁移路径。 + for _, sql := range []string{ + "ALTER TABLE sys_alert_record DROP INDEX uk_alert_record_v2_open", + "ALTER TABLE sys_alert_record DROP COLUMN v2_open_key", + "ALTER TABLE sys_alert_record DROP COLUMN independent_rule, DROP COLUMN total_players, DROP COLUMN total_pay, DROP COLUMN warning_color", + "ALTER TABLE sys_alert_rule DROP COLUMN condition_mode, DROP COLUMN amount_direction, DROP COLUMN amount_threshold", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + + if err := db.Exec("ALTER TABLE sys_alert_record ADD COLUMN unclosed_flag TINYINT GENERATED ALWAYS AS (CASE WHEN status=5 THEN NULL ELSE 1 END) STORED, ADD UNIQUE INDEX uk_alert_record_unclosed(platform_code,target_promote_id,rule_type,unclosed_flag)").Error; err != nil { + t.Fatal(err) + } + if err := migrateAlertV2(db); err != nil { + t.Fatal(err) + } + if err := migrateAlertV2(db); err != nil { + t.Fatalf("重复迁移: %v", err) + } + if db.Migrator().HasIndex("sys_alert_record", "uk_alert_record_unclosed") { + t.Fatal("旧唯一键阻挡已忽略或独立绝对值规则") + } + if err := db.Exec("ALTER TABLE sys_alert_record DROP INDEX uk_alert_record_v2_open, ADD UNIQUE INDEX uk_alert_record_v2_open(platform_code,target_promote_id,rule_type,v2_open_key)").Error; err != nil { + t.Fatal(err) + } + if err := migrateAlertV2UniqueKey(db); err != nil { + t.Fatalf("已有 V2 索引升级失败: %v", err) + } + rows := make([]map[string]any, 10000) + for i := range rows { + rows[i] = map[string]any{"id": 100 + i, "source_db": "lx", "promote_id": 1, "pay_status": 1, "pay_time": now.AddDate(-1, 0, 0).Unix(), "pay_amount": "1.00"} + } + if err := db.Table("tab_spend").CreateInBatches(rows, 500).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("ANALYZE TABLE tab_spend,tab_promote").Error; err != nil { + t.Fatal(err) + } + capture := &alertSQLCapture{Interface: logger.Default.LogMode(logger.Silent)} + r := NewAlertTriggerRepo(testData{db.Session(&gorm.Session{Logger: capture})}) + key := business.ChannelKey{SourceDB: "lx", ID: 1} + values, err := r.Comparison(context.Background(), 2, []business.ChannelKey{key}, now, 1) + if err != nil || values[key].Current != "3000.00" || values[key].Previous != "100.00" { + t.Fatalf("MySQL 周期计算: %+v %v", values, err) + } + if _, err := loadAlertLoss(db.Session(&gorm.Session{Logger: capture}), []business.ChannelKey{key, key}, business.AlertLossAt(now)); err != nil { + t.Fatal(err) + } + for i, sql := range capture.queries { + var plan []struct { + Table string + Type string + Key string + Rows int64 + Extra string + } + if err := db.Raw("EXPLAIN " + sql).Scan(&plan).Error; err != nil { + t.Fatal(err) + } + t.Logf("query %d elapsed=%v plan=%+v", i, capture.elapsed[i], plan) + for _, step := range plan { + if (strings.Contains(sql, "AS current_value") || strings.Contains(sql, "AS recent_amount")) && (step.Table == "m" || step.Table == "tab_spend") && (step.Type != "range" || step.Rows > 100) { + t.Fatalf("预警周期统计必须按时间范围读取,不能扫描整个渠道历史: %+v", plan) + } + } + } + hit := &business.AlertRecord{PlatformCode: "lx", TargetPromoteID: 1, RuleID: 1, RuleType: "充值", IndependentRule: true, GapDays: 7, Status: 1, CreatedAt: now, UpdatedAt: now} + decide := func(r *business.AlertRecord) bool { return r == nil || r.Status == 1 } + for _, id := range []uint64{1, 2} { + hit.RuleID = id + if ok, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide); err != nil || !ok { + t.Fatalf("独立规则被唯一键挡住: %v %v", ok, err) + } + } + if err := db.Model(&alertRecordPO{}).Where("rule_id=1").Update("status", 6).Error; err != nil { + t.Fatal(err) + } + hit.RuleID = 1 + hit.CreatedAt = now.AddDate(0, 0, 7) + if ok, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide); err != nil || !ok { + t.Fatalf("已忽略仍占用唯一键: %v %v", ok, err) + } +} + +func TestMySQLAlertMigrationKeepsLegacyConstraintOnUnexpectedIndex(t *testing.T) { + db := openRehearsalMySQL(t) + for _, sql := range []string{ + "ALTER TABLE sys_alert_record DROP INDEX uk_alert_record_v2_open, ADD INDEX uk_alert_record_v2_open(platform_code), ADD UNIQUE INDEX uk_alert_record_daily(platform_code,target_promote_id,rule_type,alert_date)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + if err := migrateAlertV2UniqueKey(db); err == nil { + t.Fatal("意外的现有索引定义不应被当成迁移成功") + } + if !db.Migrator().HasIndex("sys_alert_record", "uk_alert_record_daily") { + t.Fatal("验证失败前不能移除旧约束") + } +} diff --git a/internal/data/business/alert_record.go b/internal/data/business/alert_record.go index e1a3624..dfd3c3b 100644 --- a/internal/data/business/alert_record.go +++ b/internal/data/business/alert_record.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "sort" "strconv" "time" @@ -16,8 +17,17 @@ type alertRecordRepo struct{ data Provider } func NewAlertRecordRepo(data Provider) business.AlertRecordRepo { return &alertRecordRepo{data: data} } +func (r *alertRecordRepo) Platforms(ctx context.Context, scope business.AlertRecordScope) ([]string, error) { + rows := []string{} + if scope.Owner.Empty { + return rows, nil + } + err := alertBindingQuery(r.data.DB().WithContext(ctx), scope.Viewer).Distinct().Order("b.source_db").Pluck("b.source_db", &rows).Error + return rows, storageError(err) +} + func toBizAlertRecord(po alertRecordPO) *business.AlertRecord { - return &business.AlertRecord{ID: po.ID, RuleID: po.RuleID, PlatformCode: po.PlatformCode, PlatformName: po.PlatformName, RuleName: po.RuleName, RuleType: po.RuleType, MonitorMetric: po.MonitorMetric, TargetPromoteID: po.TargetPromoteID, TargetPromoteName: po.TargetPromoteName, BusinessID: po.BusinessID, BusinessName: po.BusinessName, AlertContent: po.AlertContent, Status: po.Status, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt} + return &business.AlertRecord{ID: po.ID, TotalPlayers: po.TotalPlayers, TotalPay: po.TotalPay, WarningColor: po.WarningColor, RuleID: po.RuleID, PlatformCode: po.PlatformCode, PlatformName: po.PlatformName, RuleName: po.RuleName, RuleType: po.RuleType, MonitorMetric: po.MonitorMetric, TargetPromoteID: po.TargetPromoteID, TargetPromoteName: po.TargetPromoteName, BusinessID: po.BusinessID, BusinessName: po.BusinessName, AlertContent: po.AlertContent, Status: po.Status, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt} } func alertBindingQuery(db *gorm.DB, scope business.UserScope) *gorm.DB { @@ -50,11 +60,11 @@ func prepareAlertScope(db *gorm.DB, scope business.AlertRecordScope) (empty, ava if s.Empty { return true, true, nil } - var count int64 - if err := alertBindingQuery(db, s).Count(&count).Error; err != nil { + var bindings []struct{ UserID uint } + if err := alertBindingQuery(db, s).Select("b.user_id").Limit(1).Scan(&bindings).Error; err != nil { return false, false, business.ErrStorage } - if count == 0 { + if len(bindings) == 0 { return true, true, nil } } @@ -97,6 +107,14 @@ func (r *alertRecordRepo) List(ctx context.Context, scope business.AlertRecordSc return page, nil } } + lossAvailable, err := businessTablesAvailable(db, "tab_promote", "tab_spend") + if err != nil { + return nil, err + } + if filter.Lost != nil && !lossAvailable { + return nil, business.ErrAlertChannelsUnavailable + } + lossWindow := business.AlertLossAt(time.Now()) base := func() *gorm.DB { q := alertScopedQuery(db, scope) if len(filter.PlatformCodes) > 0 { @@ -110,24 +128,49 @@ func (r *alertRecordRepo) List(ctx context.Context, scope business.AlertRecordSc } return q } - q := base() - for _, f := range []struct{ column, value string }{{"target_promote_name", filter.TargetPromoteName}, {"rule_name", filter.RuleName}, {"business_name", filter.BusinessName}} { - if f.value != "" { - q = q.Where("r."+f.column+" LIKE ?", "%"+f.value+"%") + filtered := func() *gorm.DB { + q := base() + for _, f := range []struct{ column, value string }{{"target_promote_name", filter.TargetPromoteName}, {"rule_name", filter.RuleName}, {"business_name", filter.BusinessName}} { + if f.value != "" { + q = q.Where("r."+f.column+" LIKE ?", "%"+f.value+"%") + } } + if filter.RuleType != "" { + q = q.Where("r.rule_type = ?", filter.RuleType) + } + if filter.MonitorMetric != nil { + q = q.Where("r.monitor_metric = ?", *filter.MonitorMetric) + } + if filter.AlertContent != "" { + q = q.Where("r.alert_content LIKE ?", "%"+filter.AlertContent+"%") + } + if filter.Lost != nil { + q = q.Where("EXISTS (?)", db.Table("tab_promote known").Select("1").Where("known.source_db=r.platform_code AND known.id=r.target_promote_id")) + q = q.Where("EXISTS (?) = ?", alertLostQuery(db, "r.platform_code", "r.target_promote_id", lossWindow), *filter.Lost) + } + return q } - if filter.RuleType != "" { - q = q.Where("r.rule_type = ?", filter.RuleType) - } - if filter.MonitorMetric != nil { - q = q.Where("r.monitor_metric = ?", *filter.MonitorMetric) - } + q := filtered() if filter.Status != nil { q = q.Where("r.status = ?", *filter.Status) } - if err := q.Count(&page.Total).Error; err != nil { + + // 六态卡片继承业务筛选,忽略状态自身,使切换状态时其他计数仍可见。 + var counts []struct { + Status int + Count int64 + } + if err := filtered().Select("r.status,COUNT(*) AS count").Group("r.status").Scan(&counts).Error; err != nil { return nil, business.ErrStorage } + for _, c := range counts { + page.StatusCount[c.Status] = c.Count + } + for status, count := range page.StatusCount { + if filter.Status == nil || status == *filter.Status { + page.Total += count + } + } o := business.NewListOptions(options...) var pos []alertRecordPO if err := q.Select("r.*").Order("r.created_at DESC,r.id DESC").Offset(o.Offset).Limit(o.Limit).Scan(&pos).Error; err != nil { @@ -136,20 +179,68 @@ func (r *alertRecordRepo) List(ctx context.Context, scope business.AlertRecordSc for _, po := range pos { page.Rows = append(page.Rows, toBizAlertRecord(po)) } - // 顶部汇总忽略名称、类型、指标与状态筛选,只保留数据范围、平台和指定渠道。 - var counts []struct { - Status int - Count int64 - } - if err := base().Select("r.status,COUNT(*) AS count").Group("r.status").Scan(&counts).Error; err != nil { - return nil, business.ErrStorage - } - for _, c := range counts { - page.StatusCount[c.Status] = c.Count + if lossAvailable && len(pos) > 0 { + keys := make([]business.ChannelKey, 0, len(pos)) + for _, p := range pos { + keys = append(keys, business.ChannelKey{SourceDB: p.PlatformCode, ID: p.TargetPromoteID}) + } + states, err := loadAlertLoss(db, keys, lossWindow) + if err != nil { + return nil, err + } + for _, row := range page.Rows { + row.Lost = states[business.ChannelKey{SourceDB: row.PlatformCode, ID: row.TargetPromoteID}] + } } + return page, nil } +func (r *alertRecordRepo) Ignore(ctx context.Context, scope business.AlertRecordScope, ids []uint64, log *business.AlertLog) error { + ids = append([]uint64(nil), ids...) + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return storageError(r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + empty, available, err := prepareAlertScope(tx, scope) + if err != nil { + return err + } + if empty { + return business.ErrAlertRecordScope + } + if !available { + return business.ErrAlertChannelsUnavailable + } + var rows []alertRecordPO + if err := alertScopedQuery(tx, scope).Select("r.*").Where("r.id IN ?", ids).Order("r.id").Clauses(clause.Locking{Strength: "UPDATE"}).Scan(&rows).Error; err != nil { + return err + } + unique := map[uint64]bool{} + for _, id := range ids { + unique[id] = true + } + if len(rows) != len(unique) { + return business.ErrAlertRecordScope + } + now := time.Now() + logs := []alertLogPO{} + changed := []uint64{} + for _, row := range rows { + if row.Status == business.AlertIgnored { + continue + } + changed = append(changed, row.ID) + logs = append(logs, alertLogPO{AlertID: row.ID, OperatorID: log.OperatorID, OperatorName: log.OperatorName, LogType: log.LogType, ActionDetail: log.ActionDetail, CreatedAt: now}) + } + if len(changed) == 0 { + return nil + } + if err := tx.Model(&alertRecordPO{}).Where("id IN ?", changed).Updates(map[string]any{"status": business.AlertIgnored, "updated_at": now}).Error; err != nil { + return err + } + return tx.CreateInBatches(logs, 100).Error + })) +} + func findScopedAlert(db *gorm.DB, scope business.AlertRecordScope, id uint64, lock bool) (*alertRecordPO, error) { empty, available, err := prepareAlertScope(db, scope) if err != nil { diff --git a/internal/data/business/alert_record_test.go b/internal/data/business/alert_record_test.go index 07e8e67..dbc6aaa 100644 --- a/internal/data/business/alert_record_test.go +++ b/internal/data/business/alert_record_test.go @@ -58,8 +58,8 @@ func TestAlertRecordsScopeAndSummaryUseDifferentFilters(t *testing.T) { scope := alertTestScope(7) status := 1 page, err := repo.List(ctx, scope, business.AlertRecordFilter{RuleName: "注册", Status: &status}) - if err != nil || page.Total != 1 || len(page.StatusCount) != 3 || page.StatusCount[3] != 1 || page.StatusCount[2] != 1 { - t.Fatalf("汇总错误继承名称或状态筛选: %+v %v", page, err) + if err != nil || page.Total != 1 || len(page.StatusCount) != 1 || page.StatusCount[1] != 1 { + t.Fatalf("汇总应继承名称筛选并忽略状态自身: %+v %v", page, err) } selected := alertTestScope(8).Viewer scope.Selected = &selected diff --git a/internal/data/business/alert_review_test.go b/internal/data/business/alert_review_test.go new file mode 100644 index 0000000..fd61477 --- /dev/null +++ b/internal/data/business/alert_review_test.go @@ -0,0 +1,122 @@ +package business + +import ( + "context" + "gorm.io/gorm" + "kra/internal/biz/business" + "testing" + "time" +) + +func TestAlertWritesRecheckCurrentChannelOwnerAndRejectConflictingBindings(t *testing.T) { + exerciseAlertOwnerRecheck(t, openTestDB(t)) +} +func TestMySQLAlertWritesRecheckCurrentChannelOwner(t *testing.T) { + exerciseAlertOwnerRecheck(t, openRehearsalMySQL(t)) +} +func exerciseAlertOwnerRecheck(t *testing.T, db *gorm.DB) { + t.Helper() + now := prepareAlertV2(t, db) + if err := db.AutoMigrate(&accountPO{}); err != nil { + t.Fatal(err) + } + for _, sql := range []string{ + "INSERT INTO sys_users(id,username,level,id_path) VALUES(7,'原商务',1,'0'),(8,'新商务',1,'0')", + "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',7,'0')", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + ctx := context.Background() + scope := business.UserScope{SelfID: 7, IncludeSelf: true, DescendantPath: "0,7"} + hit := &business.AlertRecord{OwnerScope: &scope, PlatformCode: "lx", TargetPromoteID: 1, BusinessID: 7, RuleID: 1, RuleType: "充值", Status: 1, AlertContent: "换绑前", CreatedAt: now, UpdatedAt: now} + trigger := NewAlertTriggerRepo(testData{db}) + write := func(want bool) { + t.Helper() + got, err := trigger.StoreHit(ctx, hit, []int{1, 2, 3, 4}, func(*business.AlertRecord) bool { return true }) + if err != nil || got != want { + t.Fatalf("written=%v want=%v err=%v", got, want, err) + } + } + write(true) + oldExemption := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, OwnerID: 7, AddedBy: 7, AddedName: "原商务", Reason: "原商务的豁免", AddedAt: now} + if err := NewAlertGovernanceRepo(testData{db}).AddExemption(ctx, scope, oldExemption.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return oldExemption, nil }); err != nil { + t.Fatal(err) + } + + if err := db.Exec("UPDATE sys_user_bind_platform SET user_id=8 WHERE source_db='lx'").Error; err != nil { + t.Fatal(err) + } + hit.AlertContent = "换绑后的私有数据" + write(false) + var old alertRecordPO + if err := db.First(&old).Error; err != nil { + t.Fatal(err) + } + if old.AlertContent != "换绑前" { + t.Fatal("旧商务预警被新归属数据刷新") + } + newHit := *hit + newScope := business.UserScope{SelfID: 8, IncludeSelf: true, DescendantPath: "0,8"} + newHit.OwnerScope = &newScope + newHit.BusinessID = 8 + newHit.RuleID = 2 + if ok, err := trigger.StoreHit(ctx, &newHit, []int{1, 2, 3, 4}, func(*business.AlertRecord) bool { return true }); err != nil || !ok { + t.Fatalf("新商务应产生独立预警: %v %v", ok, err) + } + var histories []alertRecordPO + if err := db.Order("id").Find(&histories).Error; err != nil { + t.Fatal(err) + } + if len(histories) != 2 || histories[0].BusinessID != 7 || histories[0].AlertContent != "换绑前" || histories[1].BusinessID != 8 { + t.Fatalf("旧商务预警不应被转移给新商务: %+v", histories) + } + governance := NewAlertGovernanceRepo(testData{db}) + called := false + build := func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { + called = true + return nil, business.ErrAlertRecordInput + } + if err := governance.AddExemption(ctx, scope, business.ChannelKey{SourceDB: "lx", ID: 1}, build); err != business.ErrAlertRecordScope || called { + t.Fatalf("换绑后仍允许免预警: %v called=%v", err, called) + } + if err := db.Exec("INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',7,'0')").Error; err != nil { + t.Fatal(err) + } + if err := governance.AddExemption(ctx, scope, business.ChannelKey{SourceDB: "lx", ID: 1}, build); err != business.ErrAlertRecordScope || called { + t.Fatal("不能忽略范围外的冲突绑定") + } +} + +func TestAlertLossMissingChannelRemainsUnknown(t *testing.T) { + db := openTestDB(t) + now := prepareAlertV2(t, db) + if err := db.Create(&alertRecordPO{ID: 1, PlatformCode: "lx", TargetPromoteID: 999, Status: 1, CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + scope := business.AlertRecordScope{Owner: business.LevelOneScope{All: true}, Viewer: business.UserScope{All: true}} + r := NewAlertRecordRepo(testData{db}) + page, err := r.List(context.Background(), scope, business.AlertRecordFilter{}) + if err != nil || page.Total != 1 || page.Rows[0].Lost != nil { + t.Fatalf("缺失渠道不应被展示为未流失: %+v %v", page, err) + } + lost := false + page, err = r.List(context.Background(), scope, business.AlertRecordFilter{Lost: &lost}) + if err != nil || page.Total != 0 { + t.Fatalf("未流失筛选应排除未知渠道: %+v %v", page, err) + } +} + +func TestAlertComparisonIgnoresInvalidHistoricalTimestamps(t *testing.T) { + db := openTestDB(t) + now := prepareAlertV2(t, db) + if err := db.Exec("INSERT INTO tab_user VALUES(99,'lx',1,0,'','unknown')").Error; err != nil { + t.Fatal(err) + } + key := business.ChannelKey{SourceDB: "lx", ID: 1} + values, err := NewAlertTriggerRepo(testData{db}).Comparison(context.Background(), 1, []business.ChannelKey{key}, now, 1) + if err != nil || values[key].FirstUnix != now.Add(-48*time.Hour).Unix() { + t.Fatalf("无效时间不应阻止正常渠道触发: %+v %v", values, err) + } +} diff --git a/internal/data/business/alert_rule.go b/internal/data/business/alert_rule.go index 71ffe0f..dd1c80f 100644 --- a/internal/data/business/alert_rule.go +++ b/internal/data/business/alert_rule.go @@ -12,10 +12,10 @@ type alertRuleRepo struct{ data Provider } func NewAlertRuleRepo(data Provider) business.AlertRuleRepo { return &alertRuleRepo{data: data} } func toBizAlertRule(po alertRulePO) *business.AlertRule { - return &business.AlertRule{ID: po.ID, Name: po.RuleName, Type: po.RuleType, Metric: po.MonitorMetric, StatPeriodDays: po.StatPeriodDays, MomDirection: po.MomDirection, MomThreshold: po.MomThreshold, UnloginDays: po.UnloginDays, MonitorScope: po.MonitorScope, WarningColor: po.WarningColor, CreatorID: po.CreatorID, Enabled: po.Enabled, IsDeleted: po.IsDeleted, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt} + return &business.AlertRule{ID: po.ID, Name: po.RuleName, Type: po.RuleType, Metric: po.MonitorMetric, ConditionMode: po.ConditionMode, AmountDirection: po.AmountDirection, AmountThreshold: po.AmountThreshold, StatPeriodDays: po.StatPeriodDays, MomDirection: po.MomDirection, MomThreshold: po.MomThreshold, UnloginDays: po.UnloginDays, MonitorScope: po.MonitorScope, WarningColor: po.WarningColor, CreatorID: po.CreatorID, Enabled: po.Enabled, IsDeleted: po.IsDeleted, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt} } func newAlertRule(r *business.AlertRule) alertRulePO { - return alertRulePO{ID: r.ID, RuleName: r.Name, RuleType: r.Type, MonitorMetric: r.Metric, StatPeriodDays: r.StatPeriodDays, MomDirection: r.MomDirection, MomThreshold: r.MomThreshold, UnloginDays: r.UnloginDays, MonitorScope: r.MonitorScope, WarningColor: r.WarningColor, CreatorID: r.CreatorID, Enabled: r.Enabled, IsDeleted: r.IsDeleted} + return alertRulePO{ID: r.ID, RuleName: r.Name, RuleType: r.Type, MonitorMetric: r.Metric, ConditionMode: r.ConditionMode, AmountDirection: r.AmountDirection, AmountThreshold: r.AmountThreshold, StatPeriodDays: r.StatPeriodDays, MomDirection: r.MomDirection, MomThreshold: r.MomThreshold, UnloginDays: r.UnloginDays, MonitorScope: r.MonitorScope, WarningColor: r.WarningColor, CreatorID: r.CreatorID, Enabled: r.Enabled, IsDeleted: r.IsDeleted} } func applyOwnerScope(db *gorm.DB, scope business.LevelOneScope, column string) *gorm.DB { if scope.All { @@ -59,7 +59,7 @@ func (r *alertRuleRepo) List(ctx context.Context, scope business.LevelOneScope, } func (r *alertRuleRepo) Find(ctx context.Context, id uint64) (*business.AlertRule, error) { var po alertRulePO - err := r.data.DB().WithContext(ctx).First(&po, "id = ?", id).Error + err := r.data.DB().WithContext(ctx).First(&po, "id = ? AND is_deleted = 0", id).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, business.ErrAlertRuleNotFound } @@ -82,7 +82,7 @@ func (r *alertRuleRepo) Create(ctx context.Context, rule *business.AlertRule) er } func (r *alertRuleRepo) Update(ctx context.Context, rule *business.AlertRule) error { // 编辑不覆盖启停、删除和创建人字段,避免并发启停被旧配置覆盖。 - return storageError(r.data.DB().WithContext(ctx).Model(&alertRulePO{}).Where("id = ?", rule.ID).Updates(map[string]any{"rule_name": rule.Name, "rule_type": rule.Type, "monitor_metric": rule.Metric, "stat_period_days": rule.StatPeriodDays, "mom_direction": rule.MomDirection, "mom_threshold": rule.MomThreshold, "unlogin_days": rule.UnloginDays, "monitor_scope": 1, "warning_color": rule.WarningColor, "updated_at": time.Now()}).Error) + return storageError(r.data.DB().WithContext(ctx).Model(&alertRulePO{}).Where("id = ?", rule.ID).Updates(map[string]any{"condition_mode": rule.ConditionMode, "amount_direction": rule.AmountDirection, "amount_threshold": rule.AmountThreshold, "rule_name": rule.Name, "rule_type": rule.Type, "monitor_metric": rule.Metric, "stat_period_days": rule.StatPeriodDays, "mom_direction": rule.MomDirection, "mom_threshold": rule.MomThreshold, "unlogin_days": rule.UnloginDays, "monitor_scope": 1, "warning_color": rule.WarningColor, "updated_at": time.Now()}).Error) } func (r *alertRuleRepo) SetDeleted(ctx context.Context, id uint64) error { return storageError(r.data.DB().WithContext(ctx).Model(&alertRulePO{}).Where("id = ?", id).Updates(map[string]any{"is_deleted": 1, "updated_at": time.Now()}).Error) diff --git a/internal/data/business/alert_trigger.go b/internal/data/business/alert_trigger.go index f52d124..b89b085 100644 --- a/internal/data/business/alert_trigger.go +++ b/internal/data/business/alert_trigger.go @@ -3,6 +3,7 @@ package business import ( "context" "errors" + "strings" "time" "gorm.io/gorm" @@ -24,9 +25,82 @@ type alertTriggerLockPO struct { func (alertTriggerLockPO) TableName() string { return "sys_business_alert_locks" } +type alertHitKey struct { + OwnerID uint + Source, Type string + PromoteID, RuleID uint64 + Independent bool +} + +func hitKey(source, kind string, promote, rule uint64, independent bool, owner uint) alertHitKey { + if !independent { + rule = 0 + } + return alertHitKey{OwnerID: owner, Source: strings.ToLower(source), Type: strings.ToLower(kind), PromoteID: promote, RuleID: rule, Independent: independent} +} + +// 先批量排除在途记录,避免为不会更新的预警重复扫描累计玩家和流水。 +// 这是性能预筛选;落库时仍在事务及行锁内复查当前状态。 +func (r *alertTriggerRepo) FilterWritable(ctx context.Context, hits []*business.AlertRecord, open []int, canWrite func(*business.AlertRecord) bool) ([]*business.AlertRecord, error) { + db := r.data.DB().WithContext(ctx) + current := map[alertHitKey]*business.AlertRecord{} + for start := 0; start < len(hits); start += 100 { + var condition *gorm.DB + for _, hit := range hits[start:min(start+100, len(hits))] { + q := db.Where("platform_code=? AND target_promote_id=? AND rule_type=? AND independent_rule=? AND business_id=?", hit.PlatformCode, hit.TargetPromoteID, hit.RuleType, hit.IndependentRule, hit.BusinessID) + if hit.IndependentRule { + q = q.Where("rule_id=?", hit.RuleID) + } + if condition == nil { + condition = q + } else { + condition = condition.Or(q) + } + } + var rows []alertRecordPO + if err := db.Select("id,platform_code,target_promote_id,rule_type,rule_id,independent_rule,status,business_id").Where(condition).Where("status IN ?", open).Order("id").Find(&rows).Error; err != nil { + return nil, business.ErrStorage + } + for _, row := range rows { + key := hitKey(row.PlatformCode, row.RuleType, row.TargetPromoteID, row.RuleID, row.IndependentRule, row.BusinessID) + if current[key] == nil { + current[key] = toBizAlertRecord(row) + } + } + } + result := make([]*business.AlertRecord, 0, len(hits)) + for _, hit := range hits { + if canWrite(current[hitKey(hit.PlatformCode, hit.RuleType, hit.TargetPromoteID, hit.RuleID, hit.IndependentRule, hit.BusinessID)]) { + result = append(result, hit) + } + } + return result, nil +} + func (r *alertTriggerRepo) Input(ctx context.Context) (*business.AlertTriggerInput, error) { db := r.data.DB().WithContext(ctx) - out := &business.AlertTriggerInput{} + out := &business.AlertTriggerInput{Exemptions: map[business.AlertExemptionKey]bool{}, Gaps: map[uint]*business.AlertGap{}, Dedup: map[uint]bool{}} + var exemptions []alertExemptionPO + if err := db.Select("source_db,promote_id,owner_id").Where("removed_at IS NULL").Find(&exemptions).Error; err != nil { + return nil, business.ErrStorage + } + for _, e := range exemptions { + out.Exemptions[business.AlertExemptionKey{ChannelKey: business.ChannelKey{SourceDB: e.SourceDB, ID: e.PromoteID}, OwnerID: e.OwnerID}] = true + } + var gaps []alertGapPO + if err := db.Find(&gaps).Error; err != nil { + return nil, business.ErrStorage + } + for _, g := range gaps { + out.Gaps[g.OwnerID] = &business.AlertGap{OwnerID: g.OwnerID, Enabled: g.Enabled, Days: g.Days} + } + var prefs []preferencePO + if err := db.Find(&prefs).Error; err != nil { + return nil, business.ErrStorage + } + for _, p := range prefs { + out.Dedup[p.UserID] = p.IDCardDedup == nil || *p.IDCardDedup != 0 + } var rules []alertRulePO if err := db.Where("enabled=1 AND is_deleted=0").Order("id").Find(&rules).Error; err != nil { return nil, business.ErrStorage @@ -71,6 +145,30 @@ func (r *alertTriggerRepo) Input(ctx context.Context) (*business.AlertTriggerInp if err := db.Table("tab_promote p").Select("p.id,p.source_db,p.account,p.busier_id,p.last_login_time,p.create_time").Where("p.promote_level=1 AND p.status=1").Where("EXISTS (?)", bound).Order("p.source_db,p.id").Scan(&channels).Error; err != nil { return nil, business.ErrStorage } + if len(channels) > 0 { + keys := []business.ChannelKey{} + for _, row := range channels { + keys = append(keys, business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}) + } + latest := map[business.ChannelKey]int64{} + for start := 0; start < len(keys); start += 100 { + var times []struct { + SourceDB string + PromoteID uint64 + Stamp int64 + } + if err := alertTree(db, keys[start:min(start+100, len(keys))]).Select("root.source_db,root.id AS promote_id,MAX(COALESCE(NULLIF(p.last_login_time,0),p.create_time,0)) AS stamp").Group("root.source_db,root.id").Scan(×).Error; err != nil { + return nil, business.ErrStorage + } + for _, v := range times { + latest[business.ChannelKey{SourceDB: v.SourceDB, ID: v.PromoteID}] = v.Stamp + } + } + for i := range channels { + v := latest[business.ChannelKey{SourceDB: channels[i].SourceDB, ID: channels[i].ID}] + channels[i].LastLoginTime = &v + } + } for _, row := range channels { channel := &business.AlertTriggerChannel{ChannelKey: business.ChannelKey{SourceDB: row.SourceDB, ID: row.ID}, Account: row.Account, BusierID: row.BusierID} if row.LastLoginTime != nil { @@ -84,51 +182,28 @@ func (r *alertTriggerRepo) Input(ctx context.Context) (*business.AlertTriggerInp return out, nil } -func (r *alertTriggerRepo) Comparison(ctx context.Context, metric int, channels []business.ChannelKey, today time.Time, days int) (map[business.ChannelKey]business.AlertComparison, error) { - db := r.data.DB().WithContext(ctx) - table, stamp, amount, condition := "tab_user", "register_time", "1", "(puid=0 OR puid IS NULL)" - if metric == 2 { - table, stamp, amount, condition = "tab_spend", "pay_time", "pay_amount", "pay_status=1" - } else if metric != 1 { - return nil, business.ErrAlertRuleInput - } - available, err := businessTableAvailable(db, table) - if err != nil { - return nil, err - } - if !available { - return nil, business.ErrReportBuildUnavailable - } - result := map[business.ChannelKey]business.AlertComparison{} - currentStart, previousStart := today.AddDate(0, 0, -days).Unix(), today.AddDate(0, 0, -2*days).Unix() - for start := 0; start < len(channels); start += 500 { - var rows []struct { - SourceDB string `gorm:"column:source_db"` - PromoteID uint64 - FirstUnix *int64 - CurrentValue, PreviousValue string - } - // 原始主账号与成功支付口径,不扩展下级渠道,也不使用身份证偏好。 - selection := "source_db,promote_id,MIN(" + stamp + ") AS first_unix," + - "COALESCE(SUM(CASE WHEN " + stamp + ">=? AND " + stamp + "=? AND " + stamp + " 0 { + return nil + } key := alertTriggerLockPO{SourceDB: hit.PlatformCode, PromoteID: hit.TargetPromoteID, RuleType: hit.RuleType} if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&key).Error; err != nil { return business.ErrStorage @@ -136,8 +211,30 @@ func (r *alertTriggerRepo) StoreHit(ctx context.Context, hit *business.AlertReco if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("source_db=? AND promote_id=? AND rule_type=?", key.SourceDB, key.PromoteID, key.RuleType).Take(&key).Error; err != nil { return business.ErrStorage } + if hit.GapDays > 0 { + var latest alertRecordPO + gapQuery := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("business_id=? AND platform_code=? AND target_promote_id=? AND rule_type=?", hit.BusinessID, key.SourceDB, key.PromoteID, key.RuleType) + if hit.IndependentRule { + gapQuery = gapQuery.Where("rule_id=? AND independent_rule=1", hit.RuleID) + } else { + gapQuery = gapQuery.Where("independent_rule=0") + } + err := gapQuery.Order("created_at DESC,id DESC").Take(&latest).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return business.ErrStorage + } + if err == nil && hit.CreatedAt.Unix()-latest.CreatedAt.Unix() < int64(hit.GapDays)*86400 { + return nil + } + } var previous alertRecordPO - err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("platform_code=? AND target_promote_id=? AND rule_type=? AND status IN ?", key.SourceDB, key.PromoteID, key.RuleType, open).Order("id").Take(&previous).Error + q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("business_id=? AND platform_code=? AND target_promote_id=? AND rule_type=? AND status IN ?", hit.BusinessID, key.SourceDB, key.PromoteID, key.RuleType, open) + if hit.IndependentRule { + q = q.Where("rule_id=? AND independent_rule=1", hit.RuleID) + } else { + q = q.Where("independent_rule=0") + } + err := q.Order("id").Take(&previous).Error if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { return business.ErrStorage } @@ -149,14 +246,14 @@ func (r *alertTriggerRepo) StoreHit(ctx context.Context, hit *business.AlertReco return nil } day := time.Date(hit.CreatedAt.Year(), hit.CreatedAt.Month(), hit.CreatedAt.Day(), 0, 0, 0, 0, hit.CreatedAt.Location()) - row := alertRecordPO{PlatformCode: hit.PlatformCode, PlatformName: hit.PlatformName, RuleID: hit.RuleID, RuleName: hit.RuleName, RuleType: hit.RuleType, + row := alertRecordPO{IndependentRule: hit.IndependentRule, TotalPlayers: hit.TotalPlayers, TotalPay: hit.TotalPay, WarningColor: hit.WarningColor, PlatformCode: hit.PlatformCode, PlatformName: hit.PlatformName, RuleID: hit.RuleID, RuleName: hit.RuleName, RuleType: hit.RuleType, MonitorMetric: hit.MonitorMetric, TargetPromoteID: hit.TargetPromoteID, TargetPromoteName: hit.TargetPromoteName, BusinessID: hit.BusinessID, BusinessName: hit.BusinessName, AlertContent: hit.AlertContent, Status: hit.Status, AlertDate: &day, CreatedAt: hit.CreatedAt, UpdatedAt: hit.UpdatedAt} if current == nil { err = tx.Create(&row).Error } else { row.ID = previous.ID - err = tx.Model(&alertRecordPO{}).Where("id=?", previous.ID).Select("platform_name", "rule_id", "rule_name", "rule_type", "monitor_metric", "target_promote_name", "business_id", "business_name", "alert_content", "status", "alert_date", "created_at", "updated_at").Updates(&row).Error + err = tx.Model(&alertRecordPO{}).Where("id=?", previous.ID).Select("independent_rule", "total_players", "total_pay", "warning_color", "platform_name", "rule_id", "rule_name", "rule_type", "monitor_metric", "target_promote_name", "business_id", "business_name", "alert_content", "status", "alert_date", "created_at", "updated_at").Updates(&row).Error } if err != nil { return business.ErrStorage diff --git a/internal/data/business/alert_trigger_test.go b/internal/data/business/alert_trigger_test.go index 136b946..28c2d00 100644 --- a/internal/data/business/alert_trigger_test.go +++ b/internal/data/business/alert_trigger_test.go @@ -15,19 +15,19 @@ func TestAlertTriggerStorageUsesRawMetricsAndPreservesWorkflow(t *testing.T) { } now := time.Date(2026, 11, 4, 12, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) for _, sql := range []string{ - "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER)", - "CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,register_time TEXT)", + "CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,account TEXT,promote_level INTEGER,status INTEGER,last_login_time INTEGER,create_time INTEGER,parent_id INTEGER DEFAULT 0,top_promote_id INTEGER DEFAULT 0)", + "CREATE TABLE tab_user(id INTEGER,source_db TEXT,promote_id INTEGER,puid INTEGER,register_time TEXT,idcard TEXT)", "CREATE TABLE tab_spend(source_db TEXT,promote_id INTEGER,pay_time INTEGER,pay_status INTEGER,pay_amount DECIMAL(20,2))", "INSERT INTO sys_users(id,username,level,parent_id,id_path,authority_id) VALUES(10,'一级',1,0,'0',901),(20,'二级',2,10,'0,10',902),(30,'三级',3,20,'0,10,20',903)", "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,platform_busier_account,status) VALUES(30,'lx',7,'绑定','0')", - "INSERT INTO tab_promote VALUES(10,'lx',7,'正常渠道',1,1,0,1),(11,'lx',7,'停用渠道',1,0,0,1),(12,'lx',7,'下级渠道',2,1,0,1)", + "INSERT INTO tab_promote(id,source_db,busier_id,account,promote_level,status,last_login_time,create_time) VALUES(10,'lx',7,'正常渠道',1,1,0,1),(11,'lx',7,'停用渠道',1,0,0,1),(12,'lx',7,'下级渠道',2,1,0,1)", } { if err := db.Exec(sql).Error; err != nil { t.Fatal(err) } } today := time.Date(2026, 11, 4, 0, 0, 0, 0, now.Location()) - if err := db.Exec("INSERT INTO tab_user VALUES(1,'lx',10,0,?),(2,'lx',10,0,?),(3,'lx',10,NULL,?),(4,'lx',10,1,?),(5,'hw',10,0,?)", today.AddDate(0, 0, -2).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix()).Error; err != nil { + if err := db.Exec("INSERT INTO tab_user(id,source_db,promote_id,puid,register_time) VALUES(1,'lx',10,0,?),(2,'lx',10,0,?),(3,'lx',10,NULL,?),(4,'lx',10,1,?),(5,'hw',10,0,?)", today.AddDate(0, 0, -2).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix()).Error; err != nil { t.Fatal(err) } if err := db.Exec("INSERT INTO tab_spend VALUES('lx',10,?,1,100),('lx',10,?,1,110),('lx',10,?,0,999),('hw',10,?,1,999)", today.AddDate(0, 0, -2).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix(), today.AddDate(0, 0, -1).Unix()).Error; err != nil { @@ -48,7 +48,7 @@ func TestAlertTriggerStorageUsesRawMetricsAndPreservesWorkflow(t *testing.T) { if err != nil || metrics[business.ChannelKey{SourceDB: "lx", ID: 10}].Current != "2" { t.Fatalf("主账号、NULL父账号或跨平台口径错误: %+v %v", metrics, err) } - uc := business.NewAlertTriggerUsecase(r) + uc := business.NewAlertTriggerUsecase(r, nil) result, err := uc.Detect(context.Background(), now) if err != nil || result.Written != 1 { t.Fatalf("首次命中: %+v %v", result, err) diff --git a/internal/data/business/alert_v2_migration.go b/internal/data/business/alert_v2_migration.go new file mode 100644 index 0000000..52f71c2 --- /dev/null +++ b/internal/data/business/alert_v2_migration.go @@ -0,0 +1,133 @@ +package business + +import ( + "fmt" + "gorm.io/gorm" + "kra/pkg/database/migration" + "strings" +) + +func migrateAlertV2(db *gorm.DB) error { + if err := migration.CreateMissingTables(db, &alertGapPO{}, &alertExemptionPO{}, &alertChannelLockPO{}, &alertRulePO{}, &alertRecordPO{}); err != nil { + return err + } + for _, item := range []struct { + model any + fields []string + }{ + {&alertRulePO{}, []string{"ConditionMode", "AmountDirection", "AmountThreshold"}}, + {&alertRecordPO{}, []string{"TotalPlayers", "TotalPay", "WarningColor", "IndependentRule"}}, + } { + if db.Dialector.Name() == "mysql" { + statement := &gorm.Statement{DB: db} + if err := statement.Parse(item.model); err != nil { + return err + } + columns := []string{} + args := []any{} + for _, name := range item.fields { + if !db.Migrator().HasColumn(item.model, name) { + field := statement.Schema.LookUpField(name) + definition := db.Migrator().FullDataTypeOf(field) + columns = append(columns, "ADD COLUMN `"+field.DBName+"` "+definition.SQL) + args = append(args, definition.Vars...) + } + } + // MySQL 5.7 添加普通列会重建表;合并执行,避免每个字段重建一次。 + if len(columns) > 0 { + if err := db.Exec("ALTER TABLE `"+statement.Schema.Table+"` "+strings.Join(columns, ", ")+", ALGORITHM=INPLACE, LOCK=NONE", args...).Error; err != nil { + return err + } + } + continue + } + for _, field := range item.fields { + if !db.Migrator().HasColumn(item.model, field) { + if err := db.Migrator().AddColumn(item.model, field); err != nil { + return err + } + } + } + } + if err := migrateRegistrationIndex(db); err != nil { + return err + } + if err := migrateAlertV2UniqueKey(db); err != nil { + return err + } + return migrateQueryIndexes(db, []businessQueryIndex{ + {"sys_alert_record", "idx_alert_owner_status_time", []string{"business_id", "status", "created_at", "id"}}, + {"sys_alert_record", "idx_alert_channel_type_time", []string{"platform_code", "target_promote_id", "rule_type", "created_at", "id"}}, + {"sys_alert_record", "idx_alert_owner_time", []string{"business_id", "created_at", "id"}}, + {"sys_alert_record", "idx_alert_created", []string{"created_at", "id"}}, + {"sys_alert_operation_log", "idx_alert_log_time", []string{"alert_id", "created_at", "id"}}, + {"tab_spend", "idx_alert_spend_window", []string{"source_db", "promote_id", "pay_status", "pay_time", "pay_amount"}}, + {"tab_user", "idx_alert_user_window", []string{"source_db", "promote_id", "sw_register_time_unix", "puid"}}, + {"tab_promote", "idx_alert_promote_tree", []string{"source_db", "top_promote_id", "id"}}, + }) +} + +func migrateAlertV2UniqueKey(db *gorm.DB) error { + if db.Dialector.Name() != "mysql" { + return nil + } + // 旧唯一键将已忽略视为未闭环,也无法容纳同类的两条绝对值规则。 + // 保留旧生成列,原子替换唯一键;不改写、合并或删除历史记录。 + clauses := []string{} + if !db.Migrator().HasColumn("sys_alert_record", "v2_open_key") { + // MySQL 5.7 的在线虚拟列变更不能与索引变更放在同一 ALTER 中。 + if err := db.Exec("ALTER TABLE sys_alert_record ADD COLUMN v2_open_key BIGINT UNSIGNED GENERATED ALWAYS AS (CASE WHEN status IN (1,2,3,4) THEN CASE WHEN independent_rule=1 THEN rule_id ELSE 0 END ELSE NULL END) VIRTUAL, ALGORITHM=INPLACE, LOCK=NONE").Error; err != nil { + return err + } + } else { + var column struct { + GenerationExpression string + Extra string + } + if err := db.Raw("SELECT generation_expression,extra FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='sys_alert_record' AND column_name='v2_open_key'").Scan(&column).Error; err != nil { + return err + } + normalize := strings.NewReplacer("`", "", " ", "", "\n", "", "\t", "", "(", "", ")", "") + if normalize.Replace(strings.ToLower(column.GenerationExpression)) != "casewhenstatusin1,2,3,4thencasewhenindependent_rule=1thenrule_idelse0endelsenullend" || !strings.Contains(strings.ToUpper(column.Extra), "VIRTUAL GENERATED") { + return fmt.Errorf("sys_alert_record.v2_open_key 的现有定义不符,未修改原唯一索引") + } + } + var existing []struct { + ColumnName string + NonUnique int + SubPart *int + } + if err := db.Raw("SELECT column_name,non_unique,sub_part FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='sys_alert_record' AND index_name='uk_alert_record_v2_open' ORDER BY seq_in_index").Scan(&existing).Error; err != nil { + return err + } + replaceV2 := false + if len(existing) > 0 { + columns := []string{} + for _, column := range existing { + if column.NonUnique != 0 || column.SubPart != nil { + return fmt.Errorf("sys_alert_record.uk_alert_record_v2_open 必须是完整字段的唯一索引") + } + columns = append(columns, column.ColumnName) + } + layout := strings.Join(columns, ",") + replaceV2 = layout == "platform_code,target_promote_id,rule_type,v2_open_key" + if layout != "business_id,platform_code,target_promote_id,rule_type,v2_open_key" && !replaceV2 { + return fmt.Errorf("sys_alert_record.uk_alert_record_v2_open 的现有索引列不符,未修改原唯一索引") + } + } + for _, name := range []string{"uk_alert_record_unclosed", "uk_alert_record_daily"} { + if db.Migrator().HasIndex("sys_alert_record", name) { + clauses = append(clauses, "DROP INDEX `"+name+"`") + } + } + if replaceV2 { + clauses = append(clauses, "DROP INDEX uk_alert_record_v2_open") + } + if len(existing) == 0 || replaceV2 { + clauses = append(clauses, "ADD UNIQUE INDEX uk_alert_record_v2_open(business_id,platform_code,target_promote_id,rule_type,v2_open_key)") + } + if len(clauses) == 0 { + return nil + } + return db.Exec("ALTER TABLE sys_alert_record " + strings.Join(clauses, ", ") + ", ALGORITHM=INPLACE, LOCK=NONE").Error +} diff --git a/internal/data/business/alert_v2_test.go b/internal/data/business/alert_v2_test.go new file mode 100644 index 0000000..1d18140 --- /dev/null +++ b/internal/data/business/alert_v2_test.go @@ -0,0 +1,171 @@ +package business + +import ( + "context" + "testing" + "time" + + "gorm.io/gorm" + "kra/internal/biz/business" +) + +func prepareAlertV2(t *testing.T, db *gorm.DB) time.Time { + t.Helper() + now := time.Date(2026, 11, 5, 0, 0, 0, 0, time.FixedZone("Asia/Shanghai", 8*3600)) + for _, sql := range []string{ + "CREATE TABLE tab_promote(id BIGINT,source_db VARCHAR(20),busier_id BIGINT,account VARCHAR(100),promote_level INT,status INT,last_login_time BIGINT,create_time BIGINT,parent_id BIGINT,top_promote_id BIGINT,PRIMARY KEY(source_db,id))", + "CREATE TABLE tab_user(id BIGINT,source_db VARCHAR(20),promote_id BIGINT,puid BIGINT,register_time VARCHAR(30),idcard VARCHAR(100),PRIMARY KEY(source_db,id))", + "CREATE TABLE tab_spend(id BIGINT PRIMARY KEY,source_db VARCHAR(20),promote_id BIGINT,pay_status INT,pay_time BIGINT,pay_amount DECIMAL(20,2))", + "INSERT INTO tab_promote VALUES(1,'lx',7,'父渠道',1,1,0,1,0,0),(2,'lx',7,'子渠道',2,1,0,1,1,1),(3,'lx',7,'孙渠道',3,1,0,1,2,1),(1,'hw',7,'同号渠道',1,1,0,1,0,0)", + } { + if err := db.Exec(sql).Error; err != nil { + t.Fatal(err) + } + } + if err := db.Exec("UPDATE tab_promote SET last_login_time=?", now.Unix()).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO tab_user VALUES(1,'lx',1,0,?,'card-a'),(2,'lx',2,0,?,'card-a'),(3,'lx',3,0,?,'card-b'),(4,'lx',3,1,?,'card-c'),(1,'hw',1,0,?,'other')", now.AddDate(0, 0, -2).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix()).Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO tab_spend VALUES(1,'lx',1,1,?,100),(2,'lx',2,1,?,2000),(3,'lx',3,1,?,1000),(4,'lx',3,0,?,999999),(5,'hw',1,1,?,999999)", now.AddDate(0, 0, -2).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix(), now.AddDate(0, 0, -1).Unix()).Error; err != nil { + t.Fatal(err) + } + return now +} + +func TestAlertV2SnapshotsTreeAndBoundedComparison(t *testing.T) { + db := openTestDB(t) + now := prepareAlertV2(t, db) + repo := NewAlertTriggerRepo(testData{db}) + key := business.ChannelKey{SourceDB: "lx", ID: 1} + sample, err := repo.Comparison(context.Background(), 2, []business.ChannelKey{key}, now, 1) + if err != nil || sample[key].Current != "3000.00" || sample[key].Previous != "100.00" { + t.Fatalf("子渠道汇总或平台隔离错误: %+v %v", sample, err) + } + for _, dedup := range []bool{true, false} { + totals, err := repo.Snapshots(context.Background(), []business.ChannelKey{key}, now, dedup) + want := int64(3) + if dedup { + want = 2 + } + if err != nil || totals[key].Players != want || totals[key].Pay != "3100" { + t.Fatalf("累计快照或去重错误: %+v %v", totals, err) + } + } +} + +func TestAlertV2ExemptionHistoryAndAutomaticRemoval(t *testing.T) { + db := openTestDB(t) + now := prepareAlertV2(t, db) + if err := db.AutoMigrate(&accountPO{}); err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO sys_users(id,username,level,id_path) VALUES(7,'owner',1,'0')").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES(7,'lx',7,'0')").Error; err != nil { + t.Fatal(err) + } + r := NewAlertGovernanceRepo(testData{db}) + ctx := context.Background() + entry := &business.AlertExemption{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 1}, ChannelName: "父渠道", OwnerID: 7, AddedBy: 7, AddedName: "一级商务", AddedAt: now, Reason: "长期合作"} + if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != nil { + t.Fatal(err) + } + if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != business.ErrAlertExemptExists { + t.Fatalf("重复加入: %v", err) + } + rows, total, err := r.Exemptions(ctx, business.LevelOneScope{UserID: 7}, business.AlertExemptionFilter{}) + if err != nil || total != 1 { + t.Fatalf("名单: %+v %v", rows, err) + } + if err := r.RemoveExemption(ctx, business.LevelOneScope{UserID: 8}, rows[0].ID, 8, "越权", "手动移出", now); err != business.ErrAlertExemptNotFound { + t.Fatalf("跨商务移出: %v", err) + } + hit := &business.AlertRecord{BusinessID: 7, PlatformCode: "lx", TargetPromoteID: 1, RuleID: 1, RuleType: "充值", Status: 1, CreatedAt: now, UpdatedAt: now} + written, err := NewAlertTriggerRepo(testData{db}).StoreHit(ctx, hit, []int{1, 2, 3, 4}, func(*business.AlertRecord) bool { return true }) + if err != nil || written { + t.Fatalf("免预警仍生成: %v %v", written, err) + } + // 父渠道自身近 30 天流水仅 100 元,满足激活池条件;子渠道流水不冒充它自身的流水。 + uc := business.NewAlertGovernanceUsecase(r, nil) + if err := uc.Reconcile(ctx, now); err != nil { + t.Fatal(err) + } + rows, _, _ = r.Exemptions(ctx, business.LevelOneScope{All: true}, business.AlertExemptionFilter{}) + if rows[0].RemovedAt == nil || rows[0].RemovedName != "系统(自动移出)" || rows[0].RemoveReason != "进入激活池 · 自动移出" { + t.Fatalf("自动移出未留痕: %+v", rows[0]) + } + if err := r.AddExemption(ctx, business.UserScope{All: true}, entry.ChannelKey, func(*business.AlertExemptionTarget) (*business.AlertExemption, error) { return entry, nil }); err != nil { + t.Fatal(err) + } + _, total, err = r.Exemptions(ctx, business.LevelOneScope{All: true}, business.AlertExemptionFilter{}) + if err != nil || total != 2 { + t.Fatal("重新加入不得覆盖历史") + } +} + +func TestAlertV2GapSeparatesAbsoluteRulesAndIgnoredRecords(t *testing.T) { + db := openTestDB(t) + r := NewAlertTriggerRepo(testData{db}) + now := time.Now().Truncate(time.Second) + hit := &business.AlertRecord{PlatformCode: "lx", TargetPromoteID: 1, RuleID: 1, RuleType: "充值", Status: 1, CreatedAt: now, UpdatedAt: now, GapDays: 7, IndependentRule: true} + decide := func(current *business.AlertRecord) bool { + return current == nil || current.Status == business.AlertUnprocessed + } + write := func(want bool) { + t.Helper() + got, err := r.StoreHit(context.Background(), hit, []int{1, 2, 3, 4}, decide) + if err != nil || got != want { + t.Fatalf("written=%v want=%v err=%v", got, want, err) + } + } + write(true) + hit.RuleID = 2 + write(true) + hit.RuleID = 1 + write(false) + if err := db.Model(&alertRecordPO{}).Where("rule_id=1").Update("status", business.AlertIgnored).Error; err != nil { + t.Fatal(err) + } + hit.CreatedAt = now.AddDate(0, 0, 6) + write(false) + hit.CreatedAt = now.AddDate(0, 0, 7) + write(true) + var rows []alertRecordPO + if err := db.Order("id").Find(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 3 || rows[0].Status != business.AlertIgnored { + t.Fatal("已忽略记录被复活或独立规则被覆盖") + } +} + +func TestAlertV2LiveLossDoesNotRewriteSnapshots(t *testing.T) { + db := openTestDB(t) + now := prepareAlertV2(t, db) + // 流失检测使用父渠道的当前值,快照保持触发时的值。 + players, pay := int64(2), "3100.00" + if err := db.Create(&alertRecordPO{ID: 1, PlatformCode: "lx", TargetPromoteID: 1, BusinessID: 7, Status: 1, AlertContent: "满2000", TotalPlayers: &players, TotalPay: &pay, CreatedAt: now}).Error; err != nil { + t.Fatal(err) + } + scope := business.AlertRecordScope{Owner: business.LevelOneScope{All: true}, Viewer: business.UserScope{All: true}} + r := NewAlertRecordRepo(testData{db}) + lost := true + page, err := r.List(context.Background(), scope, business.AlertRecordFilter{Lost: &lost, AlertContent: "2000"}) + if err != nil || page.Total != 1 || page.Rows[0].Lost == nil || !*page.Rows[0].Lost { + t.Fatalf("实时流失筛选: %+v %v", page, err) + } + if err := db.Exec("UPDATE tab_spend SET pay_amount=1000 WHERE id=1").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("UPDATE tab_promote SET last_login_time=?", time.Now().Unix()).Error; err != nil { + t.Fatal(err) + } + lost = false + page, err = r.List(context.Background(), scope, business.AlertRecordFilter{Lost: &lost}) + if err != nil || page.Total != 1 || *page.Rows[0].TotalPlayers != 2 || *page.Rows[0].TotalPay != "3100" { + t.Fatalf("实时状态或冻结快照错误: %+v %v", page, err) + } +} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index 0a59e55..31b0239 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -6,7 +6,7 @@ import ( ) func Migrations() []migration.Step { - return []migration.Step{{ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error { + return []migration.Step{{ID: "202611050020_business_alert_v2", Migrate: migrateAlertV2}, {ID: "202611040017_business_groups_goals_effectiveness", Migrate: migrateBusinessManagement}, {ID: "202611040018_business_effectiveness_query_index", Migrate: migrateEffectivenessQueryIndex}, {ID: "202611040015_business_approved_game_index", Migrate: migrateApprovedGameIndexes}, {ID: "202611040014_business_report_build_indexes", Migrate: migrateReportBuildQueryIndexes}, {ID: "202611040013_business_query_indexes", Migrate: migrateBusinessQueryIndexes}, {ID: "202611040012_business_server_report_index", Migrate: migrateServerReportIndex}, {ID: "202611040011_business_registration_index", Migrate: migrateRegistrationIndex}, {ID: "202611040010_business_alert_trigger_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &alertTriggerLockPO{}) }}, {ID: "202611040009_business_menu_icons", Migrate: migrateBusinessMenuIcons}, {ID: "202611040008_business_report_build_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &reportBuildLockPO{}) }}, {ID: "202611030007_business_alert_attachments", Migrate: migrateAlertAttachments}, {ID: "202611030006_business_alert_schema", Migrate: migrateAlertSchema}, {ID: "202611030005_business_password_reset", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &resetCodePO{}, &resetTokenPO{}) }}, {ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error { return migration.CreateMissingTables(db, &bindingLockPO{}) diff --git a/internal/data/business/mysql_rehearsal_test.go b/internal/data/business/mysql_rehearsal_test.go index 2fe043a..aeae5ef 100644 --- a/internal/data/business/mysql_rehearsal_test.go +++ b/internal/data/business/mysql_rehearsal_test.go @@ -70,6 +70,9 @@ func TestMySQLAlertTriggerConcurrentDeduplication(t *testing.T) { if err := db.Exec("ALTER TABLE sys_alert_record ADD COLUMN unclosed_flag TINYINT GENERATED ALWAYS AS (CASE WHEN status=5 THEN NULL ELSE 1 END) VIRTUAL, ADD UNIQUE INDEX uk_alert_record_unclosed(platform_code,target_promote_id,rule_type,unclosed_flag)").Error; err != nil { t.Fatal(err) } + if err := migrateAlertV2UniqueKey(db); err != nil { + t.Fatal(err) + } r := NewAlertTriggerRepo(testData{db}) now := time.Now().Truncate(time.Millisecond) hit := &business.AlertRecord{RuleID: 1, PlatformCode: "lx", PlatformName: "龙翔", TargetPromoteID: 10, RuleType: "注册", Status: 1, BusinessID: 10, CreatedAt: now, UpdatedAt: now} diff --git a/internal/data/data.go b/internal/data/data.go index a70cd31..bd6854c 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -40,6 +40,7 @@ var ProviderSet = wire.NewSet( databusiness.NewPlatformAccountRepo, databusiness.NewPasswordResetRepo, databusiness.NewBusinessPromoteRepo, + databusiness.NewAlertGovernanceRepo, databusiness.NewAlertRuleRepo, databusiness.NewAlertRecordRepo, databusiness.NewActivationRepo, diff --git a/internal/modules/business/alert_surface.go b/internal/modules/business/alert_surface.go new file mode 100644 index 0000000..6e735fc --- /dev/null +++ b/internal/modules/business/alert_surface.go @@ -0,0 +1,75 @@ +package business + +import ( + "context" + "gorm.io/gorm" + datasystem "kra/internal/data/system" + datatask "kra/internal/data/task" + "kra/internal/modules/surface" + "kra/pkg/module" +) + +var alertExemptionMenu = module.Menu{Name: "sw_ExemptChannels", Path: "ExemptChannels", ParentName: "sw_AlertCenter", Component: "view/sw/ExemptChannels.vue", Title: "免预警渠道", Icon: "CircleCheck", Sort: 3} +var alertDetectionTask = module.TimedTask{Name: "AlertTrigger", Description: "每日检测预警并清理流失渠道豁免", Spec: "CRON_TZ=Asia/Shanghai 0 0 * * *", MethodName: "alertTriggerTask", Enabled: true} + +func migrateAlertSurface(db *gorm.DB) error { + if db.Migrator().HasTable("sys_timed_tasks") { + var count int64 + q := db.Table("sys_timed_tasks").Where("method_name=? AND deleted_at IS NULL", alertDetectionTask.MethodName) + if err := q.Count(&count).Error; err != nil { + return err + } + if count == 0 { + if err := datatask.SeedDefaults(context.Background(), db, []module.TimedTask{alertDetectionTask}); err != nil { + return err + } + } else { + // 旧检测任务调整到上海零点;保留管理员主动停用的状态。 + if err := db.Table("sys_timed_tasks").Where("method_name=? AND deleted_at IS NULL", alertDetectionTask.MethodName).Updates(map[string]any{"spec": alertDetectionTask.Spec, "with_seconds": false}).Error; err != nil { + return err + } + } + } + if !db.Migrator().HasTable("sys_authorities") { + return nil + } + var count int64 + if err := db.Table("sys_authorities").Where("authority_id=888 AND deleted_at IS NULL").Count(&count).Error; err != nil { + return err + } + if count == 0 { + return nil + } + apis := []module.API{} + for _, api := range surface.APIsForPrefix("/sw/", 0) { + switch api.Path { + case "/sw/alertRecord/platforms", "/sw/alertRecord/ignore", "/sw/alertRule/gap", "/sw/alertExemption/list", "/sw/alertExemption/candidates", "/sw/alertExemption/add", "/sw/alertExemption/remove/:id": + apis = append(apis, api) + } + } + return db.Transaction(func(tx *gorm.DB) error { + if err := datasystem.EnsureAdminSurface(tx, module.Surface{Menus: []module.Menu{alertExemptionMenu}, APIs: apis}, 888); err != nil { + return err + } + if err := datasystem.GrantMenuAPIs(tx, "sw_AlertList", apis); err != nil { + return err + } + if err := datasystem.GrantMenuAPIs(tx, "sw_AlertRules", apis); err != nil { + return err + } + var menuID uint + if err := tx.Table("sys_base_menus").Select("id").Where("name=? AND deleted_at IS NULL", alertExemptionMenu.Name).Take(&menuID).Error; err != nil { + return err + } + var roles []uint + if err := tx.Table("sys_users").Where("level=1 AND deleted_at IS NULL").Distinct().Pluck("authority_id", &roles).Error; err != nil { + return err + } + for _, role := range roles { + if err := tx.Exec("INSERT INTO sys_authority_menus(sys_authority_authority_id,sys_base_menu_id) SELECT ?,? WHERE NOT EXISTS (SELECT 1 FROM sys_authority_menus WHERE sys_authority_authority_id=? AND sys_base_menu_id=?)", role, menuID, role, menuID).Error; err != nil { + return err + } + } + return datasystem.GrantMenuAPIs(tx, alertExemptionMenu.Name, apis) + }) +} diff --git a/internal/modules/business/alert_surface_test.go b/internal/modules/business/alert_surface_test.go new file mode 100644 index 0000000..ef3eb57 --- /dev/null +++ b/internal/modules/business/alert_surface_test.go @@ -0,0 +1,51 @@ +package business + +import ( + "github.com/glebarez/sqlite" + "github.com/robfig/cron/v3" + "gorm.io/gorm" + datatask "kra/internal/data/task" + "path/filepath" + "testing" + "time" +) + +func TestAlertScheduleUpgradePreservesDisabledTaskAndUsesShanghaiMidnight(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "alert-schedule.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range datatask.Migrations() { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + if err := db.Exec("INSERT INTO sys_timed_tasks(name,method_name,spec,with_seconds,enabled) VALUES('历史预警任务','alertTriggerTask','*/5 * * * *',0,0)").Error; err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + if err := migrateAlertSurface(db); err != nil { + t.Fatal(err) + } + } + var rows []struct { + Spec string + Enabled bool + } + if err := db.Table("sys_timed_tasks").Where("method_name='alertTriggerTask'").Scan(&rows).Error; err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Enabled || rows[0].Spec != alertDetectionTask.Spec { + t.Fatalf("迁移不应重复或启用管理员停用的任务: %+v", rows) + } + schedule, err := cron.ParseStandard(rows[0].Spec) + if err != nil { + t.Fatal(err) + } + next := schedule.Next(time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC)) + if next.UTC() != time.Date(2026, 11, 5, 16, 0, 0, 0, time.UTC) { + t.Fatalf("没有按上海零点执行: %v", next) + } +} diff --git a/internal/modules/business/definition.go b/internal/modules/business/definition.go index b52314c..b2020fc 100644 --- a/internal/modules/business/definition.go +++ b/internal/modules/business/definition.go @@ -105,8 +105,10 @@ func Definition() module.Definition { return datatask.SeedDefaults(context.Background(), tx, tasks) }) }}) - definition := module.Definition{Name: "business", Migrations: migrations, TimedTasks: tasks, Surface: module.Surface{ + migrations = append(migrations, migration.Step{ID: "202611050021_business_alert_surface", Migrate: migrateAlertSurface}) + definition := module.Definition{Name: "business", Migrations: migrations, TimedTasks: append([]module.TimedTask{alertDetectionTask}, tasks...), Surface: module.Surface{ Menus: []module.Menu{ + alertExemptionMenu, performanceMenu, settingsMenu, {Name: "sw_RankingAnalysis", Path: "RankingAnalysis", ParentName: "sw_DataAnalysis", Component: "view/sw/RankingAnalysis.vue", Title: "排行榜", Icon: databusiness.MenuIcon("sw_RankingAnalysis"), Sort: 6}, diff --git a/internal/modules/business/definition_test.go b/internal/modules/business/definition_test.go index 6b91bff..12cf37e 100644 --- a/internal/modules/business/definition_test.go +++ b/internal/modules/business/definition_test.go @@ -28,6 +28,9 @@ func TestOtherSettingsUpgradeAddsStandaloneMenuForSuperAdmin(t *testing.T) { if err := db.Exec("INSERT INTO sys_base_menus(name,title,parent_id,sort) VALUES('sw_AlertCenter','预警中心',0,4),('permission','权限管理',0,5)").Error; err != nil { t.Fatal(err) } + if err := db.Exec("INSERT INTO sys_timed_tasks(name,method_name,spec,enabled) VALUES('旧检测任务','alertTriggerTask','*/5 * * * *',0)").Error; err != nil { + t.Fatal(err) + } for attempt := 0; attempt < 2; attempt++ { for _, step := range Definition().Migrations { if err := step.Migrate(db); err != nil { @@ -64,4 +67,15 @@ func TestOtherSettingsUpgradeAddsStandaloneMenuForSuperAdmin(t *testing.T) { if err := db.Table("casbin_rule").Where("ptype='p' AND v1=? AND v0<>'888'", "/sw/settings/effectiveness").Count(&count).Error; err != nil || count != 0 { t.Fatal("不应向商务账号开放设置 API") } + var alertTasks []struct { + Name string + Enabled bool + } + if err := db.Table("sys_timed_tasks").Where("method_name='alertTriggerTask'").Scan(&alertTasks).Error; err != nil { + t.Fatal(err) + } + if len(alertTasks) != 1 || alertTasks[0].Name != "旧检测任务" || alertTasks[0].Enabled { + t.Fatalf("整个升级链不应额外创建或启用预警任务: %+v", alertTasks) + } + } diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 2437c4f..4890689 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -25,18 +25,18 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) { t.Fatalf("definition[%d] = %q, want %q", index, got, name) } } - if got := catalog.MigrationSteps(); len(got) != 33 { - t.Fatalf("module migrations = %d, want 33", len(got)) + if got := catalog.MigrationSteps(); len(got) != 35 { + t.Fatalf("module migrations = %d, want 35", len(got)) } var menus, apis int for _, definition := range catalog.Definitions { menus += len(definition.Surface.Menus) apis += len(definition.Surface.APIs) } - if menus != 20 || apis != 82 { - t.Fatalf("admin surface = %d menus/%d APIs, want 20/82", menus, apis) + if menus != 21 || apis != 90 { + t.Fatalf("admin surface = %d menus/%d APIs, want 21/90", menus, apis) } - if got := catalog.DefaultTimedTasks(); len(got) != 4 { - t.Fatalf("default timed tasks = %d, want 4", len(got)) + if got := catalog.DefaultTimedTasks(); len(got) != 5 { + t.Fatalf("default timed tasks = %d, want 5", len(got)) } } diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index 6d43d2f..e3edd6c 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -74,6 +74,14 @@ var routes = map[string]routeValue{ "POST /sw/alertRecord/reject": {group: "预警记录", description: "审核驳回预警", audit: true}, "POST /sw/alertRecord/reProcess": {group: "预警记录", description: "重新处理预警", audit: true}, "POST /sw/alertRecord/addNote": {group: "预警记录", description: "添加预警备注及附件", audit: true}, + "POST /sw/alertRecord/ignore": {group: "预警记录", description: "批量忽略预警", audit: true}, + "GET /sw/alertRecord/platforms": {group: "预警记录", description: "查询可见范围已绑定平台"}, + "GET /sw/alertExemption/candidates": {group: "免预警渠道", description: "查询可加入的一级渠道"}, + "GET /sw/alertExemption/list": {group: "免预警渠道", description: "查询免预警名单"}, + "POST /sw/alertExemption/add": {group: "免预警渠道", description: "加入免预警名单", audit: true}, + "PUT /sw/alertExemption/remove/:id": {group: "免预警渠道", description: "移出免预警名单", audit: true}, + "GET /sw/alertRule/gap": {group: "预警规则", description: "查询预警间隔"}, + "PUT /sw/alertRule/gap": {group: "预警规则", description: "设置预警间隔", audit: true}, "GET /sw/alertRule/list": {group: "预警规则", description: "查询预警规则"}, "GET /sw/alertRule/ruleTypes": {group: "预警规则", description: "查询历史预警分类"}, "GET /sw/alertRule/:id": {group: "预警规则", description: "查询规则详情"}, diff --git a/internal/server/handler/alert_governance.go b/internal/server/handler/alert_governance.go new file mode 100644 index 0000000..5399253 --- /dev/null +++ b/internal/server/handler/alert_governance.go @@ -0,0 +1,99 @@ +package handler + +import ( + "github.com/gin-gonic/gin" + "github.com/go-kratos/kratos/v3/errors" + "kra/internal/service/business" + "kra/internal/service/dto" + "net/http" + "strconv" +) + +type AlertGovernance struct { + service *business.AlertGovernanceService +} + +func (h *AlertGovernance) Candidates(c *gin.Context) { + values, err := h.service.Candidates(c.Request.Context(), c.Query("keyword")) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, values) +} + +func NewAlertGovernance(service *business.AlertGovernanceService) *AlertGovernance { + return &AlertGovernance{service} +} +func (h *AlertGovernance) Gap(c *gin.Context) { + v, err := h.service.Gap(c.Request.Context()) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *AlertGovernance) SaveGap(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 4096) + var req dto.AlertGapRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "预警间隔参数不正确") + return + } + if err := h.service.SaveGap(c.Request.Context(), &req); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) +} +func (h *AlertGovernance) List(c *gin.Context) { + var req dto.AlertExemptionQuery + if c.ShouldBindQuery(&req) != nil { + Fail(c, "查询参数不正确") + return + } + v, err := h.service.List(c.Request.Context(), &req) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, v) +} +func (h *AlertGovernance) Add(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 8192) + var req dto.AlertExemptionRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "免预警参数不正确") + return + } + if err := h.service.Add(c.Request.Context(), &req); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) +} +func (h *AlertGovernance) Remove(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + Fail(c, "记录 ID 不正确") + return + } + if err = h.service.Remove(c.Request.Context(), id); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) +} +func (h *AlertRecord) Ignore(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 32768) + var req dto.AlertIgnoreRequest + if c.ShouldBindJSON(&req) != nil { + Fail(c, "预警记录参数不正确") + return + } + if err := h.service.Ignore(c.Request.Context(), &req); err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OK(c) +} diff --git a/internal/server/handler/alert_record.go b/internal/server/handler/alert_record.go index 720b41a..81b23a5 100644 --- a/internal/server/handler/alert_record.go +++ b/internal/server/handler/alert_record.go @@ -17,6 +17,15 @@ func NewAlertRecord(service *business.AlertRecordService) *AlertRecord { return &AlertRecord{service: service} } +func (h *AlertRecord) Platforms(c *gin.Context) { + values, err := h.service.Platforms(c.Request.Context()) + if err != nil { + Fail(c, errors.FromError(err).Message) + return + } + OKWithData(c, values) +} + func (h *AlertRecord) List(c *gin.Context) { var req dto.AlertRecordQuery if c.ShouldBindQuery(&req) != nil { diff --git a/internal/server/handler/alert_rule.go b/internal/server/handler/alert_rule.go index bbf1f6b..0b366e9 100644 --- a/internal/server/handler/alert_rule.go +++ b/internal/server/handler/alert_rule.go @@ -5,6 +5,7 @@ import ( "github.com/go-kratos/kratos/v3/errors" "kra/internal/service/business" "kra/internal/service/dto" + "net/http" "strconv" ) @@ -46,17 +47,31 @@ func (h *AlertRule) Find(c *gin.Context) { OKWithData(c, v) } func (h *AlertRule) Save(c *gin.Context) { + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16384) var req dto.AlertRuleRequest if err := c.ShouldBindJSON(&req); err != nil { Fail(c, "预警规则参数不正确") return } + var requestedID uint64 + if req.ID != "" { + var err error + requestedID, err = strconv.ParseUint(req.ID.String(), 10, 64) + if err != nil { + Fail(c, "规则 ID 不正确") + return + } + } + if (c.Request.Method == http.MethodPost && requestedID != 0) || (c.Request.Method == http.MethodPut && requestedID == 0) { + Fail(c, "新建或编辑规则的 ID 不正确") + return + } id, err := h.service.Save(c.Request.Context(), &req) if err != nil { Fail(c, errors.FromError(err).Message) return } - OKWithData(c, gin.H{"id": id}) + OKWithData(c, gin.H{"id": strconv.FormatUint(id, 10)}) } func (h *AlertRule) Delete(c *gin.Context) { id, err := strconv.ParseUint(c.Param("id"), 10, 64) diff --git a/internal/server/handler/provider.go b/internal/server/handler/provider.go index dae0370..2a5c686 100644 --- a/internal/server/handler/provider.go +++ b/internal/server/handler/provider.go @@ -10,6 +10,7 @@ var ProviderSet = wire.NewSet( NewBusiness, NewBusinessPassword, NewBusinessPromote, + NewAlertGovernance, NewAlertRule, NewAlertRecord, NewActivation, diff --git a/internal/server/handler/set.go b/internal/server/handler/set.go index 15b5186..1cf8d6c 100644 --- a/internal/server/handler/set.go +++ b/internal/server/handler/set.go @@ -1,6 +1,7 @@ package handler type Set struct { + AlertGovernance *AlertGovernance BusinessPerformance *BusinessPerformance OtherSettings *OtherSettings Ranking *Ranking diff --git a/internal/server/router/alert_governance.go b/internal/server/router/alert_governance.go new file mode 100644 index 0000000..95f8c8b --- /dev/null +++ b/internal/server/router/alert_governance.go @@ -0,0 +1,15 @@ +package router + +import ( + "github.com/gin-gonic/gin" + "kra/internal/server/handler" +) + +func RegisterAlertGovernance(private *gin.RouterGroup, h *handler.AlertGovernance) { + private.GET("/sw/alertRule/gap", h.Gap) + private.PUT("/sw/alertRule/gap", h.SaveGap) + private.GET("/sw/alertExemption/list", h.List) + private.GET("/sw/alertExemption/candidates", h.Candidates) + private.POST("/sw/alertExemption/add", h.Add) + private.PUT("/sw/alertExemption/remove/:id", h.Remove) +} diff --git a/internal/server/router/alert_record.go b/internal/server/router/alert_record.go index 38af353..9ebcd1b 100644 --- a/internal/server/router/alert_record.go +++ b/internal/server/router/alert_record.go @@ -9,6 +9,7 @@ import ( func RegisterAlertRecord(private *gin.RouterGroup, h *handler.AlertRecord) { g := private.Group("/sw/alertRecord") g.GET("/list", h.List) + g.GET("/platforms", h.Platforms) g.GET("/detail/:id", h.Detail) g.POST("/startProcess", h.Process(business.AlertStart)) g.POST("/submitAudit", h.Process(business.AlertSubmit)) @@ -16,4 +17,5 @@ func RegisterAlertRecord(private *gin.RouterGroup, h *handler.AlertRecord) { g.POST("/reject", h.Process(business.AlertReject)) g.POST("/reProcess", h.Process(business.AlertReprocess)) g.POST("/addNote", h.AddNote) + g.POST("/ignore", h.Ignore) } diff --git a/internal/server/router/routes.go b/internal/server/router/routes.go index eaf8bc2..c24893f 100644 --- a/internal/server/router/routes.go +++ b/internal/server/router/routes.go @@ -54,6 +54,7 @@ func (r *Routes) RegisterRoutes(public, private *gin.RouterGroup, engine *gin.En }, func() { RegisterAlertRecord(private, r.handlers.AlertRecord) }, func() { RegisterAlertRule(private, r.handlers.AlertRule) }, + func() { RegisterAlertGovernance(private, r.handlers.AlertGovernance) }, func() { private.GET("/sw/business/:userId/promotes", r.handlers.BusinessPromote.List) }, func() { RegisterBusiness(private, r.handlers.Business) }, func() { RegisterBusinessPassword(public, r.handlers.BusinessPassword) }, diff --git a/internal/service/business/alert_governance.go b/internal/service/business/alert_governance.go new file mode 100644 index 0000000..2c744eb --- /dev/null +++ b/internal/service/business/alert_governance.go @@ -0,0 +1,109 @@ +package business + +import ( + "context" + "kra/internal/biz/business" + "kra/internal/service/dto" + "strconv" + "strings" +) + +type AlertGovernanceUsecase interface { + Candidates(context.Context, string, int) ([]*business.AlertChannelOption, error) + Gap(context.Context) (*business.AlertGap, error) + SaveGap(context.Context, bool, int) error + List(context.Context, business.AlertExemptionFilter, ...business.ListOption) ([]*business.AlertExemption, int64, error) + Add(context.Context, business.ChannelKey, string) error + Remove(context.Context, uint64) error +} + +func (s *AlertGovernanceService) Candidates(ctx context.Context, keyword string) ([]*dto.AlertChannelOption, error) { + if len([]rune(keyword)) > 100 { + return nil, business.ErrAlertRecordInput + } + rows, err := s.uc.Candidates(ctx, keyword, 30) + if err != nil { + return nil, err + } + result := make([]*dto.AlertChannelOption, 0, len(rows)) + for _, row := range rows { + result = append(result, &dto.AlertChannelOption{ID: row.ID, SourceDB: row.SourceDB, Account: row.Name}) + } + return result, nil +} + +type AlertGovernanceService struct{ uc AlertGovernanceUsecase } + +func NewAlertGovernanceService(uc *business.AlertGovernanceUsecase) *AlertGovernanceService { + return &AlertGovernanceService{uc} +} +func (s *AlertGovernanceService) Gap(ctx context.Context) (*dto.AlertGapRequest, error) { + v, err := s.uc.Gap(ctx) + if err != nil { + return nil, err + } + return &dto.AlertGapRequest{Enabled: v.Enabled, Days: v.Days}, nil +} +func (s *AlertGovernanceService) SaveGap(ctx context.Context, req *dto.AlertGapRequest) error { + if req == nil || req.Days < 1 || req.Days > 2147483647 { + return business.ErrAlertRuleInput + } + return s.uc.SaveGap(ctx, req.Enabled, req.Days) +} +func (s *AlertGovernanceService) List(ctx context.Context, req *dto.AlertExemptionQuery) (*dto.AlertExemptionPage, error) { + if req == nil { + return nil, business.ErrAlertRecordInput + } + page, size := max(req.Page, 1), req.PageSize + if size < 1 { + size = 10 + } + size = min(size, 500) + if page > int(^uint(0)>>1)/size { + return nil, business.ErrAlertRecordInput + } + rows, total, err := s.uc.List(ctx, business.AlertExemptionFilter{Name: strings.TrimSpace(req.Name), Active: req.Active}, business.ListOffset((page-1)*size), business.ListLimit(size)) + if err != nil { + return nil, err + } + out := &dto.AlertExemptionPage{Total: total, Rows: make([]*dto.AlertExemptionResponse, 0, len(rows))} + for _, r := range rows { + out.Rows = append(out.Rows, &dto.AlertExemptionResponse{ID: r.ID, SourceDB: r.SourceDB, PromoteID: r.ChannelKey.ID, ChannelName: r.ChannelName, AddedName: r.AddedName, AddedAt: localTime(&r.AddedAt), Reason: r.Reason, Active: r.RemovedAt == nil, RemovedName: r.RemovedName, RemovedAt: localTime(r.RemovedAt), RemoveReason: r.RemoveReason}) + } + return out, nil +} +func (s *AlertGovernanceService) Add(ctx context.Context, req *dto.AlertExemptionRequest) error { + if req == nil || len([]rune(req.Reason)) > 500 { + return business.ErrAlertRecordInput + } + id, err := strconv.ParseUint(req.PromoteID.String(), 10, 64) + if err != nil || id == 0 { + return business.ErrAlertRecordInput + } + return s.uc.Add(ctx, business.ChannelKey{SourceDB: req.SourceDB, ID: id}, req.Reason) +} +func (s *AlertGovernanceService) Remove(ctx context.Context, id uint64) error { + if id == 0 { + return business.ErrAlertRecordInput + } + return s.uc.Remove(ctx, id) +} + +func (s *AlertRecordService) Ignore(ctx context.Context, req *dto.AlertIgnoreRequest) error { + if req == nil || len(req.IDs) == 0 || len(req.IDs) > 500 { + return business.ErrAlertRecordInput + } + ids := make([]uint64, 0, len(req.IDs)) + seen := map[uint64]bool{} + for _, n := range req.IDs { + id, err := strconv.ParseUint(n.String(), 10, 64) + if err != nil || id == 0 { + return business.ErrAlertRecordInput + } + if !seen[id] { + ids = append(ids, id) + seen[id] = true + } + } + return s.uc.Ignore(ctx, ids) +} diff --git a/internal/service/business/alert_record.go b/internal/service/business/alert_record.go index 525d677..13c4138 100644 --- a/internal/service/business/alert_record.go +++ b/internal/service/business/alert_record.go @@ -11,12 +11,18 @@ import ( ) type AlertRecordUsecase interface { + Platforms(context.Context) ([]string, error) + Ignore(context.Context, []uint64) error List(context.Context, business.AlertRecordFilter, ...business.ListOption) (*business.AlertRecordPage, error) Detail(context.Context, uint64) (*business.AlertRecordDetail, error) Process(context.Context, uint64, business.AlertAction, string) error AddNote(context.Context, uint64, string, []string) error } +func (s *AlertRecordService) Platforms(ctx context.Context) ([]string, error) { + return s.uc.Platforms(ctx) +} + type AlertRecordService struct{ uc AlertRecordUsecase } func NewAlertRecordService(uc *business.AlertRecordUsecase) *AlertRecordService { @@ -28,7 +34,7 @@ func alertRecordResponse(r *business.AlertRecord) *dto.AlertRecordResponse { if r.MonitorMetric != nil { metricLabel = business.AlertMetricLabel(*r.MonitorMetric) } - return &dto.AlertRecordResponse{ID: r.ID, RuleID: r.RuleID, PlatformCode: r.PlatformCode, PlatformName: r.PlatformName, RuleName: r.RuleName, RuleType: r.RuleType, RuleTypeLabel: business.AlertRuleTypeLabel(r.RuleType), MonitorMetric: r.MonitorMetric, MonitorMetricLabel: metricLabel, TargetPromoteID: r.TargetPromoteID, TargetPromoteName: r.TargetPromoteName, BusinessID: r.BusinessID, BusinessName: r.BusinessName, AlertContent: r.AlertContent, Status: r.Status, StatusLabel: business.AlertStatusLabel(r.Status), CreatedAt: localTime(&r.CreatedAt), UpdatedAt: localTime(&r.UpdatedAt)} + return &dto.AlertRecordResponse{ID: r.ID, TotalPlayers: r.TotalPlayers, TotalPay: r.TotalPay, WarningColor: r.WarningColor, Lost: r.Lost, RuleID: r.RuleID, PlatformCode: r.PlatformCode, PlatformName: r.PlatformName, RuleName: r.RuleName, RuleType: r.RuleType, RuleTypeLabel: business.AlertRuleTypeLabel(r.RuleType), MonitorMetric: r.MonitorMetric, MonitorMetricLabel: metricLabel, TargetPromoteID: r.TargetPromoteID, TargetPromoteName: r.TargetPromoteName, BusinessID: r.BusinessID, BusinessName: r.BusinessName, AlertContent: r.AlertContent, Status: r.Status, StatusLabel: business.AlertStatusLabel(r.Status), CreatedAt: localTime(&r.CreatedAt), UpdatedAt: localTime(&r.UpdatedAt)} } func (s *AlertRecordService) List(ctx context.Context, req *dto.AlertRecordQuery) (*dto.AlertRecordPage, error) { @@ -41,7 +47,7 @@ func (s *AlertRecordService) List(ctx context.Context, req *dto.AlertRecordQuery if req.MonitorMetric != nil && (*req.MonitorMetric < 1 || *req.MonitorMetric > 3) { return nil, business.ErrAlertRecordInput } - if req.Status != nil && (*req.Status < 1 || *req.Status > 5) { + if req.Status != nil && (*req.Status < 1 || *req.Status > 6) { return nil, business.ErrAlertRecordInput } page, size := req.Page, req.PageSize @@ -57,7 +63,7 @@ func (s *AlertRecordService) List(ctx context.Context, req *dto.AlertRecordQuery if page > int(^uint(0)>>1)/size { return nil, business.ErrAlertRecordInput } - filter := business.AlertRecordFilter{TargetPromoteID: req.TargetPromoteID, BusinessID: req.BusinessID, TargetPromoteName: strings.TrimSpace(req.TargetPromoteName), RuleName: strings.TrimSpace(req.RuleName), RuleType: strings.TrimSpace(req.RuleType), BusinessName: strings.TrimSpace(req.BusinessName), MonitorMetric: req.MonitorMetric, Status: req.Status} + filter := business.AlertRecordFilter{AlertContent: strings.TrimSpace(req.AlertContent), Lost: req.Lost, TargetPromoteID: req.TargetPromoteID, BusinessID: req.BusinessID, TargetPromoteName: strings.TrimSpace(req.TargetPromoteName), RuleName: strings.TrimSpace(req.RuleName), RuleType: strings.TrimSpace(req.RuleType), BusinessName: strings.TrimSpace(req.BusinessName), MonitorMetric: req.MonitorMetric, Status: req.Status} seen := map[string]bool{} filter.PlatformBusierID = req.PlatformBusierID for _, code := range strings.Split(req.PlatformCodes, ",") { diff --git a/internal/service/business/alert_rule.go b/internal/service/business/alert_rule.go index 036db58..303de68 100644 --- a/internal/service/business/alert_rule.go +++ b/internal/service/business/alert_rule.go @@ -30,13 +30,13 @@ func alertRuleResponse(r *business.AlertRule) *dto.AlertRuleResponse { n := json.Number(*r.MomThreshold) threshold = &n } - return &dto.AlertRuleResponse{ID: r.ID, RuleName: r.Name, RuleType: r.Type, RuleTypeLabel: business.AlertRuleTypeLabel(r.Type), MonitorMetric: r.Metric, MonitorMetricLabel: business.AlertMetricLabel(r.Metric), StatPeriodDays: r.StatPeriodDays, MomDirection: r.MomDirection, MomDirectionLabel: business.AlertDirectionLabel(r.MomDirection), MomThreshold: threshold, UnloginDays: r.UnloginDays, MonitorScope: r.MonitorScope, WarningColor: r.WarningColor, CreatorID: r.CreatorID, CreatorName: r.CreatorName, Enabled: r.Enabled, CreatedAt: localTime(&r.CreatedAt), UpdatedAt: localTime(&r.UpdatedAt), RuleDescription: r.Description()} + return &dto.AlertRuleResponse{ID: r.ID, ConditionMode: r.ConditionMode, AmountDirection: r.AmountDirection, AmountThreshold: r.AmountThreshold, RuleName: r.Name, RuleType: r.Type, RuleTypeLabel: business.AlertRuleTypeLabel(r.Type), MonitorMetric: r.Metric, MonitorMetricLabel: business.AlertMetricLabel(r.Metric), StatPeriodDays: r.StatPeriodDays, MomDirection: r.MomDirection, MomDirectionLabel: business.AlertDirectionLabel(r.MomDirection), MomThreshold: threshold, UnloginDays: r.UnloginDays, MonitorScope: r.MonitorScope, WarningColor: r.WarningColor, CreatorID: r.CreatorID, CreatorName: r.CreatorName, Enabled: r.Enabled, CreatedAt: localTime(&r.CreatedAt), UpdatedAt: localTime(&r.UpdatedAt), RuleDescription: r.Description()} } func (s *AlertRuleService) List(ctx context.Context, req *dto.AlertRuleQuery) (*dto.AlertRulePage, error) { if req == nil { return nil, business.ErrAlertRuleInput } - filter := business.AlertRuleFilter{Name: req.RuleName, Type: req.RuleType} + filter := business.AlertRuleFilter{Name: strings.TrimSpace(req.RuleName), Type: strings.TrimSpace(req.RuleType)} if req.MonitorMetric != "" { n, err := strconv.Atoi(req.MonitorMetric) if err != nil || n < 1 || n > 3 { @@ -87,11 +87,23 @@ func (s *AlertRuleService) Save(ctx context.Context, req *dto.AlertRuleRequest) return 0, business.ErrAlertRuleInput } } - rule := &business.AlertRule{ID: req.ID, Name: req.RuleName, Type: req.RuleType, Metric: req.MonitorMetric, StatPeriodDays: req.StatPeriodDays, MomDirection: req.MomDirection, UnloginDays: req.UnloginDays, WarningColor: req.WarningColor} + var id uint64 + if req.ID != "" { + var err error + id, err = strconv.ParseUint(req.ID.String(), 10, 64) + if err != nil { + return 0, business.ErrAlertRuleInput + } + } + rule := &business.AlertRule{ID: id, ConditionMode: req.ConditionMode, AmountDirection: req.AmountDirection, Name: strings.TrimSpace(req.RuleName), Type: req.RuleType, Metric: req.MonitorMetric, StatPeriodDays: req.StatPeriodDays, MomDirection: req.MomDirection, UnloginDays: req.UnloginDays, WarningColor: strings.TrimSpace(req.WarningColor)} if req.MomThreshold != nil { value := req.MomThreshold.String() rule.MomThreshold = &value } + if req.AmountThreshold != nil { + value := req.AmountThreshold.String() + rule.AmountThreshold = &value + } return s.uc.Save(ctx, rule) } func (s *AlertRuleService) Delete(ctx context.Context, id uint64) error { diff --git a/internal/service/dto/alert_governance.go b/internal/service/dto/alert_governance.go new file mode 100644 index 0000000..07b7549 --- /dev/null +++ b/internal/service/dto/alert_governance.go @@ -0,0 +1,45 @@ +package dto + +import "encoding/json" + +type AlertChannelOption struct { + ID uint64 `json:"id,string"` + SourceDB string `json:"sourceDb"` + Account string `json:"account"` +} + +type AlertIgnoreRequest struct { + IDs []json.Number `json:"ids"` +} +type AlertGapRequest struct { + Enabled bool `json:"enabled"` + Days int `json:"days"` +} +type AlertExemptionQuery struct { + Page int `form:"pageNum"` + PageSize int `form:"pageSize"` + Name string `form:"channelName"` + Active *bool `form:"active"` +} +type AlertExemptionRequest struct { + SourceDB string `json:"sourceDb"` + PromoteID json.Number `json:"promoteId"` + Reason string `json:"reason"` +} +type AlertExemptionResponse struct { + ID uint64 `json:"id,string"` + SourceDB string `json:"sourceDb"` + PromoteID uint64 `json:"promoteId,string"` + ChannelName string `json:"channelName"` + AddedName string `json:"addedName"` + AddedAt *string `json:"addedAt"` + Reason string `json:"reason"` + Active bool `json:"active"` + RemovedName string `json:"removedName"` + RemovedAt *string `json:"removedAt"` + RemoveReason string `json:"removeReason"` +} +type AlertExemptionPage struct { + Rows []*AlertExemptionResponse `json:"rows"` + Total int64 `json:"total"` +} diff --git a/internal/service/dto/alert_record.go b/internal/service/dto/alert_record.go index fe28bab..230eb0e 100644 --- a/internal/service/dto/alert_record.go +++ b/internal/service/dto/alert_record.go @@ -3,6 +3,8 @@ package dto import "encoding/json" type AlertRecordQuery struct { + AlertContent string `form:"alertContent"` + Lost *bool `form:"lost"` Page int `form:"pageNum"` PageSize int `form:"pageSize"` PlatformCodes string `form:"platformCodes"` @@ -29,18 +31,21 @@ type AlertNoteRequest struct { } type AlertRecordResponse struct { - ID uint64 `json:"id,string"` - PlatformCode string `json:"platformCode"` - PlatformName string `json:"platformName"` - RuleID uint64 `json:"ruleId,string"` - RuleName string `json:"ruleName"` - RuleType string `json:"ruleType"` - RuleTypeLabel string `json:"ruleTypeLabel"` - MonitorMetric *int `json:"monitorMetric"` - MonitorMetricLabel string `json:"monitorMetricLabel"` - // 旧视图声明了颜色,但记录没有对应快照字段,保留空值。 + TotalPlayers *int64 `json:"totalPlayers"` + TotalPay *string `json:"totalPay"` + Lost *bool `json:"lost"` + ID uint64 `json:"id,string"` + PlatformCode string `json:"platformCode"` + PlatformName string `json:"platformName"` + RuleID uint64 `json:"ruleId,string"` + RuleName string `json:"ruleName"` + RuleType string `json:"ruleType"` + RuleTypeLabel string `json:"ruleTypeLabel"` + MonitorMetric *int `json:"monitorMetric"` + MonitorMetricLabel string `json:"monitorMetricLabel"` + // 历史记录无快照时返回 null,不回填当前数值。 WarningColor *string `json:"warningColor"` - TargetPromoteID uint64 `json:"targetPromoteId"` + TargetPromoteID uint64 `json:"targetPromoteId,string"` TargetPromoteName string `json:"targetPromoteName"` BusinessID uint `json:"businessId"` BusinessName string `json:"businessName"` diff --git a/internal/service/dto/alert_rule.go b/internal/service/dto/alert_rule.go index bc09558..1600573 100644 --- a/internal/service/dto/alert_rule.go +++ b/internal/service/dto/alert_rule.go @@ -10,18 +10,24 @@ type AlertRuleQuery struct { MonitorMetric string `form:"monitorMetric"` } type AlertRuleRequest struct { - ID uint64 `json:"id"` - RuleName string `json:"ruleName"` - RuleType string `json:"ruleType"` - MonitorMetric int `json:"monitorMetric"` - StatPeriodDays *int `json:"statPeriodDays"` - MomDirection *int `json:"momDirection"` - MomThreshold *json.Number `json:"momThreshold"` - UnloginDays *int `json:"unloginDays"` - WarningColor string `json:"warningColor"` + ConditionMode int `json:"conditionMode"` + AmountDirection int `json:"amountDirection"` + AmountThreshold *json.Number `json:"amountThreshold"` + ID json.Number `json:"id"` + RuleName string `json:"ruleName"` + RuleType string `json:"ruleType"` + MonitorMetric int `json:"monitorMetric"` + StatPeriodDays *int `json:"statPeriodDays"` + MomDirection *int `json:"momDirection"` + MomThreshold *json.Number `json:"momThreshold"` + UnloginDays *int `json:"unloginDays"` + WarningColor string `json:"warningColor"` } type AlertRuleResponse struct { - ID uint64 `json:"id"` + ConditionMode int `json:"conditionMode"` + AmountDirection int `json:"amountDirection"` + AmountThreshold *string `json:"amountThreshold"` + ID uint64 `json:"id,string"` RuleName string `json:"ruleName"` RuleType string `json:"ruleType"` RuleTypeLabel string `json:"ruleTypeLabel"` diff --git a/internal/service/service.go b/internal/service/service.go index c0eabf9..0e634a4 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -19,6 +19,7 @@ var ProviderSet = wire.NewSet( businessservice.NewBindingService, businessservice.NewPasswordResetService, businessservice.NewBusinessPromoteService, + businessservice.NewAlertGovernanceService, businessservice.NewAlertRuleService, businessservice.NewAlertRecordService, businessservice.NewActivationService, diff --git a/internal/worker/task_executor.go b/internal/worker/task_executor.go index cd0c287..066ef4f 100644 --- a/internal/worker/task_executor.go +++ b/internal/worker/task_executor.go @@ -217,7 +217,13 @@ func (e *TaskExecutor) runMethod(ctx context.Context, task *taskbiz.TimedTask) e } for attempt := 0; attempt <= options.MaxRetryTimes; attempt++ { err = e.runMethodAttempt(ctx, task, method, options.Timeout) - if err == nil || ctx.Err() != nil || errors.Is(err, context.Canceled) || e.methodOrphaned(task.ID) { + if ctxErr := ctx.Err(); ctxErr != nil { + if errors.Is(ctxErr, context.DeadlineExceeded) { + return errTaskTimeout + } + return ctxErr + } + if err == nil || errors.Is(err, context.Canceled) || e.methodOrphaned(task.ID) { return err } if attempt < options.MaxRetryTimes { diff --git a/internal/worker/task_executor_test.go b/internal/worker/task_executor_test.go index f72966d..69e5751 100644 --- a/internal/worker/task_executor_test.go +++ b/internal/worker/task_executor_test.go @@ -95,6 +95,19 @@ func TestRunMethodCancellationStopsRetryDelay(t *testing.T) { } } +func TestRunMethodCancellationWinsConcurrentAttemptFailure(t *testing.T) { + for i := 0; i < 32; i++ { + ctx, cancel := context.WithCancel(context.Background()) + registry := platformtask.NewRegistry() + registry.Register(platformtask.Method{Name: "cancel-at-return", Run: func(context.Context, json.RawMessage) error { cancel(); return errors.New("temporary") }}) + err := (&TaskExecutor{methods: registry}).runMethod(ctx, &taskbiz.TimedTask{MethodName: "cancel-at-return", Params: []byte(`{"_execution":{"maxRetryTimes":3,"retryIntervalSeconds":60}}`)}) + cancel() + if !errors.Is(err, context.Canceled) { + t.Fatalf("并发取消被执行错误覆盖: %v", err) + } + } +} + func TestRunMethodUsesParentContext(t *testing.T) { const methodName = "worker-test-parent-context" started := make(chan struct{}) diff --git a/web/src/api/alert.js b/web/src/api/alert.js index ac4c64d..3bc535c 100644 --- a/web/src/api/alert.js +++ b/web/src/api/alert.js @@ -11,3 +11,14 @@ export const listAlertRecords = (params, options = {}) => service({ ...options, export const getAlertRecord = (id, options = {}) => service({ ...options, url: `/sw/alertRecord/detail/${id}`, method: 'get' }) export const processAlertRecord = (action, data) => service({ url: `/sw/alertRecord/${action}`, method: 'post', data }) export const addAlertNote = (data) => service({ url: '/sw/alertRecord/addNote', method: 'post', data }) + +export const ignoreAlerts = (ids) => service({ url: '/sw/alertRecord/ignore', method: 'post', data: { ids } }) +export const getAlertGap = () => service({ url: '/sw/alertRule/gap', method: 'get' }) +export const saveAlertGap = (data) => service({ url: '/sw/alertRule/gap', method: 'put', data }) +export const listAlertExemptions = (params) => service({ url: '/sw/alertExemption/list', method: 'get', params }) +export const addAlertExemption = (data) => service({ url: '/sw/alertExemption/add', method: 'post', data }) +export const removeAlertExemption = (id) => service({ url: `/sw/alertExemption/remove/${id}`, method: 'put' }) + +export const getAlertPlatforms = () => service({ url: '/sw/alertRecord/platforms', method: 'get' }) + +export const alertExemptionCandidates = (keyword) => service({ url: '/sw/alertExemption/candidates', method: 'get', params: { keyword } }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index c3254b0..961efd9 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -59,6 +59,7 @@ "/src/view/sw/ChannelActivationList.vue": "SwChannelActivationList", "/src/view/sw/ChannelDataStatistics.vue": "SwChannelDataStatistics", "/src/view/sw/ChannelList.vue": "SwChannelList", + "/src/view/sw/ExemptChannels.vue": "SwExemptChannels", "/src/view/sw/GameAnalytics.vue": "SwGameAnalytics", "/src/view/sw/OtherSettings.vue": "SwOtherSettings", "/src/view/sw/RankingAnalysis.vue": "RankingAnalysis", diff --git a/web/src/view/sw/AlertList.vue b/web/src/view/sw/AlertList.vue index 5c45ec7..a4cb00f 100644 --- a/web/src/view/sw/AlertList.vue +++ b/web/src/view/sw/AlertList.vue @@ -11,6 +11,8 @@ + + 搜索 @@ -19,16 +21,21 @@
- +
批量忽略({{ selected.length }})
+ + - + + + + - +
@@ -39,10 +46,10 @@ diff --git a/web/src/view/sw/alert-display.js b/web/src/view/sw/alert-display.js index f7b00cf..8a848ca 100644 --- a/web/src/view/sw/alert-display.js +++ b/web/src/view/sw/alert-display.js @@ -1,4 +1,4 @@ -export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理' } +export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理', 6: '已忽略' } export const alertStatusTone = (status) => ({ 1: 'danger', 2: 'warning', 3: 'info', 4: 'danger', 5: 'success' })[status] || 'info' export const alertShortTime = (value) => value ? value.replace('T', ' ').slice(5, 16) : '—' -export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理' } +export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理', ignore: '忽略' } diff --git a/web/src/view/sw/components/AlertHandleDrawer.vue b/web/src/view/sw/components/AlertHandleDrawer.vue index 87238e3..ec19321 100644 --- a/web/src/view/sw/components/AlertHandleDrawer.vue +++ b/web/src/view/sw/components/AlertHandleDrawer.vue @@ -71,7 +71,7 @@ const current = ref(null) const logs = ref([]) const detailCanAudit = ref(false) const auditAllowed = computed(() => props.canAudit && detailCanAudit.value) -const canNote = computed(() => props.canProcess && current.value && ([1, 2, 4].includes(current.value.status) || (current.value.status === 3 && auditAllowed.value))) +const canNote = computed(() => props.canProcess && current.value && [1, 2, 3, 4].includes(current.value.status)) const loading = ref(false) const savingNote = ref(false) const processing = ref(false)