Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/game_fork.rs
T
suzmii c5564e59ed feat(游戏共创): 客户端落地「从平台作品开始创作」取件链路(本地来源记录 + 新命令)
- 新增 `project/fork_source.rs`:项目改编来源记录落在 `.agent/fork-source.json`
  (`{schemaVersion,gameId,versionId,adoptedAtMillis}`,版本 `agc-fork-source.v1`)。
  位置理由:`.agent/**` 随项目快照上云、随用户整目录拷贝,且导出包不带 `.agent`,
  既随行又不干扰游戏产物;不改 `manifest.json`(它是 deny_unknown_fields + 只接受 v1)。
  写入走统一入口 `write_game_creator_private_file`;读取容忍缺失/损坏/异形/非普通文件,
  一律返回 None 而不报错、不 panic。
- 新增 `game_fork.rs` + `game_fork/desktop.rs` 与命令
  `create_local_project_from_platform_game`(在 `desktop.rs` 的 generate_handler 注册):
  取平台会话 → 取件元数据 → 带 Bearer 下载整包 → 校验字节数与 SHA-256(失败关闭、不落盘)
  → `init_local_game_project_at` 生成合规 Phaser4+Vite 脚手架 → 参考副本解压到
  `<project>/reference/<gameId>/`(复用 `safe_archive_relative_path` 与
  `extract_template_archive` 的条目数/单文件/符号链接门禁,按最小改动把后者提到 pub(crate))
  → 写来源记录;任一步失败都删掉半成品项目目录。
  参考副本必须落在子目录:`create_npm_scaffold` 的判据是「没有 manifest 且根/`game` 都没有
  index.html、package.json」,成品包若落在项目根或 `game/` 根,脚手架一个文件都不会生成。
- `game_distribution_publish.rs` 新增受鉴权取件实现:`fetch_platform_game_fork_source`
  (元数据 GET + 带 Bearer 的整包下载 + 404/409/403/401 四态可区分映射 + 下载路径同源校验)。
  不复用模板库的 `fetch_limited_bytes`:它写死 `client.get(url)`,带不了鉴权头,
  复用它会让受保护内容变成匿名下载。
- 埋点:新增 `CreationSource::PlatformGame`(`analytics/contract.rs` 的 values! 登记)并在建项
  成功后上报,来源标识沿用模板链路的 ID 槽位。
- 修既有编译错误:`game_distribution_publish.rs` 的资料摘要测试缺 `fork` 字段
  (上一提交给 `GameDistributionCreateGameRequest` 加了该字段但没同步该测试字面量),
  补 `fork: None` 以恢复测试目标可编译。
2026-10-05 17:30:46 +08:00

311 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 从平台作品开始创作(成品包路径):受鉴权取件 → 摘要校验 → 合规脚手架 + 参考副本 + 来源记录。
//!
//! 边界(与 `docs/【技术方案】游戏共创与作品Fork-2026-10-03.md` §3.5.1 / §3.5.4 路线 A 一致):
//! 平台下发的是**已构建的发行成品包**(只有 `dist` 产物、没有 `package.json` 也没有源码),
//! 它只能当「可玩参考 + 素材来源」,**不能**变成可直接发布的项目。因此这里建立的是
//! 「合规的 Phaser4 + Vite 脚手架」+「子目录里的参考副本」,用户在脚手架上自己改造。
//!
//! 关键顺序(顺序本身就是合同):
//! 1. 先取件并按元数据校验字节(失败关闭,不落盘);
//! 2. 再 `init_local_game_project_at` 生成脚手架——它的 `create_npm_scaffold` 判据是
//! 「没有 manifest 且根/`game` 都没有 index.html、没有 package.json」,所以参考产物
//! 必须等脚手架生成之后再铺,且只能铺进子目录;
//! 3. 最后写 `.agent/fork-source.json`,供发布链路在首次发布时声明改编来源。
#[cfg(not(test))]
mod desktop;
#[cfg(not(test))]
pub(crate) use desktop::*;
use super::*;
/// 参考副本的子目录名。绝不能落在项目根或 `game/` 根:那会让合规脚手架一个文件都不生成。
const FORK_REFERENCE_DIRECTORY_NAME: &str = "reference";
/// 取件字节校验:字节数与 SHA-256 都必须与取件元数据一致,任一不符即失败关闭。
///
/// 抽成纯函数是为了让「失败就绝不落盘」这条规则可被单测钉住:调用点在解压之前。
pub(crate) fn verify_fork_source_bytes(
bytes: &[u8],
expected_sha256: &str,
expected_bytes: u64,
) -> Result<(), String> {
if bytes.len() as u64 != expected_bytes {
return Err(format!(
"改编来源发行包大小校验失败(期望 {expected_bytes} 字节,实际 {} 字节),已放弃落盘",
bytes.len()
));
}
let actual = sha256_hex(bytes);
if actual != expected_sha256.trim().to_ascii_lowercase() {
return Err("改编来源发行包完整性校验失败,已放弃落盘".to_string());
}
Ok(())
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = sha2::Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
/// 参考副本相对项目的路径:`reference/<gameId>/`。
///
/// 作品 ID 来自平台响应,只接受路径安全取值;拼接复用归档条目门禁(拒绝 `..`、盘符与
/// 绝对路径),保证参考副本永远落在项目内的子目录,不会逃出项目根。
pub(crate) fn fork_reference_relative_path(game_id: &str) -> Result<PathBuf, String> {
let game_id = game_id.trim();
// 空标识必须显式拒绝:`safe_archive_relative_path` 会把空段丢掉,`reference/` 会被
// 归一成 `reference`,那不是某个作品的参考目录。
if game_id.is_empty() {
return Err("改编来源作品标识无效,无法确定参考副本目录".to_string());
}
let relative = format!("{FORK_REFERENCE_DIRECTORY_NAME}/{game_id}");
let relative = crate::template_library::safe_archive_relative_path(&relative)
.map_err(|_| "改编来源作品标识无效,无法确定参考副本目录".to_string())?;
// 只接受「`reference` + 单一作品目录」两段:带 `/` 的标识会变成嵌套目录,既可能撞上
// 另一个作品的目录,也不是平台作品 ID 的形状。
if relative.components().count() != 2 {
return Err("改编来源作品标识无效,无法确定参考副本目录".to_string());
}
Ok(relative)
}
/// 参考副本目录:`<project>/reference/<gameId>/`。
pub(crate) fn fork_reference_directory(
project_root: &Path,
game_id: &str,
) -> Result<PathBuf, String> {
Ok(project_root.join(fork_reference_relative_path(game_id)?))
}
/// 用平台作品的成品包建一个新项目。
///
/// 先按标准初始化生成合规脚手架,再把参考副本铺进 `<project>/reference/<gameId>/`,
/// 最后补齐来源记录;任一步失败都删掉半成品目录,不留无法解释的项目。
pub(crate) fn create_project_from_platform_fork_at(
projects_root: &Path,
game_id: &str,
version_id: &str,
package_bytes: &[u8],
requested_name: Option<&str>,
planning: bool,
) -> Result<InitLocalProjectResult, String> {
let requested_name = requested_name
.map(normalize_game_creation_project_name)
.transpose()?;
// 目录名先算出来:标识非法时要在创建任何目录之前失败,不能留下半成品。
let reference_relative = fork_reference_relative_path(game_id)?;
if projects_root.as_os_str().is_empty() || !projects_root.is_absolute() {
return Err("自动工作区根目录必须是绝对路径".to_string());
}
ensure_game_creator_private_directory_tree(projects_root, "自动工作区根目录")?;
prepare_game_creator_private_path_for_read(projects_root, true, "自动工作区根目录")?;
let metadata = fs::symlink_metadata(projects_root).map_err(|error| {
format!(
"读取自动工作区根目录失败:{}: {error}",
projects_root.display()
)
})?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err("自动工作区根目录必须是普通文件夹".to_string());
}
for _ in 0..16 {
let workspace_id = uuid::Uuid::new_v4().simple().to_string();
let short_id = &workspace_id[..8];
let project_name = requested_name.clone().unwrap_or_else(|| {
let prefix = if planning {
"策划项目"
} else {
"改编项目"
};
format!("{prefix} {short_id}")
});
let project_root = projects_root.join(format!("gameagent-{short_id}"));
match fs::create_dir(&project_root) {
Ok(()) => {
let result = (|| {
harden_new_game_creator_private_path(&project_root, true, "自动项目目录")?;
enforce_project_permission_policy(&project_root, "project.create")?;
let _lock = acquire_project_write_lock(&project_root, "project.create")?;
// 脚手架必须在参考产物之前生成,否则 `create_npm_scaffold` 判据被
// 成品包里的 `index.html` / `package.json` 打断,项目从此无法发布。
let project = init_local_game_project_at(
&project_root,
&format!("gameagent-{workspace_id}"),
&project_name,
)?;
let reference_root = project_root.join(&reference_relative);
ensure_game_creator_private_directory_tree(&reference_root, "改编参考目录")?;
// 复用模板归档的同一套门禁:条目数上限、单文件上限、拒符号链接、
// 条目路径只允许项目内相对路径。
crate::template_library::extract_template_archive(
package_bytes,
&reference_root,
)
.map_err(|error| format!("改编来源发行包解压失败:{error}"))?;
write_project_fork_source(
&project_root,
&ProjectForkSourceRecord::new(game_id, version_id),
)?;
Ok(project)
})();
if result.is_err() {
let _ = fs::remove_dir_all(&project_root);
}
return result;
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => {
return Err(format!(
"创建自动工作区失败:{}: {error}",
project_root.display()
));
}
}
}
Err("自动工作区命名冲突,请重试".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use zip::write::SimpleFileOptions;
fn test_root(label: &str) -> PathBuf {
let nonce = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos())
.unwrap_or_default();
let root = std::env::temp_dir().join(format!(
"agc-game-fork-{label}-{}-{nonce}",
std::process::id()
));
fs::create_dir_all(&root).expect("create temp root");
root
}
/// 最小成品包:发行包的真实形状是「运行产物 + 根 index.html」,不含 package.json。
fn release_package_bytes() -> Vec<u8> {
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = SimpleFileOptions::default();
writer.start_file("index.html", options).expect("start entry");
std::io::Write::write_all(&mut writer, b"<html></html>").expect("write entry");
writer.start_file("game/main.js", options).expect("start script");
std::io::Write::write_all(&mut writer, b"console.log('playable')").expect("write script");
writer.finish().expect("finish zip").into_inner()
}
#[test]
fn fork_source_bytes_must_match_declared_size_and_digest() {
let bytes = release_package_bytes();
let digest = sha256_hex(&bytes);
assert!(verify_fork_source_bytes(&bytes, &digest, bytes.len() as u64).is_ok());
// 摘要大小写不敏感(服务端回小写,客户端仍按同一口径比较)。
assert!(verify_fork_source_bytes(&bytes, &digest.to_uppercase(), bytes.len() as u64).is_ok());
let size_error = verify_fork_source_bytes(&bytes, &digest, bytes.len() as u64 + 1)
.expect_err("大小不符必须失败");
assert!(size_error.contains("大小校验失败"), "{size_error}");
let digest_error = verify_fork_source_bytes(&bytes, &"a".repeat(64), bytes.len() as u64)
.expect_err("摘要不符必须失败");
assert!(digest_error.contains("完整性校验失败"), "{digest_error}");
assert!(verify_fork_source_bytes(&[], &digest, 0).is_err());
}
#[test]
fn fork_reference_directory_stays_inside_the_project() {
let root = Path::new("C:/work/project");
assert_eq!(
fork_reference_directory(root, "game_1").expect("safe id"),
root.join("reference").join("game_1")
);
// 路径不安全的标识一律失败关闭,不拼出逃出项目根的路径。
for unsafe_id in ["", "..", "../escape", "a/b", "a\\b", "C:game", " "] {
assert!(
fork_reference_directory(root, unsafe_id).is_err(),
"should reject {unsafe_id:?}"
);
}
}
#[test]
fn platform_fork_project_gets_scaffold_reference_copy_and_source_record() {
let projects_root = test_root("create");
let bytes = release_package_bytes();
let project = create_project_from_platform_fork_at(
&projects_root,
"game_parent",
"gamever_parent",
&bytes,
Some("改编测试"),
false,
)
.expect("create fork project");
let root = Path::new(&project.project_path);
// 1) 合规脚手架正常生成:`create_npm_scaffold` 的判据没有被参考产物打断。
assert!(root.join("game/package.json").is_file());
assert!(root.join("game/vite.config.js").is_file());
assert!(root.join("game/index.html").is_file());
assert!(root.join(".agent/manifest.json").is_file());
// 2) 参考副本落在子目录里,且与脚手架互不覆盖。
assert!(root.join("reference/game_parent/index.html").is_file());
assert!(root.join("reference/game_parent/game/main.js").is_file());
assert_ne!(
fs::read_to_string(root.join("reference/game_parent/index.html")).unwrap(),
fs::read_to_string(root.join("game/index.html")).unwrap()
);
// 3) 来源记录与项目身份一致。
let record = read_project_fork_source(root).expect("fork source record");
assert_eq!(record.game_id, "game_parent");
assert_eq!(record.version_id, "gamever_parent");
assert_eq!(project.manifest.name, "改编测试");
}
#[test]
fn failed_fork_creation_leaves_no_half_built_project() {
let projects_root = test_root("rollback");
// 不是合法 zip:解压必须在铺参考副本这一步失败,然后整个项目目录被移除。
let error = create_project_from_platform_fork_at(
&projects_root,
"game_parent",
"gamever_parent",
b"not-a-zip",
None,
false,
)
.expect_err("invalid archive must fail");
assert!(error.contains("zip"), "{error}");
let leftovers = fs::read_dir(&projects_root)
.expect("read projects root")
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().starts_with("gameagent-"))
.count();
assert_eq!(leftovers, 0, "失败的项目目录必须被清理");
}
#[test]
fn unsafe_game_id_fails_before_any_directory_is_created() {
let projects_root = test_root("unsafe-id");
let bytes = release_package_bytes();
assert!(create_project_from_platform_fork_at(
&projects_root,
"../escape",
"gamever_parent",
&bytes,
None,
false,
)
.is_err());
assert_eq!(
fs::read_dir(&projects_root).expect("read root").count(),
0,
"标识非法时不得创建任何目录"
);
}
}