🔀 合并票 01/02 账号组织可空与更新不重置数据权限

This commit is contained in:
2026-10-25 19:41:13 +08:00
3 changed files with 340 additions and 6 deletions
+7
View File
@@ -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>
@@ -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();
}
@@ -552,9 +566,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
if (Func.isBlank(user.getPhone())) {
throw new ServiceException("手机号不能为空");
}
if (Func.isBlank(user.getDeptId())) {
throw new ServiceException("请选择所属组织");
}
// 账号组织只服务调度端数据权限:为空表示空集(不是全部组织),司机身份不需要它,故不作必填
if (Func.isBlank(user.getIdentities())) {
throw new ServiceException("小程序身份不能为空");
}
@@ -580,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 -> {
@@ -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);
}
}