Files
ruoyi-vue-pro/.cursor/rules/file-upload-download.mdc

906 lines
30 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 文件上传下载指南
## 文件服务架构
### 存储方案
- **本地存储**: 直接存储到服务器磁盘
- **数据库存储**: 文件内容存储到数据库
- **云存储**: 对接阿里云 OSS、腾讯云 COS、七牛云等
- **FTP 存储**: 通过 FTP 协议存储文件
- **S3 存储**: 兼容 AWS S3 协议(如 MinIO
### 文件管理表结构
```sql
-- 文件配置表
CREATE TABLE infra_file_config (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '编号',
name VARCHAR(63) NOT NULL COMMENT '配置名',
storage TINYINT NOT NULL COMMENT '存储器',
remark VARCHAR(255) DEFAULT NULL COMMENT '备注',
master BIT NOT NULL COMMENT '是否为主配置',
config VARCHAR(4096) NOT NULL COMMENT '存储配置',
creator VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updater VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除'
);
-- 文件表
CREATE TABLE infra_file (
id VARCHAR(32) NOT NULL COMMENT '文件编号',
config_id BIGINT NOT NULL COMMENT '配置编号',
name VARCHAR(256) DEFAULT NULL COMMENT '文件名',
path VARCHAR(512) NOT NULL COMMENT '文件路径',
url VARCHAR(1024) NOT NULL COMMENT '文件 URL',
type VARCHAR(128) DEFAULT NULL COMMENT 'MIME 类型',
size INT NOT NULL COMMENT '文件大小',
creator VARCHAR(64) DEFAULT '' COMMENT '创建者',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
updater VARCHAR(64) DEFAULT '' COMMENT '更新者',
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除'
);
```
## 文件客户端抽象
### 文件客户端接口
```java
public interface FileClient {
/**
* 获得客户端编号
*/
Long getId();
/**
* 上传文件
*/
String upload(byte[] content, String path, String type) throws Exception;
/**
* 删除文件
*/
void delete(String path) throws Exception;
/**
* 获得文件的内容
*/
byte[] getContent(String path) throws Exception;
/**
* 获得文件的预签名 URL
*/
FilePresignedUrlRespDTO getPresignedObjectUrl(String path, Duration expiration) throws Exception;
}
```
### 抽象文件客户端
```java
public abstract class AbstractFileClient<Config extends FileClientConfig> implements FileClient {
protected Long id;
protected Config config;
public AbstractFileClient(Long id, Config config) {
this.id = id;
this.config = config;
}
/**
* 初始化
*/
public final void init() {
doInit();
log.info("[init][配置({}) 初始化完成]", config);
}
/**
* 自定义初始化
*/
protected abstract void doInit();
/**
* 刷新配置
*/
public final void refresh(Config config) {
// 判断是否更新
if (this.config.equals(config)) {
return;
}
log.info("[refresh][配置({})发生变化,重新初始化]", config);
this.config = config;
// 初始化
this.doInit();
}
@Override
public Long getId() {
return id;
}
}
```
## 具体存储实现
### 本地存储客户端
```java
public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
public LocalFileClient(Long id, LocalFileClientConfig config) {
super(id, config);
}
@Override
protected void doInit() {
// 补全风格
if (!config.getBasePath().endsWith(File.separator)) {
config.setBasePath(config.getBasePath() + File.separator);
}
}
@Override
public String upload(byte[] content, String path, String type) throws Exception {
// 执行写入
String filePath = getFilePath(path);
FileUtil.writeBytes(content, filePath);
// 拼接返回路径
return config.getDomain() + path;
}
@Override
public void delete(String path) throws Exception {
String filePath = getFilePath(path);
FileUtil.del(filePath);
}
@Override
public byte[] getContent(String path) throws Exception {
String filePath = getFilePath(path);
return FileUtil.readBytes(filePath);
}
private String getFilePath(String path) {
return config.getBasePath() + path;
}
@Override
public FilePresignedUrlRespDTO getPresignedObjectUrl(String path, Duration expiration) {
throw new UnsupportedOperationException("本地存储不支持预签名 URL");
}
}
```
### S3 存储客户端
```java
public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
private AmazonS3 client;
public S3FileClient(Long id, S3FileClientConfig config) {
super(id, config);
}
@Override
protected void doInit() {
// 初始化客户端
AWSCredentials credentials = new BasicAWSCredentials(config.getAccessKey(), config.getAccessSecret());
AWSCredentialsProvider credentialsProvider = new AWSStaticCredentialsProvider(credentials);
ClientConfiguration clientConfig = new ClientConfiguration();
if (StrUtil.isNotEmpty(config.getRegion())) {
clientConfig.setSignerOverride("AWSS3V4SignerType");
}
// 创建 client
this.client = AmazonS3ClientBuilder.standard()
.withCredentials(credentialsProvider)
.withClientConfiguration(clientConfig)
.withEndpointConfiguration(new AwsClientBuilder.EndpointConfiguration(
config.getEndpoint(), config.getRegion()))
.withPathStyleAccessEnabled(config.getPathStyleAccess())
.build();
}
@Override
public String upload(byte[] content, String path, String type) throws Exception {
// 执行上传
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(content.length);
metadata.setContentType(type);
ByteArrayInputStream inputStream = new ByteArrayInputStream(content);
client.putObject(config.getBucket(), path, inputStream, metadata);
// 拼接返回路径
return config.getDomain() + "/" + path;
}
@Override
public void delete(String path) throws Exception {
client.deleteObject(config.getBucket(), path);
}
@Override
public byte[] getContent(String path) throws Exception {
S3Object object = client.getObject(config.getBucket(), path);
return IoUtil.readBytes(object.getObjectContent());
}
@Override
public FilePresignedUrlRespDTO getPresignedObjectUrl(String path, Duration expiration) throws Exception {
Date expireDate = new Date(System.currentTimeMillis() + expiration.toMillis());
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(config.getBucket(), path)
.withMethod(HttpMethod.GET)
.withExpiration(expireDate);
URL url = client.generatePresignedUrl(request);
return new FilePresignedUrlRespDTO(url.toString(),
LocalDateTime.now().plus(expiration));
}
}
```
### 阿里云 OSS 客户端
```java
public class AliyunFileClient extends AbstractFileClient<AliyunFileClientConfig> {
private OSS client;
public AliyunFileClient(Long id, AliyunFileClientConfig config) {
super(id, config);
}
@Override
protected void doInit() {
// 补全 endpoint
if (!config.getEndpoint().contains("://")) {
config.setEndpoint("https://" + config.getEndpoint());
}
// 初始化客户端
client = new OSSClientBuilder().build(config.getEndpoint(),
config.getAccessKey(),
config.getAccessSecret());
}
@Override
public String upload(byte[] content, String path, String type) throws Exception {
// 执行上传
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(content.length);
metadata.setContentType(type);
ByteArrayInputStream inputStream = new ByteArrayInputStream(content);
client.putObject(config.getBucket(), path, inputStream, metadata);
// 拼接返回路径
return config.getDomain() + "/" + path;
}
@Override
public void delete(String path) throws Exception {
client.deleteObject(config.getBucket(), path);
}
@Override
public byte[] getContent(String path) throws Exception {
OSSObject ossObject = client.getObject(config.getBucket(), path);
return IoUtil.readBytes(ossObject.getObjectContent());
}
@Override
public FilePresignedUrlRespDTO getPresignedObjectUrl(String path, Duration expiration) throws Exception {
Date expireDate = new Date(System.currentTimeMillis() + expiration.toMillis());
GeneratePresignedUrlRequest request = new GeneratePresignedUrlRequest(config.getBucket(), path, HttpMethod.GET);
request.setExpiration(expireDate);
URL url = client.generatePresignedUrl(request);
return new FilePresignedUrlRespDTO(url.toString(),
LocalDateTime.now().plus(expiration));
}
}
```
## 文件服务实现
### 文件服务接口
```java
@Service
public class FileServiceImpl implements FileService {
@Resource
private FileConfigService fileConfigService;
@Resource
private FileMapper fileMapper;
@Resource
private FileClientFactory fileClientFactory;
@Override
public String createFile(String name, String path, byte[] content) throws Exception {
// 获取文件配置
FileConfigDO config = fileConfigService.getMasterFileConfig();
Assert.notNull(config, "客户端({}) 不能为空", config);
// 获取文件客户端
FileClient client = fileClientFactory.getFileClient(config.getId());
// 上传到文件存储器
String url = client.upload(content, path, name);
// 保存到数据库
FileDO file = new FileDO();
file.setConfigId(config.getId());
file.setName(name);
file.setPath(path);
file.setUrl(url);
file.setType(FileTypeUtils.getMimeType(name));
file.setSize(content.length);
fileMapper.insert(file);
return file.getId();
}
@Override
public void deleteFile(String id) throws Exception {
// 校验存在
FileDO file = validateFileExists(id);
// 从文件存储器中删除
FileClient client = fileClientFactory.getFileClient(file.getConfigId());
client.delete(file.getPath());
// 删除记录
fileMapper.deleteById(id);
}
@Override
public byte[] getFileContent(String id) throws Exception {
// 校验存在
FileDO file = validateFileExists(id);
// 从文件存储器中获取
FileClient client = fileClientFactory.getFileClient(file.getConfigId());
return client.getContent(file.getPath());
}
@Override
public FilePresignedUrlRespDTO getFilePresignedUrl(String id, Duration expiration) throws Exception {
// 校验存在
FileDO file = validateFileExists(id);
// 生成预签名 URL
FileClient client = fileClientFactory.getFileClient(file.getConfigId());
return client.getPresignedObjectUrl(file.getPath(), expiration);
}
}
```
### 文件客户端工厂
```java
@Component
public class FileClientFactory {
/**
* 文件客户端 Map
* key配置编号
*/
private final Map<Long, AbstractFileClient<?>> clients = new ConcurrentHashMap<>();
@Resource
private FileConfigService fileConfigService;
@PostConstruct
public void initLocalCache() {
// 第一步:查询文件配置
List<FileConfigDO> configs = fileConfigService.getFileConfigList();
log.info("[initLocalCache][缓存文件配置,数量为:{}]", configs.size());
// 第二步:构建缓存
configs.forEach(this::refreshFileClient);
}
@EventListener
public void onFileConfigRefresh(FileConfigRefreshMessage message) {
refreshFileClient(message.getFileConfig());
}
/**
* 获得文件客户端
*/
public FileClient getFileClient(Long configId) {
AbstractFileClient<?> client = clients.get(configId);
if (client == null) {
log.error("[getFileClient][配置编号({}) 找不到客户端]", configId);
}
return client;
}
@SuppressWarnings("unchecked")
private void refreshFileClient(FileConfigDO config) {
// 情况一:如果 config 被禁用,则移除
AbstractFileClient<?> client = clients.get(config.getId());
if (!config.getStatus()) {
if (client != null) {
clients.remove(config.getId());
log.info("[refreshFileClient][移除文件配置:{}]", config);
}
return;
}
// 情况二:如果 config 存在,则进行更新
if (client != null) {
client.refresh(config.getConfig());
return;
}
// 情况三:如果 client 不存在,则进行创建
client = this.createFileClient(config.getId(), config.getStorage(), config.getConfig());
client.init();
clients.put(client.getId(), client);
log.info("[refreshFileClient][添加文件配置:{}]", config);
}
private AbstractFileClient<?> createFileClient(Long configId, Integer storage, Object config) {
FileStorageEnum storageEnum = FileStorageEnum.valueOf(storage);
switch (storageEnum) {
case DB:
return new DBFileClient(configId, (DBFileClientConfig) config);
case LOCAL:
return new LocalFileClient(configId, (LocalFileClientConfig) config);
case FTP:
return new FtpFileClient(configId, (FtpFileClientConfig) config);
case S3:
return new S3FileClient(configId, (S3FileClientConfig) config);
case ALIYUN:
return new AliyunFileClient(configId, (AliyunFileClientConfig) config);
default:
throw new IllegalArgumentException(String.format("未知的文件存储器类型(%d)", storage));
}
}
}
```
## 文件上传控制器
### 通用文件上传
```java
@RestController
@RequestMapping("/admin-api/infra/file")
@Api(tags = "管理后台 - 文件存储")
@Validated
public class FileController {
@Resource
private FileService fileService;
@PostMapping("/upload")
@ApiOperation("上传文件")
@OperateLog(logArgs = false) // 上传文件的参数太大,不打印
public CommonResult<String> uploadFile(@RequestParam("file") MultipartFile file) throws Exception {
String path = generatePath(file);
String url = fileService.createFile(file.getOriginalFilename(), path, IoUtil.readBytes(file.getInputStream()));
return success(url);
}
@DeleteMapping("/delete")
@ApiOperation("删除文件")
public CommonResult<Boolean> deleteFile(@RequestParam("id") String id) throws Exception {
fileService.deleteFile(id);
return success(true);
}
@GetMapping("/{configId}/get/**")
@ApiOperation("下载文件")
@ApiImplicitParam(name = "configId", value = "配置编号", required = true, dataTypeClass = Long.class)
public void getFile(HttpServletRequest request,
HttpServletResponse response,
@PathVariable("configId") Long configId) throws Exception {
// 获取请求的路径
String path = StrUtil.subAfter(request.getRequestURI(), "/get/", false);
if (StrUtil.isEmpty(path)) {
throw new IllegalArgumentException("结尾的 path 路径必须传递");
}
// 读取内容
byte[] content = fileService.getFileContent(configId, path);
if (content == null) {
log.warn("[getFile][configId({}) path({}) 文件不存在]", configId, path);
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
ServletUtils.writeAttachment(response, path, content);
}
/**
* 生成文件路径
*/
private String generatePath(MultipartFile file) {
String fileName = file.getOriginalFilename();
String extName = FileUtil.extName(fileName);
return DateUtil.format(new Date(), "yyyy/MM/dd") + "/" + IdUtil.fastUUID() + "." + extName;
}
}
```
### 头像上传
```java
@PostMapping("/upload-avatar")
@ApiOperation("上传用户头像")
public CommonResult<String> uploadAvatar(@RequestParam("avatarFile") MultipartFile file) throws Exception {
// 校验文件格式
validateImageFile(file);
// 校验文件大小
if (file.getSize() > 2 * 1024 * 1024) { // 2MB
throw exception(FILE_SIZE_TOO_LARGE);
}
// 生成头像路径
String path = generateAvatarPath(file);
// 上传文件
String url = fileService.createFile(file.getOriginalFilename(), path,
IoUtil.readBytes(file.getInputStream()));
// 更新用户头像
Long userId = SecurityFrameworkUtils.getLoginUserId();
userService.updateUserAvatar(userId, url);
return success(url);
}
private void validateImageFile(MultipartFile file) {
String fileName = file.getOriginalFilename();
String extName = FileUtil.extName(fileName).toLowerCase();
Set<String> allowedExtensions = Set.of("jpg", "jpeg", "png", "gif", "bmp", "webp");
if (!allowedExtensions.contains(extName)) {
throw exception(FILE_TYPE_NOT_SUPPORTED);
}
}
private String generateAvatarPath(MultipartFile file) {
Long userId = SecurityFrameworkUtils.getLoginUserId();
String extName = FileUtil.extName(file.getOriginalFilename());
return "avatar/" + userId + "/" + System.currentTimeMillis() + "." + extName;
}
```
## 图片处理
### 图片缩放和裁剪
```java
@Service
public class ImageProcessService {
/**
* 图片缩放
*/
public byte[] resizeImage(byte[] imageData, int width, int height) {
try {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(imageData));
// 计算缩放比例
double scaleX = (double) width / originalImage.getWidth();
double scaleY = (double) height / originalImage.getHeight();
double scale = Math.min(scaleX, scaleY);
int targetWidth = (int) (originalImage.getWidth() * scale);
int targetHeight = (int) (originalImage.getHeight() * scale);
// 创建缩放后的图片
BufferedImage resizedImage = new BufferedImage(targetWidth, targetHeight, BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = resizedImage.createGraphics();
g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g2d.drawImage(originalImage, 0, 0, targetWidth, targetHeight, null);
g2d.dispose();
// 输出为字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(resizedImage, "jpg", baos);
return baos.toByteArray();
} catch (IOException e) {
throw new ServiceException("图片处理失败", e);
}
}
/**
* 图片裁剪
*/
public byte[] cropImage(byte[] imageData, int x, int y, int width, int height) {
try {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(imageData));
// 裁剪图片
BufferedImage croppedImage = originalImage.getSubimage(x, y, width, height);
// 输出为字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(croppedImage, "jpg", baos);
return baos.toByteArray();
} catch (IOException e) {
throw new ServiceException("图片裁剪失败", e);
}
}
/**
* 添加水印
*/
public byte[] addWatermark(byte[] imageData, String watermarkText) {
try {
BufferedImage originalImage = ImageIO.read(new ByteArrayInputStream(imageData));
Graphics2D g2d = originalImage.createGraphics();
// 设置水印样式
g2d.setFont(new Font("Arial", Font.BOLD, 30));
g2d.setColor(new Color(255, 255, 255, 128)); // 半透明白色
g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// 计算水印位置
FontMetrics fontMetrics = g2d.getFontMetrics();
int textWidth = fontMetrics.stringWidth(watermarkText);
int textHeight = fontMetrics.getHeight();
int x = originalImage.getWidth() - textWidth - 10;
int y = originalImage.getHeight() - 10;
// 绘制水印
g2d.drawString(watermarkText, x, y);
g2d.dispose();
// 输出为字节数组
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ImageIO.write(originalImage, "jpg", baos);
return baos.toByteArray();
} catch (IOException e) {
throw new ServiceException("添加水印失败", e);
}
}
}
```
## 大文件上传
### 分片上传
```java
@RestController
@RequestMapping("/admin-api/infra/file/chunk")
@Api(tags = "管理后台 - 分片上传")
public class ChunkUploadController {
@Resource
private ChunkUploadService chunkUploadService;
@PostMapping("/init")
@ApiOperation("初始化分片上传")
public CommonResult<ChunkUploadInitRespVO> initChunkUpload(@Valid @RequestBody ChunkUploadInitReqVO reqVO) {
return success(chunkUploadService.initChunkUpload(reqVO));
}
@PostMapping("/upload")
@ApiOperation("上传文件分片")
public CommonResult<Boolean> uploadChunk(@RequestParam("uploadId") String uploadId,
@RequestParam("chunkNumber") Integer chunkNumber,
@RequestParam("file") MultipartFile file) throws Exception {
chunkUploadService.uploadChunk(uploadId, chunkNumber, IoUtil.readBytes(file.getInputStream()));
return success(true);
}
@PostMapping("/merge")
@ApiOperation("合并文件分片")
public CommonResult<String> mergeChunks(@Valid @RequestBody ChunkUploadMergeReqVO reqVO) throws Exception {
String fileId = chunkUploadService.mergeChunks(reqVO);
return success(fileId);
}
@DeleteMapping("/abort")
@ApiOperation("取消分片上传")
public CommonResult<Boolean> abortChunkUpload(@RequestParam("uploadId") String uploadId) {
chunkUploadService.abortChunkUpload(uploadId);
return success(true);
}
}
@Service
public class ChunkUploadService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private FileService fileService;
public ChunkUploadInitRespVO initChunkUpload(ChunkUploadInitReqVO reqVO) {
// 生成上传ID
String uploadId = IdUtil.fastUUID();
// 缓存上传信息
ChunkUploadInfo uploadInfo = new ChunkUploadInfo();
uploadInfo.setFileName(reqVO.getFileName());
uploadInfo.setFileSize(reqVO.getFileSize());
uploadInfo.setChunkSize(reqVO.getChunkSize());
uploadInfo.setTotalChunks(calculateTotalChunks(reqVO.getFileSize(), reqVO.getChunkSize()));
uploadInfo.setUploadedChunks(new HashSet<>());
String key = "chunk_upload:" + uploadId;
redisTemplate.opsForValue().set(key, uploadInfo, Duration.ofHours(24));
return new ChunkUploadInitRespVO(uploadId, uploadInfo.getTotalChunks());
}
public void uploadChunk(String uploadId, Integer chunkNumber, byte[] chunkData) {
String key = "chunk_upload:" + uploadId;
ChunkUploadInfo uploadInfo = (ChunkUploadInfo) redisTemplate.opsForValue().get(key);
if (uploadInfo == null) {
throw new ServiceException("上传信息不存在或已过期");
}
// 存储分片数据
String chunkKey = "chunk_data:" + uploadId + ":" + chunkNumber;
redisTemplate.opsForValue().set(chunkKey, chunkData, Duration.ofHours(24));
// 记录已上传分片
uploadInfo.getUploadedChunks().add(chunkNumber);
redisTemplate.opsForValue().set(key, uploadInfo, Duration.ofHours(24));
}
public String mergeChunks(ChunkUploadMergeReqVO reqVO) throws Exception {
String uploadId = reqVO.getUploadId();
String key = "chunk_upload:" + uploadId;
ChunkUploadInfo uploadInfo = (ChunkUploadInfo) redisTemplate.opsForValue().get(key);
if (uploadInfo == null) {
throw new ServiceException("上传信息不存在或已过期");
}
// 检查所有分片是否已上传
if (uploadInfo.getUploadedChunks().size() != uploadInfo.getTotalChunks()) {
throw new ServiceException("还有分片未上传完成");
}
// 合并分片
ByteArrayOutputStream mergedContent = new ByteArrayOutputStream();
for (int i = 1; i <= uploadInfo.getTotalChunks(); i++) {
String chunkKey = "chunk_data:" + uploadId + ":" + i;
byte[] chunkData = (byte[]) redisTemplate.opsForValue().get(chunkKey);
if (chunkData != null) {
mergedContent.write(chunkData);
}
}
// 上传合并后的文件
String path = generatePath(uploadInfo.getFileName());
String fileId = fileService.createFile(uploadInfo.getFileName(), path, mergedContent.toByteArray());
// 清理临时数据
cleanupChunkData(uploadId, uploadInfo.getTotalChunks());
return fileId;
}
private void cleanupChunkData(String uploadId, int totalChunks) {
// 删除上传信息
redisTemplate.delete("chunk_upload:" + uploadId);
// 删除分片数据
for (int i = 1; i <= totalChunks; i++) {
redisTemplate.delete("chunk_data:" + uploadId + ":" + i);
}
}
private int calculateTotalChunks(long fileSize, int chunkSize) {
return (int) Math.ceil((double) fileSize / chunkSize);
}
}
```
## 文件安全
### 文件类型校验
```java
@Component
public class FileSecurityValidator {
private static final Set<String> ALLOWED_IMAGE_TYPES = Set.of(
"image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp"
);
private static final Set<String> ALLOWED_DOCUMENT_TYPES = Set.of(
"application/pdf", "application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
);
private static final Set<String> DANGEROUS_EXTENSIONS = Set.of(
"exe", "bat", "cmd", "com", "pif", "scr", "vbs", "js", "jar", "php", "asp", "jsp"
);
/**
* 校验文件类型
*/
public void validateFileType(MultipartFile file, FileTypeEnum allowedType) {
String contentType = file.getContentType();
String fileName = file.getOriginalFilename();
String extension = FileUtil.extName(fileName).toLowerCase();
// 检查危险扩展名
if (DANGEROUS_EXTENSIONS.contains(extension)) {
throw new ServiceException("不允许上传此类型文件");
}
// 根据允许的类型进行校验
switch (allowedType) {
case IMAGE:
if (!ALLOWED_IMAGE_TYPES.contains(contentType)) {
throw new ServiceException("只允许上传图片文件");
}
break;
case DOCUMENT:
if (!ALLOWED_DOCUMENT_TYPES.contains(contentType)) {
throw new ServiceException("只允许上传文档文件");
}
break;
default:
// 通用校验,禁止可执行文件
break;
}
}
/**
* 校验文件大小
*/
public void validateFileSize(MultipartFile file, long maxSize) {
if (file.getSize() > maxSize) {
String maxSizeStr = FileUtil.readableFileSize(maxSize);
throw new ServiceException("文件大小不能超过 " + maxSizeStr);
}
}
/**
* 扫描文件内容
*/
public void scanFileContent(byte[] content) {
// TODO: 集成病毒扫描引擎
// 这里可以集成 ClamAV 等开源病毒扫描引擎
// 简单的恶意代码检测
String contentStr = new String(content, StandardCharsets.UTF_8);
List<String> maliciousPatterns = Arrays.asList(
"<script", "javascript:", "eval(", "exec(", "system("
);
for (String pattern : maliciousPatterns) {
if (contentStr.toLowerCase().contains(pattern)) {
throw new ServiceException("文件包含潜在恶意内容");
}
}
}
}
```
## 最佳实践
### 性能优化
1. **异步上传**: 大文件使用异步上传提升用户体验
2. **CDN 加速**: 配置 CDN 加速文件访问
3. **预签名 URL**: 使用预签名 URL 减少服务器压力
4. **缓存策略**: 合理设置文件缓存策略
5. **分片上传**: 大文件使用分片上传避免超时
### 安全考虑
1. **文件类型校验**: 严格校验上传文件类型
2. **文件大小限制**: 设置合理的文件大小限制
3. **病毒扫描**: 集成病毒扫描引擎
4. **访问控制**: 实现文件访问权限控制
5. **存储隔离**: 不同租户文件存储隔离
### 运维监控
1. **存储使用量**: 监控存储空间使用情况
2. **上传失败率**: 监控文件上传成功率
3. **访问日志**: 记录文件访问日志
4. **清理策略**: 定期清理临时和过期文件