Compare commits

...
Author SHA1 Message Date
QiuSW 15bd000816 docs: 记录工单 #95 验收通过 2026-08-16 19:32:02 +08:00
QiuSW 7f13e595b6 Merge remote-tracking branch 'origin/dev' into fix/95-sense-media-path-constraint
# Conflicts:
#	wiki-docs.json
2026-08-16 19:31:00 +08:00
ila 116318df74 Merge pull request '#98' from feature/97-sense-password-login into dev
[SEN] 恢复免验证码登录并安全设置管理员密码(#97)
2026-08-16 19:05:35 +08:00
QiuSW bdb9a5b474 docs: 记录工单 #97 验收通过 2026-08-16 19:05:17 +08:00
QiuSW a92e4da043 docs: 归档 Sense 免验证码登录任务 (#97) 2026-08-15 18:08:33 +08:00
QiuSW bc1a01848d docs: 更新 Sense 登录安全边界 (#97) 2026-08-15 18:02:16 +08:00
QiuSW 0bbdb27f6d fix: 恢复 Sense 免验证码登录 (#97) 2026-08-15 18:02:10 +08:00
QiuSW d0185caa0e docs: 完成任务 #95 待验收归档 2026-08-15 16:43:41 +08:00
QiuSW 6257859b83 docs: 记录旧媒体路由迁移排错 (#95) 2026-08-15 16:09:40 +08:00
QiuSW 3dd489066c fix: 兼容旧媒体路由约束迁移 (#95) 2026-08-15 15:48:10 +08:00
ila 40409707cc Merge pull request '#94' from docs/92-acceptance into dev
记录工单 #92 验收通过
2026-08-15 15:31:29 +08:00
QiuSW 9472151103 docs: 校正工单 #92 验收归档 2026-08-15 15:31:14 +08:00
QiuSW e82f15f1fb docs: 记录工单 #92 验收通过 2026-08-15 15:30:10 +08:00
ila eaa6ae0815 Merge pull request '#93' from fix/92-sense-capabilities-jsonb into dev
[SEN] 修复旧设备 capabilities 迁移到 JSONB(#92)
2026-08-15 15:28:03 +08:00
QiuSW 3a686a8151 docs: 完成任务 #92 待验收归档 2026-08-15 15:20:01 +08:00
QiuSW 3c4578c804 docs: 登记任务 #92 归档镜像 2026-08-15 15:19:12 +08:00
QiuSW fe3badfe3e test: 覆盖旧设备完整迁移链 (#92) 2026-08-15 15:17:28 +08:00
QiuSW aee5f45d96 docs: 记录设备能力迁移排错 (#92) 2026-08-15 15:16:32 +08:00
QiuSW 68b436ac62 fix: 兼容旧设备能力字段迁移 (#92) 2026-08-15 15:15:02 +08:00
ila cf50c285f4 Merge pull request '#88' from docs/69-acceptance into dev
记录工单 #69 验收通过。
2026-08-15 09:37:08 +08:00
QiuSW 0bc113af04 docs: 记录工单 #69 验收通过 2026-08-15 09:36:34 +08:00
ila 61b79db9f7 Merge pull request '#87' from feature/69-sense-area into dev
[SEN] 重建多边形区域与方向警戒线配置(#69)

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

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

用户于 2026-08-14 验收通过。
2026-08-14 18:16:32 +08:00
QiuSW 35b9c7b8a4 docs: 记录工单 #67 验收通过 2026-08-14 18:16:15 +08:00
QiuSW 263a68c98d docs: 归档工单 #67 待验收证据 2026-08-14 18:00:44 +08:00
QiuSW 4149cc4426 docs: 记录 Sense 视频服务生命周期 (#67) 2026-08-14 17:51:16 +08:00
QiuSW 19f9bfa1a0 feat: 重建 MediaMTX 生命周期与状态对账 (#67) 2026-08-14 17:42:26 +08:00
ila 24cf4bbd65 Merge pull request '#84' from feature/66-sense-onvif-admission into dev
[SEN] 在 GoAdmin 基线上重建 ONVIF 发现、手工接入与 RTSP Profile (#66)
2026-08-14 17:18:27 +08:00
QiuSW ab54235819 docs: 记录工单 #66 验收通过 2026-08-14 17:18:13 +08:00
QiuSW d74e1aee85 docs: 归档工单 #66 待验收证据 2026-08-14 17:09:40 +08:00
QiuSW 26e2e632d5 docs: 记录 Sense 视频接入安全边界 (#66) 2026-08-14 16:58:13 +08:00
QiuSW 2bb1614a3c feat: 重建 ONVIF 与 RTSP 视频接入 (#66) 2026-08-14 16:50:00 +08:00
ila 5856b76de9 Merge pull request '#83' from feature/65-sense-device-credential into dev
[SEN] 在 GoAdmin 基线上重建设备台账与凭据边界 (#65)
2026-08-14 16:27:59 +08:00
QiuSW 3e859c3848 docs: 记录工单 #65 验收通过 2026-08-14 16:27:32 +08:00
QiuSW f7eab5d8fa docs: 归档工单 #65 待验收证据 2026-08-14 15:23:37 +08:00
QiuSW d31c35f098 docs: 记录 Sense 设备台账与凭据边界 (#65) 2026-08-14 15:07:13 +08:00
QiuSW be091f093d feat: 重建设备台账与凭据边界 (#65) 2026-08-14 14:59:34 +08:00
ila 1ef4a1fe0b Merge PR #82: rebuild Sense authentication RBAC and audit
User accepted issue #64 on 2026-08-14.
2026-08-14 14:26:09 +08:00
QiuSW 654c444c18 docs: 记录工单 #64 验收通过 2026-08-14 14:25:55 +08:00
QiuSW 903a423ed5 docs: 归档 Sense 登录权限任务 (#64) 2026-08-14 12:22:39 +08:00
120 changed files with 9471 additions and 141 deletions
+7 -1
View File
@@ -40,4 +40,10 @@ corepack pnpm@9.15.1 build:prod
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)
}
+109
View File
@@ -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")
}
}
+34
View File
@@ -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" }
+181
View File
@@ -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")
}
}
+126
View File
@@ -0,0 +1,126 @@
package area
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
base := coreService.Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
return nil, err
}
return NewService(base.Orm), nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), Kind: c.Query("kind"), RecalibrationState: c.Query("recalibrationState"), PageIndex: pageIndex, PageSize: pageSize})
if err != nil {
e.writeError(err)
return
}
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
}
func (e *API) Create(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
var request UpsertRequest
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
request.UpdateBy = user.GetUserId(c)
item, err := service.Create(c.Request.Context(), request)
if err != nil {
e.writeError(err)
return
}
e.OK(item, "区域配置已创建")
}
func (e *API) Update(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
var request UpsertRequest
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
request.UpdateBy = user.GetUserId(c)
item, err := service.Update(c.Request.Context(), c.Param("id"), request)
if err != nil {
e.writeError(err)
return
}
e.OK(item, "已保存新版本")
}
func (e *API) Versions(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "区域配置服务初始化失败")
return
}
items, err := service.Versions(c.Request.Context(), c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(items, "查询成功")
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidRequest), errors.Is(err, ErrInvalidGeometry):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrNotFound), errors.Is(err, ErrProfileMissing):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, ErrConflict):
e.Error(http.StatusConflict, err, err.Error())
default:
e.Error(http.StatusInternalServerError, err, "区域配置操作失败")
}
}
func decodeJSON(c *gin.Context, target any) error {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
return errors.New("content type must be application/json")
}
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("request body must contain one JSON object")
}
return err
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package area
import "time"
const (
KindPolygon = "polygon"
KindDirectionLine = "direction_line"
DirectionForward = "forward"
DirectionReverse = "reverse"
)
type Point struct {
X float64 `json:"x"`
Y float64 `json:"y"`
}
type PageRequest struct {
Keyword string
Kind string
RecalibrationState string
PageIndex int
PageSize int
}
type UpsertRequest struct {
Name string `json:"name"`
Kind string `json:"kind"`
RouteID string `json:"routeId"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
ExpectedVersion int64 `json:"expectedVersion"`
UpdateBy int `json:"-"`
}
type Response struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
DeviceLocation string `json:"deviceLocation"`
ProfileToken string `json:"profileToken"`
ProfileName string `json:"profileName"`
ProfileWidth int `json:"profileWidth"`
ProfileHeight int `json:"profileHeight"`
ProfileEncoding string `json:"profileEncoding"`
RouteID string `json:"routeId"`
Version int64 `json:"version"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
NeedsRecalibration bool `json:"needsRecalibration"`
UpdatedBy int `json:"updatedBy"`
UpdatedAt time.Time `json:"updatedAt"`
}
type VersionResponse struct {
ID string `json:"id"`
Version int64 `json:"version"`
Name string `json:"name"`
Kind string `json:"kind"`
DeviceID string `json:"deviceId"`
ProfileToken string `json:"profileToken"`
ProfileWidth int `json:"profileWidth"`
ProfileHeight int `json:"profileHeight"`
ProfileEncoding string `json:"profileEncoding"`
Points []Point `json:"points"`
Direction string `json:"direction"`
Enabled bool `json:"enabled"`
SupersedesID string `json:"supersedesId"`
CreatedBy int `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
}
type ProfileSnapshot struct {
Token string
Width int
Height int
Encoding string
}
+84
View File
@@ -0,0 +1,84 @@
package area
import (
"errors"
"math"
)
var ErrInvalidGeometry = errors.New("区域几何不符合要求")
func validateGeometry(kind, direction string, points []Point) error {
if kind != KindPolygon && kind != KindDirectionLine {
return ErrInvalidGeometry
}
if (kind == KindPolygon && (len(points) < 3 || len(points) > 64)) || (kind == KindDirectionLine && len(points) != 2) {
return ErrInvalidGeometry
}
if kind == KindDirectionLine && direction != DirectionForward && direction != DirectionReverse {
return ErrInvalidGeometry
}
if kind == KindPolygon && direction != "" {
return ErrInvalidGeometry
}
for i, point := range points {
if math.IsNaN(point.X) || math.IsNaN(point.Y) || math.IsInf(point.X, 0) || math.IsInf(point.Y, 0) || point.X < 0 || point.X > 1 || point.Y < 0 || point.Y > 1 {
return ErrInvalidGeometry
}
if i > 0 && samePoint(point, points[i-1]) {
return ErrInvalidGeometry
}
}
if kind == KindDirectionLine {
if samePoint(points[0], points[1]) {
return ErrInvalidGeometry
}
return nil
}
if samePoint(points[0], points[len(points)-1]) || math.Abs(polygonArea(points)) < 0.000001 || polygonSelfIntersects(points) {
return ErrInvalidGeometry
}
return nil
}
func samePoint(a, b Point) bool {
return math.Abs(a.X-b.X) < 0.0000001 && math.Abs(a.Y-b.Y) < 0.0000001
}
func polygonArea(points []Point) float64 {
area := 0.0
for i := range points {
next := points[(i+1)%len(points)]
area += points[i].X*next.Y - next.X*points[i].Y
}
return area / 2
}
func polygonSelfIntersects(points []Point) bool {
for i := range points {
a1, a2 := points[i], points[(i+1)%len(points)]
for j := i + 1; j < len(points); j++ {
if j == i || j == (i+1)%len(points) || i == (j+1)%len(points) {
continue
}
b1, b2 := points[j], points[(j+1)%len(points)]
if segmentsIntersect(a1, a2, b1, b2) {
return true
}
}
}
return false
}
func segmentsIntersect(a, b, c, d Point) bool {
orientation := func(p, q, r Point) float64 {
return (q.Y-p.Y)*(r.X-q.X) - (q.X-p.X)*(r.Y-q.Y)
}
o1, o2, o3, o4 := orientation(a, b, c), orientation(a, b, d), orientation(c, d, a), orientation(c, d, b)
if ((o1 > 0 && o2 < 0) || (o1 < 0 && o2 > 0)) && ((o3 > 0 && o4 < 0) || (o3 < 0 && o4 > 0)) {
return true
}
onSegment := func(p, q, r Point) bool {
return q.X <= math.Max(p.X, r.X)+0.0000001 && q.X >= math.Min(p.X, r.X)-0.0000001 && q.Y <= math.Max(p.Y, r.Y)+0.0000001 && q.Y >= math.Min(p.Y, r.Y)-0.0000001
}
return (math.Abs(o1) < 0.0000001 && onSegment(a, c, b)) || (math.Abs(o2) < 0.0000001 && onSegment(a, d, b)) || (math.Abs(o3) < 0.0000001 && onSegment(c, a, d)) || (math.Abs(o4) < 0.0000001 && onSegment(c, b, d))
}
@@ -0,0 +1,27 @@
package area
import "testing"
func TestGeometryValidation(t *testing.T) {
tests := []struct {
name string
kind string
direction string
points []Point
valid bool
}{
{"polygon", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, true},
{"self intersecting", KindPolygon, "", []Point{{0.1, 0.1}, {0.8, 0.8}, {0.8, 0.1}, {0.1, 0.8}}, false},
{"outside", KindPolygon, "", []Point{{-0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, false},
{"line", KindDirectionLine, DirectionForward, []Point{{0.2, 0.5}, {0.8, 0.5}}, true},
{"line missing direction", KindDirectionLine, "", []Point{{0.2, 0.5}, {0.8, 0.5}}, false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := validateGeometry(test.kind, test.direction, test.points)
if (err == nil) != test.valid {
t.Fatalf("valid=%v err=%v", test.valid, err)
}
})
}
}
+45
View File
@@ -0,0 +1,45 @@
package area
import "time"
type Definition struct {
ID string `gorm:"size:36;primaryKey"`
Name string `gorm:"size:128;not null;index"`
Kind string `gorm:"size:32;not null;index"`
DeviceID string `gorm:"size:36;not null;index"`
ProfileToken string `gorm:"size:255;not null"`
ProfileWidth int `gorm:"not null"`
ProfileHeight int `gorm:"not null"`
ProfileEncoding string `gorm:"size:32;not null"`
CurrentVersion int64 `gorm:"not null"`
CurrentVersionID string `gorm:"size:36;not null;uniqueIndex"`
Enabled bool `gorm:"not null"`
NeedsRecalibration bool `gorm:"not null;index"`
CreatedBy int `gorm:"not null"`
UpdatedBy int `gorm:"not null"`
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
func (Definition) TableName() string { return "sense_area_definitions" }
type Version struct {
ID string `gorm:"size:36;primaryKey"`
DefinitionID string `gorm:"size:36;not null;uniqueIndex:ux_sense_area_version,priority:1;index"`
Version int64 `gorm:"not null;uniqueIndex:ux_sense_area_version,priority:2"`
Name string `gorm:"size:128;not null"`
Kind string `gorm:"size:32;not null"`
DeviceID string `gorm:"size:36;not null"`
ProfileToken string `gorm:"size:255;not null"`
ProfileWidth int `gorm:"not null"`
ProfileHeight int `gorm:"not null"`
ProfileEncoding string `gorm:"size:32;not null"`
GeometryJSON string `gorm:"type:text;not null"`
Direction string `gorm:"size:16;not null"`
Enabled bool `gorm:"not null"`
SupersedesID string `gorm:"size:36"`
CreatedBy int `gorm:"not null"`
CreatedAt time.Time `gorm:"not null"`
}
func (Version) TableName() string { return "sense_area_versions" }
@@ -0,0 +1,95 @@
package area
import (
"context"
"errors"
"os"
"strings"
"sync"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestConcurrentUpdateOnPostgresReturnsConflict(t *testing.T) {
baseDSN := os.Getenv("SENSE_AREA_TEST_DATABASE_URL")
if baseDSN == "" {
t.Skip("set SENSE_AREA_TEST_DATABASE_URL to run the PostgreSQL concurrency test")
}
admin, err := gorm.Open(postgres.Open(baseDSN), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_area_69_concurrency"
if err = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = admin.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
separator := "?"
if strings.Contains(baseDSN, "?") {
separator = "&"
}
db, err := gorm.Open(postgres.Open(baseDSN+separator+"search_path="+schema), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
} {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
service := NewService(db)
created, err := service.Create(context.Background(), triangleRequest())
if err != nil {
t.Fatal(err)
}
start := make(chan struct{})
errorsChannel := make(chan error, 2)
var wait sync.WaitGroup
for index := 0; index < 2; index++ {
wait.Add(1)
go func() {
defer wait.Done()
<-start
request := triangleRequest()
request.ExpectedVersion = created.Version
_, updateErr := service.Update(context.Background(), created.ID, request)
errorsChannel <- updateErr
}()
}
close(start)
wait.Wait()
close(errorsChannel)
successes, conflicts := 0, 0
for updateErr := range errorsChannel {
switch {
case updateErr == nil:
successes++
case errors.Is(updateErr, ErrConflict):
conflicts++
default:
t.Fatalf("unexpected concurrent update error: %v", updateErr)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
}
versions, err := service.Versions(context.Background(), created.ID)
if err != nil || len(versions) != 2 {
t.Fatalf("versions=%d err=%v", len(versions), err)
}
}
+304
View File
@@ -0,0 +1,304 @@
package area
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrInvalidRequest = errors.New("区域配置请求不符合要求")
ErrNotFound = errors.New("区域配置不存在")
ErrConflict = errors.New("区域配置已被其他用户更新,请刷新后重试")
ErrProfileMissing = errors.New("绑定的视频 Profile 不可用,请先完成视频接入")
)
type Service struct{ db *gorm.DB }
func NewService(db *gorm.DB) *Service { return &Service{db: db} }
type routeSnapshot struct {
RouteID string `gorm:"column:route_id"`
DeviceID string `gorm:"column:device_id"`
DeviceName string `gorm:"column:device_name"`
DeviceLocation string `gorm:"column:device_location"`
ProfileToken string `gorm:"column:profile_token"`
ProfileName string `gorm:"column:profile_name"`
Width int `gorm:"column:width"`
Height int `gorm:"column:height"`
Encoding string `gorm:"column:encoding"`
Verification string `gorm:"column:verification_status"`
}
func (s *Service) List(ctx context.Context, request PageRequest) ([]Response, int64, error) {
request.PageIndex, request.PageSize = normalizePage(request.PageIndex, request.PageSize)
if utf8.RuneCountInString(request.Keyword) > 128 || (request.Kind != "" && request.Kind != KindPolygon && request.Kind != KindDirectionLine) {
return nil, 0, ErrInvalidRequest
}
query := s.db.WithContext(ctx).Model(&Definition{})
if keyword := strings.TrimSpace(request.Keyword); keyword != "" {
pattern := "%" + escapeLike(keyword) + "%"
query = query.Where("LOWER(name) LIKE LOWER(?) ESCAPE '\\'", pattern)
}
if request.Kind != "" {
query = query.Where("kind = ?", request.Kind)
}
switch request.RecalibrationState {
case "", "all":
case "needed":
query = query.Where("needs_recalibration = ?", true)
case "ready":
query = query.Where("needs_recalibration = ?", false)
default:
return nil, 0, ErrInvalidRequest
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var definitions []Definition
if err := query.Order("updated_at DESC, name ASC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Find(&definitions).Error; err != nil {
return nil, 0, err
}
items := make([]Response, 0, len(definitions))
for i := range definitions {
item, err := s.response(ctx, &definitions[i])
if err != nil {
return nil, 0, err
}
items = append(items, item)
}
return items, total, nil
}
func (s *Service) Create(ctx context.Context, request UpsertRequest) (Response, error) {
if request.ExpectedVersion != 0 {
return Response{}, ErrInvalidRequest
}
name, err := validateRequest(request)
if err != nil {
return Response{}, err
}
now := time.Now().UTC()
definitionID := uuid.NewString()
versionID := uuid.NewString()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
route, routeErr := loadRoute(tx, request.RouteID)
if routeErr != nil {
return routeErr
}
geometry, marshalErr := json.Marshal(request.Points)
if marshalErr != nil {
return ErrInvalidGeometry
}
definition := Definition{ID: definitionID, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, CurrentVersion: 1, CurrentVersionID: versionID, Enabled: request.Enabled, CreatedBy: request.UpdateBy, UpdatedBy: request.UpdateBy, CreatedAt: now, UpdatedAt: now}
version := Version{ID: versionID, DefinitionID: definitionID, Version: 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, CreatedBy: request.UpdateBy, CreatedAt: now}
if err = tx.Create(&definition).Error; err != nil {
return err
}
return tx.Create(&version).Error
})
if err != nil {
return Response{}, err
}
return s.Get(ctx, definitionID)
}
func (s *Service) Update(ctx context.Context, id string, request UpsertRequest) (Response, error) {
if strings.TrimSpace(id) == "" || request.ExpectedVersion < 1 {
return Response{}, ErrInvalidRequest
}
name, err := validateRequest(request)
if err != nil {
return Response{}, err
}
now := time.Now().UTC()
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current Definition
if readErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&current, "id = ?", id).Error; readErr != nil {
if errors.Is(readErr, gorm.ErrRecordNotFound) {
return ErrNotFound
}
return readErr
}
if current.CurrentVersion != request.ExpectedVersion {
return ErrConflict
}
route, routeErr := loadRoute(tx, request.RouteID)
if routeErr != nil {
return routeErr
}
geometry, marshalErr := json.Marshal(request.Points)
if marshalErr != nil {
return ErrInvalidGeometry
}
versionID := uuid.NewString()
version := Version{ID: versionID, DefinitionID: current.ID, Version: current.CurrentVersion + 1, Name: name, Kind: request.Kind, DeviceID: route.DeviceID, ProfileToken: route.ProfileToken, ProfileWidth: route.Width, ProfileHeight: route.Height, ProfileEncoding: route.Encoding, GeometryJSON: string(geometry), Direction: request.Direction, Enabled: request.Enabled, SupersedesID: current.CurrentVersionID, CreatedBy: request.UpdateBy, CreatedAt: now}
if createErr := tx.Create(&version).Error; createErr != nil {
return createErr
}
updates := map[string]any{"name": name, "kind": request.Kind, "device_id": route.DeviceID, "profile_token": route.ProfileToken, "profile_width": route.Width, "profile_height": route.Height, "profile_encoding": route.Encoding, "current_version": version.Version, "current_version_id": versionID, "enabled": request.Enabled, "needs_recalibration": false, "updated_by": request.UpdateBy, "updated_at": now}
result := tx.Model(&Definition{}).Where("id = ? AND current_version = ?", current.ID, request.ExpectedVersion).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected != 1 {
return ErrConflict
}
return nil
})
if err != nil {
return Response{}, err
}
return s.Get(ctx, id)
}
func (s *Service) Get(ctx context.Context, id string) (Response, error) {
var definition Definition
if err := s.db.WithContext(ctx).First(&definition, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Response{}, ErrNotFound
}
return Response{}, err
}
return s.response(ctx, &definition)
}
func (s *Service) Versions(ctx context.Context, id string) ([]VersionResponse, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&Definition{}).Where("id = ?", id).Count(&count).Error; err != nil {
return nil, err
}
if count == 0 {
return nil, ErrNotFound
}
var versions []Version
if err := s.db.WithContext(ctx).Where("definition_id = ?", id).Order("version DESC").Find(&versions).Error; err != nil {
return nil, err
}
out := make([]VersionResponse, 0, len(versions))
for _, version := range versions {
points, err := decodePoints(version.GeometryJSON)
if err != nil {
return nil, fmt.Errorf("decode area version %d: %w", version.Version, err)
}
out = append(out, VersionResponse{ID: version.ID, Version: version.Version, Name: version.Name, Kind: version.Kind, DeviceID: version.DeviceID, ProfileToken: version.ProfileToken, ProfileWidth: version.ProfileWidth, ProfileHeight: version.ProfileHeight, ProfileEncoding: version.ProfileEncoding, Points: points, Direction: version.Direction, Enabled: version.Enabled, SupersedesID: version.SupersedesID, CreatedBy: version.CreatedBy, CreatedAt: version.CreatedAt})
}
return out, nil
}
func (s *Service) response(ctx context.Context, definition *Definition) (Response, error) {
var version Version
if err := s.db.WithContext(ctx).First(&version, "id = ?", definition.CurrentVersionID).Error; err != nil {
return Response{}, err
}
points, err := decodePoints(version.GeometryJSON)
if err != nil {
return Response{}, err
}
var route routeSnapshot
query := s.db.WithContext(ctx).Table("sense_devices AS d").
Select("COALESCE(r.id, '') AS route_id, d.id AS device_id, d.name AS device_name, d.location AS device_location, COALESCE(p.token, '') AS profile_token, COALESCE(p.name, '') AS profile_name, COALESCE(p.width, 0) AS width, COALESCE(p.height, 0) AS height, COALESCE(p.encoding, '') AS encoding, COALESCE(p.verification_status, '') AS verification_status").
Joins("LEFT JOIN sense_admission_profiles AS p ON p.device_id = d.id AND p.token = ?", definition.ProfileToken).
Joins("LEFT JOIN sense_media_routes AS r ON r.device_id = d.id AND r.profile_token = ?", definition.ProfileToken).
Where("d.id = ?", definition.DeviceID).Limit(1).Scan(&route)
if query.Error != nil {
return Response{}, query.Error
}
recalibration := definition.NeedsRecalibration || route.ProfileToken == "" || route.Verification != "ready" || route.Width != definition.ProfileWidth || route.Height != definition.ProfileHeight || !strings.EqualFold(route.Encoding, definition.ProfileEncoding)
if recalibration && !definition.NeedsRecalibration {
if err = s.db.WithContext(ctx).Model(&Definition{}).Where("id = ? AND needs_recalibration = ?", definition.ID, false).Update("needs_recalibration", true).Error; err != nil {
return Response{}, err
}
definition.NeedsRecalibration = true
}
return Response{ID: definition.ID, Name: definition.Name, Kind: definition.Kind, DeviceID: definition.DeviceID, DeviceName: route.DeviceName, DeviceLocation: route.DeviceLocation, ProfileToken: definition.ProfileToken, ProfileName: route.ProfileName, ProfileWidth: definition.ProfileWidth, ProfileHeight: definition.ProfileHeight, ProfileEncoding: definition.ProfileEncoding, RouteID: route.RouteID, Version: definition.CurrentVersion, Points: points, Direction: version.Direction, Enabled: definition.Enabled, NeedsRecalibration: recalibration, UpdatedBy: definition.UpdatedBy, UpdatedAt: definition.UpdatedAt}, nil
}
func MarkProfilesReplaced(tx *gorm.DB, deviceID string, profiles []ProfileSnapshot) error {
if tx == nil || !tx.Migrator().HasTable(&Definition{}) {
return nil
}
available := make(map[string]ProfileSnapshot, len(profiles))
for _, profile := range profiles {
available[profile.Token] = profile
}
var definitions []Definition
if err := tx.Where("device_id = ? AND needs_recalibration = ?", deviceID, false).Find(&definitions).Error; err != nil {
return err
}
for _, definition := range definitions {
profile, ok := available[definition.ProfileToken]
if !ok || profile.Width != definition.ProfileWidth || profile.Height != definition.ProfileHeight || !strings.EqualFold(profile.Encoding, definition.ProfileEncoding) {
if err := tx.Model(&Definition{}).Where("id = ?", definition.ID).Update("needs_recalibration", true).Error; err != nil {
return err
}
}
}
return nil
}
func loadRoute(db *gorm.DB, routeID string) (routeSnapshot, error) {
if strings.TrimSpace(routeID) == "" {
return routeSnapshot{}, ErrProfileMissing
}
var route routeSnapshot
err := db.Table("sense_media_routes AS r").
Select("r.id AS route_id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.width, p.height, p.encoding, p.verification_status").
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
Where("r.id = ? AND p.verification_status = ?", routeID, "ready").Limit(1).Scan(&route).Error
if err != nil {
return routeSnapshot{}, err
}
if route.RouteID == "" || route.Width < 1 || route.Height < 1 {
return routeSnapshot{}, ErrProfileMissing
}
return route, nil
}
func validateRequest(request UpsertRequest) (string, error) {
name := strings.TrimSpace(request.Name)
if name == "" || utf8.RuneCountInString(name) > 128 || request.UpdateBy < 1 || strings.TrimSpace(request.RouteID) == "" {
return "", ErrInvalidRequest
}
if err := validateGeometry(request.Kind, request.Direction, request.Points); err != nil {
return "", err
}
return name, nil
}
func decodePoints(value string) ([]Point, error) {
var points []Point
if err := json.Unmarshal([]byte(value), &points); err != nil {
return nil, err
}
return points, nil
}
func normalizePage(index, size int) (int, int) {
if index < 1 {
index = 1
}
if size < 1 {
size = 10
}
if size > 50 {
size = 50
}
return index, size
}
func escapeLike(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `%`, `\%`)
return strings.ReplaceAll(value, `_`, `\_`)
}
@@ -0,0 +1,95 @@
package area
import (
"context"
"errors"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func areaTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Definition{}, &Version{}); err != nil {
t.Fatal(err)
}
statements := []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, width integer, height integer, encoding text, verification_status text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流',1920,1080,'H264','ready')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main')`,
}
for _, statement := range statements {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func triangleRequest() UpsertRequest {
return UpsertRequest{Name: "操场危险区域", Kind: KindPolygon, RouteID: "device-1:main", Points: []Point{{0.1, 0.1}, {0.8, 0.1}, {0.5, 0.8}}, Enabled: true, UpdateBy: 7}
}
func TestVersionsAreAppendOnlyAndUseOptimisticConcurrency(t *testing.T) {
service := NewService(areaTestDB(t))
created, err := service.Create(context.Background(), triangleRequest())
if err != nil || created.Version != 1 || created.ProfileWidth != 1920 {
t.Fatalf("created=%+v err=%v", created, err)
}
request := triangleRequest()
request.Name = "操场危险区域(校准)"
request.ExpectedVersion = 1
updated, err := service.Update(context.Background(), created.ID, request)
if err != nil || updated.Version != 2 {
t.Fatalf("updated=%+v err=%v", updated, err)
}
if _, err = service.Update(context.Background(), created.ID, request); !errors.Is(err, ErrConflict) {
t.Fatalf("stale update err=%v", err)
}
versions, err := service.Versions(context.Background(), created.ID)
if err != nil || len(versions) != 2 || versions[0].Version != 2 || versions[1].Version != 1 || versions[0].SupersedesID != versions[1].ID {
t.Fatalf("versions=%+v err=%v", versions, err)
}
}
func TestProfileReplacementMarksOnlyChangedBindings(t *testing.T) {
db := areaTestDB(t)
service := NewService(db)
created, err := service.Create(context.Background(), triangleRequest())
if err != nil {
t.Fatal(err)
}
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1920, Height: 1080, Encoding: "H264"}}); err != nil {
t.Fatal(err)
}
item, _ := service.Get(context.Background(), created.ID)
if item.NeedsRecalibration {
t.Fatal("unchanged profile was marked for recalibration")
}
if err = MarkProfilesReplaced(db, "device-1", []ProfileSnapshot{{Token: "main", Width: 1280, Height: 720, Encoding: "H264"}}); err != nil {
t.Fatal(err)
}
item, _ = service.Get(context.Background(), created.ID)
if !item.NeedsRecalibration {
t.Fatal("resolution change did not mark recalibration")
}
}
func TestListIsPaginatedAndDoesNotExposeMediaSecrets(t *testing.T) {
service := NewService(areaTestDB(t))
if _, err := service.Create(context.Background(), triangleRequest()); err != nil {
t.Fatal(err)
}
items, total, err := service.List(context.Background(), PageRequest{Keyword: "操场", PageIndex: 1, PageSize: 10})
if err != nil || total != 1 || len(items) != 1 || items[0].RouteID != "device-1:main" {
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
}
}
@@ -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
}
+132
View File
@@ -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"`
}
+134
View File
@@ -0,0 +1,134 @@
package liveview
import (
"encoding/json"
"errors"
"html/template"
"io"
"net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
)
type API struct{ api.Api }
func (e *API) service(c *gin.Context) (*Service, error) {
base := coreService.Service{}
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
return nil, err
}
return NewService(base.Orm, nil), nil
}
func (e *API) List(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
return
}
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), PageIndex: pageIndex, PageSize: pageSize})
if err != nil {
e.writeError(err)
return
}
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
}
func (e *API) Create(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
return
}
var request struct {
RouteID string `json:"routeId"`
}
if err = decodeJSON(c, &request); err != nil {
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
return
}
session, err := service.Create(c.Request.Context(), user.GetUserId(c), request.RouteID, c.Request)
if err != nil {
e.writeError(err)
return
}
e.OK(session, "播放会话已创建")
}
func (e *API) Get(c *gin.Context) {
service, err := e.service(c)
if err != nil {
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
return
}
session, err := service.Get(c.Request.Context(), user.GetUserId(c), c.Param("id"))
if err != nil {
e.writeError(err)
return
}
e.OK(session, "查询成功")
}
var playerTemplate = template.Must(template.New("liveview-player").Parse(`<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#101419;overflow:hidden}</style></head>
<body><iframe src="{{.}}" title="Sense 实时视频" allow="autoplay; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`))
func (e *API) Player(c *gin.Context) {
target, err := NewService(nil, nil).PlayerTarget(c.Param("id"))
if err != nil {
c.Header("Cache-Control", "no-store")
c.String(http.StatusGone, "播放会话已过期,请重新连接")
return
}
parsed := template.URL(target)
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "SAMEORIGIN")
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
if err = playerTemplate.Execute(c.Writer, parsed); err != nil {
c.AbortWithStatus(http.StatusInternalServerError)
}
}
func (e *API) writeError(err error) {
switch {
case errors.Is(err, ErrInvalidRequest):
e.Error(http.StatusBadRequest, err, err.Error())
case errors.Is(err, ErrRouteNotFound):
e.Error(http.StatusNotFound, err, err.Error())
case errors.Is(err, ErrSessionExpired):
e.Error(http.StatusGone, err, "播放会话已过期,请重新连接")
case strings.Contains(err.Error(), "WEBRTC_PUBLIC_BASE"), strings.Contains(err.Error(), "浏览器可访问"):
e.Error(http.StatusServiceUnavailable, err, "浏览器播放地址未正确配置")
default:
e.Error(http.StatusInternalServerError, err, "实时监看操作失败")
}
}
func decodeJSON(c *gin.Context, target any) error {
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
return errors.New("content type must be application/json")
}
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("request body must contain one JSON object")
}
return err
}
return nil
}
@@ -0,0 +1,41 @@
package liveview
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
func TestPlayerUsesShortLivedCapabilityAndRestrictiveHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
store := NewStore()
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
store.now = func() time.Time { return now }
previous := defaultStore
defaultStore = store
t.Cleanup(func() { defaultStore = previous })
store.put(sessionRecord{ID: "view_test", OwnerID: 7, RouteID: "route-1", TargetURL: "http://127.0.0.1:8889/sense_test?controls=true", ExpiresAt: now.Add(sessionTTL)})
router := gin.New()
api := &API{}
router.GET("/api/v1/liveview/player/:id", api.Player)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("X-Frame-Options") != "SAMEORIGIN" {
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
}
if !strings.Contains(recorder.Body.String(), "http://127.0.0.1:8889/sense_test?controls=true") || !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") {
t.Fatalf("unexpected wrapper response: %s", recorder.Body.String())
}
now = now.Add(sessionTTL)
expired := httptest.NewRecorder()
router.ServeHTTP(expired, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
if expired.Code != http.StatusGone {
t.Fatalf("expired capability status=%d", expired.Code)
}
}
+291
View File
@@ -0,0 +1,291 @@
package liveview
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"net/url"
"os"
"regexp"
"strings"
"sync"
"time"
"gorm.io/gorm"
)
var (
ErrInvalidRequest = errors.New("实时监看请求不符合要求")
ErrRouteNotFound = errors.New("可监看的视频不存在")
ErrSessionExpired = errors.New("播放会话已过期")
validMediaPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
validDNSHost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
)
const sessionTTL = 2 * time.Minute
type PageRequest struct {
Keyword string
PageIndex int
PageSize int
}
type RouteResponse struct {
ID string `json:"id"`
DeviceID string `json:"deviceId"`
DeviceName string `json:"deviceName"`
DeviceLocation string `json:"deviceLocation"`
ProfileToken string `json:"profileToken"`
ProfileName string `json:"profileName"`
ProfileKind string `json:"profileKind"`
Width int `json:"width"`
Height int `json:"height"`
Encoding string `json:"encoding"`
Actual string `json:"actual"`
Detail string `json:"detail"`
Readers int `json:"readers"`
}
type SessionResponse struct {
ID string `json:"id"`
RouteID string `json:"routeId"`
PlayerURL string `json:"playerUrl"`
Status string `json:"status"`
Detail string `json:"detail"`
ExpiresAt time.Time `json:"expiresAt"`
DeviceName string `json:"deviceName"`
ProfileName string `json:"profileName"`
}
type routeRecord struct {
RouteResponse
Path string `gorm:"column:path"`
Desired string `gorm:"column:desired"`
}
type sessionRecord struct {
ID string
OwnerID int
RouteID string
TargetURL string
ExpiresAt time.Time
DeviceName string
ProfileName string
}
type Store struct {
mu sync.RWMutex
sessions map[string]sessionRecord
now func() time.Time
}
func NewStore() *Store {
return &Store{sessions: make(map[string]sessionRecord), now: time.Now}
}
var defaultStore = NewStore()
type Service struct {
db *gorm.DB
store *Store
}
func NewService(db *gorm.DB, store *Store) *Service {
if store == nil {
store = defaultStore
}
return &Service{db: db, store: store}
}
func (s *Service) List(ctx context.Context, request PageRequest) ([]RouteResponse, int64, error) {
if request.PageIndex < 1 {
request.PageIndex = 1
}
if request.PageSize < 1 {
request.PageSize = 10
}
if request.PageSize > 50 {
request.PageSize = 50
}
query := s.routeQuery(ctx).Where("r.desired = ?", "running")
keyword := strings.TrimSpace(request.Keyword)
if len([]rune(keyword)) > 128 {
return nil, 0, ErrInvalidRequest
}
if keyword != "" {
pattern := "%" + escapeLike(keyword) + "%"
query = query.Where("LOWER(d.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(d.location) LIKE LOWER(?) ESCAPE '\\' OR LOWER(p.name) LIKE LOWER(?) ESCAPE '\\'", pattern, pattern, pattern)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var records []routeRecord
if err := query.Order("d.name ASC, p.kind ASC, p.width DESC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Scan(&records).Error; err != nil {
return nil, 0, err
}
items := make([]RouteResponse, 0, len(records))
for _, record := range records {
items = append(items, record.RouteResponse)
}
return items, total, nil
}
func (s *Service) Create(ctx context.Context, ownerID int, routeID string, request *http.Request) (SessionResponse, error) {
if ownerID < 1 || strings.TrimSpace(routeID) == "" {
return SessionResponse{}, ErrInvalidRequest
}
record, err := s.route(ctx, routeID)
if err != nil {
return SessionResponse{}, err
}
if record.Desired != "running" || !validMediaPath.MatchString(record.Path) {
return SessionResponse{}, ErrRouteNotFound
}
base, err := playbackBase(request)
if err != nil {
return SessionResponse{}, err
}
target := *base
target.Path = strings.TrimRight(target.Path, "/") + "/" + record.Path
target.RawQuery = "controls=true&muted=true&autoplay=true"
id, err := randomID()
if err != nil {
return SessionResponse{}, err
}
now := s.store.now().UTC()
session := sessionRecord{ID: id, OwnerID: ownerID, RouteID: record.ID, TargetURL: target.String(), ExpiresAt: now.Add(sessionTTL), DeviceName: record.DeviceName, ProfileName: record.ProfileName}
s.store.put(session)
return responseFrom(session, record), nil
}
func (s *Service) Get(ctx context.Context, ownerID int, id string) (SessionResponse, error) {
session, err := s.store.get(id, ownerID, true)
if err != nil {
return SessionResponse{}, err
}
record, err := s.route(ctx, session.RouteID)
if err != nil || record.Desired != "running" {
return SessionResponse{}, ErrRouteNotFound
}
session.ExpiresAt = s.store.now().UTC().Add(sessionTTL)
s.store.put(session)
return responseFrom(session, record), nil
}
func (s *Service) PlayerTarget(id string) (string, error) {
session, err := s.store.get(id, 0, false)
if err != nil {
return "", err
}
return session.TargetURL, nil
}
func (s *Service) route(ctx context.Context, id string) (routeRecord, error) {
var record routeRecord
if err := s.routeQuery(ctx).Where("r.id = ?", id).Limit(1).Scan(&record).Error; err != nil {
return routeRecord{}, err
}
if record.ID == "" {
return routeRecord{}, ErrRouteNotFound
}
return record, nil
}
func (s *Service) routeQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).Table("sense_media_routes AS r").
Select("r.id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.kind AS profile_kind, p.width, p.height, p.encoding, r.actual, r.detail, r.readers, r.path, r.desired").
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
Where("d.status <> ? AND p.verification_status = ?", "disabled", "ready")
}
func responseFrom(session sessionRecord, route routeRecord) SessionResponse {
return SessionResponse{ID: session.ID, RouteID: session.RouteID, PlayerURL: "/api/v1/liveview/player/" + session.ID, Status: playbackStatus(route.Actual), Detail: route.Detail, ExpiresAt: session.ExpiresAt, DeviceName: session.DeviceName, ProfileName: session.ProfileName}
}
func playbackStatus(actual string) string {
switch actual {
case "ready", "waiting", "stopped":
return actual
case "credential_unavailable", "profile_unavailable":
return "authentication_failed"
case "path_missing", "apply_failed", "status_unavailable":
return "stream_not_found"
case "process_unavailable":
return "service_unavailable"
default:
return "offline"
}
}
func (s *Store) put(session sessionRecord) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
for id, item := range s.sessions {
if !now.Before(item.ExpiresAt) || item.OwnerID == session.OwnerID {
delete(s.sessions, id)
}
}
s.sessions[session.ID] = session
}
func (s *Store) get(id string, ownerID int, checkOwner bool) (sessionRecord, error) {
s.mu.RLock()
session, ok := s.sessions[id]
s.mu.RUnlock()
if !ok || !s.now().Before(session.ExpiresAt) || (checkOwner && session.OwnerID != ownerID) {
return sessionRecord{}, ErrSessionExpired
}
return session, nil
}
func playbackBase(request *http.Request) (*url.URL, error) {
configured := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE"))
if configured != "" {
return validatePlaybackBase(configured)
}
if request == nil || request.Host == "" {
return nil, errors.New("无法确定浏览器可访问的视频服务地址")
}
host := request.Host
if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil {
host = parsedHost
}
host = strings.Trim(host, "[]")
if host == "" || (net.ParseIP(host) == nil && host != "localhost" && !validDNSHost.MatchString(host)) {
return nil, errors.New("无效的请求主机")
}
scheme := "http"
if request.TLS != nil {
scheme = "https"
}
return validatePlaybackBase(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, "8889")))
}
func validatePlaybackBase(value string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimRight(value, "/"))
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return nil, errors.New("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE 配置不安全")
}
return parsed, nil
}
func randomID() (string, error) {
value := make([]byte, 24)
if _, err := rand.Read(value); err != nil {
return "", err
}
return "view_" + hex.EncodeToString(value), nil
}
func escapeLike(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, `%`, `\%`)
return strings.ReplaceAll(value, `_`, `\_`)
}
@@ -0,0 +1,89 @@
package liveview
import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func testDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
statements := []string{
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, kind text, width integer, height integer, encoding text, verification_status text, stream_uri text)`,
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text, path text, desired text, actual text, detail text, readers integer)`,
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流','main',1920,1080,'H264','ready','rtsp://camera.example/live')`,
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main','sense_012345','running','waiting','等待播放器连接并按需拉流',0)`,
}
for _, statement := range statements {
if err = db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
return db
}
func TestListIsPaginatedSearchableAndSecretFree(t *testing.T) {
service := NewService(testDB(t), NewStore())
items, total, err := service.List(context.Background(), PageRequest{Keyword: "东门", PageIndex: 1, PageSize: 10})
if err != nil || total != 1 || len(items) != 1 || items[0].ProfileKind != "main" {
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
}
encoded, _ := json.Marshal(items)
if strings.Contains(string(encoded), "rtsp://") || strings.Contains(string(encoded), "sense_012345") {
t.Fatalf("response leaked private media data: %s", encoded)
}
}
func TestSessionIsOwnerBoundShortLivedAndUsesBrowserHost(t *testing.T) {
store := NewStore()
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
store.now = func() time.Time { return now }
service := NewService(testDB(t), store)
request := httptest.NewRequest("POST", "http://192.0.2.20:18080/api/v1/liveview/sessions", nil)
session, err := service.Create(context.Background(), 7, "device-1:main", request)
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(session.PlayerURL, "/api/v1/liveview/player/view_") || strings.Contains(session.PlayerURL, "sense_012345") {
t.Fatalf("unsafe player URL: %s", session.PlayerURL)
}
if _, err = service.Get(context.Background(), 8, session.ID); !errorsIs(err, ErrSessionExpired) {
t.Fatalf("another owner accessed session: %v", err)
}
target, err := service.PlayerTarget(session.ID)
if err != nil || target != "http://192.0.2.20:8889/sense_012345?controls=true&muted=true&autoplay=true" {
t.Fatalf("target=%q err=%v", target, err)
}
now = now.Add(sessionTTL)
if _, err = service.PlayerTarget(session.ID); !errorsIs(err, ErrSessionExpired) {
t.Fatalf("expired session remained valid: %v", err)
}
}
func TestPlaybackBaseRejectsCredentials(t *testing.T) {
if _, err := validatePlaybackBase("http://invalid-user@127.0.0.1:8889"); err == nil {
t.Fatal("expected credential-bearing base URL to be rejected")
}
}
func TestPlaybackStatusKeepsActionableFailuresDistinct(t *testing.T) {
for input, want := range map[string]string{"waiting": "waiting", "credential_unavailable": "authentication_failed", "path_missing": "stream_not_found", "process_unavailable": "service_unavailable", "unexpected": "offline"} {
if got := playbackStatus(input); got != want {
t.Fatalf("playbackStatus(%q)=%q want %q", input, got, want)
}
}
}
func errorsIs(err, target error) bool { return err == target }
+98
View File
@@ -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, "视频服务操作失败")
}
}
+91
View File
@@ -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
}
+168
View File
@@ -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)
}
}
+24
View File
@@ -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}
}
+37
View File
@@ -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" }
+70
View File
@@ -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)
}
+305
View File
@@ -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")
}
}
+149
View File
@@ -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
}
+296
View File
@@ -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)
}
}
+96
View File
@@ -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
}
+75
View File
@@ -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--
}
}
}
}
+76
View File
@@ -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")
}
}
+70
View File
@@ -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")
}
}
+18
View File
@@ -20,6 +20,7 @@ import (
"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/sense/media"
"git.ilapage.cn/ila/yovision/Sense/server/common/database"
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
@@ -85,6 +86,19 @@ func run() error {
for _, f := range AppRouters {
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{
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)
defer cancel()
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 {
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,184 @@
package version
import (
"fmt"
"runtime"
"strings"
"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 := prepareLegacyMediaRouteSchema(tx); err != nil {
return err
}
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
})
}
type mediaPathConstraint struct {
Name string
Columns string
}
// prepareLegacyMediaRouteSchema makes the old PostgreSQL table safe for GORM.
// Older Sense builds used a database-named UNIQUE(path) constraint and lacked
// the runtime-state columns now required by the Route model.
func prepareLegacyMediaRouteSchema(tx *gorm.DB) error {
if tx.Dialector.Name() != "postgres" {
return nil
}
var tableCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'sense_media_routes'`).Scan(&tableCount).Error; err != nil {
return fmt.Errorf("inspect legacy media route table: %w", err)
}
if tableCount == 0 {
return nil
}
if err := tx.Exec(`LOCK TABLE "sense_media_routes" IN ACCESS EXCLUSIVE MODE`).Error; err != nil {
return fmt.Errorf("lock sense_media_routes for legacy migration: %w", err)
}
if err := normalizeLegacyMediaPathConstraint(tx); err != nil {
return err
}
if err := initializeLegacyMediaRuntimeColumns(tx); err != nil {
return err
}
return nil
}
func normalizeLegacyMediaPathConstraint(tx *gorm.DB) error {
var constraints []mediaPathConstraint
if err := tx.Raw(`SELECT c.conname AS name,
(SELECT string_agg(a.attname, ',' ORDER BY key.ordinality)
FROM unnest(c.conkey) WITH ORDINALITY AS key(attnum, ordinality)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum) AS columns
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.contype = 'u'
AND EXISTS (
SELECT 1
FROM unnest(c.conkey) AS key(attnum)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = key.attnum
WHERE a.attname = 'path'
)
ORDER BY c.conname`).Scan(&constraints).Error; err != nil {
return fmt.Errorf("inspect legacy media path constraints: %w", err)
}
if len(constraints) == 0 {
return nil
}
if len(constraints) != 1 || constraints[0].Columns != "path" {
return fmt.Errorf("sense_media_routes.path has unsupported legacy uniqueness structure; migration rolled back")
}
const expectedName = "uni_sense_media_routes_path"
if constraints[0].Name == expectedName {
return nil
}
var conflictingNameCount int64
if err := tx.Raw(`SELECT COUNT(*)
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = current_schema()
AND t.relname = 'sense_media_routes'
AND c.conname = ?`, expectedName).Scan(&conflictingNameCount).Error; err != nil {
return fmt.Errorf("inspect target media path constraint name: %w", err)
}
if conflictingNameCount != 0 {
return fmt.Errorf("sense_media_routes has a conflicting target constraint name; migration rolled back")
}
rename := fmt.Sprintf(
`ALTER TABLE "sense_media_routes" RENAME CONSTRAINT %s TO %s`,
quotePostgresIdentifier(constraints[0].Name),
quotePostgresIdentifier(expectedName),
)
if err := tx.Exec(rename).Error; err != nil {
return fmt.Errorf("normalize legacy media path constraint name: %w", err)
}
return nil
}
func initializeLegacyMediaRuntimeColumns(tx *gorm.DB) error {
for _, statement := range []struct {
name string
sql string
}{
{name: "add source_ready", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "source_ready" boolean`},
{name: "add failure_count", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "failure_count" bigint`},
{name: "add last_error_code", sql: `ALTER TABLE "sense_media_routes" ADD COLUMN IF NOT EXISTS "last_error_code" varchar(64)`},
{name: "initialize runtime state", sql: `UPDATE "sense_media_routes"
SET "source_ready" = COALESCE("source_ready", false),
"failure_count" = COALESCE("failure_count", 0),
"last_error_code" = COALESCE("last_error_code", '')
WHERE "source_ready" IS NULL
OR "failure_count" IS NULL
OR "last_error_code" IS NULL`},
{name: "require source_ready", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "source_ready" SET NOT NULL`},
{name: "require failure_count", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "failure_count" SET NOT NULL`},
{name: "require last_error_code", sql: `ALTER TABLE "sense_media_routes" ALTER COLUMN "last_error_code" SET NOT NULL`},
} {
if err := tx.Exec(statement.sql).Error; err != nil {
return fmt.Errorf("%s for legacy media routes: %w", statement.name, err)
}
}
return nil
}
func quotePostgresIdentifier(value string) string {
return `"` + strings.ReplaceAll(value, `"`, `""`) + `"`
}
@@ -0,0 +1,198 @@
package version
import (
"os"
"strings"
"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)
}
const schema = "sense_media_95_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("migrates the legacy path constraint and preserves routes", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path)`)
if err = db.Exec(`INSERT INTO sense_media_routes
(id, device_id, profile_token, path, desired, actual, readers, detail, version, updated_at)
VALUES
('route-1', 'device-1', 'profile-1', 'camera-1', 'running', 'stopped', 0, '', 1, now()),
('route-2', 'device-2', 'profile-2', 'camera-2', 'stopped', 'stopped', 0, '', 1, now())`).Error; err != nil {
t.Fatal(err)
}
const migrationVersion = "2026081419000_media.go"
if err = migrateSenseMedia(db, migrationVersion); 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 = ?", migrationVersion).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 2 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
}
assertMediaPathUniqueIndex(t, db)
if err = db.Exec(`UPDATE sense_media_routes SET path = 'camera-1' WHERE id = 'route-2'`).Error; err == nil {
t.Fatal("expected path uniqueness violation")
}
var runtimeState struct {
SourceReady bool
FailureCount int64
LastError string
}
if err = db.Raw(`SELECT source_ready, failure_count, last_error_code AS last_error
FROM sense_media_routes WHERE id = 'route-1'`).Scan(&runtimeState).Error; err != nil {
t.Fatal(err)
}
if runtimeState.SourceReady || runtimeState.FailureCount != 0 || runtimeState.LastError != "" {
t.Fatalf("unexpected migrated runtime state: %#v", runtimeState)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatalf("repeat compatibility migration: %v", err)
}
})
t.Run("does nothing when the table is absent", func(t *testing.T) {
if err = db.Exec(`DROP TABLE IF EXISTS sense_media_routes`).Error; err != nil {
t.Fatal(err)
}
if err = db.Transaction(prepareLegacyMediaRouteSchema); err != nil {
t.Fatal(err)
}
})
t.Run("creates a fresh media table", func(t *testing.T) {
resetEmptyMediaMigration(t, db)
if err = migrateSenseMedia(db, "2026081419000_media_fresh.go"); err != nil {
t.Fatal(err)
}
var routes int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if routes != 0 {
t.Fatalf("fresh route count=%d", routes)
}
assertMediaPathUniqueIndex(t, db)
})
t.Run("rejects an unsafe composite path constraint", func(t *testing.T) {
resetLegacyMediaMigration(t, db, `UNIQUE (path, device_id)`)
if err = db.Transaction(prepareLegacyMediaRouteSchema); err == nil || !strings.Contains(err.Error(), "unsupported legacy uniqueness") {
t.Fatalf("expected unsupported uniqueness error, got %v", err)
}
var constraints int64
if err = db.Raw(`SELECT COUNT(*) FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'sense_media_routes' AND c.contype = 'u'`).Scan(&constraints).Error; err != nil {
t.Fatal(err)
}
if constraints != 1 {
t.Fatalf("constraint rollback count=%d", constraints)
}
})
}
func resetEmptyMediaMigration(t *testing.T, db *gorm.DB) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).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)
}
}
}
func resetLegacyMediaMigration(t *testing.T, db *gorm.DB, pathConstraint string) {
t.Helper()
if err := db.Exec(`DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE`).Error; err != nil {
t.Fatal(err)
}
createRouteTable := `CREATE TABLE sense_media_routes (
id text PRIMARY KEY,
device_id text NOT NULL,
profile_token text NOT NULL,
path text NOT NULL,
desired text NOT NULL,
actual text NOT NULL,
readers integer NOT NULL DEFAULT 0,
detail text NOT NULL DEFAULT '',
version bigint NOT NULL,
updated_at timestamptz NOT NULL,
` + pathConstraint + `
)`
if err := db.Exec(createRouteTable).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)
}
}
}
func assertMediaPathUniqueIndex(t *testing.T, db *gorm.DB) {
t.Helper()
var indexes []struct {
IndexName string
IndexDef string
}
if err := db.Raw(`SELECT indexname AS index_name, indexdef AS index_def
FROM pg_indexes
WHERE schemaname = current_schema()
AND tablename = 'sense_media_routes'
ORDER BY indexname`).Scan(&indexes).Error; err != nil {
t.Fatal(err)
}
for _, index := range indexes {
if strings.Contains(index.IndexDef, "UNIQUE INDEX") && strings.HasSuffix(index.IndexDef, " (path)") {
return
}
}
t.Fatalf("missing unique path index: %#v", indexes)
}
@@ -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)
}
}
+1 -11
View File
@@ -7,9 +7,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk/api"
"github.com/go-admin-team/go-admin-core/sdk/config"
"github.com/go-admin-team/go-admin-core/sdk/pkg"
"github.com/go-admin-team/go-admin-core/sdk/pkg/captcha"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
@@ -82,21 +80,13 @@ func Authenticator(c *gin.Context) (interface{}, error) {
return nil, jwt.ErrMissingLoginValues
}
if config.ApplicationConfig.Mode != "dev" {
if !captcha.Verify(loginVals.UUID, loginVals.Code, true) {
username = loginVals.Username
msg = "验证码错误"
status = "1"
return nil, jwt.ErrInvalidVerificationode
}
}
sysUser, role, e := loginVals.GetUser(db)
if e == nil {
username = loginVals.Username
return map[string]interface{}{"user": sysUser, "role": role}, nil
} else {
username = loginVals.Username
msg = "登录失败"
status = "1"
log.Warnf("%s login failed!", loginVals.Username)
@@ -9,8 +9,6 @@ import (
type Login struct {
Username string `form:"UserName" json:"username" binding:"required"`
Password string `form:"Password" json:"password" binding:"required"`
Code string `form:"Code" json:"code" binding:"required"`
UUID string `form:"UUID" json:"uuid" binding:"required"`
}
func (u *Login) GetUser(tx *gorm.DB) (user SysUser, role SysRole, err error) {
@@ -0,0 +1,35 @@
package handler
import (
"net/http/httptest"
"reflect"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestLoginAcceptsCredentialsWithoutCaptcha(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/api/v1/login", strings.NewReader(`{"username":"operator","password":"valid-password"}`))
ctx.Request.Header.Set("Content-Type", "application/json")
var login Login
if err := ctx.ShouldBindJSON(&login); err != nil {
t.Fatalf("bind credentials-only login: %v", err)
}
if login.Username != "operator" || login.Password != "valid-password" {
t.Fatalf("unexpected login payload: username=%q", login.Username)
}
typeOfLogin := reflect.TypeOf(login)
if typeOfLogin.NumField() != 2 {
t.Fatalf("login payload must only expose username and password, got %d fields", typeOfLogin.NumField())
}
for _, removed := range []string{"Code", "UUID"} {
if _, ok := typeOfLogin.FieldByName(removed); ok {
t.Fatalf("captcha field %s must not be part of the login payload", removed)
}
}
}
+2 -1
View File
@@ -136,7 +136,8 @@ func redactAuditValue(value interface{}) {
func isSensitiveAuditKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
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
default:
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) {
if got := sanitizeAuditJSON("password=secret-value"); strings.Contains(got, "secret-value") {
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: {}
+1 -9
View File
@@ -3798,23 +3798,15 @@ const docTemplateadmin = `{
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
+2 -10
View File
@@ -3789,23 +3789,15 @@
"handler.Login": {
"type": "object",
"required": [
"code",
"password",
"username",
"uuid"
"username"
],
"properties": {
"code": {
"type": "string"
},
"password": {
"type": "string"
},
"username": {
"type": "string"
},
"uuid": {
"type": "string"
}
}
},
@@ -4345,4 +4337,4 @@
"in": "header"
}
}
}
}
@@ -685,19 +685,13 @@ definitions:
type: object
handler.Login:
properties:
code:
type: string
password:
type: string
username:
type: string
uuid:
type: string
required:
- code
- password
- username
- uuid
type: object
models.SysApi:
properties:
@@ -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)
}
}
+70
View File
@@ -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)
}
}
+5
View File
@@ -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 }) }
+17
View File
@@ -0,0 +1,17 @@
import request from '@/utils/request'
export function listAreaConfigurations(query) {
return request({ url: '/api/v1/area/configurations', method: 'get', params: query })
}
export function createAreaConfiguration(data) {
return request({ url: '/api/v1/area/configurations', method: 'post', data })
}
export function updateAreaConfiguration(id, data) {
return request({ url: `/api/v1/area/configurations/${id}`, method: 'put', data })
}
export function listAreaVersions(id) {
return request({ url: `/api/v1/area/configurations/${id}/versions`, method: 'get' })
}
+25
View File
@@ -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 })
}
+13
View File
@@ -0,0 +1,13 @@
import request from '@/utils/request'
export function listLiveviewRoutes(query) {
return request({ url: '/api/v1/liveview/routes', method: 'get', params: query })
}
export function createLiveviewSession(routeId) {
return request({ url: '/api/v1/liveview/sessions', method: 'post', data: { routeId }})
}
export function getLiveviewSession(id) {
return request({ url: `/api/v1/liveview/sessions/${id}`, method: 'get' })
}
+7
View File
@@ -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>
+4 -85
View File
@@ -102,29 +102,6 @@
</el-input>
</el-form-item>
<el-form-item label="验证码" prop="code">
<div class="captcha-row">
<el-input
v-model="loginForm.code"
placeholder="请输入验证码"
name="code"
type="text"
tabindex="3"
maxlength="5"
autocomplete="off"
size="large"
:prefix-icon="Key"
@keyup.enter="handleLogin"
/>
<div class="captcha-wrap" title="点击刷新" @click="getCode">
<img v-if="codeUrl" :src="codeUrl" class="captcha-img" alt="验证码">
<div v-else class="captcha-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
</div>
</div>
</div>
</el-form-item>
<el-button
:loading="loading"
type="primary"
@@ -142,27 +119,22 @@
</template>
<script>
import { getCodeImg } from '@/api/login'
import { User, Lock, Key, View, Hide, Monitor, Loading } from '@element-plus/icons-vue'
import { User, Lock, View, Hide, Monitor } from '@element-plus/icons-vue'
export default {
name: 'LoginPage',
setup() {
return { User, Lock, Key, View, Hide, Monitor, Loading }
return { User, Lock, View, Hide, Monitor }
},
data() {
return {
codeUrl: '',
loginForm: {
username: '',
password: '',
code: '',
uuid: ''
password: ''
},
loginRules: {
username: [{ required: true, trigger: 'blur', message: '用户名不能为空' }],
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }],
code: [{ required: true, trigger: 'change', message: '验证码不能为空' }]
password: [{ required: true, trigger: 'blur', message: '密码不能为空' }]
},
passwordType: 'password',
capsTooltip: false,
@@ -185,7 +157,6 @@ export default {
}
},
created() {
this.getCode()
this.getSystemSetting()
},
mounted() {
@@ -202,15 +173,6 @@ export default {
document.title = ret.sys_app_name
})
},
getCode() {
this.codeUrl = ''
getCodeImg().then((res) => {
if (res !== undefined) {
this.codeUrl = res.data
this.loginForm.uuid = res.id
}
})
},
checkCapslock({ shiftKey, key } = {}) {
if (key && key.length === 1) {
if ((shiftKey && key >= 'a' && key <= 'z') || (!shiftKey && key >= 'A' && key <= 'Z')) {
@@ -238,7 +200,6 @@ export default {
})
.catch(() => {
this.loading = false
this.getCode()
})
}
})
@@ -562,48 +523,6 @@ export default {
}
}
/* ── 验证码 ── */
.captcha-row {
display: flex;
gap: 10px;
align-items: center;
.el-input {
flex: 1;
}
}
.captcha-wrap {
width: 110px;
height: 40px;
flex-shrink: 0;
border-radius: 8px;
border: 1px solid #e5e7eb;
overflow: hidden;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: #f9fafb;
transition: border-color 0.2s;
&:hover {
border-color: #3b82f6;
}
}
.captcha-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.captcha-placeholder {
color: #c1c7d0;
font-size: 18px;
}
/* ── 登录按钮 ── */
.submit-btn {
width: 100%;
@@ -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)
}
+455
View File
@@ -0,0 +1,455 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>区域与警戒线</h3>
<p>在设备画面上配置危险区域或方向警戒线;画面规格变化后必须重新校准。</p>
</div>
<el-button v-permisaction="['sense:area:create']" type="primary" @click="openCreate">新增配置</el-button>
</div>
<el-form ref="queryForm" :model="queryParams" inline class="search-form" @submit.prevent>
<el-form-item label="名称" prop="keyword">
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入配置名称" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="类型" prop="kind">
<el-select v-model="queryParams.kind" clearable placeholder="全部类型">
<el-option label="危险区域" value="polygon" />
<el-option label="方向警戒线" value="direction_line" />
</el-select>
</el-form-item>
<el-form-item label="校准状态" prop="recalibrationState">
<el-select v-model="queryParams.recalibrationState" clearable placeholder="全部状态">
<el-option label="需要重新校准" value="needed" />
<el-option label="已校准" value="ready" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="!loading && total === 0"
title="尚无区域配置。请先确认实时监看可用,再新增危险区域或方向警戒线。"
type="info"
:closable="false"
show-icon
class="empty-alert"
/>
<el-table v-loading="loading" :data="items" border stripe>
<el-table-column prop="name" label="配置名称" min-width="180" show-overflow-tooltip />
<el-table-column label="类型" width="130">
<template #default="scope">
<el-tag size="small" :type="scope.row.kind === 'polygon' ? 'warning' : 'danger'">{{ kindLabel(scope.row.kind) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="设备与码流" min-width="220">
<template #default="scope">
<div>{{ scope.row.deviceName || scope.row.deviceId }}</div>
<small class="muted-text">{{ profileLabel(scope.row) }}</small>
</template>
</el-table-column>
<el-table-column label="版本" width="90">
<template #default="scope">v{{ scope.row.version }}</template>
</el-table-column>
<el-table-column label="校准状态" width="150">
<template #default="scope">
<el-tag v-if="scope.row.needsRecalibration" type="danger" size="small">需要重新校准</el-tag>
<el-tag v-else type="success" size="small">已校准</el-tag>
</template>
</el-table-column>
<el-table-column label="启用状态" width="100">
<template #default="scope">
<el-tag :type="scope.row.enabled ? 'success' : 'info'" size="small">{{ scope.row.enabled ? '已启用' : '已停用' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="updatedAt" label="最近更新" min-width="170">
<template #default="scope">{{ parseTime(scope.row.updatedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="scope">
<el-button v-permisaction="['sense:area:update']" type="primary" link @click="openEdit(scope.row)">编辑/校准</el-button>
<el-button type="primary" link @click="openVersions(scope.row)">版本记录</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
v-model:page="queryParams.pageIndex"
v-model:limit="queryParams.pageSize"
:total="total"
@pagination="getList"
/>
</el-card>
<el-dialog v-model="editorOpen" width="min(1100px, 94vw)" :close-on-click-modal="false" :before-close="beforeEditorClose" destroy-on-close>
<template #header>
<div class="dialog-header">
<div>
<strong>{{ form.id ? '编辑并生成新版本' : '新增区域配置' }}</strong>
<span v-if="form.id">当前 v{{ form.version }};保存后旧版本仍可追溯</span>
</div>
<el-tag v-if="form.needsRecalibration" type="danger">需要重新校准</el-tag>
</div>
</template>
<el-alert
v-if="form.needsRecalibration"
title="绑定的 Profile、分辨率或编码已变化。请重新选择码流并确认画面坐标后保存新版本。"
type="warning"
:closable="false"
show-icon
class="dialog-alert"
/>
<el-form ref="editorForm" :model="form" :rules="rules" label-width="110px" @change="dirty = true">
<div class="form-grid">
<el-form-item label="配置名称" prop="name">
<el-input v-model.trim="form.name" maxlength="128" show-word-limit placeholder="例如:操场北侧危险区域" @input="dirty = true" />
</el-form-item>
<el-form-item label="配置类型" prop="kind">
<el-select v-model="form.kind" :disabled="Boolean(form.id)" @change="handleKindChange">
<el-option label="危险区域" value="polygon" />
<el-option label="方向警戒线" value="direction_line" />
</el-select>
</el-form-item>
<el-form-item label="设备与码流" prop="routeId" class="route-field">
<el-select
v-model="form.routeId"
filterable
remote
:remote-method="searchRoutes"
:loading="routesLoading"
placeholder="输入设备、位置或码流名称"
@change="handleRouteChange"
>
<el-option v-for="route in routeOptions" :key="route.id" :label="routeLabel(route)" :value="route.id" />
</el-select>
</el-form-item>
<el-form-item v-if="form.kind === 'direction_line'" label="警戒方向" prop="direction">
<el-select v-model="form.direction" @change="dirty = true">
<el-option label="从起点到终点" value="forward" />
<el-option label="从终点到起点" value="reverse" />
</el-select>
</el-form-item>
<el-form-item label="启用配置">
<el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" @change="dirty = true" />
</el-form-item>
</div>
<el-form-item label="绑定规格" class="profile-summary">
<span>{{ selectedProfileSummary }}</span>
</el-form-item>
<el-form-item label="绘制区域" prop="points">
<SenseGeometryEditor
:model-value="form.points"
:kind="form.kind"
:direction="form.direction"
:player-url="preview.playerUrl"
:disabled="!form.routeId"
@update:model-value="handlePointsChange"
/>
</el-form-item>
</el-form>
<p v-if="formError" class="form-error" role="alert">{{ formError }}</p>
<template #footer>
<el-button @click="requestEditorClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存新版本</el-button>
</template>
</el-dialog>
<el-dialog v-model="versionsOpen" title="版本记录" width="min(900px, 92vw)">
<el-table v-loading="versionsLoading" :data="versions" border>
<el-table-column label="版本" width="80">
<template #default="scope">v{{ scope.row.version }}</template>
</el-table-column>
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column label="绑定规格" min-width="190">
<template #default="scope">{{ scope.row.profileWidth }} × {{ scope.row.profileHeight }} · {{ scope.row.profileEncoding }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="scope">{{ scope.row.enabled ? '已启用' : '已停用' }}</template>
</el-table-column>
<el-table-column label="坐标点" width="90">
<template #default="scope">{{ scope.row.points.length }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" min-width="170">
<template #default="scope">{{ parseTime(scope.row.createdAt) }}</template>
</el-table-column>
</el-table>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { createAreaConfiguration, listAreaConfigurations, listAreaVersions, updateAreaConfiguration } from '@/api/sense/area'
import { createLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
import SenseGeometryEditor from '@/components/sense/geometry-editor'
import { buildAreaPayload, validateAreaForm } from './areaPayload'
const emptyForm = () => ({ id: '', name: '', kind: 'polygon', routeId: '', points: [], direction: '', enabled: true, version: 0, needsRecalibration: false })
export default {
name: 'SenseArea',
components: { SenseGeometryEditor },
data() {
return {
loading: false,
items: [],
total: 0,
queryParams: { keyword: '', kind: '', recalibrationState: '', pageIndex: 1, pageSize: 10 },
editorOpen: false,
saving: false,
dirty: false,
form: emptyForm(),
formError: '',
rules: {
name: [{ required: true, message: '请填写配置名称', trigger: 'blur' }],
kind: [{ required: true, message: '请选择配置类型', trigger: 'change' }],
routeId: [{ required: true, message: '请选择设备与视频码流', trigger: 'change' }]
},
routeOptions: [],
routesLoading: false,
preview: {},
versionsOpen: false,
versionsLoading: false,
versions: []
}
},
computed: {
selectedRoute() {
return this.routeOptions.find(route => route.id === this.form.routeId) || null
},
selectedProfileSummary() {
if (!this.selectedRoute) return '尚未选择视频码流'
return `${this.selectedRoute.deviceName} · ${this.selectedRoute.profileName || this.selectedRoute.profileToken} · ${this.selectedRoute.width} × ${this.selectedRoute.height} · ${this.selectedRoute.encoding}`
}
},
created() {
this.getList()
},
methods: {
async getList() {
this.loading = true
try {
const response = await listAreaConfigurations(this.queryParams)
this.items = response.data.list || []
this.total = response.data.count || 0
} finally {
this.loading = false
}
},
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
resetQuery() {
this.$refs.queryForm.resetFields()
this.handleQuery()
},
kindLabel(kind) {
return kind === 'polygon' ? '危险区域' : '方向警戒线'
},
profileLabel(item) {
return `${item.profileName || item.profileToken} · ${item.profileWidth} × ${item.profileHeight} · ${item.profileEncoding}`
},
routeLabel(route) {
return `${route.deviceName} · ${route.profileName || route.profileToken} · ${route.width}×${route.height}`
},
async searchRoutes(keyword = '') {
this.routesLoading = true
try {
const response = await listLiveviewRoutes({ keyword, pageIndex: 1, pageSize: 50 })
const routes = response.data.list || []
const selected = this.routeOptions.find(route => route.id === this.form.routeId)
this.routeOptions = selected && !routes.some(route => route.id === selected.id) ? [selected, ...routes] : routes
} finally {
this.routesLoading = false
}
},
async openCreate() {
this.form = emptyForm()
this.formError = ''
this.preview = {}
this.dirty = false
this.editorOpen = true
await this.searchRoutes('')
},
async openEdit(item) {
this.form = { id: item.id, name: item.name, kind: item.kind, routeId: item.routeId, points: item.points.map(point => ({ ...point })), direction: item.direction, enabled: item.enabled, version: item.version, needsRecalibration: item.needsRecalibration }
this.routeOptions = [{ id: item.routeId, deviceName: item.deviceName, profileName: item.profileName, profileToken: item.profileToken, width: item.profileWidth, height: item.profileHeight, encoding: item.profileEncoding }]
this.formError = ''
this.preview = {}
this.dirty = false
this.editorOpen = true
if (item.routeId) await this.openPreview()
},
handleKindChange() {
this.form.points = []
this.form.direction = this.form.kind === 'direction_line' ? 'forward' : ''
this.dirty = true
this.formError = ''
},
async handleRouteChange() {
this.form.points = []
this.dirty = true
this.formError = ''
await this.openPreview()
},
handlePointsChange(points) {
this.form.points = points
this.dirty = true
this.formError = ''
},
async openPreview() {
this.preview = {}
if (!this.form.routeId) return
try {
const response = await createLiveviewSession(this.form.routeId)
this.preview = response.data || {}
} catch (error) {
this.formError = '实时画面暂不可用;请先到“实时监看”确认该码流。'
}
},
async save() {
this.formError = validateAreaForm(this.form)
if (this.formError) return
try {
await this.$refs.editorForm.validate()
} catch (error) {
return
}
this.saving = true
try {
const payload = buildAreaPayload(this.form)
if (this.form.id) await updateAreaConfiguration(this.form.id, payload)
else await createAreaConfiguration(payload)
this.dirty = false
this.editorOpen = false
this.$message.success(this.form.id ? '已保存新版本' : '区域配置已创建')
await this.getList()
} catch (error) {
this.formError = error.message || '保存失败,请刷新后重试'
} finally {
this.saving = false
}
},
requestEditorClose() {
this.beforeEditorClose(() => { this.editorOpen = false })
},
beforeEditorClose(done) {
if (!this.dirty || this.saving) {
done()
return
}
this.$confirm('尚未保存的绘制内容将丢失,确定关闭吗?', '放弃未保存内容', { type: 'warning', confirmButtonText: '放弃并关闭', cancelButtonText: '继续编辑' }).then(done).catch(() => {})
},
async openVersions(item) {
this.versionsOpen = true
this.versionsLoading = true
this.versions = []
try {
const response = await listAreaVersions(item.id)
this.versions = response.data || []
} finally {
this.versionsLoading = false
}
}
}
}
</script>
<style scoped>
.page-header,
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-header h3 {
margin: 0 0 8px;
}
.page-header p,
.dialog-header span,
.muted-text,
.profile-summary span {
color: var(--el-text-color-secondary);
line-height: 1.5;
}
.dialog-header > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.search-form,
.empty-alert,
.dialog-alert {
margin-top: 16px;
}
.search-form :deep(.el-input) {
width: 240px;
}
.search-form :deep(.el-select) {
width: 180px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 20px;
}
.route-field {
grid-column: 1 / -1;
}
.route-field :deep(.el-select),
.form-grid :deep(.el-select) {
width: 100%;
}
.profile-summary {
margin-bottom: 12px;
}
.form-error {
margin: 12px 0 0 110px;
color: var(--el-color-danger);
line-height: 1.5;
}
@media (max-width: 767px) {
.page-header,
.dialog-header {
align-items: flex-start;
flex-direction: column;
}
.form-grid {
grid-template-columns: 1fr;
}
.route-field {
grid-column: auto;
}
.form-error {
margin-left: 0;
}
}
</style>
@@ -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 }
}
+306
View File
@@ -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>
+283
View File
@@ -0,0 +1,283 @@
<template>
<BasicLayout>
<template #wrapper>
<el-card class="box-card">
<div class="page-header">
<div>
<h3>实时监看</h3>
<p>按设备选择一路已验证视频;关闭窗口后不会继续占用播放器连接。</p>
</div>
</div>
<el-form ref="queryForm" :model="queryParams" inline label-position="left" class="search-form" @submit.prevent>
<el-form-item label="设备或位置" prop="keyword">
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入设备、位置或码流名称" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
<el-button @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-alert
v-if="!loading && total === 0"
title="没有可监看的视频。请先在“视频接入”完成验证,再到“视频服务”确认路径状态。"
type="info"
:closable="false"
show-icon
class="empty-alert"
/>
<el-table v-loading="loading" :data="routes" border stripe>
<el-table-column prop="deviceName" label="设备" min-width="160" show-overflow-tooltip />
<el-table-column prop="deviceLocation" label="安装位置" min-width="160" show-overflow-tooltip>
<template #default="scope">{{ scope.row.deviceLocation || '未填写' }}</template>
</el-table-column>
<el-table-column label="码流" min-width="170">
<template #default="scope">
<div>{{ profileLabel(scope.row) }}</div>
<small class="muted-text">{{ resolutionLabel(scope.row) }}</small>
</template>
</el-table-column>
<el-table-column label="状态" width="120">
<template #default="scope">
<el-tag :type="routeStatus(scope.row.actual).type" size="small">{{ routeStatus(scope.row.actual).label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="readers" label="当前观看" width="100" />
<el-table-column prop="detail" label="说明" min-width="220" show-overflow-tooltip />
<el-table-column label="操作" width="110" fixed="right">
<template #default="scope">
<el-button v-permisaction="['sense:liveview:view']" type="primary" link @click="handleWatch(scope.row)">实时查看</el-button>
</template>
</el-table-column>
</el-table>
<pagination
v-show="total > 0"
v-model:page="queryParams.pageIndex"
v-model:limit="queryParams.pageSize"
:total="total"
@pagination="getList"
/>
</el-card>
<el-dialog v-model="playerOpen" width="min(1000px, 92vw)" :close-on-click-modal="false" destroy-on-close @closed="closePlayer">
<template #header>
<div class="dialog-header">
<div>
<strong>{{ currentRoute.deviceName || '实时视频' }}</strong>
<span>{{ profileLabel(currentRoute) }} · {{ currentRoute.deviceLocation || '未填写位置' }}</span>
</div>
<el-tag :type="playbackStatusType(playerState)">{{ playbackStatusLabel(playerState) }}</el-tag>
</div>
</template>
<SenseVideoPlayer
v-if="playerOpen"
:state="playerState"
:detail="playerDetail"
:player-url="session.playerUrl"
:retrying="retrying"
@retry="openSession"
/>
<el-descriptions :column="3" border class="player-details">
<el-descriptions-item label="设备">{{ currentRoute.deviceName || '—' }}</el-descriptions-item>
<el-descriptions-item label="码流">{{ profileLabel(currentRoute) }}</el-descriptions-item>
<el-descriptions-item label="分辨率">{{ resolutionLabel(currentRoute) }}</el-descriptions-item>
<el-descriptions-item label="处理建议" :span="3">{{ playbackStatusDetail(playerState, playerDetail) }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button @click="playerOpen = false">关闭</el-button>
<el-button type="primary" :loading="retrying" @click="openSession">重新连接</el-button>
</template>
</el-dialog>
</template>
</BasicLayout>
</template>
<script>
import { createLiveviewSession, getLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
import SenseVideoPlayer from '@/components/sense/video-player'
import { playbackStatusDetail, playbackStatusLabel, playbackStatusType, routeStatus } from './playbackStatus'
export default {
name: 'SenseLiveview',
components: { SenseVideoPlayer },
data() {
return {
loading: false,
routes: [],
total: 0,
queryParams: { keyword: '', pageIndex: 1, pageSize: 10 },
playerOpen: false,
currentRoute: {},
session: {},
playerState: 'loading',
playerDetail: '',
retrying: false,
pollTimer: null,
timeoutTimer: null
}
},
created() {
this.getList()
},
beforeUnmount() {
this.stopTimers()
},
methods: {
playbackStatusDetail,
playbackStatusLabel,
playbackStatusType,
routeStatus,
profileLabel(route) {
if (!route || !route.id) return '—'
const kind = { main: '主码流', sub: '子码流', other: '其他码流' }[route.profileKind] || '码流'
return route.profileName ? `${kind}(${route.profileName})` : kind
},
resolutionLabel(route) {
if (!route || !route.width || !route.height) return '未取得分辨率'
return `${route.width} × ${route.height}${route.encoding ? ` · ${route.encoding}` : ''}`
},
async getList() {
this.loading = true
try {
const response = await listLiveviewRoutes(this.queryParams)
this.routes = response.data.list || []
this.total = response.data.count || 0
} finally {
this.loading = false
}
},
handleQuery() {
this.queryParams.pageIndex = 1
this.getList()
},
resetQuery() {
this.$refs.queryForm.resetFields()
this.handleQuery()
},
handleWatch(route) {
this.currentRoute = { ...route }
this.playerOpen = true
this.openSession()
},
async openSession() {
if (!this.currentRoute.id || this.retrying) return
this.stopTimers()
this.retrying = true
this.playerState = 'loading'
this.playerDetail = ''
this.session = {}
try {
const response = await createLiveviewSession(this.currentRoute.id)
this.session = response.data
this.playerState = response.data.status
this.playerDetail = response.data.detail
this.startPolling()
} catch (error) {
this.playerState = 'offline'
this.playerDetail = error.message || ''
} finally {
this.retrying = false
}
},
startPolling() {
this.pollTimer = window.setInterval(this.refreshSession, 2000)
this.timeoutTimer = window.setTimeout(() => {
if (['loading', 'waiting'].includes(this.playerState)) {
this.playerState = 'timeout'
this.playerDetail = ''
this.stopTimers()
}
}, 20000)
},
async refreshSession() {
if (!this.session.id || !this.playerOpen) return
try {
const response = await getLiveviewSession(this.session.id)
this.session = response.data
this.playerState = response.data.status
this.playerDetail = response.data.detail
if (this.playerState === 'ready') {
window.clearTimeout(this.timeoutTimer)
this.timeoutTimer = null
} else if (!['loading', 'waiting'].includes(this.playerState)) {
this.stopTimers()
}
} catch (error) {
this.playerState = 'expired'
this.playerDetail = ''
this.stopTimers()
}
},
stopTimers() {
window.clearInterval(this.pollTimer)
window.clearTimeout(this.timeoutTimer)
this.pollTimer = null
this.timeoutTimer = null
},
closePlayer() {
this.stopTimers()
this.session = {}
this.currentRoute = {}
this.playerState = 'loading'
this.playerDetail = ''
}
}
}
</script>
<style scoped>
.page-header,
.dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.page-header h3 {
margin: 0 0 8px;
}
.page-header p,
.dialog-header span,
.muted-text {
color: var(--el-text-color-secondary);
}
.dialog-header > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.search-form,
.empty-alert,
.player-details {
margin-top: 16px;
}
.search-form :deep(.el-input) {
width: 320px;
}
.muted-text {
line-height: 1.5;
}
@media (max-width: 767px) {
.search-form :deep(.el-input) {
width: min(280px, 70vw);
}
.dialog-header {
align-items: flex-start;
flex-direction: column;
}
}
</style>
@@ -0,0 +1,46 @@
const labels = {
loading: '正在连接',
waiting: '等待视频',
ready: '播放正常',
authentication_failed: '摄像头认证失败',
stream_not_found: '未找到视频流',
service_unavailable: '视频服务不可用',
timeout: '连接超时',
expired: '播放会话已过期',
offline: '视频已断开',
stopped: '视频已停止'
}
const details = {
loading: '正在建立短期播放会话,请稍候。',
waiting: '播放器已连接,正在等待摄像头开始传输画面。',
ready: '摄像头视频正在传输。',
authentication_failed: '请到“设备管理”更新摄像头账号或密码,再重新验证视频接入。',
stream_not_found: '请到“视频服务”执行对账,确认媒体路径已经恢复。',
service_unavailable: '请到“视频服务”检查 MediaMTX 进程和端口配置。',
timeout: '20 秒内未收到画面,请检查摄像头网络后重新连接。',
expired: '短期播放会话已失效,请重新连接。',
offline: '无法取得最新播放状态,请检查网络后重新连接。',
stopped: '该视频路径已停止,请先到“视频服务”恢复。'
}
export function playbackStatusLabel(value) {
return labels[value] || '暂时无法播放'
}
export function playbackStatusDetail(value, detail) {
return detail || details[value] || details.offline
}
export function playbackStatusType(value) {
if (value === 'ready') return 'success'
if (value === 'loading' || value === 'waiting') return 'warning'
if (value === 'stopped') return 'info'
return 'danger'
}
export function routeStatus(value) {
if (value === 'ready') return { label: '可观看', type: 'success' }
if (value === 'waiting') return { label: '等待观看', type: 'warning' }
return { label: '需要处理', type: 'danger' }
}
+55
View File
@@ -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,15 @@
import LoginPage from '@/views/login/index.vue'
describe('Sense login page', () => {
it('uses username and password without a captcha challenge', () => {
const state = LoginPage.data()
const getSystemSetting = jest.fn()
LoginPage.created.call({ getSystemSetting })
expect(Object.keys(state.loginForm)).toEqual(['username', 'password'])
expect(Object.keys(state.loginRules)).toEqual(['username', 'password'])
expect(LoginPage.methods.getCode).toBeUndefined()
expect(getSystemSetting).toHaveBeenCalledTimes(1)
})
})
@@ -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 })
})
})

Some files were not shown because too many files have changed in this diff Show More