diff --git a/backend/internal/handler/api.go b/backend/internal/handler/api.go index 6a5dd957c..b234b08a4 100644 --- a/backend/internal/handler/api.go +++ b/backend/internal/handler/api.go @@ -42,6 +42,7 @@ func RegisterCanvasAPI(api *gin.RouterGroup, svc *service.Service) { RegisterChunkedUploadRoutes(api, svc) RegisterDiagnosticsRoutes(api, svc) RegisterPluginRoutes(api, svc) + RegisterLibraryRoutes(api, svc) projectAPI := api.Group("") projectAPI.Use(RequireFeature(svc, service.FeatureShortDrama)) RegisterProjectRoutes(projectAPI, svc) diff --git a/backend/internal/handler/project.go b/backend/internal/handler/project.go index 9f4f09b98..1fd3cdd0c 100644 --- a/backend/internal/handler/project.go +++ b/backend/internal/handler/project.go @@ -10,7 +10,8 @@ import ( "github.com/gin-gonic/gin" ) -func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { +// RegisterLibraryRoutes 注册画布和短剧共用的个人资料库(画风、声音、角色),不受短剧功能开关限制。 +func RegisterLibraryRoutes(r *gin.RouterGroup, svc *service.Service) { RegisterStyleProfileRoutes(r, svc) r.GET("/voice-profiles", func(c *gin.Context) { user, err := currentUser(c, svc) @@ -25,6 +26,11 @@ func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"profiles": profiles}) }) + registerCharacterLibraryRoutes(r, svc) +} + +// RegisterProjectRoutes 注册短剧项目接口,调用方负责挂短剧功能开关。 +func RegisterProjectRoutes(r *gin.RouterGroup, svc *service.Service) { registerProjectCoreRoutes(r, svc) registerProjectUnitRoutes(r, svc) registerProjectAssetRoutes(r, svc) diff --git a/backend/internal/handler/project_asset_routes.go b/backend/internal/handler/project_asset_routes.go index e4d1c7033..91c730146 100644 --- a/backend/internal/handler/project_asset_routes.go +++ b/backend/internal/handler/project_asset_routes.go @@ -129,136 +129,6 @@ func registerProjectAssetRoutes(r *gin.RouterGroup, svc *service.Service) { } ok(c, gin.H{"id": c.Param("folderId")}) }) - r.GET("/characters", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - page, pageSize, parseErr := parsePaginationQuery(c, 12) - if parseErr != nil { - fail(c, http.StatusBadRequest, parseErr) - return - } - if pageSize > 48 { - pageSize = 48 - } - ids := []string{} - for _, id := range strings.Split(c.Query("ids"), ",") { - if id = strings.TrimSpace(id); id != "" { - ids = append(ids, id) - } - } - characters, err := svc.ListCharacters(user.ID, c.Query("q"), page, pageSize, ids) - if err != nil { - failService(c, err) - return - } - ok(c, characters) - }) - r.POST("/characters", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.CreateCharacterRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.CreateCharacter(user.ID, req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.GET("/characters/:assetId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - character, err := svc.Character(user.ID, c.Param("assetId")) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.PATCH("/characters/:assetId", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) - var req service.UpdateProjectCharacterRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.UpdateCharacter(user.ID, c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.PUT("/characters/:assetId/representations", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 128<<10) - var req service.ReplaceCharacterRepresentationsRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.ReplaceCharacterRepresentations(user.ID, c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.PUT("/characters/:assetId/voice", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) - var req service.BindCharacterVoiceRequest - if err := c.ShouldBindJSON(&req); err != nil { - fail(c, http.StatusBadRequest, err) - return - } - character, err := svc.BindCharacterVoice(user.ID, c.Param("assetId"), req) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.DELETE("/characters/:assetId/voice", func(c *gin.Context) { - user, err := currentUser(c, svc) - if err != nil { - failService(c, err) - return - } - character, err := svc.UnbindCharacterVoice(user.ID, c.Param("assetId")) - if err != nil { - failService(c, err) - return - } - ok(c, character) - }) - r.POST("/projects/:id/characters", func(c *gin.Context) { user, err := currentUser(c, svc) if err != nil { @@ -431,3 +301,136 @@ func registerProjectAssetRoutes(r *gin.RouterGroup, svc *service.Service) { ok(c, gin.H{"version": version}) }) } + +// registerCharacterLibraryRoutes 注册个人角色库接口。角色库由画布和短剧共用,不受短剧功能开关限制。 +func registerCharacterLibraryRoutes(r *gin.RouterGroup, svc *service.Service) { + r.GET("/characters", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + page, pageSize, parseErr := parsePaginationQuery(c, 12) + if parseErr != nil { + fail(c, http.StatusBadRequest, parseErr) + return + } + if pageSize > 48 { + pageSize = 48 + } + ids := []string{} + for _, id := range strings.Split(c.Query("ids"), ",") { + if id = strings.TrimSpace(id); id != "" { + ids = append(ids, id) + } + } + characters, err := svc.ListCharacters(user.ID, c.Query("q"), page, pageSize, ids) + if err != nil { + failService(c, err) + return + } + ok(c, characters) + }) + r.POST("/characters", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.CreateCharacterRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.CreateCharacter(user.ID, req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.GET("/characters/:assetId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + character, err := svc.Character(user.ID, c.Param("assetId")) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.PATCH("/characters/:assetId", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 256<<10) + var req service.UpdateProjectCharacterRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.UpdateCharacter(user.ID, c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.PUT("/characters/:assetId/representations", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 128<<10) + var req service.ReplaceCharacterRepresentationsRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.ReplaceCharacterRepresentations(user.ID, c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.PUT("/characters/:assetId/voice", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 64<<10) + var req service.BindCharacterVoiceRequest + if err := c.ShouldBindJSON(&req); err != nil { + fail(c, http.StatusBadRequest, err) + return + } + character, err := svc.BindCharacterVoice(user.ID, c.Param("assetId"), req) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) + r.DELETE("/characters/:assetId/voice", func(c *gin.Context) { + user, err := currentUser(c, svc) + if err != nil { + failService(c, err) + return + } + character, err := svc.UnbindCharacterVoice(user.ID, c.Param("assetId")) + if err != nil { + failService(c, err) + return + } + ok(c, character) + }) +} diff --git a/backend/internal/handler/short_drama_feature_routes_test.go b/backend/internal/handler/short_drama_feature_routes_test.go new file mode 100644 index 000000000..fcef3d24e --- /dev/null +++ b/backend/internal/handler/short_drama_feature_routes_test.go @@ -0,0 +1,100 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "yingce/backend/internal/auth" + "yingce/backend/internal/model" + "yingce/backend/internal/repository" + "yingce/backend/internal/service" + + "github.com/gin-gonic/gin" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +// 角色库、我的画风、声音是画布与短剧共用的个人资料库,短剧关闭时不能一起被拦截; +// /projects 系列接口仍受短剧开关控制,且两类接口都必须先登录。 +func TestShortDramaFeatureGateKeepsSharedLibraries(t *testing.T) { + gin.SetMode(gin.TestMode) + for _, enabled := range []bool{false, true} { + t.Run(fmt.Sprintf("shortDramaEnabled=%t", enabled), func(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + if err := db.AutoMigrate(&model.User{}, &model.AuthSession{}, &model.SystemSetting{}, + &model.Asset{}, &model.AssetVersion{}, &model.AssetRepresentation{}, &model.CharacterVoiceBinding{}, + &model.VoiceProfile{}, &model.StyleProfile{}, &model.Project{}, &model.ProjectAssetLink{}, + &model.ProjectAssetFolder{}, &model.Resource{}, &model.Shot{}, &model.ShotAssetReference{}, + &model.ProjectUnit{}, &model.CanvasProject{}); err != nil { + t.Fatal(err) + } + for _, record := range []any{ + &model.User{ID: "owner", Username: "owner", Email: "owner@example.invalid", Role: model.UserRoleUser, Status: model.UserStatusActive}, + &model.AuthSession{ID: "owner", UserID: "owner", TokenHash: auth.HashToken("test-token"), ExpiresAt: time.Now().Add(time.Hour)}, + &model.SystemSetting{Key: "feature_availability", ValueJSON: fmt.Sprintf(`{"shortDramaEnabled":%t}`, enabled)}, + } { + if err := db.Create(record).Error; err != nil { + t.Fatal(err) + } + } + svc := service.New(repository.New(db), t.TempDir()) + character, err := svc.CreateCharacter("owner", service.CreateCharacterRequest{Name: "Existing character"}) + if err != nil { + t.Fatal(err) + } + router := gin.New() + RegisterCanvasAPI(router.Group("/api"), svc) + call := func(method, path, body, actor string, want int) { + t.Helper() + req := httptest.NewRequest(method, "/api"+path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + if actor != "" { + req.AddCookie(&http.Cookie{Name: service.SessionCookieName, Value: actor + ".test-token"}) + } + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != want { + t.Fatalf("%s %s actor=%q: got %d want %d: %s", method, path, actor, w.Code, want, w.Body.String()) + } + if want == http.StatusOK { + var envelope struct { + Code int `json:"code"` + } + if err := json.Unmarshal(w.Body.Bytes(), &envelope); err != nil || envelope.Code != 0 { + t.Fatalf("%s %s: unsuccessful envelope: %s", method, path, w.Body.String()) + } + } + } + for _, route := range []struct{ method, path, body string }{ + {http.MethodGet, "/characters", ""}, + {http.MethodGet, "/characters/" + character.Asset.ID, ""}, + {http.MethodPost, "/characters", `{"name":"New character"}`}, + {http.MethodGet, "/style-profiles", ""}, + {http.MethodGet, "/voice-profiles", ""}, + } { + call(route.method, route.path, route.body, "", http.StatusUnauthorized) + call(route.method, route.path, route.body, "owner", http.StatusOK) + } + projectWant := http.StatusForbidden + if enabled { + projectWant = http.StatusOK + } + call(http.MethodGet, "/projects", "", "", http.StatusUnauthorized) + call(http.MethodGet, "/projects", "", "owner", projectWant) + }) + } +}