发布 v1.10 #5

Merged
czm merged 147 commits from develop into main 2026-08-20 11:36:27 +08:00
4 changed files with 222 additions and 76 deletions
Showing only changes of commit 9be9bd7665 - Show all commits

View File

@@ -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<SysAccountMapper, SysAcco
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 int MAX_IMPORT_ROWS = 5000;
/** 重名部门错误中最多展示的候选路径数量。 */
private static final int MAX_IMPORT_DEPARTMENT_CANDIDATE_PATHS = 10;
private static final String IMPORT_HEAD_DEPT_PATH = "部门路径*";
/** 兼容已下载的旧版用户导入模板表头。 */
private static final String IMPORT_HEAD_DEPT_NAME_LEGACY = "部门名称*";
@@ -418,7 +396,12 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
addImportDetail(details, IMPORT_FIELD_EMAIL, row.getEmail(), "邮箱格式不正确");
}
SysDept dept = resolveDepartment(deptName, deptLookup, details);
SysDept dept = resolveDepartment(
deptName,
row.isDepartmentPathHeader(),
deptLookup,
details
);
Integer status = parseStatus(row.getStatus(), details);
List<BigInteger> roleIds = resolveResourceIds(
roleNames,
@@ -592,6 +575,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
Set<BigInteger> validDepartmentIds = new LinkedHashSet<>();
Map<String, SysDept> uniquePathMap = new HashMap<>(departmentById.size());
Set<String> duplicatePaths = new LinkedHashSet<>();
Map<String, Set<String>> pathSetByName = new HashMap<>(departmentById.size());
for (SysDept department : departmentById.values()) {
String path = resolveDepartmentPath(
department,
@@ -603,6 +587,10 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
continue;
}
validDepartmentIds.add(department.getId());
pathSetByName.computeIfAbsent(
trimToNull(department.getDeptName()),
ignored -> new LinkedHashSet<>()
).add(path);
if (uniquePathMap.containsKey(path)) {
duplicatePaths.add(path);
uniquePathMap.remove(path);
@@ -612,11 +600,18 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
uniquePathMap.put(path, department);
}
}
Map<String, List<String>> pathsByName = new HashMap<>(pathSetByName.size());
pathSetByName.forEach((name, paths) -> {
List<String> 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,29 +686,60 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
}
/**
* 按完整路径或兼容的唯一部门名称解析导入部门
* 按模板语义解析完整路径或兼容的唯一部门名称。
*
* @param rawPath Excel 中填写的部门路径
* @param departmentPathHeader 是否使用当前部门路径表头
* @param lookup 部门查找表
* @param details 错误明细收集器
* @return 匹配到的部门;校验失败时返回 {@code null}
*/
private SysDept resolveDepartment(
String rawPath,
boolean departmentPathHeader,
DepartmentImportLookup lookup,
List<SysAccountImportErrorDetailVo> details) {
String path = trimToNull(rawPath);
if (path == null) {
return null;
}
if (!path.contains("/")) {
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 pathDepartment = lookup.getUniquePathMap().get(normalizedPath);
if (pathDepartment != null) {
return pathDepartment;
}
if (explicitPath) {
addImportDetail(
details,
IMPORT_FIELD_DEPT_PATH,
rawPath,
"部门路径不存在或层级关系异常,请从所属分支的顶级部门开始填写"
);
return null;
}
}
ImportNameLookup<SysDept> nameLookup = lookup.getNameLookup();
if (nameLookup.getDuplicateNames().contains(path)) {
addImportDetail(
details,
IMPORT_FIELD_DEPT_PATH,
rawPath,
"部门名称存在重名,请填写从所属分支顶级部门开始的完整路径"
buildDuplicatedDepartmentNameReason(path, lookup)
);
return null;
}
@@ -734,29 +760,28 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
return department;
}
String normalizedPath = normalizeDepartmentPath(rawPath, details);
if (normalizedPath == null) {
return null;
/**
* 构建部门名称重名时的完整路径提示。
*
* @param departmentName 重复的部门名称
* @param lookup 部门查找表
* @return 包含有效候选路径的错误原因
*/
private String buildDuplicatedDepartmentNameReason(
String departmentName,
DepartmentImportLookup lookup) {
String reason = "部门名称存在重名,请填写从所属分支顶级部门开始的完整路径";
List<String> candidatePaths = lookup.getPathsByName().get(departmentName);
if (candidatePaths == null || candidatePaths.size() < 2) {
return reason;
}
if (lookup.getDuplicatePaths().contains(normalizedPath)) {
addImportDetail(
details,
IMPORT_FIELD_DEPT_PATH,
rawPath,
"部门完整路径存在重名,请先在部门管理中处理"
);
return null;
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;
}
SysDept department = lookup.getUniquePathMap().get(normalizedPath);
if (department == null) {
addImportDetail(
details,
IMPORT_FIELD_DEPT_PATH,
rawPath,
"部门路径不存在或层级关系异常,请从所属分支的顶级部门开始填写"
);
}
return department;
return reason + "" + visiblePaths
+ "(共" + candidatePaths.size() + "个候选部门,仅展示前" + visibleCount + "个)";
}
/**
@@ -922,7 +947,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
List<List<String>> rows = new ArrayList<>();
rows.add(List.of(
"填写规则",
"多级部门从所属分支顶级部门开始填写,使用英文/分隔,例如:技术部/研发;唯一部门名称仍兼容"
"部门优先填写从所属分支顶级部门开始的完整路径,使用英文/分隔,例如:石家庄分行/交易银行部;顶级部门直接填写名称,唯一部门名称仍兼容"
));
rows.add(List.of("必填字段", "部门路径*、登录账号*、昵称*、角色名称*"));
rows.add(List.of("可选字段", "手机号、邮箱、状态、岗位名称、备注"));
@@ -979,6 +1004,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private static class SysAccountImportRow {
private Integer rowNumber;
private String deptName;
private boolean departmentPathHeader;
private String loginName;
private String nickname;
private String mobile;
@@ -1004,6 +1030,24 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
this.deptName = deptName;
}
/**
* 判断当前行是否来自部门路径表头。
*
* @return 使用部门路径表头时返回 {@code true}
*/
public boolean isDepartmentPathHeader() {
return departmentPathHeader;
}
/**
* 设置当前行是否来自部门路径表头。
*
* @param departmentPathHeader 是否使用部门路径表头
*/
public void setDepartmentPathHeader(boolean departmentPathHeader) {
this.departmentPathHeader = departmentPathHeader;
}
public String getLoginName() {
return loginName;
}
@@ -1104,6 +1148,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
SysAccountImportRow row = new SysAccountImportRow();
row.setRowNumber(sheetRowNo + 1);
row.setDeptName(deptName);
row.setDepartmentPathHeader(headIndex.containsKey(IMPORT_HEAD_DEPT_PATH));
row.setLoginName(loginName);
row.setNickname(nickname);
row.setMobile(mobile);
@@ -1191,6 +1236,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private final Set<String> duplicatePaths;
private final Set<BigInteger> validDepartmentIds;
private final ImportNameLookup<SysDept> nameLookup;
private final Map<String, List<String>> pathsByName;
/**
* 创建部门导入查找表。
@@ -1199,16 +1245,19 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
* @param duplicatePaths 重复完整路径集合
* @param validDepartmentIds 层级关系有效的部门 ID 集合
* @param nameLookup 兼容名称查找表
* @param pathsByName 部门名称到有效完整路径列表的映射
*/
private DepartmentImportLookup(
Map<String, SysDept> uniquePathMap,
Set<String> duplicatePaths,
Set<BigInteger> validDepartmentIds,
ImportNameLookup<SysDept> nameLookup) {
ImportNameLookup<SysDept> nameLookup,
Map<String, List<String>> 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<SysAccountMapper, SysAcco
public ImportNameLookup<SysDept> getNameLookup() {
return nameLookup;
}
/**
* 获取部门名称对应的有效完整路径列表。
*
* @return 名称到完整路径列表的映射
*/
public Map<String, List<String>> getPathsByName() {
return pathsByName;
}
}
private static class ImportNameLookup<T> {

View File

@@ -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<SysAccount> 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<SysAccount> 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));
}
/**
* 验证同一父部门下的同名子部门不会被完整路径静默匹配到任意一条记录。
*

View File

@@ -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."
}

View File

@@ -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": "角色和岗位支持多个名称,使用英文逗号或中文逗号分隔。"
}