feat: 增强文件上传功能支持多种存储服务

- 在系统模块中集成OSS存储服务支持
- 优化FileController支持本地存储、MinIO和阿里云OSS
- 增强SysFileController和SysPictureController的文件删除功能
- 支持存储服务降级机制确保上传可用性
- 优化文件访问路径生成逻辑
- 完善错误处理和日志记录机制
This commit is contained in:
Leo 2025-07-09 01:17:40 +08:00
parent 77d2ad543b
commit 33b341e9e7
4 changed files with 287 additions and 34 deletions

View File

@ -45,6 +45,12 @@
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- OSS对象存储模块 -->
<dependency>
<groupId>org.leocoder.thin</groupId>
<artifactId>coder-common-thin-oss</artifactId>
<version>${revision}</version>
</dependency>
</dependencies>
</project>

View File

@ -3,22 +3,24 @@ package org.leocoder.thin.system.controller.file;
import cn.dev33.satoken.annotation.SaIgnore;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import com.alibaba.excel.util.StringUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.leocoder.thin.common.constants.CoderConstants;
import org.leocoder.thin.common.exception.BusinessException;
import org.leocoder.thin.common.satoken.CoderLoginUtil;
import org.leocoder.thin.common.utils.file.FileTypeUtil;
import org.leocoder.thin.common.utils.file.UploadUtil;
import org.leocoder.thin.common.utils.ip.IpUtil;
import org.leocoder.thin.common.utils.ip.ServletUtil;
import org.leocoder.thin.domain.pojo.system.SysFile;
import org.leocoder.thin.domain.pojo.system.SysPicture;
import org.leocoder.thin.oss.service.StorageService;
import org.leocoder.thin.oss.service.StorageServiceFactory;
import org.leocoder.thin.oss.utils.OssUtil;
import org.leocoder.thin.system.service.file.SysFileService;
import org.leocoder.thin.system.service.picture.SysPictureService;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@ -41,6 +43,9 @@ public class FileController {
@Value("${coder.filePath}")
private String basePath;
@Value("${coder.storage.type:local}")
private String storageType;
// 允许的图片文件扩展名
private static final List<String> ALLOWED_IMAGE_EXTENSIONS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "bmp", "webp", "svg"
@ -62,10 +67,9 @@ public class FileController {
private static final long MAX_FILE_SIZE_10MB = 10485760L;
private final Environment env;
private final SysFileService sysFileService;
private final SysPictureService sysPictureService;
private final StorageServiceFactory storageServiceFactory;
/**
* @param fileSize 文件大小
@ -80,23 +84,61 @@ public class FileController {
// 文件预检查
validateUploadFile(file, fileSize, folderName);
Map<String, Object> fileMap = UploadUtil.coderSingleFile(file, basePath + "/" + folderName + "/"+ CoderLoginUtil.getLoginName() + "/", fileSize);
Map<String, Object> fileMap;
try {
// 根据配置选择存储服务
StorageService storageService = storageServiceFactory.getStorageService(storageType);
// 生成唯一文件名
String fileName = OssUtil.generateUniqueFileName(file.getOriginalFilename());
// 构建文件夹路径
String folderPath = OssUtil.buildFolderPath(folderName, CoderLoginUtil.getLoginName());
// 上传文件
fileMap = storageService.uploadFile(file, fileName, folderPath);
log.info("文件上传成功: storageType={}, fileName={}, filePath={}",
storageType, fileName, fileMap.get("filePath"));
} catch (Exception e) {
log.error("文件上传失败,尝试使用本地存储降级", e);
// 降级到本地存储
try {
StorageService localService = storageServiceFactory.getStorageService("local");
String fileName = OssUtil.generateUniqueFileName(file.getOriginalFilename());
String folderPath = OssUtil.buildFolderPath(folderName, CoderLoginUtil.getLoginName());
fileMap = localService.uploadFile(file, fileName, folderPath);
log.warn("使用本地存储降级成功: fileName={}", fileName);
} catch (Exception ex) {
log.error("本地存储降级也失败", ex);
throw new BusinessException(500, "文件上传失败: " + ex.getMessage());
}
}
// 获取实际使用的存储服务类型
String actualStorageType = determineActualStorageType(fileMap);
// 统一保存到文件表便于文件管理页面统一显示
saveUploadFilesInformation(fileMap, CoderConstants.TRUE);
saveUploadFilesInformation(fileMap, actualStorageType, CoderConstants.TRUE);
// 如果是图片同时保存到图库表保持图库管理功能
if (CoderConstants.PICTURES.equals(folderName)) {
saveUploadPicturesInformation(fileMap, fileParam, CoderConstants.TRUE);
saveUploadPicturesInformation(fileMap, fileParam, actualStorageType, CoderConstants.TRUE);
}
return fileMap;
}
/**
* @description [保存上传文件信息]
* @description [保存上传图片信息]
* @author Leocoder
*/
private void saveUploadPicturesInformation(Map<String, Object> fileMap, String fileParam, boolean isCreateBy) {
private void saveUploadPicturesInformation(Map<String, Object> fileMap, String fileParam, String storageServiceType, boolean isCreateBy) {
log.info("图库上传 ->");
// 新增文件信息
SysPicture sysPicture = new SysPicture();
@ -105,16 +147,26 @@ public class FileController {
sysPicture.setPictureSize(fileMap.get("fileSize").toString());
sysPicture.setPictureSuffix(fileMap.get("suffixName").toString());
sysPicture.setPictureUpload(fileMap.get("filePath").toString());
sysPicture.setPictureService(CoderConstants.ONE_STRING);
sysPicture.setPictureService(storageServiceType);
// 设置文件访问路径
String fileUploadPath = (String) fileMap.get("fileUploadPath");
if (isFullUrl(fileUploadPath)) {
// 如果已经是完整URL如OSS直接使用
sysPicture.setPicturePath(fileUploadPath);
} else {
// 如果是相对路径如本地存储构建完整URL
String protocol = IpUtil.getProtocol(ServletUtil.getRequest());
if (StringUtils.isBlank(protocol)) {
protocol = "http";
}
String hostIp = IpUtil.getHostIp(ServletUtil.getRequest());
String hostPort = StringUtils.isNotBlank(env.getProperty("server.port")) ? env.getProperty("server.port") : "18099";
log.info("IP地址{},端口号:{}", hostIp, env.getProperty("server.port"));
sysPicture.setPicturePath(protocol + "://" + hostIp + ":" + hostPort + fileMap.get("fileUploadPath").toString());
sysPicture.setPicturePath(protocol + "://" + hostIp + ":" + hostPort + fileUploadPath);
}
log.info("图片回显地址:{}", sysPicture.getPicturePath());
if (CoderConstants.MINUS_ONE_STRING.equals(fileParam)) {
sysPicture.setPictureType("9");
} else {
@ -130,25 +182,35 @@ public class FileController {
* @description [保存上传文件信息]
* @author Leocoder
*/
private void saveUploadFilesInformation(Map<String, Object> fileMap, boolean isCreateBy) {
private void saveUploadFilesInformation(Map<String, Object> fileMap, String storageServiceType, boolean isCreateBy) {
log.info("文件上传 ->");
// 新增图库信息
// 新增文件信息
SysFile sysFile = new SysFile();
sysFile.setFileName(fileMap.get("fileName").toString());
sysFile.setNewName(fileMap.get("newName").toString());
sysFile.setFileSize(fileMap.get("fileSize").toString());
sysFile.setFileSuffix(fileMap.get("suffixName").toString());
sysFile.setFileUpload(fileMap.get("filePath").toString());
sysFile.setFileService(CoderConstants.ONE_STRING);
sysFile.setFileService(storageServiceType);
// 设置文件访问路径
String fileUploadPath = (String) fileMap.get("fileUploadPath");
if (isFullUrl(fileUploadPath)) {
// 如果已经是完整URL如OSS直接使用
sysFile.setFilePath(fileUploadPath);
} else {
// 如果是相对路径如本地存储构建完整URL
String protocol = IpUtil.getProtocol(ServletUtil.getRequest());
if (StringUtils.isBlank(protocol)) {
protocol = "http";
}
String hostIp = IpUtil.getHostIp(ServletUtil.getRequest());
String hostPort = StringUtils.isNotBlank(env.getProperty("server.port")) ? env.getProperty("server.port") : "18088";
log.info("IP地址{},端口号:{}", hostIp, env.getProperty("server.port"));
sysFile.setFilePath(protocol + "://" + hostIp + ":" + hostPort + fileMap.get("fileUploadPath").toString());
String hostPort = StringUtils.isNotBlank(env.getProperty("server.port")) ? env.getProperty("server.port") : "18099";
sysFile.setFilePath(protocol + "://" + hostIp + ":" + hostPort + fileUploadPath);
}
log.info("文件回显地址:{}", sysFile.getFilePath());
String fileType = FileTypeUtil.checkFileExtension(fileMap.get("suffixName").toString());
sysFile.setFileType(fileType);
if (isCreateBy) {
@ -157,6 +219,33 @@ public class FileController {
sysFileService.save(sysFile);
}
/**
* 判断字符串是否为完整URL
*/
private boolean isFullUrl(String url) {
return StringUtils.isNotBlank(url) && (url.startsWith("http://") || url.startsWith("https://"));
}
/**
* 确定实际使用的存储服务类型
*/
private String determineActualStorageType(Map<String, Object> fileMap) {
String fileUploadPath = (String) fileMap.get("fileUploadPath");
// 根据返回的文件路径判断实际使用的存储类型
if (isFullUrl(fileUploadPath)) {
// 如果是完整URL检查是否包含OSS域名
if (fileUploadPath.contains(".aliyuncs.com") || fileUploadPath.contains("oss-")) {
return "3"; // OSS
} else if (fileUploadPath.contains("minio") || fileUploadPath.contains(":9000")) {
return "2"; // MinIO
}
}
// 默认为本地存储
return "1"; // Local
}
/**
* @param fileSize 文件大小
* @param folderName 上传指定文件夹名称
@ -171,15 +260,53 @@ public class FileController {
// 文件预检查
validateUploadFile(file, fileSize, folderName);
Map<String, Object> fileMap = UploadUtil.coderSingleFile(file, basePath + "/" + folderName + "/", fileSize);
Map<String, Object> fileMap;
try {
// 根据配置选择存储服务
StorageService storageService = storageServiceFactory.getStorageService(storageType);
// 生成唯一文件名
String fileName = OssUtil.generateUniqueFileName(file.getOriginalFilename());
// 构建文件夹路径匿名上传不包含用户名
String folderPath = folderName;
// 上传文件
fileMap = storageService.uploadFile(file, fileName, folderPath);
log.info("匿名文件上传成功: storageType={}, fileName={}, filePath={}",
storageType, fileName, fileMap.get("filePath"));
} catch (Exception e) {
log.error("匿名文件上传失败,尝试使用本地存储降级", e);
// 降级到本地存储
try {
StorageService localService = storageServiceFactory.getStorageService("local");
String fileName = OssUtil.generateUniqueFileName(file.getOriginalFilename());
String folderPath = folderName;
fileMap = localService.uploadFile(file, fileName, folderPath);
log.warn("匿名上传使用本地存储降级成功: fileName={}", fileName);
} catch (Exception ex) {
log.error("本地存储降级也失败", ex);
throw new BusinessException(500, "文件上传失败: " + ex.getMessage());
}
}
// 获取实际使用的存储服务类型
String actualStorageType = determineActualStorageType(fileMap);
// 统一保存到文件表
saveUploadFilesInformation(fileMap, false);
saveUploadFilesInformation(fileMap, actualStorageType, false);
// 如果是图片同时保存到图库表
if (CoderConstants.PICTURES.equals(folderName)) {
saveUploadPicturesInformation(fileMap, fileParam, false);
saveUploadPicturesInformation(fileMap, fileParam, actualStorageType, false);
}
return fileMap;
}

View File

@ -8,6 +8,7 @@ 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.thin.common.constants.CoderConstants;
import org.leocoder.thin.common.exception.coder.YUtil;
@ -17,6 +18,8 @@ import org.leocoder.thin.domain.enums.oper.OperType;
import org.leocoder.thin.domain.model.vo.system.SysFileVo;
import org.leocoder.thin.domain.pojo.system.SysFile;
import org.leocoder.thin.operlog.annotation.OperLog;
import org.leocoder.thin.oss.service.StorageService;
import org.leocoder.thin.oss.service.StorageServiceFactory;
import org.leocoder.thin.system.service.file.SysFileService;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@ -33,9 +36,12 @@ import java.util.List;
@RequestMapping("/coder")
@RequiredArgsConstructor
@RestController
@Slf4j
public class SysFileController {
private final SysFileService sysFileService;
private final StorageServiceFactory storageServiceFactory;
/**
* @description [分页查询]
@ -124,8 +130,8 @@ public class SysFileController {
SysFile sysFile = sysFileService.getById(id);
if (!ObjectUtil.isEmpty(sysFile)) {
if (StringUtils.isNotBlank(sysFile.getFileUpload())) {
// 删除文件
FileUtil.deleteFile(sysFile.getFileUpload());
// 根据存储服务类型删除文件
deletePhysicalFile(sysFile.getFileUpload(), sysFile.getFileService());
}
}
YUtil.isTrue(!sysFileService.removeById(id), "删除失败,请稍后重试");
@ -147,12 +153,58 @@ public class SysFileController {
if (!sysFileList.isEmpty()) {
for (SysFile sysFile : sysFileList) {
if (StringUtils.isNotBlank(sysFile.getFileUpload())) {
// 删除文件
FileUtil.deleteFile(sysFile.getFileUpload());
// 根据存储服务类型删除文件
deletePhysicalFile(sysFile.getFileUpload(), sysFile.getFileService());
}
}
}
YUtil.isTrue(!sysFileService.removeBatchByIds(ids), "删除失败,请稍后重试");
}
/**
* 根据存储服务类型删除物理文件
*/
private void deletePhysicalFile(String filePath, String fileService) {
try {
boolean deleteResult = false;
switch (fileService) {
case "1": // 本地存储
deleteResult = FileUtil.deleteFile(filePath);
break;
case "2": // MinIO存储
try {
StorageService minioService = storageServiceFactory.getStorageService("minio");
deleteResult = minioService.deleteFile(filePath);
} catch (Exception e) {
log.error("MinIO存储服务不可用尝试本地删除", e);
deleteResult = FileUtil.deleteFile(filePath);
}
break;
case "3": // OSS存储
try {
StorageService ossService = storageServiceFactory.getStorageService("oss");
deleteResult = ossService.deleteFile(filePath);
} catch (Exception e) {
log.error("OSS存储服务不可用尝试本地删除", e);
deleteResult = FileUtil.deleteFile(filePath);
}
break;
default:
log.warn("未知的存储服务类型: {},使用本地删除", fileService);
deleteResult = FileUtil.deleteFile(filePath);
break;
}
if (deleteResult) {
log.info("文件删除成功: filePath={}, fileService={}", filePath, fileService);
} else {
log.warn("文件删除失败: filePath={}, fileService={}", filePath, fileService);
}
} catch (Exception e) {
log.error("删除物理文件时发生异常: filePath={}, fileService={}", filePath, fileService, e);
}
}
}

View File

@ -7,6 +7,7 @@ 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.thin.common.constants.CoderConstants;
import org.leocoder.thin.common.exception.coder.YUtil;
@ -15,6 +16,8 @@ import org.leocoder.thin.domain.enums.oper.OperType;
import org.leocoder.thin.domain.model.vo.system.SysPictureVo;
import org.leocoder.thin.domain.pojo.system.SysPicture;
import org.leocoder.thin.operlog.annotation.OperLog;
import org.leocoder.thin.oss.service.StorageService;
import org.leocoder.thin.oss.service.StorageServiceFactory;
import org.leocoder.thin.system.service.picture.SysPictureService;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@ -31,9 +34,11 @@ import java.util.List;
@RequestMapping("/coder")
@RequiredArgsConstructor
@RestController
@Slf4j
public class SysPictureController {
private final SysPictureService sysPictureService;
private final StorageServiceFactory storageServiceFactory;
/**
* @description [分页查询]
@ -117,6 +122,13 @@ public class SysPictureController {
@PostMapping("/sysPicture/deleteById/{id}")
@OperLog(value = "删除图库", operType = OperType.DELETE)
public void delete(@PathVariable("id") Long id) {
SysPicture sysPicture = sysPictureService.getById(id);
if (sysPicture != null) {
if (StringUtils.isNotBlank(sysPicture.getPictureUpload())) {
// 根据存储服务类型删除图片文件
deletePhysicalFile(sysPicture.getPictureUpload(), sysPicture.getPictureService());
}
}
YUtil.isTrue(!sysPictureService.removeById(id), "删除失败,请稍后重试");
}
@ -130,7 +142,63 @@ public class SysPictureController {
@PostMapping("/sysPicture/batchDelete")
@OperLog(value = "批量删除图库", operType = OperType.DELETE)
public void batchDelete(@RequestBody List<Long> ids) {
List<SysPicture> sysPictureList = sysPictureService.listByIds(ids);
// 批量删除物理文件
for (SysPicture sysPicture : sysPictureList) {
if (StringUtils.isNotBlank(sysPicture.getPictureUpload())) {
deletePhysicalFile(sysPicture.getPictureUpload(), sysPicture.getPictureService());
}
}
YUtil.isTrue(!sysPictureService.removeBatchByIds(ids), "删除失败,请稍后重试");
}
/**
* 根据存储服务类型删除物理文件
*/
private void deletePhysicalFile(String filePath, String fileService) {
try {
boolean deleteResult = false;
switch (fileService) {
case "1": // 本地存储
// 对于本地存储需要使用完整路径
deleteResult = new java.io.File(filePath).delete();
break;
case "2": // MinIO存储
try {
StorageService minioService = storageServiceFactory.getStorageService("minio");
deleteResult = minioService.deleteFile(filePath);
} catch (Exception e) {
log.error("MinIO存储服务不可用跳过删除", e);
deleteResult = false;
}
break;
case "3": // OSS存储
try {
StorageService ossService = storageServiceFactory.getStorageService("oss");
deleteResult = ossService.deleteFile(filePath);
} catch (Exception e) {
log.error("OSS存储服务不可用跳过删除", e);
deleteResult = false;
}
break;
default:
log.warn("未知的存储服务类型: {},跳过文件删除", fileService);
deleteResult = false;
break;
}
if (deleteResult) {
log.info("图片文件删除成功: filePath={}, fileService={}", filePath, fileService);
} else {
log.warn("图片文件删除失败: filePath={}, fileService={}", filePath, fileService);
}
} catch (Exception e) {
log.error("删除物理图片文件时发生异常: filePath={}, fileService={}", filePath, fileService, e);
}
}
}