// API 服务组装模块,负责中间件、路由、静态资源和 HTTP 服务参数配置。 package server import ( "log/slog" "net/http" "os" "path/filepath" "strings" "time" "juhe-factory/api/internal/config" "juhe-factory/api/internal/handler" "github.com/gin-gonic/gin" "github.com/google/uuid" ) func New(cfg config.Config, health *handler.Health, adminAuth *handler.AdminAuth, adminData *handler.AdminData, web *handler.Web, creative *handler.Creative, productImage *handler.ProductImage) *http.Server { if !cfg.Debug { gin.SetMode(gin.ReleaseMode) } router := gin.New() router.Use(traceID()) router.Use(requestLogger()) router.Use(gin.Recovery(), cors(cfg.CORSOrigins)) registerRoutes(router, health, adminAuth, adminData, web, creative, productImage) registerStatic(router, cfg.StaticDirectory) return &http.Server{ Addr: cfg.ServerAddress, Handler: router, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 30 * time.Second, WriteTimeout: 5 * time.Minute, IdleTimeout: 60 * time.Second, } } // requestLogger 记录 HTTP 请求及其追踪 ID,便于通过审计记录关联服务器日志。 // 正常响应(2xx)不输出,仅在出错(4xx/5xx)时记录,避免前端轮询请求刷屏。 func requestLogger() gin.HandlerFunc { return func(c *gin.Context) { startedAt := time.Now() c.Next() if c.Writer.Status() < 400 { return } slog.InfoContext(c.Request.Context(), "http request", "trace_id", c.GetString("trace_id"), "method", c.Request.Method, "path", c.Request.URL.Path, "status", c.Writer.Status(), "latency_ms", time.Since(startedAt).Milliseconds(), "response_bytes", c.Writer.Size(), ) } } func registerRoutes(router *gin.Engine, health *handler.Health, auth *handler.AdminAuth, data *handler.AdminData, web *handler.Web, creative *handler.Creative, productImage *handler.ProductImage) { router.GET("/health", health.Check) webRoutes := router.Group("/api/web") webRoutes.GET("/health", health.Check) webRoutes.POST("/auth/login", web.Login) webRoutes.POST("/auth/refresh", web.Refresh) webRoutes.POST("/auth/logout", web.Logout) webProtected := webRoutes.Group("") webProtected.Use(web.Middleware()) webProtected.GET("/account", web.Account) webProtected.PATCH("/account/profile", web.UpdateProfile) webProtected.POST("/account/avatar", web.UploadAvatar) webProtected.PUT("/account/password", web.ChangePassword) webProtected.GET("/usage/30-days", web.Usage30Days) webProtected.GET("/usage/consumption-records", web.ConsumptionRecords) webProtected.POST("/redemption/redeem", web.Redeem) webProtected.GET("/user/prompts/:prompt_type", creative.ListUserPrompts) webProtected.PUT("/user/prompts/:prompt_type/select", creative.SelectUserPrompt) webProtected.POST("/user/custom-prompts", creative.CreateCustomPrompt) webProtected.PUT("/user/custom-prompts/:id", creative.UpdateCustomPrompt) webProtected.DELETE("/user/custom-prompts/:id", creative.DeleteCustomPrompt) webProtected.GET("/creative/options", creative.ListOptions) webProtected.POST("/product-images/generations", productImage.Generate) webProtected.GET("/product-images/generations", productImage.ListGenerations) webProtected.DELETE("/product-images/generations/:task_id", productImage.DeleteGeneration) webProtected.GET("/user/model-configs/:scope", creative.ListUserModelPreferences) webProtected.PUT("/user/model-configs/:scope/:model_type", creative.SaveUserModelPreference) webProtected.GET("/script-analyses", creative.ListScriptAnalyses) webProtected.POST("/script-analyses", creative.CreateScriptAnalysis) webProtected.GET("/script-analyses/import-projects", creative.ListScriptAnalysisImportProjects) webProtected.GET("/script-analyses/:script_id", creative.GetScriptAnalysis) webProtected.PUT("/script-analyses/:script_id", creative.UpdateScriptAnalysis) webProtected.POST("/script-analyses/:script_id/import-project", creative.ImportScriptAnalysisProject) webProtected.POST("/script-analyses/:script_id/import-file", creative.ImportScriptAnalysisFile) webProtected.POST("/script-analyses/:script_id/analyze", creative.QueueScriptAnalysis) webProtected.POST("/script-analyses/:script_id/cancel", creative.CancelScriptAnalysis) webProtected.DELETE("/script-analyses/:script_id", creative.DeleteScriptAnalysis) webProtected.GET("/media/:media_id", creative.GetMedia) webProtected.GET("/projects", creative.ListProjects) webProtected.POST("/projects", creative.CreateProject) webProtected.GET("/projects/:project_id", creative.GetProject) webProtected.PUT("/projects/:project_id", creative.UpdateProject) webProtected.DELETE("/projects/:project_id", creative.DeleteProject) webProtected.PUT("/projects/:project_id/model-configs", creative.SaveModelConfigs) webProtected.POST("/projects/:project_id/episodes", creative.CreateEpisode) webProtected.PUT("/projects/:project_id/episodes/:episode_id", creative.UpdateEpisodeName) webProtected.POST("/projects/:project_id/episodes/import/preview", creative.PreviewDramaImport) webProtected.POST("/projects/:project_id/episodes/import/confirm", creative.ConfirmDramaImport) webProtected.DELETE("/projects/:project_id/episodes/:episode_id", creative.DeleteEpisode) webProtected.GET("/projects/:project_id/episodes/:episode_id/text-source", creative.GetDramaEpisodeSource) webProtected.PUT("/projects/:project_id/episodes/:episode_id/text-source", creative.SaveDramaEpisodeSource) webProtected.POST("/projects/:project_id/episodes/:episode_id/parse", creative.QueueDramaParse) webProtected.POST("/projects/:project_id/episodes/:episode_id/reparse", creative.ReparseDrama) webProtected.POST("/projects/:project_id/episodes/:episode_id/storyboards", creative.CreateDramaStoryboard) webProtected.GET("/projects/:project_id/parse-tasks", creative.ListDramaParseTasks) webProtected.POST("/projects/:project_id/parse-tasks/:task_id/cancel", creative.CancelDramaParse) webProtected.GET("/projects/:project_id/episodes/:episode_id/workbench", creative.GetWorkbench) webProtected.GET("/projects/:project_id/episodes/:episode_id/videos.zip", creative.DownloadEpisodeVideos) webProtected.GET("/projects/:project_id/episodes/:episode_id/redraw-workbench", creative.GetRedrawEpisodeWorkbench) webProtected.POST("/projects/:project_id/episodes/:episode_id/redraw-script", creative.QueueRedrawEpisodeScript) webProtected.PUT("/projects/:project_id/episodes/:episode_id/redraw-script", creative.SaveRedrawEpisodeScript) webProtected.GET("/projects/:project_id/redraw-workbench", creative.GetRedrawWorkbench) webProtected.POST("/projects/:project_id/redraw-source", creative.UploadRedrawSource) webProtected.POST("/projects/:project_id/redraw-subtitle", creative.UploadRedrawSubtitle) webProtected.POST("/projects/:project_id/redraw-analyze", creative.QueueRedrawAnalysis) webProtected.POST("/projects/:project_id/redraw-reanalyze", creative.ReanalyzeRedraw) webProtected.POST("/projects/:project_id/redraw-script", creative.QueueRedrawScript) webProtected.PUT("/projects/:project_id/redraw-analysis-settings", creative.UpdateRedrawAnalysisSettings) webProtected.POST("/projects/:project_id/episodes/:episode_id/source", creative.UploadEpisodeSource) webProtected.POST("/projects/:project_id/episodes/:episode_id/subtitle", creative.UploadEpisodeSubtitle) webProtected.POST("/projects/:project_id/episodes/:episode_id/analyze", creative.QueueAnalysis) webProtected.POST("/projects/:project_id/episodes/:episode_id/reanalyze", creative.Reanalyze) webProtected.PUT("/projects/:project_id/episodes/:episode_id/analysis-settings", creative.UpdateEpisodeAnalysisSettings) webProtected.POST("/projects/:project_id/assets", creative.CreateAsset) webProtected.PUT("/projects/:project_id/assets/:asset_id", creative.UpdateAsset) webProtected.DELETE("/projects/:project_id/assets/:asset_id", creative.DeleteAsset) webProtected.POST("/projects/:project_id/assets/:asset_id/image", creative.UploadAssetImage) webProtected.DELETE("/projects/:project_id/assets/:asset_id/image", creative.RemoveAssetImage) webProtected.POST("/projects/:project_id/assets/:asset_id/audio", creative.UploadAssetAudio) webProtected.PUT("/projects/:project_id/storyboards/:storyboard_id", creative.UpdateStoryboard) webProtected.POST("/projects/:project_id/storyboards/:storyboard_id/insert", creative.InsertStoryboard) webProtected.DELETE("/projects/:project_id/storyboards/:storyboard_id", creative.DeleteStoryboard) webProtected.POST("/projects/:project_id/episodes/:episode_id/storyboards/:storyboard_id/reinfer", creative.QueueStoryboardAnalysis) webProtected.POST("/projects/:project_id/episodes/:episode_id/storyboards/:storyboard_id/generate", creative.QueueGeneration) webProtected.POST("/projects/:project_id/storyboards/:storyboard_id/redraw-reinfer", creative.QueueRedrawStoryboardAnalysis) webProtected.GET("/projects/:project_id/tasks", creative.ListTasks) webProtected.POST("/projects/:project_id/tasks/:task_id/cancel", creative.CancelTask) webProtected.GET("/projects/:project_id/storyboards/:storyboard_id/outputs", creative.ListStoryboardOutputs) webProtected.DELETE("/projects/:project_id/storyboards/:storyboard_id/image", creative.RemoveStoryboardImage) webProtected.PUT("/projects/:project_id/storyboards/:storyboard_id/outputs/:output_id/activate", creative.ActivateStoryboardOutput) webProtected.PUT("/projects/:project_id/storyboards/:storyboard_id/outputs/:output_id/candidate", creative.AddStoryboardCandidate) webProtected.DELETE("/projects/:project_id/storyboards/:storyboard_id/outputs/:output_id/candidate", creative.RemoveStoryboardCandidate) webProtected.DELETE("/projects/:project_id/storyboards/:storyboard_id/outputs/:output_id", creative.DeleteStoryboardOutput) webProtected.GET("/projects/:project_id/assets/:asset_id/outputs", creative.ListAssetOutputs) webProtected.PUT("/projects/:project_id/assets/:asset_id/outputs/:output_id/activate", creative.ActivateAssetOutput) webProtected.DELETE("/projects/:project_id/assets/:asset_id/outputs/:output_id", creative.DeleteAssetOutput) admin := router.Group("/api/admin") admin.GET("/health", health.Check) admin.POST("/auth/login", auth.Login) admin.POST("/auth/refresh", auth.Refresh) admin.POST("/auth/logout", auth.Logout) protected := admin.Group("") protected.Use(auth.Middleware()) protected.PUT("/auth/password", auth.ChangePassword) protected.GET("/audit-logs", data.ListAuditLogs) protected.GET("/users", data.ListUsers) protected.POST("/users", data.CreateUser) protected.POST("/users/batch", data.BatchCreateUsers) protected.PATCH("/users/batch", data.BatchUpdateUsers) protected.DELETE("/users/batch", data.BatchDeleteUsers) protected.POST("/users/:id/points", data.GrantUserPoints) protected.GET("/models", data.ListModels) protected.POST("/models", data.SaveModel) protected.PUT("/models/:id", data.SaveModel) protected.GET("/prompts", data.ListPrompts) protected.POST("/prompts", data.SavePromptSimple) protected.PUT("/prompts/:id", data.SavePromptSimple) protected.DELETE("/prompts/:id", data.DeletePromptSimple) protected.POST("/prompts/:id/actions", data.PromptActionSimple) protected.GET("/prompts/:id/history", data.PromptHistorySimple) protected.GET("/redemption-codes", data.ListRedemptions) protected.POST("/redemption-batches", data.CreateRedemptionBatch) protected.POST("/uploads/style-images", data.UploadStyleImage) protected.PUT("/resources/styles/reorder", data.ReorderStyles) resources := protected.Group("/resources/:resource") resources.Use(allowAdminResource("styles", "channels", "models")) resources.GET("", data.ListResource) resources.POST("", data.CreateResource) resources.PUT("/:id", data.UpdateResource) resources.PATCH("/:id/enabled", data.ToggleResource) resources.DELETE("/:id", data.DeleteResource) } func allowAdminResource(resources ...string) gin.HandlerFunc { allowed := make(map[string]struct{}, len(resources)) for _, resource := range resources { allowed[resource] = struct{}{} } return func(c *gin.Context) { if _, ok := allowed[c.Param("resource")]; !ok { c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"code": "route_not_found", "message": "接口不存在"}) return } c.Next() } } func traceID() gin.HandlerFunc { return func(c *gin.Context) { id := strings.TrimSpace(c.GetHeader("X-Request-ID")) if id == "" { id = uuid.NewString() } c.Set("trace_id", id) c.Header("X-Request-ID", id) c.Next() } } func registerStatic(router *gin.Engine, root string) { adminRoot := filepath.Join(root, "admin") webRoot := filepath.Join(root, "web") router.Static("/admin/assets", filepath.Join(adminRoot, "assets")) router.Static("/assets", filepath.Join(webRoot, "assets")) router.GET("/admin", func(c *gin.Context) { c.File(filepath.Join(adminRoot, "index.html")) }) router.GET("/", func(c *gin.Context) { c.File(filepath.Join(webRoot, "index.html")) }) router.NoRoute(func(c *gin.Context) { path := c.Request.URL.Path if strings.HasPrefix(path, "/api/") { c.JSON(http.StatusNotFound, gin.H{"code": "route_not_found", "message": "接口不存在"}) return } if strings.HasPrefix(path, "/admin") { c.File(filepath.Join(adminRoot, "index.html")) return } relativePath := filepath.Clean(strings.TrimPrefix(path, "/")) if filepath.IsLocal(relativePath) { candidate := filepath.Join(webRoot, relativePath) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { c.File(candidate) return } } c.File(filepath.Join(webRoot, "index.html")) }) } func cors(allowedOrigins []string) gin.HandlerFunc { allowed := make(map[string]struct{}, len(allowedOrigins)) for _, origin := range allowedOrigins { allowed[origin] = struct{}{} } return func(c *gin.Context) { origin := c.GetHeader("Origin") if _, ok := allowed[origin]; ok { c.Header("Access-Control-Allow-Origin", origin) c.Header("Access-Control-Allow-Credentials", "true") c.Header("Vary", "Origin") } c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization") c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") if c.Request.Method == http.MethodOptions { c.AbortWithStatus(http.StatusNoContent) return } c.Next() } }