新增到课签到时间窗口与迟到签到时间窗口配置,优化教练评分机制(未测试)
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>gym-manage-api</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>gym-coach-config</artifactId>
|
||||
<packaging>jar</packaging>
|
||||
|
||||
<name>Gym Coach Config</name>
|
||||
<description>Coach Time Rule Configuration Module - Configurable coach lateness/absence thresholds</description>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-common</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-db</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>cn.novalon.gym.manage</groupId>
|
||||
<artifactId>manage-sys</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.data</groupId>
|
||||
<artifactId>spring-data-commons</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springdoc</groupId>
|
||||
<artifactId>springdoc-openapi-starter-webflux-ui</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package cn.novalon.gym.manage.coachconfig.converter;
|
||||
|
||||
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 教练时间规则实体转换器
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-07-26
|
||||
*/
|
||||
@Component
|
||||
public class CoachTimeRuleConverter {
|
||||
|
||||
public CoachTimeRule toDomain(CoachTimeRuleEntity entity) {
|
||||
if (entity == null) {
|
||||
return null;
|
||||
}
|
||||
CoachTimeRule domain = new CoachTimeRule();
|
||||
domain.setId(entity.getId());
|
||||
domain.setMinDuration(entity.getMinDuration());
|
||||
domain.setMaxDuration(entity.getMaxDuration());
|
||||
domain.setNormalWindow(entity.getNormalWindow());
|
||||
domain.setLateWindow(entity.getLateWindow());
|
||||
domain.setEndGrace(entity.getEndGrace());
|
||||
domain.setIsDefault(entity.getIsDefault());
|
||||
domain.setSortOrder(entity.getSortOrder());
|
||||
domain.setStatus(entity.getStatus());
|
||||
domain.setRemark(entity.getRemark());
|
||||
domain.setCreatedAt(entity.getCreatedAt());
|
||||
domain.setUpdatedAt(entity.getUpdatedAt());
|
||||
return domain;
|
||||
}
|
||||
|
||||
public CoachTimeRuleEntity toEntity(CoachTimeRule domain) {
|
||||
if (domain == null) {
|
||||
return null;
|
||||
}
|
||||
CoachTimeRuleEntity entity = new CoachTimeRuleEntity();
|
||||
entity.setId(domain.getId());
|
||||
entity.setMinDuration(domain.getMinDuration());
|
||||
entity.setMaxDuration(domain.getMaxDuration());
|
||||
entity.setNormalWindow(domain.getNormalWindow());
|
||||
entity.setLateWindow(domain.getLateWindow());
|
||||
entity.setEndGrace(domain.getEndGrace());
|
||||
entity.setIsDefault(domain.getIsDefault());
|
||||
entity.setSortOrder(domain.getSortOrder());
|
||||
entity.setStatus(domain.getStatus());
|
||||
entity.setRemark(domain.getRemark());
|
||||
entity.setCreateBy(domain.getCreateBy());
|
||||
entity.setUpdateBy(domain.getUpdateBy());
|
||||
entity.setCreatedAt(domain.getCreatedAt());
|
||||
entity.setUpdatedAt(domain.getUpdatedAt());
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
package cn.novalon.gym.manage.coachconfig.domain;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 教练时间规则领域对象
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-07-26
|
||||
*/
|
||||
public class CoachTimeRule {
|
||||
|
||||
private Long id;
|
||||
private Integer minDuration;
|
||||
private Integer maxDuration;
|
||||
private Integer normalWindow;
|
||||
private Integer lateWindow;
|
||||
private Integer endGrace;
|
||||
private Boolean isDefault;
|
||||
private Integer sortOrder;
|
||||
private String status;
|
||||
private String remark;
|
||||
private String createBy;
|
||||
private String updateBy;
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
/**
|
||||
* 判断该规则是否匹配给定课程时长
|
||||
*/
|
||||
public boolean matches(long courseDurationMinutes) {
|
||||
boolean aboveMin = minDuration == null || courseDurationMinutes >= minDuration;
|
||||
boolean belowMax = maxDuration == null || courseDurationMinutes <= maxDuration;
|
||||
return aboveMin && belowMax;
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回规则区间的宽度(用于精确匹配排序),无边界时返回 Integer.MAX_VALUE
|
||||
*/
|
||||
public int rangeWidth() {
|
||||
if (minDuration == null && maxDuration == null) {
|
||||
return Integer.MAX_VALUE;
|
||||
}
|
||||
if (minDuration == null) {
|
||||
return maxDuration;
|
||||
}
|
||||
if (maxDuration == null) {
|
||||
return Integer.MAX_VALUE - minDuration;
|
||||
}
|
||||
return maxDuration - minDuration;
|
||||
}
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public Integer getMinDuration() { return minDuration; }
|
||||
public void setMinDuration(Integer minDuration) { this.minDuration = minDuration; }
|
||||
|
||||
public Integer getMaxDuration() { return maxDuration; }
|
||||
public void setMaxDuration(Integer maxDuration) { this.maxDuration = maxDuration; }
|
||||
|
||||
public Integer getNormalWindow() { return normalWindow; }
|
||||
public void setNormalWindow(Integer normalWindow) { this.normalWindow = normalWindow; }
|
||||
|
||||
public Integer getLateWindow() { return lateWindow; }
|
||||
public void setLateWindow(Integer lateWindow) { this.lateWindow = lateWindow; }
|
||||
|
||||
public Integer getEndGrace() { return endGrace; }
|
||||
public void setEndGrace(Integer endGrace) { this.endGrace = endGrace; }
|
||||
|
||||
public Boolean getIsDefault() { return isDefault; }
|
||||
public void setIsDefault(Boolean isDefault) { this.isDefault = isDefault; }
|
||||
|
||||
public Integer getSortOrder() { return sortOrder; }
|
||||
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
|
||||
|
||||
public String getStatus() { return status; }
|
||||
public void setStatus(String status) { this.status = status; }
|
||||
|
||||
public String getRemark() { return remark; }
|
||||
public void setRemark(String remark) { this.remark = remark; }
|
||||
|
||||
public String getCreateBy() { return createBy; }
|
||||
public void setCreateBy(String createBy) { this.createBy = createBy; }
|
||||
|
||||
public String getUpdateBy() { return updateBy; }
|
||||
public void setUpdateBy(String updateBy) { this.updateBy = updateBy; }
|
||||
|
||||
public LocalDateTime getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
|
||||
|
||||
public LocalDateTime getUpdatedAt() { return updatedAt; }
|
||||
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
|
||||
}
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
package cn.novalon.gym.manage.coachconfig.handler;
|
||||
|
||||
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||
import cn.novalon.gym.manage.coachconfig.service.CoachTimeRuleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.server.ServerRequest;
|
||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 教练时间规则 HTTP 处理器
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-07-26
|
||||
*/
|
||||
@Component
|
||||
@Tag(name = "教练时间规则配置", description = "教练迟到/缺席时间阈值的可配置化管理")
|
||||
public class CoachTimeRuleHandler {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CoachTimeRuleHandler.class);
|
||||
private final CoachTimeRuleService service;
|
||||
|
||||
public CoachTimeRuleHandler(CoachTimeRuleService service) {
|
||||
this.service = service;
|
||||
}
|
||||
|
||||
@Operation(summary = "获取所有规则", description = "获取所有启用和停用的教练时间规则")
|
||||
public Mono<ServerResponse> getAllRules(ServerRequest request) {
|
||||
return service.getAllRules()
|
||||
.collectList()
|
||||
.flatMap(rules -> ServerResponse.ok().bodyValue(rules))
|
||||
.onErrorResume(e -> {
|
||||
logger.error("获取规则列表失败: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "获取单条规则", description = "根据ID获取教练时间规则")
|
||||
public Mono<ServerResponse> getRuleById(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return service.getRuleById(id)
|
||||
.flatMap(rule -> ServerResponse.ok().bodyValue(rule))
|
||||
.onErrorResume(e -> {
|
||||
logger.error("获取规则失败: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "创建规则", description = "创建一条新的教练时间规则,创建后立即生效")
|
||||
public Mono<ServerResponse> createRule(ServerRequest request) {
|
||||
return request.bodyToMono(CoachTimeRule.class)
|
||||
.flatMap(rule -> service.createRule(rule)
|
||||
.flatMap(saved -> {
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("message", "规则创建成功");
|
||||
result.put("id", saved.getId());
|
||||
return ServerResponse.ok().bodyValue(result);
|
||||
}))
|
||||
.onErrorResume(e -> {
|
||||
logger.error("创建规则失败: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "更新规则", description = "更新教练时间规则,更新后立即生效")
|
||||
public Mono<ServerResponse> updateRule(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return request.bodyToMono(CoachTimeRule.class)
|
||||
.flatMap(rule -> service.updateRule(id, rule)
|
||||
.flatMap(updated -> ServerResponse.ok().bodyValue(Map.of("message", "规则更新成功"))))
|
||||
.onErrorResume(e -> {
|
||||
logger.error("更新规则失败: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||
});
|
||||
}
|
||||
|
||||
@Operation(summary = "删除规则", description = "软删除教练时间规则,删除后立即生效")
|
||||
public Mono<ServerResponse> deleteRule(ServerRequest request) {
|
||||
Long id = Long.valueOf(request.pathVariable("id"));
|
||||
return service.deleteRule(id)
|
||||
.then(ServerResponse.ok().bodyValue(Map.of("message", "规则删除成功")))
|
||||
.onErrorResume(e -> {
|
||||
logger.error("删除规则失败: {}", e.getMessage());
|
||||
return ServerResponse.badRequest().bodyValue(Map.of("error", e.getMessage()));
|
||||
});
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package cn.novalon.gym.manage.coachconfig.repository;
|
||||
|
||||
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||
import cn.novalon.gym.manage.coachconfig.converter.CoachTimeRuleConverter;
|
||||
import cn.novalon.gym.manage.db.dao.CoachTimeRuleDao;
|
||||
import cn.novalon.gym.manage.db.entity.CoachTimeRuleEntity;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 教练时间规则仓储实现类
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-07-26
|
||||
*/
|
||||
@Repository
|
||||
public class CoachTimeRuleRepository implements ICoachTimeRuleRepository {
|
||||
|
||||
private final CoachTimeRuleDao dao;
|
||||
private final CoachTimeRuleConverter converter;
|
||||
|
||||
public CoachTimeRuleRepository(CoachTimeRuleDao dao, CoachTimeRuleConverter converter) {
|
||||
this.dao = dao;
|
||||
this.converter = converter;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<CoachTimeRule> findByStatusAndDeletedAtIsNull(String status) {
|
||||
return dao.findByStatusAndDeletedAtIsNull(status, Sort.by(Sort.Direction.ASC, "sort_order"))
|
||||
.map(converter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<CoachTimeRule> findByDeletedAtIsNull() {
|
||||
return dao.findByDeletedAtIsNull()
|
||||
.map(converter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<CoachTimeRule> findById(Long id) {
|
||||
return dao.findByIdAndDeletedAtIsNull(id)
|
||||
.map(converter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<CoachTimeRule> save(CoachTimeRule rule) {
|
||||
CoachTimeRuleEntity entity = converter.toEntity(rule);
|
||||
entity.setCreatedAt(rule.getId() == null ? LocalDateTime.now() : entity.getCreatedAt());
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
return dao.save(entity)
|
||||
.map(converter::toDomain);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> deleteById(Long id) {
|
||||
return dao.findByIdAndDeletedAtIsNull(id)
|
||||
.flatMap(entity -> {
|
||||
entity.setDeletedAt(LocalDateTime.now());
|
||||
return dao.save(entity);
|
||||
})
|
||||
.then();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> unsetOtherDefaults(Long excludeId) {
|
||||
return dao.findByIsDefaultTrueAndStatusAndDeletedAtIsNull("1")
|
||||
.filter(entity -> excludeId == null || !entity.getId().equals(excludeId))
|
||||
.flatMap(entity -> {
|
||||
entity.setIsDefault(false);
|
||||
entity.setUpdatedAt(LocalDateTime.now());
|
||||
return dao.save(entity);
|
||||
})
|
||||
.then();
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package cn.novalon.gym.manage.coachconfig.repository;
|
||||
|
||||
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 教练时间规则仓储接口
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-07-26
|
||||
*/
|
||||
public interface ICoachTimeRuleRepository {
|
||||
|
||||
Flux<CoachTimeRule> findByStatusAndDeletedAtIsNull(String status);
|
||||
|
||||
Flux<CoachTimeRule> findByDeletedAtIsNull();
|
||||
|
||||
Mono<CoachTimeRule> findById(Long id);
|
||||
|
||||
Mono<CoachTimeRule> save(CoachTimeRule rule);
|
||||
|
||||
Mono<Void> deleteById(Long id);
|
||||
|
||||
/** 将除指定 id 之外的所有启用默认规则设为非默认(excludeId 为 null 表示清除全部) */
|
||||
Mono<Void> unsetOtherDefaults(Long excludeId);
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
package cn.novalon.gym.manage.coachconfig.service;
|
||||
|
||||
import cn.novalon.gym.manage.coachconfig.domain.CoachTimeRule;
|
||||
import cn.novalon.gym.manage.coachconfig.repository.ICoachTimeRuleRepository;
|
||||
import cn.novalon.gym.manage.common.constant.RedisKeyConstants;
|
||||
import cn.novalon.gym.manage.common.util.RedisUtil;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 教练时间规则服务
|
||||
*
|
||||
* 提供规则 CRUD、缓存管理、以及基于课程时长的规则匹配。
|
||||
* 每次写操作后立即清除 Redis 缓存,读操作使用 Cache-Aside 模式。
|
||||
*
|
||||
* @author 张翔
|
||||
* @date 2026-07-26
|
||||
*/
|
||||
@Service
|
||||
public class CoachTimeRuleService {
|
||||
|
||||
private static final Logger logger = LoggerFactory.getLogger(CoachTimeRuleService.class);
|
||||
|
||||
/** 硬编码兜底值:长课时(>=60分钟)默认正常窗口 */
|
||||
private static final int FALLBACK_LONG_NORMAL_WINDOW = 10;
|
||||
/** 硬编码兜底值:长课时(>=60分钟)默认迟到/缺席窗口 */
|
||||
private static final int FALLBACK_LONG_LATE_WINDOW = 30;
|
||||
/** 硬编码兜底值:短课时(<60分钟)默认正常窗口比例 */
|
||||
private static final double FALLBACK_SHORT_NORMAL_RATIO = 0.10;
|
||||
/** 硬编码兜底值:短课时(<60分钟)默认迟到/缺席窗口比例 */
|
||||
private static final double FALLBACK_SHORT_LATE_RATIO = 0.25;
|
||||
/** 硬编码兜底值:结课宽限期 */
|
||||
private static final int FALLBACK_END_GRACE = 10;
|
||||
/** 长/短课时分界线 */
|
||||
private static final long ONE_HOUR_MINUTES = 60;
|
||||
/** Redis 缓存 TTL(秒) */
|
||||
private static final long CACHE_TTL_SECONDS = 300;
|
||||
|
||||
private final ICoachTimeRuleRepository repository;
|
||||
private final RedisUtil redisUtil;
|
||||
|
||||
public CoachTimeRuleService(ICoachTimeRuleRepository repository, RedisUtil redisUtil) {
|
||||
this.repository = repository;
|
||||
this.redisUtil = redisUtil;
|
||||
}
|
||||
|
||||
// ==================== 规则匹配 ====================
|
||||
|
||||
/**
|
||||
* 根据课程时长(分钟)匹配最精确的时间规则。
|
||||
* 匹配逻辑:
|
||||
* 1. 从缓存/DB 获取所有启用规则
|
||||
* 2. 过滤出 courseDuration 在 [minDuration, maxDuration] 范围内的规则
|
||||
* 3. 选择区间范围最小的(最精确匹配)
|
||||
* 4. 无匹配时使用 isDefault=true 的默认规则
|
||||
* 5. 全部无匹配时使用硬编码兜底值
|
||||
*/
|
||||
public Mono<CoachTimeRule> matchRule(long courseDurationMinutes) {
|
||||
return getActiveRules()
|
||||
.collectList()
|
||||
.map(rules -> doMatch(rules, courseDurationMinutes));
|
||||
}
|
||||
|
||||
private CoachTimeRule doMatch(List<CoachTimeRule> rules, long courseDurationMinutes) {
|
||||
// 过滤出匹配的规则
|
||||
List<CoachTimeRule> matched = rules.stream()
|
||||
.filter(r -> r.matches(courseDurationMinutes))
|
||||
.toList();
|
||||
|
||||
if (!matched.isEmpty()) {
|
||||
// 选择范围最精确(区间宽度最小)的规则
|
||||
return matched.stream()
|
||||
.min(Comparator.comparingInt(CoachTimeRule::rangeWidth))
|
||||
.orElseThrow();
|
||||
}
|
||||
|
||||
// 无匹配,查找默认规则(同时也须匹配区间,防止有区间限制的默认规则覆盖不匹配的课程时长)
|
||||
CoachTimeRule defaultRule = rules.stream()
|
||||
.filter(r -> Boolean.TRUE.equals(r.getIsDefault()) && r.matches(courseDurationMinutes))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
|
||||
if (defaultRule != null) {
|
||||
logger.debug("无精确匹配规则,回退到默认规则 id={}", defaultRule.getId());
|
||||
return defaultRule;
|
||||
}
|
||||
|
||||
// 兜底:返回硬编码默认值构造的虚拟规则
|
||||
logger.warn("无匹配规则且默认规则也不匹配课程时长{}分钟,使用硬编码兜底值", courseDurationMinutes);
|
||||
return buildFallbackRule(courseDurationMinutes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造硬编码兜底规则(不持久化,仅在内存中使用)
|
||||
*/
|
||||
private CoachTimeRule buildFallbackRule(long courseDurationMinutes) {
|
||||
CoachTimeRule fallback = new CoachTimeRule();
|
||||
fallback.setEndGrace(FALLBACK_END_GRACE);
|
||||
if (courseDurationMinutes >= ONE_HOUR_MINUTES) {
|
||||
fallback.setNormalWindow(FALLBACK_LONG_NORMAL_WINDOW);
|
||||
fallback.setLateWindow(FALLBACK_LONG_LATE_WINDOW);
|
||||
} else {
|
||||
fallback.setNormalWindow(Math.max(1, (int) (courseDurationMinutes * FALLBACK_SHORT_NORMAL_RATIO)));
|
||||
fallback.setLateWindow(Math.max(1, (int) (courseDurationMinutes * FALLBACK_SHORT_LATE_RATIO)));
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// ==================== 缓存管理 ====================
|
||||
|
||||
/**
|
||||
* 获取所有启用规则(带 Redis 缓存)
|
||||
*/
|
||||
private Flux<CoachTimeRule> getActiveRules() {
|
||||
return redisUtil.get(RedisKeyConstants.COACH_TIME_RULES)
|
||||
.flatMapMany(cached -> {
|
||||
@SuppressWarnings("unchecked")
|
||||
List<CoachTimeRule> list = (List<CoachTimeRule>) cached;
|
||||
logger.debug("从 Redis 缓存加载教练时间规则,共 {} 条", list.size());
|
||||
return Flux.fromIterable(list);
|
||||
})
|
||||
.switchIfEmpty(Flux.defer(() -> {
|
||||
logger.debug("Redis 缓存未命中,从 DB 加载教练时间规则");
|
||||
return repository.findByStatusAndDeletedAtIsNull("1")
|
||||
.collectList()
|
||||
.flatMapMany(list -> {
|
||||
redisUtil.setWithExpire(RedisKeyConstants.COACH_TIME_RULES, list, CACHE_TTL_SECONDS)
|
||||
.subscribe(
|
||||
ok -> logger.debug("教练时间规则已写入 Redis 缓存,共 {} 条", list.size()),
|
||||
err -> logger.warn("教练时间规则写入 Redis 缓存失败: {}", err.getMessage())
|
||||
);
|
||||
return Flux.fromIterable(list);
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 写操作后清除缓存(热更新入口)
|
||||
*/
|
||||
private void invalidateCache() {
|
||||
redisUtil.delete(RedisKeyConstants.COACH_TIME_RULES)
|
||||
.subscribe(
|
||||
count -> logger.info("教练时间规则缓存已清除"),
|
||||
err -> logger.warn("教练时间规则缓存清除失败: {}", err.getMessage())
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== CRUD ====================
|
||||
|
||||
public Flux<CoachTimeRule> getAllRules() {
|
||||
return repository.findByDeletedAtIsNull();
|
||||
}
|
||||
|
||||
public Mono<CoachTimeRule> getRuleById(Long id) {
|
||||
return repository.findById(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("规则不存在")));
|
||||
}
|
||||
|
||||
public Mono<CoachTimeRule> createRule(CoachTimeRule rule) {
|
||||
return validateRule(rule)
|
||||
.then(Mono.defer(() -> {
|
||||
if (Boolean.TRUE.equals(rule.getIsDefault())) {
|
||||
return repository.unsetOtherDefaults(null).then(repository.save(rule));
|
||||
}
|
||||
return repository.save(rule);
|
||||
}))
|
||||
.doOnSuccess(r -> invalidateCache());
|
||||
}
|
||||
|
||||
public Mono<CoachTimeRule> updateRule(Long id, CoachTimeRule rule) {
|
||||
return repository.findById(id)
|
||||
.switchIfEmpty(Mono.error(new RuntimeException("规则不存在")))
|
||||
.flatMap(existing -> {
|
||||
existing.setMinDuration(rule.getMinDuration());
|
||||
existing.setMaxDuration(rule.getMaxDuration());
|
||||
existing.setNormalWindow(rule.getNormalWindow());
|
||||
existing.setLateWindow(rule.getLateWindow());
|
||||
existing.setEndGrace(rule.getEndGrace());
|
||||
existing.setIsDefault(rule.getIsDefault());
|
||||
existing.setSortOrder(rule.getSortOrder());
|
||||
existing.setStatus(rule.getStatus());
|
||||
existing.setRemark(rule.getRemark());
|
||||
existing.setUpdateBy(rule.getUpdateBy());
|
||||
return validateRule(existing)
|
||||
.then(Mono.defer(() -> {
|
||||
if (Boolean.TRUE.equals(existing.getIsDefault())) {
|
||||
return repository.unsetOtherDefaults(id).then(repository.save(existing));
|
||||
}
|
||||
return repository.save(existing);
|
||||
}));
|
||||
})
|
||||
.doOnSuccess(r -> invalidateCache());
|
||||
}
|
||||
|
||||
public Mono<Void> deleteRule(Long id) {
|
||||
return repository.deleteById(id)
|
||||
.doOnSuccess(v -> invalidateCache());
|
||||
}
|
||||
|
||||
// ==================== 校验 ====================
|
||||
|
||||
/**
|
||||
* 校验规则字段合法性
|
||||
*/
|
||||
private Mono<Void> validateRule(CoachTimeRule rule) {
|
||||
Integer normalWindow = rule.getNormalWindow();
|
||||
if (normalWindow == null || normalWindow < 1 || normalWindow > 1440) {
|
||||
return Mono.error(new RuntimeException("正常开课窗口必须为 1-1440 之间的整数"));
|
||||
}
|
||||
Integer lateWindow = rule.getLateWindow();
|
||||
if (lateWindow == null || lateWindow < normalWindow || lateWindow > 1440) {
|
||||
return Mono.error(new RuntimeException("迟到/缺席窗口必须 >= 正常开课窗口(" + normalWindow + ")且 <= 1440"));
|
||||
}
|
||||
Integer endGrace = rule.getEndGrace();
|
||||
if (endGrace == null || endGrace < 0 || endGrace > 1440) {
|
||||
return Mono.error(new RuntimeException("结课宽限期必须为 0-1440 之间的整数"));
|
||||
}
|
||||
// 默认规则作为兜底,不允许设置时长区间
|
||||
if (Boolean.TRUE.equals(rule.getIsDefault())) {
|
||||
if (rule.getMinDuration() != null || rule.getMaxDuration() != null) {
|
||||
return Mono.error(new RuntimeException("默认规则作为兜底规则,不允许设置时长区间"));
|
||||
}
|
||||
rule.setMinDuration(null);
|
||||
rule.setMaxDuration(null);
|
||||
}
|
||||
// 区间合法性:若 minDuration 和 maxDuration 同时非空,则 maxDuration >= minDuration
|
||||
Integer minDur = rule.getMinDuration();
|
||||
Integer maxDur = rule.getMaxDuration();
|
||||
if (minDur != null && maxDur != null && maxDur < minDur) {
|
||||
return Mono.error(new RuntimeException("时长上限必须 >= 时长下限"));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user