游戏发行入口改为平台同源路径
Project CI / AI game creator shell Rust crates (push) Successful in 1m26s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m11s
Project CI / Backend tests (push) Successful in 5m12s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 8m26s
Project CI / Native shell tests (push) Successful in 6m31s
Project CI / Frontend tests (push) Successful in 2m20s
Project CI / Repository checks (push) Successful in 2m25s
Project CI / AI game creator shell web tests (push) Successful in 1m21s
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled

- 审核通过时由 api-server 按 gameId 派生 /games/{gameId}/ 相对路径写入公开投影,删除 AppConfig 的发行入口模板字段与读取逻辑
- 删除 deploy/env 两份示例中的 GENARRATIVE_GAME_DISTRIBUTION_RELEASE_ENTRY_TEMPLATE
- 三份 nginx 模板内联同源发行入口 location,把 /games/<gameId>/ 与子资源转发到发行网关并在边缘清空 Cookie
- SPA allowlist 补齐 components、design-system、games、games/detail、games/mine、games/play、games/publish
- 前端 normalizeGameEntryUrl 支持相对路径与同源发行路径,按当前 origin 解析并补尾斜杠,继续兼容历史绝对 URL
- 删除退役的独立来源模板 deploy/nginx/genarrative-release-origin.conf、门禁脚本 scripts/check-release-origin-config.mjs 与其 npm 脚本
- 游戏分发 e2e 脚本改为在公开投影上断言 entryUrl 等于 /games/{gameId}/
- 同步平台主规范、运维主规范、nginx README 与共享决策记录
This commit is contained in:
2026-09-24 00:21:27 +08:00
parent efbdf7031e
commit 87e52860a7
19 changed files with 187 additions and 574 deletions
@@ -534,12 +534,6 @@ 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');
@@ -547,6 +541,11 @@ async function main() {
(game) => game.id === gameId,
);
check('公开目录返回该游戏', Boolean(publishedGame));
check(
'审核通过后发行入口由服务端派生为平台同源路径',
publishedGame?.currentVersion?.entryUrl === `/games/${gameId}/`,
String(publishedGame?.currentVersion?.entryUrl),
);
check(
'公开投影带封面对象键',
publishedGame?.coverObjectKey === cover.objectKey,
-272
View File
@@ -1,272 +0,0 @@
#!/usr/bin/env node
/**
* 游戏发行来源配置门禁。
*
* 逐条校验 `deploy/nginx/genarrative-release-origin.conf`
* 1) 每游戏独立 origin 的按主机映射(命名捕获 `game_id` + 发行网关前缀);
* 2) 只暴露发行网关,不代理平台 API / 后台 / SPA
* 3) 发行来源不使用 Cookie(边缘 403 + 转发前清空);
* 4) 响应头策略仍由 api-server 发行网关负责(源码级交叉检查)。
* 只要本机存在 nginx 与 openssl,还会用自签通配证书渲染一份临时配置执行
* `nginx -t`,把语法与指令上下文一起验证掉。
*/
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(scriptDir, '..');
const templatePath = join(
repoRoot,
'deploy/nginx/genarrative-release-origin.conf',
);
const gatewayPath = join(
repoRoot,
'server-rs/crates/api-server/src/modules/game_distribution.rs',
);
const failures = [];
const notes = [];
function fail(message) {
failures.push(message);
}
function normalize(source) {
return source.replace(/\s+/gu, ' ');
}
function requireSnippet(source, snippet, message) {
if (!normalize(source).includes(normalize(snippet))) {
fail(message);
}
}
function main() {
if (!existsSync(templatePath)) {
fail(`缺少发行来源模板:${templatePath}`);
return;
}
const template = readFileSync(templatePath, 'utf8');
requireSnippet(
template,
'server_name ~^(?<game_id>[a-z0-9_]+)\\.games\\.example\\.com$;',
'发行来源必须用命名捕获 game_id 的子域匹配(每游戏独立 origin)',
);
requireSnippet(
template,
'ssl_certificate /etc/letsencrypt/live/games.example.com/fullchain.pem;',
'发行来源必须使用通配 TLS 证书',
);
requireSnippet(
template,
'if ($http_cookie) { return 403; }',
'发行来源必须拒绝携带平台 Cookie 的请求',
);
requireSnippet(
template,
'proxy_set_header Cookie "";',
'发行来源转发前必须清空 Cookie',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id$request_uri;',
'发行来源必须按 game_id 映射到发行网关前缀',
);
requireSnippet(
template,
'location /.well-known/acme-challenge/',
'发行来源必须保留 ACME challenge 路径',
);
requireSnippet(
template,
'location = / {',
'发行来源必须显式把子域根路径映射为该游戏的 index.html',
);
requireSnippet(
template,
'proxy_pass http://genarrative_release_api/api/game-distribution/releases/$game_id/index.html;',
'子域根路径必须映射到该游戏的 index.html',
);
const proxyPassCount = (template.match(/proxy_pass\s/gu) ?? []).length;
if (proxyPassCount !== 2) {
fail(
`发行来源只应存在两条 proxy_pass(子域根路径与发行网关前缀),实际 ${proxyPassCount}`,
);
}
const cookieStripCount = (
template.match(/proxy_set_header Cookie "";/gu) ?? []
).length;
if (cookieStripCount !== 2) {
fail(`每条发行来源代理都必须清空 Cookie,实际 ${cookieStripCount}`);
}
const gatewayPrefixCount = (
template.match(/api\/game-distribution\/releases\/\$game_id/gu) ?? []
).length;
if (gatewayPrefixCount !== 2) {
fail(`发行来源代理必须都映射到发行网关前缀,实际 ${gatewayPrefixCount}`);
}
for (const forbidden of [
'/api/auth',
'/api/profile',
'/admin/api',
'/api/game-distribution/games',
'/api/game-distribution/versions',
]) {
if (template.includes(forbidden)) {
fail(`发行来源不得代理平台命名空间:${forbidden}`);
}
}
if (!existsSync(gatewayPath)) {
fail(`缺少发行网关源码:${gatewayPath}`);
} else {
const gateway = readFileSync(gatewayPath, 'utf8');
for (const [snippet, message] of [
[
'header::X_CONTENT_TYPE_OPTIONS',
'发行网关必须继续设置 X-Content-Type-Options',
],
[
'HeaderName::from_static("cross-origin-resource-policy")',
'发行网关必须继续设置 CORP',
],
[
'HeaderValue::from_static("cross-origin")',
'CORP 必须是 cross-originopaque sandbox 才能加载自有脚本)',
],
[
'header::ACCESS_CONTROL_ALLOW_ORIGIN',
'发行网关必须继续设置无凭据 CORS',
],
['header::CONTENT_SECURITY_POLICY', '发行网关必须继续为 HTML 设置 CSP'],
['StatusCode::FORBIDDEN', '发行网关必须继续拒绝携带 Cookie 的请求'],
]) {
if (!gateway.includes(snippet)) {
fail(message);
}
}
}
validateWithNginx(template);
if (failures.length > 0) {
console.error('[check:release-origin-config] FAILED');
for (const message of failures) {
console.error(`- ${message}`);
}
process.exit(1);
}
for (const note of notes) {
console.log(`[check:release-origin-config] ${note}`);
}
console.log(
'[check:release-origin-config] OK(发行来源模板、网关响应头策略与 nginx 语法一致)',
);
}
function binaryExists(binary) {
try {
execFileSync('sh', ['-c', `command -v ${binary}`], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function validateWithNginx(template) {
if (!binaryExists('nginx')) {
notes.push('未找到 nginx,跳过渲染后的 nginx -t');
return;
}
const workDir = mkdtempSync(join(tmpdir(), 'genarrative-release-origin-'));
try {
const certPath = join(workDir, 'wildcard.crt');
const keyPath = join(workDir, 'wildcard.key');
if (binaryExists('openssl')) {
execFileSync(
'openssl',
[
'req',
'-x509',
'-newkey',
'rsa:2048',
'-nodes',
'-days',
'1',
'-subj',
'/CN=games.example.com',
'-addext',
'subjectAltName=DNS:*.games.example.com,DNS:games.example.com',
'-keyout',
keyPath,
'-out',
certPath,
],
{ stdio: 'ignore' },
);
} else {
notes.push('未找到 openssl,跳过渲染后的 nginx -t');
return;
}
const rendered = template
.replace(
'/etc/letsencrypt/live/games.example.com/fullchain.pem',
certPath,
)
.replace('/etc/letsencrypt/live/games.example.com/privkey.pem', keyPath)
.replace(
/\/var\/log\/nginx\/(genarrative-release\.[a-z]+\.log)/gu,
join(workDir, '$1'),
)
// 非 root 环境无法绑定 80/443;语法检查用高位端口,不改生产模板本身。
.replace('listen 80;', 'listen 18080;')
.replace('listen 443 ssl http2;', 'listen 18443 ssl http2;');
const renderedPath = join(workDir, 'release-origin.conf');
writeFileSync(renderedPath, rendered);
const wrapperPath = join(workDir, 'nginx.conf');
writeFileSync(
wrapperPath,
[
`pid ${join(workDir, 'nginx.pid')};`,
`error_log ${join(workDir, 'error.log')} warn;`,
'events { worker_connections 64; }',
'http {',
' access_log off;',
' client_body_temp_path ' + join(workDir, 'client-body') + ';',
' proxy_temp_path ' + join(workDir, 'proxy') + ';',
' fastcgi_temp_path ' + join(workDir, 'fastcgi') + ';',
' uwsgi_temp_path ' + join(workDir, 'uwsgi') + ';',
' scgi_temp_path ' + join(workDir, 'scgi') + ';',
` include ${renderedPath};`,
'}',
'',
].join('\n'),
);
try {
execFileSync('nginx', ['-t', '-c', wrapperPath], {
stdio: ['ignore', 'pipe', 'pipe'],
});
notes.push('渲染后的发行来源配置通过 nginx -t');
} catch (error) {
const stderr = error.stderr ? String(error.stderr) : '';
fail(
`渲染后的发行来源配置未通过 nginx -t:${stderr.trim() || error.message}`,
);
}
} finally {
rmSync(workDir, { recursive: true, force: true });
}
}
main();