
像[Honey J][26.8.7] FWT - Icee Red/isaiah falls 上课这样一行排课文本在很多线下培训机构、舞蹈工作室、健身课程或企业内训场景里非常常见。老师或教务人员通常在群聊、表格甚至日历记事里写下这一行“人眼能看懂”的记录但系统看不懂哪个字段是教师哪个字段是日期哪个字段是课程项目哪个字段是班级或资源需不需要检查时间冲突全部被压在这一串短文本里。这篇文章就以这一行记录为切入点带你实现一个课程排期模块先把不规范文本解析成结构化数据再设计课程排期表最后用冲突检测、按天查询和一个最小可运行工程把整条链路跑通。原始材料没有指定技术栈下面统一按 Spring Boot 3 MySQL 8 的思路演示落地前要根据自己项目已有的框架和版本调整。1. 先拆解这一行排课文本要表达什么1.1 人能看懂的一行记录系统必须拆成字段把[Honey J][26.8.7] FWT - Icee Red/isaiah falls 上课拆开看它至少包含以下几类业务信息。原始片段教务人员视角系统建模建议Honey J某位老师或教练教师表中的一个教师标识26.8.7日期常见简写统一转成DATE类型并保留原始文本方便追溯FWT课程项目或课程系列课程项目主数据可关联项目表Icee Red/isaiah falls班级、分组或课程内容标识班级分组主数据或作为文本备注上课业务动作表示已确认排课在状态字段里标记为“已排课/已发布”拆分后的信息可以表达成下面这样{ teacherName: Honey J, scheduleDate: 2026-08-07, projectCode: FWT, groupCode: Icee Red/isaiah falls, action: 上课 }这一行 JSON 才是系统能处理的排课草稿。注意原始文本没有给开始时间和结束时间所以解析时不应该编造时间字段。“上课”只能表达“这个项目当天要上”具体从几点到几点需要由人工确认或从固定班表中带出排课时间不能靠猜。1.2 直接把文本存进备注字段会带来四类问题第一种是检索困难。如果所有排课都写成source_text想查“Honey J 最近 30 天有哪些课”只能用LIKE %Honey J%去扫字符串。教师名字一旦包含空格、大小写不一致或别名查询结果会非常不准确。第二种是冲突检测失效。系统无法判断同一老师在同一时间是否已经被安排也无法判断同一个小组是否被重复排课。最终结果就是线下靠人工发现撞课处理成本很高。第三种是统计失效。做月度课时统计时需要按教师、班级、课程项目聚合文本存储只能按行数粗算不能精确计算某位老师当月的实际上课分钟数。第四种是权限和通知没有抓手。如果需要给教师推送课程提醒、给学员发送取消通知业务系统必须知道每一节课的最小单元是什么。一条备注文本难以作为权限、消息和事务处理的粒度。1.3 本文要实现的不是“自动排课”而是“排课录入与防冲突”自动排课会涉及到排课算法、教师偏好、教室资源、学员冲突等复杂问题。本文的范围更克制目标只是一个教务可以使用的课程排期模块本文要做的本文不做解析历史排课文本自动为老师分配上课时间用结构化字段保存课程排期复杂调课和请假审批流新增排课时检查教师、班级冲突多教室自动找空闲资源按日期查询某位老师当天课表学员缴费、签到、消课管理保留原始文本方便溯源自动生成最优排课方案这个边界很关键。很多团队一开始就想做“全自动排课系统”结果需求永远做不完。更合理的方式是先解决“录入规范 冲突不重不漏 日历可查”这三个能力上线后自动排课才有个稳的地基。2. 数据模型设计为什么需要教师表、课程表、班级表和排课表2.1 四类实体各自解决什么问题教师表t_teacher维护“人”的主数据。教师改姓名时不需要改动历史排课记录因为排课表保存的是教师id。课程项目表t_course_project维护项目或系列例如FWT。同一个项目可以反复开课课程项目需要独立编码。班级分组表t_class_group维护分组信息例如Icee Red/isaiah falls。这样同一个班级可以关联多次课程排期也可以做学员分组管理。排课表t_course_schedule是可被消费的业务事实。它表示某天某时段某个教师、某个课程项目、某个班级要上课。2.2 用 MySQL 8 建表下面给出参考 DDL。这里没有频繁使用物理外键而是通过索引和应用层逻辑保证一致性原因在后续小节解释。CREATE TABLE t_teacher ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, teacher_code VARCHAR(64) NOT NULL COMMENT 教师编码, teacher_name VARCHAR(128) NOT NULL COMMENT 教师显示名, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1启用0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_teacher_code (teacher_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT教师/教练表; CREATE TABLE t_course_project ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, project_code VARCHAR(64) NOT NULL COMMENT 课程项目编码, project_name VARCHAR(128) NOT NULL COMMENT 课程项目名称, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1启用0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_project_code (project_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程项目表; CREATE TABLE t_class_group ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, group_code VARCHAR(128) NOT NULL COMMENT 班级分组编码, group_name VARCHAR(128) NOT NULL COMMENT 班级分组名称, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1启用0停用, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_group_code (group_code) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT班级分组表; CREATE TABLE t_course_schedule ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 排课主键, schedule_date DATE NOT NULL COMMENT 上课日期, start_time TIME NOT NULL COMMENT 开始时间, end_time TIME NOT NULL COMMENT 结束时间, teacher_id BIGINT NOT NULL COMMENT 教师ID, class_group_id BIGINT NOT NULL COMMENT 班级分组ID, project_id BIGINT NOT NULL COMMENT 课程项目ID, status VARCHAR(32) NOT NULL DEFAULT SCHEDULED COMMENT 状态SCHEDULED/CANCELED, source_text VARCHAR(1024) NULL COMMENT 原始文本, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_teacher_start (teacher_id, schedule_date, start_time), UNIQUE KEY uk_group_start (class_group_id, schedule_date, start_time), KEY idx_schedule_date (schedule_date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT课程排期表;建表时最重要的部分是开始时间和结束时间。如果只存日期不存具体开始结束时间就无法做冲突检测。这里把日期和开始时间拆成两列而不是存一个DATETIME是为了让查询时按日期条件更容易走索引同时语义也更贴近“某天从几点到几点上课”。2.3 几个容易误解的建模取舍不要把教师名、班级名直接拼接在排课表里。老系统常见的做法是在t_course_schedule里加teacher_name、group_name这样的冗余字段写入时看着方便可一旦教师改名或班级重命名后续同步非常痛苦。推荐做法是排课表只保存字典表的主键id展示阶段再 JOIN 出名称。如果想要文本溯源保留一列source_text即可。唯一索引有边界。上面用(teacher_id, schedule_date, start_time)建唯一索引只能拦截“同一教师同一天同一开始时间”的重复插入。如果上午 10:00-11:00 已有一节课另一节课被录入成 10:30-11:30因为开始时间不同唯一索引不会报错但业务上明显重叠。所以数据库唯一索引只是护栏真正的任意区间重叠检测要放到应用层做。不建物理外键不代表不检查外键关系。插入排课时业务代码必须校验教师、班级、课程项目是否都存在。生产环境里物理外键可能带来额外的锁和升级成本很多团队会选择逻辑外键建模。新建项目如果团队规模不大也可以显式创建外键两者没有绝对对错关键是进入排课保存逻辑时不能绕过引用校验。3. 用解析器把[Honey J][26.8.7]变成排课草稿3.1 约定文本格式和解析边界要把“人读文本”转成结构化对象首先得定义被支持的文本格式。本项目接受这样一类格式[教师显示名][日期] 课程项目编码 - 班级分组编码 上课其中日期支持类似26.8.7的写法也支持2026.8.7但不支持带横线的2026-08-07。原因不是不能支持而是作为导入解析器输入必须尽量确定。如果日期格式会继续扩展可以在解析器里增加对应的DateTimeFormatter不要把所有格式都塞进一个正则表达式。原始文本[Honey J][26.8.7] FWT - Icee Red/isaiah falls 上课中结尾的“上课”表示动作。业务里如果状态还有“停课”“补课”“调课”解析规则会有所不同。常见做法是先只解析结构和日期工作状态由使用者确认后再写入。3.2 定义排课草稿对象解析结果不能一上来就对应数据库排课记录因为它还没有时间信息。定义一个草稿对象它只包含已经能从文本中确定的信息public record ScheduleDraft( String teacherName, LocalDate scheduleDate, String projectCode, String groupCode, String action, String rawText ) { }teacherName保存教师显示名scheduleDate保存规范化后的日期projectCode和groupCode先保留原始字符串action表示“上课”这个动作rawText保存整行原始文本方便后续追溯和问题定位。3.3 写一个简单又带有防御判断的解析器下面代码可以放入一个普通工具类不依赖 Spring 容器便于单元测试。import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.regex.Matcher; import java.util.regex.Pattern; public class ScheduleTextParser { private static final Pattern SCHEDULE_PATTERN Pattern.compile(\\[([^\\]])]\\[([^\\]])](.*)$); public ScheduleDraft parse(String text) { if (text null || text.isBlank()) { throw new IllegalArgumentException(排课文本不能为空); } String input text.trim(); Matcher matcher SCHEDULE_PATTERN.matcher(input); if (!matcher.matches()) { throw new IllegalArgumentException(无法识别的排课文本: input); } String teacherName matcher.group(1).trim(); String rawDay matcher.group(2).trim(); String tail matcher.group(3).trim(); String actionBody tail; boolean isClassAction actionBody.endsWith(上课); if (isClassAction) { actionBody actionBody.substring(0, actionBody.length() - 2).trim(); } String projectCode null; String groupCode null; if (actionBody.contains( - )) { String[] parts actionBody.split( - , 2); projectCode parts[0].trim(); groupCode parts[1].trim(); } else if (!actionBody.isBlank()) { projectCode actionBody; } LocalDate scheduleDate parseDate(rawDay); return new ScheduleDraft( teacherName, scheduleDate, projectCode, groupCode, isClassAction ? 上课 : 未标注,