[BEL] 重建预警认领、结案与审计时间线 (#133) #137

Merged
ila merged 1 commits from feature/133-bell-alert-lifecycle into dev 2026-08-29 11:56:58 +08:00
19 changed files with 796 additions and 34 deletions
@@ -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)
}
}
+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,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)
}
}
+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 }) }
@@ -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>
+14 -29
View File
@@ -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('无法确认')
})
})