Files
ccdi/doc/requirements/plans/2026-02-08-中介导入异步化改造设计.md
2026-02-09 14:28:25 +08:00

21 KiB

中介库管理导入功能异步化改造设计文档

文档信息

项目 内容
文档标题 中介库管理导入功能异步化改造
创建日期 2026-02-08
参考实现 员工信息导入功能 (CcdiEmployeeController)
涉及模块 中介库管理 (ccdiIntermediary)
改造范围 个人中介导入、实体中介导入

1. 背景与目标

1.1 当前问题

现状: 中介库管理的导入功能采用同步处理方式,用户上传文件后需要等待所有数据处理完成才能收到响应。

存在问题:

  • ⏱️ 大数据量导入时,用户需要长时间等待(可能数十秒甚至数分钟)
  • 🚫 请求可能因超时而中断
  • 😰 用户体验不佳,无法查看导入进度
  • 导入失败后无法查看详细的失败记录

1.2 改造目标

将中介库管理的导入功能改造为异步处理模式,参考员工导入的成功实现:

核心目标:

  • 即时响应: 用户上传文件后立即获得taskId,无需等待
  • 📊 进度追踪: 前端轮询查询导入进度和状态
  • 💾 失败重试: 失败记录保存在Redis,支持7天内查询和重试
  • 🔄 并发处理: 支持多个用户同时导入,互不阻塞

2. 架构设计

2.1 三层架构模式

┌─────────────────────────────────────────────────────────┐
│  Layer 1: Controller (CcdiIntermediaryController)       │
│  - 解析Excel文件                                         │
│  - 调用主Service的importIntermediaryPerson/Entity()     │
│  - 接收taskId                                           │
│  - 封装ImportResultVO返回                                │
└──────────────────┬──────────────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────────────┐
│  Layer 2: 主Service (CcdiIntermediaryServiceImpl)       │
│  - 生成UUID作为taskId                                    │
│  - 初始化Redis状态(PROCESSING)                          │
│  - 获取当前用户名(SecurityUtils.getUsername())           │
│  - 调用异步Service的importPersonAsync/EntityAsync()     │
│  - 立即返回taskId                                        │
└──────────────────┬──────────────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────────────┐
│  Layer 3: 异步Service (CcdiIntermediaryPersonImport     │
│                      /EntityImportServiceImpl)           │
│  - @Async异步执行                                        │
│  - 批量验证、插入、更新数据                              │
│  - 保存失败记录到Redis                                   │
│  - 更新最终状态(SUCCESS/PARTIAL_SUCCESS)                │
└─────────────────────────────────────────────────────────┘

2.2 数据流转

用户上传文件
    │
    ▼
Controller解析Excel
    │
    ▼
主Service生成taskId + 初始化Redis
    │
    ├──► 立即返回taskId给Controller
    │        │
    │        ▼
    │    Controller封装ImportResultVO返回
    │             │
    │             ▼
    │         前端收到响应,开始轮询查询状态
    │
    └──► 异步Service后台执行导入
              │
              ├──► 批量验证数据
              ├──► 批量插入/更新数据
              ├──► 保存失败记录到Redis
              └──► 更新Redis状态为SUCCESS/PARTIAL_SUCCESS

2.3 Redis状态管理

状态Key设计:

类型 个人中介 实体中介
导入状态 import:intermediary:{taskId} import:intermediary-entity:{taskId}
失败记录 import:intermediary:{taskId}:failures import:intermediary-entity:{taskId}:failures
过期时间 7天 7天

状态字段结构 (Hash):

{
  taskId: "uuid-string",
  status: "PROCESSING" | "SUCCESS" | "PARTIAL_SUCCESS",
  totalCount: 100,
  successCount: 95,
  failureCount: 5,
  progress: 100,
  startTime: 1234567890,
  endTime: 1234567900,
  message: "成功95条,失败5条"
}

3. 详细实现方案

3.1 后端改造

文件1: CcdiIntermediaryServiceImpl.java

路径: ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryServiceImpl.java

需要添加的依赖注入:

@Resource
private ICcdiIntermediaryPersonImportService personImportService;

@Resource
private ICcdiIntermediaryEntityImportService entityImportService;

@Resource
private RedisTemplate<String, Object> redisTemplate;

改造1: importIntermediaryPerson方法

原实现 (同步,第251行开始):

@Override
@Transactional
public String importIntermediaryPerson(List<...> list, boolean updateSupport) {
    // 同步执行所有导入逻辑
    // 返回消息字符串
}

新实现 (异步):

@Override
@Transactional
public String importIntermediaryPerson(List<CcdiIntermediaryPersonExcel> list,
                                      boolean updateSupport) {
    String taskId = UUID.randomUUID().toString();
    long startTime = System.currentTimeMillis();

    // 初始化Redis状态
    String statusKey = "import:intermediary:" + taskId;
    Map<String, Object> statusData = new HashMap<>();
    statusData.put("taskId", taskId);
    statusData.put("status", "PROCESSING");
    statusData.put("totalCount", list.size());
    statusData.put("successCount", 0);
    statusData.put("failureCount", 0);
    statusData.put("progress", 0);
    statusData.put("startTime", startTime);
    statusData.put("message", "正在处理...");

    redisTemplate.opsForHash().putAll(statusKey, statusData);
    redisTemplate.expire(statusKey, 7, TimeUnit.DAYS);

    // 获取当前用户名
    String userName = SecurityUtils.getUsername();

    // 调用异步方法
    personImportService.importPersonAsync(list, updateSupport, taskId, userName);

    return taskId;
}

改造2: importIntermediaryEntity方法

与个人中介类似,只需修改:

  • Redis Key前缀为 import:intermediary-entity:
  • 调用 entityImportService.importEntityAsync()

文件2: CcdiIntermediaryController.java

路径: ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiIntermediaryController.java

需要添加的依赖注入:

@Resource
private ICcdiIntermediaryPersonImportService personImportService;

@Resource
private ICcdiIntermediaryEntityImportService entityImportService;

需要添加的import:

import com.ruoyi.ccdi.domain.vo.ImportResultVO;
import com.ruoyi.ccdi.domain.vo.ImportStatusVO;
import com.ruoyi.ccdi.domain.vo.IntermediaryPersonImportFailureVO;
import com.ruoyi.ccdi.domain.vo.IntermediaryEntityImportFailureVO;
import com.ruoyi.ccdi.service.ICcdiIntermediaryPersonImportService;
import com.ruoyi.ccdi.service.ICcdiIntermediaryEntityImportService;

改造1: importPersonData方法 (第183-188行)

原实现:

@PostMapping("/importPersonData")
public AjaxResult importPersonData(MultipartFile file, boolean updateSupport) throws Exception {
    List<CcdiIntermediaryPersonExcel> list = EasyExcelUtil.importExcel(...);
    String message = intermediaryService.importIntermediaryPerson(list, updateSupport);
    return success(message);
}

新实现:

@PostMapping("/importPersonData")
public AjaxResult importPersonData(MultipartFile file,
                                   @RequestParam(defaultValue = "false") boolean updateSupport)
                                   throws Exception {
    List<CcdiIntermediaryPersonExcel> list = EasyExcelUtil.importExcel(
        file.getInputStream(), CcdiIntermediaryPersonExcel.class);

    if (list == null || list.isEmpty()) {
        return error("至少需要一条数据");
    }

    // 提交异步任务
    String taskId = intermediaryService.importIntermediaryPerson(list, updateSupport);

    // 立即返回,不等待后台任务完成
    ImportResultVO result = new ImportResultVO();
    result.setTaskId(taskId);
    result.setStatus("PROCESSING");
    result.setMessage("导入任务已提交,正在后台处理");

    return AjaxResult.success("导入任务已提交,正在后台处理", result);
}

改造2: importEntityData方法 (第196-201行)

与个人中介类似,只需修改:

  • Excel类为 CcdiIntermediaryEntityExcel
  • 调用 importIntermediaryEntity()

新增3: 查询个人中介导入状态

@GetMapping("/importPersonStatus/{taskId}")
public AjaxResult getPersonImportStatus(@PathVariable String taskId) {
    try {
        ImportStatusVO status = personImportService.getImportStatus(taskId);
        return success(status);
    } catch (Exception e) {
        return error(e.getMessage());
    }
}

新增4: 查询个人中介导入失败记录

@GetMapping("/importPersonFailures/{taskId}")
public TableDataInfo getPersonImportFailures(
        @PathVariable String taskId,
        @RequestParam(defaultValue = "1") Integer pageNum,
        @RequestParam(defaultValue = "10") Integer pageSize) {

    List<IntermediaryPersonImportFailureVO> failures =
        personImportService.getImportFailures(taskId);

    // 手动分页
    int fromIndex = (pageNum - 1) * pageSize;
    int toIndex = Math.min(fromIndex + pageSize, failures.size());

    List<IntermediaryPersonImportFailureVO> pageData = failures.subList(fromIndex, toIndex);

    return getDataTable(pageData, failures.size());
}

新增5-6: 实体中介的状态和失败记录查询接口

与个人中介完全对称,只需:

  • URL中的Person改为Entity
  • Service改为entityImportService
  • VO改为IntermediaryEntityImportFailureVO

接口路径对照表:

功能 个人中介 实体中介
导入数据 POST /importPersonData POST /importEntityData
查询状态 GET /importPersonStatus/{taskId} GET /importEntityStatus/{taskId}
查询失败 GET /importPersonFailures/{taskId} GET /importEntityFailures/{taskId}

3.2 前端改造

文件1: API接口定义

路径: ruoyi-ui/src/api/ccdiIntermediary.js

需要添加的方法:

import request from '@/utils/request'

// 查询个人中介导入状态
export function getPersonImportStatus(taskId) {
  return request({
    url: `/ccdi/intermediary/importPersonStatus/${taskId}`,
    method: 'get'
  })
}

// 查询个人中介导入失败记录
export function getPersonImportFailures(taskId, pageNum, pageSize) {
  return request({
    url: `/ccdi/intermediary/importPersonFailures/${taskId}`,
    method: 'get',
    params: { pageNum, pageSize }
  })
}

// 查询实体中介导入状态
export function getEntityImportStatus(taskId) {
  return request({
    url: `/ccdi/intermediary/importEntityStatus/${taskId}`,
    method: 'get'
  })
}

// 查询实体中介导入失败记录
export function getEntityImportFailures(taskId, pageNum, pageSize) {
  return request({
    url: `/ccdi/intermediary/importEntityFailures/${taskId}`,
    method: 'get',
    params: { pageNum, pageSize }
  })
}

文件2: ImportDialog.vue改造

路径: ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue

需要添加的import:

import { getPersonImportStatus, getEntityImportStatus } from "@/api/ccdiIntermediary";

data中添加的状态管理:

data() {
  return {
    // ...原有data
    pollingTimer: null,
    currentTaskId: null
  }
}

修改handleFileSuccess方法:

handleFileSuccess(response) {
  this.isUploading = false;

  if (response.code === 200 && response.data && response.data.taskId) {
    const taskId = response.data.taskId;
    this.currentTaskId = taskId;

    // 显示通知
    this.$notify({
      title: '导入任务已提交',
      message: '正在后台处理中,处理完成后将通知您',
      type: 'info',
      duration: 3000
    });

    // 关闭对话框
    this.visible = false;
    this.$refs.upload.clearFiles();

    // 通知父组件刷新列表
    this.$emit("success", taskId);

    // 开始轮询
    this.startImportStatusPolling(taskId);
  } else {
    this.$modal.msgError(response.msg || '导入失败');
  }
}

添加轮询方法:

methods: {
  /** 开始轮询导入状态 */
  startImportStatusPolling(taskId) {
    let pollCount = 0;
    const maxPolls = 150; // 最多5分钟

    this.pollingTimer = setInterval(async () => {
      try {
        pollCount++;

        if (pollCount > maxPolls) {
          clearInterval(this.pollingTimer);
          this.$modal.msgWarning('导入任务处理超时,请联系管理员');
          return;
        }

        // 根据导入类型调用不同的API
        const apiMethod = this.formData.importType === 'person'
          ? getPersonImportStatus
          : getEntityImportStatus;

        const response = await apiMethod(taskId);

        if (response.data && response.data.status !== 'PROCESSING') {
          clearInterval(this.pollingTimer);
          this.handleImportComplete(response.data);
        }
      } catch (error) {
        clearInterval(this.pollingTimer);
        this.$modal.msgError('查询导入状态失败: ' + error.message);
      }
    }, 2000); // 每2秒轮询一次
  },

  /** 处理导入完成 */
  handleImportComplete(statusResult) {
    if (statusResult.status === 'SUCCESS') {
      this.$notify({
        title: '导入完成',
        message: `全部成功!共导入${statusResult.totalCount}条数据`,
        type: 'success',
        duration: 5000
      });
    } else if (statusResult.failureCount > 0) {
      this.$notify({
        title: '导入完成',
        message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`,
        type: 'warning',
        duration: 5000
      });
    }

    // 通知父组件更新失败记录状态
    this.$emit("import-complete", {
      taskId: statusResult.taskId,
      hasFailures: statusResult.failureCount > 0
    });
  }
}

/** 组件销毁时清除定时器 */
beforeDestroy() {
  if (this.pollingTimer) {
    clearInterval(this.pollingTimer);
    this.pollingTimer = null;
  }
}

4. 测试方案

4.1 功能测试用例

测试用例1: 正常导入流程

前置条件:

  • 准备包含10条个人中介数据的Excel文件
  • 数据格式正确,所有必填字段都已填写

测试步骤:

  1. 登录系统,进入中介管理页面
  2. 点击"导入"按钮
  3. 选择"个人中介"类型
  4. 上传Excel文件,不勾选"更新已存在的数据"
  5. 点击"开始导入"

预期结果:

  • 立即收到通知:"导入任务已提交,正在后台处理中"
  • 导入对话框关闭
  • 2-5秒后收到完成通知(根据数据量)
  • 列表自动刷新,显示新导入的数据
  • 如果全部成功,显示绿色通知:"全部成功!共导入10条数据"

测试用例2: 数据验证失败

前置条件:

  • 准备包含错误数据的Excel(如身份证号格式错误、姓名为空等)

测试步骤:

  1. 重复测试用例1的步骤

预期结果:

  • 导入任务正常提交
  • 完成后显示黄色通知:"成功X条,失败Y条"
  • 页面出现"查看导入失败记录"按钮
  • 点击按钮可以查看失败原因
  • 失败记录包含:原数据行号、错误信息

测试用例3: 更新模式

前置条件:

  • 数据库中已存在某个证件号的中介记录
  • Excel文件中包含相同证件号的数据,但其他字段不同

测试步骤:

  1. 勾选"更新已存在的数据"
  2. 上传Excel文件

预期结果:

  • 已存在的数据被更新
  • 审计字段updatedBy正确记录当前用户
  • updateTime更新为当前时间

测试用例4: 实体中介导入

前置条件:

  • 准备包含机构中介数据的Excel文件

测试步骤:

  1. 选择"机构中介"类型
  2. 上传Excel文件

预期结果:

  • 导入流程与个人中介一致
  • Redis Key前缀为import:intermediary-entity:
  • 数据正确插入ccdi_enterprise_base_info

测试用例5: 并发导入

测试步骤:

  1. 打开两个浏览器标签页
  2. 同时在不同标签页导入个人中介和实体中介

预期结果:

  • 两个导入任务互不影响
  • 各自独立显示进度通知
  • 都能正确完成

测试用例6: 大数据量导入

前置条件:

  • 准备包含1000条数据的Excel文件

测试步骤:

  1. 上传大文件
  2. 观察导入过程

预期结果:

  • 立即返回taskId,不阻塞
  • 轮询查询能正确获取进度
  • 最终完成并显示正确统计信息

4.2 性能测试

性能指标

指标 目标值
接口响应时间 < 500ms (立即返回)
轮询间隔 2秒
轮询超时 5分钟 (150次)
单批导入大小 500条
支持最大文件 10MB
并发导入任务 10个

测试方法

# 使用Apache Bench进行压力测试
ab -n 100 -c 10 -T "multipart/form-data; boundary=----WebKitFormBoundary" \
   -p test_data.xlsx http://localhost:8080/ccdi/intermediary/importPersonData

5. 部署与验证

5.1 部署步骤

  1. 代码修改

    • 按照上述方案修改3个后端文件
    • 修改2个前端文件
  2. 编译打包

    # 后端
    cd ruoyi-ccdi
    mvn clean package
    
    # 前端
    cd ruoyi-ui
    npm run build:prod
    
  3. 重启服务

    # 停止现有服务
    # 部署新的jar包
    # 启动服务
    
  4. 验证部署

    • 访问Swagger文档: http://localhost:8080/swagger-ui/index.html
    • 确认新的接口已正确注册

5.2 验证清单

  • 个人中介导入接口返回taskId
  • 实体中介导入接口返回taskId
  • 轮询查询状态接口正常工作
  • 失败记录查询接口返回正确数据
  • 前端轮询机制正常
  • 导入完成通知正确显示
  • Redis状态正确设置和过期
  • 审计字段正确记录操作人

6. 风险与注意事项

6.1 潜在风险

风险项 影响 缓解措施
Redis服务故障 导入状态无法记录 确保Redis高可用,增加监控
异步任务执行失败 任务状态卡在PROCESSING 增加超时机制和失败重试
并发量过大 系统资源耗尽 限制并发导入任务数
轮询频繁 服务器压力增大 合理设置轮询间隔(2秒)

6.2 注意事项

  1. 异步方法无法使用@Transactional

    • 异步Service中使用@Transactional会失效
    • 需要在方法内部手动管理事务
  2. Redis数据过期

    • 7天后导入状态和失败记录会自动删除
    • 用户需要及时查看失败记录
  3. userName参数

    • 中介实体需要记录createdBy/updatedBy
    • 必须传递当前用户名给异步方法
  4. 轮询超时处理

    • 最多轮询150次(5分钟)
    • 超时后需要提示用户联系管理员

7. 实施计划

7.1 任务分解

任务 负责人 预计时间
1. 后端Service层改造 后端开发 2小时
2. 后端Controller层改造 后端开发 1小时
3. 前端API接口定义 前端开发 0.5小时
4. 前端ImportDialog组件改造 前端开发 2小时
5. 单元测试 测试开发 2小时
6. 集成测试 测试开发 2小时
7. 文档更新 技术文档 1小时

总计: 约10.5小时

7.2 里程碑

  • T+0: 完成设计文档
  • T+1天: 完成后端代码改造和单元测试
  • T+2天: 完成前端代码改造
  • T+3天: 完成集成测试和部署

8. 附录

8.1 相关文档

8.2 参考代码

  • 员工导入Controller: CcdiEmployeeController.java:136-191
  • 员工导入Service: CcdiEmployeeServiceImpl.java:186-208
  • 员工异步导入Service: CcdiEmployeeImportServiceImpl.java:43-109
  • 员工导入前端: ruoyi-ui/src/views/ccdiEmployee/index.vue

8.3 数据字典

导入状态枚举:

状态值 说明
PROCESSING 处理中
SUCCESS 全部成功
PARTIAL_SUCCESS 部分成功(有失败记录)

Redis Key设计:

类型 Key模式 过期时间
个人中介状态 import:intermediary:{taskId} 7天
个人中介失败 import:intermediary:{taskId}:failures 7天
实体中介状态 import:intermediary-entity:{taskId} 7天
实体中介失败 import:intermediary-entity:{taskId}:failures 7天

文档版本: v1.0 最后更新: 2026-02-08 文档状态: 待审核