From adab0009875abca938f002e49d65483e31c9194f Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 9 Jul 2026 04:36:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(camera):=20camera/media=20=E6=8E=A8?= =?UTF-8?q?=E8=AB=96=E6=94=B9=E8=B5=B0=20tunnel=20proxy=EF=BC=88=E5=A1=8A1?= =?UTF-8?q?=EF=BC=8C=E5=8F=96=E4=BB=A3=20501=20stub=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 推論工作區後端塊1:把 camera/media 9 條路徑從 501 stub 換成走 newProxyHandler 的 tunnel proxy 宣告(比照 devices.go 慣例,新增 camera.go 而非塞 stubs.go)。 - GET /camera/stream → streaming:true(MJPEG multipart 長連線逐 chunk flush) - 其餘 8 條(camera list/start/stop、media upload×3、batch-images/:index、 seek)→ request-response(upload 大檔的 request body 由 proxy 直接 streaming 送出,streaming flag 只控 response) - 掛在已套 AuthMiddleware 的 apiGroup 下,沿用剝 Origin 修正 WS inference: 結果推播為塊2(ForwardWebSocket);MJPEG 的 stream 認證分支(R-C4)待 security 定案短期 stream-token 方案,本塊未做。 Reviewer 0C/0M/1Mi/3Sug 通過。+camera_test.go 路由黑箱測試(路徑不再 501、無 session 回 502 TUNNEL_DISCONNECTED),全套 api 回歸綠。 Co-Authored-By: Claude Opus 4.8 (1M context) --- visionA-backend/internal/api/api.go | 4 + visionA-backend/internal/api/camera.go | 62 +++++++++++++++ visionA-backend/internal/api/camera_test.go | 85 +++++++++++++++++++++ visionA-backend/internal/api/stubs.go | 15 +--- 4 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 visionA-backend/internal/api/camera.go create mode 100644 visionA-backend/internal/api/camera_test.go diff --git a/visionA-backend/internal/api/api.go b/visionA-backend/internal/api/api.go index 16e758e..e4fadf8 100644 --- a/visionA-backend/internal/api/api.go +++ b/visionA-backend/internal/api/api.go @@ -256,6 +256,10 @@ func NewRouter(deps Deps) *gin.Engine { registerModelRoutes(apiGroup, deps) registerClusterRoutes(apiGroup, deps) + // Camera / Media 推論(走 tunnel proxy)— /api/camera/* + /api/media/* + // 對齊 .autoflow/04-architecture/camera-e2e-effort-estimate.md + registerCameraRoutes(apiGroup, deps) + // Phase 0.8:Conversion(轉檔)— 5 個 endpoint // 對齊 .autoflow/04-architecture/api/api-conversion.md registerConversionRoutes(apiGroup, deps) diff --git a/visionA-backend/internal/api/camera.go b/visionA-backend/internal/api/camera.go new file mode 100644 index 0000000..831a8b0 --- /dev/null +++ b/visionA-backend/internal/api/camera.go @@ -0,0 +1,62 @@ +// camera.go — /api/camera/* 與 /api/media/* 的 route 宣告。 +// +// 這兩組 endpoint 全部走 tunnel proxy:實際的攝影機 / 圖片 / 影片 / 批次推論 +// 都執行在 local agent(USB 插著攝影機、跑著 local-tool 的那台機器)。api-server +// 只負責「面向瀏覽器 + auth」,把請求原樣中繼給 local agent(見 proxy.go)。 +// +// 為什麼從 stubs.go 的 501 搬出來獨立成檔: +// - 對齊 devices.go 的慣例(每個 domain 一個 register 檔),stubs.go 只留真正 +// 還沒 handler 的 endpoint。 +// - camera/media 與 devices 走的是同一套 newProxyHandler,pattern 已在 B5 生產跑通 +// (/api/devices/scan 等)。這裡只是「宣告路徑 + 決定 streaming flag」。 +// +// streaming flag 的判斷: +// - GET /camera/stream → streaming(MJPEG multipart/x-mixed-replace 長連線) +// - GET /media/batch-images/:i → **非 streaming**:回單張 jpeg(一次讀完),走 +// request-response 即可。 +// - 其餘(list / start / stop / upload / seek)→ 非 streaming request-response。 +// upload 類的 request body 由 http.NewRequestWithContext 以 streaming 方式送出 +// (proxy.go 直接把 c.Request.Body 當 upstream body),大 multipart 也不會一次 +// 載入記憶體;streaming flag 只控制「response 是否逐 chunk flush」,與 request +// body 上傳無關,所以 upload 用 proxyOptions{} 即可。 +// +// 對齊 .autoflow/04-architecture/camera-e2e-effort-estimate.md §2.1 / §3.1 與 +// local-tool/server/internal/api/router.go:108-118(路徑與 local agent 完全一致, +// 無需 rewritePath)。 +// +// POC 對照:edge-ai-platform relay/server.go:213-228(flusher 逐 chunk 送 MJPEG) +// 對應到 visionA 的 proxy.go writeProxyResponse streaming 分支 —— 那段已在 api-server +// 實作好,camera/media 只需宣告路徑即可享用。 + +package api + +import ( + "github.com/gin-gonic/gin" +) + +// registerCameraRoutes 註冊 /api/camera/* 與 /api/media/* 的 tunnel proxy routes。 +// +// 掛在 apiGroup(已套 AuthMiddleware)底下,所以每條路徑都受 OIDC session 保護。 +// MJPEG 帶不了 Authorization header 的認證問題(R-M3/R-C4)由前端 + api-server +// 的 query-token / cookie 機制處理,不在本檔範圍(本檔只負責把已認證請求中繼出去)。 +func registerCameraRoutes(g *gin.RouterGroup, deps Deps) { + // request-response 類:body / response 一次讀完即可。 + proxy := newProxyHandler(deps, proxyOptions{}) + // streaming 類:response 是 MJPEG 長連線,需逐 chunk flush。 + streamProxy := newProxyHandler(deps, proxyOptions{streaming: true}) + + // --- Camera(即時攝影機) --- + g.GET("/camera/list", proxy) // 列出可用攝影機 + g.POST("/camera/start", proxy) // 開 camera + 起推論,回 streamUrl + g.POST("/camera/stop", proxy) // 停止 pipeline + g.GET("/camera/stream", streamProxy) // MJPEG multipart/x-mixed-replace 長連線 + + // --- Media(圖片 / 影片 / 批次推論) --- + // 注意:media 的「結果畫面」也走上面的 /camera/stream(pipeline.go 共用同一個 + // MJPEGStreamer),所以 media 端點本身都是 request-response(回 streamUrl + metadata)。 + g.POST("/media/upload/image", proxy) // multipart 圖片上傳 + g.POST("/media/upload/video", proxy) // multipart 影片上傳(大檔,body streaming 送出) + g.POST("/media/upload/batch-images", proxy) // 多檔(最多 50 張) + g.GET("/media/batch-images/:index", proxy) // 回單張 jpeg(非 streaming) + g.POST("/media/seek", proxy) // 影片 seek +} diff --git a/visionA-backend/internal/api/camera_test.go b/visionA-backend/internal/api/camera_test.go new file mode 100644 index 0000000..d8e874d --- /dev/null +++ b/visionA-backend/internal/api/camera_test.go @@ -0,0 +1,85 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "visiona-backend/internal/session" +) + +// newCameraFixture 建立一個掛了 registerCameraRoutes 的 router。 +// +// 用 fakeSessionStore(List 回空)+ 真實但不 dial 的 Forwarder:這樣每條 proxy 路徑 +// 在 pickActiveSessionToken 會回 ErrSessionNotFound → 502 TUNNEL_DISCONNECTED。 +// 這足以證明「路徑有被註冊、且走的是 proxy handler(不是 501 stub)」。 +// +// injectStaticUserContext 模擬 AuthMiddleware 已放行(handler C1 strict mode 要求 +// UserContext 非空)。 +func newCameraFixture() *gin.Engine { + r := gin.New() + r.Use(RequestIDMiddleware()) + r.Use(injectStaticUserContext("demo-user", "")) + g := r.Group("/api") + registerCameraRoutes(g, Deps{ + SessionStore: &fakeSessionStore{}, // List 回空 → 無 active session + Forwarder: session.NewForwarder("http://localhost:0", nil), + }) + return r +} + +// TestCameraRoutes_RegisteredAsProxy 逐一驗證每條 camera/media 路徑: +// - 不再回 501(代表不是 stub、真的掛了 proxy handler) +// - 因無 active session → 回 502 TUNNEL_DISCONNECTED(proxy handler 的預期行為) +// +// 這是「路由宣告正確」的黑箱證據:request 有進到 proxy handler、走到 session lookup。 +func TestCameraRoutes_RegisteredAsProxy(t *testing.T) { + r := newCameraFixture() + + cases := []struct { + method string + path string + }{ + {http.MethodGet, "/api/camera/list"}, + {http.MethodPost, "/api/camera/start"}, + {http.MethodPost, "/api/camera/stop"}, + {http.MethodGet, "/api/camera/stream"}, + {http.MethodPost, "/api/media/upload/image"}, + {http.MethodPost, "/api/media/upload/video"}, + {http.MethodPost, "/api/media/upload/batch-images"}, + {http.MethodGet, "/api/media/batch-images/0"}, + {http.MethodPost, "/api/media/seek"}, + } + + for _, tc := range cases { + t.Run(tc.method+" "+tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, nil)) + + assert.NotEqual(t, http.StatusNotImplemented, w.Code, + "路徑應走 proxy handler、不再是 501 stub") + assert.Equal(t, http.StatusBadGateway, w.Code, + "無 active session 時 proxy 應回 502 TUNNEL_DISCONNECTED") + assert.Contains(t, w.Body.String(), ErrCodeTunnelDisconnect, + "錯誤碼應為 TUNNEL_DISCONNECTED") + }) + } +} + +// TestCameraRoutes_NoForwarder 驗證沒注入 Forwarder 時每條路徑回 501 +// (proxy handler 的依賴缺失分支),確認掛的的確是 newProxyHandler。 +func TestCameraRoutes_NoForwarder(t *testing.T) { + r := gin.New() + r.Use(RequestIDMiddleware()) + r.Use(injectStaticUserContext("demo-user", "")) + g := r.Group("/api") + registerCameraRoutes(g, Deps{}) // 無 Forwarder / SessionStore + + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/camera/stream", nil)) + assert.Equal(t, http.StatusNotImplemented, w.Code, + "缺 Forwarder 時 proxy handler 回 501(證明掛的是 newProxyHandler 而非別的)") +} diff --git a/visionA-backend/internal/api/stubs.go b/visionA-backend/internal/api/stubs.go index 9afe7c6..670ea17 100644 --- a/visionA-backend/internal/api/stubs.go +++ b/visionA-backend/internal/api/stubs.go @@ -11,8 +11,9 @@ import ( // clusters.go / storage.go 各檔)。這裡只剩: // - Cloud 裝置記錄(非 tunnel 的 CRUD,Phase 1) // - Clusters 寫入類(Phase 1) -// - Camera / Media(走 tunnel proxy;B5 先不實作以避免過度擴張,B7 補) // - Converter(Phase 1) +// +// Camera / Media 已在 camera.go(registerCameraRoutes)換成真正的 tunnel proxy 宣告。 // - WebSocket endpoints(B7 TODO — 需要 Hijack + WS relay) // // 讓前端對錯誤路徑能拿到 501 而非 404,減少除錯成本。 @@ -41,16 +42,8 @@ func registerStubRoutes(g *gin.RouterGroup, _ Deps) { g.POST("/clusters/:id/inference/start", stub("clusters.inference.start — pending Phase 1")) g.POST("/clusters/:id/inference/stop", stub("clusters.inference.stop — pending Phase 1")) - // --- Camera / Media(B7 補;走 tunnel proxy) --- - g.GET("/camera/list", stub("camera.list via tunnel — pending B7")) - g.POST("/camera/start", stub("camera.start via tunnel — pending B7")) - g.POST("/camera/stop", stub("camera.stop via tunnel — pending B7")) - g.GET("/camera/stream", stub("camera.stream MJPEG via tunnel — pending B7")) - g.POST("/media/upload/image", stub("media.upload.image — pending B7")) - g.POST("/media/upload/video", stub("media.upload.video — pending B7")) - g.POST("/media/upload/batch-images", stub("media.upload.batch — pending B7")) - g.GET("/media/batch-images/:index", stub("media.batch.get — pending B7")) - g.POST("/media/seek", stub("media.seek — pending B7")) + // --- Camera / Media(走 tunnel proxy) --- + // 已由 registerCameraRoutes(camera.go)換成真正的 proxy 宣告,不再是 501 stub。 // --- Converter(Phase 1) --- g.POST("/converter/jobs", stub("converter.submit — pending Phase 1"))