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 @@ +