ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot+MyBatis Plus课外培训管理系统设计与实现

2026/9/28 17:44:52 拓冰建站 浏览量
Spring Boot+MyBatis Plus课外培训管理系统设计与实现 1. 项目概述与核心思路1.1 为什么做课外培训管理系统课外培训机构这几年其实挺难做的除了要抓教学质量还得应付排课冲突、课时统计、家长催问进度这些琐碎事。我见过不少机构还在用Excel表手工排课一个老师临时调课后面一连串班级时间全乱套。手工记录课时消耗更是个坑月底和家长对账几张表数据对不上扯皮扯得心累。所以当朋友找我说要搞一套课外培训管理系统的时候我第一时间想到的是这系统的核心不是管理而是把机构日常运转的那些重复性、易出错的环节自动化。具体解决三个问题排课冲突多位老师、多个教室、多个班次之间人工排课很难一眼看出时间重叠。课时计费不透明剩余课时、已消耗课时、请假补课这些数据家长和机构之间容易有分歧。信息不同步学生报名信息、教师课表、教室占用各记各的没有一个统一视图。这套SpringBoot课外培训管理系统就是围绕上面三个痛点来设计的。采用Spring Boot 2.x MyBatis Plus作为后端主体前端用Vue 2.x Element UI数据库使用MySQL 5.7以上版本。对于Java课程设计、毕业设计或者中小型培训机构的真实落地场景这套架构都比较合适——没有过度设计每个模块都能说清楚为什么这么设计。1.2 技术选型背后的考量先说说为什么选Spring Boot而不是传统的SSH或者Spring MVC。Spring Boot最直观的价值是约定大于配置内置Tomcat减少了大量XML配置。对于培训管理系统这种中小型项目来说快速迭代比什么都重要。Spring Boot的自动配置机制配合YAML文件一个application.yml就能把数据源、MyBatis、Redis这些核心组件全部串起来。MyBatis Plus是我个人比较偏好的ORM框架。对比JPA/HibernateMyBatis Plus在SQL控制力上更强尤其适合培训管理这类有大量报表统计的场景。比如统计每个月的营收报表JPA写复杂查询很别扭而MyBatis Plus的分页插件和条件构造器能让我直接用Lambda表达式拼查询条件无需手写繁琐的XML映射。这点在后面的班级列表筛选、课时消耗统计中非常实用。数据库设计上MySQL是毫无疑问的选择。培训管理系统的数据体量不大单表几万条记录就算多了MySQL完全能撑住。事务隔离级别使用默认的REPEATABLE READ即可因为涉及报名缴费、扣减课时等操作需要保证数据一致性。前端选择Vue Element UI主要是看中Element UI的表格和表单组件对于后台管理系统来说开发者可以快速搭出规范的数据展示界面。前后端通过RESTful API交互使用JSON格式传输数据JWT做无状态认证免除Session共享的烦恼。这一点在后文中详细展开。这套选型组合本质上是在开发效率和可控性之间做了平衡。对于一个需要快速交付、后续持续迭代的项目来说这是个稳妥的选择。如果你之前用的是SSH那套老技术栈切换到Spring Boot会明显感受到开发体验的落差。2. 系统架构与核心模块设计2.1 整体架构分层系统的架构遵循经典的三层模式Controller接口层、Service业务层、Mapper数据访问层每一层职责分明。Controller只负责参数接收和响应封装不写任何业务逻辑Service处理核心业务规则Mapper通过MyBatis Plus操作数据库。此外增加一个DTO层用于参数传递避免Entity直接暴露给前端。比如接收报名请求时前端传过来的JSON并不等于数据库里的StudentCourse实体用CreateOrderDTO来承接配合注解校验参数格式。这一点看似多写了几个类但在后期接口演变时价值很大——实体结构调整不会影响外部接口契约。系统的模块划分如下用户模块登录、注册、权限控制涉及管理员、教师、学生、家长四种角色。课程模块课程创建、上下架、课程分类绑定授课教师。班级模块班级创建、排课、学员分配。报名模块学生选课报名、缴费记录、退课操作。课时模块课前签到、课时消耗记录、剩余课时统计。通知模块课程调整通知、课时不足提醒可扩展。数据统计模块机构整体课时消耗、收入概览、热门课程排行。2.2 角色权限体系分析培训管理系统里权限设计是重头戏。不同角色看到的界面不同可操作的数据范围也完全不同。我采用的是基于角色的访问控制模型RBAC用5张表支撑用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。权限的核心规则管理员拥有全部权限能看所有模块能管理教师和学生数据。教师只能查看和操作自己负责的课程、班级、课表录入学生成绩和每节课反馈但看不到机构收入和报名缴费数据。学生能看到已报名的课程、课表、剩余课时、成绩。家长主要查看孩子的课表、课时消耗记录和成绩。家长和学生账号在数据库中通过student_id字段关联。JWT令牌设计时我会在token中写入userId和roleId前端路由根据角色动态生成菜单。后端在Controller层使用注解PreAuthorize校验权限配合Spring Security的过滤链实现接口级拦截。这样就算用户手动调用接口也能挡住越权访问。这里补充一个设计细节课时的扣减必须放在事务里执行且要考虑并发问题。举个例子同一节课两个学生同时签到如果签到逻辑里各自执行剩余课时减一MySQL的行锁机制会解决大部分冲突但如果在扣减前还有判断是否还有剩余课时的步骤就必须在事务中加锁查询否则可能出现超扣情况。严谨的做法是使用SELECT ... FOR UPDATE对该学生的课时记录行加锁然后判断余额再执行扣减并提交事务。2.3 模块间协作流程以一次完整的报名流程为例说明模块之间的协作关系学生在前端选择课程发起报名。后端接收报名请求创建报名记录状态为待支付。支付成功后测试环境可以用模拟支付系统为学生在所选课程的班级中加入记录。同时生成课时账户记录初始课时数等于报名时购买的课时数量。开课提醒推送至家长和学生的通知列表。这个流程如果不用事务控制很容易出现支付了但没分班或者报上名但没建课时账户的数据不一致问题。所以我在报名接口上加了Transactional(rollbackFor Exception.class)并且在整个流程中所有的数据操作都在同一个事务内。这一点也是后来排查线上问题的重点方向放在后面的问题章节单独说。3. 数据库设计与核心实体解析3.1 表结构规划数据库是整个系统的基础表结构设计是否合理直接影响后续开发的复杂程度。系统核心表如下表名说明关键字段sys_user用户表id, username, password, real_name, role_id, phonesys_role角色表id, role_name, role_keycourse课程表id, course_name, category_id, price, total_hours, statuscourse_category课程分类表id, category_name, parent_idclass_info班级表id, class_name, course_id, teacher_id, start_date, end_datecourse_schedule排课表id, class_id, weekday, start_time, end_time, classroom_idstudent_class学生班级关联表id, student_id, class_id, join_datecourse_registration报名记录表id, student_id, course_id, class_id, pay_amount, statusstudy_hours课时账户表id, student_id, course_id, total_hours, used_hours, remain_hoursattendance_record签到记录表id, student_id, schedule_id, attendance_date, statusclassroom教室表id, classroom_name, capacity, status上面这些表之间的关系比较直观课程和班级是一对多班级和学生是多对多通过student_class中间表排课记录归属于班级课时账户是学生在某个课程下的独立数据。设计时我在每个业务表都加了create_time和update_time字段统一用MyBatis Plus的自动填充功能维护避免了每次写代码时都要手动set当前时间。这个习惯在处理涉及审计需求的数据时非常有用——回查这条记录是什么时候创建的很容易。3.2 课时账户和课程排期的逻辑课时账户是整个系统里逻辑最绕的部分先说设计思路。一个学生报名一门课程后系统会为该学生在study_hours表插入一条记录total_hours等于报名时设置的课时数used_hours初始为0。每次签到上课根据实际课时数比如一节课1.5小时增加used_hours值。剩余课时就是total_hours - used_hours的差。这里有一个需要用存储过程或服务层处理的问题——退课时的课时退回。假设学生上了3节课共4.5小时突然要求退课课时按剩余未使用比例退款还是全额退款需要机构自定义规则。系统默认逻辑是计算剩余可退课时对应的金额通过一个统一的退款接口处理。数据库里通过事务保证更新课时账户 更新报名记录状态 生成一条退费记录三个操作要么全部成功要么全部回滚。课程排期这块我采用的是基于weekday的周排课模式而不是具体日期排课。course_schedule表中记录每周固定在星期几上课、几点到几点。这种模式的好处是排课清晰、易于循环坏处是遇到节假日调课会比较麻烦。我的方案是增加一个holiday表来记录停课日期段在查询课表时排除停课时间并自动生成补课安排。虽然现在的版本里补课功能还没完全做进去但表结构上已经留了扩展位。3.3 数据库索引优化索引设计上踩过不少坑。初版的时候查询课时报表非常慢排查发现是因为student_class表没有加复合索引。后来加上(student_id, class_id)的复合索引查询时间从1.2秒直接降到不到100毫秒。比较实用的索引建议如下course_registration表的student_id字段加普通索引因为按学生查报名记录是最频繁的查询。attendance_record表的schedule_id加索引配合attendance_date字段可以快速统计某节课的出勤情况。course表的category_id加索引用于课程分类筛选。sys_user表的username字段必须加唯一索引防止重复用户名。索引不是越多越好。小表几千条记录以内加索引其实意义不大反而会增加写入开销。培训管理系统里sys_role、course_category这类字典性质的表根本不需要加索引全表扫描就好。4. 后端核心实现与开发实战4.1 SpringBoot项目搭建与基础配置从零搭建一个SpringBoot项目我一般用Spring Initializr生成基础骨架然后在pom.xml里引入Web、MyBatis Plus、MySQL驱动、Lombok、JWT、Spring Security这些依赖。骨架版本选择2.7.x尽量避开太新的版本天然兼容性更好减少因版本过高带来的各种奇怪问题。application.yml的核心配置server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/training_system?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl map-underscore-to-camel-case: true global-config: db-config: id-type: auto logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: your-secret-key-change-in-production expire: 604800需要重点说明map-underscore-to-camel-case这个配置。数据库字段一般是snake_case如real_nameJava属性用驼峰realName开启这个配置后MyBatis Plus就能自动完成映射省去大量TableField注解。另外逻辑删除配置用的是deleted字段这样删除课程记录时执行的是UPDATE而非DELETE避免误删造成数据不可恢复。4.2 认证授权实现基于JWT认证模块的流程如下用户提交用户名密码后端校验通过后生成JWT返回给前端前端存储到localStorage后续请求在HTTP Header里带上Authorization: Bearer token。后端通过Spring Security的过滤器链解析token获取用户信息和角色判断接口访问权限。关键代码实现一个JWT工具类Component public class JwtUtils { Value(${jwt.secret}) private String secret; Value(${jwt.expire}) private Long expire; public String generateToken(Long userId, String username, Integer roleId) { return Jwts.builder() .setSubject(username) .claim(userId, userId) .claim(roleId, roleId) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() expire * 1000)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }相对于传统的Session方案JWT最大好处是无需在服务端存储会话状态特别适合前后端分离部署。但使用JWT也要注意一点token一旦签发在有效期内无法主动让它失效。解决方案是配合Redis做一个token黑名单用户退出登录时把token加入黑名单并设置剩余有效期的过期时间。我在这个项目里为了避免引入过多中间件暂时没做黑名单只把过期时间设在7天具体取舍看项目要求。4.3 核心模块代码实现——以课时扣减为例课时扣减接口是整个系统里最关键的一个直接涉及资金和课时数据。正常情况下学生在签到时扣减。签到接口的核心逻辑Service public class AttendanceServiceImpl implements AttendanceService { Autowired private AttendanceRecordMapper attendanceRecordMapper; Autowired private StudyHoursMapper studyHoursMapper; Autowired private CourseScheduleMapper scheduleMapper; Override Transactional(rollbackFor Exception.class) public Result signIn(SignInDTO dto) { CourseSchedule schedule scheduleMapper.selectById(dto.getScheduleId()); if (schedule null) { return Result.error(排课记录不存在); } // 防重复签到 Integer count attendanceRecordMapper.selectCount( new LambdaQueryWrapperAttendanceRecord() .eq(AttendanceRecord::getStudentId, dto.getStudentId()) .eq(AttendanceRecord::getScheduleId, dto.getScheduleId()) ); if (count 0) { return Result.error(该学生已签到); } // 查询课时账户并锁定行 StudyHours hours studyHoursMapper.selectOne( new LambdaQueryWrapperStudyHours() .eq(StudyHours::getStudentId, dto.getStudentId()) .eq(StudyHours::getCourseId, schedule.getCourseId()) .last(FOR UPDATE) ); if (hours null || hours.getRemainHours() 0) { return Result.error(剩余课时不足); } // 扣减课时 hours.setUsedHours(hours.getUsedHours() schedule.getCourseHours()); hours.setRemainHours(hours.getRemainHours() - schedule.getCourseHours()); studyHoursMapper.updateById(hours); // 保存签到记录 AttendanceRecord record new AttendanceRecord(); record.setStudentId(dto.getStudentId()); record.setScheduleId(dto.getScheduleId()); record.setAttendanceDate(new Date()); record.setStatus(1); attendanceRecordMapper.insert(record); return Result.success(签到成功); } }重点说一下FOR UPDATE这个细节。查询剩余课时的时候如果不去显式锁行在并发场景下可能出现两个请求同时读到剩余课时为1然后各自扣减最终剩余课时变成-1。加锁之后第二个请求要等第一个请求的事务提交后才能读取到最新数据。这是经验之谈希望大家在写类似的先查后改逻辑时记得加锁。4.4 课表查询与排课冲突检测排课冲突是培训系统里最让管理员头疼的问题。同一间教室、同一个时间段不能被两个班级占用同一个老师也不能在同一时间出现在不同教室。冲突检测的实现思路是新增排课记录时查询相同教室和相同时间段的重叠记录。public boolean checkConflict(CourseSchedule schedule) { LambdaQueryWrapperCourseSchedule wrapper new LambdaQueryWrapper(); wrapper.eq(CourseSchedule::getClassroomId, schedule.getClassroomId()) .eq(CourseSchedule::getWeekday, schedule.getWeekday()) .and(w - w .lt(CourseSchedule::getStartTime, schedule.getEndTime()) .gt(CourseSchedule::getEndTime, schedule.getStartTime()) ); Integer count scheduleMapper.selectCount(wrapper); return count 0; }时间段冲突的判定逻辑其实就是判断两个区间是否有交集只要后一条记录的开始时间在前一条记录的结束时间之前且后一条记录的结束时间在前一条记录的开始时间之后就说明存在重叠。这个方案能快速检测出教室冲突。教师冲突也是同样的逻辑把查询条件换成就行。5. 前端实现与接口协同5.1 前端工程结构和环境准备前端使用Vue CLI脚手架创建配合Vue Router和Vuex。Element UI按需引入避免打包体积过大。项目结构src/ api/ # 接口请求封装 assets/ # 静态资源 components/ # 通用组件 router/ # 路由配置 store/ # 状态管理 views/ # 页面视图 admin/ # 管理员页面 teacher/ # 教师页面 student/ # 学生页面 login.vue # 登录页接口请求封装使用axios实例统一添加baseURL和token拦截器import axios from axios const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config })响应拦截器里统一处理业务错误码和HTTP状态码401时跳转到登录页。这里有个小坑Element UI的Message组件在拦截器中使用容易因上下文问题导致引用失败需要格外注意引入方式。5.2 核心页面交互设计管理后台的排课页面是最复杂的。前端需要实现一个周视图把一周的课程按时间线展示出来。我使用的方案是把周一至周日分为7列每列按时间顺序渲染课程卡片卡片高度根据课程时长计算。点击某个空位弹出新增排课对话框点击已有课程卡片可修改或删除。周视图的数据结构[ { weekday: 1, startTime: 09:00, endTime: 10:30, courseName: 少儿编程启蒙, className: 周六上午A班, teacherName: 张老师, classroom: A201 } ]教师端主要看当天课表实现就简单一些。根据登录用户的teacherId请求后端接口返回当天排课列表卡片样式区分未开始进行中已结束三种状态。课表上直接提供签到入口点击进入该节课的学生名单逐个标记出勤。5.3 前后端接口对接的常见问题接口对接阶段最容易出问题的就是日期格式。后端返回的LocalDateTime默认序列化成2024-05-20T10:30:00这种带T的格式前端处理麻烦。统一方案是后端配置Jackson的日期格式为yyyy-MM-dd HH:mm:ss并且在接收前端日期字符串时加上DateTimeFormat注解。搞定这个能省掉大量前端格式转换代码。另外一个问题是跨域。前后端分离部署在不同端口时后端需要配置CORS。我在项目中写了一个WebMvcConfigurer的实现类允许指定域名跨域并配置了暴露的请求头。6. 常见问题与排查技巧实录6.1 数据库连接与时区问题刚启动项目时如果控制台报错提示时区无法识别几乎都是连接串里没有加serverTimezone参数。MySQL 8.0之后的驱动对时区要求比较严格连接串中务必带上serverTimezoneAsia/Shanghai。还有一种情况是MySQL驱动版本和MySQL服务版本不匹配驱动太旧连不上MySQL 8.0直接把mysql-connector-java升级到8.0以上的版本就能解决。6.2 MyBatis Plus分页查询失效我在项目中使用MyBatis Plus的分页插件时第一版忘记配置PaginationInnerInterceptor导致分页查询返回全部数据。如果你在开发中也遇到这种情况检查一下是否有配置MybatisPlusInterceptor。还要注意分页插件和逻辑删除配合的坑——逻辑删除字段放在实体类中底层SQL会自动追加deleted0条件。如果配置不正确会出现查询到已被逻辑删除的记录。配置分页插件的正确方式Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }6.3 事务不生效的原因用Spring的Transactional注解时有个经典坑同一个类内部方法调用事务不生效。比如AttendanceServiceImpl的signIn方法里直接调用同类中的checkSchedule方法该方法的Transactional会被忽略因为事务是基于Spring AOP代理类实现的内部调用绕过了代理对象。解决办法有两种一是把需要独立事务的方法拆分到另一个Service中注入调用二是使用AopContext.currentProxy()获取代理对象再调用。另外注意Transactional默认只对RuntimeException和Error回滚如果业务代码抛的是受检异常需要显式配置rollbackFor Exception.class。我在所有事务方法上都加了rollbackFor参数防止这种问题。6.4 前端路由刷新404问题部署到Nginx后访问某个具体路由比如/training/admin/classes再刷新页面出现404。原因很简单前端使用的是history路由模式Nginx没有配置try_files找不到对应的真实文件就报了404。解决方案是在Nginx配置中添加location / { try_files $uri $uri/ /index.html; }如果用的是hash模式则没有这个问题。但在SEO要求不高的后台管理系统中为了URL美观我一般选history模式然后附加上述Nginx配置。6.5 并发环境下的课时超卖线上测试阶段某培训机构使用该系统做了一天公开课的签到结果发现部分学生课时扣成了负数。排查发现签发签到的请求被重复提交——前端操作者连点两次签到按钮后端同时收到两个请求。防重复提交先从后端控制在签到接口入口处加一个分布式锁用Redis的setnx同一个学生的签到请求只能有一个在执行。考虑到项目没引入Redis我用的是数据库唯一约束方案在attendance_record表上为(student_id, schedule_id)加上唯一性约束重复插入直接报错。这个方法实现简单效果也可靠。7. 系统部署与后续扩展建议7.1 本地打包与生产部署步骤后端打包直接用Maven命令即可执行mvn clean package -DskipTests在target目录下生成training-system.jar。生产环境用外置的application-prod.yml把数据库地址、用户名密码替换成生产环境参数。启动时指定配置文件java -jar training-system.jar --spring.profiles.activeprod --server.port8080前端构建先修改.env.production文件中的VUE_APP_BASE_API指向后端服务器的API域名然后执行npm run build生成的dist目录拷贝到Nginx配置的web目录即可。服务器环境准备安装JDK 1.8及以上MySQL用于数据库Nginx用于前端静态资源服务和反向代理。生产环境建议加上守护进程用systemd配置一个service文件这样如果应用崩溃可以自动重启并且开机自动启动。7.2 项目后续可以怎么扩展这套系统目前的覆盖面是课消和排课后面可以按机构需求扩展的方向其实挺多增加订单和退款流水模块对接真实支付微信支付或支付宝让缴费和退费实现自动流转。这个是目前最刚需的扩展方向。引入短信通知服务上课前一天自动给家长发送提醒短信降低学生缺勤率。可以用阿里云短信集成成本不高。增加角色销售支持渠道线索记录和跟进状态跟踪方便机构做招生管理。把报表能力增强按课程、按教师、按时间段统计课时消耗生成可视化图表减轻管理者的统计负担。考虑多校区支持在机构表和班级表中增加campus_id字段把运营维度扩展到校区层级。7.3 关于源码的一些实话网上很多帖子说附源码之类的源码质量参差不齐。判断一个源码能不能直接用的重要标准是数据库脚本是否完整初始化数据是否合理关键流程比如报名、签到、扣课时是否有事务控制权限模型是否实现了RBAC基础能力。很多源码只注重页面效果底层的业务逻辑其实没有闭环。拿到源码后建议先跑一次完整的核心流程再决定要不要在此基础上做二次开发。我在搭建这套系统时也经历过重构。第一版把所有业务写在Controller里结果维护困难后来按理解的服务化思想拆了一层Service出来。第二次重构引入了DTO层剥离了Entity和一些不必要暴露的字段。现在这套代码结构已经稳定许多。如果你打算在源码基础上做毕业设计或实际商用建议也做一个类似的重构动作——先让逻辑跑通再逐步优化结构而不是一上来就追求大而全的架构。写在最后这套SpringBoot中小学课外培训管理系统从需求梳理、数据库设计到核心功能实现每个阶段都有值得细说的点。我印象最深的还是防超卖问题看似简单的签到扣课时真正并发压过来的时候才会暴露数据一致性设计的不足。这也是为什么我在前面反复提到事务和锁机制——小额度的系统同样要重视这类问题否则上线后会很难收拾。如果你准备动手开发类似的系统建议优先把课时账户和排课冲突这两个模块的为什么想清楚这两块是整个系统的心跳方向对了其他功能模块就顺畅了。另外代码里的权限逻辑也别图省事直接复制零散的教程花点时间理顺RBAC的链路后续每加一个页面都会感到收益。最后补充一个小技巧开发阶段数据库里准备一批结构合理的测试数据课程覆盖几个热门分类、教师至少3位、班级横跨不同时间段、学生报名记录包含已完成和进行中两种状态。有测试数据打底联调效率能翻一倍这个经验在多个项目的实践中都得到验证。