- 添加异步导入服务接口和实现 - 创建导入失败记录VO类 - 添加导入设计文档和测试数据生成脚本 - 支持大批量招聘数据的异步处理 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
24 KiB
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-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java
操作:
- 创建
RecruitmentImportFailureVO类 - 添加招聘信息相关字段
- 复用
ImportResultVO和ImportStatusVO
步骤2: 创建Service接口
文件:
ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java
操作:
- 创建Service接口
- 定义三个方法:异步导入、查询状态、查询失败记录
步骤3: 实现Service
文件:
ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java
操作:
- 实现
ICcdiStaffRecruitmentImportService接口 - 添加
@EnableAsync注解 - 注入
CcdiStaffRecruitmentMapper和RedisTemplate - 实现异步导入逻辑
- 实现状态查询逻辑
- 实现失败记录查询逻辑
步骤4: 修改Controller
文件:
ruoyi-ccdi/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-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java |
招聘信息导入失败记录VO |
ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java |
招聘信息异步导入Service接口 |
ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java |
招聘信息异步导入Service实现 |
test/test_recruitment_import.py |
测试脚本 |
7.2 修改文件
| 文件路径 | 修改内容 |
|---|---|
ruoyi-ccdi/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 审核状态: 待审核