修复master重构后的AGC发布接线

将分片上传与校验进度迁入主线runtime模块

移除commands旧层重复发布命令并保留desktop入口

补齐合并后的Rust编译与上传回归
This commit is contained in:
2026-10-01 17:30:41 +08:00
parent b3e6950546
commit 96816a6bdf
4 changed files with 29 additions and 464 deletions
@@ -4576,149 +4576,6 @@ pub(crate) fn checkpoint_with_capture_for_test(
create_local_project_checkpoint_with_capture(project_path, capture)
}
/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。
///
/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时
/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。
#[tauri::command]
pub(crate) async fn export_local_project_package(
project_path: String,
) -> Result<LocalProjectExportPackageResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let _lock = acquire_project_write_lock(root, "project.export_package")?;
advance_agent_runtime_project_revision_locked(root)?;
export_local_project_package_for_publish_at(root).await
}
/// 把归一化后的发行包落到内容寻址的暂存文件,返回分片续传所需的元数据。
///
/// 发布链路从此只把「暂存路径 + 摘要 + 体积」交给渲染进程:整包字节不再经过
/// WebView IPC,续传时也复用同一个暂存文件(同名同内容)。
#[tauri::command]
pub(crate) fn prepare_local_project_game_package(
app: tauri::AppHandle,
project_path: String,
package_relative_path: String,
) -> Result<crate::game_package_upload::StagedGamePackage, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_package")?;
let payload = read_local_project_export_package_at(root, package_relative_path.trim())?;
let staging_dir = game_package_upload_staging_dir(&app)?;
let mut staged = crate::game_package_upload::stage_game_package_bytes(
&staging_dir,
&payload.package_sha256,
&payload.package_bytes,
)?;
staged.package_file_count = u32::try_from(payload.files.len()).unwrap_or(u32::MAX);
Ok(staged)
}
/// 分片续传上传暂存的发行包;进度通过 `game-package-upload-progress` 事件回传。
#[tauri::command]
pub(crate) async fn upload_local_project_game_package(
app: tauri::AppHandle,
staging_path: String,
version_id: String,
api_base_url: String,
access_token: String,
idempotency_key: String,
) -> Result<crate::game_package_upload::GamePackageUploadOutcome, String> {
let staging_dir = game_package_upload_staging_dir(&app)?;
let resolved_path =
crate::game_package_upload::ensure_staging_path_in_dir(&staging_dir, &staging_path)?;
let client = reqwest::Client::builder()
.build()
.map_err(|error| format!("创建上传客户端失败:{error}"))?;
let version_id = version_id.trim().to_string();
if version_id.is_empty() {
return Err("缺少发行版本标识".to_string());
}
let emit_handle = app.clone();
let progress_version_id = version_id.clone();
let progress_publish_id = idempotency_key.trim().to_string();
let verify_handle = app.clone();
let verify_version_id = version_id.clone();
let verify_publish_id = progress_publish_id.clone();
crate::game_package_upload::upload_staged_game_package(
&client,
crate::game_package_upload::GamePackageUploadRequest {
staging_path: &resolved_path,
version_id: &version_id,
api_base_url: api_base_url.trim(),
access_token: access_token.trim(),
idempotency_key: idempotency_key.trim(),
session_identity: None,
},
move |received_bytes, total_bytes| {
let _ = emit_handle.emit(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&progress_version_id,
&progress_publish_id,
"upload",
"正在上传发行包…",
received_bytes,
total_bytes,
),
);
},
move || {
let _ = verify_handle.emit(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&verify_version_id,
&verify_publish_id,
"verify",
"正在校验发行包…",
0,
0,
),
);
},
)
.await
}
fn game_package_upload_staging_dir(app: &tauri::AppHandle) -> Result<PathBuf, String> {
app.path()
.app_data_dir()
.map(|app_data_root| {
crate::game_package_upload::game_package_upload_staging_dir(&app_data_root)
})
.map_err(|error| format!("无法读取 AGC 应用数据目录:{error}"))
}
#[tauri::command]
pub(crate) fn list_local_project_export_packages(
project_path: String,
) -> Result<LocalProjectExportPackagesResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.export_list")?;
list_local_project_export_packages_at(root)
}
#[tauri::command]
pub(crate) fn diff_local_project_checkpoint(
project_path: String,
checkpoint_id: String,
) -> Result<LocalProjectDiffResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.diff")?;
diff_local_project_checkpoint_at(root, checkpoint_id.trim())
}
#[tauri::command]
pub(crate) fn restore_local_project_checkpoint(
project_path: String,
checkpoint_id: String,
) -> Result<LocalProjectRestoreResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "project.restore")?;
let _lock = acquire_project_write_lock(root, "project.restore")?;
advance_agent_runtime_project_revision_locked(root)?;
restore_local_project_checkpoint_at(root, checkpoint_id.trim())
}
#[tauri::command]
pub(crate) fn read_project_permission_policy(
project_path: String,
@@ -1509,6 +1509,10 @@ pub(crate) async fn upload_local_project_game_package(
}
let emit_handle = app.clone();
let progress_version_id = version_id.clone();
let progress_publish_id = idempotency_key.trim().to_string();
let verify_handle = app.clone();
let verify_version_id = version_id.clone();
let verify_publish_id = progress_publish_id.clone();
crate::game_package_upload::upload_staged_game_package(
&client,
crate::game_package_upload::GamePackageUploadRequest {
@@ -1524,11 +1528,27 @@ pub(crate) async fn upload_local_project_game_package(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&progress_version_id,
&progress_publish_id,
"upload",
"正在上传发行包…",
received_bytes,
total_bytes,
),
);
},
move || {
let _ = verify_handle.emit(
crate::game_package_upload::GAME_PACKAGE_UPLOAD_PROGRESS_EVENT,
crate::game_package_upload::progress_event_payload(
&verify_version_id,
&verify_publish_id,
"verify",
"正在校验发行包…",
0,
0,
),
);
},
)
.await
}
@@ -145,330 +145,10 @@ fn parse_received_bytes(body: &str) -> Option<u64> {
.and_then(Value::as_u64)
}
fn platform_request(
client: &reqwest::Client,
access_token: &str,
method: reqwest::Method,
url: &str,
) -> reqwest::RequestBuilder {
client
.request(method, url)
.header(AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_VALUE)
.header(
reqwest::header::AUTHORIZATION,
format!("Bearer {access_token}"),
)
}
async fn read_upload_state(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
access_token: &str,
) -> Result<PackageUploadState, String> {
let response = platform_request(
client,
access_token,
reqwest::Method::GET,
&upload_url(base_url, version_id, "/upload-state"),
)
.send()
.await
.map_err(|error| format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"))?;
let status = response.status().as_u16();
let body = response
.text()
.await
.map_err(|error| format!("读取上传状态失败:{error}"))?;
if status >= 400 {
let (_, message) = parse_server_error(status, &body);
return Err(message.unwrap_or_else(|| format!("读取上传状态失败(HTTP {status})")));
}
let parsed: Value = serde_json::from_str(&body)
.map_err(|error| format!("上传状态响应不是合法 JSON:{error}"))?;
let payload = parsed.get("data").unwrap_or(&parsed);
serde_json::from_value(payload.clone())
.map_err(|error| format!("上传状态响应缺少字段:{error}"))
}
/// 上传一个分片;返回服务端确认后的已收字节。
async fn upload_chunk(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
access_token: &str,
idempotency_key: &str,
plan: ChunkPlan,
body: Vec<u8>,
) -> Result<u64, ChunkUploadError> {
let response = platform_request(
client,
access_token,
reqwest::Method::PUT,
&upload_url(base_url, version_id, "/chunk"),
)
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
.header(UPLOAD_OFFSET_HEADER, plan.offset.to_string())
.header("Idempotency-Key", format!("{idempotency_key}:chunk"))
.body(body)
.send()
.await
.map_err(|error| ChunkUploadError::Retryable(format!("发行包分片发送失败:{error}")))?;
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|error| ChunkUploadError::Retryable(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())
})?;
return Ok(received);
}
return Err(ChunkUploadError::Fatal(
message.unwrap_or_else(|| "发行包分片被拒绝".to_string()),
));
}
if status >= 500 || status == 408 || status == 429 {
let (_, message) = parse_server_error(status, &text);
return Err(ChunkUploadError::Retryable(
message.unwrap_or_else(|| format!("发行包分片上传失败(HTTP {status})")),
));
}
if status >= 400 {
let (_, message) = parse_server_error(status, &text);
return Err(ChunkUploadError::Fatal(
message.unwrap_or_else(|| format!("发行包分片被拒绝(HTTP {status})")),
));
}
let parsed: Value = serde_json::from_str(&text)
.map_err(|error| ChunkUploadError::Retryable(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()))
}
enum ChunkUploadError {
Retryable(String),
Fatal(String),
}
async fn complete_upload(
client: &reqwest::Client,
base_url: &str,
version_id: &str,
access_token: &str,
idempotency_key: &str,
) -> Result<GamePackageUploadOutcome, String> {
let response = platform_request(
client,
access_token,
reqwest::Method::POST,
&upload_url(base_url, version_id, "/complete"),
)
.header("Idempotency-Key", format!("{idempotency_key}:complete"))
.send()
.await
.map_err(|error| format!("完成发行包上传失败:{error}"))?;
let status = response.status().as_u16();
let text = response
.text()
.await
.map_err(|error| format!("读取完成响应失败:{error}"))?;
if status >= 400 {
let (_, message) = parse_server_error(status, &text);
return Err(message.unwrap_or_else(|| format!("完成发行包上传失败(HTTP {status})")));
}
let parsed: Value =
serde_json::from_str(&text).map_err(|error| format!("完成响应不是合法 JSON:{error}"))?;
let payload = parsed.get("data").unwrap_or(&parsed);
Ok(GamePackageUploadOutcome {
version_id: payload
.get("versionId")
.and_then(Value::as_str)
.unwrap_or(version_id)
.to_string(),
status: payload
.get("status")
.and_then(Value::as_str)
.unwrap_or("uploaded")
.to_string(),
uploaded_bytes: 0,
})
}
pub(crate) struct GamePackageUploadRequest<'a> {
pub(crate) staging_path: &'a Path,
pub(crate) version_id: &'a str,
pub(crate) api_base_url: &'a str,
pub(crate) access_token: &'a str,
pub(crate) idempotency_key: &'a str,
pub(crate) session_identity: Option<&'a PlatformSessionIdentity>,
}
impl<'a> GamePackageUploadRequest<'a> {
fn current_access_token(&self) -> Result<String, String> {
let Some(expected) = self.session_identity else {
return Ok(self.access_token.to_string());
};
let session = current_platform_session().ok_or_else(|| {
"authentication-required: 陶泥儿登录态缺失,请重新登录后重试".to_string()
})?;
validate_platform_session_identity(expected)?;
Ok(session.access_token)
}
}
/// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。
///
/// `on_verify` 在整包收齐、调用服务端 `/complete`(重算摘要 + 展开清单 + 确认完整对象)
/// 之前触发:那一步是真正的「校验」,渲染层据此把进度切到校验阶段。
pub(crate) async fn upload_staged_game_package(
client: &reqwest::Client,
request: GamePackageUploadRequest<'_>,
mut on_progress: impl FnMut(u64, u64),
mut on_verify: impl FnMut(),
) -> Result<GamePackageUploadOutcome, String> {
let total_bytes = fs::metadata(request.staging_path)
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
.len();
if total_bytes == 0 {
return Err("发行包暂存文件为空,请重新导出试玩包".to_string());
}
let access_token = request.current_access_token()?;
let state = read_upload_state(
client,
request.api_base_url,
request.version_id,
&access_token,
)
.await?;
if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes {
return Err(format!(
"本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布",
total_bytes, state.declared_package_bytes
));
}
let chunk_bytes = if state.chunk_bytes == 0 {
MAX_CHUNK_BYTES
} else {
state.chunk_bytes.min(MAX_CHUNK_BYTES)
};
let mut received = state.received_bytes.min(total_bytes);
on_progress(received, total_bytes);
let mut file = File::open(request.staging_path)
.map_err(|error| format!("打开发行包暂存文件失败:{error}"))?;
while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) {
let body = read_chunk(&mut file, plan)?;
let mut attempt = 1_usize;
loop {
let access_token = request.current_access_token()?;
match upload_chunk(
client,
request.api_base_url,
request.version_id,
&access_token,
request.idempotency_key,
plan,
body.clone(),
)
.await
{
Ok(next_received) => {
received = next_received.min(total_bytes);
on_progress(received, total_bytes);
break;
}
Err(ChunkUploadError::Fatal(error)) => return Err(error),
Err(ChunkUploadError::Retryable(error)) => {
if attempt >= CHUNK_MAX_ATTEMPTS {
return Err(format!("{error}(已尝试 {attempt} 次,可重新发布续传)"));
}
attempt += 1;
tokio::time::sleep(CHUNK_RETRY_DELAY).await;
}
}
}
// 权威偏移可能在重试期间前进(例如响应丢失后服务端已写入),按服务端口径对齐。
let access_token = request.current_access_token()?;
let authoritative = read_upload_state(
client,
request.api_base_url,
request.version_id,
&access_token,
)
.await?;
received = authoritative.received_bytes.min(total_bytes);
on_progress(received, total_bytes);
}
let access_token = request.current_access_token()?;
on_verify();
let mut outcome = complete_upload(
client,
request.api_base_url,
request.version_id,
&access_token,
request.idempotency_key,
)
.await?;
outcome.uploaded_bytes = total_bytes;
Ok(outcome)
}
/// 进度事件的载荷形状(渲染进程按它显示阶段与百分比)。
///
/// `phase` 是发布链路的稳定阶段标识(`prepare` / `upload` / `verify` / `submit`),
/// 渲染层据此点亮步骤条;`upload` 之外的阶段 `receivedBytes` / `totalBytes` 为 0,
/// 表示该阶段没有可换算的百分比而不是 0% 进度。`publishId` 是本次发布尝试的根幂等键,
/// 只用于把事件归到同一次发布,不含 token 或任何凭据。
pub(crate) fn progress_event_payload(
version_id: &str,
publish_id: &str,
phase: &str,
message: &str,
received_bytes: u64,
total_bytes: u64,
) -> Value {
json!({
"versionId": version_id,
"publishId": publish_id,
"phase": phase,
"message": message,
"receivedBytes": received_bytes,
"totalBytes": total_bytes,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_payload_carries_publish_stage_and_percent_inputs() {
let payload = progress_event_payload(
"ver-1",
"agc-publish-key",
"upload",
"正在上传发行包…",
8 * 1024 * 1024,
12 * 1024 * 1024,
);
assert_eq!(payload["versionId"], "ver-1");
assert_eq!(payload["publishId"], "agc-publish-key");
assert_eq!(payload["phase"], "upload");
assert_eq!(payload["message"], "正在上传发行包…");
assert_eq!(payload["receivedBytes"], 8 * 1024 * 1024);
assert_eq!(payload["totalBytes"], 12 * 1024 * 1024);
// 载荷只带本地标识与计数,不带 token、路径或包字节。
let serialized = payload.to_string();
for forbidden in ["token", "Bearer", "stagingPath", ":\\"] {
assert!(!serialized.contains(forbidden), "{serialized}");
}
}
#[test]
fn chunk_plan_covers_whole_package_without_gaps_or_overlap() {
let total = 20 * 1024 * 1024 + 3;
@@ -235,6 +235,7 @@ pub(crate) async fn upload_staged_game_package(
client: &reqwest::Client,
request: GamePackageUploadRequest<'_>,
mut on_progress: impl FnMut(u64, u64),
mut on_verify: impl FnMut(),
) -> Result<GamePackageUploadOutcome, String> {
let total_bytes = fs::metadata(request.staging_path)
.map_err(|error| format!("读取发行包暂存文件失败:{error}"))?
@@ -309,6 +310,7 @@ pub(crate) async fn upload_staged_game_package(
on_progress(received, total_bytes);
}
let access_token = request.current_access_token()?;
on_verify();
let mut outcome = complete_upload(
client,
request.api_base_url,
@@ -321,14 +323,20 @@ pub(crate) async fn upload_staged_game_package(
Ok(outcome)
}
/// 进度事件的载荷形状(渲染进程按它显示进度)。
/// 发布进度载荷;上传阶段带字节进度,其他阶段按阶段文案展示。
pub(crate) fn progress_event_payload(
version_id: &str,
publish_id: &str,
phase: &str,
message: &str,
received_bytes: u64,
total_bytes: u64,
) -> Value {
json!({
"versionId": version_id,
"publishId": publish_id,
"phase": phase,
"message": message,
"receivedBytes": received_bytes,
"totalBytes": total_bytes,
})