diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs index f2d0d706f..b9162ae31 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs @@ -2459,43 +2459,71 @@ fn is_external_resource_edit_endpoint(endpoint: &str) -> bool { /// 从服务端 4xx 响应体里取出**可读的失败原因**,让用户看到的不是笼统的 HTTP 状态码。 /// -/// 平台错误体在不同路由上可能是 `{error:{message}}`、`{error:"文本"}`、`{details:{message}}` -/// 或顶层 `{message}`;这里按这个顺序兼容,并截断到 200 字符,避免把整段响应体塞进用户可见文案。 +/// 平台错误体在不同路由上可能是 `{error:{message,details:{message}}}`、`{error:"文本"}`、 +/// `{details:{message}}` 或顶层 `{message}`。**`details.message` 优先**:平台把所有 4xx 都 +/// 兜底成同一条通用文案(`http_error.rs` 的 `resolve_http_error`),真实原因写在 +/// `details.message` 里(例如「当前素材类型不支持图片快速编辑」),只读 `error.message` +/// 会让用户永远看到「请求参数不合法」。 async fn editor_api_rejection_reason(response: reqwest::Response) -> Option { let body = response.text().await.ok()?; + editor_api_rejection_reason_from_body(body.as_str()) +} + +fn editor_api_rejection_reason_from_body(body: &str) -> Option { let value: serde_json::Value = serde_json::from_str(body.trim()).ok()?; - let candidate = value - .get("error") - .and_then(|error| { - error - .get("message") - .and_then(|message| message.as_str()) - .or_else(|| error.as_str()) + let error = value.get("error"); + let details = value.get("details"); + let error_details = details.or_else(|| error.and_then(|error| error.get("details"))); + // 取值顺序:`error.details.message` → `details.message` → `error.message` → `error` 文本 + // → 顶层 `message`。前两者才是平台写进去的具体原因,后两者只是兜底。 + let candidate = error_details + .and_then(|error_details| error_details.get("message")) + .and_then(serde_json::Value::as_str) + .or_else(|| { + details + .and_then(|details| details.get("message")) + .and_then(serde_json::Value::as_str) }) .or_else(|| { - value - .get("details") - .and_then(|details| details.get("message")) - .and_then(|message| message.as_str()) + error + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) }) - .or_else(|| value.get("message").and_then(|message| message.as_str()))?; - let trimmed = candidate.trim(); - if trimmed.is_empty() { + .or_else(|| error.and_then(serde_json::Value::as_str)) + .or_else(|| value.get("message").and_then(serde_json::Value::as_str))? + .trim(); + if candidate.is_empty() { return None; } - // 平台把所有 4xx 都兜底成同一条通用文案(`http_error.rs`),真实原因在 `error.code` 里, - // 因此稳定码必须一起透出,否则用户和排障者只能看到"请求参数不合法"。 + // 平台把所有 4xx 都兜底成同一条通用文案(`http_error.rs`),稳定码和 `details` 里的 + // provider / assetKind / mediaType 必须一起透出,否则排障者只能看到"请求参数不合法"。 + let detail_text = |key: &str| { + error_details + .and_then(|error_details| error_details.get(key)) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|detail| !detail.is_empty()) + }; let code = value .get("error") .and_then(|error| error.get("code")) .and_then(|code| code.as_str()) .map(str::trim) .filter(|code| !code.is_empty() && *code != "BAD_REQUEST"); - let reason: String = trimmed.chars().take(200).collect(); - Some(match code { - Some(code) => format!("{reason}|错误码 {code}"), - None => reason, - }) + let mut reason: String = candidate.chars().take(200).collect(); + if let Some(provider) = detail_text("provider") { + reason.push_str(format!("|provider {provider}").as_str()); + } + if let Some(asset_kind) = detail_text("assetKind") { + reason.push_str(format!("|素材类型 {asset_kind}").as_str()); + } + if let Some(media_type) = detail_text("mediaType") { + reason.push_str(format!("|媒体类型 {media_type}").as_str()); + } + if let Some(code) = code { + reason.push_str(format!("|错误码 {code}").as_str()); + } + Some(reason) } async fn submit_resource_edit_remote(