Compare commits
17 Commits
4c3eeea256
...
3d4a42b9fb
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d4a42b9fb | |||
| 61e8d45212 | |||
| 0b0655174a | |||
| 50ac577297 | |||
| 20bead7ddf | |||
| 9aee2b4cde | |||
| 765ab7bc8d | |||
| db46521c8b | |||
| d709183561 | |||
| 6101d94d82 | |||
| d5af1602f9 | |||
| 8bdce0adbf | |||
| e8a4b53a0e | |||
| 97bb899093 | |||
| e00cc59eed | |||
| 0aa812c283 | |||
| ce4000f477 |
@@ -82,7 +82,9 @@
|
|||||||
"Bash(git reset:*)",
|
"Bash(git reset:*)",
|
||||||
"Skill(xlsx)",
|
"Skill(xlsx)",
|
||||||
"mcp__chrome-devtools__evaluate_script",
|
"mcp__chrome-devtools__evaluate_script",
|
||||||
"Skill(superpowers:using-git-worktrees)"
|
"Skill(superpowers:using-git-worktrees)",
|
||||||
|
"Bash(git -C D:ccdiccdi show 97bb899 --stat)",
|
||||||
|
"Bash(git show:*)"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"enabledMcpjsonServers": [
|
"enabledMcpjsonServers": [
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -18,6 +18,7 @@ target/
|
|||||||
.project
|
.project
|
||||||
.settings
|
.settings
|
||||||
.springBeans
|
.springBeans
|
||||||
|
.claude
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
### IntelliJ IDEA ###
|
||||||
.idea
|
.idea
|
||||||
|
|||||||
124
doc/api/ccdi-employee-import-api.md
Normal file
124
doc/api/ccdi-employee-import-api.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# 员工信息导入相关接口文档
|
||||||
|
|
||||||
|
## 1. 导入员工信息(异步)
|
||||||
|
|
||||||
|
**接口地址:** `POST /ccdi/employee/importData`
|
||||||
|
|
||||||
|
**权限标识:** `ccdi:employee:import`
|
||||||
|
|
||||||
|
**请求参数:**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| file | File | 是 | Excel文件 |
|
||||||
|
| updateSupport | boolean | 否 | 是否更新已存在的数据,默认false |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "导入任务已提交,正在后台处理",
|
||||||
|
"data": {
|
||||||
|
"taskId": "uuid-string",
|
||||||
|
"status": "PROCESSING",
|
||||||
|
"message": "导入任务已提交,正在后台处理"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 查询导入状态
|
||||||
|
|
||||||
|
**接口地址:** `GET /ccdi/employee/importStatus/{taskId}`
|
||||||
|
|
||||||
|
**权限标识:** 无
|
||||||
|
|
||||||
|
**路径参数:**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| taskId | String | 是 | 任务ID |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"taskId": "uuid-string",
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"totalCount": 100,
|
||||||
|
"successCount": 95,
|
||||||
|
"failureCount": 5,
|
||||||
|
"progress": 100,
|
||||||
|
"startTime": 1707225600000,
|
||||||
|
"endTime": 1707225900000,
|
||||||
|
"message": "导入完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**状态说明:**
|
||||||
|
|
||||||
|
| 状态值 | 说明 |
|
||||||
|
|--------|------|
|
||||||
|
| PROCESSING | 处理中 |
|
||||||
|
| SUCCESS | 全部成功 |
|
||||||
|
| PARTIAL_SUCCESS | 部分成功 |
|
||||||
|
| FAILED | 全部失败 |
|
||||||
|
|
||||||
|
## 3. 查询导入失败记录
|
||||||
|
|
||||||
|
**接口地址:** `GET /ccdi/employee/importFailures/{taskId}`
|
||||||
|
|
||||||
|
**权限标识:** 无
|
||||||
|
|
||||||
|
**路径参数:**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| taskId | String | 是 | 任务ID |
|
||||||
|
|
||||||
|
**查询参数:**
|
||||||
|
|
||||||
|
| 参数名 | 类型 | 必填 | 说明 |
|
||||||
|
|--------|------|------|------|
|
||||||
|
| pageNum | Integer | 否 | 页码,默认1 |
|
||||||
|
| pageSize | Integer | 否 | 每页条数,默认10 |
|
||||||
|
|
||||||
|
**响应示例:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"rows": [
|
||||||
|
{
|
||||||
|
"employeeId": "1234567",
|
||||||
|
"name": "张三",
|
||||||
|
"idCard": "110101199001011234",
|
||||||
|
"deptId": 100,
|
||||||
|
"phone": "13800138000",
|
||||||
|
"status": "0",
|
||||||
|
"hireDate": "2020-01-01",
|
||||||
|
"errorMessage": "身份证号格式错误"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用流程
|
||||||
|
|
||||||
|
1. 前端调用导入接口上传Excel文件
|
||||||
|
2. 后端立即返回taskId
|
||||||
|
3. 前端每2秒轮询查询导入状态
|
||||||
|
4. 导入完成后显示结果
|
||||||
|
5. 如有失败,显示"查看导入失败记录"按钮
|
||||||
|
6. 用户点击按钮查看失败记录详情
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. Redis中存储的导入状态和失败记录保留7天
|
||||||
|
2. taskId如果过期或不存在,查询接口会返回错误
|
||||||
|
3. 导入是异步处理,大量数据导入不会阻塞HTTP请求
|
||||||
|
4. 失败记录只保存失败的数据,成功的数据不会存储
|
||||||
BIN
doc/other/ScreenShot_2026-01-30_164916_062.png
Normal file
BIN
doc/other/ScreenShot_2026-01-30_164916_062.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
745
doc/plans/2026-02-06-employee-async-import-design.md
Normal file
745
doc/plans/2026-02-06-employee-async-import-design.md
Normal file
@@ -0,0 +1,745 @@
|
|||||||
|
# 员工信息异步导入功能设计文档
|
||||||
|
|
||||||
|
**创建日期**: 2026-02-06
|
||||||
|
**设计者**: Claude Code
|
||||||
|
**状态**: 已确认
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、需求概述
|
||||||
|
|
||||||
|
### 1.1 背景
|
||||||
|
当前员工信息导入功能为同步处理,存在以下问题:
|
||||||
|
- 导入大量数据时前端等待时间长,用户体验差
|
||||||
|
- 导入失败记录无法保留和查询
|
||||||
|
- 未充分利用批量操作提升性能
|
||||||
|
|
||||||
|
### 1.2 目标
|
||||||
|
- 实现异步导入,提升用户体验
|
||||||
|
- 失败记录存储在Redis中,保留7天,支持查询
|
||||||
|
- 新数据批量插入,已有数据使用`ON DUPLICATE KEY UPDATE`批量更新
|
||||||
|
- 以前端页面按钮方式提供失败记录查询功能
|
||||||
|
|
||||||
|
### 1.3 核心决策
|
||||||
|
- **唯一标识**: 柜员号(employeeId)
|
||||||
|
- **Redis TTL**: 7天
|
||||||
|
- **进度反馈**: 后台处理 + 完成通知
|
||||||
|
- **失败数据格式**: JSON对象列表存储
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、系统架构设计
|
||||||
|
|
||||||
|
### 2.1 整体架构
|
||||||
|
|
||||||
|
采用**生产者-消费者模式**:
|
||||||
|
|
||||||
|
```
|
||||||
|
前端 → Controller(立即返回) → 异步Service → Redis
|
||||||
|
↑ ↓
|
||||||
|
└──── 轮询查询状态 ←─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心流程**:
|
||||||
|
1. 前端提交Excel文件
|
||||||
|
2. Controller立即返回taskId,不阻塞
|
||||||
|
3. 异步线程处理导入逻辑
|
||||||
|
4. 结果实时写入Redis
|
||||||
|
5. 前端轮询查询导入状态
|
||||||
|
6. 完成后通知用户,如有失败显示查询按钮
|
||||||
|
|
||||||
|
### 2.2 技术选型
|
||||||
|
|
||||||
|
| 技术 | 用途 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Spring @Async | 异步处理 | 独立线程池处理导入任务 |
|
||||||
|
| Redis | 结果存储 | 存储导入状态和失败记录,7天TTL |
|
||||||
|
| MyBatis Plus | 批量插入 | saveBatch方法批量插入新数据 |
|
||||||
|
| 自定义SQL | 批量更新 | INSERT ... ON DUPLICATE KEY UPDATE |
|
||||||
|
| ThreadPoolExecutor | 线程管理 | 核心线程2,最大线程5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、数据库设计
|
||||||
|
|
||||||
|
### 3.1 表结构修改
|
||||||
|
|
||||||
|
确保`ccdi_employee`表的`employee_id`字段有UNIQUE约束:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE ccdi_employee
|
||||||
|
ADD UNIQUE KEY uk_employee_id (employee_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Redis数据结构
|
||||||
|
|
||||||
|
**状态信息存储**:
|
||||||
|
```
|
||||||
|
Key: import:employee:{taskId}
|
||||||
|
Type: Hash
|
||||||
|
TTL: 604800秒 (7天)
|
||||||
|
|
||||||
|
Fields:
|
||||||
|
- status: PROCESSING | SUCCESS | PARTIAL_SUCCESS | FAILED
|
||||||
|
- totalCount: 总记录数
|
||||||
|
- successCount: 成功数
|
||||||
|
- failureCount: 失败数
|
||||||
|
- startTime: 开始时间戳
|
||||||
|
- endTime: 结束时间戳
|
||||||
|
- message: 状态描述
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败记录存储**:
|
||||||
|
```
|
||||||
|
Key: import:employee:{taskId}:failures
|
||||||
|
Type: List (JSON序列化)
|
||||||
|
TTL: 604800秒 (7天)
|
||||||
|
|
||||||
|
Value: [
|
||||||
|
{
|
||||||
|
"employeeId": "1234567",
|
||||||
|
"name": "张三",
|
||||||
|
"idCard": "110101199001011234",
|
||||||
|
"deptId": 100,
|
||||||
|
"phone": "13800138000",
|
||||||
|
"status": "0",
|
||||||
|
"hireDate": "2020-01-01",
|
||||||
|
"errorMessage": "身份证号格式错误"
|
||||||
|
},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、后端实现设计
|
||||||
|
|
||||||
|
### 4.1 异步配置
|
||||||
|
|
||||||
|
**AsyncConfig.java**:
|
||||||
|
```java
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
@Bean("importExecutor")
|
||||||
|
public Executor importExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
executor.setMaxPoolSize(5);
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
executor.setThreadNamePrefix("import-async-");
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 核心VO类
|
||||||
|
|
||||||
|
**ImportResultVO** (导入提交结果):
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
public class ImportResultVO {
|
||||||
|
private String taskId;
|
||||||
|
private String status;
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ImportStatusVO** (导入状态):
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
public class ImportStatusVO {
|
||||||
|
private String taskId;
|
||||||
|
private String status;
|
||||||
|
private Integer totalCount;
|
||||||
|
private Integer successCount;
|
||||||
|
private Integer failureCount;
|
||||||
|
private Integer progress;
|
||||||
|
private Long startTime;
|
||||||
|
private Long endTime;
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**ImportFailureVO** (失败记录):
|
||||||
|
```java
|
||||||
|
@Data
|
||||||
|
public class ImportFailureVO {
|
||||||
|
private Long employeeId;
|
||||||
|
private String name;
|
||||||
|
private String idCard;
|
||||||
|
private Long deptId;
|
||||||
|
private String phone;
|
||||||
|
private String status;
|
||||||
|
private String hireDate;
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 Service层接口
|
||||||
|
|
||||||
|
**ICcdiEmployeeService**新增:
|
||||||
|
```java
|
||||||
|
/**
|
||||||
|
* 异步导入员工数据
|
||||||
|
* @param excelList Excel数据列表
|
||||||
|
* @param isUpdateSupport 是否更新已存在的数据
|
||||||
|
* @return CompletableFuture包含导入结果
|
||||||
|
*/
|
||||||
|
CompletableFuture<ImportResultVO> importEmployeeAsync(
|
||||||
|
List<CcdiEmployeeExcel> excelList,
|
||||||
|
boolean isUpdateSupport
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询导入状态
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return 导入状态信息
|
||||||
|
*/
|
||||||
|
ImportStatusVO getImportStatus(String taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入失败记录
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return 失败记录列表
|
||||||
|
*/
|
||||||
|
List<ImportFailureVO> getImportFailures(String taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 核心业务逻辑
|
||||||
|
|
||||||
|
**数据分类**:
|
||||||
|
```java
|
||||||
|
// 1. 批量查询已存在的柜员号
|
||||||
|
Set<Long> existingIds = employeeMapper.selectBatchIds(
|
||||||
|
excelList.stream()
|
||||||
|
.map(CcdiEmployeeExcel::getEmployeeId)
|
||||||
|
.collect(Collectors.toList())
|
||||||
|
).stream()
|
||||||
|
.map(CcdiEmployee::getEmployeeId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
// 2. 分类为新数据和更新数据
|
||||||
|
List<CcdiEmployee> newRecords = new ArrayList<>();
|
||||||
|
List<CcdiEmployee> updateRecords = new ArrayList<>();
|
||||||
|
|
||||||
|
for (CcdiEmployeeExcel excel : excelList) {
|
||||||
|
CcdiEmployee employee = convertToEntity(excel);
|
||||||
|
if (existingIds.contains(excel.getEmployeeId())) {
|
||||||
|
updateRecords.add(employee);
|
||||||
|
} else {
|
||||||
|
newRecords.add(employee);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**批量插入**:
|
||||||
|
```java
|
||||||
|
if (!newRecords.isEmpty()) {
|
||||||
|
employeeService.saveBatch(newRecords, 500);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**批量更新**:
|
||||||
|
```java
|
||||||
|
if (!updateRecords.isEmpty() && isUpdateSupport) {
|
||||||
|
employeeMapper.insertOrUpdateBatch(updateRecords);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败记录处理**:
|
||||||
|
```java
|
||||||
|
List<ImportFailureVO> failures = new ArrayList<>();
|
||||||
|
|
||||||
|
for (CcdiEmployeeExcel excel : excelList) {
|
||||||
|
try {
|
||||||
|
// 验证和导入逻辑
|
||||||
|
validateAndImport(excel);
|
||||||
|
} catch (Exception e) {
|
||||||
|
ImportFailureVO failure = new ImportFailureVO();
|
||||||
|
BeanUtils.copyProperties(excel, failure);
|
||||||
|
failure.setErrorMessage(e.getMessage());
|
||||||
|
failures.add(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存入Redis
|
||||||
|
if (!failures.isEmpty()) {
|
||||||
|
String key = "import:employee:" + taskId + ":failures";
|
||||||
|
redisTemplate.opsForValue().set(
|
||||||
|
key,
|
||||||
|
JSON.toJSONString(failures),
|
||||||
|
7,
|
||||||
|
TimeUnit.DAYS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.5 Mapper SQL
|
||||||
|
|
||||||
|
**CcdiEmployeeMapper.xml**:
|
||||||
|
```xml
|
||||||
|
<!-- 批量插入或更新 -->
|
||||||
|
<insert id="insertOrUpdateBatch" parameterType="java.util.List">
|
||||||
|
INSERT INTO ccdi_employee
|
||||||
|
(employee_id, name, dept_id, id_card, phone, hire_date, status,
|
||||||
|
create_time, update_by, update_time, remark)
|
||||||
|
VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(#{item.employeeId}, #{item.name}, #{item.deptId}, #{item.idCard},
|
||||||
|
#{item.phone}, #{item.hireDate}, #{item.status}, NOW(),
|
||||||
|
#{item.updateBy}, NOW(), #{item.remark})
|
||||||
|
</foreach>
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = VALUES(name),
|
||||||
|
dept_id = VALUES(dept_id),
|
||||||
|
phone = VALUES(phone),
|
||||||
|
hire_date = VALUES(hire_date),
|
||||||
|
status = VALUES(status),
|
||||||
|
update_by = VALUES(update_by),
|
||||||
|
update_time = NOW(),
|
||||||
|
remark = VALUES(remark)
|
||||||
|
</insert>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、Controller层API设计
|
||||||
|
|
||||||
|
### 5.1 修改导入接口
|
||||||
|
|
||||||
|
**接口**: `POST /ccdi/employee/importData`
|
||||||
|
|
||||||
|
**改动**:
|
||||||
|
- 改为立即返回taskId
|
||||||
|
- 使用异步处理
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"msg": "导入任务已提交,正在后台处理",
|
||||||
|
"data": {
|
||||||
|
"taskId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"status": "PROCESSING",
|
||||||
|
"message": "任务已创建"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 新增状态查询接口
|
||||||
|
|
||||||
|
**接口**: `GET /ccdi/employee/importStatus/{taskId}`
|
||||||
|
|
||||||
|
**Swagger注解**:
|
||||||
|
```java
|
||||||
|
@Operation(summary = "查询员工导入状态")
|
||||||
|
@GetMapping("/importStatus/{taskId}")
|
||||||
|
public AjaxResult getImportStatus(@PathVariable String taskId)
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"data": {
|
||||||
|
"taskId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
"status": "SUCCESS",
|
||||||
|
"totalCount": 100,
|
||||||
|
"successCount": 95,
|
||||||
|
"failureCount": 5,
|
||||||
|
"progress": 100,
|
||||||
|
"startTime": 1707225600000,
|
||||||
|
"endTime": 1707225900000,
|
||||||
|
"message": "导入完成"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 新增失败记录查询接口
|
||||||
|
|
||||||
|
**接口**: `GET /ccdi/employee/importFailures/{taskId}`
|
||||||
|
|
||||||
|
**参数**:
|
||||||
|
- `taskId`: 任务ID (路径参数)
|
||||||
|
- `pageNum`: 页码 (可选,默认1)
|
||||||
|
- `pageSize`: 每页条数 (可选,默认10)
|
||||||
|
|
||||||
|
**响应格式**: TableDataInfo
|
||||||
|
|
||||||
|
**Swagger注解**:
|
||||||
|
```java
|
||||||
|
@Operation(summary = "查询导入失败记录")
|
||||||
|
@GetMapping("/importFailures/{taskId}")
|
||||||
|
public TableDataInfo getImportFailures(
|
||||||
|
@PathVariable String taskId,
|
||||||
|
@RequestParam(defaultValue = "1") Integer pageNum,
|
||||||
|
@RequestParam(defaultValue = "10") Integer pageSize
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、前端实现设计
|
||||||
|
|
||||||
|
### 6.1 API定义
|
||||||
|
|
||||||
|
**api/ccdiEmployee.js**新增:
|
||||||
|
```javascript
|
||||||
|
// 查询导入状态
|
||||||
|
export function getImportStatus(taskId) {
|
||||||
|
return request({
|
||||||
|
url: '/ccdi/employee/importStatus/' + taskId,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询导入失败记录
|
||||||
|
export function getImportFailures(taskId, pageNum, pageSize) {
|
||||||
|
return request({
|
||||||
|
url: '/ccdi/employee/importFailures/' + taskId,
|
||||||
|
method: 'get',
|
||||||
|
params: { pageNum, pageSize }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 轮询状态检查
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// ...其他data
|
||||||
|
pollingTimer: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
startImportStatusPolling(taskId) {
|
||||||
|
this.pollingTimer = setInterval(async () => {
|
||||||
|
const response = await getImportStatus(taskId);
|
||||||
|
|
||||||
|
if (response.data.status !== 'PROCESSING') {
|
||||||
|
clearInterval(this.pollingTimer);
|
||||||
|
this.handleImportComplete(response.data);
|
||||||
|
}
|
||||||
|
}, 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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
beforeDestroy() {
|
||||||
|
// 组件销毁时清除定时器
|
||||||
|
if (this.pollingTimer) {
|
||||||
|
clearInterval(this.pollingTimer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.4 失败记录查询UI
|
||||||
|
|
||||||
|
**页面按钮**:
|
||||||
|
```vue
|
||||||
|
<el-row :gutter="10" class="mb8">
|
||||||
|
<!-- 原有按钮... -->
|
||||||
|
|
||||||
|
<el-col :span="1.5" v-if="showFailureButton">
|
||||||
|
<el-button
|
||||||
|
type="warning"
|
||||||
|
icon="el-icon-warning"
|
||||||
|
size="mini"
|
||||||
|
@click="viewImportFailures"
|
||||||
|
>
|
||||||
|
查看导入失败记录 ({{ currentTaskId }})
|
||||||
|
</el-button>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
```
|
||||||
|
|
||||||
|
**失败记录对话框**:
|
||||||
|
```vue
|
||||||
|
<el-dialog
|
||||||
|
title="导入失败记录"
|
||||||
|
:visible.sync="failureDialogVisible"
|
||||||
|
width="1200px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-table :data="failureList" v-loading="failureLoading">
|
||||||
|
<el-table-column label="姓名" prop="name" />
|
||||||
|
<el-table-column label="柜员号" prop="employeeId" />
|
||||||
|
<el-table-column label="身份证号" prop="idCard" />
|
||||||
|
<el-table-column label="电话" prop="phone" />
|
||||||
|
<el-table-column label="失败原因" prop="errorMessage" min-width="200" />
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<pagination
|
||||||
|
v-show="failureTotal > 0"
|
||||||
|
:total="failureTotal"
|
||||||
|
:page.sync="failureQueryParams.pageNum"
|
||||||
|
:limit.sync="failureQueryParams.pageSize"
|
||||||
|
@pagination="getFailureList"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div slot="footer">
|
||||||
|
<el-button @click="failureDialogVisible = false">关闭</el-button>
|
||||||
|
<el-button type="primary" @click="exportFailures">导出失败记录</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
```
|
||||||
|
|
||||||
|
**方法实现**:
|
||||||
|
```javascript
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
// 失败记录相关
|
||||||
|
showFailureButton: false,
|
||||||
|
currentTaskId: null,
|
||||||
|
failureDialogVisible: false,
|
||||||
|
failureList: [],
|
||||||
|
failureLoading: false,
|
||||||
|
failureTotal: 0,
|
||||||
|
failureQueryParams: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
exportFailures() {
|
||||||
|
this.download(
|
||||||
|
'ccdi/employee/exportFailures/' + this.currentTaskId,
|
||||||
|
{},
|
||||||
|
`导入失败记录_${new Date().getTime()}.xlsx`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、错误处理与边界情况
|
||||||
|
|
||||||
|
### 7.1 异常场景处理
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|------|----------|
|
||||||
|
| 导入文件格式错误 | 上传阶段校验,不创建任务,返回错误提示 |
|
||||||
|
| 单条数据验证失败 | 记录到Redis失败列表,继续处理其他数据 |
|
||||||
|
| Redis连接失败 | 记录日志报警,降级处理,返回警告 |
|
||||||
|
| 线程池队列满 | CallerRunsPolicy,由提交线程执行 |
|
||||||
|
| 部分成功 | status=PARTIAL_SUCCESS,显示失败记录按钮 |
|
||||||
|
| 全部失败 | status=FAILED,显示失败记录按钮 |
|
||||||
|
| taskId不存在 | 返回404,提示任务不存在或已过期 |
|
||||||
|
|
||||||
|
### 7.2 数据一致性
|
||||||
|
|
||||||
|
- 使用`@Transactional`保证批量操作原子性
|
||||||
|
- 新数据插入和已有数据更新在同一事务
|
||||||
|
- 任意步骤失败,整体回滚
|
||||||
|
- Redis状态更新在事务提交后执行
|
||||||
|
|
||||||
|
### 7.3 幂等性
|
||||||
|
|
||||||
|
- taskId使用UUID,全局唯一
|
||||||
|
- 同一文件多次导入产生多个taskId
|
||||||
|
- 支持查询历史任务状态和失败记录
|
||||||
|
- 失败记录独立存储,互不影响
|
||||||
|
|
||||||
|
### 7.4 性能优化
|
||||||
|
|
||||||
|
- 批量插入每批500条,平衡性能和内存
|
||||||
|
- 使用ON DUPLICATE KEY UPDATE替代先查后更新
|
||||||
|
- Redis操作使用Pipeline批量执行
|
||||||
|
- 线程池复用,避免频繁创建销毁
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、测试策略
|
||||||
|
|
||||||
|
### 8.1 单元测试
|
||||||
|
|
||||||
|
- 测试数据分类逻辑(新数据vs已有数据)
|
||||||
|
- 测试批量插入和批量更新
|
||||||
|
- 测试异常处理和失败记录收集
|
||||||
|
- 测试Redis读写操作
|
||||||
|
|
||||||
|
### 8.2 集成测试
|
||||||
|
|
||||||
|
- 测试完整导入流程(提交→处理→查询)
|
||||||
|
- 测试并发导入多个文件
|
||||||
|
- 测试Redis异常降级
|
||||||
|
- 测试线程池满载情况
|
||||||
|
|
||||||
|
### 8.3 性能测试
|
||||||
|
|
||||||
|
- 100条数据导入时间 < 2秒
|
||||||
|
- 1000条数据导入时间 < 10秒
|
||||||
|
- 10000条数据导入时间 < 60秒
|
||||||
|
- 导入状态查询响应时间 < 100ms
|
||||||
|
|
||||||
|
### 8.4 前端测试
|
||||||
|
|
||||||
|
- 测试轮询逻辑正确性
|
||||||
|
- 测试通知显示和关闭
|
||||||
|
- 测试失败记录分页查询
|
||||||
|
- 测试组件销毁时清除定时器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、实施检查清单
|
||||||
|
|
||||||
|
### 9.1 后端任务
|
||||||
|
|
||||||
|
- [ ] 创建AsyncConfig配置类
|
||||||
|
- [ ] 添加数据库UNIQUE约束
|
||||||
|
- [ ] 创建VO类(ImportResultVO, ImportStatusVO, ImportFailureVO)
|
||||||
|
- [ ] 实现Service层异步方法
|
||||||
|
- [ ] 实现Redis状态存储逻辑
|
||||||
|
- [ ] 实现数据分类和批量操作
|
||||||
|
- [ ] 编写Mapper XML SQL
|
||||||
|
- [ ] 添加Controller接口
|
||||||
|
- [ ] 更新Swagger文档
|
||||||
|
|
||||||
|
### 9.2 前端任务
|
||||||
|
|
||||||
|
- [ ] 添加API方法定义
|
||||||
|
- [ ] 修改导入成功处理逻辑
|
||||||
|
- [ ] 实现轮询状态检查
|
||||||
|
- [ ] 添加查看失败记录按钮
|
||||||
|
- [ ] 创建失败记录对话框
|
||||||
|
- [ ] 实现分页查询失败记录
|
||||||
|
- [ ] 添加导出失败记录功能
|
||||||
|
|
||||||
|
### 9.3 测试任务
|
||||||
|
|
||||||
|
- [ ] 编写单元测试用例
|
||||||
|
- [ ] 生成测试脚本
|
||||||
|
- [ ] 执行集成测试
|
||||||
|
- [ ] 进行性能测试
|
||||||
|
- [ ] 生成测试报告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、API文档更新
|
||||||
|
|
||||||
|
更新`doc`目录下的接口文档,包含:
|
||||||
|
- 修改的导入接口说明
|
||||||
|
- 新增的状态查询接口
|
||||||
|
- 新增的失败记录查询接口
|
||||||
|
- 请求/响应示例
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录
|
||||||
|
|
||||||
|
### A. Redis Key命名规范
|
||||||
|
|
||||||
|
```
|
||||||
|
import:employee:{taskId} # 导入状态
|
||||||
|
import:employee:{taskId}:failures # 失败记录列表
|
||||||
|
```
|
||||||
|
|
||||||
|
### B. 状态枚举
|
||||||
|
|
||||||
|
| 状态值 | 说明 | 前端行为 |
|
||||||
|
|--------|------|----------|
|
||||||
|
| PROCESSING | 处理中 | 继续轮询 |
|
||||||
|
| SUCCESS | 全部成功 | 显示成功通知,刷新列表 |
|
||||||
|
| PARTIAL_SUCCESS | 部分成功 | 显示警告通知,显示失败按钮 |
|
||||||
|
| FAILED | 全部失败 | 显示错误通知,显示失败按钮 |
|
||||||
|
|
||||||
|
### C. 相关文件清单
|
||||||
|
|
||||||
|
**后端**:
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/config/AsyncConfig.java`
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/ImportResultVO.java`
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/ImportStatusVO.java`
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/ImportFailureVO.java`
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java`
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java`
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiEmployeeMapper.java`
|
||||||
|
- `ruoyi-ccdi/src/main/resources/mapper/ccdi/CcdiEmployeeMapper.xml`
|
||||||
|
- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiEmployeeController.java`
|
||||||
|
|
||||||
|
**前端**:
|
||||||
|
- `ruoyi-ui/src/api/ccdiEmployee.js`
|
||||||
|
- `ruoyi-ui/src/views/ccdiEmployee/index.vue`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: 1.0
|
||||||
|
**最后更新**: 2026-02-06
|
||||||
1451
doc/plans/2026-02-06-employee-async-import.md
Normal file
1451
doc/plans/2026-02-06-employee-async-import.md
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ruoyi.ccdi.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableAsync;
|
||||||
|
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||||
|
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.ThreadPoolExecutor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步配置类
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableAsync
|
||||||
|
public class AsyncConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入任务专用线程池
|
||||||
|
*/
|
||||||
|
@Bean("importExecutor")
|
||||||
|
public Executor importExecutor() {
|
||||||
|
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||||
|
// 核心线程数
|
||||||
|
executor.setCorePoolSize(2);
|
||||||
|
// 最大线程数
|
||||||
|
executor.setMaxPoolSize(5);
|
||||||
|
// 队列容量
|
||||||
|
executor.setQueueCapacity(100);
|
||||||
|
// 线程名前缀
|
||||||
|
executor.setThreadNamePrefix("import-async-");
|
||||||
|
// 拒绝策略:由调用线程执行
|
||||||
|
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
|
||||||
|
// 等待所有任务完成后再关闭线程池
|
||||||
|
executor.setWaitForTasksToCompleteOnShutdown(true);
|
||||||
|
// 等待时间
|
||||||
|
executor.setAwaitTerminationSeconds(60);
|
||||||
|
executor.initialize();
|
||||||
|
return executor;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@ import com.ruoyi.ccdi.domain.dto.CcdiEmployeeEditDTO;
|
|||||||
import com.ruoyi.ccdi.domain.dto.CcdiEmployeeQueryDTO;
|
import com.ruoyi.ccdi.domain.dto.CcdiEmployeeQueryDTO;
|
||||||
import com.ruoyi.ccdi.domain.excel.CcdiEmployeeExcel;
|
import com.ruoyi.ccdi.domain.excel.CcdiEmployeeExcel;
|
||||||
import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO;
|
import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportFailureVO;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportResultVO;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportStatusVO;
|
||||||
|
import com.ruoyi.ccdi.service.ICcdiEmployeeImportService;
|
||||||
import com.ruoyi.ccdi.service.ICcdiEmployeeService;
|
import com.ruoyi.ccdi.service.ICcdiEmployeeService;
|
||||||
import com.ruoyi.ccdi.utils.EasyExcelUtil;
|
import com.ruoyi.ccdi.utils.EasyExcelUtil;
|
||||||
import com.ruoyi.common.annotation.Log;
|
import com.ruoyi.common.annotation.Log;
|
||||||
@@ -40,6 +44,9 @@ public class CcdiEmployeeController extends BaseController {
|
|||||||
@Resource
|
@Resource
|
||||||
private ICcdiEmployeeService employeeService;
|
private ICcdiEmployeeService employeeService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ICcdiEmployeeImportService importAsyncService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询员工列表
|
* 查询员工列表
|
||||||
*/
|
*/
|
||||||
@@ -120,7 +127,7 @@ public class CcdiEmployeeController extends BaseController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导入员工信息
|
* 导入员工信息(异步)
|
||||||
*/
|
*/
|
||||||
@Operation(summary = "导入员工信息")
|
@Operation(summary = "导入员工信息")
|
||||||
@PreAuthorize("@ss.hasPermi('ccdi:employee:import')")
|
@PreAuthorize("@ss.hasPermi('ccdi:employee:import')")
|
||||||
@@ -128,7 +135,57 @@ public class CcdiEmployeeController extends BaseController {
|
|||||||
@PostMapping("/importData")
|
@PostMapping("/importData")
|
||||||
public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception {
|
public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception {
|
||||||
List<CcdiEmployeeExcel> list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiEmployeeExcel.class);
|
List<CcdiEmployeeExcel> list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiEmployeeExcel.class);
|
||||||
String message = employeeService.importEmployee(list, updateSupport);
|
|
||||||
return success(message);
|
if (list == null || list.isEmpty()) {
|
||||||
|
return error("至少需要一条数据");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交异步任务
|
||||||
|
String taskId = employeeService.importEmployee(list, updateSupport);
|
||||||
|
|
||||||
|
// 立即返回,不等待后台任务完成
|
||||||
|
ImportResultVO result = new ImportResultVO();
|
||||||
|
result.setTaskId(taskId);
|
||||||
|
result.setStatus("PROCESSING");
|
||||||
|
result.setMessage("导入任务已提交,正在后台处理");
|
||||||
|
|
||||||
|
return AjaxResult.success("导入任务已提交,正在后台处理", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询导入状态
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询员工导入状态")
|
||||||
|
@PreAuthorize("@ss.hasPermi('ccdi:employee:import')")
|
||||||
|
@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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询导入失败记录
|
||||||
|
*/
|
||||||
|
@Operation(summary = "查询导入失败记录")
|
||||||
|
@PreAuthorize("@ss.hasPermi('ccdi:employee:import')")
|
||||||
|
@GetMapping("/importFailures/{taskId}")
|
||||||
|
public TableDataInfo getImportFailures(
|
||||||
|
@PathVariable String taskId,
|
||||||
|
@RequestParam(defaultValue = "1") Integer pageNum,
|
||||||
|
@RequestParam(defaultValue = "10") Integer pageSize) {
|
||||||
|
|
||||||
|
List<ImportFailureVO> failures = importAsyncService.getImportFailures( taskId);
|
||||||
|
|
||||||
|
// 手动分页
|
||||||
|
int fromIndex = (pageNum - 1) * pageSize;
|
||||||
|
int toIndex = Math.min(fromIndex + pageSize, failures.size());
|
||||||
|
|
||||||
|
List<ImportFailureVO> pageData = failures.subList(fromIndex, toIndex);
|
||||||
|
|
||||||
|
return getDataTable(pageData, failures.size());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.ruoyi.ccdi.domain.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入失败记录VO
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "导入失败记录")
|
||||||
|
public class ImportFailureVO {
|
||||||
|
|
||||||
|
@Schema(description = "柜员号")
|
||||||
|
private Long employeeId;
|
||||||
|
|
||||||
|
@Schema(description = "姓名")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Schema(description = "身份证号")
|
||||||
|
private String idCard;
|
||||||
|
|
||||||
|
@Schema(description = "部门ID")
|
||||||
|
private Long deptId;
|
||||||
|
|
||||||
|
@Schema(description = "电话")
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
@Schema(description = "状态")
|
||||||
|
private String status;
|
||||||
|
|
||||||
|
@Schema(description = "入职时间")
|
||||||
|
private String hireDate;
|
||||||
|
|
||||||
|
@Schema(description = "错误信息")
|
||||||
|
private String errorMessage;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.ruoyi.ccdi.domain.vo;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Author: wkc
|
||||||
|
* @CreateTime: 2026-02-06
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class ImportResult {
|
||||||
|
|
||||||
|
private Integer totalCount;
|
||||||
|
private Integer successCount;
|
||||||
|
private Integer failureCount;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.ruoyi.ccdi.domain.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入结果VO
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.ruoyi.ccdi.domain.vo;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入状态VO
|
||||||
|
*
|
||||||
|
* @author ruoyi
|
||||||
|
*/
|
||||||
|
@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;
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import com.ruoyi.ccdi.domain.dto.CcdiEmployeeQueryDTO;
|
|||||||
import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO;
|
import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO;
|
||||||
import org.apache.ibatis.annotations.Param;
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 员工信息 数据层
|
* 员工信息 数据层
|
||||||
*
|
*
|
||||||
@@ -25,11 +27,15 @@ public interface CcdiEmployeeMapper extends BaseMapper<CcdiEmployee> {
|
|||||||
Page<CcdiEmployeeVO> selectEmployeePageWithDept(@Param("page") Page<CcdiEmployeeVO> page,
|
Page<CcdiEmployeeVO> selectEmployeePageWithDept(@Param("page") Page<CcdiEmployeeVO> page,
|
||||||
@Param("query") CcdiEmployeeQueryDTO queryDTO);
|
@Param("query") CcdiEmployeeQueryDTO queryDTO);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
int insertOrUpdateBatch(@Param("list") List<CcdiEmployee> list);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询员工详情(包含亲属列表)
|
* 批量插入员工信息
|
||||||
*
|
*
|
||||||
* @param employeeId 员工ID
|
* @param list 员工信息列表
|
||||||
* @return 员工VO
|
* @return 影响行数
|
||||||
*/
|
*/
|
||||||
CcdiEmployeeVO selectEmployeeWithRelatives(@Param("employeeId") Long employeeId);
|
int insertBatch(@Param("list") List<CcdiEmployee> list);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package com.ruoyi.ccdi.service;
|
||||||
|
|
||||||
|
import com.ruoyi.ccdi.domain.excel.CcdiEmployeeExcel;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportFailureVO;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportStatusVO;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Author: wkc
|
||||||
|
* @CreateTime: 2026-02-06
|
||||||
|
*/
|
||||||
|
public interface ICcdiEmployeeImportService {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 异步导入员工数据
|
||||||
|
*
|
||||||
|
* @param excelList Excel数据列表
|
||||||
|
* @param isUpdateSupport 是否更新已存在的数据
|
||||||
|
*/
|
||||||
|
void importEmployeeAsync(List<CcdiEmployeeExcel> excelList, Boolean isUpdateSupport, String taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询导入状态
|
||||||
|
*
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return 导入状态信息
|
||||||
|
*/
|
||||||
|
ImportStatusVO getImportStatus(String taskId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入失败记录
|
||||||
|
*
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return 失败记录列表
|
||||||
|
*/
|
||||||
|
List<ImportFailureVO> getImportFailures(String taskId);
|
||||||
|
}
|
||||||
@@ -82,4 +82,5 @@ public interface ICcdiEmployeeService {
|
|||||||
* @return 结果
|
* @return 结果
|
||||||
*/
|
*/
|
||||||
String importEmployee(List<CcdiEmployeeExcel> excelList, Boolean isUpdateSupport);
|
String importEmployee(List<CcdiEmployeeExcel> excelList, Boolean isUpdateSupport);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,277 @@
|
|||||||
|
package com.ruoyi.ccdi.service.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.ruoyi.ccdi.domain.CcdiEmployee;
|
||||||
|
import com.ruoyi.ccdi.domain.dto.CcdiEmployeeAddDTO;
|
||||||
|
import com.ruoyi.ccdi.domain.excel.CcdiEmployeeExcel;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportFailureVO;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportResult;
|
||||||
|
import com.ruoyi.ccdi.domain.vo.ImportStatusVO;
|
||||||
|
import com.ruoyi.ccdi.mapper.CcdiEmployeeMapper;
|
||||||
|
import com.ruoyi.ccdi.service.ICcdiEmployeeImportService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @Author: wkc
|
||||||
|
* @CreateTime: 2026-02-06
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@EnableAsync
|
||||||
|
public class CcdiEmployeeImportServiceImpl implements ICcdiEmployeeImportService {
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private CcdiEmployeeMapper employeeMapper;
|
||||||
|
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Async
|
||||||
|
public void importEmployeeAsync(List<CcdiEmployeeExcel> excelList, Boolean isUpdateSupport, String taskId) {
|
||||||
|
List<CcdiEmployee> newRecords = new ArrayList<>();
|
||||||
|
List<CcdiEmployee> updateRecords = new ArrayList<>();
|
||||||
|
List<ImportFailureVO> failures = new ArrayList<>();
|
||||||
|
|
||||||
|
// 批量查询已存在的柜员号
|
||||||
|
Set<Long> existingIds = getExistingEmployeeIds(excelList);
|
||||||
|
|
||||||
|
// 分类数据
|
||||||
|
for (int i = 0; i < excelList.size(); i++) {
|
||||||
|
CcdiEmployeeExcel excel = excelList.get(i);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 转换为AddDTO进行验证
|
||||||
|
CcdiEmployeeAddDTO addDTO = new CcdiEmployeeAddDTO();
|
||||||
|
BeanUtils.copyProperties(excel, addDTO);
|
||||||
|
|
||||||
|
// 验证数据(支持更新模式)
|
||||||
|
validateEmployeeData(addDTO, isUpdateSupport, existingIds);
|
||||||
|
|
||||||
|
CcdiEmployee employee = new CcdiEmployee();
|
||||||
|
BeanUtils.copyProperties(excel, employee);
|
||||||
|
|
||||||
|
if (existingIds.contains(excel.getEmployeeId())) {
|
||||||
|
if (isUpdateSupport) {
|
||||||
|
updateRecords.add(employee);
|
||||||
|
} else {
|
||||||
|
throw new RuntimeException("柜员号已存在且未启用更新支持");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newRecords.add(employee);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
ImportFailureVO failure = new ImportFailureVO();
|
||||||
|
BeanUtils.copyProperties(excel, failure);
|
||||||
|
failure.setErrorMessage(e.getMessage());
|
||||||
|
failures.add(failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量插入新数据
|
||||||
|
if (!newRecords.isEmpty()) {
|
||||||
|
saveBatch(newRecords, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量更新已有数据(先删除再插入)
|
||||||
|
if (!updateRecords.isEmpty() && isUpdateSupport) {
|
||||||
|
employeeMapper.insertOrUpdateBatch(updateRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存失败记录到Redis
|
||||||
|
if (!failures.isEmpty()) {
|
||||||
|
String failuresKey = "import:employee:" + 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("employee", taskId, finalStatus, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取导入失败记录
|
||||||
|
*
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return 失败记录列表
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<ImportFailureVO> getImportFailures( String taskId) {
|
||||||
|
String key = "import:employee:" + taskId + ":failures";
|
||||||
|
Object failuresObj = redisTemplate.opsForValue().get(key);
|
||||||
|
|
||||||
|
if (failuresObj == null) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.parseArray(JSON.toJSONString(failuresObj), ImportFailureVO.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询导入状态
|
||||||
|
*
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @return 导入状态信息
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public ImportStatusVO getImportStatus(String taskId) {
|
||||||
|
String key = "import:employee:" + 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新导入状态
|
||||||
|
*/
|
||||||
|
private void updateImportStatus(String taskType, String taskId, String status, ImportResult result) {
|
||||||
|
String key = "import:employee:" + 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量查询已存在的员工ID
|
||||||
|
*/
|
||||||
|
private Set<Long> getExistingEmployeeIds(List<CcdiEmployeeExcel> excelList) {
|
||||||
|
List<Long> employeeIds = excelList.stream()
|
||||||
|
.map(CcdiEmployeeExcel::getEmployeeId)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
if (employeeIds.isEmpty()) {
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<CcdiEmployee> existingEmployees = employeeMapper.selectBatchIds(employeeIds);
|
||||||
|
return existingEmployees.stream()
|
||||||
|
.map(CcdiEmployee::getEmployeeId)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存
|
||||||
|
*/
|
||||||
|
private void saveBatch(List<CcdiEmployee> list, int batchSize) {
|
||||||
|
// 使用真正的批量插入,分批次执行以提高性能
|
||||||
|
for (int i = 0; i < list.size(); i += batchSize) {
|
||||||
|
int end = Math.min(i + batchSize, list.size());
|
||||||
|
List<CcdiEmployee> subList = list.subList(i, end);
|
||||||
|
employeeMapper.insertBatch(subList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证员工数据
|
||||||
|
*
|
||||||
|
* @param addDTO 新增DTO
|
||||||
|
* @param isUpdateSupport 是否支持更新
|
||||||
|
* @param existingIds 已存在的员工ID集合(导入场景使用,传null表示单条新增)
|
||||||
|
*/
|
||||||
|
public void validateEmployeeData(CcdiEmployeeAddDTO addDTO, Boolean isUpdateSupport, Set<Long> existingIds) {
|
||||||
|
// 验证必填字段
|
||||||
|
if (StringUtils.isEmpty(addDTO.getName())) {
|
||||||
|
throw new RuntimeException("姓名不能为空");
|
||||||
|
}
|
||||||
|
if (addDTO.getEmployeeId() == null) {
|
||||||
|
throw new RuntimeException("柜员号不能为空");
|
||||||
|
}
|
||||||
|
if (addDTO.getDeptId() == null) {
|
||||||
|
throw new RuntimeException("所属部门不能为空");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(addDTO.getIdCard())) {
|
||||||
|
throw new RuntimeException("身份证号不能为空");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(addDTO.getPhone())) {
|
||||||
|
throw new RuntimeException("电话不能为空");
|
||||||
|
}
|
||||||
|
if (StringUtils.isEmpty(addDTO.getStatus())) {
|
||||||
|
throw new RuntimeException("状态不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证身份证号格式
|
||||||
|
String idCardError = IdCardUtil.getErrorMessage(addDTO.getIdCard());
|
||||||
|
if (idCardError != null) {
|
||||||
|
throw new RuntimeException(idCardError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单条新增场景:检查柜员号和身份证号唯一性
|
||||||
|
if (existingIds == null) {
|
||||||
|
// 检查柜员号(employeeId)唯一性
|
||||||
|
if (employeeMapper.selectById(addDTO.getEmployeeId()) != null) {
|
||||||
|
throw new RuntimeException("该柜员号已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查身份证号唯一性
|
||||||
|
LambdaQueryWrapper<CcdiEmployee> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(CcdiEmployee::getIdCard, addDTO.getIdCard());
|
||||||
|
if (employeeMapper.selectCount(wrapper) > 0) {
|
||||||
|
throw new RuntimeException("该身份证号已存在");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 导入场景:如果柜员号不存在,才检查身份证号唯一性
|
||||||
|
if (!existingIds.contains(addDTO.getEmployeeId())) {
|
||||||
|
// 检查身份证号唯一性
|
||||||
|
LambdaQueryWrapper<CcdiEmployee> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(CcdiEmployee::getIdCard, addDTO.getIdCard());
|
||||||
|
if (employeeMapper.selectCount(wrapper) > 0) {
|
||||||
|
throw new RuntimeException("该身份证号已存在");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证状态
|
||||||
|
if (!"0".equals(addDTO.getStatus()) && !"1".equals(addDTO.getStatus())) {
|
||||||
|
throw new RuntimeException("状态只能填写'在职'或'离职'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,16 +10,17 @@ import com.ruoyi.ccdi.domain.excel.CcdiEmployeeExcel;
|
|||||||
import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO;
|
import com.ruoyi.ccdi.domain.vo.CcdiEmployeeVO;
|
||||||
import com.ruoyi.ccdi.enums.EmployeeStatus;
|
import com.ruoyi.ccdi.enums.EmployeeStatus;
|
||||||
import com.ruoyi.ccdi.mapper.CcdiEmployeeMapper;
|
import com.ruoyi.ccdi.mapper.CcdiEmployeeMapper;
|
||||||
|
import com.ruoyi.ccdi.service.ICcdiEmployeeImportService;
|
||||||
import com.ruoyi.ccdi.service.ICcdiEmployeeService;
|
import com.ruoyi.ccdi.service.ICcdiEmployeeService;
|
||||||
import com.ruoyi.common.utils.IdCardUtil;
|
|
||||||
import com.ruoyi.common.utils.StringUtils;
|
import com.ruoyi.common.utils.StringUtils;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.*;
|
||||||
import java.util.List;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 员工信息 服务层处理
|
* 员工信息 服务层处理
|
||||||
@@ -33,6 +34,13 @@ public class CcdiEmployeeServiceImpl implements ICcdiEmployeeService {
|
|||||||
@Resource
|
@Resource
|
||||||
private CcdiEmployeeMapper employeeMapper;
|
private CcdiEmployeeMapper employeeMapper;
|
||||||
|
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ICcdiEmployeeImportService importAsyncService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 查询员工列表
|
* 查询员工列表
|
||||||
*
|
*
|
||||||
@@ -176,46 +184,31 @@ public class CcdiEmployeeServiceImpl implements ICcdiEmployeeService {
|
|||||||
@Override
|
@Override
|
||||||
@Transactional
|
@Transactional
|
||||||
public String importEmployee(List<CcdiEmployeeExcel> excelList, Boolean isUpdateSupport) {
|
public String importEmployee(List<CcdiEmployeeExcel> excelList, Boolean isUpdateSupport) {
|
||||||
if (StringUtils.isNull(excelList) || excelList.isEmpty()) {
|
String taskId = UUID.randomUUID().toString();
|
||||||
return "至少需要一条数据";
|
long startTime = System.currentTimeMillis();
|
||||||
}
|
|
||||||
|
|
||||||
int successNum = 0;
|
// 初始化Redis状态
|
||||||
int failureNum = 0;
|
String statusKey = "import:employee:" + taskId;
|
||||||
StringBuilder successMsg = new StringBuilder();
|
Map<String, Object> statusData = new HashMap<>();
|
||||||
StringBuilder failureMsg = new StringBuilder();
|
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", "正在处理...");
|
||||||
|
|
||||||
for (CcdiEmployeeExcel excel : excelList) {
|
redisTemplate.opsForHash().putAll(statusKey, statusData);
|
||||||
try {
|
redisTemplate.expire(statusKey, 7, TimeUnit.DAYS);
|
||||||
// 转换为AddDTO
|
|
||||||
CcdiEmployeeAddDTO addDTO = new CcdiEmployeeAddDTO();
|
|
||||||
BeanUtils.copyProperties(excel, addDTO);
|
|
||||||
|
|
||||||
// 验证数据
|
importAsyncService.importEmployeeAsync(excelList, isUpdateSupport, taskId);
|
||||||
validateEmployeeData(addDTO, isUpdateSupport);
|
|
||||||
|
|
||||||
CcdiEmployee employee = new CcdiEmployee();
|
return taskId;
|
||||||
BeanUtils.copyProperties(addDTO, employee);
|
|
||||||
|
|
||||||
employeeMapper.insert(employee);
|
|
||||||
successNum++;
|
|
||||||
successMsg.append("<br/>").append(successNum).append("、").append(addDTO.getName()).append(" 导入成功");
|
|
||||||
} catch (Exception e) {
|
|
||||||
failureNum++;
|
|
||||||
failureMsg.append("<br/>").append(failureNum).append("、").append(excel.getName()).append(" 导入失败:");
|
|
||||||
failureMsg.append(e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (failureNum > 0) {
|
|
||||||
failureMsg.insert(0, "很抱歉,导入失败!共 " + failureNum + " 条数据格式不正确,错误如下:");
|
|
||||||
throw new RuntimeException(failureMsg.toString());
|
|
||||||
} else {
|
|
||||||
successMsg.insert(0, "恭喜您,数据已全部导入成功!共 " + successNum + " 条");
|
|
||||||
return successMsg.toString();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构建查询条件
|
* 构建查询条件
|
||||||
*/
|
*/
|
||||||
@@ -230,58 +223,12 @@ public class CcdiEmployeeServiceImpl implements ICcdiEmployeeService {
|
|||||||
return wrapper;
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证员工数据
|
|
||||||
*/
|
|
||||||
private void validateEmployeeData(CcdiEmployeeAddDTO addDTO, Boolean isUpdateSupport) {
|
|
||||||
// 验证必填字段
|
|
||||||
if (StringUtils.isEmpty(addDTO.getName())) {
|
|
||||||
throw new RuntimeException("姓名不能为空");
|
|
||||||
}
|
|
||||||
if (addDTO.getEmployeeId() == null) {
|
|
||||||
throw new RuntimeException("柜员号不能为空");
|
|
||||||
}
|
|
||||||
if (addDTO.getDeptId() == null) {
|
|
||||||
throw new RuntimeException("所属部门不能为空");
|
|
||||||
}
|
|
||||||
if (StringUtils.isEmpty(addDTO.getIdCard())) {
|
|
||||||
throw new RuntimeException("身份证号不能为空");
|
|
||||||
}
|
|
||||||
if (StringUtils.isEmpty(addDTO.getPhone())) {
|
|
||||||
throw new RuntimeException("电话不能为空");
|
|
||||||
}
|
|
||||||
if (StringUtils.isEmpty(addDTO.getStatus())) {
|
|
||||||
throw new RuntimeException("状态不能为空");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证身份证号格式
|
|
||||||
String idCardError = IdCardUtil.getErrorMessage(addDTO.getIdCard());
|
|
||||||
if (idCardError != null) {
|
|
||||||
throw new RuntimeException(idCardError);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查柜员号(employeeId)唯一性
|
|
||||||
if (employeeMapper.selectById(addDTO.getEmployeeId()) != null) {
|
|
||||||
throw new RuntimeException("该柜员号已存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查身份证号唯一性
|
|
||||||
LambdaQueryWrapper<CcdiEmployee> wrapper = new LambdaQueryWrapper<>();
|
|
||||||
wrapper.eq(CcdiEmployee::getIdCard, addDTO.getIdCard());
|
|
||||||
if (employeeMapper.selectCount(wrapper) > 0) {
|
|
||||||
throw new RuntimeException("该身份证号已存在");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证状态
|
|
||||||
if (!"0".equals(addDTO.getStatus()) && !"1".equals(addDTO.getStatus())) {
|
|
||||||
throw new RuntimeException("状态只能填写'在职'或'离职'");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 转换为VO对象
|
* 转换为VO对象
|
||||||
*/
|
*/
|
||||||
private CcdiEmployeeVO convertToVO(CcdiEmployee employee) {
|
public CcdiEmployeeVO convertToVO(CcdiEmployee employee) {
|
||||||
if (employee == null) {
|
if (employee == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -291,4 +238,12 @@ public class CcdiEmployeeServiceImpl implements ICcdiEmployeeService {
|
|||||||
vo.setStatusDesc(EmployeeStatus.getDescByCode(employee.getStatus()));
|
vo.setStatusDesc(EmployeeStatus.getDescByCode(employee.getStatus()));
|
||||||
return vo;
|
return vo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,4 +44,38 @@
|
|||||||
ORDER BY e.create_time DESC
|
ORDER BY e.create_time DESC
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- 批量插入或更新员工信息(只更新非null字段) -->
|
||||||
|
<insert id="insertOrUpdateBatch" parameterType="java.util.List">
|
||||||
|
INSERT INTO ccdi_employee
|
||||||
|
(employee_id, name, dept_id, id_card, phone, hire_date, status,
|
||||||
|
create_time, create_by, update_by, update_time)
|
||||||
|
VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(#{item.employeeId}, #{item.name}, #{item.deptId}, #{item.idCard},
|
||||||
|
#{item.phone}, #{item.hireDate}, #{item.status}, NOW(),
|
||||||
|
#{item.createBy}, #{item.updateBy}, NOW())
|
||||||
|
</foreach>
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
name = COALESCE(VALUES(name), name),
|
||||||
|
dept_id = COALESCE(VALUES(dept_id), dept_id),
|
||||||
|
phone = COALESCE(VALUES(phone), phone),
|
||||||
|
hire_date = COALESCE(VALUES(hire_date), hire_date),
|
||||||
|
status = COALESCE(VALUES(status), status),
|
||||||
|
update_by = COALESCE(VALUES(update_by), update_by),
|
||||||
|
update_time = NOW()
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<!-- 批量插入员工信息 -->
|
||||||
|
<insert id="insertBatch" parameterType="java.util.List">
|
||||||
|
INSERT INTO ccdi_employee
|
||||||
|
(employee_id, name, dept_id, id_card, phone, hire_date, status,
|
||||||
|
create_time, create_by, update_by, update_time)
|
||||||
|
VALUES
|
||||||
|
<foreach collection="list" item="item" separator=",">
|
||||||
|
(#{item.employeeId}, #{item.name}, #{item.deptId}, #{item.idCard},
|
||||||
|
#{item.phone}, #{item.hireDate}, #{item.status}, NOW(),
|
||||||
|
#{item.createBy}, #{item.updateBy}, NOW())
|
||||||
|
</foreach>
|
||||||
|
</insert>
|
||||||
|
|
||||||
</mapper>
|
</mapper>
|
||||||
|
|||||||
@@ -59,3 +59,20 @@ export function importData(data, updateSupport) {
|
|||||||
data: data
|
data: data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询导入状态
|
||||||
|
export function getImportStatus(taskId) {
|
||||||
|
return request({
|
||||||
|
url: '/ccdi/employee/importStatus/' + taskId,
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询导入失败记录
|
||||||
|
export function getImportFailures(taskId, pageNum, pageSize) {
|
||||||
|
return request({
|
||||||
|
url: '/ccdi/employee/importFailures/' + taskId,
|
||||||
|
method: 'get',
|
||||||
|
params: { pageNum, pageSize }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,15 @@
|
|||||||
v-hasPermi="['ccdi:employee:import']"
|
v-hasPermi="['ccdi:employee:import']"
|
||||||
>导入</el-button>
|
>导入</el-button>
|
||||||
</el-col>
|
</el-col>
|
||||||
|
<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>
|
||||||
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
@@ -255,11 +264,39 @@
|
|||||||
title="导入结果"
|
title="导入结果"
|
||||||
@close="handleImportResultClose"
|
@close="handleImportResultClose"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 导入失败记录对话框 -->
|
||||||
|
<el-dialog
|
||||||
|
title="导入失败记录"
|
||||||
|
:visible.sync="failureDialogVisible"
|
||||||
|
width="1200px"
|
||||||
|
append-to-body
|
||||||
|
>
|
||||||
|
<el-table :data="failureList" v-loading="failureLoading">
|
||||||
|
<el-table-column label="姓名" prop="name" align="center" />
|
||||||
|
<el-table-column label="柜员号" prop="employeeId" align="center" />
|
||||||
|
<el-table-column label="身份证号" prop="idCard" align="center" />
|
||||||
|
<el-table-column label="电话" prop="phone" 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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {addEmployee, delEmployee, getEmployee, listEmployee, updateEmployee} from "@/api/ccdiEmployee";
|
import {addEmployee, delEmployee, getEmployee, listEmployee, updateEmployee, getImportStatus, getImportFailures} from "@/api/ccdiEmployee";
|
||||||
import {deptTreeSelect} from "@/api/system/user";
|
import {deptTreeSelect} from "@/api/system/user";
|
||||||
import {getToken} from "@/utils/auth";
|
import {getToken} from "@/utils/auth";
|
||||||
import Treeselect from "@riophae/vue-treeselect";
|
import Treeselect from "@riophae/vue-treeselect";
|
||||||
@@ -358,13 +395,35 @@ export default {
|
|||||||
},
|
},
|
||||||
// 导入结果弹窗
|
// 导入结果弹窗
|
||||||
importResultVisible: false,
|
importResultVisible: false,
|
||||||
importResultContent: ""
|
importResultContent: "",
|
||||||
|
// 轮询定时器
|
||||||
|
pollingTimer: null,
|
||||||
|
// 是否显示查看失败记录按钮
|
||||||
|
showFailureButton: false,
|
||||||
|
// 当前导入任务ID
|
||||||
|
currentTaskId: null,
|
||||||
|
// 失败记录对话框
|
||||||
|
failureDialogVisible: false,
|
||||||
|
failureList: [],
|
||||||
|
failureLoading: false,
|
||||||
|
failureTotal: 0,
|
||||||
|
failureQueryParams: {
|
||||||
|
pageNum: 1,
|
||||||
|
pageSize: 10
|
||||||
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
created() {
|
created() {
|
||||||
this.getList();
|
this.getList();
|
||||||
this.getDeptTree();
|
this.getDeptTree();
|
||||||
},
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
// 组件销毁时清除定时器
|
||||||
|
if (this.pollingTimer) {
|
||||||
|
clearInterval(this.pollingTimer);
|
||||||
|
this.pollingTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
/** 查询员工列表 */
|
/** 查询员工列表 */
|
||||||
getList() {
|
getList() {
|
||||||
@@ -512,16 +571,92 @@ export default {
|
|||||||
handleFileSuccess(response, file, fileList) {
|
handleFileSuccess(response, file, fileList) {
|
||||||
this.upload.isUploading = false;
|
this.upload.isUploading = false;
|
||||||
this.upload.open = false;
|
this.upload.open = false;
|
||||||
this.getList();
|
|
||||||
// 显示导入结果弹窗
|
if (response.code === 200) {
|
||||||
this.importResultContent = response.msg;
|
const taskId = response.data.taskId;
|
||||||
this.importResultVisible = true;
|
|
||||||
|
// 显示后台处理提示
|
||||||
|
this.$notify({
|
||||||
|
title: '导入任务已提交',
|
||||||
|
message: '正在后台处理中,处理完成后将通知您',
|
||||||
|
type: 'info',
|
||||||
|
duration: 3000
|
||||||
|
});
|
||||||
|
|
||||||
|
// 开始轮询检查状态
|
||||||
|
this.startImportStatusPolling(taskId);
|
||||||
|
} else {
|
||||||
|
this.$modal.msgError(response.msg);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
// 导入结果弹窗关闭
|
// 导入结果弹窗关闭
|
||||||
handleImportResultClose() {
|
handleImportResultClose() {
|
||||||
this.importResultVisible = false;
|
this.importResultVisible = false;
|
||||||
this.importResultContent = "";
|
this.importResultContent = "";
|
||||||
},
|
},
|
||||||
|
/** 开始轮询导入状态 */
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
/** 查看导入失败记录 */
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
},
|
||||||
// 提交上传文件
|
// 提交上传文件
|
||||||
submitFileForm() {
|
submitFileForm() {
|
||||||
this.$refs.upload.submit();
|
this.$refs.upload.submit();
|
||||||
|
|||||||
15
sql/update_ccdi_employee_20260206.sql
Normal file
15
sql/update_ccdi_employee_20260206.sql
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
-- 员工信息表柜员号唯一性说明
|
||||||
|
-- 日期: 2026-02-06
|
||||||
|
-- 目的: 支持批量更新时使用ON DUPLICATE KEY UPDATE语法
|
||||||
|
|
||||||
|
-- 说明:
|
||||||
|
-- ccdi_employee表的employee_id字段已经是PRIMARY KEY(主键)
|
||||||
|
-- 主键本身具有UNIQUE约束,因此employee_id已经是唯一的
|
||||||
|
-- 不需要再添加额外的UNIQUE KEY约束
|
||||||
|
|
||||||
|
-- 验证主键约束:
|
||||||
|
-- SHOW INDEX FROM ccdi_employee WHERE Key_name = 'PRIMARY';
|
||||||
|
-- 结果显示: employee_id是主键,Non_unique=0表示唯一
|
||||||
|
|
||||||
|
-- 结论:
|
||||||
|
-- employee_id字段已满足UNIQUE约束要求,可以直接用于ON DUPLICATE KEY UPDATE语法
|
||||||
Reference in New Issue
Block a user