package api import ( "fmt" "io" "net/http" "strings" "time" "visiona-local/server/internal/api/handlers" "visiona-local/server/internal/api/ws" "visiona-local/server/internal/camera" "visiona-local/server/internal/device" "visiona-local/server/internal/flash" "visiona-local/server/internal/inference" "visiona-local/server/internal/model" "visiona-local/server/pkg/logger" "github.com/gin-gonic/gin" ) func NewRouter( modelRepo *model.Repository, modelStore *model.ModelStore, deviceMgr *device.Manager, cameraMgr *camera.Manager, flashSvc *flash.Service, inferenceSvc *inference.Service, wsHub *ws.Hub, staticFS http.FileSystem, logBroadcaster *logger.Broadcaster, systemHandler *handlers.SystemHandler, ) *gin.Engine { // Use gin.New() instead of gin.Default() to replace the default logger // with one that also pushes to the WebSocket broadcaster. r := gin.New() r.Use(gin.Recovery()) // M8-4:跳過高頻輪詢 endpoint 的 access log(TDD v2/server-lifecycle.md §9.1a)。 // 瀏覽器每 10s poll 一次 boot-id;business code 也會定期輪詢 health, // 若每次都寫 access log 會把 business log 淹沒。 // // 注意:broadcasterLogger 是我們自製的 middleware,不會直接套用 gin.LoggerConfig, // 因此 skip 邏輯在 broadcasterLogger 內部手動實作(見下方)。 r.Use(broadcasterLogger(logBroadcaster)) r.Use(CORSMiddleware()) modelHandler := handlers.NewModelHandler(modelRepo) modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore) deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub) cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub) api := r.Group("/api") { // System api.GET("/system/health", systemHandler.HealthCheck) api.GET("/system/info", systemHandler.Info) api.GET("/system/metrics", systemHandler.Metrics) api.GET("/system/deps", systemHandler.Deps) api.GET("/system/boot-id", systemHandler.BootID) // M8-4:瀏覽器 tab 用於偵測 server 重啟 api.POST("/system/restart", systemHandler.Restart) api.POST("/system/install-driver", systemHandler.InstallDriver) // MAJ-4 補丁:Wails shutdown / Restart 前廣播 server:shutdown-imminent // 到 /ws/system,讓瀏覽器 tab 立即顯示 Offline Overlay。 api.POST("/system/shutdown-notify", systemHandler.ShutdownNotify) // Models api.GET("/models", modelHandler.ListModels) api.GET("/models/:id", modelHandler.GetModel) api.POST("/models/upload", modelUploadHandler.UploadModel) api.DELETE("/models/:id", modelUploadHandler.DeleteModel) // Devices api.GET("/devices", deviceHandler.ListDevices) api.POST("/devices/scan", deviceHandler.ScanDevices) api.GET("/devices/:id", deviceHandler.GetDevice) api.POST("/devices/:id/connect", deviceHandler.ConnectDevice) api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice) api.POST("/devices/:id/flash", deviceHandler.FlashDevice) api.POST("/devices/:id/inference/start", deviceHandler.StartInference) api.POST("/devices/:id/inference/stop", deviceHandler.StopInference) // Camera api.GET("/camera/list", cameraHandler.ListCameras) api.POST("/camera/start", cameraHandler.StartPipeline) api.POST("/camera/stop", cameraHandler.StopPipeline) api.GET("/camera/stream", cameraHandler.StreamMJPEG) // Media api.POST("/media/upload/image", cameraHandler.UploadImage) api.POST("/media/upload/video", cameraHandler.UploadVideo) api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages) api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame) api.POST("/media/seek", cameraHandler.SeekVideo) } // WebSocket r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr)) r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub)) r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc)) r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster)) // MAJ-4 補丁:/ws/system — server:shutdown-imminent 事件訂閱 r.GET("/ws/system", ws.SystemEventsHandler(wsHub)) // Embedded frontend static file serving (production mode) if staticFS != nil { fileServer := http.FileServer(staticFS) // Serve Next.js-style static assets r.GET("/_next/*filepath", func(c *gin.Context) { fileServer.ServeHTTP(c.Writer, c.Request) }) r.GET("/favicon.ico", func(c *gin.Context) { fileServer.ServeHTTP(c.Writer, c.Request) }) // SPA fallback for all other routes (client-side routing) r.NoRoute(spaFallback(staticFS)) } return r } // broadcasterLoggerSkipPaths 列出不寫 access log 的 endpoint(M8-4 TDD §9.1a)。 // 這些 endpoint 被瀏覽器或業務 code 高頻輪詢,每次都寫 log 會把 log 噴滿。 var broadcasterLoggerSkipPaths = map[string]struct{}{ "/api/system/boot-id": {}, "/api/system/health": {}, } // broadcasterLogger is a Gin middleware that logs HTTP requests to both // stdout (like gin.Logger) and the WebSocket log broadcaster so that // request logs are visible in the frontend Settings page. // // M8-4:對 broadcasterLoggerSkipPaths 裡列出的 endpoint 不寫 log, // 避免把 access log 淹沒(瀏覽器每 10s poll boot-id,health 被業務 code 高頻輪詢)。 func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc { return func(c *gin.Context) { start := time.Now() path := c.Request.URL.Path raw := c.Request.URL.RawQuery c.Next() // M8-4:跳過高頻輪詢 endpoint(比對只看 path,不含 query) if _, skip := broadcasterLoggerSkipPaths[path]; skip { return } latency := time.Since(start) status := c.Writer.Status() method := c.Request.Method if raw != "" { path = path + "?" + raw } msg := fmt.Sprintf("%3d | %13v | %-7s %s", status, latency, method, path) // Write to stdout (original Gin behaviour) fmt.Printf("[GIN] %s\n", msg) // Push to broadcaster for WebSocket streaming if b != nil { level := "INFO" if status >= 500 { level = "ERROR" } else if status >= 400 { level = "WARN" } b.Push(level, fmt.Sprintf("[GIN] %s", msg)) } } } // spaFallback tries to serve the exact file from the embedded FS. // If the file doesn't exist, it finds the best matching route shell HTML // for Next.js static export client-side routing. // // Next.js static export with generateStaticParams creates: // /models/index.html — static page // /models/_/index.html — dynamic route shell (placeholder param '_') // // For a request like /models/yolov5-face-detection: // 1. Try exact file → not found // 2. Try /models/_/index.html → found → serve it (Next.js CSR picks up real param from URL) // 3. Fall back to /index.html (root) func spaFallback(staticFS http.FileSystem) gin.HandlerFunc { return func(c *gin.Context) { path := c.Request.URL.Path // Don't serve index.html for API or WebSocket routes if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") { c.Status(http.StatusNotFound) return } // Try to serve the exact file if f, err := staticFS.Open(path); err == nil { f.Close() http.FileServer(staticFS).ServeHTTP(c.Writer, c.Request) return } // Try Next.js dynamic route shell: replace last path segment with '_' // e.g. /models/yolov5 → /models/_/index.html // /devices/kl520-0 → /devices/_/index.html // /workspace/kl520-0 → /workspace/_/index.html segments := strings.Split(strings.TrimRight(path, "/"), "/") if len(segments) >= 2 { segments[len(segments)-1] = "_" shellPath := strings.Join(segments, "/") + "/index.html" if f, err := staticFS.Open(shellPath); err == nil { defer f.Close() c.Header("Content-Type", "text/html; charset=utf-8") c.Status(http.StatusOK) _, _ = io.Copy(c.Writer, f) return } } // Final fallback: root index.html index, err := staticFS.Open("/index.html") if err != nil { c.Status(http.StatusInternalServerError) return } defer index.Close() c.Header("Content-Type", "text/html; charset=utf-8") c.Status(http.StatusOK) _, _ = io.Copy(c.Writer, index) } }