游戏发行入口改为服务端按模板派生

- 后台审核页删除「发行入口」输入框与前端 HTTPS 校验,审核请求只提交结论与公开修订号
- api-server 新增 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE 模板配置,审核通过时读版本取 gameId 并派生该游戏独立来源地址
- 模板缺 {gameId}、生产未配置模板、gameId 非主机安全字符或派生结果非法时审核通过失败关闭,非生产未配置时回落本地回环发行网关
- 同步更新发行来源 nginx 模板与说明、两份 api-server 环境变量样例、平台与运维主规范、发行里程碑实施计划和决策记录
- 真实栈 e2e 脚本不再传 entryUrl,并断言审核结果里的入口为服务端派生值
This commit is contained in:
2026-09-23 21:33:42 +08:00
parent e0c324c0ac
commit cf19241544
15 changed files with 172 additions and 102 deletions
@@ -501,7 +501,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
{
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
},
);
@@ -521,7 +520,6 @@ test('游戏审核列表与审核动作使用约定的 URL、方法和幂等键'
body: JSON.stringify({
decision: 'approve',
expectedPublicationRevision: 3,
entryUrl: 'https://games.example.test/releases/game_1/index.html',
}),
}),
);
-1
View File
@@ -1110,7 +1110,6 @@ export interface AdminGameDistributionReviewRequest {
decision: 'approve' | 'reject';
expectedPublicationRevision: number;
reviewReason?: string;
entryUrl?: string;
}
export interface AdminGameDistributionReviewResponse {
@@ -9,10 +9,7 @@ import {
suspendAdminGameDistributionGame,
} from '../api/adminApiClient';
import type { AdminGameDistributionReviewEntry } from '../api/adminApiTypes';
import {
AdminGameDistributionReviewPage,
resolveGameReleaseEntryUrlError,
} from './AdminGameDistributionReviewPage';
import { AdminGameDistributionReviewPage } from './AdminGameDistributionReviewPage';
vi.mock('../api/adminApiClient', () => ({
isAdminApiError: vi.fn(
@@ -53,23 +50,7 @@ beforeEach(() => {
});
});
test('发行入口必须是带完整来源的 HTTPS 地址', () => {
expect(resolveGameReleaseEntryUrlError('')).toBe('请填写发行入口');
expect(
resolveGameReleaseEntryUrlError('http://games.test/a/index.html'),
).toBe('发行入口必须以 https:// 开头');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html?token=1'),
).toBe('发行入口不能包含 query 或 fragment');
expect(
resolveGameReleaseEntryUrlError('https://u:p@games.test/a/index.html'),
).toBe('发行入口不能包含凭据');
expect(
resolveGameReleaseEntryUrlError('https://games.test/a/index.html'),
).toBe('');
});
test('通过审核时提交当前 publicationRevision 与发行入口并刷新列表', async () => {
test('通过审核只提交当前 publicationRevision 并刷新列表', async () => {
vi.mocked(reviewAdminGameDistributionVersion).mockResolvedValue({
version: { ...entry, status: 'published' },
replayed: false,
@@ -83,9 +64,8 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
);
await screen.findByText('game_1');
fireEvent.change(screen.getByLabelText('发行入口'), {
target: { value: 'https://games.test/releases/game_1/index.html' },
});
expect(screen.queryByLabelText('发行入口')).toBeNull();
expect(screen.getByText('通过后由系统分配发行地址')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: '通过' }));
await waitFor(() =>
@@ -99,7 +79,6 @@ test('通过审核时提交当前 publicationRevision 与发行入口并刷新
expect(payload).toEqual({
decision: 'approve',
expectedPublicationRevision: 4,
entryUrl: 'https://games.test/releases/game_1/index.html',
});
await waitFor(() =>
expect(vi.mocked(listAdminGameDistributionReviews)).toHaveBeenCalledTimes(
@@ -47,26 +47,6 @@ function createReviewIdempotencyKey(versionId: string) {
return `game-review-${versionId}-${random}`.slice(0, 128);
}
export function resolveGameReleaseEntryUrlError(value: string) {
const normalized = value.trim();
if (!normalized) return '请填写发行入口';
if (!normalized.startsWith('https://')) {
return '发行入口必须以 https:// 开头';
}
if (normalized.includes('?') || normalized.includes('#')) {
return '发行入口不能包含 query 或 fragment';
}
try {
const parsed = new URL(normalized);
if (parsed.username || parsed.password) {
return '发行入口不能包含凭据';
}
} catch {
return '发行入口不是合法 URL';
}
return '';
}
export function AdminGameDistributionReviewPage({
token,
onUnauthorized,
@@ -78,9 +58,6 @@ export function AdminGameDistributionReviewPage({
const [busyVersionId, setBusyVersionId] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [statusMessage, setStatusMessage] = useState('');
const [entryUrlByVersion, setEntryUrlByVersion] = useState<
Record<string, string>
>({});
const [reasonByVersion, setReasonByVersion] = useState<
Record<string, string>
>({});
@@ -111,15 +88,8 @@ export function AdminGameDistributionReviewPage({
entry: AdminGameDistributionReviewEntry,
decision: 'approve' | 'reject',
) {
const entryUrl = (entryUrlByVersion[entry.versionId] ?? '').trim();
const reason = (reasonByVersion[entry.versionId] ?? '').trim();
if (decision === 'approve') {
const invalid = resolveGameReleaseEntryUrlError(entryUrl);
if (invalid) {
setErrorMessage(invalid);
return;
}
} else if (!reason) {
if (decision === 'reject' && !reason) {
setErrorMessage('拒绝审核必须填写理由');
return;
}
@@ -135,7 +105,6 @@ export function AdminGameDistributionReviewPage({
? {
decision,
expectedPublicationRevision: entry.publicationRevision,
entryUrl,
}
: {
decision,
@@ -269,23 +238,9 @@ export function AdminGameDistributionReviewPage({
<td>
<div className="admin-action-row">
<div className="admin-field">
<label
htmlFor={`game-release-url-${entry.versionId}`}
>
</label>
<input
id={`game-release-url-${entry.versionId}`}
value={entryUrlByVersion[entry.versionId] ?? ''}
placeholder="https://"
onChange={(event) =>
setEntryUrlByVersion((current) => ({
...current,
[entry.versionId]: event.target.value,
}))
}
disabled={busy}
/>
<span className="admin-muted-text">
</span>
</div>
<button
type="button"
+4
View File
@@ -71,3 +71,7 @@ GENARRATIVE_LLM_API_KEY=
GENARRATIVE_LLM_MODEL=gpt-5.4-mini
WECHAT_MINIPROGRAM_MESSAGE_TOKEN=
WECHAT_MINIPROGRAM_MESSAGE_ENCODING_AES_KEY=
# 游戏发行入口模板:审核通过时按 {gameId} 占位符派生每游戏独立来源地址,例如
# https://{gameId}.games.example.com/。模板必须含 {gameId},生产未配置时审核通过直接失败。
GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=
+5
View File
@@ -179,6 +179,11 @@ GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_ID=
GENARRATIVE_AGC_PROJECT_SNAPSHOT_OSS_ACCESS_KEY_SECRET=
GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL=dev
# 游戏发行入口模板:审核通过时按 {gameId} 占位符派生每游戏独立来源地址,例如
# https://{gameId}.games.example.com/。模板必须含 {gameId},生产未配置时审核通过
# 直接失败;非生产未配置时回落 http://127.0.0.1:<API_PORT>/api/game-distribution/releases/{gameId}/。
GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=
# SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
+1 -1
View File
@@ -105,5 +105,5 @@ curl -sSI -H 'Accept-Encoding: br' \
- `deploy/nginx/genarrative-release-origin.conf` 为已公开游戏提供每游戏独立来源:`https://<gameId>.games.example.com/`。部署前替换域名、通配证书路径与 upstream 端口,并为 `*.games.example.com` 配置通配 DNS 与通配 TLS。
- 该来源只把子域根路径映射到 `…/releases/<gameId>/index.html`、其余路径映射到 `…/releases/<gameId>/<原路径>`;平台 API、后台、SPA 与上传接口都不在这个来源上暴露,命中即 404。
- 发行来源不使用 Cookie:带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`。响应头(`X-Content-Type-Options`、CORP、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate`)由 `api-server` 发行网关设置,边缘不覆盖。
- 审核通过时填写的 `entryUrl` 就是该子域根地址 `https://<gameId>.games.example.com/`;换版本或下架只改变后端公开投影,边缘不需要改配置。
- 审核通过时 `api-server` 按部署模板(`GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE=https://{gameId}.games.example.com/`)与 gameId 派生 `entryUrl`,即该子域根地址;换版本或下架只改变后端公开投影,边缘不需要改配置。
- 门禁:`npm run check:release-origin-config` 会逐条校验模板约束、交叉检查发行网关仍在设置上述响应头,并在本机存在 `nginx``openssl` 时用自签通配证书渲染一份临时配置执行 `nginx -t`
+2 -1
View File
@@ -51,7 +51,8 @@ server {
}
# 子域根路径直接服务该游戏的 index.html,游戏内其余资源按相对路径原样交给
# 发行网关;这样审核通过时填写的 entryUrl 就是 https://<gameId>.games.example.com/。
# 发行网关;审核通过时 api-server 按发行入口模板派生的 entryUrl 就是
# https://<gameId>.games.example.com/。
location = / {
proxy_http_version 1.1;
proxy_set_header Host $host;
@@ -121,6 +121,12 @@
- 发布灰度改为**默认关闭**并修掉客户端“看得到点不动”:`is_game_distribution_publish_enabled_for_user` 现在要求 gate 行存在且 `enabled=true`(未登录、无行、`enabled=false` 一律 false),因此没配灰度时 `gameDistributionPublishEnabled=false`,AGC 不再渲染「发布到游戏广场」按钮、网页入口也不出现;AGC 侧新增 `announcePublishMessage`,把「已构建并打包试玩包」「先打开一个项目再发布」等提示通过 DirectProject 聊天容器的 `announce` 出口回话(普通项目不渲染工作台状态行,之前只写 workspaceStatus 才会表现为点击无反应)。后台「灰度发布配置」新增「可配置开关」列表:预设开关在未创建行时也可见并可一键配置(不再需要先猜 gate key)。
## 2026-09-23 口径更新:发行入口改为服务端派生
- 管理员不再填写 `entryUrl`:审核通过时 `api-server` 读版本取 gameId,按部署模板 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`(生产形如 `https://{gameId}.games.<发行域名>/`,必须含 `{gameId}` 占位符)派生每游戏独立来源地址,再走原有 HTTPS / 无凭据 / 无 query / 无 fragment 校验;后台审核 DTO 与页面已删除该输入框。
- 上文「已完成证据」中描述「管理员填写 / 要求 HTTPS 发行入口」的条目是当时的交付事实,当前口径以主规范《平台入口与玩法链路》《本地开发验证与生产运维》与 `shared-memory/decision-log.md` 的 2026-09-23 条目为准。
- 非生产环境未配置模板时仍回落到本地发行网关回环地址(用于免 TLS 验证内嵌游玩);生产未配置模板、模板缺 `{gameId}`、gameId 非主机安全字符或派生结果非法时,审核通过直接失败。
## 尚未完成
- 真实独立发行域名、通配 TLS 与 CDN 仍属部署侧:边缘模板与门禁已就绪,本地已用真实 nginx 验证按主机映射、Cookie 403 与命名空间隔离,但仍需在真实域名/证书下跑一次“审核通过 → 游玩 → 换版 → 下架”并确认 CDN TTL 不超过 60 秒窗口。
@@ -9309,3 +9309,12 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
- 影响范围:`server-rs/crates/api-server/src/admin.rs`(新增 schema 解析与 `build_admin_database_enum_labels`,删除 `normalize_admin_database_known_enum``parse_admin_database_table_rows_sql_response` / 行构建与归一化改为接收枚举映射)、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md``.codex/skills/genarrative-admin-backoffice/references/spacetimedb-http-sql-sats-display.md`。前端与 DTO 不变。
- 验证方式:`cargo test -p api-server --manifest-path server-rs/Cargo.toml --bin api-server admin::tests`86 passed,含新增 `admin_database_enum_labels_come_from_schema_variants` 与改写后的充值订单枚举用例);用本地 dev schema 逐表回放同一算法,85 张表里 32 个枚举列全部解析出展示名、0 个残留;`cargo check -p api-server``cargo fmt --all --check``npm run check:encoding``npm run check:doc-index``git diff --check` 通过。
- 边界(未验证):没有对真实 HTTP 表查询响应做端到端比对(本地 dev api-server 仍是改动前二进制,未重启)。
## 2026-09-23 游戏发行入口改为服务端按模板派生:管理员不再手填地址
- 背景:游戏审核通过要求管理员手填绝对 HTTPS `entryUrl`,现场出现「不知道该填什么、随手填一个外部站点也能过校验」的风险;而每游戏独立来源本身完全能由 gameId 推出,人工输入没有增加任何判断。
- 决策(唯一口径):部署侧用 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE` 配置带 `{gameId}` 占位符的模板(生产形如 `https://{gameId}.games.<发行域名>/`);审核通过时 `api-server` 读版本取 gameId、替换模板、再走原有绝对 HTTPS / 无凭据 / 无 query / 无 fragment 校验后写入公开投影。后台审核请求 DTO 删除 `entryUrl`,页面不再渲染输入框,也不要求二次确认。
- 决策(失败关闭):模板缺 `{gameId}`、生产未配置模板、gameId 含非主机安全字符或派生结果非法时,审核通过直接失败,不回落主站、内网或任意外部地址;非生产未配置模板时回落 `http://127.0.0.1:<bind_port>/api/game-distribution/releases/{gameId}/`,保持免 TLS 的本地内嵌游玩验证。
- 边界:`entryUrl` 仍是公开投影字段,只是改由服务端写入;审核请求摘要不再包含它,表结构与版本回读不变;模板变更只影响之后新通过审核的版本,历史版本已冻结的 `entry_url` 不改写。
- 影响面:`server-rs/crates/api-server/src/{config.rs,modules/game_distribution.rs}``apps/admin-web/src/{api/adminApiTypes.ts,api/adminApiClient.test.ts,pages/AdminGameDistributionReviewPage.tsx,pages/AdminGameDistributionReviewPage.test.tsx}``scripts/check-game-distribution-media-e2e.mjs``deploy/{nginx,env,container}`、平台与运维主规范、发行里程碑实施计划。
- 验证:`cargo check -p api-server``cargo test -p api-server game_distribution`31 passed)、admin-web 定向 Vitest19 passed)与 `apps/admin-web` typecheck、`npm run check:release-origin-config``npm run check:doc-index``npm run check:encoding``git diff --check` 全部通过;真实栈端到端(真实 OSS + SpacetimeDB + 审核通过)未在本轮复跑。
@@ -151,6 +151,8 @@ dev 调度器发现 revision 变化时,把同一个完整 commit 通过 `COMMI
`Genarrative-Agc-Windows-Build``Tauri NSIS toolchain` 阶段必须在 Rust 编译前预置 NSIS 工具链并失败关闭:tauri-bundler 打包时现场从 GitHub 下载 `nsis-3.11.zip``nsis_tauri_utils.dll` 且不重试,构建机每次检出都会重下,响应一旦被截断就只能抛 `io: unexpected end of file`,让发布在编译数分钟后才失败。该阶段先跑 `node apps/ai-game-creator-shell/scripts/ensure-nsis-toolset.mjs`(固定 SHA1 校验、4 次重试、解压到 `target/.tauri/NSIS`),再执行 `makensis.exe -VERSION` 验证可执行性;Checkout 的 `git clean -fdx` 必须带 `-e apps/ai-game-creator-shell/src-tauri/target/.tauri`,只保留这份工具缓存、其余 `target/` 内容照常清空,否则工作区内缓存会被每个构建删掉,退回到「每次从 GitHub 重下」(实测裸 `git clean -fdx` 会输出 `Would remove apps/ai-game-creator-shell/src-tauri/target/`);原始归档缓存在工作区外的 `%ProgramData%\genarrative\tauri-nsis-cache`(可用 `AGC_TAURI_NSIS_CACHE_DIR` 覆盖),因此同一节点只有冷缓存才需要联网,离线补缓存时把这两个文件放进缓存目录即可;构建机确实无法访问 GitHub 时使用 bundler 自带的 `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE` / `TAURI_BUNDLER_TOOLS_GITHUB_MIRROR` 指向可达镜像。升级 `@tauri-apps/cli` 时必须同步核对 `nsis-toolset.mjs` 里的归档地址、SHA1 与必需文件清单(与 tauri-bundler 的 `NSIS_REQUIRED_FILES` 逐条对齐),否则预置会被 bundler 判为不完整。
`Genarrative-Agc-Windows-Build` 另有 `Godot gdextension dependency` 阶段,同样必须在 Rust 编译前完成并失败关闭:`build.rs` 的 Godot 载荷构建会现场下载固定 commit 的 `godot-cpp` 归档(`codeload.github.com`),下载失败只会表现成数分钟后的 cargo 构建失败。该阶段先跑 `python -X utf8 plugins/agc-godot-editor/native/gdextension/tests/test_dependencies.py`,再跑 `python -X utf8 plugins/agc-godot-editor/native/gdextension/prepare_dependencies.py`(瞬时连接失败、5xx、限流与正文截断最多尝试 4 次、含 3 次重试,仍失败则按固定 SHA256 校验失败关闭);依赖缓存由 Job 环境的 `AGC_GODOT_CPP_CACHE_DIR` 指向工作区外的 `%ProgramData%\genarrative\godot-cpp-cache`,因此 Checkout 的 `git clean -fdx` 不会清掉它,同一节点只有冷缓存才需要联网;离线补缓存时把 `<commit>.zip` 放进该目录即可。构建机无法访问 `codeload.github.com` 时用环境变量 `AGC_GODOT_CPP_ARCHIVE_URL`(必须 HTTPS,由 Job 参数或节点环境提供)指向可达镜像,接受与否仍由固定 SHA256 决定,镜像必须提供与官方归档逐字节一致的文件。
调度状态是调度 Job 工作区里的 `.jenkins-last-triggered-revision`,构建描述同时回显本次 revision 与结果。工作区被清理(例如 `Wipe Out Workspace`)或状态文件缺失时,下一次运行按“版本变化”处理并触发一次,之后恢复稳定;需要重建同一版本时勾选 `FORCE_TRIGGER`。Job 按仓库内 `jenkins/scheduled-revision-trigger-job-config.xml` 创建:`scriptPath=jenkins/Jenkinsfile.scheduled-revision-trigger`、Git 入口 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`、凭据 `genarrative-local-gitea-ssh``<triggers/>` 留空(定时器写在 Jenkinsfile 里)。推送后必须让三个 live Job 各自加载一次新 Jenkinsfile,并只读核对 `config.xml`Full 与 AGC 不再有 cron,定时只来自新调度 Job;只改 Jenkinsfile 而不确认 live 配置时,旧 cron 仍会继续触发。
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
@@ -654,7 +656,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
- 路由约定:`https://<gameId>.games.<域名>/` 是该游戏的入口(子域根路径映射到该游戏的 `index.html`),其余路径按原样映射到 `/api/game-distribution/releases/<gameId>/…`;平台 API、后台、SPA 与上传接口在这个来源上一律 404,命中即证明边缘多代理了命名空间。
- 会话隔离:发行来源从不使用 Cookie。带 `Cookie` 的请求在边缘直接 403,转发前也会 `proxy_set_header Cookie ""`;发行网关自身同样对带 Cookie 的请求返回 403。
- 响应头与缓存:`X-Content-Type-Options`、CORP`cross-origin`)、无凭据 CORS、HTML CSP、内容类型白名单与 `Cache-Control: public, max-age=60, must-revalidate` 都由发行网关设置,边缘不覆盖。换版与下架只改变后端公开投影,因此**最迟 60 秒**内新请求不再拿到旧版本;已经下载到浏览器的脚本无法远程抹除,撤销能力以“停止继续分发”为准。
- 审核动作:管理员审核通过时填写的 `entryUrl` 必须是该游戏的子域根地址 `https://<gameId>.games.<域名>/`HTTPS、无凭据、无 query/fragment);非生产环境仍按现有口径允许 http 回环地址用于本地联调。
- 审核动作:管理员只提交审核结论与公开修订号,`entryUrl``api-server` 按部署模板 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE`(必须含 `{gameId}` 占位符)与 gameId 派生,生产应配置 `https://{gameId}.games.<域名>/`;派生结果仍按 HTTPS、无凭据、无 query/fragment 校验,模板缺失或派生结果非法时审核通过直接失败。非生产环境未配置模板时回落到本地回环发行网关地址(`http://127.0.0.1:<api端口>/api/game-distribution/releases/{gameId}/`)用于联调。
- 门禁与本地联调:
```bash
@@ -84,14 +84,13 @@
1. AGC 发布取当前 npm 工程已成功构建的 `dist/` 内容,重新检查入口和实际字节;ZIP 内部必须把 `dist/index.html` 归一化为根 `index.html`,其余路径相对发行根保持不变。不得上传整个项目、源码快照或仅发送本地路径。网页 ZIP 同样要求根 `index.html`,不猜测并自动剥离多层目录。
2. 所有运行依赖都必须在发行包内。资源 URL 使用与发行版本目录兼容的相对地址;前导 `/assets`、本地文件 URL、外部脚本/样式/媒体/字体地址均不属于可接受发行合同。客户端给出可操作错误,服务器仍独立校验;静态校验不能代替运行时 CSP 阻断。
3. 现行限额:压缩包 200 MiB、展开总量 500 MiB、单文件 64 MiB、最多 10,000 个文件、展开/压缩比不超过 100。压缩包上限同时决定 `api-server` 的发行包路由请求体上限(200 MiB + 1 KiB)与反代放行量:Nginx 通用 `/api` location 为 `client_max_body_size 210m`Pingora 网关为 `GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=220200960`;三者必须同时满足,否则合法包会在反代或路由层被 413。服务端拒绝加密 ZIP、重复或大小写冲突路径、绝对路径、`..`、符号链接/重解析点、设备文件和嵌套压缩包;拒绝 `.agent`、版本控制目录、`node_modules`、凭据文件与源码映射文件。超限返回明确错误,不截断后继续发布。超过约 100 MiB 的包在 `api-server` 会带来数百 MB 的瞬时内存占用,发布窗口与实例规格需按容量验证基线预留。
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` 时,未登录与已登录作者都拿到不开放(发布入口不渲染、写入口 503);运营在后台创建该键并 `enabled=true` 后,只有白名单 / 灰度比例 / 用户标签命中的作者拿到开放状态。发布入口的开放状态随 `/api/runtime/frontend-config``gameDistributionPublishEnabled` 下发,网页广场/我的游戏入口与 AGC 聊天头「发布到游戏广场」按钮据此显示或隐藏;写入口仍独立校验,收紧期间提交返回 503 与可读文案,读接口、目录、详情、发行网关与安全下架不受影响。作者续发时按版本冻结快照回填封面与截图并复用同一批素材;公开投影只暴露对象键,素材 ID 只在作者与管理员回读时返回,快照里缺素材 ID 的旧版本必须要求作者重新选择封面。AGC 发布面板不展示 ZIP 路径、文件数或体积等技术摘要;一句话简介与分类可根据有界、脱敏的创作上下文免费生成(不扣用户泥点,仍可编辑),分类必须收敛到上述白名单;游戏封面支持基于项目上下文生成,生成走现役图片生成与泥点扣费链路,产物必须登记为当前账号平台素材后才能作为 `coverAssetId` 提交。
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`,避免发行文件被主站同源读取;发行网关必须部署在独立来源。发行包按对象键在进程内做有界缓存,单个超预算包不进入缓存。
9. 审核通过时必须提交绝对 HTTPS `entryUrl`,且不接受凭据、queryfragment;服务端不根据请求 Host 或本地路径拼默认发行地址,避免把内网地址或主站来源写进公开投影。 非生产环境额外允许 http 回环地址(`127.0.0.1` / `localhost` / `[::1]`,口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。
10. 上传有两条等价入口,共用同一版本状态机、摘要口径、幂等键与校验规则:① 整包入口——网页端与旧客户端对 `versionId` 直接 `PUT` 整包字节,原子、不可续传,仍受发行包上限与请求体限制约束;② 分片续传入口——AGC 原生一键发布对同一 `versionId` 顺序上传固定大小的分片,再以单独的完成动作收口。分片大小由服务端下发且固定(现为 8 MiB,随发行包上限 200 MiB 取整到 25 片以内),客户端不得自行改变;分片续传入口只补传缺失字节,任何分片重复或乱序都不得造成重复写入。AGC 侧必须由原生进程直接读取本地试玩包并按分片发送,整包字节不得经过 WebView IPC 往返,也不得整包驻留宿主内存。
9. 发行入口不由管理员填写:部署侧用 `GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE` 配置带 `{gameId}` 占位符的模板(生产形如 `https://{gameId}.games.<发行域名>/`),审核通过时 `api-server` 按模板与 gameId 派生每游戏独立来源地址,再按绝对 HTTPS、无凭据、query/fragment 校验后写入公开投影;模板缺 `{gameId}`、生产未配置模板或派生结果非法时审核通过直接失败,不偷偷回落到主站或内网地址。非生产环境未配置模板时回落到 `http://127.0.0.1:<api端口>/api/game-distribution/releases/{gameId}/`,口径与前端 `normalizeGameEntryUrl` 一致,便于本地在没有 TLS 的情况下验证内嵌游玩;生产环境只接受 HTTPS。
### 身份、状态、审核与更新
@@ -107,9 +106,7 @@
### 幂等、并发与恢复
- 所有创建、提交、审核、撤销和下架动作携带 `Idempotency-Key`。服务端以认证主体、动作和 key 保存请求摘要与结果;同 key 同请求返回原结果,同 key 不同请求返回 `409 IDEMPOTENCY_CONFLICT`。至少保留 30 天;客户端超出恢复窗口先回读记录,不能把未知结果自动当作失败重发。
- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。
- 分片续传以「服务端已收字节」为唯一权威偏移:客户端带上自己认为的偏移上传分片,与服务端记录不一致时服务端返回 `409` 与权威偏移,客户端按权威偏移继续,不重放也不跳段。传输失败、网络中断、客户端进程退出或应用重启后,同一 `versionId` 重新发布只补传缺失分片;已收字节数由服务端持久化事实决定,不依赖客户端本地记录。
- 分片会话在全部字节到齐并执行完成动作之前,不进入包校验、不确认版本、不改变任何公开可见性,半包对象也不服务给发行网关。完成动作里校验失败时删除该半包对象并把版本落到 `upload_failed``recoveryAction=reupload`);作者要重新上传同一版本时必须先显式重置分片会话,重置后已收字节归零,不允许在半包之上续写不同字节。
- 同一个版本只能确认一份 ZIP:中断重传仍使用同 `versionId` 和摘要,已确认相同字节直接返回成功,不同摘要返回 409。上传中同版本第二个写入返回 `409 UPLOAD_IN_PROGRESS`;未确认半包不会进入校验。首版整包重传,不宣称支持分片断点续传。
- 重复提交同一次 AGC 操作不得创建第二个游戏或版本;原生端持久保存操作 ID、目标游戏/版本和 key,网页保存恢复标识并以服务端回读为准。相同 ZIP 用于不同资料修订时允许新版本,不能仅按包摘要吞掉新的发布意图。
- 公开版本切换、作者下架和管理员审核必须带 `expectedPublicationRevision`,在持久化事务中比较并推进。并发变化返回 `409 PUBLICATION_CONFLICT`;旧送审版本不能在用户已发布更新或下架之后静默覆盖状态。审核员重新查看现状后才能提交新的明确动作。
- 网络中断或响应丢失后先查询原操作/版本;服务端恢复 `validating` 的在途任务并按版本身份幂等续作,不另建版本。登录失效保留私有草稿和恢复标识,重新登录同账号后继续;换账号不能读取或接管原账号操作。
@@ -132,7 +129,7 @@
| `POST /versions/{versionId}/cancel` | owner | **已实现**:带 `expectedPublicationRevision` CAS 与 `Idempotency-Key`,只能撤回未参与公开投影的版本;同 key 同请求重放返回 `replayed: true`,摘要不同返回 409 |
| `POST /games/{gameId}/unpublish` | owner | **已实现**:CAS 关闭公开游戏及其版本入口,不删除审核记录 |
| `GET /admin/api/game-distribution/reviews` | 管理员 | **已实现**:分页获取待审版本;此行是完整后台路径 |
| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准需 HTTPS 发行入口并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 |
| `POST /admin/api/game-distribution/versions/{versionId}/review` | 管理员 | **已实现**:批准由服务端按部署模板与 gameId 派生该游戏发行入口并执行公开版本 CAS;拒绝需理由;此行是完整后台路径 |
| `POST /admin/api/game-distribution/games/{gameId}/suspend` | 管理员 | **已实现**:安全下架整个游戏并撤销发行访问,要求 `expectedPublicationRevision` CAS 与幂等键;后台游戏审核页提供带原因输入与二次确认的入口;此行是完整后台路径 |
除显式 `/admin/api/...` 外,表内路径均相对 `/api/game-distribution`。错误采用现有平台 envelope,覆盖 400 格式错误、401 未登录、403 owner/审核权限错误、404 不可见、409 幂等/状态/并发冲突、413 大小上限、422 包或资料校验失败、429 限流和明确的可重试 5xx;服务端响应不包含存储凭据和本地绝对路径。
@@ -516,7 +516,7 @@ async function main() {
`status=${readBefore.status}`,
);
// 7. 管理员审核通过(本地非生产允许回环 http 入口;管理员 token 在步骤 1.1 已取得)
// 7. 管理员审核通过(发行入口由服务端按部署模板与 gameId 派生;管理员 token 在步骤 1.1 已取得)
const approved = await api(
`/admin/api/game-distribution/versions/${versionId}/review`,
{
@@ -526,8 +526,6 @@ async function main() {
body: {
decision: 'approve',
expectedPublicationRevision: readback.data.version.publicationRevision,
// 本地用发行网关路径当入口,让「审核通过 → 游玩」在本地也走真实网关。
entryUrl: `${API}/api/game-distribution/releases/${gameId}/`,
},
},
);
@@ -536,6 +534,12 @@ async function main() {
approved.status === 200,
`status=${approved.status} ${approved.text.slice(0, 250)}`,
);
check(
'审核通过后发行入口由服务端派生',
approved.data?.version?.entryUrl ===
`${API}/api/game-distribution/releases/${gameId}/`,
String(approved.data?.version?.entryUrl),
);
// 8. 公开目录:封面/截图对象键生效
const catalogAfter = await api('/api/game-distribution/games');
@@ -94,6 +94,9 @@ pub struct AppConfig {
pub client_download_channel: String,
/// AGC 项目快照的部署渠道:上传与后台默认查询都按它分区。
pub project_snapshot_channel: String,
/// 游戏发行入口模板:审核通过时按 `{gameId}` 占位符展开成每游戏独立来源地址。
/// 生产必须显式配置;非生产缺省回落到本地发行网关回环地址,便于免 TLS 验证游玩。
pub game_distribution_release_entry_template: Option<String>,
pub log_filter: String,
pub otel_enabled: bool,
pub admin_username: Option<String>,
@@ -398,6 +401,7 @@ impl Default for AppConfig {
image_editor_agent_sidebar_enabled: false,
client_download_channel: "dev".to_string(),
project_snapshot_channel: "dev".to_string(),
game_distribution_release_entry_template: None,
log_filter: "info,tower_http=info".to_string(),
otel_enabled: false,
admin_username: None,
@@ -726,6 +730,9 @@ impl AppConfig {
if let Ok(channel) = std::env::var("GENARRATIVE_AGC_PROJECT_SNAPSHOT_CHANNEL") {
config.project_snapshot_channel = channel.trim().to_string();
}
// 发行入口模板由部署侧提供;显式空值视为未配置,不能悄悄回落到本地回环。
config.game_distribution_release_entry_template =
read_first_non_empty_env(&["GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE"]);
if let Some(enabled) =
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
{
@@ -45,6 +45,7 @@ use crate::{
admin::{AuthenticatedAdmin, require_admin_auth},
api_response::json_success_body,
auth::{AuthenticatedAccessToken, require_bearer_auth},
config::AppConfig,
http_error::AppError,
platform_errors::{map_llm_error, map_oss_error},
request_context::RequestContext,
@@ -156,8 +157,6 @@ struct AdminReviewRequest {
expected_publication_revision: u64,
#[serde(default)]
review_reason: Option<String>,
#[serde(default)]
entry_url: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -1416,25 +1415,21 @@ async fn admin_review_version(
decision.as_str(),
payload.expected_publication_revision,
payload.review_reason.as_deref(),
payload.entry_url.as_deref(),
))
.map_err(|error| internal(error.to_string()))?,
);
let (version, replayed) = if decision == "approve" {
// 回滚窗口里“关闭新版本激活”,但拒绝审核与安全下架必须始终可用。
ensure_publish_enabled(&state, None).await?;
let entry_url = payload
.entry_url
.as_deref()
.ok_or_else(|| bad_request("审核通过必须提供发行网关 HTTPS 入口"))?;
validate_release_entry_url(entry_url, !state.config.is_production())?;
// 发行入口由部署模板和 gameId 派生,管理员不填地址,也不做二次确认。
let entry_url = derive_release_entry_url(&state, &version_id).await?;
state
.spacetime_client()
.approve_game_distribution_version(GameDistributionApproveRecordInput {
version_id,
admin_user_id,
expected_publication_revision: payload.expected_publication_revision,
entry_url: entry_url.to_string(),
entry_url,
idempotency_key,
request_digest,
now_micros: now_micros(),
@@ -1506,11 +1501,58 @@ async fn admin_get_version(
))
}
/// 校验管理员提交的发行入口。
/// 审核通过时按部署模板与 gameId 派生发行入口。
async fn derive_release_entry_url(state: &AppState, version_id: &str) -> Result<String, AppError> {
let version = state
.spacetime_client()
.get_game_distribution_version(version_id.to_string())
.await
.map_err(map_spacetime_error)?
.ok_or_else(|| AppError::from_status(StatusCode::NOT_FOUND))?;
build_release_entry_url(&state.config, &version.game_id)
}
/// 本地联调缺省模板:直接指向本进程的发行网关,免 TLS 即可验证内嵌游玩。
fn default_local_release_entry_template(bind_port: u16) -> String {
format!("http://127.0.0.1:{bind_port}/api/game-distribution/releases/{{gameId}}/")
}
/// 按部署模板生成该游戏的发行入口。
///
/// 模板必须显式包含 `{gameId}`,否则所有游戏会共用同一个来源;生产环境没有模板时
/// 直接失败,不能悄悄回落到本地回环地址。
fn build_release_entry_url(config: &AppConfig, game_id: &str) -> Result<String, AppError> {
let template = match config.game_distribution_release_entry_template.as_deref() {
Some(template) => template.trim().to_string(),
None if config.is_production() => {
return Err(internal(
"发行来源未配置:请设置 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE",
));
}
None => default_local_release_entry_template(config.bind_port),
};
if !template.contains("{gameId}") {
return Err(internal(
"发行入口模板必须包含 {gameId} 占位符,避免多个游戏共用同一个来源",
));
}
if game_id.is_empty()
|| !game_id.chars().all(|character| {
character.is_ascii_alphanumeric() || character == '-' || character == '_'
})
{
return Err(internal("游戏标识不适用于发行子域"));
}
let entry_url = template.replace("{gameId}", game_id);
validate_release_entry_url(&entry_url, !config.is_production())?;
Ok(entry_url)
}
/// 校验派生出的发行入口。
///
/// 生产环境只接受绝对 HTTPS 地址;非生产环境额外允许 http 回环地址,口径与前端
/// `normalizeGameEntryUrl` 一致,便于本地把发行网关跑在 127.0.0.1 上验证内嵌游玩。
/// 任何环境都拒绝凭据、query 和 fragment,也不允许服务端自行拼默认地址
/// 任何环境都拒绝凭据、query 和 fragment。
fn validate_release_entry_url(value: &str, allow_loopback_http: bool) -> Result<(), AppError> {
let parsed =
url::Url::parse(value.trim()).map_err(|_| bad_request("发行入口必须是有效 URL"))?;
@@ -2803,7 +2845,69 @@ mod tests {
}
#[test]
fn approve_requires_credential_free_https_entry_url() {
fn release_entry_url_is_derived_from_template_and_game_id() {
let config = crate::config::AppConfig {
game_distribution_release_entry_template: Some(
"https://{gameId}.games.example.test/".to_string(),
),
..crate::config::AppConfig::default()
};
assert_eq!(
build_release_entry_url(&config, "game_1").expect("派生发行入口"),
"https://game_1.games.example.test/"
);
}
#[test]
fn release_entry_template_must_contain_game_id() {
let config = crate::config::AppConfig {
game_distribution_release_entry_template: Some(
"https://games.example.test/".to_string(),
),
..crate::config::AppConfig::default()
};
assert!(build_release_entry_url(&config, "game_1").is_err());
}
#[test]
fn production_release_entry_requires_configured_template() {
let config = crate::config::AppConfig {
environment: "production".to_string(),
..crate::config::AppConfig::default()
};
assert!(build_release_entry_url(&config, "game_1").is_err());
}
#[test]
fn non_production_release_entry_falls_back_to_loopback_gateway() {
let config = crate::config::AppConfig {
bind_port: 12401,
..crate::config::AppConfig::default()
};
assert_eq!(
build_release_entry_url(&config, "game_1").expect("本地发行入口"),
"http://127.0.0.1:12401/api/game-distribution/releases/game_1/"
);
}
#[test]
fn release_entry_rejects_game_id_that_is_not_host_safe() {
let config = crate::config::AppConfig {
game_distribution_release_entry_template: Some(
"https://{gameId}.games.example.test/".to_string(),
),
..crate::config::AppConfig::default()
};
for invalid in ["", "../escape", "game/1", "game 1"] {
assert!(
build_release_entry_url(&config, invalid).is_err(),
"未拒绝的游戏标识:{invalid}"
);
}
}
#[test]
fn release_entry_url_requires_credential_free_https() {
validate_release_entry_url(
"https://games.example.test/releases/game_1/index.html",
false,