feat: 支持用户导入按部门层级路径匹配

- 按独立顶级分支构建路径并拒绝重名与异常层级

- 兼容旧版部门名称表头并更新导入模板

- 补充多根路径与模板歧义场景测试
This commit is contained in:
2026-08-03 17:43:47 +08:00
parent 7bcaf69fc0
commit 6544cdcff0
2 changed files with 672 additions and 15 deletions

View File

@@ -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<String> 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<SysAccount> 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<SysAccount> 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<List<String>> importHeaders(String departmentHead) {
return List.of(
List.of(departmentHead),
List.of("登录账号*"),
List.of("昵称*"),
List.of("角色名称*")
);
}
/**
* 创建具备成功导入所需依赖的账号服务。
*
* @param departments 可供匹配的部门集合
* @return 测试账号服务
* @throws Exception 注入测试依赖失败
*/
private SysAccountServiceImpl createReadyImportService(List<SysDept> 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 父部门 ID0 表示顶级部门
* @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;
}
/**
* 创建账号导入测试工作簿。
*