Spring Boot + Vue 博客系统开发实战:从技术选型到部署填坑

发布时间:2026/10/22 13:25:56
Spring Boot + Vue 博客系统开发实战:从技术选型到部署填坑
去年年初我接手过一个半成品博客项目。前端是网上扒下来的模板改的后端还是传统的Spring MVC加JSP跑在Tomcat里发布一篇文章要做三件事把HTML丢进数据库、把图片手动传到某个目录、再把Tomcat重启一遍。坚持半个月后我决定推倒重来。这里记录的就是我用Spring Boot和Vue重写博客管理系统的全过程包括技术选型、表结构设计、核心接口和前后端联调的坑。这个项目做完之后我最大的感受是博客系统看起来简单但真正动手做的时候会遇到大量“文档里不会告诉你”的问题。文章编辑器传上来的HTML怎么保留格式用户上传的图片路径怎么和页面访问地址对得上Vue的history路由刷新后为什么404这些才是让一个项目从demo变成可用系统的关键。所以这篇文章不准备面面俱到讲每个API怎么拼而是按我重写项目的顺序把做决策和填坑的思路讲清楚。如果你正准备做一个博客类项目或者在学Spring Boot和Vue但不知道怎么把前后端串起来这篇文章应该能帮你少走不少弯路。1. 从传统Web到前后端分离我为什么选择这套组合1.1 旧项目的痛点让我下决心重写旧的博客系统其实打通了基本功能文章能发、评论能看但每一次小改动都要付出大代价。举个例子我想在文章详情页加一个“上一篇/下一篇”按钮后端接口需要返回相邻文章的ID。在原项目里这个逻辑被塞在ArticleController的一个方法里页面直接在JSP中用ModelAndView传递数据。前端要改样式后端要改控制层和数据查询两个人同时改一个文件天天冲突。更麻烦的是旧项目里所有页面都是服务端渲染编辑器和展示页共用一套模板导致文章正文里的HTML经常被JSP标签和JavaScript脚本干扰。那时候我意识到博客管理系统这种以内容展示为主的系统最适合的做法是前后端完全分离后端只提供JSON接口前端只关心页面渲染两者通过API通信。这也是我最终选择Spring Boot Vue的根本原因。1.2 技术选型不是越新越好要看团队和维护成本前后端分离技术方案很多我当时的取舍是这样的层级候选方案我为什么没有选后端框架Spring Boot最终选择Java生态成熟资料多部署方便后端框架Node.js Express我没选它是因为团队更熟悉Java而且博客系统涉及数据关联和权限Java写起来更稳后端框架PHP Laravel启动快但后续想扩展接口做App后端会麻烦一点前端框架Vue 3最终选择上手快组件化清晰生态完善前端框架React学习曲线相对高一点做这种CMS有点杀鸡用牛刀前端框架原生JS jQuery不现实维护性太差我最后敲定的是Spring Boot 2.7 Vue 3 Element Plus组合。Spring Boot负责提供RESTful接口和数据处理Vue 3负责构建前台展示和后台管理两个端UI组件用Element Plus可以节约大量时间。整个过程不追求花哨只求稳定、可扩展后续接App端或者小程序这套后端接口也能继续用。1.3 这套组合适合什么类型的博客系统如果你的目标是做高并发大流量平台比如类似信息流网站Spring Boot Vue也可以但需要加缓存、消息队列、分库分表一堆东西。对于个人博客、企业官网的文章板块、知识库系统这套组合已经足够。我在项目中主要实现了文章管理、分类管理、标签管理、评论管理、用户登录授权五个核心模块这基本覆盖了一个内容型系统的80%需求。2. 数据层设计五张核心表还是三张我最终这样拆2.1 先把功能清单列清楚再建表我习惯在写代码之前先画一张功能脑图把需要的数据摸清楚。这个博客系统我拆成了两个端前台文章列表、分类查看文章、标签查看文章、文章详情、评论列表、发表评论、用户登录后台文章发布/编辑/删除、分类管理、标签管理、评论审核、用户信息修改顺着功能清单我一开始想偷懒只用三张表user、article、comment分类和标签都直接用字符串存到文章表里。后来一想这样不行分类一旦改名所有文章都要更新标签也无法做聚合统计于是老老实实拆成五张核心表加一张关联表。2.2 五张表的结构和字段设计以article表为例最核心的字段是这样的CREATE TABLE article ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 作者ID, category_id bigint DEFAULT NULL COMMENT 分类ID, title varchar(200) NOT NULL COMMENT 标题, summary varchar(500) DEFAULT NULL COMMENT 摘要, content longtext COMMENT 正文HTML, cover_image varchar(500) DEFAULT NULL COMMENT 封面图, status tinyint DEFAULT 1 COMMENT 1发布 0草稿, view_count int DEFAULT 0 COMMENT 浏览量, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_category (category_id), KEY idx_create_time (create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;content字段我用的是longtext因为富文本编辑生成的HTML体积很大。编码统一用utf8mb4否则emoji表情存不进去。我见过有人用utf8结果评论区的emoji全部变成问号这个细节一定要留意。user表不需要多复杂最基本的是用户名、密码、昵称、头像、角色。密码不存明文我用BCrypt加密存储。comment表我设计成了自关联结构加了一个parent_id字段这样可以实现楼中楼回复评论列表做递归查询也不会太吃力。category和article是一对多关系article和tag是多对多关系所以额外加了一张article_tag关联表。2.3 设计数据库时我做的一个关键取舍标签到底要不要独立成表这个我想了很久。如果只做个人博客用article表里加一个逗号分隔的tags字段最简单查列表时用FIND_IN_SET也能过滤。但后续我想做标签云和标签聚合页独立表加关联表才能支持高效的统计查询。所以最后选择多对多建模代价是写文章时多两条SQL换取统计时的灵活度和可维护性。对于博客系统这种读多写少的场景这个取舍非常值得。2.4 ORM我用MyBatis-Plus而不是Spring Data JPA很多Spring Boot教程会用JPA我这次选了MyBatis-Plus。原因很简单博客系统的查询经常要拼接条件比如按标题模糊搜索、按分类筛选、按状态过滤用MyBatis-Plus的LambdaQueryWrapper写起来非常直观而且它内置分页插件拿回来就能用。JPA也能做但要写JPQL或Specification团队里有人不熟悉时改查询会难受。如果你习惯JPA也没关系但下面讲的分页和关联查询思路是一样的。3. 后端接口实战JWT鉴权不止是加一个注解3.1 项目分层结构先固定好我包结构是这样的com.example.blog ├── config ├── controller ├── service ├── mapper ├── entity ├── dto ├── vo ├── common └── utils控制器只接收参数和返回结果业务逻辑全部放service数据访问放mapper。dto放接收前端的请求对象vo放返回前端的响应对象。这个分层不高级但在多人协作时能减少冲突接口文档也容易对起来。3.2 登录和JWT签发的完整流程后台管理接口不能裸奔我用JWT做无状态鉴权。流程是这样的用户提交用户名和密码后端校验通过后生成一个JWT返回前端前端把token存到localStorage每次请求在请求头带上Authorization: Bearer token后端拦截器解析token并放入用户上下文生成token的代码核心部分public class JwtUtil { private static final String SECRET your-secret-key-change-in-production; private static final long EXPIRE_TIME 24 * 60 * 60 * 1000; // 24小时 public static String createToken(Long userId, String username) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() EXPIRE_TIME)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }这里的SECRET一定不能写在代码里写死我项目里是放到application.yml的配置项里部署时用环境变量覆盖。JWT的优势是服务端不用存session横向扩展方便缺点是一旦签发在过期前无法主动撤销。对于博客系统24小时过期时间足够。3.3 用拦截器校验token而不是到处加注解Spring Security也能做但配置复杂度偏高。对于博客后台只有一种角色的场景我用HandlerInterceptor实现了校验逻辑public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String auth request.getHeader(Authorization); if (StringUtils.hasText(auth) auth.startsWith(Bearer )) { String token auth.substring(7); if (JwtUtil.validateToken(token)) { return true; } } response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } }然后在WebMvcConfigurer中注册拦截器只拦截/api/admin/**路径前台的/api/article/list这些公开接口完全放开。这样比在Controller方法上逐个加注解干净得多也不容易漏。这里有一个我踩过的坑前端在请求拦截器里给请求头加token时有些字段名会和CORS预请求冲突。如果你用了Authorization后端跨域配置里必须把Authorization暴露到allowedHeaders里否则浏览器会先拦截预检请求。3.4 文章和评论的核心接口分页、条件查询、状态控制文章列表接口是博客系统的核心我设计成GET请求加参数GetMapping(/api/article/page) public ResultPageResultArticleVO page(RequestParam(defaultValue 1) int page, RequestParam(defaultValue 10) int size, RequestParam(required false) Long categoryId, RequestParam(required false) String keyword) { PageArticle p new Page(page, size); LambdaQueryWrapperArticle wrapper new LambdaQueryWrapper(); wrapper.eq(categoryId ! null, Article::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Article::getTitle, keyword) .eq(Article::getStatus, 1) .orderByDesc(Article::getCreateTime); PageArticle result articleMapper.selectPage(p, wrapper); // 转成VO填充作者名称和分类名称 return Result.success(buildPageResult(result)); }这个接口同时服务前台和后台。前台传status1隐藏草稿后台传status-1可以看到所有草稿。一个接口两用减少重复代码但要注意权限边界。评论接口我做了两级数据校验。第一级是参数校验前端传上来的articleId和content必填第二级是业务校验比如文章是否存在、评论内容长度是否超过500字。文章可能被软删除所以查询评论前先查文章状态避免直接拼SQL查出一个已下架文章下的评论列表。4. 前端页面实现从零搭建Vue管理端和展示端4.1 项目初始化和路由设计前端我用Vite创建工程目录结构按功能拆分src ├── api ├── assets ├── components ├── router ├── stores ├── views │ ├── admin │ └── web路由用Vue Router的history模式但有个大坑后面会讲。我设计了两种布局WebLayout管前台AdminLayout管后台。路由元信息中用meta.requiresAuth标记后台页面是否要登录配合前面说的后端拦截器前端也做一层路由守卫router.beforeEach((to, from, next) { if (to.meta.requiresAuth !authStore.isLoggedIn) { next({ path: /login }) } else { next() } })这套双保险结构虽然简单但能避免用户白屏访问后台时才被后端拒绝。4.2 前台页面列表、详情、评论互动前台首页就是文章列表卡片每张卡片显示封面、标题、摘要、分类、发布时间和浏览量。数据来源是后端的分页接口我用一个articleApi.getPage()函数封装axios调用。为了减少首屏加载时间列表页用了Vue的onMounted只加载第一页数据滚动到底部再加载下一页。文章详情页最麻烦的是正文展示。富文本编辑器存的是HTML所以我用v-html渲染。但这里必须做干净后端在保存正文时过滤掉危险标签前端再对v-html内容做一次白名单校验双保险。我实际开发中被验收方要求不能出现可执行的script标签所以后端用Jsoup做了内容清洗这个后面会细说。评论区我做了最基础的楼中楼发表评论后通过emit事件刷新评论列表。这里没有用WebSocket而是评论成功后立即调一次评论列表接口。博客系统评论频率不高用轮询反而简单稳定。4.3 后台管理富文本编辑和图片上传后台最核心的是文章编辑页。我选了一款开源的富文本编辑器配置了图片上传地址const editorConfig { uploadUrl: /api/admin/upload, uploadFileName: file }上传接口后端接收MultipartFile保存到本地目录后返回可访问的URL。这里我后来统一改成了返回相对路径比如/uploads/2024/05/xxx.png而不是http://localhost:8080/uploads/xxx.png。原因很直接本地开发和生产环境的域名不一样如果返回完整地址部署时图片就全裂了。相对路径加Nginx映射是最省心的方案。图片上传还有一个细节编辑文章时的图片地址要能回显到编辑器中。我上传接口返回的URL直接给前端存到了文章内容的img标签的src属性里这样编辑和展示共用一条数据链路没有额外做图片和文章的关系表。4.4 状态管理登录态和用户信息前端我用Pinia做状态管理只存了三个东西token、用户昵称、用户角色。本来想顺手存文章列表的缓存后来发现得不偿失数据一致性太难维护。状态管理这层应该克制只放全局需要的用户态和配置信息。axios请求拦截器统一从store里取token并注入请求头响应拦截器遇到401就清掉本地登录态并跳转到登录页。axios.interceptors.request.use(config { const authStore useAuthStore() if (authStore.token) { config.headers.Authorization Bearer ${authStore.token} } return config })这个写起来不多但一定要和路由守卫配合好否则会出现提示框还没跳走接口已经连续发好几个401请求的情况。5. 联调与部署那些文档里没写但一定会踩的坑5.1 跨域问题预检请求把所有人卡住了前后端分离项目本地联调第一个坑必然是跨域。前端跑在5173端口后端跑在8080端口浏览器默认不允许跨站请求。我当时在后端写了一个还算通用的CORS配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }注意allowCredentials(true)时不能直接使用allowedOrigins(*)否则浏览器会拒绝。我一开始用了allowedOrigins(*)加allowCredentials(true)结果OPTIONS请求一直不通过查了很久才发现规范不允许这个组合。5.2 图片上传后前端访问不到部署时我把前端打包成dist目录扔到Nginx后端jar包单独跑在8080端口。图片上传到了后端所在的机器目录比如/home/admin/blog/upload/但图片URL访问的是http://域名/upload/xxx。Nginx根本不认识这个路径导致上传成功后编辑器里的图全部裂开。我的解决办法是让Nginx把/upload/开头的请求反向代理到后端服务或者更直接一点让Nginx将/upload/映射到后端的静态资源目录location /upload/ { alias /home/admin/blog/upload/; }同时后端在Spring配置里加了静态资源映射方便本地直接预览Configuration public class WebConfig implements WebMvcConfigurer { Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler(/upload/**) .addResourceLocations(file: uploadPath /); } }这是整个项目里最不起眼但最容易出问题的环节。图片路径方案最好一开始定好代码里不要出现任何硬编码的域名。统一按/upload/相对路径处理部署时只需配好Nginx就能到处跑。5.3 分页查询的参数坑和返回格式统一用MyBatis-Plus分页需要注册分页插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }如果没有这段配置selectPage会查全量数据再内存分页数据一多就卡死。这个问题不少新手遇到因为框架没有任何报错只是分页结果莫名其妙。另外前端传来的page和size我不会直接用会做一次边界约束int finalSize Math.min(size, 30);这样防止有人恶意传size10000把接口拖垮。5.4 富文本内容里的XSS安全处理文章内容是富文本HTML如果用v-html直接渲染等于把前端的安全完全交给了后端。所以我在后端保存文章时增加了一步Jsoup清洗Whitelist whitelist Whitelist.relaxed(); whitelist.addTags(img).addAttributes(img, src, width, height); String safeContent Jsoup.clean(content, whitelist);这段代码会把script、iframe、onerror事件等全部过滤掉。我试过用编辑器直接粘贴一段带onerror的图片标签不清洗的话只要页面一打开就会不断触发恶意请求。做完清洗后文章基本可以安全展示。但要注意白名单配置别一刀切比如你希望文章里支持视频嵌入就得把iframe加到白名单里并限定只允许来自视频平台的src。5.5 Vue history路由刷新后404的解决前端路由用history模式后刷新一个深层链接比如/article/12Nginx会直接找该路径对应的文件找不到就404。解决办法是Nginx配置中把所有路径都回退到index.htmllocation / { try_files $uri $uri/ /index.html; }如果是后台管理端有独立域名还可以按路径前缀配置。这个坑我在部署当天踩的改完Nginx配置后记得nginx -s reload。6. 项目复盘博客系统需要的不是复杂而是克制6.1 上线后我发现浏览量统计数据有问题最初我设计的浏览量是每次访问详情页就update item set view_count view_count 1结果上线第二天就发现同一篇文章刷几遍浏览量破万。后来我加了IP访问限制同一个IP在10分钟内只统计一次。实现上不需要额外表直接在Redis里存一个key过期时间10分钟。但如果你没有上Redis也可以先用一个简单的内存Map加过期清空只是多机部署时会失效。这个功能如果一开始没有想好方案会越做到后面越难受。6.2 评论系统和鉴权模块的解耦原本我想把评论设计成必须登录才能发后来试了试发现体验太差。个人博客就应该是低门槛互动不登录也能评论但我需要处理昵称、邮箱、内容这三个字段。于是我把评论发接口改成公开接口后端限制发送频率防止恶意刷屏。这个决定对代码影响不大却让我更清楚一个道理权限控制不是所有地方越严格越好业务场景决定了安全策略。6.3 从单机到容器化的下一步计划当前项目是单体应用Nginx加一个Java进程数据库用本地MySQL日常维护并不复杂。如果以后要迁移服务器我会考虑用Docker Compose把MySQL、后端、Nginx三个服务编排起来数据目录用volume挂载备份也容易。但我不建议博客系统一开始就上K8s或Service Mesh复杂度过高收益却非常有限。我复盘整轮开发最值得借鉴的并不只是某个框架怎么用而是如何把项目控制在可维护的复杂度内。Spring Boot和Vue极大降低了前后端开发的门槛但项目的成功仍然取决于对业务的理解、对数据的建模和对边界情况的处理。回头再看当初那个旧项目我最大的收获其实不是代码而是明白了“做成什么样子才算真的能用”。如果让我再给后来者一个建议我会说动手之前先把文章正文怎么存、图片路径怎么回显、刷新后路由怎么不404这三个问题想清楚。这三个问题解决了你的博客系统就成功了一大半。