[SEN] 实现无 Bell 场景的本地事件候选查看 (#73) #117
@@ -0,0 +1,19 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||
)
|
||||
|
||||
func init() { routerCheckRole = append(routerCheckRole, registerSenseLocalEventRouter) }
|
||||
|
||||
func registerSenseLocalEventRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &local_event.API{}
|
||||
r := v1.Group("/local-events").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("", api.List)
|
||||
r.GET("/:id", api.Get)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||
)
|
||||
|
||||
func TestLocalEventRouterIsReadOnly(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
registerSenseLocalEventRouter(engine.Group("/api/v1"), &jwt.GinJWTMiddleware{})
|
||||
|
||||
routes := make(map[string]struct{})
|
||||
for _, route := range engine.Routes() {
|
||||
routes[route.Method+" "+route.Path] = struct{}{}
|
||||
}
|
||||
for _, expected := range []string{"GET /api/v1/local-events", "GET /api/v1/local-events/:id"} {
|
||||
if _, ok := routes[expected]; !ok {
|
||||
t.Fatalf("missing local event route: %s", expected)
|
||||
}
|
||||
}
|
||||
for route := range routes {
|
||||
if route != "GET /api/v1/local-events" && route != "GET /api/v1/local-events/:id" {
|
||||
t.Fatalf("unexpected mutable local event route: %s", route)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/common"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
service := &Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
request := &PageRequest{}
|
||||
if err = e.MakeContext(c).Bind(request).Errors; err != nil {
|
||||
e.audit(c, service, "List", auditFailure, "本地事件查询条件格式不正确")
|
||||
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||
return
|
||||
}
|
||||
list, count, err := service.List(request)
|
||||
if err != nil {
|
||||
e.audit(c, service, "List", auditFailure, "本地事件列表查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "List", auditSuccess, "读取本地事件列表")
|
||||
e.PageOK(list, int(count), request.GetPageIndex(), request.GetPageSize(), "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
item, err := service.Get(c.Param("id"))
|
||||
if err != nil {
|
||||
e.audit(c, service, "Get", auditFailure, "本地事件详情查询失败")
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.audit(c, service, "Get", auditSuccess, "读取本地事件详情 "+item.ID)
|
||||
e.OK(item, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) audit(c *gin.Context, service *Service, action, status, remark string) {
|
||||
err := WriteAccessAudit(service.Orm, AccessAudit{
|
||||
Action: action, Status: status, Username: user.GetUserName(c), UserID: user.GetUserId(c),
|
||||
ClientIP: common.GetClientIP(c), Route: c.FullPath(), Remark: remark, At: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
api.GetRequestLogger(c).Errorf("local event access audit failed: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidFilter):
|
||||
e.Error(http.StatusBadRequest, err, ErrInvalidFilter.Error())
|
||||
case errors.Is(err, ErrNotFound):
|
||||
e.Error(http.StatusNotFound, err, ErrNotFound.Error())
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "本地事件查询失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
)
|
||||
|
||||
const (
|
||||
auditSuccess = "1"
|
||||
auditFailure = "2"
|
||||
)
|
||||
|
||||
type AccessAudit struct {
|
||||
Action string
|
||||
Status string
|
||||
Username string
|
||||
UserID int
|
||||
ClientIP string
|
||||
Route string
|
||||
Remark string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
// WriteAccessAudit persists a minimal, synchronous GoAdmin operation record.
|
||||
// It deliberately excludes query values, response bodies and evidence data.
|
||||
func WriteAccessAudit(db *gorm.DB, input AccessAudit) error {
|
||||
model := adminModels.SysOperaLog{
|
||||
Title: "本地事件", BusinessType: "query", Method: "local_event.API." + input.Action,
|
||||
RequestMethod: "GET", OperatorType: "1", OperName: input.Username, OperUrl: input.Route,
|
||||
OperIp: input.ClientIP, Status: input.Status, OperTime: input.At.UTC(), Remark: input.Remark,
|
||||
CreatedAt: input.At.UTC(), UpdatedAt: input.At.UTC(),
|
||||
}
|
||||
model.CreateBy, model.UpdateBy = input.UserID, input.UserID
|
||||
return db.Create(&model).Error
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
type PageRequest struct {
|
||||
commonDTO.Pagination `search:"-"`
|
||||
CandidateState string `form:"candidateState"`
|
||||
EvidenceState string `form:"evidenceState"`
|
||||
RuleRef string `form:"ruleRef"`
|
||||
Keyword string `form:"keyword"`
|
||||
OccurredAfter string `form:"occurredAfter"`
|
||||
OccurredBefore string `form:"occurredBefore"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
ID string `json:"id"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
SourceRef string `json:"sourceRef"`
|
||||
SourceLabel string `json:"sourceLabel"`
|
||||
RuleRef string `json:"ruleRef"`
|
||||
RuleName string `json:"ruleName"`
|
||||
RuleVersion string `json:"ruleVersion"`
|
||||
ZoneRef string `json:"zoneRef"`
|
||||
CandidateState string `json:"candidateState"`
|
||||
EvidenceState string `json:"evidenceState"`
|
||||
EvidenceRef string `json:"evidenceRef,omitempty"`
|
||||
EvidenceDetail string `json:"evidenceDetail,omitempty"`
|
||||
RetainUntil time.Time `json:"retainUntil"`
|
||||
RetentionState string `json:"retentionState"`
|
||||
RemainingDays int `json:"remainingDays"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// SeedSyntheticFixture explicitly installs anonymous development/test data.
|
||||
// Production startup never calls this function.
|
||||
func SeedSyntheticFixture(db *gorm.DB, now time.Time) error {
|
||||
now = now.UTC().Truncate(time.Second)
|
||||
items := []EventCandidate{
|
||||
{ID: "00000000-0000-4000-8000-000000000001", OccurredAt: now.Add(-18 * time.Minute), SourceRef: "SEN-CAM-03", SourceLabel: "东门通道", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EAST-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateSuccess, EvidenceRef: "local-evidence/fixture-001", EvidenceDetail: "已生成事件截图与短片索引", RetainUntil: now.Add(12 * 24 * time.Hour)},
|
||||
{ID: "00000000-0000-4000-8000-000000000002", OccurredAt: now.Add(-23 * time.Minute), SourceRef: "SEN-CAM-08", SourceLabel: "仓库北区", RuleRef: "rule-person-stay", RuleName: "人员滞留", RuleVersion: "v2", ZoneRef: "ZONE-WAREHOUSE-02", CandidateState: CandidateStateConfirmed, EvidenceState: EvidenceStatePending, EvidenceDetail: "证据短片正在生成", RetainUntil: now.Add(12 * 24 * time.Hour)},
|
||||
{ID: "00000000-0000-4000-8000-000000000003", OccurredAt: now.Add(-31 * time.Minute), SourceRef: "SEN-CAM-11", SourceLabel: "设备间入口", RuleRef: "rule-zone-intrusion", RuleName: "区域闯入", RuleVersion: "v3", ZoneRef: "ZONE-EQUIPMENT-01", CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateFailed, EvidenceDetail: "源视频片段暂不可用", RetainUntil: now.Add(2 * 24 * time.Hour)},
|
||||
}
|
||||
return db.Clauses(clause.OnConflict{DoNothing: true}).Create(&items).Error
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
const (
|
||||
CandidateStateCandidate = "candidate"
|
||||
CandidateStateConfirmed = "confirmed"
|
||||
|
||||
EvidenceStatePending = "pending"
|
||||
EvidenceStateSuccess = "success"
|
||||
EvidenceStateFailed = "failed"
|
||||
)
|
||||
|
||||
// EventCandidate is a Sense-internal, anonymous event candidate. It is not a
|
||||
// cross-product event contract and does not represent a Bell alert.
|
||||
type EventCandidate struct {
|
||||
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||
OccurredAt time.Time `gorm:"not null;index" json:"occurredAt"`
|
||||
SourceRef string `gorm:"size:128;not null;index" json:"sourceRef"`
|
||||
SourceLabel string `gorm:"size:128;not null;default:''" json:"sourceLabel"`
|
||||
RuleRef string `gorm:"size:128;not null;index" json:"ruleRef"`
|
||||
RuleName string `gorm:"size:128;not null" json:"ruleName"`
|
||||
RuleVersion string `gorm:"size:64;not null;default:''" json:"ruleVersion"`
|
||||
ZoneRef string `gorm:"size:128;not null;default:''" json:"zoneRef"`
|
||||
CandidateState string `gorm:"size:32;not null;index" json:"candidateState"`
|
||||
EvidenceState string `gorm:"size:32;not null;index" json:"evidenceState"`
|
||||
EvidenceRef string `gorm:"size:512;not null;default:''" json:"evidenceRef,omitempty"`
|
||||
EvidenceDetail string `gorm:"size:512;not null;default:''" json:"evidenceDetail,omitempty"`
|
||||
RetainUntil time.Time `gorm:"not null;index" json:"retainUntil"`
|
||||
common.ControlBy
|
||||
common.ModelTime
|
||||
}
|
||||
|
||||
func (EventCandidate) TableName() string { return "sense_local_event_candidates" }
|
||||
@@ -0,0 +1,145 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidFilter = errors.New("本地事件查询条件不符合要求")
|
||||
ErrNotFound = errors.New("本地事件候选不存在")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
coreService.Service
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
func (s *Service) List(request *PageRequest) ([]Response, int64, error) {
|
||||
filter, err := normalizeFilter(request)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
query := s.Orm.Model(&EventCandidate{})
|
||||
if filter.CandidateState != "" {
|
||||
query = query.Where("candidate_state = ?", filter.CandidateState)
|
||||
}
|
||||
if filter.EvidenceState != "" {
|
||||
query = query.Where("evidence_state = ?", filter.EvidenceState)
|
||||
}
|
||||
if filter.RuleRef != "" {
|
||||
query = query.Where("rule_ref = ?", filter.RuleRef)
|
||||
}
|
||||
if filter.occurredAfter != nil {
|
||||
query = query.Where("occurred_at >= ?", *filter.occurredAfter)
|
||||
}
|
||||
if filter.occurredBefore != nil {
|
||||
query = query.Where("occurred_at <= ?", *filter.occurredBefore)
|
||||
}
|
||||
if filter.Keyword != "" {
|
||||
pattern := "%" + strings.ToLower(filter.Keyword) + "%"
|
||||
query = query.Where("LOWER(id) LIKE ? OR LOWER(source_ref) LIKE ? OR LOWER(source_label) LIKE ? OR LOWER(rule_name) LIKE ?", pattern, pattern, pattern, pattern)
|
||||
}
|
||||
var count int64
|
||||
if err = query.Count(&count).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("count local event candidates: %w", err)
|
||||
}
|
||||
var models []EventCandidate
|
||||
if err = query.Order("occurred_at DESC, id DESC").Limit(filter.GetPageSize()).Offset((filter.GetPageIndex() - 1) * filter.GetPageSize()).Find(&models).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("list local event candidates: %w", err)
|
||||
}
|
||||
result := make([]Response, 0, len(models))
|
||||
for _, model := range models {
|
||||
result = append(result, s.response(model))
|
||||
}
|
||||
return result, count, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(id string) (Response, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" || len(id) > 64 {
|
||||
return Response{}, ErrInvalidFilter
|
||||
}
|
||||
var model EventCandidate
|
||||
if err := s.Orm.First(&model, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return Response{}, ErrNotFound
|
||||
}
|
||||
return Response{}, fmt.Errorf("get local event candidate: %w", err)
|
||||
}
|
||||
return s.response(model), nil
|
||||
}
|
||||
|
||||
type normalizedFilter struct {
|
||||
*PageRequest
|
||||
occurredAfter *time.Time
|
||||
occurredBefore *time.Time
|
||||
}
|
||||
|
||||
func normalizeFilter(request *PageRequest) (*normalizedFilter, error) {
|
||||
if request == nil {
|
||||
request = &PageRequest{}
|
||||
}
|
||||
request.CandidateState = strings.TrimSpace(request.CandidateState)
|
||||
request.EvidenceState = strings.TrimSpace(request.EvidenceState)
|
||||
request.RuleRef = strings.TrimSpace(request.RuleRef)
|
||||
request.Keyword = strings.TrimSpace(request.Keyword)
|
||||
if request.GetPageSize() > 100 || len([]rune(request.Keyword)) > 128 || len(request.RuleRef) > 128 {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
if request.CandidateState != "" && request.CandidateState != CandidateStateCandidate && request.CandidateState != CandidateStateConfirmed {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
if request.EvidenceState != "" && request.EvidenceState != EvidenceStatePending && request.EvidenceState != EvidenceStateSuccess && request.EvidenceState != EvidenceStateFailed {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
result := &normalizedFilter{PageRequest: request}
|
||||
var err error
|
||||
if strings.TrimSpace(request.OccurredAfter) != "" {
|
||||
value, parseErr := time.Parse(time.RFC3339, request.OccurredAfter)
|
||||
if parseErr != nil {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
result.occurredAfter = &value
|
||||
}
|
||||
if strings.TrimSpace(request.OccurredBefore) != "" {
|
||||
value, parseErr := time.Parse(time.RFC3339, request.OccurredBefore)
|
||||
if parseErr != nil {
|
||||
return nil, ErrInvalidFilter
|
||||
}
|
||||
result.occurredBefore = &value
|
||||
}
|
||||
if result.occurredAfter != nil && result.occurredBefore != nil && result.occurredAfter.After(*result.occurredBefore) {
|
||||
err = ErrInvalidFilter
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) response(model EventCandidate) Response {
|
||||
now := time.Now().UTC()
|
||||
if s.Now != nil {
|
||||
now = s.Now().UTC()
|
||||
}
|
||||
remaining := int(model.RetainUntil.Sub(now).Hours() / 24)
|
||||
state := "active"
|
||||
if !model.RetainUntil.After(now) {
|
||||
remaining, state = 0, "expired"
|
||||
} else if remaining < 3 {
|
||||
state = "expiring"
|
||||
}
|
||||
return Response{
|
||||
ID: model.ID, OccurredAt: model.OccurredAt, SourceRef: model.SourceRef, SourceLabel: model.SourceLabel,
|
||||
RuleRef: model.RuleRef, RuleName: model.RuleName, RuleVersion: model.RuleVersion, ZoneRef: model.ZoneRef,
|
||||
CandidateState: model.CandidateState, EvidenceState: model.EvidenceState, EvidenceRef: model.EvidenceRef,
|
||||
EvidenceDetail: model.EvidenceDetail, RetainUntil: model.RetainUntil, RetentionState: state,
|
||||
RemainingDays: remaining, CreatedAt: model.CreatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package local_event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
adminModels "git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||
commonDTO "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||
)
|
||||
|
||||
func localEventService(t *testing.T, now time.Time) *Service {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+uuid.NewString()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.AutoMigrate(&EventCandidate{}, &adminModels.SysOperaLog{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{Service: coreService.Service{Orm: db}, Now: func() time.Time { return now }}
|
||||
}
|
||||
|
||||
func TestSyntheticFixtureSupportsIndependentListFilterAndDetail(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Seeding is explicit and idempotent; no Bell or Brain service is involved.
|
||||
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, count, err := service.List(&PageRequest{CandidateState: CandidateStateCandidate, EvidenceState: EvidenceStateFailed, Keyword: "SEN-CAM-11"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 || len(items) != 1 || items[0].RetentionState != "expiring" || items[0].RemainingDays != 2 {
|
||||
t.Fatalf("unexpected filtered fixture: count=%d items=%#v", count, items)
|
||||
}
|
||||
detail, err := service.Get(items[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if detail.EvidenceState != EvidenceStateFailed || detail.CandidateState != CandidateStateCandidate || detail.EvidenceDetail == "" {
|
||||
t.Fatalf("unexpected detail: %#v", detail)
|
||||
}
|
||||
encoded, _ := json.Marshal(detail)
|
||||
if strings.Contains(strings.ToLower(string(encoded)), "bell") || strings.Contains(strings.ToLower(string(encoded)), "brain") {
|
||||
t.Fatalf("local response leaked a cross-project schema field: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPaginatesAndOrdersNewestFirst(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
if err := SeedSyntheticFixture(service.Orm, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first, count, err := service.List(&PageRequest{Pagination: pagination(1, 2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, _, err := service.List(&PageRequest{Pagination: pagination(2, 2)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 3 || len(first) != 2 || len(second) != 1 || !first[0].OccurredAt.After(first[1].OccurredAt) || !first[1].OccurredAt.After(second[0].OccurredAt) {
|
||||
t.Fatalf("unexpected pagination: first=%#v second=%#v count=%d", first, second, count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRejectsInvalidStatesTimesAndOversizedPages(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
cases := []*PageRequest{
|
||||
{CandidateState: "delivered"},
|
||||
{EvidenceState: "unknown"},
|
||||
{OccurredAfter: "not-a-time"},
|
||||
{OccurredAfter: now.Format(time.RFC3339), OccurredBefore: now.Add(-time.Hour).Format(time.RFC3339)},
|
||||
{Pagination: pagination(1, 101)},
|
||||
}
|
||||
for _, request := range cases {
|
||||
if _, _, err := service.List(request); err != ErrInvalidFilter {
|
||||
t.Fatalf("request %#v: expected ErrInvalidFilter, got %v", request, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetReportsMissingCandidate(t *testing.T) {
|
||||
service := localEventService(t, time.Now().UTC())
|
||||
if _, err := service.Get("00000000-0000-4000-8000-000000000099"); err != ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessAuditIsSynchronousAndExcludesEventData(t *testing.T) {
|
||||
now := time.Date(2026, 8, 28, 2, 0, 0, 0, time.UTC)
|
||||
service := localEventService(t, now)
|
||||
if err := WriteAccessAudit(service.Orm, AccessAudit{Action: "Get", Status: auditSuccess, Username: "operator", UserID: 7, ClientIP: "127.0.0.1", Route: "/api/v1/local-events/:id", Remark: "读取本地事件详情 fixture-1", At: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stored adminModels.SysOperaLog
|
||||
if err := service.Orm.First(&stored).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Title != "本地事件" || stored.Status != auditSuccess || stored.CreateBy != 7 || stored.OperUrl != "/api/v1/local-events/:id" {
|
||||
t.Fatalf("unexpected access audit: %#v", stored)
|
||||
}
|
||||
if stored.OperParam != "" || stored.JsonResult != "" {
|
||||
t.Fatalf("audit persisted query or response data: %#v", stored)
|
||||
}
|
||||
}
|
||||
|
||||
func pagination(index, size int) commonDTO.Pagination {
|
||||
return commonDTO.Pagination{PageIndex: index, PageSize: size}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func init() {
|
||||
_, fileName, _, _ := runtime.Caller(0)
|
||||
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLocalEvent)
|
||||
}
|
||||
|
||||
func migrateSenseLocalEvent(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&local_event.EventCandidate{}, &deviceCasbinRule{}); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: senseLayoutMenuName, Title: "视频感知", Icon: "video-camera", Path: "/sense", MenuType: "M", Component: "Layout", Sort: 5, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLocalEvent", Title: "本地事件", Icon: "bell", Path: "local-event", Paths: fmt.Sprintf("/0/%d", root.MenuId), MenuType: "C", Permission: "sense:local-event:list", ParentId: root.MenuId, Component: "/sense/local-event/index", Sort: 6, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
detail, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLocalEventDetail", Title: "查看本地事件", MenuType: "F", Action: "GET", Permission: "sense:local-event:detail", ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d/%d", root.MenuId, page.MenuId), Sort: 1, Visible: "1", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, detail}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range [][2]string{{"/api/v1/local-events", "GET"}, {"/api/v1/local-events/:id", "GET"}} {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = rebuildSenseMenuPaths(tx, root.MenuId, "/0"); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/local_event"
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestLocalEventMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_LOCAL_EVENT_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_LOCAL_EVENT_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const schema = "sense_local_event_73_test"
|
||||
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error })
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
const version = "2026082810000_local_event.go"
|
||||
if err = migrateSenseLocalEvent(db, version); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, policies, rows, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseLocalEvent%").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/local-events%").Count(&policies)
|
||||
db.Model(&local_event.EventCandidate{}).Count(&rows)
|
||||
db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied)
|
||||
if menus != 2 || policies != 6 || rows != 0 || applied != 1 {
|
||||
t.Fatalf("menus=%d policies=%d rows=%d applied=%d", menus, policies, rows, applied)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listLocalEvents(query) {
|
||||
return request({ url: '/api/v1/local-events', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function getLocalEvent(id) {
|
||||
return request({ url: `/api/v1/local-events/${encodeURIComponent(id)}`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>本地事件候选</h3>
|
||||
<p>查看 Sense 在本机形成的匿名事件候选及证据处理状态。</p>
|
||||
</div>
|
||||
<el-button :icon="Refresh" @click="loadEvents">刷新</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert type="info" :closable="false" show-icon class="boundary-alert">
|
||||
<template #title>本页不依赖 Bell</template>
|
||||
这里展示的是 Sense 本地事件候选,不代表已生成或已送达 Bell 预警;Bell 未安装或未启动时仍可正常查看。
|
||||
</el-alert>
|
||||
|
||||
<el-row :gutter="12" class="summary-row" aria-label="当前页事件概览">
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页候选</span><strong>{{ pageSummary.candidate }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页已确认</span><strong>{{ pageSummary.confirmed }}</strong></div></el-col>
|
||||
<el-col :xs="24" :sm="8"><div class="summary-item"><span>当前页证据失败</span><strong>{{ pageSummary.failed }}</strong></div></el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form :model="query" label-width="78px" class="filter-form" @submit.prevent="search">
|
||||
<el-form-item label="发生时间" class="time-field">
|
||||
<el-date-picker v-model="occurredRange" type="datetimerange" start-placeholder="开始时间" end-placeholder="结束时间" range-separator="至" :clearable="true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="候选状态">
|
||||
<el-select v-model="query.candidateState" clearable placeholder="全部">
|
||||
<el-option label="候选" value="candidate" /><el-option label="已确认事件" value="confirmed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="证据状态">
|
||||
<el-select v-model="query.evidenceState" clearable placeholder="全部">
|
||||
<el-option label="处理中" value="pending" /><el-option label="成功" value="success" /><el-option label="失败" value="failed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="规则来源"><el-input v-model="query.ruleRef" clearable placeholder="规则编号" /></el-form-item>
|
||||
<el-form-item label="关键词"><el-input v-model="query.keyword" clearable placeholder="事件 / 摄像头 / 规则" @keyup.enter="search" /></el-form-item>
|
||||
<el-form-item class="filter-actions">
|
||||
<el-button type="primary" :icon="Search" native-type="submit">查询</el-button>
|
||||
<el-button :icon="RefreshLeft" @click="reset">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table v-loading="loading" :data="events" border stripe empty-text="没有符合条件的本地事件候选">
|
||||
<el-table-column prop="occurredAt" label="发生时间" width="176">
|
||||
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="事件编号" min-width="205" show-overflow-tooltip />
|
||||
<el-table-column label="摄像头" min-width="145">
|
||||
<template #default="scope"><div>{{ scope.row.sourceRef }}</div><small>{{ scope.row.sourceLabel || '未命名位置' }}</small></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="规则来源" min-width="160">
|
||||
<template #default="scope"><div>{{ scope.row.ruleName }}</div><small>{{ scope.row.ruleRef }} {{ scope.row.ruleVersion }}</small></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="候选状态" width="118" align="center">
|
||||
<template #default="scope"><el-tag :type="candidateType(scope.row.candidateState)">{{ candidateLabel(scope.row.candidateState) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="证据状态" width="104" align="center">
|
||||
<template #default="scope"><el-tag :type="evidenceType(scope.row.evidenceState)">{{ evidenceLabel(scope.row.evidenceState) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="保留期限" width="142">
|
||||
<template #default="scope"><span :class="{ 'retention-warning': scope.row.retentionState === 'expiring' }">{{ retentionLabel(scope.row) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="94" fixed="right">
|
||||
<template #default="scope"><el-button v-permisaction="['sense:local-event:detail']" type="primary" link @click="openDetail(scope.row.id)">查看详情</el-button></template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<pagination v-show="total > 0" v-model:current-page="query.pageIndex" v-model:page-size="query.pageSize" :total="total" @pagination="loadEvents" />
|
||||
<p class="retention-help">保留策略由 Sense 服务端配置;列表显示每条记录的实际到期时间,到期清理策略不由本页面修改。列表和详情读取均受权限控制并进入系统操作审计。</p>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="detailOpen" title="本地事件详情" width="min(760px, calc(100vw - 24px))" :close-on-click-modal="false">
|
||||
<el-descriptions v-if="selected" :column="2" border class="detail-descriptions">
|
||||
<el-descriptions-item label="事件编号">{{ selected.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发生时间">{{ parseTime(selected.occurredAt) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="摄像头">{{ selected.sourceRef }} / {{ selected.sourceLabel || '未命名位置' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规则来源">{{ selected.ruleName }} {{ selected.ruleVersion }}</el-descriptions-item>
|
||||
<el-descriptions-item label="区域引用">{{ selected.zoneRef || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="保留期限">{{ retentionLabel(selected) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="候选状态"><el-tag :type="candidateType(selected.candidateState)">{{ candidateLabel(selected.candidateState) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="证据状态"><el-tag :type="evidenceType(selected.evidenceState)">{{ evidenceLabel(selected.evidenceState) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="Bell 送达状态" :span="2"><strong>不适用</strong> — 本地候选未发送到 Bell,本页不推断 Bell Alert 状态。</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-alert v-if="selected" :title="evidenceTitle" :type="evidenceType(selected.evidenceState)" :closable="false" show-icon class="evidence-alert">
|
||||
{{ selected.evidenceDetail || '暂无更多证据处理说明' }}<template v-if="selected.evidenceRef"><br>本地证据引用:{{ selected.evidenceRef }}</template>
|
||||
</el-alert>
|
||||
<template #footer><el-button @click="detailOpen = false">关闭</el-button></template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { Refresh, RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getLocalEvent, listLocalEvents } from '@/api/sense/local-event'
|
||||
import { buildLocalEventQuery, candidateLabel, candidateType, evidenceLabel, evidenceType, retentionLabel } from './localEventState'
|
||||
|
||||
defineOptions({ name: 'SenseLocalEvent' })
|
||||
|
||||
const loading = ref(false)
|
||||
const events = ref([])
|
||||
const total = ref(0)
|
||||
const detailOpen = ref(false)
|
||||
const selected = ref(null)
|
||||
const now = Date.now()
|
||||
const occurredRange = ref([new Date(now - 24 * 60 * 60 * 1000), new Date(now)])
|
||||
const query = reactive({ pageIndex: 1, pageSize: 10, candidateState: '', evidenceState: '', ruleRef: '', keyword: '' })
|
||||
const pageSummary = computed(() => events.value.reduce((value, item) => {
|
||||
if (item.candidateState === 'candidate') value.candidate += 1
|
||||
if (item.candidateState === 'confirmed') value.confirmed += 1
|
||||
if (item.evidenceState === 'failed') value.failed += 1
|
||||
return value
|
||||
}, { candidate: 0, confirmed: 0, failed: 0 }))
|
||||
const evidenceTitle = computed(() => `证据处理:${evidenceLabel(selected.value?.evidenceState)}`)
|
||||
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
|
||||
async function loadEvents() {
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = unwrap(await listLocalEvents(buildLocalEventQuery(query, occurredRange.value)))
|
||||
events.value = payload?.list || payload?.data || []
|
||||
total.value = payload?.count || 0
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '本地事件查询失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() { query.pageIndex = 1; loadEvents() }
|
||||
function reset() {
|
||||
query.pageIndex = 1; query.candidateState = ''; query.evidenceState = ''; query.ruleRef = ''; query.keyword = ''
|
||||
const current = Date.now(); occurredRange.value = [new Date(current - 24 * 60 * 60 * 1000), new Date(current)]
|
||||
loadEvents()
|
||||
}
|
||||
async function openDetail(id) {
|
||||
try {
|
||||
selected.value = unwrap(await getLocalEvent(id))
|
||||
detailOpen.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message || '本地事件详情查询失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadEvents)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header{display:flex;align-items:flex-start;justify-content:space-between;gap:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.boundary-alert{margin:16px 0}.summary-row{margin-bottom:16px}.summary-item{display:flex;align-items:center;justify-content:space-between;min-height:68px;padding:12px 16px;border:1px solid #ebeef5;border-radius:4px}.summary-item span{color:#606266}.summary-item strong{font-size:24px}.filter-form{display:flex;align-items:flex-end;flex-wrap:wrap;gap:0 12px;margin-bottom:2px}.filter-form .el-form-item{width:210px}.filter-form .time-field{width:420px}.filter-form .filter-actions{width:auto}.filter-form :deep(.el-select),.filter-form :deep(.el-date-editor){width:100%}small{color:#909399}.retention-warning{color:#e6a23c}.retention-help{margin:12px 0 0;color:#909399;font-size:12px}.evidence-alert{margin-top:16px}@media (max-width:768px){.filter-form .el-form-item,.filter-form .time-field{width:100%}.detail-descriptions :deep(.el-descriptions__body .el-descriptions__table){display:block;overflow-x:auto}}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
export const candidateLabels = { candidate: '候选', confirmed: '已确认事件' }
|
||||
export const evidenceLabels = { pending: '处理中', success: '成功', failed: '失败' }
|
||||
|
||||
export function candidateLabel(value) {
|
||||
return candidateLabels[value] || value || '-'
|
||||
}
|
||||
|
||||
export function candidateType(value) {
|
||||
return value === 'confirmed' ? 'success' : 'primary'
|
||||
}
|
||||
|
||||
export function evidenceLabel(value) {
|
||||
return evidenceLabels[value] || value || '-'
|
||||
}
|
||||
|
||||
export function evidenceType(value) {
|
||||
return ({ pending: 'warning', success: 'success', failed: 'danger' })[value] || 'info'
|
||||
}
|
||||
|
||||
export function buildLocalEventQuery(query, occurredRange) {
|
||||
const result = {
|
||||
pageIndex: query.pageIndex,
|
||||
pageSize: query.pageSize,
|
||||
candidateState: query.candidateState || undefined,
|
||||
evidenceState: query.evidenceState || undefined,
|
||||
ruleRef: String(query.ruleRef || '').trim() || undefined,
|
||||
keyword: String(query.keyword || '').trim() || undefined
|
||||
}
|
||||
if (occurredRange?.length === 2) {
|
||||
result.occurredAfter = new Date(occurredRange[0]).toISOString()
|
||||
result.occurredBefore = new Date(occurredRange[1]).toISOString()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function retentionLabel(item) {
|
||||
if (!item?.retainUntil) return '-'
|
||||
if (item.retentionState === 'expired') return '已到期'
|
||||
if (item.retentionState === 'expiring') return `${item.remainingDays} 天内到期`
|
||||
return `保留至 ${String(item.retainUntil).slice(0, 10)}`
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import request from '@/utils/request'
|
||||
import { getLocalEvent, listLocalEvents } from '@/api/sense/local-event'
|
||||
|
||||
jest.mock('@/utils/request', () => jest.fn())
|
||||
|
||||
describe('Sense local event API', () => {
|
||||
beforeEach(() => request.mockReset())
|
||||
|
||||
test('uses read-only local endpoints', () => {
|
||||
listLocalEvents({ pageIndex: 1, pageSize: 10 })
|
||||
getLocalEvent('event/unsafe')
|
||||
expect(request).toHaveBeenNthCalledWith(1, { url: '/api/v1/local-events', method: 'get', params: { pageIndex: 1, pageSize: 10 }})
|
||||
expect(request).toHaveBeenNthCalledWith(2, { url: '/api/v1/local-events/event%2Funsafe', method: 'get' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { buildLocalEventQuery, candidateLabel, evidenceLabel, evidenceType, retentionLabel } from '@/views/sense/local-event/localEventState'
|
||||
|
||||
describe('Sense local event presentation state', () => {
|
||||
test('keeps candidate and evidence states distinct', () => {
|
||||
expect(candidateLabel('candidate')).toBe('候选')
|
||||
expect(candidateLabel('confirmed')).toBe('已确认事件')
|
||||
expect(evidenceLabel('pending')).toBe('处理中')
|
||||
expect(evidenceLabel('success')).toBe('成功')
|
||||
expect(evidenceLabel('failed')).toBe('失败')
|
||||
expect(evidenceType('failed')).toBe('danger')
|
||||
})
|
||||
|
||||
test('builds an allowlisted query with RFC3339 time bounds', () => {
|
||||
const result = buildLocalEventQuery({ pageIndex: 2, pageSize: 20, candidateState: 'candidate', evidenceState: 'failed', ruleRef: ' rule-1 ', keyword: ' camera-3 ', ignored: 'not-sent' }, ['2026-08-27T00:00:00Z', '2026-08-28T00:00:00Z'])
|
||||
expect(result).toEqual({ pageIndex: 2, pageSize: 20, candidateState: 'candidate', evidenceState: 'failed', ruleRef: 'rule-1', keyword: 'camera-3', occurredAfter: '2026-08-27T00:00:00.000Z', occurredBefore: '2026-08-28T00:00:00.000Z' })
|
||||
})
|
||||
|
||||
test('describes retention without inventing a global duration', () => {
|
||||
expect(retentionLabel({ retainUntil: '2026-09-09T00:00:00Z', retentionState: 'active', remainingDays: 12 })).toBe('保留至 2026-09-09')
|
||||
expect(retentionLabel({ retainUntil: '2026-08-30T00:00:00Z', retentionState: 'expiring', remainingDays: 2 })).toBe('2 天内到期')
|
||||
expect(retentionLabel({ retainUntil: '2026-08-01T00:00:00Z', retentionState: 'expired', remainingDays: 0 })).toBe('已到期')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user