返回文章

blog 项目 Ubuntu 22.04 部署手册(下篇:项目部署与配置)

blog 项目专属部署配置:前后端环境变量、Nginx 站点配置、Jenkins 流水线、发布顺序、常见问题排查、安全加固、version.json 发布检测与维护命令。

发布于 2026-07-03阅读量 46字数 9772
后端Node.js

下篇:项目部署与配置

> 本篇为 blog 项目专属配置,包含前后端部署、Nginx 站点配置、Jenkins 流水线、发布流程和运维排查。

7. 项目部署关系总览

| 模块 | 仓库 | 部署目录 | 访问路径 | 说明 |

| --- | --- | --- | --- | --- |

| 后端服务 | 你的后端Git仓库地址 | /www/blog/serve | /blog_api/ | NestJS,端口 4001 |

| 前台 web | 你的前台Git仓库地址 | /www/blog/web/dist | / | Vue/Vite 博客前台 |

| 后台 admin | 你的后台Git仓库地址 | /www/blog/admin/dist | /admin/ | Vue/Vite 后台管理 |

生产核心配置:


服务器 IP: 你的服务器IP或域名

后端端口: 4001

MySQL: 127.0.0.1:3306/blog_system

MySQL 密码: <不要写真实密码>

Redis: 127.0.0.1:6379

Redis 密码: <不要写真实密码>

PM2 名称: blog-server

Jenkins 凭据 ID: <不要写真实凭据ID>

8. 后端生产环境配置与部署

后端仓库中的 .env.production 应包含:


NODE_ENV=production

APP_ENV=production

DB_HOST=127.0.0.1

DB_PORT=3306

DB_USER=root

DB_PASSWORD=你的MySQL密码

DB_NAME=blog_system

DATABASE_URL="mysql://root:URL编码后的MySQL密码@127.0.0.1:3306/blog_system"

PORT=4001

REDIS_ENABLED=true

REDIS_HOST=127.0.0.1

REDIS_PORT=6379

REDIS_PASSWORD=你的Redis密码

REDIS_DB=0

REDIS_KEY_PREFIX=blog:

手动部署后端:


cd /www/blog

git clone 你的后端Git仓库地址 serve

cd /www/blog/serve

npm install --registry=https://registry.npmmirror.com

npm run build:prod



pm2 start dist/main.js --name blog-server

pm2 save

pm2 startup

验证:


pm2 status

pm2 logs blog-server --lines 100

curl http://127.0.0.1:4001/api/v1

说明:

当前后端启动时会自动创建 blog_system,并在首次启动时导入 ruoyi.sql

如果已存在旧数据,部署前先备份。

9. 前端生产环境配置与部署

admin .env.production


VITE_APP_BASE=/admin/

VITE_API_BASE=/blog_api/api/v1

VITE_PROXY_TARGET=http://你的服务器IP或域名

web .env.production


VITE_API_BASE=/blog_api/api/v1

VITE_API_ORIGIN=http://你的服务器IP或域名

VITE_PROXY_TARGET=http://你的服务器IP或域名

手动部署 web:


cd /www/blog

git clone 你的前台Git仓库地址 web-src

cd /www/blog/web-src

npm install --registry=https://registry.npmmirror.com

npm run build:prod

rsync -av --delete dist/ /www/blog/web/dist/

手动部署 admin:


cd /www/blog

git clone 你的后台Git仓库地址 admin-src

cd /www/blog/admin-src

npm install --registry=https://registry.npmmirror.com

npm run build:prod

rsync -av --delete dist/ /www/blog/admin/dist/

10. Nginx 站点配置

复制本目录的 blog.conf 到服务器:


sudo cp blog.conf /etc/nginx/conf.d/blog.conf

sudo nginx -t

sudo systemctl reload nginx

重点说明:

/ 指向前台 web。

/admin/ 使用 alias 指向后台 admin,并支持 history 模式刷新。

/blog_api/ 反代到后端 http://127.0.0.1:4001/,因此前端实际 API 是 /blog_api/api/v1

version.json 禁止缓存,用于发布后通知用户刷新。

hash 后的静态资源长缓存。

Nginx 必须传真实 IP:


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;

如果服务器前面还有 CDN 或负载均衡,需要额外配置可信代理头。

11. Jenkins 流水线

流水线脚本见 jenkins-pipelines.md

12. 发布顺序

推荐首次上线顺序:

1. 安装系统依赖、Node.js、MySQL、Redis、Nginx、Jenkins。

2. 创建 /www/blog 目录并设置权限。

3. 部署后端 blog-server,确认 curl http://127.0.0.1:4001/api/v1 正常。

4. 部署 web。

5. 部署 admin。

6. 配置 Nginx 并 reload。

7. 浏览器访问:

- 前台:http://你的服务器IP或域名/

- 后台:http://你的服务器IP或域名/admin/

- 接口:http://你的服务器IP或域名/blog_api/api/v1

日常发布顺序:

1. 后端有接口或数据库变更时,先发 blog-server

2. 再发 blog-webblog-admin

3. 如果页面提示"发现新版本",刷新页面。

13. 常见问题排查

#### 13.1 后端 502

检查:


pm2 status

pm2 logs blog-server --lines 100

curl http://127.0.0.1:4001/api/v1

sudo nginx -t

常见原因:

后端未启动。

.env.production 端口不是 4001

MySQL/Redis 连接失败。

Nginx proxy_pass 写错。

#### 13.2 MySQL 连接失败


systemctl status mysql

mysql -uroot -p你的MySQL密码 -e "SELECT 1;"

确认 .env.production


DB_HOST=127.0.0.1

DB_PASSWORD=你的MySQL密码

DATABASE_URL="mysql://root:URL编码后的MySQL密码@127.0.0.1:3306/blog_system"

#### 13.3 Redis 连接失败


systemctl status redis-server

redis-cli -a 你的Redis密码 ping

确认 .env.production


REDIS_HOST=127.0.0.1

REDIS_PASSWORD=你的Redis密码

#### 13.4 后台菜单点击没响应或动态模块加载失败

现象:


Failed to fetch dynamically imported module

原因通常是用户页面还停留在旧版本,服务器上旧 chunk 已被新版本替换。

处理:

当前代码已做新版本提示和菜单点击兜底。

Nginx 必须让 version.json 禁缓存。

用户刷新页面即可恢复。

#### 13.5 admin 刷新 404

确认 Nginx:


location /admin/ {

    alias /www/blog/admin/dist/;

    try_files $uri $uri/ /admin/index.html;

}

确认 admin 构建配置:


VITE_APP_BASE=/admin/

#### 13.6 web 刷新 404

确认 Nginx:


location / {

    root /www/blog/web/dist;

    try_files $uri $uri/ /index.html;

}

#### 13.7 Jenkins 构建权限不足


sudo chown -R jenkins:jenkins /www/blog

sudo systemctl restart jenkins

避免使用 chmod 777

#### 13.8 npm install 慢或失败


npm install --registry=https://registry.npmmirror.com

内存不足:


export NODE_OPTIONS=--max_old_space_size=4096

#### 13.9 PM2 开机自启

用实际运行 PM2 的用户执行:


pm2 startup

pm2 save

根据命令输出复制执行 sudo env PATH=... pm2 startup ...

14. 安全加固高阶玩法:防重复恶意请求攻击

单体 NestJS 项目也建议做分层防护,不要只依赖某一个限流点。推荐组合是:Nginx 粗限流 + NestJS Redis 精细限流 + 写接口防重复提交 + 黑灰名单。

#### 14.1 Nginx 第一层粗限流

Nginx 先挡掉明显高频请求,减少请求进入 Node.js。


# 放到 http {} 中

limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

limit_req_zone $binary_remote_addr zone=login_limit:10m rate=1r/s;



server {

    # 登录接口更严格

    location /blog_api/api/v1/auth/login {

        limit_req zone=login_limit burst=3 nodelay;

        proxy_pass http://127.0.0.1:4001/api/v1/auth/login;

        proxy_set_header X-Real-IP $remote_addr;

        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    }



    # 普通 API 基础限流

    location /blog_api/ {

        limit_req zone=api_limit burst=30 nodelay;

        proxy_pass http://127.0.0.1:4001/;

        proxy_set_header X-Real-IP $remote_addr;

        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    }

}

建议阈值:

| 场景 | Nginx 建议 |

| --- | --- |

| 普通 API | 10r/sburst=30 |

| 登录接口 | 1r/sburst=3 |

| 上传接口 | 单独限制大小和频率 |

| 后台导出 | 更严格,避免拖垮数据库 |

#### 14.2 NestJS + Redis 精细限流

Nginx 只能粗略按 IP 限制,业务层还要按用户、接口、资源做细粒度控制。

Redis key 设计:


rate:ip:{ip}:{route}:{window}

rate:user:{userId}:{route}:{window}

rate:resource:{userId}:{resourceId}:{action}

建议接口策略:

| 接口类型 | 限流维度 | 建议阈值 |

| --- | --- | --- |

| 登录 | IP + 账号 | 5 次 / 分钟,失败 5 次锁 10 分钟 |

| 注册 | IP | 5 次 / 分钟 |

| 获取验证码 | IP + 设备 | 5 次 / 分钟 |

| 评论 | 用户 + IP | 5 次 / 分钟 |

| 点赞 | 用户 + 文章 | 10 秒 1 次 |

| 后台导出 | 管理员 | 1 次 / 分钟 |

| 批量删除/批量保存 | 管理员 | 10 秒 1 次 |

装饰器设计示例:


@RateLimit({

  key: 'comment',

  limit: 5,

  windowSeconds: 60,

  scope: 'user',

})

@Post('blog/comments')

createComment() {}

Guard / Middleware 逻辑:


const key = rate:${scope}:${identity}:${route}:${Math.floor(Date.now() / 1000 / windowSeconds)};

const count = await redis.incr(key);

if (count === 1) await redis.expire(key, windowSeconds);

if (count > limit) throw new TooManyRequestsException('请求过于频繁,请稍后再试');

注意点:

未登录接口用 IP 做维度。

已登录接口优先用用户 ID,辅助叠加 IP。

后台高风险接口应比普通查询接口更严格。

Redis 异常时可以降级放行,避免 Redis 短暂异常导致全站不可用;高风险接口可选择降级拒绝。

#### 14.3 登录防暴力破解

登录不要只校验密码,还要记录失败次数。

Redis key 示例:


login:fail:account:{username}

login:fail:ip:{ip}

login:lock:account:{username}

login:lock:ip:{ip}

策略:

同账号失败 5 次,锁 10 分钟。

同 IP 失败 10 次,锁 10 分钟。

登录成功后清除失败计数。

返回提示统一成"账号或密码错误",避免泄露账号是否存在。

Token 失效返回 401,限流返回 429

#### 14.4 防重复提交

写接口最容易被重复点击、脚本重放,建议加请求指纹。

适合接口:

评论提交

点赞/取消点赞

文章发布/保存

后台新增、编辑、删除

站点设置保存

文件上传确认

请求指纹方案:


repeat:{userId}:{method}:{path}:{bodyHash}

TTL: 3-10 秒

流程:

1. 后端读取用户 ID、请求方法、路径、body。

2. 对 body 做 hash。

3. Redis SET key 1 NX EX 5

4. 如果设置失败,说明短时间内重复提交,直接返回 429

5. 正常业务执行完成即可,不需要手动删除,等待 TTL 自动过期。

伪代码:


const key = repeat:${userId}:${method}:${path}:${bodyHash};

const ok = await redis.set(key, '1', 'EX', 5, 'NX');

if (!ok) throw new TooManyRequestsException('请勿重复提交');

#### 14.5 幂等 Token 方案

对于支付、导入、批量保存这类更重要的操作,可以用一次性提交 Token。


submit:{userId}:{token} = 1

TTL: 5 分钟

流程:

1. 前端进入表单页时向后端申请 submitToken

2. 后端把 token 写入 Redis,设置 5 分钟过期。

3. 提交时带上 token。

4. 后端校验成功后立刻删除 token。

5. 第二次提交同一个 token 会失败。

适用场景:

后台批量导入

大文件合并

订单类操作

重要配置保存

普通评论、点赞用请求指纹即可,不一定需要提交 Token。

#### 14.6 黑名单和灰名单

对于明显攻击行为,单纯限流不够,可以加入黑灰名单。

Redis key:


black:ip:{ip} = 1 TTL 1 天

gray:ip:{ip} = 1 TTL 10 分钟

触发条件示例:

1 分钟内触发 429 超过 30 次。

高频访问不存在路径。

高频登录失败。

高频提交相同 body。

User-Agent 异常或为空且请求频率很高。

处理策略:

| 状态 | 处理 |

| --- | --- |

| 灰名单 | 降低限流阈值、要求验证码 |

| 黑名单 | 直接返回 403 |

#### 14.7 验证码动态升级

验证码不要所有接口都强制,否则体验差。推荐动态启用:

登录失败达到 3 次后要求验证码。

同 IP 高频获取验证码时临时锁定。

评论过快时要求验证码。

注册、找回密码默认带验证码。

Redis key:


captcha:{uuid}

sms:{phone}

verify:need:{ip}

验证码 TTL 建议 5 分钟。

#### 14.8 API 返回码规范

统一返回码,前端才好做响应:

| 场景 | HTTP 状态码 |

| --- | --- |

| 参数错误 | 400 |

| 未登录 / Token 失效 | 401 |

| 无权限 | 403 |

| 黑名单拦截 | 403 |

| 限流 / 重复提交 | 429 |

| 服务异常 | 500 |

#### 14.9 日志与监控

建议记录以下安全日志:

登录失败次数、账号、IP、User-Agent。

触发限流的接口、IP、用户 ID。

重复提交的 body hash。

黑名单、灰名单加入和解除时间。

后台强退用户操作。

后台监控页可以增加:

Redis key 数量。

当前在线 Token。

黑名单 IP 列表。

高频接口排行。

最近 30 分钟 429 次数。

#### 14.10 推荐落地顺序

1. Nginx 粗限流,先挡住明显高频 IP。

2. 登录失败计数和锁定。

3. NestJS Redis RateLimit Guard。

4. 写接口请求指纹防重复提交。

5. 高风险接口幂等 Token。

6. 黑名单、灰名单。

7. 后台安全监控面板。

对于个人博客和单体管理系统,前四项性价比最高,基本可以挡住普通脚本刷接口、重复点击、暴力登录和恶意评论。

15. 维护命令


# 后端日志

pm2 logs blog-server --lines 100



# 重启后端

pm2 reload blog-server



# 查看 Nginx 日志

sudo tail -f /var/log/nginx/access.log

sudo tail -f /var/log/nginx/error.log



# 查看端口

sudo lsof -i :4001

sudo lsof -i :80



# 检查磁盘

df -h



# 检查内存

free -h

version.json 发布检测逻辑

version.json 用来解决前端发布后用户还停留在旧页面的问题。Vite 每次构建都会生成一个新的版本文件,浏览器定时请求它;如果发现版本号变化,就提示用户刷新页面。

1. 生成逻辑

web 和 admin 都在 vite.config.ts 里通过构建插件生成 dist/version.json

web 生成内容示例:


{

  "version": "构建时的时间戳",

  "builtAt": "构建时间"

}

admin 生成内容示例:


{

  "version": "构建时的时间戳",

  "builtAt": "构建时间",

  "base": "/admin/"

}

核心点:

version 使用构建时的时间戳,每次发布都会变化。

builtAt 用于排查构建时间。

admin 额外写入 base,因为后台部署在 /admin/ 二级目录。

2. 请求路径

| 项目 | version.json 地址 |

| --- | --- |

| web 前台 | /version.json |

| admin 后台 | /admin/version.json |

admin 不能请求根路径 /version.json,否则会读到前台版本文件,导致后台版本判断不准确。

3. 前端轮询逻辑

前台和后台都在入口文件启动版本轮询,且只在生产环境启用。


首次检查延迟:2 分钟

轮询间隔:5 分钟

页面隐藏时:跳过请求

页面重新显示时:如果距离上次检查超过 5 分钟,立即检查一次

请求缓存:no-store

请求参数:?t=当前时间戳

这样做的原因:

避免刚打开页面就请求,减少服务器压力。

5 分钟检查一次,足够发现发布更新。

页面在后台标签页时不请求,减少无效流量。

?t=时间戳cache: no-store 可以绕过浏览器缓存。

伪代码:


const response = await fetch(/version.json?t=${Date.now()}, {

  cache: 'no-store',

});



const nextVersion = data.version;



if (!currentVersion) {

  currentVersion = nextVersion;

  return;

}



if (nextVersion !== currentVersion) {

  showUpdateNotice();

}

4. 提示刷新逻辑

第一次拿到版本号只记录,不提示。

后续轮询如果发现新版本:

1. 设置 updateShown = true,避免重复弹窗。

2. 弹出“发现新版本”通知。

3. 用户点击通知后执行 window.location.reload()

前台提示语偏向“站点内容已更新”,后台提示语偏向“后台已发布更新”。

5. Nginx 缓存要求

version.json 必须禁用缓存,否则浏览器可能一直拿到旧版本号,导致无法发现新发布。

前台:


location = /version.json {

    root /www/blog/web/dist;

    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";

    expires -1;

}

后台:


location = /admin/version.json {

    alias /www/blog/admin/dist/version.json;

    add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate";

    expires -1;

}

静态 hash 文件可以长缓存,例如 assets/index-xxxx.js,但 version.json 不能长缓存。

6. 后台 chunk 加载失败兜底

后台还有一层高阶兜底:如果用户停留在旧页面,发布后旧的 JS chunk 被服务器删除,点击菜单时可能出现:


Failed to fetch dynamically imported module

Importing a module script failed

vite:preloadError

Loading chunk failed

处理逻辑:

1. router.onError 捕获路由懒加载失败。

2. window.addEventListener('vite:preloadError') 捕获 Vite 预加载失败。

3. window.addEventListener('unhandledrejection') 捕获未处理的动态导入异常。

4. 判断是 chunk 加载错误后,弹出“发现新版本”。

5. 用户确认后刷新页面。

6. 如果菜单点击时已经知道目标路径,则刷新后直接进入目标路径。

这个逻辑可以避免“菜单点击没反应”的错觉。

7. 排查命令

检查 version 文件是否存在:


ls -l /www/blog/web/dist/version.json

ls -l /www/blog/admin/dist/version.json

检查 Nginx 是否禁用了缓存:


curl -I http://你的服务器IP或域名/version.json

curl -I http://你的服务器IP或域名/admin/version.json

应看到类似:


Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate

检查内容是否每次构建变化:


curl http://你的服务器IP或域名/version.json

curl http://你的服务器IP或域名/admin/version.json

如果发布后没有提示刷新,重点检查:

version.json 是否真的被部署到 dist 目录。

Nginx 是否对 version.json 做了长缓存。

admin 的 VITE_APP_BASE 是否是 /admin/

浏览器控制台是否有 chunk 加载失败。

页面是否处于隐藏标签页,隐藏时不会轮询。

8. 为什么不高频轮询

不要每几秒请求一次 version.json。版本更新不是实时强一致需求,高频轮询会浪费服务器资源。

推荐策略:


初始延迟:2 分钟

轮询间隔:5 分钟

隐藏页面跳过

重新显示页面时补一次检查

这个策略能兼顾用户体验和服务器资源占用。

评论留言

登录后可留言和点赞
一片冰心黔ICP备2026012107号