chore(sync): 上游同步 - 快照导入 upstream/main@e70fe1b9(v1.6.5)并重放本项目差异

This commit is contained in:
2026-10-12 11:52:01 +08:00
parent c9979a4a2b
commit 43c3dd3568
167 changed files with 5794 additions and 7804 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

@@ -376,99 +376,74 @@ paths:
responses:
"200":
description: 信封 data.project 包含完整画布及服务端 revision
"304":
description: If-None-Match 命中当前 revision,正文为空
"404":
description: 画布不存在或不属于当前用户
put:
delete:
tags: [canvas]
summary: 按 revision 保存画布内容
description: 新建传 revision 0,更新传读取到的版本;成功原子加 1。忽略客户端 viewport 和 remoteContentHash,updatedAt 由服务端生成。
summary: 显式删除当前用户画布
description: 删除后迟到的媒体回写和编辑操作不会重新创建画布;只有新的 create_canvas operation 可以显式创建同 ID 画布。
responses:
"200":
description: 画布已删除,信封 data.id 返回画布 ID
"401":
description: 未登录
"404":
description: 画布不存在或不属于当前用户
/canvas-projects/{id}/operations:
parameters:
- in: path
name: id
required: true
schema: { type: string }
patch:
tags: [canvas]
summary: 按对象或字段变更写入画布
description: |
请求只提交对象级或字段级 operations,不提交整批画布快照。
mutationId 是一次请求的幂等身份;响应丢失、断网、刷新或后台重试时必须复用同一个 mutationId 与原始 operations。
更新、删除和连接操作要求目标存在并通过对象基线校验;同一字段冲突不会覆盖远端修改。
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [project]
required: [mutationId, operations]
properties:
project:
type: object
required: [id, revision]
properties:
id: { type: string }
revision: { type: integer, format: int64, minimum: 0 }
additionalProperties: true
mutationId:
type: string
minLength: 1
maxLength: 160
description: 一次写入请求的幂等身份
baseRevision:
type: integer
format: int64
minimum: 0
description: 生成 operations 时已确认的画布 revision,用于诊断和响应
operations:
type: array
minItems: 1
maxItems: 500
items:
type: object
required: [type]
additionalProperties: true
description: create_canvas、create_node、patch_node、remove_node、create_connection、update_connection、remove_connection 或 set_field
responses:
"200":
description: 信封 data.project 返回摘要及递增后的 revision
description: 信封 data.project 返回当前画布摘要,data.mutationId 返回原 mutationId
"400":
description: 请求、版本或素材引用不合法
description: 请求、字段、对象基线或素材引用格式不合法
"401":
description: 未登录
"404":
description: 画布不存在或不属于当前用户,且请求未包含显式 create_canvas
"409":
description: 版本或资源状态冲突,原内容未被覆盖;保留草稿后加载最新版
description: 对象并发冲突或同一 mutationId 对应的请求摘要不同;原事务不会被覆盖
"428":
description: 缺少 revision,需刷新客户端并重新读取
/canvas-projects/{id}/history:
get:
tags: [canvas]
summary: 读取当前画布版本与最近 20 份历史摘要
parameters:
- in: path
name: id
required: true
schema: { type: string }
responses:
"200":
description: 信封 data 包含 currentRevision 和 snapshots;每项含 id、revision、title、nodeCount、connectionCount、payloadBytes、reason、contentUpdatedAt、createdAt,不含正文
"404":
description: 画布不存在或无权访问
/canvas-projects/{id}/history/{snapshotId}:
get:
tags: [canvas]
summary: 按需读取历史正文
parameters:
- in: path
name: id
required: true
schema: { type: string }
- in: path
name: snapshotId
required: true
schema: { type: string }
responses:
"200":
description: 信封 data 包含 snapshot 摘要和 project 正文
"404":
description: 画布或快照不存在、已过期或无权访问
/canvas-projects/{id}/history/{snapshotId}/restore:
post:
tags: [canvas]
summary: 备份当前内容并将历史内容恢复为新版本
parameters:
- in: path
name: id
required: true
schema: { type: string }
- in: path
name: snapshotId
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [revision]
properties:
revision: { type: integer, format: int64, minimum: 1 }
responses:
"200":
description: 信封 data.project 返回恢复后的摘要及新 revision;当前 ID、归属与项目关联保留
"404":
description: 画布或快照不存在、已过期或无权访问
"409":
description: 当前版本或素材状态已变化,恢复未执行
"428":
description: 缺少当前 revision
description: 缺少执行所需的对象基线
/public/welcome:
get:
tags: [meta]

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

View File

@@ -6,7 +6,6 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -557,59 +556,7 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
}
okCanvasProject(c, project)
})
r.GET("/canvas-projects/:id/history", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
result, err := svc.CanvasHistory(user.ID, c.Param("id"))
if err != nil {
failService(c, err)
return
}
ok(c, result)
})
r.GET("/canvas-projects/:id/history/:snapshotId", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
snapshot, err := svc.CanvasHistorySnapshot(user.ID, c.Param("id"), c.Param("snapshotId"))
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"snapshot": snapshot, "project": json.RawMessage(snapshot.PayloadJSON)})
})
r.POST("/canvas-projects/:id/history/:snapshotId/restore", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
return
}
policy, available := loadRuntimePolicy(c, svc)
if !available || !enforceRateLimit(c, "canvas-write:"+user.ID, policy.Request.CanvasWritePerMinute, time.Minute) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1024)
var req struct {
Revision *int64 `json:"revision"`
}
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, service.BadAuthRequest("恢复请求格式错误"))
return
}
project, err := svc.RestoreCanvasHistory(user.ID, c.Param("id"), c.Param("snapshotId"), req.Revision)
if err != nil {
failService(c, err)
return
}
slog.Info("canvas_restore", "request_id", RequestID(c), "trace_id", TraceID(c), "actor", user.ID, "canvas", c.Param("id"), "snapshot", c.Param("snapshotId"), "base_revision", *req.Revision, "revision", project.Revision)
ok(c, gin.H{"project": project})
})
r.PUT("/canvas-projects/:id", func(c *gin.Context) {
r.PATCH("/canvas-projects/:id/operations", func(c *gin.Context) {
user, err := currentUser(c, svc)
if err != nil {
failService(c, err)
@@ -620,58 +567,17 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 5<<20)
var req struct {
Project json.RawMessage `json:"project"`
RepairMissingResources bool `json:"repairMissingResources"`
}
var req service.CanvasOperationsRequest
if err := c.ShouldBindJSON(&req); err != nil {
fail(c, http.StatusBadRequest, err)
fail(c, http.StatusBadRequest, service.BadAuthRequest("画布操作请求格式错误"))
return
}
var identity struct {
ID string `json:"id"`
}
if json.Unmarshal(req.Project, &identity) != nil || identity.ID != c.Param("id") {
fail(c, http.StatusBadRequest, service.BadAuthRequest("画布 ID 与请求路径不一致"))
return
}
var audit struct {
Revision *int64 `json:"revision"`
Nodes []json.RawMessage `json:"nodes"`
Connections []json.RawMessage `json:"connections"`
}
_ = json.Unmarshal(req.Project, &audit)
baseRevision := int64(-1)
if audit.Revision != nil {
baseRevision = *audit.Revision
}
var project service.UserDataSummary
if req.RepairMissingResources {
project, err = svc.RepairUserCanvasProject(user.ID, req.Project)
} else {
project, err = svc.UpsertUserCanvasProject(user.ID, req.Project)
}
defer func() {
nodesBefore, nodesAfter := -1, len(audit.Nodes)
if project.SaveAudit != nil {
nodesBefore, nodesAfter = project.SaveAudit.NodesBefore, project.SaveAudit.NodesAfter
}
// Metadata only: never log prompts, media URLs, cookies, or the canvas payload.
// 自动保存非常频繁:常规保存只在 debug 输出;节点减少(可能丢数据)或保存失败才提升级别。
level := slog.LevelDebug
switch {
case c.Writer.Status() >= http.StatusBadRequest:
level = slog.LevelWarn
case nodesBefore >= 0 && nodesAfter < nodesBefore:
level = slog.LevelInfo
}
slog.Log(c.Request.Context(), level, "canvas_save", "request_id", RequestID(c), "trace_id", TraceID(c), "actor", user.ID, "canvas", identity.ID, "base_revision", baseRevision, "revision", project.Revision, "nodes_before", nodesBefore, "nodes_after", nodesAfter, "connections", len(audit.Connections), "status", c.Writer.Status())
}()
project, err := svc.ApplyUserCanvasOperations(user.ID, c.Param("id"), req)
if err != nil {
failService(c, err)
return
}
ok(c, gin.H{"project": project})
ok(c, gin.H{"project": project, "mutationId": req.MutationID})
})
r.DELETE("/canvas-projects/:id", func(c *gin.Context) {
user, err := currentUser(c, svc)