feat: 实现招聘信息异步导入功能
- 添加异步导入服务接口和实现 - 创建导入失败记录VO类 - 添加导入设计文档和测试数据生成脚本 - 支持大批量招聘数据的异步处理 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
1210
doc/plans/2026-02-06-ccdi_purchase_transaction.md
Normal file
1210
doc/plans/2026-02-06-ccdi_purchase_transaction.md
Normal file
File diff suppressed because it is too large
Load Diff
846
doc/plans/2026-02-06-recruitment-async-import-design.md
Normal file
846
doc/plans/2026-02-06-recruitment-async-import-design.md
Normal file
@@ -0,0 +1,846 @@
|
||||
# 招聘信息异步导入功能设计文档
|
||||
|
||||
**创建日期:** 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 (复用员工导入)
|
||||
|
||||
```java
|
||||
@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 (复用员工导入)
|
||||
|
||||
```java
|
||||
@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 (新建,适配招聘信息)
|
||||
|
||||
```java
|
||||
@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 接口定义
|
||||
|
||||
```java
|
||||
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 实现类核心逻辑
|
||||
|
||||
**类注解:**
|
||||
```java
|
||||
@Service
|
||||
@EnableAsync
|
||||
public class CcdiStaffRecruitmentImportServiceImpl
|
||||
implements ICcdiStaffRecruitmentImportService {
|
||||
|
||||
@Resource
|
||||
private CcdiStaffRecruitmentMapper recruitmentMapper;
|
||||
|
||||
@Resource
|
||||
private RedisTemplate<String, Object> redisTemplate;
|
||||
}
|
||||
```
|
||||
|
||||
**异步导入方法:**
|
||||
```java
|
||||
@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 修改导入接口
|
||||
|
||||
```java
|
||||
@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 新增状态查询接口
|
||||
|
||||
```java
|
||||
@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 新增失败记录查询接口
|
||||
|
||||
```java
|
||||
@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 格式验证
|
||||
|
||||
```java
|
||||
// 证件号码格式验证
|
||||
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 唯一性验证
|
||||
|
||||
```java
|
||||
// 批量查询已存在的招聘项目编号
|
||||
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 单条数据错误
|
||||
|
||||
```java
|
||||
try {
|
||||
// 验证和处理数据
|
||||
} catch (Exception e) {
|
||||
RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO();
|
||||
BeanUtils.copyProperties(excel, failure);
|
||||
failure.setErrorMessage(e.getMessage());
|
||||
failures.add(failure);
|
||||
// 继续处理下一条数据
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2.2 状态更新逻辑
|
||||
|
||||
```java
|
||||
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` 中添加:
|
||||
|
||||
```javascript
|
||||
// 查询导入状态
|
||||
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属性
|
||||
|
||||
```javascript
|
||||
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方法
|
||||
|
||||
```javascript
|
||||
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 轮询方法
|
||||
|
||||
```javascript
|
||||
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 生命周期销毁钩子
|
||||
|
||||
```javascript
|
||||
beforeDestroy() {
|
||||
// 组件销毁时清除定时器
|
||||
if (this.pollingTimer) {
|
||||
clearInterval(this.pollingTimer);
|
||||
this.pollingTimer = null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2.5 失败记录对话框
|
||||
|
||||
**模板部分:**
|
||||
```vue
|
||||
<!-- 查看失败记录按钮 -->
|
||||
<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>
|
||||
```
|
||||
|
||||
**方法部分:**
|
||||
```javascript
|
||||
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: 代码提交
|
||||
|
||||
**操作:**
|
||||
```bash
|
||||
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
|
||||
**审核状态:** 待审核
|
||||
271
doc/scripts/generate_recruitment_test_data.py
Normal file
271
doc/scripts/generate_recruitment_test_data.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
招聘信息测试数据生成器
|
||||
生成符合校验规则的招聘信息测试数据并保存到Excel文件
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Font, Alignment, PatternFill
|
||||
|
||||
# 数据配置
|
||||
RECRUIT_COUNT = 2000 # 生成数据条数
|
||||
|
||||
# 招聘项目名称列表
|
||||
RECRUIT_NAMES = [
|
||||
"2025春季校园招聘", "2025秋季校园招聘", "2025社会招聘", "2025技术专项招聘",
|
||||
"2025管培生招聘", "2025实习生招聘", "2025高端人才引进", "2025春季研发岗招聘",
|
||||
"2025夏季校园招聘", "2025冬季校园招聘", "2025春季销售岗招聘", "2025秋季市场岗招聘",
|
||||
"2025春季运营岗招聘", "2025秋季产品岗招聘", "2025春季客服岗招聘", "2025秋季人事岗招聘"
|
||||
]
|
||||
|
||||
# 职位名称列表
|
||||
POSITION_NAMES = [
|
||||
"Java开发工程师", "Python开发工程师", "前端开发工程师", "后端开发工程师",
|
||||
"全栈工程师", "算法工程师", "数据分析师", "产品经理",
|
||||
"UI设计师", "测试工程师", "运维工程师", "架构师",
|
||||
"软件工程师", "系统分析师", "数据库管理员", "网络工程师",
|
||||
"移动端开发工程师", "嵌入式开发工程师", "大数据工程师", "人工智能工程师"
|
||||
]
|
||||
|
||||
# 职位类别
|
||||
POSITION_CATEGORIES = [
|
||||
"技术类", "产品类", "设计类", "运营类",
|
||||
"市场类", "销售类", "客服类", "人事类",
|
||||
"财务类", "行政类", "管理类", "研发类"
|
||||
]
|
||||
|
||||
# 职位描述模板
|
||||
POSITION_DESCS = [
|
||||
"负责公司核心业务系统的设计和开发,要求熟悉相关技术栈,具备良好的编码规范和团队协作能力。",
|
||||
"参与产品需求分析和技术方案设计,负责模块开发和维护,优化系统性能,保障系统稳定性。",
|
||||
"负责系统架构设计和技术选型,解决技术难题,指导团队成员开发,推动技术创新。",
|
||||
"负责数据采集、清洗、分析和可视化,为业务决策提供数据支持,优化业务流程。",
|
||||
"负责产品规划、需求分析和产品设计,协调研发、测试、运营等团队,推动产品落地。",
|
||||
"负责用户界面设计和用户体验优化,与产品经理和开发团队协作,确保设计还原度。",
|
||||
"负责系统测试和质量保障,编写测试用例,执行测试,跟踪缺陷,保障产品质量。",
|
||||
"负责系统运维和监控,保障系统稳定运行,优化系统性能,处理故障和应急响应。"
|
||||
]
|
||||
|
||||
# 常见姓氏和名字
|
||||
SURNAMES = ["王", "李", "张", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "林", "罗"]
|
||||
GIVEN_NAMES = ["伟", "芳", "娜", "敏", "静", "丽", "强", "磊", "军", "洋", "勇", "艳", "杰", "娟", "涛", "明", "超", "秀英", "华", "英"]
|
||||
|
||||
# 学历列表
|
||||
EDUCATIONS = ["本科", "硕士", "博士", "大专", "高中"]
|
||||
|
||||
# 毕业院校列表
|
||||
UNIVERSITIES = [
|
||||
"清华大学", "北京大学", "复旦大学", "上海交通大学", "浙江大学", "中国科学技术大学",
|
||||
"南京大学", "中山大学", "华中科技大学", "哈尔滨工业大学", "西安交通大学", "北京理工大学",
|
||||
"中国人民大学", "北京航空航天大学", "同济大学", "南开大学", "天津大学", "东南大学",
|
||||
"武汉大学", "厦门大学", "山东大学", "四川大学", "吉林大学", "中南大学",
|
||||
"华南理工大学", "西北工业大学", "华东师范大学", "北京师范大学", "重庆大学"
|
||||
]
|
||||
|
||||
# 专业列表
|
||||
MAJORS = [
|
||||
"计算机科学与技术", "软件工程", "人工智能", "数据科学与大数据技术", "物联网工程",
|
||||
"电子信息工程", "通信工程", "自动化", "电气工程及其自动化", "机械工程",
|
||||
"材料科学与工程", "化学工程与工艺", "生物工程", "环境工程", "土木工程",
|
||||
"数学与应用数学", "统计学", "物理学", "化学", "生物学",
|
||||
"工商管理", "市场营销", "会计学", "金融学", "国际经济与贸易",
|
||||
"人力资源管理", "公共事业管理", "行政管理", "法学", "汉语言文学",
|
||||
"英语", "日语", "新闻传播学", "广告学", "艺术设计"
|
||||
]
|
||||
|
||||
# 录用状态
|
||||
ADMIT_STATUSES = ["录用", "未录用", "放弃"]
|
||||
|
||||
# 面试官姓名和工号
|
||||
INTERVIEWERS = [
|
||||
("张伟", "INT001"), ("李芳", "INT002"), ("王磊", "INT003"), ("刘娜", "INT004"),
|
||||
("陈军", "INT005"), ("杨静", "INT006"), ("黄勇", "INT007"), ("赵丽", "INT008"),
|
||||
("周涛", "INT009"), ("吴明", "INT010"), ("徐超", "INT011"), ("孙杰", "INT012"),
|
||||
("马娟", "INT013"), ("朱华", "INT014"), ("胡英", "INT015"), ("郭强", "INT016")
|
||||
]
|
||||
|
||||
|
||||
def generate_chinese_name():
|
||||
"""生成中文姓名"""
|
||||
surname = random.choice(SURNAMES)
|
||||
# 50%概率双字名,50%概率单字名
|
||||
if random.random() > 0.5:
|
||||
given_name = random.choice(GIVEN_NAMES) + random.choice(GIVEN_NAMES)
|
||||
else:
|
||||
given_name = random.choice(GIVEN_NAMES)
|
||||
return surname + given_name
|
||||
|
||||
|
||||
def generate_id_number():
|
||||
"""生成18位身份证号码"""
|
||||
# 地区码(前6位)
|
||||
area_code = f"{random.randint(110000, 659001):06d}"
|
||||
|
||||
# 出生日期(8位) - 生成1990-2005年的出生日期
|
||||
birth_year = random.randint(1990, 2005)
|
||||
birth_month = f"{random.randint(1, 12):02d}"
|
||||
birth_day = f"{random.randint(1, 28):02d}"
|
||||
birth_date = f"{birth_year}{birth_month}{birth_day}"
|
||||
|
||||
# 顺序码(3位)
|
||||
sequence_code = f"{random.randint(1, 999):03d}"
|
||||
|
||||
# 前17位
|
||||
id_17 = area_code + birth_date + sequence_code
|
||||
|
||||
# 计算校验码(最后1位)
|
||||
weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
|
||||
check_codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2']
|
||||
|
||||
total = sum(int(id_17[i]) * weights[i] for i in range(17))
|
||||
check_code = check_codes[total % 11]
|
||||
|
||||
return id_17 + check_code
|
||||
|
||||
|
||||
def generate_graduation_date():
|
||||
"""生成毕业年月(YYYYMM格式)"""
|
||||
# 生成2020-2030年之间的毕业年月
|
||||
year = random.randint(2020, 2030)
|
||||
month = f"{random.randint(1, 12):02d}"
|
||||
return f"{year}{month}"
|
||||
|
||||
|
||||
def generate_recruitment_data(start_index):
|
||||
"""生成招聘测试数据"""
|
||||
data = []
|
||||
|
||||
for i in range(start_index, start_index + RECRUIT_COUNT):
|
||||
# 生成招聘项目编号
|
||||
recruit_id = f"REC{datetime.now().strftime('%Y%m%d')}{i:06d}"
|
||||
|
||||
# 选择面试官(50%概率有两个面试官,50%概率只有一个)
|
||||
if random.random() > 0.5:
|
||||
interviewer1_name, interviewer1_id = random.choice(INTERVIEWERS)
|
||||
interviewer2_name, interviewer2_id = random.choice(INTERVIEWERS)
|
||||
else:
|
||||
interviewer1_name, interviewer1_id = random.choice(INTERVIEWERS)
|
||||
interviewer2_name = ""
|
||||
interviewer2_id = ""
|
||||
|
||||
row_data = [
|
||||
recruit_id, # 招聘项目编号
|
||||
random.choice(RECRUIT_NAMES), # 招聘项目名称
|
||||
random.choice(POSITION_NAMES), # 职位名称
|
||||
random.choice(POSITION_CATEGORIES), # 职位类别
|
||||
random.choice(POSITION_DESCS), # 职位描述
|
||||
generate_chinese_name(), # 应聘人员姓名
|
||||
random.choice(EDUCATIONS), # 应聘人员学历
|
||||
generate_id_number(), # 应聘人员证件号码
|
||||
random.choice(UNIVERSITIES), # 应聘人员毕业院校
|
||||
random.choice(MAJORS), # 应聘人员专业
|
||||
generate_graduation_date(), # 应聘人员毕业年月
|
||||
random.choice(ADMIT_STATUSES), # 录用情况
|
||||
interviewer1_name, # 面试官1姓名
|
||||
interviewer1_id, # 面试官1工号
|
||||
interviewer2_name, # 面试官2姓名
|
||||
interviewer2_id # 面试官2工号
|
||||
]
|
||||
|
||||
data.append(row_data)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
def create_excel(data, filename):
|
||||
"""创建Excel文件"""
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "招聘信息"
|
||||
|
||||
# 表头
|
||||
headers = [
|
||||
"招聘项目编号", "招聘项目名称", "职位名称", "职位类别", "职位描述",
|
||||
"应聘人员姓名", "应聘人员学历", "应聘人员证件号码", "应聘人员毕业院校",
|
||||
"应聘人员专业", "应聘人员毕业年月", "录用情况",
|
||||
"面试官1姓名", "面试官1工号", "面试官2姓名", "面试官2工号"
|
||||
]
|
||||
|
||||
# 写入表头
|
||||
ws.append(headers)
|
||||
|
||||
# 设置表头样式
|
||||
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
|
||||
header_font = Font(bold=True, color="FFFFFF")
|
||||
|
||||
for col_num, header in enumerate(headers, 1):
|
||||
cell = ws.cell(row=1, column=col_num)
|
||||
cell.fill = header_fill
|
||||
cell.font = header_font
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
# 写入数据
|
||||
for row_data in data:
|
||||
ws.append(row_data)
|
||||
|
||||
# 设置列宽
|
||||
column_widths = [20, 20, 20, 15, 30, 15, 15, 20, 20, 15, 15, 10, 15, 15, 15, 15]
|
||||
for col_num, width in enumerate(column_widths, 1):
|
||||
ws.column_dimensions[chr(64 + col_num)].width = width
|
||||
|
||||
# 设置所有单元格居中对齐
|
||||
for row in ws.iter_rows(min_row=1, max_row=ws.max_row, min_col=1, max_col=ws.max_column):
|
||||
for cell in row:
|
||||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||
|
||||
# 保存文件
|
||||
wb.save(filename)
|
||||
print(f"✓ 已生成文件: {filename}")
|
||||
print(f" 数据行数: {len(data)}")
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("=" * 70)
|
||||
print("招聘信息测试数据生成器")
|
||||
print("=" * 70)
|
||||
|
||||
# 检查是否安装了openpyxl
|
||||
try:
|
||||
import openpyxl
|
||||
except ImportError:
|
||||
print("✗ 未安装openpyxl库,正在安装...")
|
||||
import subprocess
|
||||
subprocess.check_call(["pip", "install", "openpyxl"])
|
||||
print("✓ openpyxl库安装成功")
|
||||
|
||||
print(f"\n配置信息:")
|
||||
print(f" - 生成数据量: {RECRUIT_COUNT} 条/文件")
|
||||
print(f" - 生成文件数: 2 个")
|
||||
print(f" - 总数据量: {RECRUIT_COUNT * 2} 条")
|
||||
|
||||
print(f"\n开始生成数据...")
|
||||
|
||||
# 生成第一个文件
|
||||
print(f"\n正在生成第1个文件...")
|
||||
data1 = generate_recruitment_data(1)
|
||||
filename1 = "doc/test-data/recruitment/recruitment_test_data_2000_1.xlsx"
|
||||
create_excel(data1, filename1)
|
||||
|
||||
# 生成第二个文件
|
||||
print(f"\n正在生成第2个文件...")
|
||||
data2 = generate_recruitment_data(RECRUIT_COUNT + 1)
|
||||
filename2 = "doc/test-data/recruitment/recruitment_test_data_2000_2.xlsx"
|
||||
create_excel(data2, filename2)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("✓ 所有文件生成完成!")
|
||||
print("=" * 70)
|
||||
print(f"\n生成的文件:")
|
||||
print(f" 1. {filename1}")
|
||||
print(f" 2. {filename2}")
|
||||
print(f"\n数据统计:")
|
||||
print(f" - 总数据量: {RECRUIT_COUNT * 2} 条")
|
||||
print(f" - 文件1: {len(data1)} 条")
|
||||
print(f" - 文件2: {len(data2)} 条")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.ruoyi.ccdi.domain.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 招聘信息导入失败记录VO
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-02-06
|
||||
*/
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.ruoyi.ccdi.service;
|
||||
|
||||
import com.ruoyi.ccdi.domain.excel.CcdiStaffRecruitmentExcel;
|
||||
import com.ruoyi.ccdi.domain.vo.ImportStatusVO;
|
||||
import com.ruoyi.ccdi.domain.vo.RecruitmentImportFailureVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 招聘信息异步导入Service
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-02-06
|
||||
*/
|
||||
public interface ICcdiStaffRecruitmentImportService {
|
||||
|
||||
/**
|
||||
* 异步导入招聘信息数据
|
||||
*
|
||||
* @param excelList Excel数据列表
|
||||
* @param isUpdateSupport 是否更新已存在的数据
|
||||
* @param taskId 任务ID
|
||||
*/
|
||||
void importRecruitmentAsync(List<CcdiStaffRecruitmentExcel> excelList,
|
||||
Boolean isUpdateSupport,
|
||||
String taskId,
|
||||
String userName);
|
||||
|
||||
/**
|
||||
* 查询导入状态
|
||||
*
|
||||
* @param taskId 任务ID
|
||||
* @return 导入状态信息
|
||||
*/
|
||||
ImportStatusVO getImportStatus(String taskId);
|
||||
|
||||
/**
|
||||
* 获取导入失败记录
|
||||
*
|
||||
* @param taskId 任务ID
|
||||
* @return 失败记录列表
|
||||
*/
|
||||
List<RecruitmentImportFailureVO> getImportFailures(String taskId);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
package com.ruoyi.ccdi.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.ruoyi.ccdi.domain.CcdiStaffRecruitment;
|
||||
import com.ruoyi.ccdi.domain.excel.CcdiStaffRecruitmentExcel;
|
||||
import com.ruoyi.ccdi.domain.vo.ImportResult;
|
||||
import com.ruoyi.ccdi.domain.vo.ImportStatusVO;
|
||||
import com.ruoyi.ccdi.domain.vo.RecruitmentImportFailureVO;
|
||||
import com.ruoyi.ccdi.enums.AdmitStatus;
|
||||
import com.ruoyi.ccdi.mapper.CcdiStaffRecruitmentMapper;
|
||||
import com.ruoyi.ccdi.service.ICcdiStaffRecruitmentImportService;
|
||||
import com.ruoyi.common.utils.IdCardUtil;
|
||||
import com.ruoyi.common.utils.StringUtils;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.data.redis.core.RedisTemplate;
|
||||
import org.springframework.scheduling.annotation.Async;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 招聘信息异步导入Service实现
|
||||
*
|
||||
* @author ruoyi
|
||||
* @date 2026-02-06
|
||||
*/
|
||||
@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,
|
||||
String userName) {
|
||||
long startTime = System.currentTimeMillis();
|
||||
|
||||
// 初始化Redis状态
|
||||
String statusKey = "import:recruitment:" + taskId;
|
||||
Map<String, Object> statusData = new HashMap<>();
|
||||
statusData.put("taskId", taskId);
|
||||
statusData.put("status", "PROCESSING");
|
||||
statusData.put("totalCount", excelList.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);
|
||||
|
||||
List<CcdiStaffRecruitment> newRecords = new ArrayList<>();
|
||||
List<CcdiStaffRecruitment> updateRecords = new ArrayList<>();
|
||||
List<RecruitmentImportFailureVO> failures = new ArrayList<>();
|
||||
|
||||
// 批量查询已存在的招聘项目编号
|
||||
Set<String> existingRecruitIds = getExistingRecruitIds(excelList);
|
||||
|
||||
// 分类数据
|
||||
for (CcdiStaffRecruitmentExcel excel : excelList) {
|
||||
try {
|
||||
// 验证数据
|
||||
validateRecruitmentData(excel, isUpdateSupport, existingRecruitIds);
|
||||
|
||||
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
|
||||
BeanUtils.copyProperties(excel, recruitment);
|
||||
|
||||
if (existingRecruitIds.contains(excel.getRecruitId())) {
|
||||
if (isUpdateSupport) {
|
||||
recruitment.setUpdatedBy(userName);
|
||||
updateRecords.add(recruitment);
|
||||
} else {
|
||||
throw new RuntimeException("该招聘项目编号已存在");
|
||||
}
|
||||
} else {
|
||||
recruitment.setCreatedBy(userName);
|
||||
recruitment.setUpdatedBy(userName);
|
||||
newRecords.add(recruitment);
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO();
|
||||
BeanUtils.copyProperties(excel, failure);
|
||||
failure.setErrorMessage(e.getMessage());
|
||||
failures.add(failure);
|
||||
}
|
||||
}
|
||||
|
||||
// 批量插入新数据
|
||||
if (!newRecords.isEmpty()) {
|
||||
recruitmentMapper.insertBatch(newRecords);
|
||||
}
|
||||
|
||||
// 批量更新已有数据
|
||||
if (!updateRecords.isEmpty() && isUpdateSupport) {
|
||||
recruitmentMapper.updateBatch(updateRecords);
|
||||
}
|
||||
|
||||
// 保存失败记录到Redis
|
||||
if (!failures.isEmpty()) {
|
||||
String failuresKey = "import:recruitment:" + taskId + ":failures";
|
||||
redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS);
|
||||
}
|
||||
|
||||
// 更新最终状态
|
||||
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);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ImportStatusVO getImportStatus(String taskId) {
|
||||
String key = "import:recruitment:" + taskId;
|
||||
Boolean hasKey = redisTemplate.hasKey(key);
|
||||
|
||||
if (Boolean.FALSE.equals(hasKey)) {
|
||||
throw new RuntimeException("任务不存在或已过期");
|
||||
}
|
||||
|
||||
Map<Object, Object> statusMap = redisTemplate.opsForHash().entries(key);
|
||||
|
||||
ImportStatusVO statusVO = new ImportStatusVO();
|
||||
statusVO.setTaskId((String) statusMap.get("taskId"));
|
||||
statusVO.setStatus((String) statusMap.get("status"));
|
||||
statusVO.setTotalCount((Integer) statusMap.get("totalCount"));
|
||||
statusVO.setSuccessCount((Integer) statusMap.get("successCount"));
|
||||
statusVO.setFailureCount((Integer) statusMap.get("failureCount"));
|
||||
statusVO.setProgress((Integer) statusMap.get("progress"));
|
||||
statusVO.setStartTime((Long) statusMap.get("startTime"));
|
||||
statusVO.setEndTime((Long) statusMap.get("endTime"));
|
||||
statusVO.setMessage((String) statusMap.get("message"));
|
||||
|
||||
return statusVO;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RecruitmentImportFailureVO> getImportFailures(String taskId) {
|
||||
String key = "import:recruitment:" + taskId + ":failures";
|
||||
Object failuresObj = redisTemplate.opsForValue().get(key);
|
||||
|
||||
if (failuresObj == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
return JSON.parseArray(JSON.toJSONString(failuresObj), RecruitmentImportFailureVO.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量查询已存在的招聘项目编号
|
||||
*/
|
||||
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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证招聘信息数据
|
||||
*/
|
||||
private void validateRecruitmentData(CcdiStaffRecruitmentExcel excel,
|
||||
Boolean isUpdateSupport,
|
||||
Set<String> existingRecruitIds) {
|
||||
// 验证必填字段
|
||||
if (StringUtils.isEmpty(excel.getRecruitId())) {
|
||||
throw new RuntimeException("招聘项目编号不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getRecruitName())) {
|
||||
throw new RuntimeException("招聘项目名称不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getPosName())) {
|
||||
throw new RuntimeException("职位名称不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getPosCategory())) {
|
||||
throw new RuntimeException("职位类别不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getPosDesc())) {
|
||||
throw new RuntimeException("职位描述不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getCandName())) {
|
||||
throw new RuntimeException("应聘人员姓名不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getCandEdu())) {
|
||||
throw new RuntimeException("应聘人员学历不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getCandId())) {
|
||||
throw new RuntimeException("证件号码不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getCandSchool())) {
|
||||
throw new RuntimeException("应聘人员毕业院校不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getCandMajor())) {
|
||||
throw new RuntimeException("应聘人员专业不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getCandGrad())) {
|
||||
throw new RuntimeException("应聘人员毕业年月不能为空");
|
||||
}
|
||||
if (StringUtils.isEmpty(excel.getAdmitStatus())) {
|
||||
throw new RuntimeException("录用情况不能为空");
|
||||
}
|
||||
|
||||
// 验证证件号码格式
|
||||
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("录用情况只能填写'录用'、'未录用'或'放弃'");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新导入状态
|
||||
*/
|
||||
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("totalCount", result.getTotalCount());
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user