From 3dccce7dd3feb98e8c7a13938d0e2d24ad68558c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Tue, 11 Aug 2026 10:52:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=99=AE=E9=80=9A=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=BF=81=E7=A7=BB=E6=97=A7=E5=87=AD=E8=AF=81=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 清理前按 active、backfilled、rolled_back 状态验证旧迁移凭证,写后复核新状态再受控重签。 项目资源清零同步维护受影响迁移摘要,并覆盖无显式画布字段的 rolled_back 状态。 legacy 画布保存与项目资源落表统一归一精确 image,补齐状态机回归及运维契约。 --- .../shared-memory/decision-log.md | 3 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../src/editor_project_storage.rs | 1139 +++++++++++++++-- 4 files changed, 1039 insertions(+), 107 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ef8e1e021..4f50bff35 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6921,4 +6921,5 @@ ## 2026-08-10 普通图片废弃 assetKind 的迁移与写入门禁 - 语义:普通静态图片的持久化 `asset_kind` 为 `NULL`,旧值 `image` 不再是合法业务类型。登录态 API 和 SpacetimeDB storage 的项目资源 / 账号素材创建边界统一将 trim 后精确等于 `image` 的输入归一为空;其它值不借本次迁移扩大或收紧既有校验。 -- 迁移:生产维护窗口内按 `asset → project-resource → showcase → canvas` 执行受 operator 鉴权、分批 dry-run、批次 SHA-256 绑定和 apply 后零残留复核的清理。active 结构化画布同步刷新 legacy / structured hash、完整性和 verified revision,但不推进业务 revision、不改 migration 状态或时间戳;缺失一致 active 迁移记录时整批失败关闭。 +- 迁移:生产维护窗口内按 `asset → project-resource → showcase → canvas` 执行受 operator 鉴权、分批 dry-run、批次 SHA-256 绑定和 apply 后零残留复核的清理。project-resource 清行前验证同工程迁移并把状态纳入批次 hash;清行后能保持原 status 不变量时立即重签,从而覆盖 rolled_back 布局没有显式字段、摘要却依赖资源旧值的情况,仍需显式字段清理的过渡态保留旧凭证。canvas 在写入前再按 active / backfilled / rolled_back 原状态验证旧凭证、双份 legacy shadow、structured revision/hash/integrity 与对应语义等价 / 重入不变量;只有旧凭证有效且差异仅来自受控资源清零及精确 `image` 删除时才写入,写后再用同一状态守卫验证并更新摘要。该过程不推进业务 revision、不改 migration 状态或任何时间戳。旧 migration 摘要中的 `image` 通过清理专用兼容 canonicalizer 复算,正常保存白名单仍拒绝该值。 +- 持久化边界:除项目资源 / 账号素材创建入口外,legacy 画布保存从 layer 提取 `assetKind` 时必须区分“字段不存在”和“字段存在但归一为 `NULL`”;后者仍删除布局字段并触发持久化。项目资源 metadata 落表前再次归一 stored / incoming 值,防止 legacy V2 保存重新写回 `image`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 8af6317fd..fa3c7cfb3 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -680,7 +680,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - 源码:`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` 是跨布局共享的资源默认素材类型;单个结构化图层的差异只写 `editor_canvas_layer.asset_kind_override`,有效类型按 `override ?? resource default` 计算,不能通过新增资源行模拟标签修改。`image_sequence_frames_json` 与 `image_sequence_duration_ms` 保存角色动作正式结果,前者数组顺序是唯一帧序;角色动作预览视频是独立视频资源,不在最终序列资源行重复保存路径。“动作(原始视频)”只作为 `asset_kind = video` 的 provider 中间产物保存,其项目资源与账号素材都必须保持 `generation_inputs_json = NULL`;完整生成 / 重放输入只属于最终 `asset_kind = character-animation` 的序列资源和素材。`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` 兼容读取;唯一缺资源例外是经过稳定站内路径校验的历史 `local-* + generated + image-sequence` 自包含图层,active 后只能续存同一不可变扩展。新写入不再把素材生成输入快照或正式序列帧结果作为图层布局真相保存。历史普通图层缺资源只能由 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。 - 生成链路的新 resource ID 必须按 owner + operation kind + operation ID + stable slot 派生,只能在统一结果 procedure 中创建或完整比较;普通“同媒体复用”不得把它替换成另一随机 ID。 -- 普通静态图片的默认 `asset_kind` 为 `NULL`;登录态 API 与 SpacetimeDB storage 的 `editor_project_resource` / `editor_asset` 创建边界都必须将 trim 后精确等于旧值 `image` 的输入归一为 `NULL`,其它非空值保持既有校验语义。存量清理由受限 migration procedure 执行;清理 active 结构化画布时必须同时重算 legacy shadow hash、structured hash、图层 / 对话框 / 资源引用完整性和 verified revision,保留 migration 状态与全部时间戳,并通过与下一次 V2 保存相同的 migration verification。 +- 普通静态图片的默认 `asset_kind` 为 `NULL`;登录态 API 与 SpacetimeDB storage 的 `editor_project_resource` / `editor_asset` 创建边界,以及 legacy 画布保存提取元数据和项目资源落表边界,都必须将 trim 后精确等于旧值 `image` 的输入归一为 `NULL`。legacy 布局即使该字段归一后为空也必须删除原字段并持久化清理结果,其它非空值保持既有校验语义。存量清理由受限 migration procedure 执行;project-resource 清行前先验证同工程迁移并把迁移状态纳入批次 hash,清行后若两侧仍满足原 status 不变量就立即刷新摘要;必须等待显式 canvas 字段一并删除的受控过渡态保留旧凭证。canvas scope 的任何布局写入前再按原 migration status 校验旧凭证:active 同时核对双份 legacy shadow 与当前 structured revision/hash/integrity,backfilled 还要证明两侧与原凭证一致且语义等价,rolled_back 沿用重复 rollback 的完整复检。只有旧凭证有效且差异仅由本次 project-resource 清零与精确 `image` 字段删除构成时才允许写入、复核新状态并受控重算 legacy / structured hash、图层 / 对话框 / 资源引用完整性和 verified revision;migration status 与全部时间戳保持不变。 - 完美像素资源:处理成功时只允许一个最终 PNG 对象对应一个新 project resource;源图已有正式 project resource 时,`source_resource_id` 指向该资源;结果尺寸与最终 PNG 一致,不写逻辑低分辨率图、诊断图或前后对比图。成功时同时创建一个同源 `editor_asset`,请求省略素材文件夹时落入默认素材文件夹;completion 因权威 dialog 的删除已先持久化而跳过画布写回时,这两类已确认资源无需回滚。 - `generation_inputs_json` 包络契约:`fields` / `references` 是图片信息读取的用户可见生成输入快照;顶层允许保存后端内部结果扩展。现有 `screenColorHex` 保存实际背景色,角色、图标图集和 UI 图集抠图派生资产使用 `mattingProvider` / `mattingModel` 保存实际成功的处理后端与模型。BgFilter 保存本次 `seg_model`,阿里云通用抠图保存 `Aliyun Matting / segment-common-image`,本地键色保存 `Genarrative Local / screen-color-keying`。同源画布 BFF 的角色、图标和 UI 请求由前端自动提交 `screenColor=auto` 与默认 `segModel=birefnet`,其中 `segModel` 是不可由用户选择的请求控制字段,不进入 `generationInputs`;`background_mode` 和 `cross_check` 只属于 api-server 到 worker 的内部 RPC。External OpenAPI 不开放 `segModel`。上述内部结果字段不写入 `fields`,普通用户(包括素材 owner)与匿名公开读取均不得取得;普通用户响应还必须省略素材顶层 `provider` 和内部处理 `model`,但保留正常用户可见 `model` 与其他合法的顶层功能字段。后台管理和服务端审计可读取原始值。过滤只作用于普通用户 / 公开响应边界,不修改素材或精选快照,因此历史数据无需迁移。 - `generation_inputs_json` V2 可执行改造契约沿用现有 JSON 列,无 SpacetimeDB schema 迁移或存量回填:顶层 `version=2` 和稳定 `action` 确定生成器,`fields[].id` 确定参数,`references[].id/refType/refId` 确定引用参数与稳定指针;`fields[].value` 保持 `string | number | boolean` 类型,`title` / `label` 只作展示。前端改造只在当前画布图层中匹配引用并取得运行时媒体类型,不新增 owner-only 工程资源 / 素材库 resolver;面板直接上传引用和已移出画布的引用均不恢复。可重新选择的引用由前端留空槽位、提示并交给提交门禁校验;必须依赖原 `source` 图层才能构造面板的 action 仍按 capability 保留改造按钮,source 缺失时在点击恢复路径显示明确错误并拒绝,运行期来源变化时再次校验。有效 V2 的引用缺失不得触发 legacy adapter。Owner resource / asset payload 在普通用户元数据清理后保留这些执行字段;匿名公开素材 payload 暂不返回 `generationInputs`,避免公开接口沿用 owner 可执行配方 DTO。独立裁扩、手动去背景和手动图集拆分是确定性派生操作,新结果 `generation_inputs_json = null`;原生成任务内的自动透明化 / 拆分后处理可保留同任务的原生成输入。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index a7d917664..c2a060fee 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -94,7 +94,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去 角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `,且同样只能由已授权 migration operator 执行。必须先发布包含 normalization cursor 索引和 `normalize_editor_character_animation_metadata_and_return` 的 SpacetimeDB 模块,在 API / worker 仍处于维护模式时先运行默认全量 dry-run;脚本固定按 `asset → project-resource → showcase → canvas` 扫描,普通 scope 每批最多 25 行,canvas 每批最多 5 行。全量 dry-run 会在不写库的情况下把 asset 计划结果投影给同 owner / task / 首帧对象精确匹配的 project-resource,再把前置 scope 的计划结果投影给 canvas 检查;因此同 task 的误标预览 MP4 会先按权威视频对象排除,最终图片序列会逐帧核对并补齐精确 `asset_object` 身份。canvas 中仍引用误标 preview resource 的普通 video layer 会按 project-resource 计划态 `video` 跳过,只有 layout 明确声明动作却指向视频,或资源规划本身失败时才形成 blocker。apply 时仍要求前置 scope 已按顺序物理完成,不能跳过 asset 直接让 project-resource 借未落库结果。历史 canvas 复制的 `sourceResourceId` 不是迁移证据,不要因它仍指向原角色而手工改库,补建资源会采用最终账号素材的 DB 血缘。出现 blocker 时脚本会打印 ID、原因、owner、project、task、对象身份和来源资源;先据此区分最终候选为零 / 多个、正式与旧版冲突、帧对象不匹配或缺失资源,不得跳过 scope。确认 dry-run 后追加 `--apply`,脚本会对每批重新 dry-run、携带该批 SHA-256 apply,并在最后从头要求四个 scope 均为零匹配、零 blocker。只有该复核通过后才发布移除 action fallback 的 API / Web。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-normalize-editor-character-actions.mjs` 与 `scripts/spacetime-migration-common.mjs`。本地切换分支时若要避免 dev publish 因 schema 冲突使用 `-c=on-conflict` 清库,启动命令必须追加 `--preserve-database`,让冲突直接失败。 -普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database --server-url `,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;canvas 清理会在不推进业务 revision、不刷新迁移时间戳的前提下同步 active 结构化画布的 legacy / structured hash、完整性摘要和 verified revision。新版本 API 与 SpacetimeDB storage 的项目资源 / 账号素材创建边界会将 trim 后精确等于 `image` 的 `assetKind` 归一为 `NULL`,防止旧页面和滞留请求重新制造废弃值。完成零残留复核并确认 active 结构化画布可继续保存后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs` 与 `scripts/spacetime-migration-common.mjs`。 +普通图片错误素材类型清理使用 `npm run spacetime:editor-image-asset-kind:clean -- --database --server-url `,只能由已授权 migration operator 执行。先进入维护模式并发布包含 `clean_editor_image_asset_kind_and_return` 的 SpacetimeDB module,并保持旧版本 API / controller / worker 停止;随后运行默认全量 dry-run,核对 `asset → project-resource → showcase → canvas` 各 scope 的扫描数、命中行数、字段数和 blocker 均符合预期,再追加 `--apply`。脚本对每批重新 dry-run、绑定包含画布迁移摘要的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hash,清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,只允许本批资源清零及精确字段删除造成的差异,写入后再次复核新状态才受控重签摘要,同时保持业务 revision、migration status 与全部时间戳不变。新版本 API、SpacetimeDB storage 创建入口、legacy 画布元数据提取和项目资源落表边界都会将 trim 后精确等于 `image` 的 `assetKind` 归一为 `NULL`,防止旧页面、滞留请求或 legacy 保存重新制造废弃值。完成零残留复核,并分别确认 cleaned backfilled 可激活、active 可继续保存、rolled_back 可重复复检后恢复应用版本,最后退出维护。Stdb build artifact 和完整 release 包必须同时包含 `scripts/spacetime-clean-editor-image-asset-kind.mjs` 与 `scripts/spacetime-migration-common.mjs`。 自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。 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 a6ba240f5..84aa0d26e 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -8945,6 +8945,109 @@ fn canonical_layout_sha256_with_resource_asset_kinds( .map(|value| sha256_hex(value.as_bytes())) } +fn canonical_layout_sha256_for_editor_image_cleanup( + layers_json: &str, + resource_asset_kinds: &BTreeMap>, +) -> Result { + // 中文注释:线上旧 migration 的 canonical 摘要可能含现已退出白名单的 `image`。 + // 这里只用同媒体族占位值复现旧 serializer 的字段顺序,再按精确图层身份恢复旧值; + // 正常 parse/save 仍走严格 canonicalizer,不能借迁移复核重新接受该类型。 + const PLACEHOLDER_ASSET_KIND: &str = "scene"; + + let mut value = serde_json::from_str::(layers_json) + .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; + let items = value + .as_array_mut() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut image_layer_ids = BTreeSet::new(); + 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 layer_id = json_required_string(object.get("layerId"), "layerId")?; + let resource_id = json_required_string(object.get("resourceId"), "resourceId")?; + let explicit_asset_kind = object + .get("assetKindOverride") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .or_else(|| { + object + .get("assetKind") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + }); + let effective_asset_kind = explicit_asset_kind.or_else(|| { + resource_asset_kinds + .get(&resource_id) + .and_then(Option::as_deref) + }); + if effective_asset_kind == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) { + image_layer_ids.insert((layer_id, resource_id)); + } + for field in ["assetKind", "assetKindOverride"] { + if object.get(field).and_then(JsonValue::as_str) == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) + { + object.insert( + field.to_string(), + JsonValue::String(PLACEHOLDER_ASSET_KIND.to_string()), + ); + } + } + } + let placeholder_resource_asset_kinds = resource_asset_kinds + .iter() + .map(|(resource_id, asset_kind)| { + let asset_kind = match asset_kind.as_deref() { + Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) => Some(PLACEHOLDER_ASSET_KIND.to_string()), + _ => asset_kind.clone(), + }; + (resource_id.clone(), asset_kind) + }) + .collect::>(); + let placeholder_json = + serde_json::to_string(&value).map_err(|_| "图片画布图层布局无法序列化".to_string())?; + let canonical = canonical_layout_json_with_resource_asset_kinds( + placeholder_json.as_str(), + &placeholder_resource_asset_kinds, + )?; + if image_layer_ids.is_empty() { + return Ok(sha256_hex(canonical.as_bytes())); + } + let mut canonical_value = serde_json::from_str::(&canonical) + .map_err(|_| "图片画布 canonical 布局不是合法 JSON".to_string())?; + let canonical_items = canonical_value + .as_array_mut() + .ok_or_else(|| "图片画布 canonical 布局必须是数组".to_string())?; + for item in canonical_items { + let Some(object) = item.as_object_mut() else { + continue; + }; + let Some(layer_id) = json_optional_string(object.get("layerId")) else { + continue; + }; + let Some(resource_id) = json_optional_string(object.get("resourceId")) else { + continue; + }; + if image_layer_ids.contains(&(layer_id, resource_id)) { + object.insert( + "assetKindOverride".to_string(), + JsonValue::String(EDITOR_LEGACY_IMAGE_ASSET_KIND.to_string()), + ); + } + } + serde_json::to_string(&canonical_value) + .map(|canonical| sha256_hex(canonical.as_bytes())) + .map_err(|_| "图片画布 canonical 布局无法序列化".to_string()) +} + fn project_resource_asset_kinds( ctx: &ReducerContext, project_id: &str, @@ -8966,6 +9069,38 @@ fn project_resource_asset_kinds( .collect() } +fn project_resource_asset_kinds_for_editor_image_cleanup( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, +) -> BTreeMap> { + ctx.db + .editor_project_resource() + .by_editor_project_resource_project_id() + .filter(&project_id.to_string()) + .filter(|resource| resource.owner_user_id == owner_user_id) + .map(|resource| { + let asset_kind = normalize_project_resource_asset_kind_for_editor_image_cleanup( + resource.asset_kind, + resource.object_key.as_deref(), + ); + (resource.resource_id, asset_kind) + }) + .collect() +} + +fn normalize_project_resource_asset_kind_for_editor_image_cleanup( + asset_kind: Option, + object_key: Option<&str>, +) -> Option { + normalize_legacy_editor_source_asset_kind(normalize_optional(asset_kind), object_key).filter( + |value| { + EDITOR_CANVAS_ASSET_KINDS.contains(&value.as_str()) + || value == EDITOR_LEGACY_IMAGE_ASSET_KIND + }, + ) +} + fn canvas_layout_integrity(layers_json: &str) -> Result { let value = serde_json::from_str::(layers_json) .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; @@ -11404,19 +11539,22 @@ fn canvas_layout_layer_id(item: &JsonValue) -> Option { .map(ToOwned::to_owned) } -fn migrate_canvas_layer_asset_metadata( - ctx: &ReducerContext, - project_id: &str, - owner_user_id: &str, +struct EditorLegacyCanvasResourceMetadataPatch { + resource_id: String, + asset_kind: Option, + generation_inputs_json: Option, +} + +fn extract_legacy_canvas_layer_asset_metadata( layers_json: String, - now: Timestamp, -) -> Result { +) -> Result<(String, Vec), String> { let mut layout = serde_json::from_str::(&layers_json) .map_err(|_| "图片画布图层布局不是合法 JSON".to_string())?; let Some(items) = layout.as_array_mut() else { - return Ok(layers_json); + return Ok((layers_json, Vec::new())); }; let mut changed = false; + let mut patches = Vec::new(); for item in items { let Some(object) = item.as_object_mut() else { continue; @@ -11430,32 +11568,62 @@ fn migrate_canvas_layer_asset_metadata( .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned); - let asset_kind = object - .remove("assetKind") - .and_then(|value| normalize_legacy_layout_string(value.as_str())); + let legacy_asset_kind = object.remove("assetKind"); + let had_asset_kind = legacy_asset_kind.is_some(); + let asset_kind = normalize_editor_persisted_asset_kind( + legacy_asset_kind.and_then(|value| normalize_legacy_layout_string(value.as_str())), + ); let generation_inputs_json = object .remove("generationInputs") .and_then(|value| serialize_non_empty_json_value(&value)); - changed |= asset_kind.is_some() || generation_inputs_json.is_some(); + changed |= had_asset_kind || generation_inputs_json.is_some(); let Some(resource_id) = resource_id else { continue; }; - if asset_kind.is_some() || generation_inputs_json.is_some() { - migrate_project_resource_asset_metadata( - ctx, - project_id, - owner_user_id, + if had_asset_kind || generation_inputs_json.is_some() { + patches.push(EditorLegacyCanvasResourceMetadataPatch { resource_id, asset_kind, generation_inputs_json, - now, - ); + }); } } if !changed { - return Ok(layers_json); + return Ok((layers_json, patches)); } - serde_json::to_string(&layout).map_err(|_| "图片画布图层布局无法序列化".to_string()) + serde_json::to_string(&layout) + .map(|layers_json| (layers_json, patches)) + .map_err(|_| "图片画布图层布局无法序列化".to_string()) +} + +fn migrate_canvas_layer_asset_metadata( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + layers_json: String, + now: Timestamp, +) -> Result { + let (layers_json, patches) = extract_legacy_canvas_layer_asset_metadata(layers_json)?; + for patch in patches { + migrate_project_resource_asset_metadata( + ctx, + project_id, + owner_user_id, + patch.resource_id, + patch.asset_kind, + patch.generation_inputs_json, + now, + ); + } + Ok(layers_json) +} + +fn next_editor_project_resource_asset_kind( + stored: Option, + incoming: Option, +) -> Option { + normalize_editor_persisted_asset_kind(stored) + .or_else(|| normalize_editor_persisted_asset_kind(incoming)) } fn migrate_project_resource_asset_metadata( @@ -11478,7 +11646,8 @@ fn migrate_project_resource_asset_metadata( else { return; }; - let next_asset_kind = resource.asset_kind.clone().or(asset_kind); + let next_asset_kind = + next_editor_project_resource_asset_kind(resource.asset_kind.clone(), asset_kind); let next_generation_inputs_json = resource .generation_inputs_json .clone() @@ -12746,22 +12915,67 @@ fn clean_editor_image_asset_kind_project_resources( let matched = row.asset_kind.as_deref() == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND); stats.matched_count = stats.matched_count.saturating_add(u32::from(matched)); stats.cleaned_field_count = stats.cleaned_field_count.saturating_add(u32::from(matched)); + let plan_fingerprint = if matched { + match verify_editor_image_asset_kind_project_migrations_before_resource_cleanup( + ctx, + row.project_id.as_str(), + row.owner_user_id.as_str(), + ) { + Ok(states) => editor_image_asset_kind_project_migrations_fingerprint(ctx, &states) + .map(|fingerprint| format!("clear:{fingerprint}")), + Err(message) => Err(message), + } + } else { + Ok("keep".to_string()) + }; + let plan_fingerprint = match plan_fingerprint { + Ok(fingerprint) => fingerprint, + Err(message) => { + stats + .blocker_samples + .push(editor_image_asset_kind_cleanup_blocker_sample( + "project-resource", + row.resource_id.as_str(), + message.as_str(), + )); + "blocked".to_string() + } + }; update_editor_image_asset_kind_cleanup_hash( &mut hasher, "project-resource", row.resource_id.as_str(), row.asset_kind.as_deref().unwrap_or_default(), - if matched { "clear" } else { "keep" }, + plan_fingerprint.as_str(), ); } finalize_editor_image_asset_kind_cleanup_apply_gate(input, &mut stats, hasher)?; + if !input.dry_run && !stats.blocker_samples.is_empty() { + return Ok(stats); + } if !input.dry_run { for mut row in rows { if row.asset_kind.as_deref() != Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) { continue; } + let migration_states = + verify_editor_image_asset_kind_project_migrations_before_resource_cleanup( + ctx, + row.project_id.as_str(), + row.owner_user_id.as_str(), + )?; row.asset_kind = None; + let resource_id = row.resource_id.clone(); + let project_id = row.project_id.clone(); + let owner_user_id = row.owner_user_id.clone(); ctx.db.editor_project_resource().resource_id().update(row); + refresh_editor_image_asset_kind_project_migrations_after_resource_cleanup( + ctx, + project_id.as_str(), + owner_user_id.as_str(), + resource_id.as_str(), + migration_states, + )?; stats.updated_count = stats.updated_count.saturating_add(1); } } @@ -12903,6 +13117,335 @@ fn clean_legacy_image_asset_kind_from_layer_item_json( Ok((cleaned, cleaned_field_count)) } +fn legacy_image_asset_kind_resource_ids(layers_json: &str) -> Result, String> { + let value = parse_legacy_layout_value(layers_json)?; + let items = value + .as_array() + .ok_or_else(|| "图片画布图层布局必须是数组".to_string())?; + let mut resource_ids = BTreeSet::new(); + for item in items { + let Some(object) = item.as_object() else { + continue; + }; + let has_legacy_image_kind = ["assetKind", "assetKindOverride"].into_iter().any(|field| { + object.get(field).and_then(JsonValue::as_str) == Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) + }); + if !has_legacy_image_kind { + continue; + } + resource_ids.insert(json_required_string( + object.get("resourceId"), + "resourceId", + )?); + } + Ok(resource_ids) +} + +fn resource_asset_kinds_before_editor_image_cleanup( + current: &BTreeMap>, + layouts: &[&str], +) -> Result>, String> { + let mut before = current.clone(); + for layout in layouts { + for resource_id in legacy_image_asset_kind_resource_ids(layout)? { + if before.get(&resource_id) == Some(&None) { + before.insert( + resource_id, + Some(EDITOR_LEGACY_IMAGE_ASSET_KIND.to_string()), + ); + } + } + } + Ok(before) +} + +fn verify_editor_image_asset_kind_cleanup_migration_state( + canvas: &EditorCanvas, + project: &EditorProject, + migration: Option<&EditorCanvasLayoutMigration>, + structured_layout_json: Option<&str>, + resource_asset_kinds: &BTreeMap>, +) -> Result<(), String> { + let Some(migration) = migration else { + if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + return Err("active 结构化图片画布缺少迁移记录".to_string()); + } + return Ok(()); + }; + if migration.canvas_id != canvas.canvas_id + || migration.project_id != canvas.project_id + || migration.owner_user_id != canvas.owner_user_id + || project.project_id != canvas.project_id + || project.owner_user_id != canvas.owner_user_id + { + return Err("图片画布、工程与迁移记录身份不一致".to_string()); + } + if migration.migration_version != EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + return Err("图片画布迁移记录版本不受支持".to_string()); + } + let structured_layout_json = + structured_layout_json.ok_or_else(|| "图片画布迁移记录缺少结构化布局".to_string())?; + let structured_hash = canonical_layout_sha256_for_editor_image_cleanup( + structured_layout_json, + resource_asset_kinds, + )?; + let structured_integrity = canvas_layout_integrity(structured_layout_json)?; + + match migration.status.as_str() { + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE => { + if canvas.layout_storage_version != EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED { + return Err("active 图片画布未使用结构化存储".to_string()); + } + let canvas_source_hash = canonical_layout_sha256_for_editor_image_cleanup( + canvas.layers_json.as_str(), + resource_asset_kinds, + )?; + let project_source_hash = canonical_layout_sha256_for_editor_image_cleanup( + project.layers_json.as_str(), + resource_asset_kinds, + )?; + if canvas_source_hash != migration.source_layout_sha256 + || project_source_hash != migration.source_layout_sha256 + { + return Err("已激活图片画布的 legacy shadow 与迁移记录不一致".to_string()); + } + verify_migration_layout( + migration, + canvas.revision, + structured_hash.as_str(), + &structured_integrity, + ) + } + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED => { + if canvas.layout_storage_version != 0 { + return Err("backfilled 图片画布不应使用结构化存储".to_string()); + } + let canvas_source_hash = canonical_layout_sha256_for_editor_image_cleanup( + canvas.layers_json.as_str(), + resource_asset_kinds, + )?; + let project_source_hash = canonical_layout_sha256_for_editor_image_cleanup( + project.layers_json.as_str(), + resource_asset_kinds, + )?; + if canvas_source_hash != migration.source_layout_sha256 + || project_source_hash != migration.source_layout_sha256 + { + return Err("backfilled 图片画布的 legacy 布局与迁移记录不一致".to_string()); + } + verify_migration_layout( + migration, + canvas.revision, + structured_hash.as_str(), + &structured_integrity, + )?; + if canvas_source_hash != structured_hash { + return Err("backfilled 图片画布的 legacy 与结构化布局不再等价".to_string()); + } + Ok(()) + } + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK => { + if canvas.layout_storage_version != 0 { + return Err("rolled_back 图片画布仍在使用结构化存储".to_string()); + } + validate_legacy_layout_rollback_size(structured_layout_json)?; + verify_migration_layout( + migration, + canvas.revision, + structured_hash.as_str(), + &structured_integrity, + )?; + let canvas_legacy_hash = canonical_layout_sha256_for_editor_image_cleanup( + canvas.layers_json.as_str(), + resource_asset_kinds, + )?; + let project_legacy_hash = canonical_layout_sha256_for_editor_image_cleanup( + project.layers_json.as_str(), + resource_asset_kinds, + )?; + if canvas_legacy_hash != structured_hash || project_legacy_hash != structured_hash { + return Err("已回滚图片画布的 legacy 布局与保留的结构化数据不一致".to_string()); + } + Ok(()) + } + _ => Err("图片画布迁移状态不允许执行普通图片类型清理".to_string()), + } +} + +fn verify_editor_image_asset_kind_cleanup_migration_state_allowing_prior_resource_cleanup( + canvas: &EditorCanvas, + project: &EditorProject, + migration: Option<&EditorCanvasLayoutMigration>, + structured_layout_json: Option<&str>, + current_resource_asset_kinds: &BTreeMap>, +) -> Result<(), String> { + let current_result = verify_editor_image_asset_kind_cleanup_migration_state( + canvas, + project, + migration, + structured_layout_json, + current_resource_asset_kinds, + ); + if current_result.is_ok() || migration.is_none() { + return current_result; + } + let Some(structured_layout_json) = structured_layout_json else { + return current_result; + }; + let before_resource_asset_kinds = resource_asset_kinds_before_editor_image_cleanup( + current_resource_asset_kinds, + &[ + canvas.layers_json.as_str(), + project.layers_json.as_str(), + structured_layout_json, + ], + )?; + if before_resource_asset_kinds == *current_resource_asset_kinds { + return current_result; + } + verify_editor_image_asset_kind_cleanup_migration_state( + canvas, + project, + migration, + Some(structured_layout_json), + &before_resource_asset_kinds, + ) +} + +fn verify_editor_image_asset_kind_project_migrations_before_resource_cleanup( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, +) -> Result, String> { + let project = require_owned_project(ctx, project_id, owner_user_id)?; + let resource_asset_kinds = + project_resource_asset_kinds_for_editor_image_cleanup(ctx, project_id, owner_user_id); + let mut canvases = ctx + .db + .editor_canvas() + .by_editor_canvas_project_id() + .filter(&project_id.to_string()) + .filter(|canvas| canvas.owner_user_id == owner_user_id) + .collect::>(); + canvases.sort_by(|left, right| left.canvas_id.cmp(&right.canvas_id)); + let mut states = Vec::new(); + for canvas in canvases { + let Some(migration) = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id) + else { + continue; + }; + let structured_layout_json = build_structured_canvas_layout_json(ctx, &canvas)?; + verify_editor_image_asset_kind_cleanup_migration_state_allowing_prior_resource_cleanup( + &canvas, + &project, + Some(&migration), + Some(structured_layout_json.as_str()), + &resource_asset_kinds, + )?; + states.push((canvas, migration)); + } + Ok(states) +} + +fn editor_image_asset_kind_project_migrations_fingerprint( + ctx: &ReducerContext, + states: &[(EditorCanvas, EditorCanvasLayoutMigration)], +) -> Result { + let mut hasher = Sha256::new(); + for (canvas, migration) in states { + let structured_layout_json = build_structured_canvas_layout_json(ctx, canvas)?; + for value in [ + canvas.canvas_id.as_str(), + canvas.layers_json.as_str(), + structured_layout_json.as_str(), + migration.source_layout_sha256.as_str(), + migration.structured_layout_sha256.as_str(), + migration.resource_refs_sha256.as_str(), + migration.status.as_str(), + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + hasher.update(canvas.revision.to_le_bytes()); + hasher.update(canvas.layout_storage_version.to_le_bytes()); + hasher.update(migration.verified_revision.to_le_bytes()); + hasher.update(migration.layer_count.to_le_bytes()); + hasher.update(migration.dialog_count.to_le_bytes()); + hasher.update(migration.migration_version.to_le_bytes()); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn refresh_editor_image_asset_kind_project_migrations_after_resource_cleanup( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, + cleaned_resource_id: &str, + states: Vec<(EditorCanvas, EditorCanvasLayoutMigration)>, +) -> Result<(), String> { + if states.is_empty() { + return Ok(()); + } + let project = require_owned_project(ctx, project_id, owner_user_id)?; + let resource_asset_kinds = + project_resource_asset_kinds_for_editor_image_cleanup(ctx, project_id, owner_user_id); + for (canvas, migration) in states { + let structured_layout_json = build_structured_canvas_layout_json(ctx, &canvas)?; + let source_layout_sha256 = canonical_layout_sha256_for_editor_image_cleanup( + canvas.layers_json.as_str(), + &resource_asset_kinds, + )?; + let structured_layout_sha256 = canonical_layout_sha256_for_editor_image_cleanup( + structured_layout_json.as_str(), + &resource_asset_kinds, + )?; + let integrity = canvas_layout_integrity(structured_layout_json.as_str())?; + let refreshed = refresh_editor_image_asset_kind_layout_migration( + migration, + source_layout_sha256, + structured_layout_sha256, + canvas.revision, + &integrity, + ); + match verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&refreshed), + Some(structured_layout_json.as_str()), + &resource_asset_kinds, + ) { + Ok(()) => { + ctx.db + .editor_canvas_layout_migration() + .canvas_id() + .update(refreshed); + } + Err(message) => { + let pending_canvas_cleanup = [ + canvas.layers_json.as_str(), + project.layers_json.as_str(), + structured_layout_json.as_str(), + ] + .into_iter() + .map(legacy_image_asset_kind_resource_ids) + .collect::, _>>()? + .into_iter() + .any(|resource_ids| resource_ids.contains(cleaned_resource_id)); + if !pending_canvas_cleanup { + return Err(format!( + "项目资源清理后无法保持图片画布迁移凭证,且该资源没有待清理的精确 image 字段:{message}" + )); + } + } + } + } + Ok(()) +} + fn plan_editor_image_asset_kind_canvas( ctx: &ReducerContext, canvas: &EditorCanvas, @@ -13073,21 +13616,29 @@ fn plan_editor_image_asset_kind_canvas( (Err(message), _) | (_, Err(message)) => Some(message), }, }; - if blocker.is_none() - && canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED - { - blocker = match migration.as_ref() { - None => Some("active 结构化图片画布缺少迁移记录".to_string()), - Some(migration) if migration.status != EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE => { - Some("结构化图片画布的迁移记录不是 active 状态".to_string()) + if blocker.is_none() { + let current_resource_asset_kinds = project_resource_asset_kinds_for_editor_image_cleanup( + ctx, + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + ); + let structured_layout_json = migration + .as_ref() + .map(|_| build_structured_canvas_layout_json(ctx, canvas)) + .transpose(); + blocker = match (project.as_ref(), structured_layout_json) { + (Some(project), Ok(structured_layout_json)) => { + verify_editor_image_asset_kind_cleanup_migration_state_allowing_prior_resource_cleanup( + canvas, + project, + migration.as_ref(), + structured_layout_json.as_deref(), + ¤t_resource_asset_kinds, + ) + .err() } - Some(migration) - if migration.project_id != canvas.project_id - || migration.owner_user_id != canvas.owner_user_id => - { - Some("结构化图片画布与迁移记录的工程或 owner 不一致".to_string()) - } - Some(_) => None, + (_, Err(message)) => Some(message), + (None, _) => blocker, }; } EditorImageAssetKindCanvasPlan { @@ -13111,21 +13662,29 @@ fn apply_editor_image_asset_kind_canvas_plan( .editor_canvas_layout_migration() .canvas_id() .find(&canvas.canvas_id); - if canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED - && migration.as_ref().is_none_or(|migration| { - migration.status != EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE - || migration.project_id != canvas.project_id - || migration.owner_user_id != canvas.owner_user_id - }) - { - return Err("active 结构化图片画布缺少一致的迁移记录,拒绝清理".to_string()); - } let mut project = ctx .db .editor_project() .project_id() .find(&canvas.project_id) .ok_or_else(|| "图片画布对应的工程不存在".to_string())?; + let current_resource_asset_kinds = project_resource_asset_kinds_for_editor_image_cleanup( + ctx, + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + ); + let structured_layout_json = migration + .as_ref() + .map(|_| build_structured_canvas_layout_json(ctx, &canvas)) + .transpose()?; + verify_editor_image_asset_kind_cleanup_migration_state_allowing_prior_resource_cleanup( + &canvas, + &project, + migration.as_ref(), + structured_layout_json.as_deref(), + ¤t_resource_asset_kinds, + )?; + if canvas.layers_json != plan.canvas_layers_json { canvas.layers_json = plan.canvas_layers_json; ctx.db.editor_canvas().canvas_id().update(canvas.clone()); @@ -13148,7 +13707,13 @@ fn apply_editor_image_asset_kind_canvas_plan( ctx.db.editor_canvas_layer().layer_key().update(row); } if let Some(migration) = migration { - let resource_asset_kinds = project_resource_asset_kinds( + let project = ctx + .db + .editor_project() + .project_id() + .find(&canvas.project_id) + .ok_or_else(|| "清理后的图片画布对应工程不存在".to_string())?; + let resource_asset_kinds = project_resource_asset_kinds_for_editor_image_cleanup( ctx, canvas.project_id.as_str(), canvas.owner_user_id.as_str(), @@ -13166,15 +13731,16 @@ fn apply_editor_image_asset_kind_canvas_plan( let migration = refresh_editor_image_asset_kind_layout_migration( migration, source_layout_sha256, - structured_layout_sha256.clone(), + structured_layout_sha256, canvas.revision, &integrity, ); - verify_migration_layout( - &migration, - canvas.revision, - structured_layout_sha256.as_str(), - &integrity, + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(structured_layout_json.as_str()), + &resource_asset_kinds, )?; ctx.db .editor_canvas_layout_migration() @@ -19835,79 +20401,444 @@ mod tests { } #[test] - fn ordinary_image_asset_kind_cleanup_keeps_active_structured_canvas_save_verifiable() { + fn ordinary_image_cleanup_compat_hash_reproduces_old_canonical_without_relaxing_save() { + let legacy = r#"[{"layerId":"layer-1","resourceId":"resource-1","assetKind":"image"}]"#; + let old_canonical = + r#"[{"assetKindOverride":"image","layerId":"layer-1","resourceId":"resource-1"}]"#; + let resource_asset_kinds = + BTreeMap::from([("resource-1".to_string(), Some("image".to_string()))]); + + assert_eq!( + normalize_project_resource_asset_kind_for_editor_image_cleanup( + Some("image".to_string()), + None, + ) + .as_deref(), + Some("image"), + "cleanup verification must still observe the raw historical resource value" + ); + assert_eq!( + canonical_layout_sha256_for_editor_image_cleanup(legacy, &resource_asset_kinds) + .expect("cleanup must reproduce the historical canonical hash"), + sha256_hex(old_canonical.as_bytes()) + ); + assert!( + canonical_layout_sha256_with_resource_asset_kinds(legacy, &resource_asset_kinds) + .is_err(), + "normal save canonicalization must continue rejecting the retired value" + ); + } + + #[test] + fn save_editor_project_layout_v2_drops_legacy_image_kind_without_recreating_resource_kind() { + let source = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image", + "x": 12 + }]) + .to_string(); + let (cleaned, patches) = + extract_legacy_canvas_layer_asset_metadata(source).expect("legacy metadata extraction"); + let cleaned: JsonValue = serde_json::from_str(&cleaned).expect("cleaned layout"); + + assert!(cleaned[0].get("assetKind").is_none()); + assert_eq!(patches.len(), 1); + assert_eq!(patches[0].resource_id, "resource-1"); + assert_eq!(patches[0].asset_kind, None); + assert_eq!( + next_editor_project_resource_asset_kind(None, patches[0].asset_kind.clone()), + None + ); + assert_eq!( + next_editor_project_resource_asset_kind( + Some(EDITOR_LEGACY_IMAGE_ASSET_KIND.to_string()), + None, + ), + None + ); + } + + fn editor_image_cleanup_canvas( + layers_json: &str, + revision: u64, + layout_storage_version: u32, + ) -> EditorCanvas { let now = Timestamp::from_micros_since_unix_epoch(1_000_000); - let activated_at = Timestamp::from_micros_since_unix_epoch(900_000); + EditorCanvas { + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "画布".to_string(), + viewport_x: 0.0, + viewport_y: 0.0, + viewport_scale: 1.0, + layers_json: layers_json.to_string(), + created_at: now, + updated_at: now, + revision, + layout_storage_version, + background_color: None, + } + } + + fn editor_image_cleanup_project(layers_json: &str) -> EditorProject { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + EditorProject { + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "工程".to_string(), + viewport_x: 0.0, + viewport_y: 0.0, + viewport_scale: 1.0, + layers_json: layers_json.to_string(), + created_at: now, + updated_at: now, + } + } + + fn editor_image_cleanup_migration( + status: &str, + source_layout_json: &str, + structured_layout_json: &str, + revision: u64, + resource_asset_kinds: &BTreeMap>, + ) -> EditorCanvasLayoutMigration { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + let integrity = canvas_layout_integrity(structured_layout_json).expect("layout integrity"); + EditorCanvasLayoutMigration { + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + source_layout_sha256: canonical_layout_sha256_for_editor_image_cleanup( + source_layout_json, + resource_asset_kinds, + ) + .expect("source hash"), + structured_layout_sha256: canonical_layout_sha256_for_editor_image_cleanup( + structured_layout_json, + resource_asset_kinds, + ) + .expect("structured hash"), + verified_revision: revision, + layer_count: integrity.layer_count, + dialog_count: integrity.dialog_count, + resource_refs_sha256: integrity.resource_refs_sha256, + migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + status: status.to_string(), + migrated_at: now, + activated_at: (status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE).then_some(now), + rolled_back_at: (status == EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK) + .then_some(now), + updated_at: now, + } + } + + #[test] + fn ordinary_image_cleanup_rejects_active_stale_migration_before_resigning() { let legacy_json = json!([{ "layerId": "layer-1", "resourceId": "resource-1", "assetKind": "image" }]) .to_string(); - let structured_json = json!([{ + let original_structured_json = json!([{ "layerId": "layer-1", - "resourceId": "resource-1", - "assetKindOverride": "image" + "resourceId": "resource-1" }]) .to_string(); - let (cleaned_legacy_json, legacy_cleaned_count) = - clean_legacy_image_asset_kind_from_layout(legacy_json.as_str()).unwrap(); - let (cleaned_structured_json, structured_cleaned_count) = - clean_legacy_image_asset_kind_from_layout(structured_json.as_str()).unwrap(); - assert_eq!((legacy_cleaned_count, structured_cleaned_count), (1, 1)); + let drifted_structured_json = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "x": 99 + }]) + .to_string(); + let current_resource_asset_kinds = BTreeMap::from([("resource-1".to_string(), None)]); + let before_resource_asset_kinds = resource_asset_kinds_before_editor_image_cleanup( + ¤t_resource_asset_kinds, + &[legacy_json.as_str(), original_structured_json.as_str()], + ) + .expect("reconstruct resource cleanup input"); + assert_eq!( + before_resource_asset_kinds + .get("resource-1") + .and_then(Clone::clone) + .as_deref(), + Some(EDITOR_LEGACY_IMAGE_ASSET_KIND) + ); + let canvas = editor_image_cleanup_canvas( + legacy_json.as_str(), + 7, + EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + ); + let project = editor_image_cleanup_project(legacy_json.as_str()); + let migration = editor_image_cleanup_migration( + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE, + legacy_json.as_str(), + original_structured_json.as_str(), + 7, + &before_resource_asset_kinds, + ); - let resource_asset_kinds = BTreeMap::new(); - let source_hash = canonical_layout_sha256_with_resource_asset_kinds( - cleaned_legacy_json.as_str(), + let error = verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(drifted_structured_json.as_str()), + &before_resource_asset_kinds, + ) + .expect_err("stale active migration must fail before cleanup"); + assert!(error.contains("漂移")); + } + + #[test] + fn ordinary_image_cleanup_rejects_backfilled_drift_on_both_layout_sides() { + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image" + }]) + .to_string(); + let resource_asset_kinds = + BTreeMap::from([("resource-1".to_string(), Some("image".to_string()))]); + let canvas = editor_image_cleanup_canvas(layout.as_str(), 7, 0); + let project = editor_image_cleanup_project(layout.as_str()); + let migration = editor_image_cleanup_migration( + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED, + layout.as_str(), + layout.as_str(), + 7, + &resource_asset_kinds, + ); + let drifted_legacy = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image", + "x": 1 + }]) + .to_string(); + let drifted_structured = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image", + "x": 2 + }]) + .to_string(); + + let drifted_project = editor_image_cleanup_project(drifted_legacy.as_str()); + assert!( + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &drifted_project, + Some(&migration), + Some(layout.as_str()), + &resource_asset_kinds, + ) + .is_err() + ); + assert!( + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(drifted_structured.as_str()), + &resource_asset_kinds, + ) + .is_err() + ); + } + + #[test] + fn ordinary_image_cleanup_rejects_drifted_rolled_back_reentry() { + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image" + }]) + .to_string(); + let drifted_legacy = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image", + "x": 1 + }]) + .to_string(); + let resource_asset_kinds = + BTreeMap::from([("resource-1".to_string(), Some("image".to_string()))]); + let canvas = editor_image_cleanup_canvas(drifted_legacy.as_str(), 9, 0); + let project = editor_image_cleanup_project(drifted_legacy.as_str()); + let migration = editor_image_cleanup_migration( + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK, + layout.as_str(), + layout.as_str(), + 9, + &resource_asset_kinds, + ); + + let error = verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(layout.as_str()), &resource_asset_kinds, ) - .unwrap(); - let structured_hash = canonical_layout_sha256_with_resource_asset_kinds( - cleaned_structured_json.as_str(), - &resource_asset_kinds, - ) - .unwrap(); - let integrity = canvas_layout_integrity(cleaned_structured_json.as_str()).unwrap(); - 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: "stale-source".to_string(), - structured_layout_sha256: "stale-structured".to_string(), - verified_revision: 6, - layer_count: 9, - dialog_count: 9, - resource_refs_sha256: "stale-refs".to_string(), - migration_version: EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, - status: EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE.to_string(), - migrated_at: now, - activated_at: Some(activated_at), - rolled_back_at: None, - updated_at: now, - }; + .expect_err("drifted rolled-back state must reject repeated cleanup"); + assert!(error.contains("已回滚")); + } + #[test] + fn ordinary_image_project_resource_cleanup_refreshes_rolled_back_without_canvas_field_match() { + let layout = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1" + }]) + .to_string(); + assert_eq!( + clean_legacy_image_asset_kind_from_layout(layout.as_str()) + .expect("layout scan") + .1, + 0, + "resource-only historical image state is invisible to the canvas field scanner" + ); + let before_resource_asset_kinds = + BTreeMap::from([("resource-1".to_string(), Some("image".to_string()))]); + let after_resource_asset_kinds = BTreeMap::from([("resource-1".to_string(), None)]); + let canvas = editor_image_cleanup_canvas(layout.as_str(), 9, 0); + let project = editor_image_cleanup_project(layout.as_str()); + let migration = editor_image_cleanup_migration( + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK, + layout.as_str(), + layout.as_str(), + 9, + &before_resource_asset_kinds, + ); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(layout.as_str()), + &before_resource_asset_kinds, + ) + .expect("pre-cleanup rolled-back credential"); + + let integrity = canvas_layout_integrity(layout.as_str()).expect("layout integrity"); let refreshed = refresh_editor_image_asset_kind_layout_migration( migration, - source_hash.clone(), - structured_hash, - 7, + canonical_layout_sha256_for_editor_image_cleanup( + layout.as_str(), + &after_resource_asset_kinds, + ) + .expect("cleaned source hash"), + canonical_layout_sha256_for_editor_image_cleanup( + layout.as_str(), + &after_resource_asset_kinds, + ) + .expect("cleaned structured hash"), + 9, &integrity, ); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&refreshed), + Some(layout.as_str()), + &after_resource_asset_kinds, + ) + .expect("resource cleanup must re-sign a rolled-back migration with no canvas field hit"); + } - assert_eq!(refreshed.source_layout_sha256, source_hash); - assert_eq!( - refreshed.status, - EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE - ); - assert_eq!(refreshed.migrated_at, now); - assert_eq!(refreshed.activated_at, Some(activated_at)); - assert_eq!(refreshed.updated_at, now); - verify_active_structured_canvas_migration( - &refreshed, + #[test] + fn ordinary_image_cleanup_keeps_save_activate_and_rollback_state_valid() { + let legacy_image_json = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image" + }]) + .to_string(); + let (cleaned_json, cleaned_count) = + clean_legacy_image_asset_kind_from_layout(legacy_image_json.as_str()).unwrap(); + assert_eq!(cleaned_count, 1); + let resource_asset_kinds = BTreeMap::from([("resource-1".to_string(), None)]); + + let mut canvas = editor_image_cleanup_canvas(cleaned_json.as_str(), 7, 0); + let mut project = editor_image_cleanup_project(cleaned_json.as_str()); + let mut migration = editor_image_cleanup_migration( + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_BACKFILLED, + cleaned_json.as_str(), + cleaned_json.as_str(), 7, - cleaned_structured_json.as_str(), + &resource_asset_kinds, + ); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(cleaned_json.as_str()), &resource_asset_kinds, ) - .expect("cleaned active structured canvas should pass the next save verification"); + .expect("cleaned backfill should remain activatable"); + + canvas.layout_storage_version = EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED; + canvas.revision = 8; + migration.status = EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE.to_string(); + migration.verified_revision = 8; + migration.activated_at = Some(Timestamp::from_micros_since_unix_epoch(1_000_001)); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(cleaned_json.as_str()), + &resource_asset_kinds, + ) + .expect("cleaned backfill should activate"); + + let saved_structured_json = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "x": 10 + }]) + .to_string(); + canvas.revision = 9; + let saved_integrity = + canvas_layout_integrity(saved_structured_json.as_str()).expect("saved integrity"); + migration = refresh_editor_image_asset_kind_layout_migration( + migration, + canonical_layout_sha256_with_resource_asset_kinds( + canvas.layers_json.as_str(), + &resource_asset_kinds, + ) + .expect("legacy source hash"), + canonical_layout_sha256_with_resource_asset_kinds( + saved_structured_json.as_str(), + &resource_asset_kinds, + ) + .expect("saved structured hash"), + 9, + &saved_integrity, + ); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(saved_structured_json.as_str()), + &resource_asset_kinds, + ) + .expect("cleaned active canvas should save"); + + canvas.layers_json = saved_structured_json.clone(); + canvas.layout_storage_version = 0; + canvas.revision = 10; + project.layers_json = saved_structured_json.clone(); + migration.status = EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ROLLED_BACK.to_string(); + migration.verified_revision = 10; + migration.rolled_back_at = Some(Timestamp::from_micros_since_unix_epoch(1_000_002)); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(saved_structured_json.as_str()), + &resource_asset_kinds, + ) + .expect("cleaned saved canvas should roll back and pass reentry verification"); } }