Files
ccdi/doc/requirements/plans/2026-02-06-recruitment-async-import-design.md
wkc 1cd87d2695 refactor: 重命名 ruoyi-ccdi 模块为 ruoyi-info-collection
- Maven 模块从 ruoyi-ccdi 重命名为 ruoyi-info-collection
- Java 包名从 com.ruoyi.ccdi 改为 com.ruoyi.info.collection
- MyBatis XML 命名空间同步更新
- 保留数据库表名、API URL、权限标识中的 ccdi 前缀
- 更新项目文档中的模块引用
2026-02-24 17:12:11 +08:00

24 KiB

招聘信息异步导入功能设计文档

创建日期: 2026-02-06 设计目标: 将招聘信息管理的文件导入功能改造为异步实现,完全复用员工信息异步导入的架构模式 数据量预期: 小批量(通常<500条)


一、架构概述

1.1 核心架构

招聘信息异步导入完全复用员工信息异步导入的架构模式:

  • 异步处理层: 使用Spring @Async注解,通过现有的importExecutor线程池执行异步任务
  • 状态存储层: 使用Redis Hash存储导入状态,Key格式为import:recruitment:{taskId},TTL为7天
  • 失败记录层: 使用Redis String存储失败记录,Key格式为import:recruitment:{taskId}:failures
  • API层: 提供三个接口 - 导入接口(返回taskId)、状态查询接口、失败记录查询接口

1.2 数据流程

前端上传Excel
    ↓
Controller解析并立即返回taskId
    ↓
异步服务在后台处理:
  1. 数据验证
  2. 分类(新增/更新)
  3. 批量操作
  4. 保存结果到Redis
    ↓
前端每2秒轮询状态
    ↓
状态变为SUCCESS/PARTIAL_SUCCESS/FAILED
    ↓
如有失败,显示"查看失败记录"按钮

1.3 Redis Key设计

  • 状态Key: import:recruitment:{taskId} (Hash结构)
  • 失败记录Key: import:recruitment:{taskId}:failures (String结构,存储JSON数组)
  • TTL: 7天

1.4 状态枚举

状态值 说明 前端行为
PROCESSING 处理中 继续轮询
SUCCESS 全部成功 显示成功通知,刷新列表
PARTIAL_SUCCESS 部分成功 显示警告通知,显示失败按钮
FAILED 全部失败 显示错误通知,显示失败按钮

二、组件设计

2.1 VO类设计

2.1.1 ImportResultVO (复用员工导入)

@Data
@Schema(description = "导入结果")
public class ImportResultVO {
    @Schema(description = "任务ID")
    private String taskId;

    @Schema(description = "状态: PROCESSING-处理中, SUCCESS-成功, PARTIAL_SUCCESS-部分成功, FAILED-失败")
    private String status;

    @Schema(description = "消息")
    private String message;
}

2.1.2 ImportStatusVO (复用员工导入)

@Data
@Schema(description = "导入状态")
public class ImportStatusVO {
    @Schema(description = "任务ID")
    private String taskId;

    @Schema(description = "状态")
    private String status;

    @Schema(description = "总记录数")
    private Integer totalCount;

    @Schema(description = "成功数")
    private Integer successCount;

    @Schema(description = "失败数")
    private Integer failureCount;

    @Schema(description = "进度百分比")
    private Integer progress;

    @Schema(description = "开始时间戳")
    private Long startTime;

    @Schema(description = "结束时间戳")
    private Long endTime;

    @Schema(description = "状态消息")
    private String message;
}

2.1.3 RecruitmentImportFailureVO (新建,适配招聘信息)

@Data
@Schema(description = "招聘信息导入失败记录")
public class RecruitmentImportFailureVO {

    @Schema(description = "招聘项目编号")
    private String recruitId;

    @Schema(description = "招聘项目名称")
    private String recruitName;

    @Schema(description = "应聘人员姓名")
    private String candName;

    @Schema(description = "证件号码")
    private String candId;

    @Schema(description = "录用情况")
    private String admitStatus;

    @Schema(description = "错误信息")
    private String errorMessage;
}

2.2 Service层设计

2.2.1 接口定义

public interface ICcdiStaffRecruitmentImportService {

    /**
     * 异步导入招聘信息数据
     *
     * @param excelList        Excel数据列表
     * @param isUpdateSupport  是否更新已存在的数据
     * @param taskId           任务ID
     */
    void importRecruitmentAsync(List<CcdiStaffRecruitmentExcel> excelList,
                                Boolean isUpdateSupport,
                                String taskId);

    /**
     * 查询导入状态
     *
     * @param taskId 任务ID
     * @return 导入状态信息
     */
    ImportStatusVO getImportStatus(String taskId);

    /**
     * 获取导入失败记录
     *
     * @param taskId 任务ID
     * @return 失败记录列表
     */
    List<RecruitmentImportFailureVO> getImportFailures(String taskId);
}

2.2.2 实现类核心逻辑

类注解:

@Service
@EnableAsync
public class CcdiStaffRecruitmentImportServiceImpl
    implements ICcdiStaffRecruitmentImportService {

    @Resource
    private CcdiStaffRecruitmentMapper recruitmentMapper;

    @Resource
    private RedisTemplate<String, Object> redisTemplate;
}

异步导入方法:

@Override
@Async
public void importRecruitmentAsync(List<CcdiStaffRecruitmentExcel> excelList,
                                   Boolean isUpdateSupport,
                                   String taskId) {
    List<CcdiStaffRecruitment> newRecords = new ArrayList<>();
    List<CcdiStaffRecruitment> updateRecords = new ArrayList<>();
    List<RecruitmentImportFailureVO> failures = new ArrayList<>();

    // 1. 批量查询已存在的招聘项目编号
    Set<String> existingRecruitIds = getExistingRecruitIds(excelList);

    // 2. 分类数据
    for (CcdiStaffRecruitmentExcel excel : excelList) {
        try {
            // 验证数据
            validateRecruitmentData(excel, isUpdateSupport, existingRecruitIds);

            CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
            BeanUtils.copyProperties(excel, recruitment);

            if (existingRecruitIds.contains(excel.getRecruitId())) {
                if (isUpdateSupport) {
                    updateRecords.add(recruitment);
                } else {
                    throw new RuntimeException("该招聘项目编号已存在");
                }
            } else {
                newRecords.add(recruitment);
            }
        } catch (Exception e) {
            RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO();
            BeanUtils.copyProperties(excel, failure);
            failure.setErrorMessage(e.getMessage());
            failures.add(failure);
        }
    }

    // 3. 批量插入新数据
    if (!newRecords.isEmpty()) {
        recruitmentMapper.insertBatch(newRecords);
    }

    // 4. 批量更新已有数据
    if (!updateRecords.isEmpty() && isUpdateSupport) {
        recruitmentMapper.updateBatch(updateRecords);
    }

    // 5. 保存失败记录到Redis
    if (!failures.isEmpty()) {
        String failuresKey = "import:recruitment:" + taskId + ":failures";
        redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS);
    }

    // 6. 更新最终状态
    ImportResult result = new ImportResult();
    result.setTotalCount(excelList.size());
    result.setSuccessCount(newRecords.size() + updateRecords.size());
    result.setFailureCount(failures.size());

    String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS";
    updateImportStatus(taskId, finalStatus, result);
}

2.3 Controller层设计

2.3.1 修改导入接口

@PostMapping("/importData")
public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception {
    List<CcdiStaffRecruitmentExcel> list = EasyExcelUtil.importExcel(
        file.getInputStream(),
        CcdiStaffRecruitmentExcel.class
    );

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

    // 生成任务ID
    String taskId = UUID.randomUUID().toString();

    // 提交异步任务
    importAsyncService.importRecruitmentAsync(list, updateSupport, taskId);

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

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

2.3.2 新增状态查询接口

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

2.3.3 新增失败记录查询接口

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

    List<RecruitmentImportFailureVO> failures =
        importAsyncService.getImportFailures(taskId);

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

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

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

三、数据验证与错误处理

3.1 数据验证规则

3.1.1 必填字段验证

  • 招聘项目编号 (recruitId)
  • 招聘项目名称 (recruitName)
  • 职位名称 (posName)
  • 职位类别 (posCategory)
  • 职位描述 (posDesc)
  • 应聘人员姓名 (candName)
  • 应聘人员学历 (candEdu)
  • 证件号码 (candId)
  • 应聘人员毕业院校 (candSchool)
  • 应聘人员专业 (candMajor)
  • 应聘人员毕业年月 (candGrad)
  • 录用情况 (admitStatus)

3.1.2 格式验证

// 证件号码格式验证
String idCardError = IdCardUtil.getErrorMessage(excel.getCandId());
if (idCardError != null) {
    throw new RuntimeException("证件号码" + idCardError);
}

// 毕业年月格式验证(YYYYMM)
if (!excel.getCandGrad().matches("^((19|20)\\d{2})(0[1-9]|1[0-2])$")) {
    throw new RuntimeException("毕业年月格式不正确,应为YYYYMM");
}

// 录用情况验证
if (AdmitStatus.getDescByCode(excel.getAdmitStatus()) == null) {
    throw new RuntimeException("录用情况只能填写'录用'、'未录用'或'放弃'");
}

3.1.3 唯一性验证

// 批量查询已存在的招聘项目编号
private Set<String> getExistingRecruitIds(List<CcdiStaffRecruitmentExcel> excelList) {
    List<String> recruitIds = excelList.stream()
            .map(CcdiStaffRecruitmentExcel::getRecruitId)
            .filter(StringUtils::isNotEmpty)
            .collect(Collectors.toList());

    if (recruitIds.isEmpty()) {
        return Collections.emptySet();
    }

    LambdaQueryWrapper<CcdiStaffRecruitment> wrapper = new LambdaQueryWrapper<>();
    wrapper.in(CcdiStaffRecruitment::getRecruitId, recruitIds);
    List<CcdiStaffRecruitment> existingRecruitments =
        recruitmentMapper.selectList(wrapper);

    return existingRecruitments.stream()
            .map(CcdiStaffRecruitment::getRecruitId)
            .collect(Collectors.toSet());
}

3.2 错误处理流程

3.2.1 单条数据错误

try {
    // 验证和处理数据
} catch (Exception e) {
    RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO();
    BeanUtils.copyProperties(excel, failure);
    failure.setErrorMessage(e.getMessage());
    failures.add(failure);
    // 继续处理下一条数据
}

3.2.2 状态更新逻辑

private void updateImportStatus(String taskId, String status, ImportResult result) {
    String key = "import:recruitment:" + taskId;
    Map<String, Object> statusData = new HashMap<>();
    statusData.put("status", status);
    statusData.put("successCount", result.getSuccessCount());
    statusData.put("failureCount", result.getFailureCount());
    statusData.put("progress", 100);
    statusData.put("endTime", System.currentTimeMillis());

    if ("SUCCESS".equals(status)) {
        statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据");
    } else {
        statusData.put("message", "成功" + result.getSuccessCount() +
                            "条,失败" + result.getFailureCount() + "条");
    }

    redisTemplate.opsForHash().putAll(key, statusData);
}

四、前端实现

4.1 API定义

ruoyi-ui/src/api/ccdiStaffRecruitment.js 中添加:

// 查询导入状态
export function getImportStatus(taskId) {
  return request({
    url: '/ccdi/staffRecruitment/importStatus/' + taskId,
    method: 'get'
  })
}

// 查询导入失败记录
export function getImportFailures(taskId, pageNum, pageSize) {
  return request({
    url: '/ccdi/staffRecruitment/importFailures/' + taskId,
    method: 'get',
    params: { pageNum, pageSize }
  })
}

4.2 Vue组件修改

ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue 中修改:

4.2.1 data属性

data() {
  return {
    // ...现有data
    pollingTimer: null,
    showFailureButton: false,
    currentTaskId: null,
    failureDialogVisible: false,
    failureList: [],
    failureLoading: false,
    failureTotal: 0,
    failureQueryParams: {
      pageNum: 1,
      pageSize: 10
    }
  }
}

4.2.2 handleFileSuccess方法

handleFileSuccess(response, file, fileList) {
  this.upload.isUploading = false;
  this.upload.open = false;

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

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

    // 开始轮询检查状态
    this.startImportStatusPolling(taskId);
  } else {
    this.$modal.msgError(response.msg);
  }
}

4.2.3 轮询方法

methods: {
  startImportStatusPolling(taskId) {
    this.pollingTimer = setInterval(async () => {
      try {
        const response = await getImportStatus(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
      });
      this.getList();
    } else if (statusResult.failureCount > 0) {
      this.$notify({
        title: '导入完成',
        message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`,
        type: 'warning',
        duration: 5000
      });

      // 显示查看失败记录按钮
      this.showFailureButton = true;
      this.currentTaskId = statusResult.taskId;

      // 刷新列表
      this.getList();
    }
  }
}

4.2.4 生命周期销毁钩子

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

4.2.5 失败记录对话框

模板部分:

<!-- 查看失败记录按钮 -->
<el-col :span="1.5" v-if="showFailureButton">
  <el-button
    type="warning"
    plain
    icon="el-icon-warning"
    size="mini"
    @click="viewImportFailures"
  >查看导入失败记录</el-button>
</el-col>

<!-- 导入失败记录对话框 -->
<el-dialog
  title="导入失败记录"
  :visible.sync="failureDialogVisible"
  width="1200px"
  append-to-body
>
  <el-table :data="failureList" v-loading="failureLoading">
    <el-table-column label="招聘项目编号" prop="recruitId" align="center" />
    <el-table-column label="招聘项目名称" prop="recruitName" align="center" />
    <el-table-column label="应聘人员姓名" prop="candName" align="center" />
    <el-table-column label="证件号码" prop="candId" align="center" />
    <el-table-column label="录用情况" prop="admitStatus" align="center" />
    <el-table-column label="失败原因" prop="errorMessage" align="center" min-width="200" :show-overflow-tooltip="true" />
  </el-table>

  <pagination
    v-show="failureTotal > 0"
    :total="failureTotal"
    :page.sync="failureQueryParams.pageNum"
    :limit.sync="failureQueryParams.pageSize"
    @pagination="getFailureList"
  />

  <div slot="footer" class="dialog-footer">
    <el-button @click="failureDialogVisible = false">关闭</el-button>
  </div>
</el-dialog>

方法部分:

methods: {
  viewImportFailures() {
    this.failureDialogVisible = true;
    this.getFailureList();
  },

  getFailureList() {
    this.failureLoading = true;
    getImportFailures(
      this.currentTaskId,
      this.failureQueryParams.pageNum,
      this.failureQueryParams.pageSize
    ).then(response => {
      this.failureList = response.rows;
      this.failureTotal = response.total;
      this.failureLoading = false;
    }).catch(error => {
      this.failureLoading = false;
      this.$modal.msgError('查询失败记录失败: ' + error.message);
    });
  }
}

五、测试计划

5.1 功能测试

测试项 测试内容 预期结果
正常导入 导入100-500条有效数据 全部成功,状态为SUCCESS
重复导入-不更新 recruitId已存在,updateSupport=false 导入失败,提示"该招聘项目编号已存在"
重复导入-更新 recruitId已存在,updateSupport=true 更新已有数据,状态为SUCCESS
部分错误 混合有效数据和无效数据 部分成功,状态为PARTIAL_SUCCESS
状态查询 调用getImportStatus接口 返回正确状态和进度
失败记录查询 调用getImportFailures接口 返回失败记录列表,支持分页
前端轮询 导入后观察轮询行为 每2秒查询一次,完成后停止
完成通知 导入完成后观察通知 显示正确的成功/警告通知
失败记录UI 点击"查看失败记录"按钮 显示对话框,正确展示失败数据

5.2 性能测试

测试项 测试数据量 性能要求
导入接口响应时间 任意 < 500ms(立即返回taskId)
数据处理时间 500条 < 5秒
数据处理时间 1000条 < 10秒
Redis存储 任意 数据正确存储,TTL为7天
前端轮询 任意 不阻塞UI,不影响用户操作

5.3 异常测试

测试项 测试内容 预期结果
空文件 上传空Excel文件 返回错误提示"至少需要一条数据"
格式错误 上传非Excel文件 解析失败,返回错误提示
不存在的taskId 查询导入状态时传入随机UUID 返回错误提示"任务不存在或已过期"
并发导入 同时上传3个Excel文件 生成3个不同的taskId,各自独立处理,互不影响
网络中断 导入过程中断开网络 异步任务继续执行,恢复后可查询状态

5.4 数据验证测试

测试项 测试内容 预期结果
必填字段缺失 缺少recruitId、candName等必填字段 记录到失败列表,提示具体字段不能为空
证件号格式错误 填写错误的身份证号 记录到失败列表,提示证件号码格式错误
毕业年月格式错误 填写非YYYYMM格式 记录到失败列表,提示毕业年月格式不正确
录用情况无效 填写"录用"、"未录用"、"放弃"之外的值 记录到失败列表,提示录用情况只能填写指定值

六、实施步骤

6.1 后端实施步骤

步骤1: 创建VO类

文件:

  • ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java

操作:

  • 创建RecruitmentImportFailureVO
  • 添加招聘信息相关字段
  • 复用ImportResultVOImportStatusVO

步骤2: 创建Service接口

文件:

  • ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java

操作:

  • 创建Service接口
  • 定义三个方法:异步导入、查询状态、查询失败记录

步骤3: 实现Service

文件:

  • ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java

操作:

  • 实现ICcdiStaffRecruitmentImportService接口
  • 添加@EnableAsync注解
  • 注入CcdiStaffRecruitmentMapperRedisTemplate
  • 实现异步导入逻辑
  • 实现状态查询逻辑
  • 实现失败记录查询逻辑

步骤4: 修改Controller

文件:

  • ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/controller/CcdiStaffRecruitmentController.java

操作:

  • 注入ICcdiStaffRecruitmentImportService
  • 修改importData()方法:调用异步服务,返回taskId
  • 添加getImportStatus()方法
  • 添加getImportFailures()方法
  • 添加Swagger注解

6.2 前端实施步骤

步骤5: 修改API定义

文件:

  • ruoyi-ui/src/api/ccdiStaffRecruitment.js

操作:

  • 添加getImportStatus()方法
  • 添加getImportFailures()方法

步骤6: 修改Vue组件

文件:

  • ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue

操作:

  • 添加data属性(pollingTimer、showFailureButton等)
  • 修改handleFileSuccess()方法
  • 添加startImportStatusPolling()方法
  • 添加handleImportComplete()方法
  • 添加viewImportFailures()方法
  • 添加getFailureList()方法
  • 添加beforeDestroy()生命周期钩子
  • 添加"查看失败记录"按钮
  • 添加失败记录对话框

6.3 测试与文档

步骤7: 生成测试脚本

文件:

  • test/test_recruitment_import.py

操作:

  • 编写测试脚本
  • 包含:登录、导入、状态查询、失败记录查询等测试用例

步骤8: 手动测试

操作:

  • 启动后端服务
  • 启动前端服务
  • 执行完整功能测试
  • 记录测试结果

步骤9: 更新API文档

文件:

  • doc/api/ccdi_staff_recruitment_api.md

操作:

  • 添加导入相关接口文档
  • 包含:请求参数、响应示例、错误码说明

步骤10: 代码提交

操作:

git add .
git commit -m "feat: 实现招聘信息异步导入功能"

七、文件清单

7.1 新增文件

文件路径 说明
ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java 招聘信息导入失败记录VO
ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java 招聘信息异步导入Service接口
ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java 招聘信息异步导入Service实现
test/test_recruitment_import.py 测试脚本

7.2 修改文件

文件路径 修改内容
ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/controller/CcdiStaffRecruitmentController.java 修改导入接口,添加状态查询和失败记录查询接口
ruoyi-ui/src/api/ccdiStaffRecruitment.js 添加导入状态和失败记录查询API
ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue 添加轮询逻辑和失败记录UI
doc/api/ccdi_staff_recruitment_api.md 更新API文档

7.3 复用组件

组件 说明
ImportResultVO 导入结果VO(复用员工导入)
ImportStatusVO 导入状态VO(复用员工导入)
AsyncConfig 异步配置(复用员工导入)
importExecutor 导入任务线程池(复用员工导入)

八、参考文档

  • 员工信息异步导入实施计划: doc/plans/2026-02-06-employee-async-import.md
  • 员工信息异步导入设计文档: doc/plans/2026-02-06-employee-async-import-design.md
  • 员工信息导入API文档: doc/api/ccdi-employee-import-api.md

设计版本: 1.0 创建日期: 2026-02-06 设计人员: Claude 审核状态: 待审核