Compare commits

...
Author SHA1 Message Date
QiuSW 1caa429cad fix: 修复 Bell 生产外壳白屏 (#142) 2026-08-29 17:11:24 +08:00
ila f99fe8d4f7 Merge PR #141: 收敛 Bell 管理员默认菜单 (#140)
用户已于 2026-08-29 明确验收通过 #140。
2026-08-29 16:45:28 +08:00
QiuSW b4a8e0e1f1 fix: 收敛 Bell 管理员默认菜单 (#140) 2026-08-29 16:43:52 +08:00
ila 86c3e79121 Merge pull request '#139' from feature/138-bell-production-captcha into dev
fix: 恢复 Bell 生产验证码登录 (#138)
2026-08-29 16:27:09 +08:00
QiuSW cabc29c18b fix: 恢复 Bell 生产验证码登录 (#138) 2026-08-29 16:20:34 +08:00
ila 452cd71035 Merge PR #137: Bell 预警处置生命周期
关联 #133;用户验收后关闭工单。
2026-08-29 11:56:57 +08:00
QiuSW f09a61e5fb feat: 重建 Bell 预警处置生命周期 (#133) 2026-08-29 11:56:41 +08:00
ila afc58f7bf4 merge: 完成 Bell 规则与预警链路 (#132)
实现版本化规则、不可变评估事实、open Alert 投影、Event-Alert 双向查询及 GoAdmin UI/RBAC。
2026-08-29 10:02:36 +08:00
QiuSW b01ca1fe09 feat: 重建 Bell 规则与预警链路 (#132) 2026-08-29 10:01:09 +08:00
ila 689de560bb Merge PR #135: Bell Event、Receipt 与合成事件入口
Implements #131; merge into dev for user acceptance.
2026-08-29 09:01:11 +08:00
51 changed files with 3075 additions and 7 deletions
+1 -2
View File
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
e.Error(500, err, "服务初始化失败!")
return
}
id, b64s, answer, err := captcha.DriverDigitFunc()
id, b64s, _, err := captcha.DriverDigitFunc()
if err != nil {
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
e.Error(500, err, "验证码获取失败")
return
}
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
e.Custom(gin.H{
"code": 200,
"data": b64s,
@@ -28,6 +28,9 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
}
func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
systemAPI := apis.System{}
v1.GET("/captcha", systemAPI.GenerateCaptchaHandler)
api := apis.SysMenu{}
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
+86
View File
@@ -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, "查询成功")
}
+40
View File
@@ -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" }
+136
View File
@@ -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
}
@@ -0,0 +1,83 @@
package alert_lifecycle
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 }
func (h Handler) Get(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
detail, err := NewService(h.Orm).Get(c.Request.Context(), c.Param("id"))
if err == nil {
current := actor(c)
detail.CanAck = detail.Projection.Status == StatusOpen && (current.Role == "admin" || current.Role == "operator")
detail.CanClose = detail.Projection.Status == StatusAcknowledged && (current.Role == "admin" || (detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == current.ID))
}
h.respond(c, Result{Detail: detail}, err)
}
func (h Handler) Ack(c *gin.Context) {
h.MakeContext(c).MakeOrm()
if h.Errors != nil {
h.Error(500, errors.New("数据库连接获取失败"), "数据库连接获取失败")
return
}
result, err := NewService(h.Orm).Ack(c.Request.Context(), c.Param("id"), actor(c))
h.respond(c, result, err)
}
func (h Handler) Close(c *gin.Context) {
if err := restoreCloseBody(c); err != nil {
h.MakeContext(c).Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
return
}
var input CloseInput
h.MakeContext(c).MakeOrm().Bind(&input, binding.JSON)
if h.Errors != nil {
h.Error(http.StatusBadRequest, ErrOutcomeRequired, "请求内容格式不正确")
return
}
result, err := NewService(h.Orm).Close(c.Request.Context(), c.Param("id"), input, actor(c))
h.respond(c, result, err)
}
func (h Handler) respond(c *gin.Context, result Result, err error) {
if err == nil {
h.OK(result, "操作成功")
c.Set("result", gin.H{"code": http.StatusOK, "data": "<redacted>"})
return
}
code := http.StatusInternalServerError
switch {
case errors.Is(err, ErrNotFound):
code = http.StatusNotFound
case errors.Is(err, ErrOutcomeRequired):
code = http.StatusBadRequest
case errors.Is(err, ErrAlreadyHandled), errors.Is(err, ErrInvalidTransition):
code = http.StatusConflict
case errors.Is(err, ErrForbidden):
code = http.StatusForbidden
default:
h.Logger.Errorf("Bell alert lifecycle failed: %v", err)
}
c.JSON(http.StatusOK, gin.H{"code": code, "msg": err.Error(), "data": result})
c.Set("result", gin.H{"code": code, "data": "<redacted>"})
}
func actor(c *gin.Context) Actor {
claims := jwt.ExtractClaims(c)
role, _ := claims[jwt.RoleKey].(string)
return Actor{ID: user.GetUserId(c), Name: user.GetUserName(c), Role: role}
}
@@ -0,0 +1,50 @@
package alert_lifecycle
import "time"
const (
StatusOpen = "open"
StatusAcknowledged = "acknowledged"
StatusClosed = "closed"
)
type Projection struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
Status string `json:"status"`
AcknowledgedBy *int `json:"acknowledgedBy,omitempty"`
AcknowledgedByName *string `json:"acknowledgedByName,omitempty"`
AcknowledgedAt *time.Time `json:"acknowledgedAt,omitempty"`
ClosedBy *int `json:"closedBy,omitempty"`
ClosedByName *string `json:"closedByName,omitempty"`
ClosedAt *time.Time `json:"closedAt,omitempty"`
CloseOutcome *string `json:"closeOutcome,omitempty"`
CloseNote *string `json:"closeNote,omitempty"`
}
func (Projection) TableName() string { return "bell_alerts" }
type Fact struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
AlertID string `json:"alertId" gorm:"type:uuid;not null;uniqueIndex:bell_alert_transition"`
Transition string `json:"transition" gorm:"size:24;not null;uniqueIndex:bell_alert_transition"`
ActorID int `json:"actorId" gorm:"not null"`
ActorName string `json:"actorName" gorm:"size:128;not null"`
Outcome *string `json:"outcome,omitempty" gorm:"size:32"`
Note *string `json:"note,omitempty" gorm:"size:500"`
OccurredAt time.Time `json:"occurredAt" gorm:"type:timestamptz;not null;index"`
}
func (Fact) TableName() string { return "bell_alert_lifecycle_facts" }
type RejectionAudit struct {
ID string `json:"id" gorm:"type:uuid;primaryKey"`
AlertID *string `json:"alertId,omitempty" gorm:"type:uuid;index"`
Action string `json:"action" gorm:"size:16;not null"`
ActorID int `json:"actorId" gorm:"not null;index"`
Reason string `json:"reason" gorm:"size:64;not null"`
ObservedStatus *string `json:"observedStatus,omitempty" gorm:"size:24"`
ObservedActor *int `json:"observedActor,omitempty"`
CreatedAt time.Time `json:"createdAt" gorm:"type:timestamptz;not null;index"`
}
func (RejectionAudit) TableName() string { return "bell_alert_lifecycle_rejections" }
@@ -0,0 +1,47 @@
package alert_lifecycle
import (
"bytes"
"errors"
"io"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
const maxCloseRequestBytes = 8 * 1024
const closeBodyKey = "bell.lifecycle.close-body"
const closeBodyErrorKey = "bell.lifecycle.close-body-error"
func RedactRequestBody() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodPost || !strings.HasPrefix(c.Request.URL.Path, "/api/v1/bell/alerts/") || !strings.HasSuffix(c.Request.URL.Path, "/close") {
c.Next()
return
}
body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxCloseRequestBytes+1))
if err != nil {
c.Set(closeBodyErrorKey, err)
} else if len(body) > maxCloseRequestBytes {
c.Set(closeBodyErrorKey, errors.New("request body too large"))
} else {
c.Set(closeBodyKey, body)
}
_ = c.Request.Body.Close()
c.Request.Body = io.NopCloser(bytes.NewReader([]byte(`{"redacted":true}`)))
c.Next()
}
}
func restoreCloseBody(c *gin.Context) error {
if value, ok := c.Get(closeBodyErrorKey); ok {
return value.(error)
}
value, ok := c.Get(closeBodyKey)
if !ok {
return errors.New("close request body was not captured")
}
c.Request.Body = io.NopCloser(bytes.NewReader(value.([]byte)))
return nil
}
@@ -0,0 +1,165 @@
package alert_lifecycle
import (
"context"
"errors"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Actor struct {
ID int
Name, Role string
}
type Detail struct {
Projection Projection `json:"projection"`
Timeline []Fact `json:"timeline"`
CanAck bool `json:"canAck"`
CanClose bool `json:"canClose"`
}
type Result struct {
Detail Detail `json:"detail"`
Idempotent bool `json:"idempotent"`
Won bool `json:"won"`
}
type Service struct{ DB *gorm.DB }
func NewService(db *gorm.DB) Service { return Service{DB: db} }
func (s Service) Get(ctx context.Context, alertID string) (Detail, error) {
if _, err := uuid.Parse(alertID); err != nil {
return Detail{}, ErrNotFound
}
var projection Projection
if err := s.DB.WithContext(ctx).First(&projection, "id = ?", alertID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Detail{}, ErrNotFound
}
return Detail{}, err
}
facts := make([]Fact, 0)
if err := s.DB.WithContext(ctx).Where("alert_id = ?", alertID).Order("occurred_at, id").Find(&facts).Error; err != nil {
return Detail{}, err
}
return Detail{Projection: projection, Timeline: facts}, nil
}
func (s Service) Ack(ctx context.Context, alertID string, actor Actor) (Result, error) {
if _, err := uuid.Parse(alertID); err != nil {
s.reject(ctx, nil, "ack", actor.ID, "not_found", nil)
return Result{}, ErrNotFound
}
now := time.Now().UTC()
var projection Projection
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Raw(`UPDATE bell_alerts SET status='acknowledged', acknowledged_by=?, acknowledged_by_name=?, acknowledged_at=?, updated_at=? WHERE id=? AND status='open' RETURNING id,status,acknowledged_by,acknowledged_by_name,acknowledged_at,closed_by,closed_by_name,closed_at,close_outcome,close_note`, actor.ID, actor.Name, now, now, alertID).Scan(&projection)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusAcknowledged, ActorID: actor.ID, ActorName: actor.Name, OccurredAt: now}).Error
})
if err == nil {
detail, getErr := s.Get(ctx, alertID)
return Result{Detail: detail, Won: true}, getErr
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return Result{}, err
}
detail, getErr := s.Get(ctx, alertID)
if getErr != nil {
return Result{}, getErr
}
if detail.Projection.AcknowledgedBy != nil && *detail.Projection.AcknowledgedBy == actor.ID {
s.reject(ctx, &alertID, "ack", actor.ID, "duplicate", &detail.Projection)
return Result{Detail: detail, Idempotent: true}, nil
}
s.reject(ctx, &alertID, "ack", actor.ID, "already_handled", &detail.Projection)
return Result{Detail: detail}, ErrAlreadyHandled
}
func (s Service) Close(ctx context.Context, alertID string, input CloseInput, actor Actor) (Result, error) {
normalized, err := normalizeClose(input)
if err != nil {
s.reject(ctx, validAlertID(alertID), "close", actor.ID, "invalid_outcome", nil)
return Result{}, err
}
if _, err = uuid.Parse(alertID); err != nil {
s.reject(ctx, nil, "close", actor.ID, "not_found", nil)
return Result{}, ErrNotFound
}
var projection Projection
err = s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if lockErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&projection, "id = ?", alertID).Error; lockErr != nil {
return lockErr
}
if projection.Status == StatusClosed {
return ErrInvalidTransition
}
if projection.Status != StatusAcknowledged {
return ErrInvalidTransition
}
if actor.Role != "admin" && (projection.AcknowledgedBy == nil || *projection.AcknowledgedBy != actor.ID) {
return ErrForbidden
}
now := time.Now().UTC()
var note *string
if normalized.Note != "" {
note = &normalized.Note
}
if updateErr := tx.Model(&projection).Updates(map[string]any{"status": StatusClosed, "closed_by": actor.ID, "closed_by_name": actor.Name, "closed_at": now, "close_outcome": normalized.Outcome, "close_note": note, "updated_at": now}).Error; updateErr != nil {
return updateErr
}
return tx.Create(&Fact{ID: uuid.NewString(), AlertID: alertID, Transition: StatusClosed, ActorID: actor.ID, ActorName: actor.Name, Outcome: &normalized.Outcome, Note: note, OccurredAt: now}).Error
})
if err == nil {
detail, getErr := s.Get(ctx, alertID)
return Result{Detail: detail, Won: true}, getErr
}
if !errors.Is(err, ErrInvalidTransition) && !errors.Is(err, ErrForbidden) && !errors.Is(err, gorm.ErrRecordNotFound) {
return Result{}, err
}
detail, getErr := s.Get(ctx, alertID)
if getErr != nil {
return Result{}, getErr
}
if detail.Projection.Status == StatusClosed && detail.Projection.ClosedBy != nil && *detail.Projection.ClosedBy == actor.ID && detail.Projection.CloseOutcome != nil && *detail.Projection.CloseOutcome == normalized.Outcome && equalOptional(detail.Projection.CloseNote, normalized.Note) {
s.reject(ctx, &alertID, "close", actor.ID, "duplicate", &detail.Projection)
return Result{Detail: detail, Idempotent: true}, nil
}
reason := "invalid_transition"
publicErr := ErrInvalidTransition
if errors.Is(err, ErrForbidden) {
reason, publicErr = "forbidden", ErrForbidden
} else if detail.Projection.Status == StatusClosed {
reason = "conflicting_replay"
}
s.reject(ctx, &alertID, "close", actor.ID, reason, &detail.Projection)
return Result{Detail: detail}, publicErr
}
func (s Service) reject(ctx context.Context, alertID *string, action string, actorID int, reason string, projection *Projection) {
audit := RejectionAudit{ID: uuid.NewString(), AlertID: alertID, Action: action, ActorID: actorID, Reason: reason, CreatedAt: time.Now().UTC()}
if projection != nil {
audit.ObservedStatus = &projection.Status
audit.ObservedActor = projection.AcknowledgedBy
}
_ = s.DB.WithContext(ctx).Create(&audit).Error
}
func validAlertID(value string) *string {
if _, err := uuid.Parse(value); err != nil {
return nil
}
return &value
}
func equalOptional(value *string, other string) bool {
if value == nil {
return other == ""
}
return *value == other
}
@@ -0,0 +1,34 @@
package alert_lifecycle
import (
"errors"
"strings"
"unicode/utf8"
)
var (
ErrNotFound = errors.New("预警不存在")
ErrAlreadyHandled = errors.New("预警已由其他人员开始处理")
ErrInvalidTransition = errors.New("当前状态不能执行此操作")
ErrOutcomeRequired = errors.New("请选择有效的现场结果")
ErrForbidden = errors.New("您无权完成此预警")
)
type CloseInput struct {
Outcome string `json:"outcome"`
Note string `json:"note"`
}
func normalizeClose(input CloseInput) (CloseInput, error) {
input.Outcome = strings.TrimSpace(input.Outcome)
input.Note = strings.TrimSpace(input.Note)
switch input.Outcome {
case "danger_confirmed", "false_positive", "site_normal", "unable_to_confirm":
default:
return CloseInput{}, ErrOutcomeRequired
}
if !utf8.ValidString(input.Note) || utf8.RuneCountInString(input.Note) > 500 || strings.ContainsAny(input.Note, "\x00\r") {
return CloseInput{}, ErrOutcomeRequired
}
return input, nil
}
+20
View File
@@ -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" }
@@ -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
}
@@ -0,0 +1,21 @@
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_lifecycle"
"go-admin/common/middleware"
)
func init() { registrars = append(registrars, registerAlertLifecycleRouter) }
func registerAlertLifecycleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
handler := alert_lifecycle.Handler{}
routes := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
routes.GET("/alerts/:id/lifecycle", handler.Get)
routes.POST("/alerts/:id/ack", handler.Ack)
routes.POST("/alerts/:id/close", handler.Close)
}
}
+31
View File
@@ -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)
}
}
+100
View File
@@ -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"
}
+22
View File
@@ -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" }
+124
View File
@@ -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
}
+70
View File
@@ -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")
}
+3 -1
View File
@@ -20,6 +20,7 @@ import (
"go-admin/app/admin/models"
"go-admin/app/admin/router"
"go-admin/app/bell/alert_lifecycle"
bellrouter "go-admin/app/bell/router"
"go-admin/app/bell/synthetic"
"go-admin/common/bellconfig"
@@ -182,7 +183,8 @@ func initRouter() {
r.Use(common.Sentinel()).
Use(common.RequestId(pkg.TrafficKey)).
Use(api.SetRequestLogger).
Use(synthetic.RedactRequestBody())
Use(synthetic.RedactRequestBody()).
Use(alert_lifecycle.RedactRequestBody())
common.InitMiddleware(r)
@@ -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
}
@@ -0,0 +1,96 @@
package version_local
import (
"runtime"
"gorm.io/gorm"
"go-admin/app/bell/alert_lifecycle"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellAlertLifecycle)
}
func migrateBellAlertLifecycle(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(new(alert_lifecycle.Fact), new(alert_lifecycle.RejectionAudit)); err != nil {
return err
}
for _, statement := range alertLifecycleSQL {
if err := tx.Exec(statement).Error; err != nil {
return err
}
}
if err := seedAlertLifecycleAccess(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
var alertLifecycleSQL = []string{
`ALTER TABLE bell_alerts ADD COLUMN acknowledged_by bigint, ADD COLUMN acknowledged_by_name varchar(128), ADD COLUMN acknowledged_at timestamptz, ADD COLUMN closed_by bigint, ADD COLUMN closed_by_name varchar(128), ADD COLUMN closed_at timestamptz, ADD COLUMN close_outcome varchar(32), ADD COLUMN close_note varchar(500)`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_ack_user_fk FOREIGN KEY (acknowledged_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_user_fk FOREIGN KEY (closed_by) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alerts ADD CONSTRAINT bell_alert_close_outcome_check CHECK (close_outcome IS NULL OR close_outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_alert_fk FOREIGN KEY (alert_id) REFERENCES bell_alerts(id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_actor_fk FOREIGN KEY (actor_id) REFERENCES sys_user(user_id) ON UPDATE RESTRICT ON DELETE RESTRICT`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_transition_check CHECK (transition IN ('acknowledged','closed'))`,
`ALTER TABLE bell_alert_lifecycle_facts ADD CONSTRAINT bell_alert_lifecycle_outcome_check CHECK (outcome IS NULL OR outcome IN ('danger_confirmed','false_positive','site_normal','unable_to_confirm'))`,
`CREATE TRIGGER bell_alert_lifecycle_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
`CREATE TRIGGER bell_alert_lifecycle_rejections_immutable BEFORE UPDATE OR DELETE ON bell_alert_lifecycle_rejections FOR EACH ROW EXECUTE FUNCTION bell_reject_immutable_fact()`,
}
func seedAlertLifecycleAccess(tx *gorm.DB) error {
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))`).Error; err != nil {
return err
}
var alertMenuID int
if err := tx.Table("sys_menu").Select("menu_id").Where("permission = ?", "bell:alert:list").Scan(&alertMenuID).Error; err != nil || alertMenuID == 0 {
return gorm.ErrRecordNotFound
}
ackMenu, err := insertMenu(tx, alertMenuID, "", "开始处理", "", "", "F", "bell:alert:ack", "POST", "", 1)
if err != nil {
return err
}
closeMenu, err := insertMenu(tx, alertMenuID, "", "记录结果", "", "", "F", "bell:alert:close", "POST", "", 2)
if err != nil {
return err
}
specs := []struct{ title, path, action string }{{"预警处理时间线", "/api/v1/bell/alerts/:id/lifecycle", "GET"}, {"开始处理预警", "/api/v1/bell/alerts/:id/ack", "POST"}, {"记录结果并完成", "/api/v1/bell/alerts/:id/close", "POST"}}
apis := make([]apiSeed, 0, len(specs))
for _, spec := range specs {
item, itemErr := insertAPI(tx, spec.title, spec.path, spec.action)
if itemErr != nil {
return itemErr
}
apis = append(apis, item)
}
for _, link := range []struct {
menu int
api apiSeed
}{{alertMenuID, apis[0]}, {ackMenu.ID, apis[1]}, {closeMenu.ID, apis[2]}} {
if err := tx.Exec("INSERT INTO sys_menu_api_rule(sys_menu_menu_id,sys_api_id) VALUES(?,?) ON CONFLICT DO NOTHING", link.menu, link.api.ID).Error; err != nil {
return err
}
}
var operatorRoleID int
if err := tx.Table("sys_role").Select("role_id").Where("role_key = ?", "operator").Scan(&operatorRoleID).Error; err != nil || operatorRoleID == 0 {
return gorm.ErrRecordNotFound
}
for _, menuID := range []int{ackMenu.ID, closeMenu.ID} {
if err := tx.Exec("INSERT INTO sys_role_menu(role_id,menu_id) VALUES(?,?) ON CONFLICT DO NOTHING", operatorRoleID, menuID).Error; err != nil {
return err
}
}
for _, item := range apis {
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
}
@@ -0,0 +1,52 @@
package version_local
import (
"runtime"
"gorm.io/gorm"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellMinimalMenu)
}
func migrateBellMinimalMenu(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := ApplyBellMinimalMenuVisibility(tx); err != nil {
return err
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
// ApplyBellMinimalMenuVisibility keeps the imported GoAdmin menu records for
// rollback and upgrades, but exposes only Bell product entries and the three
// RBAC administration pages required to maintain local accounts.
func ApplyBellMinimalMenuVisibility(tx *gorm.DB) error {
if err := tx.Exec(`
UPDATE sys_menu
SET visible = '1', updated_at = now()
WHERE menu_type IN ('M', 'C')
AND deleted_at IS NULL
AND visible IS DISTINCT FROM '1'
`).Error; err != nil {
return err
}
return tx.Exec(`
UPDATE sys_menu
SET visible = '0', updated_at = now()
WHERE menu_type IN ('M', 'C')
AND deleted_at IS NULL
AND (
path IN ('/admin', '/admin/sys-user', '/admin/sys-menu', '/admin/sys-role', '/bell')
OR permission IN ('admin:sysUser:list', 'admin:sysMenu:list', 'admin:sysRole:list',
'bell:alert:list', 'bell:event:list', 'bell:rule:list')
)
AND visible IS DISTINCT FROM '0'
`).Error
}
@@ -0,0 +1,172 @@
package bell_alert_lifecycle_test
import (
"context"
"os"
"sync"
"sync/atomic"
"testing"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
adminmodels "go-admin/app/admin/models"
"go-admin/app/bell/alert"
"go-admin/app/bell/alert_lifecycle"
"go-admin/app/bell/event"
"go-admin/app/bell/rule"
)
func TestConcurrentLifecycleAndPersistence(t *testing.T) {
dsn := os.Getenv("BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("integration database not configured")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
actors := createActors(t, db)
service := alert_lifecycle.NewService(db)
alertID := createAlert(t, db, "main")
const attempts = 20
var won atomic.Int32
results := make(chan alert_lifecycle.Result, attempts)
var wg sync.WaitGroup
for i := 0; i < attempts; i++ {
wg.Add(1)
actor := actors[i%2]
go func() {
defer wg.Done()
result, _ := service.Ack(ctx, alertID, actor)
if result.Won {
won.Add(1)
}
results <- result
}()
}
wg.Wait()
close(results)
if won.Load() != 1 {
t.Fatalf("ack winners=%d", won.Load())
}
detail, err := service.Get(ctx, alertID)
if err != nil || detail.Projection.Status != alert_lifecycle.StatusAcknowledged || len(detail.Timeline) != 1 {
t.Fatalf("ack projection=%#v err=%v", detail, err)
}
winner := actors[0]
loser := actors[1]
if detail.Projection.AcknowledgedBy == nil || *detail.Projection.AcknowledgedBy != winner.ID {
winner, loser = loser, winner
}
for result := range results {
if result.Detail.Projection.AcknowledgedBy != nil && *result.Detail.Projection.AcknowledgedBy != winner.ID {
t.Fatal("later ack did not report the true winner")
}
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal"}, loser); err == nil {
t.Fatal("non-owner close succeeded")
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{}, winner); err == nil {
t.Fatal("missing outcome succeeded")
}
closed, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
if err != nil || !closed.Won {
t.Fatalf("close failed: %#v %v", closed, err)
}
replay, err := service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "site_normal", Note: "现场正常"}, winner)
if err != nil || !replay.Idempotent {
t.Fatalf("close replay=%#v %v", replay, err)
}
if _, err = service.Close(ctx, alertID, alert_lifecycle.CloseInput{Outcome: "false_positive"}, winner); err == nil {
t.Fatal("conflicting close replay succeeded")
}
if err = db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Update("actor_name", "tampered").Error; err == nil {
t.Fatal("lifecycle fact update succeeded")
}
var facts, rejects int64
db.Model(&alert_lifecycle.Fact{}).Where("alert_id = ?", alertID).Count(&facts)
db.Model(&alert_lifecycle.RejectionAudit{}).Where("alert_id = ?", alertID).Count(&rejects)
if facts != 2 || rejects < 20 {
t.Fatalf("facts=%d rejects=%d", facts, rejects)
}
sqlDB, _ := db.DB()
_ = sqlDB.Close()
reopened, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
after, err := alert_lifecycle.NewService(reopened).Get(ctx, alertID)
if err != nil || after.Projection.Status != alert_lifecycle.StatusClosed || len(after.Timeline) != 2 {
t.Fatalf("restart detail=%#v err=%v", after, err)
}
rollbackID := createAlert(t, reopened, "rollback")
if err = reopened.Exec(`CREATE FUNCTION bell_test_reject_lifecycle() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'forced lifecycle failure'; END $$`).Error; err != nil {
t.Fatal(err)
}
if err = reopened.Exec(`CREATE TRIGGER bell_test_reject_lifecycle BEFORE INSERT ON bell_alert_lifecycle_facts FOR EACH ROW EXECUTE FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
t.Fatal(err)
}
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, rollbackID, winner); err == nil {
t.Fatal("forced lifecycle failure succeeded")
}
rollback, _ := alert_lifecycle.NewService(reopened).Get(ctx, rollbackID)
if rollback.Projection.Status != alert_lifecycle.StatusOpen || len(rollback.Timeline) != 0 {
t.Fatal("failed ack left partial projection")
}
if err = reopened.Exec(`DROP TRIGGER bell_test_reject_lifecycle ON bell_alert_lifecycle_facts; DROP FUNCTION bell_test_reject_lifecycle()`).Error; err != nil {
t.Fatal(err)
}
adminCloseID := createAlert(t, reopened, "admin-close")
if _, err = alert_lifecycle.NewService(reopened).Ack(ctx, adminCloseID, winner); err != nil {
t.Fatal(err)
}
administrator := loser
administrator.Role = "admin"
if result, closeErr := alert_lifecycle.NewService(reopened).Close(ctx, adminCloseID, alert_lifecycle.CloseInput{Outcome: "danger_confirmed"}, administrator); closeErr != nil || !result.Won {
t.Fatalf("administrator close failed: %#v %v", result, closeErr)
}
}
func createActors(t *testing.T, db *gorm.DB) []alert_lifecycle.Actor {
t.Helper()
var roleID int
db.Table("sys_role").Select("role_id").Where("role_key='operator'").Scan(&roleID)
result := make([]alert_lifecycle.Actor, 2)
for i := range result {
user := adminmodels.SysUser{Username: "bell_133_operator_" + string(rune('a'+i)), Password: "test-password-133", NickName: "处置员" + string(rune('A'+i)), RoleId: roleID, DeptId: 1, PostId: 1, Status: "2"}
if err := db.Create(&user).Error; err != nil {
t.Fatal(err)
}
result[i] = alert_lifecycle.Actor{ID: user.UserId, Name: user.NickName, Role: "operator"}
}
return result
}
func createAlert(t *testing.T, db *gorm.DB, suffix string) string {
t.Helper()
ctx := context.Background()
eventType := "lifecycle_" + suffix
createdRule, err := rule.NewService(db).Create(ctx, rule.WriteInput{Code: "lifecycle-" + suffix, Name: "生命周期规则", EventType: &eventType, MinimumSeverity: "low"}, 1)
if err != nil {
t.Fatal(err)
}
created, err := event.NewService(db).Ingest(ctx, event.Command{ProducerID: "bell.lifecycle-test", SourceEventID: suffix, EventType: eventType, OccurredAt: time.Date(2026, 8, 29, 0, 0, 0, 0, time.UTC), Location: "测试地点" + suffix, Severity: "high", Attributes: map[string]any{}}, 1)
if err != nil {
t.Fatal(err)
}
items, _, err := alert.NewService(db).List(ctx, alert.PageQuery{PageIndex: 1, PageSize: 100})
if err != nil {
t.Fatal(err)
}
for _, item := range items {
if item.PrimaryRuleID == createdRule.ID {
return item.ID
}
}
t.Fatal("alert not created")
return created.Event.ID
}
@@ -0,0 +1,28 @@
[CmdletBinding()] param([string]$PostgresBin='D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference='Stop'; $started=$false; $server=$null
$root=Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-133-'+[guid]::NewGuid().ToString('N'))
$data=Join-Path $root 'postgres'; $log=Join-Path $root 'postgres.log'; $serverRoot=(Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path; $serverExe=Join-Path $root 'bell.exe'
function FreePort { $l=[Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback,0); try{$l.Start();return ([Net.IPEndPoint]$l.LocalEndpoint).Port}finally{$l.Stop()} }
function WaitPort([int]$port){for($i=0;$i -lt 120;$i++){try{$c=[Net.Sockets.TcpClient]::new();$ok=$c.ConnectAsync('127.0.0.1',$port).Wait(250)-and$c.Connected;$c.Dispose();if($ok){return}}catch{};Start-Sleep -Milliseconds 250};throw 'PostgreSQL did not start'}
function Login([string]$base,[string]$username,[string]$password){$body=@{username=$username;password=$password;code='0';uuid='0'}|ConvertTo-Json -Compress; $result=Invoke-RestMethod -Method Post -Uri "$base/api/v1/login" -ContentType 'application/json' -Body $body -NoProxy; if([int]$result.code-ne 200){throw "login failed: $username"}; return @{Authorization="Bearer $($result.token)"}}
New-Item -ItemType Directory -Path $root|Out-Null; $port=FreePort
try {
foreach($name in @('initdb.exe','pg_ctl.exe','createdb.exe','psql.exe')){if(-not(Test-Path (Join-Path $PostgresBin $name))){throw "Missing $name"}}
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale|Out-Null; if($LASTEXITCODE-ne 0){throw 'initdb failed'}
$args="-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"; Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $args -WindowStyle Hidden|Out-Null; WaitPort $port; $started=$true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_133; if($LASTEXITCODE-ne 0){throw 'createdb failed'}
$bellPort=FreePort; $base="http://127.0.0.1:$bellPort"; $env:GOTOOLCHAIN='go1.26.5'; $env:BELL_DATABASE_URL="host=127.0.0.1 port=$port user=postgres dbname=bell_133 sslmode=disable"; $env:BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL=$env:BELL_DATABASE_URL; $env:BELL_JWT_SECRET=[guid]::NewGuid().ToString('N')+[guid]::NewGuid().ToString('N'); $env:BELL_BOOTSTRAP_USERNAME='bell_133_admin'; $env:BELL_BOOTSTRAP_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 $root 'migrate.log'); if($LASTEXITCODE-ne 0){throw "migration failed: $root"}; go test ./tests/bell_alert_lifecycle -count=1 -v; if($LASTEXITCODE-ne 0){throw 'lifecycle test failed'}; go build -o $serverExe . } finally { Pop-Location }
$server=Start-Process $serverExe -ArgumentList @('server','-c','config/settings.demo.yml') -WorkingDirectory $serverRoot -RedirectStandardOutput (Join-Path $root 'server.out') -RedirectStandardError (Join-Path $root 'server.err') -WindowStyle Hidden -PassThru; WaitPort $bellPort
$a=Login $base 'bell_133_operator_a' 'test-password-133'; $b=Login $base 'bell_133_operator_b' 'test-password-133'; $list=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts?status=open" -Headers $a -NoProxy; $id=[string]$list.data.list[0].id; if([string]::IsNullOrWhiteSpace($id)){throw 'open alert missing'}
$ack=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; $late=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/ack" -Headers $b -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$ack.code-ne 200-or[int]$late.code-ne 409){throw 'ack API semantics failed'}
$forbidden=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $b -ContentType 'application/json' -Body '{"outcome":"site_normal"}' -NoProxy; $missing=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json' -Body '{}' -NoProxy; if([int]$forbidden.code-ne 403-or[int]$missing.code-ne 400){throw 'close rejection semantics failed'}
$body='{"outcome":"site_normal","note":"现场正常"}'; $closed=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $replay=Invoke-RestMethod -Method Post -Uri "$base/api/v1/bell/alerts/$id/close" -Headers $a -ContentType 'application/json; charset=utf-8' -Body $body -NoProxy; $timeline=Invoke-RestMethod -Uri "$base/api/v1/bell/alerts/$id/lifecycle" -Headers $a -NoProxy; if([int]$closed.code-ne 200-or-not$replay.data.idempotent-or$timeline.data.detail.timeline.Count-ne 2){throw 'close/timeline API semantics failed'}
$leaks=& (Join-Path $PostgresBin 'psql.exe') -h 127.0.0.1 -p $port -U postgres -d bell_133 -Atc "select count(*) from sys_opera_log where oper_url like '%/bell/alerts/%/close' and ((oper_param <> '' and oper_param not like '%redacted%') or json_result not like '%redacted%');"; if($LASTEXITCODE-ne 0-or[int]$leaks-ne 0){throw 'lifecycle note leaked into GoAdmin operation log'}
Write-Output 'BELL_133_HTTP ack=200 late_ack=409 forbidden_close=403 missing_outcome=400 close=200 replay=true timeline=2'
} finally {
if($null-ne$server-and-not$server.HasExited){Stop-Process -Id $server.Id -Force; $server.WaitForExit(5000)|Out-Null}
if($started){& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')}
foreach($name in @('BELL_DATABASE_URL','BELL_ALERT_LIFECYCLE_TEST_DATABASE_URL','BELL_JWT_SECRET','BELL_BOOTSTRAP_USERNAME','BELL_BOOTSTRAP_PASSWORD','BELL_HOST','BELL_PORT')){Remove-Item "Env:$name" -ErrorAction SilentlyContinue}
}
@@ -0,0 +1,7 @@
package bell_alert_lifecycle_test
import "testing"
// Input and state validation are exercised through the PostgreSQL service test;
// this sentinel keeps the package runnable without an integration database.
func TestLifecyclePackageLoadsWithoutDatabase(t *testing.T) {}
@@ -0,0 +1,120 @@
package bell_minimal_menu_test
import (
"os"
"reflect"
"sort"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
versionlocal "go-admin/cmd/migrate/migration/version-local"
)
var expectedVisibleMenus = []string{
"事件查询",
"用户管理",
"系统管理",
"菜单管理",
"角色管理",
"规则配置",
"预警中心",
"预警管理",
}
func TestBellMinimalMenuMigration(t *testing.T) {
databaseURL := os.Getenv("BELL_MINIMAL_MENU_TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("minimal menu database is not configured")
}
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
assertVisibleMenus(t, db)
assertUnusedMenusRetainedAndHidden(t, db)
assertOperatorHasNoDefaultMenus(t, db)
var rowCountBefore int64
if err = db.Table("sys_menu").Count(&rowCountBefore).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`UPDATE sys_menu SET visible = '0' WHERE title IN ('开发工具','定时任务','系统工具')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Exec(`UPDATE sys_menu SET visible = '1' WHERE title IN ('系统管理','预警中心')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
t.Fatal(err)
}
if err = db.Transaction(versionlocal.ApplyBellMinimalMenuVisibility); err != nil {
t.Fatalf("reapplying minimal menu policy failed: %v", err)
}
var rowCountAfter int64
if err = db.Table("sys_menu").Count(&rowCountAfter).Error; err != nil {
t.Fatal(err)
}
if rowCountAfter != rowCountBefore {
t.Fatalf("menu records changed during visibility migration: before=%d after=%d", rowCountBefore, rowCountAfter)
}
assertVisibleMenus(t, db)
assertUnusedMenusRetainedAndHidden(t, db)
}
func assertVisibleMenus(t *testing.T, db *gorm.DB) {
t.Helper()
var titles []string
if err := db.Table("sys_menu").
Where("menu_type IN ? AND deleted_at IS NULL AND visible = ?", []string{"M", "C"}, "0").
Order("title").Pluck("title", &titles).Error; err != nil {
t.Fatal(err)
}
sort.Strings(titles)
expected := append([]string(nil), expectedVisibleMenus...)
sort.Strings(expected)
if !reflect.DeepEqual(titles, expected) {
t.Fatalf("visible menu mismatch\nwant: %v\n got: %v", expected, titles)
}
}
func assertUnusedMenusRetainedAndHidden(t *testing.T, db *gorm.DB) {
t.Helper()
for _, title := range []string{"开发工具", "定时任务", "系统工具"} {
var values []string
if err := db.Table("sys_menu").Where("title = ? AND deleted_at IS NULL", title).Pluck("visible", &values).Error; err != nil {
t.Fatal(err)
}
if len(values) == 0 {
t.Fatalf("unused upstream menu %q was deleted", title)
}
for _, visible := range values {
if visible != "1" {
t.Fatalf("unused upstream menu %q remains visible=%q", title, visible)
}
}
}
}
func assertOperatorHasNoDefaultMenus(t *testing.T, db *gorm.DB) {
t.Helper()
var titles []string
err := db.Raw(`
SELECT DISTINCT m.title
FROM sys_role r
JOIN sys_role_menu rm ON rm.role_id = r.role_id
JOIN sys_menu m ON m.menu_id = rm.menu_id
WHERE r.role_key = 'operator'
AND m.menu_type IN ('M', 'C')
AND m.deleted_at IS NULL
AND (m.path LIKE '/admin%' OR m.permission LIKE 'admin:%')
`).Scan(&titles).Error
if err != nil {
t.Fatal(err)
}
if len(titles) != 0 {
t.Fatalf("operator retains default administration menus: %v", titles)
}
}
@@ -0,0 +1,79 @@
[CmdletBinding()]
param([string]$PostgresBin = 'D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$started = $false
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-140-' + [guid]::NewGuid().ToString('N'))
$data = Join-Path $root 'postgres'
$log = Join-Path $root 'postgres.log'
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Get-FreePort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Wait-ForPort([int]$Port) {
for ($attempt = 0; $attempt -lt 120; $attempt++) {
try {
$client = [Net.Sockets.TcpClient]::new()
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
$client.Dispose()
if ($connected) { return }
} catch {
}
Start-Sleep -Milliseconds 250
}
throw 'PostgreSQL did not start'
}
New-Item -ItemType Directory -Path $root | Out-Null
$port = Get-FreePort
try {
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) { throw "Missing $name" }
}
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
Wait-ForPort $port
$started = $true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_140
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_140 sslmode=disable"
$env:BELL_MINIMAL_MENU_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_140_admin'
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = (Get-FreePort).ToString()
Push-Location $serverRoot
try {
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
go test ./tests/bell_minimal_menu -count=1 -v
if ($LASTEXITCODE -ne 0) { throw 'minimal menu test failed' }
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate-repeat.log')
if ($LASTEXITCODE -ne 0) { throw "repeat migration failed; evidence: $root" }
} finally {
Pop-Location
}
Write-Output 'BELL_140_MINIMAL_MENU fresh=true upgrade=true repeat=true admin_whitelist=true operator_default_menu=false'
} finally {
if ($started) {
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
}
foreach ($name in @('BELL_DATABASE_URL', 'BELL_MINIMAL_MENU_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
}
@@ -0,0 +1,195 @@
package bell_production_login_test
import (
"bytes"
"encoding/json"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/config/source/file"
"github.com/go-admin-team/go-admin-core/sdk"
sdkapi "github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
"github.com/mojocn/base64Captcha"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
adminrouter "go-admin/app/admin/router"
bellrouter "go-admin/app/bell/router"
"go-admin/common/bellconfig"
"go-admin/common/database"
"go-admin/common/middleware"
"go-admin/common/storage"
ext "go-admin/config"
)
type apiResponse struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
ID string `json:"id"`
Msg string `json:"msg"`
Token string `json:"token"`
}
func TestProductionCaptchaLoginAndRouteBoundary(t *testing.T) {
if os.Getenv("BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL") == "" {
t.Skip("production login database is not configured")
}
if err := os.MkdirAll("temp/logs", 0o700); err != nil {
t.Fatal(err)
}
gin.SetMode(gin.TestMode)
config.ExtendConfig = &ext.ExtConfig
config.Setup(file.NewSource(file.WithPath("../../config/settings.yml")))
if err := bellconfig.ApplyRequiredEnvironment(); err != nil {
t.Fatal(err)
}
if config.ApplicationConfig.Mode != "prod" {
t.Fatalf("expected production mode, got %q", config.ApplicationConfig.Mode)
}
database.Setup()
storage.Setup()
engine := gin.New()
sdk.Runtime.SetEngine(engine)
engine.Use(sdkapi.SetRequestLogger)
engine.Use(middleware.WithContextDb)
authMiddleware, err := middleware.AuthInit()
if err != nil {
t.Fatal(err)
}
adminrouter.InitSysRouter(engine, authMiddleware)
adminrouter.InitExamplesRouter(engine, authMiddleware)
bellrouter.InitRouter()
captchaResponse := requestJSON(t, engine, http.MethodGet, "/api/v1/captcha", nil, "")
if captchaResponse.Code != 200 || captchaResponse.ID == "" || !bytes.Contains(captchaResponse.Data, []byte("data:image/")) {
t.Fatalf("unexpected captcha response: code=%d id=%q data=%s", captchaResponse.Code, captchaResponse.ID, captchaResponse.Data)
}
username := os.Getenv("BELL_BOOTSTRAP_USERNAME")
password := os.Getenv("BELL_BOOTSTRAP_PASSWORD")
answer := "813907"
validID := "bell-138-valid"
if err := base64Captcha.DefaultMemStore.Set(validID, answer); err != nil {
t.Fatal(err)
}
if !captcha.Verify(validID, answer, false) {
t.Fatal("known captcha was not stored")
}
login := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
if login.Code != 200 || login.Token == "" {
t.Fatalf("valid captcha login failed: code=%d msg=%q", login.Code, login.Msg)
}
replay := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, validID, answer), "")
if replay.Code == 200 {
t.Fatal("used captcha was accepted again")
}
wrongID := "bell-138-wrong"
if err := base64Captcha.DefaultMemStore.Set(wrongID, answer); err != nil {
t.Fatal(err)
}
wrong := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, "000000"), "")
if wrong.Code == 200 {
t.Fatal("incorrect captcha was accepted")
}
consumed := requestJSON(t, engine, http.MethodPost, "/api/v1/login", loginBody(username, password, wrongID, answer), "")
if consumed.Code == 200 {
t.Fatal("captcha used by a failed attempt was not consumed")
}
unauthenticated := requestJSON(t, engine, http.MethodGet, "/api/v1/bell/alerts", nil, "")
if unauthenticated.Code == 200 {
t.Fatal("unauthenticated Bell business API was accepted")
}
disabled := httptest.NewRecorder()
engine.ServeHTTP(disabled, httptest.NewRequest(http.MethodGet, "/api/v1/config", nil))
if disabled.Code != http.StatusNotFound {
t.Fatalf("disabled default route returned HTTP %d", disabled.Code)
}
assertSecretsAbsentFromAudit(t, password, answer, login.Token)
assertSecretsAbsentFromLogs(t, password, answer, login.Token)
if captcha.Verify(captchaResponse.ID, "deliberately-wrong", true) {
t.Fatal("generated captcha accepted a deliberately incorrect answer")
}
}
func loginBody(username, password, id, answer string) map[string]string {
return map[string]string{"username": username, "password": password, "uuid": id, "code": answer}
}
func requestJSON(t *testing.T, engine http.Handler, method, path string, body any, token string) apiResponse {
t.Helper()
var payload []byte
var err error
if body != nil {
payload, err = json.Marshal(body)
if err != nil {
t.Fatal(err)
}
}
request := httptest.NewRequest(method, path, bytes.NewReader(payload))
request.Header.Set("Content-Type", "application/json")
if token != "" {
request.Header.Set("Authorization", "Bearer "+token)
}
recorder := httptest.NewRecorder()
engine.ServeHTTP(recorder, request)
var response apiResponse
if err = json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
t.Fatalf("decode %s response (HTTP %d): %v: %s", path, recorder.Code, err, recorder.Body.String())
}
return response
}
func assertSecretsAbsentFromAudit(t *testing.T, secrets ...string) {
t.Helper()
db := sdk.Runtime.GetDbByKey("").Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})
for _, table := range []string{"sys_login_log", "sys_opera_log"} {
for _, secret := range secrets {
var count int64
query := "SELECT count(*) FROM " + table + " WHERE row_to_json(" + table + ")::text LIKE ?"
if err := db.Raw(query, "%"+secret+"%").Scan(&count).Error; err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("secret leaked into %s", table)
}
}
}
}
func assertSecretsAbsentFromLogs(t *testing.T, secrets ...string) {
t.Helper()
patterns := append([]string{"DriverDigitFunc answer:"}, secrets...)
err := filepath.WalkDir("temp", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, pattern := range patterns {
if pattern != "" && strings.Contains(string(content), pattern) {
t.Fatalf("sensitive value found in server log %s", path)
}
}
return nil
})
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
}
@@ -0,0 +1,83 @@
[CmdletBinding()]
param([string]$PostgresBin = 'D:\pgsql17\bin')
Set-StrictMode -Version 3.0
$ErrorActionPreference = 'Stop'
$started = $false
$root = Join-Path ([IO.Path]::GetTempPath()) ('yovision-bell-138-' + [guid]::NewGuid().ToString('N'))
$data = Join-Path $root 'postgres'
$log = Join-Path $root 'postgres.log'
$serverRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
function Get-FreePort {
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0)
try {
$listener.Start()
return ([Net.IPEndPoint]$listener.LocalEndpoint).Port
} finally {
$listener.Stop()
}
}
function Wait-ForPort([int]$Port) {
for ($attempt = 0; $attempt -lt 120; $attempt++) {
try {
$client = [Net.Sockets.TcpClient]::new()
$connected = $client.ConnectAsync('127.0.0.1', $Port).Wait(250) -and $client.Connected
$client.Dispose()
if ($connected) {
return
}
} catch {
}
Start-Sleep -Milliseconds 250
}
throw 'PostgreSQL did not start'
}
New-Item -ItemType Directory -Path $root | Out-Null
$port = Get-FreePort
try {
foreach ($name in @('initdb.exe', 'pg_ctl.exe', 'createdb.exe')) {
if (-not (Test-Path -LiteralPath (Join-Path $PostgresBin $name))) {
throw "Missing $name"
}
}
& (Join-Path $PostgresBin 'initdb.exe') -D $data -U postgres -A trust --encoding=UTF8 --no-locale | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'initdb failed' }
$arguments = "-D `"$data`" -l `"$log`" -o `"-p $port -h 127.0.0.1`" start"
Start-Process (Join-Path $PostgresBin 'pg_ctl.exe') -ArgumentList $arguments -WindowStyle Hidden | Out-Null
Wait-ForPort $port
$started = $true
& (Join-Path $PostgresBin 'createdb.exe') -h 127.0.0.1 -p $port -U postgres bell_138
if ($LASTEXITCODE -ne 0) { throw 'createdb failed' }
$env:GOTOOLCHAIN = 'go1.26.5'
$env:BELL_DATABASE_URL = "host=127.0.0.1 port=$port user=postgres dbname=bell_138 sslmode=disable"
$env:BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL = $env:BELL_DATABASE_URL
$env:BELL_JWT_SECRET = [guid]::NewGuid().ToString('N') + [guid]::NewGuid().ToString('N')
$env:BELL_BOOTSTRAP_USERNAME = 'bell_138_admin'
$env:BELL_BOOTSTRAP_PASSWORD = [guid]::NewGuid().ToString('N')
$env:BELL_HOST = '127.0.0.1'
$env:BELL_PORT = (Get-FreePort).ToString()
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
Push-Location $serverRoot
try {
go run . migrate -c config/settings.yml *> (Join-Path $root 'migrate.log')
if ($LASTEXITCODE -ne 0) { throw "migration failed; evidence: $root" }
go test ./tests/bell_production_login -count=1 -v
if ($LASTEXITCODE -ne 0) { throw 'production login test failed' }
} finally {
Pop-Location
}
Write-Output 'BELL_138_PRODUCTION_LOGIN captcha=200 valid_login=200 wrong_rejected=true replay_rejected=true secrets_absent=true'
} finally {
if ($started) {
& (Join-Path $PostgresBin 'pg_ctl.exe') -D $data -m fast stop *> (Join-Path $root 'stop.log')
}
foreach ($name in @('BELL_DATABASE_URL', 'BELL_PRODUCTION_LOGIN_TEST_DATABASE_URL', 'BELL_JWT_SECRET', 'BELL_BOOTSTRAP_USERNAME', 'BELL_BOOTSTRAP_PASSWORD', 'BELL_HOST', 'BELL_PORT')) {
Remove-Item "Env:$name" -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath (Join-Path $PSScriptRoot 'temp') -Recurse -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,214 @@
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, ruleWritePolicyCount, lifecycleWritePolicyCount 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 <> ? AND v1 LIKE ?", "operator", "GET", "/api/v1/bell/rules%").Count(&ruleWritePolicyCount).Error; err != nil {
t.Fatal(err)
}
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ? AND v1 IN ?", "operator", "POST", []string{"/api/v1/bell/alerts/:id/ack", "/api/v1/bell/alerts/:id/close"}).Count(&lifecycleWritePolicyCount).Error; err != nil {
t.Fatal(err)
}
if menuCount != 4 || readPolicyCount < 6 || ruleWritePolicyCount != 0 || lifecycleWritePolicyCount > 2 {
t.Fatalf("operator access escaped Bell scope: menus=%d reads=%d rule_writes=%d lifecycle_writes=%d", menuCount, readPolicyCount, ruleWritePolicyCount, lifecycleWritePolicyCount)
}
}
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)
}
}
@@ -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"
}
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
import request from '@/utils/request'
export function getAlertLifecycle(id) { return request({ url: `/api/v1/bell/alerts/${id}/lifecycle`, method: 'get' }) }
export function acknowledgeAlert(id) { return request({ url: `/api/v1/bell/alerts/${id}/ack`, method: 'post' }) }
export function closeAlert(id, data) { return request({ url: `/api/v1/bell/alerts/${id}/close`, method: 'post', data }) }
+9
View File
@@ -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' })
}
+13
View File
@@ -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' })
}
+17
View File
@@ -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 }})
}
@@ -15,7 +15,7 @@
<script>
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
import { mapGetters } from 'vuex'
export default {
@@ -29,7 +29,7 @@
import { mapGetters } from 'vuex'
import Logo from './Logo'
import SidebarItem from './SidebarItem'
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
export default {
components: { SidebarItem, Logo },
+1 -1
View File
@@ -20,7 +20,7 @@ import RightPanel from '@/components/RightPanel'
import { AppMain, Navbar, Settings, Sidebar, TagsView } from './components'
import ResizeMixin from './mixin/ResizeHandler'
import { mapState } from 'vuex'
import variables from '@/styles/variables.scss'
import variables from '@/styles/variables.scss?module'
export default {
name: 'MainLayout',
+1 -1
View File
@@ -1,4 +1,4 @@
import variables from '@/styles/element-variables.scss'
import variables from '@/styles/element-variables.scss?module'
import defaultSettings from '@/settings'
const { showSettings, topNav, tagsView, fixedHeader, sidebarLogo, themeStyle } = defaultSettings
@@ -0,0 +1,28 @@
<template>
<div class="lifecycle-actions">
<el-alert v-if="error" :title="error" type="warning" show-icon :closable="false" />
<el-button v-if="lifecycle.canAck" v-permisaction="['bell:alert:ack']" type="primary" :loading="loading" @click="ack">我已看到并开始处理</el-button>
<el-button v-if="lifecycle.canClose" v-permisaction="['bell:alert:close']" type="primary" :loading="loading" @click="dialog=true">记录现场结果并完成</el-button>
<el-dialog v-model="dialog" title="记录现场结果" width="min(520px, calc(100vw - 32px))" append-to-body :close-on-click-modal="false" @closed="reset">
<el-alert title="完成后预警进入已完成状态,原始事件不会被修改。" type="info" :closable="false" class="form-alert" />
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
<el-form-item label="现场结果" prop="outcome"><el-radio-group v-model="form.outcome" class="outcome-group"><el-radio value="danger_confirmed">确认有危险</el-radio><el-radio value="false_positive">误报</el-radio><el-radio value="site_normal">现场正常</el-radio><el-radio value="unable_to_confirm">无法确认</el-radio></el-radio-group></el-form-item>
<el-form-item label="补充说明(可选)"><el-input v-model="form.note" type="textarea" :rows="3" maxlength="500" show-word-limit /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="loading" @click="finish">确认结果并完成</el-button></template>
</el-dialog>
</div>
</template>
<script>
import { acknowledgeAlert, closeAlert } from '@/api/bell/alert-lifecycle'
export default {
name: 'BellLifecycleActions', props: { alertId: { type: String, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed'],
data() { return { loading: false, error: '', dialog: false, form: { outcome: '', note: '' }, rules: { outcome: [{ required: true, message: '请选择现场结果', trigger: 'change' }] }} },
methods: {
async ack() { this.loading = true; this.error = ''; try { const r = await acknowledgeAlert(this.alertId); this.msgSuccess(r.data.idempotent ? '您已在处理此预警' : '已记录由您开始处理'); this.$emit('changed') } catch (e) { this.error = e.message || '开始处理失败'; this.$emit('changed') } finally { this.loading = false } },
async finish() { try { await this.$refs.formRef.validate(); this.loading = true; this.error = ''; const r = await closeAlert(this.alertId, this.form); this.msgSuccess(r.data.idempotent ? '该结果已记录' : '预警已完成'); this.dialog = false; this.$emit('changed') } catch (e) { if (e && e.message) this.error = e.message } finally { this.loading = false } },
reset() { this.form = { outcome: '', note: '' }; this.$refs.formRef && this.$refs.formRef.clearValidate() }
}
}
</script>
<style scoped>.lifecycle-actions{display:flex;flex-wrap:wrap;gap:12px;margin:16px 0}.lifecycle-actions .el-alert{flex-basis:100%}.form-alert{margin-bottom:16px}.outcome-group{display:grid;gap:10px}</style>
@@ -0,0 +1,2 @@
<template><el-timeline><el-timeline-item v-for="item in items" :key="item.id" :timestamp="parseTime(item.occurredAt)" :type="item.transition==='closed'?'success':'primary'"><strong>{{ item.transition === 'closed' ? '处理完成' : '开始处理' }}</strong> · {{ item.actorName }}<div v-if="item.outcome">现场结果:{{ outcomeName(item.outcome) }}<span v-if="item.note">;{{ item.note }}</span></div></el-timeline-item><el-empty v-if="!items.length" description="尚无处理记录" /></el-timeline></template>
<script>export default { name: 'BellLifecycleTimeline', props: { items: { type: Array, default: () => [] }}, methods: { outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
+3
View File
@@ -0,0 +1,3 @@
<template><div><el-descriptions :column="1" border><el-descriptions-item label="发生事项">{{ detail.alert.summary }}</el-descriptions-item><el-descriptions-item label="地点">{{ detail.alert.location }}</el-descriptions-item><el-descriptions-item label="紧急程度">{{ severityName(detail.alert.severity) }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusName(lifecycle.projection.status || detail.alert.status) }}</el-descriptions-item><el-descriptions-item label="处理人">{{ lifecycle.projection.acknowledgedByName || '尚未开始处理' }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeOutcome" label="现场结果">{{ outcomeName(lifecycle.projection.closeOutcome) }}</el-descriptions-item><el-descriptions-item v-if="lifecycle.projection.closeNote" label="处理说明">{{ lifecycle.projection.closeNote }}</el-descriptions-item><el-descriptions-item label="命中规则">{{ detail.alert.ruleName }}</el-descriptions-item><el-descriptions-item label="预警编号">{{ detail.alert.id }}</el-descriptions-item></el-descriptions><LifecycleActions :alert-id="detail.alert.id" :lifecycle="lifecycle" @changed="$emit('changed')" /><h3>关联事件</h3><el-table :data="detail.events" border row-key="id"><el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column><el-table-column prop="eventType" label="事件类型" min-width="150" /><el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="$emit('go-event',scope.row.id)">查看</el-button></template></el-table-column></el-table><h3>处理时间线</h3><LifecycleTimeline :items="lifecycle.timeline" /><h3>命中说明</h3><el-timeline><el-timeline-item v-for="match in detail.matches" :key="`${match.eventId}-${match.ruleId}`" :timestamp="parseTime(match.matchedAt)" type="primary">规则 v{{ match.ruleVersion }}:{{ match.explanation }}</el-timeline-item></el-timeline></div></template>
<script>import LifecycleActions from './components/LifecycleActions.vue'; import LifecycleTimeline from './components/LifecycleTimeline.vue'; export default { name: 'BellAlertDetail', components: { LifecycleActions, LifecycleTimeline }, props: { detail: { type: Object, required: true }, lifecycle: { type: Object, required: true }}, emits: ['changed', 'go-event'], methods: { statusName(v) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[v] || v }, severityName(v) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[v] || v }, outcomeName(v) { return { danger_confirmed: '确认有危险', false_positive: '误报', site_normal: '现场正常', unable_to_confirm: '无法确认' }[v] || v } }}</script>
<style scoped>h3{font-size:16px;margin:22px 0 10px}</style>
+76
View File
@@ -0,0 +1,76 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><h2>预警管理</h2><p>先开始处理,再记录现场结果完成预警。</p></div></template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="状态" prop="status"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:130px"><el-option label="待处理" value="open" /><el-option label="处理中" value="acknowledged" /><el-option label="已完成" value="closed" /></el-select></el-form-item>
<el-form-item label="风险" prop="severity"><el-select v-model="query.severity" clearable placeholder="全部风险" style="width:130px"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
<el-table-column prop="createdAt" label="创建时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.createdAt) }}</template></el-table-column>
<el-table-column prop="summary" label="预警事项" min-width="200" show-overflow-tooltip />
<el-table-column prop="location" label="地点" min-width="140" show-overflow-tooltip />
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column label="状态" width="90"><template #default="scope"><el-tag :type="statusType(scope.row.status)">{{ statusName(scope.row.status) }}</el-tag></template></el-table-column>
<el-table-column prop="eventCount" label="关联事件" width="100" />
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></template></el-table-column>
<template #empty><el-empty description="暂无预警" /></template>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-drawer v-model="drawer" title="预警详情" size="min(720px, 100%)">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
<BellAlertDetail v-if="detail && lifecycle" :detail="detail" :lifecycle="lifecycle" @changed="reloadDetail" @go-event="goEvent" />
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { getAlert, listAlerts } from '@/api/bell/alert'
import { getAlertLifecycle } from '@/api/bell/alert-lifecycle'
import BellAlertDetail from './detail.vue'
export default {
name: 'BellAlerts',
components: { BellAlertDetail },
data() {
return {
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
drawer: false, detail: null, lifecycle: null, activeId: '',
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, status: '', severity: '', location: '' }
}
},
created() { this.load().then(() => { if (this.$route.query.alertId) this.openDetail({ id: this.$route.query.alertId }) }) },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listAlerts(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '预警加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.search() },
async openDetail(row) {
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.detail = null; this.lifecycle = null; this.activeId = row.id
try { const [detailResponse, lifecycleResponse] = await Promise.all([getAlert(row.id), getAlertLifecycle(row.id)]); this.detail = detailResponse.data; this.lifecycle = lifecycleResponse.data.detail } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
},
async reloadDetail() { await this.openDetail({ id: this.activeId }); await this.load() },
goEvent(id) { this.drawer = false; this.$router.push({ path: '/bell/events', query: { eventId: id }}) },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' },
statusName(value) { return { open: '待处理', acknowledged: '处理中', closed: '已完成' }[value] || value },
statusType(value) { return { open: 'danger', acknowledged: 'warning', closed: 'success' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
</style>
+103
View File
@@ -0,0 +1,103 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header>
<div class="page-heading">
<div><h2>事件查询</h2><p>原始事件只读保存,规则评估和预警不会改写事件。</p></div>
</div>
</template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="事件类型" prop="eventType"><el-input v-model="query.eventType" clearable placeholder="请输入事件类型" @keyup.enter="search" /></el-form-item>
<el-form-item label="地点" prop="location"><el-input v-model="query.location" clearable placeholder="请输入地点" @keyup.enter="search" /></el-form-item>
<el-form-item label="风险" prop="severity">
<el-select v-model="query.severity" clearable placeholder="全部风险" style="width: 130px">
<el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
<el-table v-loading="loading" :data="items" border row-key="id" @row-dblclick="openDetail">
<el-table-column prop="occurredAt" label="发生时间" min-width="180"><template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template></el-table-column>
<el-table-column prop="eventType" label="事件类型" min-width="160" show-overflow-tooltip />
<el-table-column prop="location" label="地点" min-width="150" show-overflow-tooltip />
<el-table-column label="风险" width="90"><template #default="scope"><el-tag :type="severityType(scope.row.severity)">{{ severityName(scope.row.severity) }}</el-tag></template></el-table-column>
<el-table-column prop="alertCount" label="关联预警" width="100" />
<el-table-column label="操作" width="90"><template #default="scope"><el-button type="primary" link @click="openDetail(scope.row)">详情</el-button></template></el-table-column>
<template #empty><el-empty description="暂无事件" /></template>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-drawer v-model="drawer" title="事件详情" size="min(680px, 100%)">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" show-icon :closable="false" class="state-alert" />
<template v-if="selected">
<el-descriptions :column="1" border>
<el-descriptions-item label="发生事项">{{ selected.eventType }}</el-descriptions-item>
<el-descriptions-item label="地点">{{ selected.location }}</el-descriptions-item>
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
<el-descriptions-item label="紧急程度">{{ severityName(selected.severity) }}</el-descriptions-item>
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
<el-descriptions-item label="事件来源">{{ selected.producerId }}</el-descriptions-item>
<el-descriptions-item label="来源事件编号">{{ selected.sourceEventId }}</el-descriptions-item>
<el-descriptions-item label="证据引用">{{ selected.evidenceRef || '无' }}</el-descriptions-item>
</el-descriptions>
<h3>关联预警</h3>
<el-table :data="results.alerts" border row-key="id">
<el-table-column prop="summary" label="预警事项" min-width="180" />
<el-table-column prop="status" label="状态" width="90"><template #default><el-tag type="danger">待处理</el-tag></template></el-table-column>
<el-table-column label="操作" width="80"><template #default="scope"><el-button link type="primary" @click="goAlert(scope.row.id)">查看</el-button></template></el-table-column>
<template #empty><el-empty description="该事件未生成预警" /></template>
</el-table>
<h3>规则评估</h3>
<el-table :data="results.evaluations" border row-key="ruleId">
<el-table-column label="规则" min-width="160"><template #default="scope">{{ scope.row.ruleSnapshot && scope.row.ruleSnapshot.name }}</template></el-table-column>
<el-table-column prop="ruleVersion" label="版本" width="70" />
<el-table-column label="结果" width="90"><template #default="scope"><el-tag :type="scope.row.matched ? 'success' : 'info'">{{ scope.row.matched ? '命中' : '未命中' }}</el-tag></template></el-table-column>
<el-table-column prop="explanation" label="说明" min-width="180" />
<template #empty><el-empty description="接收时没有启用规则" /></template>
</el-table>
</template>
</div>
</el-drawer>
</template>
</BasicLayout>
</template>
<script>
import { getEvent, getEventRuleResults, listEvents } from '@/api/bell/event'
export default {
name: 'BellEvents',
data() {
return {
loading: false, detailLoading: false, error: '', detailError: '', items: [], total: 0,
drawer: false, selected: null, results: { alerts: [], evaluations: [] },
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, eventType: '', location: '', severity: '' }
}
},
created() { this.load().then(() => { if (this.$route.query.eventId) this.openDetail({ id: this.$route.query.eventId }) }) },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listEvents(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '事件加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.search() },
async openDetail(row) {
this.drawer = true; this.detailLoading = true; this.detailError = ''; this.selected = null
try { const [eventResponse, resultResponse] = await Promise.all([getEvent(row.id), getEventRuleResults(row.id)]); this.selected = eventResponse.data; this.results = resultResponse.data } catch (error) { this.detailError = error.message || '事件详情加载失败' } finally { this.detailLoading = false }
},
goAlert(id) { this.drawer = false; this.$router.push({ path: '/bell/alerts', query: { alertId: id }}) },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}h3{font-size:16px;margin:22px 0 10px}
</style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<template #header><div class="page-heading"><div><h2>规则配置</h2><p>规则修改会产生新版本,已有事件的命中快照不会改变。</p></div><el-button v-permisaction="['bell:rule:add']" type="primary" @click="openCreate">新增规则</el-button></div></template>
<el-form ref="queryForm" :model="query" :inline="true">
<el-form-item label="规则" prop="name"><el-input v-model="query.name" clearable placeholder="名称或编码" @keyup.enter="search" /></el-form-item>
<el-form-item label="状态" prop="enabled"><el-select v-model="query.enabled" clearable placeholder="全部状态" style="width:130px"><el-option label="已启用" :value="true" /><el-option label="已停用" :value="false" /></el-select></el-form-item>
<el-form-item><el-button type="primary" @click="search">搜索</el-button><el-button @click="reset">重置</el-button></el-form-item>
</el-form>
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false" class="state-alert" />
<el-table v-loading="loading" :data="items" border row-key="id">
<el-table-column prop="name" label="规则名称" min-width="170" />
<el-table-column prop="code" label="规则编码" min-width="150" />
<el-table-column label="事件类型" min-width="150"><template #default="scope">{{ scope.row.eventType || '全部' }}</template></el-table-column>
<el-table-column label="最低风险" width="100"><template #default="scope"><el-tag :type="severityType(scope.row.minimumSeverity)">{{ severityName(scope.row.minimumSeverity) }}</el-tag></template></el-table-column>
<el-table-column label="地点包含" min-width="130"><template #default="scope">{{ scope.row.locationContains || '不限' }}</template></el-table-column>
<el-table-column prop="version" label="版本" width="70" />
<el-table-column label="状态" width="100"><template #default="scope"><el-switch v-model="scope.row.enabled" :disabled="!canEdit" inline-prompt active-text="启" inactive-text="停" @change="toggle(scope.row)" /></template></el-table-column>
<el-table-column label="操作" width="90"><template #default="scope"><el-button v-permisaction="['bell:rule:edit']" link type="primary" @click="openEdit(scope.row)">编辑</el-button></template></el-table-column>
<template #empty><el-empty description="暂无规则" /></template>
</el-table>
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="load" />
</el-card>
<el-dialog v-model="dialog" :title="editing ? '编辑规则' : '新增规则'" width="min(560px, calc(100vw - 32px))" :close-on-click-modal="false" @closed="clearForm">
<el-alert title="保存后仅影响随后接收的事件,历史命中记录保持原样。" type="info" :closable="false" class="state-alert" />
<el-form ref="ruleForm" :model="form" :rules="formRules" label-position="top">
<el-form-item label="规则编码" prop="code"><el-input v-model.trim="form.code" :disabled="editing" placeholder="如 gate-danger" /></el-form-item>
<el-form-item label="规则名称" prop="name"><el-input v-model.trim="form.name" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="事件类型"><el-input v-model.trim="form.eventType" placeholder="留空表示全部类型" maxlength="128" /></el-form-item>
<el-form-item label="最低风险" prop="minimumSeverity"><el-select v-model="form.minimumSeverity" style="width:100%"><el-option v-for="item in severities" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item label="地点包含"><el-input v-model.trim="form.locationContains" placeholder="留空表示不限地点" maxlength="128" /></el-form-item>
</el-form>
<template #footer><el-button @click="dialog=false">取消</el-button><el-button type="primary" :loading="saving" @click="save">保存</el-button></template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { createRule, listRules, setRuleEnabled, updateRule } from '@/api/bell/rule'
export default {
name: 'BellRules',
data() {
return {
loading: false, saving: false, error: '', items: [], total: 0, dialog: false, editing: false, editingId: '',
severities: [{ label: '低', value: 'low' }, { label: '中', value: 'medium' }, { label: '高', value: 'high' }, { label: '紧急', value: 'critical' }],
query: { pageIndex: 1, pageSize: 10, name: '', enabled: null },
form: { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' },
formRules: {
code: [{ required: true, pattern: /^[a-z0-9][a-z0-9_-]{1,127}$/, message: '请输入至少2位小写字母、数字、下划线或短横线', trigger: 'blur' }],
name: [{ required: true, message: '请输入规则名称', trigger: 'blur' }],
minimumSeverity: [{ required: true, message: '请选择最低风险', trigger: 'change' }]
}
}
},
computed: {
canEdit() { const values = this.$store.getters.permisaction || []; return values.includes('*:*:*') || values.includes('bell:rule:edit') }
},
created() { this.load() },
methods: {
async load() {
this.loading = true; this.error = ''
try { const response = await listRules(this.query); this.items = response.data.list || []; this.total = response.data.count || 0 } catch (error) { this.error = error.message || '规则加载失败' } finally { this.loading = false }
},
search() { this.query.pageIndex = 1; this.load() },
reset() { this.$refs.queryForm.resetFields(); this.query.enabled = null; this.search() },
openCreate() { this.editing = false; this.editingId = ''; this.dialog = true },
openEdit(row) {
this.editing = true; this.editingId = row.id
this.form = { code: row.code, name: row.name, eventType: row.eventType || '', minimumSeverity: row.minimumSeverity, locationContains: row.locationContains || '' }
this.dialog = true
},
async save() {
try {
await this.$refs.ruleForm.validate(); this.saving = true
const payload = { ...this.form, eventType: this.form.eventType || null, locationContains: this.form.locationContains || null }
if (this.editing) await updateRule(this.editingId, payload); else await createRule(payload)
this.msgSuccess(this.editing ? '规则已更新并生成新版本' : '规则已创建'); this.dialog = false; await this.load()
} catch (error) { if (error && error.message) this.error = error.message } finally { this.saving = false }
},
async toggle(row) {
try { await setRuleEnabled(row.id, row.enabled); this.msgSuccess(row.enabled ? '规则已启用' : '规则已停用'); await this.load() } catch (error) { row.enabled = !row.enabled; this.error = error.message || '规则状态更新失败' }
},
clearForm() { this.$refs.ruleForm && this.$refs.ruleForm.clearValidate(); this.form = { code: '', name: '', eventType: '', minimumSeverity: 'high', locationContains: '' } },
severityName(value) { return { low: '低', medium: '中', high: '高', critical: '紧急' }[value] || value },
severityType(value) { return { low: 'info', medium: 'primary', high: 'warning', critical: 'danger' }[value] || 'info' }
}
}
</script>
<style scoped>
.page-heading{display:flex;align-items:center;justify-content:space-between;gap:16px}.page-heading h2{margin:0}.page-heading p{margin:6px 0 0;color:var(--el-text-color-secondary)}.state-alert{margin-bottom:16px}
</style>
@@ -0,0 +1,14 @@
import request from '@/utils/request'
import { acknowledgeAlert, closeAlert, getAlertLifecycle } from '@/api/bell/alert-lifecycle'
jest.mock('@/utils/request', () => jest.fn(config => Promise.resolve(config)))
describe('Bell alert lifecycle API', () => {
beforeEach(() => request.mockClear())
it('maps timeline, ack and close to protected Alert routes', async() => {
await getAlertLifecycle('a1'); await acknowledgeAlert('a1'); await closeAlert('a1', { outcome: 'site_normal' })
expect(request.mock.calls.map(call => call[0])).toEqual([
{ url: '/api/v1/bell/alerts/a1/lifecycle', method: 'get' },
{ url: '/api/v1/bell/alerts/a1/ack', method: 'post' },
{ url: '/api/v1/bell/alerts/a1/close', method: 'post', data: { outcome: 'site_normal' }}
])
})
})
@@ -0,0 +1,17 @@
jest.mock('@/api/bell/alert-lifecycle', () => ({
acknowledgeAlert: jest.fn(),
closeAlert: jest.fn(),
getAlertLifecycle: jest.fn()
}))
import AlertDetail from '@/views/bell/alerts/detail.vue'
import LifecycleTimeline from '@/views/bell/alerts/components/LifecycleTimeline.vue'
describe('Bell ordinary-user lifecycle wording', () => {
it('maps technical states and outcomes to familiar wording', () => {
expect(AlertDetail.methods.statusName('open')).toBe('待处理')
expect(AlertDetail.methods.statusName('acknowledged')).toBe('处理中')
expect(AlertDetail.methods.statusName('closed')).toBe('已完成')
expect(AlertDetail.methods.outcomeName('false_positive')).toBe('误报')
expect(LifecycleTimeline.methods.outcomeName('unable_to_confirm')).toBe('无法确认')
})
})
@@ -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 }}
])
})
})
@@ -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('网络不可用')
})
})
@@ -0,0 +1,16 @@
const fs = require('fs')
const path = require('path')
const valueImports = [
['src/store/modules/settings.js', "@/styles/element-variables.scss?module"],
['src/layout/index.vue', "@/styles/variables.scss?module"],
['src/layout/components/Sidebar/Logo.vue', "@/styles/variables.scss?module"],
['src/layout/components/Sidebar/index.vue', "@/styles/variables.scss?module"]
]
describe('GoAdmin shell Sass value imports', () => {
it.each(valueImports)('%s explicitly requests CSS Modules exports', (file, request) => {
const source = fs.readFileSync(path.join(__dirname, '../../..', file), 'utf8')
expect(source).toContain(`from '${request}'`)
})
})