fix: 统一账号角色校验与默认首页

- 创建与导入账号时强制校验启用角色

- 按启用角色返回默认首页并过滤禁用角色
This commit is contained in:
2026-08-03 14:48:39 +08:00
parent 219e4f7eff
commit 866688b92f
9 changed files with 697 additions and 42 deletions

View File

@@ -16,6 +16,12 @@ public interface SysRoleService extends IService<SysRole> {
void saveRoleMenu(BigInteger roleId, List<String> keys);
/**
* 查询账号当前关联的启用角色。
*
* @param accountId 账号 ID
* @return 启用角色列表
*/
List<SysRole> getRolesByAccountId(BigInteger accountId);
void saveRole(SysRole sysRole);

View File

@@ -80,7 +80,9 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private static final String IMPORT_HEAD_MOBILE = "手机号";
private static final String IMPORT_HEAD_EMAIL = "邮箱";
private static final String IMPORT_HEAD_STATUS = "状态";
private static final String IMPORT_HEAD_ROLE_NAMES = "角色名称";
private static final String IMPORT_HEAD_ROLE_NAMES = "角色名称*";
/** 兼容已下载的旧版导入模板表头。 */
private static final String IMPORT_HEAD_ROLE_NAMES_LEGACY = "角色名称";
private static final String IMPORT_HEAD_POSITION_NAMES = "岗位名称";
private static final String IMPORT_HEAD_REMARK = "备注";
private static final String IMPORT_GUIDE_HEAD_ITEM = "说明项";
@@ -328,7 +330,9 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
SysDept::getDeptName
);
ImportNameLookup<SysRole> roleLookup = buildImportNameLookup(
sysRoleMapper.selectListByQuery(QueryWrapper.create()),
sysRoleMapper.selectListByQuery(
QueryWrapper.create().eq(SysRole::getStatus, EnumDataStatus.AVAILABLE.getCode())
),
SysRole::getRoleName
);
ImportNameLookup<SysPosition> positionLookup = buildImportNameLookup(
@@ -388,6 +392,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
String nickname = trimToNull(row.getNickname());
String mobile = trimToNull(row.getMobile());
String email = trimToNull(row.getEmail());
List<String> roleNames = splitCodes(row.getRoleNames());
if (deptName == null) {
addImportDetail(details, IMPORT_FIELD_DEPT_NAME, row.getDeptName(), "部门名称不能为空");
@@ -400,6 +405,9 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
if (nickname == null) {
addImportDetail(details, IMPORT_FIELD_NICKNAME, row.getNickname(), "昵称不能为空");
}
if (roleNames.isEmpty()) {
addImportDetail(details, IMPORT_FIELD_ROLE_NAME, row.getRoleNames(), "角色名称不能为空");
}
if (mobile != null && !StringUtil.isMobileNumber(mobile)) {
addImportDetail(details, IMPORT_FIELD_MOBILE, row.getMobile(), "手机号格式不正确");
}
@@ -410,14 +418,14 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
SysDept dept = resolveSingleResource(deptName, deptLookup, IMPORT_FIELD_DEPT_NAME, details);
Integer status = parseStatus(row.getStatus(), details);
List<BigInteger> roleIds = resolveResourceIds(
row.getRoleNames(),
roleNames,
roleLookup,
SysRole::getId,
IMPORT_FIELD_ROLE_NAME,
details
);
List<BigInteger> positionIds = resolveResourceIds(
row.getPositionNames(),
splitCodes(row.getPositionNames()),
positionLookup,
SysPosition::getId,
IMPORT_FIELD_POSITION_NAME,
@@ -487,7 +495,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
/**
* 解析名称列表并映射为主键集合。
*
* @param rawNames 原始名称文本
* @param names 标准化后的名称列表
* @param lookup 名称查找表
* @param fieldName 字段名称
* @param details 错误明细收集器
@@ -495,12 +503,11 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
* @return 匹配到的主键集合
*/
private <T> List<BigInteger> resolveResourceIds(
String rawNames,
List<String> names,
ImportNameLookup<T> lookup,
Function<T, BigInteger> idExtractor,
String fieldName,
List<SysAccountImportErrorDetailVo> details) {
List<String> names = splitCodes(rawNames);
if (names.isEmpty()) {
return Collections.emptyList();
}
@@ -693,8 +700,8 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
private List<List<String>> buildImportGuideRows() {
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("状态可选值", "可留空,或填写 1/0/已启用/启用/未启用/停用/禁用"));
rows.add(List.of("多值分隔", "角色名称、岗位名称支持使用英文逗号,或中文逗号,分隔多个名称"));
rows.add(List.of(
@@ -853,7 +860,7 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
String mobile = getCellValue(data, IMPORT_HEAD_MOBILE);
String email = getCellValue(data, IMPORT_HEAD_EMAIL);
String status = getCellValue(data, IMPORT_HEAD_STATUS);
String roleNames = getCellValue(data, IMPORT_HEAD_ROLE_NAMES);
String roleNames = getCellValue(data, IMPORT_HEAD_ROLE_NAMES, IMPORT_HEAD_ROLE_NAMES_LEGACY);
String positionNames = getCellValue(data, IMPORT_HEAD_POSITION_NAMES);
String remark = getCellValue(data, IMPORT_HEAD_REMARK);
if (!StringUtil.hasText(deptName)
@@ -896,9 +903,14 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
List<String> requiredHeads = List.of(
IMPORT_HEAD_DEPT_NAME,
IMPORT_HEAD_LOGIN_NAME,
IMPORT_HEAD_NICKNAME
IMPORT_HEAD_NICKNAME,
IMPORT_HEAD_ROLE_NAMES
);
for (String requiredHead : requiredHeads) {
if (IMPORT_HEAD_ROLE_NAMES.equals(requiredHead)
&& headIndex.containsKey(IMPORT_HEAD_ROLE_NAMES_LEGACY)) {
continue;
}
if (!headIndex.containsKey(requiredHead)) {
throw new BusinessException("导入模板表头不正确,必须包含:" + String.join("", requiredHeads));
}
@@ -914,13 +926,23 @@ public class SysAccountServiceImpl extends ServiceImpl<SysAccountMapper, SysAcco
return rows;
}
private String getCellValue(Map<Integer, Object> row, String headName) {
Integer index = headIndex.get(headName);
if (index == null) {
return null;
/**
* 按候选表头顺序读取单元格文本。
*
* @param row 当前数据行
* @param headNames 候选表头
* @return 单元格文本,无匹配表头时返回 {@code null}
*/
private String getCellValue(Map<Integer, Object> row, String... headNames) {
for (String headName : headNames) {
Integer index = headIndex.get(headName);
if (index == null) {
continue;
}
Object value = row.get(index);
return value == null ? null : String.valueOf(value).trim();
}
Object value = row.get(index);
return value == null ? null : String.valueOf(value).trim();
return null;
}
}

View File

@@ -7,6 +7,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tech.easyflow.common.cache.RedisLockExecutor;
import tech.easyflow.common.constant.enums.EnumDataScope;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.entity.SysRoleDept;
@@ -80,6 +81,9 @@ public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole> impl
});
}
/**
* {@inheritDoc}
*/
@Override
public List<SysRole> getRolesByAccountId(BigInteger accountId) {
// 查询用户对应角色id集合
@@ -89,7 +93,10 @@ public class SysRoleServiceImpl extends ServiceImpl<SysRoleMapper, SysRole> impl
if (CollectionUtil.isEmpty(roleIds)) {
return new ArrayList<>();
}
return listByIds(roleIds);
return listByIds(roleIds).stream()
.filter(role -> role != null
&& EnumDataStatus.AVAILABLE.getCode().equals(role.getStatus()))
.collect(Collectors.toList());
}
@Override

View File

@@ -2,28 +2,55 @@ package tech.easyflow.system.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.crypto.digest.BCrypt;
import cn.idev.excel.EasyExcel;
import cn.idev.excel.ExcelWriter;
import cn.idev.excel.FastExcel;
import cn.idev.excel.context.AnalysisContext;
import cn.idev.excel.metadata.data.ReadCellData;
import cn.idev.excel.read.listener.ReadListener;
import cn.idev.excel.write.metadata.WriteSheet;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import org.mockito.ArgumentCaptor;
import org.mockito.MockedStatic;
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;
import tech.easyflow.system.config.AccountSecurityProperties;
import tech.easyflow.system.entity.SysAccount;
import tech.easyflow.system.entity.SysDept;
import tech.easyflow.system.entity.vo.SysAccountImportErrorDetailVo;
import tech.easyflow.system.entity.vo.SysAccountImportResultVo;
import tech.easyflow.system.mapper.SysDeptMapper;
import tech.easyflow.system.mapper.SysPositionMapper;
import tech.easyflow.system.mapper.SysRoleMapper;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.ArrayList;
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.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
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;
/**
* {@link SysAccountServiceImpl} 测试。
@@ -97,6 +124,180 @@ public class SysAccountServiceImplTest {
}
}
/**
* 验证下载模板明确标记角色为必填字段。
*
* @throws Exception 注入测试配置失败
*/
@Test
public void shouldMarkRoleAsRequiredInImportTemplate() throws Exception {
SysAccountServiceImpl service = new SysAccountServiceImpl();
AccountSecurityProperties properties = new AccountSecurityProperties();
properties.setDefaultResetPassword("Template123!");
setField(service, "accountSecurityProperties", properties);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
service.writeImportTemplate(outputStream);
List<String> headers = readFirstSheetHeaders(outputStream.toByteArray());
assertTrue(headers.contains("角色名称*"));
}
/**
* 验证缺少角色列时直接返回模板表头错误。
*
* @throws Exception 构造上传文件失败
*/
@Test
public void shouldRejectImportTemplateWithoutRequiredRoleHeader() throws Exception {
SysAccountServiceImpl service = new SysAccountServiceImpl();
byte[] workbook = createImportWorkbook(
List.of(
List.of("部门名称*"),
List.of("登录账号*"),
List.of("昵称*")
),
List.of(List.of("研发部", "missing-role", "缺少角色"))
);
BusinessException exception = assertThrows(
BusinessException.class,
() -> service.importAccounts(mockMultipartFile(workbook), new LoginAccount())
);
assertTrue(exception.getMessage().contains("角色名称*"));
}
/**
* 验证角色为空或仅包含分隔符时返回明确的行级必填错误。
*
* @throws Exception 注入测试依赖或构造上传文件失败
*/
@Test
public void shouldRejectImportRowsWithoutEffectiveRoles() 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);
SysDept dept = new SysDept();
dept.setId(BigInteger.ONE);
dept.setDeptName("研发部");
when(deptMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of(dept));
when(roleMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
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));
setField(service, "sysDeptMapper", deptMapper);
setField(service, "sysRoleMapper", roleMapper);
setField(service, "sysPositionMapper", positionMapper);
setField(service, "transactionManager", transactionManager);
byte[] workbook = createImportWorkbook(
List.of(
List.of("部门名称*"),
List.of("登录账号*"),
List.of("昵称*"),
List.of("角色名称")
),
List.of(
List.of("研发部", "empty-role", "空角色", ""),
List.of("研发部", "delimiter-role", "分隔符角色", " , ")
)
);
LoginAccount loginAccount = new LoginAccount();
loginAccount.setId(BigInteger.TEN);
SysAccountImportResultVo result = service.importAccounts(
mockMultipartFile(workbook),
loginAccount
);
assertEquals(2, result.getTotalCount());
assertEquals(0, result.getSuccessCount());
assertEquals(2, result.getErrorCount());
result.getErrorRows().forEach(errorRow -> {
List<SysAccountImportErrorDetailVo> details = errorRow.getDetails();
assertTrue(details.stream().anyMatch(detail ->
"角色名称".equals(detail.getFieldName())
&& "角色名称不能为空".equals(detail.getReason())
));
});
verify(service, never()).save(any(SysAccount.class));
}
/**
* 创建账号导入测试工作簿。
*
* @param headers 表头
* @param rows 数据行
* @return 工作簿字节
*/
private byte[] createImportWorkbook(List<List<String>> headers, List<List<String>> rows) {
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ExcelWriter excelWriter = EasyExcel.write(outputStream).build();
try {
WriteSheet sheet = EasyExcel.writerSheet("模板")
.head(headers)
.build();
excelWriter.write(rows, sheet);
} finally {
excelWriter.finish();
}
return outputStream.toByteArray();
}
/**
* 读取工作簿首个工作表的表头。
*
* @param workbook 工作簿字节
* @return 表头文本
*/
private List<String> readFirstSheetHeaders(byte[] workbook) {
List<String> headers = new ArrayList<>();
ReadListener<LinkedHashMap<Integer, Object>> listener =
new ReadListener<LinkedHashMap<Integer, Object>>() {
@Override
public void invoke(LinkedHashMap<Integer, Object> data, AnalysisContext context) {
// 模板没有数据行,仅检查表头。
}
@Override
public void invokeHead(Map<Integer, ReadCellData<?>> headMap, AnalysisContext context) {
headMap.values().stream()
.map(ReadCellData::getStringValue)
.filter(value -> value != null && !value.isBlank())
.forEach(headers::add);
}
@Override
public void doAfterAllAnalysed(AnalysisContext context) {
// 无需汇总数据行。
}
};
FastExcel.read(new ByteArrayInputStream(workbook), listener)
.sheet()
.doRead();
return headers;
}
/**
* 构造账号导入上传文件。
*
* @param workbook 工作簿字节
* @return 上传文件
* @throws Exception 模拟输入流失败
*/
private MultipartFile mockMultipartFile(byte[] workbook) throws Exception {
MultipartFile file = mock(MultipartFile.class);
when(file.isEmpty()).thenReturn(workbook.length == 0);
when(file.getSize()).thenReturn((long) workbook.length);
when(file.getOriginalFilename()).thenReturn("users.xlsx");
when(file.getInputStream()).thenAnswer(invocation -> new ByteArrayInputStream(workbook));
return file;
}
/**
* 设置测试对象的私有字段。
*

View File

@@ -0,0 +1,118 @@
package tech.easyflow.system.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Test;
import tech.easyflow.common.constant.enums.EnumDataStatus;
import tech.easyflow.system.entity.SysAccountRole;
import tech.easyflow.system.entity.SysRole;
import tech.easyflow.system.service.SysAccountRoleService;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.util.Collection;
import java.util.List;
import static org.junit.Assert.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* {@link SysRoleServiceImpl} 角色状态过滤测试。
*/
public class SysRoleServiceImplTest {
/**
* 验证账号角色查询只返回启用角色。
*
* @throws Exception 注入测试依赖失败
*/
@Test
public void getRolesByAccountIdShouldExcludeUnavailableRoles() throws Exception {
BigInteger accountId = BigInteger.valueOf(10);
BigInteger availableRoleId = BigInteger.valueOf(20);
BigInteger unavailableRoleId = BigInteger.valueOf(30);
SysAccountRoleService accountRoleService = mock(SysAccountRoleService.class);
SysAccountRole availableRelation = accountRole(accountId, availableRoleId);
SysAccountRole unavailableRelation = accountRole(accountId, unavailableRoleId);
when(accountRoleService.list(any(QueryWrapper.class)))
.thenReturn(List.of(availableRelation, unavailableRelation));
SysRole availableRole = role(availableRoleId, EnumDataStatus.AVAILABLE.getCode());
SysRole unavailableRole = role(unavailableRoleId, EnumDataStatus.UNAVAILABLE.getCode());
TestSysRoleService service = new TestSysRoleService(List.of(availableRole, unavailableRole));
setField(service, "sysAccountRoleService", accountRoleService);
List<SysRole> roles = service.getRolesByAccountId(accountId);
assertEquals(List.of(availableRole), roles);
}
/**
* 构造账号角色关系。
*
* @param accountId 账号 ID
* @param roleId 角色 ID
* @return 账号角色关系
*/
private SysAccountRole accountRole(BigInteger accountId, BigInteger roleId) {
SysAccountRole relation = new SysAccountRole();
relation.setAccountId(accountId);
relation.setRoleId(roleId);
return relation;
}
/**
* 构造角色。
*
* @param roleId 角色 ID
* @param status 角色状态
* @return 角色
*/
private SysRole role(BigInteger roleId, Integer status) {
SysRole role = new SysRole();
role.setId(roleId);
role.setStatus(status);
return role;
}
/**
* 注入私有字段。
*
* @param target 目标对象
* @param fieldName 字段名称
* @param value 字段值
* @throws Exception 字段不存在或无法写入
*/
private void setField(Object target, String fieldName, Object value) throws Exception {
Field field = SysRoleServiceImpl.class.getDeclaredField(fieldName);
field.setAccessible(true);
field.set(target, value);
}
/**
* 提供固定角色集合的测试服务。
*/
private static final class TestSysRoleService extends SysRoleServiceImpl {
private final List<SysRole> roles;
/**
* 创建测试服务。
*
* @param roles 固定角色集合
*/
private TestSysRoleService(List<SysRole> roles) {
this.roles = roles;
}
/**
* {@inheritDoc}
*/
@Override
public List<SysRole> listByIds(Collection<? extends Serializable> ids) {
return roles;
}
}
}