set = new LinkedHashSet<>();
+ set.add(templateFileName);
+ // 模版 / 模板 常见混用
+ if (templateFileName.contains("模版")) {
+ set.add(templateFileName.replace("模版", "模板"));
+ }
+ if (templateFileName.contains("模板")) {
+ set.add(templateFileName.replace("模板", "模版"));
+ }
+ if (templateFileName.startsWith("jpg/")) {
+ String name = templateFileName.substring("jpg/".length());
+ set.add("EXCEL模板/" + name);
+ if (name.contains("模版")) {
+ set.add("EXCEL模板/" + name.replace("模版", "模板"));
+ }
+ }
+ return new ArrayList<>(set);
+ }
+
+ private static byte[] readClasspathBytes(ClassLoader loader, String resourcePath) {
+ // 不用 ClassPathResource.exists():部分环境下对依赖 jar / 中文路径会误判不存在
+ try (InputStream in = loader.getResourceAsStream(resourcePath)) {
+ if (in == null) {
+ return null;
+ }
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buf = new byte[8192];
+ int n;
+ while ((n = in.read(buf)) != -1) {
+ out.write(buf, 0, n);
+ }
+ return out.toByteArray();
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
+ private static byte[] validateXlsxBytes(byte[] bytes, String templateFileName) throws IOException {
+ if (bytes == null || bytes.length < 4 || bytes[0] != 0x50 || bytes[1] != 0x4B) {
+ throw new IOException("Excel模板不是有效的xlsx文件: " + templateFileName
+ + ", size=" + (bytes == null ? -1 : bytes.length));
+ }
+ return bytes;
}
private static String sanitizeSheetName(String name) {
diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java
index 4133e21..e38de44 100644
--- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java
+++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/BeanCopyUtils.java
@@ -1,25 +1,21 @@
package com.ruoyi.common.utils;
+import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.collection.CollUtil;
-import cn.hutool.core.lang.SimpleCache;
import cn.hutool.core.map.MapUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.ReflectUtil;
-import cn.hutool.core.util.StrUtil;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
-import org.springframework.cglib.beans.BeanCopier;
-import org.springframework.cglib.beans.BeanMap;
-import org.springframework.cglib.core.Converter;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
- * bean深拷贝工具(基于 cglib 性能优异)
+ * bean深拷贝工具
*
- * 重点 cglib 不支持 拷贝到链式对象
+ * 重点 不支持 拷贝到链式对象
* 例如: 源对象 拷贝到 目标(链式对象)
* 请区分好`浅拷贝`和`深拷贝`再做使用
*
@@ -60,8 +56,7 @@ public class BeanCopyUtils {
if (ObjectUtil.isNull(desc)) {
return null;
}
- BeanCopier beanCopier = BeanCopierCache.INSTANCE.get(source.getClass(), desc.getClass(), null);
- beanCopier.copy(source, desc, null);
+ BeanUtil.copyProperties(source, desc);
return desc;
}
@@ -92,12 +87,11 @@ public class BeanCopyUtils {
* @param bean 数据来源实体
* @return map对象
*/
- @SuppressWarnings("unchecked")
public static Map copyToMap(T bean) {
if (ObjectUtil.isNull(bean)) {
return null;
}
- return BeanMap.create(bean);
+ return BeanUtil.beanToMap(bean);
}
/**
@@ -132,7 +126,7 @@ public class BeanCopyUtils {
if (ObjectUtil.isNull(bean)) {
return null;
}
- BeanMap.create(bean).putAll(map);
+ BeanUtil.fillBeanWithMap(map, bean, false);
return bean;
}
@@ -155,50 +149,4 @@ public class BeanCopyUtils {
return copyMap;
}
- /**
- * BeanCopier属性缓存
- * 缓存用于防止多次反射造成的性能问题
- *
- * @author Looly
- * @since 5.4.1
- */
- public enum BeanCopierCache {
- /**
- * BeanCopier属性缓存单例
- */
- INSTANCE;
-
- private final SimpleCache cache = new SimpleCache<>();
-
- /**
- * 获得类与转换器生成的key在{@link BeanCopier}的Map中对应的元素
- *
- * @param srcClass 源Bean的类
- * @param targetClass 目标Bean的类
- * @param converter 转换器
- * @return Map中对应的BeanCopier
- */
- public BeanCopier get(Class> srcClass, Class> targetClass, Converter converter) {
- final String key = genKey(srcClass, targetClass, converter);
- return cache.get(key, () -> BeanCopier.create(srcClass, targetClass, converter != null));
- }
-
- /**
- * 获得类与转换器生成的key
- *
- * @param srcClass 源Bean的类
- * @param targetClass 目标Bean的类
- * @param converter 转换器
- * @return 属性名和Map映射的key
- */
- private String genKey(Class> srcClass, Class> targetClass, Converter converter) {
- final StringBuilder key = StrUtil.builder()
- .append(srcClass.getName()).append('#').append(targetClass.getName());
- if (null != converter) {
- key.append('#').append(converter.getClass().getName());
- }
- return key.toString();
- }
- }
-
}
diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java
index fc68914..0bcb5ce 100644
--- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java
+++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/FtpUtil.java
@@ -16,6 +16,9 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Predicate;
@Slf4j
public final class FtpUtil {
@@ -138,6 +141,86 @@ public final class FtpUtil {
remoteDir, localDir, fileNameFilter);
}
+ /**
+ * 在远程目录(含全部子目录)中查找并下载第一个匹配的文件。
+ * 目录不存在时返回失败(与整目录递归下载「视为空」不同)。
+ */
+ public static R downloadMatchingFileRecursive(DrawingFtpProperties.Endpoint ftp, String remoteDir,
+ String localDir, Predicate fileNameMatcher) {
+ if (fileNameMatcher == null) {
+ return R.fail("文件匹配规则不能为空");
+ }
+ FTPClient ftpClient = null;
+ try {
+ ftpClient = getFTPClient(ftp.getHost(), ftp.getUsername(), ftp.getPassword(), ftp.getPort());
+ if (ftpClient == null || !ftpClient.isConnected()) {
+ return R.fail("FTP连接失败 " + ftp.getHost() + ":" + ftp.getPort());
+ }
+ String dir = normalizeRemotePath(remoteDir);
+ log.info("FTP 递归匹配下载 {}:{} dir={}", ftp.getHost(), ftp.getPort(), dir);
+ if (!changeWorkingDirectoryWithEncoding(ftpClient, dir)) {
+ return R.fail("远程目录不存在: " + dir + " (reply=" + ftpClient.getReplyString().trim() + ")");
+ }
+ File localDirectory = new File(localDir);
+ if (!localDirectory.exists() && !localDirectory.mkdirs()) {
+ return R.fail("无法创建本地目录: " + localDir);
+ }
+ String saved = findAndDownloadMatching(ftpClient, dir, localDirectory, fileNameMatcher);
+ if (saved == null) {
+ return R.fail("远程目录中未找到匹配文件 (dir=" + dir + ")");
+ }
+ return R.ok(saved);
+ } catch (Exception e) {
+ log.error("FTP递归匹配下载异常 dir={}", remoteDir, e);
+ return R.fail("下载失败: " + e.getMessage());
+ } finally {
+ disconnectQuietly(ftpClient);
+ }
+ }
+
+ private static String findAndDownloadMatching(FTPClient ftp, String remoteDir, File localDir,
+ Predicate fileNameMatcher) throws IOException {
+ if (!changeWorkingDirectoryWithEncoding(ftp, remoteDir)) {
+ return null;
+ }
+ FTPFile[] files = ftp.listFiles();
+ if (files == null || files.length == 0) {
+ return null;
+ }
+ List subDirs = new ArrayList<>();
+ for (FTPFile file : files) {
+ String name = file.getName();
+ if (name == null || ".".equals(name) || "..".equals(name)) {
+ continue;
+ }
+ if (file.isDirectory()) {
+ subDirs.add(name);
+ continue;
+ }
+ if (!fileNameMatcher.test(name)) {
+ continue;
+ }
+ File localFile = new File(localDir, name);
+ try (OutputStream os = Files.newOutputStream(localFile.toPath())) {
+ if (retrieveFileWithEncoding(ftp, name, os)) {
+ log.info("FTP匹配下载成功: {}/{} -> {}", remoteDir, name, localFile.getAbsolutePath());
+ return name;
+ }
+ Files.deleteIfExists(localFile.toPath());
+ log.warn("FTP匹配下载失败: {}/{} reply={}", remoteDir, name, ftp.getReplyString());
+ return null;
+ }
+ }
+ for (String name : subDirs) {
+ String childRemote = normalizeRemotePath(remoteDir + "/" + name);
+ String saved = findAndDownloadMatching(ftp, childRemote, localDir, fileNameMatcher);
+ if (saved != null) {
+ return saved;
+ }
+ }
+ return null;
+ }
+
/**
* 出图 Excel 远程目录:basePath(如 F:) + 年份 + 生产令号,与 SMB 路径 F:\2026\令号 一致。
*/
@@ -258,6 +341,9 @@ public final class FtpUtil {
FTPFile[] files = ftpClient.listFiles();
if (files == null || files.length == 0) {
+ if (fileNameFilter != null && !fileNameFilter.isEmpty()) {
+ return R.fail("远程目录为空,未找到文件: " + fileNameFilter + " (dir=" + remoteDir + ")");
+ }
return R.ok("远程目录为空,无需下载");
}
@@ -270,7 +356,8 @@ public final class FtpUtil {
if (fileName.startsWith(".") || file.isDirectory()) {
continue;
}
- if (fileNameFilter != null && !fileNameFilter.isEmpty() && !fileName.contains(fileNameFilter)) {
+ if (fileNameFilter != null && !fileNameFilter.isEmpty()
+ && !fileNameMatchesFilter(fileName, fileNameFilter)) {
continue;
}
totalCount++;
@@ -312,6 +399,191 @@ public final class FtpUtil {
disconnectQuietly(ftpClient);
}
+ /**
+ * 递归下载远程目录(含全部子目录)到本地。远程目录不存在时返回成功(视为空)。
+ */
+ public static R downloadFtpDirectoryRecursive(DrawingFtpProperties.Endpoint ftp, String remoteDir,
+ String localDir) {
+ FTPClient ftpClient = null;
+ try {
+ ftpClient = getFTPClient(ftp.getHost(), ftp.getUsername(), ftp.getPassword(), ftp.getPort());
+ if (ftpClient == null || !ftpClient.isConnected()) {
+ return R.fail("FTP连接失败 " + ftp.getHost() + ":" + ftp.getPort());
+ }
+ return downloadFtpDirectoryRecursive(ftpClient, remoteDir, localDir);
+ } catch (Exception e) {
+ log.error("FTP递归下载异常 dir={}", remoteDir, e);
+ return R.fail("下载失败: " + e.getMessage());
+ } finally {
+ disconnectQuietly(ftpClient);
+ }
+ }
+
+ public static R downloadFtpDirectoryRecursive(FTPClient ftpClient, String remoteDir, String localDir) {
+ if (ftpClient == null || !ftpClient.isConnected()) {
+ return R.fail("FTP连接不可用");
+ }
+ String dir = normalizeRemotePath(remoteDir);
+ try {
+ if (!changeWorkingDirectoryWithEncoding(ftpClient, dir)) {
+ log.info("FTP 远程目录不存在,跳过下载: {}", dir);
+ return R.ok("远程目录不存在,无需下载: " + dir);
+ }
+ int count = downloadTree(ftpClient, dir, localDir);
+ return R.ok("递归下载完成,文件数=" + count + " dir=" + dir);
+ } catch (Exception e) {
+ log.error("FTP递归下载异常 dir={}", dir, e);
+ return R.fail("下载失败: " + e.getMessage());
+ }
+ }
+
+ /**
+ * 删除远程目录内全部文件与子目录,保留该目录本身。目录不存在视为已清理。
+ */
+ public static R deleteRemoteDirectoryContents(DrawingFtpProperties.Endpoint ftp, String remoteDir) {
+ FTPClient ftpClient = null;
+ try {
+ ftpClient = getFTPClient(ftp.getHost(), ftp.getUsername(), ftp.getPassword(), ftp.getPort());
+ if (ftpClient == null || !ftpClient.isConnected()) {
+ return R.fail("FTP连接失败 " + ftp.getHost() + ":" + ftp.getPort());
+ }
+ return deleteRemoteDirectoryContents(ftpClient, remoteDir);
+ } catch (Exception e) {
+ log.error("FTP清理远程目录异常 dir={}", remoteDir, e);
+ return R.fail("清理远程目录失败: " + e.getMessage());
+ } finally {
+ disconnectQuietly(ftpClient);
+ }
+ }
+
+ public static R deleteRemoteDirectoryContents(FTPClient ftpClient, String remoteDir) {
+ if (ftpClient == null || !ftpClient.isConnected()) {
+ return R.fail("FTP连接不可用");
+ }
+ String dir = normalizeRemotePath(remoteDir);
+ try {
+ if (!changeWorkingDirectoryWithEncoding(ftpClient, dir)) {
+ log.info("FTP 远程目录不存在,跳过删除: {}", dir);
+ return R.ok("远程目录不存在,无需删除: " + dir);
+ }
+ int count = deleteListedContents(ftpClient, dir);
+ return R.ok("已清理远程出图目录,删除 " + count + " 项: " + dir);
+ } catch (Exception e) {
+ log.error("FTP清理远程目录异常 dir={}", dir, e);
+ return R.fail("清理远程目录失败: " + e.getMessage());
+ }
+ }
+
+ private static int downloadTree(FTPClient ftp, String remoteDir, String localDir) throws IOException {
+ if (!changeWorkingDirectoryWithEncoding(ftp, remoteDir)) {
+ throw new IOException("远程目录不存在: " + remoteDir);
+ }
+ File local = new File(localDir);
+ if (!local.exists() && !local.mkdirs()) {
+ throw new IOException("无法创建本地目录: " + localDir);
+ }
+ FTPFile[] files = ftp.listFiles();
+ if (files == null || files.length == 0) {
+ return 0;
+ }
+ int count = 0;
+ List subDirs = new ArrayList<>();
+ for (FTPFile file : files) {
+ String name = file.getName();
+ if (name == null || ".".equals(name) || "..".equals(name)) {
+ continue;
+ }
+ if (file.isDirectory()) {
+ subDirs.add(name);
+ continue;
+ }
+ File localFile = new File(local, name);
+ try (OutputStream os = Files.newOutputStream(localFile.toPath())) {
+ if (retrieveFileWithEncoding(ftp, name, os)) {
+ count++;
+ log.info("FTP递归下载成功: {}/{}", remoteDir, name);
+ } else {
+ Files.deleteIfExists(localFile.toPath());
+ log.warn("FTP递归下载失败: {}/{} reply={}", remoteDir, name, ftp.getReplyString());
+ }
+ }
+ }
+ for (String name : subDirs) {
+ String childRemote = normalizeRemotePath(remoteDir + "/" + name);
+ String childLocal = new File(local, name).getAbsolutePath();
+ count += downloadTree(ftp, childRemote, childLocal);
+ }
+ return count;
+ }
+
+ private static int deleteListedContents(FTPClient ftp, String absDir) throws IOException {
+ if (!changeWorkingDirectoryWithEncoding(ftp, absDir)) {
+ return 0;
+ }
+ FTPFile[] files = ftp.listFiles();
+ if (files == null || files.length == 0) {
+ return 0;
+ }
+ int count = 0;
+ List subDirs = new ArrayList<>();
+ List fileNames = new ArrayList<>();
+ for (FTPFile file : files) {
+ String name = file.getName();
+ if (name == null || ".".equals(name) || "..".equals(name)) {
+ continue;
+ }
+ if (file.isDirectory()) {
+ subDirs.add(name);
+ } else {
+ fileNames.add(name);
+ }
+ }
+ for (String name : fileNames) {
+ if (deleteFileWithEncoding(ftp, name)) {
+ count++;
+ log.info("FTP已删除文件: {}/{}", absDir, name);
+ } else {
+ log.warn("FTP删除文件失败: {}/{} reply={}", absDir, name, ftp.getReplyString());
+ }
+ }
+ for (String name : subDirs) {
+ String child = normalizeRemotePath(absDir + "/" + name);
+ count += deleteListedContents(ftp, child);
+ changeWorkingDirectoryWithEncoding(ftp, absDir);
+ if (removeDirectoryWithEncoding(ftp, name)) {
+ count++;
+ log.info("FTP已删除目录: {}", child);
+ } else {
+ log.warn("FTP删除目录失败: {} reply={}", child, ftp.getReplyString());
+ }
+ }
+ return count;
+ }
+
+ private static boolean deleteFileWithEncoding(FTPClient ftpClient, String fileName) throws IOException {
+ if (ftpClient.deleteFile(fileName)) {
+ return true;
+ }
+ String gbkIso = new String(fileName.getBytes("GBK"), SERVER_CHARSET);
+ if (ftpClient.deleteFile(gbkIso)) {
+ return true;
+ }
+ String utf8Iso = new String(fileName.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
+ return ftpClient.deleteFile(utf8Iso);
+ }
+
+ private static boolean removeDirectoryWithEncoding(FTPClient ftpClient, String dir) throws IOException {
+ if (ftpClient.removeDirectory(dir)) {
+ return true;
+ }
+ String gbkIso = new String(dir.getBytes("GBK"), SERVER_CHARSET);
+ if (ftpClient.removeDirectory(gbkIso)) {
+ return true;
+ }
+ String utf8Iso = new String(dir.getBytes(StandardCharsets.UTF_8), StandardCharsets.ISO_8859_1);
+ return ftpClient.removeDirectory(utf8Iso);
+ }
+
private static String combineRemotePath(String basePath, String filePath) {
String base = basePath == null ? "" : basePath.trim();
String rel = filePath == null ? "" : filePath.trim();
@@ -417,6 +689,16 @@ public final class FtpUtil {
return ftpClient.retrieveFile(utf8Iso, os);
}
+ /**
+ * 目录扫描按文件名精确匹配。不能用 contains,否则「图纸数量统计表.xlsx」会命中其它令号的「令号图纸数量统计表.xlsx」。
+ */
+ private static boolean fileNameMatchesFilter(String fileName, String fileNameFilter) {
+ if (fileName == null || fileNameFilter == null) {
+ return false;
+ }
+ return fileName.equalsIgnoreCase(fileNameFilter);
+ }
+
private static boolean changeWorkingDirectoryWithEncoding(FTPClient ftpClient, String remoteDir)
throws IOException {
if (ftpClient.changeWorkingDirectory(remoteDir)) {
diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java
index 8e0c331..2a2cfee 100644
--- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java
+++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/KingdeeRouteDepartmentHelper.java
@@ -16,7 +16,7 @@ import java.util.stream.Collectors;
* WC000002 机二工段 → 006 机加车间
* WC000004 装一工段 → 007 装配车间
* WC000005 装二工段 → 007 装配车间
- * WC000006 电钳工段 → 007 装配车间
+ * WC000006 电钳工段 → 023 电钳车间
* WC000007 下料工段 → 006 机加车间
* WC000008 委外中心 → 006 机加车间
* WC000010 下料工段 → 018 铆焊车间
@@ -31,16 +31,21 @@ public final class KingdeeRouteDepartmentHelper {
public static final String DEPT_ASSEMBLY = "007";
/** 铆焊车间 */
public static final String DEPT_RIVET_WELD = "018";
+ /** 电钳车间 */
+ public static final String DEPT_ELECTRICIAN = "023";
/** 006 机加车间 */
private static final Set MACHINE_WORKSHOP = setOf(
"机一工段", "机二工段", "机加委外");
/** 007 装配车间 */
private static final Set ASSEMBLY_WORKSHOP = setOf(
- "装一工段", "装二工段", "电钳工段","装配委外");
+ "装一工段", "装二工段", "装配委外");
/** 018 铆焊车间 */
private static final Set RIVET_WELD_WORKSHOP = setOf(
- "下料工段", "焊接工段","铆焊委外");
+ "下料工段", "焊接工段", "铆焊委外");
+ /** 023 电钳车间 */
+ private static final Set ELECTRICIAN_WORKSHOP = setOf(
+ "电钳工段");
public static final List ALLOWED_WORK_CENTERS;
static {
@@ -48,6 +53,7 @@ public final class KingdeeRouteDepartmentHelper {
all.addAll(MACHINE_WORKSHOP);
all.addAll(ASSEMBLY_WORKSHOP);
all.addAll(RIVET_WELD_WORKSHOP);
+ all.addAll(ELECTRICIAN_WORKSHOP);
ALLOWED_WORK_CENTERS = Collections.unmodifiableList(Arrays.asList(all.toArray(new String[0])));
}
@@ -72,6 +78,9 @@ public final class KingdeeRouteDepartmentHelper {
if (RIVET_WELD_WORKSHOP.contains(wc)) {
return DEPT_RIVET_WELD;
}
+ if (ELECTRICIAN_WORKSHOP.contains(wc)) {
+ return DEPT_ELECTRICIAN;
+ }
throw new ServiceException("不支持的工作中心「" + wc + "」,仅允许:"
+ String.join("、", ALLOWED_WORK_CENTERS));
}
@@ -95,6 +104,9 @@ public final class KingdeeRouteDepartmentHelper {
if (DEPT_RIVET_WELD.equals(deptFNumber)) {
return "铆焊车间";
}
+ if (DEPT_ELECTRICIAN.equals(deptFNumber)) {
+ return "电钳车间";
+ }
return deptFNumber != null ? deptFNumber : "";
}
diff --git a/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java b/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java
index ed7e6da..6a99857 100644
--- a/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java
+++ b/ruoyi-common/src/main/java/com/ruoyi/common/utils/file/PDFDocHelper.java
@@ -8,10 +8,12 @@ import com.itextpdf.text.pdf.PdfStamper;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.multipdf.PDFMergerUtility;
+import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.Files;
+import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.Map;
@@ -132,7 +134,7 @@ public class PDFDocHelper {
}
/**
- * 在 PDF 第 1 页指定位置叠加文字(无图章),经临时文件替换原文件。
+ * 在 PDF 第 1 页指定位置叠加文字(无图章),就地覆盖原文件。
*/
public static void stampTextFirstPage(String pdfPath, List