资讯动态

Java后端+多端适配的培训机构排课系统:从数据库设计到并发预约实战

发布时间:2026/10/6 19:51:30 来源:尧图企业网站定制
做教练培训机构的排课系统我前后接过好几个最典型的一个需求就是“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和coursebooking预约时会校验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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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.requestH5端用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?appidAPPIDredirect_uriREDIRECT_URIresponse_typecodescopesnsapi_basestateSTATE#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.uploadFileH5用的是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连接串加serverTimezoneAsia/Shanghai后端统一使用LocalDateTime不要用Date给前端返回的时间字段统一为long类型时间戳前端再按本地时区格式化。还有一点如果服务器跨年比如夏令时切换会闹出更大的乱子。稳妥做法是所有时间统一处理成时间戳只在最终展示层转字符串。5.5 项目管理层面的踩坑与建议最后说点项目管理上的体会。这类“源码交付”项目客户往往对“排课系统”期待很高但需求其实是模糊的。我建议在合同/需求确认阶段就把下面几件事钉死课程类型是“固定周期课”还是“自由约课”这两者排课逻辑完全不同。是否支持教练自助改期改期后是否自动通知已预约学员付款是线上支付还是线下缴费如果线上支付还要对接微信支付商户号申请周期长。数据迁移客户现有的Excel课表是否要导入系统前期多问一句后期少改十行。我在项目收尾时最大的体会是排课系统表面是写代码实际是把培训机构的管理规则化。同一个教练规则可能是“每天最多带6节课连续两节课之间需要休息30分钟”也可能是“私教课只能由固定教练上”。这类业务规则在排课校验里都要体现。如果这篇文章的读者也准备做类似系统建议先花半天梳理业务规则再动手建表。表结构设计好后面所有功能都是往里面加逻辑。遇到紧急需求时坚持“冲突校验走统一入口”这条原则就不会越改越乱。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑