diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index 6555d521e..416508df8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -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::().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::>(); + 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::>(); + 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] diff --git a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md index d75cf14f0..6f12c7312 100644 --- a/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md +++ b/docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md @@ -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 测试全部通过后,才可宣称图片素材创作正式闭环完成。