diff --git a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs index 5d4fa9881..5fd1f2aa2 100644 --- a/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs +++ b/apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs @@ -5,6 +5,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs'; + // 只操作临时复制品;不启动 GUI、不读取开发机凭据、不访问 Provider。 assert.equal(process.platform, 'darwin', '此验证必须在 macOS 执行'); const source = path.resolve(process.argv[2] || ''); @@ -287,22 +289,32 @@ try { ]) { assert.ok(fs.existsSync(path.join(plugin, file)), file); } - // 随包 Node 的 npm 是包里唯一允许出现的 node_modules:除了 npm 目录自身与它的子项, - // 还要放行它的上级目录 `game-runtime/node/node_modules`(recursive readdir 会列出目录项, - // 少了这一条会让整个门禁对合法包失败——#439 引入后一直没被跑到,直到 2026-09-21 才暴露)。 - const allowedNodeModules = (file) => - file === 'game-runtime/node/node_modules' || - file === 'game-runtime/node/node_modules/npm' || - file.startsWith('game-runtime/node/node_modules/npm/'); + const claudeAgentRoot = path.join(resources, 'claude-agent'); + const claudeAgentSdk = path.join( + claudeAgentRoot, + 'node_modules/@anthropic-ai/claude-agent-sdk', + ); + const claudeAgentBinaryPackage = `claude-agent-sdk-darwin-${architecture === 'arm64' ? 'arm64' : 'x64'}`; + const claudeAgentBinary = path.join( + claudeAgentRoot, + 'node_modules/@anthropic-ai', + claudeAgentBinaryPackage, + 'claude', + ); + for (const required of [ + path.join(claudeAgentRoot, 'index.mjs'), + path.join(claudeAgentSdk, 'sdk.mjs'), + claudeAgentBinary, + ]) { + assert.ok(fs.existsSync(required), required); + } + // 随包只允许 Node runtime 的 npm 与 Claude Agent SDK sidecar 这两棵 + // node_modules 子树;任何其它 node_modules 都是构建残留或不可控依赖。 const packageFiles = fs.readdirSync(resources, { recursive: true }); - assert.ok( - !packageFiles.some( - (file) => - /(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/.test( - file, - ) || - (/(^|\/)node_modules(\/|$)/.test(file) && !allowedNodeModules(file)), - ), + assert.deepEqual( + listForbiddenBundledResourceFiles(packageFiles), + [], + '安装包包含禁止资源', ); assert.equal(run(executable, ['--version']).stdout.trim(), manifest.version); assert.equal( diff --git a/apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs b/apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs new file mode 100644 index 000000000..34cae1aa1 --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs @@ -0,0 +1,46 @@ +/** + * macOS 安装包内容策略。 + * + * `check-macos-bundle.mjs` 会扫描随包 resources。Node runtime 与 Claude Agent SDK + * sidecar 都需要 `node_modules` 形式的目录,但其它构建残留或第三方依赖不能被 + * 静默带入安装包,因此这里只放行两棵明确的生产子树。 + */ + +const NODE_RUNTIME_NODE_MODULES = 'game-runtime/node/node_modules'; +const CLAUDE_AGENT_NODE_MODULES = 'claude-agent/node_modules'; +const CLAUDE_AGENT_SCOPE = `${CLAUDE_AGENT_NODE_MODULES}/@anthropic-ai`; + +const CLAUDE_AGENT_PACKAGE_PATTERN = + /^(?:claude-agent-sdk|claude-agent-sdk-darwin-(?:arm64|x64))(?:\/.*)?$/u; + +function isAllowedClaudeAgentNodeModulesPath(file) { + if (file === CLAUDE_AGENT_NODE_MODULES || file === CLAUDE_AGENT_SCOPE) { + return true; + } + if (!file.startsWith(`${CLAUDE_AGENT_SCOPE}/`)) { + return false; + } + return CLAUDE_AGENT_PACKAGE_PATTERN.test( + file.slice(`${CLAUDE_AGENT_SCOPE}/`.length), + ); +} + +export function isAllowedBundledNodeModulesPath(file) { + return ( + file === NODE_RUNTIME_NODE_MODULES || + file === `${NODE_RUNTIME_NODE_MODULES}/npm` || + file.startsWith(`${NODE_RUNTIME_NODE_MODULES}/npm/`) || + isAllowedClaudeAgentNodeModulesPath(file) + ); +} + +export function listForbiddenBundledResourceFiles(files) { + return files.filter( + (file) => + /(^|\/)(\.env[^/]*|auth\.json|target|\.git)(\/|$)|\.(exe|dll)$/u.test( + file, + ) || + (/(^|\/)node_modules(\/|$)/u.test(file) && + !isAllowedBundledNodeModulesPath(file)), + ); +} diff --git a/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs index 6a0397bbf..1b81f67b1 100644 --- a/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs +++ b/apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; +import { listForbiddenBundledResourceFiles } from './macos-bundle-policy.mjs'; import { assertMacosAppMatchesChannelIdentity, assertManifestArtifactMatchesExpected, @@ -8,6 +9,42 @@ import { readMacosAppInfoIdentity, } from './macos-release-identity.mjs'; +test('allows only the production node_modules subtrees in the macOS bundle', () => { + assert.deepEqual( + listForbiddenBundledResourceFiles([ + 'game-runtime/node/node_modules', + 'game-runtime/node/node_modules/npm/bin/npm-cli.js', + 'claude-agent/node_modules', + 'claude-agent/node_modules/@anthropic-ai', + 'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs', + 'claude-agent/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude', + ]), + [], + ); + assert.deepEqual( + listForbiddenBundledResourceFiles([ + 'claude-agent/node_modules/unexpected/index.mjs', + 'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs', + 'plugins/agc-cocos-editor/node_modules/leftover/index.mjs', + 'game-runtime/node/.env.local', + 'claude-agent/auth.json', + 'claude-agent/target/debug/claude', + 'claude-agent/tool.exe', + 'claude-agent/tool.dll', + ]), + [ + 'claude-agent/node_modules/unexpected/index.mjs', + 'claude-agent/node_modules/@anthropic-ai/unexpected/index.mjs', + 'plugins/agc-cocos-editor/node_modules/leftover/index.mjs', + 'game-runtime/node/.env.local', + 'claude-agent/auth.json', + 'claude-agent/target/debug/claude', + 'claude-agent/tool.exe', + 'claude-agent/tool.dll', + ], + ); +}); + const DEV_IDENTITY = { productName: '陶泥儿开发版', identifier: 'world.genarrative.ai-game-creator', diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index c40e8297c..981a7c932 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -47,79 +47,6 @@ server { } } -# 清智创游官网独立静态站点:根页面位于 /home/index.html,API 仍由当前 api-server 提供。 -# 该 vhost 不依赖主站 SERVER_ALIASES,避免域名落入静态站点后对 /api 返回 Nginx 404。 -server { - listen 80; - server_name tsingnovagames.com www.tsingnovagames.com; - return 301 https://$host$request_uri; -} - -server { - listen 443 ssl http2; - server_name tsingnovagames.com www.tsingnovagames.com; - access_log /var/log/nginx/genarrative.access.log genarrative_upstream; - error_log /var/log/nginx/genarrative.error.log warn; - - ssl_certificate /etc/letsencrypt/live/genarrative.example.com/fullchain.pem; - ssl_certificate_key /etc/letsencrypt/live/genarrative.example.com/privkey.pem; - - root /srv/genarrative/web; - index index.html; - - include /etc/nginx/snippets/genarrative-maintenance.conf; - - location ~ ^/api(?:/|$) { - default_type application/json; - client_max_body_size 210m; - limit_conn genarrative_api_conn 64; - limit_req zone=genarrative_api_rps burst=64 nodelay; - - if ($genarrative_maintenance) { - return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; - } - - proxy_pass http://genarrative_api; - proxy_http_version 1.1; - proxy_buffering off; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; - add_header X-Accel-Buffering no always; - proxy_set_header Connection ""; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header X-Forwarded-Host $host; - proxy_set_header X-Request-Id $request_id; - } - - location = / { - error_page 503 /maintenance.html; - - if ($genarrative_maintenance) { - return 503; - } - - try_files /home/index.html =404; - } - - location ^~ /home/ { - try_files $uri $uri/ =404; - } - - location / { - error_page 503 /maintenance.html; - error_page 404 /404.html; - - if ($genarrative_maintenance) { - return 503; - } - - try_files $uri $uri/ =404; - } -} - server { listen 443 ssl http2; server_name genarrative.example.com; diff --git a/deploy/nginx/snippets/tsingnova-client-downloads.conf b/deploy/nginx/snippets/tsingnova-client-downloads.conf new file mode 100644 index 000000000..d786b7bc3 --- /dev/null +++ b/deploy/nginx/snippets/tsingnova-client-downloads.conf @@ -0,0 +1,30 @@ +# 清智创游官网下载接口:此 snippet 只在现役 tsingnova-games HTTPS server 中 include。 +# 依赖主 Nginx 配置已声明的 genarrative_api、限流区和维护状态变量。 +location = /api/client-downloads { + default_type application/json; + limit_conn genarrative_api_conn 64; + limit_req zone=genarrative_api_rps burst=64 nodelay; + limit_conn_status 429; + limit_req_status 429; + + if ($genarrative_maintenance) { + return 503 '{"ok":false,"error":{"code":"MAINTENANCE","message":"服务维护中"}}'; + } + + proxy_pass http://genarrative_api; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Request-Id $request_id; + proxy_set_header Cookie ""; + proxy_set_header Authorization ""; + proxy_connect_timeout 3s; + proxy_read_timeout 15s; + proxy_send_timeout 15s; + proxy_buffering off; + proxy_cache off; + add_header X-Accel-Buffering no always; +} diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index c620792f0..1d3f8b993 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -112,19 +112,6 @@ }, "docs": ["`/assets/*`"] }, - { - "id": "official_home_assets", - "samplePath": "/home/assets/index.js", - "expect": { - "kind": "static", - "root": "web", - "mode": "exact" - }, - "nginx": { - "production": ["location ^~ /home/", "try_files $uri $uri/ =404;"] - }, - "docs": ["/home/"] - }, { "id": "generic_api_proxy", "samplePath": "/api/assets/history", diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 684f138a3..5c093965c 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -59,8 +59,17 @@ - **处理**:两侧都补了保留上限。服务端:`production-api-deploy.sh` 新增 `--keep-releases`(默认 `2`),发布成功后保留 `current` 目标与最近 1 个历史 release,只删除同时含 `api-server` 或 `web` 标记的旧目录,清理失败只告警、不改变发布结论。CI 侧:`Genarrative-Api-Deploy` / `Genarrative-Web-Deploy` / `Genarrative-Stdb-Module-Publish` 在各自部署 / 发布步骤成功后只保留最近 2 个 `build//`,失败时不清理以便诊断和重跑。`npm run check:production-api-deploy` 增加默认值、显式值和非法值三类夹具,`npm run check:production-ops` 增加对应合同。 - **不要踩的坑**:直接按 mtime 排序删除会连带删掉发布根目录下不属于发布产物的目录(例如 `dev-mcp-host-*`),必须用 `api-server`/`web` 标记筛选;脚本里的 `mv -T`、`find -printf` 都是 GNU 语义,`npm run check:production-api-deploy` 需要 `sha256sum` 和 `/usr/bin/cp`,Windows 本地跑不了,只在 Linux CI / Linux 检出上有效(本地最低限度用 `bash -n` + `npm run check:production-ops`)。 - **写 Jenkins 内联 shell 的两个坑**:① Groovy 会处理 `sh '''…'''` / `sh """…"""` 里的反斜杠转义——`\n` 到 shell 手上会变成真实换行(`Jenkinsfile.production-stdb-module-build` 里必须写 `printf "\\r"` 就是同一件事),所以内联片段要么完全不用 `\`,要么写 `\\`;`"""` 是 GString,shell 变量必须写 `\$name`,而 `'''` 不插值、保持 `${name}`。② `set -euo pipefail` 下 `ls build/*/` 在 glob 不匹配时会因 pipefail 把整个部署步骤判失败(实测:`build/` 为空时清理步骤会把一次成功发布判成失败),必须用 `if [ -d build ]` 守卫 + `|| true` 兜底。 +- **GString 里的命令替换同样要转义**:`sh """…"""` 内的 shell 命令替换必须写 `\$(...)`。写成裸 `$(...)` 时 Jenkins/Groovy 会在加载 Jenkinsfile 时直接报 `illegal string body character after dollar sign`,构建不会进入任何 stage;`npm run check:production-ops` 现已钉住 Stdb Publish 的暂存清理命令。 - **关联**:`scripts/deploy/production-api-deploy.sh`、`scripts/check-production-api-deploy.mjs`、`scripts/check-production-ops-guardrails.mjs`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`。 +## 2026-09-30 AGC macOS 包内容门禁必须识别随包 Claude Agent SDK + +- **现象**:`Genarrative-Agc-MacOS-Build #80` 已成功生成并核对 `陶泥儿开发版.app` 的身份与版本,却在 `check-macos-bundle.mjs` 的 resources 白名单断言处失败,Jenkins 只打印一条无文件名的 `AssertionError`。 +- **原因**:新增 Claude Agent SDK sidecar 后,构建会把 SDK 与匹配平台的 Claude runtime 放到 `claude-agent/node_modules/@anthropic-ai/`;macOS 包内容门禁仍按旧口径把除 Node runtime npm 以外的所有 `node_modules` 都判为禁止。 +- **处理**:把包内容策略抽成纯函数,只放行 `game-runtime/node/node_modules/npm` 和 `claude-agent/node_modules/@anthropic-ai/claude-agent-sdk[-darwin-*]` 两棵明确子树;同时显式要求 sidecar 入口、SDK 与平台 runtime 存在,并让违规时输出具体相对路径。 +- **验证**:`node --test apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs apps/ai-game-creator-shell/scripts/prepare-macos-codex.test.mjs`;macOS 实包再由 `check-macos-bundle.mjs` 复核。 +- **关联**:`apps/ai-game-creator-shell/scripts/macos-bundle-policy.mjs`、`apps/ai-game-creator-shell/scripts/check-macos-bundle.mjs`、`apps/ai-game-creator-shell/scripts/macos-release-identity.test.mjs`、`apps/ai-game-creator-shell/src-tauri/build.rs`、`jenkins/Jenkinsfile.ai-game-creator-shell-macos-build`。 + ## 2026-09-29 dev 上的 JNLP inbound agent 是历史残留,会在死端口上无限重连刷爆 syslog - **现象**:dev 的 `/var/log/syslog` 约 250MB/天,内容是 `jenkins-inbound-agent-start[pid]` 反复输出指向 `http://127.0.0.1:18080/tcpSlaveAgentListener/` 的 `Connection refused` 完整栈(2.6 天 61 万行,其中 `genarrative-release-deploy-01` 占 53 万行)。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 60ff72e24..50ceb846d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -6,11 +6,14 @@ ### 清智创游官网独立域名路由 -清智创游官网 `tsingnovagames.com` / `www.tsingnovagames.com` 的首页静态资源位于同一 Web 根目录的 `/home/`,首页“游戏引擎”入口按同源请求 `/api/client-downloads`。生产 Nginx 必须让这两个域名进入包含 `/api` 反代的主站 `server block`,并把该域名根路径重写到 `/home/`;否则页面可以打开,但接口会被静态站点直接返回 HTML 404,前端显示“暂时无法获取下载信息,请稍后重试”。 +清智创游官网 `tsingnovagames.com` / `www.tsingnovagames.com` 使用独立的 Nginx 配置 `/etc/nginx/conf.d/tsingnova-games.conf`,静态根目录是 `/srv/genarrative/corporate-site-current`;主站模板 `deploy/nginx/genarrative.conf` 不应重复声明这两个域名。官网首页按同源请求 `/api/client-downloads`,因此应在现役官网 HTTPS `server` 中 include `deploy/nginx/snippets/tsingnova-client-downloads.conf`,只代理这个精确路径到 `genarrative_api`,不要把整个 `/api` 交给官网静态站点兜底。 -`Genarrative-Server-Provision` 在 `SERVER_NAME=genarrative.world`(或 `www.genarrative.world`)且使用 `production-https` 时会自动补入这两个官网别名,并渲染 `/home/` 根路径兼容规则。修改后需重新执行一次正式服务器配置发布并 reload Nginx,不要只重启 api-server: +线上修复或排障后必须确认的是**生效配置**而不是仓库模板: ```bash +sudo nginx -T | grep -n -A45 -B5 'server_name tsingnovagames.com' +sudo nginx -t +sudo systemctl reload nginx curl -fsS -D ~/data/tmp/tsingnova-client-download.headers \ https://tsingnovagames.com/api/client-downloads \ -o ~/data/tmp/tsingnova-client-download.json @@ -18,7 +21,7 @@ jq . ~/data/tmp/tsingnova-client-download.json grep -i '^cache-control: no-store' ~/data/tmp/tsingnova-client-download.headers ``` -验收必须同时满足:接口返回 `200 application/json`,响应包含 `downloads` 与 `unavailablePlatforms`,并带 `Cache-Control: no-store`;`https://tsingnovagames.com/` 仍返回官网首页。若接口仍返回 Nginx 404,优先检查 `nginx -T` 中 `server_name` 是否包含上述两个域名以及 `/api` location 是否指向 `genarrative_api`。 +验收必须同时满足:接口返回 `200 application/json`,响应包含 `downloads` 与 `unavailablePlatforms`,并带 `Cache-Control: no-store`;`https://tsingnovagames.com/` 仍返回官网首页。若 API 直连 `http://127.0.0.1:8082/api/client-downloads` 是 `200`、官网域名仍是 `404`,说明官网 vhost 没有 include 下载 snippet,不能靠 reload 旧配置解决。 ## 构建回归的隔离与发布文件权限 diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index a4f57404b..769474dd2 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -151,7 +151,7 @@ pipeline { # 只保留最近 2 个 build/ 暂存:每次发布都用 copyArtifacts 重新落一份, # 历史暂存不参与发布、只占目标机磁盘;发布失败时不清理,便于诊断和重跑。 if [ -d build ]; then - stale_list="$(ls -1dt build/*/ 2>/dev/null | tail -n +3 || true)" + stale_list="\$(ls -1dt build/*/ 2>/dev/null | tail -n +3 || true)" while IFS= read -r stale_staging; do [ -n "\$stale_staging" ] || continue echo "[staging-cleanup] 清理历史构建暂存: \$stale_staging" diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index f78ea7eba..a4ef58b77 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -458,6 +458,13 @@ const checks = [ reason: 'Stdb Publish Job 必须清理历史 build/ 暂存,避免目标机被 copyArtifacts 暂存撑满。', }, + { + file: 'jenkins/Jenkinsfile.production-stdb-module-publish', + includes: + 'stale_list="\\$(ls -1dt build/*/ 2>/dev/null | tail -n +3 || true)"', + reason: + 'Stdb Publish 的 GString 内联 shell 必须把命令替换的 $ 写成 \\$;否则 Jenkinsfile 会在进入 stage 前因 Groovy 编译失败。', + }, { file: 'jenkins/Jenkinsfile.production-full-build-and-deploy', includes: diff --git a/scripts/tsingnova-home-route.test.ts b/scripts/tsingnova-home-route.test.ts index caa7934fc..0510c375d 100644 --- a/scripts/tsingnova-home-route.test.ts +++ b/scripts/tsingnova-home-route.test.ts @@ -3,26 +3,97 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; const productionNginx = readFileSync('deploy/nginx/genarrative.conf', 'utf8'); -const provisionScript = readFileSync( - 'scripts/jenkins-server-provision.sh', +const clientDownloadsSnippet = readFileSync( + 'deploy/nginx/snippets/tsingnova-client-downloads.conf', 'utf8', ); +function extractServerBlocks(source: string): string[] { + const blocks: string[] = []; + const serverStart = /\bserver\s*\{/gu; + let match: RegExpExecArray | null; + + while ((match = serverStart.exec(source)) !== null) { + const openBrace = source.indexOf('{', match.index); + let depth = 0; + let quote: '"' | "'" | null = null; + let escaped = false; + let inComment = false; + + for (let index = openBrace; index < source.length; index += 1) { + const character = source[index]; + + if (inComment) { + if (character === '\n') { + inComment = false; + } + continue; + } + if (quote !== null) { + if (escaped) { + escaped = false; + } else if (character === '\\') { + escaped = true; + } else if (character === quote) { + quote = null; + } + continue; + } + if (character === '#') { + inComment = true; + } else if (character === '"' || character === "'") { + quote = character; + } else if (character === '{') { + depth += 1; + } else if (character === '}') { + depth -= 1; + if (depth === 0) { + blocks.push(source.slice(match.index, index + 1)); + serverStart.lastIndex = index + 1; + break; + } + } + } + } + + return blocks; +} + describe('清智创游官网引擎下载路由', () => { - it('生产 Nginx 为官网域名提供独立 vhost 和 API 反代', () => { - expect(productionNginx).toContain( - 'server_name tsingnovagames.com www.tsingnovagames.com;', + it('主站模板不重复声明官网独立域名,下载路由由现役官网 vhost 单独维护', () => { + expect(productionNginx).not.toContain('tsingnovagames.com'); + expect(clientDownloadsSnippet).toContain( + 'location = /api/client-downloads {', ); - expect(productionNginx).toMatch( - /server_name tsingnovagames\.com www\.tsingnovagames\.com;[\s\S]*?location ~ \^\/api\(\?:\/\|\$\)[\s\S]*?proxy_pass http:\/\/genarrative_api;/u, - ); - expect(productionNginx).toContain('try_files /home/index.html =404;'); - expect(productionNginx).toContain('location ^~ /home/'); }); - it('Server-Provision 会把正式证书路径渲染进官网 vhost', () => { - expect(provisionScript).toContain( - 's|/etc/letsencrypt/live/genarrative.example.com/|/etc/letsencrypt/live/${SERVER_NAME}/|g', + it('下载 snippet 只声明精确接口并复用主站保护与 upstream', () => { + expect(clientDownloadsSnippet).toContain( + 'location = /api/client-downloads {', + ); + expect(clientDownloadsSnippet).not.toMatch( + /location\s+(?:~|\^~)?\s*\/api(?:\/|\s|\{|\$)/u, + ); + for (const directive of [ + 'limit_conn genarrative_api_conn 64;', + 'limit_req zone=genarrative_api_rps burst=64 nodelay;', + 'limit_conn_status 429;', + 'limit_req_status 429;', + 'if ($genarrative_maintenance) {', + 'proxy_pass http://genarrative_api;', + 'proxy_set_header Cookie "";', + 'proxy_set_header Authorization "";', + 'proxy_connect_timeout 3s;', + 'proxy_read_timeout 15s;', + 'proxy_send_timeout 15s;', + 'proxy_buffering off;', + 'proxy_cache off;', + 'add_header X-Accel-Buffering no always;', + ]) { + expect(clientDownloadsSnippet).toContain(directive); + } + expect(clientDownloadsSnippet).not.toMatch( + /^\s*add_header\s+Cache-Control\b/mu, ); }); }); diff --git a/src/components/creation-home/ClientDownloadEntry.tsx b/src/components/creation-home/ClientDownloadEntry.tsx index c85742755..6d228ec92 100644 --- a/src/components/creation-home/ClientDownloadEntry.tsx +++ b/src/components/creation-home/ClientDownloadEntry.tsx @@ -1,4 +1,8 @@ -import { getPlatformActionButtonClassName } from '@genarrative/shared/components'; +import { + getPlatformActionButtonClassName, + type PlatformActionButtonSize, + type PlatformActionButtonTone, +} from '@genarrative/shared/components'; import { Download, Monitor } from 'lucide-react'; import { useEffect, useState } from 'react'; @@ -10,6 +14,16 @@ import { UnifiedModal } from '../common/UnifiedModal'; const DOWNLOAD_ERROR = '暂时无法获取最新版本,请稍后重试'; +type ClientDownloadEntryProps = { + /** 按钮文案,默认「下载客户端」。 */ + label?: string; + tone?: PlatformActionButtonTone; + size?: PlatformActionButtonSize; + /** 窄屏只留图标:平台顶栏默认开启,页面级引导入口传 false 保留完整文案。 */ + iconOnlyOnNarrowScreens?: boolean; + className?: string; +}; + type DownloadState = | { status: 'loading' } | { status: 'ready'; release: ClientDownloadResponse } @@ -22,7 +36,13 @@ function downloadLabel(download: ClientDownloadResponse['downloads'][number]) { return `macOS(${download.architecture === 'aarch64' ? 'Apple Silicon' : 'Intel'})`; } -export function ClientDownloadEntry() { +export function ClientDownloadEntry({ + label = '下载客户端', + tone = 'secondary', + size = 'sm', + iconOnlyOnNarrowScreens = true, + className, +}: ClientDownloadEntryProps = {}) { const [open, setOpen] = useState(false); const [attempt, setAttempt] = useState(0); const [state, setState] = useState({ status: 'loading' }); @@ -72,19 +92,31 @@ export function ClientDownloadEntry() { return ( <> { setState({ status: 'loading' }); setOpen(true); }} > ; + +/** 悬浮引导卡片:把还没有游戏包的作者引到客户端。 */ +export function GamePublishClientGuide({ + className, + ...props +}: GamePublishClientGuideProps) { + return ( +
+ + 还没有游戏包? + + +
+ ); +} + +export default GamePublishClientGuide; diff --git a/src/components/game-distribution/GamePublishPage.tsx b/src/components/game-distribution/GamePublishPage.tsx index 514c098d5..43ff09833 100644 --- a/src/components/game-distribution/GamePublishPage.tsx +++ b/src/components/game-distribution/GamePublishPage.tsx @@ -29,6 +29,7 @@ import { resolveGamePublishImagePreview, uploadGamePublishImageAsset, } from './gamePublishAssets'; +import { GamePublishClientGuide } from './GamePublishClientGuide'; import { clearPublishDraft, type GamePublishDraft, @@ -711,12 +712,12 @@ export function GamePublishPage({ -
-
+
+
{updateGameId ? '发布新版本' : '发布游戏'} -

+

{updateGameId ? updateContext ? `为《${updateContext.title}》发布 v${updateContext.versionNumber + 1}` @@ -724,6 +725,9 @@ export function GamePublishPage({ : '上传可在线游玩的网页游戏'}

+ {!updateGameId ? ( + + ) : null}
{error ? (