diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index ac684ddf7..bca5a3cbd 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -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` | diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 77f7269e9..ad5232b9f 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -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` | diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index 6998db36c..2ddc78502 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -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: diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 7d76b8e11..8154159d9 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -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 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 24cd90211..b540e084d 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -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`、`assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage` 传 `objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,也不能在后端快照回来前自动保存。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。 +- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/"`、`objectKey`、`assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage` 传 `objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `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`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index a5f4f2e6f..6fd0e61a4 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -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`:创建画布资源记录,接收上传资源或真实生成资源元数据。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index 8639b6569..bb6795e99 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -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 仍是兼容整包入口的上限。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5f3c0c041..af5e67aeb 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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, + 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, + 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::(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::(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)) diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ee3f93733..af53d6f64 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -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, + expected_revision: u64, } #[derive(Debug, Deserialize)] @@ -341,29 +340,15 @@ pub async fn save_external_editor_canvas( Json(payload): Json, ) -> Result, 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 { mod tests { use super::*; + #[test] + fn external_editor_canvas_save_request_requires_expected_revision() { + let missing_revision = serde_json::from_value::(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::(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") diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index fc11ca2f3..406797c4c 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import { useCallback, useMemo, useRef, useState } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; @@ -25,7 +25,7 @@ const putEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = - 'genarrative.imageCanvas.projectSnapshot.v1:recent'; + 'genarrative.imageCanvas.projectSnapshot.v2:user-test:recent'; vi.mock('./ImageCanvasProjectCoverSnapshotRenderer', () => ({ createProjectCoverSnapshotBlob: createProjectCoverSnapshotBlobMock, @@ -86,11 +86,13 @@ function createDeferred() { function ProjectPersistenceHarness({ canAccessProtectedData = true, + currentUserId = 'user-test', initialCanvasBackgroundColor = DEFAULT_CANVAS_BACKGROUND_COLOR, initialGenerationDialogs = [], onProjectAccessLost, }: { canAccessProtectedData?: boolean; + currentUserId?: string | null; initialCanvasBackgroundColor?: string; initialGenerationDialogs?: CanvasGenerationDialogState[]; onProjectAccessLost?: () => void; @@ -170,6 +172,7 @@ function ProjectPersistenceHarness({ canvasBackgroundColor, isViewportInteracting, canAccessProtectedData, + currentUserId, openEditorLoginModal: openEditorLoginModalRef.current, onProjectAccessLost, }); @@ -376,6 +379,16 @@ function ProjectPersistenceHarness({ persistence.applyProjectSnapshot({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 1, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:01.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -431,13 +444,91 @@ function ProjectPersistenceHarness({ > apply completed project + + ); } describe('useImageCanvasProjectPersistence', () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); try { globalThis.sessionStorage?.clear(); } catch { @@ -446,6 +537,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValue({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], @@ -463,10 +564,8 @@ describe('useImageCanvasProjectPersistence', () => { }); saveEditorProjectLayoutMock.mockResolvedValue({ projectId: 'editor-project-default', - title: '空画布项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], + canvasId: 'editor-project-default:canvas:default', + revision: 1, updatedAt: '2026-06-12T00:00:00.000Z', }); createProjectCoverSnapshotBlobMock.mockResolvedValue(null); @@ -517,6 +616,227 @@ describe('useImageCanvasProjectPersistence', () => { }); }); + it('merges a late resource response into a reloaded authoritative layout', async () => { + const resourceCreate = + createDeferred(); + createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise); + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:local-layer-a', + ); + + rerender(); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '其它设备的新画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 1, + updatedAt: '2026-07-29T02:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-remote', + resourceId: 'resource-remote', + x: 20, + y: 30, + }, + ], + resources: [ + { + resourceId: 'resource-remote', + projectId: 'editor-project-default', + imageSrc: '/generated/remote.png', + objectKey: 'generated/remote.png', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + ], + updatedAt: '2026-07-29T02:00:00.000Z', + }); + rerender(); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + }); + saveEditorProjectLayoutMock.mockClear(); + + await act(async () => { + resourceCreate.resolve({ + resourceId: 'resource-added-asset-a', + projectId: 'editor-project-default', + imageSrc: '/generated-character-drafts/editor/assets/asset-a.png', + objectKey: 'generated-character-drafts/editor/assets/asset-a.png', + assetObjectId: 'asset-object-a', + width: 320, + height: 240, + sourceType: 'uploaded', + }); + await resourceCreate.promise; + }); + + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:resource-added-asset-a', + ); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 4, + layers: expect.arrayContaining([ + expect.objectContaining({ layerId: 'layer-remote' }), + expect.objectContaining({ + layerId: 'layer-a', + resourceId: 'resource-added-asset-a', + }), + ]), + }), + ); + }); + }); + + it('merges a late resource response after conflict recovery replaces the layout', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '冲突前画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 1, + updatedAt: '2026-07-29T05:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T05:00:00.000Z', + }); + const resourceCreate = + createDeferred(); + createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise); + const conflict = new ApiClientError({ + message: '图片画布版本冲突', + status: 409, + code: 'conflict', + }); + saveEditorProjectLayoutMock + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ + projectId: 'editor-project-default', + canvasId: 'editor-project-default:canvas:default', + revision: 5, + updatedAt: '2026-07-29T05:00:02.000Z', + }); + loadEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '其它设备画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 1, + updatedAt: '2026-07-29T05:00:01.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-remote', + resourceId: 'resource-remote', + x: 20, + y: 30, + }, + ], + resources: [ + { + resourceId: 'resource-remote', + projectId: 'editor-project-default', + imageSrc: '/generated/remote.png', + objectKey: 'generated/remote.png', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + ], + updatedAt: '2026-07-29T05:00:01.000Z', + }); + + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + await act(async () => { + vi.advanceTimersByTime(451); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + expect(screen.getByTestId('layers').textContent).not.toContain('layer-a:'); + + await act(async () => { + resourceCreate.resolve({ + resourceId: 'resource-added-asset-a', + projectId: 'editor-project-default', + imageSrc: '/generated-character-drafts/editor/assets/asset-a.png', + objectKey: 'generated-character-drafts/editor/assets/asset-a.png', + assetObjectId: 'asset-object-a', + width: 320, + height: 240, + sourceType: 'uploaded', + }); + await resourceCreate.promise; + await Promise.resolve(); + }); + + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:resource-added-asset-a', + ); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 2, + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 4, + layers: expect.arrayContaining([ + expect.objectContaining({ layerId: 'layer-remote' }), + expect.objectContaining({ + layerId: 'layer-a', + resourceId: 'resource-added-asset-a', + }), + ]), + }), + ); + }); + it('reloads the authoritative snapshot after a revision conflict without replaying the stale layout', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', @@ -609,6 +929,160 @@ describe('useImageCanvasProjectPersistence', () => { ); }); + it('ignores an authoritative callback older than the applied project revision', async () => { + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + + act(() => { + screen.getByRole('button', { name: 'apply completed project' }).click(); + screen.getByRole('button', { name: 'apply stale project' }).click(); + }); + + expect(screen.getByTestId('project-title').textContent).toBe('空画布项目'); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-generated:resource-generated', + ); + }); + + it('rejects an asynchronous project callback after authority is revoked', async () => { + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + + rerender(); + act(() => { + screen.getByRole('button', { name: 'apply completed project' }).click(); + }); + + expect(screen.getByTestId('project-title').textContent).toBe('空画布项目'); + expect(screen.getByTestId('layers').textContent).toBe(''); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + }); + + it('keeps a newer same-user completion snapshot that arrives during an authority reload', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '重载前画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T02:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T02:00:00.000Z', + }); + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + const staleReload = createDeferred(); + + await act(async () => { + rerender(); + await Promise.resolve(); + }); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(staleReload.promise); + await act(async () => { + rerender(); + await Promise.resolve(); + }); + act(() => { + screen.getByRole('button', { name: 'apply newer project' }).click(); + }); + expect(screen.getByTestId('project-title').textContent).toBe( + '异步完成的新画布', + ); + + await act(async () => { + staleReload.resolve({ + projectId: 'editor-project-default', + title: '更早发起的旧读取', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T02:30:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T02:30:00.000Z', + }); + await staleReload.promise; + await Promise.resolve(); + }); + + expect(screen.getByTestId('project-title').textContent).toBe( + '异步完成的新画布', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-async-completed:resource-async-completed', + ); + saveEditorProjectLayoutMock.mockClear(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ expectedRevision: 5 }), + ); + }); + + it('does not drain one user pending resource layer into another user project', async () => { + const firstUserLoad = createDeferred(); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( + firstUserLoad.promise, + ); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-user-two', + title: '用户二画布', + canvas: { + canvasId: 'editor-project-user-two:canvas:default', + projectId: 'editor-project-user-two', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 7, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T04:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T04:00:00.000Z', + }); + const { rerender } = render( + , + ); + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + + rerender(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '用户二画布', + ); + }); + + expect(screen.getByTestId('layers').textContent).toBe(''); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + }); + it('continues with the latest pending layout when an in-flight save fails', async () => { const firstSave = createDeferred(); render(); @@ -668,7 +1142,175 @@ describe('useImageCanvasProjectPersistence', () => { } }); - it('retries conflict recovery when the authoritative snapshot reload fails', async () => { + it('drains a new-authority save after an old in-flight request fails', async () => { + const firstSave = createDeferred<{ + projectId: string; + canvasId: string; + revision: number; + updatedAt: string; + }>(); + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + saveEditorProjectLayoutMock.mockClear(); + saveEditorProjectLayoutMock + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValue({ + projectId: 'editor-project-default', + canvasId: 'editor-project-default:canvas:default', + revision: 1, + updatedAt: '2026-07-20T00:00:02.000Z', + }); + vi.useFakeTimers(); + + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + rerender(); + await Promise.resolve(); + }); + await act(async () => { + rerender(); + await Promise.resolve(); + await Promise.resolve(); + }); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + firstSave.reject(new TypeError('old connection dropped')); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 2, + 'editor-project-default', + expect.objectContaining({ expectedRevision: 0 }), + ); + }); + + it('keeps conflict recovery cache isolated when an old runner drains a new user save', async () => { + const projectForUser = ( + userNumber: 'one' | 'two', + revision: number, + title: string, + ): EditorProjectSnapshot => ({ + projectId: `editor-project-user-${userNumber}`, + title, + canvas: { + canvasId: `editor-project-user-${userNumber}:canvas:default`, + projectId: `editor-project-user-${userNumber}`, + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision, + layoutStorageVersion: 0, + updatedAt: `2026-07-20T00:00:0${revision}.000Z`, + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: `2026-07-20T00:00:0${revision}.000Z`, + }); + loadOrCreateRecentEditorProjectMock + .mockResolvedValueOnce(projectForUser('one', 3, '用户一画布')) + .mockResolvedValueOnce(projectForUser('two', 7, '用户二画布')); + const firstSave = createDeferred<{ + projectId: string; + canvasId: string; + revision: number; + updatedAt: string; + }>(); + const conflict = new ApiClientError({ + message: '图片画布版本冲突', + status: 409, + code: 'conflict', + }); + saveEditorProjectLayoutMock + .mockReturnValueOnce(firstSave.promise) + .mockRejectedValueOnce(conflict); + loadEditorProjectMock.mockResolvedValueOnce( + projectForUser('two', 8, '用户二冲突恢复画布'), + ); + + const { rerender } = render( + , + ); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '用户一画布', + ); + }); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + rerender(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId('project-title').textContent).toBe('用户二画布'); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + + await act(async () => { + firstSave.resolve({ + projectId: 'editor-project-user-one', + canvasId: 'editor-project-user-one:canvas:default', + revision: 4, + updatedAt: '2026-07-20T00:00:04.000Z', + }); + await firstSave.promise; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(loadEditorProjectMock).toHaveBeenCalledWith( + 'editor-project-user-two', + ); + expect(screen.getByTestId('project-title').textContent).toBe( + '用户二冲突恢复画布', + ); + const userOneCache = globalThis.sessionStorage.getItem( + 'genarrative.imageCanvas.projectSnapshot.v2:user-one:recent', + ); + const userTwoCache = globalThis.sessionStorage.getItem( + 'genarrative.imageCanvas.projectSnapshot.v2:user-two:recent', + ); + expect(userOneCache).toContain('editor-project-user-one'); + expect(userOneCache).not.toContain('editor-project-user-two'); + expect(userTwoCache).toContain('editor-project-user-two'); + expect(userTwoCache).toContain('用户二冲突恢复画布'); + }); + + it('retries only the authoritative reload after conflict recovery fails', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '初始画布', @@ -692,9 +1334,7 @@ describe('useImageCanvasProjectPersistence', () => { status: 409, code: 'conflict', }); - saveEditorProjectLayoutMock - .mockRejectedValueOnce(conflict) - .mockRejectedValueOnce(conflict); + saveEditorProjectLayoutMock.mockRejectedValueOnce(conflict); loadEditorProjectMock .mockRejectedValueOnce(new TypeError('connection dropped')) .mockResolvedValueOnce({ @@ -728,6 +1368,8 @@ describe('useImageCanvasProjectPersistence', () => { vi.advanceTimersByTime(451); await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); }); expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); @@ -737,7 +1379,7 @@ describe('useImageCanvasProjectPersistence', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); expect(screen.getByTestId('project-title').textContent).toBe( '其它标签页已保存', @@ -809,8 +1451,12 @@ describe('useImageCanvasProjectPersistence', () => { expect(cachedRaw).toBeTruthy(); expect(cachedRaw).not.toContain('data:image'); const cached = JSON.parse(cachedRaw ?? '{}') as { + ownerUserId?: string; project?: EditorProjectSnapshot; + revision?: number; }; + expect(cached.ownerUserId).toBe('user-test'); + expect(cached.revision).toBe(1); expect(cached.project?.resources).toEqual([ expect.objectContaining({ resourceId: 'resource-added-asset-a', @@ -908,6 +1554,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '已有封面画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 12, y: -8, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 12, y: -8, scale: 1 }, layers: [ { @@ -1001,6 +1657,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '本地封面画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1088,7 +1754,9 @@ describe('useImageCanvasProjectPersistence', () => { vi.useFakeTimers(); act(() => { - screen.getByRole('button', { name: 'begin viewport interaction' }).click(); + screen + .getByRole('button', { name: 'begin viewport interaction' }) + .click(); }); expect(screen.getByTestId('viewport-interacting').textContent).toBe('true'); act(() => { @@ -1167,6 +1835,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '已有角色项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1295,6 +1973,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1365,6 +2053,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1425,10 +2123,8 @@ describe('useImageCanvasProjectPersistence', () => { await act(async () => { saveResolvers[0]?.({ projectId: 'editor-project-default', - title: '空画布项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], + canvasId: 'editor-project-default:canvas:default', + revision: 1, updatedAt: '2026-06-12T00:00:01.000Z', }); await Promise.resolve(); @@ -1472,6 +2168,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1529,6 +2235,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1583,6 +2299,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '角色画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-21T12:15:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1653,6 +2379,8 @@ describe('useImageCanvasProjectPersistence', () => { EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, JSON.stringify({ cachedAt: Date.now(), + ownerUserId: 'user-test', + revision: 2, project: { projectId: 'editor-project-default', title: '缓存画布项目', @@ -1704,6 +2432,16 @@ describe('useImageCanvasProjectPersistence', () => { backendProjectLoad.resolve({ projectId: 'editor-project-default', title: '后端画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:02.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], @@ -1721,6 +2459,203 @@ describe('useImageCanvasProjectPersistence', () => { expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); }); + it('waits for authority before creating a cached-project resource and then restores its layer', async () => { + const backendProjectLoad = createDeferred(); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( + backendProjectLoad.promise, + ); + globalThis.sessionStorage.setItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + JSON.stringify({ + cachedAt: Date.now(), + ownerUserId: 'user-test', + revision: 2, + project: { + projectId: 'editor-project-default', + title: '缓存画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:01.000Z', + } satisfies EditorProjectSnapshot, + }), + ); + + render(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '缓存画布项目', + ); + }); + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + + await act(async () => { + backendProjectLoad.resolve({ + projectId: 'editor-project-default', + title: '后端权威画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:02.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:02.000Z', + }); + await backendProjectLoad.promise; + }); + + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:resource-added-asset-a', + ); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 3, + layers: expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-a', + resourceId: 'resource-added-asset-a', + }), + ]), + }), + ); + }); + }); + + it('does not autosave a cached snapshot when user identity restarts the authoritative load', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '首次后端画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T01:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T01:00:00.000Z', + }); + const { rerender } = render( + , + ); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '首次后端画布', + ); + }); + + globalThis.sessionStorage.setItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + JSON.stringify({ + cachedAt: Date.now(), + ownerUserId: 'user-test', + revision: 1, + project: { + projectId: 'editor-project-default', + title: '公司旧缓存画布', + viewport: { x: 100, y: 200, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-28T01:00:00.000Z', + } satisfies EditorProjectSnapshot, + }), + ); + const secondBackendLoad = createDeferred(); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( + secondBackendLoad.promise, + ); + saveEditorProjectLayoutMock.mockClear(); + vi.useFakeTimers(); + + await act(async () => { + rerender(); + await Promise.resolve(); + }); + expect(screen.getByTestId('project-title').textContent).toBe( + '公司旧缓存画布', + ); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + + await act(async () => { + secondBackendLoad.resolve({ + projectId: 'editor-project-default', + title: '二次后端权威画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T01:01:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T01:01:00.000Z', + }); + await secondBackendLoad.promise; + await Promise.resolve(); + }); + expect(screen.getByTestId('project-title').textContent).toBe( + '二次后端权威画布', + ); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('does not save when the authoritative snapshot has no revision', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '缺少版本的异常快照', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T01:00:00.000Z', + }); + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '缺少版本的异常快照', + ); + }); + saveEditorProjectLayoutMock.mockClear(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it('does not load protected project data before login is available', () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 86f172567..6b36ad5d4 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -46,22 +46,45 @@ import { createProjectCoverSnapshotBlob } from './ImageCanvasProjectCoverSnapsho type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; snapshotLayers?: CanvasLayer[]; + restoreMissingLayer?: boolean; }; type PendingProjectResourceLayer = { layer: CanvasLayer; options: ProjectResourceOptions; + ownerUserId: string | null | undefined; + targetProjectId: string | null; +}; + +type PendingCreatedProjectResourceLayer = { + layer: CanvasLayer; + options: ProjectResourceOptions; + projectId: string; + ownerUserId: string | null | undefined; + authoritativeSnapshotSequence: number; + resourceId: string; }; type PendingProjectLayoutSave = { projectId: string; - input: Parameters[1]; + input: Omit< + Parameters[1], + 'expectedRevision' + >; + attemptExpectedRevision?: number; transportRetries?: number; }; type CachedEditorProjectSnapshot = { project: EditorProjectSnapshot; cachedAt: number; + ownerUserId: string; + revision: number; +}; + +type ApplyProjectSnapshotOptions = { + authoritative?: boolean; + allowProjectSwitch?: boolean; }; type ImageCanvasProjectPersistenceRefs = { @@ -144,8 +167,7 @@ function resolveProjectResourceCreateImageSrc(layer: CanvasLayer) { } const EDITOR_PROJECT_SESSION_CACHE_PREFIX = - 'genarrative.imageCanvas.projectSnapshot.v1:'; -const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}recent`; + 'genarrative.imageCanvas.projectSnapshot.v2:'; function getEditorProjectSessionStorage() { try { @@ -155,8 +177,16 @@ function getEditorProjectSessionStorage() { } } -function editorProjectSessionCacheKey(projectId: string) { - return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent(projectId)}`; +function editorProjectSessionCacheKey(ownerUserId: string, projectId: string) { + return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent( + ownerUserId, + )}:${encodeURIComponent(projectId)}`; +} + +function editorProjectRecentSessionCacheKey(ownerUserId: string) { + return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent( + ownerUserId, + )}:recent`; } function projectSnapshotContainsInlineMedia(project: EditorProjectSnapshot) { @@ -169,21 +199,30 @@ function projectSnapshotContainsInlineMedia(project: EditorProjectSnapshot) { } } -function readEditorProjectSessionCache(projectId: string | null) { +function readEditorProjectSessionCache( + projectId: string | null, + ownerUserId: string | null | undefined, +): CachedEditorProjectSnapshot | null { const storage = getEditorProjectSessionStorage(); - if (!storage) { + const normalizedOwnerUserId = ownerUserId?.trim(); + if (!storage || !normalizedOwnerUserId) { return null; } const key = projectId - ? editorProjectSessionCacheKey(projectId) - : EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY; + ? editorProjectSessionCacheKey(normalizedOwnerUserId, projectId) + : editorProjectRecentSessionCacheKey(normalizedOwnerUserId); try { const rawValue = storage.getItem(key); if (!rawValue) { return null; } const cached = JSON.parse(rawValue) as Partial; - if (!cached.project || typeof cached.cachedAt !== 'number') { + if ( + !cached.project || + typeof cached.cachedAt !== 'number' || + cached.ownerUserId !== normalizedOwnerUserId || + typeof cached.revision !== 'number' + ) { storage.removeItem(key); return null; } @@ -199,7 +238,12 @@ function readEditorProjectSessionCache(projectId: string | null) { storage.removeItem(key); return null; } - return cached.project; + return { + project: cached.project, + cachedAt: cached.cachedAt, + ownerUserId: cached.ownerUserId, + revision: cached.revision, + }; } catch { try { storage.removeItem(key); @@ -210,20 +254,35 @@ function readEditorProjectSessionCache(projectId: string | null) { } } -function removeEditorProjectSessionCache(projectId: string) { +function removeEditorProjectSessionCache( + projectId: string, + ownerUserId: string | null | undefined, +) { const storage = getEditorProjectSessionStorage(); - if (!storage) { + const normalizedOwnerUserId = ownerUserId?.trim(); + if (!storage || !normalizedOwnerUserId) { return; } try { - storage.removeItem(editorProjectSessionCacheKey(projectId)); + storage.removeItem( + editorProjectSessionCacheKey(normalizedOwnerUserId, projectId), + ); } catch { // ignore storage cleanup errors } } -function writeEditorProjectSessionCache(project: EditorProjectSnapshot) { - if (projectSnapshotContainsInlineMedia(project)) { +function writeEditorProjectSessionCache( + project: EditorProjectSnapshot, + ownerUserId: string | null | undefined, + revision: number | null | undefined = project.canvas?.revision, +) { + const normalizedOwnerUserId = ownerUserId?.trim(); + if ( + !normalizedOwnerUserId || + typeof revision !== 'number' || + projectSnapshotContainsInlineMedia(project) + ) { return; } const storage = getEditorProjectSessionStorage(); @@ -233,14 +292,19 @@ function writeEditorProjectSessionCache(project: EditorProjectSnapshot) { const cached: CachedEditorProjectSnapshot = { project, cachedAt: Date.now(), + ownerUserId: normalizedOwnerUserId, + revision, }; try { const serialized = JSON.stringify(cached); storage.setItem( - editorProjectSessionCacheKey(project.projectId), + editorProjectSessionCacheKey(normalizedOwnerUserId, project.projectId), + serialized, + ); + storage.setItem( + editorProjectRecentSessionCacheKey(normalizedOwnerUserId), serialized, ); - storage.setItem(EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, serialized); } catch { // ignore quota or privacy mode failures } @@ -326,12 +390,32 @@ export function useImageCanvasProjectPersistence({ }: ImageCanvasProjectPersistenceOptions) { const projectIdRef = useRef(null); const projectRevisionRef = useRef(null); + const currentUserIdRef = useRef(currentUserId); + currentUserIdRef.current = currentUserId; + const canAccessProtectedDataRef = useRef(canAccessProtectedData); + canAccessProtectedDataRef.current = canAccessProtectedData; + const authoritativeProjectIdRef = useRef(null); + const hasAuthoritativeProjectSnapshotRef = useRef(false); + const acceptedAuthoritativeSnapshotSequenceRef = useRef(0); + const lastAuthoritativeOwnerUserIdRef = useRef( + undefined, + ); + const lastAuthoritativeProjectIdRef = useRef(null); + const lastAuthoritativeRevisionRef = useRef(null); + const projectAuthorityEpochRef = useRef(0); const applyProjectSnapshotRef = useRef< - ((project: EditorProjectSnapshot) => void) | null + | (( + project: EditorProjectSnapshot, + options?: ApplyProjectSnapshotOptions, + ) => boolean) + | null >(null); const pendingProjectResourceLayersRef = useRef( [], ); + const pendingCreatedProjectResourceLayersRef = useRef< + PendingCreatedProjectResourceLayer[] + >([]); const pendingProjectLayoutSaveRef = useRef( null, ); @@ -373,17 +457,41 @@ export function useImageCanvasProjectPersistence({ if (!pendingSave) { return; } + const expectedRevision = + pendingSave.attemptExpectedRevision ?? projectRevisionRef.current; + if ( + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== pendingSave.projectId || + expectedRevision === null + ) { + pendingProjectLayoutSaveRef.current = null; + return; + } pendingProjectLayoutSaveRef.current = null; isProjectLayoutSaveRunningRef.current = true; + const saveAuthorityEpoch = projectAuthorityEpochRef.current; + const saveOwnerUserId = currentUserIdRef.current; + const attemptedSave: PendingProjectLayoutSave = { + ...pendingSave, + attemptExpectedRevision: expectedRevision, + }; + const saveStillBelongsToCurrentAuthority = () => + projectAuthorityEpochRef.current === saveAuthorityEpoch && + currentUserIdRef.current === saveOwnerUserId && + hasAuthoritativeProjectSnapshotRef.current && + authoritativeProjectIdRef.current === pendingSave.projectId; let runNextSave = false; - const expectedRevision = projectRevisionRef.current; void saveEditorProjectLayout(pendingSave.projectId, { ...pendingSave.input, - ...(expectedRevision === null ? {} : { expectedRevision }), + expectedRevision, }) .then((result) => { - if (result && typeof result.revision === 'number') { + if ( + saveStillBelongsToCurrentAuthority() && + result && + typeof result.revision === 'number' + ) { projectRevisionRef.current = Math.max( projectRevisionRef.current ?? 0, result.revision, @@ -392,29 +500,64 @@ export function useImageCanvasProjectPersistence({ runNextSave = Boolean(pendingProjectLayoutSaveRef.current); }) .catch(async (error: unknown) => { + if (!saveStillBelongsToCurrentAuthority()) { + runNextSave = Boolean(pendingProjectLayoutSaveRef.current); + return; + } if (isEditorAuthError(error)) { openEditorLoginModal(); return; } if (isEditorProjectRevisionConflict(error)) { - try { - const latestProject = await loadEditorProject(pendingSave.projectId); - writeEditorProjectSessionCache(latestProject); + const applyLatestProject = ( + latestProject: EditorProjectSnapshot, + ) => { + if (!saveStillBelongsToCurrentAuthority()) { + return; + } applyProjectSnapshotRef.current?.(latestProject); + }; + const scheduleAuthoritativeReload = (retryCount: number) => { + if ( + !saveStillBelongsToCurrentAuthority() || + retryCount >= 3 || + saveTimerRef.current !== null + ) { + return; + } + saveTimerRef.current = window.setTimeout( + () => { + saveTimerRef.current = null; + if (!saveStillBelongsToCurrentAuthority()) { + return; + } + void loadEditorProject(pendingSave.projectId) + .then(applyLatestProject) + .catch((reloadError: unknown) => { + if (!saveStillBelongsToCurrentAuthority()) { + return; + } + if (isEditorAuthError(reloadError)) { + openEditorLoginModal(); + return; + } + scheduleAuthoritativeReload(retryCount + 1); + }); + }, + Math.min(1_000 * 2 ** retryCount, 8_000), + ); + }; + try { + const latestProject = await loadEditorProject( + pendingSave.projectId, + ); + applyLatestProject(latestProject); } catch (reloadError: unknown) { if (isEditorAuthError(reloadError)) { openEditorLoginModal(); return; } - if (!pendingProjectLayoutSaveRef.current) { - pendingProjectLayoutSaveRef.current = pendingSave; - } - if (saveTimerRef.current === null) { - saveTimerRef.current = window.setTimeout(() => { - saveTimerRef.current = null; - runPendingProjectLayoutSave(); - }, 1_000); - } + scheduleAuthoritativeReload(0); } return; } @@ -428,7 +571,7 @@ export function useImageCanvasProjectPersistence({ const transportRetries = pendingSave.transportRetries ?? 0; if (transportRetries < 3) { pendingProjectLayoutSaveRef.current = { - ...pendingSave, + ...attemptedSave, transportRetries: transportRetries + 1, }; if (saveTimerRef.current === null) { @@ -558,9 +701,20 @@ export function useImageCanvasProjectPersistence({ const queueProjectLayoutSave = useCallback( ( nextProjectId: string, - input: Parameters[1], + input: Omit< + Parameters[1], + 'expectedRevision' + >, options: { delayMs?: number } = {}, ) => { + const revision = projectRevisionRef.current; + if ( + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== nextProjectId || + revision === null + ) { + return; + } pendingProjectLayoutSaveRef.current = { projectId: nextProjectId, input, @@ -573,7 +727,11 @@ export function useImageCanvasProjectPersistence({ layers: refs.layersRef.current, }); if (sessionSnapshot) { - writeEditorProjectSessionCache(sessionSnapshot); + writeEditorProjectSessionCache( + sessionSnapshot, + currentUserId, + revision, + ); } if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); @@ -601,16 +759,83 @@ export function useImageCanvasProjectPersistence({ ); runPendingProjectLayoutSave(); }, - [persistProjectCoverSnapshot, refs.layersRef, runPendingProjectLayoutSave], + [ + currentUserId, + persistProjectCoverSnapshot, + refs.layersRef, + runPendingProjectLayoutSave, + ], + ); + + const applyCreatedProjectResourceLayer = useCallback( + (pendingLayer: PendingCreatedProjectResourceLayer) => { + if ( + currentUserIdRef.current !== pendingLayer.ownerUserId || + projectIdRef.current !== pendingLayer.projectId || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== pendingLayer.projectId + ) { + return false; + } + const { layer, options } = pendingLayer; + options.onCreated?.(pendingLayer.resourceId); + const currentLayers = refs.layersRef.current; + const currentLayer = currentLayers.find( + (candidate) => candidate.id === layer.id, + ); + const snapshotLayer = options.snapshotLayers?.find( + (candidate) => candidate.id === layer.id, + ); + const layerToPersist = currentLayer + ? { ...currentLayer, resourceId: pendingLayer.resourceId } + : (options.restoreMissingLayer || + acceptedAuthoritativeSnapshotSequenceRef.current !== + pendingLayer.authoritativeSnapshotSequence) && + snapshotLayer + ? { ...snapshotLayer, resourceId: pendingLayer.resourceId } + : null; + if (!layerToPersist) { + return true; + } + const nextLayers = currentLayer + ? currentLayers.map((candidate) => + candidate.id === layer.id ? layerToPersist : candidate, + ) + : [...currentLayers, layerToPersist]; + refs.layersRef.current = nextLayers; + setLayers(nextLayers); + queueProjectLayoutSave(pendingLayer.projectId, { + viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current), + layers: serializeCanvasLayout({ + layers: nextLayers, + canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, + canvasBackgroundColor: refs.canvasBackgroundColorRef.current, + }), + }); + return true; + }, + [queueProjectLayoutSave, refs, setLayers], ); const createProjectResourceForLayer = useCallback( (layer: CanvasLayer, options: ProjectResourceOptions = {}) => { const readyProjectId = projectIdRef.current; - if (!readyProjectId) { - pendingProjectResourceLayersRef.current.push({ layer, options }); + if ( + !readyProjectId || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== readyProjectId + ) { + pendingProjectResourceLayersRef.current.push({ + layer, + options: { ...options, restoreMissingLayer: true }, + ownerUserId: currentUserIdRef.current, + targetProjectId: readyProjectId, + }); return; } + const requestOwnerUserId = currentUserIdRef.current; + const requestAuthoritativeSnapshotSequence = + acceptedAuthoritativeSnapshotSequenceRef.current; const imageSrc = resolveProjectResourceCreateImageSrc(layer); if (!imageSrc) { return; @@ -633,43 +858,23 @@ export function useImageCanvasProjectPersistence({ generationInputs: layer.generationInputs, }) .then((resource) => { - options.onCreated?.(resource.resourceId); - const layerWithResourceId = { - ...layer, + const pendingLayer: PendingCreatedProjectResourceLayer = { + projectId: readyProjectId, + ownerUserId: requestOwnerUserId, + authoritativeSnapshotSequence: + requestAuthoritativeSnapshotSequence, + layer, + options, resourceId: resource.resourceId, }; - const currentLayers = refs.layersRef.current; - const nextLayers = currentLayers.some( - (currentLayer) => currentLayer.id === layer.id, - ) - ? currentLayers.map((currentLayer) => - currentLayer.id === layer.id - ? layerWithResourceId - : currentLayer, - ) - : options.snapshotLayers?.some( - (snapshotLayer) => snapshotLayer.id === layer.id, - ) - ? options.snapshotLayers.map((snapshotLayer) => - snapshotLayer.id === layer.id - ? layerWithResourceId - : snapshotLayer, - ) - : currentLayers; - refs.layersRef.current = nextLayers; - setLayers(nextLayers); - if (nextLayers.length) { - queueProjectLayoutSave(readyProjectId, { - viewport: viewportToCanvasDisplayViewport( - refs.viewportRef.current, - ), - layers: serializeCanvasLayout({ - layers: nextLayers, - canvasGenerationDialogs: - refs.canvasGenerationDialogsRef.current, - canvasBackgroundColor: refs.canvasBackgroundColorRef.current, - }), - }); + if ( + currentUserIdRef.current !== requestOwnerUserId || + projectIdRef.current !== readyProjectId + ) { + return; + } + if (!applyCreatedProjectResourceLayer(pendingLayer)) { + pendingCreatedProjectResourceLayersRef.current.push(pendingLayer); } }) .catch((error: unknown) => { @@ -678,11 +883,79 @@ export function useImageCanvasProjectPersistence({ } }); }, - [openEditorLoginModal, queueProjectLayoutSave, refs, setLayers], + [applyCreatedProjectResourceLayer, openEditorLoginModal], + ); + + const drainPendingProjectResourceLayers = useCallback( + (readyProjectId: string) => { + const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); + pendingLayers.forEach((pendingLayer) => { + if ( + pendingLayer.ownerUserId === currentUserIdRef.current && + (pendingLayer.targetProjectId === null || + pendingLayer.targetProjectId === readyProjectId) + ) { + createProjectResourceForLayer( + pendingLayer.layer, + pendingLayer.options, + ); + } + }); + const pendingCreatedLayers = + pendingCreatedProjectResourceLayersRef.current.splice(0); + pendingCreatedLayers.forEach((pendingLayer) => { + applyCreatedProjectResourceLayer(pendingLayer); + }); + }, + [applyCreatedProjectResourceLayer, createProjectResourceForLayer], ); const applyProjectSnapshot = useCallback( - (project: EditorProjectSnapshot) => { + ( + project: EditorProjectSnapshot, + { + authoritative = true, + allowProjectSwitch = false, + }: ApplyProjectSnapshotOptions = {}, + ) => { + if (authoritative) { + const activeProjectId = projectIdRef.current; + const incomingRevision = project.canvas?.revision; + const hasCurrentProjectAuthority = + hasAuthoritativeProjectSnapshotRef.current && + authoritativeProjectIdRef.current === project.projectId; + const canResumeSameUserProjectAuthority = + !hasCurrentProjectAuthority && + canAccessProtectedDataRef.current && + activeProjectId === project.projectId && + lastAuthoritativeOwnerUserIdRef.current === currentUserId && + lastAuthoritativeProjectIdRef.current === project.projectId && + typeof incomingRevision === 'number'; + const lastKnownRevision = + lastAuthoritativeOwnerUserIdRef.current === currentUserId && + lastAuthoritativeProjectIdRef.current === project.projectId + ? lastAuthoritativeRevisionRef.current + : null; + const minimumAcceptedRevision = Math.max( + projectRevisionRef.current ?? 0, + lastKnownRevision ?? 0, + ); + if ( + currentUserIdRef.current !== currentUserId || + (!allowProjectSwitch && + !hasCurrentProjectAuthority && + !canResumeSameUserProjectAuthority) || + (!allowProjectSwitch && + activeProjectId !== null && + activeProjectId !== project.projectId) || + (activeProjectId === project.projectId && + (typeof incomingRevision !== 'number' || + incomingRevision < minimumAcceptedRevision)) + ) { + return false; + } + acceptedAuthoritativeSnapshotSequenceRef.current += 1; + } clearPendingProjectLayoutSave(); skipNextProjectLayoutSaveRef.current = true; if (projectIdRef.current !== project.projectId) { @@ -690,7 +963,20 @@ export function useImageCanvasProjectPersistence({ coverSnapshotUploadRequestRef.current += 1; } projectIdRef.current = project.projectId; - projectRevisionRef.current = project.canvas?.revision ?? null; + if (authoritative) { + const revision = project.canvas?.revision; + const hasRevision = typeof revision === 'number'; + projectRevisionRef.current = hasRevision ? revision : null; + authoritativeProjectIdRef.current = hasRevision + ? project.projectId + : null; + hasAuthoritativeProjectSnapshotRef.current = hasRevision; + if (hasRevision) { + lastAuthoritativeOwnerUserIdRef.current = currentUserId; + lastAuthoritativeProjectIdRef.current = project.projectId; + lastAuthoritativeRevisionRef.current = revision; + } + } setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; projectTitleRef.current = nextProjectTitle; @@ -739,6 +1025,18 @@ export function useImageCanvasProjectPersistence({ applyCanvasBackgroundColor( canvasBackgroundColor ?? DEFAULT_CANVAS_BACKGROUND_COLOR, ); + const projectIsAuthoritative = + authoritative && + hasAuthoritativeProjectSnapshotRef.current && + authoritativeProjectIdRef.current === project.projectId; + if (projectIsAuthoritative) { + writeEditorProjectSessionCache( + project, + currentUserId, + project.canvas?.revision, + ); + } + return projectIsAuthoritative; }, [ applyCanvasBackgroundColor, @@ -774,10 +1072,22 @@ export function useImageCanvasProjectPersistence({ ); useEffect(() => { + projectAuthorityEpochRef.current += 1; + coverSnapshotUploadRequestRef.current += 1; if (!canAccessProtectedData) { + hasAuthoritativeProjectSnapshotRef.current = false; + authoritativeProjectIdRef.current = null; + projectRevisionRef.current = null; + pendingCreatedProjectResourceLayersRef.current = []; + clearPendingProjectLayoutSave(); setIsProjectReady(false); return undefined; } + hasAuthoritativeProjectSnapshotRef.current = false; + authoritativeProjectIdRef.current = null; + projectRevisionRef.current = null; + clearPendingProjectLayoutSave(); + setIsProjectReady(false); let cancelled = false; const projectIdFromQuery = typeof window === 'undefined' @@ -785,9 +1095,12 @@ export function useImageCanvasProjectPersistence({ : new URLSearchParams(window.location.search) .get('projectid') ?.trim() || null; - const cachedProject = readEditorProjectSessionCache(projectIdFromQuery); + const cachedProject = readEditorProjectSessionCache( + projectIdFromQuery, + currentUserId, + ); if (cachedProject) { - applyProjectSnapshot(cachedProject); + applyProjectSnapshot(cachedProject.project, { authoritative: false }); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) @@ -798,12 +1111,24 @@ export function useImageCanvasProjectPersistence({ if (cancelled) { return; } - applyProjectSnapshot(project); - writeEditorProjectSessionCache(project); - const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); - pendingLayers.forEach(({ layer, options }) => { - createProjectResourceForLayer(layer, options); + const projectIsAuthoritative = applyProjectSnapshot(project, { + allowProjectSwitch: true, }); + if (!projectIsAuthoritative) { + const activeAuthoritativeProjectId = authoritativeProjectIdRef.current; + if ( + hasAuthoritativeProjectSnapshotRef.current && + activeAuthoritativeProjectId !== null && + activeAuthoritativeProjectId === projectIdRef.current + ) { + drainPendingProjectResourceLayers(activeAuthoritativeProjectId); + setIsProjectReady(true); + } else { + setIsProjectReady(false); + } + return; + } + drainPendingProjectResourceLayers(project.projectId); setIsProjectReady(true); }) .catch((error: unknown) => { @@ -818,7 +1143,7 @@ export function useImageCanvasProjectPersistence({ return; } if (projectIdFromQuery && isEditorProjectAccessError(error)) { - removeEditorProjectSessionCache(projectIdFromQuery); + removeEditorProjectSessionCache(projectIdFromQuery, currentUserId); if (onProjectAccessLost) { onProjectAccessLost(); return; @@ -833,7 +1158,10 @@ export function useImageCanvasProjectPersistence({ }, [ canAccessProtectedData, applyProjectSnapshot, + clearPendingProjectLayoutSave, createProjectResourceForLayer, + currentUserId, + drainPendingProjectResourceLayers, onProjectAccessLost, openEditorLoginModal, ]); @@ -848,7 +1176,13 @@ export function useImageCanvasProjectPersistence({ }, []); useEffect(() => { - if (!projectId || !isProjectReady) { + if ( + !projectId || + !isProjectReady || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== projectId || + projectRevisionRef.current === null + ) { return undefined; } if (skipNextProjectLayoutSaveRef.current) { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 41a0b0c87..fb213d9cb 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -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; };