blog 项目 Ubuntu 22.04 部署手册(下篇:项目部署与配置)
blog 项目专属部署配置:前后端环境变量、Nginx 站点配置、Jenkins 流水线、发布顺序、常见问题排查、安全加固、version.json 发布检测与维护命令。
下篇:项目部署与配置
> 本篇为 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-web 和 blog-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/s,burst=30 |
| 登录接口 | 1r/s,burst=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 分钟
隐藏页面跳过
重新显示页面时补一次检查
这个策略能兼顾用户体验和服务器资源占用。
评论留言
登录后可留言和点赞