diff --git a/heritage-plugins/heritage-easyexcel/pom.xml b/heritage-plugins/heritage-easyexcel/pom.xml new file mode 100644 index 0000000..c344246 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + org.leocoder.heritage + heritage-plugins + ${revision} + + + + heritage-easyexcel + heritage-easyexcel + EasyExcel导入导出插件 + + + + + org.leocoder.heritage + heritage-common + ${revision} + + + + com.alibaba + easyexcel + + + + org.springframework.boot + spring-boot-starter-aop + + + \ No newline at end of file diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/anno/EnableCoderEasyExcel.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/anno/EnableCoderEasyExcel.java new file mode 100755 index 0000000..06e1d50 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/anno/EnableCoderEasyExcel.java @@ -0,0 +1,18 @@ +package org.leocoder.heritage.easyexcel.anno; + +import org.leocoder.heritage.easyexcel.bigdata.BigDataEasyExcelUtil; +import org.leocoder.heritage.easyexcel.column.ColumnExcelUtil; +import org.leocoder.heritage.easyexcel.core.utils.EasyExcelUtil; +import org.springframework.context.annotation.Import; + +import java.lang.annotation.*; + +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +// core文件夹是核心代码,bigdata是大数据导出,column是自定义列导出 +@Import({EasyExcelUtil.class, ColumnExcelUtil.class, BigDataEasyExcelUtil.class}) +public @interface EnableCoderEasyExcel { + +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/bigdata/BigDataEasyExcelUtil.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/bigdata/BigDataEasyExcelUtil.java new file mode 100755 index 0000000..cedf910 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/bigdata/BigDataEasyExcelUtil.java @@ -0,0 +1,171 @@ +package org.leocoder.heritage.easyexcel.bigdata; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.support.ExcelTypeEnum; +import com.alibaba.excel.write.builder.ExcelWriterBuilder; +import com.alibaba.excel.write.builder.ExcelWriterSheetBuilder; +import com.alibaba.excel.write.metadata.WriteSheet; +import com.alibaba.excel.write.metadata.style.WriteCellStyle; +import com.alibaba.excel.write.metadata.style.WriteFont; +import com.alibaba.excel.write.style.HorizontalCellStyleStrategy; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.BorderStyle; +import org.apache.poi.ss.usermodel.HorizontalAlignment; +import org.apache.poi.ss.usermodel.IndexedColors; +import org.apache.poi.ss.usermodel.VerticalAlignment; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.util.Date; +import java.util.List; + +/** + * @author Leocoder + * @description [EasyExcelUtil千万级别分页] + */ +public class BigDataEasyExcelUtil { + + private static final Logger log = LoggerFactory.getLogger(BigDataEasyExcelUtil.class); + + private static final int MAXROWS = 10000; + + /** + * @description [获取默认表头内容的样式] + */ + private static HorizontalCellStyleStrategy getDefaultHorizontalCellStyleStrategy() { + /** 表头样式 **/ + WriteCellStyle headWriteCellStyle = new WriteCellStyle(); + // 背景色(浅灰色) + headWriteCellStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + // 字体大小 + WriteFont headWriteFont = new WriteFont(); + headWriteFont.setFontHeightInPoints((short) 20); + headWriteCellStyle.setWriteFont(headWriteFont); + //设置表头居中对齐 + headWriteCellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); + /** 内容样式 **/ + WriteCellStyle contentWriteCellStyle = new WriteCellStyle(); + // 内容字体样式(名称、大小) + WriteFont contentWriteFont = new WriteFont(); + contentWriteFont.setFontName("宋体"); + contentWriteFont.setFontHeightInPoints((short) 20); + contentWriteCellStyle.setWriteFont(contentWriteFont); + //设置内容垂直居中对齐 + contentWriteCellStyle.setVerticalAlignment(VerticalAlignment.CENTER); + //设置内容水平居中对齐 + contentWriteCellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); + //设置边框样式 + contentWriteCellStyle.setBorderLeft(BorderStyle.HAIR); + contentWriteCellStyle.setBorderTop(BorderStyle.HAIR); + contentWriteCellStyle.setBorderRight(BorderStyle.HAIR); + contentWriteCellStyle.setBorderBottom(BorderStyle.HAIR); + // 头样式与内容样式合并 + return new HorizontalCellStyleStrategy(headWriteCellStyle, contentWriteCellStyle); + } + + /** + * @description [导出,单个Sheet,最大支持xlsx格式sheet的行数] + */ + public static void writeExcel(HttpServletResponse response, List data, String fileName, String sheetName, Class clazz) throws Exception { + long exportStartTime = System.currentTimeMillis(); + log.info("报表导出Size: " + data.size() + "条。"); + EasyExcel.write(getOutputStream(fileName, response), clazz).excelType(ExcelTypeEnum.XLSX).sheet(sheetName).registerWriteHandler(getDefaultHorizontalCellStyleStrategy()).doWrite(data); + System.out.println("报表导出结束时间:" + new Date() + ";写入耗时: " + (System.currentTimeMillis() - exportStartTime) + "ms"); + } + + /** + * @param data 查询结果 + * @param fileName 导出文件名称 + * @param clazz 映射实体class类 + * @param 查询结果类型 + * @description [单一类型大批量数据导出,适用于超过一百万的数据,需要分多个sheet页来导出。自动分页] + */ + public static void writeExcel(HttpServletResponse response, List data, String fileName, Class clazz) throws Exception { + long exportStartTime = System.currentTimeMillis(); + log.info("报表导出Size: " + data.size() + "条。"); + + // 分割的集合 + List> lists = SplitList.splitList(data, MAXROWS); + + OutputStream out = getOutputStream(fileName, response); + ExcelWriterBuilder excelWriterBuilder = EasyExcel.write(out, clazz).excelType(ExcelTypeEnum.XLSX); + ExcelWriter excelWriter = excelWriterBuilder.build(); + ExcelWriterSheetBuilder excelWriterSheetBuilder; + WriteSheet writeSheet; + for (int i = 1; i <= lists.size(); i++) { + excelWriterSheetBuilder = new ExcelWriterSheetBuilder(excelWriter); + excelWriterSheetBuilder.sheetNo(i); + excelWriterSheetBuilder.sheetName("sheet" + i); + writeSheet = excelWriterSheetBuilder.build(); + excelWriter.write(lists.get(i - 1), writeSheet); + } + out.flush(); + excelWriter.finish(); + out.close(); + System.out.println("报表导出结束时间:" + new Date() + ";写入耗时: " + (System.currentTimeMillis() - exportStartTime) + "ms"); + } + + public static OutputStream getOutputStream(String fileName, HttpServletResponse response) throws Exception { + fileName = URLEncoder.encode(fileName, "UTF-8"); + // .xls + // response.setContentType("application/vnd.ms-excel"); + // .xlsx + response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + response.setCharacterEncoding("utf8"); + response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ".xlsx"); + return response.getOutputStream(); + } + + /** + * @param out 输出流 + * @param flag 是否添加默认打印样式,为 true 添加,为 false 不添加。大批量导出去除样式可以节省更多的资源 + */ + public static ExcelWriter buildExcelWriter(OutputStream out, Boolean flag) { + ExcelWriterBuilder excelWriterBuilder = EasyExcel.write(out).excelType(ExcelTypeEnum.XLSX); + if (flag) { + excelWriterBuilder.registerWriteHandler(getDefaultHorizontalCellStyleStrategy()); + } + return excelWriterBuilder.build(); + } + + /** + * @description [默认构建带样式] + */ + public static ExcelWriter buildExcelWriter(OutputStream out) { + return buildExcelWriter(out, true); + } + + /** + * @description [单纯写入,适用于手动分页] + */ + public static void writeOnly(ExcelWriter excelWriter, List data, Class clazz, Integer sheetNo, String sheetName) { + long exportStartTime = System.currentTimeMillis(); + log.info("报表" + sheetNo + "写入Size: " + data.size() + "条。"); + ExcelWriterSheetBuilder excelWriterSheetBuilder; + WriteSheet writeSheet; + excelWriterSheetBuilder = new ExcelWriterSheetBuilder(excelWriter); + excelWriterSheetBuilder.sheetNo(sheetNo); + excelWriterSheetBuilder.sheetName(sheetName); + writeSheet = excelWriterSheetBuilder.build(); + writeSheet.setClazz(clazz); + excelWriter.write(data, writeSheet); + log.info("报表" + sheetNo + "写入耗时: " + (System.currentTimeMillis() - exportStartTime) + "ms"); + } + + + /** + * @description [导出] + */ + public static void finishWriter(OutputStream out, ExcelWriter excelWriter) throws IOException { + out.flush(); + excelWriter.finish(); + out.close(); + System.out.println("报表导出结束时间:" + new Date()); + } + + +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/bigdata/SplitList.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/bigdata/SplitList.java new file mode 100755 index 0000000..fcdef19 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/bigdata/SplitList.java @@ -0,0 +1,65 @@ +package org.leocoder.heritage.easyexcel.bigdata; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * @description [List集合分割工具类] + * @author Leocoder + */ +public class SplitList { + + /** + * @param list 待切割集合 + * @param len 集合按照多大size来切割 + */ + public static List> splitList(List list, int len) { + if (list == null || list.size() == 0 || len < 1) { + return null; + } + List> result = new ArrayList>(); + int size = list.size(); + int count = (size + len - 1) / len; + + for (int i = 0; i < count; i++) { + List subList = list.subList(i * len, ((i + 1) * len > size ? size : len * (i + 1))); + result.add(subList); + } + return result; + } + + /** + * @param source 源集合 + * @param n 分成n个集合 + * @param 集合类型 + * @description [集合平均分组] + */ + public static List> groupList(List source, int n) { + if (source == null || source.size() == 0 || n < 1) { + return null; + } + if (source.size() < n) { + return Arrays.asList(source); + } + List> result = new ArrayList>(); + int number = source.size() / n; + int remaider = source.size() % n; + // 偏移量,每有一个余数分配,就要往右偏移一位 + int offset = 0; + for (int i = 0; i < n; i++) { + List list1 = null; + if (remaider > 0) { + list1 = source.subList(i * number + offset, (i + 1) * number + offset + 1); + remaider--; + offset++; + } else { + list1 = source.subList(i * number + offset, (i + 1) * number + offset); + } + result.add(list1); + } + + return result; + } + +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/ColumnExcelUtil.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/ColumnExcelUtil.java new file mode 100755 index 0000000..568b212 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/ColumnExcelUtil.java @@ -0,0 +1,265 @@ +package org.leocoder.heritage.easyexcel.column; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.support.ExcelTypeEnum; +import com.alibaba.excel.write.builder.ExcelWriterSheetBuilder; +import com.alibaba.excel.write.handler.WriteHandler; +import com.alibaba.excel.write.metadata.WriteSheet; +import com.alibaba.excel.write.metadata.style.WriteCellStyle; +import com.alibaba.excel.write.metadata.style.WriteFont; +import com.alibaba.excel.write.style.HorizontalCellStyleStrategy; +import jakarta.servlet.ServletOutputStream; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.poi.ss.usermodel.*; +import org.springframework.http.MediaType; +import org.springframework.util.CollectionUtils; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * @author Leocoder + * @description [ColumExcelUtil-自定义表头单/多sheet导出] + */ +public class ColumnExcelUtil { + + /** + * 头部样式 + */ + private static WriteCellStyle getHeadStyle() { + // 头的策略 + WriteCellStyle headWriteCellStyle = new WriteCellStyle(); + // 背景颜色 + headWriteCellStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + headWriteCellStyle.setFillPatternType(FillPatternType.SOLID_FOREGROUND); + + // 字体 + WriteFont headWriteFont = new WriteFont(); + // 设置字体名字 + headWriteFont.setFontName("微软雅黑"); + // 设置字体大小 + headWriteFont.setFontHeightInPoints((short) 10); + // 字体加粗 + headWriteFont.setBold(false); + // 在样式用应用设置的字体 + headWriteCellStyle.setWriteFont(headWriteFont); + // 边框样式 + setBorderStyle(headWriteCellStyle); + // 设置自动换行 + headWriteCellStyle.setWrapped(true); + // 设置水平对齐的样式为居中对齐 + headWriteCellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); + // 设置垂直对齐的样式为居中对齐 + headWriteCellStyle.setVerticalAlignment(VerticalAlignment.CENTER); + // 设置文本收缩至合适 + // headWriteCellStyle.setShrinkToFit(true); + + return headWriteCellStyle; + } + + /** + * 内容样式 + */ + private static WriteCellStyle getContentStyle() { + // 内容的策略 + WriteCellStyle contentWriteCellStyle = new WriteCellStyle(); + + // 背景白色 + // 这里需要指定 FillPatternType 为FillPatternType.SOLID_FOREGROUND 不然无法显示背景颜色.头默认了 FillPatternType所以可以不指定 + contentWriteCellStyle.setFillForegroundColor(IndexedColors.WHITE.getIndex()); + contentWriteCellStyle.setFillPatternType(FillPatternType.SOLID_FOREGROUND); + + // 设置字体 + WriteFont contentWriteFont = new WriteFont(); + // 设置字体大小 + contentWriteFont.setFontHeightInPoints((short) 10); + // 设置字体名字 + contentWriteFont.setFontName("宋体"); + // 在样式用应用设置的字体 + contentWriteCellStyle.setWriteFont(contentWriteFont); + // 设置样式 + setBorderStyle(contentWriteCellStyle); + // 水平居中 + contentWriteCellStyle.setHorizontalAlignment(HorizontalAlignment.CENTER); + // 垂直居中 + contentWriteCellStyle.setVerticalAlignment(VerticalAlignment.CENTER); + // 设置自动换行 + contentWriteCellStyle.setWrapped(true); + // 设置单元格格式是:文本格式,方式长数字文本科学计数法 + // contentWriteCellStyle.setDataFormatData(); + // 设置文本收缩至合适 + // contentWriteCellStyle.setShrinkToFit(true); + + return contentWriteCellStyle; + } + + /** + * 边框样式 + */ + private static void setBorderStyle(WriteCellStyle cellStyle) { + // 设置底边框 + cellStyle.setBorderBottom(BorderStyle.THIN); + // 设置底边框颜色 + cellStyle.setBottomBorderColor(IndexedColors.BLACK1.getIndex()); + // 设置左边框 + cellStyle.setBorderLeft(BorderStyle.THIN); + // 设置左边框颜色 + cellStyle.setLeftBorderColor(IndexedColors.BLACK1.getIndex()); + // 设置右边框 + cellStyle.setBorderRight(BorderStyle.THIN); + // 设置右边框颜色 + cellStyle.setRightBorderColor(IndexedColors.BLACK1.getIndex()); + // 设置顶边框 + cellStyle.setBorderTop(BorderStyle.THIN); + // 设置顶边框颜色 + cellStyle.setTopBorderColor(IndexedColors.BLACK1.getIndex()); + } + + /** + * @param response 返回 + * @param fileName 文件名 + * @param sheetNames sheet集合 + * @param headerList 表头集合 + * @param dataList 数据集合 + */ + public static void excelNoModelSheetExport(HttpServletResponse response, String fileName, List sheetNames, + List>> headerList, List>> dataList) { + ServletOutputStream out = null; + try { + // 清除缓存,确保每次导出都是从零开始 + CustomColumnWidthHandler.clearCache(); + + out = getOut(response, fileName); + sheetNames = sheetNames.stream().distinct().collect(Collectors.toList()); + int num = sheetNames.size(); + + // 设置基础样式 + HorizontalCellStyleStrategy horizontalCellStyleStrategy = new HorizontalCellStyleStrategy(getHeadStyle(), getContentStyle()); + // 创建ExcelWriter对象 + ExcelWriter excelWriter = EasyExcel.write(out).build(); + for (int i = 0; i < num; i++) { + ExcelWriterSheetBuilder sheetBuilder = EasyExcel.writerSheet(i, sheetNames.get(i)).head(headerList.get(i)) + // 注册自定义列宽处理器 + .registerWriteHandler(new CustomColumnWidthHandler()) + // 注册样式策略 + .registerWriteHandler(horizontalCellStyleStrategy); + // 写入数据 + WriteSheet writeSheet = sheetBuilder.build(); + if (CollectionUtils.isEmpty(dataList)) { + excelWriter.write(new ArrayList<>(), writeSheet); + } else { + excelWriter.write(dataList.get(i), writeSheet); + } + } + // 完成写入并关闭ExcelWriter + excelWriter.finish(); + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (Objects.nonNull(out)) { + try { + out.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + /** + * @param response 返回 + * @param fileName 文件名 + * @param sheetNames sheet集合 + * @param headerList 表头集合 + * @param dataList 数据集合 + * @param writeHandler 自定义样式 + */ + public static void excelNoModelSheetExport(HttpServletResponse response, String fileName, List sheetNames, + List>> headerList, List>> dataList, WriteHandler writeHandler) { + ServletOutputStream out = null; + try { + // 清除缓存,确保每次导出都是从零开始 + CustomColumnWidthHandler.clearCache(); + + out = getOut(response, fileName); + sheetNames = sheetNames.stream().distinct().collect(Collectors.toList()); + int num = sheetNames.size(); + + // 设置基础样式 + HorizontalCellStyleStrategy horizontalCellStyleStrategy = new HorizontalCellStyleStrategy(getHeadStyle(), getContentStyle()); + ExcelWriter excelWriter = EasyExcel.write(out).build(); + for (int i = 0; i < num; i++) { + ExcelWriterSheetBuilder sheetBuilder = EasyExcel.writerSheet(i, sheetNames.get(i)).head(headerList.get(i)) + // 注册自定义列宽处理器 + .registerWriteHandler(new CustomColumnWidthHandler()) + // 注册样式策略 + .registerWriteHandler(horizontalCellStyleStrategy); + if (Objects.nonNull(writeHandler)) { + // 自定义样式设置 + sheetBuilder.registerWriteHandler(writeHandler); + } + // 写入数据 + WriteSheet writeSheet = sheetBuilder.build(); + if (CollectionUtils.isEmpty(dataList)) { + excelWriter.write(new ArrayList<>(), writeSheet); + } else { + // 完成写入并关闭ExcelWriter + excelWriter.write(dataList.get(i), writeSheet); + } + } + excelWriter.finish(); + } catch (Exception e) { + e.printStackTrace(); + } finally { + if (Objects.nonNull(out)) { + try { + out.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + private static ServletOutputStream getOut(HttpServletResponse response, String fileName) throws Exception { + fileName = fileName + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()) + ExcelTypeEnum.XLSX.getValue(); + setAttachmentResponseHeader(response, fileName); + response.setCharacterEncoding("utf-8"); + response.setContentType(MediaType.MULTIPART_FORM_DATA_VALUE); + response.setContentType("application/vnd.ms-excel;charset=utf-8"); + return response.getOutputStream(); + } + + /** + * 下载文件名重新编码 + * + * @param response 响应对象 + * @param realFileName 真实文件名 + */ + public static void setAttachmentResponseHeader(HttpServletResponse response, String realFileName) { + String percentEncodedFileName = percentEncode(realFileName); + String contentDispositionValue = "attachment; filename=" + percentEncodedFileName + ";filename*=utf-8''" + percentEncodedFileName; + response.setHeader("Access-Control-Expose-Headers", "Content-Disposition"); + response.setHeader("Content-Disposition", contentDispositionValue); + } + + /** + * 百分号编码工具方法 + * + * @param s 需要百分号编码的字符串 + * @return 百分号编码后的字符串 + */ + public static String percentEncode(String s) { + String encode = URLEncoder.encode(s, StandardCharsets.UTF_8); + return encode.replaceAll("\\+", "%20"); + } + +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/CustomColumnWidthHandler.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/CustomColumnWidthHandler.java new file mode 100755 index 0000000..70d5a3a --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/CustomColumnWidthHandler.java @@ -0,0 +1,91 @@ +package org.leocoder.heritage.easyexcel.column; + +import com.alibaba.excel.enums.CellDataTypeEnum; +import com.alibaba.excel.metadata.Head; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.style.column.AbstractColumnWidthStyleStrategy; +import org.apache.poi.ss.usermodel.Cell; +import org.springframework.util.CollectionUtils; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Leocoder + * @description [CustomHandler - 自定义列宽] + */ +public class CustomColumnWidthHandler extends AbstractColumnWidthStyleStrategy { + + /** + * 最大列宽 + */ + private static final int MAX_COLUMN_WIDTH = 255; + + private static final Map> CACHE = new HashMap<>(8); + + public CustomColumnWidthHandler() { + } + + /** + * 清除缓存 + */ + public static void clearCache() { + CACHE.clear(); + } + + @Override + protected void setColumnWidth(WriteSheetHolder writeSheetHolder, List> cellDataList, Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { + boolean needSetWidth = isHead || !CollectionUtils.isEmpty(cellDataList); + if (isHead) { + // 如果不是最后一个表头,则不改变列宽 + List headNameList = head.getHeadNameList(); + if (!CollectionUtils.isEmpty(headNameList)) { + int size = headNameList.size(); + if (!cell.getStringCellValue().equals(headNameList.get(size - 1))) { + return; + } + } + } + if (needSetWidth) { + Map maxColumnWidthMap = CACHE.computeIfAbsent(writeSheetHolder.getSheetNo(), k -> new HashMap<>(16)); + Integer columnWidth = this.dataLength(cellDataList, cell, isHead); + if (columnWidth >= 0) { + if (columnWidth > MAX_COLUMN_WIDTH) { + columnWidth = MAX_COLUMN_WIDTH; + } + Integer maxColumnWidth = maxColumnWidthMap.get(cell.getColumnIndex()); + if (maxColumnWidth == null || columnWidth > maxColumnWidth) { + maxColumnWidthMap.put(cell.getColumnIndex(), columnWidth); + writeSheetHolder.getSheet().setColumnWidth(cell.getColumnIndex(), columnWidth * 256); + } + } + } + } + + private Integer dataLength(List> cellDataList, Cell cell, Boolean isHead) { + if (isHead) { + return cell.getStringCellValue().getBytes().length; + } else { + WriteCellData cellData = cellDataList.get(0); + CellDataTypeEnum type = cellData.getType(); + if (type == null) { + return -1; + } else { + switch (type) { + case STRING: + return cellData.getStringValue().getBytes().length; + case BOOLEAN: + return cellData.getBooleanValue().toString().getBytes().length; + case NUMBER: + return cellData.getNumberValue().toString().getBytes().length; + case DATE: + return cellData.getDateValue().toString().getBytes().length; + default: + return -1; + } + } + } + } +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/CustomStyle.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/CustomStyle.java new file mode 100755 index 0000000..35a94d5 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/column/CustomStyle.java @@ -0,0 +1,167 @@ +package org.leocoder.heritage.easyexcel.column; + +import com.alibaba.excel.metadata.Head; +import com.alibaba.excel.metadata.data.WriteCellData; +import com.alibaba.excel.write.handler.CellWriteHandler; +import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; +import com.alibaba.excel.write.metadata.holder.WriteTableHolder; +import org.apache.poi.ss.usermodel.*; + +import java.util.List; + +/** + * @author Leocoder + * @description [CustomStyle-自定义样式] + */ +public class CustomStyle implements CellWriteHandler { + + @Override + public void afterCellDispose(WriteSheetHolder writeSheetHolder, WriteTableHolder writeTableHolder, List> cellDataList, + Cell cell, Head head, Integer relativeRowIndex, Boolean isHead) { + int rowIndex = cell.getRowIndex(); + int columnIndex = cell.getColumnIndex(); + String sheetName = cell.getSheet().getSheetName(); + if (rowIndex == 1 && columnIndex == 0) { + // 设置表头统计的样式,由于单元格是合并的,只设置第一列就行了 + setTotalStyle(writeSheetHolder, cellDataList, cell); + return; + } + if (!isHead) { + // 内容样式处理 + Workbook workbook = cell.getSheet().getWorkbook(); + if (sheetName.equals("手机")) { + // 判断是否为”手机“sheet + if (columnIndex == 3) { + // 判断价格 + // 注意,这里的 cell.get**Value 有多个方法,一定要准确,否则会报错,报错后不会再进入这个拦截器,直接导出了 + // 如果无法准确判断应该用哪个 getValue,可以 debug 测试 + double value = cell.getNumericCellValue(); + if (value > 5000.00) { + // 字体样式改为红色 + CellStyle cellStyle = getContentCellStyle(workbook, IndexedColors.WHITE.getIndex(), IndexedColors.RED.getIndex()); + //设置当前单元格样式 + cell.setCellStyle(cellStyle); + // 这里要把 WriteCellData的样式清空 + // 不然后面还有一个拦截器 FillStyleCellWriteHandler 默认会将 WriteCellStyle 设置到cell里面去 会导致自己设置的不一样 + cellDataList.get(0).setWriteCellStyle(null); + } + } else if (columnIndex == 4) { + // 判断CPU + String value = cell.getStringCellValue(); + if (value.contains("骁龙8 Gen3")) { + // 背景改为黄色 + CellStyle cellStyle = getContentCellStyle(workbook, IndexedColors.YELLOW.getIndex(), IndexedColors.BLACK.getIndex()); + cell.setCellStyle(cellStyle); + cellDataList.get(0).setWriteCellStyle(null); + } + } + } else { + // ”电脑“sheet + if (columnIndex == 2) { + // 判断价格 + double value = cell.getNumericCellValue(); + if (value > 10000.00) { + // 字体样式改为红色 + CellStyle cellStyle = getContentCellStyle(workbook, IndexedColors.WHITE.getIndex(), IndexedColors.RED.getIndex()); + cell.setCellStyle(cellStyle); + cellDataList.get(0).setWriteCellStyle(null); + } + } else if (columnIndex == 4) { + String value = cell.getStringCellValue(); + if (value.contains("RTX 4090")) { + // 背景改为蓝色 + CellStyle cellStyle = getContentCellStyle(workbook, IndexedColors.LIGHT_BLUE.getIndex(), IndexedColors.BLACK.getIndex()); + cell.setCellStyle(cellStyle); + cellDataList.get(0).setWriteCellStyle(null); + } + } + } + } + } + + private CellStyle getContentCellStyle(Workbook workbook, short ffColorIndex, short fontColorIndex) { + // 单元格策略 + CellStyle cellStyle = workbook.createCellStyle(); + // 设置背景颜色 + cellStyle.setFillForegroundColor(ffColorIndex); + cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + // 设置垂直居中为居中对齐 + cellStyle.setVerticalAlignment(VerticalAlignment.CENTER); + // 设置左右对齐为居中对齐 + cellStyle.setAlignment(HorizontalAlignment.CENTER); + // 自动换行 + cellStyle.setWrapText(true); + // 设置边框 + setBorderStyle(cellStyle); + // 字体 + Font font = workbook.createFont(); + //设置字体名字 + font.setFontName("宋体"); + //设置字体大小 + font.setFontHeightInPoints((short) 10); + // 设置字体颜色 + font.setColor(fontColorIndex); + //字体加粗 + font.setBold(false); + //在样式用应用设置的字体 + cellStyle.setFont(font); + return cellStyle; + } + + private void setTotalStyle(WriteSheetHolder writeSheetHolder, List> cellDataList, Cell cell) { + Workbook workbook = cell.getSheet().getWorkbook(); + //设置行高 + writeSheetHolder.getSheet().getRow(cell.getRowIndex()).setHeight((short) (1.4 * 256 * 2)); + // 单元格策略 + CellStyle cellStyle = workbook.createCellStyle(); + // 设置背景颜色灰色 + cellStyle.setFillForegroundColor(IndexedColors.GREY_25_PERCENT.getIndex()); + cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND); + // 设置垂直居中为居中对齐 + cellStyle.setVerticalAlignment(VerticalAlignment.CENTER); + // 设置左右对齐为靠右对齐 + cellStyle.setAlignment(HorizontalAlignment.RIGHT); + // 自动换行 + cellStyle.setWrapText(true); + // 设置边框 + setBorderStyle(cellStyle); + // 字体 + Font font = workbook.createFont(); + //设置字体名字 + font.setFontName("微软雅黑"); + //设置字体大小 + font.setFontHeightInPoints((short) 10); + //字体加粗 + font.setBold(false); + //在样式用应用设置的字体 + cellStyle.setFont(font); + //设置当前单元格样式 + cell.setCellStyle(cellStyle); + // 这里要把 WriteCellData的样式清空 + // 不然后面还有一个拦截器 FillStyleCellWriteHandler 默认会将 WriteCellStyle 设置到cell里面去 会导致自己设置的不一样 + cellDataList.get(0).setWriteCellStyle(null); + } + + /** + * 边框样式 + */ + private static void setBorderStyle(CellStyle cellStyle) { + //设置底边框 + cellStyle.setBorderBottom(BorderStyle.THIN); + //设置底边框颜色 + cellStyle.setBottomBorderColor(IndexedColors.BLACK1.getIndex()); + //设置左边框 + cellStyle.setBorderLeft(BorderStyle.THIN); + //设置左边框颜色 + cellStyle.setLeftBorderColor(IndexedColors.BLACK1.getIndex()); + //设置右边框 + cellStyle.setBorderRight(BorderStyle.THIN); + //设置右边框颜色 + cellStyle.setRightBorderColor(IndexedColors.BLACK1.getIndex()); + //设置顶边框 + cellStyle.setBorderTop(BorderStyle.THIN); + //设置顶边框颜色 + cellStyle.setTopBorderColor(IndexedColors.BLACK1.getIndex()); + } + +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/enums/ExcelTemplateEnum.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/enums/ExcelTemplateEnum.java new file mode 100755 index 0000000..d8cf859 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/enums/ExcelTemplateEnum.java @@ -0,0 +1,71 @@ +package org.leocoder.heritage.easyexcel.core.enums; + +import lombok.Getter; + +/** + * @author Leocoder + * @description [ExcelTemplateEnum导入模板监听器] + */ +@Getter +public enum ExcelTemplateEnum { + + /** + * 单sheet导出 + */ + TEMPLATE_1("1", "下载模版"), + + /** + * 模板格式 + */ + TEMPLATE_SUFFIX("xlsx", ".xlsx"), + TEMPLATE_SUFFIX_XLS("xls", ".xls"), + TEMPLATE_SUFFIX_DOCX("docx", ".docx"), + + /** + * 模板路径 + */ + TEMPLATE_PATH("path", "excel"), + ; + + private final String code; + private final String desc; + + ExcelTemplateEnum(String code, String desc) { + this.code = code; + this.desc = desc; + } + + /** + * 通过code获取msg + * + * @param code 枚举值 + * @return + */ + public static String getMsgByCode(String code) { + if (code == null) { + return null; + } + ExcelTemplateEnum enumList = getByCode(code); + if (enumList == null) { + return null; + } + return enumList.getDesc(); + } + + public static String getCode(ExcelTemplateEnum enumList) { + if (enumList == null) { + return null; + } + return enumList.getCode(); + } + + public static ExcelTemplateEnum getByCode(String code) { + for (ExcelTemplateEnum enumList : values()) { + if (enumList.getCode().equals(code)) { + return enumList; + } + } + return null; + } + +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/listener/UploadDataListener.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/listener/UploadDataListener.java new file mode 100755 index 0000000..7a0c382 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/listener/UploadDataListener.java @@ -0,0 +1,52 @@ +package org.leocoder.heritage.easyexcel.core.listener; + +import com.alibaba.excel.context.AnalysisContext; +import com.alibaba.excel.event.AnalysisEventListener; +import com.alibaba.excel.exception.ExcelDataConvertException; + +import java.util.ArrayList; +import java.util.List; + +/** + * @author Leocoder + * @description [UploadDataListener] + */ +public class UploadDataListener extends AnalysisEventListener { + + // 数据集 + private final List list = new ArrayList<>(); + + public List getList() { + return this.list; + } + + /** + * @description [每条数据都会进入] + * @author Leocoder + */ + @Override + public void invoke(T object, AnalysisContext analysisContext) { + this.list.add(object); + } + + /** + * @description [数据解析完调用] + * @author Leocoder + */ + @Override + public void doAfterAllAnalysed(AnalysisContext analysisContext) { + + } + + /** + * @description [异常时调用] + * @author Leocoder + */ + @Override + public void onException(Exception exception, AnalysisContext context) throws Exception { + // 数据解析异常 + if (exception instanceof ExcelDataConvertException excelDataConvertException) { + throw new RuntimeException("第" + excelDataConvertException.getRowIndex() + "行" + excelDataConvertException.getColumnIndex() + "列" + "数据解析异常"); + } + } +} diff --git a/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/utils/EasyExcelUtil.java b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/utils/EasyExcelUtil.java new file mode 100755 index 0000000..90e2172 --- /dev/null +++ b/heritage-plugins/heritage-easyexcel/src/main/java/org/leocoder/heritage/easyexcel/core/utils/EasyExcelUtil.java @@ -0,0 +1,303 @@ +package org.leocoder.heritage.easyexcel.core.utils; + +import com.alibaba.excel.EasyExcel; +import com.alibaba.excel.EasyExcelFactory; +import com.alibaba.excel.ExcelWriter; +import com.alibaba.excel.read.builder.ExcelReaderBuilder; +import com.alibaba.excel.write.metadata.WriteSheet; +import com.alibaba.excel.write.metadata.fill.FillConfig; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.leocoder.heritage.easyexcel.core.enums.ExcelTemplateEnum; +import org.leocoder.heritage.easyexcel.core.listener.UploadDataListener; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * @author Leocoder + * @description [EasyExcelUtil导出工具类] + */ +@Slf4j +@Component +public class EasyExcelUtil { + + /** + * @param file:文件流 + * @param clazz:数据对象 + * @param sheetName:要读取的sheet [不传:默认读取第一个sheet] + * @description [导入简单excel数据] + */ + public List importExcel(MultipartFile file, Class clazz, String sheetName) { + try { + this.checkFile(file); + UploadDataListener uploadDataListener = new UploadDataListener<>(); + ExcelReaderBuilder builder = EasyExcelFactory.read(file.getInputStream(), clazz, uploadDataListener); + if (StringUtils.isEmpty(sheetName)) { + builder.sheet().doRead(); + } else { + builder.sheet(sheetName).doRead(); + } + return uploadDataListener.getList(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * @param multipartFile 传入文件 + * @param objList 需要导入的sheet页实体类型集合 + * @param index sheet页个数 + * @param indexList 需要导入sheet页下标集合 + * @description [指定sheet页导入通用方法] + */ + public List> importExcelsByIndex(MultipartFile multipartFile, List objList, int index, List indexList) { + try { + if (multipartFile == null) { + throw new RuntimeException("文件为空"); + } + List> resultList = new LinkedList<>(); + //初始化导入sheet页实体类型下标 + int objListClass = 0; + for (int i = 0; i < index; i++) { + if (indexList.contains(i)) { + UploadDataListener uploadDataListener = new UploadDataListener<>(); + List excels; + EasyExcelFactory.read(multipartFile.getInputStream(), objList.get(objListClass).getClass(), uploadDataListener).sheet(i).doRead(); + excels = uploadDataListener.getList(); + resultList.add(excels); + objListClass++; + } + } + return resultList; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * @param file 文件流 + * @param index 需要读取的sheet个数 [默认0开始,如果传入3,则读取0 1 2] + * @param params 每个sheet里面需要封装的对象[如果index为3,则需要传入对应的3个对象] + * @description [读取多个sheet] + */ + public List> importExcels(MultipartFile file, int index, List params) { + try { + this.checkFile(file); + List> resultList = new LinkedList<>(); + for (int i = 0; i < index; i++) { + UploadDataListener uploadDataListener = new UploadDataListener<>(); + ExcelReaderBuilder builder = EasyExcelFactory.read(file.getInputStream(), params.get(i).getClass(), uploadDataListener); + builder.sheet(i).doRead(); + List list = uploadDataListener.getList(); + resultList.add(list); + } + return resultList; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * @param response + * @param dataList 数据列表 + * @param clazz 数据对象 + * @param fileName 文件名称 + * @param sheetName sheet名称 + * @description [导出excel表格] + */ + public void exportExcel(HttpServletResponse response, List dataList, Class clazz, String fileName, String sheetName) { + try { + response.setContentType("application/vnd.ms-excel; charset=utf-8"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); + response.setHeader("Content-disposition", "attachment;filename=" + fileName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc()); + EasyExcelFactory.write(response.getOutputStream(), clazz).sheet(sheetName).doWrite(dataList); + } catch (IllegalStateException | IOException ex) { + log.error("导出Excel时发生异常: " + ex.getMessage(), ex); + } + } + + /** + * @param dataList 多个数据列表 + * @param clazzMap 对应每个列表里面的数据对应的sheet名称 + * @param fileName 文件名 + * @description [导出多个sheet] + */ + public void exportExcels(HttpServletResponse response, List> dataList, Map clazzMap, String fileName) { + try { + response.setContentType("application/vnd.ms-excel"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); + response.setHeader("Content-disposition", "attachment;filename=" + fileName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc()); + ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream()).build(); + int len = dataList.get(0).size(); + for (int i = 0; i < len; i++) { + List objects = (List) dataList.get(0).get(i); + Class aClass = objects.get(0).getClass(); + WriteSheet writeSheet0 = EasyExcel.writerSheet(i, clazzMap.get(i)).head(aClass).build(); + excelWriter.write(objects, writeSheet0); + } + excelWriter.finish(); + } catch (IOException ex) { + log.error("导出Excel时发生异常: " + ex.getMessage(), ex); + } + } + + /** + * @param list 填充对象集合 + * @param object 填充对象 + * @param fileName 文件名称 + * @param templateName 模板名称 + * @description [根据模板将集合对象填充表格-单个sheet] + */ + public void exportTemplateExcels(HttpServletResponse response, List list, Object object, String fileName, String templateName) { + try { + String template = ExcelTemplateEnum.TEMPLATE_PATH.getDesc() + File.separator + templateName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc(); + InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(template); + FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); + ExcelWriter excelWriter = EasyExcelFactory.write(getOutputStream(fileName, response)).withTemplate(inputStream).build(); + WriteSheet writeSheet0 = EasyExcelFactory.writerSheet(0).build(); + excelWriter.fill(object, fillConfig, writeSheet0); + excelWriter.fill(list, fillConfig, writeSheet0); + excelWriter.finish(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @param list1 填充对象集合 + * @param list2 填充对象集合 + * @param object1 填充对象 + * @param object2 填充对象 + * @param fileName 文件名称 + * @param templateName 模板名称 + * @description [根据模板将集合对象填充表格-多个sheet] + */ + public void exportSheetTemplateExcels(HttpServletResponse response, List list1, List list2, Object object1, Object object2, String fileName, String templateName) { + try { + String template = ExcelTemplateEnum.TEMPLATE_PATH.getDesc() + File.separator + templateName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc(); + InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(template); + FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build(); + ExcelWriter excelWriter = EasyExcelFactory.write(getOutputStream(fileName, response)).withTemplate(inputStream).build(); + WriteSheet writeSheet0 = EasyExcelFactory.writerSheet(0).build(); + WriteSheet writeSheet1 = EasyExcelFactory.writerSheet(1).build(); + excelWriter.fill(object1, fillConfig, writeSheet0); + excelWriter.fill(list1, fillConfig, writeSheet0); + excelWriter.fill(object2, fillConfig, writeSheet1); + excelWriter.fill(list2, fillConfig, writeSheet1); + excelWriter.finish(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @param object 填充对象 + * @param templateName 模板名称 + * @param fileName 文件名称 + * @param sheetName 需要写入的sheet名称 [不传:填充到第一个sheet] + * @description [根据模板将单个对象填充表格] + */ + public void exportTemplateExcel(HttpServletResponse response, Object object, String templateName, String fileName, String sheetName) { + try { + String template = ExcelTemplateEnum.TEMPLATE_PATH.getDesc() + File.separator + templateName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc(); + InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(template); + if (StringUtils.isEmpty(sheetName)) { + EasyExcelFactory.write(getOutputStream(fileName, response)).withTemplate(inputStream).sheet().doFill(object); + } else { + EasyExcelFactory.write(getOutputStream(fileName, response)).withTemplate(inputStream).sheet(sheetName).doFill(object); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @param list 填充对象集合 + * @param fileName 文件名称 + * @param templateName 模板名称 + * @param sheetName 需要写入的sheet [不传:填充到第一个sheet] + * @description [根据模板将集合对象填充表格] + */ + public void exportTemplateExcelList(HttpServletResponse response, List list, String fileName, String templateName, String sheetName) { + try { + log.info("模板名称:{}", templateName); + String template = ExcelTemplateEnum.TEMPLATE_PATH.getDesc() + File.separator + templateName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc(); + log.info("模板路径:{}", template); + InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(template); + // 全部填充:全部加载到内存中一次填充 + if (StringUtils.isEmpty(sheetName)) { + EasyExcelFactory.write(getOutputStream(fileName, response)).withTemplate(inputStream).sheet().doFill(list); + } else { + EasyExcelFactory.write(getOutputStream(fileName, response)).withTemplate(inputStream).sheet(sheetName).doFill(list); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @param list 填充对象集合 + * @param fileName 文件名称 + * @param templateName 模板名称 + * @description [根据模板将集合对象填充表格\ + */ + public void exportTemplateExcel2(HttpServletResponse response, List list, String fileName, String templateName) { + try { + String template = ExcelTemplateEnum.TEMPLATE_PATH.getDesc() + File.separator + templateName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc(); + InputStream inputStream = this.getClass().getClassLoader().getResourceAsStream(template); + ExcelWriter excelWriter = EasyExcelFactory.write(getOutputStream(fileName, response)).withTemplate(inputStream).build(); + WriteSheet writeSheet = EasyExcelFactory.writerSheet().build(); + excelWriter.fill(list, writeSheet); + excelWriter.finish(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + /** + * @param fileName 文件名称 + * @description [构建输出流] + */ + private OutputStream getOutputStream(String fileName, HttpServletResponse response) { + try { + fileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8); + response.setContentType("application/vnd.ms-excel"); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setHeader("Content-Disposition", "attachment;filename=" + fileName + ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc()); + return response.getOutputStream(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + /** + * @description [文件格式校验] + */ + private void checkFile(MultipartFile file) { + if (file == null) { + throw new RuntimeException("文件不能为空"); + } + String fileName = file.getOriginalFilename(); + if (StringUtils.isEmpty(fileName)) { + throw new RuntimeException("文件不能为空"); + } + if (!fileName.endsWith(ExcelTemplateEnum.TEMPLATE_SUFFIX.getDesc()) + && !fileName.endsWith(ExcelTemplateEnum.TEMPLATE_SUFFIX_XLS.getDesc())) { + throw new RuntimeException("请上传.xlsx或.xls文件"); + } + } + +} diff --git a/heritage-plugins/heritage-job/pom.xml b/heritage-plugins/heritage-job/pom.xml new file mode 100644 index 0000000..9d8799d --- /dev/null +++ b/heritage-plugins/heritage-job/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + org.leocoder.heritage + heritage-plugins + ${revision} + + + heritage-job + 定时任务插件模块 + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + + + + + cn.dev33 + sa-token-spring-boot3-starter + + + + + cn.hutool + hutool-all + + + + + org.apache.commons + commons-lang3 + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + + + + + org.projectlombok + lombok + true + + + + + org.leocoder.heritage + heritage-common + ${revision} + + + + org.leocoder.heritage + heritage-model + ${revision} + + + + org.leocoder.heritage + heritage-mybatisplus + ${revision} + + + + \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/anno/EnableCoderJob.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/anno/EnableCoderJob.java new file mode 100644 index 0000000..08ee204 --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/anno/EnableCoderJob.java @@ -0,0 +1,19 @@ +package org.leocoder.heritage.job.anno; + +import org.leocoder.heritage.job.config.JobConfiguration; +import org.springframework.context.annotation.Import; + +import java.lang.annotation.*; + +/** + * @author Leocoder + * @description [启用定时任务插件注解] + */ +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Import({JobConfiguration.class}) +public @interface EnableCoderJob { + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/config/JobConfiguration.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/config/JobConfiguration.java new file mode 100644 index 0000000..4fe8bef --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/config/JobConfiguration.java @@ -0,0 +1,20 @@ +package org.leocoder.heritage.job.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +/** + * @author Leocoder + * @description [定时任务插件配置类] + */ +@Slf4j +@Configuration +@ComponentScan(basePackages = "org.leocoder.heritage.job") +public class JobConfiguration { + + public JobConfiguration() { + log.info("定时任务插件已启用"); + } + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/controller/SysJobController.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/controller/SysJobController.java new file mode 100644 index 0000000..d3afa3a --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/controller/SysJobController.java @@ -0,0 +1,201 @@ +package org.leocoder.heritage.job.controller; + +import cn.dev33.satoken.annotation.SaCheckPermission; +import cn.hutool.cron.CronUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.leocoder.heritage.domain.model.vo.system.SysJobVo; +import org.leocoder.heritage.domain.pojo.system.SysJob; +import org.leocoder.heritage.job.service.SysJobService; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * @author Leocoder + * @description [定时任务管理控制器] + */ +@Tag(name = "定时任务管理", description = "系统定时任务的增删改查和执行控制") +@Slf4j +@RequestMapping("/coder") +@RequiredArgsConstructor +@RestController +public class SysJobController { + + private final SysJobService sysJobService; + + /** + * @description [多条件分页查询] + * @author Leocoder + */ + @Operation(summary = "分页查询定时任务", description = "根据查询条件分页获取系统定时任务列表") + @SaCheckPermission("monitor:job:list") + @GetMapping("/sysJob/listPage") + public IPage listPage(SysJobVo vo) { + // 分页构造器 + Page page = new Page<>(vo.getPageNo(), vo.getPageSize()); + // 条件构造器 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(StringUtils.isNotBlank(vo.getJobName()), SysJob::getJobName, vo.getJobName()); + wrapper.eq(StringUtils.isNotBlank(vo.getJobType()), SysJob::getJobType, vo.getJobType()); + wrapper.eq(StringUtils.isNotBlank(vo.getJobStatus()), SysJob::getJobStatus, vo.getJobStatus()); + wrapper.orderByDesc(SysJob::getCreateTime); + // 进行分页查询 + page = sysJobService.page(page, wrapper); + return page; + } + + /** + * @description [查询所有] + * @author Leocoder + */ + @Operation(summary = "查询所有定时任务", description = "获取系统中所有定时任务信息") + @SaCheckPermission("monitor:job:list") + @GetMapping("/sysJob/list") + public List list(SysJobVo vo) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.like(StringUtils.isNotBlank(vo.getJobName()), SysJob::getJobName, vo.getJobName()); + wrapper.eq(StringUtils.isNotBlank(vo.getJobType()), SysJob::getJobType, vo.getJobType()); + wrapper.eq(StringUtils.isNotBlank(vo.getJobStatus()), SysJob::getJobStatus, vo.getJobStatus()); + wrapper.orderByDesc(SysJob::getCreateTime); + return sysJobService.list(wrapper); + } + + /** + * @description [根据ID查询数据] + * @author Leocoder + */ + @Operation(summary = "根据ID查询任务", description = "根据任务ID获取定时任务详细信息") + @SaCheckPermission("monitor:job:list") + @GetMapping("/sysJob/getById/{id}") + public SysJob getById(@PathVariable("id") Long id) { + return sysJobService.getById(id); + } + + /** + * @description [删除任务] + * @author Leocoder + */ + @Operation(summary = "删除定时任务", description = "根据任务ID删除指定的定时任务") + @SaCheckPermission("monitor:job:delete") + @PostMapping("/sysJob/deleteById/{id}") + public String deleteById(@PathVariable("id") Long id) { + try { + // 1、停止定时任务 + CronUtil.remove(id + ""); + // 2、进行删除 + boolean remove = sysJobService.removeById(id); + if (!remove) { + return "删除任务失败,请重试"; + } + return "删除成功"; + } catch (Exception e) { + log.error("删除定时任务失败", e); + return "删除任务失败:" + e.getMessage(); + } + } + + /** + * @description [批量删除] + * @author Leocoder + */ + @Operation(summary = "批量删除定时任务", description = "根据任务ID列表批量删除定时任务") + @SaCheckPermission("monitor:job:delete") + @Transactional(rollbackFor = Exception.class) + @PostMapping("/sysJob/batchDelete") + public String batchDelete(@RequestBody List jobIds) { + try { + // 1、停止定时任务 + for (Long jobId : jobIds) { + CronUtil.remove(jobId + ""); + } + // 2、批量删除 + boolean batch = sysJobService.removeBatchByIds(jobIds); + if (!batch) { + return "删除任务失败,请重试"; + } + return "批量删除成功"; + } catch (Exception e) { + log.error("批量删除定时任务失败", e); + return "删除任务失败:" + e.getMessage(); + } + } + + /** + * @description [任务调度状态修改] + * @author Leocoder + */ + @Operation(summary = "修改任务状态", description = "修改定时任务的运行状态和执行策略") + @SaCheckPermission("monitor:job:update") + @PostMapping("/sysJob/updateStatus/{id}/{jobStatus}/{policyStatus}") + public String updateStatus(@PathVariable("id") Long id, + @PathVariable("jobStatus") String jobStatus, + @PathVariable("policyStatus") String policyStatus) { + try { + sysJobService.updateStatus(id, jobStatus, policyStatus); + return "操作成功"; + } catch (Exception e) { + log.error("修改任务状态失败", e); + return "操作失败:" + e.getMessage(); + } + } + + /** + * @description [立即运行任务-执行一次] + * @author Leocoder + */ + @Operation(summary = "立即执行任务", description = "手动立即执行指定的定时任务") + @SaCheckPermission("monitor:job:run") + @GetMapping("/sysJob/runNow/{id}") + public String runNow(@PathVariable Long id) { + try { + sysJobService.runNow(id); + return "任务执行成功"; + } catch (Exception e) { + log.error("立即执行任务失败", e); + return "任务执行失败:" + e.getMessage(); + } + } + + /** + * @description [添加定时任务] + * @author Leocoder + */ + @Operation(summary = "新增定时任务", description = "创建新的定时任务配置") + @SaCheckPermission("monitor:job:add") + @PostMapping("/sysJob/add") + public String addJob(@RequestBody SysJob job) { + try { + sysJobService.addJob(job); + return "添加成功"; + } catch (Exception e) { + log.error("添加定时任务失败", e); + return "添加失败:" + e.getMessage(); + } + } + + /** + * @description [修改定时任务] + * @author Leocoder + */ + @Operation(summary = "修改定时任务", description = "更新现有定时任务的配置信息") + @SaCheckPermission("monitor:job:update") + @PostMapping("/sysJob/update") + public String updateJob(@RequestBody SysJob job) { + try { + sysJobService.updateJob(job); + return "修改成功"; + } catch (Exception e) { + log.error("修改定时任务失败", e); + return "修改失败:" + e.getMessage(); + } + } + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/service/SysJobService.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/service/SysJobService.java new file mode 100644 index 0000000..5df91c2 --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/service/SysJobService.java @@ -0,0 +1,48 @@ +package org.leocoder.heritage.job.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import org.leocoder.heritage.domain.pojo.system.SysJob; + +/** + * @author Leocoder + * @description [定时任务服务接口] + */ +public interface SysJobService extends IService { + + /** + * @description [停止任务] + * @author Leocoder + */ + void pauseJob(Long id); + + /** + * @description [启动定时任务] + * @author Leocoder + */ + void resumeJob(Long id); + + /** + * @description [任务调度状态修改] + * @author Leocoder + */ + void updateStatus(Long id, String jobStatus, String policyStatus); + + /** + * @description [立即运行任务-执行一次] + * @author Leocoder + */ + void runNow(Long id); + + /** + * @description [添加定时任务] + * @author Leocoder + */ + void addJob(SysJob job); + + /** + * @description [修改定时任务] + * @author Leocoder + */ + void updateJob(SysJob job); + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/service/impl/SysJobServiceImpl.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/service/impl/SysJobServiceImpl.java new file mode 100644 index 0000000..1bfd3fa --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/service/impl/SysJobServiceImpl.java @@ -0,0 +1,392 @@ +package org.leocoder.heritage.job.service.impl; + +import cn.hutool.cron.CronUtil; +import cn.hutool.extra.spring.SpringUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.StringUtils; +import org.leocoder.heritage.common.satoken.CoderLoginUtil; +import org.leocoder.heritage.domain.pojo.system.SysJob; +import org.leocoder.heritage.job.service.SysJobService; +import org.leocoder.heritage.job.task.CommonTimerTaskRunner; +import org.leocoder.heritage.mybatisplus.mapper.system.SysJobMapper; +import org.springframework.scheduling.support.CronExpression; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.lang.reflect.Method; + +/** + * @author Leocoder + * @description [定时任务服务实现类] + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class SysJobServiceImpl extends ServiceImpl implements SysJobService { + + private final SysJobMapper sysJobMapper; + + /** + * @description [停止任务] + * @author Leocoder + */ + @Transactional(rollbackFor = Exception.class) + @Override + public void pauseJob(Long id) { + SysJob byId = this.getById(id); + if (byId == null) { + throw new RuntimeException("任务不存在"); + } + if ("1".equals(byId.getJobStatus())) { + throw new RuntimeException("该任务已处于停止状态"); + } + + CronUtil.remove(id + ""); + + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.set(SysJob::getJobStatus, "1"); + updateWrapper.eq(SysJob::getJobId, id); + boolean update = this.update(updateWrapper); + + if (!update) { + throw new RuntimeException("暂停任务失败,请重试"); + } + } + + /** + * @description [启动定时任务] + * @author Leocoder + */ + @Transactional(rollbackFor = Exception.class) + @Override + public void resumeJob(Long id) { + if (null == id) { + throw new RuntimeException("该任务未查询到"); + } + + SysJob job = this.getById(id); + if (job == null) { + throw new RuntimeException("任务不存在"); + } + + // 先停止定时任务 + CronUtil.remove(id + ""); + + // 再注册定时任务 + CronUtil.schedule(job.getJobId() + "", job.getCronExpression(), () -> { + executeMethod(job.getClassPath(), job.getMethodName(), job.getJobParams()); + }); + + // 更新任务状态为正常 + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.set(SysJob::getJobStatus, "0"); + updateWrapper.eq(SysJob::getJobId, id); + this.update(updateWrapper); + } + + /** + * @description [执行方法,多个参数传递,必须使用逗号分割,一个一个对应] + * @author Leocoder + */ + private void executeMethod(String classPath, String methodName, String params) { + if (StringUtils.isBlank(classPath)) { + throw new RuntimeException("类绝对路径不能为空"); + } + if (StringUtils.isBlank(methodName)) { + throw new RuntimeException("方法名称不能为空"); + } + + try { + // 使用SpringUtil获取类的实例 + CommonTimerTaskRunner runner = (CommonTimerTaskRunner) SpringUtil.getBean(Class.forName(classPath)); + + Method[] methods = runner.getClass().getMethods(); + Method targetMethod = null; + + for (Method method : methods) { + if (method.getName().equals(methodName)) { + Class[] parameterTypes = method.getParameterTypes(); + if (parameterTypes.length > 0 && StringUtils.isBlank(params)) { + throw new IllegalArgumentException("缺少参数"); + } + if (parameterTypes.length == 0 && StringUtils.isNotBlank(params)) { + throw new IllegalArgumentException("不应传递参数"); + } + if (parameterTypes.length > 0 && StringUtils.isNotBlank(params)) { + String[] paramArray = params.split(","); + if (paramArray.length != parameterTypes.length) { + throw new IllegalArgumentException("参数数量不匹配"); + } + for (int i = 0; i < paramArray.length; i++) { + Object convertedValue = convertToType(parameterTypes[i], paramArray[i]); + if (convertedValue == null) { + throw new IllegalArgumentException("参数类型不匹配"); + } + } + targetMethod = method; + } else { + targetMethod = method; + } + break; + } + } + + if (targetMethod != null) { + // 调用方法 + if (StringUtils.isNotBlank(params)) { + targetMethod.invoke(runner, extractTypedParams(targetMethod.getParameterTypes(), params)); + } else { + targetMethod.invoke(runner); + } + } else { + throw new IllegalArgumentException("指定方法不存在"); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + log.error("执行定时任务出现异常", e); + throw new RuntimeException("执行定时任务出现异常:" + e.getMessage()); + } + } + + /** + * @description [多参数使用逗号分隔开] + * @author Leocoder + */ + private Object[] extractTypedParams(Class[] parameterTypes, String params) { + String[] paramArray = params.split(","); + Object[] typedParams = new Object[paramArray.length]; + for (int i = 0; i < paramArray.length; i++) { + typedParams[i] = convertToType(parameterTypes[i], paramArray[i].trim()); + } + return typedParams; + } + + /** + * @description [类型转换] + * @author Leocoder + */ + private Object convertToType(Class targetType, String value) { + if (targetType.equals(String.class)) { + return value; + } else if (targetType.equals(Integer.class) || targetType.equals(int.class)) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException ex) { + return null; + } + } else if (targetType.equals(Boolean.class) || targetType.equals(boolean.class)) { + return Boolean.parseBoolean(value); + } else if (targetType.equals(Long.class) || targetType.equals(long.class)) { + try { + return Long.parseLong(value); + } catch (NumberFormatException ex) { + return null; + } + } + // 根据需要添加更多类型转换 + return null; + } + + /** + * @description [任务调度状态修改] + * @author Leocoder + */ + @Transactional(rollbackFor = Exception.class) + @Override + public void updateStatus(Long id, String jobStatus, String policyStatus) { + if (StringUtils.isBlank(jobStatus) || id == null) { + throw new RuntimeException("请传递相关信息"); + } + + if ("0".equals(jobStatus)) { + if ("1".equals(policyStatus)) { + resumeJob(id); // 启动定时任务 + } + } else if ("1".equals(jobStatus)) { + pauseJob(id); // 停止定时任务 + } + + LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); + wrapper.set(SysJob::getJobStatus, jobStatus); + wrapper.set(SysJob::getPolicyStatus, policyStatus); + wrapper.eq(SysJob::getJobId, id); + boolean update = this.update(wrapper); + + if (!update) { + throw new RuntimeException("操作失败,请重试"); + } + } + + /** + * @description [立即运行任务-执行一次,不影响定时调度] + * @author Leocoder + */ + @Transactional(rollbackFor = Exception.class) + @Override + public void runNow(Long id) { + SysJob job = this.getById(id); + if (ObjectUtils.isEmpty(job) || job.getJobId() == null) { + throw new RuntimeException("未查到当前任务"); + } + + // 直接执行一次任务,不影响现有的定时调度 + executeMethod(job.getClassPath(), job.getMethodName(), job.getJobParams()); + + // 注意:这里不移除定时任务,让定时调度继续按计划运行 + log.info("立即执行任务完成,任务ID:{},定时调度继续保持运行", id); + } + + /** + * @description [添加定时任务] + * @author Leocoder + */ + @Transactional(rollbackFor = Exception.class) + @Override + public void addJob(SysJob job) { + // 1、参数校验 + checkParams(job); + + // 2、是否添加重复的定时任务 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysJob::getClassPath, job.getClassPath()); + wrapper.eq(SysJob::getMethodName, job.getMethodName()); + wrapper.eq(SysJob::getCronExpression, job.getCronExpression()); + long count = this.count(wrapper); + if (count > 0) { + throw new RuntimeException("存在重复执行的定时任务,名称为:" + job.getJobName()); + } + + // 3、添加定时任务 + boolean save = this.save(job); + if (!save) { + throw new RuntimeException("添加失败,请重试"); + } + + // 4、根据任务状态,进行执行定时策略 + if ("0".equals(job.getJobStatus())) { + if ("1".equals(job.getPolicyStatus())) { + // 开启定时任务 + resumeJob(job.getJobId()); + } else if ("2".equals(job.getPolicyStatus())) { + // 先停止定时任务 + CronUtil.remove(job.getJobId() + ""); + // 执行一次 + executeMethod(job.getClassPath(), job.getMethodName(), job.getJobParams()); + } else { + // 停止任务 + CronUtil.remove(job.getJobId() + ""); + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.set(SysJob::getPolicyStatus, "3"); + updateWrapper.eq(SysJob::getJobId, job.getJobId()); + boolean update = this.update(updateWrapper); + if (!update) { + throw new RuntimeException("操作失败,请重试"); + } + } + } else { + // 停止任务,计划策略并改为放弃执行 + CronUtil.remove(job.getJobId() + ""); + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.set(SysJob::getPolicyStatus, "3"); + updateWrapper.eq(SysJob::getJobId, job.getJobId()); + boolean update = this.update(updateWrapper); + if (!update) { + throw new RuntimeException("操作失败,请重试"); + } + } + } + + /** + * @description [参数校验] + * @author Leocoder + */ + private void checkParams(SysJob job) { + // 校验表达式 + if (!CronExpression.isValidExpression(job.getCronExpression())) { + throw new RuntimeException("cron表达式:" + job.getCronExpression() + "格式不正确"); + } + + // 校验定时任务类 + try { + Class actionClass = Class.forName(job.getClassPath()); + if (!CommonTimerTaskRunner.class.isAssignableFrom(actionClass)) { + throw new RuntimeException("定时任务对应的类:" + job.getClassPath() + "不符合要求"); + } + } catch (ClassNotFoundException e) { + throw new RuntimeException("定时任务找不到对应的类,名称为:" + job.getClassPath()); + } + } + + /** + * @description [修改定时任务] + * @author Leocoder + */ + @Transactional(rollbackFor = Exception.class) + @Override + public void updateJob(SysJob job) { + // 1、参数校验 + checkParams(job); + if (job.getJobId() == null) { + throw new RuntimeException("请选择需要修改的任务"); + } + + // 2、是否修改为数据库已经存在的定时任务(保持与添加任务相同的检查逻辑) + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysJob::getClassPath, job.getClassPath()); + wrapper.eq(SysJob::getMethodName, job.getMethodName()); + wrapper.eq(SysJob::getCronExpression, job.getCronExpression()); + wrapper.ne(SysJob::getJobId, job.getJobId()); // 排除当前任务 + long count = this.count(wrapper); + if (count > 0) { + throw new RuntimeException("存在重复执行的定时任务,名称为:" + job.getJobName()); + } + + // 3、修改任务 + // 这里应该从登录用户上下文获取 + job.setUpdateBy(CoderLoginUtil.getLoginName()); + boolean update = this.updateById(job); + if (!update) { + throw new RuntimeException("修改失败,请重试"); + } + + // 4、根据任务状态,进行执行定时策略 + if ("0".equals(job.getJobStatus())) { + if ("1".equals(job.getPolicyStatus())) { + // 先停止定时任务,再开启定时任务 + resumeJob(job.getJobId()); + } else if ("2".equals(job.getPolicyStatus())) { + // 先停止定时任务 + CronUtil.remove(job.getJobId() + ""); + // 再开始执行一次定时任务 + executeMethod(job.getClassPath(), job.getMethodName(), job.getJobParams()); + } else { + // 停止任务,计划策略并改为放弃执行 + CronUtil.remove(job.getJobId() + ""); + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.set(SysJob::getPolicyStatus, "3"); + updateWrapper.eq(SysJob::getJobId, job.getJobId()); + boolean updateBoolean = this.update(updateWrapper); + if (!updateBoolean) { + throw new RuntimeException("操作失败,请重试"); + } + } + } else { + // 停止任务,计划策略并改为放弃执行 + CronUtil.remove(job.getJobId() + ""); + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.set(SysJob::getPolicyStatus, "3"); + updateWrapper.eq(SysJob::getJobId, job.getJobId()); + boolean updateBoolean = this.update(updateWrapper); + if (!updateBoolean) { + throw new RuntimeException("操作失败,请重试"); + } + } + } + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CoderJobListener.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CoderJobListener.java new file mode 100644 index 0000000..b488065 --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CoderJobListener.java @@ -0,0 +1,67 @@ +package org.leocoder.heritage.job.task; + +import cn.hutool.cron.CronUtil; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.leocoder.heritage.domain.pojo.system.SysJob; +import org.leocoder.heritage.job.service.SysJobService; +import org.springframework.boot.context.event.ApplicationStartedEvent; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import java.util.List; + +/** + * @author Leocoder + * @description [定时任务监听器,系统启动时将定时任务启动] + */ +@Slf4j +@Configuration +public class CoderJobListener implements ApplicationListener, Ordered { + + @Resource + private SysJobService sysJobService; + + @SuppressWarnings("ALL") + @Override + public void onApplicationEvent(ApplicationStartedEvent applicationStartedEvent) { + try { + // 查询状态正常的定时任务 AND 执行策略是立即执行类型的数据,筛选后进行开始定时任务 + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(SysJob::getJobStatus, "0"); // 正常状态 + wrapper.eq(SysJob::getPolicyStatus, "1"); // 立即执行 + + List jobList = sysJobService.list(wrapper); + if(CollectionUtils.isNotEmpty(jobList)){ + for (SysJob sysJob : jobList) { + try { + // 启动定时任务 + sysJobService.resumeJob(sysJob.getJobId()); + log.info("自动启动定时任务:{},表达式:{}", sysJob.getJobName(), sysJob.getCronExpression()); + } catch (Exception e) { + log.error("自动启动定时任务失败:{},错误:{}", sysJob.getJobName(), e.getMessage()); + } + } + } + + // 设置秒级别的启用 + CronUtil.setMatchSecond(true); + log.info("启动定时器执行器,支持秒级精度"); + CronUtil.restart(); + + log.info("定时任务系统初始化完成,共启动{}个任务", jobList != null ? jobList.size() : 0); + + } catch (Exception e) { + log.error("定时任务系统初始化失败", e); + } + } + + @Override + public int getOrder() { + return LOWEST_PRECEDENCE; + } + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CoderJobTimerTaskRunner.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CoderJobTimerTaskRunner.java new file mode 100644 index 0000000..75eb526 --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CoderJobTimerTaskRunner.java @@ -0,0 +1,55 @@ +package org.leocoder.heritage.job.task; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.leocoder.heritage.job.service.SysJobService; +import org.springframework.stereotype.Component; + +/** + * @author Leocoder + * @description [定时任务执行器示例实现] + * 注意:仅支持字符串、数字、布尔值进行传递参数。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CoderJobTimerTaskRunner implements CommonTimerTaskRunner { + + private final SysJobService sysJobService; + + /** + * @description [单个参数,param可以传递参数,通过数据库的job_params] + * @author Leocoder + */ + @Override + public void paramAction(String param) { + log.info("执行单参数定时任务,参数:{}", param); + // 这里可以执行具体的业务逻辑 + // 示例:发送邮件、清理缓存、同步数据等 + } + + /** + * @description [无参数执行] + * @author Leocoder + */ + @Override + public void noParamAction() { + log.info("执行无参数定时任务"); + // 示例:查询所有定时任务并输出 + sysJobService.list().forEach(job -> + log.info("任务:{},状态:{}", job.getJobName(), job.getJobStatusText()) + ); + } + + /** + * @description [多参数执行] + * @author Leocoder + */ + @Override + public void manyParamsAction(String param1, Integer param2) { + log.info("执行多参数定时任务,参数1:{},参数2:{}", param1, param2); + // 这里可以执行具体的业务逻辑 + // 示例:根据参数处理不同类型的数据 + } + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CommonTimerTaskRunner.java b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CommonTimerTaskRunner.java new file mode 100644 index 0000000..8726bb9 --- /dev/null +++ b/heritage-plugins/heritage-job/src/main/java/org/leocoder/heritage/job/task/CommonTimerTaskRunner.java @@ -0,0 +1,27 @@ +package org.leocoder.heritage.job.task; + +/** + * @author Leocoder + * @description [定时任务执行器通用接口 - 所有定时任务都实现这个接口,方便我们遍历所有可用的定时任务类] + */ +public interface CommonTimerTaskRunner { + + /** + * @description [单参数执行] + * @author Leocoder + */ + void paramAction(String param); + + /** + * @description [无参数执行] + * @author Leocoder + */ + void noParamAction(); + + /** + * @description [多参数执行,多个参数传递,必须使用逗号分割,一个一个对应] + * @author Leocoder + */ + void manyParamsAction(String param1, Integer param2); + +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oper-logs/pom.xml b/heritage-plugins/heritage-oper-logs/pom.xml new file mode 100644 index 0000000..b6301f3 --- /dev/null +++ b/heritage-plugins/heritage-oper-logs/pom.xml @@ -0,0 +1,41 @@ + + + 4.0.0 + + org.leocoder.heritage + heritage-plugins + ${revision} + + + heritage-oper-logs + heritage-oper-logs + 异步操作日志功能插件;支持AOP切面自动记录操作日志 + + + + + org.leocoder.heritage + heritage-common + ${revision} + + + + org.leocoder.heritage + heritage-model + ${revision} + + + + org.springframework.boot + spring-boot-starter-aop + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + \ No newline at end of file diff --git a/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/annotation/EnableOperLog.java b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/annotation/EnableOperLog.java new file mode 100644 index 0000000..215b240 --- /dev/null +++ b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/annotation/EnableOperLog.java @@ -0,0 +1,23 @@ +package org.leocoder.heritage.operlog.annotation; + +import org.leocoder.heritage.operlog.aspect.OperLogAspect; +import org.leocoder.heritage.operlog.config.OperLogAsyncConfig; +import org.leocoder.heritage.operlog.service.OperLogAsyncService; +import org.springframework.context.annotation.Import; + +import java.lang.annotation.*; + +/** + * @author Leocoder + * @description [启用操作日志注解] + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import({ + OperLogAspect.class, + OperLogAsyncService.class, + OperLogAsyncConfig.class +}) +public @interface EnableOperLog { +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/annotation/OperLog.java b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/annotation/OperLog.java new file mode 100644 index 0000000..5ff459f --- /dev/null +++ b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/annotation/OperLog.java @@ -0,0 +1,52 @@ +package org.leocoder.heritage.operlog.annotation; + + +import org.leocoder.heritage.domain.enums.oper.OperType; +import org.leocoder.heritage.domain.enums.oper.SystemType; + +import java.lang.annotation.*; + +/** + * @author Leocoder + * @description [操作日志注解] + */ +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface OperLog { + + /** + * 操作名称 + */ + String value() default ""; + + /** + * 操作类型 + */ + OperType operType() default OperType.OTHER; + + /** + * 系统类型 + */ + SystemType systemType() default SystemType.MANAGER; + + /** + * 是否保存请求参数 + */ + boolean saveRequestData() default true; + + /** + * 是否保存返回结果 + */ + boolean saveResponseData() default true; + + /** + * 排除敏感字段 + */ + String[] excludeFields() default {}; + + /** + * 是否异步记录 + */ + boolean async() default true; +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/aspect/OperLogAspect.java b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/aspect/OperLogAspect.java new file mode 100644 index 0000000..e7ebdd5 --- /dev/null +++ b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/aspect/OperLogAspect.java @@ -0,0 +1,581 @@ +package org.leocoder.heritage.operlog.aspect; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Pointcut; +import org.aspectj.lang.reflect.MethodSignature; +import org.leocoder.heritage.common.satoken.CoderLoginUtil; +import org.leocoder.heritage.common.utils.date.DateUtil; +import org.leocoder.heritage.common.utils.ip.IpAddressUtil; +import org.leocoder.heritage.common.utils.ip.IpUtil; +import org.leocoder.heritage.common.utils.json.JsonUtil; +import org.leocoder.heritage.common.utils.string.StringUtil; +import org.leocoder.heritage.domain.pojo.system.SysOperLog; +import org.leocoder.heritage.operlog.annotation.OperLog; +import org.leocoder.heritage.operlog.service.OperLogAsyncService; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.lang.reflect.Method; +import java.time.LocalDateTime; +import java.util.Map; + +/** + * @author Leocoder + * @description [操作日志切面处理器] + */ +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class OperLogAspect { + + private final OperLogAsyncService operLogAsyncService; + private final IpAddressUtil ipAddressUtil; + + /** + * @description [配置切入点] + * @author Leocoder + */ + @Pointcut("@annotation(org.leocoder.heritage.operlog.annotation.OperLog)") + public void operLogPointcut() { + } + + /** + * @description [处理操作日志记录] + * @author Leocoder + */ + @Around("operLogPointcut()") + public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { + long startTime = System.currentTimeMillis(); + Object result = null; + Exception exception = null; + + try { + // 执行原方法 + result = joinPoint.proceed(); + return result; + } catch (Exception e) { + exception = e; + throw e; + } finally { + try { + // 处理操作日志 + handleOperLog(joinPoint, result, exception, startTime); + } catch (Exception e) { + log.error("操作日志记录失败", e); + } + } + } + + /** + * @description [处理操作日志] + * @author Leocoder + */ + private void handleOperLog(ProceedingJoinPoint joinPoint, Object result, + Exception exception, long startTime) { + + // 获取注解信息 + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + Method method = signature.getMethod(); + OperLog operLog = method.getAnnotation(OperLog.class); + + if (operLog == null) { + return; + } + + // 构建操作日志对象 + SysOperLog operLogEntity = buildOperLog(joinPoint, operLog, result, exception, startTime); + + // 保存操作日志 + if (operLog.async()) { + operLogAsyncService.saveOperLog(operLogEntity); + } else { + operLogAsyncService.saveOperLogSync(operLogEntity); + } + } + + /** + * @description [构建操作日志对象] + * @author Leocoder + */ + private SysOperLog buildOperLog(ProceedingJoinPoint joinPoint, OperLog operLog, + Object result, Exception exception, long startTime) { + + SysOperLog operLogEntity = new SysOperLog(); + + // 设置基本信息 + operLogEntity.setOperName(getOperName(operLog, joinPoint)); + operLogEntity.setOperType(operLog.operType().getCode()); + operLogEntity.setSystemType(operLog.systemType().getCode()); + operLogEntity.setOperTime(LocalDateTime.now()); + operLogEntity.setCostTime(String.valueOf(System.currentTimeMillis() - startTime)); + + // 设置方法信息 + setMethodInfo(operLogEntity, joinPoint); + + // 设置请求信息 + setRequestInfo(operLogEntity); + + // 设置用户信息 + setUserInfo(operLogEntity); + + // 设置请求参数 + if (operLog.saveRequestData()) { + setRequestData(operLogEntity, joinPoint, operLog.excludeFields()); + } + + // 设置返回结果 + if (operLog.saveResponseData()) { + if (result != null) { + setResponseData(operLogEntity, result); + } else if (exception == null) { + // 对于void方法且无异常的情况,设置默认成功返回信息 + setDefaultSuccessResponse(operLogEntity, operLog); + } + } + + // 设置异常信息 + if (exception != null) { + setExceptionInfo(operLogEntity, exception); + } else { + operLogEntity.setOperStatus("0"); + } + + return operLogEntity; + } + + /** + * @description [获取操作名称] + * @author Leocoder + */ + private String getOperName(OperLog operLog, ProceedingJoinPoint joinPoint) { + String operName = operLog.value(); + if (StringUtils.isBlank(operName)) { + // 如果没有设置操作名称,使用方法名 + operName = joinPoint.getSignature().getName(); + } + return operName; + } + + /** + * @description [设置方法信息] + * @author Leocoder + */ + private void setMethodInfo(SysOperLog operLogEntity, ProceedingJoinPoint joinPoint) { + String className = joinPoint.getTarget().getClass().getSimpleName(); + String methodName = joinPoint.getSignature().getName(); + String fullMethodName = className + "." + methodName; + // 限制方法名长度,避免数据库字段超限(数据库字段为varchar(64)) + operLogEntity.setMethodName(StringUtil.substring(fullMethodName, 0, 64)); + } + + /** + * @description [设置请求信息] + * @author Leocoder + */ + private void setRequestInfo(SysOperLog operLogEntity) { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + operLogEntity.setRequestMethod(request.getMethod()); + operLogEntity.setOperUrl(StringUtil.substring(request.getRequestURI(), 0, 255)); + operLogEntity.setOperIp(IpUtil.getIpAddr(request)); + operLogEntity.setOperLocation(ipAddressUtil.getAddress(operLogEntity.getOperIp())); + } + } catch (Exception e) { + log.warn("设置请求信息失败", e); + } + } + + /** + * @description [设置用户信息] + * @author Leocoder + */ + private void setUserInfo(SysOperLog operLogEntity) { + try { + String userName = CoderLoginUtil.getUserName(); + operLogEntity.setOperMan(userName); + } catch (Exception e) { + operLogEntity.setOperMan("匿名用户"); + } + } + + /** + * @description [设置请求参数] + * @author Leocoder + */ + private void setRequestData(SysOperLog operLogEntity, ProceedingJoinPoint joinPoint, String[] excludeFields) { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + Map paramMap = request.getParameterMap(); + + if (!paramMap.isEmpty()) { + String params = toJsonStringWithExclude(paramMap, excludeFields); + operLogEntity.setOperParam(StringUtil.substring(params, 0, 2000)); + } else { + // 如果没有HTTP请求参数,获取方法参数(通常是POST请求的JSON体) + Object[] args = joinPoint.getArgs(); + if (args != null && args.length > 0) { + // 过滤掉非业务参数 + Object[] businessArgs = filterBusinessArgs(args); + if (businessArgs.length > 0) { + String params = toJsonStringWithExclude(businessArgs, excludeFields); + operLogEntity.setOperParam(StringUtil.substring(params, 0, 2000)); + } + } + } + } + } catch (Exception e) { + log.warn("设置请求参数失败", e); + } + } + + /** + * @description [设置返回结果] + * @author Leocoder + */ + private void setResponseData(SysOperLog operLogEntity, Object result) { + try { + if (result != null) { + String jsonResult = JsonUtil.toJsonString(result); + operLogEntity.setJsonResult(StringUtil.substring(jsonResult, 0, 2000)); + } + } catch (Exception e) { + log.warn("设置返回结果失败", e); + } + } + + /** + * @description [设置异常信息] + * @author Leocoder + */ + private void setExceptionInfo(SysOperLog operLogEntity, Exception exception) { + operLogEntity.setOperStatus("1"); + String errorMsg = StringUtil.substring(exception.getMessage(), 0, 2000); + operLogEntity.setErrorMsg(errorMsg); + } + + /** + * @description [设置默认成功返回信息] + * @author Leocoder + */ + private void setDefaultSuccessResponse(SysOperLog operLogEntity, OperLog operLog) { + try { + // 根据操作类型生成相应的成功信息 + String operationType = operLog.operType().getCode(); + String operationName = operLog.value(); + + StringBuilder responseBuilder = new StringBuilder(); + responseBuilder.append("{"); + responseBuilder.append("\"success\":true,"); + responseBuilder.append("\"code\":200,"); + responseBuilder.append("\"message\":\"").append(getSuccessMessage(operationType, operationName)).append("\","); + responseBuilder.append("\"operationType\":\"").append(operationType).append("\","); + responseBuilder.append("\"timestamp\":\"").append(DateUtil.format(new java.util.Date(), "yyyy-MM-dd HH:mm:ss")).append("\""); + responseBuilder.append("}"); + + String jsonResult = responseBuilder.toString(); + operLogEntity.setJsonResult(StringUtil.substring(jsonResult, 0, 2000)); + + } catch (Exception e) { + log.warn("设置默认成功返回信息失败", e); + // 失败时设置简单的成功信息 + operLogEntity.setJsonResult("{\"success\":true,\"message\":\"操作成功\"}"); + } + } + + /** + * @description [根据操作类型获取成功消息] + * @author Leocoder + */ + private String getSuccessMessage(String operationType, String operationName) { + switch (operationType) { + case "INSERT": + return StringUtils.isNotBlank(operationName) ? operationName + "成功" : "新增操作成功"; + case "UPDATE": + return StringUtils.isNotBlank(operationName) ? operationName + "成功" : "修改操作成功"; + case "DELETE": + return StringUtils.isNotBlank(operationName) ? operationName + "成功" : "删除操作成功"; + case "SELECT": + return StringUtils.isNotBlank(operationName) ? operationName + "成功" : "查询操作成功"; + case "IMPORT": + return StringUtils.isNotBlank(operationName) ? operationName + "成功" : "导入操作成功"; + case "EXPORT": + return StringUtils.isNotBlank(operationName) ? operationName + "成功" : "导出操作成功"; + default: + return StringUtils.isNotBlank(operationName) ? operationName + "成功" : "操作成功"; + } + } + + /** + * @description [转换为JSON字符串并排除指定字段] + * @author Leocoder + */ + private String toJsonStringWithExclude(Object obj, String[] excludeFields) { + try { + if (obj == null) { + return ""; + } + + // 先尝试转换为JSON字符串 + String jsonString; + try { + jsonString = JsonUtil.toJsonString(obj); + + // 检查是否转换失败(JsonUtil可能返回错误信息) + if (jsonString == null || jsonString.startsWith("JSON数据转换失败") || jsonString.startsWith("数据转换失败")) { + return createSimpleString(obj, excludeFields); + } + + } catch (Exception e) { + log.debug("JsonUtil转换失败,使用备用方案", e); + return createSimpleString(obj, excludeFields); + } + + // 如果没有需要排除的字段,直接返回 + if (excludeFields == null || excludeFields.length == 0) { + return jsonString; + } + + // 解析JSON并排除指定字段 + try { + ObjectMapper mapper = new ObjectMapper(); + JsonNode jsonNode = mapper.readTree(jsonString); + + if (jsonNode.isObject()) { + ObjectNode objectNode = (ObjectNode) jsonNode; + for (String field : excludeFields) { + objectNode.remove(field); + } + return objectNode.toString(); + } + + return jsonString; + + } catch (Exception e) { + log.debug("JSON排除字段失败,返回原始字符串", e); + return jsonString; + } + + } catch (Exception e) { + log.warn("参数序列化失败", e); + return "[参数转换失败]"; + } + } + + /** + * @description [创建简单字符串描述] + * @author Leocoder + */ + private String createSimpleString(Object obj, String[] excludeFields) { + try { + if (obj == null) { + return "null"; + } + + // 对于数组,尝试展示具体内容 + if (obj.getClass().isArray()) { + Object[] array = (Object[]) obj; + StringBuilder sb = new StringBuilder("["); + // 最多显示3个元素 + for (int i = 0; i < array.length && i < 3; i++) { + if (i > 0) sb.append(", "); + sb.append(createObjectString(array[i], excludeFields)); + } + if (array.length > 3) { + sb.append(", ...").append(array.length - 3).append(" more"); + } + sb.append("]"); + return sb.toString(); + } + + // 对于集合 + if (obj instanceof java.util.Collection) { + java.util.Collection collection = (java.util.Collection) obj; + return "[Collection, size=" + collection.size() + "]"; + } + + // 对于Map + if (obj instanceof Map) { + Map map = (Map) obj; + return "[Map, size=" + map.size() + "]"; + } + + // 对于其他对象 + return createObjectString(obj, excludeFields); + + } catch (Exception e) { + return "[object parsing failed]"; + } + } + + /** + * @description [创建对象字符串描述] + * @author Leocoder + */ + private String createObjectString(Object obj, String[] excludeFields) { + try { + if (obj == null) { + return "null"; + } + + // 基本类型直接返回 + if (obj instanceof String || obj instanceof Number || obj instanceof Boolean) { + return obj.toString(); + } + + // 日期类型 + if (obj instanceof java.util.Date) { + return DateUtil.format((java.util.Date) obj, "yyyy-MM-dd HH:mm:ss"); + } + + if (obj instanceof LocalDateTime) { + LocalDateTime ldt = (LocalDateTime) obj; + return ldt.toString(); + } + + if (obj instanceof java.time.LocalDate) { + return obj.toString(); + } + + // 复杂对象,提取关键字段信息 + return extractObjectInfo(obj, excludeFields); + + } catch (Exception e) { + return "{object}"; + } + } + + /** + * @description [提取对象信息] + * @author Leocoder + */ + private String extractObjectInfo(Object obj, String[] excludeFields) { + try { + StringBuilder sb = new StringBuilder("{"); + java.lang.reflect.Field[] fields = obj.getClass().getDeclaredFields(); + boolean first = true; + int fieldCount = 0; + + for (java.lang.reflect.Field field : fields) { + try { + field.setAccessible(true); + String fieldName = field.getName(); + + // 跳过系统字段 + if (fieldName.startsWith("$") || fieldName.equals("serialVersionUID")) { + continue; + } + + // 排除指定字段 + if (excludeFields != null) { + boolean excluded = false; + for (String excludeField : excludeFields) { + if (fieldName.equalsIgnoreCase(excludeField)) { + excluded = true; + break; + } + } + if (excluded) { + continue; + } + } + + // 跳过敏感字段 + if (fieldName.toLowerCase().contains("password") || + fieldName.toLowerCase().contains("salt") || + fieldName.toLowerCase().contains("token")) { + continue; + } + + Object value = field.get(obj); + if (value != null) { + if (!first) sb.append(", "); + sb.append(fieldName).append("="); + + // 处理不同类型的值 + String valueStr; + if (value instanceof String || value instanceof Number || value instanceof Boolean) { + valueStr = value.toString(); + } else if (value instanceof java.util.Date) { + valueStr = DateUtil.format((java.util.Date) value, "yyyy-MM-dd HH:mm:ss"); + } else if (value instanceof LocalDateTime || value instanceof java.time.LocalDate) { + valueStr = value.toString(); + } else if (value instanceof java.util.Collection) { + java.util.Collection collection = (java.util.Collection) value; + valueStr = "[Collection, size=" + collection.size() + "]"; + } else if (value instanceof Map) { + Map map = (Map) value; + valueStr = "[Map, size=" + map.size() + "]"; + } else { + valueStr = value.toString(); + } + + // 限制字段值长度 + if (valueStr.length() > 50) { + valueStr = valueStr.substring(0, 47) + "..."; + } + sb.append(valueStr); + first = false; + fieldCount++; + + // 最多显示8个字段,避免过长 + if (fieldCount >= 8 || sb.length() > 300) break; + } + } catch (Exception e) { + // 忽略单个字段错误 + } + } + + sb.append("}"); + return sb.toString(); + + } catch (Exception e) { + String className = obj.getClass().getSimpleName(); + return "{" + className + " object}"; + } + } + + /** + * @description [过滤业务参数,排除HttpServletResponse等非业务对象] + * @author Leocoder + */ + private Object[] filterBusinessArgs(Object[] args) { + if (args == null || args.length == 0) { + return new Object[0]; + } + + java.util.List businessArgs = new java.util.ArrayList<>(); + for (Object arg : args) { + if (arg == null) { + continue; + } + + String className = arg.getClass().getName(); + // 排除Spring框架和Servlet相关对象 + if (className.startsWith("jakarta.servlet.") || + className.startsWith("javax.servlet.") || + className.startsWith("org.springframework.") || + className.startsWith("org.apache.catalina.")) { + continue; + } + + businessArgs.add(arg); + } + + return businessArgs.toArray(new Object[0]); + } +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/config/OperLogAsyncConfig.java b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/config/OperLogAsyncConfig.java new file mode 100644 index 0000000..5d725f8 --- /dev/null +++ b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/config/OperLogAsyncConfig.java @@ -0,0 +1,59 @@ +package org.leocoder.heritage.operlog.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.ThreadPoolExecutor; + +/** + * @author Leocoder + * @description [操作日志异步配置] + */ +@Configuration +@EnableAsync +public class OperLogAsyncConfig { + + @Value("${coder.oper-log.async.core-pool-size:2}") + private int corePoolSize; + + @Value("${coder.oper-log.async.max-pool-size:5}") + private int maxPoolSize; + + @Value("${coder.oper-log.async.queue-capacity:1000}") + private int queueCapacity; + + @Value("${coder.oper-log.async.keep-alive-seconds:60}") + private int keepAliveSeconds; + + /** + * @description [操作日志线程池] + * @author Leocoder + */ + @Bean("operLogExecutor") + public ThreadPoolTaskExecutor operLogExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + + // 设置核心线程数 + executor.setCorePoolSize(corePoolSize); + // 设置最大线程数 + executor.setMaxPoolSize(maxPoolSize); + // 设置队列容量 + executor.setQueueCapacity(queueCapacity); + // 设置线程名称前缀 + executor.setThreadNamePrefix("oper-log-"); + // 设置线程空闲时间 + executor.setKeepAliveSeconds(keepAliveSeconds); + // 设置拒绝策略:调用者运行 + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + // 等待任务完成后关闭 + executor.setWaitForTasksToCompleteOnShutdown(true); + // 等待时间 + executor.setAwaitTerminationSeconds(30); + + executor.initialize(); + return executor; + } +} \ No newline at end of file diff --git a/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/service/OperLogAsyncService.java b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/service/OperLogAsyncService.java new file mode 100644 index 0000000..ca8249b --- /dev/null +++ b/heritage-plugins/heritage-oper-logs/src/main/java/org/leocoder/heritage/operlog/service/OperLogAsyncService.java @@ -0,0 +1,184 @@ +package org.leocoder.heritage.operlog.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.leocoder.heritage.common.exception.coder.YUtil; +import org.leocoder.heritage.common.utils.cache.RedisUtil; +import org.leocoder.heritage.common.utils.date.DateUtil; +import org.leocoder.heritage.domain.pojo.system.SysOperLog; +import org.springframework.context.ApplicationContext; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +/** + * @author Leocoder + * @description [操作日志异步服务] + */ +@Service +@Slf4j +@RequiredArgsConstructor +public class OperLogAsyncService { + + private final RedisUtil redisUtil; + + private final ApplicationContext applicationContext; + + /** + * @description [异步保存操作日志] + * @author Leocoder + */ + @Async("operLogExecutor") + public CompletableFuture saveOperLog(SysOperLog operLog) { + try { + // 参数校验 + YUtil.isNull(operLog, "操作日志对象不能为空"); + + // 通过ApplicationContext获取Service,避免循环依赖 + saveToDatabase(operLog); + + // 更新统计缓存 + updateStatCache(operLog); + + log.debug("操作日志保存成功: {}", operLog.getOperName()); + + return CompletableFuture.completedFuture(null); + + } catch (Exception e) { + log.error("操作日志保存失败", e); + // 保存失败日志 + saveFailedLog(operLog, e); + return CompletableFuture.failedFuture(e); + } + } + + /** + * @description [同步保存操作日志] + * @author Leocoder + */ + public void saveOperLogSync(SysOperLog operLog) { + try { + YUtil.isNull(operLog, "操作日志对象不能为空"); + saveToDatabase(operLog); + updateStatCache(operLog); + log.debug("操作日志同步保存成功: {}", operLog.getOperName()); + } catch (Exception e) { + log.error("操作日志同步保存失败", e); + saveFailedLog(operLog, e); + } + } + + /** + * @description [批量异步保存操作日志] + * @author Leocoder + */ + @Async("operLogExecutor") + public CompletableFuture batchSaveOperLog(List operLogs) { + try { + YUtil.isNull(operLogs, "操作日志列表不能为空"); + YUtil.isTrue(operLogs.isEmpty(), "操作日志列表不能为空"); + + // 批量处理 + operLogs.forEach(this::saveToDatabase); + + // 更新批量统计缓存 + operLogs.forEach(this::updateStatCache); + + log.debug("批量保存操作日志成功,数量: {}", operLogs.size()); + + return CompletableFuture.completedFuture(null); + + } catch (Exception e) { + log.error("批量保存操作日志失败", e); + return CompletableFuture.failedFuture(e); + } + } + + /** + * @description [保存到数据库] + * @author Leocoder + */ + private void saveToDatabase(SysOperLog operLog) { + try { + // 通过ApplicationContext获取SysOperLogService,避免循环依赖 + Object service = applicationContext.getBean("sysOperLogServiceImpl"); + if (service != null) { + // 使用反射调用save方法 + service.getClass().getMethod("save", Object.class).invoke(service, operLog); + log.debug("操作日志保存到数据库成功: {}", operLog.getOperName()); + } else { + // 如果Service不存在,暂时放到Redis队列中 + String queueKey = "oper:log:queue"; + redisUtil.setListLeft(queueKey, operLog); + log.debug("操作日志暂存到Redis队列: {}", operLog.getOperName()); + } + } catch (Exception e) { + log.error("操作日志保存到数据库失败", e); + // 失败时放到Redis队列中 + try { + String queueKey = "oper:log:queue"; + redisUtil.setListLeft(queueKey, operLog); + log.debug("操作日志转存到Redis队列: {}", operLog.getOperName()); + } catch (Exception ex) { + log.error("操作日志转存到Redis队列也失败", ex); + } + } + } + + /** + * @description [更新统计缓存] + * @author Leocoder + */ + private void updateStatCache(SysOperLog operLog) { + try { + String today = DateUtil.format(new Date(), "yyyy-MM-dd"); + + // 用户操作统计 + String userKey = "oper:stat:user:" + operLog.getOperMan() + ":" + today; + redisUtil.add1(userKey); + // 25小时过期 + redisUtil.setCacheObjectHours(userKey, 1, 25); + + // 操作类型统计 + String typeKey = "oper:stat:type:" + operLog.getOperType() + ":" + today; + redisUtil.add1(typeKey); + redisUtil.setCacheObjectHours(typeKey, 1, 25); + + // 总操作统计 + String totalKey = "oper:stat:total:" + today; + redisUtil.add1(totalKey); + redisUtil.setCacheObjectHours(totalKey, 1, 25); + + // 错误统计 + if ("1".equals(operLog.getOperStatus())) { + String errorKey = "oper:stat:error:" + today; + redisUtil.add1(errorKey); + redisUtil.setCacheObjectHours(errorKey, 1, 25); + } + + } catch (Exception e) { + log.warn("更新统计缓存失败", e); + } + } + + /** + * @description [保存失败日志] + * @author Leocoder + */ + private void saveFailedLog(SysOperLog operLog, Exception e) { + // 记录失败日志到特定文件或存储 + log.warn("操作日志保存失败,详情: operName={}, operMan={}, error={}", + operLog.getOperName(), operLog.getOperMan(), e.getMessage()); + + // 可以考虑将失败的日志存储到Redis或文件中,后续重试 + try { + String failedKey = "oper:failed:" + System.currentTimeMillis(); + redisUtil.setCacheObjectHours(failedKey, operLog, 24); + } catch (Exception ex) { + log.error("保存失败日志到Redis失败", ex); + } + } +} \ No newline at end of file