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:
Catlaxy AI
2026-10-11 22:03:57 +08:00
committed by GitHub
parent 7f50b4507a
commit 28f2362e3e
4 changed files with 241 additions and 131 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)
})
}

View 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)
})
}
}