Files
danyang-match/infrastructure/nginx-production.conf
T
WorkBuddy f96dcb8aa6 fix(admin): 管理端入口收敛到 /admin/ 子路径,staging 端口 13521,修复生产 /admin/ 假成功
承接 rc.15 的管理端端口收敛(3000 → 13520),本轮把「入口」也收敛到一处:

- staging admin 发布端口 3001 → 13521(保持「生产 + 1」错开),同步
  tests/uat/run-uat.sh 的默认 UAT_ADMIN_URL。
- 管理端产物改为 /admin/ 子路径部署:vite 构建 base=/admin/(dev 仍为 /,开发与
  排查习惯不变)、router history base 跟随 import.meta.env.BASE_URL、三处容器化
  入口(生产 nginx / 本地整套栈 nginx / admin-web 容器内 nginx)统一为「不剥离
  前缀」转发,形态一致。
- 修复生产 nginx /admin/ 的假成功:原为 alias /usr/share/nginx/html/,而那正是
  nginx:alpine 纯净镜像自带的默认页目录(管理端产物不在该容器内),访问返回
  200 + nginx 欢迎页 —— 浏览器不报错、状态码正常,只是打开的不是管理端,比 404
  更难发现。改为 upstream admin_web + proxy_pass,并以真实两跳容器实测:
  /admin/ → 200 且为管理端标题与 /admin/assets/*、/admin/dashboard → 200(SPA
  兜底)、/admin/brand/logo-mark.png → 200、根路径 → 显式 404(用 location = /
  钉住,不依赖「本配置恰好没有 server 级 root」这一偶然事实)。
- 品牌资源引用统一走新增的 src/brand.ts(public/ 资源不受 vite base 影响,原 5 处
  写死根路径,子路径部署下会裂图);favicon 由仓库内并不存在的 /favicon.svg 改为
  存在的品牌标。
- 补全 tests/uat/run-uat.sh 被截断的尾部:原文件停在 `if [ -`,bash -n 不通过、
  脚本根本无法运行;顺带把此前「定义了却无人消费」的 ADMIN_URL 接到管理端可达性
  实测上,并补结果汇总与非零退出码。
- ui-tests:ui-document-baseline-contracts 新增两类一致性断言(staging 发布端口 ⇄
  run-uat.sh 默认入口、子路径部署的五处约束);admin-reference-contracts 的资产
  断言适配 brand 模块(断言「组件经 brand 模块引用」+「brand 模块指向正式资产」)。
  6 条新增断言全部经负向注入验证(注入对应缺陷 → 守卫变红且信息指向正确对象)。

验证:UI 门控 14/14 绿;vue-tsc EXIT=0;vite build EXIT=0 且产物资源均为
/admin/ 前缀;三份 nginx 配置 nginx -t 通过。

注:docs/STATUS.md 含此前的在途文档改动(非本次工作,且其中引用了已被删除的
plans 文件),故未纳入本提交;本轮的口径更新已写入该文件待一并处理。
2026-09-18 20:51:12 +08:00

188 lines
7.2 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Production Nginx Configuration
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 2048;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format production '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time';
access_log /var/log/nginx/access.log production;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=50r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=upload:10m rate=5r/s;
limit_req_zone $binary_remote_addr zone=ws:10m rate=20r/s;
# Maintenance mode flag
map $http_x_maintenance_mode $maintenance_mode {
default 0;
"1" 1;
}
upstream backend {
server backend:8080;
}
# 管理端容器(同一 danyang-net 网络,服务名 admin-web —— 见 docker-compose.prod.yml)。
# 本容器是 nginx:alpine 纯净镜像,自身不含管理端产物,只能靠代理取。
upstream admin_web {
server admin-web:80;
}
# HTTP -> HTTPS redirect (uncomment for production)
# server {
# listen 80;
# server_name match.danyang.gov.cn;
# return 301 https://$server_name$request_uri;
# }
server {
listen 443 ssl http2;
server_name match.danyang.gov.cn;
# SSL
ssl_certificate /etc/nginx/certs/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "DENY" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' wss:;" always;
# Maintenance mode
location = /maintenance {
return 503 '<h1>系统维护中</h1><p>请稍后再访问。</p>';
}
# 根路径没有内容(管理端在 /admin/ 下,小程序走 /api/)。
# 显式 404:当前之所以是 404,只是因为本配置没有 server 级 root,
# 而 nginx 默认的 html 目录(/etc/nginx/html)不存在 —— 属偶然事实。
# 一旦有人为了别的用途补上 root,根路径就会开始返回那个目录下的内容。
# 用精确匹配钉住这一行为,且不影响其它未匹配路径的既有处理。
location = / {
return 404;
}
# Health checks (no auth needed)
location = /api/v1/health/live {
proxy_pass http://backend;
proxy_set_header Host $host;
}
location = /api/v1/health/ready {
proxy_pass http://backend;
proxy_set_header Host $host;
}
location = /version {
proxy_pass http://backend;
proxy_set_header Host $host;
}
# Admin Web (SPA) —— 子路径部署在 /admin/ 下(构建 base=/admin/,见 admin-web/vite.config.ts)。
#
# 2026-09-18 修正:此处原为 `alias /usr/share/nginx/html/;`,而本容器是 nginx:alpine
# 纯净镜像 —— 那个目录里只有镜像自带的欢迎页,并没有管理端产物。结果是访问 /admin/
# 返回 200 + nginx 欢迎页,属于"假成功",比 404 更难被发现(页面能打开,但不是管理端)。
# 真正的产物在 admin-web 容器内,故改为代理到 upstream。
#
# 代理**不剥离** /admin 前缀:管理端构建 base 为 /admin/,其 index.html 引用的资源
# 本身就以 /admin/ 开头,由 admin-web 容器内的 nginx(admin-web/nginx-admin.conf)
# 统一处理这个前缀,三处入口(生产 / 本地整套栈 / staging 直连)因此形态一致。
#
# 暴露面提醒:管理端登录页不应无条件对公网开放。当前依赖外层防火墙/WAF 控制;
# 若需在 nginx 层收敛,在此 location 内加 allow/deny 白名单(运营办公网段 + VPN)。
location = /admin {
return 301 /admin/;
}
location /admin/ {
# 只写 upstream 名,不写端口:upstream 块里已声明 `server admin-web:80;`,
# 此处再带 :80 会让 nginx 报 `upstream "admin_web" may not have port 80` 而拒绝启动。
proxy_pass http://admin_web;
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;
}
# API proxy
location /api/ {
limit_req zone=api burst=100 nodelay;
proxy_pass http://backend;
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 30s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# WebSocket proxy
location /ws/ {
limit_req zone=ws burst=20 nodelay;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 3600s;
}
# Auth endpoints (strict rate limiting)
location ~* ^/api/v1/auth/ {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Upload endpoints
location ~* ^/api/v1/photos/upload {
limit_req zone=upload burst=5 nodelay;
client_max_body_size 10M;
proxy_pass http://backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# MinIO public bucket (read-only)
location /uploads/ {
proxy_pass http://backend/;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, immutable";
}
}
}