夜雨聆风学习资料网

ARTICLE · 1358192

JAVA同城服务训练排课课程排课系统源码支持小程序+公众号+H5

JAVA同城服务训练排课课程排课系统源码支持小程序+公众号+H5

小程序 · 公众号 · H5

TRAINING SCHEDULING / JAVA

门店课程 · 教练教室 · 商家入驻 · 排课协同

训练排课系统

资源进系统,排课才有依据。

SpringBoot · MyBatisPlus · MySQL

uni-app · Vue + ElementUI  /  小程序 · 公众号 · H5

训练机构做排课时,卡点通常不在课表本身,而在课表之前:门店营业范围不清、课程与课时口径不一致、教练与员工职责混在一起,教室到点才发现被占用。

这套系统的做法是先把资源模型固定:门店、课程、员工、教练、商家和教室各有主数据,排课只引用已确认的资源。后台维护状态和归属,用户端看到的是同一套可售课程与可约时间。

下面按六个功能点讲清后台怎么管,再给七段关键代码,覆盖表结构、商家入驻、课程发布、资源校验、排课事务、多端请求与后台列表。

OVERVIEW / 功能总览

六个模块,管住排课前的资源基础

功能点按管理动作顺序展开:先有门店与商家,再定义课程,然后维护员工、教练与教室,最后这些资源进入排课和用户选购。

门店管理

营业信息、服务范围与门店状态集中维护。

课程管理

课时、价格、适龄与上课地点绑定课程主数据。

员工管理

按门店分配岗位与账号,控制后台数据范围。

教练管理

擅长领域、可授课程与资质信息独立维护。

商家入驻

提交资质、审核签约后分配可管理门店。

教室管理

容量、设备和可用时间成为排课校验条件。

一套资源主数据

门店、课程、员工、教练、商家与教室共用一套归属和状态

01门店管理

输入:输入门店名称、经营地址、营业时间、服务范围和状态。

状态:门店有草稿、营业、暂停与关闭状态,停业门店不再接受新排课和用户下单。

输出:输出门店主页、课程归属和数据范围;历史订单继续保留成交时的门店快照。

【训练排课】实拍 01|首页课程、教室场馆与教练介绍;截图数据为演示数据

02课程管理

输入:输入课程名称、适龄、课时、价格、上课地点和退款说明。

状态:课程分待上架、已上架、已下架;课时包与单次体验课使用同一套课程主数据。

输出:输出用户端课程详情与后台可排课清单;改价不改已支付订单,商品快照独立保存。

03员工管理

输入:输入员工姓名、所属门店、岗位、手机号和登录账号。

状态:账号可启用、停用;岗位决定后台菜单与可查询的门店数据范围。

输出:输出员工工作台权限和操作日志;离店停用后不能继续登录或审批。

【训练排课】实拍 02|课程列表、用户中心与课程详情;截图数据为演示数据

04教练管理

输入:输入教练资料、擅长领域、可授课程、资质附件与可用时间。

状态:教练状态与门店、课程分开维护,停用教练不再出现在用户端和排课候选中。

输出:输出教练主页、可约时段和排课约束;排课时自动校验教练是否具备该课程授课资格。

05商家入驻

输入:商家提交主体信息、联系人、经营资质与拟经营门店。

状态:入驻经历提交、审核、签约、开通四个状态,审核结果与材料版本都留痕。

输出:输出可管理的门店和员工范围;未签约商家不能发布课程或触达用户。

【训练排课】实拍 03|教练主页、购买订单与订单详情;截图数据为演示数据

06教室管理

输入:输入教室名称、所属门店、容量、设备、开放时段和停用时间。

状态:教室状态有开放、维修和关闭;同一时段被占用后不允许再次分配。

输出:输出排课可用教室集合和冲突提示;停用信息同步到用户端,避免约到不可用场地。

ARCHITECTURE / 三端分工

一套资源主数据,三端各管一段

端负责什么不该做什么
用户端浏览门店、课程、教练与教室并下单不修改资源状态,不自行变更上课地点
管理后台商家、门店、课程、人员、教室与订单管理不绕过审核开通,不越门店查看订单
服务端排课校验、事务、权限与状态流转不信任前端提交的课时、价格和归属

一句话分工:前端负责选择和展示,后台负责资源维护与审核;能否排课由服务端依据门店、教练、教室和课程资格共同判断。

CODE / 数据库与核心代码

七段关键代码:从主数据到排课事务

按「表结构 → 商家入驻 → 课程发布 → 资源校验 → 排课事务 → 多端请求 → 后台列表」给出可落地的示例。

以下均为示例实现,生产环境需补充参数校验、权限、日志、监控、限流、支付验签等。

01六类资源各自留痕

门店、课程、教练和教室一旦共表,状态变更就无法追溯。

SQL

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商家入驻走状态机

入驻不是一次表单提交,而是审核和签约的连续状态。

JAVA

@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课程发布回查门店

课程可以被多个接口入口触达,发布前必须确认门店仍可经营。

JAVA

@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排课前校验人员资源

教练可授课程、教室容量和开放时间必须同时满足,缺一都会产生履约冲突。

JAVA

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排课事务与占用控制

课表、教练时段与教室占用必须同事务写入,否则会出重复占用。

JAVA

@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的登录态、错误处理和请求号应保持一致。

JAVASCRIPT

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后台列表按门店取数

门店、教练和课程列表要能筛、能管,还要受管理员数据范围限制。

VUE

<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 / 写在最后

排课冲突大多不是排课器不够聪明,而是门店、课程、教练和教室一开始就没有同一套数据标准。

先把六类资源的归属、状态与校验规则放进系统,课表才有稳定基础,多端也能保持一致。

需要看完整资源模型或演示排课流程,可以在评论区留言说明你的机构类型。

文中截图均为演示数据,界面与文案随版本可能调整;具体功能以实际交付版本为准。

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料