初始化

This commit is contained in:
Ran
2026-08-25 17:59:42 +08:00
commit 4b7380dd9b
408 changed files with 327400 additions and 0 deletions
+271
View File
@@ -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()
}
}
+42
View File
@@ -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)
}
})
}
}