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

@@ -74,7 +74,9 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10); private static final Duration LOCK_LEASE_TIMEOUT = Duration.ofSeconds(10);
private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024 * 1024; private static final long MAX_IMPORT_FILE_SIZE_BYTES = 10L * 1024 * 1024;
private static final int MAX_IMPORT_ROWS = 5000; private static final int MAX_IMPORT_ROWS = 5000;
private static final String IMPORT_HEAD_DEPT_NAME = "部门名称*"; private static final String IMPORT_HEAD_DEPT_PATH = "部门路径*";
/** 兼容已下载的旧版用户导入模板表头。 */
private static final String IMPORT_HEAD_DEPT_NAME_LEGACY = "部门名称*";
private static final String IMPORT_HEAD_LOGIN_NAME = "登录账号*"; private static final String IMPORT_HEAD_LOGIN_NAME = "登录账号*";
private static final String IMPORT_HEAD_NICKNAME = "昵称*"; private static final String IMPORT_HEAD_NICKNAME = "昵称*";
private static final String IMPORT_HEAD_MOBILE = "手机号"; private static final String IMPORT_HEAD_MOBILE = "手机号";
@@ -87,7 +89,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private static final String IMPORT_HEAD_REMARK = "备注"; private static final String IMPORT_HEAD_REMARK = "备注";
private static final String IMPORT_GUIDE_HEAD_ITEM = "说明项"; private static final String IMPORT_GUIDE_HEAD_ITEM = "说明项";
private static final String IMPORT_GUIDE_HEAD_CONTENT = "内容"; private static final String IMPORT_GUIDE_HEAD_CONTENT = "内容";
private static final String IMPORT_FIELD_DEPT_NAME = "部门名称"; private static final String IMPORT_FIELD_DEPT_PATH = "部门路径";
private static final String IMPORT_FIELD_LOGIN_NAME = "登录账号"; private static final String IMPORT_FIELD_LOGIN_NAME = "登录账号";
private static final String IMPORT_FIELD_NICKNAME = "昵称"; private static final String IMPORT_FIELD_NICKNAME = "昵称";
private static final String IMPORT_FIELD_MOBILE = "手机号"; private static final String IMPORT_FIELD_MOBILE = "手机号";
@@ -325,9 +327,10 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
return result; return result;
} }
ImportNameLookup<SysDept> deptLookup = buildImportNameLookup( QueryWrapper deptQuery = QueryWrapper.create()
sysDeptMapper.selectListByQuery(QueryWrapper.create()), .eq(SysDept::getTenantId, loginAccount.getTenantId());
SysDept::getDeptName DepartmentImportLookup deptLookup = buildDepartmentImportLookup(
sysDeptMapper.selectListByQuery(deptQuery)
); );
ImportNameLookup<SysRole> roleLookup = buildImportNameLookup( ImportNameLookup<SysRole> roleLookup = buildImportNameLookup(
sysRoleMapper.selectListByQuery( sysRoleMapper.selectListByQuery(
@@ -383,7 +386,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private void importSingleRow(SysAccountImportRow row, private void importSingleRow(SysAccountImportRow row,
LoginAccount loginAccount, LoginAccount loginAccount,
ImportNameLookup<SysDept> deptLookup, DepartmentImportLookup deptLookup,
ImportNameLookup<SysRole> roleLookup, ImportNameLookup<SysRole> roleLookup,
ImportNameLookup<SysPosition> positionLookup) { ImportNameLookup<SysPosition> positionLookup) {
List<SysAccountImportErrorDetailVo> details = new ArrayList<>(); List<SysAccountImportErrorDetailVo> details = new ArrayList<>();
@@ -395,7 +398,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
List<String> roleNames = splitCodes(row.getRoleNames()); List<String> roleNames = splitCodes(row.getRoleNames());
if (deptName == null) { if (deptName == null) {
addImportDetail(details, IMPORT_FIELD_DEPT_NAME, row.getDeptName(), "部门名称不能为空"); addImportDetail(details, IMPORT_FIELD_DEPT_PATH, row.getDeptName(), "部门路径不能为空");
} }
if (loginName == null) { if (loginName == null) {
addImportDetail(details, IMPORT_FIELD_LOGIN_NAME, row.getLoginName(), "登录账号不能为空"); addImportDetail(details, IMPORT_FIELD_LOGIN_NAME, row.getLoginName(), "登录账号不能为空");
@@ -415,7 +418,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
addImportDetail(details, IMPORT_FIELD_EMAIL, row.getEmail(), "邮箱格式不正确"); addImportDetail(details, IMPORT_FIELD_EMAIL, row.getEmail(), "邮箱格式不正确");
} }
SysDept dept = resolveSingleResource(deptName, deptLookup, IMPORT_FIELD_DEPT_NAME, details); SysDept dept = resolveDepartment(deptName, deptLookup, details);
Integer status = parseStatus(row.getStatus(), details); Integer status = parseStatus(row.getStatus(), details);
List<BigInteger> roleIds = resolveResourceIds( List<BigInteger> roleIds = resolveResourceIds(
roleNames, roleNames,
@@ -566,6 +569,224 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
return new ImportNameLookup<>(uniqueMap, duplicateNames); return new ImportNameLookup<>(uniqueMap, duplicateNames);
} }
/**
* 构建账号导入使用的部门名称与完整路径查找表。
*
* <p>每个 {@code parentId=0} 的部门都作为独立顶级分支,路径只从当前分支的顶级部门开始。
* 构建过程仅使用直接父级关系,并对断链和循环层级进行隔离,避免生成错误路径。</p>
*
* @param departments 当前租户的部门集合
* @return 部门导入查找表
*/
private DepartmentImportLookup buildDepartmentImportLookup(List<SysDept> departments) {
List<SysDept> safeDepartments = departments == null ? Collections.emptyList() : departments;
Map<BigInteger, SysDept> departmentById = new HashMap<>(safeDepartments.size());
for (SysDept department : safeDepartments) {
if (department != null && department.getId() != null) {
departmentById.put(department.getId(), department);
}
}
Map<BigInteger, String> pathById = new HashMap<>(departmentById.size());
Set<BigInteger> invalidDepartmentIds = new LinkedHashSet<>();
Set<BigInteger> validDepartmentIds = new LinkedHashSet<>();
Map<String, SysDept> uniquePathMap = new HashMap<>(departmentById.size());
Set<String> 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<BigInteger, SysDept> departmentById,
Map<BigInteger, String> pathById,
Set<BigInteger> invalidDepartmentIds) {
BigInteger departmentId = department.getId();
if (departmentId == null || invalidDepartmentIds.contains(departmentId)) {
return null;
}
String cachedPath = pathById.get(departmentId);
if (cachedPath != null) {
return cachedPath;
}
List<SysDept> unresolvedChain = new ArrayList<>();
Set<BigInteger> 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<SysAccountImportErrorDetailVo> details) {
String path = trimToNull(rawPath);
if (path == null) {
return null;
}
if (!path.contains("/")) {
ImportNameLookup<SysDept> 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<SysAccountImportErrorDetailVo> details) {
String[] segments = rawPath.trim().split("/", -1);
List<String> 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<SysAccountImportRow> parseImportRows(MultipartFile file) { private List<SysAccountImportRow> parseImportRows(MultipartFile file) {
try (InputStream inputStream = file.getInputStream()) { try (InputStream inputStream = file.getInputStream()) {
SysAccountExcelReadListener listener = new SysAccountExcelReadListener(); SysAccountExcelReadListener listener = new SysAccountExcelReadListener();
@@ -678,7 +899,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private List<List<String>> buildImportHeadList() { private List<List<String>> buildImportHeadList() {
List<List<String>> headList = new ArrayList<>(9); List<List<String>> 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_LOGIN_NAME));
headList.add(Collections.singletonList(IMPORT_HEAD_NICKNAME)); headList.add(Collections.singletonList(IMPORT_HEAD_NICKNAME));
headList.add(Collections.singletonList(IMPORT_HEAD_MOBILE)); headList.add(Collections.singletonList(IMPORT_HEAD_MOBILE));
@@ -699,8 +920,11 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private List<List<String>> buildImportGuideRows() { private List<List<String>> buildImportGuideRows() {
List<List<String>> rows = new ArrayList<>(); List<List<String>> 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("可选字段", "手机号、邮箱、状态、岗位名称、备注"));
rows.add(List.of("状态可选值", "可留空,或填写 1/0/已启用/启用/未启用/停用/禁用")); rows.add(List.of("状态可选值", "可留空,或填写 1/0/已启用/启用/未启用/停用/禁用"));
rows.add(List.of("多值分隔", "角色名称、岗位名称支持使用英文逗号,或中文逗号,分隔多个名称")); rows.add(List.of("多值分隔", "角色名称、岗位名称支持使用英文逗号,或中文逗号,分隔多个名称"));
@@ -709,7 +933,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
"导入成功的账号默认密码为 " + accountSecurityProperties.getDefaultResetPassword() "导入成功的账号默认密码为 " + accountSecurityProperties.getDefaultResetPassword()
+ ",首次登录需要修改密码" + ",首次登录需要修改密码"
)); ));
rows.add(List.of("示例行", "市场部 | zhangsan | 张三 | 13800138000 | zhangsan@example.com | 已启用 | 普通员工,审批专员 | 产品经理 | 示例导入")); rows.add(List.of("示例行", "技术部/研发 | zhangsan | 张三 | 13800138000 | zhangsan@example.com | 已启用 | 普通员工,审批专员 | 产品经理 | 示例导入"));
return rows; return rows;
} }
@@ -854,7 +1078,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
@Override @Override
public void invoke(LinkedHashMap<Integer, Object> data, AnalysisContext context) { public void invoke(LinkedHashMap<Integer, Object> data, AnalysisContext context) {
sheetRowNo++; 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 loginName = getCellValue(data, IMPORT_HEAD_LOGIN_NAME);
String nickname = getCellValue(data, IMPORT_HEAD_NICKNAME); String nickname = getCellValue(data, IMPORT_HEAD_NICKNAME);
String mobile = getCellValue(data, IMPORT_HEAD_MOBILE); String mobile = getCellValue(data, IMPORT_HEAD_MOBILE);
@@ -897,16 +1121,29 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
String headValue = entry.getValue() == null ? null : entry.getValue().getStringValue(); String headValue = entry.getValue() == null ? null : entry.getValue().getStringValue();
String header = trimToNull(headValue); String header = trimToNull(headValue);
if (header != null) { if (header != null) {
headIndex.put(header, entry.getKey()); Integer previousIndex = headIndex.putIfAbsent(header, entry.getKey());
if (previousIndex != null
&& (IMPORT_HEAD_DEPT_PATH.equals(header)
|| IMPORT_HEAD_DEPT_NAME_LEGACY.equals(header))) {
throw new BusinessException("导入模板表头不正确,部门列不能重复");
} }
} }
}
boolean hasDeptPathHead = headIndex.containsKey(IMPORT_HEAD_DEPT_PATH);
boolean hasLegacyDeptNameHead = headIndex.containsKey(IMPORT_HEAD_DEPT_NAME_LEGACY);
if (hasDeptPathHead && hasLegacyDeptNameHead) {
throw new BusinessException("导入模板表头不正确,部门路径*与部门名称*只能保留一个");
}
List<String> requiredHeads = List.of( List<String> requiredHeads = List.of(
IMPORT_HEAD_DEPT_NAME, IMPORT_HEAD_DEPT_PATH,
IMPORT_HEAD_LOGIN_NAME, IMPORT_HEAD_LOGIN_NAME,
IMPORT_HEAD_NICKNAME, IMPORT_HEAD_NICKNAME,
IMPORT_HEAD_ROLE_NAMES IMPORT_HEAD_ROLE_NAMES
); );
for (String requiredHead : requiredHeads) { for (String requiredHead : requiredHeads) {
if (IMPORT_HEAD_DEPT_PATH.equals(requiredHead) && hasLegacyDeptNameHead) {
continue;
}
if (IMPORT_HEAD_ROLE_NAMES.equals(requiredHead) if (IMPORT_HEAD_ROLE_NAMES.equals(requiredHead)
&& headIndex.containsKey(IMPORT_HEAD_ROLE_NAMES_LEGACY)) { && headIndex.containsKey(IMPORT_HEAD_ROLE_NAMES_LEGACY)) {
continue; continue;
@@ -946,6 +1183,71 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
} }
} }
/**
* 账号导入的部门路径与兼容名称查找表。
*/
private static class DepartmentImportLookup {
private final Map<String, SysDept> uniquePathMap;
private final Set<String> duplicatePaths;
private final Set<BigInteger> validDepartmentIds;
private final ImportNameLookup<SysDept> nameLookup;
/**
* 创建部门导入查找表。
*
* @param uniquePathMap 唯一完整路径映射
* @param duplicatePaths 重复完整路径集合
* @param validDepartmentIds 层级关系有效的部门 ID 集合
* @param nameLookup 兼容名称查找表
*/
private DepartmentImportLookup(
Map<String, SysDept> uniquePathMap,
Set<String> duplicatePaths,
Set<BigInteger> validDepartmentIds,
ImportNameLookup<SysDept> nameLookup) {
this.uniquePathMap = uniquePathMap;
this.duplicatePaths = duplicatePaths;
this.validDepartmentIds = validDepartmentIds;
this.nameLookup = nameLookup;
}
/**
* 获取唯一完整路径映射。
*
* @return 唯一完整路径映射
*/
public Map<String, SysDept> getUniquePathMap() {
return uniquePathMap;
}
/**
* 获取重复完整路径集合。
*
* @return 重复完整路径集合
*/
public Set<String> getDuplicatePaths() {
return duplicatePaths;
}
/**
* 获取层级关系有效的部门 ID 集合。
*
* @return 有效部门 ID 集合
*/
public Set<BigInteger> getValidDepartmentIds() {
return validDepartmentIds;
}
/**
* 获取兼容名称查找表。
*
* @return 名称查找表
*/
public ImportNameLookup<SysDept> getNameLookup() {
return nameLookup;
}
}
private static class ImportNameLookup<T> { private static class ImportNameLookup<T> {
private final Map<String, T> uniqueMap; private final Map<String, T> uniqueMap;
private final Set<String> duplicateNames; private final Set<String> duplicateNames;

View File

@@ -24,6 +24,7 @@ import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.config.AccountSecurityProperties; import tech.easyflow.system.config.AccountSecurityProperties;
import tech.easyflow.system.entity.SysAccount; import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept; 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.SysAccountImportErrorDetailVo;
import tech.easyflow.system.entity.vo.SysAccountImportResultVo; import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
import tech.easyflow.system.mapper.SysDeptMapper; 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.junit.Assert.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -140,9 +142,227 @@ public class SysAccountServiceImplTest {
service.writeImportTemplate(outputStream); service.writeImportTemplate(outputStream);
List<String> headers = readFirstSheetHeaders(outputStream.toByteArray()); List<String> headers = readFirstSheetHeaders(outputStream.toByteArray());
assertTrue(headers.contains("部门路径*"));
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("角色名称*")); 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)); 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;
}
/** /**
* 创建账号导入测试工作簿。 * 创建账号导入测试工作簿。
* *