feat: implement V2 alert center and scoped alert governance

This commit is contained in:
Yvan
2026-11-05 20:08:29 +08:00
parent f8e26c5baf
commit 999c9a6315
57 changed files with 2711 additions and 216 deletions
@@ -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)
}
}
+3 -1
View File
@@ -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)
}
}
+6 -6
View File
@@ -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))
}
}