初始化
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// 管理端路由测试,验证通用资源接口仅放行明确支持的资源类型。
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestAllowAdminResource 验证模型资源可进入处理器,未知资源仍返回接口不存在。
|
||||
func TestAllowAdminResource(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
name string
|
||||
resource string
|
||||
statusCode int
|
||||
}{
|
||||
{name: "允许模型资源", resource: "models", statusCode: http.StatusNoContent},
|
||||
{name: "拒绝未知资源", resource: "unknown", statusCode: http.StatusNotFound},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
router := gin.New()
|
||||
route := router.Group("/resources/:resource")
|
||||
route.Use(allowAdminResource("styles", "channels", "models"))
|
||||
route.PATCH("/:id/enabled", func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
response := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodPatch, "/resources/"+test.resource+"/model-id/enabled", nil)
|
||||
router.ServeHTTP(response, request)
|
||||
|
||||
if response.Code != test.statusCode {
|
||||
t.Fatalf("unexpected status: got %d, want %d", response.Code, test.statusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user