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_time", window.PayBegin, window.PayEnd)
+ return db.Table("tab_promote lp").Select("1").Where("lp.source_db="+sourceColumn+" AND lp.id="+idColumn).
+ Where("(COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)>0 AND COALESCE(NULLIF(lp.last_login_time,0),lp.create_time)) OR (?) < CAST(? AS DECIMAL(20,2))", window.LoginBefore, pay, window.MinPay)
+}
+func (r *alertGovernanceRepo) LostExemptions(ctx context.Context, window business.AlertLossWindow) ([]*business.AlertExemption, error) {
+ db := r.data.DB().WithContext(ctx)
+ var pos []alertExemptionPO
+ // 没有在名单渠道时无需读取平台明细。
+ if err := db.Select("id").Where("removed_at IS NULL").Limit(1).Find(&pos).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ if len(pos) == 0 {
+ return nil, nil
+ }
+ available, err := businessTablesAvailable(db, "tab_promote", "tab_spend")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, business.ErrAlertChannelsUnavailable
+ }
+ result := []*business.AlertExemption{}
+ var cursor uint64
+ for {
+ var batch []alertExemptionPO
+ if err := db.Where("removed_at IS NULL AND id>?", 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_time", source, batch, window.PayBegin, window.PayEnd).Group("promote_id").Scan(&amounts).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ payments := map[uint64]string{}
+ for _, amount := range amounts {
+ payments[amount.PromoteID] = amount.RecentAmount
+ }
+ for _, channel := range channels {
+ amount := payments[channel.ID]
+ if amount == "" {
+ amount = "0"
+ }
+ lost, err := window.IsLost(channel.LastLoginTime, channel.CreateTime, amount)
+ if err != nil {
+ return nil, err
+ }
+ result[business.ChannelKey{SourceDB: source, ID: channel.ID}] = &lost
+ }
+ }
+ }
+ return result, nil
+}
diff --git a/internal/data/business/alert_metrics.go b/internal/data/business/alert_metrics.go
new file mode 100644
index 0000000..d2f6e03
--- /dev/null
+++ b/internal/data/business/alert_metrics.go
@@ -0,0 +1,195 @@
+package business
+
+import (
+ "context"
+ "gorm.io/gorm"
+ "kra/internal/biz/business"
+ "math/big"
+ "strings"
+ "time"
+)
+
+func alertTree(db *gorm.DB, roots []business.ChannelKey) *gorm.DB {
+ return db.Table("tab_promote root").Joins("JOIN tab_promote p ON p.source_db=root.source_db AND (p.id=root.id OR p.parent_id=root.id OR p.top_promote_id=root.id)").
+ Where("(root.source_db,root.id) IN ?", channelPairs(roots))
+}
+func alertMetricSpec(db *gorm.DB, metric int) (table, stamp, condition, amount string) {
+ if metric == 2 {
+ return "tab_spend", "m.pay_time", "m.pay_status=1", "m.pay_amount"
+ }
+ stamp = "CAST(m.register_time AS INTEGER)"
+ if db.Dialector.Name() == "mysql" {
+ stamp = "m.sw_register_time_unix"
+ }
+ return "tab_user", stamp, "(m.puid=0 OR m.puid IS NULL)", "1"
+}
+
+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)
+ if metric != 1 && metric != 2 {
+ return nil, business.ErrAlertRuleInput
+ }
+ table, stamp, condition, amount := alertMetricSpec(db, metric)
+ available, err := businessTablesAvailable(db, table, "tab_promote")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, business.ErrReportBuildUnavailable
+ }
+ result := map[business.ChannelKey]business.AlertComparison{}
+ current, previous := today.AddDate(0, 0, -days).Unix(), today.AddDate(0, 0, -2*days).Unix()
+ // 先取小规模渠道映射,再按固定平台及渠道 ID 批量聚合;避免 MySQL 5.7
+ // 将动态 JOIN 的时间范围退化为 ref 扫描,读取每个渠道的全部历史明细。
+ owners := map[business.ChannelKey][]business.ChannelKey{}
+ bySource := map[string][]uint64{}
+ for start := 0; start < len(channels); start += 100 {
+ var pairs []struct {
+ SourceDB string
+ RootID, PromoteID uint64
+ }
+ if err := alertTree(db, channels[start:min(start+100, len(channels))]).Select("root.source_db,root.id AS root_id,p.id AS promote_id").Scan(&pairs).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ for _, p := range pairs {
+ leaf := business.ChannelKey{SourceDB: p.SourceDB, ID: p.PromoteID}
+ root := business.ChannelKey{SourceDB: p.SourceDB, ID: p.RootID}
+ if len(owners[leaf]) == 0 {
+ bySource[p.SourceDB] = append(bySource[p.SourceDB], p.PromoteID)
+ }
+ owners[leaf] = append(owners[leaf], root)
+ result[root] = business.AlertComparison{Current: "0", Previous: "0"}
+ }
+ }
+ for source, ids := range bySource {
+ for start := 0; start < len(ids); start += 300 {
+ batch := ids[start:min(start+300, len(ids))]
+ firstQueries := []string{}
+ args := []any{}
+ for _, id := range batch {
+ // MIN 的三个等值前缀命中覆盖索引,MySQL 可直接读取首个索引项。
+ firstQueries = append(firstQueries, "SELECT ? AS promote_id,(SELECT MIN("+stamp+") FROM "+table+" m WHERE m.source_db=? AND m.promote_id=? AND "+condition+" AND "+stamp+">0) 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 + " THEN " + amount + " ELSE 0 END),0) AS previous_value"
+ q := db.Table(table+" m").Where("m.source_db=? AND m.promote_id IN ?", source, batch).Where(condition).Where(stamp+">=? AND "+stamp+"", previous, today.Unix())
+ if err := q.Select(selection, current, current).Group("m.promote_id").Scan(&rows).Error; err != nil {
+ return nil, business.ErrStorage
+ }
+ scale := 0
+ if metric == 2 {
+ scale = 2
+ }
+ for _, row := range rows {
+ for _, root := range owners[business.ChannelKey{SourceDB: source, ID: row.PromoteID}] {
+ v := result[root]
+ var err error
+ v.Current, err = addAlertMetric(v.Current, row.CurrentValue, scale)
+ if err != nil {
+ return nil, err
+ }
+ v.Previous, err = addAlertMetric(v.Previous, row.PreviousValue, scale)
+ if err != nil {
+ return nil, err
+ }
+ result[root] = v
+ }
+ }
+ }
+ }
+ return result, nil
+}
+
+func addAlertMetric(a, b string, scale int) (string, error) {
+ left, ok := new(big.Rat).SetString(a)
+ if !ok {
+ return "", business.ErrStorage
+ }
+ right, ok := new(big.Rat).SetString(b)
+ if !ok {
+ return "", business.ErrStorage
+ }
+ return left.Add(left, right).FloatString(scale), nil
+}
+
+func (r *alertTriggerRepo) Snapshots(ctx context.Context, channels []business.ChannelKey, now time.Time, dedup bool) (map[business.ChannelKey]business.AlertSnapshot, error) {
+ db := r.data.DB().WithContext(ctx)
+ available, err := businessTablesAvailable(db, "tab_user", "tab_spend", "tab_promote")
+ if err != nil {
+ return nil, err
+ }
+ if !available {
+ return nil, business.ErrReportBuildUnavailable
+ }
+ result := map[business.ChannelKey]business.AlertSnapshot{}
+ for _, key := range channels {
+ result[key] = business.AlertSnapshot{Pay: "0.00"}
+ }
+ for start := 0; start < len(channels); start += 100 {
+ batch := channels[start:min(start+100, len(channels))]
+ var players []struct {
+ SourceDB string
+ PromoteID uint64
+ Players int64
+ }
+ _, stamp, condition, _ := alertMetricSpec(db, 1)
+ identity := "CAST(m.id AS TEXT)"
+ if db.Dialector.Name() == "mysql" {
+ identity = "CAST(m.id AS CHAR)"
+ }
+ if dedup {
+ identity = "CASE WHEN TRIM(COALESCE(m.idcard,''))<>'' 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 + " THEN " + amount + " ELSE 0 END),0) AS current_value," +
- "COALESCE(SUM(CASE WHEN " + stamp + ">=? AND " + stamp + " THEN " + amount + " ELSE 0 END),0) AS previous_value"
- if err := db.Table(table).Select(selection, currentStart, today.Unix(), previousStart, currentStart).Where(condition).Where("(source_db,promote_id) IN ?", channelPairs(channels[start:min(start+500, len(channels))])).Group("source_db,promote_id").Scan(&rows).Error; err != nil {
- return nil, business.ErrStorage
- }
- for _, row := range rows {
- value := business.AlertComparison{Current: row.CurrentValue, Previous: row.PreviousValue}
- if row.FirstUnix != nil {
- value.FirstUnix = *row.FirstUnix
- }
- result[business.ChannelKey{SourceDB: row.SourceDB, ID: row.PromoteID}] = value
- }
- }
- return result, nil
-}
-
func (r *alertTriggerRepo) StoreHit(ctx context.Context, hit *business.AlertRecord, open []int, shouldWrite func(*business.AlertRecord) bool) (bool, error) {
written := false
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ if err := lockAlertChannel(tx, hit.PlatformCode, hit.TargetPromoteID); err != nil {
+ return business.ErrStorage
+ }
+ if hit.OwnerScope != nil {
+ _, err := alertExemptionTarget(tx, *hit.OwnerScope, business.ChannelKey{SourceDB: hit.PlatformCode, ID: hit.TargetPromoteID})
+ if errors.Is(err, business.ErrAlertRecordScope) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ }
+ var exempt int64
+ if err := tx.Model(&alertExemptionPO{}).Where("source_db=? AND promote_id=? AND owner_id=? AND removed_at IS NULL", hit.PlatformCode, hit.TargetPromoteID, hit.BusinessID).Count(&exempt).Error; err != nil {
+ return business.ErrStorage
+ }
+ if exempt > 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 @@