From 96816a6bdfa949c28d784b62c2ac540a608dda25 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Thu, 1 Oct 2026 17:30:41 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8Dmaster=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E5=90=8E=E7=9A=84AGC=E5=8F=91=E5=B8=83=E6=8E=A5=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将分片上传与校验进度迁入主线runtime模块 移除commands旧层重复发布命令并保留desktop入口 补齐合并后的Rust编译与上传回归 --- .../src-tauri/src/commands.rs | 143 -------- .../src-tauri/src/commands/desktop.rs | 20 ++ .../src-tauri/src/game_package_upload.rs | 320 ------------------ .../src/game_package_upload/runtime.rs | 10 +- 4 files changed, 29 insertions(+), 464 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index efcab64b5..d77fc38b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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 { - 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs index 7450d5ad1..d6896ff0b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands/desktop.rs @@ -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 } diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs index bfdae24e4..42ede48ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_package_upload.rs @@ -145,330 +145,10 @@ fn parse_received_bytes(body: &str) -> Option { .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 { - 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, -) -> Result { - 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 { - 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 { - 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 { - 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; 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 259757a5e..8149eac6a 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 @@ -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 { 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, })