From b01ca1fe09c49d806ed407519abdfff276404d02 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Sat, 29 Aug 2026 10:01:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=BB=BA=20Bell=20=E8=A7=84?= =?UTF-8?q?=E5=88=99=E4=B8=8E=E9=A2=84=E8=AD=A6=E9=93=BE=E8=B7=AF=20(#132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Bell/server/app/bell/alert/handler.go | 86 ++++++ Bell/server/app/bell/alert/model.go | 40 +++ Bell/server/app/bell/alert/service.go | 136 +++++++++ Bell/server/app/bell/evaluation/model.go | 20 ++ Bell/server/app/bell/evaluation/service.go | 50 ++++ Bell/server/app/bell/router/rule_alert.go | 31 +++ Bell/server/app/bell/rule/handler.go | 100 +++++++ Bell/server/app/bell/rule/model.go | 22 ++ Bell/server/app/bell/rule/service.go | 124 +++++++++ Bell/server/app/bell/rule/validation.go | 70 +++++ .../2026082909301_bell_rule_alert.go | 257 ++++++++++++++++++ .../tests/bell_rule_alert/integration_test.go | 211 ++++++++++++++ .../tests/bell_rule_alert/run-postgres.ps1 | 143 ++++++++++ .../tests/bell_rule_alert/validation_test.go | 55 ++++ Bell/ui/src/api/bell/alert.js | 9 + Bell/ui/src/api/bell/event.js | 13 + Bell/ui/src/api/bell/rule.js | 17 ++ Bell/ui/src/views/bell/alerts/index.vue | 91 +++++++ Bell/ui/src/views/bell/events/index.vue | 103 +++++++ Bell/ui/src/views/bell/rules/index.vue | 96 +++++++ .../ui/tests/unit/bell/rule-alert/api.spec.js | 41 +++ .../unit/bell/rule-alert/rules-page.spec.js | 44 +++ 22 files changed, 1759 insertions(+) create mode 100644 Bell/server/app/bell/alert/handler.go create mode 100644 Bell/server/app/bell/alert/model.go create mode 100644 Bell/server/app/bell/alert/service.go create mode 100644 Bell/server/app/bell/evaluation/model.go create mode 100644 Bell/server/app/bell/evaluation/service.go create mode 100644 Bell/server/app/bell/router/rule_alert.go create mode 100644 Bell/server/app/bell/rule/handler.go create mode 100644 Bell/server/app/bell/rule/model.go create mode 100644 Bell/server/app/bell/rule/service.go create mode 100644 Bell/server/app/bell/rule/validation.go create mode 100644 Bell/server/cmd/migrate/migration/version-local/2026082909301_bell_rule_alert.go create mode 100644 Bell/server/tests/bell_rule_alert/integration_test.go create mode 100644 Bell/server/tests/bell_rule_alert/run-postgres.ps1 create mode 100644 Bell/server/tests/bell_rule_alert/validation_test.go create mode 100644 Bell/ui/src/api/bell/alert.js create mode 100644 Bell/ui/src/api/bell/event.js create mode 100644 Bell/ui/src/api/bell/rule.js create mode 100644 Bell/ui/src/views/bell/alerts/index.vue create mode 100644 Bell/ui/src/views/bell/events/index.vue create mode 100644 Bell/ui/src/views/bell/rules/index.vue create mode 100644 Bell/ui/tests/unit/bell/rule-alert/api.spec.js create mode 100644 Bell/ui/tests/unit/bell/rule-alert/rules-page.spec.js diff --git a/Bell/server/app/bell/alert/handler.go b/Bell/server/app/bell/alert/handler.go new file mode 100644 index 0000000..34016bf --- /dev/null +++ b/Bell/server/app/bell/alert/handler.go @@ -0,0 +1,86 @@ +package alert + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-admin-team/go-admin-core/sdk/api" + + "go-admin/app/bell/evaluation" +) + +type Handler struct{ api.Api } + +func (h Handler) List(c *gin.Context) { + var query PageQuery + h.MakeContext(c).MakeOrm().Bind(&query, binding.Form) + if h.Errors != nil { + h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确") + return + } + items, count, err := NewService(h.Orm).List(c.Request.Context(), query) + if err != nil { + h.Logger.Errorf("list Bell alerts failed: %v", err) + h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败") + return + } + page, size := pageValues(query.PageIndex, query.PageSize) + h.PageOK(items, int(count), page, size, "查询成功") +} + +func (h Handler) Get(c *gin.Context) { + h.MakeContext(c).MakeOrm() + if h.Errors != nil { + h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败") + return + } + detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id")) + if err != nil { + if errors.Is(err, ErrNotFound) { + h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error()) + return + } + h.Logger.Errorf("get Bell alert failed: %v", err) + h.Error(http.StatusInternalServerError, errors.New("读取预警失败"), "读取预警失败") + return + } + h.OK(detail, "查询成功") +} + +func (h Handler) ListEvents(c *gin.Context) { + var query EventPageQuery + h.MakeContext(c).MakeOrm().Bind(&query, binding.Form) + if h.Errors != nil { + h.Error(http.StatusBadRequest, errors.New("查询条件不正确"), "查询条件不正确") + return + } + items, count, err := NewService(h.Orm).ListEvents(c.Request.Context(), query) + if err != nil { + h.Logger.Errorf("list Bell events failed: %v", err) + h.Error(http.StatusInternalServerError, errors.New("读取事件失败"), "读取事件失败") + return + } + page, size := pageValues(query.PageIndex, query.PageSize) + h.PageOK(items, int(count), page, size, "查询成功") +} + +func (h Handler) EventResults(c *gin.Context) { + h.MakeContext(c).MakeOrm() + if h.Errors != nil { + h.Error(http.StatusInternalServerError, errors.New("数据库连接获取失败"), "数据库连接获取失败") + return + } + result, err := evaluation.NewService(h.Orm).ForEvent(c.Request.Context(), c.Param("id")) + if err != nil { + if errors.Is(err, evaluation.ErrEventNotFound) { + h.Error(http.StatusNotFound, evaluation.ErrEventNotFound, evaluation.ErrEventNotFound.Error()) + return + } + h.Logger.Errorf("get Bell event rule results failed: %v", err) + h.Error(http.StatusInternalServerError, errors.New("读取规则评估失败"), "读取规则评估失败") + return + } + h.OK(result, "查询成功") +} diff --git a/Bell/server/app/bell/alert/model.go b/Bell/server/app/bell/alert/model.go new file mode 100644 index 0000000..464ffee --- /dev/null +++ b/Bell/server/app/bell/alert/model.go @@ -0,0 +1,40 @@ +package alert + +import ( + "encoding/json" + "time" +) + +type Alert struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + PrimaryRuleID string `json:"primaryRuleId" gorm:"type:uuid;not null;index"` + CorrelationKey string `json:"-" gorm:"size:384;not null"` + Status string `json:"status" gorm:"size:24;not null;default:open;index"` + Severity string `json:"severity" gorm:"size:16;not null;index"` + Summary string `json:"summary" gorm:"size:256;not null"` + Location string `json:"location" gorm:"size:256;not null;index"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"` + UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"` +} + +func (Alert) TableName() string { return "bell_alerts" } + +type AlertEvent struct { + AlertID string `json:"alertId" gorm:"type:uuid;primaryKey"` + EventID string `json:"eventId" gorm:"type:uuid;primaryKey"` + LinkedAt time.Time `json:"linkedAt" gorm:"type:timestamptz;not null"` +} + +func (AlertEvent) TableName() string { return "bell_alert_events" } + +type RuleMatch struct { + EventID string `json:"eventId" gorm:"type:uuid;primaryKey"` + RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"` + AlertID string `json:"alertId" gorm:"type:uuid;not null;index"` + RuleVersion int `json:"ruleVersion" gorm:"not null"` + RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"` + Explanation string `json:"explanation" gorm:"size:512;not null"` + MatchedAt time.Time `json:"matchedAt" gorm:"type:timestamptz;not null"` +} + +func (RuleMatch) TableName() string { return "bell_rule_matches" } diff --git a/Bell/server/app/bell/alert/service.go b/Bell/server/app/bell/alert/service.go new file mode 100644 index 0000000..d5cd24b --- /dev/null +++ b/Bell/server/app/bell/alert/service.go @@ -0,0 +1,136 @@ +package alert + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + "go-admin/app/bell/event" +) + +var ErrNotFound = errors.New("预警不存在") + +type PageQuery struct { + PageIndex int `form:"pageIndex"` + PageSize int `form:"pageSize"` + Status string `form:"status"` + Severity string `form:"severity"` + Location string `form:"location"` +} + +type Summary struct { + Alert + RuleName string `json:"ruleName"` + EventCount int64 `json:"eventCount"` +} + +type LinkedEvent struct { + event.Event + LinkedAt time.Time `json:"linkedAt"` +} + +type Detail struct { + Alert Summary `json:"alert"` + Events []LinkedEvent `json:"events"` + Matches []RuleMatch `json:"matches"` +} + +type Service struct{ DB *gorm.DB } + +func NewService(db *gorm.DB) Service { return Service{DB: db} } + +func (s Service) List(ctx context.Context, query PageQuery) ([]Summary, int64, error) { + page, size := pageValues(query.PageIndex, query.PageSize) + base := s.DB.WithContext(ctx).Table("bell_alerts a") + if query.Status = strings.TrimSpace(query.Status); query.Status != "" { + base = base.Where("a.status = ?", query.Status) + } + if query.Severity = strings.TrimSpace(query.Severity); query.Severity != "" { + base = base.Where("a.severity = ?", query.Severity) + } + if query.Location = strings.TrimSpace(query.Location); query.Location != "" { + base = base.Where("a.location ILIKE ?", "%"+query.Location+"%") + } + var count int64 + if err := base.Count(&count).Error; err != nil { + return nil, 0, err + } + items := make([]Summary, 0) + err := base.Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count"). + Joins("JOIN bell_rules r ON r.id = a.primary_rule_id"). + Order("a.created_at DESC, a.id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error + return items, count, err +} + +func (s Service) Get(ctx context.Context, id string) (Detail, error) { + if _, err := uuid.Parse(id); err != nil { + return Detail{}, ErrNotFound + } + detail := Detail{Events: make([]LinkedEvent, 0), Matches: make([]RuleMatch, 0)} + db := s.DB.WithContext(ctx) + err := db.Table("bell_alerts a"). + Select("a.*, r.name AS rule_name, (SELECT count(*) FROM bell_alert_events ae WHERE ae.alert_id = a.id) AS event_count"). + Joins("JOIN bell_rules r ON r.id = a.primary_rule_id").Where("a.id = ?", id).Take(&detail.Alert).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + return Detail{}, ErrNotFound + } + if err != nil { + return Detail{}, err + } + if err = db.Table("bell_events e").Select("e.*, ae.linked_at"). + Joins("JOIN bell_alert_events ae ON ae.event_id = e.id"). + Where("ae.alert_id = ?", id).Order("e.occurred_at, e.id").Scan(&detail.Events).Error; err != nil { + return Detail{}, err + } + err = db.Where("alert_id = ?", id).Order("matched_at, event_id, rule_id").Find(&detail.Matches).Error + return detail, err +} + +type EventPageQuery struct { + PageIndex int `form:"pageIndex"` + PageSize int `form:"pageSize"` + EventType string `form:"eventType"` + Severity string `form:"severity"` + Location string `form:"location"` +} + +type EventSummary struct { + event.Event + AlertCount int64 `json:"alertCount"` +} + +func (s Service) ListEvents(ctx context.Context, query EventPageQuery) ([]EventSummary, int64, error) { + page, size := pageValues(query.PageIndex, query.PageSize) + db := s.DB.WithContext(ctx).Model(&event.Event{}) + if value := strings.TrimSpace(query.EventType); value != "" { + db = db.Where("event_type ILIKE ?", "%"+value+"%") + } + if value := strings.TrimSpace(query.Severity); value != "" { + db = db.Where("severity = ?", value) + } + if value := strings.TrimSpace(query.Location); value != "" { + db = db.Where("location ILIKE ?", "%"+value+"%") + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return nil, 0, err + } + items := make([]EventSummary, 0) + err := db.Select("bell_events.*, (SELECT count(*) FROM bell_alert_events ae WHERE ae.event_id = bell_events.id) AS alert_count"). + Order("occurred_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Scan(&items).Error + return items, count, err +} + +func pageValues(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + return page, size +} diff --git a/Bell/server/app/bell/evaluation/model.go b/Bell/server/app/bell/evaluation/model.go new file mode 100644 index 0000000..41ae3bd --- /dev/null +++ b/Bell/server/app/bell/evaluation/model.go @@ -0,0 +1,20 @@ +package evaluation + +import ( + "encoding/json" + "time" +) + +// Evaluation is an immutable explanation of one rule version evaluated +// against one Event. +type Evaluation struct { + EventID string `json:"eventId" gorm:"type:uuid;primaryKey"` + RuleID string `json:"ruleId" gorm:"type:uuid;primaryKey"` + RuleVersion int `json:"ruleVersion" gorm:"not null"` + RuleSnapshot json.RawMessage `json:"ruleSnapshot" gorm:"type:jsonb;not null"` + Matched bool `json:"matched" gorm:"not null"` + Explanation string `json:"explanation" gorm:"size:512;not null"` + EvaluatedAt time.Time `json:"evaluatedAt" gorm:"type:timestamptz;not null"` +} + +func (Evaluation) TableName() string { return "bell_rule_evaluations" } diff --git a/Bell/server/app/bell/evaluation/service.go b/Bell/server/app/bell/evaluation/service.go new file mode 100644 index 0000000..52f9e57 --- /dev/null +++ b/Bell/server/app/bell/evaluation/service.go @@ -0,0 +1,50 @@ +package evaluation + +import ( + "context" + "errors" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +var ErrEventNotFound = errors.New("事件不存在") + +type EventResults struct { + Evaluations []Evaluation `json:"evaluations"` + Alerts []AlertLink `json:"alerts"` +} + +type AlertLink struct { + ID string `json:"id"` + Summary string `json:"summary"` + Status string `json:"status"` + Severity string `json:"severity"` + Location string `json:"location"` +} + +type Service struct{ DB *gorm.DB } + +func NewService(db *gorm.DB) Service { return Service{DB: db} } + +func (s Service) ForEvent(ctx context.Context, eventID string) (EventResults, error) { + if _, err := uuid.Parse(eventID); err != nil { + return EventResults{}, ErrEventNotFound + } + var count int64 + if err := s.DB.WithContext(ctx).Table("bell_events").Where("id = ?", eventID).Count(&count).Error; err != nil { + return EventResults{}, err + } + if count == 0 { + return EventResults{}, ErrEventNotFound + } + result := EventResults{Evaluations: make([]Evaluation, 0), Alerts: make([]AlertLink, 0)} + if err := s.DB.WithContext(ctx).Where("event_id = ?", eventID).Order("evaluated_at, rule_id").Find(&result.Evaluations).Error; err != nil { + return EventResults{}, err + } + err := s.DB.WithContext(ctx).Table("bell_alerts a"). + Select("a.id, a.summary, a.status, a.severity, a.location"). + Joins("JOIN bell_alert_events ae ON ae.alert_id = a.id"). + Where("ae.event_id = ?", eventID).Order("a.created_at, a.id").Scan(&result.Alerts).Error + return result, err +} diff --git a/Bell/server/app/bell/router/rule_alert.go b/Bell/server/app/bell/router/rule_alert.go new file mode 100644 index 0000000..c954c76 --- /dev/null +++ b/Bell/server/app/bell/router/rule_alert.go @@ -0,0 +1,31 @@ +package router + +import ( + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + + "go-admin/app/bell/alert" + "go-admin/app/bell/rule" + "go-admin/common/middleware" +) + +func init() { + registrars = append(registrars, registerRuleAlertRouter) +} + +func registerRuleAlertRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) { + rules := rule.Handler{} + alerts := alert.Handler{} + secured := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()) + { + secured.GET("/rules", rules.List) + secured.POST("/rules", rules.Create) + secured.PUT("/rules/:id", rules.Update) + secured.PUT("/rules/:id/enabled", rules.SetEnabled) + + secured.GET("/alerts", alerts.List) + secured.GET("/alerts/:id", alerts.Get) + secured.GET("/events", alerts.ListEvents) + secured.GET("/events/:id/rule-results", alerts.EventResults) + } +} diff --git a/Bell/server/app/bell/rule/handler.go b/Bell/server/app/bell/rule/handler.go new file mode 100644 index 0000000..497bc8e --- /dev/null +++ b/Bell/server/app/bell/rule/handler.go @@ -0,0 +1,100 @@ +package rule + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gin-gonic/gin/binding" + "github.com/go-admin-team/go-admin-core/sdk/api" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user" +) + +type Handler struct{ api.Api } + +type enabledInput struct { + Enabled *bool `json:"enabled" binding:"required"` +} + +func (h Handler) List(c *gin.Context) { + var query PageQuery + h.MakeContext(c).MakeOrm().Bind(&query, binding.Form) + if h.Errors != nil { + h.Error(http.StatusBadRequest, ErrInvalid, "查询条件不正确") + return + } + items, count, err := NewService(h.Orm).List(c.Request.Context(), query) + if err != nil { + h.Logger.Errorf("list Bell rules failed: %v", err) + h.Error(http.StatusInternalServerError, errors.New("读取规则失败"), "读取规则失败") + return + } + page, size := pageValues(query.PageIndex, query.PageSize) + h.PageOK(items, int(count), page, size, "查询成功") +} + +func (h Handler) Create(c *gin.Context) { + if !isAdmin(c) { + h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则") + return + } + var input WriteInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil { + h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error()) + return + } + item, err := NewService(h.Orm).Create(c.Request.Context(), input, user.GetUserId(c)) + h.writeResult(item, err) +} + +func (h Handler) Update(c *gin.Context) { + if !isAdmin(c) { + h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则") + return + } + var input WriteInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil { + h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error()) + return + } + item, err := NewService(h.Orm).Update(c.Request.Context(), c.Param("id"), input, user.GetUserId(c)) + h.writeResult(item, err) +} + +func (h Handler) SetEnabled(c *gin.Context) { + if !isAdmin(c) { + h.MakeContext(c).Error(http.StatusForbidden, errors.New("仅管理员可修改规则"), "仅管理员可修改规则") + return + } + var input enabledInput + h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON) + if h.Errors != nil || input.Enabled == nil { + h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error()) + return + } + item, err := NewService(h.Orm).SetEnabled(c.Request.Context(), c.Param("id"), *input.Enabled, user.GetUserId(c)) + h.writeResult(item, err) +} + +func (h Handler) writeResult(item Rule, err error) { + switch { + case err == nil: + h.OK(item, "保存成功") + case errors.Is(err, ErrInvalid): + h.Error(http.StatusBadRequest, ErrInvalid, ErrInvalid.Error()) + case errors.Is(err, ErrNotFound): + h.Error(http.StatusNotFound, ErrNotFound, ErrNotFound.Error()) + default: + h.Logger.Errorf("write Bell rule failed: %v", err) + h.Error(http.StatusConflict, errors.New("规则编码已存在或保存失败"), "规则编码已存在或保存失败") + } +} + +func isAdmin(c *gin.Context) bool { + claims := jwt.ExtractClaims(c) + role, _ := claims[jwt.RoleKey].(string) + return role == "admin" +} diff --git a/Bell/server/app/bell/rule/model.go b/Bell/server/app/bell/rule/model.go new file mode 100644 index 0000000..8cf06d3 --- /dev/null +++ b/Bell/server/app/bell/rule/model.go @@ -0,0 +1,22 @@ +package rule + +import "time" + +// Rule is the current editable rule definition. Historical evaluations keep a +// complete versioned snapshot, so editing this row never rewrites history. +type Rule struct { + ID string `json:"id" gorm:"type:uuid;primaryKey"` + Code string `json:"code" gorm:"size:128;not null;uniqueIndex"` + Name string `json:"name" gorm:"size:128;not null"` + Enabled bool `json:"enabled" gorm:"not null;default:true;index"` + EventType *string `json:"eventType,omitempty" gorm:"size:128"` + MinimumSeverity string `json:"minimumSeverity" gorm:"size:16;not null"` + LocationContains *string `json:"locationContains,omitempty" gorm:"size:128"` + Version int `json:"version" gorm:"not null;default:1"` + CreatedBy int `json:"createdBy" gorm:"not null"` + UpdatedBy int `json:"updatedBy" gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null"` + UpdatedAt time.Time `json:"updatedAt" gorm:"type:timestamptz;not null"` +} + +func (Rule) TableName() string { return "bell_rules" } diff --git a/Bell/server/app/bell/rule/service.go b/Bell/server/app/bell/rule/service.go new file mode 100644 index 0000000..d512b9d --- /dev/null +++ b/Bell/server/app/bell/rule/service.go @@ -0,0 +1,124 @@ +package rule + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type PageQuery struct { + PageIndex int `form:"pageIndex"` + PageSize int `form:"pageSize"` + Name string `form:"name"` + Enabled *bool `form:"enabled"` +} + +type Service struct{ DB *gorm.DB } + +func NewService(db *gorm.DB) Service { return Service{DB: db} } + +func (s Service) List(ctx context.Context, query PageQuery) ([]Rule, int64, error) { + page, size := pageValues(query.PageIndex, query.PageSize) + db := s.DB.WithContext(ctx).Model(&Rule{}) + if name := strings.TrimSpace(query.Name); name != "" { + db = db.Where("name ILIKE ? OR code ILIKE ?", "%"+name+"%", "%"+name+"%") + } + if query.Enabled != nil { + db = db.Where("enabled = ?", *query.Enabled) + } + var count int64 + if err := db.Count(&count).Error; err != nil { + return nil, 0, err + } + items := make([]Rule, 0) + err := db.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&items).Error + return items, count, err +} + +func (s Service) Create(ctx context.Context, input WriteInput, actorID int) (Rule, error) { + normalized, err := Normalize(input, true) + if err != nil { + return Rule{}, err + } + now := time.Now().UTC() + item := Rule{ + ID: uuid.NewString(), Code: normalized.Code, Name: normalized.Name, Enabled: true, + EventType: normalized.EventType, MinimumSeverity: normalized.MinimumSeverity, + LocationContains: normalized.LocationContains, Version: 1, + CreatedBy: actorID, UpdatedBy: actorID, CreatedAt: now, UpdatedAt: now, + } + if err = s.DB.WithContext(ctx).Create(&item).Error; err != nil { + return Rule{}, err + } + return item, nil +} + +func (s Service) Update(ctx context.Context, id string, input WriteInput, actorID int) (Rule, error) { + if _, err := uuid.Parse(id); err != nil { + return Rule{}, ErrNotFound + } + normalized, err := Normalize(input, false) + if err != nil { + return Rule{}, err + } + var item Rule + err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + updates := map[string]any{ + "name": normalized.Name, "event_type": normalized.EventType, + "minimum_severity": normalized.MinimumSeverity, "location_contains": normalized.LocationContains, + "version": item.Version + 1, "updated_by": actorID, "updated_at": time.Now().UTC(), + } + if err := tx.Model(&item).Updates(updates).Error; err != nil { + return err + } + return tx.First(&item, "id = ?", id).Error + }) + return item, err +} + +func (s Service) SetEnabled(ctx context.Context, id string, enabled bool, actorID int) (Rule, error) { + if _, err := uuid.Parse(id); err != nil { + return Rule{}, ErrNotFound + } + var item Rule + err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&item, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return err + } + if item.Enabled == enabled { + return nil + } + if err := tx.Model(&item).Updates(map[string]any{ + "enabled": enabled, "version": item.Version + 1, + "updated_by": actorID, "updated_at": time.Now().UTC(), + }).Error; err != nil { + return err + } + return tx.First(&item, "id = ?", id).Error + }) + return item, err +} + +func pageValues(page, size int) (int, int) { + if page < 1 { + page = 1 + } + if size < 1 || size > 100 { + size = 20 + } + return page, size +} diff --git a/Bell/server/app/bell/rule/validation.go b/Bell/server/app/bell/rule/validation.go new file mode 100644 index 0000000..b422520 --- /dev/null +++ b/Bell/server/app/bell/rule/validation.go @@ -0,0 +1,70 @@ +package rule + +import ( + "errors" + "regexp" + "strings" + "unicode/utf8" +) + +var ( + ErrInvalid = errors.New("规则内容不符合要求") + ErrNotFound = errors.New("规则不存在") + codePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{1,127}$`) +) + +type WriteInput struct { + Code string `json:"code"` + Name string `json:"name"` + EventType *string `json:"eventType"` + MinimumSeverity string `json:"minimumSeverity"` + LocationContains *string `json:"locationContains"` +} + +func Normalize(input WriteInput, requireCode bool) (WriteInput, error) { + input.Code = strings.ToLower(strings.TrimSpace(input.Code)) + input.Name = strings.TrimSpace(input.Name) + input.MinimumSeverity = strings.ToLower(strings.TrimSpace(input.MinimumSeverity)) + if requireCode && !codePattern.MatchString(input.Code) { + return WriteInput{}, ErrInvalid + } + if !validText(input.Name, 128) || !validSeverity(input.MinimumSeverity) { + return WriteInput{}, ErrInvalid + } + var err error + if input.EventType, err = optionalText(input.EventType, 128); err != nil { + return WriteInput{}, err + } + if input.LocationContains, err = optionalText(input.LocationContains, 128); err != nil { + return WriteInput{}, err + } + return input, nil +} + +func validSeverity(value string) bool { + switch value { + case "low", "medium", "high", "critical": + return true + default: + return false + } +} + +func optionalText(value *string, max int) (*string, error) { + if value == nil { + return nil, nil + } + normalized := strings.TrimSpace(*value) + if normalized == "" { + return nil, nil + } + if !validText(normalized, max) { + return nil, ErrInvalid + } + return &normalized, nil +} + +func validText(value string, max int) bool { + return value != "" && utf8.ValidString(value) && utf8.RuneCountInString(value) <= max && + !strings.ContainsAny(value, "\x00\r\n") +} diff --git a/Bell/server/cmd/migrate/migration/version-local/2026082909301_bell_rule_alert.go b/Bell/server/cmd/migrate/migration/version-local/2026082909301_bell_rule_alert.go new file mode 100644 index 0000000..6e2de14 --- /dev/null +++ b/Bell/server/cmd/migrate/migration/version-local/2026082909301_bell_rule_alert.go @@ -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 +} diff --git a/Bell/server/tests/bell_rule_alert/integration_test.go b/Bell/server/tests/bell_rule_alert/integration_test.go new file mode 100644 index 0000000..34bdd89 --- /dev/null +++ b/Bell/server/tests/bell_rule_alert/integration_test.go @@ -0,0 +1,211 @@ +package bell_rule_alert_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "testing" + "time" + + "gorm.io/driver/postgres" + "gorm.io/gorm" + + adminmodels "go-admin/app/admin/models" + "go-admin/app/bell/alert" + "go-admin/app/bell/evaluation" + "go-admin/app/bell/event" + "go-admin/app/bell/receipt" + "go-admin/app/bell/rule" +) + +func TestPostgresRuleEvaluationAndAlertProjection(t *testing.T) { + dsn := os.Getenv("BELL_RULE_ALERT_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set BELL_RULE_ALERT_TEST_DATABASE_URL to run the isolated PostgreSQL test") + } + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + rules := rule.NewService(db) + events := event.NewService(db) + evaluations := evaluation.NewService(db) + alerts := alert.NewService(db) + assertOperatorAccess(t, db) + createOperatorUser(t, db) + + eventType := "danger_area_entered" + location := "东门" + first, err := rules.Create(ctx, rule.WriteInput{Code: "area-high", Name: "高风险区域", EventType: &eventType, MinimumSeverity: "high", LocationContains: &location}, 1) + if err != nil { + t.Fatal(err) + } + second, err := rules.Create(ctx, rule.WriteInput{Code: "all-high", Name: "全局高风险", MinimumSeverity: "high"}, 1) + if err != nil { + t.Fatal(err) + } + otherEventType := "fire_detected" + _, err = rules.Create(ctx, rule.WriteInput{Code: "critical-fire", Name: "仅严重火情", EventType: &otherEventType, MinimumSeverity: "critical"}, 1) + if err != nil { + t.Fatal(err) + } + + created := ingest(t, events, "event-001", "东门 A 区", "high") + result, err := evaluations.ForEvent(ctx, created.Event.ID) + if err != nil { + t.Fatal(err) + } + if len(result.Evaluations) != 3 || len(result.Alerts) != 2 { + t.Fatalf("expected 3 evaluations and 2 alerts, got %d and %d", len(result.Evaluations), len(result.Alerts)) + } + matched, unmatched := 0, 0 + for _, item := range result.Evaluations { + if item.Matched { + matched++ + } else if item.Explanation != "" { + unmatched++ + } + } + if matched != 2 || unmatched != 1 { + t.Fatalf("unexpected match explanations: matched=%d unmatched=%d", matched, unmatched) + } + + replay := ingest(t, events, "event-001", "东门 A 区", "high") + if !replay.Duplicate || replay.Event.ID != created.Event.ID { + t.Fatalf("idempotent replay created another fact: %#v", replay) + } + assertCount(t, db, "bell_rule_evaluations", 3) + assertCount(t, db, "bell_alerts", 2) + + secondEvent := ingest(t, events, "event-002", "东门 A 区", "critical") + assertCount(t, db, "bell_alerts", 2) + items, total, err := alerts.List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 20, Status: "open"}) + if err != nil || total != 2 || len(items) != 2 { + t.Fatalf("unexpected alert list: total=%d len=%d err=%v", total, len(items), err) + } + for _, item := range items { + if item.EventCount != 2 || item.Status != "open" || item.Severity != "critical" { + t.Fatalf("open alert did not aggregate and escalate: %#v", item) + } + detail, detailErr := alerts.Get(ctx, item.ID) + if detailErr != nil || len(detail.Events) != 2 || len(detail.Matches) != 2 { + t.Fatalf("event-alert navigation is incomplete: events=%d matches=%d err=%v", len(detail.Events), len(detail.Matches), detailErr) + } + } + + updated, err := rules.Update(ctx, first.ID, rule.WriteInput{Name: "高风险区域(更新)", EventType: &eventType, MinimumSeverity: "medium", LocationContains: &location}, 1) + if err != nil || updated.Version != 2 { + t.Fatalf("rule version was not incremented: version=%d err=%v", updated.Version, err) + } + if _, err = rules.SetEnabled(ctx, second.ID, false, 1); err != nil { + t.Fatal(err) + } + thirdEvent := ingest(t, events, "event-003", "东门 B 区", "medium") + thirdResults, err := evaluations.ForEvent(ctx, thirdEvent.Event.ID) + if err != nil { + t.Fatal(err) + } + if len(thirdResults.Evaluations) != 2 || len(thirdResults.Alerts) != 1 { + t.Fatalf("disabled rule was evaluated: evaluations=%d alerts=%d", len(thirdResults.Evaluations), len(thirdResults.Alerts)) + } + var snapshot struct { + Version int `json:"version"` + } + for _, item := range thirdResults.Evaluations { + if item.RuleID == first.ID { + if err = json.Unmarshal(item.RuleSnapshot, &snapshot); err != nil || item.RuleVersion != 2 || snapshot.Version != 2 { + t.Fatalf("versioned rule snapshot missing: item=%#v snapshot=%#v err=%v", item, snapshot, err) + } + } + } + + if err = db.Model(&evaluation.Evaluation{}).Where("event_id = ? AND rule_id = ?", created.Event.ID, first.ID).Update("explanation", "tampered").Error; err == nil { + t.Fatal("immutable evaluation update unexpectedly succeeded") + } + + if err = db.Exec(`CREATE FUNCTION bell_test_reject_alert() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced alert failure'; END $$`).Error; err != nil { + t.Fatal(err) + } + if err = db.Exec(`CREATE TRIGGER bell_test_reject_alert BEFORE INSERT ON bell_alerts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_alert()`).Error; err != nil { + t.Fatal(err) + } + failedID := "event-rollback" + _, ingestErr := events.Ingest(ctx, eventCommand(failedID, "东门 C 区", "high"), 1) + if ingestErr == nil { + t.Fatal("forced alert failure did not roll back Event ingest") + } + var eventCount, receiptCount int64 + db.Model(&event.Event{}).Where("source_event_id = ?", failedID).Count(&eventCount) + db.Model(&receipt.Receipt{}).Where("source_event_id = ?", failedID).Count(&receiptCount) + if eventCount != 0 || receiptCount != 0 { + t.Fatalf("transaction failure left partial facts: events=%d receipts=%d", eventCount, receiptCount) + } + if err = db.Exec(`DROP TRIGGER bell_test_reject_alert ON bell_alerts; DROP FUNCTION bell_test_reject_alert()`).Error; err != nil { + t.Fatal(err) + } + + if secondEvent.Event.ID == thirdEvent.Event.ID { + t.Fatal("independent Events unexpectedly share an id") + } +} + +func ingest(t *testing.T, service event.Service, sourceID, location, severity string) event.Result { + t.Helper() + result, err := service.Ingest(context.Background(), eventCommand(sourceID, location, severity), 1) + if err != nil { + t.Fatal(err) + } + return result +} + +func eventCommand(sourceID, location, severity string) event.Command { + return event.Command{ProducerID: "bell.rule-test", SourceEventID: sourceID, EventType: "danger_area_entered", OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: location, Severity: severity, Attributes: map[string]any{"test": true}} +} + +func assertCount(t *testing.T, db *gorm.DB, table string, want int64) { + t.Helper() + var got int64 + if err := db.Table(table).Count(&got).Error; err != nil { + t.Fatal(err) + } + if got != want { + t.Fatal(fmt.Sprintf("%s count: got %d want %d", table, got, want)) + } +} + +func assertOperatorAccess(t *testing.T, db *gorm.DB) { + t.Helper() + var menuCount, readPolicyCount, writePolicyCount int64 + if err := db.Table("sys_role_menu rm").Joins("JOIN sys_role r ON r.role_id = rm.role_id"). + Joins("JOIN sys_menu m ON m.menu_id = rm.menu_id"). + Where("r.role_key = ? AND m.path IN ?", "operator", []string{"/bell", "alerts", "events", "rules"}).Count(&menuCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ?", "operator", "GET").Count(&readPolicyCount).Error; err != nil { + t.Fatal(err) + } + if err := db.Table("casbin_rule").Where("v0 = ? AND v2 <> ?", "operator", "GET").Count(&writePolicyCount).Error; err != nil { + t.Fatal(err) + } + if menuCount != 4 || readPolicyCount != 6 || writePolicyCount != 0 { + t.Fatalf("operator access is not read-only and minimal: menus=%d reads=%d writes=%d", menuCount, readPolicyCount, writePolicyCount) + } +} + +func createOperatorUser(t *testing.T, db *gorm.DB) { + t.Helper() + password := os.Getenv("BELL_RULE_ALERT_OPERATOR_PASSWORD") + if password == "" { + t.Skip("set BELL_RULE_ALERT_OPERATOR_PASSWORD for the HTTP RBAC continuation") + } + var roleID int + if err := db.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&roleID).Error; err != nil || roleID == 0 { + t.Fatalf("load operator role: id=%d err=%v", roleID, err) + } + user := adminmodels.SysUser{Username: "bell_132_operator", Password: password, NickName: "Bell 处置员", RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } +} diff --git a/Bell/server/tests/bell_rule_alert/run-postgres.ps1 b/Bell/server/tests/bell_rule_alert/run-postgres.ps1 new file mode 100644 index 0000000..123c117 --- /dev/null +++ b/Bell/server/tests/bell_rule_alert/run-postgres.ps1 @@ -0,0 +1,143 @@ +[CmdletBinding()] +param( + [string]$PostgresBin = 'D:\pgsql17\bin' +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' +$pgStarted = $false +$server = $null +$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-132-' + [guid]::NewGuid().ToString('N')) +$pgData = Join-Path $testRoot 'postgres' +$pgLog = Join-Path $testRoot 'postgres.log' +$pgCtlOut = Join-Path $testRoot 'pg-ctl.out.log' +$pgCtlErr = Join-Path $testRoot 'pg-ctl.err.log' +$serverOut = Join-Path $testRoot 'bell.out.log' +$serverErr = Join-Path $testRoot 'bell.err.log' +$serverExe = Join-Path $testRoot 'bell-server.exe' +$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + +function Get-FreeTcpPort { + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0) + try { + $listener.Start() + return ([Net.IPEndPoint]$listener.LocalEndpoint).Port + } finally { + $listener.Stop() + } +} + +function Wait-Tcp([int]$Port, [bool]$Open, [int]$Attempts = 120) { + for ($attempt = 0; $attempt -lt $Attempts; $attempt++) { + $client = [Net.Sockets.TcpClient]::new() + try { + $connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected + } catch { + $connected = $false + } finally { + $client.Dispose() + } + if ($connected -eq $Open) { return } + Start-Sleep -Milliseconds 250 + } + throw "TCP port $Port did not reach open=$Open" +} + +function Wait-Health([string]$BaseUrl) { + for ($attempt = 0; $attempt -lt 100; $attempt++) { + try { + $health = Invoke-RestMethod -Uri "$BaseUrl/healthz" -TimeoutSec 2 -NoProxy + if ($health.status -eq 'ok' -and $health.service -eq 'bell') { return } + } catch {} + Start-Sleep -Milliseconds 300 + } + throw 'Bell health endpoint did not become ready' +} + +New-Item -ItemType Directory -Path $testRoot | Out-Null +$pgPort = Get-FreeTcpPort +$bellPort = Get-FreeTcpPort +$baseUrl = "http://127.0.0.1:$bellPort" +$database = 'bell_132' + +try { + foreach ($required in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) { + $path = Join-Path $PostgresBin $required + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { throw "Missing PostgreSQL tool: $path" } + } + + & (Join-Path $PostgresBin 'initdb.exe') -D $pgData -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null + if ($LASTEXITCODE -ne 0) { throw 'isolated PostgreSQL initdb failed' } + $pgArguments = "-D `"$pgData`" -l `"$pgLog`" -o `"-p $pgPort -h 127.0.0.1`" start" + Start-Process -FilePath (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $pgArguments -RedirectStandardOutput $pgCtlOut -RedirectStandardError $pgCtlErr -WindowStyle Hidden | Out-Null + Wait-Tcp -Port $pgPort -Open $true + $pgStarted = $true + & (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $pgPort -U postgres $database + if ($LASTEXITCODE -ne 0) { throw 'isolated Bell database creation failed' } + + $env:GOTOOLCHAIN = 'go1.26.5' + $env:BELL_DATABASE_URL = "host=127.0.0.1 port=$pgPort user=postgres dbname=$database sslmode=disable" + $env:BELL_RULE_ALERT_TEST_DATABASE_URL = $env:BELL_DATABASE_URL + $env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N') + $env:BELL_BOOTSTRAP_USERNAME = 'bell_132_admin' + $env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N') + $env:BELL_RULE_ALERT_OPERATOR_PASSWORD = [guid]::NewGuid().ToString('N') + $env:BELL_HOST = '127.0.0.1' + $env:BELL_PORT = $bellPort.ToString() + + Push-Location $serverRoot + try { + go run . migrate -c config/settings.demo.yml *> (Join-Path $testRoot 'migrate.log') + if ($LASTEXITCODE -ne 0) { throw "Bell migration failed; see $(Join-Path $testRoot 'migrate.log')" } + go test ./tests/bell_rule_alert -count=1 -v + if ($LASTEXITCODE -ne 0) { throw 'Bell rule-alert integration test failed' } + go build -o $serverExe . + if ($LASTEXITCODE -ne 0) { throw 'Bell build failed' } + } finally { + Pop-Location + } + + $server = Start-Process -FilePath $serverExe -ArgumentList @('server', '-c', 'config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput $serverOut -RedirectStandardError $serverErr -WindowStyle Hidden -PassThru + Wait-Health $baseUrl + + $unauthorized = Invoke-RestMethod -Uri "$baseUrl/api/v1/bell/rules" -TimeoutSec 5 -NoProxy + if ([int]$unauthorized.code -ne 401) { throw "unauthenticated rule list returned code $($unauthorized.code)" } + + $adminLoginBody = @{ username = $env:BELL_BOOTSTRAP_USERNAME; password = $env:BELL_BOOTSTRAP_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress + $adminLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $adminLoginBody -TimeoutSec 5 -NoProxy + if ([int]$adminLogin.code -ne 200) { throw 'Bell administrator login failed' } + $adminHeaders = @{ Authorization = "Bearer $($adminLogin.token)" } + $adminRule = @{ code = 'http-admin'; name = '管理员 HTTP 规则'; eventType = $null; minimumSeverity = 'high'; locationContains = $null } | ConvertTo-Json -Compress + $adminWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $adminHeaders -ContentType 'application/json; charset=utf-8' -Body $adminRule -TimeoutSec 5 -NoProxy + if ([int]$adminWrite.code -ne 200 -or [int]$adminWrite.data.version -ne 1) { throw 'administrator rule create failed' } + + $operatorLoginBody = @{ username = 'bell_132_operator'; password = $env:BELL_RULE_ALERT_OPERATOR_PASSWORD; code = '0'; uuid = '0' } | ConvertTo-Json -Compress + $operatorLogin = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/login" -ContentType 'application/json' -Body $operatorLoginBody -TimeoutSec 5 -NoProxy + if ([int]$operatorLogin.code -ne 200) { throw 'Bell operator login failed' } + $operatorHeaders = @{ Authorization = "Bearer $($operatorLogin.token)" } + foreach ($path in @('/api/v1/bell/rules','/api/v1/bell/events','/api/v1/bell/alerts')) { + $read = Invoke-RestMethod -Uri "$baseUrl$path" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy + if ([int]$read.code -ne 200) { throw "operator read $path returned code $($read.code)" } + } + $operatorWrite = Invoke-RestMethod -Method Post -Uri "$baseUrl/api/v1/bell/rules" -Headers $operatorHeaders -ContentType 'application/json' -Body $adminRule -TimeoutSec 5 -NoProxy + if ([int]$operatorWrite.code -ne 403) { throw "operator rule write returned code $($operatorWrite.code)" } + $menu = Invoke-RestMethod -Uri "$baseUrl/api/v1/menurole" -Headers $operatorHeaders -TimeoutSec 5 -NoProxy + $menuJson = $menu.data | ConvertTo-Json -Depth 20 -Compress + foreach ($title in @('预警中心','预警管理','事件查询','规则配置')) { + if (-not $menuJson.Contains($title)) { throw "operator menu is missing $title" } + } + if ($menuJson.Contains('系统管理') -or $menuJson.Contains('开发工具')) { throw 'operator menu exposed unrelated GoAdmin modules' } + Write-Output 'BELL_132_HTTP unauthenticated=401 admin_write=200 operator_reads=200 operator_write=403 minimal_menu=true' +} finally { + if ($null -ne $server -and -not $server.HasExited) { + Stop-Process -Id $server.Id -Force + $server.WaitForExit(5000) | Out-Null + } + if ($pgStarted) { + & (Join-Path $PostgresBin 'pg_ctl.exe') -D $pgData -m fast stop *> (Join-Path $testRoot 'pg-stop.log') + } + foreach ($name in @('BELL_DATABASE_URL','BELL_RULE_ALERT_TEST_DATABASE_URL','BELL_RULE_ALERT_OPERATOR_PASSWORD','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')) { + Remove-Item "Env:$name" -ErrorAction SilentlyContinue + } + Write-Verbose "Bell #132 temporary artifacts: $testRoot" +} diff --git a/Bell/server/tests/bell_rule_alert/validation_test.go b/Bell/server/tests/bell_rule_alert/validation_test.go new file mode 100644 index 0000000..e37d8ce --- /dev/null +++ b/Bell/server/tests/bell_rule_alert/validation_test.go @@ -0,0 +1,55 @@ +package bell_rule_alert_test + +import ( + "errors" + "testing" + + "go-admin/app/bell/rule" +) + +func TestRuleNormalizeTrimsAndNormalizesFields(t *testing.T) { + eventType := " danger_area_entered " + location := " 东门 " + got, err := rule.Normalize(rule.WriteInput{ + Code: " AREA_HIGH ", Name: " 高风险区域 ", EventType: &eventType, + MinimumSeverity: " HIGH ", LocationContains: &location, + }, true) + if err != nil { + t.Fatal(err) + } + if got.Code != "area_high" || got.Name != "高风险区域" || got.MinimumSeverity != "high" { + t.Fatalf("unexpected normalized rule: %#v", got) + } + if got.EventType == nil || *got.EventType != "danger_area_entered" || got.LocationContains == nil || *got.LocationContains != "东门" { + t.Fatalf("optional fields were not normalized: %#v", got) + } +} + +func TestRuleNormalizeRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + input rule.WriteInput + }{ + {name: "invalid code", input: rule.WriteInput{Code: "Bad Code", Name: "规则", MinimumSeverity: "low"}}, + {name: "missing name", input: rule.WriteInput{Code: "valid-code", Name: " ", MinimumSeverity: "low"}}, + {name: "unknown severity", input: rule.WriteInput{Code: "valid-code", Name: "规则", MinimumSeverity: "urgent"}}, + {name: "control character", input: rule.WriteInput{Code: "valid-code", Name: "规则\n泄露", MinimumSeverity: "low"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := rule.Normalize(test.input, true); !errors.Is(err, rule.ErrInvalid) { + t.Fatalf("expected ErrInvalid, got %v", err) + } + }) + } +} + +func TestRuleUpdateKeepsImmutableCodeOutsideInput(t *testing.T) { + got, err := rule.Normalize(rule.WriteInput{Code: "ignored invalid code", Name: "更新后规则", MinimumSeverity: "medium"}, false) + if err != nil { + t.Fatal(err) + } + if got.Name != "更新后规则" || got.MinimumSeverity != "medium" { + t.Fatalf("unexpected update normalization: %#v", got) + } +} diff --git a/Bell/ui/src/api/bell/alert.js b/Bell/ui/src/api/bell/alert.js new file mode 100644 index 0000000..a1e8776 --- /dev/null +++ b/Bell/ui/src/api/bell/alert.js @@ -0,0 +1,9 @@ +import request from '@/utils/request' + +export function listAlerts(query) { + return request({ url: '/api/v1/bell/alerts', method: 'get', params: query }) +} + +export function getAlert(id) { + return request({ url: `/api/v1/bell/alerts/${id}`, method: 'get' }) +} diff --git a/Bell/ui/src/api/bell/event.js b/Bell/ui/src/api/bell/event.js new file mode 100644 index 0000000..aa25f57 --- /dev/null +++ b/Bell/ui/src/api/bell/event.js @@ -0,0 +1,13 @@ +import request from '@/utils/request' + +export function listEvents(query) { + return request({ url: '/api/v1/bell/events', method: 'get', params: query }) +} + +export function getEvent(id) { + return request({ url: `/api/v1/bell/events/${id}`, method: 'get' }) +} + +export function getEventRuleResults(id) { + return request({ url: `/api/v1/bell/events/${id}/rule-results`, method: 'get' }) +} diff --git a/Bell/ui/src/api/bell/rule.js b/Bell/ui/src/api/bell/rule.js new file mode 100644 index 0000000..9153aff --- /dev/null +++ b/Bell/ui/src/api/bell/rule.js @@ -0,0 +1,17 @@ +import request from '@/utils/request' + +export function listRules(query) { + return request({ url: '/api/v1/bell/rules', method: 'get', params: query }) +} + +export function createRule(data) { + return request({ url: '/api/v1/bell/rules', method: 'post', data }) +} + +export function updateRule(id, data) { + return request({ url: `/api/v1/bell/rules/${id}`, method: 'put', data }) +} + +export function setRuleEnabled(id, enabled) { + return request({ url: `/api/v1/bell/rules/${id}/enabled`, method: 'put', data: { enabled }}) +} diff --git a/Bell/ui/src/views/bell/alerts/index.vue b/Bell/ui/src/views/bell/alerts/index.vue new file mode 100644 index 0000000..b2a9cf4 --- /dev/null +++ b/Bell/ui/src/views/bell/alerts/index.vue @@ -0,0 +1,91 @@ + + + + + diff --git a/Bell/ui/src/views/bell/events/index.vue b/Bell/ui/src/views/bell/events/index.vue new file mode 100644 index 0000000..a89c604 --- /dev/null +++ b/Bell/ui/src/views/bell/events/index.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/Bell/ui/src/views/bell/rules/index.vue b/Bell/ui/src/views/bell/rules/index.vue new file mode 100644 index 0000000..40b9ab2 --- /dev/null +++ b/Bell/ui/src/views/bell/rules/index.vue @@ -0,0 +1,96 @@ + + + + + diff --git a/Bell/ui/tests/unit/bell/rule-alert/api.spec.js b/Bell/ui/tests/unit/bell/rule-alert/api.spec.js new file mode 100644 index 0000000..81a14b7 --- /dev/null +++ b/Bell/ui/tests/unit/bell/rule-alert/api.spec.js @@ -0,0 +1,41 @@ +import request from '@/utils/request' +import { getAlert, listAlerts } from '@/api/bell/alert' +import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event' +import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule' + +jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config))) + +describe('Bell rule-alert API adapters', () => { + beforeEach(() => request.mockClear()) + + it('maps read operations to the versioned Bell routes', async() => { + await listAlerts({ status: 'open' }) + await getAlert('alert-1') + await listEvents({ severity: 'high' }) + await getEvent('event-1') + await getEventRuleResults('event-1') + await listRules({ enabled: true }) + + expect(request.mock.calls.map(call => call[0])).toEqual([ + { url: '/api/v1/bell/alerts', method: 'get', params: { status: 'open' }}, + { url: '/api/v1/bell/alerts/alert-1', method: 'get' }, + { url: '/api/v1/bell/events', method: 'get', params: { severity: 'high' }}, + { url: '/api/v1/bell/events/event-1', method: 'get' }, + { url: '/api/v1/bell/events/event-1/rule-results', method: 'get' }, + { url: '/api/v1/bell/rules', method: 'get', params: { enabled: true }} + ]) + }) + + it('maps administrator writes without exposing unrelated operations', async() => { + const payload = { name: '区域规则', minimumSeverity: 'high' } + await createRule(payload) + await updateRule('rule-1', payload) + await setRuleEnabled('rule-1', false) + + expect(request.mock.calls.map(call => call[0])).toEqual([ + { url: '/api/v1/bell/rules', method: 'post', data: payload }, + { url: '/api/v1/bell/rules/rule-1', method: 'put', data: payload }, + { url: '/api/v1/bell/rules/rule-1/enabled', method: 'put', data: { enabled: false }} + ]) + }) +}) diff --git a/Bell/ui/tests/unit/bell/rule-alert/rules-page.spec.js b/Bell/ui/tests/unit/bell/rule-alert/rules-page.spec.js new file mode 100644 index 0000000..b033dd6 --- /dev/null +++ b/Bell/ui/tests/unit/bell/rule-alert/rules-page.spec.js @@ -0,0 +1,44 @@ +import RulesPage from '@/views/bell/rules/index.vue' +import { listRules } from '@/api/bell/rule' + +jest.mock('@/api/bell/rule', () => ({ + createRule: jest.fn(), + listRules: jest.fn(), + setRuleEnabled: jest.fn(), + updateRule: jest.fn() +})) + +function pageContext() { + return { + loading: false, + error: '', + items: [], + total: 0, + query: { pageIndex: 1, pageSize: 10, name: '', enabled: null } + } +} + +describe('Bell ordinary warning rule page', () => { + beforeEach(() => jest.clearAllMocks()) + + it('only enables rule changes for the GoAdmin edit permission', () => { + expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:list'] }}})).toBe(false) + expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['bell:rule:edit'] }}})).toBe(true) + expect(RulesPage.computed.canEdit.call({ $store: { getters: { permisaction: ['*:*:*'] }}})).toBe(true) + }) + + it('keeps an empty list as an intentional page state', async() => { + listRules.mockResolvedValue({ data: { list: [], count: 0 }}) + const context = pageContext() + await RulesPage.methods.load.call(context) + expect(context).toMatchObject({ loading: false, error: '', items: [], total: 0 }) + }) + + it('surfaces a failed list request instead of leaving a blank page', async() => { + listRules.mockRejectedValue(new Error('网络不可用')) + const context = pageContext() + await RulesPage.methods.load.call(context) + expect(context.loading).toBe(false) + expect(context.error).toBe('网络不可用') + }) +}) -- 2.34.1