修复图片生成鉴权刷新重试

将上下文、参考图和首次提交阶段的401账本保留为可恢复状态。

区分401鉴权失效与403权限拒绝,刷新后复用原生成身份和幂等请求。

补充持久化账本重放回归测试与登录刷新验收合同。
This commit is contained in:
2026-08-22 11:27:24 +08:00
parent d2686067eb
commit c4bf7e02d0
2 changed files with 194 additions and 8 deletions
@@ -2704,7 +2704,10 @@ fn preserve_submit_reconciliation(
) -> bool {
reconciliation
|| (code == "authentication-required"
&& phase == &GenerationLedgerPhase::ReconciliationRequired)
&& matches!(
phase,
GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired
))
}
async fn reconcile_generation(
@@ -2788,12 +2791,14 @@ async fn reconcile_generation(
{
Ok(context) => context,
Err(error) => {
let code = if error.contains("HTTP 401") || error.contains("HTTP 403") {
"authentication-required"
let (reconciliation, code) = if error.contains("HTTP 401") {
(true, "authentication-required")
} else if error.contains("HTTP 403") {
(false, "permission-denied")
} else {
"platform-service-configuration"
(false, "platform-service-configuration")
};
mark_generation_error(root, &mut ledger, false, code, emit)?;
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
return Err(sanitized_generation_error(code));
}
};
@@ -2804,7 +2809,8 @@ async fn reconcile_generation(
ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await
{
let code = error.code();
mark_generation_error(root, &mut ledger, false, code, emit)?;
let reconciliation = code == "authentication-required";
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
return Err(sanitized_generation_error(code));
}
let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?;
@@ -2883,7 +2889,11 @@ async fn reconcile_generation(
}
}
mark_generation_error(root, &mut ledger, reconciliation, code, emit)?;
return Err(sanitized_classified_generation_error(code, reconciliation));
return Err(if code == "authentication-required" {
sanitized_generation_error(code)
} else {
sanitized_classified_generation_error(code, reconciliation)
});
}
let submission = match response.json::<serde_json::Value>().await {
Ok(value) => value,
@@ -4065,6 +4075,176 @@ mod tests {
.contains("安全画布边界"));
}
#[tokio::test]
async fn context_authentication_required_replays_the_same_generation_after_refresh() {
let project_id = "phase-five-auth-refresh-project";
let project_name = "阶段五登录刷新重放测试";
let (directory, draft) = create_generation_fixture(project_id, project_name);
let listener = TcpListener::bind("127.0.0.1:0").expect("bind auth refresh server");
let base_url = format!(
"http://{}",
listener.local_addr().expect("auth refresh address")
);
let signed_url = format!("{base_url}/auth-refresh.png");
let server_signed_url = signed_url.clone();
let png = test_png();
let (sender, receiver) = mpsc::channel();
let server = std::thread::spawn(move || {
listener
.set_nonblocking(true)
.expect("set auth refresh fixture nonblocking");
for request_index in 0..7 {
let mut stream = accept_generation_fixture_connection(
&listener,
"auth refresh fixture",
request_index,
);
let request = read_http_request(&mut stream);
sender
.send(request.clone())
.expect("capture auth refresh request");
if request_index == 0 {
assert!(request.starts_with("GET /api/external/v1/editor/projects "));
assert!(request
.to_ascii_lowercase()
.contains("authorization: bearer expired-key"));
write_json(
&mut stream,
"401 Unauthorized",
serde_json::json!({"error": {"code": "authentication-required"}}),
);
} else if request.starts_with("GET /api/external/v1/editor/projects ") {
write_json(
&mut stream,
"200 OK",
serde_json::json!({"data": {"projects": [{
"projectId": "auth-refresh-remote-project",
"title": project_name,
}]}}),
);
} else if request.starts_with("GET /api/external/v1/editor/assets/library ") {
write_json(
&mut stream,
"200 OK",
serde_json::json!({"data": {"library": {"folders": [{
"folderId": "auth-refresh-remote-folder",
"label": project_name,
}]}}}),
);
} else if request.starts_with("POST /api/external/v1/editor/images/generations ") {
write_json(
&mut stream,
"202 Accepted",
serde_json::json!({"data": {
"operationId": "auth-refresh-operation",
"status": "queued",
"pollAfterMs": 0,
}}),
);
} else if request
.starts_with("GET /api/external/v1/generations/auth-refresh-operation ")
{
write_json(
&mut stream,
"200 OK",
serde_json::json!({"data": {
"operationId": "auth-refresh-operation",
"status": "completed",
"pollAfterMs": 0,
"result": {"resource": {
"resourceId": "auth-refresh-resource",
"objectKey": "generated/auth-refresh.png",
"assetObjectId": "auth-refresh-object",
}}
}}),
);
} else if request.starts_with("GET /api/external/v1/assets/read-url?") {
write_json(
&mut stream,
"200 OK",
serde_json::json!({"read": {"signedUrl": server_signed_url}}),
);
} else if request.starts_with("GET /auth-refresh.png ") {
write_png(&mut stream, &png);
} else {
panic!("unexpected auth refresh request: {request}");
}
}
});
let input = generation_input(
directory.path(),
project_id,
&draft,
"刷新登录态后继续原图片生成",
);
let mut first_progress = Vec::new();
let first_error =
match with_test_external_editor_credentials(&base_url, "expired-key", async {
generate_asset_canvas_image_at(directory.path(), &input, |event| {
first_progress.push(event)
})
.await
})
.await
{
Ok(_) => panic!("first request must expose authentication failure"),
Err(error) => error,
};
assert!(
first_error.contains("登录已失效") || first_error.contains("API Key 无效"),
"authentication failure must remain recognizable without exposing credentials"
);
assert_eq!(
first_progress.last().map(|event| event.phase.as_str()),
Some("reconciliation-required")
);
let recoverable = read_generation_ledger(directory.path(), &input.generation_id)
.expect("read authentication ledger")
.expect("authentication ledger exists");
assert_eq!(
recoverable.phase,
GenerationLedgerPhase::ReconciliationRequired
);
assert_eq!(
recoverable.error_code.as_deref(),
Some("authentication-required")
);
assert!(recoverable.operation_id.is_none());
assert!(recoverable.request_body_json.is_none());
let recovered = with_test_external_editor_credentials(&base_url, "refreshed-key", async {
generate_asset_canvas_image_at(directory.path(), &input, |_| {}).await
})
.await
.expect("refreshed credentials replay the original generation");
server.join().expect("join auth refresh server");
assert_eq!(
recovered.result.generation.phase,
AssetCanvasGenerationStatus::CandidateReady
);
assert_eq!(
recovered.result.generation.generation_id,
input.generation_id
);
let requests = std::iter::from_fn(|| receiver.try_recv().ok()).collect::<Vec<_>>();
assert_eq!(requests.len(), 7);
assert!(requests[1]
.to_ascii_lowercase()
.contains("authorization: bearer refreshed-key"));
let submits = requests
.iter()
.filter(|request| {
request.starts_with("POST /api/external/v1/editor/images/generations ")
})
.collect::<Vec<_>>();
assert_eq!(submits.len(), 1);
assert!(submits[0]
.to_ascii_lowercase()
.contains(&format!("idempotency-key: {}", input.idempotency_key)));
}
#[tokio::test]
async fn confirmed_generation_posts_once_replays_without_network_and_keeps_public_state_clean()
{
@@ -5857,7 +6037,7 @@ mod tests {
assert_eq!(progress[0].error_code.as_deref(), Some(code));
assert!(sanitized_classified_generation_error(code, reconciliation)
.starts_with("reconciliation-required:"));
assert!(!preserve_submit_reconciliation(
assert!(preserve_submit_reconciliation(
&GenerationLedgerPhase::Prepared,
code,
false,
@@ -5867,6 +6047,11 @@ mod tests {
code,
false,
));
assert!(!preserve_submit_reconciliation(
&GenerationLedgerPhase::Prepared,
"permission-denied",
false,
));
}
#[tokio::test]
@@ -902,6 +902,7 @@ cancelling
| A26 | 导出 | PNG/JPEG/WebP | Web 下载/云端、Tauri 保存对话框均成功;共享 UI 不接收绝对路径 |
| A27 | 取消 | clean、dirty、generating、staging、committing | 分别符合第 12 节;committing 不伪装成可取消 |
| A28 | 恢复草稿 | 主文件损坏但恢复副本可信/不可信 | 可信副本恢复到 clean history 基线;不可信进入对账,不猜测 |
| A29 | 登录刷新重放 | context、参考图准备或首次提交返回 401,刷新后以相同 generationId 和幂等身份重放 | 401 账本保持可恢复且第二次真实访问平台;403 直接失败且不刷新;远端最多受理一次 |
阶段一至五最终审计只有在矩阵对应的纯模型、共享 React、Web adapter、Tauri adapter、Rust 持久化与 AppSurface 测试全部通过后,才可宣称图片素材创作正式闭环完成。