ARTICLE · 1358192
JAVA同城服务训练排课课程排课系统源码支持小程序+公众号+H5
|
训练机构做排课时,卡点通常不在课表本身,而在课表之前:门店营业范围不清、课程与课时口径不一致、教练与员工职责混在一起,教室到点才发现被占用。
这套系统的做法是先把资源模型固定:门店、课程、员工、教练、商家和教室各有主数据,排课只引用已确认的资源。后台维护状态和归属,用户端看到的是同一套可售课程与可约时间。
下面按六个功能点讲清后台怎么管,再给七段关键代码,覆盖表结构、商家入驻、课程发布、资源校验、排课事务、多端请求与后台列表。
OVERVIEW / 功能总览
六个模块,管住排课前的资源基础
功能点按管理动作顺序展开:先有门店与商家,再定义课程,然后维护员工、教练与教室,最后这些资源进入排课和用户选购。
|
01门店管理
输入:输入门店名称、经营地址、营业时间、服务范围和状态。
状态:门店有草稿、营业、暂停与关闭状态,停业门店不再接受新排课和用户下单。
输出:输出门店主页、课程归属和数据范围;历史订单继续保留成交时的门店快照。

【训练排课】实拍 01|首页课程、教室场馆与教练介绍;截图数据为演示数据
02课程管理
输入:输入课程名称、适龄、课时、价格、上课地点和退款说明。
状态:课程分待上架、已上架、已下架;课时包与单次体验课使用同一套课程主数据。
输出:输出用户端课程详情与后台可排课清单;改价不改已支付订单,商品快照独立保存。
03员工管理
输入:输入员工姓名、所属门店、岗位、手机号和登录账号。
状态:账号可启用、停用;岗位决定后台菜单与可查询的门店数据范围。
输出:输出员工工作台权限和操作日志;离店停用后不能继续登录或审批。

【训练排课】实拍 02|课程列表、用户中心与课程详情;截图数据为演示数据
04教练管理
输入:输入教练资料、擅长领域、可授课程、资质附件与可用时间。
状态:教练状态与门店、课程分开维护,停用教练不再出现在用户端和排课候选中。
输出:输出教练主页、可约时段和排课约束;排课时自动校验教练是否具备该课程授课资格。
05商家入驻
输入:商家提交主体信息、联系人、经营资质与拟经营门店。
状态:入驻经历提交、审核、签约、开通四个状态,审核结果与材料版本都留痕。
输出:输出可管理的门店和员工范围;未签约商家不能发布课程或触达用户。

【训练排课】实拍 03|教练主页、购买订单与订单详情;截图数据为演示数据
06教室管理
输入:输入教室名称、所属门店、容量、设备、开放时段和停用时间。
状态:教室状态有开放、维修和关闭;同一时段被占用后不允许再次分配。
输出:输出排课可用教室集合和冲突提示;停用信息同步到用户端,避免约到不可用场地。
ARCHITECTURE / 三端分工
一套资源主数据,三端各管一段
|
一句话分工:前端负责选择和展示,后台负责资源维护与审核;能否排课由服务端依据门店、教练、教室和课程资格共同判断。
CODE / 数据库与核心代码
七段关键代码:从主数据到排课事务
按「表结构 → 商家入驻 → 课程发布 → 资源校验 → 排课事务 → 多端请求 → 后台列表」给出可落地的示例。
以下均为示例实现,生产环境需补充参数校验、权限、日志、监控、限流、支付验签等。
01六类资源各自留痕
门店、课程、教练和教室一旦共表,状态变更就无法追溯。
CREATE TABLE merchant ( id BIGINT PRIMARY KEY AUTO_INCREMENT, merchant_no VARCHAR(32) NOT NULL UNIQUE, legal_name VARCHAR(128) NOT NULL, contact_phone VARCHAR(20), audit_status VARCHAR(20) NOT NULL, version INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL);CREATE TABLE training_store ( id BIGINT PRIMARY KEY AUTO_INCREMENT, merchant_id BIGINT NOT NULL, store_name VARCHAR(128) NOT NULL, business_status VARCHAR(20) NOT NULL, open_time VARCHAR(64) NOT NULL, version INT NOT NULL DEFAULT 0, KEY idx_merchant (merchant_id));CREATE TABLE course_product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, store_id BIGINT NOT NULL, course_name VARCHAR(128) NOT NULL, lesson_count INT NOT NULL, sale_price DECIMAL(10,2) NOT NULL, sale_status VARCHAR(20) NOT NULL, version INT NOT NULL DEFAULT 0, KEY idx_store_status (store_id, sale_status));CREATE TABLE coach ( id BIGINT PRIMARY KEY AUTO_INCREMENT, store_id BIGINT NOT NULL, coach_name VARCHAR(64) NOT NULL, enabled TINYINT NOT NULL DEFAULT 1, KEY idx_store (store_id));CREATE TABLE classroom ( id BIGINT PRIMARY KEY AUTO_INCREMENT, store_id BIGINT NOT NULL, room_name VARCHAR(64) NOT NULL, capacity INT NOT NULL, room_status VARCHAR(20) NOT NULL, KEY idx_store_status (store_id, room_status)); |
商家主体、门店、课程、教练与教室分表;关键字段建索引,逻辑删除保留历史。
02商家入驻走状态机
入驻不是一次表单提交,而是审核和签约的连续状态。
@Transactional(rollbackFor = Exception.class)public void submitEntry(MerchantEntryCmd cmd) { Merchant old = merchantMapper.selectOne( Wrappers.lambdaQuery(Merchant.class) .eq(Merchant::getLegalName, cmd.getLegalName())); Assert.isNull(old, "主体已提交入驻"); Merchant merchant = Merchant.submitted( cmd.getLegalName(), cmd.getContactPhone()); merchantMapper.insert(merchant); materialService.saveVersion( merchant.getId(), cmd.getMaterials(), 1); auditLogMapper.insert(AuditLog.submit( merchant.getId(), cmd.getOperator()));}@Transactional(rollbackFor = Exception.class)public void audit(Long merchantId, AuditResult result) { Merchant merchant = merchantMapper .selectById(merchantId); Assert.notNull(merchant, "商家不存在"); String next = result.isPass() ? "WAIT_SIGN" : "REJECTED"; int changed = merchantMapper.changeStatus( merchantId, merchant.getAuditStatus(), next, merchant.getVersion()); if (changed != 1) { throw new BizException("入驻状态已变更"); } auditLogMapper.insert(AuditLog.of( merchantId, result, next));} |
状态条件更新防止审核并发覆盖;材料按版本保存,审核结果写日志。
03课程发布回查门店
课程可以被多个接口入口触达,发布前必须确认门店仍可经营。
@Transactional(rollbackFor = Exception.class)public void publish(Long merchantId, CoursePublishCmd cmd) { Store store = storeMapper.selectById( cmd.getStoreId()); Assert.notNull(store, "门店不存在"); Assert.isTrue(store.getMerchantId() .equals(merchantId), "无权管理该门店"); Assert.isTrue("BUSINESS".equals( store.getBusinessStatus()), "门店未营业"); CourseProduct course = CourseProduct .fromCommand(cmd); course.setStoreId(store.getId()); course.setSaleStatus("ON_SALE"); courseMapper.insert(course); courseVersionMapper.insert( CourseVersion.snapshot(course)); operateLogMapper.insert(OperateLog.publish( merchantId, course.getId()));} |
价格与课时由服务端写入,下架只影响后续售卖,不改历史订单。
04排课前校验人员资源
教练可授课程、教室容量和开放时间必须同时满足,缺一都会产生履约冲突。
public ScheduleCheck check( ScheduleQuery query) { CourseProduct course = courseMapper .selectById(query.getCourseId()); if (course == null || !"ON_SALE" .equals(course.getSaleStatus())) { return ScheduleCheck.fail("课程不可排"); } CoachCoach coach = coachMapper.selectById( query.getCoachId()); if (coach == null || coach.getEnabled() != 1 || !coachCourseMapper.exists( coach.getId(), course.getId())) { return ScheduleCheck.fail("教练不可授"); } Classroom room = roomMapper.selectById( query.getRoomId()); if (room == null || !"OPEN".equals(room.getRoomStatus())) { return ScheduleCheck.fail("教室不可用"); } if (room.getCapacity() < query.getStudentCount()) { return ScheduleCheck.fail("教室容量不足"); } boolean occupied = scheduleMapper.exists( query.getRoomId(), query.getStartAt(), query.getEndAt()); return occupied ? ScheduleCheck.fail("教室时段占用") : ScheduleCheck.ok(course, coach, room);} |
校验结果区分不可授、时间冲突与教室占用,便于前端给出准确提示。
05排课事务与占用控制
课表、教练时段与教室占用必须同事务写入,否则会出重复占用。
@Transactional(rollbackFor = Exception.class)public String createSchedule( ScheduleCreateCmd cmd) { ScheduleCheck check = checker.check(cmd); if (!check.isOk()) { throw new BizException(check.getReason()); } String requestNo = cmd.getRequestNo(); Schedule old = scheduleMapper.selectOne( Wrappers.lambdaQuery(Schedule.class) .eq(Schedule::getRequestNo, requestNo)); if (old != null) return old.getScheduleNo(); Schedule schedule = Schedule.of( cmd, check.getCourse(), check.getCoach(), check.getRoom()); scheduleMapper.insert(schedule); int changed = roomTimeMapper.occupy( cmd.getRoomId(), cmd.getStartAt(), cmd.getEndAt()); if (changed != 1) { throw new BizException("教室占用失败"); } coachTimeMapper.occupy( cmd.getCoachId(), cmd.getStartAt(), cmd.getEndAt()); return schedule.getScheduleNo();} |
唯一时间占用与条件更新双层兜底;取消课程按原记录释放资源。
06多端请求统一封装
小程序、公众号和H5的登录态、错误处理和请求号应保持一致。
const newRequestNo = () => { const time = Date.now().toString(36) const rand = Math.random() .toString(36).slice(2, 10) return `${time}-${rand}`}export function request(options) { const token = uni.getStorageSync('token') return new Promise((resolve, reject) => { uni.request({ url: options.url, method: options.method || 'GET', data: options.data || {}, header: { Authorization: token ? `Bearer ${token}` : '', 'X-Request-No': options.requestNo || newRequestNo() }, success: (res) => { if (res.statusCode === 401) { uni.removeStorageSync('token') uni.reLaunch({ url: '/pages/login/index' }) reject(new Error('请先登录')) return } if (res.data.code !== 0) { reject(new Error(res.data.message)) return } resolve(res.data.data) }, fail: reject }) })} |
统一携带令牌与请求号,401清登录态,业务页面只关心结果对象。
07后台列表按门店取数
门店、教练和课程列表要能筛、能管,还要受管理员数据范围限制。
<template> <div> <el-form :inline="true" :model="query"> <el-input v-model="query.orderNo" placeholder="订单号" clearable /> <el-select v-model="query.status" placeholder="状态" clearable> <el-option label="待付款" value="WAIT_PAY" /> <el-option label="待发货" value="WAIT_SHIP" /> <el-option label="待收货" value="SHIPPED" /> <el-option label="已完成" value="FINISHED" /> </el-select> <el-button type="primary" @click="load">查询</el-button> </el-form> <el-table :data="rows" v-loading="loading"> <el-table-column prop="orderNo" label="订单号" /> <el-table-column prop="shopName" label="店铺" /> <el-table-column prop="payAmount" label="实付金额" /> <el-table-column prop="statusName" label="状态" /> <el-table-column label="操作"> <template #default="scope"> <el-button type="text" v-if="scope.row.canShip" @click="ship(scope.row)"> 发货 </el-button> </template> </el-table-column> </el-table> <el-pagination :total="total" layout="total, prev, pager, next" :page-size="query.size" :current-page="query.page" @current-change="load" /> </div></template><script setup>import { reactive, ref, onMounted } from 'vue'import { getOrders, shipOrder } from '@/api/order'const query = reactive({ page: 1, size: 20 })const rows = ref([])const total = ref(0)const loading = ref(false)async function load(page = 1) { query.page = page loading.value = true try { const data = await getOrders(query) rows.value = data.records total.value = data.total } finally { loading.value = false }}async function ship(row) { await shipOrder(row.orderNo) await load(query.page)}onMounted(() => load())</script> |
查询范围在服务端组装;启用、停用与排课按钮只在合法状态出现。
COMPLIANCE / 合规与上线
训练排课上线,这几条边界要写清
课程承诺要与实际资源一致。课时、适龄、上课地点、教练和退款规则以发布时的主数据为准,变更需留版本和操作记录。
资质材料按最小范围保存。商家证照只在审核和管理范围内使用,用户端不展示非必要信息,日志不保存完整敏感材料。
排课冲突由服务端判定。不能只凭前端时间选择;教练可授资格、教室开放时段和已有占用都要在下单前重新校验。
示例不代替外部服务联调。支付、消息通知、地图定位、短信和实名认证需要配置正式服务并完成联调后才能对外使用。
上线前建议逐项打勾
|
FIT / 适合谁
适合谁用
适合同城培训、体育训练、素质教育等多门店机构,需要统一课程和教室资源,再按教练能力安排课表。
不太适合只想要单店静态展示页、没有复杂资源约束的团队不太适合;排课效果依赖课程和人员数据持续维护。
二次开发服务端采用SpringBoot与MyBatisPlus,后台使用Vue和ElementUI,用户端通过uni-app发布到小程序、公众号与H5,便于继续接入支付、消息和评价能力。
FAQ / 常见问题
常被问到的五个问题
Q:支持一个商家管理多个门店吗?A:支持。商家主体与门店分离,商家可按授权范围管理多个门店,员工和订单也按门店隔离。
Q:一位教练可以教多门课程吗?A:可以。教练与课程多对多关联,具体排课时还会校验授课资格、可用时间和教室资源。
Q:同一教室能同时排两节课吗?A:不能。教室占用在服务端记录并有唯一约束,取消课程时再按原记录释放。
Q:修改课程会影响已支付订单吗?A:不会。订单保存成交时的课程名称、课时和价格快照,后续调整只影响新的购买与排课。
Q:源码包含哪些部分?A:包含SpringBoot + MyBatisPlus + MySQL服务端、Vue + ElementUI后台,以及支持小程序、公众号、H5的uni-app用户端。
LAST / 写在最后
排课冲突大多不是排课器不够聪明,而是门店、课程、教练和教室一开始就没有同一套数据标准。
先把六类资源的归属、状态与校验规则放进系统,课表才有稳定基础,多端也能保持一致。
需要看完整资源模型或演示排课流程,可以在评论区留言说明你的机构类型。
文中截图均为演示数据,界面与文案随版本可能调整;具体功能以实际交付版本为准。















































































