修复精修素材稳定运行入口
提交精修后刷新游戏引用的稳定入口字节 记录运行入口并在幂等重放与恢复中修复 补充稳定入口回归测试与技术合同
This commit is contained in:
@@ -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 测试全部通过后,才可宣称图片素材创作正式闭环完成。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user