第10章 - 文件服务与存储

第10章 - 文件服务与存储

10.1 文件服务概述

10.1.1 功能介绍

RuoYi-Cloud的文件服务模块(ruoyi-file)提供统一的文件上传和下载功能,支持多种存储方式:

  • 本地存储
  • MinIO分布式存储
  • FastDFS分布式存储
  • 阿里云OSS
  • 腾讯云COS
  • 七牛云存储

10.1.2 项目结构

ruoyi-modules/ruoyi-file/
├── src/main/java/com/ruoyi/file/
│   ├── RuoYiFileApplication.java      # 启动类
│   ├── config/
│   │   ├── MinioConfig.java           # MinIO配置
│   │   └── ResourcesConfig.java       # 资源配置
│   ├── controller/
│   │   └── SysFileController.java     # 文件控制器
│   └── service/
│       ├── ISysFileService.java       # 文件服务接口
│       └── impl/
│           ├── FastDfsSysFileServiceImpl.java   # FastDFS实现
│           ├── LocalSysFileServiceImpl.java     # 本地存储实现
│           └── MinioSysFileServiceImpl.java     # MinIO实现
└── src/main/resources/
    └── bootstrap.yml

10.2 本地文件存储

10.2.1 配置

# 文件服务配置
file:
  # 文件存储路径
  path: /home/ruoyi/uploadPath
  # 文件访问前缀
  prefix: http://127.0.0.1:9300
  # 域名(可选,用于访问时替换prefix)
  domain:

10.2.2 本地存储实现

@Primary
@Service
public class LocalSysFileServiceImpl implements ISysFileService {
    
    @Value("${file.path}")
    private String localFilePath;
    
    @Value("${file.domain}")
    private String domain;
    
    @Value("${file.prefix}")
    private String localFilePrefix;
    
    /**
     * 本地文件上传接口
     * 
     * @param file 上传的文件
     * @return 访问地址
     */
    @Override
    public String uploadFile(MultipartFile file) throws Exception {
        String name = FileUploadUtils.upload(localFilePath, file);
        String url = domain + localFilePrefix + name;
        return url;
    }
}

10.2.3 文件上传工具类

public class FileUploadUtils {
    
    /**
     * 默认大小 50M
     */
    public static final long DEFAULT_MAX_SIZE = 50 * 1024 * 1024;
    
    /**
     * 默认的文件名最大长度 100
     */
    public static final int DEFAULT_FILE_NAME_LENGTH = 100;
    
    /**
     * 根据文件路径上传
     */
    public static String upload(String baseDir, MultipartFile file) throws IOException {
        try {
            return upload(baseDir, file, MimeTypeUtils.DEFAULT_ALLOWED_EXTENSION);
        } catch (Exception e) {
            throw new IOException(e.getMessage(), e);
        }
    }
    
    /**
     * 文件上传
     */
    public static String upload(String baseDir, MultipartFile file, String[] allowedExtension)
            throws FileSizeLimitExceededException, InvalidExtensionException, IOException {
        int fileNameLength = Objects.requireNonNull(file.getOriginalFilename()).length();
        if (fileNameLength > DEFAULT_FILE_NAME_LENGTH) {
            throw new FileNameLengthLimitExceededException(DEFAULT_FILE_NAME_LENGTH);
        }
        
        assertAllowed(file, allowedExtension);
        
        String fileName = extractFilename(file);
        
        String absPath = getAbsoluteFile(baseDir, fileName).getAbsolutePath();
        file.transferTo(Paths.get(absPath));
        return getPathFileName(baseDir, fileName);
    }
    
    /**
     * 编码文件名
     */
    public static String extractFilename(MultipartFile file) {
        return StringUtils.format("{}/{}_{}.{}", 
            DateUtils.datePath(),
            FilenameUtils.getBaseName(file.getOriginalFilename()), 
            Seq.getId(Seq.uploadSeqType), 
            FileTypeUtils.getExtension(file));
    }
    
    private static File getAbsoluteFile(String uploadDir, String fileName) throws IOException {
        File desc = new File(uploadDir + File.separator + fileName);
        if (!desc.exists()) {
            if (!desc.getParentFile().exists()) {
                desc.getParentFile().mkdirs();
            }
        }
        return desc;
    }
    
    private static String getPathFileName(String uploadDir, String fileName) {
        int dirLastIndex = uploadDir.lastIndexOf("/") + 1;
        String currentDir = StringUtils.substring(uploadDir, dirLastIndex);
        return "/" + currentDir + "/" + fileName;
    }
    
    /**
     * 文件大小校验
     */
    public static void assertAllowed(MultipartFile file, String[] allowedExtension)
            throws FileSizeLimitExceededException, InvalidExtensionException {
        long size = file.getSize();
        if (size > DEFAULT_MAX_SIZE) {
            throw new FileSizeLimitExceededException(DEFAULT_MAX_SIZE / 1024 / 1024);
        }
        
        String fileName = file.getOriginalFilename();
        String extension = FileTypeUtils.getExtension(file);
        if (allowedExtension != null && !isAllowedExtension(extension, allowedExtension)) {
            if (allowedExtension == MimeTypeUtils.IMAGE_EXTENSION) {
                throw new InvalidExtensionException.InvalidImageExtensionException(allowedExtension, extension, fileName);
            } else if (allowedExtension == MimeTypeUtils.FLASH_EXTENSION) {
                throw new InvalidExtensionException.InvalidFlashExtensionException(allowedExtension, extension, fileName);
            } else if (allowedExtension == MimeTypeUtils.MEDIA_EXTENSION) {
                throw new InvalidExtensionException.InvalidMediaExtensionException(allowedExtension, extension, fileName);
            } else if (allowedExtension == MimeTypeUtils.VIDEO_EXTENSION) {
                throw new InvalidExtensionException.InvalidVideoExtensionException(allowedExtension, extension, fileName);
            } else {
                throw new InvalidExtensionException(allowedExtension, extension, fileName);
            }
        }
    }
    
    /**
     * 判断MIME类型是否是允许的MIME类型
     */
    public static boolean isAllowedExtension(String extension, String[] allowedExtension) {
        for (String str : allowedExtension) {
            if (str.equalsIgnoreCase(extension)) {
                return true;
            }
        }
        return false;
    }
}

10.3 MinIO分布式存储

10.3.1 MinIO简介

MinIO是一个高性能的对象存储服务器,兼容Amazon S3 API,适合存储非结构化数据如图片、视频、日志文件等。

10.3.2 MinIO配置

# MinIO配置
minio:
  url: http://localhost:9000
  accessKey: minioadmin
  secretKey: minioadmin
  bucketName: ruoyi

10.3.3 MinIO配置类

@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
    
    /** 服务地址 */
    private String url;
    
    /** 用户名 */
    private String accessKey;
    
    /** 密码 */
    private String secretKey;
    
    /** 存储桶名称 */
    private String bucketName;
    
    @Bean
    public MinioClient getMinioClient() {
        return MinioClient.builder()
                .endpoint(url)
                .credentials(accessKey, secretKey)
                .build();
    }
    
    // getter/setter...
}

10.3.4 MinIO存储实现

@Service
public class MinioSysFileServiceImpl implements ISysFileService {
    
    @Autowired
    private MinioConfig minioConfig;
    
    @Autowired
    private MinioClient minioClient;
    
    /**
     * MinIO文件上传接口
     */
    @Override
    public String uploadFile(MultipartFile file) throws Exception {
        String fileName = extractFilename(file);
        PutObjectArgs args = PutObjectArgs.builder()
                .bucket(minioConfig.getBucketName())
                .object(fileName)
                .stream(file.getInputStream(), file.getSize(), -1)
                .contentType(file.getContentType())
                .build();
        minioClient.putObject(args);
        return minioConfig.getUrl() + "/" + minioConfig.getBucketName() + "/" + fileName;
    }
    
    /**
     * 提取文件名
     */
    private String extractFilename(MultipartFile file) {
        return StringUtils.format("{}/{}_{}.{}", 
            DateUtils.datePath(),
            FilenameUtils.getBaseName(file.getOriginalFilename()), 
            Seq.getId(Seq.uploadSeqType), 
            FileTypeUtils.getExtension(file));
    }
}

10.3.5 MinIO常用操作

@Component
public class MinioUtil {
    
    @Autowired
    private MinioClient minioClient;
    
    /**
     * 创建桶
     */
    public void createBucket(String bucketName) throws Exception {
        boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        if (!found) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
        }
    }
    
    /**
     * 上传文件
     */
    public void upload(String bucketName, String objectName, InputStream inputStream, String contentType) throws Exception {
        minioClient.putObject(PutObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .stream(inputStream, inputStream.available(), -1)
                .contentType(contentType)
                .build());
    }
    
    /**
     * 下载文件
     */
    public InputStream download(String bucketName, String objectName) throws Exception {
        return minioClient.getObject(GetObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .build());
    }
    
    /**
     * 删除文件
     */
    public void delete(String bucketName, String objectName) throws Exception {
        minioClient.removeObject(RemoveObjectArgs.builder()
                .bucket(bucketName)
                .object(objectName)
                .build());
    }
    
    /**
     * 获取文件访问URL
     */
    public String getPresignedUrl(String bucketName, String objectName) throws Exception {
        return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                .method(Method.GET)
                .bucket(bucketName)
                .object(objectName)
                .expiry(7, TimeUnit.DAYS)
                .build());
    }
}

10.4 FastDFS分布式存储

10.4.1 FastDFS简介

FastDFS是一个开源的高性能分布式文件系统,主要功能包括:文件存储、文件同步、文件访问(文件上传、文件下载)等。

10.4.2 FastDFS配置

# FastDFS配置
fdfs:
  domain: http://localhost:8080
  soTimeout: 1500
  connectTimeout: 600
  pool:
    jmxEnabled: false
  trackerList:
    - localhost:22122

10.4.3 FastDFS存储实现

@Service
public class FastDfsSysFileServiceImpl implements ISysFileService {
    
    @Autowired
    private FastFileStorageClient storageClient;
    
    @Value("${fdfs.domain}")
    private String domain;
    
    /**
     * FastDFS文件上传接口
     */
    @Override
    public String uploadFile(MultipartFile file) throws Exception {
        StorePath storePath = storageClient.uploadFile(file.getInputStream(), file.getSize(),
                FileTypeUtils.getExtension(file), null);
        return domain + "/" + storePath.getFullPath();
    }
}

10.5 文件控制器

10.5.1 文件上传接口

@RestController
public class SysFileController {
    
    private static final Logger log = LoggerFactory.getLogger(SysFileController.class);
    
    @Autowired
    private ISysFileService sysFileService;
    
    /**
     * 文件上传请求
     */
    @PostMapping("upload")
    public R<SysFile> upload(MultipartFile file) {
        try {
            // 上传并返回访问地址
            String url = sysFileService.uploadFile(file);
            SysFile sysFile = new SysFile();
            sysFile.setName(FileUtils.getName(url));
            sysFile.setUrl(url);
            return R.ok(sysFile);
        } catch (Exception e) {
            log.error("上传文件失败", e);
            return R.fail(e.getMessage());
        }
    }
}

10.5.2 文件实体

public class SysFile {
    
    /**
     * 文件名称
     */
    private String name;
    
    /**
     * 文件地址
     */
    private String url;
    
    // getter/setter...
}

10.6 远程文件服务调用

10.6.1 远程服务接口

@FeignClient(contextId = "remoteFileService", 
    value = ServiceNameConstants.FILE_SERVICE, 
    fallbackFactory = RemoteFileFallbackFactory.class)
public interface RemoteFileService {
    
    /**
     * 上传文件
     */
    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    R<SysFile> upload(@RequestPart(value = "file") MultipartFile file);
}

10.6.2 降级处理

@Component
public class RemoteFileFallbackFactory implements FallbackFactory<RemoteFileService> {
    
    private static final Logger log = LoggerFactory.getLogger(RemoteFileFallbackFactory.class);
    
    @Override
    public RemoteFileService create(Throwable throwable) {
        log.error("文件服务调用失败:{}", throwable.getMessage());
        return new RemoteFileService() {
            @Override
            public R<SysFile> upload(MultipartFile file) {
                return R.fail("上传文件失败:" + throwable.getMessage());
            }
        };
    }
}

10.6.3 调用示例

@RestController
@RequestMapping("/user/profile")
public class SysProfileController extends BaseController {
    
    @Autowired
    private RemoteFileService remoteFileService;
    
    /**
     * 头像上传
     */
    @Log(title = "用户头像", businessType = BusinessType.UPDATE)
    @PostMapping("/avatar")
    public AjaxResult avatar(@RequestParam("avatarfile") MultipartFile file) {
        if (!file.isEmpty()) {
            LoginUser loginUser = SecurityUtils.getLoginUser();
            R<SysFile> fileResult = remoteFileService.upload(file);
            if (R.isSuccess(fileResult)) {
                String avatar = fileResult.getData().getUrl();
                if (userService.updateUserAvatar(loginUser.getUsername(), avatar)) {
                    AjaxResult ajax = AjaxResult.success();
                    ajax.put("imgUrl", avatar);
                    // 更新缓存用户头像
                    loginUser.getSysUser().setAvatar(avatar);
                    tokenService.setLoginUser(loginUser);
                    return ajax;
                }
            }
            return AjaxResult.error(fileResult.getMsg());
        }
        return AjaxResult.error("上传图片异常,请联系管理员");
    }
}

10.7 前端文件上传组件

10.7.1 图片上传组件

<template>
  <div class="component-upload-image">
    <el-upload
      multiple
      :action="uploadImgUrl"
      list-type="picture-card"
      :on-success="handleUploadSuccess"
      :before-upload="handleBeforeUpload"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      ref="imageUpload"
      :on-remove="handleDelete"
      :show-file-list="true"
      :headers="headers"
      :file-list="fileList"
      :on-preview="handlePictureCardPreview"
      :class="{ hide: fileList.length >= limit }"
    >
      <el-icon class="avatar-uploader-icon"><plus /></el-icon>
    </el-upload>
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip">
      请上传
      <template v-if="fileSize">
        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
      </template>
      <template v-if="fileType">
        格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
      </template>
      的文件
    </div>

    <el-dialog
      v-model="dialogVisible"
      title="预览"
      width="800px"
      append-to-body
    >
      <img
        :src="dialogImageUrl"
        style="display: block; max-width: 100%; margin: 0 auto"
      />
    </el-dialog>
  </div>
</template>

<script setup>
import { getToken } from "@/utils/auth";

const props = defineProps({
  modelValue: [String, Object, Array],
  // 图片数量限制
  limit: {
    type: Number,
    default: 5,
  },
  // 大小限制(MB)
  fileSize: {
    type: Number,
    default: 5,
  },
  // 文件类型, 例如['png', 'jpg', 'jpeg']
  fileType: {
    type: Array,
    default: () => ["png", "jpg", "jpeg"],
  },
  // 是否显示提示
  isShowTip: {
    type: Boolean,
    default: true
  }
});

const { proxy } = getCurrentInstance();
const emit = defineEmits();
const number = ref(0);
const uploadList = ref([]);
const dialogImageUrl = ref("");
const dialogVisible = ref(false);
const baseUrl = import.meta.env.VITE_APP_BASE_API;
const uploadImgUrl = ref(baseUrl + "/file/upload");
const headers = ref({ Authorization: "Bearer " + getToken() });
const fileList = ref([]);
const showTip = computed(
  () => props.isShowTip && (props.fileType || props.fileSize)
);

// 其他方法...
</script>

10.7.2 文件上传组件

<template>
  <div class="upload-file">
    <el-upload
      multiple
      :action="uploadFileUrl"
      :before-upload="handleBeforeUpload"
      :file-list="fileList"
      :limit="limit"
      :on-error="handleUploadError"
      :on-exceed="handleExceed"
      :on-success="handleUploadSuccess"
      :show-file-list="false"
      :headers="headers"
      class="upload-file-uploader"
      ref="fileUpload"
    >
      <!-- 上传按钮 -->
      <el-button type="primary">选取文件</el-button>
    </el-upload>
    <!-- 上传提示 -->
    <div class="el-upload__tip" v-if="showTip">
      请上传
      <template v-if="fileSize">
        大小不超过 <b style="color: #f56c6c">{{ fileSize }}MB</b>
      </template>
      <template v-if="fileType">
        格式为 <b style="color: #f56c6c">{{ fileType.join("/") }}</b>
      </template>
      的文件
    </div>
    <!-- 文件列表 -->
    <transition-group
      class="upload-file-list el-upload-list el-upload-list--text"
      name="el-fade-in-linear"
      tag="ul"
    >
      <li
        :key="file.url"
        class="el-upload-list__item ele-upload-list__item-content"
        v-for="(file, index) in fileList"
      >
        <el-link :href="`${baseUrl}${file.url}`" :underline="false" target="_blank">
          <span class="el-icon-document"> {{ getFileName(file.name) }} </span>
        </el-link>
        <div class="ele-upload-list__item-content-action">
          <el-link :underline="false" @click="handleDelete(index)" type="danger">删除</el-link>
        </div>
      </li>
    </transition-group>
  </div>
</template>

<script setup>
import { getToken } from "@/utils/auth";

// 组件逻辑...
</script>

10.8 文件安全

10.8.1 文件类型限制

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",
            // pdf
            "pdf" };
}

10.8.2 文件名安全处理

public class FileTypeUtils {
    
    /**
     * 获取文件类型
     */
    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;
    }
    
    /**
     * 获取文件名称(不含路径)
     */
    public static String getName(String fileName) {
        if (fileName == null) {
            return null;
        }
        int lastUnixPos = fileName.lastIndexOf('/');
        int lastWindowsPos = fileName.lastIndexOf('\\');
        int index = Math.max(lastUnixPos, lastWindowsPos);
        return fileName.substring(index + 1);
    }
}

10.9 小结

本章详细介绍了RuoYi-Cloud的文件服务模块,包括:

  1. 功能概述:支持多种存储方式
  2. 本地存储:文件上传工具和配置
  3. MinIO存储:分布式对象存储配置和使用
  4. FastDFS存储:高性能分布式文件系统
  5. 远程调用:文件服务的Feign调用
  6. 前端组件:图片和文件上传组件
  7. 安全处理:文件类型和名称安全

文件服务是系统中的基础服务,理解其实现原理对于扩展其他存储方式非常重要。


上一章:定时任务模块 | 返回目录 | 下一章:前端Vue项目详解

posted @ 2026-01-08 14:05  我才是银古  阅读(15)  评论(0)    收藏  举报