feat(dev): 新增FTP文件存储功能

- 添加FTP文件存储相关配置项到数据库
- 实现FTP文件上传接口和控制器方法
- 集成FTP文件工具类和相关枚举类型
- 添加FTP文件配置表单页面
- 增加前端FTP文件上传功能和API调用
- 添加FTP文件下载和删除功能
- 配置FTP文件操作所需的依赖包
This commit is contained in:
17381448491
2026-07-06 14:47:46 +00:00
committed by 小诺方舟
parent b94e159e74
commit e76da2fa20
13 changed files with 430 additions and 1 deletions
+7
View File
@@ -209,6 +209,13 @@
<version>3.45.0</version>
</dependency>
<!-- commons-net -->
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
<version>3.10.0</version>
</dependency>
<!-- hutool -->
<dependency>
<groupId>cn.hutool</groupId>
+30
View File
@@ -9,8 +9,15 @@
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
import { baseRequest } from '@/utils/request'
import { convertUrl } from '@/utils/apiAdaptive'
import sysConfig from '@/config'
import tool from '@/utils/tool'
const request = (url, ...arg) => baseRequest(`/dev/file/` + url, ...arg)
const apiUrl = (url) => {
const convertedUrl = convertUrl(url)
return convertedUrl.startsWith('/api') ? convertedUrl : '/api' + convertedUrl
}
/**
* 文件
*
@@ -42,6 +49,10 @@ export default {
fileUploadMinioReturnId(data) {
return request('uploadMinioReturnId', data)
},
// FTP文件上传,返回文件id
fileUploadFtpReturnId(data) {
return request('uploadFtpReturnId', data)
},
// 本地文件上传,返回文件Url
fileUploadLocalReturnUrl(data) {
return request('uploadLocalReturnUrl', data)
@@ -58,6 +69,10 @@ export default {
fileUploadMinioReturnUrl(data) {
return request('uploadMinioReturnUrl', data)
},
// FTP文件上传,返回文件Url
fileUploadFtpReturnUrl(data) {
return request('uploadFtpReturnUrl', data)
},
// 获取文件分页列表
filePage(data) {
return request('page', data, 'get')
@@ -72,6 +87,21 @@ export default {
responseType: 'blob'
})
},
// 授权下载文件,这里要带上blob类型
fileAuthDownload(data) {
return request('authDownload', data, 'get', {
responseType: 'blob'
})
},
// 获取文件预览地址
fileDownloadUrl(record) {
if (record?.isDownloadAuth) {
const token = tool.data.get('TOKEN')
const tokenValue = token ? encodeURIComponent(sysConfig.TOKEN_PREFIX + token) : ''
return apiUrl(`/dev/file/authDownload?id=${record.id}&${sysConfig.TOKEN_NAME}=${tokenValue}`)
}
return apiUrl(`/dev/file/download?id=${record.id}`)
},
// 获取文件详情
fileDetail(data) {
return request('detail', data, 'get')
@@ -0,0 +1,117 @@
<template>
<a-spin :spinning="loadSpinning">
<a-form
ref="formRef"
:model="formData"
:rules="formRules"
layout="vertical"
:label-col="{ ...layout.labelCol, offset: 0 }"
:wrapper-col="{ ...layout.wrapperCol, offset: 0 }"
>
<a-row :gutter="8">
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<a-form-item label="FTP主机:" name="SNOWY_FILE_FTP_HOST">
<a-input v-model:value="formData.SNOWY_FILE_FTP_HOST" placeholder="请输入FTP主机" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<a-form-item label="FTP端口:" name="SNOWY_FILE_FTP_PORT">
<a-input v-model:value="formData.SNOWY_FILE_FTP_PORT" placeholder="请输入FTP端口" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<a-form-item label="FTP用户名:" name="SNOWY_FILE_FTP_USERNAME">
<a-input v-model:value="formData.SNOWY_FILE_FTP_USERNAME" placeholder="请输入FTP用户名" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<a-form-item label="FTP密码:" name="SNOWY_FILE_FTP_PASSWORD">
<a-input-password v-model:value="formData.SNOWY_FILE_FTP_PASSWORD" placeholder="请输入FTP密码" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<a-form-item label="FTP基础目录:" name="SNOWY_FILE_FTP_BASE_PATH">
<a-input v-model:value="formData.SNOWY_FILE_FTP_BASE_PATH" placeholder="请输入FTP基础目录" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<a-form-item label="FTP储存桶:" name="SNOWY_FILE_FTP_DEFAULT_BUCKET_NAME">
<a-input v-model:value="formData.SNOWY_FILE_FTP_DEFAULT_BUCKET_NAME" placeholder="请输入FTP储存桶" />
</a-form-item>
</a-col>
<a-col :xs="24" :sm="24" :md="24" :lg="24" :xl="24">
<a-form-item>
<a-space>
<a-button type="primary" :loading="submitLoading" @click="onSubmit()">保存</a-button>
<a-button @click="() => formRef.resetFields()">重置</a-button>
</a-space>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-spin>
</template>
<script setup name="ftpFileForm">
import { cloneDeep } from 'lodash-es'
import { required } from '@/utils/formRules'
import { message } from 'ant-design-vue'
import configApi from '@/api/dev/configApi'
const formRef = ref()
const formData = ref({})
const submitLoading = ref(false)
const loadSpinning = ref(true)
const param = {
category: 'FILE_FTP'
}
configApi.configList(param).then((data) => {
loadSpinning.value = false
if (data) {
data.forEach((item) => {
formData.value[item.configKey] = item.configValue
})
} else {
message.warning('表单项不存在,请初始化数据库')
}
})
const formRules = {
SNOWY_FILE_FTP_HOST: [required('请输入FTP主机')],
SNOWY_FILE_FTP_PORT: [required('请输入FTP端口')],
SNOWY_FILE_FTP_USERNAME: [required('请输入FTP用户名')],
SNOWY_FILE_FTP_PASSWORD: [required('请输入FTP密码')],
SNOWY_FILE_FTP_BASE_PATH: [required('请输入FTP基础目录')],
SNOWY_FILE_FTP_DEFAULT_BUCKET_NAME: [required('请输入FTP储存桶')]
}
const onSubmit = () => {
formRef.value
.validate()
.then(() => {
submitLoading.value = true
let submitParam = cloneDeep(formData.value)
const param = Object.entries(submitParam).map((item) => {
return {
configKey: item[0],
configValue: item[1]
}
})
configApi
.configEditForm(param)
.then(() => {})
.finally(() => {
submitLoading.value = false
})
})
.catch(() => {})
}
const layout = {
labelCol: {
span: 24
},
wrapperCol: {
span: 12
}
}
</script>
@@ -15,6 +15,9 @@
<a-tab-pane key="rustfsFile" tab="RUSTFS文件">
<rustfsFileForm />
</a-tab-pane>
<a-tab-pane key="ftpFile" tab="FTP文件">
<ftpFileForm />
</a-tab-pane>
</a-tabs>
</template>
@@ -24,5 +27,6 @@
import TencentFileForm from './tencentFileForm.vue'
import MinioFileForm from './minioFileForm.vue'
import RustfsFileForm from './rustfsFileForm.vue'
import FtpFileForm from './ftpFileForm.vue'
const activeKey = ref('localFile')
</script>
@@ -52,6 +52,17 @@
</a-upload-dragger>
</a-spin>
</a-tab-pane>
<a-tab-pane key="Ftp" tab="FTP">
<a-spin :spinning="uploadLoading">
<a-upload-dragger :custom-request="customRequestFtp" :show-upload-list="false">
<p class="ant-upload-drag-icon">
<inbox-outlined></inbox-outlined>
</p>
<p class="ant-upload-text">单击或拖动文件到此区域进行上传</p>
<p class="ant-upload-hint">支持单个上传</p>
</a-upload-dragger>
</a-spin>
</a-tab-pane>
</a-tabs>
</xn-form-container>
</template>
@@ -130,6 +141,20 @@
uploadLoading.value = false
})
}
// 上传FTP文件
const customRequestFtp = (data) => {
uploadLoading.value = true
const fileData = new FormData()
fileData.append('file', data.file)
fileApi
.fileUploadFtpReturnUrl(fileData)
.then(() => {
emit('successful')
})
.finally(() => {
uploadLoading.value = false
})
}
// 调用这个函数将子组件的一些数据和方法暴露出去
defineExpose({
openUpload
@@ -122,6 +122,26 @@ public interface DevFileApi {
**/
String storageFileWithReturnIdMinio(MultipartFile file);
/* =========FTP文件========= */
/**
* 上传文件返回Url
*
* @param file 文件
* @author lilang
* @date 2026/06/30
**/
String storageFileWithReturnUrlFtp(MultipartFile file);
/**
* 上传文件返回Id
*
* @param file 文件
* @author lilang
* @date 2026/06/30
**/
String storageFileWithReturnIdFtp(MultipartFile file);
/**
* 通过文件id查询文件详情
*
+6
View File
@@ -40,6 +40,12 @@
<artifactId>s3</artifactId>
</dependency>
<!-- FTP文件操作依赖,Hutool FTP基于Apache Commons Net -->
<dependency>
<groupId>commons-net</groupId>
<artifactId>commons-net</artifactId>
</dependency>
<!-- websocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
@@ -249,6 +249,32 @@ public class DevFileController {
return CommonResult.data(devFileService.uploadReturnUrl(DevFileEngineTypeEnum.MINIO.getValue(), file));
}
/**
* FTP文件上传,返回文件id
*
* @author lilang
* @date 2026/06/30
**/
@Operation(summary = "上传FTP文件返回id")
@CommonLog("上传FTP文件返回id")
@PostMapping("/dev/file/uploadFtpReturnId")
public CommonResult<String> uploadFtpReturnId(@RequestPart("file") MultipartFile file) {
return CommonResult.data(devFileService.uploadReturnId(DevFileEngineTypeEnum.FTP.getValue(), file));
}
/**
* FTP文件上传,返回文件Url
*
* @author lilang
* @date 2026/06/30
**/
@Operation(summary = "上传FTP文件返回url")
@CommonLog("上传FTP文件返回url")
@PostMapping("/dev/file/uploadFtpReturnUrl")
public CommonResult<String> uploadFtpReturnUrl(@RequestPart("file") MultipartFile file) {
return CommonResult.data(devFileService.uploadReturnUrl(DevFileEngineTypeEnum.FTP.getValue(), file));
}
/**
* 获取文件分页列表
*
@@ -36,7 +36,10 @@ public enum DevFileEngineTypeEnum {
MINIO("MINIO"),
/** RUSTFS */
RUSTFS("RUSTFS");
RUSTFS("RUSTFS"),
/** FTP */
FTP("FTP");
private final String value;
@@ -96,6 +96,16 @@ public class DevFileApiProvider implements DevFileApi {
return devFileService.uploadReturnId(DevFileEngineTypeEnum.MINIO.getValue(), file);
}
@Override
public String storageFileWithReturnUrlFtp(MultipartFile file) {
return devFileService.uploadReturnUrl(DevFileEngineTypeEnum.FTP.getValue(), file);
}
@Override
public String storageFileWithReturnIdFtp(MultipartFile file) {
return devFileService.uploadReturnId(DevFileEngineTypeEnum.FTP.getValue(), file);
}
@Override
public JSONObject getFileInfoById(String id) {
return Optional.ofNullable(devFileService.getById(id))
@@ -160,6 +160,8 @@ public class DevFileServiceImpl extends ServiceImpl<DevFileMapper, DevFile> impl
return;
}
CommonDownloadUtil.download(devFile.getName(), IoUtil.readBytes(FileUtil.getInputStream(file)), response);
} else if(devFile.getEngine().equals(DevFileEngineTypeEnum.FTP.getValue())) {
CommonDownloadUtil.download(devFile.getName(), DevFileFtpUtil.getFileBytes(devFile.getBucket(), devFile.getFileKey()), response);
} else {
String storagePath = devFile.getStoragePath();
CommonDownloadUtil.download(devFile.getName(), HttpUtil.downloadBytes(storagePath), response);
@@ -193,6 +195,8 @@ public class DevFileServiceImpl extends ServiceImpl<DevFileMapper, DevFile> impl
DevFileMinIoUtil.deleteFile(bucketName, fileKey);
} else if (DevFileEngineTypeEnum.RUSTFS.getValue().equals(engine)) {
DevFileRustFsUtil.deleteFile(bucketName, fileKey);
} else if (DevFileEngineTypeEnum.FTP.getValue().equals(engine)) {
DevFileFtpUtil.deleteFile(bucketName, fileKey);
} else {
log.error("未知存储引擎:{}", engine);
}
@@ -253,6 +257,11 @@ public class DevFileServiceImpl extends ServiceImpl<DevFileMapper, DevFile> impl
// 使用RUSTFS默认配置的bucketName
bucketName = DevFileRustFsUtil.getDefaultBucketName();
storageUrl = DevFileRustFsUtil.storageFileWithReturnUrl(bucketName, fileKey, file);
} else if(engine.equals(DevFileEngineTypeEnum.FTP.getValue())) {
// 使用FTP默认配置的bucketName
bucketName = DevFileFtpUtil.getDefaultBucketName();
storageUrl = DevFileFtpUtil.storageFileWithReturnUrl(bucketName, fileKey, file);
} else {
throw new CommonException("不支持的文件引擎:{}", engine);
}
@@ -0,0 +1,164 @@
/*
* Copyright [2022] [https://www.xiaonuo.vip]
*
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
*
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
package vip.xiaonuo.dev.modular.file.util;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.ftp.Ftp;
import cn.hutool.extra.ftp.FtpMode;
import cn.hutool.extra.spring.SpringUtil;
import org.springframework.web.multipart.MultipartFile;
import vip.xiaonuo.common.exception.CommonException;
import vip.xiaonuo.dev.api.DevConfigApi;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
/**
* FTP文件工具类
*
* @author lilang
* @date 2026/06/30
*/
public class DevFileFtpUtil {
private static final String SNOWY_FILE_FTP_HOST_KEY = "SNOWY_FILE_FTP_HOST";
private static final String SNOWY_FILE_FTP_PORT_KEY = "SNOWY_FILE_FTP_PORT";
private static final String SNOWY_FILE_FTP_USERNAME_KEY = "SNOWY_FILE_FTP_USERNAME";
private static final String SNOWY_FILE_FTP_PASSWORD_KEY = "SNOWY_FILE_FTP_PASSWORD";
private static final String SNOWY_FILE_FTP_BASE_PATH_KEY = "SNOWY_FILE_FTP_BASE_PATH";
private static final String SNOWY_FILE_FTP_DEFAULT_BUCKET_NAME_KEY = "SNOWY_FILE_FTP_DEFAULT_BUCKET_NAME";
private static Ftp initClient() {
DevConfigApi devConfigApi = SpringUtil.getBean(DevConfigApi.class);
String host = devConfigApi.getValueByKey(SNOWY_FILE_FTP_HOST_KEY);
if (ObjectUtil.isEmpty(host)) {
throw new CommonException("FTP文件操作客户端未正确配置:host为空");
}
String portStr = devConfigApi.getValueByKey(SNOWY_FILE_FTP_PORT_KEY);
if (ObjectUtil.isEmpty(portStr)) {
throw new CommonException("FTP文件操作客户端未正确配置:port为空");
}
Integer port;
try {
port = Integer.valueOf(portStr);
} catch (Exception e) {
throw new CommonException("FTP文件操作客户端未正确配置:port格式错误");
}
String username = devConfigApi.getValueByKey(SNOWY_FILE_FTP_USERNAME_KEY);
if (ObjectUtil.isEmpty(username)) {
throw new CommonException("FTP文件操作客户端未正确配置:username为空");
}
String password = devConfigApi.getValueByKey(SNOWY_FILE_FTP_PASSWORD_KEY);
if (ObjectUtil.isEmpty(password)) {
throw new CommonException("FTP文件操作客户端未正确配置:password为空");
}
try {
return new Ftp(host, port, username, password, StandardCharsets.UTF_8, null, null, FtpMode.Passive);
} catch (Exception e) {
throw new CommonException("FTP文件操作客户端连接失败:{}", e.getMessage());
}
}
public static String getDefaultBucketName() {
DevConfigApi devConfigApi = SpringUtil.getBean(DevConfigApi.class);
String defaultBucketName = devConfigApi.getValueByKey(SNOWY_FILE_FTP_DEFAULT_BUCKET_NAME_KEY);
if (ObjectUtil.isEmpty(defaultBucketName)) {
throw new CommonException("FTP文件操作客户端未正确配置:defaultBucketName为空");
}
return defaultBucketName;
}
public static void storageFile(String bucketName, String key, MultipartFile multipartFile) {
try (InputStream inputStream = multipartFile.getInputStream()) {
storageFile(bucketName, key, inputStream);
} catch (IOException e) {
throw new CommonException("获取文件流异常,名称是:{}", multipartFile.getName());
}
}
public static void storageFile(String bucketName, String key, InputStream inputStream) {
Ftp ftp = initClient();
try {
String dir = getRemoteDir(bucketName, key);
String fileName = StrUtil.subAfter(key, StrUtil.SLASH, true);
boolean success = ftp.upload(dir, fileName, inputStream);
if (!success) {
throw new CommonException("FTP文件上传失败:{}", key);
}
} finally {
IoUtil.close(ftp);
}
}
public static String storageFileWithReturnUrl(String bucketName, String key, MultipartFile multipartFile) {
storageFile(bucketName, key, multipartFile);
return getFileAuthUrl(bucketName, key);
}
public static byte[] getFileBytes(String bucketName, String key) {
Ftp ftp = initClient();
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
String dir = getRemoteDir(bucketName, key);
String fileName = StrUtil.subAfter(key, StrUtil.SLASH, true);
ftp.download(dir, fileName, outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new CommonException("FTP文件下载失败:{}", e.getMessage());
} finally {
IoUtil.close(ftp);
}
}
public static String getFileAuthUrl(String bucketName, String key) {
return "ftp://" + getHost() + normalizePath(StrUtil.SLASH + getBasePath() + StrUtil.SLASH + bucketName + StrUtil.SLASH + key);
}
public static void deleteFile(String bucketName, String key) {
Ftp ftp = initClient();
try {
String path = normalizePath(getBasePath() + StrUtil.SLASH + bucketName + StrUtil.SLASH + key);
if (!ftp.delFile(path)) {
throw new CommonException("FTP文件删除失败:{}", path);
}
} finally {
IoUtil.close(ftp);
}
}
private static String getRemoteDir(String bucketName, String key) {
String dir = StrUtil.subBefore(key, StrUtil.SLASH, true);
return normalizePath(getBasePath() + StrUtil.SLASH + bucketName + StrUtil.SLASH + dir);
}
private static String getHost() {
DevConfigApi devConfigApi = SpringUtil.getBean(DevConfigApi.class);
return devConfigApi.getValueByKey(SNOWY_FILE_FTP_HOST_KEY);
}
private static String getBasePath() {
DevConfigApi devConfigApi = SpringUtil.getBean(DevConfigApi.class);
return ObjectUtil.defaultIfEmpty(devConfigApi.getValueByKey(SNOWY_FILE_FTP_BASE_PATH_KEY), StrUtil.EMPTY);
}
private static String normalizePath(String path) {
String normalized = path.replace("\\", StrUtil.SLASH);
while (normalized.contains("//")) {
normalized = normalized.replace("//", StrUtil.SLASH);
}
return normalized;
}
}
@@ -0,0 +1,8 @@
INSERT INTO `DEV_CONFIG` VALUES ('1908870094824755321', 'SNOWY_FILE_FTP_HOST', '127.0.0.1', 'FILE_FTP', 'FTP文件主机', 189, NULL, 'NOT_DELETE', NULL, NULL, NULL, NULL);
INSERT INTO `DEV_CONFIG` VALUES ('1908870094824755322', 'SNOWY_FILE_FTP_PORT', '21', 'FILE_FTP', 'FTP文件端口', 190, NULL, 'NOT_DELETE', NULL, NULL, NULL, NULL);
INSERT INTO `DEV_CONFIG` VALUES ('1908870094824755323', 'SNOWY_FILE_FTP_USERNAME', 'ftpuser', 'FILE_FTP', 'FTP文件用户名', 191, NULL, 'NOT_DELETE', NULL, NULL, NULL, NULL);
INSERT INTO `DEV_CONFIG` VALUES ('1908870094824755324', 'SNOWY_FILE_FTP_PASSWORD', 'ftppassword', 'FILE_FTP', 'FTP文件密码', 192, NULL, 'NOT_DELETE', NULL, NULL, NULL, NULL);
INSERT INTO `DEV_CONFIG` VALUES ('1908870094824755325', 'SNOWY_FILE_FTP_BASE_PATH', '/snowy', 'FILE_FTP', 'FTP文件基础目录', 193, NULL, 'NOT_DELETE', NULL, NULL, NULL, NULL);
INSERT INTO `DEV_CONFIG` VALUES ('1908870094824755326', 'SNOWY_FILE_FTP_DEFAULT_BUCKET_NAME', 'defaultbucket', 'FILE_FTP', 'FTP文件默认存储桶', 194, NULL, 'NOT_DELETE', NULL, NULL, NULL, NULL);
INSERT INTO `DEV_DICT` VALUES ('1908870094824755331', '1554726376265744386', 'FTP', 'FTP', 'blue', 'FTPStorage', 'FRM', 50, NULL, 'NOT_DELETE', NULL, NULL, NULL, NULL);