From 028519a710b68d2ddca3fe3a5eedca4252c222bb Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 28 Aug 2026 10:26:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20Sense=20=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E4=BA=8B=E4=BB=B6=E5=8F=AA=E8=AF=BB=E6=9F=A5=E7=9C=8B?= =?UTF-8?q?=20(#73)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/admin/router/sense_local_event.go | 19 +++ .../admin/router/sense_local_event_test.go | 29 ++++ Sense/server/app/sense/local_event/apis.go | 82 +++++++++ Sense/server/app/sense/local_event/audit.go | 38 +++++ Sense/server/app/sense/local_event/dto.go | 36 ++++ Sense/server/app/sense/local_event/fixture.go | 20 +++ Sense/server/app/sense/local_event/models.go | 38 +++++ Sense/server/app/sense/local_event/service.go | 145 ++++++++++++++++ .../app/sense/local_event/service_test.go | 128 +++++++++++++++ .../version/2026082810000_local_event.go | 53 ++++++ .../version/2026082810000_local_event_test.go | 60 +++++++ Sense/ui/src/api/sense/local-event.js | 9 + .../ui/src/views/sense/local-event/index.vue | 155 ++++++++++++++++++ .../sense/local-event/localEventState.js | 41 +++++ .../ui/tests/unit/sense/localEventApi.spec.js | 15 ++ .../tests/unit/sense/localEventState.spec.js | 23 +++ 16 files changed, 891 insertions(+) create mode 100644 Sense/server/app/admin/router/sense_local_event.go create mode 100644 Sense/server/app/admin/router/sense_local_event_test.go create mode 100644 Sense/server/app/sense/local_event/apis.go create mode 100644 Sense/server/app/sense/local_event/audit.go create mode 100644 Sense/server/app/sense/local_event/dto.go create mode 100644 Sense/server/app/sense/local_event/fixture.go create mode 100644 Sense/server/app/sense/local_event/models.go create mode 100644 Sense/server/app/sense/local_event/service.go create mode 100644 Sense/server/app/sense/local_event/service_test.go create mode 100644 Sense/server/cmd/migrate/migration/version/2026082810000_local_event.go create mode 100644 Sense/server/cmd/migrate/migration/version/2026082810000_local_event_test.go create mode 100644 Sense/ui/src/api/sense/local-event.js create mode 100644 Sense/ui/src/views/sense/local-event/index.vue create mode 100644 Sense/ui/src/views/sense/local-event/localEventState.js create mode 100644 Sense/ui/tests/unit/sense/localEventApi.spec.js create mode 100644 Sense/ui/tests/unit/sense/localEventState.spec.js diff --git a/Sense/server/app/admin/router/sense_local_event.go b/Sense/server/app/admin/router/sense_local_event.go new file mode 100644 index 0000000..1568f88 --- /dev/null +++ b/Sense/server/app/admin/router/sense_local_event.go @@ -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) +} diff --git a/Sense/server/app/admin/router/sense_local_event_test.go b/Sense/server/app/admin/router/sense_local_event_test.go new file mode 100644 index 0000000..e793bd5 --- /dev/null +++ b/Sense/server/app/admin/router/sense_local_event_test.go @@ -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) + } + } +} diff --git a/Sense/server/app/sense/local_event/apis.go b/Sense/server/app/sense/local_event/apis.go new file mode 100644 index 0000000..120d0b3 --- /dev/null +++ b/Sense/server/app/sense/local_event/apis.go @@ -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, "本地事件查询失败") + } +} diff --git a/Sense/server/app/sense/local_event/audit.go b/Sense/server/app/sense/local_event/audit.go new file mode 100644 index 0000000..b0fc325 --- /dev/null +++ b/Sense/server/app/sense/local_event/audit.go @@ -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 +} diff --git a/Sense/server/app/sense/local_event/dto.go b/Sense/server/app/sense/local_event/dto.go new file mode 100644 index 0000000..9003d04 --- /dev/null +++ b/Sense/server/app/sense/local_event/dto.go @@ -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"` +} diff --git a/Sense/server/app/sense/local_event/fixture.go b/Sense/server/app/sense/local_event/fixture.go new file mode 100644 index 0000000..b056202 --- /dev/null +++ b/Sense/server/app/sense/local_event/fixture.go @@ -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 +} diff --git a/Sense/server/app/sense/local_event/models.go b/Sense/server/app/sense/local_event/models.go new file mode 100644 index 0000000..cd205a5 --- /dev/null +++ b/Sense/server/app/sense/local_event/models.go @@ -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" } diff --git a/Sense/server/app/sense/local_event/service.go b/Sense/server/app/sense/local_event/service.go new file mode 100644 index 0000000..6f72943 --- /dev/null +++ b/Sense/server/app/sense/local_event/service.go @@ -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, + } +} diff --git a/Sense/server/app/sense/local_event/service_test.go b/Sense/server/app/sense/local_event/service_test.go new file mode 100644 index 0000000..acd67dd --- /dev/null +++ b/Sense/server/app/sense/local_event/service_test.go @@ -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} +} diff --git a/Sense/server/cmd/migrate/migration/version/2026082810000_local_event.go b/Sense/server/cmd/migrate/migration/version/2026082810000_local_event.go new file mode 100644 index 0000000..1c3b2d9 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082810000_local_event.go @@ -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 + }) +} diff --git a/Sense/server/cmd/migrate/migration/version/2026082810000_local_event_test.go b/Sense/server/cmd/migrate/migration/version/2026082810000_local_event_test.go new file mode 100644 index 0000000..4f7f6b4 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026082810000_local_event_test.go @@ -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) + } +} diff --git a/Sense/ui/src/api/sense/local-event.js b/Sense/ui/src/api/sense/local-event.js new file mode 100644 index 0000000..dd0e571 --- /dev/null +++ b/Sense/ui/src/api/sense/local-event.js @@ -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' }) +} diff --git a/Sense/ui/src/views/sense/local-event/index.vue b/Sense/ui/src/views/sense/local-event/index.vue new file mode 100644 index 0000000..865adc8 --- /dev/null +++ b/Sense/ui/src/views/sense/local-event/index.vue @@ -0,0 +1,155 @@ + + + + + diff --git a/Sense/ui/src/views/sense/local-event/localEventState.js b/Sense/ui/src/views/sense/local-event/localEventState.js new file mode 100644 index 0000000..bdd2260 --- /dev/null +++ b/Sense/ui/src/views/sense/local-event/localEventState.js @@ -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)}` +} diff --git a/Sense/ui/tests/unit/sense/localEventApi.spec.js b/Sense/ui/tests/unit/sense/localEventApi.spec.js new file mode 100644 index 0000000..85b5f5e --- /dev/null +++ b/Sense/ui/tests/unit/sense/localEventApi.spec.js @@ -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' }) + }) +}) diff --git a/Sense/ui/tests/unit/sense/localEventState.spec.js b/Sense/ui/tests/unit/sense/localEventState.spec.js new file mode 100644 index 0000000..f863d0a --- /dev/null +++ b/Sense/ui/tests/unit/sense/localEventState.spec.js @@ -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('已到期') + }) +}) -- 2.34.1