diff --git a/blade-service/blade-system/pom.xml b/blade-service/blade-system/pom.xml index 81ac5a93..27c09ffa 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 f3f280af..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(); } @@ -552,9 +566,7 @@ public class MpUserServiceImpl extends BaseServiceImpl 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 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..8ff82829 --- /dev/null +++ b/blade-service/blade-system/src/test/java/org/springblade/system/service/impl/MpUserServiceImplTest.java @@ -0,0 +1,411 @@ +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 org.springblade.system.pojo.vo.MpUserDataScopeVO; + +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.assertFalse; +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("显式传 level=1:层级改写为仅本级,范围与自定义组织保持库值,关联表一行都不动") + void updateWithExplicitLevelRewritesLevelOnly() { + 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.setIdentities("admin"); + user.setDataLevelRange(1); + + assertTrue(updateUser(user)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).updateById(captor.capture()); + assertEquals(1, captor.getValue().getDataLevelRange(), "显式传的层级要落库"); + assertEquals(3, captor.getValue().getDataScopeRange(), "未传范围时沿用库里的 3-自定义"); + assertEquals(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B), captor.getValue().getDataScopeDeptIds()); + verify(mpUserDataScopeMapper, never()).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)); + } + + // ------------------------------------------------------------ 注册路径不要求所属组织 + + @ParameterizedTest(name = "{1}") + @MethodSource("emptyDeptRegistrationCases") + @DisplayName("注册:司机 / 管理员 / 司机+管理员不选所属组织也能建号") + void submitAcceptsEmptyDeptForEveryIdentity(String identities, String label) { + 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(identities); + + try (MockedStatic ignored = mockStatic(MpUserCache.class)) { + assertTrue(service.submit(user), label + ":所属组织不是注册的准入门槛"); + } + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserMapper).insert(captor.capture()); + assertNull(captor.getValue().getDeptId(), "未选组织时组织列保持 null,不写空串"); + assertEquals(1, captor.getValue().getDataScopeRange(), "未选组织的账号按默认 1-仅所属组织落库"); + } + + static Stream emptyDeptRegistrationCases() { + return Stream.of( + Arguments.of("driver", "司机"), + Arguments.of("admin", "管理员"), + Arguments.of("driver,admin", "司机+管理员")); + } + + // ------------------------------------------------------------ 空组织的解析结果是空集 + + @ParameterizedTest(name = "{2}") + @MethodSource("emptyScopeCases") + @DisplayName("数据权限解析:基础组织为空时得到空集,不退化成全部组织") + void resolveDataScopeIsEmptyWhenBaseDeptsEmpty(Integer dataScopeRange, Integer dataLevelRange, String label) { + MpUser user = dbUser(); + user.setDeptId(null); + user.setDataScopeRange(dataScopeRange); + user.setDataLevelRange(dataLevelRange); + when(mpUserMapper.selectById(USER_ID)).thenReturn(user); + when(mpUserDataScopeMapper.selectList(any())).thenReturn(List.of()); + + MpUserDataScopeVO scope = service.resolveDataScope(USER_ID); + + assertFalse(scope.isAllDept(), label + ":空集不是全部组织,allDept=true 表示不按组织过滤"); + assertTrue(scope.getDeptIds().isEmpty()); + } + + static Stream emptyScopeCases() { + return Stream.of( + Arguments.of(null, null, "范围与层级都未设置"), + Arguments.of(1, 1, "1-仅所属组织 + 1-含下级,但没有组织"), + Arguments.of(3, 2, "3-自定义,但一条组织关联都没有")); + } + + @Test + @DisplayName("数据权限解析:只有范围是 2-全部组织时才返回全部") + void resolveDataScopeIsAllOnlyForAllScope() { + MpUser user = dbUser(); + user.setDeptId(null); + user.setDataScopeRange(2); + when(mpUserMapper.selectById(USER_ID)).thenReturn(user); + + MpUserDataScopeVO scope = service.resolveDataScope(USER_ID); + + assertTrue(scope.isAllDept()); + } + + // ------------------------------------------------------------ 夹具 + + /** + * 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); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java index f27ce70d..93f278b8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/constant/TransportBusiConstant.java @@ -61,6 +61,14 @@ public final class TransportBusiConstant { /** 审批状态:已作废(终态)。 */ public static final String VOIDED = "voided"; + /** + * 「未分配组织」筛选项的哨兵值,不是真实组织名。 + *

+ * 前端 organization-search 混入、DriverMapper.xml 与 TransportVehicleMapper.xml 的 + * organizationName 分支、以及两个导出查询都按这个字面量判定,四处必须同步修改。 + */ + public static final String UNASSIGNED_ORG_FILTER = "__UNASSIGNED_ORG__"; + private TransportBusiConstant() { } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java index 89f336bc..e409446d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverController.java @@ -43,6 +43,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; @@ -233,7 +234,11 @@ public class DriverController extends BladeController { if (Func.isNotEmpty(driver.getDriverType())) { queryWrapper.eq(Driver::getDriverType, driver.getDriverType()); } - if (Func.isNotEmpty(driver.getOrganizationName())) { + // 「未分配组织」哨兵改判组织为空串:组织列 NOT NULL,无组织存空串。 + // 哨兵字面量由 TransportBusiConstant.UNASSIGNED_ORG_FILTER 定义,前端与两个 Mapper XML 同步。 + if (TransportBusiConstant.UNASSIGNED_ORG_FILTER.equals(driver.getOrganizationName())) { + queryWrapper.eq(Driver::getOrganizationName, ""); + } else if (Func.isNotEmpty(driver.getOrganizationName())) { queryWrapper.like(Driver::getOrganizationName, driver.getOrganizationName()); } if (Func.isNotEmpty(driver.getStatus())) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java index 37596eb4..a321bdc3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java @@ -44,6 +44,7 @@ import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.transport.constant.TransportBusiConstant; import org.springblade.transport.excel.TransportVehicleExcel; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO; @@ -226,7 +227,11 @@ public class TransportVehicleController extends BladeController { if (Func.isNotEmpty(ids)) { queryWrapper.in(TransportVehicle::getId, Func.toLongList(ids)); } - if (Func.isNotEmpty(vehicle.getOrganizationName())) { + // 「未分配组织」哨兵改判组织为空串:组织列 NOT NULL,无组织存空串。 + // 哨兵字面量由 TransportBusiConstant.UNASSIGNED_ORG_FILTER 定义,前端与两个 Mapper XML 同步。 + if (TransportBusiConstant.UNASSIGNED_ORG_FILTER.equals(vehicle.getOrganizationName())) { + queryWrapper.eq(TransportVehicle::getOrganizationName, ""); + } else if (Func.isNotEmpty(vehicle.getOrganizationName())) { queryWrapper.like(TransportVehicle::getOrganizationName, vehicle.getOrganizationName()); } if (Func.isNotEmpty(vehicle.getPlateNo())) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml index 89e87067..2f1b5e2a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml @@ -170,8 +170,18 @@ AND driver_type = #{driver.driverType} - - AND organization_name LIKE #{organizationNameLike} + + + + AND organization_name = '' + + + + AND organization_name LIKE #{organizationNameLike} + + AND status = #{driver.status} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml index 0faafb78..0d5857fd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportVehicleMapper.xml @@ -117,8 +117,18 @@ is_deleted = 0 - - AND organization_name LIKE #{organizationNameLike} + + + + AND organization_name = '' + + + + AND organization_name LIKE #{organizationNameLike} + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index ca7ef54e..703119df 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -123,21 +123,6 @@ public class DriverCertServiceImpl implements IDriverCertService { } } - /** - * 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT - * 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。 - *

- * 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属), - * 拿它拦住重新认证只会让司机无从下手。 - * - * @param driver 当前司机 - */ - private void requireOrganizationForNewVehicle(Driver driver) { - if (Func.isEmpty(driver.getOrganizationName())) { - throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交"); - } - } - /** * 文本必填 + 长度校验 * @@ -474,15 +459,13 @@ public class DriverCertServiceImpl implements IDriverCertService { */ private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request, String normalizedPlate) { - // 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件 - requireOrganizationForNewVehicle(driver); - + // 内容校验已在 submitVehicleCert 里统一做过 TransportVehicle vehicle = new TransportVehicle(); vehicle.setPlateNo(normalizedPlate); - // 所属组织取司机档案:车辆表的该列 NOT NULL 且无默认值,不填这条 INSERT 直接报 - // 「Field 'organization_name' doesn't have a default value」(严格模式下必错)。 - // 车辆归到司机所属组织名下,管理端按组织查看时才看得到这台车。 - vehicle.setOrganizationName(driver.getOrganizationName()); + // 所属组织是建档时对司机档案取的一次**快照**,不是活关联:司机之后补上组织也不回填已建的车辆。 + // 司机档案没有组织时写空串——该列 NOT NULL 且无默认值,落 null 这条 INSERT 直接报 + // 「Field 'organization_name' doesn't have a default value」。 + vehicle.setOrganizationName(trimToEmpty(driver.getOrganizationName())); vehicle.setOwnerName(request.getOwnerName()); vehicle.setPlateColor(request.getPlateColor()); vehicle.setVehicleType(request.getVehicleType()); @@ -571,4 +554,8 @@ public class DriverCertServiceImpl implements IDriverCertService { return driver; } + private String trimToEmpty(String value) { + return value == null ? "" : value.trim(); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index 41964813..423bafec 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -383,9 +383,6 @@ public class DriverServiceImpl extends BaseServiceImpl imp if (Func.isEmpty(driver.getMobile())) { throw new ServiceException("手机号不能为空"); } - if (Func.isEmpty(driver.getOrganizationName())) { - throw new ServiceException("所属组织不能为空"); - } if (Func.isEmpty(driver.getEmergencyContactName())) { throw new ServiceException("紧急联系人姓名不能为空"); } @@ -475,11 +472,17 @@ public class DriverServiceImpl extends BaseServiceImpl imp } } - // 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步 + // 按「组织名称」反查组织树 id:组织改名后这里必然落空——不报错、不掉数据, + // 只是该司机账号的权限不再随组织变化(新建账号落空时数据权限为空集,管理端看不到)。 + // 组织为空时不做反查:DeptServiceImpl 会把空名称拆成空集合,MyBatis-Plus 的 inExpression + // 对空集合拼出 dept_name IN () 非法子句。此时 deptId 保持 null 而不是空串:updateUser 的 + // updateById 忽略 null,账号原有的组织与数据权限得以保留;写成空串会把它们覆盖掉。 String deptId = null; - R deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName()); - if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) { - deptId = deptResult.getData(); + if (Func.isNotEmpty(driver.getOrganizationName())) { + R deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName()); + if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) { + deptId = deptResult.getData(); + } } MpUser existing = findExistingMpUser(driver, tenantId, mobile); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index d9e1c734..4e6e2c00 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java @@ -264,9 +264,6 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl service.submitVehicleCert(completeVehicleRequest("桂A32523"))); - assertTrue(exception.getMessage().contains("所属组织")); + service.submitVehicleCert(completeVehicleRequest("桂A32523")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).save(captor.capture()); + assertEquals("", captor.getValue().getOrganizationName(), + "该列 NOT NULL 且无默认值:缺组织要落空串,落 null 这条 INSERT 就直接报错"); + verify(certRecordService).save(any(TransportCertRecord.class)); } @Test @@ -689,8 +693,7 @@ class DriverCertServiceImplTest { // 司机档案ID与用户ID是两个不同的ID:提交人字段要的是后者 driver.setUserId(USER_ID); driver.setDriverName("张三"); - // 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败, - // 故建车链路要求它非空,fixture 也必须给 + // 车辆行的 organization_name 是建档时从司机档案抄下来的快照 driver.setOrganizationName("广西现代物流集团有限公司"); driver.setDrivingVehicle(drivingVehicle); return driver; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java index 878fa815..0ecd4f9e 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverServiceImplTest.java @@ -23,6 +23,7 @@ import java.time.LocalDate; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +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; @@ -141,9 +142,72 @@ class DriverServiceImplTest { verify(driverMapper, never()).updateById(any(Driver.class)); } + // ------------------------------------------------------------ 无组织司机 + + @Test + @DisplayName("保存司机:组织为空照常保存成功,档案里的组织是空串") + void submitAllowsEmptyOrganization() { + Driver driver = driver(PLATE, ""); + submitWithExistingMpUser(driver, mpUser(USER_ID, "1123598821738675201")); + verify(mpUserClient).updateUser(any(MpUser.class)); + assertEquals("", driver.getOrganizationName(), "无组织按空串落库,不是 NULL"); + } + + @Test + @DisplayName("保存司机:组织为空时不发起组织反查,deptId 留 null 交给更新忽略") + void submitWithEmptyOrganizationKeepsMpUserDeptId() { + Driver driver = driver(PLATE, ""); + submitWithExistingMpUser(driver, mpUser(USER_ID, "1123598821738675201")); + + verify(sysClient, never()).getDeptIds(any(), any()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserClient).updateUser(captor.capture()); + assertNull(captor.getValue().getDeptId(), + "组织为空时 deptId 只能留 null;写成空串会把账号原有组织与数据权限覆盖掉"); + } + + @Test + @DisplayName("保存司机:组织非空且名称反查命中时,落库的 deptId 是反查到的组织 id") + void submitWithFilledOrganizationUsesResolvedDeptId() { + Driver driver = driver(PLATE, "测试组织"); + when(sysClient.getDeptIds(TENANT_ID, "测试组织")).thenReturn(R.data("1123598821738675201")); + submitWithExistingMpUser(driver, mpUser(USER_ID, "1120666100345487362")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(MpUser.class); + verify(mpUserClient).updateUser(captor.capture()); + assertEquals("1123598821738675201", captor.getValue().getDeptId(), + "组织非空时 deptId 由名称反查结果覆盖账号原组织"); + } + // ------------------------------------------------------------ 夹具 + // 库里已有该司机与对应的小程序账号:走 submit 的「更新并关联」分支 + private void submitWithExistingMpUser(Driver driver, MpUser existing) { + when(driverMapper.selectByIdIncludingDeleted(DRIVER_ID)).thenReturn(driver); + when(driverMapper.selectByIdCardNoIncludingDeleted(driver.getIdCardNo())).thenReturn(null); + when(driverMapper.selectById(DRIVER_ID)).thenReturn(driver); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + when(mpUserClient.userByPhone(TENANT_ID, MOBILE)).thenReturn(R.data(existing)); + when(mpUserClient.updateUser(any(MpUser.class))).thenReturn(R.data(true)); + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getTenantId).thenReturn(TENANT_ID); + service.submit(driver); + } + } + + private MpUser mpUser(Long id, String deptId) { + MpUser user = new MpUser(); + user.setId(id); + user.setDeptId(deptId); + return user; + } + private Driver driver(String drivingVehicle) { + return driver(drivingVehicle, "测试组织"); + } + + private Driver driver(String drivingVehicle, String organizationName) { Driver driver = new Driver(); driver.setId(DRIVER_ID); driver.setIsDeleted(0); @@ -160,7 +224,7 @@ class DriverServiceImplTest { driver.setQualificationLongTerm(1); driver.setDriverType("自有"); driver.setMobile(MOBILE); - driver.setOrganizationName("测试组织"); + driver.setOrganizationName(organizationName); driver.setEmergencyContactName("李四"); driver.setEmergencyContactMobile("13800000000"); driver.setIdCardFront("https://oss/id-front.jpg"); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TransportVehicleServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TransportVehicleServiceImplTest.java new file mode 100644 index 00000000..933ee1ea --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TransportVehicleServiceImplTest.java @@ -0,0 +1,148 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.AfterEach; +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.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.mapper.TransportVehicleMapper; +import org.springblade.transport.pojo.entity.TransportVehicle; + +import java.lang.reflect.Field; +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 车辆管理保存({@code submit} → {@code prepare} + {@code validate})的组织口径。 + *

+ * 所属组织是车辆的档案属性,不是保存的前置条件:组织为空串(司机端建车、管理端未选组织) + * 也能保存,落库后即「未分配组织」。 + */ +class TransportVehicleServiceImplTest { + + private static final String PLATE = "桂A32523"; + + private TransportVehicleServiceImpl service; + private TransportVehicleMapper vehicleMapper; + private MockedStatic authUtil; + + @BeforeAll + static void initTableInfo() { + // saveOrUpdate / count 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + + @BeforeEach + void setUp() throws Exception { + vehicleMapper = mock(TransportVehicleMapper.class); + service = new TransportVehicleServiceImpl(mock(DriverMapper.class)); + inject("baseMapper", vehicleMapper); + authUtil = mockStatic(AuthUtil.class); + } + + @AfterEach + void tearDown() { + authUtil.close(); + } + + @Test + @DisplayName("保存车辆:所属组织为空也允许保存,落库是空串而不是 null(该列 NOT NULL)") + void submitAcceptsVehicleWithoutOrganization() { + when(vehicleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + + TransportVehicle vehicle = completeVehicle(); + vehicle.setOrganizationName(null); + + assertDoesNotThrow(() -> service.submit(vehicle)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(vehicleMapper).insert(captor.capture()); + assertEquals("", captor.getValue().getOrganizationName(), + "organization_name 是 NOT NULL 且无默认值,null 会让这条 INSERT 直接报错"); + } + + @Test + @DisplayName("保存车辆:所属组织是空白串时归一成空串,不落一片空格") + void submitTrimsBlankOrganizationToEmpty() { + when(vehicleMapper.selectCount(any(Wrapper.class))).thenReturn(0L); + + TransportVehicle vehicle = completeVehicle(); + vehicle.setOrganizationName(" "); + + assertDoesNotThrow(() -> service.submit(vehicle)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(vehicleMapper).insert(captor.capture()); + assertEquals("", captor.getValue().getOrganizationName()); + } + + @Test + @DisplayName("保存车辆:组织已不再必填,但其余必填项照旧拦住(证明校验本身还在跑)") + void submitStillValidatesOtherRequiredFields() { + TransportVehicle vehicle = completeVehicle(); + vehicle.setPlateNo(""); + + ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(vehicle)); + assertEquals("车牌号不能为空", exception.getMessage()); + verify(vehicleMapper, never()).insert(any(TransportVehicle.class)); + } + + // ------------------------------------------------------------ 夹具 + + /** + * 一份能通过全部校验的车辆内容;只有组织一项按用例需要改动。 + */ + private TransportVehicle completeVehicle() { + TransportVehicle vehicle = new TransportVehicle(); + vehicle.setOrganizationName("测试组织"); + vehicle.setPlateNo(PLATE); + vehicle.setVehicleType("重型货车"); + vehicle.setBusinessRelation("管理挂靠"); + vehicle.setEnergyType("柴油"); + vehicle.setDrivingLicenseNo("450100123456"); + vehicle.setDrivingLicenseEndDate(LocalDate.of(2028, 3, 9)); + vehicle.setRoadTransportCertNo("桂交运管0001"); + vehicle.setRoadTransportCertEndDate(LocalDate.of(2030, 1, 1)); + vehicle.setAnnualReviewEndDate(LocalDate.of(2030, 1, 1)); + vehicle.setRegistrationNo("450100123456"); + vehicle.setApprovedLoadKg(18000); + return vehicle; + } + + 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); + } + +}