修复精修素材稳定运行入口

提交精修后刷新游戏引用的稳定入口字节

记录运行入口并在幂等重放与恢复中修复

补充稳定入口回归测试与技术合同
This commit is contained in:
2026-08-22 17:10:24 +08:00
parent 44fd29a251
commit 9c2001b970
3 changed files with 193 additions and 1 deletions
@@ -2579,6 +2579,10 @@ struct AssetCanvasTransactionJournal {
final_image_relative_path: String,
final_image_sha256: String,
final_image_existed_before: bool,
#[serde(default)]
runtime_entry_relative_path: Option<String>,
#[serde(default)]
runtime_entry_refreshed: bool,
manifest_before_sha256: String,
manifest_after_sha256: String,
project_revision_before_sha256: Option<String>,
@@ -2960,6 +2964,143 @@ fn asset_canvas_conflict(
}
}
fn replace_asset_canvas_runtime_entry(
path: &Path,
bytes: &[u8],
label: &str,
) -> Result<(), String> {
let parent = path.parent().ok_or_else(|| format!("{label} 缺少父目录"))?;
fs::create_dir_all(parent).map_err(|_| format!("创建 {label} 目录失败"))?;
if let Ok(metadata) = fs::symlink_metadata(path) {
if metadata.file_type().is_symlink() || !metadata.is_file() {
return Err(format!("{label} 必须是普通文件"));
}
}
let temp_path = path.with_file_name(format!(".asset-canvas-runtime-{}.tmp", Uuid::new_v4()));
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
let mut file = options
.open(&temp_path)
.map_err(|_| format!("创建 {label} 临时文件失败"))?;
if let Err(error) = file.write_all(bytes).and_then(|_| file.sync_all()) {
let _ = fs::remove_file(&temp_path);
return Err(format!("写入 {label} 临时文件失败:{error}"));
}
drop(file);
fs::rename(&temp_path, path).map_err(|error| {
let _ = fs::remove_file(&temp_path);
format!("安装 {label} 失败:{error}")
})?;
let (_, metadata) = open_project_snapshot_regular_file(path, label)?;
if metadata.len() != bytes.len() as u64 {
return Err(format!("{label} 安装后大小不一致"));
}
#[cfg(unix)]
File::open(parent)
.and_then(|directory| directory.sync_all())
.map_err(|error| format!("同步 {label} 目录失败:{error}"))?;
Ok(())
}
fn asset_canvas_runtime_entry_path(
root: &Path,
manifest_before: &GameCreationAppManifest,
asset_id: &str,
) -> Result<Option<String>, String> {
let mut historical = None;
for journal in read_asset_canvas_transaction_journals(root)? {
if journal.asset_id != asset_id {
continue;
}
let Some(path) = journal.runtime_entry_relative_path else {
continue;
};
let is_newer = historical
.as_ref()
.is_none_or(|(_, revision): &(String, u64)| {
journal.target_project_revision > *revision
});
if is_newer {
historical = Some((path, journal.target_project_revision));
}
}
if let Some((path, _)) = historical {
return Ok(Some(path));
}
let Some(source) = manifest_before
.assets
.iter()
.find(|asset| asset.id == asset_id)
else {
return Ok(None);
};
if source.local_path.starts_with("assets/canvas/") {
return Ok(None);
}
let path = resolve_local_project_path(root, &source.local_path)?;
if !path.is_file() {
return Ok(None);
}
Ok(Some(source.local_path.clone()))
}
fn runtime_entry_matches_journal(
root: &Path,
journal: &AssetCanvasTransactionJournal,
) -> Result<bool, String> {
let Some(relative) = journal.runtime_entry_relative_path.as_deref() else {
return Ok(true);
};
let path = resolve_local_project_path(root, relative)?;
if !path.is_file() {
return Ok(false);
}
Ok(open_and_validate_image_file(
&path,
&journal.staged_image.media_type,
Some(&journal.final_image_sha256),
)
.is_ok_and(|(bytes, width, height)| {
bytes.len() as u64 == journal.staged_image.byte_length
&& width == journal.staged_image.pixel_width
&& height == journal.staged_image.pixel_height
}))
}
fn refresh_runtime_entry_for_journal(
root: &Path,
journal: &mut AssetCanvasTransactionJournal,
) -> Result<(), String> {
let Some(relative) = journal.runtime_entry_relative_path.as_deref() else {
return Ok(());
};
if runtime_entry_matches_journal(root, journal)? {
if journal.runtime_entry_refreshed {
return Ok(());
}
journal.runtime_entry_refreshed = true;
journal.updated_at = asset_canvas_now();
return write_asset_canvas_journal(root, journal);
}
let final_path = resolve_local_project_path(root, &journal.final_image_relative_path)?;
let (bytes, _, _) = open_and_validate_image_file(
&final_path,
&journal.staged_image.media_type,
Some(&journal.final_image_sha256),
)?;
let runtime_path = resolve_local_project_path(root, relative)?;
replace_asset_canvas_runtime_entry(&runtime_path, &bytes, "素材运行入口")?;
journal.runtime_entry_refreshed = true;
journal.updated_at = asset_canvas_now();
write_asset_canvas_journal(root, journal)
}
fn verify_committed_asset_canvas_state(
root: &Path,
journal: &AssetCanvasTransactionJournal,
@@ -3233,6 +3374,9 @@ fn commit_asset_canvas_at_internal(
return Err("commitId 或 idempotencyKey 已绑定到不同提交请求".to_string());
}
if ledger.status == AssetCanvasLedgerStatus::Committed {
if let Some(mut journal) = read_asset_canvas_journal(root, &input.commit_id)? {
refresh_runtime_entry_for_journal(root, &mut journal)?;
}
return committed_result_from_ledger(root, ledger, manifest, current_revision.revision);
}
if matches!(
@@ -3376,6 +3520,11 @@ fn commit_asset_canvas_at_internal(
if fs::symlink_metadata(&final_path).is_ok() {
return Err("正式素材目标路径已存在".to_string());
}
let runtime_entry_relative_path = if input.intent == AssetCanvasIntent::Refine {
asset_canvas_runtime_entry_path(root, &manifest_before, &asset_id)?
} else {
None
};
let asset = if input.intent == AssetCanvasIntent::Refine {
let source = manifest
.assets
@@ -3484,6 +3633,8 @@ fn commit_asset_canvas_at_internal(
final_image_relative_path: final_relative_path.clone(),
final_image_sha256: staged.sha256.clone(),
final_image_existed_before: false,
runtime_entry_relative_path: runtime_entry_relative_path.clone(),
runtime_entry_refreshed: false,
manifest_before_sha256: asset_canvas_sha256(&manifest_before_bytes),
manifest_after_sha256: asset_canvas_sha256(&manifest_after_bytes),
project_revision_before_sha256: Some(asset_canvas_sha256(&revision_before_bytes)),
@@ -3576,6 +3727,7 @@ fn commit_asset_canvas_at_internal(
journal.stage = AssetCanvasTransactionStage::Committed;
journal.updated_at = asset_canvas_now();
write_asset_canvas_journal(root, &journal)?;
refresh_runtime_entry_for_journal(root, &mut journal)?;
Ok(CommitAssetCanvasExecution {
result: CommitAssetCanvasResult::Committed {
@@ -4073,6 +4225,7 @@ fn finish_recovered_asset_canvas_commit_locked(
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
}
refresh_runtime_entry_for_journal(root, &mut journal)?;
journal.stage = AssetCanvasTransactionStage::Committed;
journal.updated_at = asset_canvas_now();
write_asset_canvas_journal(root, &journal)?;
@@ -4089,7 +4242,7 @@ fn finish_recovered_asset_canvas_commit_locked(
fn recover_asset_canvas_transaction_locked(
root: &Path,
journal: AssetCanvasTransactionJournal,
mut journal: AssetCanvasTransactionJournal,
) -> Result<(RecoverAssetCanvasOutcome, Option<AssetCanvasCommittedEvent>), String> {
if journal.schema_version != ASSET_CANVAS_TRANSACTION_SCHEMA_VERSION {
return Err("不支持的素材画布 transaction schema".to_string());
@@ -4158,6 +4311,7 @@ fn recover_asset_canvas_transaction_locked(
&& asset_present
&& current_revision.revision >= journal.target_project_revision
{
refresh_runtime_entry_for_journal(root, &mut journal)?;
let mut draft =
read_asset_canvas_draft_locked(root, &journal.project_id, &journal.draft_id)?
.ok_or_else(|| "已提交事务缺少草稿".to_string())?;
@@ -521,7 +521,14 @@ fn refine_preserves_source_and_records_non_destructive_lineage() {
Uuid::new_v4().to_string(),
Uuid::new_v4().to_string(),
);
fs::create_dir_all(fixture.root().join("game")).expect("create game directory");
fs::write(
fixture.root().join("game/game.js"),
"const backgroundImage = '../assets/source.png';\n",
)
.expect("write stable game resource reference");
let execution = commit_asset_canvas_at(fixture.root(), &input).expect("commit refine asset");
assert!(fixture.root().join("game/game.js").is_file());
let committed_asset = match execution.result {
CommitAssetCanvasResult::Committed { asset, .. } => asset,
other => panic!("unexpected refine result: {other:?}"),
@@ -541,6 +548,36 @@ fn refine_preserves_source_and_records_non_destructive_lineage() {
fs::read(fixture.root().join(&source.local_path)).expect("read committed source"),
fixture.png
);
assert_eq!(
fs::read(fixture.root().join("assets/source.png")).expect("read stable runtime entry"),
fixture.png
);
assert_eq!(
fs::read_to_string(fixture.root().join("game/game.js"))
.expect("read stable game resource reference"),
"const backgroundImage = '../assets/source.png';\n"
);
let journal = read_asset_canvas_journal(fixture.root(), &input.commit_id)
.expect("read runtime entry journal")
.expect("runtime entry journal retained");
assert_eq!(
journal.runtime_entry_relative_path.as_deref(),
Some("assets/source.png")
);
assert!(journal.runtime_entry_refreshed);
fs::write(
fixture.root().join("assets/source.png"),
png_bytes([90, 120, 150, 255]),
)
.expect("corrupt stable runtime entry");
recover_asset_canvas_transactions_at(fixture.root(), PROJECT_ID)
.expect("repair stable runtime entry");
assert_eq!(
fs::read(fixture.root().join("assets/source.png"))
.expect("read repaired stable runtime entry"),
fixture.png
);
assert_eq!(
source.source.resource_id.as_deref(),
Some("local-asset:source-asset")
@@ -911,6 +911,7 @@ cancelling
| A35 | 精修文件名包含历史提交后缀 | 后续精修重新打开当前 `localPath`,或再次生成 / 设为最终图 | 统一剥离文件名末尾一个或多个 `--<uuid>` 后缀并规范化为合法 1..=80 字符显示名;生成与最终提交使用同一结果 |
| A36 | 确定性提交参数无效 | 候选提交名称或用途在校验阶段失败 | 在读取候选、staging、transaction 或 ledger 写入前零副作用失败;UI 作为输入校验错误允许继续编辑,不触发安全恢复 |
| A37 | 候选首次确认 | 生成完成后与旧 autosave 并发 | 前端先同步 authoritative layers 并保存确认;确认前后端把未确认候选层合回旧保存,重启恢复可从私有 ledger 重建候选层,确认后的显式删除仍允许 |
| A38 | 稳定运行入口 | 精修替换已在游戏源码中引用的图片 | manifest 指向不可变正式版本,同时原稳定入口路径不变并刷新为新版本字节;幂等重放和事务恢复会修复缺失或不匹配入口,游戏源码不需要改路径 |
阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。