diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java new file mode 100644 index 00000000..874fde21 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverEnsureProfileTest.java @@ -0,0 +1,255 @@ +package org.springblade.transport.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.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.feign.ISysClient; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.vo.DriverEnsureProfileVO; +import org.springframework.dao.DuplicateKeyException; + +import java.lang.reflect.Field; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +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.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; + +/** + * 登录自动建档与绑定已有档案的测试。 + *

+ * 守住三条不变式:按 user_id 幂等重入不重复建档、同号冲突一律不绑定也不新建、 + * 绑定那次 patch 必须带上 driving_vehicle 原值(该列 {@code FieldStrategy.ALWAYS} + * 会把缺失的列写成 NULL,司机端的车辆绑定会整块消失)。 + */ +class DriverEnsureProfileTest { + + private static final Long MP_USER_ID = 7001L; + private static final Long OTHER_USER_ID = 7002L; + private static final Long DRIVER_ID = 9001L; + private static final Long NEW_DRIVER_ID = 9002L; + private static final String TENANT_ID = "000000"; + private static final String MOBILE = "15975348524"; + private static final String PLATE = "桂A32523"; + + private DriverMapper driverMapper; + private DriverServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // save 走 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + + @BeforeEach + void setUp() throws Exception { + driverMapper = mock(DriverMapper.class); + IMpUserClient mpUserClient = mock(IMpUserClient.class); + ISysClient sysClient = mock(ISysClient.class); + service = new DriverServiceImpl(mpUserClient, sysClient); + inject("baseMapper", driverMapper); + } + + // ------------------------------------------------------------ 新建占位档案 + + @Test + @DisplayName("无账号档案、无同号档案:按票 01 的占位值新建,created=true") + void createsPlaceholderProfile() { + // 第一次按 user_id 查、第二次按手机号查,都没有 + when(driverMapper.selectList(any())).thenReturn(Collections.emptyList(), Collections.emptyList()); + when(driverMapper.insert(any(Driver.class))).thenAnswer(invocation -> { + Driver inserted = invocation.getArgument(0); + inserted.setId(NEW_DRIVER_ID); + return 1; + }); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertTrue(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getMessage()); + assertEquals(NEW_DRIVER_ID, result.getDriverId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper).insert(captor.capture()); + Driver saved = captor.getValue(); + assertEquals(TENANT_ID, saved.getTenantId(), "tenant_id 必须显式写入且非空"); + assertEquals(MP_USER_ID, saved.getUserId()); + assertEquals(MOBILE, saved.getMobile()); + assertEquals("司机8524", saved.getDriverName()); + assertNull(saved.getIdCardNo(), "身份证号留 NULL 才是「未采集」,也是补全判据"); + assertEquals("", saved.getDrivingType()); + assertEquals("", saved.getDriverType()); + assertEquals("", saved.getOrganizationName()); + assertEquals("", saved.getEmergencyContactName()); + assertEquals("", saved.getEmergencyContactMobile()); + assertEquals(1, saved.getStatus()); + assertEquals("login_auto", saved.getProfileSource()); + assertNotNull(saved.getAccountBoundTime()); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 绑定已有档案 + + @Test + @DisplayName("命中同号未绑定的档案:绑定并留痕,patch 必须带上 driving_vehicle 原值") + void bindsExistingProfileAndKeepsDrivingVehicle() { + Driver existing = driver(DRIVER_ID, null, PLATE); + when(driverMapper.selectList(any())).thenReturn(Collections.emptyList(), List.of(existing)); + when(driverMapper.selectById(DRIVER_ID)).thenReturn(existing); + when(driverMapper.updateById(any(Driver.class))).thenReturn(1); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertTrue(result.getBoundExisting()); + assertNull(result.getMessage()); + assertEquals(DRIVER_ID, result.getDriverId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverMapper).updateById(captor.capture()); + Driver patch = captor.getValue(); + assertEquals(MP_USER_ID, patch.getUserId()); + assertEquals("login_bound", patch.getProfileSource()); + assertNotNull(patch.getAccountBoundTime()); + assertEquals(PLATE, patch.getDrivingVehicle(), + "绑定 patch 不带 driving_vehicle 会把司机的车辆绑定写掉"); + assertNull(patch.getMobile(), "绑定不得覆盖档案上的手机号"); + verify(driverMapper, never()).insert(any(Driver.class)); + } + + // ------------------------------------------------------------ 幂等重入 + + @Test + @DisplayName("按 user_id 命中:直接返回该档案,不新建、不改手机号") + void returnsExistingProfileForRepeatLogin() { + when(driverMapper.selectList(any())).thenReturn(List.of(driver(DRIVER_ID, MP_USER_ID, PLATE))); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getMessage()); + assertEquals(DRIVER_ID, result.getDriverId()); + verify(driverMapper, never()).insert(any(Driver.class)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("并发重复建档撞唯一键:重查后按已存在的档案返回,不把异常抛给登录方") + void toleratesConcurrentInsert() { + Driver concurrent = driver(DRIVER_ID, MP_USER_ID, PLATE); + when(driverMapper.selectList(any())).thenReturn( + Collections.emptyList(), + Collections.emptyList(), + List.of(concurrent)); + when(driverMapper.insert(any(Driver.class))).thenThrow(new DuplicateKeyException("uk_transport_driver_user")); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertEquals(DRIVER_ID, result.getDriverId()); + } + + // ------------------------------------------------------------ 冲突 + + @Test + @DisplayName("同手机号多条档案:拒绝绑定也不新建,返回可读原因") + void rejectsMultipleProfilesWithSameMobile() { + when(driverMapper.selectList(any())).thenReturn( + Collections.emptyList(), + List.of(driver(DRIVER_ID, null, PLATE), driver(9003L, null, ""))); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getDriverId()); + assertEquals("该手机号存在多条司机档案,请联系管理员核对", result.getMessage()); + verify(driverMapper, never()).insert(any(Driver.class)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("同手机号档案已属于其他账号:不覆盖,返回可读原因") + void rejectsMobileOwnedByAnotherAccount() { + when(driverMapper.selectList(any())).thenReturn( + Collections.emptyList(), + List.of(driver(DRIVER_ID, OTHER_USER_ID, PLATE))); + + DriverEnsureProfileVO result = service.ensureProfile(TENANT_ID, MP_USER_ID, MOBILE); + + assertFalse(result.getCreated()); + assertFalse(result.getBoundExisting()); + assertNull(result.getDriverId()); + assertEquals("该手机号已被其他账号占用,请联系管理员", result.getMessage()); + verify(driverMapper, never()).insert(any(Driver.class)); + verify(driverMapper, never()).updateById(any(Driver.class)); + } + + // ------------------------------------------------------------ 入参兜底 + + @Test + @DisplayName("租户为空且当前上下文也没有租户:拒绝写库") + void rejectsBlankTenant() { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getTenantId).thenReturn(null); + + assertThrows(ServiceException.class, () -> service.ensureProfile(null, MP_USER_ID, MOBILE)); + } + verify(driverMapper, never()).insert(any(Driver.class)); + } + + // ------------------------------------------------------------ 夹具 + + private Driver driver(Long id, Long userId, String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(id); + driver.setIsDeleted(0); + driver.setTenantId(TENANT_ID); + driver.setUserId(userId); + driver.setMobile(MOBILE); + driver.setDrivingVehicle(drivingVehicle); + return driver; + } + + 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); + } + +}