diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs index 7e8920816..b06eb543a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload/runtime.rs @@ -67,12 +67,75 @@ fn platform_request( ) } +/// 发行包上传里的一个远端步骤。 +/// +/// 一次上传会跨多请求(读上传状态、每个分片 PUT、最后 complete),失败时只抛一句 +/// `HTTP {status}` 分不出是哪一步;`step` 把「哪一步」结构化带出来,用户文案与诊断各取所需。 +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum GamePackageUploadStep { + ReadUploadState, + UploadChunk, + CompleteUpload, +} + +impl GamePackageUploadStep { + fn label(self) -> &'static str { + match self { + Self::ReadUploadState => "读取上传状态失败", + Self::UploadChunk => "发行包分片上传失败", + Self::CompleteUpload => "完成发行包上传失败", + } + } +} + +/// 发行包上传失败:`step` 说明是哪一步,`detail` 是这一步的原始事实(服务端 message / code / +/// `HTTP {status}`)。 +#[derive(Clone, Debug, serde::Serialize)] +pub(crate) struct GamePackageUploadError { + pub(crate) step: GamePackageUploadStep, + pub(crate) detail: String, +} + +impl GamePackageUploadError { + fn read_upload_state(detail: impl Into) -> Self { + Self { + step: GamePackageUploadStep::ReadUploadState, + detail: detail.into(), + } + } + + fn upload_chunk(detail: impl Into) -> Self { + Self { + step: GamePackageUploadStep::UploadChunk, + detail: detail.into(), + } + } + + fn complete_upload(detail: impl Into) -> Self { + Self { + step: GamePackageUploadStep::CompleteUpload, + detail: detail.into(), + } + } + + /// 给用户/模型的那句:步骤 + 事实。步骤来自 `step` 枚举,不从调用点硬拼。 + pub(crate) fn to_user_msg(&self) -> String { + let detail = self.detail.trim(); + if detail.is_empty() { + self.step.label().to_string() + } else { + format!("{}:{detail}", self.step.label()) + } + } +} + async fn read_upload_state( client: &reqwest::Client, base_url: &str, version_id: &str, access_token: &str, -) -> Result { +) -> Result { let response = platform_request( client, access_token, @@ -81,24 +144,30 @@ async fn read_upload_state( ) .send() .await - .map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?; + .map_err(|error| { + GamePackageUploadError::read_upload_state(format!( + "无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}" + )) + })?; let status = response.status().as_u16(); - let body = response - .text() - .await - .map_err(|error| format!("读取上传状态失败:{error}"))?; + let body = response.text().await.map_err(|error| { + GamePackageUploadError::read_upload_state(format!("读取响应失败:{error}")) + })?; if status >= 400 { let (code, message) = parse_server_error(status, &body); - return Err(message + let detail = message .filter(|detail| !detail.trim().is_empty()) .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}"))); + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(GamePackageUploadError::read_upload_state(detail)); } - let parsed: Value = serde_json::from_str(&body) - .map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?; + let parsed: Value = serde_json::from_str(&body).map_err(|error| { + GamePackageUploadError::read_upload_state(format!("上传状态响应不是合法 JSON:{error}")) + })?; let payload = parsed.get("data").unwrap_or(&parsed); - serde_json::from_value(payload.clone()) - .map_err(|error| format!("上传状态响应缺少字段:{error}")) + serde_json::from_value(payload.clone()).map_err(|error| { + GamePackageUploadError::read_upload_state(format!("上传状态响应缺少字段:{error}")) + }) } /// 上传一个分片;返回服务端确认后的已收字节。 @@ -123,54 +192,74 @@ async fn upload_chunk( .body(body) .send() .await - .map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?; + .map_err(|error| { + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( + "请求未送达:{error}" + ))) + })?; let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| ChunkUploadError::Retryable(format!("读取分片响应失败:{error}")))?; + let text = response.text().await.map_err(|error| { + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( + "读取分片响应失败:{error}" + ))) + })?; if status == 409 { let (code, message) = parse_server_error(status, &text); if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { let received = parse_received_bytes(&text).ok_or_else(|| { - ChunkUploadError::Fatal("分片偏移不一致,但服务端未返回权威偏移".to_string()) + ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk( + "分片偏移不一致,但服务端未返回权威偏移", + )) })?; return Ok(received); } + let detail = message + .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) + .unwrap_or_else(|| format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( - message.unwrap_or_else(|| "发行包分片被拒绝".to_string()), + GamePackageUploadError::upload_chunk(detail), )); } if status >= 500 || status == 408 || status == 429 { let (code, message) = parse_server_error(status, &text); + let detail = message + .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) + .unwrap_or_else(|| format!("HTTP {status}")); return Err(ChunkUploadError::Retryable( - message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")), + GamePackageUploadError::upload_chunk(detail), )); } if status >= 400 { let (code, message) = parse_server_error(status, &text); + let detail = message + .filter(|detail| !detail.trim().is_empty()) + .or_else(|| code.filter(|detail| !detail.trim().is_empty())) + .unwrap_or_else(|| format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( - message - .filter(|detail| !detail.trim().is_empty()) - .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}")), + GamePackageUploadError::upload_chunk(detail), )); } - let parsed: Value = serde_json::from_str(&text) - .map_err(|error| ChunkUploadError::Retryable(format!("分片响应不是合法 JSON:{error}")))?; + let parsed: Value = serde_json::from_str(&text).map_err(|error| { + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( + "分片响应不是合法 JSON:{error}" + ))) + })?; let payload = parsed.get("data").unwrap_or(&parsed); payload .get("receivedBytes") .and_then(Value::as_u64) - .ok_or_else(|| ChunkUploadError::Fatal("分片响应缺少 receivedBytes".to_string())) + .ok_or_else(|| { + ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk( + "分片响应缺少 receivedBytes", + )) + }) } enum ChunkUploadError { - Retryable(String), - Fatal(String), + Retryable(GamePackageUploadError), + Fatal(GamePackageUploadError), } async fn complete_upload( @@ -179,7 +268,7 @@ async fn complete_upload( version_id: &str, access_token: &str, idempotency_key: &str, -) -> Result { +) -> Result { let response = platform_request( client, access_token, @@ -189,21 +278,22 @@ async fn complete_upload( .header("Idempotency-Key", format!("{idempotency_key}:complete")) .send() .await - .map_err(|error| format!("完成发行包上传失败:{error}"))?; + .map_err(|error| GamePackageUploadError::complete_upload(format!("请求未送达:{error}")))?; let status = response.status().as_u16(); - let text = response - .text() - .await - .map_err(|error| format!("读取完成响应失败:{error}"))?; + let text = response.text().await.map_err(|error| { + GamePackageUploadError::complete_upload(format!("读取响应失败:{error}")) + })?; if status >= 400 { let (code, message) = parse_server_error(status, &text); - return Err(message + let detail = message .filter(|detail| !detail.trim().is_empty()) .or_else(|| code.filter(|detail| !detail.trim().is_empty())) - .unwrap_or_else(|| format!("HTTP {status}"))); + .unwrap_or_else(|| format!("HTTP {status}")); + return Err(GamePackageUploadError::complete_upload(detail)); } - let parsed: Value = - serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?; + let parsed: Value = serde_json::from_str(&text).map_err(|error| { + GamePackageUploadError::complete_upload(format!("完成响应不是合法 JSON:{error}")) + })?; let payload = parsed.get("data").unwrap_or(&parsed); Ok(GamePackageUploadOutcome { version_id: payload @@ -261,7 +351,8 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, ) - .await?; + .await + .map_err(|error| error.to_user_msg())?; if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { return Err(format!( "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", @@ -298,10 +389,13 @@ pub(crate) async fn upload_staged_game_package( on_progress(received, total_bytes); break; } - Err(ChunkUploadError::Fatal(error)) => return Err(error), + Err(ChunkUploadError::Fatal(error)) => return Err(error.to_user_msg()), Err(ChunkUploadError::Retryable(error)) => { if attempt >= CHUNK_MAX_ATTEMPTS { - return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)")); + return Err(format!( + "{}(已尝试 {attempt} 次,可重新发布续传)", + error.to_user_msg() + )); } attempt += 1; tokio::time::sleep(CHUNK_RETRY_DELAY).await; @@ -316,7 +410,8 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, ) - .await?; + .await + .map_err(|error| error.to_user_msg())?; received = authoritative.received_bytes.min(total_bytes); on_progress(received, total_bytes); } @@ -328,7 +423,8 @@ pub(crate) async fn upload_staged_game_package( &access_token, request.idempotency_key, ) - .await?; + .await + .map_err(|error| error.to_user_msg())?; outcome.uploaded_bytes = total_bytes; Ok(outcome) }