修复维护页品牌图片加载
Project CI / Repository checks (push) Successful in 1m4s
Project CI / Frontend tests (push) Successful in 2m2s
Project CI / Native shell tests (push) Successful in 2m28s
Project CI / Backend tests (push) Successful in 3m3s

Nginx 精确放行维护页背景图与品牌图标
Pingora 同步维护资产白名单
补充维护模式网关回归测试
更新生产运维与共享决策文档
This commit is contained in:
2026-07-27 21:57:30 +08:00
parent a5906ac26e
commit 47f00dae4d
7 changed files with 71 additions and 2 deletions
@@ -9,6 +9,17 @@ if ($genarrative_internal_client) {
set $genarrative_maintenance 0;
}
# 维护页自身依赖的品牌图片必须在维护期间保持可读;只放行精确文件,避免扩大公网静态面。
location = /branding/taonier-maintenance-page.png {
root /srv/genarrative/web;
try_files /branding/taonier-maintenance-page.png =404;
}
location = /branding/taonier-product-ip.png {
root /srv/genarrative/web;
try_files /branding/taonier-product-ip.png =404;
}
location = /maintenance.html {
root /var/lib/genarrative/maintenance;
try_files /page.html @genarrative_default_maintenance;
@@ -4307,7 +4307,7 @@
## 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 制品默认页。
- 决策:`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 制品默认页。维护期间只精确放行 `/branding/taonier-maintenance-page.png``/branding/taonier-product-ip.png`,不得扩大到整个品牌或静态资源目录;网关 smoke 必须验证这两个路径仍返回 PNG,同时其它公网页面、API 与后台静态资源继续命中维护门禁。
- 生命周期:新维护窗口未提供 `--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`
@@ -92,6 +92,8 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
维护门禁启用时,Nginx 与 Pingora 只精确放行默认维护页依赖的 `/branding/taonier-maintenance-page.png``/branding/taonier-product-ip.png`;不得放开整个 `/branding/``/assets/` 或后台静态目录。公网验收除主站、后台和 API 继续返回维护响应外,还必须确认这两个品牌图片返回 `200 image/png`,避免维护页 HTML 正常但背景图请求被再次改写成 `503`
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `source_module = editor-canvas``job_kind = worker_smoke_unsupported` 的测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,用队列概览 BFF、单 job 状态接口和 worker 日志确认任务从 queued/running 收敛到预期失败态。
本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev``npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun``POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。
+5
View File
@@ -148,6 +148,10 @@ function validateGatewayConfiguration() {
'try_files /page.html @genarrative_default_maintenance;',
'location @genarrative_default_maintenance',
'root /srv/genarrative/web;',
'location = /branding/taonier-maintenance-page.png',
'try_files /branding/taonier-maintenance-page.png =404;',
'location = /branding/taonier-product-ip.png',
'try_files /branding/taonier-product-ip.png =404;',
]) {
if (!nginxSnippet.includes(expected)) {
fail(`Nginx 维护页配置缺少运行态覆盖约束: ${expected}`);
@@ -161,6 +165,7 @@ function validateGatewayConfiguration() {
for (const expected of [
'GENARRATIVE_PINGORA_GATEWAY_MAINTENANCE_PAGE_FILE',
'maintenance_page_file',
'is_maintenance_page_asset(path)',
]) {
if (!pingoraSource.includes(expected)) {
fail(`Pingora 维护页配置缺少运行态覆盖约束: ${expected}`);
+4
View File
@@ -164,6 +164,10 @@ function validateMaintenanceInternalBypass() {
fail(`${MAINTENANCE_SNIPPET_PATH} 不应保留仅后台使用的维护变量。`);
}
for (const fragment of [
'location = /branding/taonier-maintenance-page.png {',
'try_files /branding/taonier-maintenance-page.png =404;',
'location = /branding/taonier-product-ip.png {',
'try_files /branding/taonier-product-ip.png =404;',
'location = /404.html {',
'if ($http_accept !~* "text/html") {',
'try_files /404.html =404;',
+23 -1
View File
@@ -347,6 +347,19 @@ async function prepareStaticRoots(webRoot, acmeRoot) {
path.join(webRoot, 'maintenance.html'),
'<main>default-maintenance</main>',
);
await mkdir(path.join(webRoot, 'branding'), { recursive: true });
for (const fileName of [
'taonier-maintenance-page.png',
'taonier-product-ip.png',
]) {
await writeFile(
path.join(webRoot, 'branding', fileName),
Buffer.concat([
PNG_MAGIC_BYTES,
Buffer.from([0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52]),
]),
);
}
await writeFile(path.join(webRoot, '404.html'), '<main>not-found-page</main>');
await writeFile(
path.join(acmeRoot, '.well-known', 'acme-challenge', 'token'),
@@ -1204,6 +1217,14 @@ async function runSmokeCases(
},
);
}
for (const path of [
'/branding/taonier-maintenance-page.png',
'/branding/taonier-product-ip.png',
]) {
await expectStaticPng(baseUrl, path, `维护模式放行品牌图片 ${path}`, {
headers: publicClientHeaders,
});
}
await rm(maintenancePageFile, { force: true });
await expectHttp(
baseUrl,
@@ -1433,9 +1454,10 @@ async function expectNotCompressedResponse(baseUrl, route, bodyNeedle, label) {
}
}
async function expectStaticPng(baseUrl, route, label) {
async function expectStaticPng(baseUrl, route, label, options = {}) {
console.log(`[pingora-gateway-smoke] ${label}`);
const response = await requestHttp(`${baseUrl}${route}`, {
headers: options.headers,
rawBody: true,
});
@@ -1117,6 +1117,7 @@ impl ProxyHttp for GenarrativeGateway {
let internal_bypass =
allows_internal_maintenance_bypass(request_source_ip(session).as_ref());
let should_apply_maintenance = !internal_bypass
&& !is_maintenance_page_asset(path)
&& (is_admin_request_path(path) || ctx.route.applies_maintenance_gate());
if should_apply_maintenance {
respond_maintenance(
@@ -1887,6 +1888,13 @@ fn is_main_spa_path(path: &str) -> bool {
.any(|candidate| normalized.eq_ignore_ascii_case(candidate))
}
fn is_maintenance_page_asset(path: &str) -> bool {
matches!(
path,
"/branding/taonier-maintenance-page.png" | "/branding/taonier-product-ip.png"
)
}
fn classify_http_redirect_path(path: &str) -> RouteDecision {
if path.starts_with("/.well-known/acme-challenge/") {
return RouteDecision::Local(LocalResponse::Static {
@@ -3308,6 +3316,23 @@ mod tests {
assert!(!allows_internal_maintenance_bypass(None));
}
#[test]
fn maintenance_only_allows_required_branding_assets() {
for path in [
"/branding/taonier-maintenance-page.png",
"/branding/taonier-product-ip.png",
] {
assert!(is_maintenance_page_asset(path));
}
for path in [
"/branding/other.png",
"/branding/taonier-maintenance-page.png/extra",
"/assets/app.js",
] {
assert!(!is_maintenance_page_asset(path));
}
}
#[test]
fn normalizes_gateway_hosts_for_matching() {
assert_eq!(