feat: 添加排课功能支持代码 - 课程类型枚举、冲突检测、定时提醒

新增功能:
- LessonTypeEnum: 7种课程类型枚举(导入课、集体课、五大领域课)
- ScheduleConflictService: 排课冲突检测服务
- ScheduleReminderTask: 排课提醒定时任务
- ScheduleConfig: 排课相关配置
- 新增DTO: ScheduleCreateByClassesRequest, CalendarViewResponse等

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Opus 4.6 2026-03-17 19:34:16 +08:00
parent eb1b1a3153
commit 0a09097095
9 changed files with 732 additions and 0 deletions

View File

@ -0,0 +1,14 @@
package com.reading.platform.common.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 定时任务配置
* 启用 Spring Scheduling 支持
*/
@Configuration
@EnableScheduling
public class ScheduleConfig {
}

View File

@ -0,0 +1,55 @@
package com.reading.platform.dto.request;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.util.List;
/**
* 批量创建排课请求DTO按班级
*/
@Schema(description = "批量创建排课请求")
@Data
public class ScheduleCreateByClassesRequest {
@Schema(description = "课程包ID")
@NotNull(message = "课程包ID不能为空")
private Long coursePackageId;
@Schema(description = "课程ID")
@NotNull(message = "课程ID不能为空")
private Long courseId;
@Schema(description = "课程类型")
@NotBlank(message = "课程类型不能为空")
private String lessonType;
@Schema(description = "班级ID列表")
@NotEmpty(message = "班级ID列表不能为空")
private List<Long> classIds;
@Schema(description = "教师ID")
@NotNull(message = "教师ID不能为空")
private Long teacherId;
@Schema(description = "排课日期")
@NotNull(message = "排课日期不能为空")
private LocalDate scheduledDate;
@Schema(description = "时间段 (如09:00-10:00)")
@NotBlank(message = "时间段不能为空")
private String scheduledTime;
@Schema(description = "重复方式 (NONE/WEEKLY/BIWEEKLY)")
private String repeatType = "NONE";
@Schema(description = "重复截止日期")
private LocalDate repeatEndDate;
@Schema(description = "备注")
private String note;
}

View File

@ -0,0 +1,62 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 日历视图响应DTO
*/
@Schema(description = "日历视图响应")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class CalendarViewResponse {
@Schema(description = "开始日期")
private LocalDate startDate;
@Schema(description = "结束日期")
private LocalDate endDate;
@Schema(description = "排课数据 (key: 日期字符串 YYYY-MM-DD, value: 排课列表)")
private Map<String, List<DayScheduleItem>> schedules;
/**
* 日期排课项
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DayScheduleItem {
@Schema(description = "排课ID")
private Long id;
@Schema(description = "班级名称")
private String className;
@Schema(description = "课程包名称")
private String coursePackageName;
@Schema(description = "课程类型名称")
private String lessonTypeName;
@Schema(description = "教师名称")
private String teacherName;
@Schema(description = "时间段")
private String scheduledTime;
@Schema(description = "状态")
private String status;
}
}

View File

@ -0,0 +1,76 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 冲突检测结果
*/
@Data
@Schema(description = "冲突检测结果")
public class ConflictCheckResult {
@Schema(description = "是否存在冲突")
private Boolean hasConflict;
@Schema(description = "冲突类型列表")
private List<ConflictInfo> conflicts = new ArrayList<>();
@Schema(description = "冲突信息描述")
private String message;
/**
* 冲突详情
*/
@Data
@Schema(description = "冲突详情")
public static class ConflictInfo {
@Schema(description = "冲突类型 (TEACHER/CLASS)")
private String type;
@Schema(description = "冲突的资源 ID")
private Long resourceId;
@Schema(description = "冲突的资源名称")
private String resourceName;
@Schema(description = "冲突的排课 ID")
private Long schedulePlanId;
@Schema(description = "冲突的排课名称")
private String scheduleName;
@Schema(description = "冲突的日期")
private String scheduledDate;
@Schema(description = "冲突的时间段")
private String scheduledTime;
}
/**
* 创建无冲突结果
*/
public static ConflictCheckResult noConflict() {
ConflictCheckResult result = new ConflictCheckResult();
result.setHasConflict(false);
result.setMessage("无时间冲突");
return result;
}
/**
* 创建有冲突结果
*/
public static ConflictCheckResult withConflicts(List<ConflictInfo> conflicts) {
ConflictCheckResult result = new ConflictCheckResult();
result.setHasConflict(true);
result.setConflicts(conflicts);
result.setMessage("检测到时间冲突");
return result;
}
}

View File

@ -0,0 +1,27 @@
package com.reading.platform.dto.response;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 课程类型信息DTO
*/
@Schema(description = "课程类型信息")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LessonTypeInfo {
@Schema(description = "课程类型代码")
private String lessonType;
@Schema(description = "课程类型名称")
private String lessonTypeName;
@Schema(description = "该类型下的课程数量")
private Long count;
}

View File

@ -0,0 +1,92 @@
package com.reading.platform.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
/**
* 课程类型枚举
*/
public enum LessonTypeEnum {
/**
* 导入课
*/
INTRODUCTION("导入课", "INTRODUCTION"),
/**
* 集体课
*/
COLLECTIVE("集体课", "COLLECTIVE"),
/**
* 语言课五大领域
*/
LANGUAGE("语言课", "LANGUAGE"),
/**
* 社会课五大领域
*/
SOCIETY("社会课", "SOCIETY"),
/**
* 科学课五大领域
*/
SCIENCE("科学课", "SCIENCE"),
/**
* 艺术课五大领域
*/
ART("艺术课", "ART"),
/**
* 健康课五大领域
*/
HEALTH("健康课", "HEALTH");
/**
* 描述
*/
private final String description;
/**
* 代码
*/
@EnumValue
@JsonValue
private final String code;
LessonTypeEnum(String description, String code) {
this.description = description;
this.code = code;
}
public String getDescription() {
return description;
}
public String getCode() {
return code;
}
/**
* 根据代码获取枚举
*/
public static LessonTypeEnum fromCode(String code) {
if (code == null) {
return null;
}
for (LessonTypeEnum type : values()) {
if (type.code.equals(code)) {
return type;
}
}
return null;
}
/**
* 验证是否有效的课程类型代码
*/
public static boolean isValidCode(String code) {
return fromCode(code) != null;
}
}

View File

@ -0,0 +1,71 @@
package com.reading.platform.service;
import com.reading.platform.dto.response.ConflictCheckResult;
import com.reading.platform.entity.SchedulePlan;
import java.time.LocalDate;
import java.util.List;
/**
* 排课冲突检测服务接口
*/
public interface ScheduleConflictService {
/**
* 检测排课冲突
*
* @param tenantId 租户 ID
* @param classId 班级 ID
* @param teacherId 教师 ID
* @param scheduledDate 排课日期
* @param scheduledTime 时间段 (09:00-10:00)
* @param excludeId 排除的排课 ID (用于更新时排除自身)
* @return 冲突检测结果
*/
ConflictCheckResult checkConflict(Long tenantId, Long classId, Long teacherId,
LocalDate scheduledDate, String scheduledTime, Long excludeId);
/**
* 检测排课冲突 (无排除 ID)
*/
default ConflictCheckResult checkConflict(Long tenantId, Long classId, Long teacherId,
LocalDate scheduledDate, String scheduledTime) {
return checkConflict(tenantId, classId, teacherId, scheduledDate, scheduledTime, null);
}
/**
* 检测教师时间冲突
*
* @param tenantId 租户 ID
* @param teacherId 教师 ID
* @param scheduledDate 排课日期
* @param scheduledTime 时间段
* @param excludeId 排除的排课 ID
* @return 冲突的排课列表
*/
List<SchedulePlan> checkTeacherConflict(Long tenantId, Long teacherId,
LocalDate scheduledDate, String scheduledTime, Long excludeId);
/**
* 检测班级时间冲突
*
* @param tenantId 租户 ID
* @param classId 班级 ID
* @param scheduledDate 排课日期
* @param scheduledTime 时间段
* @param excludeId 排除的排课 ID
* @return 冲突的排课列表
*/
List<SchedulePlan> checkClassConflict(Long tenantId, Long classId,
LocalDate scheduledDate, String scheduledTime, Long excludeId);
/**
* 判断两个时间段是否重叠
*
* @param time1 时间段1 (09:00-10:00)
* @param time2 时间段2 (09:30-10:30)
* @return 是否重叠
*/
boolean isTimeOverlap(String time1, String time2);
}

View File

@ -0,0 +1,164 @@
package com.reading.platform.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.reading.platform.dto.response.ConflictCheckResult;
import com.reading.platform.entity.SchedulePlan;
import com.reading.platform.mapper.SchedulePlanMapper;
import com.reading.platform.service.ScheduleConflictService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.List;
/**
* 排课冲突检测服务实现
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ScheduleConflictServiceImpl implements ScheduleConflictService {
private final SchedulePlanMapper schedulePlanMapper;
@Override
public ConflictCheckResult checkConflict(Long tenantId, Long classId, Long teacherId,
LocalDate scheduledDate, String scheduledTime, Long excludeId) {
log.debug("检测排课冲突: tenantId={}, classId={}, teacherId={}, date={}, time={}, excludeId={}",
tenantId, classId, teacherId, scheduledDate, scheduledTime, excludeId);
List<ConflictCheckResult.ConflictInfo> conflicts = new ArrayList<>();
// 检测教师时间冲突
List<SchedulePlan> teacherConflicts = checkTeacherConflict(tenantId, teacherId,
scheduledDate, scheduledTime, excludeId);
for (SchedulePlan plan : teacherConflicts) {
ConflictCheckResult.ConflictInfo info = new ConflictCheckResult.ConflictInfo();
info.setType("TEACHER");
info.setResourceId(teacherId);
info.setResourceName("教师");
info.setSchedulePlanId(plan.getId());
info.setScheduleName(plan.getName());
info.setScheduledDate(plan.getScheduledDate().toString());
info.setScheduledTime(plan.getScheduledTime());
conflicts.add(info);
}
// 检测班级时间冲突
List<SchedulePlan> classConflicts = checkClassConflict(tenantId, classId,
scheduledDate, scheduledTime, excludeId);
for (SchedulePlan plan : classConflicts) {
ConflictCheckResult.ConflictInfo info = new ConflictCheckResult.ConflictInfo();
info.setType("CLASS");
info.setResourceId(classId);
info.setResourceName("班级");
info.setSchedulePlanId(plan.getId());
info.setScheduleName(plan.getName());
info.setScheduledDate(plan.getScheduledDate().toString());
info.setScheduledTime(plan.getScheduledTime());
conflicts.add(info);
}
if (conflicts.isEmpty()) {
return ConflictCheckResult.noConflict();
} else {
return ConflictCheckResult.withConflicts(conflicts);
}
}
@Override
public List<SchedulePlan> checkTeacherConflict(Long tenantId, Long teacherId,
LocalDate scheduledDate, String scheduledTime, Long excludeId) {
if (teacherId == null) {
return new ArrayList<>();
}
// 查询该教师当天所有排课
LambdaQueryWrapper<SchedulePlan> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SchedulePlan::getTenantId, tenantId)
.eq(SchedulePlan::getTeacherId, teacherId)
.eq(SchedulePlan::getScheduledDate, scheduledDate)
.ne(SchedulePlan::getStatus, "cancelled");
if (excludeId != null) {
wrapper.ne(SchedulePlan::getId, excludeId);
}
List<SchedulePlan> plans = schedulePlanMapper.selectList(wrapper);
// 过滤出时间重叠的排课
List<SchedulePlan> conflicts = new ArrayList<>();
for (SchedulePlan plan : plans) {
if (isTimeOverlap(scheduledTime, plan.getScheduledTime())) {
conflicts.add(plan);
}
}
return conflicts;
}
@Override
public List<SchedulePlan> checkClassConflict(Long tenantId, Long classId,
LocalDate scheduledDate, String scheduledTime, Long excludeId) {
if (classId == null) {
return new ArrayList<>();
}
// 查询该班级当天所有排课
LambdaQueryWrapper<SchedulePlan> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SchedulePlan::getTenantId, tenantId)
.eq(SchedulePlan::getClassId, classId)
.eq(SchedulePlan::getScheduledDate, scheduledDate)
.ne(SchedulePlan::getStatus, "cancelled");
if (excludeId != null) {
wrapper.ne(SchedulePlan::getId, excludeId);
}
List<SchedulePlan> plans = schedulePlanMapper.selectList(wrapper);
// 过滤出时间重叠的排课
List<SchedulePlan> conflicts = new ArrayList<>();
for (SchedulePlan plan : plans) {
if (isTimeOverlap(scheduledTime, plan.getScheduledTime())) {
conflicts.add(plan);
}
}
return conflicts;
}
@Override
public boolean isTimeOverlap(String time1, String time2) {
if (time1 == null || time2 == null) {
return false;
}
try {
// 解析时间段 (格式: HH:mm-HH:mm)
String[] parts1 = time1.split("-");
String[] parts2 = time2.split("-");
if (parts1.length != 2 || parts2.length != 2) {
log.warn("时间段格式不正确: {} 或 {}", time1, time2);
return false;
}
LocalTime start1 = LocalTime.parse(parts1[0].trim());
LocalTime end1 = LocalTime.parse(parts1[1].trim());
LocalTime start2 = LocalTime.parse(parts2[0].trim());
LocalTime end2 = LocalTime.parse(parts2[1].trim());
// 时间重叠判断: start1 < end2 && start2 < end1
return start1.isBefore(end2) && start2.isBefore(end1);
} catch (Exception e) {
log.error("解析时间段失败: {} 或 {}", time1, time2, e);
return false;
}
}
}

View File

@ -0,0 +1,171 @@
package com.reading.platform.task;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.reading.platform.entity.SchedulePlan;
import com.reading.platform.entity.Teacher;
import com.reading.platform.mapper.SchedulePlanMapper;
import com.reading.platform.mapper.TeacherMapper;
import com.reading.platform.service.NotificationService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.List;
/**
* 排课提醒定时任务
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ScheduleReminderTask {
private final SchedulePlanMapper schedulePlanMapper;
private final TeacherMapper teacherMapper;
private final NotificationService notificationService;
/**
* 每天早上 7:00 发送当天排课提醒
* cron:
*/
@Scheduled(cron = "0 0 7 * * ?")
public void sendDailyScheduleReminder() {
log.info("开始发送每日排课提醒...");
LocalDate today = LocalDate.now();
// 查询当天所有未取消且未发送提醒的排课
LambdaQueryWrapper<SchedulePlan> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SchedulePlan::getScheduledDate, today)
.ne(SchedulePlan::getStatus, "cancelled")
.eq(SchedulePlan::getReminderSent, 0);
List<SchedulePlan> plans = schedulePlanMapper.selectList(wrapper);
log.info("找到 {} 条待发送提醒的排课", plans.size());
int successCount = 0;
int failCount = 0;
for (SchedulePlan plan : plans) {
try {
sendReminder(plan, "daily");
// 标记已发送
plan.setReminderSent(1);
plan.setReminderSentAt(LocalDateTime.now());
schedulePlanMapper.updateById(plan);
successCount++;
} catch (Exception e) {
log.error("发送排课提醒失败: planId={}, error={}", plan.getId(), e.getMessage(), e);
failCount++;
}
}
log.info("每日排课提醒发送完成: 成功={}, 失败={}", successCount, failCount);
}
/**
* 30 分钟检查即将开始的排课发送提醒
* 在课程开始前 30 分钟发送提醒
*/
@Scheduled(cron = "0 0/30 * * * ?")
public void sendUpcomingScheduleReminder() {
log.debug("检查即将开始的排课...");
LocalDate today = LocalDate.now();
LocalTime now = LocalTime.now();
LocalTime after30Minutes = now.plusMinutes(30);
// 查询今天未取消未发送即将提醒开始时间在接下来 30 分钟内的排课
LambdaQueryWrapper<SchedulePlan> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SchedulePlan::getScheduledDate, today)
.ne(SchedulePlan::getStatus, "cancelled");
List<SchedulePlan> plans = schedulePlanMapper.selectList(wrapper);
for (SchedulePlan plan : plans) {
try {
LocalTime startTime = parseStartTime(plan.getScheduledTime());
if (startTime == null) {
continue;
}
// 判断是否在接下来 30 分钟内开始
if (startTime.isAfter(now) && !startTime.isAfter(after30Minutes)) {
// 检查是否已发送过即将开始提醒使用 reminderSent 字段值为 2 表示已发送即将开始提醒
if (plan.getReminderSent() == null || plan.getReminderSent() < 2) {
sendReminder(plan, "upcoming");
// 标记已发送即将开始提醒设置为 2
plan.setReminderSent(2);
plan.setReminderSentAt(LocalDateTime.now());
schedulePlanMapper.updateById(plan);
log.info("发送即将开始提醒: planId={}, time={}", plan.getId(), plan.getScheduledTime());
}
}
} catch (Exception e) {
log.error("处理即将开始排课失败: planId={}, error={}", plan.getId(), e.getMessage());
}
}
}
/**
* 发送提醒通知
*/
private void sendReminder(SchedulePlan plan, String reminderType) {
String title;
String content;
if ("daily".equals(reminderType)) {
title = "今日课程提醒";
content = String.format("您有课程「%s」安排在今天 %s请提前做好准备。",
plan.getName(), plan.getScheduledTime());
} else {
title = "课程即将开始";
content = String.format("您的课程「%s」将在 30 分钟内(%s开始请做好准备。",
plan.getName(), plan.getScheduledTime());
}
// 发送给教师
if (plan.getTeacherId() != null) {
Teacher teacher = teacherMapper.selectById(plan.getTeacherId());
if (teacher != null) {
notificationService.createNotification(
plan.getTenantId(),
0L,
"SYSTEM",
title,
content,
"SCHEDULE",
"TEACHER",
plan.getTeacherId()
);
}
}
// 可以扩展发送给班级相关的人员如班主任等
}
/**
* 解析开始时间
*/
private LocalTime parseStartTime(String scheduledTime) {
if (scheduledTime == null || scheduledTime.isEmpty()) {
return null;
}
try {
String[] parts = scheduledTime.split("-");
if (parts.length >= 1) {
return LocalTime.parse(parts[0].trim());
}
} catch (Exception e) {
log.warn("解析时间失败: {}", scheduledTime);
}
return null;
}
}