🔀 合并「所属组织不是小程序注册与司机建档门槛」

This commit is contained in:
2026-10-25 20:24:31 +08:00
14 changed files with 725 additions and 52 deletions
+7
View File
@@ -94,6 +94,13 @@
</exclusion>
</exclusions>
</dependency>
<!-- JUnit 5 / Mockito 由该 starter 带入,版本由父 pom 管理 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@@ -299,7 +299,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
throw new ServiceException("该手机号已存在小程序用户");
}
encryptPasswordIfPresent(user);
boolean saved = save(user) && submitScopes(user);
boolean saved = save(user) && submitScopes(user, true);
if (saved) {
MpUserCache.clear();
}
@@ -316,6 +316,20 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
if (dbUser == null || Func.isEmpty(dbUser.getId())) {
throw new ServiceException("小程序用户不存在");
}
// 数据权限三字段以 null 表示调用方未提供(与 updateById 忽略 null 同口径),未提供的用库值回填。
// 判定与回填都必须在 prepare 之前:prepare 会把 range != 3 的 dataScopeDeptIds 置空,
// 之后就分不清「未提供」和「提供了空列表」。
// 不变量:只改人口字段的更新不得改变数据权限范围、层级与自定义组织。
boolean rebuildScopes = user.getDataScopeRange() != null || user.getDataScopeDeptIds() != null;
if (user.getDataScopeRange() == null) {
user.setDataScopeRange(dbUser.getDataScopeRange());
}
if (user.getDataLevelRange() == null) {
user.setDataLevelRange(dbUser.getDataLevelRange());
}
if (user.getDataScopeDeptIds() == null && Objects.equals(user.getDataScopeRange(), 3)) {
user.setDataScopeDeptIds(fillScopes(dbUser).getDataScopeDeptIds());
}
prepare(user);
// 未改手机号(前端脱敏未提交 / 仍为脱敏值)时沿用库中原值,避免把 138****8000 写回
if (Func.isBlank(user.getPhone()) || isMaskedPhone(user.getPhone())) {
@@ -331,7 +345,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
}
// 后台编辑不改密码,避免空串覆盖
user.setPassword(null);
boolean updated = updateById(user) && submitScopes(user);
boolean updated = updateById(user) && submitScopes(user, rebuildScopes);
if (updated) {
MpUserCache.clear();
}
@@ -552,9 +566,7 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
if (Func.isBlank(user.getPhone())) {
throw new ServiceException("手机号不能为空");
}
if (Func.isBlank(user.getDeptId())) {
throw new ServiceException("请选择所属组织");
}
// 账号组织只服务调度端数据权限:为空表示空集(不是全部组织),司机身份不需要它,故不作必填
if (Func.isBlank(user.getIdentities())) {
throw new ServiceException("小程序身份不能为空");
}
@@ -580,10 +592,13 @@ public class MpUserServiceImpl extends BaseServiceImpl<MpUserMapper, MpUser> imp
}
}
private boolean submitScopes(MpUser user) {
private boolean submitScopes(MpUser user, boolean rebuild) {
if (user == null || Func.isEmpty(user.getId())) {
return false;
}
if (!rebuild) {
return true;
}
mpUserDataScopeMapper.delete(Wrappers.<MpUserDataScope>lambdaQuery().eq(MpUserDataScope::getUserId, user.getId()));
if (Objects.equals(user.getDataScopeRange(), 3) && Func.isNotEmpty(user.getDataScopeDeptIds())) {
user.getDataScopeDeptIds().stream().filter(Objects::nonNull).distinct().forEach(deptId -> {
@@ -0,0 +1,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;
/**
* 小程序用户身份与数据权限测试。
* <p>
* 注入方式:被测类直接 new(唯一构造参数是 MpUserDataScopeMapper),
* {@code BaseServiceImpl.baseMapper} 用反射写入 MpUserMapper 的 mock,
* 与 blade-transport 的 DriverServiceImplTest 同一套路,不起 Spring 容器。
* {@code MpUserCache.clear()} 内部走 SpringUtil.getBean(CacheManager),无容器即抛空指针,
* 故每次调用前把该静态方法 mock 掉。
* <p>
* 非法入参用例反射调用私有的 validate:updateUser 会先把手机号用库值回填、再补数据权限默认值,
* 空值走不到校验分支。
*/
class MpUserServiceImplTest {
private static final Long USER_ID = 7001L;
private static final Long SCOPE_DEPT_A = 1001L;
private static final Long SCOPE_DEPT_B = 1002L;
private static final String TENANT_ID = "000000";
private static final String PHONE = "15975348524";
private static final String DEPT_ID = "1123598817739906";
private MpUserMapper mpUserMapper;
private MpUserDataScopeMapper mpUserDataScopeMapper;
private MpUserServiceImpl service;
@BeforeAll
static void initTableInfo() {
// 构造 Lambda 条件需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, MpUser.class);
TableInfoHelper.initTableInfo(assistant, MpUserDataScope.class);
}
@BeforeEach
void setUp() throws Exception {
mpUserMapper = mock(MpUserMapper.class);
mpUserDataScopeMapper = mock(MpUserDataScopeMapper.class);
service = new MpUserServiceImpl(mpUserDataScopeMapper);
inject("baseMapper", mpUserMapper);
}
// ------------------------------------------------------------ 账号组织不再必填
@ParameterizedTest(name = "{2}")
@MethodSource("identityCases")
@DisplayName("编辑小程序用户:司机 / 管理员 / 司机+管理员 × 有组织 / 无组织,六种组合全部放行")
void updateUserAcceptsEmptyDeptForEveryIdentity(String identities, String deptId, String label) {
when(mpUserMapper.selectById(USER_ID)).thenReturn(dbUser());
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setIdentities(identities);
user.setDeptId(deptId);
assertTrue(updateUser(user), label + ":账号组织不是准入门槛");
}
static Stream<Arguments> identityCases() {
return Stream.of(
Arguments.of("driver", DEPT_ID, "司机 + 有组织"),
Arguments.of("driver", null, "司机 + 无组织"),
Arguments.of("admin", DEPT_ID, "管理员 + 有组织"),
Arguments.of("admin", null, "管理员 + 无组织"),
Arguments.of("driver,admin", DEPT_ID, "司机+管理员 + 有组织"),
Arguments.of("driver,admin", null, "司机+管理员 + 无组织"));
}
@Test
@DisplayName("validate 仍拦截:租户空、手机号空、身份空、数据权限范围与层级非法")
void validateStillRejectsMissingOrIllegalFields() {
assertValidateRejects(user -> user.setTenantId(null), "租户信息不能为空");
assertValidateRejects(user -> user.setPhone(null), "手机号不能为空");
assertValidateRejects(user -> user.setIdentities(null), "小程序身份不能为空");
assertValidateRejects(user -> user.setDataScopeRange(0), "数据权限范围不正确");
assertValidateRejects(user -> user.setDataScopeRange(4), "数据权限范围不正确");
assertValidateRejects(user -> user.setDataLevelRange(0), "数据层级范围不正确");
assertValidateRejects(user -> user.setDataLevelRange(3), "数据层级范围不正确");
}
// ------------------------------------------------------------ 只改人口字段不重置数据权限
@Test
@DisplayName("只改姓名:数据权限范围、层级与自定义组织全部不变,关联表一行都不动")
void updatePersonFieldsOnlyKeepsDataScope() {
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
when(mpUserDataScopeMapper.selectList(any())).thenReturn(scopes(SCOPE_DEPT_A, SCOPE_DEPT_B));
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setName("张三");
user.setIdentities("admin");
assertTrue(updateUser(user));
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).updateById(captor.capture());
assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库里的 3-自定义");
assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库里的 2-仅本级");
assertEquals(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B), captor.getValue().getDataScopeDeptIds());
assertNull(captor.getValue().getDeptId(), "调用方不传组织时 updateById 忽略该列,不得写成空串");
verify(mpUserDataScopeMapper, never()).delete(any());
verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class));
}
@Test
@DisplayName("显式传 range=2:范围改写为全部组织,自定义组织关联被清空")
void updateWithExplicitRangeRewritesScopes() {
when(mpUserMapper.selectById(USER_ID)).thenReturn(customScopeDbUser());
when(mpUserMapper.updateById(any(MpUser.class))).thenReturn(1);
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setIdentities("admin");
user.setDataScopeRange(2);
assertTrue(updateUser(user));
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).updateById(captor.capture());
assertEquals(2, captor.getValue().getDataScopeRange());
assertEquals(2, captor.getValue().getDataLevelRange(), "未提供层级时沿用库值");
assertTrue(captor.getValue().getDataScopeDeptIds().isEmpty(), "range != 3 时关联列表被清空");
verify(mpUserDataScopeMapper).delete(any());
verify(mpUserDataScopeMapper, never()).insert(any(MpUserDataScope.class));
}
@Test
@DisplayName("显式传 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<MpUser> 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<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).updateById(captor.capture());
assertEquals(3, captor.getValue().getDataScopeRange(), "未提供范围时沿用库值");
assertEquals(List.of(SCOPE_DEPT_A), captor.getValue().getDataScopeDeptIds());
verify(mpUserDataScopeMapper).delete(any());
verify(mpUserDataScopeMapper).insert(any(MpUserDataScope.class));
}
@Test
@DisplayName("新建小程序用户:仍按提交的 range=3 重建自定义组织关联")
void submitStillRebuildsScopes() {
when(mpUserMapper.selectOne(any(), anyBoolean())).thenReturn(null);
when(mpUserMapper.insert(any(MpUser.class))).thenAnswer(invocation -> {
invocation.getArgument(0, MpUser.class).setId(USER_ID);
return 1;
});
MpUser user = new MpUser();
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setIdentities("admin");
user.setDataScopeRange(3);
user.setDataScopeDeptIds(List.of(SCOPE_DEPT_A, SCOPE_DEPT_B));
try (MockedStatic<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
assertTrue(service.submit(user));
}
verify(mpUserDataScopeMapper).delete(any());
verify(mpUserDataScopeMapper, times(2)).insert(any(MpUserDataScope.class));
}
// ------------------------------------------------------------ 注册路径不要求所属组织
@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<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
assertTrue(service.submit(user), label + ":所属组织不是注册的准入门槛");
}
ArgumentCaptor<MpUser> captor = ArgumentCaptor.forClass(MpUser.class);
verify(mpUserMapper).insert(captor.capture());
assertNull(captor.getValue().getDeptId(), "未选组织时组织列保持 null,不写空串");
assertEquals(1, captor.getValue().getDataScopeRange(), "未选组织的账号按默认 1-仅所属组织落库");
}
static Stream<Arguments> 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<Arguments> 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<MpUserCache> ignored = mockStatic(MpUserCache.class)) {
return service.updateUser(user);
}
}
private MpUser dbUser() {
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setAccount(PHONE);
user.setName("张三");
user.setIdentities("driver");
user.setDeptId(DEPT_ID);
user.setDataScopeRange(1);
user.setDataLevelRange(1);
return user;
}
/**
* range=3(自定义)+ 仅本级,自定义组织由 mpUserDataScopeMapper.selectList 提供
*/
private MpUser customScopeDbUser() {
MpUser user = dbUser();
user.setIdentities("admin");
user.setDataScopeRange(3);
user.setDataLevelRange(2);
return user;
}
private List<MpUserDataScope> scopes(Long... deptIds) {
return Stream.of(deptIds).map(deptId -> {
MpUserDataScope scope = new MpUserDataScope();
scope.setUserId(USER_ID);
scope.setDeptId(deptId);
return scope;
}).toList();
}
private MpUser validUser() {
MpUser user = new MpUser();
user.setId(USER_ID);
user.setTenantId(TENANT_ID);
user.setPhone(PHONE);
user.setIdentities("driver");
user.setDataScopeRange(1);
user.setDataLevelRange(1);
return user;
}
private void assertValidateRejects(Consumer<MpUser> mutation, String expectedMessage) {
MpUser user = validUser();
mutation.accept(user);
ServiceException exception = assertThrows(ServiceException.class, () -> invokeValidate(user));
assertEquals(expectedMessage, exception.getMessage());
}
private void invokeValidate(MpUser user) throws Exception {
Method validate = MpUserServiceImpl.class.getDeclaredMethod("validate", MpUser.class);
validate.setAccessible(true);
try {
validate.invoke(service, user);
} catch (InvocationTargetException exception) {
if (exception.getCause() instanceof RuntimeException runtimeException) {
throw runtimeException;
}
throw exception;
}
}
private void inject(String field, Object value) throws Exception {
Class<?> type = service.getClass();
while (type != null) {
try {
Field declared = type.getDeclaredField(field);
declared.setAccessible(true);
declared.set(service, value);
return;
} catch (NoSuchFieldException ignored) {
type = type.getSuperclass();
}
}
throw new IllegalStateException("未找到字段:" + field);
}
}
@@ -61,6 +61,14 @@ public final class TransportBusiConstant {
/** 审批状态:已作废(终态)。 */
public static final String VOIDED = "voided";
/**
* 「未分配组织」筛选项的哨兵值,不是真实组织名。
* <p>
* 前端 organization-search 混入、DriverMapper.xml 与 TransportVehicleMapper.xml 的
* organizationName 分支、以及两个导出查询都按这个字面量判定,四处必须同步修改。
*/
public static final String UNASSIGNED_ORG_FILTER = "__UNASSIGNED_ORG__";
private TransportBusiConstant() {
}
}
@@ -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())) {
@@ -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())) {
@@ -170,8 +170,18 @@
AND driver_type = #{driver.driverType}
</if>
<if test="driver.organizationName != null and driver.organizationName != ''">
<bind name="organizationNameLike" value="'%' + driver.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
<!-- 「未分配组织」哨兵值定义在 TransportBusiConstant.UNASSIGNED_ORG_FILTER 与前端
tms-erp-web/src/mixins/organization-search.js,改这里必须同步常量与 TransportVehicleMapper.xml 的同名分支。
组织列 NOT NULL,无组织存空串。 -->
<choose>
<when test="'__UNASSIGNED_ORG__'.equals(driver.organizationName)">
AND organization_name = ''
</when>
<otherwise>
<bind name="organizationNameLike" value="'%' + driver.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
</otherwise>
</choose>
</if>
<if test="driver.status != null">
AND status = #{driver.status}
@@ -117,8 +117,18 @@
<sql id="QueryCondition">
is_deleted = 0
<if test="vehicle.organizationName != null and vehicle.organizationName != ''">
<bind name="organizationNameLike" value="'%' + vehicle.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
<!-- 「未分配组织」哨兵值定义在 TransportBusiConstant.UNASSIGNED_ORG_FILTER 与前端
tms-erp-web/src/mixins/organization-search.js,改这里必须同步常量与 DriverMapper.xml 的同名分支。
组织列 NOT NULL,无组织存空串。 -->
<choose>
<when test="'__UNASSIGNED_ORG__'.equals(vehicle.organizationName)">
AND organization_name = ''
</when>
<otherwise>
<bind name="organizationNameLike" value="'%' + vehicle.organizationName + '%'"/>
AND organization_name LIKE #{organizationNameLike}
</otherwise>
</choose>
</if>
<if test="vehicle.useDepartment != null and vehicle.useDepartment != ''">
<bind name="useDepartmentLike" value="'%' + vehicle.useDepartment + '%'"/>
@@ -123,21 +123,6 @@ public class DriverCertServiceImpl implements IDriverCertService {
}
}
/**
* 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT
* 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。
* <p>
* 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属),
* 拿它拦住重新认证只会让司机无从下手。
*
* @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();
}
}
@@ -383,9 +383,6 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> 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<DriverMapper, Driver> imp
}
}
// 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步
// 按「组织名称」反查组织树 id:组织改名后这里必然落空——不报错、不掉数据,
// 只是该司机账号的权限不再随组织变化(新建账号落空时数据权限为空集,管理端看不到)。
// 组织为空时不做反查:DeptServiceImpl 会把空名称拆成空集合,MyBatis-Plus 的 inExpression
// 对空集合拼出 dept_name IN () 非法子句。此时 deptId 保持 null 而不是空串:updateUser 的
// updateById 忽略 null,账号原有的组织与数据权限得以保留;写成空串会把它们覆盖掉。
String deptId = null;
R<String> 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<String> 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);
@@ -264,9 +264,6 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
}
private void validate(TransportVehicle vehicle) {
if (Func.isEmpty(vehicle.getOrganizationName())) {
throw new ServiceException("所属组织不能为空");
}
if (Func.isEmpty(vehicle.getPlateNo())) {
throw new ServiceException("车牌号不能为空");
}
@@ -581,17 +581,21 @@ class DriverCertServiceImplTest {
}
@Test
@DisplayName("司机建车:没有所属组织时明确拒绝,不落一辆查不到归属的车")
void submitVehicleRejectsDriverWithoutOrganization() {
@DisplayName("司机建车:司机档案没有组织也允许建车,车辆组织落空串而不是 null")
void submitVehicleCreatesVehicleForDriverWithoutOrganization() {
Driver driver = driver(null);
driver.setOrganizationName(null);
when(driverAppService.currentDriverEntity()).thenReturn(driver);
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
ServiceException exception = assertThrows(ServiceException.class,
() -> service.submitVehicleCert(completeVehicleRequest("桂A32523")));
assertTrue(exception.getMessage().contains("所属组织"));
service.submitVehicleCert(completeVehicleRequest("桂A32523"));
ArgumentCaptor<TransportVehicle> 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;
@@ -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<MpUser> 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<MpUser> 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> 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");
@@ -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})的组织口径。
* <p>
* 所属组织是车辆的档案属性,不是保存的前置条件:组织为空串(司机端建车、管理端未选组织)
* 也能保存,落库后即「未分配组织」。
*/
class TransportVehicleServiceImplTest {
private static final String PLATE = "桂A32523";
private TransportVehicleServiceImpl service;
private TransportVehicleMapper vehicleMapper;
private MockedStatic<AuthUtil> 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<TransportVehicle> 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<TransportVehicle> 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);
}
}