☰
Spring Boot毕业设计实战:瑜伽馆课程预约系统从设计到部署
2026/10/16 7:03:28 网站建设 项目流程

1. 项目概述与核心价值

做毕设选题的时候,不少同学都在“管理系统”这个老套路上打转,无非是学生信息管理、图书借阅、超市收银这类做了无数遍的CRUD。说实话,这类题目不是不行,但答辩时很难出彩。我这次要拆解的这套“基于Spring Boot的瑜伽馆课程预约系统”,看起来也是管理系统,但它把业务场景拉到了健身行业的具体痛点——课程排期、约课冲突、会员消课、教练排班,这些都不是单纯的增删改查,而是有真实业务逻辑在里面的。如果你正在纠结毕业设计选什么题,或者想找一个既能覆盖主流技术栈、又能在答辩时有话说的项目,这个题目是个很好的切入点。

这个系统能做什么?简单说,它解决的是瑜伽馆日常运营中最烦人的三件事:第一,会员约课靠微信群接龙或用EXCEL登记,约没约上全凭运气;第二,教练的排班和课程时间经常撞车,馆里协调起来头大;第三,会员的课程次数消耗和到期时间总是对不上账,月底一算就容易扯皮。这套系统用Spring Boot做后端,配合MySQL存数据,前端用Thymeleaf或Vue都行,把课程发布、在线预约、后台管理、会员消课这些流程串起来,让整个约课链路清晰可见。

适合谁来参考?两类人:一类是正在做毕业设计、需要完整项目源码和思路的计算机专业学生;另一类是刚入行的Java开发,想看看一个业务型的Web项目是怎么从需求分析做到落地的。我会把项目从需求建模、数据库设计、核心接口实现到部署上线全过程的关键环节都过一遍,重点讲清楚那些网上资料通常不会写明白的决策逻辑——为什么这样设计表、为什么这个接口要这么做,踩坑的地方在哪。

2. 系统整体设计与方案选型

2.1 技术栈选型的底层逻辑

技术栈这块,核心选了Spring Boot 2.x + MyBatis Plus + MySQL 8.x,前端用Thymeleaf模板引擎加Bootstrap。很多同学会问,为什么不直接用Vue前后端分离?这里得看场景。

如果你是做毕业设计,时间紧、要快速跑通、还要好答辩,那么服务端渲染的Thymeleaf是更稳的选择。原因有几点:第一,不用处理跨域和Token鉴权的前后端联调问题,代码量少一大截;第二,页面数据和后端状态天然同步,做演示的时候不用额外启动前端工程;第三,答辩时老师问“你这个页面数据怎么来的”,你可以直接讲Thymeleaf的模型传参,链路短,容易说清楚。当然,如果你个人对Vue很熟,或者导师明确要求前后端分离,那也可以把前端换成Vue+Axios,后端接口保持RESTful风格即可。我在这次示例项目中用的是Thymeleaf方案,同时把Controller层的接口都写成了返回JSON的格式,这样既有页面演示的方便,又不妨碍以后拆成前后端分离。

Spring Boot本身的好处不用多讲,自动配置、内嵌Tomcat、起步依赖,能让你把精力集中在业务逻辑而不是环境搭建上。MyBatis Plus在MyBatis基础上封装了通用Mapper和条件构造器,单表操作几乎不用写SQL,对于这个项目里的会员表、课程表、预约表来说足够了,而且它的分页插件对列表页非常友好。

2.2 功能模块划分与角色权限模型

瑜伽馆的日常运营里有三类角色:

  • 管理员:负责教练管理、课程发布、查看所有预约记录、处理退款或取消预约。
  • 教练:查看自己的课程安排和约课学员名单,可以修改课程状态。
  • 会员(普通用户):注册登录后浏览课程、预约课程、查看自己的预约历史和剩余次数。

权限模型直接用Spring Security或者简单的拦截器就能实现。我倾向于用Spring Boot自带的Interceptor加自定义注解来控制角色权限,因为Spring Security对于这种简单项目来说配置偏重,而且很多同学对Security的过滤器链不熟,一旦配错全校验失效,答辩时被问住很难受。用拦截器的话,逻辑清晰、容易自证:

public class AuthInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("loginUser"); if (user == null) { response.sendRedirect("/user/login"); return false; } // 检查角色权限:通过注解或者URL前缀判断 return true; } }

这个方案的缺点是没有细粒度的权限控制,比如你想让教练只能改自己的课程,还得在业务代码里再做一层校验。但作为毕设项目,完全够用。真正的重点在于业务数据的关联关系,那是这个项目的灵魂。

2.3 业务难点拆解:约课冲突与库存扣减

这个系统最核心的业务不是“新增一条预约记录”,而是保证“同一时段同一场地/同一教练不能被重复预约”。如果只在Service层做判断,在高并发下会有问题——两个会员同时点击约课,都查询到“当前无预约”,然后都插入成功,这就是超卖。用MySQL的话,解决办法是在预约表上加唯一约束,比如把课程实例ID(course_schedule_id)和会员ID(member_id)做联合唯一索引:

ALTER TABLE booking_record ADD UNIQUE KEY uk_schedule_member (course_schedule_id, member_id);

这样即使出现并发插入,数据库也会拒绝重复数据,从根上保证一个会员对同一节课只能有一条预约记录。

同时,每一个课程实例有固定的最大容纳人数(capacity),需要在预约时判断当前预约人数是否已满。这里不能只靠应用层查count,更稳妥的做法是使用数据库乐观锁。具体思路是在课程排期表里加一个remaining字段,每次预约时执行UPDATE y_course_schedule SET remaining = remaining - 1 WHERE id = ? AND remaining > 0,通过受影响行数判断是否预约成功。这一步操作是行级原子性的,不会超卖。预约成功后再插入预约记录,失败则提示“课程已满”。

我在项目里把这两套机制都用上了:唯一索引兜底,乐观锁控制名额。这就是一个可以写进论文里的亮点——不是简单的CRUD,而是考虑了并发场景下的数据一致性。

3. 数据库设计与核心表结构

3.1 数据表概览与关系说明

整个系统我设计了八张核心表,这里挑最重要的五张展开讲:

  1. 用户表(sys_user):管理员、教练、会员统一存储,用role字段区分(0管理员,1教练,2会员)。密码用BCrypt加密存储。
  2. 会员信息表(member_info):存储会员的姓名、手机号、剩余课时数、会员到期时间等,和sys_user是一对一关系。
  3. 教练表(coach_info):教练的专长(如哈他瑜伽、流瑜伽、阴瑜伽)、简介、头像。
  4. 瑜伽课程表(yoga_course):课程基础信息,如课程名称、类型、适合人群、课程时长、课程介绍。
  5. 课程排期表(course_schedule):某节课在什么时间、由哪个教练上、在哪间教室、最大人数、剩余名额、状态。

另外三张是预约记录表、课时消耗流水表和公告表,预约记录表用于记录每次约课/取消的明细,课时流水表则记录会员每次消耗课时或者管理员手动充值的记录。

3.2 关键表结构设计细节

课程排期表是核心中的核心,建表语句大致如下:

CREATE TABLE course_schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL COMMENT '关联课程ID', coach_id BIGINT NOT NULL COMMENT '关联教练ID', class_date DATE NOT NULL COMMENT '上课日期', start_time TIME NOT NULL COMMENT '开始时间', end_time TIME NOT NULL COMMENT '结束时间', classroom VARCHAR(50) NOT NULL COMMENT '教室', capacity INT NOT NULL DEFAULT 1 COMMENT '最大人数', remaining INT NOT NULL DEFAULT 0 COMMENT '剩余名额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束 3已取消', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', UNIQUE KEY uk_coach_time (coach_id, class_date, start_time), KEY idx_course_date (course_id, class_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程排期表';

注意这里我加了uk_coach_time唯一约束,也就是一个教练在同一个日期和开始时间只能有一节课,这从数据库层面避免了教练排班冲突。

预约记录表:

CREATE TABLE booking_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, member_id BIGINT NOT NULL, booking_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, status TINYINT NOT NULL DEFAULT 0 COMMENT '0已预约 1已取消 2已完成 3爽约', UNIQUE KEY uk_schedule_member (schedule_id, member_id), KEY idx_member_status (member_id, status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约记录表';

这样一个会员对同一节课只能预约一次,而且通过索引可以快速查询某个会员的所有预约历史。

3.3 为什么不用物理外键

这是个很容易被答辩老师问到的点。我的建议是:表之间逻辑上有关联,但不建物理外键。原因有三:第一,物理外键会降低插入和删除的性能,尤其是高并发写入时,外键检查是额外开销;第二,核心业务已经在应用层和唯一索引做了约束,物理外键属于重复约束;第三,毕设项目后期如果要分库分表或者做数据迁移,物理外键会成为一个很大的障碍。我在表里只保留了逻辑外键字段(如schedule_id、member_id),通过MyBatis Plus的关联查询或者普通Join查询来维护关系。这一点建议写进论文的“数据库设计”章节,算是加分的思考。

4. 核心功能模块的实操实现

4.1 课程发布的完整流程

管理员发布一节新课,不是只插一条记录就完事了。流程应该是:

  1. 管理员在后台选择基础课程(如“流瑜伽基础课”),选择教练、日期、起始时间、教室、最大人数。
  2. 提交前系统需要校验:这个教练在所选时间段是否已有其他课程,所选教室是否有其他课程占用。这些校验放在Service层统一处理。
  3. 校验通过后插入course_schedule记录,初始remaining等于capacity。
  4. 课程状态自动计算:如果class_date小于今天,则状态置为已结束。这个可以用定时任务或者查询时动态计算。为了简单,我采用查询时动态计算的方式,避免维护定时任务。

Service层核心校验代码如下(简化示意):

// 检查教练时间冲突 Integer conflict = scheduleMapper.selectCount( new LambdaQueryWrapper<CourseSchedule>() .eq(CourseSchedule::getCoachId, coachId) .eq(CourseSchedule::getClassDate, classDate) .eq(CourseSchedule::getStartTime, startTime) .ne(CourseSchedule::getId, scheduleId != null ? scheduleId : 0) ); if (conflict > 0) { throw new ServiceException("该教练在此时间段已有课程安排"); }

这里有个细节容易被忽略:编辑课程时,要把自身ID排除掉,否则你会发现自己编辑自己的课程也被判定为冲突。

4.2 会员约课的原子性操作

会员端约课的逻辑,我拆成了三步,并且放在一个带事务的方法里:

@Transactional(rollbackFor = Exception.class) public boolean bookCourse(Long scheduleId, Long memberId) { // 1. 判断会员是否有效:是否在有效期内,剩余课时是否大于0 MemberInfo member = memberInfoMapper.selectById(memberId); if (member.getRemainingCourses() <= 0) { throw new ServiceException("剩余课时不足,请联系管理员充值"); } if (member.getExpireDate().isBefore(LocalDate.now())) { throw new ServiceException("会员已过期"); } // 2. 扣减剩余名额(乐观锁) int updated = courseScheduleMapper.reduceRemaining(scheduleId); if (updated == 0) { throw new ServiceException("课程已约满"); } // 3. 插入预约记录 + 扣减课时 + 记录流水 BookingRecord record = new BookingRecord(); record.setScheduleId(scheduleId); record.setMemberId(memberId); bookingRecordMapper.insert(record); member.setRemainingCourses(member.getRemainingCourses() - 1); memberInfoMapper.updateById(member); // 插入流水表 consumeLogService.writeLog(memberId, scheduleId, -1, "约课消费"); return true; }

注意第2步的reduceRemaining是自定义SQL:

UPDATE course_schedule SET remaining = remaining - 1, version = version + 1 WHERE id = #{scheduleId} AND remaining > 0

这里通过updated == 0来判断是否更新成功,比先select再update要安全得多。

4.3 取消预约与恢复时长的设计

取消预约的规则是:在课程开始前2小时可以取消,取消后恢复消耗的课时,预约名额回填。这里同样要用到事务和状态判断。

有一个容易漏掉的坑:用户可能在移动端和PC端同时发起取消请求,导致课时被恢复两次。所以取消操作也要有一个状态流转的原子更新:

UPDATE booking_record SET status = 1 WHERE id = #{recordId} AND status = 0

先把这个预约记录从“已预约”改成“已取消”,受影响行数为1才继续执行后续的“课时恢复”和“名额回填”。否则直接返回“已经取消或状态异常”。这个套路叫“状态机更新”,我在项目里几乎所有的状态变更都用这种方式,简单有效。

4.4 后台统计报表的处理

管理员最想看的几个数字:今日预约人数、课程上座率、热门课程排行、会员增长趋势。这些统计如果用原生的SQL写,可以用GROUP BY + DATE_FORMAT等函数实现。举一个热门课程排行:

SELECT c.course_name, COUNT(b.id) AS booking_count FROM booking_record b JOIN course_schedule s ON b.schedule_id = s.id JOIN yoga_course c ON s.course_id = c.id WHERE b.booking_time BETWEEN #{startTime} AND #{endTime} AND b.status IN (0, 2) GROUP BY c.id ORDER BY booking_count DESC LIMIT 10;

统计页面用ECharts插件画柱状图和折线图非常合适。前端模板里引入ECharts的CDN,Controller返回JSON数据,页面用Ajax请求后渲染,效果出得来。

5. 前后端交互与关键细节处理

5.1 Thymeleaf页面渲染的实践心得

用Thymeleaf做服务端渲染,最舒服的一点是后端把数据塞进Model,页面直接取。比如列表页示例:

@GetMapping("/schedule/list") public String list(@RequestParam(defaultValue = "1") Integer pageNum, Model model) { Page<CourseSchedule> page = new Page<>(pageNum, 10); LambdaQueryWrapper<CourseSchedule> wrapper = new LambdaQueryWrapper<>(); wrapper.orderByAsc(CourseSchedule::getClassDate).orderByAsc(CourseSchedule::getStartTime); Page<CourseSchedule> result = scheduleService.page(page, wrapper); model.addAttribute("page", result); return "schedule/list"; }

页面对应的th:each循环不需要我多讲,重点说一下表单回显和校验。

我踩过一个坑:在编辑课程时,日期和时间字段如果直接绑定到LocalDate和LocalTime,Thymeleaf的th:value使用默认格式会报错。解决办法是引入thymeleaf-extras-java8time模块,或者在Controller里把日期时间先格式化成字符串再传给页面。我图省事,直接在Controller里把它转成了String,页面用type="date"和type="time"的input来展示。

另一个细节是表单提交时的后端校验。Spring Boot自带的@Validated加@NotNull这些注解,只能管住字段非空,业务逻辑校验还是得写在Service里。比如课程最大人数我限制在1~30之间,就只能在Service层手动判:

if (capacity < 1 || capacity > 30) { throw new ServiceException("课程人数需在1-30之间"); }

5.2 REST接口与页面混合架构的取舍

我在项目里做了一点折中:主要页面走Thymeleaf渲染,但部分异步交互(比如会员端点“约课”按钮、取消预约)通过fetch调用后端JSON接口,然后用JS局部刷新按钮状态。这样做的原因有两个:一是约课操作需要实时反馈成功或失败,局部刷新体验更好,不会因为整个页面刷新丢状态;二是接口层独立出来后,以后想写小程序端或者移动端,直接复用这几个JSON接口就行。

比如约课按钮的前端代码块:

function bookSchedule(scheduleId, btn) { fetch('/api/booking', { method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({scheduleId: scheduleId}) }) .then(response => response.json()) .then(data => { if (data.code === 200) { alert('预约成功'); btn.disabled = true; btn.textContent = '已预约'; } else { alert(data.msg); } }) .catch(error => console.error(error)); }

后端对应的接口:

@PostMapping("/api/booking") @ResponseBody public Result book(@RequestBody Map<String, Long> params, HttpSession session) { Long scheduleId = params.get("scheduleId"); Object obj = session.getAttribute("loginUser"); if (obj == null) { return Result.error("未登录"); } // 省略业务调用... }

这套交互模型在整个项目里用得很顺手,而且答辩时不至于被问“你都是同步刷新页面,有何现代性”——你有接口,有JSON,有局部刷新,这就是谈资。

5.3 MyBatis Plus的坑与常用写法

MyBatis Plus本身很好用,但有几个坑我提醒一下:

第一,分页插件必须配置一个拦截器Bean,否则selectPage查出来的总数一直是0。这个在官方文档里有,但初学者特别容易漏:

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; }

第二,实体类的驼峰字段映射,需要在application.yml里配置:

mybatis-plus: configuration: map-underscore-to-camel-case: true

否则courseName映射不到course_name列,查出全是null,到时候排查半天。

第三,逻辑删除字段使用@TableLogic注解,这样删除数据其实是update,查询时自动加上deleted=0条件。对于预约记录这种需要留痕的表,强烈建议开启逻辑删除,答辩时能说明白数据安全合规性的考量。

6. 常见问题与排查技巧实录

6.1 会话过期与登录状态丢失

这个太经典了。你部署完后,用户登录进去,过一段时间点“约课”,直接跳回登录页或者报500。原因多半是Session默认30分钟超时,而Ajax请求没有携带Session ID。排查思路很简单:打开浏览器开发者工具,看网络请求的Cookie里有没有JSESSIONID,如果没有,那大概率是跨域或者请求被重定向了。

我的解决方案是:统一封装Ajax请求,监听全局401状态码。如果后端检测到session为null时返回401,前端就弹窗提示“登录过期,请重新登录”,然后跳转到登录页。比直接返回“未登录”然后页面刷掉用户的操作更友好。

6.2 前端日期时间格式的格式化问题

Thymeleaf + LocalDate最烦的就是格式。一次用户报名约课,我后端返回的日期格式是2025-05-18,但前端页面显示却是2025-05-18T00:00:00,明显是用了LocalDateTime。经验是:对于不需要时分秒的字段,一律用LocalDate;页面取值时用th:field或th:value加上#temporals.format:

<input th:value="${#temporals.format(schedule.classDate, 'yyyy-MM-dd')}" />

Controller直接传LocalDate给Thymeleaf时,3.0版本已经默认支持了,不需要额外配置。如果传的是LocalDateTime,就一定要指定格式,否则会带上T。

6.3 数据库连接池连接耗尽

本系统并发量不会很大,但如果忘了配连接池参数,Spring Boot默认的HikariCP也有默认限制(最大连接数10)。在课程整点放约的高峰,有可能出现连接等待超时。建议在配置里调大:

spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 30000

另外,代码里如果有手动获取Connection,记得要在finally里关闭。MyBatis Plus管理了这部分,但自己写JDBC工具类时特别容易漏。

6.4 常见报错速查表

报错现象可能原因解决办法
启动报“Failed to configure a DataSource”没有配置数据源或驱动依赖缺失检查pom的mysql-connector-java依赖及application.yml的url
接口返回500,日志里有“Duplicate entry”唯一索引重复,约课并发或数据重复插入检查业务是否已做重复校验,利用异常捕获提示用户
Thymeleaf模板渲染出来空白后端没有将数据放进Model,或模板变量名不对核对Controller中model.addAttribute的key与页面th:text中的表达式
分页total为0缺少分页插件配置添加MybatisPlusInterceptor Bean
登录后仍然无法访问某些页面拦截器放行路径配置不对检查Interceptor注册时excludePathPatterns是否包含了静态资源及登录接口

7. 部署上线与演示准备的小技巧

7.1 本地打包与运行

开发环境建议用IDEA直接跑Spring Boot的main方法。需要部署给老师演示时,用Maven打jar包:

mvn clean package -DskipTests java -jar target/yoga-app.jar

如果服务器上MySQL账户密码与本地不一致,用外部配置覆盖:

java -jar target/yoga-app.jar --spring.datasource.username=root --spring.datasource.password=xxx

这个命令在答辩现场很能体现你的部署经验。

7.2 演示数据的准备策略

一个空的系统拿去演示,观感很差。我会在数据库初始化脚本里预置一批演示数据:五个会员、三个教练、十门基础课程、未来两周每天若干排期。这样打开首页就是丰富的课程列表,点一个课程就有得约。还可以预置几条预约记录,让统计页面的图表有数据可看。

预置数据时注意时间要设在未来,并且避开系统当前时间。如果你写死的是2025年某一天,但演示那天下课了,就会出现“已结束”状态,体验割裂。更好的做法是脚本里用DATE_ADD(NOW(), INTERVAL 1 DAY)这种方式动态生成。

7.3 答辩演示的演示脚本

我建议每场演示控制在五分钟以内,顺序是:作为管理员登录,发布一节新课程,调整名额;退出,用会员登录,浏览课程,完成一次约课;回到管理员端,查看这条预约记录;最后展示一下个人中心的预约历史,以及后台的统计报表。每个环节一两个动作就够,不要过多在表单上磨叽。遇到数据库有重复数据的,大大方方说“这是测试数据,用来验证列表分页”。

8. 写在最后:项目还可以怎么延伸

如果你做完这个项目还有余力,我建议增加两个方向。

第一个是增加“课程提醒”。在课程开始前2小时,系统通过邮件或短信提醒已预约会员。思路不复杂:定时任务每分钟扫一次排期表,找出start_time减去当前时间等于120分钟的课程,然后遍历该课程的预约用户,发提醒。注意提醒状态要存起来,避免重复发送。

第二个是把“课程评价”加进去。课程结束后,会员可以给教练打分并写评论。这个功能会多出两张表(评价表和回复表),也可以和课程排期表关联。加入评价功能后,系统的完整性更强,也更好扩展。

我个人在做这个项目的过程中,最大的体会是:毕设选题不怕小,怕的是逻辑没有闭环。瑜伽馆课程预约系统表面上是“张三预约李四的课”,但背后牵扯到角色权限、时间冲突、并发扣减、状态流转,每一处都值得深入挖掘。把这些细节做扎实,代码量和论文深度自然就有了,答辩时老师问的每个问题你都能有理有据地回答。这也是我觉得这个题目值得推荐的核心原因。如果你正在为选题发愁,不妨照着这个思路动手试一试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询