修复失败结算竞态与对账阻断状态
将失败结算的 revision 校验、公开投影和 staging 更新收敛到同一草稿锁内。 对账失败结算后保持安全恢复阻断态,避免误恢复为可编辑状态。 补充过期 revision 不改写账本及前端对账阻断回归测试。
This commit is contained in:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user