☰
Java教练排课系统实战:从业务架构到三端集成部署
2026/10/9 6:08:39 网站建设 项目流程

好,我明白要求了。这是一个针对培训教练排课系统的技术实战经验分享,我会以资深开发者的口吻,从业务架构、技术选型、核心算法、三端集成的实操差异,到部署排障,完整展开这篇博文。


站点刚上线那会儿,我朋友在本地开了一家驾校教练培训基地,天天跟我倒苦水:三十几个教练,四十多门课程,排课靠Excel,改一节课要打十几个电话,教练和学员放鸽子是常态,还有学员投诉“约好的课被临时插班顶掉”。他问我能不能搞一套排课系统,我一开始以为就是做个日历管理,真正动手才发现,排课系统最难的从来不是“排”,而是“变”——变更通知、冲突检测、角色权限,随便一个都够喝一壶。

于是就有了这套JAVA教练培训课程培训教练排课系统的完整落地过程。后端采用Spring Boot + MyBatis Plus,前端覆盖微信小程序、公众号H5和独立H5端,支持教练、学员、教务三种角色,核心功能包括课程排期、预约抢课、换课请假、消息通知和排课统计。这篇文章不是项目说明书,是我把整个系统从需求到上线的完整复盘,包括踩过的坑和那些常规文档里不会写的细节。

1. 教练排课系统的业务边界:先想清楚要管哪几件事

动工之前,我把排课系统的业务拆成四块:排课、约课、变更、统计。听起来简单,但每一块都牵扯着不同的角色和流程,界面设计也完全不同。如果你也打算做类似的系统,建议先把这个边界理清,不然编码到一半就会陷入“这个功能到底属于哪一端”的泥潭。

1.1 教练、课程、学员三张主表的关系

排课系统的地基不是排课表,而是主数据。教练表、课程表、学员表这三者的关系没建对,后面一切功能都是空中楼阁。

  • 教练表:除了姓名、手机号、入职时间,关键要有教练级别和可排课时间段。驾校里教练分初级、中级、高级,不同级别能带的课程不同,排课时必须先做资格校验。
  • 课程表:课程名称肯定要有,但真正排课常用的是课程类型(理论/实操/模拟)、单节课时长(标准45分钟还是90分钟)、可预约人数(1对1还是1对多群体课)。这三个字段决定了排课的粒度和约课规则。
  • 学员表:除了基本信息,要存当前学习阶段,比如科目二训练中、科目三待考。排课推荐、课程包绑定都依赖这个字段。

我在最初的设计里忽略了一个细节:教练和课程是多对多关系,一个教练可以带课程A和课程B,同一门课程也可以由多个教练带。这个关系必须单独建一张关联表,不能图省事在教练表里存课程ID列表,不然后面做排课冲突检测的时候,SQL会写到怀疑人生。

1.2 排课的核心流程与状态流转

排课不是简单地把教练塞进某个时间段,它是一条状态链路。我的系统里用的是五态流转:

  1. 待发布(草稿):教务排好了时间、教室、教练,但学员还看不到。
  2. 已发布(可预约):学员端可见,可以开始约课。
  3. 已约满(满员):预约人数达到上限,后续学员进入候补列表。
  4. 已开始/进行中:开课后,锁定该节次的所有预约记录。
  5. 已结束:课程完成,进入课后评价和统计环节。

变更操作会打断这个流程:学员请假,节次从已约满退回已发布(释放一个名额);教练临时调课,已发布的节次要先撤回到待发布,改完时间再重新发布;已开始的节次原则上不允许取消,只能标记缺课或补课。

我在第一版代码里把状态设计成了数据库字段里的纯数字(0草稿、1发布、2约满),后来被坑惨了——运营要求统计“取消率”,但我根本没有取消状态的记录,历史数据全废。所以状态流转必须包含“撤销原因”和“操作人”这两个审计字段,哪怕原型阶段觉得多余,也一定先留好。

1.3 角色权限的几种典型划分

教练培训机构和一般培训机构不同,它的课程排布具有周期性——同一批学员会在几年内滚动培训,所以角色权限不能太粗。

  • 教务管理员:最高权限。可以创建课程、分配教练、发布/取消节次、查看所有排课表、处理换课申请。
  • 教练:只能查看自己的排课表、提交调课申请、给学员写课后评价。注意,我没有给教练直接删除节次的权限,所有变更必须走申请审批流程,这能避免教练手滑导致的排课事故。
  • 学员:在自己已报名的课程包范围内,查看可预约节次、抢课、请假、查看上课提醒。学员端不展示教练的完整课表,只展示“余量大于0”且“与自己培训阶段匹配”的节次。

权限模型我选的是RBAC(基于角色的访问控制),后端用Spring Security + JWT,给三种角色各分配一个角色标识。前端按角色动态渲染菜单和按钮,避免直接把权限判断散落在每个页面里。

2. 技术选型的底层逻辑:为什么是 Java + Spring Boot + MyBatis Plus

这套系统的技术栈不是我拍脑袋定的,是根据业务特点和团队情况权衡出来的。下面把选型理由和设计细节摊开说,顺便解答一个很多人会问的问题:为什么不用Python写个后台管理系统就完事?

2.1 后端选型考量

Java在这个场景里的优势是生态成熟、稳定、招人容易。培训排课系统不是什么高并发大流量的项目,日活可能就几千人,但它的业务规则复杂——排课冲突检测、课程包余量扣减、候补队列,这些都需要严谨的事务处理。Java + Spring Boot天然适合这种场景。

  • Spring Boot 2.7:选这个版本是因为稳定且社区资料多,Spring Boot 3要求JDK 17起步,老项目迁移成本反而高。我用的是JDK 8 + Spring Boot 2.7。
  • MyBatis Plus:这是实用主义的选择。排课系统涉及大量的多表联查和条件统计,MyBatis的XML可以写很灵活的SQL,而MyBatis Plus提供的CRUD和条件构造器又能省掉大量重复代码。尤其是它支持从实体类自动生成建表SQL,开发初期大大加速了表结构的搭建速度——不过这功能只适合提前做原型,正式环境我还是建议用Flyway管理表结构变更,History表里留痕迹,回滚时心里有底。
  • MySQL 8.0:存业务数据。排课表的数据量不会特别大,但查询条件和索引设计要上心。
  • Redis:做三件事——缓存课程日历的查询结果、存储JWT token(做主动下线用)、处理抢课时的分布式锁。

我见过很多开发者在这种项目里堆了一堆中间件,Elasticsearch、MQ、MongoDB全上,其实完全没必要。排课系统的核心是业务规则,不是数据量。单机MySQL + Redis足够撑住,省下来的维护成本可以多写几个好用的查询接口。

2.2 三端(小程序/公众号/H5)的取舍

标题里写了支持小程序、公众号、H5,这里有个技术实现的细节差别,很多人容易搞混:

  • 微信小程序:独立的客户端,运行在微信生态内。需要调用微信登录换取openid,手机号获取必须走微信的API(现在还要收费),消息触达用订阅消息。代码用原生小程序或uni-app(我用的uni-app)写了都要在微信开发者工具里跑。
  • 公众号H5:本质上是一个网页,运行在微信内置浏览器里。它走的是微信网页授权(OAuth2.0),拿的是用户在公众号下的openid,和扫码登录不是一回事。很多培训机构习惯让学员关注公众号后通过菜单进入系统,这就是公众号H5的入口。
  • 独立H5:不依赖微信,通过浏览器访问。可以是手机浏览器、电脑浏览器,也可以嵌到其他App的webview里。它的登录方案完全由自己控制,账号密码或手机验证码都行。

开发的时候注意,一套接口要同时服务三端,但登录身份体系和消息推送的适配逻辑不能混在一起写。我的做法是后端统一用userId识别用户身份,前端在登录成功后会告诉我“我是小程序端/公众号端/H5端”,后端再去调不同的微信API获取openid或发送订阅消息。这个设计让我后来对接其他终端时几乎没改代码。

2.3 数据库设计要点(表结构核心字段)

数据库是排课系统的骨架。我只把最核心的几张表列出来,给出关键字段和设计理由,方便你对照自己的项目。

教练表(coach)

CREATE TABLE coach ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) UNIQUE, level TINYINT COMMENT '1初级 2中级 3高级', hire_date DATE, status TINYINT COMMENT '1在职 2休假 3离职', created_at DATETIME, updated_at DATETIME );

课程表(course)

CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, course_type TINYINT COMMENT '1理论 2实操 3模拟', duration_minutes INT COMMENT '课程时长', max_students INT COMMENT '可预约人数上限', coach_level_required TINYINT COMMENT '最低教练级别' );

节次表(schedule),我管它叫“可预约的时间片段”:

CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_id BIGINT NOT NULL, coach_id BIGINT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, max_students INT, booked_count INT DEFAULT 0, status TINYINT COMMENT '0草稿 1已发布 2已约满 3进行中 4已结束 5已取消', cancel_reason VARCHAR(255), created_by BIGINT, created_at DATETIME, updated_at DATETIME );

一个重要的设计细节:我特意把start_time和end_time存成DATETIME,而不是拆成“日期”和“时间”两个字段。这样做的好处是冲突检测的SQL写起来直观,时区问题也更容易排查——数据库统一存UTC,应用层转北京时间。如果你用拆字段的方式,查“今天上午10点到11点的所有节次”还得拼字符串,很啰嗦。

预约表(booking)

CREATE TABLE booking ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, student_id BIGINT NOT NULL, status TINYINT COMMENT '1已预约 2已完成 3已取消 4缺课 5已补课', source TINYINT COMMENT '1小程序 2公众号 3H5', created_at DATETIME );

这里特别强调:booking表一定要记录source字段。我一开始没加,后来想做渠道来源统计,抓瞎了,只能加字段后手动补数据。任何项目里,埋点字段宁可在建表时加全,也不要等报表需求来了再改。

3. 排课冲突检测与日历呈现:最容易写崩的两个模块

如果你要复制这套系统,我建议把60%的开发精力放在排课冲突检测和日历视图上。这两个模块是排课系统的灵魂,也是bug最多的地方。

3.1 冲突检测算法与SQL写法

冲突检测要回答的问题很简单:我要给教练A在某个时间段排一节新课,他这段时间是不是已经有课了?

最直观的写法是查出教练A当天所有节次,在Java里逐条判断时间是否重叠。问题是当天节次可能有十几条,每次发布课程都全量加载,数据量一大就会卡。我的做法是把冲突检测下推到SQL里:

SELECT COUNT(*) FROM schedule WHERE coach_id = #{coachId} AND status NOT IN (5) -- 已取消的不算冲突 AND #{newStartTime} < end_time AND start_time < #{newEndTime}

这段SQL的核心逻辑是“两个时间区间存在交集”的判断条件:新开始时间 < 原结束时间 AND 原开始时间 < 新结束时间。这个写法是标准解法,也是最容易记错的——新手很容易写成newStartTime BETWEEN start_time AND end_time,那只能判断新课程起点是否落在已有课程范围里,但漏掉了新课程直接把已有课程整个包住的情况。

同理,教室冲突、同一个学员同时段约了两节课,也是同一套条件,只是换表换字段。写个公共的SQL片段,三个地方复用。

除了数据库层面的检测,我还加了一层Redis拦截。学员抢课时,后端先尝试用分布式锁锁住“学员ID + 时间段”这个key,锁不住就直接返回“该时段已有预约”。这个设计主要是防连点——按钮被恶意狂点十几下,同一毫秒发来多个请求,数据库层的查询是查不到彼此存在的,但Redis锁能挡住。

3.2 周视图/月视图的渲染思路

排课管理端的日历视图,我一开始用现成的日历组件,后来发现定制需求太多,干脆自己写渲染逻辑。核心思路是:后端一次性返回一个时间范围(比如一周或一个月)的所有节次,前端在内存里按天分组,再用绝对定位把节次块画在对应的时间轴上。

这里有一个经验之谈:日历视图的接口不要返回明细数据,返回概要就行。月视图只需要节次id、开始时间、结束时间、教练名、课程名、状态,这些字段拼成JSON传给前端就够了。等用户点击某个节次,再调用详情接口加载全部信息。如果你把学员列表、评价列表都塞进日历接口,一个月视图的数据量会冲垮渲染性能,手机端尤其明显。

时间轴渲染的另一个坑是跨天节次。夜里有课的情况下(有的驾校有夜间训练),节次的结束时间会跨过零点。前端渲染时如果按自然日分组,这个节次会丢。我的处理办法是:后端按“节次开始日期”归属分组,同时在节次对象里带上isCrossDay标记,前端渲染时跨天的节次块分成两部分绘制,中间用虚线连接。

3.3 换课、请假、补课的闭环处理

真正把系统搞复杂的不是正常排课,而是各种异常变更。列一下我处理的三条核心业务规则:

  • 学员请假:学员在开课前2小时提交请假申请,系统自动释放该节次名额,同时给教练推送一条“某某学员请假”的订阅消息。如果这时有候补学员,按候补顺序自动递补,并通知候补者“你已约课成功”。这个自动递补的逻辑要写事务里,释放名额和递补操作要么都成功,要么都失败。
  • 教练调课:教练申请把节次从周二9点改到周三10点,系统先校验新时间段是否冲突,再校验学员是否与新时间冲突(有些学员可能同时段已约其他课)。校验通过后,节次状态变为已发布,所有已预约学员会收到“课程时间调整”的消息推送。
  • 缺课与补课:学员未请假缺席,标记为缺课。系统不自动扣减课程包次数,而是生成一个补课任务,由教务在后台为学员安排新的补课节次。补课节次不计入正常排课的数量统计,这条靠字段区分(type字段标1普通/2补课)。

换课是整个系统里最容易出线上bug的地方,因为状态要变、名额要动、消息要发。我的建议是:凡涉及库存(名额)变更的接口,一律加事务注解,并提前用Redis锁锁住节次ID。不要相信前端“我只点了一次”,double-click和三端同时操作比你想象的更常见。

4. 三端集成的实战差异:小程序、公众号H5、独立H5不是复制粘贴

同名功能在三端做起来完全不同,这里是最容易让新手崩溃的部分。我把三端的核心差异拆开讲,每一端都有值得注意的细节。

4.1 微信小程序:登录手机号、订阅消息

小程序端开发用的是uni-app,但很多细节是微信生态独有的,我必须单独处理。

登录:现在的微信小程序登录已经不推荐“wx.login拿code换openid”那一套了,而是用uni.login拿code给后端,后端再用code换openid和session_key。这里有个坑:同一用户在“小程序”和“公众号”里的openid是不同的,所以后端用户表要分别存mp_openid(小程序)和gzh_openid(公众号)。如果用户同时关注了公众号和小程序,需要手动绑定,否则会被当成两个用户。

获取手机号:小程序获取手机号现在必须通过button组件的open-type="getPhoneNumber"触发,用户点击后把code传给后端,后端用这个code调微信接口换取手机号。这个code五分钟内有效且只能用一次,所以必须前端拿到后立刻传给后端,像一些项目里“前端先把code存本地,等用户填完其他信息再提交”的做法,后端必报错。

订阅消息:给学员发上课提醒、调动通知,我用的是微信订阅消息。注意订阅消息的授权是一次性的,用户点一次“允许”,你只能给他发一次消息。所以要设计好触发时机:不要节节课都发,只在关键节点——约课成功、上课前2小时、调课通知——发订阅请求。我在代码里做了个标记,同一用户同一类型的订阅消息一周内最多弹一次授权,避免反复骚扰用户被投诉。

4.2 公众号H5:网页授权与免登录

公众号H5的登录逻辑和很多后端开发者的习惯完全不同。它没有“密码”概念,走的是OAuth2.0的网页授权流程:

  1. 用户访问公众号菜单链接,后端检测到未登录,重定向到微信授权URL。
  2. 用户同意授权,微信回调后端,带上code。
  3. 后端用code换取用户的openid(和用户资料,如果你申请了用户信息权限的话)。
  4. 后端根据openid查询用户,查到则自动登录,查不到则自动注册一个“待完善资料”的账号,引导用户补填手机号。

这个流程的坑点是:微信网页授权的redirect_uri必须经过URL编码,并且域名必须和公众号后台配置的网页授权域名完全一致(不能带端口)。很多人开发时用localhost调试,授权时转圈圈,查半天发现是回调地址不对。

还有一个体验问题:公众号H5页面在微信内打开时,iOS和Android的底部导航栏处理逻辑不一致。iOS的Safari底部有工具栏,Android的微信内置浏览器没有。我试过给固定定位的元素加了safe-area-inset-bottom,在Android反而产生多余间距,最后用了环境判断,分平台写样式。

4.3 独立H5:适配与版本管理

独立H5是给那些不想通过微信进入系统的用户准备的,比如教练在电脑上查看排课表,或者学员在手机浏览器直接访问。它的技术栈和小程序端基本一致(同一套uni-app代码改条件编译),但有三件事不一样:

  • 登录方式:H5端不能用微信openid自动登录,我做了手机号+验证码登录,后端发短信验证码存在Redis里,5分钟有效。这个登录方式还兼顾了“测试环境调试”的需求,开发期不用真发短信,验证码用固定值。
  • 路由模式:H5端路由我用的hash模式,而不是history模式。原因是history模式下刷新页面会404,需要后端配合做rewrite,小项目不想在这上面磨叽。
  • 版本更新:H5端的坑是用户浏览器缓存。我发版后总有用户反馈“还是旧页面”,查了Network发现JS文件被304缓存了。解决办法是在前端打包时给文件名加MD5 hash,每次发版文件名都变,浏览器自然请求新文件。

补充一个小点:uni-app有现成的uni.getAppBaseInfo()可以获取App版本号,H5端也能用。但你在调试“用户反馈的bug是不是旧版本”时,靠这个接口做版本判断比单纯看浏览器缓存靠谱得多。

4.4 接口鉴权统一方案

三端共用一套后端接口,但登录身份体系不同,所以接口鉴权要统一。我的做法是:

  • 用户登录成功后,后端签发一个JWT token,存到Redis里,过期时间7天。
  • 前端每次请求在Authorization头带上token。
  • 后端写一个拦截器,从token里解析出userId、用户类型、终端来源。终端来源由前端在登录时指定——小程序端、公众号端、H5端——后端只在必要时才去动微信接口。

这么做的一个现实好处是:新终端接入只需要加一个登录方式,不需要动任何业务接口的鉴权代码。后端只要能识别出“这是哪个用户的哪个角色”,业务逻辑就照常跑。后来有客户问我“能不能对接钉钉”,我说能,只要加一个钉钉授权登录,后面的事都一样。

5. 部署上线后的排障经验与性能优化

系统跑起来只是第一步,上线之后才是考验。下面几个问题是我在实际运维中遇到的,按踩坑的先后顺序分享。

5.1 时区、token失效、并发抢课:三个经典坑

时区问题:数据库连接串里忘了加serverTimezone=Asia/Shanghai,导致MySQL返回的时间比实际慢了8小时。当时测试环境用的云数据库默认UTC,本地用的又是别的时区,两边数据对不上,排查了整整一个下午。检查数据库连接配置,确认时区参数一致,这是上线前必查项。

Token失效:JWT token存Redis后,面临“用户主动退出登录”和“后台强制下线”两种失效场景。我一开始只校验token的过期时间,结果用户改完密码后旧token还能用,这是个安全隐患。改成:Redis里存一个sessionId,每次请求校验Redis中的sessionId与token中的sessionId是否一致,不一致则返回401,前端自动跳登录页。

并发抢课:一门热门教练的实操课,发布后1分钟约满。用户端连点会触发多个请求,即使前端做了按钮禁用,也有用户用脚本刷接口。除了前面提到的Redis锁,我还做了第二层防护:同一节次每秒钟只允许一个预约请求通过,超出的直接返回“请稍后重试”。

String lockKey = "schedule:lock:" + scheduleId; boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 3, TimeUnit.SECONDS); if (!locked) { throw new BizException("手速太快了,稍等一下再试"); }

3秒的锁时间足够完成一次预约事务,加上事务回滚兜底,实测下来抢课高峰期没有出现过超卖。

5.2 查询性能优化:从“踩着单车”到“开着跑车”

运营后台有一个“教练月度课表”页面,查一个月所有节次并统计教练工作量。一开始SQL是全表扫描加内存统计,项目上线一个月数据量到四万条时页面就要3秒多才出数据。优化的核心是索引和SQL改写:

CREATE INDEX idx_schedule_coach_time ON schedule(coach_id, start_time, status);

加上这个复合索引后,同样的查询从3秒降到300毫秒以内。原因很简单:MySQL可以先用coach_id定位教练,再用start_time定位时间范围,最后用status过滤已取消的记录,不用全表扫。

Redis缓存我也用上了。日历接口的响应体被整个缓存10分钟,key的设计是schedule:calendar:{coachId}:{weekStart},教练排课有变动时主动删除对应缓存。这个方案在处理“教务改完课,教练端立刻看到最新课表”的需求里表现不错——改课接口里顺手删一下缓存即可。

5.3 数据统计与报表:给运营一个“驾驶舱”

系统上线两个月之后,运营提了一个新需求:统计每个教练的课时收入、学员课时消耗率、科目通过率。这批数据靠业务表临时查是查不动的,而且口径不统一,报表和报表之间对不上。

我的解决办法是一张日汇总表:

CREATE TABLE daily_summary ( id BIGINT PRIMARY KEY AUTO_INCREMENT, stat_date DATE NOT NULL, coach_id BIGINT NOT NULL, course_count INT COMMENT '授课节次', student_count INT COMMENT '覆盖学员数', booked_minutes INT COMMENT '授课总时长(分钟)', cancel_count INT COMMENT '取消节次', created_at DATETIME, UNIQUE KEY uk_date_coach (stat_date, coach_id) );

每天晚上10点跑定时任务,把当天的上课记录按教练维度汇总进来。报表查询直接查这张表,不管数据量多大,只要按日期加索引筛选,秒出结果。不要试图在报表页面实时统计业务表,那样做不仅慢,而且会把业务数据库拖垮。

5.4 扩展方向:从排课系统到预约中台

系统稳定运行后,我开始把它抽象成可复用的能力——不仅是教练培训,还可以是健身私教预约、美容师排班、维修工上门排期。核心改动思路是:

  • 把教练、课程、学员抽象成资源、服务、客户,业务字段下沉到扩展属性表。
  • 排课引擎独立成模块,冲突检测、通知派发、状态流转全在这里。
  • 消息通道改造成适配器模式:小程序订阅消息、公众号模板消息、短信、App推送,都实现同一个接口,新增通道只加实现类。

架构调整不大,但价值立刻体现。后来一个做健身房的客户找到我,说也想要一套“私教排课”,我直接在原系统上加了一套场馆配置和私教套餐,三周就交付了。

最后,给准备上手这类项目的人几句实在话:先花一周梳理业务,把状态流程、角色权限、异常规则定清楚,写代码反而是其次。如果业务规则没理清,技术栈选得再花哨也白搭。这套系统经历过日均几百次预约、上百条调课申请的考验,稳定性和实用性是可以放心的。照着这套思路做,你可以少走我走过的所有弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询