ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM + Vue前后端分离实现学生选课系统:从架构到部署实战

SSM + Vue前后端分离实现学生选课系统:从架构到部署实战 简介面向计算机、软件工程等专业的毕业设计及课程设计基于SSM框架与Vue.js的旅客行李管理系统聚焦机场、车站行李登记与查询等场景前后端完整覆盖业务流程。压缩包共414个文件约29.07MB包含90个Java后端源码、52个Vue前端组件、MySQL初始化脚本及XML、yml等配置附毕业论文doc文档目录结构清楚便于按模块学习。目前已有100人学习适合作为毕设或期末大作业参考资料。包内说明文档与论文详细讲述系统设计、实现和测试过程代码调试通过可直接运行。如需扩展功能需具备Java和Vue基础并深入研读源码是兼具工程实践与学术参考价值的完整资源。1. 用 Java 和 Vue 写学生选课系统这次不只是换了个前端框架做过课设的人都清楚学生选课系统几乎是 Java Web 里最常见的题目但今年这一版的要求明显变了后端得用 SSM前端得用 Vue而且必须前后端分离。以前 JSP 里套 Java 代码的写法在今天已经很难让论文和项目说明书写出花来了。前后端分离真正的价值不在「代码分层」这四个字而在于把「数据接口」和「页面交互」拆成两个可以独立开发的工程两个人并行开发不互相阻塞联调时只对接口文档改页面样式也完全不用重启后端服务。当然代价是引入了跨域、token 鉴权、路由守卫、代理转发这一批新的概念。这篇文章就把 SSM 加 Vue 做学生选课系统这件事拆开讲从设计思路、数据库结构到后端接口、前端页面怎么落地再到联调与部署的坑全部给到能直接使用的命令、配置和代码让一份课程设计既能跑得起来又能讲得清楚。2. 从架构到数据库学生选课系统的数据流与核心表设计2.1 前后端分离到底分离了什么很多人在概念上有个误区以为前后端分离就是把 HTML 文件从 Java 工程里挪到另一个文件夹里。真正的分离是职责的分离后端只负责处理业务逻辑、读写数据库、以 JSON 形式返回结果不再关心页面长什么样前端拿到 JSON 数据后自己决定如何渲染成表格、弹窗、表单。两者之间唯一的契约就是接口文档。在你做的这个学生选课系统里前端的核心职责包括登录页面、学生端课程列表页、选课/退课操作、教师端开课表单、选课名单展示后端要提供的能力则是用户登录与角色鉴权、课程信息的增删改查、学生选课退课、教师开课申请、管理员审核。模块数量不多但足够把 SSM 与 Vue 的典型用法全部覆盖到。按照图表的方式看两者的协作关系浏览器Vue Element UI ↓ 发送 axios 请求JSON 格式 Spring MVC Controller接收请求、校验参数 ↓ Service 业务层处理选课规则、审核逻辑 ↓ MyBatis Mapper封装数据库操作 ↓ MySQL持久化数据从前端发起一个请求到数据库返回结果再反过来渲染到页面整个链路的每一层都有明确的职责边界。调用链中有一个值得留意的点后端返回数据时需要统一包装不能一会儿返回一个数组一会儿返回一个对象。2.2 为什么毕设项目仍然选 SSM 而不是 Spring Boot如果说你现在是为了就业去学框架那直接用 Spring Boot 是合理的。但学生选课系统这种课设、毕设题目SSM 依然有它的不可替代性知识点更裸露。Spring 的 IOC 容器怎么管理 Bean、Spring MVC 的 DispatcherServlet 如何分发请求、MyBatis 怎么把接口方法映射到 SQL这些在 SSM 里都要通过显式配置来理解。在答辩现场面试或评审老师最喜欢问的恰恰是这些底层组装过程。SSM 最核心的整合点是让 Spring 管理 MyBatis 的 SqlSessionFactory并把 Mapper 接口动态代理成 Spring 容器中的 Bean。项目中的 spring-mybatis.xml 里最常见的写法如下!-- spring-mybatis.xml -- bean iddataSource classorg.springframework.jdbc.datasource.DriverManagerDataSource property namedriverClassName valuecom.mysql.jdbc.Driver/ property nameurl valuejdbc:mysql://localhost:3306/course_system?useUnicodetrueamp;characterEncodingutf8/ property nameusername valueroot/ property namepassword value你的数据库密码/ /bean bean idsqlSessionFactory classorg.mybatis.spring.SqlSessionFactoryBean property namedataSource refdataSource/ property namemapperLocations valueclasspath:mapper/*.xml/ /bean bean classorg.mybatis.spring.mapper.MapperScannerConfigurer property namebasePackage valuecom.course.system.dao/ /beanmapperLocations指定的是 Mapper XML 映射文件的存放位置SQL 语句全部写在 XML 文件里MapperScannerConfigurer会扫描com.course.system.dao包下的接口并将它们自动代理成 Spring 管理的 Bean。这就是为什么 Mapper 接口没有实现类却还能直接被 Service 注入使用的原因答辩时这个问题出现的频率非常高。2.3 前端技术选型Vue 2 加 Element UI 的高效组合前端部分采用 Vue 2、Vue Router、Vuex 与 Element UI 的组合。Vue 3 已经发布很久但放到课设项目里Vue 2 的资源多、组件库兼容好、网上案例充足这套方案更稳妥。Element UI 提供的表格、表单、分页、消息提示组件几乎能覆盖学生选课系统的全部 UI 需求节省大量手写 CSS 的时间。前端的工程目录规划如下course-web/ ├── public/ ├── src/ │ ├── api/ # 按功能拆分的接口请求文件 │ ├── router/ # 路由配置与路由守卫 │ ├── store/ # Vuex 状态管理登录态、用户信息 │ ├── views/ # 页面组件Login / Student / Teacher / Admin │ ├── components/ # 复用组件如课程卡片、分页 │ ├── utils/ # axios 封装、工具函数 │ └── App.vue ├── package.json ├── vue.config.js # 开发环境配置 └── babel.config.js目录中最重要的文件是vue.config.js因为开发环境下前后端分离必然涉及跨域问题。前端 dev server 跑在 8081后端 Tomcat 跑在 8080两边端口不同直接发请求会被 CORS 拦截。常见做法是使用 devServer 的代理功能让浏览器以为请求是同源的// vue.config.js module.exports { devServer: { port: 8081, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } };这组配置的含义是当前端发送以/api开头的请求时dev server 会将请求转发到http://localhost:8080同时自动把 URL 中的/api前缀去掉。举例来说前端调用POST /api/user/login后端 Tomcat 接收到的是POST /user/login。这样浏览器看到的请求始终是同源的跨域问题就被优雅地绕开了。2.4 数据库设计四张表支撑整个选课业务学生选课系统的表结构不复杂核心表加上必要的辅助表四张就够。用户表、课程表、选课关系表这三张是必须的另外再加一张审核日志表用于记录管理员对课程的上架与下架操作能让系统完整度更高论文的 E-R 图和数据流图也好画。CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(64) NOT NULL, role TINYINT NOT NULL DEFAULT 3 COMMENT 1-管理员 2-教师 3-学生, real_name VARCHAR(50), created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_course ( id INT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id INT NOT NULL, credit DECIMAL(3,1) DEFAULT 2.0, capacity INT DEFAULT 30, selected_count INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT 0-待审核 1-已通过 2-已拒绝, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE t_selection ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, selected_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id) ); CREATE TABLE t_audit_log ( id INT PRIMARY KEY AUTO_INCREMENT, course_id INT NOT NULL, operator_id INT NOT NULL, action VARCHAR(20), comment VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP );注意t_selection表上的联合唯一键uk_student_course它保证了同一个学生不能重复选同一门课属于数据库层的最后一道防线。t_course表中的selected_count是一处特别设计的冗余字段维护它的目的是避免每次查询课程列表都执行COUNT(*)提升列表页的响应速度后文会重点说明如何保证这个字段不出错。3. 后端接口实现登录鉴权、课程管理与选课防超卖3.1 SSM 后端工程的包结构与统一返回结果后端工程使用 Maven 构建包结构按 controller、service、dao、entity、common 划分。common 包里通常放的是一个统一的返回结果类这个类虽然不起眼却决定了整个前后端联调的效率。避免每个接口返回的 JSON 格式参差不齐前端只要认准一个结构。// common/Result.java public class ResultT { private Integer code; // 200-成功 500-业务失败 401-未登录 private String message; private T data; public static T ResultT success(T data) { ResultT r new Result(); r.setCode(200); r.setMessage(成功); r.setData(data); return r; } public static T ResultT fail(String message) { ResultT r new Result(); r.setCode(500); r.setMessage(message); return r; } }后续所有 Controller 的返回值类型都使用Result前端 axios 拦截器统一判断code值即可。实际项目中还会有参数不合法、未登录等状态码可以依葫芦画瓢在枚举或常量类中补充。3.2 登录接口从 Session 到 Token 的思路演进传统 JSP 项目登录后依赖 Session 保存用户状态但前后端分离后基于 Cookie 的 Session 机制变得不那么好用跨域请求下 Cookie 的携带规则很麻烦。这里采用更常见的 Token 认证方案核心逻辑是用户登录成功后后端生成一个唯一的 Token前端保存下来并在后续请求的请求头中携带。SSM 中最轻量化的 Token 方案是完全不用第三方库直接在内存中维护一个 Map。Token 服务类如下所示用来生成和校验登录凭证Component public class TokenService { private MapString, Integer tokenMap new ConcurrentHashMap(); public String createToken(Integer userId) { String token UUID.randomUUID().toString().replace(-, ); tokenMap.put(token, userId); return token; } public Integer getUserId(String token) { if (token null || token.isEmpty()) { return null; } return tokenMap.get(token); } public void removeToken(String token) { tokenMap.remove(token); } }为了让 Token 鉴权能够在多个接口上复用常规做法是配合 Spring MVC 的拦截器。拦截器在请求进入 Controller 之前验证请求头里是否存在有效 Tokenpublic class LoginInterceptor implements HandlerInterceptor { private TokenService tokenService; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (!(handler instanceof HandlerMethod)) { return true; } HandlerMethod handlerMethod (HandlerMethod) handler; LoginRequired annotation handlerMethod.getMethodAnnotation(LoginRequired.class); if (annotation null) { return true; } String token request.getHeader(Authorization); Integer userId tokenService.getUserId(token); if (userId null) { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\未登录\}); return false; } request.setAttribute(currentUserId, userId); return true; } }LoginRequired是自定义的一个注解标记哪些接口需要登录才能访问。拦截器从请求头Authorization中取出 Token调用 TokenService 反查用户 ID如果查不到就返回 401由前端统一跳转到登录页。request.setAttribute将用户 ID 传入 Controller 方法业务代码就不必再重复解析 Token。3.3 课程列表分页查询PageHelper 与动态 SQL 的组合应用课程列表是学生和教师最常关注的界面它需要支持按课程名称模糊查询、按状态筛选、以及分页显示。SSM 中分页最常见的选择是 PageHelper使用方法是在查询前调用静态方法接下来的一条 Mapper 查询就会被自动拼接分页参数。// CourseController.java GetMapping(/course/list) public ResultPageInfoCourseVO list( RequestParam(defaultValue 1) Integer pageNum, RequestParam(defaultValue 10) Integer pageSize, RequestParam(required false) String keyword, RequestParam(required false) Integer status) { PageHelper.startPage(pageNum, pageSize); ListCourseVO courseList courseMapper.selectCourseList(keyword, status); PageInfoCourseVO pageInfo new PageInfo(courseList); return Result.success(pageInfo); }这里 PageInfo 对象中除了包含当页数据外还有 total、pages 等前端分页组件需要的字段。课程查询对应的 SQL 写在 Mapper XML 中核心部分用到了 MyBatis 的动态 SQL 来满足多条件组合查询select idselectCourseList resultTypecom.course.system.entity.CourseVO SELECT c.id, c.course_name, c.credit, c.capacity, c.selected_count, c.status, u.real_name AS teacher_name FROM t_course c LEFT JOIN t_user u ON c.teacher_id u.id where if testkeyword ! null and keyword ! AND c.course_name LIKE CONCAT(%, #{keyword}, %) /if if teststatus ! null AND c.status #{status} /if /where ORDER BY c.created_at DESC /select该 SQL 将教师用户名通过 LEFT JOIN 关联查询出来前端拿到课程列表后可以直接展示教师姓名不需要为每一行再发一次请求。where标签配合if是 MyBatis 中处理动态查询条件的标准写法能够自动去掉第一个条件前的 AND 关键字。3.4 选课接口的防超卖设计用一条原子 SQL 解决问题学生选课是整个系统的核心业务最容易出问题的点就是并发。当一门课程只剩最后一个名额两个学生同时点击选课时如果程序先 SELECT 当前人数、再判断是否小于容量、最后再 UPDATE那么两个请求可能同时读到同一个旧值导致超出课程容量上限。解决这个问题的方案是使用乐观锁思路把「判断并更新」合并为一条原子 SQL依靠数据库的行级锁保证并发安全。在 MyBatis 的 Mapper XML 中写出下面的语句update idincreaseSelectedCount UPDATE t_course SET selected_count selected_count 1 WHERE id #{courseId} AND status 1 AND selected_count lt; capacity /update这段 SQL 的执行逻辑是只有当课程状态是已审核通过并且当前选中人数小于容量时才会执行加一操作。数据库会对命中的行加锁第二个并发的请求在这个事务提交之前会一直等待。最后通过update方法返回的影响行数判断是否成功Transactional(rollbackFor Exception.class) public Result? selectCourse(Integer studentId, Integer courseId) { int affected courseMapper.increaseSelectedCount(courseId); if (affected 0) { return Result.fail(课程已满或未开放选课); } try { Selection record new Selection(); record.setStudentId(studentId); record.setCourseId(courseId); selectionMapper.insert(record); } catch (DuplicateKeyException e) { throw new RuntimeException(请勿重复选课); } return Result.success(null); }业务层使用Transactional让名额增加与选课记录插入处于同一事务中。如果插入选课记录时触发了唯一键冲突说明该学生已经选过这门课那么整个事务会回滚刚才增加的名额也会一并撤销。有同学会问如果学生选课后想退课对应的selected_count应该执行减 1 操作道理完全一致只要反过来写一条原子 UPDATE 即可。4. Vue 前端实现路由权限、页面开发与接口对接4.1 用路由守卫实现角色访问控制学生选课系统中存在三种角色学生能看到选课页面教师能创建课程管理员能审核课程。前端需要通过 Vue Router 的全局前置守卫在路由跳转前判断当前用户的登录状态和角色权限。// router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token); const userRole localStorage.getItem(role); if (to.path /login) { next(); return; } if (!token) { next(/login); return; } if (to.meta.roles !to.meta.roles.includes(userRole)) { next(/403); return; } next(); });路由配置中为需要权限的页面增加meta.roles字段例如/student/courses页面的meta.roles设为[3]。登录后根据后端返回的角色编号进行判断无权限时跳转到 403 页面。注意前端的权限校验只是用户体验层面的控制真正的安全边界永远在后端的拦截器里前端守卫并不能防止恶意请求。4.2 封装 axios 请求与响应拦截器为了让前端各页面调用接口时减少重复代码通常会在 utils 目录下封装一个 request.js。该模块创建 axios 实例配置基础路径和拦截器// utils/request.js import axios from axios import { Message } from element-ui import router from /router const service axios.create({ baseURL: /api, // dev-server 代理前缀 timeout: 10000 }) // 请求拦截器附加 token service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) // 响应拦截器统一处理业务状态码 service.interceptors.response.use( response { const res response.data if (res.code 200) { return res.data } if (res.code 401) { Message.error(登录已过期请重新登录) localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(未登录)) } Message.error(res.message) return Promise.reject(new Error(res.message)) }, error { Message.error(网络异常请检查后端服务) return Promise.reject(error) } ) export default service响应拦截器统一将res.data返回业务代码拿到的是数据本体而非整个响应对象。当后端返回 401 时自动清除本地登录信息并跳转登录页这是前后端分离项目中最标准的会话失效处理方式。4.3 学生选课页面的状态控制学生端课程列表用 Element UI 的 Table 组件渲染每行数据有一个选课按钮。处理选课操作时需要注意一个细节按钮的 loading 状态不能设置成一个全局布尔值否则一个请求未返回整个表格的所有按钮都会变成 loading。// views/student/CourseList.vue 核心逻辑 data() { return { courseList: [], pageNum: 1, pageSize: 10, total: 0, loadingCourseId: null // 当前正在请求的课程 ID } }, methods: { async handleSelect(row) { this.loadingCourseId row.id try { await selectCourse(row.id) this.$message.success(选课成功) row.selected_count 1 } catch (error) { // 错误提示已在拦截器中统一处理 } finally { this.loadingCourseId null } } }按钮的:loadingloadingCourseId row.id绑定可以让点击哪一行哪一行的按钮单独显示加载状态。选课成功后对 selected_count 做乐观加一更新页面反馈即时可见这个方案下用户不需要等待整张表重新渲染。如果选课失败由于事务回滚后端的名额不会增加前端的计数也不会发生变化数据依然保持一致。5. 项目联调与部署从本地验证到 Tomcat 上线的完整流程5.1 使用命令验证后端接口是否可用前后端开发完成后下一步是联调。联调前先确保后端接口能独立工作用 curl 直接请求后端接口验证排除前端代码干扰是比较高效的诊断手段# 测试登录接口 curl -X POST http://localhost:8080/user/login \ -H Content-Type: application/json \ -d {username:2023001,password:123456} # 测试课程列表接口 curl http://localhost:8080/course/list?pageNum1pageSize5如果返回结果不是预期的 JSON 格式可以从返回的状态码和错误信息判断是 SQL 写错、参数绑定失败还是请求路径不对。接口单独验证通过后再启动前端 dev server 联调页面。5.2 编译打包与 Tomcat 部署SSM 项目在本地验证结束后打包为 war 包部署到 Tomcat。打包前修改 pom.xml 中的 finalName方便部署后访问路径的确定。mvn clean package -DskipTests # 生成 target/course-system.war将 war 包复制到 Tomcat 的 webapps 目录重启 Tomcat 后访问http://localhost:8080/course-system/需要注意访问路径中会带有项目名。Tomcat 自动解压 war 包时执行startup.sh启动脚本可以在控制台看到部署日志查看有没有 Bean 创建失败或数据库连接异常。5.3 生产部署采用前后端分离架构课程设计答辩如果只演示本地开发模式通常已经足够。但想让项目在折叠的架构层面更有说服力生产环境的部署就应该是前后端彻底分离后端 war 包运行在 Tomcat前端执行构建命令生成静态文件交由 Nginx 托管cd course-web npm install npm run build # 构建产物位于 dist/ 目录Nginx 同时承担静态资源服务和 API 反向代理两种职责典型的 server 配置如下server { listen 80; server_name localhost; location / { root /usr/share/nginx/html/course-web/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/course-system/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }前端路由如果使用 history 模式刷新页面时会出现 404try_files $uri $uri/ /index.html这段配置的作用就是将页面请求全部回退到 index.html由 Vue Router 接管页面匹配。/api/前缀的请求则转发到后端的 Tomcat 端口注意 proxy_pass 末尾是否携带路径会直接影响转发的结果http://127.0.0.1:8080/course-system/带路径会将/api/course/list映射成/course-system/course/list如果没有规划好前缀会导致接口请求 404。5.4 高频踩坑排查单联调和部署阶段有几个出现频率极高的问题整理成一份速查表对照排查会比反复看日志更有效率现象 排查顺序 前后端联调时网络请求报 404 检查后端接口路径 / 检查 vue.config.js 代理路径是否和 axios baseURL 匹配 跨域请求被拦截CORS error 开发环境必须使用 proxy 代理生产环境必须用 Nginx 转发且不能同时乱配跨域注解 选课成功后列表数量没变化 查看 t_course.selected_count 是否更新 / 检查前端是否乐观更新还是重新加载 刷新前端页面报 404 确认是否配置 try_files 回退 index.html 后端打包后访问提示 404 确认 Tomcat 部署路径带了 course-system 上下文Nginx 的 proxy_pass 是否补齐了前缀6. 用十分钟给项目增加可视化选课数据看板6.1 为什么加数据看板是性价比最高的扩展点课程设计答辩时评审老师最喜欢问的问题是「你的系统有没有统计分析功能」。一个只有增删改查的选课系统很容易让人觉得停留在基础 CRUD 层面。增加一个数据可视化看板能让项目从操作型系统跃升为带决策支持能力的完整系统而且实现成本很低。看板不需要引入复杂的大数据组件用后端一个统计接口加前端一个图表库就够了。从实际开发角度选课系统最有统计价值的数据包括每门课程的选课人数、各学院的选课比例、每天选课操作的时间分布。助教可以使用这些数据快速了解选课压力管理员可以依据课程热度调整容量上限。围绕这组需求设计的接口比随便加一个「用户统计」要有说服力得多。6.2 后端增加选课统计接口在 SSM 后端中新增一个统计查询使用 SQL 的 GROUP BY 汇总选课人数select idselectCourseStats resultTypemap SELECT c.course_name AS name, COUNT(s.id) AS value FROM t_course c LEFT JOIN t_selection s ON c.id s.course_id WHERE c.status 1 GROUP BY c.id ORDER BY value DESC LIMIT 10 /select查询语句用在课程表与选课表之间做左连接统计每门课的被选次数按数量降序取前 10。这段 SQL 即使用了 GROUP BY还有 LEFT JOIN在课程数据量数千条的教学场景下性能是完全没有问题的。以路由/course/stats暴露该查询结果前端拿到数组后填充图表即可。6.3 前端集成 ECharts 展示课程热度对比可视化部分选择 ECharts在 npm 中安装依赖后导入即可使用npm install echarts --save创建views/admin/Dashboard.vue在组件挂载后请求接口并初始化图表import * as echarts from echarts export default { name: Dashboard, data() { return { chart: null, loading: false } }, mounted() { this.loadStats() }, methods: { async loadStats() { this.loading true try { const data await getCourseStats() this.initChart(data) } finally { this.loading false } }, initChart(data) { this.chart echarts.init(this.$refs.chartRef) this.chart.setOption({ title: { text: 课程选课人数 TOP10 }, tooltip: {}, xAxis: { type: category, data: data.map(item item.name) }, yAxis: { type: value }, series: [{ type: bar, data: data.map(item item.value), itemStyle: { color: #409EFF } }] }) } }, beforeDestroy() { if (this.chart) { this.chart.dispose() } } }图表初始化时需要使用$refs获取到 DOM 容器如果数据在mounted阶段还没回来图表不会渲染因此要在loadStats完成后才调用initChart。别忘了在组件销毁时调用chart.dispose()释放资源否则 Vue 组件切换后会产生内存泄漏控制台也会报错。将Dashboard.vue挂到 Admin 路由下数据看板模块就完成了。整个扩展点只涉及一个查询接口、一个页面组件耗时很短却为项目增加了分析维度也让答辩时「数据可视化」不再是空话。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进