USER_LOCALE = new ThreadLocal();
+
+ private LocaleUtil() {
+ }
+
+ public static void setUserTimeZone(TimeZone timezone) {
+ USER_TIME_ZONE.set(timezone);
+ }
+
+ public static TimeZone getUserTimeZone() {
+ TimeZone timeZone = USER_TIME_ZONE.get();
+ return timeZone != null ? timeZone : TimeZone.getDefault();
+ }
+
+ public static void resetUserTimeZone() {
+ USER_TIME_ZONE.remove();
+ }
+
+ public static void setUserLocale(Locale locale) {
+ USER_LOCALE.set(locale);
+ }
+
+ public static Locale getUserLocale() {
+ Locale locale = USER_LOCALE.get();
+ return locale != null ? locale : Locale.getDefault();
+ }
+
+ public static void resetUserLocale() {
+ USER_LOCALE.remove();
+ }
+
+ public static Calendar getLocaleCalendar() {
+ return getLocaleCalendar(getUserTimeZone());
+ }
+
+ public static Calendar getLocaleCalendar(int year, int month, int day) {
+ return getLocaleCalendar(year, month, day, 0, 0, 0);
+ }
+
+ public static Calendar getLocaleCalendar(int year, int month, int day, int hour, int minute, int second) {
+ Calendar cal = getLocaleCalendar();
+ cal.set(year, month, day, hour, minute, second);
+ cal.clear(14);
+ return cal;
+ }
+
+ public static Calendar getLocaleCalendar(TimeZone timeZone) {
+ return Calendar.getInstance(timeZone, getUserLocale());
+ }
+
+}
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/FileTypeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/FileTypeUtils.java
new file mode 100644
index 0000000000..5d1a5ab0e7
--- /dev/null
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/FileTypeUtils.java
@@ -0,0 +1,91 @@
+package cn.iocoder.yudao.framework.common.util.file;
+
+import org.apache.commons.io.FilenameUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.File;
+import java.util.Objects;
+
+/**
+ * 文件类型工具类
+ *
+ * @author 范劲松
+ */
+public class FileTypeUtils {
+ /**
+ * 获取文件类型
+ *
+ * 例如: test.txt, 返回: txt
+ *
+ * @param file 文件名
+ * @return 后缀(不含".")
+ */
+ public static String getFileType(File file) {
+ if (null == file) {
+ return StringUtils.EMPTY;
+ }
+ return getFileType(file.getName());
+ }
+
+ /**
+ * 获取文件类型
+ *
+ * 例如: test.txt, 返回: txt
+ *
+ * @param fileName 文件名
+ * @return 后缀(不含".")
+ */
+ public static String getFileType(String fileName) {
+ int separatorIndex = fileName.lastIndexOf(".");
+ if (separatorIndex < 0) {
+ return "";
+ }
+ return fileName.substring(separatorIndex + 1).toLowerCase();
+ }
+
+ /**
+ * 获取文件名的后缀
+ *
+ * @param file 表单文件
+ * @return 后缀名
+ */
+ public static String getExtension(MultipartFile file) {
+ String extension = FilenameUtils.getExtension(file.getOriginalFilename());
+ if (StringUtils.isEmpty(extension)) {
+ extension = MimeTypeUtils.getExtension(Objects.requireNonNull(file.getContentType()));
+ }
+ return extension;
+ }
+
+ /**
+ * 获取文件名的后缀
+ *
+ * @param filename 文件名
+ * @return 后缀名
+ */
+ public static String getExtension(String filename) {
+ return FilenameUtils.getExtension(filename);
+ }
+
+ /**
+ * 获取文件类型
+ *
+ * @param photoByte 文件字节码
+ * @return 后缀(不含".")
+ */
+ public static String getFileExtendName(byte[] photoByte) {
+ String strFileExtendName = "JPG";
+ if ((photoByte[0] == 71) && (photoByte[1] == 73) && (photoByte[2] == 70) && (photoByte[3] == 56)
+ && ((photoByte[4] == 55) || (photoByte[4] == 57)) && (photoByte[5] == 97)) {
+ strFileExtendName = "GIF";
+ } else if ((photoByte[6] == 74) && (photoByte[7] == 70) && (photoByte[8] == 73) && (photoByte[9] == 70)) {
+ strFileExtendName = "JPG";
+ } else if ((photoByte[0] == 66) && (photoByte[1] == 77)) {
+ strFileExtendName = "BMP";
+ } else if ((photoByte[1] == 80) && (photoByte[2] == 78) && (photoByte[3] == 71)) {
+ strFileExtendName = "PNG";
+ }
+ return strFileExtendName;
+ }
+}
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/FileUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/FileUtils.java
new file mode 100644
index 0000000000..f32a2c1765
--- /dev/null
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/FileUtils.java
@@ -0,0 +1,329 @@
+package cn.iocoder.yudao.framework.common.util.file;
+
+import cn.hutool.core.codec.Base64;
+import cn.hutool.core.io.FileTypeUtil;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.io.file.FileNameUtil;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import cn.iocoder.yudao.framework.common.util.string.StringUtils;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.SneakyThrows;
+import lombok.extern.log4j.Log4j2;
+import org.apache.commons.lang3.ArrayUtils;
+
+import java.io.*;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 文件处理工具类
+ *
+ * @author 范劲松
+ */
+@Log4j2
+public class FileUtils {
+ /**
+ * 字符常量:斜杠 {@code '/'}
+ */
+ public static final char SLASH = '/';
+
+ /**
+ * 字符常量:反斜杠 {@code '\\'}
+ */
+ public static final char BACKSLASH = '\\';
+
+ public static String FILENAME_PATTERN = "[a-zA-Z0-9_\\-\\|\\.\\u4e00-\\u9fa5]+";
+
+ /**
+ * 输出指定文件的byte数组
+ *
+ * @param filePath 文件路径
+ * @param os 输出流
+ * @return
+ */
+ public static void writeBytes(String filePath, OutputStream os) throws IOException {
+ FileInputStream fis = null;
+ try {
+ File file = new File(filePath);
+ if (!file.exists()) {
+ throw new FileNotFoundException(filePath);
+ }
+ fis = new FileInputStream(file);
+ byte[] b = new byte[1024];
+ int length;
+ while ((length = fis.read(b)) > 0) {
+ os.write(b, 0, length);
+ }
+ } catch (IOException e) {
+ throw e;
+ } finally {
+ if (os != null) {
+ try {
+ os.close();
+ } catch (IOException e1) {
+ e1.printStackTrace();
+ }
+ }
+ if (fis != null) {
+ try {
+ fis.close();
+ } catch (IOException e1) {
+ e1.printStackTrace();
+ }
+ }
+ }
+ }
+
+ /**
+ * 将inputstream转为Base64
+ *
+ * @param is InputStream
+ * @return base64
+ * @throws Exception 可能的异常
+ */
+ public static String getBase64FromInputStream(InputStream is) throws Exception {
+ // 将图片文件转化为字节数组字符串,并对其进行Base64编码处理
+ byte[] data = null;
+
+ // 读取图片字节数组
+ try {
+ ByteArrayOutputStream swapStream = new ByteArrayOutputStream();
+ byte[] buff = new byte[1024];
+ int rc = 0;
+ while ((rc = is.read(buff, 0, 1024)) > 0) {
+ swapStream.write(buff, 0, rc);
+ }
+ data = swapStream.toByteArray();
+ } catch (IOException e) {
+ e.printStackTrace();
+ } finally {
+ if (is != null) {
+ try {
+ is.close();
+ } catch (IOException e) {
+ throw new Exception("输入流关闭异常");
+ }
+ }
+ }
+// log.info("byte string: {}", new String(data));
+ return Base64.encode(data);
+ }
+
+
+ /**
+ * 删除文件
+ *
+ * @param filePath 文件
+ * @return
+ */
+ public static boolean deleteFile(String filePath) {
+ boolean flag = false;
+ File file = new File(filePath);
+ // 路径为文件且不为空则进行删除
+ if (file.isFile() && file.exists()) {
+ file.delete();
+ flag = true;
+ }
+ return flag;
+ }
+
+ /**
+ * 文件名称验证
+ *
+ * @param filename 文件名称
+ * @return true 正常 false 非法
+ */
+ public static boolean isValidFilename(String filename) {
+ return filename.matches(FILENAME_PATTERN);
+ }
+
+ /**
+ * 检查文件是否可下载
+ *
+ * @param resource 需要下载的文件
+ * @return true 正常 false 非法
+ */
+ public static boolean checkAllowDownload(String resource) {
+ // 禁止目录上跳级别
+ if (StringUtils.contains(resource, "..")) {
+ return false;
+ }
+
+ // 检查允许下载的文件规则
+ if (ArrayUtils.contains(MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION, FileTypeUtils.getFileType(resource))) {
+ return true;
+ }
+
+ // 不在允许下载的文件规则
+ return false;
+ }
+
+ /**
+ * 下载文件名重新编码
+ *
+ * @param request 请求对象
+ * @param fileName 文件名
+ * @return 编码后的文件名
+ */
+ public static String setFileDownloadHeader(HttpServletRequest request, String fileName) throws UnsupportedEncodingException {
+ final String agent = request.getHeader("USER-AGENT");
+ String filename = fileName;
+ if (agent.contains("MSIE")) {
+ // IE浏览器
+ filename = URLEncoder.encode(filename, "utf-8");
+ filename = filename.replace("+", " ");
+ } else if (agent.contains("Firefox")) {
+ // 火狐浏览器
+ filename = new String(fileName.getBytes(), "ISO8859-1");
+ } else if (agent.contains("Chrome")) {
+ // google浏览器
+ filename = URLEncoder.encode(filename, "utf-8");
+ } else {
+ // 其它浏览器
+ filename = URLEncoder.encode(filename, "utf-8");
+ }
+ return filename;
+ }
+
+ /**
+ * 返回文件名
+ *
+ * @param filePath 文件
+ * @return 文件名
+ */
+ public static String getName(String filePath) {
+ if (null == filePath) {
+ return null;
+ }
+ int len = filePath.length();
+ if (0 == len) {
+ return filePath;
+ }
+ if (isFileSeparator(filePath.charAt(len - 1))) {
+ // 以分隔符结尾的去掉结尾分隔符
+ len--;
+ }
+
+ int begin = 0;
+ char c;
+ for (int i = len - 1; i > -1; i--) {
+ c = filePath.charAt(i);
+ if (isFileSeparator(c)) {
+ // 查找最后一个路径分隔符(/或者\)
+ begin = i + 1;
+ break;
+ }
+ }
+
+ return filePath.substring(begin, len);
+ }
+
+ /**
+ * 是否为Windows或者Linux(Unix)文件分隔符
+ * Windows平台下分隔符为\,Linux(Unix)为/
+ *
+ * @param c 字符
+ * @return 是否为Windows或者Linux(Unix)文件分隔符
+ */
+ public static boolean isFileSeparator(char c) {
+ return SLASH == c || BACKSLASH == c;
+ }
+
+ /**
+ * 下载文件名重新编码
+ *
+ * @param response 响应对象
+ * @param realFileName 真实文件名
+ * @return
+ */
+ public static void setAttachmentResponseHeader(HttpServletResponse response, String realFileName) throws UnsupportedEncodingException {
+ String percentEncodedFileName = percentEncode(realFileName);
+
+ StringBuilder contentDispositionValue = new StringBuilder();
+ contentDispositionValue.append("attachment; filename=")
+ .append(percentEncodedFileName)
+ .append(";")
+ .append("filename*=")
+ .append("utf-8''")
+ .append(percentEncodedFileName);
+
+ response.setHeader("Content-disposition", contentDispositionValue.toString());
+ }
+
+ /**
+ * 百分号编码工具方法
+ *
+ * @param s 需要百分号编码的字符串
+ * @return 百分号编码后的字符串
+ */
+ public static String percentEncode(String s) throws UnsupportedEncodingException {
+ String encode = URLEncoder.encode(s, StandardCharsets.UTF_8.toString());
+ return encode.replaceAll("\\+", "%20");
+ }
+
+ /**
+ * 创建临时文件
+ * 该文件会在 JVM 退出时,进行删除
+ *
+ * @param data 文件内容
+ * @return 文件
+ */
+ @SneakyThrows
+ public static File createTempFile(String data) {
+ File file = createTempFile();
+ // 写入内容
+ FileUtil.writeUtf8String(data, file);
+ return file;
+ }
+
+ /**
+ * 创建临时文件
+ * 该文件会在 JVM 退出时,进行删除
+ *
+ * @param data 文件内容
+ * @return 文件
+ */
+ @SneakyThrows
+ public static File createTempFile(byte[] data) {
+ File file = createTempFile();
+ // 写入内容
+ FileUtil.writeBytes(data, file);
+ return file;
+ }
+
+ /**
+ * 创建临时文件,无内容
+ * 该文件会在 JVM 退出时,进行删除
+ *
+ * @return 文件
+ */
+ @SneakyThrows
+ public static File createTempFile() {
+ // 创建文件,通过 UUID 保证唯一
+ File file = File.createTempFile(IdUtil.simpleUUID(), null);
+ // 标记 JVM 退出时,自动删除
+ file.deleteOnExit();
+ return file;
+ }
+
+ /**
+ * 生成文件路径
+ *
+ * @param content 文件内容
+ * @param originalName 原始文件名
+ * @return path,唯一不可重复
+ */
+ public static String generatePath(byte[] content, String originalName) {
+ String sha256Hex = DigestUtil.sha256Hex(content);
+ // 情况一:如果存在 name,则优先使用 name 的后缀
+ if (StrUtil.isNotBlank(originalName)) {
+ String extName = FileNameUtil.extName(originalName);
+ return StrUtil.isBlank(extName) ? sha256Hex : sha256Hex + "." + extName;
+ }
+ // 情况二:基于 content 计算
+ return sha256Hex + '.' + FileTypeUtil.getType(new ByteArrayInputStream(content));
+ }
+}
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/ImageUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/ImageUtils.java
new file mode 100644
index 0000000000..47369759e6
--- /dev/null
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/ImageUtils.java
@@ -0,0 +1,71 @@
+package cn.iocoder.yudao.framework.common.util.file;
+
+import org.apache.commons.io.IOUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.net.URL;
+import java.net.URLConnection;
+import java.util.Arrays;
+
+/**
+ * 图片处理工具类
+ *
+ * @author 范劲松
+ */
+public class ImageUtils {
+ private static final Logger log = LoggerFactory.getLogger(ImageUtils.class);
+
+ public static byte[] getImage(String imagePath) {
+ InputStream is = getFile(imagePath);
+ try {
+ return IOUtils.toByteArray(is);
+ } catch (Exception e) {
+ log.error("图片加载异常 {}", e);
+ return null;
+ } finally {
+ IOUtils.closeQuietly(is);
+ }
+ }
+
+ public static InputStream getFile(String imagePath) {
+ try {
+ byte[] result = readFile(imagePath);
+ result = Arrays.copyOf(result, result.length);
+ return new ByteArrayInputStream(result);
+ } catch (Exception e) {
+ log.error("获取图片异常 {}", e);
+ }
+ return null;
+ }
+
+ /**
+ * 读取文件为字节数据
+ *
+ * @param url 地址
+ * @return 字节数据
+ */
+ public static byte[] readFile(String url) {
+ InputStream in = null;
+ ByteArrayOutputStream baos = null;
+ try {
+ // 网络地址
+ URL urlObj = new URL(url);
+ URLConnection urlConnection = urlObj.openConnection();
+ urlConnection.setConnectTimeout(30 * 1000);
+ urlConnection.setReadTimeout(60 * 1000);
+ urlConnection.setDoInput(true);
+ in = urlConnection.getInputStream();
+ return IOUtils.toByteArray(in);
+ } catch (Exception e) {
+ log.error("访问文件异常 {}", e);
+ return null;
+ } finally {
+ IOUtils.closeQuietly(in);
+ IOUtils.closeQuietly(baos);
+ }
+ }
+}
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/IoUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/IoUtils.java
new file mode 100644
index 0000000000..93147d8596
--- /dev/null
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/IoUtils.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.framework.common.util.file;
+
+import cn.hutool.core.io.IORuntimeException;
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.util.StrUtil;
+
+import java.io.InputStream;
+
+/**
+ * IO 工具类,用于 {@link IoUtil} 缺失的方法
+ *
+ * @author 范劲松
+ */
+public class IoUtils {
+
+ /**
+ * 从流中读取 UTF8 编码的内容
+ *
+ * @param in 输入流
+ * @param isClose 是否关闭
+ * @return 内容
+ * @throws IORuntimeException IO 异常
+ */
+ public static String readUtf8(InputStream in, boolean isClose) throws IORuntimeException {
+ return StrUtil.utf8Str(IoUtil.read(in, isClose));
+ }
+
+}
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/MimeTypeUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/MimeTypeUtils.java
new file mode 100644
index 0000000000..b5ab76137d
--- /dev/null
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/MimeTypeUtils.java
@@ -0,0 +1,59 @@
+package cn.iocoder.yudao.framework.common.util.file;
+
+/**
+ * 媒体类型工具类
+ *
+ * @author 范劲松
+ */
+public class MimeTypeUtils {
+ public static final String IMAGE_PNG = "image/png";
+
+ public static final String IMAGE_JPG = "image/jpg";
+
+ public static final String IMAGE_JPEG = "image/jpeg";
+
+ public static final String IMAGE_BMP = "image/bmp";
+
+ public static final String IMAGE_GIF = "image/gif";
+
+ public static final String[] IMAGE_EXTENSION = {"bmp", "gif", "jpg", "jpeg", "png"};
+
+ public static final String[] FLASH_EXTENSION = {"swf", "flv"};
+
+ public static final String[] MEDIA_EXTENSION = {"swf", "flv", "mp3", "wav", "wma", "wmv", "mid", "avi", "mpg",
+ "asf", "rm", "rmvb"};
+
+ public static final String[] VIDEO_EXTENSION = {"mp4", "avi", "rmvb"};
+
+ public static final String[] DEFAULT_ALLOWED_EXTENSION = {
+ // 图片
+ "bmp", "gif", "jpg", "jpeg", "png",
+ // word excel powerpoint
+ "doc", "docx", "xls", "xlsx", "ppt", "pptx", "html", "htm", "txt",
+ // 压缩文件
+ "rar", "zip", "gz", "bz2",
+ // 视频格式
+ "mp4", "avi", "rmvb",
+ //音频格式
+ "swf", "flv", "mp3", "wav", "wma", "wmv", "mid", "avi", "mpg",
+ "asf", "rm", "rmvb",
+ // pdf
+ "pdf"};
+
+ public static String getExtension(String prefix) {
+ switch (prefix) {
+ case IMAGE_PNG:
+ return "png";
+ case IMAGE_JPG:
+ return "jpg";
+ case IMAGE_JPEG:
+ return "jpeg";
+ case IMAGE_BMP:
+ return "bmp";
+ case IMAGE_GIF:
+ return "gif";
+ default:
+ return "";
+ }
+ }
+}
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/PicUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/PicUtils.java
new file mode 100644
index 0000000000..b8c264dd71
--- /dev/null
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/file/PicUtils.java
@@ -0,0 +1,69 @@
+package cn.iocoder.yudao.framework.common.util.file;
+
+import lombok.extern.log4j.Log4j2;
+import net.coobird.thumbnailator.Thumbnails;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+
+/**
+ * 图片处理工具类
+ *
+ * @author 范劲松
+ */
+@Log4j2
+public class PicUtils {
+
+ /**
+ * 根据指定大小压缩图片
+ *
+ * @param imageBytes 源图片字节数组
+ * @param desFileSize 指定图片大小,单位kb
+ * @param imageId 影像编号
+ * @return 压缩质量后的图片字节数组
+ */
+ public static byte[] compressPicForScale(byte[] imageBytes, long desFileSize, String imageId) {
+ if (imageBytes == null || imageBytes.length <= 0 || imageBytes.length < desFileSize * 1024) {
+ return imageBytes;
+ }
+ long srcSize = imageBytes.length;
+ double accuracy = getAccuracy(srcSize / 1024);
+ try {
+ while (imageBytes.length > desFileSize * 1024) {
+ ByteArrayInputStream inputStream = new ByteArrayInputStream(imageBytes);
+ ByteArrayOutputStream outputStream = new ByteArrayOutputStream(imageBytes.length);
+ Thumbnails.of(inputStream)
+ .scale(accuracy)
+ .outputQuality(accuracy)
+ .toOutputStream(outputStream);
+ imageBytes = outputStream.toByteArray();
+ }
+ log.info("【图片压缩】imageId={} | 图片原大小={}kb | 压缩后大小={}kb",
+ imageId, srcSize / 1024, imageBytes.length / 1024);
+ } catch (Exception e) {
+ log.error("【图片压缩】msg=图片压缩失败!", e);
+ }
+ return imageBytes;
+ }
+
+ /**
+ * 自动调节精度(经验数值)
+ *
+ * @param size 源图片大小
+ * @return 图片压缩质量比
+ */
+ private static double getAccuracy(long size) {
+ double accuracy;
+ if (size < 900) {
+ accuracy = 0.85;
+ } else if (size < 2047) {
+ accuracy = 0.6;
+ } else if (size < 3275) {
+ accuracy = 0.44;
+ } else {
+ accuracy = 0.4;
+ }
+ return accuracy;
+ }
+
+}
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/servlet/ServletUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/servlet/ServletUtils.java
index 12731edad6..70dd412853 100644
--- a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/servlet/ServletUtils.java
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/servlet/ServletUtils.java
@@ -54,6 +54,17 @@ public class ServletUtils {
return ((ServletRequestAttributes) requestAttributes).getRequest();
}
+ /**
+ * 获取response
+ */
+ public static HttpServletResponse getResponse() {
+ RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
+ if (!(requestAttributes instanceof ServletRequestAttributes)) {
+ return null;
+ }
+ return ((ServletRequestAttributes) requestAttributes).getResponse();
+ }
+
public static String getUserAgent() {
HttpServletRequest request = getRequest();
if (request == null) {
diff --git a/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StringUtils.java b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StringUtils.java
new file mode 100644
index 0000000000..29230912d5
--- /dev/null
+++ b/yudao-framework/yudao-common/src/main/java/cn/iocoder/yudao/framework/common/util/string/StringUtils.java
@@ -0,0 +1,478 @@
+package cn.iocoder.yudao.framework.common.util.string;
+
+import cn.hutool.core.text.AntPathMatcher;
+import cn.iocoder.yudao.framework.common.constants.Constants;
+import cn.iocoder.yudao.framework.common.text.StrFormatter;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 字符串工具类
+ *
+ * @author 范劲松
+ * @date 2022/08/05 16:03
+ */
+public class StringUtils extends org.apache.commons.lang3.StringUtils {
+ /**
+ * 空字符串
+ */
+ private static final String NULLSTR = "";
+
+ /**
+ * 下划线
+ */
+ private static final char SEPARATOR = '_';
+
+ /**
+ * 获取参数不为空值
+ *
+ * @param value defaultValue 要判断的value
+ * @return value 返回值
+ */
+ public static T nvl(T value, T defaultValue) {
+ return value != null ? value : defaultValue;
+ }
+
+ /**
+ * * 判断一个Collection是否为空, 包含List,Set,Queue
+ *
+ * @param coll 要判断的Collection
+ * @return true:为空 false:非空
+ */
+ public static boolean isEmpty(Collection> coll) {
+ return isNull(coll) || coll.isEmpty();
+ }
+
+ /**
+ * * 判断一个Collection是否非空,包含List,Set,Queue
+ *
+ * @param coll 要判断的Collection
+ * @return true:非空 false:空
+ */
+ public static boolean isNotEmpty(Collection> coll) {
+ return !isEmpty(coll);
+ }
+
+ /**
+ * * 判断一个对象数组是否为空
+ *
+ * @param objects 要判断的对象数组
+ * * @return true:为空 false:非空
+ */
+ public static boolean isEmpty(Object[] objects) {
+ return isNull(objects) || (objects.length == 0);
+ }
+
+ /**
+ * * 判断一个对象数组是否非空
+ *
+ * @param objects 要判断的对象数组
+ * @return true:非空 false:空
+ */
+ public static boolean isNotEmpty(Object[] objects) {
+ return !isEmpty(objects);
+ }
+
+ /**
+ * * 判断一个Map是否为空
+ *
+ * @param map 要判断的Map
+ * @return true:为空 false:非空
+ */
+ public static boolean isEmpty(Map, ?> map) {
+ return isNull(map) || map.isEmpty();
+ }
+
+ /**
+ * * 判断一个Map是否为空
+ *
+ * @param map 要判断的Map
+ * @return true:非空 false:空
+ */
+ public static boolean isNotEmpty(Map, ?> map) {
+ return !isEmpty(map);
+ }
+
+ /**
+ * * 判断一个字符串是否为空串
+ *
+ * @param str String
+ * @return true:为空 false:非空
+ */
+ public static boolean isEmpty(String str) {
+ return isNull(str) || NULLSTR.equals(str.trim());
+ }
+
+ /**
+ * * 判断一个字符串是否为非空串
+ *
+ * @param str String
+ * @return true:非空串 false:空串
+ */
+ public static boolean isNotEmpty(String str) {
+ return !isEmpty(str);
+ }
+
+ /**
+ * * 判断一个对象是否为空
+ *
+ * @param object Object
+ * @return true:为空 false:非空
+ */
+ public static boolean isNull(Object object) {
+ return object == null;
+ }
+
+ /**
+ * * 判断一个对象是否非空
+ *
+ * @param object Object
+ * @return true:非空 false:空
+ */
+ public static boolean isNotNull(Object object) {
+ return !isNull(object);
+ }
+
+ /**
+ * * 判断一个对象是否是数组类型(Java基本型别的数组)
+ *
+ * @param object 对象
+ * @return true:是数组 false:不是数组
+ */
+ public static boolean isArray(Object object) {
+ return isNotNull(object) && object.getClass().isArray();
+ }
+
+ /**
+ * 去空格
+ */
+ public static String trim(String str) {
+ return (str == null ? "" : str.trim());
+ }
+
+ /**
+ * 截取字符串
+ *
+ * @param str 字符串
+ * @param start 开始
+ * @return 结果
+ */
+ public static String substring(final String str, int start) {
+ if (str == null) {
+ return NULLSTR;
+ }
+
+ if (start < 0) {
+ start = str.length() + start;
+ }
+
+ if (start < 0) {
+ start = 0;
+ }
+ if (start > str.length()) {
+ return NULLSTR;
+ }
+
+ return str.substring(start);
+ }
+
+ /**
+ * 截取字符串
+ *
+ * @param str 字符串
+ * @param start 开始
+ * @param end 结束
+ * @return 结果
+ */
+ public static String substring(final String str, int start, int end) {
+ if (str == null) {
+ return NULLSTR;
+ }
+
+ if (end < 0) {
+ end = str.length() + end;
+ }
+ if (start < 0) {
+ start = str.length() + start;
+ }
+
+ if (end > str.length()) {
+ end = str.length();
+ }
+
+ if (start > end) {
+ return NULLSTR;
+ }
+
+ if (start < 0) {
+ start = 0;
+ }
+ if (end < 0) {
+ end = 0;
+ }
+
+ return str.substring(start, end);
+ }
+
+ /**
+ * 判断是否为空,并且不是空白字符
+ *
+ * @param str 要判断的value
+ * @return 结果
+ */
+ public static boolean hasText(String str) {
+ return (str != null && !str.isEmpty() && containsText(str));
+ }
+
+ private static boolean containsText(CharSequence str) {
+ int strLen = str.length();
+ for (int i = 0; i < strLen; i++) {
+ if (!Character.isWhitespace(str.charAt(i))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 格式化文本, {} 表示占位符
+ * 此方法只是简单将占位符 {} 按照顺序替换为参数
+ * 如果想输出 {} 使用 \\转义 { 即可,如果想输出 {} 之前的 \ 使用双转义符 \\\\ 即可
+ * 例:
+ * 通常使用:format("this is {} for {}", "a", "b") -> this is a for b
+ * 转义{}: format("this is \\{} for {}", "a", "b") -> this is \{} for a
+ * 转义\: format("this is \\\\{} for {}", "a", "b") -> this is \a for b
+ *
+ * @param template 文本模板,被替换的部分用 {} 表示
+ * @param params 参数值
+ * @return 格式化后的文本
+ */
+ public static String format(String template, Object... params) {
+ if (isEmpty(params) || isEmpty(template)) {
+ return template;
+ }
+ return StrFormatter.format(template, params);
+ }
+
+ /**
+ * 是否为http(s)://开头
+ *
+ * @param link 链接
+ * @return 结果
+ */
+ public static boolean ishttp(String link) {
+ return StringUtils.startsWithAny(link, Constants.HTTP, Constants.HTTPS);
+ }
+
+ /**
+ * 判断给定的set列表中是否包含数组array 判断给定的数组array中是否包含给定的元素value
+ *
+ * @param set 给定的集合
+ * @param array 给定的数组
+ * @return boolean 结果
+ */
+ public static boolean containsAny(Collection collection, String... array) {
+ if (isEmpty(collection) || isEmpty(array)) {
+ return false;
+ } else {
+ for (String str : array) {
+ if (collection.contains(str)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ /**
+ * 驼峰转下划线命名
+ */
+ public static String toUnderScoreCase(String str) {
+ if (str == null) {
+ return null;
+ }
+ StringBuilder sb = new StringBuilder();
+ // 前置字符是否大写
+ boolean preCharIsUpperCase = true;
+ // 当前字符是否大写
+ boolean curreCharIsUpperCase = true;
+ // 下一字符是否大写
+ boolean nexteCharIsUpperCase = true;
+ for (int i = 0; i < str.length(); i++) {
+ char c = str.charAt(i);
+ if (i > 0) {
+ preCharIsUpperCase = Character.isUpperCase(str.charAt(i - 1));
+ } else {
+ preCharIsUpperCase = false;
+ }
+
+ curreCharIsUpperCase = Character.isUpperCase(c);
+
+ if (i < (str.length() - 1)) {
+ nexteCharIsUpperCase = Character.isUpperCase(str.charAt(i + 1));
+ }
+
+ if (preCharIsUpperCase && curreCharIsUpperCase && !nexteCharIsUpperCase) {
+ sb.append(SEPARATOR);
+ } else if ((i != 0 && !preCharIsUpperCase) && curreCharIsUpperCase) {
+ sb.append(SEPARATOR);
+ }
+ sb.append(Character.toLowerCase(c));
+ }
+
+ return sb.toString();
+ }
+
+ /**
+ * 是否包含字符串
+ *
+ * @param str 验证字符串
+ * @param strs 字符串组
+ * @return 包含返回true
+ */
+ public static boolean inStringIgnoreCase(String str, String... strs) {
+ if (str != null && strs != null) {
+ for (String s : strs) {
+ if (str.equalsIgnoreCase(trim(s))) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 将下划线大写方式命名的字符串转换为驼峰式。如果转换前的下划线大写方式命名的字符串为空,则返回空字符串。 例如:HELLO_WORLD->HelloWorld
+ *
+ * @param name 转换前的下划线大写方式命名的字符串
+ * @return 转换后的驼峰式命名的字符串
+ */
+ public static String convertToCamelCase(String name) {
+ StringBuilder result = new StringBuilder();
+ // 快速检查
+ if (name == null || name.isEmpty()) {
+ // 没必要转换
+ return "";
+ } else if (!name.contains("_")) {
+ // 不含下划线,仅将首字母大写
+ return name.substring(0, 1).toUpperCase() + name.substring(1);
+ }
+ // 用下划线将原始字符串分割
+ String[] camels = name.split("_");
+ for (String camel : camels) {
+ // 跳过原始字符串中开头、结尾的下换线或双重下划线
+ if (camel.isEmpty()) {
+ continue;
+ }
+ // 首字母大写
+ result.append(camel.substring(0, 1).toUpperCase());
+ result.append(camel.substring(1).toLowerCase());
+ }
+ return result.toString();
+ }
+
+ /**
+ * 驼峰式命名法 例如:user_name->userName
+ */
+ public static String toCamelCase(String s) {
+ if (s == null) {
+ return null;
+ }
+ s = s.toLowerCase();
+ StringBuilder sb = new StringBuilder(s.length());
+ boolean upperCase = false;
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+
+ if (c == SEPARATOR) {
+ upperCase = true;
+ } else if (upperCase) {
+ sb.append(Character.toUpperCase(c));
+ upperCase = false;
+ } else {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+
+ /**
+ * 查找指定字符串是否匹配指定字符串列表中的任意一个字符串
+ *
+ * @param str 指定字符串
+ * @param strs 需要检查的字符串数组
+ * @return 是否匹配
+ */
+ public static boolean matches(String str, List strs) {
+ if (isEmpty(str) || isEmpty(strs)) {
+ return false;
+ }
+ for (String pattern : strs) {
+ if (isMatch(pattern, str)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * 判断url是否与规则配置:
+ * ? 表示单个字符;
+ * * 表示一层路径内的任意字符串,不可跨层级;
+ * ** 表示任意层路径;
+ *
+ * @param pattern 匹配规则
+ * @param url 需要匹配的url
+ * @return
+ */
+ public static boolean isMatch(String pattern, String url) {
+ AntPathMatcher matcher = new AntPathMatcher();
+ return matcher.match(pattern, url);
+ }
+
+ @SuppressWarnings("unchecked")
+ public static T cast(Object obj) {
+ return (T) obj;
+ }
+
+ /**
+ * 数字左边补齐0,使之达到指定长度。注意,如果数字转换为字符串后,长度大于size,则只保留 最后size个字符。
+ *
+ * @param num 数字对象
+ * @param size 字符串指定长度
+ * @return 返回数字的字符串格式,该字符串为指定长度。
+ */
+ public static final String padl(final Number num, final int size) {
+ return padl(num.toString(), size, '0');
+ }
+
+ /**
+ * 字符串左补齐。如果原始字符串s长度大于size,则只保留最后size个字符。
+ *
+ * @param s 原始字符串
+ * @param size 字符串指定长度
+ * @param c 用于补齐的字符
+ * @return 返回指定长度的字符串,由原字符串左补齐或截取得到。
+ */
+ public static final String padl(final String s, final int size, final char c) {
+ final StringBuilder sb = new StringBuilder(size);
+ if (s != null) {
+ final int len = s.length();
+ if (s.length() <= size) {
+ for (int i = size - len; i > 0; i--) {
+ sb.append(c);
+ }
+ sb.append(s);
+ } else {
+ return s.substring(len - size, len);
+ }
+ } else {
+ for (int i = size; i > 0; i--) {
+ sb.append(c);
+ }
+ }
+ return sb.toString();
+ }
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/Excel.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/Excel.java
new file mode 100644
index 0000000000..1fc415f381
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/Excel.java
@@ -0,0 +1,203 @@
+package cn.iocoder.yudao.framework.excel.core.annotations;
+
+
+import cn.iocoder.yudao.framework.excel.core.util.ExcelHandlerAdapter;
+import org.apache.poi.ss.usermodel.HorizontalAlignment;
+import org.apache.poi.ss.usermodel.IndexedColors;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.math.BigDecimal;
+
+/**
+ * 自定义导出Excel数据注解
+ *
+ * @author 范劲松
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface Excel {
+ /**
+ * 导出时在excel中排序
+ */
+ public int sort() default Integer.MAX_VALUE;
+
+ /**
+ * 导出到Excel中的名字.
+ */
+ public String name() default "";
+
+ /**
+ * 日期格式, 如: yyyy-MM-dd
+ */
+ public String dateFormat() default "";
+
+ /**
+ * 读取内容转表达式 (如: 0=男,1=女,2=未知)
+ */
+ public String readConverterExp() default "";
+
+ /**
+ * 分隔符,读取字符串组内容
+ */
+ public String separator() default ",";
+
+ /**
+ * BigDecimal 精度 默认:-1(默认不开启BigDecimal格式化)
+ */
+ public int scale() default -1;
+
+ /**
+ * BigDecimal 舍入规则 默认:BigDecimal.ROUND_HALF_EVEN
+ */
+ public int roundingMode() default BigDecimal.ROUND_HALF_EVEN;
+
+ /**
+ * 导出时在excel中每个列的高度 单位为字符
+ */
+ public double height() default 14;
+
+ /**
+ * 导出时在excel中每个列的宽 单位为字符
+ */
+ public double width() default 16;
+
+ /**
+ * 文字后缀,如% 90 变成90%
+ */
+ public String suffix() default "";
+
+ /**
+ * 当值为空时,字段的默认值
+ */
+ public String defaultValue() default "";
+
+ /**
+ * 提示信息
+ */
+ public String prompt() default "";
+
+ /**
+ * 设置只能选择不能输入的列内容.
+ */
+ public String[] combo() default {};
+
+ /**
+ * 是否需要纵向合并单元格,应对需求:含有list集合单元格)
+ */
+ public boolean needMerge() default false;
+
+ /**
+ * 是否导出数据,应对需求:有时我们需要导出一份模板,这是标题需要但内容需要用户手工填写.
+ */
+ public boolean isExport() default true;
+
+ /**
+ * 另一个类中的属性名称,支持多级获取,以小数点隔开
+ */
+ public String targetAttr() default "";
+
+ /**
+ * 是否自动统计数据,在最后追加一行统计数据总和
+ */
+ public boolean isStatistics() default false;
+
+ /**
+ * 导出类型(0数字 1字符串)
+ */
+ public ColumnType cellType() default ColumnType.STRING;
+
+ /**
+ * 导出列头背景色
+ */
+ public IndexedColors headerBackgroundColor() default IndexedColors.GREY_50_PERCENT;
+
+ /**
+ * 导出列头字体颜色
+ */
+ public IndexedColors headerColor() default IndexedColors.WHITE;
+
+ /**
+ * 导出单元格背景色
+ */
+ public IndexedColors backgroundColor() default IndexedColors.WHITE;
+
+ /**
+ * 导出单元格字体颜色
+ */
+ public IndexedColors color() default IndexedColors.BLACK;
+
+ /**
+ * 导出字段对齐方式
+ */
+ public HorizontalAlignment align() default HorizontalAlignment.CENTER;
+
+ /**
+ * 自定义数据处理器
+ */
+ public Class> handler() default ExcelHandlerAdapter.class;
+
+ /**
+ * 自定义数据处理器参数
+ */
+ public String[] args() default {};
+
+ /**
+ * 字段类型(0:导出导入;1:仅导出;2:仅导入)
+ */
+ Type type() default Type.ALL;
+
+ public enum Type {
+ /**
+ * 导出导入
+ */
+ ALL(0),
+
+ /**
+ * 仅导出
+ */
+ EXPORT(1),
+
+ /**
+ * 仅导入
+ */
+ IMPORT(2);
+ private final int value;
+
+ Type(int value) {
+ this.value = value;
+ }
+
+ public int value() {
+ return this.value;
+ }
+ }
+
+ public enum ColumnType {
+ /**
+ * 0数字
+ */
+ NUMERIC(0),
+
+ /**
+ * 1字符串
+ */
+ STRING(1),
+
+ /**
+ * 2图片
+ */
+ IMAGE(2);
+ private final int value;
+
+ ColumnType(int value) {
+ this.value = value;
+ }
+
+ public int value() {
+ return this.value;
+ }
+ }
+}
\ No newline at end of file
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/ExcelValid.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/ExcelValid.java
new file mode 100644
index 0000000000..c33f038268
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/ExcelValid.java
@@ -0,0 +1,18 @@
+package cn.iocoder.yudao.framework.excel.core.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+
+/**
+ * @USER: LiLiang
+ * @DATE: 2022/11/5 11:03
+ * @DESCRIPTION: excel导入自定义校验注解
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.FIELD)
+public @interface ExcelValid {
+ String message() default "此列数据不能为空";
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/Excels.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/Excels.java
new file mode 100644
index 0000000000..f73e85b5bb
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/annotations/Excels.java
@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.framework.excel.core.annotations;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Excel注解集
+ *
+ * @author 范劲松
+ */
+@Target(ElementType.FIELD)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Excels {
+ Excel[] value();
+}
\ No newline at end of file
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/function/IEasyExcelExportServer.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/function/IEasyExcelExportServer.java
new file mode 100644
index 0000000000..0747b223e6
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/function/IEasyExcelExportServer.java
@@ -0,0 +1,12 @@
+package cn.iocoder.yudao.framework.excel.core.function;
+
+import java.util.List;
+
+/**
+ * @USER: LiLiang
+ * @DATE: 2022/11/5 10:17
+ * @DESCRIPTION: EasyExcel大数据量分页导出实现接口
+ */
+public interface IEasyExcelExportServer {
+ List selectForExcelExport (Object param, int pageNum);
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/function/IEasyExcelImportServer.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/function/IEasyExcelImportServer.java
new file mode 100644
index 0000000000..fc1f4c6041
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/function/IEasyExcelImportServer.java
@@ -0,0 +1,28 @@
+package cn.iocoder.yudao.framework.excel.core.function;
+
+
+import cn.iocoder.yudao.framework.excel.core.util.ExcelIOResultVo;
+
+import java.util.List;
+
+/**
+ * @USER: LiLiang
+ * @DATE: 2022/11/5 10:17
+ * @DESCRIPTION: EasyExcel大数据量分批导入实现接口
+ */
+public interface IEasyExcelImportServer {
+ /**
+ * 批量数据库校验去重查询,in一次控制在1000条数据
+ * @param onlyCode
+ * @return
+ */
+ List batchCheckByOnlyCode(List onlyCode);
+
+ /**
+ * 批量持久化导入数据
+ * @param list
+ */
+ void importBatchSave(List list);
+
+ void syncSaveSchedule(ExcelIOResultVo excelIoResultVo);
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/AbstractEasyExcelImportValidHandler.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/AbstractEasyExcelImportValidHandler.java
new file mode 100644
index 0000000000..b03b54f59c
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/handler/AbstractEasyExcelImportValidHandler.java
@@ -0,0 +1,40 @@
+package cn.iocoder.yudao.framework.excel.core.handler;
+
+
+
+import cn.iocoder.yudao.framework.common.exception.CustomException;
+import cn.iocoder.yudao.framework.excel.core.annotations.ExcelValid;
+
+import java.lang.reflect.Field;
+import java.util.Objects;
+
+/**
+ * @author liliang
+ * @DATE: 2022/11/5 11:03
+ * @DESCRIPTION: 自定义导入校验抽象类
+ */
+public abstract class AbstractEasyExcelImportValidHandler{
+ /**
+ * Excel导入基础校验
+ */
+ public void baseValid(T t) throws IllegalAccessException {
+ Field[] fields = t.getClass().getDeclaredFields();
+ for (Field field : fields) {
+ //设置可访问
+ field.setAccessible(true);
+ //属性的值
+ Object fieldValue = field.get(t);
+ //是否包含必填校验注解
+ boolean isExcelValid = field.isAnnotationPresent(ExcelValid.class);
+ if (isExcelValid && Objects.isNull(fieldValue)) {
+ throw new CustomException(field.getAnnotation(ExcelValid.class).message());
+ }
+ }
+ }
+
+ /**
+ * Excel导入自定义校验(请勿在此方法中进行数据库连接操作)
+ * @param t
+ */
+ public abstract void valid(T t) throws Exception;
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/EasyExcelUtil.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/EasyExcelUtil.java
new file mode 100644
index 0000000000..a6af66d930
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/EasyExcelUtil.java
@@ -0,0 +1,282 @@
+package cn.iocoder.yudao.framework.excel.core.util;
+
+import cn.hutool.core.collection.CollectionUtil;
+import cn.hutool.core.io.FileUtil;
+import cn.hutool.core.util.ReflectUtil;
+import cn.iocoder.yudao.framework.common.exception.CustomException;
+import cn.iocoder.yudao.framework.excel.core.function.IEasyExcelExportServer;
+import cn.iocoder.yudao.framework.excel.core.function.IEasyExcelImportServer;
+import cn.iocoder.yudao.framework.excel.core.handler.AbstractEasyExcelImportValidHandler;
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
+import com.alibaba.excel.context.AnalysisContext;
+import com.alibaba.excel.read.listener.ReadListener;
+import com.alibaba.excel.support.ExcelTypeEnum;
+import com.alibaba.excel.util.ListUtils;
+import com.alibaba.excel.write.handler.WriteHandler;
+import com.alibaba.excel.write.metadata.WriteSheet;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.multipart.MultipartFile;
+
+import cn.iocoder.yudao.framework.common.util.servlet.ServletUtils;
+import java.io.File;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.URLEncoder;
+import java.util.*;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+/**
+ * @USER: LiLiang
+ * @DATE: 2022/11/5 10:17
+ * @DESCRIPTION: Excel大数据量处理工具类
+ */
+@Slf4j
+public class EasyExcelUtil {
+
+ private static final String LOCAL_PATH = System.getProperty("user.dir") + File.separator + "temp";
+
+ /**
+ * 每隔1000条存储数据库,然后清理list,方便内存回收
+ */
+ private static final int BATCH_COUNT = 1000;
+
+ /**
+ * 大数据量分页导出
+ *
+ * @param pageParams
+ * @return
+ */
+ public static void bigDataExport(IEasyExcelExportServer excelExportServer, Class cls, Object pageParams, String fileName, long total, int thresholdValue, List ignoreColumns) {
+ File file = new File(LOCAL_PATH);
+ if (!file.exists()) {
+ file.mkdir();
+ }
+
+ long st = System.currentTimeMillis();
+ String filePath = LOCAL_PATH + File.separator + fileName + st + ".xlsx";
+ String path = null;
+ thresholdValue = Optional.ofNullable(thresholdValue).orElse(10000);
+ ExcelWriter excelWriter = null;
+ try {
+ long st1 = System.currentTimeMillis();
+ excelWriter = EasyExcel.write(filePath, cls).excludeColumnFieldNames(ignoreColumns).build();
+ WriteSheet writeSheet = EasyExcel.writerSheet(fileName).build();
+ // 避免内存溢出,大于1万条数据采用分页分批导出,底层workbook会随时往磁盘写入数据,内存中始终保持恒定数据量
+ for (int pageNum = 1; pageNum <= total / thresholdValue + (total % thresholdValue > 0 ? 1 : 0); pageNum++) {
+ List list = excelExportServer.selectForExcelExport(pageParams, pageNum);
+ excelWriter.write(list, writeSheet);
+ log.info(fileName + "分批导出当前次数{} " + pageNum + "次");
+ }
+ // 调用此方法后后续才能读取文件,并在线程结束后自动关闭流
+ excelWriter.finish();
+ log.info("导出excel耗时:{}ms", System.currentTimeMillis() - st1);
+ HttpServletResponse response = ServletUtils.getResponse();
+ response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ response.setCharacterEncoding("utf-8");
+ FileUtil.writeToStream(filePath, response.getOutputStream());
+ } catch (Exception e) {
+ log.error(fileName + "导出失败,失败原因:{}", e);
+ } finally {
+ // 删除临时文件
+ FileUtil.del(filePath);
+ }
+ }
+
+
+ /**
+ * @param multipartFile 导入文件
+ * @param excelImportServer 导入服务实现
+ * @param importValidHandler 导入校验器
+ * @param businessName 业务名称
+ * @param cls 实体类型
+ * @param onlyCodeFun 去重字段
+ * @param remarkFiledName 错误备注字段
+ * @return
+ * @throws IOException
+ */
+ public static void bigDataImport(MultipartFile multipartFile, IEasyExcelImportServer excelImportServer, AbstractEasyExcelImportValidHandler importValidHandler, String businessName, Class cls, Function onlyCodeFun, String remarkFiledName) throws IOException {
+
+ try {
+ EasyExcel.read(multipartFile.getInputStream(),
+ cls,
+ new ReadListener() {
+ // 缓存的数据
+ private List cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
+
+ // 初始化变量
+ Long st = System.currentTimeMillis();
+ File file = new File(LOCAL_PATH);
+
+ {
+ if (!file.exists()) {
+ file.mkdir();
+ }
+ }
+
+ final String fileName = businessName + "校验失败";
+ final String filePath = LOCAL_PATH + File.separator + fileName + st + ".xlsx";
+ final WriteSheet writeSheet = EasyExcel.writerSheet(fileName).build();
+ ExcelWriter excelWriter = EasyExcel.write(filePath, cls).build();
+ Map sortedMap = new TreeMap();
+ Integer successRowNum = 0;
+ Integer failRowNum = 0;
+ Integer currentNum = 0;
+
+ @SneakyThrows
+ @Override
+ public void invoke(T t, AnalysisContext analysisContext) {
+ try {
+ // 导入自定义校验
+ importValidHandler.valid(t);
+ } catch (CustomException e) {
+ // 获取当前行号
+ Integer rowIndex = analysisContext.readRowHolder().getRowIndex();
+ // 设置行号和导入校验提示的映射
+ sortedMap.put(rowIndex, e.getMessage());
+ }
+
+ cachedDataList.add(t);
+ currentNum = analysisContext.readRowHolder().getRowIndex();
+
+ // 达到BATCH_COUNT了,需要去存储一次数据库,防止数据几万条数据在内存,容易OOM
+ if (cachedDataList.size() >= BATCH_COUNT) {
+ ExcelIOResultVo excelIoResultVo = importExecutor(cachedDataList, onlyCodeFun, excelImportServer, sortedMap, remarkFiledName
+ , currentNum, successRowNum, failRowNum, excelWriter, writeSheet);
+
+ // 异步保存导入结果,通常保存到redis中
+ excelImportServer.syncSaveSchedule(excelIoResultVo);
+ }
+ }
+
+ @Override
+ public void doAfterAllAnalysed(AnalysisContext analysisContext) {
+
+ ExcelIOResultVo resultVo = importExecutor(cachedDataList, onlyCodeFun, excelImportServer, sortedMap, remarkFiledName
+ , currentNum, successRowNum, failRowNum, excelWriter, writeSheet);
+
+ // 关闭校验文件写出流
+ excelWriter.finish();
+
+ String uploadPath = null;
+ // 保存导入结果,通常保存到redis中
+ resultVo.setFilePath(uploadPath);
+ resultVo.setEnd(true);
+ excelImportServer.syncSaveSchedule(resultVo);
+ }
+ }).doReadAll();
+ } catch (Exception e) {
+ log.error(businessName + "解析异常{}", e);
+ throw e;
+ }
+ }
+
+
+ /**
+ * 校验、保存进度、持久化数据、导出错误文件
+ *
+ * @param cachedDataList
+ * @param onlyCodeFun
+ * @param excelImportServer
+ * @param sortedMap
+ * @param remarkFiledName
+ * @param successRowNum
+ * @param failRowNum
+ * @param excelWriter
+ * @param writeSheet
+ */
+ private static ExcelIOResultVo importExecutor(List cachedDataList, Function onlyCodeFun, IEasyExcelImportServer excelImportServer, Map sortedMap, String remarkFiledName, Integer currentNum, Integer successRowNum, Integer failRowNum, ExcelWriter excelWriter, WriteSheet writeSheet) {
+ // 二次校验,批量去重
+ List onlyCodes = cachedDataList.stream().map(onlyCodeFun).collect(Collectors.toList());
+ // 数据库中存在的数据
+ List onlyCodeList = Optional.ofNullable(excelImportServer.batchCheckByOnlyCode(onlyCodes)).orElse(new ArrayList());
+ Map groupMap = cachedDataList.stream().collect(Collectors
+ .groupingBy(onlyCodeFun, Collectors.collectingAndThen(Collectors.toList(), value -> value.get(0))));
+
+ List validErrorList = new ArrayList<>();
+ // 设置校验失败列提示
+ for (int i = 0; i < cachedDataList.size(); i++) {
+ if (sortedMap.containsKey(i + 1)) {
+ T var = cachedDataList.get(i);
+ ReflectUtil.setFieldValue(var, remarkFiledName, sortedMap.get(i + 1));
+ validErrorList.add(var);
+ }
+ }
+
+ // 设置重复数据列提示
+ for (R onlyCode : onlyCodeList) {
+ int index = onlyCodes.indexOf(onlyCode);
+ String value = sortedMap.get(index + 1);
+ // 校验不通过列数据不重复设置提示
+ if (value == null) {
+ T var = groupMap.get(onlyCode);
+ ReflectUtil.setFieldValue(var, remarkFiledName, "数据重复");
+ validErrorList.add(var);
+ }
+ }
+
+ // 剔除校验不通过的数据
+ cachedDataList.removeAll(validErrorList);
+
+ if (CollectionUtil.isNotEmpty(validErrorList)) {
+ failRowNum += validErrorList.size();
+ // 写出错误校验文件
+ excelWriter.write(validErrorList, writeSheet);
+ }
+
+ // 批量入库
+ if (CollectionUtil.isNotEmpty(cachedDataList)) {
+ excelImportServer.importBatchSave(cachedDataList);
+ }
+ successRowNum += cachedDataList.size();
+
+ // 异步保存导入结果,通常保存到redis中
+ ExcelIOResultVo excelIoResultVo = new ExcelIOResultVo();
+ excelIoResultVo.setTotal(currentNum);
+ excelIoResultVo.setSheduleNum(currentNum);
+ excelIoResultVo.setSuccessNum(successRowNum);
+ excelIoResultVo.setFailNum(failRowNum);
+ excelIoResultVo.setEnd(false);
+
+ // 处理完成清理 list
+ cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
+ return excelIoResultVo;
+ }
+
+ public static void write(HttpServletResponse response, Class> headClazz, List> data,
+ WriteHandler writeHandler, String sheetName, String fileName) throws Exception {
+
+ EasyExcel.write(getOutputStream(fileName, response), headClazz)
+ .excelType(ExcelTypeEnum.XLSX)
+ .registerWriteHandler(writeHandler)
+ .sheet(sheetName)
+ .doWrite(data);
+ }
+
+ private static OutputStream getOutputStream(String fileName, HttpServletResponse response) throws Exception {
+ try {
+ fileName = URLEncoder.encode(fileName, "UTF-8");
+ response.setContentType("application/vnd.ms-excel");
+ response.setCharacterEncoding("utf8");
+ response.setHeader("Content-Disposition", "attachment; filename=" + fileName + ".xlsx");
+ response.setHeader("Pragma", "public");
+ response.setHeader("Cache-Control", "no-store");
+ response.addHeader("Cache-Control", "max-age=0");
+ return response.getOutputStream();
+ } catch (IOException var3) {
+ resetResponse(response, var3);
+ return null;
+ }
+ }
+
+ private static void resetResponse(HttpServletResponse response, Exception e) {
+ response.reset();
+ response.setContentType("application/json");
+ response.setCharacterEncoding("utf-8");
+ throw new RuntimeException("导出Excel异常:", e);
+ }
+
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelHandlerAdapter.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelHandlerAdapter.java
new file mode 100644
index 0000000000..e236a7dbca
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelHandlerAdapter.java
@@ -0,0 +1,17 @@
+package cn.iocoder.yudao.framework.excel.core.util;
+
+/**
+ * Excel数据格式处理适配器
+ *
+ * @author 范劲松
+ */
+public interface ExcelHandlerAdapter {
+ /**
+ * 格式化
+ *
+ * @param value 单元格数据值
+ * @param args excel注解args参数组
+ * @return 处理后的值
+ */
+ Object format(Object value, String[] args);
+}
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelIOResultVo.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelIOResultVo.java
new file mode 100644
index 0000000000..85378b6f50
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelIOResultVo.java
@@ -0,0 +1,58 @@
+package cn.iocoder.yudao.framework.excel.core.util;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.io.Serializable;
+
+/**
+ * 导出结果
+ *
+ * @author LiLiang
+ * @since 1.0.0
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class ExcelIOResultVo implements Serializable {
+
+ private static final long serialVersionUID = 8552255701105369006L;
+
+ /**
+ * 文件地址
+ */
+ private String filePath;
+ /**
+ * 描述
+ */
+ private String description;
+ /**
+ * 全局唯一id
+ */
+ private String uid;
+
+ /**
+ * 总条数
+ */
+ private Integer total;
+ /**
+ * 进度数
+ */
+ private Integer sheduleNum;
+ /**
+ * 成功条数
+ */
+ private Integer successNum;
+ /**
+ * 失败条数
+ */
+ private Integer failNum;
+ /**
+ * 结束标志
+ */
+ private boolean end = false;
+
+}
\ No newline at end of file
diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtil.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtil.java
new file mode 100644
index 0000000000..968eaef1c3
--- /dev/null
+++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/util/ExcelUtil.java
@@ -0,0 +1,1164 @@
+package cn.iocoder.yudao.framework.excel.core.util;
+
+import cn.hutool.core.convert.Convert;
+import cn.iocoder.yudao.framework.common.reflect.ReflectUtils;
+import cn.iocoder.yudao.framework.common.util.date.DateUtils;
+import cn.iocoder.yudao.framework.common.util.file.FileTypeUtils;
+import cn.iocoder.yudao.framework.common.util.file.ImageUtils;
+import cn.iocoder.yudao.framework.common.util.string.StringUtils;
+import cn.iocoder.yudao.framework.excel.core.annotations.Excel;
+import cn.iocoder.yudao.framework.excel.core.annotations.Excel.ColumnType;
+import cn.iocoder.yudao.framework.excel.core.annotations.Excel.Type;
+import cn.iocoder.yudao.framework.excel.core.annotations.Excels;
+import jakarta.servlet.http.HttpServletResponse;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.RegExUtils;
+import org.apache.commons.lang3.reflect.FieldUtils;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.ss.util.CellRangeAddress;
+import org.apache.poi.ss.util.CellRangeAddressList;
+import org.apache.poi.util.IOUtils;
+import org.apache.poi.xssf.streaming.SXSSFWorkbook;
+import org.apache.poi.xssf.usermodel.XSSFClientAnchor;
+import org.apache.poi.xssf.usermodel.XSSFDataValidation;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.lang.reflect.Field;
+import java.lang.reflect.Method;
+import java.lang.reflect.ParameterizedType;
+import java.math.BigDecimal;
+import java.text.DecimalFormat;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Excel相关处理
+ *
+ * @author 范劲松
+ */
+public class ExcelUtil {
+ private static final Logger log = LoggerFactory.getLogger(ExcelUtil.class);
+
+ public static final String FORMULA_REGEX_STR = "=|-|\\+|@";
+
+ public static final String[] FORMULA_STR = {"=", "-", "+", "@"};
+
+ /**
+ * Excel sheet最大行数,默认65536
+ */
+ public static final int SHEET_SIZE = 65536;
+
+ /**
+ * 工作表名称
+ */
+ private String sheetName;
+
+ /**
+ * 导出类型(EXPORT:导出数据;IMPORT:导入模板)
+ */
+ private Type type;
+
+ /**
+ * 工作薄对象
+ */
+ private Workbook wb;
+
+ /**
+ * 工作表对象
+ */
+ private Sheet sheet;
+
+ /**
+ * 样式列表
+ */
+ private Map styles;
+
+ /**
+ * 导入导出数据列表
+ */
+ private List list;
+
+ /**
+ * 注解列表
+ */
+ private List