SpringBoot搭Vue的博客系统,几乎是计算机毕业设计题库里出现频率最高的题目。标题换一个说法叫“个人内容发布与互动平台”,再换一个叫“轻量级在线创作社区系统”,核心要解决的其实是同一件事:用户能注册登录、发文章、看文章、评论点赞,管理员能管内容。这篇文章我就基于自己做过的这个项目,把从数据库设计、后端接口、前端页面到联调部署的完整链路过一遍,哪些地方是坑、哪些地方能成为答辩亮点,都会讲到。适合正在做毕设的同学直接抄作业,也适合想快速搭一个内容平台的开发者参考。
1. 项目整体设计与技术选型
1.1 先拆解需求:这个“博客系统”到底要做什么
很多同学拿到这个题目,第一反应是“不就是个发文章的网站嘛”,结果做着做着就发现功能边界全是模糊的。其实标题里藏了三个关键词:博客系统、个人内容发布与互动平台、轻量级在线创作社区。这三个词是层层递进的,拆开看就清楚需求边界了。
博客系统是最基础的理解,解决“文章怎么发布、怎么展示”。
个人内容发布与互动平台,重点是“互动”两个字。也就是说这个系统不能只让用户闷头写文章,还得有评论、回复、点赞、收藏这类能让读者和作者产生连接的功能。我在设计时把互动模块单独拎出来做,而不是把评论简单挂在文章下面,就是因为这个“互动平台”的定位决定了评论不能只是几句话,最好有楼中楼回复,有点赞计数,甚至能在个人主页看到自己发过的评论。
轻量级在线创作社区系统,强调的是“轻量”和“创作”。轻量意味着不要一上来就套 Spring Cloud、Redis、消息队列那套重架构,一个好用的单体应用完全够撑起一个学习项目;创作意味着编辑器体验要合格,支持 Markdown 是底线,代码高亮、图片上传、草稿保存这些都值得做进去。
所以我的功能清单最终收敛成这样:
| 模块 | 功能 | 是否必须 |
|---|---|---|
| 用户 | 注册、登录、个人主页 | 必须 |
| 文章 | 发布、编辑、逻辑删除、草稿状态 | 必须 |
| 文章展示 | 分页列表、分类筛选、详情、热门排行 | 必须 |
| 互动 | 评论、楼层回复、点赞、收藏 | 必须 |
| 辅助 | 搜索、标签、浏览量统计 | 建议做 |
那些大而全的后台管理、多角色权限、敏感词审核,这个阶段不建议碰。做得再花哨,答辩时被问一句“你这个和 WordPress 有什么区别”就露馅了,不如把用户端这条主链路做扎实。
1.2 为什么选 SpringBoot + Vue,版本怎么定
技术选型这件事,我在开题报告里写了很多理由,但真正动手时发现核心就三条:生态成熟、资料多、前后端分离好分工。
SpringBoot 解决了传统 SSM 项目配置地狱的问题,内嵌 Tomcat,打成一个 jar 就能跑,这对部署和答辩演示都极其友好。Vue 这边组件化开发特别适合博客这种页面结构固定的系统,文章列表、评论框、侧边栏都是天然组件,拆开写互不干扰。
版本这里有个非常值得注意的坑。我刚开始创建项目时直接选了最新的 SpringBoot 3.2,结果发现 3.x 全家桶把javax换成了jakarta,网上查资料时一大半老教程都是javax.servlet,对不上。如果你用的是 JDK 8,老老实实选 SpringBoot 2.7.x;如果机器上只有 JDK 17,那就用 SpringBoot 3.x,看教程时留意教程的版本说明,别让版本问题消耗掉大量调试时间。
配套选型我给一套在毕设场景下最稳的组合:
- 数据库:MySQL 8.x,存储引擎 InnoDB,字符集
utf8mb4 - ORM:MyBatis-Plus 3.5.x,分页插件、自动填充字段都很省事
- 前端构建:Vite + Vue3 + Pinia + Vue Router
- 编辑器:md-editor-v3,支持 Markdown 编辑和预览,对中文环境很友好
- 请求库:Axios,统一封装拦截器
这套组合的好处是每个组件的资料都足够多,哪怕项目写到一半发现某个点不会,搜索一下立刻能找到对应方案的解决方案,不会卡死在冷门技术上。
1.3 整体架构与前后端目录怎么组织
架构上就是标准的前后端分离。前端通过 Axios 调用后端 REST 接口,后端只负责业务逻辑和数据存取,前端只负责渲染和交互。开发阶段用 Vite 的代理转发解决跨域,生产环境可以打成单 jar 运行。
后端代码按包分好,别一股脑塞进 controller。我习惯的目录结构是:
com.example.blog ├── controller # 接口层 ├── service # 业务层 ├── mapper # MyBatis-Plus 数据访问 ├── entity # 数据库实体 ├── dto # 接口入参出参对象 ├── common # 统一返回结果、异常、常量 └── config # 拦截器、跨域等配置前端目录对应这样:
src ├── api # 按模块拆分的接口文件 ├── router # 路由与守卫 ├── stores # Pinia 状态管理 ├── views # 页面组件 ├── components # 通用组件 └── utils # axios 实例、工具函数目录规范不是形式主义。项目后期要加功能时,你只需要知道“接口调用放 api、页面逻辑放 views、状态管理放 stores”,就不会出现一个文件堆几千行的问题。答辩时考官看代码结构,第一眼印象也很重要。
2. 后端实现:从建表到认证,把核心逻辑写扎实
2.1 数据库设计:五张核心表最关键
博客系统的数据库设计并不复杂,但有几个细节会直接影响后续开发体验。
用户表是基础中的基础:
CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `nickname` VARCHAR(50) DEFAULT '', `avatar` VARCHAR(255) DEFAULT '', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );密码字段一定要留到 100 长度,因为 BCrypt 加密后的字符串长度在 60 左右,50 不够用。用户名加唯一索引是底线。
文章表是设计的重心:
CREATE TABLE `article` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `category_id` BIGINT DEFAULT NULL, `title` VARCHAR(100) NOT NULL, `summary` VARCHAR(255) DEFAULT '', `content` LONGTEXT, `cover` VARCHAR(255) DEFAULT '', `status` TINYINT DEFAULT 1 COMMENT '0草稿 1发布 2删除', `view_count` INT DEFAULT 0, `like_count` INT DEFAULT 0, `comment_count` INT DEFAULT 0, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY `idx_user_id` (`user_id`), KEY `idx_category_id` (`category_id`) );这里有几个设计决策值得展开说。
第一,content用LONGTEXT,因为 Markdown 原文可能很长,加上还要存渲染后的 HTML,TEXT类型容易触顶。
第二,summary字段是冗余设计的典型用法。文章列表页只需要展示标题和摘要,如果每次查列表都把content全文捞出来,既浪费网络带宽又拖慢查询。发布文章时前端摘要自动截取正文前 100 个字符,列表查询时不查 content,这个优化效果很明显。
第三,status字段做的是逻辑删除。用户删除文章时只是把状态改成 2,数据还留在库里。这比物理删除多一道保险,答辩时还能顺势讲一句“考虑到用户误删后恢复的场景,我选择了逻辑删除”。这句话就是加分项。
评论表采用自关联设计:
CREATE TABLE `comment` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `article_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `parent_id` BIGINT DEFAULT NULL COMMENT '父评论ID,NULL为顶级', `content` VARCHAR(500) NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, KEY `idx_article_id` (`article_id`) );楼中楼回复不需要单独建一张回复表,parent_id指向同一张表的id就能形成树形结构。前端递归渲染子评论,后端查询时按article_id一次捞出来再在内存中组装成树,不用写递归 SQL。这个方案在评论量不大的场景下非常简洁可靠。
2.2 统一返回结构与全局异常处理
后端接口设计要先定一个统一的返回格式,否则每个接口返回结构都不一样,前端 axios 拦截器根本没法写。我定义一个R对象:
@Data public class R<T> { private int code; private String message; private T data; public static <T> R<T> ok(T data) { R<T> r = new R<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> R<T> fail(int code, String message) { R<T> r = new R<>(); r.code = code; r.message = message; return r; } }配合全局异常处理器,把业务异常、参数校验异常、兜底异常统一转换成R对象返回。这样前端就只需要认准一个结构:code为 200 代表成功,否则弹出message即可。整个项目里不需要每个接口单独写 try-catch,代码能干净不少。
2.3 JWT登录认证与权限校验
登录认证我建议直接手写 JWT + 拦截器,不要在这个项目里引入 Spring Security。原因很简单:毕设的核心是讲清楚业务逻辑,Spring Security 那套过滤器链和配置类会占据大量篇幅,而且很多同学只是配置好了但并没有真正理解,答辩被追问反而容易翻车。手写拦截器只有几十行代码,但你能把“token 如何签发、如何校验、如何获取当前用户”讲得明明白白。
登录流程是这样的:
- 注册时密码用
BCryptPasswordEncoder加密,绝不存明文。 - 登录成功后用用户的 id 和用户名生成 JWT,设置过期时间 7 天。
- 前端把 token 存到
localStorage,每次请求在请求头带上Authorization: Bearer <token>。 - 后端写一个
AuthInterceptor,拦截需要登录的接口,解析 token,把用户 id 放到 request 的 attribute 里。
核心代码大致长这样:
public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BizException(401, "未登录"); } Long userId = JwtUtil.parseToken(token.replace("Bearer ", "")); request.setAttribute("currentUserId", userId); return true; } }在 WebMvcConfig 里注册拦截器时,放行登录、注册、文章列表、文章详情这些无需登录的接口,其他写操作全部拦截。Controller 需要拿当前用户时,通过@RequestAttribute("currentUserId") Long userId获取,这样接口内部就不会出现到处从 session 取用户的混乱代码。
越权校验是另一个容易被忽略的坑。比如编辑文章接口,不能只根据前端传的 articleId 就去更新,必须校验当前用户是不是这篇文章的作者:
Article article = articleMapper.selectById(articleId); if (!article.getUserId().equals(currentUserId)) { throw new BizException(403, "无权操作"); }删除评论同理。这种校验逻辑就一句话,但没有的话,别人登录后就能改你的文章,属于严重漏洞。答辩时主动提一句“我在修改和删除接口做了归属校验”,比什么炫酷功能都加分。
2.4 文章发布与查询的接口细节
文章接口是核心,我把它拆成几个场景分别处理。
发布和编辑走同一个保存逻辑:如果传入了id就是编辑,没有就是新增;status传入 0 存草稿,传入 1 直接发布。保存之前校验标题不能为空、长度在 1 到 100 之间、正文不能为空。
这里有个性能细节:文章详情接口(GET /api/article/detail/{id})需要把浏览量加 1。我一开始写成先selectById再把 viewCount 加一后 update,等于两次数据库操作。优化成一句话更好:
articleMapper.updateViewCount(articleId);对应 SQL 就是UPDATE article SET view_count = view_count + 1 WHERE id = ?,这种原子更新在高并发下不会丢计数,也省掉了一次查询。
列表接口用 MyBatis-Plus 的分页插件:
Page<ArticleVO> page = new Page<>(pageNum, pageSize); LambdaQueryWrapper<Article> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Article::getStatus, 1) .eq(categoryId != null, Article::getCategoryId, categoryId) .like(StrUtil.isNotBlank(keyword), Article::getTitle, keyword) .orderByDesc(Article::getCreateTime);查询结果不要直接返回实体,而是转成 VO 对象放在ArticleVO里。VO 里只包含 id、title、summary、cover、authorName、viewCount、commentCount 这些列表页需要的数据,不包含 content 全文。字段裁剪这件事既为了性能,也为了接口语义清晰。
分页返回结构里total字段来自 MyBatis-Plus 自动执行的 count 查询,前端表格组件直接拿它算总页数就行。
3. 前端实现:Vue3页面、编辑器与交互
3.1 项目初始化、路由与axios封装
前端我用的 Vite 创建项目:
npm create vite@latest blog-web -- --template vue装完基础依赖后,先装核心库:
npm install vue-router@4 pinia axios element-plus md-editor-v3 dayjs路由规划要提前想清楚,页面虽然不多,但守卫逻辑要安排明白:
| 路径 | 页面 | 是否需要登录 |
|---|---|---|
/ | 首页文章列表 | 否 |
/article/:id | 文章详情 | 否 |
/write | 创作中心 | 是 |
/login | 登录 | 否 |
/profile/:id | 个人主页 | 否 |
在路由配置里给需要登录的路由加一个meta: { requiresAuth: true },然后在全局前置守卫里判断:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.meta.requiresAuth && !token) { next('/login'); } else { next(); } });axios 封装是所有页面交互的地基。我建了一个utils/request.js,把 baseURL、请求拦截器、响应拦截器集中处理:
const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use(config => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = 'Bearer ' + token; } return config; }); request.interceptors.response.use( response => { const res = response.data; if (res.code !== 200) { ElMessage.error(res.message); return Promise.reject(new Error(res.message)); } return res.data; }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } return Promise.reject(error); } );这样业务代码里调用接口时,拿到的是一个干净的数据对象,不需要每个页面都处理错误码和 token,非常省心。
开发环境的跨域问题用 Vite 的 proxy 解决,vite.config.js里加一段:
server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }前端所有请求都以/api开头,Vite 自动转发到后端 8080 端口,浏览器端完全不感知跨域。
3.2 Markdown编辑器选型与文章渲染
文章编辑是创作系统的门面。我做项目前对比过两个方案:一是传统的富文本编辑器比如 wangEditor,二是 Markdown 编辑器。最终选了 Markdown,原因有三:Markdown 源文件存储更省空间,渲染成 HTML 的样式容易统一控制,代码块编辑体验远超富文本。
我用的 md-editor-v3 中文文档齐全,安装后使用非常简单:
<template> <MdEditor v-model="title" /> </template> <script setup> import { MdEditor } from 'md-editor-v3'; import 'md-editor-v3/lib/style.css'; </script>编辑器支持上传图片,我在toolbar里配置了图片上传按钮,后端提供一个统一的上传接口,返回图片 URL 后编辑器自动插入到正文中。
文章详情页的渲染用MdPreview组件:
<MdPreview :modelValue="article.content" />代码高亮这块,md-editor-v3 默认支持 highlight.js,我只需要在引入样式时选一个主题,比如 GitHub 风格的主题,代码展示效果立刻变得很专业。这一步花费很小,但对观感提升是决定性的。
3.3 评论与点赞的数据流转
评论组件是互动功能的核心载体。我的实现思路是:详情页加载时并行请求文章详情和顶级评论列表,评论列表接口一次性返回该文章所有评论,包含层级关系。前端用一个递归组件CommentItem.vue渲染:
<template> <div class="comment-item"> <div class="comment-content">{{ comment.content }}</div> <div class="comment-meta"> <span>{{ comment.nickname }}</span> <a @click="showReply = !showReply">回复</a> </div> <div v-if="showReply"> <textarea v-model="replyContent" /> <button @click="submitReply(comment.id)">提交</button> </div> <CommentItem v-for="child in comment.children" :key="child.id" :comment="child" /> </div> </template>递归组件的写法注意子组件要写上name属性,Vue3 里用<script setup>时还需要通过defineOptions({ name: 'CommentItem' })声明组件名,否则无法自我引用。这个点很隐蔽,我当时卡了十几分钟才反应过来。
点赞功能我单独建了一张like_record表,用户对同一篇文章只能点一次赞,数据库层用唯一索引(article_id, user_id)兜底,防止并发重复点赞。前端点击点赞按钮后调接口,后端如果发现已经点过就返回“已经点过赞了”的提示,正常则更新article.like_count。这样的设计虽然简单,但数据一致性是可靠的。
互动功能还有一个容易忽略的细节:评论成功后,文章详情页的评论总数要加 1。最简单的方式是评论接口返回评论 id,前端重新请求评论列表,保证列表的一致性;也可以在发表成功后对commentCount += 1做纯前端更新。刷新页面时以数据库为准,最终不会出问题。
4. 联调阶段常见问题与踩坑实录
4.1 跨域问题的两种解法
联调第一天肯定会遇到跨域。前端跑在http://localhost:5173,后端跑在http://localhost:8080,这俩端口不同,浏览器默认拦截。
我的做法是开发环境一口气配完 Vite proxy,如上文所说,前端请求走/api前缀转发。这样开发时完全不需要后端配 CORS,代码也更干净。
但如果你直接请求后端完整地址(比如http://localhost:8080/api/article/page),那就必须在后端配 CORS:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:5173") .allowedMethods("*") .allowedHeaders("*") .allowCredentials(true); } }这里有个常见报错点:allowedOrigins配置成*且开启allowCredentials(true)时,浏览器会直接报错。因为携带凭证的情况下不允许使用通配符,必须写明确的前端地址。如果你的 token 通过 Authorization header 传递,其实不依赖 cookie,开发时可以把allowCredentials设为 false 来规避这个报错。
4.2 JSON序列化与时间显示的坑
后端实体里用LocalDateTime很顺手,但序列化给前端时,SpringBoot 默认会输出成类似2024-05-16T10:30:00的 ISO 格式,前端直接展示很难看,而且如果不做处理还会因为缺少依赖直接序列化失败。
解决方式是在application.yml里配置:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai注意timestamp类型字段要确保控制台输出为北京时间,否则会发现create_time比实际时间少了 8 小时。这时检查一下 JDBC 连接串有没有带serverTimezone=Asia/Shanghai:
jdbc:mysql://localhost:3306/blog?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai前端拿到yyyy-MM-dd HH:mm:ss字符串后直接用 dayjs 解析展示,不需要再做本地时区换算。
4.3 富文本内容的XSS安全问题
博客系统的核心风险点是文章内容支持 HTML 渲染,如果用户提交了<script>alert(1)</script>,前端渲染时就会执行恶意脚本。这是 XSS 攻击的典型场景,毕设阶段很容易被忽略。
我的处理分两层。后端在文章保存时用 jsoup 做白名单过滤,只保留必要的标签:
String safeContent = Jsoup.clean(content, Safelist.relaxed() .addTags("h1", "h2", "h3", "pre", "code", "img") .addAttributes("img", "src", "alt"));前端渲染时再用xss库做一次过滤,双保险。这个细节写到论文里是实打实的“安全性设计”章节内容,答辩老师基本都会认可,因为它是博客系统真实存在的隐患,而不是空泛地写一句“系统具有安全性”。
另外一个容易踩的坑是接口越权,前面已经讲了编辑文章、删除评论时的归属校验。把校验逻辑做成统一模式,每个写操作的 service 方法开头先确认当前用户与资源归属一致,就能把这一整类安全问题兜住。
4.4 分页与MyBatis-Plus的使用细节
使用 MyBatis-Plus 分页必须先注册分页插件,否则Page对象只会返回全部数据,total 永远为 0:
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }我最初漏配了这段,导致前端列表页永远只有一页数据,排查了好一会儿才发现是插件没注册。这类配置类问题在项目初期就要检查一遍,不要等到联调时才找原因。
5. 部署上线与毕业设计收尾
5.1 打包部署的两种方案
项目写完要能够现场演示,部署方案直接影响演示的稳定性。我准备了两种方式,这里都分享出来。
第一种是前后端分离部署。前端执行npm run build,产物在dist目录,由 Nginx 做静态托管;后端打成 jar 通过java -jar blog-server.jar运行。Nginx 配置里把/api的请求反向代理到后端:
location /api/ { proxy_pass http://localhost:8080; }第二种更省事,适合毕设演示场景。把前端的dist目录里的所有文件复制到后端src/main/resources/static下,重新打包后端 jar。这样一个 jar 就同时包含前端页面和后端接口,启动后默认端口直接访问页面,无论答辩的电脑是否安装 Nginx 都能跑起来。
我实际答辩演示用的是第二种方案,并且提前在application-prod.yml里把数据库连接、端口配置好,现场启动再加一份数据初始化脚本,确保演示环境干干净净不会翻车。
5.2 论文结构和答辩准备的几个关键点
论文结构不用标新立异,按学校要求的套路走即可,但以下细节决定了论文质量:
需求分析章节,把系统分为“游客、注册用户、管理员”三类角色,逐一列出用例,不要只写“用户能发布文章”这种一句话需求,要写出前置条件、主流程和异常流程。
系统设计章节,重点展示数据库设计的三范式分析和 ER 图,以及 JWT 认证流程图、文章发布时序图。图表质量比文字数量重要,答辩老师翻论文首先看图。
系统实现章节,不要在论文里堆大段代码。我当时的策略是:每部分截取 10 到 20 行关键代码,配上功能描述和设计原因,比如 JWT 拦截器为什么要放进 HandlerInterceptor 而不是直接在 Service 里判断。这种“为什么”比“是什么”更能体现理解深度。
测试章节,至少准备二十条功能测试用例,覆盖正常流程、异常输入、权限场景。用表格列出功能模块、操作步骤、预期结果、实际结果。另外可以加几条简单的安全测试,比如“非作者访问编辑接口应返回 403”,这是加分亮点。
答辩演示顺序我建议固定为:注册两个账号 → 账号A发布一篇带代码块和图片的 Markdown 文章 → 账号B评论并回复 → 点赞 → 个人主页展示创作列表 → 修改文章 → 删除文章 → 扩展功能(搜索、热门)。整个流程控制在五分钟以内,每一步之前想好停顿和讲解词,不要一上来就机械地点页面。
6. 实际项目落地后的几点感悟
项目做完回看,最深刻的体会是“先审计数据库后写前端”。我第一版做完突然发现文章列表没有摘要字段,前端列表页显示很难看,回头补字段、补接口、改前端展示,反复折腾浪费了两天。如果你能先把建表脚本写出来,对着表字段逐一把所有页面能想到的信息写清楚,再开始写代码,后面的返工能少一大半。
还有一个小技巧,后端每个接口打印一下耗时日志。我写了个非常简单的 AOP 切面,统一打印请求路径、参数、耗时,联调阶段排查问题效率翻倍。答辩时被问到“项目有没有优化点”,这个日志设计也能作为系统可维护性的佐证。
最后关于学习路径,如果你的基础比较薄弱,我的建议是不要一上来就研究深层源码。先按本文的链路把项目完整跑通一次,遇到问题搜索答案记录下来,跑完一遍你自然会发现哪些原理值得深挖——到那时候再去看 SpringBoot 自动装配或者 Vue 响应式原理,理解成本会低很多。毕竟做毕设的目标不是写一个“完美”的系统,而是把一个“完整且能跑”的系统彻底讲清楚。