From 2ca812b52aca08dc7a6e14f55f7be24a92b6198b 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 13:10:01 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E6=99=AE=E9=80=9A=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=BF=81=E7=A7=BB=E5=A4=B1=E8=B4=A5=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 generation-dialog 权威 JSON 纳入画布清理扫描、批次指纹和原子 patch。 在项目资源清理前阻断缺少 migration 的结构化画布并绑定 blocker 状态。 补充 active 画布、零残留、批次指纹和缺记录门禁回归测试。 同步后端契约、生产运维说明和共享决策记录。 --- .../shared-memory/decision-log.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- .../src/editor_project_storage.rs | 369 +++++++++++++++++- 4 files changed, 368 insertions(+), 7 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6fe5dc51d..90d04c7b1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6944,5 +6944,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 后零残留复核的清理。project-resource 清行前验证同工程迁移并把状态纳入批次 hash;清行后能保持原 status 不变量时立即重签,从而覆盖 rolled_back 布局没有显式字段、摘要却依赖资源旧值的情况,仍需显式字段清理的过渡态保留旧凭证。canvas 在写入前再按 active / backfilled / rolled_back 原状态验证旧凭证、双份 legacy shadow、structured revision/hash/integrity 与对应语义等价 / 重入不变量;只有旧凭证有效且差异仅来自受控资源清零及精确 `image` 删除时才写入,写后再用同一状态守卫验证并更新摘要。该过程不推进业务 revision、不改 migration 状态或任何时间戳。旧 migration 摘要中的 `image` 通过清理专用兼容 canonicalizer 复算,正常保存白名单仍拒绝该值。 +- 迁移:生产维护窗口内按 `asset → project-resource → showcase → canvas` 执行受 operator 鉴权、分批 dry-run、批次 SHA-256 绑定和 apply 后零残留复核的清理。project-resource 清行前验证同工程迁移并把状态纳入批次 hash;只有 layout version 0 的 legacy 画布可以缺 migration,structured 画布缺记录必须在任何资源写入前失败关闭。清行后能保持原 status 不变量时立即重签,从而覆盖 rolled_back 布局没有显式字段、摘要却依赖资源旧值的情况,仍需显式字段清理的过渡态保留旧凭证。canvas 的权威扫描、批次 hash 和原子 patch 同时覆盖双份 legacy shadow、`editor_canvas_layer` 与 `editor_canvas_generation_dialog.dialog_json`;写入前再按 active / backfilled / rolled_back 原状态验证旧凭证、structured revision/hash/integrity 与对应语义等价 / 重入不变量。只有旧凭证有效且差异仅来自受控资源清零及精确 `image` 删除时才写入,写后从全部 structured 权威行重建 layout,再用同一状态守卫验证并更新摘要。该过程不推进业务 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 fa3c7cfb3..4d8db475e 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -448,7 +448,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - `generation_inputs_json` 只保存用户生成 / 重放输入,例如 `fields`、`references`、`artSpec`;动作输出和内部背景决策色不得再写入该 JSON。背景决策审计继续走独立审计链路。 - 存量 `generation_inputs_json.characterAnimation`、已确认动作行的 helper 顶层 `frames/previewVideoPath/frameCount/fps/durationSeconds`、`screenColorHex`、正式帧中的 `frameIndex` 和误标动作预览 MP4,统一由 migration operator procedure `normalize_editor_character_animation_metadata_and_return` 分 `asset → project-resource → showcase → canvas` 四个 scope 迁移。顶层旧字段只有在 `asset_kind=character-animation`、正式序列字段、嵌套 `characterAnimation` 或权威 `asset_object` 已证明该行属于角色动作时才解释,普通图片 / 视频任意 JSON 中同名字段保持原样。迁移在计划态先把同 task 的已验证预览 MP4 重分类为 `video`,只有能形成正式图片序列的候选才可用于后续 scope;project-resource 自身缺帧但能用同 owner、同 task、同首帧 `assetObjectId/objectKey` 精确定位唯一最终账号素材时,dry-run 可消费 asset 的计划态正式序列,apply 仍要求 asset scope 已物理完成。canvas 同样按 project-resource 的计划态类型分类:旧库误标为动作、但规划后为 `video` 且 layout 未声明动作的 preview 图层不参与动作副本清理;layout 明确声明动作却指向该视频,或原始动作资源的规划存在 blocker,继续失败关闭。每一帧必须按首帧 bucket 下的精确对象路径匹配同 owner、同 task、`editor_character_animation` 且为图片的 `asset_object`,并从登记对象补齐 `objectKey/assetObjectId`。canvas layout 复制的 `sourceResourceId` 不参与迁移血缘校验,补建资源只采用最终账号素材的 DB 血缘;新生成链路仍严格校验直接来源。正式与旧版帧或时长冲突、最终候选为零或多于一个、帧对象无法精确验证和缺失正式帧集均作为 blocker,整批拒绝 apply;procedure 同时返回不含生成输入正文的 blocker 完整诊断,包含 scope、ID、原因、owner、project、task、对象身份和来源资源。 - 运维入口固定为 `node scripts/spacetime-normalize-editor-character-actions.mjs --database --server-url `;默认全量 dry-run,`--apply` 时每批先 dry-run,再用返回的 batch SHA-256 写入,四个 scope 完成后从头执行零匹配 / 零 blocker 复核。迁移完成后 api-server、后台、前端和外部 helper 只读取正式字段,不再包含 legacy fallback。新建动作资源 / 素材若在 `generationInputs` 提交旧运行字段,或正式帧包含 `frameIndex`,api-server 与 SpacetimeDB storage 均失败关闭;其它素材的任意生成输入不受动作专属门禁影响。 -- 2026-08-10 前短期错误版本写入的 `asset_kind = "image"` 不走永久兼容,统一由 migration operator procedure `clean_editor_image_asset_kind_and_return` 清理。scope 固定为 `asset → project-resource → showcase → canvas`:前三者只把精确旧值改为 `None`;canvas 同时清理 `editor_canvas` / `editor_project` 双份 legacy layout 顶层 `assetKind` / `assetKindOverride` 和结构化 `editor_canvas_layer.asset_kind_override`,但不递归修改 `generationInputs.references[*].mediaType`,也不修改 MIME、`CanvasMediaType` 或 `asset_object.asset_kind`。纯分类修复保留原 `updated_at`、画布 revision 和迁移状态。双份 layout 清理后仍不一致、缺工程、owner 不一致或命中字段的画布项缺少稳定 layer / resource 身份时形成 blocker,整批拒绝 apply;诊断只返回 ID SHA-256、scope 和原因。 +- 2026-08-10 前短期错误版本写入的 `asset_kind = "image"` 不走永久兼容,统一由 migration operator procedure `clean_editor_image_asset_kind_and_return` 清理。scope 固定为 `asset → project-resource → showcase → canvas`:前三者只把精确旧值改为 `None`;canvas 同时清理 `editor_canvas` / `editor_project` 双份 legacy layout 顶层 `assetKind` / `assetKindOverride`、结构化 `editor_canvas_layer` 的 typed override / `item_json`,以及 generation-dialog 权威 `editor_canvas_generation_dialog.dialog_json` 中的同名顶层扩展字段,但不递归修改 `generationInputs.references[*].mediaType`,也不修改 MIME、`CanvasMediaType` 或 `asset_object.asset_kind`。dialog 权威行必须进入 dry-run 命中计数、批次 SHA-256 和同一事务内的 patch,清理后以重建的完整 structured layout 复核并更新 migration 摘要。纯分类修复保留原 `updated_at`、画布 revision 和迁移状态。双份 layout 清理后仍不一致、缺工程、owner 不一致或命中字段的画布项缺少稳定 layer / resource 身份时形成 blocker,整批拒绝 apply;project-resource scope 只允许 layout version 0 的 legacy 画布没有 migration,任一 structured 画布缺 migration 必须在资源行写入前失败关闭,并把该状态绑定进 dry-run / apply 批次 hash;诊断只返回 ID SHA-256、scope 和原因。 - 普通画布 layer 的持久化和前端响应不包含顶层 `mediaType`;渲染类型只由资源 / 素材 `assetKind` 在前端派生。`generationInputs.references[*].mediaType` 是生成参考输入契约,不属于画布 layer legacy 字段,迁移和响应清洗不得递归删除。 ## 新增编辑器 `assetKind` 接入清单(2026-08-03,2026-08-08 增补) diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index c2a060fee..d3118f21d 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 或媒体类型;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`。 +普通图片错误素材类型清理使用 `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、绑定包含画布迁移摘要、结构化 layer 与 generation-dialog 权威 JSON 的 SHA-256,最后自动从头复核零命中;任一画布数据异常都会只输出哈希化 ID、scope 与原因并停止,不能跳过。清理只处理精确业务旧值,不修改 `asset_object.asset_kind`、MIME 或媒体类型;project-resource scope 在清行前验证同工程 migration 并将其状态纳入批次 hash,layout version 0 的 legacy 画布可以没有 migration,但 structured 画布缺 migration 必须立即形成 blocker,资源行不得先被清空;清行后能保持原 status 不变量时立即刷新摘要,否则只允许留给后续精确 canvas 字段清理收口。canvas scope 在任何布局写入前再次按 active / backfilled / rolled_back 状态验证原 migration 凭证和双份 legacy / structured 不变量,将 `editor_canvas_generation_dialog.dialog_json` 与 layer rows 一并扫描并在同一事务 patch;只允许本批资源清零及精确字段删除造成的差异,写入后从全部结构化权威行重建 layout、再次复核新状态才受控重签摘要,同时保持业务 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 84aa0d26e..d5f0c9429 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -12677,11 +12677,18 @@ struct EditorImageAssetKindLayerPatch { clear_override: bool, } +#[derive(Clone, Debug)] +struct EditorImageAssetKindDialogPatch { + dialog_key: String, + dialog_json: String, +} + #[derive(Clone, Debug)] struct EditorImageAssetKindCanvasPlan { canvas_layers_json: String, project_layers_json: String, layer_patches: Vec, + dialog_patches: Vec, source_fingerprint: String, cleaned_field_count: u32, changed: bool, @@ -12831,6 +12838,21 @@ fn editor_image_asset_kind_cleanup_blocker_sample(scope: &str, id: &str, reason: .to_string() } +fn editor_image_asset_kind_cleanup_blocked_plan_fingerprint(reason: &str) -> String { + format!("blocked:{}", sha256_hex(reason.as_bytes())) +} + +fn update_editor_image_asset_kind_dialog_source_hash( + hasher: &mut Sha256, + dialog_key: &str, + dialog_json: &str, +) { + for value in [dialog_key, dialog_json] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } +} + fn clean_editor_image_asset_kind_assets( ctx: &ReducerContext, input: &EditorImageAssetKindCleanupInput, @@ -12938,7 +12960,7 @@ fn clean_editor_image_asset_kind_project_resources( row.resource_id.as_str(), message.as_str(), )); - "blocked".to_string() + editor_image_asset_kind_cleanup_blocked_plan_fingerprint(message.as_str()) } }; update_editor_image_asset_kind_cleanup_hash( @@ -13117,6 +13139,46 @@ fn clean_legacy_image_asset_kind_from_layer_item_json( Ok((cleaned, cleaned_field_count)) } +fn clean_legacy_image_asset_kind_from_dialog_json( + dialog_json: &str, +) -> Result<(String, u32), String> { + if !layout_may_contain_legacy_image_asset_kind(dialog_json) { + return Ok((dialog_json.to_string(), 0)); + } + let mut value = serde_json::from_str::(dialog_json) + .map_err(|_| "结构化画布 generation-dialog dialog_json 不是合法 JSON".to_string())?; + let object = value + .as_object_mut() + .ok_or_else(|| "结构化画布 generation-dialog dialog_json 必须是对象".to_string())?; + let cleaned_field_count = clean_legacy_image_asset_kind_from_layer_object(object, false)?; + if cleaned_field_count == 0 { + return Ok((dialog_json.to_string(), 0)); + } + let cleaned = serde_json::to_string(&value) + .map_err(|_| "普通图片 asset_kind 清理后的 generation-dialog 无法序列化".to_string())?; + if cleaned.len() > EDITOR_CANVAS_STRUCTURED_ITEM_MAX_JSON_BYTES { + return Err("普通图片 asset_kind 清理后的 generation-dialog 超过 512 KiB".to_string()); + } + Ok((cleaned, cleaned_field_count)) +} + +fn plan_editor_image_asset_kind_dialog_patch( + row: &EditorCanvasGenerationDialog, +) -> Result, String> { + let (dialog_json, cleaned_field_count) = + clean_legacy_image_asset_kind_from_dialog_json(row.dialog_json.as_str())?; + if cleaned_field_count == 0 { + return Ok(None); + } + Ok(Some(( + EditorImageAssetKindDialogPatch { + dialog_key: row.dialog_key.clone(), + dialog_json, + }, + 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 @@ -13312,6 +13374,21 @@ fn verify_editor_image_asset_kind_cleanup_migration_state_allowing_prior_resourc ) } +fn require_editor_image_asset_kind_project_cleanup_migration( + canvas: &EditorCanvas, + migration: Option, +) -> Result, String> { + if migration.is_none() + && canvas.layout_storage_version == EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED + { + return Err(format!( + "project-resource 清理前发现 structured 图片画布缺少迁移记录(canvasSha256={})", + sha256_hex(canvas.canvas_id.as_bytes()) + )); + } + Ok(migration) +} + fn verify_editor_image_asset_kind_project_migrations_before_resource_cleanup( ctx: &ReducerContext, project_id: &str, @@ -13330,11 +13407,13 @@ fn verify_editor_image_asset_kind_project_migrations_before_resource_cleanup( 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 + let migration = ctx .db .editor_canvas_layout_migration() .canvas_id() - .find(&canvas.canvas_id) + .find(&canvas.canvas_id); + let Some(migration) = + require_editor_image_asset_kind_project_cleanup_migration(&canvas, migration)? else { continue; }; @@ -13462,6 +13541,13 @@ fn plan_editor_image_asset_kind_canvas( .filter(&canvas.canvas_id) .collect::>(); layer_rows.sort_by(|left, right| left.layer_key.cmp(&right.layer_key)); + let mut dialog_rows = ctx + .db + .editor_canvas_generation_dialog() + .by_editor_canvas_generation_dialog_canvas_id() + .filter(&canvas.canvas_id) + .collect::>(); + dialog_rows.sort_by(|left, right| left.dialog_key.cmp(&right.dialog_key)); let migration = ctx .db .editor_canvas_layout_migration() @@ -13494,6 +13580,13 @@ fn plan_editor_image_asset_kind_canvas( source_hasher.update([0]); } } + for row in &dialog_rows { + update_editor_image_asset_kind_dialog_source_hash( + &mut source_hasher, + row.dialog_key.as_str(), + row.dialog_json.as_str(), + ); + } if let Some(migration) = migration.as_ref() { for value in [ migration.canvas_id.as_str(), @@ -13525,6 +13618,7 @@ fn plan_editor_image_asset_kind_canvas( .map(|row| row.layers_json.clone()) .unwrap_or_default(), layer_patches: Vec::new(), + dialog_patches: Vec::new(), source_fingerprint, cleaned_field_count: 0, changed: false, @@ -13541,6 +13635,7 @@ fn plan_editor_image_asset_kind_canvas( canvas_layers_json, project_layers_json: project.layers_json.clone(), layer_patches: Vec::new(), + dialog_patches: Vec::new(), source_fingerprint, cleaned_field_count: canvas_cleaned_count, changed: canvas_cleaned_count > 0, @@ -13565,6 +13660,7 @@ fn plan_editor_image_asset_kind_canvas( canvas_layers_json, project_layers_json, layer_patches, + dialog_patches: Vec::new(), source_fingerprint, cleaned_field_count: canvas_cleaned_count .saturating_add(project_cleaned_count) @@ -13586,14 +13682,44 @@ fn plan_editor_image_asset_kind_canvas( }); } + let mut dialog_patches = Vec::new(); + let mut dialog_cleaned_count = 0u32; + for row in &dialog_rows { + let planned_patch = match plan_editor_image_asset_kind_dialog_patch(row) { + Ok(result) => result, + Err(message) => { + return EditorImageAssetKindCanvasPlan { + canvas_layers_json, + project_layers_json, + layer_patches, + dialog_patches, + source_fingerprint, + cleaned_field_count: canvas_cleaned_count + .saturating_add(project_cleaned_count) + .saturating_add(layer_cleaned_count) + .saturating_add(dialog_cleaned_count), + changed: true, + blocker: Some(message), + }; + } + }; + let Some((patch, cleaned_field_count)) = planned_patch else { + continue; + }; + dialog_cleaned_count = dialog_cleaned_count.saturating_add(cleaned_field_count); + dialog_patches.push(patch); + } + let cleaned_field_count = canvas_cleaned_count .saturating_add(project_cleaned_count) - .saturating_add(layer_cleaned_count); + .saturating_add(layer_cleaned_count) + .saturating_add(dialog_cleaned_count); if cleaned_field_count == 0 { return EditorImageAssetKindCanvasPlan { canvas_layers_json, project_layers_json, layer_patches, + dialog_patches, source_fingerprint, cleaned_field_count, changed: false, @@ -13645,6 +13771,7 @@ fn plan_editor_image_asset_kind_canvas( canvas_layers_json, project_layers_json, layer_patches, + dialog_patches, source_fingerprint, cleaned_field_count, changed: true, @@ -13706,6 +13833,25 @@ fn apply_editor_image_asset_kind_canvas_plan( row.item_json = patch.item_json; ctx.db.editor_canvas_layer().layer_key().update(row); } + for patch in plan.dialog_patches { + let mut row = ctx + .db + .editor_canvas_generation_dialog() + .dialog_key() + .find(&patch.dialog_key) + .ok_or_else(|| "待清理的结构化画布 generation-dialog 不存在".to_string())?; + if row.canvas_id != canvas.canvas_id + || row.project_id != canvas.project_id + || row.owner_user_id != canvas.owner_user_id + { + return Err("待清理的结构化画布 generation-dialog 与画布身份不一致".to_string()); + } + row.dialog_json = patch.dialog_json; + ctx.db + .editor_canvas_generation_dialog() + .dialog_key() + .update(row); + } if let Some(migration) = migration { let project = ctx .db @@ -20384,6 +20530,104 @@ mod tests { assert!(sample.contains("布局不一致")); } + #[test] + fn ordinary_image_cleanup_dialog_json_is_bound_to_batch_source_fingerprint() { + let mut before = Sha256::new(); + update_editor_image_asset_kind_dialog_source_hash( + &mut before, + "dialog-key", + r#"{"assetKind":"image"}"#, + ); + let mut after = Sha256::new(); + update_editor_image_asset_kind_dialog_source_hash(&mut after, "dialog-key", r#"{}"#); + + assert_ne!( + format!("{:x}", before.finalize()), + format!("{:x}", after.finalize()) + ); + } + + #[test] + fn ordinary_image_project_resource_cleanup_rejects_structured_canvas_without_migration() { + let structured = + editor_image_cleanup_canvas("[]", 7, EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED); + let error = + match require_editor_image_asset_kind_project_cleanup_migration(&structured, None) { + Ok(_) => { + panic!("structured canvas without migration must block before resource writes") + } + Err(error) => error, + }; + assert!(error.contains("缺少迁移记录")); + assert!(!error.contains(structured.canvas_id.as_str())); + + let blocked_fingerprint = + editor_image_asset_kind_cleanup_blocked_plan_fingerprint(error.as_str()); + assert!(blocked_fingerprint.starts_with("blocked:")); + assert_ne!( + blocked_fingerprint, + editor_image_asset_kind_cleanup_blocked_plan_fingerprint("其它 blocker"), + "the exact missing-migration state must be bound into the dry-run/apply batch hash" + ); + + let legacy = editor_image_cleanup_canvas("[]", 7, 0); + assert!( + require_editor_image_asset_kind_project_cleanup_migration(&legacy, None) + .expect("legacy version 0 may have no migration") + .is_none() + ); + } + + #[test] + fn ordinary_image_cleanup_removes_generation_dialog_authoritative_extension() { + let source = json!([{ + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "assetKind": "image", + "panelState": {"collapsed": true}, + "dialog": { + "id": "dialog-1", + "mode": "generate", + "status": "idle", + "prompt": "角色" + } + }]) + .to_string(); + let mut structured = + parse_structured_canvas_layout(source.as_str()).expect("structured dialog layout"); + assert!( + structured.dialogs[0] + .dialog_json + .contains("\"assetKind\":\"image\"") + ); + + let dialog_row = + editor_image_cleanup_dialog_row(structured.dialogs[0].dialog_json.as_str()); + let (dialog_patch, cleaned_count) = plan_editor_image_asset_kind_dialog_patch(&dialog_row) + .expect("dialog extension cleanup") + .expect("dialog patch"); + assert_eq!(cleaned_count, 1); + assert_eq!(dialog_patch.dialog_key, dialog_row.dialog_key); + structured.dialogs[0].dialog_json = dialog_patch.dialog_json; + let rebuilt = + serialize_structured_canvas_layout(&structured).expect("rebuilt structured layout"); + let rebuilt_value: JsonValue = serde_json::from_str(&rebuilt).expect("rebuilt JSON"); + + assert!(rebuilt_value[0].get("assetKind").is_none()); + assert_eq!(rebuilt_value[0]["panelState"]["collapsed"], json!(true)); + assert_eq!(rebuilt_value[0]["dialog"]["prompt"], json!("角色")); + assert_eq!( + clean_legacy_image_asset_kind_from_dialog_json( + structured.dialogs[0].dialog_json.as_str() + ) + .expect("second scan") + .1, + 0, + "the authoritative dialog row must be zero-residual after patch" + ); + } + #[test] fn ordinary_image_asset_kind_write_boundary_normalizes_only_exact_legacy_value() { assert_eq!( @@ -20497,6 +20741,31 @@ mod tests { } } + fn editor_image_cleanup_dialog_row(dialog_json: &str) -> EditorCanvasGenerationDialog { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + EditorCanvasGenerationDialog { + dialog_key: "dialog-key".to_string(), + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + dialog_id: "dialog-1".to_string(), + layer_id: "generation-dialog:dialog-1".to_string(), + resource_id: "generation-dialog:dialog-1".to_string(), + sort_order: 0, + mode: "generate".to_string(), + status: "idle".to_string(), + source_layer_id: None, + generated_layer_id: None, + placeholder_x: None, + placeholder_y: None, + placeholder_width: None, + placeholder_height: None, + dialog_json: dialog_json.to_string(), + created_at: now, + updated_at: now, + } + } + fn editor_image_cleanup_migration( status: &str, source_layout_json: &str, @@ -20841,4 +21110,96 @@ mod tests { ) .expect("cleaned saved canvas should roll back and pass reentry verification"); } + + #[test] + fn ordinary_image_cleanup_keeps_active_generation_dialog_migration_valid() { + let original = json!([{ + "itemType": "generation-dialog", + "layerId": "generation-dialog:dialog-1", + "resourceId": "generation-dialog:dialog-1", + "assetKind": "image", + "panelState": {"collapsed": true}, + "dialog": { + "id": "dialog-1", + "mode": "generate", + "status": "idle", + "prompt": "角色" + } + }]) + .to_string(); + let resource_asset_kinds = + BTreeMap::from([("generation-dialog:dialog-1".to_string(), None)]); + let mut canvas = editor_image_cleanup_canvas( + original.as_str(), + 7, + EDITOR_CANVAS_LAYOUT_STORAGE_VERSION_STRUCTURED, + ); + let mut project = editor_image_cleanup_project(original.as_str()); + let migration = editor_image_cleanup_migration( + EDITOR_CANVAS_LAYOUT_MIGRATION_STATUS_ACTIVE, + original.as_str(), + original.as_str(), + 7, + &resource_asset_kinds, + ); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&migration), + Some(original.as_str()), + &resource_asset_kinds, + ) + .expect("historical active credential must be valid before cleanup"); + + let (cleaned_legacy, legacy_cleaned_count) = + clean_legacy_image_asset_kind_from_layout(original.as_str()).expect("legacy cleanup"); + assert_eq!(legacy_cleaned_count, 1); + canvas.layers_json = cleaned_legacy.clone(); + project.layers_json = cleaned_legacy; + + let mut structured = + parse_structured_canvas_layout(original.as_str()).expect("structured dialog layout"); + let dialog_row = + editor_image_cleanup_dialog_row(structured.dialogs[0].dialog_json.as_str()); + let (dialog_patch, dialog_cleaned_count) = + plan_editor_image_asset_kind_dialog_patch(&dialog_row) + .expect("authoritative dialog cleanup") + .expect("authoritative dialog patch"); + assert_eq!(dialog_cleaned_count, 1); + structured.dialogs[0].dialog_json = dialog_patch.dialog_json; + let cleaned_structured = + serialize_structured_canvas_layout(&structured).expect("rebuilt structured layout"); + assert_eq!( + clean_legacy_image_asset_kind_from_layout(cleaned_structured.as_str()) + .expect("zero-residual scan") + .1, + 0 + ); + + let integrity = + canvas_layout_integrity(cleaned_structured.as_str()).expect("cleaned integrity"); + let refreshed = refresh_editor_image_asset_kind_layout_migration( + migration, + canonical_layout_sha256_with_resource_asset_kinds( + canvas.layers_json.as_str(), + &resource_asset_kinds, + ) + .expect("cleaned source hash"), + canonical_layout_sha256_with_resource_asset_kinds( + cleaned_structured.as_str(), + &resource_asset_kinds, + ) + .expect("cleaned structured hash"), + canvas.revision, + &integrity, + ); + verify_editor_image_asset_kind_cleanup_migration_state( + &canvas, + &project, + Some(&refreshed), + Some(cleaned_structured.as_str()), + &resource_asset_kinds, + ) + .expect("active structured generation-dialog must remain saveable after cleanup"); + } }