拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot+Vue学生选课系统全栈开发实战

简介本资源是一套面向Java全栈初学者与课程设计学生的完整学生网上选课系统基于SpringBoot后端与Vue前端构建覆盖高校教务管理核心业务场景助力开发者掌握前后端分离架构、权限分级控制与MySQL数据建模等实战能力。压缩包共895个文件含121个Java后端逻辑类、126个Vue组件页面、322个SVG图标资源、50张界面截图JPG/PNG、36个JS交互脚本、27个XML配置及4个SQL数据库脚本整体34.05MB结构清晰、模块完备。已有165人学习下载资源附带项目运行指导视频、完整数据库表结构设计说明及三类角色管理员/教师/学生的全流程功能演示。开箱即用支持Eclipse或IDEA导入含build/run/install三类批处理脚本适配JDK1.8Tomcat7MySQL5.7环境显著降低部署门槛与调试成本。1. 为什么一个“学生网上选课系统”能成为 SpringBoot Vue 全栈开发的黄金练手项目你可能已经看过几十个“图书管理系统”“员工考勤系统”的 SpringBoot 教程但真正卡住新手、也最能暴露工程能力断层的恰恰是这个看似简单的“学生选课”。它不是 CRUD 的线性堆砌——学生要抢热门课课程有容量上限和先修要求教师要开课排课教务要审核冲突选课结果要实时反馈甚至还要支持退课重选的事务回滚。这些需求天然倒逼你去设计合理的数据库范式、实现分布式事务边界、处理前端路由守卫与权限联动、做接口幂等性校验。更关键的是它完整覆盖了 Java 后端SpringBoot MyBatis-Plus MySQL、Vue3 Pinia Element Plus 前端、前后端分离鉴权JWT、以及真实部署中必须面对的跨域、静态资源托管、数据库初始化等环节。如果你正在准备 Java 或全栈面试这个项目源码里埋着的不是 demo而是 SpringBoot 多环境配置怎么写、Vue 路由参数如何安全透传、MyBatis-Plus 的TableField(fill FieldFill.INSERT)怎么配合自动填充逻辑、以及 MySQL 中CHECK约束与应用层校验的职责边界——这些才是面试官翻你 GitHub 时真正会点开细看的细节。2. 数据库表结构设计从 ER 图到可执行 SQL避开 3 类典型范式陷阱选课系统的核心矛盾在于“多对多关系的业务语义落地”。学生和课程是典型的 N:N 关系但直接建一张student_course关联表远远不够。真实场景中选课记录本身携带状态待审核/已选中/已退选、时间戳选课时间、审核时间、成绩期末录入、甚至操作人教务员审核。如果把这些字段全塞进关联表会导致该表承担过多职责违反第三范式。我们采用分层建模策略基础实体表student,course,teacher保持高内聚核心业务表course_selection只存强业务主键和状态字段扩展信息如成绩、审核意见下沉到独立的course_selection_record表。这种设计让course_selection成为轻量级“事实表”支撑高频查询与并发更新。2.1 核心表结构与字段语义说明以下 SQL 是基于 MySQL 8.0 编写已通过mysql -u root -p schema.sql验证可执行。注意course_selection表中的selection_status使用ENUM而非TINYINT既保证数据一致性又避免魔法数字硬编码-- 学生表学号为主键姓名非空邮箱唯一且带格式校验 CREATE TABLE student ( student_id VARCHAR(12) PRIMARY KEY COMMENT 学号如20230001, name VARCHAR(50) NOT NULL COMMENT 学生姓名, email VARCHAR(100) UNIQUE NOT NULL COMMENT 邮箱用于登录和通知, major VARCHAR(50) COMMENT 专业, grade INT COMMENT 年级如2023, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生基本信息表; -- 课程表课程代码唯一学分、学时为数值型状态字段控制是否开放选课 CREATE TABLE course ( course_id VARCHAR(10) PRIMARY KEY COMMENT 课程代码如CS101, course_name VARCHAR(100) NOT NULL COMMENT 课程名称, credit DECIMAL(2,1) NOT NULL DEFAULT 2.0 COMMENT 学分, hours INT NOT NULL DEFAULT 48 COMMENT 总学时, capacity INT NOT NULL DEFAULT 60 COMMENT 最大容量, current_enrolled INT NOT NULL DEFAULT 0 COMMENT 当前已选人数冗余字段用于快速判断, status ENUM(OPEN, CLOSED, ARCHIVED) NOT NULL DEFAULT OPEN COMMENT 课程状态, teacher_id VARCHAR(12) NOT NULL COMMENT 授课教师ID外键关联teacher表, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_teacher (teacher_id), INDEX idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程基本信息表; -- 选课记录表复合主键(student_id, course_id)确保一人一课唯一 -- status字段使用ENUM而非INT避免业务层出现非法状态值 CREATE TABLE course_selection ( student_id VARCHAR(12) NOT NULL COMMENT 学生ID, course_id VARCHAR(10) NOT NULL COMMENT 课程ID, selection_status ENUM(PENDING, CONFIRMED, REJECTED, WITHDRAWN) NOT NULL DEFAULT PENDING COMMENT 选课状态, selected_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 选课时间, confirmed_at DATETIME NULL COMMENT 确认时间教务审核后, rejected_reason VARCHAR(200) NULL COMMENT 拒绝原因仅当statusREJECTED时有效, PRIMARY KEY (student_id, course_id), FOREIGN KEY (student_id) REFERENCES student(student_id) ON DELETE CASCADE, FOREIGN KEY (course_id) REFERENCES course(course_id) ON DELETE CASCADE, INDEX idx_status_time (selection_status, selected_at), INDEX idx_student (student_id, selection_status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT学生选课主记录表;提示course_selection表未设置ON UPDATE CASCADE因为状态变更如 PENDING → CONFIRMED是业务逻辑驱动不应由外键约束自动触发。若需更新关联课程的current_enrolled字段必须在 Service 层显式调用updateCourseEnrolledCount()方法并用Transactional保证原子性。2.2 容量校验与并发安全为什么不能只靠数据库 CHECK 约束初学者常误以为在course表加CHECK (current_enrolled capacity)就能防止超选。这是严重误区MySQL 8.0 的CHECK约束在 INSERT/UPDATE 时仅校验单行无法感知其他并发事务对current_enrolled的修改。当两个学生同时点击“选课”数据库可能读到相同的current_enrolled59各自1后都写入60实际变成61人。正确解法是在应用层加数据库行锁 乐观锁版本号。我们在course_selection插入前先执行UPDATE course SET current_enrolled current_enrolled 1 WHERE course_id ? AND current_enrolled capacity;该语句返回影响行数。若为0说明容量已满抛出CourseFullException若为1则继续插入选课记录。此方案无需引入 Redis 或分布式锁纯数据库即可保障强一致性。3. SpringBoot 后端实现从 Controller 到 Service拆解选课核心链路的 4 层校验选课不是一个简单INSERT INTO course_selection的操作而是一条横跨 HTTP 协议、权限控制、业务规则、数据持久化的完整链路。SpringBoot 的分层架构恰好为此提供清晰切面。我们以POST /api/v1/selections接口为例逐层解析其设计逻辑。3.1 Controller 层统一异常处理与请求体校验Controller 不做业务判断只负责接收、转换、转发。关键点在于使用Valid触发 JSR-303 校验并将全局异常捕获为标准 JSON 响应RestController RequestMapping(/api/v1) public class SelectionController { PostMapping(/selections) public ResponseEntityApiResponseString selectCourse( RequestBody Valid SelectionRequest request, AuthenticationPrincipal JwtUserDetails userDetails) { // 从JWT中提取student_id避免前端伪造 String studentId userDetails.getUsername(); selectionService.selectCourse(studentId, request.getCourseId()); return ResponseEntity.ok(ApiResponse.success(选课申请已提交)); } }SelectionRequest类中定义了严格的字段约束public class SelectionRequest { NotBlank(message 课程ID不能为空) Pattern(regexp ^[A-Z]{2,3}\\d{3}$, message 课程ID格式错误如CS101) private String courseId; // getter/setter... }注意AuthenticationPrincipal JwtUserDetails是 Spring Security 提供的安全上下文注入确保studentId来自可信的 JWT 解析而非前端传入的任意字符串。这是防止越权选课的第一道防线。3.2 Service 层事务边界与领域逻辑聚合Service 是业务核心必须包裹在Transactional中。此处我们不使用默认的REQUIRED传播行为而是显式指定rollbackFor Exception.class确保任何异常都回滚Service Transactional(rollbackFor Exception.class) public class SelectionServiceImpl implements SelectionService { Override public void selectCourse(String studentId, String courseId) { // 1. 检查学生是否存在查student表 Student student studentMapper.selectById(studentId); if (student null) { throw new BusinessException(学生不存在 studentId); } // 2. 检查课程是否存在且状态为OPEN查course表 Course course courseMapper.selectById(courseId); if (course null || !OPEN.equals(course.getStatus())) { throw new BusinessException(课程不可选 courseId); } // 3. 检查学生是否已选该课查course_selection表状态非WITHDRAWN QueryWrapperCourseSelection wrapper new QueryWrapper(); wrapper.eq(student_id, studentId) .eq(course_id, courseId) .ne(selection_status, WITHDRAWN); if (selectionMapper.selectOne(wrapper) ! null) { throw new BusinessException(您已选过该课程); } // 4. 执行容量校验与选课插入见2.2节SQL int updated courseMapper.updateEnrolledCount(courseId, course.getCapacity()); if (updated 0) { throw new BusinessException(课程已满请选择其他课程); } // 5. 插入选课记录 CourseSelection selection new CourseSelection(); selection.setStudentId(studentId); selection.setCourseId(courseId); selection.setSelectionStatus(PENDING); // 默认待审核 selectionMapper.insert(selection); } }3.3 Mapper 层MyBatis-Plus 的高级用法实战courseMapper.updateEnrolledCount()并非 MyBatis-Plus 内置方法而是我们自定义的 XML SQL。在CourseMapper.xml中编写!-- CourseMapper.xml -- update idupdateEnrolledCount parameterTypemap UPDATE course SET current_enrolled current_enrolled 1 WHERE course_id #{courseId} AND current_enrolled lt; #{capacity} /update此 SQL 利用 MySQL 的lt;实体编码规避 XML 解析错误且AND current_enrolled #{capacity}是关键——它将容量检查与更新合并为原子操作彻底规避竞态条件。4. Vue3 前端实现Pinia 状态管理与路由守卫如何协同控制选课流程Vue 前端不是后端 API 的简单调用者而是选课体验的最终塑造者。学生点击“选课”按钮后界面需即时反馈禁用按钮、显示加载图标、成功后跳转至我的课表、失败时弹出结构化错误如“课程已满”需引导查看其他课。这一切依赖 Pinia 管理全局状态以及 Vue Router 守卫拦截非法访问。4.1 Pinia Store封装选课状态与错误消息我们创建selectionStore.ts集中管理选课相关状态。关键设计是将loading和error状态与具体课程 ID 绑定避免多个课程卡片同时操作时状态污染// stores/selectionStore.ts import { defineStore } from pinia export const useSelectionStore defineStore(selection, { state: () ({ // 按课程ID索引的loading状态如 { CS101: true } loadingMap: {} as Recordstring, boolean, // 按课程ID索引的错误消息如 { CS101: 课程已满 } errorMap: {} as Recordstring, string, }), actions: { setLoading(courseId: string, loading: boolean) { this.loadingMap[courseId] loading }, setError(courseId: string, message: string) { this.errorMap[courseId] message // 3秒后自动清除错误避免用户手动关闭 setTimeout(() { delete this.errorMap[courseId] }, 3000) }, clearError(courseId: string) { delete this.errorMap[courseId] } } })4.2 课程列表组件响应式按钮与错误提示在CourseList.vue中我们使用组合式 API 消费 store并绑定按钮状态template div v-forcourse in courses :keycourse.courseId classcourse-card h3{{ course.courseName }}/h3 p学分{{ course.credit }} | 容量{{ course.capacity }}/{{ course.currentEnrolled }}/p !-- 按钮状态由store控制 -- button clickhandleSelect(course.courseId) :disabledselectionStore.loadingMap[course.courseId] classselect-btn {{ selectionStore.loadingMap[course.courseId] ? 提交中... : 选课 }} /button !-- 错误消息气泡绝对定位在按钮下方 -- div v-ifselectionStore.errorMap[course.courseId] classerror-tip {{ selectionStore.errorMap[course.courseId] }} /div /div /template script setup langts import { useSelectionStore } from /stores/selectionStore import { apiSelectCourse } from /api/selection const selectionStore useSelectionStore() const handleSelect async (courseId: string) { selectionStore.setLoading(courseId, true) selectionStore.clearError(courseId) try { await apiSelectCourse(courseId) // 成功后可触发全局通知或跳转 ElMessage.success(选课申请已提交请等待审核) } catch (error: any) { selectionStore.setError(courseId, error.response?.data?.message || 选课失败) } finally { selectionStore.setLoading(courseId, false) } } /script4.3 路由守卫保护“我的课表”页面不被未登录用户访问my-courses.vue页面需校验用户登录态及角色。我们在router/index.ts中添加全局前置守卫// router/index.ts router.beforeEach(async (to, from, next) { // 只对/my-courses路径启用守卫 if (to.path /my-courses) { const token localStorage.getItem(token) if (!token) { ElMessage.error(请先登录) next(/login) return } try { // 调用API验证token有效性并获取用户角色 const user await apiGetUserInfo() if (user.role ! STUDENT) { ElMessage.error(无权访问学生课表) next(/) return } next() // 放行 } catch (error) { ElMessage.error(登录信息失效请重新登录) localStorage.removeItem(token) next(/login) } } else { next() } })5. 项目运行与调试从本地启动到生产部署的 5 个关键验证点拿到源码后90% 的失败源于环境配置疏漏。我们按执行顺序列出必须验证的 5 个节点每个都对应一个可执行命令或检查项。跳过任一环节都可能导致“源码跑不起来”的挫败感。5.1 数据库连接验证用一条 SQL 命令确认连通性不要依赖 IDEA 的 Database 工具图标是否绿色。直接在终端执行# 进入MySQL客户端 mysql -h 127.0.0.1 -P 3306 -u root -p # 在MySQL内执行替换your_database_name为实际库名 USE your_database_name; SHOW TABLES LIKE student; -- 若返回结果包含student说明库存在且可访问 -- 若报错ERROR 1049则需先创建数据库CREATE DATABASE your_database_name CHARACTER SET utf8mb4;5.2 SpringBoot 配置文件检查application.yml 的 3 个致命参数src/main/resources/application.yml中以下三项配置错误率最高参数正确示例常见错误后果spring.datasource.urljdbc:mysql://127.0.0.1:3306/edu_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue忘记?useSSLfalse或serverTimezone启动报The server time zone value XXX is unrecognizedspring.redis.host127.0.0.1写成localhostDocker 环境下无法解析导致缓存失效mybatis-plus.global-config.db-config.id-typeauto误设为none新增记录时主键为空插入失败5.3 Vue 依赖安装与代理配置Vue 项目根目录下执行# 清理node_modules并重装比npm install更可靠 rm -rf node_modules package-lock.json npm cache clean --force npm install # 启动开发服务器 npm run dev此时打开浏览器访问http://localhost:3000若看到空白页且控制台报Failed to fetch检查vue.config.js中的代理配置// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, // 必须与SpringBoot端口一致 changeOrigin: true, pathRewrite: { ^/api: /api // 前端/api请求代理到后端/api } } } } }5.4 启动顺序与端口占用排查严格按以下顺序启动避免端口冲突先启动 MySQL确认3306端口未被占用lsof -i :3306on Mac/Linux,netstat -ano | findstr :3306on Windows再启动 SpringBootmvn spring-boot:run观察控制台是否输出Tomcat started on port(s): 8080最后启动 Vuenpm run dev确认App running at: http://localhost:3000若 SpringBoot 启动失败并提示Port 8080 is already in use用kill -9 PID结束占用进程。5.5 生产环境构建前后端分离部署的 Nginx 配置要点将 Vue 打包后的静态文件部署到 Nginx关键在于解决 History 模式下的 404 问题。nginx.conf中location /块必须添加try_filesserver { listen 80; server_name localhost; location / { # 指向Vue dist目录 root /var/www/edu-frontend; index index.html; # 关键所有前端路由都 fallback 到 index.html try_files $uri $uri/ /index.html; } # 后端API代理避免跨域 location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }提示proxy_pass末尾的/不能省略。若写成proxy_pass http://127.0.0.1:8080;无斜杠则location /api/匹配的请求会被代理到http://127.0.0.1:8080/api/xxx而后端 SpringBoot 的RequestMapping(/api)会将其解析为/api/api/xxx导致 404。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门