From dfe2d3265fc7a499a1f2209509755948e768e22a Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Sun, 25 Oct 2026 19:38:25 +0800 Subject: [PATCH] =?UTF-8?q?:bug:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E4=BB=85=E6=94=B9=E4=BA=BA=E5=8F=A3=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=97=B6=E4=B8=8D=E5=86=8D=E9=87=8D=E7=BD=AE=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E6=9D=83=E9=99=90=E8=8C=83=E5=9B=B4=E4=B8=8E=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E7=BB=84=E7=BB=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- blade-service/blade-system/pom.xml | 7 + .../service/impl/MpUserServiceImpl.java | 23 +- .../service/impl/MpUserServiceImplTest.java | 312 ++++++++++++++++++ 3 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java diff --git a/blade-service/blade-system/pom.xml b/blade-service/blade-system/pom.xml index 81ac5a93..785c5efb 100644 --- a/blade-service/blade-system/pom.xml +++ b/blade-service/blade-system/pom.xml @@ -94,6 +94,13 @@ + + + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index ba58c564..26b0832e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -299,7 +299,7 @@ public class MpUserServiceImpl extends BaseServiceImpl 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 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 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 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.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); + } + +}