feat: implement V2 alert center and scoped alert governance
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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("编辑覆盖启停状态或未传入的历史参数")
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Generated
+7
-2
@@ -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)
|
||||
|
||||
+1
-1
@@ -11,4 +11,4 @@ import (
|
||||
)
|
||||
|
||||
// ProviderSet is biz providers.
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
||||
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewOtherSettingsUsecase, business.NewTeamUsecase, business.NewGoalUsecase, business.NewEffectivenessUsecase, business.NewPerformanceUsecase, business.NewDashboardUsecase, business.NewAccountUsecase, business.NewBindingUsecase, business.NewPasswordResetUsecase, business.NewBusinessPromoteUsecase, business.NewAlertGovernanceUsecase, business.NewAlertRuleUsecase, business.NewAlertRecordUsecase, business.NewActivationUsecase, business.NewChannelUsecase, business.NewBusinessPortraitUsecase, business.NewChannelPortraitUsecase, business.NewLedgerUsecase, business.NewAnalyticsUsecase, business.NewServerAnalysisUsecase, business.NewWorkbenchUsecase, business.NewRankingUsecase, business.NewReportBuildUsecase, business.NewAlertTriggerUsecase)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"kra/internal/biz/system"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAlertGovernanceLevel = errors.Forbidden("SW_ALERT_GOVERNANCE_LEVEL", "仅一级商务或超管可操作预警治理")
|
||||
ErrAlertExemptExists = errors.Conflict("SW_ALERT_EXEMPT_EXISTS", "渠道已在免预警名单中")
|
||||
ErrAlertExemptNotFound = errors.NotFound("SW_ALERT_EXEMPT_NOT_FOUND", "免预警记录不存在或已移出")
|
||||
)
|
||||
|
||||
type AlertGap struct {
|
||||
OwnerID uint
|
||||
Enabled bool
|
||||
Days int
|
||||
}
|
||||
type AlertExemption struct {
|
||||
ID uint64
|
||||
ChannelKey
|
||||
ChannelName string
|
||||
OwnerID, AddedBy uint
|
||||
AddedName, Reason string
|
||||
AddedAt time.Time
|
||||
RemovedBy uint
|
||||
RemovedName, RemoveReason string
|
||||
RemovedAt *time.Time
|
||||
}
|
||||
type AlertExemptionFilter struct {
|
||||
Name string
|
||||
Active *bool
|
||||
}
|
||||
type AlertExemptionTarget struct {
|
||||
Name string
|
||||
Account *Account
|
||||
}
|
||||
type AlertChannelOption struct {
|
||||
ChannelKey
|
||||
Name string
|
||||
}
|
||||
type AlertLossWindow struct {
|
||||
LoginBefore, PayBegin, PayEnd int64
|
||||
MinPay string
|
||||
}
|
||||
|
||||
func AlertLossAt(now time.Time) AlertLossWindow {
|
||||
now = now.In(time.FixedZone("Asia/Shanghai", 8*3600))
|
||||
return AlertLossWindow{LoginBefore: now.AddDate(0, 0, -15).Unix(), PayBegin: now.AddDate(0, 0, -30).Unix(), PayEnd: now.Unix(), MinPay: "500"}
|
||||
}
|
||||
|
||||
func (w AlertLossWindow) IsLost(lastLogin, created int64, pay string) (bool, error) {
|
||||
if lastLogin <= 0 {
|
||||
lastLogin = created
|
||||
}
|
||||
if lastLogin > 0 && lastLogin < w.LoginBefore {
|
||||
return true, nil
|
||||
}
|
||||
amount, err := portraitNumber(pay)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
minimum, err := portraitNumber(w.MinPay)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return amount.Cmp(minimum) < 0, nil
|
||||
}
|
||||
|
||||
type AlertGovernanceRepo interface {
|
||||
Candidates(context.Context, UserScope, string, int) ([]*AlertChannelOption, error)
|
||||
Gap(context.Context, uint) (*AlertGap, error)
|
||||
SaveGap(context.Context, *AlertGap) error
|
||||
Exemptions(context.Context, LevelOneScope, AlertExemptionFilter, ...ListOption) ([]*AlertExemption, int64, error)
|
||||
// 在渠道锁及事务内读取当前归属,由领域回调构造豁免记录后写入。
|
||||
AddExemption(context.Context, UserScope, ChannelKey, func(*AlertExemptionTarget) (*AlertExemption, error)) error
|
||||
RemoveExemption(context.Context, LevelOneScope, uint64, uint, string, string, time.Time) error
|
||||
LostExemptions(context.Context, AlertLossWindow) ([]*AlertExemption, error)
|
||||
}
|
||||
|
||||
func (uc *AlertGovernanceUsecase) Candidates(ctx context.Context, keyword string, limit int) ([]*AlertChannelOption, error) {
|
||||
scope, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
viewer := alertUserScope(a)
|
||||
if scope.All {
|
||||
viewer = UserScope{All: true}
|
||||
}
|
||||
return uc.repo.Candidates(ctx, viewer, strings.TrimSpace(keyword), limit)
|
||||
}
|
||||
|
||||
type AlertGovernanceUsecase struct {
|
||||
repo AlertGovernanceRepo
|
||||
accounts *AccountUsecase
|
||||
}
|
||||
|
||||
func NewAlertGovernanceUsecase(repo AlertGovernanceRepo, accounts *AccountUsecase) *AlertGovernanceUsecase {
|
||||
return &AlertGovernanceUsecase{repo, accounts}
|
||||
}
|
||||
|
||||
func (uc *AlertGovernanceUsecase) owner(ctx context.Context, write bool) (LevelOneScope, *Account, error) {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return LevelOneScope{}, nil, err
|
||||
}
|
||||
if write && actor.AuthorityID != 888 && account.Level != 1 {
|
||||
return LevelOneScope{}, nil, ErrAlertGovernanceLevel
|
||||
}
|
||||
return LevelOneOwner(actor, account), account, nil
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Gap(ctx context.Context) (*AlertGap, error) {
|
||||
scope, a, err := uc.owner(ctx, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope.Empty {
|
||||
return nil, ErrAlertRuleScope
|
||||
}
|
||||
owner := scope.UserID
|
||||
if scope.All {
|
||||
owner = a.ID
|
||||
}
|
||||
return uc.repo.Gap(ctx, owner)
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) SaveGap(ctx context.Context, enabled bool, days int) error {
|
||||
_, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if days < 1 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
return uc.repo.SaveGap(ctx, &AlertGap{OwnerID: a.ID, Enabled: enabled, Days: days})
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) List(ctx context.Context, filter AlertExemptionFilter, opts ...ListOption) ([]*AlertExemption, int64, error) {
|
||||
scope, _, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return uc.repo.Exemptions(ctx, scope, filter, opts...)
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Add(ctx context.Context, key ChannelKey, reason string) error {
|
||||
scope, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reason = strings.TrimSpace(reason)
|
||||
if key.ID == 0 || reason == "" {
|
||||
return ErrAlertRecordInput
|
||||
}
|
||||
key.SourceDB, err = NormalizePlatform(key.SourceDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
viewer := alertUserScope(a)
|
||||
if scope.All {
|
||||
viewer = UserScope{All: true}
|
||||
}
|
||||
return uc.repo.AddExemption(ctx, viewer, key, func(target *AlertExemptionTarget) (*AlertExemption, error) {
|
||||
owner := LevelOneOwnerForAccount(target.Account)
|
||||
if owner.Empty {
|
||||
return nil, ErrAlertRecordScope
|
||||
}
|
||||
return &AlertExemption{ChannelKey: key, ChannelName: target.Name, OwnerID: owner.UserID, AddedBy: a.ID, AddedName: a.Username, Reason: reason, AddedAt: time.Now()}, nil
|
||||
})
|
||||
}
|
||||
func LevelOneOwnerForAccount(a *Account) LevelOneScope {
|
||||
// 系统账号特权只用于授权,不用于渠道的商务归属。
|
||||
return LevelOneOwner(system.Actor{}, a)
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Remove(ctx context.Context, id uint64) error {
|
||||
scope, a, err := uc.owner(ctx, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.RemoveExemption(ctx, scope, id, a.ID, a.Username, "手动移出", time.Now())
|
||||
}
|
||||
func (uc *AlertGovernanceUsecase) Reconcile(ctx context.Context, now time.Time) error {
|
||||
rows, err := uc.repo.LostExemptions(ctx, AlertLossAt(now))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := uc.repo.RemoveExemption(ctx, LevelOneScope{All: true}, row.ID, 0, "系统(自动移出)", "进入激活池 · 自动移出", now); err != nil && !errors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -19,6 +19,13 @@ var (
|
||||
)
|
||||
|
||||
type AlertRecord struct {
|
||||
OwnerScope *UserScope
|
||||
// 检测写入策略,仅供任务与仓储使用,不向 HTTP 返回。
|
||||
GapDays int
|
||||
IndependentRule bool
|
||||
TotalPlayers *int64
|
||||
TotalPay, WarningColor *string
|
||||
Lost *bool
|
||||
ID, RuleID, TargetPromoteID uint64
|
||||
PlatformCode, PlatformName, RuleName, RuleType string
|
||||
MonitorMetric *int
|
||||
@@ -47,6 +54,8 @@ type AlertRecordScope struct {
|
||||
}
|
||||
|
||||
type AlertRecordFilter struct {
|
||||
AlertContent string
|
||||
Lost *bool
|
||||
PlatformCodes []string
|
||||
TargetPromoteID *uint64
|
||||
PlatformBusierID *uint64
|
||||
@@ -69,12 +78,22 @@ type AlertRecordDetail struct {
|
||||
}
|
||||
|
||||
type AlertRecordRepo interface {
|
||||
Platforms(context.Context, AlertRecordScope) ([]string, error)
|
||||
Ignore(context.Context, AlertRecordScope, []uint64, *AlertLog) error
|
||||
List(context.Context, AlertRecordScope, AlertRecordFilter, ...ListOption) (*AlertRecordPage, error)
|
||||
Detail(context.Context, AlertRecordScope, uint64) (*AlertRecordDetail, error)
|
||||
// 在同一事务和行锁内校验当前记录、执行业务回调、写入状态与日志。
|
||||
WithRecord(context.Context, AlertRecordScope, uint64, func(*AlertRecord) ([]*AlertLog, error)) error
|
||||
}
|
||||
|
||||
func (uc *AlertRecordUsecase) Platforms(ctx context.Context) ([]string, error) {
|
||||
scope, _, err := uc.scope(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return uc.repo.Platforms(ctx, scope)
|
||||
}
|
||||
|
||||
type AlertAction string
|
||||
|
||||
const (
|
||||
@@ -83,6 +102,7 @@ const (
|
||||
AlertPendingAudit = 3
|
||||
AlertAuditRejected = 4
|
||||
AlertProcessed = 5
|
||||
AlertIgnored = 6
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -205,13 +225,34 @@ func (uc *AlertRecordUsecase) AddNote(ctx context.Context, id uint64, note strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.WithRecord(ctx, scope, id, func(_ *AlertRecord) ([]*AlertLog, error) {
|
||||
return uc.repo.WithRecord(ctx, scope, id, func(record *AlertRecord) ([]*AlertLog, error) {
|
||||
if record.Status == AlertProcessed || record.Status == AlertIgnored {
|
||||
return nil, ErrAlertRecordChanged
|
||||
}
|
||||
return []*AlertLog{{AlertID: id, OperatorID: account.ID, OperatorName: account.Username, LogType: 2, NoteContent: note, AttachmentIDs: attachments}}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func AlertStatusLabel(status int) string {
|
||||
return map[int]string{1: "未处理", 2: "处理中", 3: "待审核", 4: "审核驳回", 5: "已处理"}[status]
|
||||
return map[int]string{1: "未处理", 2: "处理中", 3: "待审核", 4: "审核驳回", 5: "已处理", 6: "已忽略"}[status]
|
||||
}
|
||||
|
||||
func (uc *AlertRecordUsecase) Ignore(ctx context.Context, ids []uint64) error {
|
||||
actor, account, err := uc.accounts.actor(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actor.AuthorityID != 888 && account.Level != 1 {
|
||||
return ErrAlertGovernanceLevel
|
||||
}
|
||||
if len(ids) == 0 || len(ids) > 500 {
|
||||
return ErrAlertRecordInput
|
||||
}
|
||||
scope, _, err := uc.scope(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.repo.Ignore(ctx, scope, ids, &AlertLog{OperatorID: account.ID, OperatorName: account.Username, LogType: 1, ActionDetail: "ignore"})
|
||||
}
|
||||
|
||||
func AlertLogTypeLabel(logType int) string {
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestAlertRecordIndependentNotePreservesClosedState(t *testing.T) {
|
||||
if err := uc.AddNote(accountContext(8, 902), 100, " ", []string{"1"}); err != ErrAlertNoteEmpty {
|
||||
t.Fatal("空备注不应绕过校验")
|
||||
}
|
||||
if err := uc.AddNote(accountContext(8, 902), 100, "后续补充", []string{"1"}); err != nil || repo.status != 5 || len(repo.logs) != 1 || repo.logs[0].LogType != 2 {
|
||||
t.Fatal("独立备注改变了状态")
|
||||
if err := uc.AddNote(accountContext(8, 902), 100, "后续补充", []string{"1"}); err != ErrAlertRecordChanged || repo.status != 5 || len(repo.logs) != 0 {
|
||||
t.Fatal("已处理预警应只读")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,16 @@ import (
|
||||
"fmt"
|
||||
"github.com/go-kratos/kratos/v3/errors"
|
||||
"kra/internal/biz/system"
|
||||
"math"
|
||||
"math/big"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
)
|
||||
|
||||
var alertDecimal = regexp.MustCompile(`^[0-9]{1,12}(\.[0-9]{1,2})?$`)
|
||||
|
||||
var (
|
||||
ErrAlertRuleNotFound = errors.NotFound("SW_ALERT_RULE_NOT_FOUND", "规则不存在")
|
||||
ErrAlertRuleScope = errors.Forbidden("SW_ALERT_RULE_SCOPE", "无权限操作该规则")
|
||||
@@ -53,6 +56,9 @@ func LevelOneOwner(actor system.Actor, a *Account) LevelOneScope {
|
||||
}
|
||||
|
||||
type AlertRule struct {
|
||||
ConditionMode int
|
||||
AmountDirection int
|
||||
AmountThreshold *string
|
||||
ID uint64
|
||||
Name, Type string
|
||||
Metric int
|
||||
@@ -134,6 +140,48 @@ func validateAlertRule(r *AlertRule) error {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
r.Type = strings.TrimSpace(r.Type)
|
||||
if r.ConditionMode == 0 {
|
||||
r.ConditionMode = 1
|
||||
}
|
||||
if r.ConditionMode != 1 && r.ConditionMode != 2 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
// 非当前指标的输入不参与业务配置,避免隐藏字段绕过校验写入存储。
|
||||
if r.Metric == 3 {
|
||||
r.StatPeriodDays = nil
|
||||
r.MomDirection = nil
|
||||
r.MomThreshold = nil
|
||||
} else {
|
||||
r.UnloginDays = nil
|
||||
}
|
||||
if r.ConditionMode == 2 {
|
||||
r.MomDirection = nil
|
||||
r.MomThreshold = nil
|
||||
} else {
|
||||
r.AmountThreshold = nil
|
||||
r.AmountDirection = 1
|
||||
}
|
||||
if r.ConditionMode == 2 {
|
||||
if r.Metric != 2 || r.StatPeriodDays == nil || *r.StatPeriodDays <= 0 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
if r.AmountDirection != 1 && r.AmountDirection != 2 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
if r.AmountThreshold == nil || !alertDecimal.MatchString(*r.AmountThreshold) {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
n, ok := new(big.Rat).SetString(*r.AmountThreshold)
|
||||
if !ok || n.Sign() <= 0 || n.Cmp(big.NewRat(99999999999999, 100)) > 0 || !new(big.Rat).Mul(n, big.NewRat(100, 1)).IsInt() {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
amount := n.FloatString(2)
|
||||
r.AmountThreshold = &amount
|
||||
if r.Type == "" || len(utf16.Encode([]rune(r.Type))) > 50 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if r.Type == "" || len(utf16.Encode([]rune(r.Type))) > 50 {
|
||||
return ErrAlertRuleInput
|
||||
}
|
||||
@@ -145,11 +193,11 @@ func validateAlertRule(r *AlertRule) error {
|
||||
if r.MomDirection == nil || (*r.MomDirection != 1 && *r.MomDirection != 2) {
|
||||
return ErrAlertRuleDirection
|
||||
}
|
||||
if r.MomThreshold == nil {
|
||||
if r.MomThreshold == nil || !alertDecimal.MatchString(*r.MomThreshold) {
|
||||
return ErrAlertRuleThreshold
|
||||
}
|
||||
n, err := strconv.ParseFloat(*r.MomThreshold, 64)
|
||||
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n <= 0 || n > 9999 {
|
||||
n, ok := new(big.Rat).SetString(*r.MomThreshold)
|
||||
if !ok || n.Sign() <= 0 || n.Cmp(big.NewRat(9999, 1)) > 0 {
|
||||
return ErrAlertRuleThreshold
|
||||
}
|
||||
case 3:
|
||||
@@ -269,6 +317,13 @@ func optionalInt(value *int) string {
|
||||
return strconv.Itoa(*value)
|
||||
}
|
||||
func (r *AlertRule) Description() string {
|
||||
if r.Metric == 2 && r.ConditionMode == 2 && r.AmountThreshold != nil {
|
||||
direction := "满"
|
||||
if r.AmountDirection == 2 {
|
||||
direction = "未满"
|
||||
}
|
||||
return fmt.Sprintf("渠道近 %s 天充值金额%s %s 元时触发预警(汇总子渠道)", optionalInt(r.StatPeriodDays), direction, *r.AmountThreshold)
|
||||
}
|
||||
if r.Metric == 3 {
|
||||
return "渠道连续 " + optionalInt(r.UnloginDays) + " 天未登录时触发预警"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -18,10 +18,22 @@ type AlertTriggerChannel struct {
|
||||
}
|
||||
|
||||
type AlertTriggerInput struct {
|
||||
Rules []*AlertRule
|
||||
Accounts []*Account
|
||||
Bindings []*Binding
|
||||
Channels []*AlertTriggerChannel
|
||||
Exemptions map[AlertExemptionKey]bool
|
||||
Gaps map[uint]*AlertGap
|
||||
Dedup map[uint]bool
|
||||
Rules []*AlertRule
|
||||
Accounts []*Account
|
||||
Bindings []*Binding
|
||||
Channels []*AlertTriggerChannel
|
||||
}
|
||||
type AlertExemptionKey struct {
|
||||
ChannelKey
|
||||
OwnerID uint
|
||||
}
|
||||
|
||||
type AlertSnapshot struct {
|
||||
Players int64
|
||||
Pay string
|
||||
}
|
||||
|
||||
type AlertComparison struct {
|
||||
@@ -30,6 +42,8 @@ type AlertComparison struct {
|
||||
}
|
||||
|
||||
type AlertTriggerRepo interface {
|
||||
FilterWritable(context.Context, []*AlertRecord, []int, func(*AlertRecord) bool) ([]*AlertRecord, error)
|
||||
Snapshots(context.Context, []ChannelKey, time.Time, bool) (map[ChannelKey]AlertSnapshot, error)
|
||||
Input(context.Context) (*AlertTriggerInput, error)
|
||||
Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error)
|
||||
// 仓储在渠道/分类键锁及事务内读取未关闭记录,由领域决定是否刷新。
|
||||
@@ -38,10 +52,13 @@ type AlertTriggerRepo interface {
|
||||
|
||||
type AlertTriggerResult struct{ Rules, Written, Skipped int }
|
||||
|
||||
type AlertTriggerUsecase struct{ repo AlertTriggerRepo }
|
||||
type AlertTriggerUsecase struct {
|
||||
repo AlertTriggerRepo
|
||||
governance *AlertGovernanceUsecase
|
||||
}
|
||||
|
||||
func NewAlertTriggerUsecase(repo AlertTriggerRepo) *AlertTriggerUsecase {
|
||||
return &AlertTriggerUsecase{repo: repo}
|
||||
func NewAlertTriggerUsecase(repo AlertTriggerRepo, governance *AlertGovernanceUsecase) *AlertTriggerUsecase {
|
||||
return &AlertTriggerUsecase{repo: repo, governance: governance}
|
||||
}
|
||||
|
||||
func (uc *AlertTriggerUsecase) Run(ctx context.Context) error {
|
||||
@@ -53,6 +70,10 @@ func validTriggerRule(rule *AlertRule) bool {
|
||||
if rule == nil || rule.CreatorID == 0 || strings.TrimSpace(rule.Type) == "" {
|
||||
return false
|
||||
}
|
||||
if rule.Metric == 2 && rule.ConditionMode == 2 {
|
||||
copy := *rule
|
||||
return validateAlertRule(©) == nil
|
||||
}
|
||||
if rule.Metric == 3 {
|
||||
return rule.UnloginDays != nil && *rule.UnloginDays > 0
|
||||
}
|
||||
@@ -102,18 +123,15 @@ func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]
|
||||
channel.SourceDB = source
|
||||
key := PlatformKey{SourceDB: source, BusierID: channel.BusierID}
|
||||
account := accounts[bindings[key]]
|
||||
if conflicts[key] || account == nil || account.Level != 3 {
|
||||
if conflicts[key] || account == nil || account.Level < 1 || account.Level > 3 {
|
||||
continue
|
||||
}
|
||||
path := strings.Split(account.IDPath, ",")
|
||||
if len(path) < 2 {
|
||||
continue
|
||||
}
|
||||
id, err := strconv.ParseUint(path[1], 10, 64)
|
||||
owner := accounts[uint(id)]
|
||||
if err != nil || owner == nil || owner.Level != 1 {
|
||||
scope := LevelOneOwnerForAccount(account)
|
||||
owner := accounts[scope.UserID]
|
||||
if scope.Empty || owner == nil || owner.Level != 1 {
|
||||
continue
|
||||
}
|
||||
|
||||
owners[channel.ChannelKey] = owner
|
||||
}
|
||||
return owners, accounts
|
||||
@@ -122,6 +140,11 @@ func triggerOwners(input *AlertTriggerInput) (map[ChannelKey]*Account, map[uint]
|
||||
// 一次执行固定上海自然日,避免跨午夜时同一任务的各规则使用不同的比较窗口。
|
||||
func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*AlertTriggerResult, error) {
|
||||
result := &AlertTriggerResult{}
|
||||
if uc.governance != nil {
|
||||
if err := uc.governance.Reconcile(ctx, now); err != nil {
|
||||
return result, err
|
||||
}
|
||||
}
|
||||
input, err := uc.repo.Input(ctx)
|
||||
if err != nil {
|
||||
return result, err
|
||||
@@ -139,6 +162,7 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
}
|
||||
snapshots := map[snapshotKey]map[ChannelKey]AlertComparison{}
|
||||
var failures []string
|
||||
pending := map[uint][]*AlertRecord{}
|
||||
for _, rule := range input.Rules {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return result, err
|
||||
@@ -160,6 +184,9 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
}
|
||||
owner := owners[channel.ChannelKey]
|
||||
if owner != nil && (creator.AuthorityID == 888 || owner.ID == creator.ID) {
|
||||
if input.Exemptions[AlertExemptionKey{ChannelKey: channel.ChannelKey, OwnerID: owner.ID}] {
|
||||
continue
|
||||
}
|
||||
channels = append(channels, channel)
|
||||
keys = append(keys, channel.ChannelKey)
|
||||
}
|
||||
@@ -198,9 +225,62 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
record := &AlertRecord{RuleID: rule.ID, RuleName: rule.Name, RuleType: strings.TrimSpace(rule.Type), MonitorMetric: &rule.Metric,
|
||||
PlatformCode: channel.SourceDB, PlatformName: PlatformName(channel.SourceDB), TargetPromoteID: channel.ID, TargetPromoteName: channel.Account,
|
||||
BusinessID: owner.ID, BusinessName: owner.Username, AlertContent: content, Status: AlertUnprocessed, CreatedAt: now, UpdatedAt: now}
|
||||
written, err := uc.repo.StoreHit(ctx, record, []int{AlertUnprocessed, AlertProcessing, AlertPendingAudit, AlertAuditRejected}, func(current *AlertRecord) bool { return current == nil || current.Status == AlertUnprocessed })
|
||||
record.WarningColor = &rule.WarningColor
|
||||
record.IndependentRule = rule.Metric == 2 && rule.ConditionMode == 2
|
||||
ownerScope := alertUserScope(owner)
|
||||
record.OwnerScope = &ownerScope
|
||||
if gap := input.Gaps[creator.ID]; gap != nil && gap.Enabled {
|
||||
record.GapDays = gap.Days
|
||||
}
|
||||
pending[creator.ID] = append(pending[creator.ID], record)
|
||||
}
|
||||
}
|
||||
creators := make([]uint, 0, len(pending))
|
||||
for creator := range pending {
|
||||
creators = append(creators, creator)
|
||||
}
|
||||
sort.Slice(creators, func(i, j int) bool { return creators[i] < creators[j] })
|
||||
for _, creator := range creators {
|
||||
hits := pending[creator]
|
||||
openStatuses := []int{AlertUnprocessed, AlertProcessing, AlertPendingAudit, AlertAuditRejected}
|
||||
canWrite := func(current *AlertRecord) bool { return current == nil || current.Status == AlertUnprocessed }
|
||||
hits, err = uc.repo.FilterWritable(ctx, hits, openStatuses, canWrite)
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("商务 %d 读取在途预警失败: %v", creator, err))
|
||||
continue
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
continue
|
||||
}
|
||||
keys := []ChannelKey{}
|
||||
seen := map[ChannelKey]bool{}
|
||||
for _, hit := range hits {
|
||||
key := ChannelKey{SourceDB: hit.PlatformCode, ID: hit.TargetPromoteID}
|
||||
if !seen[key] {
|
||||
keys = append(keys, key)
|
||||
seen[key] = true
|
||||
}
|
||||
}
|
||||
dedup, ok := input.Dedup[creator]
|
||||
if !ok {
|
||||
dedup = true
|
||||
}
|
||||
totals, err := uc.repo.Snapshots(ctx, keys, now, dedup)
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("商务 %d 读取预警快照失败: %v", creator, err))
|
||||
continue
|
||||
}
|
||||
for _, record := range hits {
|
||||
total, ok := totals[ChannelKey{SourceDB: record.PlatformCode, ID: record.TargetPromoteID}]
|
||||
if !ok {
|
||||
failures = append(failures, "预警快照缺失")
|
||||
continue
|
||||
}
|
||||
record.TotalPlayers = &total.Players
|
||||
record.TotalPay = &total.Pay
|
||||
written, err := uc.repo.StoreHit(ctx, record, openStatuses, canWrite)
|
||||
if err != nil {
|
||||
failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d 写入失败: %v", rule.ID, channel.SourceDB, channel.ID, err))
|
||||
failures = append(failures, fmt.Sprintf("规则 %d 渠道 %s/%d 写入失败: %v", record.RuleID, record.PlatformCode, record.TargetPromoteID, err))
|
||||
continue
|
||||
}
|
||||
if written {
|
||||
@@ -208,6 +288,7 @@ func (uc *AlertTriggerUsecase) Detect(ctx context.Context, now time.Time) (*Aler
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(failures) > 0 {
|
||||
for _, failure := range failures {
|
||||
slog.ErrorContext(ctx, "预警检测失败", "detail", failure)
|
||||
@@ -234,6 +315,25 @@ func triggerContent(rule *AlertRule, channel *AlertTriggerChannel, sample AlertC
|
||||
}
|
||||
return fmt.Sprintf("连续%d天未登录", days), true, nil
|
||||
}
|
||||
if rule.Metric == 2 && rule.ConditionMode == 2 {
|
||||
amount, err := portraitNumber(sample.Current)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
threshold, ok := new(big.Rat).SetString(*rule.AmountThreshold)
|
||||
if !ok {
|
||||
return "", false, ErrAlertRuleInput
|
||||
}
|
||||
reached := amount.Cmp(threshold) >= 0
|
||||
if (rule.AmountDirection == 1 && !reached) || (rule.AmountDirection == 2 && reached) {
|
||||
return "", false, nil
|
||||
}
|
||||
direction := "满"
|
||||
if rule.AmountDirection == 2 {
|
||||
direction = "未满"
|
||||
}
|
||||
return fmt.Sprintf("截至昨日%d个完整自然日充值¥%s,%s¥%s(汇总子渠道)", *rule.StatPeriodDays, portraitDecimal(amount, 2), direction, *rule.AmountThreshold), true, nil
|
||||
}
|
||||
days := *rule.StatPeriodDays
|
||||
if sample.FirstUnix <= 0 || time.Unix(sample.FirstUnix, 0).In(today.Location()).Format("2006-01-02") > today.AddDate(0, 0, -2*days).Format("2006-01-02") {
|
||||
return "", false, nil
|
||||
|
||||
@@ -9,14 +9,21 @@ import (
|
||||
)
|
||||
|
||||
type triggerRepoFake struct {
|
||||
input *AlertTriggerInput
|
||||
metrics map[ChannelKey]AlertComparison
|
||||
hits []*AlertRecord
|
||||
metricCalls int
|
||||
current *AlertRecord
|
||||
failure error
|
||||
input *AlertTriggerInput
|
||||
metrics map[ChannelKey]AlertComparison
|
||||
hits []*AlertRecord
|
||||
metricCalls int
|
||||
snapshotCalls int
|
||||
current *AlertRecord
|
||||
failure error
|
||||
}
|
||||
|
||||
func (r *triggerRepoFake) FilterWritable(_ context.Context, hits []*AlertRecord, _ []int, allow func(*AlertRecord) bool) ([]*AlertRecord, error) {
|
||||
if !allow(r.current) {
|
||||
return nil, nil
|
||||
}
|
||||
return hits, nil
|
||||
}
|
||||
func (r *triggerRepoFake) Input(context.Context) (*AlertTriggerInput, error) { return r.input, nil }
|
||||
func (r *triggerRepoFake) Comparison(context.Context, int, []ChannelKey, time.Time, int) (map[ChannelKey]AlertComparison, error) {
|
||||
r.metricCalls++
|
||||
@@ -52,7 +59,7 @@ func TestAlertTriggerSeparatesOwnerPlatformAndConflictingBindings(t *testing.T)
|
||||
r, now := triggerFixture()
|
||||
days := 3
|
||||
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "未登录", Metric: 3, UnloginDays: &days}, {ID: 2, CreatorID: 20, Type: "越级", Metric: 3, UnloginDays: &days}}
|
||||
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
result, err := NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 1 || result.Skipped != 1 || len(r.hits) != 1 {
|
||||
t.Fatalf("检测结果 %+v %v", result, err)
|
||||
}
|
||||
@@ -62,8 +69,8 @@ func TestAlertTriggerSeparatesOwnerPlatformAndConflictingBindings(t *testing.T)
|
||||
}
|
||||
r.hits = nil
|
||||
r.current = &AlertRecord{Status: 2}
|
||||
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 0 || len(r.hits) != 0 {
|
||||
result, err = NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 0 || len(r.hits) != 0 || r.snapshotCalls != 1 {
|
||||
t.Fatalf("在途记录不应刷新: %+v %v", result, err)
|
||||
}
|
||||
}
|
||||
@@ -75,7 +82,7 @@ func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) {
|
||||
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "充值", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold},
|
||||
{ID: 2, CreatorID: 10, Type: "另一个充值规则", Metric: 2, StatPeriodDays: &days, MomDirection: &direction, MomThreshold: &threshold}}
|
||||
r.metrics = map[ChannelKey]AlertComparison{{"lx", 10}: {FirstUnix: now.AddDate(0, 0, -4).Unix(), Current: "110000", Previous: "100000"}}
|
||||
result, err := NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
result, err := NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 2 || r.metricCalls != 1 {
|
||||
t.Fatalf("同窗口应共享指标: %+v calls=%d %v", result, r.metricCalls, err)
|
||||
}
|
||||
@@ -84,12 +91,12 @@ func TestAlertTriggerMoMBoundariesAndSnapshots(t *testing.T) {
|
||||
}
|
||||
r.metrics[ChannelKey{"lx", 10}] = AlertComparison{FirstUnix: now.AddDate(0, 0, -3).Unix(), Current: "110000", Previous: "100000"}
|
||||
r.hits = nil
|
||||
result, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
result, err = NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err != nil || result.Written != 0 {
|
||||
t.Fatalf("不足双周期不应触发: %+v %v", result, err)
|
||||
}
|
||||
r.failure = errors.New("指标不可用")
|
||||
_, err = NewAlertTriggerUsecase(r).Detect(context.Background(), now)
|
||||
_, err = NewAlertTriggerUsecase(r, nil).Detect(context.Background(), now)
|
||||
if err == nil || !strings.Contains(err.Error(), "2 项失败") {
|
||||
t.Fatalf("读取失败不能报告成功: %v", err)
|
||||
}
|
||||
@@ -110,3 +117,30 @@ func TestAlertTriggerRoundedThresholdAndNoBaseline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *triggerRepoFake) Snapshots(_ context.Context, keys []ChannelKey, _ time.Time, _ bool) (map[ChannelKey]AlertSnapshot, error) {
|
||||
r.snapshotCalls++
|
||||
out := map[ChannelKey]AlertSnapshot{}
|
||||
for _, key := range keys {
|
||||
out[key] = AlertSnapshot{Pay: "0"}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func TestAlertExemptionIsLimitedToItsBusinessOwner(t *testing.T) {
|
||||
r, now := triggerFixture()
|
||||
days := 3
|
||||
r.input.Rules = []*AlertRule{{ID: 1, CreatorID: 10, Type: "未登录", Metric: 3, UnloginDays: &days}}
|
||||
key := ChannelKey{SourceDB: "lx", ID: 10}
|
||||
r.input.Exemptions = map[AlertExemptionKey]bool{{ChannelKey: key, OwnerID: 11}: true}
|
||||
uc := NewAlertTriggerUsecase(r, nil)
|
||||
first, err := uc.Detect(context.Background(), now)
|
||||
if err != nil || first.Written != 1 {
|
||||
t.Fatalf("其他商务的豁免影响了当前商务: %+v %v", first, err)
|
||||
}
|
||||
r.input.Exemptions[AlertExemptionKey{ChannelKey: key, OwnerID: 10}] = true
|
||||
second, err := uc.Detect(context.Background(), now)
|
||||
if err != nil || second.Written != 0 || r.snapshotCalls != 1 {
|
||||
t.Fatalf("当前商务的豁免未在统计前生效: %+v %v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
package business
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAlertAbsoluteThresholdBoundaries(t *testing.T) {
|
||||
days := 30
|
||||
threshold := "2000"
|
||||
rule := &AlertRule{Type: "充值", Metric: 2, ConditionMode: 2, AmountDirection: 1, AmountThreshold: &threshold, StatPeriodDays: &days}
|
||||
if err := validateAlertRule(rule); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
direction int
|
||||
amount string
|
||||
want bool
|
||||
}{{1, "1999.99", false}, {1, "2000", true}, {1, "3000", true}, {2, "1999.99", true}, {2, "2000", false}} {
|
||||
rule.AmountDirection = tc.direction
|
||||
_, hit, err := triggerContent(rule, &AlertTriggerChannel{}, AlertComparison{Current: tc.amount}, time.Now())
|
||||
if err != nil || hit != tc.want {
|
||||
t.Fatalf("%+v hit=%v err=%v", tc, hit, err)
|
||||
}
|
||||
}
|
||||
bad := "0.001"
|
||||
rule.AmountThreshold = &bad
|
||||
if validateAlertRule(rule) == nil {
|
||||
t.Fatal("不足一分的金额不能入库后被四舍五入为零")
|
||||
}
|
||||
rule.Metric = 1
|
||||
if validateAlertRule(rule) == nil {
|
||||
t.Fatal("注册量不能使用充值绝对值条件")
|
||||
}
|
||||
}
|
||||
|
||||
type governanceFake struct {
|
||||
AlertGovernanceRepo
|
||||
gap *AlertGap
|
||||
adds int
|
||||
target *AlertExemptionTarget
|
||||
}
|
||||
|
||||
func (r *governanceFake) SaveGap(_ context.Context, g *AlertGap) error { r.gap = g; return nil }
|
||||
func (r *governanceFake) AddExemption(_ context.Context, _ UserScope, _ ChannelKey, build func(*AlertExemptionTarget) (*AlertExemption, error)) error {
|
||||
if _, err := build(r.target); err != nil {
|
||||
return err
|
||||
}
|
||||
r.adds++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAlertGovernanceChecksLevelAndKeepsTenantGap(t *testing.T) {
|
||||
records, _ := alertRecordFixture()
|
||||
r := &governanceFake{target: &AlertExemptionTarget{Name: "渠道", Account: &Account{ID: 9, Level: 3, IDPath: "0,7,8"}}}
|
||||
uc := NewAlertGovernanceUsecase(r, records.accounts)
|
||||
if err := uc.SaveGap(accountContext(9, 903), true, 7); err != ErrAlertGovernanceLevel {
|
||||
t.Fatalf("越级间隔配置: %v", err)
|
||||
}
|
||||
if err := uc.SaveGap(accountContext(7, 901), true, 1000); err != nil || r.gap.OwnerID != 7 || r.gap.Days != 1000 {
|
||||
t.Fatal("间隔应归属当前一级商务且不限制为365天")
|
||||
}
|
||||
if err := uc.Add(accountContext(8, 902), ChannelKey{"lx", 1}, "备注"); err != ErrAlertGovernanceLevel {
|
||||
t.Fatal("下级不能加入免预警")
|
||||
}
|
||||
if err := uc.Add(accountContext(7, 901), ChannelKey{"lx", 1}, " "); err != ErrAlertRecordInput {
|
||||
t.Fatal("加入原因不能为空")
|
||||
}
|
||||
if err := uc.Add(accountContext(7, 901), ChannelKey{"lx", 1}, "长期合作"); err != nil || r.adds != 1 {
|
||||
t.Fatalf("加入失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertRuleRejectsUnboundedDecimalAndIgnoresInactiveInputs(t *testing.T) {
|
||||
days := 7
|
||||
for _, value := range []string{"1e10000000", "0.001", "NaN", "999999999999999999999999"} {
|
||||
rule := &AlertRule{Type: "充值", Metric: 2, ConditionMode: 2, StatPeriodDays: &days, AmountDirection: 1, AmountThreshold: &value}
|
||||
if validateAlertRule(rule) == nil {
|
||||
t.Fatalf("非法金额通过校验: %s", value)
|
||||
}
|
||||
}
|
||||
huge := "1e10000000"
|
||||
direction := 999999
|
||||
rule := &AlertRule{Type: "登录", Metric: 3, UnloginDays: &days, MomDirection: &direction, MomThreshold: &huge, AmountThreshold: &huge}
|
||||
if err := validateAlertRule(rule); err != nil || rule.MomThreshold != nil || rule.MomDirection != nil || rule.AmountThreshold != nil {
|
||||
t.Fatalf("未使用字段不应绕过校验写入数据库: %+v %v", rule, err)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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("验证失败前不能移除旧约束")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -40,6 +40,7 @@ var ProviderSet = wire.NewSet(
|
||||
databusiness.NewPlatformAccountRepo,
|
||||
databusiness.NewPasswordResetRepo,
|
||||
databusiness.NewBusinessPromoteRepo,
|
||||
databusiness.NewAlertGovernanceRepo,
|
||||
databusiness.NewAlertRuleRepo,
|
||||
databusiness.NewAlertRecordRepo,
|
||||
databusiness.NewActivationRepo,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: "查询规则详情"},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -10,6 +10,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewBusiness,
|
||||
NewBusinessPassword,
|
||||
NewBusinessPromote,
|
||||
NewAlertGovernance,
|
||||
NewAlertRule,
|
||||
NewAlertRecord,
|
||||
NewActivation,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
type Set struct {
|
||||
AlertGovernance *AlertGovernance
|
||||
BusinessPerformance *BusinessPerformance
|
||||
OtherSettings *OtherSettings
|
||||
Ranking *Ranking
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, ",") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -19,6 +19,7 @@ var ProviderSet = wire.NewSet(
|
||||
businessservice.NewBindingService,
|
||||
businessservice.NewPasswordResetService,
|
||||
businessservice.NewBusinessPromoteService,
|
||||
businessservice.NewAlertGovernanceService,
|
||||
businessservice.NewAlertRuleService,
|
||||
businessservice.NewAlertRecordService,
|
||||
businessservice.NewActivationService,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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 } })
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<el-form-item label="规则名称"><el-input v-model="query.ruleName" placeholder="规则名称" clearable /></el-form-item>
|
||||
<el-form-item label="预警对象"><el-input v-model="query.targetPromoteName" placeholder="渠道名称" clearable /></el-form-item>
|
||||
<el-form-item label="规则类型"><el-select v-model="query.ruleType" clearable placeholder="全部类型" style="width: 160px"><el-option v-for="type in types" :key="type" :label="type" :value="type" /></el-select></el-form-item>
|
||||
<el-form-item label="预警内容"><el-input v-model="query.alertContent" clearable placeholder="输入预警内容" /></el-form-item>
|
||||
<el-form-item label="流失状态"><el-select v-model="query.lost" clearable placeholder="全部" style="width: 130px"><el-option label="未流失" :value="false" /><el-option label="已流失" :value="true" /></el-select></el-form-item>
|
||||
<el-form-item label="处理状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width: 150px"><el-option v-for="(label, value) in statuses" :key="value" :label="label" :value="Number(value)" /></el-select></el-form-item>
|
||||
<el-form-item label="归属商务"><el-input v-model="query.businessName" clearable placeholder="归属商务" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button></el-form-item>
|
||||
@@ -19,16 +21,21 @@
|
||||
<div class="kra-table-box">
|
||||
<el-alert v-if="!available" class="mb-4" type="warning" :closable="false" title="渠道数据尚未接入,暂时无法核对预警可见范围。" />
|
||||
<el-alert v-else-if="!isAdmin" class="mb-4" type="info" :closable="false" title="仅展示当前账号自身及全部下级商务渠道产生的预警;上级、平级其他商务渠道预警不可见。" />
|
||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id">
|
||||
<div v-if="canGovern" class="kra-btn-list"><el-button type="warning" :disabled="!selected.length || acting" @click="ignore(selected.map(row => row.id))">批量忽略({{ selected.length }})</el-button></div>
|
||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id" :row-class-name="({ row }) => row.status === 6 || row.lost ? 'alert-muted' : ''" @selection-change="selected = $event">
|
||||
<el-table-column v-if="canGovern" type="selection" width="46" :selectable="row => row.status !== 6" />
|
||||
<el-table-column prop="platformName" label="所属平台" width="104"><template #default="{ row }"><platform-tag :code="row.sourceDb || row.platformCode" :label="row.platformName" /></template></el-table-column>
|
||||
<el-table-column label="触发时间" min-width="120"><template #default="{ row }">{{ shortTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column prop="ruleName" label="规则名称" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="ruleType" label="规则类型" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="规则类型" min-width="130"><template #default="{ row }"><el-tag effect="plain" :style="row.warningColor ? { color: row.warningColor, borderColor: row.warningColor } : {}">{{ row.ruleTypeLabel || row.ruleType }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="targetPromoteName" label="预警对象" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="businessName" label="归属商务" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="流失状态" width="105"><template #default="{ row }">{{ row.lost == null ? '未接入' : row.lost ? '已流失' : '未流失' }}</template></el-table-column>
|
||||
<el-table-column label="总注册玩家" width="120"><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPlayers ?? '—' }}</span></template></el-table-column>
|
||||
<el-table-column label="总充值流水" width="140"><template #default="{ row }"><span title="预警触发时的累计快照">{{ row.totalPay == null ? '—' : `¥${row.totalPay}` }}</span></template></el-table-column>
|
||||
<el-table-column prop="alertContent" label="预警内容" min-width="280" show-overflow-tooltip />
|
||||
<el-table-column label="处理状态" width="110"><template #default="{ row }"><el-tag :type="statusTone(row.status)" effect="plain">{{ row.statusLabel || statuses[row.status] }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="115" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" link type="primary" @click="open(row)">{{ actionLabel(row.status) }}</el-button></template></el-table-column>
|
||||
<el-table-column label="操作" :width="canGovern ? 230 : 115" fixed="right"><template #default="{ row }"><el-button v-if="canQuery" link type="primary" @click="open(row)">{{ actionLabel(row.status) }}</el-button><el-button v-if="canGovern && row.status !== 6" link type="warning" :disabled="acting" @click="ignore([row.id])">忽略</el-button><el-button v-if="canGovern" link type="primary" :disabled="acting" @click="exempt(row)">免预警</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
@@ -39,10 +46,10 @@
|
||||
<script setup>
|
||||
import PlatformTag from './components/PlatformTag.vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listAlertRecords, getAlertRuleTypes } from '@/api/alert'
|
||||
import { listAlertRecords, getAlertRuleTypes, ignoreAlerts, addAlertExemption, getAlertPlatforms } from '@/api/alert'
|
||||
import AlertHandleDrawer from './components/AlertHandleDrawer.vue'
|
||||
import { alertStatuses as statuses, alertStatusTone as statusTone, alertShortTime as shortTime } from './alert-display'
|
||||
|
||||
@@ -53,10 +60,14 @@ const isAdmin = computed(() => Number(user.userInfo.authorityId) === 888)
|
||||
const canProcess = computed(() => isAdmin.value || !!buttons['sw:alertRecord:process'])
|
||||
const canQuery = computed(() => isAdmin.value || !!buttons['sw:alertRecord:query'])
|
||||
const canAudit = computed(() => Number(user.userInfo.level) === 1 && (isAdmin.value || !!buttons['sw:alertRecord:audit']))
|
||||
const platformOptions = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const platforms = ref(Object.keys(platformOptions))
|
||||
const canGovern = computed(() => isAdmin.value || Number(user.userInfo.level) === 1)
|
||||
const selected = ref([])
|
||||
const acting = ref(false)
|
||||
const platformNames = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
const platformOptions = ref({})
|
||||
const platforms = ref([])
|
||||
const types = ref([])
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, ruleName: '', targetPromoteName: '', ruleType: '', status: undefined, businessName: '' })
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, ruleName: '', targetPromoteName: '', ruleType: '', status: undefined, businessName: '', alertContent: '', lost: undefined })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const counts = ref({})
|
||||
@@ -65,7 +76,7 @@ const loading = ref(false)
|
||||
const drawer = ref(false)
|
||||
const currentID = ref('')
|
||||
let requestVersion = 0
|
||||
const clear = () => { rows.value = []; total.value = 0; counts.value = {} }
|
||||
const clear = () => { rows.value = []; total.value = 0; counts.value = {}; selected.value = [] }
|
||||
const load = async () => {
|
||||
const version = ++requestVersion
|
||||
if (!platforms.value.length) { clear(); available.value = true; loading.value = false; return }
|
||||
@@ -77,6 +88,7 @@ const load = async () => {
|
||||
rows.value = res.data.pageData.rows
|
||||
total.value = res.data.pageData.total
|
||||
counts.value = res.data.statusCount
|
||||
selected.value = []
|
||||
available.value = res.data.available !== false
|
||||
} catch { if (version === requestVersion) clear() } finally { if (version === requestVersion) loading.value = false }
|
||||
}
|
||||
@@ -92,15 +104,38 @@ const actionLabel = (status) => {
|
||||
return { 1: '处理', 2: '继续处理', 4: '重新处理' }[status] || '查看'
|
||||
}
|
||||
const open = (row) => { currentID.value = row.id; drawer.value = true }
|
||||
const ignore = async (ids) => {
|
||||
if (acting.value || !ids.length) return
|
||||
acting.value = true
|
||||
try {
|
||||
if (!await ElMessageBox.confirm(`确认忽略这 ${ids.length} 条预警?历史记录会保留,再次命中规则可生成新预警。`, '忽略预警', { type: 'warning' }).then(() => true).catch(() => false)) return
|
||||
const res = await ignoreAlerts(ids)
|
||||
if (res.code === 0) { ElMessage.success('已忽略'); await load() }
|
||||
} finally { acting.value = false }
|
||||
}
|
||||
const exempt = async (row) => {
|
||||
if (acting.value) return
|
||||
acting.value = true
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(`将「${row.targetPromoteName}」加入免预警名单后,该渠道不再产生新预警,历史预警保留。请填写加入原因。`, '加入免预警渠道', { inputValidator: value => !!value?.trim() && value.length <= 500 || '请填写 1 至 500 字的原因', confirmButtonText: '确认加入' }).catch(() => null)
|
||||
if (!result) return
|
||||
const res = await addAlertExemption({ sourceDb: row.platformCode, promoteId: String(row.targetPromoteId), reason: result.value.trim() })
|
||||
if (res.code === 0) ElMessage.success('已加入免预警渠道')
|
||||
} finally { acting.value = false }
|
||||
}
|
||||
onMounted(async () => {
|
||||
void load()
|
||||
const res = await getAlertRuleTypes()
|
||||
if (res.code === 0) types.value = res.data
|
||||
const results = await Promise.allSettled([getAlertPlatforms(), getAlertRuleTypes()])
|
||||
const bound = results[0].status === 'fulfilled' ? results[0].value : null
|
||||
if (bound?.code === 0) { platforms.value = bound.data; platformOptions.value = Object.fromEntries(bound.data.map(code => [code, platformNames[code] || code])) }
|
||||
const labels = results[1].status === 'fulfilled' ? results[1].value : null
|
||||
if (labels?.code === 0) types.value = labels.data
|
||||
await load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.alert-summary-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
|
||||
:deep(.alert-muted) { color: var(--el-text-color-placeholder); background: var(--el-fill-color-light); }
|
||||
.alert-summary-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 16px; margin-bottom: 16px; }
|
||||
.alert-summary-card { display: flex; flex-direction: column; gap: 12px; text-align: left; padding: 20px; background: var(--el-bg-color); color: var(--el-text-color-regular); border: 1px solid var(--el-border-color-light); border-radius: 8px; cursor: pointer; }
|
||||
.alert-summary-card strong { font-size: 26px; color: var(--el-text-color-primary); }
|
||||
.alert-summary-card.active, .alert-summary-card:focus-visible { border-color: var(--el-color-primary); outline: 1px solid var(--el-color-primary); }
|
||||
|
||||
@@ -4,39 +4,47 @@
|
||||
<el-form :inline="true" :model="query" @submit.prevent="search">
|
||||
<el-form-item label="监控指标"><el-select v-model="query.monitorMetric" clearable placeholder="全部监控指标" style="width: 160px"><el-option v-for="(label, value) in metrics" :key="value" :value="Number(value)" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item label="规则名称"><el-input v-model="query.ruleName" clearable placeholder="输入规则名称" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button><el-button icon="refresh" @click="reset">重置</el-button></el-form-item>
|
||||
<el-form-item><el-button type="primary" icon="search" native-type="submit">搜索</el-button><el-button icon="refresh" @click="reset">重置</el-button><el-button @click="openGap">预警间隔:{{ gapLoaded ? gap.enabled ? `${gap.days} 天` : '未启用' : '未加载' }}</el-button><el-button v-if="canCreate" type="primary" icon="plus" @click="open()">新建规则</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<div class="kra-btn-list"><el-button v-if="canCreate" type="primary" icon="plus" @click="open()">新建规则</el-button></div>
|
||||
<el-alert class="mb-4" type="info" :closable="false" title="同一平台、同一渠道、同类预警未关闭时不重复新建。未处理预警再次命中会刷新;处理中、待审核和审核驳回的记录保持不变。" />
|
||||
<el-alert v-if="!manage" class="mb-4" type="info" :closable="false" title="只读模式:仅一级商务可配置预警规则。" />
|
||||
<el-alert class="mb-4" type="info" :closable="false" :title="`未处理预警再次命中会刷新;处理中、待审核和审核驳回保持不变。绝对值规则分别生成预警。${gap.enabled ? `同一渠道同类预警间隔 ${gap.days} 天。` : '预警间隔未启用。'}`" />
|
||||
<el-table scrollbar-always-on v-loading="loading" :data="rows" row-key="id">
|
||||
<el-table-column label="启用状态" width="110"><template #default="{ row }"><el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" :disabled="!canEdit || busy === row.id" @change="toggle(row, $event)" /></template></el-table-column>
|
||||
<el-table-column label="启用状态" width="110"><template #default="{ row }"><el-switch :model-value="row.enabled" :active-value="1" :inactive-value="0" :disabled="!canEdit || busy.has(row.id)" @change="toggle(row, $event)" /></template></el-table-column>
|
||||
<el-table-column prop="ruleName" label="规则名称" min-width="150" />
|
||||
<el-table-column label="规则类型" min-width="130"><template #default="{ row }"><el-tag effect="plain" :style="{ color: row.warningColor, borderColor: row.warningColor }">{{ row.ruleTypeLabel }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="ruleDescription" label="规则描述" min-width="320" show-overflow-tooltip />
|
||||
<el-table-column prop="creatorName" label="创建人" min-width="120" />
|
||||
<el-table-column label="作用范围" min-width="150"><template #default>仅对一级渠道生效</template></el-table-column>
|
||||
<el-table-column label="作用范围" min-width="150"><template #default>一级渠道(汇总子渠道)</template></el-table-column>
|
||||
<el-table-column v-if="canEdit || canDelete" label="操作" width="130" fixed="right"><template #default="{ row }"><el-button v-if="canEdit" type="primary" link @click="open(row)">编辑</el-button><el-button v-if="canDelete" type="danger" link :loading="removing === row.id" :disabled="!!removing" @click="remove(row)">删除</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<el-drawer class="sw-dialog" v-model="drawer" append-to-body :title="form.id ? '编辑规则' : '新建规则'" size="480px" destroy-on-close>
|
||||
<el-drawer class="sw-dialog" v-model="drawer" append-to-body :title="form.id ? '编辑规则' : '新建规则'" size="min(520px, 100vw)" :close-on-click-modal="!saving" :close-on-press-escape="!saving" :show-close="!saving" destroy-on-close>
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item label="规则名称" prop="ruleName"><el-input v-model="form.ruleName" maxlength="50" placeholder="请输入规则名称" /></el-form-item>
|
||||
<el-form-item label="规则类型" prop="ruleType"><el-input v-model="form.ruleType" maxlength="20" placeholder="如:注册异常 / 充值异常 / 登录异常" /></el-form-item>
|
||||
<el-form-item label="规则名称" prop="ruleName"><el-input v-model="form.ruleName" maxlength="100" placeholder="请输入规则名称" /></el-form-item>
|
||||
<el-form-item label="规则类型" prop="ruleType"><el-input v-model="form.ruleType" maxlength="50" placeholder="如:注册异常 / 充值异常 / 登录异常" /></el-form-item>
|
||||
<el-form-item label="监控指标" prop="monitorMetric"><el-select v-model="form.monitorMetric"><el-option v-for="(label, value) in metrics" :key="value" :value="Number(value)" :label="label" /></el-select></el-form-item>
|
||||
<el-form-item v-if="form.monitorMetric === 2" label="判定方式"><el-select v-model="form.conditionMode"><el-option label="环比波动(百分比)" :value="1" /><el-option label="绝对值阈值(满 / 未满 X 元)" :value="2" /></el-select></el-form-item>
|
||||
<template v-if="form.monitorMetric !== 3">
|
||||
<el-form-item label="统计周期(天)" prop="statPeriodDays"><el-input-number v-model="form.statPeriodDays" :min="1" :max="365" :precision="0" /></el-form-item>
|
||||
<el-form-item label="环比方向" prop="momDirection"><el-select v-model="form.momDirection"><el-option label="环比上升 (异常暴涨)" :value="1" /><el-option label="环比下降 (异常恶化)" :value="2" /></el-select></el-form-item>
|
||||
<el-form-item label="环比阈值 (%)" prop="momThreshold"><el-input-number v-model="form.momThreshold" :min="1" :max="9999" :precision="2" /></el-form-item>
|
||||
<el-form-item label="统计周期(天)" prop="statPeriodDays"><el-input-number v-model="form.statPeriodDays" :min="1" :precision="0" /></el-form-item>
|
||||
<el-form-item v-if="!absolute" label="环比方向" prop="momDirection"><el-select v-model="form.momDirection"><el-option label="环比上升 (异常暴涨)" :value="1" /><el-option label="环比下降 (异常恶化)" :value="2" /></el-select></el-form-item>
|
||||
<el-form-item v-if="!absolute" label="环比阈值 (%)" prop="momThreshold"><el-input-number v-model="form.momThreshold" :min="0.01" :max="9999" :precision="2" /></el-form-item>
|
||||
<el-form-item v-if="absolute" label="金额条件"><el-select v-model="form.amountDirection"><el-option label="满(大于等于)" :value="1" /><el-option label="未满(小于)" :value="2" /></el-select></el-form-item>
|
||||
<el-form-item v-if="absolute" label="金额(元)" prop="amountThreshold"><el-input-number v-model="form.amountThreshold" :min="0.01" :max="999999999999.99" :precision="2" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else label="未登录天数" prop="unloginDays"><el-input-number v-model="form.unloginDays" :min="1" :max="365" :precision="0" /></el-form-item>
|
||||
<el-form-item v-else label="未登录天数" prop="unloginDays"><el-input-number v-model="form.unloginDays" :min="1" :precision="0" /></el-form-item>
|
||||
<el-form-item label="预警颜色" prop="warningColor"><el-color-picker v-model="form.warningColor" :predefine="colors" color-format="hex" /></el-form-item>
|
||||
<el-text type="info">作用范围固定为一级渠道。</el-text>
|
||||
<el-text type="info">仅向一级渠道预警,监测数据汇总其二、三级子渠道。</el-text>
|
||||
</el-form>
|
||||
<template #footer><el-button @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||
<template #footer><el-button v-if="form.id && canDelete" type="danger" :disabled="saving" @click="remove(form)">删除规则</el-button><el-button :disabled="saving" @click="drawer = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
|
||||
</el-drawer>
|
||||
<el-dialog v-model="gapDialog" title="预警间隔" width="min(460px, 95vw)" :close-on-click-modal="!gapSaving">
|
||||
<el-form label-position="top"><el-form-item label="启用预警间隔"><el-switch v-model="gapForm.enabled" :disabled="!canEdit || gapSaving" /></el-form-item><el-form-item label="间隔天数"><el-input-number :key="String(gapForm.enabled)" v-model="gapForm.days" :min="1" :precision="0" :disabled="!canEdit || !gapForm.enabled || gapSaving" /></el-form-item></el-form>
|
||||
<p>统一作用于当前一级商务的全部规则。从上次命中起计时,即使预警已闭环,间隔内也不再生成;到期后仍满足规则才会再次预警。</p>
|
||||
<template #footer><el-button :disabled="gapSaving" @click="gapDialog = false">取消</el-button><el-button v-if="canEdit" type="primary" :loading="gapSaving" @click="saveGap">保存</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -45,7 +53,7 @@ import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useBtnAuth } from '@/utils/btnAuth'
|
||||
import { listAlertRules, createAlertRule, updateAlertRule, deleteAlertRule, toggleAlertRule } from '@/api/alert'
|
||||
import { listAlertRules, createAlertRule, updateAlertRule, deleteAlertRule, toggleAlertRule, getAlertGap, saveAlertGap } from '@/api/alert'
|
||||
|
||||
defineOptions({ name: 'SwAlertRules' })
|
||||
const user = useUserStore()
|
||||
@@ -63,39 +71,60 @@ const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const drawer = ref(false)
|
||||
const busy = ref(0)
|
||||
const busy = reactive(new Set())
|
||||
const removing = ref(0)
|
||||
const formRef = ref()
|
||||
const form = ref({})
|
||||
const rules = Object.fromEntries(['ruleName', 'ruleType', 'monitorMetric', 'statPeriodDays', 'momDirection', 'momThreshold', 'unloginDays', 'warningColor'].map((name) => [name, [{ required: true, message: '请填写完整规则信息', trigger: 'change' }]]))
|
||||
const absolute = computed(() => form.value.monitorMetric === 2 && form.value.conditionMode === 2)
|
||||
const gap = ref({ enabled: false, days: 1 })
|
||||
const gapLoaded = ref(false)
|
||||
const loadGap = async () => {
|
||||
try { const res = await getAlertGap(); if (res.code !== 0) return false; gap.value = res.data; gapLoaded.value = true; return true } catch { return false }
|
||||
}
|
||||
const gapForm = ref({})
|
||||
const gapDialog = ref(false)
|
||||
const gapSaving = ref(false)
|
||||
const openGap = async () => { if (!await loadGap()) return; gapForm.value = { ...gap.value }; gapDialog.value = true }
|
||||
const saveGap = async () => {
|
||||
if (gapSaving.value || !Number.isInteger(gapForm.value.days) || gapForm.value.days < 1) return
|
||||
gapSaving.value = true
|
||||
try { const res = await saveAlertGap(gapForm.value); if (res.code === 0) { gap.value = { ...gapForm.value }; gapDialog.value = false; ElMessage.success('预警间隔已保存') } } finally { gapSaving.value = false }
|
||||
}
|
||||
const rules = Object.fromEntries(['ruleName', 'ruleType', 'monitorMetric', 'statPeriodDays', 'momDirection', 'momThreshold', 'unloginDays', 'warningColor', 'amountThreshold'].map((name) => [name, [{ required: true, message: '请填写完整规则信息', trigger: 'change' }]]))
|
||||
let requestVersion = 0
|
||||
const load = async () => {
|
||||
const version = ++requestVersion
|
||||
loading.value = true
|
||||
try { const res = await listAlertRules(query); if (res.code === 0) { rows.value = res.data.rows; total.value = res.data.total } } finally { loading.value = false }
|
||||
try { const res = await listAlertRules(query); if (version === requestVersion) { rows.value = res.code === 0 ? res.data.rows : []; total.value = res.code === 0 ? res.data.total : 0 } } catch { if (version === requestVersion) { rows.value = []; total.value = 0 } } finally { if (version === requestVersion) loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNum = 1; return load() }
|
||||
const reset = () => { query.monitorMetric = undefined; query.ruleName = ''; return search() }
|
||||
const open = (row) => {
|
||||
form.value = { ruleName: '', ruleType: '', monitorMetric: 1, statPeriodDays: 7, momDirection: 1, momThreshold: 20, unloginDays: 7, warningColor: colors[0], ...row }
|
||||
form.value = { conditionMode: 1, amountDirection: 2, amountThreshold: 3000, ruleName: '', ruleType: '', monitorMetric: 1, statPeriodDays: 7, momDirection: 1, momThreshold: 20, unloginDays: 7, warningColor: colors[0], ...row }
|
||||
if (form.value.momThreshold != null) form.value.momThreshold = Number(form.value.momThreshold)
|
||||
if (form.value.amountThreshold != null) form.value.amountThreshold = Number(form.value.amountThreshold)
|
||||
drawer.value = true
|
||||
}
|
||||
const save = async () => {
|
||||
if (!await formRef.value.validate().catch(() => false)) return
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try { const res = await (form.value.id ? updateAlertRule : createAlertRule)(form.value); if (res.code === 0) { ElMessage.success('保存成功'); drawer.value = false; await load() } } finally { saving.value = false }
|
||||
try {
|
||||
if (!await formRef.value.validate().catch(() => false)) return
|
||||
const res = await (form.value.id ? updateAlertRule : createAlertRule)({ ...form.value, ruleName: form.value.ruleName.trim(), ruleType: form.value.ruleType.trim(), conditionMode: absolute.value ? 2 : 1 }); if (res.code === 0) { ElMessage.success('保存成功'); drawer.value = false; await load() } } finally { saving.value = false }
|
||||
}
|
||||
const toggle = async (row, enabled) => {
|
||||
busy.value = row.id
|
||||
try { const res = await toggleAlertRule(row.id, enabled); if (res.code === 0) row.enabled = enabled } finally { busy.value = 0 }
|
||||
if (busy.has(row.id)) return
|
||||
busy.add(row.id)
|
||||
try { const res = await toggleAlertRule(row.id, enabled); if (res.code === 0) row.enabled = enabled } finally { busy.delete(row.id) }
|
||||
}
|
||||
const remove = async (row) => {
|
||||
if (removing.value) return
|
||||
if (!await ElMessageBox.confirm(`确认删除规则「${row.ruleName}」?`, '删除确认', { type: 'warning' }).then(() => true).catch(() => false)) return
|
||||
removing.value = row.id
|
||||
try {
|
||||
if (!await ElMessageBox.confirm(`确认删除规则「${row.ruleName}」?`, '删除确认', { type: 'warning' }).then(() => true).catch(() => false)) return
|
||||
const res = await deleteAlertRule(row.id)
|
||||
if (res.code === 0) { ElMessage.success('删除成功'); await load() }
|
||||
if (res.code === 0) { ElMessage.success('删除成功'); drawer.value = false; if (rows.value.length === 1 && query.pageNum > 1) query.pageNum--; await load() }
|
||||
} finally { removing.value = 0 }
|
||||
}
|
||||
onMounted(load)
|
||||
onMounted(() => { void load(); void loadGap() })
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div class="sw-page">
|
||||
<div class="kra-search-box">
|
||||
<el-form :inline="true" :model="query" @submit.prevent="search">
|
||||
<el-form-item label="渠道名称"><el-input v-model="query.channelName" clearable placeholder="输入渠道名称" /></el-form-item>
|
||||
<el-form-item label="状态"><el-select v-model="query.active" clearable placeholder="全部状态" style="width: 150px"><el-option label="在名单" :value="true" /><el-option label="已移出" :value="false" /></el-select></el-form-item>
|
||||
<el-form-item><el-button type="primary" native-type="submit">搜索</el-button><el-button @click="reset">重置</el-button><el-button v-if="canManage" type="primary" @click="openAdd">加入渠道</el-button></el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="kra-table-box">
|
||||
<el-alert class="mb-4" type="info" :closable="false" title="名单内的渠道不再产生新预警,历史预警保留。渠道进入激活池后自动移出,加入与移出记录永久保留。" />
|
||||
<el-table v-loading="loading" :data="rows" row-key="id" scrollbar-always-on :row-class-name="({ row }) => row.active ? '' : 'exempt-removed'">
|
||||
<el-table-column label="平台" width="95"><template #default="{ row }"><platform-tag :code="row.sourceDb" /></template></el-table-column>
|
||||
<el-table-column prop="channelName" label="渠道名称" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="addedName" label="加入人" min-width="110" />
|
||||
<el-table-column prop="addedAt" label="加入时间" width="170" />
|
||||
<el-table-column prop="reason" label="加入原因" min-width="210" show-overflow-tooltip />
|
||||
<el-table-column label="状态" width="100"><template #default="{ row }"><el-tag :type="row.active ? 'success' : 'info'">{{ row.active ? '在名单' : '已移出' }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="removedName" label="移出人" min-width="140" />
|
||||
<el-table-column prop="removedAt" label="移出时间" width="170" />
|
||||
<el-table-column prop="removeReason" label="移出原因" min-width="190"><template #default="{ row }"><span :title="row.removeReason.includes('自动') ? '30 天累计流水不足 ¥500,或连续未登录超过 15 天。系统自动判定,恢复预警。' : ''">{{ row.removeReason || '—' }}</span></template></el-table-column>
|
||||
<el-table-column label="操作" fixed="right" width="80"><template #default="{ row }"><el-button v-if="row.active && canManage" link type="danger" :disabled="busy" @click="remove(row)">移出</el-button><span v-else>—</span></template></el-table-column>
|
||||
</el-table>
|
||||
<div class="kra-pagination"><el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" :page-sizes="[10, 20, 50]" layout="total, sizes, prev, pager, next, jumper" @size-change="search" @current-change="load" /></div>
|
||||
</div>
|
||||
<el-dialog v-model="adding" title="加入免预警渠道" width="min(500px, 95vw)" :close-on-click-modal="!busy" :close-on-press-escape="!busy" :show-close="!busy">
|
||||
<el-form label-position="top">
|
||||
<el-form-item label="一级渠道" required><el-select v-model="selected" filterable remote :remote-method="findChannels" :loading="finding" placeholder="输入渠道名称搜索" style="width: 100%"><el-option v-for="channel in channels" :key="`${channel.sourceDb}/${channel.id}`" :value="`${channel.sourceDb}/${channel.id}`" :label="`${platforms[channel.sourceDb] || channel.sourceDb} · ${channel.account}`" /></el-select></el-form-item>
|
||||
<el-form-item label="加入原因" required><el-input v-model="reason" type="textarea" :rows="4" maxlength="500" show-word-limit /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer><el-button :disabled="busy" @click="adding = false">取消</el-button><el-button type="primary" :loading="busy" @click="add">加入名单</el-button></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { listAlertExemptions, addAlertExemption, removeAlertExemption, alertExemptionCandidates } from '@/api/alert'
|
||||
import PlatformTag from './components/PlatformTag.vue'
|
||||
|
||||
defineOptions({ name: 'SwExemptChannels' })
|
||||
const user = useUserStore()
|
||||
const canManage = computed(() => Number(user.userInfo.authorityId) === 888 || Number(user.userInfo.level) === 1)
|
||||
const query = reactive({ pageNum: 1, pageSize: 10, channelName: '', active: undefined })
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const busy = ref(false)
|
||||
const adding = ref(false)
|
||||
const selected = ref('')
|
||||
const reason = ref('')
|
||||
const channels = ref([])
|
||||
const finding = ref(false)
|
||||
const platforms = { lx: '龙翔', hw: '汇玩', yq: '游趣' }
|
||||
let version = 0
|
||||
let channelVersion = 0
|
||||
const load = async () => {
|
||||
const current = ++version
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listAlertExemptions({ ...query, channelName: query.channelName.trim() })
|
||||
if (current === version) { rows.value = res.code === 0 ? res.data.rows : []; total.value = res.code === 0 ? res.data.total : 0 }
|
||||
} catch { if (current === version) { rows.value = []; total.value = 0 } } finally { if (current === version) loading.value = false }
|
||||
}
|
||||
const search = () => { query.pageNum = 1; return load() }
|
||||
const reset = () => { query.channelName = ''; query.active = undefined; return search() }
|
||||
const findChannels = async (keyword) => {
|
||||
const current = ++channelVersion
|
||||
finding.value = true
|
||||
try { const res = await alertExemptionCandidates(keyword.trim()); if (current === channelVersion) channels.value = res.code === 0 ? res.data : [] } catch { if (current === channelVersion) channels.value = [] } finally { if (current === channelVersion) finding.value = false }
|
||||
}
|
||||
const openAdd = () => { selected.value = ''; reason.value = ''; adding.value = true; void findChannels('') }
|
||||
const add = async () => {
|
||||
if (busy.value) return
|
||||
if (!selected.value || !reason.value.trim()) { ElMessage.warning('请选择渠道并填写加入原因'); return }
|
||||
busy.value = true
|
||||
try {
|
||||
if (!await ElMessageBox.confirm('确认加入免预警名单?该渠道将停止产生新预警,历史预警保留。', '加入确认', { type: 'warning' }).then(() => true).catch(() => false)) return
|
||||
const [sourceDb, promoteId] = selected.value.split('/')
|
||||
const res = await addAlertExemption({ sourceDb, promoteId, reason: reason.value.trim() })
|
||||
if (res.code === 0) { adding.value = false; ElMessage.success('已加入免预警名单'); await search() }
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
const remove = async (row) => {
|
||||
if (busy.value) return
|
||||
busy.value = true
|
||||
try {
|
||||
if (!await ElMessageBox.confirm(`确认将「${row.channelName}」移出免预警名单?移出后恢复预警。`, '移出确认', { type: 'warning' }).then(() => true).catch(() => false)) return
|
||||
const res = await removeAlertExemption(row.id)
|
||||
if (res.code === 0) { ElMessage.success('已移出'); if (query.active === true && rows.value.length === 1 && query.pageNum > 1) query.pageNum--; await load() }
|
||||
} finally { busy.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.exempt-removed) { color: var(--el-text-color-placeholder); background: var(--el-fill-color-light); }
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理' }
|
||||
export const alertStatuses = { 1: '未处理', 2: '处理中', 3: '待审核', 4: '审核驳回', 5: '已处理', 6: '已忽略' }
|
||||
export const alertStatusTone = (status) => ({ 1: 'danger', 2: 'warning', 3: 'info', 4: 'danger', 5: 'success' })[status] || 'info'
|
||||
export const alertShortTime = (value) => value ? value.replace('T', ' ').slice(5, 16) : '—'
|
||||
export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理' }
|
||||
export const alertActions = { start: '开始处理', submit: '提交审核', pass: '审核通过', reject: '审核驳回', reprocess: '重新处理', ignore: '忽略' }
|
||||
|
||||
@@ -71,7 +71,7 @@ const current = ref(null)
|
||||
const logs = ref([])
|
||||
const detailCanAudit = ref(false)
|
||||
const auditAllowed = computed(() => props.canAudit && detailCanAudit.value)
|
||||
const canNote = computed(() => props.canProcess && current.value && ([1, 2, 4].includes(current.value.status) || (current.value.status === 3 && auditAllowed.value)))
|
||||
const canNote = computed(() => props.canProcess && current.value && [1, 2, 3, 4].includes(current.value.status))
|
||||
const loading = ref(false)
|
||||
const savingNote = ref(false)
|
||||
const processing = ref(false)
|
||||
|
||||
Reference in New Issue
Block a user