Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17c1afd195 | ||
|
|
c83c181a12 | ||
|
|
a279a1ec0d | ||
|
|
c63c623df5 | ||
|
|
b1bdb91fdb | ||
|
|
35b9c7b8a4 | ||
|
|
263a68c98d | ||
|
|
4149cc4426 | ||
|
|
19f9bfa1a0 | ||
|
|
24cf4bbd65 |
@@ -44,4 +44,6 @@ go run . server -c C:\secure-path\sense-settings.yml
|
||||
|
||||
使用 ONVIF 发现或手工接入前,还必须设置 `SENSE_ONVIF_DISCOVERY_IP` 和 `SENSE_ONVIF_ALLOWED_CIDRS`。前者只能是获准用于 WS-Discovery 的本机网卡地址;后者是获准访问的摄像头网段(多个 CIDR 用逗号分隔)。未配置时系统会给出可行动提示且不会扫描任意网卡;手工地址、Media XAddr 和 Stream URI 同样受该网段限制,并拒绝重定向或 URL 内凭据。
|
||||
|
||||
MediaMTX 保持独立二进制。配置 `SENSE_MEDIAMTX_BINARY`、`SENSE_MEDIAMTX_CONFIG` 和只允许回环地址的 `SENSE_MEDIAMTX_API`。Sense 只生成无摄像头凭据的基础配置;路径和凭据在运行时通过回环 Control API 下发。模板见 `server/config/mediamtx/mediamtx.yml.example`。
|
||||
|
||||
仓库不提供默认账号、默认密码或可用密钥。首位管理员通过受仓库外 `SENSE_BOOTSTRAP_TOKEN` 保护的一次性初始化接口创建,详细步骤以项目 Wiki 的本地开发与验证页为准。
|
||||
|
||||
@@ -0,0 +1,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)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -53,6 +54,9 @@ func (e *API) Probe(c *gin.Context) {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth/user"
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
base := coreService.Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewService(base.Orm, nil), nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||
return
|
||||
}
|
||||
pageIndex, _ := strconv.Atoi(c.DefaultQuery("pageIndex", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
items, total, err := service.List(c.Request.Context(), PageRequest{Keyword: c.Query("keyword"), PageIndex: pageIndex, PageSize: pageSize})
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.PageOK(items, int(total), pageIndex, pageSize, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Create(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||
return
|
||||
}
|
||||
var request struct {
|
||||
RouteID string `json:"routeId"`
|
||||
}
|
||||
if err = decodeJSON(c, &request); err != nil {
|
||||
e.Error(http.StatusBadRequest, err, "请求内容格式不正确")
|
||||
return
|
||||
}
|
||||
session, err := service.Create(c.Request.Context(), user.GetUserId(c), request.RouteID, c.Request)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(session, "播放会话已创建")
|
||||
}
|
||||
|
||||
func (e *API) Get(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看服务初始化失败")
|
||||
return
|
||||
}
|
||||
session, err := service.Get(c.Request.Context(), user.GetUserId(c), c.Param("id"))
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(session, "查询成功")
|
||||
}
|
||||
|
||||
var playerTemplate = template.Must(template.New("liveview-player").Parse(`<!doctype html>
|
||||
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>html,body,iframe{width:100%;height:100%;margin:0;border:0;background:#101419;overflow:hidden}</style></head>
|
||||
<body><iframe src="{{.}}" title="Sense 实时视频" allow="autoplay; fullscreen" referrerpolicy="no-referrer"></iframe></body></html>`))
|
||||
|
||||
func (e *API) Player(c *gin.Context) {
|
||||
target, err := NewService(nil, nil).PlayerTarget(c.Param("id"))
|
||||
if err != nil {
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.String(http.StatusGone, "播放会话已过期,请重新连接")
|
||||
return
|
||||
}
|
||||
parsed := template.URL(target)
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Header("Referrer-Policy", "no-referrer")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "SAMEORIGIN")
|
||||
c.Header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'self'; frame-src http: https:; style-src 'unsafe-inline'")
|
||||
if err = playerTemplate.Execute(c.Writer, parsed); err != nil {
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrInvalidRequest):
|
||||
e.Error(http.StatusBadRequest, err, err.Error())
|
||||
case errors.Is(err, ErrRouteNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, ErrSessionExpired):
|
||||
e.Error(http.StatusGone, err, "播放会话已过期,请重新连接")
|
||||
case strings.Contains(err.Error(), "WEBRTC_PUBLIC_BASE"), strings.Contains(err.Error(), "浏览器可访问"):
|
||||
e.Error(http.StatusServiceUnavailable, err, "浏览器播放地址未正确配置")
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "实时监看操作失败")
|
||||
}
|
||||
}
|
||||
|
||||
func decodeJSON(c *gin.Context, target any) error {
|
||||
if !strings.HasPrefix(strings.ToLower(strings.TrimSpace(c.GetHeader("Content-Type"))), "application/json") {
|
||||
return errors.New("content type must be application/json")
|
||||
}
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 16<<10))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
return errors.New("request body must contain one JSON object")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestPlayerUsesShortLivedCapabilityAndRestrictiveHeaders(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
store := NewStore()
|
||||
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
store.now = func() time.Time { return now }
|
||||
previous := defaultStore
|
||||
defaultStore = store
|
||||
t.Cleanup(func() { defaultStore = previous })
|
||||
store.put(sessionRecord{ID: "view_test", OwnerID: 7, RouteID: "route-1", TargetURL: "http://127.0.0.1:8889/sense_test?controls=true", ExpiresAt: now.Add(sessionTTL)})
|
||||
|
||||
router := gin.New()
|
||||
api := &API{}
|
||||
router.GET("/api/v1/liveview/player/:id", api.Player)
|
||||
recorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||
if recorder.Code != http.StatusOK || recorder.Header().Get("Cache-Control") != "no-store" || recorder.Header().Get("X-Frame-Options") != "SAMEORIGIN" {
|
||||
t.Fatalf("status=%d headers=%v", recorder.Code, recorder.Header())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), "http://127.0.0.1:8889/sense_test?controls=true") || !strings.Contains(recorder.Header().Get("Content-Security-Policy"), "frame-ancestors 'self'") {
|
||||
t.Fatalf("unexpected wrapper response: %s", recorder.Body.String())
|
||||
}
|
||||
|
||||
now = now.Add(sessionTTL)
|
||||
expired := httptest.NewRecorder()
|
||||
router.ServeHTTP(expired, httptest.NewRequest(http.MethodGet, "/api/v1/liveview/player/view_test", nil))
|
||||
if expired.Code != http.StatusGone {
|
||||
t.Fatalf("expired capability status=%d", expired.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("实时监看请求不符合要求")
|
||||
ErrRouteNotFound = errors.New("可监看的视频不存在")
|
||||
ErrSessionExpired = errors.New("播放会话已过期")
|
||||
validMediaPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||
validDNSHost = regexp.MustCompile(`^[A-Za-z0-9.-]+$`)
|
||||
)
|
||||
|
||||
const sessionTTL = 2 * time.Minute
|
||||
|
||||
type PageRequest struct {
|
||||
Keyword string
|
||||
PageIndex int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
type RouteResponse struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
DeviceLocation string `json:"deviceLocation"`
|
||||
ProfileToken string `json:"profileToken"`
|
||||
ProfileName string `json:"profileName"`
|
||||
ProfileKind string `json:"profileKind"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
Encoding string `json:"encoding"`
|
||||
Actual string `json:"actual"`
|
||||
Detail string `json:"detail"`
|
||||
Readers int `json:"readers"`
|
||||
}
|
||||
|
||||
type SessionResponse struct {
|
||||
ID string `json:"id"`
|
||||
RouteID string `json:"routeId"`
|
||||
PlayerURL string `json:"playerUrl"`
|
||||
Status string `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
DeviceName string `json:"deviceName"`
|
||||
ProfileName string `json:"profileName"`
|
||||
}
|
||||
|
||||
type routeRecord struct {
|
||||
RouteResponse
|
||||
Path string `gorm:"column:path"`
|
||||
Desired string `gorm:"column:desired"`
|
||||
}
|
||||
|
||||
type sessionRecord struct {
|
||||
ID string
|
||||
OwnerID int
|
||||
RouteID string
|
||||
TargetURL string
|
||||
ExpiresAt time.Time
|
||||
DeviceName string
|
||||
ProfileName string
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]sessionRecord
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewStore() *Store {
|
||||
return &Store{sessions: make(map[string]sessionRecord), now: time.Now}
|
||||
}
|
||||
|
||||
var defaultStore = NewStore()
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
store *Store
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, store *Store) *Service {
|
||||
if store == nil {
|
||||
store = defaultStore
|
||||
}
|
||||
return &Service{db: db, store: store}
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, request PageRequest) ([]RouteResponse, int64, error) {
|
||||
if request.PageIndex < 1 {
|
||||
request.PageIndex = 1
|
||||
}
|
||||
if request.PageSize < 1 {
|
||||
request.PageSize = 10
|
||||
}
|
||||
if request.PageSize > 50 {
|
||||
request.PageSize = 50
|
||||
}
|
||||
query := s.routeQuery(ctx).Where("r.desired = ?", "running")
|
||||
keyword := strings.TrimSpace(request.Keyword)
|
||||
if len([]rune(keyword)) > 128 {
|
||||
return nil, 0, ErrInvalidRequest
|
||||
}
|
||||
if keyword != "" {
|
||||
pattern := "%" + escapeLike(keyword) + "%"
|
||||
query = query.Where("LOWER(d.name) LIKE LOWER(?) ESCAPE '\\' OR LOWER(d.location) LIKE LOWER(?) ESCAPE '\\' OR LOWER(p.name) LIKE LOWER(?) ESCAPE '\\'", pattern, pattern, pattern)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var records []routeRecord
|
||||
if err := query.Order("d.name ASC, p.kind ASC, p.width DESC").Offset((request.PageIndex - 1) * request.PageSize).Limit(request.PageSize).Scan(&records).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
items := make([]RouteResponse, 0, len(records))
|
||||
for _, record := range records {
|
||||
items = append(items, record.RouteResponse)
|
||||
}
|
||||
return items, total, nil
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, ownerID int, routeID string, request *http.Request) (SessionResponse, error) {
|
||||
if ownerID < 1 || strings.TrimSpace(routeID) == "" {
|
||||
return SessionResponse{}, ErrInvalidRequest
|
||||
}
|
||||
record, err := s.route(ctx, routeID)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
if record.Desired != "running" || !validMediaPath.MatchString(record.Path) {
|
||||
return SessionResponse{}, ErrRouteNotFound
|
||||
}
|
||||
base, err := playbackBase(request)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
target := *base
|
||||
target.Path = strings.TrimRight(target.Path, "/") + "/" + record.Path
|
||||
target.RawQuery = "controls=true&muted=true&autoplay=true"
|
||||
id, err := randomID()
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
now := s.store.now().UTC()
|
||||
session := sessionRecord{ID: id, OwnerID: ownerID, RouteID: record.ID, TargetURL: target.String(), ExpiresAt: now.Add(sessionTTL), DeviceName: record.DeviceName, ProfileName: record.ProfileName}
|
||||
s.store.put(session)
|
||||
return responseFrom(session, record), nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, ownerID int, id string) (SessionResponse, error) {
|
||||
session, err := s.store.get(id, ownerID, true)
|
||||
if err != nil {
|
||||
return SessionResponse{}, err
|
||||
}
|
||||
record, err := s.route(ctx, session.RouteID)
|
||||
if err != nil || record.Desired != "running" {
|
||||
return SessionResponse{}, ErrRouteNotFound
|
||||
}
|
||||
session.ExpiresAt = s.store.now().UTC().Add(sessionTTL)
|
||||
s.store.put(session)
|
||||
return responseFrom(session, record), nil
|
||||
}
|
||||
|
||||
func (s *Service) PlayerTarget(id string) (string, error) {
|
||||
session, err := s.store.get(id, 0, false)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return session.TargetURL, nil
|
||||
}
|
||||
|
||||
func (s *Service) route(ctx context.Context, id string) (routeRecord, error) {
|
||||
var record routeRecord
|
||||
if err := s.routeQuery(ctx).Where("r.id = ?", id).Limit(1).Scan(&record).Error; err != nil {
|
||||
return routeRecord{}, err
|
||||
}
|
||||
if record.ID == "" {
|
||||
return routeRecord{}, ErrRouteNotFound
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
func (s *Service) routeQuery(ctx context.Context) *gorm.DB {
|
||||
return s.db.WithContext(ctx).Table("sense_media_routes AS r").
|
||||
Select("r.id, r.device_id, d.name AS device_name, d.location AS device_location, r.profile_token, p.name AS profile_name, p.kind AS profile_kind, p.width, p.height, p.encoding, r.actual, r.detail, r.readers, r.path, r.desired").
|
||||
Joins("JOIN sense_devices AS d ON d.id = r.device_id").
|
||||
Joins("JOIN sense_admission_profiles AS p ON p.device_id = r.device_id AND p.token = r.profile_token").
|
||||
Where("d.status <> ? AND p.verification_status = ?", "disabled", "ready")
|
||||
}
|
||||
|
||||
func responseFrom(session sessionRecord, route routeRecord) SessionResponse {
|
||||
return SessionResponse{ID: session.ID, RouteID: session.RouteID, PlayerURL: "/api/v1/liveview/player/" + session.ID, Status: playbackStatus(route.Actual), Detail: route.Detail, ExpiresAt: session.ExpiresAt, DeviceName: session.DeviceName, ProfileName: session.ProfileName}
|
||||
}
|
||||
|
||||
func playbackStatus(actual string) string {
|
||||
switch actual {
|
||||
case "ready", "waiting", "stopped":
|
||||
return actual
|
||||
case "credential_unavailable", "profile_unavailable":
|
||||
return "authentication_failed"
|
||||
case "path_missing", "apply_failed", "status_unavailable":
|
||||
return "stream_not_found"
|
||||
case "process_unavailable":
|
||||
return "service_unavailable"
|
||||
default:
|
||||
return "offline"
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) put(session sessionRecord) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := s.now()
|
||||
for id, item := range s.sessions {
|
||||
if !now.Before(item.ExpiresAt) || item.OwnerID == session.OwnerID {
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
}
|
||||
s.sessions[session.ID] = session
|
||||
}
|
||||
|
||||
func (s *Store) get(id string, ownerID int, checkOwner bool) (sessionRecord, error) {
|
||||
s.mu.RLock()
|
||||
session, ok := s.sessions[id]
|
||||
s.mu.RUnlock()
|
||||
if !ok || !s.now().Before(session.ExpiresAt) || (checkOwner && session.OwnerID != ownerID) {
|
||||
return sessionRecord{}, ErrSessionExpired
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
func playbackBase(request *http.Request) (*url.URL, error) {
|
||||
configured := strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE"))
|
||||
if configured != "" {
|
||||
return validatePlaybackBase(configured)
|
||||
}
|
||||
if request == nil || request.Host == "" {
|
||||
return nil, errors.New("无法确定浏览器可访问的视频服务地址")
|
||||
}
|
||||
host := request.Host
|
||||
if parsedHost, _, err := net.SplitHostPort(request.Host); err == nil {
|
||||
host = parsedHost
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
if host == "" || (net.ParseIP(host) == nil && host != "localhost" && !validDNSHost.MatchString(host)) {
|
||||
return nil, errors.New("无效的请求主机")
|
||||
}
|
||||
scheme := "http"
|
||||
if request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return validatePlaybackBase(fmt.Sprintf("%s://%s", scheme, net.JoinHostPort(host, "8889")))
|
||||
}
|
||||
|
||||
func validatePlaybackBase(value string) (*url.URL, error) {
|
||||
parsed, err := url.Parse(strings.TrimRight(value, "/"))
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Hostname() == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
|
||||
return nil, errors.New("SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE 配置不安全")
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func randomID() (string, error) {
|
||||
value := make([]byte, 24)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "view_" + hex.EncodeToString(value), nil
|
||||
}
|
||||
|
||||
func escapeLike(value string) string {
|
||||
value = strings.ReplaceAll(value, `\`, `\\`)
|
||||
value = strings.ReplaceAll(value, `%`, `\%`)
|
||||
return strings.ReplaceAll(value, `_`, `\_`)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package liveview
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func testDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
statements := []string{
|
||||
`CREATE TABLE sense_devices (id text primary key, name text, location text, status text)`,
|
||||
`CREATE TABLE sense_admission_profiles (device_id text, token text, name text, kind text, width integer, height integer, encoding text, verification_status text, stream_uri text)`,
|
||||
`CREATE TABLE sense_media_routes (id text primary key, device_id text, profile_token text, path text, desired text, actual text, detail text, readers integer)`,
|
||||
`INSERT INTO sense_devices VALUES ('device-1','东门摄像机','教学楼东门','active')`,
|
||||
`INSERT INTO sense_admission_profiles VALUES ('device-1','main','主码流','main',1920,1080,'H264','ready','rtsp://camera.example/live')`,
|
||||
`INSERT INTO sense_media_routes VALUES ('device-1:main','device-1','main','sense_012345','running','waiting','等待播放器连接并按需拉流',0)`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if err = db.Exec(statement).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func TestListIsPaginatedSearchableAndSecretFree(t *testing.T) {
|
||||
service := NewService(testDB(t), NewStore())
|
||||
items, total, err := service.List(context.Background(), PageRequest{Keyword: "东门", PageIndex: 1, PageSize: 10})
|
||||
if err != nil || total != 1 || len(items) != 1 || items[0].ProfileKind != "main" {
|
||||
t.Fatalf("items=%+v total=%d err=%v", items, total, err)
|
||||
}
|
||||
encoded, _ := json.Marshal(items)
|
||||
if strings.Contains(string(encoded), "rtsp://") || strings.Contains(string(encoded), "sense_012345") {
|
||||
t.Fatalf("response leaked private media data: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionIsOwnerBoundShortLivedAndUsesBrowserHost(t *testing.T) {
|
||||
store := NewStore()
|
||||
now := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
store.now = func() time.Time { return now }
|
||||
service := NewService(testDB(t), store)
|
||||
request := httptest.NewRequest("POST", "http://192.0.2.20:18080/api/v1/liveview/sessions", nil)
|
||||
session, err := service.Create(context.Background(), 7, "device-1:main", request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(session.PlayerURL, "/api/v1/liveview/player/view_") || strings.Contains(session.PlayerURL, "sense_012345") {
|
||||
t.Fatalf("unsafe player URL: %s", session.PlayerURL)
|
||||
}
|
||||
if _, err = service.Get(context.Background(), 8, session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||
t.Fatalf("another owner accessed session: %v", err)
|
||||
}
|
||||
target, err := service.PlayerTarget(session.ID)
|
||||
if err != nil || target != "http://192.0.2.20:8889/sense_012345?controls=true&muted=true&autoplay=true" {
|
||||
t.Fatalf("target=%q err=%v", target, err)
|
||||
}
|
||||
now = now.Add(sessionTTL)
|
||||
if _, err = service.PlayerTarget(session.ID); !errorsIs(err, ErrSessionExpired) {
|
||||
t.Fatalf("expired session remained valid: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackBaseRejectsCredentials(t *testing.T) {
|
||||
if _, err := validatePlaybackBase("http://invalid-user@127.0.0.1:8889"); err == nil {
|
||||
t.Fatal("expected credential-bearing base URL to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaybackStatusKeepsActionableFailuresDistinct(t *testing.T) {
|
||||
for input, want := range map[string]string{"waiting": "waiting", "credential_unavailable": "authentication_failed", "path_missing": "stream_not_found", "process_unavailable": "service_unavailable", "unexpected": "offline"} {
|
||||
if got := playbackStatus(input); got != want {
|
||||
t.Fatalf("playbackStatus(%q)=%q want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func errorsIs(err, target error) bool { return err == target }
|
||||
@@ -0,0 +1,98 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-admin-team/go-admin-core/sdk/api"
|
||||
coreService "github.com/go-admin-team/go-admin-core/sdk/service"
|
||||
)
|
||||
|
||||
type API struct{ api.Api }
|
||||
|
||||
func (e *API) service(c *gin.Context) (*Service, error) {
|
||||
base := coreService.Service{}
|
||||
if err := e.MakeContext(c).MakeOrm().MakeService(&base).Errors; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return serviceFor(base.Orm), nil
|
||||
}
|
||||
|
||||
func (e *API) List(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
items, err := service.List(c.Request.Context())
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(gin.H{"list": items, "total": len(items)}, "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) Process(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(service.ProcessState(), "查询成功")
|
||||
}
|
||||
|
||||
func (e *API) ReconcileAll(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
if err = service.EnsureAllVerified(c.Request.Context()); err == nil {
|
||||
err = service.ReconcileDue(c.Request.Context())
|
||||
}
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(nil, "对账完成")
|
||||
}
|
||||
|
||||
func (e *API) Reconcile(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
item, err := service.Reconcile(c.Request.Context(), c.Param("id"))
|
||||
if err != nil && item.ID == "" {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(item, "对账完成")
|
||||
}
|
||||
|
||||
func (e *API) Stop(c *gin.Context) {
|
||||
service, err := e.service(c)
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
item, err := service.StopRoute(c.Request.Context(), c.Param("id"))
|
||||
if err != nil {
|
||||
e.writeError(err)
|
||||
return
|
||||
}
|
||||
e.OK(item, "媒体路径已停止")
|
||||
}
|
||||
|
||||
func (e *API) writeError(err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
e.Error(http.StatusNotFound, err, err.Error())
|
||||
case errors.Is(err, ErrRuntimeUnavailable), errors.Is(err, ErrUnsafeControlAPI):
|
||||
e.Error(http.StatusServiceUnavailable, err, "视频服务运行配置不可用")
|
||||
default:
|
||||
e.Error(http.StatusInternalServerError, err, "视频服务操作失败")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrUnsafeControlAPI = errors.New("MediaMTX Control API 必须使用本机回环地址")
|
||||
|
||||
type RuntimeConfig struct {
|
||||
Binary string
|
||||
ConfigPath string
|
||||
APIBase string
|
||||
PollInterval time.Duration
|
||||
StartTimeout time.Duration
|
||||
}
|
||||
|
||||
func ConfigFromEnvironment() (RuntimeConfig, error) {
|
||||
c := RuntimeConfig{
|
||||
Binary: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_BINARY")), ConfigPath: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_CONFIG")),
|
||||
APIBase: strings.TrimSpace(os.Getenv("SENSE_MEDIAMTX_API")), PollInterval: 5 * time.Second, StartTimeout: 8 * time.Second,
|
||||
}
|
||||
if c.APIBase == "" {
|
||||
c.APIBase = "http://127.0.0.1:9997"
|
||||
}
|
||||
if err := validateControlAPI(c.APIBase); err != nil {
|
||||
return RuntimeConfig{}, err
|
||||
}
|
||||
if c.Binary != "" && c.ConfigPath == "" {
|
||||
return RuntimeConfig{}, errors.New("SENSE_MEDIAMTX_CONFIG is required when SENSE_MEDIAMTX_BINARY is configured")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func validateControlAPI(value string) error {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
|
||||
return ErrUnsafeControlAPI
|
||||
}
|
||||
host := u.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||
return ErrUnsafeControlAPI
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RenderBaseConfig(w io.Writer, apiBase string) error {
|
||||
if err := validateControlAPI(apiBase); err != nil {
|
||||
return err
|
||||
}
|
||||
u, _ := url.Parse(apiBase)
|
||||
_, err := fmt.Fprintf(w, "logLevel: info\napi: true\napiAddress: %s\nmetrics: false\npaths: {}\n", u.Host)
|
||||
return err
|
||||
}
|
||||
|
||||
func EnsureBaseConfig(path, apiBase string) error {
|
||||
if path == "" {
|
||||
return errors.New("MediaMTX config path is empty")
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
temporary := path + ".tmp"
|
||||
f, err := os.OpenFile(temporary, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeErr := RenderBaseConfig(f, apiBase)
|
||||
closeErr := f.Close()
|
||||
if writeErr != nil || closeErr != nil {
|
||||
_ = os.Remove(temporary)
|
||||
return errors.Join(writeErr, closeErr)
|
||||
}
|
||||
if err = os.Rename(temporary, path); err != nil {
|
||||
_ = os.Remove(temporary)
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var validPath = regexp.MustCompile(`^[A-Za-z0-9_-]{1,96}$`)
|
||||
|
||||
type Source struct {
|
||||
Path, URI, Username, Password string
|
||||
}
|
||||
|
||||
type PathStatus struct {
|
||||
Exists, Ready bool
|
||||
Readers int
|
||||
}
|
||||
|
||||
type Controller interface {
|
||||
Health(context.Context) error
|
||||
Apply(context.Context, Source) error
|
||||
Delete(context.Context, string) error
|
||||
Status(context.Context, string) (PathStatus, error)
|
||||
}
|
||||
|
||||
type HTTPController struct {
|
||||
base string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewHTTPController(base string) (*HTTPController, error) {
|
||||
if err := validateControlAPI(base); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
return &HTTPController{base: strings.TrimRight(base, "/"), client: &http.Client{
|
||||
Timeout: 5 * time.Second, Transport: transport,
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return errors.New("MediaMTX Control API redirect rejected")
|
||||
},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPController) Health(ctx context.Context) error {
|
||||
return c.request(ctx, http.MethodGet, "/v3/config/global/get", nil, nil)
|
||||
}
|
||||
|
||||
func (c *HTTPController) Apply(ctx context.Context, source Source) error {
|
||||
if !validPath.MatchString(source.Path) {
|
||||
return errors.New("invalid MediaMTX path")
|
||||
}
|
||||
u, err := url.Parse(source.URI)
|
||||
if err != nil || u.User != nil || u.Host == "" || (u.Scheme != "rtsp" && u.Scheme != "rtsps") {
|
||||
return errors.New("invalid credential-free RTSP source")
|
||||
}
|
||||
payload := map[string]any{"source": u.String(), "sourceOnDemand": true, "rtspTransport": "tcp"}
|
||||
if source.Username != "" {
|
||||
// MediaMTX v1.19.3 has no sourceUser/sourcePass fields. Credentials
|
||||
// are assembled only for this loopback request and are never stored,
|
||||
// logged, or returned by a Sense endpoint.
|
||||
u.User = url.UserPassword(source.Username, source.Password)
|
||||
payload["source"] = u.String()
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
configured, err := c.configured(ctx, source.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
method, action := http.MethodPost, "add"
|
||||
if configured {
|
||||
method, action = http.MethodPatch, "patch"
|
||||
}
|
||||
return c.request(ctx, method, "/v3/config/paths/"+action+"/"+url.PathEscape(source.Path), bytes.NewReader(data), nil)
|
||||
}
|
||||
|
||||
func (c *HTTPController) configured(ctx context.Context, path string) (bool, error) {
|
||||
status := 0
|
||||
err := c.request(ctx, http.MethodGet, "/v3/config/paths/get/"+url.PathEscape(path), nil, &status)
|
||||
if status == http.StatusNotFound {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (c *HTTPController) Delete(ctx context.Context, path string) error {
|
||||
if !validPath.MatchString(path) {
|
||||
return errors.New("invalid MediaMTX path")
|
||||
}
|
||||
status := 0
|
||||
err := c.request(ctx, http.MethodDelete, "/v3/config/paths/delete/"+url.PathEscape(path), nil, &status)
|
||||
if status == http.StatusNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *HTTPController) Status(ctx context.Context, path string) (PathStatus, error) {
|
||||
statusCode := 0
|
||||
var raw struct {
|
||||
Ready bool `json:"ready"`
|
||||
Readers []any `json:"readers"`
|
||||
}
|
||||
err := c.requestJSON(ctx, http.MethodGet, "/v3/paths/get/"+url.PathEscape(path), &statusCode, &raw)
|
||||
if statusCode == http.StatusNotFound {
|
||||
return PathStatus{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return PathStatus{}, err
|
||||
}
|
||||
return PathStatus{Exists: true, Ready: raw.Ready, Readers: len(raw.Readers)}, nil
|
||||
}
|
||||
|
||||
func (c *HTTPController) request(ctx context.Context, method, path string, body io.Reader, statusOut *int) error {
|
||||
return c.requestJSON(ctx, method, path, body, statusOut, nil)
|
||||
}
|
||||
|
||||
func (c *HTTPController) requestJSON(ctx context.Context, method, path string, args ...any) error {
|
||||
var body io.Reader
|
||||
var statusOut *int
|
||||
var target any
|
||||
for _, arg := range args {
|
||||
switch value := arg.(type) {
|
||||
case io.Reader:
|
||||
body = value
|
||||
case *int:
|
||||
statusOut = value
|
||||
default:
|
||||
target = value
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.base+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
res, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("MediaMTX Control API unavailable: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
if statusOut != nil {
|
||||
*statusOut = res.StatusCode
|
||||
}
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||
return fmt.Errorf("MediaMTX Control API returned %d", res.StatusCode)
|
||||
}
|
||||
if target == nil {
|
||||
_, _ = io.Copy(io.Discard, io.LimitReader(res.Body, 1<<20))
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(res.Body, 1<<20)).Decode(target)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestControllerUsesCredentialsOnlyAtLoopbackBoundary(t *testing.T) {
|
||||
var payload map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.URL.Path == "/v3/config/paths/get/sense_test":
|
||||
http.NotFound(w, r)
|
||||
case r.URL.Path == "/v3/config/paths/add/sense_test":
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
controller, err := NewHTTPController(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
source := Source{Path: "sense_test", URI: "rtsp://192.0.2.1/live", Username: "synthetic-user", Password: "synthetic-pass"}
|
||||
if err = controller.Apply(context.Background(), source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["source"] != "rtsp://synthetic-user:synthetic-pass@192.0.2.1/live" {
|
||||
t.Fatalf("unexpected MediaMTX payload: %#v", payload)
|
||||
}
|
||||
if source.URI != "rtsp://192.0.2.1/live" || strings.Contains(source.URI, "synthetic") {
|
||||
t.Fatalf("caller source was mutated: %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlAPIAndGeneratedConfigAreLoopbackOnly(t *testing.T) {
|
||||
if _, err := NewHTTPController("http://192.0.2.1:9997"); err != ErrUnsafeControlAPI {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
var output strings.Builder
|
||||
if err := RenderBaseConfig(&output, "http://127.0.0.1:9997"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(output.String()), "password") || !strings.Contains(output.String(), "paths: {}") {
|
||||
t.Fatalf("unsafe config: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalProcessUsesOrphanSafetyGate(t *testing.T) {
|
||||
supervisor := NewSupervisor("", "")
|
||||
supervisor.MarkExternal()
|
||||
if err := supervisor.Stop(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := supervisor.State()
|
||||
if !state.External || state.Owned || state.Phase != "running" {
|
||||
t.Fatalf("unexpected state: %#v", state)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package media
|
||||
|
||||
import "time"
|
||||
|
||||
type RouteResponse struct {
|
||||
ID string `json:"id"`
|
||||
DeviceID string `json:"deviceId"`
|
||||
ProfileToken string `json:"profileToken"`
|
||||
Path string `json:"path"`
|
||||
Desired string `json:"desired"`
|
||||
Actual string `json:"actual"`
|
||||
Readers int `json:"readers"`
|
||||
SourceReady bool `json:"sourceReady"`
|
||||
FailureCount int `json:"failureCount"`
|
||||
NextRetryAt *time.Time `json:"nextRetryAt,omitempty"`
|
||||
LastErrorCode string `json:"lastErrorCode,omitempty"`
|
||||
Detail string `json:"detail"`
|
||||
Version int64 `json:"version"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func routeResponse(r Route) RouteResponse {
|
||||
return RouteResponse{ID: r.ID, DeviceID: r.DeviceID, ProfileToken: r.ProfileToken, Path: r.Path, Desired: r.Desired, Actual: r.Actual, Readers: r.Readers, SourceReady: r.SourceReady, FailureCount: r.FailureCount, NextRetryAt: r.NextRetryAt, LastErrorCode: r.LastErrorCode, Detail: r.Detail, Version: r.Version, UpdatedAt: r.UpdatedAt}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package media
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
DesiredRunning = "running"
|
||||
DesiredStopped = "stopped"
|
||||
)
|
||||
|
||||
type Route struct {
|
||||
ID string `gorm:"size:96;primaryKey"`
|
||||
DeviceID string `gorm:"size:36;not null;uniqueIndex:media_device_profile"`
|
||||
ProfileToken string `gorm:"size:255;not null;uniqueIndex:media_device_profile"`
|
||||
Path string `gorm:"size:96;not null;uniqueIndex"`
|
||||
Desired string `gorm:"size:16;not null;index"`
|
||||
Actual string `gorm:"size:32;not null;index"`
|
||||
Readers int `gorm:"not null"`
|
||||
SourceReady bool `gorm:"not null"`
|
||||
FailureCount int `gorm:"not null"`
|
||||
NextRetryAt *time.Time `gorm:"index"`
|
||||
LastErrorCode string `gorm:"size:64;not null"`
|
||||
Detail string `gorm:"size:512;not null"`
|
||||
Version int64 `gorm:"not null"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (Route) TableName() string { return "sense_media_routes" }
|
||||
|
||||
// admissionProfile intentionally maps only non-secret fields from #66.
|
||||
type admissionProfile struct {
|
||||
DeviceID string `gorm:"column:device_id"`
|
||||
Token string `gorm:"column:token"`
|
||||
StreamURI string `gorm:"column:stream_uri"`
|
||||
VerificationStatus string `gorm:"column:verification_status"`
|
||||
}
|
||||
|
||||
func (admissionProfile) TableName() string { return "sense_admission_profiles" }
|
||||
@@ -0,0 +1,70 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var runtimeState struct {
|
||||
sync.RWMutex
|
||||
service *Service
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func StartRuntime(parent context.Context, db *gorm.DB) error {
|
||||
if db == nil {
|
||||
return errors.New("Sense database is unavailable for MediaMTX runtime")
|
||||
}
|
||||
config, err := ConfigFromEnvironment()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
controller, err := NewHTTPController(config.APIBase)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
service := NewService(db, controller, NewSupervisor(config.Binary, config.ConfigPath), config)
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
runtimeState.Lock()
|
||||
if runtimeState.cancel != nil {
|
||||
runtimeState.cancel()
|
||||
}
|
||||
runtimeState.service, runtimeState.cancel = service, cancel
|
||||
runtimeState.Unlock()
|
||||
go service.Run(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ShutdownRuntime(ctx context.Context) error {
|
||||
runtimeState.Lock()
|
||||
service, cancel := runtimeState.service, runtimeState.cancel
|
||||
runtimeState.service, runtimeState.cancel = nil, nil
|
||||
runtimeState.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if service != nil && service.process != nil {
|
||||
return service.process.Stop(ctx)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func serviceFor(db *gorm.DB) *Service {
|
||||
runtimeState.RLock()
|
||||
service := runtimeState.service
|
||||
runtimeState.RUnlock()
|
||||
if service != nil {
|
||||
return service
|
||||
}
|
||||
return NewService(db, nil, nil, RuntimeConfig{PollInterval: 5 * time.Second})
|
||||
}
|
||||
|
||||
// EnsureDeviceRoutes is an internal post-admission port. It persists only
|
||||
// credential-free route intent and deliberately does not fail device probing.
|
||||
func EnsureDeviceRoutes(ctx context.Context, db *gorm.DB, deviceID string) error {
|
||||
return serviceFor(db).EnsureDevice(ctx, deviceID)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/reconcile"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("媒体路由不存在")
|
||||
ErrRuntimeUnavailable = errors.New("视频服务运行配置不可用")
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
controller Controller
|
||||
process *Supervisor
|
||||
config RuntimeConfig
|
||||
now func() time.Time
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, controller Controller, process *Supervisor, config RuntimeConfig) *Service {
|
||||
return &Service{db: db, controller: controller, process: process, config: config, now: time.Now}
|
||||
}
|
||||
|
||||
func (s *Service) EnsureAllVerified(ctx context.Context) error {
|
||||
var ids []string
|
||||
if err := s.db.WithContext(ctx).Model(&admissionProfile{}).Where("verification_status = ?", "ready").Distinct().Pluck("device_id", &ids).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range ids {
|
||||
if err := s.ensureDevice(ctx, id, false); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) EnsureDevice(ctx context.Context, deviceID string) error {
|
||||
return s.ensureDevice(ctx, deviceID, true)
|
||||
}
|
||||
|
||||
func (s *Service) ensureDevice(ctx context.Context, deviceID string, reactivate bool) error {
|
||||
if strings.TrimSpace(deviceID) == "" {
|
||||
return errors.New("device id is required")
|
||||
}
|
||||
var profiles []admissionProfile
|
||||
if err := s.db.WithContext(ctx).Where("device_id = ? AND verification_status = ?", deviceID, "ready").Find(&profiles).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
now := s.now().UTC()
|
||||
for _, profile := range profiles {
|
||||
id := profile.DeviceID + ":" + profile.Token
|
||||
digest := sha256.Sum256([]byte(id))
|
||||
route := Route{ID: id, DeviceID: profile.DeviceID, ProfileToken: profile.Token, Path: fmt.Sprintf("sense_%x", digest[:12]), Desired: DesiredRunning, Actual: "pending", Detail: "等待视频服务对账", Version: 1, UpdatedAt: now}
|
||||
if err := s.db.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(&route).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var existing Route
|
||||
if err := s.db.WithContext(ctx).First(&existing, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if reactivate && existing.Desired != DesiredRunning {
|
||||
if err := s.db.WithContext(ctx).Model(&existing).Updates(map[string]any{"desired": DesiredRunning, "actual": "pending", "detail": "等待视频服务对账", "next_retry_at": nil, "version": existing.Version + 1, "updated_at": now}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) Run(ctx context.Context) {
|
||||
_ = s.EnsureAllVerified(ctx)
|
||||
_ = s.ReconcileDue(ctx)
|
||||
interval := s.config.PollInterval
|
||||
if interval <= 0 {
|
||||
interval = 5 * time.Second
|
||||
}
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = s.ReconcileDue(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) ReconcileDue(ctx context.Context) error {
|
||||
now := s.now().UTC()
|
||||
var routes []Route
|
||||
if err := s.db.WithContext(ctx).Where("desired = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", DesiredRunning, now).Order("updated_at ASC").Limit(128).Find(&routes).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var result error
|
||||
for _, route := range routes {
|
||||
var err error
|
||||
if route.FailureCount == 0 && (route.Actual == "waiting" || route.Actual == "ready") {
|
||||
_, err = s.Refresh(ctx, route.ID)
|
||||
} else {
|
||||
_, err = s.Reconcile(ctx, route.ID)
|
||||
}
|
||||
if err != nil {
|
||||
result = errors.Join(result, err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *Service) Refresh(ctx context.Context, id string) (RouteResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var route Route
|
||||
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||
return RouteResponse{}, ErrNotFound
|
||||
}
|
||||
if err := s.ensureControl(ctx); err != nil {
|
||||
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||
}
|
||||
status, err := s.controller.Status(ctx, route.Path)
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||
}
|
||||
if !status.Exists {
|
||||
// A cold MediaMTX start begins with paths: {}; re-apply the route
|
||||
// after releasing the service lock.
|
||||
s.mu.Unlock()
|
||||
response, reconcileErr := s.Reconcile(ctx, id)
|
||||
s.mu.Lock()
|
||||
return response, reconcileErr
|
||||
}
|
||||
if status.Ready {
|
||||
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
|
||||
}
|
||||
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
|
||||
}
|
||||
|
||||
func (s *Service) Reconcile(ctx context.Context, id string) (RouteResponse, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
var route Route
|
||||
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return RouteResponse{}, ErrNotFound
|
||||
}
|
||||
return RouteResponse{}, err
|
||||
}
|
||||
if route.Desired == DesiredStopped {
|
||||
if s.controller != nil {
|
||||
_ = s.controller.Delete(ctx, route.Path)
|
||||
}
|
||||
return s.saveSuccess(ctx, route, "stopped", false, 0, "媒体路径已停止")
|
||||
}
|
||||
if err := s.ensureControl(ctx); err != nil {
|
||||
return s.saveFailure(ctx, route, "process_unavailable", "MediaMTX 未启动或 Control API 未就绪", err)
|
||||
}
|
||||
var profile admissionProfile
|
||||
if err := s.db.WithContext(ctx).Where("device_id = ? AND token = ? AND verification_status = ?", route.DeviceID, route.ProfileToken, "ready").First(&profile).Error; err != nil {
|
||||
return s.saveFailure(ctx, route, "profile_unavailable", "已验证 Profile 不可用", err)
|
||||
}
|
||||
value, err := credential.Read(s.db.WithContext(ctx), route.DeviceID, credential.PurposeRTSP)
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "credential_unavailable", "RTSP 凭据不可用", err)
|
||||
}
|
||||
if err = s.controller.Apply(ctx, Source{Path: route.Path, URI: profile.StreamURI, Username: value.Username, Password: value.Password}); err != nil {
|
||||
return s.saveFailure(ctx, route, "apply_failed", "媒体路径配置失败", err)
|
||||
}
|
||||
status, err := s.controller.Status(ctx, route.Path)
|
||||
if err != nil {
|
||||
return s.saveFailure(ctx, route, "status_unavailable", "尚未取得媒体路径状态", err)
|
||||
}
|
||||
if !status.Exists {
|
||||
return s.saveFailure(ctx, route, "path_missing", "媒体路径不存在", errors.New("MediaMTX path missing after apply"))
|
||||
}
|
||||
if status.Ready {
|
||||
return s.saveSuccess(ctx, route, "ready", true, status.Readers, "上游拉流正常")
|
||||
}
|
||||
return s.saveSuccess(ctx, route, "waiting", false, status.Readers, "等待播放器连接并按需拉流")
|
||||
}
|
||||
|
||||
func (s *Service) ensureControl(ctx context.Context) error {
|
||||
if s.controller == nil {
|
||||
return ErrRuntimeUnavailable
|
||||
}
|
||||
if err := s.controller.Health(ctx); err == nil {
|
||||
if s.process != nil && !s.process.State().Owned {
|
||||
s.process.MarkExternal()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if s.process == nil {
|
||||
return ErrRuntimeUnavailable
|
||||
}
|
||||
if err := EnsureBaseConfig(s.config.ConfigPath, s.config.APIBase); err != nil {
|
||||
s.process.MarkFailed("MediaMTX 基础配置不可用")
|
||||
return err
|
||||
}
|
||||
if err := s.process.Start(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := s.now().Add(s.config.StartTimeout)
|
||||
for s.now().Before(deadline) {
|
||||
if err := s.controller.Health(ctx); err == nil {
|
||||
s.process.MarkReady()
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
s.process.MarkFailed("MediaMTX Control API 就绪超时")
|
||||
return errors.New("MediaMTX readiness timeout")
|
||||
}
|
||||
|
||||
func (s *Service) saveSuccess(ctx context.Context, route Route, actual string, sourceReady bool, readers int, detail string) (RouteResponse, error) {
|
||||
if route.Actual == actual && route.SourceReady == sourceReady && route.Readers == readers && route.FailureCount == 0 && route.NextRetryAt == nil && route.LastErrorCode == "" && route.Detail == detail {
|
||||
return routeResponse(route), nil
|
||||
}
|
||||
now := s.now().UTC()
|
||||
updates := map[string]any{"actual": actual, "source_ready": sourceReady, "readers": readers, "failure_count": 0, "next_retry_at": nil, "last_error_code": "", "detail": detail, "version": route.Version + 1, "updated_at": now}
|
||||
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
|
||||
return RouteResponse{}, err
|
||||
}
|
||||
for key, value := range updates {
|
||||
switch key {
|
||||
case "actual":
|
||||
route.Actual = value.(string)
|
||||
case "source_ready":
|
||||
route.SourceReady = value.(bool)
|
||||
case "readers":
|
||||
route.Readers = value.(int)
|
||||
case "failure_count":
|
||||
route.FailureCount = value.(int)
|
||||
case "last_error_code":
|
||||
route.LastErrorCode = value.(string)
|
||||
case "detail":
|
||||
route.Detail = value.(string)
|
||||
case "version":
|
||||
route.Version = value.(int64)
|
||||
case "updated_at":
|
||||
route.UpdatedAt = value.(time.Time)
|
||||
}
|
||||
}
|
||||
route.NextRetryAt = nil
|
||||
return routeResponse(route), nil
|
||||
}
|
||||
|
||||
func (s *Service) saveFailure(ctx context.Context, route Route, code, detail string, cause error) (RouteResponse, error) {
|
||||
now := s.now().UTC()
|
||||
failures := route.FailureCount + 1
|
||||
next := now.Add(reconcile.Backoff(failures))
|
||||
updates := map[string]any{"actual": code, "source_ready": false, "readers": 0, "failure_count": failures, "next_retry_at": &next, "last_error_code": code, "detail": detail, "version": route.Version + 1, "updated_at": now}
|
||||
if err := s.db.WithContext(ctx).Model(&route).Updates(updates).Error; err != nil {
|
||||
return RouteResponse{}, errors.Join(cause, err)
|
||||
}
|
||||
route.Actual, route.SourceReady, route.Readers, route.FailureCount, route.NextRetryAt, route.LastErrorCode, route.Detail = code, false, 0, failures, &next, code, detail
|
||||
route.Version, route.UpdatedAt = route.Version+1, now
|
||||
return routeResponse(route), cause
|
||||
}
|
||||
|
||||
func (s *Service) StopRoute(ctx context.Context, id string) (RouteResponse, error) {
|
||||
var route Route
|
||||
if err := s.db.WithContext(ctx).First(&route, "id = ?", id).Error; err != nil {
|
||||
return RouteResponse{}, ErrNotFound
|
||||
}
|
||||
route.Desired = DesiredStopped
|
||||
if err := s.db.WithContext(ctx).Model(&route).Updates(map[string]any{"desired": DesiredStopped, "next_retry_at": nil, "version": route.Version + 1, "updated_at": s.now().UTC()}).Error; err != nil {
|
||||
return RouteResponse{}, err
|
||||
}
|
||||
return s.Reconcile(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context) ([]RouteResponse, error) {
|
||||
var routes []Route
|
||||
if err := s.db.WithContext(ctx).Order("updated_at DESC").Find(&routes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := make([]RouteResponse, 0, len(routes))
|
||||
for _, route := range routes {
|
||||
items = append(items, routeResponse(route))
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) ProcessState() ProcessState {
|
||||
if s.process == nil {
|
||||
return ProcessState{Phase: "configuration_failed", Detail: "视频服务运行配置不可用", LastChanged: s.now().UTC()}
|
||||
}
|
||||
return s.process.State()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
)
|
||||
|
||||
type fakeController struct {
|
||||
healthErr, applyErr, statusErr error
|
||||
applies int
|
||||
status PathStatus
|
||||
}
|
||||
|
||||
func (f *fakeController) Health(context.Context) error { return f.healthErr }
|
||||
func (f *fakeController) Apply(context.Context, Source) error { f.applies++; return f.applyErr }
|
||||
func (f *fakeController) Delete(context.Context, string) error { return nil }
|
||||
func (f *fakeController) Status(context.Context, string) (PathStatus, error) {
|
||||
return f.status, f.statusErr
|
||||
}
|
||||
|
||||
func mediaTestService(t *testing.T, controller Controller) (*Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&Route{}, &admissionProfile{}, &credential.DeviceCredential{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||
vault, _ := credential.NewVault(key)
|
||||
ciphertext, err := vault.Encrypt("device-1", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-1", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&admissionProfile{DeviceID: "device-1", Token: "main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := NewService(db, controller, nil, RuntimeConfig{})
|
||||
service.now = func() time.Time { return time.Date(2026, 8, 14, 0, 0, 0, 0, time.UTC) }
|
||||
return service, db
|
||||
}
|
||||
|
||||
func TestEnsureAndReconcileAreIdempotent(t *testing.T) {
|
||||
controller := &fakeController{status: PathStatus{Exists: true, Ready: true, Readers: 2}}
|
||||
service, db := mediaTestService(t, controller)
|
||||
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int64
|
||||
if err := db.Model(&Route{}).Count(&count).Error; err != nil || count != 1 {
|
||||
t.Fatalf("count=%d err=%v", count, err)
|
||||
}
|
||||
item, err := service.Reconcile(context.Background(), "device-1:main")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Actual != "ready" || item.Readers != 2 || controller.applies != 1 {
|
||||
t.Fatalf("item=%#v applies=%d", item, controller.applies)
|
||||
}
|
||||
if err = service.ReconcileDue(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.List(context.Background())
|
||||
if err != nil || controller.applies != 1 || items[0].Version != item.Version {
|
||||
t.Fatalf("steady route was rewritten: items=%#v applies=%d err=%v", items, controller.applies, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColdStartDoesNotReactivateStoppedRoute(t *testing.T) {
|
||||
controller := &fakeController{status: PathStatus{Exists: true}}
|
||||
service, _ := mediaTestService(t, controller)
|
||||
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := service.StopRoute(context.Background(), "device-1:main"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := service.EnsureAllVerified(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := service.List(context.Background())
|
||||
if err != nil || len(items) != 1 || items[0].Desired != DesiredStopped {
|
||||
t.Fatalf("stopped route was reactivated: %#v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailurePersistsBackoffWithoutChangingProfile(t *testing.T) {
|
||||
controller := &fakeController{applyErr: errors.New("synthetic apply failure")}
|
||||
service, db := mediaTestService(t, controller)
|
||||
if err := service.EnsureDevice(context.Background(), "device-1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item, err := service.Reconcile(context.Background(), "device-1:main")
|
||||
if err == nil || item.Actual != "apply_failed" || item.FailureCount != 1 || item.NextRetryAt == nil {
|
||||
t.Fatalf("item=%#v err=%v", item, err)
|
||||
}
|
||||
var profile admissionProfile
|
||||
if err = db.First(&profile, "device_id = ? AND token = ?", "device-1", "main").Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if profile.VerificationStatus != "ready" {
|
||||
t.Fatalf("profile changed: %#v", profile)
|
||||
}
|
||||
if value := os.Getenv(credential.EnvironmentKey); value == "" {
|
||||
t.Fatal("test key unexpectedly missing")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProcessState struct {
|
||||
Phase string `json:"phase"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
Owned bool `json:"owned"`
|
||||
External bool `json:"external"`
|
||||
Restarts int `json:"restarts"`
|
||||
Detail string `json:"detail"`
|
||||
LastChanged time.Time `json:"lastChanged"`
|
||||
}
|
||||
|
||||
type Process interface {
|
||||
Start(context.Context) error
|
||||
Stop(context.Context) error
|
||||
State() ProcessState
|
||||
}
|
||||
|
||||
type Supervisor struct {
|
||||
binary, config string
|
||||
mu sync.Mutex
|
||||
command *exec.Cmd
|
||||
state ProcessState
|
||||
}
|
||||
|
||||
func NewSupervisor(binary, config string) *Supervisor {
|
||||
phase, detail := "stopped", "MediaMTX 尚未启动"
|
||||
if binary == "" {
|
||||
phase, detail = "not_configured", "未配置 MediaMTX 二进制;可连接外部已启动实例"
|
||||
}
|
||||
return &Supervisor{binary: binary, config: config, state: ProcessState{Phase: phase, Detail: detail, LastChanged: time.Now().UTC()}}
|
||||
}
|
||||
|
||||
func (s *Supervisor) MarkExternal() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Owned {
|
||||
return
|
||||
}
|
||||
s.state.Phase, s.state.External, s.state.Detail, s.state.LastChanged = "running", true, "检测到外部 MediaMTX;孤儿安全闸禁止 Sense 停止该进程", time.Now().UTC()
|
||||
}
|
||||
|
||||
func (s *Supervisor) Start(_ context.Context) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Owned && s.command != nil {
|
||||
return nil
|
||||
}
|
||||
if s.binary == "" {
|
||||
return errors.New("SENSE_MEDIAMTX_BINARY is not configured")
|
||||
}
|
||||
args := []string{}
|
||||
if s.config != "" {
|
||||
args = append(args, s.config)
|
||||
}
|
||||
cmd := exec.Command(s.binary, args...)
|
||||
if s.config != "" {
|
||||
cmd.Dir = filepath.Dir(s.config)
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", "MediaMTX 进程启动失败", time.Now().UTC()
|
||||
return err
|
||||
}
|
||||
if s.state.Phase == "failed" {
|
||||
s.state.Restarts++
|
||||
}
|
||||
s.command = cmd
|
||||
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "starting", cmd.Process.Pid, true, false
|
||||
s.state.Detail, s.state.LastChanged = "等待 MediaMTX Control API 就绪", time.Now().UTC()
|
||||
go s.wait(cmd)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) MarkReady() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.state.Owned {
|
||||
s.state.Phase, s.state.Detail, s.state.LastChanged = "running", "MediaMTX Control API 已就绪", time.Now().UTC()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) MarkFailed(detail string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.state.Phase, s.state.Detail, s.state.LastChanged = "failed", detail, time.Now().UTC()
|
||||
}
|
||||
|
||||
func (s *Supervisor) wait(cmd *exec.Cmd) {
|
||||
err := cmd.Wait()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.command != cmd {
|
||||
return
|
||||
}
|
||||
s.command = nil
|
||||
s.state.Phase, s.state.PID, s.state.Owned, s.state.External = "failed", 0, false, false
|
||||
s.state.Detail = "MediaMTX 进程已退出"
|
||||
if err == nil {
|
||||
s.state.Phase, s.state.Detail = "stopped", "MediaMTX 进程已停止"
|
||||
}
|
||||
s.state.LastChanged = time.Now().UTC()
|
||||
}
|
||||
|
||||
func (s *Supervisor) Stop(ctx context.Context) error {
|
||||
s.mu.Lock()
|
||||
cmd := s.command
|
||||
owned := s.state.Owned
|
||||
s.mu.Unlock()
|
||||
if !owned || cmd == nil {
|
||||
return nil
|
||||
}
|
||||
if err := cmd.Process.Signal(os.Interrupt); err != nil {
|
||||
if killErr := cmd.Process.Kill(); killErr != nil {
|
||||
return errors.Join(err, killErr)
|
||||
}
|
||||
}
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
_ = cmd.Process.Kill()
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
s.mu.Lock()
|
||||
finished := s.command != cmd
|
||||
s.mu.Unlock()
|
||||
if finished {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) State() ProcessState {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state
|
||||
}
|
||||
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -6,3 +6,6 @@ SENSE_CREDENTIAL_KEY=
|
||||
# approved local interface/IP ranges; comma-separate multiple CIDRs.
|
||||
SENSE_ONVIF_DISCOVERY_IP=
|
||||
SENSE_ONVIF_ALLOWED_CIDRS=
|
||||
SENSE_MEDIAMTX_BINARY=
|
||||
SENSE_MEDIAMTX_CONFIG=
|
||||
SENSE_MEDIAMTX_API=http://127.0.0.1:9997
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Sense generates only this credential-free base configuration.
|
||||
# The Control API must stay on loopback; camera paths are applied at runtime.
|
||||
logLevel: info
|
||||
api: true
|
||||
apiAddress: 127.0.0.1:9997
|
||||
metrics: false
|
||||
paths: {}
|
||||
@@ -0,0 +1,73 @@
|
||||
package media_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
)
|
||||
|
||||
func freeAddress(t *testing.T) string {
|
||||
t.Helper()
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
address := listener.Addr().String()
|
||||
if err = listener.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return address
|
||||
}
|
||||
|
||||
func TestRealMediaMTXControlLifecycle(t *testing.T) {
|
||||
binary := os.Getenv("SENSE_MEDIAMTX_TEST_BINARY")
|
||||
if binary == "" {
|
||||
t.Skip("set SENSE_MEDIAMTX_TEST_BINARY to run the real MediaMTX integration")
|
||||
}
|
||||
apiAddress, rtspAddress := freeAddress(t), freeAddress(t)
|
||||
configPath := filepath.Join(t.TempDir(), "mediamtx.yml")
|
||||
config := fmt.Sprintf("logLevel: warn\napi: true\napiAddress: %s\nrtspAddress: %s\nrtmp: false\nhls: false\nwebrtc: false\nsrt: false\nplayback: false\npaths: {}\n", apiAddress, rtspAddress)
|
||||
if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
controller, err := media.NewHTTPController("http://" + apiAddress)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
supervisor := media.NewSupervisor(binary, configPath)
|
||||
if err = supervisor.Start(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = supervisor.Stop(ctx)
|
||||
})
|
||||
deadline := time.Now().Add(8 * time.Second)
|
||||
for {
|
||||
err = controller.Health(context.Background())
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("MediaMTX did not become ready: %v", err)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
source := media.Source{Path: "sense_integration", URI: "rtsp://127.0.0.1:65530/test", Username: "synthetic-user", Password: "synthetic-password"}
|
||||
if err = controller.Apply(context.Background(), source); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = controller.Apply(context.Background(), source); err != nil {
|
||||
t.Fatalf("replace must be idempotent: %v", err)
|
||||
}
|
||||
if err = controller.Delete(context.Background(), source.Path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package media_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/admission"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/credential"
|
||||
"git.ilapage.cn/ila/yovision/Sense/server/app/sense/media"
|
||||
)
|
||||
|
||||
type readyController struct{}
|
||||
|
||||
func (readyController) Health(context.Context) error { return nil }
|
||||
func (readyController) Apply(context.Context, media.Source) error { return nil }
|
||||
func (readyController) Delete(context.Context, string) error { return nil }
|
||||
func (readyController) Status(context.Context, string) (media.PathStatus, error) {
|
||||
return media.PathStatus{Exists: true, Ready: true}, nil
|
||||
}
|
||||
|
||||
func TestPostgresColdStartRestoresDesiredRoute(t *testing.T) {
|
||||
dsn := os.Getenv("SENSE_MEDIA_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("set SENSE_MEDIA_TEST_DATABASE_URL to run PostgreSQL media recovery")
|
||||
}
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.AutoMigrate(&media.Route{}, &admission.Profile{}, &credential.DeviceCredential{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
db.Exec("DROP TABLE IF EXISTS sense_media_routes, sense_admission_profiles, sense_device_credentials")
|
||||
})
|
||||
key := make([]byte, 32)
|
||||
if _, err = rand.Read(key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(credential.EnvironmentKey, base64.StdEncoding.EncodeToString(key))
|
||||
vault, _ := credential.NewVault(key)
|
||||
ciphertext, err := vault.Encrypt("device-pg", credential.PurposeRTSP, "synthetic-user", "synthetic-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&credential.DeviceCredential{DeviceID: "device-pg", Purpose: credential.PurposeRTSP, Ciphertext: ciphertext, KeyVersion: credential.Version()}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.Create(&admission.Profile{DeviceID: "device-pg", Token: "main", Name: "Main", StreamURI: "rtsp://192.0.2.1/live", VerificationStatus: "ready", VerificationDetail: "synthetic"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
first := media.NewService(db, readyController{}, nil, media.RuntimeConfig{})
|
||||
if err = first.EnsureDevice(context.Background(), "device-pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second := media.NewService(db.Session(&gorm.Session{NewDB: true}), readyController{}, nil, media.RuntimeConfig{})
|
||||
if err = second.ReconcileDue(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := second.List(context.Background())
|
||||
if err != nil || len(items) != 1 || items[0].Actual != "ready" {
|
||||
t.Fatalf("items=%#v err=%v", items, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listLiveviewRoutes(query) {
|
||||
return request({ url: '/api/v1/liveview/routes', method: 'get', params: query })
|
||||
}
|
||||
|
||||
export function createLiveviewSession(routeId) {
|
||||
return request({ url: '/api/v1/liveview/sessions', method: 'post', data: { routeId }})
|
||||
}
|
||||
|
||||
export function getLiveviewSession(id) {
|
||||
return request({ url: `/api/v1/liveview/sessions/${id}`, method: 'get' })
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export function listMediaRoutes() { return request({ url: '/api/v1/media/routes', method: 'get' }) }
|
||||
export function getMediaProcess() { return request({ url: '/api/v1/media/process', method: 'get' }) }
|
||||
export function reconcileAllMedia() { return request({ url: '/api/v1/media/reconcile', method: 'post' }) }
|
||||
export function reconcileMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/reconcile`, method: 'post' }) }
|
||||
export function stopMediaRoute(id) { return request({ url: `/api/v1/media/routes/${encodeURIComponent(id)}/stop`, method: 'post' }) }
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="sense-video-player" :aria-busy="busy ? 'true' : 'false'">
|
||||
<iframe
|
||||
v-if="playerUrl && playable"
|
||||
:key="playerUrl"
|
||||
class="sense-video-player__frame"
|
||||
:src="playerUrl"
|
||||
title="Sense 单路实时视频"
|
||||
allow="autoplay; fullscreen"
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
referrerpolicy="no-referrer"
|
||||
@load="$emit('loaded')"
|
||||
/>
|
||||
<div v-if="state !== 'ready'" class="sense-video-player__state" :class="{ 'is-overlay': playerUrl && playable }" aria-live="polite">
|
||||
<el-icon v-if="busy" class="is-loading sense-video-player__icon" aria-hidden="true"><Loading /></el-icon>
|
||||
<el-icon v-else class="sense-video-player__icon" aria-hidden="true"><WarningFilled /></el-icon>
|
||||
<strong>{{ title }}</strong>
|
||||
<span>{{ message }}</span>
|
||||
<el-button v-if="recoverable" type="primary" :loading="retrying" @click="$emit('retry')">重新连接</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { playbackStatusDetail, playbackStatusLabel } from '@/views/sense/liveview/playbackStatus'
|
||||
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||
|
||||
export default {
|
||||
name: 'SenseVideoPlayer',
|
||||
components: { Loading, WarningFilled },
|
||||
props: {
|
||||
state: { type: String, default: 'loading' },
|
||||
detail: { type: String, default: '' },
|
||||
playerUrl: { type: String, default: '' },
|
||||
retrying: { type: Boolean, default: false }
|
||||
},
|
||||
emits: ['retry', 'loaded'],
|
||||
computed: {
|
||||
busy() {
|
||||
return this.state === 'loading' || this.state === 'waiting'
|
||||
},
|
||||
playable() {
|
||||
return ['loading', 'waiting', 'ready'].includes(this.state)
|
||||
},
|
||||
recoverable() {
|
||||
return !this.busy && this.state !== 'ready'
|
||||
},
|
||||
title() {
|
||||
return playbackStatusLabel(this.state)
|
||||
},
|
||||
message() {
|
||||
return playbackStatusDetail(this.state, this.detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sense-video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
min-height: 280px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--el-border-radius-base);
|
||||
background: #101419;
|
||||
}
|
||||
|
||||
.sense-video-player__frame {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.sense-video-player__state {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
color: #dcdfe6;
|
||||
text-align: center;
|
||||
background: #101419;
|
||||
}
|
||||
|
||||
.sense-video-player__state.is-overlay {
|
||||
pointer-events: none;
|
||||
background: rgb(16 20 25 / 78%);
|
||||
}
|
||||
|
||||
.sense-video-player__state strong {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.sense-video-player__state span {
|
||||
max-width: 640px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.sense-video-player__state .el-button {
|
||||
pointer-events: auto;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.sense-video-player__icon {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.sense-video-player {
|
||||
min-height: 210px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sense-video-player__icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,283 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h3>实时监看</h3>
|
||||
<p>按设备选择一路已验证视频;关闭窗口后不会继续占用播放器连接。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-form ref="queryForm" :model="queryParams" inline label-position="left" class="search-form" @submit.prevent>
|
||||
<el-form-item label="设备或位置" prop="keyword">
|
||||
<el-input v-model.trim="queryParams.keyword" clearable placeholder="输入设备、位置或码流名称" @keyup.enter="handleQuery" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="handleQuery">搜索</el-button>
|
||||
<el-button @click="resetQuery">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-alert
|
||||
v-if="!loading && total === 0"
|
||||
title="没有可监看的视频。请先在“视频接入”完成验证,再到“视频服务”确认路径状态。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="empty-alert"
|
||||
/>
|
||||
|
||||
<el-table v-loading="loading" :data="routes" border stripe>
|
||||
<el-table-column prop="deviceName" label="设备" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="deviceLocation" label="安装位置" min-width="160" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.deviceLocation || '未填写' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="码流" min-width="170">
|
||||
<template #default="scope">
|
||||
<div>{{ profileLabel(scope.row) }}</div>
|
||||
<small class="muted-text">{{ resolutionLabel(scope.row) }}</small>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="scope">
|
||||
<el-tag :type="routeStatus(scope.row.actual).type" size="small">{{ routeStatus(scope.row.actual).label }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="readers" label="当前观看" width="100" />
|
||||
<el-table-column prop="detail" label="说明" min-width="220" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="110" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:liveview:view']" type="primary" link @click="handleWatch(scope.row)">实时查看</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination
|
||||
v-show="total > 0"
|
||||
v-model:page="queryParams.pageIndex"
|
||||
v-model:limit="queryParams.pageSize"
|
||||
:total="total"
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="playerOpen" width="min(1000px, 92vw)" :close-on-click-modal="false" destroy-on-close @closed="closePlayer">
|
||||
<template #header>
|
||||
<div class="dialog-header">
|
||||
<div>
|
||||
<strong>{{ currentRoute.deviceName || '实时视频' }}</strong>
|
||||
<span>{{ profileLabel(currentRoute) }} · {{ currentRoute.deviceLocation || '未填写位置' }}</span>
|
||||
</div>
|
||||
<el-tag :type="playbackStatusType(playerState)">{{ playbackStatusLabel(playerState) }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<SenseVideoPlayer
|
||||
v-if="playerOpen"
|
||||
:state="playerState"
|
||||
:detail="playerDetail"
|
||||
:player-url="session.playerUrl"
|
||||
:retrying="retrying"
|
||||
@retry="openSession"
|
||||
/>
|
||||
|
||||
<el-descriptions :column="3" border class="player-details">
|
||||
<el-descriptions-item label="设备">{{ currentRoute.deviceName || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="码流">{{ profileLabel(currentRoute) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="分辨率">{{ resolutionLabel(currentRoute) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="处理建议" :span="3">{{ playbackStatusDetail(playerState, playerDetail) }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="playerOpen = false">关闭</el-button>
|
||||
<el-button type="primary" :loading="retrying" @click="openSession">重新连接</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { createLiveviewSession, getLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
import SenseVideoPlayer from '@/components/sense/video-player'
|
||||
import { playbackStatusDetail, playbackStatusLabel, playbackStatusType, routeStatus } from './playbackStatus'
|
||||
|
||||
export default {
|
||||
name: 'SenseLiveview',
|
||||
components: { SenseVideoPlayer },
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
routes: [],
|
||||
total: 0,
|
||||
queryParams: { keyword: '', pageIndex: 1, pageSize: 10 },
|
||||
playerOpen: false,
|
||||
currentRoute: {},
|
||||
session: {},
|
||||
playerState: 'loading',
|
||||
playerDetail: '',
|
||||
retrying: false,
|
||||
pollTimer: null,
|
||||
timeoutTimer: null
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.getList()
|
||||
},
|
||||
beforeUnmount() {
|
||||
this.stopTimers()
|
||||
},
|
||||
methods: {
|
||||
playbackStatusDetail,
|
||||
playbackStatusLabel,
|
||||
playbackStatusType,
|
||||
routeStatus,
|
||||
profileLabel(route) {
|
||||
if (!route || !route.id) return '—'
|
||||
const kind = { main: '主码流', sub: '子码流', other: '其他码流' }[route.profileKind] || '码流'
|
||||
return route.profileName ? `${kind}(${route.profileName})` : kind
|
||||
},
|
||||
resolutionLabel(route) {
|
||||
if (!route || !route.width || !route.height) return '未取得分辨率'
|
||||
return `${route.width} × ${route.height}${route.encoding ? ` · ${route.encoding}` : ''}`
|
||||
},
|
||||
async getList() {
|
||||
this.loading = true
|
||||
try {
|
||||
const response = await listLiveviewRoutes(this.queryParams)
|
||||
this.routes = response.data.list || []
|
||||
this.total = response.data.count || 0
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageIndex = 1
|
||||
this.getList()
|
||||
},
|
||||
resetQuery() {
|
||||
this.$refs.queryForm.resetFields()
|
||||
this.handleQuery()
|
||||
},
|
||||
handleWatch(route) {
|
||||
this.currentRoute = { ...route }
|
||||
this.playerOpen = true
|
||||
this.openSession()
|
||||
},
|
||||
async openSession() {
|
||||
if (!this.currentRoute.id || this.retrying) return
|
||||
this.stopTimers()
|
||||
this.retrying = true
|
||||
this.playerState = 'loading'
|
||||
this.playerDetail = ''
|
||||
this.session = {}
|
||||
try {
|
||||
const response = await createLiveviewSession(this.currentRoute.id)
|
||||
this.session = response.data
|
||||
this.playerState = response.data.status
|
||||
this.playerDetail = response.data.detail
|
||||
this.startPolling()
|
||||
} catch (error) {
|
||||
this.playerState = 'offline'
|
||||
this.playerDetail = error.message || ''
|
||||
} finally {
|
||||
this.retrying = false
|
||||
}
|
||||
},
|
||||
startPolling() {
|
||||
this.pollTimer = window.setInterval(this.refreshSession, 2000)
|
||||
this.timeoutTimer = window.setTimeout(() => {
|
||||
if (['loading', 'waiting'].includes(this.playerState)) {
|
||||
this.playerState = 'timeout'
|
||||
this.playerDetail = ''
|
||||
this.stopTimers()
|
||||
}
|
||||
}, 20000)
|
||||
},
|
||||
async refreshSession() {
|
||||
if (!this.session.id || !this.playerOpen) return
|
||||
try {
|
||||
const response = await getLiveviewSession(this.session.id)
|
||||
this.session = response.data
|
||||
this.playerState = response.data.status
|
||||
this.playerDetail = response.data.detail
|
||||
if (this.playerState === 'ready') {
|
||||
window.clearTimeout(this.timeoutTimer)
|
||||
this.timeoutTimer = null
|
||||
} else if (!['loading', 'waiting'].includes(this.playerState)) {
|
||||
this.stopTimers()
|
||||
}
|
||||
} catch (error) {
|
||||
this.playerState = 'expired'
|
||||
this.playerDetail = ''
|
||||
this.stopTimers()
|
||||
}
|
||||
},
|
||||
stopTimers() {
|
||||
window.clearInterval(this.pollTimer)
|
||||
window.clearTimeout(this.timeoutTimer)
|
||||
this.pollTimer = null
|
||||
this.timeoutTimer = null
|
||||
},
|
||||
closePlayer() {
|
||||
this.stopTimers()
|
||||
this.session = {}
|
||||
this.currentRoute = {}
|
||||
this.playerState = 'loading'
|
||||
this.playerDetail = ''
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header,
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-header h3 {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.page-header p,
|
||||
.dialog-header span,
|
||||
.muted-text {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.dialog-header > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.search-form,
|
||||
.empty-alert,
|
||||
.player-details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.search-form :deep(.el-input) {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.muted-text {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.search-form :deep(.el-input) {
|
||||
width: min(280px, 70vw);
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,46 @@
|
||||
const labels = {
|
||||
loading: '正在连接',
|
||||
waiting: '等待视频',
|
||||
ready: '播放正常',
|
||||
authentication_failed: '摄像头认证失败',
|
||||
stream_not_found: '未找到视频流',
|
||||
service_unavailable: '视频服务不可用',
|
||||
timeout: '连接超时',
|
||||
expired: '播放会话已过期',
|
||||
offline: '视频已断开',
|
||||
stopped: '视频已停止'
|
||||
}
|
||||
|
||||
const details = {
|
||||
loading: '正在建立短期播放会话,请稍候。',
|
||||
waiting: '播放器已连接,正在等待摄像头开始传输画面。',
|
||||
ready: '摄像头视频正在传输。',
|
||||
authentication_failed: '请到“设备管理”更新摄像头账号或密码,再重新验证视频接入。',
|
||||
stream_not_found: '请到“视频服务”执行对账,确认媒体路径已经恢复。',
|
||||
service_unavailable: '请到“视频服务”检查 MediaMTX 进程和端口配置。',
|
||||
timeout: '20 秒内未收到画面,请检查摄像头网络后重新连接。',
|
||||
expired: '短期播放会话已失效,请重新连接。',
|
||||
offline: '无法取得最新播放状态,请检查网络后重新连接。',
|
||||
stopped: '该视频路径已停止,请先到“视频服务”恢复。'
|
||||
}
|
||||
|
||||
export function playbackStatusLabel(value) {
|
||||
return labels[value] || '暂时无法播放'
|
||||
}
|
||||
|
||||
export function playbackStatusDetail(value, detail) {
|
||||
return detail || details[value] || details.offline
|
||||
}
|
||||
|
||||
export function playbackStatusType(value) {
|
||||
if (value === 'ready') return 'success'
|
||||
if (value === 'loading' || value === 'waiting') return 'warning'
|
||||
if (value === 'stopped') return 'info'
|
||||
return 'danger'
|
||||
}
|
||||
|
||||
export function routeStatus(value) {
|
||||
if (value === 'ready') return { label: '可观看', type: 'success' }
|
||||
if (value === 'waiting') return { label: '等待观看', type: 'warning' }
|
||||
return { label: '需要处理', type: 'danger' }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<BasicLayout>
|
||||
<template #wrapper>
|
||||
<el-card class="box-card">
|
||||
<div class="page-header">
|
||||
<div><h3>视频服务</h3><p>查看 MediaMTX 进程、摄像头拉流路径和自动重试状态。</p></div>
|
||||
<el-button v-permisaction="['sense:media:reconcile']" type="primary" :loading="reconciling" @click="handleReconcileAll">立即对账</el-button>
|
||||
</div>
|
||||
<el-alert title="视频服务故障不会删除已验证的设备和码流;外部进程受孤儿安全闸保护,不会被 Sense 停止。" type="info" :closable="false" show-icon />
|
||||
<el-descriptions :column="3" border class="process-state">
|
||||
<el-descriptions-item label="进程状态"><el-tag :type="mediaStatusType(process.phase)">{{ processLabel(process.phase) }}</el-tag></el-descriptions-item>
|
||||
<el-descriptions-item label="进程归属">{{ process.owned ? 'Sense 启动' : process.external ? '外部启动(受保护)' : '未运行' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="进程号">{{ process.pid || '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="说明" :span="3">{{ process.detail || '尚未取得状态' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<el-table v-loading="loading" :data="routes" border stripe class="route-table">
|
||||
<el-table-column prop="path" label="媒体路径" min-width="190" show-overflow-tooltip />
|
||||
<el-table-column prop="profileToken" label="Profile" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column label="期望状态" width="100"><template #default="scope"><el-tag size="small" type="info">{{ scope.row.desired === 'running' ? '运行' : '停止' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="实际状态" width="120"><template #default="scope"><el-tag :type="mediaStatusType(scope.row.actual)" size="small">{{ mediaStatusLabel(scope.row.actual) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="readers" label="观看数" width="90" />
|
||||
<el-table-column label="下次重试" min-width="165"><template #default="scope">{{ scope.row.nextRetryAt ? parseTime(scope.row.nextRetryAt) : '—' }}</template></el-table-column>
|
||||
<el-table-column prop="detail" label="说明" min-width="210" show-overflow-tooltip />
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-permisaction="['sense:media:reconcile']" type="primary" link @click="handleRoute(scope.row)">对账</el-button>
|
||||
<el-button v-if="scope.row.desired === 'running'" v-permisaction="['sense:media:stop']" type="danger" link @click="handleStop(scope.row)">停止</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && routes.length === 0" description="暂无媒体路径;请先完成摄像头接入验证" />
|
||||
</el-card>
|
||||
</template>
|
||||
</BasicLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { getMediaProcess, listMediaRoutes, reconcileAllMedia, reconcileMediaRoute, stopMediaRoute } from '@/api/sense/media'
|
||||
import { mediaStatusLabel, mediaStatusType } from './mediaStatus'
|
||||
|
||||
const routes = ref([]); const process = ref({}); const loading = ref(false); const reconciling = ref(false)
|
||||
function unwrap(response) { return response?.data?.data ?? response?.data ?? response }
|
||||
function processLabel(value) { return ({ running: '运行中', starting: '启动中', stopped: '已停止', not_configured: '未配置', configuration_failed: '配置错误', failed: '启动失败' })[value] || value || '未知' }
|
||||
async function load() { loading.value = true; try { const [routeResponse, processResponse] = await Promise.all([listMediaRoutes(), getMediaProcess()]); const routePayload = unwrap(routeResponse); routes.value = routePayload?.list || []; process.value = unwrap(processResponse) || {} } catch (error) { ElMessage.error(error.message || '视频服务状态加载失败') } finally { loading.value = false } }
|
||||
async function handleReconcileAll() { reconciling.value = true; try { await reconcileAllMedia(); ElMessage.success('对账完成'); await load() } catch (error) { ElMessage.warning(error.message || '对账未完成,请查看路径状态') } finally { reconciling.value = false } }
|
||||
async function handleRoute(route) { try { await reconcileMediaRoute(route.id); await load() } catch (error) { ElMessage.warning(error.message || '该路径对账失败') } }
|
||||
async function handleStop(route) { try { await ElMessageBox.confirm('停止后该路径将不再拉取摄像头视频,设备和 Profile 不会被删除。', '停止媒体路径', { type: 'warning' }); await stopMediaRoute(route.id); ElMessage.success('路径已停止'); await load() } catch (error) { if (error !== 'cancel' && error !== 'close') ElMessage.warning(error.message || '停止失败') } }
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:16px}.page-header h3{margin:0 0 6px}.page-header p{margin:0;color:#909399}.process-state{margin-top:18px}.route-table{margin-top:18px}
|
||||
</style>
|
||||
@@ -0,0 +1,10 @@
|
||||
export function mediaStatusLabel(value) {
|
||||
return ({ ready: '拉流正常', waiting: '等待拉流', pending: '等待对账', stopped: '已停止', process_unavailable: '进程不可用', apply_failed: '配置失败', status_unavailable: '状态未知', path_missing: '路径缺失', profile_unavailable: 'Profile 不可用', credential_unavailable: '凭据不可用' })[value] || value || '未知'
|
||||
}
|
||||
|
||||
export function mediaStatusType(value) {
|
||||
if (value === 'ready' || value === 'running') return 'success'
|
||||
if (value === 'waiting' || value === 'pending' || value === 'starting') return 'warning'
|
||||
if (value === 'stopped' || value === 'not_configured') return 'info'
|
||||
return 'danger'
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import SenseLiveview from '@/views/sense/liveview/index.vue'
|
||||
import { createLiveviewSession, getLiveviewSession, listLiveviewRoutes } from '@/api/sense/liveview'
|
||||
|
||||
jest.mock('@/api/sense/liveview', () => ({
|
||||
listLiveviewRoutes: jest.fn(),
|
||||
createLiveviewSession: jest.fn(),
|
||||
getLiveviewSession: jest.fn()
|
||||
}))
|
||||
|
||||
describe('Sense live-view page lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers()
|
||||
listLiveviewRoutes.mockResolvedValue({ data: { list: [], count: 0 }})
|
||||
createLiveviewSession.mockResolvedValue({ data: { id: 'view_test', playerUrl: '/api/v1/liveview/player/view_test', status: 'waiting', detail: '等待播放器连接' }})
|
||||
getLiveviewSession.mockResolvedValue({ data: { id: 'view_test', playerUrl: '/api/v1/liveview/player/view_test', status: 'ready', detail: '上游拉流正常' }})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('opens only the selected route and clears polling when unmounted', async() => {
|
||||
const context = {
|
||||
...SenseLiveview.data(),
|
||||
currentRoute: { id: 'device-1:main', deviceName: '东门摄像机' },
|
||||
playerOpen: true
|
||||
}
|
||||
context.stopTimers = SenseLiveview.methods.stopTimers.bind(context)
|
||||
context.refreshSession = SenseLiveview.methods.refreshSession.bind(context)
|
||||
context.startPolling = SenseLiveview.methods.startPolling.bind(context)
|
||||
await SenseLiveview.methods.openSession.call(context)
|
||||
|
||||
expect(createLiveviewSession).toHaveBeenCalledWith('device-1:main')
|
||||
expect(context.session.playerUrl).toBe('/api/v1/liveview/player/view_test')
|
||||
expect(jest.getTimerCount()).toBe(2)
|
||||
|
||||
SenseLiveview.beforeUnmount.call(context)
|
||||
expect(jest.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { mount } from '@vue/test-utils'
|
||||
import SenseVideoPlayer from '@/components/sense/video-player/index.vue'
|
||||
|
||||
const ElButton = { emits: ['click'], template: '<button @click="$emit(\'click\')"><slot /></button>' }
|
||||
const ElIcon = { template: '<span><slot /></span>' }
|
||||
|
||||
const mountPlayer = props => mount(SenseVideoPlayer, {
|
||||
props,
|
||||
global: { components: { ElButton, ElIcon }}
|
||||
})
|
||||
|
||||
describe('Sense live-view player lifecycle', () => {
|
||||
it('creates an iframe only for a selected playable session', () => {
|
||||
const empty = mountPlayer({ state: 'loading', playerUrl: '' })
|
||||
expect(empty.find('iframe').exists()).toBe(false)
|
||||
empty.unmount()
|
||||
|
||||
const waiting = mountPlayer({ state: 'waiting', playerUrl: '/api/v1/liveview/player/view_test' })
|
||||
expect(waiting.findAll('iframe')).toHaveLength(1)
|
||||
expect(waiting.find('iframe').attributes('src')).toBe('/api/v1/liveview/player/view_test')
|
||||
expect(waiting.attributes('aria-busy')).toBe('true')
|
||||
waiting.unmount()
|
||||
})
|
||||
|
||||
it('removes the iframe on timeout and offers an explicit retry', async() => {
|
||||
const wrapper = mountPlayer({ state: 'timeout', playerUrl: '/api/v1/liveview/player/view_test' })
|
||||
expect(wrapper.find('iframe').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('连接超时')
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(wrapper.emitted('retry')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { playbackStatusDetail, playbackStatusLabel, playbackStatusType, routeStatus } from '@/views/sense/liveview/playbackStatus'
|
||||
|
||||
describe('Sense live-view playback states', () => {
|
||||
it('keeps authentication, missing stream and timeout actionable', () => {
|
||||
expect(playbackStatusLabel('authentication_failed')).toBe('摄像头认证失败')
|
||||
expect(playbackStatusDetail('stream_not_found')).toContain('视频服务')
|
||||
expect(playbackStatusDetail('timeout')).toContain('20 秒')
|
||||
expect(playbackStatusType('offline')).toBe('danger')
|
||||
})
|
||||
|
||||
it('maps route readiness without relying on color alone', () => {
|
||||
expect(routeStatus('ready')).toEqual({ label: '可观看', type: 'success' })
|
||||
expect(routeStatus('waiting')).toEqual({ label: '等待观看', type: 'warning' })
|
||||
expect(routeStatus('apply_failed').label).toBe('需要处理')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
import { mediaStatusLabel, mediaStatusType } from '@/views/sense/media/mediaStatus'
|
||||
|
||||
describe('Sense media status presentation', () => {
|
||||
it('uses actionable labels for expected lifecycle states', () => {
|
||||
expect(mediaStatusLabel('waiting')).toBe('等待拉流')
|
||||
expect(mediaStatusLabel('process_unavailable')).toBe('进程不可用')
|
||||
expect(mediaStatusType('ready')).toBe('success')
|
||||
expect(mediaStatusType('apply_failed')).toBe('danger')
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Architecture-and-Code-Map
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Architecture-and-Code-Map.-
|
||||
wiki_revision: 915b5ca9a9a097470cb67a80f73045d16d3d57be
|
||||
synchronized_at: 2026-08-14T08:53:04Z
|
||||
wiki_revision: 0a15795aa69c5a0279a42ff1b48b44ddf68a9fad
|
||||
synchronized_at: 2026-08-14T10:45:46Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 架构与代码地图
|
||||
@@ -96,6 +96,14 @@ Sense JWT realm 固定为 `Sense`;浏览器令牌 Cookie 为 `Sense-Admin-Toke
|
||||
工单 #66 在 Sense/server/app/sense/onvif/、rtsp/ 与 admission/ 建立视频接入边界:WS-Discovery 只能绑定 SENSE_ONVIF_DISCOVERY_IP 指定的本机网卡,所有 ONVIF、Media XAddr 与 RTSP Stream URI 都必须落在 SENSE_ONVIF_ALLOWED_CIDRS 明确授权的网段。HTTP 客户端禁止代理和重定向,并在每次连接时重新解析、校验和固定目标 IP,防止 DNS 重绑定;URL 用户信息及敏感查询参数被拒绝。
|
||||
|
||||
ONVIF 支持 Basic 与 MD5/SHA-256 Digest challenge,Profile 与无凭据 Stream URI 持久化到 PostgreSQL。接入失败会记录可行动状态但保留最后一次已验证 Profile;成功接入清除凭据更新触发的重试标记。前端继续复用 GoAdmin 动态菜单、权限链、BasicLayout 和 Element Plus 表单、Dialog、Table、Tag。
|
||||
工单 #67 在 `Sense/server/app/sense/media/` 与 `reconcile/` 建立 MediaMTX 管理面:`cmd/api/server.go` 随 Sense 生命周期启动后台对账并只停止本实例拥有的子进程;检测到外部实例时设置孤儿安全闸,不发送停止信号。MediaMTX Control API 只允许 loopback HTTP,禁用代理与重定向。
|
||||
|
||||
媒体路由只保存设备/Profile 引用、无秘密路径名、期望态、实际态、reader、退避和下次重试;摄像头凭据从内部端口按需解密,仅在 loopback Control API 请求内临时组装,不写入路由表、基础配置、日志或 Sense 响应。MediaMTX 故障和退避不改变 #66 的设备/Profile 验证状态。
|
||||
|
||||
工单 #68 在 `Sense/server/app/sense/liveview/` 建立单路监看投影和短期播放会话:认证 API 只返回设备/Profile 展示字段、播放状态和同源短期播放器地址,不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。播放能力令牌使用 192 位随机值、绑定登录用户、同一用户只保留一个活动会话,登录页面持续轮询时按 2 分钟无活动窗口续期;关闭页面后停止续期。
|
||||
|
||||
`Sense/server/app/admin/router/sense_liveview.go` 将列表、创建会话和状态查询接入 GoAdmin JWT/Casbin,短期播放器包装页只凭不可猜测能力令牌访问,并设置 no-store、no-referrer、SAMEORIGIN 与 CSP。前端入口为 `Sense/ui/src/views/sense/liveview/index.vue`,复用 BasicLayout、Element Plus 表格/分页/Dialog/Tag 和权限指令;仅 `Sense/ui/src/components/sense/video-player/` 是业务专用播放器组件,任一时刻只建立一路 reader。
|
||||
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
<!-- sense-mvp:start -->
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Business-Rules-and-Glossary
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Business-Rules-and-Glossary.-
|
||||
wiki_revision: d1471acadb4759b80e9bc20e5cec18bd21ba65cd
|
||||
synchronized_at: 2026-08-14T08:53:13Z
|
||||
wiki_revision: e80685e19115e26384d58ddee8a15e5d80b03bb4
|
||||
synchronized_at: 2026-08-14T10:45:50Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 业务规则与术语
|
||||
@@ -76,6 +76,25 @@ synchronized_at: 2026-08-14T08:53:13Z
|
||||
- 登录成功/失败、登出、密码变更和鉴权拒绝必须留下身份审计;密码、令牌、Cookie、验证码、数据库连接和摄像头凭据不得进入审计正文。
|
||||
- `site_admin` 可在账户维护流程中读取角色、部门、岗位和字典等必要支撑数据,但不能修改角色、菜单或系统配置;`implementation_operator` 与 `viewer` 不具备账户管理权限。未注册的配置和接口管理路由对所有角色返回 404。
|
||||
|
||||
<!-- sense-media:start -->
|
||||
## Sense 视频服务规则
|
||||
|
||||
- MediaMTX 始终是独立二进制;Sense 管理配置、进程生命周期、路径期望态和状态对账,不把媒体内核放入 GoAdmin handler 或 GORM model。
|
||||
- Control API 只能绑定回环地址。Sense 可启动配置的 MediaMTX,也可连接已由外部启动的实例;外部实例标记为非本实例所有,孤儿安全闸禁止 Sense 停止它。
|
||||
- 已验证 Profile 幂等形成媒体路径;数据库不保存带凭据 Stream URI。摄像头凭据只在 loopback Control API 请求边界临时使用,不进入基础配置、日志或 Sense API。
|
||||
- 路径状态区分 pending、waiting、ready、process_unavailable、apply_failed、status_unavailable、path_missing、stopped,并保存失败次数和有上限的下次重试时间。
|
||||
- 冷启动恢复 desired=running 路径;用户明确停止的路径保持 stopped,不因启动扫描自动重新启用。稳定路径只刷新状态,不重复下发配置或无意义增加版本。
|
||||
- MediaMTX 失败不得删除或降级设备台账与最后一次已验证 Profile。
|
||||
### Sense 实时监看规则
|
||||
|
||||
- 设备/Profile 列表必须分页和搜索;16/128 路不能导致页面同时创建全部播放器。用户选择一路并打开 Dialog 后才建立 reader,关闭或切换时销毁当前播放器。
|
||||
- 页面和 JSON API 不返回 RTSP URI、摄像头凭据或 MediaMTX 内部路径。播放器只使用同源短期能力地址;能力令牌必须高熵、绑定用户、每用户单会话,并在停止认证轮询后最多 2 分钟失效。
|
||||
- 播放状态必须区分 loading、waiting、ready、authentication_failed、stream_not_found、service_unavailable、timeout、expired、offline 和 stopped,并同时显示文字与可行动处理建议,不能只用颜色表达。
|
||||
- waiting 表示媒体路径存在且播放器正在建立 reader,不等于摄像头接入失败;20 秒仍未就绪才显示连接超时并允许显式重连。
|
||||
- 默认从浏览器访问 Sense 的主机名推导 MediaMTX WebRTC 端口 8889;经过反向代理、HTTPS 或端口映射时,部署方必须显式配置安全的 `SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE`,不得回退到只对服务器自身有效的地址。
|
||||
|
||||
<!-- sense-media:end -->
|
||||
|
||||
<!-- sense-mvp:start -->
|
||||
## Sense 旧 MVP 规则状态
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Local-Development-and-Verification
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Local-Development-and-Verification.-
|
||||
wiki_revision: c8afa08e1e189a9c2779da1cbd3ed75124fd7eae
|
||||
synchronized_at: 2026-08-14T08:53:20Z
|
||||
wiki_revision: 95be7e2aa152205f524a185897dff5eb2c7e7e04
|
||||
synchronized_at: 2026-08-14T10:45:55Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 本地开发与验证
|
||||
@@ -167,6 +167,59 @@ $env:SENSE_CREDENTIAL_KEY = [Convert]::ToBase64String($keyBytes)
|
||||
视频接入还需在仓库外配置 SENSE_ONVIF_DISCOVERY_IP(获准的本机网卡 IP)和 SENSE_ONVIF_ALLOWED_CIDRS(逗号分隔的获准摄像头网段)。不要使用 0.0.0.0/0 代替授权清单。
|
||||
|
||||
协议回归位于 app/sense/onvif、app/sense/rtsp、app/sense/admission;隔离 PostgreSQL 重启恢复测试通过 SENSE_ADMISSION_TEST_DATABASE_URL 显式启用。验证至少覆盖 Digest/Basic、无配置发现提示、URL 凭据和敏感查询拒绝、目标网段、重定向、Media/Stream 主机归一化、主子码流、失败重探保留已验证 Profile,以及 viewer 只读权限。
|
||||
MediaMTX 保持仓库外独立二进制。运行前在进程环境设置:
|
||||
|
||||
```powershell
|
||||
$env:SENSE_MEDIAMTX_BINARY = '<MediaMTX 可执行文件>'
|
||||
$env:SENSE_MEDIAMTX_CONFIG = '<仓库外 mediamtx.yml>'
|
||||
$env:SENSE_MEDIAMTX_API = 'http://127.0.0.1:9997'
|
||||
```
|
||||
|
||||
配置文件不存在时 Sense 只生成 loopback API 和空 `paths: {}` 的无凭据基础配置;模板位于 `Sense/server/config/mediamtx/mediamtx.yml.example`。Control API 不允许非回环地址。真实集成验证使用:
|
||||
|
||||
```powershell
|
||||
$env:SENSE_MEDIAMTX_TEST_BINARY = '<MediaMTX 可执行文件>'
|
||||
go test ./tests/media -run TestRealMediaMTXControlLifecycle -v
|
||||
|
||||
$env:SENSE_MEDIA_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||
go test ./tests/media -run TestPostgresColdStartRestoresDesiredRoute -v
|
||||
|
||||
$env:SENSE_MEDIA_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||
go test ./cmd/migrate/migration/version -run TestMediaMigrationOnPostgres -v
|
||||
```
|
||||
|
||||
测试必须使用隔离端口和数据库;结束后停止测试进程。不得输出连接串或摄像头凭据。
|
||||
|
||||
### Sense 实时监看
|
||||
|
||||
浏览器直接访问 Sense 所在主机且 MediaMTX 使用默认 WebRTC 端口 8889 时无需额外变量。反向代理、HTTPS 或端口映射部署必须在 Sense 进程环境提供浏览器可达的基础地址;值只能是无用户信息、查询和片段的 HTTP(S) origin:
|
||||
|
||||
```powershell
|
||||
$env:SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE = 'http://<浏览器可达主机>:8889'
|
||||
```
|
||||
|
||||
不要填写 RTSP 地址、Control API 地址、摄像头凭据或服务器内部文件路径。HTTPS 页面不得嵌入 HTTP 视频地址;应为 MediaMTX WebRTC 配置 HTTPS 或受控同源代理后填写对应 HTTPS origin。
|
||||
|
||||
定向与回归验证:
|
||||
|
||||
```powershell
|
||||
cd Sense/server
|
||||
go test -race ./app/sense/liveview
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./...
|
||||
|
||||
$env:SENSE_LIVEVIEW_MIGRATION_TEST_DATABASE_URL = '<隔离 PostgreSQL 连接>'
|
||||
go test ./cmd/migrate/migration/version -run TestLiveviewMigrationOnPostgres -count=1 -v
|
||||
|
||||
cd ../ui
|
||||
corepack pnpm@9.15.1 lint
|
||||
corepack pnpm@9.15.1 test:unit
|
||||
corepack pnpm@9.15.1 build:prod
|
||||
```
|
||||
|
||||
真实 smoke 使用隔离端口、MediaMTX 和合成 RTSP:浏览器打开 WebRTC 播放地址后必须取得非零视频尺寸和可播放 readyState,并确认任一时刻只存在一个播放器。结束后停止测试 MediaMTX/FFmpeg/浏览器并删除临时目录。客户真实摄像机与现场网络仍需获得授权后验证,记录状态而不记录地址、URI 或凭据。
|
||||
|
||||
<!-- sense-runtime:end -->
|
||||
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Troubleshooting
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Troubleshooting
|
||||
wiki_revision: ae80dc2d34341ff3cddfff93f51373adb3bd0664
|
||||
synchronized_at: 2026-08-14T08:53:30Z
|
||||
wiki_revision: 331755cb39ac9d63c188395c5caafe0815d64975
|
||||
synchronized_at: 2026-08-14T10:46:04Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 故障排查
|
||||
@@ -73,4 +73,31 @@ synchronized_at: 2026-08-14T08:53:30Z
|
||||
| 设备时间异常 | 在摄像机管理页或受控 NTP 环境校时后重新探测;Sense 不自动修改设备时间。 |
|
||||
| 部分码流失败 | 查看逐 Profile 状态、设备 RTSP 权限和端口;最后一次已验证 Profile 会保留。 |
|
||||
| 重定向已拒绝 | ONVIF 服务返回了 3xx;修正为摄像机最终服务地址,不允许 Sense 跟随到未知目标。 |
|
||||
<!-- sense-media:start -->
|
||||
## Sense 视频服务排错
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|---|---|
|
||||
| 进程状态“未配置” | 未设置 SENSE_MEDIAMTX_BINARY;如由外部服务管理,先确认 loopback Control API 已就绪,否则配置二进制和仓库外配置路径。 |
|
||||
| 进程启动失败 | 核对二进制存在、配置目录可写、MediaMTX 配置可解析,以及 RTSP/API 端口未被其他进程占用。 |
|
||||
| 等待拉流 | 路径已建立但 sourceOnDemand 尚无 reader;打开实时监看后再观察,不等同于接入失败。 |
|
||||
| 配置失败或路径缺失 | 在“视频服务”点击对账;检查 Control API 仍为 loopback、Profile 仍已验证、RTSP 凭据可用。 |
|
||||
| 显示外部启动(受保护) | Sense 检测到不是本实例启动的 MediaMTX;孤儿安全闸生效,Sense 关闭时不会停止它。 |
|
||||
| 持续自动重试 | 查看失败码、失败次数和下次重试时间;修正二进制、端口、凭据或上游后等待退避到期,或由有权限用户立即对账。 |
|
||||
| Sense 重启后路径未恢复 | 确认数据库 route 的 desired 为 running、迁移已执行、Control API 可达;明确停止的路径不会自动恢复。 |
|
||||
### Sense 实时监看排错
|
||||
|
||||
| 现象 | 原因与处理 |
|
||||
|---|---|
|
||||
| 浏览器提示 127.0.0.1 拒绝连接 | 127.0.0.1 指向使用者电脑,不一定是 Sense 服务器;将 `SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE` 配成浏览器实际可达的 MediaMTX HTTP(S) origin,并检查 8889 或映射端口。 |
|
||||
| `stream not found` / 未找到视频流 | 路径未恢复或 Profile 已失效;先到“视频服务”对账,确认路径存在,再重新打开实时监看建立 reader。不要把内部路径手工拼进页面。 |
|
||||
| 一直显示“等待视频” | 播放器已创建但 sourceOnDemand 尚未 ready;保持 Dialog 打开并检查摄像头 RTSP、WebRTC 端口和 MediaMTX reader。20 秒后页面会转为超时并提供重连。 |
|
||||
| 摄像头认证失败 | 到“设备管理”更新凭据,再到“视频接入”重新验证;页面不会显示或回填旧凭据。 |
|
||||
| 视频服务不可用 | 到“视频服务”检查 MediaMTX 进程、Control API、WebRTC 端口和路径对账,不要只刷新浏览器。 |
|
||||
| 播放会话已过期 | 页面关闭、网络中断或认证轮询停止超过 2 分钟;重新连接会生成新能力令牌,旧地址不应继续可用。 |
|
||||
| HTTPS 页面无法播放 HTTP 视频 | 浏览器阻止混合内容;为 MediaMTX WebRTC 配置 HTTPS 或受控同源代理,并把公开基础地址改成 HTTPS。 |
|
||||
| 服务端 curl 正常、浏览器仍失败 | 服务端可达不代表客户端可达;从实际用户浏览器检查公开主机、端口、防火墙、证书和 WebRTC UDP/TCP 路径。 |
|
||||
|
||||
<!-- sense-media:end -->
|
||||
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
+12
-2
@@ -2,8 +2,8 @@
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Delivery-Documentation-Guide
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Delivery-Documentation-Guide.-
|
||||
wiki_revision: 07c8711ff4d75da5e4acbd61d029d27f01009747
|
||||
synchronized_at: 2026-08-14T08:54:16Z
|
||||
wiki_revision: 45c86f2a0e4d3252e8042df5ee725e633dd497c1
|
||||
synchronized_at: 2026-08-14T09:47:31Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 交付文档指南
|
||||
@@ -119,4 +119,14 @@ Sense 面向网管、实施人员和非技术现场人员,菜单按日常任
|
||||
部署人员必须先确认获准摄像头网段,再把本机对应网卡 IP 配置为 SENSE_ONVIF_DISCOVERY_IP,把获准网段配置为逗号分隔的 SENSE_ONVIF_ALLOWED_CIDRS。不得为了省事填写全网段。现场人员在“视频接入”选择已登记且已配置凭据的视频设备,可使用发现结果或手工填写不含账号密码的 ONVIF 地址。
|
||||
|
||||
验证结果区分可用、部分码流失败、认证失败、目标未获准、重定向拒绝、响应超时、设备时间异常和无法连接,并显示主/子码流及逐 Profile 状态。失败重试不会删除上次已验证 Profile;修改凭据后应重新验证。真实摄像机兼容性、网络 ACL 和设备校时仍需在客户授权环境完成。
|
||||
<!-- sense-media:start -->
|
||||
### Sense 视频服务交付说明
|
||||
|
||||
“视频服务”面向实施、运维和站点管理员展示 MediaMTX 进程归属、媒体路径、拉流状态、观看数、失败原因和下次重试。只读用户只能查看;有操作权限的人员可立即对账或停止单一路径。
|
||||
|
||||
交付时 MediaMTX 二进制和真实配置放在仓库外受控目录,Control API 只能绑定回环地址。Sense 关闭时只停止自己启动的 MediaMTX;外部启动进程会显示“外部启动(受保护)”。“等待拉流”表示按需路径尚无观看者,不等同于故障。停止路径不会删除设备或 Profile,后续重新接入可恢复期望态。
|
||||
|
||||
任何客户文档、截图和日志都不得包含 Control API 请求体、摄像头凭据或带凭据 URI。现场至少验证启动失败、端口冲突、外部进程保护、重复对账、冷启动恢复和 reader 状态。
|
||||
<!-- sense-media:end -->
|
||||
|
||||
<!-- sense-admission:end -->
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-67-Sense视频服务生命周期与状态对账
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-67-Sense%E8%A7%86%E9%A2%91%E6%9C%8D%E5%8A%A1%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E4%B8%8E%E7%8A%B6%E6%80%81%E5%AF%B9%E8%B4%A6.-
|
||||
wiki_revision: 4642924705d7a3406874ef532579fb2d6a25a88b
|
||||
synchronized_at: 2026-08-14T10:13:23Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 67 Sense视频服务生命周期与状态对账
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-14
|
||||
- 工单:https://git.ilapage.cn/ila/yovision/issues/67
|
||||
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/85
|
||||
- 主项目:Sense
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 #66 已验证设备/Profile 上重建 MediaMTX 安全配置、独立进程生命周期、按需路径和持久化状态对账。Brain、Bell 不启动时可独立运行和验收。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- MediaMTX 保持独立二进制;Sense 在 GoAdmin server 生命周期内启动后台对账,只停止自己启动的子进程。
|
||||
- Control API 只允许 loopback HTTP,禁用代理和重定向;检测到外部实例时标记“外部启动(受保护)”,孤儿安全闸禁止停止。
|
||||
- 路由只持久化 Device/Profile 引用、哈希路径、desired/actual、reader、失败次数、退避和下次重试,不保存带凭据 URI。
|
||||
- 摄像头凭据按需从 #65 内部端口读取,仅在 MediaMTX v1.19.3 loopback 请求边界临时组装,不进入基础配置、日志或 Sense API。
|
||||
- 接入成功后幂等建立路由;冷启动恢复 desired=running,明确 stopped 路径保持停止;稳定路径只刷新状态,不重复下发或增加版本。
|
||||
- 复用 GoAdmin JWT/Casbin/操作审计、迁移、动态菜单和 go-admin-ui BasicLayout、Element Plus Descriptions/Table/Tag/Button/MessageBox。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 标准 | 结果 |
|
||||
|---|---|
|
||||
| 已验证 Profile 幂等建立/更新路径 | 单元测试及接入内部端口通过 |
|
||||
| 进程未启动、启动中、失败和恢复可定位 | 状态机、真实二进制和页面反馈通过 |
|
||||
| 冷启动恢复 running 路径 | PostgreSQL 17 重建 Service 测试通过 |
|
||||
| reader、上游、退避、下次重试、孤儿闸可观察 | DTO、页面、退避和外部进程测试通过 |
|
||||
| 配置、日志和 Sense API 不泄漏凭据 | loopback 边界、无秘密模型/响应及扫描通过 |
|
||||
| MediaMTX 失败不破坏 Profile | 失败持久化测试确认 Profile 保持 ready |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./app/sense/media ./app/sense/reconcile`:通过。
|
||||
- 前端 lint:0 error,32 条上游/目录命名 warning。
|
||||
- 前端单测:10 suites、35 tests 通过。
|
||||
- 前端生产构建:通过,6 条上游继承 warning。
|
||||
- 真实 MediaMTX v1.19.3:Control API 就绪、path add/patch/delete 通过。
|
||||
- PostgreSQL 17:desired route 保存后重建 Service 并恢复为 ready 通过。
|
||||
- PostgreSQL 17 定向迁移:3 个菜单、12 条角色策略、1 条迁移记录通过。
|
||||
- Wiki 镜像检查:通过。
|
||||
- 已处理测试安全问题:MediaMTX 自动 TLS 文件的工作目录已固定到外部配置目录;临时证书/私钥未进入最终提交或远端。
|
||||
- 未验证部分:未连接客户真实摄像机和现场网络;真实上游持续拉流、reader 变化、端口 ACL 和目标浏览器留待授权现场验收。
|
||||
- 已知相邻问题:空白 PostgreSQL 执行完整上游迁移链时,在到达 #67 前被旧 `sys_config` 初始化字段长度问题中止;#67 定向迁移已通过,空库安装链应由 #70/#71 单独复核,不在本工单混改上游初始化。
|
||||
|
||||
## 回退
|
||||
|
||||
回退 PR #85 可移除运行时、路由和页面入口;已验证 Device/Profile 不受影响。媒体路由表保留可审计状态,停止/回退不删除摄像机数据。
|
||||
|
||||
## 验收
|
||||
|
||||
- 用户于 2026-08-14 明确验收通过。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- 19f9bfa feat: 重建 MediaMTX 生命周期与状态对账 (#67)
|
||||
- 4149cc4 docs: 记录 Sense 视频服务生命周期 (#67)
|
||||
|
||||
- 263a68c docs: 归档工单 #67 待验收证据
|
||||
@@ -0,0 +1,81 @@
|
||||
<!-- gitea-wiki-mirror:start -->
|
||||
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
|
||||
wiki_page: Task-68-Sense单路实时监看与播放状态反馈
|
||||
wiki_url: https://git.ilapage.cn/ila/yovision/wiki/Task-68-Sense%E5%8D%95%E8%B7%AF%E5%AE%9E%E6%97%B6%E7%9B%91%E7%9C%8B%E4%B8%8E%E6%92%AD%E6%94%BE%E7%8A%B6%E6%80%81%E5%8F%8D%E9%A6%88.-
|
||||
wiki_revision: 88e542d512e472274a74e443a761217e24618941
|
||||
synchronized_at: 2026-08-15T00:40:11Z
|
||||
<!-- gitea-wiki-mirror:end -->
|
||||
|
||||
# 68 Sense单路实时监看与播放状态反馈
|
||||
|
||||
- 类型:需求
|
||||
- 所属 Epic:#7
|
||||
- 所属 MVP / 版本:#8
|
||||
- 状态:已完成
|
||||
- 日期:2026-08-14
|
||||
- Gitea 工单:https://git.ilapage.cn/ila/yovision/issues/68
|
||||
- Pull Request:https://git.ilapage.cn/ila/yovision/pulls/86
|
||||
- 主项目:Sense
|
||||
|
||||
## 背景与目标
|
||||
|
||||
在 #67 已对账的 MediaMTX 路径上,为网管和非技术人员提供设备/Profile 搜索、分页和单路实时查看。Brain、Bell 不启动时可独立运行;页面不得暴露摄像头凭据、RTSP URI或内部媒体路径。
|
||||
|
||||
## 最终方案
|
||||
|
||||
- 后端在 `app/sense/liveview` 建立只含展示字段的分页投影,最多返回 50 条,不把 16/128 路变成硬上限。
|
||||
- 登录用户选择一路后创建 192 位随机播放能力;令牌绑定用户、同用户只保留一个会话,认证轮询时按 2 分钟无活动窗口续期,关闭页面后失效。
|
||||
- JSON 只返回同源 `/api/v1/liveview/player/<能力>`;包装页使用 no-store、no-referrer、SAMEORIGIN 与 CSP,再嵌入浏览器可达的 MediaMTX WebRTC 页面。
|
||||
- 默认由 Sense 请求主机推导 WebRTC 8889;反向代理、HTTPS 或端口映射通过安全的 `SENSE_MEDIAMTX_WEBRTC_PUBLIC_BASE` 显式配置。
|
||||
- 复用 GoAdmin Router/JWT/Casbin/迁移/动态菜单及 go-admin-ui BasicLayout、Axios、Element Plus 搜索/表格/分页/Dialog/Tag/权限按钮;只新增播放器业务组件。
|
||||
- waiting 时先保留 iframe 建立 reader;ready 后持续轮询,20 秒未就绪显示超时。关闭 Dialog 或组件卸载会清理 iframe、轮询和超时计时器。
|
||||
- 状态区分 loading、waiting、ready、authentication_failed、stream_not_found、service_unavailable、timeout、expired、offline 和 stopped,并给出可行动中文处理建议。
|
||||
|
||||
## 修改文件
|
||||
|
||||
- `Sense/server/app/sense/liveview/**`:分页投影、短期会话、播放器包装页及测试。
|
||||
- `Sense/server/app/admin/router/sense_liveview.go`:认证 API 与短期播放器路由。
|
||||
- `Sense/server/cmd/migrate/migration/version/*liveview*`:菜单、角色和 Casbin 权限。
|
||||
- `Sense/ui/src/views/sense/liveview/**`、`api/sense/liveview.js`:实时监看页面、状态映射和请求。
|
||||
- `Sense/ui/src/components/sense/video-player/**`:唯一业务专用播放器组件。
|
||||
- `Sense/ui/tests/unit/sense/liveview*.spec.js`:状态、iframe 和页面计时器生命周期测试。
|
||||
- Wiki Architecture、Business Rules、Local Development、Troubleshooting:架构、安全、配置、验证和排错。
|
||||
|
||||
## 验收结果
|
||||
|
||||
| 验收标准 | 结果 |
|
||||
|---|---|
|
||||
| 设备与 Profile 可搜索、分页并进入单路实时监看 | 通过 |
|
||||
| waiting 且播放 URL 有效时建立 reader,不循环等待 | 通过;真实合成 WebRTC smoke |
|
||||
| 加载、空、认证失败、stream not found、重连和超时可区分 | 通过 |
|
||||
| 页面不暴露凭据与内部文件路径 | 通过;DTO/JSON 测试与扫描 |
|
||||
| 128 路使用分页且不同时创建全部播放器 | 通过;服务端最大页 50,前端单 Dialog |
|
||||
| 通用组件无重复实现且播放器风格一致 | 通过 |
|
||||
|
||||
## 测试
|
||||
|
||||
- `go test ./...`、`go vet ./...`、`go build ./...`:通过。
|
||||
- `go test -race ./app/sense/liveview`:通过。
|
||||
- PostgreSQL 17 隔离迁移:1 个菜单、9 条 Casbin 策略、1 条迁移记录通过;临时实例/schema/目录已清理。
|
||||
- 前端 lint:0 error,32 条冻结上游 warning。
|
||||
- 前端单测:13 suites、40 tests 通过。
|
||||
- 前端生产构建:通过,6 条冻结上游 warning。
|
||||
- MediaMTX v1.19.3 + FFmpeg 合成 RTSP + Edge WebRTC:640×360、readyState=4、自动播放中;临时进程、配置和自动证书已清理。
|
||||
- Wiki 页面读取确认并成功导出镜像。
|
||||
- **未验证部分**:客户真实摄像机、现场防火墙/证书、实际部署反向代理和客户目标浏览器,需授权现场验收。
|
||||
|
||||
## 风险与回退
|
||||
|
||||
回退 PR #86 可移除实时监看入口、会话 API、菜单和播放器组件;#67 的设备/Profile、媒体路由与 MediaMTX 生命周期不受影响。内存会话随进程退出自然失效,无数据库业务数据需要回滚。
|
||||
|
||||
## 相关提交
|
||||
|
||||
- `c63c623` feat: 重建 Sense 单路实时监看 (#68)
|
||||
- `a279a1e` docs: 记录 Sense 实时监看架构 (#68)
|
||||
|
||||
|
||||
## 人工验收
|
||||
|
||||
- 验收日期:2026-08-15
|
||||
- 验收结论:用户明确回复“#68 验收通过”。
|
||||
- 后续处理:按分支治理将 PR #86 合入 `dev`,关闭单元工单,并在 MVP #8 与 Epic #7 更新索引;不修改 `main`。
|
||||
@@ -115,6 +115,14 @@
|
||||
{
|
||||
"page": "Task-66-Sense视频接入与Profile",
|
||||
"path": "docs/task/66-Sense视频接入与Profile.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-67-Sense视频服务生命周期与状态对账",
|
||||
"path": "docs/task/67-Sense视频服务生命周期与状态对账.md"
|
||||
},
|
||||
{
|
||||
"page": "Task-68-Sense单路实时监看与播放状态反馈",
|
||||
"path": "docs/task/68-Sense单路实时监看与播放状态反馈.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user