From 1fc78faff27b3894e1f05548b8b61774c5c1d299 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Mon, 5 Oct 2026 16:10:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B8=E6=88=8F=E5=85=B1=E5=88=9B):=20?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E9=9D=9E=E7=A6=81=E6=AD=A2=E6=97=B6=E9=9A=8F?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E4=B8=8A=E4=BC=A0=E5=B7=A5=E7=A8=8B=E6=BA=90?= =?UTF-8?q?=E5=8C=85=EF=BC=88M2b=20=E4=B8=8A=E4=BC=A0=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=EF=BC=8C=E5=A4=B1=E8=B4=A5=E4=B8=8D=E9=98=BB=E6=96=AD=E5=8F=91?= =?UTF-8?q?=E5=B8=83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上传链路(复用既有分片续传,不另写一套): - `PackageUploadAsset` 收拢两条资产的差异(URL 资源段 `package` / `project-bundle`、文案、 偏移错误码 `PACKAGE_UPLOAD_OFFSET_MISMATCH` / `PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH`、 是否有创建版本时预登记大小),`upload_staged_game_package` 改名 `upload_staged_version_asset`,发行包与工程源包共用同一段主循环与重试/续传语义。 - 发布流程:建游戏 → 建版本 → 上传发行包 → 〔授权非「禁止共创」且作者没取消时〕打包工程源包 并上传 → 送审。插入点必须在送审**之前**:服务端只允许 `awaiting_upload` / `upload_failed` 档位补传工程包,送审后即 409。 - 失败关闭与不阻断:工程源包打包/上传任何失败都只写进结果的 `projectBundleWarning` (「未上传工程源码,本作品只能被产物级改编:…」),发布本身照常成功;作者的作品仍可玩可发布。 - 进度复用既有 `game-package-upload-progress` 事件(phase 仍用 upload/verify,文案区分资产), 渲染层的阶段联合是闭集,不新增阶段名。 面板与渲染层: - 新增「同时公开工程源码」勾选:仅当授权非「禁止共创」且为首次发布时出现,默认勾选, 文案写清「允许他人下载并使用你的工程源文件(含素材),且不可撤销」; 取消勾选才显式传 `includeProjectBundle: false`,其余情况交给原生按「授权非禁止即上传」判断, 渲染层不重复实现同一套默认值。 - 成功页如实展示 `projectBundleWarning`,避免作者误以为源码已随版本公开。 测试:Rust 新增「两条资产的路由段/偏移错误码/预登记大小差异」用例,并更新既有 `upload_url` 用例; TS 新增勾选默认值、取消勾选显式 false、未上传提示三条,服务层新增「true/缺省不带字段、 false 才带」用例。 --- .../src-tauri/src/commands/desktop.rs | 3 +- .../src/game_distribution_publish.rs | 132 +++++++++++- .../src-tauri/src/game_package_upload.rs | 65 +++++- .../src/game_package_upload/runtime.rs | 188 +++++++++++++----- .../GameDistributionPublishPanel.tsx | 43 ++++ .../src/services/gameDistributionPublish.ts | 17 ++ apps/ai-game-creator-shell/src/styles.css | 31 +++ .../tests/gameDistributionPublish.test.ts | 26 +++ .../gameDistributionPublishPanel.test.tsx | 91 +++++++++ 9 files changed, 527 insertions(+), 69 deletions(-) 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 ff2565b84..145701db2 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 @@ -1204,9 +1204,10 @@ pub(crate) async fn upload_local_project_game_package( 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( + crate::game_package_upload::upload_staged_version_asset( &client, crate::game_package_upload::GamePackageUploadRequest { + asset: crate::game_package_upload::PackageUploadAsset::ReleasePackage, staging_path: &resolved_path, version_id: &version_id, api_base_url: api_base_url.trim(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs index 08531a088..b94fcf8a5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/game_distribution_publish.rs @@ -6,8 +6,8 @@ use crate::game_package_upload::{ game_package_upload_staging_dir, server_error_detail, stage_game_package_bytes, - upload_staged_game_package, GamePackageUploadOutcome, GamePackageUploadRequest, - StagedGamePackage, + upload_staged_version_asset, GamePackageUploadOutcome, GamePackageUploadRequest, + PackageUploadAsset, StagedGamePackage, }; use crate::http_client::agc_main_site_client_builder; use crate::platform_session::{ @@ -27,9 +27,10 @@ use shared_contracts::game_creation_app::{ }; use shared_contracts::game_distribution::{ GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest, - GameDistributionDeviceSupport, GameDistributionForkDeclaration, GameDistributionForkSource, - GameDistributionInputMode, GameDistributionOrientation, - GameDistributionPublishMetadataSuggestion, GameDistributionPublishMetadataSuggestionRequest, + GameDistributionDeviceSupport, GameDistributionForkAuthorization, + GameDistributionForkDeclaration, GameDistributionForkSource, GameDistributionInputMode, + GameDistributionOrientation, GameDistributionPublishMetadataSuggestion, + GameDistributionPublishMetadataSuggestionRequest, }; use std::path::Path; use std::time::Duration; @@ -66,6 +67,12 @@ pub(crate) struct GameDistributionPublishResult { /// 本次写回的发布绑定,便于调用方不解析 manifest 也能就地更新。 #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) publication: Option, + /// 工程源包**未**随版本上传时的用户可见原因(`None` = 未尝试或已成功)。 + /// + /// 上传工程源包失败绝不阻断发布:作者的作品已经可玩可发布,只是退化为「产物级改编」。 + /// 面板据此提示「未上传工程源码,本作品只能被产物级改编」。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) project_bundle_warning: Option, } /// 回读版本冻结资料时带回的封面/截图草稿条目。 @@ -1437,6 +1444,89 @@ fn emit_publish_progress( ); } +/// 把工程源包(当前项目源码)打成包并随版本上传。 +/// +/// 调用点在发布流程送审之前——服务端只允许 `awaiting_upload` / `upload_failed` 档位补传工程包, +/// 送审后就是 409。失败只向上抛一句原因,由调用方转成面板提示,**不阻断发布**。 +#[allow(clippy::too_many_arguments)] +async fn upload_project_bundle_for_version( + app: &AppHandle, + client: &reqwest::Client, + snapshot: &PlatformSessionSnapshot, + session_identity: &crate::platform_session::PlatformSessionIdentity, + app_data_dir: &Path, + project_root: &Path, + version_id: &str, + root_key: &str, +) -> Result<(), String> { + let bundle = crate::project_bundle::build_project_bundle( + project_root, + crate::project_bundle::ProjectBundleScope::WholeProject, + )?; + // 与发行包同一套内容寻址暂存:同一份源码重传复用同一文件,续传才有意义。 + let staged = stage_game_package_bytes( + &game_package_upload_staging_dir(app_data_dir), + &bundle.sha256, + &bundle.bytes, + )?; + let message = "正在上传工程源包(供他人源码级改编)…"; + emit_publish_progress( + app, + root_key, + version_id, + "upload", + message, + 0, + staged.package_size_bytes, + ); + let emit_handle = app.clone(); + let progress_publish_id = root_key.to_string(); + let progress_version_id = version_id.to_string(); + let verify_handle = app.clone(); + let verify_publish_id = root_key.to_string(); + let verify_version_id = version_id.to_string(); + upload_staged_version_asset( + client, + GamePackageUploadRequest { + asset: PackageUploadAsset::ProjectBundle, + staging_path: Path::new(&staged.staging_path), + version_id, + api_base_url: &snapshot.api_base_url, + access_token: &snapshot.access_token, + idempotency_key: &format!("{root_key}:project-bundle"), + session_identity: Some(session_identity), + }, + move |received_bytes, total_bytes| { + emit_publish_progress( + &emit_handle, + &progress_publish_id, + &progress_version_id, + "upload", + message, + received_bytes, + total_bytes, + ); + }, + move || { + emit_publish_progress( + &verify_handle, + &verify_publish_id, + &verify_version_id, + "verify", + "正在校验工程源包…", + 0, + 0, + ); + }, + ) + .await?; + Ok(()) +} + +/// 发布:建游戏(首次)→ 建版本 → 上传发行包 → 〔授权非禁止时〕上传工程源包 → 送审。 +/// +/// `include_project_bundle`:是否随版本上传工程源包(面板勾选)。`None`/`true` = 授权非禁止时 +/// 默认上传;`false` = 作者取消(源码不外发,作品退化为产物级改编)。 #[tauri::command] pub(crate) async fn publish_local_project_game( app: AppHandle, @@ -1447,6 +1537,7 @@ pub(crate) async fn publish_local_project_game( version_number: Option, expected_publication_revision: Option, idempotency_key: Option, + include_project_bundle: Option, ) -> Result { let snapshot = require_platform_session()?; let app_data_dir = app @@ -1509,6 +1600,13 @@ pub(crate) async fn publish_local_project_game( } } let digest = metadata_digest(&metadata)?; + // 工程源包要不要一起传,只取决于**授权档位**与面板勾选,与后面把 metadata 交给 + // 创建版本请求无关:这里先算出来,避免 metadata 被 move 之后再用它。 + let fork_authorized = !matches!( + metadata.fork_authorization, + GameDistributionForkAuthorization::Forbidden + ); + let include_project_bundle = fork_authorized && include_project_bundle.unwrap_or(true); // 根幂等键由账本按「账号 + origin + 本地项目 + 包摘要 + 目标游戏/版本 + 资料摘要」解析: // 同一份包重发、响应丢失后重试、进程重启后的分片续传都落在同一次服务端尝试上; // 包内容、目标版本标签或资料任一变化都换键,新标签的提交不会被幂等收据吞掉。 @@ -1631,9 +1729,10 @@ pub(crate) async fn publish_local_project_game( ); let upload_publish_id = root_key.clone(); let verify_publish_id = root_key.clone(); - let uploaded: GamePackageUploadOutcome = upload_staged_game_package( + let uploaded: GamePackageUploadOutcome = upload_staged_version_asset( &client, GamePackageUploadRequest { + asset: PackageUploadAsset::ReleasePackage, staging_path: Path::new(&staged.staging_path), version_id: &version.version_id, api_base_url: &snapshot.api_base_url, @@ -1666,6 +1765,26 @@ pub(crate) async fn publish_local_project_game( ) .await?; validate_session(&snapshot)?; + // 工程源包(M2b):授权非「禁止共创」且作者没取消时,随这次版本一起传上去。发布流程在这里 + // 还不是终态(下面才送审),正是服务端允许传工程包的阶段(`awaiting_upload` / `upload_failed`); + // 传失败**不阻断发布**,只把原因交给面板提示「只能被产物级改编」。 + let project_bundle_warning = if include_project_bundle { + upload_project_bundle_for_version( + &app, + &client, + &snapshot, + &session_identity, + &app_data_dir, + root, + version.version_id.as_str(), + root_key.as_str(), + ) + .await + .err() + .map(|error| format!("未上传工程源码,本作品只能被产物级改编:{error}")) + } else { + None + }; emit_publish_progress( &app, &root_key, @@ -1743,6 +1862,7 @@ pub(crate) async fn publish_local_project_game( file_count: staged.package_file_count, manifest: Some(manifest), publication: Some(binding), + project_bundle_warning, }) } 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 d90d18c3d..0522f1abe 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 @@ -15,8 +15,9 @@ use serde_json::Value; mod runtime; pub(crate) use runtime::{ - game_package_upload_staging_dir, progress_event_payload, upload_staged_game_package, - GamePackageUploadOutcome, GamePackageUploadRequest, GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, + game_package_upload_staging_dir, progress_event_payload, upload_staged_version_asset, + GamePackageUploadOutcome, GamePackageUploadRequest, PackageUploadAsset, + GAME_PACKAGE_UPLOAD_PROGRESS_EVENT, }; /// 已暂存(归一化后)的发行包:内容寻址,重启后同一包复用同一个文件,续传才有意义。 @@ -104,10 +105,11 @@ fn next_chunk_plan(received: u64, total: u64, chunk_bytes: u64) -> Option String { +fn upload_url(base_url: &str, version_id: &str, asset: PackageUploadAsset, suffix: &str) -> String { format!( - "{}/api/game-distribution/versions/{version_id}/package{suffix}", - base_url.trim_end_matches('/') + "{}/api/game-distribution/versions/{version_id}/{}{suffix}", + base_url.trim_end_matches('/'), + asset.url_segment() ) } @@ -181,6 +183,45 @@ mod tests { assert!(next_chunk_plan(0, total, 0).is_none()); } + #[test] + fn upload_asset_routes_and_offset_codes_stay_apart() { + // 两条资产走同一段续传逻辑,URL 资源段与「偏移不符」错误码必须各自独立: + // 判错码会让偏移续传认不出服务端给的位置。 + assert_eq!( + upload_url( + "https://dev.test/", + "version_1", + PackageUploadAsset::ReleasePackage, + "/chunk" + ), + "https://dev.test/api/game-distribution/versions/version_1/package/chunk" + ); + assert_eq!( + upload_url( + "https://dev.test", + "version_1", + PackageUploadAsset::ProjectBundle, + "/upload-state" + ), + "https://dev.test/api/game-distribution/versions/version_1/project-bundle/upload-state" + ); + assert_eq!( + PackageUploadAsset::ReleasePackage.offset_mismatch_code(), + "PACKAGE_UPLOAD_OFFSET_MISMATCH" + ); + assert_eq!( + PackageUploadAsset::ProjectBundle.offset_mismatch_code(), + "PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH" + ); + // 只有发行包在创建版本时预登记了大小。 + assert!(PackageUploadAsset::ReleasePackage.has_declared_bytes()); + assert!(!PackageUploadAsset::ProjectBundle.has_declared_bytes()); + // 文案必须能区分是哪条资产失败了(面板据此提示)。 + assert!(PackageUploadAsset::ProjectBundle + .label() + .contains("工程源包")); + } + #[test] fn offset_mismatch_response_yields_authoritative_position() { let body = r#"{"error":{"code":"PACKAGE_UPLOAD_OFFSET_MISMATCH","message":"偏移不一致","details":{"provider":"game-distribution","receivedBytes":16777216}}}"#; @@ -201,11 +242,21 @@ mod tests { #[test] fn upload_url_joins_base_without_double_slash() { assert_eq!( - upload_url("https://dev.genarrative.world/", "ver-1", "/chunk"), + upload_url( + "https://dev.genarrative.world/", + "ver-1", + PackageUploadAsset::ReleasePackage, + "/chunk" + ), "https://dev.genarrative.world/api/game-distribution/versions/ver-1/package/chunk" ); assert_eq!( - upload_url("http://127.0.0.1:10001", "ver 1", "/complete"), + upload_url( + "http://127.0.0.1:10001", + "ver 1", + PackageUploadAsset::ReleasePackage, + "/complete" + ), "http://127.0.0.1:10001/api/game-distribution/versions/ver 1/package/complete" ); } 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 ccaf75a95..c607545a2 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 @@ -27,6 +27,48 @@ pub(crate) struct GamePackageUploadOutcome { pub(crate) uploaded_bytes: u64, } +/// 可续传上传的版本级资产:发行包与工程源包共用同一条分片续传链路,只有资源段与文案不同。 +/// +/// 两侧服务端路由同形,但**错误码不同**(`PACKAGE_UPLOAD_OFFSET_MISMATCH` / +/// `PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH`),偏移续传必须按资产判码,不能靠子串猜。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum PackageUploadAsset { + ReleasePackage, + ProjectBundle, +} + +impl PackageUploadAsset { + /// URL 里的资源段:`…/versions/{version_id}/`。 + pub(super) fn url_segment(self) -> &'static str { + match self { + Self::ReleasePackage => "package", + Self::ProjectBundle => "project-bundle", + } + } + + /// 面向用户的资产名。 + pub(crate) fn label(self) -> &'static str { + match self { + Self::ReleasePackage => "发行包", + Self::ProjectBundle => "工程源包", + } + } + + /// 服务端「偏移不符」错误码;偏移续传按它取权威偏移。 + pub(super) fn offset_mismatch_code(self) -> &'static str { + match self { + Self::ReleasePackage => "PACKAGE_UPLOAD_OFFSET_MISMATCH", + Self::ProjectBundle => "PROJECT_BUNDLE_UPLOAD_OFFSET_MISMATCH", + } + } + + /// 只有发行包在创建版本时预登记了大小;工程源包没有,因此不做「本地体积 vs 声明体积」校验。 + pub(super) fn has_declared_bytes(self) -> bool { + matches!(self, Self::ReleasePackage) + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct PackageUploadState { @@ -43,12 +85,16 @@ pub(crate) fn game_package_upload_staging_dir(app_data_dir: &Path) -> PathBuf { app_data_dir.join(UPLOAD_STAGING_DIR_NAME) } -fn read_chunk(file: &mut File, plan: ChunkPlan) -> Result, String> { +fn read_chunk( + file: &mut File, + plan: ChunkPlan, + asset: PackageUploadAsset, +) -> Result, String> { file.seek(SeekFrom::Start(plan.offset)) - .map_err(|error| format!("读取发行包失败:{error}"))?; + .map_err(|error| format!("读取{}失败:{error}", asset.label()))?; let mut buffer = vec![0_u8; usize::try_from(plan.length).unwrap_or(0)]; file.read_exact(&mut buffer) - .map_err(|error| format!("读取发行包失败:{error}"))?; + .map_err(|error| format!("读取{}失败:{error}", asset.label()))?; Ok(buffer) } @@ -80,52 +126,57 @@ pub(crate) enum GamePackageUploadStep { } impl GamePackageUploadStep { - fn label(self) -> &'static str { + fn label(self, asset: PackageUploadAsset) -> String { match self { - Self::ReadUploadState => "读取上传状态失败", - Self::UploadChunk => "发行包分片上传失败", - Self::CompleteUpload => "完成发行包上传失败", + Self::ReadUploadState => format!("读取{}上传状态失败", asset.label()), + Self::UploadChunk => format!("{}分片上传失败", asset.label()), + Self::CompleteUpload => format!("完成{}上传失败", asset.label()), } } } -/// 发行包上传失败:`step` 说明是哪一步,`detail` 是这一步的原始事实(服务端 message / code / -/// `HTTP {status}`)。 +/// 上传失败:`asset` + `step` 说明是哪条资产的哪一步,`detail` 是这一步的原始事实 +/// (服务端 message / code / `HTTP {status}`)。 #[derive(Clone, Debug, serde::Serialize)] pub(crate) struct GamePackageUploadError { + pub(crate) asset: PackageUploadAsset, pub(crate) step: GamePackageUploadStep, pub(crate) detail: String, } impl GamePackageUploadError { - fn read_upload_state(detail: impl Into) -> Self { + fn new( + asset: PackageUploadAsset, + step: GamePackageUploadStep, + detail: impl Into, + ) -> Self { Self { - step: GamePackageUploadStep::ReadUploadState, + asset, + step, detail: detail.into(), } } - fn upload_chunk(detail: impl Into) -> Self { - Self { - step: GamePackageUploadStep::UploadChunk, - detail: detail.into(), - } + fn read_upload_state(asset: PackageUploadAsset, detail: impl Into) -> Self { + Self::new(asset, GamePackageUploadStep::ReadUploadState, detail) } - fn complete_upload(detail: impl Into) -> Self { - Self { - step: GamePackageUploadStep::CompleteUpload, - detail: detail.into(), - } + fn upload_chunk(asset: PackageUploadAsset, detail: impl Into) -> Self { + Self::new(asset, GamePackageUploadStep::UploadChunk, detail) + } + + fn complete_upload(asset: PackageUploadAsset, detail: impl Into) -> Self { + Self::new(asset, GamePackageUploadStep::CompleteUpload, detail) } /// 给用户/模型的那句:步骤 + 事实。步骤来自 `step` 枚举,不从调用点硬拼。 pub(crate) fn to_user_msg(&self) -> String { + let label = self.step.label(self.asset); let detail = self.detail.trim(); if detail.is_empty() { - self.step.label().to_string() + label } else { - format!("{}:{detail}", self.step.label()) + format!("{label}:{detail}") } } } @@ -142,36 +193,38 @@ async fn read_upload_state( base_url: &str, version_id: &str, access_token: &str, + asset: PackageUploadAsset, ) -> Result { let response = platform_request( client, access_token, reqwest::Method::GET, - &upload_url(base_url, version_id, "/upload-state"), + &upload_url(base_url, version_id, asset, "/upload-state"), ) .send() .await .map_err(|error| { - GamePackageUploadError::read_upload_state(format!( - "无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}" - )) + GamePackageUploadError::read_upload_state( + asset, + format!("无法连接登录服务,请确认配套后端或 API 代理已启动后重试:{error}"), + ) })?; let status = response.status().as_u16(); let body = response .text() .await - .map_err(|error| GamePackageUploadError::read_upload_state(format!("{error}")))?; + .map_err(|error| GamePackageUploadError::read_upload_state(asset, format!("{error}")))?; if status >= 400 { let (code, message) = parse_server_error(status, &body); let detail = server_error_detail(code, message, || format!("HTTP {status}")); - return Err(GamePackageUploadError::read_upload_state(detail)); + return Err(GamePackageUploadError::read_upload_state(asset, detail)); } let parsed: Value = serde_json::from_str(&body).map_err(|error| { - GamePackageUploadError::read_upload_state(format!("响应不是合法 JSON:{error}")) + GamePackageUploadError::read_upload_state(asset, format!("响应不是合法 JSON:{error}")) })?; let payload = parsed.get("data").unwrap_or(&parsed); serde_json::from_value(payload.clone()).map_err(|error| { - GamePackageUploadError::read_upload_state(format!("响应缺少字段:{error}")) + GamePackageUploadError::read_upload_state(asset, format!("响应缺少字段:{error}")) }) } @@ -182,6 +235,7 @@ async fn upload_chunk( version_id: &str, access_token: &str, idempotency_key: &str, + asset: PackageUploadAsset, plan: ChunkPlan, body: Vec, ) -> Result { @@ -189,7 +243,7 @@ async fn upload_chunk( client, access_token, reqwest::Method::PUT, - &upload_url(base_url, version_id, "/chunk"), + &upload_url(base_url, version_id, asset, "/chunk"), ) .header(reqwest::header::CONTENT_TYPE, "application/octet-stream") .header(UPLOAD_OFFSET_HEADER, plan.offset.to_string()) @@ -198,19 +252,24 @@ async fn upload_chunk( .send() .await .map_err(|error| { - ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( - "请求未送达:{error}" - ))) + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk( + asset, + format!("请求未送达:{error}"), + )) })?; let status = response.status().as_u16(); let text = response.text().await.map_err(|error| { - ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!("{error}"))) + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk( + asset, + format!("{error}"), + )) })?; if status == 409 { let (code, message) = parse_server_error(status, &text); - if code.as_deref() == Some("PACKAGE_UPLOAD_OFFSET_MISMATCH") { + if code.as_deref() == Some(asset.offset_mismatch_code()) { let received = parse_received_bytes(&text).ok_or_else(|| { ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk( + asset, "分片偏移不一致,但服务端未返回权威偏移", )) })?; @@ -218,27 +277,28 @@ async fn upload_chunk( } let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( - GamePackageUploadError::upload_chunk(detail), + GamePackageUploadError::upload_chunk(asset, detail), )); } if status >= 500 || status == 408 || status == 429 { let (code, message) = parse_server_error(status, &text); let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Retryable( - GamePackageUploadError::upload_chunk(detail), + GamePackageUploadError::upload_chunk(asset, detail), )); } if status >= 400 { let (code, message) = parse_server_error(status, &text); let detail = server_error_detail(code, message, || format!("HTTP {status}")); return Err(ChunkUploadError::Fatal( - GamePackageUploadError::upload_chunk(detail), + GamePackageUploadError::upload_chunk(asset, detail), )); } let parsed: Value = serde_json::from_str(&text).map_err(|error| { - ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk(format!( - "响应不是合法 JSON:{error}" - ))) + ChunkUploadError::Retryable(GamePackageUploadError::upload_chunk( + asset, + format!("响应不是合法 JSON:{error}"), + )) })?; let payload = parsed.get("data").unwrap_or(&parsed); payload @@ -246,6 +306,7 @@ async fn upload_chunk( .and_then(Value::as_u64) .ok_or_else(|| { ChunkUploadError::Fatal(GamePackageUploadError::upload_chunk( + asset, "响应缺少 receivedBytes", )) }) @@ -262,29 +323,32 @@ async fn complete_upload( version_id: &str, access_token: &str, idempotency_key: &str, + asset: PackageUploadAsset, ) -> Result { let response = platform_request( client, access_token, reqwest::Method::POST, - &upload_url(base_url, version_id, "/complete"), + &upload_url(base_url, version_id, asset, "/complete"), ) .header("Idempotency-Key", format!("{idempotency_key}:complete")) .send() .await - .map_err(|error| GamePackageUploadError::complete_upload(format!("请求未送达:{error}")))?; + .map_err(|error| { + GamePackageUploadError::complete_upload(asset, format!("请求未送达:{error}")) + })?; let status = response.status().as_u16(); let text = response .text() .await - .map_err(|error| GamePackageUploadError::complete_upload(format!("{error}")))?; + .map_err(|error| GamePackageUploadError::complete_upload(asset, format!("{error}")))?; if status >= 400 { let (code, message) = parse_server_error(status, &text); let detail = server_error_detail(code, message, || format!("HTTP {status}")); - return Err(GamePackageUploadError::complete_upload(detail)); + return Err(GamePackageUploadError::complete_upload(asset, detail)); } let parsed: Value = serde_json::from_str(&text).map_err(|error| { - GamePackageUploadError::complete_upload(format!("响应不是合法 JSON:{error}")) + GamePackageUploadError::complete_upload(asset, format!("响应不是合法 JSON:{error}")) })?; let payload = parsed.get("data").unwrap_or(&parsed); Ok(GamePackageUploadOutcome { @@ -303,6 +367,7 @@ async fn complete_upload( } pub(crate) struct GamePackageUploadRequest<'a> { + pub(crate) asset: PackageUploadAsset, pub(crate) staging_path: &'a Path, pub(crate) version_id: &'a str, pub(crate) api_base_url: &'a str, @@ -325,17 +390,22 @@ impl<'a> GamePackageUploadRequest<'a> { } /// 分片续传主循环:权威偏移来自服务端,失败按可重试分类退避,偏移不符立即按权威偏移继续。 -pub(crate) async fn upload_staged_game_package( +/// +/// 发行包与工程源包共用这一段:差异全部收在 `request.asset`(URL 资源段、文案、偏移错误码、 +/// 是否有预登记大小)。 +pub(crate) async fn upload_staged_version_asset( client: &reqwest::Client, request: GamePackageUploadRequest<'_>, mut on_progress: impl FnMut(u64, u64), mut on_verify: impl FnMut(), ) -> Result { + let asset = request.asset; + let label = asset.label(); let total_bytes = fs::metadata(request.staging_path) - .map_err(|error| format!("读取发行包暂存文件失败:{error}"))? + .map_err(|error| format!("读取{label}暂存文件失败:{error}"))? .len(); if total_bytes == 0 { - return Err("发行包暂存文件为空,请重新导出试玩包".to_string()); + return Err(format!("{label}暂存文件为空,请重新导出后再发布")); } let access_token = request.current_access_token()?; let state = read_upload_state( @@ -343,11 +413,16 @@ pub(crate) async fn upload_staged_game_package( request.api_base_url, request.version_id, &access_token, + asset, ) .await?; - if state.declared_package_bytes != 0 && state.declared_package_bytes != total_bytes { + // 只有发行包在创建版本时预登记了大小;工程源包的体积由 complete 时的整包校验确定。 + if asset.has_declared_bytes() + && state.declared_package_bytes != 0 + && state.declared_package_bytes != total_bytes + { return Err(format!( - "本地发行包与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", + "本地{label}与版本声明的体积不一致(本地 {} 字节,声明 {} 字节),请重新导出后再发布", total_bytes, state.declared_package_bytes )); } @@ -359,9 +434,9 @@ pub(crate) async fn upload_staged_game_package( 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}"))?; + .map_err(|error| format!("打开{label}暂存文件失败:{error}"))?; while let Some(plan) = next_chunk_plan(received, total_bytes, chunk_bytes) { - let body = read_chunk(&mut file, plan)?; + let body = read_chunk(&mut file, plan, asset)?; let mut attempt = 1_usize; loop { let access_token = request.current_access_token()?; @@ -371,6 +446,7 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, request.idempotency_key, + asset, plan, body.clone(), ) @@ -401,6 +477,7 @@ pub(crate) async fn upload_staged_game_package( request.api_base_url, request.version_id, &access_token, + asset, ) .await?; received = authoritative.received_bytes.min(total_bytes); @@ -414,6 +491,7 @@ pub(crate) async fn upload_staged_game_package( request.version_id, &access_token, request.idempotency_key, + asset, ) .await?; outcome.uploaded_bytes = total_bytes; diff --git a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx index 84e3a959f..26ed5f613 100644 --- a/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx +++ b/apps/ai-game-creator-shell/src/components/game-distribution/GameDistributionPublishPanel.tsx @@ -253,6 +253,13 @@ export function GameDistributionPublishPanel({ useState( GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION, ); + /** + * 是否随版本公开工程源包(默认勾选)。 + * + * 口径(与产品一致):授权非「禁止共创」时默认帮作者把工程源码一起传上去,他人即可取走; + * 作者可以取消——取消后作品仍照常发布,只是退化为「只能被产物级改编(试玩 + 参考)」。 + */ + const [includeProjectBundle, setIncludeProjectBundle] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState( @@ -765,6 +772,11 @@ export function GameDistributionPublishPanel({ publicationState === 'update' ? (publicationBinding?.revision ?? null) : null, + // 只在「授权已开放但作者取消勾选」时显式关掉工程源包上传;其余情况由原生按 + // 「授权非禁止即上传」判断,避免面板与原生各写一套默认值。 + ...(forkAuthorization !== 'forbidden' && !includeProjectBundle + ? { includeProjectBundle: false } + : {}), }); setResult(next); onPublished?.(next); @@ -828,6 +840,18 @@ export function GameDistributionPublishPanel({ {result.packageSizeBytes.toLocaleString()} B ·{' '} {result.packageSha256.slice(0, 16)}…

+ {/* + 工程源包没传上去不属于发布失败:作品照常可玩可发布,但他人只能产物级改编。 + 必须如实告知,否则作者会以为源码已经随版本公开。 + */} + {result.projectBundleWarning ? ( +

+ {result.projectBundleWarning} +

+ ) : null}