feat(游戏共创): 授权非禁止时随版本上传工程源包(M2b 上传链路,失败不阻断发布)

上传链路(复用既有分片续传,不另写一套):
- `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 才带」用例。
This commit is contained in:
2026-10-05 16:10:12 +08:00
parent 360cb9830a
commit 1fc78faff2
9 changed files with 527 additions and 69 deletions
@@ -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(),
@@ -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<GameCreationAppPublicationBinding>,
/// 工程源包**未**随版本上传时的用户可见原因(`None` = 未尝试或已成功)。
///
/// 上传工程源包失败绝不阻断发布:作者的作品已经可玩可发布,只是退化为「产物级改编」。
/// 面板据此提示「未上传工程源码,本作品只能被产物级改编」。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) project_bundle_warning: Option<String>,
}
/// 回读版本冻结资料时带回的封面/截图草稿条目。
@@ -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<u64>,
expected_publication_revision: Option<u64>,
idempotency_key: Option<String>,
include_project_bundle: Option<bool>,
) -> Result<GameDistributionPublishResult, String> {
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,
})
}
@@ -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<ChunkP
})
}
fn upload_url(base_url: &str, version_id: &str, suffix: &str) -> 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"
);
}
@@ -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}/<segment>`。
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<Vec<u8>, String> {
fn read_chunk(
file: &mut File,
plan: ChunkPlan,
asset: PackageUploadAsset,
) -> Result<Vec<u8>, 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<String>) -> Self {
fn new(
asset: PackageUploadAsset,
step: GamePackageUploadStep,
detail: impl Into<String>,
) -> Self {
Self {
step: GamePackageUploadStep::ReadUploadState,
asset,
step,
detail: detail.into(),
}
}
fn upload_chunk(detail: impl Into<String>) -> Self {
Self {
step: GamePackageUploadStep::UploadChunk,
detail: detail.into(),
}
fn read_upload_state(asset: PackageUploadAsset, detail: impl Into<String>) -> Self {
Self::new(asset, GamePackageUploadStep::ReadUploadState, detail)
}
fn complete_upload(detail: impl Into<String>) -> Self {
Self {
step: GamePackageUploadStep::CompleteUpload,
detail: detail.into(),
}
fn upload_chunk(asset: PackageUploadAsset, detail: impl Into<String>) -> Self {
Self::new(asset, GamePackageUploadStep::UploadChunk, detail)
}
fn complete_upload(asset: PackageUploadAsset, detail: impl Into<String>) -> 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<PackageUploadState, GamePackageUploadError> {
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<u8>,
) -> Result<u64, ChunkUploadError> {
@@ -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<GamePackageUploadOutcome, GamePackageUploadError> {
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<GamePackageUploadOutcome, String> {
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;
@@ -253,6 +253,13 @@ export function GameDistributionPublishPanel({
useState<GameDistributionForkAuthorization>(
GAME_DISTRIBUTION_DEFAULT_FORK_AUTHORIZATION,
);
/**
* 是否随版本公开工程源包(默认勾选)。
*
* 口径(与产品一致):授权非「禁止共创」时默认帮作者把工程源码一起传上去,他人即可取走;
* 作者可以取消——取消后作品仍照常发布,只是退化为「只能被产物级改编(试玩 + 参考)」。
*/
const [includeProjectBundle, setIncludeProjectBundle] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [result, setResult] = useState<GameDistributionPublishResult | null>(
@@ -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)}…
</p>
{/*
工程源包没传上去不属于发布失败:作品照常可玩可发布,但他人只能产物级改编。
必须如实告知,否则作者会以为源码已经随版本公开。
*/}
{result.projectBundleWarning ? (
<p
className="game-distribution-publish-panel__bundle-warning"
role="alert"
>
{result.projectBundleWarning}
</p>
) : null}
<div className="game-distribution-publish-panel__actions">
<button type="button" onClick={onClose}>
完成
@@ -949,6 +973,25 @@ export function GameDistributionPublishPanel({
? '共创授权只在首次发布时生效;上架后只能在网页端「我的作品」单向提升。'
: '开启后可被他人复刻改编(仅能拿到已构建成品,用于试玩与参考);上架后只能单向提升,不能撤销。'}
</p>
{/*
工程源码是否随版本公开:授权非「禁止共创」时才有意义(禁止共创谁也取不走)。
默认勾选,且必须把「不可撤销」说清楚——源码一旦公开就无法收回。
*/}
{forkAuthorization !== 'forbidden' && !forkAuthorizationLocked ? (
<label className="game-distribution-publish-panel__bundle-consent">
<input
type="checkbox"
checked={includeProjectBundle}
onChange={(event) =>
setIncludeProjectBundle(event.target.checked)
}
/>
<span>
同时公开工程源码:允许他人下载并使用你的工程源文件(含素材),
且不可撤销;取消勾选则本作品只能被他人试玩与参考(不能直接重新发布)
</span>
</label>
) : null}
</fieldset>
<label>
<span className="game-distribution-publish-panel__version-label">
@@ -61,6 +61,12 @@ export type GameDistributionPublishResult = {
/** 发布成功并回写绑定后的整份 manifest;渲染层据此同步工作台状态。 */
manifest?: GameCreationAppManifest | null;
publication?: GameCreationAppPublicationBinding | null;
/**
* 工程源包**未**随版本上传时的用户可见原因;`undefined`/`null` = 未尝试或已成功。
*
* 上传工程源包失败不阻断发布:作品照常可玩可发布,只是退化为「产物级改编」。
*/
projectBundleWarning?: string | null;
};
/** 更新发布时默认回填的封面/截图;`assetId` 缺失表示历史版本没有冻结素材,需重新选择。 */
@@ -331,6 +337,12 @@ export async function publishLocalProjectGame(args: {
* 只有显式传入(测试或调用方已持有键)时才覆盖账本。
*/
idempotencyKey?: string;
/**
* 是否随版本上传工程源包(面板「同时公开工程源码」勾选)。
*
* 缺省即「授权非禁止时上传」;`false` 表示作者主动取消——源码不外发,作品只能被产物级改编。
*/
includeProjectBundle?: boolean;
}): Promise<GameDistributionPublishResult> {
const projectPath = args.projectPath.trim();
const packageRelativePath = args.packageRelativePath.trim();
@@ -373,6 +385,11 @@ export async function publishLocalProjectGame(args: {
? { expectedPublicationRevision: args.expectedPublicationRevision }
: {}),
...(rootKey ? { idempotencyKey: rootKey } : {}),
// 面板勾选:`false` 才是「作者取消」,因此只有显式 false 时才带这个字段,
// 其余情况交给原生按「授权非禁止即上传」判断。
...(args.includeProjectBundle === false
? { includeProjectBundle: false }
: {}),
},
);
}
+31
View File
@@ -3702,6 +3702,37 @@ textarea {
line-height: 1.5;
}
/* 工程源码公开勾选:与上面的三态同宽同位,复选框不能套用「整行输入框」样式。 */
.game-distribution-publish-panel__fields
.game-distribution-publish-panel__bundle-consent {
display: flex;
align-items: flex-start;
gap: 8px;
color: var(--platform-text-soft);
font-size: 11px;
font-weight: 500;
line-height: 1.5;
}
.game-distribution-publish-panel__fields
.game-distribution-publish-panel__bundle-consent
input[type='checkbox'] {
box-sizing: border-box;
width: auto;
min-width: 0;
margin: 2px 0 0;
padding: 0;
accent-color: #a8663d;
}
.game-distribution-publish-panel__bundle-warning {
margin: 0;
color: #b42318;
font-size: 11px;
font-weight: 600;
line-height: 1.5;
}
.game-distribution-publish-panel__media {
display: grid;
gap: 14px;
@@ -115,6 +115,32 @@ describe('Rust 发布 facade', () => {
});
});
test('取消公开工程源码时显式传 includeProjectBundle=false', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
includeProjectBundle: false,
});
expect(calls[0]?.args).toMatchObject({ includeProjectBundle: false });
// 勾选(或未表态)时不带这个字段:默认由原生按「授权非禁止即上传」判断,
// 渲染层不重复实现同一套默认值。
await publishLocalProjectGame({
invoke,
projectPath: '/tmp/project',
packageRelativePath: 'exports/playtest-package-1.zip',
manifest: MANIFEST,
metadata: { coverAssetId: 'asset_cover' },
includeProjectBundle: true,
});
expect(calls[1]?.args).not.toHaveProperty('includeProjectBundle');
});
test('共创授权档位随创建作品请求提交,改编声明不来自渲染层', async () => {
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
@@ -382,6 +382,97 @@ describe('GameDistributionPublishPanel', () => {
expect(args?.metadata?.forkAuthorization).toBe('full');
});
test('授权开放后默认勾选公开工程源码,勾选时不重复传默认值', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
renderPanel();
// 默认「禁止共创」时源码不会被任何人取走,因此不出现这个勾选。
expect(screen.queryByRole('checkbox')).toBeNull();
fireEvent.click(screen.getByRole('radio', { name: '允许非商用共创' }));
const consent = await screen.findByRole('checkbox');
// 默认勾选:授权非禁止时默认帮作者把工程源码一起传上去。
expect(consent).toHaveProperty('checked', true);
// 文案必须把「不可撤销」与「含素材」说清楚。
expect(
screen.getByText(/允许他人下载并使用你的工程源文件(含素材)/u),
).not.toBeNull();
expect(screen.getByText(/且不可撤销/u)).not.toBeNull();
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
// 勾选状态留给原生按默认值判断:这里不带 includeProjectBundle。
expect(
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0],
).not.toHaveProperty('includeProjectBundle');
});
test('取消勾选公开工程源码时显式传 includeProjectBundle=false', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
});
renderPanel();
fireEvent.click(screen.getByRole('radio', { name: '允许全开放共创' }));
fireEvent.click(await screen.findByRole('checkbox'));
await waitFor(() =>
expect(screen.queryByRole('checkbox')).toHaveProperty('checked', false),
);
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
await waitFor(() =>
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
);
// 显式 false:源码不外发,作品退化为产物级改编。
expect(
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0],
).toMatchObject({ includeProjectBundle: false });
});
test('工程源包未上传时成功页如实提示只能产物级改编', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(publishLocalProjectGame).mockResolvedValue({
gameId: 'game_1',
versionId: 'gamever_1',
versionNumber: 1,
status: 'pending_review',
packageSha256: 'a'.repeat(64),
packageSizeBytes: 3,
fileCount: 2,
projectBundleWarning: '未上传工程源码,本作品只能被产物级改编:工程源包过大',
});
renderPanel();
await selectCover();
fireEvent.click(screen.getByRole('button', { name: '发布游戏' }));
expect(await screen.findByText('已提交审核')).not.toBeNull();
// 发布成功但源码没传上去必须如实告知,否则作者会以为源码已随版本公开。
const warning = await screen.findByRole('alert');
expect(warning.textContent).toContain('未上传工程源码');
expect(warning.textContent).toContain('只能被产物级改编');
});
test('更新发布时共创授权只读并给出可操作提示', async () => {
installTauriInvoke(async () => undefined);
vi.mocked(readGameDistributionPublication).mockResolvedValue({