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、调试信息或实现说明。
|
- SEO metadata 只能使用公开业务数据和系统文案;不得暴露邮箱、权限 key、token/hash、内部审计 payload、调试信息或实现说明。
|
||||||
- 多语言 metadata 使用当前前端语言和系统文案回退机制;当前没有语言专属 URL,因此暂不输出 `hreflang`。
|
- 多语言 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 概览
|
||||||
|
|
||||||
公开浏览 API:
|
公开浏览 API:
|
||||||
|
|||||||
@@ -44,11 +44,21 @@ services:
|
|||||||
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
|
VITE_SITE_URL: ${VITE_SITE_URL:-https://pokopiawiki.tootaio.com}
|
||||||
environment:
|
environment:
|
||||||
PORT: 20015
|
PORT: 20015
|
||||||
ports:
|
expose:
|
||||||
- "20015:20015"
|
- "20015"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- 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:
|
volumes:
|
||||||
postgres18_data:
|
postgres18_data:
|
||||||
backend_uploads:
|
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