新增到课签到时间窗口与迟到签到时间窗口配置,优化教练评分机制(未测试)

This commit is contained in:
2026-07-26 16:10:23 +08:00
parent 86b7555943
commit c2f11727fe
31 changed files with 2069 additions and 138 deletions
+5
View File
@@ -37,6 +37,11 @@
<artifactId>gym-groupCourse</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>cn.novalon.gym.manage</groupId>
<artifactId>gym-coach-config</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
@@ -1,6 +1,7 @@
package cn.novalon.gym.manage.coach.scheduler;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseDao;
@@ -30,22 +31,23 @@ import java.time.LocalDateTime;
public class CoachCourseScheduler {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseScheduler.class);
private static final long ONE_HOUR_MINUTES = 60;
private static final long END_GRACE_MINUTES = 10;
private final GroupCourseDao groupCourseDao;
private final GroupCourseBookingDao groupCourseBookingDao;
private final DatabaseClient databaseClient;
private final RedisUtil redisUtil;
private final CoachTimeRuleService timeRuleService;
public CoachCourseScheduler(GroupCourseDao groupCourseDao,
GroupCourseBookingDao groupCourseBookingDao,
DatabaseClient databaseClient,
RedisUtil redisUtil) {
RedisUtil redisUtil,
CoachTimeRuleService timeRuleService) {
this.groupCourseDao = groupCourseDao;
this.groupCourseBookingDao = groupCourseBookingDao;
this.databaseClient = databaseClient;
this.redisUtil = redisUtil;
this.timeRuleService = timeRuleService;
}
/**
@@ -89,39 +91,38 @@ public class CoachCourseScheduler {
*/
private Mono<Long> processAbsentCourses(LocalDateTime now) {
return groupCourseDao.findByStatusAndStartTimeBefore(databaseClient, "0", now)
.filter(course -> isAbsentThresholdExceeded(course, now))
.flatMap(course -> markAsCoachAbsent(course, now))
.flatMap(course -> {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
return timeRuleService.matchRule(courseDurationMinutes)
.filter(rule -> {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
return minutesSinceStart > rule.getLateWindow();
})
.flatMap(rule -> markAsCoachAbsent(course, now));
})
.count();
}
/**
* 处理自动结课:status IN ('3','7') 且 end_time + 10分钟 已过
* 处理自动结课:status IN ('3','7') 且 end_time + endGrace 已过
*/
private Mono<Long> processAutoEndCourses(LocalDateTime now) {
LocalDateTime endThreshold = now.minusMinutes(END_GRACE_MINUTES);
return groupCourseDao.findByStatusInAndEndTimeBefore(databaseClient,
new String[]{String.valueOf(CourseStatus.IN_PROGRESS.getValue()),
String.valueOf(CourseStatus.COACH_LATE.getValue())},
endThreshold)
.flatMap(course -> markAsAutoEnded(course, now))
now)
.flatMap(course -> {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
return timeRuleService.matchRule(courseDurationMinutes)
.filter(rule -> {
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
return minutesAfterEnd > rule.getEndGrace();
})
.flatMap(rule -> markAsAutoEnded(course, now));
})
.count();
}
/**
* 判断课程是否已过缺席阈值
*/
private boolean isAbsentThresholdExceeded(GroupCourseEntity course, LocalDateTime now) {
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return minutesSinceStart > 30;
} else {
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
return minutesSinceStart > thresholdB;
}
}
/**
* 标记课程为教练缺席(5),更新预约记录为教练缺席(4),记录违规
*/
@@ -3,6 +3,8 @@ package cn.novalon.gym.manage.coach.service;
import cn.novalon.gym.manage.coach.dao.CoachViolationDao;
import cn.novalon.gym.manage.coach.entity.CoachViolationEntity;
import cn.novalon.gym.manage.coach.enums.ViolationReason;
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
import cn.novalon.gym.manage.common.util.RedisUtil;
import cn.novalon.gym.manage.common.util.StatusConstants;
import cn.novalon.gym.manage.groupcourse.dao.GroupCourseBookingDao;
@@ -43,7 +45,6 @@ public class CoachCourseService {
private static final Logger logger = LoggerFactory.getLogger(CoachCourseService.class);
private static final String COACH_ROLE_NAME = "教练";
private static final long ONE_HOUR_MINUTES = 60;
private final ISysUserRepository userRepository;
private final ISysRoleRepository roleRepository;
@@ -56,6 +57,7 @@ public class CoachCourseService {
private final DatabaseClient databaseClient;
private final PasswordEncoder passwordEncoder;
private final RedisUtil redisUtil;
private final CoachTimeRuleService timeRuleService;
public CoachCourseService(ISysUserRepository userRepository,
ISysRoleRepository roleRepository,
@@ -67,7 +69,8 @@ public class CoachCourseService {
CoachViolationDao violationDao,
DatabaseClient databaseClient,
PasswordEncoder passwordEncoder,
RedisUtil redisUtil) {
RedisUtil redisUtil,
CoachTimeRuleService timeRuleService) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.userRoleRepository = userRoleRepository;
@@ -79,6 +82,7 @@ public class CoachCourseService {
this.databaseClient = databaseClient;
this.passwordEncoder = passwordEncoder;
this.redisUtil = redisUtil;
this.timeRuleService = timeRuleService;
}
// ==================== 教练管理(从原 CoachService 迁移) ====================
@@ -176,9 +180,7 @@ public class CoachCourseService {
/**
* 教练手动开课
* 判定逻辑
* - 长课时(>=1h): 10分钟内正常,10~30分钟迟到,>30分钟拒绝
* - 短课时(<1h): 10%时长内正常,10%~25%迟到,>25%拒绝
* 通过 CoachTimeRuleService 匹配规则获取时间阈值,替代原硬编码逻辑
*/
public Mono<GroupCourseEntity> startCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
@@ -196,53 +198,29 @@ public class CoachCourseService {
LocalDateTime now = LocalDateTime.now();
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
return handleLongCourseStart(course, now);
} else {
return handleShortCourseStart(course, now, courseDurationMinutes);
}
return timeRuleService.matchRule(courseDurationMinutes)
.flatMap(rule -> doStartCourseWithRule(course, now, rule));
});
}
private Mono<GroupCourseEntity> handleLongCourseStart(GroupCourseEntity course, LocalDateTime now) {
private Mono<GroupCourseEntity> doStartCourseWithRule(GroupCourseEntity course, LocalDateTime now,
CoachTimeRule rule) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= 10) {
if (minutesSinceStart <= rule.getNormalWindow()) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= 30) {
if (minutesSinceStart <= rule.getLateWindow()) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >30分钟,拒绝(调度器应已标记为缺席)
return Mono.error(new RuntimeException("已超过开课时间30分钟,无法开课"));
}
private Mono<GroupCourseEntity> handleShortCourseStart(GroupCourseEntity course, LocalDateTime now,
long courseDurationMinutes) {
long minutesSinceStart = Duration.between(course.getStartTime(), now).toMinutes();
long thresholdA = Math.max(1, (long) (courseDurationMinutes * 0.10));
long thresholdB = Math.max(1, (long) (courseDurationMinutes * 0.25));
if (minutesSinceStart < 0) {
return Mono.error(new RuntimeException("课程尚未到开课时间"));
}
if (minutesSinceStart <= thresholdA) {
// 正常开课
return doStartCourse(course, now, CourseStatus.IN_PROGRESS, null);
}
if (minutesSinceStart <= thresholdB) {
// 教练迟到
return recordViolation(course.getCoachId(), course.getId(), now, ViolationReason.COACH_LATE)
.then(doStartCourse(course, now, CourseStatus.COACH_LATE, ViolationReason.COACH_LATE));
}
// >thresholdB,拒绝
return Mono.error(new RuntimeException("已超过开课时间,无法开课"));
// 超过 lateWindow,拒绝
return Mono.error(new RuntimeException("已超过开课时间" + rule.getLateWindow() + "分钟,无法开课"));
}
private Mono<GroupCourseEntity> doStartCourse(GroupCourseEntity course, LocalDateTime now,
@@ -260,7 +238,7 @@ public class CoachCourseService {
/**
* 教练手动结课
* 可在 IN_PROGRESS(3) 或 COACH_LATE(7) 状态下结课
* 必须在标注结课时间 + 10分钟内
* 必须在标注结课时间 + endGrace 分钟内
*/
public Mono<GroupCourseEntity> endCourse(Long courseId, Long coachId) {
return groupCourseDao.findByIdIsAndDeletedAtIsNull(courseId)
@@ -278,18 +256,25 @@ public class CoachCourseService {
}
LocalDateTime now = LocalDateTime.now();
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
long courseDurationMinutes = Duration.between(course.getStartTime(), course.getEndTime()).toMinutes();
if (minutesAfterEnd > 10) {
return Mono.error(new RuntimeException("已超过结课时间10分钟,请等待系统自动结课"));
}
return timeRuleService.matchRule(courseDurationMinutes)
.flatMap(rule -> {
long minutesAfterEnd = Duration.between(course.getEndTime(), now).toMinutes();
course.setStatus(CourseStatus.ENDED.getValue());
course.setActualEndTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateEndInfo(course.getId(), String.valueOf(CourseStatus.ENDED.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
if (minutesAfterEnd > rule.getEndGrace()) {
return Mono.error(new RuntimeException(
"已超过结课时间" + rule.getEndGrace() + "分钟,请等待系统自动结课"));
}
course.setStatus(CourseStatus.ENDED.getValue());
course.setActualEndTime(now);
course.setUpdatedAt(now);
return groupCourseDao.updateEndInfo(course.getId(),
String.valueOf(CourseStatus.ENDED.getValue()), now, now)
.then(groupCourseDao.findByIdIsAndDeletedAtIsNull(course.getId()))
.flatMap(entity -> invalidateStatisticsCache().thenReturn(entity));
});
});
}