Merge remote-tracking branch 'origin/master' into fix/canva-preview-mismatch
Project CI / Frontend tests (pull_request) Failing after 39s
Project CI / Repository checks (pull_request) Successful in 1m5s
Project CI / Native shell tests (pull_request) Successful in 2m29s
Project CI / Backend tests (pull_request) Successful in 3m3s

# Conflicts:
#	src/components/image-editor/useImageCanvasProjectPersistence.test.tsx
This commit is contained in:
2026-07-29 14:31:00 +08:00
12 changed files with 1476 additions and 193 deletions
@@ -36,7 +36,7 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext
| Intent | Method and path | Required fields |
| --- | --- | --- |
| List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` |
| Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` |
| Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` |
@@ -53,7 +53,7 @@ Ask a follow-up only when two routes could both be correct and produce different
| Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key |
| Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` |
| Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` |
| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` |
| Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` |
| Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` |
| Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` |
@@ -270,11 +270,21 @@ class GenarrativeExternalClient:
fields[asset_label_field] = normalize_optional_text(asset_label) or "生成素材"
return fields
def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any:
def save_canvas(
self,
project_id: str,
viewport: dict[str, Any],
layers: dict[str, Any],
expected_revision: int,
) -> Any:
return self.request_json(
"PATCH",
f"/api/external/v1/editor/projects/{urllib.parse.quote(project_id, safe='')}/canvas",
{"viewport": viewport, "layers": layers},
{
"viewport": viewport,
"layers": layers,
"expectedRevision": expected_revision,
},
)
def _apply_art_spec(self, fields: dict[str, Any], prompt: str) -> str:
@@ -1761,7 +1761,8 @@
"type": "object",
"required": [
"viewport",
"layers"
"layers",
"expectedRevision"
],
"properties": {
"viewport": {
@@ -1778,7 +1779,7 @@
"expectedRevision": {
"type": "integer",
"minimum": 0,
"description": "可选的画布 revision CAS;不匹配时返回 409。"
"description": "必填的画布 revision CAS;不匹配时返回 409。"
}
},
"additionalProperties": false
@@ -349,7 +349,7 @@
- 现象:`/api/editor/projects*`、素材库、项目资源或 layout payload 里出现数 MB 的 `data:image/*``data:video/*``data:audio/*`,刷新恢复变慢,发布入口可能 OOM / 413,素材库缩略图还可能只显示文件名。
- 原因:生成、规范图、角色图、图标 / UI spritesheet、音视频或动画帧如果直接把 Data URL / signed URL 写入 `editor_project_resource``editor_asset``editor_canvas.layers_json`,就把媒体本体塞进了项目快照;signed URL 还会过期,素材库也无法稳定换签。
- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage``objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<objectKey>`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,不能在后端快照回来前自动保存。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。
- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage``objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/<objectKey>`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,必须按用户隔离,而且不能在后端快照回来前自动保存。认证状态变化重跑加载 effect 时,要同步用 ref 关闭写门禁并清除 revision、pending save 和 timer;不能只等 `isProjectReady=false` 的下一次 render,否则旧 effect 会先消费 skip 标记,再把公司浏览器的旧缓存无版本 PATCH 到服务端,覆盖另一台设备的新画布布局。现役 Web 与 External layout PATCH 的 `expectedRevision` 都必填,三层门禁分别放在 autosave effect、queue 和真正发送前;session cache 即使带 revision 也只有显示权。异步 project resource 创建必须把未发请求队列按用户 / 项目隔离,并记录发起时已接受的权威快照序号;若资源响应前发生认证重载、409 恢复或生成完成快照替换,只把新资源对应图层合并进当前权威布局,禁止用历史 `snapshotLayers` 整体覆盖。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。
- 验证:Network 中 `/api/editor/projects*``PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`
- 关联:`server-rs/crates/api-server/src/editor_project.rs``src/components/image-editor/ImageCanvasEditorModel.ts``src/components/image-editor/useImageCanvasProjectPersistence.ts``src/components/common/PlatformMediaFrame.tsx``src/services/assetReadUrlService.ts`
@@ -46,7 +46,7 @@
- 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。
- 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。
- 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不返回完整 project前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。
- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project前端用 ack 更新后续保存版本,仍必须以后续显式读取或生成完成返回的后端快照作为项目真相。
- 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。
- 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。
@@ -68,7 +68,7 @@
- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。
- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。
- 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。
- 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。
- 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存 key 和 envelope 必须按当前用户隔离并携带 revision,缓存快照必须排除 `data:*` / `blob:` 内联媒体。session 缓存只提供显示,不授予写权限;项目加载或认证身份变化触发重载时,必须先通过同步 ref 关闭写门禁、清除 revision 与待保存 timer,只有本次服务端权威快照携带 revision 并应用完成后才重新开放保存。不能只依赖异步 `isProjectReady` state 阻止同一轮 effect,也不能把缓存中的 revision 当作权威写凭据。未创建的 project resource 队列必须绑定发起用户和目标项目;已发出的 resource 请求还要捕获发起时的权威快照序号,若响应前画布已被更新权威快照替换,只把本次新资源对应的单个图层合并到当前布局后用最新 revision 保存,不得恢复请求发起前的整份旧图层数组,也不得把一个用户的 pending 图层排入另一个用户项目。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。
- 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。
## 后端接口
@@ -77,7 +77,7 @@
- `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。
- `POST /api/editor/projects`:创建图片画布工程。
- `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。
- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照;响应只包含 `{ projectId, canvasId, updatedAt }` ack,不返回完整工程快照。
- `PATCH /api/editor/projects/{projectId}`携带必填 `expectedRevision` 保存 viewport 与图层布局快照;响应只包含 `{ projectId, canvasId, revision, updatedAt }` ack,不返回完整工程快照。
- `PATCH /api/editor/projects/{projectId}/metadata`:重命名指定工程。
- `DELETE /api/editor/projects/{projectId}`:删除指定工程,并级联删除默认画布和资源元数据。
- `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。
@@ -45,7 +45,7 @@ layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualP
所有用户布局写入必须携带读取快照时获得的 `expectedRevision`。procedure 在事务中校验 canvas 当前 revision;不一致返回 `409`,前端应重载后端最新快照,不能只换上新 revision 就原样重放冲突前的整包布局。
前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后权威快照暂时加载失败时保留 pending save 并定时重新进入冲突恢复,不能等待用户再次拖动画布才恢复保存
前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409`冲突布局立即作废,权威快照暂时加载失败时保留冲突恢复状态并定时只重试 GET,不能把旧布局换上新 revision 后重放,也不能等待用户再次拖动画布才恢复。
本次结构化 V1 先保留旧 `{ viewport, layers }` PATCH 作为兼容输入。legacy canvas 即使携带 `expectedRevision` 也只做 CAS legacy 保存,不允许用户写入绕过 migration operator 直接激活 structured;只有已完成 backfill / activate、且 active 迁移记录的 revision / hash / 数量 / 资源引用校验均通过时,后端才在单个事务内把兼容输入拆成 layer / dialog 行并递增一次 revision。旧无 CAS procedure 不得写 structured canvas。V1 快照从 typed 列重组,`item_json / dialog_json` 只保留最大 512 KiB 的未结构化扩展字段。自包含本地图片序列在 active canvas 中只能继续保存已回填且 `layerId / resourceId / sourceType / item_json` 语义完全一致的原行;允许修改几何、层级、分组、显隐等 typed 布局字段。前端序列化按正常资源真相边界省略 `assetKind / generationInputs` 时,后端只从既有结构化行恢复这两个冻结字段再校验;显式修改仍拒绝。active 路径不再经过 legacy 元数据清洗,拒绝新增缺资源序列或改写既有帧、预览、prompt 和生成扩展。后续将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation;在此之前 2 MiB 仍是兼容整包入口的上限。
@@ -41,14 +41,14 @@ use spacetime_client::{
EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput,
EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput,
EditorProjectDeleteRecordInput, EditorProjectGetRecordInput,
EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord,
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord,
EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput,
EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord,
EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput,
EditorShowcaseCampaignConfigRecord, ExternalGenerationJobPhaseUpdateError,
ExternalGenerationJobPhaseUpdateRecordInput, SpacetimeClientError,
EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput,
EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput,
EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput,
EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput,
EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord,
ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput,
SpacetimeClientError,
};
use crate::{
@@ -157,7 +157,7 @@ pub struct EditorCanvasViewportPayload {
pub struct EditorProjectLayoutSaveRequest {
pub(crate) viewport: EditorCanvasViewportPayload,
pub(crate) layers: Value,
pub(crate) expected_revision: Option<u64>,
pub(crate) expected_revision: u64,
}
#[derive(Debug, Deserialize)]
@@ -446,8 +446,7 @@ pub struct EditorProjectResponse {
pub struct EditorProjectLayoutSaveResponse {
project_id: String,
canvas_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
revision: Option<u64>,
revision: u64,
updated_at: String,
}
@@ -1064,48 +1063,26 @@ pub async fn save_editor_project_layout(
let layers_json = serialize_editor_layers(payload.layers)?;
let owner_user_id = authenticated.claims().user_id().to_string();
let updated_at_micros = current_utc_micros();
let (project_id, canvas_id, revision, updated_at) =
if let Some(expected_revision) = expected_revision {
let ack = state
.spacetime_client()
.save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput {
project_id,
owner_user_id,
viewport,
layers_json,
expected_revision,
updated_at_micros,
})
.await
.map_err(map_editor_project_error)?;
(
ack.project_id,
ack.canvas_id,
Some(ack.revision),
ack.updated_at,
)
} else {
let ack = state
.spacetime_client()
.save_editor_project_layout_ack(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id,
viewport,
layers_json,
updated_at_micros,
})
.await
.map_err(map_editor_project_error)?;
(ack.project_id, ack.canvas_id, None, ack.updated_at)
};
let ack = state
.spacetime_client()
.save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput {
project_id,
owner_user_id,
viewport,
layers_json,
expected_revision,
updated_at_micros,
})
.await
.map_err(map_editor_project_error)?;
Ok(json_success_body(
Some(&request_context),
EditorProjectLayoutSaveResponse {
project_id,
canvas_id,
revision,
updated_at,
project_id: ack.project_id,
canvas_id: ack.canvas_id,
revision: ack.revision,
updated_at: ack.updated_at,
},
))
}
@@ -7893,6 +7870,24 @@ mod tests {
assert_eq!(error.status_code(), StatusCode::CONFLICT);
}
#[test]
fn editor_project_layout_save_request_requires_expected_revision() {
let missing_revision = serde_json::from_value::<EditorProjectLayoutSaveRequest>(json!({
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
"layers": [],
}))
.expect_err("现役画布保存缺少 expectedRevision 时必须在进入写路径前失败");
assert!(missing_revision.to_string().contains("expectedRevision"));
let request = serde_json::from_value::<EditorProjectLayoutSaveRequest>(json!({
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
"layers": [],
"expectedRevision": 7,
}))
.expect("携带 expectedRevision 的画布保存请求应通过反序列化");
assert_eq!(request.expected_revision, 7);
}
fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision {
EditorScreenBackgroundDecision {
color: parse_editor_screen_background_color(Some(hex))
@@ -11,8 +11,8 @@ use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput,
EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput,
EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput,
EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput,
EditorProjectGetRecordInput, EditorProjectRenameRecordInput,
EditorProjectResourceCreateRecordInput,
};
use crate::{
@@ -33,7 +33,6 @@ use crate::{
generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner,
map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string,
save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata,
serialize_editor_layers,
},
external_api_auth::ExternalApiPrincipal,
http_error::AppError,
@@ -63,7 +62,7 @@ pub struct ExternalEditorProjectCreateRequest {
pub struct ExternalEditorCanvasSaveRequest {
viewport: EditorCanvasViewportPayload,
layers: Value,
expected_revision: Option<u64>,
expected_revision: u64,
}
#[derive(Debug, Deserialize)]
@@ -341,29 +340,15 @@ pub async fn save_external_editor_canvas(
Json(payload): Json<ExternalEditorCanvasSaveRequest>,
) -> Result<Json<Value>, AppError> {
require_scope(&principal, SCOPE_EDITOR_CANVAS)?;
let project = if let Some(expected_revision) = payload.expected_revision {
save_editor_project_layout_with_revision_and_get(
&state,
project_id.as_str(),
principal.owner_user_id(),
payload.viewport.into_record(),
payload.layers,
expected_revision,
)
.await?
} else {
state
.spacetime_client()
.save_editor_project_layout(EditorProjectLayoutSaveRecordInput {
project_id,
owner_user_id: principal.owner_user_id().to_string(),
viewport: payload.viewport.into_record(),
layers_json: serialize_editor_layers(payload.layers)?,
updated_at_micros: current_utc_micros(),
})
.await
.map_err(map_editor_project_error)?
};
let project = save_editor_project_layout_with_revision_and_get(
&state,
project_id.as_str(),
principal.owner_user_id(),
payload.viewport.into_record(),
payload.layers,
payload.expected_revision,
)
.await?;
Ok(json_success_body(
Some(&request_context),
@@ -803,6 +788,24 @@ fn normalize_project_title(title: Option<String>) -> String {
mod tests {
use super::*;
#[test]
fn external_editor_canvas_save_request_requires_expected_revision() {
let missing_revision = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
"layers": [],
}))
.expect_err("外部画布保存缺少 expectedRevision 时必须在进入写路径前失败");
assert!(missing_revision.to_string().contains("expectedRevision"));
let request = serde_json::from_value::<ExternalEditorCanvasSaveRequest>(json!({
"viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 },
"layers": [],
"expectedRevision": 7,
}))
.expect("外部画布保存携带 expectedRevision 时应通过反序列化");
assert_eq!(request.expected_revision, 7);
}
#[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
@@ -876,6 +879,11 @@ mod tests {
parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"],
"array"
);
assert!(
parsed["components"]["schemas"]["ExternalEditorCanvasSaveRequest"]["required"]
.as_array()
.is_some_and(|required| required.contains(&json!("expectedRevision")))
);
assert!(
parsed["paths"]
.get("/api/external/v1/editor/images/edits")
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -555,13 +555,13 @@ export type EditorProjectCreateInput = {
export type EditorProjectLayoutSaveInput = {
viewport: EditorCanvasViewport;
layers: EditorProjectLayerSnapshot[];
expectedRevision?: number;
expectedRevision: number;
};
export type EditorProjectLayoutSaveResult = {
projectId: string;
canvasId: string;
revision?: number;
revision: number;
updatedAt: string;
};