chore(sync): 上游同步 - 快照导入 upstream/main@e70fe1b9(v1.6.5)并重放本项目差异
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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user