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}