Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bc113af04 | ||
|
|
61b79db9f7 | ||
|
|
55be48069f | ||
|
|
afd4dab567 | ||
|
|
f82dd51d95 | ||
|
|
17bd383229 | ||
|
|
020bf3fe5a | ||
|
|
17c1afd195 | ||
|
|
c83c181a12 | ||
|
|
a279a1ec0d | ||
|
|
c63c623df5 | ||
|
|
b1bdb91fdb |
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/liveview"
|
||||
"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, registerSenseLiveviewRouter)
|
||||
routerNoCheckRole = append(routerNoCheckRole, registerSenseLiveviewPlayerRouter)
|
||||
}
|
||||
|
||||
func registerSenseLiveviewRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||
api := &liveview.API{}
|
||||
r := v1.Group("/liveview").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||
r.GET("/routes", api.List)
|
||||
r.POST("/sessions", api.Create)
|
||||
r.GET("/sessions/:id", api.Get)
|
||||
}
|
||||
|
||||
func registerSenseLiveviewPlayerRouter(v1 *gin.RouterGroup) {
|
||||
api := &liveview.API{}
|
||||
v1.GET("/liveview/player/:id", api.Player)
|
||||
}
|
||||
@@ -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,134 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"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), 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"), 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 struct {
|
||||
RouteID string `json:"routeId"`
|
||||
}
|
||||
if err = decodeJSON(c, &request); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
session, err := service.Create(c.Request.Context(), user.GetUserId(c), request.RouteID, c.Request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(session, "播放会话已创建")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||
return
|
||||
}
|
||||
session, err := service.Get(c.Request.Context(), user.GetUserId(c), c.Param("id"))
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(session, "查询成功")
|
||||
}
|
||||
|
||||
var playerTemplate = template.Must(template.New("liveview-player").Parse(`<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#101419;overflow:hidden}</style></head>
|
||||
<body><iframe src="{{.}}" title="Sense 实时视频" allow="autoplay; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`))
|
||||
|
||||
func (e *API) Player(c *gin.Context) {
|
||||
target, err := NewService(nil, nil).PlayerTarget(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.String(http.StatusGone, "播放会话已过期,请重新连接")
|
||||
return
|
||||
}
|
||||
parsed := template.URL(target)
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||
if err = playerTemplate.Execute(c.Writer, parsed); err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrRouteNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, ErrSessionExpired):
|
||||
e.Error(http.StatusGone, err, "播放会话已过期,请重新连接")
|
||||
case strings.Contains(err.Error(), "WEBRTC_PUBLIC_BASE"), strings.Contains(err.Error(), "浏览器可访问"):
|
||||
e.Error(http.StatusServiceUnavailable, err, "浏览器播放地址未正确配置")
|
||||
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, 16<<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,41 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPlayerUsesShortLivedCapabilityAndRestrictiveHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := NewStore()
|
||||
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
store.now = func() time.Time { return now }
|
||||
previous := defaultStore
|
||||
defaultStore = store
|
||||
t.Cleanup(func() { defaultStore = previous })
|
||||
store.put(sessionRecord{ID: "view_test", OwnerID: 7, RouteID: "route-1", TargetURL: "http://127.0.0.1:8889/sense_test?controls=true", ExpiresAt: now.Add(sessionTTL)})
|
||||
|
||||
router := gin.New()
|
||||
api := &API{}
|
||||
router.GET("/api/v1/liveview/player/:id", api.Player)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("X-Frame-Options") != "SAMEORIGIN" {
|
||||
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "http://127.0.0.1:8889/sense_test?controls=true") || !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") {
|
||||
t.Fatalf("unexpected wrapper response: %s", recorder.Body.String())
|
||||
}
|
||||
|
||||
now = now.Add(sessionTTL)
|
||||
expired := httptest.NewRecorder()
|
||||
router.ServeHTTP(expired, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||
if expired.Code != http.StatusGone {
|
||||
t.Fatalf("expired capability status=%d", expired.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("实时监看请求不符合要求")
|
||||
ErrRouteNotFound = errors.New("可监看的视频不存在")
|
||||
ErrSessionExpired = errors.New("播放会话已过期")
|
||||
validMediaPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||
validDNSHost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||
)
|
||||
|
||||
const sessionTTL = 2 * time.Minute
|
||||
|
||||
type PageRequest struct {
|
||||
Keyword string
|
||||
PageIndex int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type RouteResponse struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
DeviceLocation string `json:"deviceLocation"`
|
||||
ProfileToken string `json:"profileToken"`
|
||||
ProfileName string `json:"profileName"`
|
||||
ProfileKind string `json:"profileKind"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
Actual string `json:"actual"`
|
||||
Detail string `json:"detail"`
|
||||
Readers int `json:"readers"`
|
||||
}
|
||||
|
||||
type SessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
RouteID string `json:"routeId"`
|
||||
PlayerURL string `json:"playerUrl"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
ProfileName string `json:"profileName"`
|
||||
}
|
||||
|
||||
type routeRecord struct {
|
||||
RouteResponse
|
||||
Path string `gorm:"column:path"`
|
||||
Desired string `gorm:"column:desired"`
|
||||
}
|
||||
|
||||
type sessionRecord struct {
|
||||
ID string
|
||||
OwnerID int
|
||||
RouteID string
|
||||
TargetURL string
|
||||
ExpiresAt time.Time
|
||||
DeviceName string
|
||||
ProfileName string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]sessionRecord
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{sessions: make(map[string]sessionRecord), now: time.Now}
|
||||
}
|
||||
|
||||
var defaultStore = NewStore()
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, store *Store) *Service {
|
||||
if store == nil {
|
||||
store = defaultStore
|
||||
}
|
||||
return &Service{db: db, store: store}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, request PageRequest) ([]RouteResponse, int64, error) {
|
||||
if request.PageIndex < 1 {
|
||||
request.PageIndex = 1
|
||||
}
|
||||
if request.PageSize < 1 {
|
||||
request.PageSize = 10
|
||||
}
|
||||
if request.PageSize > 50 {
|
||||
request.PageSize = 50
|
||||
}
|
||||
query := s.routeQuery(ctx).Where("r.desired = ?", "running")
|
||||
keyword := strings.TrimSpace(request.Keyword)
|
||||
if len([]rune(keyword)) > 128 {
|
||||
return nil, 0, ErrInvalidRequest
|
||||
}
|
||||
if keyword != "" {
|
||||
pattern := "%" + escapeLike(keyword) + "%"
|
||||
query = query.Where("LOWER(d.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(d.location) LIKE LOWER(?) ESCAPE '\\' OR LOWER(p.name) LIKE LOWER(?) ESCAPE '\\'", pattern, pattern, pattern)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var records []routeRecord
|
||||
if err := query.Order("d.name ASC, p.kind ASC, p.width DESC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Scan(&records).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]RouteResponse, 0, len(records))
|
||||
for _, record := range records {
|
||||
items = append(items, record.RouteResponse)
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ownerID int, routeID string, request *http.Request) (SessionResponse, error) {
|
||||
if ownerID < 1 || strings.TrimSpace(routeID) == "" {
|
||||
return SessionResponse{}, ErrInvalidRequest
|
||||
}
|
||||
record, err := s.route(ctx, routeID)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
if record.Desired != "running" || !validMediaPath.MatchString(record.Path) {
|
||||
return SessionResponse{}, ErrRouteNotFound
|
||||
}
|
||||
base, err := playbackBase(request)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
target := *base
|
||||
target.Path = strings.TrimRight(target.Path, "/") + "/" + record.Path
|
||||
target.RawQuery = "controls=true&muted=true&autoplay=true"
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
now := s.store.now().UTC()
|
||||
session := sessionRecord{ID: id, OwnerID: ownerID, RouteID: record.ID, TargetURL: target.String(), ExpiresAt: now.Add(sessionTTL), DeviceName: record.DeviceName, ProfileName: record.ProfileName}
|
||||
s.store.put(session)
|
||||
return responseFrom(session, record), nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, ownerID int, id string) (SessionResponse, error) {
|
||||
session, err := s.store.get(id, ownerID, true)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
record, err := s.route(ctx, session.RouteID)
|
||||
if err != nil || record.Desired != "running" {
|
||||
return SessionResponse{}, ErrRouteNotFound
|
||||
}
|
||||
session.ExpiresAt = s.store.now().UTC().Add(sessionTTL)
|
||||
s.store.put(session)
|
||||
return responseFrom(session, record), nil
|
||||
}
|
||||
|
||||
func (s *Service) PlayerTarget(id string) (string, error) {
|
||||
session, err := s.store.get(id, 0, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return session.TargetURL, nil
|
||||
}
|
||||
|
||||
func (s *Service) route(ctx context.Context, id string) (routeRecord, error) {
|
||||
var record routeRecord
|
||||
if err := s.routeQuery(ctx).Where("r.id = ?", id).Limit(1).Scan(&record).Error; err != nil {
|
||||
return routeRecord{}, err
|
||||
}
|
||||
if record.ID == "" {
|
||||
return routeRecord{}, ErrRouteNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) routeQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("sense_media_routes AS r").
|
||||
Select("r.id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.kind AS profile_kind, p.width, p.height, p.encoding, r.actual, r.detail, r.readers, r.path, r.desired").
|
||||
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("d.status <> ? AND p.verification_status = ?", "disabled", "ready")
|
||||
}
|
||||
|
||||
func responseFrom(session sessionRecord, route routeRecord) SessionResponse {
|
||||
return SessionResponse{ID: session.ID, RouteID: session.RouteID, PlayerURL: "/api/v1/liveview/player/" + session.ID, Status: playbackStatus(route.Actual), Detail: route.Detail, ExpiresAt: session.ExpiresAt, DeviceName: session.DeviceName, ProfileName: session.ProfileName}
|
||||
}
|
||||
|
||||
func playbackStatus(actual string) string {
|
||||
switch actual {
|
||||
case "ready", "waiting", "stopped":
|
||||
return actual
|
||||
case "credential_unavailable", "profile_unavailable":
|
||||
return "authentication_failed"
|
||||
case "path_missing", "apply_failed", "status_unavailable":
|
||||
return "stream_not_found"
|
||||
case "process_unavailable":
|
||||
return "service_unavailable"
|
||||
default:
|
||||
return "offline"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) put(session sessionRecord) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.now()
|
||||
for id, item := range s.sessions {
|
||||
if !now.Before(item.ExpiresAt) || item.OwnerID == session.OwnerID {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (s *Store) get(id string, ownerID int, checkOwner bool) (sessionRecord, error) {
|
||||
s.mu.RLock()
|
||||
session, ok := s.sessions[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok || !s.now().Before(session.ExpiresAt) || (checkOwner && session.OwnerID != ownerID) {
|
||||
return sessionRecord{}, ErrSessionExpired
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func playbackBase(request *http.Request) (*url.URL, error) {
|
||||
configured := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE"))
|
||||
if configured != "" {
|
||||
return validatePlaybackBase(configured)
|
||||
}
|
||||
if request == nil || request.Host == "" {
|
||||
return nil, errors.New("无法确定浏览器可访问的视频服务地址")
|
||||
}
|
||||
host := request.Host
|
||||
if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
if host == "" || (net.ParseIP(host) == nil && host != "localhost" && !validDNSHost.MatchString(host)) {
|
||||
return nil, errors.New("无效的请求主机")
|
||||
}
|
||||
scheme := "http"
|
||||
if request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return validatePlaybackBase(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, "8889")))
|
||||
}
|
||||
|
||||
func validatePlaybackBase(value string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimRight(value, "/"))
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||
return nil, errors.New("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE 配置不安全")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func randomID() (string, error) {
|
||||
value := make([]byte, 24)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "view_" + hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func escapeLike(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, `%`, `\%`)
|
||||
return strings.ReplaceAll(value, `_`, `\_`)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB(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)
|
||||
}
|
||||
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, kind text, width integer, height integer, encoding text, verification_status text, stream_uri text)`,
|
||||
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text, path text, desired text, actual text, detail text, readers integer)`,
|
||||
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流','main',1920,1080,'H264','ready','rtsp://camera.example/live')`,
|
||||
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main','sense_012345','running','waiting','等待播放器连接并按需拉流',0)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestListIsPaginatedSearchableAndSecretFree(t *testing.T) {
|
||||
service := NewService(testDB(t), NewStore())
|
||||
items, total, err := service.List(context.Background(), PageRequest{Keyword: "东门", PageIndex: 1, PageSize: 10})
|
||||
if err != nil || total != 1 || len(items) != 1 || items[0].ProfileKind != "main" {
|
||||
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(items)
|
||||
if strings.Contains(string(encoded), "rtsp://") || strings.Contains(string(encoded), "sense_012345") {
|
||||
t.Fatalf("response leaked private media data: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIsOwnerBoundShortLivedAndUsesBrowserHost(t *testing.T) {
|
||||
store := NewStore()
|
||||
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
store.now = func() time.Time { return now }
|
||||
service := NewService(testDB(t), store)
|
||||
request := httptest.NewRequest("POST", "http://192.0.2.20:18080/api/v1/liveview/sessions", nil)
|
||||
session, err := service.Create(context.Background(), 7, "device-1:main", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(session.PlayerURL, "/api/v1/liveview/player/view_") || strings.Contains(session.PlayerURL, "sense_012345") {
|
||||
t.Fatalf("unsafe player URL: %s", session.PlayerURL)
|
||||
}
|
||||
if _, err = service.Get(context.Background(), 8, session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||
t.Fatalf("another owner accessed session: %v", err)
|
||||
}
|
||||
target, err := service.PlayerTarget(session.ID)
|
||||
if err != nil || target != "http://192.0.2.20:8889/sense_012345?controls=true&muted=true&autoplay=true" {
|
||||
t.Fatalf("target=%q err=%v", target, err)
|
||||
}
|
||||
now = now.Add(sessionTTL)
|
||||
if _, err = service.PlayerTarget(session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||
t.Fatalf("expired session remained valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackBaseRejectsCredentials(t *testing.T) {
|
||||
if _, err := validatePlaybackBase("http://invalid-user@127.0.0.1:8889"); err == nil {
|
||||
t.Fatal("expected credential-bearing base URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackStatusKeepsActionableFailuresDistinct(t *testing.T) {
|
||||
for input, want := range map[string]string{"waiting": "waiting", "credential_unavailable": "authentication_failed", "path_missing": "stream_not_found", "process_unavailable": "service_unavailable", "unexpected": "offline"} {
|
||||
if got := playbackStatus(input); got != want {
|
||||
t.Fatalf("playbackStatus(%q)=%q want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errorsIs(err, target error) bool { return err == target }
|
||||
@@ -0,0 +1,37 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"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), migrateSenseLiveview)
|
||||
}
|
||||
|
||||
func migrateSenseLiveview(db *gorm.DB, version string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "/sense/liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 8, Visible: "0", IsFrame: "1"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page}); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, policy := range [][2]string{{"/api/v1/liveview/routes", "GET"}, {"/api/v1/liveview/sessions", "POST"}, {"/api/v1/liveview/sessions/:id", "GET"}} {
|
||||
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Create(&common.Migration{Version: version}).Error
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||
)
|
||||
|
||||
func TestLiveviewMigrationOnPostgres(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_LIVEVIEW_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_liveview_68_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 = migrateSenseLiveview(db, "2026081420000_liveview.go"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var menus, policies, applied int64
|
||||
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseLiveview").Count(&menus)
|
||||
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/liveview%").Count(&policies)
|
||||
db.Model(&common.Migration{}).Where("version = ?", "2026081420000_liveview.go").Count(&applied)
|
||||
if menus != 1 || policies != 9 || applied != 1 {
|
||||
t.Fatalf("menus=%d policies=%d applied=%d", menus, policies, applied)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listLiveviewRoutes(query) {
|
||||
return request({ url: '/api/v1/liveview/routes', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function createLiveviewSession(routeId) {
|
||||
return request({ url: '/api/v1/liveview/sessions', method: 'post', data: { routeId }})
|
||||
}
|
||||
|
||||
export function getLiveviewSession(id) {
|
||||
return request({ url: `/api/v1/liveview/sessions/${id}`, method: 'get' })
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<section class="geometry-editor" aria-labelledby="geometry-editor-title">
|
||||
<div class="editor-toolbar">
|
||||
<div>
|
||||
<strong id="geometry-editor-title">画面坐标</strong>
|
||||
<span>{{ instruction }}</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<el-button :disabled="disabled || !canAdd" @click="addCenterPoint">添加中心点</el-button>
|
||||
<el-button :disabled="disabled || undoStack.length === 0" @click="undo">撤销</el-button>
|
||||
<el-button :disabled="disabled || points.length === 0" @click="clear">清空</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-shell" :class="{ 'is-disabled': disabled }">
|
||||
<iframe
|
||||
v-if="playerUrl"
|
||||
:src="playerUrl"
|
||||
title="区域校准实时视频"
|
||||
allow="autoplay; fullscreen"
|
||||
referrerpolicy="no-referrer"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<div v-else class="canvas-placeholder">选择可用视频后,可在实时画面上绘制</div>
|
||||
<svg
|
||||
ref="canvas"
|
||||
class="geometry-canvas"
|
||||
viewBox="0 0 1000 562.5"
|
||||
role="application"
|
||||
:aria-label="instruction"
|
||||
tabindex="0"
|
||||
@click="addFromPointer"
|
||||
@pointermove="dragPoint"
|
||||
@pointerup="stopDrag"
|
||||
@pointercancel="stopDrag"
|
||||
@keydown.enter.prevent="addCenterPoint"
|
||||
@keydown.space.prevent="addCenterPoint"
|
||||
>
|
||||
<defs>
|
||||
<marker id="sense-area-arrow" markerWidth="12" markerHeight="12" refX="8" refY="4" orient="auto" markerUnits="strokeWidth">
|
||||
<path d="M0,0 L0,8 L10,4 z" class="arrow-head" />
|
||||
</marker>
|
||||
</defs>
|
||||
<polygon v-if="kind === 'polygon' && points.length >= 2" :points="svgPoints" class="area-shape" />
|
||||
<line
|
||||
v-if="kind === 'direction_line' && points.length === 2"
|
||||
:x1="scaledPoints[0].x"
|
||||
:y1="scaledPoints[0].y"
|
||||
:x2="scaledPoints[1].x"
|
||||
:y2="scaledPoints[1].y"
|
||||
class="direction-line"
|
||||
:marker-start="direction === 'reverse' ? 'url(#sense-area-arrow)' : undefined"
|
||||
:marker-end="direction === 'forward' ? 'url(#sense-area-arrow)' : undefined"
|
||||
/>
|
||||
<g
|
||||
v-for="(point, index) in scaledPoints"
|
||||
:key="index"
|
||||
class="point-control"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`坐标点 ${index + 1},横向 ${Math.round(points[index].x * 100)}%,纵向 ${Math.round(points[index].y * 100)}%`"
|
||||
@click.stop
|
||||
@pointerdown.stop.prevent="startDrag(index, $event)"
|
||||
@keydown="movePointByKeyboard(index, $event)"
|
||||
>
|
||||
<circle :cx="point.x" :cy="point.y" r="12" />
|
||||
<text :x="point.x" :y="point.y + 4" text-anchor="middle">{{ index + 1 }}</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="point-summary" aria-live="polite">
|
||||
<span>已添加 {{ points.length }} 个点</span>
|
||||
<span>键盘:Enter 添加中心点;聚焦顶点后用方向键移动,Delete 删除。</span>
|
||||
</div>
|
||||
<p v-if="error" class="geometry-error" role="alert">{{ error }}</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { clamp, geometryError } from './geometry'
|
||||
|
||||
export default {
|
||||
name: 'SenseGeometryEditor',
|
||||
props: {
|
||||
modelValue: { type: Array, default: () => [] },
|
||||
kind: { type: String, required: true },
|
||||
direction: { type: String, default: '' },
|
||||
playerUrl: { type: String, default: '' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['update:modelValue'],
|
||||
data() {
|
||||
return { undoStack: [], draggingIndex: -1 }
|
||||
},
|
||||
computed: {
|
||||
points() {
|
||||
return this.modelValue || []
|
||||
},
|
||||
scaledPoints() {
|
||||
return this.points.map(point => ({ x: point.x * 1000, y: point.y * 562.5 }))
|
||||
},
|
||||
svgPoints() {
|
||||
return this.scaledPoints.map(point => `${point.x},${point.y}`).join(' ')
|
||||
},
|
||||
canAdd() {
|
||||
return this.kind === 'polygon' ? this.points.length < 64 : this.points.length < 2
|
||||
},
|
||||
instruction() {
|
||||
return this.kind === 'polygon' ? '点击画面添加顶点,拖动顶点调整危险区域' : '依次添加起点和终点,箭头表示警戒方向'
|
||||
},
|
||||
error() {
|
||||
if (this.points.length === 0) return ''
|
||||
return geometryError(this.kind, this.points, this.direction)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
commit(next, remember = true) {
|
||||
if (this.disabled) return
|
||||
if (remember) this.undoStack.push(this.points.map(point => ({ ...point })))
|
||||
this.$emit('update:modelValue', next.map(point => ({ x: clamp(point.x), y: clamp(point.y) })))
|
||||
},
|
||||
addFromPointer(event) {
|
||||
if (this.disabled || !this.canAdd || event.target.closest('.point-control')) return
|
||||
this.commit([...this.points, this.eventPoint(event)])
|
||||
},
|
||||
addCenterPoint() {
|
||||
if (!this.disabled && this.canAdd) this.commit([...this.points, { x: 0.5, y: 0.5 }])
|
||||
},
|
||||
clear() {
|
||||
if (this.points.length) this.commit([])
|
||||
},
|
||||
undo() {
|
||||
if (!this.undoStack.length || this.disabled) return
|
||||
const previous = this.undoStack.pop()
|
||||
this.commit(previous, false)
|
||||
},
|
||||
startDrag(index, event) {
|
||||
if (this.disabled) return
|
||||
this.undoStack.push(this.points.map(point => ({ ...point })))
|
||||
this.draggingIndex = index
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
},
|
||||
dragPoint(event) {
|
||||
if (this.draggingIndex < 0 || this.disabled) return
|
||||
const next = this.points.map(point => ({ ...point }))
|
||||
next[this.draggingIndex] = this.eventPoint(event)
|
||||
this.commit(next, false)
|
||||
},
|
||||
stopDrag() {
|
||||
this.draggingIndex = -1
|
||||
},
|
||||
movePointByKeyboard(index, event) {
|
||||
if (this.disabled) return
|
||||
if (event.key === 'Delete' || event.key === 'Backspace') {
|
||||
event.preventDefault()
|
||||
this.commit(this.points.filter((_, pointIndex) => pointIndex !== index))
|
||||
return
|
||||
}
|
||||
const movement = { ArrowLeft: [-0.01, 0], ArrowRight: [0.01, 0], ArrowUp: [0, -0.01], ArrowDown: [0, 0.01] }[event.key]
|
||||
if (!movement) return
|
||||
event.preventDefault()
|
||||
const next = this.points.map(point => ({ ...point }))
|
||||
next[index] = { x: next[index].x + movement[0], y: next[index].y + movement[1] }
|
||||
this.commit(next)
|
||||
},
|
||||
eventPoint(event) {
|
||||
const rect = this.$refs.canvas.getBoundingClientRect()
|
||||
return { x: clamp((event.clientX - rect.left) / rect.width), y: clamp((event.clientY - rect.top) / rect.height) }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.geometry-editor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.editor-toolbar,
|
||||
.point-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.editor-toolbar > div:first-child {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.editor-toolbar span,
|
||||
.point-summary {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.editor-actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.canvas-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
aspect-ratio: 16 / 9;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: var(--el-fill-color-darker);
|
||||
}
|
||||
|
||||
.canvas-shell iframe,
|
||||
.geometry-canvas,
|
||||
.canvas-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.canvas-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.geometry-canvas {
|
||||
cursor: crosshair;
|
||||
outline-offset: -3px;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.geometry-canvas:focus-visible {
|
||||
outline: 3px solid var(--el-color-primary);
|
||||
}
|
||||
|
||||
.area-shape {
|
||||
fill: color-mix(in srgb, var(--el-color-warning) 24%, transparent);
|
||||
stroke: var(--el-color-warning-dark-2);
|
||||
stroke-width: 4;
|
||||
}
|
||||
|
||||
.direction-line {
|
||||
stroke: var(--el-color-danger);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.arrow-head {
|
||||
fill: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.point-control {
|
||||
cursor: grab;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.point-control circle {
|
||||
fill: var(--el-color-primary);
|
||||
stroke: var(--el-color-white);
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.point-control text {
|
||||
fill: var(--el-color-white);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.point-control:focus-visible circle {
|
||||
stroke: var(--el-color-warning);
|
||||
stroke-width: 6;
|
||||
}
|
||||
|
||||
.point-summary {
|
||||
margin-top: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.geometry-error {
|
||||
margin: 8px 0 0;
|
||||
color: var(--el-color-danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.is-disabled .geometry-canvas {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.editor-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="sense-video-player" :aria-busy="busy ? 'true' : 'false'">
|
||||
<iframe
|
||||
v-if="playerUrl && playable"
|
||||
:key="playerUrl"
|
||||
class="sense-video-player__frame"
|
||||
:src="playerUrl"
|
||||
title="Sense 单路实时视频"
|
||||
allow="autoplay; fullscreen"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
referrerpolicy="no-referrer"
|
||||
@load="$emit('loaded')"
|
||||
/>
|
||||
<div v-if="state !== 'ready'" class="sense-video-player__state" :class="{ 'is-overlay': playerUrl && playable }" aria-live="polite">
|
||||
<el-icon v-if="busy" class="is-loading sense-video-player__icon" aria-hidden="true"><Loading /></el-icon>
|
||||
<el-icon v-else class="sense-video-player__icon" aria-hidden="true"><WarningFilled /></el-icon>
|
||||
<strong>{{ title }}</strong>
|
||||
<span>{{ message }}</span>
|
||||
<el-button v-if="recoverable" type="primary" :loading="retrying" @click="$emit('retry')">重新连接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { playbackStatusDetail, playbackStatusLabel } from '@/views/sense/liveview/playbackStatus'
|
||||
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'SenseVideoPlayer',
|
||||
components: { Loading, WarningFilled },
|
||||
props: {
|
||||
state: { type: String, default: 'loading' },
|
||||
detail: { type: String, default: '' },
|
||||
playerUrl: { type: String, default: '' },
|
||||
retrying: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['retry', 'loaded'],
|
||||
computed: {
|
||||
busy() {
|
||||
return this.state === 'loading' || this.state === 'waiting'
|
||||
},
|
||||
playable() {
|
||||
return ['loading', 'waiting', 'ready'].includes(this.state)
|
||||
},
|
||||
recoverable() {
|
||||
return !this.busy && this.state !== 'ready'
|
||||
},
|
||||
title() {
|
||||
return playbackStatusLabel(this.state)
|
||||
},
|
||||
message() {
|
||||
return playbackStatusDetail(this.state, this.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sense-video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 280px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: #101419;
|
||||
}
|
||||
|
||||
.sense-video-player__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sense-video-player__state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
color: #dcdfe6;
|
||||
text-align: center;
|
||||
background: #101419;
|
||||
}
|
||||
|
||||
.sense-video-player__state.is-overlay {
|
||||
pointer-events: none;
|
||||
background: rgb(16 20 25 / 78%);
|
||||
}
|
||||
|
||||
.sense-video-player__state strong {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sense-video-player__state span {
|
||||
max-width: 640px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sense-video-player__state .el-button {
|
||||
pointer-events: auto;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.sense-video-player__icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sense-video-player {
|
||||
min-height: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sense-video-player__icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>区域与警戒线</h3>
|
||||
<p>在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。</p>
|
||||
</div>
|
||||
<el-button v-permisaction="['sense:area:create']" type="primary" @click="openCreate">新增配置</el-button>
|
||||
</div>
|
||||
|
||||
<el-form ref="queryForm" :model="queryParams" inline class="search-form" @submit.prevent>
|
||||
<el-form-item label="名称" prop="keyword">
|
||||
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入配置名称" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item label="类型" prop="kind">
|
||||
<el-select v-model="queryParams.kind" clearable placeholder="全部类型">
|
||||
<el-option label="危险区域" value="polygon" />
|
||||
<el-option label="方向警戒线" value="direction_line" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="校准状态" prop="recalibrationState">
|
||||
<el-select v-model="queryParams.recalibrationState" clearable placeholder="全部状态">
|
||||
<el-option label="需要重新校准" value="needed" />
|
||||
<el-option label="已校准" value="ready" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!loading && total === 0"
|
||||
title="尚无区域配置。请先确认实时监看可用,再新增危险区域或方向警戒线。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="empty-alert"
|
||||
/>
|
||||
|
||||
<el-table v-loading="loading" :data="items" border stripe>
|
||||
<el-table-column prop="name" label="配置名称" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column label="类型" width="130">
|
||||
<template #default="scope">
|
||||
<el-tag size="small" :type="scope.row.kind === 'polygon' ? 'warning' : 'danger'">{{ kindLabel(scope.row.kind) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="设备与码流" min-width="220">
|
||||
<template #default="scope">
|
||||
<div>{{ scope.row.deviceName || scope.row.deviceId }}</div>
|
||||
<small class="muted-text">{{ profileLabel(scope.row) }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="90">
|
||||
<template #default="scope">v{{ scope.row.version }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="校准状态" width="150">
|
||||
<template #default="scope">
|
||||
<el-tag v-if="scope.row.needsRecalibration" type="danger" size="small">需要重新校准</el-tag>
|
||||
<el-tag v-else type="success" size="small">已校准</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用状态" width="100">
|
||||
<template #default="scope">
|
||||
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="small">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="最近更新" min-width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:area:update']" type="primary" link @click="openEdit(scope.row)">编辑/校准</el-button>
|
||||
<el-button type="primary" link @click="openVersions(scope.row)">版本记录</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:page="queryParams.pageIndex"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="editorOpen" width="min(1100px, 94vw)" :close-on-click-modal="false" :before-close="beforeEditorClose" destroy-on-close>
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<strong>{{ form.id ? '编辑并生成新版本' : '新增区域配置' }}</strong>
|
||||
<span v-if="form.id">当前 v{{ form.version }};保存后旧版本仍可追溯</span>
|
||||
</div>
|
||||
<el-tag v-if="form.needsRecalibration" type="danger">需要重新校准</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
v-if="form.needsRecalibration"
|
||||
title="绑定的 Profile、分辨率或编码已变化。请重新选择码流并确认画面坐标后保存新版本。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="dialog-alert"
|
||||
/>
|
||||
|
||||
<el-form ref="editorForm" :model="form" :rules="rules" label-width="110px" @change="dirty = true">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="配置名称" prop="name">
|
||||
<el-input v-model.trim="form.name" maxlength="128" show-word-limit placeholder="例如:操场北侧危险区域" @input="dirty = true" />
|
||||
</el-form-item>
|
||||
<el-form-item label="配置类型" prop="kind">
|
||||
<el-select v-model="form.kind" :disabled="Boolean(form.id)" @change="handleKindChange">
|
||||
<el-option label="危险区域" value="polygon" />
|
||||
<el-option label="方向警戒线" value="direction_line" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备与码流" prop="routeId" class="route-field">
|
||||
<el-select
|
||||
v-model="form.routeId"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchRoutes"
|
||||
:loading="routesLoading"
|
||||
placeholder="输入设备、位置或码流名称"
|
||||
@change="handleRouteChange"
|
||||
>
|
||||
<el-option v-for="route in routeOptions" :key="route.id" :label="routeLabel(route)" :value="route.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.kind === 'direction_line'" label="警戒方向" prop="direction">
|
||||
<el-select v-model="form.direction" @change="dirty = true">
|
||||
<el-option label="从起点到终点" value="forward" />
|
||||
<el-option label="从终点到起点" value="reverse" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用配置">
|
||||
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" @change="dirty = true" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
|
||||
<el-form-item label="绑定规格" class="profile-summary">
|
||||
<span>{{ selectedProfileSummary }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="绘制区域" prop="points">
|
||||
<SenseGeometryEditor
|
||||
:model-value="form.points"
|
||||
:kind="form.kind"
|
||||
:direction="form.direction"
|
||||
:player-url="preview.playerUrl"
|
||||
:disabled="!form.routeId"
|
||||
@update:model-value="handlePointsChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
||||
<template #footer>
|
||||
<el-button @click="requestEditorClose">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存新版本</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="versionsOpen" title="版本记录" width="min(900px, 92vw)">
|
||||
<el-table v-loading="versionsLoading" :data="versions" border>
|
||||
<el-table-column label="版本" width="80">
|
||||
<template #default="scope">v{{ scope.row.version }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="180" />
|
||||
<el-table-column label="绑定规格" min-width="190">
|
||||
<template #default="scope">{{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="scope">{{ scope.row.enabled ? '已启用' : '已停用' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="坐标点" width="90">
|
||||
<template #default="scope">{{ scope.row.points.length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createAreaConfiguration, listAreaConfigurations, listAreaVersions, updateAreaConfiguration } from '@/api/sense/area'
|
||||
import { createLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
import SenseGeometryEditor from '@/components/sense/geometry-editor'
|
||||
import { buildAreaPayload, validateAreaForm } from './areaPayload'
|
||||
|
||||
const emptyForm = () => ({ id: '', name: '', kind: 'polygon', routeId: '', points: [], direction: '', enabled: true, version: 0, needsRecalibration: false })
|
||||
|
||||
export default {
|
||||
name: 'SenseArea',
|
||||
components: { SenseGeometryEditor },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
items: [],
|
||||
total: 0,
|
||||
queryParams: { keyword: '', kind: '', recalibrationState: '', pageIndex: 1, pageSize: 10 },
|
||||
editorOpen: false,
|
||||
saving: false,
|
||||
dirty: false,
|
||||
form: emptyForm(),
|
||||
formError: '',
|
||||
rules: {
|
||||
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
|
||||
kind: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
|
||||
routeId: [{ required: true, message: '请选择设备与视频码流', trigger: 'change' }]
|
||||
},
|
||||
routeOptions: [],
|
||||
routesLoading: false,
|
||||
preview: {},
|
||||
versionsOpen: false,
|
||||
versionsLoading: false,
|
||||
versions: []
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selectedRoute() {
|
||||
return this.routeOptions.find(route => route.id === this.form.routeId) || null
|
||||
},
|
||||
selectedProfileSummary() {
|
||||
if (!this.selectedRoute) return '尚未选择视频码流'
|
||||
return `${this.selectedRoute.deviceName} · ${this.selectedRoute.profileName || this.selectedRoute.profileToken} · ${this.selectedRoute.width} × ${this.selectedRoute.height} · ${this.selectedRoute.encoding}`
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
methods: {
|
||||
async getList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await listAreaConfigurations(this.queryParams)
|
||||
this.items = response.data.list || []
|
||||
this.total = response.data.count || 0
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.$refs.queryForm.resetFields()
|
||||
this.handleQuery()
|
||||
},
|
||||
kindLabel(kind) {
|
||||
return kind === 'polygon' ? '危险区域' : '方向警戒线'
|
||||
},
|
||||
profileLabel(item) {
|
||||
return `${item.profileName || item.profileToken} · ${item.profileWidth} × ${item.profileHeight} · ${item.profileEncoding}`
|
||||
},
|
||||
routeLabel(route) {
|
||||
return `${route.deviceName} · ${route.profileName || route.profileToken} · ${route.width}×${route.height}`
|
||||
},
|
||||
async searchRoutes(keyword = '') {
|
||||
this.routesLoading = true
|
||||
try {
|
||||
const response = await listLiveviewRoutes({ keyword, pageIndex: 1, pageSize: 50 })
|
||||
const routes = response.data.list || []
|
||||
const selected = this.routeOptions.find(route => route.id === this.form.routeId)
|
||||
this.routeOptions = selected && !routes.some(route => route.id === selected.id) ? [selected, ...routes] : routes
|
||||
} finally {
|
||||
this.routesLoading = false
|
||||
}
|
||||
},
|
||||
async openCreate() {
|
||||
this.form = emptyForm()
|
||||
this.formError = ''
|
||||
this.preview = {}
|
||||
this.dirty = false
|
||||
this.editorOpen = true
|
||||
await this.searchRoutes('')
|
||||
},
|
||||
async openEdit(item) {
|
||||
this.form = { id: item.id, name: item.name, kind: item.kind, routeId: item.routeId, points: item.points.map(point => ({ ...point })), direction: item.direction, enabled: item.enabled, version: item.version, needsRecalibration: item.needsRecalibration }
|
||||
this.routeOptions = [{ id: item.routeId, deviceName: item.deviceName, profileName: item.profileName, profileToken: item.profileToken, width: item.profileWidth, height: item.profileHeight, encoding: item.profileEncoding }]
|
||||
this.formError = ''
|
||||
this.preview = {}
|
||||
this.dirty = false
|
||||
this.editorOpen = true
|
||||
if (item.routeId) await this.openPreview()
|
||||
},
|
||||
handleKindChange() {
|
||||
this.form.points = []
|
||||
this.form.direction = this.form.kind === 'direction_line' ? 'forward' : ''
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
},
|
||||
async handleRouteChange() {
|
||||
this.form.points = []
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
await this.openPreview()
|
||||
},
|
||||
handlePointsChange(points) {
|
||||
this.form.points = points
|
||||
this.dirty = true
|
||||
this.formError = ''
|
||||
},
|
||||
async openPreview() {
|
||||
this.preview = {}
|
||||
if (!this.form.routeId) return
|
||||
try {
|
||||
const response = await createLiveviewSession(this.form.routeId)
|
||||
this.preview = response.data || {}
|
||||
} catch (error) {
|
||||
this.formError = '实时画面暂不可用;请先到“实时监看”确认该码流。'
|
||||
}
|
||||
},
|
||||
async save() {
|
||||
this.formError = validateAreaForm(this.form)
|
||||
if (this.formError) return
|
||||
try {
|
||||
await this.$refs.editorForm.validate()
|
||||
} catch (error) {
|
||||
return
|
||||
}
|
||||
this.saving = true
|
||||
try {
|
||||
const payload = buildAreaPayload(this.form)
|
||||
if (this.form.id) await updateAreaConfiguration(this.form.id, payload)
|
||||
else await createAreaConfiguration(payload)
|
||||
this.dirty = false
|
||||
this.editorOpen = false
|
||||
this.$message.success(this.form.id ? '已保存新版本' : '区域配置已创建')
|
||||
await this.getList()
|
||||
} catch (error) {
|
||||
this.formError = error.message || '保存失败,请刷新后重试'
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
},
|
||||
requestEditorClose() {
|
||||
this.beforeEditorClose(() => { this.editorOpen = false })
|
||||
},
|
||||
beforeEditorClose(done) {
|
||||
if (!this.dirty || this.saving) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
this.$confirm('尚未保存的绘制内容将丢失,确定关闭吗?', '放弃未保存内容', { type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' }).then(done).catch(() => {})
|
||||
},
|
||||
async openVersions(item) {
|
||||
this.versionsOpen = true
|
||||
this.versionsLoading = true
|
||||
this.versions = []
|
||||
try {
|
||||
const response = await listAreaVersions(item.id)
|
||||
this.versions = response.data || []
|
||||
} finally {
|
||||
this.versionsLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.dialog-header span,
|
||||
.muted-text,
|
||||
.profile-summary span {
|
||||
color: var(--el-text-color-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.dialog-header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-form,
|
||||
.empty-alert,
|
||||
.dialog-alert {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-input) {
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-select) {
|
||||
width: 180px;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 20px;
|
||||
}
|
||||
|
||||
.route-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.route-field :deep(.el-select),
|
||||
.form-grid :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.profile-summary {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin: 12px 0 0 110px;
|
||||
color: var(--el-color-danger);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.route-field {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>实时监看</h3>
|
||||
<p>按设备选择一路已验证视频;关闭窗口后不会继续占用播放器连接。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="queryForm" :model="queryParams" inline label-position="left" class="search-form" @submit.prevent>
|
||||
<el-form-item label="设备或位置" prop="keyword">
|
||||
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入设备、位置或码流名称" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!loading && total === 0"
|
||||
title="没有可监看的视频。请先在“视频接入”完成验证,再到“视频服务”确认路径状态。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="empty-alert"
|
||||
/>
|
||||
|
||||
<el-table v-loading="loading" :data="routes" border stripe>
|
||||
<el-table-column prop="deviceName" label="设备" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceLocation" label="安装位置" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.deviceLocation || '未填写' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="码流" min-width="170">
|
||||
<template #default="scope">
|
||||
<div>{{ profileLabel(scope.row) }}</div>
|
||||
<small class="muted-text">{{ resolutionLabel(scope.row) }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="routeStatus(scope.row.actual).type" size="small">{{ routeStatus(scope.row.actual).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="readers" label="当前观看" width="100" />
|
||||
<el-table-column prop="detail" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:liveview:view']" type="primary" link @click="handleWatch(scope.row)">实时查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:page="queryParams.pageIndex"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="playerOpen" width="min(1000px, 92vw)" :close-on-click-modal="false" destroy-on-close @closed="closePlayer">
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<strong>{{ currentRoute.deviceName || '实时视频' }}</strong>
|
||||
<span>{{ profileLabel(currentRoute) }} · {{ currentRoute.deviceLocation || '未填写位置' }}</span>
|
||||
</div>
|
||||
<el-tag :type="playbackStatusType(playerState)">{{ playbackStatusLabel(playerState) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SenseVideoPlayer
|
||||
v-if="playerOpen"
|
||||
:state="playerState"
|
||||
:detail="playerDetail"
|
||||
:player-url="session.playerUrl"
|
||||
:retrying="retrying"
|
||||
@retry="openSession"
|
||||
/>
|
||||
|
||||
<el-descriptions :column="3" border class="player-details">
|
||||
<el-descriptions-item label="设备">{{ currentRoute.deviceName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="码流">{{ profileLabel(currentRoute) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分辨率">{{ resolutionLabel(currentRoute) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理建议" :span="3">{{ playbackStatusDetail(playerState, playerDetail) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="playerOpen = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="retrying" @click="openSession">重新连接</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createLiveviewSession, getLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
import SenseVideoPlayer from '@/components/sense/video-player'
|
||||
import { playbackStatusDetail, playbackStatusLabel, playbackStatusType, routeStatus } from './playbackStatus'
|
||||
|
||||
export default {
|
||||
name: 'SenseLiveview',
|
||||
components: { SenseVideoPlayer },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
routes: [],
|
||||
total: 0,
|
||||
queryParams: { keyword: '', pageIndex: 1, pageSize: 10 },
|
||||
playerOpen: false,
|
||||
currentRoute: {},
|
||||
session: {},
|
||||
playerState: 'loading',
|
||||
playerDetail: '',
|
||||
retrying: false,
|
||||
pollTimer: null,
|
||||
timeoutTimer: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopTimers()
|
||||
},
|
||||
methods: {
|
||||
playbackStatusDetail,
|
||||
playbackStatusLabel,
|
||||
playbackStatusType,
|
||||
routeStatus,
|
||||
profileLabel(route) {
|
||||
if (!route || !route.id) return '—'
|
||||
const kind = { main: '主码流', sub: '子码流', other: '其他码流' }[route.profileKind] || '码流'
|
||||
return route.profileName ? `${kind}(${route.profileName})` : kind
|
||||
},
|
||||
resolutionLabel(route) {
|
||||
if (!route || !route.width || !route.height) return '未取得分辨率'
|
||||
return `${route.width} × ${route.height}${route.encoding ? ` · ${route.encoding}` : ''}`
|
||||
},
|
||||
async getList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await listLiveviewRoutes(this.queryParams)
|
||||
this.routes = response.data.list || []
|
||||
this.total = response.data.count || 0
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.$refs.queryForm.resetFields()
|
||||
this.handleQuery()
|
||||
},
|
||||
handleWatch(route) {
|
||||
this.currentRoute = { ...route }
|
||||
this.playerOpen = true
|
||||
this.openSession()
|
||||
},
|
||||
async openSession() {
|
||||
if (!this.currentRoute.id || this.retrying) return
|
||||
this.stopTimers()
|
||||
this.retrying = true
|
||||
this.playerState = 'loading'
|
||||
this.playerDetail = ''
|
||||
this.session = {}
|
||||
try {
|
||||
const response = await createLiveviewSession(this.currentRoute.id)
|
||||
this.session = response.data
|
||||
this.playerState = response.data.status
|
||||
this.playerDetail = response.data.detail
|
||||
this.startPolling()
|
||||
} catch (error) {
|
||||
this.playerState = 'offline'
|
||||
this.playerDetail = error.message || ''
|
||||
} finally {
|
||||
this.retrying = false
|
||||
}
|
||||
},
|
||||
startPolling() {
|
||||
this.pollTimer = window.setInterval(this.refreshSession, 2000)
|
||||
this.timeoutTimer = window.setTimeout(() => {
|
||||
if (['loading', 'waiting'].includes(this.playerState)) {
|
||||
this.playerState = 'timeout'
|
||||
this.playerDetail = ''
|
||||
this.stopTimers()
|
||||
}
|
||||
}, 20000)
|
||||
},
|
||||
async refreshSession() {
|
||||
if (!this.session.id || !this.playerOpen) return
|
||||
try {
|
||||
const response = await getLiveviewSession(this.session.id)
|
||||
this.session = response.data
|
||||
this.playerState = response.data.status
|
||||
this.playerDetail = response.data.detail
|
||||
if (this.playerState === 'ready') {
|
||||
window.clearTimeout(this.timeoutTimer)
|
||||
this.timeoutTimer = null
|
||||
} else if (!['loading', 'waiting'].includes(this.playerState)) {
|
||||
this.stopTimers()
|
||||
}
|
||||
} catch (error) {
|
||||
this.playerState = 'expired'
|
||||
this.playerDetail = ''
|
||||
this.stopTimers()
|
||||
}
|
||||
},
|
||||
stopTimers() {
|
||||
window.clearInterval(this.pollTimer)
|
||||
window.clearTimeout(this.timeoutTimer)
|
||||
this.pollTimer = null
|
||||
this.timeoutTimer = null
|
||||
},
|
||||
closePlayer() {
|
||||
this.stopTimers()
|
||||
this.session = {}
|
||||
this.currentRoute = {}
|
||||
this.playerState = 'loading'
|
||||
this.playerDetail = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.dialog-header span,
|
||||
.muted-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dialog-header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-form,
|
||||
.empty-alert,
|
||||
.player-details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-input) {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.search-form :deep(.el-input) {
|
||||
width: min(280px, 70vw);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
const labels = {
|
||||
loading: '正在连接',
|
||||
waiting: '等待视频',
|
||||
ready: '播放正常',
|
||||
authentication_failed: '摄像头认证失败',
|
||||
stream_not_found: '未找到视频流',
|
||||
service_unavailable: '视频服务不可用',
|
||||
timeout: '连接超时',
|
||||
expired: '播放会话已过期',
|
||||
offline: '视频已断开',
|
||||
stopped: '视频已停止'
|
||||
}
|
||||
|
||||
const details = {
|
||||
loading: '正在建立短期播放会话,请稍候。',
|
||||
waiting: '播放器已连接,正在等待摄像头开始传输画面。',
|
||||
ready: '摄像头视频正在传输。',
|
||||
authentication_failed: '请到“设备管理”更新摄像头账号或密码,再重新验证视频接入。',
|
||||
stream_not_found: '请到“视频服务”执行对账,确认媒体路径已经恢复。',
|
||||
service_unavailable: '请到“视频服务”检查 MediaMTX 进程和端口配置。',
|
||||
timeout: '20 秒内未收到画面,请检查摄像头网络后重新连接。',
|
||||
expired: '短期播放会话已失效,请重新连接。',
|
||||
offline: '无法取得最新播放状态,请检查网络后重新连接。',
|
||||
stopped: '该视频路径已停止,请先到“视频服务”恢复。'
|
||||
}
|
||||
|
||||
export function playbackStatusLabel(value) {
|
||||
return labels[value] || '暂时无法播放'
|
||||
}
|
||||
|
||||
export function playbackStatusDetail(value, detail) {
|
||||
return detail || details[value] || details.offline
|
||||
}
|
||||
|
||||
export function playbackStatusType(value) {
|
||||
if (value === 'ready') return 'success'
|
||||
if (value === 'loading' || value === 'waiting') return 'warning'
|
||||
if (value === 'stopped') return 'info'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
export function routeStatus(value) {
|
||||
if (value === 'ready') return { label: '可观看', type: 'success' }
|
||||
if (value === 'waiting') return { label: '等待观看', type: 'warning' }
|
||||
return { label: '需要处理', type: 'danger' }
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import SenseLiveview from '@/views/sense/liveview/index.vue'
|
||||
import { createLiveviewSession, getLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
|
||||
jest.mock('@/api/sense/liveview', () => ({
|
||||
listLiveviewRoutes: jest.fn(),
|
||||
createLiveviewSession: jest.fn(),
|
||||
getLiveviewSession: jest.fn()
|
||||
}))
|
||||
|
||||
describe('Sense live-view page lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
listLiveviewRoutes.mockResolvedValue({ data: { list: [], count: 0 }})
|
||||
createLiveviewSession.mockResolvedValue({ data: { id: 'view_test', playerUrl: '/api/v1/liveview/player/view_test', status: 'waiting', detail: '等待播放器连接' }})
|
||||
getLiveviewSession.mockResolvedValue({ data: { id: 'view_test', playerUrl: '/api/v1/liveview/player/view_test', status: 'ready', detail: '上游拉流正常' }})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens only the selected route and clears polling when unmounted', async() => {
|
||||
const context = {
|
||||
...SenseLiveview.data(),
|
||||
currentRoute: { id: 'device-1:main', deviceName: '东门摄像机' },
|
||||
playerOpen: true
|
||||
}
|
||||
context.stopTimers = SenseLiveview.methods.stopTimers.bind(context)
|
||||
context.refreshSession = SenseLiveview.methods.refreshSession.bind(context)
|
||||
context.startPolling = SenseLiveview.methods.startPolling.bind(context)
|
||||
await SenseLiveview.methods.openSession.call(context)
|
||||
|
||||
expect(createLiveviewSession).toHaveBeenCalledWith('device-1:main')
|
||||
expect(context.session.playerUrl).toBe('/api/v1/liveview/player/view_test')
|
||||
expect(jest.getTimerCount()).toBe(2)
|
||||
|
||||
SenseLiveview.beforeUnmount.call(context)
|
||||
expect(jest.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SenseVideoPlayer from '@/components/sense/video-player/index.vue'
|
||||
|
||||
const ElButton = { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' }
|
||||
const ElIcon = { template: '<span><slot /></span>' }
|
||||
|
||||
const mountPlayer = props => mount(SenseVideoPlayer, {
|
||||
props,
|
||||
global: { components: { ElButton, ElIcon }}
|
||||
})
|
||||
|
||||
describe('Sense live-view player lifecycle', () => {
|
||||
it('creates an iframe only for a selected playable session', () => {
|
||||
const empty = mountPlayer({ state: 'loading', playerUrl: '' })
|
||||
expect(empty.find('iframe').exists()).toBe(false)
|
||||
empty.unmount()
|
||||
|
||||
const waiting = mountPlayer({ state: 'waiting', playerUrl: '/api/v1/liveview/player/view_test' })
|
||||
expect(waiting.findAll('iframe')).toHaveLength(1)
|
||||
expect(waiting.find('iframe').attributes('src')).toBe('/api/v1/liveview/player/view_test')
|
||||
expect(waiting.attributes('aria-busy')).toBe('true')
|
||||
waiting.unmount()
|
||||
})
|
||||
|
||||
it('removes the iframe on timeout and offers an explicit retry', async() => {
|
||||
const wrapper = mountPlayer({ state: 'timeout', playerUrl: '/api/v1/liveview/player/view_test' })
|
||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('连接超时')
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('retry')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { playbackStatusDetail, playbackStatusLabel, playbackStatusType, routeStatus } from '@/views/sense/liveview/playbackStatus'
|
||||
|
||||
describe('Sense live-view playback states', () => {
|
||||
it('keeps authentication, missing stream and timeout actionable', () => {
|
||||
expect(playbackStatusLabel('authentication_failed')).toBe('摄像头认证失败')
|
||||
expect(playbackStatusDetail('stream_not_found')).toContain('视频服务')
|
||||
expect(playbackStatusDetail('timeout')).toContain('20 秒')
|
||||
expect(playbackStatusType('offline')).toBe('danger')
|
||||
})
|
||||
|
||||
it('maps route readiness without relying on color alone', () => {
|
||||
expect(routeStatus('ready')).toEqual({ label: '可观看', type: 'success' })
|
||||
expect(routeStatus('waiting')).toEqual({ label: '等待观看', type: 'warning' })
|
||||
expect(routeStatus('apply_failed').label).toBe('需要处理')
|
||||
})
|
||||
})
|
||||
@@ -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: c5482806a40428a436c202760eefdb6403406584
|
||||
synchronized_at: 2026-08-14T09:46:37Z
|
||||
wiki_revision: 0ba2909431bd04320a9b31121126b59b1824e3dc
|
||||
synchronized_at: 2026-08-15T01:13:02Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -100,6 +100,10 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
|
||||
|
||||
媒体路由只保存设备/Profile 引用、无秘密路径名、期望态、实际态、reader、退避和下次重试;摄像头凭据从内部端口按需解密,仅在 loopback Control API 请求内临时组装,不写入路由表、基础配置、日志或 Sense 响应。MediaMTX 故障和退避不改变 #66 的设备/Profile 验证状态。
|
||||
|
||||
工单 #68 在 `Sense/server/app/sense/liveview/` 建立单路监看投影和短期播放会话:认证 API 只返回设备/Profile 展示字段、播放状态和同源短期播放器地址,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。播放能力令牌使用 192 位随机值、绑定登录用户、同一用户只保留一个活动会话,登录页面持续轮询时按 2 分钟无活动窗口续期;关闭页面后停止续期。
|
||||
|
||||
`Sense/server/app/admin/router/sense_liveview.go` 将列表、创建会话和状态查询接入 GoAdmin JWT/Casbin,短期播放器包装页只凭不可猜测能力令牌访问,并设置 no-store、no-referrer、SAMEORIGIN 与 CSP。前端入口为 `Sense/ui/src/views/sense/liveview/index.vue`,复用 BasicLayout、Element Plus 表格/分页/Dialog/Tag 和权限指令;仅 `Sense/ui/src/components/sense/video-player/` 是业务专用播放器组件,任一时刻只建立一路 reader。
|
||||
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
<!-- sense-mvp:start -->
|
||||
@@ -114,3 +118,15 @@ ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Strea
|
||||
|
||||
旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。
|
||||
<!-- bell-mvp:end -->
|
||||
|
||||
<!-- sense-area:start -->
|
||||
## 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 内部路径。
|
||||
<!-- sense-area:end -->
|
||||
|
||||
@@ -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: ea2c540a9ac3f8ccf40123f8389eb392fb5e172b
|
||||
synchronized_at: 2026-08-14T09:46:41Z
|
||||
wiki_revision: 2bcd90c508180eb9a2f9fe37b62115d7d9207e18
|
||||
synchronized_at: 2026-08-15T01:13:04Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -85,6 +85,14 @@ synchronized_at: 2026-08-14T09:46:41Z
|
||||
- 路径状态区分 pending、waiting、ready、process_unavailable、apply_failed、status_unavailable、path_missing、stopped,并保存失败次数和有上限的下次重试时间。
|
||||
- 冷启动恢复 desired=running 路径;用户明确停止的路径保持 stopped,不因启动扫描自动重新启用。稳定路径只刷新状态,不重复下发配置或无意义增加版本。
|
||||
- MediaMTX 失败不得删除或降级设备台账与最后一次已验证 Profile。
|
||||
### Sense 实时监看规则
|
||||
|
||||
- 设备/Profile 列表必须分页和搜索;16/128 路不能导致页面同时创建全部播放器。用户选择一路并打开 Dialog 后才建立 reader,关闭或切换时销毁当前播放器。
|
||||
- 页面和 JSON API 不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。播放器只使用同源短期能力地址;能力令牌必须高熵、绑定用户、每用户单会话,并在停止认证轮询后最多 2 分钟失效。
|
||||
- 播放状态必须区分 loading、waiting、ready、authentication_failed、stream_not_found、service_unavailable、timeout、expired、offline 和 stopped,并同时显示文字与可行动处理建议,不能只用颜色表达。
|
||||
- waiting 表示媒体路径存在且播放器正在建立 reader,不等于摄像头接入失败;20 秒仍未就绪才显示连接超时并允许显式重连。
|
||||
- 默认从浏览器访问 Sense 的主机名推导 MediaMTX WebRTC 端口 8889;经过反向代理、HTTPS 或端口映射时,部署方必须显式配置安全的 `SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE`,不得回退到只对服务器自身有效的地址。
|
||||
|
||||
<!-- sense-media:end -->
|
||||
|
||||
<!-- sense-mvp:start -->
|
||||
@@ -126,3 +134,15 @@ synchronized_at: 2026-08-14T09:46:41Z
|
||||
- Profile 保存 token、名称、分辨率、编码、用途、无凭据 Stream URI 和逐 Profile 验证状态;主码流默认取分辨率最高项,子码流取最低项。
|
||||
- 认证失败、超时、时间异常、目标未授权和重定向拒绝必须给出不同状态。失败重探不得删除最后一次已验证 Profile;凭据更新后可重新探测。
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
<!-- sense-area:start -->
|
||||
## 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 配置协议必须由独立协调工单从当前版本投影生成,不能共享数据库模型。
|
||||
<!-- sense-area:end -->
|
||||
|
||||
@@ -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: e084a4ae3f5038c4ec7dae6028a2e3d7527680a8
|
||||
synchronized_at: 2026-08-14T09:46:47Z
|
||||
wiki_revision: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54
|
||||
synchronized_at: 2026-08-15T01:13:07Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -190,6 +190,36 @@ go test ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -v
|
||||
|
||||
测试必须使用隔离端口和数据库;结束后停止测试进程。不得输出连接串或摄像头凭据。
|
||||
|
||||
### Sense 实时监看
|
||||
|
||||
浏览器直接访问 Sense 所在主机且 MediaMTX 使用默认 WebRTC 端口 8889 时无需额外变量。反向代理、HTTPS 或端口映射部署必须在 Sense 进程环境提供浏览器可达的基础地址;值只能是无用户信息、查询和片段的 HTTP(S) origin:
|
||||
|
||||
```powershell
|
||||
$env:SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE = 'http://<浏览器可达主机>:8889'
|
||||
```
|
||||
|
||||
不要填写 RTSP 地址、Control API 地址、摄像头凭据或服务器内部文件路径。HTTPS 页面不得嵌入 HTTP 视频地址;应为 MediaMTX WebRTC 配置 HTTPS 或受控同源代理后填写对应 HTTPS origin。
|
||||
|
||||
定向与回归验证:
|
||||
|
||||
```powershell
|
||||
cd Sense/server
|
||||
go test -race ./app/sense/liveview
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
$env:SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||
go test ./cmd/migrate/migration/version -run TestLiveviewMigrationOnPostgres -count=1 -v
|
||||
|
||||
cd ../ui
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 test:unit
|
||||
corepack pnpm@9.15.1 build:prod
|
||||
```
|
||||
|
||||
真实 smoke 使用隔离端口、MediaMTX 和合成 RTSP:浏览器打开 WebRTC 播放地址后必须取得非零视频尺寸和可播放 readyState,并确认任一时刻只存在一个播放器。结束后停止测试 MediaMTX/FFmpeg/浏览器并删除临时目录。客户真实摄像机与现场网络仍需获得授权后验证,记录状态而不记录地址、URI 或凭据。
|
||||
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
|
||||
@@ -215,3 +245,40 @@ go test ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -v
|
||||
3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。
|
||||
4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。
|
||||
5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。
|
||||
|
||||
<!-- sense-area:start -->
|
||||
### 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 不启动时必须能独立保存、读取和预览。
|
||||
<!-- sense-area:end -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: c615cd5235557a3888260beb5a682c6ed4e5b5dc
|
||||
synchronized_at: 2026-08-14T09:46:56Z
|
||||
wiki_revision: 3523b7cd1f126ebc3ef77414cb6d658103f2491d
|
||||
synchronized_at: 2026-08-15T01:13:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -85,6 +85,34 @@ synchronized_at: 2026-08-14T09:46:56Z
|
||||
| 显示外部启动(受保护) | Sense 检测到不是本实例启动的 MediaMTX;孤儿安全闸生效,Sense 关闭时不会停止它。 |
|
||||
| 持续自动重试 | 查看失败码、失败次数和下次重试时间;修正二进制、端口、凭据或上游后等待退避到期,或由有权限用户立即对账。 |
|
||||
| Sense 重启后路径未恢复 | 确认数据库 route 的 desired 为 running、迁移已执行、Control API 可达;明确停止的路径不会自动恢复。 |
|
||||
### Sense 实时监看排错
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|---|---|
|
||||
| 浏览器提示 127.0.0.1 拒绝连接 | 127.0.0.1 指向使用者电脑,不一定是 Sense 服务器;将 `SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE` 配成浏览器实际可达的 MediaMTX HTTP(S) origin,并检查 8889 或映射端口。 |
|
||||
| `stream not found` / 未找到视频流 | 路径未恢复或 Profile 已失效;先到“视频服务”对账,确认路径存在,再重新打开实时监看建立 reader。不要把内部路径手工拼进页面。 |
|
||||
| 一直显示“等待视频” | 播放器已创建但 sourceOnDemand 尚未 ready;保持 Dialog 打开并检查摄像头 RTSP、WebRTC 端口和 MediaMTX reader。20 秒后页面会转为超时并提供重连。 |
|
||||
| 摄像头认证失败 | 到“设备管理”更新凭据,再到“视频接入”重新验证;页面不会显示或回填旧凭据。 |
|
||||
| 视频服务不可用 | 到“视频服务”检查 MediaMTX 进程、Control API、WebRTC 端口和路径对账,不要只刷新浏览器。 |
|
||||
| 播放会话已过期 | 页面关闭、网络中断或认证轮询停止超过 2 分钟;重新连接会生成新能力令牌,旧地址不应继续可用。 |
|
||||
| HTTPS 页面无法播放 HTTP 视频 | 浏览器阻止混合内容;为 MediaMTX WebRTC 配置 HTTPS 或受控同源代理,并把公开基础地址改成 HTTPS。 |
|
||||
| 服务端 curl 正常、浏览器仍失败 | 服务端可达不代表客户端可达;从实际用户浏览器检查公开主机、端口、防火墙、证书和 WebRTC UDP/TCP 路径。 |
|
||||
|
||||
<!-- sense-media:end -->
|
||||
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
<!-- sense-area:start -->
|
||||
## Sense 区域与警戒线排错
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|---|---|
|
||||
| 显示“需要重新校准” | 绑定 Profile 已删除、验证失效,或 Token、分辨率、编码发生变化;打开“编辑/校准”,选择当前可用码流,在实际画面确认坐标后保存新版本。不要手工清状态或复制旧坐标冒充校准。 |
|
||||
| 保存提示配置已被其他用户更新 | 当前页面的 `expectedVersion` 已过期;刷新列表,查看最新版本后重新编辑。系统会保留已成功写入的版本,不覆盖对方结果。 |
|
||||
| 画面可见但无法添加更多顶点 | 方向警戒线最多两个点,多边形最多 64 个点;检查配置类型,必要时撤销或清空后重画。 |
|
||||
| 提示边线交叉或面积过小 | 顶点顺序形成自交、重叠或退化多边形;拖动顶点消除交叉,确保至少三个不同且围成有效面积的点。 |
|
||||
| 实时画面不可用 | 先到“实时监看”确认该 Profile 可播放,再检查 MediaMTX/WebRTC 公开地址。区域 API 不返回或要求填写 RTSP URI。 |
|
||||
| Profile 已恢复但仍显示重校准 | 这是保守安全状态;恢复相同规格不会自动认可旧坐标。必须由有权限用户打开实际画面确认并保存新版本。 |
|
||||
| viewer 看得到页面但不能保存 | 符合只读权限;由 implementation_operator、site_admin 或 admin 完成配置。 |
|
||||
| 键盘无法操作顶点 | Tab 聚焦画布或编号顶点;Enter/Space 添加中心点,方向键移动,Delete/Backspace 删除。检查浏览器焦点轮廓是否可见。 |
|
||||
<!-- sense-area:end -->
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-68-Sense单路实时监看与播放状态反馈
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-68-Sense%E5%8D%95%E8%B7%AF%E5%AE%9E%E6%97%B6%E7%9B%91%E7%9C%8B%E4%B8%8E%E6%92%AD%E6%94%BE%E7%8A%B6%E6%80%81%E5%8F%8D%E9%A6%88.-
|
||||
wiki_revision: 88e542d512e472274a74e443a761217e24618941
|
||||
synchronized_at: 2026-08-15T00:40:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 68 Sense单路实时监看与播放状态反馈
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-14
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/68
|
||||
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/86
|
||||
- 主项目:Sense
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 #67 已对账的 MediaMTX 路径上,为网管和非技术人员提供设备/Profile 搜索、分页和单路实时查看。Brain、Bell 不启动时可独立运行;页面不得暴露摄像头凭据、RTSP URI或内部媒体路径。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 后端在 `app/sense/liveview` 建立只含展示字段的分页投影,最多返回 50 条,不把 16/128 路变成硬上限。
|
||||
- 登录用户选择一路后创建 192 位随机播放能力;令牌绑定用户、同用户只保留一个会话,认证轮询时按 2 分钟无活动窗口续期,关闭页面后失效。
|
||||
- JSON 只返回同源 `/api/v1/liveview/player/<能力>`;包装页使用 no-store、no-referrer、SAMEORIGIN 与 CSP,再嵌入浏览器可达的 MediaMTX WebRTC 页面。
|
||||
- 默认由 Sense 请求主机推导 WebRTC 8889;反向代理、HTTPS 或端口映射通过安全的 `SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE` 显式配置。
|
||||
- 复用 GoAdmin Router/JWT/Casbin/迁移/动态菜单及 go-admin-ui BasicLayout、Axios、Element Plus 搜索/表格/分页/Dialog/Tag/权限按钮;只新增播放器业务组件。
|
||||
- waiting 时先保留 iframe 建立 reader;ready 后持续轮询,20 秒未就绪显示超时。关闭 Dialog 或组件卸载会清理 iframe、轮询和超时计时器。
|
||||
- 状态区分 loading、waiting、ready、authentication_failed、stream_not_found、service_unavailable、timeout、expired、offline 和 stopped,并给出可行动中文处理建议。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/liveview/**`:分页投影、短期会话、播放器包装页及测试。
|
||||
- `Sense/server/app/admin/router/sense_liveview.go`:认证 API 与短期播放器路由。
|
||||
- `Sense/server/cmd/migrate/migration/version/*liveview*`:菜单、角色和 Casbin 权限。
|
||||
- `Sense/ui/src/views/sense/liveview/**`、`api/sense/liveview.js`:实时监看页面、状态映射和请求。
|
||||
- `Sense/ui/src/components/sense/video-player/**`:唯一业务专用播放器组件。
|
||||
- `Sense/ui/tests/unit/sense/liveview*.spec.js`:状态、iframe 和页面计时器生命周期测试。
|
||||
- Wiki Architecture、Business Rules、Local Development、Troubleshooting:架构、安全、配置、验证和排错。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 设备与 Profile 可搜索、分页并进入单路实时监看 | 通过 |
|
||||
| waiting 且播放 URL 有效时建立 reader,不循环等待 | 通过;真实合成 WebRTC smoke |
|
||||
| 加载、空、认证失败、stream not found、重连和超时可区分 | 通过 |
|
||||
| 页面不暴露凭据与内部文件路径 | 通过;DTO/JSON 测试与扫描 |
|
||||
| 128 路使用分页且不同时创建全部播放器 | 通过;服务端最大页 50,前端单 Dialog |
|
||||
| 通用组件无重复实现且播放器风格一致 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./app/sense/liveview`:通过。
|
||||
- PostgreSQL 17 隔离迁移:1 个菜单、9 条 Casbin 策略、1 条迁移记录通过;临时实例/schema/目录已清理。
|
||||
- 前端 lint:0 error,32 条冻结上游 warning。
|
||||
- 前端单测:13 suites、40 tests 通过。
|
||||
- 前端生产构建:通过,6 条冻结上游 warning。
|
||||
- MediaMTX v1.19.3 + FFmpeg 合成 RTSP + Edge WebRTC:640×360、readyState=4、自动播放中;临时进程、配置和自动证书已清理。
|
||||
- Wiki 页面读取确认并成功导出镜像。
|
||||
- **未验证部分**:客户真实摄像机、现场防火墙/证书、实际部署反向代理和客户目标浏览器,需授权现场验收。
|
||||
|
||||
## 风险与回退
|
||||
|
||||
回退 PR #86 可移除实时监看入口、会话 API、菜单和播放器组件;#67 的设备/Profile、媒体路由与 MediaMTX 生命周期不受影响。内存会话随进程退出自然失效,无数据库业务数据需要回滚。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `c63c623` feat: 重建 Sense 单路实时监看 (#68)
|
||||
- `a279a1e` docs: 记录 Sense 实时监看架构 (#68)
|
||||
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 验收日期:2026-08-15
|
||||
- 验收结论:用户明确回复“#68 验收通过”。
|
||||
- 后续处理:按分支治理将 PR #86 合入 `dev`,关闭单元工单,并在 MVP #8 与 Epic #7 更新索引;不修改 `main`。
|
||||
@@ -0,0 +1,84 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
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: c679c3c2b41a190480732cd28cc52083c76843e5
|
||||
synchronized_at: 2026-08-15T01:36:30Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 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 独立配置能力。
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 用户于 2026-08-15 明确确认“#69 验收通过”。
|
||||
- PR #87 已合并到 `dev`,合并提交:`61b79db9f72db3ca8b7187898b34763d9cab806c`。
|
||||
- 工单在归档与父工单同步完成后关闭;本次未合入 `main`。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `17bd383` feat: 重建 Sense 区域与警戒线配置 (#69)
|
||||
- `f82dd51` docs: 记录 Sense 区域配置架构 (#69)
|
||||
- `61b79db9` Merge pull request '#87' from feature/69-sense-area into dev
|
||||
@@ -119,6 +119,14 @@
|
||||
{
|
||||
"page": "Task-67-Sense视频服务生命周期与状态对账",
|
||||
"path": "docs/task/67-Sense视频服务生命周期与状态对账.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-68-Sense单路实时监看与播放状态反馈",
|
||||
"path": "docs/task/68-Sense单路实时监看与播放状态反馈.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-69-Sense多边形区域与方向警戒线配置",
|
||||
"path": "docs/task/69-Sense多边形区域与方向警戒线配置.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user