diff --git a/.gitignore b/.gitignore index 51ec430..cc28b08 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,9 @@ nbdist/ *.swp nul +# Git Worktrees +.worktrees/ + test/ !*/build/*.java diff --git a/doc/plans/2026-02-08-中介导入异步化改造设计.md b/doc/plans/2026-02-08-中介导入异步化改造设计.md new file mode 100644 index 0000000..a9cb688 --- /dev/null +++ b/doc/plans/2026-02-08-中介导入异步化改造设计.md @@ -0,0 +1,743 @@ +# 中介库管理导入功能异步化改造设计文档 + +## 文档信息 + +| 项目 | 内容 | +|------|------| +| **文档标题** | 中介库管理导入功能异步化改造 | +| **创建日期** | 2026-02-08 | +| **参考实现** | 员工信息导入功能 (CcdiEmployeeController) | +| **涉及模块** | 中介库管理 (ccdiIntermediary) | +| **改造范围** | 个人中介导入、实体中介导入 | + +--- + +## 1. 背景与目标 + +### 1.1 当前问题 + +**现状**: 中介库管理的导入功能采用**同步处理**方式,用户上传文件后需要等待所有数据处理完成才能收到响应。 + +**存在问题**: +- ⏱️ 大数据量导入时,用户需要长时间等待(可能数十秒甚至数分钟) +- 🚫 请求可能因超时而中断 +- 😰 用户体验不佳,无法查看导入进度 +- ❌ 导入失败后无法查看详细的失败记录 + +### 1.2 改造目标 + +将中介库管理的导入功能改造为**异步处理模式**,参考员工导入的成功实现: + +**核心目标**: +- ⚡ **即时响应**: 用户上传文件后立即获得taskId,无需等待 +- 📊 **进度追踪**: 前端轮询查询导入进度和状态 +- 💾 **失败重试**: 失败记录保存在Redis,支持7天内查询和重试 +- 🔄 **并发处理**: 支持多个用户同时导入,互不阻塞 + +--- + +## 2. 架构设计 + +### 2.1 三层架构模式 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Layer 1: Controller (CcdiIntermediaryController) │ +│ - 解析Excel文件 │ +│ - 调用主Service的importIntermediaryPerson/Entity() │ +│ - 接收taskId │ +│ - 封装ImportResultVO返回 │ +└──────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Layer 2: 主Service (CcdiIntermediaryServiceImpl) │ +│ - 生成UUID作为taskId │ +│ - 初始化Redis状态(PROCESSING) │ +│ - 获取当前用户名(SecurityUtils.getUsername()) │ +│ - 调用异步Service的importPersonAsync/EntityAsync() │ +│ - 立即返回taskId │ +└──────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────┐ +│ Layer 3: 异步Service (CcdiIntermediaryPersonImport │ +│ /EntityImportServiceImpl) │ +│ - @Async异步执行 │ +│ - 批量验证、插入、更新数据 │ +│ - 保存失败记录到Redis │ +│ - 更新最终状态(SUCCESS/PARTIAL_SUCCESS) │ +└─────────────────────────────────────────────────────────┘ +``` + +### 2.2 数据流转 + +``` +用户上传文件 + │ + ▼ +Controller解析Excel + │ + ▼ +主Service生成taskId + 初始化Redis + │ + ├──► 立即返回taskId给Controller + │ │ + │ ▼ + │ Controller封装ImportResultVO返回 + │ │ + │ ▼ + │ 前端收到响应,开始轮询查询状态 + │ + └──► 异步Service后台执行导入 + │ + ├──► 批量验证数据 + ├──► 批量插入/更新数据 + ├──► 保存失败记录到Redis + └──► 更新Redis状态为SUCCESS/PARTIAL_SUCCESS +``` + +### 2.3 Redis状态管理 + +**状态Key设计**: + +| 类型 | 个人中介 | 实体中介 | +|------|---------|---------| +| **导入状态** | `import:intermediary:{taskId}` | `import:intermediary-entity:{taskId}` | +| **失败记录** | `import:intermediary:{taskId}:failures` | `import:intermediary-entity:{taskId}:failures` | +| **过期时间** | 7天 | 7天 | + +**状态字段结构** (Hash): +```javascript +{ + taskId: "uuid-string", + status: "PROCESSING" | "SUCCESS" | "PARTIAL_SUCCESS", + totalCount: 100, + successCount: 95, + failureCount: 5, + progress: 100, + startTime: 1234567890, + endTime: 1234567900, + message: "成功95条,失败5条" +} +``` + +--- + +## 3. 详细实现方案 + +### 3.1 后端改造 + +#### 文件1: CcdiIntermediaryServiceImpl.java + +**路径**: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryServiceImpl.java` + +**需要添加的依赖注入**: +```java +@Resource +private ICcdiIntermediaryPersonImportService personImportService; + +@Resource +private ICcdiIntermediaryEntityImportService entityImportService; + +@Resource +private RedisTemplate redisTemplate; +``` + +**改造1: importIntermediaryPerson方法** + +**原实现** (同步,第251行开始): +```java +@Override +@Transactional +public String importIntermediaryPerson(List<...> list, boolean updateSupport) { + // 同步执行所有导入逻辑 + // 返回消息字符串 +} +``` + +**新实现** (异步): +```java +@Override +@Transactional +public String importIntermediaryPerson(List list, + boolean updateSupport) { + String taskId = UUID.randomUUID().toString(); + long startTime = System.currentTimeMillis(); + + // 初始化Redis状态 + String statusKey = "import:intermediary:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("taskId", taskId); + statusData.put("status", "PROCESSING"); + statusData.put("totalCount", list.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); + + // 获取当前用户名 + String userName = SecurityUtils.getUsername(); + + // 调用异步方法 + personImportService.importPersonAsync(list, updateSupport, taskId, userName); + + return taskId; +} +``` + +**改造2: importIntermediaryEntity方法** + +与个人中介类似,只需修改: +- Redis Key前缀为 `import:intermediary-entity:` +- 调用 `entityImportService.importEntityAsync()` + +--- + +#### 文件2: CcdiIntermediaryController.java + +**路径**: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiIntermediaryController.java` + +**需要添加的依赖注入**: +```java +@Resource +private ICcdiIntermediaryPersonImportService personImportService; + +@Resource +private ICcdiIntermediaryEntityImportService entityImportService; +``` + +**需要添加的import**: +```java +import com.ruoyi.ccdi.domain.vo.ImportResultVO; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; +import com.ruoyi.ccdi.domain.vo.IntermediaryPersonImportFailureVO; +import com.ruoyi.ccdi.domain.vo.IntermediaryEntityImportFailureVO; +import com.ruoyi.ccdi.service.ICcdiIntermediaryPersonImportService; +import com.ruoyi.ccdi.service.ICcdiIntermediaryEntityImportService; +``` + +**改造1: importPersonData方法** (第183-188行) + +**原实现**: +```java +@PostMapping("/importPersonData") +public AjaxResult importPersonData(MultipartFile file, boolean updateSupport) throws Exception { + List list = EasyExcelUtil.importExcel(...); + String message = intermediaryService.importIntermediaryPerson(list, updateSupport); + return success(message); +} +``` + +**新实现**: +```java +@PostMapping("/importPersonData") +public AjaxResult importPersonData(MultipartFile file, + @RequestParam(defaultValue = "false") boolean updateSupport) + throws Exception { + List list = EasyExcelUtil.importExcel( + file.getInputStream(), CcdiIntermediaryPersonExcel.class); + + if (list == null || list.isEmpty()) { + return error("至少需要一条数据"); + } + + // 提交异步任务 + String taskId = intermediaryService.importIntermediaryPerson(list, updateSupport); + + // 立即返回,不等待后台任务完成 + ImportResultVO result = new ImportResultVO(); + result.setTaskId(taskId); + result.setStatus("PROCESSING"); + result.setMessage("导入任务已提交,正在后台处理"); + + return AjaxResult.success("导入任务已提交,正在后台处理", result); +} +``` + +**改造2: importEntityData方法** (第196-201行) + +与个人中介类似,只需修改: +- Excel类为 `CcdiIntermediaryEntityExcel` +- 调用 `importIntermediaryEntity()` + +**新增3: 查询个人中介导入状态** +```java +@GetMapping("/importPersonStatus/{taskId}") +public AjaxResult getPersonImportStatus(@PathVariable String taskId) { + try { + ImportStatusVO status = personImportService.getImportStatus(taskId); + return success(status); + } catch (Exception e) { + return error(e.getMessage()); + } +} +``` + +**新增4: 查询个人中介导入失败记录** +```java +@GetMapping("/importPersonFailures/{taskId}") +public TableDataInfo getPersonImportFailures( + @PathVariable String taskId, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + + List failures = + personImportService.getImportFailures(taskId); + + // 手动分页 + int fromIndex = (pageNum - 1) * pageSize; + int toIndex = Math.min(fromIndex + pageSize, failures.size()); + + List pageData = failures.subList(fromIndex, toIndex); + + return getDataTable(pageData, failures.size()); +} +``` + +**新增5-6: 实体中介的状态和失败记录查询接口** + +与个人中介完全对称,只需: +- URL中的`Person`改为`Entity` +- Service改为`entityImportService` +- VO改为`IntermediaryEntityImportFailureVO` + +**接口路径对照表**: + +| 功能 | 个人中介 | 实体中介 | +|------|---------|---------| +| 导入数据 | `POST /importPersonData` | `POST /importEntityData` | +| 查询状态 | `GET /importPersonStatus/{taskId}` | `GET /importEntityStatus/{taskId}` | +| 查询失败 | `GET /importPersonFailures/{taskId}` | `GET /importEntityFailures/{taskId}` | + +--- + +### 3.2 前端改造 + +#### 文件1: API接口定义 + +**路径**: `ruoyi-ui/src/api/ccdiIntermediary.js` + +**需要添加的方法**: + +```javascript +import request from '@/utils/request' + +// 查询个人中介导入状态 +export function getPersonImportStatus(taskId) { + return request({ + url: `/ccdi/intermediary/importPersonStatus/${taskId}`, + method: 'get' + }) +} + +// 查询个人中介导入失败记录 +export function getPersonImportFailures(taskId, pageNum, pageSize) { + return request({ + url: `/ccdi/intermediary/importPersonFailures/${taskId}`, + method: 'get', + params: { pageNum, pageSize } + }) +} + +// 查询实体中介导入状态 +export function getEntityImportStatus(taskId) { + return request({ + url: `/ccdi/intermediary/importEntityStatus/${taskId}`, + method: 'get' + }) +} + +// 查询实体中介导入失败记录 +export function getEntityImportFailures(taskId, pageNum, pageSize) { + return request({ + url: `/ccdi/intermediary/importEntityFailures/${taskId}`, + method: 'get', + params: { pageNum, pageSize } + }) +} +``` + +#### 文件2: ImportDialog.vue改造 + +**路径**: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue` + +**需要添加的import**: +```javascript +import { getPersonImportStatus, getEntityImportStatus } from "@/api/ccdiIntermediary"; +``` + +**data中添加的状态管理**: +```javascript +data() { + return { + // ...原有data + pollingTimer: null, + currentTaskId: null + } +} +``` + +**修改handleFileSuccess方法**: +```javascript +handleFileSuccess(response) { + this.isUploading = false; + + if (response.code === 200 && response.data && response.data.taskId) { + const taskId = response.data.taskId; + this.currentTaskId = taskId; + + // 显示通知 + this.$notify({ + title: '导入任务已提交', + message: '正在后台处理中,处理完成后将通知您', + type: 'info', + duration: 3000 + }); + + // 关闭对话框 + this.visible = false; + this.$refs.upload.clearFiles(); + + // 通知父组件刷新列表 + this.$emit("success", taskId); + + // 开始轮询 + this.startImportStatusPolling(taskId); + } else { + this.$modal.msgError(response.msg || '导入失败'); + } +} +``` + +**添加轮询方法**: +```javascript +methods: { + /** 开始轮询导入状态 */ + startImportStatusPolling(taskId) { + let pollCount = 0; + const maxPolls = 150; // 最多5分钟 + + this.pollingTimer = setInterval(async () => { + try { + pollCount++; + + if (pollCount > maxPolls) { + clearInterval(this.pollingTimer); + this.$modal.msgWarning('导入任务处理超时,请联系管理员'); + return; + } + + // 根据导入类型调用不同的API + const apiMethod = this.formData.importType === 'person' + ? getPersonImportStatus + : getEntityImportStatus; + + const response = await apiMethod(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 + }); + } else if (statusResult.failureCount > 0) { + this.$notify({ + title: '导入完成', + message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, + type: 'warning', + duration: 5000 + }); + } + + // 通知父组件更新失败记录状态 + this.$emit("import-complete", { + taskId: statusResult.taskId, + hasFailures: statusResult.failureCount > 0 + }); + } +} + +/** 组件销毁时清除定时器 */ +beforeDestroy() { + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } +} +``` + +--- + +## 4. 测试方案 + +### 4.1 功能测试用例 + +#### 测试用例1: 正常导入流程 + +**前置条件**: +- 准备包含10条个人中介数据的Excel文件 +- 数据格式正确,所有必填字段都已填写 + +**测试步骤**: +1. 登录系统,进入中介管理页面 +2. 点击"导入"按钮 +3. 选择"个人中介"类型 +4. 上传Excel文件,不勾选"更新已存在的数据" +5. 点击"开始导入" + +**预期结果**: +- ✅ 立即收到通知:"导入任务已提交,正在后台处理中" +- ✅ 导入对话框关闭 +- ✅ 2-5秒后收到完成通知(根据数据量) +- ✅ 列表自动刷新,显示新导入的数据 +- ✅ 如果全部成功,显示绿色通知:"全部成功!共导入10条数据" + +#### 测试用例2: 数据验证失败 + +**前置条件**: +- 准备包含错误数据的Excel(如身份证号格式错误、姓名为空等) + +**测试步骤**: +1. 重复测试用例1的步骤 + +**预期结果**: +- ✅ 导入任务正常提交 +- ✅ 完成后显示黄色通知:"成功X条,失败Y条" +- ✅ 页面出现"查看导入失败记录"按钮 +- ✅ 点击按钮可以查看失败原因 +- ✅ 失败记录包含:原数据行号、错误信息 + +#### 测试用例3: 更新模式 + +**前置条件**: +- 数据库中已存在某个证件号的中介记录 +- Excel文件中包含相同证件号的数据,但其他字段不同 + +**测试步骤**: +1. 勾选"更新已存在的数据" +2. 上传Excel文件 + +**预期结果**: +- ✅ 已存在的数据被更新 +- ✅ 审计字段`updatedBy`正确记录当前用户 +- ✅ `updateTime`更新为当前时间 + +#### 测试用例4: 实体中介导入 + +**前置条件**: +- 准备包含机构中介数据的Excel文件 + +**测试步骤**: +1. 选择"机构中介"类型 +2. 上传Excel文件 + +**预期结果**: +- ✅ 导入流程与个人中介一致 +- ✅ Redis Key前缀为`import:intermediary-entity:` +- ✅ 数据正确插入`ccdi_enterprise_base_info`表 + +#### 测试用例5: 并发导入 + +**测试步骤**: +1. 打开两个浏览器标签页 +2. 同时在不同标签页导入个人中介和实体中介 + +**预期结果**: +- ✅ 两个导入任务互不影响 +- ✅ 各自独立显示进度通知 +- ✅ 都能正确完成 + +#### 测试用例6: 大数据量导入 + +**前置条件**: +- 准备包含1000条数据的Excel文件 + +**测试步骤**: +1. 上传大文件 +2. 观察导入过程 + +**预期结果**: +- ✅ 立即返回taskId,不阻塞 +- ✅ 轮询查询能正确获取进度 +- ✅ 最终完成并显示正确统计信息 + +### 4.2 性能测试 + +#### 性能指标 + +| 指标 | 目标值 | +|------|--------| +| 接口响应时间 | < 500ms (立即返回) | +| 轮询间隔 | 2秒 | +| 轮询超时 | 5分钟 (150次) | +| 单批导入大小 | 500条 | +| 支持最大文件 | 10MB | +| 并发导入任务 | 10个 | + +#### 测试方法 + +```bash +# 使用Apache Bench进行压力测试 +ab -n 100 -c 10 -T "multipart/form-data; boundary=----WebKitFormBoundary" \ + -p test_data.xlsx http://localhost:8080/ccdi/intermediary/importPersonData +``` + +--- + +## 5. 部署与验证 + +### 5.1 部署步骤 + +1. **代码修改** + - 按照上述方案修改3个后端文件 + - 修改2个前端文件 + +2. **编译打包** + ```bash + # 后端 + cd ruoyi-ccdi + mvn clean package + + # 前端 + cd ruoyi-ui + npm run build:prod + ``` + +3. **重启服务** + ```bash + # 停止现有服务 + # 部署新的jar包 + # 启动服务 + ``` + +4. **验证部署** + - 访问Swagger文档: `http://localhost:8080/swagger-ui/index.html` + - 确认新的接口已正确注册 + +### 5.2 验证清单 + +- [ ] 个人中介导入接口返回taskId +- [ ] 实体中介导入接口返回taskId +- [ ] 轮询查询状态接口正常工作 +- [ ] 失败记录查询接口返回正确数据 +- [ ] 前端轮询机制正常 +- [ ] 导入完成通知正确显示 +- [ ] Redis状态正确设置和过期 +- [ ] 审计字段正确记录操作人 + +--- + +## 6. 风险与注意事项 + +### 6.1 潜在风险 + +| 风险项 | 影响 | 缓解措施 | +|--------|------|----------| +| Redis服务故障 | 导入状态无法记录 | 确保Redis高可用,增加监控 | +| 异步任务执行失败 | 任务状态卡在PROCESSING | 增加超时机制和失败重试 | +| 并发量过大 | 系统资源耗尽 | 限制并发导入任务数 | +| 轮询频繁 | 服务器压力增大 | 合理设置轮询间隔(2秒) | + +### 6.2 注意事项 + +1. **异步方法无法使用@Transactional** + - 异步Service中使用`@Transactional`会失效 + - 需要在方法内部手动管理事务 + +2. **Redis数据过期** + - 7天后导入状态和失败记录会自动删除 + - 用户需要及时查看失败记录 + +3. **userName参数** + - 中介实体需要记录`createdBy/updatedBy` + - 必须传递当前用户名给异步方法 + +4. **轮询超时处理** + - 最多轮询150次(5分钟) + - 超时后需要提示用户联系管理员 + +--- + +## 7. 实施计划 + +### 7.1 任务分解 + +| 任务 | 负责人 | 预计时间 | +|------|--------|----------| +| 1. 后端Service层改造 | 后端开发 | 2小时 | +| 2. 后端Controller层改造 | 后端开发 | 1小时 | +| 3. 前端API接口定义 | 前端开发 | 0.5小时 | +| 4. 前端ImportDialog组件改造 | 前端开发 | 2小时 | +| 5. 单元测试 | 测试开发 | 2小时 | +| 6. 集成测试 | 测试开发 | 2小时 | +| 7. 文档更新 | 技术文档 | 1小时 | + +**总计**: 约10.5小时 + +### 7.2 里程碑 + +- **T+0**: 完成设计文档 +- **T+1天**: 完成后端代码改造和单元测试 +- **T+2天**: 完成前端代码改造 +- **T+3天**: 完成集成测试和部署 + +--- + +## 8. 附录 + +### 8.1 相关文档 + +- [员工导入功能设计](../员工导入功能/) +- [MyBatis Plus批量操作文档](https://baomidou.com/pages/2976a3/) +- [Spring异步任务文档](https://docs.spring.io/spring-framework/docs/current/reference/html/integration.html#scheduling) + +### 8.2 参考代码 + +- **员工导入Controller**: `CcdiEmployeeController.java:136-191` +- **员工导入Service**: `CcdiEmployeeServiceImpl.java:186-208` +- **员工异步导入Service**: `CcdiEmployeeImportServiceImpl.java:43-109` +- **员工导入前端**: `ruoyi-ui/src/views/ccdiEmployee/index.vue` + +### 8.3 数据字典 + +**导入状态枚举**: + +| 状态值 | 说明 | +|--------|------| +| PROCESSING | 处理中 | +| SUCCESS | 全部成功 | +| PARTIAL_SUCCESS | 部分成功(有失败记录) | + +**Redis Key设计**: + +| 类型 | Key模式 | 过期时间 | +|------|---------|----------| +| 个人中介状态 | `import:intermediary:{taskId}` | 7天 | +| 个人中介失败 | `import:intermediary:{taskId}:failures` | 7天 | +| 实体中介状态 | `import:intermediary-entity:{taskId}` | 7天 | +| 实体中介失败 | `import:intermediary-entity:{taskId}:failures` | 7天 | + +--- + +**文档版本**: v1.0 +**最后更新**: 2026-02-08 +**文档状态**: 待审核 diff --git a/doc/test-data/purchase_transaction/purchase_test_data_2000_final.xlsx b/doc/test-data/purchase_transaction/purchase_test_data_2000_final.xlsx new file mode 100644 index 0000000..871a2eb Binary files /dev/null and b/doc/test-data/purchase_transaction/purchase_test_data_2000_final.xlsx differ