From daf03e1ef09411bd487a150fdfe28bb00c7b9bff Mon Sep 17 00:00:00 2001 From: wkc <978997012@qq.com> Date: Sun, 8 Feb 2026 16:24:02 +0800 Subject: [PATCH] =?UTF-8?q?test:=20=E6=B7=BB=E5=8A=A0=E4=B8=AD=E4=BB=8B?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD=E6=B5=8B=E8=AF=95=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=92=8C=E6=8A=A5=E5=91=8A=E6=A8=A1=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加自动化测试脚本 test-import-upsert.js - 覆盖5个测试场景(首次导入、重复导入、更新等) - 添加测试报告模板 TEST-REPORT-TEMPLATE.md Co-Authored-By: Claude Sonnet 4.5 --- .../intermediary/TEST-REPORT-TEMPLATE.md | 301 ++++++++++++ .../intermediary/test-import-upsert.js | 446 ++++++++++++++++++ 2 files changed, 747 insertions(+) create mode 100644 doc/test-data/intermediary/TEST-REPORT-TEMPLATE.md create mode 100644 doc/test-data/intermediary/test-import-upsert.js diff --git a/doc/test-data/intermediary/TEST-REPORT-TEMPLATE.md b/doc/test-data/intermediary/TEST-REPORT-TEMPLATE.md new file mode 100644 index 0000000..70ddb34 --- /dev/null +++ b/doc/test-data/intermediary/TEST-REPORT-TEMPLATE.md @@ -0,0 +1,301 @@ +# 中介导入功能重构测试报告 + +## 测试目标 + +验证Service层重构后,使用 `importPersonBatch` 和 `importEntityBatch` 方法 +(基于 `ON DUPLICATE KEY UPDATE`) 的导入功能是否正常工作。 + +## 重构内容 + +### Task 5: 重构个人中介导入Service + +**文件:** `CcdiIntermediaryPersonImportServiceImpl.java` + +**核心变更:** +- 移除"先查询后分类再删除再插入"的逻辑 +- 更新模式(`isUpdateSupport=true`): 直接调用 `intermediaryMapper.importPersonBatch(validRecords)` +- 仅新增模式(`isUpdateSupport=false`): 先查询冲突,然后只插入无冲突数据 +- 新增辅助方法: + - `saveBatchWithUpsert()`: 使用 `importPersonBatch` 进行批量UPSERT + - `getExistingPersonIdsFromDb()`: 从数据库获取已存在的证件号 + - `createFailureVO()`: 创建失败记录VO(两个重载方法) + +### Task 6: 重构实体中介导入Service + +**文件:** `CcdiIntermediaryEntityImportServiceImpl.java` + +**同样的重构逻辑** + +## 测试场景 + +### 场景1: 个人中介 - 更新模式(第一次导入) + +**目的:** 验证批量INSERT功能 + +**操作:** +- 上传测试数据文件(1000条个人中介数据) +- 设置 `updateSupport=true` + +**预期结果:** +- 所有数据成功插入 +- 状态: SUCCESS +- 成功数 = 总数 +- 失败数 = 0 + +**实际结果:** _待测试_ + +**状态:** ⏳ 待执行 + +--- + +### 场景2: 个人中介 - 仅新增模式(重复导入) + +**目的:** 验证冲突检测功能 + +**操作:** +- 再次上传相同的测试数据 +- 设置 `updateSupport=false` + +**预期结果:** +- 所有数据因为冲突而失败 +- 状态: PARTIAL_SUCCESS 或 FAILURE +- 成功数 = 0 +- 失败数 = 总数 +- 失败原因: "该证件号码已存在" + +**实际结果:** _待测试_ + +**状态:** ⏳ 待执行 + +--- + +### 场景3: 实体中介 - 更新模式(第一次导入) + +**目的:** 验证实体中介批量INSERT功能 + +**操作:** +- 上传测试数据文件(1000条实体中介数据) +- 设置 `updateSupport=true` + +**预期结果:** +- 所有数据成功插入 +- 状态: SUCCESS +- 成功数 = 总数 +- 失败数 = 0 + +**实际结果:** _待测试_ + +**状态:** ⏳ 待执行 + +--- + +### 场景4: 实体中介 - 仅新增模式(重复导入) + +**目的:** 验证实体中介冲突检测功能 + +**操作:** +- 再次上传相同的测试数据 +- 设置 `updateSupport=false` + +**预期结果:** +- 所有数据因为冲突而失败 +- 状态: PARTIAL_SUCCESS 或 FAILURE +- 成功数 = 0 +- 失败数 = 总数 +- 失败原因: "该统一社会信用代码已存在" + +**实际结果:** _待测试_ + +**状态:** ⏳ 待执行 + +--- + +### 场景5: 个人中介 - 再次更新模式 + +**目的:** 验证 `ON DUPLICATE KEY UPDATE` 功能 + +**操作:** +- 第三次上传相同的测试数据 +- 设置 `updateSupport=true` + +**预期结果:** +- 所有数据成功更新(而不是先删除再插入) +- 状态: SUCCESS +- 成功数 = 总数 +- 失败数 = 0 +- 数据库中不会出现重复记录 + +**实际结果:** _待测试_ + +**状态:** ⏳ 待执行 + +--- + +## 测试方法 + +### 手动测试 + +1. **启动后端服务** + ```bash + cd ruoyi-ccdi + mvn spring-boot:run + ``` + +2. **访问Swagger UI** + - URL: http://localhost:8080/swagger-ui/index.html + - 找到 `/ccdi/intermediary/importPersonData` 和 `/ccdi/intermediary/importEntityData` 接口 + +3. **执行测试场景** + - 使用"Try it out"功能上传测试文件 + - 观察响应结果 + - 使用任务ID查询导入状态 + - 查看失败记录 + +### 自动化测试 + +运行测试脚本: +```bash +cd doc/test-data/intermediary +node test-import-upsert.js +``` + +测试脚本会自动执行所有测试场景并生成报告。 + +## 测试数据 + +### 个人中介测试数据 + +- 文件: `doc/test-data/intermediary/个人中介黑名单测试数据_1000条_第1批.xlsx` +- 记录数: 1000 +- 特点: 包含有效的身份证号码 + +### 实体中介测试数据 + +- 文件: `doc/test-data/intermediary/机构中介黑名单测试数据_1000条_第1批.xlsx` +- 记录数: 1000 +- 特点: 包含有效的统一社会信用代码 + +## 关键验证点 + +### 1. 数据库层面验证 + +**更新模式下的UPSERT操作:** +- 检查 `ccdi_biz_intermediary` 表,确保持有相同 `person_id` 的记录只有1条 +- 检查 `ccdi_enterprise_base_info` 表,确保持有相同 `social_credit_code` 的记录只有1条 + +**验证SQL:** +```sql +-- 检查个人中介重复记录 +SELECT person_id, COUNT(*) as cnt +FROM ccdi_biz_intermediary +GROUP BY person_id +HAVING cnt > 1; + +-- 检查实体中介重复记录 +SELECT social_credit_code, COUNT(*) as cnt +FROM ccdi_enterprise_base_info +GROUP BY social_credit_code +HAVING cnt > 1; +``` + +### 2. 性能验证 + +**对比重构前后的性能差异:** + +| 场景 | 重构前(先删后插) | 重构后(UPSERT) | 性能提升 | +|------|----------------|---------------|---------| +| 1000条首次导入 | _待测试_ | _待测试_ | _待计算_ | +| 1000条重复导入 | _待测试_ | _待测试_ | _待计算_ | + +### 3. 错误处理验证 + +**验证失败记录的正确性:** +- 失败原因是否准确 +- 失败记录的完整信息是否保留 +- Redis中失败记录的存储和读取 + +## 测试结果汇总 + +| 场景 | 状态 | 通过/失败 | 备注 | +|------|------|----------|------| +| 场景1 | ⏳ 待执行 | - | 个人中介首次导入 | +| 场景2 | ⏳ 待执行 | - | 个人中介重复导入(仅新增) | +| 场景3 | ⏳ 待执行 | - | 实体中介首次导入 | +| 场景4 | ⏳ 待执行 | - | 实体中介重复导入(仅新增) | +| 场景5 | ⏳ 待执行 | - | 个人中介重复导入(更新) | + +**总通过率:** 0/5 (0%) + +## 问题记录 + +### 问题1: _问题描述_ + +**场景:** _相关场景_ + +**现象:** _具体表现_ + +**原因:** _根本原因_ + +**解决方案:** _修复方法_ + +**状态:** ⏳ 待解决 / ✅ 已解决 + +--- + +## 结论 + +_测试完成后填写总体结论_ + +### 代码质量评估 + +- **可读性:** _评分_ / 10 +- **可维护性:** _评分_ / 10 +- **性能:** _评分_ / 10 +- **错误处理:** _评分_ / 10 + +### 优化建议 + +_根据测试结果提出优化建议_ + +## 附录 + +### A. 测试环境信息 + +- **操作系统:** Windows 11 +- **Java版本:** 17 +- **Spring Boot版本:** 3.5.8 +- **MySQL版本:** 8.2.0 +- **Redis版本:** _待填写_ + +### B. 相关文件清单 + +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryPersonImportServiceImpl.java` +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiIntermediaryEntityImportServiceImpl.java` +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiBizIntermediaryMapper.java` +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiEnterpriseBaseInfoMapper.java` +- `doc/test-data/intermediary/test-import-upsert.js` + +### C. Git提交信息 + +``` +commit 7d534de +refactor: 重构Service层使用ON DUPLICATE KEY UPDATE + +- 更新模式直接调用importPersonBatch/importEntityBatch +- 移除'先删除再插入'逻辑,代码简化约50% +- 添加辅助方法saveBatchWithUpsert/getExistingPersonIdsFromDb +- 添加createFailureVO重载方法简化失败记录创建 + +变更详情: +- CcdiIntermediaryPersonImportServiceImpl: 重构importPersonAsync方法 +- CcdiIntermediaryEntityImportServiceImpl: 重构importEntityAsync方法 +- 两个Service均采用统一的处理模式 + +Co-Authored-By: Claude Sonnet 4.5 +``` + +--- + +**报告生成时间:** 2026-02-08 +**测试执行人:** _待填写_ +**审核人:** _待填写_ diff --git a/doc/test-data/intermediary/test-import-upsert.js b/doc/test-data/intermediary/test-import-upsert.js new file mode 100644 index 0000000..b6e528b --- /dev/null +++ b/doc/test-data/intermediary/test-import-upsert.js @@ -0,0 +1,446 @@ +/** + * 中介导入功能测试脚本 - 验证ON DUPLICATE KEY UPDATE重构 + * + * 测试场景: + * 1. 更新模式 - 测试importPersonBatch/importEntityBatch的INSERT ON DUPLICATE KEY UPDATE + * 2. 仅新增模式 - 测试冲突检测和失败记录 + * 3. 边界情况 - 空列表、全部冲突、部分冲突等 + */ + +const axios = require('axios'); +const FormData = require('form-data'); +const fs = require('fs'); +const path = require('path'); + +// 配置 +const BASE_URL = 'http://localhost:8080'; +const LOGIN_URL = `${BASE_URL}/login/test`; +const PERSON_IMPORT_URL = `${BASE_URL}/ccdi/intermediary/importPersonData`; +const ENTITY_IMPORT_URL = `${BASE_URL}/ccdi/intermediary/importEntityData`; +const PERSON_STATUS_URL = `${BASE_URL}/ccdi/intermediary/person/import/status`; +const ENTITY_STATUS_URL = `${BASE_URL}/ccdi/intermediary/entity/import/status`; +const PERSON_FAILURES_URL = `${BASE_URL}/ccdi/intermediary/person/import/failures`; +const ENTITY_FAILURES_URL = `${BASE_URL}/ccdi/intermediary/entity/import/failures`; + +// 测试数据文件路径 +const TEST_DATA_DIR = path.join(__dirname, '../test-data/intermediary'); +const PERSON_TEST_FILE = path.join(TEST_DATA_DIR, '个人中介黑名单测试数据_1000条_第1批.xlsx'); +const ENTITY_TEST_FILE = path.join(TEST_DATA_DIR, '机构中介黑名单测试数据_1000条_第1批.xlsx'); + +let authToken = ''; + +// 颜色输出 +const colors = { + reset: '\x1b[0m', + green: '\x1b[32m', + red: '\x1b[31m', + yellow: '\x1b[33m', + blue: '\x1b[36m' +}; + +function log(message, color = 'reset') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function logSuccess(message) { + log(`✓ ${message}`, 'green'); +} + +function logError(message) { + log(`✗ ${message}`, 'red'); +} + +function logInfo(message) { + log(`ℹ ${message}`, 'blue'); +} + +function logSection(title) { + console.log('\n' + '='.repeat(60)); + log(title, 'yellow'); + console.log('='.repeat(60)); +} + +/** + * 登录获取Token + */ +async function login() { + logSection('登录系统'); + + try { + const response = await axios.post(LOGIN_URL, { + username: 'admin', + password: 'admin123' + }); + + if (response.data.code === 200) { + authToken = response.data.data; + logSuccess('登录成功'); + logInfo(`Token: ${authToken.substring(0, 20)}...`); + return true; + } else { + logError(`登录失败: ${response.data.msg}`); + return false; + } + } catch (error) { + logError(`登录请求失败: ${error.message}`); + return false; + } +} + +/** + * 上传文件并开始导入 + */ +async function importData(file, url, updateSupport, description) { + logSection(description); + + if (!fs.existsSync(file)) { + logError(`测试文件不存在: ${file}`); + return null; + } + + logInfo(`上传文件: ${path.basename(file)}`); + logInfo(`更新模式: ${updateSupport ? '是' : '否'}`); + + try { + const form = new FormData(); + form.append('file', fs.createReadStream(file)); + form.append('updateSupport', updateSupport.toString()); + + const response = await axios.post(url, form, { + headers: { + ...form.getHeaders(), + 'Authorization': `Bearer ${authToken}` + } + }); + + if (response.data.code === 200) { + logSuccess('导入任务已提交'); + logInfo(`响应信息: ${response.data.msg}`); + + // 从响应中提取taskId + const match = response.data.msg.match(/任务ID: ([a-zA-Z0-9-]+)/); + if (match) { + const taskId = match[1]; + logInfo(`任务ID: ${taskId}`); + return taskId; + } + } else { + logError(`导入失败: ${response.data.msg}`); + } + } catch (error) { + logError(`导入请求失败: ${error.message}`); + if (error.response) { + logError(`状态码: ${error.response.status}`); + logError(`响应数据: ${JSON.stringify(error.response.data)}`); + } + } + + return null; +} + +/** + * 轮询查询导入状态 + */ +async function pollImportStatus(taskId, url, description, maxAttempts = 30, interval = 2000) { + logInfo(`等待导入完成...`); + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + const response = await axios.get(`${url}?taskId=${taskId}`, { + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + + if (response.data.code === 200) { + const status = response.data.data; + logInfo(`[尝试 ${attempt}/${maxAttempts}] 状态: ${status.status}, 进度: ${status.progress}%`); + + if (status.status === 'SUCCESS' || status.status === 'PARTIAL_SUCCESS') { + logSuccess(`${description}完成!`); + logInfo(`总数: ${status.totalCount}, 成功: ${status.successCount}, 失败: ${status.failureCount}`); + return status; + } else if (status.status === 'FAILURE') { + logError(`${description}失败`); + return status; + } + } + } catch (error) { + logError(`查询状态失败: ${error.message}`); + } + + await sleep(interval); + } + + logError('导入超时'); + return null; +} + +/** + * 获取导入失败记录 + */ +async function getImportFailures(taskId, url, description) { + logSection(`获取${description}失败记录`); + + try { + const response = await axios.get(`${url}?taskId=${taskId}`, { + headers: { + 'Authorization': `Bearer ${authToken}` + } + }); + + if (response.data.code === 200) { + const failures = response.data.data; + logInfo(`失败记录数: ${failures.length}`); + + if (failures.length > 0) { + logInfo('前3条失败记录:'); + failures.slice(0, 3).forEach((failure, index) => { + console.log(` ${index + 1}. ${failure.errorMessage || '未知错误'}`); + }); + + // 保存失败记录到文件 + const failureFile = path.join(__dirname, `failures_${taskId}.json`); + fs.writeFileSync(failureFile, JSON.stringify(failures, null, 2)); + logInfo(`失败记录已保存到: ${failureFile}`); + } + + return failures; + } + } catch (error) { + logError(`获取失败记录失败: ${error.message}`); + } + + return []; +} + +/** + * 辅助函数: 延迟 + */ +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * 测试场景1: 个人中介 - 更新模式(第一次导入) + */ +async function testPersonImportUpdateMode() { + logSection('测试场景1: 个人中介 - 更新模式(第一次导入)'); + + const taskId = await importData( + PERSON_TEST_FILE, + PERSON_IMPORT_URL, + true, // 更新模式 + '个人中介导入(更新模式)' + ); + + if (!taskId) { + logError('导入任务未创建'); + return false; + } + + const status = await pollImportStatus(taskId, PERSON_STATUS_URL, '个人中介导入'); + + if (status && (status.status === 'SUCCESS' || status.status === 'PARTIAL_SUCCESS')) { + const failures = await getImportFailures(taskId, PERSON_FAILURES_URL, '个人中介'); + logSuccess(`测试场景1完成 - 成功: ${status.successCount}, 失败: ${status.failureCount}`); + return true; + } + + return false; +} + +/** + * 测试场景2: 个人中介 - 仅新增模式(重复导入应失败) + */ +async function testPersonImportInsertOnly() { + logSection('测试场景2: 个人中介 - 仅新增模式(重复导入)'); + + const taskId = await importData( + PERSON_TEST_FILE, + PERSON_IMPORT_URL, + false, // 仅新增模式 + '个人中介导入(仅新增)' + ); + + if (!taskId) { + logError('导入任务未创建'); + return false; + } + + const status = await pollImportStatus(taskId, PERSON_STATUS_URL, '个人中介导入'); + + if (status && (status.status === 'SUCCESS' || status.status === 'PARTIAL_SUCCESS')) { + const failures = await getImportFailures(taskId, PERSON_FAILURES_URL, '个人中介'); + + // 在仅新增模式下,重复导入应该全部失败 + if (failures.length > 0) { + logSuccess(`测试场景2完成 - 预期有失败记录, 实际失败: ${failures.length}`); + return true; + } else { + logError('测试场景2失败 - 预期有失败记录, 但实际没有'); + return false; + } + } + + return false; +} + +/** + * 测试场景3: 实体中介 - 更新模式(第一次导入) + */ +async function testEntityImportUpdateMode() { + logSection('测试场景3: 实体中介 - 更新模式(第一次导入)'); + + const taskId = await importData( + ENTITY_TEST_FILE, + ENTITY_IMPORT_URL, + true, // 更新模式 + '实体中介导入(更新模式)' + ); + + if (!taskId) { + logError('导入任务未创建'); + return false; + } + + const status = await pollImportStatus(taskId, ENTITY_STATUS_URL, '实体中介导入'); + + if (status && (status.status === 'SUCCESS' || status.status === 'PARTIAL_SUCCESS')) { + const failures = await getImportFailures(taskId, ENTITY_FAILURES_URL, '实体中介'); + logSuccess(`测试场景3完成 - 成功: ${status.successCount}, 失败: ${status.failureCount}`); + return true; + } + + return false; +} + +/** + * 测试场景4: 实体中介 - 仅新增模式(重复导入应失败) + */ +async function testEntityImportInsertOnly() { + logSection('测试场景4: 实体中介 - 仅新增模式(重复导入)'); + + const taskId = await importData( + ENTITY_TEST_FILE, + ENTITY_IMPORT_URL, + false, // 仅新增模式 + '实体中介导入(仅新增)' + ); + + if (!taskId) { + logError('导入任务未创建'); + return false; + } + + const status = await pollImportStatus(taskId, ENTITY_STATUS_URL, '实体中介导入'); + + if (status && (status.status === 'SUCCESS' || status.status === 'PARTIAL_SUCCESS')) { + const failures = await getImportFailures(taskId, ENTITY_FAILURES_URL, '实体中介'); + + // 在仅新增模式下,重复导入应该全部失败 + if (failures.length > 0) { + logSuccess(`测试场景4完成 - 预期有失败记录, 实际失败: ${failures.length}`); + return true; + } else { + logError('测试场景4失败 - 预期有失败记录, 但实际没有'); + return false; + } + } + + return false; +} + +/** + * 测试场景5: 个人中介 - 再次更新模式(应该更新已有数据) + */ +async function testPersonImportUpdateAgain() { + logSection('测试场景5: 个人中介 - 再次更新模式'); + + const taskId = await importData( + PERSON_TEST_FILE, + PERSON_IMPORT_URL, + true, // 更新模式 + '个人中介导入(再次更新)' + ); + + if (!taskId) { + logError('导入任务未创建'); + return false; + } + + const status = await pollImportStatus(taskId, PERSON_STATUS_URL, '个人中介导入'); + + if (status && (status.status === 'SUCCESS' || status.status === 'PARTIAL_SUCCESS')) { + const failures = await getImportFailures(taskId, PERSON_FAILURES_URL, '个人中介'); + logSuccess(`测试场景5完成 - 成功: ${status.successCount}, 失败: ${status.failureCount}`); + return true; + } + + return false; +} + +/** + * 主测试流程 + */ +async function runTests() { + console.log('\n╔════════════════════════════════════════════════════════════╗'); + console.log('║ 中介导入功能测试 - ON DUPLICATE KEY UPDATE验证 ║'); + console.log('╚════════════════════════════════════════════════════════════╝'); + + const startTime = Date.now(); + const results = { + passed: 0, + failed: 0 + }; + + // 登录 + const loginSuccess = await login(); + if (!loginSuccess) { + logError('无法登录,终止测试'); + return; + } + + // 执行测试 + const tests = [ + { name: '场景1: 个人中介-更新模式(首次)', fn: testPersonImportUpdateMode }, + { name: '场景2: 个人中介-仅新增(重复)', fn: testPersonImportInsertOnly }, + { name: '场景3: 实体中介-更新模式(首次)', fn: testEntityImportUpdateMode }, + { name: '场景4: 实体中介-仅新增(重复)', fn: testEntityImportInsertOnly }, + { name: '场景5: 个人中介-再次更新', fn: testPersonImportUpdateAgain } + ]; + + for (const test of tests) { + try { + const passed = await test.fn(); + if (passed) { + results.passed++; + } else { + results.failed++; + } + await sleep(2000); // 测试之间间隔 + } catch (error) { + logError(`${test.name} 执行异常: ${error.message}`); + results.failed++; + } + } + + // 输出测试结果摘要 + const duration = ((Date.now() - startTime) / 1000).toFixed(2); + console.log('\n' + '='.repeat(60)); + log('测试结果摘要', 'yellow'); + console.log('='.repeat(60)); + logSuccess(`通过: ${results.passed}/${tests.length}`); + if (results.failed > 0) { + logError(`失败: ${results.failed}/${tests.length}`); + } + logInfo(`总耗时: ${duration}秒`); + console.log('='.repeat(60) + '\n'); +} + +// 运行测试 +runTests().catch(error => { + logError(`测试运行失败: ${error.message}`); + console.error(error); + process.exit(1); +});