修复普通图片类型迁移一致性
Project CI / Repository checks (pull_request) Successful in 1m16s
Project CI / Frontend tests (pull_request) Successful in 2m57s
Project CI / Backend tests (pull_request) Successful in 4m3s
Project CI / Native shell tests (pull_request) Successful in 13m53s

同步 active 结构化画布迁移摘要与完整性记录,保证清理后可继续保存。

在 API 与 SpacetimeDB 权威写入边界将精确 image 归一为空类型。

补充迁移后保存与写入边界回归测试并更新生产迁移契约。
This commit is contained in:
2026-08-10 17:38:35 +08:00
parent f0319c9e82
commit e3c28a1bca
5 changed files with 241 additions and 9 deletions
@@ -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 迁移记录时整批失败关闭。
File diff suppressed because one or more lines are too long
@@ -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媒体类型、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 <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媒体类型;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 定时发布行为。
@@ -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<AuthenticatedAccessToken>,
Json(payload): Json<EditorProjectResourceCreateRequest>,
) -> Result<Json<Value>, 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<AuthenticatedAccessToken>,
Json(payload): Json<EditorAssetCreateRequest>,
) -> Result<Json<Value>, 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<String>) -> Option<String>
.filter(|item| !item.is_empty())
}
fn normalize_editor_persisted_asset_kind(value: Option<String>) -> Option<String> {
normalize_optional_string(value).filter(|value| value != EDITOR_LEGACY_IMAGE_ASSET_KIND)
}
pub(crate) fn resolve_editor_generated_asset_label(
value: Option<String>,
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!([
@@ -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<String>) -> Option<String> {
.filter(|item| !item.is_empty())
}
fn normalize_editor_persisted_asset_kind(value: Option<String>) -> Option<String> {
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::<Vec<_>>();
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");
}
}