简介这是一套面向高校计算机专业学生的高分毕业设计完整项目主题为基于JavaSSMMySQL微信小程序的健身房私教预约系统适合用作毕设、课程设计或期末大作业下载后无需修改即可运行。资源包共1301个文件约22.33MB涵盖Java后端源码、微信小程序前端页面、MySQL数据库脚本及论文文档其中java文件承载SSM业务逻辑js、vue、wxml、wxss构成前后端界面sql脚本负责建库建表另附bat启动脚本、properties配置与docx论文目录结构清晰便于按模块查阅。项目已通过导师指导与严格调试功能覆盖用户注册登录、教练档案与课程表查看、在线预约、教练评价及后台课程、教练、用户管理并采用权限控制保障数据安全。目前已有40人学习关注适合希望快速获得可运行、可答辩、可二次开发完整方案的读者参考借鉴。1. 健身房私教预约小程序从 SSM 后端到微信小程序的完整落地路径健身房的私教排课一直是个体力活。前台用纸质本子记教练在微信群里喊会员到店才发现自己约的时段被占了。我见过一家中型健身房六个私教、日均四十节课靠一个 Excel 表格轮转月底对账时发现漏记了十七节课。这个场景催生了「基于 JavaSSMMySQL微信小程序的健身房私教预约小程序」这类毕设项目的真实需求——它不是玩具而是一套能跑通「会员选课→教练确认→到店核销→课时结算」的最小闭环系统。这套技术栈的选择有它的道理。SSMSpring SpringMVC MyBatis是国内 Java Web 教学和中小型项目里最成熟的组合分层清晰、资料多、调试直观MySQL 负责持久化会员、教练、课程、订单四类核心数据微信小程序作为前端入口免安装、扫码即用对健身房这种到店场景天然契合。适合谁看正在做课程设计或毕设的计算机专业学生、想快速搭一套预约系统的初级开发者、以及需要理解「小程序Java 后端」如何联调的全栈入门者。接下来我会按「数据怎么设计→后端怎么搭→小程序怎么接→坑在哪」的顺序把每个环节的可复现细节讲清楚。2. 数据库设计与 SSM 分层四张核心表撑起预约逻辑2.1 会员、教练、课程、订单的字段与关系预约系统的本质是「谁在什么时间跟谁上了什么课」拆开就是四张主表加若干关联。我一般会这样设计表名核心字段说明memberid, openid, nickname, phone, balance, create_time会员表openid 是小程序登录唯一标识coachid, name, specialty, price_per_hour, status, intro教练表status 控制是否接单course_scheduleid, coach_id, course_date, start_time, end_time, capacity, booked排课表capacity 是容量booked 是已约人数appointmentid, member_id, schedule_id, status, create_time, verify_time预约订单表status 走 0待确认/1已确认/2已核销/3已取消关系上appointment通过member_id和schedule_id分别关联会员与排课course_schedule通过coach_id关联教练。这里有个容易忽略的点排课表的 booked 字段和预约表的记录必须保持一致否则会出现「显示约满但实际能约」的玄学问题。我的做法是在预约成功时用事务同时更新两张表。CREATE TABLE course_schedule ( id INT NOT NULL AUTO_INCREMENT, coach_id INT NOT NULL COMMENT 教练ID, course_date DATE NOT NULL COMMENT 上课日期, start_time TIME NOT NULL, end_time TIME NOT NULL, capacity INT DEFAULT 1 COMMENT 可约人数, booked INT DEFAULT 0 COMMENT 已约人数, PRIMARY KEY (id), KEY idx_coach_date (coach_id, course_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时给coach_id course_date建联合索引是因为小程序端最高频的查询就是「某教练某天的排课」没有索引时数据量上千后列表加载会明显变慢。字符集统一用utf8mb4避免会员昵称里的 emoji 存进去变问号。2.2 SSM 三层结构怎么分Controller 只做参数校验SSM 的分层不是形式主义分错了后期改需求会非常痛苦。我的习惯是Controller 层只接收小程序请求、校验必填参数、调用 Service、封装返回体。不写业务逻辑不直接调 Mapper。Service 层事务边界在这里预约的「查余量→扣名额→写订单」三步必须在一个Transactional方法里。Mapper 层只做 SQL 映射复杂查询写在 XML 里简单 CRUD 用注解。Service public class AppointmentServiceImpl implements AppointmentService { Autowired private CourseScheduleMapper scheduleMapper; Autowired private AppointmentMapper appointmentMapper; Override Transactional(rollbackFor Exception.class) public Result book(Integer memberId, Integer scheduleId) { // 1. 查排课并加行锁防止并发超约 CourseSchedule schedule scheduleMapper.selectForUpdate(scheduleId); if (schedule null) { return Result.fail(课程不存在); } // 2. 校验余量 if (schedule.getBooked() schedule.getCapacity()) { return Result.fail(该时段已约满); } // 3. 扣名额 写订单 scheduleMapper.increaseBooked(scheduleId); Appointment appt new Appointment(); appt.setMemberId(memberId); appt.setScheduleId(scheduleId); appt.setStatus(0); appt.setCreateTime(new Date()); appointmentMapper.insert(appt); return Result.success(预约成功); } }这段代码的关键在selectForUpdate它在查询时给行加了排他锁两个会员同时点「预约」时后一个会等前一个事务提交后再读从而避免超约。参数上rollbackFor Exception.class保证任何异常都回滚不然扣了名额没写订单就成了脏数据。很多同学这里只写Transactional不加 rollbackFor遇到检查型异常时事务不回滚是典型的翻车点。2.3 MyBatis 映射文件与分页查询排课列表要按日期筛选并分页用 PageHelper 是最省事的做法。Mapper 接口定义好方法XML 里写 SQLselect idselectByDate resultTypecom.gym.entity.CourseSchedule SELECT cs.*, c.name AS coachName, c.specialty FROM course_schedule cs LEFT JOIN coach c ON cs.coach_id c.id WHERE cs.course_date #{date} ORDER BY cs.start_time ASC /selectService 里调用前加一行PageHelper.startPage(pageNum, pageSize)即可自动分页。注意LEFT JOIN而不是INNER JOIN因为教练被停用后历史排课仍需展示用内连接会直接丢数据。返回的coachName需要在实体类里加对应字段接收否则前端拿不到教练名字列表里全是 null。3. 微信小程序端登录、选课、核销三个页面的实现3.1 wx.login 换 openid 的完整链路小程序的登录不能直接拿用户信息必须先wx.login拿 code传给后端换 openid。后端用 code 调微信接口返回 openid 和 session_key再把 openid 存进 member 表作为唯一标识。// 小程序端 app.js wx.login({ success: (res) { if (res.code) { wx.request({ url: https://your-domain.com/api/login, method: POST, data: { code: res.code }, success: (resp) { // 后端返回自定义 token存入缓存 wx.setStorageSync(token, resp.data.token); wx.setStorageSync(memberId, resp.data.memberId); } }); } } });后端拿到 code 后用appid secret code请求微信的jscode2session接口。这里有个坑secret 绝对不能放在小程序端必须由后端保管。另外wx.setStorageSync存 token 时要注意小程序缓存默认没有过期时间需要自己记录时间戳判断否则用户换了账号 token 还是旧的。3.2 排课列表与预约按钮的交互选课页的核心是一个按日期切换的列表。顶部放日期选择器下面渲染当天排课每条显示教练名、时间段、剩余名额余量为 0 时按钮置灰。Page({ data: { date: , schedules: [] }, onLoad() { const today new Date().toISOString().slice(0, 10); this.setData({ date: today }); this.loadSchedules(today); }, loadSchedules(date) { wx.request({ url: https://your-domain.com/api/schedule/list, data: { date: date }, header: { token: wx.getStorageSync(token) }, success: (res) { this.setData({ schedules: res.data.list }); } }); }, onBook(e) { const scheduleId e.currentTarget.dataset.id; wx.request({ url: https://your-domain.com/api/appointment/book, method: POST, data: { scheduleId: scheduleId }, header: { token: wx.getStorageSync(token) }, success: (res) { wx.showToast({ title: res.data.msg, icon: res.data.code 200 ? success : none }); if (res.data.code 200) this.loadSchedules(this.data.date); } }); } });header里带 token 是每个请求都要做的建议封装一个request.js统一处理不然每个页面都手写一遍改起来想哭。预约成功后重新拉列表刷新余量这一步不能省否则用户看到的名额还是旧的。3.3 到店核销扫码或数字码的验证核销环节我一般用「教练端输入会员的六位数字码」或「会员出示二维码教练扫码」两种方式。数字码实现简单适合毕设演示。后端生成订单时同时生成一个六位随机码存进 appointment 表教练端调核销接口时校验码是否匹配且状态为已确认。PostMapping(/verify) public Result verify(RequestParam String code, RequestParam Integer coachId) { Appointment appt appointmentMapper.selectByCode(code); if (appt null) { return Result.fail(核销码无效); } if (appt.getStatus() ! 1) { return Result.fail(该订单状态不可核销); } // 校验是否是该教练的课 CourseSchedule cs scheduleMapper.selectById(appt.getScheduleId()); if (!cs.getCoachId().equals(coachId)) { return Result.fail(无权核销此课程); } appt.setStatus(2); appt.setVerifyTime(new Date()); appointmentMapper.updateById(appt); return Result.success(核销成功); }状态判断是重点只有status1已确认的订单才能核销待确认和已取消的都不行。教练权限校验也不能漏否则 A 教练能核销 B 教练的课数据就乱了。4. 避坑与排查联调阶段最容易翻车的五个点4.1 小程序请求报「不在合法域名列表」现象开发者工具里请求正常真机预览时报错接口全部失败。原因微信要求所有请求域名必须在后台配置白名单且必须是 HTTPS。解决登录小程序管理后台在「开发设置→服务器域名」里把后端域名加进 request 合法域名。本地调试时勾选「不校验合法域名」但上线前必须配好。域名不能带端口必须是 443。4.2 MySQL 连接报 error 2002 或 SSL 错误现象后端启动时报Cant connect to local MySQL server through socket或 SSL 相关异常。原因前者通常是 MySQL 服务没启动或 socket 路径配置不对后者是连接串里开了 SSL 但服务端没配证书。解决先systemctl status mysql确认服务在跑。连接串里加useSSLfalseserverTimezoneAsia/Shanghai时区不设会导致时间差八小时排课日期全错。4.3 预约并发导致超约现象容量为 1 的课程被两个人同时约上。原因查询余量和扣减之间没有锁两个请求都读到 booked0。解决用SELECT ... FOR UPDATE加行锁或者用UPDATE course_schedule SET booked booked 1 WHERE id ? AND booked capacity这种原子更新根据影响行数判断是否成功。后者性能更好推荐。4.4 时间字段前后端格式不一致现象小程序传2024-05-01到后端存进数据库变成2024-04-30 16:00:00。原因时区没统一或者日期格式没加DateTimeFormat注解。解决实体类日期字段加JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)连接串统一时区小程序端传字符串时保持yyyy-MM-dd格式。4.5 核销后余量没释放现象会员取消预约后课程余量没有恢复名额白白浪费。原因取消逻辑只改了订单状态忘了把booked减回去。解决取消接口里同样用事务UPDATE course_schedule SET booked booked - 1 WHERE id ? AND booked 0和订单状态更新放在一起。这个坑我在两个项目里都踩过血泪经验就是任何改变订单状态的操作都要同步检查排课表的计数。5. 进阶技巧用定时任务和缓存把系统做稳5.1 用 Spring Task 自动关闭过期未核销订单会员约了课但没到店订单一直挂在「已确认」状态教练端看着乱。我一般加一个定时任务每天凌晨把前一天未核销的订单自动置为「已完成」或「已过期」同时释放名额。Component public class OrderExpireTask { Autowired private AppointmentMapper appointmentMapper; Autowired private CourseScheduleMapper scheduleMapper; // 每天凌晨2点执行 Scheduled(cron 0 0 2 * * ?) Transactional(rollbackFor Exception.class) public void closeExpiredOrders() { // 查出昨天之前所有状态为已确认的订单 ListAppointment list appointmentMapper.selectExpired(new Date()); for (Appointment appt : list) { appt.setStatus(3); // 置为已过期 appointmentMapper.updateById(appt); // 释放排课名额 scheduleMapper.decreaseBooked(appt.getScheduleId()); } } }cron 表达式0 0 2 * * ?表示每天两点执行。注意要在 Spring 配置里加EnableScheduling否则注解不生效。这个任务处理的是「昨天及以前」的订单当天的不能动因为可能还有会员在路上。5.2 用 Redis 缓存排课列表降低数据库压力排课列表是读多写少的典型场景每次请求都查库没必要。我一般把「某教练某天的排课」缓存 5 分钟预约或取消时主动删缓存。缓存键内容过期时间失效时机schedule:coach:{id}:{date}排课列表 JSON300 秒预约/取消/改排课时删除coach:list:all教练列表600 秒教练信息变更时删除public ListCourseSchedule getSchedules(Integer coachId, String date) { String key schedule:coach: coachId : date; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseArray(cached, CourseSchedule.class); } ListCourseSchedule list scheduleMapper.selectByCoachAndDate(coachId, date); redisTemplate.opsForValue().set(key, JSON.toJSONString(list), 300, TimeUnit.SECONDS); return list; }缓存时间设 300 秒是个折中太短起不到减压作用太长会出现「刚约满但列表还显示有名额」的错觉。预约成功后主动redisTemplate.delete(key)保证下次读的是最新数据。如果项目规模小、并发低这一步可以省略但理解这个思路对后续扩展有帮助。5.3 一个我坚持的习惯接口返回统一结构不管多小的项目我都会定义一个Result类包含code、msg、data三个字段。小程序端只判断code 200其余一律走错误提示。这样前端不用为每个接口写不同的解析逻辑后端改返回格式时也只动一处。看起来是小事但联调时能省下大量沟通成本。希望帮到你。本文还有配套的精品资源点击获取