diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 96468794f..3a0ec0140 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5974,15 +5974,19 @@ pub(crate) fn create_local_project_checkpoint( create_local_project_checkpoint_at(root) } +/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `npm run build`。 +/// +/// 作者只点一次「发布」:已有 `game/index.html` 或 `dist/index.html` 直接打包;只有源码时 +/// 走 `project.verify` 的受控 npm 运行器构建后再打包,失败信息带构建日志尾部。 #[tauri::command] -pub(crate) fn export_local_project_package( +pub(crate) async fn export_local_project_package( project_path: String, ) -> Result { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "project.export_package")?; let _lock = acquire_project_write_lock(root, "project.export_package")?; advance_agent_runtime_project_revision_locked(root)?; - export_local_project_package_at(root) + export_local_project_package_for_publish_at(root).await } #[tauri::command] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs index cb8fc948c..214b3f16d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/export.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/export.rs @@ -136,6 +136,159 @@ pub(crate) fn export_local_project_package_at( /// /// The caller receives the package bytes and a deterministic file manifest, but /// never receives a filesystem path that it could accidentally send to the API. +/// 发布前构建的超时上限:与 `project.verify` 的上限保持一致(构建属于常规步骤, +/// 给足时间但必须有界),避免发布路径越过校验器允许的区间。 +pub(crate) const PUBLISH_BUILD_TIMEOUT_SECONDS: u64 = 300; + +/// 找到声明了 `scripts.build` 的 npm 工作目录(项目根或 `game/` 子工程)。 +/// +/// 只读 `package.json`,不执行任何东西;真正的执行交给 `project.verify` 的受控 +/// npm 运行器(脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义、沙箱与超时都在那里)。 +pub(crate) fn resolve_publish_build_cwd(root: &Path) -> Result, String> { + for cwd in [".", "game"] { + let package_root = if cwd == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, cwd)? + }; + let package_path = package_root.join("package.json"); + let metadata = match fs::symlink_metadata(&package_path) { + Ok(metadata) => metadata, + Err(_) => continue, + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + continue; + } + let Ok(content) = fs::read_to_string(&package_path) else { + continue; + }; + let Ok(package) = serde_json::from_str::(&content) else { + continue; + }; + let declared = package + .get("scripts") + .and_then(|scripts| scripts.get("build")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()); + if declared.is_some() { + return Ok(Some(cwd)); + } + } + Ok(None) +} + +/// 读取声明的 build 脚本原文:`project.verify` 用它做 expectedCommand 反漂移校验。 +pub(crate) fn read_publish_build_command( + root: &Path, + cwd_relative: &str, +) -> Result { + let package_root = if cwd_relative == "." { + root.to_path_buf() + } else { + resolve_local_project_path(root, cwd_relative)? + }; + let package_path = package_root.join("package.json"); + let content = fs::read_to_string(&package_path).map_err(|error| { + format!( + "读取 package.json 失败:{}: {error}", + package_path.display() + ) + })?; + let package: serde_json::Value = serde_json::from_str(&content) + .map_err(|error| format!("解析 package.json 失败:{error}"))?; + package + .get("scripts") + .and_then(|scripts| scripts.get("build")) + .and_then(serde_json::Value::as_str) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "package.json 未定义 build 脚本".to_string()) +} + +/// 构建失败的日志尾部:命令输出有界,直接回传最后一段给作者判断。 +fn publish_build_failure_tail(output: &str) -> String { + const MAX_CHARS: usize = 2_000; + let trimmed = output.trim(); + let chars = trimmed.chars().count(); + if chars <= MAX_CHARS { + return trimmed.to_string(); + } + let tail = trimmed.chars().skip(chars - MAX_CHARS).collect::(); + format!("…{tail}") +} + +/// 发布前构建计划:在哪个目录构建、构建脚本原文、以及是否需要先装依赖。 +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct PublishBuildPlan { + pub(crate) cwd_relative: &'static str, + pub(crate) command: String, + /// `game/` 子工程缺 `node_modules` 时为 true:构建前必须先跑 `project.bootstrap`。 + pub(crate) needs_dependency_install: bool, +} + +/// 解析发布前构建计划;项目没有任何可构建的 npm 工程时返回可操作错误。 +pub(crate) fn resolve_publish_build_plan(root: &Path) -> Result { + let Some(cwd_relative) = resolve_publish_build_cwd(root)? else { + return Err( + "项目还没有可玩入口,且项目根 / game 目录的 package.json 都没有 build 脚本:请让 Agent 生成可玩产物,或补上 build 脚本后重试" + .to_string(), + ); + }; + let command = read_publish_build_command(root, cwd_relative)?; + let needs_dependency_install = + cwd_relative == "game" && !root.join("game").join("node_modules").is_dir(); + Ok(PublishBuildPlan { + cwd_relative, + command, + needs_dependency_install, + }) +} + +/// 为发布导出试玩包:项目还没有可玩入口时,先跑项目自己的 `npm run build`。 +/// +/// 作者只需要点一次「发布」:已有可玩产物(`game/index.html` 或 `dist/index.html`)直接打包; +/// 只有源码时用 `project.verify` 的受控 npm 运行器执行 build,再校验入口并打包。构建失败 +/// 返回带日志尾部的可操作错误,不回传本地路径。 +pub(crate) async fn export_local_project_package_for_publish_at( + root: &Path, +) -> Result { + if validate_project_game_entry(root).is_ok() { + return export_local_project_package_at(root); + } + let plan = resolve_publish_build_plan(root)?; + // `game/` 子工程构建前必须先有依赖:缺 node_modules 时由发布流程自己补一次安装, + // 否则作者要点两次(先 bootstrap 再发布)。 + if plan.needs_dependency_install { + let bootstrap = + crate::project::run_project_bootstrap_at(root, PUBLISH_BUILD_TIMEOUT_SECONDS).await?; + if bootstrap.status != "completed" { + return Err(format!( + "安装 game 依赖失败(npm install 未通过):\n{}", + publish_build_failure_tail(&bootstrap.output) + )); + } + } + let built = crate::project::verification::run_project_verification_with_commit_at( + root, + "build", + &plan.command, + PUBLISH_BUILD_TIMEOUT_SECONDS, + plan.cwd_relative, + || Ok(()), + ) + .await?; + if built.status != "completed" { + return Err(format!( + "构建可玩版本失败(npm run build 未通过):\n{}", + publish_build_failure_tail(&built.output) + )); + } + validate_project_game_entry(root) + .map_err(|error| format!("构建完成但项目仍没有可玩入口:{error}"))?; + export_local_project_package_at(root) +} + pub(crate) fn read_local_project_export_package_at( root: &Path, package_relative_path: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index a67c171f5..9b0f1a480 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -3935,6 +3935,165 @@ fn local_project_export_package_uses_runtime_whitelist_and_records() { fs::remove_dir_all(root).ok(); } +#[test] +fn publish_build_plan_prefers_game_subproject_and_requires_dependencies() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-plan-game", "Phaser 工程").expect("project init"); + + // 脚手架是 game/ + vite build(Phaser 4 工程):缺依赖时必须先 install。 + let plan = resolve_publish_build_plan(&root).expect("解析构建计划"); + assert_eq!(plan.cwd_relative, "game"); + assert_eq!(plan.command, "vite build"); + assert!( + plan.needs_dependency_install, + "缺少 game/node_modules 时应先装依赖" + ); + + fs::create_dir_all(root.join("game/node_modules")).expect("create node_modules"); + let installed = resolve_publish_build_plan(&root).expect("解析构建计划"); + assert!( + !installed.needs_dependency_install, + "已有依赖时不应重复 install" + ); + + fs::remove_dir_all(root).ok(); +} + +#[test] +fn publish_build_plan_falls_back_to_root_npm_build() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-plan-root", "根工程构建").expect("project init"); + // 去掉 game 子工程的 build,改用项目根 npm 工程构建。 + fs::write( + root.join("game/package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "plan-root-game", + "private": true, + "scripts": { "check": "node -e \"process.exit(0)\"" } + })) + .expect("serialize game package json"), + ) + .expect("write game package json"); + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "plan-root-fixture", + "private": true, + "scripts": { "build": "node build-root.mjs" } + })) + .expect("serialize root package json"), + ) + .expect("write root package json"); + + let plan = resolve_publish_build_plan(&root).expect("解析构建计划"); + assert_eq!(plan.cwd_relative, "."); + assert_eq!(plan.command, "node build-root.mjs"); + assert!(!plan.needs_dependency_install); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn publish_export_runs_project_build_before_packaging() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-auto-build", "自动构建发布项目") + .expect("project init"); + // 只有源码:package.json 声明 build,构建脚本产出 dist/ 可玩产物。 + fs::write( + root.join("package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "publish-auto-build-fixture", + "private": true, + "scripts": { "build": "node build-publish.mjs" } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + fs::write( + root.join("build-publish.mjs"), + r#"import { mkdirSync, writeFileSync } from 'node:fs'; +mkdirSync('dist/assets', { recursive: true }); +writeFileSync('dist/index.html', 'Auto Build

AUTO-BUILD

'); +writeFileSync('dist/assets/app.js', 'document.documentElement.dataset.autoBuild = "1";'); +"#, + ) + .expect("write build script"); + write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); + + let result = export_local_project_package_for_publish_at(&root) + .await + .expect("发布前构建并导出"); + + assert!(root.join("dist/index.html").is_file()); + assert!(root.join("dist/assets/app.js").is_file()); + assert!(result + .package_relative_path + .starts_with("exports/playtest-package-")); + let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); + assert!( + log.contains("project.verify build"), + "发布前应记录一次 project.verify build:{log}" + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn publish_export_skips_build_when_playable_entry_exists() { + let root = unique_project_path(); + init_existing_html_project_at(&root, "project-publish-skip", "已构建发布项目") + .expect("project init"); + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + write_local_project_file_at(&root, "exports/README.md", "publish notes").expect("write readme"); + + let result = export_local_project_package_for_publish_at(&root) + .await + .expect("已有可玩入口时直接导出"); + + assert!(result.package_relative_path.ends_with(".zip")); + let log = fs::read_to_string(root.join(".agent/logs/command.log")).unwrap_or_default(); + assert!( + !log.contains("project.verify build"), + "已有可玩入口时不应触发构建:{log}" + ); + + fs::remove_dir_all(root).ok(); +} + +#[tokio::test] +async fn publish_export_reports_actionable_error_without_entry_or_build_script() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-no-entry", "缺少可玩入口项目") + .expect("project init"); + // 脚手架默认带 build 脚本;这里改成只有 check 脚本,模拟“没有可玩产物且没有构建脚本”。 + fs::write( + root.join("game/package.json"), + serde_json::to_string_pretty(&serde_json::json!({ + "name": "publish-no-entry-fixture", + "private": true, + "scripts": { "check": "node -e \"process.exit(0)\"" } + })) + .expect("serialize package json"), + ) + .expect("write package json"); + + let error = export_local_project_package_for_publish_at(&root) + .await + .expect_err("缺少入口且没有 build 脚本时必须失败关闭"); + + assert!( + error.contains("还没有可玩入口"), + "错误应说明缺少可玩入口:{error}" + ); + assert!( + error.contains("build 脚本"), + "错误应指向 build 脚本:{error}" + ); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_project_export_package_publish_payload_contains_bytes_and_file_digests() { let root = unique_project_path(); diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index c5b4f61eb..a53cbbf36 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -116,6 +116,9 @@ - 发布入口灰度下发:`GET /api/runtime/frontend-config` 新增 `gameDistributionPublishEnabled`,复用既有 `is_game_distribution_publish_enabled_for_user`(未配置 `game-distribution:publish` 或 `enabled=false` 时对已登录作者默认开放,显式收紧后只放行白名单/灰度命中,匿名恒为 false),避免前端入口与写入口出现两套判据。网页端 `PlatformEntryActiveFlowShell` 据此隐藏「发布游戏 / 发布新版本」入口,`/games/publish` 直接访问时渲染「发布功能正在灰度中」并提供重新检查;AGC 端 `readGamePublishAvailability` 同样读该字段,只有命中才把发布回调交给 DirectProject 聊天头。 - 灰度验证:`cargo test -p api-server frontend_runtime_config`(6 passed,含新增的 `frontend_runtime_config_game_distribution_publish_is_scoped_to_authenticated_gate`:无 gate 行 → 登录作者 true/匿名 false;`enabled=true` 无白名单 → false;白名单命中 → true;`deny_user_ids` → false;`enabled=false` → true;`rolloutPercent=100` → true)、网页发布页 15 用例(含灰度未命中隐藏表单与「重新检查」放行)、平台壳 18 用例(含广场入口按灰度隐藏/显示)、AGC 发布服务 6 用例(含字段缺失与读取失败按不开放处理)。 +- Phaser 一键发布闭环(作者不构建、不打 ZIP):`export_local_project_package` 改为发布前构建——已有可玩入口直接打包,否则解析 `game/` 或项目根的 npm `build` 脚本(`resolve_publish_build_plan`),缺 `game/node_modules` 时先跑 `project.bootstrap`,再走 `project.verify` 的受控 npm 运行器执行 build,最后校验入口并打包;构建或安装失败返回带日志尾部的可操作错误。真实 Phaser 4.2.1 + Vite 7 工程验证:构建产物使用相对引用(`./assets/...`),ZIP 370,969 B 经真实素材直传 + 创建游戏/版本/上传/送审/审核通过后,发行网关 `index.html` 200(323 B)与 `assets/index-DZGg_tPs.js` 200(1,388,719 B),网页播放页在 `allow-scripts` 沙箱 iframe 内渲染出 `PHASER-PUBLISH-OK` 与可点击按钮。 +- 发行网关根路径:`GET /api/game-distribution/releases/{gameId}` 与带尾斜杠的同一路径等价于 `index.html`(生产由每游戏 origin 映射根路径,本地直连网关或入口直接填网关地址时同样可玩);路由级用例覆盖 Cookie 拒绝门与根路径。 + ## 尚未完成 - 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3272fd5ca..045e7d7c8 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -86,10 +86,10 @@ 2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。 3. 建议首版限额:压缩包 100 MiB、展开总量 250 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。 4. 提交声明 ZIP 的 SHA-256 与字节数,服务端对收到的真实 ZIP 重新计算,再对展开文件建立相对路径、字节数和 SHA-256 清单。摘要不一致、缺文件或入口损坏时停止;只有 metadata 而没有已确认完整对象的提交必须失败。 -5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。未配置该键、或 `enabled=false` 时对已登录作者默认开放;显式 `enabled=true` 后只有白名单或灰度命中的作者拿到开放状态,匿名恒为不开放。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。 +5. 游戏资料随发行版本冻结:标题 2–40 字、短简介不超过 120 字、详细介绍不超过 2,000 字、一个分类、最多 5 个标签(每个不超过 20 字)、必需封面、最多 6 张截图、操作方式不超过 240 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 ZIP:AGC 发布时对 `game/` 子工程按需执行 `npm install`(复用 `project.bootstrap`)与 `npm run build`(复用 `project.verify` 的受控 npm 运行器,脚本白名单含 `build`、禁止项目级 `.npmrc` 改写语义),再把 `game/dist` 归一化成根 `index.html` 的发行包上传;已有可玩入口(`game/index.html` 或 `dist/index.html`)时跳过构建。Phaser 4 + Vite 已按此口径端到端验证(构建产物、发行网关与网页沙箱播放)。发布入口按灰度下发:后端灰度配置键固定为 `game-distribution:publish`(后台「灰度发布配置」可改,支持 `enabled` / `rolloutPercent` / `allowUserIds` / `allowUserTags`)。未配置该键、或 `enabled=false` 时对已登录作者默认开放;显式 `enabled=true` 后只有白名单或灰度命中的作者拿到开放状态,匿名恒为不开放。发布入口的开放状态随 `/api/runtime/frontend-config` 的 `gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。 6. `supportedDevices` 至少包含 `desktop` 或 `mobile`;`inputModes` 来自 `keyboard`、`mouse`、`touch`;声明移动端必须包含 `touch`。`orientation` 为 `landscape`、`portrait` 或 `responsive`。这些是待人工复核的作者声明,目录只显示已经随版本审核通过的值。 7. 原始 ZIP、未审核展开目录、审核资料均为私有对象;公开版本不暴露源码镜像键、本地路径、访问凭据或私有账号元数据。运行文件只能由发行网关按游戏、版本和文件白名单读取,不能绕过网关访问公开 OSS bucket。 -8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 +8. 现役发行网关由 `api-server` 提供:`GET /api/game-distribution/releases/{gameId}`(含尾斜杠)等价于该游戏的 `index.html`,`GET /api/game-distribution/releases/{gameId}/{assetPath}` 只服务当前已公开版本包内的文件,私有 ZIP 与未公开版本不因知道 ID 而可读。响应按扩展名白名单设定内容类型,未知扩展名返回 404;全部响应带 `X-Content-Type-Options: nosniff`、`Cross-Origin-Resource-Policy: cross-origin` 与不带 credentials 的 `Access-Control-Allow-Origin: *`(发行文档运行在 `allow-scripts` 的 opaque origin 沙箱里,`same-origin` 会让游戏自己的脚本被浏览器拦下),HTML 追加最小权限 CSP。带平台 `Cookie` 的请求一律 `403`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、query 和 fragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`),口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。 ### 身份、状态、审核与更新 @@ -119,7 +119,7 @@ | --- | --- | --- | | `GET /games` | 游客 | **已实现**:关键词与分类筛选,最多 48 项;仅公开可玩版本 | | `GET /games/{gameId}` | 游客 | **已实现**:当前公开资料与 `currentVersion.entryUrl`;不可见时 404 | -| `GET /game-distribution/releases/{gameId}/{assetPath}` | 游客 | **已实现**:发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` | +| `GET /game-distribution/releases/{gameId}[/{assetPath}]` | 游客 | **已实现**:根路径等价于 `index.html`;发行网关只服务当前已公开版本包内文件,按扩展名白名单设内容类型,未知扩展名 404,带 Cookie 的请求 403;游玩页的入口来自详情投影的 `currentVersion.entryUrl` | | `GET /my/games` | 登录作者 | **已实现**:当前账号游戏、最近版本状态与驳回理由;owner 只从认证主体派生 | | `POST /games` | 登录作者 | **已实现**:幂等创建游戏身份,尚不公开;带 `localProjectId` 时同一作者复用既有 `gameId` | | `POST /games/{gameId}/versions` | owner | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 | diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 90599514b..8b8ae0bc1 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -4,10 +4,15 @@ // E2E_ADMIN_USER=<管理员用户名> E2E_ADMIN_PASSWORD=<管理员密码> \ // npm run check:game-distribution-media-e2e // E2E_API_BASE 可覆盖 api-server 地址(默认 http://127.0.0.1:12401)。 +// E2E_PACKAGE_ZIP 指向一个已经构建好的发行包(根目录含 index.html),例如真实 +// Phaser/Vite 工程 `game/dist/**` 打成的 ZIP;不传时使用脚本内置的最小 fixture。 +// E2E_GAME_TITLE 可覆盖游戏标题,便于在广场里认出这次验证。 // // 覆盖:真实素材直传 OSS → 创建游戏(素材归属校验)→ 创建版本(资料冻结)→ 送审 → // 作者回读 frozenMetadata → 待审期间匿名不可见/不可读 → 管理员审核通过 → 公开投影 // 暴露对象键且不泄露素材 ID → 匿名换签读封面与截图 → 发行网关可直接游玩。 +import { readFile } from 'node:fs/promises'; + import JSZip from 'jszip'; const API = process.env.E2E_API_BASE ?? 'http://127.0.0.1:12401'; @@ -139,7 +144,39 @@ function gameMetadata(overrides = {}) { }; } +const externalPackageZip = (process.env.E2E_PACKAGE_ZIP ?? '').trim(); +const gameTitleOverride = (process.env.E2E_GAME_TITLE ?? '').trim(); + +/** 返回待发布的发行包字节与条目数:优先使用调用方真实构建产物,否则用内置 fixture。 */ async function buildZip() { + if (externalPackageZip) { + const bytes = await readFile(externalPackageZip); + const archive = new JSZip(); + const parsed = await archive.loadAsync(bytes); + const entryNames = Object.keys(parsed.files).filter( + (name) => !parsed.files[name].dir, + ); + if (!entryNames.includes('index.html')) { + throw new Error( + `E2E_PACKAGE_ZIP 根目录缺少 index.html:${externalPackageZip}`, + ); + } + // 真实构建产物(Phaser/Vite 等)资源名带哈希:从包内派生一个资源路径做网关断言。 + const assetPath = + entryNames.find((name) => /^assets\/.+\.js$/u.test(name)) ?? + entryNames.find((name) => name.endsWith('.js')); + if (!assetPath) { + throw new Error( + `E2E_PACKAGE_ZIP 内没有可断言的 JS 资源:${externalPackageZip}`, + ); + } + return { + bytes: Buffer.from(bytes), + fileCount: entryNames.length, + assetPath, + entryMarker: null, + }; + } const zip = new JSZip(); zip.file( 'index.html', @@ -147,7 +184,12 @@ async function buildZip() { ); zip.file('assets/app.js', 'document.documentElement.dataset.e2e="media";'); const bytes = await zip.generateAsync({ type: 'uint8array' }); - return Buffer.from(bytes); + return { + bytes: Buffer.from(bytes), + fileCount: 2, + assetPath: 'assets/app.js', + entryMarker: 'E2E-MEDIA-OK', + }; } async function main() { @@ -230,7 +272,7 @@ async function main() { // 4. 创建游戏 + 版本(冻结资料) const metadata = gameMetadata({ - title: `分发媒体验证 ${id.slice(-6)}`, + title: gameTitleOverride || `分发媒体验证 ${id.slice(-6)}`, coverAssetId: cover.assetObjectId, screenshots: [shot1.assetObjectId, shot2.assetObjectId], }); @@ -284,7 +326,8 @@ async function main() { `status=${ghostVersion.status}`, ); - const zipBytes = await buildZip(); + const built = await buildZip(); + const zipBytes = built.bytes; const crypto = await import('node:crypto'); const sha256 = crypto.createHash('sha256').update(zipBytes).digest('hex'); const version = await api(`/api/game-distribution/games/${gameId}/versions`, { @@ -294,7 +337,7 @@ async function main() { body: { packageSha256: sha256, packageBytes: zipBytes.length, - packageFileCount: 2, + packageFileCount: built.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, @@ -423,7 +466,8 @@ async function main() { body: { decision: 'approve', expectedPublicationRevision: readback.data.version.publicationRevision, - entryUrl: `${API}`, + // 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。 + entryUrl: `${API}/api/game-distribution/releases/${gameId}/`, }, }, ); @@ -480,22 +524,27 @@ async function main() { `${API}/api/game-distribution/releases/${gameId}/index.html`, ); const releaseBody = await release.text(); + const entryOk = + release.status === 200 && + / 0, + `status=${releaseAsset.status} path=${built.assetPath} bytes=${assetBody.byteLength}`, ); console.log(`\n结果:${failures === 0 ? '全部通过' : `${failures} 项失败`}`); diff --git a/server-rs/crates/api-server/src/modules/game_distribution.rs b/server-rs/crates/api-server/src/modules/game_distribution.rs index 5ed4b8608..b6996d55a 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -224,10 +224,29 @@ pub fn router(state: AppState) -> Router { "/api/game-distribution/releases/{game_id}/{*asset_path}", get(serve_release_asset), ) + // 根路径等价于入口页:生产由发行来源(每游戏 origin)把 `/` 映射到 index.html, + // 本地直连网关或入口直接填网关地址时也必须能打开游戏。 + .route( + "/api/game-distribution/releases/{game_id}", + get(serve_release_entry), + ) + .route( + "/api/game-distribution/releases/{game_id}/", + get(serve_release_entry), + ) .merge(protected) .merge(admin) } +/// 发行网关根路径:等价于请求该游戏的 `index.html`。 +async fn serve_release_entry( + state: State, + headers: HeaderMap, + Path(game_id): Path, +) -> Result { + serve_release_asset(state, headers, Path((game_id, "index.html".to_string()))).await +} + /// 公开发行网关。 /// /// 只服务当前已公开版本的游戏文件,路径必须在白名单内容类型内;私有 ZIP 对象和 @@ -1902,6 +1921,7 @@ mod tests { // 未在白名单内的扩展名直接 404,不进入 SpacetimeDB 与对象存储。 let unknown_extension = app + .clone() .oneshot( Request::builder() .uri("/api/game-distribution/releases/game_1/payload.bin") @@ -1911,6 +1931,29 @@ mod tests { .await .expect("路由响应"); assert_eq!(unknown_extension.status(), StatusCode::NOT_FOUND); + + // 根路径(含尾斜杠)等价于入口页:生产由发行来源映射,直接连网关时也必须能开。 + for uri in [ + "/api/game-distribution/releases/game_1", + "/api/game-distribution/releases/game_1/", + ] { + let with_cookie = app + .clone() + .oneshot( + Request::builder() + .uri(uri) + .header("cookie", "genarrative.refresh-token=1") + .body(Body::empty()) + .expect("请求"), + ) + .await + .expect("路由响应"); + assert_eq!( + with_cookie.status(), + StatusCode::FORBIDDEN, + "{uri} 必须先过 Cookie 拒绝门,而不是 404" + ); + } } #[tokio::test]