
在后台管理系统、小程序管理端、企业官网这类项目里Spring Boot Vue 的组合几乎成了事实标准。后端 Spring Boot 提供稳健的 API 服务前端 Vue 负责交互体验两者配合能快速完成从数据库到页面的全链路打通。这篇文章不是我第一次写全栈项目的总结而是把最近一个完整项目的实践过程做了系统梳理——从技术选型、项目结构、后端接口设计、前端页面实现到联调部署和踩坑记录尽量覆盖一个真实开发周期里最容易出问题的环节。无论你是刚接触全栈开发的学生还是工作中需要独立搞定前后端的开发者这篇文章的目标都是让你跟着思路走一遍就能搭出属于自己的一套可扩展、可维护的全栈项目。我先把结论放在前面Spring Boot Vue 这套组合学习曲线不算平缓但一旦你把项目结构、接口规范、鉴权流程和数据流这几个关键点想清楚开发效率会非常高。难点不在于某一个框架而在于前后端如何衔接、如何约定、如何排错。正文里我会用一个模拟项目 X 作为贯穿案例它是一个典型的课程管理 用户学习记录系统既有后端的增删改查和权限控制也有前端的列表、表单、路由守卫和状态管理非常适合用来演示全栈开发的完整链路。1. 在动手写代码之前先想清楚这套全栈方案到底在解决什么问题很多人一拿到需求就直接建项目开写结果往往是后端接口写得很随意前端页面堆了一大堆重复代码联调时各种字段对不上。在我实际做过的项目里这个阶段往往决定整个项目的最终质量值得多说几句。1.1 为什么是 Spring Boot 而不是其他后端框架Spring Boot 的核心价值不是快而是约定优于配置。它内置了 Tomcat、自动配置了数据源、简化了依赖管理让你从繁琐的 XML 配置里彻底解放出来。尤其是它的 Starter 机制想加一个 Web 层就引入 spring-boot-starter-web想操作数据库就引入 spring-boot-starter-data-jpa 或 MyBatis 依赖几乎不用写额外的配置代码就能跑起来。从我个人的经验看Spring Boot 在中小型项目里的优势最明显团队不用花大量时间在框架整合上可以把精力集中在业务逻辑本身。它的生态也非常成熟无论是做权限控制Spring Security、接口文档springdoc-openapi还是做缓存Redis、消息队列RabbitMQ都能找到很完善的开源解决方案。对于后面要讲的模拟项目 X 这种带用户体系、权限区分的系统Spring Boot 自带的依赖注入和面向切面编程能力可以让代码结构保持清晰。1.2 为什么前端选择 Vue 3 Vite PiniaVue 在前端框架里的特点一直是渐进式你可以在一个页面里引入它当局部工具用也可以把它当完整的 SPA 框架组织整个前端工程。这套灵活性让团队里不同水平的开发者都能上手不至于像某些重型框架那样一上来就必须按特定模式写。我特别想强调 Vue 3 组合式 API 的价值。用选项式 API 写组件时逻辑是按 data、methods、computed 这些选项去分块的当组件变复杂后同一个功能的代码往往被拆散在各个选项里改一个功能要上下跳好几处。组合式 API 则让你能按功能将逻辑组织在一个函数块里就像写普通函数一样自然。Pinia 作为 Vue 3 官方推荐的状态管理库比 Vuex 更简洁没有那么多样板代码非常适合本项目里存储用户登录状态和课程学习记录。Vite 带来的变化同样明显。开发调试时它能做到秒级启动得益于它基于 ES Module 的原生加载方式而不是像 Webpack 那样先把所有东西打包一遍。这种即时反馈对开发体验的提升是实打实的——我在项目里改动一个组件浏览器几乎是同步刷新。1.3 案例项目的整体目标和功能边界为了让整篇指南有一条清晰的主线我用模拟项目 X 作为贯穿案例。这是一个课程管理与学习记录系统目标用户是在线教育机构的学习者和管理员。它需要支持以下核心功能用户注册与登录登录后区分普通用户和管理员角色管理员可以维护课程信息包括新增、编辑、上下架普通用户可以浏览课程列表、查看课程详情、记录自己的学习进度所有接口都需要身份验证管理员操作还要额外的权限校验这套功能虽然不算复杂但覆盖了全栈开发里最重要的几块拼图用户鉴权、CRUD 接口、前后端交互、状态管理、权限控制。把它拆解清楚再做类似的系统就有了明确的参考。2. 后端工程结构先搭骨架还是先写业务顺序有讲究后端工程结构决定了整个后端团队协作的顺畅程度。我见过太多项目把 Controller、Service、Mapper 全部写在几个大类里几百行的文件比比皆是后续维护苦不堪言。Spring Boot 本身对分层没有强制规定但成熟的工程结构能帮你和同事节省大量沟通成本。2.1 按功能模块分包而不是按技术分层分包这里要区分两个概念按技术分层是指所有 Controller 放一个包、所有 Service 放一个包、所有 Mapper 放一个包按功能模块分包是指把用户相关的 Controller、Service、Mapper 放在同一个包下课程相关的放在另一个包下。我强烈建议按功能模块分包。为什么想象一个用户模块和课程模块按技术分层时你改一个用户功能可能需要同时在 controller、service、mapper 三个包之间跳跃而按功能模块分包后所有相关的文件就在同一个目录树里查找和修改的路径缩短很多。当项目变大按模块分包还能天然地形成模块边界后续拆分成微服务时可以直接把某个包提取出去。具体到模拟项目 X我建议的包结构大致是这样的com.example.projectx ├── common // 通用类统一返回结果、异常处理、工具类 ├── config // 配置类安全配置、跨域配置、拦截器注册 ├── security // 鉴权相关JWT 工具类、过滤器、自定义认证逻辑 ├── module │ ├── user // 用户模块实体、DTO、Controller、Service、Mapper │ ├── course // 课程模块 │ └── study // 学习记录模块common 和 config 是横向的公共基础设施module 下每个子包都是一个完整的业务模块。如果你用了 MyBatis通常会在 module 里直接放 Mapper 接口和对应的 XML 文件而不是单独抽一个 mapper 包这同样是按模块聚合的思路。2.2 统一后端返回结果和异常处理联调时能省一半时间前后端联调里最让人烦躁的就是错误信息不统一。有时候接口返回的是一个字符串有时候是一个 Map前端根本没法做统一的错误提示。我在项目里的做法是定义一个通用的返回结果类所有接口都用它包一层。Data public class ApiResultT { private int code; private String message; private T data; public static T ApiResultT success(T data) { ApiResultT result new ApiResult(); result.setCode(200); result.setMessage(success); result.setData(data); return result; } public static T ApiResultT error(int code, String message) { ApiResultT result new ApiResult(); result.setCode(code); result.setMessage(message); return result; } }这样前端只需要根据 code 字段判断请求是否成功message 用于提示data 才是真正需要的数据。配合全局异常处理器业务异常能自动转换成统一的错误结构不会让一些底层异常堆栈直接暴露到前端。全局异常处理也是必须考虑的我用了RestControllerAdvice来统一接收异常。比如参数校验失败的异常、业务异常、未知异常分别处理成合适的返回码和提示信息。这一点很多人容易忽略等到接口报错时前端看到的是 500 加一串堆栈体验很差。2.3 使用 JWT 做登录态管理无状态鉴权更适合前后端分离模拟项目 X 需要区分用户和管理员角色所以我选了 JWTJSON Web Token做鉴权。JWT 的核心原理是用户在登录成功后服务端生成一个包含了用户信息和过期时间的签名字符串返回给前端前端在后续请求的请求头里带上这个 Token服务端通过校验签名和有效期来识别用户身份。这里值得说明的是 JWT 本身是无状态的服务端不需要存储 Session这让服务可以水平扩展非常适合微服务和前后端分离架构。但在实际项目中我也踩过一些坑比如 Token 如何续期的问题。我的做法是设置一个较短的过期时间比如三十分钟同时前端维护一个刷新 Token 的接口在 Token 快过期时自动换取新的 Token。这样既保证了安全性又不会频繁要求用户重新登录。在 Spring Boot 里用 JWT一般通过自定义过滤器在请求进入 Controller 之前完成 Token 的解析和用户信息的注入。Spring Security 配置里我允许了登录和注册接口匿名访问其余接口需要经过 JWT 过滤器同时针对/admin开头的接口额外校验管理员角色。3. 模拟项目 X 后端核心模块实现从数据库表设计到接口组装前面讲了工程结构和通用基础设施现在开始具体写业务代码。模拟项目 X 涉及用户、课程和学习记录三个核心模块我会重点讲解课程模块和学习记录模块的设计思路用户模块主要涉及密码加密和角色处理。3.1 数据库表设计宁可多花半天建模也不要上线后三天两头改表数据表是业务的地基设计不好后面改动成本极高。模拟项目 X 里我设计了五张关键表表名用途关键字段user用户表id, username, password, role, create_timecourse课程表id, title, cover, description, teacher, statuscourse_chapter课程章节表id, course_id, title, sort, contentstudy_record学习记录表id, user_id, course_id, chapter_id, progresscourse_category课程分类表id, name, sort关于 user 表的 password 字段我很想强调一点永远不要明文存储密码。这里我用了 BCryptPasswordEncoder 对密码做不可逆的哈希处理而不是简单的 MD5。尽管 MD5 在学术意义上也是哈希但它已经被彩虹表攻击得很透BCrypt 加入了盐值计算同样的密码在不同用户表里也会生成不同的哈希值安全性高了很多。course 表和 course_chapter 表是一对多的关系每一门课程下面挂着多个章节。study_record 表则记录了某个用户在学习某个课程时的进度比如学到了第几章节、当前进度百分比。这样设计的好处是可以很自然地支持后续的继续上次学习功能。3.2 课程接口的完整实现Controller 只做调度Service 承载业务以课程的新增、修改、列表查询三个核心接口作为例子说明控制层和服务层各自的职责划分。先看 Service 层的实现思路。Service 接口定义了业务方法ServiceImpl 实现类里写真正的逻辑。课程新增时需要做几件事检查课程标题是否重复、处理封面图片的存储路径、设置默认状态为下架。课程列表查询时支持按关键词搜索、按分类筛选、按状态过滤还要支持分页。Service public class CourseServiceImpl implements CourseService { Autowired private CourseMapper courseMapper; Autowired private CourseCategoryMapper categoryMapper; Override public PageResultCourseVO pageQuery(CourseQueryDTO query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); // 条件查询交给 Mapper 来处理动态 SQL ListCourseVO list courseMapper.selectCoursePage(query); PageInfoCourseVO pageInfo new PageInfo(list); return PageResult.build(pageInfo.getTotal(), pageInfo.getList()); } }Controller 层只负责接收参数和返回结果不再写业务逻辑。拿新增课程接口来说PostMapping(/course) public ApiResultLong createCourse(RequestBody Validated CourseCreateDTO dto) { Long courseId courseService.createCourse(dto); return ApiResult.success(courseId); }参数校验我放在了 DTO 里通过NotNull、NotBlank这类注解完成简单的非空校验。复杂一点的业务校验则下放到 Service 层比如管理员角色校验、状态机流转的合法性判断。这样拆分之后Controller 很薄Service 才是逻辑的载体。3.3 学习记录模块事务边界和进度计算的细节学习记录是模拟项目 X 里最容易被忽视但实际很考验细节的模块。用户每次看完一节前端会调用接口上报学习进度。这个接口的特别之处在于它不是一个简单的新增或修改而是需要有则更新、无则插入的逻辑。Transactional Override public void reportStudyProgress(StudyProgressDTO dto) { StudyRecord record studyRecordMapper.findByUserAndCourseAndChapter( dto.getUserId(), dto.getCourseId(), dto.getChapterId()); if (record null) { StudyRecord newRecord new StudyRecord(); newRecord.setUserId(dto.getUserId()); newRecord.setCourseId(dto.getCourseId()); newRecord.setChapterId(dto.getChapterId()); newRecord.setProgress(dto.getProgress()); studyRecordMapper.insert(newRecord); } else { record.setProgress(dto.getProgress()); studyRecordMapper.updateById(record); } // 更新课程总进度如果本次上报的章节是最后章节且进度为100%则整个课程完成 updateCourseTotalProgress(dto.getUserId(), dto.getCourseId()); }为什么这里一定要加Transactional因为查找—插入或更新—更新总进度这三个操作是一个整体任何一个环节失败前面的操作都不应该生效。比如用户在极短的时间内重复点击下一章并发请求可能导致记录被插入两次。事务配合数据库的唯一索引可以尽量避免这种脏数据问题。关于进度计算的细节我在项目里是把每个章节的权重按章节数平均分配。课程的总进度等于用户学完的章节数占总章节数的百分比。前端上报的是看完了哪一章后端来计算总进度而不是让前端直接传一个百分比数字避免前端数据被篡改。3.4 MyBatis 动态 SQL 和多表联查的实际用法在课程列表的分页查询里需要根据不同的条件拼接不同的 SQLMyBatis 的where和if标签在此格外好用。select idselectCoursePage resultTypecom.example.projectx.module.course.vo.CourseVO SELECT c.id, c.title, c.cover, c.teacher, c.status, cc.name AS categoryName FROM course c LEFT JOIN course_category cc ON c.category_id cc.id where if testkeyword ! null and keyword ! AND c.title LIKE CONCAT(%, #{keyword}, %) /if if testcategoryId ! null AND c.category_id #{categoryId} /if if teststatus ! null AND c.status #{status} /if /where ORDER BY c.create_time DESC /select这里要特别留意 SQL 注入的问题。很多新手会把前端传来的关键字直接用字符串拼接进 SQL这是非常危险的做法。MyBatis 的#{}预处理参数会生成?占位符由 JDBC 的 PreparedStatement 来绑定参数从根本上避免了注入风险。永远不要在生产代码里用${}去拼接用户输入。4. 前端工程实现路由、状态管理、API 封装一个都不能少后端接口写完前端就可以同步开工了。我用 Vite 初始化了一个 Vue 3 项目目录结构做了清晰的分层页面组件放在 views 下公共组件放在 components 下网络请求封装在 api 目录状态管理放在 stores 目录。4.1 用 Pinia 管理登录状态而不是每个页面各自请求用户信息一个非常常见的错误设计是每个需要用户信息的页面都去调一次获取当前用户信息的接口多浪费请求不说页面间状态还不统一。我用 Pinia 定义了一个 user store登录成功后把用户信息和 Token 都保存到 store 里后续页面直接从 store 中读取需要时才向后端刷新信息。import { defineStore } from pinia export const useUserStore defineStore(user, { state: () ({ token: localStorage.getItem(token) || , userInfo: null }), actions: { async login(loginForm) { const res await api.post(/login, loginForm) this.token res.data.token this.userInfo res.data.userInfo localStorage.setItem(token, this.token) }, logout() { this.token this.userInfo null localStorage.removeItem(token) } } })为什么要把 token 同时存到 localStorage原因很简单页面刷新后 Pinia 的状态会全部丢失Token 存在 localStorage 里可以保证刷新后还能从本地恢复登录态。这种持久化一部分状态内存里留一部分状态的组合策略在全栈项目里很常用。4.2 axios 拦