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);
+ }
+
+}