修复失败结算竞态与对账阻断状态
Project CI / Frontend tests (pull_request) Successful in 4m38s
Project CI / Repository checks (pull_request) Successful in 5m53s
Project CI / Backend tests (pull_request) Successful in 6m37s
Project CI / Native shell tests (pull_request) Successful in 16m9s

将失败结算的 revision 校验、公开投影和 staging 更新收敛到同一草稿锁内。

对账失败结算后保持安全恢复阻断态,避免误恢复为可编辑状态。

补充过期 revision 不改写账本及前端对账阻断回归测试。
This commit is contained in:
2026-08-23 18:28:07 +08:00
parent d93fc2fc4b
commit f0ea23978d
4 changed files with 197 additions and 58 deletions
@@ -2221,7 +2221,7 @@ fn stage_asset_canvas_image_with_token_at(
})
}
fn rebind_asset_canvas_staged_image_revision_at(
fn rebind_asset_canvas_staged_image_revision_locked(
root: &Path,
staged_image_token: &str,
expected_project_id: &str,
@@ -2229,11 +2229,6 @@ fn rebind_asset_canvas_staged_image_revision_at(
expected_previous_revision: u64,
next_revision: u64,
) -> Result<(), String> {
validate_uuid_v4(staged_image_token, "stagedImageToken")?;
validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?;
validate_safe_revision(next_revision, "staging 新草稿 revision")?;
validate_asset_canvas_project_identity(root, expected_project_id)?;
let _lock = acquire_asset_canvas_draft_lock(root)?;
let manifest = validate_asset_canvas_project_identity(root, expected_project_id)?;
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
@@ -2260,6 +2255,29 @@ fn rebind_asset_canvas_staged_image_revision_at(
)
}
fn rebind_asset_canvas_staged_image_revision_at(
root: &Path,
staged_image_token: &str,
expected_project_id: &str,
draft_id: &str,
expected_previous_revision: u64,
next_revision: u64,
) -> Result<(), String> {
validate_uuid_v4(staged_image_token, "stagedImageToken")?;
validate_safe_revision(expected_previous_revision, "staging 原草稿 revision")?;
validate_safe_revision(next_revision, "staging 新草稿 revision")?;
validate_asset_canvas_project_identity(root, expected_project_id)?;
let _lock = acquire_asset_canvas_draft_lock(root)?;
rebind_asset_canvas_staged_image_revision_locked(
root,
staged_image_token,
expected_project_id,
draft_id,
expected_previous_revision,
next_revision,
)
}
fn draft_media_relative_path(
draft_id: &str,
media_ref: &AssetCanvasMediaRef,
@@ -1160,13 +1160,12 @@ fn ensure_generation_draft_active(
validate_generation_draft_active(ledger, &draft)
}
fn upsert_public_generation_record(
fn upsert_public_generation_record_locked(
root: &Path,
ledger: &AssetCanvasGenerationLedger,
) -> Result<(AssetCanvasGenerationRecord, u64), String> {
let phase =
public_phase(&ledger.phase).ok_or_else(|| "私有准备态不能投影到公开草稿".to_string())?;
let _lock = acquire_asset_canvas_draft_lock(root)?;
let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
validate_generation_draft_for_public_phase(ledger, &draft)?;
@@ -1234,6 +1233,14 @@ fn upsert_public_generation_record(
Ok((record, draft.revision))
}
fn upsert_public_generation_record(
root: &Path,
ledger: &AssetCanvasGenerationLedger,
) -> Result<(AssetCanvasGenerationRecord, u64), String> {
let _lock = acquire_asset_canvas_draft_lock(root)?;
upsert_public_generation_record_locked(root, ledger)
}
fn publish_public_phase(
root: &Path,
ledger: &mut AssetCanvasGenerationLedger,
@@ -2802,6 +2809,101 @@ fn mark_generation_error(
publish_public_phase(root, ledger, emit).map(|_| ())
}
fn settle_generation_failure_at_expected_revision(
root: &Path,
ledger: &mut AssetCanvasGenerationLedger,
expected_draft_revision: u64,
reconciliation: bool,
error_code: &str,
emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send),
) -> Result<FinalizeAssetCanvasGenerationFailureResult, String> {
let (disposition, record, draft) = {
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
if draft.revision != expected_draft_revision {
return Err("draft-revision-conflict".to_string());
}
if matches!(
ledger.phase,
GenerationLedgerPhase::CandidateReady
| GenerationLedgerPhase::AssetDurableCommitted
| GenerationLedgerPhase::ArchivePending
| GenerationLedgerPhase::Archived
) {
return Ok(FinalizeAssetCanvasGenerationFailureResult {
disposition: "already-terminal".to_string(),
draft,
});
}
let reconciliation =
reconciliation || ledger.phase == GenerationLedgerPhase::ReconciliationRequired;
let target_phase = if reconciliation {
GenerationLedgerPhase::ReconciliationRequired
} else {
GenerationLedgerPhase::Failed
};
let already_settled = ledger.phase == target_phase
&& ledger.error_code.as_deref() == Some(error_code)
&& draft.generations.iter().any(|record| {
record.generation_id == ledger.generation_id
&& record.intent_id == ledger.intent_id
&& record.phase == public_phase(&target_phase).expect("failure phase is public")
&& record.error_code.as_deref() == Some(error_code)
});
if already_settled {
return Ok(FinalizeAssetCanvasGenerationFailureResult {
disposition: if reconciliation {
"reconciliation-required".to_string()
} else {
"failed".to_string()
},
draft,
});
}
validate_generation_draft_active(ledger, &draft)?;
ledger.phase = if reconciliation {
GenerationLedgerPhase::ReconciliationRequired
} else {
GenerationLedgerPhase::Failed
};
ledger.error_code = Some(error_code.to_string());
let previous_staged_revision = ledger.staged_draft_revision;
let (record, draft_revision) = upsert_public_generation_record_locked(root, ledger)?;
ledger.current_draft_revision = Some(draft_revision);
if let Some(previous_revision) = previous_staged_revision {
rebind_asset_canvas_staged_image_revision_locked(
root,
&ledger.staged_image_token,
&ledger.project_id,
&ledger.draft_id,
previous_revision,
draft_revision,
)?;
ledger.staged_draft_revision = Some(draft_revision);
}
write_generation_ledger(root, ledger)?;
draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
(
if reconciliation {
"reconciliation-required".to_string()
} else {
"failed".to_string()
},
record,
draft,
)
};
emit(generation_progress_event(
ledger,
public_phase_name(&record.phase),
public_progress_value(&record.phase),
record.error_code.clone(),
));
Ok(FinalizeAssetCanvasGenerationFailureResult { disposition, draft })
}
fn sanitized_generation_error(code: &str) -> String {
match code {
"configuration-missing" => match editor_api_mode() {
@@ -3670,56 +3772,15 @@ pub(crate) async fn finalize_asset_canvas_generation_failure_at(
{
return Err("生成失败结算对应的账本身份无效".to_string());
}
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
if draft.revision != input.expected_draft_revision {
return Err("draft-revision-conflict".to_string());
}
let terminal_disposition = match ledger.phase {
GenerationLedgerPhase::CandidateReady
| GenerationLedgerPhase::AssetDurableCommitted
| GenerationLedgerPhase::ArchivePending
| GenerationLedgerPhase::Archived => Some("already-terminal".to_string()),
_ => None,
};
if let Some(disposition) = terminal_disposition {
return Ok(FinalizeAssetCanvasGenerationFailureResult { disposition, draft });
}
let reconciliation_required = input.reconciliation_required
|| ledger.phase == GenerationLedgerPhase::ReconciliationRequired;
let target_phase = if reconciliation_required {
GenerationLedgerPhase::ReconciliationRequired
} else {
GenerationLedgerPhase::Failed
};
let already_settled = ledger.phase == target_phase
&& ledger.error_code.as_deref() == Some(input.error_code.as_str())
&& draft.generations.iter().any(|record| {
record.generation_id == input.generation_id
&& record.intent_id == input.intent_id
&& record.phase == public_phase(&target_phase).expect("failure phase is public")
&& record.error_code.as_deref() == Some(input.error_code.as_str())
});
if !already_settled {
let mut emit = |_event: AssetCanvasGenerationProgressEvent| {};
mark_generation_error(
root,
&mut ledger,
reconciliation_required,
&input.error_code,
&mut emit,
)?;
}
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
return Ok(FinalizeAssetCanvasGenerationFailureResult {
disposition: if reconciliation_required {
"reconciliation-required".to_string()
} else {
"failed".to_string()
},
draft,
});
let mut emit = |_event: AssetCanvasGenerationProgressEvent| {};
return settle_generation_failure_at_expected_revision(
root,
&mut ledger,
input.expected_draft_revision,
input.reconciliation_required,
&input.error_code,
&mut emit,
);
}
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
@@ -5151,6 +5212,20 @@ mod tests {
.expect_err("stale failure settlement must fail CAS"),
"draft-revision-conflict"
);
let draft_after_stale_settlement =
read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id)
.expect("read draft after stale failure settlement")
.expect("draft remains after stale failure settlement");
assert_eq!(draft_after_stale_settlement, running_draft);
let ledger_after_stale_settlement =
read_generation_ledger(directory.path(), &input.generation_id)
.expect("read ledger after stale failure settlement")
.expect("ledger remains after stale failure settlement");
assert_eq!(
ledger_after_stale_settlement.phase,
GenerationLedgerPhase::Running
);
assert_eq!(ledger_after_stale_settlement.error_code, None);
let settle = FinalizeAssetCanvasGenerationFailureInput {
expected_draft_revision: running_draft.revision,
@@ -2572,6 +2572,16 @@ export function AssetCanvasSurface({
setNotice(`${message};失败状态保存未完成,请重新打开后对账`);
return false;
}
if (settled.value.disposition === 'reconciliation-required') {
setLifecycle({
kind: 'canvas.failed',
operation: 'recovery',
code,
message,
reconciliationRequired: true,
});
return true;
}
setLifecycle({ kind: 'canvas.editing', dirty: false });
return true;
};
@@ -2900,6 +2900,42 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
),
).toBeTruthy();
});
it('生成失败需要对账时保持安全恢复阻断态', async () => {
const memory = memoryHost({
initialDraft: draftFixture(scope),
generationFailure: {
code: 'reconciliation-required',
message: '远端生成结果需要安全对账',
},
});
renderSurface(memory.host);
await screen.findByText('画布可编辑');
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
fireEvent.change(screen.getByLabelText('图片提示词'), {
target: { value: '需要安全对账的游戏场景' },
});
fireEvent.click(screen.getByRole('button', { name: '继续确认' }));
fireEvent.click(screen.getByRole('button', { name: '确认并生成' }));
expect(
await screen.findByRole('alert', { name: '原任务恢复未完成' }),
).not.toBeNull();
expect(
screen.getAllByText('远端生成结果需要安全对账').length,
).toBeGreaterThan(0);
expect(memory.getDraft()?.generations[0]?.phase).toBe(
'reconciliation-required',
);
const surface = screen.getByRole('region', { name: '素材创作无限画布' });
const viewport = document.querySelector(
'.asset-canvas-surface__viewport',
) as HTMLElement;
expect(surface.getAttribute('data-state')).toBe('canvas.failed');
expect(viewport.hasAttribute('inert')).toBe(true);
expect(screen.queryByRole('button', { name: '返回修改' })).toBeNull();
expect(screen.queryByRole('button', { name: '重新确认' })).toBeNull();
});
it('快速编辑失败只记录任务错误,已有图片仍可继续编辑', async () => {
const prompt = '登录恢复后继续生成的角色立绘';
const memory = memoryHost({