[BEL] 重建预警认领、结案与审计时间线 (#133) #137
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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,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) {}
|
||||
@@ -177,7 +177,7 @@ func assertCount(t *testing.T, db *gorm.DB, table string, want int64) {
|
||||
|
||||
func assertOperatorAccess(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
var menuCount, readPolicyCount, writePolicyCount int64
|
||||
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 {
|
||||
@@ -186,11 +186,14 @@ func assertOperatorAccess(t *testing.T, db *gorm.DB) {
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 = ?", "operator", "GET").Count(&readPolicyCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table("casbin_rule").Where("v0 = ? AND v2 <> ?", "operator", "GET").Count(&writePolicyCount).Error; err != nil {
|
||||
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 menuCount != 4 || readPolicyCount != 6 || writePolicyCount != 0 {
|
||||
t.Fatalf("operator access is not read-only and minimal: menus=%d reads=%d writes=%d", menuCount, readPolicyCount, writePolicyCount)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) }
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -2,9 +2,9 @@
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<template #header><div class="page-heading"><h2>预警管理</h2><p>查看待处理预警及其关联事件;开始处理和完成操作将在下一阶段提供。</p></div></template>
|
||||
<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-select></el-form-item>
|
||||
<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>
|
||||
@@ -15,7 +15,7 @@
|
||||
<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><el-tag type="danger">待处理</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>
|
||||
@@ -26,28 +26,7 @@
|
||||
<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" />
|
||||
<template v-if="detail">
|
||||
<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="状态">待处理</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>
|
||||
<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="goEvent(scope.row.id)">查看</el-button></template></el-table-column>
|
||||
</el-table>
|
||||
<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>
|
||||
</template>
|
||||
<BellAlertDetail v-if="detail && lifecycle" :detail="detail" :lifecycle="lifecycle" @changed="reloadDetail" @go-event="goEvent" />
|
||||
</div>
|
||||
</el-drawer>
|
||||
</template>
|
||||
@@ -56,13 +35,16 @@
|
||||
|
||||
<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,
|
||||
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: '' }
|
||||
}
|
||||
@@ -76,12 +58,15 @@ export default {
|
||||
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
|
||||
try { const response = await getAlert(row.id); this.detail = response.data } catch (error) { this.detailError = error.message || '预警详情加载失败' } finally { this.detailLoading = false }
|
||||
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' }
|
||||
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>
|
||||
|
||||
@@ -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('无法确认')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user