fix(canvas): 画布版本 - 合并 PR #528 防止旧内容覆盖并支持历史恢复
将画布 revision CAS 与历史快照接到 schema v23,保留当前顶栏积分弹窗和已有菜单入口。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,12 +18,118 @@ tags:
|
||||
- name: meta
|
||||
- name: auth
|
||||
- name: projects
|
||||
- name: canvas
|
||||
- name: resources
|
||||
- name: tasks
|
||||
- name: assets
|
||||
- name: agent
|
||||
- name: admin
|
||||
paths:
|
||||
/canvas-projects/{id}:
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema: { type: string }
|
||||
get:
|
||||
tags: [canvas]
|
||||
summary: 读取当前用户画布内容和 revision
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.project 包含完整画布及服务端 revision
|
||||
"404":
|
||||
description: 画布不存在或不属于当前用户
|
||||
put:
|
||||
tags: [canvas]
|
||||
summary: 按 revision 保存画布内容
|
||||
description: 新建传 revision 0,更新传读取到的版本;成功原子加 1。忽略客户端 viewport 和 remoteContentHash,updatedAt 由服务端生成。
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [project]
|
||||
properties:
|
||||
project:
|
||||
type: object
|
||||
required: [id, revision]
|
||||
properties:
|
||||
id: { type: string }
|
||||
revision: { type: integer, format: int64, minimum: 0 }
|
||||
additionalProperties: true
|
||||
responses:
|
||||
"200":
|
||||
description: 信封 data.project 返回摘要及递增后的 revision
|
||||
"400":
|
||||
description: 请求、版本或素材引用不合法
|
||||
"409":
|
||||
description: 版本或资源状态冲突,原内容未被覆盖;保留草稿后加载最新版
|
||||
"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
|
||||
/public/welcome:
|
||||
get:
|
||||
tags: [meta]
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -631,6 +632,58 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
}
|
||||
ok(c, gin.H{"project": 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
|
||||
}
|
||||
log.Printf("canvas_restore request_id=%q trace_id=%q actor=%q canvas=%q snapshot=%q base_revision=%d revision=%d", RequestID(c), TraceID(c), user.ID, c.Param("id"), c.Param("snapshotId"), *req.Revision, project.Revision)
|
||||
ok(c, gin.H{"project": project})
|
||||
})
|
||||
r.PUT("/canvas-projects/:id", func(c *gin.Context) {
|
||||
user, err := currentUser(c, svc)
|
||||
if err != nil {
|
||||
@@ -656,7 +709,25 @@ func RegisterUserDataRoutes(r *gin.RouterGroup, svc *service.Service) {
|
||||
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
|
||||
}
|
||||
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.
|
||||
log.Printf("canvas_save request_id=%q trace_id=%q actor=%q canvas=%q base_revision=%d revision=%d nodes_before=%d nodes_after=%d connections=%d status=%d", RequestID(c), TraceID(c), user.ID, identity.ID, baseRevision, project.Revision, nodesBefore, nodesAfter, len(audit.Connections), c.Writer.Status())
|
||||
}()
|
||||
if err != nil {
|
||||
failService(c, err)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user