From c63c623df5bf84ff434ae29df2967b0d076ccd2e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Fri, 14 Aug 2026 18:48:06 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=87=8D=E5=BB=BA=20Sense=20=E5=8D=95?= =?UTF-8?q?=E8=B7=AF=E5=AE=9E=E6=97=B6=E7=9B=91=E7=9C=8B=20(#68)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/app/admin/router/sense_liveview.go | 27 ++ Sense/server/app/sense/liveview/apis.go | 134 ++++++++ Sense/server/app/sense/liveview/apis_test.go | 41 +++ Sense/server/app/sense/liveview/service.go | 291 ++++++++++++++++++ .../server/app/sense/liveview/service_test.go | 89 ++++++ .../version/2026081420000_liveview.go | 37 +++ .../version/2026081420000_liveview_test.go | 57 ++++ Sense/ui/src/api/sense/liveview.js | 13 + .../components/sense/video-player/index.vue | 126 ++++++++ Sense/ui/src/views/sense/liveview/index.vue | 283 +++++++++++++++++ .../views/sense/liveview/playbackStatus.js | 46 +++ .../unit/sense/liveviewLifecycle.spec.js | 41 +++ .../tests/unit/sense/liveviewPlayer.spec.js | 32 ++ .../tests/unit/sense/liveviewStatus.spec.js | 16 + 14 files changed, 1233 insertions(+) create mode 100644 Sense/server/app/admin/router/sense_liveview.go create mode 100644 Sense/server/app/sense/liveview/apis.go create mode 100644 Sense/server/app/sense/liveview/apis_test.go create mode 100644 Sense/server/app/sense/liveview/service.go create mode 100644 Sense/server/app/sense/liveview/service_test.go create mode 100644 Sense/server/cmd/migrate/migration/version/2026081420000_liveview.go create mode 100644 Sense/server/cmd/migrate/migration/version/2026081420000_liveview_test.go create mode 100644 Sense/ui/src/api/sense/liveview.js create mode 100644 Sense/ui/src/components/sense/video-player/index.vue create mode 100644 Sense/ui/src/views/sense/liveview/index.vue create mode 100644 Sense/ui/src/views/sense/liveview/playbackStatus.js create mode 100644 Sense/ui/tests/unit/sense/liveviewLifecycle.spec.js create mode 100644 Sense/ui/tests/unit/sense/liveviewPlayer.spec.js create mode 100644 Sense/ui/tests/unit/sense/liveviewStatus.spec.js diff --git a/Sense/server/app/admin/router/sense_liveview.go b/Sense/server/app/admin/router/sense_liveview.go new file mode 100644 index 0000000..33f3227 --- /dev/null +++ b/Sense/server/app/admin/router/sense_liveview.go @@ -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) +} diff --git a/Sense/server/app/sense/liveview/apis.go b/Sense/server/app/sense/liveview/apis.go new file mode 100644 index 0000000..4658e91 --- /dev/null +++ b/Sense/server/app/sense/liveview/apis.go @@ -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(` +
+ +`)) + +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 +} diff --git a/Sense/server/app/sense/liveview/apis_test.go b/Sense/server/app/sense/liveview/apis_test.go new file mode 100644 index 0000000..0a3b682 --- /dev/null +++ b/Sense/server/app/sense/liveview/apis_test.go @@ -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) + } +} diff --git a/Sense/server/app/sense/liveview/service.go b/Sense/server/app/sense/liveview/service.go new file mode 100644 index 0000000..aa65e61 --- /dev/null +++ b/Sense/server/app/sense/liveview/service.go @@ -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, `_`, `\_`) +} diff --git a/Sense/server/app/sense/liveview/service_test.go b/Sense/server/app/sense/liveview/service_test.go new file mode 100644 index 0000000..70b8f54 --- /dev/null +++ b/Sense/server/app/sense/liveview/service_test.go @@ -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 } diff --git a/Sense/server/cmd/migrate/migration/version/2026081420000_liveview.go b/Sense/server/cmd/migrate/migration/version/2026081420000_liveview.go new file mode 100644 index 0000000..9054750 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081420000_liveview.go @@ -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 + }) +} diff --git a/Sense/server/cmd/migrate/migration/version/2026081420000_liveview_test.go b/Sense/server/cmd/migrate/migration/version/2026081420000_liveview_test.go new file mode 100644 index 0000000..708ff75 --- /dev/null +++ b/Sense/server/cmd/migrate/migration/version/2026081420000_liveview_test.go @@ -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) + } +} diff --git a/Sense/ui/src/api/sense/liveview.js b/Sense/ui/src/api/sense/liveview.js new file mode 100644 index 0000000..ab09c87 --- /dev/null +++ b/Sense/ui/src/api/sense/liveview.js @@ -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' }) +} diff --git a/Sense/ui/src/components/sense/video-player/index.vue b/Sense/ui/src/components/sense/video-player/index.vue new file mode 100644 index 0000000..3e0f7e4 --- /dev/null +++ b/Sense/ui/src/components/sense/video-player/index.vue @@ -0,0 +1,126 @@ + +按设备选择一路已验证视频;关闭窗口后不会继续占用播放器连接。
+