feat: 重建 Sense 区域与警戒线配置 (#69)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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, `_`, `\_`)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user