From 9be9bd7665be77d097d95533105c0828c6ea9297 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Thu, 13 Aug 2026 22:30:45 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BC=98=E5=8C=96=E9=A1=B6=E7=BA=A7?= =?UTF-8?q?=E9=83=A8=E9=97=A8=E5=AD=98=E5=9C=A8=E5=92=8C=E5=AD=90=E9=83=A8?= =?UTF-8?q?=E9=97=A8=E9=87=8D=E5=A4=8D=E6=97=B6=EF=BC=8C=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E9=94=99=E8=AF=AF=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/SysAccountServiceImpl.java | 164 ++++++++++++------ .../impl/SysAccountServiceImplTest.java | 122 +++++++++++-- .../src/locales/langs/en-US/sysAccount.json | 6 +- .../src/locales/langs/zh-CN/sysAccount.json | 6 +- 4 files changed, 222 insertions(+), 76 deletions(-) diff --git a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java index 1bd3971c..0c841a55 100644 --- a/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java +++ b/easyflow-modules/easyflow-module-system/src/main/java/tech/easyflow/system/service/impl/SysAccountServiceImpl.java @@ -12,8 +12,8 @@ import cn.idev.excel.write.metadata.WriteSheet; import com.mybatisflex.core.query.QueryWrapper; import com.mybatisflex.spring.service.impl.ServiceImpl; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.multipart.MultipartFile; import tech.easyflow.common.cache.RedisLockExecutor; @@ -23,23 +23,9 @@ import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.util.StringUtil; import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.config.AccountSecurityProperties; -import tech.easyflow.system.entity.SysAccount; -import tech.easyflow.system.entity.SysAccountPosition; -import tech.easyflow.system.entity.SysAccountRole; -import tech.easyflow.system.entity.SysDept; -import tech.easyflow.system.entity.SysPosition; -import tech.easyflow.system.entity.SysRole; -import tech.easyflow.system.entity.vo.SysAccountBatchActionErrorItemVo; -import tech.easyflow.system.entity.vo.SysAccountBatchActionResultVo; -import tech.easyflow.system.entity.vo.SysAccountImportErrorDetailVo; -import tech.easyflow.system.entity.vo.SysAccountImportErrorRowVo; -import tech.easyflow.system.entity.vo.SysAccountImportResultVo; -import tech.easyflow.system.mapper.SysAccountMapper; -import tech.easyflow.system.mapper.SysAccountPositionMapper; -import tech.easyflow.system.mapper.SysAccountRoleMapper; -import tech.easyflow.system.mapper.SysDeptMapper; -import tech.easyflow.system.mapper.SysPositionMapper; -import tech.easyflow.system.mapper.SysRoleMapper; +import tech.easyflow.system.entity.*; +import tech.easyflow.system.entity.vo.*; +import tech.easyflow.system.mapper.*; import tech.easyflow.system.service.SysAccountService; import javax.annotation.Resource; @@ -47,17 +33,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.math.BigInteger; import java.time.Duration; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Date; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Set; +import java.util.*; import java.util.function.Function; /** @@ -74,6 +50,8 @@ public class SysAccountServiceImpl extends ServiceImpl roleIds = resolveResourceIds( roleNames, @@ -592,6 +575,7 @@ public class SysAccountServiceImpl extends ServiceImpl validDepartmentIds = new LinkedHashSet<>(); Map uniquePathMap = new HashMap<>(departmentById.size()); Set duplicatePaths = new LinkedHashSet<>(); + Map> pathSetByName = new HashMap<>(departmentById.size()); for (SysDept department : departmentById.values()) { String path = resolveDepartmentPath( department, @@ -603,6 +587,10 @@ public class SysAccountServiceImpl extends ServiceImpl new LinkedHashSet<>() + ).add(path); if (uniquePathMap.containsKey(path)) { duplicatePaths.add(path); uniquePathMap.remove(path); @@ -612,11 +600,18 @@ public class SysAccountServiceImpl extends ServiceImpl> pathsByName = new HashMap<>(pathSetByName.size()); + pathSetByName.forEach((name, paths) -> { + List sortedPaths = new ArrayList<>(paths); + Collections.sort(sortedPaths); + pathsByName.put(name, sortedPaths); + }); return new DepartmentImportLookup( uniquePathMap, duplicatePaths, validDepartmentIds, - buildImportNameLookup(new ArrayList<>(departmentById.values()), SysDept::getDeptName) + buildImportNameLookup(new ArrayList<>(departmentById.values()), SysDept::getDeptName), + pathsByName ); } @@ -691,74 +686,104 @@ public class SysAccountServiceImpl extends ServiceImpl details) { String path = trimToNull(rawPath); if (path == null) { return null; } - if (!path.contains("/")) { - ImportNameLookup nameLookup = lookup.getNameLookup(); - if (nameLookup.getDuplicateNames().contains(path)) { + boolean explicitPath = path.contains("/"); + if (departmentPathHeader || explicitPath) { + String normalizedPath = normalizeDepartmentPath(rawPath, details); + if (normalizedPath == null) { + return null; + } + if (lookup.getDuplicatePaths().contains(normalizedPath)) { addImportDetail( details, IMPORT_FIELD_DEPT_PATH, rawPath, - "部门名称存在重名,请填写从所属分支顶级部门开始的完整路径" + "部门完整路径存在重名,请先在部门管理中处理" ); return null; } - SysDept department = nameLookup.getUniqueMap().get(path); - if (department == null) { - addImportDetail(details, IMPORT_FIELD_DEPT_PATH, rawPath, "部门不存在"); - return null; + SysDept pathDepartment = lookup.getUniquePathMap().get(normalizedPath); + if (pathDepartment != null) { + return pathDepartment; } - if (!lookup.getValidDepartmentIds().contains(department.getId())) { + if (explicitPath) { addImportDetail( details, IMPORT_FIELD_DEPT_PATH, rawPath, - "部门层级关系异常,请先在部门管理中处理" + "部门路径不存在或层级关系异常,请从所属分支的顶级部门开始填写" ); return null; } - return department; } - String normalizedPath = normalizeDepartmentPath(rawPath, details); - if (normalizedPath == null) { - return null; - } - if (lookup.getDuplicatePaths().contains(normalizedPath)) { + ImportNameLookup nameLookup = lookup.getNameLookup(); + if (nameLookup.getDuplicateNames().contains(path)) { addImportDetail( details, IMPORT_FIELD_DEPT_PATH, rawPath, - "部门完整路径存在重名,请先在部门管理中处理" + buildDuplicatedDepartmentNameReason(path, lookup) ); return null; } - SysDept department = lookup.getUniquePathMap().get(normalizedPath); + SysDept department = nameLookup.getUniqueMap().get(path); if (department == null) { + addImportDetail(details, IMPORT_FIELD_DEPT_PATH, rawPath, "部门不存在"); + return null; + } + if (!lookup.getValidDepartmentIds().contains(department.getId())) { addImportDetail( details, IMPORT_FIELD_DEPT_PATH, rawPath, - "部门路径不存在或层级关系异常,请从所属分支的顶级部门开始填写" + "部门层级关系异常,请先在部门管理中处理" ); + return null; } return department; } + /** + * 构建部门名称重名时的完整路径提示。 + * + * @param departmentName 重复的部门名称 + * @param lookup 部门查找表 + * @return 包含有效候选路径的错误原因 + */ + private String buildDuplicatedDepartmentNameReason( + String departmentName, + DepartmentImportLookup lookup) { + String reason = "部门名称存在重名,请填写从所属分支顶级部门开始的完整路径"; + List candidatePaths = lookup.getPathsByName().get(departmentName); + if (candidatePaths == null || candidatePaths.size() < 2) { + return reason; + } + int visibleCount = Math.min(candidatePaths.size(), MAX_IMPORT_DEPARTMENT_CANDIDATE_PATHS); + String visiblePaths = String.join("、", candidatePaths.subList(0, visibleCount)); + if (visibleCount == candidatePaths.size()) { + return reason + ":" + visiblePaths; + } + return reason + ":" + visiblePaths + + "(共" + candidatePaths.size() + "个候选部门,仅展示前" + visibleCount + "个)"; + } + /** * 规范化部门路径的各级名称。 * @@ -922,7 +947,7 @@ public class SysAccountServiceImpl extends ServiceImpl> rows = new ArrayList<>(); rows.add(List.of( "填写规则", - "多级部门从所属分支的顶级部门开始填写,使用英文/分隔,例如:技术部/研发;唯一部门名称仍兼容" + "部门优先填写从所属分支顶级部门开始的完整路径,使用英文/分隔,例如:石家庄分行/交易银行部;顶级部门直接填写名称,唯一部门名称仍兼容" )); rows.add(List.of("必填字段", "部门路径*、登录账号*、昵称*、角色名称*")); rows.add(List.of("可选字段", "手机号、邮箱、状态、岗位名称、备注")); @@ -979,6 +1004,7 @@ public class SysAccountServiceImpl extends ServiceImpl duplicatePaths; private final Set validDepartmentIds; private final ImportNameLookup nameLookup; + private final Map> pathsByName; /** * 创建部门导入查找表。 @@ -1199,16 +1245,19 @@ public class SysAccountServiceImpl extends ServiceImpl uniquePathMap, Set duplicatePaths, Set validDepartmentIds, - ImportNameLookup nameLookup) { + ImportNameLookup nameLookup, + Map> pathsByName) { this.uniquePathMap = uniquePathMap; this.duplicatePaths = duplicatePaths; this.validDepartmentIds = validDepartmentIds; this.nameLookup = nameLookup; + this.pathsByName = pathsByName; } /** @@ -1246,6 +1295,15 @@ public class SysAccountServiceImpl extends ServiceImpl getNameLookup() { return nameLookup; } + + /** + * 获取部门名称对应的有效完整路径列表。 + * + * @return 名称到完整路径列表的映射 + */ + public Map> getPathsByName() { + return pathsByName; + } } private static class ImportNameLookup { diff --git a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysAccountServiceImplTest.java b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysAccountServiceImplTest.java index 3c5648eb..faa7567f 100644 --- a/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysAccountServiceImplTest.java +++ b/easyflow-modules/easyflow-module-system/src/test/java/tech/easyflow/system/service/impl/SysAccountServiceImplTest.java @@ -17,7 +17,6 @@ import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.TransactionDefinition; import org.springframework.transaction.TransactionStatus; import org.springframework.web.multipart.MultipartFile; - import tech.easyflow.common.constant.enums.EnumAccountType; import tech.easyflow.common.entity.LoginAccount; import tech.easyflow.common.web.exceptions.BusinessException; @@ -40,19 +39,9 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertThrows; -import static org.junit.Assert.assertTrue; +import static org.junit.Assert.*; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.mockStatic; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.spy; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; /** * {@link SysAccountServiceImpl} 测试。 @@ -174,7 +163,8 @@ public class SysAccountServiceImplTest { List.of("技术部/研发", "tech-research", "技术研发", "普通员工"), List.of(" 产品部 / 研发 ", "product-research", "产品研发", "普通员工"), List.of("技术部/交付", "delivery", "交付人员", "普通员工"), - List.of("总公司", "head-office", "总部人员", "普通员工") + List.of("总公司", "head-office", "总部人员", "普通员工"), + List.of("交付", "delivery-short", "唯一名称兼容", "普通员工") ) ); @@ -183,21 +173,88 @@ public class SysAccountServiceImplTest { importLoginAccount() ); - assertEquals(4, result.getSuccessCount()); + assertEquals(5, result.getSuccessCount()); assertEquals(0, result.getErrorCount()); ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(SysAccount.class); - verify(service, times(4)).save(accountCaptor.capture()); + verify(service, times(5)).save(accountCaptor.capture()); assertEquals( List.of( BigInteger.valueOf(3), BigInteger.valueOf(6), BigInteger.valueOf(4), - BigInteger.ONE + BigInteger.ONE, + BigInteger.valueOf(4) ), accountCaptor.getAllValues().stream().map(SysAccount::getDeptId).toList() ); } + /** + * 验证当前模板优先把单段内容解析为顶级部门完整路径。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldPreferTopLevelPathWhenCurrentTemplateHasDuplicatedDepartmentName() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 0, "交易银行部"), + buildDept(2, 0, "石家庄分行"), + buildDept(3, 2, "交易银行部"), + buildDept(4, 0, "济南分行"), + buildDept(5, 4, "交易银行部") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门路径*"), + List.of(List.of(" 交易银行部 ", "head-office-trade", "总行交易银行", "普通员工")) + ); + + SysAccountImportResultVo result = service.importAccounts( + mockMultipartFile(workbook), + importLoginAccount() + ); + + assertEquals(1, result.getSuccessCount()); + assertEquals(0, result.getErrorCount()); + ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(SysAccount.class); + verify(service).save(accountCaptor.capture()); + assertEquals(BigInteger.ONE, accountCaptor.getValue().getDeptId()); + } + + /** + * 验证当前模板无法按完整路径命中重名子部门时返回可填写的候选路径。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldListCandidatePathsForDuplicatedDepartmentName() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 0, "石家庄分行"), + buildDept(2, 1, "交易银行部"), + buildDept(3, 0, "济南分行"), + buildDept(4, 3, "交易银行部") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门路径*"), + List.of(List.of("交易银行部", "ambiguous-trade", "重名交易银行", "普通员工")) + ); + + SysAccountImportResultVo result = service.importAccounts( + mockMultipartFile(workbook), + importLoginAccount() + ); + + assertEquals(0, result.getSuccessCount()); + assertEquals(1, result.getErrorCount()); + String reason = result.getErrorRows().get(0).getDetails().stream() + .filter(detail -> "部门路径".equals(detail.getFieldName())) + .findFirst() + .orElseThrow() + .getReason(); + assertTrue(reason.contains("石家庄分行/交易银行部")); + assertTrue(reason.contains("济南分行/交易银行部")); + verify(service, never()).save(any(SysAccount.class)); + } + /** * 验证错误地把并列顶级部门拼到总公司之后时不会发生误匹配。 * @@ -266,6 +323,37 @@ public class SysAccountServiceImplTest { assertEquals(BigInteger.valueOf(5), accountCaptor.getValue().getDeptId()); } + /** + * 验证旧部门名称表头不会把重名顶级部门静默解释为完整路径。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldKeepLegacyNameAmbiguityForDuplicatedTopLevelDepartment() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 0, "交易银行部"), + buildDept(2, 0, "石家庄分行"), + buildDept(3, 2, "交易银行部") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门名称*"), + List.of(List.of("交易银行部", "legacy-trade", "旧模板交易银行", "普通员工")) + ); + + SysAccountImportResultVo result = service.importAccounts( + mockMultipartFile(workbook), + importLoginAccount() + ); + + assertEquals(0, result.getSuccessCount()); + assertEquals(1, result.getErrorCount()); + assertTrue(result.getErrorRows().get(0).getDetails().stream().anyMatch(detail -> + detail.getReason().contains("交易银行部") + && detail.getReason().contains("石家庄分行/交易银行部") + )); + verify(service, never()).save(any(SysAccount.class)); + } + /** * 验证同一父部门下的同名子部门不会被完整路径静默匹配到任意一条记录。 * diff --git a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json index d99031ab..520e0647 100644 --- a/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/en-US/sysAccount.json @@ -52,7 +52,7 @@ "batchResetPasswordAllFailed": "Batch password reset failed", "importTitle": "Import Users", "importUploadTitle": "Drag the Excel file here, or click to select a file", - "importUploadDesc": "Only .xlsx / .xls files are supported. Fill department, role, and position by name. Headers marked with * are required.", + "importUploadDesc": "Only .xlsx / .xls files are supported. Prefer the full department path; enter roles and positions by name. Headers marked with * are required.", "importSelectFileRequired": "Please select a file to import", "downloadTemplate": "Download Template", "importFinished": "User import completed", @@ -68,7 +68,7 @@ "importFieldValue": "Value", "importReason": "Reason", "importGuideTitle": "Instructions", - "importGuideNameRule": "Enter department, role, and position by name.", - "importGuideRequired": "Only department name, login name, and nickname are required. Position, mobile, and email are optional.", + "importGuideNameRule": "Enter the full department path and separate levels with /. Enter a top-level department by name.", + "importGuideRequired": "Department path, login name, nickname, and role name are required. Position, mobile, and email are optional.", "importGuideMultiValue": "Role and position accept multiple names separated by commas." } diff --git a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json index 47c2649c..32f0537e 100644 --- a/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json +++ b/easyflow-ui-admin/app/src/locales/langs/zh-CN/sysAccount.json @@ -53,7 +53,7 @@ "batchResetPasswordAllFailed": "批量重置密码失败", "importTitle": "导入用户", "importUploadTitle": "拖拽 Excel 文件到此处,或点击选择文件", - "importUploadDesc": "仅支持 .xlsx / .xls。请按名称填写部门、角色、岗位,模板中的 * 为必填项。", + "importUploadDesc": "仅支持 .xlsx / .xls。部门优先填写完整路径,角色、岗位填写名称,模板中的 * 为必填项。", "importSelectFileRequired": "请先选择要导入的文件", "downloadTemplate": "下载导入模板", "importFinished": "用户导入完成", @@ -69,7 +69,7 @@ "importFieldValue": "填写内容", "importReason": "失败原因", "importGuideTitle": "填写说明", - "importGuideNameRule": "部门、角色、岗位都填写名称。", - "importGuideRequired": "仅部门名称、登录账号、昵称为必填;岗位、手机号、邮箱为非必填。", + "importGuideNameRule": "部门按完整路径填写,多级部门使用英文 / 分隔;顶级部门直接填写名称。", + "importGuideRequired": "部门路径、登录账号、昵称、角色名称为必填;岗位、手机号、邮箱为非必填。", "importGuideMultiValue": "角色和岗位支持多个名称,使用英文逗号或中文逗号分隔。" }