From 09fec601b132fbbad768771038cd3378ff7c2217 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 10 Jul 2026 12:14:44 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=AE=98=E7=BD=91=20SEO?= =?UTF-8?q?=20=E5=9C=B0=E5=9F=BA=E4=B8=8E=E7=B2=BE=E7=A1=AE=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充 robots、sitemap、SEO 元信息、结构化数据与首页语义内容 统一三套 Nginx 与 Pingora 的 62 条 SPA 路由和真实 404 行为 新增路由一致性检查、网关测试并同步技术文档与项目记忆 --- deploy/container/nginx.conf | 12 +- deploy/nginx/genarrative-dev-http.conf | 24 ++- deploy/nginx/genarrative.conf | 24 ++- deploy/pingora/nginx-route-parity.matrix.json | 84 ++++++++- docs/README.md | 1 + .../shared-memory/decision-log.md | 7 + docs/project-memory/shared-memory/pitfalls.md | 8 + .../【SEO】官网SEO地基实施约定-2026-07-10.md | 50 +++++ ...开发运维】Pingora独立网关试点-2026-06-11.md | 10 +- index.html | 40 +++- package.json | 1 + public/robots.txt | 21 +++ public/sitemap.xml | 8 + scripts/check-nginx-spa-routes.mjs | 173 ++++++++++++++++++ scripts/check-pingora-gateway-smoke.mjs | 25 ++- scripts/check-pingora-route-parity.mjs | 36 ++++ server-rs/crates/pingora-gateway/src/main.rs | 85 ++++++++- src/App.test.tsx | 8 +- .../CreationLandingView.test.tsx | 4 +- .../creation-home/CreationLandingView.tsx | 9 +- .../PlatformMobileHomeWelcomeDialog.test.tsx | 5 +- .../PlatformMobileHomeWelcomeDialog.tsx | 2 +- .../RpgEntryHomeView.recharge.test.tsx | 36 ++++ src/components/rpg-entry/RpgEntryHomeView.tsx | 22 ++- src/services/appTitle.test.ts | 6 +- src/services/appTitle.ts | 7 +- 26 files changed, 681 insertions(+), 27 deletions(-) create mode 100644 docs/technical/【SEO】官网SEO地基实施约定-2026-07-10.md create mode 100644 public/robots.txt create mode 100644 public/sitemap.xml create mode 100644 scripts/check-nginx-spa-routes.mjs diff --git a/deploy/container/nginx.conf b/deploy/container/nginx.conf index be9dd0ebd..0f5bc237c 100644 --- a/deploy/container/nginx.conf +++ b/deploy/container/nginx.conf @@ -215,8 +215,18 @@ http { return 404; } + # BEGIN GENARRATIVE MAIN SPA ROUTES + location = / { + try_files /index.html =404; + } + + location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" { + try_files $uri /index.html =404; + } + # END GENARRATIVE MAIN SPA ROUTES + location / { - try_files $uri $uri/ /index.html; + try_files $uri $uri/ =404; } } } diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 62e87f148..b5a33d8e3 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -249,6 +249,28 @@ server { return 404; } + # BEGIN GENARRATIVE MAIN SPA ROUTES + location = / { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files /index.html =404; + } + + location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } + # END GENARRATIVE MAIN SPA ROUTES + location / { error_page 503 /maintenance.html; @@ -256,6 +278,6 @@ server { return 503; } - try_files $uri $uri/ /index.html; + try_files $uri $uri/ =404; } } diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index fa1a111b4..f4b3455dc 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -269,6 +269,28 @@ server { return 404; } + # BEGIN GENARRATIVE MAIN SPA ROUTES + location = / { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files /index.html =404; + } + + location ~* "^/(?:bark-battle|big-fish|child-motion-demo|creation|creation/baby-object-match|creation/baby-object-match/generating|creation/baby-object-match/result|creation/bark-battle|creation/bark-battle/generating|creation/bark-battle/result|creation/big-fish|creation/big-fish/generating|creation/big-fish/result|creation/creative-agent|creation/jump-hop|creation/jump-hop/generating|creation/jump-hop/result|creation/match3d|creation/match3d/generating|creation/match3d/result|creation/puzzle|creation/puzzle-clear|creation/puzzle-clear/generating|creation/puzzle-clear/result|creation/puzzle/generating|creation/puzzle/result|creation/rpg|creation/rpg/agent|creation/rpg/generating|creation/rpg/result|creation/square-hole|creation/square-hole/generating|creation/square-hole/result|creation/visual-novel|creation/visual-novel/generating|creation/visual-novel/result|creation/wooden-fish|creation/wooden-fish/generating|creation/wooden-fish/result|editor/canvas|gallery/jump-hop/detail|gallery/puzzle/detail|gallery/visual-novel/detail|match3d|project|puzzle|runtime/baby-love-drawing|runtime/baby-object-match|runtime/bark-battle|runtime/big-fish|runtime/jump-hop|runtime/match3d|runtime/puzzle|runtime/puzzle-clear|runtime/rpg/adventure|runtime/rpg/characters|runtime/square-hole|runtime/visual-novel|runtime/wooden-fish|works/detail|worlds/detail)/?$" { + error_page 503 /maintenance.html; + + if ($genarrative_maintenance) { + return 503; + } + + try_files $uri /index.html =404; + } + # END GENARRATIVE MAIN SPA ROUTES + location / { error_page 503 /maintenance.html; @@ -276,6 +298,6 @@ server { return 503; } - try_files $uri $uri/ /index.html; + try_files $uri $uri/ =404; } } diff --git a/deploy/pingora/nginx-route-parity.matrix.json b/deploy/pingora/nginx-route-parity.matrix.json index 8642b8651..d923df865 100644 --- a/deploy/pingora/nginx-route-parity.matrix.json +++ b/deploy/pingora/nginx-route-parity.matrix.json @@ -359,17 +359,93 @@ }, { "id": "web_spa_fallback", - "samplePath": "/some/spa/path", + "samplePath": "/creation/puzzle/result", "expect": { "kind": "static", "root": "web", "mode": "spa_fallback" }, "nginx": { - "production": ["location /", "try_files $uri $uri/ /index.html;"], - "development": ["location /", "try_files $uri $uri/ /index.html;"] + "production": [ + "# BEGIN GENARRATIVE MAIN SPA ROUTES", + "try_files $uri /index.html =404;" + ], + "development": [ + "# BEGIN GENARRATIVE MAIN SPA ROUTES", + "try_files $uri /index.html =404;" + ] }, - "docs": ["其它路径", "失败回退 `/index.html`"] + "docs": ["主站 SPA allowlist", "失败回退 `/index.html`"] + }, + { + "id": "web_spa_case_trailing_slash", + "samplePath": "/CREATION/PUZZLE/RESULT/", + "expect": { + "kind": "static", + "root": "web", + "mode": "spa_fallback" + }, + "nginx": { + "production": ["location ~*", "try_files $uri /index.html =404;"], + "development": ["location ~*", "try_files $uri /index.html =404;"] + }, + "docs": ["大小写不敏感", "一个尾部斜杠"] + }, + { + "id": "web_unknown_path_exact", + "samplePath": "/some/spa/path", + "expect": { + "kind": "static", + "root": "web", + "mode": "exact" + }, + "nginx": { + "production": ["location /", "try_files $uri $uri/ =404;"], + "development": ["location /", "try_files $uri $uri/ =404;"] + }, + "docs": ["其它 Web 路径", "缺失时返回真实 404"] + }, + { + "id": "creation_unknown_path_exact", + "samplePath": "/creation/not-exist", + "expect": { + "kind": "static", + "root": "web", + "mode": "exact" + }, + "nginx": { + "production": ["try_files $uri $uri/ =404;"], + "development": ["try_files $uri $uri/ =404;"] + }, + "docs": ["`/creation/not-exist`"] + }, + { + "id": "runtime_unknown_path_exact", + "samplePath": "/runtime/not-exist", + "expect": { + "kind": "static", + "root": "web", + "mode": "exact" + }, + "nginx": { + "production": ["try_files $uri $uri/ =404;"], + "development": ["try_files $uri $uri/ =404;"] + }, + "docs": ["`/runtime/not-exist`"] + }, + { + "id": "puzzle_unknown_path_exact", + "samplePath": "/puzzle/not-exist", + "expect": { + "kind": "static", + "root": "web", + "mode": "exact" + }, + "nginx": { + "production": ["try_files $uri $uri/ =404;"], + "development": ["try_files $uri $uri/ =404;"] + }, + "docs": ["`/puzzle/not-exist`"] } ] } diff --git a/docs/README.md b/docs/README.md index f30c79873..534082653 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ ## 快速入口 - [Agent 工作入口与执行准则](./%E3%80%90%E5%8D%8F%E4%BD%9C%E8%A7%84%E8%8C%83%E3%80%91Agent%E5%B7%A5%E4%BD%9C%E5%85%A5%E5%8F%A3%E4%B8%8E%E6%89%A7%E8%A1%8C%E5%87%86%E5%88%99-2026-06-22.md):复杂任务前的 Agent 阅读顺序、执行边界、技能路由、文档规则和验证口径。 +- [官网 SEO 地基实施约定](./technical/【SEO】官网SEO地基实施约定-2026-07-10.md):首页基础 head、robots/sitemap、唯一 H1、精确 SPA 路由与未知路径 404 的长期技术边界。 - [经验沉淀](./experience/README.md):项目开发经验、UI 交接、历史实现经验。 - [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。 - [系统设计](./design/README.md):玩法、关系、物品与对话设计。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 9881ef82a..555d10076 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3890,3 +3890,10 @@ - 性能:`api-server` 只在当前判定涉及的已启用 gate 配置了用户标签白名单时读取用户标签;不因无关 gate 或纯用户 ID / 百分比灰度触发额外标签读取。 - 影响范围:`feature_gate_config`、`spacetime-client` runtime facade、`api-server` 创作入口配置与路由熔断、`apps/admin-web` 灰度发布页。 - 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-runtime --manifest-path server-rs/Cargo.toml feature_gate`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml creation_entry_feature_gate`、`npm run admin-web:typecheck`、后台灰度页 Vitest、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-10 官网 SEO 与主站 SPA 404 边界 + +- 背景:主站 Nginx 和 Pingora 原先会把任意未知路径回退到 `index.html`,导致 soft 404;共享 `index.html` 也缺少首页 SEO head,robots 和 sitemap 请求会落入 SPA fallback。 +- 决策:新增真实 `robots.txt` 和仅首页的 `sitemap.xml`,首页共享 head 提供基础 SEO/OG/JSON-LD 文本但不使用未确认的 image/logo URL;首页 DOM 只保留一个稳定产品定位 H1。Nginx 与 Pingora 只允许当前完整 SPA 路径回退 `index.html`,同前缀未知路径必须返回 404;`/admin` 继续走独立子应用。路由变化必须同步三套 Nginx、Pingora、route parity matrix 和自动门禁。 +- 影响范围:`index.html`、`public/robots.txt`、`public/sitemap.xml`、首页组件、三套 Nginx、Pingora 网关和路由 parity 门禁。 +- 验证方式:前端定向测试与构建、`npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`npm run check:pingora-gateway-smoke`、`npm run check:encoding`、`git diff --check`,部署后同时抽查根级未知路径和 `/creation/not-exist` 等同前缀未知路径。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 42506a86f..c95d67ea6 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2893,3 +2893,11 @@ - 处理:`applyGateTarget` 进入不存在的新 target 时必须重置为新建态:`enabled=false`、`rolloutPercent=0`、allow / deny 列表为空,并使用 target 默认描述。只有显式点已有 gate 才 `fillForm` 复制服务端规则。 - 验证:`npm run test -- apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。 - 关联:`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.tsx`、`apps/admin-web/src/pages/AdminGrayReleaseConfigPage.test.tsx`。 + +## SPA 路由白名单不能只按一级目录放行 + +- 现象:`/not-exist` 已返回 404,但 `/creation/not-exist`、`/runtime/not-exist` 或 `/puzzle/not-exist` 仍返回 200 首页,搜索引擎继续判定为 soft 404。 +- 原因:Nginx 或 Pingora 使用 `/creation/*`、`/runtime/*` 等宽前缀作为 SPA fallback,前端对未知路径又回到平台首页;只验收根级未知 URL 无法发现该问题。 +- 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件,失败返回 404。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。 +- 验证:除全部真实 SPA 路径外,至少检查 `/not-exist`、`/creation/not-exist`、`/runtime/not-exist` 和 `/puzzle/not-exist` 均返回 404;维护模式仍保持页面 503 优先语义。 +- 关联:`src/routing/appRoutes.tsx`、`src/routing/appPageRoutes.ts`、`deploy/nginx/`、`deploy/container/nginx.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。 diff --git a/docs/technical/【SEO】官网SEO地基实施约定-2026-07-10.md b/docs/technical/【SEO】官网SEO地基实施约定-2026-07-10.md new file mode 100644 index 000000000..4c77207ab --- /dev/null +++ b/docs/technical/【SEO】官网SEO地基实施约定-2026-07-10.md @@ -0,0 +1,50 @@ +# 官网 SEO 地基实施约定 + +更新时间:`2026-07-10` + +## 本轮边界 + +本轮只建立陶泥儿中文首页的 SEO 基础设施,不新增英文站、长尾落地页、SSR、动态 sitemap、前端访问埋点或分享图。 + +- `public/robots.txt` 是真实静态文件;非 SEO 产品路径本轮阻止抓取,但不把 `Disallow` 解释为保证不收录。 +- `public/sitemap.xml` 本轮只包含 `https://www.genarrative.world/`,不写难以持续维护的 `lastmod`。 +- `index.html` 提供首页 title、description、canonical、robots、基础 OG/Twitter 文本和 JSON-LD;没有正式分享图时不写 `og:image`、`twitter:image` 或 JSON-LD `logo`。 +- 首页渲染后只有一个稳定、可见的产品定位 H1;动态作品名、卡片标题、按钮内部标题和隐藏 Tab 不作为 H1。 +- Nginx 和 Pingora 只对当前已知完整 SPA 路径回退 `index.html`;未知路径以及 `/creation/not-exist` 等同前缀未知路径返回 HTTP 404。 +- Nginx access log 继续使用现有 `$http_referer`,本轮不新增前端 pageview 或用户身份采集。 + +## 路由事实源 + +主站 SPA 路由以以下源码为事实源: + +- `src/routing/appRoutes.tsx` +- `src/routing/appPageRoutes.ts` + +`/creation/rpg/agent` 仍被现有刷新恢复链路使用,当前作为兼容深链保留。新增或删除前端路由时,必须同步三套 Nginx 配置、Pingora 路由、`deploy/pingora/nginx-route-parity.matrix.json` 和对应自动门禁。不得把 `/creation/*`、`/runtime/*` 等一级目录整体设为 SPA fallback。 + +后续新增 SEO 落地页时,还必须同时满足:返回 200、不被 robots.txt 阻止抓取、加入 sitemap,并提供独立 title、description、canonical、H1、正文和内链入口。纯 SPA 页面需要独立 head 时,应评估构建时静态 HTML、预渲染或 SSR。 + +## 验收口径 + +```bash +npm run build +npm run typecheck +npm run check:nginx-spa-routes +npm run check:pingora-route-parity +npm run check:pingora-gateway-smoke +npm run check:encoding +git diff --check +``` + +部署后至少验证: + +```bash +curl -I https://www.genarrative.world/robots.txt +curl -I https://www.genarrative.world/sitemap.xml +curl -I https://www.genarrative.world/not-exist-test +curl -I https://www.genarrative.world/creation/not-exist-test +curl -I https://www.genarrative.world/runtime/not-exist-test +curl -I https://www.genarrative.world/puzzle/not-exist-test +``` + +真实 SPA 路径不得误 404;以上未知路径必须返回 404。首页浏览器 DOM 应只有一个 H1,并确认桌面和移动端布局、导航、推荐流和创作入口没有因 SEO 文案变形。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index 4e22e53b8..9756a2fa7 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -42,6 +42,7 @@ cargo run -p pingora-gateway --manifest-path server-rs/Cargo.toml ```bash npm run check:pingora-gateway-smoke +npm run check:nginx-spa-routes npm run check:pingora-route-parity npm run check:nginx-pingora-canary npm run check:pingora-canary-docker @@ -59,9 +60,11 @@ npm run check:pingora-cutover-evidence-audit npm run check:pingora-release-readiness ``` -`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖 SPA fallback、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。 +`check:pingora-gateway-smoke` 会临时启动 mock `api-server`、mock SpacetimeDB、mock Gitea 和 `pingora-gateway`,覆盖精确主站 SPA fallback、大小写与尾部斜杠兼容、同前缀未知路径真实 404、后台静态路由、HTML / 普通静态资源 `no-cache`、Vite 指纹静态资源 immutable 缓存、静态 `ETag` / `Last-Modified` 与 `304` 协商缓存、静态 `HEAD` 响应、静态 Range、静态 access log method/path/status 对账、gzip 最小长度、小响应不压缩、图片资源不压缩、大响应压缩、ACME、TLS 直连、HTTP/2 ALPN、HTTP 到 HTTPS 重定向、内部路由拒绝、shadow probe、API 代理头(`Host` / `X-Forwarded-Host` / `X-Forwarded-Proto` / `X-Real-IP` / `X-Forwarded-For`)、Gitea Host 整站转发、请求体上限、429 接流保护、上游断连 / 超时 JSON 错误、维护模式、维护模式不拦截 Gitea Host 和 SpacetimeDB WebSocket Upgrade,并复用 `check-pingora-direct-live.mjs` 对临时 HTTPS / HTTP redirect / WSS subscribe 入口做 live smoke。该本地 fixture 会让首页同时引用普通静态资源和 Vite 指纹静态资源,direct live JSON 必须确认指纹资源 GET / HEAD / `Range: bytes=0-0` 以及 access log method/path/status 证据,避免正式直连前只证明普通静态读取。排查失败时可追加 `-- --verbose` 输出网关 stderr / stdout;已确认二进制无需重编时可追加 `-- --skip-build`。 -`check:pingora-route-parity` 读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由单测和本文档都覆盖同一组核心路由。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。 +`check:nginx-spa-routes` 从 `appPageRoutes.ts` 的 `STAGE_ROUTE_ENTRIES` / `APP_RUNTIME_ROUTES`、`appRoutes.tsx` 的精确路由判断和兼容恢复路径 `/creation/rpg/agent` 提取当前主站 SPA allowlist,确认生产、开发和容器三套 Nginx 模板集合一致,并验证大小写、尾部斜杠和 `/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 等未知反例。 + +`check:pingora-route-parity` 会先执行同一 Nginx SPA 路由门禁,再读取 `deploy/pingora/nginx-route-parity.matrix.json`,静态确认生产 / 开发 Nginx 模板、Pingora Rust 路由 allowlist / 单测和本文档都覆盖同一组核心路由。`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml matches_nginx_route_parity_matrix` 会读取同一份矩阵,逐条断言 `classify_path` 的路由结果、body limit 和接流保护分组。 `check:nginx-pingora-canary` 会静态校验 `deploy/nginx/snippets/genarrative-pingora-canary.conf` 的本机来源限制、handoff 响应头、probe token 占位、前缀 rewrite、低缓冲和 WebSocket Upgrade 设置,也会校验 `deploy/nginx/snippets/genarrative-pingora-realpath-canary.conf` 只能作为独立 loopback `server` 片段使用、默认监听 `127.0.0.1:18083`、写独立 access log、没有 rewrite、覆盖真实 `/api` / `/v1` / `/assets` 代表路径。本机安装了 Nginx 时脚本会额外把两个 snippet 包进临时 `http {}` 执行 `nginx -t`;需要在 CI / 目标 agent 上强制要求真实 Nginx 语法检查时执行 `node scripts/check-nginx-pingora-canary.mjs --require-nginx`。 @@ -539,7 +542,8 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | `/v1/database/{db}/subscribe`、`/v1/identity*` | 转发到 SpacetimeDB,保留 WebSocket Upgrade 头。 | | `/__genarrative_pingora/healthz` | 仅在携带 `X-Genarrative-Pingora-Probe` 且匹配配置 token 时返回 shadow JSON,否则 404。 | | `/v1/*`、`/generated-*`、`/healthz*`、`/readyz*` | 返回 404,保持生产公网不暴露口径。 | -| 其它路径 | 先读取静态文件或目录 index,失败回退 `/index.html`,HTML 默认 `no-cache`,并支持条件请求返回 `304` 与单段 `Range: bytes=` 返回 `206` / 越界返回 `416`。 | +| 主站 SPA allowlist | 只对当前前端完整路由及兼容恢复路径 `/creation/rpg/agent` 失败回退 `/index.html`;匹配大小写不敏感并允许一个尾部斜杠,HTML 默认 `no-cache`。 | +| 其它 Web 路径 | 只读取真实静态文件或目录 index,缺失时返回真实 404;`/creation/not-exist`、`/runtime/not-exist`、`/puzzle/not-exist` 不进入 SPA fallback。 | 维护模式下,API-like 路由返回 JSON `503`,Web 静态路由优先返回 `maintenance.html`,不存在时返回纯文本 `503`。 代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。 diff --git a/index.html b/index.html index bc00d59ee..1b183cb68 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,45 @@ content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" /> - 陶泥儿 + 陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台 + + + + + + + + + + + +
diff --git a/package.json b/package.json index 69954509c..d55d951c8 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "check:production-api-deploy": "node scripts/check-production-api-deploy.mjs", "check:pingora-gateway-smoke": "node scripts/check-pingora-gateway-smoke.mjs", "check:nginx-pingora-canary": "node scripts/check-nginx-pingora-canary.mjs", + "check:nginx-spa-routes": "node scripts/check-nginx-spa-routes.mjs", "check:pingora-route-parity": "node scripts/check-pingora-route-parity.mjs", "check:pingora-canary-live": "node scripts/check-pingora-canary-live.mjs", "check:pingora-canary-live-guard": "node scripts/check-pingora-canary-live-guard.mjs", diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 000000000..5a957b98f --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,21 @@ +User-agent: * +Allow: / + +Disallow: /api +Disallow: /v1 +Disallow: /admin +Disallow: /creation +Disallow: /runtime +Disallow: /editor +Disallow: /project +Disallow: /profile +Disallow: /works/detail +Disallow: /worlds/detail +Disallow: /gallery +Disallow: /puzzle +Disallow: /big-fish +Disallow: /match3d +Disallow: /bark-battle +Disallow: /child-motion-demo + +Sitemap: https://www.genarrative.world/sitemap.xml diff --git a/public/sitemap.xml b/public/sitemap.xml new file mode 100644 index 000000000..4f6156bd4 --- /dev/null +++ b/public/sitemap.xml @@ -0,0 +1,8 @@ + + + + https://www.genarrative.world/ + weekly + 1.0 + + diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs new file mode 100644 index 000000000..bfbc3a837 --- /dev/null +++ b/scripts/check-nginx-spa-routes.mjs @@ -0,0 +1,173 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs'; + +const APP_PAGE_ROUTES_PATH = 'src/routing/appPageRoutes.ts'; +const APP_ROUTES_PATH = 'src/routing/appRoutes.tsx'; +const COMPATIBILITY_ROUTES = ['/creation/rpg/agent']; +const NGINX_PATHS = [ + 'deploy/nginx/genarrative.conf', + 'deploy/nginx/genarrative-dev-http.conf', + 'deploy/container/nginx.conf', +]; +const SPA_BLOCK_START = '# BEGIN GENARRATIVE MAIN SPA ROUTES'; +const SPA_BLOCK_END = '# END GENARRATIVE MAIN SPA ROUTES'; +const UNKNOWN_ROUTE_SAMPLES = [ + '/unknown-root', + '/creation/not-exist', + '/runtime/not-exist', + '/puzzle/not-exist', +]; + +const failures = []; + +function fail(message) { + failures.push(message); +} + +function extractSourceBlock(source, pattern, label) { + const match = source.match(pattern); + if (!match) { + fail(`${label} 未找到。`); + return ''; + } + return match[1]; +} + +function collectExpectedMainSpaRoutes() { + const appPageRoutes = readFileSync(APP_PAGE_ROUTES_PATH, 'utf8'); + const appRoutes = readFileSync(APP_ROUTES_PATH, 'utf8'); + const stageEntries = extractSourceBlock( + appPageRoutes, + /const STAGE_ROUTE_ENTRIES = \[([\s\S]*?)\] as const/u, + `${APP_PAGE_ROUTES_PATH} STAGE_ROUTE_ENTRIES`, + ); + const runtimeEntries = extractSourceBlock( + appPageRoutes, + /export const APP_RUNTIME_ROUTES[^=]*= \{([\s\S]*?)\n\};/u, + `${APP_PAGE_ROUTES_PATH} APP_RUNTIME_ROUTES`, + ); + + const routes = [ + ...Array.from( + stageEntries.matchAll(/\[\s*'[^']+'\s*,\s*'([^']+)'\s*\]/gu), + (match) => match[1], + ), + ...Array.from( + runtimeEntries.matchAll(/'[^']+'\s*:\s*'([^']+)'/gu), + (match) => match[1], + ), + ...Array.from( + appRoutes.matchAll(/normalizedPath === '([^']+)'/gu), + (match) => match[1], + ), + ...COMPATIBILITY_ROUTES, + ]; + + const uniqueRoutes = [...new Set(routes)].sort(); + if (uniqueRoutes.length === 0) { + fail('未从前端路由源提取到主站 SPA 路由。'); + } + for (const route of uniqueRoutes) { + if (!/^\/(?:[a-z0-9-]+(?:\/[a-z0-9-]+)*)?$/u.test(route)) { + fail(`前端路由源包含门禁暂不支持的路径格式: ${route}`); + } + } + return uniqueRoutes; +} + +function compareRouteSets(actualRoutes, expectedRoutes, label) { + const actual = new Set(actualRoutes); + const expected = new Set(expectedRoutes); + const missing = expectedRoutes.filter((route) => !actual.has(route)); + const extra = actualRoutes.filter((route) => !expected.has(route)); + if (missing.length > 0) { + fail(`${label} 缺少 SPA 路由: ${missing.join(', ')}`); + } + if (extra.length > 0) { + fail(`${label} 包含非当前路由: ${extra.join(', ')}`); + } +} + +function validateNginxRoutes(nginxPath, expectedRoutes) { + const source = readFileSync(nginxPath, 'utf8'); + const blockStart = source.indexOf(SPA_BLOCK_START); + const blockEnd = source.indexOf(SPA_BLOCK_END); + if (blockStart < 0 || blockEnd <= blockStart) { + fail(`${nginxPath} 缺少完整 SPA allowlist 标记。`); + return; + } + + const block = source.slice(blockStart, blockEnd + SPA_BLOCK_END.length); + if (!/location\s+=\s+\/\s*\{/u.test(block)) { + fail(`${nginxPath} SPA allowlist 缺少根路径精确 location。`); + } + if (!block.includes('try_files /index.html =404;')) { + fail(`${nginxPath} 根路径没有精确回退 index.html。`); + } + if (!block.includes('try_files $uri /index.html =404;')) { + fail(`${nginxPath} SPA allowlist 没有精确回退 index.html。`); + } + + const regexMatch = block.match(/location\s+~\*\s+"([^"]+)"\s*\{/u); + if (!regexMatch) { + fail(`${nginxPath} 缺少大小写不敏感的 SPA allowlist regex location。`); + return; + } + + const nginxPattern = regexMatch[1]; + const alternativesMatch = nginxPattern.match(/^\^\/\(\?:(.+)\)\/\?\$$/u); + if (!alternativesMatch) { + fail(`${nginxPath} SPA allowlist 必须锚定完整路径并允许一个尾部斜杠。`); + return; + } + + const configuredRoutes = [ + '/', + ...alternativesMatch[1].split('|').map((route) => `/${route}`), + ].sort(); + compareRouteSets(configuredRoutes, expectedRoutes, nginxPath); + + const matcher = new RegExp(nginxPattern, 'iu'); + for (const route of expectedRoutes.filter((candidate) => candidate !== '/')) { + if (!matcher.test(route)) { + fail(`${nginxPath} SPA allowlist 未匹配完整路径: ${route}`); + } + if (!matcher.test(`${route.toUpperCase()}/`)) { + fail(`${nginxPath} SPA allowlist 未允许大小写差异和尾部斜杠: ${route}`); + } + } + for (const route of UNKNOWN_ROUTE_SAMPLES) { + if (matcher.test(route) || matcher.test(`${route}/`)) { + fail(`${nginxPath} SPA allowlist 错误接收未知路径: ${route}`); + } + } + + const defaultLocation = source.slice(blockEnd + SPA_BLOCK_END.length); + if (!defaultLocation.includes('try_files $uri $uri/ =404;')) { + fail( + `${nginxPath} 未命中 SPA allowlist 的路径必须只读真实静态文件并返回 404。`, + ); + } + if (defaultLocation.includes('try_files $uri $uri/ /index.html;')) { + fail(`${nginxPath} 默认 location 仍存在全路径 SPA fallback。`); + } +} + +export const expectedMainSpaRoutes = collectExpectedMainSpaRoutes(); + +for (const nginxPath of NGINX_PATHS) { + validateNginxRoutes(nginxPath, expectedMainSpaRoutes); +} + +if (failures.length > 0) { + console.error('[check:nginx-spa-routes] FAILED'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log( + `[check:nginx-spa-routes] OK (${expectedMainSpaRoutes.length} SPA routes, ${NGINX_PATHS.length} Nginx templates)`, +); diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index ef2aac8c1..421c394ed 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -568,15 +568,36 @@ async function runSmokeCases( ); await expectHttp( baseUrl, - '/some/deep/link', + '/creation/puzzle/result', 200, 'site-shell', - '主站深链回退 index.html', + '主站 allowlist 深链回退 index.html', { validate: (response) => response.headers['cache-control'] === 'no-cache', }, ); + await expectHttp( + baseUrl, + '/CREATION/PUZZLE/RESULT/', + 200, + 'site-shell', + '主站 allowlist 允许大小写差异和尾部斜杠', + ); + for (const unknownPath of [ + '/some/deep/link', + '/creation/not-exist', + '/runtime/not-exist', + '/puzzle/not-exist', + ]) { + await expectHttp( + baseUrl, + unknownPath, + 404, + '', + `主站未知路径返回真实 404: ${unknownPath}`, + ); + } await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', { validate: (response) => response.headers.location === '/admin/', }); diff --git a/scripts/check-pingora-route-parity.mjs b/scripts/check-pingora-route-parity.mjs index 78372e43b..cf56ea3dd 100644 --- a/scripts/check-pingora-route-parity.mjs +++ b/scripts/check-pingora-route-parity.mjs @@ -2,6 +2,8 @@ import { readFileSync } from 'node:fs'; +import { expectedMainSpaRoutes } from './check-nginx-spa-routes.mjs'; + const MATRIX_PATH = 'deploy/pingora/nginx-route-parity.matrix.json'; const PRODUCTION_NGINX_PATH = 'deploy/nginx/genarrative.conf'; const DEVELOPMENT_NGINX_PATH = 'deploy/nginx/genarrative-dev-http.conf'; @@ -46,6 +48,11 @@ const REQUIRED_ROUTE_IDS = [ 'readyz_forbidden', 'generated_assets_forbidden', 'web_spa_fallback', + 'web_spa_case_trailing_slash', + 'web_unknown_path_exact', + 'creation_unknown_path_exact', + 'runtime_unknown_path_exact', + 'puzzle_unknown_path_exact', ]; const files = { @@ -221,6 +228,9 @@ function validateRustTestUsesMatrix() { 'serde_json::from_str(ROUTE_PARITY_MATRIX_JSON)', 'protection_class_for_route(&route, &case.sample_path)', 'fn matches_nginx_route_parity_matrix()', + 'fn is_main_spa_path(path: &str)', + "path.strip_suffix('/')", + 'normalized.eq_ignore_ascii_case(candidate)', ]) { if (!pingoraGatewaySource.includes(fragment)) { fail(`Pingora Rust 路由 parity 测试缺少矩阵接入片段: ${fragment}`); @@ -228,8 +238,34 @@ function validateRustTestUsesMatrix() { } } +function validateRustMainSpaRoutes() { + const routeBlock = pingoraGatewaySource.match( + /const MAIN_SPA_PATHS: &\[&str\] = &\[([\s\S]*?)\n\];/u, + ); + if (!routeBlock) { + fail('Pingora Rust 缺少 MAIN_SPA_PATHS allowlist。'); + return; + } + + const rustRoutes = Array.from( + routeBlock[1].matchAll(/"([^"]+)"/gu), + (match) => match[1], + ).sort(); + const expected = new Set(expectedMainSpaRoutes); + const actual = new Set(rustRoutes); + const missing = expectedMainSpaRoutes.filter((route) => !actual.has(route)); + const extra = rustRoutes.filter((route) => !expected.has(route)); + if (missing.length > 0) { + fail(`Pingora MAIN_SPA_PATHS 缺少当前前端路由: ${missing.join(', ')}`); + } + if (extra.length > 0) { + fail(`Pingora MAIN_SPA_PATHS 包含非当前前端路由: ${extra.join(', ')}`); + } +} + validateMatrixShape(); validateRustTestUsesMatrix(); +validateRustMainSpaRoutes(); if (failures.length > 0) { console.error('[check:pingora-route-parity] FAILED'); diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 4978a84c4..aa5f9f09d 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -54,6 +54,70 @@ const SHADOW_PROBE_HEADER: &str = "x-genarrative-pingora-probe"; const PAYLOAD_TOO_LARGE_CONTEXT: &str = "genarrative_payload_too_large"; const PROTECTION_STATE_TTL: Duration = Duration::from_secs(600); const PROTECTION_CLEANUP_INTERVAL: Duration = Duration::from_secs(60); +const MAIN_SPA_PATHS: &[&str] = &[ + "/", + "/bark-battle", + "/big-fish", + "/child-motion-demo", + "/creation", + "/creation/baby-object-match", + "/creation/baby-object-match/generating", + "/creation/baby-object-match/result", + "/creation/bark-battle", + "/creation/bark-battle/generating", + "/creation/bark-battle/result", + "/creation/big-fish", + "/creation/big-fish/generating", + "/creation/big-fish/result", + "/creation/creative-agent", + "/creation/jump-hop", + "/creation/jump-hop/generating", + "/creation/jump-hop/result", + "/creation/match3d", + "/creation/match3d/generating", + "/creation/match3d/result", + "/creation/puzzle", + "/creation/puzzle-clear", + "/creation/puzzle-clear/generating", + "/creation/puzzle-clear/result", + "/creation/puzzle/generating", + "/creation/puzzle/result", + "/creation/rpg", + "/creation/rpg/agent", + "/creation/rpg/generating", + "/creation/rpg/result", + "/creation/square-hole", + "/creation/square-hole/generating", + "/creation/square-hole/result", + "/creation/visual-novel", + "/creation/visual-novel/generating", + "/creation/visual-novel/result", + "/creation/wooden-fish", + "/creation/wooden-fish/generating", + "/creation/wooden-fish/result", + "/editor/canvas", + "/gallery/jump-hop/detail", + "/gallery/puzzle/detail", + "/gallery/visual-novel/detail", + "/match3d", + "/project", + "/puzzle", + "/runtime/baby-love-drawing", + "/runtime/baby-object-match", + "/runtime/bark-battle", + "/runtime/big-fish", + "/runtime/jump-hop", + "/runtime/match3d", + "/runtime/puzzle", + "/runtime/puzzle-clear", + "/runtime/rpg/adventure", + "/runtime/rpg/characters", + "/runtime/square-hole", + "/runtime/visual-novel", + "/runtime/wooden-fish", + "/works/detail", + "/worlds/detail", +]; #[derive(Clone, Debug)] struct GatewayConfig { @@ -1887,12 +1951,31 @@ fn classify_path(path: &str) -> RouteDecision { }); } + if is_main_spa_path(path) { + return RouteDecision::Local(LocalResponse::Static { + root: StaticRoot::Web, + mode: StaticMode::SpaFallback, + }); + } + RouteDecision::Local(LocalResponse::Static { root: StaticRoot::Web, - mode: StaticMode::SpaFallback, + mode: StaticMode::Exact, }) } +fn is_main_spa_path(path: &str) -> bool { + let normalized = if path.len() > 1 { + path.strip_suffix('/').unwrap_or(path) + } else { + path + }; + + MAIN_SPA_PATHS + .iter() + .any(|candidate| normalized.eq_ignore_ascii_case(candidate)) +} + fn classify_http_redirect_path(path: &str) -> RouteDecision { if path.starts_with("/.well-known/acme-challenge/") { return RouteDecision::Local(LocalResponse::Static { diff --git a/src/App.test.tsx b/src/App.test.tsx index 65056755e..c9378b342 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -161,7 +161,9 @@ describe('App title sync', () => { test('主站阶段变化会同步浏览器与宿主标题', () => { renderApp(); - expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿'); + expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( + '陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台', + ); act(() => { fireEvent.click(screen.getByRole('button', { name: '打开拼图创作' })); @@ -187,7 +189,9 @@ describe('App title sync', () => { fireEvent.click(screen.getByRole('button', { name: '退出 RPG' })); }); - expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith('陶泥儿'); + expect(appTitleMock.syncAppTitle).toHaveBeenLastCalledWith( + '陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台', + ); }); test('启动时回读宿主 runtime 后刷新壳能力 UI', async () => { diff --git a/src/components/creation-home/CreationLandingView.test.tsx b/src/components/creation-home/CreationLandingView.test.tsx index 970e66e09..0ae26fb28 100644 --- a/src/components/creation-home/CreationLandingView.test.tsx +++ b/src/components/creation-home/CreationLandingView.test.tsx @@ -158,11 +158,11 @@ describe('CreationLandingView', () => { expect(screen.getByRole('main', { name: '陶泥儿创作主页' })).toBeTruthy(); expect( screen.getByRole('heading', { - name: '陶泥儿 - 开启全民精品游戏创作', + name: '游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材', }), ).toBeTruthy(); expect( - screen.getByText('登录即送100泥点,可以免费制作50个素材'), + screen.getByText(/陶泥儿 Genarrative 以美术 Agent 与无限画布/u), ).toBeTruthy(); expect(screen.getByRole('heading', { name: '创作工具' })).toBeTruthy(); expect(screen.getByText('游戏视觉规范')).toBeTruthy(); diff --git a/src/components/creation-home/CreationLandingView.tsx b/src/components/creation-home/CreationLandingView.tsx index d4c989cdb..d44e29181 100644 --- a/src/components/creation-home/CreationLandingView.tsx +++ b/src/components/creation-home/CreationLandingView.tsx @@ -859,8 +859,13 @@ export function CreationLandingView({
陶泥儿创作工具 -

陶泥儿 - 开启全民精品游戏创作

-

登录即送100泥点,可以免费制作50个素材

+

+ 游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材 +

+

+ 陶泥儿 Genarrative 以美术 Agent + 与无限画布帮助个人创作者准备角色、场景与宣发素材;登录即送100泥点,可以免费制作50个素材。 +

-

欢迎

+

欢迎

尼嚎,新陶泥er!
diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index 3547f9a88..f4a67f022 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -378,6 +378,9 @@ const { }; }); +const HOME_SEO_HEADING = + '游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材'; + const { mockGetPublicAuthUserByCode, mockGetPublicAuthUserById, @@ -4280,6 +4283,39 @@ test('desktop home shows empty states when public shelves are unavailable', () = expect(screen.getByText('暂时还没有推荐作品。')).toBeTruthy(); }); +test('desktop home exposes one product h1 and section h2 headings', () => { + renderLoggedOutHomeView(vi.fn(), {}, 'home', true); + + const productHeading = screen.getByRole('heading', { + level: 1, + name: HOME_SEO_HEADING, + }); + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(productHeading.closest('button')).toBeNull(); + expect( + screen.getByRole('heading', { level: 2, name: '今日游戏' }), + ).toBeTruthy(); + expect( + screen.getByRole('heading', { level: 2, name: '推荐' }), + ).toBeTruthy(); + expect( + screen.getByRole('heading', { level: 2, name: '作品分类' }), + ).toBeTruthy(); +}); + +test('mobile shell exposes one product h1 outside interactive content', () => { + renderLoggedOutHomeView(vi.fn(), {}, 'profile'); + + const productHeading = screen.getByRole('heading', { + level: 1, + name: HOME_SEO_HEADING, + }); + + expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); + expect(productHeading.closest('button')).toBeNull(); +}); + test('logged in create tab shows real wallet balance beside the brand', () => { mockNarrowMobileLayout(); diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 7670d1fcb..543ddc4d8 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1020,13 +1020,27 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {

{detail}
-
+

{title} -

+
); } +function HomeSeoIntro() { + return ( +
+

+ 游戏美术AI工作台,让个人创作者更快做出角色、场景与宣发素材 +

+

+ 在陶泥儿 Genarrative,用美术 Agent + 快速生成与迭代素材,再在无限画布中整理角色、场景和宣发灵感。 +

+
+ ); +} + function PublicCodeSearchBar({ value, onChange, @@ -4578,6 +4592,8 @@ export function RpgEntryHomeView({ const desktopHomeContent: ReactNode = (
+ + {platformError ? ( ) : null} + +
{tabPanels}
diff --git a/src/services/appTitle.test.ts b/src/services/appTitle.test.ts index a1e4cff78..59f7bf3d5 100644 --- a/src/services/appTitle.test.ts +++ b/src/services/appTitle.test.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import { + APP_HOME_TITLE, resolveAppTitleForSelectionStage, syncAppTitle, } from './appTitle'; @@ -22,7 +23,10 @@ afterEach(() => { describe('appTitle', () => { test('按平台阶段生成可读页面标题', () => { - expect(resolveAppTitleForSelectionStage('platform')).toBe('陶泥儿'); + expect(resolveAppTitleForSelectionStage('platform')).toBe(APP_HOME_TITLE); + expect(resolveAppTitleForSelectionStage('creation-home')).toBe( + APP_HOME_TITLE, + ); expect(resolveAppTitleForSelectionStage('work-detail')).toBe( '作品详情 - 陶泥儿', ); diff --git a/src/services/appTitle.ts b/src/services/appTitle.ts index 4a411b5d1..ef25a00d0 100644 --- a/src/services/appTitle.ts +++ b/src/services/appTitle.ts @@ -2,6 +2,8 @@ import type { SelectionStage } from '../components/platform-entry'; import { setHostAppTitle } from './host-bridge/hostBridge'; const APP_TITLE_BRAND = '陶泥儿'; +export const APP_HOME_TITLE = + '陶泥儿 Genarrative|游戏美术AI创作工具与美术Agent工作台'; const APP_TITLE_BY_SELECTION_STAGE: Partial> = { 'agent-workspace': 'RPG 创作', @@ -21,6 +23,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Partial> = { 'creative-agent-workspace': '创意 Agent', 'custom-world-generating': 'RPG 生成中', 'custom-world-result': 'RPG 结果', + 'creation-home': APP_HOME_TITLE, detail: '世界详情', 'jump-hop-gallery-detail': '跳一跳详情', 'jump-hop-generating': '跳一跳生成中', @@ -31,7 +34,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Partial> = { 'match3d-generating': '抓大鹅生成中', 'match3d-result': '抓大鹅结果', 'match3d-runtime': '抓大鹅', - platform: APP_TITLE_BRAND, + platform: APP_HOME_TITLE, 'profile-feedback': '反馈与投诉', 'puzzle-agent-workspace': '拼图创作', 'puzzle-clear-generating': '拼消消生成中', @@ -61,7 +64,7 @@ const APP_TITLE_BY_SELECTION_STAGE: Partial> = { export function resolveAppTitleForSelectionStage(stage: SelectionStage) { const title = APP_TITLE_BY_SELECTION_STAGE[stage] ?? APP_TITLE_BRAND; - if (title === APP_TITLE_BRAND) { + if (title === APP_TITLE_BRAND || title === APP_HOME_TITLE) { return title; } From c483980c9c8032eceff126d1e21547ca1221b31c Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 10 Jul 2026 16:20:21 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Pingora=20=E9=83=A8?= =?UTF-8?q?=E7=BD=B2=E6=A3=80=E6=9F=A5=E8=AF=AF=E6=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 显式传播 systemd 与 EnvironmentFile 提取失败,首错后立即终止部署检查 补充 direct-entry 与 systemctl cat 失败回归,阻止空环境连带误报 同步记录旧 release 残留 capability 的安全清理与验收口径 --- docs/project-memory/shared-memory/pitfalls.md | 2 + ...发运维】本地开发验证与生产运维-2026-05-15.md | 2 + scripts/check-production-api-deploy.mjs | 65 +++++++++++++++++++ scripts/deploy/production-api-deploy.sh | 34 ++++++---- 4 files changed, 90 insertions(+), 13 deletions(-) diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index c95d67ea6..718bef3cb 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -379,6 +379,8 @@ - 现象:`/etc/genarrative/pingora-gateway.env` 已把 `GENARRATIVE_PINGORA_GATEWAY_TLS_LISTEN` / `HTTP_REDIRECT_LISTEN` 改到 `0.0.0.0:443` / `0.0.0.0:80`,但 `genarrative-pingora-gateway.service` 启动失败,日志出现低端口绑定权限错误。 - 原因:默认 service 用非 root `genarrative` 用户运行,并且主模板为了保持 shadow 安全边界不带 `CAP_NET_BIND_SERVICE`。低端口直连必须通过显式 systemd drop-in 单独授予 capability;同时 Certbot 私钥默认未必允许 `genarrative` 读取,Nginx 也可能仍占用 `80/443`。另一个常见误区是 API release 只带 `pingora-direct-enable.sh` / rollback 壳脚本,却漏带 `pingora-current-release-audit.mjs`、`pingora-direct-rehearsal-status.mjs`、`check-pingora-direct-preflight.mjs`、`check-pingora-direct-live.mjs`、`deploy/systemd/`、`deploy/env/` 或 `deploy/pingora/`,导致从 `/opt/genarrative/current` 启用时依赖 Jenkins 工作区、源码 checkout 或 `/etc` 里某份参考模板;或者 release 已经包含新版 `pingora-gateway`,但已运行的 shadow / canary / direct service 没有随 `current` 链接切换重启,仍在跑旧二进制。Jenkins API Build、API Deploy 和 Full Build-And-Deploy 默认要求 Pingora 产物,并用 `--require-pingora-gateway` 在部署阶段硬校验;手工本地 API 包仍需显式 `--include-pingora-gateway` 才会把二进制、checksum 和 manifest artifact 写入发布包。Server-Provision 安装到 `/etc/genarrative/pingora/genarrative-pingora-gateway-direct-entry.conf` 的 drop-in 只用于人工审阅和显式覆盖;直连启用脚本默认必须读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,否则旧 `/etc` 模板会掩盖发布包缺失。API deploy 脚本本身也不能继续用部署工作区根部的 `scripts/deploy/production-api-deploy.sh`,否则 Jenkins workspace 里的脚本会掩盖 `build/` 发布包缺少 deploy / maintenance 同目录脚本的问题;备份脚本、健康巡检脚本和 env 示例目录同样不能从部署工作区兜底,切换命令证据脚本也不能从部署工作区兜底,否则 current release 会和上游构建归档漂移。Pingora 直连依赖、备份脚本、巡检脚本、env 示例目录和 API deploy 执行入口都必须来自上游发布产物;随包 `api-server.sha256` 和可选 `pingora-gateway.sha256` 也必须复制进 current release,供随包 current release 自审校验二进制;随包 `deploy/pingora/pingora-gateway.env.example` 也不能只检查存在,还要保持 gzip-only、不信任 XFF、前置代理确认关闭、接流保护开启和空 probe token 这些生产安全默认值;`production-api-deploy.sh` 发现缺失时应在 current 切换前 fail-fast、清理 staging 并退出本次打开的维护模式,不应从部署工作区兜底补齐;所有 API 发布包都必须携带 `release-manifest.json` 且登记 `api-server` artifact,发布包包含 Pingora 时还必须登记 `pingora-gateway` artifact,否则 deploy 应在切换 current 前失败;deploy 必须要求 release root、current link 和 api env file 都是绝对路径,release version 以数字或字母开头并拒绝点目录,再先写 staging release,全部复制完成后用非合并语义提升为正式 release,失败时清理 staging 且不留下正式 release,同版本 release 已存在、提升前竞态出现或 current 路径不是符号链接时拒绝覆盖 / 合并,避免旧文件混入 current;发布包包含 Pingora 时,deploy 必须先确认 systemd 最终配置没有 direct-entry `CAP_NET_BIND_SERVICE`、env 仍是 `127.0.0.1:18081` shadow 且未配置 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,再提升 release、切换 current 并 `restart` Pingora shadow;配置不安全时必须在切换 current 前失败并退出本次打开的维护模式,current 切换后的 readiness / 服务重启失败仍保留维护模式。 +- 踩坑补充:Bash 的进程替换 `< <(...)` 不会自动把生产者子进程的失败状态传给消费循环。Pingora systemd 检查若在子进程发现 `CAP_NET_BIND_SERVICE` 后直接退出,父函数仍可能继续用空列表输出“缺少 EnvironmentFile”,外层命令替换又继续用空 env 输出“LISTEN 为空”,形成三条互相矛盾的错误。部署前检查必须先捕获并显式检查配置提取命令的退出状态,再解析 EnvironmentFile;首错失败后立即返回。回归用 `npm run check:production-api-deploy` 的 direct-entry fixture 同时断言后两条误报不存在。 +- 踩坑补充:旧 release 可能没有 `pingora-gateway` 二进制,但 systemd 仍残留历史 `direct-entry.conf`,同时 Nginx 已正常接回 `80/443`、Pingora inactive、env 已是 shadow。此时不要直接执行当前 `pingora-direct-rollback.sh --apply`:脚本删除 drop-in 后会固定重启 Pingora,因 current 二进制不存在而中止,后续 Nginx reload/smoke 不会执行。先确认 current 确实无可执行网关、Pingora inactive、env 已完整恢复 shadow、Nginx 配置与公网 smoke 正常,再以单次 fail-fast 运维命令删除 stale drop-in、`daemon-reload`、复核 capability/DropInPaths 已清空,随后 reload(若 inactive 则 start)Nginx,并复核 Nginx/API/SpacetimeDB、正式 vhost smoke 和 health patrol nginx 模式;不要伪造 `--require-pingora-shadow` 验收。下一次包含 Pingora artifact 的 API Deploy 会在切换 current 后启动新 shadow 网关。 - 处理:确认真实 TLS 证书和 redirect env 已写入 `/etc/genarrative/pingora-gateway.env`、service 模板和 `systemctl cat` 最终配置读取的 `EnvironmentFile=` 都包含这份 env、当前执行用户和 `genarrative-pingora-gateway.service` 的 `User=` 服务用户都能读取证书链 / 私钥、current release 的 `pingora-gateway` 已存在且可执行、Nginx 或其它进程已释放 `80/443` 后,先用 `npm run plan:pingora-direct-cutover -- --require-direct ...` 生成只读 JSON runbook,并逐条审阅 Host 与回退巡检入口确认、current release 自包含自审、current release preflight、启用前基础 readiness、direct enable dry-run、direct enable apply、启用后 `--require-direct` 复核、rollback dry-run、rollback apply、回退后 health patrol 切回 Nginx 并恢复切换前 public base URL / Host、回退后 health patrol env 复核;runbook 只用于审阅,不修改系统。正式 runbook 中 `--direct-redirect-host`、`--rollback-nginx-smoke-host` 和 `--direct-host` 必须使用同一 hostname,只允许端口不同,避免 redirect 或回退 smoke 各自验证到不同入口;同时必须提供 `--rollback-health-patrol-public-base-url <切换前Nginx巡检入口>`,若切换前 Nginx 巡检需要 Host 覆盖,再追加 `--rollback-health-patrol-public-host <切换前Host>`,确认步骤会展示回退后要恢复的 public base URL / Host,避免回退 runbook 把现场巡检入口覆盖成仓库默认值;如需把回退后 Pingora shadow 探针复核纳入 runbook,追加 `--rollback-pingora-shadow-probe-url` / `--rollback-pingora-shadow-probe-token`,JSON 输出会隐藏 token 原文。随后先执行 `/opt/genarrative/current/scripts/ops/pingora-current-release-audit.mjs --release-root /opt/genarrative/current --require-pingora-gateway --systemd-show`,再 dry-run `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --no-status`,最后执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-enable.sh --apply --preflight-env-file /etc/genarrative/pingora-gateway.env --preflight-check-cert-readable --preflight-check-service-env-file --preflight-check-service-user-cert-readable --preflight-check-service-binary-executable --preflight-check-ports-free --direct-https-base-url https://127.0.0.1 --direct-http-base-url http://127.0.0.1 --direct-host <域名> --direct-redirect-host <域名或host:port> --direct-spacetime-database <库名> --direct-pingora-access-log /var/log/genarrative/pingora-gateway.access.log`,由脚本先跑 direct preflight,再安装 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 和 `EnvironmentFile=/etc/genarrative/pingora-gateway.env` 已生效、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`、用 `systemctl is-active` 确认服务 active,再以 JSON 模式执行 direct live smoke,验证 HTTPS / HTTP redirect / ACME / WSS 101 和 Pingora access log request_id 落盘,并要求 `direct-access-log` 结构化结果 `matchedCount == checked`、`missingCount=0`、`mismatchCount=0`;如果 direct live 退出 0 但缺少该结构化证据,也必须视为启用失败。直连启用后同步调整 `/etc/genarrative/health-patrol.env`:设置 `GENARRATIVE_HEALTH_PATROL_GATEWAY_MODE=pingora-direct`,本机打 `127.0.0.1` 时设置 `GENARRATIVE_HEALTH_PATROL_PUBLIC_HOST=<域名>`,否则巡检会继续按 Nginx 模式误报。验证失败时执行 `/opt/genarrative/current/scripts/deploy/pingora-direct-rollback.sh --apply --reload-nginx --nginx-smoke-url https://<域名>/ --nginx-smoke-expect-body ''` 或 `npm run deploy:pingora-direct-rollback -- --apply --reload-nginx --nginx-smoke-url https://<域名>/ --nginx-smoke-expect-body ''`;回退脚本先跑 `nginx -t`,通过后才移除 drop-in、reload systemd、重启 Pingora,并用 `systemctl cat` 核验 capability 已移除、用 `systemctl show ... ExecStart` 核验最终 service 仍指向随包主 service 模板里的 current release `pingora-gateway`,随后 reload Nginx、确认 Nginx service 仍为 active,并用 curl smoke URL 证明公网入口已回到 Nginx;回退 smoke URL/body 必须来自切换前真实 Nginx 入口,不要继续用固定 `http://127.0.0.1/healthz` 与 `"ok":true`;回退脚本 `--apply` 不允许省略 `--reload-nginx` 或 `--nginx-smoke-url`,当 smoke URL 指向本机地址时必须同时提供 `--nginx-smoke-host <域名>`,且 host 值不能包含 URL、路径或查询;回退后把 health patrol gateway mode 改回 `nginx`,恢复切换前 public base URL / Host,并用 `node -- /opt/genarrative/current/scripts/check-production-health-patrol-env.mjs --env-file /etc/genarrative/health-patrol.env --expected-gateway-mode nginx --expected-public-base-url <切换前Nginx巡检入口> --require-empty-public-host` 复核;若切换前 Nginx 巡检需要 Host 覆盖,则把 `--require-empty-public-host` 换成 `--expected-public-host <切换前Host>`。若 env 已在回退命令前切回 Nginx,也可给 rollback 脚本追加 `--health-patrol-env-file /etc/genarrative/health-patrol.env --health-patrol-expected-public-base-url <切换前Nginx巡检入口> --health-patrol-require-empty-public-host` 让它在 Nginx smoke 后自动复核;切换前 Nginx 巡检需要 Host 覆盖时把最后一项换成 `--health-patrol-expected-public-host <切换前Host>`。若要同时证明 Pingora shadow 高端口仍活着,追加 `--pingora-shadow-probe-url http://127.0.0.1:18081/__genarrative_pingora/healthz --pingora-shadow-probe-token `,脚本会隐藏 token 并要求响应为 `gateway=pingora-shadow`。 - 处理补充:不要直接 chmod `/etc/letsencrypt/live` 或 `archive` 来让 Pingora 读取证书;Certbot live 路径通常是 symlink,即使 `stat -L` 看起来是普通文件,父目录权限也会让非 root `genarrative` 用户不可达。先用随包 `node -- /opt/genarrative/current/scripts/deploy/pingora-tls-cert-sync.mjs --apply --source-cert-file /etc/letsencrypt/live/<域名>/fullchain.pem --source-key-file /etc/letsencrypt/live/<域名>/privkey.pem --target-dir /etc/genarrative/pingora-tls/<域名>` 把证书同步到 Pingora 私有目录,再让 `GENARRATIVE_PINGORA_GATEWAY_TLS_CERT_FILE` / `TLS_KEY_FILE` 指向 `/etc/genarrative/pingora-tls/<域名>/fullchain.pem` 和 `privkey.pem`。脚本默认 dry-run,`--apply` 才写入,目标目录默认 `root:genarrative 0750`,文件默认 `root:genarrative 0640`,并拒绝符号链接目标目录或目标文件。 - 处理补充:不要在切换窗口手工编辑 `/etc/genarrative/health-patrol.env` 的三项网关变量;使用 `node -- /opt/genarrative/current/scripts/deploy/pingora-health-patrol-env-switch.mjs --apply --env-file /etc/genarrative/health-patrol.env --gateway-mode pingora-direct --public-base-url <直连HTTPS入口> --public-host <域名>` 切到直连,回退前用同一脚本传 `--gateway-mode nginx --public-base-url <切换前Nginx巡检入口>` 并按切换前记录选择 `--clear-public-host` 或 `--public-host <切换前Host>`。脚本只改 gateway mode / public base URL / public Host,并立即复用随包 env 复核脚本,减少空 Host 和旧值残留;生产巡检、env 复核和 env 切换脚本读取的布尔 env 都必须是明确布尔值,非法值直接失败,不能把拼写错误当成 false;env 复核脚本的 `--env-file` 与 env 切换脚本的 `--env-file` / `--check-script` 必须是绝对路径且不能是文件系统根目录,也不能包含换行或 NUL;env 切换脚本写入的 public base URL / Host 同样不能包含换行或 NUL。Node 22 已内置 `--env-file` 启动参数,直接用 `node script.mjs --env-file ...` 或 shebang 执行 `.mjs --env-file ...` 都可能让 Node 抢走业务参数;所有这类命令都必须写成 `node -- script.mjs --env-file ...`,或通过已内置 `node --` 的 npm script 执行。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index a7feb482c..8bf64209d 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -378,6 +378,8 @@ cat /var/lib/genarrative/health-patrol/status.json `Genarrative-Api-Build` 的 Jenkins 归档产物必须包含 `build//api-server`、`api-server.sha256`、`release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`scripts/database-backup-to-oss.mjs`、`scripts/ops/production-health-patrol.mjs`、`scripts/ops/pingora-current-release-audit.mjs`、`scripts/ops/pingora-cutover-status-snapshot.mjs`、`scripts/ops/pingora-cutover-evidence-bundle.mjs`、`scripts/ops/pingora-cutover-command-evidence.mjs`、`scripts/ops/pingora-cutover-evidence-verify.mjs`、`scripts/ops/pingora-cutover-evidence-audit.mjs`、`scripts/check-pingora-direct-preflight.mjs`、`scripts/check-pingora-direct-live.mjs`、`scripts/check-pingora-canary-access-log-parity.mjs`、`scripts/check-production-health-patrol-env.mjs`、`scripts/deploy/pingora-direct-enable.sh`、`scripts/deploy/pingora-direct-rollback.sh`、`deploy/systemd/**`、`deploy/env/**` 和 `deploy/pingora/**`。`deploy/systemd/genarrative-database-backup.service` 从 `/opt/genarrative/current/scripts/database-backup-to-oss.mjs` 执行冷备份,`deploy/systemd/genarrative-health-patrol.service` 从 `/opt/genarrative/current/scripts/ops/production-health-patrol.mjs` 执行巡检;`Genarrative-Api-Deploy` 会从上游 API 构建产物复制并执行 `build//scripts/deploy/production-api-deploy.sh`,同目录的 `maintenance-on.sh` / `maintenance-off.sh` 也必须来自同一 build 产物;部署脚本会先写入 `${RELEASE_ROOT}/.${VERSION}.staging.$$`,把 `release-manifest.json` 校验后复制为 current release 的 `release-manifest.api-server.json`,并把备份脚本、巡检脚本、Pingora 直连启用 / 回退 / 预检 / live smoke / canary access log 对账 / health patrol env 复核 / current release 自审 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本,以及 `deploy/systemd`、`deploy/env`、`deploy/pingora` 支撑配置全部复制完成后,才用非合并语义提升为 `${RELEASE_ROOT}/${VERSION}` 并用固定替换语义切换 `current` 符号链接,不再在目标机器 checkout Git,也不再执行部署工作区根部脚本。Pingora 直连启用脚本必须能从 `/opt/genarrative/current` 独立执行 preflight 和 direct live smoke,并默认读取 current release 随包 `deploy/systemd/genarrative-pingora-gateway-direct-entry.conf`,不依赖 Jenkins 工作区、源码 checkout 或 `/etc` 参考模板;`plan:pingora-direct-cutover` 必须能用同一组参数生成 current release 切换 / 回退 runbook。`production-api-deploy.sh` 对 release manifest、备份脚本、巡检脚本、env 示例目录和 Pingora 直连依赖都执行 fail-fast,且 `--release-root`、`--current-link`、`--api-env-file` 必须是绝对路径,`--version` 必须以数字或字母开头并只能包含数字、字母、点、下划线和短横线,禁止 `.` / `..` 点目录;发布产物缺少 manifest、manifest 未登记 `api-server`、缺少脚本 / 配置目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现或 staging 构建中失败时会在 current 切换前停止部署,清理 staging 并退出本次打开的维护模式;current 切换后的 Pingora 重启、worker 重启、controller 启动或 readiness 失败仍保留维护模式,避免暴露半发布版本;失败不会留下正式 release 目录,不再从部署机工作区兜底补文件,也不把旧同名 release 目录和新文件混合。如果 API 发布后 current release 中缺少这些脚本或目录,应先检查 `Genarrative-Api-Build` 的 `archiveArtifacts` 和 `Genarrative-Api-Deploy` 的 `copyArtifacts` 过滤器是否仍包含 `build//release-manifest.json`、`build//scripts/deploy/production-api-deploy.sh`、`build//scripts/deploy/maintenance-on.sh`、`build//scripts/deploy/maintenance-off.sh`、`build//scripts/database-backup-to-oss.mjs`、`build//scripts/ops/production-health-patrol.mjs`、`build//scripts/ops/pingora-current-release-audit.mjs`、`build//scripts/ops/pingora-cutover-status-snapshot.mjs`、`build//scripts/ops/pingora-cutover-evidence-bundle.mjs`、`build//scripts/ops/pingora-cutover-command-evidence.mjs`、`build//scripts/ops/pingora-cutover-evidence-verify.mjs`、`build//scripts/ops/pingora-cutover-evidence-audit.mjs`、`build//scripts/check-pingora-direct-preflight.mjs`、`build//scripts/check-pingora-direct-live.mjs`、`build//scripts/check-pingora-canary-access-log-parity.mjs`、`build//scripts/check-production-health-patrol-env.mjs`、`build//scripts/deploy/pingora-direct-enable.sh`、`build//scripts/deploy/pingora-direct-rollback.sh`、`build//deploy/systemd/**`、`build//deploy/env/**` 与 `build//deploy/pingora/**`,不要只在部署机工作区手工补文件。本机用 `npm run check:production-api-release` 通过临时 `CARGO_TARGET_DIR` 和假 `api-server` / `pingora-gateway` release binary 验证 `build-production-release.sh --component api-server --skip-api-build` 会把这些文件打进 API release,并验证显式 `--include-pingora-gateway --skip-pingora-gateway-build` 时发布包包含 `pingora-gateway`、`pingora-gateway.sha256` 和 manifest 登记;`npm run check:pingora-production-release-build` 则用假 `api-server` 和真实 `cargo build -p pingora-gateway --release --target x86_64-unknown-linux-gnu` 验证显式 include 路径能构出可执行网关二进制、checksum 和 manifest 登记;再用 `npm run check:production-api-deploy` 通过临时 release、fake `systemctl` / `curl` 验证从发布产物内执行 `production-api-deploy.sh` 会把这些文件复制到 current release,并验证缺少 release manifest、manifest 未登记 `api-server`、Pingora manifest / 二进制漂移、`--require-pingora-gateway` 缺少 Pingora、缺少数据库备份脚本、健康巡检脚本、健康巡检 env 复核脚本、current release 自审脚本、状态快照脚本、证据包脚本、证据验真脚本、证据根目录审计脚本、canary access log 对账脚本、env 示例目录或 direct live smoke 脚本时都会在 current 切换前失败并退出本次打开的维护模式,还会验证相对 release root / current link / api env file、点目录或点开头 version 被拒绝、失败时不留下 staging / 正式 release 目录、同版本 release 目录已存在、current 路径不是符号链接、提升前 release 目录竞态出现时拒绝覆盖 / 合并,以及 current 切换后的 readiness 失败会保留维护模式。Pingora 影子网关在 `Genarrative-Api-Build`、`Genarrative-Api-Deploy` 和 `Genarrative-Full-Build-And-Deploy` 中默认随 release 构建、归档、复制并用 `--require-pingora-gateway` 硬校验;只有显式取消 `INCLUDE_PINGORA_GATEWAY` 时才允许 API release 不带 `pingora-gateway` / `pingora-gateway.sha256`。本地 CLI 仍保留显式 `npm run build:production-release -- --component api-server --include-pingora-gateway`,用于在需要 Pingora 的手工发布包里登记 manifest 和 checksum;Jenkins 会先检查 `cmake`、C 编译器和 C++ 编译器,避免进入 Cargo 后才因 `libz-ng-sys` 构建依赖缺失失败。发布包包含 Pingora 时,deploy 会在提升 release 前读取 `systemctl cat genarrative-pingora-gateway.service` 和其 `EnvironmentFile`,拒绝 direct-entry `CAP_NET_BIND_SERVICE`、拒绝非 `127.0.0.1:18081` 的 shadow listen、拒绝 `TLS_LISTEN` / `HTTP_REDIRECT_LISTEN`,确认仍是本机 shadow 高端口后才切换 current;切换后执行 `systemctl restart genarrative-pingora-gateway.service` 并复核 active,让 shadow / canary 机器加载新网关二进制。该自动拉起不会启用公网 `80/443` 直连入口;已经进入 direct-entry 状态的机器应走正式直连 runbook 或先回退到 shadow。 +Pingora shadow 部署前检查必须按首个权威错误 fail-fast:读取 systemd 最终配置失败或发现 `CAP_NET_BIND_SERVICE` 后,不得继续把空的 EnvironmentFile 解析结果传给 shadow listen 校验,也不得再输出“缺少 EnvironmentFile”或“LISTEN 为空”的连带误报。`npm run check:production-api-deploy` 的 direct-entry fixture 会锁定这一错误顺序,避免值班人员被多条互相矛盾的诊断引向错误配置。 + Pingora current release 自审脚本 `scripts/ops/pingora-current-release-audit.mjs`、直连切换状态快照脚本 `scripts/ops/pingora-cutover-status-snapshot.mjs`、证据包脚本 `scripts/ops/pingora-cutover-evidence-bundle.mjs`、命令证据脚本 `scripts/ops/pingora-cutover-command-evidence.mjs`、证据验真脚本 `scripts/ops/pingora-cutover-evidence-verify.mjs`、证据根目录审计脚本 `scripts/ops/pingora-cutover-evidence-audit.mjs` 和 canary access log 对账脚本 `scripts/check-pingora-canary-access-log-parity.mjs` 都属于 API release 的强制随包依赖;缺少任一脚本时 `check:production-api-release`、`check:production-api-deploy` 和生产运维护栏都必须失败,避免切换窗口只能靠 Jenkins 工作区或源码 checkout 临时补自审、证据或日志对账脚本。 同一 API release 随包依赖还必须包含 `scripts/check-pingora-release-readiness.mjs` 与 `scripts/check-pingora-canary-live.mjs`。前者在 current release 上以 `--release-runtime-only` 汇总运行时复核,后者支撑目标 Nginx canary live smoke;缺少任一脚本时不能进入直连切换窗口。 diff --git a/scripts/check-production-api-deploy.mjs b/scripts/check-production-api-deploy.mjs index db9aa38d0..f46e6d961 100644 --- a/scripts/check-production-api-deploy.mjs +++ b/scripts/check-production-api-deploy.mjs @@ -39,6 +39,7 @@ function main() { assertDeployRestartsActivePingoraWhenArtifactIncluded(); assertDeployStartsInactivePingoraWhenArtifactIncluded(); assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded(); + assertDeployStopsAfterPingoraSystemdReadFailure(); assertDeployRejectsPingoraPublicListenWhenArtifactIncluded(); assertDeployRejectsPingoraArtifactMissingManifestEntry(); assertDeployRejectsPingoraManifestEntryMissingArtifact(); @@ -416,6 +417,24 @@ function assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded() { 'Pingora systemd 已包含 CAP_NET_BIND_SERVICE', 'direct-entry capability 存在时必须给出明确错误。', ); + if ( + result.stderr.includes( + 'Pingora systemd 配置缺少包含 GENARRATIVE_PINGORA_GATEWAY_LISTEN 的 EnvironmentFile', + ) + ) { + failures.push( + 'direct-entry capability 已被识别后不应继续误报 Pingora EnvironmentFile 缺失。', + ); + } + if ( + result.stderr.includes( + 'Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081', + ) + ) { + failures.push( + 'direct-entry capability 已被识别后不应继续用空 env 误报 shadow listen。', + ); + } const commandsLog = readOptionalCommandsLog(fixture); if ( commandsLog.includes( @@ -431,6 +450,47 @@ function assertDeployRejectsPingoraDirectEntryWhenArtifactIncluded() { ); } +function assertDeployStopsAfterPingoraSystemdReadFailure() { + const fixture = prepareFixture('with-unreadable-pingora-systemd'); + addPingoraGatewayArtifact(fixture); + const result = runDeploy(fixture, { pingoraSystemctlCatFails: true }); + + if (result.status === 0) { + failures.push('包含 Pingora 但无法读取 systemd 最终配置时部署必须失败。'); + } + assertIncludes( + result.stderr, + '无法读取 Pingora systemd 最终配置', + 'systemctl cat 失败时必须保留权威错误。', + ); + if ( + result.stderr.includes( + 'Pingora systemd 配置缺少包含 GENARRATIVE_PINGORA_GATEWAY_LISTEN 的 EnvironmentFile', + ) + ) { + failures.push( + 'systemctl cat 失败后不应继续误报 Pingora EnvironmentFile 缺失。', + ); + } + if ( + result.stderr.includes( + 'Pingora 自动启动只允许 shadow 监听 127.0.0.1:18081', + ) + ) { + failures.push('systemctl cat 失败后不应继续用空 env 误报 shadow listen。'); + } + const commandsLog = readOptionalCommandsLog(fixture); + if ( + commandsLog.includes( + 'systemctl restart genarrative-pingora-gateway.service', + ) + ) { + failures.push('systemctl cat 失败后不能自动 restart Pingora。'); + } + assertMaintenanceCleared(fixture, '读取 Pingora systemd 配置失败'); + assertNoReleasePromoted(fixture, '读取 Pingora systemd 配置失败时'); +} + function assertDeployRejectsPingoraPublicListenWhenArtifactIncluded() { const fixture = prepareFixture('with-public-listen-pingora-artifact'); addPingoraGatewayArtifact(fixture); @@ -1414,6 +1474,9 @@ function prepareFixture(name) { 'worker_state_file="${FAKE_WORKER_STATE_FILE}"', 'pingora_state_file="${FAKE_PINGORA_STATE_FILE}"', 'if [[ "$1" == "cat" && "${2:-}" == "genarrative-pingora-gateway.service" ]]; then', + ' if [[ "${FAKE_PINGORA_SYSTEMCTL_CAT_FAIL:-false}" == "true" ]]; then', + ' exit 1', + ' fi', ' printf "[Service]\\n"', ' printf "EnvironmentFile=%s\\n" "${FAKE_PINGORA_ENV_FILE}"', ' printf "ExecStart=/opt/genarrative/current/pingora-gateway\\n"', @@ -1616,6 +1679,8 @@ function runDeploy(fixture, options = {}) { FAKE_PINGORA_ACTIVE: options.pingoraActive === false ? 'false' : 'true', FAKE_PINGORA_DIRECT_ENTRY: options.pingoraDirectEntry === true ? 'true' : 'false', + FAKE_PINGORA_SYSTEMCTL_CAT_FAIL: + options.pingoraSystemctlCatFails === true ? 'true' : 'false', FAKE_PINGORA_ENV_FILE: fixture.pingoraEnvFile, FAKE_PINGORA_STATE_FILE: fixture.pingoraStateFile, FAKE_CURL_FAIL: options.curlFails === true ? 'true' : 'false', diff --git a/scripts/deploy/production-api-deploy.sh b/scripts/deploy/production-api-deploy.sh index d541e5d0e..ae4350900 100644 --- a/scripts/deploy/production-api-deploy.sh +++ b/scripts/deploy/production-api-deploy.sh @@ -319,19 +319,25 @@ extract_pingora_env_files_from_unit() { find_pingora_gateway_env_file() { local service_name="$1" - local env_file listen + local env_file listen unit_env_files - while IFS= read -r env_file; do - if [[ "${env_file}" != /* ]]; then - echo "[production-api-deploy] Pingora EnvironmentFile 必须使用绝对路径: ${env_file}" >&2 - exit 1 - fi - listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_LISTEN")" - if [[ -n "${listen}" ]]; then - printf "%s\n" "${env_file}" - return - fi - done < <(extract_pingora_env_files_from_unit "${service_name}") + if ! unit_env_files="$(extract_pingora_env_files_from_unit "${service_name}")"; then + return 1 + fi + + if [[ -n "${unit_env_files}" ]]; then + while IFS= read -r env_file; do + if [[ "${env_file}" != /* ]]; then + echo "[production-api-deploy] Pingora EnvironmentFile 必须使用绝对路径: ${env_file}" >&2 + exit 1 + fi + listen="$(read_env_value "${env_file}" "GENARRATIVE_PINGORA_GATEWAY_LISTEN")" + if [[ -n "${listen}" ]]; then + printf "%s\n" "${env_file}" + return + fi + done <<< "${unit_env_files}" + fi echo "[production-api-deploy] Pingora systemd 配置缺少包含 GENARRATIVE_PINGORA_GATEWAY_LISTEN 的 EnvironmentFile: ${service_name}" >&2 exit 1 @@ -363,7 +369,9 @@ check_pingora_shadow_service_config() { local service_name="$1" local env_file - env_file="$(find_pingora_gateway_env_file "${service_name}")" + if ! env_file="$(find_pingora_gateway_env_file "${service_name}")"; then + return 1 + fi require_pingora_shadow_env "${env_file}" printf "%s\n" "${env_file}" }