发行包上传失败带出「哪一步」子枚举
新增 GamePackageUploadStep(ReadUploadState / UploadChunk / CompleteUpload)与 GamePackageUploadError { step, detail }
read_upload_state / upload_chunk / complete_upload 改返回 typed 错误,detail 只放服务端 message / code / HTTP {status}
步骤名由 step 枚举渲染,不再靠各调用点硬拼「读取上传状态失败」这类前缀
upload_staged_game_package 在三个远端调用点 .map_err(to_user_msg);本地错误与对外签名保持 String,两处调用方零改动
This commit is contained in:
@@ -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<String>) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::ReadUploadState,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn upload_chunk(detail: impl Into<String>) -> Self {
|
||||
Self {
|
||||
step: GamePackageUploadStep::UploadChunk,
|
||||
detail: detail.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_upload(detail: impl Into<String>) -> 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<PackageUploadState, String> {
|
||||
) -> Result<PackageUploadState, GamePackageUploadError> {
|
||||
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<GamePackageUploadOutcome, String> {
|
||||
) -> Result<GamePackageUploadOutcome, GamePackageUploadError> {
|
||||
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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user