Compare commits

...
Author SHA1 Message Date
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
QiuSW 1dd29b68fc docs: 记录 Sense 身份权限规则 (#64) 2026-08-14 12:12:42 +08:00
QiuSW 0411aa37c5 feat: 重建 Sense 认证权限与审计 (#64) 2026-08-14 12:03:10 +08:00
ila c515f00319 Merge PR #81: rebuild Sense from frozen GoAdmin sources
User accepted issue #61 on 2026-08-14.
2026-08-14 11:31:51 +08:00
117 changed files with 6949 additions and 143 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 的本地开发与验证页为准。
+1 -2
View File
@@ -21,13 +21,12 @@ func (e System) GenerateCaptchaHandler(c *gin.Context) {
e.Error(500, err, "服务初始化失败!")
return
}
id, b64s, answer, err := captcha.DriverDigitFunc()
id, b64s, _, err := captcha.DriverDigitFunc()
if err != nil {
e.Logger.Errorf("DriverDigitFunc error, %s", err.Error())
e.Error(500, err, "验证码获取失败")
return
}
e.Logger.Infof("DriverDigitFunc answer: %s", answer)
e.Custom(gin.H{
"code": 200,
"data": b64s,
@@ -0,0 +1,94 @@
package apis
import (
"crypto/subtle"
"errors"
"net/http"
"os"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
)
const bootstrapTokenHeader = "X-Sense-Bootstrap-Token"
type bootstrapRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
NickName string `json:"nickName"`
}
// Bootstrap creates the first Sense administrator. The one-time authorization
// token is supplied out of band and never accepted in the JSON request body.
func (e System) Bootstrap(c *gin.Context) {
if err := e.MakeContext(c).MakeOrm().Errors; err != nil {
e.Error(http.StatusInternalServerError, err, "安全初始化失败")
return
}
db := e.Orm
var req bootstrapRequest
if err := c.ShouldBindJSON(&req); err != nil {
e.Custom(gin.H{"code": http.StatusBadRequest, "msg": "安全初始化不可用或输入不符合要求"})
return
}
req.Username = strings.TrimSpace(req.Username)
if req.NickName == "" {
req.NickName = req.Username
}
if err := validateBootstrapInput(req); err != nil || !validBootstrapToken(c.GetHeader(bootstrapTokenHeader)) {
handler.WriteIdentityAudit(c, "1", "安全初始化被拒绝", req.Username)
e.Custom(gin.H{"code": http.StatusForbidden, "msg": "安全初始化不可用或输入不符合要求"})
return
}
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("LOCK TABLE sys_user IN EXCLUSIVE MODE").Error; err != nil {
return err
}
var count int64
if err := tx.Model(&models.SysUser{}).Count(&count).Error; err != nil {
return err
}
if count != 0 {
return errors.New("identity already initialized")
}
var role models.SysRole
if err := tx.Where("role_key = ? AND status = ?", "admin", "2").First(&role).Error; err != nil {
return err
}
return tx.Create(&models.SysUser{
Username: req.Username,
Password: req.Password,
NickName: req.NickName,
RoleId: role.RoleId,
Status: "2",
}).Error
})
if err != nil {
handler.WriteIdentityAudit(c, "1", "安全初始化被拒绝", req.Username)
e.Custom(gin.H{"code": http.StatusForbidden, "msg": "安全初始化不可用或输入不符合要求"})
return
}
handler.WriteIdentityAudit(c, "2", "首个管理员创建成功", req.Username)
e.Custom(gin.H{"code": http.StatusOK, "msg": "管理员创建成功"})
}
func validateBootstrapInput(req bootstrapRequest) error {
if len(req.Username) < 3 || len(req.Username) > 64 || strings.ContainsAny(req.Username, " \t\r\n") {
return errors.New("invalid username")
}
return service.ValidatePassword(req.Password)
}
func validBootstrapToken(candidate string) bool {
expected := os.Getenv("SENSE_BOOTSTRAP_TOKEN")
if len(expected) < 32 || len(candidate) != len(expected) {
return false
}
return subtle.ConstantTimeCompare([]byte(candidate), []byte(expected)) == 1
}
@@ -0,0 +1,33 @@
package apis
import "testing"
func TestValidateBootstrapInput(t *testing.T) {
if err := validateBootstrapInput(bootstrapRequest{Username: "admin", Password: "abcdef"}); err != nil {
t.Fatalf("expected valid bootstrap input: %v", err)
}
for _, req := range []bootstrapRequest{
{Username: "ab", Password: "abcdef"},
{Username: "bad name", Password: "abcdef"},
{Username: "admin", Password: "abcde"},
} {
if err := validateBootstrapInput(req); err == nil {
t.Fatalf("expected invalid bootstrap input: %+v", req)
}
}
}
func TestValidBootstrapToken(t *testing.T) {
const token = "0123456789abcdef0123456789abcdef"
t.Setenv("SENSE_BOOTSTRAP_TOKEN", token)
if !validBootstrapToken(token) {
t.Fatal("expected configured token to be accepted")
}
if validBootstrapToken("wrong") {
t.Fatal("expected wrong token to be rejected")
}
t.Setenv("SENSE_BOOTSTRAP_TOKEN", "short")
if validBootstrapToken("short") {
t.Fatal("expected short configured token to be rejected")
}
}
+19 -5
View File
@@ -1,9 +1,8 @@
package apis
import (
"github.com/gin-gonic/gin/binding"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/models"
"golang.org/x/crypto/bcrypt"
"github.com/gin-gonic/gin/binding"
"net/http"
"github.com/gin-gonic/gin"
@@ -15,6 +14,7 @@ import (
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/service/dto"
"git.ilapage.cn/ila/yovision/Sense/server/common/actions"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
)
type SysUser struct {
@@ -113,6 +113,10 @@ func (e SysUser) Insert(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
if err = service.ValidatePassword(req.Password); err != nil {
e.Error(http.StatusBadRequest, err, err.Error())
return
}
// 设置创建人
req.SetCreateBy(user.GetUserId(c))
err = s.Insert(&req)
@@ -306,6 +310,11 @@ func (e SysUser) ResetPwd(c *gin.Context) {
e.Error(500, err, err.Error())
return
}
if err = service.ValidatePassword(req.Password); err != nil {
handler.WriteIdentityAudit(c, "1", "管理员重置密码失败", user.GetUserName(c))
e.Error(http.StatusBadRequest, err, err.Error())
return
}
req.SetUpdateBy(user.GetUserId(c))
@@ -314,9 +323,11 @@ func (e SysUser) ResetPwd(c *gin.Context) {
err = s.ResetPwd(&req, p)
if err != nil {
handler.WriteIdentityAudit(c, "1", "管理员重置密码失败", user.GetUserName(c))
e.Logger.Error(err)
return
}
handler.WriteIdentityAudit(c, "2", "管理员重置密码成功", user.GetUserName(c))
e.OK(req.GetId(), "更新成功")
}
@@ -346,18 +357,21 @@ func (e SysUser) UpdatePwd(c *gin.Context) {
// 数据权限检查
p := actions.GetPermissionFromContext(c)
var hash []byte
if hash, err = bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost); err != nil {
req.NewPassword = string(hash)
if err = service.ValidatePassword(req.NewPassword); err != nil {
handler.WriteIdentityAudit(c, "1", "修改密码失败", user.GetUserName(c))
e.Error(http.StatusBadRequest, err, err.Error())
return
}
err = s.UpdatePwd(user.GetUserId(c), req.OldPassword, req.NewPassword, p)
if err != nil {
handler.WriteIdentityAudit(c, "1", "修改密码失败", user.GetUserName(c))
e.Logger.Error(err)
e.Error(http.StatusForbidden, err, "密码修改失败")
return
}
handler.WriteIdentityAudit(c, "2", "修改密码成功", user.GetUserName(c))
e.OK(nil, "密码修改成功")
}
+1 -1
View File
@@ -3,10 +3,10 @@ package router
import (
"os"
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
"github.com/gin-gonic/gin"
log "github.com/go-admin-team/go-admin-core/logger"
"github.com/go-admin-team/go-admin-core/sdk"
common "git.ilapage.cn/ila/yovision/Sense/server/common/middleware"
)
// InitRouter 路由初始化,不要怀疑,这里用到了
@@ -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,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)
}
-1
View File
@@ -9,7 +9,6 @@ import (
)
func init() {
routerCheckRole = append(routerCheckRole, registerSysApiRouter)
}
// registerSysApiRouter
@@ -9,7 +9,6 @@ import (
)
func init() {
routerCheckRole = append(routerCheckRole, registerSysConfigRouter)
}
// 需认证的路由代码
+4 -4
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"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() {
@@ -24,9 +24,9 @@ func registerSysDeptRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r.DELETE("", api.Delete)
}
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
r1.GET("/deptTree", api.Get2Tree)
}
}
}
+2 -2
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"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() {
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"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() {
@@ -21,4 +21,4 @@ func registerSysLoginLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"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() {
@@ -30,4 +30,4 @@ func registerSysMenuRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
//r1.GET("/menuids", api.GetMenuIDS)
}
}
}
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"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() {
@@ -20,4 +20,4 @@ func registerSysOperaLogRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMi
r.GET("/:id", api.Get)
r.DELETE("", api.Delete)
}
}
}
+3 -3
View File
@@ -1,10 +1,10 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"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() {
@@ -22,4 +22,4 @@ func registerSyPostRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlew
r.PUT("/:id", api.Update)
r.DELETE("", api.Delete)
}
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ func registerSysRoleRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r.PUT("/:id", api.Update)
r.DELETE("", api.Delete)
}
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc())
r1 := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
r1.PUT("/role-status", api.Update2Status)
r1.PUT("/roledatascope", api.Update2DataScope)
+3 -2
View File
@@ -68,6 +68,8 @@ func sysCheckRoleRouterInit(r *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
v1 := r.Group("/api/v1")
{
v1.GET("/captcha", apis.System{}.GenerateCaptchaHandler)
v1.POST("/bootstrap", apis.System{}.Bootstrap)
v1.POST("/login", authMiddleware.LoginHandler)
// Refresh time can be longer than token timeout
v1.GET("/refresh_token", authMiddleware.RefreshHandler)
@@ -81,8 +83,7 @@ func registerBaseRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddlewar
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole())
{
v1auth.GET("/roleMenuTreeselect/:roleId", api.GetMenuTreeSelect)
//v1.GET("/menuTreeselect", api.GetMenuTreeSelect)
v1auth.GET("/roleDeptTreeselect/:roleId", api2.GetDeptTreeRoleSelect)
v1auth.POST("/logout", handler.LogOut)
}
v1.Group("").Use(authMiddleware.MiddlewareFunc()).POST("/logout", handler.LogOut)
}
+9 -6
View File
@@ -1,11 +1,11 @@
package router
import (
"github.com/gin-gonic/gin"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/app/admin/apis"
"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() {
@@ -24,16 +24,19 @@ func registerSysUserRouter(v1 *gin.RouterGroup, authMiddleware *jwt.GinJWTMiddle
r.DELETE("", api.Delete)
}
user := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
user := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(actions.PermissionAction())
{
user.GET("/profile", api.GetProfile)
user.POST("/avatar", api.InsetAvatar)
user.PUT("/pwd/set", api.UpdatePwd)
user.PUT("/pwd/reset", api.ResetPwd)
user.PUT("/status", api.UpdateStatus)
}
userAdmin := v1.Group("/user").Use(authMiddleware.MiddlewareFunc()).Use(middleware.AuthCheckRole()).Use(actions.PermissionAction())
{
userAdmin.PUT("/pwd/reset", api.ResetPwd)
userAdmin.PUT("/status", api.UpdateStatus)
}
v1auth := v1.Group("").Use(authMiddleware.MiddlewareFunc())
{
v1auth.GET("/getinfo", api.GetInfo)
}
}
}
@@ -0,0 +1,23 @@
package service
import (
"errors"
"unicode/utf8"
)
const (
MinPasswordLength = 6
MaxPasswordBytes = 72
)
// ValidatePassword enforces the frozen Sense policy. Lowercase-only passwords
// are valid; complexity rules are intentionally not added here.
func ValidatePassword(password string) error {
if utf8.RuneCountInString(password) < MinPasswordLength {
return errors.New("密码至少需要 6 个字符")
}
if len(password) > MaxPasswordBytes {
return errors.New("密码不能超过 72 个字节")
}
return nil
}
@@ -0,0 +1,26 @@
package service
import (
"strings"
"testing"
)
func TestValidatePassword(t *testing.T) {
tests := []struct {
name string
value string
wantErr bool
}{
{name: "lowercase accepted", value: "chengma", wantErr: false},
{name: "six characters accepted", value: "abcdef", wantErr: false},
{name: "too short", value: "abcde", wantErr: true},
{name: "bcrypt byte limit", value: strings.Repeat("a", MaxPasswordBytes+1), wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ValidatePassword(tt.value); (got != nil) != tt.wantErr {
t.Fatalf("ValidatePassword() error = %v, wantErr %v", got, tt.wantErr)
}
})
}
}
+8 -6
View File
@@ -103,12 +103,13 @@ func (e *SysRole) Insert(c *dto.SysRoleInsertReq, cb *casbin.SyncedEnforcer) err
return err
}
mp := make(map[string]interface{}, 0)
mp := make(map[string]struct{})
polices := make([][]string, 0)
for _, menu := range dataMenu {
for _, api := range menu.SysApi {
if mp[data.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
mp[data.RoleKey+"-"+api.Path+"-"+api.Action] = ""
key := data.RoleKey + "-" + api.Path + "-" + api.Action
if _, exists := mp[key]; !exists {
mp[key] = struct{}{}
polices = append(polices, []string{data.RoleKey, api.Path, api.Action})
}
}
@@ -169,12 +170,13 @@ func (e *SysRole) Update(c *dto.SysRoleUpdateReq, cb *casbin.SyncedEnforcer) err
e.Log.Errorf("delete policy error:%s", err)
return err
}
mp := make(map[string]interface{}, 0)
mp := make(map[string]struct{})
polices := make([][]string, 0)
for _, menu := range mlist {
for _, api := range menu.SysApi {
if mp[model.RoleKey+"-"+api.Path+"-"+api.Action] != "" {
mp[model.RoleKey+"-"+api.Path+"-"+api.Action] = ""
key := model.RoleKey + "-" + api.Path + "-" + api.Action
if _, exists := mp[key]; !exists {
mp[key] = struct{}{}
//_, err = cb.AddNamedPolicy("p", model.RoleKey, api.Path, api.Action)
polices = append(polices, []string{model.RoleKey, api.Path, api.Action})
}
+5 -2
View File
@@ -161,6 +161,9 @@ func (e *SysUser) UpdateStatus(c *dto.UpdateSysUserStatusReq, p *actions.DataPer
// ResetPwd 重置用户密码
func (e *SysUser) ResetPwd(c *dto.ResetSysUserPwdReq, p *actions.DataPermission) error {
var err error
if err = ValidatePassword(c.Password); err != nil {
return err
}
var model models.SysUser
db := e.Orm.Scopes(
actions.Permission(model.TableName(), p),
@@ -204,8 +207,8 @@ func (e *SysUser) Remove(c *dto.SysUserById, p *actions.DataPermission) error {
func (e *SysUser) UpdatePwd(id int, oldPassword, newPassword string, p *actions.DataPermission) error {
var err error
if newPassword == "" {
return nil
if err = ValidatePassword(newPassword); err != nil {
return err
}
c := &models.SysUser{}
+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" }
+171
View File
@@ -0,0 +1,171 @@
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/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 {
return tx.Create(&result.Profiles).Error
}
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,107 @@
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/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 }
func (f fakeONVIF) Profiles(context.Context, string, onvif.Credential) ([]onvif.Profile, error) {
if f.err != nil {
return nil, f.err
}
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)
}
}
}
@@ -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,124 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
type identityRoleDefinition struct {
name string
key string
sort int
menuIDs []int
policies [][2]string
}
type identityCasbinRule 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 (identityCasbinRule) TableName() string { return "casbin_rule" }
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseIdentityBaseline)
}
func migrateSenseIdentityBaseline(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&identityCasbinRule{}); err != nil {
return err
}
resetPasswordMenu, err := ensureIdentityButton(tx, "SenseResetPassword", "重置用户密码", "admin:sysUser:resetPassword", 154)
if err != nil {
return err
}
changeStatusMenu, err := ensureIdentityButton(tx, "SenseChangeUserStatus", "启停用户", "admin:sysUser:edit", 155)
if err != nil {
return err
}
definitions := []identityRoleDefinition{
{name: "系统管理员", key: "admin", sort: 1},
{
name: "实施运维", key: "implementation_operator", sort: 10,
menuIDs: []int{2, 211, 212, 216, 248, 250},
policies: [][2]string{{"/api/v1/sys-login-log", "GET"}, {"/api/v1/sys-login-log/:id", "GET"}, {"/api/v1/sys-opera-log", "GET"}, {"/api/v1/sys-opera-log/:id", "GET"}, {"/api/v1/dict-data/option-select", "GET"}},
},
{
name: "站点管理员", key: "site_admin", sort: 20,
menuIDs: []int{2, 3, 43, 44, 45, resetPasswordMenu.MenuId, changeStatusMenu.MenuId},
policies: [][2]string{{"/api/v1/sys-user", "GET"}, {"/api/v1/sys-user/:id", "GET"}, {"/api/v1/sys-user", "POST"}, {"/api/v1/sys-user", "PUT"}, {"/api/v1/user/pwd/reset", "PUT"}, {"/api/v1/user/status", "PUT"}, {"/api/v1/deptTree", "GET"}, {"/api/v1/post", "GET"}, {"/api/v1/role", "GET"}, {"/api/v1/dict-data/option-select", "GET"}},
},
{name: "只读用户", key: "viewer", sort: 30},
}
for _, definition := range definitions {
role, err := ensureIdentityRole(tx, definition)
if err != nil {
return err
}
if err = attachIdentityMenus(tx, &role, definition.menuIDs); err != nil {
return err
}
for _, policy := range definition.policies {
rule := identityCasbinRule{Ptype: "p", V0: definition.key, 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
})
}
func ensureIdentityRole(tx *gorm.DB, definition identityRoleDefinition) (models.SysRole, error) {
var role models.SysRole
err := tx.Where("role_key = ?", definition.key).First(&role).Error
if err != nil && err != gorm.ErrRecordNotFound {
return role, err
}
if err == gorm.ErrRecordNotFound {
role = models.SysRole{RoleName: definition.name, RoleKey: definition.key, RoleSort: definition.sort, Status: "2", Admin: definition.key == "admin", DataScope: "1", Remark: "Sense 内置最小权限角色"}
return role, tx.Create(&role).Error
}
err = tx.Model(&role).Updates(map[string]interface{}{"role_name": definition.name, "status": "2", "role_sort": definition.sort, "admin": definition.key == "admin"}).Error
return role, err
}
func ensureIdentityButton(tx *gorm.DB, name, title, permission string, apiID int) (models.SysMenu, error) {
menu := models.SysMenu{MenuName: name, Title: title, MenuType: "F", Action: "PUT", Permission: permission, ParentId: 3, Paths: "/0/2/3", Sort: 25, Visible: "1", IsFrame: "1"}
if err := tx.Where("menu_name = ?", name).FirstOrCreate(&menu).Error; err != nil {
return menu, err
}
var api models.SysApi
if err := tx.First(&api, apiID).Error; err != nil {
return menu, err
}
return menu, tx.Model(&menu).Association("SysApi").Append(&api)
}
func attachIdentityMenus(tx *gorm.DB, role *models.SysRole, menuIDs []int) error {
if len(menuIDs) == 0 {
return nil
}
var menus []models.SysMenu
if err := tx.Where("menu_id IN ?", menuIDs).Find(&menus).Error; err != nil {
return err
}
return tx.Model(role).Association("SysMenu").Append(&menus)
}
@@ -0,0 +1,123 @@
package version
import (
"fmt"
"runtime"
"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 := 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
})
}
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,60 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseAdmission)
}
func migrateSenseAdmission(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&admission.Result{}, &admission.Profile{}); err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmission", Title: "视频接入", Icon: "video-camera", Path: "/sense/admission", MenuType: "C", Permission: "sense:admission:list", Component: "/sense/admission/index", Sort: 6, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
discover, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionDiscover", Title: "发现设备", MenuType: "F", Action: "GET", Permission: "sense:admission:discover", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
probe, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseAdmissionProbe", Title: "验证接入", MenuType: "F", Action: "POST", Permission: "sense:admission:probe", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, discover, probe}); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
return err
}
read := [][2]string{{"/api/v1/admission/devices/:id", "GET"}}
write := [][2]string{{"/api/v1/admission/discover", "GET"}, {"/api/v1/admission/devices/:id/probe", "POST"}}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
policies := append([][2]string{}, read...)
if role != "viewer" {
policies = append(policies, write...)
}
for _, policy := range policies {
rule := deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&rule).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,60 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseMedia)
}
func migrateSenseMedia(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(&media.Route{}); err != nil {
return err
}
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMedia", Title: "视频服务", Icon: "video-play", Path: "/sense/media", MenuType: "C", Permission: "sense:media:list", Component: "/sense/media/index", Sort: 7, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
reconcile, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaReconcile", Title: "立即对账", MenuType: "F", Action: "POST", Permission: "sense:media:reconcile", ParentId: page.MenuId, Sort: 1, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
stop, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseMediaStop", Title: "停止路径", MenuType: "F", Action: "POST", Permission: "sense:media:stop", ParentId: page.MenuId, Sort: 2, Visible: "1", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page, reconcile, stop}); err != nil {
return err
}
}
if err = attachDeviceRole(tx, "viewer", []migrationModels.SysMenu{page}); err != nil {
return err
}
read := [][2]string{{"/api/v1/media/routes", "GET"}, {"/api/v1/media/process", "GET"}}
write := [][2]string{{"/api/v1/media/reconcile", "POST"}, {"/api/v1/media/routes/:id/reconcile", "POST"}, {"/api/v1/media/routes/:id/stop", "POST"}}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
policies := append([][2]string{}, read...)
if role != "viewer" {
policies = append(policies, write...)
}
for _, policy := range policies {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,54 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestMediaMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_MEDIA_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_MEDIA_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sys_role_menu, sys_menu, sys_role, casbin_rule, sys_migration CASCADE")
})
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseMedia(db, "2026081419000_media.go"); err != nil {
t.Fatal(err)
}
var routes, menus, policies, applied int64
if err = db.Model(&media.Route{}).Count(&routes).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&migrationModels.SysMenu{}).Where("menu_name LIKE ?", "SenseMedia%").Count(&menus).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/media%").Count(&policies).Error; err != nil {
t.Fatal(err)
}
if err = db.Model(&common.Migration{}).Where("version = ?", "2026081419000_media.go").Count(&applied).Error; err != nil {
t.Fatal(err)
}
if routes != 0 || menus != 3 || policies != 12 || applied != 1 {
t.Fatalf("routes=%d menus=%d policies=%d applied=%d", routes, menus, policies, applied)
}
}
@@ -0,0 +1,37 @@
package version
import (
"runtime"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateSenseLiveview)
}
func migrateSenseLiveview(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
page, err := ensureDeviceMenu(tx, migrationModels.SysMenu{MenuName: "SenseLiveview", Title: "实时监看", Icon: "eye-open", Path: "/sense/liveview", MenuType: "C", Permission: "sense:liveview:view", Component: "/sense/liveview/index", Sort: 8, Visible: "0", IsFrame: "1"})
if err != nil {
return err
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = attachDeviceRole(tx, role, []migrationModels.SysMenu{page}); err != nil {
return err
}
for _, policy := range [][2]string{{"/api/v1/liveview/routes", "GET"}, {"/api/v1/liveview/sessions", "POST"}, {"/api/v1/liveview/sessions/:id", "GET"}} {
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&deviceCasbinRule{Ptype: "p", V0: role, V1: policy[0], V2: policy[1]}).Error; err != nil {
return err
}
}
}
return tx.Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,57 @@
package version
import (
"os"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
migrationModels "git.ilapage.cn/ila/yovision/Sense/server/cmd/migrate/migration/models"
common "git.ilapage.cn/ila/yovision/Sense/server/common/models"
)
func TestLiveviewMigrationOnPostgres(t *testing.T) {
dsn := os.Getenv("SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL to run the PostgreSQL migration test")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
const schema = "sense_liveview_68_test"
if err = db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE SCHEMA " + schema).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE") })
sqlDB, err := db.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
if err = db.Exec("SET search_path TO " + schema).Error; err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&migrationModels.SysRole{}, &migrationModels.SysMenu{}, &deviceCasbinRule{}, &common.Migration{}); err != nil {
t.Fatal(err)
}
for _, role := range []string{"implementation_operator", "site_admin", "viewer"} {
if err = db.Create(&migrationModels.SysRole{RoleName: role, RoleKey: role, Status: "2"}).Error; err != nil {
t.Fatal(err)
}
}
if err = migrateSenseLiveview(db, "2026081420000_liveview.go"); err != nil {
t.Fatal(err)
}
var menus, policies, applied int64
db.Model(&migrationModels.SysMenu{}).Where("menu_name = ?", "SenseLiveview").Count(&menus)
db.Model(&deviceCasbinRule{}).Where("v1 LIKE ?", "/api/v1/liveview%").Count(&policies)
db.Model(&common.Migration{}).Where("version = ?", "2026081420000_liveview.go").Count(&applied)
if menus != 1 || policies != 9 || applied != 1 {
t.Fatalf("menus=%d policies=%d applied=%d", menus, policies, applied)
}
}
+4 -4
View File
@@ -3,9 +3,9 @@ package middleware
import (
"time"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
"github.com/go-admin-team/go-admin-core/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
)
// AuthInit jwt验证new
@@ -19,7 +19,7 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
}
}
return jwt.New(&jwt.GinJWTMiddleware{
Realm: "test zone",
Realm: "Sense",
Key: []byte(config.JwtConfig.Secret),
Timeout: timeout,
MaxRefresh: time.Hour,
@@ -28,9 +28,9 @@ func AuthInit() (*jwt.GinJWTMiddleware, error) {
Authenticator: handler.Authenticator,
Authorizator: handler.Authorizator,
Unauthorized: handler.Unauthorized,
TokenLookup: "header: Authorization, query: token, cookie: jwt",
TokenLookup: "header: Authorization, cookie: sense_session",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
})
}
}
+37 -28
View File
@@ -6,7 +6,6 @@ import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"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"
@@ -15,7 +14,6 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
"github.com/go-admin-team/go-admin-core/sdk/pkg/response"
"github.com/mssola/user_agent"
"git.ilapage.cn/ila/yovision/Sense/server/common/global"
)
func PayloadFunc(data interface{}) jwt.MapClaims {
@@ -28,7 +26,7 @@ func PayloadFunc(data interface{}) jwt.MapClaims {
jwt.RoleKey: r.RoleKey,
jwt.NiceKey: u.Username,
jwt.DataScopeKey: r.DataScope,
jwt.RoleNameKey: r.RoleName,
jwt.RoleNameKey: r.RoleKey,
}
}
return jwt.MapClaims{}
@@ -108,36 +106,44 @@ func Authenticator(c *gin.Context) (interface{}, error) {
// LoginLogToDB Write log to database
func LoginLogToDB(c *gin.Context, status string, msg string, username string) {
if !config.LoggerConfig.EnabledDB {
WriteIdentityAudit(c, status, msg, username)
}
// WriteIdentityAudit persists identity/security events synchronously. It never
// records request bodies, credentials, tokens, captcha answers, or cookies.
func WriteIdentityAudit(c *gin.Context, status string, msg string, username string) {
log := api.GetRequestLogger(c)
db, err := pkg.GetOrm(c)
if err != nil {
log.Errorf("identity audit database unavailable: %s", err.Error())
return
}
log := api.GetRequestLogger(c)
l := make(map[string]interface{})
ua := user_agent.New(c.Request.UserAgent())
l["ipaddr"] = common.GetClientIP(c)
l["loginLocation"] = "" // pkg.GetLocation(common.GetClientIP(c),gaConfig.ExtConfig.AMap.Key)
l["loginTime"] = pkg.GetCurrentTime()
l["status"] = status
l["remark"] = c.Request.UserAgent()
browserName, browserVersion := ua.Browser()
l["browser"] = browserName + " " + browserVersion
l["os"] = ua.OS()
l["platform"] = ua.Platform()
l["username"] = username
l["msg"] = msg
q := sdk.Runtime.GetMemoryQueue(c.Request.Host)
message, err := sdk.Runtime.GetStreamMessage("", global.LoginLog, l)
if err != nil {
log.Errorf("GetStreamMessage error, %s", err.Error())
//日志报错错误,不中断请求
} else {
err = q.Append(message)
if err != nil {
log.Errorf("Append message error, %s", err.Error())
}
l := models.SysLoginLog{
Username: truncateAuditText(username, 128),
Status: status,
Ipaddr: truncateAuditText(common.GetClientIP(c), 255),
LoginLocation: "",
Browser: truncateAuditText(browserName+" "+browserVersion, 255),
Os: truncateAuditText(ua.OS(), 255),
Platform: truncateAuditText(ua.Platform(), 255),
LoginTime: pkg.GetCurrentTime(),
Remark: truncateAuditText(c.Request.Method+" "+c.FullPath(), 255),
Msg: truncateAuditText(msg, 255),
}
if err = db.Create(&l).Error; err != nil {
log.Errorf("identity audit write failed: %s", err.Error())
}
}
func truncateAuditText(value string, limit int) string {
runes := []rune(value)
if len(runes) <= limit {
return value
}
return string(runes[:limit])
}
// LogOut
@@ -164,7 +170,7 @@ func Authorizator(data interface{}, c *gin.Context) bool {
if v, ok := data.(map[string]interface{}); ok {
u, _ := v["user"].(models.SysUser)
r, _ := v["role"].(models.SysRole)
c.Set("role", r.RoleName)
c.Set("role", r.RoleKey)
c.Set("roleIds", r.RoleId)
c.Set("userId", u.UserId)
c.Set("userName", u.Username)
@@ -175,6 +181,9 @@ func Authorizator(data interface{}, c *gin.Context) bool {
}
func Unauthorized(c *gin.Context, code int, message string) {
if c.FullPath() != "/api/v1/login" {
WriteIdentityAudit(c, "1", "未认证访问被拒绝", "")
}
c.JSON(http.StatusOK, gin.H{
"code": code,
"msg": message,
@@ -0,0 +1,12 @@
package handler
import "testing"
func TestTruncateAuditText(t *testing.T) {
if got := truncateAuditText("中文审计记录", 3); got != "中文审" {
t.Fatalf("unexpected truncation: %q", got)
}
if got := truncateAuditText("short", 10); got != "short" {
t.Fatalf("unexpected unchanged value: %q", got)
}
}
+45 -1
View File
@@ -95,11 +95,55 @@ func LoggerToFile() gin.HandlerFunc {
log.Fields(map[string]interface{}{})
}()
if c.Request.Method != "OPTIONS" && config.LoggerConfig.EnabledDB && statusCode != 404 {
SetDBOperLog(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, body, result, statusBus)
SetDBOperLog(c, clientIP, statusCode, reqUri, reqMethod, latencyTime, sanitizeAuditJSON(body), sanitizeAuditJSON(result), statusBus)
}
}
}
func sanitizeAuditJSON(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
var decoded interface{}
if err := json.Unmarshal([]byte(value), &decoded); err != nil {
return "[内容已隐藏]"
}
redactAuditValue(decoded)
encoded, err := json.Marshal(decoded)
if err != nil {
return "[内容已隐藏]"
}
return string(encoded)
}
func redactAuditValue(value interface{}) {
switch current := value.(type) {
case map[string]interface{}:
for key, item := range current {
if isSensitiveAuditKey(key) {
current[key] = "[已隐藏]"
continue
}
redactAuditValue(item)
}
case []interface{}:
for _, item := range current {
redactAuditValue(item)
}
}
}
func isSensitiveAuditKey(key string) bool {
normalized := strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(key, "_", ""), "-", ""))
switch normalized {
case "password", "oldpassword", "newpassword", "onvifusername", "onvifpassword", "rtspusername", "rtsppassword",
"token", "refreshtoken", "secret", "code", "uuid":
return true
default:
return false
}
}
// SetDBOperLog 写入操作日志表 fixme 该方法后续即将弃用
func SetDBOperLog(c *gin.Context, clientIP string, statusCode int, reqUri string, reqMethod string, latencyTime time.Duration, body string, result string, status int) {
@@ -0,0 +1,37 @@
package middleware
import (
"strings"
"testing"
)
func TestSanitizeAuditJSON(t *testing.T) {
input := `{"username":"operator","password":"secret-value","nested":{"newPassword":"another-secret"},"token":"jwt-value"}`
got := sanitizeAuditJSON(input)
for _, secret := range []string{"secret-value", "another-secret", "jwt-value"} {
if strings.Contains(got, secret) {
t.Fatalf("sensitive value leaked: %s", got)
}
}
if !strings.Contains(got, "operator") {
t.Fatalf("non-sensitive context was unexpectedly removed: %s", got)
}
}
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)
}
}
@@ -4,6 +4,7 @@ import (
"github.com/casbin/casbin/v2/util"
"net/http"
"git.ilapage.cn/ila/yovision/Sense/server/common/middleware/handler"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"github.com/go-admin-team/go-admin-core/sdk/api"
@@ -49,6 +50,7 @@ func AuthCheckRole() gin.HandlerFunc {
c.Next()
} else {
log.Warnf("isTrue: %v role: %s method: %s path: %s message: %s", res, v["rolekey"], c.Request.Method, c.Request.URL.Path, "当前request无权限,请管理员确认!")
handler.WriteIdentityAudit(c, "1", "拒绝访问 "+c.Request.Method+" "+c.FullPath(), userNameFromClaims(v))
c.JSON(http.StatusOK, gin.H{
"code": 403,
"msg": "对不起,您没有该接口访问权限,请联系管理员",
@@ -59,3 +61,10 @@ func AuthCheckRole() gin.HandlerFunc {
}
}
func userNameFromClaims(claims jwtauth.MapClaims) string {
if value, ok := claims["nice"].(string); ok {
return value
}
return ""
}
+1 -35
View File
@@ -6,38 +6,4 @@ type UrlInfo struct {
}
// CasbinExclude casbin 排除的路由列表
var CasbinExclude = []UrlInfo{
{Url: "/api/v1/dict/type-option-select", Method: "GET"},
{Url: "/api/v1/dict-data/option-select", Method: "GET"},
{Url: "/api/v1/deptTree", Method: "GET"},
{Url: "/api/v1/db/tables/page", Method: "GET"},
{Url: "/api/v1/db/columns/page", Method: "GET"},
{Url: "/api/v1/gen/toproject/:tableId", Method: "GET"},
{Url: "/api/v1/gen/todb/:tableId", Method: "GET"},
{Url: "/api/v1/gen/tabletree", Method: "GET"},
{Url: "/api/v1/gen/preview/:tableId", Method: "GET"},
{Url: "/api/v1/gen/apitofile/:tableId", Method: "GET"},
{Url: "/api/v1/getCaptcha", Method: "GET"},
{Url: "/api/v1/getinfo", Method: "GET"},
{Url: "/api/v1/menuTreeselect", Method: "GET"},
{Url: "/api/v1/menurole", Method: "GET"},
{Url: "/api/v1/menuids", Method: "GET"},
{Url: "/api/v1/roleMenuTreeselect/:roleId", Method: "GET"},
{Url: "/api/v1/roleDeptTreeselect/:roleId", Method: "GET"},
{Url: "/api/v1/refresh_token", Method: "GET"},
{Url: "/api/v1/configKey/:configKey", Method: "GET"},
{Url: "/api/v1/app-config", Method: "GET"},
{Url: "/api/v1/user/profile", Method: "GET"},
{Url: "/info", Method: "GET"},
{Url: "/api/v1/login", Method: "POST"},
{Url: "/api/v1/logout", Method: "POST"},
{Url: "/api/v1/user/avatar", Method: "POST"},
{Url: "/api/v1/user/pwd", Method: "PUT"},
{Url: "/api/v1/metrics", Method: "GET"},
{Url: "/api/v1/health", Method: "GET"},
{Url: "/", Method: "GET"},
{Url: "/api/v1/server-monitor", Method: "GET"},
{Url: "/api/v1/public/uploadFile", Method: "POST"},
{Url: "/api/v1/user/pwd/set", Method: "PUT"},
{Url: "/api/v1/sys-user", Method: "PUT"},
}
var CasbinExclude = []UrlInfo{}
@@ -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
+3
View File
@@ -0,0 +1,3 @@
# 首次创建管理员时临时设置为至少 32 个字符的随机值。
# 初始化成功后应从运行环境移除;不要把真实值写入本文件或仓库。
SENSE_BOOTSTRAP_TOKEN=
@@ -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 -1
View File
@@ -19,7 +19,7 @@ settings:
stdout: '' #控制台日志,启用后,不输出到文件
# 日志等级, trace, debug, info, warn, error, fatal
level: info
# 数据库日志开关
# 通用操作数据库日志开关;登录、退出、密码和拒绝访问等身份审计始终写入数据库。
enableddb: false
jwt:
# 必填。生产环境至少 32 个字符;不得提交真实值。
@@ -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 }) }
+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,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>
+1 -1
View File
@@ -1,6 +1,6 @@
import Cookies from 'js-cookie'
const TokenKey = 'Admin-Token'
const TokenKey = 'Sense-Admin-Token'
export function getToken() {
return Cookies.get(TokenKey)
+7 -8
View File
@@ -353,8 +353,6 @@ export default {
open: false,
// 部门名称
deptName: undefined,
// 默认密码
initPassword: undefined,
// 日期范围
dateRange: [],
// 状态数据字典
@@ -400,7 +398,10 @@ export default {
username: [{ required: true, message: '用户名称不能为空', trigger: 'blur' }],
nickName: [{ required: true, message: '用户昵称不能为空', trigger: 'blur' }],
deptId: [{ required: true, message: '归属部门不能为空', trigger: 'blur' }],
password: [{ required: true, message: '用户密码不能为空', trigger: 'blur' }],
password: [
{ required: true, message: '用户密码不能为空', trigger: 'blur' },
{ min: 6, max: 72, message: '密码长度为 6 至 72 个字符', trigger: 'blur' }
],
email: [
{ required: true, message: '邮箱地址不能为空', trigger: 'blur' },
{ type: 'email', message: "'请输入正确的邮箱地址", trigger: ['blur', 'change'] }
@@ -427,9 +428,6 @@ export default {
this.getDicts('sys_user_sex').then(response => {
this.sexOptions = response.data
})
this.getConfigKey('sys_user_initPassword').then(response => {
this.initPassword = response.data.configValue
})
},
methods: {
/** 查询用户列表 */
@@ -556,7 +554,6 @@ export default {
})
this.open = true
this.title = '添加用户'
this.form.password = this.initPassword
},
/** 修改按钮操作 */
handleUpdate(row) {
@@ -580,7 +577,9 @@ export default {
handleResetPwd(row) {
this.$prompt('请输入"' + row.username + '"的新密码', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消'
cancelButtonText: '取消',
inputPattern: /^.{6,72}$/,
inputErrorMessage: '密码长度为 6 至 72 个字符'
}).then(({ value }) => {
resetUserPwd(row.userId, value).then(response => {
if (response.code === 200) {
@@ -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,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'
}

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