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

新增品牌维护页和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
+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}"