攻克 大 Excel 上传难题:从异步处理到并发去重的全链路解决方案

在企业级应用开发中,Excel 批量导入是一个高频需求。当面对几百兆甚至更大的 Excel 文件时,传统的同步处理方式往往会导致接口超时、内存溢出,甚至整个应用崩溃。更复杂的是,还需要处理数据校验、重复数据检测、多用户并发上传等问题,最终还要生成一份详细的导入结果供用户核对。

本文将从实际业务场景出发,构建一套完整的大 Excel 文件上传处理方案,涵盖从前端分片上传到后端异步处理、数据校验、并发去重、结果生成的全流程,并提供可直接运行的代码实现。

一、需求分析与架构设计

1.1 核心业务需求

  • 支持上传几百兆的大型 Excel 文件
  • 异步处理导入过程,不阻塞用户操作
  • 对每条数据进行规则校验
  • 检查数据是否已存在于数据库,避免重复导入
  • 处理多用户并发上传的情况
  • 生成包含每条数据导入结果(成功 / 失败及原因)的 Excel 文件
  • 提供导入进度查询功能

1.2 技术挑战

  • 大文件上传导致的内存溢出问题
  • 长时间处理导致的接口超时问题
  • 大量数据校验的性能瓶颈
  • 并发场景下的数据一致性问题
  • 重复数据的高效检测
  • 导入结果的精准记录与反馈

1.3 整体架构设计

针对上述需求和挑战,我们设计如下架构:

架构说明

  1. 前端分片上传:将大文件拆分为小分片上传,避免单次请求过大
  2. 后端 API 服务:接收文件分片、合并文件、创建导入任务
  3. 文件存储服务:存储原始 Excel 文件和生成的结果文件
  4. 消息队列:解耦 API 服务和异步处理服务,实现削峰填谷
  5. 异步处理服务:负责 Excel 解析、数据校验、去重、导入和结果生成
  6. 校验规则引擎:集中处理数据校验逻辑
  7. 数据库:存储业务数据、导入任务状态和结果信息

二、技术选型与环境配置

2.1 核心技术栈

  • 后端框架:Spring Boot 3.2.0
  • 文件处理:Alibaba EasyExcel 3.3.0
  • 消息队列:RabbitMQ 3.13.0
  • 数据库:MySQL 8.0.35
  • ORM 框架:MyBatis-Plus 3.5.5
  • 缓存:Redis 7.2.3(用于分布式锁和临时存储)
  • API 文档:SpringDoc OpenAPI 2.2.0(Swagger 3)
  • 工具类:Lombok 1.18.30、Fastjson2 2.0.32、Guava 32.1.3-jre
  • 前端:Vue 3 + Element Plus(本文不展开前端实现细节)

2.2 Maven 依赖配置

复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.0</version>
        <relativePath/>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>large-excel-import</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>large-excel-import</name>
    <description>Large Excel Import Solution</description>
    
    <properties>
        <java.version>17</java.version>
        <easyexcel.version>3.3.0</easyexcel.version>
        <mybatis-plus.version>3.5.5</mybatis-plus.version>
        <springdoc.version>2.2.0</springdoc.version>
        <fastjson2.version>2.0.32</fastjson2.version>
        <guava.version>32.1.3-jre</guava.version>
        <lombok.version>1.18.30</lombok.version>
    </properties>
    
    <dependencies>
        <!-- Spring Boot Core -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        
        <!-- Database -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        
        <!-- MyBatis-Plus -->
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-extension</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        
        <!-- Excel Processing -->
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>easyexcel</artifactId>
            <version>${easyexcel.version}</version>
        </dependency>
        
        <!-- API Documentation -->
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>${springdoc.version}</version>
        </dependency>
        
        <!-- Utilities -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
        
        <!-- Testing -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

2.3 核心配置文件

复制代码
# application.yml
spring:
  profiles:
    active: dev
  servlet:
    multipart:
      max-file-size: 10MB
      max-request-size: 10MB

# 日志配置
logging:
  level:
    root: INFO
    com.example: DEBUG
    com.alibaba.easyexcel: WARN
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n"

# application-dev.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/excel_import?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  redis:
    host: localhost
    port: 6379
    password:
    database: 0
    timeout: 3000ms
    lettuce:
      pool:
        max-active: 16
        max-idle: 8
        min-idle: 4
  
  rabbitmq:
    host: localhost
    port: 5672
    username: guest
    password: guest
    virtual-host: /
    listener:
      simple:
        concurrency: 2
        max-concurrency: 8
        prefetch: 1
    template:
      retry:
        enabled: true
        initial-interval: 1000ms
        max-attempts: 3
        max-interval: 3000ms

# 文件存储配置
file:
  storage:
    path: ./uploadFiles
    temp-path: ./tempFiles
    result-path: ./resultFiles
    max-size: 524288000 # 500MB

# MyBatis-Plus配置
mybatis-plus:
  mapper-locations: classpath*:mapper/**/*.xml
  type-aliases-package: com.example.excelimport.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
  global-config:
    db-config:
      id-type: ASSIGN_ID
      logic-delete-field: deleted
      logic-delete-value: 1
      logic-not-delete-value: 0

# 异步任务配置
async:
  executor:
    core-pool-size: 4
    max-pool-size: 16
    queue-capacity: 100
    keep-alive-seconds: 60
    thread-name-prefix: ExcelImport-

# Swagger配置
springdoc:
  api-docs:
    path: /api-docs
  swagger-ui:
    path: /swagger-ui.html
    operationsSorter: method
  packages-to-scan: com.example.excelimport.controller

三、数据库设计

3.1 核心表结构

3.1.1 导入任务表(import_task)
复制代码
CREATE TABLE `import_task` (
  `id` bigint NOT NULL COMMENT '主键ID',
  `task_no` varchar(64) NOT NULL COMMENT '任务编号',
  `file_name` varchar(255) NOT NULL COMMENT '文件名',
  `file_path` varchar(512) NOT NULL COMMENT '文件路径',
  `result_file_path` varchar(512) DEFAULT NULL COMMENT '结果文件路径',
  `file_size` bigint NOT NULL COMMENT '文件大小(字节)',
  `total_rows` int DEFAULT 0 COMMENT '总记录数',
  `success_rows` int DEFAULT 0 COMMENT '成功记录数',
  `fail_rows` int DEFAULT 0 COMMENT '失败记录数',
  `status` tinyint NOT NULL COMMENT '状态:0-初始化,1-处理中,2-完成,3-失败',
  `progress` int NOT NULL DEFAULT 0 COMMENT '进度(%)',
  `user_id` bigint NOT NULL COMMENT '操作人ID',
  `user_name` varchar(64) NOT NULL COMMENT '操作人姓名',
  `error_msg` varchar(1024) DEFAULT NULL COMMENT '错误信息',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_task_no` (`task_no`),
  KEY `idx_user_id` (`user_id`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导入任务表';
3.1.2 业务数据表(business_data)

以一个商品信息表为例,实际业务中根据需求调整:

复制代码
CREATE TABLE `business_data` (
  `id` bigint NOT NULL COMMENT '主键ID',
  `product_code` varchar(64) NOT NULL COMMENT '商品编码',
  `product_name` varchar(255) NOT NULL COMMENT '商品名称',
  `category_id` bigint NOT NULL COMMENT '分类ID',
  `price` decimal(10,2) NOT NULL COMMENT '价格',
  `stock` int NOT NULL COMMENT '库存',
  `status` tinyint NOT NULL DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
  `create_user` bigint NOT NULL COMMENT '创建人',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_user` bigint DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  `deleted` tinyint NOT NULL DEFAULT 0 COMMENT '删除标识:0-未删,1-已删',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_product_code` (`product_code`) COMMENT '商品编码唯一',
  KEY `idx_category_id` (`category_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品信息表';
3.1.3 导入日志表(可选,用于详细追踪)
复制代码
CREATE TABLE `import_log` (
  `id` bigint NOT NULL COMMENT '主键ID',
  `task_id` bigint NOT NULL COMMENT '任务ID',
  `row_num` int NOT NULL COMMENT '行号',
  `data_content` text COMMENT '数据内容',
  `success` tinyint NOT NULL COMMENT '是否成功:0-失败,1-成功',
  `message` varchar(1024) DEFAULT NULL COMMENT '提示信息',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  PRIMARY KEY (`id`),
  KEY `idx_task_id` (`task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='导入日志表';

3.2 MyBatis-Plus 实体类

3.2.1 导入任务实体
复制代码
package com.example.excelimport.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 导入任务实体类
 *
 * @author ken
 */
@Data
@TableName("import_task")
public class ImportTask {

    /**
     * 主键ID
     */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    /**
     * 任务编号
     */
    @TableField("task_no")
    private String taskNo;

    /**
     * 文件名
     */
    @TableField("file_name")
    private String fileName;

    /**
     * 文件路径
     */
    @TableField("file_path")
    private String filePath;

    /**
     * 结果文件路径
     */
    @TableField("result_file_path")
    private String resultFilePath;

    /**
     * 文件大小(字节)
     */
    @TableField("file_size")
    private Long fileSize;

    /**
     * 总记录数
     */
    @TableField("total_rows")
    private Integer totalRows;

    /**
     * 成功记录数
     */
    @TableField("success_rows")
    private Integer successRows;

    /**
     * 失败记录数
     */
    @TableField("fail_rows")
    private Integer failRows;

    /**
     * 状态:0-初始化,1-处理中,2-完成,3-失败
     */
    @TableField("status")
    private Integer status;

    /**
     * 进度(%)
     */
    @TableField("progress")
    private Integer progress;

    /**
     * 操作人ID
     */
    @TableField("user_id")
    private Long userId;

    /**
     * 操作人姓名
     */
    @TableField("user_name")
    private String userName;

    /**
     * 错误信息
     */
    @TableField("error_msg")
    private String errorMsg;

    /**
     * 创建时间
     */
    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    @TableField(value = "update_time", fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}
3.2.2 商品信息实体
复制代码
package com.example.excelimport.entity;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;

import java.math.BigDecimal;
import java.time.LocalDateTime;

/**
 * 商品信息实体类
 *
 * @author ken
 */
@Data
@TableName("business_data")
public class BusinessData {

    /**
     * 主键ID
     */
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    /**
     * 商品编码
     */
    @TableField("product_code")
    private String productCode;

    /**
     * 商品名称
     */
    @TableField("product_name")
    private String productName;

    /**
     * 分类ID
     */
    @TableField("category_id")
    private Long categoryId;

    /**
     * 价格
     */
    @TableField("price")
    private BigDecimal price;

    /**
     * 库存
     */
    @TableField("stock")
    private Integer stock;

    /**
     * 状态:0-禁用,1-启用
     */
    @TableField("status")
    private Integer status;

    /**
     * 创建人
     */
    @TableField(value = "create_user", fill = FieldFill.INSERT)
    private Long createUser;

    /**
     * 创建时间
     */
    @TableField(value = "create_time", fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    /**
     * 更新人
     */
    @TableField(value = "update_user", fill = FieldFill.UPDATE)
    private Long updateUser;

    /**
     * 更新时间
     */
    @TableField(value = "update_time", fill = FieldFill.UPDATE)
    private LocalDateTime updateTime;

    /**
     * 删除标识:0-未删,1-已删
     */
    @TableField("deleted")
    @TableLogic
    private Integer deleted;
}
3.2.3 Mapper 接口
复制代码
package com.example.excelimport.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.excelimport.entity.ImportTask;
import org.apache.ibatis.annotations.Mapper;

/**
 * 导入任务Mapper
 *
 * @author ken
 */
@Mapper
public interface ImportTaskMapper extends BaseMapper<ImportTask> {
}

package com.example.excelimport.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.excelimport.entity.BusinessData;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;

import java.util.List;

/**
 * 商品数据Mapper
 *
 * @author ken
 */
@Mapper
public interface BusinessDataMapper extends BaseMapper<BusinessData> {

    /**
     * 批量插入商品数据
     *
     * @param list 商品数据列表
     * @return 插入数量
     */
    int batchInsert(@Param("list") List<BusinessData> list);

    /**
     * 根据商品编码列表查询
     *
     * @param codeList 商品编码列表
     * @return 商品数据列表
     */
    List<BusinessData> selectByCodes(@Param("codeList") List<String> codeList);
}

四、文件上传与任务管理

4.1 分片上传核心组件

4.1.1 文件分片 DTO
复制代码
package com.example.excelimport.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

/**
 * 文件分片上传DTO
 *
 * @author ken
 */
@Data
@Schema(description = "文件分片上传参数")
public class FileChunkDTO {

    /**
     * 文件名
     */
    @Schema(description = "文件名", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileName;

    /**
     * 文件唯一标识
     */
    @Schema(description = "文件唯一标识", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileId;

    /**
     * 分片索引
     */
    @Schema(description = "分片索引,从0开始", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer chunkIndex;

    /**
     * 总分片数
     */
    @Schema(description = "总分片数", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer totalChunks;

    /**
     * 分片大小(字节)
     */
    @Schema(description = "分片大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long chunkSize;

    /**
     * 文件总大小(字节)
     */
    @Schema(description = "文件总大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long totalSize;

    /**
     * 文件分片数据
     */
    @Schema(description = "文件分片数据", requiredMode = Schema.RequiredMode.REQUIRED)
    private MultipartFile chunkFile;
}
4.1.2 文件上传服务接口
复制代码
package com.example.excelimport.service;

import com.example.excelimport.dto.FileChunkDTO;
import com.example.excelimport.vo.FileUploadResultVO;

/**
 * 文件上传服务
 *
 * @author ken
 */
public interface FileUploadService {

    /**
     * 上传文件分片
     *
     * @param chunkDTO 分片上传参数
     * @param userId 用户ID
     * @return 上传结果
     */
    FileUploadResultVO uploadChunk(FileChunkDTO chunkDTO, Long userId);

    /**
     * 合并文件分片
     *
     * @param fileId 文件唯一标识
     * @param fileName 文件名
     * @param totalChunks 总分片数
     * @param userId 用户ID
     * @return 合并结果,包含完整文件路径
     */
    String mergeChunks(String fileId, String fileName, Integer totalChunks, Long userId);

    /**
     * 检查分片是否已上传
     *
     * @param fileId 文件唯一标识
     * @param chunkIndex 分片索引
     * @return true-已上传,false-未上传
     */
    boolean checkChunkExists(String fileId, Integer chunkIndex);
}
4.1.3 文件上传服务实现
复制代码
package com.example.excelimport.service.impl;

import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.IdUtil;
import com.example.excelimport.dto.FileChunkDTO;
import com.example.excelimport.service.FileUploadService;
import com.example.excelimport.vo.FileUploadResultVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * 文件上传服务实现
 *
 * @author ken
 */
@Slf4j
@Service
public class FileUploadServiceImpl implements FileUploadService {

    /**
     * 临时文件存储路径
     */
    @Value("${file.storage.temp-path}")
    private String tempPath;

    /**
     * 正式文件存储路径
     */
    @Value("${file.storage.path}")
    private String storagePath;

    /**
     * 最大文件大小
     */
    @Value("${file.storage.max-size}")
    private Long maxFileSize;

    /**
     * 日期格式化器
     */
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

    @Override
    public FileUploadResultVO uploadChunk(FileChunkDTO chunkDTO, Long userId) {
        // 参数校验
        StringUtils.hasText(chunkDTO.getFileId(), "文件标识不能为空");
        StringUtils.hasText(chunkDTO.getFileName(), "文件名不能为空");
        if (chunkDTO.getChunkIndex() == null || chunkDTO.getChunkIndex() < 0) {
            throw new IllegalArgumentException("分片索引必须大于等于0");
        }
        if (chunkDTO.getTotalChunks() == null || chunkDTO.getTotalChunks() <= 0) {
            throw new IllegalArgumentException("总分片数必须大于0");
        }
        if (chunkDTO.getChunkFile() == null || chunkDTO.getChunkFile().isEmpty()) {
            throw new IllegalArgumentException("分片文件不能为空");
        }
        if (chunkDTO.getTotalSize() == null || chunkDTO.getTotalSize() <= 0) {
            throw new IllegalArgumentException("文件总大小必须大于0");
        }
        if (chunkDTO.getTotalSize() > maxFileSize) {
            throw new IllegalArgumentException("文件大小超过限制,最大支持" + maxFileSize / (1024 * 1024) + "MB");
        }

        // 创建临时目录
        String chunkDir = getChunkDir(chunkDTO.getFileId());
        File dirFile = new File(chunkDir);
        if (!dirFile.exists() && !dirFile.mkdirs()) {
            log.error("创建分片目录失败: {}", chunkDir);
            throw new RuntimeException("上传失败,无法创建临时目录");
        }

        // 保存分片文件
        String chunkFileName = chunkDTO.getChunkIndex().toString();
        File chunkFile = new File(chunkDir, chunkFileName);
        try {
            chunkDTO.getChunkFile().transferTo(chunkFile);
            log.info("分片上传成功,fileId: {}, chunkIndex: {}, userId: {}",
                    chunkDTO.getFileId(), chunkDTO.getChunkIndex(), userId);
        } catch (IOException e) {
            log.error("分片上传失败,fileId: {}, chunkIndex: {}", chunkDTO.getFileId(), chunkDTO.getChunkIndex(), e);
            // 失败时删除可能的残留文件
            if (chunkFile.exists() && !chunkFile.delete()) {
                log.warn("删除失败的分片文件失败: {}", chunkFile.getAbsolutePath());
            }
            throw new RuntimeException("上传失败: " + e.getMessage());
        }

        // 检查是否所有分片都已上传
        boolean allUploaded = checkAllChunksUploaded(chunkDTO.getFileId(), chunkDTO.getTotalChunks());

        FileUploadResultVO result = new FileUploadResultVO();
        result.setFileId(chunkDTO.getFileId());
        result.setFileName(chunkDTO.getFileName());
        result.setChunkIndex(chunkDTO.getChunkIndex());
        result.setTotalChunks(chunkDTO.getTotalChunks());
        result.setAllUploaded(allUploaded);
        result.setMessage("分片上传成功");

        return result;
    }

    @Override
    public String mergeChunks(String fileId, String fileName, Integer totalChunks, Long userId) {
        StringUtils.hasText(fileId, "文件标识不能为空");
        StringUtils.hasText(fileName, "文件名不能为空");
        if (totalChunks == null || totalChunks <= 0) {
            throw new IllegalArgumentException("总分片数必须大于0");
        }

        // 验证所有分片是否已上传
        if (!checkAllChunksUploaded(fileId, totalChunks)) {
            throw new RuntimeException("存在未上传的分片,无法合并");
        }

        try {
            // 获取文件存储目录
            String dateDir = DATE_FORMATTER.format(LocalDate.now());
            String saveDir = storagePath + File.separator + dateDir;
            File saveDirFile = new File(saveDir);
            if (!saveDirFile.exists() && !saveDirFile.mkdirs()) {
                log.error("创建文件存储目录失败: {}", saveDir);
                throw new RuntimeException("合并失败,无法创建存储目录");
            }

            // 生成唯一文件名,保留原扩展名
            String ext = FileUtil.extName(fileName);
            String uniqueFileName = IdUtil.fastSimpleUUID() + (StringUtils.hasText(ext) ? "." + ext : "");
            String filePath = saveDir + File.separator + uniqueFileName;

            // 合并分片
            Path targetPath = Paths.get(filePath);
            String chunkDir = getChunkDir(fileId);

            // 按分片索引升序排列
            List<File> chunkFiles = new ArrayList<>();
            for (int i = 0; i < totalChunks; i++) {
                File chunkFile = new File(chunkDir, String.valueOf(i));
                if (!chunkFile.exists()) {
                    throw new RuntimeException("分片文件缺失: " + i);
                }
                chunkFiles.add(chunkFile);
            }

            // 合并所有分片到目标文件
            for (File chunkFile : chunkFiles) {
                Files.write(targetPath, Files.readAllBytes(chunkFile.toPath()), StandardOpenOption.CREATE, StandardOpenOption.APPEND);
            }

            log.info("文件合并成功,fileId: {}, fileName: {}, savePath: {}, userId: {}",
                    fileId, fileName, filePath, userId);

            // 清理临时分片文件
            deleteChunkDir(fileId);

            return filePath;
        } catch (IOException e) {
            log.error("文件合并失败,fileId: {}", fileId, e);
            throw new RuntimeException("合并失败: " + e.getMessage());
        }
    }

    @Override
    public boolean checkChunkExists(String fileId, Integer chunkIndex) {
        StringUtils.hasText(fileId, "文件标识不能为空");
        if (chunkIndex == null || chunkIndex < 0) {
            throw new IllegalArgumentException("分片索引必须大于等于0");
        }

        String chunkDir = getChunkDir(fileId);
        File chunkFile = new File(chunkDir, chunkIndex.toString());
        return chunkFile.exists() && chunkFile.length() > 0;
    }

    /**
     * 获取分片存储目录
     */
    private String getChunkDir(String fileId) {
        return tempPath + File.separator + "chunks" + File.separator + fileId;
    }

    /**
     * 检查所有分片是否已上传
     */
    private boolean checkAllChunksUploaded(String fileId, int totalChunks) {
        String chunkDir = getChunkDir(fileId);
        File dirFile = new File(chunkDir);
        
        if (!dirFile.exists()) {
            return false;
        }
        
        // 列出所有分片文件并检查数量是否匹配
        File[] chunkFiles = dirFile.listFiles();
        if (chunkFiles == null || chunkFiles.length != totalChunks) {
            return false;
        }
        
        // 检查每个分片是否存在
        for (int i = 0; i < totalChunks; i++) {
            File chunkFile = new File(dirFile, String.valueOf(i));
            if (!chunkFile.exists() || chunkFile.length() == 0) {
                return false;
            }
        }
        
        return true;
    }

    /**
     * 删除分片目录及文件
     */
    private void deleteChunkDir(String fileId) {
        String chunkDir = getChunkDir(fileId);
        File dirFile = new File(chunkDir);
        
        if (dirFile.exists()) {
            try {
                // 递归删除目录
                FileUtil.del(dirFile);
                log.info("分片目录已删除: {}", chunkDir);
            } catch (Exception e) {
                log.warn("删除分片目录失败: {}", chunkDir, e);
            }
        }
    }
}

4.2 导入任务管理

4.2.1 任务相关 DTO 和 VO
复制代码
package com.example.excelimport.dto;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

/**
 * 创建导入任务DTO
 *
 * @author ken
 */
@Data
@Schema(description = "创建导入任务参数")
public class CreateImportTaskDTO {

    /**
     * 文件唯一标识
     */
    @Schema(description = "文件唯一标识", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileId;

    /**
     * 文件名
     */
    @Schema(description = "文件名", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileName;

    /**
     * 总分片数
     */
    @Schema(description = "总分片数", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer totalChunks;
}

package com.example.excelimport.vo;

import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 导入任务VO
 *
 * @author ken
 */
@Data
@Schema(description = "导入任务信息")
public class ImportTaskVO {

    /**
     * 任务ID
     */
    @Schema(description = "任务ID")
    private Long id;

    /**
     * 任务编号
     */
    @Schema(description = "任务编号")
    private String taskNo;

    /**
     * 文件名
     */
    @Schema(description = "文件名")
    private String fileName;

    /**
     * 文件大小(MB)
     */
    @Schema(description = "文件大小(MB)")
    private Double fileSizeMB;

    /**
     * 总记录数
     */
    @Schema(description = "总记录数")
    private Integer totalRows;

    /**
     * 成功记录数
     */
    @Schema(description = "成功记录数")
    private Integer successRows;

    /**
     * 失败记录数
     */
    @Schema(description = "失败记录数")
    private Integer failRows;

    /**
     * 状态:0-初始化,1-处理中,2-完成,3-失败
     */
    @Schema(description = "状态:0-初始化,1-处理中,2-完成,3-失败")
    private Integer status;

    /**
     * 状态描述
     */
    @Schema(description = "状态描述")
    private String statusDesc;

    /**
     * 进度(%)
     */
    @Schema(description = "进度(%)")
    private Integer progress;

    /**
     * 错误信息
     */
    @Schema(description = "错误信息")
    private String errorMsg;

    /**
     * 结果文件下载地址
     */
    @Schema(description = "结果文件下载地址")
    private String resultFileUrl;

    /**
     * 创建时间
     */
    @Schema(description = "创建时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    @Schema(description = "更新时间")
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime updateTime;
}
4.2.2 任务服务接口
复制代码
package com.example.excelimport.service;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.excelimport.dto.CreateImportTaskDTO;
import com.example.excelimport.entity.ImportTask;
import com.example.excelimport.vo.ImportTaskVO;

/**
 * 导入任务服务
 *
 * @author ken
 */
public interface ImportTaskService {

    /**
     * 创建导入任务
     *
     * @param dto 创建任务参数
     * @param userId 用户ID
     * @param userName 用户名
     * @return 任务信息
     */
    ImportTaskVO createTask(CreateImportTaskDTO dto, Long userId, String userName);

    /**
     * 查询任务详情
     *
     * @param taskId 任务ID
     * @param userId 用户ID
     * @return 任务详情
     */
    ImportTaskVO getTaskDetail(Long taskId, Long userId);

    /**
     * 分页查询用户的导入任务
     *
     * @param page 分页参数
     * @param userId 用户ID
     * @param status 任务状态,null表示查询所有
     * @return 任务分页列表
     */
    IPage<ImportTaskVO> queryUserTasks(Page<ImportTask> page, Long userId, Integer status);

    /**
     * 更新任务状态
     *
     * @param taskId 任务ID
     * @param status 新状态
     * @param progress 进度(%)
     * @param successRows 成功记录数
     * @param failRows 失败记录数
     * @param errorMsg 错误信息
     * @param resultFilePath 结果文件路径
     * @return 是否更新成功
     */
    boolean updateTaskStatus(Long taskId, Integer status, Integer progress,
                            Integer successRows, Integer failRows,
                            String errorMsg, String resultFilePath);

    /**
     * 获取任务实体
     *
     * @param taskId 任务ID
     * @return 任务实体
     */
    ImportTask getTaskById(Long taskId);
}
4.2.3 任务服务实现
复制代码
package com.example.excelimport.service.impl;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.excelimport.dto.CreateImportTaskDTO;
import com.example.excelimport.entity.ImportTask;
import com.example.excelimport.mapper.ImportTaskMapper;
import com.example.excelimport.service.ImportTaskService;
import com.example.excelimport.service.FileUploadService;
import com.example.excelimport.vo.ImportTaskVO;
import com.google.common.collect.Maps;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.io.File;
import java.text.MessageFormat;
import java.util.Map;
import java.util.UUID;

/**
 * 导入任务服务实现
 *
 * @author ken
 */
@Slf4j
@Service
public class ImportTaskServiceImpl extends ServiceImpl<ImportTaskMapper, ImportTask> implements ImportTaskService {

    /**
     * 任务状态描述映射
     */
    private static final Map<Integer, String> STATUS_DESC_MAP = Maps.newHashMap();

    static {
        STATUS_DESC_MAP.put(0, "初始化");
        STATUS_DESC_MAP.put(1, "处理中");
        STATUS_DESC_MAP.put(2, "完成");
        STATUS_DESC_MAP.put(3, "失败");
    }

    @Autowired
    private FileUploadService fileUploadService;

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 结果文件访问路径前缀
     */
    @Value("${server.servlet.context-path:}")
    private String contextPath;

    /**
     * 导入任务交换机
     */
    @Value("${rabbitmq.exchange.import-task:import.task.exchange}")
    private String importTaskExchange;

    /**
     * 导入任务路由键
     */
    @Value("${rabbitmq.routing-key.import-task:import.task.process}")
    private String importTaskRoutingKey;

    @Override
    @Transactional(rollbackFor = Exception.class)
    public ImportTaskVO createTask(CreateImportTaskDTO dto, Long userId, String userName) {
        // 参数校验
        StringUtils.hasText(dto.getFileId(), "文件标识不能为空");
        StringUtils.hasText(dto.getFileName(), "文件名不能为空");
        if (dto.getTotalChunks() == null || dto.getTotalChunks() <= 0) {
            throw new IllegalArgumentException("总分片数必须大于0");
        }
        ObjectUtils.isEmpty(userId, "用户ID不能为空");
        StringUtils.hasText(userName, "用户名不能为空");

        // 合并文件分片
        String filePath = fileUploadService.mergeChunks(dto.getFileId(), dto.getFileName(), dto.getTotalChunks(), userId);
        if (!StringUtils.hasText(filePath)) {
            throw new RuntimeException("文件合并失败");
        }

        // 获取文件大小
        File file = new File(filePath);
        if (!file.exists() || !file.isFile()) {
            throw new RuntimeException("合并后的文件不存在");
        }
        long fileSize = file.length();

        // 创建任务记录
        ImportTask task = new ImportTask();
        task.setTaskNo(generateTaskNo());
        task.setFileName(dto.getFileName());
        task.setFilePath(filePath);
        task.setFileSize(fileSize);
        task.setStatus(0); // 初始化状态
        task.setProgress(0);
        task.setUserId(userId);
        task.setUserName(userName);

        int insert = baseMapper.insert(task);
        if (insert <= 0) {
            log.error("创建导入任务失败,userId: {}, fileName: {}", userId, dto.getFileName());
            throw new RuntimeException("创建导入任务失败");
        }

        log.info("创建导入任务成功,taskId: {}, taskNo: {}, fileName: {}, userId: {}",
                task.getId(), task.getTaskNo(), dto.getFileName(), userId);

        // 发送消息到队列,异步处理导入
        try {
            rabbitTemplate.convertAndSend(importTaskExchange, importTaskRoutingKey, task.getId());
            log.info("导入任务已发送到队列,taskId: {}", task.getId());
        } catch (Exception e) {
            log.error("发送导入任务到队列失败,taskId: {}", task.getId(), e);
            // 发送失败时,更新任务状态为失败
            task.setStatus(3);
            task.setErrorMsg("任务提交失败: " + e.getMessage());
            baseMapper.updateById(task);
            throw new RuntimeException("创建任务成功,但提交处理失败,请稍后重试");
        }

        return convertToVO(task);
    }

    @Override
    public ImportTaskVO getTaskDetail(Long taskId, Long userId) {
        ObjectUtils.isEmpty(taskId, "任务ID不能为空");
        ObjectUtils.isEmpty(userId, "用户ID不能为空");

        ImportTask task = baseMapper.selectOne(Wrappers.<ImportTask>lambdaQuery()
                .eq(ImportTask::getId, taskId)
                .eq(ImportTask::getUserId, userId));

        if (task == null) {
            return null;
        }

        return convertToVO(task);
    }

    @Override
    public IPage<ImportTaskVO> queryUserTasks(Page<ImportTask> page, Long userId, Integer status) {
        ObjectUtils.isEmpty(userId, "用户ID不能为空");

        IPage<ImportTask> taskPage = baseMapper.selectPage(page, Wrappers.<ImportTask>lambdaQuery()
                .eq(ImportTask::getUserId, userId)
                .eq(status != null, ImportTask::getStatus, status)
                .orderByDesc(ImportTask::getCreateTime));

        IPage<ImportTaskVO> resultPage = new Page<>();
        BeanUtils.copyProperties(taskPage, resultPage);
        resultPage.setRecords(taskPage.getRecords().stream()
                .map(this::convertToVO)
                .collect(java.util.stream.Collectors.toList()));

        return resultPage;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public boolean updateTaskStatus(Long taskId, Integer status, Integer progress,
                                   Integer successRows, Integer failRows,
                                   String errorMsg, String resultFilePath) {
        ObjectUtils.isEmpty(taskId, "任务ID不能为空");
        ObjectUtils.isEmpty(status, "状态不能为空");

        ImportTask task = new ImportTask();
        task.setId(taskId);
        task.setStatus(status);

        if (progress != null) {
            task.setProgress(progress);
        }
        if (successRows != null) {
            task.setSuccessRows(successRows);
        }
        if (failRows != null) {
            task.setFailRows(failRows);
        }
        if (totalRows != null) {
            task.setTotalRows(totalRows);
        }
        task.setErrorMsg(errorMsg);
        task.setResultFilePath(resultFilePath);

        int update = baseMapper.updateById(task);
        return update > 0;
    }

    @Override
    public ImportTask getTaskById(Long taskId) {
        if (taskId == null) {
            return null;
        }
        return baseMapper.selectById(taskId);
    }

    /**
     * 生成任务编号
     */
    private String generateTaskNo() {
        return "IMPT" + System.currentTimeMillis() + UUID.randomUUID().toString().substring(0, 8).toUpperCase();
    }

    /**
     * 转换为VO对象
     */
    private ImportTaskVO convertToVO(ImportTask task) {
        ImportTaskVO vo = new ImportTaskVO();
        BeanUtils.copyProperties(task, vo);

        // 计算文件大小(MB)
        if (task.getFileSize() != null) {
            vo.setFileSizeMB(task.getFileSize() / (1024.0 * 1024.0));
        }

        // 设置状态描述
        vo.setStatusDesc(STATUS_DESC_MAP.getOrDefault(task.getStatus(), "未知状态"));

        // 设置结果文件下载地址
        if (StringUtils.hasText(task.getResultFilePath())) {
            String relativePath = task.getResultFilePath().replace(File.separator, "/");
            vo.setResultFileUrl(contextPath + "/api/v1/files/download?path=" + relativePath);
        }

        return vo;
    }
}

4.3 控制器实现

复制代码
package com.example.excelimport.controller;

import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.excelimport.dto.CreateImportTaskDTO;
import com.example.excelimport.dto.FileChunkDTO;
import com.example.excelimport.entity.ImportTask;
import com.example.excelimport.service.ImportTaskService;
import com.example.excelimport.service.FileUploadService;
import com.example.excelimport.vo.FileUploadResultVO;
import com.example.excelimport.vo.ImportTaskVO;
import com.example.excelimport.vo.ResponseVO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import javax.servlet.http.HttpServletResponse;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

/**
 * 文件导入控制器
 *
 * @author ken
 */
@Slf4j
@RestController
@RequestMapping("/api/v1/import")
@Tag(name = "文件导入接口", description = "大文件分片上传及异步导入处理")
public class ImportController {

    @Autowired
    private FileUploadService fileUploadService;

    @Autowired
    private ImportTaskService importTaskService;

    @Operation(summary = "上传文件分片", description = "将大文件拆分为分片上传")
    @PostMapping("/file/chunk")
    public ResponseVO<FileUploadResultVO> uploadChunk(
            @Parameter(description = "用户ID", required = true) @RequestHeader("X-User-Id") Long userId,
            @Parameter(description = "文件分片信息", required = true) @ModelAttribute FileChunkDTO chunkDTO) {
        log.info("接收文件分片上传请求,fileId: {}, chunkIndex: {}, userId: {}",
                chunkDTO.getFileId(), chunkDTO.getChunkIndex(), userId);
        FileUploadResultVO result = fileUploadService.uploadChunk(chunkDTO, userId);
        return ResponseVO.success(result);
    }

    @Operation(summary = "检查分片是否已上传", description = "用于断点续传时检查分片状态")
    @GetMapping("/file/chunk/exists")
    public ResponseVO<Boolean> checkChunkExists(
            @Parameter(description = "用户ID", required = true) @RequestHeader("X-User-Id") Long userId,
            @Parameter(description = "文件唯一标识", required = true) @RequestParam String fileId,
            @Parameter(description = "分片索引", required = true) @RequestParam Integer chunkIndex) {
        log.info("检查分片是否已上传,fileId: {}, chunkIndex: {}, userId: {}", fileId, chunkIndex, userId);
        boolean exists = fileUploadService.checkChunkExists(fileId, chunkIndex);
        return ResponseVO.success(exists);
    }

    @Operation(summary = "创建导入任务", description = "合并文件分片并创建导入任务")
    @PostMapping("/task")
    public ResponseVO<ImportTaskVO> createImportTask(
            @Parameter(description = "用户ID", required = true) @RequestHeader("X-User-Id") Long userId,
            @Parameter(description = "用户名", required = true) @RequestHeader("X-User-Name") String userName,
            @Parameter(description = "创建任务参数", required = true) @RequestBody CreateImportTaskDTO dto) {
        log.info("创建导入任务,fileId: {}, fileName: {}, userId: {}", dto.getFileId(), dto.getFileName(), userId);
        ImportTaskVO taskVO = importTaskService.createTask(dto, userId, userName);
        return ResponseVO.success(taskVO);
    }

    @Operation(summary = "查询任务详情", description = "获取导入任务的详细信息和进度")
    @GetMapping("/task/{taskId}")
    public ResponseVO<ImportTaskVO> getTaskDetail(
            @Parameter(description = "用户ID", required = true) @RequestHeader("X-User-Id") Long userId,
            @Parameter(description = "任务ID", required = true) @PathVariable Long taskId) {
        log.info("查询任务详情,taskId: {}, userId: {}", taskId, userId);
        ImportTaskVO taskVO = importTaskService.getTaskDetail(taskId, userId);
        return ResponseVO.success(taskVO);
    }

    @Operation(summary = "查询用户导入任务列表", description = "分页查询当前用户的导入任务")
    @GetMapping("/tasks")
    public ResponseVO<IPage<ImportTaskVO>> queryUserTasks(
            @Parameter(description = "用户ID", required = true) @RequestHeader("X-User-Id") Long userId,
            @Parameter(description = "页码,从1开始") @RequestParam(defaultValue = "1") Integer pageNum,
            @Parameter(description = "每页条数") @RequestParam(defaultValue = "10") Integer pageSize,
            @Parameter(description = "任务状态:0-初始化,1-处理中,2-完成,3-失败") @RequestParam(required = false) Integer status) {
        log.info("查询用户导入任务列表,userId: {}, pageNum: {}, pageSize: {}, status: {}",
                userId, pageNum, pageSize, status);
        Page<ImportTask> page = new Page<>(pageNum, pageSize);
        IPage<ImportTaskVO> taskPage = importTaskService.queryUserTasks(page, userId, status);
        return ResponseVO.success(taskPage);
    }

    @Operation(summary = "下载导入结果文件", description = "下载包含导入结果的Excel文件")
    @GetMapping("/file/result")
    public void downloadResultFile(
            @Parameter(description = "用户ID", required = true) @RequestHeader("X-User-Id") Long userId,
            @Parameter(description = "任务ID", required = true) @RequestParam Long taskId,
            HttpServletResponse response) throws IOException {
        log.info("下载导入结果文件,taskId: {}, userId: {}", taskId, userId);

        // 获取任务信息
        ImportTask task = importTaskService.getTaskById(taskId);
        if (task == null) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "任务不存在");
            return;
        }

        // 验证权限
        if (!task.getUserId().equals(userId)) {
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "没有权限下载该文件");
            return;
        }

        // 检查结果文件是否存在
        String resultFilePath = task.getResultFilePath();
        if (!org.springframework.util.StringUtils.hasText(resultFilePath)) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "结果文件不存在");
            return;
        }

        File file = new File(resultFilePath);
        if (!file.exists() || !file.isFile()) {
            response.sendError(HttpServletResponse.SC_NOT_FOUND, "结果文件不存在");
            return;
        }

        // 设置响应头
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        String fileName = "导入结果_" + task.getFileName();
        String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name());
        response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName);
        response.setContentLengthLong(file.length());

        // 写入文件内容
        try (FileInputStream fis = new FileInputStream(file);
             OutputStream os = response.getOutputStream()) {
            byte[] buffer = new byte[1024 * 1024]; // 1MB缓冲区
            int len;
            while ((len = fis.read(buffer)) != -1) {
                os.write(buffer, 0, len);
            }
            os.flush();
        } catch (IOException e) {
            log.error("下载结果文件失败,taskId: {}", taskId, e);
            throw e;
        }
    }
}

五、异步处理与数据导入

5.1 消息队列配置

复制代码
package com.example.excelimport.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.HashMap;
import java.util.Map;

/**
 * RabbitMQ配置
 *
 * @author ken
 */
@Configuration
public class RabbitMQConfig {

    /**
     * 导入任务交换机
     */
    @Value("${rabbitmq.exchange.import-task:import.task.exchange}")
    private String importTaskExchange;

    /**
     * 导入任务队列
     */
    @Value("${rabbitmq.queue.import-task:import.task.queue}")
    private String importTaskQueue;

    /**
     * 导入任务路由键
     */
    @Value("${rabbitmq.routing-key.import-task:import.task.process}")
    private String importTaskRoutingKey;

    /**
     * 死信交换机
     */
    @Value("${rabbitmq.exchange.dlq:import.dlq.exchange}")
    private String dlqExchange;

    /**
     * 死信队列
     */
    @Value("${rabbitmq.queue.dlq:import.dlq.queue}")
    private String dlqQueue;

    /**
     * 死信路由键
     */
    @Value("${rabbitmq.routing-key.dlq:import.dlq.routing}")
    private String dlqRoutingKey;

    /**
     * 死信交换机
     */
    @Bean
    public DirectExchange dlqExchange() {
        return new DirectExchange(dlqExchange, true, false);
    }

    /**
     * 死信队列
     */
    @Bean
    public Queue dlqQueue() {
        return QueueBuilder.durable(dlqQueue)
                .build();
    }

    /**
     * 死信队列绑定
     */
    @Bean
    public Binding dlqBinding() {
        return BindingBuilder.bind(dlqQueue())
                .to(dlqExchange())
                .with(dlqRoutingKey);
    }

    /**
     * 导入任务交换机
     */
    @Bean
    public DirectExchange importTaskExchange() {
        return new DirectExchange(importTaskExchange, true, false);
    }

    /**
     * 导入任务队列
     * 设置死信队列,用于处理失败的任务
     */
    @Bean
    public Queue importTaskQueue() {
        Map<String, Object> arguments = new HashMap<>(3);
        // 绑定死信交换机
        arguments.put("x-dead-letter-exchange", dlqExchange);
        // 绑定死信路由键
        arguments.put("x-dead-letter-routing-key", dlqRoutingKey);
        // 消息过期时间 30分钟
        arguments.put("x-message-ttl", 30 * 60 * 1000);
        
        return QueueBuilder.durable(importTaskQueue)
                .withArguments(arguments)
                .build();
    }

    /**
     * 导入任务队列绑定
     */
    @Bean
    public Binding importTaskBinding() {
        return BindingBuilder.bind(importTaskQueue())
                .to(importTaskExchange())
                .with(importTaskRoutingKey);
    }
}

5.2 Excel 数据模型与解析

5.2.1 导入数据模型
复制代码
package com.example.excelimport.excel;

import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.alibaba.excel.annotation.write.style.ContentRowHeight;
import com.alibaba.excel.annotation.write.style.HeadRowHeight;
import lombok.Data;

import java.math.BigDecimal;

/**
 * 商品导入Excel模型
 *
 * @author ken
 */
@Data
@HeadRowHeight(20)
@ContentRowHeight(18)
@ColumnWidth(20)
public class ProductImportModel {

    @ExcelProperty(value = "商品编码", index = 0)
    private String productCode;

    @ExcelProperty(value = "商品名称", index = 1)
    private String productName;

    @ExcelProperty(value = "分类ID", index = 2)
    private Long categoryId;

    @ExcelProperty(value = "价格", index = 3)
    private BigDecimal price;

    @ExcelProperty(value = "库存", index = 4)
    private Integer stock;

    @ExcelProperty(value = "状态(0-禁用 1-启用)", index = 5)
    private Integer status;

    /**
     * 导入结果状态:成功/失败
     */
    @ExcelProperty(value = "导入结果", index = 6)
    private String importStatus;

    /**
     * 导入失败原因
     */
    @ExcelProperty(value = "失败原因", index = 7)
    private String errorMsg;
}
5.2.2 Excel 读取监听器
复制代码
package com.example.excelimport.excel;

import com.alibaba.excel.context.AnalysisContext;
import com.alibaba.excel.read.listener.ReadListener;
import com.alibaba.excel.util.ListUtils;
import com.example.excelimport.entity.BusinessData;
import com.example.excelimport.service.BusinessDataService;
import com.example.excelimport.service.ImportTaskService;
import com.google.common.collect.Lists;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.CollectionUtils;

import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * 商品导入Excel监听器
 *
 * @author ken
 */
@Slf4j
public class ProductImportListener implements ReadListener<ProductImportModel> {

    /**
     * 批处理阈值
     */
    private static final int BATCH_COUNT = 1000;

    /**
     * 缓存的数据列表
     */
    private List<ProductImportModel> cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);

    /**
     * 所有数据列表(用于生成结果文件)
     */
    private List<ProductImportModel> allDataList = Lists.newArrayList();

    /**
     * 当前行号(从1开始,包含表头)
     */
    private AtomicInteger rowNum = new AtomicInteger(0);

    /**
     * 任务ID
     */
    private final Long taskId;

    /**
     * 处理用户ID
     */
    private final Long userId;

    /**
     * 业务数据服务
     */
    private final BusinessDataService businessDataService;

    /**
     * 导入任务服务
     */
    private final ImportTaskService importTaskService;

    /**
     * 构造函数
     */
    public ProductImportListener(Long taskId, Long userId,
                                BusinessDataService businessDataService,
                                ImportTaskService importTaskService) {
        this.taskId = taskId;
        this.userId = userId;
        this.businessDataService = businessDataService;
        this.importTaskService = importTaskService;
    }

    /**
     * 每解析一行数据都会调用此方法
     */
    @Override
    public void invoke(ProductImportModel data, AnalysisContext context) {
        // 行号递增(表头行也会被计数,所以需要过滤)
        int currentRowNum = rowNum.incrementAndGet();
        
        // 跳过表头行
        if (currentRowNum == 1) {
            return;
        }
        
        // 记录原始行号(展示给用户时从1开始)
        data.setRowNum(currentRowNum - 1);
        
        // 添加到缓存列表
        cachedDataList.add(data);
        allDataList.add(data);
        
        // 达到批处理阈值时进行处理
        if (cachedDataList.size() >= BATCH_COUNT) {
            processBatchData();
            // 清空缓存
            cachedDataList = ListUtils.newArrayListWithExpectedSize(BATCH_COUNT);
            
            // 更新进度
            updateTaskProgress();
        }
    }

    /**
     * 解析完成后调用此方法
     */
    @Override
    public void doAfterAllAnalysed(AnalysisContext context) {
        // 处理剩余数据
        if (!CollectionUtils.isEmpty(cachedDataList)) {
            processBatchData();
        }
        
        log.info("Excel解析完成,taskId: {}, 总记录数: {}", taskId, allDataList.size());
        
        // 更新总记录数
        importTaskService.updateTaskStatus(taskId, null, null, null, null, null, null, allDataList.size());
    }

    /**
     * 处理批量数据
     */
    private void processBatchData() {
        log.info("开始处理批量数据,taskId: {}, 数量: {}", taskId, cachedDataList.size());
        
        try {
            // 1. 数据校验
            businessDataService.validateData(cachedDataList);
            
            // 2. 检查重复数据
            businessDataService.checkDuplicateData(cachedDataList);
            
            // 3. 过滤有效数据
            List<BusinessData> validDataList = businessDataService.filterValidData(cachedDataList, userId);
            
            // 4. 批量导入数据
            if (!CollectionUtils.isEmpty(validDataList)) {
                businessDataService.batchImportData(validDataList);
            }
        } catch (Exception e) {
            log.error("处理批量数据失败,taskId: {}", taskId, e);
            // 标记该批次数据为失败
            cachedDataList.forEach(data -> {
                if (data.getImportStatus() == null) {
                    data.setImportStatus("失败");
                    data.setErrorMsg("系统处理错误: " + e.getMessage());
                }
            });
        }
    }

    /**
     * 更新任务进度
     */
    private void updateTaskProgress() {
        try {
            // 计算总记录数(预估)
            int totalCount = allDataList.size() + (cachedDataList.size() > 0 ? BATCH_COUNT : 0);
            if (totalCount == 0) {
                return;
            }
            
            // 计算进度百分比
            int progress = Math.min(90, (allDataList.size() * 100) / totalCount);
            importTaskService.updateTaskStatus(taskId, 1, progress, null, null, null, null, null);
        } catch (Exception e) {
            log.error("更新任务进度失败,taskId: {}", taskId, e);
        }
    }

    /**
     * 获取所有数据列表
     */
    public List<ProductImportModel> getAllDataList() {
        return allDataList;
    }
}

5.3 数据校验与去重服务

5.3.1 业务服务接口
复制代码
package com.example.excelimport.service;

import com.example.excelimport.entity.BusinessData;
import com.example.excelimport.excel.ProductImportModel;

import java.util.List;

/**
 * 业务数据服务
 *
 * @author ken
 */
public interface BusinessDataService {

    /**
     * 数据校验
     *
     * @param dataList 导入数据列表
     */
    void validateData(List<ProductImportModel> dataList);

    /**
     * 检查重复数据(包括列表内部重复和与数据库重复)
     *
     * @param dataList 导入数据列表
     */
    void checkDuplicateData(List<ProductImportModel> dataList);

    /**
     * 过滤有效数据
     *
     * @param dataList 导入数据列表
     * @param userId 用户ID
     * @return 有效业务数据列表
     */
    List<BusinessData> filterValidData(List<ProductImportModel> dataList, Long userId);

    /**
     * 批量导入数据
     *
     * @param dataList 业务数据列表
     * @return 导入成功数量
     */
    int batchImportData(List<BusinessData> dataList);

    /**
     * 根据编码列表查询数据
     *
     * @param codeList 编码列表
     * @return 业务数据列表
     */
    List<BusinessData> getByCodes(List<String> codeList);
}
5.3.2 业务服务实现
复制代码
package com.example.excelimport.service.impl;

import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.example.excelimport.entity.BusinessData;
import com.example.excelimport.excel.ProductImportModel;
import com.example.excelimport.mapper.BusinessDataMapper;
import com.example.excelimport.service.BusinessDataService;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Lists;
import com.google.common.collect.Maps;
import com.google.common.collect.Multimap;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
 * 业务数据服务实现
 *
 * @author ken
 */
@Slf4j
@Service
public class BusinessDataServiceImpl implements BusinessDataService {

    /**
     * Redis分布式锁前缀
     */
    private static final String LOCK_PREFIX = "import:lock:product:";

    /**
     * 分布式锁过期时间(秒)
     */
    private static final int LOCK_EXPIRE_SECONDS = 60;

    @Autowired
    private BusinessDataMapper businessDataMapper;

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Override
    public void validateData(List<ProductImportModel> dataList) {
        if (CollectionUtils.isEmpty(dataList)) {
            return;
        }

        for (ProductImportModel data : dataList) {
            // 重置之前的校验结果
            data.setImportStatus(null);
            data.setErrorMsg(null);

            List<String> errors = Lists.newArrayList();

            // 商品编码校验
            if (!StringUtils.hasText(data.getProductCode())) {
                errors.add("商品编码不能为空");
            } else if (data.getProductCode().length() > 64) {
                errors.add("商品编码长度不能超过64个字符");
            }

            // 商品名称校验
            if (!StringUtils.hasText(data.getProductName())) {
                errors.add("商品名称不能为空");
            } else if (data.getProductName().length() > 255) {
                errors.add("商品名称长度不能超过255个字符");
            }

            // 分类ID校验
            if (data.getCategoryId() == null || data.getCategoryId() <= 0) {
                errors.add("分类ID必须大于0");
            }

            // 价格校验
            if (data.getPrice() == null) {
                errors.add("价格不能为空");
            } else if (data.getPrice().compareTo(BigDecimal.ZERO) <= 0) {
                errors.add("价格必须大于0");
            } else if (data.getPrice().scale() > 2) {
                errors.add("价格最多保留两位小数");
            }

            // 库存校验
            if (data.getStock() == null) {
                errors.add("库存不能为空");
            } else if (data.getStock() < 0) {
                errors.add("库存不能为负数");
            }

            // 状态校验
            if (data.getStatus() == null) {
                errors.add("状态不能为空");
            } else if (data.getStatus() != 0 && data.getStatus() != 1) {
                errors.add("状态必须为0(禁用)或1(启用)");
            }

            // 设置校验结果
            if (!errors.isEmpty()) {
                data.setImportStatus("失败");
                data.setErrorMsg(String.join(";", errors));
            }
        }
    }

    @Override
    public void checkDuplicateData(List<ProductImportModel> dataList) {
        if (CollectionUtils.isEmpty(dataList)) {
            return;
        }

        // 1. 过滤已校验失败的数据
        List<ProductImportModel> validDataList = dataList.stream()
                .filter(data -> data.getImportStatus() == null)
                .collect(Collectors.toList());

        if (CollectionUtils.isEmpty(validDataList)) {
            return;
        }

        // 2. 检查列表内部重复
        Multimap<String, ProductImportModel> codeMap = HashMultimap.create();
        for (ProductImportModel data : validDataList) {
            codeMap.put(data.getProductCode(), data);
        }

        // 标记重复数据
        for (Map.Entry<String, ProductImportModel> entry : codeMap.entries()) {
            String code = entry.getKey();
            ProductImportModel data = entry.getValue();
            
            // 如果编码对应的记录数大于1,则表示有重复
            if (codeMap.get(code).size() > 1) {
                data.setImportStatus("失败");
                data.setErrorMsg("商品编码在导入文件中重复");
            }
        }

        // 3. 过滤掉内部重复的数据,检查数据库中是否已存在
        List<ProductImportModel> uniqueDataList = validDataList.stream()
                .filter(data -> data.getImportStatus() == null)
                .collect(Collectors.toList());

        if (CollectionUtils.isEmpty(uniqueDataList)) {
            return;
        }

        // 4. 批量查询数据库中已存在的编码
        List<String> codeList = uniqueDataList.stream()
                .map(ProductImportModel::getProductCode)
                .collect(Collectors.toList());

        List<BusinessData> existDataList = businessDataMapper.selectByCodes(codeList);
        if (!CollectionUtils.isEmpty(existDataList)) {
            Set<String> existCodeSet = existDataList.stream()
                    .map(BusinessData::getProductCode)
                    .collect(Collectors.toSet());

            // 标记数据库中已存在的数据
            for (ProductImportModel data : uniqueDataList) {
                if (existCodeSet.contains(data.getProductCode())) {
                    data.setImportStatus("失败");
                    data.setErrorMsg("商品编码在系统中已存在");
                }
            }
        }
    }

    @Override
    public List<BusinessData> filterValidData(List<ProductImportModel> dataList, Long userId) {
        if (CollectionUtils.isEmpty(dataList)) {
            return Lists.newArrayList();
        }

        // 过滤出校验通过的数据
        List<ProductImportModel> validDataList = dataList.stream()
                .filter(data -> data.getImportStatus() == null)
                .collect(Collectors.toList());

        if (CollectionUtils.isEmpty(validDataList)) {
            return Lists.newArrayList();
        }

        // 转换为业务实体
        List<BusinessData> businessDataList = Lists.newArrayListWithExpectedSize(validDataList.size());
        for (ProductImportModel data : validDataList) {
            BusinessData businessData = new BusinessData();
            BeanUtils.copyProperties(data, businessData);
            businessData.setCreateUser(userId);
            businessData.setCreateTime(LocalDateTime.now());
            businessDataList.add(businessData);
        }

        return businessDataList;
    }

    @Override
    @Transactional(rollbackFor = Exception.class)
    public int batchImportData(List<BusinessData> dataList) {
        if (CollectionUtils.isEmpty(dataList)) {
            return 0;
        }

        log.info("开始批量导入数据,数量: {}", dataList.size());

        // 1. 并发控制:使用分布式锁确保数据唯一性
        List<BusinessData> finalDataList = Lists.newArrayList();
        for (BusinessData data : dataList) {
            String lockKey = LOCK_PREFIX + data.getProductCode();
            Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", LOCK_EXPIRE_SECONDS, TimeUnit.SECONDS);
            
            if (Boolean.TRUE.equals(locked)) {
                try {
                    // 再次检查数据库,确保没有并发插入
                    BusinessData existData = businessDataMapper.selectOne(Wrappers.<BusinessData>lambdaQuery()
                            .eq(BusinessData::getProductCode, data.getProductCode())
                            .eq(BusinessData::getDeleted, 0));
                    
                    if (existData == null) {
                        finalDataList.add(data);
                    }
                } finally {
                    // 释放锁
                    redisTemplate.delete(lockKey);
                }
            } else {
                log.warn("获取分布式锁失败,可能有并发操作,productCode: {}", data.getProductCode());
            }
        }

        if (CollectionUtils.isEmpty(finalDataList)) {
            log.info("没有可导入的数据(可能被并发操作拦截)");
            return 0;
        }

        // 2. 批量插入数据
        int insertCount = businessDataMapper.batchInsert(finalDataList);
        log.info("批量导入完成,计划导入: {}, 实际导入: {}", finalDataList.size(), insertCount);

        return insertCount;
    }

    @Override
    public List<BusinessData> getByCodes(List<String> codeList) {
        if (CollectionUtils.isEmpty(codeList)) {
            return Lists.newArrayList();
        }
        return businessDataMapper.selectByCodes(codeList);
    }
}

5.4 消息消费者(异步处理服务)

复制代码
package com.example.excelimport.consumer;

import com.alibaba.excel.EasyExcel;
import com.example.excelimport.entity.ImportTask;
import com.example.excelimport.excel.ProductImportModel;
import com.example.excelimport.excel.ProductImportListener;
import com.example.excelimport.service.BusinessDataService;
import com.example.excelimport.service.ImportTaskService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.io.File;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.UUID;

/**
 * 导入任务消费者
 *
 * @author ken
 */
@Slf4j
@Component
public class ImportTaskConsumer {

    @Autowired
    private ImportTaskService importTaskService;

    @Autowired
    private BusinessDataService businessDataService;

    /**
     * 结果文件存储路径
     */
    @Value("${file.storage.result-path}")
    private String resultPath;

    /**
     * 日期格式化器
     */
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd");

    /**
     * 处理导入任务
     */
    @RabbitListener(queues = "${rabbitmq.queue.import-task:import.task.queue}")
    public void processImportTask(Long taskId) {
        log.info("开始处理导入任务,taskId: {}", taskId);

        if (taskId == null) {
            log.error("导入任务ID为空,跳过处理");
            return;
        }

        ImportTask task = null;
        try {
            // 1. 获取任务信息
            task = importTaskService.getTaskById(taskId);
            if (task == null) {
                log.error("导入任务不存在,taskId: {}", taskId);
                return;
            }

            log.info("开始处理文件导入,taskId: {}, fileName: {}, filePath: {}",
                    taskId, task.getFileName(), task.getFilePath());

            // 2. 更新任务状态为处理中
            importTaskService.updateTaskStatus(taskId, 1, 5, 0, 0, null, null, 0);

            // 3. 验证文件是否存在
            String filePath = task.getFilePath();
            if (!StringUtils.hasText(filePath)) {
                throw new RuntimeException("文件路径为空");
            }

            File file = new File(filePath);
            if (!file.exists() || !file.isFile()) {
                throw new RuntimeException("文件不存在或不是有效文件: " + filePath);
            }

            // 4. 创建监听器并读取Excel
            ProductImportListener listener = new ProductImportListener(
                    taskId, task.getUserId(), businessDataService, importTaskService);

            EasyExcel.read(file, ProductImportModel.class, listener)
                    .sheet()
                    .doRead();

            // 5. 获取所有数据并统计结果
            List<ProductImportModel> allDataList = listener.getAllDataList();
            int totalCount = allDataList.size();
            int successCount = (int) allDataList.stream()
                    .filter(data -> "成功".equals(data.getImportStatus()))
                    .count();
            int failCount = totalCount - successCount;

            // 6. 生成结果文件
            String resultFilePath = generateResultFile(task, allDataList);

            // 7. 更新任务状态为完成
            importTaskService.updateTaskStatus(
                    taskId, 2, 100, successCount, failCount, null, resultFilePath, totalCount);

            log.info("导入任务处理完成,taskId: {}, 总记录数: {}, 成功: {}, 失败: {}",
                    taskId, totalCount, successCount, failCount);

        } catch (Exception e) {
            log.error("导入任务处理失败,taskId: {}", taskId, e);
            
            // 更新任务状态为失败
            String errorMsg = e.getMessage();
            if (errorMsg != null && errorMsg.length() > 1000) {
                errorMsg = errorMsg.substring(0, 1000) + "...";
            }
            
            if (task != null) {
                importTaskService.updateTaskStatus(taskId, 3, null, null, null, errorMsg, null, null);
            }
        }
    }

    /**
     * 处理死信队列中的失败任务
     */
    @RabbitListener(queues = "${rabbitmq.queue.dlq:import.dlq.queue}")
    public void processFailedTask(Long taskId) {
        log.warn("接收到死信队列中的失败任务,taskId: {}", taskId);
        
        // 这里可以做一些告警或者重试处理
        // 例如:记录到失败任务表,通知管理员处理
        ImportTask task = importTaskService.getTaskById(taskId);
        if (task != null) {
            log.warn("失败任务详情:taskNo: {}, fileName: {}, status: {}",
                    task.getTaskNo(), task.getFileName(), task.getStatus());
            // TODO: 发送告警通知
        }
    }

    /**
     * 生成结果文件
     */
    private String generateResultFile(ImportTask task, List<ProductImportModel> dataList) {
        try {
            // 创建结果文件存储目录
            String dateDir = DATE_FORMATTER.format(LocalDate.now());
            String saveDir = resultPath + File.separator + dateDir;
            File saveDirFile = new File(saveDir);
            if (!saveDirFile.exists() && !saveDirFile.mkdirs()) {
                log.error("创建结果文件目录失败: {}", saveDir);
                throw new RuntimeException("生成结果文件失败,无法创建存储目录");
            }

            // 生成结果文件名
            String originalFileName = task.getFileName();
            String ext = originalFileName.contains(".") ? originalFileName.substring(originalFileName.lastIndexOf(".")) : ".xlsx";
            String resultFileName = "result_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8) + ext;
            String resultFilePath = saveDir + File.separator + resultFileName;

            // 写入结果文件
            EasyExcel.write(resultFilePath, ProductImportModel.class)
                    .sheet("导入结果")
                    .doWrite(dataList);

            log.info("结果文件生成成功,taskId: {}, filePath: {}", task.getId(), resultFilePath);
            return resultFilePath;
        } catch (Exception e) {
            log.error("生成结果文件失败,taskId: {}", task.getId(), e);
            throw new RuntimeException("生成结果文件失败: " + e.getMessage());
        }
    }
}

六、并发控制与性能优化

6.1 分布式锁实现

在多用户并发上传的场景下,需要确保数据的唯一性,避免重复导入。我们使用 Redis 实现分布式锁来控制并发访问:

复制代码
package com.example.excelimport.util;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * Redis分布式锁实现
 *
 * @author ken
 */
@Slf4j
@Component
public class RedisDistributedLock implements Lock {

    private final RedisTemplate<String, Object> redisTemplate;
    
    /**
     * 锁的默认过期时间(毫秒)
     */
    private static final long DEFAULT_EXPIRE_MILLIS = 30000;
    
    /**
     * 锁的键
     */
    private final String lockKey;
    
    /**
     * 锁的持有者标识
     */
    private final String lockValue;
    
    /**
     * 锁的过期时间(毫秒)
     */
    private final long expireMillis;

    /**
     * 构造函数
     */
    public RedisDistributedLock(RedisTemplate<String, Object> redisTemplate, String lockKey) {
        this(redisTemplate, lockKey, UUID.randomUUID().toString(), DEFAULT_EXPIRE_MILLIS);
    }

    /**
     * 构造函数
     */
    public RedisDistributedLock(RedisTemplate<String, Object> redisTemplate, String lockKey, String lockValue, long expireMillis) {
        this.redisTemplate = redisTemplate;
        this.lockKey = lockKey;
        this.lockValue = lockValue;
        this.expireMillis = expireMillis;
    }

    @Override
    public void lock() {
        // 循环获取锁,直到成功
        while (!tryLock()) {
            // 短暂休眠,避免过度消耗CPU
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
        }
    }

    @Override
    public void lockInterruptibly() throws InterruptedException {
        if (Thread.interrupted()) {
            throw new InterruptedException();
        }
        // 循环获取锁,直到成功或被中断
        while (!tryLock()) {
            Thread.sleep(50);
            if (Thread.interrupted()) {
                throw new InterruptedException();
            }
        }
    }

    @Override
    public boolean tryLock() {
        return tryLock(expireMillis, TimeUnit.MILLISECONDS);
    }

    @Override
    public boolean tryLock(long time, TimeUnit unit) {
        long millisToWait = unit.toMillis(time);
        long start = System.currentTimeMillis();
        
        // 循环获取锁,直到成功或超时
        while (true) {
            // 尝试获取锁
            Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireMillis, TimeUnit.MILLISECONDS);
            if (Boolean.TRUE.equals(success)) {
                log.debug("获取分布式锁成功,lockKey: {}, lockValue: {}", lockKey, lockValue);
                return true;
            }
            
            // 检查是否超时
            if (System.currentTimeMillis() - start > millisToWait) {
                log.debug("获取分布式锁超时,lockKey: {}, lockValue: {}", lockKey, lockValue);
                return false;
            }
            
            // 短暂休眠,避免过度消耗CPU
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return false;
            }
        }
    }

    @Override
    public void unlock() {
        // 使用Lua脚本保证删除操作的原子性
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        script = script.replace("ARGV[1]", "'" + lockValue + "'");
        
        Long result = (Long) redisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Lists.newArrayList(lockKey));
        
        if (result != null && result > 0) {
            log.debug("释放分布式锁成功,lockKey: {}, lockValue: {}", lockKey, lockValue);
        } else {
            log.warn("释放分布式锁失败,可能锁已过期或被其他线程持有,lockKey: {}, lockValue: {}", lockKey, lockValue);
        }
    }

    @Override
    public Condition newCondition() {
        throw new UnsupportedOperationException("RedisDistributedLock不支持Condition");
    }

    /**
     * 获取锁的键
     */
    public String getLockKey() {
        return lockKey;
    }

    /**
     * 获取锁的持有者标识
     */
    public String getLockValue() {
        return lockValue;
    }
}

6.2 性能优化策略

  1. 分批次处理:将大量数据分成小批次处理,避免内存溢出

  2. 异步处理:使用消息队列和异步任务,避免阻塞主线程

  3. 缓存热点数据:将频繁访问的数据(如分类信息)缓存到 Redis

  4. 批量操作:使用 MyBatis-Plus 的批量插入功能,减少数据库交互

  5. 索引优化:为商品编码等查询条件建立唯一索引

  6. 文件读写优化:使用缓冲流读写文件,设置合理的缓冲区大小

  7. 并发控制:使用分布式锁控制并发写入,避免数据冲突

  8. JVM 优化:根据实际情况调整 JVM 参数,尤其是堆内存大小

    package com.example.excelimport.config;

    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.scheduling.annotation.EnableAsync;
    import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

    import java.util.concurrent.Executor;
    import java.util.concurrent.ThreadPoolExecutor;

    /**

    • 异步任务配置

    • @author ken
      */
      @Configuration
      @EnableAsync
      public class AsyncConfig {

      /**

      • 核心线程数
        */
        @Value("${async.executor.core-pool-size:4}")
        private int corePoolSize;

      /**

      • 最大线程数
        */
        @Value("${async.executor.max-pool-size:16}")
        private int maxPoolSize;

      /**

      • 队列容量
        */
        @Value("${async.executor.queue-capacity:100}")
        private int queueCapacity;

      /**

      • 线程存活时间(秒)
        */
        @Value("${async.executor.keep-alive-seconds:60}")
        private int keepAliveSeconds;

      /**

      • 线程名称前缀
        */
        @Value("${async.executor.thread-name-prefix:ExcelImport-}")
        private String threadNamePrefix;

      /**

      • 异步任务执行器
        */
        @Bean(name = "asyncExecutor")
        public Executor asyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 核心线程数
        executor.setCorePoolSize(corePoolSize);
        // 最大线程数
        executor.setMaxPoolSize(maxPoolSize);
        // 队列容量
        executor.setQueueCapacity(queueCapacity);
        // 线程存活时间
        executor.setKeepAliveSeconds(keepAliveSeconds);
        // 线程名称前缀
        executor.setThreadNamePrefix(threadNamePrefix);

        // 拒绝策略:当线程池和队列都满了,由提交任务的线程执行
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());

        // 初始化
        executor.initialize();
        return executor;
        }
        }

七、总结与扩展

本文详细介绍了一个大 Excel 文件异步上传和导入的完整解决方案,从前端分片上传到后端异步处理,再到数据校验、并发控制和结果生成,涵盖了整个流程的关键技术点。

该方案的核心优势在于:

  1. 高性能:通过分片上传、异步处理、批量操作等技术,支持处理几百兆的大型 Excel 文件
  2. 高可靠性:使用消息队列解耦,结合死信队列处理失败任务,确保数据不丢失
  3. 数据一致性:通过分布式锁和数据库唯一索引,有效防止并发导入导致的数据重复
  4. 良好的用户体验:异步处理不阻塞用户操作,提供进度查询和详细的结果反馈

可扩展方向

  1. 断点续传优化:结合文件哈希校验,实现更高效的断点续传
  2. 分布式部署:将文件存储迁移到分布式文件系统(如 MinIO),支持多节点部署
  3. 任务优先级:为不同用户或业务场景设置任务优先级
  4. 监控告警:增加详细的监控指标和告警机制,及时发现和处理问题
  5. 导入模板管理:支持多种导入模板和动态配置校验规则
  6. 大数据量优化:对于超大规模数据(千万级以上),可考虑使用 Spark 等大数据处理框架

通过本文提供的方案和代码实现,开发者可以快速构建一个稳定、高效的大 Excel 导入功能,满足企业级应用的需求。同时,也可以根据实际业务场景进行定制和扩展,进一步提升系统的性能和可靠性。

相关推荐
脑花儿2 小时前
ABAP EXCEL模板数据上传 及 注意事项
开发语言·excel
咖啡星人k2 小时前
MonkeyCode+Excel混合双打:3步把表格变成可视化大屏
人工智能·excel
吴声子夜歌2 小时前
Excel——常用函数三
excel
Waitind_2 小时前
Excel常用函数
excel
揭老师高效办公2 小时前
WPS表格和Excel中快速选择有批注的全部单元格
excel·wps表格
mudtools2 小时前
.NET操作Excel:高效数据读写与批量操作
c#·.net·excel·wps
老师可可3 小时前
Excel学生成绩表,如何生成成绩分析报告?
经验分享·学习·小程序·excel·学习方法
huan19933 小时前
Java中实现html转pdf
java
野犬寒鸦3 小时前
从零起步学习Redis || 第二章:Redis中数据类型的深层剖析讲解(下)
java·redis·后端·算法·哈希算法