Spring Boot+Vue前后端分离项目实战:从联调翻车到工程化开发

发布时间:2026/7/21 15:47:18
Spring Boot+Vue前后端分离项目实战:从联调翻车到工程化开发 上周帮一个学弟看他的毕业设计他选了一个看起来“很常规”的题目基于 Spring Boot 和 Vue 的考试报名系统。他信心满满觉得框架成熟、资料也多应该很快就能搞定。结果在项目跑起来的第一周就卡在了前后端联调上——前端请求发不出去后端接口收不到数据控制台一片红。他问我“哥我照着网上的教程一步步做的为什么就是不行”这其实不是他一个人的问题。很多同学在做这类“毕设/课设/练手”项目时都会陷入一个误区以为把 Spring Boot 和 Vue 的架子搭起来再把功能点一个个“堆”上去项目就算完成了。结果往往是项目能跑但代码像一盘散沙经不起任何推敲更别提应对答辩老师的提问了。今天我们就以这个“考试报名系统”为例彻底拆解一下如何把一个看似简单的“增删改查”项目做成一个结构清晰、逻辑严谨、具备一定工程化思维的“学习作品”。这不仅仅是完成一个作业更是理解现代 Web 应用开发核心工作流的一次深度实践。1. 为什么你的 Spring Boot Vue 项目总在“联调”上翻车很多人把联调失败归咎于“跨域”这个技术名词。但跨域只是一个表象更深层的原因是你没有建立起前后端分离开发最基本的“通信契约”意识。1.1 从“各写各的”到“契约先行”在单体应用时代JSP 里直接写 Java 代码数据和视图混在一起没有契约问题。但在前后端分离架构下前端Vue和后端Spring Boot是两个独立的应用它们通过网络 API主要是 HTTP JSON进行通信。这个 API 的格式请求路径、方法、参数、响应结构就是它们之间的“契约”。最常见的翻车现场是前端按照自己臆想的格式比如{username: xxx}发送请求。后端按照自己定义的格式比如{userName: xxx}接收参数。结果后端收不到数据返回null或报错。解决方案不是一上来就配CrossOrigin而是先明确契约。一个高效的做法是后端先行并生成 API 文档。使用 Swagger/OpenAPI在 Spring Boot 中集成springdoc-openapi-starter-webmvc-ui。为每个控制器Controller和方法添加详细的注解Tag,Operation,Parameter等。启动项目访问/swagger-ui.html你会得到一个可视化的、可交互的 API 文档。这里明确定义了每个接口的 URL、方法、请求体格式、响应体格式。前端开发者以此文档为唯一依据Vue 项目中发起请求使用 Axios时严格遵循文档中的路径和数据结构。这样做联调就从“互相猜测”变成了“对照合同执行”效率和质量天差地别。跨域配置CorsConfig只是为了在开发环境下允许来自前端地址如localhost:8080的请求访问后端如localhost:8081它是为契约服务的不能替代契约。1.2 超越“Postman 测试通过”理解完整的请求生命周期很多同学在 Postman 里测试接口返回 200 就认为后端没问题了但前端一调用就出错。这是因为 Postman 绕过了一些前端框架如 Vue Axios特有的处理逻辑。你需要理解一个 HTTP 请求从前端发出到后端处理再返回的完整链条Vue Component (触发) - Axios Instance (拦截/转换) - Network - Spring Boot DispatcherServlet (路由) - Interceptor (拦截) - Controller (处理) - Service - Mapper - DB - Response -在这个链条中每个环节都可能出问题Axios 配置baseURL设对了吗请求头Content-Type: application/json加了吗请求/响应拦截器你是否统一处理了错误如response.status ! 200是否在请求头携带了 TokenSpring Boot 参数接收是RequestBody接收 JSON 对象还是RequestParam接收 URL 参数字段名是否匹配Spring Boot 统一响应封装你的 Controller 是返回一个自定义的Result对象如Result.success(data)还是直接返回实体或Map前端期望的格式是什么实操建议在 Vue 的main.js或单独的request.js中配置一个全局的 Axios 实例并设置好拦截器。在后端创建一个统一的响应结果类如R或ApiResponse让所有接口返回相同结构的 JSON。// 后端示例统一响应体 Data public class ResultT { private Integer code; // 200成功500失败 private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(操作成功); result.setData(data); return result; } // ... 其他静态工厂方法 }// 前端示例Axios 实例与拦截器 import axios from axios; const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, // 从环境变量读取 timeout: 10000 }); // 请求拦截器 service.interceptors.request.use( config { // 如果有token携带在请求头 const token localStorage.getItem(token); if (token) { config.headers[Authorization] Bearer token; } return config; }, error { return Promise.reject(error); } ); // 响应拦截器 service.interceptors.response.use( response { const res response.data; // 假设你的后端统一返回 code 字段 if (res.code ! 200) { // 处理业务错误例如提示用户 console.error(请求错误:, res.msg); return Promise.reject(new Error(res.msg || Error)); } else { return res.data; // 直接返回后端封装里的 data 部分 } }, error { // 处理 HTTP 状态码错误如 404, 500 console.error(网络或服务器错误:, error); return Promise.reject(error); } ); export default service;当你能清晰地描述出这个链条并能在每个环节放置“检查点”如浏览器开发者工具的 Network 面板、后端控制台日志联调的问题绝大多数都能被快速定位。2. 考试报名系统从功能清单到领域模型设计拿到“考试报名系统”这个题目新手通常会立刻开始罗列功能用户注册登录、考试信息发布、学生报名、缴费、打印准考证……然后为每个功能创建一张表。这种方法做出来的系统表之间关系混乱业务逻辑分散在各个角落维护和扩展极其困难。正确的起点是进行简单的领域模型设计。这不是复杂的 DDD而是思考你的系统核心“东西”是什么它们之间如何关联。2.1 识别核心实体与关系对于考试报名系统我们可以先找出核心实体User (用户)包括学生、管理员。核心属性id, username, password, realName, role。Exam (考试)核心属性id, name, description, startTime, endTime, signUpStart, signUpEnd, capacity (容量), fee。SignUpRecord (报名记录)这是连接用户和考试的核心纽带。核心属性id, userId, examId, signUpTime, status (如“待支付”、“已报名”、“已取消”), paymentInfo。它们的关系是一个User可以报名多个Exam。一个Exam可以被多个User报名。SignUpRecord记录了某次具体的报名行为它是“多对多”关系的中间实体。2.2 设计数据表与实体类基于上述模型设计数据库表以 MySQL 为例-- 用户表 CREATE TABLE sys_user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(50) UNIQUE NOT NULL COMMENT 用户名, password varchar(100) NOT NULL COMMENT 密码加密后, real_name varchar(50) COMMENT 真实姓名, role varchar(20) DEFAULT STUDENT COMMENT 角色ADMIN, STUDENT, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); -- 考试表 CREATE TABLE exam ( id bigint NOT NULL AUTO_INCREMENT, name varchar(100) NOT NULL COMMENT 考试名称, description text COMMENT 考试描述, start_time datetime NOT NULL COMMENT 考试开始时间, end_time datetime NOT NULL COMMENT 考试结束时间, sign_up_start datetime NOT NULL COMMENT 报名开始时间, sign_up_end datetime NOT NULL COMMENT 报名截止时间, capacity int DEFAULT 0 COMMENT 可报名容量0表示无限制, fee decimal(10,2) DEFAULT 0.00 COMMENT 报名费用, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ); -- 报名记录表核心业务表 CREATE TABLE sign_up_record ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL COMMENT 报名用户ID, exam_id bigint NOT NULL COMMENT 报名的考试ID, sign_up_time datetime DEFAULT CURRENT_TIMESTAMP COMMENT 报名时间, status varchar(20) DEFAULT PENDING_PAYMENT COMMENT 状态PENDING_PAYMENT, PAID, CANCELLED, payment_id varchar(100) COMMENT 第三方支付流水号, PRIMARY KEY (id), UNIQUE KEY uk_user_exam (user_id, exam_id), -- 防止同一用户重复报名同一考试 FOREIGN KEY (user_id) REFERENCES sys_user(id), FOREIGN KEY (exam_id) REFERENCES exam(id) );在 Spring Boot 中使用 MyBatis-Plus 或 JPA 创建对应的实体类。关键点在于实体类之间的关系映射。以 MyBatis-Plus 为例我们通常不在实体类中做复杂的 ORM 映射而是在 Service 层或 Mapper 的 XML 中通过查询关联。但实体类可以体现基础关系// Exam.java Data TableName(exam) public class Exam { TableId(type IdType.AUTO) private Long id; private String name; private String description; private LocalDateTime startTime; private LocalDateTime endTime; private LocalDateTime signUpStart; private LocalDateTime signUpEnd; private Integer capacity; private BigDecimal fee; private LocalDateTime createTime; } // SignUpRecord.java Data TableName(sign_up_record) public class SignUpRecord { TableId(type IdType.AUTO) private Long id; private Long userId; private Long examId; private LocalDateTime signUpTime; private String status; // 可使用枚举类更佳 private String paymentId; // 非数据库字段用于关联查询后填充 TableField(exist false) private String userName; TableField(exist false) private String examName; }2.3 业务逻辑的落脚点Service 层Controller 只负责接收请求和返回响应真正的业务逻辑应该在 Service 层。以“报名”这个核心业务为例Service 方法需要做很多事情远不止一个insertService Slf4j public class SignUpService { Autowired private ExamMapper examMapper; Autowired private SignUpRecordMapper recordMapper; Transactional(rollbackFor Exception.class) // 开启事务 public Result signUp(Long userId, Long examId) { // 1. 校验考试是否存在且未过期 Exam exam examMapper.selectById(examId); if (exam null) { return Result.fail(考试不存在); } LocalDateTime now LocalDateTime.now(); if (now.isBefore(exam.getSignUpStart())) { return Result.fail(报名尚未开始); } if (now.isAfter(exam.getSignUpEnd())) { return Result.fail(报名已截止); } // 2. 校验是否已报名 LambdaQueryWrapperSignUpRecord queryWrapper new LambdaQueryWrapper(); queryWrapper.eq(SignUpRecord::getUserId, userId) .eq(SignUpRecord::getExamId, examId); if (recordMapper.selectCount(queryWrapper) 0) { return Result.fail(您已报名该考试请勿重复操作); } // 3. 校验考试容量如果有限制 if (exam.getCapacity() 0) { LambdaQueryWrapperSignUpRecord countWrapper new LambdaQueryWrapper(); countWrapper.eq(SignUpRecord::getExamId, examId) .in(SignUpRecord::getStatus, Arrays.asList(PENDING_PAYMENT, PAID)); // 统计有效报名 int signedCount recordMapper.selectCount(countWrapper); if (signedCount exam.getCapacity()) { return Result.fail(该考试报名人数已满); } } // 4. 创建报名记录状态为“待支付” SignUpRecord record new SignUpRecord(); record.setUserId(userId); record.setExamId(examId); record.setSignUpTime(now); record.setStatus(PENDING_PAYMENT); recordMapper.insert(record); // 5. 这里可以触发后续流程如发送站内信、调用支付接口等 log.info(用户[{}]成功创建考试[{}]的报名记录记录ID:{}, userId, examId, record.getId()); // 6. 返回成功并可能包含支付所需信息如订单号、金额 MapString, Object data new HashMap(); data.put(recordId, record.getId()); data.put(examName, exam.getName()); data.put(fee, exam.getFee()); return Result.success(data); } }这个signUp方法体现了业务服务的核心价值封装原子操作维护业务规则保证数据一致性。它处理了时间校验、唯一性校验、容量校验并在一个事务内完成记录创建。这才是答辩时值得展示的“业务逻辑”而不是简单的 CRUD。3. 前端 Vue不是画页面而是管理状态与交互很多同学把 Vue 项目做成了“静态页面集合”用 jQuery 的思路去操作 DOM。Vue 的核心是响应式数据驱动和组件化。对于考试报名系统前端的关键在于如何优雅地管理应用状态。3.1 状态集中管理Vuex 的必要性当你的应用涉及到多个组件共享状态时比如用户登录信息、全局的考试列表就应该使用 Vuex。以用户登录为例定义 Store// store/index.js import Vue from vue; import Vuex from vuex; Vue.use(Vuex); export default new Vuex.Store({ state: { token: localStorage.getItem(token) || , userInfo: JSON.parse(localStorage.getItem(userInfo)) || null }, mutations: { SET_TOKEN(state, token) { state.token token; localStorage.setItem(token, token); }, SET_USER_INFO(state, userInfo) { state.userInfo userInfo; localStorage.setItem(userInfo, JSON.stringify(userInfo)); }, CLEAR_AUTH(state) { state.token ; state.userInfo null; localStorage.removeItem(token); localStorage.removeItem(userInfo); } }, actions: { login({ commit }, { username, password }) { return new Promise((resolve, reject) { // 调用后端的登录接口 api.login({ username, password }).then(res { commit(SET_TOKEN, res.data.token); commit(SET_USER_INFO, res.data.userInfo); resolve(); }).catch(error { reject(error); }); }); }, logout({ commit }) { commit(CLEAR_AUTH); // 可能还需要调用后端退出接口 } }, getters: { isAuthenticated: state !!state.token, currentUser: state state.userInfo } });在组件中使用template div div v-if!isAuthenticated !-- 登录表单 -- /div div v-else 欢迎{{ currentUser.realName }} button clicklogout退出/button /div /div /template script import { mapGetters, mapActions } from vuex; export default { computed: { ...mapGetters([isAuthenticated, currentUser]) }, methods: { ...mapActions([logout]) } }; /script这样用户的登录状态在整个应用中都是一致且可追踪的。3.2 组件化设计以“考试列表”和“考试卡片”为例不要在一个Exam.vue文件里写几百行代码。应该拆分成ExamList.vue负责获取考试列表数据、处理分页和筛选逻辑。ExamCard.vue负责展示单条考试的视觉信息并触发报名等操作。ExamFilter.vue负责提供筛选条件如按时间、状态。ExamList.vue通过props向ExamCard传递考试数据ExamCard通过$emit向父组件传递“报名”事件。这种设计使得每个组件职责单一易于复用和测试。3.3 路由与导航守卫使用 Vue Router 管理页面跳转。对于需要权限的页面如“个人中心”、“管理后台”使用导航守卫router.beforeEach进行拦截。// router/index.js router.beforeEach((to, from, next) { const isAuthenticated store.getters.isAuthenticated; const requiresAuth to.matched.some(record record.meta.requiresAuth); const userRole store.getters.currentUser?.role; if (requiresAuth !isAuthenticated) { // 需要登录但未登录跳转到登录页 next({ path: /login, query: { redirect: to.fullPath } }); } else if (to.meta.roles !to.meta.roles.includes(userRole)) { // 需要特定角色但角色不符跳转到无权限页面或首页 next({ path: /403 }); } else { next(); // 放行 } });4. 从“能跑”到“耐看”毕设项目的深度打磨点一个仅能完成增删改查的“玩具”系统和一个能让答辩老师眼前一亮的“作品级”系统差距往往体现在细节上。4.1 安全性不只是密码加密密码存储必须使用 BCrypt 等强哈希算法绝对不要用 MD5 或明文。Spring Security 或BCryptPasswordEncoder可以轻松实现。API 防护SQL 注入使用 MyBatis-Plus 等框架的预编译机制避免手动拼接 SQL。XSS对用户输入进行过滤或转义或使用模板引擎如 Thymeleaf的默认转义功能。在 Vue 中使用{{ }}插值会自动转义 HTML。CSRF如果使用 Session 认证Spring Security 默认提供 CSRF 防护。如果是 JWT 无状态认证需确保 Token 存放在安全的地方如 HttpOnly Cookie并防范 XSS 盗取 Token。权限控制实现基于角色的访问控制RBAC。在 Spring Boot 中可以使用PreAuthorize(“hasRole(‘ADMIN’)”)注解在方法级别进行控制。前端菜单和按钮也应根据用户角色动态渲染。4.2 用户体验与健壮性输入验证前端使用 Vue 的表单验证库如 VeeValidate进行即时提示后端必须再次进行合法性校验使用 Spring 的Valid注解和自定义校验器。校验考试时间是否合理、报名截止时间是否早于考试时间等业务规则。友好的反馈操作成功或失败应有明确的提示如使用 Element UI 的Message组件。加载数据时显示加载状态。异常处理后端使用ControllerAdvice定义全局异常处理器将不同的异常如ServiceException、UnauthorizedException转换为结构化的错误信息返回给前端。前端在 Axios 拦截器中统一捕获并提示。数据一致性涉及多个表更新的操作如报名成功后更新考试已报名人数务必使用Transactional保证事务。4.3 可扩展性思考加分项在答辩时如果能提到以下几点会显得你对项目有更深的理解支付集成报名系统绕不开支付。可以调研并模拟集成支付宝/微信支付的沙箱环境设计一个PaymentService定义支付、回调、查询的接口。即使不真实接通这个设计本身就有价值。文件上传如果需要上传照片或证明材料设计一个独立的文件服务或使用 OSS并在数据库中存储文件路径而非文件本身。缓存对于频繁读取且变化不频繁的数据如考试列表可以考虑引入 Redis 缓存减轻数据库压力。部署与监控简单描述如何将 Spring Boot 项目打包成 JAR使用 Nginx 代理前端静态文件并转发后端 API。提及基本的监控如查看应用日志、数据库连接池状态等。4.4 文档与代码质量README.md这是项目的门面。必须包含项目简介、技术栈、本地运行指南环境要求、数据库配置、启动步骤、项目结构说明。清晰的代码结构exam-system-backend/ ├── src/main/java/com/example/exam/ │ ├── config/ # 配置类Swagger, Cors, MybatisPlus, Redis等 │ ├── controller/ # 控制器层 │ ├── entity/ # 实体类 │ ├── mapper/ # MyBatis Mapper接口 │ ├── service/ # 业务服务层接口 │ ├── service/impl/ # 业务服务层实现 │ ├── dto/ # 数据传输对象用于接口入参出参 │ ├── vo/ # 视图对象用于前端展示 │ └── ExamApplication.java # 启动类 ├── resources/ │ ├── application.yml # 主配置文件 │ └── mapper/ # MyBatis XML文件如果用的话 └── pom.xml有意义的提交信息使用 Git并且提交信息不要都是“update”。可以按功能或修复来提交如feat: 完成用户登录注册模块、fix: 修复报名时间校验逻辑bug。做这个项目的最终目的不是得到一个能运行的.jar包和一堆.vue文件而是通过它完整地走一遍现代 Web 应用从设计、开发、联调到部署的核心流程。当你下次再看到“基于 Spring Boot 和 Vue 的 XX 系统”时你脑子里浮现的不再是一个个孤立的技术名词而是一套清晰、可协作、可维护的开发蓝图。这才是“练手学习”真正要带走的东西。