统一维护与错误页面并修复移动端顶部文案
新增品牌维护页和404页面并补齐网关路由 将临时维护公告迁到release外运行态覆盖并增加构建门禁 同步Nginx、Pingora、部署脚本与生产运维文档 隐藏移动端顶部SEO介绍并保留桌面端与H1语义 增加维护页、404页和移动端回归测试
This commit is contained in:
@@ -298,6 +298,7 @@ server {
|
||||
|
||||
location / {
|
||||
error_page 503 /maintenance.html;
|
||||
error_page 404 /404.html;
|
||||
|
||||
if ($genarrative_maintenance) {
|
||||
return 503;
|
||||
|
||||
@@ -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 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`。
|
||||
|
||||
@@ -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
@@ -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
@@ -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 |
+126
-115
@@ -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;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.window-label {
|
||||
color: #9eabb8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.window-value {
|
||||
color: #fff2e7;
|
||||
font-size: 16px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-top: 34px;
|
||||
padding-top: 28px;
|
||||
border-top: 1px solid #303942;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 14px;
|
||||
color: #86563d;
|
||||
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;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.feature strong {
|
||||
color: #7be0c0;
|
||||
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%);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
button:hover {
|
||||
background: #df682d;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 3px solid rgb(239 121 57 / 28%);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 680px) {
|
||||
main {
|
||||
width: min(100% - 28px, 720px);
|
||||
padding: 38px 0 48px;
|
||||
padding: 52px 38px 108px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 27px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.lead {
|
||||
p {
|
||||
margin-top: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.window {
|
||||
grid-template-columns: 1fr;
|
||||
button {
|
||||
margin-top: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-height: 620px) {
|
||||
main {
|
||||
padding-top: 44px;
|
||||
padding-bottom: 76px;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 30px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 10px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
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="status">
|
||||
<span class="status-mark" aria-hidden="true"></span>
|
||||
<span>服务维护中</span>
|
||||
<div class="content">
|
||||
<div class="status">
|
||||
<span class="status-mark" aria-hidden="true"></span>
|
||||
<span>系统维护中</span>
|
||||
</div>
|
||||
<h1>服务维护中</h1>
|
||||
<p>我们正在更新系统,稍后请重新访问。</p>
|
||||
<button type="button" onclick="window.location.reload()">刷新页面</button>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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 {} +
|
||||
|
||||
@@ -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] 通过');
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
'/',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user