Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9472151103 | ||
|
|
e82f15f1fb | ||
|
|
eaa6ae0815 | ||
|
|
3a686a8151 | ||
|
|
3c4578c804 | ||
|
|
fe3badfe3e | ||
|
|
aee5f45d96 | ||
|
|
68b436ac62 | ||
|
|
cf50c285f4 | ||
|
|
0bc113af04 | ||
|
|
61b79db9f7 | ||
|
|
55be48069f | ||
|
|
afd4dab567 | ||
|
|
f82dd51d95 | ||
|
|
17bd383229 | ||
|
|
020bf3fe5a | ||
|
|
17c1afd195 | ||
|
|
c83c181a12 | ||
|
|
a279a1ec0d | ||
|
|
c63c623df5 | ||
|
|
b1bdb91fdb | ||
|
|
35b9c7b8a4 | ||
|
|
263a68c98d | ||
|
|
4149cc4426 | ||
|
|
19f9bfa1a0 | ||
|
|
24cf4bbd65 | ||
|
|
ab54235819 | ||
|
|
d74e1aee85 | ||
|
|
26e2e632d5 | ||
|
|
2bb1614a3c | ||
|
|
5856b76de9 | ||
|
|
3e859c3848 | ||
|
|
f7eab5d8fa | ||
|
|
d31c35f098 | ||
|
|
be091f093d | ||
|
|
1ef4a1fe0b | ||
|
|
654c444c18 | ||
|
|
903a423ed5 |
+7
-1
@@ -40,4 +40,10 @@ corepack pnpm@9.15.1 build:prod
|
|||||||
go run . server -c C:\secure-path\sense-settings.yml
|
go run . server -c C:\secure-path\sense-settings.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
仓库不提供默认账号、默认密码或可用密钥。管理员安全初始化由后续工单实现。
|
设备台账本身可以在不配置摄像头凭据的情况下使用。创建或更新 ONVIF/RTSP 凭据前,还必须在启动进程环境中设置 `SENSE_CREDENTIAL_KEY`:该值是随机 32 字节密钥的 Base64 编码,仅保存在仓库外。变量名模板见 `server/config/credential.env.example`;不要把真实值写入配置、脚本、日志或工单。密钥缺失或格式不正确时,Sense 会拒绝凭据写入,不会降级为明文存储。
|
||||||
|
|
||||||
|
使用 ONVIF 发现或手工接入前,还必须设置 `SENSE_ONVIF_DISCOVERY_IP` 和 `SENSE_ONVIF_ALLOWED_CIDRS`。前者只能是获准用于 WS-Discovery 的本机网卡地址;后者是获准访问的摄像头网段(多个 CIDR 用逗号分隔)。未配置时系统会给出可行动提示且不会扫描任意网卡;手工地址、Media XAddr 和 Stream URI 同样受该网段限制,并拒绝重定向或 URL 内凭据。
|
||||||
|
|
||||||
|
MediaMTX 保持独立二进制。配置 `SENSE_MEDIAMTX_BINARY`、`SENSE_MEDIAMTX_CONFIG` 和只允许回环地址的 `SENSE_MEDIAMTX_API`。Sense 只生成无摄像头凭据的基础配置;路径和凭据在运行时通过回环 Control API 下发。模板见 `server/config/mediamtx/mediamtx.yml.example`。
|
||||||
|
|
||||||
|
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||||
|
"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, registerSenseAdmissionRouter) }
|
||||||
|
func registerSenseAdmissionRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||||
|
api := &admission.API{}
|
||||||
|
r := v1.Group("/admission").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||||
|
r.GET("/discover", api.Discover)
|
||||||
|
r.GET("/devices/:id", api.Get)
|
||||||
|
r.POST("/devices/:id/probe", api.Probe)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
|
||||||
|
|
||||||
|
senseapis "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/apis"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
routerCheckRole = append(routerCheckRole, registerSenseDeviceRouter)
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerSenseDeviceRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddleware) {
|
||||||
|
api := senseapis.Device{}
|
||||||
|
r := v1.Group("/devices").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||||
|
{
|
||||||
|
r.GET("", api.GetPage)
|
||||||
|
r.GET("/:id", api.Get)
|
||||||
|
r.POST("", api.Insert)
|
||||||
|
r.PUT("/:id", api.Update)
|
||||||
|
r.PUT("/:id/disable", api.Disable)
|
||||||
|
r.PUT("/:id/credentials", api.UpdateCredentials)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
"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, registerSenseMediaRouter) }
|
||||||
|
|
||||||
|
func registerSenseMediaRouter(v1 *gin.RouterGroup, auth *jwt.GinJWTMiddleware) {
|
||||||
|
api := &media.API{}
|
||||||
|
r := v1.Group("/media").Use(auth.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
|
||||||
|
r.GET("/routes", api.List)
|
||||||
|
r.GET("/process", api.Process)
|
||||||
|
r.POST("/reconcile", api.ReconcileAll)
|
||||||
|
r.POST("/routes/:id/reconcile", api.Reconcile)
|
||||||
|
r.POST("/routes/:id/stop", api.Stop)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package admission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
|
||||||
|
)
|
||||||
|
|
||||||
|
type API struct{ api.Api }
|
||||||
|
|
||||||
|
func (e *API) runtime(c *gin.Context) (*Service, error) {
|
||||||
|
base := Service{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&base.Service).Errors; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewRuntime(base.Service)
|
||||||
|
}
|
||||||
|
func (e *API) Discover(c *gin.Context) {
|
||||||
|
service, err := e.runtime(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.Discover(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(result, "发现完成")
|
||||||
|
}
|
||||||
|
func (e *API) Probe(c *gin.Context) {
|
||||||
|
service, err := e.runtime(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request := ProbeRequest{DeviceID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||||
|
if err = bindStrict(c, &request); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.Probe(c.Request.Context(), request)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Media route intent is best-effort and credential-free. A MediaMTX
|
||||||
|
// failure must never roll back the verified device/Profile transaction.
|
||||||
|
_ = media.EnsureDeviceRoutes(c.Request.Context(), service.Orm, request.DeviceID)
|
||||||
|
e.OK(result, "探测完成")
|
||||||
|
}
|
||||||
|
func (e *API) Get(c *gin.Context) {
|
||||||
|
service := &Service{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := service.Get(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(result, "查询成功")
|
||||||
|
}
|
||||||
|
func (e *API) writeError(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalid):
|
||||||
|
e.Error(http.StatusBadRequest, err, err.Error())
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
e.Error(http.StatusNotFound, err, err.Error())
|
||||||
|
case errors.Is(err, ErrConflict):
|
||||||
|
e.Error(http.StatusConflict, err, err.Error())
|
||||||
|
case errors.Is(err, onvif.ErrDiscoveryNotConfigured), errors.Is(err, onvif.ErrDiscoveryInterface), errors.Is(err, onvif.ErrTargetNotAllowed):
|
||||||
|
e.Error(http.StatusBadRequest, err, err.Error())
|
||||||
|
case errors.Is(err, credential.ErrCredentialNotConfigured):
|
||||||
|
e.Error(http.StatusConflict, err, "请先在设备管理中配置摄像头凭据")
|
||||||
|
case errors.Is(err, credential.ErrKeyUnavailable):
|
||||||
|
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
|
||||||
|
default:
|
||||||
|
e.Error(http.StatusInternalServerError, err, "视频接入操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func bindStrict(c *gin.Context, target any) error {
|
||||||
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||||
|
return errors.New("content type must be application/json")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
if err == nil {
|
||||||
|
return errors.New("request body must contain one JSON object")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package admission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProbePayloadRejectsCredentialFields(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest("POST", "/api/v1/admission/devices/device/probe", strings.NewReader(`{"address":"http://192.0.2.10/onvif","version":1,"password":"must-not-be-accepted"}`))
|
||||||
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
var request ProbeRequest
|
||||||
|
if err := bindStrict(ctx, &request); err == nil {
|
||||||
|
t.Fatal("credential-like unknown field accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package admission
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type ProbeRequest struct {
|
||||||
|
DeviceID string `json:"-"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdateBy int `json:"-"`
|
||||||
|
}
|
||||||
|
type ProfileResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
StreamURI string `json:"streamUri"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
VerificationStatus string `json:"verificationStatus"`
|
||||||
|
VerificationLatencyMS int64 `json:"verificationLatencyMs"`
|
||||||
|
VerificationDetail string `json:"verificationDetail"`
|
||||||
|
}
|
||||||
|
type ResultResponse struct {
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
CheckedAt time.Time `json:"checkedAt"`
|
||||||
|
Profiles []ProfileResponse `json:"profiles"`
|
||||||
|
}
|
||||||
|
type DiscoveryResponse struct {
|
||||||
|
Addresses []string `json:"addresses"`
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package admission
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Result struct {
|
||||||
|
DeviceID string `gorm:"size:36;primaryKey"`
|
||||||
|
Address string `gorm:"size:1024;not null"`
|
||||||
|
Status string `gorm:"size:32;not null;index"`
|
||||||
|
Detail string `gorm:"size:512;not null"`
|
||||||
|
CheckedAt time.Time `gorm:"not null"`
|
||||||
|
UpdatedAt time.Time
|
||||||
|
Profiles []Profile `gorm:"foreignKey:DeviceID;references:DeviceID;constraint:OnDelete:CASCADE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Result) TableName() string { return "sense_admission_results" }
|
||||||
|
|
||||||
|
type Profile struct {
|
||||||
|
DeviceID string `gorm:"size:36;primaryKey"`
|
||||||
|
Token string `gorm:"size:255;primaryKey"`
|
||||||
|
Name string `gorm:"size:255;not null"`
|
||||||
|
Width int `gorm:"not null"`
|
||||||
|
Height int `gorm:"not null"`
|
||||||
|
Encoding string `gorm:"size:32;not null"`
|
||||||
|
StreamURI string `gorm:"size:2048;not null"`
|
||||||
|
Kind string `gorm:"size:16;not null"`
|
||||||
|
VerificationStatus string `gorm:"size:32;not null"`
|
||||||
|
VerificationLatencyMS int64 `gorm:"not null"`
|
||||||
|
VerificationDetail string `gorm:"size:512;not null"`
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Profile) TableName() string { return "sense_admission_profiles" }
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package admission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
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"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/rtsp"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("尚无该设备的接入结果")
|
||||||
|
ErrInvalid = errors.New("接入请求不符合要求")
|
||||||
|
ErrConflict = errors.New("设备已被其他用户更新,请刷新后重试")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
coreService.Service
|
||||||
|
ONVIF onvif.Client
|
||||||
|
RTSP rtsp.Verifier
|
||||||
|
Policy onvif.Policy
|
||||||
|
DiscoveryIP string
|
||||||
|
Timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRuntime(service coreService.Service) (*Service, error) {
|
||||||
|
policy, err := onvif.ParsePolicy(os.Getenv("SENSE_ONVIF_ALLOWED_CIDRS"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Service{Service: service, Policy: policy, DiscoveryIP: strings.TrimSpace(os.Getenv("SENSE_ONVIF_DISCOVERY_IP")), ONVIF: onvif.NewHTTPClient(8*time.Second, policy), RTSP: rtsp.NetVerifier{Timeout: 5 * time.Second, Policy: policy}}, nil
|
||||||
|
}
|
||||||
|
func (s *Service) Discover(ctx context.Context) (DiscoveryResponse, error) {
|
||||||
|
addresses, err := onvif.Discover(ctx, s.DiscoveryIP, 3*time.Second, s.Policy)
|
||||||
|
return DiscoveryResponse{Addresses: addresses, Interface: s.DiscoveryIP}, err
|
||||||
|
}
|
||||||
|
func (s *Service) Probe(ctx context.Context, request ProbeRequest) (ResultResponse, error) {
|
||||||
|
if request.Version < 1 || strings.TrimSpace(request.Address) == "" || len(request.Address) > 1024 {
|
||||||
|
return ResultResponse{}, ErrInvalid
|
||||||
|
}
|
||||||
|
var device deviceModels.Device
|
||||||
|
if err := s.Orm.Select("id", "modality", "version").First(&device, "id = ?", request.DeviceID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ResultResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return ResultResponse{}, err
|
||||||
|
}
|
||||||
|
if device.Modality != deviceModels.ModalityVideo {
|
||||||
|
return ResultResponse{}, ErrInvalid
|
||||||
|
}
|
||||||
|
if device.Version != request.Version {
|
||||||
|
return ResultResponse{}, ErrConflict
|
||||||
|
}
|
||||||
|
onvifValue, err := credential.Read(s.Orm, request.DeviceID, credential.PurposeONVIF)
|
||||||
|
if err != nil {
|
||||||
|
return ResultResponse{}, err
|
||||||
|
}
|
||||||
|
rtspValue, err := credential.Read(s.Orm, request.DeviceID, credential.PurposeRTSP)
|
||||||
|
if err != nil {
|
||||||
|
return ResultResponse{}, err
|
||||||
|
}
|
||||||
|
profiles, probeErr := s.ONVIF.Profiles(ctx, request.Address, onvif.Credential{Username: onvifValue.Username, Password: onvifValue.Password})
|
||||||
|
now := time.Now().UTC()
|
||||||
|
result := Result{DeviceID: request.DeviceID, Address: strings.TrimSpace(request.Address), CheckedAt: now, UpdatedAt: now}
|
||||||
|
if probeErr != nil {
|
||||||
|
result.Status, result.Detail = classify(probeErr)
|
||||||
|
if err = s.save(result, request, false); err != nil {
|
||||||
|
return ResultResponse{}, err
|
||||||
|
}
|
||||||
|
return s.Get(request.DeviceID)
|
||||||
|
}
|
||||||
|
for _, profile := range profiles {
|
||||||
|
verification, verifyErr := s.RTSP.Verify(ctx, profile.StreamURI, rtsp.Credential{Username: rtspValue.Username, Password: rtspValue.Password})
|
||||||
|
if verifyErr != nil {
|
||||||
|
verification = rtsp.Result{Status: "failed", Detail: "视频地址未通过安全检查"}
|
||||||
|
}
|
||||||
|
result.Profiles = append(result.Profiles, Profile{DeviceID: request.DeviceID, Token: profile.Token, Name: profile.Name, Width: profile.Width, Height: profile.Height, Encoding: profile.Encoding, StreamURI: profile.StreamURI, Kind: "other", VerificationStatus: verification.Status, VerificationLatencyMS: verification.LatencyMS, VerificationDetail: verification.Detail, UpdatedAt: now})
|
||||||
|
}
|
||||||
|
sort.Slice(result.Profiles, func(i, j int) bool {
|
||||||
|
return result.Profiles[i].Width*result.Profiles[i].Height > result.Profiles[j].Width*result.Profiles[j].Height
|
||||||
|
})
|
||||||
|
if len(result.Profiles) > 0 {
|
||||||
|
result.Profiles[0].Kind = "main"
|
||||||
|
}
|
||||||
|
if len(result.Profiles) > 1 {
|
||||||
|
result.Profiles[len(result.Profiles)-1].Kind = "sub"
|
||||||
|
}
|
||||||
|
result.Status = "ready"
|
||||||
|
result.Detail = "设备与视频 Profile 已验证"
|
||||||
|
for _, profile := range result.Profiles {
|
||||||
|
if profile.VerificationStatus != "ready" {
|
||||||
|
result.Status = "profile_failed"
|
||||||
|
result.Detail = "部分视频 Profile 验证失败"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = s.save(result, request, true); err != nil {
|
||||||
|
return ResultResponse{}, err
|
||||||
|
}
|
||||||
|
return response(result), nil
|
||||||
|
}
|
||||||
|
func (s *Service) save(result Result, request ProbeRequest, replaceProfiles bool) error {
|
||||||
|
return s.Orm.Transaction(func(tx *gorm.DB) error {
|
||||||
|
updates := map[string]any{"version": request.Version + 1, "update_by": request.UpdateBy, "updated_at": result.UpdatedAt, "retry_requested_at": nil}
|
||||||
|
if replaceProfiles {
|
||||||
|
updates["status"] = map[bool]string{true: deviceModels.StatusActive, false: deviceModels.StatusPending}[result.Status == "ready"]
|
||||||
|
updates["adapter_status"] = map[bool]string{true: deviceModels.AdapterReady, false: deviceModels.AdapterFailed}[result.Status == "ready"]
|
||||||
|
}
|
||||||
|
update := tx.Model(&deviceModels.Device{}).Where("id = ? AND version = ?", request.DeviceID, request.Version).Updates(updates)
|
||||||
|
if update.Error != nil {
|
||||||
|
return update.Error
|
||||||
|
}
|
||||||
|
if update.RowsAffected == 0 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
if err := tx.Omit("Profiles").Save(&result).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if replaceProfiles {
|
||||||
|
if err := tx.Where("device_id = ?", request.DeviceID).Delete(&Profile{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if replaceProfiles && len(result.Profiles) > 0 {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
|
func (s *Service) Get(deviceID string) (ResultResponse, error) {
|
||||||
|
var result Result
|
||||||
|
if err := s.Orm.Preload("Profiles", func(db *gorm.DB) *gorm.DB { return db.Order("width * height DESC") }).First(&result, "device_id = ?", deviceID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ResultResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return ResultResponse{}, fmt.Errorf("read admission: %w", err)
|
||||||
|
}
|
||||||
|
return response(result), nil
|
||||||
|
}
|
||||||
|
func response(result Result) ResultResponse {
|
||||||
|
out := ResultResponse{DeviceID: result.DeviceID, Address: result.Address, Status: result.Status, Detail: result.Detail, CheckedAt: result.CheckedAt, Profiles: make([]ProfileResponse, 0, len(result.Profiles))}
|
||||||
|
for _, p := range result.Profiles {
|
||||||
|
out.Profiles = append(out.Profiles, ProfileResponse{Token: p.Token, Name: p.Name, Width: p.Width, Height: p.Height, Encoding: p.Encoding, StreamURI: p.StreamURI, Kind: p.Kind, VerificationStatus: p.VerificationStatus, VerificationLatencyMS: p.VerificationLatencyMS, VerificationDetail: p.VerificationDetail})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
func classify(err error) (string, string) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, onvif.ErrAuthentication):
|
||||||
|
return "authentication_failed", "设备拒绝了当前凭据,请更新后重试"
|
||||||
|
case errors.Is(err, onvif.ErrTargetNotAllowed):
|
||||||
|
return "target_not_allowed", "设备地址不在获准网段内"
|
||||||
|
case errors.Is(err, onvif.ErrRedirect):
|
||||||
|
return "redirect_rejected", "设备返回了不允许的重定向"
|
||||||
|
case errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout"):
|
||||||
|
return "timeout", "设备响应超时"
|
||||||
|
case strings.Contains(strings.ToLower(err.Error()), "time") || strings.Contains(strings.ToLower(err.Error()), "clock"):
|
||||||
|
return "clock_skew", "设备时间可能不准确,请校时后重试"
|
||||||
|
default:
|
||||||
|
return "unreachable", "无法读取设备信息,请检查地址和网络"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package admission
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||||
|
"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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeRTSP struct{}
|
||||||
|
|
||||||
|
func (fakeRTSP) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
|
||||||
|
return rtsp.Result{Status: "ready", Detail: "码流可访问"}, nil
|
||||||
|
}
|
||||||
|
func admissionService(t *testing.T) *Service {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &Result{}, &Profile{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := []byte("0123456789abcdef0123456789abcdef")
|
||||||
|
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||||
|
vault, _ := credential.NewVault(key)
|
||||||
|
device := deviceModels.Device{ID: "device-1", Name: "东门摄像机", Modality: deviceModels.ModalityVideo, Version: 1, Status: "pending", AdapterStatus: "ready"}
|
||||||
|
if err = db.Create(&device).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, purpose := range []string{credential.PurposeONVIF, credential.PurposeRTSP} {
|
||||||
|
cipher, _ := vault.Encrypt(device.ID, purpose, "synthetic-user", "synthetic-password")
|
||||||
|
if err = db.Create(&credential.DeviceCredential{DeviceID: device.ID, Purpose: purpose, Ciphertext: cipher, KeyVersion: credential.Version()}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Service{Service: coreService.Service{Orm: db}, ONVIF: fakeONVIF{}, RTSP: fakeRTSP{}}
|
||||||
|
}
|
||||||
|
func TestProbePersistsProfilesWithoutReturningCredentials(t *testing.T) {
|
||||||
|
service := admissionService(t)
|
||||||
|
result, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Status != "ready" || len(result.Profiles) != 2 || result.Profiles[0].Kind != "main" || result.Profiles[1].Kind != "sub" {
|
||||||
|
t.Fatalf("result=%#v", result)
|
||||||
|
}
|
||||||
|
if result.Profiles[0].StreamURI == "" {
|
||||||
|
t.Fatal("stream URI missing")
|
||||||
|
}
|
||||||
|
saved, err := service.Get("device-1")
|
||||||
|
if err != nil || len(saved.Profiles) != 2 {
|
||||||
|
t.Fatalf("saved=%#v err=%v", saved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestFailedReprobePreservesLastVerifiedProfiles(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)
|
||||||
|
}
|
||||||
|
service.ONVIF = fakeONVIF{err: onvif.ErrAuthentication}
|
||||||
|
result, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Status != "authentication_failed" || len(result.Profiles) != 2 {
|
||||||
|
t.Fatalf("last verified profiles lost: %#v", result)
|
||||||
|
}
|
||||||
|
if _, err = service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("stale error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeErrorsHaveActionableStates(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
err error
|
||||||
|
status string
|
||||||
|
}{
|
||||||
|
{onvif.ErrAuthentication, "authentication_failed"},
|
||||||
|
{onvif.ErrTargetNotAllowed, "target_not_allowed"},
|
||||||
|
{errors.New("device clock time fault"), "clock_skew"},
|
||||||
|
{context.DeadlineExceeded, "timeout"},
|
||||||
|
} {
|
||||||
|
status, detail := classify(test.err)
|
||||||
|
if status != test.status || detail == "" {
|
||||||
|
t.Fatalf("error=%v status=%s detail=%s", test.err, status, detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileResolutionChangeMarksAreaForRecalibration(t *testing.T) {
|
||||||
|
service := admissionService(t)
|
||||||
|
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.Orm.AutoMigrate(&area.Definition{}, &area.Version{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
definition := area.Definition{ID: "area-1", Name: "东门警戒线", Kind: area.KindDirectionLine, DeviceID: "device-1", ProfileToken: "main", ProfileWidth: 1920, ProfileHeight: 1080, ProfileEncoding: "H264", CurrentVersion: 1, CurrentVersionID: "version-1", Enabled: true, CreatedBy: 7, UpdatedBy: 7}
|
||||||
|
if err := service.Orm.Create(&definition).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service.ONVIF = fakeONVIF{profiles: []onvif.Profile{{Token: "main", Name: "主码流", Width: 1280, Height: 720, Encoding: "H264", StreamURI: "rtsp://192.0.2.10/main"}}}
|
||||||
|
if _, err := service.Probe(context.Background(), ProbeRequest{DeviceID: "device-1", Address: "http://192.0.2.10/onvif", Version: 2}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.Orm.First(&definition, "id = ?", "area-1").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !definition.NeedsRecalibration {
|
||||||
|
t.Fatal("profile resolution change did not mark the bound area")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||||
|
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type API struct{ api.Api }
|
||||||
|
|
||||||
|
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||||
|
base := coreService.Service{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewService(base.Orm), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) List(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||||
|
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), Kind: c.Query("kind"), RecalibrationState: c.Query("recalibrationState"), PageIndex: pageIndex, PageSize: pageSize})
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Create(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request UpsertRequest
|
||||||
|
if err = decodeJSON(c, &request); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.UpdateBy = user.GetUserId(c)
|
||||||
|
item, err := service.Create(c.Request.Context(), request)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(item, "区域配置已创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Update(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request UpsertRequest
|
||||||
|
if err = decodeJSON(c, &request); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.UpdateBy = user.GetUserId(c)
|
||||||
|
item, err := service.Update(c.Request.Context(), c.Param("id"), request)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(item, "已保存新版本")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Versions(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := service.Versions(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(items, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) writeError(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidRequest), errors.Is(err, ErrInvalidGeometry):
|
||||||
|
e.Error(http.StatusBadRequest, err, err.Error())
|
||||||
|
case errors.Is(err, ErrNotFound), errors.Is(err, ErrProfileMissing):
|
||||||
|
e.Error(http.StatusNotFound, err, err.Error())
|
||||||
|
case errors.Is(err, ErrConflict):
|
||||||
|
e.Error(http.StatusConflict, err, err.Error())
|
||||||
|
default:
|
||||||
|
e.Error(http.StatusInternalServerError, err, "区域配置操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(c *gin.Context, target any) error {
|
||||||
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||||
|
return errors.New("content type must be application/json")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
if err == nil {
|
||||||
|
return errors.New("request body must contain one JSON object")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
KindPolygon = "polygon"
|
||||||
|
KindDirectionLine = "direction_line"
|
||||||
|
DirectionForward = "forward"
|
||||||
|
DirectionReverse = "reverse"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Point struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageRequest struct {
|
||||||
|
Keyword string
|
||||||
|
Kind string
|
||||||
|
RecalibrationState string
|
||||||
|
PageIndex int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpsertRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
RouteID string `json:"routeId"`
|
||||||
|
Points []Point `json:"points"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ExpectedVersion int64 `json:"expectedVersion"`
|
||||||
|
UpdateBy int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
DeviceLocation string `json:"deviceLocation"`
|
||||||
|
ProfileToken string `json:"profileToken"`
|
||||||
|
ProfileName string `json:"profileName"`
|
||||||
|
ProfileWidth int `json:"profileWidth"`
|
||||||
|
ProfileHeight int `json:"profileHeight"`
|
||||||
|
ProfileEncoding string `json:"profileEncoding"`
|
||||||
|
RouteID string `json:"routeId"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
Points []Point `json:"points"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
NeedsRecalibration bool `json:"needsRecalibration"`
|
||||||
|
UpdatedBy int `json:"updatedBy"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VersionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
ProfileToken string `json:"profileToken"`
|
||||||
|
ProfileWidth int `json:"profileWidth"`
|
||||||
|
ProfileHeight int `json:"profileHeight"`
|
||||||
|
ProfileEncoding string `json:"profileEncoding"`
|
||||||
|
Points []Point `json:"points"`
|
||||||
|
Direction string `json:"direction"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
SupersedesID string `json:"supersedesId"`
|
||||||
|
CreatedBy int `json:"createdBy"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProfileSnapshot struct {
|
||||||
|
Token string
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Encoding string
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidGeometry = errors.New("区域几何不符合要求")
|
||||||
|
|
||||||
|
func validateGeometry(kind, direction string, points []Point) error {
|
||||||
|
if kind != KindPolygon && kind != KindDirectionLine {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
if (kind == KindPolygon && (len(points) < 3 || len(points) > 64)) || (kind == KindDirectionLine && len(points) != 2) {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
if kind == KindDirectionLine && direction != DirectionForward && direction != DirectionReverse {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
if kind == KindPolygon && direction != "" {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
for i, point := range points {
|
||||||
|
if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) || point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
if i > 0 && samePoint(point, points[i-1]) {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if kind == KindDirectionLine {
|
||||||
|
if samePoint(points[0], points[1]) {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if samePoint(points[0], points[len(points)-1]) || math.Abs(polygonArea(points)) < 0.000001 || polygonSelfIntersects(points) {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func samePoint(a, b Point) bool {
|
||||||
|
return math.Abs(a.X-b.X) < 0.0000001 && math.Abs(a.Y-b.Y) < 0.0000001
|
||||||
|
}
|
||||||
|
|
||||||
|
func polygonArea(points []Point) float64 {
|
||||||
|
area := 0.0
|
||||||
|
for i := range points {
|
||||||
|
next := points[(i+1)%len(points)]
|
||||||
|
area += points[i].X*next.Y - next.X*points[i].Y
|
||||||
|
}
|
||||||
|
return area / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func polygonSelfIntersects(points []Point) bool {
|
||||||
|
for i := range points {
|
||||||
|
a1, a2 := points[i], points[(i+1)%len(points)]
|
||||||
|
for j := i + 1; j < len(points); j++ {
|
||||||
|
if j == i || j == (i+1)%len(points) || i == (j+1)%len(points) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b1, b2 := points[j], points[(j+1)%len(points)]
|
||||||
|
if segmentsIntersect(a1, a2, b1, b2) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func segmentsIntersect(a, b, c, d Point) bool {
|
||||||
|
orientation := func(p, q, r Point) float64 {
|
||||||
|
return (q.Y-p.Y)*(r.X-q.X) - (q.X-p.X)*(r.Y-q.Y)
|
||||||
|
}
|
||||||
|
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
|
||||||
|
if ((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
onSegment := func(p, q, r Point) bool {
|
||||||
|
return q.X <= math.Max(p.X, r.X)+0.0000001 && q.X >= math.Min(p.X, r.X)-0.0000001 && q.Y <= math.Max(p.Y, r.Y)+0.0000001 && q.Y >= math.Min(p.Y, r.Y)-0.0000001
|
||||||
|
}
|
||||||
|
return (math.Abs(o1) < 0.0000001 && onSegment(a, c, b)) || (math.Abs(o2) < 0.0000001 && onSegment(a, d, b)) || (math.Abs(o3) < 0.0000001 && onSegment(c, a, d)) || (math.Abs(o4) < 0.0000001 && onSegment(c, b, d))
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestGeometryValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
kind string
|
||||||
|
direction string
|
||||||
|
points []Point
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"polygon", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, true},
|
||||||
|
{"self intersecting", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.8}, {0.8, 0.1}, {0.1, 0.8}}, false},
|
||||||
|
{"outside", KindPolygon, "", []Point{{-0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, false},
|
||||||
|
{"line", KindDirectionLine, DirectionForward, []Point{{0.2, 0.5}, {0.8, 0.5}}, true},
|
||||||
|
{"line missing direction", KindDirectionLine, "", []Point{{0.2, 0.5}, {0.8, 0.5}}, false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
err := validateGeometry(test.kind, test.direction, test.points)
|
||||||
|
if (err == nil) != test.valid {
|
||||||
|
t.Fatalf("valid=%v err=%v", test.valid, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type Definition struct {
|
||||||
|
ID string `gorm:"size:36;primaryKey"`
|
||||||
|
Name string `gorm:"size:128;not null;index"`
|
||||||
|
Kind string `gorm:"size:32;not null;index"`
|
||||||
|
DeviceID string `gorm:"size:36;not null;index"`
|
||||||
|
ProfileToken string `gorm:"size:255;not null"`
|
||||||
|
ProfileWidth int `gorm:"not null"`
|
||||||
|
ProfileHeight int `gorm:"not null"`
|
||||||
|
ProfileEncoding string `gorm:"size:32;not null"`
|
||||||
|
CurrentVersion int64 `gorm:"not null"`
|
||||||
|
CurrentVersionID string `gorm:"size:36;not null;uniqueIndex"`
|
||||||
|
Enabled bool `gorm:"not null"`
|
||||||
|
NeedsRecalibration bool `gorm:"not null;index"`
|
||||||
|
CreatedBy int `gorm:"not null"`
|
||||||
|
UpdatedBy int `gorm:"not null"`
|
||||||
|
CreatedAt time.Time `gorm:"not null"`
|
||||||
|
UpdatedAt time.Time `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Definition) TableName() string { return "sense_area_definitions" }
|
||||||
|
|
||||||
|
type Version struct {
|
||||||
|
ID string `gorm:"size:36;primaryKey"`
|
||||||
|
DefinitionID string `gorm:"size:36;not null;uniqueIndex:ux_sense_area_version,priority:1;index"`
|
||||||
|
Version int64 `gorm:"not null;uniqueIndex:ux_sense_area_version,priority:2"`
|
||||||
|
Name string `gorm:"size:128;not null"`
|
||||||
|
Kind string `gorm:"size:32;not null"`
|
||||||
|
DeviceID string `gorm:"size:36;not null"`
|
||||||
|
ProfileToken string `gorm:"size:255;not null"`
|
||||||
|
ProfileWidth int `gorm:"not null"`
|
||||||
|
ProfileHeight int `gorm:"not null"`
|
||||||
|
ProfileEncoding string `gorm:"size:32;not null"`
|
||||||
|
GeometryJSON string `gorm:"type:text;not null"`
|
||||||
|
Direction string `gorm:"size:16;not null"`
|
||||||
|
Enabled bool `gorm:"not null"`
|
||||||
|
SupersedesID string `gorm:"size:36"`
|
||||||
|
CreatedBy int `gorm:"not null"`
|
||||||
|
CreatedAt time.Time `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Version) TableName() string { return "sense_area_versions" }
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConcurrentUpdateOnPostgresReturnsConflict(t *testing.T) {
|
||||||
|
baseDSN := os.Getenv("SENSE_AREA_TEST_DATABASE_URL")
|
||||||
|
if baseDSN == "" {
|
||||||
|
t.Skip("set SENSE_AREA_TEST_DATABASE_URL to run the PostgreSQL concurrency test")
|
||||||
|
}
|
||||||
|
admin, err := gorm.Open(postgres.Open(baseDSN), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
const schema = "sense_area_69_concurrency"
|
||||||
|
if err = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
|
||||||
|
separator := "?"
|
||||||
|
if strings.Contains(baseDSN, "?") {
|
||||||
|
separator = "&"
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(baseDSN+separator+"search_path="+schema), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, statement := range []string{
|
||||||
|
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
|
||||||
|
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
|
||||||
|
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
|
||||||
|
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||||
|
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
|
||||||
|
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
|
||||||
|
} {
|
||||||
|
if err = db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service := NewService(db)
|
||||||
|
created, err := service.Create(context.Background(), triangleRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
start := make(chan struct{})
|
||||||
|
errorsChannel := make(chan error, 2)
|
||||||
|
var wait sync.WaitGroup
|
||||||
|
for index := 0; index < 2; index++ {
|
||||||
|
wait.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wait.Done()
|
||||||
|
<-start
|
||||||
|
request := triangleRequest()
|
||||||
|
request.ExpectedVersion = created.Version
|
||||||
|
_, updateErr := service.Update(context.Background(), created.ID, request)
|
||||||
|
errorsChannel <- updateErr
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
wait.Wait()
|
||||||
|
close(errorsChannel)
|
||||||
|
successes, conflicts := 0, 0
|
||||||
|
for updateErr := range errorsChannel {
|
||||||
|
switch {
|
||||||
|
case updateErr == nil:
|
||||||
|
successes++
|
||||||
|
case errors.Is(updateErr, ErrConflict):
|
||||||
|
conflicts++
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected concurrent update error: %v", updateErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if successes != 1 || conflicts != 1 {
|
||||||
|
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
|
||||||
|
}
|
||||||
|
versions, err := service.Versions(context.Background(), created.ID)
|
||||||
|
if err != nil || len(versions) != 2 {
|
||||||
|
t.Fatalf("versions=%d err=%v", len(versions), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = errors.New("区域配置请求不符合要求")
|
||||||
|
ErrNotFound = errors.New("区域配置不存在")
|
||||||
|
ErrConflict = errors.New("区域配置已被其他用户更新,请刷新后重试")
|
||||||
|
ErrProfileMissing = errors.New("绑定的视频 Profile 不可用,请先完成视频接入")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct{ db *gorm.DB }
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB) *Service { return &Service{db: db} }
|
||||||
|
|
||||||
|
type routeSnapshot struct {
|
||||||
|
RouteID string `gorm:"column:route_id"`
|
||||||
|
DeviceID string `gorm:"column:device_id"`
|
||||||
|
DeviceName string `gorm:"column:device_name"`
|
||||||
|
DeviceLocation string `gorm:"column:device_location"`
|
||||||
|
ProfileToken string `gorm:"column:profile_token"`
|
||||||
|
ProfileName string `gorm:"column:profile_name"`
|
||||||
|
Width int `gorm:"column:width"`
|
||||||
|
Height int `gorm:"column:height"`
|
||||||
|
Encoding string `gorm:"column:encoding"`
|
||||||
|
Verification string `gorm:"column:verification_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, request PageRequest) ([]Response, int64, error) {
|
||||||
|
request.PageIndex, request.PageSize = normalizePage(request.PageIndex, request.PageSize)
|
||||||
|
if utf8.RuneCountInString(request.Keyword) > 128 || (request.Kind != "" && request.Kind != KindPolygon && request.Kind != KindDirectionLine) {
|
||||||
|
return nil, 0, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
query := s.db.WithContext(ctx).Model(&Definition{})
|
||||||
|
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
|
||||||
|
pattern := "%" + escapeLike(keyword) + "%"
|
||||||
|
query = query.Where("LOWER(name) LIKE LOWER(?) ESCAPE '\\'", pattern)
|
||||||
|
}
|
||||||
|
if request.Kind != "" {
|
||||||
|
query = query.Where("kind = ?", request.Kind)
|
||||||
|
}
|
||||||
|
switch request.RecalibrationState {
|
||||||
|
case "", "all":
|
||||||
|
case "needed":
|
||||||
|
query = query.Where("needs_recalibration = ?", true)
|
||||||
|
case "ready":
|
||||||
|
query = query.Where("needs_recalibration = ?", false)
|
||||||
|
default:
|
||||||
|
return nil, 0, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var definitions []Definition
|
||||||
|
if err := query.Order("updated_at DESC, name ASC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Find(&definitions).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]Response, 0, len(definitions))
|
||||||
|
for i := range definitions {
|
||||||
|
item, err := s.response(ctx, &definitions[i])
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, request UpsertRequest) (Response, error) {
|
||||||
|
if request.ExpectedVersion != 0 {
|
||||||
|
return Response{}, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
name, err := validateRequest(request)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
definitionID := uuid.NewString()
|
||||||
|
versionID := uuid.NewString()
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
route, routeErr := loadRoute(tx, request.RouteID)
|
||||||
|
if routeErr != nil {
|
||||||
|
return routeErr
|
||||||
|
}
|
||||||
|
geometry, marshalErr := json.Marshal(request.Points)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
definition := Definition{ID: definitionID, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, CurrentVersion: 1, CurrentVersionID: versionID, Enabled: request.Enabled, CreatedBy: request.UpdateBy, UpdatedBy: request.UpdateBy, CreatedAt: now, UpdatedAt: now}
|
||||||
|
version := Version{ID: versionID, DefinitionID: definitionID, Version: 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, CreatedBy: request.UpdateBy, CreatedAt: now}
|
||||||
|
if err = tx.Create(&definition).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Create(&version).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
return s.Get(ctx, definitionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Update(ctx context.Context, id string, request UpsertRequest) (Response, error) {
|
||||||
|
if strings.TrimSpace(id) == "" || request.ExpectedVersion < 1 {
|
||||||
|
return Response{}, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
name, err := validateRequest(request)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
var current Definition
|
||||||
|
if readErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(¤t, "id = ?", id).Error; readErr != nil {
|
||||||
|
if errors.Is(readErr, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return readErr
|
||||||
|
}
|
||||||
|
if current.CurrentVersion != request.ExpectedVersion {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
route, routeErr := loadRoute(tx, request.RouteID)
|
||||||
|
if routeErr != nil {
|
||||||
|
return routeErr
|
||||||
|
}
|
||||||
|
geometry, marshalErr := json.Marshal(request.Points)
|
||||||
|
if marshalErr != nil {
|
||||||
|
return ErrInvalidGeometry
|
||||||
|
}
|
||||||
|
versionID := uuid.NewString()
|
||||||
|
version := Version{ID: versionID, DefinitionID: current.ID, Version: current.CurrentVersion + 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, SupersedesID: current.CurrentVersionID, CreatedBy: request.UpdateBy, CreatedAt: now}
|
||||||
|
if createErr := tx.Create(&version).Error; createErr != nil {
|
||||||
|
return createErr
|
||||||
|
}
|
||||||
|
updates := map[string]any{"name": name, "kind": request.Kind, "device_id": route.DeviceID, "profile_token": route.ProfileToken, "profile_width": route.Width, "profile_height": route.Height, "profile_encoding": route.Encoding, "current_version": version.Version, "current_version_id": versionID, "enabled": request.Enabled, "needs_recalibration": false, "updated_by": request.UpdateBy, "updated_at": now}
|
||||||
|
result := tx.Model(&Definition{}).Where("id = ? AND current_version = ?", current.ID, request.ExpectedVersion).Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected != 1 {
|
||||||
|
return ErrConflict
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
return s.Get(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, id string) (Response, error) {
|
||||||
|
var definition Definition
|
||||||
|
if err := s.db.WithContext(ctx).First(&definition, "id = ?", id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return Response{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
return s.response(ctx, &definition)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Versions(ctx context.Context, id string) ([]VersionResponse, error) {
|
||||||
|
var count int64
|
||||||
|
if err := s.db.WithContext(ctx).Model(&Definition{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
var versions []Version
|
||||||
|
if err := s.db.WithContext(ctx).Where("definition_id = ?", id).Order("version DESC").Find(&versions).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]VersionResponse, 0, len(versions))
|
||||||
|
for _, version := range versions {
|
||||||
|
points, err := decodePoints(version.GeometryJSON)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decode area version %d: %w", version.Version, err)
|
||||||
|
}
|
||||||
|
out = append(out, VersionResponse{ID: version.ID, Version: version.Version, Name: version.Name, Kind: version.Kind, DeviceID: version.DeviceID, ProfileToken: version.ProfileToken, ProfileWidth: version.ProfileWidth, ProfileHeight: version.ProfileHeight, ProfileEncoding: version.ProfileEncoding, Points: points, Direction: version.Direction, Enabled: version.Enabled, SupersedesID: version.SupersedesID, CreatedBy: version.CreatedBy, CreatedAt: version.CreatedAt})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) response(ctx context.Context, definition *Definition) (Response, error) {
|
||||||
|
var version Version
|
||||||
|
if err := s.db.WithContext(ctx).First(&version, "id = ?", definition.CurrentVersionID).Error; err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
points, err := decodePoints(version.GeometryJSON)
|
||||||
|
if err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
var route routeSnapshot
|
||||||
|
query := s.db.WithContext(ctx).Table("sense_devices AS d").
|
||||||
|
Select("COALESCE(r.id, '') AS route_id, d.id AS device_id, d.name AS device_name, d.location AS device_location, COALESCE(p.token, '') AS profile_token, COALESCE(p.name, '') AS profile_name, COALESCE(p.width, 0) AS width, COALESCE(p.height, 0) AS height, COALESCE(p.encoding, '') AS encoding, COALESCE(p.verification_status, '') AS verification_status").
|
||||||
|
Joins("LEFT JOIN sense_admission_profiles AS p ON p.device_id = d.id AND p.token = ?", definition.ProfileToken).
|
||||||
|
Joins("LEFT JOIN sense_media_routes AS r ON r.device_id = d.id AND r.profile_token = ?", definition.ProfileToken).
|
||||||
|
Where("d.id = ?", definition.DeviceID).Limit(1).Scan(&route)
|
||||||
|
if query.Error != nil {
|
||||||
|
return Response{}, query.Error
|
||||||
|
}
|
||||||
|
recalibration := definition.NeedsRecalibration || route.ProfileToken == "" || route.Verification != "ready" || route.Width != definition.ProfileWidth || route.Height != definition.ProfileHeight || !strings.EqualFold(route.Encoding, definition.ProfileEncoding)
|
||||||
|
if recalibration && !definition.NeedsRecalibration {
|
||||||
|
if err = s.db.WithContext(ctx).Model(&Definition{}).Where("id = ? AND needs_recalibration = ?", definition.ID, false).Update("needs_recalibration", true).Error; err != nil {
|
||||||
|
return Response{}, err
|
||||||
|
}
|
||||||
|
definition.NeedsRecalibration = true
|
||||||
|
}
|
||||||
|
return Response{ID: definition.ID, Name: definition.Name, Kind: definition.Kind, DeviceID: definition.DeviceID, DeviceName: route.DeviceName, DeviceLocation: route.DeviceLocation, ProfileToken: definition.ProfileToken, ProfileName: route.ProfileName, ProfileWidth: definition.ProfileWidth, ProfileHeight: definition.ProfileHeight, ProfileEncoding: definition.ProfileEncoding, RouteID: route.RouteID, Version: definition.CurrentVersion, Points: points, Direction: version.Direction, Enabled: definition.Enabled, NeedsRecalibration: recalibration, UpdatedBy: definition.UpdatedBy, UpdatedAt: definition.UpdatedAt}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MarkProfilesReplaced(tx *gorm.DB, deviceID string, profiles []ProfileSnapshot) error {
|
||||||
|
if tx == nil || !tx.Migrator().HasTable(&Definition{}) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
available := make(map[string]ProfileSnapshot, len(profiles))
|
||||||
|
for _, profile := range profiles {
|
||||||
|
available[profile.Token] = profile
|
||||||
|
}
|
||||||
|
var definitions []Definition
|
||||||
|
if err := tx.Where("device_id = ? AND needs_recalibration = ?", deviceID, false).Find(&definitions).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, definition := range definitions {
|
||||||
|
profile, ok := available[definition.ProfileToken]
|
||||||
|
if !ok || profile.Width != definition.ProfileWidth || profile.Height != definition.ProfileHeight || !strings.EqualFold(profile.Encoding, definition.ProfileEncoding) {
|
||||||
|
if err := tx.Model(&Definition{}).Where("id = ?", definition.ID).Update("needs_recalibration", true).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRoute(db *gorm.DB, routeID string) (routeSnapshot, error) {
|
||||||
|
if strings.TrimSpace(routeID) == "" {
|
||||||
|
return routeSnapshot{}, ErrProfileMissing
|
||||||
|
}
|
||||||
|
var route routeSnapshot
|
||||||
|
err := db.Table("sense_media_routes AS r").
|
||||||
|
Select("r.id AS route_id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.width, p.height, p.encoding, p.verification_status").
|
||||||
|
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
|
||||||
|
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
|
||||||
|
Where("r.id = ? AND p.verification_status = ?", routeID, "ready").Limit(1).Scan(&route).Error
|
||||||
|
if err != nil {
|
||||||
|
return routeSnapshot{}, err
|
||||||
|
}
|
||||||
|
if route.RouteID == "" || route.Width < 1 || route.Height < 1 {
|
||||||
|
return routeSnapshot{}, ErrProfileMissing
|
||||||
|
}
|
||||||
|
return route, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRequest(request UpsertRequest) (string, error) {
|
||||||
|
name := strings.TrimSpace(request.Name)
|
||||||
|
if name == "" || utf8.RuneCountInString(name) > 128 || request.UpdateBy < 1 || strings.TrimSpace(request.RouteID) == "" {
|
||||||
|
return "", ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if err := validateGeometry(request.Kind, request.Direction, request.Points); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodePoints(value string) ([]Point, error) {
|
||||||
|
var points []Point
|
||||||
|
if err := json.Unmarshal([]byte(value), &points); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return points, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizePage(index, size int) (int, int) {
|
||||||
|
if index < 1 {
|
||||||
|
index = 1
|
||||||
|
}
|
||||||
|
if size < 1 {
|
||||||
|
size = 10
|
||||||
|
}
|
||||||
|
if size > 50 {
|
||||||
|
size = 50
|
||||||
|
}
|
||||||
|
return index, size
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeLike(value string) string {
|
||||||
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||||
|
value = strings.ReplaceAll(value, `%`, `\%`)
|
||||||
|
return strings.ReplaceAll(value, `_`, `\_`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package area
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func areaTestDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
|
||||||
|
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
|
||||||
|
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
|
||||||
|
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||||
|
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
|
||||||
|
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if err = db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func triangleRequest() UpsertRequest {
|
||||||
|
return UpsertRequest{Name: "操场危险区域", Kind: KindPolygon, RouteID: "device-1:main", Points: []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, Enabled: true, UpdateBy: 7}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionsAreAppendOnlyAndUseOptimisticConcurrency(t *testing.T) {
|
||||||
|
service := NewService(areaTestDB(t))
|
||||||
|
created, err := service.Create(context.Background(), triangleRequest())
|
||||||
|
if err != nil || created.Version != 1 || created.ProfileWidth != 1920 {
|
||||||
|
t.Fatalf("created=%+v err=%v", created, err)
|
||||||
|
}
|
||||||
|
request := triangleRequest()
|
||||||
|
request.Name = "操场危险区域(校准)"
|
||||||
|
request.ExpectedVersion = 1
|
||||||
|
updated, err := service.Update(context.Background(), created.ID, request)
|
||||||
|
if err != nil || updated.Version != 2 {
|
||||||
|
t.Fatalf("updated=%+v err=%v", updated, err)
|
||||||
|
}
|
||||||
|
if _, err = service.Update(context.Background(), created.ID, request); !errors.Is(err, ErrConflict) {
|
||||||
|
t.Fatalf("stale update err=%v", err)
|
||||||
|
}
|
||||||
|
versions, err := service.Versions(context.Background(), created.ID)
|
||||||
|
if err != nil || len(versions) != 2 || versions[0].Version != 2 || versions[1].Version != 1 || versions[0].SupersedesID != versions[1].ID {
|
||||||
|
t.Fatalf("versions=%+v err=%v", versions, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfileReplacementMarksOnlyChangedBindings(t *testing.T) {
|
||||||
|
db := areaTestDB(t)
|
||||||
|
service := NewService(db)
|
||||||
|
created, err := service.Create(context.Background(), triangleRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1920, Height: 1080, Encoding: "H264"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item, _ := service.Get(context.Background(), created.ID)
|
||||||
|
if item.NeedsRecalibration {
|
||||||
|
t.Fatal("unchanged profile was marked for recalibration")
|
||||||
|
}
|
||||||
|
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1280, Height: 720, Encoding: "H264"}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item, _ = service.Get(context.Background(), created.ID)
|
||||||
|
if !item.NeedsRecalibration {
|
||||||
|
t.Fatal("resolution change did not mark recalibration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsPaginatedAndDoesNotExposeMediaSecrets(t *testing.T) {
|
||||||
|
service := NewService(areaTestDB(t))
|
||||||
|
if _, err := service.Create(context.Background(), triangleRequest()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, total, err := service.List(context.Background(), PageRequest{Keyword: "操场", PageIndex: 1, PageSize: 10})
|
||||||
|
if err != nil || total != 1 || len(items) != 1 || items[0].RouteID != "device-1:main" {
|
||||||
|
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrCredentialNotConfigured = errors.New("摄像头凭据尚未配置")
|
||||||
|
|
||||||
|
type Value struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read is an internal adapter port. HTTP handlers must never expose Value.
|
||||||
|
func Read(db *gorm.DB, deviceID, purpose string) (Value, error) {
|
||||||
|
var row DeviceCredential
|
||||||
|
if err := db.First(&row, "device_id = ? AND purpose = ?", deviceID, purpose).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return Value{}, ErrCredentialNotConfigured
|
||||||
|
}
|
||||||
|
return Value{}, fmt.Errorf("read device credential: %w", err)
|
||||||
|
}
|
||||||
|
vault, err := NewVaultFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return Value{}, err
|
||||||
|
}
|
||||||
|
username, password, err := vault.Decrypt(deviceID, purpose, row.Ciphertext)
|
||||||
|
if err != nil {
|
||||||
|
return Value{}, err
|
||||||
|
}
|
||||||
|
return Value{Username: username, Password: password}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
EnvironmentKey = "SENSE_CREDENTIAL_KEY"
|
||||||
|
PurposeONVIF = "onvif"
|
||||||
|
PurposeRTSP = "rtsp"
|
||||||
|
keyVersion = "v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrKeyUnavailable = errors.New("摄像头凭据加密密钥不可用")
|
||||||
|
|
||||||
|
// DeviceCredential is deliberately stored separately from the device ledger.
|
||||||
|
// No HTTP response type embeds this model.
|
||||||
|
type DeviceCredential struct {
|
||||||
|
DeviceID string `gorm:"size:36;primaryKey" json:"-"`
|
||||||
|
Purpose string `gorm:"size:16;primaryKey" json:"-"`
|
||||||
|
Ciphertext []byte `gorm:"type:bytea;not null" json:"-"`
|
||||||
|
KeyVersion string `gorm:"size:16;not null" json:"-"`
|
||||||
|
CreatedAt time.Time `json:"-"`
|
||||||
|
UpdatedAt time.Time `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (DeviceCredential) TableName() string { return "sense_device_credentials" }
|
||||||
|
|
||||||
|
type Vault struct{ key []byte }
|
||||||
|
|
||||||
|
func NewVaultFromEnvironment() (*Vault, error) {
|
||||||
|
encoded := strings.TrimSpace(os.Getenv(EnvironmentKey))
|
||||||
|
if encoded == "" {
|
||||||
|
return nil, ErrKeyUnavailable
|
||||||
|
}
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil || len(decoded) != 32 {
|
||||||
|
return nil, ErrKeyUnavailable
|
||||||
|
}
|
||||||
|
return NewVault(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewVault(key []byte) (*Vault, error) {
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, ErrKeyUnavailable
|
||||||
|
}
|
||||||
|
copyOfKey := append([]byte(nil), key...)
|
||||||
|
return &Vault{key: copyOfKey}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Vault) Encrypt(deviceID, purpose, username, password string) ([]byte, error) {
|
||||||
|
if err := validateScope(deviceID, purpose); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plaintext, err := json.Marshal(struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}{Username: username, Password: password})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode credential: %w", err)
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(v.key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("initialize credential cipher: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("initialize credential gcm: %w", err)
|
||||||
|
}
|
||||||
|
nonce := make([]byte, gcm.NonceSize())
|
||||||
|
if _, err = io.ReadFull(rand.Reader, nonce); err != nil {
|
||||||
|
return nil, fmt.Errorf("generate credential nonce: %w", err)
|
||||||
|
}
|
||||||
|
return gcm.Seal(nonce, nonce, plaintext, associatedData(deviceID, purpose)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt is an internal adapter boundary. It is intentionally not exposed by
|
||||||
|
// any Sense HTTP handler and must only be used for the matching device/purpose.
|
||||||
|
func (v *Vault) Decrypt(deviceID, purpose string, ciphertext []byte) (string, string, error) {
|
||||||
|
if err := validateScope(deviceID, purpose); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(v.key)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("initialize credential cipher: %w", err)
|
||||||
|
}
|
||||||
|
gcm, err := cipher.NewGCM(block)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("initialize credential gcm: %w", err)
|
||||||
|
}
|
||||||
|
if len(ciphertext) < gcm.NonceSize() {
|
||||||
|
return "", "", errors.New("invalid credential ciphertext")
|
||||||
|
}
|
||||||
|
plaintext, err := gcm.Open(nil, ciphertext[:gcm.NonceSize()], ciphertext[gcm.NonceSize():], associatedData(deviceID, purpose))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", errors.New("credential ciphertext does not match device purpose")
|
||||||
|
}
|
||||||
|
var value struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err = json.Unmarshal(plaintext, &value); err != nil {
|
||||||
|
return "", "", errors.New("invalid credential payload")
|
||||||
|
}
|
||||||
|
return value.Username, value.Password, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func Version() string { return keyVersion }
|
||||||
|
|
||||||
|
func validateScope(deviceID, purpose string) error {
|
||||||
|
if strings.TrimSpace(deviceID) == "" {
|
||||||
|
return errors.New("device id is required")
|
||||||
|
}
|
||||||
|
if purpose != PurposeONVIF && purpose != PurposeRTSP {
|
||||||
|
return errors.New("unsupported credential purpose")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func associatedData(deviceID, purpose string) []byte {
|
||||||
|
return []byte("sense-device-credential-v1\x00" + deviceID + "\x00" + purpose)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package credential
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVaultSeparatesDeviceAndPurpose(t *testing.T) {
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
vault, err := NewVault(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ciphertext, err := vault.Encrypt("device-a", PurposeONVIF, "synthetic-user", "synthetic-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
username, password, err := vault.Decrypt("device-a", PurposeONVIF, ciphertext)
|
||||||
|
if err != nil || username != "synthetic-user" || password != "synthetic-password" {
|
||||||
|
t.Fatalf("credential round trip failed: username=%q err=%v", username, err)
|
||||||
|
}
|
||||||
|
if _, _, err = vault.Decrypt("device-a", PurposeRTSP, ciphertext); err == nil {
|
||||||
|
t.Fatal("credential ciphertext was reusable for another purpose")
|
||||||
|
}
|
||||||
|
if _, _, err = vault.Decrypt("device-b", PurposeONVIF, ciphertext); err == nil {
|
||||||
|
t.Fatal("credential ciphertext was reusable for another device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVaultRejectsInvalidKeyLength(t *testing.T) {
|
||||||
|
if _, err := NewVault(make([]byte, 16)); err == nil {
|
||||||
|
t.Fatal("short credential key accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package apis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
deviceService "git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Device struct{ api.Api }
|
||||||
|
|
||||||
|
func (e Device) GetPage(c *gin.Context) {
|
||||||
|
service := deviceService.Device{}
|
||||||
|
req := dto.PageReq{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().Bind(&req).MakeService(&service.Service).Errors; err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "查询条件格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
list := make([]dto.DeviceResponse, 0)
|
||||||
|
var count int64
|
||||||
|
if err := service.GetPage(&req, &list, &count); err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "设备列表查询失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.PageOK(list, int(count), req.GetPageIndex(), req.GetPageSize(), "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Device) Get(c *gin.Context) {
|
||||||
|
service := deviceService.Device{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var response dto.DeviceResponse
|
||||||
|
if err := service.Get(c.Param("id"), &response); err != nil {
|
||||||
|
e.writeServiceError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(response, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Device) Insert(c *gin.Context) {
|
||||||
|
service := deviceService.Device{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := dto.CreateReq{}
|
||||||
|
if err := bindStrictJSON(c, &req); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.CreateBy = user.GetUserId(c)
|
||||||
|
var response dto.DeviceResponse
|
||||||
|
if err := service.Insert(&req, &response); err != nil {
|
||||||
|
e.writeServiceError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(response, "设备创建成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Device) Update(c *gin.Context) {
|
||||||
|
service := deviceService.Device{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := dto.UpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||||
|
if err := bindStrictJSON(c, &req); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var response dto.DeviceResponse
|
||||||
|
if err := service.Update(&req, &response); err != nil {
|
||||||
|
e.writeServiceError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(response, "设备更新成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Device) Disable(c *gin.Context) {
|
||||||
|
service := deviceService.Device{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := dto.DisableReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||||
|
if err := bindStrictJSON(c, &req); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var response dto.DeviceResponse
|
||||||
|
if err := service.Disable(&req, &response); err != nil {
|
||||||
|
e.writeServiceError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(response, "设备已停用")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Device) UpdateCredentials(c *gin.Context) {
|
||||||
|
service := deviceService.Device{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&service.Service).Errors; err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := dto.CredentialUpdateReq{ID: c.Param("id"), UpdateBy: user.GetUserId(c)}
|
||||||
|
if err := bindStrictJSON(c, &req); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var response dto.DeviceResponse
|
||||||
|
if err := service.UpdateCredentials(&req, &response); err != nil {
|
||||||
|
e.writeServiceError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(response, "凭据已安全更新,已请求重新验证")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e Device) writeServiceError(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, deviceService.ErrInvalidDevice):
|
||||||
|
e.Error(http.StatusBadRequest, err, "设备信息不符合要求")
|
||||||
|
case errors.Is(err, deviceService.ErrDeviceNotFound):
|
||||||
|
e.Error(http.StatusNotFound, err, err.Error())
|
||||||
|
case errors.Is(err, deviceService.ErrVersionConflict):
|
||||||
|
e.Error(http.StatusConflict, err, err.Error())
|
||||||
|
case errors.Is(err, credential.ErrKeyUnavailable):
|
||||||
|
e.Error(http.StatusServiceUnavailable, err, "摄像头凭据安全配置不可用")
|
||||||
|
default:
|
||||||
|
e.Error(http.StatusInternalServerError, err, "设备操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func bindStrictJSON(c *gin.Context, target any) error {
|
||||||
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||||
|
return errors.New("content type must be application/json")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
if err == nil {
|
||||||
|
return errors.New("request body must contain one JSON object")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package apis
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBindStrictJSONRejectsUnknownDeviceField(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
ctx, _ := gin.CreateTestContext(recorder)
|
||||||
|
ctx.Request = httptest.NewRequest("POST", "/api/v1/devices", strings.NewReader(`{"name":"测试设备","location":"东门","modality":"video","capabilities":["video"],"password":"must-not-be-accepted"}`))
|
||||||
|
ctx.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
var req dto.CreateReq
|
||||||
|
if err := bindStrictJSON(ctx, &req); err == nil {
|
||||||
|
t.Fatal("unknown credential-like field was accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModalityVideo = "video"
|
||||||
|
StatusPending = "pending"
|
||||||
|
StatusActive = "active"
|
||||||
|
StatusDisabled = "disabled"
|
||||||
|
AdapterReady = "ready"
|
||||||
|
AdapterFailed = "verification_failed"
|
||||||
|
AdapterNotReady = "adapter_not_ready"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
ID string `gorm:"size:36;primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"size:128;not null" json:"name"`
|
||||||
|
Location string `gorm:"size:255;not null;default:''" json:"location"`
|
||||||
|
Modality string `gorm:"size:32;not null;index" json:"modality"`
|
||||||
|
CapabilitiesJSON string `gorm:"column:capabilities;type:jsonb;not null;default:'[]'" json:"-"`
|
||||||
|
Status string `gorm:"size:32;not null;index" json:"status"`
|
||||||
|
AdapterStatus string `gorm:"size:32;not null" json:"adapterStatus"`
|
||||||
|
RTSPSameAsONVIF bool `gorm:"not null;default:true" json:"rtspCredentialSameAsOnvif"`
|
||||||
|
CredentialUpdatedAt *time.Time `json:"credentialUpdatedAt,omitempty"`
|
||||||
|
RetryRequestedAt *time.Time `json:"retryRequestedAt,omitempty"`
|
||||||
|
Version int64 `gorm:"not null;default:1" json:"version"`
|
||||||
|
common.ControlBy
|
||||||
|
common.ModelTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Device) TableName() string { return "sense_devices" }
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDeviceNotFound = errors.New("设备不存在")
|
||||||
|
ErrVersionConflict = errors.New("设备已被其他用户更新,请刷新后重试")
|
||||||
|
ErrInvalidDevice = errors.New("设备信息不符合要求")
|
||||||
|
)
|
||||||
|
|
||||||
|
var supportedValues = map[string]struct{}{
|
||||||
|
"video": {}, "radar": {}, "contact": {}, "button": {}, "wearable": {}, "other": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
type Device struct {
|
||||||
|
coreService.Service
|
||||||
|
VaultFactory func() (*credential.Vault, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type credentialPresence struct {
|
||||||
|
DeviceID string
|
||||||
|
Purpose string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) GetPage(req *dto.PageReq, list *[]dto.DeviceResponse, count *int64) error {
|
||||||
|
query := e.Orm.Model(&models.Device{})
|
||||||
|
if keyword := strings.TrimSpace(req.Keyword); keyword != "" {
|
||||||
|
pattern := "%" + strings.ToLower(keyword) + "%"
|
||||||
|
query = query.Where("LOWER(name) LIKE ? OR LOWER(location) LIKE ?", pattern, pattern)
|
||||||
|
}
|
||||||
|
if req.Modality != "" {
|
||||||
|
query = query.Where("modality = ?", req.Modality)
|
||||||
|
}
|
||||||
|
if req.Status != "" {
|
||||||
|
query = query.Where("status = ?", req.Status)
|
||||||
|
}
|
||||||
|
if err := query.Count(count).Error; err != nil {
|
||||||
|
return fmt.Errorf("count devices: %w", err)
|
||||||
|
}
|
||||||
|
pageSize := req.GetPageSize()
|
||||||
|
if pageSize > 100 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
req.PageSize = pageSize
|
||||||
|
var devices []models.Device
|
||||||
|
if err := query.Order("created_at DESC").Limit(pageSize).Offset((req.GetPageIndex() - 1) * pageSize).Find(&devices).Error; err != nil {
|
||||||
|
return fmt.Errorf("list devices: %w", err)
|
||||||
|
}
|
||||||
|
responses, err := e.responses(devices)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*list = responses
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) Get(id string, response *dto.DeviceResponse) error {
|
||||||
|
var model models.Device
|
||||||
|
if err := e.Orm.First(&model, "id = ?", id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrDeviceNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("get device: %w", err)
|
||||||
|
}
|
||||||
|
responses, err := e.responses([]models.Device{model})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*response = responses[0]
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) Insert(req *dto.CreateReq, response *dto.DeviceResponse) error {
|
||||||
|
name, location, modality, capabilities, err := normalizeDevice(req.Name, req.Location, req.Modality, req.Capabilities)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(capabilities)
|
||||||
|
adapterStatus := models.AdapterNotReady
|
||||||
|
if modality == models.ModalityVideo {
|
||||||
|
adapterStatus = models.AdapterReady
|
||||||
|
}
|
||||||
|
model := models.Device{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: name,
|
||||||
|
Location: location,
|
||||||
|
Modality: modality,
|
||||||
|
CapabilitiesJSON: string(encoded),
|
||||||
|
Status: models.StatusPending,
|
||||||
|
AdapterStatus: adapterStatus,
|
||||||
|
RTSPSameAsONVIF: true,
|
||||||
|
Version: 1,
|
||||||
|
}
|
||||||
|
model.CreateBy = req.CreateBy
|
||||||
|
model.UpdateBy = req.CreateBy
|
||||||
|
if err = e.Orm.Create(&model).Error; err != nil {
|
||||||
|
return fmt.Errorf("create device: %w", err)
|
||||||
|
}
|
||||||
|
return e.Get(model.ID, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) Update(req *dto.UpdateReq, response *dto.DeviceResponse) error {
|
||||||
|
name := strings.TrimSpace(req.Name)
|
||||||
|
if name == "" || len([]rune(name)) > 128 || len([]rune(req.Location)) > 255 || req.Version < 1 {
|
||||||
|
return ErrInvalidDevice
|
||||||
|
}
|
||||||
|
capabilities, err := normalizeCapabilities(req.Capabilities)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(capabilities)
|
||||||
|
updates := map[string]any{
|
||||||
|
"name": name, "location": strings.TrimSpace(req.Location), "capabilities": string(encoded),
|
||||||
|
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
|
||||||
|
}
|
||||||
|
result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("update device: %w", result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return e.notFoundOrConflict(req.ID)
|
||||||
|
}
|
||||||
|
return e.Get(req.ID, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) Disable(req *dto.DisableReq, response *dto.DeviceResponse) error {
|
||||||
|
if req.Version < 1 {
|
||||||
|
return ErrInvalidDevice
|
||||||
|
}
|
||||||
|
result := e.Orm.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
|
||||||
|
"status": models.StatusDisabled, "version": req.Version + 1,
|
||||||
|
"update_by": req.UpdateBy, "updated_at": time.Now().UTC(),
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return fmt.Errorf("disable device: %w", result.Error)
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return e.notFoundOrConflict(req.ID)
|
||||||
|
}
|
||||||
|
return e.Get(req.ID, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) UpdateCredentials(req *dto.CredentialUpdateReq, response *dto.DeviceResponse) error {
|
||||||
|
if req.Version < 1 || strings.TrimSpace(req.ONVIFUsername) == "" || req.ONVIFPassword == "" {
|
||||||
|
return ErrInvalidDevice
|
||||||
|
}
|
||||||
|
var target models.Device
|
||||||
|
if err := e.Orm.Select("id", "modality").First(&target, "id = ?", req.ID).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return ErrDeviceNotFound
|
||||||
|
}
|
||||||
|
return fmt.Errorf("read credential target: %w", err)
|
||||||
|
}
|
||||||
|
if target.Modality != models.ModalityVideo {
|
||||||
|
return ErrInvalidDevice
|
||||||
|
}
|
||||||
|
if len(req.ONVIFUsername) > 255 || len(req.ONVIFPassword) > 1024 {
|
||||||
|
return ErrInvalidDevice
|
||||||
|
}
|
||||||
|
rtspUsername, rtspPassword := req.RTSPUsername, req.RTSPPassword
|
||||||
|
if req.RTSPSameAsONVIF {
|
||||||
|
rtspUsername, rtspPassword = req.ONVIFUsername, req.ONVIFPassword
|
||||||
|
} else if strings.TrimSpace(rtspUsername) == "" || rtspPassword == "" || len(rtspUsername) > 255 || len(rtspPassword) > 1024 {
|
||||||
|
return ErrInvalidDevice
|
||||||
|
}
|
||||||
|
factory := e.VaultFactory
|
||||||
|
if factory == nil {
|
||||||
|
factory = credential.NewVaultFromEnvironment
|
||||||
|
}
|
||||||
|
vault, err := factory()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
onvifCiphertext, err := vault.Encrypt(req.ID, credential.PurposeONVIF, strings.TrimSpace(req.ONVIFUsername), req.ONVIFPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rtspCiphertext, err := vault.Encrypt(req.ID, credential.PurposeRTSP, strings.TrimSpace(rtspUsername), rtspPassword)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
err = e.Orm.Transaction(func(tx *gorm.DB) error {
|
||||||
|
result := tx.Model(&models.Device{}).Where("id = ? AND version = ?", req.ID, req.Version).Updates(map[string]any{
|
||||||
|
"rtsp_same_as_onvif": req.RTSPSameAsONVIF,
|
||||||
|
"credential_updated_at": now, "retry_requested_at": now,
|
||||||
|
"version": req.Version + 1, "update_by": req.UpdateBy, "updated_at": now,
|
||||||
|
})
|
||||||
|
if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
return e.notFoundOrConflictWith(tx, req.ID)
|
||||||
|
}
|
||||||
|
rows := []credential.DeviceCredential{
|
||||||
|
{DeviceID: req.ID, Purpose: credential.PurposeONVIF, Ciphertext: onvifCiphertext, KeyVersion: credential.Version()},
|
||||||
|
{DeviceID: req.ID, Purpose: credential.PurposeRTSP, Ciphertext: rtspCiphertext, KeyVersion: credential.Version()},
|
||||||
|
}
|
||||||
|
return tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "device_id"}, {Name: "purpose"}},
|
||||||
|
DoUpdates: clause.AssignmentColumns([]string{"ciphertext", "key_version", "updated_at"}),
|
||||||
|
}).Create(&rows).Error
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrDeviceNotFound) || errors.Is(err, ErrVersionConflict) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fmt.Errorf("update device credential: %w", err)
|
||||||
|
}
|
||||||
|
return e.Get(req.ID, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) responses(devices []models.Device) ([]dto.DeviceResponse, error) {
|
||||||
|
result := make([]dto.DeviceResponse, 0, len(devices))
|
||||||
|
if len(devices) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(devices))
|
||||||
|
for _, device := range devices {
|
||||||
|
ids = append(ids, device.ID)
|
||||||
|
}
|
||||||
|
var presence []credentialPresence
|
||||||
|
if err := e.Orm.Model(&credential.DeviceCredential{}).Select("device_id", "purpose").Where("device_id IN ?", ids).Find(&presence).Error; err != nil {
|
||||||
|
return nil, fmt.Errorf("read credential status: %w", err)
|
||||||
|
}
|
||||||
|
configured := make(map[string]map[string]bool, len(devices))
|
||||||
|
for _, row := range presence {
|
||||||
|
if configured[row.DeviceID] == nil {
|
||||||
|
configured[row.DeviceID] = map[string]bool{}
|
||||||
|
}
|
||||||
|
configured[row.DeviceID][row.Purpose] = true
|
||||||
|
}
|
||||||
|
for _, device := range devices {
|
||||||
|
var capabilities []string
|
||||||
|
if err := json.Unmarshal([]byte(device.CapabilitiesJSON), &capabilities); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode device capabilities: %w", err)
|
||||||
|
}
|
||||||
|
result = append(result, dto.DeviceResponse{
|
||||||
|
ID: device.ID, Name: device.Name, Location: device.Location, Modality: device.Modality,
|
||||||
|
Capabilities: capabilities, Status: device.Status, AdapterStatus: device.AdapterStatus,
|
||||||
|
ONVIFCredentialConfigured: configured[device.ID][credential.PurposeONVIF],
|
||||||
|
RTSPCredentialConfigured: configured[device.ID][credential.PurposeRTSP],
|
||||||
|
RTSPCredentialSameAsONVIF: device.RTSPSameAsONVIF,
|
||||||
|
RetryPending: device.RetryRequestedAt != nil, Version: device.Version,
|
||||||
|
CreatedAt: device.CreatedAt, UpdatedAt: device.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Device) notFoundOrConflict(id string) error { return e.notFoundOrConflictWith(e.Orm, id) }
|
||||||
|
|
||||||
|
func (e *Device) notFoundOrConflictWith(db *gorm.DB, id string) error {
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&models.Device{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return ErrDeviceNotFound
|
||||||
|
}
|
||||||
|
return ErrVersionConflict
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeDevice(name, location, modality string, capabilities []string) (string, string, string, []string, error) {
|
||||||
|
name, location, modality = strings.TrimSpace(name), strings.TrimSpace(location), strings.TrimSpace(modality)
|
||||||
|
if modality == "" {
|
||||||
|
modality = models.ModalityVideo
|
||||||
|
}
|
||||||
|
if name == "" || len([]rune(name)) > 128 || len([]rune(location)) > 255 {
|
||||||
|
return "", "", "", nil, ErrInvalidDevice
|
||||||
|
}
|
||||||
|
if _, ok := supportedValues[modality]; !ok {
|
||||||
|
return "", "", "", nil, ErrInvalidDevice
|
||||||
|
}
|
||||||
|
if len(capabilities) == 0 {
|
||||||
|
capabilities = []string{modality}
|
||||||
|
}
|
||||||
|
normalized, err := normalizeCapabilities(capabilities)
|
||||||
|
return name, location, modality, normalized, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeCapabilities(values []string) ([]string, error) {
|
||||||
|
if len(values) == 0 || len(values) > 16 {
|
||||||
|
return nil, ErrInvalidDevice
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
result := make([]string, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if _, ok := supportedValues[value]; !ok {
|
||||||
|
return nil, ErrInvalidDevice
|
||||||
|
}
|
||||||
|
if !seen[value] {
|
||||||
|
seen[value] = true
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/models"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/device/service/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDeviceService(t *testing.T) (*Device, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&models.Device{}, &credential.DeviceCredential{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
vault, err := credential.NewVault(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := &Device{Service: coreService.Service{Orm: db}, VaultFactory: func() (*credential.Vault, error) { return vault, nil }}
|
||||||
|
return service, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeviceLifecycleUsesAllowlistedFieldsAndOptimisticVersion(t *testing.T) {
|
||||||
|
service, _ := testDeviceService(t)
|
||||||
|
var created dto.DeviceResponse
|
||||||
|
err := service.Insert(&dto.CreateReq{Name: "东门摄像机", Location: "教学楼一楼东门", Modality: "video", Capabilities: []string{"video"}, CreateBy: 7}, &created)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if created.Name != "东门摄像机" || created.Location != "教学楼一楼东门" || created.Version != 1 || created.AdapterStatus != models.AdapterReady {
|
||||||
|
t.Fatalf("unexpected device: %#v", created)
|
||||||
|
}
|
||||||
|
var updated dto.DeviceResponse
|
||||||
|
err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "东门主摄像机", Location: "教学楼一楼东门", Capabilities: []string{"video"}, Version: 1, UpdateBy: 8}, &updated)
|
||||||
|
if err != nil || updated.Version != 2 || updated.Name != "东门主摄像机" {
|
||||||
|
t.Fatalf("update failed: device=%#v err=%v", updated, err)
|
||||||
|
}
|
||||||
|
err = service.Update(&dto.UpdateReq{ID: created.ID, Name: "过期写入", Capabilities: []string{"video"}, Version: 1}, &updated)
|
||||||
|
if err != ErrVersionConflict {
|
||||||
|
t.Fatalf("stale update error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCredentialIsPurposeSeparatedNeverReturnedAndRequestsRetry(t *testing.T) {
|
||||||
|
service, db := testDeviceService(t)
|
||||||
|
var created dto.DeviceResponse
|
||||||
|
if err := service.Insert(&dto.CreateReq{Name: "测试摄像机", Modality: "video", Capabilities: []string{"video"}}, &created); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var updated dto.DeviceResponse
|
||||||
|
if err := service.UpdateCredentials(&dto.CredentialUpdateReq{
|
||||||
|
ID: created.ID, ONVIFUsername: "synthetic-onvif-user", ONVIFPassword: "synthetic-onvif-password",
|
||||||
|
RTSPSameAsONVIF: false, RTSPUsername: "synthetic-rtsp-user", RTSPPassword: "synthetic-rtsp-password", Version: created.Version,
|
||||||
|
}, &updated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !updated.ONVIFCredentialConfigured || !updated.RTSPCredentialConfigured || !updated.RetryPending || updated.Version != 2 {
|
||||||
|
t.Fatalf("credential status not reflected: %#v", updated)
|
||||||
|
}
|
||||||
|
encoded, err := json.Marshal(updated)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, secretPart := range []string{"synthetic-onvif", "synthetic-rtsp", "ciphertext", "password", "username"} {
|
||||||
|
if strings.Contains(strings.ToLower(string(encoded)), secretPart) {
|
||||||
|
t.Fatalf("response leaked credential material: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var stored []credential.DeviceCredential
|
||||||
|
if err = db.Order("purpose").Find(&stored).Error; err != nil || len(stored) != 2 {
|
||||||
|
t.Fatalf("stored credentials=%d err=%v", len(stored), err)
|
||||||
|
}
|
||||||
|
if string(stored[0].Ciphertext) == string(stored[1].Ciphertext) {
|
||||||
|
t.Fatal("ONVIF and RTSP credentials were not purpose-separated")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsupportedModalityIsExplicitlyNotReadyAndPaginationExceedsDefaultQuota(t *testing.T) {
|
||||||
|
service, _ := testDeviceService(t)
|
||||||
|
var radar dto.DeviceResponse
|
||||||
|
for index := 0; index < 20; index++ {
|
||||||
|
var response dto.DeviceResponse
|
||||||
|
if err := service.Insert(&dto.CreateReq{Name: "雷达" + strings.Repeat("号", index+1), Modality: "radar", Capabilities: []string{"radar"}}, &response); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if response.AdapterStatus != models.AdapterNotReady {
|
||||||
|
t.Fatalf("adapter status=%s", response.AdapterStatus)
|
||||||
|
}
|
||||||
|
radar = response
|
||||||
|
}
|
||||||
|
var credentialResponse dto.DeviceResponse
|
||||||
|
if err := service.UpdateCredentials(&dto.CredentialUpdateReq{ID: radar.ID, ONVIFUsername: "synthetic", ONVIFPassword: "synthetic", RTSPSameAsONVIF: true, Version: radar.Version}, &credentialResponse); err != ErrInvalidDevice {
|
||||||
|
t.Fatalf("non-video credential update error=%v", err)
|
||||||
|
}
|
||||||
|
request := &dto.PageReq{}
|
||||||
|
request.PageIndex, request.PageSize = 1, 20
|
||||||
|
var list []dto.DeviceResponse
|
||||||
|
var count int64
|
||||||
|
if err := service.GetPage(request, &list, &count); err != nil || count != 20 || len(list) != 20 {
|
||||||
|
t.Fatalf("count=%d items=%d err=%v", count, len(list), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
commonDto "git.ilapage.cn/ila/yovision/Sense/server/common/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PageReq struct {
|
||||||
|
commonDto.Pagination `search:"-"`
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
Modality string `form:"modality"`
|
||||||
|
Status string `form:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Modality string `json:"modality"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
CreateBy int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateReq struct {
|
||||||
|
ID string `json:"-"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdateBy int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DisableReq struct {
|
||||||
|
ID string `json:"-"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdateBy int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CredentialUpdateReq struct {
|
||||||
|
ID string `json:"-"`
|
||||||
|
ONVIFUsername string `json:"onvifUsername"`
|
||||||
|
ONVIFPassword string `json:"onvifPassword"`
|
||||||
|
RTSPSameAsONVIF bool `json:"rtspSameAsOnvif"`
|
||||||
|
RTSPUsername string `json:"rtspUsername"`
|
||||||
|
RTSPPassword string `json:"rtspPassword"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdateBy int `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Location string `json:"location"`
|
||||||
|
Modality string `json:"modality"`
|
||||||
|
Capabilities []string `json:"capabilities"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
AdapterStatus string `json:"adapterStatus"`
|
||||||
|
ONVIFCredentialConfigured bool `json:"onvifCredentialConfigured"`
|
||||||
|
RTSPCredentialConfigured bool `json:"rtspCredentialConfigured"`
|
||||||
|
RTSPCredentialSameAsONVIF bool `json:"rtspCredentialSameAsOnvif"`
|
||||||
|
RetryPending bool `json:"retryPending"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||||
|
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type API struct{ api.Api }
|
||||||
|
|
||||||
|
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||||
|
base := coreService.Service{}
|
||||||
|
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewService(base.Orm, nil), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) List(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||||
|
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), PageIndex: pageIndex, PageSize: pageSize})
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Create(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request struct {
|
||||||
|
RouteID string `json:"routeId"`
|
||||||
|
}
|
||||||
|
if err = decodeJSON(c, &request); err != nil {
|
||||||
|
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := service.Create(c.Request.Context(), user.GetUserId(c), request.RouteID, c.Request)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(session, "播放会话已创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Get(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := service.Get(c.Request.Context(), user.GetUserId(c), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(session, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
var playerTemplate = template.Must(template.New("liveview-player").Parse(`<!doctype html>
|
||||||
|
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#101419;overflow:hidden}</style></head>
|
||||||
|
<body><iframe src="{{.}}" title="Sense 实时视频" allow="autoplay; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`))
|
||||||
|
|
||||||
|
func (e *API) Player(c *gin.Context) {
|
||||||
|
target, err := NewService(nil, nil).PlayerTarget(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.String(http.StatusGone, "播放会话已过期,请重新连接")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parsed := template.URL(target)
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.Header("Cache-Control", "no-store")
|
||||||
|
c.Header("Referrer-Policy", "no-referrer")
|
||||||
|
c.Header("X-Content-Type-Options", "nosniff")
|
||||||
|
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||||
|
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||||
|
if err = playerTemplate.Execute(c.Writer, parsed); err != nil {
|
||||||
|
c.AbortWithStatus(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) writeError(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrInvalidRequest):
|
||||||
|
e.Error(http.StatusBadRequest, err, err.Error())
|
||||||
|
case errors.Is(err, ErrRouteNotFound):
|
||||||
|
e.Error(http.StatusNotFound, err, err.Error())
|
||||||
|
case errors.Is(err, ErrSessionExpired):
|
||||||
|
e.Error(http.StatusGone, err, "播放会话已过期,请重新连接")
|
||||||
|
case strings.Contains(err.Error(), "WEBRTC_PUBLIC_BASE"), strings.Contains(err.Error(), "浏览器可访问"):
|
||||||
|
e.Error(http.StatusServiceUnavailable, err, "浏览器播放地址未正确配置")
|
||||||
|
default:
|
||||||
|
e.Error(http.StatusInternalServerError, err, "实时监看操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeJSON(c *gin.Context, target any) error {
|
||||||
|
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||||
|
return errors.New("content type must be application/json")
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||||
|
if err == nil {
|
||||||
|
return errors.New("request body must contain one JSON object")
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlayerUsesShortLivedCapabilityAndRestrictiveHeaders(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
store := NewStore()
|
||||||
|
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||||
|
store.now = func() time.Time { return now }
|
||||||
|
previous := defaultStore
|
||||||
|
defaultStore = store
|
||||||
|
t.Cleanup(func() { defaultStore = previous })
|
||||||
|
store.put(sessionRecord{ID: "view_test", OwnerID: 7, RouteID: "route-1", TargetURL: "http://127.0.0.1:8889/sense_test?controls=true", ExpiresAt: now.Add(sessionTTL)})
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
api := &API{}
|
||||||
|
router.GET("/api/v1/liveview/player/:id", api.Player)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||||
|
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("X-Frame-Options") != "SAMEORIGIN" {
|
||||||
|
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
|
||||||
|
}
|
||||||
|
if !strings.Contains(recorder.Body.String(), "http://127.0.0.1:8889/sense_test?controls=true") || !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") {
|
||||||
|
t.Fatalf("unexpected wrapper response: %s", recorder.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
now = now.Add(sessionTTL)
|
||||||
|
expired := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(expired, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||||
|
if expired.Code != http.StatusGone {
|
||||||
|
t.Fatalf("expired capability status=%d", expired.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidRequest = errors.New("实时监看请求不符合要求")
|
||||||
|
ErrRouteNotFound = errors.New("可监看的视频不存在")
|
||||||
|
ErrSessionExpired = errors.New("播放会话已过期")
|
||||||
|
validMediaPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||||
|
validDNSHost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionTTL = 2 * time.Minute
|
||||||
|
|
||||||
|
type PageRequest struct {
|
||||||
|
Keyword string
|
||||||
|
PageIndex int
|
||||||
|
PageSize int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RouteResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
DeviceLocation string `json:"deviceLocation"`
|
||||||
|
ProfileToken string `json:"profileToken"`
|
||||||
|
ProfileName string `json:"profileName"`
|
||||||
|
ProfileKind string `json:"profileKind"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
Actual string `json:"actual"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Readers int `json:"readers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
RouteID string `json:"routeId"`
|
||||||
|
PlayerURL string `json:"playerUrl"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
ExpiresAt time.Time `json:"expiresAt"`
|
||||||
|
DeviceName string `json:"deviceName"`
|
||||||
|
ProfileName string `json:"profileName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type routeRecord struct {
|
||||||
|
RouteResponse
|
||||||
|
Path string `gorm:"column:path"`
|
||||||
|
Desired string `gorm:"column:desired"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionRecord struct {
|
||||||
|
ID string
|
||||||
|
OwnerID int
|
||||||
|
RouteID string
|
||||||
|
TargetURL string
|
||||||
|
ExpiresAt time.Time
|
||||||
|
DeviceName string
|
||||||
|
ProfileName string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
sessions map[string]sessionRecord
|
||||||
|
now func() time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStore() *Store {
|
||||||
|
return &Store{sessions: make(map[string]sessionRecord), now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultStore = NewStore()
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
store *Store
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, store *Store) *Service {
|
||||||
|
if store == nil {
|
||||||
|
store = defaultStore
|
||||||
|
}
|
||||||
|
return &Service{db: db, store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, request PageRequest) ([]RouteResponse, int64, error) {
|
||||||
|
if request.PageIndex < 1 {
|
||||||
|
request.PageIndex = 1
|
||||||
|
}
|
||||||
|
if request.PageSize < 1 {
|
||||||
|
request.PageSize = 10
|
||||||
|
}
|
||||||
|
if request.PageSize > 50 {
|
||||||
|
request.PageSize = 50
|
||||||
|
}
|
||||||
|
query := s.routeQuery(ctx).Where("r.desired = ?", "running")
|
||||||
|
keyword := strings.TrimSpace(request.Keyword)
|
||||||
|
if len([]rune(keyword)) > 128 {
|
||||||
|
return nil, 0, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
if keyword != "" {
|
||||||
|
pattern := "%" + escapeLike(keyword) + "%"
|
||||||
|
query = query.Where("LOWER(d.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(d.location) LIKE LOWER(?) ESCAPE '\\' OR LOWER(p.name) LIKE LOWER(?) ESCAPE '\\'", pattern, pattern, pattern)
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
var records []routeRecord
|
||||||
|
if err := query.Order("d.name ASC, p.kind ASC, p.width DESC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Scan(&records).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
items := make([]RouteResponse, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
items = append(items, record.RouteResponse)
|
||||||
|
}
|
||||||
|
return items, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, ownerID int, routeID string, request *http.Request) (SessionResponse, error) {
|
||||||
|
if ownerID < 1 || strings.TrimSpace(routeID) == "" {
|
||||||
|
return SessionResponse{}, ErrInvalidRequest
|
||||||
|
}
|
||||||
|
record, err := s.route(ctx, routeID)
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
if record.Desired != "running" || !validMediaPath.MatchString(record.Path) {
|
||||||
|
return SessionResponse{}, ErrRouteNotFound
|
||||||
|
}
|
||||||
|
base, err := playbackBase(request)
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
target := *base
|
||||||
|
target.Path = strings.TrimRight(target.Path, "/") + "/" + record.Path
|
||||||
|
target.RawQuery = "controls=true&muted=true&autoplay=true"
|
||||||
|
id, err := randomID()
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
now := s.store.now().UTC()
|
||||||
|
session := sessionRecord{ID: id, OwnerID: ownerID, RouteID: record.ID, TargetURL: target.String(), ExpiresAt: now.Add(sessionTTL), DeviceName: record.DeviceName, ProfileName: record.ProfileName}
|
||||||
|
s.store.put(session)
|
||||||
|
return responseFrom(session, record), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Get(ctx context.Context, ownerID int, id string) (SessionResponse, error) {
|
||||||
|
session, err := s.store.get(id, ownerID, true)
|
||||||
|
if err != nil {
|
||||||
|
return SessionResponse{}, err
|
||||||
|
}
|
||||||
|
record, err := s.route(ctx, session.RouteID)
|
||||||
|
if err != nil || record.Desired != "running" {
|
||||||
|
return SessionResponse{}, ErrRouteNotFound
|
||||||
|
}
|
||||||
|
session.ExpiresAt = s.store.now().UTC().Add(sessionTTL)
|
||||||
|
s.store.put(session)
|
||||||
|
return responseFrom(session, record), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PlayerTarget(id string) (string, error) {
|
||||||
|
session, err := s.store.get(id, 0, false)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return session.TargetURL, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) route(ctx context.Context, id string) (routeRecord, error) {
|
||||||
|
var record routeRecord
|
||||||
|
if err := s.routeQuery(ctx).Where("r.id = ?", id).Limit(1).Scan(&record).Error; err != nil {
|
||||||
|
return routeRecord{}, err
|
||||||
|
}
|
||||||
|
if record.ID == "" {
|
||||||
|
return routeRecord{}, ErrRouteNotFound
|
||||||
|
}
|
||||||
|
return record, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) routeQuery(ctx context.Context) *gorm.DB {
|
||||||
|
return s.db.WithContext(ctx).Table("sense_media_routes AS r").
|
||||||
|
Select("r.id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.kind AS profile_kind, p.width, p.height, p.encoding, r.actual, r.detail, r.readers, r.path, r.desired").
|
||||||
|
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
|
||||||
|
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
|
||||||
|
Where("d.status <> ? AND p.verification_status = ?", "disabled", "ready")
|
||||||
|
}
|
||||||
|
|
||||||
|
func responseFrom(session sessionRecord, route routeRecord) SessionResponse {
|
||||||
|
return SessionResponse{ID: session.ID, RouteID: session.RouteID, PlayerURL: "/api/v1/liveview/player/" + session.ID, Status: playbackStatus(route.Actual), Detail: route.Detail, ExpiresAt: session.ExpiresAt, DeviceName: session.DeviceName, ProfileName: session.ProfileName}
|
||||||
|
}
|
||||||
|
|
||||||
|
func playbackStatus(actual string) string {
|
||||||
|
switch actual {
|
||||||
|
case "ready", "waiting", "stopped":
|
||||||
|
return actual
|
||||||
|
case "credential_unavailable", "profile_unavailable":
|
||||||
|
return "authentication_failed"
|
||||||
|
case "path_missing", "apply_failed", "status_unavailable":
|
||||||
|
return "stream_not_found"
|
||||||
|
case "process_unavailable":
|
||||||
|
return "service_unavailable"
|
||||||
|
default:
|
||||||
|
return "offline"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) put(session sessionRecord) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
now := s.now()
|
||||||
|
for id, item := range s.sessions {
|
||||||
|
if !now.Before(item.ExpiresAt) || item.OwnerID == session.OwnerID {
|
||||||
|
delete(s.sessions, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sessions[session.ID] = session
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) get(id string, ownerID int, checkOwner bool) (sessionRecord, error) {
|
||||||
|
s.mu.RLock()
|
||||||
|
session, ok := s.sessions[id]
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !ok || !s.now().Before(session.ExpiresAt) || (checkOwner && session.OwnerID != ownerID) {
|
||||||
|
return sessionRecord{}, ErrSessionExpired
|
||||||
|
}
|
||||||
|
return session, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func playbackBase(request *http.Request) (*url.URL, error) {
|
||||||
|
configured := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE"))
|
||||||
|
if configured != "" {
|
||||||
|
return validatePlaybackBase(configured)
|
||||||
|
}
|
||||||
|
if request == nil || request.Host == "" {
|
||||||
|
return nil, errors.New("无法确定浏览器可访问的视频服务地址")
|
||||||
|
}
|
||||||
|
host := request.Host
|
||||||
|
if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil {
|
||||||
|
host = parsedHost
|
||||||
|
}
|
||||||
|
host = strings.Trim(host, "[]")
|
||||||
|
if host == "" || (net.ParseIP(host) == nil && host != "localhost" && !validDNSHost.MatchString(host)) {
|
||||||
|
return nil, errors.New("无效的请求主机")
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if request.TLS != nil {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
return validatePlaybackBase(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, "8889")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePlaybackBase(value string) (*url.URL, error) {
|
||||||
|
parsed, err := url.Parse(strings.TrimRight(value, "/"))
|
||||||
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||||
|
return nil, errors.New("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE 配置不安全")
|
||||||
|
}
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func randomID() (string, error) {
|
||||||
|
value := make([]byte, 24)
|
||||||
|
if _, err := rand.Read(value); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return "view_" + hex.EncodeToString(value), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func escapeLike(value string) string {
|
||||||
|
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||||
|
value = strings.ReplaceAll(value, `%`, `\%`)
|
||||||
|
return strings.ReplaceAll(value, `_`, `\_`)
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package liveview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
statements := []string{
|
||||||
|
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
|
||||||
|
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, kind text, width integer, height integer, encoding text, verification_status text, stream_uri text)`,
|
||||||
|
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text, path text, desired text, actual text, detail text, readers integer)`,
|
||||||
|
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||||
|
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流','main',1920,1080,'H264','ready','rtsp://camera.example/live')`,
|
||||||
|
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main','sense_012345','running','waiting','等待播放器连接并按需拉流',0)`,
|
||||||
|
}
|
||||||
|
for _, statement := range statements {
|
||||||
|
if err = db.Exec(statement).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListIsPaginatedSearchableAndSecretFree(t *testing.T) {
|
||||||
|
service := NewService(testDB(t), NewStore())
|
||||||
|
items, total, err := service.List(context.Background(), PageRequest{Keyword: "东门", PageIndex: 1, PageSize: 10})
|
||||||
|
if err != nil || total != 1 || len(items) != 1 || items[0].ProfileKind != "main" {
|
||||||
|
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
|
||||||
|
}
|
||||||
|
encoded, _ := json.Marshal(items)
|
||||||
|
if strings.Contains(string(encoded), "rtsp://") || strings.Contains(string(encoded), "sense_012345") {
|
||||||
|
t.Fatalf("response leaked private media data: %s", encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSessionIsOwnerBoundShortLivedAndUsesBrowserHost(t *testing.T) {
|
||||||
|
store := NewStore()
|
||||||
|
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||||
|
store.now = func() time.Time { return now }
|
||||||
|
service := NewService(testDB(t), store)
|
||||||
|
request := httptest.NewRequest("POST", "http://192.0.2.20:18080/api/v1/liveview/sessions", nil)
|
||||||
|
session, err := service.Create(context.Background(), 7, "device-1:main", request)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(session.PlayerURL, "/api/v1/liveview/player/view_") || strings.Contains(session.PlayerURL, "sense_012345") {
|
||||||
|
t.Fatalf("unsafe player URL: %s", session.PlayerURL)
|
||||||
|
}
|
||||||
|
if _, err = service.Get(context.Background(), 8, session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||||
|
t.Fatalf("another owner accessed session: %v", err)
|
||||||
|
}
|
||||||
|
target, err := service.PlayerTarget(session.ID)
|
||||||
|
if err != nil || target != "http://192.0.2.20:8889/sense_012345?controls=true&muted=true&autoplay=true" {
|
||||||
|
t.Fatalf("target=%q err=%v", target, err)
|
||||||
|
}
|
||||||
|
now = now.Add(sessionTTL)
|
||||||
|
if _, err = service.PlayerTarget(session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||||
|
t.Fatalf("expired session remained valid: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackBaseRejectsCredentials(t *testing.T) {
|
||||||
|
if _, err := validatePlaybackBase("http://invalid-user@127.0.0.1:8889"); err == nil {
|
||||||
|
t.Fatal("expected credential-bearing base URL to be rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackStatusKeepsActionableFailuresDistinct(t *testing.T) {
|
||||||
|
for input, want := range map[string]string{"waiting": "waiting", "credential_unavailable": "authentication_failed", "path_missing": "stream_not_found", "process_unavailable": "service_unavailable", "unexpected": "offline"} {
|
||||||
|
if got := playbackStatus(input); got != want {
|
||||||
|
t.Fatalf("playbackStatus(%q)=%q want %q", input, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func errorsIs(err, target error) bool { return err == target }
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||||
|
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 serviceFor(base.Orm), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) List(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
items, err := service.List(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(gin.H{"list": items, "total": len(items)}, "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Process(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(service.ProcessState(), "查询成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) ReconcileAll(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = service.EnsureAllVerified(c.Request.Context()); err == nil {
|
||||||
|
err = service.ReconcileDue(c.Request.Context())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(nil, "对账完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Reconcile(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := service.Reconcile(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil && item.ID == "" {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(item, "对账完成")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) Stop(c *gin.Context) {
|
||||||
|
service, err := e.service(c)
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := service.StopRoute(c.Request.Context(), c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
e.writeError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.OK(item, "媒体路径已停止")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *API) writeError(err error) {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
e.Error(http.StatusNotFound, err, err.Error())
|
||||||
|
case errors.Is(err, ErrRuntimeUnavailable), errors.Is(err, ErrUnsafeControlAPI):
|
||||||
|
e.Error(http.StatusServiceUnavailable, err, "视频服务运行配置不可用")
|
||||||
|
default:
|
||||||
|
e.Error(http.StatusInternalServerError, err, "视频服务操作失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUnsafeControlAPI = errors.New("MediaMTX Control API 必须使用本机回环地址")
|
||||||
|
|
||||||
|
type RuntimeConfig struct {
|
||||||
|
Binary string
|
||||||
|
ConfigPath string
|
||||||
|
APIBase string
|
||||||
|
PollInterval time.Duration
|
||||||
|
StartTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFromEnvironment() (RuntimeConfig, error) {
|
||||||
|
c := RuntimeConfig{
|
||||||
|
Binary: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_BINARY")), ConfigPath: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_CONFIG")),
|
||||||
|
APIBase: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_API")), PollInterval: 5 * time.Second, StartTimeout: 8 * time.Second,
|
||||||
|
}
|
||||||
|
if c.APIBase == "" {
|
||||||
|
c.APIBase = "http://127.0.0.1:9997"
|
||||||
|
}
|
||||||
|
if err := validateControlAPI(c.APIBase); err != nil {
|
||||||
|
return RuntimeConfig{}, err
|
||||||
|
}
|
||||||
|
if c.Binary != "" && c.ConfigPath == "" {
|
||||||
|
return RuntimeConfig{}, errors.New("SENSE_MEDIAMTX_CONFIG is required when SENSE_MEDIAMTX_BINARY is configured")
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateControlAPI(value string) error {
|
||||||
|
u, err := url.Parse(value)
|
||||||
|
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||||
|
return ErrUnsafeControlAPI
|
||||||
|
}
|
||||||
|
host := u.Hostname()
|
||||||
|
ip := net.ParseIP(host)
|
||||||
|
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||||
|
return ErrUnsafeControlAPI
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func RenderBaseConfig(w io.Writer, apiBase string) error {
|
||||||
|
if err := validateControlAPI(apiBase); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u, _ := url.Parse(apiBase)
|
||||||
|
_, err := fmt.Fprintf(w, "logLevel: info\napi: true\napiAddress: %s\nmetrics: false\npaths: {}\n", u.Host)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func EnsureBaseConfig(path, apiBase string) error {
|
||||||
|
if path == "" {
|
||||||
|
return errors.New("MediaMTX config path is empty")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
return nil
|
||||||
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
temporary := path + ".tmp"
|
||||||
|
f, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
writeErr := RenderBaseConfig(f, apiBase)
|
||||||
|
closeErr := f.Close()
|
||||||
|
if writeErr != nil || closeErr != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
return errors.Join(writeErr, closeErr)
|
||||||
|
}
|
||||||
|
if err = os.Rename(temporary, path); err != nil {
|
||||||
|
_ = os.Remove(temporary)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||||
|
|
||||||
|
type Source struct {
|
||||||
|
Path, URI, Username, Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PathStatus struct {
|
||||||
|
Exists, Ready bool
|
||||||
|
Readers int
|
||||||
|
}
|
||||||
|
|
||||||
|
type Controller interface {
|
||||||
|
Health(context.Context) error
|
||||||
|
Apply(context.Context, Source) error
|
||||||
|
Delete(context.Context, string) error
|
||||||
|
Status(context.Context, string) (PathStatus, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HTTPController struct {
|
||||||
|
base string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPController(base string) (*HTTPController, error) {
|
||||||
|
if err := validateControlAPI(base); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||||
|
transport.Proxy = nil
|
||||||
|
return &HTTPController{base: strings.TrimRight(base, "/"), client: &http.Client{
|
||||||
|
Timeout: 5 * time.Second, Transport: transport,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return errors.New("MediaMTX Control API redirect rejected")
|
||||||
|
},
|
||||||
|
}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Health(ctx context.Context) error {
|
||||||
|
return c.request(ctx, http.MethodGet, "/v3/config/global/get", nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Apply(ctx context.Context, source Source) error {
|
||||||
|
if !validPath.MatchString(source.Path) {
|
||||||
|
return errors.New("invalid MediaMTX path")
|
||||||
|
}
|
||||||
|
u, err := url.Parse(source.URI)
|
||||||
|
if err != nil || u.User != nil || u.Host == "" || (u.Scheme != "rtsp" && u.Scheme != "rtsps") {
|
||||||
|
return errors.New("invalid credential-free RTSP source")
|
||||||
|
}
|
||||||
|
payload := map[string]any{"source": u.String(), "sourceOnDemand": true, "rtspTransport": "tcp"}
|
||||||
|
if source.Username != "" {
|
||||||
|
// MediaMTX v1.19.3 has no sourceUser/sourcePass fields. Credentials
|
||||||
|
// are assembled only for this loopback request and are never stored,
|
||||||
|
// logged, or returned by a Sense endpoint.
|
||||||
|
u.User = url.UserPassword(source.Username, source.Password)
|
||||||
|
payload["source"] = u.String()
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
configured, err := c.configured(ctx, source.Path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
method, action := http.MethodPost, "add"
|
||||||
|
if configured {
|
||||||
|
method, action = http.MethodPatch, "patch"
|
||||||
|
}
|
||||||
|
return c.request(ctx, method, "/v3/config/paths/"+action+"/"+url.PathEscape(source.Path), bytes.NewReader(data), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) configured(ctx context.Context, path string) (bool, error) {
|
||||||
|
status := 0
|
||||||
|
err := c.request(ctx, http.MethodGet, "/v3/config/paths/get/"+url.PathEscape(path), nil, &status)
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return err == nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Delete(ctx context.Context, path string) error {
|
||||||
|
if !validPath.MatchString(path) {
|
||||||
|
return errors.New("invalid MediaMTX path")
|
||||||
|
}
|
||||||
|
status := 0
|
||||||
|
err := c.request(ctx, http.MethodDelete, "/v3/config/paths/delete/"+url.PathEscape(path), nil, &status)
|
||||||
|
if status == http.StatusNotFound {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, error) {
|
||||||
|
statusCode := 0
|
||||||
|
var raw struct {
|
||||||
|
Ready bool `json:"ready"`
|
||||||
|
Readers []any `json:"readers"`
|
||||||
|
}
|
||||||
|
err := c.requestJSON(ctx, http.MethodGet, "/v3/paths/get/"+url.PathEscape(path), &statusCode, &raw)
|
||||||
|
if statusCode == http.StatusNotFound {
|
||||||
|
return PathStatus{}, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return PathStatus{}, err
|
||||||
|
}
|
||||||
|
return PathStatus{Exists: true, Ready: raw.Ready, Readers: len(raw.Readers)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) request(ctx context.Context, method, path string, body io.Reader, statusOut *int) error {
|
||||||
|
return c.requestJSON(ctx, method, path, body, statusOut, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPController) requestJSON(ctx context.Context, method, path string, args ...any) error {
|
||||||
|
var body io.Reader
|
||||||
|
var statusOut *int
|
||||||
|
var target any
|
||||||
|
for _, arg := range args {
|
||||||
|
switch value := arg.(type) {
|
||||||
|
case io.Reader:
|
||||||
|
body = value
|
||||||
|
case *int:
|
||||||
|
statusOut = value
|
||||||
|
default:
|
||||||
|
target = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
res, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("MediaMTX Control API unavailable: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if statusOut != nil {
|
||||||
|
*statusOut = res.StatusCode
|
||||||
|
}
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||||
|
return fmt.Errorf("MediaMTX Control API returned %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target)
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestControllerUsesCredentialsOnlyAtLoopbackBoundary(t *testing.T) {
|
||||||
|
var payload map[string]any
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.URL.Path == "/v3/config/paths/get/sense_test":
|
||||||
|
http.NotFound(w, r)
|
||||||
|
case r.URL.Path == "/v3/config/paths/add/sense_test":
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
controller, err := NewHTTPController(server.URL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := Source{Path: "sense_test", URI: "rtsp://192.0.2.1/live", Username: "synthetic-user", Password: "synthetic-pass"}
|
||||||
|
if err = controller.Apply(context.Background(), source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if payload["source"] != "rtsp://synthetic-user:synthetic-pass@192.0.2.1/live" {
|
||||||
|
t.Fatalf("unexpected MediaMTX payload: %#v", payload)
|
||||||
|
}
|
||||||
|
if source.URI != "rtsp://192.0.2.1/live" || strings.Contains(source.URI, "synthetic") {
|
||||||
|
t.Fatalf("caller source was mutated: %#v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestControlAPIAndGeneratedConfigAreLoopbackOnly(t *testing.T) {
|
||||||
|
if _, err := NewHTTPController("http://192.0.2.1:9997"); err != ErrUnsafeControlAPI {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
if err := RenderBaseConfig(&output, "http://127.0.0.1:9997"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(output.String()), "password") || !strings.Contains(output.String(), "paths: {}") {
|
||||||
|
t.Fatalf("unsafe config: %s", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExternalProcessUsesOrphanSafetyGate(t *testing.T) {
|
||||||
|
supervisor := NewSupervisor("", "")
|
||||||
|
supervisor.MarkExternal()
|
||||||
|
if err := supervisor.Stop(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
state := supervisor.State()
|
||||||
|
if !state.External || state.Owned || state.Phase != "running" {
|
||||||
|
t.Fatalf("unexpected state: %#v", state)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type RouteResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
DeviceID string `json:"deviceId"`
|
||||||
|
ProfileToken string `json:"profileToken"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Desired string `json:"desired"`
|
||||||
|
Actual string `json:"actual"`
|
||||||
|
Readers int `json:"readers"`
|
||||||
|
SourceReady bool `json:"sourceReady"`
|
||||||
|
FailureCount int `json:"failureCount"`
|
||||||
|
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
|
||||||
|
LastErrorCode string `json:"lastErrorCode,omitempty"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeResponse(r Route) RouteResponse {
|
||||||
|
return RouteResponse{ID: r.ID, DeviceID: r.DeviceID, ProfileToken: r.ProfileToken, Path: r.Path, Desired: r.Desired, Actual: r.Actual, Readers: r.Readers, SourceReady: r.SourceReady, FailureCount: r.FailureCount, NextRetryAt: r.NextRetryAt, LastErrorCode: r.LastErrorCode, Detail: r.Detail, Version: r.Version, UpdatedAt: r.UpdatedAt}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
const (
|
||||||
|
DesiredRunning = "running"
|
||||||
|
DesiredStopped = "stopped"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Route struct {
|
||||||
|
ID string `gorm:"size:96;primaryKey"`
|
||||||
|
DeviceID string `gorm:"size:36;not null;uniqueIndex:media_device_profile"`
|
||||||
|
ProfileToken string `gorm:"size:255;not null;uniqueIndex:media_device_profile"`
|
||||||
|
Path string `gorm:"size:96;not null;uniqueIndex"`
|
||||||
|
Desired string `gorm:"size:16;not null;index"`
|
||||||
|
Actual string `gorm:"size:32;not null;index"`
|
||||||
|
Readers int `gorm:"not null"`
|
||||||
|
SourceReady bool `gorm:"not null"`
|
||||||
|
FailureCount int `gorm:"not null"`
|
||||||
|
NextRetryAt *time.Time `gorm:"index"`
|
||||||
|
LastErrorCode string `gorm:"size:64;not null"`
|
||||||
|
Detail string `gorm:"size:512;not null"`
|
||||||
|
Version int64 `gorm:"not null"`
|
||||||
|
UpdatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Route) TableName() string { return "sense_media_routes" }
|
||||||
|
|
||||||
|
// admissionProfile intentionally maps only non-secret fields from #66.
|
||||||
|
type admissionProfile struct {
|
||||||
|
DeviceID string `gorm:"column:device_id"`
|
||||||
|
Token string `gorm:"column:token"`
|
||||||
|
StreamURI string `gorm:"column:stream_uri"`
|
||||||
|
VerificationStatus string `gorm:"column:verification_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (admissionProfile) TableName() string { return "sense_admission_profiles" }
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var runtimeState struct {
|
||||||
|
sync.RWMutex
|
||||||
|
service *Service
|
||||||
|
cancel context.CancelFunc
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartRuntime(parent context.Context, db *gorm.DB) error {
|
||||||
|
if db == nil {
|
||||||
|
return errors.New("Sense database is unavailable for MediaMTX runtime")
|
||||||
|
}
|
||||||
|
config, err := ConfigFromEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
controller, err := NewHTTPController(config.APIBase)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
service := NewService(db, controller, NewSupervisor(config.Binary, config.ConfigPath), config)
|
||||||
|
ctx, cancel := context.WithCancel(parent)
|
||||||
|
runtimeState.Lock()
|
||||||
|
if runtimeState.cancel != nil {
|
||||||
|
runtimeState.cancel()
|
||||||
|
}
|
||||||
|
runtimeState.service, runtimeState.cancel = service, cancel
|
||||||
|
runtimeState.Unlock()
|
||||||
|
go service.Run(ctx)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShutdownRuntime(ctx context.Context) error {
|
||||||
|
runtimeState.Lock()
|
||||||
|
service, cancel := runtimeState.service, runtimeState.cancel
|
||||||
|
runtimeState.service, runtimeState.cancel = nil, nil
|
||||||
|
runtimeState.Unlock()
|
||||||
|
if cancel != nil {
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
if service != nil && service.process != nil {
|
||||||
|
return service.process.Stop(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serviceFor(db *gorm.DB) *Service {
|
||||||
|
runtimeState.RLock()
|
||||||
|
service := runtimeState.service
|
||||||
|
runtimeState.RUnlock()
|
||||||
|
if service != nil {
|
||||||
|
return service
|
||||||
|
}
|
||||||
|
return NewService(db, nil, nil, RuntimeConfig{PollInterval: 5 * time.Second})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureDeviceRoutes is an internal post-admission port. It persists only
|
||||||
|
// credential-free route intent and deliberately does not fail device probing.
|
||||||
|
func EnsureDeviceRoutes(ctx context.Context, db *gorm.DB, deviceID string) error {
|
||||||
|
return serviceFor(db).EnsureDevice(ctx, deviceID)
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/reconcile"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrNotFound = errors.New("媒体路由不存在")
|
||||||
|
ErrRuntimeUnavailable = errors.New("视频服务运行配置不可用")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
db *gorm.DB
|
||||||
|
controller Controller
|
||||||
|
process *Supervisor
|
||||||
|
config RuntimeConfig
|
||||||
|
now func() time.Time
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(db *gorm.DB, controller Controller, process *Supervisor, config RuntimeConfig) *Service {
|
||||||
|
return &Service{db: db, controller: controller, process: process, config: config, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureAllVerified(ctx context.Context) error {
|
||||||
|
var ids []string
|
||||||
|
if err := s.db.WithContext(ctx).Model(&admissionProfile{}).Where("verification_status = ?", "ready").Distinct().Pluck("device_id", &ids).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, id := range ids {
|
||||||
|
if err := s.ensureDevice(ctx, id, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) EnsureDevice(ctx context.Context, deviceID string) error {
|
||||||
|
return s.ensureDevice(ctx, deviceID, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureDevice(ctx context.Context, deviceID string, reactivate bool) error {
|
||||||
|
if strings.TrimSpace(deviceID) == "" {
|
||||||
|
return errors.New("device id is required")
|
||||||
|
}
|
||||||
|
var profiles []admissionProfile
|
||||||
|
if err := s.db.WithContext(ctx).Where("device_id = ? AND verification_status = ?", deviceID, "ready").Find(&profiles).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
now := s.now().UTC()
|
||||||
|
for _, profile := range profiles {
|
||||||
|
id := profile.DeviceID + ":" + profile.Token
|
||||||
|
digest := sha256.Sum256([]byte(id))
|
||||||
|
route := Route{ID: id, DeviceID: profile.DeviceID, ProfileToken: profile.Token, Path: fmt.Sprintf("sense_%x", digest[:12]), Desired: DesiredRunning, Actual: "pending", Detail: "等待视频服务对账", Version: 1, UpdatedAt: now}
|
||||||
|
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(&route).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var existing Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&existing, "id = ?", id).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if reactivate && existing.Desired != DesiredRunning {
|
||||||
|
if err := s.db.WithContext(ctx).Model(&existing).Updates(map[string]any{"desired": DesiredRunning, "actual": "pending", "detail": "等待视频服务对账", "next_retry_at": nil, "version": existing.Version + 1, "updated_at": now}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Run(ctx context.Context) {
|
||||||
|
_ = s.EnsureAllVerified(ctx)
|
||||||
|
_ = s.ReconcileDue(ctx)
|
||||||
|
interval := s.config.PollInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
interval = 5 * time.Second
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
_ = s.ReconcileDue(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ReconcileDue(ctx context.Context) error {
|
||||||
|
now := s.now().UTC()
|
||||||
|
var routes []Route
|
||||||
|
if err := s.db.WithContext(ctx).Where("desired = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", DesiredRunning, now).Order("updated_at ASC").Limit(128).Find(&routes).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var result error
|
||||||
|
for _, route := range routes {
|
||||||
|
var err error
|
||||||
|
if route.FailureCount == 0 && (route.Actual == "waiting" || route.Actual == "ready") {
|
||||||
|
_, err = s.Refresh(ctx, route.ID)
|
||||||
|
} else {
|
||||||
|
_, err = s.Reconcile(ctx, route.ID)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
result = errors.Join(result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Refresh(ctx context.Context, id string) (RouteResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var route Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||||
|
return RouteResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
if err := s.ensureControl(ctx); err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||||
|
}
|
||||||
|
status, err := s.controller.Status(ctx, route.Path)
|
||||||
|
if err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||||
|
}
|
||||||
|
if !status.Exists {
|
||||||
|
// A cold MediaMTX start begins with paths: {}; re-apply the route
|
||||||
|
// after releasing the service lock.
|
||||||
|
s.mu.Unlock()
|
||||||
|
response, reconcileErr := s.Reconcile(ctx, id)
|
||||||
|
s.mu.Lock()
|
||||||
|
return response, reconcileErr
|
||||||
|
}
|
||||||
|
if status.Ready {
|
||||||
|
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
|
||||||
|
}
|
||||||
|
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Reconcile(ctx context.Context, id string) (RouteResponse, error) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
var route Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return RouteResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return RouteResponse{}, err
|
||||||
|
}
|
||||||
|
if route.Desired == DesiredStopped {
|
||||||
|
if s.controller != nil {
|
||||||
|
_ = s.controller.Delete(ctx, route.Path)
|
||||||
|
}
|
||||||
|
return s.saveSuccess(ctx, route, "stopped", false, 0, "媒体路径已停止")
|
||||||
|
}
|
||||||
|
if err := s.ensureControl(ctx); err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||||
|
}
|
||||||
|
var profile admissionProfile
|
||||||
|
if err := s.db.WithContext(ctx).Where("device_id = ? AND token = ? AND verification_status = ?", route.DeviceID, route.ProfileToken, "ready").First(&profile).Error; err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "profile_unavailable", "已验证 Profile 不可用", err)
|
||||||
|
}
|
||||||
|
value, err := credential.Read(s.db.WithContext(ctx), route.DeviceID, credential.PurposeRTSP)
|
||||||
|
if err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "credential_unavailable", "RTSP 凭据不可用", err)
|
||||||
|
}
|
||||||
|
if err = s.controller.Apply(ctx, Source{Path: route.Path, URI: profile.StreamURI, Username: value.Username, Password: value.Password}); err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "apply_failed", "媒体路径配置失败", err)
|
||||||
|
}
|
||||||
|
status, err := s.controller.Status(ctx, route.Path)
|
||||||
|
if err != nil {
|
||||||
|
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||||
|
}
|
||||||
|
if !status.Exists {
|
||||||
|
return s.saveFailure(ctx, route, "path_missing", "媒体路径不存在", errors.New("MediaMTX path missing after apply"))
|
||||||
|
}
|
||||||
|
if status.Ready {
|
||||||
|
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
|
||||||
|
}
|
||||||
|
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ensureControl(ctx context.Context) error {
|
||||||
|
if s.controller == nil {
|
||||||
|
return ErrRuntimeUnavailable
|
||||||
|
}
|
||||||
|
if err := s.controller.Health(ctx); err == nil {
|
||||||
|
if s.process != nil && !s.process.State().Owned {
|
||||||
|
s.process.MarkExternal()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.process == nil {
|
||||||
|
return ErrRuntimeUnavailable
|
||||||
|
}
|
||||||
|
if err := EnsureBaseConfig(s.config.ConfigPath, s.config.APIBase); err != nil {
|
||||||
|
s.process.MarkFailed("MediaMTX 基础配置不可用")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.process.Start(ctx); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
deadline := s.now().Add(s.config.StartTimeout)
|
||||||
|
for s.now().Before(deadline) {
|
||||||
|
if err := s.controller.Health(ctx); err == nil {
|
||||||
|
s.process.MarkReady()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.process.MarkFailed("MediaMTX Control API 就绪超时")
|
||||||
|
return errors.New("MediaMTX readiness timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveSuccess(ctx context.Context, route Route, actual string, sourceReady bool, readers int, detail string) (RouteResponse, error) {
|
||||||
|
if route.Actual == actual && route.SourceReady == sourceReady && route.Readers == readers && route.FailureCount == 0 && route.NextRetryAt == nil && route.LastErrorCode == "" && route.Detail == detail {
|
||||||
|
return routeResponse(route), nil
|
||||||
|
}
|
||||||
|
now := s.now().UTC()
|
||||||
|
updates := map[string]any{"actual": actual, "source_ready": sourceReady, "readers": readers, "failure_count": 0, "next_retry_at": nil, "last_error_code": "", "detail": detail, "version": route.Version + 1, "updated_at": now}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
|
||||||
|
return RouteResponse{}, err
|
||||||
|
}
|
||||||
|
for key, value := range updates {
|
||||||
|
switch key {
|
||||||
|
case "actual":
|
||||||
|
route.Actual = value.(string)
|
||||||
|
case "source_ready":
|
||||||
|
route.SourceReady = value.(bool)
|
||||||
|
case "readers":
|
||||||
|
route.Readers = value.(int)
|
||||||
|
case "failure_count":
|
||||||
|
route.FailureCount = value.(int)
|
||||||
|
case "last_error_code":
|
||||||
|
route.LastErrorCode = value.(string)
|
||||||
|
case "detail":
|
||||||
|
route.Detail = value.(string)
|
||||||
|
case "version":
|
||||||
|
route.Version = value.(int64)
|
||||||
|
case "updated_at":
|
||||||
|
route.UpdatedAt = value.(time.Time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
route.NextRetryAt = nil
|
||||||
|
return routeResponse(route), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) saveFailure(ctx context.Context, route Route, code, detail string, cause error) (RouteResponse, error) {
|
||||||
|
now := s.now().UTC()
|
||||||
|
failures := route.FailureCount + 1
|
||||||
|
next := now.Add(reconcile.Backoff(failures))
|
||||||
|
updates := map[string]any{"actual": code, "source_ready": false, "readers": 0, "failure_count": failures, "next_retry_at": &next, "last_error_code": code, "detail": detail, "version": route.Version + 1, "updated_at": now}
|
||||||
|
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
|
||||||
|
return RouteResponse{}, errors.Join(cause, err)
|
||||||
|
}
|
||||||
|
route.Actual, route.SourceReady, route.Readers, route.FailureCount, route.NextRetryAt, route.LastErrorCode, route.Detail = code, false, 0, failures, &next, code, detail
|
||||||
|
route.Version, route.UpdatedAt = route.Version+1, now
|
||||||
|
return routeResponse(route), cause
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) StopRoute(ctx context.Context, id string) (RouteResponse, error) {
|
||||||
|
var route Route
|
||||||
|
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||||
|
return RouteResponse{}, ErrNotFound
|
||||||
|
}
|
||||||
|
route.Desired = DesiredStopped
|
||||||
|
if err := s.db.WithContext(ctx).Model(&route).Updates(map[string]any{"desired": DesiredStopped, "next_retry_at": nil, "version": route.Version + 1, "updated_at": s.now().UTC()}).Error; err != nil {
|
||||||
|
return RouteResponse{}, err
|
||||||
|
}
|
||||||
|
return s.Reconcile(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context) ([]RouteResponse, error) {
|
||||||
|
var routes []Route
|
||||||
|
if err := s.db.WithContext(ctx).Order("updated_at DESC").Find(&routes).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items := make([]RouteResponse, 0, len(routes))
|
||||||
|
for _, route := range routes {
|
||||||
|
items = append(items, routeResponse(route))
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ProcessState() ProcessState {
|
||||||
|
if s.process == nil {
|
||||||
|
return ProcessState{Phase: "configuration_failed", Detail: "视频服务运行配置不可用", LastChanged: s.now().UTC()}
|
||||||
|
}
|
||||||
|
return s.process.State()
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeController struct {
|
||||||
|
healthErr, applyErr, statusErr error
|
||||||
|
applies int
|
||||||
|
status PathStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeController) Health(context.Context) error { return f.healthErr }
|
||||||
|
func (f *fakeController) Apply(context.Context, Source) error { f.applies++; return f.applyErr }
|
||||||
|
func (f *fakeController) Delete(context.Context, string) error { return nil }
|
||||||
|
func (f *fakeController) Status(context.Context, string) (PathStatus, error) {
|
||||||
|
return f.status, f.statusErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaTestService(t *testing.T, controller Controller) (*Service, *gorm.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&Route{}, &admissionProfile{}, &credential.DeviceCredential{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||||
|
vault, _ := credential.NewVault(key)
|
||||||
|
ciphertext, err := vault.Encrypt("device-1", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-1", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&admissionProfile{DeviceID: "device-1", Token: "main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
service := NewService(db, controller, nil, RuntimeConfig{})
|
||||||
|
service.now = func() time.Time { return time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) }
|
||||||
|
return service, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnsureAndReconcileAreIdempotent(t *testing.T) {
|
||||||
|
controller := &fakeController{status: PathStatus{Exists: true, Ready: true, Readers: 2}}
|
||||||
|
service, db := mediaTestService(t, controller)
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.Model(&Route{}).Count(&count).Error; err != nil || count != 1 {
|
||||||
|
t.Fatalf("count=%d err=%v", count, err)
|
||||||
|
}
|
||||||
|
item, err := service.Reconcile(context.Background(), "device-1:main")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if item.Actual != "ready" || item.Readers != 2 || controller.applies != 1 {
|
||||||
|
t.Fatalf("item=%#v applies=%d", item, controller.applies)
|
||||||
|
}
|
||||||
|
if err = service.ReconcileDue(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := service.List(context.Background())
|
||||||
|
if err != nil || controller.applies != 1 || items[0].Version != item.Version {
|
||||||
|
t.Fatalf("steady route was rewritten: items=%#v applies=%d err=%v", items, controller.applies, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColdStartDoesNotReactivateStoppedRoute(t *testing.T) {
|
||||||
|
controller := &fakeController{status: PathStatus{Exists: true}}
|
||||||
|
service, _ := mediaTestService(t, controller)
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := service.StopRoute(context.Background(), "device-1:main"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := service.EnsureAllVerified(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := service.List(context.Background())
|
||||||
|
if err != nil || len(items) != 1 || items[0].Desired != DesiredStopped {
|
||||||
|
t.Fatalf("stopped route was reactivated: %#v err=%v", items, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFailurePersistsBackoffWithoutChangingProfile(t *testing.T) {
|
||||||
|
controller := &fakeController{applyErr: errors.New("synthetic apply failure")}
|
||||||
|
service, db := mediaTestService(t, controller)
|
||||||
|
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item, err := service.Reconcile(context.Background(), "device-1:main")
|
||||||
|
if err == nil || item.Actual != "apply_failed" || item.FailureCount != 1 || item.NextRetryAt == nil {
|
||||||
|
t.Fatalf("item=%#v err=%v", item, err)
|
||||||
|
}
|
||||||
|
var profile admissionProfile
|
||||||
|
if err = db.First(&profile, "device_id = ? AND token = ?", "device-1", "main").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if profile.VerificationStatus != "ready" {
|
||||||
|
t.Fatalf("profile changed: %#v", profile)
|
||||||
|
}
|
||||||
|
if value := os.Getenv(credential.EnvironmentKey); value == "" {
|
||||||
|
t.Fatal("test key unexpectedly missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package media
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProcessState struct {
|
||||||
|
Phase string `json:"phase"`
|
||||||
|
PID int `json:"pid,omitempty"`
|
||||||
|
Owned bool `json:"owned"`
|
||||||
|
External bool `json:"external"`
|
||||||
|
Restarts int `json:"restarts"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
LastChanged time.Time `json:"lastChanged"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Process interface {
|
||||||
|
Start(context.Context) error
|
||||||
|
Stop(context.Context) error
|
||||||
|
State() ProcessState
|
||||||
|
}
|
||||||
|
|
||||||
|
type Supervisor struct {
|
||||||
|
binary, config string
|
||||||
|
mu sync.Mutex
|
||||||
|
command *exec.Cmd
|
||||||
|
state ProcessState
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSupervisor(binary, config string) *Supervisor {
|
||||||
|
phase, detail := "stopped", "MediaMTX 尚未启动"
|
||||||
|
if binary == "" {
|
||||||
|
phase, detail = "not_configured", "未配置 MediaMTX 二进制;可连接外部已启动实例"
|
||||||
|
}
|
||||||
|
return &Supervisor{binary: binary, config: config, state: ProcessState{Phase: phase, Detail: detail, LastChanged: time.Now().UTC()}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) MarkExternal() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.state.Owned {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.state.Phase, s.state.External, s.state.Detail, s.state.LastChanged = "running", true, "检测到外部 MediaMTX;孤儿安全闸禁止 Sense 停止该进程", time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) Start(_ context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.state.Owned && s.command != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if s.binary == "" {
|
||||||
|
return errors.New("SENSE_MEDIAMTX_BINARY is not configured")
|
||||||
|
}
|
||||||
|
args := []string{}
|
||||||
|
if s.config != "" {
|
||||||
|
args = append(args, s.config)
|
||||||
|
}
|
||||||
|
cmd := exec.Command(s.binary, args...)
|
||||||
|
if s.config != "" {
|
||||||
|
cmd.Dir = filepath.Dir(s.config)
|
||||||
|
}
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", "MediaMTX 进程启动失败", time.Now().UTC()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.state.Phase == "failed" {
|
||||||
|
s.state.Restarts++
|
||||||
|
}
|
||||||
|
s.command = cmd
|
||||||
|
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "starting", cmd.Process.Pid, true, false
|
||||||
|
s.state.Detail, s.state.LastChanged = "等待 MediaMTX Control API 就绪", time.Now().UTC()
|
||||||
|
go s.wait(cmd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) MarkReady() {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.state.Owned {
|
||||||
|
s.state.Phase, s.state.Detail, s.state.LastChanged = "running", "MediaMTX Control API 已就绪", time.Now().UTC()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) MarkFailed(detail string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", detail, time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) wait(cmd *exec.Cmd) {
|
||||||
|
err := cmd.Wait()
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.command != cmd {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.command = nil
|
||||||
|
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "failed", 0, false, false
|
||||||
|
s.state.Detail = "MediaMTX 进程已退出"
|
||||||
|
if err == nil {
|
||||||
|
s.state.Phase, s.state.Detail = "stopped", "MediaMTX 进程已停止"
|
||||||
|
}
|
||||||
|
s.state.LastChanged = time.Now().UTC()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) Stop(ctx context.Context) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
cmd := s.command
|
||||||
|
owned := s.state.Owned
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !owned || cmd == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := cmd.Process.Signal(os.Interrupt); err != nil {
|
||||||
|
if killErr := cmd.Process.Kill(); killErr != nil {
|
||||||
|
return errors.Join(err, killErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ticker := time.NewTicker(50 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = cmd.Process.Kill()
|
||||||
|
return ctx.Err()
|
||||||
|
case <-ticker.C:
|
||||||
|
s.mu.Lock()
|
||||||
|
finished := s.command != cmd
|
||||||
|
s.mu.Unlock()
|
||||||
|
if finished {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) State() ProcessState {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.state
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAuthentication = errors.New("设备拒绝了当前凭据")
|
||||||
|
ErrRedirect = errors.New("设备返回了不允许的重定向")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credential struct{ Username, Password string }
|
||||||
|
type Profile struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
Encoding string `json:"encoding"`
|
||||||
|
StreamURI string `json:"streamUri"`
|
||||||
|
}
|
||||||
|
type Client interface {
|
||||||
|
Profiles(context.Context, string, Credential) ([]Profile, error)
|
||||||
|
}
|
||||||
|
type HTTPClient struct {
|
||||||
|
client *http.Client
|
||||||
|
policy Policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPClient(timeout time.Duration, policy Policy) *HTTPClient {
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 8 * time.Second
|
||||||
|
}
|
||||||
|
dialer := net.Dialer{Timeout: timeout}
|
||||||
|
transport := &http.Transport{Proxy: nil, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
host, port, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrAddressInvalid
|
||||||
|
}
|
||||||
|
_, ip, err := policy.ValidateURL(ctx, "http://"+net.JoinHostPort(host, port), "http")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
|
||||||
|
}}
|
||||||
|
return &HTTPClient{policy: policy, client: &http.Client{Timeout: timeout, Transport: transport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) Profiles(ctx context.Context, address string, credential Credential) ([]Profile, error) {
|
||||||
|
device, _, err := c.policy.ValidateURL(ctx, address, "http", "https")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
capabilities, err := c.soap(ctx, device.String(), credential, `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetCapabilities xmlns="http://www.onvif.org/ver10/device/wsdl"><Category>All</Category></GetCapabilities></s:Body></s:Envelope>`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
mediaRaw, err := parseElement(capabilities, "Media", "XAddr")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
media, err := c.normalizeService(ctx, device, mediaRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, err := c.soap(ctx, media.String(), credential, `<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetProfiles xmlns="http://www.onvif.org/ver10/media/wsdl"/></s:Body></s:Envelope>`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
profiles, err := parseProfiles(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for index := range profiles {
|
||||||
|
body := fmt.Sprintf(`<?xml version="1.0"?><s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"><s:Body><GetStreamUri xmlns="http://www.onvif.org/ver10/media/wsdl"><StreamSetup><Stream xmlns="http://www.onvif.org/ver10/schema">RTP-Unicast</Stream><Transport xmlns="http://www.onvif.org/ver10/schema"><Protocol>RTSP</Protocol></Transport></StreamSetup><ProfileToken>%s</ProfileToken></GetStreamUri></s:Body></s:Envelope>`, xmlEscape(profiles[index].Token))
|
||||||
|
response, requestErr := c.soap(ctx, media.String(), credential, body)
|
||||||
|
if requestErr != nil {
|
||||||
|
return nil, requestErr
|
||||||
|
}
|
||||||
|
raw, parseErr := parseElement(response, "", "Uri")
|
||||||
|
if parseErr != nil {
|
||||||
|
return nil, parseErr
|
||||||
|
}
|
||||||
|
profiles[index].StreamURI, err = c.normalizeStream(ctx, device, raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return profiles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *HTTPClient) normalizeService(ctx context.Context, device *url.URL, raw string) (*url.URL, error) {
|
||||||
|
advertised, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || advertised.Hostname() == "" || advertised.User != nil || (advertised.Scheme != "http" && advertised.Scheme != "https") {
|
||||||
|
return nil, ErrAddressInvalid
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(advertised.Hostname(), device.Hostname()) {
|
||||||
|
advertised.Scheme = device.Scheme
|
||||||
|
advertised.Host = device.Host
|
||||||
|
}
|
||||||
|
if _, _, err = c.policy.ValidateURL(ctx, advertised.String(), "http", "https"); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return advertised, nil
|
||||||
|
}
|
||||||
|
func (c *HTTPClient) normalizeStream(ctx context.Context, device *url.URL, raw string) (string, error) {
|
||||||
|
stream, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || len(raw) > 2048 || stream.Hostname() == "" || stream.User != nil || stream.Scheme != "rtsp" {
|
||||||
|
return "", ErrAddressInvalid
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(stream.Hostname(), device.Hostname()) {
|
||||||
|
port := stream.Port()
|
||||||
|
stream.Host = device.Hostname()
|
||||||
|
if port != "" {
|
||||||
|
stream.Host = net.JoinHostPort(device.Hostname(), port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, _, err = c.policy.ValidateURL(ctx, stream.String(), "rtsp"); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return stream.String(), nil
|
||||||
|
}
|
||||||
|
func (c *HTTPClient) soap(ctx context.Context, endpoint string, credential Credential, body string) ([]byte, error) {
|
||||||
|
return c.soapAttempt(ctx, endpoint, credential, body, "")
|
||||||
|
}
|
||||||
|
func (c *HTTPClient) soapAttempt(ctx context.Context, endpoint string, credential Credential, body, authorization string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/soap+xml; charset=utf-8")
|
||||||
|
if authorization != "" {
|
||||||
|
req.Header.Set("Authorization", authorization)
|
||||||
|
} else if credential.Username != "" {
|
||||||
|
req.SetBasicAuth(credential.Username, credential.Password)
|
||||||
|
}
|
||||||
|
res, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("onvif request: %w", err)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
data, err := io.ReadAll(io.LimitReader(res.Body, 2<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.StatusCode >= 300 && res.StatusCode < 400 {
|
||||||
|
return nil, ErrRedirect
|
||||||
|
}
|
||||||
|
if res.StatusCode == http.StatusUnauthorized {
|
||||||
|
if authorization == "" && credential.Username != "" {
|
||||||
|
challenge, challengeErr := parseDigestChallenge(res.Header.Values("WWW-Authenticate"))
|
||||||
|
if challengeErr == nil {
|
||||||
|
digest, digestErr := digestAuthorization(http.MethodPost, req.URL.RequestURI(), credential, challenge)
|
||||||
|
if digestErr != nil {
|
||||||
|
return nil, digestErr
|
||||||
|
}
|
||||||
|
return c.soapAttempt(ctx, endpoint, credential, body, digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, ErrAuthentication
|
||||||
|
}
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("onvif http status %d", res.StatusCode)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type digestChallenge struct{ realm, nonce, opaque, algorithm, qop string }
|
||||||
|
|
||||||
|
func parseDigestChallenge(values []string) (digestChallenge, error) {
|
||||||
|
for _, value := range values {
|
||||||
|
parts := strings.SplitN(strings.TrimSpace(value), " ", 2)
|
||||||
|
if len(parts) != 2 || !strings.EqualFold(parts[0], "Digest") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
params, err := parseAuthParameters(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return digestChallenge{}, err
|
||||||
|
}
|
||||||
|
c := digestChallenge{realm: params["realm"], nonce: params["nonce"], opaque: params["opaque"], algorithm: strings.ToUpper(params["algorithm"])}
|
||||||
|
if c.realm == "" || c.nonce == "" {
|
||||||
|
return digestChallenge{}, ErrAuthentication
|
||||||
|
}
|
||||||
|
if c.algorithm == "" {
|
||||||
|
c.algorithm = "MD5"
|
||||||
|
}
|
||||||
|
if c.algorithm != "MD5" && c.algorithm != "SHA-256" {
|
||||||
|
return digestChallenge{}, ErrAuthentication
|
||||||
|
}
|
||||||
|
for _, q := range strings.Split(params["qop"], ",") {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(q), "auth") {
|
||||||
|
c.qop = "auth"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if params["qop"] != "" && c.qop == "" {
|
||||||
|
return digestChallenge{}, ErrAuthentication
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
return digestChallenge{}, ErrAuthentication
|
||||||
|
}
|
||||||
|
func parseAuthParameters(value string) (map[string]string, error) {
|
||||||
|
result := map[string]string{}
|
||||||
|
for position := 0; position < len(value); {
|
||||||
|
for position < len(value) && (value[position] == ' ' || value[position] == ',') {
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
start := position
|
||||||
|
for position < len(value) && value[position] != '=' && value[position] != ',' {
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
if position == start || position >= len(value) || value[position] != '=' {
|
||||||
|
return nil, ErrAuthentication
|
||||||
|
}
|
||||||
|
name := strings.ToLower(strings.TrimSpace(value[start:position]))
|
||||||
|
position++
|
||||||
|
var parameter string
|
||||||
|
if position < len(value) && value[position] == '"' {
|
||||||
|
position++
|
||||||
|
var builder strings.Builder
|
||||||
|
closed := false
|
||||||
|
for position < len(value) {
|
||||||
|
if value[position] == '"' {
|
||||||
|
position++
|
||||||
|
closed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if value[position] == '\\' && position+1 < len(value) {
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
builder.WriteByte(value[position])
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
if !closed {
|
||||||
|
return nil, ErrAuthentication
|
||||||
|
}
|
||||||
|
parameter = builder.String()
|
||||||
|
} else {
|
||||||
|
start = position
|
||||||
|
for position < len(value) && value[position] != ',' {
|
||||||
|
position++
|
||||||
|
}
|
||||||
|
parameter = strings.TrimSpace(value[start:position])
|
||||||
|
}
|
||||||
|
result[name] = parameter
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
func digestAuthorization(method, uri string, credential Credential, c digestChallenge) (string, error) {
|
||||||
|
random := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(random); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
cnonce := fmt.Sprintf("%x", random)
|
||||||
|
hash := func(value string) string {
|
||||||
|
if c.algorithm == "SHA-256" {
|
||||||
|
sum := sha256.Sum256([]byte(value))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
|
sum := md5.Sum([]byte(value))
|
||||||
|
return fmt.Sprintf("%x", sum)
|
||||||
|
}
|
||||||
|
ha1 := hash(credential.Username + ":" + c.realm + ":" + credential.Password)
|
||||||
|
ha2 := hash(method + ":" + uri)
|
||||||
|
nc := "00000001"
|
||||||
|
response := hash(ha1 + ":" + c.nonce + ":" + ha2)
|
||||||
|
if c.qop != "" {
|
||||||
|
response = hash(ha1 + ":" + c.nonce + ":" + nc + ":" + cnonce + ":" + c.qop + ":" + ha2)
|
||||||
|
}
|
||||||
|
values := []string{`username=` + strconv.Quote(credential.Username), `realm=` + strconv.Quote(c.realm), `nonce=` + strconv.Quote(c.nonce), `uri=` + strconv.Quote(uri), `response=` + strconv.Quote(response), `algorithm=` + c.algorithm}
|
||||||
|
if c.opaque != "" {
|
||||||
|
values = append(values, `opaque=`+strconv.Quote(c.opaque))
|
||||||
|
}
|
||||||
|
if c.qop != "" {
|
||||||
|
values = append(values, `qop=`+c.qop, `nc=`+nc, `cnonce=`+strconv.Quote(cnonce))
|
||||||
|
}
|
||||||
|
return "Digest " + strings.Join(values, ", "), nil
|
||||||
|
}
|
||||||
|
func xmlEscape(value string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
_ = xml.EscapeText(&b, []byte(value))
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loopbackPolicy(t *testing.T) Policy {
|
||||||
|
t.Helper()
|
||||||
|
policy, err := ParsePolicy("127.0.0.0/8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return policy
|
||||||
|
}
|
||||||
|
func TestProfilesSupportsDigestNormalizesAdvertisedHostsAndRejectsCredentials(t *testing.T) {
|
||||||
|
digestSeen := false
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body := make([]byte, r.ContentLength)
|
||||||
|
_, _ = r.Body.Read(body)
|
||||||
|
value := string(body)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(value, "GetCapabilities"):
|
||||||
|
fmt.Fprint(w, `<Envelope><Body><GetCapabilitiesResponse><Capabilities><Media><XAddr>http://unusable.invalid/onvif/media</XAddr></Media></Capabilities></GetCapabilitiesResponse></Body></Envelope>`)
|
||||||
|
case !strings.HasPrefix(r.Header.Get("Authorization"), "Digest "):
|
||||||
|
w.Header().Set("WWW-Authenticate", `Digest realm="camera", nonce="n", algorithm=MD5, qop="auth"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
case strings.Contains(value, "GetProfiles"):
|
||||||
|
digestSeen = strings.HasPrefix(r.Header.Get("Authorization"), "Digest ")
|
||||||
|
fmt.Fprint(w, `<Envelope><Body><GetProfilesResponse><Profiles token="main"><Name>主码流</Name><VideoEncoderConfiguration><Encoding>H264</Encoding><Resolution><Width>1920</Width><Height>1080</Height></Resolution></VideoEncoderConfiguration></Profiles></GetProfilesResponse></Body></Envelope>`)
|
||||||
|
default:
|
||||||
|
fmt.Fprint(w, `<Envelope><Body><GetStreamUriResponse><MediaUri><Uri>rtsp://unusable.invalid:8554/live</Uri></MediaUri></GetStreamUriResponse></Body></Envelope>`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
profiles, err := NewHTTPClient(2*time.Second, loopbackPolicy(t)).Profiles(context.Background(), server.URL+"/onvif/device", Credential{Username: "synthetic", Password: "synthetic"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !digestSeen || len(profiles) != 1 || !strings.Contains(profiles[0].StreamURI, "127.0.0.1:8554") {
|
||||||
|
t.Fatalf("profiles=%#v digest=%v", profiles, digestSeen)
|
||||||
|
}
|
||||||
|
if _, _, err = loopbackPolicy(t).ValidateURL(context.Background(), "http://user:pass@127.0.0.1/onvif", "http"); err == nil {
|
||||||
|
t.Fatal("credential URL accepted")
|
||||||
|
}
|
||||||
|
if _, _, err = loopbackPolicy(t).ValidateURL(context.Background(), "http://127.0.0.1/onvif?access_token=synthetic", "http"); err == nil {
|
||||||
|
t.Fatal("credential-like query accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestPolicyRequiresExplicitCIDRAndRejectsOutsideTarget(t *testing.T) {
|
||||||
|
if _, err := ParsePolicy(""); err == nil {
|
||||||
|
t.Fatal("empty policy accepted")
|
||||||
|
}
|
||||||
|
policy := loopbackPolicy(t)
|
||||||
|
if _, _, err := policy.ValidateURL(context.Background(), "http://192.0.2.1/onvif", "http"); err == nil {
|
||||||
|
t.Fatal("outside target accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestDiscoveryRequiresApprovedInterface(t *testing.T) {
|
||||||
|
_, err := Discover(context.Background(), "", time.Millisecond, loopbackPolicy(t))
|
||||||
|
if err != ErrDiscoveryNotConfigured {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClientRejectsRedirect(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Location", "http://127.0.0.1/other")
|
||||||
|
w.WriteHeader(http.StatusFound)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
_, err := NewHTTPClient(time.Second, loopbackPolicy(t)).Profiles(context.Background(), server.URL+"/onvif", Credential{})
|
||||||
|
if err != ErrRedirect {
|
||||||
|
t.Fatalf("error=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrDiscoveryNotConfigured = errors.New("未配置获准的发现网卡")
|
||||||
|
ErrDiscoveryInterface = errors.New("配置的发现地址不是本机网卡")
|
||||||
|
)
|
||||||
|
|
||||||
|
const discoveryProbe = `<?xml version="1.0"?><e:Envelope xmlns:e="http://www.w3.org/2003/05/soap-envelope" xmlns:w="http://schemas.xmlsoap.org/ws/2004/08/addressing" xmlns:d="http://schemas.xmlsoap.org/ws/2005/04/discovery" xmlns:dn="http://www.onvif.org/ver10/network/wsdl"><e:Header><w:MessageID>uuid:sense-controlled-discovery</w:MessageID><w:To e:mustUnderstand="true">urn:schemas-xmlsoap-org:ws:2005:04:discovery</w:To><w:Action e:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2005/04/discovery/Probe</w:Action></e:Header><e:Body><d:Probe><d:Types>dn:NetworkVideoTransmitter</d:Types></d:Probe></e:Body></e:Envelope>`
|
||||||
|
|
||||||
|
func Discover(ctx context.Context, localIP string, timeout time.Duration, policy Policy) ([]string, error) {
|
||||||
|
ip := net.ParseIP(strings.TrimSpace(localIP))
|
||||||
|
if ip == nil {
|
||||||
|
return nil, ErrDiscoveryNotConfigured
|
||||||
|
}
|
||||||
|
approved := false
|
||||||
|
interfaces, _ := net.Interfaces()
|
||||||
|
for _, iface := range interfaces {
|
||||||
|
addresses, _ := iface.Addrs()
|
||||||
|
for _, address := range addresses {
|
||||||
|
host, _, _ := net.ParseCIDR(address.String())
|
||||||
|
if host != nil && host.Equal(ip) {
|
||||||
|
approved = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !approved {
|
||||||
|
return nil, ErrDiscoveryInterface
|
||||||
|
}
|
||||||
|
connection, err := net.ListenUDP("udp4", &net.UDPAddr{IP: ip})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 3 * time.Second
|
||||||
|
}
|
||||||
|
_ = connection.SetDeadline(time.Now().Add(timeout))
|
||||||
|
if _, err = connection.WriteToUDP([]byte(discoveryProbe), &net.UDPAddr{IP: net.ParseIP("239.255.255.250"), Port: 3702}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found := map[string]bool{}
|
||||||
|
buffer := make([]byte, 65535)
|
||||||
|
for {
|
||||||
|
n, _, readErr := connection.ReadFromUDP(buffer)
|
||||||
|
if readErr != nil {
|
||||||
|
if e, ok := readErr.(net.Error); ok && e.Timeout() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return nil, readErr
|
||||||
|
}
|
||||||
|
for _, candidate := range extractXAddrs(string(buffer[:n])) {
|
||||||
|
if _, _, validErr := policy.ValidateURL(ctx, candidate, "http", "https"); validErr == nil {
|
||||||
|
found[candidate] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result := make([]string, 0, len(found))
|
||||||
|
for value := range found {
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
sort.Strings(result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractXAddrs(value string) []string {
|
||||||
|
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
start, ok := token.(xml.StartElement)
|
||||||
|
if !ok || start.Name.Local != "XAddrs" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var addresses string
|
||||||
|
if decoder.DecodeElement(&addresses, &start) == nil {
|
||||||
|
return strings.Fields(addresses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidResponse = errors.New("ONVIF 返回内容无法识别")
|
||||||
|
|
||||||
|
type profileEnvelope struct {
|
||||||
|
Profiles []struct {
|
||||||
|
Token string `xml:"token,attr"`
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
Encoder struct {
|
||||||
|
Encoding string `xml:"Encoding"`
|
||||||
|
Resolution struct {
|
||||||
|
Width int `xml:"Width"`
|
||||||
|
Height int `xml:"Height"`
|
||||||
|
} `xml:"Resolution"`
|
||||||
|
} `xml:"VideoEncoderConfiguration"`
|
||||||
|
} `xml:"Body>GetProfilesResponse>Profiles"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseProfiles(data []byte) ([]Profile, error) {
|
||||||
|
var envelope profileEnvelope
|
||||||
|
if err := xml.Unmarshal(data, &envelope); err != nil || len(envelope.Profiles) == 0 || len(envelope.Profiles) > 128 {
|
||||||
|
return nil, ErrInvalidResponse
|
||||||
|
}
|
||||||
|
result := make([]Profile, 0, len(envelope.Profiles))
|
||||||
|
for _, p := range envelope.Profiles {
|
||||||
|
if strings.TrimSpace(p.Token) == "" || len(p.Token) > 255 || len([]rune(p.Name)) > 255 || p.Encoder.Resolution.Width <= 0 || p.Encoder.Resolution.Width > 32768 || p.Encoder.Resolution.Height <= 0 || p.Encoder.Resolution.Height > 32768 || len(p.Encoder.Encoding) > 32 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result = append(result, Profile{Token: p.Token, Name: p.Name, Width: p.Encoder.Resolution.Width, Height: p.Encoder.Resolution.Height, Encoding: p.Encoder.Encoding})
|
||||||
|
}
|
||||||
|
if len(result) == 0 {
|
||||||
|
return nil, ErrInvalidResponse
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseElement(data []byte, parent, name string) (string, error) {
|
||||||
|
decoder := xml.NewDecoder(strings.NewReader(string(data)))
|
||||||
|
depth := 0
|
||||||
|
for {
|
||||||
|
token, err := decoder.Token()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return "", ErrInvalidResponse
|
||||||
|
}
|
||||||
|
return "", ErrInvalidResponse
|
||||||
|
}
|
||||||
|
switch value := token.(type) {
|
||||||
|
case xml.StartElement:
|
||||||
|
if parent == "" || value.Name.Local == parent {
|
||||||
|
if value.Name.Local == parent {
|
||||||
|
depth++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parent == "" || depth > 0) && value.Name.Local == name {
|
||||||
|
var text string
|
||||||
|
if err := decoder.DecodeElement(&text, &value); err != nil {
|
||||||
|
return "", ErrInvalidResponse
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(text), nil
|
||||||
|
}
|
||||||
|
case xml.EndElement:
|
||||||
|
if value.Name.Local == parent && depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package onvif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTargetNotAllowed = errors.New("目标地址不在获准网段内")
|
||||||
|
ErrAddressInvalid = errors.New("设备地址格式不正确")
|
||||||
|
)
|
||||||
|
|
||||||
|
type Policy struct{ Networks []*net.IPNet }
|
||||||
|
|
||||||
|
func ParsePolicy(value string) (Policy, error) {
|
||||||
|
var policy Policy
|
||||||
|
for _, item := range strings.Split(value, ",") {
|
||||||
|
item = strings.TrimSpace(item)
|
||||||
|
if item == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, network, err := net.ParseCIDR(item)
|
||||||
|
if err != nil {
|
||||||
|
return Policy{}, ErrTargetNotAllowed
|
||||||
|
}
|
||||||
|
policy.Networks = append(policy.Networks, network)
|
||||||
|
}
|
||||||
|
if len(policy.Networks) == 0 {
|
||||||
|
return Policy{}, ErrTargetNotAllowed
|
||||||
|
}
|
||||||
|
return policy, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Policy) ValidateURL(ctx context.Context, raw string, schemes ...string) (*url.URL, net.IP, error) {
|
||||||
|
u, err := url.Parse(strings.TrimSpace(raw))
|
||||||
|
if err != nil || u.Hostname() == "" || u.User != nil || u.Fragment != "" {
|
||||||
|
return nil, nil, ErrAddressInvalid
|
||||||
|
}
|
||||||
|
for key := range u.Query() {
|
||||||
|
lower := strings.ToLower(key)
|
||||||
|
for _, sensitive := range []string{"user", "password", "passwd", "token", "auth", "credential", "secret", "key"} {
|
||||||
|
if strings.Contains(lower, sensitive) {
|
||||||
|
return nil, nil, ErrAddressInvalid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allowedScheme := false
|
||||||
|
for _, scheme := range schemes {
|
||||||
|
if strings.EqualFold(u.Scheme, scheme) {
|
||||||
|
allowedScheme = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowedScheme {
|
||||||
|
return nil, nil, ErrAddressInvalid
|
||||||
|
}
|
||||||
|
addresses, err := net.DefaultResolver.LookupIP(ctx, "ip", u.Hostname())
|
||||||
|
if err != nil || len(addresses) == 0 {
|
||||||
|
return nil, nil, ErrTargetNotAllowed
|
||||||
|
}
|
||||||
|
for _, address := range addresses {
|
||||||
|
ok := false
|
||||||
|
for _, network := range p.Networks {
|
||||||
|
if network.Contains(address) {
|
||||||
|
ok = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, ErrTargetNotAllowed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return u, addresses[0], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package reconcile
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
func Backoff(failures int) time.Duration {
|
||||||
|
if failures <= 1 {
|
||||||
|
return time.Second
|
||||||
|
}
|
||||||
|
d := time.Second
|
||||||
|
for i := 1; i < failures; i++ {
|
||||||
|
if d >= 30*time.Second {
|
||||||
|
return time.Minute
|
||||||
|
}
|
||||||
|
d *= 2
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package reconcile
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBackoffIsBounded(t *testing.T) {
|
||||||
|
if Backoff(1) != time.Second || Backoff(4) != 8*time.Second || Backoff(20) != time.Minute {
|
||||||
|
t.Fatal("unexpected retry backoff")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package rtsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Credential struct{ Username, Password string }
|
||||||
|
type Result struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
LatencyMS int64 `json:"latencyMs"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
type Verifier interface {
|
||||||
|
Verify(context.Context, string, Credential) (Result, error)
|
||||||
|
}
|
||||||
|
type NetVerifier struct {
|
||||||
|
Timeout time.Duration
|
||||||
|
Policy onvif.Policy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v NetVerifier) Verify(ctx context.Context, raw string, credential Credential) (Result, error) {
|
||||||
|
parsed, ip, err := v.Policy.ValidateURL(ctx, raw, "rtsp")
|
||||||
|
if err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
port := parsed.Port()
|
||||||
|
if port == "" {
|
||||||
|
port = "554"
|
||||||
|
}
|
||||||
|
timeout := v.Timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 5 * time.Second
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
connection, err := (&net.Dialer{Timeout: timeout}).DialContext(ctx, "tcp", net.JoinHostPort(ip.String(), port))
|
||||||
|
if err != nil {
|
||||||
|
return Result{Status: "unreachable", Detail: "无法连接视频端口"}, nil
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
_ = connection.SetDeadline(time.Now().Add(timeout))
|
||||||
|
authorization := ""
|
||||||
|
if credential.Username != "" {
|
||||||
|
authorization = "Authorization: Basic " + base64.StdEncoding.EncodeToString([]byte(credential.Username+":"+credential.Password)) + "\r\n"
|
||||||
|
}
|
||||||
|
request := fmt.Sprintf("OPTIONS %s RTSP/1.0\r\nCSeq: 1\r\nUser-Agent: YoVision-Sense\r\n%s\r\n", parsed.String(), authorization)
|
||||||
|
if _, err = connection.Write([]byte(request)); err != nil {
|
||||||
|
return Result{}, err
|
||||||
|
}
|
||||||
|
line, err := bufio.NewReader(connection).ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return Result{Status: "timeout", Detail: "等待视频响应超时"}, nil
|
||||||
|
}
|
||||||
|
result := Result{Status: "ready", Detail: "码流可访问", LatencyMS: time.Since(started).Milliseconds()}
|
||||||
|
if strings.Contains(line, " 401 ") {
|
||||||
|
result.Status = "authentication_failed"
|
||||||
|
result.Detail = "设备拒绝了当前 RTSP 凭据"
|
||||||
|
} else if !strings.Contains(line, " 200 ") {
|
||||||
|
result.Status = "failed"
|
||||||
|
result.Detail = "设备返回非成功状态"
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package rtsp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/onvif"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifierUsesCredentialHeaderWithoutPuttingItInURI(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
requestChannel := make(chan string, 1)
|
||||||
|
go func() {
|
||||||
|
connection, _ := listener.Accept()
|
||||||
|
if connection == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer connection.Close()
|
||||||
|
reader := bufio.NewReader(connection)
|
||||||
|
request, _ := reader.ReadString('\n')
|
||||||
|
headers := request
|
||||||
|
for {
|
||||||
|
line, _ := reader.ReadString('\n')
|
||||||
|
headers += line
|
||||||
|
if line == "\r\n" || line == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestChannel <- headers
|
||||||
|
_, _ = connection.Write([]byte("RTSP/1.0 200 OK\r\nCSeq: 1\r\n\r\n"))
|
||||||
|
}()
|
||||||
|
policy, _ := onvif.ParsePolicy("127.0.0.0/8")
|
||||||
|
result, err := (NetVerifier{Timeout: time.Second, Policy: policy}).Verify(context.Background(), "rtsp://"+listener.Addr().String()+"/live", Credential{Username: "synthetic", Password: "synthetic"})
|
||||||
|
if err != nil || result.Status != "ready" {
|
||||||
|
t.Fatalf("result=%#v err=%v", result, err)
|
||||||
|
}
|
||||||
|
request := <-requestChannel
|
||||||
|
if strings.Contains(strings.Split(request, "\r\n")[0], "synthetic") {
|
||||||
|
t.Fatal("credentials leaked into request URI")
|
||||||
|
}
|
||||||
|
if !strings.Contains(request, "Authorization: Basic ") {
|
||||||
|
t.Fatal("authorization header missing")
|
||||||
|
}
|
||||||
|
if _, err = (NetVerifier{Policy: policy}).Verify(context.Background(), "rtsp://user:pass@"+listener.Addr().String()+"/live", Credential{}); err == nil {
|
||||||
|
t.Fatal("credential-bearing URI accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
|
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
|
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/router"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
|
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
|
||||||
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
|
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
|
||||||
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
|
||||||
@@ -85,6 +86,19 @@ func run() error {
|
|||||||
for _, f := range AppRouters {
|
for _, f := range AppRouters {
|
||||||
f()
|
f()
|
||||||
}
|
}
|
||||||
|
runtimeCtx, runtimeCancel := context.WithCancel(context.Background())
|
||||||
|
defer runtimeCancel()
|
||||||
|
var runtimeDBFound bool
|
||||||
|
for _, db := range sdk.Runtime.GetDb() {
|
||||||
|
runtimeDBFound = true
|
||||||
|
if err := media.StartRuntime(runtimeCtx, db); err != nil {
|
||||||
|
log.Errorf("MediaMTX runtime unavailable: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !runtimeDBFound {
|
||||||
|
log.Error("MediaMTX runtime unavailable: Sense database is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
Addr: fmt.Sprintf("%s:%d", config.ApplicationConfig.Host, config.ApplicationConfig.Port),
|
||||||
@@ -139,6 +153,10 @@ func run() error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
log.Info("Shutdown Server ... ")
|
log.Info("Shutdown Server ... ")
|
||||||
|
runtimeCancel()
|
||||||
|
if err := media.ShutdownRuntime(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
log.Errorf("Shutdown MediaMTX runtime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
if err := srv.Shutdown(ctx); err != nil {
|
if err := srv.Shutdown(ctx); err != nil {
|
||||||
log.Fatal("Server Shutdown:", err)
|
log.Fatal("Server Shutdown:", err)
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"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/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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type deviceCasbinRule struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement"`
|
||||||
|
Ptype string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||||
|
V0 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||||
|
V1 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||||
|
V2 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||||
|
V3 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||||
|
V4 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||||
|
V5 string `gorm:"size:512;uniqueIndex:unique_index"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (deviceCasbinRule) TableName() string { return "casbin_rule" }
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
_, fileName, _, _ := runtime.Caller(0)
|
||||||
|
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseDeviceLedger)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||||
|
MenuName: "SenseDeviceManage", Title: "设备管理", Icon: "monitor", Path: "/sense/devices",
|
||||||
|
MenuType: "C", Permission: "sense:device:list", ParentId: 0, Component: "/sense/device/index",
|
||||||
|
Sort: 5, Visible: "0", IsFrame: "1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buttons := make([]migrationModels.SysMenu, 0, 4)
|
||||||
|
for index, definition := range []struct{ name, title, action, permission string }{
|
||||||
|
{"SenseDeviceAdd", "新增设备", "POST", "sense:device:add"},
|
||||||
|
{"SenseDeviceEdit", "编辑设备", "PUT", "sense:device:edit"},
|
||||||
|
{"SenseDeviceDisable", "停用设备", "PUT", "sense:device:disable"},
|
||||||
|
{"SenseDeviceCredential", "更新设备凭据", "PUT", "sense:device:credential"},
|
||||||
|
} {
|
||||||
|
button, err := ensureDeviceMenu(tx, migrationModels.SysMenu{
|
||||||
|
MenuName: definition.name, Title: definition.title, MenuType: "F", Action: definition.action,
|
||||||
|
Permission: definition.permission, ParentId: page.MenuId, Paths: fmt.Sprintf("/0/%d", page.MenuId),
|
||||||
|
Sort: index + 1, Visible: "1", IsFrame: "1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
buttons = append(buttons, button)
|
||||||
|
}
|
||||||
|
if err = attachDeviceRole(tx, "implementation_operator", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = attachDeviceRole(tx, "site_admin", append([]migrationModels.SysMenu{page}, buttons...)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
readPolicies := [][2]string{{"/api/v1/devices", "GET"}, {"/api/v1/devices/:id", "GET"}}
|
||||||
|
writePolicies := [][2]string{{"/api/v1/devices", "POST"}, {"/api/v1/devices/:id", "PUT"}, {"/api/v1/devices/:id/disable", "PUT"}, {"/api/v1/devices/:id/credentials", "PUT"}}
|
||||||
|
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
|
||||||
|
policies := append([][2]string{}, readPolicies...)
|
||||||
|
if role != "viewer" {
|
||||||
|
policies = append(policies, writePolicies...)
|
||||||
|
}
|
||||||
|
for _, policy := range policies {
|
||||||
|
rule := deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}
|
||||||
|
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).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
|
||||||
|
if err != nil && err != gorm.ErrRecordNotFound {
|
||||||
|
return menu, err
|
||||||
|
}
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
menu = desired
|
||||||
|
if err = tx.Create(&menu).Error; err != nil {
|
||||||
|
return menu, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := desired.Paths
|
||||||
|
if desired.ParentId == 0 {
|
||||||
|
paths = fmt.Sprintf("/0/%d", menu.MenuId)
|
||||||
|
}
|
||||||
|
err = tx.Model(&menu).Updates(map[string]any{
|
||||||
|
"title": desired.Title, "icon": desired.Icon, "path": desired.Path, "paths": paths,
|
||||||
|
"menu_type": desired.MenuType, "action": desired.Action, "permission": desired.Permission,
|
||||||
|
"parent_id": desired.ParentId, "component": desired.Component, "sort": desired.Sort,
|
||||||
|
"visible": desired.Visible, "is_frame": desired.IsFrame,
|
||||||
|
}).Error
|
||||||
|
return menu, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachDeviceRole(tx *gorm.DB, roleKey string, menus []migrationModels.SysMenu) error {
|
||||||
|
var role migrationModels.SysRole
|
||||||
|
if err := tx.Where("role_key = ?", roleKey).First(&role).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Model(&role).Association("SysMenu").Append(menus)
|
||||||
|
}
|
||||||
@@ -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,60 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||||
|
"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), migrateSenseAdmission)
|
||||||
|
}
|
||||||
|
func migrateSenseAdmission(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.AutoMigrate(&admission.Result{}, &admission.Profile{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmission", Title: "视频接入", Icon: "video-camera", Path: "/sense/admission", MenuType: "C", Permission: "sense:admission:list", Component: "/sense/admission/index", Sort: 6, Visible: "0", IsFrame: "1"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
discover, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionDiscover", Title: "发现设备", MenuType: "F", Action: "GET", Permission: "sense:admission:discover", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
probe, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionProbe", Title: "验证接入", MenuType: "F", Action: "POST", Permission: "sense:admission:probe", 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, discover, probe}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
read := [][2]string{{"/api/v1/admission/devices/:id", "GET"}}
|
||||||
|
write := [][2]string{{"/api/v1/admission/discover", "GET"}, {"/api/v1/admission/devices/:id/probe", "POST"}}
|
||||||
|
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 {
|
||||||
|
rule := deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}
|
||||||
|
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Create(&common.Migration{Version: version}).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
"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), migrateSenseMedia)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrateSenseMedia(db *gorm.DB, version string) error {
|
||||||
|
return db.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.AutoMigrate(&media.Route{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMedia", Title: "视频服务", Icon: "video-play", Path: "/sense/media", MenuType: "C", Permission: "sense:media:list", Component: "/sense/media/index", Sort: 7, Visible: "0", IsFrame: "1"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reconcile, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaReconcile", Title: "立即对账", MenuType: "F", Action: "POST", Permission: "sense:media:reconcile", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
stop, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaStop", Title: "停止路径", MenuType: "F", Action: "POST", Permission: "sense:media:stop", 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, reconcile, stop}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
read := [][2]string{{"/api/v1/media/routes", "GET"}, {"/api/v1/media/process", "GET"}}
|
||||||
|
write := [][2]string{{"/api/v1/media/reconcile", "POST"}, {"/api/v1/media/routes/:id/reconcile", "POST"}, {"/api/v1/media/routes/:id/stop", "POST"}}
|
||||||
|
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,54 @@
|
|||||||
|
package version
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
|
||||||
|
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMediaMigrationOnPostgres(t *testing.T) {
|
||||||
|
dsn := os.Getenv("SENSE_MEDIA_MIGRATION_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set SENSE_MEDIA_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)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
|
||||||
|
})
|
||||||
|
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 = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var routes, menus, policies, applied int64
|
||||||
|
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
|
||||||
|
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,7 +136,8 @@ func redactAuditValue(value interface{}) {
|
|||||||
func isSensitiveAuditKey(key string) bool {
|
func isSensitiveAuditKey(key string) bool {
|
||||||
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
|
||||||
switch normalized {
|
switch normalized {
|
||||||
case "password", "oldpassword", "newpassword", "token", "refreshtoken", "secret", "code", "uuid":
|
case "password", "oldpassword", "newpassword", "onvifusername", "onvifpassword", "rtspusername", "rtsppassword",
|
||||||
|
"token", "refreshtoken", "secret", "code", "uuid":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ func TestSanitizeAuditJSON(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSanitizeAuditJSONHidesCameraCredentialFields(t *testing.T) {
|
||||||
|
value := sanitizeAuditJSON(`{"onvifUsername":"camera-user","onvifPassword":"camera-password","rtspUsername":"stream-user","rtspPassword":"stream-password","name":"东门摄像机"}`)
|
||||||
|
for _, forbidden := range []string{"camera-user", "camera-password", "stream-user", "stream-password"} {
|
||||||
|
if strings.Contains(value, forbidden) {
|
||||||
|
t.Fatalf("credential value leaked in audit JSON: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(value, "东门摄像机") {
|
||||||
|
t.Fatalf("non-sensitive device field was unexpectedly removed: %s", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSanitizeAuditJSONRejectsUnstructuredBodies(t *testing.T) {
|
func TestSanitizeAuditJSONRejectsUnstructuredBodies(t *testing.T) {
|
||||||
if got := sanitizeAuditJSON("password=secret-value"); strings.Contains(got, "secret-value") {
|
if got := sanitizeAuditJSON("password=secret-value"); strings.Contains(got, "secret-value") {
|
||||||
t.Fatalf("unstructured body leaked: %s", got)
|
t.Fatalf("unstructured body leaked: %s", got)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Required only when creating or updating camera credentials.
|
||||||
|
# Set outside the repository to a Base64-encoded random 32-byte key.
|
||||||
|
SENSE_CREDENTIAL_KEY=
|
||||||
|
|
||||||
|
# Required before ONVIF discovery or manual probing. Use only explicitly
|
||||||
|
# approved local interface/IP ranges; comma-separate multiple CIDRs.
|
||||||
|
SENSE_ONVIF_DISCOVERY_IP=
|
||||||
|
SENSE_ONVIF_ALLOWED_CIDRS=
|
||||||
|
SENSE_MEDIAMTX_BINARY=
|
||||||
|
SENSE_MEDIAMTX_CONFIG=
|
||||||
|
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Sense generates only this credential-free base configuration.
|
||||||
|
# The Control API must stay on loopback; camera paths are applied at runtime.
|
||||||
|
logLevel: info
|
||||||
|
api: true
|
||||||
|
apiAddress: 127.0.0.1:9997
|
||||||
|
metrics: false
|
||||||
|
paths: {}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package admission_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||||
|
"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 onvifFixture struct{}
|
||||||
|
|
||||||
|
func (onvifFixture) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
type rtspFixture struct{}
|
||||||
|
|
||||||
|
func (rtspFixture) Verify(context.Context, string, rtsp.Credential) (rtsp.Result, error) {
|
||||||
|
return rtsp.Result{Status: "ready", Detail: "合成 RTSP 可用"}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProfilesSurvivePostgreSQLReopen(t *testing.T) {
|
||||||
|
dsn := os.Getenv("SENSE_ADMISSION_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set SENSE_ADMISSION_TEST_DATABASE_URL to an isolated PostgreSQL database")
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&deviceModels.Device{}, &credential.DeviceCredential{}, &admission.Result{}, &admission.Profile{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
deviceID := "issue66-postgres-device"
|
||||||
|
db.Where("device_id = ?", deviceID).Delete(&admission.Profile{})
|
||||||
|
db.Where("device_id = ?", deviceID).Delete(&admission.Result{})
|
||||||
|
db.Where("device_id = ?", deviceID).Delete(&credential.DeviceCredential{})
|
||||||
|
db.Where("id = ?", deviceID).Delete(&deviceModels.Device{})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Where("device_id = ?", deviceID).Delete(&admission.Profile{})
|
||||||
|
db.Where("device_id = ?", deviceID).Delete(&admission.Result{})
|
||||||
|
db.Where("device_id = ?", deviceID).Delete(&credential.DeviceCredential{})
|
||||||
|
db.Where("id = ?", deviceID).Delete(&deviceModels.Device{})
|
||||||
|
})
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||||
|
vault, _ := credential.NewVault(key)
|
||||||
|
device := deviceModels.Device{ID: deviceID, Name: "重启持久化摄像机", Modality: deviceModels.ModalityVideo, Status: deviceModels.StatusPending, AdapterStatus: deviceModels.AdapterReady, Version: 1}
|
||||||
|
if err = db.Create(&device).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, purpose := range []string{credential.PurposeONVIF, credential.PurposeRTSP} {
|
||||||
|
cipher, _ := vault.Encrypt(deviceID, purpose, "synthetic-user", "synthetic-password")
|
||||||
|
if err = db.Create(&credential.DeviceCredential{DeviceID: deviceID, Purpose: purpose, Ciphertext: cipher, KeyVersion: credential.Version()}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
service := admission.Service{Service: coreService.Service{Orm: db}, ONVIF: onvifFixture{}, RTSP: rtspFixture{}}
|
||||||
|
if _, err = service.Probe(context.Background(), admission.ProbeRequest{DeviceID: deviceID, Address: "http://192.0.2.10/onvif", Version: 1}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reopened, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
restarted := admission.Service{Service: coreService.Service{Orm: reopened}}
|
||||||
|
saved, err := restarted.Get(deviceID)
|
||||||
|
if err != nil || saved.Status != "ready" || len(saved.Profiles) != 2 {
|
||||||
|
t.Fatalf("saved=%#v err=%v", saved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package media_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
func freeAddress(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
address := listener.Addr().String()
|
||||||
|
if err = listener.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return address
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRealMediaMTXControlLifecycle(t *testing.T) {
|
||||||
|
binary := os.Getenv("SENSE_MEDIAMTX_TEST_BINARY")
|
||||||
|
if binary == "" {
|
||||||
|
t.Skip("set SENSE_MEDIAMTX_TEST_BINARY to run the real MediaMTX integration")
|
||||||
|
}
|
||||||
|
apiAddress, rtspAddress := freeAddress(t), freeAddress(t)
|
||||||
|
configPath := filepath.Join(t.TempDir(), "mediamtx.yml")
|
||||||
|
config := fmt.Sprintf("logLevel: warn\napi: true\napiAddress: %s\nrtspAddress: %s\nrtmp: false\nhls: false\nwebrtc: false\nsrt: false\nplayback: false\npaths: {}\n", apiAddress, rtspAddress)
|
||||||
|
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
controller, err := media.NewHTTPController("http://" + apiAddress)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
supervisor := media.NewSupervisor(binary, configPath)
|
||||||
|
if err = supervisor.Start(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_ = supervisor.Stop(ctx)
|
||||||
|
})
|
||||||
|
deadline := time.Now().Add(8 * time.Second)
|
||||||
|
for {
|
||||||
|
err = controller.Health(context.Background())
|
||||||
|
if err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatalf("MediaMTX did not become ready: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
source := media.Source{Path: "sense_integration", URI: "rtsp://127.0.0.1:65530/test", Username: "synthetic-user", Password: "synthetic-password"}
|
||||||
|
if err = controller.Apply(context.Background(), source); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = controller.Apply(context.Background(), source); err != nil {
|
||||||
|
t.Fatalf("replace must be idempotent: %v", err)
|
||||||
|
}
|
||||||
|
if err = controller.Delete(context.Background(), source.Path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package media_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gorm.io/driver/postgres"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||||
|
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||||
|
)
|
||||||
|
|
||||||
|
type readyController struct{}
|
||||||
|
|
||||||
|
func (readyController) Health(context.Context) error { return nil }
|
||||||
|
func (readyController) Apply(context.Context, media.Source) error { return nil }
|
||||||
|
func (readyController) Delete(context.Context, string) error { return nil }
|
||||||
|
func (readyController) Status(context.Context, string) (media.PathStatus, error) {
|
||||||
|
return media.PathStatus{Exists: true, Ready: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresColdStartRestoresDesiredRoute(t *testing.T) {
|
||||||
|
dsn := os.Getenv("SENSE_MEDIA_TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("set SENSE_MEDIA_TEST_DATABASE_URL to run PostgreSQL media recovery")
|
||||||
|
}
|
||||||
|
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.AutoMigrate(&media.Route{}, &admission.Profile{}, &credential.DeviceCredential{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sense_admission_profiles, sense_device_credentials")
|
||||||
|
})
|
||||||
|
key := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(key); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||||
|
vault, _ := credential.NewVault(key)
|
||||||
|
ciphertext, err := vault.Encrypt("device-pg", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-pg", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err = db.Create(&admission.Profile{DeviceID: "device-pg", Token: "main", Name: "Main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready", VerificationDetail: "synthetic"}).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := media.NewService(db, readyController{}, nil, media.RuntimeConfig{})
|
||||||
|
if err = first.EnsureDevice(context.Background(), "device-pg"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second := media.NewService(db.Session(&gorm.Session{NewDB: true}), readyController{}, nil, media.RuntimeConfig{})
|
||||||
|
if err = second.ReconcileDue(context.Background()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := second.List(context.Background())
|
||||||
|
if err != nil || len(items) != 1 || items[0].Actual != "ready" {
|
||||||
|
t.Fatalf("items=%#v err=%v", items, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function discoverDevices() { return request({ url: '/api/v1/admission/discover', method: 'get' }) }
|
||||||
|
export function getAdmission(deviceId) { return request({ url: `/api/v1/admission/devices/${deviceId}`, method: 'get' }) }
|
||||||
|
export function probeDevice(deviceId, data) { return request({ url: `/api/v1/admission/devices/${deviceId}/probe`, method: 'post', data }) }
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listAreaConfigurations(query) {
|
||||||
|
return request({ url: '/api/v1/area/configurations', method: 'get', params: query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAreaConfiguration(data) {
|
||||||
|
return request({ url: '/api/v1/area/configurations', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateAreaConfiguration(id, data) {
|
||||||
|
return request({ url: `/api/v1/area/configurations/${id}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listAreaVersions(id) {
|
||||||
|
return request({ url: `/api/v1/area/configurations/${id}/versions`, method: 'get' })
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listDevices(query) {
|
||||||
|
return request({ url: '/api/v1/devices', method: 'get', params: query })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDevice(id) {
|
||||||
|
return request({ url: `/api/v1/devices/${id}`, method: 'get' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDevice(data) {
|
||||||
|
return request({ url: '/api/v1/devices', method: 'post', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDevice(id, data) {
|
||||||
|
return request({ url: `/api/v1/devices/${id}`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableDevice(id, data) {
|
||||||
|
return request({ url: `/api/v1/devices/${id}/disable`, method: 'put', data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDeviceCredentials(id, data) {
|
||||||
|
return request({ url: `/api/v1/devices/${id}/credentials`, method: 'put', data })
|
||||||
|
}
|
||||||
@@ -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,7 @@
|
|||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export function listMediaRoutes() { return request({ url: '/api/v1/media/routes', method: 'get' }) }
|
||||||
|
export function getMediaProcess() { return request({ url: '/api/v1/media/process', method: 'get' }) }
|
||||||
|
export function reconcileAllMedia() { return request({ url: '/api/v1/media/reconcile', method: 'post' }) }
|
||||||
|
export function reconcileMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/reconcile`, method: 'post' }) }
|
||||||
|
export function stopMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/stop`, method: 'post' }) }
|
||||||
@@ -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,7 @@
|
|||||||
|
export function buildProbePayload(form) {
|
||||||
|
return { address: String(form.address || '').trim(), version: Number(form.version) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addressHasCredentials(value) {
|
||||||
|
try { return Boolean(new URL(value).username || new URL(value).password) } catch (_) { return false }
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<template #wrapper>
|
||||||
|
<el-card class="box-card">
|
||||||
|
<div class="page-header">
|
||||||
|
<div><h3>视频接入</h3><p>选择已登记的视频设备,发现或填写 ONVIF 地址,然后验证主、子码流。</p></div>
|
||||||
|
<el-button v-permisaction="['sense:admission:discover']" type="primary" plain :loading="discovering" @click="handleDiscover">发现设备</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert title="发现只使用服务端配置的获准网卡;手工地址也只能访问获准网段。" type="info" :closable="false" show-icon />
|
||||||
|
<el-form ref="probeFormRef" :model="form" :rules="rules" label-width="120px" class="probe-form">
|
||||||
|
<el-form-item label="设备" prop="deviceId">
|
||||||
|
<el-select v-model="form.deviceId" filterable placeholder="请选择已登记的视频设备" @change="selectDevice">
|
||||||
|
<el-option v-for="device in devices" :key="device.id" :label="`${device.name} · ${device.location || '未填写位置'}`" :value="device.id" :disabled="device.status === 'disabled' || !device.onvifCredentialConfigured" />
|
||||||
|
</el-select>
|
||||||
|
<span class="field-hint">未配置凭据或已停用的设备不可探测。</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="ONVIF 地址" prop="address">
|
||||||
|
<el-input v-model="form.address" placeholder="例如:http://设备地址/onvif/device_service" />
|
||||||
|
<span class="field-hint">地址中不能包含用户名或密码;凭据从设备管理安全读取。</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button v-permisaction="['sense:admission:probe']" type="primary" :loading="probing" @click="handleProbe">验证接入</el-button>
|
||||||
|
<el-button @click="loadSaved">查看上次结果</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<el-divider />
|
||||||
|
<el-empty v-if="!result" description="尚无接入验证结果" />
|
||||||
|
<template v-else>
|
||||||
|
<el-descriptions :column="2" border>
|
||||||
|
<el-descriptions-item label="设备状态"><el-tag :type="result.status === 'ready' ? 'success' : 'warning'">{{ statusLabel(result.status) }}</el-tag></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="检查时间">{{ parseTime(result.checkedAt) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="结果说明" :span="2">{{ result.detail }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-table :data="result.profiles || []" border stripe class="profile-table">
|
||||||
|
<el-table-column prop="name" label="Profile" min-width="130" />
|
||||||
|
<el-table-column label="用途" width="90"><template #default="scope"><el-tag size="small">{{ scope.row.kind === 'main' ? '主码流' : scope.row.kind === 'sub' ? '子码流' : '其他' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="分辨率" width="110"><template #default="scope">{{ scope.row.width }} × {{ scope.row.height }}</template></el-table-column>
|
||||||
|
<el-table-column prop="encoding" label="编码" width="90" />
|
||||||
|
<el-table-column label="验证状态" width="130"><template #default="scope"><el-tag :type="scope.row.verificationStatus === 'ready' ? 'success' : 'danger'">{{ statusLabel(scope.row.verificationStatus) }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="verificationDetail" label="说明" min-width="180" />
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</el-card>
|
||||||
|
<el-dialog v-model="discoveryOpen" title="发现结果" width="720px">
|
||||||
|
<el-empty v-if="!discovered.length" description="获准网卡内未发现设备" />
|
||||||
|
<el-table v-else :data="discovered.map(address => ({ address }))" border>
|
||||||
|
<el-table-column prop="address" label="ONVIF 地址" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="100"><template #default="scope"><el-button type="primary" link @click="useAddress(scope.row.address)">使用</el-button></template></el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { listDevices } from '@/api/sense/device'
|
||||||
|
import { discoverDevices, getAdmission, probeDevice } from '@/api/sense/admission'
|
||||||
|
import { addressHasCredentials, buildProbePayload } from './admissionPayload'
|
||||||
|
|
||||||
|
const devices = ref([]); const result = ref(null); const discovered = ref([])
|
||||||
|
const discovering = ref(false); const probing = ref(false); const discoveryOpen = ref(false); const probeFormRef = ref()
|
||||||
|
const form = reactive({ deviceId: '', address: '', version: 0 })
|
||||||
|
const rules = { deviceId: [{ required: true, message: '请选择设备', trigger: 'change' }], address: [{ required: true, message: '请输入 ONVIF 地址', trigger: 'blur' }, { validator: (_r, value, done) => addressHasCredentials(value) ? done(new Error('地址中不能包含用户名或密码')) : done(), trigger: 'blur' }] }
|
||||||
|
|
||||||
|
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||||
|
function selectDevice(id) { const device = devices.value.find(item => item.id === id); form.version = device?.version || 0; result.value = null }
|
||||||
|
async function loadDevices() { const response = await listDevices({ pageIndex: 1, pageSize: 100, modality: 'video' }); const payload = unwrap(response); devices.value = payload?.list || payload?.data || payload || [] }
|
||||||
|
async function handleDiscover() { discovering.value = true; try { const response = await discoverDevices(); const payload = unwrap(response); discovered.value = payload?.addresses || []; discoveryOpen.value = true } catch (error) { ElMessage.error(error.message || '发现失败,请检查获准网卡配置') } finally { discovering.value = false } }
|
||||||
|
function useAddress(address) { form.address = address; discoveryOpen.value = false }
|
||||||
|
async function handleProbe() { const valid = await probeFormRef.value?.validate().catch(() => false); if (!valid) return; probing.value = true; try { const response = await probeDevice(form.deviceId, buildProbePayload(form)); result.value = unwrap(response); const device = devices.value.find(item => item.id === form.deviceId); if (device) { device.version += 1; form.version = device.version } ElMessage.success('接入验证完成') } catch (error) { ElMessage.error(error.message || '接入验证失败') } finally { probing.value = false } }
|
||||||
|
async function loadSaved() { if (!form.deviceId) return ElMessage.warning('请先选择设备'); try { result.value = unwrap(await getAdmission(form.deviceId)) } catch (error) { ElMessage.warning(error.message || '尚无验证结果') } }
|
||||||
|
function statusLabel(status) { return ({ ready: '可用', profile_failed: '部分码流失败', authentication_failed: '认证失败', target_not_allowed: '目标未获准', redirect_rejected: '重定向已拒绝', timeout: '响应超时', clock_skew: '设备时间异常', unreachable: '无法连接', failed: '验证失败' })[status] || status || '未知' }
|
||||||
|
onMounted(loadDevices)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.probe-form{max-width:820px;margin-top:22px}.probe-form .el-select{width:100%}.field-hint{display:block;color:#909399;font-size:12px;line-height:20px}.profile-table{margin-top:18px}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { DIRECTION_LINE, geometryError } from '@/components/sense/geometry-editor/geometry'
|
||||||
|
|
||||||
|
export function buildAreaPayload(form) {
|
||||||
|
return {
|
||||||
|
name: String(form.name || '').trim(),
|
||||||
|
kind: form.kind,
|
||||||
|
routeId: form.routeId,
|
||||||
|
points: (form.points || []).map(point => ({ x: Number(point.x), y: Number(point.y) })),
|
||||||
|
direction: form.kind === DIRECTION_LINE ? form.direction : '',
|
||||||
|
enabled: Boolean(form.enabled),
|
||||||
|
expectedVersion: Number(form.version || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateAreaForm(form) {
|
||||||
|
if (!String(form.name || '').trim()) return '请填写配置名称'
|
||||||
|
if (!form.routeId) return '请选择设备与视频码流'
|
||||||
|
const payload = buildAreaPayload(form)
|
||||||
|
return geometryError(payload.kind, payload.points, payload.direction)
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<template #wrapper>
|
||||||
|
<el-card class="box-card">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h3>区域与警戒线</h3>
|
||||||
|
<p>在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。</p>
|
||||||
|
</div>
|
||||||
|
<el-button v-permisaction="['sense:area:create']" type="primary" @click="openCreate">新增配置</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="queryForm" :model="queryParams" inline class="search-form" @submit.prevent>
|
||||||
|
<el-form-item label="名称" prop="keyword">
|
||||||
|
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入配置名称" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型" prop="kind">
|
||||||
|
<el-select v-model="queryParams.kind" clearable placeholder="全部类型">
|
||||||
|
<el-option label="危险区域" value="polygon" />
|
||||||
|
<el-option label="方向警戒线" value="direction_line" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="校准状态" prop="recalibrationState">
|
||||||
|
<el-select v-model="queryParams.recalibrationState" clearable placeholder="全部状态">
|
||||||
|
<el-option label="需要重新校准" value="needed" />
|
||||||
|
<el-option label="已校准" value="ready" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="!loading && total === 0"
|
||||||
|
title="尚无区域配置。请先确认实时监看可用,再新增危险区域或方向警戒线。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="empty-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="items" border stripe>
|
||||||
|
<el-table-column prop="name" label="配置名称" min-width="180" show-overflow-tooltip />
|
||||||
|
<el-table-column label="类型" width="130">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag size="small" :type="scope.row.kind === 'polygon' ? 'warning' : 'danger'">{{ kindLabel(scope.row.kind) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="设备与码流" min-width="220">
|
||||||
|
<template #default="scope">
|
||||||
|
<div>{{ scope.row.deviceName || scope.row.deviceId }}</div>
|
||||||
|
<small class="muted-text">{{ profileLabel(scope.row) }}</small>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="版本" width="90">
|
||||||
|
<template #default="scope">v{{ scope.row.version }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="校准状态" width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag v-if="scope.row.needsRecalibration" type="danger" size="small">需要重新校准</el-tag>
|
||||||
|
<el-tag v-else type="success" size="small">已校准</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="启用状态" width="100">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="small">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="updatedAt" label="最近更新" min-width="170">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button v-permisaction="['sense:area:update']" type="primary" link @click="openEdit(scope.row)">编辑/校准</el-button>
|
||||||
|
<el-button type="primary" link @click="openVersions(scope.row)">版本记录</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
v-model:page="queryParams.pageIndex"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
:total="total"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="editorOpen" width="min(1100px, 94vw)" :close-on-click-modal="false" :before-close="beforeEditorClose" destroy-on-close>
|
||||||
|
<template #header>
|
||||||
|
<div class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ form.id ? '编辑并生成新版本' : '新增区域配置' }}</strong>
|
||||||
|
<span v-if="form.id">当前 v{{ form.version }};保存后旧版本仍可追溯</span>
|
||||||
|
</div>
|
||||||
|
<el-tag v-if="form.needsRecalibration" type="danger">需要重新校准</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="form.needsRecalibration"
|
||||||
|
title="绑定的 Profile、分辨率或编码已变化。请重新选择码流并确认画面坐标后保存新版本。"
|
||||||
|
type="warning"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="dialog-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-form ref="editorForm" :model="form" :rules="rules" label-width="110px" @change="dirty = true">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="配置名称" prop="name">
|
||||||
|
<el-input v-model.trim="form.name" maxlength="128" show-word-limit placeholder="例如:操场北侧危险区域" @input="dirty = true" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="配置类型" prop="kind">
|
||||||
|
<el-select v-model="form.kind" :disabled="Boolean(form.id)" @change="handleKindChange">
|
||||||
|
<el-option label="危险区域" value="polygon" />
|
||||||
|
<el-option label="方向警戒线" value="direction_line" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备与码流" prop="routeId" class="route-field">
|
||||||
|
<el-select
|
||||||
|
v-model="form.routeId"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
:remote-method="searchRoutes"
|
||||||
|
:loading="routesLoading"
|
||||||
|
placeholder="输入设备、位置或码流名称"
|
||||||
|
@change="handleRouteChange"
|
||||||
|
>
|
||||||
|
<el-option v-for="route in routeOptions" :key="route.id" :label="routeLabel(route)" :value="route.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="form.kind === 'direction_line'" label="警戒方向" prop="direction">
|
||||||
|
<el-select v-model="form.direction" @change="dirty = true">
|
||||||
|
<el-option label="从起点到终点" value="forward" />
|
||||||
|
<el-option label="从终点到起点" value="reverse" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="启用配置">
|
||||||
|
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" @change="dirty = true" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form-item label="绑定规格" class="profile-summary">
|
||||||
|
<span>{{ selectedProfileSummary }}</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="绘制区域" prop="points">
|
||||||
|
<SenseGeometryEditor
|
||||||
|
:model-value="form.points"
|
||||||
|
:kind="form.kind"
|
||||||
|
:direction="form.direction"
|
||||||
|
:player-url="preview.playerUrl"
|
||||||
|
:disabled="!form.routeId"
|
||||||
|
@update:model-value="handlePointsChange"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="requestEditorClose">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="save">保存新版本</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="versionsOpen" title="版本记录" width="min(900px, 92vw)">
|
||||||
|
<el-table v-loading="versionsLoading" :data="versions" border>
|
||||||
|
<el-table-column label="版本" width="80">
|
||||||
|
<template #default="scope">v{{ scope.row.version }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="name" label="名称" min-width="180" />
|
||||||
|
<el-table-column label="绑定规格" min-width="190">
|
||||||
|
<template #default="scope">{{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="scope">{{ scope.row.enabled ? '已启用' : '已停用' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="坐标点" width="90">
|
||||||
|
<template #default="scope">{{ scope.row.points.length }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createdAt" label="创建时间" min-width="170">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createAreaConfiguration, listAreaConfigurations, listAreaVersions, updateAreaConfiguration } from '@/api/sense/area'
|
||||||
|
import { createLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||||
|
import SenseGeometryEditor from '@/components/sense/geometry-editor'
|
||||||
|
import { buildAreaPayload, validateAreaForm } from './areaPayload'
|
||||||
|
|
||||||
|
const emptyForm = () => ({ id: '', name: '', kind: 'polygon', routeId: '', points: [], direction: '', enabled: true, version: 0, needsRecalibration: false })
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SenseArea',
|
||||||
|
components: { SenseGeometryEditor },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
items: [],
|
||||||
|
total: 0,
|
||||||
|
queryParams: { keyword: '', kind: '', recalibrationState: '', pageIndex: 1, pageSize: 10 },
|
||||||
|
editorOpen: false,
|
||||||
|
saving: false,
|
||||||
|
dirty: false,
|
||||||
|
form: emptyForm(),
|
||||||
|
formError: '',
|
||||||
|
rules: {
|
||||||
|
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
|
||||||
|
kind: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
|
||||||
|
routeId: [{ required: true, message: '请选择设备与视频码流', trigger: 'change' }]
|
||||||
|
},
|
||||||
|
routeOptions: [],
|
||||||
|
routesLoading: false,
|
||||||
|
preview: {},
|
||||||
|
versionsOpen: false,
|
||||||
|
versionsLoading: false,
|
||||||
|
versions: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
selectedRoute() {
|
||||||
|
return this.routeOptions.find(route => route.id === this.form.routeId) || null
|
||||||
|
},
|
||||||
|
selectedProfileSummary() {
|
||||||
|
if (!this.selectedRoute) return '尚未选择视频码流'
|
||||||
|
return `${this.selectedRoute.deviceName} · ${this.selectedRoute.profileName || this.selectedRoute.profileToken} · ${this.selectedRoute.width} × ${this.selectedRoute.height} · ${this.selectedRoute.encoding}`
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async getList() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const response = await listAreaConfigurations(this.queryParams)
|
||||||
|
this.items = response.data.list || []
|
||||||
|
this.total = response.data.count || 0
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleQuery() {
|
||||||
|
this.queryParams.pageIndex = 1
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
resetQuery() {
|
||||||
|
this.$refs.queryForm.resetFields()
|
||||||
|
this.handleQuery()
|
||||||
|
},
|
||||||
|
kindLabel(kind) {
|
||||||
|
return kind === 'polygon' ? '危险区域' : '方向警戒线'
|
||||||
|
},
|
||||||
|
profileLabel(item) {
|
||||||
|
return `${item.profileName || item.profileToken} · ${item.profileWidth} × ${item.profileHeight} · ${item.profileEncoding}`
|
||||||
|
},
|
||||||
|
routeLabel(route) {
|
||||||
|
return `${route.deviceName} · ${route.profileName || route.profileToken} · ${route.width}×${route.height}`
|
||||||
|
},
|
||||||
|
async searchRoutes(keyword = '') {
|
||||||
|
this.routesLoading = true
|
||||||
|
try {
|
||||||
|
const response = await listLiveviewRoutes({ keyword, pageIndex: 1, pageSize: 50 })
|
||||||
|
const routes = response.data.list || []
|
||||||
|
const selected = this.routeOptions.find(route => route.id === this.form.routeId)
|
||||||
|
this.routeOptions = selected && !routes.some(route => route.id === selected.id) ? [selected, ...routes] : routes
|
||||||
|
} finally {
|
||||||
|
this.routesLoading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async openCreate() {
|
||||||
|
this.form = emptyForm()
|
||||||
|
this.formError = ''
|
||||||
|
this.preview = {}
|
||||||
|
this.dirty = false
|
||||||
|
this.editorOpen = true
|
||||||
|
await this.searchRoutes('')
|
||||||
|
},
|
||||||
|
async openEdit(item) {
|
||||||
|
this.form = { id: item.id, name: item.name, kind: item.kind, routeId: item.routeId, points: item.points.map(point => ({ ...point })), direction: item.direction, enabled: item.enabled, version: item.version, needsRecalibration: item.needsRecalibration }
|
||||||
|
this.routeOptions = [{ id: item.routeId, deviceName: item.deviceName, profileName: item.profileName, profileToken: item.profileToken, width: item.profileWidth, height: item.profileHeight, encoding: item.profileEncoding }]
|
||||||
|
this.formError = ''
|
||||||
|
this.preview = {}
|
||||||
|
this.dirty = false
|
||||||
|
this.editorOpen = true
|
||||||
|
if (item.routeId) await this.openPreview()
|
||||||
|
},
|
||||||
|
handleKindChange() {
|
||||||
|
this.form.points = []
|
||||||
|
this.form.direction = this.form.kind === 'direction_line' ? 'forward' : ''
|
||||||
|
this.dirty = true
|
||||||
|
this.formError = ''
|
||||||
|
},
|
||||||
|
async handleRouteChange() {
|
||||||
|
this.form.points = []
|
||||||
|
this.dirty = true
|
||||||
|
this.formError = ''
|
||||||
|
await this.openPreview()
|
||||||
|
},
|
||||||
|
handlePointsChange(points) {
|
||||||
|
this.form.points = points
|
||||||
|
this.dirty = true
|
||||||
|
this.formError = ''
|
||||||
|
},
|
||||||
|
async openPreview() {
|
||||||
|
this.preview = {}
|
||||||
|
if (!this.form.routeId) return
|
||||||
|
try {
|
||||||
|
const response = await createLiveviewSession(this.form.routeId)
|
||||||
|
this.preview = response.data || {}
|
||||||
|
} catch (error) {
|
||||||
|
this.formError = '实时画面暂不可用;请先到“实时监看”确认该码流。'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async save() {
|
||||||
|
this.formError = validateAreaForm(this.form)
|
||||||
|
if (this.formError) return
|
||||||
|
try {
|
||||||
|
await this.$refs.editorForm.validate()
|
||||||
|
} catch (error) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.saving = true
|
||||||
|
try {
|
||||||
|
const payload = buildAreaPayload(this.form)
|
||||||
|
if (this.form.id) await updateAreaConfiguration(this.form.id, payload)
|
||||||
|
else await createAreaConfiguration(payload)
|
||||||
|
this.dirty = false
|
||||||
|
this.editorOpen = false
|
||||||
|
this.$message.success(this.form.id ? '已保存新版本' : '区域配置已创建')
|
||||||
|
await this.getList()
|
||||||
|
} catch (error) {
|
||||||
|
this.formError = error.message || '保存失败,请刷新后重试'
|
||||||
|
} finally {
|
||||||
|
this.saving = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
requestEditorClose() {
|
||||||
|
this.beforeEditorClose(() => { this.editorOpen = false })
|
||||||
|
},
|
||||||
|
beforeEditorClose(done) {
|
||||||
|
if (!this.dirty || this.saving) {
|
||||||
|
done()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.$confirm('尚未保存的绘制内容将丢失,确定关闭吗?', '放弃未保存内容', { type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' }).then(done).catch(() => {})
|
||||||
|
},
|
||||||
|
async openVersions(item) {
|
||||||
|
this.versionsOpen = true
|
||||||
|
this.versionsLoading = true
|
||||||
|
this.versions = []
|
||||||
|
try {
|
||||||
|
const response = await listAreaVersions(item.id)
|
||||||
|
this.versions = response.data || []
|
||||||
|
} finally {
|
||||||
|
this.versionsLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header,
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p,
|
||||||
|
.dialog-header span,
|
||||||
|
.muted-text,
|
||||||
|
.profile-summary span {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form,
|
||||||
|
.empty-alert,
|
||||||
|
.dialog-alert {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-input) {
|
||||||
|
width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-select) {
|
||||||
|
width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-field {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-field :deep(.el-select),
|
||||||
|
.form-grid :deep(.el-select) {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-summary {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin: 12px 0 0 110px;
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.page-header,
|
||||||
|
.dialog-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.route-field {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-error {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export function createDevicePayload(form) {
|
||||||
|
return {
|
||||||
|
name: form.name,
|
||||||
|
location: form.location,
|
||||||
|
modality: form.modality,
|
||||||
|
capabilities: [...form.capabilities]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateDevicePayload(form) {
|
||||||
|
return {
|
||||||
|
name: form.name,
|
||||||
|
location: form.location,
|
||||||
|
capabilities: [...form.capabilities],
|
||||||
|
version: form.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function credentialPayload(form) {
|
||||||
|
return {
|
||||||
|
onvifUsername: form.onvifUsername,
|
||||||
|
onvifPassword: form.onvifPassword,
|
||||||
|
rtspSameAsOnvif: form.rtspSameAsOnvif,
|
||||||
|
rtspUsername: form.rtspSameAsOnvif ? '' : form.rtspUsername,
|
||||||
|
rtspPassword: form.rtspSameAsOnvif ? '' : form.rtspPassword,
|
||||||
|
version: form.version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function disableDevicePayload(version) {
|
||||||
|
return { version }
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<template #wrapper>
|
||||||
|
<el-card class="box-card">
|
||||||
|
<el-form ref="queryForm" :model="queryParams" :inline="true" class="search-form">
|
||||||
|
<el-form-item label="设备信息" prop="keyword">
|
||||||
|
<el-input v-model="queryParams.keyword" placeholder="名称或安装位置" clearable size="small" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备类型" prop="modality">
|
||||||
|
<el-select v-model="queryParams.modality" placeholder="全部类型" clearable size="small">
|
||||||
|
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-select v-model="queryParams.status" placeholder="全部状态" clearable size="small">
|
||||||
|
<el-option label="待接入" value="pending" />
|
||||||
|
<el-option label="已接入" value="active" />
|
||||||
|
<el-option label="已停用" value="disabled" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" size="small" :icon="Search" @click="handleQuery">搜索</el-button>
|
||||||
|
<el-button size="small" :icon="Refresh" @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<div class="toolbar mb8">
|
||||||
|
<el-button v-permisaction="['sense:device:add']" type="primary" size="small" :icon="Plus" @click="handleAdd">新增设备</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="deviceList" border stripe>
|
||||||
|
<el-table-column label="设备名称" min-width="150" prop="name" />
|
||||||
|
<el-table-column label="安装位置" min-width="180" prop="location" show-overflow-tooltip />
|
||||||
|
<el-table-column label="设备类型" width="110" align="center">
|
||||||
|
<template #default="scope">{{ modalityLabel(scope.row.modality) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="能力" min-width="130">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag v-for="capability in scope.row.capabilities" :key="capability" size="small" class="capability-tag">{{ modalityLabel(capability) }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="接入能力" width="130" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.adapterStatus === 'ready' ? 'success' : 'warning'">
|
||||||
|
{{ scope.row.adapterStatus === 'ready' ? '可接入' : '适配器未就绪' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="凭据" width="120" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.onvifCredentialConfigured ? 'success' : 'info'">
|
||||||
|
{{ scope.row.onvifCredentialConfigured ? '已配置' : '未配置' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="scope.row.status === 'active' ? 'success' : scope.row.status === 'disabled' ? 'info' : 'warning'">{{ scope.row.status === 'active' ? '已接入' : scope.row.status === 'disabled' ? '已停用' : '待接入' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="版本" width="80" align="center" prop="version" />
|
||||||
|
<el-table-column label="更新时间" width="180" align="center">
|
||||||
|
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="230" align="center" fixed="right" class-name="small-padding fixed-width">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button v-permisaction="['sense:device:edit']" type="primary" link size="small" :icon="Edit" @click="handleUpdate(scope.row)">编辑</el-button>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<el-button v-permisaction="['sense:device:credential']" type="primary" link size="small" :icon="Key" @click="handleCredential(scope.row)">更新凭据</el-button>
|
||||||
|
<el-divider direction="vertical" />
|
||||||
|
<el-button v-if="scope.row.status !== 'disabled'" v-permisaction="['sense:device:disable']" type="danger" link size="small" @click="handleDisable(scope.row)">停用</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination v-show="total > 0" v-model:current-page="queryParams.pageIndex" v-model:page-size="queryParams.pageSize" :total="total" @pagination="getList" />
|
||||||
|
|
||||||
|
<el-dialog v-model="deviceOpen" :title="deviceTitle" width="620px" :close-on-click-modal="false">
|
||||||
|
<el-form ref="deviceForm" :model="deviceForm" :rules="deviceRules" label-width="100px">
|
||||||
|
<el-form-item label="设备名称" prop="name">
|
||||||
|
<el-input v-model="deviceForm.name" maxlength="128" show-word-limit placeholder="例如:东门摄像机" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="安装位置" prop="location">
|
||||||
|
<el-input v-model="deviceForm.location" maxlength="255" show-word-limit placeholder="例如:教学楼一楼东门" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备类型" prop="modality">
|
||||||
|
<el-select v-model="deviceForm.modality" :disabled="Boolean(deviceForm.id)" @change="handleModalityChange">
|
||||||
|
<el-option v-for="item in modalityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
<span v-if="deviceForm.modality !== 'video'" class="form-tip">该类型尚未提供接入适配器,将显示“适配器未就绪”。</span>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="设备能力" prop="capabilities">
|
||||||
|
<el-checkbox-group v-model="deviceForm.capabilities">
|
||||||
|
<el-checkbox v-for="item in modalityOptions" :key="item.value" :label="item.value">{{ item.label }}</el-checkbox>
|
||||||
|
</el-checkbox-group>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="submitDevice">确定</el-button>
|
||||||
|
<el-button @click="deviceOpen = false">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="credentialOpen" title="更新摄像头凭据" width="620px" :close-on-click-modal="false" @closed="resetCredential">
|
||||||
|
<el-alert title="凭据只可写入,保存后不会回显。更新成功会请求后续接入流程重新验证。" type="warning" :closable="false" show-icon class="credential-alert" />
|
||||||
|
<el-form ref="credentialForm" :model="credentialForm" :rules="credentialRules" label-width="120px" autocomplete="off">
|
||||||
|
<el-form-item label="ONVIF 用户名" prop="onvifUsername">
|
||||||
|
<el-input v-model="credentialForm.onvifUsername" maxlength="255" autocomplete="off" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="ONVIF 密码" prop="onvifPassword">
|
||||||
|
<el-input v-model="credentialForm.onvifPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="RTSP 凭据">
|
||||||
|
<el-checkbox v-model="credentialForm.rtspSameAsOnvif">与 ONVIF 相同</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
|
<template v-if="!credentialForm.rtspSameAsOnvif">
|
||||||
|
<el-form-item label="RTSP 用户名" prop="rtspUsername">
|
||||||
|
<el-input v-model="credentialForm.rtspUsername" maxlength="255" autocomplete="off" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="RTSP 密码" prop="rtspPassword">
|
||||||
|
<el-input v-model="credentialForm.rtspPassword" type="password" show-password maxlength="1024" autocomplete="new-password" />
|
||||||
|
</el-form-item>
|
||||||
|
</template>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="submitCredential">安全保存</el-button>
|
||||||
|
<el-button @click="credentialOpen = false">取消</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { Edit, Key, Plus, Refresh, Search } from '@element-plus/icons-vue'
|
||||||
|
import {
|
||||||
|
addDevice,
|
||||||
|
disableDevice,
|
||||||
|
getDevice,
|
||||||
|
listDevices,
|
||||||
|
updateDevice,
|
||||||
|
updateDeviceCredentials
|
||||||
|
} from '@/api/sense/device'
|
||||||
|
import {
|
||||||
|
createDevicePayload,
|
||||||
|
credentialPayload,
|
||||||
|
disableDevicePayload,
|
||||||
|
updateDevicePayload
|
||||||
|
} from './devicePayload'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SenseDeviceManage',
|
||||||
|
setup() {
|
||||||
|
return { Edit, Key, Plus, Refresh, Search }
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
total: 0,
|
||||||
|
deviceList: [],
|
||||||
|
deviceOpen: false,
|
||||||
|
credentialOpen: false,
|
||||||
|
deviceTitle: '',
|
||||||
|
queryParams: { pageIndex: 1, pageSize: 10, keyword: '', modality: '', status: '' },
|
||||||
|
modalityOptions: [
|
||||||
|
{ value: 'video', label: '视频设备' },
|
||||||
|
{ value: 'radar', label: '雷达' },
|
||||||
|
{ value: 'contact', label: '门磁' },
|
||||||
|
{ value: 'button', label: '按钮' },
|
||||||
|
{ value: 'wearable', label: '穿戴设备' },
|
||||||
|
{ value: 'other', label: '其他' }
|
||||||
|
],
|
||||||
|
deviceForm: {},
|
||||||
|
credentialForm: {},
|
||||||
|
deviceRules: {
|
||||||
|
name: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
|
||||||
|
modality: [{ required: true, message: '请选择设备类型', trigger: 'change' }],
|
||||||
|
capabilities: [{ type: 'array', required: true, min: 1, message: '至少选择一项设备能力', trigger: 'change' }]
|
||||||
|
},
|
||||||
|
credentialRules: {
|
||||||
|
onvifUsername: [{ required: true, message: 'ONVIF 用户名不能为空', trigger: 'blur' }],
|
||||||
|
onvifPassword: [{ required: true, message: 'ONVIF 密码不能为空', trigger: 'blur' }],
|
||||||
|
rtspUsername: [{ required: true, message: 'RTSP 用户名不能为空', trigger: 'blur' }],
|
||||||
|
rtspPassword: [{ required: true, message: 'RTSP 密码不能为空', trigger: 'blur' }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.resetDevice()
|
||||||
|
this.resetCredential()
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
getList() {
|
||||||
|
this.loading = true
|
||||||
|
listDevices(this.queryParams).then(response => {
|
||||||
|
this.deviceList = response.data.list
|
||||||
|
this.total = response.data.count
|
||||||
|
}).finally(() => {
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
modalityLabel(value) {
|
||||||
|
return this.modalityOptions.find(item => item.value === value)?.label || value
|
||||||
|
},
|
||||||
|
resetDevice() {
|
||||||
|
this.deviceForm = { id: undefined, name: '', location: '', modality: 'video', capabilities: ['video'], version: undefined }
|
||||||
|
this.$nextTick(() => this.resetForm('deviceForm'))
|
||||||
|
},
|
||||||
|
resetCredential() {
|
||||||
|
this.credentialForm = {
|
||||||
|
id: undefined,
|
||||||
|
onvifUsername: '',
|
||||||
|
onvifPassword: '',
|
||||||
|
rtspSameAsOnvif: true,
|
||||||
|
rtspUsername: '',
|
||||||
|
rtspPassword: '',
|
||||||
|
version: undefined
|
||||||
|
}
|
||||||
|
this.$nextTick(() => this.resetForm('credentialForm'))
|
||||||
|
},
|
||||||
|
handleQuery() {
|
||||||
|
this.queryParams.pageIndex = 1
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
resetQuery() {
|
||||||
|
this.resetForm('queryForm')
|
||||||
|
this.handleQuery()
|
||||||
|
},
|
||||||
|
handleAdd() {
|
||||||
|
this.resetDevice()
|
||||||
|
this.deviceTitle = '新增设备'
|
||||||
|
this.deviceOpen = true
|
||||||
|
},
|
||||||
|
handleUpdate(row) {
|
||||||
|
this.resetDevice()
|
||||||
|
getDevice(row.id).then(response => {
|
||||||
|
this.deviceForm = { ...response.data, capabilities: [...response.data.capabilities] }
|
||||||
|
this.deviceTitle = '编辑设备'
|
||||||
|
this.deviceOpen = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleModalityChange(value) {
|
||||||
|
this.deviceForm.capabilities = [value]
|
||||||
|
},
|
||||||
|
submitDevice() {
|
||||||
|
this.$refs.deviceForm.validate(valid => {
|
||||||
|
if (!valid) return
|
||||||
|
const request = this.deviceForm.id
|
||||||
|
? updateDevice(this.deviceForm.id, updateDevicePayload(this.deviceForm))
|
||||||
|
: addDevice(createDevicePayload(this.deviceForm))
|
||||||
|
request.then(response => {
|
||||||
|
this.msgSuccess(response.msg)
|
||||||
|
this.deviceOpen = false
|
||||||
|
this.getList()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleCredential(row) {
|
||||||
|
this.resetCredential()
|
||||||
|
this.credentialForm.id = row.id
|
||||||
|
this.credentialForm.version = row.version
|
||||||
|
this.credentialOpen = true
|
||||||
|
},
|
||||||
|
submitCredential() {
|
||||||
|
this.$refs.credentialForm.validate(valid => {
|
||||||
|
if (!valid) return
|
||||||
|
updateDeviceCredentials(this.credentialForm.id, credentialPayload(this.credentialForm)).then(response => {
|
||||||
|
this.msgSuccess(response.msg)
|
||||||
|
this.credentialOpen = false
|
||||||
|
this.getList()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
},
|
||||||
|
handleDisable(row) {
|
||||||
|
this.$confirm(`停用“${row.name}”后,后续接入流程不会继续使用该设备。是否继续?`, '停用设备', {
|
||||||
|
confirmButtonText: '确认停用', cancelButtonText: '取消', type: 'warning'
|
||||||
|
}).then(() => disableDevice(row.id, disableDevicePayload(row.version))).then(response => {
|
||||||
|
this.msgSuccess(response.msg)
|
||||||
|
this.getList()
|
||||||
|
}).catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.capability-tag {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
.form-tip {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
color: #909399;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.credential-alert {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<template #wrapper>
|
||||||
|
<el-card class="box-card">
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h3>实时监看</h3>
|
||||||
|
<p>按设备选择一路已验证视频;关闭窗口后不会继续占用播放器连接。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="queryForm" :model="queryParams" inline label-position="left" class="search-form" @submit.prevent>
|
||||||
|
<el-form-item label="设备或位置" prop="keyword">
|
||||||
|
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入设备、位置或码流名称" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||||
|
<el-button @click="resetQuery">重置</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="!loading && total === 0"
|
||||||
|
title="没有可监看的视频。请先在“视频接入”完成验证,再到“视频服务”确认路径状态。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
class="empty-alert"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-table v-loading="loading" :data="routes" border stripe>
|
||||||
|
<el-table-column prop="deviceName" label="设备" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="deviceLocation" label="安装位置" min-width="160" show-overflow-tooltip>
|
||||||
|
<template #default="scope">{{ scope.row.deviceLocation || '未填写' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="码流" min-width="170">
|
||||||
|
<template #default="scope">
|
||||||
|
<div>{{ profileLabel(scope.row) }}</div>
|
||||||
|
<small class="muted-text">{{ resolutionLabel(scope.row) }}</small>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="120">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-tag :type="routeStatus(scope.row.actual).type" size="small">{{ routeStatus(scope.row.actual).label }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="readers" label="当前观看" width="100" />
|
||||||
|
<el-table-column prop="detail" label="说明" min-width="220" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="110" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button v-permisaction="['sense:liveview:view']" type="primary" link @click="handleWatch(scope.row)">实时查看</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="total > 0"
|
||||||
|
v-model:page="queryParams.pageIndex"
|
||||||
|
v-model:limit="queryParams.pageSize"
|
||||||
|
:total="total"
|
||||||
|
@pagination="getList"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-dialog v-model="playerOpen" width="min(1000px, 92vw)" :close-on-click-modal="false" destroy-on-close @closed="closePlayer">
|
||||||
|
<template #header>
|
||||||
|
<div class="dialog-header">
|
||||||
|
<div>
|
||||||
|
<strong>{{ currentRoute.deviceName || '实时视频' }}</strong>
|
||||||
|
<span>{{ profileLabel(currentRoute) }} · {{ currentRoute.deviceLocation || '未填写位置' }}</span>
|
||||||
|
</div>
|
||||||
|
<el-tag :type="playbackStatusType(playerState)">{{ playbackStatusLabel(playerState) }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<SenseVideoPlayer
|
||||||
|
v-if="playerOpen"
|
||||||
|
:state="playerState"
|
||||||
|
:detail="playerDetail"
|
||||||
|
:player-url="session.playerUrl"
|
||||||
|
:retrying="retrying"
|
||||||
|
@retry="openSession"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-descriptions :column="3" border class="player-details">
|
||||||
|
<el-descriptions-item label="设备">{{ currentRoute.deviceName || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="码流">{{ profileLabel(currentRoute) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="分辨率">{{ resolutionLabel(currentRoute) }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="处理建议" :span="3">{{ playbackStatusDetail(playerState, playerDetail) }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="playerOpen = false">关闭</el-button>
|
||||||
|
<el-button type="primary" :loading="retrying" @click="openSession">重新连接</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { createLiveviewSession, getLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||||
|
import SenseVideoPlayer from '@/components/sense/video-player'
|
||||||
|
import { playbackStatusDetail, playbackStatusLabel, playbackStatusType, routeStatus } from './playbackStatus'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'SenseLiveview',
|
||||||
|
components: { SenseVideoPlayer },
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
routes: [],
|
||||||
|
total: 0,
|
||||||
|
queryParams: { keyword: '', pageIndex: 1, pageSize: 10 },
|
||||||
|
playerOpen: false,
|
||||||
|
currentRoute: {},
|
||||||
|
session: {},
|
||||||
|
playerState: 'loading',
|
||||||
|
playerDetail: '',
|
||||||
|
retrying: false,
|
||||||
|
pollTimer: null,
|
||||||
|
timeoutTimer: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
beforeUnmount() {
|
||||||
|
this.stopTimers()
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
playbackStatusDetail,
|
||||||
|
playbackStatusLabel,
|
||||||
|
playbackStatusType,
|
||||||
|
routeStatus,
|
||||||
|
profileLabel(route) {
|
||||||
|
if (!route || !route.id) return '—'
|
||||||
|
const kind = { main: '主码流', sub: '子码流', other: '其他码流' }[route.profileKind] || '码流'
|
||||||
|
return route.profileName ? `${kind}(${route.profileName})` : kind
|
||||||
|
},
|
||||||
|
resolutionLabel(route) {
|
||||||
|
if (!route || !route.width || !route.height) return '未取得分辨率'
|
||||||
|
return `${route.width} × ${route.height}${route.encoding ? ` · ${route.encoding}` : ''}`
|
||||||
|
},
|
||||||
|
async getList() {
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
const response = await listLiveviewRoutes(this.queryParams)
|
||||||
|
this.routes = response.data.list || []
|
||||||
|
this.total = response.data.count || 0
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleQuery() {
|
||||||
|
this.queryParams.pageIndex = 1
|
||||||
|
this.getList()
|
||||||
|
},
|
||||||
|
resetQuery() {
|
||||||
|
this.$refs.queryForm.resetFields()
|
||||||
|
this.handleQuery()
|
||||||
|
},
|
||||||
|
handleWatch(route) {
|
||||||
|
this.currentRoute = { ...route }
|
||||||
|
this.playerOpen = true
|
||||||
|
this.openSession()
|
||||||
|
},
|
||||||
|
async openSession() {
|
||||||
|
if (!this.currentRoute.id || this.retrying) return
|
||||||
|
this.stopTimers()
|
||||||
|
this.retrying = true
|
||||||
|
this.playerState = 'loading'
|
||||||
|
this.playerDetail = ''
|
||||||
|
this.session = {}
|
||||||
|
try {
|
||||||
|
const response = await createLiveviewSession(this.currentRoute.id)
|
||||||
|
this.session = response.data
|
||||||
|
this.playerState = response.data.status
|
||||||
|
this.playerDetail = response.data.detail
|
||||||
|
this.startPolling()
|
||||||
|
} catch (error) {
|
||||||
|
this.playerState = 'offline'
|
||||||
|
this.playerDetail = error.message || ''
|
||||||
|
} finally {
|
||||||
|
this.retrying = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
startPolling() {
|
||||||
|
this.pollTimer = window.setInterval(this.refreshSession, 2000)
|
||||||
|
this.timeoutTimer = window.setTimeout(() => {
|
||||||
|
if (['loading', 'waiting'].includes(this.playerState)) {
|
||||||
|
this.playerState = 'timeout'
|
||||||
|
this.playerDetail = ''
|
||||||
|
this.stopTimers()
|
||||||
|
}
|
||||||
|
}, 20000)
|
||||||
|
},
|
||||||
|
async refreshSession() {
|
||||||
|
if (!this.session.id || !this.playerOpen) return
|
||||||
|
try {
|
||||||
|
const response = await getLiveviewSession(this.session.id)
|
||||||
|
this.session = response.data
|
||||||
|
this.playerState = response.data.status
|
||||||
|
this.playerDetail = response.data.detail
|
||||||
|
if (this.playerState === 'ready') {
|
||||||
|
window.clearTimeout(this.timeoutTimer)
|
||||||
|
this.timeoutTimer = null
|
||||||
|
} else if (!['loading', 'waiting'].includes(this.playerState)) {
|
||||||
|
this.stopTimers()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.playerState = 'expired'
|
||||||
|
this.playerDetail = ''
|
||||||
|
this.stopTimers()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopTimers() {
|
||||||
|
window.clearInterval(this.pollTimer)
|
||||||
|
window.clearTimeout(this.timeoutTimer)
|
||||||
|
this.pollTimer = null
|
||||||
|
this.timeoutTimer = null
|
||||||
|
},
|
||||||
|
closePlayer() {
|
||||||
|
this.stopTimers()
|
||||||
|
this.session = {}
|
||||||
|
this.currentRoute = {}
|
||||||
|
this.playerState = 'loading'
|
||||||
|
this.playerDetail = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header,
|
||||||
|
.dialog-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header p,
|
||||||
|
.dialog-header span,
|
||||||
|
.muted-text {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header > div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form,
|
||||||
|
.empty-alert,
|
||||||
|
.player-details {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form :deep(.el-input) {
|
||||||
|
width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted-text {
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.search-form :deep(.el-input) {
|
||||||
|
width: min(280px, 70vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-header {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
const labels = {
|
||||||
|
loading: '正在连接',
|
||||||
|
waiting: '等待视频',
|
||||||
|
ready: '播放正常',
|
||||||
|
authentication_failed: '摄像头认证失败',
|
||||||
|
stream_not_found: '未找到视频流',
|
||||||
|
service_unavailable: '视频服务不可用',
|
||||||
|
timeout: '连接超时',
|
||||||
|
expired: '播放会话已过期',
|
||||||
|
offline: '视频已断开',
|
||||||
|
stopped: '视频已停止'
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = {
|
||||||
|
loading: '正在建立短期播放会话,请稍候。',
|
||||||
|
waiting: '播放器已连接,正在等待摄像头开始传输画面。',
|
||||||
|
ready: '摄像头视频正在传输。',
|
||||||
|
authentication_failed: '请到“设备管理”更新摄像头账号或密码,再重新验证视频接入。',
|
||||||
|
stream_not_found: '请到“视频服务”执行对账,确认媒体路径已经恢复。',
|
||||||
|
service_unavailable: '请到“视频服务”检查 MediaMTX 进程和端口配置。',
|
||||||
|
timeout: '20 秒内未收到画面,请检查摄像头网络后重新连接。',
|
||||||
|
expired: '短期播放会话已失效,请重新连接。',
|
||||||
|
offline: '无法取得最新播放状态,请检查网络后重新连接。',
|
||||||
|
stopped: '该视频路径已停止,请先到“视频服务”恢复。'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playbackStatusLabel(value) {
|
||||||
|
return labels[value] || '暂时无法播放'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playbackStatusDetail(value, detail) {
|
||||||
|
return detail || details[value] || details.offline
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playbackStatusType(value) {
|
||||||
|
if (value === 'ready') return 'success'
|
||||||
|
if (value === 'loading' || value === 'waiting') return 'warning'
|
||||||
|
if (value === 'stopped') return 'info'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function routeStatus(value) {
|
||||||
|
if (value === 'ready') return { label: '可观看', type: 'success' }
|
||||||
|
if (value === 'waiting') return { label: '等待观看', type: 'warning' }
|
||||||
|
return { label: '需要处理', type: 'danger' }
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<template>
|
||||||
|
<BasicLayout>
|
||||||
|
<template #wrapper>
|
||||||
|
<el-card class="box-card">
|
||||||
|
<div class="page-header">
|
||||||
|
<div><h3>视频服务</h3><p>查看 MediaMTX 进程、摄像头拉流路径和自动重试状态。</p></div>
|
||||||
|
<el-button v-permisaction="['sense:media:reconcile']" type="primary" :loading="reconciling" @click="handleReconcileAll">立即对账</el-button>
|
||||||
|
</div>
|
||||||
|
<el-alert title="视频服务故障不会删除已验证的设备和码流;外部进程受孤儿安全闸保护,不会被 Sense 停止。" type="info" :closable="false" show-icon />
|
||||||
|
<el-descriptions :column="3" border class="process-state">
|
||||||
|
<el-descriptions-item label="进程状态"><el-tag :type="mediaStatusType(process.phase)">{{ processLabel(process.phase) }}</el-tag></el-descriptions-item>
|
||||||
|
<el-descriptions-item label="进程归属">{{ process.owned ? 'Sense 启动' : process.external ? '外部启动(受保护)' : '未运行' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="进程号">{{ process.pid || '—' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="说明" :span="3">{{ process.detail || '尚未取得状态' }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<el-table v-loading="loading" :data="routes" border stripe class="route-table">
|
||||||
|
<el-table-column prop="path" label="媒体路径" min-width="190" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="profileToken" label="Profile" min-width="120" show-overflow-tooltip />
|
||||||
|
<el-table-column label="期望状态" width="100"><template #default="scope"><el-tag size="small" type="info">{{ scope.row.desired === 'running' ? '运行' : '停止' }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column label="实际状态" width="120"><template #default="scope"><el-tag :type="mediaStatusType(scope.row.actual)" size="small">{{ mediaStatusLabel(scope.row.actual) }}</el-tag></template></el-table-column>
|
||||||
|
<el-table-column prop="readers" label="观看数" width="90" />
|
||||||
|
<el-table-column label="下次重试" min-width="165"><template #default="scope">{{ scope.row.nextRetryAt ? parseTime(scope.row.nextRetryAt) : '—' }}</template></el-table-column>
|
||||||
|
<el-table-column prop="detail" label="说明" min-width="210" show-overflow-tooltip />
|
||||||
|
<el-table-column label="操作" width="150" fixed="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<el-button v-permisaction="['sense:media:reconcile']" type="primary" link @click="handleRoute(scope.row)">对账</el-button>
|
||||||
|
<el-button v-if="scope.row.desired === 'running'" v-permisaction="['sense:media:stop']" type="danger" link @click="handleStop(scope.row)">停止</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-empty v-if="!loading && routes.length === 0" description="暂无媒体路径;请先完成摄像头接入验证" />
|
||||||
|
</el-card>
|
||||||
|
</template>
|
||||||
|
</BasicLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import { getMediaProcess, listMediaRoutes, reconcileAllMedia, reconcileMediaRoute, stopMediaRoute } from '@/api/sense/media'
|
||||||
|
import { mediaStatusLabel, mediaStatusType } from './mediaStatus'
|
||||||
|
|
||||||
|
const routes = ref([]); const process = ref({}); const loading = ref(false); const reconciling = ref(false)
|
||||||
|
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||||
|
function processLabel(value) { return ({ running: '运行中', starting: '启动中', stopped: '已停止', not_configured: '未配置', configuration_failed: '配置错误', failed: '启动失败' })[value] || value || '未知' }
|
||||||
|
async function load() { loading.value = true; try { const [routeResponse, processResponse] = await Promise.all([listMediaRoutes(), getMediaProcess()]); const routePayload = unwrap(routeResponse); routes.value = routePayload?.list || []; process.value = unwrap(processResponse) || {} } catch (error) { ElMessage.error(error.message || '视频服务状态加载失败') } finally { loading.value = false } }
|
||||||
|
async function handleReconcileAll() { reconciling.value = true; try { await reconcileAllMedia(); ElMessage.success('对账完成'); await load() } catch (error) { ElMessage.warning(error.message || '对账未完成,请查看路径状态') } finally { reconciling.value = false } }
|
||||||
|
async function handleRoute(route) { try { await reconcileMediaRoute(route.id); await load() } catch (error) { ElMessage.warning(error.message || '该路径对账失败') } }
|
||||||
|
async function handleStop(route) { try { await ElMessageBox.confirm('停止后该路径将不再拉取摄像头视频,设备和 Profile 不会被删除。', '停止媒体路径', { type: 'warning' }); await stopMediaRoute(route.id); ElMessage.success('路径已停止'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '停止失败') } }
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.process-state{margin-top:18px}.route-table{margin-top:18px}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function mediaStatusLabel(value) {
|
||||||
|
return ({ ready: '拉流正常', waiting: '等待拉流', pending: '等待对账', stopped: '已停止', process_unavailable: '进程不可用', apply_failed: '配置失败', status_unavailable: '状态未知', path_missing: '路径缺失', profile_unavailable: 'Profile 不可用', credential_unavailable: '凭据不可用' })[value] || value || '未知'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mediaStatusType(value) {
|
||||||
|
if (value === 'ready' || value === 'running') return 'success'
|
||||||
|
if (value === 'waiting' || value === 'pending' || value === 'starting') return 'warning'
|
||||||
|
if (value === 'stopped' || value === 'not_configured') return 'info'
|
||||||
|
return 'danger'
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { addressHasCredentials, buildProbePayload } from '@/views/sense/admission/admissionPayload'
|
||||||
|
|
||||||
|
describe('Sense admission payload', () => {
|
||||||
|
it('only sends the approved probe fields', () => {
|
||||||
|
expect(buildProbePayload({ address: ' http://192.0.2.1/onvif ', version: '3', password: 'never-send' })).toEqual({ address: 'http://192.0.2.1/onvif', version: 3 })
|
||||||
|
})
|
||||||
|
it('detects credentials embedded in a URL', () => {
|
||||||
|
expect(addressHasCredentials('http://user:secret@192.0.2.1/onvif')).toBe(true)
|
||||||
|
expect(addressHasCredentials('http://192.0.2.1/onvif')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,56 @@
|
|||||||
|
import {
|
||||||
|
createDevicePayload,
|
||||||
|
credentialPayload,
|
||||||
|
disableDevicePayload,
|
||||||
|
updateDevicePayload
|
||||||
|
} from '@/views/sense/device/devicePayload'
|
||||||
|
|
||||||
|
describe('Sense device payload allowlists', () => {
|
||||||
|
const form = {
|
||||||
|
id: 'read-only-id',
|
||||||
|
name: '东门摄像机',
|
||||||
|
location: '教学楼一楼东门',
|
||||||
|
modality: 'video',
|
||||||
|
capabilities: ['video'],
|
||||||
|
version: 9,
|
||||||
|
adapterStatus: 'ready',
|
||||||
|
unexpected: 'must-not-be-sent'
|
||||||
|
}
|
||||||
|
|
||||||
|
test('create keeps Chinese fields and only writable create fields', () => {
|
||||||
|
expect(createDevicePayload(form)).toEqual({
|
||||||
|
name: '东门摄像机',
|
||||||
|
location: '教学楼一楼东门',
|
||||||
|
modality: 'video',
|
||||||
|
capabilities: ['video']
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('update excludes id, modality and server state while carrying version', () => {
|
||||||
|
expect(updateDevicePayload(form)).toEqual({
|
||||||
|
name: '东门摄像机',
|
||||||
|
location: '教学楼一楼东门',
|
||||||
|
capabilities: ['video'],
|
||||||
|
version: 9
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('credential payload does not send unused RTSP fields', () => {
|
||||||
|
expect(credentialPayload({
|
||||||
|
onvifUsername: 'synthetic-user',
|
||||||
|
onvifPassword: 'synthetic-password',
|
||||||
|
rtspSameAsOnvif: true,
|
||||||
|
rtspUsername: 'must-not-be-sent',
|
||||||
|
rtspPassword: 'must-not-be-sent',
|
||||||
|
version: 9
|
||||||
|
})).toEqual({
|
||||||
|
onvifUsername: 'synthetic-user',
|
||||||
|
onvifPassword: 'synthetic-password',
|
||||||
|
rtspSameAsOnvif: true,
|
||||||
|
rtspUsername: '',
|
||||||
|
rtspPassword: '',
|
||||||
|
version: 9
|
||||||
|
})
|
||||||
|
expect(disableDevicePayload(9)).toEqual({ version: 9 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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('需要处理')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { mediaStatusLabel, mediaStatusType } from '@/views/sense/media/mediaStatus'
|
||||||
|
|
||||||
|
describe('Sense media status presentation', () => {
|
||||||
|
it('uses actionable labels for expected lifecycle states', () => {
|
||||||
|
expect(mediaStatusLabel('waiting')).toBe('等待拉流')
|
||||||
|
expect(mediaStatusLabel('process_unavailable')).toBe('进程不可用')
|
||||||
|
expect(mediaStatusType('ready')).toBe('success')
|
||||||
|
expect(mediaStatusType('apply_failed')).toBe('danger')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Architecture-and-Code-Map
|
wiki_page: Architecture-and-Code-Map
|
||||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||||
wiki_revision: eb1174040657f2064e26b6448ca92e7a93666423
|
wiki_revision: 0ba2909431bd04320a9b31121126b59b1824e3dc
|
||||||
synchronized_at: 2026-08-14T04:09:07Z
|
synchronized_at: 2026-08-15T01:13:02Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 架构与代码地图
|
# 架构与代码地图
|
||||||
@@ -88,6 +88,22 @@ Sense 已由工单 #61 从冻结 go-admin/go-admin-ui 源码建立:后端入
|
|||||||
工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。
|
工单 #64 在该基线上重建 Sense 独立身份能力:`Sense/server/app/admin/apis/identity_bootstrap.go` 提供受外部高熵令牌保护的一次性首位管理员初始化,数据库迁移固定建立 `admin`、`implementation_operator`、`site_admin`、`viewer` 四个角色及最小 Casbin 权限;前端继续复用 go-admin-ui 动态菜单、权限按钮、请求封装与 Layout。仓库仍不提供默认账号、默认密码或可用 JWT 密钥。
|
||||||
|
|
||||||
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
|
Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Token`,后端仅接受标准 Authorization Bearer 或独立的 `sense_session` Cookie,不接受查询参数令牌,也不得与 Bell 共享 JWT 密钥、Cookie 或账户库。登录成功/失败、登出、密码变更和鉴权拒绝写入身份审计;审计内容必须剔除密码、令牌、Cookie、验证码和其他秘密。配置、接口管理等非产品必要路由不注册,即使管理员直接调用也返回 404。
|
||||||
|
|
||||||
|
工单 #65 新增设备台账入口:后端按 `models → dto → service → api → router` 分层位于 `Sense/server/app/sense/device/`,管理路由在 `Sense/server/app/admin/router/sense_device.go`,前端页面位于 `Sense/ui/src/views/sense/device/index.vue`。设备凭据由 `Sense/server/app/sense/credential/` 独立存储和 AES-256-GCM 加密,HTTP 只返回是否已配置,不提供凭据读取接口。
|
||||||
|
|
||||||
|
设备写入采用版本号乐观并发控制;视频设备适配器状态为可接入,雷达、门磁、按钮、穿戴和其他类型明确显示“适配器未就绪”,不得伪装成已接入。`admin`、`implementation_operator`、`site_admin` 可维护设备,`viewer` 只读;停用替代物理删除。
|
||||||
|
|
||||||
|
工单 #66 在 Sense/server/app/sense/onvif/、rtsp/ 与 admission/ 建立视频接入边界:WS-Discovery 只能绑定 SENSE_ONVIF_DISCOVERY_IP 指定的本机网卡,所有 ONVIF、Media XAddr 与 RTSP Stream URI 都必须落在 SENSE_ONVIF_ALLOWED_CIDRS 明确授权的网段。HTTP 客户端禁止代理和重定向,并在每次连接时重新解析、校验和固定目标 IP,防止 DNS 重绑定;URL 用户信息及敏感查询参数被拒绝。
|
||||||
|
|
||||||
|
ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Stream URI 持久化到 PostgreSQL。接入失败会记录可行动状态但保留最后一次已验证 Profile;成功接入清除凭据更新触发的重试标记。前端继续复用 GoAdmin 动态菜单、权限链、BasicLayout 和 Element Plus 表单、Dialog、Table、Tag。
|
||||||
|
工单 #67 在 `Sense/server/app/sense/media/` 与 `reconcile/` 建立 MediaMTX 管理面:`cmd/api/server.go` 随 Sense 生命周期启动后台对账并只停止本实例拥有的子进程;检测到外部实例时设置孤儿安全闸,不发送停止信号。MediaMTX Control API 只允许 loopback HTTP,禁用代理与重定向。
|
||||||
|
|
||||||
|
媒体路由只保存设备/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-runtime:end -->
|
||||||
|
|
||||||
<!-- sense-mvp:start -->
|
<!-- sense-mvp:start -->
|
||||||
@@ -102,3 +118,15 @@ Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Toke
|
|||||||
|
|
||||||
旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。
|
旧 Event → Rule → Alert → ack/close 实现在 `explore`。迁移时保留业务语义、不可变与幂等约束,但新的通用认证、RBAC、菜单、审计和管理端外壳必须基于冻结 GoAdmin 源码。
|
||||||
<!-- bell-mvp:end -->
|
<!-- bell-mvp:end -->
|
||||||
|
|
||||||
|
<!-- sense-area:start -->
|
||||||
|
## Sense 区域与警戒线代码入口
|
||||||
|
|
||||||
|
工单 #69 在 `Sense/server/app/sense/area/` 建立区域配置业务层,GoAdmin 路由位于 `Sense/server/app/admin/router/sense_area.go`,前端页面位于 `Sense/ui/src/views/sense/area/index.vue`。页面继续复用 BasicLayout、Axios、Element Plus 表单/表格/分页/Dialog/Tag/Alert 和权限指令;`Sense/ui/src/components/sense/geometry-editor/` 是唯一新增的业务专用绘制组件。
|
||||||
|
|
||||||
|
数据采用两层结构:`sense_area_definitions` 保存当前版本指针和当前校准状态,`sense_area_versions` 保存每次创建、编辑、启停或重校准形成的不可变快照。更新请求携带 `expectedVersion`,服务在 PostgreSQL 事务中锁定当前定义;并发保存只有一个成功,其余返回冲突。历史版本不覆盖、不删除。
|
||||||
|
|
||||||
|
几何坐标使用画面内 0–1 归一化值,同时每个版本固化 Device、Profile Token、分辨率和编码。视频接入成功替换 Profile 后,`admission` 服务比较 Token、分辨率和编码并主动标记当前定义 `needs_recalibration`;读取时也会对缺失或失效 Profile 进行保守校验。系统不自动重投影旧坐标,只有用户重新确认画面并保存新版本后才清除重校准状态。
|
||||||
|
|
||||||
|
认证 API 为 `/api/v1/area/configurations` 及其版本子资源,接入 GoAdmin JWT、Casbin、动态菜单和操作权限。API 只返回设备/Profile 展示字段、规格、归一化坐标和版本信息,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。
|
||||||
|
<!-- sense-area:end -->
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Business-Rules-and-Glossary
|
wiki_page: Business-Rules-and-Glossary
|
||||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||||
wiki_revision: 53a9c59534560841cd65fec74492ac3a55ffebe1
|
wiki_revision: 2bcd90c508180eb9a2f9fe37b62115d7d9207e18
|
||||||
synchronized_at: 2026-08-14T04:09:11Z
|
synchronized_at: 2026-08-15T01:13:04Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 业务规则与术语
|
# 业务规则与术语
|
||||||
@@ -76,6 +76,25 @@ synchronized_at: 2026-08-14T04:09:11Z
|
|||||||
- 登录成功/失败、登出、密码变更和鉴权拒绝必须留下身份审计;密码、令牌、Cookie、验证码、数据库连接和摄像头凭据不得进入审计正文。
|
- 登录成功/失败、登出、密码变更和鉴权拒绝必须留下身份审计;密码、令牌、Cookie、验证码、数据库连接和摄像头凭据不得进入审计正文。
|
||||||
- `site_admin` 可在账户维护流程中读取角色、部门、岗位和字典等必要支撑数据,但不能修改角色、菜单或系统配置;`implementation_operator` 与 `viewer` 不具备账户管理权限。未注册的配置和接口管理路由对所有角色返回 404。
|
- `site_admin` 可在账户维护流程中读取角色、部门、岗位和字典等必要支撑数据,但不能修改角色、菜单或系统配置;`implementation_operator` 与 `viewer` 不具备账户管理权限。未注册的配置和接口管理路由对所有角色返回 404。
|
||||||
|
|
||||||
|
<!-- sense-media:start -->
|
||||||
|
## Sense 视频服务规则
|
||||||
|
|
||||||
|
- MediaMTX 始终是独立二进制;Sense 管理配置、进程生命周期、路径期望态和状态对账,不把媒体内核放入 GoAdmin handler 或 GORM model。
|
||||||
|
- Control API 只能绑定回环地址。Sense 可启动配置的 MediaMTX,也可连接已由外部启动的实例;外部实例标记为非本实例所有,孤儿安全闸禁止 Sense 停止它。
|
||||||
|
- 已验证 Profile 幂等形成媒体路径;数据库不保存带凭据 Stream URI。摄像头凭据只在 loopback Control API 请求边界临时使用,不进入基础配置、日志或 Sense API。
|
||||||
|
- 路径状态区分 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 -->
|
<!-- sense-mvp:start -->
|
||||||
## Sense 旧 MVP 规则状态
|
## Sense 旧 MVP 规则状态
|
||||||
|
|
||||||
@@ -94,3 +113,36 @@ synchronized_at: 2026-08-14T04:09:11Z
|
|||||||
## 旧实现迁移边界
|
## 旧实现迁移边界
|
||||||
|
|
||||||
旧 Sense 中已验证的中文设备字段、请求白名单、凭据隔离和媒体链路行为可作为迁移验收用例;不得把旧自研基础框架直接复制到新 `dev`。
|
旧 Sense 中已验证的中文设备字段、请求白名单、凭据隔离和媒体链路行为可作为迁移验收用例;不得把旧自研基础框架直接复制到新 `dev`。
|
||||||
|
|
||||||
|
<!-- sense-device-ledger:start -->
|
||||||
|
## Sense 设备台账与凭据规则
|
||||||
|
|
||||||
|
- Device 是 Sense 内的设备根实体,类型包括视频、雷达、门磁、按钮、穿戴和其他;16 路仍只是默认交付配额,分页和台账容量不得硬编码为 16。
|
||||||
|
- 当前仅视频设备具备可用接入适配器;其余类型必须显示“适配器未就绪”,不允许通过虚假成功状态掩盖能力缺口。
|
||||||
|
- 设备台账支持列表、新建、编辑和停用;停用保留审计与后续关联,不提供物理删除。编辑、停用和凭据更新均使用版本号检测并发冲突。
|
||||||
|
- 摄像头 ONVIF 与 RTSP 凭据按设备和用途分离,以 AES-256-GCM 密文保存;加密密钥只从外部环境 `SENSE_CREDENTIAL_KEY` 注入,不写入数据库、仓库、工单、Wiki 或日志。
|
||||||
|
- 凭据只写不可读:HTTP 和页面仅显示“已配置/未配置”,不得回填用户名、密码或密文;更新凭据后只记录状态并请求后续接入流程重试。
|
||||||
|
- `admin`、`implementation_operator`、`site_admin` 可维护设备与凭据,`viewer` 仅可查看设备台账。
|
||||||
|
<!-- sense-device-ledger:end -->
|
||||||
|
|
||||||
|
<!-- sense-admission:start -->
|
||||||
|
## Sense 视频接入规则
|
||||||
|
|
||||||
|
- “获准网卡”和“获准目标网段”都是部署人员显式配置的授权边界;私网地址不自动代表已授权。未配置发现网卡时不发送 WS-Discovery,手工地址也必须通过目标 CIDR 检查。
|
||||||
|
- ONVIF 设备地址、Media XAddr 和 RTSP Stream URI 禁止 URL 用户信息、敏感认证查询参数、HTTP 重定向和超出授权网段的目标。摄像机返回不可用主机名时,只能归一化为已验证设备主机并重新执行授权检查。
|
||||||
|
- ONVIF 支持 Basic、MD5 Digest 和 SHA-256 Digest 的 auth;不支持的算法或 qop 必须拒绝,不静默降级。
|
||||||
|
- Profile 保存 token、名称、分辨率、编码、用途、无凭据 Stream URI 和逐 Profile 验证状态;主码流默认取分辨率最高项,子码流取最低项。
|
||||||
|
- 认证失败、超时、时间异常、目标未授权和重定向拒绝必须给出不同状态。失败重探不得删除最后一次已验证 Profile;凭据更新后可重新探测。
|
||||||
|
<!-- sense-admission:end -->
|
||||||
|
|
||||||
|
<!-- sense-area:start -->
|
||||||
|
## Sense 区域与方向警戒线规则
|
||||||
|
|
||||||
|
- 区域配置必须绑定已验证的 Device、Profile Token、分辨率和编码;客户端只提交媒体路由引用,最终绑定规格由后端重新查询确认。
|
||||||
|
- 多边形使用 3–64 个画面内坐标点,必须有非零面积,边线不得自交或重叠;方向警戒线恰好两个不同点,并明确“起点到终点”或“终点到起点”。
|
||||||
|
- 坐标以 0–1 归一化值保存,同时固化当时分辨率和编码。Profile 删除、Token 替换、分辨率或编码变化必须设置 `needs_recalibration`,不得静默缩放或重投影旧几何。
|
||||||
|
- 新建为 v1;编辑、启停和重新校准都追加不可变版本。请求使用 `expectedVersion` 乐观并发,过期版本返回 409,旧版本保留用于审计。
|
||||||
|
- `admin`、`implementation_operator`、`site_admin` 可创建和保存新版本;`viewer` 只读。页面状态必须同时使用文字和 Tag,不能只靠颜色表达。
|
||||||
|
- 鼠标可点击/拖动顶点;键盘必须能添加、移动和删除顶点。错误在绘制区域附近以可被辅助技术感知的文字给出,并提供撤销、清空和未保存关闭确认。
|
||||||
|
- 区域配置是 Sense 内部事实;#69 不发布 Brain 契约。后续 Sense→Brain 配置协议必须由独立协调工单从当前版本投影生成,不能共享数据库模型。
|
||||||
|
<!-- sense-area:end -->
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Local-Development-and-Verification
|
wiki_page: Local-Development-and-Verification
|
||||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||||
wiki_revision: 9007f0ff45178b7535c2a38b6e38d44628c3210d
|
wiki_revision: 31cad04ab68ee653f2ec3ca6d7297a6bef768f54
|
||||||
synchronized_at: 2026-08-14T04:09:15Z
|
synchronized_at: 2026-08-15T01:13:07Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 本地开发与验证
|
# 本地开发与验证
|
||||||
@@ -151,6 +151,75 @@ Remove-Variable bootstrapToken, bootstrapBody
|
|||||||
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
|
身份回归至少覆盖:admin 可管理账户及查看审计;implementation_operator 只能查看实施所需日志和字典支撑数据;site_admin 可维护账户并读取角色、部门、岗位、字典,但不能修改角色或菜单;viewer 不能访问管理接口。还要验证配置/接口管理路由返回 404、短密码被拒绝、6 位全小写密码可用,以及登录/登出/改密/拒绝审计中不含密码、令牌、Cookie 或验证码。身份审计直接写入 PostgreSQL,不依赖通用操作日志数据库开关。
|
||||||
|
|
||||||
详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`。
|
详细来源与安全约束见 `Sense/LICENSES/SOURCES.md` 和 `Sense/README.md`。
|
||||||
|
|
||||||
|
设备台账启用凭据写入前,还必须在服务进程环境提供独立随机密钥;示例文件 `Sense/server/config/credential.env.example` 只保留空值:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 生成一次随机 32 字节密钥并以 Base64 形式注入当前进程;不要打印或写入仓库
|
||||||
|
$keyBytes = New-Object byte[] 32
|
||||||
|
[System.Security.Cryptography.RandomNumberGenerator]::Fill($keyBytes)
|
||||||
|
$env:SENSE_CREDENTIAL_KEY = [Convert]::ToBase64String($keyBytes)
|
||||||
|
[Array]::Clear($keyBytes, 0, $keyBytes.Length)
|
||||||
|
```
|
||||||
|
|
||||||
|
缺少或格式错误的密钥时,普通设备台账仍可读写,但凭据更新返回服务不可用且不得产生部分写入。设备回归至少覆盖:中文名称与位置、未知 JSON 字段拒绝、版本冲突返回 409、非视频设备显示适配器未就绪、viewer 只读、凭据响应/操作日志不含明文,以及 PostgreSQL 迁移重复执行不增加菜单或权限记录。
|
||||||
|
|
||||||
|
视频接入还需在仓库外配置 SENSE_ONVIF_DISCOVERY_IP(获准的本机网卡 IP)和 SENSE_ONVIF_ALLOWED_CIDRS(逗号分隔的获准摄像头网段)。不要使用 0.0.0.0/0 代替授权清单。
|
||||||
|
|
||||||
|
协议回归位于 app/sense/onvif、app/sense/rtsp、app/sense/admission;隔离 PostgreSQL 重启恢复测试通过 SENSE_ADMISSION_TEST_DATABASE_URL 显式启用。验证至少覆盖 Digest/Basic、无配置发现提示、URL 凭据和敏感查询拒绝、目标网段、重定向、Media/Stream 主机归一化、主子码流、失败重探保留已验证 Profile,以及 viewer 只读权限。
|
||||||
|
MediaMTX 保持仓库外独立二进制。运行前在进程环境设置:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SENSE_MEDIAMTX_BINARY = '<MediaMTX 可执行文件>'
|
||||||
|
$env:SENSE_MEDIAMTX_CONFIG = '<仓库外 mediamtx.yml>'
|
||||||
|
$env:SENSE_MEDIAMTX_API = 'http://127.0.0.1:9997'
|
||||||
|
```
|
||||||
|
|
||||||
|
配置文件不存在时 Sense 只生成 loopback API 和空 `paths: {}` 的无凭据基础配置;模板位于 `Sense/server/config/mediamtx/mediamtx.yml.example`。Control API 不允许非回环地址。真实集成验证使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SENSE_MEDIAMTX_TEST_BINARY = '<MediaMTX 可执行文件>'
|
||||||
|
go test ./tests/media -run TestRealMediaMTXControlLifecycle -v
|
||||||
|
|
||||||
|
$env:SENSE_MEDIA_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||||
|
go test ./tests/media -run TestPostgresColdStartRestoresDesiredRoute -v
|
||||||
|
|
||||||
|
$env:SENSE_MEDIA_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||||
|
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 -->
|
<!-- sense-runtime:end -->
|
||||||
|
|
||||||
|
|
||||||
@@ -176,3 +245,40 @@ Remove-Variable bootstrapToken, bootstrapBody
|
|||||||
3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。
|
3. 阅读任务相关的 go-admin-doc 主题/文件,并把参考项记录到工单。
|
||||||
4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。
|
4. 记录计划继承的 go-admin/go-admin-ui 路径、计划隐藏/禁用的模块和许可证处理。
|
||||||
5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。
|
5. 验证最终产品树确实包含上游派生结构;只使用 Go、Vue、Element Plus 或相似视觉不算通过。
|
||||||
|
|
||||||
|
<!-- sense-area:start -->
|
||||||
|
### Sense 区域与警戒线验证
|
||||||
|
|
||||||
|
后端定向与全量验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd Sense/server
|
||||||
|
go test ./app/sense/area ./app/sense/admission
|
||||||
|
go test -race ./app/sense/area ./app/sense/admission
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
使用隔离 PostgreSQL 验证迁移和真实并发;连接值只放当前进程环境,不写入仓库或日志:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SENSE_AREA_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||||
|
go test ./cmd/migrate/migration/version -run TestAreaMigrationOnPostgres -count=1 -v
|
||||||
|
|
||||||
|
$env:SENSE_AREA_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||||
|
go test ./app/sense/area -run TestConcurrentUpdateOnPostgresReturnsConflict -count=1 -v
|
||||||
|
```
|
||||||
|
|
||||||
|
前端验证:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd Sense/ui
|
||||||
|
corepack pnpm@9.15.1 install --frozen-lockfile
|
||||||
|
corepack pnpm@9.15.1 lint
|
||||||
|
corepack pnpm@9.15.1 test:unit
|
||||||
|
corepack pnpm@9.15.1 build:prod
|
||||||
|
```
|
||||||
|
|
||||||
|
浏览器 smoke 至少覆盖:鼠标添加和拖动顶点;键盘 Enter 添加、方向键移动、Delete 删除;错误文字可见并具有 aria-live/alert 语义;刷新后版本、启停和重新校准状态仍可追溯。真实摄像机校准只使用明确授权设备,不记录地址、URI、凭据或视频内容。Brain、Bell 不启动时必须能独立保存、读取和预览。
|
||||||
|
<!-- sense-area:end -->
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||||
wiki_page: Troubleshooting
|
wiki_page: Troubleshooting
|
||||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||||
wiki_revision: 2be5d803b954df64e31085bcf097ee27f8611f9d
|
wiki_revision: a169b2323323d9de6304e9b430ddbe9888ea1d25
|
||||||
synchronized_at: 2026-08-14T01:17:28Z
|
synchronized_at: 2026-08-15T07:16:25Z
|
||||||
<!-- gitea-wiki-mirror:end -->
|
<!-- gitea-wiki-mirror:end -->
|
||||||
|
|
||||||
# 故障排查
|
# 故障排查
|
||||||
@@ -60,3 +60,77 @@ synchronized_at: 2026-08-14T01:17:28Z
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 在 `dev` 找不到 Sense/Bell 可运行代码 | 这是重建空基线的预期状态;旧实现位于 `explore`,新代码必须由 GoAdmin 源码派生工单建立。 |
|
| 在 `dev` 找不到 Sense/Bell 可运行代码 | 这是重建空基线的预期状态;旧实现位于 `explore`,新代码必须由 GoAdmin 源码派生工单建立。 |
|
||||||
| 新骨架只有相似页面、没有 GoAdmin 启动链或权限模块 | 不符合二次开发门禁;停止验收,对照 `goadmin-baseline.json`、上游源码和 go-admin-doc 重新实施。 |
|
| 新骨架只有相似页面、没有 GoAdmin 启动链或权限模块 | 不符合二次开发门禁;停止验收,对照 `goadmin-baseline.json`、上游源码和 go-admin-doc 重新实施。 |
|
||||||
|
|
||||||
|
<!-- sense-admission:start -->
|
||||||
|
## Sense 视频接入排错
|
||||||
|
|
||||||
|
| 现象 | 原因与处理 |
|
||||||
|
|---|---|
|
||||||
|
| 未配置获准的发现网卡 | 在服务进程环境设置本机实际网卡 IP SENSE_ONVIF_DISCOVERY_IP;不要填写摄像机 IP。 |
|
||||||
|
| 配置的发现地址不是本机网卡 | 网卡地址已变化或填写错误;用 Get-NetIPAddress 核对后重启服务。 |
|
||||||
|
| 目标地址不在获准网段内 | 核对摄像机实际地址与 SENSE_ONVIF_ALLOWED_CIDRS;只追加已审批的最小 CIDR,不使用全网放行。 |
|
||||||
|
| 认证失败 | 在设备管理重新填写 ONVIF/RTSP 凭据,再返回视频接入重新验证;页面不会回显旧凭据。 |
|
||||||
|
| 设备时间异常 | 在摄像机管理页或受控 NTP 环境校时后重新探测;Sense 不自动修改设备时间。 |
|
||||||
|
| 部分码流失败 | 查看逐 Profile 状态、设备 RTSP 权限和端口;最后一次已验证 Profile 会保留。 |
|
||||||
|
| 重定向已拒绝 | ONVIF 服务返回了 3xx;修正为摄像机最终服务地址,不允许 Sense 跟随到未知目标。 |
|
||||||
|
<!-- sense-media:start -->
|
||||||
|
## Sense 视频服务排错
|
||||||
|
|
||||||
|
| 现象 | 原因与处理 |
|
||||||
|
|---|---|
|
||||||
|
| 进程状态“未配置” | 未设置 SENSE_MEDIAMTX_BINARY;如由外部服务管理,先确认 loopback Control API 已就绪,否则配置二进制和仓库外配置路径。 |
|
||||||
|
| 进程启动失败 | 核对二进制存在、配置目录可写、MediaMTX 配置可解析,以及 RTSP/API 端口未被其他进程占用。 |
|
||||||
|
| 等待拉流 | 路径已建立但 sourceOnDemand 尚无 reader;打开实时监看后再观察,不等同于接入失败。 |
|
||||||
|
| 配置失败或路径缺失 | 在“视频服务”点击对账;检查 Control API 仍为 loopback、Profile 仍已验证、RTSP 凭据可用。 |
|
||||||
|
| 显示外部启动(受保护) | 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 -->
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user