☰
全栈博客系统实战:Vue3+SpringBoot+MySQL+Redis从设计到部署
2026/10/8 4:13:52 网站建设 项目流程

做全栈开发的,几乎都绕不过博客系统这个项目。社区里说自己是全栈工程师的人,十有八九第一个拿得出手的作品就是它;去面试聊项目,讲博客系统也是最不容易冷场的开场白。这个项目看起来简单——发文章、看文章、删文章,但真正动手做起来,你会发现它把全栈开发的骨架全都碰了一遍:数据库设计、接口规范、认证鉴权、富文本处理、文件上传、缓存、部署上线。说它是微缩版的内容管理系统毫不夸张,麻雀虽小,五脏俱全。

这次我把一套完整的全栈博客系统从零到上线的全过程整理出来。技术栈选的是 Vue 3 + Spring Boot 3 + MySQL + Redis,前端用 Vite 构建,后端按模块拆分,最终用 Docker Compose 部署到一台云服务器上。整套代码量不算大,但足够覆盖生产级个人博客需要的全部核心能力。不管你是刚学完框架想找个全栈项目练手,还是已经在职想系统补齐全栈开发的知识盲区,这篇内容都可以当作一份完整参考。

先说清楚这篇文章会讲什么。我不会贴一整仓库的代码让你自己看,而是把设计决策、关键实现、踩坑记录一条条拆开讲——为什么这样建表、为什么用 JWT 不用 Session、前端路由为什么这么组织、部署时 Nginx 怎么配才能让 SPA 刷新不 404。每个决策背后都有原因,这些原因才是做全栈项目真正值钱的部分。项目做到后面你会发现,写代码的时间只占三成,剩下七成都耗在环境、配置和细节上。

1. 动手前,先把需求边界画清楚

1.1 博客系统不仅仅是文章的增删改查

很多人拿到"博客系统"这个需求,第一反应就是打开 IDE 开始写 Article 表,写一个新增接口,再写一个列表页,感觉很快就能跑起来。真这么做的人,多半做到一半就开始返工,因为博客系统牵扯的角色和场景比你最初想的要多。

从用户视角拆分,一个完整的博客系统至少要包含两个终端。普通访客看到的是前台页面,能浏览文章列表、点进详情阅读、按分类和标签筛选文章、搜索关键词、发表评论;内容管理者看到的是后台管理界面,要登录、发文章、改文章、管理分类标签、审核评论。这两个视角对功能的要求完全不同,前台要轻要快,后台要全要顺手。很多新手失败就失败在把这两个职责混在一个页面里写。

再往下拆,文章本身也不是一张简单表就完事的。文章有状态,草稿和已发布要分开;文章有分类和标签,分类是层级结构还是平铺结构,标签是自由输入还是可复用;文章内容要不要存格式,编辑时用 Markdown 还是富文本;评论要不要审核,要不要支持嵌套回复;图片上传是存本地还是对象存储。这些都是在写第一行代码之前就该定的问题。我的做法是先列用户故事,再画功能清单,最后砍掉三个没用的功能,让核心路径保持干净。最初版本只保留文章管理、分类标签、评论展示、Markdown 渲染这四条主线,其余功能全部放到迭代计划里。

1.2 技术栈选型:我为什么选了 Vue 3 + Spring Boot

技术栈的选择是这类项目里最容易被质疑的部分。我直接说结论:这套博客用的是 Vue 3 + Vite 做前端,Spring Boot 3 + MyBatis-Plus 做后端,MySQL 8 存业务数据,Redis 做缓存,部署用 Docker Compose + Nginx。这个组合不是最好的,但它是目前中文技术社区里资料最全、最容易找到同类项目参考的组合。

先说后端。选 Spring Boot 是因为它的生态太成熟了,安全框架有 Spring Security 或者 Sa-Token,ORM 有 MyBatis-Plus 和 JPA,文件处理、参数校验、定时任务都有现成方案。对想系统学习后端的人来说,Java 这条路线的就业面也广,学了不亏。如果你完全不用考虑找工作,只想快速把博客跑起来,用 Node.js 的 Express 或者 NestJS 也完全可以,代码量会更少。我见过用 Python FastAPI 做的,也见过用 Go Gin 做的,都能做得很好,核心问题不在语言,而在你对这个生态是否熟悉。

前端锁 Vue 3 的理由也类似,组合式 API 写业务逻辑比选项式清晰,Vite 的开发体验比老一代 Webpack 构建快出一个量级。这里有一个要提前想清楚的权衡:博客内容需要被搜索引擎收录,纯 SPA 的 SEO 效果很差。我选的妥协方案是前台做 SPA,但每个页面都设置完整的 title、description、keyword 和 Open Graph 标签;文章详情页由后端提前渲染好 META 信息注入到 index.html 模板中。这个方案对个人博客足够用。如果追求极致的 SEO,应该直接上 Nuxt 或 Next.js 做 SSR,但对全栈学习来说,那一套的复杂度会分散你学后端和部署的精力。

1.3 模块划分与开发顺序

模块划分的目标是让每个人都能在自己的脑子里建立清晰的上下文边界。我按部署单元和职责边界把项目切成了前端、后端、数据库、中间件四块,前端内部再拆成 portal(门户)和 admin(管理后台)两个路由域,后端内部按 controller、service、mapper、entity 四层组织。这个划分先于代码存在,它决定了后续所有人写代码时把文件放到哪里,也决定了 CI/CD 的产物是什么。

开发顺序也有讲究。我在这个项目里的顺序是:先定数据库表结构,再出接口文档,接着写后端代码,然后写前端页面,最后做部署。反过来做一定会后悔。因为表结构是数据的根基,表设计错了,接口和页面全都要跟着改;接口文档是前后端协作的契约,先定义好路径、参数、返回结构,前端开发时就不用一边写一边猜后端返回的字段。这听起来像团队协作的流程,但一个人做全栈项目时同样适用,因为你会频繁地在前后端之间切换,如果不先把契约定好,切换时的上下文丢失成本非常高。

实际开发时,我给自己的节奏是第一周只做数据库设计和接口文档,第二周完成后端全部核心接口,第三周做后台管理页面,第四周做前台浏览页面,第五周部署上线。前两周是最枯燥的,但后三周的顺利程度完全取决于前两周想得够不够清楚。

2. 后端:从建表到接口的一整套设计思路

2.1 表结构设计:五张核心表和一个多对多关系

建表是整个项目最不能急的一环。我在第一版设计里定了五张核心表:用户表、文章表、分类表、标签表、评论表,外加一张文章标签关联表。下面把文章表和关联表单独拿出来讲,因为它们最能代表设计取舍。

文章表的核心字段长这样:

CREATE TABLE t_article ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, summary VARCHAR(500) NULL, content_md LONGTEXT NOT NULL, content_html LONGTEXT NOT NULL, category_id BIGINT NULL, cover_image VARCHAR(500) NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0-草稿 1-已发布', view_count INT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, deleted TINYINT NOT NULL DEFAULT 0, KEY idx_category_status (category_id, status), KEY idx_create_time (create_time) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

第一个容易纠结的问题是,文章内容为什么要存content_md和content_html两列。我见过很多方案只存 Markdown 原文,每次请求时在后端现渲染成 HTML。这个方案代码简单,但有几个问题:一是渲染逻辑每次请求都要执行,CPU 开销不划算;二是如果以后渲染规则升级,你无法只对历史文章定向刷新;三是后台保存为草稿时,不方便预览最终的渲染效果。我现在每次保存文章时,后端把 Markdown 渲染成 HTML 再一起落库,查询时直接返回已经渲染好的内容,接口响应速度肉眼可见地快。这是用一个字段的空间换取每个请求的时间,对内容系统是划算的。

第二个容易踩坑的点是软删除。为什么不用物理删除?因为文章发布过就可能被搜索引擎收录、被外部引用,物理删掉会导致死链;评论更是需要保留审计痕迹。所以我在每张表上都加了deleted字段,查询时统一加deleted = 0条件。这里有个我自己踩过的坑:MySQL 索引对deleted这样的低选择性字段帮助有限,如果列表查询经常带status和category_id,索引应该建在业务字段的组合上,而不是单独给deleted建索引。我在文章表上建了(category_id, status)的联合索引,配合分页查询效果不错。联合索引的字段顺序也很重要,要把区分度高的字段放前面,同时要考虑等值查询和范围查询的混合场景。

分类和标签的关系也值得多说一句。分类适合用单表平铺,一个文章只属于一个分类,因为个人博客的分类数量一般不超过十个,不需要做层级。标签则是典型的多对多场景,一篇文章可以有多个标签,一个标签下有多篇文章,所以单独建t_article_tag关联表。关联表只需要三个字段:主键、文章 ID、标签 ID,然后给article_id建索引。这样做的好处是标签可以复用,统计某个标签下的文章数量时直接查关联表,效率和灵活性都远高于把标签存成逗号分隔字符串。逗号分隔的方案在处理"修改文章标签""标签合并""按标签过滤"时都很难受,这是新手最容易忽略的地方。

2.2 接口设计:统一响应、JWT 认证与权限控制

表结构定了,接下来就是接口设计。我全部按 RESTful 风格来,路径里只写资源名词,操作用 HTTP 方法表达。核心接口整理出来是这样的:

方法路径说明权限
POST/api/auth/login登录,返回 token公开
GET/api/auth/me获取当前登录者信息管理员
GET/api/articles分页查询已发布文章,支持分类、标签、关键词过滤公开
GET/api/articles/{id}文章详情,浏览量 +1公开
POST/api/articles新建文章管理员
PUT/api/articles/{id}更新文章管理员
DELETE/api/articles/{id}删除文章(软删除)管理员
GET/api/admin/articles分页查询全部文章,包含草稿管理员
POST/api/comments提交评论公开
GET/api/comments查询文章评论列表公开

所有接口返回统一的结构:{ "code": 0, "message": "success", "data": {...} }。code 是业务状态码,0 代表成功,非 0 代表各种错误,比如 401 表示未登录、403 表示无权限、404 表示资源不存在。这个约定前后端都要遵循,前端 axios 拦截器只看 code,HTTP 状态码只作为传输层信息。我见过不少项目把业务错误直接映射成 500,前端一接到 500 就得猜后端哪里炸了,体验很差。统一响应结构之后,前端处理错误会清爽很多。

认证方案我选了 JWT。对比 Session 方案,JWT 是无状态的,后端不存登录状态,水平扩容时不用考虑 Session 同步的问题,也天然适合前后端分离的场景。JWT 本身是个三段式字符串:Header 存算法类型,Payload 存用户信息和过期时间,Signature 用服务端密钥对前两段签名,防止篡改。登录成功后,后端生成 token 返回,前端保存起来,之后每个请求都在Authorization: Bearer <token>头里带上,后端用一个拦截器解析 token、取出用户信息放进上下文。我项目里的真实参数是:Access Token 有效期 2 小时,签发密钥放在环境变量里,密码用 BCrypt 加密存储,成本因子取 10。

权限控制这一层,我没有使用 Spring Security 全家桶,原因是不想引入大量配置把核心逻辑淹没掉。我用了轻量的拦截器方案:写一个AuthInterceptor,在配置类里注册到需要鉴权的路径上,比如/api/admin/**。拦截器里做三件事:解析 token,校验签名和过期时间,检查用户角色是否为管理员。这三步全部通过才放行。对于学习项目来说,这种方式从头到尾都可以自己控制,理解起来也更直接。如果你的目标是进大厂面试,还是建议系统学一下 Spring Security 的 Filter Chain 和授权模型,原理是相通的,只是配置复杂度更高。

2.3 Markdown 渲染、XSS 清洗与代码高亮

博客系统的核心是内容展示,内容格式则决定了阅读体验。我这边文章内容一律用 Markdown 编辑、存储、渲染,后台保存时由后端负责把 Markdown 转成 HTML,再存储到content_html字段。这个方案需要解决三个问题:渲染扩展、安全清洗、代码高亮。

渲染扩展指的是 Markdown 语法之外的常见需求。基础 Markdown 没有表格、任务列表、删除线这些能力,所以我用的是 GFM 风格,即 GitHub Flavored Markdown,它是在标准 Markdown 基础上扩展了这些语法。Java 生态里的实现很多,我选的是 flexmark,因为它对 GFM 支持完整,扩展机制也灵活。另外一个需求是标题锚点,文章内容里的一二级标题要自动生成 id,这样前台页面才能实现目录跳转和文内链接。flexmark 提供了一个 AnchorLink 扩展,配置一下就能在渲染时自动给标题加锚点。

安全清洗是必须死磕的一环。Markdown 本身允许嵌入原始 HTML 标签,如果渲染时不清洗,用户可以往文章里写<script>标签,发布后直接在前台执行,这就是典型的存储型 XSS。文章是博主自己写的还好,评论是用户生成的,风险更大。我的做法是在渲染完成之后,再用一个 HTML 白名单过滤器清洗一遍,只保留 p、h1-h6、pre、code、img、a、ul、ol、li、blockquote、table 这些安全的标签,script、iframe、object、style、form 等一律删除,标签上的事件属性和 javascript: 链接全部剔除。Java 生态里常用的清洗库是 OWASP Java HTML Sanitizer,配置一个白名单策略,调用一次 sanitize 就行。这一步不能偷懒,做全栈项目必须建立安全意识。

代码高亮我放在前端做。后端渲染 HTML 时给<code>标签带上语言 class,前端引入 highlight.js 或者 Prism 的主题样式,页面加载后自动识别并着色。我个人更推荐 highlight.js,配一个 GitHub 风格的深色主题,和阅读页的整体风格比较搭。代码块的行号、复制按钮这些附加功能可以后续再加,第一版先把着色和横向滚动做好。

3. 前端:工程化、路由与编辑体验

3.1 项目初始化与目录规范

前端部分我用了 Vue 3 + Vite + Vue Router + Pinia 的组合。项目初始化命令很简单,npm create vite@latest,选 Vue 模板即可。真正的工作量在目录规范和基础封装上,这些决定了项目写到后面会不会乱。

我的目录结构大致是这样的:src/api放接口请求模块,每个资源一个文件,比如article.js、comment.js、auth.js;src/views下按照portal和admin两个目录划分路由组件;src/components放公共组件,比如分页器、文章卡片、评论列表;src/stores放 Pinia 的状态定义;src/utils放请求封装、时间格式化、文本截断这些工具函数。src 根目录下再放一个router/index.js和一个styles/目录。这套结构不复杂,但边界很清晰,任何人接手项目都能在三十秒内找到自己该改的文件。

工程化配置里有两个点值得多说。第一是路径别名,我把@指向src目录,这样深层组件里引用其他模块时不需要写一长串相对路径,代码可读性好很多。Vite 需要在配置文件的resolve.alias里设置,同时记得在 jsconfig.json 或 tsconfig.json 里同步声明,否则编辑器跳转会失灵。第二是环境变量,我在项目根目录建了.env.development和.env.production,里面定义VITE_API_BASE_URL,开发环境指向本地后端,生产环境指向服务器的 HTTPS 域名。Vite 会自动把以VITE_开头的变量暴露给前端代码,这样打包时不需要改任何业务代码,环境切换全靠变量控制。

3.2 路由守卫、状态管理与请求封装

路由设计上,我将所有带/admin前缀的页面放进一个单独的路由层级,统一挂在AdminLayout组件下面,这个布局组件负责渲染侧边栏、顶栏和内容区域。登录页在/login,前台门户的首页在/,文章详情在/article/:id,分类和标签聚合页分别用/category/:slug和/tag/:name。这样一个干净的路由表,配合导航守卫,就能把权限控制的逻辑集中到一处。

导航守卫是前端权限控制的关键。我在router.beforeEach里做了三件事:判断目标路由是否以/admin开头;如果进入管理后台,检查 Pinia 的 user store 里有没有 token;没有 token 就重定向到/login?redirect=/admin,登录成功后回跳。这里要特别注意一个问题:token 存在 localStorage 里,刷新页面后 Pinia 状态会清空,但 localStorage 里的 token 还在。所以在 store 初始化或应用启动时,要加一步"从 localStorage 重新恢复用户状态"的逻辑,否则用户明明登录过,刷新一下就又被踢到登录页。

请求封装要说的细节最多。我基于 axios 写了一个统一实例,baseURL从环境变量读取,timeout设成 15000 毫秒。请求拦截器负责从 token 存储里取出 JWT,放到请求头的Authorization字段。响应拦截器是重点:先判断 HTTP 状态,非 2xx 统一走错误提示;再按业务 code 分流,code 为 0 时直接返回response.data.data,非 0 时弹出错误消息;如果遇到 401,说明 token 已过期,这时要清空本地登录态、跳转到登录页。我在这段逻辑里踩过一个坑:登录接口本身请求时还没有 token,如果拦截器无脑加 Authorization 也没问题,但 401 的处理逻辑要跳过一个白名单机制,否则登录失败会触发无限循环跳转。

3.3 编辑器选型和阅读页的体验细节

后台用得最多的功能就是写文章,编辑器的选择直接影响使用体验。我对比过三个方案:纯 textarea 配合 Markdown 预览、引入 Vditor、引入 ByteMD。纯 textarea 方案代码最少,但用户体验太原始,需要左右分屏预览,还得自己做工具栏,实际用起来效率很低。最终我选了 Vditor,因为它开箱即用,支持即时渲染模式、所见即所得的工具栏、文件上传回调、代码高亮和深色主题。说实话它的包体积不算小,但管理后台是登录后访问的,对首屏加载的敏感度没那么高,这个取舍可以接受。

Vditor 的接入比想象中简单,核心是两件事:初始化传一个 div 元素,设置初始内容和模式;配置好上传回调,让编辑器里的图片按钮能直接把文件传到后端。上传回调里要带Authorization请求头,因为上传接口是管理员权限;上传成功后后端返回图片 URL,Vditor 会自动把 URL 插入到光标位置。这里有个前后端约定要提前确认:上传接口的响应格式必须是 Vditor 预期的{ code: 0, data: { url: "xxx" } },如果你用统一的接口封装返回{ code: 0, message: "", data: { url: "xxx" } },那也是兼容的。

阅读页的体验和编辑页完全不同,核心目标是让读者舒服地看完一篇文章。我在文章详情页做了几件事:标题和摘要排版用大字号和宽松行距;目录导航放在页面右侧,滚动时自动高亮当前章节;代码块使用 highlight.js 着色并开启横向滚动;图片懒加载,用 loading="lazy" 加占位背景色防止布局抖动。还有一个细节是阅读进度条,监听 window 的 scroll 事件,计算出已读百分比,在页面顶部显示一条细进度线,这个功能实现起来不到三十行代码,但对阅读体验的提升非常明显。

4. 部署上线:Docker 编排、Nginx 与 HTTPS

4.1 前后端容器化与 Compose 编排

部署环节是全栈项目里最劝退新手的一步,也是踩坑最多的一步。我的方案是前端和后端各写一个 Dockerfile,数据库和 Redis 直接用官方镜像,最后用一个 docker-compose.yml 把四个服务编排在一起。

前端 Dockerfile 用多阶段构建,第一阶段用node:18-alpine作为构建镜像,安装依赖后执行npm run build,产出 dist 目录;第二阶段直接基于nginx:alpine,把 dist 目录里的文件复制到 Nginx 的默认静态目录/usr/share/nginx/html。这个做法的好处是最终镜像里只有 Nginx 和静态文件,体积只有几十兆,而且构建产物是高度可复现的。后端 Dockerfile 思路一样,第一阶段用maven:3.9-eclipse-temurin-17执行mvn clean package,第二阶段用eclipse-temurin:17-jre作为运行环境,把 jar 包复制进去,启动命令是java -jar。多阶段构建最大的价值是运行镜像里不会残留编译器、依赖缓存这些无关文件,既缩小镜像体积,也降低被攻击的面。

docker-compose.yml 是部署的核心配置文件。MySQL 和 Redis 挂载了数据卷,保证容器重建后数据不丢;MySQL 设置了初始数据库名和账号密码;后端服务通过环境变量注入数据库连接串、Redis 地址和 JWT 密钥,这些敏感信息放到一个.env文件里,.env不进 Git 仓库。服务间的通信走 Docker 内部网络,后端访问数据库和 Redis 直接用服务名当主机名,不需要暴露这些端口到公网。前端 Nginx 容器把 80 和 443 端口映射到宿主机,反向代理后端 API。这里要提醒一句:容器内部的服务端口不要随意映射到宿主机,尤其是 MySQL 的 3306,暴露到公网等于给黑客送靶子。

4.2 Nginx:SPA 路由刷新 404 与反向代理

Nginx 配置是全栈博客上线时最容易出问题的部分。第一个问题就是 SPA 路由刷新 404。Vue Router 默认用 history 模式,路由路径是真实的 URL,比如/article/123。前端容器里其实并没有article/123这个物理文件,当用户在浏览器里直接访问这个地址,或者在这个页面按 F5 刷新,Nginx 会去找对应的文件,找不到就返回 404。解决办法是配置 try_files:

location / { try_files $uri $uri/ /index.html; }

这个配置的意思是:先尝试查找请求的 URI 对应的文件,找不到就尝试找目录,目录也找不到就回退到index.html,由 Vue Router 接管并渲染对应的页面。这一行配置是 SPA 部署的标配,不知道的人第一次上线几乎都是在这里卡住的。

第二个问题是反向代理。前端容器和后端容器在同一个 Docker 网络里,但外部浏览器只能访问 Nginx 的 80 端口。所有以/api开头的请求,都要由 Nginx 转发到后端容器去。我给 Nginx 加的配置是:

location /api/ { proxy_pass http://backend:8080; 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_pass后面如果带了路径,转发时会替换匹配到的前缀;我这里不带路径,请求原样转发。backend:8080这个地址是 Docker Compose 里后端服务的服务名和端口,在容器内部网络里可以直接用服务名访问,不需要知道容器的动态 IP。这套配置跑通之后,前端的VITE_API_BASE_URL在生产环境里就写/api这一个相对路径,不需要写完整域名,浏览器发请求时自动打到同域的 Nginx 上,再由 Nginx 转发到后端,同域请求天然没有跨域问题。

HTTPS 是上线博客必须要做的。理由很简单:搜索引擎对 HTTPS 站点有倾向性,浏览器对 HTTP 请求会显示"不安全"警告,而且 JWT 在明文 HTTP 下传输等于裸奔,很容易被中间人截取。我用 Certbot 申请了免费的 Let's Encrypt 证书,把 80 端口请求 301 跳转到 443,证书配置好后加一个定时续期任务。证书相关的文件和配置路径很多,建议把证书放在独立的目录,用 volumes 挂载进 Nginx 容器,续期时从宿主机执行脚本即可。

4.3 缓存策略:Redis 缓存与静态资源加速

部署上线后,博客的性能优化才真正开始。个人博客流量不大,但体验必须快,慢一秒都对不起读者。我的优化分三层:数据库层、Redis 层、静态资源层。

数据库层最基础的是索引优化。文章列表页按发布时间倒序、按分类过滤,我在(category_id, status)上建了联合索引;评论按article_id查询,单列索引就够了。不要迷信索引越多越好,索引会拖慢写入速度、占用磁盘空间,只给真正的查询路径建索引。每次写完一条新查询,我习惯先EXPLAIN看一眼执行计划,确认 type 不是 ALL 全表扫描,再考虑下一步。

Redis 层我主要做文章详情的缓存。详情页是访问量最大的页面,而且文章内容几乎不变,非常适合缓存。我用 Cache Aside 模式:查询时先读 Redis,key 是article:detail:{id},命中直接返回;没命中就查数据库,查到后写入 Redis,设置十分钟过期时间。后台修改文章时,除了更新数据库,还要主动删掉对应 Redis key,保证下次请求是新鲜数据。这个模式实现成本低,收益立竿见影。列表接口也可以缓存,但因为分页参数变化多,缓存粒度不好控制,个人博客流量不大,列表直接查数据库加索引就够了,不要过度设计。

静态资源层,Nginx 对 dist 目录里的 JS、CSS、图片资源配置了强缓存。Vite 构建时会给文件名带上 hash 后缀,内容变了文件名就变,所以这些资源可以放心设置expires 7d和Cache-Control: public, max-age=604800,浏览器直接用本地缓存,连请求都不发。图片资源如果以后量大,可以再接入对象存储加 CDN,但个人博客初期用本地存储配合 Nginx 静态托管足够了。别忘了给 Nginx 开启 gzip,一般文本资源的压缩率在 60% 以上,一个 200KB 的 JS 文件能压缩到 80KB 左右,传输时间直接砍半。这些优化做完之后,本地的 Lighthouse 性能评分基本稳定在 90 分以上。

5. 常见问题排查与实战避坑记录

5.1 跨域和登录态:开发期与生产期的两套体验

前后端分离项目碰到的第一个高频问题一定是跨域。开发时前端跑在 Vite 的 5173 端口,后端跑在 8080 端口,浏览器里前端页面所在域是http://localhost:5173,它去请求http://localhost:8080/api就是跨域。解决跨域有三个常用方案,我建议用 Vite 代理,而不是在后端开 CORS。

Vite 代理的做法是在vite.config.js里配置:

server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }

这样前端开发时请求/api开头的接口,Vite 会代替浏览器转发到后端,浏览器端看到的是同域请求,不存在跨域问题。这个方案的好处是不动后端代码,生产环境里 Nginx 也是同样的代理逻辑,前后端环境保持一致,开发和生产之间几乎没有行为差异。如果不方便用代理,也可以在 Spring Boot 里配 CORS 过滤器,允许指定源跨域访问。我必须提醒一件事:跨域配置里如果用了allowedOrigins("*")同时又要带 Cookie 或凭证,浏览器会直接拒绝,因为 Provisional headers 的问题会让你排查半天。正确的做法是把允许的来源明确写出来,比如http://localhost:5173,而不是用通配符。

登录态失效是另一个必踩的坑。JWT 过期后,用户正在后台写一篇长文章,点保存时返回 401,前端直接把人踢到登录页,文章内容还没保存,气得摔键盘。我的改进方案是:在 axios 响应拦截器里,遇到 401 先不立刻跳转,而是尝试用 refresh token 换新的 access token;刷新成功就重放刚才失败的请求,用户无感;刷新失败才清空登录态并跳转登录页。因为刷新 token 可能并发触发多次,我加了一个变量标记刷新状态,刷新期间其他请求先挂起排队,等新 token 拿到后统一重放。这套逻辑实现起来约五十行代码,但对体验的提升是决定性的。

5.2 图片上传:文件校验、存储路径与访问映射

图片上传在博客系统里是躲不开的。用户写文章要插图,设置封面要传封面,这些文件校验做不好,服务器迟早要出事。我在这上面踩过很深的坑,现在总结出三条铁律。

第一,文件类型不能只看扩展名。用户把文件名改成xxx.jpg但内容实际是个 PHP 脚本,你的校验如果只检查扩展名,等于开了个后门。至少要做两层校验:先看 Content-Type,再读取文件头几个字节的魔数,比如 JPEG 的开头是FF D8 FF,PNG 的开头是89 50 4E 47,GIF 是47 49 46 38。Java 里可以用一个简单的工具方法读取文件头,只有魔数匹配才放行。第二,文件名一定不能使用用户上传的原始文件名。原始文件名可能存在路径穿越风险,也可能包含中文和特殊字符导致乱码。我的做法是用 UUID 生成新文件名,但保留原始扩展名,再按日期分成子目录,比如/uploads/2025/06/01/uuid.jpg。这样文件名已知且唯一,管理也方便。第三,请求体和代理都要限制大小。我在 Spring Boot 的配置文件里设置了spring.servlet.multipart.max-file-size=10MB,同时 Nginx 的client_max_body_size也要设置成同样的值,否则大的上传请求会先被 Nginx 拦下来,后端看到的是 413,排查时方向很容易跑偏。

图片存储位置我第一版用本地磁盘,放在后端项目的静态目录下,通过 Nginx 的/uploads/location 直接映射访问。后来流量大了、需要换服务器时才发现,这种方案的迁移成本很高,图片和代码耦合在一起,备份时容易漏。如果你预期图片量会涨,建议一开始就设计成独立的文件访问路径,或者直接接入对象存储。第一次做项目时可以先本地存储跑通全流程,但心里要有数:这不是终局方案。

5.3 数据备份、时间时区与编码问题

最后这段写几个看起来不起眼、实际上能坑到人的细节。第一个是数据库备份。线上博客最怕的不是崩溃,而是数据丢了找不回来。我配了一个简单的定时任务,每天凌晨三点用 mysqldump 把整个库导出成 SQL 文件,压缩后保留最近七份,再同步一份到另一台服务器上做异地容灾。恢复流程我也实际演练过:在干净的 MySQL 容器里导入 SQL 文件,检查文章量、评论量、账号信息是否一致,确认恢复时间点。备份这件事,做了不一定有事,不做一定出事,而且出事一定是在你没有备份的那一天。

第二个是时区问题。MySQL 连接串上如果不加serverTimezone=Asia/Shanghai,会跟你服务器的系统时区产生偏差;Java 17 里如果数据库字段是 DATETIME,而实体字段用了LocalDateTime,读写时也要注意时区换算。我的建议是:所有时间字段在数据库里统一用 DATETIME 存本地时间,后端用 LocalDateTime 映射,前端展示时再根据读者所在时区做一次格式化。千万别把时间戳和字符串混着存,查起来你会疯掉。

第三个是字符集问题。文章的 Markdown 内容里有 emoji、有各种中文标点,如果表字符集不是 utf8mb4,部分字符写入时会被截断或变成问号,这是字符集最强的"受害者"。MySQL 8 里默认字符集已经是 utf8mb4,但如果你是老版本迁移来的,记得检查每个表和每个字段的字符集。建表时我统一指定CHARSET=utf8mb4,并在连接串里加上characterEncoding=utf8,从源头杜绝乱码问题。


这套博客系统做完之后,我自己最大的感受是:全栈项目最难的从来不是某个技术点,而是把散落的细节串成一个完整闭环的能力。数据库、后端、前端、部署,每一层单独拎出来都有大量资料可查,但它们之间的衔接——比如 Markdown 渲染放后端还是前端、token 存哪、图片怎么访问、备份怎么恢复——这些决定恰恰是网上很难直接找到答案的部分。我的建议是,第一版一定要克制,按本文这个规模做,先把闭环跑通,再根据实际使用反馈去迭代功能。如果你遇到和我不同的坑,欢迎记录下来补进自己的项目文档,这些一手经验比任何教程都值钱。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询