fix(handler): 短剧开关 - 关闭短剧时保留角色库、画风与声音接口 (#736)
短剧功能开关原本挂在整个 RegisterProjectRoutes 上,连画布共用的 /characters、 /style-profiles、/voice-profiles 也一起返回 403,导致关闭短剧后画布的角色库、我的画风和声音不可用。 将这三类个人资料库路由拆到 RegisterLibraryRoutes,在开关之外注册;/projects 系列仍受开关控制,登录校验不变。 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
100
backend/internal/handler/short_drama_feature_routes_test.go
Normal file
100
backend/internal/handler/short_drama_feature_routes_test.go
Normal file
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user