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