feat: 提交课表冲突检查相关功能模块

This commit is contained in:
dengxiaoyue
2026-08-21 14:39:03 +08:00
parent 179b12e3bf
commit f190c29e05
857 changed files with 10838 additions and 5 deletions
@@ -0,0 +1,144 @@
package com.roomroot.jwgl.service;
import com.roomroot.jwgl.entity.TimetableConflictResult;
import com.roomroot.jwgl.unit.PageResult;
import com.roomroot.jwgl.unit.conflict.TimetableConflictDimension;
import com.roomroot.jwgl.vo.timetableconflict.TimetableConflictCardVO;
import com.roomroot.jwgl.vo.timetableconflict.TimetableConflictDetailVO;
import com.roomroot.jwgl.vo.timetableconflict.TimetableConflictSummaryVO;
import java.util.List;
/**
* 课表冲突检查服务接口。
* <p>
* 现在支持两套"存储/读取"机制,调用方可按需选择:
* <ul>
* <li><b>内存模式(默认,兼容旧逻辑)</b>:检查结果只存在 ConcurrentHashMap,JVM 重启丢失。
* 方法签名不带 jcpch/useDb。</li>
* <li><b>落库模式(方案 A)</b>:检查结果同时写"课表_冲突检查结果"表,
* 分页可按 useDb=true / jcpch 指定某一批次从 DB 读。</li>
* </ul>
* </p>
*
* @author management
*/
public interface TimetableConflictService {
/**
* 加载课表冲突检查页面的初始化汇总(卡片列表)。
* <p>
* 用于 checkAll / reset 内部生成卡片结构;同时也可以返回"已落库历史检查结果回填"的状态,
* 若该年度该维度在 DB 有最新批次也会自动填充 checked=true。
* </p>
*/
TimetableConflictSummaryVO loadSummary(Integer nd);
// =========================================================
// 检查相关(兼容旧签名 + 新签名)
// =========================================================
/**
* 【旧签名,内存模式】单维度检查。
* 等价于 {@code checkDimension(nd, dimension, null, false)}。
*/
default TimetableConflictCardVO checkDimension(Integer nd, TimetableConflictDimension dimension) {
return checkDimension(nd, dimension, null, false);
}
/**
* 【完整签名】单维度检查。
*
* @param nd 年度
* @param dimension 维度枚举
* @param jcpch 检查批次号(=null 则内部自动生成;checkAll 调用时所有维度传同一个批次号)
* @param writeToDb true=同时写入落库表"课表_冲突检查结果",false=只写内存缓存
* @return 卡片结果 VO(内部会保证 conflictCount / checked 的正确性)
*/
TimetableConflictCardVO checkDimension(Integer nd, TimetableConflictDimension dimension,
String jcpch, boolean writeToDb);
/**
* 【旧签名,内存模式】全部维度检查。等价 checkAll(nd, false)。
*/
default TimetableConflictSummaryVO checkAll(Integer nd) {
return checkAll(nd, false);
}
/**
* 【完整签名】全部维度检查。
*
* @param nd 年度
* @param writeToDb true=写落库表(自动生成统一批次号,所有维度共用;返回 summary 会带上最后生成的批次号),false=只写内存
* @return 汇总 VO
*/
TimetableConflictSummaryVO checkAll(Integer nd, boolean writeToDb);
/**
* 返回 checkAll(writeToDb=true) 生成的批次号(如果 writeToDb=false,返回 null)。
* 提供给 Controller 层把"本次检查批次号"放到 Result.message / data 扩展字段给前端用。
*/
String getLastBatchNo();
// =========================================================
// 重置
// =========================================================
/**
* 【旧签名】重置:清内存 + 不清 DB。
*/
default TimetableConflictSummaryVO reset(Integer nd) {
return reset(nd, false);
}
/**
* 重置检查结果。
*
* @param nd 年度
* @param clearDb true=同时 DELETE 落库表里该年度的所有批次;false=只清内存缓存(保持历史)
*/
TimetableConflictSummaryVO reset(Integer nd, boolean clearDb);
// =========================================================
// 分页查询明细
// =========================================================
/**
* 【旧签名,内存模式】分页查询明细。
* 等价于 {@code getDetailsPage(nd, dimensionCode, pageNum, pageSize, false, null)}。
*/
default PageResult<TimetableConflictDetailVO> getDetailsPage(Integer nd, String dimensionCode,
Integer pageNum, Integer pageSize) {
return getDetailsPage(nd, dimensionCode, pageNum, pageSize, false, null);
}
/**
* 【完整签名】分页查询明细。
*
* @param nd 年度
* @param dimensionCode 维度编码,null / "ALL"=合并全部维度
* @param pageNum 页码
* @param pageSize 每页大小
* @param useDb true=从"课表_冲突检查结果"表查;false=从内存缓存查(旧行为)
* @param jcpch useDb=true 时可选:具体查哪一个批次号;null 自动取该年度 DB 里"最新批次号"(MAX 创建时间对应批次号)
*/
PageResult<TimetableConflictDetailVO> getDetailsPage(Integer nd, String dimensionCode,
Integer pageNum, Integer pageSize,
boolean useDb, String jcpch);
// =========================================================
// 便捷方法:批次号列表查询、实体→VO 转换等
// =========================================================
/**
* 查询指定年度的所有检查批次号(倒序,最新批次排第一个)。
* 给前端下拉框"选择某次历史检查查看详情"。
*/
List<String> listBatchNos(Integer nd);
/**
* 把一条"课表_冲突检查结果"实体转成前端明细表格需要的 TimetableConflictDetailVO。
*/
TimetableConflictDetailVO toDetailVO(TimetableConflictResult entity);
}
@@ -0,0 +1,438 @@
package com.roomroot.jwgl.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.roomroot.jwgl.entity.TimetableConflictResult;
import com.roomroot.jwgl.mapper.SSKCBFZJYMapper;
import com.roomroot.jwgl.mapper.SSKCBJSMapper;
import com.roomroot.jwgl.mapper.SSKCBXYDMapper;
import com.roomroot.jwgl.mapper.TimetableConflictResultMapper;
import com.roomroot.jwgl.service.TimetableConflictService;
import com.roomroot.jwgl.unit.PageResult;
import com.roomroot.jwgl.unit.conflict.TimetableConflictDimension;
import com.roomroot.jwgl.vo.timetableconflict.TimetableConflictCardVO;
import com.roomroot.jwgl.vo.timetableconflict.TimetableConflictDetailVO;
import com.roomroot.jwgl.vo.timetableconflict.TimetableConflictSummaryVO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
/**
* 课表冲突检查服务实现类。
* <p>
* 现在支持:
* <ul>
* <li><b>内存模式(兼容旧前端)</b>:ConcurrentHashMap 缓存(默认行为,不改代码也能跑);</li>
* <li><b>落库模式(方案 A)</b>:检查结果写入【课表_冲突检查结果】表,分页可用 useDb=true 查询。</li>
* </ul>
* </p>
*
* @author management
*/
@Slf4j
@Service
public class TimetableConflictServiceImpl implements TimetableConflictService {
// =====================================================================
// 依赖注入
// =====================================================================
@Resource
private SSKCBXYDMapper sskcbxydMapper;
@Resource
private SSKCBFZJYMapper sskcbfzjyMapper;
@Resource
private SSKCBJSMapper sskcbjsMapper;
/**
* 方案 A 落库表 Mapper(继承 MP BaseMapper,开箱自带 insert / selectList / delete)。
*/
@Resource
private TimetableConflictResultMapper conflictResultMapper;
// =====================================================================
// 内存缓存(不需要持久化到数据库,临时保存每次检查结果)
// Key 格式:"<年度>_<维度编码>" 例:"2026_TEACHER_CONFLICT"
// =====================================================================
private final ConcurrentHashMap<String, List<TimetableConflictDetailVO>> detailCache
= new ConcurrentHashMap<>();
private String cacheKey(Integer nd, String dimensionCode) {
return nd + "_" + dimensionCode;
}
/** 最近一次 checkAll(writeToDb=true) 生成的批次号 */
private volatile String lastBatchNo;
private static final DateTimeFormatter BATCH_FMT
= DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS");
// =====================================================================
// 工具:生成批次号 / VO ↔ Entity 转换
// =====================================================================
private String generateBatchNo(Integer nd) {
// 格式:{年度}_{yyyyMMdd-HHmmss-SSS},例:2026_20260821-153000-123
String ts = LocalDateTime.now().format(BATCH_FMT);
return nd + "_" + ts;
}
private TimetableConflictResult toResultEntity(Integer nd, String jcpch,
TimetableConflictDetailVO vo) {
TimetableConflictResult e = new TimetableConflictResult();
e.setNd(nd);
e.setJcpch(jcpch);
e.setWdbm(vo.getDimensionCode());
e.setWdbt(vo.getDimensionTitle());
e.setZybh(vo.getResourceId());
e.setZymc(vo.getResourceName());
e.setRq(vo.getRq());
e.setJc(vo.getJc());
e.setZykcsl(vo.getOccupiedLessonCount());
e.setKcmclb(vo.getCourseNames());
e.setCtkcbhlb(vo.getConflictSskcbBhs());
e.setCtyy(vo.getReason());
e.setCth(vo.getConflictNo());
e.setBcmclb(vo.getXydNames());
e.setJyxmlb(vo.getTeacherNames());
e.setCdmclb(vo.getClassroomNames());
e.setZrdwlb(vo.getResponsibleDept());
return e;
}
@Override
public TimetableConflictDetailVO toDetailVO(TimetableConflictResult e) {
if (e == null) return null;
return TimetableConflictDetailVO.builder()
.dimensionCode(e.getWdbm())
.dimensionTitle(e.getWdbt())
.resourceId(e.getZybh())
.resourceName(e.getZymc())
.rq(e.getRq())
.jc(e.getJc())
.occupiedLessonCount(e.getZykcsl())
.courseNames(e.getKcmclb())
.conflictSskcbBhs(e.getCtkcbhlb())
.reason(e.getCtyy())
.conflictNo(e.getCth())
.xydNames(e.getBcmclb())
.teacherNames(e.getJyxmlb())
.classroomNames(e.getCdmclb())
.responsibleDept(e.getZrdwlb())
.build();
}
// =====================================================================
// 接口实现:初始化汇总
// =====================================================================
@Override
public TimetableConflictSummaryVO loadSummary(Integer nd) {
TimetableConflictSummaryVO summary = new TimetableConflictSummaryVO();
summary.setNd(nd);
for (TimetableConflictDimension dim : TimetableConflictDimension.values()) {
TimetableConflictCardVO card = TimetableConflictCardVO.builder()
.dimensionCode(dim.getCode())
.title(dim.getTitle())
.description(dim.getDescription())
.checked(false)
.conflictCount(0)
.build();
// 1) 优先:内存缓存有就回填(最高优先级:刚点过检查的结果)
List<TimetableConflictDetailVO> cached = detailCache.get(cacheKey(nd, dim.getCode()));
if (cached != null) {
card.setChecked(true);
card.setConflictCount(cached.size());
} else {
// 2) 兜底:DB 里该年度最新批次有数据就回填(避免"重启完 JVM 卡片全变未检查")
Long dbCnt = countLatestInDb(nd, dim.getCode());
if (dbCnt != null && dbCnt > 0) {
card.setChecked(true);
card.setConflictCount(dbCnt.intValue());
}
}
summary.getCards().add(card);
}
summary.recalculateTotal();
return summary;
}
/**
* DB 辅助:返回某年度某维度在"最新批次"中的冲突条数。
*/
private Long countLatestInDb(Integer nd, String wdbm) {
// 1. 查该年度在 DB 中"最新批次号"
List<String> batchNos = listBatchNos(nd);
if (batchNos.isEmpty()) return null;
String latest = batchNos.get(0);
// 2. 按 nd+jcpch+wdbm count
LambdaQueryWrapper<TimetableConflictResult> qw = new LambdaQueryWrapper<>();
qw.eq(TimetableConflictResult::getNd, nd)
.eq(TimetableConflictResult::getJcpch, latest)
.eq(TimetableConflictResult::getWdbm, wdbm);
try {
return conflictResultMapper.selectCount(qw);
} catch (Exception ex) {
log.warn("[课表冲突检查] 回填DB历史冲突数失败(nd={},wdbm={}): {}", nd, wdbm, ex.getMessage());
return null;
}
}
// =====================================================================
// 接口实现:单维度检查(完整签名 = 内存 + 可选落库)
// =====================================================================
@Override
public TimetableConflictCardVO checkDimension(Integer nd, TimetableConflictDimension dimension,
String jcpch, boolean writeToDb) {
if (dimension == null) {
throw new IllegalArgumentException("维度不能为空");
}
// 1. 跑聚合 SQL(和旧版完全一样,保证结果一致性)
List<TimetableConflictDetailVO> details;
long startMs = System.currentTimeMillis();
try {
details = switch (dimension) {
case TEAM_CONFLICT -> sskcbxydMapper.selectTeamConflictDetails(nd);
case ELECTIVE_REQUIRED_CONFLICT -> sskcbxydMapper.selectElectiveRequiredConflictDetails(nd);
case TEACHER_CONFLICT -> sskcbfzjyMapper.selectTeacherConflictDetails(nd);
case CLASSROOM_CONFLICT -> sskcbjsMapper.selectClassroomConflictDetails(nd);
case GUARANTEE_CONFLICT, EVENT_CONFLICT -> Collections.emptyList();
};
} catch (Exception e) {
log.error("[课表冲突检查][维度:{}][年度:{}] 执行SQL失败: {}",
dimension.getCode(), nd, e.getMessage(), e);
details = Collections.emptyList();
}
if (details == null) details = Collections.emptyList();
long costMs = System.currentTimeMillis() - startMs;
log.info("[课表冲突检查][维度:{}][年度:{}] SQL完成, {} 条冲突, 耗时 {} ms",
dimension.getCode(), nd, details.size(), costMs);
// 2. 写内存缓存(保持原逻辑,旧前端不动也能看)
detailCache.put(cacheKey(nd, dimension.getCode()), details);
// 3. 可选:写落库表(方案 A)
if (writeToDb) {
String batch = jcpch;
if (batch == null || batch.isEmpty()) {
batch = generateBatchNo(nd);
}
persistOneDimension(nd, batch, dimension.getCode(), details);
}
// 4. 返回卡片
return TimetableConflictCardVO.builder()
.dimensionCode(dimension.getCode())
.title(dimension.getTitle())
.description(dimension.getDescription())
.checked(true)
.conflictCount(details.size())
.build();
}
/**
* 把一个维度的明细持久化到【课表_冲突检查结果】。
* 幂等策略:先 DELETE (nd, jcpch, wdbm) 再 INSERT 明细,保证同一批次重跑不重复。
*/
private void persistOneDimension(Integer nd, String jcpch, String wdbm,
List<TimetableConflictDetailVO> details) {
long start = System.currentTimeMillis();
// 1) 幂等清理
LambdaQueryWrapper<TimetableConflictResult> qw = new LambdaQueryWrapper<>();
qw.eq(TimetableConflictResult::getNd, nd)
.eq(TimetableConflictResult::getJcpch, jcpch)
.eq(TimetableConflictResult::getWdbm, wdbm);
conflictResultMapper.delete(qw);
// 2) 逐条 INSERT(一次检查一个维度最多几千条,直接循环够用;要更高性能改成 SqlSession Batch)
int ok = 0, fail = 0;
for (TimetableConflictDetailVO vo : details) {
try {
conflictResultMapper.insert(toResultEntity(nd, jcpch, vo));
ok++;
} catch (Exception e) {
fail++;
if (fail <= 3) {
log.warn("[课表冲突检查][落库] nd={},jcpch={},wdbm={},插入冲突号={}失败: {}",
nd, jcpch, wdbm, vo.getConflictNo(), e.getMessage());
}
}
}
log.info("[课表冲突检查][落库][维度:{}] nd={},jcpch={},成功{}条/失败{}条,耗时{}ms",
wdbm, nd, jcpch, ok, fail, (System.currentTimeMillis() - start));
}
// =====================================================================
// 接口实现:全部维度检查(完整签名)
// =====================================================================
@Override
public TimetableConflictSummaryVO checkAll(Integer nd, boolean writeToDb) {
TimetableConflictSummaryVO summary = loadSummary(nd);
// 1) writeToDb 模式:统一生成一个批次号,6 个维度共用
String batchNo = null;
if (writeToDb) {
batchNo = generateBatchNo(nd);
lastBatchNo = batchNo;
log.info("[课表冲突检查][checkAll] nd={}, 生成批次号={}", nd, batchNo);
} else {
lastBatchNo = null;
}
// 2) 按卡片顺序串行检查
for (int i = 0; i < summary.getCards().size(); i++) {
TimetableConflictCardVO old = summary.getCards().get(i);
TimetableConflictDimension dim = TimetableConflictDimension.of(old.getDimensionCode());
if (dim == null) continue;
TimetableConflictCardVO newCard = checkDimension(nd, dim, batchNo, writeToDb);
summary.getCards().set(i, newCard);
}
summary.recalculateTotal();
summary.setFullyChecked(true);
return summary;
}
@Override
public String getLastBatchNo() {
return lastBatchNo;
}
// =====================================================================
// 接口实现:重置
// =====================================================================
@Override
public TimetableConflictSummaryVO reset(Integer nd, boolean clearDb) {
// 1) 清内存缓存
Iterator<Map.Entry<String, List<TimetableConflictDetailVO>>> it
= detailCache.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, List<TimetableConflictDetailVO>> e = it.next();
if (e.getKey().startsWith(nd + "_")) {
it.remove();
}
}
lastBatchNo = null;
log.info("[课表冲突检查][年度:{}] 已重置内存缓存", nd);
// 2) 可选:清落库表(方案A 硬删除,不是逻辑删除——重置意为"不留任何检查痕迹")
if (clearDb) {
int del = conflictResultMapper.deleteByNd(nd);
log.info("[课表冲突检查][年度:{}] 已清空落库表记录 {} 条", nd, del);
}
return loadSummary(nd);
}
// =====================================================================
// 接口实现:明细分页(支持 useDb)
// =====================================================================
@Override
public PageResult<TimetableConflictDetailVO> getDetailsPage(Integer nd, String dimensionCode,
Integer pageNum, Integer pageSize,
boolean useDb, String jcpch) {
// 兜底分页参数
if (pageNum == null || pageNum < 1) pageNum = 1;
if (pageSize == null || pageSize < 1) pageSize = 20;
List<TimetableConflictDetailVO> all = useDb
? buildListFromDb(nd, dimensionCode, jcpch)
: buildListFromCache(nd, dimensionCode);
// 排序(不管缓存/DB,统一按 日期 → 节次 → 维度 排)
all.sort(Comparator
.comparing((TimetableConflictDetailVO v) -> v.getRq() == null ? "" : v.getRq().toString())
.thenComparing(v -> v.getJc() == null ? 0 : v.getJc())
.thenComparing(v -> v.getDimensionCode() == null ? "" : v.getDimensionCode()));
// 手动分页
long total = all.size();
int fromIndex = Math.min((pageNum - 1) * pageSize, (int) total);
int toIndex = Math.min(fromIndex + pageSize, (int) total);
List<TimetableConflictDetailVO> pageData = total == 0
? Collections.emptyList()
: all.subList(fromIndex, toIndex);
return new PageResult<>(pageData, total, pageNum, pageSize);
}
private List<TimetableConflictDetailVO> buildListFromCache(Integer nd, String dimensionCode) {
List<TimetableConflictDetailVO> all = new ArrayList<>();
for (TimetableConflictDimension dim : TimetableConflictDimension.values()) {
if (dimensionCode != null && !"ALL".equalsIgnoreCase(dimensionCode)
&& !dim.getCode().equalsIgnoreCase(dimensionCode)) {
continue;
}
List<TimetableConflictDetailVO> part = detailCache.get(cacheKey(nd, dim.getCode()));
if (part != null && !part.isEmpty()) all.addAll(part);
}
return all;
}
private List<TimetableConflictDetailVO> buildListFromDb(Integer nd, String dimensionCode,
String jcpch) {
// 批次号没传 → 取该年度最新批次
String batch = jcpch;
if (batch == null || batch.isEmpty()) {
List<String> batches = listBatchNos(nd);
if (batches.isEmpty()) {
return Collections.emptyList();
}
batch = batches.get(0);
}
// 从 DB 拉该 nd+jcpch 的所有结果行
List<TimetableConflictResult> rows = conflictResultMapper.listByNdAndJcpch(nd, batch);
if (rows == null || rows.isEmpty()) return Collections.emptyList();
// 按 dimensionCode 过滤(null/ALL=保留全部),再实体→VO
return rows.stream()
.filter(r -> {
if (dimensionCode == null || "ALL".equalsIgnoreCase(dimensionCode)) return true;
return dimensionCode.equalsIgnoreCase(r.getWdbm());
})
.map(this::toDetailVO)
.collect(Collectors.toList());
}
// =====================================================================
// 便捷方法:批次号列表(倒序,最新排第一)
// =====================================================================
@Override
public List<String> listBatchNos(Integer nd) {
// 用 MP QueryWrapper:SELECT DISTINCT "检查批次号" FROM 表 WHERE "年度" = ? ORDER BY MAX("创建时间") DESC
QueryWrapper<TimetableConflictResult> qw = new QueryWrapper<>();
qw.select("检查批次号 AS jcpch")
.eq("年度", nd)
.groupBy("检查批次号")
.orderByDesc("MAX(创建时间)");
try {
List<Object> objs = conflictResultMapper.selectObjs(qw);
List<String> res = new ArrayList<>(objs.size());
for (Object o : objs) {
if (o != null) res.add(String.valueOf(o));
}
return res;
} catch (Exception e) {
// 表若不存在 / 列名报错 → 不影响主流程,返回空
log.warn("[课表冲突检查] 查询批次号列表异常(nd={}): {}", nd, e.getMessage());
return Collections.emptyList();
}
}
}