实现统一项目发行版本
Project CI / AI game creator shell Rust crates (pull_request) Failing after 56s
Project CI / Backend tests (pull_request) Failing after 23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 5m7s
Project CI / Repository checks (pull_request) Failing after 21s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Failing after 56s
Project CI / Backend tests (pull_request) Failing after 23s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 5m7s
Project CI / Repository checks (pull_request) Failing after 21s
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
以 projectVersion 作为 AGC 唯一用户版本,支持同版本重复提交和版本回退。 保留 publicationRevision 并发校验与旧客户端自动递增兼容。
This commit is contained in:
@@ -64,7 +64,7 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [
|
||||
key: 'game-distribution:publish',
|
||||
label: '游戏发布',
|
||||
description:
|
||||
'未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行',
|
||||
'游戏发布入口灰度:未配置或关闭时不开放,启用后按白名单 / 用户标签 / 灰度比例放行',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -135,6 +135,7 @@ const allowedUncalledTauriCommands = [
|
||||
'read_game_cover_generation_price',
|
||||
'suggest_game_distribution_publish_metadata',
|
||||
'read_game_distribution_publication',
|
||||
'update_game_distribution_project_version',
|
||||
'upload_local_project_game_package',
|
||||
// 账户与钱包由 Rust typed command 持有 origin/Bearer/envelope;命令名在 `accountHost.ts`
|
||||
// 里以字面量出现,静态扫描仍按共享注册表核验。
|
||||
|
||||
@@ -611,6 +611,7 @@ pub(super) fn run() {
|
||||
read_game_cover_generation_price,
|
||||
generate_game_distribution_cover,
|
||||
read_game_distribution_publication,
|
||||
update_game_distribution_project_version,
|
||||
publish_local_project_game,
|
||||
read_game_creator_app_config,
|
||||
clear_game_creator_acl_elevation_denials,
|
||||
|
||||
@@ -22,7 +22,8 @@ use serde_json::{json, Value};
|
||||
use sha2::{Digest, Sha256};
|
||||
use shared_contracts::api::API_RESPONSE_ENVELOPE_HEADER;
|
||||
use shared_contracts::game_creation_app::{
|
||||
GameCreationAppManifest, GameCreationAppPublicationBinding,
|
||||
resolve_game_creation_app_project_version, GameCreationAppManifest,
|
||||
GameCreationAppPublicationBinding,
|
||||
};
|
||||
use shared_contracts::game_distribution::{
|
||||
GameDistributionCreateGameRequest, GameDistributionCreateVersionRequest,
|
||||
@@ -342,8 +343,8 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||||
server_error_detail(code, message, || "当前账号无权执行此操作".to_string())
|
||||
);
|
||||
}
|
||||
// 目标版本号冲突必须是可读且可区分的提示:这是更新发布会遇到的主要正常失败,
|
||||
// 不能让作者看到笼统的「HTTP 409」。
|
||||
// 版本号无法分配必须是可读且可区分的提示:只有旧客户端不传版本号、服务端自动递增撞上
|
||||
// 上限时才会走到这里,不能让作者看到笼统的「HTTP 409」。
|
||||
if status == StatusCode::CONFLICT
|
||||
&& (code.as_deref() == Some("VERSION_NUMBER_CONFLICT")
|
||||
|| message
|
||||
@@ -360,7 +361,7 @@ fn map_http_error(status: StatusCode, body: &str, fallback: &str) -> String {
|
||||
.trim()
|
||||
})
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("目标版本号必须大于当前最大版本号");
|
||||
.unwrap_or("服务端无法为这次发布分配版本号");
|
||||
return format!("版本号冲突:{detail}");
|
||||
}
|
||||
let detail = server_error_detail(code, message, || format!("HTTP {}", status.as_u16()));
|
||||
@@ -427,8 +428,8 @@ fn normalized_origin(value: &str) -> String {
|
||||
}
|
||||
|
||||
fn metadata_digest(metadata: &GameDistributionCreateGameRequest) -> Result<String, String> {
|
||||
let bytes = serde_json::to_vec(metadata)
|
||||
.map_err(|error| format!("计算发布资料摘要失败:{error}"))?;
|
||||
let bytes =
|
||||
serde_json::to_vec(metadata).map_err(|error| format!("计算发布资料摘要失败:{error}"))?;
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
Ok(format!("{:x}", hasher.finalize()))
|
||||
@@ -451,8 +452,8 @@ async fn request_json_for_read(
|
||||
segments: &[&str],
|
||||
fallback: &str,
|
||||
) -> Result<Value, GameDistributionReadError> {
|
||||
let current = current_scoped_session(snapshot)
|
||||
.map_err(GameDistributionReadError::Unavailable)?;
|
||||
let current =
|
||||
current_scoped_session(snapshot).map_err(GameDistributionReadError::Unavailable)?;
|
||||
let url = endpoint(snapshot, segments).map_err(GameDistributionReadError::Rejected)?;
|
||||
let request = client
|
||||
.request(method, &url)
|
||||
@@ -474,10 +475,9 @@ async fn request_json_for_read(
|
||||
})
|
||||
})?;
|
||||
let status = response.status();
|
||||
let text = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|error| GameDistributionReadError::Unavailable(format!("{fallback}:读取响应失败:{error}")))?;
|
||||
let text = response.text().await.map_err(|error| {
|
||||
GameDistributionReadError::Unavailable(format!("{fallback}:读取响应失败:{error}"))
|
||||
})?;
|
||||
if let Err(message) = validate_session(snapshot) {
|
||||
return Err(GameDistributionReadError::Unavailable(message));
|
||||
}
|
||||
@@ -524,11 +524,20 @@ fn unavailable_state(message: Option<String>) -> GameDistributionPublicationRead
|
||||
}
|
||||
}
|
||||
|
||||
/// 回写发布绑定;旧清单缺 `projectVersion` 时按绑定回填。
|
||||
///
|
||||
/// 绑定本身已按当前会话(账号 + origin)与 gameId 隔离,所以回填只会用**本项目**的线上版本号,
|
||||
/// 不会把别的账号/环境的版本写进这份清单。已有值一律保留:用户手动改过的版本标签,重开项目
|
||||
/// 不能被线上最大值悄悄覆盖。
|
||||
fn write_publication_binding(
|
||||
root: &Path,
|
||||
binding: GameCreationAppPublicationBinding,
|
||||
) -> Result<(), String> {
|
||||
crate::project::mutate_manifest_at(root, |manifest| {
|
||||
manifest.project_version = Some(resolve_game_creation_app_project_version(
|
||||
manifest.project_version,
|
||||
Some(&binding),
|
||||
));
|
||||
manifest.publication = Some(binding.clone());
|
||||
Ok(())
|
||||
})
|
||||
@@ -717,7 +726,9 @@ async fn build_publication_draft(
|
||||
.into_iter()
|
||||
.map(|screenshot| (screenshot.asset_id, screenshot.object_key))
|
||||
.collect::<Vec<_>>(),
|
||||
frozen.device_support.or_else(|| entry.device_support.clone()),
|
||||
frozen
|
||||
.device_support
|
||||
.or_else(|| entry.device_support.clone()),
|
||||
if frozen.input_modes.is_empty() {
|
||||
entry.input_modes.clone()
|
||||
} else {
|
||||
@@ -743,8 +754,11 @@ async fn build_publication_draft(
|
||||
entry.orientation.clone(),
|
||||
),
|
||||
};
|
||||
let normalize_asset_id =
|
||||
|value: Option<String>| value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty());
|
||||
let normalize_asset_id = |value: Option<String>| {
|
||||
value
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
};
|
||||
let cover = if cover_asset_id.is_some() || cover_object_key.is_some() {
|
||||
Some(GameDistributionPublicationImageDraft {
|
||||
asset_id: normalize_asset_id(cover_asset_id),
|
||||
@@ -839,8 +853,14 @@ pub(crate) async fn read_game_distribution_publication(
|
||||
Some(binding) => {
|
||||
let game_id = binding.game_id.trim().to_string();
|
||||
let segments = ["api", "game-distribution", "my-games", game_id.as_str()];
|
||||
match request_json_for_read(&client, &snapshot, Method::GET, &segments, "读取线上作品失败")
|
||||
.await
|
||||
match request_json_for_read(
|
||||
&client,
|
||||
&snapshot,
|
||||
Method::GET,
|
||||
&segments,
|
||||
"读取线上作品失败",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => {
|
||||
let entry =
|
||||
@@ -865,7 +885,9 @@ pub(crate) async fn read_game_distribution_publication(
|
||||
message: Some(format!("无法刷新线上状态:{message}")),
|
||||
})
|
||||
}
|
||||
Err(GameDistributionReadError::Rejected(message)) => Ok(unavailable_state(Some(message))),
|
||||
Err(GameDistributionReadError::Rejected(message)) => {
|
||||
Ok(unavailable_state(Some(message)))
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -912,6 +934,36 @@ pub(crate) async fn read_game_distribution_publication(
|
||||
}
|
||||
}
|
||||
|
||||
/// 只写项目清单里的用户发行版本标签(`项目版本 vN`),不发布、不改发布绑定。
|
||||
///
|
||||
/// 版本标签是"对同一个 gameId 反复提交的标签",所以这里不做递增校验、也不与线上版本比较:
|
||||
/// 重复提交同一标签与回退到更小标签都合法,具体提交实例由不可变 versionId 区分。非法输入
|
||||
/// (0 或超出安全整数)在这里失败关闭,避免坏值落进清单。
|
||||
///
|
||||
/// 返回整份 manifest,调用方据此同步工作台状态(与发布成功同一条回写语义)。
|
||||
#[tauri::command]
|
||||
pub(crate) fn update_game_distribution_project_version(
|
||||
project_path: String,
|
||||
project_version: u64,
|
||||
) -> Result<GameCreationAppManifest, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err("发布需要绑定本地项目".to_string());
|
||||
}
|
||||
if project_version == 0 {
|
||||
return Err("项目版本必须是大于 0 的整数".to_string());
|
||||
}
|
||||
if project_version > MAX_SAFE_VERSION_NUMBER {
|
||||
return Err("项目版本超出安全整数范围".to_string());
|
||||
}
|
||||
// 与发布同一条门禁:改版本标签属于发布资料的一部分,不是独立的新权限。
|
||||
crate::project::enforce_project_permission_policy(root, "project.export_package")?;
|
||||
crate::project::mutate_manifest_at(root, |manifest| {
|
||||
manifest.project_version = Some(project_version);
|
||||
Ok(manifest.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn read_cover_asset_id(payload: &CoverGenerationPayload) -> Option<String> {
|
||||
payload
|
||||
.asset_object_id
|
||||
@@ -1250,17 +1302,17 @@ pub(crate) async fn publish_local_project_game(
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string);
|
||||
// 版本号按 JSON number 传输:超出安全整数就已经丢精度,必须在发请求前失败关闭。
|
||||
// 项目版本按 JSON number 传输:超出安全整数就已经丢精度,必须在发请求前失败关闭。
|
||||
if let Some(number) = version_number {
|
||||
if number == 0 {
|
||||
return Err("目标版本号必须是大于 0 的整数".to_string());
|
||||
return Err("项目版本必须是大于 0 的整数".to_string());
|
||||
}
|
||||
if number > MAX_SAFE_VERSION_NUMBER {
|
||||
return Err("目标版本号超出安全整数范围".to_string());
|
||||
return Err("项目版本超出安全整数范围".to_string());
|
||||
}
|
||||
}
|
||||
if target_game_id.is_some() && version_number.is_none() {
|
||||
return Err("更新游戏必须填写目标版本号".to_string());
|
||||
return Err("更新游戏必须填写项目版本".to_string());
|
||||
}
|
||||
if target_game_id.is_some() && expected_publication_revision.is_none() {
|
||||
return Err("缺少公开修订号,请重新打开发布面板后再试".to_string());
|
||||
@@ -1463,8 +1515,10 @@ pub(crate) async fn publish_local_project_game(
|
||||
uploaded.status.clone()
|
||||
}
|
||||
});
|
||||
// 发布成功:把平台作品绑定和最近版本状态写回本地清单。写回失败不回滚已经成功的发布,
|
||||
// 但会把错误交给作者——否则下次打开项目会退回首次发布,甚至可能重复建作品。
|
||||
// 发布成功:把平台作品绑定和最近版本状态写回本地清单,并把项目版本标签对齐到服务端返回值
|
||||
// (服务端是版本号的唯一事实来源;旧客户端没传版本号时这里会把自动递增的结果记下来)。
|
||||
// 写回失败不回滚已经成功的发布,但会把错误交给作者——否则下次打开项目会退回首次发布,
|
||||
// 甚至可能重复建作品。
|
||||
let binding = GameCreationAppPublicationBinding {
|
||||
account_id: snapshot.user_id.clone(),
|
||||
api_base_url: normalized_origin(&snapshot.api_base_url),
|
||||
@@ -1475,6 +1529,7 @@ pub(crate) async fn publish_local_project_game(
|
||||
status: Some(status.clone()),
|
||||
};
|
||||
let manifest = crate::project::mutate_manifest_at(root, |manifest| {
|
||||
manifest.project_version = Some(version.version_number);
|
||||
manifest.publication = Some(binding.clone());
|
||||
Ok(manifest.clone())
|
||||
})?;
|
||||
@@ -1557,14 +1612,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn version_number_conflict_is_a_readable_distinct_message() {
|
||||
// 更新发布会遇到的主要正常失败:必须让作者看到「版本号冲突」而不是笼统 HTTP 409。
|
||||
// 只有旧客户端不传版本号、服务端自动递增撞上上限时才可能走到这里;必须让作者看到
|
||||
// 「版本号冲突」而不是笼统 HTTP 409。
|
||||
assert_eq!(
|
||||
map_http_error(
|
||||
StatusCode::CONFLICT,
|
||||
r#"{"error":{"code":"VERSION_NUMBER_CONFLICT","message":"VERSION_NUMBER_CONFLICT: 版本号 3 必须严格大于当前最大版本号 5"}}"#,
|
||||
r#"{"error":{"code":"VERSION_NUMBER_CONFLICT","message":"VERSION_NUMBER_CONFLICT: 版本号已达上限 18446744073709551615,无法自动递增"}}"#,
|
||||
"创建游戏发行版本失败"
|
||||
),
|
||||
"版本号冲突:版本号 3 必须严格大于当前最大版本号 5"
|
||||
"版本号冲突:版本号已达上限 18446744073709551615,无法自动递增"
|
||||
);
|
||||
// 没有 message 时给出兜底提示,不泄漏原始英文代码。
|
||||
assert_eq!(
|
||||
@@ -1573,10 +1629,21 @@ mod tests {
|
||||
r#"{"error":{"code":"VERSION_NUMBER_CONFLICT"}}"#,
|
||||
"创建游戏发行版本失败"
|
||||
),
|
||||
"版本号冲突:目标版本号必须大于当前最大版本号"
|
||||
"版本号冲突:服务端无法为这次发布分配版本号"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_or_lower_project_version_is_not_a_client_side_conflict() {
|
||||
// 重复提交同一版本、回退到更小版本都由服务端接受,客户端不得在本地把它当成冲突。
|
||||
let repeated = map_http_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
r#"{"error":{"code":"BAD_REQUEST","message":"版本号必须是大于 0 的整数"}}"#,
|
||||
"创建游戏发行版本失败",
|
||||
);
|
||||
assert_eq!(repeated, "创建游戏发行版本失败:版本号必须是大于 0 的整数");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_digest_changes_with_frozen_metadata() {
|
||||
let mut metadata = GameDistributionCreateGameRequest {
|
||||
|
||||
@@ -36,7 +36,7 @@ struct GamePublishAttemptRecord {
|
||||
/// 目标游戏身份:更新已有作品时非空;首次发布为空。
|
||||
#[serde(default)]
|
||||
target_game_id: Option<String>,
|
||||
/// 用户选择的目标版本号:更新发布必须记录,换了目标版本就是一次新尝试。
|
||||
/// 这次发布提交的项目版本标签(清单里的 `projectVersion`):换了标签就是一次新尝试。
|
||||
#[serde(default)]
|
||||
target_version_number: Option<u64>,
|
||||
/// 随版本冻结的资料摘要:同一份包换了资料修订也必须换根键,不能只按 ZIP 摘要吞掉新发布。
|
||||
@@ -342,22 +342,28 @@ mod tests {
|
||||
fn same_package_reuses_root_key_and_new_package_rotates_it() {
|
||||
let dir = fixture_dir("reuse");
|
||||
let sha = "a".repeat(64);
|
||||
let (first, reused) =
|
||||
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
|
||||
.expect("first key");
|
||||
let (first, reused) = resolve_game_publish_root_key(
|
||||
&dir,
|
||||
intent("user-1", "https://dev.test", "proj-1", &sha),
|
||||
)
|
||||
.expect("first key");
|
||||
assert!(!reused);
|
||||
assert!(first.starts_with("agc-publish-"));
|
||||
|
||||
let (again, reused) =
|
||||
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
|
||||
.expect("reused key");
|
||||
let (again, reused) = resolve_game_publish_root_key(
|
||||
&dir,
|
||||
intent("user-1", "https://dev.test", "proj-1", &sha),
|
||||
)
|
||||
.expect("reused key");
|
||||
assert!(reused, "同一份包重发必须复用同一个根幂等键");
|
||||
assert_eq!(again, first);
|
||||
|
||||
// 重启不改变结论:换一个进程视角重新读盘仍然是同一个键。
|
||||
let (after_restart, reused) =
|
||||
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
|
||||
.expect("restarted key");
|
||||
let (after_restart, reused) = resolve_game_publish_root_key(
|
||||
&dir,
|
||||
intent("user-1", "https://dev.test", "proj-1", &sha),
|
||||
)
|
||||
.expect("restarted key");
|
||||
assert!(reused);
|
||||
assert_eq!(after_restart, first);
|
||||
|
||||
@@ -398,8 +404,8 @@ mod tests {
|
||||
bumped_metadata.metadata_digest = &digest_b;
|
||||
bumped_metadata.target_game_id = Some("game-1");
|
||||
bumped_metadata.target_version_number = Some(4);
|
||||
let (metadata_rotated, reused) = resolve_game_publish_root_key(&dir, bumped_metadata)
|
||||
.expect("metadata rotated key");
|
||||
let (metadata_rotated, reused) =
|
||||
resolve_game_publish_root_key(&dir, bumped_metadata).expect("metadata rotated key");
|
||||
assert!(!reused, "资料修订变化也必须换键");
|
||||
assert_ne!(metadata_rotated, bumped);
|
||||
|
||||
@@ -414,9 +420,11 @@ mod tests {
|
||||
fn attempts_are_scoped_per_account_origin_and_project() {
|
||||
let dir = fixture_dir("scope");
|
||||
let sha = "c".repeat(64);
|
||||
let (base, _) =
|
||||
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
|
||||
.expect("base key");
|
||||
let (base, _) = resolve_game_publish_root_key(
|
||||
&dir,
|
||||
intent("user-1", "https://dev.test", "proj-1", &sha),
|
||||
)
|
||||
.expect("base key");
|
||||
for (account, origin, project) in [
|
||||
("user-2", "https://dev.test", "proj-1"),
|
||||
("user-1", "https://release.test", "proj-1"),
|
||||
@@ -435,14 +443,18 @@ mod tests {
|
||||
fn clearing_attempt_rotates_the_next_publish_of_the_same_package() {
|
||||
let dir = fixture_dir("clear");
|
||||
let sha = "d".repeat(64);
|
||||
let (first, _) =
|
||||
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
|
||||
.expect("first key");
|
||||
let (first, _) = resolve_game_publish_root_key(
|
||||
&dir,
|
||||
intent("user-1", "https://dev.test", "proj-1", &sha),
|
||||
)
|
||||
.expect("first key");
|
||||
clear_game_publish_attempt(&dir, "user-1", "https://dev.test", "proj-1")
|
||||
.expect("clear attempt");
|
||||
let (second, reused) =
|
||||
resolve_game_publish_root_key(&dir, intent("user-1", "https://dev.test", "proj-1", &sha))
|
||||
.expect("second key");
|
||||
let (second, reused) = resolve_game_publish_root_key(
|
||||
&dir,
|
||||
intent("user-1", "https://dev.test", "proj-1", &sha),
|
||||
)
|
||||
.expect("second key");
|
||||
assert!(!reused, "收口过的尝试不再复用,下一次发布是新版本");
|
||||
assert_ne!(second, first);
|
||||
// 清掉不存在的记录是幂等的空操作。
|
||||
|
||||
@@ -546,7 +546,6 @@ pub(crate) fn read_local_project_export_package_at(
|
||||
}
|
||||
let content = normalize_release_asset_references(&path, content);
|
||||
entries.push((path, content));
|
||||
|
||||
}
|
||||
entries.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
if entries.is_empty() {
|
||||
@@ -594,7 +593,11 @@ pub(crate) fn read_local_project_export_package_at(
|
||||
|
||||
fn normalize_release_asset_references(path: &str, content: Vec<u8>) -> Vec<u8> {
|
||||
let is_text = matches!(
|
||||
path.rsplit('.').next().unwrap_or_default().to_ascii_lowercase().as_str(),
|
||||
path.rsplit('.')
|
||||
.next()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.as_str(),
|
||||
"css" | "html" | "htm" | "js" | "json" | "mjs"
|
||||
);
|
||||
if !is_text {
|
||||
|
||||
@@ -205,6 +205,17 @@ fn validate_manifest_cocos_project_root(value: Option<&str>) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 唯一用户发行版本标签:缺省合法(旧清单),写了就必须是正整数。
|
||||
///
|
||||
/// `0` 不是"未设置"的别名——未设置就是字段缺失;一旦落盘 0,服务端与面板都会把它当成非法
|
||||
/// 版本号,所以在读写两侧都失败关闭,避免坏值被静默传播。
|
||||
fn validate_manifest_project_version(value: Option<u64>) -> Result<(), String> {
|
||||
if value == Some(0) {
|
||||
return Err("manifest projectVersion 必须是大于 0 的整数".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unity 工程身份来自根目录普通文件和目录;禁止借助链接跳到其它工程。
|
||||
pub(crate) fn discover_local_unity_project_root(root: &Path) -> Result<Option<String>, String> {
|
||||
if root.as_os_str().is_empty() || !root.is_absolute() || project_path_has_control_chars(root) {
|
||||
@@ -2273,6 +2284,8 @@ pub(crate) fn read_manifest(path: &Path) -> Result<GameCreationAppManifest, Stri
|
||||
.map_err(|error| format!("校验 {label} Godot 项目根失败:{error}"))?;
|
||||
validate_manifest_cocos_project_root(manifest.cocos_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 {label} Cocos 项目根失败:{error}"))?;
|
||||
validate_manifest_project_version(manifest.project_version)
|
||||
.map_err(|error| format!("校验 {label} 项目版本标签失败:{error}"))?;
|
||||
validate_game_iteration_versions(&manifest.versions)
|
||||
.map_err(|error| format!("校验 {label} 项目版本失败:{error}"))?;
|
||||
Ok(manifest)
|
||||
@@ -2466,6 +2479,8 @@ where
|
||||
.map_err(|error| format!("校验 manifest Godot 项目根失败:{error}"))?;
|
||||
validate_manifest_cocos_project_root(manifest.cocos_project_root.as_deref())
|
||||
.map_err(|error| format!("校验 manifest Cocos 项目根失败:{error}"))?;
|
||||
validate_manifest_project_version(manifest.project_version)
|
||||
.map_err(|error| format!("校验 manifest 项目版本标签失败:{error}"))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
ensure_game_creator_private_directory_tree(parent, "manifest 目录")?;
|
||||
prepare_game_creator_private_path_for_read(parent, true, "manifest 目录")?;
|
||||
|
||||
@@ -1813,6 +1813,7 @@ export function App({
|
||||
// 工作台状态,两处面板挂载点(普通项目 / DirectProject)共享同一份状态。
|
||||
if (published.manifest) setManifest(published.manifest);
|
||||
}}
|
||||
onManifestUpdated={setManifest}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
@@ -1947,6 +1948,7 @@ export function App({
|
||||
onPublished={(published) => {
|
||||
if (published.manifest) setManifest(published.manifest);
|
||||
}}
|
||||
onManifestUpdated={setManifest}
|
||||
/>
|
||||
<GamePublishProgressDialog
|
||||
progress={publishProgress}
|
||||
|
||||
+80
-28
@@ -1,7 +1,10 @@
|
||||
import { Eye, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { GameCreationAppManifest } from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import {
|
||||
resolveGameCreationAppProjectVersion,
|
||||
type GameCreationAppManifest,
|
||||
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import { GAME_DISTRIBUTION_CATEGORIES } from '../../../../../packages/shared/src/contracts/gameDistribution';
|
||||
import { IMAGE_MODEL_GPT_IMAGE_2 } from '../../../../../src/components/image-editor/ImageCanvasGenerationModel';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
readGameDistributionPublication,
|
||||
saveGameDistributionProjectVersion,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../../services/gameDistributionPublish';
|
||||
import {
|
||||
@@ -122,8 +126,8 @@ function publicationVersionStatusLabel(status: string | null | undefined) {
|
||||
return PUBLICATION_VERSION_STATUS_LABELS[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
/** 目标版本号只接受正整数安全整数;非法输入返回 null,由调用方给出可读错误。 */
|
||||
function parseTargetVersion(value: string): number | null {
|
||||
/** 项目版本只接受正整数安全整数;非法输入返回 null,由调用方给出可读错误。 */
|
||||
function parseProjectVersion(value: string): number | null {
|
||||
const trimmed = value.trim();
|
||||
if (!/^\d+$/u.test(trimmed)) return null;
|
||||
const parsed = Number(trimmed);
|
||||
@@ -227,6 +231,7 @@ export function GameDistributionPublishPanel({
|
||||
packageResult,
|
||||
onClose,
|
||||
onPublished,
|
||||
onManifestUpdated,
|
||||
}: {
|
||||
open: boolean;
|
||||
projectPath: string;
|
||||
@@ -234,6 +239,8 @@ export function GameDistributionPublishPanel({
|
||||
packageResult: LocalProjectExportPackageResult | null;
|
||||
onClose: () => void;
|
||||
onPublished?: (result: GameDistributionPublishResult) => void;
|
||||
/** 项目版本写回本地清单后回调整份 manifest,让工作台同步这份唯一发行版本。 */
|
||||
onManifestUpdated?: (manifest: GameCreationAppManifest) => void;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
@@ -272,9 +279,13 @@ export function GameDistributionPublishPanel({
|
||||
>(null);
|
||||
const [publicationRefreshed, setPublicationRefreshed] = useState(false);
|
||||
const [publicationMessage, setPublicationMessage] = useState('');
|
||||
const [targetVersion, setTargetVersion] = useState('1');
|
||||
/** 已知最大版本号;首次发布为 0。 */
|
||||
/** 项目清单里唯一的用户发行版本标签(`项目版本 vN`),输入框的当前文本。 */
|
||||
const [projectVersion, setProjectVersion] = useState('1');
|
||||
/** 已写回清单的项目版本;失焦时用它判断是否需要落盘。 */
|
||||
const [savedProjectVersion, setSavedProjectVersion] = useState(0);
|
||||
/** 线上最近提交的版本号;首次发布为 0(没有线上版本)。 */
|
||||
const [latestVersionNumber, setLatestVersionNumber] = useState(0);
|
||||
const projectVersionSaveRequestRef = useRef(0);
|
||||
const metadataSuggestionRequestRef = useRef(0);
|
||||
const publicationRequestRef = useRef(0);
|
||||
const publicationDraftAppliedRef = useRef(false);
|
||||
@@ -317,7 +328,8 @@ export function GameDistributionPublishPanel({
|
||||
setPublicationBinding(null);
|
||||
setPublicationRefreshed(false);
|
||||
setPublicationMessage('');
|
||||
setTargetVersion('1');
|
||||
setProjectVersion(String(resolveGameCreationAppProjectVersion(manifest)));
|
||||
setSavedProjectVersion(resolveGameCreationAppProjectVersion(manifest));
|
||||
setLatestVersionNumber(0);
|
||||
summaryTouchedRef.current = false;
|
||||
categoryTouchedRef.current = false;
|
||||
@@ -381,14 +393,19 @@ export function GameDistributionPublishPanel({
|
||||
return;
|
||||
}
|
||||
if (recovery.state === 'update') {
|
||||
const latest = recovery.binding.latestVersionNumber ?? null;
|
||||
const max = latest ?? 0;
|
||||
// 线上最近提交的版本号只用于展示与旧清单回填;用户输入的项目版本才是这次提交的标签。
|
||||
const latestVersionNumber = recovery.binding.latestVersionNumber ?? 0;
|
||||
const resolved = resolveGameCreationAppProjectVersion(
|
||||
manifest,
|
||||
recovery.binding,
|
||||
);
|
||||
setPublicationState('update');
|
||||
setPublicationBinding(recovery.binding);
|
||||
setPublicationRefreshed(recovery.refreshed);
|
||||
setPublicationMessage(recovery.message?.trim() ?? '');
|
||||
setLatestVersionNumber(max);
|
||||
setTargetVersion(String(max + 1));
|
||||
setLatestVersionNumber(latestVersionNumber);
|
||||
setProjectVersion(String(resolved));
|
||||
setSavedProjectVersion(resolved);
|
||||
if (recovery.draft) {
|
||||
applyPublicationDraft(recovery.draft);
|
||||
}
|
||||
@@ -657,6 +674,40 @@ export function GameDistributionPublishPanel({
|
||||
if (screenshotInputRef.current) screenshotInputRef.current.value = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 失焦即落盘:清单里的项目版本必须是用户改过的那个值,而不是只在这次面板里有效。
|
||||
*
|
||||
* 非法输入不写盘、直接回退到已保存值并给出可读原因;落盘失败保留用户输入(发布仍用输入值),
|
||||
* 只提示失败,避免"看起来没改成功、其实改了一半"。
|
||||
*/
|
||||
async function handleProjectVersionCommit() {
|
||||
const parsed = parseProjectVersion(projectVersion);
|
||||
if (parsed === null) {
|
||||
setError('项目版本必须是大于 0 的整数');
|
||||
setProjectVersion(String(savedProjectVersion));
|
||||
return;
|
||||
}
|
||||
if (parsed === savedProjectVersion) return;
|
||||
const requestId = ++projectVersionSaveRequestRef.current;
|
||||
try {
|
||||
const updated = await saveGameDistributionProjectVersion({
|
||||
projectPath,
|
||||
projectVersion: parsed,
|
||||
});
|
||||
if (requestId !== projectVersionSaveRequestRef.current) return;
|
||||
setSavedProjectVersion(parsed);
|
||||
setError('');
|
||||
if (updated) onManifestUpdated?.(updated);
|
||||
} catch (saveError) {
|
||||
if (requestId !== projectVersionSaveRequestRef.current) return;
|
||||
setError(
|
||||
saveError instanceof Error
|
||||
? saveError.message
|
||||
: '项目版本保存失败,请重试',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!packageResult || !projectPath.trim()) {
|
||||
setError('请先导出有效的试玩包');
|
||||
@@ -677,15 +728,9 @@ export function GameDistributionPublishPanel({
|
||||
);
|
||||
return;
|
||||
}
|
||||
const parsedTargetVersion = parseTargetVersion(targetVersion);
|
||||
if (parsedTargetVersion === null) {
|
||||
setError('目标版本号必须是大于 0 的整数');
|
||||
return;
|
||||
}
|
||||
if (parsedTargetVersion <= latestVersionNumber) {
|
||||
setError(
|
||||
`目标版本号必须大于当前最大版本号 ${latestVersionNumber},不能覆盖已有版本`,
|
||||
);
|
||||
const parsedProjectVersion = parseProjectVersion(projectVersion);
|
||||
if (parsedProjectVersion === null) {
|
||||
setError('项目版本必须是大于 0 的整数');
|
||||
return;
|
||||
}
|
||||
if (!cover) {
|
||||
@@ -756,7 +801,7 @@ export function GameDistributionPublishPanel({
|
||||
},
|
||||
gameId:
|
||||
publicationState === 'update' ? publicationBinding?.gameId ?? null : null,
|
||||
versionNumber: parsedTargetVersion,
|
||||
versionNumber: parsedProjectVersion,
|
||||
expectedPublicationRevision:
|
||||
publicationState === 'update' ? publicationBinding?.revision ?? null : null,
|
||||
});
|
||||
@@ -816,7 +861,7 @@ export function GameDistributionPublishPanel({
|
||||
<strong>已提交审核</strong>
|
||||
<p>版本已进入审核队列,审核通过后才会在游戏广场公开展示。</p>
|
||||
<p className="game-distribution-publish-panel__mono">
|
||||
版本 {result.versionNumber} ·{' '}
|
||||
项目版本 v{result.versionNumber} ·{' '}
|
||||
{result.packageSizeBytes.toLocaleString()} B ·{' '}
|
||||
{result.packageSha256.slice(0, 16)}…
|
||||
</p>
|
||||
@@ -843,8 +888,8 @@ export function GameDistributionPublishPanel({
|
||||
</strong>
|
||||
{publicationState === 'update' && publicationBinding ? (
|
||||
<span>
|
||||
当前版本{' '}
|
||||
{latestVersionNumber > 0 ? latestVersionNumber : '—'} ·{' '}
|
||||
线上最近提交{' '}
|
||||
{latestVersionNumber > 0 ? `v${latestVersionNumber}` : '—'} ·{' '}
|
||||
{publicationVersionStatusLabel(publicationBinding.status)}
|
||||
{publicationRefreshed
|
||||
? ''
|
||||
@@ -905,26 +950,33 @@ export function GameDistributionPublishPanel({
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
目标版本号
|
||||
<span className="game-distribution-publish-panel__version-label">
|
||||
项目版本
|
||||
<span className="game-distribution-publish-panel__version-tag">
|
||||
{`v${projectVersion.trim() || '—'}`}
|
||||
</span>
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
inputMode="numeric"
|
||||
value={targetVersion}
|
||||
aria-label="项目版本"
|
||||
value={projectVersion}
|
||||
disabled={
|
||||
busy ||
|
||||
generatingCover ||
|
||||
publicationState === 'checking' ||
|
||||
publicationState === 'unavailable'
|
||||
}
|
||||
onChange={(event) => setTargetVersion(event.target.value)}
|
||||
onChange={(event) => setProjectVersion(event.target.value)}
|
||||
onBlur={() => void handleProjectVersionCommit()}
|
||||
/>
|
||||
</label>
|
||||
<span className="game-distribution-publish-panel__hint">
|
||||
{latestVersionNumber > 0
|
||||
? `当前最大版本号 ${latestVersionNumber},建议 ${latestVersionNumber + 1};允许跳号,但不能小于等于当前版本号。`
|
||||
: '首次发布建议使用版本号 1;版本号必须是大于 0 的整数。'}
|
||||
? `版本标签不要求递增:可以重复提交同一个版本,也可以回退到更小的版本号(线上最近提交 v${latestVersionNumber})。`
|
||||
: '第一次发布建议用 v1;版本号是大于 0 的整数,之后可以重复提交或回退,不要求递增。'}
|
||||
</span>
|
||||
<span
|
||||
className="game-distribution-publish-panel__metadata-hint"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
GameCreationAppManifest,
|
||||
GameCreationAppPublicationBinding,
|
||||
import {
|
||||
resolveGameCreationAppProjectVersion,
|
||||
type GameCreationAppManifest,
|
||||
type GameCreationAppPublicationBinding,
|
||||
} from '../../../../packages/shared/src/contracts/gameCreationApp';
|
||||
import type {
|
||||
GameDistributionCategory,
|
||||
@@ -135,6 +136,36 @@ export async function readGameDistributionPublication(
|
||||
return payload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「项目版本」写回本地清单。
|
||||
*
|
||||
* 版本标签是清单里唯一的用户发行版本字段:面板里改完就要落盘,重开项目显示的才是同一个值。
|
||||
* 这里只改这个字段,不发布、不动发布绑定与线上状态。
|
||||
*
|
||||
* @returns 写回后的整份 manifest;原生宿主没有返回清单(例如测试替身)时为 null,调用方保留
|
||||
* 本地状态即可,不视为失败。
|
||||
*/
|
||||
export async function saveGameDistributionProjectVersion(
|
||||
args: { projectPath: string; projectVersion: number },
|
||||
invoke?: TauriInvoke,
|
||||
): Promise<GameCreationAppManifest | null> {
|
||||
const projectPath = args.projectPath.trim();
|
||||
if (!projectPath) {
|
||||
throw new Error('发布需要绑定本地项目');
|
||||
}
|
||||
const projectVersion = args.projectVersion;
|
||||
if (!Number.isSafeInteger(projectVersion) || projectVersion <= 0) {
|
||||
throw new Error('项目版本必须是大于 0 的安全整数');
|
||||
}
|
||||
const payload = await requireNativeInvoke(invoke)<
|
||||
GameCreationAppManifest | null | undefined
|
||||
>('update_game_distribution_project_version', {
|
||||
projectPath,
|
||||
projectVersion,
|
||||
});
|
||||
return payload ?? null;
|
||||
}
|
||||
|
||||
export type GameDistributionPublishMetadataSuggestion = {
|
||||
summary: string;
|
||||
category: GameDistributionCategory;
|
||||
@@ -294,11 +325,16 @@ export async function publishLocalProjectGame(args: {
|
||||
metadata?: Partial<GameDistributionPublishMetadata>;
|
||||
/**
|
||||
* 更新已有作品时的目标游戏身份(来自发布绑定)。提供时必须同时给出
|
||||
* `versionNumber` 与 `expectedPublicationRevision`:原生会跳过 create game,
|
||||
* 直接在原 gameId 上创建新版本。
|
||||
* `expectedPublicationRevision`:原生会跳过 create game,直接在原 gameId 上创建新版本。
|
||||
*/
|
||||
gameId?: string | null;
|
||||
/** 用户选择的目标正整数版本号;首次发布默认 1。不传则退回服务端自动递增。 */
|
||||
/**
|
||||
* 这次提交使用的项目版本标签。
|
||||
*
|
||||
* 缺省时取清单的唯一发行版本 `manifest.projectVersion`(缺失按发布绑定回填,最终落到首版 1):
|
||||
* 发布面板编辑的就是这个字段,所以「用户改了什么、这次就提交什么」,不存在第二份版本来源。
|
||||
* 同一个版本标签可以反复提交,每次提交生成新的 `versionId`。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
/** 更新送审所需的公开修订号 CAS;来自发布绑定。 */
|
||||
expectedPublicationRevision?: number | null;
|
||||
@@ -321,30 +357,24 @@ export async function publishLocalProjectGame(args: {
|
||||
throw new Error('发布需要本地项目标识,请重新打开项目后再试');
|
||||
}
|
||||
const gameId = args.gameId?.trim() ?? '';
|
||||
const versionNumber = args.versionNumber ?? null;
|
||||
if (versionNumber !== null) {
|
||||
if (
|
||||
!Number.isSafeInteger(versionNumber) ||
|
||||
versionNumber <= 0
|
||||
) {
|
||||
throw new Error('目标版本号必须是大于 0 的安全整数');
|
||||
}
|
||||
// 版本标签只有一个来源:调用方显式传入的值优先(发布面板编辑中的输入),否则回落到清单里
|
||||
// 的项目版本(旧清单按发布绑定回填)。服务端的自动递增路径只服务不传版本号的老客户端。
|
||||
const versionNumber =
|
||||
args.versionNumber ?? resolveGameCreationAppProjectVersion(args.manifest);
|
||||
if (!Number.isSafeInteger(versionNumber) || versionNumber <= 0) {
|
||||
throw new Error('项目版本必须是大于 0 的安全整数');
|
||||
}
|
||||
if (gameId) {
|
||||
if (versionNumber === null) {
|
||||
throw new Error('更新游戏必须填写目标版本号');
|
||||
}
|
||||
if (
|
||||
args.expectedPublicationRevision === null ||
|
||||
args.expectedPublicationRevision === undefined
|
||||
) {
|
||||
throw new Error('缺少公开修订号,请重新打开发布面板后再试');
|
||||
}
|
||||
if (
|
||||
gameId &&
|
||||
(args.expectedPublicationRevision === null ||
|
||||
args.expectedPublicationRevision === undefined)
|
||||
) {
|
||||
throw new Error('缺少公开修订号,请重新打开发布面板后再试');
|
||||
}
|
||||
const gameMetadata = toCreateGameRequest(metadata, localProjectId);
|
||||
const rootKey = args.idempotencyKey?.trim() ?? '';
|
||||
// 发布编排、认证、origin、分片续传与送审都在 Rust 完成;渲染进程只传递
|
||||
// 已归一化的资料、目标游戏/版本和本地项目相对包路径,不接触 token、base URL 或整包字节。
|
||||
// 已归一化的资料、项目版本和本地项目相对包路径,不接触 token、base URL 或整包字节。
|
||||
return args.invoke<GameDistributionPublishResult>(
|
||||
'publish_local_project_game',
|
||||
{
|
||||
@@ -352,7 +382,7 @@ export async function publishLocalProjectGame(args: {
|
||||
packageRelativePath,
|
||||
metadata: gameMetadata,
|
||||
...(gameId ? { gameId } : {}),
|
||||
...(versionNumber !== null ? { versionNumber } : {}),
|
||||
versionNumber,
|
||||
...(gameId
|
||||
? { expectedPublicationRevision: args.expectedPublicationRevision }
|
||||
: {}),
|
||||
|
||||
@@ -3737,6 +3737,23 @@ textarea {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 项目版本标签:与榜单/详情页看到的「vN」一致,避免用户以为它是内部版本号。 */
|
||||
.game-distribution-publish-panel__version-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__version-tag {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgb(168 102 61 / 12%);
|
||||
color: #a8663d;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.game-distribution-publish-panel__cover-editor {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
readGameDistributionPublication,
|
||||
saveGameDistributionProjectVersion,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
const MANIFEST = {
|
||||
@@ -291,7 +292,7 @@ describe('Rust 发布 facade', () => {
|
||||
).rejects.toThrow('发布状态回读结果无效');
|
||||
});
|
||||
|
||||
test('更新发布会带上原 gameId、目标版本号与公开修订号', async () => {
|
||||
test('更新发布会带上原 gameId、项目版本与公开修订号', async () => {
|
||||
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
|
||||
|
||||
await publishLocalProjectGame({
|
||||
@@ -312,18 +313,41 @@ describe('Rust 发布 facade', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('更新发布缺少目标版本号或公开修订号时在 native command 前失败关闭', async () => {
|
||||
test('未显式给出项目版本时沿用清单里的唯一发行版本', async () => {
|
||||
const { invoke, calls } = installNativeInvoke(() => PUBLISH_RESULT);
|
||||
|
||||
// 旧清单没有 projectVersion:首版回落 1,不把「没写」变成服务端自动递增。
|
||||
await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
});
|
||||
expect(calls[0]?.args).toMatchObject({ versionNumber: 1 });
|
||||
|
||||
// 清单写过的项目版本就是这次提交的标签;同一标签重复提交由服务端接受。
|
||||
await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
projectVersion: 4,
|
||||
} as unknown as GameCreationAppManifest,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
gameId: 'game_9',
|
||||
expectedPublicationRevision: 7,
|
||||
});
|
||||
expect(calls[1]?.args).toMatchObject({
|
||||
gameId: 'game_9',
|
||||
versionNumber: 4,
|
||||
expectedPublicationRevision: 7,
|
||||
});
|
||||
});
|
||||
|
||||
test('更新发布缺少公开修订号时在 native command 前失败关闭', async () => {
|
||||
const invoke = vi.fn() as unknown as TauriInvoke;
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/tmp/project',
|
||||
packageRelativePath: 'exports/playtest-package-1.zip',
|
||||
manifest: MANIFEST,
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
gameId: 'game_9',
|
||||
}),
|
||||
).rejects.toThrow('更新游戏必须填写目标版本号');
|
||||
await expect(
|
||||
publishLocalProjectGame({
|
||||
invoke,
|
||||
@@ -338,7 +362,7 @@ describe('Rust 发布 facade', () => {
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('目标版本号不是安全整数时在 native command 前失败关闭', async () => {
|
||||
test('项目版本不是正整数安全整数时在 native command 前失败关闭', async () => {
|
||||
const invoke = vi.fn() as unknown as TauriInvoke;
|
||||
for (const versionNumber of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
await expect(
|
||||
@@ -350,8 +374,46 @@ describe('Rust 发布 facade', () => {
|
||||
metadata: { coverAssetId: 'asset_cover' },
|
||||
versionNumber,
|
||||
}),
|
||||
).rejects.toThrow('目标版本号必须是大于 0 的安全整数');
|
||||
).rejects.toThrow('项目版本必须是大于 0 的安全整数');
|
||||
}
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('项目版本写回清单通过单个 native command 并回传整份 manifest', async () => {
|
||||
const savedManifest = {
|
||||
...MANIFEST,
|
||||
projectVersion: 6,
|
||||
} as unknown as GameCreationAppManifest;
|
||||
const { invoke, calls } = installNativeInvoke(() => savedManifest);
|
||||
|
||||
const updated = await saveGameDistributionProjectVersion(
|
||||
{ projectPath: '/tmp/project', projectVersion: 6 },
|
||||
invoke,
|
||||
);
|
||||
|
||||
expect(updated).toEqual(savedManifest);
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
command: 'update_game_distribution_project_version',
|
||||
args: { projectPath: '/tmp/project', projectVersion: 6 },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('项目版本写回在非法输入时失败关闭', async () => {
|
||||
const invoke = vi.fn() as unknown as TauriInvoke;
|
||||
await expect(
|
||||
saveGameDistributionProjectVersion(
|
||||
{ projectPath: '/tmp/project', projectVersion: 0 },
|
||||
invoke,
|
||||
),
|
||||
).rejects.toThrow('项目版本必须是大于 0 的安全整数');
|
||||
await expect(
|
||||
saveGameDistributionProjectVersion(
|
||||
{ projectPath: ' ', projectVersion: 2 },
|
||||
invoke,
|
||||
),
|
||||
).rejects.toThrow('发布需要绑定本地项目');
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -674,7 +674,7 @@ liveTest(
|
||||
expect(second.gameId).toBe(first.gameId);
|
||||
expect(second.versionNumber).toBe(first.versionNumber + 1);
|
||||
|
||||
// 更新发布:AGC 带原 gameId + 用户目标版本号,跳过 create game,只新增版本。
|
||||
// 更新发布:AGC 带原 gameId + 用户项目版本,跳过 create game,只新增版本。
|
||||
const ownerDetail = await unwrap<{
|
||||
game: { publicationRevision: number };
|
||||
}>(
|
||||
@@ -697,6 +697,50 @@ liveTest(
|
||||
expect(updated.gameId).toBe(second.gameId);
|
||||
expect(updated.versionNumber).toBe(targetVersionNumber);
|
||||
|
||||
// 同一个项目版本重复提交:服务端必须生成新的 versionId,而不是覆盖上一个提交实例。
|
||||
const afterUpdate = await unwrap<{
|
||||
game: { publicationRevision: number };
|
||||
}>(
|
||||
await realFetch(apiUrl(`/api/game-distribution/my-games/${second.gameId}`), {
|
||||
headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS },
|
||||
}),
|
||||
);
|
||||
const repeated = await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
|
||||
packageRelativePath: 'dist/game.zip',
|
||||
manifest,
|
||||
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
|
||||
gameId: second.gameId,
|
||||
versionNumber: targetVersionNumber,
|
||||
expectedPublicationRevision: afterUpdate.game.publicationRevision,
|
||||
});
|
||||
expect(repeated.gameId).toBe(second.gameId);
|
||||
expect(repeated.versionNumber).toBe(targetVersionNumber);
|
||||
expect(repeated.versionId).not.toBe(updated.versionId);
|
||||
|
||||
// 回退:项目版本可以低于线上最大版本号,服务端只校验它是正整数。
|
||||
const afterRepeat = await unwrap<{
|
||||
game: { publicationRevision: number };
|
||||
}>(
|
||||
await realFetch(apiUrl(`/api/game-distribution/my-games/${second.gameId}`), {
|
||||
headers: { Authorization: `Bearer ${token}`, ...ENVELOPE_HEADERS },
|
||||
}),
|
||||
);
|
||||
const reverted = await publishLocalProjectGame({
|
||||
invoke,
|
||||
projectPath: '/data/dsk/Genarrative/tmp-agc-live-project',
|
||||
packageRelativePath: 'dist/game.zip',
|
||||
manifest,
|
||||
metadata: { ...metadata, inputModes: [...metadata.inputModes] },
|
||||
gameId: second.gameId,
|
||||
versionNumber: 2,
|
||||
expectedPublicationRevision: afterRepeat.game.publicationRevision,
|
||||
});
|
||||
expect(reverted.gameId).toBe(second.gameId);
|
||||
expect(reverted.versionNumber).toBe(2);
|
||||
expect(reverted.versionId).not.toBe(repeated.versionId);
|
||||
|
||||
const myGames = await unwrap<{
|
||||
games: Array<{
|
||||
id: string;
|
||||
@@ -708,9 +752,12 @@ liveTest(
|
||||
}),
|
||||
);
|
||||
const published = myGames.games.find((game) => game.id === first.gameId);
|
||||
expect(published?.latestVersion?.versionId).toBe(updated.versionId);
|
||||
// 「最新提交」按版本号降序取第一条:v7 有两条提交实例,回退的 v2 不会顶掉它们。
|
||||
expect([updated.versionId, repeated.versionId]).toContain(
|
||||
published?.latestVersion?.versionId,
|
||||
);
|
||||
expect(published?.latestVersion?.status).toBe('pending_review');
|
||||
// 更新只增加版本,不重复创建作品:该 localProjectId 在作者名下仍然只有一款游戏。
|
||||
// 重复提交同一版本、回退到更小版本都只增加版本,不重复创建作品。
|
||||
expect(
|
||||
myGames.games.filter(
|
||||
(game) => game.id === first.gameId || game.id === updated.gameId,
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
publishLocalProjectGame,
|
||||
readGameCoverGenerationPrice,
|
||||
readGameDistributionPublication,
|
||||
saveGameDistributionProjectVersion,
|
||||
suggestGameDistributionPublishMetadata,
|
||||
} from '../src/services/gameDistributionPublish';
|
||||
import { GAME_PACKAGE_UPLOAD_PROGRESS_EVENT } from '../src/services/gamePackageUploadProgress';
|
||||
@@ -43,6 +44,7 @@ vi.mock('../src/services/gameDistributionPublish', async (importOriginal) => {
|
||||
publishLocalProjectGame: vi.fn(),
|
||||
readGameCoverGenerationPrice: vi.fn(),
|
||||
readGameDistributionPublication: vi.fn(),
|
||||
saveGameDistributionProjectVersion: vi.fn(),
|
||||
suggestGameDistributionPublishMetadata: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -145,6 +147,7 @@ afterEach(() => {
|
||||
vi.mocked(publishLocalProjectGame).mockReset();
|
||||
vi.mocked(readGameCoverGenerationPrice).mockReset();
|
||||
vi.mocked(readGameDistributionPublication).mockReset();
|
||||
vi.mocked(saveGameDistributionProjectVersion).mockReset();
|
||||
vi.mocked(suggestGameDistributionPublishMetadata).mockReset();
|
||||
vi.mocked(uploadPlatformMediaAsset).mockReset();
|
||||
delete (window as unknown as { __TAURI__?: unknown }).__TAURI__;
|
||||
@@ -335,7 +338,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(invoke).toBeDefined();
|
||||
|
||||
expect(await screen.findByText('已提交审核')).not.toBeNull();
|
||||
expect(screen.getByText(/版本 1/u)).not.toBeNull();
|
||||
expect(screen.getByText(/项目版本/u)).not.toBeNull();
|
||||
expect(onPublished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -682,7 +685,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(screen.queryByLabelText('发行包摘要')).toBeNull();
|
||||
});
|
||||
|
||||
test('已有绑定时进入更新模式,回填线上资料并提交原 gameId 与更高版本号', async () => {
|
||||
test('已有绑定时进入更新模式,回填线上资料并按项目版本提交原 gameId', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'update',
|
||||
@@ -721,7 +724,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||||
gameId: 'game_9',
|
||||
versionId: 'gamever_3',
|
||||
versionNumber: 3,
|
||||
versionNumber: 2,
|
||||
status: 'pending_review',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
@@ -729,7 +732,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
});
|
||||
const { onPublished } = renderPanel();
|
||||
|
||||
// 回填线上冻结资料,目标版本默认「最大版本号 + 1」。
|
||||
// 回填线上冻结资料;项目版本按绑定回填成线上最近提交的版本标签(旧清单缺字段时用它)。
|
||||
await screen.findByRole('button', { name: '更新游戏' });
|
||||
expect(screen.getByLabelText('游戏名称')).toHaveProperty(
|
||||
'value',
|
||||
@@ -739,9 +742,13 @@ describe('GameDistributionPublishPanel', () => {
|
||||
'value',
|
||||
'线上简介',
|
||||
);
|
||||
expect(screen.getByLabelText('目标版本号')).toHaveProperty('value', '3');
|
||||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2');
|
||||
expect(screen.getByText('v2')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/当前最大版本号 2,建议 3/u),
|
||||
screen.getByText(/线上最近提交 v2 · 审核中/u),
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(/版本标签不要求递增/u),
|
||||
).not.toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||||
@@ -750,7 +757,8 @@ describe('GameDistributionPublishPanel', () => {
|
||||
);
|
||||
const args = vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0];
|
||||
expect(args?.gameId).toBe('game_9');
|
||||
expect(args?.versionNumber).toBe(3);
|
||||
// 同一个版本标签可以重复提交:不做递增改写,提交的就是清单里的 projectVersion。
|
||||
expect(args?.versionNumber).toBe(2);
|
||||
expect(args?.expectedPublicationRevision).toBe(7);
|
||||
// 冻结素材 ID 直接复用,不要求作者重新上传封面/截图。
|
||||
expect(args?.metadata?.coverAssetId).toBe('asset_cover_old');
|
||||
@@ -767,7 +775,7 @@ describe('GameDistributionPublishPanel', () => {
|
||||
expect(onPublished).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('目标版本号不大于当前最大版本号时本地拦截且不发起发布', async () => {
|
||||
test('清单里的项目版本优先于线上版本,回退到更小版本也能提交', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
vi.mocked(readGameDistributionPublication).mockResolvedValue({
|
||||
state: 'update',
|
||||
@@ -784,18 +792,79 @@ describe('GameDistributionPublishPanel', () => {
|
||||
draft: null,
|
||||
visibility: 'published',
|
||||
});
|
||||
renderPanel();
|
||||
vi.mocked(publishLocalProjectGame).mockResolvedValue({
|
||||
gameId: 'game_9',
|
||||
versionId: 'gamever_6',
|
||||
versionNumber: 2,
|
||||
status: 'pending_review',
|
||||
packageSha256: 'a'.repeat(64),
|
||||
packageSizeBytes: 3,
|
||||
fileCount: 2,
|
||||
});
|
||||
renderPanel({
|
||||
manifest: {
|
||||
...MANIFEST,
|
||||
projectVersion: 2,
|
||||
} as unknown as GameCreationAppManifest,
|
||||
});
|
||||
await screen.findByRole('button', { name: '更新游戏' });
|
||||
|
||||
fireEvent.change(screen.getByLabelText('目标版本号'), {
|
||||
target: { value: '5' },
|
||||
});
|
||||
// 清单写过的项目版本胜出:不被线上最大版本号悄悄改成 5 或 6。
|
||||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '2');
|
||||
await selectCover();
|
||||
fireEvent.click(screen.getByRole('button', { name: '更新游戏' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(publishLocalProjectGame).toHaveBeenCalledTimes(1),
|
||||
);
|
||||
expect(
|
||||
await screen.findByText(/目标版本号必须大于当前最大版本号 5/u),
|
||||
vi.mocked(publishLocalProjectGame).mock.calls[0]?.[0].versionNumber,
|
||||
).toBe(2);
|
||||
});
|
||||
|
||||
test('修改项目版本后失焦写回清单并回调工作台', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
const savedManifest = {
|
||||
...MANIFEST,
|
||||
projectVersion: 4,
|
||||
} as unknown as GameCreationAppManifest;
|
||||
vi.mocked(saveGameDistributionProjectVersion).mockResolvedValue(
|
||||
savedManifest,
|
||||
);
|
||||
const onManifestUpdated = vi.fn();
|
||||
renderPanel({ onManifestUpdated });
|
||||
await screen.findByLabelText('项目版本');
|
||||
|
||||
const input = screen.getByLabelText('项目版本');
|
||||
fireEvent.change(input, { target: { value: '4' } });
|
||||
expect(screen.getByText('v4')).not.toBeNull();
|
||||
fireEvent.blur(input);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveGameDistributionProjectVersion).toHaveBeenCalledWith({
|
||||
projectPath: '/tmp/authorized-game',
|
||||
projectVersion: 4,
|
||||
}),
|
||||
);
|
||||
expect(onManifestUpdated).toHaveBeenCalledWith(savedManifest);
|
||||
});
|
||||
|
||||
test('项目版本非法时失焦回退到已保存值且不写盘', async () => {
|
||||
installTauriInvoke(async () => undefined);
|
||||
renderPanel();
|
||||
const input = await screen.findByLabelText('项目版本');
|
||||
expect(input).toHaveProperty('value', '1');
|
||||
|
||||
fireEvent.change(input, { target: { value: '0' } });
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(
|
||||
await screen.findByText('项目版本必须是大于 0 的整数'),
|
||||
).not.toBeNull();
|
||||
expect(publishLocalProjectGame).not.toHaveBeenCalled();
|
||||
expect(saveGameDistributionProjectVersion).not.toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('项目版本')).toHaveProperty('value', '1');
|
||||
});
|
||||
});
|
||||
|
||||
test('恢复失败(网络/5xx)时禁用提交,不按首次发布继续', async () => {
|
||||
|
||||
@@ -976,6 +976,42 @@ export interface GameCreationAppPublicationBinding {
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
/** 项目版本标签的唯一合法形状:正整数,且能安全走 JSON number。 */
|
||||
export function isGameCreationAppProjectVersion(value: unknown): value is number {
|
||||
return (
|
||||
typeof value === 'number' &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value > 0
|
||||
);
|
||||
}
|
||||
|
||||
/** 项目版本标签的建议默认值:没有任何线上记录时的首个版本。 */
|
||||
export const GAME_CREATION_APP_FIRST_PROJECT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* 项目版本标签的有效值。
|
||||
*
|
||||
* 新清单直接用写下来的 `projectVersion`;旧清单缺这个字段时按发布绑定回填——线上已有版本号
|
||||
* (`latestVersionNumber > 0`)就用它,否则用首版 1。
|
||||
*
|
||||
* `binding` 必须已经按**当前会话**(`accountId` + `apiBaseUrl`)与 `gameId` 隔离:调用方传
|
||||
* 别的账号 / 别的环境的绑定,就会把该项目的版本标签回填成别人的线上版本号。原生回读与发布
|
||||
* 写回都遵守这条隔离,渲染层只消费原生已经隔离过的绑定。
|
||||
*/
|
||||
export function resolveGameCreationAppProjectVersion(
|
||||
manifest: Pick<GameCreationAppManifest, 'projectVersion'>,
|
||||
binding?: GameCreationAppPublicationBinding | null,
|
||||
): number {
|
||||
if (isGameCreationAppProjectVersion(manifest.projectVersion)) {
|
||||
return manifest.projectVersion;
|
||||
}
|
||||
const latest = binding?.latestVersionNumber;
|
||||
if (isGameCreationAppProjectVersion(latest)) {
|
||||
return latest;
|
||||
}
|
||||
return GAME_CREATION_APP_FIRST_PROJECT_VERSION;
|
||||
}
|
||||
|
||||
export interface GameCreationAppManifest {
|
||||
schemaVersion: string;
|
||||
projectId: string;
|
||||
@@ -988,6 +1024,17 @@ export interface GameCreationAppManifest {
|
||||
versions?: GameIterationVersion[];
|
||||
godotProjectRoot?: string | null;
|
||||
cocosProjectRoot?: string | null;
|
||||
/**
|
||||
* 唯一用户发行版本标签(`项目版本 vN`),正整数,不需要递增。
|
||||
*
|
||||
* 同一个 `gameId` 的同一个版本标签可以反复提交:每次具体包/资料/审核提交由不可变
|
||||
* `versionId` 区分,因此这里允许重复与回退(低于线上当前版本)。它**不是**内部编辑迭代
|
||||
* `versions[].projectRevision`,也不是 semver 字符串。
|
||||
*
|
||||
* 旧清单没有这个字段:读取方按当前账号 / origin / gameId 隔离后的发布绑定回填(见
|
||||
* `resolveGameCreationAppProjectVersion`),发布或改版本标签时由原生写回。
|
||||
*/
|
||||
projectVersion?: number | null;
|
||||
/** 平台发布绑定;缺失即该项目尚未发布过。 */
|
||||
publication?: GameCreationAppPublicationBinding | null;
|
||||
}
|
||||
|
||||
@@ -169,8 +169,11 @@ export type GameDistributionCreateGameRequest = {
|
||||
export type GameDistributionCreateVersionRequest = {
|
||||
localProjectId?: string | null;
|
||||
/**
|
||||
* 显式目标版本号;缺省时服务端按该游戏已有最大版本号自动 +1。
|
||||
* 必须严格大于当前最大版本号(允许跳号),否则返回 409。
|
||||
* 用户可见的正整数版本标签(AGC 里就是 `projectVersion`)。
|
||||
*
|
||||
* 传入时只要求 `>= 1`:同一 `gameId` 的同一个版本号可以反复提交,每次提交生成新的
|
||||
* `versionId`,允许重复标签与回退到更小的版本号,不与已有最大值比较。
|
||||
* 缺省时保留旧客户端兼容行为:服务端按该游戏已有最大版本号 +1。
|
||||
*/
|
||||
versionNumber?: number | null;
|
||||
packageSha256: string;
|
||||
|
||||
@@ -881,7 +881,11 @@ fn release_package_asset_response(
|
||||
};
|
||||
let content = normalize_release_asset_references(content, content_type);
|
||||
let content = inject_release_storage_bootstrap(content, content_type);
|
||||
Ok(release_asset_response_with_cache(content, content_type, cache_control))
|
||||
Ok(release_asset_response_with_cache(
|
||||
content,
|
||||
content_type,
|
||||
cache_control,
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_release_asset_references(content: Vec<u8>, content_type: &str) -> Vec<u8> {
|
||||
@@ -951,7 +955,6 @@ async fn release_package_bytes(
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
|
||||
fn release_asset_response_with_cache(
|
||||
content: Vec<u8>,
|
||||
content_type: &'static str,
|
||||
@@ -2929,7 +2932,6 @@ fn private_version_payload(version: &GameDistributionVersionRecord) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
/// 游戏分发写入开关。
|
||||
///
|
||||
/// 运营在灰度配置里把 `game-distribution:publish` 收紧后,作者写入与新版本激活会返回
|
||||
@@ -4615,14 +4617,12 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn release_normalizes_legacy_root_asset_references() {
|
||||
let source = br#"<script>fetch('/assets/hero.png')</script><style>url(/ui/icon.svg)</style>"#;
|
||||
let normalized = normalize_release_asset_references(
|
||||
source.to_vec(),
|
||||
"text/javascript; charset=utf-8",
|
||||
);
|
||||
let source =
|
||||
br#"<script>fetch('/assets/hero.png')</script><style>url(/ui/icon.svg)</style>"#;
|
||||
let normalized =
|
||||
normalize_release_asset_references(source.to_vec(), "text/javascript; charset=utf-8");
|
||||
let normalized = String::from_utf8(normalized).expect("normalized source");
|
||||
assert!(normalized.contains("fetch('assets/hero.png')"));
|
||||
assert!(normalized.contains("url(ui/icon.svg)"));
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision},
|
||||
domain::{
|
||||
GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus,
|
||||
GameVisibility, normalize_id,
|
||||
GameVisibility, normalize_id, resolve_version_number,
|
||||
},
|
||||
errors::{GameDistributionError, GameDistributionFieldError},
|
||||
};
|
||||
@@ -146,14 +146,16 @@ impl GameDistributionService {
|
||||
if state.versions.contains_key(&version_id) {
|
||||
return Err(GameDistributionError::VersionAlreadyExists);
|
||||
}
|
||||
let version_number = state
|
||||
.versions
|
||||
.values()
|
||||
.filter(|version| version.game_id == game_id)
|
||||
.map(|version| version.version_number)
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.saturating_add(1);
|
||||
let version_number = resolve_version_number(
|
||||
state
|
||||
.versions
|
||||
.values()
|
||||
.filter(|version| version.game_id == game_id)
|
||||
.map(|version| version.version_number)
|
||||
.max()
|
||||
.unwrap_or(0),
|
||||
input.version_number,
|
||||
)?;
|
||||
let snapshot = GameVersionSnapshot {
|
||||
version_id: version_id.clone(),
|
||||
game_id,
|
||||
@@ -880,6 +882,7 @@ mod tests {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-1".to_string(),
|
||||
version_number: None,
|
||||
package_sha256: "a".repeat(64),
|
||||
package_bytes: 42,
|
||||
idempotency: idem("create-version-1", "version-1"),
|
||||
@@ -1159,6 +1162,7 @@ mod tests {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-2".to_string(),
|
||||
version_number: None,
|
||||
package_sha256: "b".repeat(64),
|
||||
package_bytes: 7,
|
||||
idempotency: idem("create-version-2", "version-2"),
|
||||
@@ -1270,4 +1274,61 @@ mod tests {
|
||||
assert_eq!(snapshot.status, GameVersionStatus::Uploaded);
|
||||
assert_eq!(repeated.snapshot, snapshot);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_version_number_accepts_repeats_and_reverts_of_the_same_label() {
|
||||
assert_eq!(resolve_version_number(0, Some(1)).expect("首版显式 1"), 1);
|
||||
// 重复标签:线上已有 3 也要能再提交一次 3,并生成新的版本记录。
|
||||
assert_eq!(resolve_version_number(3, Some(3)).expect("重复标签"), 3);
|
||||
// 回退:线上最大 5,也允许提交 2。
|
||||
assert_eq!(resolve_version_number(5, Some(2)).expect("回退标签"), 2);
|
||||
assert_eq!(
|
||||
resolve_version_number(0, None).expect("旧客户端自动递增"),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_version_number(7, None).expect("旧客户端按最大值递增"),
|
||||
8
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_number_must_be_positive_and_auto_increment_fails_closed() {
|
||||
assert_eq!(
|
||||
resolve_version_number(0, Some(0)).expect_err("0 不是合法标签"),
|
||||
GameDistributionFieldError::InvalidVersionNumber
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_version_number(u64::MAX, None).expect_err("自动递增溢出必须失败关闭"),
|
||||
GameDistributionFieldError::VersionNumberExhausted
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeating_the_same_label_creates_a_new_version_record() {
|
||||
let service = service();
|
||||
let first = create_version(&service);
|
||||
assert_eq!(first.version_number, 1);
|
||||
|
||||
// 同一个版本标签再提交一次:旧的已公开实例不原地修改,而是新增一条版本记录。
|
||||
let repeated = service
|
||||
.create_version(CreateVersionInput {
|
||||
game_id: "game-1".to_string(),
|
||||
owner_user_id: "owner-1".to_string(),
|
||||
version_id: "version-1-again".to_string(),
|
||||
version_number: Some(first.version_number),
|
||||
package_sha256: "b".repeat(64),
|
||||
package_bytes: 9,
|
||||
idempotency: idem("create-version-again", "version-again"),
|
||||
created_at_micros: 12,
|
||||
})
|
||||
.expect("同一个版本标签可以重复提交")
|
||||
.snapshot;
|
||||
assert_eq!(repeated.version_number, first.version_number);
|
||||
assert_ne!(repeated.version_id, first.version_id);
|
||||
assert_eq!(
|
||||
service.get_version(&first.version_id).expect("旧版本仍在"),
|
||||
first
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,10 @@ pub struct CreateVersionInput {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
/// 用户可见的正整数版本标签;`None` 时按该游戏已有最大版本号 +1(旧客户端兼容)。
|
||||
/// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交,每次都是一条新版本记录。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version_number: Option<u64>,
|
||||
pub package_sha256: String,
|
||||
pub package_bytes: u64,
|
||||
pub idempotency: IdempotencyRequest,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::errors::GameDistributionFieldError;
|
||||
use shared_kernel::{build_prefixed_seed_id, normalize_required_string};
|
||||
|
||||
pub const GAME_ID_PREFIX: &str = "game_";
|
||||
@@ -17,6 +18,25 @@ pub fn generate_game_version_id(seed_micros: i64, version_number: u64) -> String
|
||||
)
|
||||
}
|
||||
|
||||
/// 解析本次创建的版本号(用户可见的版本标签,不是不可变的 `version_id`)。
|
||||
///
|
||||
/// - `None`:旧客户端兼容路径,取该游戏已有最大版本号 +1;溢出时失败关闭,不回绕成 0。
|
||||
/// - `Some(requested)`:只要求正整数。同一个 gameId 的同一个版本号可以反复提交——每次提交都是
|
||||
/// 一条新的版本记录(新的 `version_id`),旧公开实例不原地修改——所以**不与已有最大值比较**,
|
||||
/// 允许重复标签,也允许回退到更小的标签。
|
||||
pub fn resolve_version_number(
|
||||
max_existing: u64,
|
||||
requested: Option<u64>,
|
||||
) -> Result<u64, GameDistributionFieldError> {
|
||||
match requested {
|
||||
Some(0) => Err(GameDistributionFieldError::InvalidVersionNumber),
|
||||
Some(requested) => Ok(requested),
|
||||
None => max_existing
|
||||
.checked_add(1)
|
||||
.ok_or(GameDistributionFieldError::VersionNumberExhausted),
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes the lowercase SHA-256 digest used by the idempotency contract.
|
||||
pub fn compute_request_digest(payload: impl AsRef<[u8]>) -> String {
|
||||
let digest = Sha256::digest(payload.as_ref());
|
||||
|
||||
@@ -13,6 +13,8 @@ pub enum GameDistributionFieldError {
|
||||
InvalidPackageDigest,
|
||||
InvalidPackageBytes,
|
||||
MissingReviewReason,
|
||||
InvalidVersionNumber,
|
||||
VersionNumberExhausted,
|
||||
}
|
||||
|
||||
impl fmt::Display for GameDistributionFieldError {
|
||||
@@ -27,6 +29,8 @@ impl fmt::Display for GameDistributionFieldError {
|
||||
Self::InvalidPackageDigest => "packageSha256 必须是 64 位十六进制摘要",
|
||||
Self::InvalidPackageBytes => "packageBytes 必须大于 0",
|
||||
Self::MissingReviewReason => "拒绝审核必须提供理由",
|
||||
Self::InvalidVersionNumber => "版本号必须是大于 0 的整数",
|
||||
Self::VersionNumberExhausted => "版本号已达上限,无法自动递增",
|
||||
};
|
||||
formatter.write_str(message)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub use application::{
|
||||
pub use commands::{CreateGameInput, CreateVersionInput, IdempotencyRequest, ReviewDecision};
|
||||
pub use domain::{
|
||||
GameDistributionAction, GameSnapshot, GameVersionSnapshot, GameVersionStatus, GameVisibility,
|
||||
compute_request_digest, generate_game_id, generate_game_version_id,
|
||||
compute_request_digest, generate_game_id, generate_game_version_id, resolve_version_number,
|
||||
};
|
||||
pub use errors::{GameDistributionError, GameDistributionFieldError};
|
||||
pub use events::GameDistributionEvent;
|
||||
|
||||
@@ -1087,6 +1087,36 @@ pub struct GameCreationAppPublicationBinding {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// 项目版本标签的建议默认值:没有任何线上记录时的首个版本。
|
||||
pub const GAME_CREATION_APP_FIRST_PROJECT_VERSION: u64 = 1;
|
||||
|
||||
/// 项目版本标签的唯一合法形状:正整数(`0` 永远是非法标签,不表示"未设置")。
|
||||
pub fn is_game_creation_app_project_version(value: u64) -> bool {
|
||||
value > 0
|
||||
}
|
||||
|
||||
/// 项目版本标签的有效值。
|
||||
///
|
||||
/// 新清单直接用写下来的 `project_version`;旧清单缺这个字段时按发布绑定回填——线上已有版本号
|
||||
/// (`latest_version_number > 0`)就用它,否则用首版 `1`。`0` 与缺失同义(历史上没有任何写入口
|
||||
/// 会落 0),所以两者都走回填分支。
|
||||
///
|
||||
/// `binding` 必须已经按**当前会话**(`account_id` + `api_base_url`)与 `game_id` 隔离:传别的
|
||||
/// 账号 / 别的环境的绑定,就会把该项目的版本标签回填成别人的线上版本号。原生回读与发布写回都
|
||||
/// 遵守这条隔离。
|
||||
pub fn resolve_game_creation_app_project_version(
|
||||
project_version: Option<u64>,
|
||||
binding: Option<&GameCreationAppPublicationBinding>,
|
||||
) -> u64 {
|
||||
if let Some(project_version) = project_version.filter(|value| *value > 0) {
|
||||
return project_version;
|
||||
}
|
||||
binding
|
||||
.and_then(|binding| binding.latest_version_number)
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(GAME_CREATION_APP_FIRST_PROJECT_VERSION)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
pub struct GameCreationAppManifest {
|
||||
@@ -1108,6 +1138,16 @@ pub struct GameCreationAppManifest {
|
||||
pub godot_project_root: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub cocos_project_root: Option<String>,
|
||||
/// 唯一用户发行版本标签(`项目版本 vN`),正整数,不需要递增。
|
||||
///
|
||||
/// 同一个 gameId 的同一个版本标签可以反复提交:每次具体包/资料/审核提交由不可变
|
||||
/// `versionId` 区分,所以这里允许重复与回退(低于线上当前版本)。它不是内部编辑迭代
|
||||
/// `versions[].project_revision`,也不是 semver 字符串。
|
||||
///
|
||||
/// 旧清单没有这个字段:读取方用 [`resolve_game_creation_app_project_version`] 按当前会话
|
||||
/// 隔离后的发布绑定回填,发布或改版本标签时由原生写回。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub project_version: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub publication: Option<GameCreationAppPublicationBinding>,
|
||||
}
|
||||
@@ -1243,6 +1283,7 @@ pub fn new_game_creation_app_manifest(
|
||||
command_runs: Vec::new(),
|
||||
versions: Vec::new(),
|
||||
godot_project_root: None,
|
||||
project_version: None,
|
||||
publication: None,
|
||||
cocos_project_root: None,
|
||||
}
|
||||
@@ -2182,6 +2223,67 @@ mod tests {
|
||||
assert_eq!(payload["godotProjectRoot"], json!("game-source"));
|
||||
}
|
||||
|
||||
/// 项目版本标签是清单里唯一的用户发行版本:线上线同一字段名(`projectVersion`),
|
||||
/// 缺省时按已隔离的发布绑定回填,且绝不落 0。
|
||||
#[test]
|
||||
fn manifest_project_version_round_trips_and_backfills_from_binding() {
|
||||
let manifest = new_game_creation_app_manifest("project-1", "像素动作原型");
|
||||
let payload = serde_json::to_value(&manifest).expect("manifest should serialize");
|
||||
assert!(
|
||||
payload.get("projectVersion").is_none(),
|
||||
"旧清单语义:没有版本标签时不写字段,由发布/回读回填"
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(manifest.project_version, None),
|
||||
GAME_CREATION_APP_FIRST_PROJECT_VERSION
|
||||
);
|
||||
|
||||
let mut published = manifest.clone();
|
||||
published.project_version = Some(4);
|
||||
published.publication = Some(GameCreationAppPublicationBinding {
|
||||
account_id: "user-1".to_string(),
|
||||
api_base_url: "https://dev.test".to_string(),
|
||||
game_id: "game-1".to_string(),
|
||||
revision: 3,
|
||||
latest_version_id: Some("gamever-1".to_string()),
|
||||
latest_version_number: Some(4),
|
||||
status: Some("pending_review".to_string()),
|
||||
});
|
||||
let payload = serde_json::to_value(&published).expect("published manifest serializes");
|
||||
assert_eq!(payload["projectVersion"], json!(4));
|
||||
assert_eq!(payload["publication"]["latestVersionNumber"], json!(4));
|
||||
assert_eq!(
|
||||
serde_json::from_value::<GameCreationAppManifest>(payload)
|
||||
.expect("project version must round-trip")
|
||||
.project_version,
|
||||
Some(4)
|
||||
);
|
||||
|
||||
// 缺失或 0(不是合法标签)都走回填:线上有版本号就用它,否则首版 1。
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(None, published.publication.as_ref()),
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(Some(0), published.publication.as_ref()),
|
||||
4
|
||||
);
|
||||
let mut without_latest = published.publication.clone();
|
||||
without_latest
|
||||
.as_mut()
|
||||
.expect("binding")
|
||||
.latest_version_number = Some(0);
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(None, without_latest.as_ref()),
|
||||
GAME_CREATION_APP_FIRST_PROJECT_VERSION
|
||||
);
|
||||
// 清单里写过的值胜出:重开项目不能被线上版本号悄悄改写。
|
||||
assert_eq!(
|
||||
resolve_game_creation_app_project_version(Some(2), published.publication.as_ref()),
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
/// 未知顶层字段必须失败关闭。这条契约级断言钉住的是本结构体本身的取向,而不是某个调用方:
|
||||
/// AGC 客户端读写 manifest 的方式是「整结构体反序列化 + 整结构体重新序列化覆盖落盘」,
|
||||
/// 一旦放行未知顶层字段,「读一次 + 任意一次写」就会静默抹掉未来版本新增的字段。
|
||||
|
||||
@@ -14,10 +14,12 @@ pub const GAME_DISTRIBUTION_CATEGORIES: [&str; 7] =
|
||||
/// 客户端要么放行服务端必拒的包(白等一次上传),要么拦掉服务端能收的包。
|
||||
pub const GAME_DISTRIBUTION_MAX_PACKAGE_BYTES: u64 = 200 * 1024 * 1024;
|
||||
|
||||
/// 显式版本号与既有版本冲突时的统一错误前缀。
|
||||
/// 版本号无法分配时的统一错误前缀(只在自动递增溢出时使用)。
|
||||
///
|
||||
/// SpacetimeDB 创建版本事务在 max 校验失败、或自动递增溢出时返回带此前缀的错误;
|
||||
/// api-server 据此映射成 HTTP 409,让客户端区分"版本号必须严格更大"与参数格式错误。
|
||||
/// 旧的「必须严格大于当前最大版本号」限制已经取消:同一 gameId 的同一个版本号可以反复提交,
|
||||
/// 每次生成新的 versionId,也允许回退到更小的标签。显式版本号只要求 `>= 1`,`0` 这类非法值
|
||||
/// 走普通 400;只有旧客户端缺省时按最大版本号自动递增、撞上 `u64::MAX` 才会带上此前缀,
|
||||
/// api-server 据此映射成 HTTP 409。
|
||||
pub const GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT: &str = "VERSION_NUMBER_CONFLICT";
|
||||
|
||||
/// 发布页免费生成简介与分类的输入。
|
||||
@@ -230,10 +232,11 @@ pub struct GameDistributionCreateGameRequest {
|
||||
pub struct GameDistributionCreateVersionRequest {
|
||||
#[serde(default)]
|
||||
pub local_project_id: Option<String>,
|
||||
/// 显式目标版本号;缺省时服务端按该游戏已有最大版本号自动 +1。
|
||||
/// 用户可见的正整数版本标签(AGC 里就是 `projectVersion`)。
|
||||
///
|
||||
/// 传入值必须严格大于该游戏已有最大版本号,允许跳号但不允许覆盖、重用或回退;
|
||||
/// 冲突时服务端返回 409(`GAME_DISTRIBUTION_VERSION_NUMBER_CONFLICT`)。
|
||||
/// 传入时只要求 `>= 1`:同一 gameId 的同一个版本号可以反复提交,每次提交生成新的
|
||||
/// versionId,允许重复标签与回退到更小的版本号,不与已有最大值比较。缺省时保留旧客户端
|
||||
/// 兼容行为:服务端按该游戏已有最大版本号 +1(此时溢出才会返回 409)。
|
||||
/// 缺省时序列化省略该字段,保证旧客户端请求的幂等摘要与升级前一致。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub version_number: Option<u64>,
|
||||
|
||||
@@ -88,7 +88,8 @@ pub struct GameDistributionCreateVersionRecordInput {
|
||||
pub game_id: String,
|
||||
pub owner_user_id: String,
|
||||
pub version_id: String,
|
||||
/// 显式目标版本号;`None` 时由服务端按已有最大版本号自动 +1。
|
||||
/// 用户可见的正整数版本标签;`None` 时由服务端按已有最大版本号自动 +1(旧客户端兼容)。
|
||||
/// 传入时只要求 `>= 1`:同一 gameId 的同一标签可以反复提交,每次生成新的 version_id。
|
||||
pub version_number: Option<u64>,
|
||||
/// api-server 校验并解析素材后生成的资料快照 JSON。
|
||||
pub metadata_json: String,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user