diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 71695e2ab..ef8e1e021 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6917,3 +6917,8 @@ - 一致性:`start-tauri-dev.mjs` 是端口选择权威,最终端口通过 Tauri CLI `--config` 覆盖 `build.devUrl`,通过 `GENARRATIVE_AGC_VITE_PORT` 传给 `beforeDevCommand` 和配套后端端口解析器,并通过 Vite CLI `--port` 启动严格监听。父启动器选定端口后,子启动器只允许严格使用同一端口,配套后端漂移必须跳过该预留端口,竞态占用必须失败关闭。 - 安全边界:动态端口不恢复旧 Vite 复用。无法证明 worktree 归属的监听器仍不复用、不主动终止;同用户多 worktree 通过段内漂移并行,不通过共享未知服务并行。 - 验证:公共端口映射、Linux 默认槽位与段内漂移、非 Linux 兼容漂移、Tauri 动态配置、启动前预检、进程树收束、AGC typecheck / 配置门禁、编码检查和差异检查必须通过。 + +## 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 迁移记录时整批失败关闭。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 704f4acb4..8af6317fd 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -680,6 +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。 - 完美像素资源:处理成功时只允许一个最终 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 0987165e6..a7d917664 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、媒体类型、API 或常规 storage 写入语义;完成零残留复核后恢复已修正的应用版本,最后退出维护。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 或媒体类型;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`。 自 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/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index b1fc2bd74..02d43aa43 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -160,6 +160,7 @@ pub(crate) fn editor_provider_reference_limit(model: &str) -> usize { } const EDITOR_CHARACTER_IMAGE_ASSET_KIND: &str = "editor_character_image"; const EDITOR_CHARACTER_ANIMATION_CONTRACT_KIND: &str = "character-animation"; +const EDITOR_LEGACY_IMAGE_ASSET_KIND: &str = "image"; pub(crate) const EDITOR_CHARACTER_IMAGE_ENTITY_KIND: &str = "editor_project"; const EDITOR_CHARACTER_IMAGE_SLOT: &str = "character"; const EDITOR_GENERATED_IMAGE_ASSET_KIND: &str = "editor_generated_image"; @@ -2120,8 +2121,9 @@ pub async fn create_editor_project_resource( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + let asset_kind = normalize_editor_persisted_asset_kind(payload.asset_kind); let generation_inputs_json = serialize_editor_generation_inputs( - payload.asset_kind.as_deref(), + asset_kind.as_deref(), sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let image_sequence_frames_json = @@ -2146,7 +2148,7 @@ pub async fn create_editor_project_resource( provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), source_resource_id: normalize_optional_string(payload.source_resource_id), - asset_kind: normalize_optional_string(payload.asset_kind), + asset_kind, generation_inputs_json, updated_at_micros: current_utc_micros(), image_sequence_frames_json, @@ -2294,8 +2296,9 @@ pub async fn create_editor_asset( Extension(authenticated): Extension, Json(payload): Json, ) -> Result, AppError> { + let asset_kind = normalize_editor_persisted_asset_kind(payload.asset_kind); let generation_inputs_json = serialize_editor_generation_inputs( - payload.asset_kind.as_deref(), + asset_kind.as_deref(), sanitize_editor_untrusted_generation_inputs(payload.generation_inputs.clone()), )?; let image_sequence_frames_json = @@ -2320,7 +2323,7 @@ pub async fn create_editor_asset( model: normalize_optional_string(payload.model), provider: normalize_optional_string(payload.provider), task_id: normalize_optional_string(payload.task_id), - asset_kind: normalize_optional_string(payload.asset_kind), + asset_kind, generation_inputs_json, source_resource_id: None, now_micros: current_utc_micros(), @@ -11137,6 +11140,10 @@ pub(crate) fn normalize_optional_string(value: Option) -> Option .filter(|item| !item.is_empty()) } +fn normalize_editor_persisted_asset_kind(value: Option) -> Option { + normalize_optional_string(value).filter(|value| value != EDITOR_LEGACY_IMAGE_ASSET_KIND) +} + pub(crate) fn resolve_editor_generated_asset_label( value: Option, fallback: &str, @@ -14897,6 +14904,22 @@ mod tests { } } + #[test] + fn ordinary_image_asset_kind_write_boundary_normalizes_only_exact_legacy_value() { + assert_eq!( + normalize_editor_persisted_asset_kind(Some(" image ".to_string())), + None + ); + assert_eq!( + normalize_editor_persisted_asset_kind(Some("scene".to_string())).as_deref(), + Some("scene") + ); + assert_eq!( + normalize_editor_persisted_asset_kind(Some("Image".to_string())).as_deref(), + Some("Image") + ); + } + #[test] fn editor_sequence_frame_serialization_rejects_frame_index() { let error = serialize_editor_image_sequence_frames(Some(json!([ 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 cbb36a7c1..a6ba240f5 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -2636,7 +2636,7 @@ fn prepare_editor_project_resource( let model = normalize_optional(input.model); let provider = normalize_optional(input.provider); let task_id = normalize_optional(input.task_id); - let asset_kind = normalize_optional(input.asset_kind); + let asset_kind = normalize_editor_persisted_asset_kind(input.asset_kind); let generation_inputs_json = normalize_optional(input.generation_inputs_json); let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json); let image_sequence_duration_ms = input.image_sequence_duration_ms; @@ -5474,7 +5474,7 @@ fn create_editor_asset( if group_task_id.is_some() { require_editor_generation_runtime_service_identity(ctx, caller)?; } - let asset_kind = normalize_optional(input.asset_kind); + let asset_kind = normalize_editor_persisted_asset_kind(input.asset_kind); let generation_inputs_json = normalize_optional(input.generation_inputs_json); let image_sequence_frames_json = normalize_optional(input.image_sequence_frames_json); validate_editor_generation_inputs(asset_kind.as_deref(), generation_inputs_json.as_deref())?; @@ -8400,6 +8400,10 @@ fn normalize_optional(value: Option) -> Option { .filter(|item| !item.is_empty()) } +fn normalize_editor_persisted_asset_kind(value: Option) -> Option { + normalize_optional(value).filter(|value| value != EDITOR_LEGACY_IMAGE_ASSET_KIND) +} + fn normalize_title(value: &str) -> String { let title = value.trim(); if title.is_empty() { @@ -10062,6 +10066,22 @@ fn verify_migration_layout( Ok(()) } +fn refresh_editor_image_asset_kind_layout_migration( + mut migration: EditorCanvasLayoutMigration, + source_layout_sha256: String, + structured_layout_sha256: String, + verified_revision: u64, + integrity: &CanvasLayoutIntegrity, +) -> EditorCanvasLayoutMigration { + migration.source_layout_sha256 = source_layout_sha256; + migration.structured_layout_sha256 = structured_layout_sha256; + migration.verified_revision = verified_revision; + migration.layer_count = integrity.layer_count; + migration.dialog_count = integrity.dialog_count; + migration.resource_refs_sha256 = integrity.resource_refs_sha256.clone(); + migration +} + #[derive(Debug, PartialEq, Eq)] struct EditorCanvasResourceLayoutPatch { layers_json: String, @@ -12899,6 +12919,11 @@ 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 migration = ctx + .db + .editor_canvas_layout_migration() + .canvas_id() + .find(&canvas.canvas_id); let mut source_hasher = Sha256::new(); for value in [ @@ -12914,6 +12939,8 @@ fn plan_editor_image_asset_kind_canvas( source_hasher.update(value.as_bytes()); source_hasher.update([0]); } + source_hasher.update(canvas.revision.to_le_bytes()); + source_hasher.update(canvas.layout_storage_version.to_le_bytes()); for row in &layer_rows { for value in [ row.layer_key.as_str(), @@ -12924,6 +12951,24 @@ fn plan_editor_image_asset_kind_canvas( source_hasher.update([0]); } } + if let Some(migration) = migration.as_ref() { + for value in [ + migration.canvas_id.as_str(), + migration.project_id.as_str(), + migration.owner_user_id.as_str(), + migration.source_layout_sha256.as_str(), + migration.structured_layout_sha256.as_str(), + migration.resource_refs_sha256.as_str(), + migration.status.as_str(), + ] { + source_hasher.update(value.as_bytes()); + source_hasher.update([0]); + } + source_hasher.update(migration.verified_revision.to_le_bytes()); + source_hasher.update(migration.layer_count.to_le_bytes()); + source_hasher.update(migration.dialog_count.to_le_bytes()); + source_hasher.update(migration.migration_version.to_le_bytes()); + } let source_fingerprint = format!("{:x}", source_hasher.finalize()); let (canvas_layers_json, canvas_cleaned_count) = @@ -13012,7 +13057,7 @@ fn plan_editor_image_asset_kind_canvas( blocker: None, }; } - let blocker = match project.as_ref() { + let mut blocker = match project.as_ref() { None => Some("命中普通图片旧值的画布缺少对应 editor_project".to_string()), Some(project) if project.owner_user_id != canvas.owner_user_id => { Some("命中普通图片旧值的 editor_canvas 与 editor_project owner 不一致".to_string()) @@ -13028,6 +13073,23 @@ 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()) + } + Some(migration) + if migration.project_id != canvas.project_id + || migration.owner_user_id != canvas.owner_user_id => + { + Some("结构化图片画布与迁移记录的工程或 owner 不一致".to_string()) + } + Some(_) => None, + }; + } EditorImageAssetKindCanvasPlan { canvas_layers_json, project_layers_json, @@ -13044,6 +13106,20 @@ fn apply_editor_image_asset_kind_canvas_plan( mut canvas: EditorCanvas, plan: EditorImageAssetKindCanvasPlan, ) -> Result<(), String> { + let migration = ctx + .db + .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() @@ -13052,7 +13128,7 @@ fn apply_editor_image_asset_kind_canvas_plan( .ok_or_else(|| "图片画布对应的工程不存在".to_string())?; if canvas.layers_json != plan.canvas_layers_json { canvas.layers_json = plan.canvas_layers_json; - ctx.db.editor_canvas().canvas_id().update(canvas); + ctx.db.editor_canvas().canvas_id().update(canvas.clone()); } if project.layers_json != plan.project_layers_json { project.layers_json = plan.project_layers_json; @@ -13071,6 +13147,40 @@ fn apply_editor_image_asset_kind_canvas_plan( row.item_json = patch.item_json; ctx.db.editor_canvas_layer().layer_key().update(row); } + if let Some(migration) = migration { + let resource_asset_kinds = project_resource_asset_kinds( + ctx, + canvas.project_id.as_str(), + canvas.owner_user_id.as_str(), + ); + let source_layout_sha256 = canonical_layout_sha256_with_resource_asset_kinds( + canvas.layers_json.as_str(), + &resource_asset_kinds, + )?; + let structured_layout_json = build_structured_canvas_layout_json(ctx, &canvas)?; + let structured_layout_sha256 = canonical_layout_sha256_with_resource_asset_kinds( + structured_layout_json.as_str(), + &resource_asset_kinds, + )?; + let integrity = canvas_layout_integrity(structured_layout_json.as_str())?; + let migration = refresh_editor_image_asset_kind_layout_migration( + migration, + source_layout_sha256, + structured_layout_sha256.clone(), + canvas.revision, + &integrity, + ); + verify_migration_layout( + &migration, + canvas.revision, + structured_layout_sha256.as_str(), + &integrity, + )?; + ctx.db + .editor_canvas_layout_migration() + .canvas_id() + .update(migration); + } Ok(()) } @@ -19707,4 +19817,97 @@ mod tests { assert!(sample.contains("idSha256")); assert!(sample.contains("布局不一致")); } + + #[test] + fn ordinary_image_asset_kind_write_boundary_normalizes_only_exact_legacy_value() { + assert_eq!( + normalize_editor_persisted_asset_kind(Some(" image ".to_string())), + None + ); + assert_eq!( + normalize_editor_persisted_asset_kind(Some("scene".to_string())).as_deref(), + Some("scene") + ); + assert_eq!( + normalize_editor_persisted_asset_kind(Some("Image".to_string())).as_deref(), + Some("Image") + ); + } + + #[test] + fn ordinary_image_asset_kind_cleanup_keeps_active_structured_canvas_save_verifiable() { + let now = Timestamp::from_micros_since_unix_epoch(1_000_000); + let activated_at = Timestamp::from_micros_since_unix_epoch(900_000); + let legacy_json = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKind": "image" + }]) + .to_string(); + let structured_json = json!([{ + "layerId": "layer-1", + "resourceId": "resource-1", + "assetKindOverride": "image" + }]) + .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 resource_asset_kinds = BTreeMap::new(); + let source_hash = canonical_layout_sha256_with_resource_asset_kinds( + cleaned_legacy_json.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, + }; + + let refreshed = refresh_editor_image_asset_kind_layout_migration( + migration, + source_hash.clone(), + structured_hash, + 7, + &integrity, + ); + + 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, + 7, + cleaned_structured_json.as_str(), + &resource_asset_kinds, + ) + .expect("cleaned active structured canvas should pass the next save verification"); + } }