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:
@@ -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;
|
||||
|
||||
+43
@@ -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 }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user