diff --git a/deploy/nginx/genarrative-dev-http.conf b/deploy/nginx/genarrative-dev-http.conf index 336a486e8..24701af41 100644 --- a/deploy/nginx/genarrative-dev-http.conf +++ b/deploy/nginx/genarrative-dev-http.conf @@ -298,6 +298,7 @@ server { location / { error_page 503 /maintenance.html; + error_page 404 /404.html; if ($genarrative_maintenance) { return 503; diff --git a/deploy/nginx/genarrative.conf b/deploy/nginx/genarrative.conf index c3b202613..eb880a129 100644 --- a/deploy/nginx/genarrative.conf +++ b/deploy/nginx/genarrative.conf @@ -318,6 +318,7 @@ server { location / { error_page 503 /maintenance.html; + error_page 404 /404.html; if ($genarrative_maintenance) { return 503; diff --git a/deploy/nginx/snippets/genarrative-maintenance.conf b/deploy/nginx/snippets/genarrative-maintenance.conf index e101c488f..bd4f16e77 100644 --- a/deploy/nginx/snippets/genarrative-maintenance.conf +++ b/deploy/nginx/snippets/genarrative-maintenance.conf @@ -10,7 +10,26 @@ if ($genarrative_internal_client) { } location = /maintenance.html { - root /srv/genarrative/web; + root /var/lib/genarrative/maintenance; + try_files /page.html @genarrative_default_maintenance; + add_header Cache-Control "no-store"; + internal; +} + +location @genarrative_default_maintenance { + root /srv/genarrative/web; + try_files /maintenance.html =503; + add_header Cache-Control "no-store"; + internal; +} + +location = /404.html { + if ($http_accept !~* "text/html") { + return 404; + } + + root /srv/genarrative/web; + try_files /404.html =404; add_header Cache-Control "no-store"; internal; } diff --git a/deploy/pingora/pingora-gateway.env.example b/deploy/pingora/pingora-gateway.env.example index 67b1bc3ff..fc12d4a58 100644 --- a/deploy/pingora/pingora-gateway.env.example +++ b/deploy/pingora/pingora-gateway.env.example @@ -25,6 +25,7 @@ GENARRATIVE_PINGORA_GATEWAY_SPACETIME_UPSTREAM=127.0.0.1:3101 GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT=/srv/genarrative/web GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT=/var/www/html GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE=/var/lib/genarrative/maintenance/enabled +GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE=/var/lib/genarrative/maintenance/page.html GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO=http GENARRATIVE_PINGORA_GATEWAY_MAX_API_BODY_BYTES=67108864 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 15f59ed04..f5b1ca82a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3970,6 +3970,7 @@ - 背景:主站 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 和自动门禁。 +- 2026-07-13 补充:浏览器导航到 Web 未知路径时继续保持 HTTP `404`,但正文统一返回 `public/404.html` 品牌页面和 `public/branding/taonier-404-page.png`;API、探针及非 HTML 请求仍返回原有 `404` 响应,不得把品牌页 HTML 混入接口响应。Nginx 最终 Web catch-all 与 Pingora `Accept: text/html` 分支保持一致。 - 影响范围:`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` 等同前缀未知路径。 @@ -4013,3 +4014,11 @@ - 边界:查单使用订单所属用户的小程序 `openid`,不把 AppKey、AppSecret、access token 或 `openid` 下发前端;虚拟支付不得误用微信支付 V3 查单。 - 历史单:升级前遗留的 pending 订单不会被 expiration catch-up 覆盖,使用 `spacetime:wechat-virtual-payment:reconcile` 逐单 dry-run,再使用当次 `applyFingerprint` 明确 `--apply`。脚本每次重读本地订单与微信查单结果,指纹漂移、非 `2/3/4`、单号/金额/支付类型 `order_type=0/7` 不一致或非官方 endpoint 时默认拒绝入账。 - 验证方式:`cargo test -p platform-wechat --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml virtual_payment_query`、`npm run check:wechat-virtual-payment-reconcile`、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-13 临时维护公告改为 release 外运行态覆盖 + +- 背景:一次性停服公告曾直接提交到 `public/maintenance.html`,后续 Web Build 将它持续打入 `web.tar.gz`,每次 Web Deploy 或再次进入维护都会重新显示已经过期的公告。 +- 决策:`public/maintenance.html` 永久作为无日期、无具体时段的默认维护页,并使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉;生产 Web 打包必须对最终 `web/maintenance.html` 执行临时文案门禁。临时公告通过 `maintenance-on.sh --page-file <公告HTML>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`,Nginx 与 Pingora 优先读取该运行态文件,缺失时回退 Web 制品默认页。 +- 生命周期:新维护窗口未提供 `--page-file` 时清理 marker 外残留公告;同一窗口内 Stdb / API 发布重复调用 `maintenance-on.sh` 时保留已安装公告;`maintenance-off.sh` 同时清理 marker 和公告页。Web Deploy 不再拥有临时公告事实源。 +- 影响范围:默认维护页、维护开关脚本、Nginx snippet、Pingora 配置与 smoke、生产 Web 发布包门禁和生产运维文档。 +- 验证方式:`npm run check:maintenance-page`、`npm run check:nginx-spa-routes`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke`、`npm run check:production-ops`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 70370cf09..850512ad0 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2964,8 +2964,8 @@ - 现象:`/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 优先语义。 +- 处理:SPA fallback 必须精确匹配当前真实完整路径,同时允许前端已有的大小写归一和尾部斜杠;最终 catch-all 只提供真实静态文件。浏览器 HTML 导航失败时返回品牌 `404.html`,但状态码仍为 404;API、探针和非 HTML 请求保持原有 404 响应。路由增删同步三套 Nginx、Pingora、route parity matrix 和路由门禁。 +- 验证:除全部真实 SPA 路径外,至少检查 `/not-exist`、`/creation/not-exist`、`/runtime/not-exist` 和 `/puzzle/not-exist` 均返回 404;带 `Accept: text/html` 的未知 Web 路径正文命中品牌页,不带 HTML Accept 的请求不得命中品牌页;维护模式仍保持页面 503 优先语义。 - 关联:`src/routing/appRoutes.tsx`、`src/routing/appPageRoutes.ts`、`deploy/nginx/`、`deploy/container/nginx.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。 ## Jenkins Job UI 参数会被 SCM Jenkinsfile 覆盖 @@ -2991,6 +2991,14 @@ - 处理:Full 使用 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 表达产品选择,Stdb Publish 和 API Deploy 全程固定保持维护,Web Deploy 成功后才进入独立最终退出阶段;API Deploy 的独立 `KEEP_MAINTENANCE_MODE` 再转换为脚本 `--keep-maintenance-mode`。API deploy 还必须把 `production-api-deploy.sh`、`maintenance-on.sh` 和 `maintenance-off.sh` 从同一 build artifact 复制进 current release,否则 Full 最终阶段即使有选项也找不到随包退出脚本。默认值仍在 Full 结束时退出维护,避免定时 dev 发布行为变化。 - 验证:API deploy fixture 必须覆盖成功发布并保留 marker,还要断言 current release 中三个部署 / 维护脚本存在;生产运维静态门禁同时反查 Full 参数、下游透传、API Deploy 参数和脚本 flag。推送后用 fail-closed 首阶段运行刷新 live Job 参数,再核对 `config.xml`,不能只看仓库文件。 +## 临时维护公告不能提交进版本化默认页 + +- 现象:现场已恢复通用维护页,但后续 Web Deploy 或下一次进入维护后,又显示昨天的“今天晚上 HH:MM~HH:MM”公告。 +- 原因:`public/maintenance.html` 会被 Vite 复制进 `web.tar.gz`,Web Deploy 解包后把 `/srv/genarrative/web` 指向新制品;临时公告一旦进入该源码,就会成为每次发布都恢复的长期内容。旧维护 on / off 只控制 marker,浏览器缓存不是根因。 +- 处理:版本化默认页只保留无日期通用文案;临时公告用 `maintenance-on.sh --page-file <公告HTML>` 安装到 `/var/lib/genarrative/maintenance/page.html`。Nginx / Pingora 优先读取运行态公告,退出维护时同步清理;不要再原地编辑 `/srv/genarrative/web/maintenance.html` 或提交临时公告到 `public/`。 +- 验证:`npm run check:maintenance-page` 必须拒绝相对日期、具体日期和具体时间,并覆盖公告安装、同窗口保留、退出清理与新窗口清残留;Pingora smoke 必须证明运行态公告优先且删除后回退默认页。 +- 关联:`public/maintenance.html`、`scripts/deploy/maintenance-on.sh`、`scripts/deploy/maintenance-off.sh`、`deploy/nginx/snippets/genarrative-maintenance.conf`、`server-rs/crates/pingora-gateway/src/main.rs`。 + ## 遮罩点击关闭必须校验完整指针序列 - 现象:在弹窗内容内按下鼠标,拖到弹窗外的遮罩上松开时,弹窗被误关闭。 diff --git a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md index 7a11eb150..9077b7003 100644 --- a/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md +++ b/docs/technical/【开发运维】Pingora独立网关试点-2026-06-11.md @@ -545,7 +545,7 @@ dev 根盘空间在安装后曾接近满盘;2026-06-17 进入 canary 前已清 | 主站 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`。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。 +维护模式下,公网 API-like 路由返回 JSON `503`;公网 Web 静态路由先读取 `GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE` 指向的 release 外运行态公告,缺失时回退 `GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT/maintenance.html`,两者都不存在时返回纯文本 `503`。版本化默认页不得包含日期或具体时段,临时公告由 `maintenance-on.sh --page-file` 安装并在 `maintenance-off.sh` 时清理。IPv4 loopback / RFC1918 / link-local 和 IPv6 loopback / ULA / link-local 来源绕过整站维护闸,主站页面与静态资源、普通 API、后台页面与后台 API、SpacetimeDB 路由均按非维护状态继续处理;应用层登录、管理员鉴权和其它业务鉴权保持不变。Pingora 直连按 TCP peer 判定来源;仅当 peer 是 loopback 的同机 Nginx 时才接受 Nginx 强制覆盖的 `X-Real-IP`,绝不使用客户端可伪造的 `X-Forwarded-For` 做维护放行。该放行只绕过网关维护响应;若 `pause-after-stdb` 已停止 api-server,内网普通 API 和后台 API 仍不可用。 代理失败时,API / SpacetimeDB 等代理路由返回统一 JSON 网关错误;本地静态路由仍保持对应 HTTP 错误状态。 静态 `Range` 只支持单段 bytes range;多段 range 暂按完整文件返回,避免在正式替换前引入 multipart 响应面。`If-None-Match` / `If-Modified-Since` 优先于 `Range` 判定,命中时仍返回 `304`;`If-Range` 日期匹配时继续返回 `206`,日期旧于文件或弱 ETag 校验器时回完整 `200`;`206` / `304` / `416` 不做 gzip 压缩,避免 `Content-Range` 语义被响应体改写破坏。Gateway smoke 会用固定 `X-Request-Id` 对账静态 `304`、`405`、`206`、`416` 的 Pingora access log 行,确认本地响应状态也进入正式切换证据链。 静态路由只允许 `GET` / `HEAD` 读取;其它方法在确认命中静态候选后返回 `405` 并写入 `Allow: GET, HEAD`,缺失文件仍返回 `404`,避免直连后错误客户端把静态入口当作可写接口。 diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index ccc313d62..cf5084de7 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -411,7 +411,7 @@ Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸 该规则只绕过网关维护响应,不会自动拉起 api-server、SpacetimeDB 或其它已停止的服务。人工执行 `maintenance-on.sh` 且后端仍运行时,可以从内网继续访问整站和修改后台数据;`pause-after-stdb` 会停止旧 API/controller/worker,在 API 被停期间静态页面可能仍可加载,但普通 API 与 `/admin/api/**` 仍不可用。验证使用 `npm run check:nginx-spa-routes`、`npm run check:pingora-route-parity`、`cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml`、`npm run check:pingora-gateway-smoke` 和 `npm run check:production-ops`,不要在 live 机器上为测试临时创建维护 marker。 -维护页源码固定为 `public/maintenance.html`,正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`。计划内停服需要临时更新公告时,先更新该源码,避免后续 Web Deploy 把现场公告覆盖回旧内容;现场紧急替换必须原子写入当前 `/srv/genarrative/web/maintenance.html`,并同时用 `genarrative.world` 与 `www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。 +版本化默认维护页固定为 `public/maintenance.html`,使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉,只允许保存无日期、无具体时段的通用文案;正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`,并由 `check-maintenance-page.mjs` 在打包前拒绝“今天 / 今晚”、具体日期或 `HH:MM` 等临时公告。计划内停服的临时公告必须放在 release 外文件中,通过 `/opt/genarrative/current/scripts/deploy/maintenance-on.sh --page-file <公告HTML> <维护原因>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`。Nginx 与 Pingora 在该文件存在时优先返回它,缺失时回退当前 Web 制品的默认维护页;同一维护窗口内 Stdb / API 的后续 `maintenance-on.sh` 调用保留已安装公告,`maintenance-off.sh` 同时删除 marker 和运行态公告,避免下次维护复活旧内容。公告启用后同时用 `genarrative.world` 与 `www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。 生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。`Genarrative-Server-Provision` 是服务器初始化流水线,Job 配置里的 SCM URL 必须使用 controller 本机可访问的仓库路径或内网 Gitea 地址,不能使用 `https://git.genarrative.world/...`;否则日志一开始的 `Checking out git ... to read jenkins/Jenkinsfile.production-server-provision` 就会先从公网拉 Jenkinsfile。构建类流水线和 `Genarrative-Server-Provision` 的 Jenkinsfile 内部源码准备阶段统一使用 `ssh://git@192.168.35.82:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置 `https://git.genarrative.world/...` 公网 fallback,也不再默认使用 `http://genarrative-station/git/GenarrativeAI/Genarrative.git`。所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true`、`depth=1`、`noTags=true` 与 `honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本,避免产物 commit 与部署脚本 commit 漂移;Server-Provision 也不在目标 dev / release agent checkout Git,而是由 Jenkins 构建节点先准备 provision 脚本与配置并上传给目标 agent。 diff --git a/package.json b/package.json index 28f7de087..f50def4fa 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "check:encoding": "node scripts/check-encoding.mjs", "check:spacetime-schema": "node scripts/check-spacetime-schema-guard.mjs", "check:production-ops": "node scripts/check-production-ops-guardrails.mjs", + "check:maintenance-page": "node scripts/check-maintenance-page.mjs", "check:database-backup": "node scripts/check-database-backup-to-oss.mjs", "check:production-health-patrol": "node scripts/check-production-health-patrol.mjs", "check:production-health-patrol-env": "node scripts/check-production-health-patrol-env-check.mjs", @@ -82,7 +83,7 @@ "lint:guardrails": "npm run lint:eslint", "typecheck": "tsc -p tsconfig.typecheck-guardrails.json --noEmit", "typecheck:guardrails": "npm run typecheck", - "lint": "npm run check:encoding && npm run check:spacetime-schema && npm run check:production-ops && npm run lint:eslint && npm run typecheck", + "lint": "npm run check:encoding && npm run check:spacetime-schema && npm run check:production-ops && npm run check:maintenance-page && npm run lint:eslint && npm run typecheck", "lint:fix": "eslint . --ext .ts,.tsx,.js,.mjs,.cjs --fix && prettier --write .", "format": "prettier --write .", "format:check": "prettier --check .", diff --git a/public/404.html b/public/404.html new file mode 100644 index 000000000..1588d6bb9 --- /dev/null +++ b/public/404.html @@ -0,0 +1,213 @@ + + + + + + + + 页面无法连接 - 陶泥儿 + + + + 404 页面无法连接,您访问的页面不存在或已被移除 +
+

404

+

页面无法连接

+

抱歉,您访问的页面不存在或已被移除。

+ 陶泥儿从陶罐中探出头,旁边放着施工路障 +
+ + 前往首页 +
+
+ + diff --git a/public/branding/taonier-404-character.png b/public/branding/taonier-404-character.png new file mode 100644 index 000000000..e12222aa4 Binary files /dev/null and b/public/branding/taonier-404-character.png differ diff --git a/public/branding/taonier-404-page.png b/public/branding/taonier-404-page.png new file mode 100644 index 000000000..184b6b939 Binary files /dev/null and b/public/branding/taonier-404-page.png differ diff --git a/public/branding/taonier-maintenance-page.png b/public/branding/taonier-maintenance-page.png new file mode 100644 index 000000000..5cbc970b4 Binary files /dev/null and b/public/branding/taonier-maintenance-page.png differ diff --git a/public/maintenance.html b/public/maintenance.html index 75799f377..6cca73d0f 100644 --- a/public/maintenance.html +++ b/public/maintenance.html @@ -4,44 +4,72 @@ - 陶泥儿停服升级公告 + + 服务维护中 - 陶泥儿 +
-
- - 服务维护中 +
+
+ + 系统维护中 +
+

服务维护中

+

我们正在更新系统,稍后请重新访问。

+
- -

陶泥儿美术工具停服升级

-

- 各位陶泥儿创作者,美术工具官网将在今天晚上 21:00~22:00 停服升级。
- 注意保存好自己的项目和下载好素材。 -

- -
- 维护时间 - 今天晚上 21:00~22:00 -
- -
-

本次更新修复内容

-
    -
  1. 修复多个影响画布使用的 BUG。
  2. -
  3. - 修复了 PC 端不能充值的恶性 BUG(bushi),PC - 网页端支持微信二维码充值。 -
  4. -
-
- -
-

新增功能

-

- 抠图与背景色功能升级:使用更强、更稳定的抠图技术,解决角色扣残问题,角色动作的逐帧抠图同步升级。 -

-
diff --git a/scripts/build-production-release.sh b/scripts/build-production-release.sh index fb368e3ce..2ced31812 100644 --- a/scripts/build-production-release.sh +++ b/scripts/build-production-release.sh @@ -446,6 +446,17 @@ if [[ "${BUILD_WEB}" -eq 1 ]]; then MAINTENANCE_HTML fi + if [[ ! -f "${WEB_DIR}/404.html" ]]; then + echo "[production-release] Web 发布包缺少品牌 404 页面: ${WEB_DIR}/404.html" >&2 + exit 1 + fi + + echo "[production-release] 校验默认维护页不包含临时公告" + ( + cd "${REPO_ROOT}" + node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html" + ) + echo "[production-release] 规范 Web 静态资源权限" find "${WEB_DIR}" -type d -exec chmod 755 {} + find "${WEB_DIR}" -type f -exec chmod 644 {} + diff --git a/scripts/check-maintenance-page.mjs b/scripts/check-maintenance-page.mjs new file mode 100644 index 000000000..1109f99c0 --- /dev/null +++ b/scripts/check-maintenance-page.mjs @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const failures = []; +const requestedFiles = []; + +for (let index = 2; index < process.argv.length; index += 1) { + if (process.argv[index] !== '--file' || !process.argv[index + 1]) { + failures.push(`未知或不完整参数: ${process.argv[index]}`); + continue; + } + requestedFiles.push(path.resolve(process.argv[index + 1])); + index += 1; +} + +function fail(message) { + failures.push(message); +} + +function validateDefaultPage(filePath) { + if (!existsSync(filePath)) { + fail(`默认维护页不存在: ${filePath}`); + return; + } + + const source = readFileSync(filePath, 'utf8'); + if (!source.includes('服务维护中')) { + fail(`${filePath} 必须保留无日期的“服务维护中”默认文案。`); + } + for (const [pattern, label] of [ + [/(?:今天|今晚|明天|昨天|昨日)/u, '相对日期'], + [/(?:20\d{2}[-/.年]\d{1,2}(?:[-/.月]\d{1,2}日?)?|\d{1,2}月\d{1,2}日)/u, '具体日期'], + [/(?:[01]?\d|2[0-3]):[0-5]\d/u, '具体维护时间'], + ]) { + if (pattern.test(source)) { + fail(`${filePath} 不能包含${label};临时公告必须使用运行态覆盖页。`); + } + } +} + +function runScript(scriptPath, args, env) { + return spawnSync('bash', [scriptPath, ...args], { + cwd: repoRoot, + env: { ...process.env, ...env }, + encoding: 'utf8', + }); +} + +function validateRuntimePageLifecycle() { + const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'genarrative-maintenance-')); + const markerFile = path.join(tempRoot, 'state', 'enabled'); + const runtimePageFile = path.join(tempRoot, 'state', 'page.html'); + const sourcePageFile = path.join(tempRoot, 'announcement.html'); + const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh'); + const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh'); + const env = { + GENARRATIVE_MAINTENANCE_FILE: markerFile, + GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile, + }; + + try { + const announcement = 'planned maintenance\n'; + writeFileSync(sourcePageFile, announcement); + + const enable = runScript( + onScript, + ['--page-file', sourcePageFile, 'planned maintenance'], + env, + ); + if (enable.status !== 0) { + fail(`maintenance-on --page-file 执行失败: ${enable.stderr || enable.stdout}`); + return; + } + if (!existsSync(markerFile)) { + fail('maintenance-on --page-file 必须创建维护 marker。'); + } + if (!existsSync(runtimePageFile)) { + fail('maintenance-on --page-file 必须原子安装运行态公告页。'); + } else { + if (readFileSync(runtimePageFile, 'utf8') !== announcement) { + fail('运行态公告页内容与输入文件不一致。'); + } + if ((statSync(runtimePageFile).mode & 0o777) !== 0o644) { + fail('运行态公告页权限必须为 0644。'); + } + } + + const nestedEnable = runScript(onScript, ['api deploy'], env); + if (nestedEnable.status !== 0 || !existsSync(runtimePageFile)) { + fail('同一维护窗口内的后续 maintenance-on 必须保留已安装公告页。'); + } + + const disable = runScript(offScript, [], env); + if (disable.status !== 0) { + fail(`maintenance-off 执行失败: ${disable.stderr || disable.stdout}`); + } + if (existsSync(markerFile) || existsSync(runtimePageFile)) { + fail('maintenance-off 必须同时清理 marker 和运行态公告页。'); + } + + mkdirSync(path.dirname(runtimePageFile), { recursive: true }); + writeFileSync(runtimePageFile, announcement); + chmodSync(runtimePageFile, 0o644); + const genericEnable = runScript(onScript, ['generic maintenance'], env); + if (genericEnable.status !== 0) { + fail(`通用 maintenance-on 执行失败: ${genericEnable.stderr || genericEnable.stdout}`); + } + if (existsSync(runtimePageFile)) { + fail('新维护窗口未提供 --page-file 时必须清理残留公告页。'); + } + runScript(offScript, [], env); + + const missingPage = runScript( + onScript, + ['--page-file', path.join(tempRoot, 'missing.html')], + env, + ); + if (missingPage.status === 0 || existsSync(markerFile)) { + fail('不存在的 --page-file 必须在创建 marker 前失败。'); + } + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } +} + +function validateGatewayConfiguration() { + const nginxSnippet = readFileSync( + path.join(repoRoot, 'deploy/nginx/snippets/genarrative-maintenance.conf'), + 'utf8', + ); + for (const expected of [ + 'root /var/lib/genarrative/maintenance;', + 'try_files /page.html @genarrative_default_maintenance;', + 'location @genarrative_default_maintenance', + 'root /srv/genarrative/web;', + ]) { + if (!nginxSnippet.includes(expected)) { + fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`); + } + } + + const pingoraSource = readFileSync( + path.join(repoRoot, 'server-rs/crates/pingora-gateway/src/main.rs'), + 'utf8', + ); + for (const expected of [ + 'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE', + 'maintenance_page_file', + ]) { + if (!pingoraSource.includes(expected)) { + fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`); + } + } + + const releaseBuildScript = readFileSync( + path.join(repoRoot, 'scripts/build-production-release.sh'), + 'utf8', + ); + if ( + !releaseBuildScript.includes( + 'node scripts/check-maintenance-page.mjs --file "${WEB_DIR}/maintenance.html"', + ) + ) { + fail('生产 Web 发布包构建必须校验最终 maintenance.html 不含临时公告。'); + } +} + +for (const filePath of + requestedFiles.length > 0 + ? requestedFiles + : [path.join(repoRoot, 'public/maintenance.html')]) { + validateDefaultPage(filePath); +} + +if (requestedFiles.length === 0) { + validateRuntimePageLifecycle(); + validateGatewayConfiguration(); +} + +if (failures.length > 0) { + console.error('[check:maintenance-page] FAILED'); + for (const failure of failures) { + console.error(`- ${failure}`); + } + process.exit(1); +} + +console.log('[check:maintenance-page] 通过'); diff --git a/scripts/check-nginx-spa-routes.mjs b/scripts/check-nginx-spa-routes.mjs index 3ab5682c0..3fc282c5a 100644 --- a/scripts/check-nginx-spa-routes.mjs +++ b/scripts/check-nginx-spa-routes.mjs @@ -172,6 +172,17 @@ function validateMaintenanceInternalBypass() { if (snippet.includes('$genarrative_admin_maintenance')) { fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`); } + for (const fragment of [ + 'location = /404.html {', + 'if ($http_accept !~* "text/html") {', + 'try_files /404.html =404;', + 'add_header Cache-Control "no-store";', + 'internal;', + ]) { + if (!snippet.includes(fragment)) { + fail(`${MAINTENANCE_SNIPPET_PATH} 缺少品牌 404 页面约束: ${fragment}`); + } + } for (const nginxPath of MAINTENANCE_NGINX_PATHS) { const source = readFileSync(nginxPath, 'utf8'); @@ -193,6 +204,9 @@ function validateMaintenanceInternalBypass() { if (source.includes('$genarrative_admin_maintenance')) { fail(`${nginxPath} 的维护入口必须统一使用全站维护变量。`); } + if (!source.includes('error_page 404 /404.html;')) { + fail(`${nginxPath} 的 Web 未知路由必须返回品牌 404 页面。`); + } const maintenanceChecks = source.match(/if \(\$genarrative_[a-z_]*maintenance\)/gu) ?? []; if (maintenanceChecks.length === 0) { diff --git a/scripts/check-pingora-gateway-smoke.mjs b/scripts/check-pingora-gateway-smoke.mjs index 96bf12f53..951b1998b 100644 --- a/scripts/check-pingora-gateway-smoke.mjs +++ b/scripts/check-pingora-gateway-smoke.mjs @@ -2,14 +2,14 @@ import { spawn, spawnSync } from 'node:child_process'; import { createHash, randomBytes } from 'node:crypto'; -import { gunzipSync } from 'node:zlib'; +import { existsSync } from 'node:fs'; +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import http from 'node:http'; import https from 'node:https'; import net from 'node:net'; -import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { gunzipSync } from 'node:zlib'; const repoRoot = process.cwd(); const failures = []; @@ -51,6 +51,7 @@ async function main() { const webRoot = path.join(tempRoot, 'web'); const acmeRoot = path.join(tempRoot, 'acme'); const maintenanceFile = path.join(tempRoot, 'maintenance', 'enabled'); + const maintenancePageFile = path.join(tempRoot, 'maintenance', 'page.html'); const accessLogFile = path.join(tempRoot, 'logs', 'pingora.access.log'); await prepareStaticRoots(webRoot, acmeRoot); @@ -158,6 +159,7 @@ async function main() { GENARRATIVE_PINGORA_GATEWAY_WEB_ROOT: webRoot, GENARRATIVE_PINGORA_GATEWAY_ACME_ROOT: acmeRoot, GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE: maintenanceFile, + GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE: maintenancePageFile, GENARRATIVE_PINGORA_GATEWAY_ACCESS_LOG_FILE: accessLogFile, GENARRATIVE_PINGORA_GATEWAY_PROBE_TOKEN: probeToken, GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO: 'https', @@ -208,6 +210,7 @@ async function main() { redirectBaseUrl, probeToken, maintenanceFile, + maintenancePageFile, accessLogFile, api, spacetime, @@ -348,8 +351,9 @@ async function prepareStaticRoots(webRoot, acmeRoot) { ); await writeFile( path.join(webRoot, 'maintenance.html'), - '
maintenance
', + '
default-maintenance
', ); + await writeFile(path.join(webRoot, '404.html'), '
not-found-page
'); await writeFile( path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'), 'acme-token', @@ -550,6 +554,7 @@ async function runSmokeCases( redirectBaseUrl, probeToken, maintenanceFile, + maintenancePageFile, accessLogFile, api, spacetime, @@ -598,6 +603,21 @@ async function runSmokeCases( `主站未知路径返回真实 404: ${unknownPath}`, ); } + await expectHttp( + baseUrl, + '/some/browser/navigation', + 404, + 'not-found-page', + '浏览器导航未知路径返回品牌 404 页面', + { + headers: { + Accept: 'text/html,application/xhtml+xml', + }, + validate: (response) => + response.headers['cache-control'] === 'no-store' && + response.headers['content-type']?.startsWith('text/html'), + }, + ); await expectHttp(baseUrl, '/admin', 301, '', '/admin 301 到 /admin/', { validate: (response) => response.headers.location === '/admin/', }); @@ -1124,6 +1144,7 @@ async function runSmokeCases( ]); await mkdir(path.dirname(maintenanceFile), { recursive: true }); + await writeFile(maintenancePageFile, '
runtime-maintenance
'); await writeFile(maintenanceFile, 'enabled'); const publicClientHeaders = { 'X-Real-IP': '203.0.113.50', @@ -1149,11 +1170,11 @@ async function runSmokeCases( '维护模式 Gitea Host 请求没有打到 Gitea mock', ); for (const [path, bodyNeedle, label] of [ - ['/', 'maintenance', '公网主站页面'], + ['/', 'runtime-maintenance', '公网主站页面'], ['/api/creation-entry/config', 'MAINTENANCE', '公网普通 API'], - ['/v1/identity', 'maintenance', '公网 SpacetimeDB 路由'], - ['/admin/settings', 'maintenance', '公网后台页面'], - ['/admin/assets/admin.js', 'maintenance', '公网后台静态资源'], + ['/v1/identity', 'runtime-maintenance', '公网 SpacetimeDB 路由'], + ['/admin/settings', 'runtime-maintenance', '公网后台页面'], + ['/admin/assets/admin.js', 'runtime-maintenance', '公网后台静态资源'], ['/admin/api/users', 'MAINTENANCE', '公网后台 API'], ]) { await expectHttp( @@ -1167,6 +1188,15 @@ async function runSmokeCases( }, ); } + await rm(maintenancePageFile, { force: true }); + await expectHttp( + baseUrl, + '/', + 503, + 'default-maintenance', + '运行态公告缺失时回退 Web 制品默认维护页', + { headers: publicClientHeaders }, + ); await expectHttp( baseUrl, '/', diff --git a/scripts/deploy/maintenance-off.sh b/scripts/deploy/maintenance-off.sh index c47ada80d..7a3648399 100644 --- a/scripts/deploy/maintenance-off.sh +++ b/scripts/deploy/maintenance-off.sh @@ -3,10 +3,16 @@ set -euo pipefail MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}" +MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}" if [[ -f "${MAINTENANCE_FILE}" ]]; then - rm -f "${MAINTENANCE_FILE}" + rm -f -- "${MAINTENANCE_FILE}" echo "[maintenance] 已退出维护模式: ${MAINTENANCE_FILE}" else echo "[maintenance] 当前未处于维护模式: ${MAINTENANCE_FILE}" fi + +if [[ -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ]]; then + rm -f -- "${MAINTENANCE_PAGE_FILE}" + echo "[maintenance] 已清理本次运行态公告页: ${MAINTENANCE_PAGE_FILE}" +fi diff --git a/scripts/deploy/maintenance-on.sh b/scripts/deploy/maintenance-on.sh index ae9a54e3a..66df9ddb4 100644 --- a/scripts/deploy/maintenance-on.sh +++ b/scripts/deploy/maintenance-on.sh @@ -3,13 +3,59 @@ set -euo pipefail MAINTENANCE_FILE="${GENARRATIVE_MAINTENANCE_FILE:-/var/lib/genarrative/maintenance/enabled}" -REASON="${*:-manual}" +MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative/maintenance/page.html}" +PAGE_SOURCE="" +REASON_PARTS=() -mkdir -p "$(dirname "${MAINTENANCE_FILE}")" +while [[ $# -gt 0 ]]; do + case "$1" in + --page-file) + PAGE_SOURCE="${2:?缺少 --page-file 的值}" + shift 2 + ;; + -h|--help) + echo "用法: $0 [--page-file <临时公告 HTML>] [维护原因]" + exit 0 + ;; + --*) + echo "[maintenance] 未知参数: $1" >&2 + exit 1 + ;; + *) + REASON_PARTS+=("$1") + shift + ;; + esac +done + +REASON="${REASON_PARTS[*]:-manual}" + +if [[ -n "${PAGE_SOURCE}" && ( ! -f "${PAGE_SOURCE}" || ! -s "${PAGE_SOURCE}" ) ]]; then + echo "[maintenance] 临时公告页不存在或为空: ${PAGE_SOURCE}" >&2 + exit 1 +fi + +mkdir -p "$(dirname "${MAINTENANCE_FILE}")" "$(dirname "${MAINTENANCE_PAGE_FILE}")" + +if [[ -n "${PAGE_SOURCE}" ]]; then + page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")" + trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT + install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}" + mv -fT -- "${page_temp}" "${MAINTENANCE_PAGE_FILE}" + page_temp="" + echo "[maintenance] 已安装本次运行态公告页: ${MAINTENANCE_PAGE_FILE}" +elif [[ ! -f "${MAINTENANCE_FILE}" && ( -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ) ]]; then + rm -f -- "${MAINTENANCE_PAGE_FILE}" +fi + +marker_temp="$(mktemp "${MAINTENANCE_FILE}.tmp.XXXXXX")" { printf "enabled_at=%s\n" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" printf "reason=%s\n" "${REASON}" -} >"${MAINTENANCE_FILE}" +} >"${marker_temp}" -chmod 0644 "${MAINTENANCE_FILE}" +chmod 0644 "${marker_temp}" +mv -fT -- "${marker_temp}" "${MAINTENANCE_FILE}" +marker_temp="" +trap - EXIT echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}" diff --git a/server-rs/crates/pingora-gateway/src/main.rs b/server-rs/crates/pingora-gateway/src/main.rs index 8c080f789..a38896340 100644 --- a/server-rs/crates/pingora-gateway/src/main.rs +++ b/server-rs/crates/pingora-gateway/src/main.rs @@ -36,6 +36,7 @@ const DEFAULT_SPACETIME_UPSTREAM: &str = "127.0.0.1:3101"; const DEFAULT_WEB_ROOT: &str = "/srv/genarrative/web"; const DEFAULT_ACME_ROOT: &str = "/var/www/html"; const DEFAULT_MAINTENANCE_FILE: &str = "/var/lib/genarrative/maintenance/enabled"; +const DEFAULT_MAINTENANCE_PAGE_FILE: &str = "/var/lib/genarrative/maintenance/page.html"; const DEFAULT_MAX_API_BODY_BYTES: u64 = 64 * 1024 * 1024; const DEFAULT_GZIP_LEVEL: u32 = 5; const DEFAULT_GZIP_MIN_LENGTH_BYTES: u64 = 1024; @@ -134,6 +135,7 @@ struct GatewayConfig { web_root: PathBuf, acme_root: PathBuf, maintenance_file: PathBuf, + maintenance_page_file: PathBuf, forwarded_proto: String, max_api_body_bytes: u64, gzip_enabled: bool, @@ -195,6 +197,10 @@ impl GatewayConfig { "GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_FILE", DEFAULT_MAINTENANCE_FILE, )), + maintenance_page_file: PathBuf::from(read_env_or_default( + "GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE", + DEFAULT_MAINTENANCE_PAGE_FILE, + )), forwarded_proto: read_env_or_default( "GENARRATIVE_PINGORA_GATEWAY_FORWARDED_PROTO", "http", @@ -1198,8 +1204,13 @@ impl ProxyHttp for GenarrativeGateway { let should_apply_maintenance = !internal_bypass && (is_admin_request_path(path) || ctx.route.applies_maintenance_gate()); if should_apply_maintenance { - respond_maintenance(session, ctx.route.is_api_like(), &self.config.web_root) - .await?; + respond_maintenance( + session, + ctx.route.is_api_like(), + &self.config.maintenance_page_file, + &self.config.web_root, + ) + .await?; return Ok(true); } } @@ -2083,7 +2094,10 @@ async fn serve_static( ) -> PingoraResult<()> { let path = session.req_header().uri.path(); let Some(candidate) = resolve_static_candidate(root, root_kind, path, mode).await else { - respond_not_found(session).await?; + match root_kind { + StaticRoot::Web => respond_page_not_found(session, root).await?, + StaticRoot::Acme => respond_not_found(session).await?, + } return Ok(()); }; if !is_static_read_method(&session.req_header().method) { @@ -2537,6 +2551,7 @@ async fn is_directory(path: &Path) -> bool { async fn respond_maintenance( session: &mut Session, api_like: bool, + maintenance_page_file: &Path, web_root: &Path, ) -> PingoraResult<()> { if api_like { @@ -2548,9 +2563,17 @@ async fn respond_maintenance( .await; } - let maintenance_page = web_root.join("maintenance.html"); - if is_regular_file(&maintenance_page).await { - let body = Bytes::from(tokio::fs::read(&maintenance_page).await.unwrap_or_default()); + let default_maintenance_page = web_root.join("maintenance.html"); + let maintenance_page = if is_regular_file(maintenance_page_file).await { + Some(maintenance_page_file) + } else if is_regular_file(&default_maintenance_page).await { + Some(default_maintenance_page.as_path()) + } else { + None + }; + + if let Some(maintenance_page) = maintenance_page { + let body = Bytes::from(tokio::fs::read(maintenance_page).await.unwrap_or_default()); respond_bytes(session, 503, Some("text/html; charset=utf-8"), body).await } else { respond_bytes( @@ -2613,6 +2636,33 @@ async fn respond_not_found(session: &mut Session) -> PingoraResult<()> { respond_bytes(session, 404, None, Bytes::new()).await } +async fn respond_page_not_found(session: &mut Session, web_root: &Path) -> PingoraResult<()> { + let accepts_html = session + .req_header() + .headers + .get("accept") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .split(',') + .any(|entry| entry.trim().starts_with("text/html")) + }); + let not_found_page = web_root.join("404.html"); + if accepts_html && is_regular_file(¬_found_page).await { + let body = Bytes::from(tokio::fs::read(¬_found_page).await.unwrap_or_default()); + return respond_bytes_with_headers( + session, + 404, + Some("text/html; charset=utf-8"), + &[("Cache-Control".to_string(), "no-store".to_string())], + body, + ) + .await; + } + + respond_not_found(session).await +} + async fn respond_static_method_not_allowed(session: &mut Session) -> PingoraResult<()> { respond_head_with_headers( session, @@ -3225,6 +3275,7 @@ mod tests { web_root: PathBuf::from("/srv/genarrative/web"), acme_root: PathBuf::from("/var/www/html"), maintenance_file: PathBuf::from("/var/lib/genarrative/maintenance/enabled"), + maintenance_page_file: PathBuf::from("/var/lib/genarrative/maintenance/page.html"), forwarded_proto: "http".to_string(), max_api_body_bytes: DEFAULT_MAX_API_BODY_BYTES, gzip_enabled: true, diff --git a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx index d0298e996..dbdbf58db 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.recharge.test.tsx @@ -4133,6 +4133,9 @@ test('desktop home exposes one product h1 and section h2 headings', () => { expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(productHeading.closest('button')).toBeNull(); + expect(productHeading.closest('header')?.classList.contains('sr-only')).toBe( + false, + ); expect( screen.getByRole('heading', { level: 2, name: '今日游戏' }), ).toBeTruthy(); @@ -4142,7 +4145,7 @@ test('desktop home exposes one product h1 and section h2 headings', () => { ).toBeTruthy(); }); -test('mobile shell exposes one product h1 outside interactive content', () => { +test('mobile shell keeps the product h1 visually hidden outside interactive content', () => { renderLoggedOutHomeView(vi.fn(), {}, 'profile'); const productHeading = screen.getByRole('heading', { @@ -4152,6 +4155,9 @@ test('mobile shell exposes one product h1 outside interactive content', () => { expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1); expect(productHeading.closest('button')).toBeNull(); + expect(productHeading.closest('header')?.classList.contains('sr-only')).toBe( + true, + ); }); test('logged in create tab shows real wallet balance beside the brand', () => { diff --git a/src/components/rpg-entry/RpgEntryHomeView.tsx b/src/components/rpg-entry/RpgEntryHomeView.tsx index 78b66671a..702c6367e 100644 --- a/src/components/rpg-entry/RpgEntryHomeView.tsx +++ b/src/components/rpg-entry/RpgEntryHomeView.tsx @@ -1006,9 +1006,15 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) { ); } -function HomeSeoIntro() { +function HomeSeoIntro({ + visuallyHidden = false, +}: { + visuallyHidden?: boolean; +}) { return ( -
+

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

@@ -5029,7 +5035,7 @@ export function RpgEntryHomeView({ ) : null} - +
{tabPanels}