简介这套健身房预约管理系统使用 Java 语言开发后端基于 SpringBoot 2.X前端采用微信小程序数据库选用 MySQL整体功能围绕会员管理、课程预约等典型场景展开适合计算机相关专业学生用于毕业设计、课程设计或学习完整前后端联调流程。包体共 1642 个文件约 106.03MB其中 179 个 Java 源文件配合 XML/YML 配置组成后端工程132 个 WXML、132 个 WXSS 与 250 个 JS 对应小程序端173 个 Vue 和 SCSS 文件构成后台管理页面319 个 PNG、71 个 JPG 等图片素材用于页面展示另有 SQL 数据库脚本、MP4 演示视频及 DOCX 设计文档整体目录结构清晰便于按模块查阅。目前已有 401 人学习下载。除可直接运行的全套源码外资源还提供部署说明、数据库初始化脚本和常用配置脚本读者可按照文档快速搭建环境、跑通项目也能参考其中的模块划分与接口写法进行二次扩展节省自建框架和排错的时间。1. 预约不是表单健身预约管理系统的核心矛盾健身房的课程预约往往卡在“信息同步”上前台表格记录、教练微信群群接龙、会员口头预约三者各自独立。这个项目把用户端的微信小程序、管理端的SpringBoot后端、持久化的MySQL组合成一条完整链路。会员在小程序看到课程列表、提交预约、取消预约后端用接口控制课程状态和名额数据库层用唯一索引挡住重复请求。对正在选毕设题目的学生它足够简短——两个核心业务表就能跑通又能拿出“事务唯一约束”作为答辩技术点对要快速搭一个预约类小程序的工程师它也提供了可以直接复用的登录、预约、状态更新模板。这个系统不做复杂的营销和社交聚焦在预约这个单一场景正好把SpringBoot和微信小程序中最容易出问题的部分——数据一致性、登录态、部署配置——完整覆盖一遍。下面从数据库设计开始拆。2. SpringBoot后端预约表设计、唯一约束与接口分层2.1 核心表结构让冲突在数据库层就不可发生先看三张核心表。会员表保存微信openid课程表保存教练、开始时间、结束时间和最大人数预约记录表则把课程和会员关联起来。关键是预约记录上的唯一索引。如果同一个会员对同一节课重复提交无论如何都能被数据库拦下来Service层不需要再做一次“先查后插”。CREATE TABLE member ( id bigint NOT NULL AUTO_INCREMENT, openid varchar(64) NOT NULL, name varchar(32) DEFAULT NULL, phone varchar(20) DEFAULT NULL, level tinyint DEFAULT 1 COMMENT 会员等级 1普通 2高级, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_openid (openid) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id bigint NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL, coach_id bigint NOT NULL, start_time datetime NOT NULL, end_time datetime NOT NULL, capacity int NOT NULL DEFAULT 10 COMMENT 剩余名额, status tinyint DEFAULT 1 COMMENT 1可预约 0已满 2取消, PRIMARY KEY (id), KEY idx_coach_time (coach_id, start_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE appointment ( id bigint NOT NULL AUTO_INCREMENT, course_id bigint NOT NULL, member_id bigint NOT NULL, status tinyint DEFAULT 1 COMMENT 1已预约 2已取消 3已核销, create_time datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_course_member (course_id, member_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表语句里有几个点值得说使用utf8mb4是为了兼容emoji会员备注里可能出现表情openid字段加上唯一索引确保同一个微信用户被识别为唯一会员course表在(coach_id, start_time)上建普通索引查询某教练某时间是否有课会快很多。预约表的唯一索引uk_course_member是这套设计最重要的保护。实际项目中教练通常还要单独建一张coach表为了演示课程表直接引用coach_id。如果是毕设答辩可以补充说明为什么用逻辑外键而不是物理外键避免高频预约写入时的外键检查开销也能减少删除时的约束阻力。这个说法在面试中也是一个常见考点。2.2 Service层事务预约逻辑的完整边界设计好表之后Service层才是控制预约规则的地方。常见做法是把“检查课程状态、扣减名额、插入预约记录”放在一个事务里。下面这段代码是MyBatis-Plus风格的Service实现核心逻辑清晰也方便在项目里直接改。Service public class AppointmentService { Autowired private AppointmentMapper appointmentMapper; Autowired private CourseMapper courseMapper; Transactional public void createAppointment(Long courseId, Long memberId) { Course course courseMapper.selectById(courseId); if (course null || course.getStatus() ! 1) { throw new BizException(课程不存在或已停止预约); } if (course.getCapacity() 0) { throw new BizException(该课程名额已满); } Appointment appointment new Appointment(); appointment.setCourseId(courseId); appointment.setMemberId(memberId); try { appointmentMapper.insert(appointment); } catch (DuplicateKeyException e) { throw new BizException(您已预约过该课程); } Course update new Course(); update.setId(courseId); update.setCapacity(course.getCapacity() - 1); update.setStatus(course.getCapacity() - 1 0 ? 0 : 1); courseMapper.updateById(update); } }这段代码先判断课程状态和剩余名额直接插入预约记录捕获唯一索引冲突最后再更新course表的剩余名额和状态。相比“先count再insert”的方式这里已经把剩余名额作为course表的一个字段查询和更新天然更接近原子操作。不过前面的写法仍然存在并发问题两个请求同时读到capacity1然后都执行insert一个成功一个被唯一索引挡住但成功那个可能已经把capacity减成0另一个的DuplicateKeyException与名额无关提示也还合理。要真正把超卖封死需要用到第五章讲解的条件更新。Service层使用Transactional保证插入预约和更新课程在同一事务里任何一个异常都会整体回滚。这里要注意Spring默认只对RuntimeException回滚如果BizException继承自RuntimeException则没问题如果继承自Exception需要显式指定rollbackFor。2.3 接口设计与Controller层参数约定前端小程序需要的接口一般有四个课程列表、提交预约、取消预约、我的预约。为了避免传输用户id造成越权几乎所有需要身份的请求都可以从header里的token解析出memberId而不是由前端传memberId。下面给出一个Controller示例实际项目中memberId可以从拦截器里写入ThreadLocal再在Service方法里取。RestController RequestMapping(/api) public class AppointController { Resource private AppointmentService appointmentService; PostMapping(/appointment/commit) public R commit(RequestBody CommitRequest req) { Long memberId CurrentUser.get(); appointmentService.createAppointment(req.getCourseId(), memberId); return R.ok(); } }提交参数里只出现courseIdmemberId从登录态获取。这样签名简单也不容易被工具抓到越权参数。下面的接口表可以作为前后端联调的参考。接口路径方法请求参数返回说明/api/course/listGETpage, size, type课程列表含课程状态、剩余名额/api/course/detailGETcourseId课程详情含教练和时间/api/appointment/commitPOSTcourseId预约成功返回code 0/api/appointment/cancelPOSTappointmentId只允许取消“已预约”状态/api/appointment/myGET无当前会员的预约记录列表3. 微信小程序端从登录到预约完成的完整链路3.1 wx.request封装统一处理token和错误码小程序端和后端交互的第一步是封装请求函数。如果每个页面都直接wx.request后面一旦要统一增加token或处理会话过期就会改几十处。常见做法是抽一个request.js把业务状态码和HTTP状态码拆开处理。下面这段代码可以直接放进小程序的utils目录。const BASE_URL http://127.0.0.1:8080/api; const request (url, method, data) { return new Promise((resolve, reject) { wx.request({ url: ${BASE_URL}${url}, method: method || GET, data: data || {}, header: { Content-Type: application/json, token: wx.getStorageSync(token) || }, success: (res) { if (res.data res.data.code 401) { wx.removeStorageSync(token); reject(new Error(登录状态已失效请重新登录)); return; } if (res.data res.data.code ! 0) { reject(new Error(res.data.msg || 请求失败)); return; } resolve(res.data.data); }, fail: (err) { reject(err); } }); }); }; module.exports { request };这段封装把后端返回体约定为{code, msg, data}。401表示登录过期此时清理本地token并中断流程。业务上预约人数已满也会返回非0的code直接进入reject页面就能用catch统一弹toast。header里的token字段名要和后端解析的一致很多联调问题都出在大小写或命名不一致上。3.2 小程序登录wx.login换openid再换token微信小程序不像网页端可以直接拿到user_id。前端先调用wx.login获得临时code再把code发给后端。后端拿code调微信接口换取openid用openid生成业务token返回给前端。下面是一个简化版的登录逻辑放在小程序启动页或首页的onLoad里。onLoad() { wx.login({ success: async (res) { try { const data await request(/auth/login, POST, { code: res.code }); wx.setStorageSync(token, data.token); } catch (e) { wx.showToast({ title: 登录失败, icon: none }); } } }); }注意这里的request请求也会携带token但第一次登录时本地没有tokenheader为空。后端对/auth/login接口要做白名单处理不能用拦截器强制要求token否则会把自己拦在门外。这个问题在本地联调时几乎每个人都会遇到提前在这里标注一下。3.3 预约提交前端防连点后端防重预约页面的实现比较直接用户点击课程卡片进入详情再点击预约按钮提交courseId。但页面有个容易被忽略的细节按钮必须防连点。下面的代码用submitting标志位保证同一时间只有一个请求在途。Page({ data: { courseId: null, submitting: false }, submit() { if (this.data.submitting) return; this.setData({ submitting: true }); request(/appointment/commit, POST, { courseId: this.data.courseId }).then(() { wx.showToast({ title: 预约成功 }); }).catch((err) { wx.showToast({ title: err.message || 预约失败, icon: none }); }).finally(() { this.setData({ submitting: false }); }); } });前端防连点只能降低误操作概率真正防止重复预约的还是数据库唯一索引和服务端事务。如果用户通过抓包工具连续提交两次两个请求都会打到后端前者插入成功后者被DuplicateKeyException拦下来。这套“前端体验后端保证”的组合是这个项目里很典型的双保险。小程序端的课程列表字段对应关系可以整理成一张映射表方便联调时查。数据库字段小程序渲染字段说明course.namecourseName课程名称course.start_timestartTime开始时间course.end_timeendTime结束时间course.capacitycapacity剩余名额course.statusstatus课程状态0或14. 本地部署源码导入、配置修改与常见坑点4.1 环境准备拿到源码包后不要急着打开编辑器。先把基础环境对齐。开发语言是JAVA项目使用SpringBoot2.X所以JDK版本建议1.8或11。数据库使用MySQL建议5.7或8.0。小程序端用微信开发者工具。三个环境缺一不可。一个相对省事的环境检查流程是命令行输入java -version确认JDK输入mysql --version确认MySQL。随后在MySQL中新建一个数据库名字可以叫gym_appointment。接下来执行项目根目录下的数据库脚本完成建表和初始化数据。这一步在Navicat或MySQL命令行都行命令行导入更直观。mysql -u root -p gym_appointment gym_appointment.sql密码输入后如果没有报错表结构就导入完成了。注意MySQL 8.0的密码加密方式可能会让一些旧版本驱动报错如果遇到Access denied for user优先检查application.yml里的URL是否带了serverTimezoneAsia/Shanghai再检查驱动版本是否是8.x对应的com.mysql.cj.jdbc.Driver。4.2 修改 SpringBoot 配置并启动项目中的配置文件是application.yml需要修改的地方集中在数据源。下面是一份典型的本地配置注意URL里的数据库名要和上一步建好的库一致username和password改成你自己的账号密码。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/gym_appointment?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImplmybatis-plus的日志配置不是必须的但联调阶段建议打开这样每条SQL和参数都会打印在控制台排查“剩余名额没更新”这类问题非常直观。启动后端可以在IDEA里直接运行含有SpringBootApplication的主类也可以使用Maven。mvn spring-boot:run看到Started Application日志后后端就起来了。启动失败时先看控制台第一条异常大部分是数据库连接问题或端口被占用。端口被占用的话把server.port改成8081即可。4.3 小程序端配置合法域名用微信开发者工具打开项目目录下的小程序文件夹重点检查app.js里的BASE_URL。如果是后端地址写localhost:8080没问题但在真机预览时localhost指向手机本身不是电脑所以真机调试要改成电脑的局域网IP比如http://192.168.1.100:8080/api。小程序默认不允许请求未经配置的域名。开发阶段可以在微信开发者工具的“详情-本地设置”里勾选“不校验合法域名、web-view业务域名、TLS版本以及HTTPS证书”。如果要在线上发布就必须在微信公众平台的小程序管理后台配置request合法域名并且需要备案过的HTTPS域名不能用IP和端口。这里整理几个最常见的部署报错基本覆盖了环境搭建的大多数问题。现象原因解决方式连接数据库失败MySQL未启动或账号密码错误检查MySQL服务与application.ymlUnknown database gym_appointment没有创建数据库或库名不一致执行 CREATE DATABASE gym_appointment小程序请求报 url not in domain list线上环境未配置合法域名开发工具勾选“不校验合法域名”后端启动但接口404Controller未被Spring扫描检查controller包是否在启动类子包下预约时提示请勿重复预约用户已经预约过同一节课唯一索引生效属正常业务拦截5. 把预约上限压到零冲突条件更新与状态机前面章节用唯一索引解决了同一会员重复预约但两个不同会员同时抢最后一个名额时仍可能出现超卖。原因是先查capacity再update不是原子操作。解决这个问题最简单的方式是把“扣减名额”做成一条带条件的UPDATE语句。Transactional public void safeBook(Long courseId, Long memberId) { int updated courseMapper.reduceCapacity(courseId); if (updated 0) { throw new BizException(名额已满); } try { appointmentMapper.insert(new Appointment(courseId, memberId)); } catch (DuplicateKeyException e) { throw new BizException(请勿重复预约); } }对应的Mapper XML中使用一条UPDATE course SET capacity capacity - 1并且WHERE capacity 0。这条语句在MySQL中是行级原子操作多个事务同时执行时只有一个能更新成功返回影响行数为1时说明名额成功扣减返回0时说明容量耗尽。先扣减再插入预约记录即使插入失败也会通过事务回滚让capacity恢复。update idreduceCapacity UPDATE course SET capacity capacity - 1 WHERE id #{courseId} AND capacity 0 /update这里有一个隐含设计始终用capacity表示“剩余名额”而不是总名额。课程列表展示剩余名额时直接查course.capacity即可不需要count预约表。数据库里的capacity是可变数字初始值等于总名额每次预约减1取消预约加1。预约记录本身还需要一个状态机来规范取消和核销流程。比如管理员端扫描会员的预约码时只允许从“已预约”转成“已核销”不允许“已取消”直接跳成“已核销”。当前状态允许操作目标状态事务要求已预约取消预约已取消同时把course.capacity加1已预约到店核销已核销不改变capacity已取消重新预约已预约走第一次预约的完整流程状态机并不需要引入复杂的框架只用status字段配合Service层的校验就能实现。取消预约时判断status等于已预约修改为已取消并且对course执行capacity加1。核销时判断status等于已预约修改为已核销capacity不动。把所有状态流转收口到Service方法里避免小程序端直接更新status字段。到这里预约系统就同时具备了三件事数据库唯一索引防止重复预约条件更新防止超卖状态机保证业务状态可追踪。这套组合可以直接套用在自习室占座、会议室预订等类似场景。本文还有配套的精品资源点击获取
