package com.yunhai.userbase.utils; import com.yunhai.platform.utils.exception.DException; import cn.hutool.core.io.IoUtil; import cn.hutool.core.util.IdUtil; import org.springframework.web.multipart.MultipartFile; import javax.servlet.ServletOutputStream; import javax.servlet.http.HttpServletResponse; import java.io.*; import java.net.HttpURLConnection; import java.net.URL; import java.text.DecimalFormat; import java.util.Arrays; import java.util.HashSet; import java.util.Set; /** * @author min * @description File工具类,扩展 hutool 工具包 * @date 2019-07-05 */ public class FileUtil { /** * 定义GB的计算常量(改为long避免int溢出) */ private static final long GB = 1024L * 1024 * 1024; /** * 定义MB的计算常量 */ private static final long MB = 1024L * 1024; /** * 定义KB的计算常量 */ private static final long KB = 1024L; /** * 格式化小数 */ private static final DecimalFormat DF = new DecimalFormat("0.00"); /** * 上传文件存储根目录(由 FileStorageConfig 在启动时注入)。
*/ private static String BASE_STORE_PATH; /** * 上传文件对外访问的 Web 根地址(由 FileStorageConfig 在启动时注入)。
*/ private static String BASE_WEB_PATH; /** * 危险后缀黑名单:命中则禁止上传,防止恶意文件被执行/访问。
*/ private static final Set DANGEROUS_EXT = new HashSet<>(Arrays.asList( "jsp", "jspx", "html", "htm", "js", "exe", "sh", "bat", "php", "asp", "aspx", "jar", "war", "class", "htaccess" )); /** * 由 Spring 启动时注入存储根目录与 Web 访问前缀。
* * @param storePath 磁盘存储根目录 * @param webPath 对外访问的 Web 根地址 */ public static void init(String storePath, String webPath) { BASE_STORE_PATH = storePath; BASE_WEB_PATH = webPath; } /** * 校验文件后缀是否在危险黑名单中。
* * @param ext 文件后缀(可带或不带点) * @throws DException 命中黑名单时抛出 */ private static void validateExt(String ext) { if (ext == null || ext.isEmpty()) { return; } String lower = ext.toLowerCase().replaceFirst("^\\.", ""); if (DANGEROUS_EXT.contains(lower)) { throw new DException("不支持的文件类型: " + ext); } } /** * 净化文件名,防止路径穿越(../ 等)。
* * @param name 原始文件名 * @return 净化后的安全文件名 */ private static String sanitizeFileName(String name) { if (name == null || name.trim().isEmpty()) { return "file"; } return name.replaceAll("[\\\\/]", "_") .replaceAll("\\.{2,}", "_") .replaceAll("\\s+", "_"); } /** * 拼接完整可访问的 Web URL(根地址 + 子目录 + 文件名)。
* * @param subDir 子目录(如 image/file/icon) * @param fileName 文件名 * @return 完整访问 URL */ private static String buildUrl(String subDir, String fileName) { String web = (BASE_WEB_PATH == null) ? "" : BASE_WEB_PATH; web = web.endsWith("/") ? web : web + "/"; return web + subDir + "/" + fileName; } /** * 校验存储配置已注入。
* * @throws DException 未注入时抛出 */ private static void ensureStoreBase() { if (BASE_STORE_PATH == null || BASE_WEB_PATH == null) { throw new DException("文件存储路径未初始化,请检查 FILE_PATH / FILE_WEB_PATH 配置"); } } /** * MultipartFile转File(临时文件) * @param multipartFile 上传文件 * @return 临时文件对象,失败返回null */ public static File toFile(MultipartFile multipartFile) { if (multipartFile == null || multipartFile.isEmpty()) { return null; } // 获取文件后缀 String ext = getExtensionName(multipartFile.getOriginalFilename()); String prefix = "." + ext; File file = null; try { // 用uuid作为文件名,防止生成的临时文件重复 file = File.createTempFile(IdUtil.simpleUUID(), prefix); multipartFile.transferTo(file); } catch (IOException e) { e.printStackTrace(); } return file; } /** * 删除文件/目录 * @param files 待删除文件数组 */ public static void deleteFile(File... files) { if (files == null || files.length == 0) { return; } for (File file : files) { if (file != null && file.exists()) { cn.hutool.core.io.FileUtil.del(file); } } } /** * 获取文件扩展名(不带点) * @param filename 文件名 * @return 扩展名,无后缀返回原文件名 */ public static String getExtensionName(String filename) { return cn.hutool.core.io.FileUtil.extName(filename); } /** * 获取不带扩展名的文件名 * @param filename 文件名 * @return 主文件名 */ public static String getFileNameNoEx(String filename) { return cn.hutool.core.io.FileUtil.mainName(filename); } /** * 文件大小转换(修复int溢出问题) * @param size 文件字节数 * @return 格式化后的大小字符串 */ public static String getSize(int size) { long sizeLong = size; String resultSize; if (sizeLong / GB >= 1) { resultSize = DF.format(sizeLong / (double) GB) + "GB "; } else if (sizeLong / MB >= 1) { resultSize = DF.format(sizeLong / (double) MB) + "MB "; } else if (sizeLong / KB >= 1) { resultSize = DF.format(sizeLong / (double) KB) + "KB "; } else { resultSize = sizeLong + "B "; } return resultSize; } /** * 字节数组上传图片(固定.png后缀,存 image 子目录)。
* * @param imgData 图片字节数组 * @return 完整可访问的 Web URL */ public static String uploadImg(byte[] imgData) { return uploadBytes(imgData, "image", ".png"); } /** * MultipartFile上传图片(按原后缀,存 image 子目录)。
* * @param file 上传文件 * @return 完整可访问的 Web URL */ public static String uploadImg(MultipartFile file) { return uploadMultipart(file, "image"); } /** * 字节数组上传文件(固定.png后缀,存 file 子目录)。
* * @param imgData 文件字节数组 * @return 完整可访问的 Web URL */ public static String uploadFile(byte[] imgData) { return uploadBytes(imgData, "file", ".png"); } /** * MultipartFile上传文件(按原后缀,存 file 子目录)。
* * @param file 上传文件 * @return 完整可访问的 Web URL */ public static String uploadFile(MultipartFile file) { return uploadMultipart(file, "file"); } /** * 通用上传:按原后缀保存到指定子目录,返回完整可访问的 Web URL。
* * @param file 上传文件 * @param subDir 子目录(如 image/file/icon/media/word/html) * @return 完整可访问的 Web URL */ public static String upload(MultipartFile file, String subDir) { return uploadMultipart(file, subDir); } /** * URL文件代理中转下载 * @param response 响应对象 * @param address 远程文件地址 * @param contentType 响应Content-Type,传null自动识别 * @param fileName 下载文件名,传null取远程响应头 * @throws IOException 网络/IO异常 */ public static void proxyUrlFile(HttpServletResponse response, String address, String contentType, String fileName) throws IOException { // 原有https转http逻辑保留 if (address != null && address.toLowerCase().startsWith("https:")) { address = address.replaceFirst("(?i)^https:", "http:") .replaceAll(":443(/|$)", ":80$1"); } HttpURLConnection httpURLConnection = null; InputStream inputStream = null; ServletOutputStream outputStream = null; try { URL url = new URL(address); httpURLConnection = (HttpURLConnection) url.openConnection(); httpURLConnection.setConnectTimeout(5000); httpURLConnection.setReadTimeout(30000); httpURLConnection.connect(); inputStream = httpURLConnection.getInputStream(); outputStream = response.getOutputStream(); // 设置Content-Type if (contentType != null) { response.setContentType(contentType); } else { response.setContentType("multipart/form-data"); } // 设置下载文件名 if (fileName != null) { response.setHeader("Content-disposition", "attachment; filename=\"" + new String(fileName.getBytes("utf-8"), "ISO8859-1") + "\""); } else { String headerFileName = ""; String disposition = httpURLConnection.getHeaderField("Content-Disposition"); if (disposition != null) { headerFileName = new String(disposition.getBytes("ISO-8859-1"), "UTF-8"); } if (headerFileName.contains("filename")) { headerFileName = headerFileName.substring(headerFileName.lastIndexOf("filename") + "filename".length()); // 去除可能的=号和引号 headerFileName = headerFileName.replaceFirst("=", "").replace("\"", "").trim(); } response.setHeader("Content-disposition", "attachment; filename=" + headerFileName); } // 流式拷贝,8KB缓冲区提升性能 IoUtil.copy(inputStream, outputStream, IoUtil.DEFAULT_BUFFER_SIZE); } catch (Exception e) { throw e; } finally { IoUtil.close(inputStream); if (outputStream != null) { outputStream.flush(); IoUtil.close(outputStream); } if (httpURLConnection != null) { httpURLConnection.disconnect(); } } } /** * 保存上传文件到指定完整路径 * @param mf 上传文件 * @param path 完整保存路径(含文件名) */ public static void saveResource(MultipartFile mf, String path) { if (mf == null || path == null || path.trim().length() == 0) { throw new DException(DicUtil.ERROR_MSG_IMPORT_WRONG); } // 净化文件名,防止路径穿越(如 ../../evil) File target = new File(path); String safeName = sanitizeFileName(target.getName()); File safeFile = new File(target.getParentFile(), safeName); // 自动创建父目录 cn.hutool.core.io.FileUtil.mkParentDirs(safeFile.getPath()); try (InputStream is = mf.getInputStream(); FileOutputStream fos = new FileOutputStream(safeFile)) { IoUtil.copy(is, fos); } catch (Exception e) { throw new DException(DicUtil.ERROR_MSG_IMPORT_WRONG); } } // ==================== 私有通用方法,消除重复代码 ==================== /** * 字节数组通用上传(流式写入,返回完整 Web URL)。
* * @param data 字节数组 * @param subDir 子目录 * @param ext 固定后缀(带点) * @return 完整可访问的 Web URL */ private static String uploadBytes(byte[] data, String subDir, String ext) { if (data == null || data.length == 0 || subDir == null || subDir.trim().length() == 0) { throw new DException("上传内容为空"); } // 后缀黑名单校验(固定后缀也走同一规则) validateExt(ext); ensureStoreBase(); try { // 自动创建多级目录 String dirPath = BASE_STORE_PATH + File.separator + subDir; cn.hutool.core.io.FileUtil.mkdir(dirPath); String fileName = IdUtil.fastSimpleUUID() + ext; File targetFile = new File(dirPath, fileName); // Hutool写入,自动关流 cn.hutool.core.io.FileUtil.writeBytes(data, targetFile); return buildUrl(subDir, fileName); } catch (DException e) { throw e; } catch (Exception e) { throw new DException("文件上传失败: " + e.getMessage()); } } /** * MultipartFile通用上传(流式写入,避免大文件OOM,返回完整 Web URL)。
* * @param file 上传文件 * @param subDir 子目录 * @return 完整可访问的 Web URL */ private static String uploadMultipart(MultipartFile file, String subDir) { if (file == null || file.isEmpty() || subDir == null || subDir.trim().length() == 0) { throw new DException("上传文件不能为空"); } // 提取后缀 String originalFilename = file.getOriginalFilename(); String ext = cn.hutool.core.io.FileUtil.extName(originalFilename); ext = (ext == null || ext.trim().length() == 0) ? "" : "." + ext.toLowerCase(); // 后缀黑名单校验 validateExt(ext); ensureStoreBase(); try { String dirPath = BASE_STORE_PATH + File.separator + subDir; cn.hutool.core.io.FileUtil.mkdir(dirPath); String fileName = IdUtil.fastSimpleUUID() + ext; File targetFile = new File(dirPath, fileName); // 流式写入,不加载全文件到内存 cn.hutool.core.io.FileUtil.writeFromStream(file.getInputStream(), targetFile); return buildUrl(subDir, fileName); } catch (DException e) { throw e; } catch (Exception e) { throw new DException("文件上传失败: " + e.getMessage()); } } }