发行包上传失败带出「哪一步」子枚举

新增 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:
2026-10-02 22:19:27 +08:00
parent 1a99a2f26b
commit cdccfa6cd5
@@ -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)
}