feat(gateway): add nginx gateway for maintenance mode fallback
Proxy frontend traffic through Nginx to handle service restarts gracefully Serve a static 503 maintenance page when frontend or backend is unavailable Update deployment design docs and docker-compose configuration
This commit is contained in:
@@ -930,6 +930,13 @@ API 暴露边界:
|
||||
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。
|
||||
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL,因此暂不输出 `hreflang`。
|
||||
|
||||
## 部署与升级维护
|
||||
|
||||
- Docker 部署时公开前端端口由 `frontend_gateway` 承载,正常流量代理到 `frontend` 服务。
|
||||
- `frontend` 因 `docker compose up -d --build` 重建、启动中或临时不可达时,`frontend_gateway` 返回静态升级维护页并保持公开端口可访问;后端 `/health` 不可用时,前端网关也返回同一维护页,避免用户看到静态页面后遇到 API 不可用。
|
||||
- 升级维护页是基础设施级静态 fallback,不依赖 Vue、Vue I18n、后端 API 或数据库;页面只展示正式用户文案和品牌视觉,不展示构建日志、调试信息、内部字段或实现说明。
|
||||
- 升级维护页使用 `503`、`Retry-After: 300`、`Cache-Control: no-store` 和 `noindex`,提示用户 Pokopia Wiki 正在升级并将在约 5 分钟内恢复。
|
||||
|
||||
## API 概览
|
||||
|
||||
公开浏览 API:
|
||||
|
||||
@@ -44,11 +44,21 @@ services:
|
||||
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||
environment:
|
||||
PORT: 20015
|
||||
ports:
|
||||
- "20015:20015"
|
||||
expose:
|
||||
- "20015"
|
||||
depends_on:
|
||||
- backend
|
||||
|
||||
frontend_gateway:
|
||||
image: nginx:1.29-alpine
|
||||
ports:
|
||||
- "20015:20015"
|
||||
volumes:
|
||||
- ./frontend/gateway/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
- ./frontend/gateway/maintenance.html:/usr/share/nginx/html/maintenance.html:ro
|
||||
depends_on:
|
||||
- frontend
|
||||
|
||||
volumes:
|
||||
postgres18_data:
|
||||
backend_uploads:
|
||||
|
||||
224
frontend/gateway/maintenance.html
Normal file
224
frontend/gateway/maintenance.html
Normal file
@@ -0,0 +1,224 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<meta http-equiv="refresh" content="30" />
|
||||
<title>Pokopia Wiki is upgrading</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--pokemon-yellow: #ffcb05;
|
||||
--pokemon-yellow-soft: #ffe46b;
|
||||
--pokemon-blue: #2a75bb;
|
||||
--pokemon-blue-deep: #003a70;
|
||||
--pokemon-red: #ee1515;
|
||||
--pokemon-red-deep: #cc0000;
|
||||
--bg: #f2f5fa;
|
||||
--bg-alt: #eaf1fb;
|
||||
--surface: #ffffff;
|
||||
--surface-soft: #f8fafd;
|
||||
--ink: #151923;
|
||||
--ink-soft: #354052;
|
||||
--muted: #687487;
|
||||
--line: #d8deea;
|
||||
--line-strong: #1f2a3b;
|
||||
--shadow-raised: 0 14px 32px rgba(23, 35, 54, .13);
|
||||
--font-sans: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: "Arial Rounded MT Bold", "Nunito", "Avenir Next Rounded", var(--font-sans);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: var(--font-sans);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
|
||||
linear-gradient(rgba(42, 117, 187, .08) 1px, transparent 1px) 0 0 / 32px 32px,
|
||||
linear-gradient(180deg, var(--bg) 0%, var(--bg-alt) 100%);
|
||||
}
|
||||
|
||||
main {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px 20px;
|
||||
}
|
||||
|
||||
.maintenance-card {
|
||||
width: min(100%, 560px);
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(31, 42, 59, .14);
|
||||
border-radius: 8px;
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-raised);
|
||||
}
|
||||
|
||||
.status-ribbon {
|
||||
height: 12px;
|
||||
background:
|
||||
linear-gradient(90deg, var(--pokemon-red) 0 28%, var(--line-strong) 28% 34%, var(--surface) 34% 66%, var(--line-strong) 66% 72%, var(--pokemon-blue) 72% 100%);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: clamp(28px, 6vw, 48px);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.mark {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
overflow: hidden;
|
||||
border: 4px solid var(--line-strong);
|
||||
border-radius: 50%;
|
||||
background:
|
||||
linear-gradient(180deg, var(--pokemon-red) 0 45%, var(--line-strong) 45% 55%, var(--surface) 55% 100%);
|
||||
box-shadow: 0 4px 0 rgba(31, 42, 59, .2);
|
||||
}
|
||||
|
||||
.mark::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 13px;
|
||||
border: 4px solid var(--line-strong);
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
display: block;
|
||||
color: var(--pokemon-yellow);
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
font-weight: 900;
|
||||
line-height: .95;
|
||||
-webkit-text-stroke: 2px var(--pokemon-blue-deep);
|
||||
text-shadow: 2px 3px 0 var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.brand-subtitle {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
color: var(--muted);
|
||||
font-size: .78rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--pokemon-blue) 28%, var(--line));
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-size: .82rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.2;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 20px 0 10px;
|
||||
color: var(--ink);
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.04;
|
||||
}
|
||||
|
||||
p {
|
||||
max-width: 38rem;
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 1.12rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.meter {
|
||||
height: 12px;
|
||||
margin-top: 30px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.meter span {
|
||||
display: block;
|
||||
width: 70%;
|
||||
height: 100%;
|
||||
border-right: 1px solid rgba(31, 42, 59, .28);
|
||||
background: linear-gradient(90deg, var(--pokemon-yellow) 0%, var(--pokemon-yellow-soft) 46%, var(--pokemon-blue) 100%);
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
main {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.maintenance-card {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 1.65rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main aria-labelledby="maintenance-title">
|
||||
<section class="maintenance-card" aria-live="polite">
|
||||
<div class="status-ribbon" aria-hidden="true"></div>
|
||||
<div class="content">
|
||||
<div class="brand">
|
||||
<span class="mark" aria-hidden="true"></span>
|
||||
<div>
|
||||
<span class="brand-name">Pokopia</span>
|
||||
<span class="brand-subtitle">Wiki</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="status">Upgrading</span>
|
||||
<h1 id="maintenance-title">Pokopia Wiki is upgrading</h1>
|
||||
<p>We'll be online within 5 minutes.</p>
|
||||
<div class="meter" aria-hidden="true"><span></span></div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
45
frontend/gateway/nginx.conf
Normal file
45
frontend/gateway/nginx.conf
Normal file
@@ -0,0 +1,45 @@
|
||||
server {
|
||||
listen 20015;
|
||||
server_name _;
|
||||
|
||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||
|
||||
location / {
|
||||
auth_request /backend-health;
|
||||
error_page 500 502 503 504 =503 /maintenance.html;
|
||||
|
||||
set $frontend_upstream http://frontend:20015;
|
||||
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
proxy_connect_timeout 1s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_intercept_errors on;
|
||||
|
||||
proxy_pass $frontend_upstream;
|
||||
}
|
||||
|
||||
location = /backend-health {
|
||||
internal;
|
||||
set $backend_upstream http://backend:3001/health;
|
||||
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_connect_timeout 1s;
|
||||
proxy_read_timeout 1s;
|
||||
|
||||
proxy_pass $backend_upstream;
|
||||
}
|
||||
|
||||
location = /maintenance.html {
|
||||
internal;
|
||||
root /usr/share/nginx/html;
|
||||
add_header Cache-Control "no-store" always;
|
||||
add_header Retry-After "300" always;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user