diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index ac4bb8e80..dc407ae28 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -175,6 +175,107 @@ test('灰度发布页可选择模板库并默认启用零比例灰度', async () ); }); +test('灰度发布页可选择游戏发布开关,默认保持「未开启即开放」语义', async () => { + const user = userEvent.setup(); + render( + , + ); + + await screen.findByRole('button', { name: 'editor.new-toolbar' }); + await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [ + 'game-distribution', + ]); + + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'game-distribution:publish', + ); + expect( + (screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value, + ).toBe('publish'); + // 该开关的语义是「未配置/关闭 = 默认开放」,所以选中后不能默认打开收紧。 + expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( + false, + ); + expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe( + '0', + ); + expect( + (screen.getByLabelText('描述') as HTMLTextAreaElement).value, + ).toContain('游戏发布入口灰度'); +}); + +test('灰度发布页保存游戏发布开关时写入白名单与比例', async () => { + const user = userEvent.setup(); + vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({ + gates: [ + ...configResponse.gates, + { + gateKey: 'game-distribution:publish', + enabled: true, + rolloutPercent: 20, + allowUserIds: ['user-internal'], + allowUserTags: [], + denyUserIds: [], + description: '游戏发布入口灰度', + updatedAt: '2026-09-22T10:00:00Z', + }, + ], + }); + render( + , + ); + + await screen.findByRole('button', { name: 'editor.new-toolbar' }); + await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), [ + 'game-distribution', + ]); + fireEvent.click(screen.getByLabelText('启用')); + fireEvent.change(screen.getByLabelText('灰度比例'), { + target: { value: '20' }, + }); + fireEvent.change(screen.getByLabelText('允许用户 ID'), { + target: { value: 'user-internal' }, + }); + fireEvent.change(screen.getByLabelText('描述'), { + target: { value: '游戏发布入口灰度' }, + }); + await user.click(screen.getByRole('button', { name: '保存配置' })); + await user.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(upsertAdminFeatureGateConfig).toHaveBeenCalledWith('admin-token', { + gateKey: 'game-distribution:publish', + enabled: true, + rolloutPercent: 20, + allowUserIds: ['user-internal'], + allowUserTags: [], + denyUserIds: [], + description: '游戏发布入口灰度', + }), + ); +}); + +test('未创建的预设开关在后台可见并可一键配置', async () => { + const user = userEvent.setup(); + render( + , + ); + + const row = await screen.findByText('game-distribution:publish'); + expect(row).not.toBeNull(); + // 该开关默认未创建:列表里给出「配置」入口,点击后按默认关闭填充表单。 + const configureButton = row.closest('tr')?.querySelector('button'); + expect(configureButton).not.toBeNull(); + await user.click(configureButton!); + + expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe( + 'game-distribution:publish', + ); + expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe( + false, + ); +}); + test('灰度发布页保存时转换数组和百分比', async () => { const user = userEvent.setup(); vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({ diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 814f2ac34..9dd2a0fba 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -28,6 +28,7 @@ interface GateTargetOption { const GATE_PREFIX_LABELS: Record = { 'image-editor': '画布', agc: '客户端', + 'game-distribution': '游戏分发', }; const FIXED_GATE_TARGETS: GateTargetOption[] = [ @@ -45,6 +46,14 @@ const FIXED_GATE_TARGETS: GateTargetOption[] = [ label: 'Agent 侧边栏', description: '画布 Agent 入口灰度', }, + { + prefix: 'game-distribution', + suffix: 'publish', + key: 'game-distribution:publish', + label: '游戏发布', + description: + '游戏发布入口灰度:未配置或关闭时对已登录作者默认开放,开启后只放行白名单 / 灰度命中', + }, ]; export function AdminGrayReleaseConfigPage({ @@ -197,6 +206,11 @@ export function AdminGrayReleaseConfigPage({ setErrorMessage(''); } + // 预设里尚未创建行的开关也要可见:运营需要先看到 key 才能配置灰度。 + const unconfiguredGateTargets = FIXED_GATE_TARGETS.filter( + (option) => !gates.some((gate) => gate.gateKey === option.key), + ); + function buildPayload(): AdminUpsertFeatureGateConfigRequest { return { gateKey: gateKey.trim(), @@ -443,6 +457,53 @@ export function AdminGrayReleaseConfigPage({ )} + + + + 可配置开关 + {unconfiguredGateTargets.length} + + {unconfiguredGateTargets.length ? ( + + + + + Gate + 说明 + 操作 + + + + {unconfiguredGateTargets.map((option) => ( + + + {option.key} + + {GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '} + {option.label} + + + {option.description} + + applyGateTarget(option)} + > + 配置 + + + + ))} + + + + ) : ( + + {isLoading ? '加载中' : '预设开关都已创建'} + + )} + {confirmDialog} 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 05250a35f..b5c09bb9d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -6081,15 +6081,19 @@ pub(crate) fn checkpoint_with_capture_for_test( create_local_project_checkpoint_with_capture(project_path, capture) } +/// 为发布导出试玩包:项目还没有可玩入口时先跑项目自己的 `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 BuildAUTO-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/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 00f0cda8f..30427f1be 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1206,16 +1206,27 @@ export function App({ * 权限口径沿用本地命令:`project.export_package` 需要确认时先入队,确认后再导出; * 导出结果只留在壳里,发布面板关闭即丢弃,不写入项目。 */ + /** + * 发布相关提示同时写工作台状态与 DirectProject 对话。 + * + * 普通项目走 `DirectProjectChatView` 时并不渲染工作台状态行,只写 workspaceStatus + * 会让「点了发布没反应」;这里统一通过聊天容器的 announce 出口回话。 + */ + function announcePublishMessage(message: string) { + setWorkspaceStatus(message); + directProjectChatRef.current?.announce(message); + } + async function requestGamePublish() { const invoke = resolveTauriInvoke(); if (!invoke) { - setWorkspaceStatus('需要在 Tauri App 内发布'); + announcePublishMessage('需要在 Tauri App 内发布'); return; } const nextProjectPath = resolveChatProjectPath(localProject) ?? projectPath.trim(); if (!nextProjectPath) { - setWorkspaceStatus('先打开一个项目再发布'); + announcePublishMessage('先打开一个项目再发布'); return; } const runExport = async () => { @@ -1224,7 +1235,9 @@ export function App({ 'export_local_project_package', { projectPath: nextProjectPath }, ); - setWorkspaceStatus(`已导出本地试玩包:${result.packageRelativePath}`); + announcePublishMessage( + `已构建并打包试玩包:${result.packageRelativePath}`, + ); setPublishPackageResult(result); setPublishPanelOpen(true); appendLocalPermissionLog( @@ -1233,7 +1246,7 @@ export function App({ 'project.export_package', ); } catch (error) { - setWorkspaceStatus( + announcePublishMessage( error instanceof Error ? error.message : String(error), ); } diff --git a/deploy/container/README.md b/deploy/container/README.md index 0f992bafa..801cbe671 100644 --- a/deploy/container/README.md +++ b/deploy/container/README.md @@ -112,6 +112,10 @@ runner 配置保留原 `ubuntu-latest` 映射,`genarrative-ci` 继续映射到 ### Rust 测试组编译对象快照 +宿主下载每份 artifact 时核对 Gitea `size_in_bytes`、响应 `Content-Length`(若提供)和实际字节数,并在下载完成后立即检查 ZIP 格式与 CRC。截断、损坏归档或临时传输故障最多尝试 3 次,每次重新获取签名下载地址;不向签名地址转发 API Token。重试仍失败则删除临时下载并保留现役镜像,不能把 EOF 当作完整下载成功。 + +同一 sccache key 的完整对象 SHA 不同时,不直接视为编译结果不同:sccache 对象 ZIP 的成员写入顺序可能不同。仅对不同 job 新增对象之间的冲突,按成员名核对内容 SHA-256、权限及 ZIP 元数据,全部一致才保留一份原始对象并更新使用时间;真实内容差异、异常 ZIP 和继承对象冲突仍拒绝。此比较不改写缓存 key 或对象,不依赖 CRC 代替内容校验。 + 自动维护由宿主 systemd timer 调用 `scripts/maintain-gitea-rust-cache.py`,只管理 Gitea CI 测试镜像,不修改 Jenkins、生产发布、本地开发或客户端发行构建。六个 Rust job 仅在 master push 中导出本次 CI 新增的 sccache 对象;已命中的继承对象只上传新近使用时间,通过 Gitea 原生 V4 artifact 接口上传;PR 不发布。维护器选择已结束且六组产物完整的最新 master run,校验提交、任务尝试、工具链与来源镜像,与六组实际使用的同一镜像快照合并去重,并按新近使用时间限制快照总容量为 4 GiB,然后从无对象缓存基础镜像组装新镜像,**不重复执行 Cargo 预热编译,也不要求源 run 事先全绿**。缺组、取消或校验失败时保留现役版,不混合不同 run 的对象来假装完整快照。 维护 journal 分阶段记录来源 run、基础镜像重建或复用、artifact 下载、对象合并、镜像组装校验、导出、载入及空闲等待;长操作记录开始和结束耗时,失败输出对应私有 `artifacts//build.log` 路径。构建的详细下载与 Docker 输出仍只写该日志,不回显 Token、命令环境或认证配置。 diff --git a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index c5b4f61eb..0620f55c5 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -116,6 +116,11 @@ - 发布入口灰度下发:`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 拒绝门与根路径。 + +- 发布灰度改为**默认关闭**并修掉客户端“看得到点不动”:`is_game_distribution_publish_enabled_for_user` 现在要求 gate 行存在且 `enabled=true`(未登录、无行、`enabled=false` 一律 false),因此没配灰度时 `gameDistributionPublishEnabled=false`,AGC 不再渲染「发布到游戏广场」按钮、网页入口也不出现;AGC 侧新增 `announcePublishMessage`,把「已构建并打包试玩包」「先打开一个项目再发布」等提示通过 DirectProject 聊天容器的 `announce` 出口回话(普通项目不渲染工作台状态行,之前只写 workspaceStatus 才会表现为点击无反应)。后台「灰度发布配置」新增「可配置开关」列表:预设开关在未创建行时也可见并可一键配置(不再需要先猜 gate key)。 + ## 尚未完成 - 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index bb3a362a7..cabe36649 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -96,6 +96,8 @@ SpacetimeDB 任务统一先读取 `.codex/skills/genarrative-spacetimedb/SKILL.m ## Gitea CI 依赖闭合 +缓存维护下载必须核对 artifact 元数据大小与实际响应,并立即检查 ZIP 完整性;有长度上限不等于能检测短读。网络或归档损坏最多重试 3 次且重新取签名链接。sccache 同 key 的不同 ZIP 成员排列会改变整个对象 SHA;新增对象去重仅在成员内容 SHA、权限和 ZIP 元数据均一致时接受排列差异,真实内容及继承对象冲突仍拒绝。禁止通过任取一份冲突对象绕过完整性契约。 + Buildx 0.30.1 的 `inspect` 不支持 `--format`,builder 驱动校验读取普通输出的 `Driver:` 字段。相关命令须在宿主真实插件上验证;测试替身应拒绝不支持的参数,避免把模拟命令成功误当兼容性证据。 Gitea 基础镜像通过专用 `genarrative-ci-images` Buildx builder 持久复用 Cargo/npm 下载缓存;稳定 cache mount 与 commit、lock 哈希无关,以 `sharing=locked` 隔离并发写入,仅供可信宿主构建、不开放给 PR。最终镜像显式物化当前依赖下载快照,仍不包含 node_modules/target 或上一版 sccache 层。首次可用 `seed-downloads` 从可信完整 Image ID 提取包缓存,操作账号须与维护服务一致;部署要求及 builder GC 空间目标见 `deploy/container/README.md`。构建上下文必须覆盖 AGC vendor 与编辑器 bridge 的全部本地 path manifest,普通源码变化不应使依赖层失效。维护 journal 提供阶段耗时和失败 build.log 定位。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 89d2cdc55..adb2590e5 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -541,7 +541,7 @@ Responses 的终态载荷既是工具调用的恢复源,也是正文的恢复 - Rust 结构体:`GameDistributionGame` - 源码:`server-rs/crates/spacetime-module/src/game_distribution.rs` - 用途:游戏分发稳定身份与公开版本指针。保存 owner、标题/简介/分类资料、设备与输入声明、`publication_revision`、当前 `active_version_id`、可见性和游玩计数;标签与输入模式按版本化 JSON 保存,展示资料由 `api-server` 通过 `spacetime-client` 归一后返回。 -- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:未配置或 `enabled=false` 默认开放,显式收紧后写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。 +- 公开素材:游戏行末尾追加可空 `cover_object_key` 与 `screenshots_json`(截图 `{assetId, objectKey}` 数组);创建游戏时 `api-server` 就复核封面/截图素材存在且属于当前作者(不存在 400、他人素材 403),创建版本时按同一口径再次复核并派生对象键。 发布写入受灰度配置键 `game-distribution:publish` 约束:**灰度默认关闭**,未配置或 `enabled=false` 时写入口(创建游戏/版本、确认包、送审、审核通过激活)返回 503 `GAME_DISTRIBUTION_PUBLISH_DISABLED`,`enabled=true` 且白名单/比例/标签命中才放行,读取与安全下架保持可用;同一判据在 `GET /api/runtime/frontend-config` 以 `gameDistributionPublishEnabled` 下发给前端入口,匿名恒为 `false`。只有可见性为 `published` 且存在有效 `active_version_id` 的游戏,其封面/截图素材才在 `/api/assets/read-url` 上获得匿名读授权。 - 复用规则:末尾可空列 `local_project_id` 保存发布方本地项目标识(AGC 的 `manifest.projectId`)。同一 `owner_user_id` 再次以相同 `local_project_id` 创建游戏时复用既有 `game_id` 并只新增版本,避免“更新”被实现成新建游戏;该字段只是复用提示,不构成所有权或路径凭证,也不能用于跨账号匹配。 - 索引:`by_game_distribution_game_owner_user_id` 用于作者私有游戏列表;`game_id` 为主键。公开目录只返回 `visibility = published` 且存在有效 `active_version_id` 的投影。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 76cc81a16..767ad823b 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -678,8 +678,8 @@ journalctl -u genarrative-api -o cat | grep 'operation="release_rejected"' 发布事故或回滚窗口里用 `game-distribution:publish` 灰度开关控制写入,不需要改代码或重启: -- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`。 -- 语义:没有该 gate 行或 `enabled=false` 表示**默认开放**;`enabled=true` 时只有 `allowUserIds` / `allowUserTags` / `rolloutPercent` 命中的作者能发布,`rolloutPercent=0` 且无白名单即**全部关闭**(等价紧急关闭投稿)。 +- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`;后台「可配置开关」里固定列出「游戏分发 · 游戏发布」,点「配置」即按默认关闭填表(`enabled=false`),再填白名单 / 灰度比例并开启保存。灰度默认关闭:该键未创建或 `enabled=false` 时作者看不到发布入口、写入口返回 503。 +- 语义:没有该 gate 行或 `enabled=false` 表示**未开放**(灰度默认关闭);`enabled=true` 时只有 `allowUserIds` / `allowUserTags` / `rolloutPercent` 命中的作者能发布,`rolloutPercent=0` 且无白名单同样全关(等价紧急关闭投稿)。开放灰度就是把 `enabled` 打开并放白名单或提高比例。 - 关闭范围:创建游戏、创建版本、上传包、送审、撤回、作者下架,以及管理员**批准**(新版本激活)都返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`。 - 始终可用:目录、详情、版本回读、发行网关(已公开游戏继续游玩)、`/my-games`、审核队列读取、**拒绝审核**与管理员**安全下架**。 - 失败姿态:开关状态读取失败时按关闭处理,避免绕过运营刚下的收紧动作;本地排障时确认 SpacetimeDB 正常后再判断业务是否被误伤。 diff --git a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md index 3272fd5ca..4c4858c85 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` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `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 | **已实现**:创建不可变待上传版本,冻结包摘要/字节数/文件数与资料 | @@ -151,7 +151,7 @@ - 建议 HTML、公开状态与启动 API 使用 `no-store`;发行静态资源的浏览器与 CDN 有效期均不超过 60 秒,禁止 `stale-while-revalidate`、`stale-if-error` 和发行 Service Worker。下架主动 purge 相关 CDN 键,60 秒作为最大缓存撤销窗口,不把 purge 成功当唯一保障。旧版本被更新替代后,新启动只用当前版;旧游戏已经载入的脚本/资源不承诺远程抹除,用户退出或刷新后按当前授权重新判断。 - 发布所需部署依赖包括独立站点域名及通配 TLS、每游戏 host 路由、私有存储、网关 CSP/CORS/MIME、CDN TTL/purge、管理员审核运营入口和可恢复校验执行器;缺少任一项不能宣布公开上线。 - 观察上传失败、校验耗时、审核积压、发行 4xx/5xx、撤销传播时间与容量,日志按游戏/版本/操作 ID 关联,不记录 Token、完整用户文件内容或 signed URL。原始失败/撤回包建议保留 7 天后清理,公开版本和审核记录的保留周期在上线前确定;清理必须先检查引用,不能删除仍在服务的版本。 -- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。现役实现:`game-distribution:publish` 灰度开关(后台「灰度发布配置」)控制作者写入与新版本激活——没有 gate 行或 `enabled=false` 时默认开放;`enabled=true` 时只有白名单/灰度命中的用户能发布(`rolloutPercent=0` 且无白名单即全部关闭)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。 +- 回滚部署时关闭新提交和新版本激活,保留当前可玩版本与状态读取;数据库迁移不以删表回滚。安全事件通过服务端关闭游戏发行权限,不依赖前端隐藏按钮。现役实现:`game-distribution:publish` 灰度开关(后台「灰度发布配置」)控制作者写入与新版本激活——灰度默认关闭,没有 gate 行或 `enabled=false` 时不允许发布;`enabled=true` 时只有白名单/灰度命中的用户能发布(`rolloutPercent=0` 且无白名单即仍然全关)。关闭期间目录、详情、版本回读、发行网关、审核队列读取、拒绝审核与安全下架都不受影响;开关读取失败按关闭处理。 ### 验收标准与证据 diff --git a/scripts/check-game-distribution-media-e2e.mjs b/scripts/check-game-distribution-media-e2e.mjs index 90599514b..fb6913ae7 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() { @@ -172,6 +214,78 @@ async function main() { }); const another = otherEntry.data.token; + // 1.1 管理员登录:发布灰度默认关闭,脚本先验证关闭态再为本轮验证开启。 + const adminLogin = await api('/admin/api/login', { + method: 'POST', + body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, + }); + check( + '管理员登录成功', + adminLogin.status === 200 && + Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken), + `status=${adminLogin.status}`, + ); + const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; + + const setPublishGate = (enabled, rolloutPercent) => + api('/admin/api/feature-gates', { + method: 'PUT', + token: admin, + body: { + gateKey: 'game-distribution:publish', + enabled, + rolloutPercent, + allowUserIds: [], + allowUserTags: [], + denyUserIds: [], + description: 'E2E 发布灰度', + }, + }); + + const gateClosed = await setPublishGate(false, 0); + check( + '发布灰度可配置为关闭', + gateClosed.status === 200, + `status=${gateClosed.status}`, + ); + + const closedAvailability = await api('/api/runtime/frontend-config', { + token: author, + }); + check( + '灰度关闭时作者拿不到发布入口', + closedAvailability.data?.gameDistributionPublishEnabled === false, + `value=${closedAvailability.data?.gameDistributionPublishEnabled}`, + ); + + const closedPublish = await api('/api/game-distribution/games', { + method: 'POST', + token: author, + headers: { 'Idempotency-Key': `e2e-gate-closed-${Date.now()}` }, + body: gameMetadata({ title: `灰度关闭验证 ${Date.now()}` }), + }); + check( + '灰度关闭时写入口 503', + closedPublish.status === 503, + `status=${closedPublish.status} code=${closedPublish.error?.code ?? ''}`, + ); + + const gateOpen = await setPublishGate(true, 100); + check( + '发布灰度可开启并放量', + gateOpen.status === 200, + `status=${gateOpen.status}`, + ); + + const openAvailability = await api('/api/runtime/frontend-config', { + token: author, + }); + check( + '灰度开启后作者拿到发布入口', + openAvailability.data?.gameDistributionPublishEnabled === true, + `value=${openAvailability.data?.gameDistributionPublishEnabled}`, + ); + // 2. 真实素材直传 const id = stamp(); const cover = await uploadImage(author, 'cover', id); @@ -230,7 +344,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 +398,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 +409,7 @@ async function main() { body: { packageSha256: sha256, packageBytes: zipBytes.length, - packageFileCount: 2, + packageFileCount: built.fileCount, packageEntryPath: 'index.html', gameMetadata: metadata, }, @@ -401,19 +516,7 @@ async function main() { `status=${readBefore.status}`, ); - // 7. 管理员审核通过(本地非生产允许回环 http 入口) - const adminLogin = await api('/admin/api/login', { - method: 'POST', - body: { username: ADMIN_USER, password: ADMIN_PASSWORD }, - }); - check( - '管理员登录成功', - adminLogin.status === 200 && - Boolean(adminLogin.data?.token ?? adminLogin.data?.accessToken), - `status=${adminLogin.status}`, - ); - const admin = adminLogin.data?.token ?? adminLogin.data?.accessToken; - + // 7. 管理员审核通过(本地非生产允许回环 http 入口;管理员 token 在步骤 1.1 已取得) const approved = await api( `/admin/api/game-distribution/versions/${versionId}/review`, { @@ -423,7 +526,8 @@ async function main() { body: { decision: 'approve', expectedPublicationRevision: readback.data.version.publicationRevision, - entryUrl: `${API}`, + // 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。 + entryUrl: `${API}/api/game-distribution/releases/${gameId}/`, }, }, ); @@ -480,22 +584,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/scripts/gitea_cache_snapshot.py b/scripts/gitea_cache_snapshot.py index 46e117e15..f270ea1b8 100644 --- a/scripts/gitea_cache_snapshot.py +++ b/scripts/gitea_cache_snapshot.py @@ -167,6 +167,14 @@ def _snapshot_member(archive: Path) -> tuple[zipfile.ZipFile, zipfile.ZipInfo]: raise +def validate_artifact_zip(archive: Path) -> None: + """Validate the bounded stored ZIP envelope before consuming an artifact.""" + bundle, _ = _snapshot_member(archive) + with bundle: + if bundle.testzip() is not None: + raise SnapshotError("artifact ZIP CRC check failed") + + def _tar_stream(archive: Path): bundle, member = _snapshot_member(archive) try: @@ -440,6 +448,94 @@ def _file_sha256(path: Path) -> str: digest.update(chunk) +def _object_zip_signature(stream: BinaryIO, expected: _Object) -> tuple: + """只允许 sccache 对象的 ZIP 成员排列不同,不放宽内容或元数据校验。""" + with tempfile.TemporaryFile() as temporary: + digest = hashlib.sha256() + total = 0 + while chunk := stream.read(1024 * 1024): + total += len(chunk) + if total > expected.size: + raise SnapshotError("cache object grew while comparing ZIP contents") + digest.update(chunk) + temporary.write(chunk) + if (total, digest.hexdigest()) != (expected.size, expected.sha256): + raise SnapshotError("cache object checksum changed while comparing ZIP contents") + temporary.seek(0) + with zipfile.ZipFile(temporary) as bundle: + infos = bundle.infolist() + if (not infos or len(infos) > MAX_OBJECTS + or len({info.filename for info in infos}) != len(infos) + or sum(info.file_size for info in infos) > expected.size): + raise SnapshotError("invalid sccache ZIP member set") + members = [] + for info in sorted(infos, key=lambda entry: entry.filename): + _safe_zip_path(info.filename) + mode = info.external_attr >> 16 + if (info.is_dir() or info.flag_bits & 1 or stat.S_ISLNK(mode) + or stat.S_IFMT(mode) not in (0, stat.S_IFREG) + or info.compress_type != zipfile.ZIP_STORED): + raise SnapshotError("unsupported sccache ZIP member") + with bundle.open(info) as contents: + size, checksum = _hash_stream(contents, info.file_size) + members.append(( + info.filename, size, checksum, info.CRC, info.compress_size, + info.compress_type, info.date_time, info.flag_bits, + info.external_attr, info.internal_attr, info.create_system, + info.create_version, info.extract_version, info.reserved, + info.extra, info.comment, + )) + return bundle.comment, tuple(members) + + +def _equivalent_delta_paths(archives: Sequence[_Archive]) -> set[str]: + variants: dict[str, set[tuple[int, str | None]]] = {} + for archive in archives: + for obj in archive.objects: + variants.setdefault(obj.path, set()).add((obj.size, obj.sha256)) + conflicts = {path for path, versions in variants.items() if len(versions) > 1} + if not conflicts: + return conflicts + for path in conflicts: + if len({size for size, _ in variants[path]}) != 1: + raise SnapshotError(f"conflicting content for duplicate object: {path}") + signatures = {} + # 每份归档最多额外顺序读取一次,仅将冲突对象暂存到磁盘供 ZIP 随机读取。 + for archive in archives: + wanted = {obj.path: obj for obj in archive.objects if obj.path in conflicts} + if not wanted: + continue + if _archive_signature(archive.input.archive) != archive.signature: + raise SnapshotError("artifact archive changed while comparing cache objects") + bundle, raw, tar = _tar_stream(archive.input.archive) + try: + for member in tar: + expected = wanted.get(member.name) + if expected is None: + continue + if not member.isreg() or member.size != expected.size: + raise SnapshotError("cache object changed while comparing ZIP contents") + stream = tar.extractfile(member) + if stream is None: + raise SnapshotError("cache object is missing while comparing ZIP contents") + try: + with stream: + signature = _object_zip_signature(stream, expected) + except (SnapshotError, zipfile.BadZipFile, NotImplementedError) as error: + raise SnapshotError(f"conflicting content for duplicate object: {member.name}") from error + if member.name in signatures and signatures[member.name] != signature: + raise SnapshotError(f"conflicting content for duplicate object: {member.name}") + signatures[member.name] = signature + del wanted[member.name] + if wanted: + raise SnapshotError("cache objects disappeared while comparing ZIP contents") + finally: + tar.close() + raw.close() + bundle.close() + return conflicts + + def _select_objects( archives: Sequence[_Archive], base_root: Path, @@ -447,6 +543,7 @@ def _select_objects( maximum: int, ) -> tuple[_Object, ...]: merged = dict(base) + equivalent_paths = _equivalent_delta_paths(archives) delta_paths = {obj.path for archive in archives for obj in archive.objects} touched_paths = {touch.path for archive in archives for touch in archive.touched} overlap = delta_paths & touched_paths @@ -490,7 +587,8 @@ def _select_objects( mtime_ns=max(current.mtime_ns, candidate.mtime_ns), source_index=None, ) - elif (current.size, current.sha256) != (candidate.size, candidate.sha256): + elif ((current.size, current.sha256) != (candidate.size, candidate.sha256) + and candidate.path not in equivalent_paths): raise SnapshotError(f"conflicting content for duplicate object: {candidate.path}") elif candidate.mtime_ns > current.mtime_ns: merged[candidate.path] = _Object( diff --git a/scripts/maintain-gitea-rust-cache.py b/scripts/maintain-gitea-rust-cache.py index 1219fc3a1..fc948355d 100644 --- a/scripts/maintain-gitea-rust-cache.py +++ b/scripts/maintain-gitea-rust-cache.py @@ -5,6 +5,7 @@ import argparse import contextlib import datetime import hashlib +import http.client import json import os from pathlib import Path @@ -18,8 +19,11 @@ import time import urllib.error import urllib.parse import urllib.request +import zipfile -from gitea_cache_snapshot import ArtifactIdentity, ArtifactInput, merge_snapshots +from gitea_cache_snapshot import ( + ArtifactIdentity, ArtifactInput, SnapshotError, merge_snapshots, validate_artifact_zip, +) from gitea_cache_upload_cleanup import cleanup_upload_chunks @@ -46,6 +50,7 @@ RUST_JOBS = set(RUST_JOB_IDS) ARTIFACT_PREFIX = "rust-cache-v1-" MAX_DOWNLOAD = 4 * 1024 ** 3 + 129 * 1024 ** 2 EXPORT_STEP = "Publish master Rust cache artifact" +DOWNLOAD_ATTEMPTS = 3 def now(): @@ -183,35 +188,90 @@ class Api: raise RuntimeError(f"Gitea API HTTP {error.code}") from None return content if raw else (json.loads(content) if content else None) - def download(self, path, destination): - """REST V4 archive redirects to a signed URL; never forward the API token.""" + def download(self, path, destination, expected_size): + """Fetch one artifact archive, retrying incomplete signed-URL transfers.""" + if (isinstance(expected_size, bool) or not isinstance(expected_size, int) + or expected_size <= 0): + raise RuntimeError("artifact has invalid size metadata") + if expected_size > MAX_DOWNLOAD: + raise RuntimeError("artifact exceeds per-job size limit") + + for attempt in range(DOWNLOAD_ATTEMPTS): + try: + self._download_once(path, destination, expected_size) + return + except RetryableArtifactDownload as error: + destination.unlink(missing_ok=True) + log(f"artifact download attempt {attempt + 1}/{DOWNLOAD_ATTEMPTS} failed: {error}") + if attempt + 1 == DOWNLOAD_ATTEMPTS: + raise RuntimeError("artifact download remained incomplete after retries") from error + time.sleep(1 << attempt) + except Exception: + destination.unlink(missing_ok=True) + raise + + def _download_once(self, path, destination, expected_size): + """Obtain a fresh signed URL and validate its complete ZIP response.""" token = self.token_file.read_text().strip() url = self.url + "/" + path.lstrip("/") opener = urllib.request.build_opener(NoRedirect()) request = urllib.request.Request(url, headers={"Authorization": "token " + token}) + target = None try: - response = opener.open(request, timeout=60) - except urllib.error.HTTPError as error: - error.close() - if error.code not in (301, 302, 303, 307, 308): - raise RuntimeError(f"artifact download HTTP {error.code}") from None - target = urllib.parse.urljoin(url, error.headers.get("Location", "")) - parsed, origin = urllib.parse.urlsplit(target), urllib.parse.urlsplit(self.url) - if (parsed.scheme != "https" or parsed.netloc != origin.netloc - or parsed.username or parsed.password or target == url): - raise RuntimeError("artifact redirect must stay on configured HTTPS Gitea origin") from None - response = opener.open(target, timeout=60) - try: - with response, destination.open("wb") as out: - total = 0 - while chunk := response.read(1024 * 1024): - total += len(chunk) - if total > MAX_DOWNLOAD: + try: + response = opener.open(request, timeout=60) + except urllib.error.HTTPError as error: + error.close() + if error.code not in (301, 302, 303, 307, 308): + if error.code >= 500: + raise RetryableArtifactDownload(f"artifact download HTTP {error.code}") from None + raise RuntimeError(f"artifact download HTTP {error.code}") from None + target = urllib.parse.urljoin(url, error.headers.get("Location", "")) + parsed, origin = urllib.parse.urlsplit(target), urllib.parse.urlsplit(self.url) + if (parsed.scheme != "https" or parsed.netloc != origin.netloc + or parsed.username or parsed.password or target == url): + raise RuntimeError("artifact redirect must stay on configured HTTPS Gitea origin") from None + if target is not None: + try: + response = opener.open(target, timeout=60) + except urllib.error.HTTPError as error: + error.close() + if error.code >= 500: + raise RetryableArtifactDownload(f"artifact download HTTP {error.code}") from None + raise RuntimeError(f"artifact download HTTP {error.code}") from None + with response: + content_length = getattr(response, "headers", {}).get("Content-Length") + if content_length is not None: + try: + content_length = int(content_length) + except (TypeError, ValueError): + raise RetryableArtifactDownload("artifact response has invalid Content-Length") from None + if content_length > MAX_DOWNLOAD: raise RuntimeError("artifact exceeds per-job size limit") - out.write(chunk) - except Exception: - destination.unlink(missing_ok=True) - raise + if content_length != expected_size: + raise RetryableArtifactDownload( + f"artifact response size differs: expected {expected_size} bytes, " + f"Content-Length is {content_length}") + with destination.open("wb") as out: + total = 0 + while chunk := response.read(1024 * 1024): + total += len(chunk) + if total > MAX_DOWNLOAD: + raise RuntimeError("artifact exceeds per-job size limit") + if total > expected_size: + raise RetryableArtifactDownload( + f"artifact response exceeds metadata: expected {expected_size} bytes, " + f"received at least {total}") + out.write(chunk) + if total != expected_size: + raise RetryableArtifactDownload( + f"artifact response is truncated: expected {expected_size} bytes, received {total}") + try: + validate_artifact_zip(destination) + except (SnapshotError, zipfile.BadZipFile): + raise RetryableArtifactDownload("artifact response is not a valid ZIP") from None + except (http.client.HTTPException, urllib.error.URLError, OSError) as error: + raise RetryableArtifactDownload("artifact transfer failed") from error def pages(self, path, key): separator = "&" if "?" in path else "?" @@ -230,6 +290,10 @@ class NoRedirect(urllib.request.HTTPRedirectHandler): return None +class RetryableArtifactDownload(RuntimeError): + """A signed archive transfer may be retried with a newly issued URL.""" + + class Maintenance: def __init__(self, config): self.config = config @@ -371,7 +435,10 @@ class Maintenance: if len(used) != 1: break images.update(used) - selected.append({"id": matches[0]["id"], "name": name, + size = matches[0].get("size_in_bytes") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0 or size > MAX_DOWNLOAD: + break + selected.append({"id": matches[0]["id"], "name": name, "size_in_bytes": size, "job": RUST_JOB_IDS[job["name"]], "attempt": job["run_attempt"]}) if len(selected) != len(RUST_JOB_IDS) or len(images) != 1: continue @@ -431,7 +498,8 @@ class Maintenance: archive = work / (str(export["id"]) + ".zip") with operation(f"download cache artifact job={export['job']} attempt={export['attempt']}", build_log=build_log): - self.api.download(self.repo_api + f'/actions/artifacts/{export["id"]}/zip', archive) + self.api.download(self.repo_api + f'/actions/artifacts/{export["id"]}/zip', archive, + export["size_in_bytes"]) inputs.append(ArtifactInput(archive, ArtifactIdentity( self.config["repository"], source["run_id"], export["attempt"], export["job"], sha))) snapshot = work / "snapshot" diff --git a/scripts/test_gitea_cache_maintenance.py b/scripts/test_gitea_cache_maintenance.py index 730e44049..ed7c48b3e 100644 --- a/scripts/test_gitea_cache_maintenance.py +++ b/scripts/test_gitea_cache_maintenance.py @@ -11,6 +11,7 @@ import re import tempfile import unittest from unittest.mock import patch +import zipfile SCRIPT = Path(__file__).with_name("maintain-gitea-rust-cache.py") @@ -29,6 +30,26 @@ def runner_config(image: str) -> str: return f'runners:\n - "genarrative-ci:docker://{image}"\n' +def zip_bytes(*, size=None) -> bytes: + """Make a valid stored ZIP, optionally padded to an exact download size.""" + content = b"x" * 40000 if size else b"cache artifact" + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_STORED) as archive: + archive.writestr("snapshot.tar", content) + value = output.getvalue() + if size is None: + return value + if not len(value) < size <= len(value) + 65535: + raise AssertionError("requested ZIP size cannot be represented by its comment") + output = io.BytesIO(value) + with zipfile.ZipFile(output, "a") as archive: + archive.comment = b"p" * (size - len(value)) + value = output.getvalue() + if len(value) != size: + raise AssertionError("ZIP comment did not produce the requested size") + return value + + class FakeApi: def __init__(self): self.requests: list[str] = [] @@ -299,7 +320,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase): "steps": [{"name": maintenance_module.EXPORT_STEP, "conclusion": "success"}]} for i, name in enumerate(maintenance_module.RUST_JOB_IDS, start=1)] artifacts = [{"id": job["id"], "name": "rust-cache-v1-" + maintenance_module.RUST_JOB_IDS[job["name"]] - + "-attempt-1", "expired": False, "workflow_run": run} for job in jobs] + + "-attempt-1", "size_in_bytes": 10, "expired": False, "workflow_run": run} for job in jobs] class Api: def pages(self, path, key): return iter({"workflow_runs": [run], "jobs": jobs, "artifacts": artifacts}[key]) @@ -320,6 +341,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase): result = self.select_source(instance) self.assertEqual(result["run_id"], 44) self.assertEqual(len(result["exports"]), 6) + self.assertEqual({item["size_in_bytes"] for item in result["exports"]}, {10}) run["event"] = "pull_request" self.assertIsNone(self.select_source(instance)) run["event"] = "push" @@ -375,7 +397,7 @@ class GiteaCacheMaintenanceTest(unittest.TestCase): return "rustc test\n" return "" instance.docker = docker - instance.api.download = lambda path, destination: destination.write_bytes(b"download") + instance.api.download = lambda path, destination, expected_size: destination.write_bytes(b"download") def merge(inputs, output, **kwargs): self.assertEqual(len(inputs), 6) self.assertEqual(kwargs["expected_inherited_source_sha"], "b" * 40) @@ -419,23 +441,111 @@ class GiteaCacheMaintenanceTest(unittest.TestCase): def test_signed_download_drops_token_and_rejects_other_origins(self): api = maintenance_module.Api(self.config["api_url"], self.token) destination = self.root / "artifact.zip" + archive = zip_bytes() seen = [] target = ["https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test"] + + class Response(io.BytesIO): + def __init__(self, value): + super().__init__(value) + self.headers = {"Content-Length": str(len(value))} + class Opener: def open(self, request, timeout): seen.append(request) if not isinstance(request, str): raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target[0]}, None) - return io.BytesIO(b"archive") + return Response(archive) + with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()): - api.download("repos/team/project/actions/artifacts/1/zip", destination) - self.assertEqual(destination.read_bytes(), b"archive") + api.download("repos/team/project/actions/artifacts/1/zip", destination, len(archive)) + self.assertEqual(destination.read_bytes(), archive) self.assertEqual(seen[0].get_header("Authorization"), "token test-token") self.assertIsInstance(seen[1], str) # signed URL request has no Authorization header target[0] = "https://other.example.test/download" with self.assertRaisesRegex(RuntimeError, "configured HTTPS Gitea origin"): - api.download("repos/team/project/actions/artifacts/1/zip", self.root / "rejected.zip") + api.download("repos/team/project/actions/artifacts/1/zip", self.root / "rejected.zip", len(archive)) self.assertFalse((self.root / "rejected.zip").exists()) + self.assertEqual(len(seen), 3) # invalid redirect is deterministic and is not retried + + def test_download_retries_transport_and_truncated_response_with_fresh_signed_urls(self): + api = maintenance_module.Api(self.config["api_url"], self.token) + destination = self.root / "artifact.zip" + archive = zip_bytes(size=100000) + seen, signed_responses = [], [] + origin_requests = 0 + target = "https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test" + + class Response(io.BytesIO): + def __init__(self, value): + super().__init__(value) + self.headers = {"Content-Length": "100000"} + + class Opener: + def open(self, request, timeout): + nonlocal origin_requests + seen.append(request) + if not isinstance(request, str): + origin_requests += 1 + if origin_requests == 1: + raise maintenance_module.urllib.error.URLError("connection reset") + raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target}, None) + signed_responses.append(request) + return Response(b"x" * 512 if len(signed_responses) == 1 else archive) + + with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()), \ + patch.object(maintenance_module.time, "sleep") as sleep: + api.download("repos/team/project/actions/artifacts/1/zip", destination, 100000) + self.assertEqual(destination.read_bytes(), archive) + self.assertEqual(len(signed_responses), 2) + self.assertEqual(origin_requests, 3) + self.assertEqual([item.args for item in sleep.call_args_list], [(1,), (2,)]) + + def test_download_rejects_malformed_zip_after_retries(self): + api = maintenance_module.Api(self.config["api_url"], self.token) + destination = self.root / "artifact.zip" + seen = [] + target = "https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test" + + class Response(io.BytesIO): + headers = {"Content-Length": "9"} + + class Opener: + def open(self, request, timeout): + seen.append(request) + if not isinstance(request, str): + raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target}, None) + return Response(b"not a zip") + + with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()), \ + patch.object(maintenance_module.time, "sleep") as sleep: + with self.assertRaisesRegex(RuntimeError, "incomplete after retries"): + api.download("repos/team/project/actions/artifacts/1/zip", destination, 9) + self.assertFalse(destination.exists()) + self.assertEqual(sum(not isinstance(item, str) for item in seen), 3) + self.assertEqual([item.args for item in sleep.call_args_list], [(1,), (2,)]) + + def test_download_rejects_valid_zip_when_its_size_differs_from_metadata(self): + api = maintenance_module.Api(self.config["api_url"], self.token) + destination = self.root / "artifact.zip" + archive = zip_bytes() + seen = [] + target = "https://gitea.example.test/api/v1/repos/team/project/actions/artifacts/1/zip/raw?sig=test" + + class Opener: + def open(self, request, timeout): + seen.append(request) + if not isinstance(request, str): + raise maintenance_module.urllib.error.HTTPError(request.full_url, 302, "Found", {"Location": target}, None) + return io.BytesIO(archive) + + with patch.object(maintenance_module.urllib.request, "build_opener", return_value=Opener()), \ + patch.object(maintenance_module.time, "sleep") as sleep: + with self.assertRaisesRegex(RuntimeError, "incomplete after retries"): + api.download("repos/team/project/actions/artifacts/1/zip", destination, len(archive) + 1) + self.assertFalse(destination.exists()) + self.assertEqual(sum(not isinstance(item, str) for item in seen), 3) + self.assertEqual([item.args for item in sleep.call_args_list], [(1,), (2,)]) def test_pending_chunk_cleanup_requires_old_terminal_master_run(self): instance = self.maintenance({"versions": [], "current": None, "attempts": [{"run_id": 4}]}) diff --git a/scripts/test_gitea_cache_snapshot.py b/scripts/test_gitea_cache_snapshot.py index 135cd903b..e94bf2742 100644 --- a/scripts/test_gitea_cache_snapshot.py +++ b/scripts/test_gitea_cache_snapshot.py @@ -37,6 +37,16 @@ def object_path(character: str) -> str: return f"objects/{key[0]}/{key[1]}/{key}" +def cache_object(entries, *, mode=0o100644) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED) as bundle: + for name, contents in entries: + info = zipfile.ZipInfo(name) + info.external_attr = mode << 16 + bundle.writestr(info, contents) + return output.getvalue() + + class SnapshotMergeTest(unittest.TestCase): def setUp(self) -> None: self.temporary_directory = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) @@ -207,6 +217,42 @@ class SnapshotMergeTest(unittest.TestCase): with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"): self.merge(inputs) + def test_deduplicates_cache_zip_member_order_preserving_payload_and_newest_touch(self) -> None: + path = object_path("a") + entries = [("lib.rlib", b"compiled"), ("lib.rmeta", b"metadata"), ("stderr", b"warning")] + first = cache_object(entries) + second = cache_object(list(reversed(entries))) + self.assertEqual(len(first), len(second)) + self.assertNotEqual(hashlib.sha256(first).digest(), hashlib.sha256(second).digest()) + inputs = [ + self.archive("smoke", "smoke", 45, [(path, first, 100)]), + self.archive("lane-1", "lane-1", 45, [(path, second, 300)]), + self.archive("lane-2", "lane-2", 45, [(path, second, 200)]), + ] + result = self.merge(inputs) + output = self.root / "merged" / path + self.assertEqual((result.object_count, result.total_bytes), (1, len(first))) + self.assertEqual(output.read_bytes(), first) + self.assertEqual(output.stat().st_mtime_ns, 300) + + def test_rejects_cache_zip_payload_or_permissions_conflicts(self) -> None: + path = object_path("a") + first = cache_object([("lib.rlib", b"one"), ("stderr", b"err")]) + variants = { + "payload": cache_object([("stderr", b"err"), ("lib.rlib", b"two")]), + "permissions": cache_object([("stderr", b"err"), ("lib.rlib", b"one")], mode=0o100755), + } + for name, second in variants.items(): + with self.subTest(name=name): + self.assertEqual(len(first), len(second)) + inputs = [ + self.archive("first", "smoke", 45, [(path, first, 100)]), + self.archive("second", "lane-1", 45, [(path, second, 200)]), + ] + with self.assertRaisesRegex(snapshot.SnapshotError, "conflicting content"): + self.merge(inputs) + self.assertFalse((self.root / "merged").exists()) + def test_rejects_delta_that_conflicts_with_inherited_key(self) -> None: path = object_path("f") self.base_object(path, b"base", 1) diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d23316068..88b2d90f9 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1292,15 +1292,15 @@ mod tests { let user = seed_phone_user_with_password(&state, "13800138195", TEST_PASSWORD).await; let token = sign_test_user_token(&state, &user, "sess_game_distribution_publish_gate"); let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY); - // 无 gate 行时默认开放:已登录作者拿到 true,匿名仍为 false。 - let mut cases = vec![(vec![], true)]; + // 灰度默认关闭:无 gate 行、enabled=false、rollout 0 都拿不到入口。 + let mut cases = vec![(vec![], false)]; cases.push((vec![gate.clone()], false)); gate.allow_user_ids = vec![user.id.clone()]; cases.push((vec![gate.clone()], true)); gate.deny_user_ids = vec![user.id.clone()]; cases.push((vec![gate.clone()], false)); gate.enabled = false; - cases.push((vec![gate.clone()], true)); + cases.push((vec![gate.clone()], false)); gate.enabled = true; gate.allow_user_ids.clear(); gate.deny_user_ids.clear(); @@ -1590,6 +1590,52 @@ mod tests { ); } + #[tokio::test] + async fn game_distribution_publish_open_ignores_author_allowlist_for_activation() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + // 默认关闭:作者判定与总开关都为 false。 + assert!( + !state + .is_game_distribution_publish_enabled_for_user(None) + .await + .expect("author decision") + ); + assert!( + !state + .is_game_distribution_publish_open() + .await + .expect("open decision") + ); + + // 开启但只放白名单作者:作者判定限白名单,管理员激活按总开关放行。 + let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY); + gate.enabled = true; + gate.rollout_percent = 0; + gate.allow_user_ids = vec!["user-allowlisted".to_string()]; + state.set_test_feature_gate_config(vec![gate]); + assert!( + state + .is_game_distribution_publish_enabled_for_user(Some("user-allowlisted")) + .await + .expect("allowlisted author decision"), + "白名单作者应拿到发布入口" + ); + assert!( + !state + .is_game_distribution_publish_enabled_for_user(Some("user-other")) + .await + .expect("other author decision"), + "白名单外作者不应拿到发布入口" + ); + assert!( + state + .is_game_distribution_publish_open() + .await + .expect("open decision"), + "灰度开启后管理员激活新版本不应被作者白名单挡住" + ); + } + #[tokio::test] async fn game_distribution_publish_switch_blocks_writes_but_keeps_reads_and_allowlist() { let state = AppState::new(AppConfig::default()).expect("state should build"); @@ -1619,22 +1665,37 @@ mod tests { .expect("request should build") }; - // 默认没有 gate 行:写入进入业务,不能被发布开关拦下。 - let open = app + // 灰度默认关闭:没有 gate 行时发布写入必须 503 + 专用错误码。 + let default_blocked = app .clone() .oneshot(publish_request()) .await .expect("request should succeed"); - assert_ne!( - open.status(), - StatusCode::SERVICE_UNAVAILABLE, - "默认状态不应拦截发布" + assert_eq!(default_blocked.status(), StatusCode::SERVICE_UNAVAILABLE); + let default_payload = read_json_response(default_blocked).await; + assert_eq!( + default_payload["error"]["code"], + "GAME_DISTRIBUTION_PUBLISH_DISABLED" ); - // 运营收紧到 rollout 0 且无白名单:作者写入 503 + 专用错误码。 - state.set_test_feature_gate_config(vec![test_feature_gate( - module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY, - )]); + // gate 行 enabled=false 同样未开放。 + let mut disabled_gate = + test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY); + disabled_gate.enabled = false; + state.set_test_feature_gate_config(vec![disabled_gate]); + let disabled = app + .clone() + .oneshot(publish_request()) + .await + .expect("request should succeed"); + assert_eq!(disabled.status(), StatusCode::SERVICE_UNAVAILABLE); + + // 开启但 rollout 0 且无白名单:仍然拦截。 + let mut zero_rollout = + test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY); + zero_rollout.enabled = true; + zero_rollout.rollout_percent = 0; + state.set_test_feature_gate_config(vec![zero_rollout]); let blocked = app .clone() .oneshot(publish_request()) @@ -1662,6 +1723,7 @@ mod tests { // 白名单内用户仍可发布(灰度放行)。 let mut gate = test_feature_gate(module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY); + gate.enabled = true; gate.allow_user_ids = vec![user.id.clone()]; state.set_test_feature_gate_config(vec![gate]); let allowed = app 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..6d07359f0 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 对象和 @@ -1425,10 +1444,17 @@ fn private_version_payload(version: &GameDistributionVersionRecord) -> Value { /// 拒绝审核与安全下架都不受影响,用于发布事故或回滚窗口期间“关投稿、保在线”。 /// 开关状态读取失败时按关闭处理,避免绕过运营刚下的收紧动作。 async fn ensure_publish_enabled(state: &AppState, user_id: Option<&str>) -> Result<(), AppError> { - match state - .is_game_distribution_publish_enabled_for_user(user_id) - .await - { + // 作者写入按白名单/灰度判定;管理员激活新版本没有作者身份,只按总开关判定, + // 否则审核通过会被作者灰度挡住。 + let decision = match user_id { + Some(user_id) => { + state + .is_game_distribution_publish_enabled_for_user(Some(user_id)) + .await + } + None => state.is_game_distribution_publish_open().await, + }; + match decision { Ok(true) => Ok(()), Ok(false) => { warn!( @@ -1902,6 +1928,7 @@ mod tests { // 未在白名单内的扩展名直接 404,不进入 SpacetimeDB 与对象存储。 let unknown_extension = app + .clone() .oneshot( Request::builder() .uri("/api/game-distribution/releases/game_1/payload.bin") @@ -1911,6 +1938,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] diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index d2a4208a3..a7b970bfc 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -1181,23 +1181,44 @@ impl AppState { Ok(module_runtime::is_feature_gate_allowed(gate, &user_context)) } - /// 游戏分发写入开关:默认开放,只有运营在灰度配置里显式收紧(白名单/灰度/全关)才拦截。 - /// 读取、目录、详情、发行网关与安全下架不经过这里。 + /// 游戏分发发布开关:灰度未配置时**不开放**(运营在后台配置后才对白名单/灰度命中 + /// 的作者开放),未登录、gate 行缺失或 `enabled=false` 都返回 false。 + /// 读取、目录、详情、发行网关与安全下架不经过这里,已公开游戏始终可玩。 pub async fn is_game_distribution_publish_enabled_for_user( &self, user_id: Option<&str>, ) -> Result { + let Some(user_id) = user_id.map(str::trim).filter(|id| !id.is_empty()) else { + return Ok(false); + }; let gates = self.get_feature_gate_config().await?; - let gate = gates + let Some(gate) = gates .iter() - .find(|item| item.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY); + .find(|item| item.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY) + else { + return Ok(false); + }; + if !gate.enabled { + return Ok(false); + } let user_context = self - .feature_gate_user_context( - user_id, - gate.map(feature_gate_requires_user_tags).unwrap_or(false), - ) + .feature_gate_user_context(Some(user_id), feature_gate_requires_user_tags(gate)) .await; - Ok(module_runtime::is_feature_gate_allowed(gate, &user_context)) + Ok(module_runtime::is_feature_gate_allowed( + Some(gate), + &user_context, + )) + } + + /// 游戏分发发布总开关(与具体作者无关):gate 行存在且 `enabled=true` 即为开放。 + /// + /// 管理员审核通过(激活新版本)没有作者身份,只能按总开关判定;作者写入仍走 + /// `is_game_distribution_publish_enabled_for_user` 的白名单/灰度判定。 + pub async fn is_game_distribution_publish_open(&self) -> Result { + let gates = self.get_feature_gate_config().await?; + Ok(gates.iter().any(|gate| { + gate.gate_key == module_runtime::GAME_DISTRIBUTION_PUBLISH_GATE_KEY && gate.enabled + })) } pub async fn is_agc_template_library_enabled_for_user( diff --git a/server-rs/crates/module-runtime/src/application.rs b/server-rs/crates/module-runtime/src/application.rs index e7f1ead08..f40b8388a 100644 --- a/server-rs/crates/module-runtime/src/application.rs +++ b/server-rs/crates/module-runtime/src/application.rs @@ -84,8 +84,8 @@ pub fn creation_entry_feature_gate_key(creation_type_id: &str) -> String { pub const IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY: &str = "image-editor:agent-sidebar"; pub const AGC_TEMPLATE_LIBRARY_GATE_KEY: &str = "agc:template-library"; -/// 游戏分发写入开关:gate 行缺失或 `enabled=false` 时默认开放;`enabled=true` 时 -/// 只有白名单/灰度命中的用户能发布新版本(`rollout_percent=0` 且无白名单即全部关闭)。 +/// 游戏分发发布开关:`gate 行缺失`或 `enabled=false` 都表示**未开放**(灰度默认关闭); +/// 只有 `enabled=true` 且白名单 / 灰度比例 / 用户标签命中时才允许发布。 pub const GAME_DISTRIBUTION_PUBLISH_GATE_KEY: &str = "game-distribution:publish"; #[cfg(any())]