首页 / 资讯中心 / 文章详情

SpringBoot+Vue个人博客系统全栈开发实战解析

SpringBoot+Vue个人博客系统全栈开发实战解析 ★ FEATURED ARTICLE
从标题看这个项目几乎就是Java后端初学者的“毕业设计标配”——SpringBoot扛后端、Vue管前端、MySQL存数据、MyBatis做持久层一套下来覆盖了Web开发全链路。我当年做类似项目时也是从这套组合入门的所以拿到这个标题我能直接告诉你它解决的核心问题是什么让你从零搭出一个能跑、能改、能扩展的个人博客后台同时理解前后端分离架构下各层是怎么协作的。这篇文章我会把整个系统的设计思路、核心模块的实现方式、数据库设计、联调阶段容易踩的坑全部摊开讲适合正在做课程设计、自学JavaWeb或者打算写个作品集项目的开发者参考。1. 项目整体设计与技术选型思路1.1 为什么是SpringBootVue而不是其他组合很多人在选技术栈时都纠结过我直接说说这套组合的好处。SpringBoot的核心价值在于“约定大于配置”你不需要像过去用SSH框架那样写一堆XML配置它内嵌了Tomcat打成jar包就能跑这对个人项目来说太友好了。Vue则解决了传统模板引擎下前后端耦合的问题——后端只负责提供JSON数据前端通过Ajax请求拿数据、渲染页面分工明确。有人可能问怎么不用SpringBoot Thymeleaf确实服务端渲染写起来更快但你现在看到的招聘要求里“前后端分离”几乎成了标配用Vue能顺便练熟组件化开发、路由管理、状态管理这些企业级技能性价比高得多。至于MyBatis它比JPA更直观——SQL自己写优化起来心里有数数据量上去之后排查慢查询也更容易。1.2 个人博客系统需要哪些功能模块博客系统听起来简单但真要做得完整拆开来看模块并不少。我按“前台展示”和“后台管理”两条线来梳理前台展示模块文章列表分页、文章详情支持Markdown渲染、分类归档、标签过滤、文章搜索、友情链接展示、关于我页面。后台管理模块管理员登录、文章发布/编辑/删除、分类管理、标签管理、评论审核/删除、用户留言管理、系统设置博客标题、公告、头像等。用户与权限普通用户和博主权限区分普通用户可评论博主可进入后台管理。核心业务逻辑集中在“文章”这条主线上——文章归属于某个分类、打上多个标签、接收多条评论除此之外就是博客的基础设置信息。这个规模对单体应用来说正好不太大也不太小能完整展示一个业务系统的闭环。1.3 数据库表结构设计思路数据库设计直接决定了后续开发是否顺畅。我按“用户-文章-分类-标签-评论”的经典模型来建表。关键在于文章和标签是多对多关系所以需要中间表文章和分类是一对多评论则要引用的文章和用户另外还要考虑评论的层级关系父评论ID。表结构大致是这样规划的tb_user用户ID、用户名、密码加密存储、昵称、头像、角色admin/user、创建时间。tb_article文章ID、标题、摘要、正文内容、封面图、分类ID、作者ID、浏览量、点赞数、状态草稿/已发布、创建时间、更新时间。tb_category分类ID、分类名、排序号。tb_tag标签ID、标签名。tb_article_tag文章ID、标签ID联合主键。tb_comment评论ID、文章ID、用户ID、父评论ID、评论内容、点赞数、状态待审核/已通过、创建时间。这里有个细节值得注意浏览量。不要在查询文章时直接count(1)统计而是直接在文章表里维护一个数字字段每次访问时做1操作。如果后续流量大了还可以用Redis来做计数器定期落库但个人项目阶段直接更新数据库字段就够了。2. 核心细节解析与实操要点2.1 后端分层与接口设计规范后端代码我习惯按Controller-Service-Mapper三层来写这是SpringBoot最经典的架构。Controller层只负责接收请求、参数校验、调用Service、封装返回结果Service层处理业务逻辑比如发布文章时先落文章表、再绑标签关系Mapper层就是纯粹的数据库访问接口方法对应一条SQL。返回结构必须统一这个看起来简单但很多人前期图省事直接用Map返回后面联调时特别痛苦。我的做法是定义一个ResultT泛型类包含三个字段code状态码、message提示信息、data具体数据。成功时code为200失败时可能是400、401、500前端拿到这个结构就能统一处理。接口命名上RESTful风格别学个半吊子。我见过有人把“通过ID获取文章”写成GET /api/getArticle?id1REST的标准写法是GET /api/article/1。资源用名词、操作交给HTTP方法这虽然是个习惯问题但会影响后续接口文档自动生成的质量。2.2 登录鉴权的实现方式大多数博客后台并不需要太复杂的权限体系JWT方案是最常见也最实用的选择。我直接说说具体怎么落地用户提交用户名和密码后端通过AuthenticationManager校验。密码用BCrypt加密存储注册时加密、登录时用matches方法比对。校验通过后用JWT工具类生成Token里面放入userId、username和过期时间然后返回给前端。前端把Token存到LocalStorage里每次请求在拦截器中带上Authorization: Bearer token。后端写一个JwtFilter继承OncePerRequestFilter从请求头解析Token有效则把用户信息放进SecurityContextHolder无效则直接返回401。实际处理时有个细节容易漏拦截器要排除登录接口本身和前台公开接口。我通常维护一个白名单比如/api/auth/login、/api/article/list、/api/article/detail/**不需要鉴权其他后台接口都要求有效Token。Spring Security或者Shiro个人项目用哪个都行但初学者我更推荐用一个轻量级的JWT拦截器方案代码写一遍能加深对Filter执行流程的理解。2.3 文章发布与Markdown渲染个人博客写文章编辑器体验很重要。我这里用的是mavon-editor组件Vue生态下比较成熟的Markdown编辑器数据流是前端拿到Markdown原文后端存content字段是原文同时存一个content_html字段是渲染后的HTML。这么做的好处是前台列表页不需要每次请求都做一次Markdown转HTML直接返回渲染好的内容就行性能更好。如果后续想支持在线编辑也不需要额外转换。前端部分要注意从后端拿到的HTML是字符串直接用v-html渲染时容易触发XSS注入风险。尤其评论、留言这块用户输入的内容必须做过滤。我的做法是添加一个简单的防XSS过滤器把script、iframe这类标签替换掉或者在前端用第三方库来做净化处理。个人项目虽然流量小但这个安全意识从第一天就要有。2.4 前后端交互时的Date与JSON处理写后端接口时时间字段是个高频坑。默认情况下Java的时间类型序列化成JSON会是一长串时间戳前端拿到以后格式很难看。我习惯在全局配置里统一处理// 统一时间格式配置 spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8数据库字段类型对应也要选对。create_time用datetimeJava实体用LocalDateTime只要类型对齐、格式统一一般不会出问题。3. 实操过程与核心环节实现3.1 环境准备与项目初始化我建议的开发环境是JDK 1.8或11、Maven 3.6、MySQL 5.7或8.0、Node.js 14。需要注意SpringBoot版本和JDK版本要匹配比如SpringBoot 2.7支持JDK 8到17这个组合兼容性最好。创建项目时我习惯先在Spring官方Initializr上把基础工程拉下来也可以直接用IDE的项目向导依赖选择Spring Web、MyBatis、MySQL Driver、Lombok。前端用Vue CLI创建工程选Vue 2 Element UI组合配套axios、vue-router、vuex。这里有个坑Element UI对Vue 3不兼容如果你用的是Vue 3要选Element Plus。很多初学者在这里栽过跟头建议项目开始前就定好版本矩阵。3.2 数据库初始化脚本数据库建表脚本直接放在项目的sql/init.sql目录下方便别人拿到源码后一次性初始化。这里给出核心表的部分SQL方便你对照理解CREATE TABLE tb_user ( user_id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT 用户ID, username VARCHAR(50) NOT NULL UNIQUE COMMENT 用户名, password VARCHAR(100) NOT NULL COMMENT BCrypt加密后的密码, nickname VARCHAR(50) COMMENT 昵称, avatar VARCHAR(255) COMMENT 头像URL, role VARCHAR(20) DEFAULT user COMMENT 角色admin/user, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_article ( article_id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, summary VARCHAR(400), content LONGTEXT NOT NULL, content_html LONGTEXT, cover_image VARCHAR(255), category_id BIGINT, user_id BIGINT, views INT DEFAULT 0, likes INT DEFAULT 0, status TINYINT DEFAULT 1 COMMENT 1-已发布 0-草稿, publish_time DATETIME, update_time DATETIME ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_category ( category_id BIGINT PRIMARY KEY AUTO_INCREMENT, category_name VARCHAR(50) NOT NULL, sort_order INT DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_tag ( tag_id BIGINT PRIMARY KEY AUTO_INCREMENT, tag_name VARCHAR(50) NOT NULL UNIQUE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_article_tag ( article_id BIGINT NOT NULL, tag_id BIGINT NOT NULL, PRIMARY KEY (article_id, tag_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE tb_comment ( comment_id BIGINT PRIMARY KEY AUTO_INCREMENT, article_id BIGINT NOT NULL, user_id BIGINT NOT NULL, parent_id BIGINT DEFAULT NULL, content VARCHAR(1000) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0-待审核 1-已通过 2-已拒绝, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;字符集用utf8mb4是必须的——因为utf8在MySQL里存不了emoji表情而评论内容里很可能出现emoji不用后期改表很麻烦。3.3 核心后端代码实现后端整体的结构可以这样创建config包放跨域、拦截器、全局异常处理controller包放接口层service包放业务逻辑mapper包放MyBatis的Mapper接口entity包放实体类dto包放参数接收对象。以“分页查询文章列表”这个最常见接口为例我用的是PageHelper插件一行代码完成物理分页Override public PageResultArticleVo getArticleList(int pageNum, int pageSize, String keyword) { PageHelper.startPage(pageNum, pageSize); ListArticleVo list articleMapper.selectArticleList(keyword); PageInfoArticleVo pageInfo new PageInfo(list); PageResultArticleVo result new PageResult(); result.setTotal(pageInfo.getTotal()); result.setList(pageInfo.getList()); return result; }这里需要特别说明的一点是MyBatis的Mapper接口方法不能重载。昨天我给某学习者改代码时就发现他写了两个selectArticleList方法一个传keyword、一个传categoryIdMyBatis会直接报错“Mapper method not found”。解决办法要么拆分成两个不同名字的方法要么统一封装一个查询参数对象。文件上传功能也几乎是必备的——文章封面、头像都需要上传图片。本地存储就行不需要接OSSPostMapping(/upload) public ResultString upload(RequestParam(file) MultipartFile file) { String originalFilename file.getOriginalFilename(); String ext originalFilename.substring(originalFilename.lastIndexOf(.)); // 防止文件名重复用UUID重命名 String newFileName UUID.randomUUID().toString().replace(-, ) ext; String datePath LocalDate.now().toString(); String dirPath this.uploadDir / datePath; File dir new File(dirPath); if (!dir.exists()) dir.mkdirs(); file.transferTo(new File(dirPath / newFileName)); String url http://localhost:8080/files/ datePath / newFileName; return Result.success(url); }上传路径要配置成静态资源映射application.yml里加一行spring: servlet: multipart: max-file-size: 10MB resources: static-locations: file:/path/to/upload/,classpath:/static/3.4 前端页面与路由设计前端我按“前台”和“后台”拆成两个布局。前台路由包括/首页、/article/:id详情页、/category/:id分类页、/tag/:id标签页、/about关于我。后台路由统一放在/admin前缀下面包括文章列表、写文章、分类管理、标签管理、评论管理。写文章页面用的组件是mavon-editor使用方式很简单template mavon-editor v-modelarticle.content changeonEditorChange / /template script methods: { onEditorChange(value, render) { this.article.contentHtml render; } } /scriptvalue是Markdown原文render是转换后的HTML字符串直接绑定到表单对应的字段上提交时一并带上。有一个交互上的细节值得注意文章列表页和详情页的浏览量更新策略。我用的是进入详情页时调用一个独立的接口POST /api/article/{id}/view更新浏览量而不是在查询详情时顺带更新。这样即使前端做了页面缓存也不会造成无谓的写操作。3.5 前后端联调与跨域问题联调时首先面临的就是跨域问题。开发环境下前端跑在localhost:8081后端跑在localhost:8080端口不同必然跨域。后端的全局跨域配置Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }有两个细节要提醒一是addAllowedOriginPattern(*)和setAllowCredentials(true)不能冲突——如果你用addAllowedOrigin(*)同时setAllowCredentials(true)会报错必须用Pattern形式。二是这个配置解决的是浏览器跨域限制如果你用Postman测试根本不会触发跨域所以前端报跨域错误时先确认浏览器网络请求是否真的带上了Origin头。3.6 项目部署与打包个人博客的部署方案我推荐两种一种是服务器上直接跑前后端另一种是Docker容器化。后端打包很直接mvn clean package -DskipTests java -jar target/blog-admin-0.0.1-SNAPSHOT.jar前端构建后生成dist目录npm run build然后把dist里的静态文件丢给Nginx托管同时配置反向代理把/api开头的请求转发给后端端口。Nginx配置示例server { listen 80; server_name your-domain.com; root /home/blog/front-dist; index index.html; location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location /files/ { alias /home/blog/upload/; } # 前端路由使用history模式时需要把所有非文件请求转发到index.html location / { try_files $uri $uri/ /index.html; } }这里有一个初学者经常忽略的重点直接访问/admin子路由时页面会空白。原因在于Vue Router的history模式下刷新页面时Nginx会去找真实的/admin路径找不到就404了。try_files $uri $uri/ /index.html这段就是解决这个问题的关键。4. 常见问题与排查技巧实录4.1 启动类报错排查速查我在给不少学习者排错时发现大部分问题都集中在启动阶段、接口联调、数据库连接三个环节。直接整理成速查表现象直接原因解决办法启动报Consider defining a bean of type xxxMapperMapper接口没有被扫描到启动类上加MapperScan(你的mapper包路径)或每个Mapper加Mapper注解启动报数据库连接失败application.yml里库名、账号、密码不对确认url里的IP、端口、databaseName完全一致本地一般localhost:3306前端请求接口返回404前后端Context Path不一致确保后端server.servlet.context-path为空前端请求的路径前缀和后端一致前端接口报500后端异常被全局异常处理吞掉了打开IDEA控制台看堆栈信息重点排查被catch掉的逻辑上传图片后无法访问静态资源映射没配置按前述spring.resources.static-locations配置上传目录4.2 数据库时间差八小时这个问题几乎每个做JavaWeb的都会遇到。数据库里存的时间正常但Java查出来后差了8小时。原因是MySQL驱动和Java时区不一致。解决方式jdbc:mysql://localhost:3306/blog?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiserverTimezoneAsia/Shanghai必须加上同时实体字段类型用LocalDateTime不要用Date。这是最省事的方案。用java.util.Date且数据库是datetime类型时容易出现“时间被截断”或“时区错乱”的问题。4.3 MyBatis动态SQL中的常见Bug文章列表的筛选条件可能包含分类ID、关键词、标签ID、状态等多个参数。我用where配合if去处理动态条件但见过不少错误的写法——比如在if testcategoryId ! null里面拼SQL时忘了在条件前加AND。where标签会自动去掉第一个AND但如果你用了两个where就会出问题。另外还有一个新手很容易踩的坑if test里写的是实体属性名不是数据库字段名。比如数据库字段是status实体属性也是status没问题。但数据库字段是is_delete实体属性是deleteFlag这里就要用deleteFlag来判断。4.4 JWT拦截器导致OPTIONS请求被拦截前后端联调时前端所有非简单请求都会先发一个OPTIONS预检请求如果拦截器对所有请求都做JWT校验OPTIONS请求就会因为没有Token被拦截结果前端一脸懵——“我明明登录了啊为什么还报401”。解决办法很简单在拦截器里判断一下请求方式放行OPTIONS请求if (request.getMethod().equals(HttpMethod.OPTIONS.name())) { filterChain.doFilter(request, response); return; }4.5 集成页面Markdown渲染后样式缺失mavon-editor生成的HTML自带基础的class但引入的是Editor组件的样式文件如果不额外引入主题样式在前台展示时会出现排版异常——标题不像标题、表格没有边框。解决方案是在前台页面全局引入import ~mavon-editor/dist/css/index.css; import ~github-markdown-css/github-markdown.css;然后在展示区域加一个markdown-body的class渲染效果基本等同于GitHub的阅读体验。4.6 评论区“无限嵌套”的层级问题评论设计时如果允许二级以上回复前端渲染评论列表时容易写出递归地狱。我采取的方案是只保留两级结构顶级评论展示所有二级回复三级回复写入时父节点直接挂在二级评论上。这样既不会无限嵌套代码实现也简单。数据库里存的是parent_id指向直接父评论ID前端展示时做一次分组处理就行。5. 功能扩展建议与性能优化方向这个系统跑通之后我从自己的实践体验出发聊聊可以继续完善的方向。数据层面文章表加上全文索引或者引入ElasticSearch做站内搜索。个人博客文章量不大时用MySQL的LIKE %keyword%模糊查询应付几百篇绰绰有余但到了上万篇就要考虑搜索引擎了。部署层面用Docker Compose把MySQL、后端、前端Nginx编排起来一条命令启动整套环境。配合GitLab Runner或者GitHub Actions实现push代码后自动构建、自动部署整个CI/CD链路就通了。体验层面加个阅读量统计的Redis缓存、热门文章排行在首页做“推荐文章”和“标签云”还能接入第三方评论组件增强互动性。安全层面增加接口防刷比如对“发评论”“点赞”这类高频操作做限流。简单方案是基于IP的计数器放在过滤器里做不需要引入复杂的中间件。我个人在实际操作中的体会是做一个项目最重要不是“代码写完了”而是“代码里的每一处设计都能讲清楚为什么”。比如为什么表之间要用中间关系、为什么密码要加密存储、为什么前端要有BaseURL的后端环境变量——面试时考官深挖的往往就是这些“常识背后的问题”。最后再分享一个小技巧项目文档别等做完再写从表设计那天就建一个README.md记录每张表的作用、每个接口的入参出参、每次修改的原因。等这个博客项目作为作品集提交时你的文档就是和别人拉开差距的地方。后续想扩展的时候这份记录还能帮你快速回忆起当时的设计初衷省掉大量翻代码的时间。
阅读完成 · 觉得有帮助?
咨询建站