SpringBoot琴行管理系统:排课冲突检测与课时扣减设计

发布时间:2026/10/16 5:11:56
SpringBoot琴行管理系统:排课冲突检测与课时扣减设计
每年这个时间点准备Java毕业设计的人就开始挠头。翻来覆去都是商城、图书管理、新闻发布真要动手写又觉得没什么技术含量。如果你手里攥着的是“琴行管理系统的设计与实现”或者“基于SpringBoot的乐器培训机构信息化管理平台”先别急着把它当成又一个增删改查项目——这类系统的名字虽然有三种叫法业务模型却是同一套琴行要做课程要做乐器销售还要管老师和教室的排班。把这三条线理顺系统设计自然就立起来了。这篇文章我会按做这类毕设的完整思路走一遍从需求拆解、技术选型、数据库设计到排课冲突检测、课时扣减这种真正有含金量的业务逻辑再到开发过程中容易踩的坑和答辩加分项。如果你正准备动手或者已经写到一半想救场这篇应该能给你省下不少时间。1. 需求拆解琴行管理系统到底在管什么1.1 核心不是增删改查是资源调度很多人拿到这种题目第一反应就是课程表、学员表、老师表然后做一堆下拉框和表格做完自己都不好意思演示。问题就出在把业务看简单了。琴行的经营本质是把教师、教室、乐器、学员课时这几种资源在时间轴上做合理分配。老师的时间有限教室的时段不能重叠学员买的课时包要能扣减、能过期乐器库存既要支撑教学又要卖给学员。这些资源一旦错配真实门店就会出乱子同一间琴房被约给两个学生、老师的课表撞车、学员剩余课时被扣成了负数。所以这套系统的技术难点根本不在登录和界面而在排课冲突检测、课时包扣减这一类容易被忽略的角落。这也是为什么很多外包团队做的琴行系统报价不低——业务逻辑不简单不只是页面堆砌。1.2 三类角色三种看待系统的视角设计功能前先把用户分成三类。管理员最关心的不是某个学员上了几节课而是整个店的经营状态今天有没有开课、乐器卖出多少、哪门课程最受欢迎、哪些课时包快过期。他需要的是订单、报表和库存总览。教师角色很简单只想看自己今天的课表在哪个教室、上什么课、学生是谁。学员和家长主要看剩余课时、课程安排和缴费记录。三种角色关注的东西完全不同所以菜单和首页也要分开设计。角色模型不需要复杂3个角色用字段区分就够。管理员、教师、学员分别对应0、1、2登录后放一个角色标识到token里后续拦截器判断即可。非要上一套RBAC权限框架反而让项目显得臃肿答辩时也不好解释为什么杀鸡用牛刀。1.3 功能范围怎么圈定才不会做成四不像功能清单不用贪多按“教务 库存 财务”三条线来切就很完整。教务线课程管理、教师管理、排课管理、考勤管理、课时包管理。库存线乐器档案、入库、出库、销售。财务线订单管理、退款管理、经营统计。三条线串起来就是一个完整闭环学员报名购课包 → 教师排课上课 → 考勤确认扣课时 → 乐器销售产生订单 → 月底看报表。这个闭环做完题目里“信息化管理”和“数字化运营”都站得住了。我见过不少把琴行系统做成“通用管理系统”改名的案例课也没有、琴也没有只有一堆用户和公告。这种项目答辩时经不住三问。圈定范围时守住一条原则功能必须围绕琴行的真实经营场景宁可少也不能偏。2. 技术选型怎么看SpringBoot搭什么前端更稳2.1 SpringBoot对毕设场景的价值先回答最实在的问题用SpringBoot到底比纯SSM强在哪早期SSM项目里光是Spring和SpringMVC的XML配置就要写几十行跑起来还动不动版本冲突。SpringBoot把自动配置做好了内置Tomcat打成一个可执行jar包拿到答辩教室的电脑上也能直接运行。这对毕设场景太重要了你总不希望答辩前一刻还在调环境。版本上有讲究如果学校机房还是JDK8就用SpringBoot 2.7.x如果能用JDK17可以上SpringBoot 3.x。选型理由说到底有三条一是配置少、上手快二是生态成熟、案例多三是SpringBoot是当前企业Java后端的主流选择讲技术选型不会站不住脚。顺便提醒一句如果导师特别在意版本可以重点讲一下SpringBoot的自动配置原理和Starter机制这是比“我会用”更有说服力的深度。2.2 持久层MyBatis-Plus是实用主义的选择持久层的主流候选有三个原生MyBatis、MyBatis-Plus、Spring Data JPA。这个项目我更推荐MyBatis-Plus。原因很直接单表CRUD、分页、逻辑删除都是内置的不用写XML省下的时间可以去做排课和课时包这些业务逻辑碰到多表联查统计的时候手写SQL也不生疏。和JPA比MyBatis-Plus的规则更直白生成的SQL可读老师问起来你能讲清楚和原生MyBatis比省掉了大量模板代码。简单对比一下对比维度MyBatis-PlusSpring Data JPA原生MyBatis单表CRUD内置零SQL内置但方法名规则复杂每个方法都要写SQL多表统计手写SQL灵活JPQL/原生SQL都有门槛逻辑清晰但代码量大逻辑删除内置TableLogic需要额外配置手动处理学习曲线平缓中等中等偏平缓适合场景管理信息系统、毕设领域模型复杂的业务已有规范的老项目这个项目用MyBatis-Plus本质上是在“开发效率”和“可控性”之间取了一个平衡点尤其适合单人开发、周期半年以内的毕设场景。2.3 前端路线前后端分离还是服务端渲染前端有两个完全不同的走法。走Vue3 Element Plus前后端分离演示时页面现代感强导师一眼觉得工作量足。代价是要配置CORS、处理token存储、前后端联调就一个登录流程就能折腾好几天。走Thymeleaf Bootstrap服务端渲染后端一个依赖搞定页面直接用Thymeleaf语法遍历数据登录拦截简单少踩很多跨域和权限坑。代价是界面朴素交互增强要手写JavaScript。如果总开发时间不足一个月我建议走Thymeleaf先保证能交付一个业务闭环完整的东西。如果时间充裕前后端分离的答辩效果更好特别是你能把JWT登录和拦截器鉴权讲清楚这本身就是加分项。一个很实际的经验是宁可前端简单一点也要把后端业务逻辑做扎实。页面花哨但业务经不起问等于白做。2.4 统一返回结构与全局异常接口统一返回Result结构包含code、message、data三个字段。成功code200业务失败code业务码前端根据code判断而不是看HTTP状态码。配合RestControllerAdvice做全局异常处理把校验异常和业务异常都转成Result返回。Data public class ResultT { private int code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT fail(String message) { ResultT r new Result(); r.code 500; r.message message; return r; } }别小看这步。统一结构让前端少写一半判断逻辑也避免了每个接口返回格式都不一样、前端到处特判的情况。同时自定义一个BusinessException遇到课时不足、排课冲突这类业务规则就直接抛出去全局异常处理器统一转成Result。答辩时讲这个设计比讲十个页面更有分量。3. 数据库设计拆解把核心表关系一次理清3.1 用户、学员、教师一套账号体系最省事的做法是建一张sys_user统一存登录信息用户名、密码、角色、手机号。再建student和teacher_info两张扩展表用user_id关联存各自的专属字段。学员表放家长姓名、家长电话、学习级别教师表放主授乐器、入职时间、简介。这样设计的好处有两层。第一登录只需要查一张表不用根据角色去不同表里找账号。第二扩展表可以各存各的信息不会把一张用户表撑得又大又乱。密码入库用BCrypt加密数据库泄了密码还是散列的这也是一个安全加分点。有些同学喜欢用户、教师、学员各建一套登录账号表看起来很规范实际操作时登录代码要按角色路由到三张表接口也不好写。这种复杂度没必要。3.2 课程、课时包、排课、考勤的关系这条链路是系统的主心骨表之间的关联顺序就是业务顺序。学员购买课程后生成course_package课时包记录总节数和剩余节数。老师或管理员创建排课schedule记录哪一天、从几点到几点、哪个教室、哪个老师、哪个学员。上课当天学员签到或老师确认生成attendance_record考勤记录。确认考勤的同时对课时包做扣减。这里有一个容易被忽略的点小组课。一次排课可能对应多个学员所以考勤记录不能直接挂在排课表上要单独建表一条schedule对应多条attendance_record。一对一课虽然只有一条记录但用同一个结构不会出错。如果偷懒把student_id直接嵌进schedule表后面要支持小组课就得重构表结构得不偿失。3.3 订单与乐器库存怎么落表乐器模块分成两张表。instrument乐器档案存名称、分类、品牌、库存数量、进价、售价、预警值inventory_record出入库流水存乐器ID、变更类型、变更数量、操作人、备注。卖乐器时先生成订单再扣减库存。但库存扣减不要先查后改应该用一段带条件的UPDATE语句update instrument set stock_quantity stock_quantity - #{count} where id #{id} and stock_quantity #{count}用受影响行数判断是否库存不足避免两个请求同时读库存、同时扣减导致的超卖。这个思路和后面课时扣减是同一个套路属于一套可以复用的并发安全写法。订单建议拆成order主表和order_item明细表。主表存订单号、学员ID、订单类型、总金额、支付状态明细表存具体买了什么、单价多少、数量多少。价格变动时订单明细里的价格就是快照历史报表不会因为课程涨价而金额对不上。3.4 字段类型、索引与命名细节几个写库时的实操建议都是踩过坑换来的。金额字段用decimal绝对不要用float/double浮点误差在财务数据上是致命的。金额单位要么统一用元要么统一用分别混着来。状态字段用int表示枚举比如0待支付、1已支付、2已退款不要用varchar存中文状态否则统计SQL会很痛苦。时间字段统一类型。日期用LocalDate日期时间用LocalDateTime。排课表的开始、结束时间用LocalTime就行因为它只表示当天内的时间段不需要带日期。另外注意order是MySQL的关键字订单表最好加上反引号或者叫t_order否则SQL写出来就是语法错误。索引不用贪多。我会在schedule表建(teacher_id, course_date)和(classroom_id, course_date)两个组合索引覆盖排课冲突检测和课表查询在course_package表建(student_id, status)索引在order表建order_no唯一索引和student_id索引流水表按create_time建普通索引。覆盖常用报表查询就够了索引太多反而拖慢写入。4. 核心功能实现登录、排课、扣课时、统计4.1 登录鉴权JWT与拦截器的组合毕设管理系统用JWT比Session更省心。前端存token后端用拦截器统一校验。登录接口放行其余接口从Authorization头解析token解析成功就把用户ID和角色放进ThreadLocal上下文解析失败返回401。依赖引入jjwt代码量不大。登录成功后生成tokenString token Jwts.builder() .setSubject(String.valueOf(user.getId())) .claim(username, user.getUsername()) .claim(role, user.getRole()) .setExpiration(new Date(System.currentTimeMillis() 7 * 24 * 3600 * 1000)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact();拦截器里解析token并放行放行规则Claims claims Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); UserContext.setUserId(Long.parseLong(claims.getSubject())); UserContext.setRole((Integer) claims.get(role));注意两个细节。密钥不要硬编码到代码里放配置文件。过期时间设7天太长不安全太短用户要频繁登录。另外jjwt不同版本的API有差异0.12版本改了构建方式按你引入的依赖版本来写即可。这里如果用的是前后端分离记得把token返回给前端存起来登录后的请求都要带上。4.2 排课冲突检测时间重叠的判断排课是这类系统里最值得讲的业务逻辑。先明确规则一个老师同一时间只能在一间教室上课一间教室同一时间只能被一个老师使用。所以新增排课要检测两个维度教师维度、教室维度。时间重叠的数学判断是newStart oldEnd and newEnd oldStart。用生活化的说法新排的开始时间比原课程结束时间早并且新排的结束时间比原课程开始时间晚两段时间一定有交集。等号要特别注意如果一条课正好10:00结束另一条10:00开始这两节课不冲突所以必须用严格小于和严格大于。代码实现public void createSchedule(ScheduleCreateDTO dto) { CoursePackage pkg coursePackageService.getById(dto.getPackageId()); if (pkg null || pkg.getRemainCount() 0) { throw new BusinessException(学员课时不足无法排课); } long teacherCount scheduleMapper.selectCount( new LambdaQueryWrapperSchedule() .eq(Schedule::getTeacherId, dto.getTeacherId()) .eq(Schedule::getCourseDate, dto.getCourseDate()) .lt(Schedule::getStartTime, dto.getEndTime()) .gt(Schedule::getEndTime, dto.getStartTime())); if (teacherCount 0) { throw new BusinessException(该教师在这个时间段已有课程); } long roomCount scheduleMapper.selectCount( new LambdaQueryWrapperSchedule() .eq(Schedule::getClassroomId, dto.getClassroomId()) .eq(Schedule::getCourseDate, dto.getCourseDate()) .lt(Schedule::getStartTime, dto.getEndTime()) .gt(Schedule::getEndTime, dto.getStartTime())); if (roomCount 0) { throw new BusinessException(该教室在这个时间段已被占用); } scheduleMapper.insert(convert(dto)); }修改排课的时候要额外排除自己这条记录不然编辑后保持原时间不修改也会被自己的原记录挡住报冲突。4.3 考勤确认与课时扣减并发安全的原子操作课时扣减不能简单写成“查剩余 → 减一 → 更新”。如果两个请求同时进入可能都查到剩余1节都减一最终剩余变成0但两次操作实际只该扣一节。在实际系统里同一个学员被重复扣课时的风险是真实存在的。推荐写法是一段原子UPDATE语句update iddeductRemainCount update course_package set remain_count remain_count - 1 where id #{id} and remain_count 0 /update然后用受影响行数判断等于1说明扣减成功等于0说明课时不足或课时包状态异常。整个确认考勤的流程包在事务里扣减成功后更新考勤记录状态事务提交后数据一致。Transactional(rollbackFor Exception.class) public void confirmAttendance(ConfirmDTO dto) { AttendanceRecord record attendanceMapper.selectById(dto.getRecordId()); if (record null || !record.isPending()) { throw new BusinessException(考勤记录不存在或已确认); } CoursePackage pkg findAvailablePackage(record.getStudentId(), record.getCourseId()); if (pkg null) { throw new BusinessException(学员没有可用课时包); } int rows coursePackageMapper.deductRemainCount(pkg.getId()); if (rows 0) { throw new BusinessException(课时不足或课时包状态异常); } record.setStatus(1); attendanceMapper.updateById(record); }这里有个很容易踩的坑如果用MyBatis-Plus默认的updateById没有remain_count大于0的条件判断并发问题依然存在。所以这段UPDATE必须手写靠数据库条件去保证一致性程序里再做一次判断是双保险。4.4 经营统计一类报表SQL的写法库存和销售贯通之后管理员最常用的就是报表。最常见的是按月、按乐器类型看销售额以及统计每个教师的授课课时数。一个典型的统计SQL是这种SELECT i.category AS instrument_category, DATE_FORMAT(o.create_time, %Y-%m) AS month, SUM(oi.amount) AS revenue FROM t_order o LEFT JOIN order_item oi ON o.id oi.order_id LEFT JOIN instrument i ON oi.instrument_id i.id WHERE o.order_type 1 AND o.pay_status 1 AND o.create_time BETWEEN #{start} AND #{end} GROUP BY i.category, DATE_FORMAT(o.create_time, %Y-%m) ORDER BY month, revenue DESC这个SQL有两个细节。第一订单表如果叫order必须加反引号或者起别名MySQL里order是关键字。第二GROUP BY的字段要和查询列保持一致排序用月份和金额倒序报表展示直接能用。多表关联时给表起短别名SQL可读性会好很多。教师课时统计的思路类似按teacher_id分组统计status为已完成的排课记录数量。注意考勤表里有跨天的可能排课按日期分组时先把日期格式化好再GROUP BY避免出现同一节课被拆到两天。5. 开发踩坑实录四个高频问题5.1 时间字段序列化与时区SpringBoot 2.x默认序列化LocalDateTime是ISO格式比如2024-06-01T10:30:00前端显示很别扭。最省心的办法是统一在配置文件里指定格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8另外数据库连接串一定要加serverTimezoneAsia/Shanghai否则查出来的时间可能差8小时。这个坑很容易被忽略但几乎每个做时间相关功能的同学都会遇到一次。5.2 事务不生效的三种场景Transactional看上去简单出问题却很难查。最常见的是三种情况。第一种同类内部调用。类A的方法a()调用同类方法b()b上有Transactionalb抛异常并不会触发回滚因为Spring是通过代理类拦截事务的内部调用走的是原始对象代理没生效。解决方法是把需要事务的方法放到另一个Service里或者自己注入自己。第二种异常被catch吞了。事务方法里捕获了异常但没有重新抛出去Spring看到方法正常返回就不回滚。规则是事务方法内部不要吞掉需要回滚的异常。第三种rollbackFor没配置。Spring默认只对RuntimeException回滚如果抛了自定义的Checked Exception事务不会回滚。建议统一写Transactional(rollbackFor Exception.class)不要省这个属性。5.3 逻辑删除与唯一索引的冲突MyBatis-Plus的TableLogic做逻辑删除非常省事但它有个隐藏问题。假设订单表的order_no建了唯一索引删除了一条订单记录后数据库里那条数据其实还占着这个order_no新订单再生成同样的订单号就会报冲突。解决思路有两个。一是订单号不要简单用自增ID拼用时间戳加随机数或者日期序列生成重复概率极低。二是逻辑删除字段不存0/1改存删除时间戳配合唯一索引就不会撞了。第二个方案更彻底答辨时讲出这个细节老师会觉得你考虑问题完整。5.4 CORS跨域配置前后端分离下前端在8081后端在8080登录请求发出去就被浏览器拦下。简单配置一个CorsFilter就能解决Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(http://localhost:*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }穿过的坑是allowedOrigins不能和allowCredentialstrue同时用通配符*旧版本会直接报错。用allowedOriginPatterns更省心开发环境配好这一处就够了生产环境通常同域部署跨域问题反而不存在。6. 答辩加分方向从能用走向好用6.1 学员预约模式如果基础系统已经稳定可以把排课拆成两种模式固定排课和学员预约。预约模式的核心是一个可预约时间段列表展示老师可约的时间片学员选一个时间生成待确认排课老师确认后才正式占用该时段。实现上需要给schedule增加状态字段从待确认变成已确认时先做冲突检测再更新。互斥控制还是老套路更新时带上当前状态作为条件affected rows为1才算抢到时间片。答辩演示预约流程很直观比干讲排课表好讲。6.2 定时备份与操作日志Spring内置的Scheduled可以做定时任务每天凌晨把核心表导出成SQL或CSV文件。操作日志用AOP加注解实现记录谁在什么时间做了什么操作。这两个功能实现成本低但对“数字化运营”这个题目表达很重要能证明你考虑过系统上线后怎么长期运行。具体做法是写一个注解OperationLog在需要记录的方法上标注然后用一个切面统一读取方法参数、当前登录用户、操作描述异步写入日志表。答辩时打开操作日志页面说一句“我们可以审计关键操作”印象分明显不同。6.3 经营看板与预警管理员登录后的首页做成经营看板今日收入、本月营收趋势、各课程报名人数排行、库存预警清单。低库存和课时包即将过期用一条查询就能做不需要消息队列。图表可以用前端图表库把后端统计接口的数据渲染成折线和柱状图。关键是把“数据可视化”讲清楚从哪些表统计、按什么维度聚合、能支撑什么经营决策。答辩时直接打开看板讲经营建议效果远好于对着数据表格念数字。回看整套系统最有价值的并不在某个花哨页面而是把琴行管理的业务逻辑真正想透的那部分课时包能扣、排课不冲突、库存不超卖、报表能对上账。只要这条主链路是通的题目叫琴行管理系统也好叫信息化管理平台也好换个名字它仍然站得住。如果你手上正好在写类似系统我的建议是从排课冲突和课时扣减这两个点开始深挖这两个逻辑能讲明白答辩已经成功一半。做毕设别贪功能多把一条业务线做通做透比十个半成品页面更拿得出手。