feat: 重建 Bell 规则与预警链路 (#132)
This commit is contained in:
@@ -0,0 +1,257 @@
|
||||
package version_local
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go-admin/app/bell/alert"
|
||||
"go-admin/app/bell/evaluation"
|
||||
"go-admin/app/bell/rule"
|
||||
"go-admin/cmd/migrate/migration"
|
||||
common "go-admin/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellRuleAlert)
|
||||
}
|
||||
|
||||
func migrateBellRuleAlert(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(new(rule.Rule), new(evaluation.Evaluation), new(alert.Alert), new(alert.AlertEvent), new(alert.RuleMatch), new(runtimeCasbinRule)); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, statement := range bellRuleAlertSchemaSQL {
|
||||
if err := tx.Exec(statement).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := seedBellRuleAlertAccess(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
|
||||
var bellRuleAlertSchemaSQL = []string{
|
||||
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_severity_check CHECK (minimum_severity IN ('low','medium','high','critical'))`,
|
||||
`ALTER TABLE bell_rules ADD CONSTRAINT bell_rules_version_check CHECK (version > 0)`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_status_check CHECK (status IN ('open','acknowledged','closed'))`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_severity_check CHECK (severity IN ('low','medium','high','critical'))`,
|
||||
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_evaluations ADD CONSTRAINT bell_rule_evaluations_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alerts_rule_fk FOREIGN KEY (primary_rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_alert_events ADD CONSTRAINT bell_alert_events_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_event_fk FOREIGN KEY (event_id) REFERENCES bell_events(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`ALTER TABLE bell_rule_matches ADD CONSTRAINT bell_rule_matches_rule_fk FOREIGN KEY (rule_id) REFERENCES bell_rules(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
|
||||
`CREATE UNIQUE INDEX bell_alert_open_correlation_idx ON bell_alerts(primary_rule_id, correlation_key) WHERE status = 'open'`,
|
||||
`CREATE INDEX bell_alert_events_event_idx ON bell_alert_events(event_id, alert_id)`,
|
||||
`CREATE TRIGGER bell_rule_evaluations_immutable BEFORE UPDATE OR DELETE ON bell_rule_evaluations FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_alert_events_immutable BEFORE UPDATE OR DELETE ON bell_alert_events FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE TRIGGER bell_rule_matches_immutable BEFORE UPDATE OR DELETE ON bell_rule_matches FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
|
||||
`CREATE OR REPLACE FUNCTION bell_evaluate_new_event() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE
|
||||
current_rule bell_rules%ROWTYPE;
|
||||
is_match boolean;
|
||||
reasons text[];
|
||||
explanation_text text;
|
||||
snapshot jsonb;
|
||||
target_alert_id uuid;
|
||||
correlation text;
|
||||
BEGIN
|
||||
FOR current_rule IN SELECT * FROM bell_rules WHERE enabled = true ORDER BY id LOOP
|
||||
reasons := ARRAY[]::text[];
|
||||
IF current_rule.event_type IS NOT NULL AND current_rule.event_type <> NEW.event_type THEN
|
||||
reasons := array_append(reasons, '事件类型不匹配');
|
||||
END IF;
|
||||
IF array_position(ARRAY['low','medium','high','critical'], NEW.severity) <
|
||||
array_position(ARRAY['low','medium','high','critical'], current_rule.minimum_severity) THEN
|
||||
reasons := array_append(reasons, '风险等级低于阈值');
|
||||
END IF;
|
||||
IF current_rule.location_contains IS NOT NULL AND
|
||||
position(lower(current_rule.location_contains) in lower(NEW.location)) = 0 THEN
|
||||
reasons := array_append(reasons, '地点条件不匹配');
|
||||
END IF;
|
||||
is_match := cardinality(reasons) = 0;
|
||||
explanation_text := CASE WHEN is_match THEN '全部条件命中' ELSE array_to_string(reasons, ';') END;
|
||||
snapshot := jsonb_build_object(
|
||||
'id', current_rule.id, 'code', current_rule.code, 'name', current_rule.name,
|
||||
'enabled', current_rule.enabled, 'eventType', current_rule.event_type,
|
||||
'minimumSeverity', current_rule.minimum_severity,
|
||||
'locationContains', current_rule.location_contains, 'version', current_rule.version
|
||||
);
|
||||
INSERT INTO bell_rule_evaluations(event_id, rule_id, rule_version, rule_snapshot, matched, explanation, evaluated_at)
|
||||
VALUES(NEW.id, current_rule.id, current_rule.version, snapshot, is_match, explanation_text, now());
|
||||
IF NOT is_match THEN
|
||||
CONTINUE;
|
||||
END IF;
|
||||
correlation := lower(trim(NEW.location));
|
||||
INSERT INTO bell_alerts(id, primary_rule_id, correlation_key, status, severity, summary, location, created_at, updated_at)
|
||||
VALUES(gen_random_uuid(), current_rule.id, correlation, 'open', NEW.severity,
|
||||
left(current_rule.name || ':' || NEW.event_type, 256), NEW.location, now(), now())
|
||||
ON CONFLICT(primary_rule_id, correlation_key) WHERE status = 'open'
|
||||
DO UPDATE SET
|
||||
updated_at = now(),
|
||||
severity = CASE
|
||||
WHEN array_position(ARRAY['low','medium','high','critical'], EXCLUDED.severity) >
|
||||
array_position(ARRAY['low','medium','high','critical'], bell_alerts.severity)
|
||||
THEN EXCLUDED.severity ELSE bell_alerts.severity END
|
||||
RETURNING id INTO target_alert_id;
|
||||
INSERT INTO bell_alert_events(alert_id, event_id, linked_at)
|
||||
VALUES(target_alert_id, NEW.id, now());
|
||||
INSERT INTO bell_rule_matches(event_id, rule_id, alert_id, rule_version, rule_snapshot, explanation, matched_at)
|
||||
VALUES(NEW.id, current_rule.id, target_alert_id, current_rule.version, snapshot, explanation_text, now());
|
||||
END LOOP;
|
||||
RETURN NEW;
|
||||
END $$`,
|
||||
`CREATE TRIGGER bell_events_evaluate_rules AFTER INSERT ON bell_events FOR EACH ROW EXECUTE FUNCTION bell_evaluate_new_event()`,
|
||||
}
|
||||
|
||||
type menuSeed struct {
|
||||
ID int
|
||||
Permission string
|
||||
}
|
||||
|
||||
type apiSeed struct {
|
||||
ID int
|
||||
Path string
|
||||
Action string
|
||||
}
|
||||
|
||||
// runtimeCasbinRule deliberately matches the table used by the frozen
|
||||
// go-admin-core gorm adapter. The legacy SysCasbinRule model is not the table
|
||||
// loaded by middleware.AuthCheckRole in this baseline.
|
||||
type runtimeCasbinRule struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||
Ptype string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V0 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V1 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V2 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V3 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V4 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
V5 string `gorm:"size:100;uniqueIndex:idx_casbin_rule"`
|
||||
}
|
||||
|
||||
func (runtimeCasbinRule) TableName() string { return "casbin_rule" }
|
||||
|
||||
func seedBellRuleAlertAccess(tx *gorm.DB) error {
|
||||
// db.sql contains explicit primary keys, so PostgreSQL sequences can lag
|
||||
// behind the imported baseline data. Align them before allocating any new
|
||||
// menu, API or role IDs.
|
||||
if err := tx.Exec(`SELECT setval(pg_get_serial_sequence('sys_menu','menu_id'), GREATEST((SELECT max(menu_id) FROM sys_menu),1));
|
||||
SELECT setval(pg_get_serial_sequence('sys_api','id'), GREATEST((SELECT max(id) FROM sys_api),1));
|
||||
SELECT setval(pg_get_serial_sequence('sys_role','role_id'), GREATEST((SELECT max(role_id) FROM sys_role),1))`).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
root, err := insertMenu(tx, 0, "BellWarning", "预警中心", "warning", "/bell", "M", "", "", "Layout", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
alerts, err := insertMenu(tx, root.ID, "BellAlerts", "预警管理", "bell", "alerts", "C", "bell:alert:list", "", "/bell/alerts/index", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
events, err := insertMenu(tx, root.ID, "BellEvents", "事件查询", "list", "events", "C", "bell:event:list", "", "/bell/events/index", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rules, err := insertMenu(tx, root.ID, "BellRules", "规则配置", "guide", "rules", "C", "bell:rule:list", "", "/bell/rules/index", 3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addRule, err := insertMenu(tx, rules.ID, "", "新增规则", "", "", "F", "bell:rule:add", "POST", "", 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
editRule, err := insertMenu(tx, rules.ID, "", "修改规则", "", "", "F", "bell:rule:edit", "PUT", "", 2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiSpecs := []struct{ title, path, action string }{
|
||||
{"预警列表", "/api/v1/bell/alerts", "GET"}, {"预警详情", "/api/v1/bell/alerts/:id", "GET"},
|
||||
{"事件列表", "/api/v1/bell/events", "GET"}, {"事件详情", "/api/v1/bell/events/:id", "GET"},
|
||||
{"事件规则结果", "/api/v1/bell/events/:id/rule-results", "GET"},
|
||||
{"规则列表", "/api/v1/bell/rules", "GET"}, {"新增规则", "/api/v1/bell/rules", "POST"},
|
||||
{"修改规则", "/api/v1/bell/rules/:id", "PUT"}, {"启停规则", "/api/v1/bell/rules/:id/enabled", "PUT"},
|
||||
}
|
||||
apis := make([]apiSeed, 0, len(apiSpecs))
|
||||
for _, spec := range apiSpecs {
|
||||
seed, seedErr := insertAPI(tx, spec.title, spec.path, spec.action)
|
||||
if seedErr != nil {
|
||||
return seedErr
|
||||
}
|
||||
apis = append(apis, seed)
|
||||
}
|
||||
links := map[int][]apiSeed{
|
||||
alerts.ID: {apis[0], apis[1]}, events.ID: {apis[2], apis[3], apis[4]}, rules.ID: {apis[5]},
|
||||
addRule.ID: {apis[6]}, editRule.ID: {apis[7], apis[8]},
|
||||
}
|
||||
for menuID, menuAPIs := range links {
|
||||
for _, item := range menuAPIs {
|
||||
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id, sys_api_id) VALUES(?, ?) ON CONFLICT DO NOTHING", menuID, item.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var operatorRoleID int
|
||||
if err := tx.Raw("SELECT role_id FROM sys_role WHERE role_key = 'operator' AND deleted_at IS NULL ORDER BY role_id LIMIT 1").Scan(&operatorRoleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if operatorRoleID == 0 {
|
||||
if err := tx.Raw(`INSERT INTO sys_role(role_name,status,role_key,role_sort,flag,remark,admin,data_scope,create_by,update_by,created_at,updated_at)
|
||||
VALUES('处置员','2','operator',2,'','仅访问 Bell 预警处理入口',false,'',1,1,now(),now())
|
||||
RETURNING role_id`).Scan(&operatorRoleID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, menu := range []menuSeed{root, alerts, events, rules} {
|
||||
if err := tx.Exec("INSERT INTO sys_role_menu(role_id, menu_id) VALUES(?, ?) ON CONFLICT DO NOTHING", operatorRoleID, menu.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, item := range apis {
|
||||
if item.Action != "GET" {
|
||||
continue
|
||||
}
|
||||
if err := tx.Exec("INSERT INTO casbin_rule(ptype,v0,v1,v2,v3,v4,v5) VALUES('p','operator',?,?, '', '', '') ON CONFLICT DO NOTHING", item.Path, item.Action).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertMenu(tx *gorm.DB, parentID int, name, title, icon, path, menuType, permission, action, component string, sort int) (menuSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_menu(menu_name,title,icon,path,paths,menu_type,action,permission,parent_id,no_cache,breadcrumb,component,sort,visible,is_frame,create_by,update_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?, '',?,?,?,?,false,'',?,?, '0','1',1,1,now(),now()) RETURNING menu_id`,
|
||||
name, title, icon, path, menuType, action, permission, parentID, component, sort).Scan(&id).Error
|
||||
if err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
paths := fmt.Sprintf("/0/%d", id)
|
||||
if parentID != 0 {
|
||||
var parentPaths string
|
||||
if err = tx.Raw("SELECT paths FROM sys_menu WHERE menu_id = ?", parentID).Scan(&parentPaths).Error; err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
paths = fmt.Sprintf("%s/%d", parentPaths, id)
|
||||
}
|
||||
if err = tx.Exec("UPDATE sys_menu SET paths = ? WHERE menu_id = ?", paths, id).Error; err != nil {
|
||||
return menuSeed{}, err
|
||||
}
|
||||
return menuSeed{ID: id, Permission: permission}, nil
|
||||
}
|
||||
|
||||
func insertAPI(tx *gorm.DB, title, path, action string) (apiSeed, error) {
|
||||
var id int
|
||||
err := tx.Raw(`INSERT INTO sys_api(handle,title,path,type,action,created_at,updated_at,create_by,update_by)
|
||||
VALUES('',?,?, 'BUS',?,now(),now(),1,1) RETURNING id`, title, path, action).Scan(&id).Error
|
||||
return apiSeed{ID: id, Path: path, Action: action}, err
|
||||
}
|
||||
Reference in New Issue
Block a user