资讯详情

Spring Boot毕业设计实战:瑜伽馆课程预约系统从设计到部署

📅 2026/10/16 7:03:30 | 华诺云谱 👁 阅读
Spring Boot毕业设计实战:瑜伽馆课程预约系统从设计到部署
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很熟或者导师明确要求前后端分离那也可以把前端换成VueAxios后端接口保持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的话解决办法是在预约表上加唯一约束比如把课程实例IDcourse_schedule_id和会员IDmember_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 数据表概览与关系说明整个系统我设计了八张核心表这里挑最重要的五张展开讲用户表sys_user管理员、教练、会员统一存储用role字段区分0管理员1教练2会员。密码用BCrypt加密存储。会员信息表member_info存储会员的姓名、手机号、剩余课时数、会员到期时间等和sys_user是一对一关系。教练表coach_info教练的专长如哈他瑜伽、流瑜伽、阴瑜伽、简介、头像。瑜伽课程表yoga_course课程基础信息如课程名称、类型、适合人群、课程时长、课程介绍。课程排期表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT预约记录表;这样一个会员对同一节课只能预约一次而且通过索引可以快速查询某个会员的所有预约历史。3.3 为什么不用物理外键这是个很容易被答辩老师问到的点。我的建议是表之间逻辑上有关联但不建物理外键。原因有三第一物理外键会降低插入和删除的性能尤其是高并发写入时外键检查是额外开销第二核心业务已经在应用层和唯一索引做了约束物理外键属于重复约束第三毕设项目后期如果要分库分表或者做数据迁移物理外键会成为一个很大的障碍。我在表里只保留了逻辑外键字段如schedule_id、member_id通过MyBatis Plus的关联查询或者普通Join查询来维护关系。这一点建议写进论文的“数据库设计”章节算是加分的思考。4. 核心功能模块的实操实现4.1 课程发布的完整流程管理员发布一节新课不是只插一条记录就完事了。流程应该是管理员在后台选择基础课程如“流瑜伽基础课”选择教练、日期、起始时间、教室、最大人数。提交前系统需要校验这个教练在所选时间段是否已有其他课程所选教室是否有其他课程占用。这些校验放在Service层统一处理。校验通过后插入course_schedule记录初始remaining等于capacity。课程状态自动计算如果class_date小于今天则状态置为已结束。这个可以用定时任务或者查询时动态计算。为了简单我采用查询时动态计算的方式避免维护定时任务。Service层核心校验代码如下简化示意// 检查教练时间冲突 Integer conflict scheduleMapper.selectCount( new LambdaQueryWrapperCourseSchedule() .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是自定义SQLUPDATE 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的CDNController返回JSON数据页面用Ajax请求后渲染效果出得来。5. 前后端交互与关键细节处理5.1 Thymeleaf页面渲染的实践心得用Thymeleaf做服务端渲染最舒服的一点是后端把数据塞进Model页面直接取。比如列表页示例GetMapping(/schedule/list) public String list(RequestParam(defaultValue 1) Integer pageNum, Model model) { PageCourseSchedule page new Page(pageNum, 10); LambdaQueryWrapperCourseSchedule wrapper new LambdaQueryWrapper(); wrapper.orderByAsc(CourseSchedule::getClassDate).orderByAsc(CourseSchedule::getStartTime); PageCourseSchedule result scheduleService.page(page, wrapper); model.addAttribute(page, result); return schedule/list; }页面对应的th:each循环不需要我多讲重点说一下表单回显和校验。我踩过一个坑在编辑课程时日期和时间字段如果直接绑定到LocalDate和LocalTimeThymeleaf的th:value使用默认格式会报错。解决办法是引入thymeleaf-extras-java8time模块或者在Controller里把日期时间先格式化成字符串再传给页面。我图省事直接在Controller里把它转成了String页面用typedate和typetime的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 MapString, 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查询时自动加上deleted0条件。对于预约记录这种需要留痕的表强烈建议开启逻辑删除答辩时能说明白数据安全合规性的考量。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.formatinput 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.usernameroot --spring.datasource.passwordxxx这个命令在答辩现场很能体现你的部署经验。7.2 演示数据的准备策略一个空的系统拿去演示观感很差。我会在数据库初始化脚本里预置一批演示数据五个会员、三个教练、十门基础课程、未来两周每天若干排期。这样打开首页就是丰富的课程列表点一个课程就有得约。还可以预置几条预约记录让统计页面的图表有数据可看。预置数据时注意时间要设在未来并且避开系统当前时间。如果你写死的是2025年某一天但演示那天下课了就会出现“已结束”状态体验割裂。更好的做法是脚本里用DATE_ADD(NOW(), INTERVAL 1 DAY)这种方式动态生成。7.3 答辩演示的演示脚本我建议每场演示控制在五分钟以内顺序是作为管理员登录发布一节新课程调整名额退出用会员登录浏览课程完成一次约课回到管理员端查看这条预约记录最后展示一下个人中心的预约历史以及后台的统计报表。每个环节一两个动作就够不要过多在表单上磨叽。遇到数据库有重复数据的大大方方说“这是测试数据用来验证列表分页”。8. 写在最后项目还可以怎么延伸如果你做完这个项目还有余力我建议增加两个方向。第一个是增加“课程提醒”。在课程开始前2小时系统通过邮件或短信提醒已预约会员。思路不复杂定时任务每分钟扫一次排期表找出start_time减去当前时间等于120分钟的课程然后遍历该课程的预约用户发提醒。注意提醒状态要存起来避免重复发送。第二个是把“课程评价”加进去。课程结束后会员可以给教练打分并写评论。这个功能会多出两张表评价表和回复表也可以和课程排期表关联。加入评价功能后系统的完整性更强也更好扩展。我个人在做这个项目的过程中最大的体会是毕设选题不怕小怕的是逻辑没有闭环。瑜伽馆课程预约系统表面上是“张三预约李四的课”但背后牵扯到角色权限、时间冲突、并发扣减、状态流转每一处都值得深入挖掘。把这些细节做扎实代码量和论文深度自然就有了答辩时老师问的每个问题你都能有理有据地回答。这也是我觉得这个题目值得推荐的核心原因。如果你正在为选题发愁不妨照着这个思路动手试一试。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑