From b49bf9c9845752889f2e3996354479f3b8c9572f Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 20 Jul 2026 20:45:15 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E7=94=BB=E5=B8=83=E6=8C=81=E4=B9=85=E5=8C=96=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=8E=86=E5=8F=B2=E8=B5=84=E6=BA=90=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将旧布局上限统一提升至2MiB并接入revision CAS保存 新增画布图层、生成对话框和迁移状态结构化存储 新增migration operator保护的回填、激活和回滚脚本 新增历史图片映射及音频资源恢复的定向dry-run/apply流程 补齐前端保存队列、OpenAPI、Bindings、发布归档与回归测试 --- .../genarrative-external-v1.openapi.json | 54 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 31 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- ...片画布结构化持久化与迁移回滚方案-2026-07-19.md | 106 + .../Jenkinsfile.production-stdb-module-build | 2 +- ...Jenkinsfile.production-stdb-module-publish | 2 +- package.json | 2 + scripts/check-production-ops-guardrails.mjs | 40 + ...spacetime-migrate-editor-canvas-layout.mjs | 123 + scripts/spacetime-migration-common.mjs | 64 + scripts/spacetime-migration-common.test.ts | 66 + ...acetime-repair-editor-canvas-resources.mjs | 671 +++ ...ime-repair-editor-canvas-resources.test.ts | 477 ++ .../crates/api-server/src/editor_project.rs | 170 +- .../api-server/src/external_editor_api.rs | 46 +- .../spacetime-client/src/editor_project.rs | 47 + server-rs/crates/spacetime-client/src/lib.rs | 3 +- .../crates/spacetime-client/src/mapper.rs | 7 +- .../src/mapper/editor_project.rs | 63 + .../spacetime-client/src/module_bindings.rs | 130 +- ...itor_canvas_layout_and_return_procedure.rs | 59 + ...itor_canvas_layout_and_return_procedure.rs | 59 + .../editor_canvas_generation_dialog_table.rs | 169 + .../editor_canvas_generation_dialog_type.rs | 108 + .../editor_canvas_layer_table.rs | 161 + .../editor_canvas_layer_type.rs | 122 + ...itor_canvas_layout_migration_input_type.rs | 18 + ..._layout_migration_procedure_result_type.rs | 19 + ...r_canvas_layout_migration_snapshot_type.rs | 25 + .../editor_canvas_layout_migration_table.rs | 169 + .../editor_canvas_layout_migration_type.rs | 104 + ...vas_missing_resource_restore_input_type.rs | 18 + ...editor_canvas_resource_remap_input_type.rs | 18 + ...ditor_canvas_resource_repair_input_type.rs | 27 + ...s_resource_repair_procedure_result_type.rs | 19 + ...or_canvas_resource_repair_snapshot_type.rs | 21 + .../editor_canvas_snapshot_type.rs | 3 + .../src/module_bindings/editor_canvas_type.rs | 12 + ...editor_project_layout_save_v_2_ack_type.rs | 18 + ...itor_project_layout_save_v_2_input_type.rs | 22 + ...t_layout_save_v_2_procedure_result_type.rs | 19 + ...r_canvas_resources_and_return_procedure.rs | 59 + ...itor_canvas_layout_and_return_procedure.rs | 59 + ...editor_project_layout_v_2_ack_procedure.rs | 59 + ...project_layout_v_2_and_return_procedure.rs | 59 + .../src/asset_metadata/objects.rs | 11 + .../src/editor_project_storage.rs | 4093 ++++++++++++++++- .../crates/spacetime-module/src/migration.rs | 29 + .../useImageCanvasProjectPersistence.test.tsx | 263 ++ .../useImageCanvasProjectPersistence.ts | 89 +- .../image-editor/editorProjectClient.test.ts | 4 + .../image-editor/editorProjectClient.ts | 6 + 52 files changed, 7911 insertions(+), 116 deletions(-) create mode 100644 docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md create mode 100644 scripts/spacetime-migrate-editor-canvas-layout.mjs create mode 100644 scripts/spacetime-repair-editor-canvas-resources.mjs create mode 100644 scripts/spacetime-repair-editor-canvas-resources.test.ts create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_missing_resource_restore_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_remap_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_ack_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 5c3559d43..7d76b8e11 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -503,6 +503,9 @@ }, "404": { "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" } } } @@ -1327,6 +1330,16 @@ } } }, + "Conflict": { + "description": "画布 revision 冲突,需重新读取最新快照后合并或重试", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "UpstreamError": { "description": "上游生成服务失败", "content": { @@ -1755,8 +1768,17 @@ "$ref": "#/components/schemas/EditorCanvasViewport" }, "layers": { - "type": "object", - "description": "画布图层 JSON,最大约 256KB。" + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "description": "兼容画布布局 JSON,最大 2 MiB;服务端会拆分为结构化图层与生成对话框行。" + }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "可选的画布 revision CAS;不匹配时返回 409。" } }, "additionalProperties": false @@ -2110,7 +2132,11 @@ "$ref": "#/components/schemas/EditorCanvasViewport" }, "layers": { - "type": "object" + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } }, "resources": { "type": "array", @@ -2132,6 +2158,8 @@ "title", "viewport", "layers", + "revision", + "layoutStorageVersion", "createdAt", "updatedAt" ], @@ -2149,7 +2177,25 @@ "$ref": "#/components/schemas/EditorCanvasViewport" }, "layers": { - "type": "object" + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + } + }, + "revision": { + "type": "integer", + "minimum": 0 + }, + "layoutStorageVersion": { + "type": "integer", + "minimum": 0 + }, + "backgroundColor": { + "type": [ + "string", + "null" + ] }, "createdAt": { "type": "string", diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 61815353a..9b3e16efe 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -86,7 +86,7 @@ npm run check:server-rs-ddd - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 - `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。 - 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。 -- 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、`editor_canvas.layers_json` 或 `external_generation_job` 的业务真相。 +- 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、结构化画布表或 `external_generation_job` 的业务真相。未激活结构化存储的 canvas 才继续以 `editor_canvas.layers_json` 作为 legacy 布局真相。 ### 创作 / 游玩统一流程主干 @@ -286,7 +286,7 @@ npm run check:server-rs-ddd - Rust 结构体:`ExternalGenerationJob` - 源码:`server-rs/crates/spacetime-module/src/external_generation.rs` -- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 +- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表。结构化 canvas 激活后,当前 worker completion 先以读取时 canvas revision 执行 CAS,并发冲突时拒绝覆盖并保留可诊断失败;目标是进一步收口为受 lease 栅栏保护的单事务幂等写入 `editor_project_resource`、结果 `editor_canvas_layer`、`editor_canvas_generation_dialog` 终态和 canvas revision。未激活 canvas 在 2 MiB 上限内继续走 legacy `editor_canvas.layers_json` 兼容写回。前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。 - 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。 - 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。 @@ -531,21 +531,42 @@ npm run check:server-rs-ddd - Rust 结构体:`EditorProject` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布和资源元数据。 +- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与画布数据已拆到 `editor_canvas` 及其结构化子表,旧 project layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布、结构化画布行、迁移状态和资源元数据。 - 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程和项目页工程列表。 ### `editor_canvas` - Rust 结构体:`EditorCanvas` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布数据表,归属于 `editor_project`,保存默认画布的 viewport typed columns、图层布局 JSON、owner 和时间戳;当前编辑器读取 / 保存 project 的默认 canvas,后续支持一个工程多个 canvas。 +- 说明:图片画布根表,归属于 `editor_project`,保存默认画布的 viewport typed columns、owner、时间戳与当前 revision;当前编辑器读取 / 保存 project 的默认 canvas,后续支持一个工程多个 canvas。`layers_json` 继续保留,legacy canvas 在统一 **2 MiB** 上限内以它作为布局真相;`editor_canvas_layout_migration` 激活 structured 后,图层和生成对话框分别以 `editor_canvas_layer`、`editor_canvas_generation_dialog` 为权威,`layers_json` 只作为存量迁移输入和受限回滚载体。所有结构化 mutation 必须以 `expected_revision` 做 CAS,成功事务只递增一次 revision。 - 索引:`by_editor_canvas_project_id`、`by_editor_canvas_owner_user_id`。 +### `editor_canvas_layer` + +- Rust 结构体:`EditorCanvasLayer` +- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` +- 说明:图片画布结构化图层表,一行保存一个 layer 的 canvas / project / owner 归属、几何、层级顺序、分组、hidden / locked / flip 状态与 `resource_id`。V1 的 `item_json` 只保留未结构化扩展字段,单行最大 512 KiB;快照以 typed 列重组,不得把完整图层 JSON 当作平行真相。媒体业务真相仍由 `editor_project_resource` / `editor_asset` 持有。历史 layout 的 `sourceResourceId == resourceId` 属于无意义自引用,迁移时按资源表真相剥离;其他资源字段冲突继续 fail-closed。唯一存量缺资源例外是已缺资源行、但帧与预览均为稳定站内对象路径的 `local-* + generated + image-sequence` 历史角色动作图层:迁移保留其有界媒体扩展并纳入 canonical hash;active 后只能续存同一行且扩展不可变,不能新增或篡改。其他缺资源图层继续 fail-closed。当前站内写入通过 revision CAS 把兼容布局事务性拆成行;后续再将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation。 +- 索引:按 canvas 读取完整结构化快照,按 project 做级联清理;owner 保留在行内用于归属校验。同一 canvas 的 layer id 必须稳定且唯一。 + +### `editor_canvas_generation_dialog` + +- Rust 结构体:`EditorCanvasGenerationDialog` +- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` +- 说明:图片画布生成对话框表,保存 canvas / project / owner、生成模式、状态、可选 source / generated layer、占位几何和有界扩展字段;typed 列为快照真相,`dialog_json` 只保留未结构化参数。当前 worker completion 以读取时 revision 调用 CAS 保存,冲突时拒绝覆盖;`job_id + worker_id + lease_token` 栅栏下的资源 / layer / dialog / job 单事务完成仍是后续收口。 +- 索引:按 canvas 和 project 读取结构化行;当前未建立 external job 二级索引。 + +### `editor_canvas_layout_migration` + +- Rust 结构体:`EditorCanvasLayoutMigration` +- 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` +- 说明:每个 canvas 一行的结构化持久化控制与迁移审计表,保存 schema version、`backfilled / active / rolled_back` 状态、最后校验 revision、legacy / structured canonical hash、layer / dialog 数量、资源引用集合 hash 和激活 / 回滚时间。存量迁移固定执行幂等 `backfill → hash / 数量 / 资源引用核对 → revision CAS activate`;active 重入和 rollback 同样重新核对,不只按状态早返。回滚结果还必须不超过 2 MiB。三个 procedure 只允许 database migration operator 调用;`npm run spacetime:editor-canvas-layout:migrate` 默认 dry-run,显式 `--apply` 才写入。 +- 索引:`canvas_id` 唯一,一切模式切换仅允许受限迁移 procedure 执行;前端不得提交或推断存储模式。 + ### `editor_project_resource` - Rust 结构体:`EditorProjectResource` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和历史 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 `editor_canvas.layers_json` 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。`editor_canvas.layers_json` 只保存图层几何、层级、分组、资源引用和生成器对象;新写入不再把素材生成输入快照作为图层布局真相保存,旧布局字段只作为兼容兜底读取。 +- 说明:图片画布工程资源元数据表,保存已经放入某个 project 画布的上传 / 生成图片资源快照、OSS 引用、尺寸、来源类型、prompt、provider、task、源资源关系、`asset_kind`、`generation_inputs_json` 和历史 `public_showcase_enabled`。`asset_kind` 标记角色、图标、UI 设计图、视频、音频等素材类别;`generation_inputs_json` 保存用户可见生成输入快照,供图片信息页刷新后恢复。`public_showcase_enabled` 只保留旧接口兼容,不再作为 `/creation` 的 `陶泥儿精选` 事实源;精选公开改由账号级生成素材提交 `editor_showcase_asset` 审核决定。图片 / 图标 / UI 提取等生成 BFF 在请求携带 `project_id` 时负责创建该表记录并把 resource 快照返回前端;前端只保存稳定 `resource_id` 布局引用,不能把同一生成结果再次作为正式业务真相写入。项目封面快照也落在该表,使用 `asset_kind = project-cover-snapshot`、`source_type = uploaded` 和私有 OSS / asset object 引用,代表画布当前视口栅格化后的静态封面;项目列表和创作主页最近项目只读取最新封面快照资源,不在列表页根据 layout 临时拼画布。从账号级素材库把同一生成素材拖回同一项目画布时,后端优先复用同项目内同源同媒体资源,避免每个图层实例都插入新的资源行。账号级素材删除不级联删除该表,避免历史画布丢图。结构化 canvas 的几何、层级、分组和资源引用以 `editor_canvas_layer` 为权威,生成器对象以 `editor_canvas_generation_dialog` 为权威;legacy canvas 才在 2 MiB 上限内从 `editor_canvas.layers_json` 兼容读取。新写入不再把素材生成输入快照作为图层布局真相保存。历史普通图层缺资源只能由 migration operator 调用 `repair_editor_canvas_resources_and_return` 定向修复:procedure 每次只处理一个尚无迁移记录的 legacy canvas,校验 owner/project、revision、canvas/project 两份 raw layout SHA-256、精确 layer/resource/sourceResourceId、同工程替换资源与 private asset_object 谱系;图片只替换引用,音频只恢复经核验的 `420x120` 项目资源行。运维入口 `npm run spacetime:editor-canvas-resources:repair` 默认 dry-run,apply 必须绑定 plan SHA-256 并在成功后自动复核 already-repaired,禁止手工 SQL 绕过事务 guard。 - 索引:`by_editor_project_resource_project_id`、`by_editor_project_resource_owner_user_id`。 ### `editor_asset_folder` diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 5f19e904f..866f2e792 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -518,7 +518,7 @@ Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit. 同一 API release 随包依赖还必须包含 `scripts/check-pingora-release-readiness.mjs` 与 `scripts/check-pingora-canary-live.mjs`。前者在 current release 上以 `--release-runtime-only` 汇总运行时复核,后者支撑目标 Nginx canary live smoke;缺少任一脚本时不能进入直连切换窗口。 -`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build//spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。 +`Genarrative-Stdb-Module-Build` 的 Jenkins 归档产物必须包含 `build//spacetime_module.wasm`、`spacetime_module.wasm.sha256`、`release-manifest.json`、`scripts/deploy/production-stdb-publish.sh`、`scripts/deploy/production-runtime-writer-identity-rotate.mjs`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`scripts/spacetime-migration-common.mjs`、`scripts/spacetime-maintain-external-generation-jobs.mjs`、`scripts/spacetime-migrate-editor-canvas-layout.mjs` 和 `scripts/database-backup-to-oss.mjs`,不得包含 `migration-bootstrap-secret.txt` 或任何原始 bootstrap secret。`Genarrative-Stdb-Module-Build` 只接受 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 指向的受保护 Jenkins Secret File:构建 shell 从临时文件读取原始值,强制校验为 64 位十六进制,计算 SHA-256,随后只通过 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256` 注入 Rust 编译;WASM 因而只包含摘要,不包含可下载的原文,Stdb `release-manifest.json` 以 `migration_bootstrap_secret_sha256` 记录该非敏感摘要。`Genarrative-Stdb-Module-Publish` 只通过 `copyArtifacts` 复制上述非敏感产物,不在目标机器 checkout Git,并在发布阶段用同一个凭据 ID 再次挂载 Secret File;publish 必须再次校验 64 位十六进制、重算 SHA-256,并与 manifest 的 `migration_bootstrap_secret_sha256` 强制匹配后才可发布。Full Build 必须保证 Stdb Build / Publish 的 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 完全相同并把同一个 ID 同时透传,不能从构建 artifact 传 secret;ID 不同、manifest 缺摘要或摘要不匹配都必须在发布前失败。 三个 SCM Jenkinsfile 将 `MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID` 默认固定为 `genarrative-spacetime-bootstrap-secret-dev-file`。Secret File 的原文只存在于 Jenkins Credentials;credential ID、参数默认值和定时 / 发布行为以仓库 Jenkinsfile 为事实源,不能只改 Job UI,因为 Declarative Pipeline 下一次载入会重写参数定义。旧 Secret Text `genarrative-spacetime-bootstrap-secret-dev` 继续保留给 Database Import / Export,不得原地改类型或删除。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md new file mode 100644 index 000000000..8639b6569 --- /dev/null +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -0,0 +1,106 @@ +# 图片画布结构化持久化与迁移回滚方案 + +## 1. 背景与目标 + +图片画布当前把全部图层和生成对话框整体序列化到 `editor_canvas.layers_json`。该字段接近原 256 KiB 上限时,资源登记仍可成功,而布局保存会返回 `413`;任务完成后重新读取后端旧快照,会把前端尚未持久化的参考图和生成结果从画布移除。 + +本方案分两步处理: + +1. 先把 `api-server` 与 SpacetimeDB 的 legacy layout 校验统一提高到 **2 MiB**,为存量画布止血。HTTP 路由的 body envelope 必须覆盖 2 MiB payload 及 JSON 包装开销,不能只修改领域常量。 +2. 再把画布拆为结构化的 layer、generation dialog、layout migration/state 三张表;`editor_canvas.layers_json` 保留为旧数据、迁移输入和受限回滚载体,不再作为激活结构化存储后的权威读取来源。 + +2 MiB 是迁移窗口内的临时兼容上限,不是继续扩大整体 JSON 的长期容量方案。媒体正文、Data URL、生成输入快照仍不得进入画布布局;媒体和生成元数据继续以 `editor_project_resource` / `editor_asset` 为真相源。 + +## 2. 权威边界 + +结构化模式激活后,画布事实分工如下: + +| 数据 | 权威位置 | 说明 | +| --- | --- | --- | +| 工程归属、默认画布、viewport、当前 revision | `editor_project` / `editor_canvas` | `editor_canvas` 保留 `layers_json` legacy 列,但其内容不参与结构化模式下的正常写入仲裁 | +| 图层实例、几何、层级、分组、显示与锁定状态、资源引用 | `editor_canvas_layer` | 一行一个图层;模式专属的有界扩展字段可放 JSON,不能复制媒体正文或完整资源元数据 | +| 生成对话框、占位层、来源层、结果层、任务与状态 | `editor_canvas_generation_dialog` | 生成任务和 UI 对话框的持久关联,不再嵌入全量 layout JSON | +| 存储模式、迁移阶段、校验 hash、激活与回滚审计 | `editor_canvas_layout_migration` | 每个 canvas 一行,控制 legacy / structured 读取,不允许前端自行切换 | +| 图片、视频、音频等媒体及生成元数据 | `editor_project_resource` / `editor_asset` | layer/dialog 只保存稳定 ID 引用;资源创建成功不能被当作布局保存成功 | + +结构化快照由后端在同一 revision 下读取 `editor_canvas`、layer、dialog 和 layout migration state 后组装。前端只消费 BFF 快照,不直接订阅表、做 join 或推断迁移状态。 + +## 3. 表与写入契约 + +### 3.1 `editor_canvas_layer` + +保存 `layer_id`、`canvas_id`、`project_id`、`owner_user_id`、坐标、宽高、原始尺寸、层级顺序、可选 `group_id`、hidden / locked / flip 状态、`resource_id`、有界的未结构化扩展 JSON、创建与更新时间。查询有 canvas / project 索引;同一 canvas 的层级顺序由 `sort_order` 决定。 + +layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualPrompt / model / provider / taskId / objectKey / assetObjectId / sourceResourceId / assetKind / generationInputs / sourceType` 不得为方便展示而重复进入 `item_json` 或成为 layer 真相;迁移或结构化保存时先逐字段核对 `editor_project_resource`,缺少资源、字段冲突或无法无损重组时必须 fail-closed。历史 layout 若把 `sourceResourceId` 错写成当前图层自己的 `resourceId`,这是无意义的自引用,不作为 A/B 来源冲突:迁移时删除该重复字段并以项目资源表为真相;其他非空且不一致的来源 ID 继续拒绝。唯一存量缺资源例外是历史角色动作产生的自包含本地图层:`resourceId` 必须以 `local-` 开头、`sourceType=generated`、`mediaType=image-sequence`,至少包含一帧;帧序号必须从 1 连续递增,宽高必须是有限正数;所有 `imageSequenceFrames[].imageSrc` 及可选 `thumbnailSrc / previewVideoPath` 都必须是无 query / fragment、无路径回退段的站内根路径,且不能含 `data:`、`blob:`、HTTP 或签名 URL;可选帧 `objectKey` 必须与 `imageSrc` 去掉首斜杠后完全一致,图层级 `imageSrc / objectKey / assetObjectId` 必须为空,图层级 `src` 只允许为空或与首帧 `imageSrc` 完全一致。满足这些条件但资源行已不存在时,保留其有界媒体扩展和生成元数据以便前端从首帧恢复,不把这些字段从 canonical hash 中剥离;`sourceResourceId` 目标仍存在时必须属于同工程和 owner,目标已删除时保留原引用参与 hash,不据此伪造资源行。该例外不适用于普通图片、视频、音频、非本地 ID、空帧序列或已有资源字段冲突。存量 `assetKind / generationInputs` 仅允许在资源行尚未记录时由 apply 事务补入资源表,dry-run 只计算预览而不写库。前端读取项目快照时继续使用随项目返回的 resources 按 `resource_id` hydrate,兼容现有画布快照语义。 + +### 3.2 `editor_canvas_generation_dialog` + +保存 `dialog_id`、`canvas_id`、`project_id`、`owner_user_id`、生成模式、状态、可选 `source_layer_id`、`generated_layer_id`、占位几何、有界扩展 JSON、创建与更新时间。扩展 JSON 只保留未结构化的生成参数,`mode / status / source / generated / placeholder.x/y/width/height` 以 typed 列为真相;`placeholder.originalWidth / originalHeight`、其他未知 placeholder 字段、dialog 内未知字段及 generation-dialog 顶层未知字段必须原样保存在扩展 JSON。`canvas-settings` 暂无扩展列,出现未识别字段时直接拒绝迁移,不能静默丢弃。当前 external job 关联仍以 worker 任务为准,未在本表重复建立任务真相。 + +### 3.3 `editor_canvas_layout_migration` + +保存 `canvas_id`、schema version、`backfilled / active / rolled_back` 状态、最后校验 revision、legacy / structured canonical hash、layer / dialog 数量、资源引用集合 hash、迁移 / 激活 / 回滚时间。状态变更只允许已授权 database migration operator 调用 procedure 执行。procedure 错误作为调用结果和运维日志保留,不另外写入可能与失败事务脱节的 `last_error` 列。 + +### 3.4 revision CAS + +所有用户布局写入必须携带读取快照时获得的 `expectedRevision`。procedure 在事务中校验 canvas 当前 revision;不一致返回 `409`,前端应重载后端最新快照,不能只换上新 revision 就原样重放冲突前的整包布局。 + +前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后权威快照暂时加载失败时保留 pending save 并定时重新进入冲突恢复,不能等待用户再次拖动画布才恢复保存。 + +本次结构化 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 仍是兼容整包入口的上限。 + +### 3.5 worker 原子完成 + +worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存;发生并发变更时拒绝覆盖并让任务保留可诊断失败,不再静默覆盖用户布局。最终收口仍是受 `job_id + worker_id + lease_token` 栅栏保护的后端 procedure 在同一事务内: + +1. 校验 job、owner、project、canvas、dialog 和租约; +2. 幂等创建或确认 `editor_project_resource`; +3. 创建 / 替换结果 layer,并删除或更新占位 layer; +4. 把 dialog 更新为终态并关联 `generated_layer_id`; +5. 递增 canvas revision,最后才允许完成 external job。 + +重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。 + +## 4. 存量迁移 + +迁移按 canvas 执行 `backfill → hash 核对 → activate`,并保持幂等: + +1. **Backfill**:在短事务中读取 `editor_canvas.layers_json`、canvas revision 和更新时间;解析 legacy items,把普通图层与 generation dialog 分别归一到两张结构化表。按稳定 `canvas_id + item id` upsert,重复运行不能产生新 ID 或重复行。记录本次基准 revision 和 legacy canonical hash。 +2. **Hash 核对**:按固定字段顺序、数值归一规则和稳定 item 排序,把结构化行重组成 canonical legacy 语义;分别计算 layer 数、dialog 数、资源引用集合以及 SHA-256 canonical hash。raw JSON 的空白、对象 key 顺序和无意义默认值差异不作为不一致;任何不可识别字段必须保存在有界扩展字段中或使迁移失败,不能静默丢弃。项目资源存在时继续逐字段核对;除 `sourceResourceId == resourceId` 的历史自引用按资源表真相剥离外,其他 `sourceResourceId` 等冲突都拒绝迁移。只有上一节定义的缺资源自包含本地图片序列保留 layout 内元数据并参与 hash。 +3. **Activate**:只有 structured hash、数量和资源引用集合均匹配,且 `editor_canvas.revision` 仍等于 backfill 基准 revision 时,才以 CAS 把 layout migration state 切为 `active` 并把 canvas storage version 切到 structured。revision 已变化时丢弃本轮验证结果并重新 backfill;不得覆盖迁移期间的用户更新。 + +当前提供单项目、可重跑的运维入口:`npm run spacetime:editor-canvas-layout:migrate -- --database --server --project-id --owner-user-id --action `。默认 dry-run,只有显式追加 `--apply` 才写入;backfill dry-run 即使遇到尚无 `editor_canvas` 的旧工程,也只用 `editor_project` 构造内存预览,不创建 canvas、不补资源元数据。每个项目依次执行 backfill dry-run / apply 和 activate dry-run / apply,不在一个长事务中扫全表。脚本只调用受 migration operator 保护的 procedure,不直接修改生产表。 + +若全量 backfill 审计发现普通图层缺少 `editor_project_resource`,先使用定向资源修复入口:`npm run spacetime:editor-canvas-resources:repair -- --database --server --plan-file `。plan 必须是仓库外、当前用户持有、权限严格为 `0600` 的普通文件;每个 canvas 绑定 owner / project、expected revision、`editor_canvas.layers_json` 与 legacy `editor_project.layers_json` 两份原始字节 SHA-256,以及精确 layer/resource/sourceResourceId 或 asset_object 修复动作。默认逐 canvas dry-run;apply 必须追加 `--apply --confirm-plan-sha256 `,成功后脚本自动以同一 plan 再 dry-run,并要求全部返回 `already_repaired`。 + +`repair_editor_canvas_resources_and_return` 只允许 migration operator 调用,并且只修尚无迁移记录的 legacy canvas。图片动作仅在缺失旧 resourceId、精确旧 sourceResourceId、同工程唯一现存资源、layout 资源元数据与 private asset_object owner/objectKey/task 谱系全部一致时,替换 `resourceId` 并删除重复 `sourceResourceId`。音频动作仅在原 resourceId 全局不存在、图层与 private asset_object 的 owner/objectKey/source job/entity/content type/长度全部匹配时,恢复 `420x120` 的 `sound-effect` / `background-music` 项目资源行;资源 `asset_kind` 分别映射 asset_object 的 `editor_sound_effect` / `editor_background_music`。apply 在一个事务内插入资源、同步两份 legacy layout、递增一次 canvas revision;任一 guard 失败整画布回滚。plan、脚本输出、测试和文档均不得包含生产真实 ID。 + +## 5. 兼容与回滚 + +- 结构化代码上线后先保持 `legacy` 主读写,允许 schema 与 procedure 先行发布;确认新 API 可用后才开始 backfill 和逐 canvas 激活。 +- `layers_json` 在迁移和观察期内保留原值,不删除、不改名、不重排现有字段。激活结构化模式不立即清空 legacy JSON。 +- 回滚前由后端在一个一致 revision 下读取 layer 与 dialog,按 legacy schema 重组并计算 canonical hash。重组结果必须是合法 JSON 且 UTF-8 大小 **不超过 2 MiB**,随后在事务内写回 `editor_canvas.layers_json`、递增 revision、记录回滚审计并把 state 切回 `legacy`。对已处于 `rolled_back` 的重复回滚请求,也必须重新复核 revision、structured hash、layer/dialog 数量、资源引用集合以及 legacy/structured 一致性,任一漂移都 fail-closed。 +- 超过 2 MiB、包含无法降级字段、hash / 资源引用核对失败或 revision CAS 冲突时,回滚必须拒绝并保持 structured,禁止截断图层、丢弃 dialog 或只切换读取标志。生产二进制回滚应保留能读取 structured 的前向兼容版本,不能把超限 canvas 强行交给旧二进制。 +- 观察期结束前不删除三张结构化表或 legacy 列。后续是否停止生成 legacy 回滚快照、是否清理旧 JSON,需另行评审和迁移窗口,不随本次结构化上线自动执行。 + +## 6. 发布次序 + +1. 发布 2 MiB hotfix:先发布不变更 schema 的 SpacetimeDB 模块校验,再部署同一上限和足够 body envelope 的 `api-server`;确认 release manifest、服务状态和健康检查。 +2. 发布新增三表、revision / CAS、typed 快照重组与迁移 procedure 的 SpacetimeDB 模块;同步 migration table 白名单和生成绑定。此时保持 legacy 主读写。 +3. 部署可同时读取 legacy / structured 的 `spacetime-client` 与 `api-server`,再部署携带 revision 的前端。混合版本期间未激活 canvas 必须仍可正常编辑,旧 API 写 structured canvas 必须失败而不是无 CAS 覆盖。 +4. 先对测试账号和单个真实 canvas dry-run、backfill、核对、activate,再按批次扩大;持续观测 CAS conflict、迁移失败、快照缺资源和 worker completion 指标。 +5. 验收完成并经过观察期后,才把新 canvas 默认设为 structured。legacy 回滚能力继续保留。 + +SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端必须最后发布。任何阶段失败都停留在当前可读模式,不做跨版本破坏性清理。 + +## 7. 验收清单 + +- 2 MiB hotfix 后,原先约 254 KiB 的受影响画布和大于 256 KiB 的测试布局可保存并刷新恢复;超过 2 MiB 的 legacy layout 在 API 与 SpacetimeDB 两层都稳定拒绝,错误可观测。 +- backfill 重跑不会增加 layer / dialog 行,canonical hash、数量和资源引用集合一致;迁移中发生用户写入时 activate CAS 失败并安全重跑。 +- release 存量抽样中的缺资源 `local-*` 角色动作序列可无损 round-trip;同形状但空帧、相对路径、HTTP / 签名 URL、`data:` / `blob:` 引用必须拒绝。已有资源的 `sourceResourceId == resourceId` 历史自引用应按资源表真相安全剥离,其他来源 ID 或资源字段冲突仍必须拒绝。 +- release 全量审计暴露的普通缺资源行必须先通过定向 repair dry-run;图片只能复用同工程唯一资源,音频只能从已登记 private asset_object 恢复。修复后同一 plan 全部命中 `already_repaired`,再重跑全量 backfill dry-run,要求所有 canvas 均通过。 +- structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用和 dialog 状态;两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。 +- worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。 +- structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。 +- 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。 +- 完成 `npm run spacetime:generate`、`npm run check:spacetime-runtime-access`、`npm run check:spacetime-schema`、相关 Rust / API / 前端定向测试、`npm run check:encoding` 和 `git diff --check`。 diff --git a/jenkins/Jenkinsfile.production-stdb-module-build b/jenkins/Jenkinsfile.production-stdb-module-build index 3f3894c68..47d282eb8 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-build +++ b/jenkins/Jenkinsfile.production-stdb-module-build @@ -153,7 +153,7 @@ pipeline { stage('Archive') { steps { - archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true + archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm,build/${env.EFFECTIVE_BUILD_VERSION}/spacetime_module.wasm.sha256,build/${env.EFFECTIVE_BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", fingerprint: true } } diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 892b87e9f..f2222f1d5 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -93,7 +93,7 @@ pipeline { copyArtifacts( projectName: params.BUILD_JOB_NAME, selector: specific(params.BUILD_NUMBER_TO_DEPLOY), - filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/database-backup-to-oss.mjs", + filter: "build/${params.BUILD_VERSION}/spacetime_module.wasm,build/${params.BUILD_VERSION}/spacetime_module.wasm.sha256,build/${params.BUILD_VERSION}/release-manifest.json,scripts/deploy/production-stdb-publish.sh,scripts/deploy/production-runtime-writer-identity-rotate.mjs,scripts/deploy/maintenance-on.sh,scripts/deploy/maintenance-off.sh,scripts/spacetime-migration-common.mjs,scripts/spacetime-maintain-external-generation-jobs.mjs,scripts/spacetime-migrate-editor-canvas-layout.mjs,scripts/spacetime-repair-editor-canvas-resources.mjs,scripts/database-backup-to-oss.mjs", target: '.', fingerprintArtifacts: true ) diff --git a/package.json b/package.json index 6652d22fd..54fafb4c1 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "admin-web:preview": "npm --prefix apps/admin-web run preview --", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", "spacetime:external-generation:maintain": "node scripts/spacetime-maintain-external-generation-jobs.mjs", + "spacetime:editor-canvas-layout:migrate": "node scripts/spacetime-migrate-editor-canvas-layout.mjs", + "spacetime:editor-canvas-resources:repair": "node scripts/spacetime-repair-editor-canvas-resources.mjs", "spacetime:wechat-virtual-payment:reconcile": "node scripts/reconcile-wechat-virtual-payment-order.mjs", "check:wechat-virtual-payment-reconcile": "node scripts/check-wechat-virtual-payment-reconcile.mjs", "check:api-server-env": "node scripts/check-api-server-env.mjs", diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index bf3331dc6..931ad7244 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -521,6 +521,46 @@ const checks = [ includes: 'completed_before_micros: encodeSpacetimeCliOption(', reason: '历史 payload 截止时间必须编码为 CLI SATS Option,确保事故时间过滤可调用。', }, + { + file: 'scripts/spacetime-migrate-editor-canvas-layout.mjs', + includes: 'dry_run: !options.apply', + reason: '图片画布存量迁移必须默认 dry-run,只有显式 --apply 才写入。', + }, + { + file: 'scripts/spacetime-repair-editor-canvas-resources.mjs', + includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)', + reason: '图片画布资源修复必须默认 dry-run,只有显式 --apply 才写入。', + }, + { + file: 'scripts/spacetime-repair-editor-canvas-resources.mjs', + includes: '--confirm-plan-sha256', + reason: '图片画布资源修复 apply 必须绑定同一份 dry-run plan 摘要。', + }, + { + file: 'scripts/spacetime-repair-editor-canvas-resources.mjs', + includes: '(metadata.mode & 0o777) !== 0o600', + reason: '包含真实资源 ID 的修复 plan 必须使用仓库外 0600 文件。', + }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs', + reason: 'Stdb Build 必须归档图片画布存量迁移脚本。', + }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-publish', + includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs', + reason: 'Stdb Publish 必须从同一上游制品复制图片画布存量迁移脚本。', + }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-build', + includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs', + reason: 'Stdb Build 必须归档图片画布资源修复脚本。', + }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-publish', + includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs', + reason: 'Stdb Publish 必须从同一上游制品复制图片画布资源修复脚本。', + }, { file: 'scripts/deploy-rust-remote.sh', excludes: diff --git a/scripts/spacetime-migrate-editor-canvas-layout.mjs b/scripts/spacetime-migrate-editor-canvas-layout.mjs new file mode 100644 index 000000000..f466133b0 --- /dev/null +++ b/scripts/spacetime-migrate-editor-canvas-layout.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node + +import { + callSpacetimeProcedureViaCli, + ensureProcedureOk, +} from './spacetime-migration-common.mjs'; + +const ACTIONS = new Map([ + ['backfill', 'backfill_editor_canvas_layout_and_return'], + ['activate', 'activate_editor_canvas_layout_and_return'], + ['rollback', 'rollback_editor_canvas_layout_and_return'], +]); + +function usage() { + return `用法: + node scripts/spacetime-migrate-editor-canvas-layout.mjs \\ + --database --project-id --owner-user-id \\ + --action [--server ] [--apply] + +默认只执行 dry-run 校验,不修改数据;追加 --apply 才写入。 +必须使用已授权 database migration operator 的 spacetime CLI 登录态, +并显式传入 --server 或设置 GENARRATIVE_SPACETIME_SERVER。 + +存量项目的安全顺序: + 1. backfill dry-run + 2. backfill --apply + 3. activate dry-run + 4. activate --apply +需要回滚时先执行 rollback dry-run,确认 hash / revision / 2 MiB 门禁通过后再 --apply。`; +} + +function parseOptions(argv) { + const options = { + action: '', + apply: false, + database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', + ownerUserId: '', + passthrough: [], + projectId: '', + server: process.env.GENARRATIVE_SPACETIME_SERVER || '', + serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '', + }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${arg} 缺少参数值。`); + } + index += 1; + return value.trim(); + }; + if (arg === '--action') { + options.action = readValue(); + } else if (arg === '--database') { + options.database = readValue(); + } else if (arg === '--project-id') { + options.projectId = readValue(); + } else if (arg === '--owner-user-id') { + options.ownerUserId = readValue(); + } else if (arg === '--server') { + options.server = readValue(); + } else if (arg === '--server-url') { + options.serverUrl = readValue(); + } else if (arg === '--apply') { + options.apply = true; + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else if (arg === '--no-config' || arg === '--anonymous') { + options.passthrough.push(arg); + } else { + throw new Error(`未知参数: ${arg}`); + } + } + return options; +} + +try { + const options = parseOptions(process.argv.slice(2)); + if (options.help) { + console.log(usage()); + process.exit(0); + } + if (!options.database || !options.projectId || !options.ownerUserId) { + throw new Error('--database、--project-id 和 --owner-user-id 均为必填。'); + } + if (!options.server && !options.serverUrl) { + throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。'); + } + const procedureName = ACTIONS.get(options.action); + if (!procedureName) { + throw new Error('--action 只支持 backfill、activate 或 rollback。'); + } + const result = await callSpacetimeProcedureViaCli(options, procedureName, { + project_id: options.projectId, + owner_user_id: options.ownerUserId, + updated_at_micros: Date.now() * 1_000, + dry_run: !options.apply, + }); + ensureProcedureOk(result); + console.log( + JSON.stringify( + { + action: options.action, + applied: options.apply, + procedure: procedureName, + ...result, + }, + null, + 2, + ), + ); + if (!options.apply) { + console.log('dry-run 已通过;确认输出后追加 --apply 重跑同一动作。'); + } +} catch (error) { + console.error( + `[spacetime:editor-canvas-layout:migrate] ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exit(1); +} diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 0dc8d3fd1..435dda916 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -292,6 +292,36 @@ function normalizeProcedureResult(value, procedureName) { } function normalizeSatsProduct(value, procedureName) { + if ( + procedureName === 'repair_editor_canvas_resources_and_return' && + value.length === 3 + ) { + return { + ok: normalizeSatsValue(value[0]), + repair: normalizeEditorCanvasResourceRepairSnapshot( + normalizeSatsOption(value[1]), + ), + error_message: normalizeSatsOption(value[2]), + }; + } + + if ( + [ + 'backfill_editor_canvas_layout_and_return', + 'activate_editor_canvas_layout_and_return', + 'rollback_editor_canvas_layout_and_return', + ].includes(procedureName) && + value.length === 3 + ) { + return { + ok: normalizeSatsValue(value[0]), + migration: normalizeEditorCanvasLayoutMigrationSnapshot( + normalizeSatsOption(value[1]), + ), + error_message: normalizeSatsOption(value[2]), + }; + } + if ( procedureName === 'backfill_external_generation_job_summaries_and_return' && value.length === 8 @@ -357,6 +387,40 @@ function normalizeSatsProduct(value, procedureName) { }; } +function normalizeEditorCanvasLayoutMigrationSnapshot(value) { + if (!Array.isArray(value) || value.length !== 11) { + return value; + } + return { + canvas_id: normalizeSatsValue(value[0]), + project_id: normalizeSatsValue(value[1]), + source_layout_sha256: normalizeSatsValue(value[2]), + structured_layout_sha256: normalizeSatsValue(value[3]), + verified_revision: normalizeSatsValue(value[4]), + layer_count: normalizeSatsValue(value[5]), + dialog_count: normalizeSatsValue(value[6]), + resource_refs_sha256: normalizeSatsValue(value[7]), + migration_version: normalizeSatsValue(value[8]), + status: normalizeSatsValue(value[9]), + revision: normalizeSatsValue(value[10]), + }; +} + +function normalizeEditorCanvasResourceRepairSnapshot(value) { + if (!Array.isArray(value) || value.length !== 7) { + return value; + } + return { + dry_run: normalizeSatsValue(value[0]), + already_repaired: normalizeSatsValue(value[1]), + matched_layer_count: normalizeSatsValue(value[2]), + remapped_layer_count: normalizeSatsValue(value[3]), + restored_resource_count: normalizeSatsValue(value[4]), + removed_source_resource_id_count: normalizeSatsValue(value[5]), + revision: normalizeSatsValue(value[6]), + }; +} + function normalizeSatsValue(value) { if (Array.isArray(value)) { return value.map((item) => normalizeSatsValue(item)); diff --git a/scripts/spacetime-migration-common.test.ts b/scripts/spacetime-migration-common.test.ts index 1e14f7c6f..5ba7114ab 100644 --- a/scripts/spacetime-migration-common.test.ts +++ b/scripts/spacetime-migration-common.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildSpacetimeCallArgs, encodeSpacetimeCliOption, + parseProcedureResult, } from './spacetime-migration-common.mjs'; describe('SpacetimeDB CLI SATS option encoding', () => { @@ -33,4 +34,69 @@ describe('SpacetimeDB CLI SATS option encoding', () => { expect(input.cursor_job_id).toEqual([0, 'job-2']); expect(input.completed_before_micros).toEqual([0, 123]); }); + + it('normalizes editor canvas migration procedure results', () => { + const result = parseProcedureResult( + JSON.stringify([ + true, + [ + 0, + [ + 'canvas-1', + 'project-1', + 'source-hash', + 'structured-hash', + 7, + 2, + 1, + 'resource-hash', + 1, + 'backfilled', + 7, + ], + ], + [1], + ]), + 'backfill_editor_canvas_layout_and_return', + ); + + expect(result).toEqual({ + ok: true, + migration: { + canvas_id: 'canvas-1', + project_id: 'project-1', + source_layout_sha256: 'source-hash', + structured_layout_sha256: 'structured-hash', + verified_revision: 7, + layer_count: 2, + dialog_count: 1, + resource_refs_sha256: 'resource-hash', + migration_version: 1, + status: 'backfilled', + revision: 7, + }, + error_message: null, + }); + }); + + it('normalizes editor canvas resource repair procedure results', () => { + const result = parseProcedureResult( + JSON.stringify([true, [0, [true, false, 2, 0, 0, 0, 7]], [1]]), + 'repair_editor_canvas_resources_and_return', + ); + + expect(result).toEqual({ + ok: true, + repair: { + dry_run: true, + already_repaired: false, + matched_layer_count: 2, + remapped_layer_count: 0, + restored_resource_count: 0, + removed_source_resource_id_count: 0, + revision: 7, + }, + error_message: null, + }); + }); }); diff --git a/scripts/spacetime-repair-editor-canvas-resources.mjs b/scripts/spacetime-repair-editor-canvas-resources.mjs new file mode 100644 index 000000000..a8967d9c7 --- /dev/null +++ b/scripts/spacetime-repair-editor-canvas-resources.mjs @@ -0,0 +1,671 @@ +#!/usr/bin/env node + +import { createHash } from 'node:crypto'; +import { lstat, readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + callSpacetimeProcedureViaCli, +} from './spacetime-migration-common.mjs'; + +const PROCEDURE_NAME = 'repair_editor_canvas_resources_and_return'; +const PLAN_SCHEMA_VERSION = 1; +const MAX_PLAN_BYTES = 1024 * 1024; +const MAX_CANVAS_COUNT = 100; +const MAX_ACTIONS_PER_CANVAS = 16; +const SHA256_PATTERN = /^[0-9a-f]{64}$/u; +const REPO_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const ACTION_KINDS = new Set([ + 'reuse_project_resource', + 'restore_project_resource', +]); +const RESTORABLE_ASSET_KINDS = new Set(['background-music', 'sound-effect']); + +function usage() { + return `用法: + node scripts/spacetime-repair-editor-canvas-resources.mjs \\ + --database --server --plan-file \\ + [--apply --confirm-plan-sha256 ] + +默认逐画布调用 ${PROCEDURE_NAME} 执行 dry-run,不修改数据。 +只有显式追加 --apply,并用 --confirm-plan-sha256 绑定同一份 dry-run plan,才执行写入。 +plan 必须位于仓库外,是当前用户持有、权限严格为 0600 的普通文件,且路径链不能包含符号链接。 +必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`; +} + +export function parseOptions(argv) { + const options = { + apply: false, + confirmPlanSha256: '', + database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', + passthrough: [], + planFile: '', + server: process.env.GENARRATIVE_SPACETIME_SERVER || '', + serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '', + }; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + const readValue = () => { + const value = argv[index + 1]; + if (!value || value.startsWith('--')) { + throw new Error(`${arg} 缺少参数值。`); + } + index += 1; + return value.trim(); + }; + + if (arg === '--database') { + options.database = readValue(); + } else if (arg === '--server') { + options.server = readValue(); + } else if (arg === '--server-url') { + options.serverUrl = readValue(); + } else if (arg === '--plan-file') { + options.planFile = readValue(); + } else if (arg === '--confirm-plan-sha256') { + options.confirmPlanSha256 = readValue().toLowerCase(); + } else if (arg === '--apply') { + options.apply = true; + } else if (arg === '--no-config') { + options.passthrough.push(arg); + } else if (arg === '--help' || arg === '-h') { + options.help = true; + } else { + throw new Error(`未知参数: ${arg}`); + } + } + + return options; +} + +export async function readRepairPlan(planFile, { repoRoot = REPO_ROOT } = {}) { + assertSafeAbsolutePath(planFile, '--plan-file'); + const resolvedPath = path.resolve(planFile); + const resolvedRepoRoot = path.resolve(repoRoot); + let canonicalPath; + try { + canonicalPath = await realpath(resolvedPath); + } catch { + throw new Error('--plan-file 无法解析或不存在。'); + } + if (canonicalPath !== resolvedPath) { + throw new Error('--plan-file 路径链不能包含符号链接。'); + } + if (isPathInside(canonicalPath, resolvedRepoRoot)) { + throw new Error('--plan-file 必须位于仓库外,避免真实 ID 进入工作区。'); + } + + let metadata; + try { + metadata = await lstat(canonicalPath); + } catch { + throw new Error('--plan-file 无法读取元数据。'); + } + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw new Error('--plan-file 必须是普通文件且不能是符号链接。'); + } + if ((metadata.mode & 0o777) !== 0o600) { + throw new Error('--plan-file 权限必须严格为 0600。'); + } + if ( + typeof process.getuid === 'function' && + metadata.uid !== process.getuid() + ) { + throw new Error('--plan-file 必须由当前执行用户持有。'); + } + if (metadata.size <= 0 || metadata.size > MAX_PLAN_BYTES) { + throw new Error(`--plan-file 大小必须在 1 到 ${MAX_PLAN_BYTES} 字节之间。`); + } + + let bytes; + try { + bytes = await readFile(canonicalPath); + } catch { + throw new Error('--plan-file 无法读取。'); + } + const planSha256 = createHash('sha256').update(bytes).digest('hex'); + let value; + try { + value = JSON.parse(bytes.toString('utf8')); + } catch { + throw new Error('--plan-file 不是合法 JSON。'); + } + + return { + plan: validateRepairPlan(value), + planSha256, + }; +} + +export function validateRepairPlan(value) { + assertPlainObject(value, 'plan'); + assertExactKeys( + value, + [ + 'schema_version', + 'expected_canvas_count', + 'expected_action_count', + 'canvases', + ], + 'plan', + ); + if (value.schema_version !== PLAN_SCHEMA_VERSION) { + throw new Error(`plan.schema_version 必须为 ${PLAN_SCHEMA_VERSION}。`); + } + const expectedCanvasCount = readPositiveInteger( + value.expected_canvas_count, + 'plan.expected_canvas_count', + ); + const expectedActionCount = readPositiveInteger( + value.expected_action_count, + 'plan.expected_action_count', + ); + if (!Array.isArray(value.canvases) || value.canvases.length === 0) { + throw new Error('plan.canvases 必须是非空数组。'); + } + if (value.canvases.length > MAX_CANVAS_COUNT) { + throw new Error(`plan.canvases 不能超过 ${MAX_CANVAS_COUNT} 项。`); + } + + const canvasIds = new Set(); + const projectIds = new Set(); + let actionCount = 0; + const canvases = value.canvases.map((canvas, canvasIndex) => { + const label = `plan.canvases[${canvasIndex}]`; + assertPlainObject(canvas, label); + assertExactKeys( + canvas, + [ + 'canvas_id', + 'project_id', + 'owner_user_id', + 'expected_revision', + 'expected_canvas_layout_sha256', + 'expected_project_layout_sha256', + 'actions', + ], + label, + ); + const normalized = { + canvas_id: readIdentifier(canvas.canvas_id, `${label}.canvas_id`), + project_id: readIdentifier(canvas.project_id, `${label}.project_id`), + owner_user_id: readIdentifier( + canvas.owner_user_id, + `${label}.owner_user_id`, + ), + expected_revision: readNonNegativeInteger( + canvas.expected_revision, + `${label}.expected_revision`, + ), + expected_canvas_layout_sha256: readSha256( + canvas.expected_canvas_layout_sha256, + `${label}.expected_canvas_layout_sha256`, + ), + expected_project_layout_sha256: readSha256( + canvas.expected_project_layout_sha256, + `${label}.expected_project_layout_sha256`, + ), + actions: [], + }; + if (canvasIds.has(normalized.canvas_id) || projectIds.has(normalized.project_id)) { + throw new Error(`${label}.canvas_id 或 project_id 与 plan 内其它画布重复。`); + } + canvasIds.add(normalized.canvas_id); + projectIds.add(normalized.project_id); + + if (!Array.isArray(canvas.actions) || canvas.actions.length === 0) { + throw new Error(`${label}.actions 必须是非空数组。`); + } + if (canvas.actions.length > MAX_ACTIONS_PER_CANVAS) { + throw new Error( + `${label}.actions 不能超过 ${MAX_ACTIONS_PER_CANVAS} 项。`, + ); + } + const layerIds = new Set(); + normalized.actions = canvas.actions.map((action, actionIndex) => { + const actionLabel = `${label}.actions[${actionIndex}]`; + const normalizedAction = validateRepairAction(action, actionLabel); + if (layerIds.has(normalizedAction.layer_id)) { + throw new Error(`${actionLabel}.layer_id 在同一画布内重复。`); + } + layerIds.add(normalizedAction.layer_id); + return normalizedAction; + }); + actionCount += normalized.actions.length; + return normalized; + }); + + if (expectedCanvasCount !== canvases.length) { + throw new Error('plan.expected_canvas_count 与 canvases 实际数量不一致。'); + } + if (expectedActionCount !== actionCount) { + throw new Error('plan.expected_action_count 与 actions 实际数量不一致。'); + } + + return { + schema_version: PLAN_SCHEMA_VERSION, + expected_canvas_count: expectedCanvasCount, + expected_action_count: expectedActionCount, + canvases, + }; +} + +function validateRepairAction(action, label) { + assertPlainObject(action, label); + assertExactKeys( + action, + [ + 'action_kind', + 'layer_id', + 'expected_resource_id', + 'replacement_resource_id', + 'expected_layout_source_resource_id', + 'asset_object_id', + 'asset_kind', + ], + label, + ); + const actionKind = readIdentifier(action.action_kind, `${label}.action_kind`); + if (!ACTION_KINDS.has(actionKind)) { + throw new Error( + `${label}.action_kind 只支持 reuse_project_resource 或 restore_project_resource。`, + ); + } + const normalized = { + action_kind: actionKind, + layer_id: readIdentifier(action.layer_id, `${label}.layer_id`), + expected_resource_id: readIdentifier( + action.expected_resource_id, + `${label}.expected_resource_id`, + ), + replacement_resource_id: readNullableIdentifier( + action.replacement_resource_id, + `${label}.replacement_resource_id`, + ), + expected_layout_source_resource_id: readNullableIdentifier( + action.expected_layout_source_resource_id, + `${label}.expected_layout_source_resource_id`, + ), + asset_object_id: readNullableIdentifier( + action.asset_object_id, + `${label}.asset_object_id`, + ), + asset_kind: readNullableIdentifier( + action.asset_kind, + `${label}.asset_kind`, + ), + }; + + if (actionKind === 'reuse_project_resource') { + if (!normalized.replacement_resource_id) { + throw new Error( + `${label}.replacement_resource_id 为复用资源动作的必填项。`, + ); + } + if (!normalized.expected_layout_source_resource_id) { + throw new Error( + `${label}.expected_layout_source_resource_id 为复用资源动作的必填项。`, + ); + } + if ( + normalized.replacement_resource_id === normalized.expected_resource_id + ) { + throw new Error( + `${label}.replacement_resource_id 不能等于 expected_resource_id。`, + ); + } + if (normalized.asset_object_id || normalized.asset_kind) { + throw new Error( + `${label} 的复用资源动作不能携带 asset_object_id / asset_kind。`, + ); + } + } else { + if (normalized.replacement_resource_id) { + throw new Error( + `${label} 的补资源动作不能携带 replacement_resource_id。`, + ); + } + if (normalized.expected_layout_source_resource_id) { + throw new Error( + `${label} 的补资源动作不能携带 expected_layout_source_resource_id。`, + ); + } + if (!normalized.asset_object_id || !normalized.asset_kind) { + throw new Error( + `${label}.asset_object_id / asset_kind 为补资源动作的必填项。`, + ); + } + if (!RESTORABLE_ASSET_KINDS.has(normalized.asset_kind)) { + throw new Error( + `${label}.asset_kind 只支持 background-music 或 sound-effect。`, + ); + } + } + return normalized; +} + +export async function executeRepairPlan({ + options, + plan, + planSha256, + callProcedure = callSpacetimeProcedureViaCli, + updatedAtMicros = Date.now() * 1_000, +}) { + validateExecutionOptions(options, planSha256); + const summary = { + ok: true, + dry_run: !options.apply, + plan_sha256: planSha256, + canvas_count: plan.expected_canvas_count, + action_count: plan.expected_action_count, + processed_canvas_count: 0, + matched_layer_count: 0, + updated_layer_count: 0, + inserted_resource_count: 0, + already_repaired_canvas_count: 0, + failures: [], + }; + + for (let index = 0; index < plan.canvases.length; index += 1) { + const canvas = plan.canvases[index]; + const ordinal = index + 1; + const input = buildProcedureInput(canvas, updatedAtMicros, !options.apply); + let result; + try { + result = await callProcedure(options, PROCEDURE_NAME, input); + } catch { + throw new Error( + `第 ${ordinal} 个画布调用失败;详细信息已隐藏,且未继续处理后续画布。`, + ); + } + + const normalizedResult = normalizePublicResult(result, !options.apply); + if (!normalizedResult.ok) { + const failure = { + canvas_ordinal: ordinal, + error_code: normalizedResult.error_code, + }; + if (options.apply) { + throw new Error( + `第 ${ordinal} 个画布修复失败,error_code=${failure.error_code};此前成功处理 ${summary.processed_canvas_count} 个画布,已停止。`, + ); + } + summary.failures.push(failure); + continue; + } + + summary.processed_canvas_count += 1; + summary.matched_layer_count += normalizedResult.matched_layer_count; + summary.updated_layer_count += normalizedResult.updated_layer_count; + summary.inserted_resource_count += normalizedResult.inserted_resource_count; + if (normalizedResult.already_repaired) { + summary.already_repaired_canvas_count += 1; + } + } + + summary.ok = summary.failures.length === 0; + return summary; +} + +export function buildProcedureInput(canvas, updatedAtMicros, dryRun) { + return { + canvas_id: canvas.canvas_id, + project_id: canvas.project_id, + owner_user_id: canvas.owner_user_id, + expected_revision: canvas.expected_revision, + expected_canvas_layout_sha256: canvas.expected_canvas_layout_sha256, + expected_project_layout_sha256: canvas.expected_project_layout_sha256, + remaps: canvas.actions + .filter((action) => action.action_kind === 'reuse_project_resource') + .map((action) => ({ + layer_id: action.layer_id, + expected_missing_resource_id: action.expected_resource_id, + replacement_resource_id: action.replacement_resource_id, + expected_layout_source_resource_id: + action.expected_layout_source_resource_id, + })), + restores: canvas.actions + .filter((action) => action.action_kind === 'restore_project_resource') + .map((action) => ({ + layer_id: action.layer_id, + resource_id: action.expected_resource_id, + asset_object_id: action.asset_object_id, + asset_kind: action.asset_kind, + })), + updated_at_micros: updatedAtMicros, + dry_run: dryRun, + }; +} + +function validateExecutionOptions(options, planSha256) { + validateConnectionOptions(options); + if (options.apply) { + if (!SHA256_PATTERN.test(options.confirmPlanSha256 || '')) { + throw new Error('--apply 必须传入 64 位 --confirm-plan-sha256。'); + } + if (options.confirmPlanSha256 !== planSha256) { + throw new Error('--confirm-plan-sha256 与当前 plan 不一致,拒绝 apply。'); + } + } else if (options.confirmPlanSha256) { + throw new Error('--confirm-plan-sha256 只能与 --apply 一起使用。'); + } +} + +function validateConnectionOptions(options) { + if (!options.database) { + throw new Error( + '必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。', + ); + } + if (!options.server && !options.serverUrl) { + throw new Error( + '必须显式传入 --server / --server-url,不使用默认 cloud target。', + ); + } +} + +function normalizePublicResult(result, expectedDryRun) { + if (!result || typeof result !== 'object' || Array.isArray(result)) { + throw new Error('procedure 返回结构无效;详细信息已隐藏。'); + } + if (typeof result.ok !== 'boolean') { + throw new Error('procedure 返回的 ok 无效。'); + } + if (!result.ok) { + return { + ok: false, + matched_layer_count: 0, + updated_layer_count: 0, + inserted_resource_count: 0, + already_repaired: false, + error_code: 'repair_rejected', + }; + } + const repair = result.repair; + if ( + !repair || + typeof repair !== 'object' || + Array.isArray(repair) || + repair.dry_run !== expectedDryRun + ) { + throw new Error('procedure 返回的 repair / dry_run 与请求不一致。'); + } + return { + ok: true, + matched_layer_count: readResultCount( + repair.matched_layer_count, + 'matched_layer_count', + ), + updated_layer_count: readResultCount( + repair.remapped_layer_count, + 'remapped_layer_count', + ), + inserted_resource_count: readResultCount( + repair.restored_resource_count, + 'restored_resource_count', + ), + already_repaired: repair.already_repaired === true, + error_code: null, + }; +} + +function readResultCount(value, name) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`procedure 返回的 ${name} 无效。`); + } + return value; +} + +function assertSafeAbsolutePath(value, name) { + if (typeof value !== 'string' || !value || !path.isAbsolute(value)) { + throw new Error(`${name} 必须是绝对路径。`); + } + if ( + value.includes('\0') || + /[\r\n]/u.test(value) || + value === path.parse(value).root + ) { + throw new Error(`${name} 不是安全的文件路径。`); + } +} + +function isPathInside(candidate, root) { + const relative = path.relative(root, candidate); + return ( + relative === '' || + (!relative.startsWith('..') && !path.isAbsolute(relative)) + ); +} + +function assertPlainObject(value, label) { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`${label} 必须是对象。`); + } +} + +function assertExactKeys(value, expectedKeys, label) { + const expected = new Set(expectedKeys); + const actualKeys = Object.keys(value); + const unexpected = actualKeys.filter((key) => !expected.has(key)); + const missing = expectedKeys.filter((key) => !Object.hasOwn(value, key)); + if (unexpected.length > 0 || missing.length > 0) { + throw new Error(`${label} 字段不完整或包含未知字段。`); + } +} + +function readIdentifier(value, label) { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > 512 || + value !== value.trim() || + value.includes('\0') || + /[\r\n]/u.test(value) + ) { + throw new Error(`${label} 必须是非空且不含控制字符的字符串。`); + } + return value; +} + +function readNullableIdentifier(value, label) { + return value === null ? null : readIdentifier(value, label); +} + +function readSha256(value, label) { + if (typeof value !== 'string' || !SHA256_PATTERN.test(value.toLowerCase())) { + throw new Error(`${label} 必须是 64 位十六进制 SHA-256。`); + } + return value.toLowerCase(); +} + +function readPositiveInteger(value, label) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${label} 必须是正整数。`); + } + return value; +} + +function readNonNegativeInteger(value, label) { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${label} 必须是非负整数。`); + } + return value; +} + +export async function runCli(argv, dependencies = {}) { + const options = parseOptions(argv); + if (options.help) { + console.log(usage()); + return { ok: true, help: true }; + } + if (!options.planFile) { + throw new Error('必须传入 --plan-file。'); + } + validateConnectionOptions(options); + const readPlan = dependencies.readPlan || readRepairPlan; + const { plan, planSha256 } = await readPlan(options.planFile); + const summary = await executeRepairPlan({ + options, + plan, + planSha256, + callProcedure: dependencies.callProcedure, + updatedAtMicros: dependencies.updatedAtMicros, + }); + let verification = null; + if (options.apply && summary.ok) { + const verificationSummary = await executeRepairPlan({ + options: { + ...options, + apply: false, + confirmPlanSha256: '', + }, + plan, + planSha256, + callProcedure: dependencies.callProcedure, + updatedAtMicros: dependencies.updatedAtMicros, + }); + if ( + !verificationSummary.ok || + verificationSummary.processed_canvas_count !== plan.expected_canvas_count || + verificationSummary.already_repaired_canvas_count !== plan.expected_canvas_count + ) { + throw new Error('apply 后自动 dry-run 复核未全部命中 already_repaired。'); + } + verification = { + ok: true, + processed_canvas_count: verificationSummary.processed_canvas_count, + already_repaired_canvas_count: + verificationSummary.already_repaired_canvas_count, + }; + } + console.log(JSON.stringify({ ...summary, verification }, null, 2)); + if (!options.apply && summary.ok) { + console.log( + `dry-run 已通过;确认匿名计数后,使用 --apply --confirm-plan-sha256 ${summary.plan_sha256} 重跑同一 plan。`, + ); + } + if (!summary.ok) { + process.exitCode = 1; + } + return summary; +} + +function isCliEntry() { + const entry = process.argv[1]; + return entry + ? import.meta.url === `file://${entry.replace(/\\/gu, '/')}` + : false; +} + +if (isCliEntry()) { + runCli(process.argv.slice(2)).catch((error) => { + console.error( + `[spacetime:editor-canvas-resources:repair] ${ + error instanceof Error ? error.message : '操作失败;详细信息已隐藏。' + }`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/spacetime-repair-editor-canvas-resources.test.ts b/scripts/spacetime-repair-editor-canvas-resources.test.ts new file mode 100644 index 000000000..ecb13b527 --- /dev/null +++ b/scripts/spacetime-repair-editor-canvas-resources.test.ts @@ -0,0 +1,477 @@ +import { + chmod, + mkdir, + mkdtemp, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildProcedureInput, + executeRepairPlan, + parseOptions, + readRepairPlan, + runCli, + validateRepairPlan, +} from './spacetime-repair-editor-canvas-resources.mjs'; + +const SHA_A = 'a'.repeat(64); +const SHA_B = 'b'.repeat(64); +const PLAN_SHA = 'c'.repeat(64); +const temporaryRoots: string[] = []; + +afterEach(async () => { + process.exitCode = undefined; + await Promise.all( + temporaryRoots + .splice(0) + .map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +function action(overrides: Record = {}) { + return { + action_kind: 'reuse_project_resource', + layer_id: 'private-layer-id', + expected_resource_id: 'missing-private-resource-id', + replacement_resource_id: 'replacement-private-resource-id', + expected_layout_source_resource_id: 'source-private-resource-id', + asset_object_id: null, + asset_kind: null, + ...overrides, + }; +} + +function canvas(overrides: Record = {}) { + return { + canvas_id: 'private-canvas-id', + project_id: 'private-project-id', + owner_user_id: 'private-owner-id', + expected_revision: 7, + expected_canvas_layout_sha256: SHA_A, + expected_project_layout_sha256: SHA_B, + actions: [action()], + ...overrides, + }; +} + +function planValue(canvases = [canvas()]) { + return { + schema_version: 1, + expected_canvas_count: canvases.length, + expected_action_count: canvases.reduce( + (sum, item) => + sum + (Array.isArray(item.actions) ? item.actions.length : 0), + 0, + ), + canvases, + }; +} + +function executionOptions(overrides: Record = {}) { + return { + apply: false, + confirmPlanSha256: '', + database: 'genarrative-prod', + passthrough: [], + planFile: '/outside/repair-plan.json', + server: 'http://127.0.0.1:3101', + serverUrl: '', + ...overrides, + }; +} + +function procedureResult(overrides: Record = {}) { + return { + ok: true, + repair: { + dry_run: true, + already_repaired: false, + matched_layer_count: 1, + remapped_layer_count: 1, + restored_resource_count: 0, + removed_source_resource_id_count: 1, + revision: 7, + }, + error_message: null, + ...overrides, + }; +} + +describe('spacetime editor canvas resource repair options', () => { + it('defaults to dry-run and requires an explicit apply flag', () => { + const options = parseOptions([ + '--database', + 'genarrative-prod', + '--server-url', + 'http://127.0.0.1:3101', + '--plan-file', + '/tmp/repair-plan.json', + ]); + + expect(options.apply).toBe(false); + expect(options.confirmPlanSha256).toBe(''); + expect(options.planFile).toBe('/tmp/repair-plan.json'); + }); + + it('records explicit apply and lowercases the confirmed plan hash', () => { + const options = parseOptions([ + '--apply', + '--confirm-plan-sha256', + SHA_A.toUpperCase(), + ]); + + expect(options.apply).toBe(true); + expect(options.confirmPlanSha256).toBe(SHA_A); + }); + + it('rejects anonymous procedure calls', () => { + expect(() => parseOptions(['--anonymous'])).toThrow('未知参数'); + }); +}); + +describe('spacetime editor canvas resource repair plan', () => { + it('validates and normalizes the two supported action kinds', () => { + const plan = validateRepairPlan( + planValue([ + canvas({ + actions: [ + action(), + action({ + action_kind: 'restore_project_resource', + layer_id: 'private-audio-layer-id', + expected_resource_id: 'missing-private-audio-resource-id', + replacement_resource_id: null, + expected_layout_source_resource_id: null, + asset_object_id: 'private-asset-object-id', + asset_kind: 'background-music', + }), + ], + }), + ]), + ); + + expect(plan.expected_action_count).toBe(2); + expect(plan.canvases[0].actions).toMatchObject([ + { action_kind: 'reuse_project_resource' }, + { + action_kind: 'restore_project_resource', + asset_kind: 'background-music', + }, + ]); + }); + + it('rejects count mismatches, duplicate layers, and invalid kind-specific fields', () => { + expect(() => + validateRepairPlan({ ...planValue(), expected_action_count: 2 }), + ).toThrow('expected_action_count'); + expect(() => + validateRepairPlan( + planValue([ + canvas({ + actions: [action(), action({ replacement_resource_id: 'other' })], + }), + ]), + ), + ).toThrow('layer_id 在同一画布内重复'); + expect(() => + validateRepairPlan( + planValue([ + canvas({ + actions: [ + action({ + action_kind: 'restore_project_resource', + replacement_resource_id: null, + expected_layout_source_resource_id: null, + asset_object_id: 'private-asset-object-id', + asset_kind: 'image', + }), + ], + }), + ]), + ), + ).toThrow('asset_kind 只支持'); + }); + + it('reads only an external current-user 0600 regular plan and returns its hash', async () => { + const root = await makeTemporaryRoot(); + const planPath = path.join(root, 'repair-plan.json'); + await writeFile(planPath, `${JSON.stringify(planValue())}\n`, 'utf8'); + await chmod(planPath, 0o600); + + const result = await readRepairPlan(planPath); + + expect(result.plan.expected_canvas_count).toBe(1); + expect(result.planSha256).toMatch(/^[0-9a-f]{64}$/u); + }); + + it('rejects permissive modes, repository paths, and symlink path components', async () => { + const root = await makeTemporaryRoot(); + const planPath = path.join(root, 'repair-plan.json'); + await writeFile(planPath, JSON.stringify(planValue()), 'utf8'); + await chmod(planPath, 0o644); + await expect(readRepairPlan(planPath)).rejects.toThrow('0600'); + + await chmod(planPath, 0o600); + await expect(readRepairPlan(planPath, { repoRoot: root })).rejects.toThrow( + '必须位于仓库外', + ); + + const realDirectory = path.join(root, 'real'); + const linkedDirectory = path.join(root, 'linked'); + await mkdir(realDirectory); + const linkedPlanPath = path.join(realDirectory, 'linked-plan.json'); + await writeFile(linkedPlanPath, JSON.stringify(planValue()), 'utf8'); + await chmod(linkedPlanPath, 0o600); + await symlink(realDirectory, linkedDirectory); + await expect( + readRepairPlan(path.join(linkedDirectory, 'linked-plan.json')), + ).rejects.toThrow('路径链不能包含符号链接'); + }); +}); + +describe('spacetime editor canvas resource repair execution', () => { + it('splits the two action kinds into typed procedure inputs', () => { + const plan = validateRepairPlan( + planValue([ + canvas({ + actions: [ + action(), + action({ + action_kind: 'restore_project_resource', + layer_id: 'private-audio-layer-id', + expected_resource_id: 'missing-private-audio-resource-id', + replacement_resource_id: null, + expected_layout_source_resource_id: null, + asset_object_id: 'private-asset-object-id', + asset_kind: 'sound-effect', + }), + ], + }), + ]), + ); + + const input = buildProcedureInput(plan.canvases[0], 123, true); + + expect(input.remaps).toEqual([ + { + layer_id: 'private-layer-id', + expected_missing_resource_id: 'missing-private-resource-id', + replacement_resource_id: 'replacement-private-resource-id', + expected_layout_source_resource_id: 'source-private-resource-id', + }, + ]); + expect(input.restores).toEqual([ + { + layer_id: 'private-audio-layer-id', + resource_id: 'missing-private-audio-resource-id', + asset_object_id: 'private-asset-object-id', + asset_kind: 'sound-effect', + }, + ]); + expect(input.canvas_id).toBe('private-canvas-id'); + expect(input.updated_at_micros).toBe(123); + expect(input.dry_run).toBe(true); + }); + + it('calls one procedure per canvas and emits only anonymous aggregate output', async () => { + const plan = validateRepairPlan( + planValue([ + canvas(), + canvas({ + canvas_id: 'second-private-canvas-id', + project_id: 'second-private-project-id', + actions: [ + action({ + layer_id: 'second-private-layer-id', + expected_resource_id: 'second-private-resource-id', + replacement_resource_id: 'second-private-replacement-id', + }), + ], + }), + ]), + ); + const calls: unknown[] = []; + + const summary = await executeRepairPlan({ + options: executionOptions(), + plan, + planSha256: PLAN_SHA, + updatedAtMicros: 123, + callProcedure: async (options, procedureName, input) => { + calls.push({ options, procedureName, input }); + return procedureResult(); + }, + }); + + expect(calls).toHaveLength(2); + expect(calls).toMatchObject([ + { procedureName: 'repair_editor_canvas_resources_and_return' }, + { procedureName: 'repair_editor_canvas_resources_and_return' }, + ]); + expect(summary).toMatchObject({ + ok: true, + dry_run: true, + canvas_count: 2, + action_count: 2, + processed_canvas_count: 2, + matched_layer_count: 2, + updated_layer_count: 2, + inserted_resource_count: 0, + }); + const publicOutput = JSON.stringify(summary); + for (const privateValue of [ + 'private-project-id', + 'private-layer-id', + 'missing-private-resource-id', + 'replacement-private-resource-id', + ]) { + expect(publicOutput).not.toContain(privateValue); + } + }); + + it('requires apply to match the dry-run plan hash', async () => { + const plan = validateRepairPlan(planValue()); + const callProcedure = async () => procedureResult(); + + await expect( + executeRepairPlan({ + options: executionOptions({ apply: true, confirmPlanSha256: SHA_A }), + plan, + planSha256: PLAN_SHA, + callProcedure, + }), + ).rejects.toThrow('与当前 plan 不一致'); + }); + + it('stops apply at the first failed canvas without exposing procedure messages', async () => { + const plan = validateRepairPlan( + planValue([ + canvas(), + canvas({ + canvas_id: 'second-private-canvas-id', + project_id: 'second-private-project-id', + actions: [action({ layer_id: 'second-private-layer-id' })], + }), + ]), + ); + let callCount = 0; + + await expect( + executeRepairPlan({ + options: executionOptions({ + apply: true, + confirmPlanSha256: PLAN_SHA, + }), + plan, + planSha256: PLAN_SHA, + callProcedure: async () => { + callCount += 1; + return procedureResult({ + ok: false, + repair: null, + error_message: + 'contains private-project-id and private-resource-id', + }); + }, + }), + ).rejects.toThrow( + '第 1 个画布修复失败,error_code=repair_rejected;此前成功处理 0 个画布', + ); + expect(callCount).toBe(1); + }); + + it('continues dry-run validation and reports only ordinal-safe failure codes', async () => { + const plan = validateRepairPlan( + planValue([ + canvas(), + canvas({ + canvas_id: 'second-private-canvas-id', + project_id: 'second-private-project-id', + actions: [action({ layer_id: 'second-private-layer-id' })], + }), + ]), + ); + let callCount = 0; + + const summary = await executeRepairPlan({ + options: executionOptions(), + plan, + planSha256: PLAN_SHA, + callProcedure: async () => { + callCount += 1; + return callCount === 1 + ? procedureResult({ + ok: false, + repair: null, + error_message: 'contains private-project-id', + }) + : procedureResult(); + }, + }); + + expect(callCount).toBe(2); + expect(summary).toMatchObject({ + ok: false, + processed_canvas_count: 1, + failures: [{ canvas_ordinal: 1, error_code: 'repair_rejected' }], + }); + expect(JSON.stringify(summary)).not.toContain('private-project-id'); + }); + + it('automatically dry-runs the same plan after apply and requires already repaired', async () => { + const plan = validateRepairPlan(planValue()); + const originalLog = console.log; + console.log = () => {}; + let callCount = 0; + try { + const summary = await runCli( + [ + '--database', + 'genarrative-prod', + '--server-url', + 'http://127.0.0.1:3101', + '--plan-file', + '/outside/repair-plan.json', + '--apply', + '--confirm-plan-sha256', + PLAN_SHA, + ], + { + readPlan: async () => ({ plan, planSha256: PLAN_SHA }), + callProcedure: async (_options, _procedureName, input) => { + callCount += 1; + const dryRun = input.dry_run === true; + return procedureResult({ + repair: { + ...procedureResult().repair, + dry_run: dryRun, + already_repaired: dryRun, + remapped_layer_count: dryRun ? 0 : 1, + removed_source_resource_id_count: dryRun ? 0 : 1, + revision: 8, + }, + }); + }, + updatedAtMicros: 123, + }, + ); + + expect(callCount).toBe(2); + expect(summary).toMatchObject({ ok: true, dry_run: false }); + } finally { + console.log = originalLog; + } + }); +}); + +async function makeTemporaryRoot() { + const root = await mkdtemp(path.join(tmpdir(), 'editor-canvas-repair-')); + temporaryRoots.push(root); + return root; +} diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index d3f52d6ea..71d42cb7a 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -42,14 +42,14 @@ use spacetime_client::{ EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, - EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, - EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, - EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, - EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, - EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, - EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, - ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput, - SpacetimeClientError, + EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, + EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, + EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, + EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput, + EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, + EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, + EditorShowcaseCampaignConfigRecord, ExternalGenerationJobPhaseUpdateError, + ExternalGenerationJobPhaseUpdateRecordInput, SpacetimeClientError, }; use crate::{ @@ -166,6 +166,7 @@ pub struct EditorCanvasViewportPayload { pub struct EditorProjectLayoutSaveRequest { pub(crate) viewport: EditorCanvasViewportPayload, pub(crate) layers: Value, + pub(crate) expected_revision: Option, } #[derive(Debug, Deserialize)] @@ -454,6 +455,8 @@ pub struct EditorProjectResponse { pub struct EditorProjectLayoutSaveResponse { project_id: String, canvas_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + revision: Option, updated_at: String, } @@ -743,6 +746,10 @@ pub struct EditorCanvasPayload { title: String, viewport: EditorCanvasViewportPayload, layers: Value, + revision: u64, + layout_storage_version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + background_color: Option, created_at: String, updated_at: String, } @@ -1048,25 +1055,53 @@ pub async fn save_editor_project_layout( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + let expected_revision = payload.expected_revision; + let viewport = payload.viewport.into_record(); let layers_json = serialize_editor_layers(payload.layers)?; - let ack = state - .spacetime_client() - .save_editor_project_layout_ack(EditorProjectLayoutSaveRecordInput { - project_id, - owner_user_id: authenticated.claims().user_id().to_string(), - viewport: payload.viewport.into_record(), - layers_json, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(map_editor_project_error)?; + 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) + }; Ok(json_success_body( Some(&request_context), EditorProjectLayoutSaveResponse { - project_id: ack.project_id, - canvas_id: ack.canvas_id, - updated_at: ack.updated_at, + project_id, + canvas_id, + revision, + updated_at, }, )) } @@ -5730,6 +5765,9 @@ fn editor_canvas_payload_from_record( scale: record.viewport.scale, }, layers: sanitize_editor_payload_media(record.layers, resources), + revision: record.revision, + layout_storage_version: record.layout_storage_version, + background_color: record.background_color, created_at: record.created_at, updated_at: record.updated_at, } @@ -6206,6 +6244,7 @@ pub(crate) async fn complete_editor_canvas_generation( .await .map_err(map_editor_project_error)?; let project = repair_editor_project_record_inline_media(state, project).await; + let expected_revision = project.canvas.revision; let viewport = project.viewport.clone(); let project_payload = editor_project_payload_from_record(project); let Some(placeholder) = @@ -6226,17 +6265,15 @@ pub(crate) async fn complete_editor_canvas_generation( if !completion_result.changed { return Ok(None); } - let saved = state - .spacetime_client() - .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { - project_id: project_id.to_string(), - owner_user_id: owner_user_id.to_string(), - viewport, - layers_json: serialize_editor_layers(completion_result.layers)?, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(map_editor_project_error)?; + let saved = save_editor_project_layout_with_revision_and_get( + state, + project_id, + owner_user_id, + viewport, + completion_result.layers, + expected_revision, + ) + .await?; Ok(Some(editor_project_payload_from_record(saved))) } @@ -6266,6 +6303,7 @@ pub(crate) async fn complete_editor_canvas_generation_with_items( .await .map_err(map_editor_project_error)?; let project = repair_editor_project_record_inline_media(state, project).await; + let expected_revision = project.canvas.revision; let viewport = project.viewport.clone(); let project_payload = editor_project_payload_from_record(project); let Some(placeholder) = @@ -6285,17 +6323,15 @@ pub(crate) async fn complete_editor_canvas_generation_with_items( if !completion_result.changed { return Ok(None); } - let saved = state - .spacetime_client() - .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { - project_id: project_id.to_string(), - owner_user_id: owner_user_id.to_string(), - viewport, - layers_json: serialize_editor_layers(completion_result.layers)?, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(map_editor_project_error)?; + let saved = save_editor_project_layout_with_revision_and_get( + state, + project_id, + owner_user_id, + viewport, + completion_result.layers, + expected_revision, + ) + .await?; Ok(Some(editor_project_payload_from_record(saved))) } @@ -6329,6 +6365,7 @@ pub(crate) async fn complete_editor_canvas_background_removal( .await .map_err(map_editor_project_error)?; let project = repair_editor_project_record_inline_media(state, project).await; + let expected_revision = project.canvas.revision; let viewport = project.viewport.clone(); let project_payload = editor_project_payload_from_record(project); let Some(layers) = replace_editor_canvas_layer_with_resource( @@ -6341,19 +6378,39 @@ pub(crate) async fn complete_editor_canvas_background_removal( return Ok(None); }; - let saved = state + let saved = save_editor_project_layout_with_revision_and_get( + state, + project_id, + owner_user_id, + viewport, + layers, + expected_revision, + ) + .await?; + + Ok(Some(editor_project_payload_from_record(saved))) +} + +pub(crate) async fn save_editor_project_layout_with_revision_and_get( + state: &AppState, + project_id: &str, + owner_user_id: &str, + viewport: EditorCanvasViewportRecord, + layers: Value, + expected_revision: u64, +) -> Result { + state .spacetime_client() - .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { + .save_editor_project_layout_v2(EditorProjectLayoutSaveV2RecordInput { project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), viewport, layers_json: serialize_editor_layers(layers)?, + expected_revision, updated_at_micros: current_utc_micros(), }) .await - .map_err(map_editor_project_error)?; - - Ok(Some(editor_project_payload_from_record(saved))) + .map_err(map_editor_project_error) } fn replace_editor_canvas_layer_with_resource( @@ -7954,6 +8011,12 @@ pub(crate) fn map_editor_project_error(error: SpacetimeClientError) -> AppError "message": message, })) } + SpacetimeClientError::Procedure(message) if message.contains("版本冲突") => { + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-project", + "message": message, + })) + } SpacetimeClientError::Procedure(message) => AppError::from_status(StatusCode::BAD_REQUEST) .with_details(json!({ "provider": "editor-project", @@ -8149,6 +8212,15 @@ mod tests { ); } + #[test] + fn editor_project_revision_conflicts_map_to_http_conflict() { + let error = map_editor_project_error(SpacetimeClientError::Procedure( + "图片画布版本冲突:期望 revision 4,当前为 5".to_string(), + )); + + assert_eq!(error.status_code(), StatusCode::CONFLICT); + } + #[test] fn bgfilter_body_read_failure_audits_as_transport_not_5xx() { // HTTP 状态已成功、但读 body 时链路断裂:外部 API 失败审计必须归类为 transport, 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 dc9935d49..ee3f93733 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -32,7 +32,8 @@ use crate::{ editor_project_resource_payload_from_record, extract_editor_ui_design_assets_for_owner, generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, - serialize_editor_asset_metadata, serialize_editor_layers, + save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata, + serialize_editor_layers, }, external_api_auth::ExternalApiPrincipal, http_error::AppError, @@ -62,6 +63,7 @@ pub struct ExternalEditorProjectCreateRequest { pub struct ExternalEditorCanvasSaveRequest { viewport: EditorCanvasViewportPayload, layers: Value, + expected_revision: Option, } #[derive(Debug, Deserialize)] @@ -339,17 +341,29 @@ pub async fn save_external_editor_canvas( Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_CANVAS)?; - let project = 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 = 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)? + }; Ok(json_success_body( Some(&request_context), @@ -854,6 +868,14 @@ mod tests { .get("priceMudPoints") .is_none() ); + assert_eq!( + parsed["components"]["schemas"]["EditorProject"]["properties"]["layers"]["type"], + "array" + ); + assert_eq!( + parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"], + "array" + ); assert!( parsed["paths"] .get("/api/external/v1/editor/images/edits") diff --git a/server-rs/crates/spacetime-client/src/editor_project.rs b/server-rs/crates/spacetime-client/src/editor_project.rs index 3511a1fa0..c02657e0b 100644 --- a/server-rs/crates/spacetime-client/src/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/editor_project.rs @@ -185,6 +185,53 @@ impl SpacetimeClient { .await } + pub async fn save_editor_project_layout_v2_ack( + &self, + input: EditorProjectLayoutSaveV2RecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "save_editor_project_layout_v_2_ack", + move |connection, sender| { + connection + .procedures() + .save_editor_project_layout_v_2_ack_then(procedure_input, move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_layout_save_v2_procedure_result); + send_once(&sender, mapped); + }); + }, + ) + .await + } + + pub async fn save_editor_project_layout_v2( + &self, + input: EditorProjectLayoutSaveV2RecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "save_editor_project_layout_v_2_and_return", + move |connection, sender| { + connection + .procedures() + .save_editor_project_layout_v_2_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_project_required_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn create_editor_project_resource( &self, input: EditorProjectResourceCreateRecordInput, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index f51015531..16f931444 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -46,7 +46,8 @@ pub use mapper::{ EditorGenerationPricingConfigRecord, EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, - EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, + EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2AckRecord, + EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetAdminListRecordInput, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 5e8b748d6..da3ac68b9 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -99,7 +99,8 @@ pub use self::editor_project::{ EditorGenerationPricingConfigRecord, EditorGenerationPricingConfigUpsertRecordInput, EditorGenerationPricingTierRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, - EditorProjectLayoutSaveRecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, + EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2AckRecord, + EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, EditorProjectResourcePublicShowcaseListRecordInput, EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetAdminListRecordInput, @@ -256,8 +257,8 @@ pub(crate) use self::editor_project::{ map_editor_generation_pricing_config_optional_procedure_result, map_editor_generation_pricing_config_required_procedure_result, map_editor_project_delete_procedure_result, map_editor_project_layout_save_procedure_result, - map_editor_project_list_procedure_result, map_editor_project_optional_procedure_result, - map_editor_project_required_procedure_result, + map_editor_project_layout_save_v2_procedure_result, map_editor_project_list_procedure_result, + map_editor_project_optional_procedure_result, map_editor_project_required_procedure_result, map_editor_project_resource_list_procedure_result, map_editor_project_resource_procedure_result, map_editor_showcase_asset_list_procedure_result, map_editor_showcase_asset_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs index f5ed19831..fa3b03ec2 100644 --- a/server-rs/crates/spacetime-client/src/mapper/editor_project.rs +++ b/server-rs/crates/spacetime-client/src/mapper/editor_project.rs @@ -27,6 +27,9 @@ pub struct EditorCanvasRecord { pub title: String, pub viewport: EditorCanvasViewportRecord, pub layers: serde_json::Value, + pub revision: u64, + pub layout_storage_version: u32, + pub background_color: Option, pub created_at: String, pub updated_at: String, } @@ -39,6 +42,15 @@ pub struct EditorProjectLayoutSaveAckRecord { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EditorProjectLayoutSaveV2AckRecord { + pub project_id: String, + pub canvas_id: String, + pub revision: u64, + pub updated_at: String, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct EditorProjectResourceRecord { pub resource_id: String, @@ -251,6 +263,16 @@ pub struct EditorProjectLayoutSaveRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq)] +pub struct EditorProjectLayoutSaveV2RecordInput { + pub project_id: String, + pub owner_user_id: String, + pub viewport: EditorCanvasViewportRecord, + pub layers_json: String, + pub expected_revision: u64, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct EditorProjectResourceCreateRecordInput { pub resource_id: String, @@ -544,6 +566,25 @@ impl From } } +impl From + for crate::module_bindings::EditorProjectLayoutSaveV2Input +{ + fn from(input: EditorProjectLayoutSaveV2RecordInput) -> Self { + Self { + project_id: input.project_id, + owner_user_id: input.owner_user_id, + viewport: crate::module_bindings::EditorProjectViewportSnapshot { + x: input.viewport.x, + y: input.viewport.y, + scale: input.viewport.scale, + }, + layers_json: input.layers_json, + expected_revision: input.expected_revision, + updated_at_micros: input.updated_at_micros, + } + } +} + impl From for crate::module_bindings::EditorProjectResourceCreateInput { @@ -944,6 +985,25 @@ pub(crate) fn map_editor_project_layout_save_procedure_result( }) } +pub(crate) fn map_editor_project_layout_save_v2_procedure_result( + result: EditorProjectLayoutSaveV2ProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + let ack = result + .ack + .ok_or_else(|| SpacetimeClientError::missing_snapshot("图片画布保存 V2 结果"))?; + Ok(EditorProjectLayoutSaveV2AckRecord { + project_id: ack.project_id, + canvas_id: ack.canvas_id, + revision: ack.revision, + updated_at: format_timestamp_micros(ack.updated_at_micros), + updated_at_micros: ack.updated_at_micros, + }) +} + pub(crate) fn map_editor_project_list_procedure_result( result: EditorProjectListProcedureResult, ) -> Result, SpacetimeClientError> { @@ -1157,6 +1217,9 @@ fn map_editor_project_snapshot( title: snapshot.canvas.title, viewport: viewport.clone(), layers: layers.clone(), + revision: snapshot.canvas.revision, + layout_storage_version: snapshot.canvas.layout_storage_version, + background_color: snapshot.canvas.background_color, created_at: format_timestamp_micros(snapshot.canvas.created_at_micros), updated_at: format_timestamp_micros(snapshot.canvas.updated_at_micros), }; diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 13162e635..9274f30d8 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -10,6 +10,7 @@ pub mod accept_quest_reducer; pub mod acknowledge_external_generation_job_summaries_and_return_procedure; pub mod acknowledge_external_generation_jobs_and_return_procedure; pub mod acknowledge_quest_completion_reducer; +pub mod activate_editor_canvas_layout_and_return_procedure; pub mod admin_account_create_input_type; pub mod admin_account_credential_procedure_result_type; pub mod admin_account_credential_snapshot_type; @@ -138,6 +139,7 @@ pub mod auth_store_projection_user_type; pub mod auth_store_projection_view_type; pub mod authenticate_external_api_key_and_return_procedure; pub mod authorize_database_migration_operator_procedure; +pub mod backfill_editor_canvas_layout_and_return_procedure; pub mod backfill_external_generation_job_summaries_and_return_procedure; pub mod bark_battle_draft_config_row_type; pub mod bark_battle_draft_config_snapshot_type; @@ -432,6 +434,20 @@ pub mod editor_asset_snapshot_type; pub mod editor_asset_table; pub mod editor_asset_type; pub mod editor_asset_update_input_type; +pub mod editor_canvas_generation_dialog_table; +pub mod editor_canvas_generation_dialog_type; +pub mod editor_canvas_layer_table; +pub mod editor_canvas_layer_type; +pub mod editor_canvas_layout_migration_input_type; +pub mod editor_canvas_layout_migration_procedure_result_type; +pub mod editor_canvas_layout_migration_snapshot_type; +pub mod editor_canvas_layout_migration_table; +pub mod editor_canvas_layout_migration_type; +pub mod editor_canvas_missing_resource_restore_input_type; +pub mod editor_canvas_resource_remap_input_type; +pub mod editor_canvas_resource_repair_input_type; +pub mod editor_canvas_resource_repair_procedure_result_type; +pub mod editor_canvas_resource_repair_snapshot_type; pub mod editor_canvas_snapshot_type; pub mod editor_canvas_table; pub mod editor_canvas_type; @@ -453,6 +469,9 @@ pub mod editor_project_get_recent_input_type; pub mod editor_project_layout_save_ack_type; pub mod editor_project_layout_save_input_type; pub mod editor_project_layout_save_procedure_result_type; +pub mod editor_project_layout_save_v_2_ack_type; +pub mod editor_project_layout_save_v_2_input_type; +pub mod editor_project_layout_save_v_2_procedure_result_type; pub mod editor_project_list_input_type; pub mod editor_project_list_procedure_result_type; pub mod editor_project_procedure_result_type; @@ -520,10 +539,10 @@ pub mod external_generation_job_phase_update_failure_kind_type; pub mod external_generation_job_phase_update_input_type; pub mod external_generation_job_phase_update_procedure_result_type; pub mod external_generation_job_procedure_result_type; -pub mod external_generation_job_result_procedure_result_type; pub mod external_generation_job_renew_lease_input_type; -pub mod external_generation_job_snapshot_type; +pub mod external_generation_job_result_procedure_result_type; pub mod external_generation_job_result_snapshot_type; +pub mod external_generation_job_snapshot_type; pub mod external_generation_job_summary_backfill_input_type; pub mod external_generation_job_summary_backfill_procedure_result_type; pub mod external_generation_job_summary_procedure_result_type; @@ -1034,6 +1053,7 @@ pub mod remix_puzzle_work_procedure; pub mod rename_editor_project_and_return_procedure; pub mod renew_external_generation_job_lease_and_return_procedure; pub mod repair_editor_asset_media_and_return_procedure; +pub mod repair_editor_canvas_resources_and_return_procedure; pub mod repair_editor_project_resource_media_and_return_procedure; pub mod resolve_combat_action_and_return_procedure; pub mod resolve_combat_action_input_type; @@ -1058,6 +1078,7 @@ pub mod resume_profile_save_archive_and_return_procedure; pub mod retry_puzzle_clear_level_run_procedure; pub mod revoke_database_migration_operator_procedure; pub mod revoke_external_api_key_and_return_procedure; +pub mod rollback_editor_canvas_layout_and_return_procedure; pub mod rotate_editor_generation_runtime_service_identity_and_return_procedure; pub mod rpg_agent_draft_card_kind_type; pub mod rpg_agent_draft_card_status_type; @@ -1224,6 +1245,8 @@ pub mod runtime_tracking_event_procedure_result_type; pub mod runtime_tracking_scope_kind_type; pub mod save_editor_project_layout_ack_procedure; pub mod save_editor_project_layout_and_return_procedure; +pub mod save_editor_project_layout_v_2_ack_procedure; +pub mod save_editor_project_layout_v_2_and_return_procedure; pub mod save_puzzle_form_draft_procedure; pub mod save_puzzle_generated_images_procedure; pub mod save_puzzle_ui_background_procedure; @@ -1449,6 +1472,7 @@ pub use accept_quest_reducer::accept_quest; pub use acknowledge_external_generation_job_summaries_and_return_procedure::acknowledge_external_generation_job_summaries_and_return; pub use acknowledge_external_generation_jobs_and_return_procedure::acknowledge_external_generation_jobs_and_return; pub use acknowledge_quest_completion_reducer::acknowledge_quest_completion; +pub use activate_editor_canvas_layout_and_return_procedure::activate_editor_canvas_layout_and_return; pub use admin_account_create_input_type::AdminAccountCreateInput; pub use admin_account_credential_procedure_result_type::AdminAccountCredentialProcedureResult; pub use admin_account_credential_snapshot_type::AdminAccountCredentialSnapshot; @@ -1577,6 +1601,7 @@ pub use auth_store_projection_user_type::AuthStoreProjectionUser; pub use auth_store_projection_view_type::AuthStoreProjectionView; pub use authenticate_external_api_key_and_return_procedure::authenticate_external_api_key_and_return; pub use authorize_database_migration_operator_procedure::authorize_database_migration_operator; +pub use backfill_editor_canvas_layout_and_return_procedure::backfill_editor_canvas_layout_and_return; pub use backfill_external_generation_job_summaries_and_return_procedure::backfill_external_generation_job_summaries_and_return; pub use bark_battle_draft_config_row_type::BarkBattleDraftConfigRow; pub use bark_battle_draft_config_snapshot_type::BarkBattleDraftConfigSnapshot; @@ -1871,6 +1896,20 @@ pub use editor_asset_snapshot_type::EditorAssetSnapshot; pub use editor_asset_table::*; pub use editor_asset_type::EditorAsset; pub use editor_asset_update_input_type::EditorAssetUpdateInput; +pub use editor_canvas_generation_dialog_table::*; +pub use editor_canvas_generation_dialog_type::EditorCanvasGenerationDialog; +pub use editor_canvas_layer_table::*; +pub use editor_canvas_layer_type::EditorCanvasLayer; +pub use editor_canvas_layout_migration_input_type::EditorCanvasLayoutMigrationInput; +pub use editor_canvas_layout_migration_procedure_result_type::EditorCanvasLayoutMigrationProcedureResult; +pub use editor_canvas_layout_migration_snapshot_type::EditorCanvasLayoutMigrationSnapshot; +pub use editor_canvas_layout_migration_table::*; +pub use editor_canvas_layout_migration_type::EditorCanvasLayoutMigration; +pub use editor_canvas_missing_resource_restore_input_type::EditorCanvasMissingResourceRestoreInput; +pub use editor_canvas_resource_remap_input_type::EditorCanvasResourceRemapInput; +pub use editor_canvas_resource_repair_input_type::EditorCanvasResourceRepairInput; +pub use editor_canvas_resource_repair_procedure_result_type::EditorCanvasResourceRepairProcedureResult; +pub use editor_canvas_resource_repair_snapshot_type::EditorCanvasResourceRepairSnapshot; pub use editor_canvas_snapshot_type::EditorCanvasSnapshot; pub use editor_canvas_table::*; pub use editor_canvas_type::EditorCanvas; @@ -1892,6 +1931,9 @@ pub use editor_project_get_recent_input_type::EditorProjectGetRecentInput; pub use editor_project_layout_save_ack_type::EditorProjectLayoutSaveAck; pub use editor_project_layout_save_input_type::EditorProjectLayoutSaveInput; pub use editor_project_layout_save_procedure_result_type::EditorProjectLayoutSaveProcedureResult; +pub use editor_project_layout_save_v_2_ack_type::EditorProjectLayoutSaveV2Ack; +pub use editor_project_layout_save_v_2_input_type::EditorProjectLayoutSaveV2Input; +pub use editor_project_layout_save_v_2_procedure_result_type::EditorProjectLayoutSaveV2ProcedureResult; pub use editor_project_list_input_type::EditorProjectListInput; pub use editor_project_list_procedure_result_type::EditorProjectListProcedureResult; pub use editor_project_procedure_result_type::EditorProjectProcedureResult; @@ -1959,10 +2001,10 @@ pub use external_generation_job_phase_update_failure_kind_type::ExternalGenerati pub use external_generation_job_phase_update_input_type::ExternalGenerationJobPhaseUpdateInput; pub use external_generation_job_phase_update_procedure_result_type::ExternalGenerationJobPhaseUpdateProcedureResult; pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; -pub use external_generation_job_result_procedure_result_type::ExternalGenerationJobResultProcedureResult; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; -pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; +pub use external_generation_job_result_procedure_result_type::ExternalGenerationJobResultProcedureResult; pub use external_generation_job_result_snapshot_type::ExternalGenerationJobResultSnapshot; +pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; pub use external_generation_job_summary_backfill_input_type::ExternalGenerationJobSummaryBackfillInput; pub use external_generation_job_summary_backfill_procedure_result_type::ExternalGenerationJobSummaryBackfillProcedureResult; pub use external_generation_job_summary_procedure_result_type::ExternalGenerationJobSummaryProcedureResult; @@ -2473,6 +2515,7 @@ pub use remix_puzzle_work_procedure::remix_puzzle_work; pub use rename_editor_project_and_return_procedure::rename_editor_project_and_return; pub use renew_external_generation_job_lease_and_return_procedure::renew_external_generation_job_lease_and_return; pub use repair_editor_asset_media_and_return_procedure::repair_editor_asset_media_and_return; +pub use repair_editor_canvas_resources_and_return_procedure::repair_editor_canvas_resources_and_return; pub use repair_editor_project_resource_media_and_return_procedure::repair_editor_project_resource_media_and_return; pub use resolve_combat_action_and_return_procedure::resolve_combat_action_and_return; pub use resolve_combat_action_input_type::ResolveCombatActionInput; @@ -2497,6 +2540,7 @@ pub use resume_profile_save_archive_and_return_procedure::resume_profile_save_ar pub use retry_puzzle_clear_level_run_procedure::retry_puzzle_clear_level_run; pub use revoke_database_migration_operator_procedure::revoke_database_migration_operator; pub use revoke_external_api_key_and_return_procedure::revoke_external_api_key_and_return; +pub use rollback_editor_canvas_layout_and_return_procedure::rollback_editor_canvas_layout_and_return; pub use rotate_editor_generation_runtime_service_identity_and_return_procedure::rotate_editor_generation_runtime_service_identity_and_return; pub use rpg_agent_draft_card_kind_type::RpgAgentDraftCardKind; pub use rpg_agent_draft_card_status_type::RpgAgentDraftCardStatus; @@ -2663,6 +2707,8 @@ pub use runtime_tracking_event_procedure_result_type::RuntimeTrackingEventProced pub use runtime_tracking_scope_kind_type::RuntimeTrackingScopeKind; pub use save_editor_project_layout_ack_procedure::save_editor_project_layout_ack; pub use save_editor_project_layout_and_return_procedure::save_editor_project_layout_and_return; +pub use save_editor_project_layout_v_2_ack_procedure::save_editor_project_layout_v_2_ack; +pub use save_editor_project_layout_v_2_and_return_procedure::save_editor_project_layout_v_2_and_return; pub use save_puzzle_form_draft_procedure::save_puzzle_form_draft; pub use save_puzzle_generated_images_procedure::save_puzzle_generated_images; pub use save_puzzle_ui_background_procedure::save_puzzle_ui_background; @@ -3213,6 +3259,9 @@ pub struct DbUpdate { editor_asset_group_cohort: __sdk::TableUpdate, editor_asset_group_source_provenance: __sdk::TableUpdate, editor_canvas: __sdk::TableUpdate, + editor_canvas_generation_dialog: __sdk::TableUpdate, + editor_canvas_layer: __sdk::TableUpdate, + editor_canvas_layout_migration: __sdk::TableUpdate, editor_generation_pricing_config: __sdk::TableUpdate, editor_generation_runtime_identity_rotation: __sdk::TableUpdate, @@ -3481,6 +3530,19 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "editor_canvas" => db_update .editor_canvas .append(editor_canvas_table::parse_table_update(table_update)?), + "editor_canvas_generation_dialog" => { + db_update.editor_canvas_generation_dialog.append( + editor_canvas_generation_dialog_table::parse_table_update(table_update)?, + ) + } + "editor_canvas_layer" => db_update + .editor_canvas_layer + .append(editor_canvas_layer_table::parse_table_update(table_update)?), + "editor_canvas_layout_migration" => { + db_update.editor_canvas_layout_migration.append( + editor_canvas_layout_migration_table::parse_table_update(table_update)?, + ) + } "editor_generation_pricing_config" => { db_update.editor_generation_pricing_config.append( editor_generation_pricing_config_table::parse_table_update(table_update)?, @@ -4078,6 +4140,24 @@ impl __sdk::DbUpdate for DbUpdate { diff.editor_canvas = cache .apply_diff_to_table::("editor_canvas", &self.editor_canvas) .with_updates_by_pk(|row| &row.canvas_id); + diff.editor_canvas_generation_dialog = cache + .apply_diff_to_table::( + "editor_canvas_generation_dialog", + &self.editor_canvas_generation_dialog, + ) + .with_updates_by_pk(|row| &row.dialog_key); + diff.editor_canvas_layer = cache + .apply_diff_to_table::( + "editor_canvas_layer", + &self.editor_canvas_layer, + ) + .with_updates_by_pk(|row| &row.layer_key); + diff.editor_canvas_layout_migration = cache + .apply_diff_to_table::( + "editor_canvas_layout_migration", + &self.editor_canvas_layout_migration, + ) + .with_updates_by_pk(|row| &row.canvas_id); diff.editor_generation_pricing_config = cache .apply_diff_to_table::( "editor_generation_pricing_config", @@ -4764,6 +4844,15 @@ impl __sdk::DbUpdate for DbUpdate { "editor_canvas" => db_update .editor_canvas .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_canvas_generation_dialog" => db_update + .editor_canvas_generation_dialog + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_canvas_layer" => db_update + .editor_canvas_layer + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_canvas_layout_migration" => db_update + .editor_canvas_layout_migration + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "editor_generation_pricing_config" => db_update .editor_generation_pricing_config .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -5224,6 +5313,15 @@ impl __sdk::DbUpdate for DbUpdate { "editor_canvas" => db_update .editor_canvas .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_canvas_generation_dialog" => db_update + .editor_canvas_generation_dialog + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_canvas_layer" => db_update + .editor_canvas_layer + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_canvas_layout_migration" => db_update + .editor_canvas_layout_migration + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "editor_generation_pricing_config" => db_update .editor_generation_pricing_config .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -5597,6 +5695,9 @@ pub struct AppliedDiff<'r> { editor_asset_group_source_provenance: __sdk::TableAppliedDiff<'r, EditorAssetGroupSourceProvenance>, editor_canvas: __sdk::TableAppliedDiff<'r, EditorCanvas>, + editor_canvas_generation_dialog: __sdk::TableAppliedDiff<'r, EditorCanvasGenerationDialog>, + editor_canvas_layer: __sdk::TableAppliedDiff<'r, EditorCanvasLayer>, + editor_canvas_layout_migration: __sdk::TableAppliedDiff<'r, EditorCanvasLayoutMigration>, editor_generation_pricing_config: __sdk::TableAppliedDiff<'r, EditorGenerationPricingConfig>, editor_generation_runtime_identity_rotation: __sdk::TableAppliedDiff<'r, EditorGenerationRuntimeIdentityRotation>, @@ -5942,6 +6043,21 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.editor_canvas, event, ); + callbacks.invoke_table_row_callbacks::( + "editor_canvas_generation_dialog", + &self.editor_canvas_generation_dialog, + event, + ); + callbacks.invoke_table_row_callbacks::( + "editor_canvas_layer", + &self.editor_canvas_layer, + event, + ); + callbacks.invoke_table_row_callbacks::( + "editor_canvas_layout_migration", + &self.editor_canvas_layout_migration, + event, + ); callbacks.invoke_table_row_callbacks::( "editor_generation_pricing_config", &self.editor_generation_pricing_config, @@ -7151,6 +7267,9 @@ impl __sdk::SpacetimeModule for RemoteModule { editor_asset_group_cohort_table::register_table(client_cache); editor_asset_group_source_provenance_table::register_table(client_cache); editor_canvas_table::register_table(client_cache); + editor_canvas_generation_dialog_table::register_table(client_cache); + editor_canvas_layer_table::register_table(client_cache); + editor_canvas_layout_migration_table::register_table(client_cache); editor_generation_pricing_config_table::register_table(client_cache); editor_generation_runtime_identity_rotation_table::register_table(client_cache); editor_project_table::register_table(client_cache); @@ -7302,6 +7421,9 @@ impl __sdk::SpacetimeModule for RemoteModule { "editor_asset_group_cohort", "editor_asset_group_source_provenance", "editor_canvas", + "editor_canvas_generation_dialog", + "editor_canvas_layer", + "editor_canvas_layout_migration", "editor_generation_pricing_config", "editor_generation_runtime_identity_rotation", "editor_project", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs new file mode 100644 index 000000000..b9c3aa0ba --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/activate_editor_canvas_layout_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_canvas_layout_migration_input_type::EditorCanvasLayoutMigrationInput; +use super::editor_canvas_layout_migration_procedure_result_type::EditorCanvasLayoutMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ActivateEditorCanvasLayoutAndReturnArgs { + pub input: EditorCanvasLayoutMigrationInput, +} + +impl __sdk::InModule for ActivateEditorCanvasLayoutAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `activate_editor_canvas_layout_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait activate_editor_canvas_layout_and_return { + fn activate_editor_canvas_layout_and_return(&self, input: EditorCanvasLayoutMigrationInput) { + self.activate_editor_canvas_layout_and_return_then(input, |_, _| {}); + } + + fn activate_editor_canvas_layout_and_return_then( + &self, + input: EditorCanvasLayoutMigrationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl activate_editor_canvas_layout_and_return for super::RemoteProcedures { + fn activate_editor_canvas_layout_and_return_then( + &self, + input: EditorCanvasLayoutMigrationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>( + "activate_editor_canvas_layout_and_return", + ActivateEditorCanvasLayoutAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs new file mode 100644 index 000000000..9af4d59d3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/backfill_editor_canvas_layout_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_canvas_layout_migration_input_type::EditorCanvasLayoutMigrationInput; +use super::editor_canvas_layout_migration_procedure_result_type::EditorCanvasLayoutMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct BackfillEditorCanvasLayoutAndReturnArgs { + pub input: EditorCanvasLayoutMigrationInput, +} + +impl __sdk::InModule for BackfillEditorCanvasLayoutAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `backfill_editor_canvas_layout_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait backfill_editor_canvas_layout_and_return { + fn backfill_editor_canvas_layout_and_return(&self, input: EditorCanvasLayoutMigrationInput) { + self.backfill_editor_canvas_layout_and_return_then(input, |_, _| {}); + } + + fn backfill_editor_canvas_layout_and_return_then( + &self, + input: EditorCanvasLayoutMigrationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl backfill_editor_canvas_layout_and_return for super::RemoteProcedures { + fn backfill_editor_canvas_layout_and_return_then( + &self, + input: EditorCanvasLayoutMigrationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>( + "backfill_editor_canvas_layout_and_return", + BackfillEditorCanvasLayoutAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_table.rs new file mode 100644 index 000000000..353947c22 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_table.rs @@ -0,0 +1,169 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_canvas_generation_dialog_type::EditorCanvasGenerationDialog; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_canvas_generation_dialog`. +/// +/// Obtain a handle from the [`EditorCanvasGenerationDialogTableAccess::editor_canvas_generation_dialog`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_canvas_generation_dialog()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas_generation_dialog().on_insert(...)`. +pub struct EditorCanvasGenerationDialogTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_canvas_generation_dialog`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorCanvasGenerationDialogTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorCanvasGenerationDialogTableHandle`], which mediates access to the table `editor_canvas_generation_dialog`. + fn editor_canvas_generation_dialog(&self) -> EditorCanvasGenerationDialogTableHandle<'_>; +} + +impl EditorCanvasGenerationDialogTableAccess for super::RemoteTables { + fn editor_canvas_generation_dialog(&self) -> EditorCanvasGenerationDialogTableHandle<'_> { + EditorCanvasGenerationDialogTableHandle { + imp: self + .imp + .get_table::("editor_canvas_generation_dialog"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorCanvasGenerationDialogInsertCallbackId(__sdk::CallbackId); +pub struct EditorCanvasGenerationDialogDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for EditorCanvasGenerationDialogTableHandle<'ctx> { + type Row = EditorCanvasGenerationDialog; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorCanvasGenerationDialogInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasGenerationDialogInsertCallbackId { + EditorCanvasGenerationDialogInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorCanvasGenerationDialogInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorCanvasGenerationDialogDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasGenerationDialogDeleteCallbackId { + EditorCanvasGenerationDialogDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorCanvasGenerationDialogDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorCanvasGenerationDialogUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorCanvasGenerationDialogTableHandle<'ctx> { + type UpdateCallbackId = EditorCanvasGenerationDialogUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorCanvasGenerationDialogUpdateCallbackId { + EditorCanvasGenerationDialogUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorCanvasGenerationDialogUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `dialog_key` unique index on the table `editor_canvas_generation_dialog`, +/// which allows point queries on the field of the same name +/// via the [`EditorCanvasGenerationDialogDialogKeyUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas_generation_dialog().dialog_key().find(...)`. +pub struct EditorCanvasGenerationDialogDialogKeyUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorCanvasGenerationDialogTableHandle<'ctx> { + /// Get a handle on the `dialog_key` unique index on the table `editor_canvas_generation_dialog`. + pub fn dialog_key(&self) -> EditorCanvasGenerationDialogDialogKeyUnique<'ctx> { + EditorCanvasGenerationDialogDialogKeyUnique { + imp: self.imp.get_unique_constraint::("dialog_key"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorCanvasGenerationDialogDialogKeyUnique<'ctx> { + /// Find the subscribed row whose `dialog_key` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("editor_canvas_generation_dialog"); + _table.add_unique_constraint::("dialog_key", |row| &row.dialog_key); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorCanvasGenerationDialog`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_canvas_generation_dialogQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorCanvasGenerationDialog`. + fn editor_canvas_generation_dialog( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl editor_canvas_generation_dialogQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_canvas_generation_dialog( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_canvas_generation_dialog") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_type.rs new file mode 100644 index 000000000..adbbc6125 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_generation_dialog_type.rs @@ -0,0 +1,108 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasGenerationDialog { + pub dialog_key: String, + pub canvas_id: String, + pub project_id: String, + pub owner_user_id: String, + pub dialog_id: String, + pub layer_id: String, + pub resource_id: String, + pub sort_order: u32, + pub mode: String, + pub status: String, + pub source_layer_id: Option, + pub generated_layer_id: Option, + pub placeholder_x: Option, + pub placeholder_y: Option, + pub placeholder_width: Option, + pub placeholder_height: Option, + pub dialog_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorCanvasGenerationDialog { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorCanvasGenerationDialog`. +/// +/// Provides typed access to columns for query building. +pub struct EditorCanvasGenerationDialogCols { + pub dialog_key: __sdk::__query_builder::Col, + pub canvas_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub dialog_id: __sdk::__query_builder::Col, + pub layer_id: __sdk::__query_builder::Col, + pub resource_id: __sdk::__query_builder::Col, + pub sort_order: __sdk::__query_builder::Col, + pub mode: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub source_layer_id: __sdk::__query_builder::Col>, + pub generated_layer_id: + __sdk::__query_builder::Col>, + pub placeholder_x: __sdk::__query_builder::Col>, + pub placeholder_y: __sdk::__query_builder::Col>, + pub placeholder_width: __sdk::__query_builder::Col>, + pub placeholder_height: __sdk::__query_builder::Col>, + pub dialog_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorCanvasGenerationDialog { + type Cols = EditorCanvasGenerationDialogCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorCanvasGenerationDialogCols { + dialog_key: __sdk::__query_builder::Col::new(table_name, "dialog_key"), + canvas_id: __sdk::__query_builder::Col::new(table_name, "canvas_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + dialog_id: __sdk::__query_builder::Col::new(table_name, "dialog_id"), + layer_id: __sdk::__query_builder::Col::new(table_name, "layer_id"), + resource_id: __sdk::__query_builder::Col::new(table_name, "resource_id"), + sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), + mode: __sdk::__query_builder::Col::new(table_name, "mode"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + source_layer_id: __sdk::__query_builder::Col::new(table_name, "source_layer_id"), + generated_layer_id: __sdk::__query_builder::Col::new(table_name, "generated_layer_id"), + placeholder_x: __sdk::__query_builder::Col::new(table_name, "placeholder_x"), + placeholder_y: __sdk::__query_builder::Col::new(table_name, "placeholder_y"), + placeholder_width: __sdk::__query_builder::Col::new(table_name, "placeholder_width"), + placeholder_height: __sdk::__query_builder::Col::new(table_name, "placeholder_height"), + dialog_json: __sdk::__query_builder::Col::new(table_name, "dialog_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorCanvasGenerationDialog`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorCanvasGenerationDialogIxCols { + pub canvas_id: __sdk::__query_builder::IxCol, + pub dialog_key: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorCanvasGenerationDialog { + type IxCols = EditorCanvasGenerationDialogIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorCanvasGenerationDialogIxCols { + canvas_id: __sdk::__query_builder::IxCol::new(table_name, "canvas_id"), + dialog_key: __sdk::__query_builder::IxCol::new(table_name, "dialog_key"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorCanvasGenerationDialog {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_table.rs new file mode 100644 index 000000000..d3561fe47 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_table.rs @@ -0,0 +1,161 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_canvas_layer_type::EditorCanvasLayer; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_canvas_layer`. +/// +/// Obtain a handle from the [`EditorCanvasLayerTableAccess::editor_canvas_layer`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_canvas_layer()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas_layer().on_insert(...)`. +pub struct EditorCanvasLayerTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_canvas_layer`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorCanvasLayerTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorCanvasLayerTableHandle`], which mediates access to the table `editor_canvas_layer`. + fn editor_canvas_layer(&self) -> EditorCanvasLayerTableHandle<'_>; +} + +impl EditorCanvasLayerTableAccess for super::RemoteTables { + fn editor_canvas_layer(&self) -> EditorCanvasLayerTableHandle<'_> { + EditorCanvasLayerTableHandle { + imp: self + .imp + .get_table::("editor_canvas_layer"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorCanvasLayerInsertCallbackId(__sdk::CallbackId); +pub struct EditorCanvasLayerDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for EditorCanvasLayerTableHandle<'ctx> { + type Row = EditorCanvasLayer; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorCanvasLayerInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasLayerInsertCallbackId { + EditorCanvasLayerInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorCanvasLayerInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorCanvasLayerDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasLayerDeleteCallbackId { + EditorCanvasLayerDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorCanvasLayerDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorCanvasLayerUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorCanvasLayerTableHandle<'ctx> { + type UpdateCallbackId = EditorCanvasLayerUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorCanvasLayerUpdateCallbackId { + EditorCanvasLayerUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorCanvasLayerUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `layer_key` unique index on the table `editor_canvas_layer`, +/// which allows point queries on the field of the same name +/// via the [`EditorCanvasLayerLayerKeyUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas_layer().layer_key().find(...)`. +pub struct EditorCanvasLayerLayerKeyUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorCanvasLayerTableHandle<'ctx> { + /// Get a handle on the `layer_key` unique index on the table `editor_canvas_layer`. + pub fn layer_key(&self) -> EditorCanvasLayerLayerKeyUnique<'ctx> { + EditorCanvasLayerLayerKeyUnique { + imp: self.imp.get_unique_constraint::("layer_key"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorCanvasLayerLayerKeyUnique<'ctx> { + /// Find the subscribed row whose `layer_key` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache.get_or_make_table::("editor_canvas_layer"); + _table.add_unique_constraint::("layer_key", |row| &row.layer_key); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorCanvasLayer`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_canvas_layerQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorCanvasLayer`. + fn editor_canvas_layer(&self) -> __sdk::__query_builder::Table; +} + +impl editor_canvas_layerQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_canvas_layer(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_canvas_layer") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs new file mode 100644 index 000000000..8c0c28706 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layer_type.rs @@ -0,0 +1,122 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasLayer { + pub layer_key: String, + pub canvas_id: String, + pub project_id: String, + pub owner_user_id: String, + pub layer_id: String, + pub resource_id: String, + pub sort_order: u32, + pub title: Option, + pub x: Option, + pub y: Option, + pub width: Option, + pub height: Option, + pub original_width: Option, + pub original_height: Option, + pub z_index: Option, + pub source_type: Option, + pub group_id: Option, + pub hidden: bool, + pub locked: bool, + pub flip_x: bool, + pub flip_y: bool, + pub item_json: String, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorCanvasLayer { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorCanvasLayer`. +/// +/// Provides typed access to columns for query building. +pub struct EditorCanvasLayerCols { + pub layer_key: __sdk::__query_builder::Col, + pub canvas_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub layer_id: __sdk::__query_builder::Col, + pub resource_id: __sdk::__query_builder::Col, + pub sort_order: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col>, + pub x: __sdk::__query_builder::Col>, + pub y: __sdk::__query_builder::Col>, + pub width: __sdk::__query_builder::Col>, + pub height: __sdk::__query_builder::Col>, + pub original_width: __sdk::__query_builder::Col>, + pub original_height: __sdk::__query_builder::Col>, + pub z_index: __sdk::__query_builder::Col>, + pub source_type: __sdk::__query_builder::Col>, + pub group_id: __sdk::__query_builder::Col>, + pub hidden: __sdk::__query_builder::Col, + pub locked: __sdk::__query_builder::Col, + pub flip_x: __sdk::__query_builder::Col, + pub flip_y: __sdk::__query_builder::Col, + pub item_json: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorCanvasLayer { + type Cols = EditorCanvasLayerCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorCanvasLayerCols { + layer_key: __sdk::__query_builder::Col::new(table_name, "layer_key"), + canvas_id: __sdk::__query_builder::Col::new(table_name, "canvas_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + layer_id: __sdk::__query_builder::Col::new(table_name, "layer_id"), + resource_id: __sdk::__query_builder::Col::new(table_name, "resource_id"), + sort_order: __sdk::__query_builder::Col::new(table_name, "sort_order"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + x: __sdk::__query_builder::Col::new(table_name, "x"), + y: __sdk::__query_builder::Col::new(table_name, "y"), + width: __sdk::__query_builder::Col::new(table_name, "width"), + height: __sdk::__query_builder::Col::new(table_name, "height"), + original_width: __sdk::__query_builder::Col::new(table_name, "original_width"), + original_height: __sdk::__query_builder::Col::new(table_name, "original_height"), + z_index: __sdk::__query_builder::Col::new(table_name, "z_index"), + source_type: __sdk::__query_builder::Col::new(table_name, "source_type"), + group_id: __sdk::__query_builder::Col::new(table_name, "group_id"), + hidden: __sdk::__query_builder::Col::new(table_name, "hidden"), + locked: __sdk::__query_builder::Col::new(table_name, "locked"), + flip_x: __sdk::__query_builder::Col::new(table_name, "flip_x"), + flip_y: __sdk::__query_builder::Col::new(table_name, "flip_y"), + item_json: __sdk::__query_builder::Col::new(table_name, "item_json"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorCanvasLayer`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorCanvasLayerIxCols { + pub canvas_id: __sdk::__query_builder::IxCol, + pub layer_key: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorCanvasLayer { + type IxCols = EditorCanvasLayerIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorCanvasLayerIxCols { + canvas_id: __sdk::__query_builder::IxCol::new(table_name, "canvas_id"), + layer_key: __sdk::__query_builder::IxCol::new(table_name, "layer_key"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorCanvasLayer {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_input_type.rs new file mode 100644 index 000000000..bda8cd46f --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasLayoutMigrationInput { + pub project_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, + pub dry_run: bool, +} + +impl __sdk::InModule for EditorCanvasLayoutMigrationInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_procedure_result_type.rs new file mode 100644 index 000000000..d720a33f6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_canvas_layout_migration_snapshot_type::EditorCanvasLayoutMigrationSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasLayoutMigrationProcedureResult { + pub ok: bool, + pub migration: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorCanvasLayoutMigrationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_snapshot_type.rs new file mode 100644 index 000000000..14f8d2234 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_snapshot_type.rs @@ -0,0 +1,25 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasLayoutMigrationSnapshot { + pub canvas_id: String, + pub project_id: String, + pub source_layout_sha_256: String, + pub structured_layout_sha_256: String, + pub verified_revision: u64, + pub layer_count: u32, + pub dialog_count: u32, + pub resource_refs_sha_256: String, + pub migration_version: u32, + pub status: String, + pub revision: u64, +} + +impl __sdk::InModule for EditorCanvasLayoutMigrationSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_table.rs new file mode 100644 index 000000000..77dfffaf4 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_table.rs @@ -0,0 +1,169 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_canvas_layout_migration_type::EditorCanvasLayoutMigration; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_canvas_layout_migration`. +/// +/// Obtain a handle from the [`EditorCanvasLayoutMigrationTableAccess::editor_canvas_layout_migration`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_canvas_layout_migration()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas_layout_migration().on_insert(...)`. +pub struct EditorCanvasLayoutMigrationTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_canvas_layout_migration`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorCanvasLayoutMigrationTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorCanvasLayoutMigrationTableHandle`], which mediates access to the table `editor_canvas_layout_migration`. + fn editor_canvas_layout_migration(&self) -> EditorCanvasLayoutMigrationTableHandle<'_>; +} + +impl EditorCanvasLayoutMigrationTableAccess for super::RemoteTables { + fn editor_canvas_layout_migration(&self) -> EditorCanvasLayoutMigrationTableHandle<'_> { + EditorCanvasLayoutMigrationTableHandle { + imp: self + .imp + .get_table::("editor_canvas_layout_migration"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorCanvasLayoutMigrationInsertCallbackId(__sdk::CallbackId); +pub struct EditorCanvasLayoutMigrationDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for EditorCanvasLayoutMigrationTableHandle<'ctx> { + type Row = EditorCanvasLayoutMigration; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorCanvasLayoutMigrationInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasLayoutMigrationInsertCallbackId { + EditorCanvasLayoutMigrationInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorCanvasLayoutMigrationInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorCanvasLayoutMigrationDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorCanvasLayoutMigrationDeleteCallbackId { + EditorCanvasLayoutMigrationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorCanvasLayoutMigrationDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorCanvasLayoutMigrationUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorCanvasLayoutMigrationTableHandle<'ctx> { + type UpdateCallbackId = EditorCanvasLayoutMigrationUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorCanvasLayoutMigrationUpdateCallbackId { + EditorCanvasLayoutMigrationUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorCanvasLayoutMigrationUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `canvas_id` unique index on the table `editor_canvas_layout_migration`, +/// which allows point queries on the field of the same name +/// via the [`EditorCanvasLayoutMigrationCanvasIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_canvas_layout_migration().canvas_id().find(...)`. +pub struct EditorCanvasLayoutMigrationCanvasIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorCanvasLayoutMigrationTableHandle<'ctx> { + /// Get a handle on the `canvas_id` unique index on the table `editor_canvas_layout_migration`. + pub fn canvas_id(&self) -> EditorCanvasLayoutMigrationCanvasIdUnique<'ctx> { + EditorCanvasLayoutMigrationCanvasIdUnique { + imp: self.imp.get_unique_constraint::("canvas_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorCanvasLayoutMigrationCanvasIdUnique<'ctx> { + /// Find the subscribed row whose `canvas_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = client_cache + .get_or_make_table::("editor_canvas_layout_migration"); + _table.add_unique_constraint::("canvas_id", |row| &row.canvas_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse( + "TableUpdate", + "TableUpdate", + ) + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorCanvasLayoutMigration`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_canvas_layout_migrationQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorCanvasLayoutMigration`. + fn editor_canvas_layout_migration( + &self, + ) -> __sdk::__query_builder::Table; +} + +impl editor_canvas_layout_migrationQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_canvas_layout_migration( + &self, + ) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_canvas_layout_migration") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_type.rs new file mode 100644 index 000000000..5318a6bd1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_layout_migration_type.rs @@ -0,0 +1,104 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasLayoutMigration { + pub canvas_id: String, + pub project_id: String, + pub owner_user_id: String, + pub source_layout_sha_256: String, + pub structured_layout_sha_256: String, + pub verified_revision: u64, + pub layer_count: u32, + pub dialog_count: u32, + pub resource_refs_sha_256: String, + pub migration_version: u32, + pub status: String, + pub migrated_at: __sdk::Timestamp, + pub activated_at: Option<__sdk::Timestamp>, + pub rolled_back_at: Option<__sdk::Timestamp>, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorCanvasLayoutMigration { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorCanvasLayoutMigration`. +/// +/// Provides typed access to columns for query building. +pub struct EditorCanvasLayoutMigrationCols { + pub canvas_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub source_layout_sha_256: __sdk::__query_builder::Col, + pub structured_layout_sha_256: __sdk::__query_builder::Col, + pub verified_revision: __sdk::__query_builder::Col, + pub layer_count: __sdk::__query_builder::Col, + pub dialog_count: __sdk::__query_builder::Col, + pub resource_refs_sha_256: __sdk::__query_builder::Col, + pub migration_version: __sdk::__query_builder::Col, + pub status: __sdk::__query_builder::Col, + pub migrated_at: __sdk::__query_builder::Col, + pub activated_at: + __sdk::__query_builder::Col>, + pub rolled_back_at: + __sdk::__query_builder::Col>, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorCanvasLayoutMigration { + type Cols = EditorCanvasLayoutMigrationCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorCanvasLayoutMigrationCols { + canvas_id: __sdk::__query_builder::Col::new(table_name, "canvas_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + source_layout_sha_256: __sdk::__query_builder::Col::new( + table_name, + "source_layout_sha_256", + ), + structured_layout_sha_256: __sdk::__query_builder::Col::new( + table_name, + "structured_layout_sha_256", + ), + verified_revision: __sdk::__query_builder::Col::new(table_name, "verified_revision"), + layer_count: __sdk::__query_builder::Col::new(table_name, "layer_count"), + dialog_count: __sdk::__query_builder::Col::new(table_name, "dialog_count"), + resource_refs_sha_256: __sdk::__query_builder::Col::new( + table_name, + "resource_refs_sha_256", + ), + migration_version: __sdk::__query_builder::Col::new(table_name, "migration_version"), + status: __sdk::__query_builder::Col::new(table_name, "status"), + migrated_at: __sdk::__query_builder::Col::new(table_name, "migrated_at"), + activated_at: __sdk::__query_builder::Col::new(table_name, "activated_at"), + rolled_back_at: __sdk::__query_builder::Col::new(table_name, "rolled_back_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorCanvasLayoutMigration`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorCanvasLayoutMigrationIxCols { + pub canvas_id: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorCanvasLayoutMigration { + type IxCols = EditorCanvasLayoutMigrationIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorCanvasLayoutMigrationIxCols { + canvas_id: __sdk::__query_builder::IxCol::new(table_name, "canvas_id"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorCanvasLayoutMigration {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_missing_resource_restore_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_missing_resource_restore_input_type.rs new file mode 100644 index 000000000..97cb34f5d --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_missing_resource_restore_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasMissingResourceRestoreInput { + pub layer_id: String, + pub resource_id: String, + pub asset_object_id: String, + pub asset_kind: String, +} + +impl __sdk::InModule for EditorCanvasMissingResourceRestoreInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_remap_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_remap_input_type.rs new file mode 100644 index 000000000..4519f05c0 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_remap_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasResourceRemapInput { + pub layer_id: String, + pub expected_missing_resource_id: String, + pub replacement_resource_id: String, + pub expected_layout_source_resource_id: String, +} + +impl __sdk::InModule for EditorCanvasResourceRemapInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_input_type.rs new file mode 100644 index 000000000..88338ebeb --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_input_type.rs @@ -0,0 +1,27 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_canvas_missing_resource_restore_input_type::EditorCanvasMissingResourceRestoreInput; +use super::editor_canvas_resource_remap_input_type::EditorCanvasResourceRemapInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasResourceRepairInput { + pub canvas_id: String, + pub project_id: String, + pub owner_user_id: String, + pub expected_revision: u64, + pub expected_canvas_layout_sha_256: String, + pub expected_project_layout_sha_256: String, + pub remaps: Vec, + pub restores: Vec, + pub updated_at_micros: i64, + pub dry_run: bool, +} + +impl __sdk::InModule for EditorCanvasResourceRepairInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_procedure_result_type.rs new file mode 100644 index 000000000..1534f21ca --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_canvas_resource_repair_snapshot_type::EditorCanvasResourceRepairSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasResourceRepairProcedureResult { + pub ok: bool, + pub repair: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorCanvasResourceRepairProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_snapshot_type.rs new file mode 100644 index 000000000..9bc537d22 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_resource_repair_snapshot_type.rs @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorCanvasResourceRepairSnapshot { + pub dry_run: bool, + pub already_repaired: bool, + pub matched_layer_count: u32, + pub remapped_layer_count: u32, + pub restored_resource_count: u32, + pub removed_source_resource_id_count: u32, + pub revision: u64, +} + +impl __sdk::InModule for EditorCanvasResourceRepairSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs index a11f1bb4c..373e420d7 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_snapshot_type.rs @@ -16,6 +16,9 @@ pub struct EditorCanvasSnapshot { pub layers_json: String, pub created_at_micros: i64, pub updated_at_micros: i64, + pub revision: u64, + pub layout_storage_version: u32, + pub background_color: Option, } impl __sdk::InModule for EditorCanvasSnapshot { diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs index 0854e6adc..a4a4982ba 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_canvas_type.rs @@ -17,6 +17,9 @@ pub struct EditorCanvas { pub layers_json: String, pub created_at: __sdk::Timestamp, pub updated_at: __sdk::Timestamp, + pub revision: u64, + pub layout_storage_version: u32, + pub background_color: Option, } impl __sdk::InModule for EditorCanvas { @@ -37,6 +40,9 @@ pub struct EditorCanvasCols { pub layers_json: __sdk::__query_builder::Col, pub created_at: __sdk::__query_builder::Col, pub updated_at: __sdk::__query_builder::Col, + pub revision: __sdk::__query_builder::Col, + pub layout_storage_version: __sdk::__query_builder::Col, + pub background_color: __sdk::__query_builder::Col>, } impl __sdk::__query_builder::HasCols for EditorCanvas { @@ -53,6 +59,12 @@ impl __sdk::__query_builder::HasCols for EditorCanvas { layers_json: __sdk::__query_builder::Col::new(table_name, "layers_json"), created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + revision: __sdk::__query_builder::Col::new(table_name, "revision"), + layout_storage_version: __sdk::__query_builder::Col::new( + table_name, + "layout_storage_version", + ), + background_color: __sdk::__query_builder::Col::new(table_name, "background_color"), } } } diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_ack_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_ack_type.rs new file mode 100644 index 000000000..9f75a6af8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_ack_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectLayoutSaveV2Ack { + pub project_id: String, + pub canvas_id: String, + pub revision: u64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectLayoutSaveV2Ack { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_input_type.rs new file mode 100644 index 000000000..c4e37b916 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_input_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_viewport_snapshot_type::EditorProjectViewportSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectLayoutSaveV2Input { + pub project_id: String, + pub owner_user_id: String, + pub viewport: EditorProjectViewportSnapshot, + pub layers_json: String, + pub expected_revision: u64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorProjectLayoutSaveV2Input { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_procedure_result_type.rs new file mode 100644 index 000000000..7f37e6a51 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_project_layout_save_v_2_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_layout_save_v_2_ack_type::EditorProjectLayoutSaveV2Ack; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorProjectLayoutSaveV2ProcedureResult { + pub ok: bool, + pub ack: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorProjectLayoutSaveV2ProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs new file mode 100644 index 000000000..a45b084b1 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/repair_editor_canvas_resources_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_canvas_resource_repair_input_type::EditorCanvasResourceRepairInput; +use super::editor_canvas_resource_repair_procedure_result_type::EditorCanvasResourceRepairProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RepairEditorCanvasResourcesAndReturnArgs { + pub input: EditorCanvasResourceRepairInput, +} + +impl __sdk::InModule for RepairEditorCanvasResourcesAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `repair_editor_canvas_resources_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait repair_editor_canvas_resources_and_return { + fn repair_editor_canvas_resources_and_return(&self, input: EditorCanvasResourceRepairInput) { + self.repair_editor_canvas_resources_and_return_then(input, |_, _| {}); + } + + fn repair_editor_canvas_resources_and_return_then( + &self, + input: EditorCanvasResourceRepairInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl repair_editor_canvas_resources_and_return for super::RemoteProcedures { + fn repair_editor_canvas_resources_and_return_then( + &self, + input: EditorCanvasResourceRepairInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorCanvasResourceRepairProcedureResult>( + "repair_editor_canvas_resources_and_return", + RepairEditorCanvasResourcesAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs new file mode 100644 index 000000000..ef6918cf8 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/rollback_editor_canvas_layout_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_canvas_layout_migration_input_type::EditorCanvasLayoutMigrationInput; +use super::editor_canvas_layout_migration_procedure_result_type::EditorCanvasLayoutMigrationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct RollbackEditorCanvasLayoutAndReturnArgs { + pub input: EditorCanvasLayoutMigrationInput, +} + +impl __sdk::InModule for RollbackEditorCanvasLayoutAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `rollback_editor_canvas_layout_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait rollback_editor_canvas_layout_and_return { + fn rollback_editor_canvas_layout_and_return(&self, input: EditorCanvasLayoutMigrationInput) { + self.rollback_editor_canvas_layout_and_return_then(input, |_, _| {}); + } + + fn rollback_editor_canvas_layout_and_return_then( + &self, + input: EditorCanvasLayoutMigrationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl rollback_editor_canvas_layout_and_return for super::RemoteProcedures { + fn rollback_editor_canvas_layout_and_return_then( + &self, + input: EditorCanvasLayoutMigrationInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorCanvasLayoutMigrationProcedureResult>( + "rollback_editor_canvas_layout_and_return", + RollbackEditorCanvasLayoutAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs new file mode 100644 index 000000000..457b4a069 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_ack_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_layout_save_v_2_input_type::EditorProjectLayoutSaveV2Input; +use super::editor_project_layout_save_v_2_procedure_result_type::EditorProjectLayoutSaveV2ProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SaveEditorProjectLayoutV2AckArgs { + pub input: EditorProjectLayoutSaveV2Input, +} + +impl __sdk::InModule for SaveEditorProjectLayoutV2AckArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `save_editor_project_layout_v_2_ack`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait save_editor_project_layout_v_2_ack { + fn save_editor_project_layout_v_2_ack(&self, input: EditorProjectLayoutSaveV2Input) { + self.save_editor_project_layout_v_2_ack_then(input, |_, _| {}); + } + + fn save_editor_project_layout_v_2_ack_then( + &self, + input: EditorProjectLayoutSaveV2Input, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl save_editor_project_layout_v_2_ack for super::RemoteProcedures { + fn save_editor_project_layout_v_2_ack_then( + &self, + input: EditorProjectLayoutSaveV2Input, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectLayoutSaveV2ProcedureResult>( + "save_editor_project_layout_v_2_ack", + SaveEditorProjectLayoutV2AckArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs new file mode 100644 index 000000000..4d5eabf86 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/save_editor_project_layout_v_2_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_project_layout_save_v_2_input_type::EditorProjectLayoutSaveV2Input; +use super::editor_project_procedure_result_type::EditorProjectProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct SaveEditorProjectLayoutV2AndReturnArgs { + pub input: EditorProjectLayoutSaveV2Input, +} + +impl __sdk::InModule for SaveEditorProjectLayoutV2AndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `save_editor_project_layout_v_2_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait save_editor_project_layout_v_2_and_return { + fn save_editor_project_layout_v_2_and_return(&self, input: EditorProjectLayoutSaveV2Input) { + self.save_editor_project_layout_v_2_and_return_then(input, |_, _| {}); + } + + fn save_editor_project_layout_v_2_and_return_then( + &self, + input: EditorProjectLayoutSaveV2Input, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl save_editor_project_layout_v_2_and_return for super::RemoteProcedures { + fn save_editor_project_layout_v_2_and_return_then( + &self, + input: EditorProjectLayoutSaveV2Input, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorProjectProcedureResult>( + "save_editor_project_layout_v_2_and_return", + SaveEditorProjectLayoutV2AndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs index e4fae51d8..0d00a3584 100644 --- a/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs +++ b/server-rs/crates/spacetime-module/src/asset_metadata/objects.rs @@ -366,6 +366,17 @@ pub(crate) fn has_asset_object(ctx: &ReducerContext, asset_object_id: &str) -> b .is_some() } +pub(crate) fn find_asset_object_snapshot_by_id( + ctx: &ReducerContext, + asset_object_id: &str, +) -> Option { + ctx.db + .asset_object() + .asset_object_id() + .find(&asset_object_id.trim().to_string()) + .map(asset_object_snapshot) +} + fn list_asset_history( ctx: &ReducerContext, input: AssetHistoryListInput, diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index 70d8f8d52..199d57e8d 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1,4 +1,5 @@ use crate::*; +use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet}; const EDITOR_PROJECT_DEFAULT_TITLE: &str = "未命名画布"; @@ -8,6 +9,28 @@ const EDITOR_ASSET_DEFAULT_FOLDER_LABEL: &str = "项目素材"; const EDITOR_PROJECT_MAX_TITLE_CHARS: usize = 80; const EDITOR_ASSET_MAX_LABEL_CHARS: usize = 80; const EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES: usize = 2 * 1024 * 1024; +const EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES: usize = 512 * 1024; +const EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED: u32 = 1; +const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED: &str = "backfilled"; +const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE: &str = "active"; +const EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK: &str = "rolled_back"; +const EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS: usize = 16; +const EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH: u32 = 420; +const EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT: u32 = 120; +const EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS: [&str; 12] = [ + "src", + "imageSrc", + "prompt", + "actualPrompt", + "model", + "provider", + "taskId", + "objectKey", + "assetObjectId", + "sourceResourceId", + "assetKind", + "generationInputs", +]; const EDITOR_PROJECT_SOURCE_TYPES: [&str; 3] = ["uploaded", "generated", "mock_generated"]; const EDITOR_SHOWCASE_STATUS_PENDING: &str = "pending"; const EDITOR_SHOWCASE_STATUS_APPROVED: &str = "approved"; @@ -71,6 +94,7 @@ pub struct EditorProject { index(accessor = by_editor_canvas_project_id, btree(columns = [project_id])), index(accessor = by_editor_canvas_owner_user_id, btree(columns = [owner_user_id])) )] +#[derive(Clone)] pub struct EditorCanvas { #[primary_key] canvas_id: String, @@ -83,6 +107,97 @@ pub struct EditorCanvas { layers_json: String, created_at: Timestamp, updated_at: Timestamp, + #[default(0u64)] + revision: u64, + #[default(0u32)] + layout_storage_version: u32, + #[default(None::)] + background_color: Option, +} + +#[spacetimedb::table( + accessor = editor_canvas_layer, + index(accessor = by_editor_canvas_layer_canvas_id, btree(columns = [canvas_id])), + index(accessor = by_editor_canvas_layer_project_id, btree(columns = [project_id])) +)] +pub struct EditorCanvasLayer { + #[primary_key] + layer_key: String, + canvas_id: String, + project_id: String, + owner_user_id: String, + layer_id: String, + resource_id: String, + sort_order: u32, + title: Option, + x: Option, + y: Option, + width: Option, + height: Option, + original_width: Option, + original_height: Option, + z_index: Option, + source_type: Option, + group_id: Option, + hidden: bool, + locked: bool, + flip_x: bool, + flip_y: bool, + item_json: String, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = editor_canvas_generation_dialog, + index(accessor = by_editor_canvas_generation_dialog_canvas_id, btree(columns = [canvas_id])), + index(accessor = by_editor_canvas_generation_dialog_project_id, btree(columns = [project_id])) +)] +pub struct EditorCanvasGenerationDialog { + #[primary_key] + dialog_key: String, + canvas_id: String, + project_id: String, + owner_user_id: String, + dialog_id: String, + layer_id: String, + resource_id: String, + sort_order: u32, + mode: String, + status: String, + source_layer_id: Option, + generated_layer_id: Option, + placeholder_x: Option, + placeholder_y: Option, + placeholder_width: Option, + placeholder_height: Option, + dialog_json: String, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[spacetimedb::table( + accessor = editor_canvas_layout_migration, + index(accessor = by_editor_canvas_layout_migration_project_id, btree(columns = [project_id])) +)] +#[derive(Clone)] +pub struct EditorCanvasLayoutMigration { + #[primary_key] + canvas_id: String, + project_id: String, + owner_user_id: String, + source_layout_sha256: String, + structured_layout_sha256: String, + verified_revision: u64, + layer_count: u32, + dialog_count: u32, + resource_refs_sha256: String, + migration_version: u32, + status: String, + migrated_at: Timestamp, + activated_at: Option, + rolled_back_at: Option, + updated_at: Timestamp, } #[spacetimedb::table( @@ -90,6 +205,7 @@ pub struct EditorCanvas { index(accessor = by_editor_project_resource_project_id, btree(columns = [project_id])), index(accessor = by_editor_project_resource_owner_user_id, btree(columns = [owner_user_id])) )] +#[derive(Clone)] pub struct EditorProjectResource { #[primary_key] resource_id: String, @@ -351,6 +467,94 @@ pub struct EditorProjectLayoutSaveInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, SpacetimeType)] +pub struct EditorProjectLayoutSaveV2Input { + pub project_id: String, + pub owner_user_id: String, + pub viewport: EditorProjectViewportSnapshot, + pub layers_json: String, + pub expected_revision: u64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasLayoutMigrationInput { + pub project_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, + pub dry_run: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasLayoutMigrationSnapshot { + pub canvas_id: String, + pub project_id: String, + pub source_layout_sha256: String, + pub structured_layout_sha256: String, + pub verified_revision: u64, + pub layer_count: u32, + pub dialog_count: u32, + pub resource_refs_sha256: String, + pub migration_version: u32, + pub status: String, + pub revision: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasLayoutMigrationProcedureResult { + pub ok: bool, + pub migration: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasResourceRemapInput { + pub layer_id: String, + pub expected_missing_resource_id: String, + pub replacement_resource_id: String, + pub expected_layout_source_resource_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasMissingResourceRestoreInput { + pub layer_id: String, + pub resource_id: String, + pub asset_object_id: String, + pub asset_kind: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasResourceRepairInput { + pub canvas_id: String, + pub project_id: String, + pub owner_user_id: String, + pub expected_revision: u64, + pub expected_canvas_layout_sha256: String, + pub expected_project_layout_sha256: String, + pub remaps: Vec, + pub restores: Vec, + pub updated_at_micros: i64, + pub dry_run: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasResourceRepairSnapshot { + pub dry_run: bool, + pub already_repaired: bool, + pub matched_layer_count: u32, + pub remapped_layer_count: u32, + pub restored_resource_count: u32, + pub removed_source_resource_id_count: u32, + pub revision: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorCanvasResourceRepairProcedureResult { + pub ok: bool, + pub repair: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectResourceCreateInput { pub resource_id: String, @@ -769,6 +973,9 @@ pub struct EditorCanvasSnapshot { pub layers_json: String, pub created_at_micros: i64, pub updated_at_micros: i64, + pub revision: u64, + pub layout_storage_version: u32, + pub background_color: Option, } #[derive(Clone, Debug, PartialEq, SpacetimeType)] @@ -796,6 +1003,21 @@ pub struct EditorProjectLayoutSaveAck { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectLayoutSaveV2Ack { + pub project_id: String, + pub canvas_id: String, + pub revision: u64, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorProjectLayoutSaveV2ProcedureResult { + pub ok: bool, + pub ack: Option, + pub error_message: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct EditorProjectLayoutSaveProcedureResult { pub ok: bool, @@ -962,6 +1184,99 @@ pub fn save_editor_project_layout_ack( } } +#[spacetimedb::procedure] +pub fn save_editor_project_layout_v2_ack( + ctx: &mut ProcedureContext, + input: EditorProjectLayoutSaveV2Input, +) -> EditorProjectLayoutSaveV2ProcedureResult { + match ctx.try_with_tx(|tx| persist_editor_project_layout_v2(tx, input.clone())) { + Ok(ack) => EditorProjectLayoutSaveV2ProcedureResult { + ok: true, + ack: Some(ack), + error_message: None, + }, + Err(message) => EditorProjectLayoutSaveV2ProcedureResult { + ok: false, + ack: None, + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn save_editor_project_layout_v2_and_return( + ctx: &mut ProcedureContext, + input: EditorProjectLayoutSaveV2Input, +) -> EditorProjectProcedureResult { + match ctx.try_with_tx(|tx| { + let ack = persist_editor_project_layout_v2(tx, input.clone())?; + build_project_snapshot(tx, ack.project_id.as_str()) + }) { + Ok(project) => editor_project_ok(Some(project)), + Err(message) => editor_project_error(message), + } +} + +#[spacetimedb::procedure] +pub fn backfill_editor_canvas_layout_and_return( + ctx: &mut ProcedureContext, + input: EditorCanvasLayoutMigrationInput, +) -> EditorCanvasLayoutMigrationProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + backfill_editor_canvas_layout(tx, input.clone()) + }) { + Ok(migration) => editor_canvas_layout_migration_ok(migration), + Err(message) => editor_canvas_layout_migration_error(message), + } +} + +#[spacetimedb::procedure] +pub fn activate_editor_canvas_layout_and_return( + ctx: &mut ProcedureContext, + input: EditorCanvasLayoutMigrationInput, +) -> EditorCanvasLayoutMigrationProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + activate_editor_canvas_layout(tx, input.clone()) + }) { + Ok(migration) => editor_canvas_layout_migration_ok(migration), + Err(message) => editor_canvas_layout_migration_error(message), + } +} + +#[spacetimedb::procedure] +pub fn rollback_editor_canvas_layout_and_return( + ctx: &mut ProcedureContext, + input: EditorCanvasLayoutMigrationInput, +) -> EditorCanvasLayoutMigrationProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + rollback_editor_canvas_layout(tx, input.clone()) + }) { + Ok(migration) => editor_canvas_layout_migration_ok(migration), + Err(message) => editor_canvas_layout_migration_error(message), + } +} + +#[spacetimedb::procedure] +pub fn repair_editor_canvas_resources_and_return( + ctx: &mut ProcedureContext, + input: EditorCanvasResourceRepairInput, +) -> EditorCanvasResourceRepairProcedureResult { + let caller = ctx.sender(); + match ctx.try_with_tx(|tx| { + crate::migration::require_migration_operator(tx, caller)?; + repair_editor_canvas_resources(tx, input.clone()) + }) { + Ok(repair) => editor_canvas_resource_repair_ok(repair), + Err(message) => editor_canvas_resource_repair_error(message), + } +} + #[spacetimedb::procedure] pub fn rename_editor_project_and_return( ctx: &mut ProcedureContext, @@ -1495,6 +1810,11 @@ fn delete_editor_project( ); for canvas_id in canvas_ids { + delete_structured_canvas_layout(ctx, canvas_id.as_str()); + ctx.db + .editor_canvas_layout_migration() + .canvas_id() + .delete(&canvas_id); ctx.db.editor_canvas().canvas_id().delete(&canvas_id); } for resource_id in resource_ids { @@ -1520,6 +1840,36 @@ fn persist_editor_project_layout( ctx: &ReducerContext, input: EditorProjectLayoutSaveInput, ) -> Result { + let ack = persist_editor_project_layout_inner(ctx, input, None)?; + Ok(EditorProjectLayoutSaveAck { + project_id: ack.project_id, + canvas_id: ack.canvas_id, + updated_at_micros: ack.updated_at_micros, + }) +} + +fn persist_editor_project_layout_v2( + ctx: &ReducerContext, + input: EditorProjectLayoutSaveV2Input, +) -> Result { + persist_editor_project_layout_inner( + ctx, + EditorProjectLayoutSaveInput { + project_id: input.project_id, + owner_user_id: input.owner_user_id, + viewport: input.viewport, + layers_json: input.layers_json, + updated_at_micros: input.updated_at_micros, + }, + Some(input.expected_revision), + ) +} + +fn persist_editor_project_layout_inner( + ctx: &ReducerContext, + input: EditorProjectLayoutSaveInput, + expected_revision: Option, +) -> Result { let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; let input_layers_json = normalize_layout_json(input.layers_json)?; @@ -1532,23 +1882,135 @@ fn persist_editor_project_layout( Some(&project), now, )?; + if let Some(expected_revision) = expected_revision { + if canvas.revision != expected_revision { + return Err(format!( + "图片画布版本冲突:期望 revision {expected_revision},当前为 {}", + canvas.revision + )); + } + } let canvas_id = canvas.canvas_id.clone(); - let _ = migrate_canvas_layer_asset_metadata( - ctx, - project.project_id.as_str(), - owner_user_id.as_str(), - canvas.layers_json.clone(), - now, - )?; + let current_layers_json = + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &canvas)? + } else { + canvas.layers_json.clone() + }; + let is_structured = + canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED; + if !is_structured { + let _ = migrate_canvas_layer_asset_metadata( + ctx, + project.project_id.as_str(), + owner_user_id.as_str(), + current_layers_json.clone(), + now, + )?; + } let input_layers_json = - merge_completed_canvas_generation_items(canvas.layers_json.as_str(), input_layers_json)?; - let layers_json = migrate_canvas_layer_asset_metadata( - ctx, - project.project_id.as_str(), - owner_user_id.as_str(), - input_layers_json, - now, + merge_completed_canvas_generation_items(current_layers_json.as_str(), input_layers_json)?; + let layers_json = if is_structured { + input_layers_json + } else { + migrate_canvas_layer_asset_metadata( + ctx, + project.project_id.as_str(), + owner_user_id.as_str(), + input_layers_json, + now, + )? + }; + let layers_json = normalize_layout_json(layers_json)?; + if !is_structured { + let next_revision = canvas.revision.saturating_add(1); + ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id); + ctx.db.editor_canvas().insert(EditorCanvas { + canvas_id: canvas.canvas_id, + project_id: project.project_id.clone(), + owner_user_id: project.owner_user_id.clone(), + title: canvas.title, + viewport_x: input.viewport.x, + viewport_y: input.viewport.y, + viewport_scale: input.viewport.scale.clamp(0.01, 8.0), + layers_json: layers_json.clone(), + created_at: canvas.created_at, + updated_at: now, + revision: next_revision, + layout_storage_version: 0, + background_color: canvas.background_color, + }); + ctx.db.editor_project().project_id().delete(&project_id); + ctx.db.editor_project().insert(EditorProject { + project_id: project.project_id.clone(), + owner_user_id: project.owner_user_id, + title: project.title, + viewport_x: input.viewport.x, + viewport_y: input.viewport.y, + viewport_scale: input.viewport.scale.clamp(0.01, 8.0), + layers_json, + created_at: project.created_at, + updated_at: now, + }); + return Ok(EditorProjectLayoutSaveV2Ack { + project_id, + canvas_id, + revision: next_revision, + updated_at_micros: now.to_micros_since_unix_epoch(), + }); + } + if expected_revision.is_none() { + return Err("结构化图片画布保存必须携带 expectedRevision".to_string()); + } + let active_migration = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id) + .filter(|migration| migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE) + .ok_or_else(|| "结构化图片画布缺少 active 迁移记录,拒绝保存".to_string())?; + let current_structured_hash = canonical_layout_sha256(current_layers_json.as_str())?; + let current_structured_integrity = canvas_layout_integrity(current_layers_json.as_str())?; + verify_migration_layout( + &active_migration, + canvas.revision, + current_structured_hash.as_str(), + ¤t_structured_integrity, )?; + let mut structured_layout = parse_structured_canvas_layout(layers_json.as_str())?; + normalize_structured_canvas_layer_resource_metadata( + ctx, + canvas.canvas_id.as_str(), + project_id.as_str(), + owner_user_id.as_str(), + &mut structured_layout, + now, + true, + false, + )?; + let normalized_structured_layers_json = normalize_layout_json( + serialize_structured_canvas_layout(&structured_layout) + .map_err(|_| "结构化图片画布布局无法序列化".to_string())?, + )?; + replace_structured_canvas_layout(ctx, &canvas, &structured_layout, now)?; + let next_revision = canvas.revision.saturating_add(1); + let layout_sha256 = canonical_layout_sha256(normalized_structured_layers_json.as_str())?; + let integrity = canvas_layout_integrity(normalized_structured_layers_json.as_str())?; + let legacy_source_hash = active_migration.source_layout_sha256; + let legacy_canvas_layers_json = canvas.layers_json.clone(); + let legacy_project_layers_json = project.layers_json.clone(); + upsert_editor_canvas_layout_migration( + ctx, + &canvas, + legacy_source_hash.as_str(), + layout_sha256.as_str(), + next_revision, + &integrity, + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE, + Some(now), + None, + now, + ); ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id); ctx.db.editor_canvas().insert(EditorCanvas { @@ -1559,9 +2021,12 @@ fn persist_editor_project_layout( viewport_x: input.viewport.x, viewport_y: input.viewport.y, viewport_scale: input.viewport.scale.clamp(0.01, 8.0), - layers_json: layers_json.clone(), + layers_json: legacy_canvas_layers_json, created_at: canvas.created_at, updated_at: now, + revision: next_revision, + layout_storage_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + background_color: structured_layout.background_color, }); ctx.db.editor_project().project_id().delete(&project_id); ctx.db.editor_project().insert(EditorProject { @@ -1571,14 +2036,15 @@ fn persist_editor_project_layout( viewport_x: input.viewport.x, viewport_y: input.viewport.y, viewport_scale: input.viewport.scale.clamp(0.01, 8.0), - layers_json, + layers_json: legacy_project_layers_json, created_at: project.created_at, updated_at: now, }); - Ok(EditorProjectLayoutSaveAck { + Ok(EditorProjectLayoutSaveV2Ack { project_id, canvas_id, + revision: next_revision, updated_at_micros: now.to_micros_since_unix_epoch(), }) } @@ -3022,7 +3488,7 @@ fn build_project_snapshot( project_id: project.project_id, owner_user_id: project.owner_user_id, title: project.title, - canvas: canvas_snapshot_from_row(canvas), + canvas: canvas_snapshot_from_row(ctx, canvas)?, resources, created_at_micros: project.created_at.to_micros_since_unix_epoch(), updated_at_micros: project.updated_at.to_micros_since_unix_epoch(), @@ -3108,6 +3574,21 @@ fn ensure_default_canvas( return Ok(canvas); } + let canvas = default_canvas_from_legacy_project(project_id, owner_user_id, legacy_project, now); + ctx.db.editor_canvas().insert(canvas); + ctx.db + .editor_canvas() + .canvas_id() + .find(&canvas_id) + .ok_or_else(|| "图片画布创建失败".to_string()) +} + +fn default_canvas_from_legacy_project( + project_id: &str, + owner_user_id: &str, + legacy_project: Option<&EditorProject>, + now: Timestamp, +) -> EditorCanvas { let legacy_view = legacy_project .map(|project| { ( @@ -3120,8 +3601,8 @@ fn ensure_default_canvas( ) }) .unwrap_or((0.0, 0.0, 1.0, "[]".to_string(), now, now)); - let canvas = EditorCanvas { - canvas_id: canvas_id.clone(), + EditorCanvas { + canvas_id: default_canvas_id(project_id), project_id: project_id.to_string(), owner_user_id: owner_user_id.to_string(), title: EDITOR_CANVAS_DEFAULT_TITLE.to_string(), @@ -3131,21 +3612,27 @@ fn ensure_default_canvas( layers_json: legacy_view.3, created_at: legacy_view.4, updated_at: legacy_view.5, - }; - ctx.db.editor_canvas().insert(canvas); - ctx.db - .editor_canvas() - .canvas_id() - .find(&canvas_id) - .ok_or_else(|| "图片画布创建失败".to_string()) + revision: 0, + layout_storage_version: 0, + background_color: None, + } } fn default_canvas_id(project_id: &str) -> String { format!("{project_id}:canvas:default") } -fn canvas_snapshot_from_row(row: EditorCanvas) -> EditorCanvasSnapshot { - EditorCanvasSnapshot { +fn canvas_snapshot_from_row( + ctx: &ReducerContext, + row: EditorCanvas, +) -> Result { + let layers_json = + if row.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + build_structured_canvas_layout_json(ctx, &row)? + } else { + row.layers_json.clone() + }; + Ok(EditorCanvasSnapshot { canvas_id: row.canvas_id, project_id: row.project_id, title: row.title, @@ -3154,10 +3641,13 @@ fn canvas_snapshot_from_row(row: EditorCanvas) -> EditorCanvasSnapshot { y: row.viewport_y, scale: row.viewport_scale, }, - layers_json: row.layers_json, + layers_json, created_at_micros: row.created_at.to_micros_since_unix_epoch(), updated_at_micros: row.updated_at.to_micros_since_unix_epoch(), - } + revision: row.revision, + layout_storage_version: row.layout_storage_version, + background_color: row.background_color, + }) } // 中文注释:供 editor_agent_storage 校验画布工程归属,仅返回是否可访问,不暴露表行。 @@ -4543,6 +5033,2419 @@ fn normalize_asset_label(value: &str) -> String { label.chars().take(EDITOR_ASSET_MAX_LABEL_CHARS).collect() } +#[derive(Clone)] +struct StructuredCanvasLayout { + layers: Vec, + dialogs: Vec, + background_color: Option, +} + +#[derive(Clone)] +struct StructuredCanvasLayerDraft { + layer_id: String, + resource_id: String, + sort_order: u32, + title: Option, + x: Option, + y: Option, + width: Option, + height: Option, + original_width: Option, + original_height: Option, + z_index: Option, + source_type: Option, + group_id: Option, + hidden: bool, + locked: bool, + flip_x: bool, + flip_y: bool, + item_json: String, +} + +#[derive(Clone)] +struct StructuredCanvasDialogDraft { + dialog_id: String, + layer_id: String, + resource_id: String, + sort_order: u32, + mode: String, + status: String, + source_layer_id: Option, + generated_layer_id: Option, + placeholder_x: Option, + placeholder_y: Option, + placeholder_width: Option, + placeholder_height: Option, + dialog_json: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CanvasLayoutIntegrity { + layer_count: u32, + dialog_count: u32, + resource_refs_sha256: String, +} + +fn parse_structured_canvas_layout(layers_json: &str) -> Result { + let value = serde_json::from_str::(layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let items = value + .as_array() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut layers = Vec::new(); + let mut dialogs = Vec::new(); + let mut background_color = None; + + for (index, item) in items.iter().enumerate() { + let object = item + .as_object() + .ok_or_else(|| format!("图片画布第 {} 个布局项不是对象", index + 1))?; + let item_type = object.get("itemType").and_then(JsonValue::as_str); + if item_type == Some("canvas-settings") { + if background_color.is_some() { + return Err("图片画布只能包含一个 canvas-settings 布局项".to_string()); + } + if let Some(unknown_key) = object.keys().find(|key| { + !["itemType", "layerId", "resourceId", "canvasBackgroundColor"] + .contains(&key.as_str()) + }) { + return Err(format!( + "图片画布 canvas-settings 包含无法结构化保存的字段:{unknown_key}" + )); + } + background_color = Some(json_required_string( + object.get("canvasBackgroundColor"), + "canvasBackgroundColor", + )?); + continue; + } + let layer_id = json_required_string(object.get("layerId"), "layerId")?; + let resource_id = json_required_string(object.get("resourceId"), "resourceId")?; + let sort_order = u32::try_from(index).map_err(|_| "图片画布布局项数量过多".to_string())?; + + if item_type == Some("generation-dialog") { + let dialog = object + .get("dialog") + .and_then(JsonValue::as_object) + .ok_or_else(|| "图片画布生成对话框缺少 dialog".to_string())?; + let placeholder = match dialog.get("placeholder") { + Some(value) => Some( + value + .as_object() + .ok_or_else(|| "图片画布生成对话框 placeholder 必须是对象".to_string())?, + ), + None => None, + }; + let mut item_extension = object.clone(); + for key in ["itemType", "layerId", "resourceId", "dialog"] { + item_extension.remove(key); + } + let mut dialog_extension = dialog.clone(); + for key in ["id", "mode", "status", "sourceLayerId", "generatedLayerId"] { + dialog_extension.remove(key); + } + if let Some(placeholder) = placeholder { + let mut placeholder_extension = placeholder.clone(); + for key in ["x", "y", "width", "height"] { + placeholder_extension.remove(key); + } + // Keep the placeholder key even when the extension is empty so an explicitly + // persisted empty placeholder cannot be mistaken for an omitted placeholder. + dialog_extension.insert( + "placeholder".to_string(), + JsonValue::Object(placeholder_extension), + ); + } + if !dialog_extension.is_empty() { + item_extension.insert("dialog".to_string(), JsonValue::Object(dialog_extension)); + } + let dialog_json = serde_json::to_string(&JsonValue::Object(item_extension)) + .map_err(|_| "图片画布生成对话框无法序列化".to_string())?; + if dialog_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { + return Err("单个图片画布生成对话框扩展数据超过 512 KiB".to_string()); + } + dialogs.push(StructuredCanvasDialogDraft { + dialog_id: json_required_string(dialog.get("id"), "dialog.id")?, + layer_id, + resource_id, + sort_order, + mode: json_required_string(dialog.get("mode"), "dialog.mode")?, + status: json_required_string(dialog.get("status"), "dialog.status")?, + source_layer_id: json_optional_string(dialog.get("sourceLayerId")), + generated_layer_id: json_optional_string(dialog.get("generatedLayerId")), + placeholder_x: placeholder.and_then(|value| json_optional_number(value.get("x"))), + placeholder_y: placeholder.and_then(|value| json_optional_number(value.get("y"))), + placeholder_width: placeholder + .and_then(|value| json_optional_number(value.get("width"))), + placeholder_height: placeholder + .and_then(|value| json_optional_number(value.get("height"))), + dialog_json, + }); + continue; + } + + let mut item_extension = object.clone(); + for key in [ + "layerId", + "resourceId", + "title", + "x", + "y", + "width", + "height", + "originalWidth", + "originalHeight", + "zIndex", + "sourceType", + "groupId", + "hidden", + "locked", + "flipX", + "flipY", + ] { + item_extension.remove(key); + } + let item_json = serde_json::to_string(&JsonValue::Object(item_extension)) + .map_err(|_| "图片画布图层无法序列化".to_string())?; + layers.push(StructuredCanvasLayerDraft { + layer_id, + resource_id, + sort_order, + title: json_optional_string(object.get("title")), + x: json_optional_number(object.get("x")), + y: json_optional_number(object.get("y")), + width: json_optional_number(object.get("width")), + height: json_optional_number(object.get("height")), + original_width: json_optional_number(object.get("originalWidth")), + original_height: json_optional_number(object.get("originalHeight")), + z_index: json_optional_number(object.get("zIndex")), + source_type: json_optional_string(object.get("sourceType")), + group_id: json_optional_string(object.get("groupId")), + hidden: object + .get("hidden") + .and_then(JsonValue::as_bool) + .unwrap_or(false), + locked: object + .get("locked") + .and_then(JsonValue::as_bool) + .unwrap_or(false), + flip_x: object + .get("flipX") + .and_then(JsonValue::as_bool) + .unwrap_or(false), + flip_y: object + .get("flipY") + .and_then(JsonValue::as_bool) + .unwrap_or(false), + item_json, + }); + } + + Ok(StructuredCanvasLayout { + layers, + dialogs, + background_color, + }) +} + +fn json_required_string(value: Option<&JsonValue>, field: &str) -> Result { + json_optional_string(value).ok_or_else(|| format!("图片画布布局缺少 {field}")) +} + +fn json_optional_string(value: Option<&JsonValue>) -> Option { + value + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn json_optional_number(value: Option<&JsonValue>) -> Option { + value + .and_then(JsonValue::as_f64) + .filter(|value| value.is_finite()) +} + +fn structured_canvas_row_key(canvas_id: &str, kind: &str, item_id: &str) -> String { + sha256_hex(format!("{canvas_id}\0{kind}\0{item_id}").as_bytes()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn canonical_layout_json(layers_json: &str) -> Result { + let mut value = serde_json::from_str::(layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + strip_canvas_layer_resource_metadata_for_canonical_hash(&mut value)?; + let normalized = + serde_json::to_string(&value).map_err(|_| "图片画布图层布局无法序列化".to_string())?; + parse_structured_canvas_layout(normalized.as_str()).and_then(|layout| { + serialize_structured_canvas_layout(&layout) + .map_err(|_| "图片画布图层布局无法序列化".to_string()) + }) +} + +fn strip_canvas_layer_resource_metadata_for_canonical_hash( + value: &mut JsonValue, +) -> Result<(), String> { + let items = value + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + for item in items { + let object = item + .as_object_mut() + .ok_or_else(|| "图片画布布局项必须是对象".to_string())?; + if matches!( + object.get("itemType").and_then(JsonValue::as_str), + Some("canvas-settings" | "generation-dialog") + ) { + continue; + } + let resource_id = json_required_string(object.get("resourceId"), "resourceId")?; + let source_type = json_optional_string(object.get("sourceType")); + if validate_self_contained_legacy_local_image_sequence( + resource_id.as_str(), + source_type.as_deref(), + object, + )? { + continue; + } + for field in EDITOR_CANVAS_LAYER_RESOURCE_METADATA_FIELDS { + object.remove(field); + } + object.remove("sourceType"); + } + Ok(()) +} + +fn validate_self_contained_legacy_local_image_sequence( + resource_id: &str, + source_type: Option<&str>, + item: &JsonMap, +) -> Result { + if !resource_id.starts_with("local-") + || source_type != Some("generated") + || item.get("mediaType").and_then(JsonValue::as_str) != Some("image-sequence") + { + return Ok(false); + } + + for field in ["imageSrc", "objectKey", "assetObjectId"] { + match item.get(field) { + None | Some(JsonValue::Null) => {} + Some(JsonValue::String(value)) if value.trim().is_empty() => {} + Some(_) => { + return Err(format!( + "本地图片序列 {resource_id} 包含不允许的 {field},拒绝结构化保存" + )); + } + } + } + + let frames = item + .get("imageSequenceFrames") + .and_then(JsonValue::as_array) + .filter(|frames| !frames.is_empty()) + .ok_or_else(|| format!("本地图片序列 {resource_id} 缺少有效帧,拒绝结构化保存"))?; + for (index, frame) in frames.iter().enumerate() { + let frame = frame.as_object().ok_or_else(|| { + format!( + "本地图片序列 {resource_id} 的第 {} 帧不是对象,拒绝结构化保存", + index + 1 + ) + })?; + let expected_frame_index = u64::try_from(index + 1) + .map_err(|_| format!("本地图片序列 {resource_id} 的帧数过多"))?; + if frame.get("frameIndex").and_then(JsonValue::as_u64) != Some(expected_frame_index) { + return Err(format!( + "本地图片序列 {resource_id} 的第 {} 帧序号无效,拒绝结构化保存", + index + 1 + )); + } + for field in ["width", "height"] { + if frame + .get(field) + .and_then(JsonValue::as_f64) + .filter(|value| value.is_finite() && *value > 0.0) + .is_none() + { + return Err(format!( + "本地图片序列 {resource_id} 的第 {} 帧 {field} 无效,拒绝结构化保存", + index + 1 + )); + } + } + let image_src = frame + .get("imageSrc") + .and_then(JsonValue::as_str) + .filter(|value| is_stable_canvas_object_path(value)) + .ok_or_else(|| { + format!( + "本地图片序列 {resource_id} 的第 {} 帧不是稳定站内路径,拒绝结构化保存", + index + 1 + ) + })?; + if let Some(frame_object_key) = frame.get("objectKey") { + let frame_object_key = frame_object_key.as_str().ok_or_else(|| { + format!( + "本地图片序列 {resource_id} 的第 {} 帧 objectKey 无效,拒绝结构化保存", + index + 1 + ) + })?; + if !is_stable_canvas_object_key(frame_object_key) + || frame_object_key.trim() != image_src.trim().trim_start_matches('/') + { + return Err(format!( + "本地图片序列 {resource_id} 的第 {} 帧 objectKey 与 imageSrc 不一致,拒绝结构化保存", + index + 1 + )); + } + } + } + + match item.get("src") { + None | Some(JsonValue::Null) => {} + Some(JsonValue::String(value)) if value.trim().is_empty() => {} + Some(JsonValue::String(value)) + if is_stable_canvas_object_path(value) + && value.trim() + == frames[0] + .as_object() + .and_then(|frame| frame.get("imageSrc")) + .and_then(JsonValue::as_str) + .map(str::trim) + .unwrap_or_default() => {} + Some(_) => { + return Err(format!( + "本地图片序列 {resource_id} 的 src 与首帧不一致,拒绝结构化保存" + )); + } + } + + for (field, label) in [("thumbnailSrc", "缩略图"), ("previewVideoPath", "预览视频")] { + match item.get(field) { + None | Some(JsonValue::Null) => {} + Some(JsonValue::String(value)) if is_stable_canvas_object_path(value) => {} + Some(_) => { + return Err(format!( + "本地图片序列 {resource_id} 的{label}不是稳定站内路径,拒绝结构化保存" + )); + } + } + } + + Ok(true) +} + +fn is_stable_canvas_object_path(value: &str) -> bool { + let value = value.trim(); + value.starts_with('/') + && !value.starts_with("//") + && !value.contains('?') + && !value.contains('#') + && !value.contains('\\') + && !value.split('/').any(|segment| segment == "..") + && !value.chars().any(char::is_control) +} + +fn is_stable_canvas_object_key(value: &str) -> bool { + let value = value.trim(); + !value.is_empty() + && !value.starts_with('/') + && !value.contains('?') + && !value.contains('#') + && !value.contains('\\') + && !value.split('/').any(|segment| segment == "..") + && !value.chars().any(char::is_control) +} + +fn canonical_layout_sha256(layers_json: &str) -> Result { + canonical_layout_json(layers_json).map(|value| sha256_hex(value.as_bytes())) +} + +fn canvas_layout_integrity(layers_json: &str) -> Result { + let value = serde_json::from_str::(layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let items = value + .as_array() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut layer_count = 0u32; + let mut dialog_count = 0u32; + let mut resource_refs = BTreeSet::new(); + for item in items { + let Some(object) = item.as_object() else { + return Err("图片画布布局项必须是对象".to_string()); + }; + match object.get("itemType").and_then(JsonValue::as_str) { + Some("canvas-settings") => continue, + Some("generation-dialog") => { + dialog_count = dialog_count + .checked_add(1) + .ok_or_else(|| "图片画布生成对话框数量过多".to_string())?; + } + _ => { + layer_count = layer_count + .checked_add(1) + .ok_or_else(|| "图片画布图层数量过多".to_string())?; + } + } + if let Some(resource_id) = object + .get("resourceId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + resource_refs.insert(resource_id.to_string()); + } + } + let canonical_refs = + serde_json::to_vec(&resource_refs).map_err(|_| "图片画布资源引用无法序列化".to_string())?; + Ok(CanvasLayoutIntegrity { + layer_count, + dialog_count, + resource_refs_sha256: sha256_hex(canonical_refs.as_slice()), + }) +} + +fn normalize_structured_canvas_layer_against_resource( + layer: &mut StructuredCanvasLayerDraft, + resource: Option<&EditorProjectResource>, +) -> Result, Option)>, String> { + let mut item = serde_json::from_str::>(&layer.item_json) + .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", layer.layer_id))?; + let is_self_contained_local_sequence = validate_self_contained_legacy_local_image_sequence( + layer.resource_id.as_str(), + layer.source_type.as_deref(), + &item, + )?; + let Some(resource) = resource else { + if !is_self_contained_local_sequence { + return Err(format!( + "图片画布图层 {} 缺少项目资源 {},拒绝结构化保存", + layer.layer_id, layer.resource_id + )); + } + if layer.item_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { + return Err(format!( + "图片画布图层 {} 的非资源扩展数据超过 512 KiB", + layer.layer_id + )); + } + return Ok(None); + }; + if is_self_contained_local_sequence { + return Err(format!( + "本地图片序列 {} 同时存在项目资源行,无法确定权威元数据,拒绝结构化保存", + layer.resource_id + )); + } + + take_matching_optional_resource_string( + &mut item, + "prompt", + resource.prompt.as_deref(), + layer.layer_id.as_str(), + )?; + take_matching_optional_resource_string( + &mut item, + "actualPrompt", + resource.actual_prompt.as_deref(), + layer.layer_id.as_str(), + )?; + take_matching_optional_resource_string( + &mut item, + "model", + resource.model.as_deref(), + layer.layer_id.as_str(), + )?; + take_matching_optional_resource_string( + &mut item, + "provider", + resource.provider.as_deref(), + layer.layer_id.as_str(), + )?; + take_matching_optional_resource_string( + &mut item, + "taskId", + resource.task_id.as_deref(), + layer.layer_id.as_str(), + )?; + take_matching_optional_resource_string( + &mut item, + "objectKey", + resource.object_key.as_deref(), + layer.layer_id.as_str(), + )?; + take_matching_optional_resource_string( + &mut item, + "assetObjectId", + resource.asset_object_id.as_deref(), + layer.layer_id.as_str(), + )?; + take_matching_source_resource_id( + &mut item, + resource.source_resource_id.as_deref(), + layer.layer_id.as_str(), + layer.resource_id.as_str(), + )?; + take_matching_required_resource_string( + &mut item, + "src", + resource.image_src.as_str(), + layer.layer_id.as_str(), + )?; + take_matching_required_resource_string( + &mut item, + "imageSrc", + resource.image_src.as_str(), + layer.layer_id.as_str(), + )?; + if let Some(source_type) = layer.source_type.as_deref() + && source_type != resource.source_type + { + return Err(format!( + "图片画布图层 {} 的 sourceType 与项目资源不一致,拒绝结构化保存", + layer.layer_id + )); + } + layer.source_type = None; + + let layout_asset_kind = take_optional_layout_string(&mut item, "assetKind")?; + let layout_generation_inputs = take_optional_layout_json(&mut item, "generationInputs")?; + layer.item_json = serde_json::to_string(&JsonValue::Object(item)) + .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", layer.layer_id))?; + if layer.item_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { + return Err(format!( + "图片画布图层 {} 的非资源扩展数据超过 512 KiB", + layer.layer_id + )); + } + Ok(Some((layout_asset_kind, layout_generation_inputs))) +} + +fn legacy_local_sequence_extension_matches_existing_layer( + existing_source_type: Option<&str>, + existing_item_json: &str, + incoming: &StructuredCanvasLayerDraft, +) -> bool { + existing_source_type == incoming.source_type.as_deref() + && serde_json::from_str::(existing_item_json).ok() + == serde_json::from_str::(&incoming.item_json).ok() +} + +fn restore_omitted_legacy_local_sequence_metadata( + existing_item_json: &str, + incoming: &mut StructuredCanvasLayerDraft, +) -> Result<(), String> { + let existing = serde_json::from_str::>(existing_item_json) + .map_err(|_| "已迁移本地图片序列扩展数据不是合法对象".to_string())?; + let mut item = serde_json::from_str::>(&incoming.item_json) + .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", incoming.layer_id))?; + for field in ["assetKind", "generationInputs"] { + if !item.contains_key(field) + && let Some(value) = existing.get(field) + { + item.insert(field.to_string(), value.clone()); + } + } + incoming.item_json = serde_json::to_string(&JsonValue::Object(item)) + .map_err(|_| format!("图片画布图层 {} 扩展数据无法序列化", incoming.layer_id))?; + if incoming.item_json.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { + return Err(format!( + "图片画布图层 {} 的非资源扩展数据超过 512 KiB", + incoming.layer_id + )); + } + Ok(()) +} + +fn normalize_structured_canvas_layer_resource_metadata( + ctx: &ReducerContext, + canvas_id: &str, + project_id: &str, + owner_user_id: &str, + layout: &mut StructuredCanvasLayout, + now: Timestamp, + apply_resource_migrations: bool, + allow_new_legacy_local_sequences: bool, +) -> Result<(), String> { + let mut resource_metadata_updates = BTreeMap::, Option)>::new(); + for layer in &mut layout.layers { + let resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&layer.resource_id) + .filter(|resource| { + resource.project_id == project_id && resource.owner_user_id == owner_user_id + }); + let layout_resource_metadata = + normalize_structured_canvas_layer_against_resource(layer, resource.as_ref())?; + let Some((layout_asset_kind, layout_generation_inputs)) = layout_resource_metadata else { + let item = serde_json::from_str::>(&layer.item_json) + .map_err(|_| format!("图片画布图层 {} 扩展数据不是合法对象", layer.layer_id))?; + if let Some(source_resource_id) = json_optional_string(item.get("sourceResourceId")) + && let Some(source_resource) = ctx + .db + .editor_project_resource() + .resource_id() + .find(&source_resource_id) + && (source_resource.project_id != project_id + || source_resource.owner_user_id != owner_user_id) + { + return Err(format!( + "图片画布图层 {} 的 sourceResourceId 不属于当前工程,拒绝结构化保存", + layer.layer_id + )); + } + if !allow_new_legacy_local_sequences { + let existing_layer_key = + structured_canvas_row_key(canvas_id, "layer", layer.layer_id.as_str()); + let existing_layer = ctx + .db + .editor_canvas_layer() + .layer_key() + .find(&existing_layer_key); + let Some(existing_layer) = existing_layer else { + return Err(format!( + "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", + layer.layer_id + )); + }; + if existing_layer.canvas_id != canvas_id + || existing_layer.project_id != project_id + || existing_layer.owner_user_id != owner_user_id + || existing_layer.layer_id != layer.layer_id + || existing_layer.resource_id != layer.resource_id + { + return Err(format!( + "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", + layer.layer_id + )); + } + restore_omitted_legacy_local_sequence_metadata( + existing_layer.item_json.as_str(), + layer, + )?; + if !legacy_local_sequence_extension_matches_existing_layer( + existing_layer.source_type.as_deref(), + existing_layer.item_json.as_str(), + layer, + ) { + return Err(format!( + "图片画布图层 {} 不能新增或修改缺少项目资源的本地图片序列", + layer.layer_id + )); + } + } + continue; + }; + let Some(resource) = resource else { + return Err("图片画布项目资源在元数据归一时消失".to_string()); + }; + let update = resource_metadata_updates + .entry(resource.resource_id.clone()) + .or_insert_with(|| { + ( + resource.asset_kind.clone(), + resource.generation_inputs_json.clone(), + ) + }); + merge_optional_resource_metadata( + &mut update.0, + layout_asset_kind, + "assetKind", + layer.layer_id.as_str(), + )?; + merge_optional_resource_json_metadata( + &mut update.1, + layout_generation_inputs, + "generationInputs", + layer.layer_id.as_str(), + )?; + } + + if apply_resource_migrations { + for (resource_id, (asset_kind, generation_inputs_json)) in resource_metadata_updates { + let Some(resource) = ctx + .db + .editor_project_resource() + .resource_id() + .find(&resource_id) + else { + return Err(format!("图片画布项目资源 {resource_id} 在迁移时消失")); + }; + if resource.asset_kind == asset_kind + && resource.generation_inputs_json == generation_inputs_json + { + continue; + } + ctx.db + .editor_project_resource() + .resource_id() + .delete(&resource_id); + ctx.db + .editor_project_resource() + .insert(EditorProjectResource { + asset_kind, + generation_inputs_json, + updated_at: now, + ..resource + }); + } + } + Ok(()) +} + +fn take_matching_optional_resource_string( + item: &mut JsonMap, + field: &str, + expected: Option<&str>, + layer_id: &str, +) -> Result<(), String> { + let Some(value) = item.remove(field) else { + return Ok(()); + }; + let matches = match value { + JsonValue::Null => true, + JsonValue::String(value) => expected == Some(value.as_str()), + _ => false, + }; + if matches { + Ok(()) + } else { + Err(format!( + "图片画布图层 {layer_id} 的 {field} 与项目资源不一致,拒绝结构化保存" + )) + } +} + +fn take_matching_source_resource_id( + item: &mut JsonMap, + expected: Option<&str>, + layer_id: &str, + resource_id: &str, +) -> Result<(), String> { + let Some(value) = item.remove("sourceResourceId") else { + return Ok(()); + }; + let matches = match value { + JsonValue::Null => true, + JsonValue::String(value) => value == resource_id || expected == Some(value.as_str()), + _ => false, + }; + if matches { + Ok(()) + } else { + Err(format!( + "图片画布图层 {layer_id} 的 sourceResourceId 与项目资源不一致,拒绝结构化保存" + )) + } +} + +fn take_matching_required_resource_string( + item: &mut JsonMap, + field: &str, + expected: &str, + layer_id: &str, +) -> Result<(), String> { + let Some(value) = item.remove(field) else { + return Ok(()); + }; + if value.is_null() || value.as_str() == Some(expected) { + Ok(()) + } else { + Err(format!( + "图片画布图层 {layer_id} 的 {field} 与项目资源不一致,拒绝结构化保存" + )) + } +} + +fn take_optional_layout_string( + item: &mut JsonMap, + field: &str, +) -> Result, String> { + match item.remove(field) { + None | Some(JsonValue::Null) => Ok(None), + Some(JsonValue::String(value)) => Ok(normalize_optional(Some(value))), + Some(_) => Err(format!("图片画布图层 {field} 必须是字符串")), + } +} + +fn take_optional_layout_json( + item: &mut JsonMap, + field: &str, +) -> Result, String> { + match item.remove(field) { + None | Some(JsonValue::Null) => Ok(None), + Some(value) => serde_json::to_string(&value) + .map(Some) + .map_err(|_| format!("图片画布图层 {field} 无法序列化")), + } +} + +fn merge_optional_resource_metadata( + current: &mut Option, + candidate: Option, + field: &str, + layer_id: &str, +) -> Result<(), String> { + let Some(candidate) = candidate else { + return Ok(()); + }; + match current { + Some(current) if current != &candidate => Err(format!( + "图片画布图层 {layer_id} 的 {field} 与项目资源不一致,拒绝结构化保存" + )), + Some(_) => Ok(()), + None => { + *current = Some(candidate); + Ok(()) + } + } +} + +fn merge_optional_resource_json_metadata( + current: &mut Option, + candidate: Option, + field: &str, + layer_id: &str, +) -> Result<(), String> { + let Some(candidate) = candidate else { + return Ok(()); + }; + match current { + Some(current) => { + let current_value = serde_json::from_str::(current).map_err(|_| { + format!("项目资源 {field} 不是合法 JSON,拒绝迁移图片画布图层 {layer_id}") + })?; + let candidate_value = serde_json::from_str::(&candidate) + .map_err(|_| format!("图片画布图层 {layer_id} 的 {field} 不是合法 JSON"))?; + if current_value == candidate_value { + Ok(()) + } else { + Err(format!( + "图片画布图层 {layer_id} 的 {field} 与项目资源不一致,拒绝结构化保存" + )) + } + } + None => { + *current = Some(candidate); + Ok(()) + } + } +} + +fn delete_structured_canvas_layout(ctx: &ReducerContext, canvas_id: &str) { + let canvas_key = canvas_id.to_string(); + let layer_keys = ctx + .db + .editor_canvas_layer() + .by_editor_canvas_layer_canvas_id() + .filter(&canvas_key) + .map(|row| row.layer_key) + .collect::>(); + let dialog_keys = ctx + .db + .editor_canvas_generation_dialog() + .by_editor_canvas_generation_dialog_canvas_id() + .filter(&canvas_key) + .map(|row| row.dialog_key) + .collect::>(); + for key in layer_keys { + ctx.db.editor_canvas_layer().layer_key().delete(&key); + } + for key in dialog_keys { + ctx.db + .editor_canvas_generation_dialog() + .dialog_key() + .delete(&key); + } +} + +fn replace_structured_canvas_layout( + ctx: &ReducerContext, + canvas: &EditorCanvas, + layout: &StructuredCanvasLayout, + now: Timestamp, +) -> Result<(), String> { + let canvas_key = canvas.canvas_id.clone(); + let existing_layer_created_at = ctx + .db + .editor_canvas_layer() + .by_editor_canvas_layer_canvas_id() + .filter(&canvas_key) + .map(|row| (row.layer_key, row.created_at)) + .collect::>(); + let existing_dialog_created_at = ctx + .db + .editor_canvas_generation_dialog() + .by_editor_canvas_generation_dialog_canvas_id() + .filter(&canvas_key) + .map(|row| (row.dialog_key, row.created_at)) + .collect::>(); + delete_structured_canvas_layout(ctx, canvas.canvas_id.as_str()); + + let mut unique_keys = BTreeSet::new(); + for layer in &layout.layers { + let layer_key = + structured_canvas_row_key(canvas.canvas_id.as_str(), "layer", layer.layer_id.as_str()); + if !unique_keys.insert(layer_key.clone()) { + return Err(format!("图片画布存在重复图层 ID:{}", layer.layer_id)); + } + ctx.db.editor_canvas_layer().try_insert(EditorCanvasLayer { + created_at: existing_layer_created_at + .get(&layer_key) + .copied() + .unwrap_or(now), + layer_key, + canvas_id: canvas.canvas_id.clone(), + project_id: canvas.project_id.clone(), + owner_user_id: canvas.owner_user_id.clone(), + layer_id: layer.layer_id.clone(), + resource_id: layer.resource_id.clone(), + sort_order: layer.sort_order, + title: layer.title.clone(), + x: layer.x, + y: layer.y, + width: layer.width, + height: layer.height, + original_width: layer.original_width, + original_height: layer.original_height, + z_index: layer.z_index, + source_type: layer.source_type.clone(), + group_id: layer.group_id.clone(), + hidden: layer.hidden, + locked: layer.locked, + flip_x: layer.flip_x, + flip_y: layer.flip_y, + item_json: layer.item_json.clone(), + updated_at: now, + })?; + } + for dialog in &layout.dialogs { + let dialog_key = structured_canvas_row_key( + canvas.canvas_id.as_str(), + "dialog", + dialog.dialog_id.as_str(), + ); + if !unique_keys.insert(dialog_key.clone()) { + return Err(format!( + "图片画布存在重复生成对话框 ID:{}", + dialog.dialog_id + )); + } + ctx.db + .editor_canvas_generation_dialog() + .try_insert(EditorCanvasGenerationDialog { + created_at: existing_dialog_created_at + .get(&dialog_key) + .copied() + .unwrap_or(now), + dialog_key, + canvas_id: canvas.canvas_id.clone(), + project_id: canvas.project_id.clone(), + owner_user_id: canvas.owner_user_id.clone(), + dialog_id: dialog.dialog_id.clone(), + layer_id: dialog.layer_id.clone(), + resource_id: dialog.resource_id.clone(), + sort_order: dialog.sort_order, + mode: dialog.mode.clone(), + status: dialog.status.clone(), + source_layer_id: dialog.source_layer_id.clone(), + generated_layer_id: dialog.generated_layer_id.clone(), + placeholder_x: dialog.placeholder_x, + placeholder_y: dialog.placeholder_y, + placeholder_width: dialog.placeholder_width, + placeholder_height: dialog.placeholder_height, + dialog_json: dialog.dialog_json.clone(), + updated_at: now, + })?; + } + Ok(()) +} + +fn build_structured_canvas_layout_json( + ctx: &ReducerContext, + canvas: &EditorCanvas, +) -> Result { + let canvas_key = canvas.canvas_id.clone(); + let layers = ctx + .db + .editor_canvas_layer() + .by_editor_canvas_layer_canvas_id() + .filter(&canvas_key) + .map(|row| StructuredCanvasLayerDraft { + layer_id: row.layer_id, + resource_id: row.resource_id, + sort_order: row.sort_order, + title: row.title, + x: row.x, + y: row.y, + width: row.width, + height: row.height, + original_width: row.original_width, + original_height: row.original_height, + z_index: row.z_index, + source_type: row.source_type, + group_id: row.group_id, + hidden: row.hidden, + locked: row.locked, + flip_x: row.flip_x, + flip_y: row.flip_y, + item_json: row.item_json, + }) + .collect::>(); + let dialogs = ctx + .db + .editor_canvas_generation_dialog() + .by_editor_canvas_generation_dialog_canvas_id() + .filter(&canvas_key) + .map(|row| StructuredCanvasDialogDraft { + dialog_id: row.dialog_id, + layer_id: row.layer_id, + resource_id: row.resource_id, + sort_order: row.sort_order, + mode: row.mode, + status: row.status, + source_layer_id: row.source_layer_id, + generated_layer_id: row.generated_layer_id, + placeholder_x: row.placeholder_x, + placeholder_y: row.placeholder_y, + placeholder_width: row.placeholder_width, + placeholder_height: row.placeholder_height, + dialog_json: row.dialog_json, + }) + .collect::>(); + serialize_structured_canvas_layout(&StructuredCanvasLayout { + layers, + dialogs, + background_color: canvas.background_color.clone(), + }) + .map_err(|error| format!("结构化图片画布布局无法序列化:{error}")) +} + +fn serialize_structured_canvas_layout( + layout: &StructuredCanvasLayout, +) -> Result { + let mut items = Vec::<(u32, String, JsonValue)>::new(); + for layer in &layout.layers { + let mut object = serde_json::from_str::>(&layer.item_json)?; + object.insert( + "layerId".to_string(), + JsonValue::String(layer.layer_id.clone()), + ); + object.insert( + "resourceId".to_string(), + JsonValue::String(layer.resource_id.clone()), + ); + insert_optional_json_string(&mut object, "title", layer.title.as_deref()); + insert_optional_json_number(&mut object, "x", layer.x); + insert_optional_json_number(&mut object, "y", layer.y); + insert_optional_json_number(&mut object, "width", layer.width); + insert_optional_json_number(&mut object, "height", layer.height); + insert_optional_json_number(&mut object, "originalWidth", layer.original_width); + insert_optional_json_number(&mut object, "originalHeight", layer.original_height); + insert_optional_json_number(&mut object, "zIndex", layer.z_index); + insert_optional_json_string(&mut object, "sourceType", layer.source_type.as_deref()); + insert_optional_json_string(&mut object, "groupId", layer.group_id.as_deref()); + insert_true_json_bool(&mut object, "hidden", layer.hidden); + insert_true_json_bool(&mut object, "locked", layer.locked); + insert_true_json_bool(&mut object, "flipX", layer.flip_x); + insert_true_json_bool(&mut object, "flipY", layer.flip_y); + items.push(( + layer.sort_order, + format!("layer:{}", layer.layer_id), + JsonValue::Object(object), + )); + } + for dialog in &layout.dialogs { + let mut item_object = + serde_json::from_str::>(&dialog.dialog_json)?; + let mut dialog_object = match item_object.remove("dialog") { + Some(JsonValue::Object(value)) => value, + Some(_) => { + return Err(::custom( + "generation dialog extension must be an object", + )); + } + None => JsonMap::new(), + }; + dialog_object.insert( + "id".to_string(), + JsonValue::String(dialog.dialog_id.clone()), + ); + dialog_object.insert("mode".to_string(), JsonValue::String(dialog.mode.clone())); + dialog_object.insert( + "status".to_string(), + JsonValue::String(dialog.status.clone()), + ); + insert_optional_json_string( + &mut dialog_object, + "sourceLayerId", + dialog.source_layer_id.as_deref(), + ); + insert_optional_json_string( + &mut dialog_object, + "generatedLayerId", + dialog.generated_layer_id.as_deref(), + ); + let placeholder_was_present = dialog_object.contains_key("placeholder"); + let mut placeholder = match dialog_object.remove("placeholder") { + Some(JsonValue::Object(value)) => value, + Some(_) => { + return Err(::custom( + "generation dialog placeholder extension must be an object", + )); + } + None => JsonMap::new(), + }; + insert_optional_json_number(&mut placeholder, "x", dialog.placeholder_x); + insert_optional_json_number(&mut placeholder, "y", dialog.placeholder_y); + insert_optional_json_number(&mut placeholder, "width", dialog.placeholder_width); + insert_optional_json_number(&mut placeholder, "height", dialog.placeholder_height); + if placeholder_was_present || !placeholder.is_empty() { + dialog_object.insert("placeholder".to_string(), JsonValue::Object(placeholder)); + } + item_object.insert( + "itemType".to_string(), + JsonValue::String("generation-dialog".to_string()), + ); + item_object.insert( + "layerId".to_string(), + JsonValue::String(dialog.layer_id.clone()), + ); + item_object.insert( + "resourceId".to_string(), + JsonValue::String(dialog.resource_id.clone()), + ); + item_object.insert("dialog".to_string(), JsonValue::Object(dialog_object)); + items.push(( + dialog.sort_order, + format!("dialog:{}", dialog.dialog_id), + JsonValue::Object(item_object), + )); + } + items.sort_by(|left, right| left.0.cmp(&right.0).then_with(|| left.1.cmp(&right.1))); + let mut values = items + .into_iter() + .map(|(_, _, value)| value) + .collect::>(); + if let Some(background_color) = layout.background_color.as_ref() { + values.insert( + 0, + serde_json::json!({ + "itemType": "canvas-settings", + "layerId": "canvas-settings:default", + "resourceId": "canvas-settings:default", + "canvasBackgroundColor": background_color, + }), + ); + } + serde_json::to_string(&values) +} + +fn insert_optional_json_string( + object: &mut JsonMap, + key: &str, + value: Option<&str>, +) { + if let Some(value) = value { + object.insert(key.to_string(), JsonValue::String(value.to_string())); + } +} + +fn insert_optional_json_number( + object: &mut JsonMap, + key: &str, + value: Option, +) { + if let Some(number) = value.and_then(serde_json::Number::from_f64) { + object.insert(key.to_string(), JsonValue::Number(number)); + } +} + +fn insert_true_json_bool(object: &mut JsonMap, key: &str, value: bool) { + if value { + object.insert(key.to_string(), JsonValue::Bool(true)); + } +} + +fn upsert_editor_canvas_layout_migration( + ctx: &ReducerContext, + canvas: &EditorCanvas, + source_layout_sha256: &str, + structured_layout_sha256: &str, + verified_revision: u64, + integrity: &CanvasLayoutIntegrity, + status: &str, + activated_at: Option, + rolled_back_at: Option, + now: Timestamp, +) -> EditorCanvasLayoutMigration { + let existing = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id); + let row = EditorCanvasLayoutMigration { + canvas_id: canvas.canvas_id.clone(), + project_id: canvas.project_id.clone(), + owner_user_id: canvas.owner_user_id.clone(), + source_layout_sha256: source_layout_sha256.to_string(), + structured_layout_sha256: structured_layout_sha256.to_string(), + verified_revision, + layer_count: integrity.layer_count, + dialog_count: integrity.dialog_count, + resource_refs_sha256: integrity.resource_refs_sha256.clone(), + migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + status: status.to_string(), + migrated_at: existing.as_ref().map(|row| row.migrated_at).unwrap_or(now), + activated_at: existing + .as_ref() + .and_then(|row| row.activated_at) + .or(activated_at), + rolled_back_at: rolled_back_at + .or_else(|| existing.as_ref().and_then(|row| row.rolled_back_at)), + updated_at: now, + }; + if existing.is_some() { + ctx.db + .editor_canvas_layout_migration() + .canvas_id() + .delete(&canvas.canvas_id); + } + ctx.db.editor_canvas_layout_migration().insert(row.clone()); + row +} + +fn migration_snapshot( + row: EditorCanvasLayoutMigration, + revision: u64, +) -> EditorCanvasLayoutMigrationSnapshot { + EditorCanvasLayoutMigrationSnapshot { + canvas_id: row.canvas_id, + project_id: row.project_id, + source_layout_sha256: row.source_layout_sha256, + structured_layout_sha256: row.structured_layout_sha256, + verified_revision: row.verified_revision, + layer_count: row.layer_count, + dialog_count: row.dialog_count, + resource_refs_sha256: row.resource_refs_sha256, + migration_version: row.migration_version, + status: row.status, + revision, + } +} + +fn migration_preview_snapshot( + canvas: &EditorCanvas, + source_layout_sha256: String, + structured_layout_sha256: String, + integrity: &CanvasLayoutIntegrity, + status: &str, +) -> EditorCanvasLayoutMigrationSnapshot { + EditorCanvasLayoutMigrationSnapshot { + canvas_id: canvas.canvas_id.clone(), + project_id: canvas.project_id.clone(), + source_layout_sha256, + structured_layout_sha256, + verified_revision: canvas.revision, + layer_count: integrity.layer_count, + dialog_count: integrity.dialog_count, + resource_refs_sha256: integrity.resource_refs_sha256.clone(), + migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + status: status.to_string(), + revision: canvas.revision, + } +} + +fn verify_migration_layout( + migration: &EditorCanvasLayoutMigration, + revision: u64, + layout_sha256: &str, + integrity: &CanvasLayoutIntegrity, +) -> Result<(), String> { + if revision != migration.verified_revision { + return Err(format!( + "图片画布 revision 在结构化校验后已变化:校验时为 {},当前为 {revision}", + migration.verified_revision + )); + } + if layout_sha256 != migration.structured_layout_sha256 + || integrity.layer_count != migration.layer_count + || integrity.dialog_count != migration.dialog_count + || integrity.resource_refs_sha256 != migration.resource_refs_sha256 + { + return Err("图片画布结构化数据已漂移,hash、数量或资源引用校验失败".to_string()); + } + Ok(()) +} + +#[derive(Debug, PartialEq, Eq)] +struct EditorCanvasResourceLayoutPatch { + layers_json: String, + matched_layer_count: u32, + remapped_layer_count: u32, + removed_source_resource_id_count: u32, +} + +fn repair_editor_canvas_resources( + ctx: &ReducerContext, + input: EditorCanvasResourceRepairInput, +) -> Result { + let canvas_id = normalize_required(&input.canvas_id, "editor_canvas.canvas_id")?; + let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; + validate_raw_sha256( + input.expected_canvas_layout_sha256.as_str(), + "expected_canvas_layout_sha256", + )?; + validate_raw_sha256( + input.expected_project_layout_sha256.as_str(), + "expected_project_layout_sha256", + )?; + validate_editor_canvas_resource_repair_actions(&input)?; + + let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + let canvas = ctx + .db + .editor_canvas() + .canvas_id() + .find(&canvas_id) + .ok_or_else(|| "图片画布不存在,拒绝资源修复".to_string())?; + if canvas.project_id != project_id || canvas.owner_user_id != owner_user_id { + return Err("图片画布与工程或 owner 不一致,拒绝资源修复".to_string()); + } + if canvas.layout_storage_version != 0 { + return Err("只允许修复未激活结构化存储的 legacy 图片画布".to_string()); + } + if ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas_id) + .is_some() + { + return Err("图片画布已有结构化迁移记录,请先处理迁移状态再修复资源".to_string()); + } + let canvas_layout = parse_legacy_layout_value(canvas.layers_json.as_str())?; + let project_layout = parse_legacy_layout_value(project.layers_json.as_str())?; + if canvas_layout != project_layout { + return Err("editor_canvas 与 editor_project 的 legacy 布局不一致".to_string()); + } + + let expected_next_revision = input + .expected_revision + .checked_add(1) + .ok_or_else(|| "图片画布 revision 已达到上限".to_string())?; + if canvas.revision == expected_next_revision { + let patch = patch_editor_canvas_resource_layout( + canvas.layers_json.as_str(), + &input.remaps, + &input.restores, + true, + )?; + let planned_resources = prepare_editor_canvas_restored_resources( + ctx, + &patch.layers_json, + project_id.as_str(), + owner_user_id.as_str(), + &input.restores, + Timestamp::from_micros_since_unix_epoch(input.updated_at_micros), + true, + )?; + validate_editor_canvas_remap_resources( + ctx, + &patch.layers_json, + project_id.as_str(), + owner_user_id.as_str(), + &input.remaps, + true, + )?; + validate_repaired_editor_canvas_layout_resources( + ctx, + patch.layers_json.as_str(), + canvas_id.as_str(), + project_id.as_str(), + owner_user_id.as_str(), + &planned_resources, + )?; + return Ok(EditorCanvasResourceRepairSnapshot { + dry_run: input.dry_run, + already_repaired: true, + matched_layer_count: patch.matched_layer_count, + remapped_layer_count: 0, + restored_resource_count: 0, + removed_source_resource_id_count: 0, + revision: canvas.revision, + }); + } + if canvas.revision != input.expected_revision { + return Err("图片画布 revision 与修复计划不一致".to_string()); + } + if raw_layout_sha256(canvas.layers_json.as_str()) != input.expected_canvas_layout_sha256 { + return Err("editor_canvas 原始布局 hash 与修复计划不一致".to_string()); + } + if raw_layout_sha256(project.layers_json.as_str()) != input.expected_project_layout_sha256 { + return Err("editor_project 原始布局 hash 与修复计划不一致".to_string()); + } + + let patch = patch_editor_canvas_resource_layout( + canvas.layers_json.as_str(), + &input.remaps, + &input.restores, + false, + )?; + if patch.layers_json.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES { + return Err("资源修复后的图片画布布局超过 2 MiB".to_string()); + } + validate_editor_canvas_remap_resources( + ctx, + patch.layers_json.as_str(), + project_id.as_str(), + owner_user_id.as_str(), + &input.remaps, + false, + )?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let planned_resources = prepare_editor_canvas_restored_resources( + ctx, + patch.layers_json.as_str(), + project_id.as_str(), + owner_user_id.as_str(), + &input.restores, + now, + false, + )?; + validate_repaired_editor_canvas_layout_resources( + ctx, + patch.layers_json.as_str(), + canvas_id.as_str(), + project_id.as_str(), + owner_user_id.as_str(), + &planned_resources, + )?; + + if input.dry_run { + return Ok(EditorCanvasResourceRepairSnapshot { + dry_run: true, + already_repaired: false, + matched_layer_count: patch.matched_layer_count, + remapped_layer_count: patch.remapped_layer_count, + restored_resource_count: planned_resources.len() as u32, + removed_source_resource_id_count: patch.removed_source_resource_id_count, + revision: canvas.revision, + }); + } + + for resource in planned_resources { + ctx.db.editor_project_resource().try_insert(resource)?; + } + ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id); + ctx.db.editor_canvas().insert(EditorCanvas { + layers_json: patch.layers_json.clone(), + revision: expected_next_revision, + updated_at: now, + ..canvas + }); + ctx.db + .editor_project() + .project_id() + .delete(&project.project_id); + ctx.db.editor_project().insert(EditorProject { + layers_json: patch.layers_json, + updated_at: now, + ..project + }); + Ok(EditorCanvasResourceRepairSnapshot { + dry_run: false, + already_repaired: false, + matched_layer_count: patch.matched_layer_count, + remapped_layer_count: patch.remapped_layer_count, + restored_resource_count: input.restores.len() as u32, + removed_source_resource_id_count: patch.removed_source_resource_id_count, + revision: expected_next_revision, + }) +} + +fn validate_raw_sha256(value: &str, field: &str) -> Result<(), String> { + if value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + Ok(()) + } else { + Err(format!("{field} 必须是 64 位十六进制 SHA-256")) + } +} + +fn raw_layout_sha256(layers_json: &str) -> String { + sha256_hex(layers_json.as_bytes()) +} + +fn parse_legacy_layout_value(layers_json: &str) -> Result { + let value = serde_json::from_str::(layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + if !value.is_array() { + return Err("图片画布图层布局必须是数组".to_string()); + } + Ok(value) +} + +fn validate_editor_canvas_resource_repair_actions( + input: &EditorCanvasResourceRepairInput, +) -> Result<(), String> { + let action_count = input.remaps.len() + input.restores.len(); + if action_count == 0 || action_count > EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS { + return Err(format!( + "单画布资源修复项数量必须在 1 到 {EDITOR_CANVAS_RESOURCE_REPAIR_MAX_ACTIONS} 之间" + )); + } + let mut layer_ids = BTreeSet::new(); + let mut missing_resource_ids = BTreeSet::new(); + for action in &input.remaps { + let layer_id = normalize_required(&action.layer_id, "remap.layer_id")?; + let missing = normalize_required( + &action.expected_missing_resource_id, + "remap.expected_missing_resource_id", + )?; + let replacement = normalize_required( + &action.replacement_resource_id, + "remap.replacement_resource_id", + )?; + normalize_required( + &action.expected_layout_source_resource_id, + "remap.expected_layout_source_resource_id", + )?; + if missing == replacement { + return Err("资源 remap 的缺失 ID 与替换 ID 不能相同".to_string()); + } + if !layer_ids.insert(layer_id) || !missing_resource_ids.insert(missing) { + return Err("资源修复计划包含重复 layerId 或缺失 resourceId".to_string()); + } + } + for action in &input.restores { + let layer_id = normalize_required(&action.layer_id, "restore.layer_id")?; + let resource_id = normalize_required(&action.resource_id, "restore.resource_id")?; + normalize_required(&action.asset_object_id, "restore.asset_object_id")?; + validate_editor_canvas_audio_asset_kind(action.asset_kind.as_str())?; + if !layer_ids.insert(layer_id) || !missing_resource_ids.insert(resource_id) { + return Err("资源修复计划包含重复 layerId 或缺失 resourceId".to_string()); + } + } + Ok(()) +} + +fn patch_editor_canvas_resource_layout( + layers_json: &str, + remaps: &[EditorCanvasResourceRemapInput], + restores: &[EditorCanvasMissingResourceRestoreInput], + already_repaired: bool, +) -> Result { + let mut value = parse_legacy_layout_value(layers_json)?; + let items = value + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut layer_counts = BTreeMap::::new(); + for item in items.iter() { + if is_canvas_generation_dialog_layout_item(item) + || item.get("itemType").and_then(JsonValue::as_str) == Some("canvas-settings") + { + continue; + } + if let Some(layer_id) = json_optional_string(item.get("layerId")) { + *layer_counts.entry(layer_id).or_default() += 1; + } + } + if layer_counts.values().any(|count| *count != 1) { + return Err("图片画布包含重复 layerId,拒绝定向资源修复".to_string()); + } + + for action in remaps { + let missing_reference_count = items + .iter() + .filter(|item| { + json_optional_string(item.get("resourceId")).as_deref() + == Some(action.expected_missing_resource_id.trim()) + }) + .count(); + let expected_count = usize::from(!already_repaired); + if missing_reference_count != expected_count { + return Err("图片缺失 resourceId 在画布中的引用数量与修复状态不一致".to_string()); + } + } + for action in restores { + let resource_reference_count = items + .iter() + .filter(|item| { + json_optional_string(item.get("resourceId")).as_deref() + == Some(action.resource_id.trim()) + }) + .count(); + if resource_reference_count != 1 { + return Err("音频 resourceId 在画布中必须只被目标图层引用一次".to_string()); + } + } + + let mut removed_source_resource_id_count = 0u32; + for action in remaps { + let item = find_unique_resource_repair_layer_mut(items, action.layer_id.as_str())?; + let object = item + .as_object_mut() + .ok_or_else(|| "资源修复目标图层不是对象".to_string())?; + let current_resource_id = json_required_string(object.get("resourceId"), "resourceId")?; + if already_repaired { + if current_resource_id != action.replacement_resource_id + || object.contains_key("sourceResourceId") + { + return Err("图片画布不是修复计划对应的已完成状态".to_string()); + } + continue; + } + if current_resource_id != action.expected_missing_resource_id { + return Err("图片图层的原 resourceId 与修复计划不一致".to_string()); + } + let source_resource_id = + json_required_string(object.get("sourceResourceId"), "sourceResourceId")?; + if source_resource_id != action.expected_layout_source_resource_id { + return Err("图片图层的 sourceResourceId 与修复计划不一致".to_string()); + } + object.insert( + "resourceId".to_string(), + JsonValue::String(action.replacement_resource_id.trim().to_string()), + ); + object.remove("sourceResourceId"); + removed_source_resource_id_count = removed_source_resource_id_count.saturating_add(1); + } + for action in restores { + let item = find_unique_resource_repair_layer_mut(items, action.layer_id.as_str())?; + let object = item + .as_object() + .ok_or_else(|| "资源修复目标图层不是对象".to_string())?; + if json_required_string(object.get("resourceId"), "resourceId")? != action.resource_id + || json_required_string(object.get("assetObjectId"), "assetObjectId")? + != action.asset_object_id + { + return Err("音频图层的 resourceId 或 assetObjectId 与修复计划不一致".to_string()); + } + } + let layers_json = serde_json::to_string(&value) + .map_err(|_| "资源修复后的图片画布布局无法序列化".to_string())?; + Ok(EditorCanvasResourceLayoutPatch { + layers_json, + matched_layer_count: (remaps.len() + restores.len()) as u32, + remapped_layer_count: if already_repaired { + 0 + } else { + remaps.len() as u32 + }, + removed_source_resource_id_count, + }) +} + +fn find_unique_resource_repair_layer_mut<'a>( + items: &'a mut [JsonValue], + layer_id: &str, +) -> Result<&'a mut JsonValue, String> { + let normalized = normalize_required(layer_id, "repair.layer_id")?; + let mut matched_index = None; + for (index, item) in items.iter().enumerate() { + if is_canvas_generation_dialog_layout_item(item) + || item.get("itemType").and_then(JsonValue::as_str) == Some("canvas-settings") + { + continue; + } + if json_optional_string(item.get("layerId")).as_deref() == Some(normalized.as_str()) { + if matched_index.replace(index).is_some() { + return Err("资源修复目标 layerId 在画布中不唯一".to_string()); + } + } + } + matched_index + .map(|index| &mut items[index]) + .ok_or_else(|| "资源修复目标 layerId 在画布中不存在".to_string()) +} + +fn validate_editor_canvas_remap_resources( + ctx: &ReducerContext, + layers_json: &str, + project_id: &str, + owner_user_id: &str, + remaps: &[EditorCanvasResourceRemapInput], + already_repaired: bool, +) -> Result<(), String> { + let value = parse_legacy_layout_value(layers_json)?; + let items = value + .as_array() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + for action in remaps { + if ctx + .db + .editor_project_resource() + .resource_id() + .find(&action.expected_missing_resource_id) + .is_some() + { + return Err("图片图层声明的缺失 resourceId 实际已存在".to_string()); + } + let resource = ctx + .db + .editor_project_resource() + .resource_id() + .find(&action.replacement_resource_id) + .filter(|row| row.project_id == project_id && row.owner_user_id == owner_user_id) + .ok_or_else(|| "图片图层替换资源不存在或不属于当前工程".to_string())?; + let equivalent_resource_count = ctx + .db + .editor_project_resource() + .by_editor_project_resource_project_id() + .filter(&project_id.to_string()) + .filter(|candidate| { + candidate.owner_user_id == owner_user_id + && candidate.asset_object_id == resource.asset_object_id + && candidate.image_src == resource.image_src + && candidate.object_key == resource.object_key + && candidate.width == resource.width + && candidate.height == resource.height + && candidate.source_type == resource.source_type + && candidate.prompt == resource.prompt + && candidate.actual_prompt == resource.actual_prompt + && candidate.model == resource.model + && candidate.provider == resource.provider + && candidate.task_id == resource.task_id + }) + .count(); + if equivalent_resource_count != 1 { + return Err("图片图层在当前工程内没有唯一可复用的项目资源".to_string()); + } + let item = find_unique_resource_repair_layer(items, action.layer_id.as_str())?; + if json_required_string(item.get("resourceId"), "resourceId")? + != action.replacement_resource_id + || (!already_repaired && item.contains_key("sourceResourceId")) + { + return Err("图片图层修复预览与替换资源不一致".to_string()); + } + validate_resource_repair_layer_against_project_resource(item, &resource)?; + validate_asset_object_for_project_resource(ctx, &resource, owner_user_id, "image/", None)?; + } + Ok(()) +} + +fn find_unique_resource_repair_layer<'a>( + items: &'a [JsonValue], + layer_id: &str, +) -> Result<&'a JsonMap, String> { + let normalized = normalize_required(layer_id, "repair.layer_id")?; + let mut matched = None; + for item in items { + if is_canvas_generation_dialog_layout_item(item) + || item.get("itemType").and_then(JsonValue::as_str) == Some("canvas-settings") + { + continue; + } + let Some(object) = item.as_object() else { + continue; + }; + if json_optional_string(object.get("layerId")).as_deref() == Some(normalized.as_str()) { + if matched.replace(object).is_some() { + return Err("资源修复目标 layerId 在画布中不唯一".to_string()); + } + } + } + matched.ok_or_else(|| "资源修复目标 layerId 在画布中不存在".to_string()) +} + +fn validate_resource_repair_layer_against_project_resource( + item: &JsonMap, + resource: &EditorProjectResource, +) -> Result<(), String> { + let single_layout = serde_json::to_string(&vec![JsonValue::Object(item.clone())]) + .map_err(|_| "资源修复目标图层无法序列化".to_string())?; + let mut parsed = parse_structured_canvas_layout(single_layout.as_str())?; + let layer = parsed + .layers + .first_mut() + .ok_or_else(|| "资源修复目标不是普通图层".to_string())?; + match normalize_structured_canvas_layer_against_resource(layer, Some(resource))? { + Some(_) => Ok(()), + None => Err("资源修复目标被错误识别为缺资源本地序列".to_string()), + } +} + +fn validate_asset_object_for_project_resource( + ctx: &ReducerContext, + resource: &EditorProjectResource, + owner_user_id: &str, + content_type_prefix: &str, + expected_asset_object_kind: Option<&str>, +) -> Result { + let asset_object_id = resource + .asset_object_id + .as_deref() + .ok_or_else(|| "项目资源缺少 assetObjectId".to_string())?; + let asset_object = + crate::asset_metadata::find_asset_object_snapshot_by_id(ctx, asset_object_id) + .ok_or_else(|| "项目资源对应的 asset_object 不存在".to_string())?; + if asset_object.owner_user_id.as_deref() != Some(owner_user_id) + || asset_object.access_policy != AssetObjectAccessPolicy::Private + || asset_object.content_length == 0 + || !asset_object + .content_type + .as_deref() + .is_some_and(|value| value.starts_with(content_type_prefix)) + || resource.object_key.as_deref() != Some(asset_object.object_key.as_str()) + || resource.image_src != format!("/{}", asset_object.object_key.trim_start_matches('/')) + || resource.task_id != asset_object.source_job_id + || expected_asset_object_kind.is_some_and(|value| asset_object.asset_kind.as_str() != value) + { + return Err("项目资源与 asset_object 的 owner、媒体或任务谱系不一致".to_string()); + } + Ok(asset_object) +} + +fn prepare_editor_canvas_restored_resources( + ctx: &ReducerContext, + layers_json: &str, + project_id: &str, + owner_user_id: &str, + restores: &[EditorCanvasMissingResourceRestoreInput], + now: Timestamp, + already_repaired: bool, +) -> Result, String> { + let value = parse_legacy_layout_value(layers_json)?; + let items = value + .as_array() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut resources = Vec::with_capacity(restores.len()); + for action in restores { + let item = find_unique_resource_repair_layer(items, action.layer_id.as_str())?; + let asset_kind = validate_editor_canvas_audio_asset_kind(action.asset_kind.as_str())?; + let resource = build_restored_audio_project_resource( + ctx, + item, + project_id, + owner_user_id, + action, + asset_kind, + now, + )?; + let existing = ctx + .db + .editor_project_resource() + .resource_id() + .find(&action.resource_id); + if already_repaired { + let Some(existing) = existing else { + return Err("音频项目资源尚未恢复,不能视为已修复".to_string()); + }; + if !restored_project_resource_matches(&existing, &resource) { + return Err("已恢复音频项目资源与修复计划不一致".to_string()); + } + } else if existing.is_some() { + return Err("待恢复的音频 resourceId 已存在,拒绝覆盖".to_string()); + } + resources.push(resource); + } + Ok(resources) +} + +fn validate_editor_canvas_audio_asset_kind(asset_kind: &str) -> Result<&'static str, String> { + match asset_kind.trim() { + "sound-effect" => Ok("sound-effect"), + "background-music" => Ok("background-music"), + _ => Err("音频资源 assetKind 只允许 sound-effect 或 background-music".to_string()), + } +} + +fn audio_asset_object_kind(asset_kind: &str) -> &'static str { + match asset_kind { + "sound-effect" => "editor_sound_effect", + "background-music" => "editor_background_music", + _ => "", + } +} + +fn build_restored_audio_project_resource( + ctx: &ReducerContext, + item: &JsonMap, + project_id: &str, + owner_user_id: &str, + action: &EditorCanvasMissingResourceRestoreInput, + asset_kind: &str, + now: Timestamp, +) -> Result { + if json_required_string(item.get("resourceId"), "resourceId")? != action.resource_id + || json_required_string(item.get("assetObjectId"), "assetObjectId")? + != action.asset_object_id + || json_required_string(item.get("mediaType"), "mediaType")? != "audio" + || json_required_string(item.get("sourceType"), "sourceType")? != "generated" + { + return Err("音频图层类型或资源引用与恢复计划不一致".to_string()); + } + if item + .get("sourceResourceId") + .is_some_and(|value| !value.is_null()) + || item + .get("generationInputs") + .is_some_and(|value| !value.is_null()) + { + return Err("待恢复音频图层包含未审计的来源或生成输入字段".to_string()); + } + if let Some(layout_asset_kind) = json_optional_string(item.get("assetKind")) + && layout_asset_kind != asset_kind + { + return Err("音频图层 assetKind 与恢复计划不一致".to_string()); + } + let object_key = json_required_string(item.get("objectKey"), "objectKey")?; + let task_id = json_required_string(item.get("taskId"), "taskId")?; + let asset_object = crate::asset_metadata::find_asset_object_snapshot_by_id( + ctx, + action.asset_object_id.as_str(), + ) + .ok_or_else(|| "待恢复音频对应的 asset_object 不存在".to_string())?; + if asset_object.owner_user_id.as_deref() != Some(owner_user_id) + || asset_object.access_policy != AssetObjectAccessPolicy::Private + || asset_object.object_key != object_key + || asset_object.source_job_id.as_deref() != Some(task_id.as_str()) + || asset_object.entity_id.as_deref() != Some(task_id.as_str()) + || asset_object.profile_id.is_some() + || asset_object.asset_kind != audio_asset_object_kind(asset_kind) + || asset_object.content_length == 0 + || !asset_object + .content_type + .as_deref() + .is_some_and(|value| value.starts_with("audio/")) + { + return Err("待恢复音频与 asset_object 的 owner、对象或任务谱系不一致".to_string()); + } + for field in ["originalWidth", "width"] { + if json_optional_number(item.get(field)) + != Some(f64::from(EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH)) + { + return Err(format!("音频图层 {field} 不是 420")); + } + } + for field in ["originalHeight", "height"] { + if json_optional_number(item.get(field)) + != Some(f64::from(EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT)) + { + return Err(format!("音频图层 {field} 不是 120")); + } + } + Ok(EditorProjectResource { + resource_id: action.resource_id.trim().to_string(), + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + asset_object_id: Some(action.asset_object_id.trim().to_string()), + image_src: format!("/{}", object_key.trim_start_matches('/')), + object_key: Some(object_key), + width: EDITOR_CANVAS_AUDIO_RESOURCE_WIDTH, + height: EDITOR_CANVAS_AUDIO_RESOURCE_HEIGHT, + source_type: "generated".to_string(), + prompt: optional_repair_layout_string(item, "prompt")?, + actual_prompt: optional_repair_layout_string(item, "actualPrompt")?, + model: optional_repair_layout_string(item, "model")?, + provider: optional_repair_layout_string(item, "provider")?, + task_id: Some(task_id), + source_resource_id: None, + created_at: Timestamp::from_micros_since_unix_epoch(asset_object.created_at_micros), + updated_at: now, + asset_kind: Some(asset_kind.to_string()), + generation_inputs_json: None, + public_showcase_enabled: false, + }) +} + +fn optional_repair_layout_string( + item: &JsonMap, + field: &str, +) -> Result, String> { + match item.get(field) { + None | Some(JsonValue::Null) => Ok(None), + Some(JsonValue::String(value)) => Ok(normalize_optional(Some(value.clone()))), + Some(_) => Err(format!("音频图层 {field} 必须是字符串")), + } +} + +fn restored_project_resource_matches( + existing: &EditorProjectResource, + expected: &EditorProjectResource, +) -> bool { + existing.resource_id == expected.resource_id + && existing.project_id == expected.project_id + && existing.owner_user_id == expected.owner_user_id + && existing.asset_object_id == expected.asset_object_id + && existing.image_src == expected.image_src + && existing.object_key == expected.object_key + && existing.width == expected.width + && existing.height == expected.height + && existing.source_type == expected.source_type + && existing.prompt == expected.prompt + && existing.actual_prompt == expected.actual_prompt + && existing.model == expected.model + && existing.provider == expected.provider + && existing.task_id == expected.task_id + && existing.source_resource_id == expected.source_resource_id + && existing.created_at == expected.created_at + && existing.asset_kind == expected.asset_kind + && existing.generation_inputs_json == expected.generation_inputs_json + && existing.public_showcase_enabled == expected.public_showcase_enabled +} + +fn validate_repaired_editor_canvas_layout_resources( + ctx: &ReducerContext, + layers_json: &str, + _canvas_id: &str, + project_id: &str, + owner_user_id: &str, + planned_resources: &[EditorProjectResource], +) -> Result<(), String> { + let mut layout = parse_structured_canvas_layout(layers_json)?; + for layer in &mut layout.layers { + let stored = ctx + .db + .editor_project_resource() + .resource_id() + .find(&layer.resource_id) + .filter(|row| row.project_id == project_id && row.owner_user_id == owner_user_id); + let resource = stored.as_ref().or_else(|| { + planned_resources + .iter() + .find(|row| row.resource_id == layer.resource_id) + }); + let normalized = normalize_structured_canvas_layer_against_resource(layer, resource)?; + if normalized.is_none() { + let item = serde_json::from_str::>(&layer.item_json) + .map_err(|_| "缺资源本地图片序列扩展不是合法对象".to_string())?; + if let Some(source_resource_id) = json_optional_string(item.get("sourceResourceId")) + && let Some(source_resource) = ctx + .db + .editor_project_resource() + .resource_id() + .find(&source_resource_id) + && (source_resource.project_id != project_id + || source_resource.owner_user_id != owner_user_id) + { + return Err("缺资源本地图片序列的来源资源不属于当前工程".to_string()); + } + } + } + Ok(()) +} + +fn backfill_editor_canvas_layout( + ctx: &ReducerContext, + input: EditorCanvasLayoutMigrationInput, +) -> Result { + let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; + let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let canvas_id = default_canvas_id(project_id.as_str()); + let canvas = if input.dry_run { + ctx.db + .editor_canvas() + .canvas_id() + .find(&canvas_id) + .unwrap_or_else(|| { + default_canvas_from_legacy_project( + project_id.as_str(), + owner_user_id.as_str(), + Some(&project), + now, + ) + }) + } else { + ensure_default_canvas(ctx, &project_id, &owner_user_id, Some(&project), now)? + }; + let source_hash = canonical_layout_sha256(canvas.layers_json.as_str())?; + let source_integrity = canvas_layout_integrity(canvas.layers_json.as_str())?; + if let Some(existing) = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id) + { + if existing.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE { + let structured_json = build_structured_canvas_layout_json(ctx, &canvas)?; + let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; + if existing.source_layout_sha256 != source_hash { + return Err("已激活图片画布的 legacy shadow 与迁移记录不一致".to_string()); + } + verify_migration_layout( + &existing, + canvas.revision, + structured_hash.as_str(), + &structured_integrity, + )?; + return Ok(migration_snapshot(existing, canvas.revision)); + } + } + let mut layout = parse_structured_canvas_layout(canvas.layers_json.as_str())?; + normalize_structured_canvas_layer_resource_metadata( + ctx, + canvas.canvas_id.as_str(), + project_id.as_str(), + owner_user_id.as_str(), + &mut layout, + now, + !input.dry_run, + true, + )?; + let preview_json = serialize_structured_canvas_layout(&layout) + .map_err(|_| "图片画布结构化回填预览无法序列化".to_string())?; + let preview_hash = canonical_layout_sha256(preview_json.as_str())?; + let preview_integrity = canvas_layout_integrity(preview_json.as_str())?; + if preview_hash != source_hash || preview_integrity != source_integrity { + return Err("图片画布结构化回填预览校验失败".to_string()); + } + if input.dry_run { + return Ok(migration_preview_snapshot( + &canvas, + source_hash, + preview_hash, + &preview_integrity, + "dry_run_backfill", + )); + } + replace_structured_canvas_layout(ctx, &canvas, &layout, now)?; + let structured_json = build_structured_canvas_layout_json_with_background( + ctx, + canvas.canvas_id.as_str(), + layout.background_color.as_deref(), + )?; + let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; + if structured_hash != source_hash || structured_integrity != source_integrity { + return Err( + "图片画布结构化回填校验失败,重组布局的 hash、数量或资源引用与旧布局不一致".to_string(), + ); + } + let row = upsert_editor_canvas_layout_migration( + ctx, + &canvas, + source_hash.as_str(), + structured_hash.as_str(), + canvas.revision, + &structured_integrity, + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED, + None, + None, + now, + ); + Ok(migration_snapshot(row, canvas.revision)) +} + +fn activate_editor_canvas_layout( + ctx: &ReducerContext, + input: EditorCanvasLayoutMigrationInput, +) -> Result { + let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; + require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let canvas = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id.as_str())) + .ok_or_else(|| "图片画布尚未创建,不能激活结构化布局".to_string())?; + let migration = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id) + .ok_or_else(|| "图片画布尚未执行结构化回填".to_string())?; + if migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE + && canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED + { + let structured_json = build_structured_canvas_layout_json(ctx, &canvas)?; + let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; + verify_migration_layout( + &migration, + canvas.revision, + structured_hash.as_str(), + &structured_integrity, + )?; + return Ok(migration_snapshot(migration, canvas.revision)); + } + if migration.status != EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED { + return Err("图片画布迁移状态不允许激活,请先重新回填".to_string()); + } + if canvas.revision != migration.verified_revision { + return Err("图片画布在回填后 revision 已变化,请重新回填后再激活".to_string()); + } + let current_source_hash = canonical_layout_sha256(canvas.layers_json.as_str())?; + if current_source_hash != migration.source_layout_sha256 { + return Err("图片画布旧布局在回填后发生变化,请重新回填后再激活".to_string()); + } + let structured_json = build_structured_canvas_layout_json_with_background( + ctx, + canvas.canvas_id.as_str(), + parse_structured_canvas_layout(canvas.layers_json.as_str())? + .background_color + .as_deref(), + )?; + let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; + verify_migration_layout( + &migration, + canvas.revision, + structured_hash.as_str(), + &structured_integrity, + )?; + let background_color = + parse_structured_canvas_layout(canvas.layers_json.as_str())?.background_color; + if input.dry_run { + return Ok(migration_snapshot(migration, canvas.revision)); + } + let next_revision = canvas.revision.saturating_add(1); + ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id); + ctx.db.editor_canvas().insert(EditorCanvas { + layout_storage_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + revision: next_revision, + background_color, + updated_at: now, + ..canvas.clone() + }); + let row = upsert_editor_canvas_layout_migration( + ctx, + &canvas, + current_source_hash.as_str(), + structured_hash.as_str(), + next_revision, + &structured_integrity, + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE, + Some(now), + None, + now, + ); + Ok(migration_snapshot(row, next_revision)) +} + +fn rollback_editor_canvas_layout( + ctx: &ReducerContext, + input: EditorCanvasLayoutMigrationInput, +) -> Result { + let project_id = normalize_required(&input.project_id, "editor_project.project_id")?; + let owner_user_id = normalize_required(&input.owner_user_id, "editor_project.owner_user_id")?; + let project = require_owned_project(ctx, project_id.as_str(), owner_user_id.as_str())?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let canvas = ctx + .db + .editor_canvas() + .canvas_id() + .find(&default_canvas_id(project_id.as_str())) + .ok_or_else(|| "图片画布尚未创建,不能回滚结构化布局".to_string())?; + let migration = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id) + .ok_or_else(|| "图片画布没有可回滚的结构化迁移记录".to_string())?; + if migration.status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK + && canvas.layout_storage_version == 0 + { + let structured_json = build_structured_canvas_layout_json(ctx, &canvas)?; + validate_legacy_layout_rollback_size(structured_json.as_str())?; + let structured_hash = canonical_layout_sha256(structured_json.as_str())?; + let structured_integrity = canvas_layout_integrity(structured_json.as_str())?; + verify_migration_layout( + &migration, + canvas.revision, + structured_hash.as_str(), + &structured_integrity, + )?; + let legacy_hash = canonical_layout_sha256(canvas.layers_json.as_str())?; + if legacy_hash != structured_hash { + return Err( + "已回滚图片画布的 legacy 布局与保留的结构化数据不一致,拒绝重复回滚".to_string(), + ); + } + return Ok(migration_snapshot(migration, canvas.revision)); + } + if migration.status != EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE + || canvas.layout_storage_version != EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED + { + return Err("图片画布当前不是已激活的结构化布局,拒绝回滚".to_string()); + } + let layers_json = build_structured_canvas_layout_json(ctx, &canvas)?; + validate_legacy_layout_rollback_size(layers_json.as_str())?; + let layout_hash = canonical_layout_sha256(layers_json.as_str())?; + let integrity = canvas_layout_integrity(layers_json.as_str())?; + verify_migration_layout( + &migration, + canvas.revision, + layout_hash.as_str(), + &integrity, + )?; + if input.dry_run { + return Ok(migration_snapshot(migration, canvas.revision)); + } + let next_revision = canvas.revision.saturating_add(1); + ctx.db.editor_canvas().canvas_id().delete(&canvas.canvas_id); + ctx.db.editor_canvas().insert(EditorCanvas { + layers_json: layers_json.clone(), + layout_storage_version: 0, + revision: next_revision, + updated_at: now, + ..canvas.clone() + }); + ctx.db.editor_project().project_id().delete(&project_id); + ctx.db.editor_project().insert(EditorProject { + viewport_x: canvas.viewport_x, + viewport_y: canvas.viewport_y, + viewport_scale: canvas.viewport_scale, + layers_json, + updated_at: now, + ..project + }); + let row = upsert_editor_canvas_layout_migration( + ctx, + &canvas, + migration.source_layout_sha256.as_str(), + layout_hash.as_str(), + next_revision, + &integrity, + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK, + migration.activated_at, + Some(now), + now, + ); + Ok(migration_snapshot(row, next_revision)) +} + +fn build_structured_canvas_layout_json_with_background( + ctx: &ReducerContext, + canvas_id: &str, + background_color: Option<&str>, +) -> Result { + let canvas = ctx + .db + .editor_canvas() + .canvas_id() + .find(&canvas_id.to_string()) + .ok_or_else(|| "图片画布不存在".to_string())?; + build_structured_canvas_layout_json( + ctx, + &EditorCanvas { + background_color: background_color.map(ToOwned::to_owned), + ..canvas + }, + ) +} + +fn validate_legacy_layout_rollback_size(layers_json: &str) -> Result<(), String> { + if layers_json.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES { + return Err("结构化画布重组后超过 2 MiB,拒绝回滚到旧布局;结构化数据未删除".to_string()); + } + Ok(()) +} + fn normalize_layout_json(value: String) -> Result { if value.len() > EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES { return Err("图片画布图层布局过大".to_string()); @@ -4843,6 +7746,46 @@ fn editor_project_layout_save_error(message: String) -> EditorProjectLayoutSaveP } } +fn editor_canvas_layout_migration_ok( + migration: EditorCanvasLayoutMigrationSnapshot, +) -> EditorCanvasLayoutMigrationProcedureResult { + EditorCanvasLayoutMigrationProcedureResult { + ok: true, + migration: Some(migration), + error_message: None, + } +} + +fn editor_canvas_layout_migration_error( + message: String, +) -> EditorCanvasLayoutMigrationProcedureResult { + EditorCanvasLayoutMigrationProcedureResult { + ok: false, + migration: None, + error_message: Some(message), + } +} + +fn editor_canvas_resource_repair_ok( + repair: EditorCanvasResourceRepairSnapshot, +) -> EditorCanvasResourceRepairProcedureResult { + EditorCanvasResourceRepairProcedureResult { + ok: true, + repair: Some(repair), + error_message: None, + } +} + +fn editor_canvas_resource_repair_error( + message: String, +) -> EditorCanvasResourceRepairProcedureResult { + EditorCanvasResourceRepairProcedureResult { + ok: false, + repair: None, + error_message: Some(message), + } +} + fn editor_asset_library_ok( library: EditorAssetLibrarySnapshot, ) -> EditorAssetLibraryProcedureResult { @@ -5021,6 +7964,1094 @@ mod tests { assert_eq!(error, "图片画布图层布局过大"); } + #[test] + fn editor_canvas_resource_repair_changes_only_expected_image_fields() { + let layout = json!([ + { + "layerId": "layer-target", + "resourceId": "resource-missing", + "sourceResourceId": "resource-source-old", + "prompt": "保留提示词", + "x": 12, + "custom": { "tone": "warm" } + }, + { + "layerId": "layer-other", + "resourceId": "resource-other", + "sourceResourceId": "resource-source-other", + "x": 20 + } + ]); + let remaps = vec![EditorCanvasResourceRemapInput { + layer_id: "layer-target".to_string(), + expected_missing_resource_id: "resource-missing".to_string(), + replacement_resource_id: "resource-existing".to_string(), + expected_layout_source_resource_id: "resource-source-old".to_string(), + }]; + + let patch = patch_editor_canvas_resource_layout( + layout.to_string().as_str(), + remaps.as_slice(), + &[], + false, + ) + .expect("repair patch should succeed"); + let repaired: JsonValue = + serde_json::from_str(&patch.layers_json).expect("repaired layout json"); + + assert_eq!(repaired[0]["resourceId"], json!("resource-existing")); + assert!(repaired[0].get("sourceResourceId").is_none()); + assert_eq!(repaired[0]["prompt"], json!("保留提示词")); + assert_eq!(repaired[0]["custom"]["tone"], json!("warm")); + assert_eq!(repaired[1], layout[1]); + assert_eq!(patch.matched_layer_count, 1); + assert_eq!(patch.remapped_layer_count, 1); + assert_eq!(patch.removed_source_resource_id_count, 1); + } + + #[test] + fn editor_canvas_resource_repair_requires_exact_source_resource_id() { + let layout = json!([{ + "layerId": "layer-target", + "resourceId": "resource-missing", + "sourceResourceId": "unexpected-source" + }]); + let remaps = vec![EditorCanvasResourceRemapInput { + layer_id: "layer-target".to_string(), + expected_missing_resource_id: "resource-missing".to_string(), + replacement_resource_id: "resource-existing".to_string(), + expected_layout_source_resource_id: "expected-source".to_string(), + }]; + + let error = patch_editor_canvas_resource_layout( + layout.to_string().as_str(), + remaps.as_slice(), + &[], + false, + ) + .expect_err("unexpected source resource must fail closed"); + + assert!(error.contains("sourceResourceId")); + } + + #[test] + fn editor_canvas_resource_repair_recognizes_only_exact_applied_state() { + let repaired = json!([{ + "layerId": "layer-target", + "resourceId": "resource-existing", + "prompt": "保留" + }]); + let remaps = vec![EditorCanvasResourceRemapInput { + layer_id: "layer-target".to_string(), + expected_missing_resource_id: "resource-missing".to_string(), + replacement_resource_id: "resource-existing".to_string(), + expected_layout_source_resource_id: "resource-source-old".to_string(), + }]; + + let patch = patch_editor_canvas_resource_layout( + repaired.to_string().as_str(), + remaps.as_slice(), + &[], + true, + ) + .expect("exact applied state should be idempotent"); + assert_eq!(patch.remapped_layer_count, 0); + assert_eq!(patch.removed_source_resource_id_count, 0); + + let unsafe_state = json!([{ + "layerId": "layer-target", + "resourceId": "resource-existing", + "sourceResourceId": "still-present" + }]); + assert!( + patch_editor_canvas_resource_layout( + unsafe_state.to_string().as_str(), + remaps.as_slice(), + &[], + true, + ) + .is_err() + ); + } + + #[test] + fn editor_canvas_resource_repair_rejects_duplicate_layer_ids() { + let layout = json!([ + { "layerId": "layer-target", "resourceId": "resource-missing" }, + { "layerId": "layer-target", "resourceId": "resource-other" } + ]); + let restores = vec![EditorCanvasMissingResourceRestoreInput { + layer_id: "layer-target".to_string(), + resource_id: "resource-missing".to_string(), + asset_object_id: "asset-object".to_string(), + asset_kind: "sound-effect".to_string(), + }]; + + let error = patch_editor_canvas_resource_layout( + layout.to_string().as_str(), + &[], + restores.as_slice(), + false, + ) + .expect_err("duplicate layer id must fail closed"); + + assert!(error.contains("重复 layerId")); + } + + #[test] + fn editor_canvas_resource_repair_requires_unique_missing_resource_reference() { + let layout = json!([ + { + "layerId": "layer-target", + "resourceId": "resource-missing", + "sourceResourceId": "source-old" + }, + { + "layerId": "layer-other", + "resourceId": "resource-missing" + } + ]); + let remaps = vec![EditorCanvasResourceRemapInput { + layer_id: "layer-target".to_string(), + expected_missing_resource_id: "resource-missing".to_string(), + replacement_resource_id: "resource-existing".to_string(), + expected_layout_source_resource_id: "source-old".to_string(), + }]; + + let error = patch_editor_canvas_resource_layout( + layout.to_string().as_str(), + remaps.as_slice(), + &[], + false, + ) + .expect_err("missing resource id reused by another layer must fail closed"); + + assert!(error.contains("引用数量")); + } + + #[test] + fn editor_canvas_resource_repair_uses_raw_hash_for_resource_metadata_guard() { + let left = + r#"[{"layerId":"layer-1","resourceId":"resource-1","sourceResourceId":"source-a"}]"#; + let right = + r#"[{"layerId":"layer-1","resourceId":"resource-1","sourceResourceId":"source-b"}]"#; + + assert_eq!( + canonical_layout_sha256(left).expect("left canonical hash"), + canonical_layout_sha256(right).expect("right canonical hash") + ); + assert_ne!(raw_layout_sha256(left), raw_layout_sha256(right)); + } + + #[test] + fn editor_canvas_audio_resource_kind_mapping_is_explicit() { + assert_eq!( + audio_asset_object_kind( + validate_editor_canvas_audio_asset_kind("sound-effect").expect("sound effect kind") + ), + "editor_sound_effect" + ); + assert_eq!( + audio_asset_object_kind( + validate_editor_canvas_audio_asset_kind("background-music") + .expect("background music kind") + ), + "editor_background_music" + ); + assert!(validate_editor_canvas_audio_asset_kind("audio").is_err()); + } + + #[test] + fn structured_canvas_layout_splits_layers_dialogs_and_settings() { + let layout = json!([ + { + "itemType": "canvas-settings", + "layerId": "canvas-settings:default", + "resourceId": "canvas-settings:default", + "canvasBackgroundColor": "#f8fafc" + }, + { + "itemType": "layer", + "layerId": "layer-1", + "resourceId": "resource-1", + "title": "角色", + "x": 12.5, + "y": 20, + "width": 512, + "height": 768, + "zIndex": 9, + "groupId": "group-1", + "hidden": true, + "flipX": true + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "generate", + "status": "generating", + "sourceLayerId": "layer-1", + "placeholder": { + "x": 1, + "y": 2, + "width": 3, + "height": 4 + } + } + } + ]); + + let parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("structured layout should parse"); + + assert_eq!(parsed.background_color.as_deref(), Some("#f8fafc")); + assert_eq!(parsed.layers.len(), 1); + assert_eq!(parsed.layers[0].layer_id, "layer-1"); + assert_eq!(parsed.layers[0].sort_order, 1); + assert_eq!(parsed.layers[0].x, Some(12.5)); + assert_eq!(parsed.layers[0].group_id.as_deref(), Some("group-1")); + assert!(parsed.layers[0].hidden); + assert!(parsed.layers[0].flip_x); + assert_eq!(parsed.dialogs.len(), 1); + assert_eq!(parsed.dialogs[0].dialog_id, "dialog-1"); + assert_eq!(parsed.dialogs[0].sort_order, 2); + assert_eq!( + parsed.dialogs[0].source_layer_id.as_deref(), + Some("layer-1") + ); + assert_eq!(parsed.dialogs[0].placeholder_width, Some(3.0)); + } + + #[test] + fn structured_canvas_source_hash_is_canonical() { + let compact = r#"[{"layerId":"layer-1","resourceId":"resource-1"}]"#; + let spaced = "[ { \"resourceId\": \"resource-1\", \"layerId\": \"layer-1\" } ]"; + + assert_eq!( + canonical_layout_sha256(compact).expect("compact hash"), + canonical_layout_sha256(spaced).expect("spaced hash") + ); + } + + #[test] + fn structured_canvas_snapshot_rebuilds_from_typed_columns() { + let layout = json!([ + { + "itemType": "layer", + "layerId": "layer-1", + "resourceId": "resource-1", + "x": 12, + "hidden": false, + "customExtension": { "tone": "warm" } + }, + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "dialog": { + "id": "dialog-1", + "mode": "generate", + "status": "idle", + "prompt": "角色" + } + } + ]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("structured layout should parse"); + assert!(!parsed.layers[0].item_json.contains("\"x\"")); + assert!(!parsed.dialogs[0].dialog_json.contains("\"status\"")); + + parsed.layers[0].x = Some(88.0); + parsed.layers[0].hidden = true; + parsed.dialogs[0].status = "generating".to_string(); + let rebuilt = serialize_structured_canvas_layout(&parsed).expect("layout should rebuild"); + let value: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt layout json"); + + assert_eq!(value[0]["x"], json!(88.0)); + assert_eq!(value[0]["hidden"], json!(true)); + assert_eq!(value[0]["customExtension"]["tone"], json!("warm")); + assert_eq!(value[1]["dialog"]["status"], json!("generating")); + assert_eq!(value[1]["dialog"]["prompt"], json!("角色")); + } + + #[test] + fn structured_canvas_dialog_extensions_rebuild_without_loss() { + let layout = json!([ + { + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "panelState": { "collapsed": true }, + "dialog": { + "id": "dialog-1", + "mode": "generate", + "status": "idle", + "prompt": "角色", + "placeholder": { + "x": 1, + "y": 2, + "width": 3, + "height": 4, + "originalWidth": 1024, + "originalHeight": 2048, + "anchor": "center" + } + } + } + ]); + + let parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("dialog extensions should parse"); + let rebuilt = serialize_structured_canvas_layout(&parsed).expect("dialog should rebuild"); + let value: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt layout json"); + + assert_eq!(value[0]["panelState"]["collapsed"], json!(true)); + assert_eq!(value[0]["dialog"]["prompt"], json!("角色")); + assert_eq!( + value[0]["dialog"]["placeholder"]["originalWidth"], + json!(1024) + ); + assert_eq!( + value[0]["dialog"]["placeholder"]["originalHeight"], + json!(2048) + ); + assert_eq!(value[0]["dialog"]["placeholder"]["anchor"], json!("center")); + assert_eq!( + canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), + canonical_layout_sha256(rebuilt.as_str()).expect("rebuilt hash") + ); + + let changed = rebuilt.replace("2048", "2049"); + assert_ne!( + canonical_layout_sha256(rebuilt.as_str()).expect("original hash"), + canonical_layout_sha256(changed.as_str()).expect("changed hash") + ); + } + + #[test] + fn structured_canvas_rejects_unknown_canvas_settings_fields() { + let layout = json!([ + { + "itemType": "canvas-settings", + "layerId": "canvas-settings:default", + "resourceId": "canvas-settings:default", + "canvasBackgroundColor": "#f8fafc", + "futureSetting": true + } + ]); + + let error = parse_structured_canvas_layout(layout.to_string().as_str()) + .err() + .expect("unknown canvas setting must fail closed"); + + assert!(error.contains("futureSetting")); + } + + #[test] + fn structured_canvas_canonical_hash_excludes_resource_table_metadata_only() { + let duplicated = json!([ + { + "itemType": "layer", + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "generated", + "prompt": "角色", + "actualPrompt": "角色,正面", + "model": "gpt-image-2", + "provider": "vector-engine", + "taskId": "task-1", + "objectKey": "generated/role.png", + "assetObjectId": "asset-object-1", + "customExtension": { "tone": "warm" } + } + ]); + let detached = json!([ + { + "itemType": "layer", + "layerId": "layer-1", + "resourceId": "resource-1", + "customExtension": { "tone": "warm" } + } + ]); + + assert_eq!( + canonical_layout_sha256(duplicated.to_string().as_str()).expect("duplicated hash"), + canonical_layout_sha256(detached.to_string().as_str()).expect("detached hash") + ); + + let changed_extension = json!([ + { + "itemType": "layer", + "layerId": "layer-1", + "resourceId": "resource-1", + "customExtension": { "tone": "cold" } + } + ]); + assert_ne!( + canonical_layout_sha256(detached.to_string().as_str()).expect("detached hash"), + canonical_layout_sha256(changed_extension.to_string().as_str()) + .expect("changed extension hash") + ); + } + + fn self_contained_local_sequence_layout() -> JsonValue { + json!([{ + "layerId": "local-sequence-layer", + "resourceId": "local-resource-sequence", + "title": "历史角色动作", + "x": 10, + "y": 20, + "width": 320, + "height": 320, + "originalWidth": 512, + "originalHeight": 512, + "zIndex": 2, + "sourceType": "generated", + "mediaType": "image-sequence", + "src": null, + "imageSrc": null, + "objectKey": null, + "assetObjectId": null, + "thumbnailSrc": "/generated/sequence/thumbnail.png", + "previewVideoPath": "/generated/sequence/preview.mp4", + "imageSequenceFrames": [{ + "frameIndex": 1, + "imageSrc": "/generated/sequence/frame01.png", + "width": 512, + "height": 512 + }], + "prompt": "角色向前走", + "actualPrompt": "角色向前走,透明背景", + "model": "legacy-animation", + "provider": "legacy-provider", + "taskId": "legacy-task", + "sourceResourceId": "deleted-source-resource", + "assetKind": "character-animation", + "generationInputs": { "action": "walk" } + }]) + } + + fn normalize_single_layer_without_resource(layout: &JsonValue) -> Result { + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str())?; + if parsed.layers.len() != 1 { + return Err("expected one layer".to_string()); + } + let normalized = + normalize_structured_canvas_layer_against_resource(&mut parsed.layers[0], None)?; + if normalized.is_some() { + return Err("local sequence unexpectedly produced resource metadata".to_string()); + } + serialize_structured_canvas_layout(&parsed) + .map_err(|error| format!("local sequence serialization failed: {error}")) + } + + #[test] + fn structured_canvas_self_contained_local_sequence_round_trips_and_hashes_all_metadata() { + let layout = self_contained_local_sequence_layout(); + let rebuilt = normalize_single_layer_without_resource(&layout) + .expect("valid self-contained local sequence should normalize"); + assert_eq!( + canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), + canonical_layout_sha256(rebuilt.as_str()).expect("rebuilt hash") + ); + + let mut redundant_first_frame_src = layout.clone(); + redundant_first_frame_src[0]["src"] = json!("/generated/sequence/frame01.png"); + normalize_single_layer_without_resource(&redundant_first_frame_src) + .expect("stable src equal to the first sequence frame should normalize"); + + for (pointer, changed_value) in [ + ("/0/prompt", "changed"), + ( + "/0/imageSequenceFrames/0/imageSrc", + "/generated/sequence/frame01-changed.png", + ), + ( + "/0/thumbnailSrc", + "/generated/sequence/thumbnail-changed.png", + ), + ( + "/0/previewVideoPath", + "/generated/sequence/preview-changed.mp4", + ), + ("/0/assetKind", "changed"), + ("/0/generationInputs/action", "changed"), + ] { + let mut changed = layout.clone(); + *changed + .pointer_mut(pointer) + .expect("test pointer should exist") = JsonValue::String(changed_value.to_string()); + assert_ne!( + canonical_layout_sha256(layout.to_string().as_str()).expect("source hash"), + canonical_layout_sha256(changed.to_string().as_str()).expect("changed hash"), + "{pointer} must participate in the local sequence hash" + ); + } + } + + #[test] + fn structured_canvas_self_contained_local_sequence_rejects_unstable_or_incomplete_media() { + let base = self_contained_local_sequence_layout(); + let mut invalid_layouts = Vec::new(); + for invalid_path in [ + "relative/frame.png", + "//cdn.example/frame.png", + "https://example.test/frame.png", + "/generated/frame.png?signature=secret", + "/generated/frame.png#fragment", + "data:image/png;base64,AAAA", + "blob:browser-only", + "/generated/../private/frame.png", + ] { + let mut invalid = base.clone(); + invalid[0]["imageSequenceFrames"][0]["imageSrc"] = + JsonValue::String(invalid_path.to_string()); + invalid_layouts.push(invalid); + } + for (pointer, value) in [ + ("/0/resourceId", json!("resource-not-local")), + ("/0/sourceType", json!("uploaded")), + ("/0/mediaType", json!("image")), + ("/0/imageSequenceFrames", json!([])), + ("/0/imageSequenceFrames/0/frameIndex", json!(0)), + ("/0/imageSequenceFrames/0/width", json!(0)), + ("/0/imageSequenceFrames/0/height", json!(-1)), + ("/0/src", json!("/generated/sequence/other.png")), + ("/0/objectKey", json!("generated/sequence/frame01.png")), + ("/0/thumbnailSrc", json!("https://example.test/thumb.png")), + ("/0/previewVideoPath", json!("/preview.mp4?expires=1")), + ] { + let mut invalid = base.clone(); + *invalid + .pointer_mut(pointer) + .expect("test pointer should exist") = value; + invalid_layouts.push(invalid); + } + let mut mismatched_frame_key = base.clone(); + mismatched_frame_key[0]["imageSequenceFrames"][0]["objectKey"] = + json!("generated/sequence/other.png"); + invalid_layouts.push(mismatched_frame_key); + let mut repeated_frame_index = base.clone(); + let repeated_frame = repeated_frame_index[0]["imageSequenceFrames"][0].clone(); + repeated_frame_index[0]["imageSequenceFrames"] = + JsonValue::Array(vec![repeated_frame.clone(), repeated_frame]); + invalid_layouts.push(repeated_frame_index); + + for invalid in invalid_layouts { + normalize_single_layer_without_resource(&invalid) + .expect_err("invalid local sequence must fail closed"); + } + } + + #[test] + fn structured_canvas_resource_row_conflicts_remain_fail_closed() { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + let resource = EditorProjectResource { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/generated/resource-1.png".to_string(), + object_key: Some("generated/resource-1.png".to_string()), + width: 512, + height: 512, + source_type: "generated".to_string(), + prompt: Some("角色".to_string()), + actual_prompt: None, + model: Some("model-1".to_string()), + provider: Some("provider-1".to_string()), + task_id: Some("task-1".to_string()), + source_resource_id: Some("source-correct".to_string()), + created_at: now, + updated_at: now, + asset_kind: None, + generation_inputs_json: None, + public_showcase_enabled: true, + }; + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "sourceType": "generated", + "src": "/generated/resource-1.png", + "prompt": "角色", + "model": "model-1", + "provider": "provider-1", + "taskId": "task-1", + "objectKey": "generated/resource-1.png", + "assetObjectId": "asset-object-1", + "sourceResourceId": "source-conflicting" + }]); + let mut parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("resource-backed layout should parse"); + let error = normalize_structured_canvas_layer_against_resource( + &mut parsed.layers[0], + Some(&resource), + ) + .expect_err("sourceResourceId conflict must fail closed"); + assert!(error.contains("sourceResourceId")); + + let mut self_reference_layout = layout.clone(); + self_reference_layout[0]["sourceResourceId"] = json!("resource-1"); + let mut self_reference = + parse_structured_canvas_layout(self_reference_layout.to_string().as_str()) + .expect("self-referencing layout should parse"); + normalize_structured_canvas_layer_against_resource( + &mut self_reference.layers[0], + Some(&resource), + ) + .expect("legacy self-reference should defer to resource truth"); + assert!( + !self_reference.layers[0] + .item_json + .contains("sourceResourceId") + ); + + let mut local = parse_structured_canvas_layout( + self_contained_local_sequence_layout().to_string().as_str(), + ) + .expect("local sequence should parse"); + let error = normalize_structured_canvas_layer_against_resource( + &mut local.layers[0], + Some(&resource), + ) + .expect_err("local sequence with a resource row must be ambiguous"); + assert!(error.contains("权威元数据")); + + let resource_without_source = EditorProjectResource { + source_resource_id: None, + ..resource + }; + let mut self_reference = + parse_structured_canvas_layout(self_reference_layout.to_string().as_str()) + .expect("self-referencing layout should parse"); + normalize_structured_canvas_layer_against_resource( + &mut self_reference.layers[0], + Some(&resource_without_source), + ) + .expect("legacy self-reference should also be ignored when resource truth is empty"); + } + + #[test] + fn structured_canvas_existing_local_sequence_only_allows_typed_layout_changes() { + let layout = self_contained_local_sequence_layout(); + let parsed = parse_structured_canvas_layout(layout.to_string().as_str()) + .expect("local sequence should parse"); + let existing_source_type = parsed.layers[0].source_type.clone(); + let existing_item_json = parsed.layers[0].item_json.clone(); + + let mut moved = parsed.layers[0].clone(); + moved.x = Some(999.0); + moved.y = Some(888.0); + assert!(legacy_local_sequence_extension_matches_existing_layer( + existing_source_type.as_deref(), + existing_item_json.as_str(), + &moved, + )); + + let mut omitted_frontend_metadata = moved.clone(); + let mut item = serde_json::from_str::>( + omitted_frontend_metadata.item_json.as_str(), + ) + .expect("local item extension should parse"); + item.remove("assetKind"); + item.remove("generationInputs"); + omitted_frontend_metadata.item_json = + serde_json::to_string(&item).expect("item should serialize"); + restore_omitted_legacy_local_sequence_metadata( + existing_item_json.as_str(), + &mut omitted_frontend_metadata, + ) + .expect("frontend-omitted frozen metadata should restore"); + assert!(legacy_local_sequence_extension_matches_existing_layer( + existing_source_type.as_deref(), + existing_item_json.as_str(), + &omitted_frontend_metadata, + )); + + let mut changed_media = moved; + let mut item = + serde_json::from_str::>(changed_media.item_json.as_str()) + .expect("local item extension should parse"); + item.insert("prompt".to_string(), json!("篡改后的提示词")); + changed_media.item_json = serde_json::to_string(&item).expect("item should serialize"); + assert!(!legacy_local_sequence_extension_matches_existing_layer( + existing_source_type.as_deref(), + existing_item_json.as_str(), + &changed_media, + )); + } + + fn release_fixture_option_string(value: &JsonValue) -> Result, ()> { + let value = value.as_array().ok_or(())?; + if value.len() != 2 { + return Err(()); + } + match value[0].as_u64() { + Some(0) => value[1] + .as_str() + .map(|value| Some(value.to_string())) + .ok_or(()), + Some(1) if value[1].as_array().is_some_and(Vec::is_empty) => Ok(None), + _ => Err(()), + } + } + + fn release_fixture_resource(value: &JsonValue) -> Result { + let row = value.as_array().filter(|row| row.len() == 15).ok_or(())?; + let required = |index: usize| { + row.get(index) + .and_then(JsonValue::as_str) + .map(ToOwned::to_owned) + .ok_or(()) + }; + let optional = |index: usize| { + row.get(index) + .ok_or(()) + .and_then(release_fixture_option_string) + }; + let now = Timestamp::from_micros_since_unix_epoch(0); + Ok(EditorProjectResource { + resource_id: required(0)?, + project_id: required(1)?, + owner_user_id: required(2)?, + asset_object_id: optional(3)?, + image_src: required(4)?, + object_key: optional(5)?, + width: 1, + height: 1, + source_type: required(6)?, + prompt: optional(7)?, + actual_prompt: optional(8)?, + model: optional(9)?, + provider: optional(10)?, + task_id: optional(11)?, + source_resource_id: optional(12)?, + created_at: now, + updated_at: now, + asset_kind: optional(13)?, + generation_inputs_json: optional(14)?, + public_showcase_enabled: true, + }) + } + + #[test] + #[ignore = "requires anonymous read-only release canvas fixtures outside the repository"] + fn structured_canvas_release_fixture_round_trip() { + use std::fs; + use std::path::PathBuf; + + let fixture_dir = std::env::var("GENARRATIVE_RELEASE_CANVAS_FIXTURE_DIR") + .map(PathBuf::from) + .expect("GENARRATIVE_RELEASE_CANVAS_FIXTURE_DIR is required"); + let expected_ok = std::env::var("GENARRATIVE_EXPECTED_RESOURCE_OK") + .expect("GENARRATIVE_EXPECTED_RESOURCE_OK is required") + .parse::() + .expect("GENARRATIVE_EXPECTED_RESOURCE_OK must be an integer"); + let expected_conflicts = std::env::var("GENARRATIVE_EXPECTED_SOURCE_RESOURCE_CONFLICTS") + .expect("GENARRATIVE_EXPECTED_SOURCE_RESOURCE_CONFLICTS is required") + .parse::() + .expect("GENARRATIVE_EXPECTED_SOURCE_RESOURCE_CONFLICTS must be an integer"); + let expected_local_sequences = std::env::var("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES") + .expect("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES is required") + .parse::() + .expect("GENARRATIVE_EXPECTED_LOCAL_SEQUENCES must be an integer"); + + let mut layout_paths = fs::read_dir(&fixture_dir) + .expect("fixture directory should be readable") + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".layout.json")) + }) + .collect::>(); + layout_paths.sort(); + assert_eq!( + layout_paths.len(), + expected_ok + expected_conflicts, + "external fixture sample count should match expectations" + ); + + let mut ok_count = 0usize; + let mut conflict_count = 0usize; + let mut local_sequence_count = 0usize; + for layout_path in layout_paths { + let sample_name = layout_path + .file_name() + .and_then(|name| name.to_str()) + .and_then(|name| name.strip_suffix(".layout.json")) + .filter(|name| { + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_hexdigit() || byte == b'-') + }) + .expect("fixture basename must be an anonymous hash"); + let resources_path = fixture_dir.join(format!("{sample_name}.resources.json")); + let source = fs::read_to_string(&layout_path) + .unwrap_or_else(|_| panic!("fixture {sample_name} layout is unreadable")); + let source = normalize_layout_json(source) + .unwrap_or_else(|_| panic!("fixture {sample_name} layout is invalid")); + let source_hash = canonical_layout_sha256(source.as_str()) + .unwrap_or_else(|_| panic!("fixture {sample_name} canonical hash failed")); + let source_integrity = canvas_layout_integrity(source.as_str()) + .unwrap_or_else(|_| panic!("fixture {sample_name} integrity failed")); + let mut layout = parse_structured_canvas_layout(source.as_str()) + .unwrap_or_else(|_| panic!("fixture {sample_name} structured parse failed")); + let raw_rebuilt = serialize_structured_canvas_layout(&layout) + .unwrap_or_else(|_| panic!("fixture {sample_name} raw rebuild failed")); + assert_eq!( + canonical_layout_sha256(raw_rebuilt.as_str()).ok(), + Some(source_hash.clone()), + "fixture {sample_name} raw canonical round-trip drifted" + ); + assert_eq!( + canvas_layout_integrity(raw_rebuilt.as_str()).ok(), + Some(source_integrity.clone()), + "fixture {sample_name} raw integrity drifted" + ); + + for layer in &layout.layers { + let item = serde_json::from_str::>(&layer.item_json) + .unwrap_or_else(|_| panic!("fixture {sample_name} item extension is invalid")); + match validate_self_contained_legacy_local_image_sequence( + layer.resource_id.as_str(), + layer.source_type.as_deref(), + &item, + ) { + Ok(true) => local_sequence_count += 1, + Ok(false) => {} + Err(_) => panic!("fixture {sample_name} local sequence validation failed"), + } + } + + let resources = fs::read_to_string(resources_path) + .unwrap_or_else(|_| panic!("fixture {sample_name} resources are unreadable")); + let resources = serde_json::from_str::>(&resources) + .unwrap_or_else(|_| panic!("fixture {sample_name} resources are invalid")); + let mut resources_by_id = BTreeMap::new(); + for value in &resources { + let resource = release_fixture_resource(value) + .unwrap_or_else(|_| panic!("fixture {sample_name} resource shape is invalid")); + if resources_by_id + .insert(resource.resource_id.clone(), resource) + .is_some() + { + panic!("fixture {sample_name} contains duplicate resource ids"); + } + } + + let mut metadata_updates = BTreeMap::, Option)>::new(); + let mut source_resource_conflict = false; + for layer in &mut layout.layers { + let resource = resources_by_id.get(&layer.resource_id); + match normalize_structured_canvas_layer_against_resource(layer, resource) { + Ok(None) => {} + Ok(Some((layout_asset_kind, layout_generation_inputs))) => { + let resource = resource.expect("normalized resource should exist"); + let update = metadata_updates + .entry(resource.resource_id.clone()) + .or_insert_with(|| { + ( + resource.asset_kind.clone(), + resource.generation_inputs_json.clone(), + ) + }); + if merge_optional_resource_metadata( + &mut update.0, + layout_asset_kind, + "assetKind", + "fixture-layer", + ) + .is_err() + || merge_optional_resource_json_metadata( + &mut update.1, + layout_generation_inputs, + "generationInputs", + "fixture-layer", + ) + .is_err() + { + panic!("fixture {sample_name} resource metadata merge failed"); + } + } + Err(error) if error.contains("sourceResourceId") => { + source_resource_conflict = true; + break; + } + Err(_) => panic!( + "fixture {sample_name} resource validation failed outside the expected category" + ), + } + } + if source_resource_conflict { + conflict_count += 1; + continue; + } + + let normalized = serialize_structured_canvas_layout(&layout) + .unwrap_or_else(|_| panic!("fixture {sample_name} normalized rebuild failed")); + assert_eq!( + canonical_layout_sha256(normalized.as_str()).ok(), + Some(source_hash), + "fixture {sample_name} normalized canonical round-trip drifted" + ); + assert_eq!( + canvas_layout_integrity(normalized.as_str()).ok(), + Some(source_integrity), + "fixture {sample_name} normalized integrity drifted" + ); + assert!(layout.layers.iter().all(|layer| { + layer.item_json.len() <= EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES + })); + assert!(layout.dialogs.iter().all(|dialog| { + dialog.dialog_json.len() <= EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES + })); + ok_count += 1; + } + + assert_eq!(ok_count, expected_ok); + assert_eq!(conflict_count, expected_conflicts); + assert_eq!(local_sequence_count, expected_local_sequences); + } + + #[test] + fn structured_canvas_resource_metadata_detach_is_fail_closed() { + let mut matching = json!({ + "prompt": "角色", + "objectKey": null, + "src": "/generated/role.png", + "customExtension": true + }) + .as_object() + .expect("metadata object") + .clone(); + + take_matching_optional_resource_string(&mut matching, "prompt", Some("角色"), "layer-1") + .expect("matching prompt should detach"); + take_matching_optional_resource_string( + &mut matching, + "objectKey", + Some("generated/role.png"), + "layer-1", + ) + .expect("null override should defer to resource truth"); + take_matching_required_resource_string( + &mut matching, + "src", + "/generated/role.png", + "layer-1", + ) + .expect("matching src should detach"); + assert_eq!( + JsonValue::Object(matching), + json!({ "customExtension": true }) + ); + + let mut conflicting = json!({ "provider": "legacy-provider" }) + .as_object() + .expect("metadata object") + .clone(); + let error = take_matching_optional_resource_string( + &mut conflicting, + "provider", + Some("vector-engine"), + "layer-1", + ) + .expect_err("conflicting provider must fail closed"); + assert!(error.contains("provider")); + + let mut generation_inputs = Some(r#"{ "style": "clay", "seed": 7 }"#.to_string()); + merge_optional_resource_json_metadata( + &mut generation_inputs, + Some(r#"{"seed":7,"style":"clay"}"#.to_string()), + "generationInputs", + "layer-1", + ) + .expect("JSON object key order must not create a metadata conflict"); + } + + #[test] + fn structured_canvas_migration_verification_checks_every_guard() { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + let integrity = CanvasLayoutIntegrity { + layer_count: 2, + dialog_count: 1, + resource_refs_sha256: "refs".to_string(), + }; + let migration = EditorCanvasLayoutMigration { + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + source_layout_sha256: "source".to_string(), + structured_layout_sha256: "layout".to_string(), + verified_revision: 7, + layer_count: integrity.layer_count, + dialog_count: integrity.dialog_count, + resource_refs_sha256: integrity.resource_refs_sha256.clone(), + migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + status: EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK.to_string(), + migrated_at: now, + activated_at: Some(now), + rolled_back_at: Some(now), + updated_at: now, + }; + + verify_migration_layout(&migration, 7, "layout", &integrity) + .expect("matching rollback state should verify"); + assert!(verify_migration_layout(&migration, 8, "layout", &integrity).is_err()); + assert!(verify_migration_layout(&migration, 7, "drift", &integrity).is_err()); + assert!( + verify_migration_layout( + &migration, + 7, + "layout", + &CanvasLayoutIntegrity { + layer_count: 3, + ..integrity.clone() + } + ) + .is_err() + ); + assert!( + verify_migration_layout( + &migration, + 7, + "layout", + &CanvasLayoutIntegrity { + resource_refs_sha256: "other-refs".to_string(), + ..integrity + } + ) + .is_err() + ); + } + + #[test] + fn structured_canvas_hash_normalizes_explicit_false_flags() { + let explicit_false = r#"[{"layerId":"layer-1","resourceId":"resource-1","hidden":false}]"#; + let omitted = r#"[{"layerId":"layer-1","resourceId":"resource-1"}]"#; + + assert_eq!( + canonical_layout_sha256(explicit_false).expect("explicit false hash"), + canonical_layout_sha256(omitted).expect("omitted flag hash") + ); + } + + #[test] + fn structured_canvas_rollback_rejects_layout_over_two_mib() { + let oversized = "x".repeat(EDITOR_PROJECT_MAX_LAYOUT_JSON_BYTES + 1); + + let error = validate_legacy_layout_rollback_size(oversized.as_str()) + .expect_err("oversized rollback should fail"); + + assert!(error.contains("拒绝回滚")); + assert!(error.contains("结构化数据未删除")); + } + fn public_showcase_asset() -> EditorShowcaseAsset { let now = Timestamp::from_micros_since_unix_epoch(1_000_000); EditorShowcaseAsset { diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index f5be3b9c1..07f9e0da8 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -251,6 +251,9 @@ macro_rules! migration_tables { editor_agent_conversation, editor_project, editor_canvas, + editor_canvas_layer, + editor_canvas_generation_dialog, + editor_canvas_layout_migration, editor_project_resource, editor_asset_folder, editor_asset, @@ -1364,6 +1367,20 @@ fn normalize_migration_row(table_name: &str, value: &serde_json::Value) -> serde .or_insert(serde_json::Value::Null); } } + if table_name == "editor_canvas" { + if let Some(object) = next_value.as_object_mut() { + // 中文注释:结构化画布上线前的迁移包仍以 layers_json 为唯一真相。 + object + .entry("revision".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("layout_storage_version".to_string()) + .or_insert_with(|| serde_json::Value::from(0)); + object + .entry("background_color".to_string()) + .or_insert(serde_json::Value::Null); + } + } if table_name == "editor_showcase_campaign_config" { if let Some(object) = next_value.as_object_mut() { // 中文注释:活动卡图片 Object Key 晚于活动卡表加入,旧迁移包按仅有图片地址兼容。 @@ -1681,6 +1698,18 @@ mod migration_bootstrap_secret_tests { assert_eq!(normalized["warning_message"], serde_json::Value::Null); } + #[test] + fn old_editor_canvas_rows_default_to_legacy_storage() { + let normalized = normalize_migration_row( + "editor_canvas", + &serde_json::json!({ "canvas_id": "canvas-1", "layers_json": "[]" }), + ); + + assert_eq!(normalized["revision"], serde_json::json!(0)); + assert_eq!(normalized["layout_storage_version"], serde_json::json!(0)); + assert_eq!(normalized["background_color"], serde_json::Value::Null); + } + #[test] fn bootstrap_secret_sha256_is_stable_and_does_not_contain_plaintext() { let secret = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index 5ea1a2fa9..c433c4597 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -521,6 +521,269 @@ describe('useImageCanvasProjectPersistence', () => { }); }); + it('reloads the authoritative snapshot after a revision conflict without replaying the stale 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-19T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-19T00:00:00.000Z', + }); + saveEditorProjectLayoutMock.mockRejectedValueOnce( + new ApiClientError({ + message: '图片画布版本冲突', + status: 409, + code: 'conflict', + }), + ); + loadEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '其它标签页已保存', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 12, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-remote', + resourceId: 'resource-remote', + x: 20, + y: 30, + }, + ], + revision: 4, + layoutStorageVersion: 1, + updatedAt: '2026-07-19T00:00:01.000Z', + }, + viewport: { x: 12, 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-19T00:00:01.000Z', + }); + + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + act(() => { + screen.getByRole('button', { name: 'append server resource' }).click(); + }); + + await waitFor(() => { + expect(loadEditorProjectMock).toHaveBeenCalledWith( + 'editor-project-default', + ); + }); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ expectedRevision: 3 }), + ); + }); + + it('continues with the latest pending layout when an in-flight save fails', async () => { + const firstSave = createDeferred(); + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + saveEditorProjectLayoutMock.mockClear(); + saveEditorProjectLayoutMock + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValue({ + projectId: 'editor-project-default', + title: '空画布项目', + viewport: { x: 20, y: 10, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-20T00:00:00.000Z', + }); + vi.useFakeTimers(); + + try { + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 1, + 'editor-project-default', + expect.objectContaining({ + viewport: { x: 10, y: 5, scale: 1 }, + }), + ); + + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + firstSave.reject(new TypeError('connection dropped')); + await Promise.resolve(); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 2, + 'editor-project-default', + expect.objectContaining({ + viewport: { x: 20, y: 10, scale: 1 }, + }), + ); + } finally { + vi.useRealTimers(); + } + }); + + it('retries conflict recovery when the authoritative snapshot reload fails', 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-20T00:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-20T00:00:00.000Z', + }); + const conflict = new ApiClientError({ + message: '图片画布版本冲突', + status: 409, + code: 'conflict', + }); + saveEditorProjectLayoutMock + .mockRejectedValueOnce(conflict) + .mockRejectedValueOnce(conflict); + loadEditorProjectMock + .mockRejectedValueOnce(new TypeError('connection dropped')) + .mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '其它标签页已保存', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 12, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 1, + updatedAt: '2026-07-20T00:00:01.000Z', + }, + viewport: { x: 12, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-20T00:00:01.000Z', + }); + + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + vi.useFakeTimers(); + + try { + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + await act(async () => { + vi.advanceTimersByTime(451); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(1_001); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); + expect(screen.getByTestId('project-title').textContent).toBe( + '其它标签页已保存', + ); + } finally { + vi.useRealTimers(); + } + }); + + it('does not retry a permanent layout payload error', async () => { + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + saveEditorProjectLayoutMock.mockClear(); + saveEditorProjectLayoutMock.mockRejectedValue( + new ApiClientError({ + message: '图片画布图层布局过大', + status: 413, + code: 'payload_too_large', + }), + ); + vi.useFakeTimers(); + + try { + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + await act(async () => { + vi.advanceTimersByTime(451); + await Promise.resolve(); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + vi.advanceTimersByTime(30_000); + await Promise.resolve(); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + it('writes a lightweight session cache when saving layout changes', async () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index f956d5e6c..00148dda5 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -54,6 +54,7 @@ type PendingProjectResourceLayer = { type PendingProjectLayoutSave = { projectId: string; input: Parameters[1]; + transportRetries?: number; }; type CachedEditorProjectSnapshot = { @@ -109,6 +110,21 @@ function isEditorProjectAccessError(error: unknown) { ); } +function isEditorProjectRevisionConflict(error: unknown) { + return error instanceof ApiClientError && error.status === 409; +} + +const EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES = new Set([ + 408, 425, 429, 502, 503, 504, +]); + +function isRetryableEditorProjectLayoutSaveError(error: unknown) { + return ( + !(error instanceof ApiClientError) || + EDITOR_PROJECT_LAYOUT_RETRYABLE_STATUS_CODES.has(error.status) + ); +} + function isLocalProjectResourceId(resourceId: string) { return resourceId.startsWith('local-'); } @@ -305,6 +321,10 @@ export function useImageCanvasProjectPersistence({ onProjectAccessLost, }: ImageCanvasProjectPersistenceOptions) { const projectIdRef = useRef(null); + const projectRevisionRef = useRef(null); + const applyProjectSnapshotRef = useRef< + ((project: EditorProjectSnapshot) => void) | null + >(null); const pendingProjectResourceLayersRef = useRef( [], ); @@ -350,17 +370,76 @@ export function useImageCanvasProjectPersistence({ pendingProjectLayoutSaveRef.current = null; isProjectLayoutSaveRunningRef.current = true; - Promise.resolve( - saveEditorProjectLayout(pendingSave.projectId, pendingSave.input), - ) - .catch((error: unknown) => { + let runNextSave = false; + const expectedRevision = projectRevisionRef.current; + void saveEditorProjectLayout(pendingSave.projectId, { + ...pendingSave.input, + ...(expectedRevision === null ? {} : { expectedRevision }), + }) + .then((result) => { + if (result && typeof result.revision === 'number') { + projectRevisionRef.current = Math.max( + projectRevisionRef.current ?? 0, + result.revision, + ); + } + runNextSave = Boolean(pendingProjectLayoutSaveRef.current); + }) + .catch(async (error: unknown) => { if (isEditorAuthError(error)) { openEditorLoginModal(); + return; + } + if (isEditorProjectRevisionConflict(error)) { + try { + const latestProject = await loadEditorProject(pendingSave.projectId); + writeEditorProjectSessionCache(latestProject); + applyProjectSnapshotRef.current?.(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); + } + } + return; + } + if (pendingProjectLayoutSaveRef.current) { + runNextSave = true; + return; + } + if (!isRetryableEditorProjectLayoutSaveError(error)) { + return; + } + const transportRetries = pendingSave.transportRetries ?? 0; + if (transportRetries < 3) { + pendingProjectLayoutSaveRef.current = { + ...pendingSave, + transportRetries: transportRetries + 1, + }; + if (saveTimerRef.current === null) { + saveTimerRef.current = window.setTimeout( + () => { + saveTimerRef.current = null; + runPendingProjectLayoutSave(); + }, + Math.min(1_000 * 2 ** transportRetries, 8_000), + ); + } } }) .finally(() => { isProjectLayoutSaveRunningRef.current = false; if ( + runNextSave && pendingProjectLayoutSaveRef.current && saveTimerRef.current === null ) { @@ -589,6 +668,7 @@ export function useImageCanvasProjectPersistence({ coverSnapshotUploadRequestRef.current += 1; } projectIdRef.current = project.projectId; + projectRevisionRef.current = project.canvas?.revision ?? null; setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; projectTitleRef.current = nextProjectTitle; @@ -652,6 +732,7 @@ export function useImageCanvasProjectPersistence({ setViewport, ], ); + applyProjectSnapshotRef.current = applyProjectSnapshot; const appendCanvasLayersWithResources = useCallback( (nextLayers: CanvasLayer[]) => { diff --git a/src/services/image-editor/editorProjectClient.test.ts b/src/services/image-editor/editorProjectClient.test.ts index 71ba9169c..a21136fe5 100644 --- a/src/services/image-editor/editorProjectClient.test.ts +++ b/src/services/image-editor/editorProjectClient.test.ts @@ -157,17 +157,20 @@ describe('editorProjectClient', () => { requestJsonMock.mockResolvedValueOnce({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', + revision: 8, updatedAt: '2026-06-12T00:00:00.000Z', }); const result = await saveEditorProjectLayout('editor-project-1', { viewport: { x: 12, y: 24, scale: 0.5 }, layers: [{ layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }], + expectedRevision: 7, }); expect(result).toEqual({ projectId: 'editor-project-1', canvasId: 'editor-project-1:canvas:default', + revision: 8, updatedAt: '2026-06-12T00:00:00.000Z', }); expect(requestJsonMock).toHaveBeenCalledWith( @@ -180,6 +183,7 @@ describe('editorProjectClient', () => { layers: [ { layerId: 'layer-1', resourceId: 'resource-1', x: 10, y: 20 }, ], + expectedRevision: 7, }), }), '保存图片画布工程失败', diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 93a5c90ed..41a0b0c87 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -541,6 +541,9 @@ export type EditorCanvasSnapshot = { title: string; viewport: EditorCanvasViewport; layers: EditorProjectLayerSnapshot[]; + revision?: number; + layoutStorageVersion?: number; + backgroundColor?: string | null; createdAt?: string; updatedAt: string; }; @@ -552,11 +555,13 @@ export type EditorProjectCreateInput = { export type EditorProjectLayoutSaveInput = { viewport: EditorCanvasViewport; layers: EditorProjectLayerSnapshot[]; + expectedRevision?: number; }; export type EditorProjectLayoutSaveResult = { projectId: string; canvasId: string; + revision?: number; updatedAt: string; }; @@ -781,6 +786,7 @@ export async function saveEditorProjectLayout( jsonRequest('PATCH', { viewport: input.viewport, layers: input.layers, + expectedRevision: input.expectedRevision, }), '保存图片画布工程失败', );