统一维护与错误页面并修复移动端顶部文案

新增品牌维护页和404页面并补齐网关路由
将临时维护公告迁到release外运行态覆盖并增加构建门禁
同步Nginx、Pingora、部署脚本与生产运维文档
隐藏移动端顶部SEO介绍并保留桌面端与H1语义
增加维护页、404页和移动端回归测试
This commit is contained in:
2026-07-13 16:26:27 +08:00
parent 36a8719d0f
commit f4153ebfa7
23 changed files with 781 additions and 144 deletions
+1
View File
@@ -298,6 +298,7 @@ server {
location / {
error_page 503 /maintenance.html;
error_page 404 /404.html;
if ($genarrative_maintenance) {
return 503;
+1
View File
@@ -318,6 +318,7 @@ server {
location / {
error_page 503 /maintenance.html;
error_page 404 /404.html;
if ($genarrative_maintenance) {
return 503;
@@ -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;
}
@@ -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
@@ -3970,6 +3970,7 @@
- 背景:主站 Nginx 和 Pingora 原先会把任意未知路径回退到 `index.html`,导致 soft 404;共享 `index.html` 也缺少首页 SEO headrobots 和 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`
+10 -2
View File
@@ -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`
## 遮罩点击关闭必须校验完整指针序列
- 现象:在弹窗内容内按下鼠标,拖到弹窗外的遮罩上松开时,弹窗被误关闭。
@@ -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`,避免直连后错误客户端把静态入口当作可写接口。
@@ -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,也不再默认使用 `https://git.genarrative.world/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。
+2 -1
View File
@@ -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 .",
+213
View File
@@ -0,0 +1,213 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<link rel="icon" type="image/png" href="/branding/taonier-product-ip.png" />
<title>页面无法连接 - 陶泥儿</title>
<style>
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
min-height: 100%;
margin: 0;
background: #fff8f4;
}
body {
min-height: 100vh;
min-height: 100dvh;
overflow: hidden;
}
.page-art {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
.mobile-page {
position: absolute;
visibility: hidden;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-aspect-ratio: 4 / 5) {
.page-art {
object-fit: contain;
}
}
@media (max-width: 680px) {
body {
overflow: auto;
}
.page-art {
display: none;
}
.mobile-page {
position: relative;
visibility: visible;
display: flex;
width: 100%;
min-height: 100vh;
min-height: 100dvh;
height: auto;
margin: 0;
padding: 36px 24px 30px;
overflow: visible;
clip: auto;
flex-direction: column;
align-items: center;
justify-content: center;
color: #563018;
text-align: center;
white-space: normal;
}
.mobile-page h1 {
margin: 0;
color: #ef762f;
font-size: 68px;
line-height: 1;
letter-spacing: 0;
}
.mobile-page h2 {
margin: 12px 0 0;
font-size: 28px;
line-height: 1.3;
letter-spacing: 0;
}
.mobile-page p {
max-width: 300px;
margin: 12px 0 0;
color: #86563d;
font-size: 15px;
line-height: 1.7;
letter-spacing: 0;
}
.mobile-character {
width: min(64vw, 250px);
height: auto;
margin-top: 20px;
}
.mobile-actions {
display: flex;
gap: 12px;
margin-top: 18px;
}
.mobile-actions a,
.mobile-actions button {
display: inline-flex;
height: 42px;
align-items: center;
justify-content: center;
padding: 0 18px;
border: 1px solid #e98a54;
border-radius: 8px;
background: #fff8f4;
color: #d86228;
font: inherit;
font-size: 14px;
font-weight: 700;
letter-spacing: 0;
text-decoration: none;
cursor: pointer;
}
.mobile-actions .primary {
background: #ef7939;
color: #fff;
box-shadow: 0 8px 20px rgb(205 100 44 / 16%);
}
.mobile-actions a:focus-visible,
.mobile-actions button:focus-visible {
outline: 3px solid rgb(239 121 57 / 28%);
outline-offset: 3px;
}
}
@media (max-width: 680px) and (max-height: 720px) {
.mobile-page {
padding-top: 24px;
padding-bottom: 22px;
}
.mobile-page h1 {
font-size: 58px;
}
.mobile-page h2 {
margin-top: 8px;
font-size: 24px;
}
.mobile-page p {
margin-top: 8px;
}
.mobile-character {
width: 190px;
margin-top: 12px;
}
.mobile-actions {
margin-top: 12px;
}
}
</style>
</head>
<body>
<img
class="page-art"
src="/branding/taonier-404-page.png"
alt="404 页面无法连接,您访问的页面不存在或已被移除"
width="1672"
height="941"
/>
<main class="mobile-page">
<h1>404</h1>
<h2>页面无法连接</h2>
<p>抱歉,您访问的页面不存在或已被移除。</p>
<img
class="mobile-character"
src="/branding/taonier-404-character.png"
alt="陶泥儿从陶罐中探出头,旁边放着施工路障"
width="470"
height="590"
/>
<div class="mobile-actions">
<button
type="button"
onclick="window.history.length > 1 ? window.history.back() : (window.location.href = '/')"
>
返回上一页
</button>
<a class="primary" href="/">前往首页</a>
</div>
</main>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 886 KiB

+117 -106
View File
@@ -4,44 +4,72 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="robots" content="noindex, nofollow" />
<title>陶泥儿停服升级公告</title>
<link rel="icon" type="image/png" href="/branding/taonier-product-ip.png" />
<title>服务维护中 - 陶泥儿</title>
<style>
:root {
color-scheme: dark;
color-scheme: light;
font-family:
Inter,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
"Segoe UI",
sans-serif;
background: #101418;
color: #f7f9fb;
color: #5d2c13;
background: #fff9f5;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
min-height: 100%;
margin: 0;
}
body {
min-height: 100vh;
background: #101418;
min-height: 100dvh;
overflow: hidden;
background: #fff9f5;
}
.page-art {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
main {
width: min(100% - 32px, 720px);
margin: 0 auto;
padding: 56px 0 64px;
position: relative;
z-index: 1;
display: flex;
min-height: 100vh;
min-height: 100dvh;
align-items: center;
justify-content: center;
padding: 72px 24px 120px;
text-align: center;
}
.content {
width: min(100%, 680px);
}
.status {
display: flex;
display: inline-flex;
align-items: center;
gap: 10px;
margin-bottom: 22px;
color: #ffb36b;
font-size: 14px;
margin-bottom: 20px;
color: #e76d2f;
font-size: 15px;
font-weight: 700;
}
@@ -50,138 +78,121 @@
height: 10px;
flex: 0 0 10px;
border-radius: 50%;
background: #ff8a3d;
box-shadow: 0 0 0 5px rgb(255 138 61 / 14%);
background: #f27a35;
box-shadow: 0 0 0 6px rgb(242 122 53 / 13%);
}
h1 {
margin: 0;
font-size: 32px;
color: #6a3216;
font-size: 42px;
line-height: 1.25;
letter-spacing: 0;
}
.lead {
p {
margin: 18px 0 0;
color: #d5dde5;
font-size: 17px;
color: #86563d;
font-size: 18px;
line-height: 1.8;
letter-spacing: 0;
}
.window {
display: grid;
grid-template-columns: auto 1fr;
gap: 8px 18px;
margin: 30px 0 0;
padding: 18px 20px;
border: 1px solid #3c4650;
border-left: 4px solid #ff8a3d;
border-radius: 6px;
background: #171d22;
button {
min-width: 132px;
height: 44px;
margin-top: 30px;
padding: 0 24px;
border: 1px solid #ed8a51;
border-radius: 8px;
background: #ef7939;
color: #fff;
font: inherit;
font-size: 15px;
font-weight: 700;
letter-spacing: 0;
cursor: pointer;
box-shadow: 0 8px 20px rgb(205 100 44 / 16%);
}
.window-label {
color: #9eabb8;
button:hover {
background: #df682d;
}
button:focus-visible {
outline: 3px solid rgb(239 121 57 / 28%);
outline-offset: 3px;
}
@media (max-width: 680px) {
main {
padding: 52px 38px 108px;
}
.status {
margin-bottom: 16px;
font-size: 14px;
}
.window-value {
color: #fff2e7;
h1 {
font-size: 32px;
}
p {
margin-top: 14px;
font-size: 16px;
font-weight: 750;
}
section {
margin-top: 34px;
padding-top: 28px;
border-top: 1px solid #303942;
button {
margin-top: 24px;
}
}
h2 {
margin: 0 0 14px;
font-size: 18px;
line-height: 1.5;
}
ol {
margin: 0;
padding-left: 24px;
color: #d5dde5;
}
li {
margin: 10px 0;
padding-left: 4px;
line-height: 1.75;
}
.feature {
margin: 0;
padding-left: 16px;
border-left: 3px solid #53c7a3;
color: #d5dde5;
line-height: 1.8;
}
.feature strong {
color: #7be0c0;
}
@media (max-width: 520px) {
@media (max-height: 620px) {
main {
width: min(100% - 28px, 720px);
padding: 38px 0 48px;
padding-top: 44px;
padding-bottom: 76px;
}
.status {
margin-bottom: 12px;
}
h1 {
font-size: 27px;
font-size: 30px;
}
.lead {
font-size: 16px;
p {
margin-top: 10px;
font-size: 15px;
}
.window {
grid-template-columns: 1fr;
button {
height: 40px;
margin-top: 18px;
}
}
</style>
</head>
<body>
<img
class="page-art"
src="/branding/taonier-maintenance-page.png"
alt=""
aria-hidden="true"
width="1672"
height="941"
/>
<main>
<div class="content">
<div class="status">
<span class="status-mark" aria-hidden="true"></span>
<span>服务维护中</span>
<span>系统维护中</span>
</div>
<h1>陶泥儿美术工具停服升级</h1>
<p class="lead">
各位陶泥儿创作者,美术工具官网将在今天晚上 21:00~22:00 停服升级。<br />
注意保存好自己的项目和下载好素材。
</p>
<div class="window" aria-label="维护时间">
<span class="window-label">维护时间</span>
<span class="window-value">今天晚上 21:00~22:00</span>
<h1>服务维护中</h1>
<p>我们正在更新系统,稍后请重新访问。</p>
<button type="button" onclick="window.location.reload()">刷新页面</button>
</div>
<section>
<h2>本次更新修复内容</h2>
<ol>
<li>修复多个影响画布使用的 BUG。</li>
<li>
修复了 PC 端不能充值的恶性 BUG(bushi),PC
网页端支持微信二维码充值。
</li>
</ol>
</section>
<section>
<h2>新增功能</h2>
<p class="feature">
<strong>抠图与背景色功能升级:</strong
>使用更强、更稳定的抠图技术,解决角色扣残问题,角色动作的逐帧抠图同步升级。
</p>
</section>
</main>
</body>
</html>
+11
View File
@@ -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 {} +
+203
View File
@@ -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 = '<!doctype html><title>planned maintenance</title>\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] 通过');
+14
View File
@@ -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) {
+38 -8
View File
@@ -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'),
'<main>maintenance</main>',
'<main>default-maintenance</main>',
);
await writeFile(path.join(webRoot, '404.html'), '<main>not-found-page</main>');
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, '<main>runtime-maintenance</main>');
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,
'/',
+7 -1
View File
@@ -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
+50 -4
View File
@@ -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}"
+56 -5
View File
@@ -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,7 +1204,12 @@ 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)
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(&not_found_page).await {
let body = Bytes::from(tokio::fs::read(&not_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,
@@ -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', () => {
@@ -1006,9 +1006,15 @@ function SectionHeader({ title, detail }: { title: string; detail: string }) {
);
}
function HomeSeoIntro() {
function HomeSeoIntro({
visuallyHidden = false,
}: {
visuallyHidden?: boolean;
}) {
return (
<header className="mb-2 min-w-0 shrink-0 px-1">
<header
className={visuallyHidden ? 'sr-only' : 'mb-2 min-w-0 shrink-0 px-1'}
>
<h1 className="text-sm font-semibold leading-5 text-[var(--platform-text-strong)]">
AI工作台
</h1>
@@ -5029,7 +5035,7 @@ export function RpgEntryHomeView({
</div>
) : null}
<HomeSeoIntro />
<HomeSeoIntro visuallyHidden />
<div className="platform-tab-panel-stack min-w-0 flex-1">
{tabPanels}