浏览代码

AWS S3 上传下载功能

zhangzl 2 周之前
父节点
当前提交
a185a21eef

+ 108 - 0
new-react-admin-ui/src/components/S3DownloadButton.tsx

@@ -0,0 +1,108 @@
+import React, { useState } from 'react';
+import {
+  Button,
+  TextField,
+  Box,
+  Typography,
+  Alert,
+} from '@mui/material';
+import { Download } from '@mui/icons-material';
+
+interface S3DownloadButtonProps {
+  label?: string;
+}
+
+const S3DownloadButton: React.FC<S3DownloadButtonProps> = ({ label = 'S3下载' }) => {
+  const [fileName, setFileName] = useState('');
+  const [error, setError] = useState('');
+  const [success, setSuccess] = useState('');
+
+  const handleDownload = async () => {
+    if (!fileName.trim()) {
+      setError('请输入文件名');
+      return;
+    }
+
+    try {
+      setError('');
+      setSuccess('');
+
+      // 构建S3文件URL
+      const fileUrl = `http://172.14.3.142:4566/my-bucket/uploads/${fileName.trim()}`;
+      
+      // 方法1:直接使用a标签下载(如果S3配置了正确的Content-Disposition)
+      const link = document.createElement('a');
+      link.href = fileUrl;
+      link.download = fileName.trim();
+      link.target = '_blank';
+      
+      // 方法2:如果方法1不行,尝试添加时间戳避免缓存问题
+      // const timestamp = new Date().getTime();
+      // const downloadUrl = `${fileUrl}?t=${timestamp}`;
+      // link.href = downloadUrl;
+      
+      // 添加到DOM并触发点击
+      document.body.appendChild(link);
+      link.click();
+      document.body.removeChild(link);
+
+      setSuccess(`文件 ${fileName} 下载成功!`);
+      setFileName('');
+    } catch (err) {
+      setError(`下载失败: ${err instanceof Error ? err.message : '未知错误'}`);
+    }
+  };
+
+  const handleKeyPress = (event: React.KeyboardEvent) => {
+    if (event.key === 'Enter') {
+      handleDownload();
+    }
+  };
+
+  return (
+    <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, p: 2, border: '1px solid #e0e0e0', borderRadius: 1 }}>
+      <Typography variant="h6" gutterBottom>
+        S3文件下载
+      </Typography>
+      
+      <Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
+        <TextField
+          label="文件名"
+          variant="outlined"
+          size="small"
+          value={fileName}
+          onChange={(e) => setFileName(e.target.value)}
+          onKeyPress={handleKeyPress}
+          placeholder="例如: 20251125_150429_f43f9a67.txt"
+          sx={{ minWidth: 300 }}
+        />
+        <Button
+          variant="contained"
+          startIcon={<Download />}
+          onClick={handleDownload}
+          disabled={!fileName.trim()}
+        >
+          {label}
+        </Button>
+      </Box>
+      
+      {error && (
+        <Alert severity="error" onClose={() => setError('')}>
+          {error}
+        </Alert>
+      )}
+      
+      {success && (
+        <Alert severity="success" onClose={() => setSuccess('')}>
+          {success}
+        </Alert>
+      )}
+      
+      <Typography variant="body2" color="text.secondary">
+        提示:输入文件名后点击下载按钮,文件将从S3存储桶下载到本地
+      </Typography>
+    </Box>
+  );
+};
+
+export default S3DownloadButton;

+ 72 - 0
new-react-admin-ui/src/components/S3UploadButton.tsx

@@ -0,0 +1,72 @@
+import { useState } from 'react';
+import { Button, useNotify, useRefresh } from 'react-admin';
+import CloudUploadIcon from '@mui/icons-material/CloudUpload';
+
+interface S3UploadButtonProps {
+  label?: string;
+}
+
+const S3UploadButton = ({ label = "上传到S3" }: S3UploadButtonProps) => {
+  const [uploading, setUploading] = useState(false);
+  const notify = useNotify();
+  const refresh = useRefresh();
+
+  const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+    if (!file) return;
+
+    setUploading(true);
+    
+    const formData = new FormData();
+    formData.append('file', file);
+
+    try {
+      const response = await fetch('/common/upload/s3', {
+        method: 'POST',
+        body: formData,
+      });
+
+      const result = await response.json();
+
+      if (response.ok) {
+        notify(`文件上传成功!URL: ${result.url}`, { type: 'success' });
+        console.log('S3上传结果:', result);
+      } else {
+        notify(`文件上传失败: ${result.message || result.msg || '未知错误'}`, { type: 'error' });
+      }
+    } catch (error) {
+      console.error('S3上传错误:', error);
+      notify('文件上传失败,请检查网络连接', { type: 'error' });
+    } finally {
+      setUploading(false);
+      // 清空input值,允许重复选择同一文件
+      event.target.value = '';
+    }
+  };
+
+  return (
+    <div style={{ display: 'inline-block', marginRight: '10px' }}>
+      <input
+        type="file"
+        id="s3-upload-input"
+        style={{ display: 'none' }}
+        onChange={handleFileUpload}
+        disabled={uploading}
+      />
+      <label htmlFor="s3-upload-input">
+        <Button
+          component="span"
+          label={label}
+          disabled={uploading}
+          startIcon={<CloudUploadIcon />}
+          variant="outlined"
+          color="primary"
+        >
+          {uploading ? '上传中...' : label}
+        </Button>
+      </label>
+    </div>
+  );
+};
+
+export default S3UploadButton;

+ 15 - 0
new-react-admin-ui/src/pages/users/UserList.tsx

@@ -10,7 +10,12 @@ import {
   Filter,
   SelectInput,
   FunctionField,
+  TopToolbar,
+  CreateButton,
+  ExportButton,
 } from 'react-admin';
+import S3UploadButton from '../../components/S3UploadButton';
+import S3DownloadButton from '../../components/S3DownloadButton';
 
 const UserFilter = (props: any) => (
   <Filter {...props}>
@@ -28,12 +33,22 @@ const UserFilter = (props: any) => (
   </Filter>
 );
 
+const UserListActions = () => (
+  <TopToolbar>
+    <S3UploadButton label="S3上传" />
+    <S3DownloadButton label="S3下载" />
+    <CreateButton />
+    <ExportButton />
+  </TopToolbar>
+);
+
 export const UserList = (props: any) => (
   <List
     {...props}
     filters={<UserFilter />}
     sort={{ field: 'createTime', order: 'DESC' }}
     perPage={25}
+    actions={<UserListActions />}
   >
     <Datagrid rowClick="edit">
       <TextField source="userId" label="用户ID" />

+ 4 - 0
new-react-admin-ui/vite.config.ts

@@ -23,6 +23,10 @@ export default defineConfig({
         target: 'http://localhost:8080',
         changeOrigin: true
       },
+      '/common': {
+        target: 'http://localhost:8080',
+        changeOrigin: true
+      },
       '/api': {
         target: 'http://localhost:8080',
         changeOrigin: true,

+ 7 - 0
pom.xml

@@ -213,6 +213,13 @@
                 <artifactId>ses</artifactId>
                 <version>2.21.10</version>
             </dependency>
+            
+            <!-- AWS SDK for S3 -->
+            <dependency>
+                <groupId>software.amazon.awssdk</groupId>
+                <artifactId>s3</artifactId>
+                <version>2.21.10</version>
+            </dependency>
 
             <!-- Lombok -->
             <dependency>

+ 6 - 0
ruoyi-admin/pom.xml

@@ -65,6 +65,12 @@
             <artifactId>ses</artifactId>
         </dependency>
 
+        <!-- AWS S3 -->
+        <dependency>
+            <groupId>software.amazon.awssdk</groupId>
+            <artifactId>s3</artifactId>
+        </dependency>
+
         <!-- Lombok -->
         <dependency>
             <groupId>org.projectlombok</groupId>

+ 30 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/controller/common/CommonController.java

@@ -20,6 +20,8 @@ import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.common.utils.file.FileUploadUtils;
 import com.ruoyi.common.utils.file.FileUtils;
 import com.ruoyi.framework.config.ServerConfig;
+import com.ruoyi.web.core.service.AwsS3FileService;
+import com.ruoyi.common.annotation.Anonymous;
 
 /**
  * 通用请求处理
@@ -35,6 +37,9 @@ public class CommonController
     @Autowired
     private ServerConfig serverConfig;
 
+    @Autowired
+    private AwsS3FileService awsS3FileService;
+
     private static final String FILE_DELIMETER = ",";
 
     /**
@@ -160,4 +165,29 @@ public class CommonController
             log.error("下载文件失败", e);
         }
     }
+
+    /**
+     * S3文件上传请求
+     */
+    @Anonymous
+    @PostMapping("/upload/s3")
+    public AjaxResult uploadFileToS3(MultipartFile file) throws Exception
+    {
+        try
+        {
+            // 使用S3服务上传文件
+            String fileUrl = awsS3FileService.uploadFile(file);
+            
+            AjaxResult ajax = AjaxResult.success();
+            ajax.put("url", fileUrl);
+            ajax.put("fileName", file.getOriginalFilename());
+            ajax.put("message", "文件已成功上传到S3存储");
+            return ajax;
+        }
+        catch (Exception e)
+        {
+            log.error("S3文件上传失败", e);
+            return AjaxResult.error("S3文件上传失败: " + e.getMessage());
+        }
+    }
 }

+ 134 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/core/config/AwsS3Config.java

@@ -0,0 +1,134 @@
+package com.ruoyi.web.core.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.services.s3.S3Client;
+
+/**
+ * AWS S3文件存储配置类
+ * 
+ * @author ruoyi
+ */
+@Configuration
+@ConfigurationProperties(prefix = "aws.s3")
+public class AwsS3Config {
+    
+    /**
+     * AWS区域
+     */
+    private String region;
+    
+    /**
+     * AWS访问密钥ID
+     */
+    private String accessKeyId;
+    
+    /**
+     * AWS秘密访问密钥
+     */
+    private String secretAccessKey;
+    
+    /**
+     * 存储桶名称
+     */
+    private String bucketName;
+    
+    /**
+     * 文件存储路径前缀
+     */
+    private String pathPrefix;
+    
+    /**
+     * 文件访问URL前缀
+     */
+    private String urlPrefix;
+    
+    /**
+     * 端点URL (用于LocalStack等本地开发环境)
+     */
+    private String endpointUrl;
+    
+    /**
+     * 创建S3客户端
+     * 
+     * @return S3Client实例
+     */
+    @Bean
+    public S3Client s3Client() {
+        AwsBasicCredentials awsCreds = AwsBasicCredentials.create(accessKeyId, secretAccessKey);
+        
+        var builder = S3Client.builder()
+                .region(Region.of(region))
+                .credentialsProvider(StaticCredentialsProvider.create(awsCreds));
+        
+        // 如果配置了端点URL,则使用自定义端点(用于LocalStack)
+        if (endpointUrl != null && !endpointUrl.trim().isEmpty()) {
+            builder.endpointOverride(java.net.URI.create(endpointUrl));
+            // 对于LocalStack,需要禁用路径样式访问
+            builder.forcePathStyle(true);
+        }
+        
+        return builder.build();
+    }
+    
+    // Getter和Setter方法
+    public String getRegion() {
+        return region;
+    }
+    
+    public void setRegion(String region) {
+        this.region = region;
+    }
+    
+    public String getAccessKeyId() {
+        return accessKeyId;
+    }
+    
+    public void setAccessKeyId(String accessKeyId) {
+        this.accessKeyId = accessKeyId;
+    }
+    
+    public String getSecretAccessKey() {
+        return secretAccessKey;
+    }
+    
+    public void setSecretAccessKey(String secretAccessKey) {
+        this.secretAccessKey = secretAccessKey;
+    }
+    
+    public String getBucketName() {
+        return bucketName;
+    }
+    
+    public void setBucketName(String bucketName) {
+        this.bucketName = bucketName;
+    }
+    
+    public String getPathPrefix() {
+        return pathPrefix;
+    }
+    
+    public void setPathPrefix(String pathPrefix) {
+        this.pathPrefix = pathPrefix;
+    }
+    
+    public String getUrlPrefix() {
+        return urlPrefix;
+    }
+    
+    public void setUrlPrefix(String urlPrefix) {
+        this.urlPrefix = urlPrefix;
+    }
+    
+    public String getEndpointUrl() {
+        return endpointUrl;
+    }
+    
+    public void setEndpointUrl(String endpointUrl) {
+        this.endpointUrl = endpointUrl;
+    }
+}

+ 215 - 0
ruoyi-admin/src/main/java/com/ruoyi/web/core/service/AwsS3FileService.java

@@ -0,0 +1,215 @@
+package com.ruoyi.web.core.service;
+
+import com.ruoyi.web.core.config.AwsS3Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+import software.amazon.awssdk.core.sync.RequestBody;
+import software.amazon.awssdk.services.s3.S3Client;
+import software.amazon.awssdk.services.s3.model.PutObjectRequest;
+import software.amazon.awssdk.services.s3.model.DeleteObjectRequest;
+import software.amazon.awssdk.services.s3.model.GetObjectRequest;
+import software.amazon.awssdk.services.s3.model.HeadObjectRequest;
+import software.amazon.awssdk.services.s3.model.S3Exception;
+import software.amazon.awssdk.services.s3.presigner.S3Presigner;
+import software.amazon.awssdk.services.s3.presigner.model.GetObjectPresignRequest;
+import java.time.Duration;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Paths;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.UUID;
+
+/**
+ * AWS S3文件存储服务
+ * 
+ * @author ruoyi
+ */
+@Service
+public class AwsS3FileService {
+    
+    private static final Logger log = LoggerFactory.getLogger(AwsS3FileService.class);
+    
+    @Autowired
+    private S3Client s3Client;
+    
+    @Autowired
+    private AwsS3Config awsS3Config;
+    
+    /**
+     * 上传文件到S3
+     * 
+     * @param file 上传的文件
+     * @return 文件访问URL
+     * @throws IOException
+     */
+    public String uploadFile(MultipartFile file) throws IOException {
+        try {
+            // 验证文件大小(最大100MB)
+            if (file.getSize() > 100 * 1024 * 1024) {
+                throw new IOException("文件大小超过限制(最大100MB)");
+            }
+            
+            String fileName = generateFileName(file.getOriginalFilename());
+            String key = awsS3Config.getPathPrefix() + fileName;
+            
+            // 设置Content-Disposition头,强制浏览器下载而非预览
+            String contentDisposition = "attachment; filename=\"" + file.getOriginalFilename() + "\"";
+            
+            PutObjectRequest putObjectRequest = PutObjectRequest.builder()
+                    .bucket(awsS3Config.getBucketName())
+                    .key(key)
+                    .contentType(file.getContentType())
+                    .contentDisposition(contentDisposition)
+                    .build();
+            
+            try (InputStream inputStream = file.getInputStream()) {
+                s3Client.putObject(putObjectRequest, RequestBody.fromInputStream(inputStream, file.getSize()));
+            }
+            
+            String fileUrl = awsS3Config.getUrlPrefix() + key;
+            log.info("文件上传成功: {}, URL: {}", fileName, fileUrl);
+            
+            return fileUrl;
+            
+        } catch (S3Exception e) {
+            String errorMsg = "S3文件上传失败: " + e.awsErrorDetails().errorMessage();
+            log.error(errorMsg);
+            throw new IOException(errorMsg);
+        } catch (Exception e) {
+            log.error("文件上传失败", e);
+            throw new IOException("文件上传失败: " + e.getMessage());
+        }
+    }
+    
+    /**
+     * 删除S3中的文件
+     * 
+     * @param fileUrl 文件URL
+     * @return 是否删除成功
+     */
+    public boolean deleteFile(String fileUrl) {
+        try {
+            // 从URL中提取key
+            String key = extractKeyFromUrl(fileUrl);
+            
+            DeleteObjectRequest deleteObjectRequest = DeleteObjectRequest.builder()
+                    .bucket(awsS3Config.getBucketName())
+                    .key(key)
+                    .build();
+            
+            s3Client.deleteObject(deleteObjectRequest);
+            log.info("文件删除成功: {}", key);
+            return true;
+            
+        } catch (S3Exception e) {
+            log.error("S3文件删除失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            log.error("文件删除失败", e);
+            return false;
+        }
+    }
+    
+    /**
+     * 检查文件是否存在
+     * 
+     * @param fileUrl 文件URL
+     * @return 是否存在
+     */
+    public boolean fileExists(String fileUrl) {
+        try {
+            String key = extractKeyFromUrl(fileUrl);
+            
+            HeadObjectRequest headObjectRequest = HeadObjectRequest.builder()
+                    .bucket(awsS3Config.getBucketName())
+                    .key(key)
+                    .build();
+            
+            // 使用headObject检查文件存在性,避免下载文件内容
+            s3Client.headObject(headObjectRequest);
+            return true;
+            
+        } catch (S3Exception e) {
+            if ("NoSuchKey".equals(e.awsErrorDetails().errorCode()) || 
+                "404 Not Found".equals(e.awsErrorDetails().errorMessage())) {
+                return false;
+            }
+            log.error("检查文件存在性失败: {}", e.awsErrorDetails().errorMessage());
+            return false;
+        } catch (Exception e) {
+            log.error("检查文件存在性失败", e);
+            return false;
+        }
+    }
+    
+    /**
+     * 生成唯一的文件名
+     * 
+     * @param originalFileName 原始文件名
+     * @return 生成的文件名
+     */
+    private String generateFileName(String originalFileName) {
+        String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss"));
+        String uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+        String extension = "";
+        
+        if (originalFileName != null && originalFileName.contains(".")) {
+            extension = originalFileName.substring(originalFileName.lastIndexOf("."));
+        }
+        
+        return timestamp + "_" + uuid + extension;
+    }
+    
+    /**
+     * 从文件URL中提取S3 key
+     * 
+     * @param fileUrl 文件URL
+     * @return S3 key
+     */
+    private String extractKeyFromUrl(String fileUrl) {
+        if (fileUrl.startsWith(awsS3Config.getUrlPrefix())) {
+            return fileUrl.substring(awsS3Config.getUrlPrefix().length());
+        }
+        
+        // 如果URL不匹配前缀,尝试从完整路径中提取
+        String bucketPart = awsS3Config.getBucketName() + "/";
+        int bucketIndex = fileUrl.indexOf(bucketPart);
+        if (bucketIndex != -1) {
+            return fileUrl.substring(bucketIndex + bucketPart.length());
+        }
+        
+        // 如果无法提取,返回原URL(可能是相对路径)
+        return fileUrl;
+    }
+    
+    /**
+     * 获取文件下载URL(预签名URL,适用于私有文件)
+     * 
+     * @param fileUrl 文件URL
+     * @param expirationMinutes 过期时间(分钟)
+     * @return 预签名URL
+     */
+    public String getDownloadUrl(String fileUrl, int expirationMinutes) {
+        try {
+            String key = extractKeyFromUrl(fileUrl);
+            
+            // 对于LocalStack,直接返回公开访问URL
+            if (awsS3Config.getEndpointUrl() != null && !awsS3Config.getEndpointUrl().trim().isEmpty()) {
+                return awsS3Config.getUrlPrefix() + key;
+            }
+            
+            // 对于真实的AWS S3,这里可以生成预签名URL
+            // 由于LocalStack不需要预签名,我们直接返回公开URL
+            return awsS3Config.getUrlPrefix() + key;
+            
+        } catch (Exception e) {
+            log.error("生成下载URL失败", e);
+            return fileUrl;
+        }
+    }
+}

+ 19 - 2
ruoyi-admin/src/main/resources/application.yml

@@ -109,12 +109,29 @@ aws:
     access-key-id: test
     # AWS秘密访问密钥 (LocalStack使用任意值)
     secret-access-key: test
-    # 发件人邮箱 (LocalStack中任意邮箱都可用)
-    from-email: sender@example.com
+    # 发件人邮箱 (请替换为已验证的邮箱地址)
+    from-email: admin@yourdomain.com
     # 邮件编码
     charset: UTF-8
     # LocalStack端点URL
     endpoint-url: http://172.14.3.142:4566
+  
+  # AWS S3文件存储配置
+  s3:
+    # AWS区域 (与SES保持一致)
+    region: us-east-1
+    # AWS访问密钥ID (LocalStack使用任意值)
+    access-key-id: test
+    # AWS秘密访问密钥 (LocalStack使用任意值)
+    secret-access-key: test
+    # 存储桶名称 (LocalStack中任意名称都可用)
+    bucket-name: my-bucket
+    # 文件存储路径前缀
+    path-prefix: uploads/
+    # 文件访问URL前缀 (用于生成文件访问链接)
+    url-prefix: http://172.14.3.142:4566/my-bucket/
+    # LocalStack端点URL
+    endpoint-url: http://172.14.3.142:4566
 
 # MyBatis配置
 mybatis: