🐛 小程序用户仅改人口字段时不再重置数据权限范围与自定义组织
This commit is contained in:
@@ -94,6 +94,13 @@
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试:本模块此前没有 src/test,也没有测试依赖;JUnit 5 / Mockito 由该 starter 带入 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+20
-3
@@ -299,7 +299,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
throw new ServiceException("该手机号已存在小程序用户");
|
||||
}
|
||||
encryptPasswordIfPresent(user);
|
||||
boolean saved = save(user) && submitScopes(user);
|
||||
boolean saved = save(user) && submitScopes(user, true);
|
||||
if (saved) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
@@ -316,6 +316,20 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
if (dbUser == null || Func.isEmpty(dbUser.getId())) {
|
||||
throw new ServiceException("小程序用户不存在");
|
||||
}
|
||||
// 数据权限三字段以 null 表示调用方未提供(与 updateById 忽略 null 同口径),未提供的用库值回填。
|
||||
// 判定与回填都必须在 prepare 之前:prepare 会把 range != 3 的 dataScopeDeptIds 置空,
|
||||
// 之后就分不清「未提供」和「提供了空列表」。
|
||||
// 不变量:只改人口字段的更新不得改变数据权限范围、层级与自定义组织。
|
||||
boolean rebuildScopes = user.getDataScopeRange() != null || user.getDataScopeDeptIds() != null;
|
||||
if (user.getDataScopeRange() == null) {
|
||||
user.setDataScopeRange(dbUser.getDataScopeRange());
|
||||
}
|
||||
if (user.getDataLevelRange() == null) {
|
||||
user.setDataLevelRange(dbUser.getDataLevelRange());
|
||||
}
|
||||
if (user.getDataScopeDeptIds() == null && Objects.equals(user.getDataScopeRange(), 3)) {
|
||||
user.setDataScopeDeptIds(fillScopes(dbUser).getDataScopeDeptIds());
|
||||
}
|
||||
prepare(user);
|
||||
// 未改手机号(前端脱敏未提交 / 仍为脱敏值)时沿用库中原值,避免把 138****8000 写回
|
||||
if (Func.isBlank(user.getPhone()) || isMaskedPhone(user.getPhone())) {
|
||||
@@ -331,7 +345,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
}
|
||||
// 后台编辑不改密码,避免空串覆盖
|
||||
user.setPassword(null);
|
||||
boolean updated = updateById(user) && submitScopes(user);
|
||||
boolean updated = updateById(user) && submitScopes(user, rebuildScopes);
|
||||
if (updated) {
|
||||
MpUserCache.clear();
|
||||
}
|
||||
@@ -578,10 +592,13 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
|
||||
}
|
||||
}
|
||||
|
||||
private boolean submitScopes(MpUser user) {
|
||||
private boolean submitScopes(MpUser user, boolean rebuild) {
|
||||
if (user == null || Func.isEmpty(user.getId())) {
|
||||
return false;
|
||||
}
|
||||
if (!rebuild) {
|
||||
return true;
|
||||
}
|
||||
mpUserDataScopeMapper.delete(Wrappers.<MpUserDataScope>lambdaQuery().eq(MpUserDataScope::getUserId, user.getId()));
|
||||
if (Objects.equals(user.getDataScopeRange(), 3) && Func.isNotEmpty(user.getDataScopeDeptIds())) {
|
||||
user.getDataScopeDeptIds().stream().filter(Objects::nonNull).distinct().forEach(deptId -> {
|
||||
|
||||
+312
@@ -0,0 +1,312 @@
|
||||
package org.springblade.system.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.Arguments;
|
||||
import org.junit.jupiter.params.provider.MethodSource;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.system.cache.MpUserCache;
|
||||
import org.springblade.system.mapper.MpUserDataScopeMapper;
|
||||
import org.springblade.system.mapper.MpUserMapper;
|
||||
import org.springblade.system.pojo.entity.MpUser;
|
||||
import org.springblade.system.pojo.entity.MpUserDataScope;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyBoolean;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 小程序用户身份与数据权限测试。
|
||||
* <p>
|
||||
* 注入方式:被测类直接 new(唯一构造参数是 MpUserDataScopeMapper),
|
||||
* {@code BaseServiceImpl.baseMapper} 用反射写入 MpUserMapper 的 mock,
|
||||
* 与 blade-transport 的 DriverServiceImplTest 同一套路,不起 Spring 容器。
|
||||
* {@code MpUserCache.clear()} 内部走 SpringUtil.getBean(CacheManager),无容器即抛空指针,
|
||||
* 故每次调用前把该静态方法 mock 掉。
|
||||
* <p>
|
||||
* 非法入参用例反射调用私有的 validate:updateUser 会先把手机号用库值回填、再补数据权限默认值,
|
||||
* 空值走不到校验分支。
|
||||
*/
|
||||
class MpUserServiceImplTest {
|
||||
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final Long SCOPE_DEPT_A = 1001L;
|
||||
private static final Long SCOPE_DEPT_B = 1002L;
|
||||
private static final String TENANT_ID = "000000";
|
||||
private static final String PHONE = "15975348524";
|
||||
private static final String DEPT_ID = "1123598817739906";
|
||||
|
||||
private MpUserMapper mpUserMapper;
|
||||
private MpUserDataScopeMapper mpUserDataScopeMapper;
|
||||
private MpUserServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 Lambda 条件需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, MpUser.class);
|
||||
TableInfoHelper.initTableInfo(assistant, MpUserDataScope.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
mpUserMapper = mock(MpUserMapper.class);
|
||||
mpUserDataScopeMapper = mock(MpUserDataScopeMapper.class);
|
||||
service = new MpUserServiceImpl(mpUserDataScopeMapper);
|
||||
inject("baseMapper", mpUserMapper);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 账号组织不再必填
|
||||
|
||||
@ParameterizedTest(name = "{2}")
|
||||
@MethodSource("identityCases")
|
||||
@DisplayName("编辑小程序用户:司机 / 管理员 / 司机+管理员 × 有组织 / 无组织,六种组合全部放行")
|
||||
void updateUserAcceptsEmptyDeptForEveryIdentity(String identities, String deptId, String label) {
|
||||
when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser());
|
||||
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
|
||||
|
||||
MpUser user = new MpUser();
|
||||
user.setId(USER_ID);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setPhone(PHONE);
|
||||
user.setIdentities(identities);
|
||||
user.setDeptId(deptId);
|
||||
|
||||
assertTrue(updateUser(user), label + ":账号组织不是准入门槛");
|
||||
}
|
||||
|
||||
static Stream<Arguments> identityCases() {
|
||||
return Stream.of(
|
||||
Arguments.of("driver", DEPT_ID, "司机 + 有组织"),
|
||||
Arguments.of("driver", null, "司机 + 无组织"),
|
||||
Arguments.of("admin", DEPT_ID, "管理员 + 有组织"),
|
||||
Arguments.of("admin", null, "管理员 + 无组织"),
|
||||
Arguments.of("driver,admin", DEPT_ID, "司机+管理员 + 有组织"),
|
||||
Arguments.of("driver,admin", null, "司机+管理员 + 无组织"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("validate 仍拦截:租户空、手机号空、身份空、数据权限范围与层级非法")
|
||||
void validateStillRejectsMissingOrIllegalFields() {
|
||||
assertValidateRejects(user -> user.setTenantId(null), "租户信息不能为空");
|
||||
assertValidateRejects(user -> user.setPhone(null), "手机号不能为空");
|
||||
assertValidateRejects(user -> user.setIdentities(null), "小程序身份不能为空");
|
||||
assertValidateRejects(user -> user.setDataScopeRange(0), "数据权限范围不正确");
|
||||
assertValidateRejects(user -> user.setDataScopeRange(4), "数据权限范围不正确");
|
||||
assertValidateRejects(user -> user.setDataLevelRange(0), "数据层级范围不正确");
|
||||
assertValidateRejects(user -> user.setDataLevelRange(3), "数据层级范围不正确");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 只改人口字段不重置数据权限
|
||||
|
||||
@Test
|
||||
@DisplayName("只改姓名:数据权限范围、层级与自定义组织全部不变,关联表一行都不动")
|
||||
void updatePersonFieldsOnlyKeepsDataScope() {
|
||||
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
|
||||
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
|
||||
when(mpUserDataScopeMapper.selectList(any())).thenReturn(scopes(SCOPE_DEPT_A, SCOPE_DEPT_B));
|
||||
|
||||
MpUser user = new MpUser();
|
||||
user.setId(USER_ID);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setName("张三");
|
||||
user.setIdentities("admin");
|
||||
|
||||
assertTrue(updateUser(user));
|
||||
|
||||
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
|
||||
verify(mpUserMapper).updateById(captor.capture());
|
||||
assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库里的 3-自定义");
|
||||
assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库里的 2-仅本级");
|
||||
assertEquals(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B), captor.getValue().getDataScopeDeptIds());
|
||||
assertNull(captor.getValue().getDeptId(), "调用方不传组织时 updateById 忽略该列,不得写成空串");
|
||||
verify(mpUserDataScopeMapper, never()).delete(any());
|
||||
verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("显式传 range=2:范围改写为全部组织,自定义组织关联被清空")
|
||||
void updateWithExplicitRangeRewritesScopes() {
|
||||
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
|
||||
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
|
||||
|
||||
MpUser user = new MpUser();
|
||||
user.setId(USER_ID);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setIdentities("admin");
|
||||
user.setDataScopeRange(2);
|
||||
|
||||
assertTrue(updateUser(user));
|
||||
|
||||
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
|
||||
verify(mpUserMapper).updateById(captor.capture());
|
||||
assertEquals(2, captor.getValue().getDataScopeRange());
|
||||
assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库值");
|
||||
assertTrue(captor.getValue().getDataScopeDeptIds().isEmpty(), "range != 3 时关联列表被清空");
|
||||
verify(mpUserDataScopeMapper).delete(any());
|
||||
verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("只显式传自定义组织列表:关联按新列表重建,范围沿用库里的 3-自定义")
|
||||
void updateWithExplicitDeptIdsRebuildsScopes() {
|
||||
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
|
||||
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
|
||||
|
||||
MpUser user = new MpUser();
|
||||
user.setId(USER_ID);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setIdentities("admin");
|
||||
user.setDataScopeDeptIds(List.of(SCOPE_DEPT_A));
|
||||
|
||||
assertTrue(updateUser(user));
|
||||
|
||||
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
|
||||
verify(mpUserMapper).updateById(captor.capture());
|
||||
assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库值");
|
||||
assertEquals(List.of(SCOPE_DEPT_A), captor.getValue().getDataScopeDeptIds());
|
||||
verify(mpUserDataScopeMapper).delete(any());
|
||||
verify(mpUserDataScopeMapper).insert(any(MpUserDataScope.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("新建小程序用户:仍按提交的 range=3 重建自定义组织关联")
|
||||
void submitStillRebuildsScopes() {
|
||||
when(mpUserMapper.selectOne(any(), anyBoolean())).thenReturn(null);
|
||||
when(mpUserMapper.insert(any(MpUser.class))).thenAnswer(invocation -> {
|
||||
invocation.getArgument(0, MpUser.class).setId(USER_ID);
|
||||
return 1;
|
||||
});
|
||||
|
||||
MpUser user = new MpUser();
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setPhone(PHONE);
|
||||
user.setIdentities("admin");
|
||||
user.setDataScopeRange(3);
|
||||
user.setDataScopeDeptIds(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B));
|
||||
|
||||
try (MockedStatic<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
|
||||
assertTrue(service.submit(user));
|
||||
}
|
||||
verify(mpUserDataScopeMapper).delete(any());
|
||||
verify(mpUserDataScopeMapper, times(2)).insert(any(MpUserDataScope.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/**
|
||||
* updateUser 成功后会清缓存;缓存实现依赖 Spring 容器,无容器时静态 mock 掉
|
||||
*/
|
||||
private boolean updateUser(MpUser user) {
|
||||
try (MockedStatic<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
|
||||
return service.updateUser(user);
|
||||
}
|
||||
}
|
||||
|
||||
private MpUser dbUser() {
|
||||
MpUser user = new MpUser();
|
||||
user.setId(USER_ID);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setPhone(PHONE);
|
||||
user.setAccount(PHONE);
|
||||
user.setName("张三");
|
||||
user.setIdentities("driver");
|
||||
user.setDeptId(DEPT_ID);
|
||||
user.setDataScopeRange(1);
|
||||
user.setDataLevelRange(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* range=3(自定义)+ 仅本级,自定义组织由 mpUserDataScopeMapper.selectList 提供
|
||||
*/
|
||||
private MpUser customScopeDbUser() {
|
||||
MpUser user = dbUser();
|
||||
user.setIdentities("admin");
|
||||
user.setDataScopeRange(3);
|
||||
user.setDataLevelRange(2);
|
||||
return user;
|
||||
}
|
||||
|
||||
private List<MpUserDataScope> scopes(Long... deptIds) {
|
||||
return Stream.of(deptIds).map(deptId -> {
|
||||
MpUserDataScope scope = new MpUserDataScope();
|
||||
scope.setUserId(USER_ID);
|
||||
scope.setDeptId(deptId);
|
||||
return scope;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private MpUser validUser() {
|
||||
MpUser user = new MpUser();
|
||||
user.setId(USER_ID);
|
||||
user.setTenantId(TENANT_ID);
|
||||
user.setPhone(PHONE);
|
||||
user.setIdentities("driver");
|
||||
user.setDataScopeRange(1);
|
||||
user.setDataLevelRange(1);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void assertValidateRejects(Consumer<MpUser> mutation, String expectedMessage) {
|
||||
MpUser user = validUser();
|
||||
mutation.accept(user);
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> invokeValidate(user));
|
||||
assertEquals(expectedMessage, exception.getMessage());
|
||||
}
|
||||
|
||||
private void invokeValidate(MpUser user) throws Exception {
|
||||
Method validate = MpUserServiceImpl.class.getDeclaredMethod("validate", MpUser.class);
|
||||
validate.setAccessible(true);
|
||||
try {
|
||||
validate.invoke(service, user);
|
||||
} catch (InvocationTargetException exception) {
|
||||
if (exception.getCause() instanceof RuntimeException runtimeException) {
|
||||
throw runtimeException;
|
||||
}
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
Class<?> type = service.getClass();
|
||||
while (type != null) {
|
||||
try {
|
||||
Field declared = type.getDeclaredField(field);
|
||||
declared.setAccessible(true);
|
||||
declared.set(service, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
type = type.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("未找到字段:" + field);
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user