diff --git a/Sense/server/app/admin/router/sense_area.go b/Sense/server/app/admin/router/sense_area.go new file mode 100644 index 0000000..cf1676d --- /dev/null +++ b/Sense/server/app/admin/router/sense_area.go @@ -0,0 +1,20 @@ +package router + +import ( + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/area" + "git.ilapage.cn/ila/yovision/Sense/server/common/actions" + "git.ilapage.cn/ila/yovision/Sense/server/common/middleware" + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" +) + +func init() { routerCheckRole = append(routerCheckRole, registerSenseAreaRouter) } + +func registerSenseAreaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) { + api := &area.API{} + r := v1.Group("/area").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction()) + r.GET("/configurations", api.List) + r.POST("/configurations", api.Create) + r.PUT("/configurations/:id", api.Update) + r.GET("/configurations/:id/versions", api.Versions) +} diff --git a/Sense/server/app/sense/admission/service.go b/Sense/server/app/sense/admission/service.go index 3899c3c..df2da63 100644 --- a/Sense/server/app/sense/admission/service.go +++ b/Sense/server/app/sense/admission/service.go @@ -12,6 +12,7 @@ import ( coreService "github.com/go-admin-team/go-admin-core/sdk/service" "gorm.io/gorm" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/area" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential" deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif" @@ -131,7 +132,16 @@ func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool } } if replaceProfiles && len(result.Profiles) > 0 { - return tx.Create(&result.Profiles).Error + if err := tx.Create(&result.Profiles).Error; err != nil { + return err + } + } + if replaceProfiles { + snapshots := make([]area.ProfileSnapshot, 0, len(result.Profiles)) + for _, profile := range result.Profiles { + snapshots = append(snapshots, area.ProfileSnapshot{Token: profile.Token, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding}) + } + return area.MarkProfilesReplaced(tx, request.DeviceID, snapshots) } return nil }) diff --git a/Sense/server/app/sense/admission/service_test.go b/Sense/server/app/sense/admission/service_test.go index 1566ef3..418982a 100644 --- a/Sense/server/app/sense/admission/service_test.go +++ b/Sense/server/app/sense/admission/service_test.go @@ -10,18 +10,25 @@ import ( "gorm.io/driver/sqlite" "gorm.io/gorm" + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/area" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential" deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif" "git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp" ) -type fakeONVIF struct{ err error } +type fakeONVIF struct { + err error + profiles []onvif.Profile +} func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) { if f.err != nil { return nil, f.err } + if f.profiles != nil { + return f.profiles, nil + } return []onvif.Profile{{Token: "main", Name: "主码流", Width: 1920, Height: 1080, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}, {Token: "sub", Name: "子码流", Width: 640, Height: 360, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/sub"}}, nil } @@ -105,3 +112,27 @@ func TestProbeErrorsHaveActionableStates(t *testing.T) { } } } + +func TestProfileResolutionChangeMarksAreaForRecalibration(t *testing.T) { + service := admissionService(t) + if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil { + t.Fatal(err) + } + if err := service.Orm.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil { + t.Fatal(err) + } + definition := area.Definition{ID: "area-1", Name: "东门警戒线", Kind: area.KindDirectionLine, DeviceID: "device-1", ProfileToken: "main", ProfileWidth: 1920, ProfileHeight: 1080, ProfileEncoding: "H264", CurrentVersion: 1, CurrentVersionID: "version-1", Enabled: true, CreatedBy: 7, UpdatedBy: 7} + if err := service.Orm.Create(&definition).Error; err != nil { + t.Fatal(err) + } + service.ONVIF = fakeONVIF{profiles: []onvif.Profile{{Token: "main", Name: "主码流", Width: 1280, Height: 720, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}}} + if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); err != nil { + t.Fatal(err) + } + if err := service.Orm.First(&definition, "id = ?", "area-1").Error; err != nil { + t.Fatal(err) + } + if !definition.NeedsRecalibration { + t.Fatal("profile resolution change did not mark the bound area") + } +} diff --git a/Sense/server/app/sense/area/apis.go b/Sense/server/app/sense/area/apis.go new file mode 100644 index 0000000..2de972e --- /dev/null +++ b/Sense/server/app/sense/area/apis.go @@ -0,0 +1,126 @@ +package area + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + + "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" + coreService "github.com/go-admin-team/go-admin-core/sdk/service" +) + +type API struct{ api.Api } + +func (e *API) service(c *gin.Context) (*Service, error) { + base := coreService.Service{} + if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil { + return nil, err + } + return NewService(base.Orm), nil +} + +func (e *API) List(c *gin.Context) { + service, err := e.service(c) + if err != nil { + e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败") + return + } + pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10")) + items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), Kind: c.Query("kind"), RecalibrationState: c.Query("recalibrationState"), PageIndex: pageIndex, PageSize: pageSize}) + if err != nil { + e.writeError(err) + return + } + e.PageOK(items, int(total), pageIndex, pageSize, "查询成功") +} + +func (e *API) Create(c *gin.Context) { + service, err := e.service(c) + if err != nil { + e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败") + return + } + var request UpsertRequest + if err = decodeJSON(c, &request); err != nil { + e.Error(http.StatusBadRequest, err, "请求内容格式不正确") + return + } + request.UpdateBy = user.GetUserId(c) + item, err := service.Create(c.Request.Context(), request) + if err != nil { + e.writeError(err) + return + } + e.OK(item, "区域配置已创建") +} + +func (e *API) Update(c *gin.Context) { + service, err := e.service(c) + if err != nil { + e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败") + return + } + var request UpsertRequest + if err = decodeJSON(c, &request); err != nil { + e.Error(http.StatusBadRequest, err, "请求内容格式不正确") + return + } + request.UpdateBy = user.GetUserId(c) + item, err := service.Update(c.Request.Context(), c.Param("id"), request) + if err != nil { + e.writeError(err) + return + } + e.OK(item, "已保存新版本") +} + +func (e *API) Versions(c *gin.Context) { + service, err := e.service(c) + if err != nil { + e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败") + return + } + items, err := service.Versions(c.Request.Context(), c.Param("id")) + if err != nil { + e.writeError(err) + return + } + e.OK(items, "查询成功") +} + +func (e *API) writeError(err error) { + switch { + case errors.Is(err, ErrInvalidRequest), errors.Is(err, ErrInvalidGeometry): + e.Error(http.StatusBadRequest, err, err.Error()) + case errors.Is(err, ErrNotFound), errors.Is(err, ErrProfileMissing): + e.Error(http.StatusNotFound, err, err.Error()) + case errors.Is(err, ErrConflict): + e.Error(http.StatusConflict, err, err.Error()) + default: + e.Error(http.StatusInternalServerError, err, "区域配置操作失败") + } +} + +func decodeJSON(c *gin.Context, target any) error { + if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") { + return errors.New("content type must be application/json") + } + decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("request body must contain one JSON object") + } + return err + } + return nil +} diff --git a/Sense/server/app/sense/area/dto.go b/Sense/server/app/sense/area/dto.go new file mode 100644 index 0000000..abb7191 --- /dev/null +++ b/Sense/server/app/sense/area/dto.go @@ -0,0 +1,81 @@ +package area + +import "time" + +const ( + KindPolygon = "polygon" + KindDirectionLine = "direction_line" + DirectionForward = "forward" + DirectionReverse = "reverse" +) + +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +type PageRequest struct { + Keyword string + Kind string + RecalibrationState string + PageIndex int + PageSize int +} + +type UpsertRequest struct { + Name string `json:"name"` + Kind string `json:"kind"` + RouteID string `json:"routeId"` + Points []Point `json:"points"` + Direction string `json:"direction"` + Enabled bool `json:"enabled"` + ExpectedVersion int64 `json:"expectedVersion"` + UpdateBy int `json:"-"` +} + +type Response struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + DeviceID string `json:"deviceId"` + DeviceName string `json:"deviceName"` + DeviceLocation string `json:"deviceLocation"` + ProfileToken string `json:"profileToken"` + ProfileName string `json:"profileName"` + ProfileWidth int `json:"profileWidth"` + ProfileHeight int `json:"profileHeight"` + ProfileEncoding string `json:"profileEncoding"` + RouteID string `json:"routeId"` + Version int64 `json:"version"` + Points []Point `json:"points"` + Direction string `json:"direction"` + Enabled bool `json:"enabled"` + NeedsRecalibration bool `json:"needsRecalibration"` + UpdatedBy int `json:"updatedBy"` + UpdatedAt time.Time `json:"updatedAt"` +} + +type VersionResponse struct { + ID string `json:"id"` + Version int64 `json:"version"` + Name string `json:"name"` + Kind string `json:"kind"` + DeviceID string `json:"deviceId"` + ProfileToken string `json:"profileToken"` + ProfileWidth int `json:"profileWidth"` + ProfileHeight int `json:"profileHeight"` + ProfileEncoding string `json:"profileEncoding"` + Points []Point `json:"points"` + Direction string `json:"direction"` + Enabled bool `json:"enabled"` + SupersedesID string `json:"supersedesId"` + CreatedBy int `json:"createdBy"` + CreatedAt time.Time `json:"createdAt"` +} + +type ProfileSnapshot struct { + Token string + Width int + Height int + Encoding string +} diff --git a/Sense/server/app/sense/area/geometry.go b/Sense/server/app/sense/area/geometry.go new file mode 100644 index 0000000..a6b3d19 --- /dev/null +++ b/Sense/server/app/sense/area/geometry.go @@ -0,0 +1,84 @@ +package area + +import ( + "errors" + "math" +) + +var ErrInvalidGeometry = errors.New("区域几何不符合要求") + +func validateGeometry(kind, direction string, points []Point) error { + if kind != KindPolygon && kind != KindDirectionLine { + return ErrInvalidGeometry + } + if (kind == KindPolygon && (len(points) < 3 || len(points) > 64)) || (kind == KindDirectionLine && len(points) != 2) { + return ErrInvalidGeometry + } + if kind == KindDirectionLine && direction != DirectionForward && direction != DirectionReverse { + return ErrInvalidGeometry + } + if kind == KindPolygon && direction != "" { + return ErrInvalidGeometry + } + for i, point := range points { + if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) || point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 { + return ErrInvalidGeometry + } + if i > 0 && samePoint(point, points[i-1]) { + return ErrInvalidGeometry + } + } + if kind == KindDirectionLine { + if samePoint(points[0], points[1]) { + return ErrInvalidGeometry + } + return nil + } + if samePoint(points[0], points[len(points)-1]) || math.Abs(polygonArea(points)) < 0.000001 || polygonSelfIntersects(points) { + return ErrInvalidGeometry + } + return nil +} + +func samePoint(a, b Point) bool { + return math.Abs(a.X-b.X) < 0.0000001 && math.Abs(a.Y-b.Y) < 0.0000001 +} + +func polygonArea(points []Point) float64 { + area := 0.0 + for i := range points { + next := points[(i+1)%len(points)] + area += points[i].X*next.Y - next.X*points[i].Y + } + return area / 2 +} + +func polygonSelfIntersects(points []Point) bool { + for i := range points { + a1, a2 := points[i], points[(i+1)%len(points)] + for j := i + 1; j < len(points); j++ { + if j == i || j == (i+1)%len(points) || i == (j+1)%len(points) { + continue + } + b1, b2 := points[j], points[(j+1)%len(points)] + if segmentsIntersect(a1, a2, b1, b2) { + return true + } + } + } + return false +} + +func segmentsIntersect(a, b, c, d Point) bool { + orientation := func(p, q, r Point) float64 { + return (q.Y-p.Y)*(r.X-q.X) - (q.X-p.X)*(r.Y-q.Y) + } + o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b) + if ((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0)) { + return true + } + onSegment := func(p, q, r Point) bool { + return q.X <= math.Max(p.X, r.X)+0.0000001 && q.X >= math.Min(p.X, r.X)-0.0000001 && q.Y <= math.Max(p.Y, r.Y)+0.0000001 && q.Y >= math.Min(p.Y, r.Y)-0.0000001 + } + return (math.Abs(o1) < 0.0000001 && onSegment(a, c, b)) || (math.Abs(o2) < 0.0000001 && onSegment(a, d, b)) || (math.Abs(o3) < 0.0000001 && onSegment(c, a, d)) || (math.Abs(o4) < 0.0000001 && onSegment(c, b, d)) +} diff --git a/Sense/server/app/sense/area/geometry_test.go b/Sense/server/app/sense/area/geometry_test.go new file mode 100644 index 0000000..05ee150 --- /dev/null +++ b/Sense/server/app/sense/area/geometry_test.go @@ -0,0 +1,27 @@ +package area + +import "testing" + +func TestGeometryValidation(t *testing.T) { + tests := []struct { + name string + kind string + direction string + points []Point + valid bool + }{ + {"polygon", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, true}, + {"self intersecting", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.8}, {0.8, 0.1}, {0.1, 0.8}}, false}, + {"outside", KindPolygon, "", []Point{{-0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, false}, + {"line", KindDirectionLine, DirectionForward, []Point{{0.2, 0.5}, {0.8, 0.5}}, true}, + {"line missing direction", KindDirectionLine, "", []Point{{0.2, 0.5}, {0.8, 0.5}}, false}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := validateGeometry(test.kind, test.direction, test.points) + if (err == nil) != test.valid { + t.Fatalf("valid=%v err=%v", test.valid, err) + } + }) + } +} diff --git a/Sense/server/app/sense/area/models.go b/Sense/server/app/sense/area/models.go new file mode 100644 index 0000000..c94ad30 --- /dev/null +++ b/Sense/server/app/sense/area/models.go @@ -0,0 +1,45 @@ +package area + +import "time" + +type Definition struct { + ID string `gorm:"size:36;primaryKey"` + Name string `gorm:"size:128;not null;index"` + Kind string `gorm:"size:32;not null;index"` + DeviceID string `gorm:"size:36;not null;index"` + ProfileToken string `gorm:"size:255;not null"` + ProfileWidth int `gorm:"not null"` + ProfileHeight int `gorm:"not null"` + ProfileEncoding string `gorm:"size:32;not null"` + CurrentVersion int64 `gorm:"not null"` + CurrentVersionID string `gorm:"size:36;not null;uniqueIndex"` + Enabled bool `gorm:"not null"` + NeedsRecalibration bool `gorm:"not null;index"` + CreatedBy int `gorm:"not null"` + UpdatedBy int `gorm:"not null"` + CreatedAt time.Time `gorm:"not null"` + UpdatedAt time.Time `gorm:"not null"` +} + +func (Definition) TableName() string { return "sense_area_definitions" } + +type Version struct { + ID string `gorm:"size:36;primaryKey"` + DefinitionID string `gorm:"size:36;not null;uniqueIndex:ux_sense_area_version,priority:1;index"` + Version int64 `gorm:"not null;uniqueIndex:ux_sense_area_version,priority:2"` + Name string `gorm:"size:128;not null"` + Kind string `gorm:"size:32;not null"` + DeviceID string `gorm:"size:36;not null"` + ProfileToken string `gorm:"size:255;not null"` + ProfileWidth int `gorm:"not null"` + ProfileHeight int `gorm:"not null"` + ProfileEncoding string `gorm:"size:32;not null"` + GeometryJSON string `gorm:"type:text;not null"` + Direction string `gorm:"size:16;not null"` + Enabled bool `gorm:"not null"` + SupersedesID string `gorm:"size:36"` + CreatedBy int `gorm:"not null"` + CreatedAt time.Time `gorm:"not null"` +} + +func (Version) TableName() string { return "sense_area_versions" } diff --git a/Sense/server/app/sense/area/postgres_test.go b/Sense/server/app/sense/area/postgres_test.go new file mode 100644 index 0000000..b326dc5 --- /dev/null +++ b/Sense/server/app/sense/area/postgres_test.go @@ -0,0 +1,95 @@ +package area + +import ( + "context" + "errors" + "os" + "strings" + "sync" + "testing" + + "gorm.io/driver/postgres" + "gorm.io/gorm" +) + +func TestConcurrentUpdateOnPostgresReturnsConflict(t *testing.T) { + baseDSN := os.Getenv("SENSE_AREA_TEST_DATABASE_URL") + if baseDSN == "" { + t.Skip("set SENSE_AREA_TEST_DATABASE_URL to run the PostgreSQL concurrency test") + } + admin, err := gorm.Open(postgres.Open(baseDSN), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + const schema = "sense_area_69_concurrency" + if err = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil { + t.Fatal(err) + } + if err = admin.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") }) + separator := "?" + if strings.Contains(baseDSN, "?") { + separator = "&" + } + db, err := gorm.Open(postgres.Open(baseDSN+separator+"search_path="+schema), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`, + `CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`, + `CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`, + `INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`, + `INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`, + `INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`, + } { + if err = db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + service := NewService(db) + created, err := service.Create(context.Background(), triangleRequest()) + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + errorsChannel := make(chan error, 2) + var wait sync.WaitGroup + for index := 0; index < 2; index++ { + wait.Add(1) + go func() { + defer wait.Done() + <-start + request := triangleRequest() + request.ExpectedVersion = created.Version + _, updateErr := service.Update(context.Background(), created.ID, request) + errorsChannel <- updateErr + }() + } + close(start) + wait.Wait() + close(errorsChannel) + successes, conflicts := 0, 0 + for updateErr := range errorsChannel { + switch { + case updateErr == nil: + successes++ + case errors.Is(updateErr, ErrConflict): + conflicts++ + default: + t.Fatalf("unexpected concurrent update error: %v", updateErr) + } + } + if successes != 1 || conflicts != 1 { + t.Fatalf("successes=%d conflicts=%d", successes, conflicts) + } + versions, err := service.Versions(context.Background(), created.ID) + if err != nil || len(versions) != 2 { + t.Fatalf("versions=%d err=%v", len(versions), err) + } +} diff --git a/Sense/server/app/sense/area/service.go b/Sense/server/app/sense/area/service.go new file mode 100644 index 0000000..bfdeaef --- /dev/null +++ b/Sense/server/app/sense/area/service.go @@ -0,0 +1,304 @@ +package area + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrInvalidRequest = errors.New("区域配置请求不符合要求") + ErrNotFound = errors.New("区域配置不存在") + ErrConflict = errors.New("区域配置已被其他用户更新,请刷新后重试") + ErrProfileMissing = errors.New("绑定的视频 Profile 不可用,请先完成视频接入") +) + +type Service struct{ db *gorm.DB } + +func NewService(db *gorm.DB) *Service { return &Service{db: db} } + +type routeSnapshot struct { + RouteID string `gorm:"column:route_id"` + DeviceID string `gorm:"column:device_id"` + DeviceName string `gorm:"column:device_name"` + DeviceLocation string `gorm:"column:device_location"` + ProfileToken string `gorm:"column:profile_token"` + ProfileName string `gorm:"column:profile_name"` + Width int `gorm:"column:width"` + Height int `gorm:"column:height"` + Encoding string `gorm:"column:encoding"` + Verification string `gorm:"column:verification_status"` +} + +func (s *Service) List(ctx context.Context, request PageRequest) ([]Response, int64, error) { + request.PageIndex, request.PageSize = normalizePage(request.PageIndex, request.PageSize) + if utf8.RuneCountInString(request.Keyword) > 128 || (request.Kind != "" && request.Kind != KindPolygon && request.Kind != KindDirectionLine) { + return nil, 0, ErrInvalidRequest + } + query := s.db.WithContext(ctx).Model(&Definition{}) + if keyword := strings.TrimSpace(request.Keyword); keyword != "" { + pattern := "%" + escapeLike(keyword) + "%" + query = query.Where("LOWER(name) LIKE LOWER(?) ESCAPE '\\'", pattern) + } + if request.Kind != "" { + query = query.Where("kind = ?", request.Kind) + } + switch request.RecalibrationState { + case "", "all": + case "needed": + query = query.Where("needs_recalibration = ?", true) + case "ready": + query = query.Where("needs_recalibration = ?", false) + default: + return nil, 0, ErrInvalidRequest + } + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + var definitions []Definition + if err := query.Order("updated_at DESC, name ASC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Find(&definitions).Error; err != nil { + return nil, 0, err + } + items := make([]Response, 0, len(definitions)) + for i := range definitions { + item, err := s.response(ctx, &definitions[i]) + if err != nil { + return nil, 0, err + } + items = append(items, item) + } + return items, total, nil +} + +func (s *Service) Create(ctx context.Context, request UpsertRequest) (Response, error) { + if request.ExpectedVersion != 0 { + return Response{}, ErrInvalidRequest + } + name, err := validateRequest(request) + if err != nil { + return Response{}, err + } + now := time.Now().UTC() + definitionID := uuid.NewString() + versionID := uuid.NewString() + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + route, routeErr := loadRoute(tx, request.RouteID) + if routeErr != nil { + return routeErr + } + geometry, marshalErr := json.Marshal(request.Points) + if marshalErr != nil { + return ErrInvalidGeometry + } + definition := Definition{ID: definitionID, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, CurrentVersion: 1, CurrentVersionID: versionID, Enabled: request.Enabled, CreatedBy: request.UpdateBy, UpdatedBy: request.UpdateBy, CreatedAt: now, UpdatedAt: now} + version := Version{ID: versionID, DefinitionID: definitionID, Version: 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, CreatedBy: request.UpdateBy, CreatedAt: now} + if err = tx.Create(&definition).Error; err != nil { + return err + } + return tx.Create(&version).Error + }) + if err != nil { + return Response{}, err + } + return s.Get(ctx, definitionID) +} + +func (s *Service) Update(ctx context.Context, id string, request UpsertRequest) (Response, error) { + if strings.TrimSpace(id) == "" || request.ExpectedVersion < 1 { + return Response{}, ErrInvalidRequest + } + name, err := validateRequest(request) + if err != nil { + return Response{}, err + } + now := time.Now().UTC() + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current Definition + if readErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", id).Error; readErr != nil { + if errors.Is(readErr, gorm.ErrRecordNotFound) { + return ErrNotFound + } + return readErr + } + if current.CurrentVersion != request.ExpectedVersion { + return ErrConflict + } + route, routeErr := loadRoute(tx, request.RouteID) + if routeErr != nil { + return routeErr + } + geometry, marshalErr := json.Marshal(request.Points) + if marshalErr != nil { + return ErrInvalidGeometry + } + versionID := uuid.NewString() + version := Version{ID: versionID, DefinitionID: current.ID, Version: current.CurrentVersion + 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, SupersedesID: current.CurrentVersionID, CreatedBy: request.UpdateBy, CreatedAt: now} + if createErr := tx.Create(&version).Error; createErr != nil { + return createErr + } + updates := map[string]any{"name": name, "kind": request.Kind, "device_id": route.DeviceID, "profile_token": route.ProfileToken, "profile_width": route.Width, "profile_height": route.Height, "profile_encoding": route.Encoding, "current_version": version.Version, "current_version_id": versionID, "enabled": request.Enabled, "needs_recalibration": false, "updated_by": request.UpdateBy, "updated_at": now} + result := tx.Model(&Definition{}).Where("id = ? AND current_version = ?", current.ID, request.ExpectedVersion).Updates(updates) + if result.Error != nil { + return result.Error + } + if result.RowsAffected != 1 { + return ErrConflict + } + return nil + }) + if err != nil { + return Response{}, err + } + return s.Get(ctx, id) +} + +func (s *Service) Get(ctx context.Context, id string) (Response, error) { + var definition Definition + if err := s.db.WithContext(ctx).First(&definition, "id = ?", id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return Response{}, ErrNotFound + } + return Response{}, err + } + return s.response(ctx, &definition) +} + +func (s *Service) Versions(ctx context.Context, id string) ([]VersionResponse, error) { + var count int64 + if err := s.db.WithContext(ctx).Model(&Definition{}).Where("id = ?", id).Count(&count).Error; err != nil { + return nil, err + } + if count == 0 { + return nil, ErrNotFound + } + var versions []Version + if err := s.db.WithContext(ctx).Where("definition_id = ?", id).Order("version DESC").Find(&versions).Error; err != nil { + return nil, err + } + out := make([]VersionResponse, 0, len(versions)) + for _, version := range versions { + points, err := decodePoints(version.GeometryJSON) + if err != nil { + return nil, fmt.Errorf("decode area version %d: %w", version.Version, err) + } + out = append(out, VersionResponse{ID: version.ID, Version: version.Version, Name: version.Name, Kind: version.Kind, DeviceID: version.DeviceID, ProfileToken: version.ProfileToken, ProfileWidth: version.ProfileWidth, ProfileHeight: version.ProfileHeight, ProfileEncoding: version.ProfileEncoding, Points: points, Direction: version.Direction, Enabled: version.Enabled, SupersedesID: version.SupersedesID, CreatedBy: version.CreatedBy, CreatedAt: version.CreatedAt}) + } + return out, nil +} + +func (s *Service) response(ctx context.Context, definition *Definition) (Response, error) { + var version Version + if err := s.db.WithContext(ctx).First(&version, "id = ?", definition.CurrentVersionID).Error; err != nil { + return Response{}, err + } + points, err := decodePoints(version.GeometryJSON) + if err != nil { + return Response{}, err + } + var route routeSnapshot + query := s.db.WithContext(ctx).Table("sense_devices AS d"). + Select("COALESCE(r.id, '') AS route_id, d.id AS device_id, d.name AS device_name, d.location AS device_location, COALESCE(p.token, '') AS profile_token, COALESCE(p.name, '') AS profile_name, COALESCE(p.width, 0) AS width, COALESCE(p.height, 0) AS height, COALESCE(p.encoding, '') AS encoding, COALESCE(p.verification_status, '') AS verification_status"). + Joins("LEFT JOIN sense_admission_profiles AS p ON p.device_id = d.id AND p.token = ?", definition.ProfileToken). + Joins("LEFT JOIN sense_media_routes AS r ON r.device_id = d.id AND r.profile_token = ?", definition.ProfileToken). + Where("d.id = ?", definition.DeviceID).Limit(1).Scan(&route) + if query.Error != nil { + return Response{}, query.Error + } + recalibration := definition.NeedsRecalibration || route.ProfileToken == "" || route.Verification != "ready" || route.Width != definition.ProfileWidth || route.Height != definition.ProfileHeight || !strings.EqualFold(route.Encoding, definition.ProfileEncoding) + if recalibration && !definition.NeedsRecalibration { + if err = s.db.WithContext(ctx).Model(&Definition{}).Where("id = ? AND needs_recalibration = ?", definition.ID, false).Update("needs_recalibration", true).Error; err != nil { + return Response{}, err + } + definition.NeedsRecalibration = true + } + return Response{ID: definition.ID, Name: definition.Name, Kind: definition.Kind, DeviceID: definition.DeviceID, DeviceName: route.DeviceName, DeviceLocation: route.DeviceLocation, ProfileToken: definition.ProfileToken, ProfileName: route.ProfileName, ProfileWidth: definition.ProfileWidth, ProfileHeight: definition.ProfileHeight, ProfileEncoding: definition.ProfileEncoding, RouteID: route.RouteID, Version: definition.CurrentVersion, Points: points, Direction: version.Direction, Enabled: definition.Enabled, NeedsRecalibration: recalibration, UpdatedBy: definition.UpdatedBy, UpdatedAt: definition.UpdatedAt}, nil +} + +func MarkProfilesReplaced(tx *gorm.DB, deviceID string, profiles []ProfileSnapshot) error { + if tx == nil || !tx.Migrator().HasTable(&Definition{}) { + return nil + } + available := make(map[string]ProfileSnapshot, len(profiles)) + for _, profile := range profiles { + available[profile.Token] = profile + } + var definitions []Definition + if err := tx.Where("device_id = ? AND needs_recalibration = ?", deviceID, false).Find(&definitions).Error; err != nil { + return err + } + for _, definition := range definitions { + profile, ok := available[definition.ProfileToken] + if !ok || profile.Width != definition.ProfileWidth || profile.Height != definition.ProfileHeight || !strings.EqualFold(profile.Encoding, definition.ProfileEncoding) { + if err := tx.Model(&Definition{}).Where("id = ?", definition.ID).Update("needs_recalibration", true).Error; err != nil { + return err + } + } + } + return nil +} + +func loadRoute(db *gorm.DB, routeID string) (routeSnapshot, error) { + if strings.TrimSpace(routeID) == "" { + return routeSnapshot{}, ErrProfileMissing + } + var route routeSnapshot + err := db.Table("sense_media_routes AS r"). + Select("r.id AS route_id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.width, p.height, p.encoding, p.verification_status"). + Joins("JOIN sense_devices AS d ON d.id = r.device_id"). + Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token"). + Where("r.id = ? AND p.verification_status = ?", routeID, "ready").Limit(1).Scan(&route).Error + if err != nil { + return routeSnapshot{}, err + } + if route.RouteID == "" || route.Width < 1 || route.Height < 1 { + return routeSnapshot{}, ErrProfileMissing + } + return route, nil +} + +func validateRequest(request UpsertRequest) (string, error) { + name := strings.TrimSpace(request.Name) + if name == "" || utf8.RuneCountInString(name) > 128 || request.UpdateBy < 1 || strings.TrimSpace(request.RouteID) == "" { + return "", ErrInvalidRequest + } + if err := validateGeometry(request.Kind, request.Direction, request.Points); err != nil { + return "", err + } + return name, nil +} + +func decodePoints(value string) ([]Point, error) { + var points []Point + if err := json.Unmarshal([]byte(value), &points); err != nil { + return nil, err + } + return points, nil +} + +func normalizePage(index, size int) (int, int) { + if index < 1 { + index = 1 + } + if size < 1 { + size = 10 + } + if size > 50 { + size = 50 + } + return index, size +} + +func escapeLike(value string) string { + value = strings.ReplaceAll(value, `\`, `\\`) + value = strings.ReplaceAll(value, `%`, `\%`) + return strings.ReplaceAll(value, `_`, `\_`) +} diff --git a/Sense/server/app/sense/area/service_test.go b/Sense/server/app/sense/area/service_test.go new file mode 100644 index 0000000..31f4dea --- /dev/null +++ b/Sense/server/app/sense/area/service_test.go @@ -0,0 +1,95 @@ +package area + +import ( + "context" + "errors" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func areaTestDB(t *testing.T) *gorm.DB { + t.Helper() + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil { + t.Fatal(err) + } + statements := []string{ + `CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`, + `CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`, + `CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`, + `INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`, + `INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`, + `INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`, + } + for _, statement := range statements { + if err = db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + return db +} + +func triangleRequest() UpsertRequest { + return UpsertRequest{Name: "操场危险区域", Kind: KindPolygon, RouteID: "device-1:main", Points: []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, Enabled: true, UpdateBy: 7} +} + +func TestVersionsAreAppendOnlyAndUseOptimisticConcurrency(t *testing.T) { + service := NewService(areaTestDB(t)) + created, err := service.Create(context.Background(), triangleRequest()) + if err != nil || created.Version != 1 || created.ProfileWidth != 1920 { + t.Fatalf("created=%+v err=%v", created, err) + } + request := triangleRequest() + request.Name = "操场危险区域(校准)" + request.ExpectedVersion = 1 + updated, err := service.Update(context.Background(), created.ID, request) + if err != nil || updated.Version != 2 { + t.Fatalf("updated=%+v err=%v", updated, err) + } + if _, err = service.Update(context.Background(), created.ID, request); !errors.Is(err, ErrConflict) { + t.Fatalf("stale update err=%v", err) + } + versions, err := service.Versions(context.Background(), created.ID) + if err != nil || len(versions) != 2 || versions[0].Version != 2 || versions[1].Version != 1 || versions[0].SupersedesID != versions[1].ID { + t.Fatalf("versions=%+v err=%v", versions, err) + } +} + +func TestProfileReplacementMarksOnlyChangedBindings(t *testing.T) { + db := areaTestDB(t) + service := NewService(db) + created, err := service.Create(context.Background(), triangleRequest()) + if err != nil { + t.Fatal(err) + } + if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1920, Height: 1080, Encoding: "H264"}}); err != nil { + t.Fatal(err) + } + item, _ := service.Get(context.Background(), created.ID) + if item.NeedsRecalibration { + t.Fatal("unchanged profile was marked for recalibration") + } + if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1280, Height: 720, Encoding: "H264"}}); err != nil { + t.Fatal(err) + } + item, _ = service.Get(context.Background(), created.ID) + if !item.NeedsRecalibration { + t.Fatal("resolution change did not mark recalibration") + } +} + +func TestListIsPaginatedAndDoesNotExposeMediaSecrets(t *testing.T) { + service := NewService(areaTestDB(t)) + if _, err := service.Create(context.Background(), triangleRequest()); err != nil { + t.Fatal(err) + } + items, total, err := service.List(context.Background(), PageRequest{Keyword: "操场", PageIndex: 1, PageSize: 10}) + if err != nil || total != 1 || len(items) != 1 || items[0].RouteID != "device-1:main" { + t.Fatalf("items=%+v total=%d err=%v", items, total, err) + } +} diff --git a/Sense/server/cmd/migrate/migration/version/2026081509000_area.go b/Sense/server/cmd/migrate/migration/version/2026081509000_area.go new file mode 100644 index 0000000..7f59fee --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081509000_area.go @@ -0,0 +1,60 @@ +package version + +import ( + "runtime" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "git.ilapage.cn/ila/yovision/Sense/server/app/sense/area" + "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), migrateSenseArea) +} + +func migrateSenseArea(db *gorm.DB, version string) error { + return db.Transaction(func(tx *gorm.DB) error { + if err := tx.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil { + return err + } + page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseArea", Title: "区域与警戒线", Icon: "guide", Path: "/sense/area", MenuType: "C", Permission: "sense:area:list", Component: "/sense/area/index", Sort: 9, Visible: "0", IsFrame: "1"}) + if err != nil { + return err + } + create, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaCreate", Title: "新增配置", MenuType: "F", Action: "POST", Permission: "sense:area:create", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"}) + if err != nil { + return err + } + update, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAreaUpdate", Title: "编辑配置", MenuType: "F", Action: "PUT", Permission: "sense:area:update", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"}) + if err != nil { + return err + } + for _, role := range []string{"implementation_operator", "site_admin"} { + if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, create, update}); err != nil { + return err + } + } + if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil { + return err + } + read := [][2]string{{"/api/v1/area/configurations", "GET"}, {"/api/v1/area/configurations/:id/versions", "GET"}} + write := [][2]string{{"/api/v1/area/configurations", "POST"}, {"/api/v1/area/configurations/:id", "PUT"}} + for _, role := range []string{"implementation_operator", "site_admin", "viewer"} { + policies := append([][2]string{}, read...) + if role != "viewer" { + policies = append(policies, write...) + } + for _, policy := range policies { + 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 + } + } + } + return tx.Create(&common.Migration{Version: version}).Error + }) +} diff --git a/Sense/server/cmd/migrate/migration/version/2026081509000_area_test.go b/Sense/server/cmd/migrate/migration/version/2026081509000_area_test.go new file mode 100644 index 0000000..2314e41 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081509000_area_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/area" + migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models" + common "git.ilapage.cn/ila/yovision/Sense/server/common/models" +) + +func TestAreaMigrationOnPostgres(t *testing.T) { + dsn := os.Getenv("SENSE_AREA_MIGRATION_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set SENSE_AREA_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_area_69_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") }) + 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) + } + } + if err = migrateSenseArea(db, "2026081509000_area.go"); err != nil { + t.Fatal(err) + } + var menus, policies, definitions, versions, applied int64 + db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseArea%").Count(&menus) + db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/area%").Count(&policies) + db.Model(&area.Definition{}).Count(&definitions) + db.Model(&area.Version{}).Count(&versions) + db.Model(&common.Migration{}).Where("version = ?", "2026081509000_area.go").Count(&applied) + if menus != 3 || policies != 10 || definitions != 0 || versions != 0 || applied != 1 { + t.Fatalf("menus=%d policies=%d definitions=%d versions=%d applied=%d", menus, policies, definitions, versions, applied) + } +} diff --git a/Sense/ui/src/api/sense/area.js b/Sense/ui/src/api/sense/area.js new file mode 100644 index 0000000..9d00a8f --- /dev/null +++ b/Sense/ui/src/api/sense/area.js @@ -0,0 +1,17 @@ +import request from '@/utils/request' + +export function listAreaConfigurations(query) { + return request({ url: '/api/v1/area/configurations', method: 'get', params: query }) +} + +export function createAreaConfiguration(data) { + return request({ url: '/api/v1/area/configurations', method: 'post', data }) +} + +export function updateAreaConfiguration(id, data) { + return request({ url: `/api/v1/area/configurations/${id}`, method: 'put', data }) +} + +export function listAreaVersions(id) { + return request({ url: `/api/v1/area/configurations/${id}/versions`, method: 'get' }) +} diff --git a/Sense/ui/src/components/sense/geometry-editor/geometry.js b/Sense/ui/src/components/sense/geometry-editor/geometry.js new file mode 100644 index 0000000..8109ea9 --- /dev/null +++ b/Sense/ui/src/components/sense/geometry-editor/geometry.js @@ -0,0 +1,55 @@ +export const POLYGON = 'polygon' +export const DIRECTION_LINE = 'direction_line' + +export function clamp(value) { + return Math.min(1, Math.max(0, Number(value))) +} + +export function geometryError(kind, points, direction = '') { + if (!Array.isArray(points)) return '请在画面中添加坐标点' + if (kind === POLYGON && (points.length < 3 || points.length > 64)) return '多边形需要 3 到 64 个点' + if (kind === DIRECTION_LINE && points.length !== 2) return '方向警戒线需要恰好 2 个点' + if (kind === DIRECTION_LINE && !['forward', 'reverse'].includes(direction)) return '请选择警戒方向' + if (![POLYGON, DIRECTION_LINE].includes(kind)) return '请选择配置类型' + if (points.some(point => !Number.isFinite(point.x) || !Number.isFinite(point.y) || point.x < 0 || point.x > 1 || point.y < 0 || point.y > 1)) return '坐标必须位于画面范围内' + if (kind === POLYGON && selfIntersects(points)) return '多边形边线不能交叉,请调整顶点' + if (kind === POLYGON && Math.abs(polygonArea(points)) < 0.000001) return '多边形面积过小,请重新绘制' + if (kind === DIRECTION_LINE && samePoint(points[0], points[1])) return '警戒线起点和终点不能重合' + return '' +} + +function samePoint(a, b) { + return Math.abs(a.x - b.x) < 0.0000001 && Math.abs(a.y - b.y) < 0.0000001 +} + +function polygonArea(points) { + return points.reduce((total, point, index) => { + const next = points[(index + 1) % points.length] + return total + point.x * next.y - next.x * point.y + }, 0) / 2 +} + +function selfIntersects(points) { + for (let first = 0; first < points.length; first += 1) { + const a = points[first] + const b = points[(first + 1) % points.length] + for (let second = first + 1; second < points.length; second += 1) { + if (second === first || second === (first + 1) % points.length || first === (second + 1) % points.length) continue + const c = points[second] + const d = points[(second + 1) % points.length] + if (segmentsIntersect(a, b, c, d)) return true + } + } + return false +} + +function segmentsIntersect(a, b, c, d) { + const orientation = (p, q, r) => (q.y - p.y) * (r.x - q.x) - (q.x - p.x) * (r.y - q.y) + const values = [orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)] + if (((values[0] > 0 && values[1] < 0) || (values[0] < 0 && values[1] > 0)) && ((values[2] > 0 && values[3] < 0) || (values[2] < 0 && values[3] > 0))) return true + const onSegment = (p, q, r) => q.x <= Math.max(p.x, r.x) + 0.0000001 && q.x >= Math.min(p.x, r.x) - 0.0000001 && q.y <= Math.max(p.y, r.y) + 0.0000001 && q.y >= Math.min(p.y, r.y) - 0.0000001 + return (Math.abs(values[0]) < 0.0000001 && onSegment(a, c, b)) || + (Math.abs(values[1]) < 0.0000001 && onSegment(a, d, b)) || + (Math.abs(values[2]) < 0.0000001 && onSegment(c, a, d)) || + (Math.abs(values[3]) < 0.0000001 && onSegment(c, b, d)) +} diff --git a/Sense/ui/src/components/sense/geometry-editor/index.vue b/Sense/ui/src/components/sense/geometry-editor/index.vue new file mode 100644 index 0000000..02cc5fd --- /dev/null +++ b/Sense/ui/src/components/sense/geometry-editor/index.vue @@ -0,0 +1,307 @@ + + + + + 画面坐标 + {{ instruction }} + + + 添加中心点 + 撤销 + 清空 + + + + + + 选择可用视频后,可在实时画面上绘制 + + + + + + + + + + + {{ index + 1 }} + + + + + + 已添加 {{ points.length }} 个点 + 键盘:Enter 添加中心点;聚焦顶点后用方向键移动,Delete 删除。 + + {{ error }} + + + + + + diff --git a/Sense/ui/src/views/sense/area/areaPayload.js b/Sense/ui/src/views/sense/area/areaPayload.js new file mode 100644 index 0000000..ea94254 --- /dev/null +++ b/Sense/ui/src/views/sense/area/areaPayload.js @@ -0,0 +1,20 @@ +import { DIRECTION_LINE, geometryError } from '@/components/sense/geometry-editor/geometry' + +export function buildAreaPayload(form) { + return { + name: String(form.name || '').trim(), + kind: form.kind, + routeId: form.routeId, + points: (form.points || []).map(point => ({ x: Number(point.x), y: Number(point.y) })), + direction: form.kind === DIRECTION_LINE ? form.direction : '', + enabled: Boolean(form.enabled), + expectedVersion: Number(form.version || 0) + } +} + +export function validateAreaForm(form) { + if (!String(form.name || '').trim()) return '请填写配置名称' + if (!form.routeId) return '请选择设备与视频码流' + const payload = buildAreaPayload(form) + return geometryError(payload.kind, payload.points, payload.direction) +} diff --git a/Sense/ui/src/views/sense/area/index.vue b/Sense/ui/src/views/sense/area/index.vue new file mode 100644 index 0000000..14f4d60 --- /dev/null +++ b/Sense/ui/src/views/sense/area/index.vue @@ -0,0 +1,455 @@ + + + + + + + 区域与警戒线 + 在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。 + + 新增配置 + + + + + + + + + + + + + + + + + + + + 搜索 + 重置 + + + + + + + + + + {{ kindLabel(scope.row.kind) }} + + + + + {{ scope.row.deviceName || scope.row.deviceId }} + {{ profileLabel(scope.row) }} + + + + v{{ scope.row.version }} + + + + 需要重新校准 + 已校准 + + + + + {{ scope.row.enabled ? '已启用' : '已停用' }} + + + + {{ parseTime(scope.row.updatedAt) }} + + + + 编辑/校准 + 版本记录 + + + + + + + + + + + + {{ form.id ? '编辑并生成新版本' : '新增区域配置' }} + 当前 v{{ form.version }};保存后旧版本仍可追溯 + + 需要重新校准 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ selectedProfileSummary }} + + + + + + + {{ formError }} + + 取消 + 保存新版本 + + + + + + + v{{ scope.row.version }} + + + + {{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }} + + + {{ scope.row.enabled ? '已启用' : '已停用' }} + + + {{ scope.row.points.length }} + + + {{ parseTime(scope.row.createdAt) }} + + + + + + + + + + diff --git a/Sense/ui/tests/unit/sense/areaEditor.spec.js b/Sense/ui/tests/unit/sense/areaEditor.spec.js new file mode 100644 index 0000000..b54bb0f --- /dev/null +++ b/Sense/ui/tests/unit/sense/areaEditor.spec.js @@ -0,0 +1,25 @@ +import GeometryEditor from '@/components/sense/geometry-editor/index.vue' + +describe('Sense geometry editor keyboard operations', () => { + it('adds, moves and removes a point without a mouse', () => { + const context = { + disabled: false, + kind: 'polygon', + points: [], + undoStack: [], + canAdd: true, + $emit(event, points) { this.points = points } + } + context.commit = GeometryEditor.methods.commit.bind(context) + GeometryEditor.methods.addCenterPoint.call(context) + expect(context.points).toEqual([{ x: 0.5, y: 0.5 }]) + + const moveEvent = { key: 'ArrowRight', preventDefault: jest.fn() } + GeometryEditor.methods.movePointByKeyboard.call(context, 0, moveEvent) + expect(context.points[0].x).toBeCloseTo(0.51) + expect(moveEvent.preventDefault).toHaveBeenCalled() + + GeometryEditor.methods.movePointByKeyboard.call(context, 0, { key: 'Delete', preventDefault: jest.fn() }) + expect(context.points).toEqual([]) + }) +}) diff --git a/Sense/ui/tests/unit/sense/areaGeometry.spec.js b/Sense/ui/tests/unit/sense/areaGeometry.spec.js new file mode 100644 index 0000000..bc6e55d --- /dev/null +++ b/Sense/ui/tests/unit/sense/areaGeometry.spec.js @@ -0,0 +1,16 @@ +import { DIRECTION_LINE, POLYGON, clamp, geometryError } from '@/components/sense/geometry-editor/geometry' + +describe('Sense area geometry', () => { + it('validates polygon and direction-line constraints', () => { + expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.1 }, { x: 0.5, y: 0.8 }])).toBe('') + expect(geometryError(POLYGON, [{ x: 0.1, y: 0.1 }, { x: 0.8, y: 0.8 }, { x: 0.8, y: 0.1 }, { x: 0.1, y: 0.8 }])).toContain('不能交叉') + expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], 'forward')).toBe('') + expect(geometryError(DIRECTION_LINE, [{ x: 0.2, y: 0.5 }], 'forward')).toContain('恰好 2 个点') + }) + + it('clamps pointer coordinates to the normalized frame', () => { + expect(clamp(-0.2)).toBe(0) + expect(clamp(0.4)).toBe(0.4) + expect(clamp(1.2)).toBe(1) + }) +}) diff --git a/Sense/ui/tests/unit/sense/areaPayload.spec.js b/Sense/ui/tests/unit/sense/areaPayload.spec.js new file mode 100644 index 0000000..357974b --- /dev/null +++ b/Sense/ui/tests/unit/sense/areaPayload.spec.js @@ -0,0 +1,13 @@ +import { buildAreaPayload, validateAreaForm } from '@/views/sense/area/areaPayload' + +describe('Sense area payload', () => { + it('keeps the optimistic version and normalized geometry', () => { + const form = { name: ' 东门警戒线 ', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: '0.2', y: '0.5' }, { x: '0.8', y: '0.5' }], direction: 'reverse', enabled: true, version: 4 } + expect(validateAreaForm(form)).toBe('') + expect(buildAreaPayload(form)).toEqual({ name: '东门警戒线', kind: 'direction_line', routeId: 'device-1:main', points: [{ x: 0.2, y: 0.5 }, { x: 0.8, y: 0.5 }], direction: 'reverse', enabled: true, expectedVersion: 4 }) + }) + + it('rejects incomplete geometry before sending', () => { + expect(validateAreaForm({ name: '危险区域', kind: 'polygon', routeId: 'route-1', points: [] })).toContain('3 到 64') + }) +}) diff --git a/docs/02-architecture-and-code-map.md b/docs/02-architecture-and-code-map.md index a9f1607..5d659c3 100644 --- a/docs/02-architecture-and-code-map.md +++ b/docs/02-architecture-and-code-map.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Architecture-and-Code-Map wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.- -wiki_revision: 0a15795aa69c5a0279a42ff1b48b44ddf68a9fad -synchronized_at: 2026-08-14T10:45:46Z +wiki_revision: 0ba2909431bd04320a9b31121126b59b1824e3dc +synchronized_at: 2026-08-15T01:13:02Z # 架构与代码地图 @@ -118,3 +118,15 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea 旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。 + + +## Sense 区域与警戒线代码入口 + +工单 #69 在 `Sense/server/app/sense/area/` 建立区域配置业务层,GoAdmin 路由位于 `Sense/server/app/admin/router/sense_area.go`,前端页面位于 `Sense/ui/src/views/sense/area/index.vue`。页面继续复用 BasicLayout、Axios、Element Plus 表单/表格/分页/Dialog/Tag/Alert 和权限指令;`Sense/ui/src/components/sense/geometry-editor/` 是唯一新增的业务专用绘制组件。 + +数据采用两层结构:`sense_area_definitions` 保存当前版本指针和当前校准状态,`sense_area_versions` 保存每次创建、编辑、启停或重校准形成的不可变快照。更新请求携带 `expectedVersion`,服务在 PostgreSQL 事务中锁定当前定义;并发保存只有一个成功,其余返回冲突。历史版本不覆盖、不删除。 + +几何坐标使用画面内 0–1 归一化值,同时每个版本固化 Device、Profile Token、分辨率和编码。视频接入成功替换 Profile 后,`admission` 服务比较 Token、分辨率和编码并主动标记当前定义 `needs_recalibration`;读取时也会对缺失或失效 Profile 进行保守校验。系统不自动重投影旧坐标,只有用户重新确认画面并保存新版本后才清除重校准状态。 + +认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。 + diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md index 09e59d3..df741d8 100644 --- a/docs/03-business-rules-and-glossary.md +++ b/docs/03-business-rules-and-glossary.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Business-Rules-and-Glossary wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.- -wiki_revision: e80685e19115e26384d58ddee8a15e5d80b03bb4 -synchronized_at: 2026-08-14T10:45:50Z +wiki_revision: 2bcd90c508180eb9a2f9fe37b62115d7d9207e18 +synchronized_at: 2026-08-15T01:13:04Z # 业务规则与术语 @@ -134,3 +134,15 @@ synchronized_at: 2026-08-14T10:45:50Z - Profile 保存 token、名称、分辨率、编码、用途、无凭据 Stream URI 和逐 Profile 验证状态;主码流默认取分辨率最高项,子码流取最低项。 - 认证失败、超时、时间异常、目标未授权和重定向拒绝必须给出不同状态。失败重探不得删除最后一次已验证 Profile;凭据更新后可重新探测。 + + +## Sense 区域与方向警戒线规则 + +- 区域配置必须绑定已验证的 Device、Profile Token、分辨率和编码;客户端只提交媒体路由引用,最终绑定规格由后端重新查询确认。 +- 多边形使用 3–64 个画面内坐标点,必须有非零面积,边线不得自交或重叠;方向警戒线恰好两个不同点,并明确“起点到终点”或“终点到起点”。 +- 坐标以 0–1 归一化值保存,同时固化当时分辨率和编码。Profile 删除、Token 替换、分辨率或编码变化必须设置 `needs_recalibration`,不得静默缩放或重投影旧几何。 +- 新建为 v1;编辑、启停和重新校准都追加不可变版本。请求使用 `expectedVersion` 乐观并发,过期版本返回 409,旧版本保留用于审计。 +- `admin`、`implementation_operator`、`site_admin` 可创建和保存新版本;`viewer` 只读。页面状态必须同时使用文字和 Tag,不能只靠颜色表达。 +- 鼠标可点击/拖动顶点;键盘必须能添加、移动和删除顶点。错误在绘制区域附近以可被辅助技术感知的文字给出,并提供撤销、清空和未保存关闭确认。 +- 区域配置是 Sense 内部事实;#69 不发布 Brain 契约。后续 Sense→Brain 配置协议必须由独立协调工单从当前版本投影生成,不能共享数据库模型。 + diff --git a/docs/04-local-development-and-verification.md b/docs/04-local-development-and-verification.md index c289b26..8c4709b 100644 --- a/docs/04-local-development-and-verification.md +++ b/docs/04-local-development-and-verification.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Local-Development-and-Verification wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.- -wiki_revision: 95be7e2aa152205f524a185897dff5eb2c7e7e04 -synchronized_at: 2026-08-14T10:45:55Z +wiki_revision: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54 +synchronized_at: 2026-08-15T01:13:07Z # 本地开发与验证 @@ -245,3 +245,40 @@ corepack pnpm@9.15.1 build:prod 3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。 4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。 5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。 + + +### Sense 区域与警戒线验证 + +后端定向与全量验证: + +```powershell +cd Sense/server +go test ./app/sense/area ./app/sense/admission +go test -race ./app/sense/area ./app/sense/admission +go test ./... +go vet ./... +go build ./... +``` + +使用隔离 PostgreSQL 验证迁移和真实并发;连接值只放当前进程环境,不写入仓库或日志: + +```powershell +$env:SENSE_AREA_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>' +go test ./cmd/migrate/migration/version -run TestAreaMigrationOnPostgres -count=1 -v + +$env:SENSE_AREA_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>' +go test ./app/sense/area -run TestConcurrentUpdateOnPostgresReturnsConflict -count=1 -v +``` + +前端验证: + +```powershell +cd Sense/ui +corepack pnpm@9.15.1 install --frozen-lockfile +corepack pnpm@9.15.1 lint +corepack pnpm@9.15.1 test:unit +corepack pnpm@9.15.1 build:prod +``` + +浏览器 smoke 至少覆盖:鼠标添加和拖动顶点;键盘 Enter 添加、方向键移动、Delete 删除;错误文字可见并具有 aria-live/alert 语义;刷新后版本、启停和重新校准状态仍可追溯。真实摄像机校准只使用明确授权设备,不记录地址、URI、凭据或视频内容。Brain、Bell 不启动时必须能独立保存、读取和预览。 + diff --git a/docs/06-troubleshooting.md b/docs/06-troubleshooting.md index 7c77424..130cb14 100644 --- a/docs/06-troubleshooting.md +++ b/docs/06-troubleshooting.md @@ -2,8 +2,8 @@ generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) wiki_page: Troubleshooting wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting -wiki_revision: 331755cb39ac9d63c188395c5caafe0815d64975 -synchronized_at: 2026-08-14T10:46:04Z +wiki_revision: 3523b7cd1f126ebc3ef77414cb6d658103f2491d +synchronized_at: 2026-08-15T01:13:11Z # 故障排查 @@ -101,3 +101,18 @@ synchronized_at: 2026-08-14T10:46:04Z + + +## Sense 区域与警戒线排错 + +| 现象 | 原因与处理 | +|---|---| +| 显示“需要重新校准” | 绑定 Profile 已删除、验证失效,或 Token、分辨率、编码发生变化;打开“编辑/校准”,选择当前可用码流,在实际画面确认坐标后保存新版本。不要手工清状态或复制旧坐标冒充校准。 | +| 保存提示配置已被其他用户更新 | 当前页面的 `expectedVersion` 已过期;刷新列表,查看最新版本后重新编辑。系统会保留已成功写入的版本,不覆盖对方结果。 | +| 画面可见但无法添加更多顶点 | 方向警戒线最多两个点,多边形最多 64 个点;检查配置类型,必要时撤销或清空后重画。 | +| 提示边线交叉或面积过小 | 顶点顺序形成自交、重叠或退化多边形;拖动顶点消除交叉,确保至少三个不同且围成有效面积的点。 | +| 实时画面不可用 | 先到“实时监看”确认该 Profile 可播放,再检查 MediaMTX/WebRTC 公开地址。区域 API 不返回或要求填写 RTSP URI。 | +| Profile 已恢复但仍显示重校准 | 这是保守安全状态;恢复相同规格不会自动认可旧坐标。必须由有权限用户打开实际画面确认并保存新版本。 | +| viewer 看得到页面但不能保存 | 符合只读权限;由 implementation_operator、site_admin 或 admin 完成配置。 | +| 键盘无法操作顶点 | Tab 聚焦画布或编号顶点;Enter/Space 添加中心点,方向键移动,Delete/Backspace 删除。检查浏览器焦点轮廓是否可见。 | + diff --git a/docs/task/69-Sense多边形区域与方向警戒线配置.md b/docs/task/69-Sense多边形区域与方向警戒线配置.md new file mode 100644 index 0000000..18483d4 --- /dev/null +++ b/docs/task/69-Sense多边形区域与方向警戒线配置.md @@ -0,0 +1,77 @@ + +generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件) +wiki_page: Task-69-Sense多边形区域与方向警戒线配置 +wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-69-Sense%E5%A4%9A%E8%BE%B9%E5%BD%A2%E5%8C%BA%E5%9F%9F%E4%B8%8E%E6%96%B9%E5%90%91%E8%AD%A6%E6%88%92%E7%BA%BF%E9%85%8D%E7%BD%AE.- +wiki_revision: 107d58783efef0d9876f082d5e61ed2b5bc6045d +synchronized_at: 2026-08-15T01:24:21Z + + +# 69 Sense 多边形区域与方向警戒线配置 + +- 类型:需求 +- 所属 Epic:#7 +- 所属 MVP:#8 +- 状态:待验收 +- 日期:2026-08-15 +- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/69 +- 评审 PR:https://git.ilapage.cn/ila/yovision/pulls/87 + +## 背景与目标 + +在 #61 冻结的 GoAdmin/go-admin-ui 源码派生基线上,为网管和非技术实施人员提供绑定 Device/Profile/分辨率的危险区域与方向警戒线配置。配置必须可追溯、能识别视频 Profile 变化,并且 Brain、Bell 不启动时可独立管理和预览。 + +## 最终方案 + +- 新增当前区域定义与不可变版本表;创建为 v1,编辑、启停和重新校准均追加版本,不覆盖历史。 +- 服务层在 PostgreSQL 事务内锁定当前定义并校验 `expectedVersion`;并发保存只允许一方成功,另一方返回稳定 409 冲突。 +- 服务端根据监看路由解析 Device/Profile,保存归一化坐标和冻结的 Profile token、分辨率、编码等非敏感快照;不信任客户端提交的绑定元数据。 +- 多边形支持 3–64 个点并拒绝越界、零面积、自交和重叠边;方向线固定两个点并记录正向/反向。 +- admission 完成 Profile 替换后按 token、分辨率和编码比较,主动标记 `needs_recalibration`;读取时也保守识别 Profile 缺失、未验证或发生变化。 +- 前端复用 go-admin-ui 的 BasicLayout、Axios、Element Plus 搜索、表格、分页、表单、Dialog、Tag、Alert 和权限按钮,仅新增视频坐标几何编辑器。 +- 编辑器支持鼠标点选/拖动,以及键盘 Enter/Space 加点、方向键移动、Delete/Backspace 删除;提供可见焦点、文字错误、撤销、清空和未保存关闭确认。 +- 权限沿用 GoAdmin Casbin:viewer 只读,implementation_operator/site_admin 可写;页面预览复用 #68 的实时监看会话。 + +## 修改文件 + +- `Sense/server/app/sense/area/**`:模型、DTO、几何校验、服务、API 与测试。 +- `Sense/server/cmd/migrate/migration/version/2026081509000_area*`:表、菜单、权限迁移及测试。 +- `Sense/server/app/admin/router/sense_area.go`:区域 API 路由。 +- `Sense/server/app/sense/admission/service*.go`:Profile 替换触发重新校准。 +- `Sense/ui/src/views/sense/area/**`、`Sense/ui/src/api/sense/area.js`:区域管理页面、请求与载荷。 +- `Sense/ui/src/components/sense/geometry-editor/**`:业务专用几何编辑器。 +- `Sense/ui/tests/unit/sense/area*.spec.js`:载荷、几何和页面行为单测。 +- Wiki 的 Architecture、Business Rules、Local Development、Troubleshooting:记录架构、规则、验证和排错;`docs/` 为同步镜像。 + +## 验收结果 + +| 验收标准 | 结果 | +|---|---| +| 多边形与方向线几何校验、版本并发和审计成立 | 通过 | +| 配置绑定明确的 Device/Profile/分辨率 | 通过 | +| Profile、分辨率或能力变化触发 needs_recalibration | 通过 | +| 旧版本可追溯且不能被静默覆盖 | 通过 | +| 画布键盘/鼠标操作、错误反馈和视觉风格一致 | 通过 | +| Brain/Bell 未启动时可独立保存、读取和预览配置 | 通过 | + +## 测试 + +- `go test ./...`:通过。 +- `go vet ./...`:通过。 +- `go build ./...`:通过。 +- `go test -race ./app/sense/area ./app/sense/admission`:通过。 +- PostgreSQL 17 实库:迁移、3 个菜单、10 条 Casbin 策略及并发更新测试通过;并发结果为一次成功、一次冲突、两个版本。 +- `pnpm lint`:通过,0 error;保留 32 个冻结上游 warning。 +- `pnpm test:unit`:16 suites / 45 tests 通过。 +- `pnpm build:prod`:通过,保留 6 个冻结上游 warning。 +- Edge/Playwright 浏览器烟测:鼠标绘制、键盘移动/删除和 ARIA 错误提示通过。 +- Brain、Bell 未启动,独立后端与前端验证通过。 +- **未验证部分**:客户现场授权摄像头、实际部署反向代理及目标客户浏览器上的端到端人工操作尚未验证,留给本工单人工验收或后续集成验收。 + +## 遗留问题 + +- Sense→Brain 的版本化区域配置发布属于后续共享契约工单,本工单未写入 `contracts/`,不阻塞 Sense 独立配置能力。 + +## 相关提交 + +- `17bd383` feat: 重建 Sense 区域与警戒线配置 (#69) +- `f82dd51` docs: 记录 Sense 区域配置架构 (#69) diff --git a/wiki-docs.json b/wiki-docs.json index f251e76..9eab71e 100644 --- a/wiki-docs.json +++ b/wiki-docs.json @@ -123,6 +123,10 @@ { "page": "Task-68-Sense单路实时监看与播放状态反馈", "path": "docs/task/68-Sense单路实时监看与播放状态反馈.md" + }, + { + "page": "Task-69-Sense多边形区域与方向警戒线配置", + "path": "docs/task/69-Sense多边形区域与方向警戒线配置.md" } ] }
{{ error }}
在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。
{{ formError }}