补齐普通图片迁移失败关闭边界

将 generation-dialog 权威 JSON 纳入画布清理扫描、批次指纹和原子 patch。

在项目资源清理前阻断缺少 migration 的结构化画布并绑定 blocker 状态。

补充 active 画布、零残留、批次指纹和缺记录门禁回归测试。

同步后端契约、生产运维说明和共享决策记录。
This commit is contained in:
2026-08-11 13:10:01 +08:00
parent 81b5168d38
commit 2ca812b52a
4 changed files with 368 additions and 7 deletions
@@ -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 画布可以缺 migrationstructured 画布缺记录必须在任何资源写入前失败关闭。清行后能保持原 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`
@@ -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`,只有能形成正式图片序列的候选才可用于后续 scopeproject-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,整批拒绝 applyprocedure 同时返回不含生成输入正文的 blocker 完整诊断,包含 scope、ID、原因、owner、project、task、对象身份和来源资源。
- 运维入口固定为 `node scripts/spacetime-normalize-editor-character-actions.mjs --database <database> --server-url <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,整批拒绝 applyproject-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-032026-08-08 增补)
@@ -94,7 +94,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
角色动作正式字段收口使用 `node scripts/spacetime-normalize-editor-character-actions.mjs --database <database> --server-url <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 <database> --server-url <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 <database> --server-url <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 定时发布行为。
@@ -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<EditorImageAssetKindLayerPatch>,
dialog_patches: Vec<EditorImageAssetKindDialogPatch>,
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::<JsonValue>(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<Option<(EditorImageAssetKindDialogPatch, u32)>, 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<BTreeSet<String>, 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<EditorCanvasLayoutMigration>,
) -> Result<Option<EditorCanvasLayoutMigration>, 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::<Vec<_>>();
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::<Vec<_>>();
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");
}
}