Spring Boot 4 邮件发送与文件上传下载:常见业务场景实战(含大文件与对象存储)

本篇《Spring Boot 4 学习从入门到大神》邮件通知、文件上传下载是几乎所有后台系统的刚需。本文将系统讲解 Spring Boot 4 邮件整合、模板邮件、文件上传下载、大小与格式限制,并延伸到大文件分片上传、断点续传与 MinIO 对象存储,帮你覆盖从简单到复杂的真实业务场景。


一、邮件发送:从简单到模板化

1️⃣ 引入依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-mail</artifactId>
</dependency>

<!-- 模板邮件(可选) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

2️⃣ 邮箱配置(application.yml)

QQ 邮箱​ 为例(其他邮箱类似):

spring:
  mail:
    host: smtp.qq.com
    port: 587
    username: 123456@qq.com
    password: xxxx              # 邮箱授权码,不是登录密码
    protocol: smtp
    default-encoding: UTF-8
    properties:
      mail:
        smtp:
          auth: true
          starttls:
            enable: true
            required: true
          ssl:
            enable: false

📌 授权码获取:

  • QQ 邮箱 → 设置 → 账号 → 开启 POP3/SMTP → 获取授权码

3️⃣ 发送简单文本邮件

@Service
public class MailService {

    @Autowired
    private JavaMailSender mailSender;

    @Value("${spring.mail.username}")
    private String from;

    public void sendSimpleMail(String to, String subject, String content) {
        SimpleMailMessage message = new SimpleMailMessage();
        message.setFrom(from);
        message.setTo(to);
        message.setSubject(subject);
        message.setText(content);
        mailSender.send(message);
    }
}
mailService.sendSimpleMail(
        "test@test.com",
        "测试邮件",
        "这是一封来自 Spring Boot 4 的测试邮件"
);

4️⃣ 发送 HTML 邮件

public void sendHtmlMail(String to, String subject, String htmlContent) throws MessagingException {
    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

    helper.setFrom(from);
    helper.setTo(to);
    helper.setSubject(subject);
    helper.setText(htmlContent, true); // true = HTML

    mailSender.send(message);
}
String html = "<h1>欢迎注册</h1><p>请点击链接激活账号:<a href='#'>激活</a></p>";
sendHtmlMail("test@test.com", "账号激活", html);

5️⃣ 发送带附件的邮件

public void sendAttachmentMail(String to, String subject, String content, String filePath)
        throws MessagingException {

    MimeMessage message = mailSender.createMimeMessage();
    MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");

    helper.setFrom(from);
    helper.setTo(to);
    helper.setSubject(subject);
    helper.setText(content, true);

    FileSystemResource file = new FileSystemResource(new File(filePath));
    helper.addAttachment("附件.pdf", file);

    mailSender.send(message);
}

6️⃣ 模板邮件(Thymeleaf,生产推荐 ⭐)

模板:templates/mail/welcome.html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>
<h1>欢迎您,<span th:text="${username}"></span>!</h1>
<p>您的注册邮箱是:<span th:text="${email}"></span></p>
<p>请点击以下链接激活账号:</p>
<a th:href="${activeUrl}">激活账号</a>
</body>
</html>
发送模板邮件
@Service
public class MailService {

    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private SpringTemplateEngine templateEngine;

    public void sendTemplateMail(String to, String username, String email, String activeUrl)
            throws MessagingException {

        Context context = new Context();
        context.setVariable("username", username);
        context.setVariable("email", email);
        context.setVariable("activeUrl", activeUrl);

        String content = templateEngine.process("mail/welcome", context);

        MimeMessage message = mailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8");
        helper.setFrom(from);
        helper.setTo(to);
        helper.setSubject("账号激活邮件");
        helper.setText(content, true);

        mailSender.send(message);
    }
}

模板邮件优点:

  • 样式可控
  • 逻辑与视图分离
  • 支持复杂业务数据

7️⃣ 异步发送邮件(强烈推荐)

@Async("taskExecutor")
public CompletableFuture<Void> sendTemplateMailAsync(...) throws MessagingException {
    sendTemplateMail(...);
    return CompletableFuture.completedFuture(null);
}

📌 避免邮件发送阻塞主流程(如注册接口)。


二、文件上传:从基础到安全

1️⃣ 基础文件上传(MultipartFile)

@RestController
@RequestMapping("/files")
public class FileController {

    @PostMapping("/upload")
    public Result<?> upload(@RequestParam("file") MultipartFile file) throws IOException {

        String originalFilename = file.getOriginalFilename();
        String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
        String fileName = UUID.randomUUID() + suffix;

        String uploadDir = System.getProperty("user.dir") + "/uploads/";
        File dest = new File(uploadDir + fileName);
        if (!dest.getParentFile().exists()) {
            dest.getParentFile().mkdirs();
        }

        file.transferTo(dest);

        return Result.success(Map.of(
                "fileName", fileName,
                "originalName", originalFilename,
                "size", file.getSize()
        ));
    }
}

2️⃣ 文件上传配置(大小限制)

spring:
  servlet:
    multipart:
      enabled: true
      max-file-size: 50MB        # 单文件大小
      max-request-size: 100MB    # 单次请求总大小
      file-size-threshold: 2MB   # 超过则写入磁盘

3️⃣ 文件类型校验(安全必做 ⭐)

private static final List<String> ALLOWED_TYPES =
        Arrays.asList("jpg", "jpeg", "png", "pdf", "doc", "docx");

private boolean isAllowed(String filename) {
    String suffix = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
    return ALLOWED_TYPES.contains(suffix);
}

永远不要相信前端传的文件名和 MIME 类型。


4️⃣ 多文件上传

@PostMapping("/upload/multiple")
public Result<?> uploadMultiple(@RequestParam("files") MultipartFile[] files) throws IOException {
    List<String> fileNames = new ArrayList<>();
    for (MultipartFile file : files) {
        if (!file.isEmpty()) {
            // 保存文件...
            fileNames.add(file.getOriginalFilename());
        }
    }
    return Result.success(fileNames);
}

三、文件下载与断点续传

1️⃣ 基础文件下载

@GetMapping("/download")
public void download(HttpServletResponse response) throws IOException {

    File file = new File("uploads/test.pdf");
    response.setContentType("application/pdf");
    response.setHeader("Content-Disposition",
            "attachment;filename=" + URLEncoder.encode(file.getName(), "UTF-8"));

    try (InputStream in = new FileInputStream(file);
         OutputStream out = response.getOutputStream()) {
        byte[] buffer = new byte[1024];
        int len;
        while ((len = in.read(buffer)) != -1) {
            out.write(buffer, 0, len);
        }
    }
}

2️⃣ 断点续传(Range 支持,大文件必备 ⭐)

@GetMapping("/download/range")
public void downloadWithRange(HttpServletRequest request, HttpServletResponse response)
        throws IOException {

    File file = new File("uploads/bigfile.zip");
    long fileLength = file.length();

    long start = 0;
    long end = fileLength - 1;

    String range = request.getHeader("Range");
    if (range != null && range.startsWith("bytes=")) {
        String[] ranges = range.substring(6).split("-");
        start = Long.parseLong(ranges[0]);
        if (ranges.length > 1 && !ranges[1].isEmpty()) {
            end = Long.parseLong(ranges[1]);
        }
    }

    long contentLength = end - start + 1;

    response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT);
    response.setContentType("application/octet-stream");
    response.setHeader("Content-Range", "bytes " + start + "-" + end + "/" + fileLength);
    response.setHeader("Accept-Ranges", "bytes");
    response.setContentLengthLong(contentLength);

    try (RandomAccessFile raf = new RandomAccessFile(file, "r");
         OutputStream out = response.getOutputStream()) {

        raf.seek(start);
        byte[] buffer = new byte[1024];
        long need = contentLength;
        int len;
        while (need > 0 &&
               (len = raf.read(buffer, 0, (int) Math.min(buffer.length, need))) != -1) {
            out.write(buffer, 0, len);
            need -= len;
        }
    }
}

支持浏览器/下载工具断点续传。


四、大文件分片上传(生产级方案)

1️⃣ 分片上传流程

前端:
  1. 计算文件 MD5
  2. 将文件切成 5MB 分片
  3. 依次上传分片(带 index)
  4. 所有分片上传完成后,通知后端合并

后端:
  1. 接收分片,按 MD5 + index 存储
  2. 记录分片上传状态
  3. 合并分片,校验 MD5

2️⃣ 分片上传接口

@PostMapping("/upload/chunk")
public Result<?> uploadChunk(
        @RequestParam("file") MultipartFile chunk,
        @RequestParam("md5") String md5,
        @RequestParam("index") int index,
        @RequestParam("total") int total) throws IOException {

    String chunkDir = "uploads/chunks/" + md5 + "/";
    File dir = new File(chunkDir);
    if (!dir.exists()) {
        dir.mkdirs();
    }

    chunk.transferTo(new File(chunkDir + index));

    return Result.success("分片 " + index + " 上传成功");
}

3️⃣ 合并分片

@PostMapping("/upload/merge")
public Result<?> mergeChunks(@RequestParam("md5") String md5,
                             @RequestParam("fileName") String fileName) throws IOException {

    String chunkDir = "uploads/chunks/" + md5 + "/";
    File dir = new File(chunkDir);
    File[] chunks = dir.listFiles();
    if (chunks == null) {
        return Result.error("分片不存在");
    }

    Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName())));

    String mergePath = "uploads/" + fileName;
    try (FileOutputStream fos = new FileOutputStream(mergePath)) {
        byte[] buffer = new byte[1024];
        for (File chunk : chunks) {
            try (FileInputStream fis = new FileInputStream(chunk)) {
                int len;
                while ((len = fis.read(buffer)) != -1) {
                    fos.write(buffer, 0, len);
                }
            }
        }
    }

    // 删除分片
    for (File chunk : chunks) {
        chunk.delete();
    }
    dir.delete();

    return Result.success("文件合并完成");
}

五、对象存储:MinIO 实战(企业级推荐 ⭐)

1️⃣ 为什么不用本地磁盘?

本地磁盘

对象存储

容量有限

无限扩容

单机故障

高可用

难做 CDN

天然支持 CDN

难迁移

云厂商通用

生产环境:本地 + 对象存储(MinIO / OSS / COS / S3)


2️⃣ 引入 MinIO

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.9</version>
</dependency>

3️⃣ 配置 MinIO

minio:
  endpoint: http://localhost:9000
  access-key: minioadmin
  secret-key: minioadmin
  bucket-name: test-bucket

4️⃣ MinIO 配置类

@Configuration
public class MinioConfig {

    @Value("${minio.endpoint}")
    private String endpoint;

    @Value("${minio.access-key}")
    private String accessKey;

    @Value("${minio.secret-key}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

5️⃣ 上传文件到 MinIO

@Service
public class MinioService {

    @Autowired
    private MinioClient minioClient;

    @Value("${minio.bucket-name}")
    private String bucketName;

    public String upload(MultipartFile file) throws Exception {

        String fileName = UUID.randomUUID() + "-" + file.getOriginalFilename();

        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(fileName)
                        .stream(file.getInputStream(), file.getSize(), -1)
                        .contentType(file.getContentType())
                        .build()
        );

        return endpoint + "/" + bucketName + "/" + fileName;
    }
}

六、Spring Boot 4 新变化

  • 文件上传与虚拟线程结合,高并发下吞吐更高
  • MultipartFile 与 AOT 编译兼容性更好
  • 邮件发送支持更严格的 TLS 配置

七、常见坑位总结

解决

邮件发送阻塞接口

@Async 异步发送

邮件被当成垃圾邮件

配置 SPF / DKIM

文件上传大小超限

配置 max-file-size

恶意文件上传

校验后缀 + 内容类型

大文件上传失败

分片上传 + 断点续传

本地磁盘满了

使用 MinIO / OSS

文件下载中文乱码

URLEncoder.encode


八、本篇总结

  1. 邮件发送:简单 → HTML → 模板 → 异步
  2. 文件上传必须做:大小限制 + 类型校验
  3. 大文件:分片上传 + 断点续传
  4. 生产环境文件存储:优先对象存储(MinIO)
  5. 下载接口支持 Range,提升用户体验
  6. 所有耗时操作(邮件 / 文件处理)异步化
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值