简介:一份面向毕业设计、课程设计场景的健身房私教预约小程序项目,基于Java+SSM框架与微信小程序前端实现,包含完整源码、MySQL数据库脚本和配套论文。系统覆盖用户注册登录、教练档案查看、课程预约、评价反馈以及后台管理等功能,前后端分离,代码结构清晰,已在导师指导下完成调试,下载后可直接运行。压缩包共1301个文件,约22.33MB,其中包含233个png图片、184个js脚本、139个vue组件、127个java后端类、94个wxss样式、92个wxml页面以及2个sql脚本等,涵盖前端页面、后端逻辑、配置文件与数据库初始化脚本,便于按模块查阅。目前已有40人学习下载,适用于需要参考完整项目实现或快速搭建同类型预约系统的开发者。除了可运行的完整工程,还附有论文文档(docx/doc)和部署说明,同时包含maven配置、bat启动脚本等工具,可帮助理解SSM与微信小程序整合流程、预约业务逻辑及数据库设计思路,适合作为毕设或期末项目的直接参考。
1. 这套私教预约小程序,解决的是健身房排课中最头疼的「人肉抢课」问题
做健身房私教预约系统,核心难点从来不是「小程序怎么写」,而是「预约规则怎么定」。我拆这套基于 java+ssm+mysql+微信小程序的毕设源码时,第一反应是看它的数据库表设计——私教预约牵扯到教练时间片、会员等级、课程类型、爽约惩罚,如果表结构没做好,后面写多少代码都是打补丁。这套资源里带完整源码、数据库脚本和论文,适合正在做毕设的在校生,也适合想快速搭一套「会员端预约+教练端排课+管理端统计」的健身房管理系统的开发者。它不炫技,但把 SSM 整合、微信小程序登录、预约事务处理这些关键链路走得很完整,拿来改改就能用到真实场景。下面我从骨架到踩坑,把整条链路拆给你看。
2. SSM 后端骨架搭建:从 pom 依赖到 MySQL 连接池的落地细节
2.1 SSM 整合为什么这样配:注解扫描与事务边界
SSM 是 Spring + SpringMVC + MyBatis 的组合,这套架构的核心矛盾是「三个框架各自管一件事,但需要一个人来统筹」。Spring 管 Bean 和事务,SpringMVC 管 HTTP 请求路由,MyBatis 管 SQL 映射。统筹的关键在配置类里,我惯用的做法是用一个SpringConfig注解类取代繁琐的 XML。
@Configuration @ComponentScan(basePackages = {"com.gym.service", "com.gym.dao"}) @MapperScan("com.gym.dao") @EnableTransactionManagement public class SpringConfig { @Bean public DataSource dataSource() { DruidDataSource ds = new DruidDataSource(); ds.setDriverClassName("com.mysql.cj.jdbc.Driver"); ds.setUrl("jdbc:mysql://localhost:3306/gym_reserve?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"); ds.setUsername("root"); ds.setPassword("123456"); ds.setInitialSize(5); ds.setMaxActive(20); return ds; } @Bean public SqlSessionFactory sqlSessionFactory(DataSource ds) throws Exception { SqlSessionFactoryBean bean = new SqlSessionFactoryBean(); bean.setDataSource(ds); bean.setTypeAliasesPackage("com.gym.entity"); PathMatchingResourcePatternResolver resolver = new PathMatchingResourcePatternResolver(); bean.setMapperLocations(resolver.getResources("classpath:mapper/*.xml")); return bean.getObject(); } }这段配置的逻辑是:@ComponentScan扫描 service 和 dao 包,让 Spring 管理业务层和持久层 Bean;@MapperScan告诉 MyBatis 去哪里找 mapper 接口;@EnableTransactionManagement开启注解事务,这一步在预约写入场景尤其关键。DataSource 用的是 Druid 连接池,initialSize是初始连接数,maxActive是最大活跃连接数,毕设项目 5/20 这个配置够用,如果部署到服务器且并发量高,可以调到 10/30。
这里有个容易忽略的点:mapperLocations必须指向classpath:mapper/*.xml,如果 mapper XML 文件没编译进 target 目录,运行时会报Invalid bound statement (not found),这个错我见太多人踩了。Maven 默认只把 resources 目录下的文件打包,如果你的 XML 放在src/main/java下,需要在 pom 里加<build>资源配置,否则本地 IDE 能跑,打成 war 包就废。
2.2 数据库连接与 MyBatis 映射:从建表到第一个查询
数据库脚本是这套源码里最值钱的部分。私教预约的核心表必须有 user(会员+教练统一表,用 role 字段区分)、course(私教课程)、appointment(预约记录)、schedule(教练排课时间片)。我拆的时候发现它的字段设计有讲究,比如appointment表里同时存了coach_id和member_id,没有去关联查询用户表两次,查列表时少一次 JOIN,性能更好。
MyBatis 的 mapper XML 是整套数据访问的核心,我挑最典型的「查教练时间片」来拆:
<select id="selectAvailableSchedules" resultType="com.gym.entity.Schedule"> SELECT s.id, s.coach_id, s.course_id, s.start_time, s.end_time, s.status FROM schedule s WHERE s.coach_id = #{coachId} AND s.start_time >= NOW() AND s.status = 0 ORDER BY s.start_time ASC </select>这个 SQL 里有三个关键参数:coachId是前端选中教练传入的 ID,NOW()是数据库当前时间,用来过滤已过期的排课,status = 0表示空闲状态。ORDER BY start_time ASC让最近的可用时间排在前面,小程序端直接渲染就能用。注意>=是 XML 里对>=的转义,如果不转义,MyBatis 解析 XML 会直接报错,这是 XML 写 SQL 的经典坑。
参数传递的细节也要说清楚:mapper 接口方法如果只有一个参数,直接用#{参数名}就行;如果有多个参数,必须在接口方法上用@Param("coachId")注解标明,否则 MyBatis 找不到参数。我在源码里看到它的写法是List<Schedule> selectAvailableSchedules(@Param("coachId") Integer coachId),这种写法在 SSM 里是标准做法,建议照抄。
2.3 第一个联调接口:Controller→Service→Mapper 的完整链路
SSM 的接口链路是 Controller 收请求 → Service 写业务逻辑 → Mapper 操作数据库,三层各司其职。我拿「获取私教课程列表」这个接口演示最简链路,小程序端首页要展示课程卡片,就需要这个接口。
@RestController @RequestMapping("/api/course") public class CourseController { @Autowired private CourseService courseService; @GetMapping("/list") public Result list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { PageInfo<Course> pageInfo = courseService.pageQuery(page, size); return Result.success(pageInfo); } }Controller 层只做参数接收和结果包装,@RequestParam里defaultValue是给前端兜底,用户不传页码时默认查第一页。Result是统一的返回体,源码里定义成{code, msg, data}结构,小程序端用res.data.code === 0判断业务是否成功,这个约定要和前端对好,否则两边各写各的,联调时都是泪。
Service 层在这里不展开,但要记住一个原则:事务注解@Transactional必须加在 Service 方法上,而不是 Controller 方法上。因为 Spring 用 AOP 代理 Service 层的类,Controller 是 SpringMVC 管理的,事务注解放在 Controller 上会被当成普通方法调用,完全不生效。这就是为什么有些人写了@Transactional但数据插入一半失败后没有回滚,检查一下注解位置,十有八九放错了。
3. 微信小程序端联调:登录态、请求封装与页面渲染
3.1 小程序登录链路:wx.login、openid 与 token 签发
微信小程序的登录不能直接拿wx.login返回的 code 去换自己的用户 ID,正确链路是:小程序端把 code 发给后端,后端拿 code 去微信接口换 openid,再把 openid 存进user表,返回一个自定义 token 维持会话。这套源码把流程做全了,值得照着理解。
// 小程序端 login.js wx.login({ success: async (res) => { if (res.code) { const loginRes = await request({ url: '/api/auth/login', method: 'POST', data: { code: res.code, nickname: '临时昵称' } }); wx.setStorageSync('token', loginRes.data.token); wx.setStorageSync('userInfo', loginRes.data.userInfo); } } });// 后端 AuthController 核心逻辑 @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { String openid = wechatService.code2Session(dto.getCode()); User user = userDao.selectByOpenid(openid); if (user == null) { user = new User(); user.setOpenid(openid); user.setNickname(dto.getNickname()); user.setRole(1); // 1 会员 2 教练 3 管理员 userDao.insert(user); } String token = JwtUtil.generateToken(user.getId(), user.getRole()); return Result.success(token); }这套登录设计的核心是:第一次登录时自动注册,后续登录直接发 token,省掉注册流程。JwtUtil.generateToken生成的是 JWT,里面只存用户 ID 和角色,过期时间建议设 7 天,小程序端每次请求把 token 放在 header 里。我在拆源码包时看它的 token 密钥是硬编码在配置文件里的,这种情况真实项目要改成环境变量,毕设答辩时如果老师问安全性,你能答出这点就是加分项。
3.2 请求封装与列表渲染:wx.request 的 Promise 化与加载更多
小程序原生的wx.request是回调风格,页面多了以后回调嵌套让人崩溃。常规做法是封装一层 Promise,把 baseURL、token 注入、错误处理统一收口。这套源码里应该有类似的封装,我给你看核心结构:
// utils/request.js const BASE_URL = 'http://localhost:8080/gym'; function request({ url, method = 'GET', data = {} }) { return new Promise((resolve, reject) => { wx.request({ url: BASE_URL + url, method: method, data: data, header: { 'Content-Type': 'application/json', 'token': wx.getStorageSync('token') }, success: (res) => { if (res.data.code === 0) { resolve(res.data.data); } else if (res.data.code === 401) { wx.redirectTo({ url: '/pages/login/login' }); } else { wx.showToast({ title: res.data.msg, icon: 'none' }); reject(res.data); } }, fail: (err) => reject(err) }); }); } module.exports = { request };这里有个值得说透的点:BASE_URL里的 IP 是开发环境地址,真机调试时必须改成电脑的局域网 IP,不能写 localhost,否则手机上访问的是手机自己。另外统一在success回调里判断code === 401跳登录页,比每个页面单独写登录失效判断要省事得多——这就是「统一收口」的意义,实际项目里 token 过期是高频问题,散落在各页面的判断代码后期根本维护不动。
列表加载更多是热搜词里的高频需求,小程序端惯用方案是onReachBottom页面生命周期配合分页参数:
Page({ data: { courseList: [], page: 1, size: 10, hasMore: true }, async loadCourses() { const list = await request({ url: '/api/course/list', data: { page: this.data.page, size: this.data.size } }); if (list.list.length < this.data.size) { this.setData({ hasMore: false }); } this.setData({ courseList: this.data.courseList.concat(list.list) }); }, onReachBottom() { if (this.data.hasMore) { this.setData({ page: this.data.page + 1 }); this.loadCourses(); } } });分页的核心是hasMore这个状态位:当返回的列表长度小于size时说明没有更多数据,把hasMore置为false,onReachBottom里就不再发请求。这里有个细节很多人翻车——concat是数组拼接,不能直接this.data.courseList = list.list,否则翻页后旧数据被覆盖,我刚学小程序时在这个坑上浪费过一下午。
3.3 预约表单与时间选择器:组件值绑定和提交校验
预约页面的表单是小程序端交互最重的部分,用户要选教练、选课程、选时间。这里最需要注意的是 Picker 组件的 value 是索引而不是实际值,我见过大量代码在这里出逻辑错。
<picker mode="multiSelector" bindchange="bindTimeChange" bindcolumnchange="bindColumnChange" value="{{timeIndex}}" range="{{timeRange}}"> <view class="picker-view">{{selectedTimeText}}</view> </picker>bindColumnChange(e) { const { column, value } = e.detail; if (column === 0) { // 第一列是日期,切换日期后刷新第二列(时段)的数据 this.setData({ 'timeRange[1]': this.getTimeSlotsByDate(value) }); } }multiSelector的联动逻辑是:第一列选日期,第二列选时段,切换日期时第二列的数据要重新计算。bindcolumnchange监听列变化,bindchange只监听最终确定。这里最隐蔽的坑是数据绑定路径写法:用'timeRange[1]'来更新数组的第二项,不能直接this.setData({ timeRange: newRange }),前者是局部更新,后者会把整个数组重置,导致联动失效。
提交前的校验也建议留在前端做一层:先判断用户有没有选教练、有没有选时间,再调wx.showLoading防重复点击,然后才发wx.request。后端同样要做时间校验,这就是第四章的核心内容。
4. 预约核心流程与数据库设计:状态机与并发冲突处理
4.1 五张核心表的设计:从 user 到 appointment 的字段取舍
私教预约的数据库设计我拆完感觉结构是比较典型的「用户-课程-排课-预约」四层模型,外加一张订单表。核心的是建表脚本里那些 status 字段,它们的状态流转逻辑直接决定业务代码怎么写。我先说最关键的 appointment 表:
CREATE TABLE `appointment` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL COMMENT '订单号,前端展示用', `member_id` INT NOT NULL COMMENT '会员ID', `coach_id` INT NOT NULL COMMENT '教练ID', `course_id` INT NOT NULL COMMENT '课程ID', `schedule_id` INT NOT NULL COMMENT '排课时间片ID', `appointment_date` DATE NOT NULL COMMENT '预约日期', `start_time` TIME NOT NULL COMMENT '开始时间', `end_time` TIME NOT NULL COMMENT '结束时间', `status` TINYINT DEFAULT 0 COMMENT '0待上课 1已完成 2已取消 3爽约', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `cancel_time` DATETIME DEFAULT NULL, UNIQUE KEY `uk_schedule_member` (`schedule_id`, `member_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表的字段设计有三个用心之处:order_no是业务单号,和自增主键分离,将来对账或客服查单时用单号而不是 ID;appointment_date、start_time、end_time是从 schedule 表冗余过来的字段,查列表时不需要 JOIN schedule 表就能展示;uk_schedule_member这个唯一索引是关键——同一个会员对同一个时间片只能预约一次,这是数据库层的兜底约束,应用层写漏了也插不进去。
status 字段的取值用 TINYINT 而不是 VARCHAR,省空间且查询快。这里我建议你改 set 的时候用 Java 枚举常量去对应数字,不要到处写魔法数字if (status == 2),维护起来太痛苦。
4.2 预约状态机与冲突检测:同一教练同一时段不能重复排课
状态机是预约系统的灵魂。我总结的流转规则是:0待上课 → 1已完成(正常上完课)、0待上课 → 2已取消(用户主动取消)、0待上课 → 3爽约(过了上课时间没来且没取消)。每个状态的变迁要在 Service 层做校验,不能让前端随便传一个 status 就更新。源码里应该有对应的AppointmentService方法,我建议你去看它是怎么处理取消和爽约的。
教练时间冲突检测是业务上最容易出错的地方。用户提交预约一个时间段[start_time, end_time],要保证该教练在这个时间段没有任何已预约的排课。实现思路是查交集:
SELECT COUNT(*) FROM appointment WHERE coach_id = #{coachId} AND status IN (0, 1) AND appointment_date = #{date} AND start_time < #{endTime} AND end_time > #{startTime}这个 SQL 的判断逻辑是「两个时间段有交集当且仅当 新时间段的开始 < 已有时间段的结束 且 新时间段的结束 > 已有时间段的开始」。COUNT(*)大于 0 就说明冲突。这条 SQL 是预约功能的核心中的核心,如果写反了(比如用>和<反着来),用户在高峰期反复预约同一个教练同一个时段,系统完全查不出来。
日程表 schedule 的初始化也要注意:管理员录入教练的排课计划时,通常是生成一段时间的固定排课,比如每天早上 9 点到晚上 9 点,每隔 1 小时一条记录。这个生成逻辑建议用 Service 层循环插入,不要手动一条条录,我在源码里看到它有一个ScheduleGenerator工具类,一次生成 7 天的排课数据,测试和演示都方便。
4.3 高并发预约:行级锁 FOR UPDATE 的真正用法
真正的并发场景玩家同时秒杀同一个教练的时间段,查冲突和插入之间有时间窗口,两条 SQL 同时执行可能双双通过冲突检查,造成超卖。解决方案是给冲突检测加行级锁——但要注意锁加在哪里,加错了照样没用。
@Transactional public void reserve(AppointmentDTO dto) { Schedule schedule = scheduleMapper.selectByIdForUpdate(dto.getScheduleId()); if (schedule.getStatus() != 0) { throw new BusinessException("该时间段已被预约"); } // 更新排课状态 scheduleMapper.updateStatus(dto.getScheduleId(), 1); // 插入预约记录 appointmentMapper.insert(dto); }关键在于selectByIdForUpdate这个 SQL 用了FOR UPDATE:
SELECT * FROM schedule WHERE id = #{id} AND status = 0 FOR UPDATEFOR UPDATE会对这条 schedule 记录加行级排他锁,事务提交之前其他事务的SELECT ... FOR UPDATE会被阻塞。当两个用户同时预约同一个时间片时,第二个请求会等第一个事务提交后才读到数据,此时 status 已经变成 1,冲突检测的status = 0条件不成立,直接抛异常。这就是「乐观锁不适用、悲观锁兜底」的典型案例,毕设答辩时老师问并发控制,你能把这套逻辑讲通,绝对是加分项。
注意FOR UPDATE必须放在事务里,@Transactional不能少。另外锁定的行必须能通过索引定位,如果 where 条件不走索引,InnoDB 会锁全表,并发直接卡死。这条 SQL 的查询条件是主键 id,天然走索引,没问题。
5. 避坑手册:SSM+小程序联调时最该小心的六个现场
5.1 小程序真机请求报错:xxx 不在以下 request 合法域名列表中
现象是微信开发者工具里能通,一到手机上就白屏,控制台报域名不合法。原因是微信小程序强制要求所有请求的 URL 必须是 HTTPS,且域名要在小程序后台完成备案和配置。开发阶段的处理方式在开发者工具的「详情→本地设置」勾选「不校验合法域名、web-view(业务域名)、TLS 版本以及 HTTPS 证书」,真机预览时wx.request的 host 必须改成电脑在局域网里的 IP,比如http://192.168.1.100:8080/gym,别写 localhost。
5.2 MySQL 连接报 SSL 错误或时区错误
现象是 Tomcat 启动时抛Connector cannot process the handshake或者The server time zone value is unrecognized。原因是 MySQL 8.x 默认启用 SSL,且时区默认是系统时区,JDBC 连接串里没显式声明。解决方法是把 URL 改成jdbc:mysql://localhost:3306/gym_reserve?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8,三个参数含义分别是:关闭 SSL、指定东八区、指定 UTF-8 编码。第二个参数尤其关键,否则NOW()返回的时间和本地时间可能差 8 小时,预约时间段会全部错位。
5.3 接口 404:SpringMVC 拦截了静态资源或路径前缀没对上
现象是 Controller 方法明明写了,Postman 一调就 404。常见原因有两个:第一是web.xml或配置类里 DispatcherServlet 的 url-pattern 配成了/*,导致静态资源也被拦截;第二是项目部署在 Tomcat 的 webapps 下的路径带上下文前缀,比如项目路径是/gym,那接口完整路径是http://localhost:8080/gym/api/course/list,小程序端BASE_URL必须写全这个前缀。建议把 url-pattern 配成/,同时加一个 WebMvcConfigurer 放行静态资源。
5.4 LocalDateTime 在 JSON 序列化后变成数组
现象是后端实体类用了LocalDateTime,前端拿到的时间不是"2024-06-01 10:00:00"而是[2024,6,1,10,0,0]。原因是 Jackson 默认对LocalDateTime的处理方式不对。解决方法是加依赖和配置:
@Bean public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { return builder -> builder.serializers(new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))) .deserializers(new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); }这个坑在预约记录列表的startTime字段最容易爆,不解决的话小程序端解析时间会变成数组,页面直接显示乱码。
5.5 数据库乱码:characterEncoding 和表字符集缺一不可
现象是中文昵称写入 MySQL 后变成???。原因是连接串配了characterEncoding=utf8,但建表时没指定字符集,MySQL 默认是 latin1。解决方法是建表时统一用DEFAULT CHARSET=utf8mb4,注意是utf8mb4不是utf8,前者是完整的 UTF-8,能存 emoji 表情,会员昵称带表情时不会挂。另外 MySQL 配置文件my.ini里也建议加character-set-server=utf8mb4,三处统一才能彻底解决乱码。
5.6 MyBatis XML 里使用大于号小于号导致解析异常
现象是应用启动时直接报The content of elements must consist of well-formed character data or markup。原因是 XML 里写了裸的<或>。解决方法是转义:>写成>,<写成<,或者用<![CDATA[ 原SQL ]]>包住整段 SQL。我建议复杂 SQL 用 CDATA,简单条件用转义,代码可读性更好。
6. 多走一步:用并发压测验证预约正确性,再顺手改成多门店版
资源拿到手先别急着写代码去改业务,第一步应该把环境跑起来,然后用压测去验证第四章说的并发控制逻辑是不是真的没漏。我惯用的工具是 JMeter,不需要写代码,加一个线程组就能模拟十个用户抢同一个时间段。
配置方法不复杂:线程组里设 10 个线程、循环 1 次;HTTP 请求里填POST http://localhost:8080/gym/api/appointment/reserve,参数写死同一个scheduleId;再加一个「查看结果树」监听器,跑完看返回结果。预期结果是只有 1 个请求返回成功,其余 9 个返回「该时间段已被预约」。如果出现多个成功,说明事务没生效或锁没锁对,按下面的优先级排查:第一步看@Transactional是否在 Service 方法上,第二步看FOR UPDATE是否生效,第三步看scheduleMapper的 SQL 是不是真的用了selectByIdForUpdate而不是普通selectById。
验证通过后,我建议你把这套单店系统顺手改成多门店版,这是一个高性价比的进阶练习。改动集中在三处:第一处加一张store门店表,字段只需要id、name、address,然后在user表和schedule表各加一个store_id外键字段;第二处是查询教练列表和排课列表时,所有 SQL 的 WHERE 条件都带上store_id(这里顺便练一下 MyBatis 动态 SQL 的<if>判断);第三处是小程序端首页加一个门店选择器,切换门店时把storeId作为请求参数传到后端。三步改完,这套系统从单店管理升级成连锁门店管理,作为毕设的创新点写进论文完全站得住。
最后说一个我拆毕设项目时的习惯:拿到任何一套带数据库的资源,我会先看sql脚本里的INSERT INTO语句有没有测试数据。如果只有建表语句没有数据,启动后页面全是空白,容易误判成代码 bug,实际只是库里没数据。我一般会先把 module 里涉及的管理员账号找出来(通常藏在user表的username和password字段,密码可能是 MD5 密文),登录一次把基础数据录进去,再开始走预约流程。从那以后我每次跑这套私教预约流程都强制先清一遍appointment表再重新压测,避免上一次的脏数据干扰时间冲突判断。这套资源拿来跑通、改改、答辩,是完全够用的,希望帮到你。
本文还有配套的精品资源,点击获取