feat: 实现 Sense 本地事件只读查看 (#73)

This commit is contained in:
QiuSW
2026-08-28 10:26:13 +08:00
parent 44b60b5b46
commit 028519a710
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)
}
}