修复生成候选被旧保存覆盖

为生成账本增加候选层首次确认修订记录

未确认候选在普通草稿保存中被后端合并保留

恢复画布时从私有账本重建未确认候选层

前端水合后同步运行时引用并立即确认保存
This commit is contained in:
2026-08-22 16:41:25 +08:00
parent efc275f304
commit 44fd29a251
4 changed files with 287 additions and 5 deletions
@@ -1560,7 +1560,12 @@ pub(crate) fn update_asset_canvas_draft_at(
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.status = input.status.clone();
draft.canvas = input.canvas.clone();
draft.canvas = protect_candidate_layers_before_frontend_update_at(
root,
&draft,
draft.revision,
&input.canvas,
)?;
// Generation lifecycle facts remain backend-authoritative, while the card
// placement is ordinary canvas state and may be changed by the user.
// Merge only placeholders so an older autosave cannot erase phase, output,
@@ -358,6 +358,8 @@ struct AssetCanvasGenerationLedger {
candidate_media_ref: Option<AssetCanvasMediaRef>,
#[serde(default)]
candidate_layer_id: Option<String>,
#[serde(default)]
candidate_layer_acknowledged_revision: Option<u64>,
commit_result: Option<PrivateCommitResult>,
error_code: Option<String>,
#[serde(default)]
@@ -1397,6 +1399,7 @@ fn persist_generation_candidate_at(
ledger.current_draft_revision = Some(draft.revision);
ledger.candidate_media_ref = Some(media_ref.clone());
ledger.candidate_layer_id = Some(layer_id);
ledger.candidate_layer_acknowledged_revision = None;
write_generation_ledger(root, ledger)?;
Ok((
record,
@@ -1408,6 +1411,171 @@ fn persist_generation_candidate_at(
))
}
pub(crate) fn protect_candidate_layers_before_frontend_update_at(
root: &Path,
current_draft: &AssetCanvasDraft,
next_revision: u64,
incoming_canvas: &AssetCanvasState,
) -> Result<AssetCanvasState, String> {
let mut merged_canvas = incoming_canvas.clone();
for record in &current_draft.generations {
if record.phase != AssetCanvasGenerationStatus::CandidateReady {
continue;
}
let Some(mut ledger) = read_generation_ledger(root, &record.generation_id)? else {
continue;
};
if ledger.project_id != current_draft.project_id
|| ledger.draft_id != current_draft.draft_id
|| ledger.generation_id != record.generation_id
|| ledger.candidate_layer_acknowledged_revision.is_some()
{
continue;
}
let Some(layer_id) = ledger.candidate_layer_id.clone() else {
continue;
};
let Some(candidate_layer) = current_draft
.canvas
.layers
.iter()
.find(|layer| layer.layer_id == layer_id)
else {
continue;
};
if merged_canvas
.layers
.iter()
.any(|layer| layer.layer_id == layer_id)
{
ledger.candidate_layer_acknowledged_revision = Some(next_revision);
write_generation_ledger(root, &mut ledger)?;
} else {
merged_canvas.layers.push(candidate_layer.clone());
}
}
Ok(merged_canvas)
}
pub(crate) fn restore_unacknowledged_candidate_layers_at(
root: &Path,
expected_project_id: &str,
draft_id: &str,
) -> Result<Option<AssetCanvasDraft>, String> {
validate_asset_canvas_project_identity(root, expected_project_id)?;
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?;
let mut draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
if draft.project_id != manifest.project_id || draft.draft_id != draft_id {
return Err("素材画布候选恢复身份不一致".to_string());
}
let mut changed = false;
for record in &draft.generations {
if record.phase != AssetCanvasGenerationStatus::CandidateReady {
continue;
}
let Some(ledger) = read_generation_ledger(root, &record.generation_id)? else {
continue;
};
if ledger.project_id != draft.project_id
|| ledger.draft_id != draft.draft_id
|| ledger.generation_id != record.generation_id
|| ledger.candidate_layer_acknowledged_revision.is_some()
{
continue;
}
let Some(layer_id) = ledger.candidate_layer_id.as_deref() else {
continue;
};
if draft
.canvas
.layers
.iter()
.any(|layer| layer.layer_id == layer_id)
{
continue;
}
let AssetCanvasMediaRef::DraftMedia {
media_id,
pixel_width,
pixel_height,
..
} = ledger
.candidate_media_ref
.as_ref()
.ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())?
else {
continue;
};
let (x, y, width, height) = ledger
.placeholder
.as_ref()
.map(|placeholder| {
(
placeholder.x,
placeholder.y,
placeholder.width,
placeholder.height,
)
})
.unwrap_or_else(|| {
(
6000.0 - f64::from(*pixel_width) / 2.0,
6000.0 - f64::from(*pixel_height) / 2.0,
f64::from(*pixel_width),
f64::from(*pixel_height),
)
});
let z_index = draft
.canvas
.layers
.iter()
.map(|layer| layer.z_index)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| "生成候选图层层序已达到上限".to_string())?;
draft.canvas.layers.push(AssetCanvasLayer {
layer_id: layer_id.to_string(),
resource_id: format!("draft-media:{media_id}"),
title: format!("{} 候选", ledger.asset_name),
media_ref: ledger
.candidate_media_ref
.clone()
.ok_or_else(|| "候选生成账本缺少候选媒体引用".to_string())?,
x,
y,
width,
height,
original_width: f64::from(*pixel_width),
original_height: f64::from(*pixel_height),
z_index,
group_id: None,
hidden: false,
locked: false,
flip_x: false,
flip_y: false,
});
draft.canvas.selected_layer_ids = vec![layer_id.to_string()];
draft.canvas.primary_selected_layer_id = Some(layer_id.to_string());
changed = true;
}
if !changed {
return Ok(None);
}
draft.revision = draft
.revision
.checked_add(1)
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
Ok(Some(draft))
}
fn normalized_request_fingerprint(
input: &GenerateAssetCanvasImageInput,
manifest: &GameCreationAppManifest,
@@ -1633,6 +1801,7 @@ fn validate_and_prepare_ledger(
staged_draft_revision: None,
candidate_media_ref: None,
candidate_layer_id: None,
candidate_layer_acknowledged_revision: None,
commit_result: None,
error_code: None,
archived_at: None,
@@ -3444,6 +3613,7 @@ pub(crate) async fn recover_asset_canvas_generations_at(
events: Vec::new(),
});
}
restore_unacknowledged_candidate_layers_at(root, &input.expected_project_id, &input.draft_id)?;
let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None)
.map_err(|_| sanitized_generation_error("configuration-missing"))?;
let api_mode = CanvasGenerationApiMode { api_key };
@@ -3904,6 +4074,7 @@ mod tests {
staged_draft_revision: None,
candidate_media_ref: None,
candidate_layer_id: None,
candidate_layer_acknowledged_revision: None,
commit_result: None,
error_code: None,
archived_at: None,
@@ -4046,15 +4217,111 @@ mod tests {
assert_eq!(candidate_layer.width, placeholder.width);
assert_eq!(candidate_layer.height, placeholder.height);
// Simulate the observed stale-autosave failure: the authoritative draft
// loses an unacknowledged candidate, then recovery restores it from the
// private ledger before the canvas is hydrated again.
let mut lost_draft = updated.clone();
lost_draft
.canvas
.layers
.retain(|layer| layer.layer_id != candidate_layer_id);
lost_draft.canvas.selected_layer_ids = vec![source_layer_id.clone()];
lost_draft.canvas.primary_selected_layer_id = Some(source_layer_id.clone());
write_asset_canvas_draft_locked(directory.path(), &lost_draft)
.expect("write draft without candidate");
let restored = restore_unacknowledged_candidate_layers_at(
directory.path(),
project_id,
&draft.draft_id,
)
.expect("restore unacknowledged candidate")
.expect("candidate restored");
assert!(restored
.canvas
.layers
.iter()
.any(|layer| layer.layer_id == candidate_layer_id));
let mut stale_canvas = restored.canvas.clone();
stale_canvas
.layers
.retain(|layer| layer.layer_id != candidate_layer_id);
stale_canvas.selected_layer_ids = vec![source_layer_id.clone()];
stale_canvas.primary_selected_layer_id = Some(source_layer_id.clone());
let protected = update_asset_canvas_draft_at(
directory.path(),
&UpdateAssetCanvasDraftInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
expected_draft_revision: restored.revision,
status: AssetCanvasDraftStatus::Editing,
canvas: stale_canvas,
generations: restored.generations.clone(),
},
)
.expect("protect stale autosave")
.draft;
assert!(protected
.canvas
.layers
.iter()
.any(|layer| layer.layer_id == candidate_layer_id));
let acknowledged = update_asset_canvas_draft_at(
directory.path(),
&UpdateAssetCanvasDraftInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
expected_draft_revision: protected.revision,
status: AssetCanvasDraftStatus::Editing,
canvas: protected.canvas.clone(),
generations: protected.generations.clone(),
},
)
.expect("acknowledge candidate")
.draft;
let acknowledged_ledger = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read acknowledged ledger")
.expect("acknowledged ledger retained");
assert_eq!(
acknowledged_ledger.candidate_layer_acknowledged_revision,
Some(acknowledged.revision)
);
let mut intentional_delete_canvas = acknowledged.canvas.clone();
intentional_delete_canvas
.layers
.retain(|layer| layer.layer_id != candidate_layer_id);
intentional_delete_canvas.selected_layer_ids = vec![source_layer_id.clone()];
intentional_delete_canvas.primary_selected_layer_id = Some(source_layer_id.clone());
let after_delete = update_asset_canvas_draft_at(
directory.path(),
&UpdateAssetCanvasDraftInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
expected_draft_revision: acknowledged.revision,
status: AssetCanvasDraftStatus::Editing,
canvas: intentional_delete_canvas,
generations: acknowledged.generations.clone(),
},
)
.expect("allow intentional candidate deletion after acknowledgement")
.draft;
assert!(!after_delete
.canvas
.layers
.iter()
.any(|layer| layer.layer_id == candidate_layer_id));
let recovered_ledger = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read ledger after restart")
.expect("placement ledger retained");
assert_eq!(recovered_ledger.source_layer_id, Some(source_layer_id));
assert_eq!(recovered_ledger.placeholder, Some(placeholder.clone()));
let recovered_draft =
read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id)
.expect("read draft after restart")
.expect("draft retained after restart");
let recovered_draft = after_delete;
let recovered_record = recovered_draft
.generations
.iter()
@@ -5824,6 +6091,7 @@ mod tests {
staged_draft_revision: None,
candidate_media_ref: None,
candidate_layer_id: None,
candidate_layer_acknowledged_revision: None,
commit_result: None,
error_code: None,
archived_at: None,
@@ -1056,6 +1056,10 @@ export function AssetCanvasSurface({
if (epoch !== epochRef.current) return;
minimumDraftRevisionRef.current = nextDraft.revision;
draftRef.current = nextDraft;
layersRef.current = runtimeLayers;
viewportRef.current = nextDraft.canvas.viewport;
backgroundRef.current = nextDraft.canvas.backgroundColor;
selectionRef.current = nextDraft.canvas.selectedLayerIds;
setDraft(nextDraft);
setGenerationTasks(
nextDraft.generations.map(runtimeGenerationTaskFromRecord),
@@ -2463,6 +2467,10 @@ export function AssetCanvasSurface({
URL.revokeObjectURL(image.previewUrl),
);
await hydrateDraft(result.value.draft, epoch);
// Acknowledge the authoritative candidate with a canvas save. Until
// this first save succeeds, the backend keeps merging the new layer
// back into stale autosaves that were queued while generation ran.
if (!(await persistDraft())) return;
pendingGenerationRef.current = null;
persistedDocumentVersionRef.current = documentVersionRef.current;
setLifecycle({ kind: 'canvas.editing', dirty: false });
@@ -910,6 +910,7 @@ cancelling
| A34 | 同资源并发正式提交 | 同一 project/draft/asset 存在 prepared 或 reconciliation 事务时再次提交 | 拒绝新提交并要求先安全恢复;已 committed/rolled-back/superseded 事务不阻塞后续提交 |
| A35 | 精修文件名包含历史提交后缀 | 后续精修重新打开当前 `localPath`,或再次生成 / 设为最终图 | 统一剥离文件名末尾一个或多个 `--<uuid>` 后缀并规范化为合法 1..=80 字符显示名;生成与最终提交使用同一结果 |
| A36 | 确定性提交参数无效 | 候选提交名称或用途在校验阶段失败 | 在读取候选、staging、transaction 或 ledger 写入前零副作用失败;UI 作为输入校验错误允许继续编辑,不触发安全恢复 |
| A37 | 候选首次确认 | 生成完成后与旧 autosave 并发 | 前端先同步 authoritative layers 并保存确认;确认前后端把未确认候选层合回旧保存,重启恢复可从私有 ledger 重建候选层,确认后的显式删除仍允许 |
阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。