From bd31a955b71ed764d0843dee1831acd483082daf Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 22 Sep 2026 20:06:37 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=90=8E=E5=8F=B0=E7=81=B0=E5=BA=A6?= =?UTF-8?q?=E9=A2=84=E8=AE=BE=E8=A1=A5=E4=B8=8A=E6=B8=B8=E6=88=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - admin-web:灰度发布配置的固定目标新增「游戏分发 → 游戏发布」(game-distribution:publish),前缀标签与描述说明「未配置或关闭时默认开放,开启后只放行白名单/灰度命中」。 - 默认语义:选中预设后保持 enabled=false,避免误把发布收紧成全关;需要灰度时再显式打开并填白名单/比例。 - 用例:新增「可选择游戏发布开关且默认不收紧」与「保存写入白名单与比例」两条后台用例。 - 文档:开发运维文档说明该键配置前不会出现在已有列表、必须从预设或手填新建一行。 --- .../pages/AdminGrayReleaseConfigPage.test.tsx | 80 +++++++++++++++++++ .../src/pages/AdminGrayReleaseConfigPage.tsx | 9 +++ ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 +- 3 files changed, 90 insertions(+), 1 deletion(-) diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index ac4bb8e80..7bf4b5499 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -175,6 +175,86 @@ 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(); vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({ diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx index 814f2ac34..f90e51a48 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({ diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 76cc81a16..bebbbefd1 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -678,7 +678,7 @@ journalctl -u genarrative-api -o cat | grep 'operation="release_rejected"' 发布事故或回滚窗口里用 `game-distribution:publish` 灰度开关控制写入,不需要改代码或重启: -- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`。 +- 开关位置:后台「灰度发布配置」(`GET/PUT /admin/api/feature-gates`),`gateKey = game-distribution:publish`;后台预设「游戏分发 → 游戏发布」,选中后默认保持 `enabled=false`(即默认开放),需要收紧时再显式打开并填白名单 / 灰度比例。该键在配置前不会出现在已有开关列表里,必须从预设或手填 Gate Key 新建一行。 - 语义:没有该 gate 行或 `enabled=false` 表示**默认开放**;`enabled=true` 时只有 `allowUserIds` / `allowUserTags` / `rolloutPercent` 命中的作者能发布,`rolloutPercent=0` 且无白名单即**全部关闭**(等价紧急关闭投稿)。 - 关闭范围:创建游戏、创建版本、上传包、送审、撤回、作者下架,以及管理员**批准**(新版本激活)都返回 `503 GAME_DISTRIBUTION_PUBLISH_DISABLED`。 - 始终可用:目录、详情、版本回读、发行网关(已公开游戏继续游玩)、`/my-games`、审核队列读取、**拒绝审核**与管理员**安全下架**。 -- 2.52.0 From 4d7c2530bfd8009c746e98ecdb0b301940511456 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 22 Sep 2026 21:18:14 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E5=8F=91=E5=B8=83=E7=81=B0=E5=BA=A6?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=BB=98=E8=AE=A4=E5=85=B3=E9=97=AD=E5=B9=B6?= =?UTF-8?q?=E4=BF=AE=E6=8E=89=E5=AE=A2=E6=88=B7=E7=AB=AF=E2=80=9C=E7=9C=8B?= =?UTF-8?q?=E5=BE=97=E5=88=B0=E7=82=B9=E4=B8=8D=E5=8A=A8=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端:is_game_distribution_publish_enabled_for_user 现在要求 gate 行存在且 enabled=true(未登录、无行、enabled=false 一律不开放),因此没配灰度时 gameDistributionPublishEnabled=false;新增与作者无关的 is_game_distribution_publish_open,管理员审核激活新版本只按总开关判定,避免被作者白名单挡住(修复合入后立即发现的回归)。 - AGC:新增 announcePublishMessage,把「已构建并打包试玩包」「先打开一个项目再发布」等提示通过 DirectProject 聊天容器的 announce 出口回话;普通项目不渲染工作台状态行,之前只写 workspaceStatus 才会表现为点了没反应。 - 后台:「灰度发布配置」新增「可配置开关」列表,预设开关在未创建行时也可见并可一键配置(game-distribution:publish 不再需要先猜键名)。 - 脚本:check:game-distribution-media-e2e 先验证「灰度关闭 → 作者拿不到入口 + 写入口 503」,再开启灰度把真实 Phaser 构建产物发布到可玩,全链路 25 项断言通过。 - 文档:玩法链路、后端契约、开发运维与实施计划统一改为「灰度默认关闭,运营配置后开放」口径。 --- .../pages/AdminGrayReleaseConfigPage.test.tsx | 21 +++++ .../src/pages/AdminGrayReleaseConfigPage.tsx | 52 +++++++++++ apps/ai-game-creator-shell/src/App.tsx | 21 ++++- ...施计划】游戏分发阶段A领域合同-2026-09-19.md | 2 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 2 +- ...发运维】本地开发验证与生产运维-2026-05-15.md | 4 +- ...玩法创作】平台入口与玩法链路-2026-05-15.md | 4 +- scripts/check-game-distribution-media-e2e.mjs | 86 +++++++++++++++--- server-rs/crates/api-server/src/app.rs | 88 ++++++++++++++++--- .../src/modules/game_distribution.rs | 15 +++- server-rs/crates/api-server/src/state.rs | 39 ++++++-- .../crates/module-runtime/src/application.rs | 4 +- 12 files changed, 288 insertions(+), 50 deletions(-) diff --git a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx index 7bf4b5499..dc407ae28 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx @@ -255,6 +255,27 @@ test('灰度发布页保存游戏发布开关时写入白名单与比例', async ); }); +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 f90e51a48..9dd2a0fba 100644 --- a/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx +++ b/apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx @@ -206,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(), @@ -452,6 +457,53 @@ export function AdminGrayReleaseConfigPage({ )} + +
+
+

可配置开关

+ {unconfiguredGateTargets.length} +
+ {unconfiguredGateTargets.length ? ( +
+ + + + + + + + + + {unconfiguredGateTargets.map((option) => ( + + + + + + ))} + +
Gate说明操作
+ {option.key} + + {GATE_PREFIX_LABELS[option.prefix] ?? option.prefix} ·{' '} + {option.label} + + {option.description} + +
+
+ ) : ( +
+ {isLoading ? '加载中' : '预设开关都已创建'} +
+ )} +
{confirmDialog} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 493f3ba80..374560d0b 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/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md index a53cbbf36..0620f55c5 100644 --- a/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md +++ b/docs/project-memory/plans/【实施计划】游戏分发阶段A领域合同-2026-09-19.md @@ -119,6 +119,8 @@ - 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/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ca02639f0..70623b09a 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 bebbbefd1..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`;后台预设「游戏分发 → 游戏发布」,选中后默认保持 `enabled=false`(即默认开放),需要收紧时再显式打开并填白名单 / 灰度比例。该键在配置前不会出现在已有开关列表里,必须从预设或手填 Gate Key 新建一行。 -- 语义:没有该 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 045e7d7c8..4c4858c85 100644 --- a/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md +++ b/docs/【玩法创作】平台入口与玩法链路-2026-05-15.md @@ -86,7 +86,7 @@ 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 字。分类首版为休闲、益智、动作、冒险、模拟、策略、其他;封面/截图复用平台图片上传与归属校验,不接受任意外链作为审核图片。作者不需要自己构建或打 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 的旧版本必须要求作者重新选择封面。 +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}`(含尾斜杠)等价于该游戏的 `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`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。 @@ -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 8b8ae0bc1..fb6913ae7 100644 --- a/scripts/check-game-distribution-media-e2e.mjs +++ b/scripts/check-game-distribution-media-e2e.mjs @@ -214,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); @@ -444,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`, { diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index d9136e5c3..5920b4594 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -1063,15 +1063,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(); @@ -1361,6 +1361,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"); @@ -1390,22 +1436,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()) @@ -1433,6 +1494,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 b6996d55a..6d07359f0 100644 --- a/server-rs/crates/api-server/src/modules/game_distribution.rs +++ b/server-rs/crates/api-server/src/modules/game_distribution.rs @@ -1444,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!( 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())] -- 2.52.0