From 6544cdcff0543393c5816c67665f437c24ac22cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com> Date: Mon, 3 Aug 2026 17:43:47 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E6=8C=89=E9=83=A8=E9=97=A8=E5=B1=82=E7=BA=A7?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按独立顶级分支构建路径并拒绝重名与异常层级 - 兼容旧版部门名称表头并更新导入模板 - 补充多根路径与模板歧义场景测试 --- .../service/impl/SysAccountServiceImpl.java | 332 +++++++++++++++- .../impl/SysAccountServiceImplTest.java | 355 ++++++++++++++++++ 2 files changed, 672 insertions(+), 15 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 67fc3f16..1bd3971c 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 @@ -74,7 +74,9 @@ public class SysAccountServiceImpl extends ServiceImpl deptLookup = buildImportNameLookup( - sysDeptMapper.selectListByQuery(QueryWrapper.create()), - SysDept::getDeptName + QueryWrapper deptQuery = QueryWrapper.create() + .eq(SysDept::getTenantId, loginAccount.getTenantId()); + DepartmentImportLookup deptLookup = buildDepartmentImportLookup( + sysDeptMapper.selectListByQuery(deptQuery) ); ImportNameLookup roleLookup = buildImportNameLookup( sysRoleMapper.selectListByQuery( @@ -383,7 +386,7 @@ public class SysAccountServiceImpl extends ServiceImpl deptLookup, + DepartmentImportLookup deptLookup, ImportNameLookup roleLookup, ImportNameLookup positionLookup) { List details = new ArrayList<>(); @@ -395,7 +398,7 @@ public class SysAccountServiceImpl extends ServiceImpl roleNames = splitCodes(row.getRoleNames()); if (deptName == null) { - addImportDetail(details, IMPORT_FIELD_DEPT_NAME, row.getDeptName(), "部门名称不能为空"); + addImportDetail(details, IMPORT_FIELD_DEPT_PATH, row.getDeptName(), "部门路径不能为空"); } if (loginName == null) { addImportDetail(details, IMPORT_FIELD_LOGIN_NAME, row.getLoginName(), "登录账号不能为空"); @@ -415,7 +418,7 @@ public class SysAccountServiceImpl extends ServiceImpl roleIds = resolveResourceIds( roleNames, @@ -566,6 +569,224 @@ public class SysAccountServiceImpl extends ServiceImpl(uniqueMap, duplicateNames); } + /** + * 构建账号导入使用的部门名称与完整路径查找表。 + * + *

每个 {@code parentId=0} 的部门都作为独立顶级分支,路径只从当前分支的顶级部门开始。 + * 构建过程仅使用直接父级关系,并对断链和循环层级进行隔离,避免生成错误路径。

+ * + * @param departments 当前租户的部门集合 + * @return 部门导入查找表 + */ + private DepartmentImportLookup buildDepartmentImportLookup(List departments) { + List safeDepartments = departments == null ? Collections.emptyList() : departments; + Map departmentById = new HashMap<>(safeDepartments.size()); + for (SysDept department : safeDepartments) { + if (department != null && department.getId() != null) { + departmentById.put(department.getId(), department); + } + } + + Map pathById = new HashMap<>(departmentById.size()); + Set invalidDepartmentIds = new LinkedHashSet<>(); + Set validDepartmentIds = new LinkedHashSet<>(); + Map uniquePathMap = new HashMap<>(departmentById.size()); + Set duplicatePaths = new LinkedHashSet<>(); + for (SysDept department : departmentById.values()) { + String path = resolveDepartmentPath( + department, + departmentById, + pathById, + invalidDepartmentIds + ); + if (path == null) { + continue; + } + validDepartmentIds.add(department.getId()); + if (uniquePathMap.containsKey(path)) { + duplicatePaths.add(path); + uniquePathMap.remove(path); + continue; + } + if (!duplicatePaths.contains(path)) { + uniquePathMap.put(path, department); + } + } + return new DepartmentImportLookup( + uniquePathMap, + duplicatePaths, + validDepartmentIds, + buildImportNameLookup(new ArrayList<>(departmentById.values()), SysDept::getDeptName) + ); + } + + /** + * 沿直接父级关系解析单个部门的完整路径。 + * + * @param department 当前部门 + * @param departmentById 部门 ID 索引 + * @param pathById 已解析路径缓存 + * @param invalidDepartmentIds 层级异常的部门 ID + * @return 从所属顶级部门开始的完整路径;层级异常时返回 {@code null} + */ + private String resolveDepartmentPath( + SysDept department, + Map departmentById, + Map pathById, + Set invalidDepartmentIds) { + BigInteger departmentId = department.getId(); + if (departmentId == null || invalidDepartmentIds.contains(departmentId)) { + return null; + } + String cachedPath = pathById.get(departmentId); + if (cachedPath != null) { + return cachedPath; + } + + List unresolvedChain = new ArrayList<>(); + Set visitingIds = new LinkedHashSet<>(); + SysDept current = department; + String parentPath = null; + while (current != null) { + BigInteger currentId = current.getId(); + String currentName = trimToNull(current.getDeptName()); + if (currentId == null + || invalidDepartmentIds.contains(currentId) + || !visitingIds.add(currentId) + || currentName == null + || currentName.contains("/")) { + invalidDepartmentIds.addAll(visitingIds); + return null; + } + + String currentCachedPath = pathById.get(currentId); + if (currentCachedPath != null) { + parentPath = currentCachedPath; + break; + } + unresolvedChain.add(current); + + BigInteger parentId = current.getParentId(); + if (parentId == null) { + invalidDepartmentIds.addAll(visitingIds); + return null; + } + if (BigInteger.ZERO.equals(parentId)) { + break; + } + current = departmentById.get(parentId); + if (current == null) { + invalidDepartmentIds.addAll(visitingIds); + return null; + } + } + + for (int index = unresolvedChain.size() - 1; index >= 0; index--) { + SysDept item = unresolvedChain.get(index); + String itemName = trimToNull(item.getDeptName()); + parentPath = parentPath == null ? itemName : parentPath + "/" + itemName; + pathById.put(item.getId(), parentPath); + } + return pathById.get(departmentId); + } + + /** + * 按完整路径或兼容的唯一部门名称解析导入部门。 + * + * @param rawPath Excel 中填写的部门路径 + * @param lookup 部门查找表 + * @param details 错误明细收集器 + * @return 匹配到的部门;校验失败时返回 {@code null} + */ + private SysDept resolveDepartment( + String rawPath, + DepartmentImportLookup lookup, + List details) { + String path = trimToNull(rawPath); + if (path == null) { + return null; + } + if (!path.contains("/")) { + ImportNameLookup nameLookup = lookup.getNameLookup(); + if (nameLookup.getDuplicateNames().contains(path)) { + 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; + } + if (!lookup.getValidDepartmentIds().contains(department.getId())) { + 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)) { + addImportDetail( + details, + IMPORT_FIELD_DEPT_PATH, + rawPath, + "部门完整路径存在重名,请先在部门管理中处理" + ); + return null; + } + SysDept department = lookup.getUniquePathMap().get(normalizedPath); + if (department == null) { + addImportDetail( + details, + IMPORT_FIELD_DEPT_PATH, + rawPath, + "部门路径不存在或层级关系异常,请从所属分支的顶级部门开始填写" + ); + } + return department; + } + + /** + * 规范化部门路径的各级名称。 + * + * @param rawPath 原始部门路径 + * @param details 错误明细收集器 + * @return 使用英文斜杠连接的规范路径;格式错误时返回 {@code null} + */ + private String normalizeDepartmentPath( + String rawPath, + List details) { + String[] segments = rawPath.trim().split("/", -1); + List normalizedSegments = new ArrayList<>(segments.length); + for (String segment : segments) { + String normalizedSegment = trimToNull(segment); + if (normalizedSegment == null) { + addImportDetail( + details, + IMPORT_FIELD_DEPT_PATH, + rawPath, + "部门路径格式不正确,层级名称不能为空且必须使用英文 / 分隔" + ); + return null; + } + normalizedSegments.add(normalizedSegment); + } + return String.join("/", normalizedSegments); + } + private List parseImportRows(MultipartFile file) { try (InputStream inputStream = file.getInputStream()) { SysAccountExcelReadListener listener = new SysAccountExcelReadListener(); @@ -678,7 +899,7 @@ public class SysAccountServiceImpl extends ServiceImpl> buildImportHeadList() { List> headList = new ArrayList<>(9); - headList.add(Collections.singletonList(IMPORT_HEAD_DEPT_NAME)); + headList.add(Collections.singletonList(IMPORT_HEAD_DEPT_PATH)); headList.add(Collections.singletonList(IMPORT_HEAD_LOGIN_NAME)); headList.add(Collections.singletonList(IMPORT_HEAD_NICKNAME)); headList.add(Collections.singletonList(IMPORT_HEAD_MOBILE)); @@ -699,8 +920,11 @@ public class SysAccountServiceImpl extends ServiceImpl> buildImportGuideRows() { List> rows = new ArrayList<>(); - rows.add(List.of("填写规则", "请按名称填写部门、角色、岗位。")); - rows.add(List.of("必填字段", "部门名称*、登录账号*、昵称*、角色名称*")); + rows.add(List.of( + "填写规则", + "多级部门从所属分支的顶级部门开始填写,使用英文/分隔,例如:技术部/研发;唯一部门名称仍兼容" + )); + rows.add(List.of("必填字段", "部门路径*、登录账号*、昵称*、角色名称*")); rows.add(List.of("可选字段", "手机号、邮箱、状态、岗位名称、备注")); rows.add(List.of("状态可选值", "可留空,或填写 1/0/已启用/启用/未启用/停用/禁用")); rows.add(List.of("多值分隔", "角色名称、岗位名称支持使用英文逗号,或中文逗号,分隔多个名称")); @@ -709,7 +933,7 @@ public class SysAccountServiceImpl extends ServiceImpl data, AnalysisContext context) { sheetRowNo++; - String deptName = getCellValue(data, IMPORT_HEAD_DEPT_NAME); + String deptName = getCellValue(data, IMPORT_HEAD_DEPT_PATH, IMPORT_HEAD_DEPT_NAME_LEGACY); String loginName = getCellValue(data, IMPORT_HEAD_LOGIN_NAME); String nickname = getCellValue(data, IMPORT_HEAD_NICKNAME); String mobile = getCellValue(data, IMPORT_HEAD_MOBILE); @@ -897,16 +1121,29 @@ public class SysAccountServiceImpl extends ServiceImpl requiredHeads = List.of( - IMPORT_HEAD_DEPT_NAME, + IMPORT_HEAD_DEPT_PATH, IMPORT_HEAD_LOGIN_NAME, IMPORT_HEAD_NICKNAME, IMPORT_HEAD_ROLE_NAMES ); for (String requiredHead : requiredHeads) { + if (IMPORT_HEAD_DEPT_PATH.equals(requiredHead) && hasLegacyDeptNameHead) { + continue; + } if (IMPORT_HEAD_ROLE_NAMES.equals(requiredHead) && headIndex.containsKey(IMPORT_HEAD_ROLE_NAMES_LEGACY)) { continue; @@ -946,6 +1183,71 @@ public class SysAccountServiceImpl extends ServiceImpl uniquePathMap; + private final Set duplicatePaths; + private final Set validDepartmentIds; + private final ImportNameLookup nameLookup; + + /** + * 创建部门导入查找表。 + * + * @param uniquePathMap 唯一完整路径映射 + * @param duplicatePaths 重复完整路径集合 + * @param validDepartmentIds 层级关系有效的部门 ID 集合 + * @param nameLookup 兼容名称查找表 + */ + private DepartmentImportLookup( + Map uniquePathMap, + Set duplicatePaths, + Set validDepartmentIds, + ImportNameLookup nameLookup) { + this.uniquePathMap = uniquePathMap; + this.duplicatePaths = duplicatePaths; + this.validDepartmentIds = validDepartmentIds; + this.nameLookup = nameLookup; + } + + /** + * 获取唯一完整路径映射。 + * + * @return 唯一完整路径映射 + */ + public Map getUniquePathMap() { + return uniquePathMap; + } + + /** + * 获取重复完整路径集合。 + * + * @return 重复完整路径集合 + */ + public Set getDuplicatePaths() { + return duplicatePaths; + } + + /** + * 获取层级关系有效的部门 ID 集合。 + * + * @return 有效部门 ID 集合 + */ + public Set getValidDepartmentIds() { + return validDepartmentIds; + } + + /** + * 获取兼容名称查找表。 + * + * @return 名称查找表 + */ + public ImportNameLookup getNameLookup() { + return nameLookup; + } + } + private static class ImportNameLookup { private final Map uniqueMap; private final Set duplicateNames; 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 03bab512..3c5648eb 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 @@ -24,6 +24,7 @@ import tech.easyflow.common.web.exceptions.BusinessException; import tech.easyflow.system.config.AccountSecurityProperties; import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysDept; +import tech.easyflow.system.entity.SysRole; import tech.easyflow.system.entity.vo.SysAccountImportErrorDetailVo; import tech.easyflow.system.entity.vo.SysAccountImportResultVo; import tech.easyflow.system.mapper.SysDeptMapper; @@ -44,6 +45,7 @@ import static org.junit.Assert.assertThrows; import static org.junit.Assert.assertTrue; 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; @@ -140,9 +142,227 @@ public class SysAccountServiceImplTest { service.writeImportTemplate(outputStream); List headers = readFirstSheetHeaders(outputStream.toByteArray()); + assertTrue(headers.contains("部门路径*")); assertTrue(headers.contains("角色名称*")); } + /** + * 验证多个顶级部门分别作为路径起点,且不会把总公司错误拼接到其他顶级分支。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldResolveDepartmentPathFromItsOwnTopLevelBranch() throws Exception { + SysDept rootCompany = buildDept(1, 0, "总公司"); + SysDept technology = buildDept(2, 0, "技术部"); + SysDept technologyResearch = buildDept(3, 2, "研发"); + SysDept technologyDelivery = buildDept(4, 2, "交付"); + SysDept product = buildDept(5, 0, "产品部"); + SysDept productResearch = buildDept(6, 5, "研发"); + SysAccountServiceImpl service = createReadyImportService(List.of( + rootCompany, + technology, + technologyResearch, + technologyDelivery, + product, + productResearch + )); + + byte[] workbook = createImportWorkbook( + importHeaders("部门路径*"), + List.of( + List.of("技术部/研发", "tech-research", "技术研发", "普通员工"), + List.of(" 产品部 / 研发 ", "product-research", "产品研发", "普通员工"), + List.of("技术部/交付", "delivery", "交付人员", "普通员工"), + List.of("总公司", "head-office", "总部人员", "普通员工") + ) + ); + + SysAccountImportResultVo result = service.importAccounts( + mockMultipartFile(workbook), + importLoginAccount() + ); + + assertEquals(4, result.getSuccessCount()); + assertEquals(0, result.getErrorCount()); + ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(SysAccount.class); + verify(service, times(4)).save(accountCaptor.capture()); + assertEquals( + List.of( + BigInteger.valueOf(3), + BigInteger.valueOf(6), + BigInteger.valueOf(4), + BigInteger.ONE + ), + accountCaptor.getAllValues().stream().map(SysAccount::getDeptId).toList() + ); + } + + /** + * 验证错误地把并列顶级部门拼到总公司之后时不会发生误匹配。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldRejectPathThatCrossesTopLevelBranches() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 0, "总公司"), + buildDept(2, 0, "技术部"), + buildDept(3, 2, "研发") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门路径*"), + List.of(List.of("总公司/技术部/研发", "wrong-root", "错误根路径", "普通员工")) + ); + + SysAccountImportResultVo result = service.importAccounts( + mockMultipartFile(workbook), + importLoginAccount() + ); + + assertEquals(0, result.getSuccessCount()); + assertEquals(1, result.getErrorCount()); + assertTrue(result.getErrorRows().get(0).getDetails().stream().anyMatch(detail -> + "部门路径".equals(detail.getFieldName()) + && detail.getReason().contains("部门路径不存在") + )); + verify(service, never()).save(any(SysAccount.class)); + } + + /** + * 验证旧模板中的唯一部门名称继续兼容,重名叶子部门要求填写完整路径。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldKeepLegacyUniqueNameAndRejectDuplicatedName() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 0, "技术部"), + buildDept(2, 1, "研发"), + buildDept(3, 0, "产品部"), + buildDept(4, 3, "研发"), + buildDept(5, 1, "交付") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门名称*"), + List.of( + List.of("交付", "legacy-unique", "旧模板唯一名称", "普通员工"), + List.of("研发", "legacy-duplicate", "旧模板重名名称", "普通员工") + ) + ); + + SysAccountImportResultVo result = service.importAccounts( + mockMultipartFile(workbook), + importLoginAccount() + ); + + assertEquals(1, result.getSuccessCount()); + assertEquals(1, result.getErrorCount()); + assertTrue(result.getErrorRows().get(0).getDetails().stream().anyMatch(detail -> + detail.getReason().contains("请填写从所属分支顶级部门开始的完整路径") + )); + ArgumentCaptor accountCaptor = ArgumentCaptor.forClass(SysAccount.class); + verify(service).save(accountCaptor.capture()); + assertEquals(BigInteger.valueOf(5), accountCaptor.getValue().getDeptId()); + } + + /** + * 验证同一父部门下的同名子部门不会被完整路径静默匹配到任意一条记录。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldRejectDuplicatedFullDepartmentPath() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 0, "技术部"), + buildDept(2, 1, "研发"), + buildDept(3, 1, "研发") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门路径*"), + List.of(List.of("技术部/研发", "duplicate-path", "重复路径", "普通员工")) + ); + + 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("部门完整路径存在重名") + )); + verify(service, never()).save(any(SysAccount.class)); + } + + /** + * 验证空层级和循环部门关系均返回明确错误,且不会产生错误账号。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldRejectMalformedPathAndCyclicDepartmentHierarchy() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 2, "技术部"), + buildDept(2, 1, "研发") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门路径*"), + List.of( + List.of("技术部//研发", "malformed-path", "空层级", "普通员工"), + List.of("技术部/研发", "cyclic-path", "循环层级", "普通员工"), + List.of("技术部", "cyclic-name", "名称兼容入口", "普通员工") + ) + ); + + SysAccountImportResultVo result = service.importAccounts( + mockMultipartFile(workbook), + importLoginAccount() + ); + + assertEquals(0, result.getSuccessCount()); + assertEquals(3, result.getErrorCount()); + assertTrue(result.getErrorRows().get(0).getDetails().stream().anyMatch(detail -> + detail.getReason().contains("路径格式不正确") + )); + assertTrue(result.getErrorRows().get(1).getDetails().stream().anyMatch(detail -> + detail.getReason().contains("层级关系异常") + )); + assertTrue(result.getErrorRows().get(2).getDetails().stream().anyMatch(detail -> + detail.getReason().contains("层级关系异常") + )); + verify(service, never()).save(any(SysAccount.class)); + } + + /** + * 验证部门名称包含路径分隔符时拒绝匹配,避免名称与层级路径产生歧义。 + * + * @throws Exception 注入测试依赖或构造上传文件失败 + */ + @Test + public void shouldRejectDepartmentNameContainingPathSeparator() throws Exception { + SysAccountServiceImpl service = createReadyImportService(List.of( + buildDept(1, 0, "平台/中台") + )); + byte[] workbook = createImportWorkbook( + importHeaders("部门路径*"), + List.of(List.of("平台/中台", "separator-name", "分隔符名称", "普通员工")) + ); + + 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("层级关系异常") + )); + verify(service, never()).save(any(SysAccount.class)); + } + /** * 验证缺少角色列时直接返回模板表头错误。 * @@ -168,6 +388,60 @@ public class SysAccountServiceImplTest { assertTrue(exception.getMessage().contains("角色名称*")); } + /** + * 验证新旧部门表头同时存在时拒绝导入,避免列值来源不明确。 + * + * @throws Exception 构造上传文件失败 + */ + @Test + public void shouldRejectImportTemplateWithBothDepartmentHeaders() throws Exception { + SysAccountServiceImpl service = new SysAccountServiceImpl(); + byte[] workbook = createImportWorkbook( + List.of( + List.of("部门路径*"), + List.of("部门名称*"), + List.of("登录账号*"), + List.of("昵称*"), + List.of("角色名称*") + ), + List.of(List.of("技术部/研发", "研发", "ambiguous-dept", "歧义部门列", "普通员工")) + ); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.importAccounts(mockMultipartFile(workbook), new LoginAccount()) + ); + + assertTrue(exception.getMessage().contains("只能保留一个")); + } + + /** + * 验证重复部门路径表头会被拒绝,避免导入时静默选取错误列。 + * + * @throws Exception 构造上传文件失败 + */ + @Test + public void shouldRejectImportTemplateWithDuplicatedDepartmentHeader() throws Exception { + SysAccountServiceImpl service = new SysAccountServiceImpl(); + byte[] workbook = createImportWorkbook( + List.of( + List.of("部门路径*"), + List.of("部门路径*"), + List.of("登录账号*"), + List.of("昵称*"), + List.of("角色名称*") + ), + List.of(List.of("技术部/研发", "产品部/研发", "duplicate-head", "重复部门列", "普通员工")) + ); + + BusinessException exception = assertThrows( + BusinessException.class, + () -> service.importAccounts(mockMultipartFile(workbook), new LoginAccount()) + ); + + assertTrue(exception.getMessage().contains("部门列不能重复")); + } + /** * 验证角色为空或仅包含分隔符时返回明确的行级必填错误。 * @@ -227,6 +501,87 @@ public class SysAccountServiceImplTest { verify(service, never()).save(any(SysAccount.class)); } + /** + * 构造标准账号导入表头。 + * + * @param departmentHead 部门列名称 + * @return 导入表头 + */ + private List> importHeaders(String departmentHead) { + return List.of( + List.of(departmentHead), + List.of("登录账号*"), + List.of("昵称*"), + List.of("角色名称*") + ); + } + + /** + * 创建具备成功导入所需依赖的账号服务。 + * + * @param departments 可供匹配的部门集合 + * @return 测试账号服务 + * @throws Exception 注入测试依赖失败 + */ + private SysAccountServiceImpl createReadyImportService(List departments) throws Exception { + SysAccountServiceImpl service = spy(new SysAccountServiceImpl()); + SysDeptMapper deptMapper = mock(SysDeptMapper.class); + SysRoleMapper roleMapper = mock(SysRoleMapper.class); + SysPositionMapper positionMapper = mock(SysPositionMapper.class); + PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class); + TransactionStatus transactionStatus = mock(TransactionStatus.class); + SysRole role = new SysRole(); + role.setId(BigInteger.valueOf(100)); + role.setRoleName("普通员工"); + + when(deptMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(departments); + when(roleMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(role)); + when(positionMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of()); + when(transactionManager.getTransaction(any(TransactionDefinition.class))).thenReturn(transactionStatus); + doReturn(0L).when(service).count(any(QueryWrapper.class)); + doReturn(true).when(service).save(any(SysAccount.class)); + doNothing().when(service).syncRelations(any(SysAccount.class)); + + AccountSecurityProperties properties = new AccountSecurityProperties(); + properties.setDefaultResetPassword("Import123!"); + properties.afterPropertiesSet(); + setField(service, "sysDeptMapper", deptMapper); + setField(service, "sysRoleMapper", roleMapper); + setField(service, "sysPositionMapper", positionMapper); + setField(service, "transactionManager", transactionManager); + setField(service, "accountSecurityProperties", properties); + return service; + } + + /** + * 构造部门测试数据。 + * + * @param id 部门 ID + * @param parentId 父部门 ID,0 表示顶级部门 + * @param name 部门名称 + * @return 部门实体 + */ + private SysDept buildDept(long id, long parentId, String name) { + SysDept department = new SysDept(); + department.setId(BigInteger.valueOf(id)); + department.setTenantId(BigInteger.valueOf(1000000)); + department.setParentId(BigInteger.valueOf(parentId)); + department.setDeptName(name); + return department; + } + + /** + * 构造账号导入登录上下文。 + * + * @return 登录账号 + */ + private LoginAccount importLoginAccount() { + LoginAccount account = new LoginAccount(); + account.setId(BigInteger.TEN); + account.setTenantId(BigInteger.valueOf(1000000)); + return account; + } + /** * 创建账号导入测试工作簿。 *