Compare commits

...
Author SHA1 Message Date
QiuSW 9472151103 docs: 校正工单 #92 验收归档 2026-08-15 15:31:14 +08:00
QiuSW e82f15f1fb docs: 记录工单 #92 验收通过 2026-08-15 15:30:10 +08:00
ila eaa6ae0815 Merge pull request '#93' from fix/92-sense-capabilities-jsonb into dev
[SEN] 修复旧设备 capabilities 迁移到 JSONB(#92)
2026-08-15 15:28:03 +08:00
QiuSW 3a686a8151 docs: 完成任务 #92 待验收归档 2026-08-15 15:20:01 +08:00
QiuSW 3c4578c804 docs: 登记任务 #92 归档镜像 2026-08-15 15:19:12 +08:00
QiuSW fe3badfe3e test: 覆盖旧设备完整迁移链 (#92) 2026-08-15 15:17:28 +08:00
QiuSW aee5f45d96 docs: 记录设备能力迁移排错 (#92) 2026-08-15 15:16:32 +08:00
QiuSW 68b436ac62 fix: 兼容旧设备能力字段迁移 (#92) 2026-08-15 15:15:02 +08:00
ila cf50c285f4 Merge pull request '#88' from docs/69-acceptance into dev
记录工单 #69 验收通过。
2026-08-15 09:37:08 +08:00
QiuSW 0bc113af04 docs: 记录工单 #69 验收通过 2026-08-15 09:36:34 +08:00
ila 61b79db9f7 Merge pull request '#87' from feature/69-sense-area into dev
[SEN] 重建多边形区域与方向警戒线配置(#69)

用户已明确验收通过。
2026-08-15 09:33:32 +08:00
QiuSW 55be48069f docs: 同步 Sense 区域配置归档 (#69) 2026-08-15 09:24:42 +08:00
QiuSW afd4dab567 docs: 归档 Sense 区域配置工单 (#69) 2026-08-15 09:19:40 +08:00
QiuSW f82dd51d95 docs: 记录 Sense 区域配置架构 (#69) 2026-08-15 09:15:11 +08:00
QiuSW 17bd383229 feat: 重建 Sense 区域与警戒线配置 (#69) 2026-08-15 09:10:43 +08:00
ila 020bf3fe5a Merge pull request '#86' from feature/68-sense-liveview into dev
[SEN] 重建单路实时监看与播放状态反馈(#68)

用户于 2026-08-15 验收通过。
2026-08-15 08:42:04 +08:00
QiuSW 17c1afd195 docs: 记录工单 #68 验收通过 2026-08-15 08:40:23 +08:00
QiuSW c83c181a12 docs: 归档工单 #68 待验收证据 2026-08-14 18:56:50 +08:00
QiuSW a279a1ec0d docs: 记录 Sense 实时监看架构 (#68) 2026-08-14 18:48:11 +08:00
QiuSW c63c623df5 feat: 重建 Sense 单路实时监看 (#68) 2026-08-14 18:48:06 +08:00
ila b1bdb91fdb Merge pull request '#85' from feature/67-sense-mediamtx-reconcile into dev
[SEN] 在 GoAdmin 基线上重建 MediaMTX 生命周期与状态对账(#67)

用户于 2026-08-14 验收通过。
2026-08-14 18:16:32 +08:00
45 changed files with 3906 additions and 10 deletions
@@ -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)
}
+11 -1
View File
@@ -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")
}
}
+126
View File
@@ -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
}
+81
View File
@@ -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
}
+84
View File
@@ -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)
}
})
}
}
+45
View File
@@ -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)
}
}
+304
View File
@@ -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(&current, "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)
}
}
+134
View File
@@ -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)
}
}
+291
View File
@@ -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 }
@@ -1,8 +1,11 @@
package version
import (
"database/sql"
"encoding/json"
"fmt"
"runtime"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -34,6 +37,9 @@ func init() {
func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := prepareLegacyDeviceCapabilities(tx); err != nil {
return err
}
if err := tx.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &deviceCasbinRule{}); err != nil {
return err
}
@@ -89,6 +95,104 @@ func migrateSenseDeviceLedger(db *gorm.DB, version string) error {
})
}
var supportedLegacyCapabilities = map[string]struct{}{
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
}
type legacyDeviceCapabilitiesRow struct {
ID string
Capabilities sql.NullString
}
// prepareLegacyDeviceCapabilities upgrades the pre-GoAdmin text column before
// GORM sees it. PostgreSQL cannot cast the old empty-string default to jsonb,
// and old rows stored a single capability token rather than a JSON array.
func prepareLegacyDeviceCapabilities(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
type columnMetadata struct {
DataType string
}
var column columnMetadata
result := tx.Raw(`SELECT data_type
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'sense_devices'
AND column_name = 'capabilities'`).Scan(&column)
if result.Error != nil {
return fmt.Errorf("inspect sense_devices.capabilities: %w", result.Error)
}
if result.RowsAffected == 0 || column.DataType == "json" || column.DataType == "jsonb" {
return nil
}
if column.DataType != "text" && column.DataType != "character varying" {
return fmt.Errorf("sense_devices.capabilities has unsupported legacy type %q", column.DataType)
}
if err := tx.Exec(`LOCK TABLE "sense_devices" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
return fmt.Errorf("lock sense_devices for capabilities migration: %w", err)
}
var rows []legacyDeviceCapabilitiesRow
if err := tx.Raw(`SELECT id, capabilities FROM "sense_devices" ORDER BY id`).Scan(&rows).Error; err != nil {
return fmt.Errorf("read legacy device capabilities: %w", err)
}
canonical := make(map[string]string, len(rows))
invalid := 0
for _, row := range rows {
value, err := canonicalLegacyCapabilities(row.Capabilities)
if err != nil {
invalid++
continue
}
canonical[row.ID] = value
}
if invalid > 0 {
return fmt.Errorf("sense_devices.capabilities contains unsupported legacy data in %d row(s); migration rolled back", invalid)
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" DROP DEFAULT`).Error; err != nil {
return fmt.Errorf("drop legacy capabilities default: %w", err)
}
for _, row := range rows {
if err := tx.Exec(`UPDATE "sense_devices" SET "capabilities" = ? WHERE "id" = ?`, canonical[row.ID], row.ID).Error; err != nil {
return fmt.Errorf("normalize legacy device capabilities: %w", err)
}
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" TYPE jsonb USING "capabilities"::jsonb`).Error; err != nil {
return fmt.Errorf("convert capabilities to jsonb: %w", err)
}
if err := tx.Exec(`ALTER TABLE "sense_devices" ALTER COLUMN "capabilities" SET DEFAULT '[]'::jsonb`).Error; err != nil {
return fmt.Errorf("set jsonb capabilities default: %w", err)
}
return nil
}
func canonicalLegacyCapabilities(value sql.NullString) (string, error) {
if !value.Valid || strings.TrimSpace(value.String) == "" {
return "[]", nil
}
trimmed := strings.TrimSpace(value.String)
if _, ok := supportedLegacyCapabilities[trimmed]; ok {
encoded, _ := json.Marshal([]string{trimmed})
return string(encoded), nil
}
if !strings.HasPrefix(trimmed, "[") {
return "", fmt.Errorf("legacy capability value is not an array")
}
var values []string
if err := json.Unmarshal([]byte(trimmed), &values); err != nil || values == nil || len(values) > 16 {
return "", fmt.Errorf("legacy capability array is invalid")
}
for index, item := range values {
item = strings.TrimSpace(item)
if _, ok := supportedLegacyCapabilities[item]; !ok {
return "", fmt.Errorf("legacy capability array contains an unsupported value")
}
values[index] = item
}
encoded, _ := json.Marshal(values)
return string(encoded), nil
}
func ensureDeviceMenu(tx *gorm.DB, desired migrationModels.SysMenu) (migrationModels.SysMenu, error) {
var menu migrationModels.SysMenu
err := tx.Where("menu_name = ?", desired.MenuName).First(&menu).Error
@@ -0,0 +1,207 @@
package version
import (
"database/sql"
"os"
"strings"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
deviceModels "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestCanonicalLegacyCapabilities(t *testing.T) {
tests := []struct {
name string
input sql.NullString
want string
wantErr bool
}{
{name: "null", input: sql.NullString{}, want: "[]"},
{name: "blank", input: sql.NullString{String: " ", Valid: true}, want: "[]"},
{name: "single", input: sql.NullString{String: " video ", Valid: true}, want: `["video"]`},
{name: "array", input: sql.NullString{String: `["radar", "contact"]`, Valid: true}, want: `["radar","contact"]`},
{name: "empty array", input: sql.NullString{String: `[]`, Valid: true}, want: `[]`},
{name: "unknown", input: sql.NullString{String: "unknown", Valid: true}, wantErr: true},
{name: "object", input: sql.NullString{String: `{"video":true}`, Valid: true}, wantErr: true},
{name: "unknown array item", input: sql.NullString{String: `["video","unknown"]`, Valid: true}, wantErr: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := canonicalLegacyCapabilities(test.input)
if test.wantErr {
if err == nil {
t.Fatalf("expected error, got %q", got)
}
return
}
if err != nil || got != test.want {
t.Fatalf("got %q, %v; want %q", got, err, test.want)
}
})
}
}
func TestDeviceCapabilitiesMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_DEVICE_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_DEVICE_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_device_92_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)
}
t.Run("converts legacy values and remains idempotent", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
for _, row := range [][2]string{{"single", "video"}, {"blank", ""}, {"array", `["radar","contact"]`}} {
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES (?, ?)`, row[0], row[1]).Error; err != nil {
t.Fatal(err)
}
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&deviceModels.Device{}); err != nil {
t.Fatalf("AutoMigrate after compatibility conversion: %v", err)
}
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
var values []struct{ ID, Capabilities string }
if err = db.Raw(`SELECT id, capabilities::text AS capabilities FROM sense_devices ORDER BY id`).Scan(&values).Error; err != nil {
t.Fatal(err)
}
got := map[string]string{}
for _, value := range values {
got[value.ID] = strings.ReplaceAll(value.Capabilities, " ", "")
}
if got["single"] != `["video"]` || got["blank"] != `[]` || got["array"] != `["radar","contact"]` {
t.Fatalf("unexpected converted values: %#v", got)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatalf("repeat migration: %v", err)
}
})
t.Run("rejects unknown values without partial conversion", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('valid', 'video'), ('invalid', 'unknown')`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err == nil {
t.Fatal("expected unsupported legacy data error")
}
assertCapabilitiesColumn(t, db, "text", "''::text")
var value string
if err = db.Raw(`SELECT capabilities FROM sense_devices WHERE id = 'valid'`).Scan(&value).Error; err != nil {
t.Fatal(err)
}
if value != "video" {
t.Fatalf("transaction left partial data: %q", value)
}
})
t.Run("does nothing when the table is absent", func(t *testing.T) {
if err = db.Exec(`DROP TABLE sense_devices`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyDeviceCapabilities); err != nil {
t.Fatal(err)
}
})
t.Run("completes the device migration and records its version", func(t *testing.T) {
resetLegacyDeviceTable(t, db)
if err = db.Exec(`INSERT INTO sense_devices (id, capabilities) VALUES ('legacy', 'video')`).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
const migrationVersion = "2026081414000_device.go"
if err = migrateSenseDeviceLedger(db, migrationVersion); err != nil {
t.Fatal(err)
}
assertCapabilitiesColumn(t, db, "jsonb", "'[]'::jsonb")
var migratedValue string
if err = db.Raw(`SELECT capabilities::text FROM sense_devices WHERE id = 'legacy'`).Scan(&migratedValue).Error; err != nil {
t.Fatal(err)
}
if strings.ReplaceAll(migratedValue, " ", "") != `["video"]` {
t.Fatalf("unexpected full-migration value: %q", migratedValue)
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration record count=%d", applied)
}
})
}
func resetLegacyDeviceTable(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_devices`).Error; err != nil {
t.Fatal(err)
}
if err := db.Exec(`CREATE TABLE sense_devices (
id text PRIMARY KEY,
name varchar(128) NOT NULL DEFAULT '',
location varchar(255) NOT NULL DEFAULT '',
modality varchar(32) NOT NULL DEFAULT 'video',
capabilities text NOT NULL DEFAULT '',
status varchar(32) NOT NULL DEFAULT 'active',
adapter_status varchar(32) NOT NULL DEFAULT 'ready',
rtsp_credential_same_as_onvif boolean NOT NULL DEFAULT true,
credential_updated_at timestamptz,
retry_requested_at timestamptz,
version bigint NOT NULL DEFAULT 1,
create_by bigint NOT NULL DEFAULT 0,
update_by bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT current_timestamp,
updated_at timestamptz NOT NULL DEFAULT current_timestamp,
deleted_at timestamptz
)`).Error; err != nil {
t.Fatal(err)
}
}
func assertCapabilitiesColumn(t *testing.T, db *gorm.DB, wantType, wantDefault string) {
t.Helper()
var column struct{ DataType, ColumnDefault string }
if err := db.Raw(`SELECT data_type, column_default
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'sense_devices'
AND column_name = 'capabilities'`).Scan(&column).Error; err != nil {
t.Fatal(err)
}
if column.DataType != wantType || column.ColumnDefault != wantDefault {
t.Fatalf("type/default=%q/%q; want %q/%q", column.DataType, column.ColumnDefault, wantType, wantDefault)
}
}
@@ -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)
}
}
+17
View File
@@ -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' })
}
+13
View File
@@ -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)
}
+455
View File
@@ -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>
+283
View File
@@ -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('需要处理')
})
})
+18 -2
View File
@@ -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 -->
+22 -2
View File
@@ -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 -->
+69 -2
View File
@@ -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 -->
+48 -2
View File
@@ -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: a169b2323323d9de6304e9b430ddbe9888ea1d25
synchronized_at: 2026-08-15T07:16:25Z
<!-- gitea-wiki-mirror:end -->
# 故障排查
@@ -85,6 +85,52 @@ 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 -->
<!-- sense-capabilities-jsonb:start -->
## Sense 旧设备能力字段迁移排错
启动迁移出现 `字段 "capabilities" 的默认值不能转换成类型 jsonb (SQLSTATE 42804)`,表示数据库仍保留旧版 `sense_devices.capabilities text DEFAULT ''`,而当前 GoAdmin 派生模型要求 JSONB。不要跳过迁移、删除设备记录或只手工删除默认值;旧单值数据仍可能在下一步转换失败。
工单 #92 的兼容迁移会在同一事务内锁定设备表并先验证全部旧值:空值转为 `[]`,`video`、`radar`、`contact`、`button`、`wearable`、`other` 等旧单值转为 JSON 数组,合法 JSON 数组保持数组。未知值或非数组 JSON 会拒绝迁移并整体回滚,不输出具体业务值。
处理步骤:
1. 停止所有连接该 Sense 数据库的服务实例。
2. 使用 `backup-sense.bat` 创建 PostgreSQL custom-format 备份,并确认备份文件可读取。
3. 部署包含 #92 的新 `sense.exe` 后重新运行 `migrate-sense.bat` 或正常启动。
4. 若提示“unsupported legacy data”,不要直接改表;保留错误、恢复测试副本并由维护人员确认旧能力语义。
5. 迁移成功后确认设备仍存在、能力标签正确,再启动其他实例。
正式数据库未备份时不得执行该结构迁移。需要回退版本时停止服务并从迁移前备份恢复,不把 JSONB 反向猜测为旧文本。
<!-- sense-capabilities-jsonb: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
@@ -0,0 +1,80 @@
<!-- gitea-wiki-mirror:start -->
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Task-92-Sense旧设备能力JSONB兼容迁移
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-92-Sense%E6%97%A7%E8%AE%BE%E5%A4%87%E8%83%BD%E5%8A%9BJSONB%E5%85%BC%E5%AE%B9%E8%BF%81%E7%A7%BB.-
wiki_revision: cbdcc65c2b7da74048713d49dcb4b49b47cec17e
synchronized_at: 2026-08-15T07:30:56Z
<!-- gitea-wiki-mirror:end -->
# 92 Sense旧设备能力JSONB兼容迁移
- 类型:缺陷
- 所属 Epic:#7
- 所属 MVP / 版本:#8
- 状态:已完成
- 日期:2026-08-15
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/92
- Wiki 页面:Task-92-Sense旧设备能力JSONB兼容迁移
- Wiki revision:见本地镜像头
## 背景与目标
用户通过 Windows 包启动 Sense 时,设备迁移报 PostgreSQL `SQLSTATE 42804`。只读诊断确认旧数据库的 `sense_devices.capabilities` 为 `text NOT NULL DEFAULT ''::text`,一条旧记录保存受支持的单值能力但不是 JSON;新模型要求 JSONB,GORM AutoMigrate 无法直接转换旧默认值和数据。
目标是在不删除设备、不跳过迁移、不修改当前用户数据库的前提下,为旧 schema 提供确定性、可回滚的 JSONB 兼容迁移。
## 最终方案
在现有 `2026081414000` 设备迁移事务开头执行 PostgreSQL 专用兼容步骤。受影响数据库尚未登记该迁移版本,后置新版本无法越过失败点,因此兼容逻辑必须放在原失败迁移内。
兼容步骤只处理既有 text/varchar 列:取得 ACCESS EXCLUSIVE 表锁,读取并验证全部旧值后才开始改变默认值和数据。空值转为 `[]`;六种受支持旧单值转为单元素 JSON 数组;合法字符串数组规范化后保持语义。未知单值、对象、非字符串数组、未知数组元素和超过 16 项的数组会返回不含业务值的错误,整个事务回滚。
全部验证通过后删除旧 text 默认值,参数化更新规范 JSON,使用显式 `USING capabilities::jsonb` 转型并设置 `'[]'::jsonb` 默认值,再继续原有 GORM AutoMigrate、菜单、权限和迁移版本登记。非 PostgreSQL、表/列不存在以及已是 json/jsonb 时不执行旧值转换。
## 修改文件
- `Sense/server/cmd/migrate/migration/version/2026081414000_device.go`:事务内旧 text 能力验证、转换、锁表和 JSONB 默认值处理。
- `Sense/server/cmd/migrate/migration/version/2026081414000_device_test.go`:纯函数和隔离 PostgreSQL 17 回归,包括完整设备迁移与版本登记。
- Wiki `Troubleshooting`、`docs/06-troubleshooting.md`:备份、重试、未知旧值和回退说明。
- `wiki-docs.json`、`docs/task/92-Sense旧设备能力JSONB兼容迁移.md`:任务归档登记和镜像。
## 验收结果
| 验收标准 | 结果 |
|---|---|
| 旧 text 默认值不再触发 SQLSTATE 42804 | 通过;隔离 PostgreSQL 重现结构成功转为 jsonb |
| 旧单值无损转换为 JSONB 数组 | 通过;`video` 转为单元素数组 |
| 空值与合法数组正确处理 | 通过;空字符串转空数组,合法数组保持顺序与值 |
| 未知值拒绝且无半成品 | 通过;类型、默认值和已验证行均保持旧状态 |
| 新库、jsonb 和重复执行兼容 | 通过;无表跳过、jsonb 重复调用无操作 |
| 全量 Go 和迁移回归通过 | 通过 |
| 当前用户数据库未被修改 | 通过;仅执行只读诊断,写测试使用独立数据库并在结束后删除 |
| Wiki 与归档一致 | 受影响页面定向检查通过 |
## 测试
- `go test ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1 -v`:通过。
- 隔离 PostgreSQL 17 `TestDeviceCapabilitiesMigrationOnPostgres`:旧单值/空值/合法数组、未知值事务回滚、无表、重复调用、完整设备迁移和 `sys_migration` 登记全部通过。
- 隔离数据库 `sense92_migration_test` 测试前确认不存在,测试后确认计数为 0。
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
- `go test -race ./cmd/migrate/migration/version -run TestCanonicalLegacyCapabilities -count=1`:通过。
- `git diff --check`:通过。
- 受影响 Wiki 定向同步与检查:通过。
- **未验证部分**:按工单安全边界未在当前用户 `sense` 数据库执行写迁移,也未替换当前 `Sense/dist` 中的待验收 #70 二进制;需先备份,再部署包含 #92 的新包进行最终启动验收。全量 Wiki 检查仍会先发现待验收 PR #89 的 #70 镜像尚未合入 `dev`。
## 用户验收
- 用户于 2026-08-15 明确确认 `#92 验收通过`。
- 实现 PR #93 已合入 `dev`,合并提交为 `eaa6ae081542b0b9c74cc2d92a9138639fdbe530`。
- #92 已完成;真实数据库备份、交付包重建与启动回归继续在 #70 的交付闭环中执行。
## 遗留问题
- 当前交付包仍含 #92 修复前的 `sense.exe`。#92 合入 `dev` 后需让 #70 交付分支吸收该提交并重新打包,用户备份数据库后再运行迁移。
- Harness strict 的 #66/#67 既有归档格式问题不属于本工单。
## 相关提交
- `68b436a` 兼容旧设备能力字段迁移。
- `fe3badf` 覆盖旧设备完整迁移链。
- `aee5f45` 记录设备能力迁移排错。
+12
View File
@@ -112,6 +112,10 @@
"page": "Task-65-Sense设备台账与凭据边界",
"path": "docs/task/65-Sense设备台账与凭据边界.md"
},
{
"page": "Task-92-Sense旧设备能力JSONB兼容迁移",
"path": "docs/task/92-Sense旧设备能力JSONB兼容迁移.md"
},
{
"page": "Task-66-Sense视频接入与Profile",
"path": "docs/task/66-Sense视频接入与Profile.md"
@@ -119,6 +123,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"
}
]
}