feat(camera): camera/media 推論改走 tunnel proxy(塊1,取代 501 stub)

推論工作區後端塊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:<deviceId> 結果推播為塊2(ForwardWebSocket);MJPEG <img>
的 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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-09 04:36:06 +08:00
parent c9f7eab682
commit adab000987
4 changed files with 155 additions and 11 deletions

View File

@ -256,6 +256,10 @@ func NewRouter(deps Deps) *gin.Engine {
registerModelRoutes(apiGroup, deps) registerModelRoutes(apiGroup, deps)
registerClusterRoutes(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.8Conversion轉檔— 5 個 endpoint // Phase 0.8Conversion轉檔— 5 個 endpoint
// 對齊 .autoflow/04-architecture/api/api-conversion.md // 對齊 .autoflow/04-architecture/api/api-conversion.md
registerConversionRoutes(apiGroup, deps) registerConversionRoutes(apiGroup, deps)

View File

@ -0,0 +1,62 @@
// camera.go — /api/camera/* 與 /api/media/* 的 route 宣告。
//
// 這兩組 endpoint 全部走 tunnel proxy實際的攝影機 / 圖片 / 影片 / 批次推論
// 都執行在 local agentUSB 插著攝影機、跑著 local-tool 的那台機器。api-server
// 只負責「面向瀏覽器 + auth」把請求原樣中繼給 local agent見 proxy.go
//
// 為什麼從 stubs.go 的 501 搬出來獨立成檔:
// - 對齊 devices.go 的慣例(每個 domain 一個 register 檔stubs.go 只留真正
// 還沒 handler 的 endpoint。
// - camera/media 與 devices 走的是同一套 newProxyHandlerpattern 已在 B5 生產跑通
// /api/devices/scan 等)。這裡只是「宣告路徑 + 決定 streaming flag」。
//
// streaming flag 的判斷:
// - GET /camera/stream → streamingMJPEG 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-228flusher 逐 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 <img src> 帶不了 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/streampipeline.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
}

View File

@ -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。
//
// 用 fakeSessionStoreList 回空)+ 真實但不 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_DISCONNECTEDproxy 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 而非別的)")
}

View File

@ -11,8 +11,9 @@ import (
// clusters.go / storage.go 各檔)。這裡只剩: // clusters.go / storage.go 各檔)。這裡只剩:
// - Cloud 裝置記錄(非 tunnel 的 CRUDPhase 1 // - Cloud 裝置記錄(非 tunnel 的 CRUDPhase 1
// - Clusters 寫入類Phase 1 // - Clusters 寫入類Phase 1
// - Camera / Media走 tunnel proxyB5 先不實作以避免過度擴張B7 補)
// - ConverterPhase 1 // - ConverterPhase 1
//
// Camera / Media 已在 camera.goregisterCameraRoutes換成真正的 tunnel proxy 宣告。
// - WebSocket endpointsB7 TODO — 需要 Hijack + WS relay // - WebSocket endpointsB7 TODO — 需要 Hijack + WS relay
// //
// 讓前端對錯誤路徑能拿到 501 而非 404減少除錯成本。 // 讓前端對錯誤路徑能拿到 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/start", stub("clusters.inference.start — pending Phase 1"))
g.POST("/clusters/:id/inference/stop", stub("clusters.inference.stop — pending Phase 1")) g.POST("/clusters/:id/inference/stop", stub("clusters.inference.stop — pending Phase 1"))
// --- Camera / MediaB7 補;走 tunnel proxy --- // --- Camera / Media走 tunnel proxy ---
g.GET("/camera/list", stub("camera.list via tunnel — pending B7")) // 已由 registerCameraRoutescamera.go換成真正的 proxy 宣告,不再是 501 stub。
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"))
// --- ConverterPhase 1 --- // --- ConverterPhase 1 ---
g.POST("/converter/jobs", stub("converter.submit — pending Phase 1")) g.POST("/converter/jobs", stub("converter.submit — pending Phase 1"))