# 员工信息异步导入功能设计文档

**创建日期**: 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-info-collection/src/main/java/com/ruoyi/ccdi/config/AsyncConfig.java`
- `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/domain/vo/ImportResultVO.java`
- `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/domain/vo/ImportStatusVO.java`
- `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/domain/vo/ImportFailureVO.java`
- `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/service/ICcdiEmployeeService.java`
- `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeServiceImpl.java`
- `ruoyi-info-collection/src/main/java/com/ruoyi/ccdi/mapper/CcdiEmployeeMapper.java`
- `ruoyi-info-collection/src/main/resources/mapper/ccdi/CcdiEmployeeMapper.xml`
- `ruoyi-info-collection/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
