[SEN] 实现无 Bell 场景的本地事件候选查看 (#73) #117

Merged
ila merged 1 commits from feature/73-sense-local-events into dev 2026-08-28 10:26:58 +08:00
16 changed files with 891 additions and 0 deletions
@@ -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
}
+36
View File
@@ -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)
}
}
+9
View File
@@ -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('已到期')
})
})