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 -> {
diff --git a/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java
new file mode 100644
index 00000000..53e22937
--- /dev/null
+++ b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java
@@ -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;
+
+/**
+ * 小程序用户身份与数据权限测试。
+ *
+ * 注入方式:被测类直接 new(唯一构造参数是 MpUserDataScopeMapper),
+ * {@code BaseServiceImpl.baseMapper} 用反射写入 MpUserMapper 的 mock,
+ * 与 blade-transport 的 DriverServiceImplTest 同一套路,不起 Spring 容器。
+ * {@code MpUserCache.clear()} 内部走 SpringUtil.getBean(CacheManager),无容器即抛空指针,
+ * 故每次调用前把该静态方法 mock 掉。
+ *
+ * 非法入参用例反射调用私有的 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 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 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 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 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 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 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 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 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);
+ }
+
+}