做教练培训机构的排课系统,我前后接过好几个,最典型的一个需求就是“JAVA后端 + 小程序 + 公众号 + H5”全端覆盖。这类系统真正要解决的,不是写代码的问题,而是把“教练时间”“教室资源”“学员约课”“上课通知”这一整条业务链理顺。很多培训机构还在用Excel排课、微信群约课,不仅容易撞时间,上课提醒全靠人工,学员体验也很差。这篇文章我就把这套系统的完整设计思路、核心代码片段、多端适配细节和踩过的坑一次说清楚,适合有Java基础想接外包的开发者,也适合培训机构的技术负责人评估方案。
1. 项目整体设计与技术选型
1.1 先摸清业务:排课系统的核心痛点
做系统之前,一定先搞清楚培训机构每天在经历什么。我去客户现场蹲了一天,发现他们的日常是这样的:教务老师在电脑上打开Excel,手动输入教练名字、课程时间、教室编号,然后截图发到微信群里让学员选课。教练临时调课,就要挨个通知学员。学员想约课,得翻聊天记录,约完还要担心教练是否看到。这套流程最大的问题有三个:
- 时间冲突全靠人眼判断:教练一天三节课,偶尔加一节私教课,Excel里看着不冲突,实际已经撞了。
- 预约状态不透明:学员不知道自己约没约上,教练不知道谁约了,管理员不知道满没满。
- 通知成本高:新建排课、调课、取消都需要人工发消息,漏发一次就会产生客诉。
所以系统要服务三类角色:管理员负责排课、设教室、管教练;教练查看自己的日程、确认预约;学员浏览课程、预约和取消预约。围绕这三个角色,核心功能就是:教练管理、课程管理、排课管理、预约管理、消息通知。
1.2 为什么选择JAVA后端 + 多端复用
技术选型不是一个单选题,更多是看交付团队手里有什么牌。我选的是Spring Boot + MyBatis Plus + MySQL + Redis,前端用uni-app写小程序和H5,公众号端直接用同一套H5代码通过公众号菜单跳转。这套组合有四个理由:
- Java生态稳:培训机构客户偏向传统行业,他们最担心的是“你走了代码没人维护”。Java的Spring Boot团队接管成本低,招人也容易。
- 多端共用同一个后端:小程序、公众号、H5只是前端入口不同,后端的接口、业务逻辑、数据库完全复用。只需要一套API,三端都按同样的接口规范对接。
- uni-app一套代码多端编译:小程序端和H5端共用代码仓库,公众号菜单里打开的页面其实就是H5版,不用单独再维护一套。
- Redis解决预约抢课场景:热门课程在放课瞬间可能几十个人同时点预约,Redis做缓存和计数器,MySQL做最终一致性,后面我会详细说。
如果团队只会Python或PHP,那也可以做,但遇到多端登录、并发预约这类场景时,Java社区的方案最成熟,网上能找到的排课案例也最多。
1.3 系统模块划分与工程结构
按我的习惯,后端工程不搞复杂的微服务,单体应用足够支撑培训机构几千个学员的并发量。模块上直接按业务边界拆包:
coach:教练管理,包括基本信息、资质、状态启停course:课程库,包含课程分类、时长、价格schedule:排课核心,包含排课创建、改期、取消、冲突校验booking:预约管理,包含名额占用、释放、记录查询user:用户体系,包含学员、教练、管理员的登录和权限message:消息中心,对接小程序订阅消息、公众号模板消息common:公共模块,统一返回结果、异常处理、工具类
模块之间是垂直调用的,schedule创建排课时会引用coach和course,booking预约时会校验schedule状态。这种拆法是照着业务流程走的,不绕弯,代码看起来也舒服。
2. 核心功能设计与数据库实现
2.1 数据库表设计:一切围绕时间展开
排课系统的核心是时间和资源。数据库设计的第一原则就是:把时间用绝对时间存储,不存字符串。下面是我实际项目里的核心表结构(精简过字段):
-- 教练表 CREATE TABLE `coach` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL COMMENT '教练姓名', `phone` varchar(20) DEFAULT NULL, `avatar` varchar(255) DEFAULT NULL, `status` tinyint DEFAULT '1' COMMENT '1正常 0停用', `intro` varchar(500) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='教练表'; -- 课程表 CREATE TABLE `course` ( `id` bigint NOT NULL AUTO_INCREMENT, `name` varchar(128) NOT NULL COMMENT '课程名称', `category` varchar(64) DEFAULT NULL COMMENT '课程分类', `duration_minutes` int NOT NULL COMMENT '课时长(分钟)', `price` decimal(10,2) NOT NULL, `cover` varchar(255) DEFAULT NULL, `description` text, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; -- 排课表 CREATE TABLE `schedule` ( `id` bigint NOT NULL AUTO_INCREMENT, `course_id` bigint NOT NULL, `coach_id` bigint NOT NULL, `classroom` varchar(64) DEFAULT NULL COMMENT '教室', `start_time` datetime NOT NULL, `end_time` datetime NOT NULL, `max_students` int NOT NULL DEFAULT '1' COMMENT '名额上限', `booked_count` int NOT NULL DEFAULT '0' COMMENT '已约人数', `status` tinyint NOT NULL DEFAULT '1' COMMENT '1可约 2已满 3已取消 4已结束', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_coach_time` (`coach_id`, `start_time`, `end_time`), KEY `idx_start_time` (`start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='排课表'; -- 预约表 CREATE TABLE `booking` ( `id` bigint NOT NULL AUTO_INCREMENT, `schedule_id` bigint NOT NULL, `user_id` bigint NOT NULL, `status` tinyint NOT NULL DEFAULT '1' COMMENT '1已预约 2已取消 3已上课', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `cancel_time` datetime DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_schedule_user` (`schedule_id`, `user_id`) COMMENT '同一用户同一次排课只能预约一次', KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='预约表';这里有两个设计细节要特别说明。第一个是coach_id加(start_time, end_time)联合索引,排课冲突检测靠这个索引做区间查询,效率很高。第二个是预约表的唯一约束uk_schedule_user,防止同一个用户手滑点了两次预约,这是数据库层面的兜底。
用户表我没有单独放出来,实际项目中user表会记录openid、unionid、手机号、角色等字段。小程序端通过openid关联,公众号端通过unionid关联,如果没有unionid,就通过手机号绑定合并账号。这块很容易出问题,在后面踩坑部分我会细讲。
2.2 排课冲突检测:核心中的核心
排课系统的核心算法只有一个:同一教练在同一时间段不能有两节课。这个逻辑写不好,后面所有功能都是白搭。实现方式不复杂,关键是查重的SQL怎么写。
假设要创建一个时间区间为[newStart, newEnd)的排课,教练是coachId,那么数据库中任何已有的排课如果满足“开始时间早于新结束时间,且结束时间晚于新开始时间”,就说明有冲突。SQL如下:
SELECT COUNT(*) FROM schedule WHERE coach_id = #{coachId} AND status NOT IN (3, 4) -- 排除已取消和已结束 AND start_time < #{newEnd} AND end_time > #{newStart};注意是start_time < 新结束且end_time > 新开始,这两个条件同时满足才冲突。很多新手写反,写成start_time > newStart AND end_time < newEnd,那就只能查出完全被包含的课,前后搭界的情况全漏了。
实际代码里,我会在插入排课前走一遍这个校验,同时利用MySQL事务级别REPEATABLE READ配合唯一约束。更严谨的做法是在schedule表上加一个隐藏的业务约束,但MySQL不支持自定义约束,所以一般用“事务内先查后插 + 应用层再次校验”的方式。为了处理并发创建排课的极端情况,我会在表里增加一个coach_id + start_time的联合前缀索引,并在插入时捕获Deadlock异常做重试。对于培训机构的管理员手动排课场景,并发概率不高,上面的查重SQL已经够用了。
代码层面,Service层的核心校验可以写成这样:
@Service public class ScheduleService { @Transactional(rollbackFor = Exception.class) public Long createSchedule(ScheduleCreateDTO dto) { // 1. 校验课程时长与时间区间 if (!dto.getEndTime().isAfter(dto.getStartTime())) { throw new BizException("结束时间必须晚于开始时间"); } // 2. 教练状态校验 Coach coach = coachMapper.selectByPrimaryKey(dto.getCoachId()); if (coach == null || coach.getStatus() != 1) { throw new BizException("教练不存在或已停用"); } // 3. 排课冲突校验 int conflictCount = scheduleMapper.countConflictByCoach( dto.getCoachId(), dto.getStartTime(), dto.getEndTime()); if (conflictCount > 0) { throw new BizException("该教练在当前时间段已有排课"); } // 4. 插入排课记录 Schedule schedule = new Schedule(); schedule.setCourseId(dto.getCourseId()); schedule.setCoachId(dto.getCoachId()); schedule.setStartTime(dto.getStartTime()); schedule.setEndTime(dto.getEndTime()); schedule.setMaxStudents(dto.getMaxStudents()); schedule.setBookedCount(0); schedule.setStatus(1); scheduleMapper.insertSelective(schedule); return schedule.getId(); } }这个接口是所有排课操作的唯一入口,不管是单节课、系列课还是批量生成课表,最终都要走这一个地方,这样冲突校验就不会漏。
2.3 统一API设计:三端一套接口
多端项目最容易出现的毛病是“小程序一个接口,公众号一个接口,H5又一套”,最后后端代码越来越乱。我的做法是:后端只出RESTful接口,三端按同样的风格调用。
接口规范说明如下:
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/schedule/list | 获取可约排课列表,支持按课程、日期筛选 |
| POST | /api/schedule | 管理员创建排课 |
| PUT | /api/schedule/{id}/cancel | 取消排课 |
| POST | /api/booking | 学员预约排课 |
| DELETE | /api/booking/{id} | 取消预约 |
| GET | /api/coach/me/schedules | 教练查看自己的排课 |
统一返回体:
{ "code": 0, "message": "success", "data": {} }code = 0表示成功,非0为业务异常码。三端的请求封装都要遵循这个结构,小程序端用wx.request,H5端用axios,但底层请求拦截器都会解析这个统一返回体。
接口的鉴权我用JWT,三端登录成功后拿到同一个token(实际上是用用户ID签发的),后续请求都带Authorization: Bearer <token>。小程序和公众号登录流程不同,但最终都在后端拿到了同样的JWT,所以三端对业务接口来说完全一致。
时间字段的传输要注意:前端传参统一用时间戳毫秒值,后端LocalDateTime与时间戳互转。别直接传"2026-03-12 09:00:00"这种字符串,不同手机时区、不同前端框架容易解析出错,时间戳最保险。
3. 多端前端适配实操
3.1 小程序端:登录与订阅消息
小程序端的核心入口是微信登录。流程是:wx.login()拿到code,传给后端,后端用code调微信官方接口换openid和session_key,再为这个用户生成JWT。
这里有个常见的坑:不要试图在前端拿session_key做任何业务逻辑。正确姿势是后端保存session_key,前端只保存JWT。因为session_key敏感,一旦泄露就可以解密用户手机号,安全上必须守住。
上课提醒是小程序端的一大亮点。学员预约成功后,需要弹窗让用户点击“允许”订阅上课提醒,代码大概是这样:
wx.requestSubscribeMessage({ tmplIds: ['上课提醒模板ID'], success(res) { // 用户同意后,后端存下订阅关系 } });注意:小程序订阅消息是一次性订阅,用户订阅一次只能收到一次通知,所以预约、取消、改期都要适时引导用户重新订阅。这块体验要做得很克制,不然用户会烦。
3.2 公众号H5端:网页授权与静默登录
公众号端本质上就是H5页面,通过公众号“自定义菜单”跳转到https://yourdomain.com/h5即可。但用户一旦在微信里打开这个页面,首先要做的是网页授权。
流程分两步:
- 前端引导用户跳转微信授权地址:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect - 微信回跳到
redirect_uri,带上code,后端拿code换openid,然后签发JWT。
如果想拿到用户手机号,需要用户在H5端手动绑定手机号,或者通过公众号内的微信支付获取授权手机号。这里要说明一下,snsapi_base是静默授权,用户无感知;snsapi_userinfo需要用户点击确认,现在微信已经收紧,非认证服务号基本申请不下来。所以我的方案是:能拿openid就先登录,用户预约时必须绑定手机号,否则无法接收电话通知。
公众号端还有一个坑:安全域名必须是备案过的域名,且要配置在公众号后台的“JS接口安全域名”和“网页授权域名”中。如果配置不对,页面会一直提示“redirect_uri参数错误”。
3.3 H5端在微信内外的兼容处理
H5端有两个使用场景:微信内打开、普通浏览器打开。做兼容时,我会在H5的登录入口处判断环境:
function isWechat() { const ua = navigator.userAgent.toLowerCase(); return ua.indexOf('micromessenger') !== -1; }如果是微信内,走上面的网页授权静默登录;否则走手机号+验证码登录。这样校外用户在浏览器里也能约课,体验不会断。
样式上还要注意微信浏览器底部的工具栏遮挡问题,尤其是“预约成功”那种带固定按钮的页面,要给底部留足够安全距离。微信的view-port设置也经常和普通浏览器不一样,我在项目里统一加了:
html, body { max-width: 100%; overflow-x: hidden; }避免H5页面在微信内被横向拉伸。
3.4 前后端联调细节
多端联调时,我踩过不少低级坑,整理几个关键的:
- 跨域:H5端在非微信浏览器访问接口会触发跨域,后端要配置CORS,允许特定域名和微信授权域。
- 请求头:小程序内置请求不会有
Origin头,但H5会有。后端如果配置了CORS,需要把Access-Control-Allow-Headers加上Authorization,否则JWT带不上去。 - 图片上传:微信小程序和H5的上传方式不同,小程序
wx.uploadFile,H5用的是multipart/form-data,后端接口要同时兼容这两种方式的字段名。 - 前端路由:H5端在公众号里如果用了
history模式,刷新页面时微信会重新加一次授权参数,容易把code留在URL里。建议H5路由用hash模式,或者在前端登录后立刻清除URL上的code参数。
就这一套组合,三端共用后端,联调的效率其实很高。最耗时的往往不是接口,而是微信平台本身的审核和配置。
4. 安全与性能优化
4.1 接口防刷与权限控制
排课系统面向公众开放预约,自然会被爬虫和恶意用户盯上。我遇到过一个情况:有人写脚本用假手机号在凌晨批量预约课程,把热门课的库存全部占掉,导致真实学员约不上。所以接口防刷是上线前必须做的。
具体做法:
- 创建预约接口限流:每个用户每分钟最多调用5次预约接口,用Redis的
INCR + EXPIRE实现,超过就返回错误码。 - 手机号验证码防刷:同一个手机号60秒内只能发送一次验证码,每天最多10次。
- 管理员接口权限:所有写操作接口必须校验角色。用Spring Security或简单的拦截器都可以,我习惯用拦截器扫描
@RequireRole注解,冗余但直观。
@Component public class RoleInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { RequireRole role = ((HandlerMethod) handler).getMethodAnnotation(RequireRole.class); if (role != null) { UserContext.User user = UserContext.get(); if (user == null || !user.getRole().equals(role.value())) { throw new BizException(403, "无权限访问"); } } } return true; } }教练和管理员必须走后台单独登录,不能和学员共用同一个注册入口。
4.2 缓存热门课程和排课列表
排课列表的读取频率远高于写入频率。尤其是学员打开小程序首页,第一件事就是看今天有什么课。如果每次查询都打数据库,万人在线时MySQL压力很大。
我的优化策略:
- 课程详情和排课列表在Redis中缓存10分钟,热点数据查询全部走缓存。
- 排课被预约时,同步更新缓存中的
booked_count,保持近似一致。 - 管理员手动改排课时,删除对应课程的缓存,强制刷新。
Redis存的数据要设计成适合页面列表展示的结构,我习惯用JSON存一个数组,过期时间设短一点。不要走“先删缓存再更新数据库”的经典双删策略,培训机构后台操作不频繁,简单做“更新数据库后主动踢缓存”就够。
4.3 预约超卖问题:避免多端同时抢同一节课
预约功能是整个系统并发最高的地方。比如早上10点放出一节热门私教课,只有5个名额,瞬间100个人点击预约。如果不做并发控制,就会卖出6个甚至更多名额。
最可靠的方案是利用MySQL的原子更新:
UPDATE schedule SET booked_count = booked_count + 1 WHERE id = #{scheduleId} AND booked_count < max_students;这个SQL会通过行锁保证同一时间只有一个请求能成功更新。如果影响行数为0,说明名额已经满了,直接返回“课程已约满”。
在更新排课表之前,还要先插入预约记录。为了不让一个人重复预约,预约表有唯一约束。整体事务写法和代码:
@Transactional(rollbackFor = Exception.class) public Booking booking(Long scheduleId, Long userId) { // 1. 唯一约束兜底,先查一次,再插 int repeat = bookingMapper.countByScheduleAndUser(scheduleId, userId); if (repeat > 0) { throw new BizException("您已预约过该课程"); } // 2. 尝试占用名额 int rows = scheduleMapper.tryBook(scheduleId); if (rows == 0) { throw new BizException("课程已满约"); } // 3. 插入预约记录 Booking booking = new Booking(); booking.setScheduleId(scheduleId); booking.setUserId(userId); booking.setStatus(1); bookingMapper.insertSelective(booking); return booking; }tryBook就是那条原子更新的SQL。注意:schedule表中的booked_count和max_students字段都必须用int,不要用Integer运算,避免NaN问题。MySQL 8.0之后还能用UPDATE的RETURNING子句,这样更简洁,但为了兼容5.7,我一般保持上面的写法。
虽然没有用Redis做预扣减,但爆发式并发时数据库行锁会造成排队,其实对培训机构来说完全够用。如果真要做千万级秒杀,才需要考虑Redis Lua脚本,那个成本太高,不适合这个场景。
5. 常见问题与排错实录
5.1 多端登录态不一致:同一个用户两个身份
排课系统最容易出的问题就是:同一个用户在小程序用微信登录,在公众号H5用另一个微信授权登录,结果后台出现两个账号,预约记录分散在两处。
原因:小程序端拿到的openid是 AppID 维度下的,公众号端拿到的openid是公众号 AppID 维度下的,两者不同,除非同一个微信开放平台账号下绑定了小程序和公众号,才能拿到同一个unionid。
解决方案有两种:
- 用户首次登录时,强制绑定手机号,之后以手机号为唯一身份标识。
- 有微信开放平台账号时,登录后先查
unionid,有unionid就直接关联老账号。
我实际项目中,因为客户没有开通微信开放平台,所以采用了“手机号绑定”方案:小程序登录后引导绑定手机号,公众号授权后如果检测到该手机号已有账号,则自动合并。合并时要注意把预约记录、订阅消息关联都迁到新主账号下。
5.2 排课时间跨天或隔夜问题
排课时间很容易出现“晚上20:00到21:30”这种跨天边缘情况。如果校验只比对日期,不比对时间,就会漏掉真正的冲突。
我之前遇到一个bug:教练在周一21:00有一节课,教务在周一20:30新建了一节21:30结束的课,但代码里只判断start_time是否在已有排课区间内,结果冲突没查出来,两个学员约了同一个时间。解决方法就是按我前面给的冲突SQL,完整校验区间交叉。
还有隔夜班,比如晚上22:00上课,凌晨0:30下课。这种我建议start_time和end_time都用带日期的datetime存储,不要拆成两个日期字段。跨天只是小时数变大,校验逻辑不用特判。
5.3 公众号网页授权失败:redirect_uri参数错误
这个报错90%是公众号后台配置问题。开发者拿着页面URL反复查,其实问题在后台:
- 网页授权域名必须配置成“域名”,不能带
https://,也不能带路径。 - 域名必须是你自己在微信公众平台后台填的那一个,且保证域名能正常访问。
- 测试时如果用IP地址访问,授权会失败,必须在绑定域名下访问。
另外,redirect_uri参数需要进行URL编码,而且回调地址要和请求时的redirect_uri完全一致。有一次我因为这个编码问题排查了两个小时,最后发现是?和&被前端代码拼接时解析丢了。
5.4 时区与时间格式化问题
后端部署在阿里云,默认时区可能是UTC,而数据库start_time存的是CST(中国标准时间),小程序端展示时差8小时。这个问题很隐蔽,页面上一节课明明显示的是9点,MySQL查出来却是1点。
解决办法:
- JDBC连接串加
serverTimezone=Asia/Shanghai; - 后端统一使用
LocalDateTime,不要用Date; - 给前端返回的时间字段统一为
long类型时间戳,前端再按本地时区格式化。
还有一点,如果服务器跨年,比如夏令时切换,会闹出更大的乱子。稳妥做法是所有时间统一处理成时间戳,只在最终展示层转字符串。
5.5 项目管理层面的踩坑与建议
最后说点项目管理上的体会。这类“源码交付”项目,客户往往对“排课系统”期待很高,但需求其实是模糊的。我建议在合同/需求确认阶段就把下面几件事钉死:
- 课程类型是“固定周期课”还是“自由约课”?这两者排课逻辑完全不同。
- 是否支持教练自助改期?改期后是否自动通知已预约学员?
- 付款是线上支付还是线下缴费?如果线上支付,还要对接微信支付,商户号申请周期长。
- 数据迁移:客户现有的Excel课表,是否要导入系统?
前期多问一句,后期少改十行。我在项目收尾时最大的体会是:排课系统表面是写代码,实际是把培训机构的管理规则化。同一个教练,规则可能是“每天最多带6节课,连续两节课之间需要休息30分钟”,也可能是“私教课只能由固定教练上”。这类业务规则在排课校验里都要体现。
如果这篇文章的读者也准备做类似系统,建议先花半天梳理业务规则,再动手建表。表结构设计好,后面所有功能都是往里面加逻辑。遇到紧急需求时,坚持“冲突校验走统一入口”这条原则,就不会越改越乱。