From 4a4b69024fc637d9fc09fb5c77c12cc3887aa03c Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 20:29:47 +0800 Subject: [PATCH 01/27] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E5=94=AF=E4=B8=80=E5=8C=B9=E9=85=8D=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E6=A1=A3=E6=A1=88=20ID=20=E7=9A=84=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/service/IDriverService.java | 12 ++ .../service/impl/DriverServiceImpl.java | 26 ++++ .../service/impl/DriverResolveMobileTest.java | 130 ++++++++++++++++++ 3 files changed, 168 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java index 73924e3e..d2962801 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverService.java @@ -111,4 +111,16 @@ public interface IDriverService extends BaseService { */ List exportDriver(Wrapper queryWrapper); + /** + * 按手机号解析司机档案 ID + *

+ * 归属主键是档案 ID,手机号只是派单时的号码快照:只有租户内唯一命中才认, + * 0 条(承运商司机、未登录过小程序的司机)与多条(历史脏数据)都返回 null,不抛异常。 + * + * @param tenantId 租户ID,为空时取当前登录租户 + * @param mobile 手机号 + * @return 司机档案ID;无法唯一确定时为 null + */ + Long resolveDriverIdByMobile(String tenantId, String mobile); + } 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 2d4dc598..fa3a1c80 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 @@ -25,6 +25,7 @@ */ package org.springblade.transport.service.impl; +import cn.hutool.core.util.DesensitizedUtil; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; @@ -171,6 +172,31 @@ public class DriverServiceImpl extends BaseServiceImpl imp return bindExistingProfile(existing.getId(), mpUserId); } + @Override + public Long resolveDriverIdByMobile(String tenantId, String mobile) { + String resolvedTenantId = trimToNull(tenantId); + if (resolvedTenantId == null) { + resolvedTenantId = trimToNull(AuthUtil.getTenantId()); + } + String mobileSnapshot = trimToNull(mobile); + // 脱敏手机号(如 138****8000)与空号都不是可用业务数据,查不到也不该查 + if (resolvedTenantId == null || mobileSnapshot == null || mobileSnapshot.contains("*")) { + return null; + } + // 手机号会被二次放号,同租户内可能残留多条历史档案:只有唯一命中才认,不猜 + List matchedDrivers = list(Wrappers.lambdaQuery() + .select(Driver::getId) + .eq(Driver::getTenantId, resolvedTenantId) + .eq(Driver::getMobile, mobileSnapshot)); + if (matchedDrivers.size() != 1) { + log.warn("司机档案解析未唯一命中,driver_id 留空。租户={} 手机号={} 命中={} 条", + resolvedTenantId, DesensitizedUtil.mobilePhone(mobileSnapshot), matchedDrivers.size()); + return null; + } + Driver matchedDriver = matchedDrivers.get(0); + return matchedDriver == null ? null : matchedDriver.getId(); + } + @Override @Transactional(rollbackFor = Exception.class) public DriverSyncMobileVO syncMobile(String tenantId, Long mpUserId, String oldPhone, String newPhone) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java new file mode 100644 index 00000000..01db1969 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java @@ -0,0 +1,130 @@ +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.springblade.system.feign.IMpUserClient; +import org.springblade.system.feign.ISysClient; +import org.springblade.transport.mapper.DriverMapper; +import org.springblade.transport.pojo.entity.Driver; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 手机号 -> 司机档案 ID 解析入口的测试。 + *

+ * 归属主键是档案 ID,手机号只是派单时的号码快照:只有租户内唯一命中才认,0 条与多条都不认。 + * 解析结果是"能不能写归属"的判断,不是业务校验,因此任何入参都只返回 null,不抛异常。 + */ +class DriverResolveMobileTest { + + private static final String TENANT_ID = "000000"; + private static final String MOBILE = "13800138000"; + + private DriverMapper driverMapper; + private DriverServiceImpl service; + + @BeforeAll + static void initTableInfo() { + 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); + service = new DriverServiceImpl(mock(IMpUserClient.class), mock(ISysClient.class)); + inject("baseMapper", driverMapper); + } + + @Test + @DisplayName("租户内唯一命中:返回档案 ID") + void returnsProfileIdWhenUnique() { + when(driverMapper.selectList(any())).thenReturn(List.of(profile(9001L))); + + assertEquals(9001L, service.resolveDriverIdByMobile(TENANT_ID, MOBILE)); + } + + @Test + @DisplayName("租户内没有档案:返回 null") + void returnsNullWhenAbsent() { + when(driverMapper.selectList(any())).thenReturn(Collections.emptyList()); + + assertNull(service.resolveDriverIdByMobile(TENANT_ID, MOBILE)); + } + + @Test + @DisplayName("租户内多条档案(历史同号):返回 null,不猜") + void returnsNullWhenMultiple() { + List profiles = new ArrayList<>(); + profiles.add(profile(9001L)); + profiles.add(profile(9002L)); + when(driverMapper.selectList(any())).thenReturn(profiles); + + assertNull(service.resolveDriverIdByMobile(TENANT_ID, MOBILE)); + } + + @Test + @DisplayName("手机号为空或脱敏串:返回 null,且不查库") + void returnsNullWithoutQueryForBlankOrMaskedMobile() { + assertNull(service.resolveDriverIdByMobile(TENANT_ID, null)); + assertNull(service.resolveDriverIdByMobile(TENANT_ID, " ")); + assertNull(service.resolveDriverIdByMobile(TENANT_ID, "138****8000")); + verify(driverMapper, never()).selectList(any()); + } + + @Test + @DisplayName("租户为空:取当前登录租户,仍不抛异常") + void fallsBackToCurrentTenant() { + when(driverMapper.selectList(any())).thenReturn(List.of(profile(9001L))); + + Long driverId = null; + try (var ignored = org.mockito.Mockito.mockStatic(org.springblade.core.secure.utils.AuthUtil.class)) { + ignored.when(org.springblade.core.secure.utils.AuthUtil::getTenantId).thenReturn(TENANT_ID); + driverId = service.resolveDriverIdByMobile(null, MOBILE); + } + assertEquals(9001L, driverId); + } + + private Driver profile(Long id) { + Driver profile = new Driver(); + profile.setId(id); + profile.setIsDeleted(0); + profile.setTenantId(TENANT_ID); + profile.setMobile(MOBILE); + return profile; + } + + 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); + } + +} From 50ad99370c382337ea35def6eaabd0bc02e42007 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 20:30:07 +0800 Subject: [PATCH 02/27] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E9=85=8D=E8=BD=BD=E5=8D=95=E5=9C=A8=E6=B4=BE=E5=8D=95=E3=80=81?= =?UTF-8?q?=E9=85=8D=E8=BD=BD=E3=80=81=E5=AF=BC=E5=85=A5=E6=97=B6=E5=86=99?= =?UTF-8?q?=E5=85=A5=E5=8F=B8=E6=9C=BA=E6=A1=A3=E6=A1=88=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/entity/LoadingManage.java | 3 + .../impl/LoadingManageServiceImpl.java | 15 ++ .../service/impl/MasterOrderServiceImpl.java | 12 +- .../impl/WaybillImportBatchServiceImpl.java | 28 ++- .../service/impl/WaybillServiceImpl.java | 14 +- .../impl/LoadingManageAdmissionTest.java | 2 + .../LoadingManageChildStatusWriteTest.java | 161 +++++++++++++++- .../service/impl/MasterOrderDriverIdTest.java | 120 ++++++++++++ .../service/impl/MasterOrderProgressTest.java | 4 +- .../impl/WaybillImportDriverIdTest.java | 121 ++++++++++++ .../impl/WaybillReassignDriverIdTest.java | 173 ++++++++++++++++++ 11 files changed, 640 insertions(+), 13 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java index e037a882..1a1283de 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/LoadingManage.java @@ -68,6 +68,9 @@ public class LoadingManage extends TenantEntity { @Schema(description = "联系电话") private String driverPhone; + @Schema(description = "司机ID") + private Long driverId; + @Schema(description = "押运人") private String escortName; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 0777be99..13b6252e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -29,6 +29,7 @@ import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.LoadingCarrierContractVO; import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; @@ -89,6 +90,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl selectLoadingManagePage(IPage page, LoadingManageVO loadingManage) { IPage entityPage = page(page, buildQuery(loadingManage)); @@ -319,6 +323,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl0 才算有主,0 / -1 都是「无主」的哨兵写法,落库统一为 NULL + loadingManage.setDriverId(resolvedDriverId != null && resolvedDriverId > 0 ? resolvedDriverId : null); loadingManage.setEscortName(TransportBusinessSupport.trimToNull(loadingManage.getEscortName())); loadingManage.setEscortPhone(TransportBusinessSupport.trimToNull(loadingManage.getEscortPhone())); loadingManage.setCarrierType(TransportBusinessSupport.trimToNull(loadingManage.getCarrierType())); @@ -1150,6 +1163,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl 0 ? resolvedDriverId : null; + } + waybill.setDriverId(driverId); waybill.setCaptainName(string(dispatch, "captainName")); waybill.setCabinNo(string(dispatch, "cabinNo")); waybill.setContainerNo(string(dispatch, "containerNo")); waybill.setEscortName(string(dispatch, "escortName")); waybill.setEscortPhone(string(dispatch, "escortPhone")); waybill.setMileage(nullableDecimal(dispatch, "mileage")); waybill.setDepartureName(string(dispatch, "departureName")); waybill.setDepartureAddress(string(dispatch, "departureAddress")); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index 61604120..f78533ab 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.common.excel.ImportFailureExcelUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; @@ -41,6 +42,7 @@ import org.springblade.transport.pojo.vo.WaybillImportBatchVO; import org.springblade.transport.pojo.vo.WaybillImportRowErrorVO; import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO; import org.springblade.transport.service.ICustomerArchiveService; +import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.service.ILoadingManageService; @@ -68,6 +70,7 @@ import java.util.regex.Pattern; import java.util.stream.Collectors; /** 运单批次服务实现。 */ +@Slf4j @Service @RequiredArgsConstructor public class WaybillImportBatchServiceImpl extends BaseServiceImpl implements IWaybillImportBatchService { @@ -101,6 +104,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl rowWaybills = new ArrayList<>(); for (int index = 0; index < rows.size(); index++) { try { - rowWaybills.add(buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft)); + Waybill builtWaybill = buildWaybill(rows.get(index), batch, request.getCarrierContractId(), draft); + fillDriverOwnership(builtWaybill, index + 1); + rowWaybills.add(builtWaybill); } catch (Exception exception) { throw new ServiceException("第" + (index + 1) + "行" + (draft ? "保存" : "导入") + "失败:" + exception.getMessage()); } @@ -485,6 +491,26 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl 0) { + return; + } + Long resolvedDriverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), waybill.getDriverPhone()); + if (resolvedDriverId != null && resolvedDriverId > 0) { + waybill.setDriverId(resolvedDriverId); + return; + } + // driver_id 只有 >0 才算有主,0 / -1 都按无主落空 + waybill.setDriverId(null); + log.warn("第{}行 运单 {} 的司机手机号未唯一匹配司机档案,driver_id 留空(不阻断导入)", + rowNo, waybill.getWaybillNo()); + } + private Waybill buildWaybill(Map row, WaybillImportBatch batch, Long carrierContractId, boolean draft) { if (row == null) throw new ServiceException("运单数据不正确"); Waybill waybill = new Waybill(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index bc263482..aa3d3290 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -61,6 +61,7 @@ import org.springblade.transport.pojo.vo.WaybillPunchPhotoVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; +import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IProjectApplyService; @@ -110,6 +111,9 @@ public class WaybillServiceImpl extends BaseServiceImpl @jakarta.annotation.Resource private ILoadingManageService loadingManageService; + @jakarta.annotation.Resource + private IDriverService driverService; + @jakarta.annotation.Resource private IProcessConfigService processConfigService; @@ -1610,7 +1614,15 @@ public class WaybillServiceImpl extends BaseServiceImpl TransportBusinessSupport.validateRequired(driverPhone, "手机号不能为空"); TransportBusinessSupport.validateRequired(vehicleNo, "车牌号不能为空"); - waybill.setDriverId(request.getDriverId()); + // 归属主键是档案 ID,手机号只是派单时的号码快照:调用方带了档案 ID 就用它, + // 否则按手机号解析;承运商司机、未建档司机解析为空,派单照旧成功,只留 WARN + Long driverId = request.getDriverId(); + if (driverId == null || driverId <= 0) { + Long resolvedDriverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), driverPhone); + // driver_id 只有 >0 才算有主,0 / -1 都是「无主」的哨兵写法,落库统一为 NULL + driverId = resolvedDriverId != null && resolvedDriverId > 0 ? resolvedDriverId : null; + } + waybill.setDriverId(driverId); waybill.setDriverName(driverName); waybill.setDriverPhone(driverPhone); waybill.setVehicleNo(vehicleNo); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java index a8eccbcd..b71ce064 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java @@ -19,6 +19,7 @@ import org.springblade.transport.pojo.entity.ContractManage; import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IDriverService; import java.lang.reflect.Field; import java.util.List; @@ -81,6 +82,7 @@ class LoadingManageAdmissionTest { inject("baseMapper", loadingManageMapper); inject("waybillMapper", waybillMapper); inject("contractManageService", contractManageService); + inject("driverService", mock(IDriverService.class)); authUtil = mockStatic(AuthUtil.class); authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID)); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java index 10dab615..6087f36b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java @@ -15,16 +15,19 @@ import org.mockito.ArgumentMatchers; import org.mockito.MockedStatic; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.transport.mapper.LoadingManageMapper; import org.springblade.transport.mapper.WaybillMapper; import org.springblade.transport.pojo.entity.LoadingManage; import org.springblade.transport.pojo.entity.ProcessConfig; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.util.regex.Matcher; import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; @@ -35,6 +38,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; @@ -60,6 +64,9 @@ class LoadingManageChildStatusWriteTest { private static final String LOADING_NO = "PZ-20261018-001"; private static final Long LOADING_ID = 1800000000000000001L; private static final String DRIVER_PHONE = "13800138000"; + private static final String TENANT_ID = "000000"; + private static final Long RESOLVED_DRIVER_ID = 9321L; + private static final Long SOURCE_LOADING_ID = 1800000000000000002L; private static final Long NEED_ACCEPT_WAYBILL_ID = 3101L; private static final Long NO_ACCEPT_WAYBILL_ID = 3102L; @@ -80,6 +87,7 @@ class LoadingManageChildStatusWriteTest { "carrier_type", "carrier_contract_id", "carrier_name", + "driver_id", "driver_name", "driver_phone", "vehicle_no", @@ -104,6 +112,8 @@ class LoadingManageChildStatusWriteTest { private IProcessConfigService processConfigService; private IContractManageService contractManageService; private IReceivablePayableDetailService receivablePayableDetailService; + private IDriverService driverService; + private LoadingManageMapper loadingManageMapper; private LoadingManageServiceImpl service; private MockedStatic authUtil; @@ -115,6 +125,7 @@ class LoadingManageChildStatusWriteTest { assistant.setCurrentNamespace("test"); TableInfoHelper.initTableInfo(assistant, Waybill.class); TableInfoHelper.initTableInfo(assistant, ProcessConfig.class); + TableInfoHelper.initTableInfo(assistant, LoadingManage.class); } @BeforeEach @@ -123,14 +134,19 @@ class LoadingManageChildStatusWriteTest { processConfigService = mock(IProcessConfigService.class); contractManageService = mock(IContractManageService.class); receivablePayableDetailService = mock(IReceivablePayableDetailService.class); + driverService = mock(IDriverService.class); + loadingManageMapper = mock(LoadingManageMapper.class); service = spy(new LoadingManageServiceImpl()); inject(service, "waybillMapper", waybillMapper); inject(service, "processConfigService", processConfigService); inject(service, "contractManageService", contractManageService); inject(service, "receivablePayableDetailService", receivablePayableDetailService); + inject(service, "driverService", driverService); + inject(service, "baseMapper", loadingManageMapper); doReturnTrue(service); authUtil = mockStatic(AuthUtil.class); authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID); + authUtil.when(AuthUtil::getTenantId).thenReturn(TENANT_ID); } @AfterEach @@ -309,6 +325,75 @@ class LoadingManageChildStatusWriteTest { verify(processConfigService, never()).list(ArgumentMatchers.>any()); } + @Test + @DisplayName("提交配载单:按手机号解析到档案 ID 时,配载单与子单一起记住归属") + void submitWritesResolvedDriverId() { + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID); + + assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID))); + + assertEquals(RESOLVED_DRIVER_ID, capturedLoading().getDriverId()); + assertEquals(RESOLVED_DRIVER_ID, statusSyncUpdates().get(0).setValue("driver_id"), + "子单必须与配载单写同一个归属,否则司机端按归属查不到这张票"); + } + + @Test + @DisplayName("解析不到档案(承运商司机):提交照常成功,归属留空") + void submitAllowsUnresolvedDriverId() { + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null); + + assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID))); + + assertNull(capturedLoading().getDriverId()); + assertNull(statusSyncUpdates().get(0).setValue("driver_id")); + } + + @Test + @DisplayName("进行中编辑保留的子单:任务字段更新也带上司机归属,改派后不留旧值") + void runningEditCarriesDriverIdToKeptWaybills() { + LoadingManage record = runningRecord(); + stubLoadingRecord(record); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID); + + assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID))); + + CapturedUpdate taskUpdate = waybillUpdates().stream() + .filter(update -> update.columns().contains("driver_name")) + .findFirst() + .orElseThrow(); + assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), taskUpdate.whereIds()); + assertEquals(RESOLVED_DRIVER_ID, taskUpdate.setValue("driver_id")); + } + + @Test + @DisplayName("复制配载单:沿用源单的司机归属,不按手机号重解析覆盖") + void copyKeepsSourceDriverId() { + LoadingManage source = copySource(); + source.setDriverId(555L); + stubCopyDependencies(source); + + service.copy(SOURCE_LOADING_ID); + + assertEquals(555L, capturedSaved().getDriverId()); + } + + @Test + @DisplayName("复制配载单:源单没有归属时,用手机号解析出的档案 ID") + void copyFallsBackToResolvedDriverId() { + LoadingManage source = copySource(); + stubCopyDependencies(source); + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID); + + service.copy(SOURCE_LOADING_ID); + + assertEquals(RESOLVED_DRIVER_ID, capturedSaved().getDriverId()); + } + // ------------------------------------------------------------ 夹具 private LoadingManage loadingForm(Long... waybillIds) { @@ -375,10 +460,46 @@ class LoadingManageChildStatusWriteTest { return waybill; } - private String capturedLoadingStatus() { + private LoadingManage runningRecord() { + LoadingManage record = loadingRecord("running", NEED_ACCEPT_WAYBILL_ID); + record.setVehicleNo("桂A12345"); + record.setDriverName("张三"); + record.setDriverPhone(DRIVER_PHONE); + return record; + } + + private LoadingManage copySource() { + LoadingManage source = loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID); + source.setLoadingSubNos("YD-" + NEED_ACCEPT_WAYBILL_ID); + source.setTransportType("公路运输"); + source.setVehicleNo("桂A12345"); + source.setDriverName("张三"); + source.setDriverPhone(DRIVER_PHONE); + source.setDepartureAddress("南宁"); + source.setArrivalAddress("广州"); + return source; + } + + private void stubCopyDependencies(LoadingManage source) { + source.setId(SOURCE_LOADING_ID); + doReturn(source).when(service).getById(SOURCE_LOADING_ID); + doReturn(null).when(service).detail(any()); + } + + private LoadingManage capturedSaved() { + ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingManage.class); + verify(service, atLeastOnce()).save(captor.capture()); + return captor.getValue(); + } + + private LoadingManage capturedLoading() { ArgumentCaptor captor = ArgumentCaptor.forClass(LoadingManage.class); verify(service, atLeastOnce()).saveOrUpdate(captor.capture()); - return captor.getValue().getBusinessStatus(); + return captor.getValue(); + } + + private String capturedLoadingStatus() { + return capturedLoading().getBusinessStatus(); } private void stubWaybills(Waybill... waybills) { @@ -438,18 +559,28 @@ class LoadingManageChildStatusWriteTest { } private static void inject(LoadingManageServiceImpl target, String fieldName, Object value) { - try { - Field field = LoadingManageServiceImpl.class.getDeclaredField(fieldName); - field.setAccessible(true); - field.set(target, value); - } catch (ReflectiveOperationException exception) { - throw new IllegalStateException("注入测试替身失败:" + fieldName, exception); + Class type = LoadingManageServiceImpl.class; + while (type != null) { + try { + Field field = type.getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } catch (ReflectiveOperationException exception) { + throw new IllegalStateException("注入测试替身失败:" + fieldName, exception); + } } + throw new IllegalStateException("注入测试替身失败:" + fieldName); } /** 一条被捕获的批量更新:SET 的列 → 值、WHERE 的运单 id */ private record CapturedUpdate(String sqlSet, String whereSql, Map params, List columns) { + private static final java.util.regex.Pattern PARAM_REF = + java.util.regex.Pattern.compile("#\\{ew\\.paramNameValuePairs\\.([^}]+)}"); + @SuppressWarnings("unchecked") static CapturedUpdate of(Wrapper wrapper) { LambdaUpdateWrapper update = (LambdaUpdateWrapper) wrapper; @@ -478,8 +609,20 @@ class LoadingManageChildStatusWriteTest { return null; } + /** 只认 WHERE 段用到的参数:SET 段的 driver_id 也是 Long,混进来会当成运单 id */ List whereIds() { - return params.values().stream().filter(Long.class::isInstance).map(Long.class::cast).toList(); + Set setParamKeys = new java.util.HashSet<>(); + Matcher matcher = PARAM_REF.matcher(sqlSet); + while (matcher.find()) { + setParamKeys.add(matcher.group(1)); + } + List ids = new java.util.ArrayList<>(); + for (Map.Entry entry : params.entrySet()) { + if (entry.getValue() instanceof Long longValue && !setParamKeys.contains(entry.getKey())) { + ids.add(longValue); + } + } + return ids; } } } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java new file mode 100644 index 00000000..8872dd01 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java @@ -0,0 +1,120 @@ +package org.springblade.transport.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.transport.pojo.entity.MasterOrder; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.ITransportPlanService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Method; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 多联总单派单建运单时写司机档案 ID 的测试。 + *

+ * 派单请求可能来自调度(带档案 ID),也可能只填了姓名手机号(手工派单、承运商司机), + * 后者按手机号解析;解析不到也不能挡住派单。 + */ +class MasterOrderDriverIdTest { + + private static final String DRIVER_PHONE = "13800138000"; + private static final Long RESOLVED_DRIVER_ID = 9321L; + + private IWaybillService waybillService; + private IDriverService driverService; + private MasterOrderServiceImpl service; + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + service = new MasterOrderServiceImpl(waybillService, mock(ITransportPlanService.class), + mock(IProjectApplyService.class), mock(IContractManageService.class), driverService); + when(waybillService.submit(any(Waybill.class))).thenReturn(true); + } + + @Test + @DisplayName("调度带了档案 ID:按它写,不再按手机号解析") + void keepsExplicitDriverId() throws Exception { + Map dispatch = dispatch(); + dispatch.put("driverId", 7001L); + + assertTrue(createWaybill(dispatch)); + + assertEquals(7001L, submittedWaybill().getDriverId()); + verify(driverService, never()).resolveDriverIdByMobile(any(), any()); + } + + @Test + @DisplayName("只填了手机号:派单时解析出档案 ID 写入运单") + void resolvesDriverIdFromPhone() throws Exception { + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID); + + assertTrue(createWaybill(dispatch())); + + assertEquals(RESOLVED_DRIVER_ID, submittedWaybill().getDriverId()); + } + + @Test + @DisplayName("解析不到档案(承运商司机):运单照常提交,归属留空") + void allowsDispatchWithoutProfile() throws Exception { + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null); + + assertTrue(createWaybill(dispatch())); + + assertNull(submittedWaybill().getDriverId()); + verify(waybillService).submit(any(Waybill.class)); + } + + // ------------------------------------------------------------ 夹具 + + private boolean createWaybill(Map dispatch) throws Exception { + MasterOrder masterOrder = new MasterOrder(); + masterOrder.setId(1L); + masterOrder.setMasterNo("ZD-20261027-001"); + masterOrder.setProjectId(2101L); + masterOrder.setProjectName("测试项目"); + Method method = MasterOrderServiceImpl.class + .getDeclaredMethod("createWaybill", MasterOrder.class, List.class); + method.setAccessible(true); + return (boolean) method.invoke(service, masterOrder, List.of(dispatch)); + } + + private Map dispatch() { + Map dispatch = new LinkedHashMap<>(); + dispatch.put("documentType", "运单"); + dispatch.put("transportType", "公路运输"); + dispatch.put("carrierType", "自运"); + dispatch.put("driverName", "张三"); + dispatch.put("driverPhone", DRIVER_PHONE); + dispatch.put("vehicleNo", "桂A12345"); + dispatch.put("quantity", 1); + dispatch.put("unitPrice", 100); + return dispatch; + } + + private Waybill submittedWaybill() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillService).submit(captor.capture()); + return captor.getValue(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java index f7d77747..aa52fec8 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderProgressTest.java @@ -14,6 +14,7 @@ import org.springblade.transport.pojo.entity.TransportPlan; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.MasterOrderVO; import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.ITransportPlanService; import org.springblade.transport.service.IWaybillService; @@ -67,7 +68,8 @@ class MasterOrderProgressTest { waybillService, transportPlanService, mock(IProjectApplyService.class), - mock(IContractManageService.class)); + mock(IContractManageService.class), + mock(IDriverService.class)); inject("baseMapper", masterOrderMapper); when(transportPlanService.list(anyWrapper())).thenReturn(List.of()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java new file mode 100644 index 00000000..d9581932 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java @@ -0,0 +1,121 @@ +package org.springblade.transport.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.system.feign.ISysClient; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.ICustomerArchiveService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ILoadingManageService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.service.ITransportPlanService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Method; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 批量导入运单时写司机档案 ID 的测试。 + *

+ * 导入模板可能带档案 ID(老系统导出),也可能只有司机姓名手机号。 + * 解析不到档案时只留行级 WARN,该行照常导入(承运商司机本来就没有小程序档案)。 + */ +class WaybillImportDriverIdTest { + + private static final String DRIVER_PHONE = "13800138000"; + private static final Long RESOLVED_DRIVER_ID = 9321L; + + private IDriverService driverService; + private WaybillImportBatchServiceImpl service; + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + service = new WaybillImportBatchServiceImpl( + mock(IWaybillService.class), + mock(ICustomerArchiveService.class), + mock(IProjectApplyService.class), + mock(ITransportPlanService.class), + mock(IReceivablePayableDetailService.class), + mock(ILoadingManageService.class), + mock(ISysClient.class), + driverService); + } + + @Test + @DisplayName("行内带了档案 ID:直接用,不再按手机号解析") + void keepsDriverIdFromRow() throws Exception { + Waybill waybill = importedWaybill(7001L); + + fillDriverOwnership(waybill, 3); + + assertEquals(7001L, waybill.getDriverId()); + verify(driverService, never()).resolveDriverIdByMobile(any(), any()); + } + + @Test + @DisplayName("行内只有手机号:按手机号解析出档案 ID 写入") + void resolvesDriverIdFromRowPhone() throws Exception { + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(RESOLVED_DRIVER_ID); + + Waybill waybill = importedWaybill(null); + fillDriverOwnership(waybill, 3); + + assertEquals(RESOLVED_DRIVER_ID, waybill.getDriverId()); + } + + @Test + @DisplayName("解析不到档案:该行照常导入,归属留空(只留行级 WARN)") + void keepsRowWithoutResolvedProfile() throws Exception { + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null); + + Waybill waybill = importedWaybill(null); + assertDoesNotThrow(() -> fillDriverOwnership(waybill, 3)); + + assertNull(waybill.getDriverId()); + } + + @Test + @DisplayName("解析器返回 0(无主哨兵):按无主落空,不把 0 写进库") + void normalizesSentinelToNull() throws Exception { + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(0L); + + Waybill waybill = importedWaybill(0L); + fillDriverOwnership(waybill, 3); + + assertNull(waybill.getDriverId()); + } + + // ------------------------------------------------------------ 夹具 + + private void fillDriverOwnership(Waybill waybill, int rowNo) throws Exception { + Method method = WaybillImportBatchServiceImpl.class.getDeclaredMethod("fillDriverOwnership", Waybill.class, int.class); + method.setAccessible(true); + try { + method.invoke(service, waybill, rowNo); + } catch (java.lang.reflect.InvocationTargetException exception) { + throw (Exception) exception.getCause(); + } + } + + private Waybill importedWaybill(Long driverId) { + Waybill waybill = new Waybill(); + waybill.setWaybillNo("YD-IMPORT-1"); + waybill.setDriverId(driverId); + waybill.setDriverName("张三"); + waybill.setDriverPhone(DRIVER_PHONE); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java new file mode 100644 index 00000000..6ce47414 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java @@ -0,0 +1,173 @@ +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.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; + +import java.lang.reflect.Field; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 重新派单写司机档案 ID 的测试。 + *

+ * 派单是司机归属的唯一入口:调用方带了档案 ID(>0)就按它写, + * 只带姓名手机号(承运商司机、手工派单)就按手机号解析;解析不到也不能挡住派单。 + */ +class WaybillReassignDriverIdTest { + + private static final Long WAYBILL_ID = 1900000000000000001L; + private static final String TENANT_ID = "000000"; + private static final String DRIVER_PHONE = "13800138000"; + private static final Long RESOLVED_DRIVER_ID = 9321L; + + private WaybillMapper waybillMapper; + private IDriverService driverService; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + driverService = mock(IDriverService.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("driverService", driverService); + inject("processConfigService", processConfigService()); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(pendingWaybill()); + when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1); + } + + @Test + @DisplayName("调用方带了档案 ID:按它写,不再按手机号解析") + void keepsExplicitDriverId() { + Waybill request = reassignRequest(7721L); + + assertTrue(service.reassignWithoutDeptCheck(request)); + + assertEquals(7721L, capturedWaybill().getDriverId()); + verify(driverService, never()).resolveDriverIdByMobile(any(), anyString()); + } + + @Test + @DisplayName("调用方只带手机号:按手机号解析出档案 ID 写入") + void resolvesDriverIdFromPhone() { + when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(RESOLVED_DRIVER_ID); + + assertTrue(service.reassignWithoutDeptCheck(reassignRequest(null))); + + assertEquals(RESOLVED_DRIVER_ID, capturedWaybill().getDriverId()); + } + + @Test + @DisplayName("档案 ID 为 0 或 -1(无主哨兵):按手机号重新解析") + void resolvesWhenDriverIdIsSentinel() throws Exception { + when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(RESOLVED_DRIVER_ID); + + assertTrue(service.reassignWithoutDeptCheck(reassignRequest(0L))); + assertEquals(RESOLVED_DRIVER_ID, capturedWaybill().getDriverId()); + + setUpAgain(); + assertTrue(service.reassignWithoutDeptCheck(reassignRequest(-1L))); + assertEquals(RESOLVED_DRIVER_ID, capturedWaybill().getDriverId()); + } + + @Test + @DisplayName("解析不到档案(承运商司机):派单照常成功,归属留空") + void allowsReassignWithoutResolvedProfile() { + when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(null); + + assertTrue(service.reassignWithoutDeptCheck(reassignRequest(null))); + + assertNull(capturedWaybill().getDriverId()); + } + + // ------------------------------------------------------------ 夹具 + + private void setUpAgain() throws Exception { + waybillMapper = mock(WaybillMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("driverService", driverService); + inject("processConfigService", processConfigService()); + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(pendingWaybill()); + when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1); + } + + private IProcessConfigService processConfigService() { + IProcessConfigService processConfigService = mock(IProcessConfigService.class); + when(processConfigService.listByIds(any())).thenReturn(Collections.emptyList()); + return processConfigService; + } + + private Waybill pendingWaybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setIsDeleted(0); + waybill.setTenantId(TENANT_ID); + waybill.setBusinessStatus("pending"); + waybill.setTransportType("road"); + waybill.setDriverName("张三"); + waybill.setDriverPhone("13900139000"); + waybill.setVehicleNo("桂A12345"); + return waybill; + } + + private Waybill reassignRequest(Long driverId) { + Waybill request = new Waybill(); + request.setId(WAYBILL_ID); + request.setDriverId(driverId); + request.setDriverName("李四"); + request.setDriverPhone(DRIVER_PHONE); + request.setVehicleNo("桂A88888"); + return request; + } + + private Waybill capturedWaybill() { + ArgumentCaptor captor = ArgumentCaptor.forClass(Waybill.class); + verify(waybillMapper, org.mockito.Mockito.atLeastOnce()).updateById(captor.capture()); + return captor.getValue(); + } + + 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); + } + +} From 625fbc5f042e0242497d9f6e2ea3eb8b4959ca1d Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 20:30:13 +0800 Subject: [PATCH 03/27] =?UTF-8?q?:card=5Ffile=5Fbox:=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E9=85=8D=E8=BD=BD=E5=8D=95=E5=8F=B8=E6=9C=BA=20ID=20=E5=88=97?= =?UTF-8?q?=E4=B8=8E=E8=BF=90=E5=8D=95=E9=85=8D=E8=BD=BD=E5=8D=95=E7=A7=9F?= =?UTF-8?q?=E6=88=B7=E5=8F=B8=E6=9C=BA=E7=B4=A2=E5=BC=95=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../blade_loading_manage_20260804.sql | 4 +- ...lade_loading_manage_driver_id_20261028.sql | 80 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 doc/sql/transport/blade_loading_manage_driver_id_20261028.sql diff --git a/doc/sql/transport/blade_loading_manage_20260804.sql b/doc/sql/transport/blade_loading_manage_20260804.sql index ffadac6b..4d10b9f4 100644 --- a/doc/sql/transport/blade_loading_manage_20260804.sql +++ b/doc/sql/transport/blade_loading_manage_20260804.sql @@ -21,6 +21,7 @@ CREATE TABLE IF NOT EXISTS `blade_loading_manage` ( `trailer_vehicle_no` varchar(100) DEFAULT NULL COMMENT '挂车车牌号', `driver_name` varchar(100) DEFAULT NULL COMMENT '司机', `driver_phone` varchar(50) DEFAULT NULL COMMENT '联系电话', + `driver_id` bigint(20) DEFAULT NULL COMMENT '司机ID', `escort_name` varchar(100) DEFAULT NULL COMMENT '押运人', `escort_phone` varchar(50) DEFAULT NULL COMMENT '押运人手机号', `carrier_type` varchar(50) DEFAULT NULL COMMENT '承运类型', @@ -50,7 +51,8 @@ CREATE TABLE IF NOT EXISTS `blade_loading_manage` ( UNIQUE KEY `uk_loading_manage_no` (`loading_no`) USING BTREE, KEY `idx_loading_manage_carrier_contract_id` (`carrier_contract_id`) USING BTREE, KEY `idx_loading_manage_dept` (`dept_id`) USING BTREE, - KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE + KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE, + KEY `idx_loading_manage_tenant_driver_id` (`tenant_id`, `driver_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配载管理'; INSERT IGNORE INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES diff --git a/doc/sql/transport/blade_loading_manage_driver_id_20261028.sql b/doc/sql/transport/blade_loading_manage_driver_id_20261028.sql new file mode 100644 index 00000000..1e828a9c --- /dev/null +++ b/doc/sql/transport/blade_loading_manage_driver_id_20261028.sql @@ -0,0 +1,80 @@ +-- ============================================================ +-- blade_loading_manage_driver_id_20261028.sql +-- ============================================================ +-- 运单/配载单写入司机档案 ID,把「归属」与「手机号快照」解耦: +-- blade_loading_manage.driver_id 配载单当前司机的司机档案 ID(blade_transport_driver.id) +-- blade_waybill.driver_id 已有列(本脚本只补索引),派单/配载/导入时写入 +-- 归属判据:driver_id > 0 才算有主;0、-1 与 NULL 一律等同「无主」(承运商司机、未登录过小程序的司机)。 +-- 历史数据不回填:手机号二次放号后按号反查会张冠李戴,旧单统一按无主处理,等下一次派单/配载再写入。 +-- 本脚本只做结构升级,不改任何业务数据;以下结构变更可重复执行。 + +-- ------------------------------------------------------------ +-- 0. 存档 +-- ------------------------------------------------------------ +-- 本脚本无数据订正,无需备份表。 + +-- ------------------------------------------------------------ +-- 1. 结构变更 +-- ------------------------------------------------------------ +-- ① blade_loading_manage.driver_id +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_loading_manage` ADD COLUMN `driver_id` bigint(20) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''司机ID'' AFTER `driver_phone`', + 'SELECT 1') + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_loading_manage' + AND COLUMN_NAME = 'driver_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ② blade_loading_manage 按租户 + 司机归属检索(司机端/归属巡检) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_loading_manage` ADD KEY `idx_loading_manage_tenant_driver_id` (`tenant_id`, `driver_id`) USING BTREE', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_loading_manage' + AND INDEX_NAME = 'idx_loading_manage_tenant_driver_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ③ blade_waybill 按租户 + 司机归属检索(列已存在,只补索引) +SET @ddl_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_waybill` ADD KEY `idx_waybill_tenant_driver_id` (`tenant_id`, `driver_id`) USING BTREE', + 'SELECT 1') + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'blade_waybill' + AND INDEX_NAME = 'idx_waybill_tenant_driver_id' +); +PREPARE ddl_stmt FROM @ddl_sql; +EXECUTE ddl_stmt; +DEALLOCATE PREPARE ddl_stmt; + +-- ------------------------------------------------------------ +-- 2. 校验 +-- ------------------------------------------------------------ +-- 列存在(期望 1 行) +-- SELECT COUNT(*) FROM information_schema.COLUMNS +-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_loading_manage' AND COLUMN_NAME = 'driver_id'; +-- 索引存在(期望 2 行) +-- SELECT TABLE_NAME, INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS +-- WHERE TABLE_SCHEMA = DATABASE() +-- AND INDEX_NAME IN ('idx_loading_manage_tenant_driver_id', 'idx_waybill_tenant_driver_id') +-- ORDER BY TABLE_NAME, SEQ_IN_INDEX; +-- 归属分布(历史单 driver_id 应为空) +-- SELECT COUNT(*) AS total, SUM(driver_id IS NOT NULL AND driver_id > 0) AS owned FROM blade_waybill; + +-- ------------------------------------------------------------ +-- 3. 回退 +-- ------------------------------------------------------------ +-- ALTER TABLE `blade_waybill` DROP INDEX `idx_waybill_tenant_driver_id`; +-- ALTER TABLE `blade_loading_manage` DROP INDEX `idx_loading_manage_tenant_driver_id`; +-- ALTER TABLE `blade_loading_manage` DROP COLUMN `driver_id`; From 9daf7e5be4e2d608a08e7610aeae6273aa5ea942 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 20:30:13 +0800 Subject: [PATCH 04/27] =?UTF-8?q?:white=5Fcheck=5Fmark:=20=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E5=8F=B8=E6=9C=BA=E6=94=B9=E5=8F=B7=E4=B8=8D=E5=9B=9E?= =?UTF-8?q?=E5=86=99=E5=8D=95=E6=8D=AE=E5=BF=AB=E7=85=A7=E7=9A=84=E5=AE=88?= =?UTF-8?q?=E5=8D=AB=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/service/impl/DriverSyncMobileTest.java | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java index 35568abb..16b484b9 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverSyncMobileTest.java @@ -105,6 +105,17 @@ class DriverSyncMobileTest { verify(driverMapper, never()).updateById(any(Driver.class)); } + @Test + @DisplayName("改号链路不持有运单/配载服务:换号只改档案,不回写单据上的手机号快照") + void phoneSyncDoesNotDependOnWaybillServices() { + for (Field field : DriverServiceImpl.class.getDeclaredFields()) { + String typeName = field.getType().getSimpleName(); + assertFalse(typeName.contains("Waybill") || typeName.contains("LoadingManage"), + "改号链路引入了 " + typeName + ":换号不得顺手回写运单/配载单的司机快照," + + "否则司机档案与历史单据的归属会一起漂移"); + } + } + @Test @DisplayName("档案 mobile 与账号旧号不一致:不动档案,返回可读原因(不抛异常)") void skipsWhenProfileMobileDiffersFromOldPhone() { From 4b489c00411fc0bdea3ad576d33cb2821ea682f1 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 20:35:49 +0800 Subject: [PATCH 05/27] =?UTF-8?q?:card=5Ffile=5Fbox:=20=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E9=85=8D=E8=BD=BD=E5=8D=95=E5=8F=B8=E6=9C=BA=20ID=20=E5=88=97?= =?UTF-8?q?=E4=B8=8A=E6=97=A0=E6=84=8F=E4=B9=89=E7=9A=84=20COLLATE=20?= =?UTF-8?q?=E5=AD=90=E5=8F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/sql/transport/blade_loading_manage_driver_id_20261028.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/sql/transport/blade_loading_manage_driver_id_20261028.sql b/doc/sql/transport/blade_loading_manage_driver_id_20261028.sql index 1e828a9c..73b762ba 100644 --- a/doc/sql/transport/blade_loading_manage_driver_id_20261028.sql +++ b/doc/sql/transport/blade_loading_manage_driver_id_20261028.sql @@ -19,7 +19,7 @@ -- ① blade_loading_manage.driver_id SET @ddl_sql = ( SELECT IF(COUNT(*) = 0, - 'ALTER TABLE `blade_loading_manage` ADD COLUMN `driver_id` bigint(20) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''司机ID'' AFTER `driver_phone`', + 'ALTER TABLE `blade_loading_manage` ADD COLUMN `driver_id` bigint(20) DEFAULT NULL COMMENT ''司机ID'' AFTER `driver_phone`', 'SELECT 1') FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() From 179444b1fd48327102f34e8536787739ce60a381 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 20:48:48 +0800 Subject: [PATCH 06/27] =?UTF-8?q?:sparkles:=20=E5=8F=B8=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E8=BF=90=E5=8D=95=E5=BD=92=E5=B1=9E=E6=94=B9=E6=8C=89=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E6=A1=A3=E6=A1=88=20ID=20=E6=94=B6=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 当前司机解析只按 user_id 查档案,删除按手机号回退匹配档案的分支(同号档案可互相冒充) - 列表、预览、三档计数、详情、打卡、接单、异常上报的收口判据改为 driver_id = 当前档案 id,且要求 driver_id > 0 - driver_phone 退回纯展示快照;已拒单排除与在途打卡豁免保持不变;运次侧留待票 04 - 四个司机端运单测试类改用档案 ID 夹具,补「改号后仍可用」「同号换人看不到」「无档案被拒」用例 --- .../impl/DriverWaybillServiceImpl.java | 88 +++++--------- .../impl/DriverTripAcceptRejectTest.java | 22 +++- .../impl/DriverWaybillOwnershipTest.java | 107 ++++++++++++------ .../impl/DriverWaybillRejectDetachTest.java | 34 +++++- .../impl/DriverWaybillTabStatusTest.java | 44 ++++++- 5 files changed, 193 insertions(+), 102 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index afd82e56..9bd09ed8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -166,12 +166,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO currentTask() { - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return null; } // 进行中:running,或无需确认接单但仍为 pending 的历史数据 - List candidates = waybillService.list(scopedQuery(phone) + List candidates = waybillService.list(scopedQuery(driver.getId()) .in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING)) .orderByDesc(Waybill::getUpdateTime) .last("LIMIT 20")); @@ -187,12 +187,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillPreviewVO pendingPreview(Integer size) { DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO(); - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return preview; } int limit = normalizePreviewSize(size); - List pendingList = waybillService.list(scopedQuery(phone) + List pendingList = waybillService.list(scopedQuery(driver.getId()) .in(Waybill::getBusinessStatus, PENDING_STATUSES) .orderByDesc(Waybill::getCreateTime)); List needAccept = pendingList.stream() @@ -207,11 +207,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillTabCountsVO tabCounts() { DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO(); - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return vo; } - List waybills = waybillService.list(scopedQuery(phone) + List waybills = waybillService.list(scopedQuery(driver.getId()) .in(Waybill::getBusinessStatus, TAB_ALL_STATUSES)); long pending = 0; long doing = 0; @@ -243,12 +243,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); Page empty = new Page<>(pageNo, pageSize); - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return empty; } - LambdaQueryWrapper wrapper = scopedQuery(phone); + LambdaQueryWrapper wrapper = scopedQuery(driver.getId()); // 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页 applyStatusFilterForQuery(wrapper, status); applyKeyword(wrapper, keyword); @@ -271,7 +271,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO detail(Long id) { - requireCurrentDriver(); Waybill waybill = loadDriverWaybill(id); return toCard(normalizeAcceptStatus(waybill), true); } @@ -355,7 +354,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public WaybillTrackVO track(Long id) { - requireCurrentDriver(); Waybill waybill = loadDriverWaybill(id); LocalDate[] range = resolveTrackDateRange(waybill); return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString()); @@ -822,13 +820,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (id == null) { throw new ServiceException("运单ID不能为空"); } - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { - throw new ServiceException("当前用户手机号为空,无法查找运单"); - } + Driver driver = requireCurrentDriver(); // MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后 String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"; - Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected) + Waybill waybill = waybillService.getOne(scopedQuery(driver.getId(), includeRejected) .eq(Waybill::getId, id).last(tail)); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); @@ -885,14 +880,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单司机手机号(driver_phone)等于当前用户手机号, + * 司机可见运单范围:运单归属档案(driver_id)就是当前司机档案, * 且该运单未被这个司机拒单。 *

+ * driver_id 必须有值且大于 0 才算有主:存量数据的 -1 是哨兵值,与 0 一样等同无主。 + * 运单上的 driver_phone 只是派单快照,不参与归属判定——司机改号不影响可见性。 + *

* 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。 * 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。 */ - private LambdaQueryWrapper scopedQuery(String phone) { - return scopedQuery(phone, false); + private LambdaQueryWrapper scopedQuery(Long driverId) { + return scopedQuery(driverId, false); } /** @@ -900,11 +898,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { * 拒单的运单不可能处于运输中,收口拦不到合法操作, * 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤 */ - private LambdaQueryWrapper scopedQuery(String phone, boolean includeRejected) { - String normalizedPhone = Func.toStr(phone, "").trim(); - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> w - .eq(Waybill::getDriverPhone, normalizedPhone) - .or().apply("REPLACE(IFNULL(driver_phone,''),' ','') = {0}", normalizedPhone)); + private LambdaQueryWrapper scopedQuery(Long driverId, boolean includeRejected) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() + .gt(Waybill::getDriverId, 0) + .eq(Waybill::getDriverId, driverId); if (!includeRejected) { wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); @@ -948,43 +945,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 当前登录司机:优先 userId,其次手机号(JWT 账号 / 小程序用户 account)匹配 driver.mobile + * 当前登录司机:只按登录用户 ID 查司机档案,查不到即「无归属」。 + *

+ * 曾经查不到档案时按手机号回退匹配 driver.mobile,这让两个同号档案可以互相冒充, + * 已删除:手机号在归属链路上不再有任何作用。 */ private Driver currentDriver() { Long userId = AuthUtil.getUserId(); if (userId == null || userId <= 0) { return null; } - Driver driver = driverService.getOne(Wrappers.lambdaQuery() + return driverService.getOne(Wrappers.lambdaQuery() .eq(Driver::getUserId, userId) .last("LIMIT 1")); - if (driver != null) { - return driver; - } - String phone = resolveSelfPhone(userId); - if (Func.isNotEmpty(phone)) { - return driverService.getOne(Wrappers.lambdaQuery() - .eq(Driver::getMobile, phone) - .orderByDesc(Driver::getUpdateTime) - .last("LIMIT 1")); - } - return null; - } - - /** - * 解析当前小程序用户可用于匹配司机档案的手机号 - */ - private String resolveSelfPhone(Long userId) { - String account = AuthUtil.getUserAccount(); - if (isMobileLike(account)) { - return account.trim(); - } - R result = mpUserClient.userById(userId); - if (result != null && R.isSuccess(result) && result.getData() != null - && isMobileLike(result.getData().getAccount())) { - return result.getData().getAccount().trim(); - } - return Func.isEmpty(account) ? null : account.trim(); } private boolean isMobileLike(String value) { @@ -1308,8 +1281,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public void assertCurrentDriverOwnsWaybill(Long waybillId) { - // 归属口径与司机打卡、司机接单一致:按当前用户手机号匹配运单司机手机号 - requireCurrentDriver(); + // 归属口径与司机打卡、司机接单一致:运单 driver_id 必须是当前登录司机的档案(查不到档案即无归属) loadDriverWaybill(waybillId); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java index 05c3af55..4b18c922 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java @@ -57,7 +57,10 @@ class DriverTripAcceptRejectTest { private static final Long USER_ID = 7401L; private static final Long DRIVER_ID = 9001L; - private static final String PHONE = "17587421208"; + /** 派单时落在运次上的号码快照。 */ + private static final String SNAPSHOT_PHONE = "17587421208"; + /** 司机改号后档案与登录账号上的号码。 */ + private static final String CURRENT_PHONE = "17687421208"; private static final String TRIP_ID = "2101146192739459074"; private static final Long PENDING_ID = 2101144166395699202L; private static final Long SECOND_ID = 2101145869819994113L; @@ -121,7 +124,8 @@ class DriverTripAcceptRejectTest { "两张子运单与接单司机一起下发:" + capturedUpdateParams()); assertTrue(capturedWhereSql().contains("(id IN ("), capturedWhereSql()); assertEquals(TRIP_ID, capturedQuery().getTripId()); - assertEquals(PHONE, capturedQuery().getDriverPhone(), "归属仍按司机手机号收口"); + assertEquals(CURRENT_PHONE, capturedQuery().getDriverPhone(), + "运次侧本票不动:仍按档案当前手机号收口(票 04 统一改成档案 ID)"); } @Test @@ -340,6 +344,16 @@ class DriverTripAcceptRejectTest { verify(waybillService, never()).update(any()); } + @Test + @DisplayName("没有司机档案的账号不能运次接单:不按手机号回退、不提供代报") + void tripAcceptWithoutDriverProfileIsRefused() { + when(driverService.getOne(any())).thenReturn(null); + + assertEquals("未找到当前登录司机档案", assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage()); + verify(waybillService, never()).update(any()); + } + // ------------------------------------------------------------ 夹具 private T runAsDriver(Supplier action) { @@ -373,7 +387,7 @@ class DriverTripAcceptRejectTest { private void stubTrip(TripVO.TripWaybillVO[] leading, TripVO.TripWaybillVO... children) { TripVO trip = new TripVO(); trip.setId(TRIP_ID); - trip.setDriverPhone(PHONE); + trip.setDriverPhone(SNAPSHOT_PHONE); List all = new java.util.ArrayList<>(List.of(leading)); all.addAll(List.of(children)); trip.setWaybills(all); @@ -400,7 +414,7 @@ class DriverTripAcceptRejectTest { driver.setUserId(USER_ID); driver.setDriverName("黄宇富"); driver.setDrivingVehicle("桂A32523"); - driver.setMobile(PHONE); + driver.setMobile(CURRENT_PHONE); return driver; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index 7f3f6cae..ad19610d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -27,26 +27,34 @@ import org.springblade.transport.support.CertificationGate; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; 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.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; /** * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 *

- * 口径:当前用户手机号匹配运单 {@code driverPhone}。 + * 口径:登录用户按 {@code user_id} 解析到司机档案,运单归属键是运单上的 {@code driver_id}; + * {@code driver_phone} 只是派单快照,不参与判定。 * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 */ class DriverWaybillOwnershipTest { private static final Long WAYBILL_ID = 5301L; private static final Long USER_ID = 7001L; - private static final String PHONE = "13800138000"; + private static final Long DRIVER_ID = 9001L; + private static final Long OTHER_DRIVER_ID = 9002L; + /** 派单时落在运单上的号码快照。 */ + private static final String SNAPSHOT_PHONE = "13800138000"; + /** 司机改号后档案与登录账号上的号码。 */ + private static final String CURRENT_PHONE = "13900139000"; private IDriverService driverService; private IWaybillService waybillService; @@ -58,6 +66,7 @@ class DriverWaybillOwnershipTest { MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); assistant.setCurrentNamespace("test"); TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, Driver.class); } @BeforeEach @@ -91,33 +100,26 @@ class DriverWaybillOwnershipTest { } @Test - @DisplayName("司机档案与登录态都没有手机号时被拒") - void withoutPhoneIsRejected() { - Driver driver = driver(null); - when(driverService.getOne(any())).thenReturn(driver); + @DisplayName("司机档案只按登录用户 ID 解析,不按手机号回退") + void driverProfileResolvedByUserIdOnly() { + when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE)); + when(waybillService.getOne(any())).thenReturn(waybill()); - ServiceException exception = assertThrows(ServiceException.class, - () -> runOwnershipCheck(WAYBILL_ID)); + assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); - assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage()); + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(driverService, times(1)).getOne(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + assertTrue(wrapper.getTargetSql().contains("user_id"), + "司机档案必须按登录用户 ID 解析:" + wrapper.getTargetSql()); + assertFalse(wrapper.getTargetSql().contains("mobile"), + "司机档案不允许再按手机号回退:" + wrapper.getTargetSql()); } @Test - @DisplayName("运单司机手机号与当前用户不一致时被拒") - void waybillOfAnotherPhoneIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PHONE)); - when(waybillService.getOne(any())).thenReturn(null); - - ServiceException exception = assertThrows(ServiceException.class, - () -> runOwnershipCheck(WAYBILL_ID)); - - assertEquals("运单不存在或无权操作", exception.getMessage()); - } - - @Test - @DisplayName("本人手机号运单放行,且查询确实按司机手机号收敛") - void ownWaybillPasses() { - when(driverService.getOne(any())).thenReturn(driver(PHONE)); + @DisplayName("司机改号后本人运单仍放行,归属只认 driver_id") + void ownWaybillPassesAfterNumberChange() { + when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE)); when(waybillService.getOne(any())).thenReturn(waybill()); assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); @@ -125,17 +127,55 @@ class DriverWaybillOwnershipTest { ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(waybillService).getOne(captor.capture()); LambdaQueryWrapper wrapper = captor.getValue(); - assertTrue(wrapper.getTargetSql().contains("driver_phone"), - "归属查询必须落在运单司机手机号上:" + wrapper.getTargetSql()); - assertTrue(wrapper.getParamNameValuePairs().values().stream() - .anyMatch(PHONE::equals), - "归属查询必须带上当前用户手机号:" + wrapper.getParamNameValuePairs()); + // 护栏:归属查询必须落在运单 driver_id 上,不能再出现手机号比较,否则改号即失效。 + assertTrue(wrapper.getTargetSql().contains("driver_id"), + "归属查询必须落在运单司机档案 ID 上:" + wrapper.getTargetSql()); + assertFalse(wrapper.getTargetSql().contains("driver_phone"), + "归属查询不能再比较运单司机手机号快照:" + wrapper.getTargetSql()); + assertTrue(wrapper.getParamNameValuePairs().values().contains(DRIVER_ID), + "归属查询必须带上当前司机档案 ID:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getParamNameValuePairs().values().contains(SNAPSHOT_PHONE), + "快照号不参与归属判定:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getParamNameValuePairs().values().contains(CURRENT_PHONE), + "档案手机号不参与归属判定:" + wrapper.getParamNameValuePairs()); + } + + @Test + @DisplayName("同号换人:同手机号但不同档案看不到对方的运单") + void waybillOwnedByAnotherDriverWithSamePhoneIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE)); + // 库里存在该运单,但归属档案是另一个同号档案,按 driver_id 收口后查不到。 + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).getOne(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + // 参数槽由 SQL 片段构建时填充:不先取一次片段,参数表是空的 + wrapper.getTargetSql(); + assertTrue(wrapper.getParamNameValuePairs().values().contains(DRIVER_ID), + "收口条件必须是当前档案 ID:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getParamNameValuePairs().values().contains(OTHER_DRIVER_ID), + "不能把别人的档案 ID 当查询条件:" + wrapper.getParamNameValuePairs()); + } + + @Test + @DisplayName("司机档案没有手机号也能按档案 ID 认领自己的运单") + void profileWithoutPhoneStillOwnsByDriverId() { + when(driverService.getOne(any())).thenReturn(driver(null)); + when(waybillService.getOne(any())).thenReturn(waybill()); + + assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); } @Test @DisplayName("运单ID为空时被拒") void withoutWaybillIdIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PHONE)); + when(driverService.getOne(any())).thenReturn(driver(CURRENT_PHONE)); ServiceException exception = assertThrows(ServiceException.class, () -> runOwnershipCheck(null)); @@ -148,14 +188,14 @@ class DriverWaybillOwnershipTest { private void runOwnershipCheck(Long waybillId) { try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); - authUtil.when(AuthUtil::getUserAccount).thenReturn(PHONE); + authUtil.when(AuthUtil::getUserAccount).thenReturn(CURRENT_PHONE); service.assertCurrentDriverOwnsWaybill(waybillId); } } private Driver driver(String mobile) { Driver driver = new Driver(); - driver.setId(9001L); + driver.setId(DRIVER_ID); driver.setUserId(USER_ID); driver.setDriverName("韦司机"); driver.setMobile(mobile); @@ -166,7 +206,8 @@ class DriverWaybillOwnershipTest { Waybill waybill = new Waybill(); waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610070001"); - waybill.setDriverPhone(PHONE); + waybill.setDriverId(DRIVER_ID); + waybill.setDriverPhone(SNAPSHOT_PHONE); waybill.setIsDeleted(0); return waybill; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 49502666..228d5800 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -34,6 +34,7 @@ import org.springblade.transport.support.CertificationGate; import java.util.List; import java.util.function.Supplier; +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.junit.jupiter.api.Assertions.assertTrue; @@ -58,7 +59,10 @@ class DriverWaybillRejectDetachTest { private static final Long USER_ID = 7401L; private static final Long DRIVER_ID = 9001L; private static final String PLATE = "沪AE31312"; - private static final String PHONE = "13800138002"; + /** 派单时落在运单上的号码快照。 */ + private static final String SNAPSHOT_PHONE = "13800138002"; + /** 司机改号后档案与登录账号上的号码。 */ + private static final String CURRENT_PHONE = "13900139002"; private static final Long REJECTED_ID = 7201L; private IDriverService driverService; @@ -155,6 +159,29 @@ class DriverWaybillRejectDetachTest { assertEquals(2, page.getRecords().size()); } + @Test + @DisplayName("司机改号后运单仍可见,卡片仍展示派单时的号码快照") + void numberChangeKeepsWaybillsVisible() { + // 同一个档案:档案与登录账号已换成新号,运单快照还是派单时的旧号 + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(waybill(7202L, "YD-20261001-0002", "pending", "pending"))); + + IPage page = runAsDriver(() -> service.page(1, 10, null, null)); + + assertEquals(1, page.getTotal(), "改号不影响本人运单的可见性"); + assertEquals(SNAPSHOT_PHONE, page.getRecords().get(0).getDriverPhone(), + "卡片展示的仍是运单上的号码快照,不改成本人新号"); + } + + @Test + @DisplayName("重新派回后运单恢复可见:归属看新的 driver_id,不看历史拒单") + void reassignedWaybillBecomesVisibleAgain() { + when(waybillService.getOne(any())) + .thenReturn(waybill(REJECTED_ID, "YD-20261010-0001", "pending", "pending")); + + assertDoesNotThrow(() -> runAsDriver(() -> service.detail(REJECTED_ID))); + } + @Test @DisplayName("已拒单运单的详情不可达:取单条件已把它挡住") void detailOfRejectedIsUnreachable() { @@ -218,7 +245,7 @@ class DriverWaybillRejectDetachTest { driver.setUserId(USER_ID); driver.setDriverName("黄大富"); driver.setDrivingVehicle(PLATE); - driver.setMobile(PHONE); + driver.setMobile(CURRENT_PHONE); return driver; } @@ -231,7 +258,8 @@ class DriverWaybillRejectDetachTest { waybill.setId(id); waybill.setWaybillNo(no); waybill.setVehicleNo(PLATE); - waybill.setDriverPhone(PHONE); + waybill.setDriverId(DRIVER_ID); + waybill.setDriverPhone(SNAPSHOT_PHONE); waybill.setBusinessStatus(businessStatus); waybill.setDriverAcceptStatus(acceptStatus); waybill.setIsDeleted(0); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java index 1789c892..256eff6b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -35,6 +35,7 @@ import java.util.List; import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; @@ -51,8 +52,12 @@ class DriverWaybillTabStatusTest { private static final Long WAYBILL_ID = 5901L; private static final Long USER_ID = 7001L; + private static final Long DRIVER_ID = 9001L; private static final String PLATE = "桂AAW3256"; - private static final String PHONE = "13800138002"; + /** 派单时落在运单上的号码快照。 */ + private static final String SNAPSHOT_PHONE = "13800138002"; + /** 司机改号后档案与登录账号上的号码。 */ + private static final String CURRENT_PHONE = "13900139002"; private IWaybillService waybillService; private IDriverService driverService; @@ -141,6 +146,27 @@ class DriverWaybillTabStatusTest { assertEquals(1, counts.getAll()); } + @Test + @DisplayName("司机改号后三档计数不变,计数查询按 driver_id 收口") + void tabCountsAfterNumberChangeScopedByDriverId() { + DriverWaybillTabCountsVO counts = runAsDriver(service::tabCounts); + + assertEquals(1, counts.getDoing(), "改号不影响本人运单的分档"); + assertEquals(1, counts.getAll()); + + LambdaQueryWrapper wrapper = firstQueriedWrapper(); + // 护栏:计数查询必须落在运单 driver_id 上;比对手机号的话,改号后司机的数字会清零 + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("driver_id"), "计数查询必须按司机档案 ID 收口:" + sql); + assertFalse(sql.contains("driver_phone"), "计数查询不能再比较运单手机号快照:" + sql); + assertTrue(wrapper.getParamNameValuePairs().values().contains(DRIVER_ID), + "计数查询必须带上当前司机档案 ID:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getParamNameValuePairs().values().contains(SNAPSHOT_PHONE), + "快照号不参与计数:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getParamNameValuePairs().values().contains(CURRENT_PHONE), + "档案手机号不参与计数:" + wrapper.getParamNameValuePairs()); + } + // ------------------------------------------------------------ 夹具 private T runAsDriver(Supplier action) { @@ -167,6 +193,15 @@ class DriverWaybillTabStatusTest { return statuses; } + @SuppressWarnings("unchecked") + private LambdaQueryWrapper firstQueriedWrapper() { + assertEquals(1, queried.size(), "一次列表查询"); + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) queried.get(0); + // 参数槽由 SQL 片段构建时填充:不先取一次片段,参数表是空的 + wrapper.getSqlSegment(); + return wrapper; + } + @SuppressWarnings("unchecked") private static Wrapper anyWrapper() { return org.mockito.ArgumentMatchers.>any(); @@ -177,7 +212,8 @@ class DriverWaybillTabStatusTest { waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610080002"); waybill.setVehicleNo(PLATE); - waybill.setDriverPhone(PHONE); + waybill.setDriverId(DRIVER_ID); + waybill.setDriverPhone(SNAPSHOT_PHONE); waybill.setBusinessStatus(WaybillProcessSupport.STATUS_PENDING_RETURN_CONFIRM); waybill.setIsDeleted(0); return waybill; @@ -185,10 +221,10 @@ class DriverWaybillTabStatusTest { private Driver driver() { Driver driver = new Driver(); - driver.setId(9001L); + driver.setId(DRIVER_ID); driver.setUserId(USER_ID); driver.setDrivingVehicle(PLATE); - driver.setMobile(PHONE); + driver.setMobile(CURRENT_PHONE); return driver; } From 3ff2c829383a2eb2c56c803d960be781f4681534 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 20:55:03 +0800 Subject: [PATCH 07/27] =?UTF-8?q?:sparkles:=20=E5=8F=B8=E6=9C=BA=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=20ID=20=E5=AD=98=E9=87=8F=E5=9B=9E=E5=A1=AB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E4=B8=8E=E6=96=87=E6=9C=AC=E6=8A=A4=E6=A0=8F=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/DriverIdBackfillSqlTest.java | 289 ++++++++++++++++++ ...ing_manage_driver_id_backfill_20261028.sql | 238 +++++++++++++++ ...de_waybill_driver_id_backfill_20261028.sql | 241 +++++++++++++++ 3 files changed, 768 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/support/DriverIdBackfillSqlTest.java create mode 100644 doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql create mode 100644 doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/DriverIdBackfillSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/DriverIdBackfillSqlTest.java new file mode 100644 index 00000000..968f1671 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/DriverIdBackfillSqlTest.java @@ -0,0 +1,289 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * 存量司机档案 ID 回填脚本的文本护栏。 + *

+ * 仓库里没有连库的测试基座,回填脚本的正确性只能靠「读脚本、断言不变量」来守。 + * 这里守的是「改脚本时最容易写坏、写坏了又不会报错」的几条: + *

    + *
  • 只填 {@code driver_id IS NULL} 的行 —— 手滑把条件放宽成 {@code <= 0} 就会覆盖 + * {@code -1}(无主哨兵)和已经派给别人的单。
  • + *
  • {@code driver_id = -1} 必须有一道显式排除 —— 只靠 {@code IS NULL} 挡住是隐式的, + * 后人看不出「-1 不能动」是需求,容易在重构时丢掉。
  • + *
  • 跨表比较必须显式 {@code COLLATE} —— 运单/配载单侧是 {@code utf8mb4_0900_ai_ci}, + * 司机档案侧是 {@code utf8mb4_general_ci},漏了就在执行时抛 + * {@code ERROR 1267 Illegal mix of collations},而这只有真跑库才会暴露。
  • + *
  • 不许 {@code SET driver_phone} / {@code SET update_time} —— 手机号是派单时的号码快照, + * 回填是「补归属」不是「改快照」;{@code update_time} 一改,业务上就分不清这张单是不是 + * 真的被人动过。
  • + *
  • 唯一命中({@code COUNT(*) = 1})与可重复执行({@code IF NOT EXISTS} / {@code INSERT IGNORE} + * / {@code IS NULL} 兜底)—— 同号多档案时填空会把单挂错人,重复跑必须零变更。
  • + *
+ * 这些断言只在脚本文本层面成立,不证明数据结果正确;数据结果由脚本第 2 节校验负责。 + */ +class DriverIdBackfillSqlTest { + + /** + * 每个用例跑两张表:运单一份、配载单一份,规则完全相同。 + */ + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("只更新 driver_id 为 NULL 的行") + void onlyFillsNullDriverId(String fileName, String table) { + List updates = updateStatements(script(fileName)); + assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句:回填脚本必须真的写 driver_id"); + + for (String update : updates) { + String where = whereClause(update).toUpperCase(); + assertTrue(where.contains("DRIVER_ID IS NULL"), + table + " 的 UPDATE 缺少 driver_id IS NULL:" + + "条件一放宽就会覆盖 -1(无主哨兵)与已派单的行。原文:" + squeeze(update)); + assertTrue(setClause(update).toUpperCase().contains("DRIVER_ID"), + table + " 的 UPDATE 没有写 driver_id:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("driver_id = -1 的哨兵行有一道显式排除") + void keepsSentinelRowsUntouched(String fileName, String table) { + List updates = updateStatements(script(fileName)); + assertFalse(updates.isEmpty(), fileName + " 里找不到 UPDATE 语句"); + + for (String update : updates) { + String where = whereClause(update); + assertTrue(where.contains("-1"), + table + " 的 UPDATE 没有出现 -1 保护:哨兵行会被回填覆盖。原文:" + squeeze(update)); + assertTrue(where.toUpperCase().contains("IS NOT TRUE") || where.toUpperCase().contains("NOT ("), + table + " 的 UPDATE 里 -1 只是一笔带过(例如 driver_id <> -1)," + + "对 NULL 行的比较结果是 NULL、条件不成立,要填的行会被整批漏掉。原文:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("跨表比较手机号与租户时显式 COLLATE") + void comparesAcrossTablesWithExplicitCollate(String fileName, String table) { + for (String line : body(fileName).split("\\R")) { + String trimmed = line.trim(); + if (trimmed.isEmpty() || !trimmed.contains("=")) { + continue; + } + boolean tenantComparison = countOf(trimmed, ".tenant_id") >= 2; + boolean mobileComparison = trimmed.contains(".mobile") && trimmed.contains("driver_phone"); + if (!tenantComparison && !mobileComparison) { + continue; + } + assertTrue(trimmed.toUpperCase().contains("COLLATE"), + table + " 的跨表比较没有显式 COLLATE(会抛 ERROR 1267 Illegal mix of collations):" + trimmed); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("不改写 driver_phone 快照") + void neverRewritesPhoneSnapshot(String fileName, String table) { + for (String update : updateStatements(script(fileName))) { + assertFalse(setClause(update).toUpperCase().contains("DRIVER_PHONE"), + table + " 的 UPDATE 改写了 driver_phone:手机号是派单时的号码快照,回填只补归属。原文:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("不改写 update_time") + void neverRewritesUpdateTime(String fileName, String table) { + for (String update : updateStatements(script(fileName))) { + assertFalse(setClause(update).toUpperCase().contains("UPDATE_TIME"), + table + " 的 UPDATE 改写了 update_time:回填不是业务动作,改了就看不出单据是否真被人动过。原文:" + squeeze(update)); + } + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("同号多档案不填:命中映射要求唯一") + void requiresUniqueMobileMatch(String fileName, String table) { + String body = body(fileName); + assertTrue(Pattern.compile("(?i)HAVING\\s+COUNT\\(\\*\\)\\s*=\\s*1").matcher(body).find(), + table + " 的回填映射没有 HAVING COUNT(*) = 1:同号多档案时会把单挂到其中一条档案上"); + assertTrue(Pattern.compile("(?i)COUNT\\(DISTINCT\\s+d\\.id\\)\\s*>\\s*1").matcher(body).find(), + table + " 没有同号多档案的冲突清单:跳过多少、为什么跳过必须可复核"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("可重复执行:存档表与命中映射都带重复执行保护") + void isRerunnable(String fileName, String table) { + String body = body(fileName); + assertTrue(Pattern.compile("(?i)CREATE\\s+TABLE\\s+IF\\s+NOT\\s+EXISTS").matcher(body).find(), + table + " 的存档表没有 IF NOT EXISTS:第二次执行会报 1050 Table already exists,跑不完"); + assertTrue(Pattern.compile("(?i)INSERT\\s+IGNORE").matcher(body).find(), + table + " 的命中映射没有 INSERT IGNORE:第二次执行会撞主键,跑不完"); + + String insert = statement(body, "INSERT IGNORE"); + assertFalse(insert.isEmpty(), table + " 的 INSERT IGNORE 不是一条完整语句"); + assertTrue(insert.toUpperCase().contains("DRIVER_ID IS NULL"), + table + " 的命中映射没有 driver_id IS NULL 兜底:重复执行会给已经填过的行反复建映射"); + assertTrue(body.contains("ROW_COUNT()"), + table + " 的分批循环没有 ROW_COUNT() 退出条件:会死循环或只跑第一批"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("分批执行:循环里带 LIMIT 的批次大小") + void batchesInBoundedChunks(String fileName, String table) { + String body = body(fileName); + assertTrue(body.contains("REPEAT") || body.contains("WHILE"), + table + " 的回填没有分批循环:一次性 UPDATE 全表会长时间锁行"); + assertTrue(Pattern.compile("(?i)LIMIT\\s+(\\d+|v_\\w+)").matcher(body).find(), + table + " 的 LIMIT 后面不是批次大小的数字或变量:不是真正的分批"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("脚本按目录惯例分了 0 存档 / 1 订正 / 2 校验 / 3 回退") + void hasRequiredSections(String fileName, String table) { + String script = script(fileName); + assertTrue(script.contains("-- 0. 存档"), table + " 缺 -- 0. 存档 段"); + assertTrue(script.contains("-- 1. 订正"), table + " 缺 -- 1. 订正 段"); + assertTrue(script.contains("-- 2. 校验"), table + " 缺 -- 2. 校验 段"); + assertTrue(script.contains("-- 3. 回退"), table + " 缺 -- 3. 回退 段"); + } + + @ParameterizedTest(name = "{0}") + @CsvSource({ + "blade_waybill_driver_id_backfill_20261028.sql, blade_waybill", + "blade_loading_manage_driver_id_backfill_20261028.sql, blade_loading_manage" + }) + @DisplayName("取数与写入全在 SQL 内:不经过 JSON 中转") + void keepsSnowflakeIdsInsideSql(String fileName, String table) { + String body = body(fileName).toUpperCase(); + assertFalse(body.contains("JSON_"), + table + " 的脚本用了 JSON 函数中转:19 位雪花 ID 过 JSON 会丢精度,取数与写入必须在 SQL 内完成"); + } + + private static String script(String fileName) { + Path path = locate(fileName); + try { + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException exception) { + throw new UncheckedIOException("读不了回填脚本:" + path, exception); + } + } + + private static Path locate(String fileName) { + List candidates = List.of( + Path.of("..", "..", "doc", "sql", "transport", fileName), + Path.of("doc", "sql", "transport", fileName)); + List tried = new ArrayList<>(); + for (Path candidate : candidates) { + if (Files.isRegularFile(candidate)) { + return candidate; + } + tried.add(candidate.toAbsolutePath().normalize().toString()); + } + fail("找不到回填脚本 " + fileName + "。surefire 的工作目录是模块目录(blade-service/blade-transport)," + + "脚本应在 ../../doc/sql/transport/ 下。已尝试:" + String.join(";", tried)); + return null; + } + + private static List updateStatements(String script) { + List updates = new ArrayList<>(); + Matcher matcher = Pattern.compile("(?is)\\bUPDATE\\b.*?;").matcher(stripComments(script)); + while (matcher.find()) { + updates.add(matcher.group()); + } + return updates; + } + + private static String statement(String body, String prefix) { + Matcher matcher = Pattern.compile("(?is)\\b" + Pattern.quote(prefix) + "\\b.*?;").matcher(body); + return matcher.find() ? matcher.group() : ""; + } + + private static String setClause(String update) { + String upper = update.toUpperCase(); + int set = upper.indexOf(" SET "); + if (set < 0) { + fail("UPDATE 里没有 SET:" + squeeze(update)); + } + int where = upper.indexOf(" WHERE ", set); + return where < 0 ? update.substring(set) : update.substring(set, where); + } + + private static String whereClause(String update) { + int where = update.toUpperCase().indexOf(" WHERE "); + if (where < 0) { + fail("UPDATE 里没有 WHERE:" + squeeze(update)); + } + return update.substring(where); + } + + private static String body(String fileName) { + return stripComments(script(fileName)); + } + + private static String stripComments(String script) { + return script.replaceAll("(?s)/\\*.*?\\*/", " ").replaceAll("--[^\\r\\n]*", " "); + } + + private static int countOf(String text, String needle) { + int count = 0; + int index = text.indexOf(needle); + while (index >= 0) { + count++; + index = text.indexOf(needle, index + needle.length()); + } + return count; + } + + private static String squeeze(String sql) { + return sql.replaceAll("\\s+", " ").trim(); + } + +} diff --git a/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql new file mode 100644 index 00000000..8aa2da8a --- /dev/null +++ b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql @@ -0,0 +1,238 @@ +-- ============================================================ +-- blade_loading_manage_driver_id_backfill_20261028.sql +-- ============================================================ +-- 存量配载单补司机档案 ID:把「归属」从手机号快照切到 blade_transport_driver.id +-- 写业务数据。第 0 节建存档表,第 1 节分批回填,第 2 节校验(只读,执行后必看),第 3 节回退。可重复执行。 +-- +-- 为什么要订正: +-- 票 01 之后配载单的归属键是 driver_id(blade_transport_driver.id),driver_phone 只是配载时的号码快照。 +-- 存量配载单全部只有号码、没有 driver_id(票 01 才加的列),司机端按档案 ID 查配载单会一张都查不到; +-- 按号码反查又会踩二次放号与改号,所以才需要先按「唯一命中」把能确定的补齐。 +-- 本脚本只处理「号码在本租户内唯一命中一条未删除档案」的配载单,其余留在原样、进清单等人工处置。 +-- +-- 判定与边界(严格按票面口径,不扩大): +-- ① 只动 driver_id IS NULL 的行。driver_id = -1 是「无主」哨兵,正数表示已有归属,两者一行都不动; +-- 第 1 节的 WHERE 里另加了一道显式排除((driver_id = -1) IS NOT TRUE), +-- 防止后人把条件放宽成 driver_id <= 0 时把哨兵一起覆盖。 +-- ② 唯一命中:同租户内、is_deleted = 0、mobile 与 driver_phone 相等的档案恰好 1 条。 +-- 命中 0 条 → 进「无法匹配」清单;命中 ≥2 条 → 进「冲突」清单。两类都跳过,不猜。 +-- 逻辑删除的档案不参与命中:档案删了以后重跑脚本,不能把这些单挂到废弃档案上。 +-- ③ 手机号为空、空白、含 * 的跳过(* 是脱敏占位,不是真号码)。 +-- ④ driver_phone、update_time 与其它任何列都不改,只写 driver_id。 +-- ⑤ 逻辑删除的配载单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围; +-- 这些单不会回到列表,回填对它们无副作用。 +-- ⑥ 跨表比较显式 COLLATE:blade_loading_manage.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci, +-- blade_transport_driver.mobile / tenant_id 是 utf8mb4_general_ci; +-- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。 +-- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。 +-- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。 +-- +-- 范围说明:与运单脚本同一口径,只做「手机号 → 档案唯一命中」这一条规则;其余留给人工处置。 +-- 幂等:命中映射先落存档表;重复执行时映射新增 0 行、回填更新 0 行,结果不变(第 2.2 节校验)。 +-- 执行方式(脚本里有 DELIMITER 与存储过程,按分号切句的 GUI 客户端会切错): +-- mysql -h127.0.0.1 -uroot -p transport < blade_loading_manage_driver_id_backfill_20261028.sql + +-- ------------------------------------------------------------ +-- 0. 存档(必须在订正之前执行;重复执行不会覆盖已有存档) +-- ------------------------------------------------------------ +-- 存档表同时是第 1 节的「命中映射」:一行 = 一个待回填的配载单 + 它唯一命中的档案 ID。 +-- 回填前先把映射定下来:回填只按映射写,跑之前就能看出「打算填谁」,回退也按它还原。 +CREATE TABLE IF NOT EXISTS blade_loading_manage_driver_id_bak_20261028 ( + loading_id bigint NOT NULL COMMENT '配载单ID', + driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)', + driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)', + driver_phone varchar(50) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', + update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写', + PRIMARY KEY (loading_id), + KEY idx_driver_id_after (driver_id_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='20261028 配载单司机档案ID回填存档'; + +-- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。 +-- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。 +INSERT IGNORE INTO blade_loading_manage_driver_id_bak_20261028 (loading_id, driver_id_before, driver_id_after, driver_phone, update_time_before) +SELECT matched.biz_id, NULL, matched.driver_id, matched.driver_phone, matched.update_time + FROM ( + SELECT w.id AS biz_id, + MIN(d.id) AS driver_id, + w.driver_phone AS driver_phone, + w.update_time AS update_time + FROM blade_loading_manage w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.id, w.driver_phone, w.update_time + HAVING COUNT(*) = 1 + ) matched; + +-- ------------------------------------------------------------ +-- 1. 订正(分批回填,默认每批 500 行) +-- ------------------------------------------------------------ +-- 循环退出条件是「这一批没有更新任何行」,所以重复执行时第一批就是 0 行,脚本空转结束。 +-- 每批单独提交(不开长事务):大表上不会长时间持锁,中断后重跑从剩余 NULL 行继续。 +DELIMITER $$ +DROP PROCEDURE IF EXISTS backfill_blade_loading_manage_driver_id_20261028$$ +CREATE PROCEDURE backfill_blade_loading_manage_driver_id_20261028(IN p_batch_size INT) +BEGIN + DECLARE v_batch_size INT DEFAULT 500; + DECLARE v_affected INT DEFAULT 0; + + IF p_batch_size IS NOT NULL AND p_batch_size > 0 THEN + SET v_batch_size = p_batch_size; + END IF; + + CREATE TEMPORARY TABLE IF NOT EXISTS tmp_loading_manage_driver_id_20261028 ( + loading_id bigint NOT NULL, + PRIMARY KEY (loading_id) + ) ENGINE = InnoDB; + + REPEAT + -- 取批:只取还空着的行,填过的行不再入批,所以每一批都在推进,不会原地打转 + DELETE FROM tmp_loading_manage_driver_id_20261028; + + INSERT INTO tmp_loading_manage_driver_id_20261028 (loading_id) + SELECT b.loading_id + FROM blade_loading_manage_driver_id_bak_20261028 b + JOIN blade_loading_manage w ON w.id = b.loading_id + WHERE w.driver_id IS NULL + ORDER BY b.loading_id + LIMIT v_batch_size; + + UPDATE blade_loading_manage w + JOIN blade_loading_manage_driver_id_bak_20261028 b ON b.loading_id = w.id + JOIN tmp_loading_manage_driver_id_20261028 t ON t.loading_id = w.id + SET w.driver_id = b.driver_id_after + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND b.driver_id_after IS NOT NULL; + + SET v_affected = ROW_COUNT(); + UNTIL v_affected = 0 END REPEAT; + + DROP TEMPORARY TABLE IF EXISTS tmp_loading_manage_driver_id_20261028; +END$$ +DELIMITER ; + +CALL backfill_blade_loading_manage_driver_id_20261028(500); +DROP PROCEDURE IF EXISTS backfill_blade_loading_manage_driver_id_20261028; + +-- ------------------------------------------------------------ +-- 2. 校验(执行后必看;本节只有 SELECT,不动数据) +-- ------------------------------------------------------------ +-- 2.1 规模:存档多少行、还剩多少 NULL 行、正数归属多少行(跑前跑后各查一次) +-- remaining_null_rows 只会减少「本次命中」的那部分;差额应当等于第 2.7 节的命中行数。 +SELECT COUNT(*) AS archived_rows FROM blade_loading_manage_driver_id_bak_20261028; +SELECT COUNT(*) AS remaining_null_rows FROM blade_loading_manage WHERE driver_id IS NULL; +SELECT SUM(driver_id > 0) AS owned_rows, + SUM(driver_id = -1) AS sentinel_rows, + SUM(driver_id IS NULL) AS null_rows + FROM blade_loading_manage; + +-- 2.2 哨兵不变:跑前跑后各执行一次,四列必须完全相同(-1 一行都不能动) +SELECT COUNT(*) AS sentinel_rows, + SUM(id) AS sentinel_id_sum, + MIN(id) AS min_id, + MAX(id) AS max_id + FROM blade_loading_manage + WHERE driver_id = -1; + +-- 2.3 写入正确:本次映射的行,driver_id 必须等于映射值(期望 0 行) +SELECT COUNT(*) AS wrong_driver_id + FROM blade_loading_manage_driver_id_bak_20261028 b + JOIN blade_loading_manage w ON w.id = b.loading_id + WHERE NOT (w.driver_id <=> b.driver_id_after); + +-- 2.4 快照没有被改写:本次映射的行,driver_phone 与 update_time 必须与存档逐行一致(期望 0 行) +SELECT COUNT(*) AS snapshot_rewritten + FROM blade_loading_manage_driver_id_bak_20261028 b + JOIN blade_loading_manage w ON w.id = b.loading_id + WHERE NOT (w.driver_phone <=> b.driver_phone) + OR NOT (w.update_time <=> b.update_time_before); + +-- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案) +SELECT w.driver_phone AS conflicting_phone, + COUNT(DISTINCT w.id) AS pending_rows, + COUNT(DISTINCT d.id) AS driver_profiles, + GROUP_CONCAT(DISTINCT d.id ORDER BY d.id) AS driver_profile_ids + FROM blade_loading_manage w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.driver_phone +HAVING COUNT(DISTINCT d.id) > 1; + +-- 2.6 无法匹配清单:号码在本租户内命中 0 条档案,这些行按规则跳过(等人工处置,脚本不猜) +SELECT w.driver_phone AS unmatched_phone, + COUNT(*) AS pending_rows + FROM blade_loading_manage w + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + AND NOT EXISTS ( + SELECT 1 + FROM blade_transport_driver d + WHERE d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0) + GROUP BY w.driver_phone + ORDER BY pending_rows DESC, unmatched_phone; + +-- 2.7 逐司机对照:按手机号可见单量 vs 按档案 ID 可见单量 +-- by_phone = 号码快照等于该档案号码的单量(旧口径,改号/二次放号会串) +-- by_driver_id = 档案 ID 等于该档案的单量(新口径) +-- phone_only_null = 号码对上、但归属还空着的单(本次应被回填的那部分) +-- phone_only_sentinel = 号码对上、但归属被标成 -1 无主的单(回填不碰,需要人工确认) +-- phone_only_other_owner = 号码对上、却已归属另一条档案的单(改号/换司机后号码被复用) +-- id_only_phone_differs = 已归属该档案、但号码快照不是该档案当前号码的单(改号后的历史快照) +SELECT d.id AS driver_id, + d.driver_name, + d.mobile, + (SELECT COUNT(*) FROM blade_loading_manage w WHERE w.driver_id = d.id) AS by_driver_id, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile) AS by_phone, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id IS NULL) AS phone_only_null, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id = -1) AS phone_only_sentinel, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id > 0 + AND w.driver_id <> d.id) AS phone_only_other_owner, + (SELECT COUNT(*) FROM blade_loading_manage w + WHERE w.driver_id = d.id + AND (w.driver_phone IS NULL + OR NOT (w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile))) AS id_only_phone_differs + FROM blade_transport_driver d + WHERE d.is_deleted = 0 + ORDER BY by_phone DESC, by_driver_id DESC, driver_id; + +-- ------------------------------------------------------------ +-- 3. 回退(仅在需要撤销本次回填时手工执行) +-- ------------------------------------------------------------ +-- 按存档表把本次写入的行还原成 NULL。只还原「当前值仍等于本次写入值」的行: +-- 回填之后又被人改派过的单不会被冲掉。 +-- UPDATE blade_loading_manage w +-- JOIN blade_loading_manage_driver_id_bak_20261028 b ON b.loading_id = w.id +-- SET w.driver_id = b.driver_id_before +-- WHERE w.driver_id <=> b.driver_id_after; +-- 回退只碰 driver_id,不碰 driver_phone / update_time。备份表确认不再需要时再删: +-- DROP TABLE blade_loading_manage_driver_id_bak_20261028; diff --git a/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql new file mode 100644 index 00000000..e42a82b9 --- /dev/null +++ b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql @@ -0,0 +1,241 @@ +-- ============================================================ +-- blade_waybill_driver_id_backfill_20261028.sql +-- ============================================================ +-- 存量运单补司机档案 ID:把「归属」从手机号快照切到 blade_transport_driver.id +-- 写业务数据。第 0 节建存档表,第 1 节分批回填,第 2 节校验(只读,执行后必看),第 3 节回退。可重复执行。 +-- +-- 为什么要订正: +-- 票 01 之后运单的归属键是 driver_id(blade_transport_driver.id),driver_phone 只是派单时的号码快照。 +-- 存量运单大多只有号码、没有 driver_id,司机端按档案 ID 查单会整批漏掉它们; +-- 按号码反查又会踩二次放号与改号(本机库实测:黄宇富档案已改号 15577122721, +-- 名下 92 张单仍写着旧号 17587421208,其中 20 张 driver_id 为空、5 张能从接单司机推出来, +-- 剩下 15 张号码已经对不上任何档案——本脚本不猜,它们会出现在第 2.6 节清单里)。 +-- 本脚本只处理「号码在本租户内唯一命中一条未删除档案」的运单,其余留在原样、进清单等人工处置。 +-- +-- 判定与边界(严格按票面口径,不扩大): +-- ① 只动 driver_id IS NULL 的行。driver_id = -1 是「无主」哨兵,正数表示已有归属,两者一行都不动; +-- 第 1 节的 WHERE 里另加了一道显式排除((driver_id = -1) IS NOT TRUE), +-- 防止后人把条件放宽成 driver_id <= 0 时把哨兵一起覆盖。 +-- ② 唯一命中:同租户内、is_deleted = 0、mobile 与 driver_phone 相等的档案恰好 1 条。 +-- 命中 0 条 → 进「无法匹配」清单;命中 ≥2 条 → 进「冲突」清单。两类都跳过,不猜。 +-- 逻辑删除的档案不参与命中:档案删了以后重跑脚本,不能把这些单挂到废弃档案上。 +-- ③ 手机号为空、空白、含 * 的跳过(* 是脱敏占位,不是真号码)。 +-- ④ driver_phone、update_time 与其它任何列都不改,只写 driver_id。 +-- ⑤ 逻辑删除的运单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围; +-- 这些单不会回到列表,回填对它们无副作用。 +-- ⑥ 跨表比较显式 COLLATE:blade_waybill.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci, +-- blade_transport_driver.mobile / tenant_id 是 utf8mb4_general_ci; +-- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。 +-- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。 +-- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。 +-- +-- 范围说明:本脚本只做「手机号 → 档案唯一命中」这一条规则。 +-- 不引入 driver_accept_driver_id 推断,也不按姓名/历史号码猜归属:这些属于人工处置,在票面范围外。 +-- 幂等:命中映射先落存档表;重复执行时映射新增 0 行、回填更新 0 行,结果不变(第 2.2 节校验)。 +-- 执行方式(脚本里有 DELIMITER 与存储过程,按分号切句的 GUI 客户端会切错): +-- mysql -h127.0.0.1 -uroot -p transport < blade_waybill_driver_id_backfill_20261028.sql + +-- ------------------------------------------------------------ +-- 0. 存档(必须在订正之前执行;重复执行不会覆盖已有存档) +-- ------------------------------------------------------------ +-- 存档表同时是第 1 节的「命中映射」:一行 = 一个待回填的运单 + 它唯一命中的档案 ID。 +-- 回填前先把映射定下来:回填只按映射写,跑之前就能看出「打算填谁」,回退也按它还原。 +CREATE TABLE IF NOT EXISTS blade_waybill_driver_id_bak_20261028 ( + waybill_id bigint NOT NULL COMMENT '运单ID', + driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)', + driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)', + driver_phone varchar(50) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', + update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写', + PRIMARY KEY (waybill_id), + KEY idx_driver_id_after (driver_id_after) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='20261028 运单司机档案ID回填存档'; + +-- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。 +-- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。 +INSERT IGNORE INTO blade_waybill_driver_id_bak_20261028 (waybill_id, driver_id_before, driver_id_after, driver_phone, update_time_before) +SELECT matched.biz_id, NULL, matched.driver_id, matched.driver_phone, matched.update_time + FROM ( + SELECT w.id AS biz_id, + MIN(d.id) AS driver_id, + w.driver_phone AS driver_phone, + w.update_time AS update_time + FROM blade_waybill w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.id, w.driver_phone, w.update_time + HAVING COUNT(*) = 1 + ) matched; + +-- ------------------------------------------------------------ +-- 1. 订正(分批回填,默认每批 500 行) +-- ------------------------------------------------------------ +-- 循环退出条件是「这一批没有更新任何行」,所以重复执行时第一批就是 0 行,脚本空转结束。 +-- 每批单独提交(不开长事务):大表上不会长时间持锁,中断后重跑从剩余 NULL 行继续。 +DELIMITER $$ +DROP PROCEDURE IF EXISTS backfill_blade_waybill_driver_id_20261028$$ +CREATE PROCEDURE backfill_blade_waybill_driver_id_20261028(IN p_batch_size INT) +BEGIN + DECLARE v_batch_size INT DEFAULT 500; + DECLARE v_affected INT DEFAULT 0; + + IF p_batch_size IS NOT NULL AND p_batch_size > 0 THEN + SET v_batch_size = p_batch_size; + END IF; + + CREATE TEMPORARY TABLE IF NOT EXISTS tmp_waybill_driver_id_20261028 ( + waybill_id bigint NOT NULL, + PRIMARY KEY (waybill_id) + ) ENGINE = InnoDB; + + REPEAT + -- 取批:只取还空着的行,填过的行不再入批,所以每一批都在推进,不会原地打转 + DELETE FROM tmp_waybill_driver_id_20261028; + + INSERT INTO tmp_waybill_driver_id_20261028 (waybill_id) + SELECT b.waybill_id + FROM blade_waybill_driver_id_bak_20261028 b + JOIN blade_waybill w ON w.id = b.waybill_id + WHERE w.driver_id IS NULL + ORDER BY b.waybill_id + LIMIT v_batch_size; + + UPDATE blade_waybill w + JOIN blade_waybill_driver_id_bak_20261028 b ON b.waybill_id = w.id + JOIN tmp_waybill_driver_id_20261028 t ON t.waybill_id = w.id + SET w.driver_id = b.driver_id_after + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND b.driver_id_after IS NOT NULL; + + SET v_affected = ROW_COUNT(); + UNTIL v_affected = 0 END REPEAT; + + DROP TEMPORARY TABLE IF EXISTS tmp_waybill_driver_id_20261028; +END$$ +DELIMITER ; + +CALL backfill_blade_waybill_driver_id_20261028(500); +DROP PROCEDURE IF EXISTS backfill_blade_waybill_driver_id_20261028; + +-- ------------------------------------------------------------ +-- 2. 校验(执行后必看;本节只有 SELECT,不动数据) +-- ------------------------------------------------------------ +-- 2.1 规模:存档多少行、还剩多少 NULL 行、正数归属多少行(跑前跑后各查一次) +-- remaining_null_rows 只会减少「本次命中」的那部分;差额应当等于第 2.7 节的命中行数。 +SELECT COUNT(*) AS archived_rows FROM blade_waybill_driver_id_bak_20261028; +SELECT COUNT(*) AS remaining_null_rows FROM blade_waybill WHERE driver_id IS NULL; +SELECT SUM(driver_id > 0) AS owned_rows, + SUM(driver_id = -1) AS sentinel_rows, + SUM(driver_id IS NULL) AS null_rows + FROM blade_waybill; + +-- 2.2 哨兵不变:跑前跑后各执行一次,四列必须完全相同(-1 一行都不能动) +SELECT COUNT(*) AS sentinel_rows, + SUM(id) AS sentinel_id_sum, + MIN(id) AS min_id, + MAX(id) AS max_id + FROM blade_waybill + WHERE driver_id = -1; + +-- 2.3 写入正确:本次映射的行,driver_id 必须等于映射值(期望 0 行) +SELECT COUNT(*) AS wrong_driver_id + FROM blade_waybill_driver_id_bak_20261028 b + JOIN blade_waybill w ON w.id = b.waybill_id + WHERE NOT (w.driver_id <=> b.driver_id_after); + +-- 2.4 快照没有被改写:本次映射的行,driver_phone 与 update_time 必须与存档逐行一致(期望 0 行) +SELECT COUNT(*) AS snapshot_rewritten + FROM blade_waybill_driver_id_bak_20261028 b + JOIN blade_waybill w ON w.id = b.waybill_id + WHERE NOT (w.driver_phone <=> b.driver_phone) + OR NOT (w.update_time <=> b.update_time_before); + +-- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案) +SELECT w.driver_phone AS conflicting_phone, + COUNT(DISTINCT w.id) AS pending_rows, + COUNT(DISTINCT d.id) AS driver_profiles, + GROUP_CONCAT(DISTINCT d.id ORDER BY d.id) AS driver_profile_ids + FROM blade_waybill w + JOIN blade_transport_driver d + ON d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0 + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + GROUP BY w.driver_phone +HAVING COUNT(DISTINCT d.id) > 1; + +-- 2.6 无法匹配清单:号码在本租户内命中 0 条档案,这些行按规则跳过(等人工处置,脚本不猜) +SELECT w.driver_phone AS unmatched_phone, + COUNT(*) AS pending_rows + FROM blade_waybill w + WHERE w.driver_id IS NULL + AND (w.driver_id = -1) IS NOT TRUE + AND w.driver_phone IS NOT NULL + AND TRIM(w.driver_phone) <> '' + AND w.driver_phone NOT LIKE '%*%' + AND NOT EXISTS ( + SELECT 1 + FROM blade_transport_driver d + WHERE d.tenant_id COLLATE utf8mb4_0900_ai_ci = w.tenant_id + AND d.mobile COLLATE utf8mb4_0900_ai_ci = w.driver_phone + AND d.is_deleted = 0) + GROUP BY w.driver_phone + ORDER BY pending_rows DESC, unmatched_phone; + +-- 2.7 逐司机对照:按手机号可见单量 vs 按档案 ID 可见单量 +-- by_phone = 号码快照等于该档案号码的单量(旧口径,改号/二次放号会串) +-- by_driver_id = 档案 ID 等于该档案的单量(新口径) +-- phone_only_null = 号码对上、但归属还空着的单(本次应被回填的那部分) +-- phone_only_sentinel = 号码对上、但归属被标成 -1 无主的单(回填不碰,需要人工确认) +-- phone_only_other_owner = 号码对上、却已归属另一条档案的单(改号/换司机后号码被复用) +-- id_only_phone_differs = 已归属该档案、但号码快照不是该档案当前号码的单(改号后的历史快照) +SELECT d.id AS driver_id, + d.driver_name, + d.mobile, + (SELECT COUNT(*) FROM blade_waybill w WHERE w.driver_id = d.id) AS by_driver_id, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile) AS by_phone, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id IS NULL) AS phone_only_null, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id = -1) AS phone_only_sentinel, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.tenant_id COLLATE utf8mb4_0900_ai_ci = d.tenant_id + AND w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile + AND w.driver_id > 0 + AND w.driver_id <> d.id) AS phone_only_other_owner, + (SELECT COUNT(*) FROM blade_waybill w + WHERE w.driver_id = d.id + AND (w.driver_phone IS NULL + OR NOT (w.driver_phone COLLATE utf8mb4_0900_ai_ci = d.mobile))) AS id_only_phone_differs + FROM blade_transport_driver d + WHERE d.is_deleted = 0 + ORDER BY by_phone DESC, by_driver_id DESC, driver_id; + +-- ------------------------------------------------------------ +-- 3. 回退(仅在需要撤销本次回填时手工执行) +-- ------------------------------------------------------------ +-- 按存档表把本次写入的行还原成 NULL。只还原「当前值仍等于本次写入值」的行: +-- 回填之后又被人改派过的单不会被冲掉。 +-- UPDATE blade_waybill w +-- JOIN blade_waybill_driver_id_bak_20261028 b ON b.waybill_id = w.id +-- SET w.driver_id = b.driver_id_before +-- WHERE w.driver_id <=> b.driver_id_after; +-- 回退只碰 driver_id,不碰 driver_phone / update_time。备份表确认不再需要时再删: +-- DROP TABLE blade_waybill_driver_id_bak_20261028; From 01b4dd0b5bdd8f7aa3bac3fe97bf8f5c36030737 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 21:07:07 +0800 Subject: [PATCH 08/27] =?UTF-8?q?:recycle:=20=E8=BF=90=E6=AC=A1=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E5=88=A4=E6=8D=AE=E7=94=B1=E5=8F=B8=E6=9C=BA=E6=89=8B?= =?UTF-8?q?=E6=9C=BA=E5=8F=B7=E6=94=B9=E4=B8=BA=E5=8F=B8=E6=9C=BA=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=20ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TripQueryVO 的收口字段由 driverPhone 换成 driverId,删除按手机号构造收口条件的入口, 让编译器暴露全部调用点;forAdmin() 与出参 driverPhone 快照不变 - 运次可见范围两段同时切:已配载段按配载单 lm.driver_id 子查询,未配载段按运单 driver_id - 详情复核改为比较档案 ID(ownedBy)、删除 samePhone;收口缺省(空/0/负数)仍返回空集 - 司机端六个运次入口改用当前司机档案 ID;删除 currentUserPhone/isMobileLike 手机号兜底 - 测试夹具改为同时铺设 driverId 与派单快照号,新增改号仍可见、同号换人、管理端、缺省收口四组用例 --- .../transport/pojo/vo/TripQueryVO.java | 13 +- .../impl/DriverWaybillServiceImpl.java | 58 ++--- .../service/impl/TripQueryServiceImpl.java | 40 ++-- .../impl/DriverTripAcceptRejectTest.java | 4 +- .../service/impl/DriverTripCardTest.java | 7 +- .../impl/TripQueryServiceImplTest.java | 204 ++++++++++++++---- .../impl/TripReadServiceBoundaryTest.java | 15 +- 7 files changed, 220 insertions(+), 121 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java index 750dd6d0..cbdc9f69 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java @@ -34,8 +34,11 @@ import java.util.Set; /** * 运次查询条件 *

- * 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。 + * 司机端用 {@link #forDriver(Long)} 按司机档案 ID 收口;管理端用 {@link #forAdmin()} 不按司机收口。 * 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。 + *

+ * 判据是司机档案 ID 而不是手机号:手机号在配载单/运单上只是派单时的快照,司机换号后并不跟随, + * 按号收口会让改过号的司机看不到自己手上的运次,号码被回收给别人时还会反向串号。 */ @Data @Schema(description = "运次查询条件") @@ -47,8 +50,8 @@ public class TripQueryVO implements Serializable { @Schema(description = "运次主键(详情按它取单个运次)") private String tripId; - @Schema(description = "司机手机号") - private String driverPhone; + @Schema(description = "司机档案 ID(司机端收口判据,空或非正数一律不放行)") + private Long driverId; @Schema(description = "是否不按司机收口(管理端)") private boolean allDrivers; @@ -92,9 +95,9 @@ public class TripQueryVO implements Serializable { @Schema(description = "每页条数(按运次,不是运单)") private Integer size; - public static TripQueryVO forDriver(String driverPhone) { + public static TripQueryVO forDriver(Long driverId) { TripQueryVO query = new TripQueryVO(); - query.setDriverPhone(driverPhone); + query.setDriverId(driverId); return query; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 9bd09ed8..8aab3912 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -33,10 +33,8 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.constant.BladeConstant; -import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.system.feign.IMpUserClient; -import org.springblade.system.pojo.entity.MpUser; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -277,11 +275,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO tripCurrentTask() { - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return null; } - TripQueryVO query = TripQueryVO.forDriver(phone); + TripQueryVO query = TripQueryVO.forDriver(driver.getId()); // 待确认(待回单确认)的趟车司机仍需回到卡片上继续操作,与进行中并列作为当前任务 query.setStatuses(List.of(TRIP_STATUS_RUNNING, TRIP_STATUS_PENDING_RETURN_CONFIRM)); query.setCurrent(1); @@ -293,11 +291,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillPreviewVO tripPendingPreview(Integer size) { DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO(); - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return preview; } - TripQueryVO query = TripQueryVO.forDriver(phone); + TripQueryVO query = TripQueryVO.forDriver(driver.getId()); query.setStatus(TRIP_STATUS_PENDING); query.setCurrent(1); query.setSize(normalizePreviewSize(size)); @@ -312,11 +310,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillTabCountsVO tripTabCounts() { DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO(); - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return counts; } - TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone)); + TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(driver.getId())); counts.setPending(tripCounts.getPending()); counts.setDoing(tripCounts.getDoing()); counts.setConfirm(tripCounts.getConfirm()); @@ -327,11 +325,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public IPage tripPage(Integer current, Integer size, Integer status, String keyword) { - String phone = currentUserPhone(); - if (Func.isEmpty(phone)) { + Driver driver = currentDriver(); + if (driver == null) { return new Page<>(normalizePageNo(current), normalizePageSize(size)); } - TripQueryVO query = TripQueryVO.forDriver(phone); + TripQueryVO query = TripQueryVO.forDriver(driver.getId()); query.setStatus(status); query.setKeyword(keyword); query.setCurrent(current); @@ -346,8 +344,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO tripDetail(String tripId) { - requireCurrentDriver(); - TripQueryVO query = TripQueryVO.forDriver(currentUserPhone()); + Driver driver = requireCurrentDriver(); + TripQueryVO query = TripQueryVO.forDriver(driver.getId()); query.setTripId(tripId); return toTripCard(tripQueryService.detail(query), true); } @@ -740,7 +738,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { * 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。 */ private List tripChildren(String tripId) { - TripQueryVO query = TripQueryVO.forDriver(currentUserPhone()); + TripQueryVO query = TripQueryVO.forDriver(requireCurrentDriver().getId()); query.setTripId(tripId); TripVO trip = tripQueryService.detail(query); List children = trip.getWaybills() == null ? List.of() : trip.getWaybills(); @@ -922,28 +920,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .or().like(Waybill::getVehicleNo, kw)); } - /** - * 当前用户手机号:司机档案 mobile 优先,其次小程序用户账号。 - *

- * 派单时运单 driver_phone 取自司机档案 mobile,故档案有手机号就以它为准。 - */ - private String currentUserPhone() { - Driver driver = currentDriver(); - if (driver != null && isMobileLike(driver.getMobile())) { - return driver.getMobile().trim(); - } - Long userId = AuthUtil.getUserId(); - if (userId == null || userId <= 0) { - return null; - } - R result = mpUserClient.userById(userId); - if (result != null && R.isSuccess(result) && result.getData() != null - && isMobileLike(result.getData().getAccount())) { - return result.getData().getAccount().trim(); - } - return null; - } - /** * 当前登录司机:只按登录用户 ID 查司机档案,查不到即「无归属」。 *

@@ -960,10 +936,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .last("LIMIT 1")); } - private boolean isMobileLike(String value) { - return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); - } - private List splitPlates(String drivingVehicle) { return VehiclePlateSupport.splitPlates(drivingVehicle); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java index fcff3fdc..7c35161c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java @@ -389,22 +389,24 @@ public class TripQueryServiceImpl implements ITripQueryService { } /** - * 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。 + * 运次可见范围:配载段按配载单的司机档案 ID 收口,未配载段按运单的司机档案 ID 收口。 *

- * 手机号允许带空格,与运单列表的收口判据一致。 + * 判据是司机档案 ID 而不是手机号:司机换号后档案 ID 不变,而派单时落在配载单/运单上的手机号 + * 是快照,按号收口会让改过号的司机看不到自己手上的运次;号码被回收给别人时又会反向串号。 + * 两段必须同时切:只切一段,另一段就会把不属于该司机的运次放进来。 + * 缺省收口(空、0、负数)不放行,由 {@link #scoped} 在外层挡掉。 */ private void applyScope(LambdaQueryWrapper wrapper, TripQueryVO condition) { if (condition.isAllDrivers()) { return; } - String phone = Func.toStr(condition.getDriverPhone(), "").trim(); + Long driverId = condition.getDriverId(); wrapper.and(scope -> scope .apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm" - + " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone) + + " WHERE lm.is_deleted = 0 AND lm.driver_id = {0}))", driverId) .or(lone -> lone .apply("IFNULL(loading_no, '') = ''") - .and(row -> row.eq(Waybill::getDriverPhone, phone) - .or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone)))); + .and(row -> row.eq(Waybill::getDriverId, driverId)))); } /** @@ -786,7 +788,7 @@ public class TripQueryServiceImpl implements ITripQueryService { } /** - * 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口 + * 按运次主键取配载单:主键命中配载单即配载运次,再按配载单的司机档案 ID 收口 */ private LoadingManage visibleLoading(Long id, TripQueryVO condition) { LoadingManage loading = loadingManageService.getById(id); @@ -796,7 +798,7 @@ public class TripQueryServiceImpl implements ITripQueryService { if (condition.isAllDrivers()) { return loading; } - return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null; + return ownedBy(loading.getDriverId(), condition.getDriverId()) ? loading : null; } /** @@ -813,19 +815,23 @@ public class TripQueryServiceImpl implements ITripQueryService { if (condition.isAllDrivers()) { return waybill; } - return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null; + return ownedBy(waybill.getDriverId(), condition.getDriverId()) ? waybill : null; } + /** 收口条件是否成立:管理端不收口;司机端必须给出有效的档案 ID,缺省一律拒绝 */ private boolean scoped(TripQueryVO condition) { - return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim()); + Long driverId = condition.getDriverId(); + return condition.isAllDrivers() || (driverId != null && driverId > 0); } - private boolean samePhone(String left, String right) { - String expected = Func.toStr(right, "").trim().replace(" ", ""); - if (Func.isEmpty(expected)) { - return false; - } - return expected.equals(Func.toStr(left, "").trim().replace(" ", "")); + /** + * 归属判据:行的司机档案 ID 与收口条件相等。 + *

+ * 条件侧缺省(空、0、负数)一律不可见——没有有效档案 ID 就等于没有收口条件, + * 此时任何行都不能算作「本人」,否则业务主键未回填的历史数据会把运次漏给所有人。 + */ + private boolean ownedBy(Long ownerId, Long expectedId) { + return expectedId != null && expectedId > 0 && expectedId.equals(ownerId); } private TripQueryVO condition(TripQueryVO query) { @@ -835,7 +841,7 @@ public class TripQueryServiceImpl implements ITripQueryService { /** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */ private TripQueryVO withoutTab(TripQueryVO source) { TripQueryVO copy = new TripQueryVO(); - copy.setDriverPhone(source.getDriverPhone()); + copy.setDriverId(source.getDriverId()); copy.setAllDrivers(source.isAllDrivers()); copy.setDeptIds(source.getDeptIds()); copy.setProjectIds(source.getProjectIds()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java index 4b18c922..8e8e474d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java @@ -124,8 +124,8 @@ class DriverTripAcceptRejectTest { "两张子运单与接单司机一起下发:" + capturedUpdateParams()); assertTrue(capturedWhereSql().contains("(id IN ("), capturedWhereSql()); assertEquals(TRIP_ID, capturedQuery().getTripId()); - assertEquals(CURRENT_PHONE, capturedQuery().getDriverPhone(), - "运次侧本票不动:仍按档案当前手机号收口(票 04 统一改成档案 ID)"); + assertEquals(DRIVER_ID, capturedQuery().getDriverId(), + "运次查询按司机档案 ID 收口:手机号换了也认得出本人"); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java index 8ac29d0f..d783008e 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java @@ -125,7 +125,7 @@ class DriverTripCardTest { IPage page = runAsDriver(() -> service.tripPage(1, 10, null, null)); given = capturedQuery(); - assertEquals(PHONE, given.getDriverPhone()); + assertEquals(DRIVER_ID, given.getDriverId(), "运次列表按司机档案 ID 收口"); assertEquals(1, given.getCurrent()); assertEquals(10, given.getSize(), "一页 10 条即 10 个运次"); assertNull(given.getStatus()); @@ -334,10 +334,9 @@ class DriverTripCardTest { } @Test - @DisplayName("收口缺失时不放行:空页、零统计、当前任务为空,且不查运次") - void missingPhoneIsRefused() { + @DisplayName("收口缺失时不放行:没有司机档案就空页、零统计、当前任务为空,且不查运次") + void missingDriverProfileIsRefused() { when(driverService.getOne(any())).thenReturn(null); - when(mpUserClient.userById(any())).thenReturn(null); IPage page = runAsDriver(() -> service.tripPage(null, null, null, null)); DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null)); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java index d717921a..b12f1624 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripQueryServiceImplTest.java @@ -50,7 +50,10 @@ import static org.mockito.Mockito.when; */ class TripQueryServiceImplTest { + /** 派单时的司机手机号快照:只用于展示,归属判据是下面的档案 ID */ private static final String PHONE = "17587421208"; + private static final Long DRIVER_ID = 9001L; + private static final Long OTHER_DRIVER_ID = 9002L; private static final String LOADING_NO = "PZ-20261017-001"; private static final Long LOADING_ID = 2111350784070275123L; private static final Long WAYBILL_ID = 2110690671695380513L; @@ -85,7 +88,7 @@ class TripQueryServiceImplTest { @Test @DisplayName("段一在库层只做收口与行级筛选,分组与分档不在 SQL 里") void rowsAreFetchedWithoutDatabaseGrouping() { - TripQueryVO query = TripQueryVO.forDriver(PHONE); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); query.setStatus(1); query.setCurrent(1); query.setSize(10); @@ -98,9 +101,9 @@ class TripQueryServiceImplTest { assertFalse(sql.contains("LIMIT"), "分页在服务层,不在 wrapper 里拼:" + sql); assertFalse(sql.contains("MAX(create_time)"), "排序键在服务层算:" + sql); assertTrue(sql.contains("blade_loading_manage"), "配载段按配载单的司机收口:" + sql); - assertTrue(sql.contains("lm.driver_phone = ?"), "司机手机号要作为参数下发:" + sql); - assertTrue(sql.contains("REPLACE(IFNULL(driver_phone, ''), ' ', '') = ?"), - "未配载段按运单司机收口,且容忍手机号带空格:" + sql); + assertTrue(sql.contains("lm.driver_id = ?"), "配载段按配载单的司机档案 ID 收口:" + sql); + assertTrue(sql.contains("driver_id = ?"), "未配载段按运单的司机档案 ID 收口:" + sql); + assertFalse(sql.contains("driver_phone"), "手机号不再是归属判据:" + sql); assertTrue(sql.contains("business_status IN"), "草稿子单不成运次:" + sql); assertTrue(sql.contains("driver_accept_status"), "被该司机拒单的子单不进运次:" + sql); } @@ -108,7 +111,7 @@ class TripQueryServiceImplTest { @Test @DisplayName("段一只 select 用得上的列,大 JSON 列不拖回来") void onlyNeededColumnsAreSelected() { - service.page(TripQueryVO.forDriver(PHONE)); + service.page(TripQueryVO.forDriver(DRIVER_ID)); String select = String.valueOf(capturedRowQuery().getSqlSelect()); assertTrue(select.contains("process_json"), "分档要按过程配置推导,必须取:" + select); @@ -124,7 +127,7 @@ class TripQueryServiceImplTest { @Test @DisplayName("组织收口给了就落在库层运单行上,分档与分页都由缩过的行集合算") void deptScopeIsAppliedOnRows() { - TripQueryVO query = TripQueryVO.forDriver(PHONE); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); query.setDeptIds(List.of(1123598813738675202L, 1123598813738675201L)); service.page(query); @@ -134,7 +137,7 @@ class TripQueryServiceImplTest { Set values = new HashSet<>(capturedRowQuery().getParamNameValuePairs().values()); assertTrue(values.contains(1123598813738675202L) && values.contains(1123598813738675201L), "组织值按参数下发:" + values); - assertTrue(capturedRowSql().contains("driver_phone"), "组织收口不顶替司机收口:两种收口同时生效"); + assertTrue(capturedRowSql().contains("driver_id"), "组织收口不顶替司机收口:两种收口同时生效"); } @Test @@ -152,7 +155,7 @@ class TripQueryServiceImplTest { @Test @DisplayName("组织收口缺省时不下发组织条件:超管与司机端都按不传处理") void deptScopeIsOptional() { - service.page(TripQueryVO.forDriver(PHONE)); + service.page(TripQueryVO.forDriver(DRIVER_ID)); assertFalse(capturedRowSql().contains("dept_id"), "不给组织集合即不过滤:" + capturedRowSql()); } @@ -165,7 +168,7 @@ class TripQueryServiceImplTest { waybill(2L, "YD-2", null, "completed", "accepted", at(2000L), ACCEPT_NO), waybill(3L, "YD-3", null, "running", "accepted", at(1000L), ACCEPT_NO)); - TripQueryVO doing = TripQueryVO.forDriver(PHONE); + TripQueryVO doing = TripQueryVO.forDriver(DRIVER_ID); doing.setStatus(1); IPage doingPage = service.page(doing); @@ -175,11 +178,11 @@ class TripQueryServiceImplTest { assertTrue(doingPage.getRecords().stream().allMatch(trip -> Integer.valueOf(1).equals(trip.getStatus())), "每张卡的 status 都要等于这一档"); - TripQueryVO done = TripQueryVO.forDriver(PHONE); + TripQueryVO done = TripQueryVO.forDriver(DRIVER_ID); done.setStatus(2); assertEquals(List.of("2"), service.page(done).getRecords().stream().map(TripVO::getId).toList()); - TripQueryVO all = TripQueryVO.forDriver(PHONE); + TripQueryVO all = TripQueryVO.forDriver(DRIVER_ID); all.setStatus(null); assertEquals(3, service.page(all).getTotal(), "不传 Tab 为全部(不含已取消)"); } @@ -192,12 +195,12 @@ class TripQueryServiceImplTest { waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES), waybill(1L, "YD-1", null, "pending", "pending", at(1000L), ACCEPT_NO)); - TripQueryVO pending = TripQueryVO.forDriver(PHONE); + TripQueryVO pending = TripQueryVO.forDriver(DRIVER_ID); pending.setStatus(0); assertEquals(List.of("2"), service.page(pending).getRecords().stream().map(TripVO::getId).toList(), "库值 running 但要接单未接 → 落待接单档"); - TripQueryVO doing = TripQueryVO.forDriver(PHONE); + TripQueryVO doing = TripQueryVO.forDriver(DRIVER_ID); doing.setStatus(1); assertEquals(List.of("1"), service.page(doing).getRecords().stream().map(TripVO::getId).toList(), "库值 pending 但不需要接单 → 落进行中档"); @@ -212,8 +215,8 @@ class TripQueryServiceImplTest { List.of( waybill(1L, "YD-1", LOADING_NO, "running", null, at(3000L), ACCEPT_YES), waybill(2L, "YD-2", LOADING_NO, "running", null, at(2000L), ACCEPT_YES))); - when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); - TripQueryVO query = TripQueryVO.forDriver(PHONE); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); query.setKeyword("PZ-20261017"); TripVO trip = service.page(query).getRecords().get(0); @@ -232,7 +235,7 @@ class TripQueryServiceImplTest { void unloadedWaybillIsItsOwnTrip() { stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO)); - IPage page = service.page(TripQueryVO.forDriver(PHONE)); + IPage page = service.page(TripQueryVO.forDriver(DRIVER_ID)); assertEquals(1, page.getTotal()); assertEquals(1, page.getRecords().size()); @@ -256,7 +259,7 @@ class TripQueryServiceImplTest { row.setMasterNo("DL-20261001-0001"); stubRows(row); - TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); assertEquals(Boolean.TRUE, trip.getMultimodal(), "挂总单即含多式联运"); assertEquals("common", trip.getTransportOrgType(), "运输类型是组织形态维,与多式联运各算一个值"); @@ -270,9 +273,9 @@ class TripQueryServiceImplTest { stubRows( onMasterOrder, waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)); - when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); - TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); assertEquals("load", trip.getTransportOrgType()); assertEquals(Boolean.TRUE, trip.getMultimodal(), "两个事实叠加,不做互斥单选"); @@ -283,7 +286,7 @@ class TripQueryServiceImplTest { void unloadedTripStateComesFromFetchedWaybill() { stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", at(1000L), ACCEPT_YES)); - TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); assertEquals(Boolean.TRUE, trip.getRequireAccept()); assertEquals("pending", trip.getAcceptStatus()); @@ -300,7 +303,7 @@ class TripQueryServiceImplTest { row.setEstimatedEndTime(LocalDate.of(2026, 8, 24)); stubRows(row); - TripVO listed = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO listed = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); assertEquals(LocalDate.of(2026, 8, 7), listed.getEstimatedStartDate(), "列表卡片与详情同源,不能只在列表补"); assertEquals(LocalDate.of(2026, 8, 24), listed.getEstimatedEndDate()); @@ -319,7 +322,7 @@ class TripQueryServiceImplTest { void loadingMissingIsDropped() { stubRows(waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(1000L), ACCEPT_YES)); - IPage page = service.page(TripQueryVO.forDriver(PHONE)); + IPage page = service.page(TripQueryVO.forDriver(DRIVER_ID)); assertTrue(page.getRecords().isEmpty(), "配载单取不到就不给这一趟车"); assertEquals(1, page.getTotal(), "总数按运次算,卡片少一张时总数仍在"); @@ -335,9 +338,9 @@ class TripQueryServiceImplTest { waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", null, earlier, ACCEPT_NO), waybill(2092186187571130369L, "YD-20260825-0008", LOADING_NO, "running", null, earlier, ACCEPT_NO), waybill(2102780956493299713L, "YD-20260923-0008", LOADING_NO, "running", null, latest, ACCEPT_NO)); - when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); - TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); assertEquals(String.valueOf(LOADING_ID), trip.getId()); assertEquals(LOADING_NO, trip.getLoadingNo()); @@ -358,9 +361,9 @@ class TripQueryServiceImplTest { stubRows( waybill(2092186187239780353L, "YD-1", LOADING_NO, "pending", "pending", at(2000L), ACCEPT_YES), waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)); - when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); - TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO trip = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); assertEquals(Boolean.TRUE, trip.getRequireAccept(), "跨项目按 OR 合并:一张要接单整趟车都要接单"); assertEquals("pending", trip.getAcceptStatus()); @@ -372,8 +375,8 @@ class TripQueryServiceImplTest { void repeatedReadsDoNotDriftAndNeverWrite() { stubOneLoadedTrip(); - TripVO first = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); - TripVO second = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0); + TripVO first = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); + TripVO second = service.page(TripQueryVO.forDriver(DRIVER_ID)).getRecords().get(0); assertEquals(first.getStatus(), second.getStatus()); assertEquals(first.getAcceptStatus(), second.getAcceptStatus()); @@ -412,19 +415,19 @@ class TripQueryServiceImplTest { waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES), waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES)); - TripQueryVO firstPage = TripQueryVO.forDriver(PHONE); + TripQueryVO firstPage = TripQueryVO.forDriver(DRIVER_ID); firstPage.setCurrent(1); firstPage.setSize(2); assertEquals(3, service.page(firstPage).getTotal(), "总数是过滤后的运次数,不是运单数"); assertEquals(List.of("3", "2"), service.page(firstPage).getRecords().stream().map(TripVO::getId).toList()); - TripQueryVO secondPage = TripQueryVO.forDriver(PHONE); + TripQueryVO secondPage = TripQueryVO.forDriver(DRIVER_ID); secondPage.setCurrent(2); secondPage.setSize(2); assertEquals(List.of("1"), service.page(secondPage).getRecords().stream().map(TripVO::getId).toList(), "第 2 页接着第 1 页,不重复不遗漏"); - TripQueryVO bySeven = TripQueryVO.forDriver(PHONE); + TripQueryVO bySeven = TripQueryVO.forDriver(DRIVER_ID); bySeven.setCurrent(1); bySeven.setSize(7); assertEquals(3, service.page(bySeven).getTotal(), "换每页条数总数不变"); @@ -440,7 +443,7 @@ class TripQueryServiceImplTest { waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO), waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO)); - TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(PHONE)); + TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(DRIVER_ID)); assertEquals(1, counts.getPending()); assertEquals(2, counts.getDoing()); @@ -476,7 +479,7 @@ class TripQueryServiceImplTest { stubRows( waybill(2L, "YD-2", LOADING_NO, "completed", "accepted", at(2000L), ACCEPT_NO), waybill(1L, "YD-1", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)); - when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); TripQueryVO query = TripQueryVO.forAdmin(); query.setException("exception"); query.setExceptionWaybillIds(java.util.Set.of(2L)); @@ -572,7 +575,7 @@ class TripQueryServiceImplTest { @Test @DisplayName("详情与列表同口径:子单快照没有接单节点、项目生效配置要求接单时,详情也算待接单") void detailUsesLiveProcessConfig() { - when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID)); Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending", at(2000L), ACCEPT_NO); child.setProjectId(7001L); @@ -590,7 +593,7 @@ class TripQueryServiceImplTest { @Test @DisplayName("详情仍是只读取数:生效配置只写回内存里的行,不落库") void detailStaysReadOnly() { - when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID)); Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending", at(2000L), ACCEPT_NO); child.setProjectId(7001L); @@ -611,7 +614,7 @@ class TripQueryServiceImplTest { when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null); when(waybillService.getById(WAYBILL_ID)) .thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES)); - when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID)); stubChildren(); TripVO lone = service.detail(tripQuery(String.valueOf(WAYBILL_ID))); @@ -627,13 +630,13 @@ class TripQueryServiceImplTest { @Test @DisplayName("详情的收口与列表一致:别人的运次、子单全被拒单的配载运次都不可达") void detailRefusesForeignTrips() { - when(loadingManageService.getById(LOADING_ID)).thenReturn(loading("13800000001")); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(OTHER_DRIVER_ID)); when(waybillService.getById(WAYBILL_ID)) .thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES)); assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID)))); - when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE)); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID)); when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of()); assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID)))); @@ -647,14 +650,119 @@ class TripQueryServiceImplTest { void rejectedChildrenLeaveTheTrip() { // 段一的可见口径已经把被拒子单挡在库层,取回来是空则这趟车不存在 stubRows(); - when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); - IPage page = service.page(TripQueryVO.forDriver(PHONE)); + IPage page = service.page(TripQueryVO.forDriver(DRIVER_ID)); assertTrue(page.getRecords().isEmpty(), "可见子单为空时不给没有子单的运次"); assertEquals(0, page.getTotal()); } + // ------------------------------------------------------------ 收口判据:司机档案 ID + + @Test + @DisplayName("司机改号后运次仍可见:判据是档案 ID,派单快照号不再参与") + void driverScopeSurvivesPhoneChange() { + Waybill lone = waybill(7001L, "YD-LONE-0001", null, "running", null, at(1000L), ACCEPT_YES); + stubRows( + waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES), + lone); + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(DRIVER_ID)); + when(waybillService.getById(7001L)).thenReturn(lone); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); + + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); + query.setCurrent(1); + query.setSize(10); + IPage page = service.page(query); + + assertEquals(2, page.getTotal(), "档案换号不影响归属:已配载与未配载两段都要按档案 ID 命中"); + assertEquals(Set.of(LOADING_NO, "YD-LONE-0001"), + page.getRecords().stream().map(TripVO::getTripNo).collect(java.util.stream.Collectors.toSet())); + assertTrue(page.getRecords().stream().allMatch(trip -> PHONE.equals(trip.getDriverPhone())), + "响应里的司机手机号仍是派单快照,改号后不变"); + + assertEquals(LOADING_NO, service.detail(tripQuery(String.valueOf(LOADING_ID))).getTripNo()); + assertEquals(PHONE, service.detail(tripQuery(String.valueOf(LOADING_ID))).getDriverPhone(), + "已配载运次详情仍回派单快照号"); + assertEquals(PHONE, service.detail(tripQuery(String.valueOf(7001L))).getDriverPhone(), + "未配载运次详情仍回派单快照号"); + } + + @Test + @DisplayName("同号换人不放行:手机号相同、档案 ID 不同的运次仍然看不到") + void samePhoneDoesNotGrantAccess() { + // 派单快照号一模一样,档案 ID 却是别人 + Waybill foreignLone = waybill(7002L, "YD-LONE-0002", null, "running", null, at(1000L), ACCEPT_YES); + foreignLone.setDriverId(OTHER_DRIVER_ID); + when(waybillService.getById(7002L)).thenReturn(foreignLone); + + assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(7002L))), + "快照号相同不代表同一个司机"); + + // 已配载段同样只认配载单上的档案 ID + when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(OTHER_DRIVER_ID)); + assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))), + "配载单归别人时详情同样拒绝"); + } + + @Test + @DisplayName("两段各按自己的 driver_id 判:已配载看配载单,未配载看运单") + void driverScopeReadsDriverIdOnBothSegments() { + Waybill loadedRow = waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, + at(2000L), ACCEPT_YES); + // 运单行归别人、配载单归本人:已配载段只认配载单 + loadedRow.setDriverId(OTHER_DRIVER_ID); + stubRows(loadedRow); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); + query.setCurrent(1); + query.setSize(10); + + assertEquals(1, service.page(query).getTotal(), "已配载段按配载单的 driver_id 收口,不看运单行的"); + + Waybill lone = waybill(7003L, "YD-LONE-0003", null, "running", null, at(1000L), ACCEPT_YES); + // 运单归本人、手机号却完全对不上:未配载段只认运单的 driver_id + lone.setDriverPhone("13800000003"); + stubRows(lone); + + IPage page = service.page(query); + assertEquals(1, page.getTotal(), "未配载段按运单的 driver_id 收口,不看手机号"); + assertEquals("13800000003", page.getRecords().get(0).getDriverPhone(), "展示的仍是运单上的派单快照号"); + } + + @Test + @DisplayName("管理端不按司机收口:带上任何 driverId 都照样全出") + void adminScopeIgnoresDriverId() { + stubRows( + waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES), + waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES)); + + for (Long driverId : java.util.Arrays.asList(null, 0L, -1L, OTHER_DRIVER_ID)) { + TripQueryVO admin = TripQueryVO.forAdmin(); + admin.setDriverId(driverId); + + assertEquals(2, service.page(admin).getTotal(), "管理端收口与司机无关:" + driverId); + } + } + + @Test + @DisplayName("收口缺省(空、0、负数)一律空集:不放行也不抛异常") + void missingDriverIdIsRefused() { + stubRows(waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES)); + + for (Long driverId : java.util.Arrays.asList(null, 0L, -1L, -9001L)) { + TripQueryVO query = TripQueryVO.forDriver(driverId); + + IPage page = service.page(query); + assertEquals(0, page.getTotal(), "收口缺失不能退回「不按司机收口」:" + driverId); + assertTrue(page.getRecords().isEmpty()); + assertEquals(0, service.tabCounts(query).getAll(), "统计同样为零:" + driverId); + assertThrows(ServiceException.class, () -> service.detail(query), "详情直接拒绝:" + driverId); + } + verify(waybillService, never()).list(anyWaybillWrapper()); + } + // ------------------------------------------------------------ 夹具 @SuppressWarnings("unchecked") @@ -712,7 +820,7 @@ class TripQueryServiceImplTest { } private List orgTypeTrips(String orgType) { - TripQueryVO query = TripQueryVO.forDriver(PHONE); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); query.setTransportOrgType(orgType); query.setCurrent(1); query.setSize(50); @@ -737,7 +845,7 @@ class TripQueryServiceImplTest { stubRows( waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES), waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO)); - when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE))); + when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(DRIVER_ID))); } private void stubChildren() { @@ -756,23 +864,25 @@ class TripQueryServiceImplTest { } private TripQueryVO tripQuery(String tripId) { - TripQueryVO query = TripQueryVO.forDriver(PHONE); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); query.setTripId(tripId); return query; } private LoadingManage loading(String loadingNo, Long loadingId) { - LoadingManage loading = loading(PHONE); + LoadingManage loading = loading(DRIVER_ID); loading.setId(loadingId); loading.setLoadingNo(loadingNo); return loading; } - private LoadingManage loading(String driverPhone) { + private LoadingManage loading(Long driverId) { LoadingManage loading = new LoadingManage(); loading.setId(LOADING_ID); loading.setLoadingNo(LOADING_NO); - loading.setDriverPhone(driverPhone); + loading.setDriverId(driverId); + // 配载单上留的是派单时的手机号快照,档案改号后它不变 + loading.setDriverPhone(PHONE); loading.setDriverName("黄宇富"); loading.setVehicleNo("桂A32523"); loading.setProjectName("武宣建筑原料运输项目"); @@ -791,6 +901,8 @@ class TripQueryServiceImplTest { waybill.setLoadingNo(loadingNo); waybill.setBusinessStatus(businessStatus); waybill.setDriverAcceptStatus(acceptStatus); + waybill.setDriverId(DRIVER_ID); + // 运单上留的是派单时的手机号快照,档案改号后它不变 waybill.setDriverPhone(PHONE); waybill.setProcessJson(processJson); waybill.setCreateTime(createTime); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java index ec37714d..e8432184 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/TripReadServiceBoundaryTest.java @@ -45,6 +45,7 @@ import static org.mockito.Mockito.when; class TripReadServiceBoundaryTest { private static final String PHONE = "17587421208"; + private static final Long DRIVER_ID = 9001L; private static final String LOADING_NO = "PZ-20261017-001"; /** 19 位雪花号,按数字下发会被双精度取整 */ private static final Long LOADING_ID = 2111350784070275074L; @@ -100,7 +101,7 @@ class TripReadServiceBoundaryTest { child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L))); // 「全部」按定义不含已取消档,别在它上面找这趟车;这一趟车本身仍要算成已取消 - TripQueryVO query = TripQueryVO.forDriver(PHONE); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); query.setStatus(3); TripVO trip = onlyTrip(query); @@ -205,7 +206,7 @@ class TripReadServiceBoundaryTest { void emptyChildrenDoNotThrow() { stubLoadedTrip(); - IPage page = service.page(TripQueryVO.forDriver(PHONE)); + IPage page = service.page(TripQueryVO.forDriver(DRIVER_ID)); assertTrue(page.getRecords().isEmpty(), "配载单在、可见子单为空时不给没有子单的运次"); @@ -239,6 +240,8 @@ class TripReadServiceBoundaryTest { bare.setBusinessStatus("pending"); bare.setDriverAcceptStatus("pending"); bare.setProcessJson(ACCEPT_YES); + bare.setDriverId(DRIVER_ID); + // 运单上留的是派单时的手机号快照,档案改号后它不变 bare.setDriverPhone(PHONE); when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(bare)); @@ -368,7 +371,7 @@ class TripReadServiceBoundaryTest { } private TripVO onlyTrip() { - return onlyTrip(TripQueryVO.forDriver(PHONE)); + return onlyTrip(TripQueryVO.forDriver(DRIVER_ID)); } private TripVO onlyTrip(TripQueryVO query) { @@ -378,7 +381,7 @@ class TripReadServiceBoundaryTest { } private TripQueryVO detailQuery() { - TripQueryVO query = TripQueryVO.forDriver(PHONE); + TripQueryVO query = TripQueryVO.forDriver(DRIVER_ID); query.setTripId(String.valueOf(LOADING_ID)); return query; } @@ -387,6 +390,8 @@ class TripReadServiceBoundaryTest { LoadingManage loading = new LoadingManage(); loading.setId(LOADING_ID); loading.setLoadingNo(LOADING_NO); + loading.setDriverId(DRIVER_ID); + // 配载单上留的是派单时的手机号快照 loading.setDriverPhone(PHONE); loading.setDriverName("黄宇富"); loading.setVehicleNo("桂A32523"); @@ -401,6 +406,8 @@ class TripReadServiceBoundaryTest { waybill.setLoadingNo(LOADING_NO); waybill.setBusinessStatus(businessStatus); waybill.setDriverAcceptStatus(acceptStatus); + waybill.setDriverId(DRIVER_ID); + // 运单上留的是派单时的手机号快照 waybill.setDriverPhone(PHONE); waybill.setProcessJson(processJson); waybill.setCreateTime(createTime); From 5be20465884fb170c28ae4d07fe972000f3a173b Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 21:45:38 +0800 Subject: [PATCH 09/27] =?UTF-8?q?:memo:=20=E4=BF=AE=E6=AD=A3=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E7=AB=AF=E8=BF=90=E5=8D=95=E6=8E=A5=E5=8F=A3=E7=9A=84?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E5=8F=A3=E5=BE=84=E6=8F=8F=E8=BF=B0=EF=BC=88?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E2=86=92=E5=8F=B8=E6=9C=BA=E6=A1=A3?= =?UTF-8?q?=E6=A1=88=20driver=5Fid=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DriverWaybillController.java | 14 +++++++------- .../transport/service/ITripQueryService.java | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index 06eaf200..96a6e25d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -65,14 +65,14 @@ public class DriverWaybillController extends BladeController { @GetMapping("/current-task") @ApiOperationSupport(order = 1) - @Operation(summary = "首页:当前运输中任务", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=running 的最新一条运单") + @Operation(summary = "首页:当前运输中任务", description = "当前司机档案(按登录用户解析)名下、businessStatus=running 的最新一条运单;归属判据是运单 driver_id") public R currentTask() { return R.data(driverWaybillService.currentTask()); } @GetMapping("/pending-preview") @ApiOperationSupport(order = 2) - @Operation(summary = "首页:待接运单预览", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=pending 的预览列表与总数") + @Operation(summary = "首页:待接运单预览", description = "当前司机档案名下、businessStatus=pending 的预览列表与总数;归属判据是运单 driver_id") public R pendingPreview( @Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) { return R.data(driverWaybillService.pendingPreview(size)); @@ -80,14 +80,14 @@ public class DriverWaybillController extends BladeController { @GetMapping("/counts") @ApiOperationSupport(order = 3) - @Operation(summary = "运单 Tab 统计", description = "仅统计当前用户手机号匹配运单 driverPhone 的运单:全部 / 待接单 / 进行中 / 已完成") + @Operation(summary = "运单 Tab 统计", description = "仅统计当前司机档案名下(运单 driver_id)的运单:全部 / 待接单 / 进行中 / 已完成") public R counts() { return R.data(driverWaybillService.tabCounts()); } @GetMapping("/page") @ApiOperationSupport(order = 4) - @Operation(summary = "运单分页列表", description = "仅返回运单 driverPhone 等于当前用户手机号的数据;status:空=全部,0待接单,1运输中,2已完成") + @Operation(summary = "运单分页列表", description = "仅返回运单 driver_id 等于当前司机档案的数据;status:空=全部,0待接单,1运输中,2已完成") public R> page( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, @@ -107,14 +107,14 @@ public class DriverWaybillController extends BladeController { @GetMapping("/task/current") @ApiOperationSupport(order = 21) - @Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条") + @Operation(summary = "首页:当前运输中运次", description = "当前司机档案可见的运输中或待确认(待回单确认)运次(有配载单的一趟车算一个)最新一条;配载段按配载单 driver_id,未配载段按运单 driver_id") public R tripCurrentTask() { return R.data(driverWaybillService.tripCurrentTask()); } @GetMapping("/task/pending-preview") @ApiOperationSupport(order = 22) - @Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)") + @Operation(summary = "首页:待接运次预览", description = "当前司机档案可见的待接运次预览与总数(按运次计)") public R tripPendingPreview( @Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) { return R.data(driverWaybillService.tripPendingPreview(size)); @@ -166,7 +166,7 @@ public class DriverWaybillController extends BladeController { @PostMapping("/track") @ApiOperationSupport(order = 11) - @Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)") + @Operation(summary = "司机端行驶轨迹", description = "按当前司机档案校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)") public R track(@Parameter(description = "运单ID", required = true) @RequestParam Long id) { return R.data(driverWaybillService.track(id)); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java index dbc87bde..c1c8ecb6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITripQueryService.java @@ -41,7 +41,7 @@ public interface ITripQueryService { *

* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。 * - * @param query 查询条件,收口由 driverPhone 或 allDrivers 给出 + * @param query 查询条件,收口由 driverId(司机档案)或 allDrivers 给出 * @return 运次分页 */ IPage page(TripQueryVO query); From 7504ad38f442035f084607f9b4b6a5d8cf11396a Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 22:15:09 +0800 Subject: [PATCH 10/27] =?UTF-8?q?:bug:=20=E9=85=8D=E8=BD=BD=E5=8D=95?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E6=8C=89=E9=9C=80=E8=A7=A3=E6=9E=90=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E5=BD=92=E5=B1=9E=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8A=B9?= =?UTF-8?q?=E6=8E=89=E5=B7=B2=E6=9C=89=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原实现每次保存都按手机号重解析 driver_id,与司机无关的保存会把归属清空并同步到子运单。改为:单据带正数 driver_id 直接沿用;更新时号码与库中一致且库中已有归属则保留;其余情况(新建、换号、原本无主)才按手机号在租户内唯一匹配,匹配不到落 null 并留脱敏 WARN,不阻断保存。复制配载单直接带源单归属。 --- .../impl/LoadingManageServiceImpl.java | 57 ++++++--- .../LoadingManageChildStatusWriteTest.java | 119 ++++++++++++++++++ .../transport/support/LogCapture.java | 48 +++++++ 3 files changed, 208 insertions(+), 16 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/support/LogCapture.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 13b6252e..63a9cbf4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -4,6 +4,7 @@ */ package org.springblade.transport.service.impl; +import cn.hutool.core.util.DesensitizedUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; @@ -11,6 +12,7 @@ import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; @@ -143,16 +145,17 @@ public class LoadingManageServiceImpl extends BaseServiceImpl oldWaybillIds = new ArrayList<>(); // 新建暂存为草稿;编辑已有单据时保持原业务状态(如待执行),避免被改回草稿 String businessStatus = STATUS_DRAFT; - if (Func.isNotEmpty(loadingManage.getId())) { - LoadingManage oldRecord = loadEditable(loadingManage.getId(), true); - oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson()); - loadingManage.setLoadingNo(oldRecord.getLoadingNo()); - loadingManage.setDeptId(oldRecord.getDeptId()); - loadingManage.setDeptName(oldRecord.getDeptName()); - if (Func.isNotEmpty(oldRecord.getBusinessStatus())) { - businessStatus = oldRecord.getBusinessStatus(); + LoadingManage stored = Func.isEmpty(loadingManage.getId()) ? null : loadEditable(loadingManage.getId(), true); + if (stored != null) { + oldWaybillIds = waybillIds(stored.getWaybillIdsJson()); + loadingManage.setLoadingNo(stored.getLoadingNo()); + loadingManage.setDeptId(stored.getDeptId()); + loadingManage.setDeptName(stored.getDeptName()); + if (Func.isNotEmpty(stored.getBusinessStatus())) { + businessStatus = stored.getBusinessStatus(); } } + applyDriverOwnership(loadingManage, stored); prepareCreateOrUpdate(loadingManage); loadingManage.setBusinessStatus(businessStatus); if (Func.isEmpty(loadingManage.getLoadingNo())) { @@ -204,6 +207,7 @@ public class LoadingManageServiceImpl extends BaseServiceImpl oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson()); loadingManage.setLoadingNo(oldRecord.getLoadingNo()); @@ -756,12 +761,6 @@ public class LoadingManageServiceImpl extends BaseServiceImpl0 才算有主,0 / -1 都是「无主」的哨兵写法,落库统一为 NULL - loadingManage.setDriverId(resolvedDriverId != null && resolvedDriverId > 0 ? resolvedDriverId : null); loadingManage.setEscortName(TransportBusinessSupport.trimToNull(loadingManage.getEscortName())); loadingManage.setEscortPhone(TransportBusinessSupport.trimToNull(loadingManage.getEscortPhone())); loadingManage.setCarrierType(TransportBusinessSupport.trimToNull(loadingManage.getCarrierType())); @@ -794,6 +793,32 @@ public class LoadingManageServiceImpl extends BaseServiceImpl + * 判定顺序:调用方带了正数档案 ID 就用它;更新时 driver_phone 与库中一致且库中 driver_id 非空就保留库中值; + * 其余情况(新建、换号、原本无主)按手机号在租户内唯一匹配司机档案,匹配不到落 null 并留 WARN,不阻断保存。 + */ + private void applyDriverOwnership(LoadingManage loadingManage, LoadingManage stored) { + Long driverId = loadingManage.getDriverId(); + if (driverId != null && driverId > 0) { + return; + } + if (stored != null && Func.isNotEmpty(stored.getDriverId()) + && Objects.equals(TransportBusinessSupport.trimToNull(stored.getDriverPhone()), loadingManage.getDriverPhone())) { + loadingManage.setDriverId(stored.getDriverId()); + return; + } + String tenantId = Func.isNotEmpty(loadingManage.getTenantId()) ? loadingManage.getTenantId() : AuthUtil.getTenantId(); + Long resolvedDriverId = driverService.resolveDriverIdByMobile(tenantId, loadingManage.getDriverPhone()); + loadingManage.setDriverId(resolvedDriverId); + if (resolvedDriverId == null) { + log.warn("配载单 {} 的司机手机号未唯一匹配司机档案,driver_id 落空。租户={} 手机号={}", + Func.isEmpty(loadingManage.getLoadingNo()) ? "(未编号)" : loadingManage.getLoadingNo(), + tenantId, DesensitizedUtil.mobilePhone(loadingManage.getDriverPhone())); + } + } + private void validate(LoadingManage loadingManage, boolean draftMode) { TransportBusinessSupport.validateRequired(loadingManage.getLoadingSubNos(), "配载子单号不能为空"); TransportBusinessSupport.validateRequired(loadingManage.getTransportType(), "运输类型不能为空"); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java index 6087f36b..3213ac95 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageChildStatusWriteTest.java @@ -1,5 +1,7 @@ package org.springblade.transport.service.impl; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; @@ -24,6 +26,7 @@ import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; import org.springblade.transport.service.IReceivablePayableDetailService; +import org.springblade.transport.support.LogCapture; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -35,6 +38,7 @@ import java.util.Map; import java.util.Set; 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.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -66,6 +70,9 @@ class LoadingManageChildStatusWriteTest { private static final String DRIVER_PHONE = "13800138000"; private static final String TENANT_ID = "000000"; private static final Long RESOLVED_DRIVER_ID = 9321L; + private static final Long MANUAL_DRIVER_ID = 7001L; + private static final Long OTHER_PROFILE_ID = 8888L; + private static final String OTHER_DRIVER_PHONE = "13900139000"; private static final Long SOURCE_LOADING_ID = 1800000000000000002L; private static final Long NEED_ACCEPT_WAYBILL_ID = 3101L; @@ -394,6 +401,110 @@ class LoadingManageChildStatusWriteTest { assertEquals(RESOLVED_DRIVER_ID, capturedSaved().getDriverId()); } + @Test + @DisplayName("已归属的单子号码没变:无关字段的保存不重解析,归属原样保留") + void unrelatedEditKeepsStoredDriverId() { + LoadingManage record = runningRecord(); + record.setDriverId(RESOLVED_DRIVER_ID); + stubLoadingRecord(record); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + // 这一次谁都解析不到:只要实现还按手机号重解析,归属就会被清空 + when(driverService.resolveDriverIdByMobile(any(), any())).thenReturn(null); + + assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID))); + + assertEquals(RESOLVED_DRIVER_ID, capturedLoading().getDriverId()); + assertEquals(RESOLVED_DRIVER_ID, statusSyncUpdates().get(0).setValue("driver_id")); + verify(driverService, never()).resolveDriverIdByMobile(any(), any()); + } + + @Test + @DisplayName("人工补过归属的单子:号码恰好命中别人的档案,保存也不改归属") + void unrelatedEditKeepsManualOwnership() { + LoadingManage record = runningRecord(); + record.setDriverId(MANUAL_DRIVER_ID); + stubLoadingRecord(record); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + when(driverService.resolveDriverIdByMobile(any(), any())).thenReturn(OTHER_PROFILE_ID); + + assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID))); + + assertEquals(MANUAL_DRIVER_ID, capturedLoading().getDriverId(), + "人工归属不能被一次无关保存改到同号的另一个档案上"); + assertEquals(MANUAL_DRIVER_ID, statusSyncUpdates().get(0).setValue("driver_id")); + } + + @Test + @DisplayName("-1 哨兵的归属:号码没变的保存原样保留,不抹成 NULL") + void sentinelDriverIdSurvivesUnrelatedEdit() { + LoadingManage record = runningRecord(); + record.setDriverId(-1L); + stubLoadingRecord(record); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + when(driverService.resolveDriverIdByMobile(any(), any())).thenReturn(null); + + assertTrue(service.submit(editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID))); + + assertEquals(-1L, capturedLoading().getDriverId()); + } + + @Test + @DisplayName("换司机:号码变了就按新号码重新解析,归属跟着换") + void changingPhoneResolvesNewDriverId() { + LoadingManage record = pendingRecord(); + record.setDriverId(RESOLVED_DRIVER_ID); + stubLoadingRecord(record); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + when(driverService.resolveDriverIdByMobile(any(), eq(OTHER_DRIVER_PHONE))).thenReturn(OTHER_PROFILE_ID); + LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID); + form.setDriverPhone(OTHER_DRIVER_PHONE); + + assertTrue(service.submit(form)); + + assertEquals(OTHER_PROFILE_ID, capturedLoading().getDriverId()); + assertEquals(OTHER_PROFILE_ID, statusSyncUpdates().get(0).setValue("driver_id")); + } + + @Test + @DisplayName("号码改成一个解析不到的号:归属落空,子单同步落空") + void changingPhoneToUnresolvedNumberClearsDriverId() { + LoadingManage record = pendingRecord(); + record.setDriverId(RESOLVED_DRIVER_ID); + stubLoadingRecord(record); + stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID)); + stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON)); + when(driverService.resolveDriverIdByMobile(any(), eq(OTHER_DRIVER_PHONE))).thenReturn(null); + LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID); + form.setDriverPhone(OTHER_DRIVER_PHONE); + + assertTrue(service.submit(form)); + + assertNull(capturedLoading().getDriverId()); + assertNull(statusSyncUpdates().get(0).setValue("driver_id")); + } + + @Test + @DisplayName("解析不到档案:留一条脱敏 WARN,保存照常成功") + void unresolvedPhoneLogsMaskedWarning() { + stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)); + stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON)); + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null); + try (LogCapture logs = LogCapture.open(LoadingManageServiceImpl.class)) { + assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID))); + + String warning = logs.events().stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.contains("司机档案")) + .findFirst() + .orElseThrow(() -> new AssertionError("解析失败必须留 WARN,实际日志:" + logs.messages())); + assertTrue(warning.contains("138****8000"), "WARN 里的号码必须脱敏,实际:" + warning); + assertFalse(warning.contains(DRIVER_PHONE), "WARN 里不能出现完整号码,实际:" + warning); + assertTrue(warning.contains(LOADING_NO), "WARN 要带业务标识(配载单号),实际:" + warning); + } + } + // ------------------------------------------------------------ 夹具 private LoadingManage loadingForm(Long... waybillIds) { @@ -468,6 +579,14 @@ class LoadingManageChildStatusWriteTest { return record; } + private LoadingManage pendingRecord() { + LoadingManage record = loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID); + record.setVehicleNo("桂A12345"); + record.setDriverName("张三"); + record.setDriverPhone(DRIVER_PHONE); + return record; + } + private LoadingManage copySource() { LoadingManage source = loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID); source.setLoadingSubNos("YD-" + NEED_ACCEPT_WAYBILL_ID); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LogCapture.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LogCapture.java new file mode 100644 index 00000000..7fa8d76b --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/LogCapture.java @@ -0,0 +1,48 @@ +package org.springblade.transport.support; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * 抓取某个类的日志输出(logback ListAppender)。 + *

+ * 「解析失败时留一条脱敏 WARN」这类要求只看返回值验不出来,必须断言真实日志正文。 + */ +public final class LogCapture implements AutoCloseable { + + private final Logger logger; + private final ListAppender appender; + + private LogCapture(Logger logger, ListAppender appender) { + this.logger = logger; + this.appender = appender; + } + + public static LogCapture open(Class loggerType) { + Logger logger = (Logger) LoggerFactory.getLogger(loggerType); + ListAppender appender = new ListAppender<>(); + appender.start(); + logger.addAppender(appender); + return new LogCapture(logger, appender); + } + + /** 已按占位符填充好的日志正文,按发生顺序 */ + public List messages() { + return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList(); + } + + public List events() { + return List.copyOf(appender.list); + } + + @Override + public void close() { + logger.detachAppender(appender); + appender.stop(); + } + +} From 48acd6a91e70dfbfde216946d9c2006546c0af29 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 22:15:16 +0800 Subject: [PATCH 11/27] =?UTF-8?q?:bug:=20=E6=B4=BE=E5=8D=95=E4=B8=8E?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E8=A7=A3=E6=9E=90=E4=B8=8D=E5=88=B0=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E6=A1=A3=E6=A1=88=E6=97=B6=E7=95=99=E8=84=B1=E6=95=8F?= =?UTF-8?q?=20WARN?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改派、主单派单、Excel 导入三条写路径在解析不到档案时只落空不阻断,补 log.warn(号码用 DesensitizedUtil.mobilePhone 脱敏)。同时去掉解析结果上多余的 id > 0 归一:解析入口只返回 null 或唯一命中的档案 ID。 --- .../service/impl/MasterOrderServiceImpl.java | 13 +++++++--- .../impl/WaybillImportBatchServiceImpl.java | 4 ++-- .../service/impl/WaybillServiceImpl.java | 12 ++++++---- .../service/impl/MasterOrderDriverIdTest.java | 23 ++++++++++++++++++ .../impl/WaybillImportDriverIdTest.java | 11 --------- .../impl/WaybillReassignDriverIdTest.java | 24 +++++++++++++++++++ 6 files changed, 67 insertions(+), 20 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java index 1f054b21..d459c234 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/MasterOrderServiceImpl.java @@ -4,8 +4,10 @@ */ package org.springblade.transport.service.impl; +import cn.hutool.core.util.DesensitizedUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.tool.jackson.JsonUtil; @@ -51,6 +53,7 @@ import java.util.Objects; * @author Chill */ @Service +@Slf4j public class MasterOrderServiceImpl extends BaseServiceImpl implements IMasterOrderService { private static final String CARRIER_SELF = "自运"; @@ -266,11 +269,15 @@ public class MasterOrderServiceImpl extends BaseServiceImpl 0 ? resolvedDriverId : null; + driverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), waybill.getDriverPhone()); + if (driverId == null) { + log.warn("总单 {} 派单的司机手机号未唯一匹配司机档案,driver_id 落空(不阻断派单)。租户={} 手机号={}", + masterOrder.getMasterNo(), waybill.getTenantId(), DesensitizedUtil.mobilePhone(waybill.getDriverPhone())); + } } waybill.setDriverId(driverId); waybill.setCaptainName(string(dispatch, "captainName")); waybill.setCabinNo(string(dispatch, "cabinNo")); waybill.setContainerNo(string(dispatch, "containerNo")); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index f78533ab..5eabbc36 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -500,12 +500,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl 0) { return; } + // 解析入口只返回 null 或唯一命中的档案 ID,不需要再按哨兵归一 Long resolvedDriverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), waybill.getDriverPhone()); - if (resolvedDriverId != null && resolvedDriverId > 0) { + if (resolvedDriverId != null) { waybill.setDriverId(resolvedDriverId); return; } - // driver_id 只有 >0 才算有主,0 / -1 都按无主落空 waybill.setDriverId(null); log.warn("第{}行 运单 {} 的司机手机号未唯一匹配司机档案,driver_id 留空(不阻断导入)", rowNo, waybill.getWaybillNo()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index aa3d3290..611b4e5f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -22,6 +22,7 @@ */ package org.springblade.transport.service.impl; +import cn.hutool.core.util.DesensitizedUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; @@ -1615,12 +1616,15 @@ public class WaybillServiceImpl extends BaseServiceImpl TransportBusinessSupport.validateRequired(vehicleNo, "车牌号不能为空"); // 归属主键是档案 ID,手机号只是派单时的号码快照:调用方带了档案 ID 就用它, - // 否则按手机号解析;承运商司机、未建档司机解析为空,派单照旧成功,只留 WARN + // 否则按手机号解析;解析入口只返回 null 或唯一命中的档案 ID,承运商司机、未建档司机 + // 解析为空,派单照旧成功,落空时留 WARN Long driverId = request.getDriverId(); if (driverId == null || driverId <= 0) { - Long resolvedDriverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), driverPhone); - // driver_id 只有 >0 才算有主,0 / -1 都是「无主」的哨兵写法,落库统一为 NULL - driverId = resolvedDriverId != null && resolvedDriverId > 0 ? resolvedDriverId : null; + driverId = driverService.resolveDriverIdByMobile(waybill.getTenantId(), driverPhone); + if (driverId == null) { + log.warn("运单 {} 的司机手机号未唯一匹配司机档案,driver_id 落空(不阻断派单)。租户={} 手机号={}", + waybill.getWaybillNo(), waybill.getTenantId(), DesensitizedUtil.mobilePhone(driverPhone)); + } } waybill.setDriverId(driverId); waybill.setDriverName(driverName); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java index 8872dd01..c9290709 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/MasterOrderDriverIdTest.java @@ -1,5 +1,7 @@ package org.springblade.transport.service.impl; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -11,6 +13,7 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProjectApplyService; import org.springblade.transport.service.ITransportPlanService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.LogCapture; import java.lang.reflect.Method; import java.util.LinkedHashMap; @@ -18,6 +21,7 @@ import java.util.List; import java.util.Map; 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.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -84,6 +88,25 @@ class MasterOrderDriverIdTest { verify(waybillService).submit(any(Waybill.class)); } + @Test + @DisplayName("解析不到档案:落空时留一条脱敏 WARN,派单照常成功") + void unresolvedProfileLogsMaskedWarning() throws Exception { + when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(null); + try (LogCapture logs = LogCapture.open(MasterOrderServiceImpl.class)) { + assertTrue(createWaybill(dispatch())); + + String warning = logs.events().stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.contains("司机档案")) + .findFirst() + .orElseThrow(() -> new AssertionError("解析失败必须留 WARN,实际日志:" + logs.messages())); + assertTrue(warning.contains("138****8000"), "WARN 里的号码必须脱敏,实际:" + warning); + assertFalse(warning.contains(DRIVER_PHONE), "WARN 里不能出现完整号码,实际:" + warning); + assertTrue(warning.contains("ZD-20261027-001"), "WARN 要带业务标识(总单号),实际:" + warning); + } + } + // ------------------------------------------------------------ 夹具 private boolean createWaybill(Map dispatch) throws Exception { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java index d9581932..b7bf273b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillImportDriverIdTest.java @@ -86,17 +86,6 @@ class WaybillImportDriverIdTest { assertNull(waybill.getDriverId()); } - @Test - @DisplayName("解析器返回 0(无主哨兵):按无主落空,不把 0 写进库") - void normalizesSentinelToNull() throws Exception { - when(driverService.resolveDriverIdByMobile(any(), eq(DRIVER_PHONE))).thenReturn(0L); - - Waybill waybill = importedWaybill(0L); - fillDriverOwnership(waybill, 3); - - assertNull(waybill.getDriverId()); - } - // ------------------------------------------------------------ 夹具 private void fillDriverOwnership(Waybill waybill, int rowNo) throws Exception { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java index 6ce47414..8646431a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillReassignDriverIdTest.java @@ -1,5 +1,7 @@ package org.springblade.transport.service.impl; +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import org.apache.ibatis.builder.MapperBuilderAssistant; @@ -12,11 +14,13 @@ import org.springblade.transport.mapper.WaybillMapper; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.support.LogCapture; import java.lang.reflect.Field; import java.util.Collections; 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.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -108,6 +112,25 @@ class WaybillReassignDriverIdTest { assertNull(capturedWaybill().getDriverId()); } + @Test + @DisplayName("解析不到档案:落空时留一条脱敏 WARN,派单照常成功") + void unresolvedProfileLogsMaskedWarning() { + when(driverService.resolveDriverIdByMobile(TENANT_ID, DRIVER_PHONE)).thenReturn(null); + try (LogCapture logs = LogCapture.open(WaybillServiceImpl.class)) { + assertTrue(service.reassignWithoutDeptCheck(reassignRequest(null))); + + String warning = logs.events().stream() + .filter(event -> event.getLevel() == Level.WARN) + .map(ILoggingEvent::getFormattedMessage) + .filter(message -> message.contains("司机档案")) + .findFirst() + .orElseThrow(() -> new AssertionError("解析失败必须留 WARN,实际日志:" + logs.messages())); + assertTrue(warning.contains("138****8000"), "WARN 里的号码必须脱敏,实际:" + warning); + assertFalse(warning.contains(DRIVER_PHONE), "WARN 里不能出现完整号码,实际:" + warning); + assertTrue(warning.contains("YD-20261027-001"), "WARN 要带业务标识(运单号),实际:" + warning); + } + } + // ------------------------------------------------------------ 夹具 private void setUpAgain() throws Exception { @@ -131,6 +154,7 @@ class WaybillReassignDriverIdTest { waybill.setId(WAYBILL_ID); waybill.setIsDeleted(0); waybill.setTenantId(TENANT_ID); + waybill.setWaybillNo("YD-20261027-001"); waybill.setBusinessStatus("pending"); waybill.setTransportType("road"); waybill.setDriverName("张三"); From bd97b880543c77d6265ae489e7d0cb6d3f97ebdb Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 22:15:25 +0800 Subject: [PATCH 12/27] =?UTF-8?q?:recycle:=20=E5=88=A0=E9=99=A4=20DriverWa?= =?UTF-8?q?ybillServiceImpl=20=E6=97=A0=E7=94=A8=E7=9A=84=20IMpUserClient?= =?UTF-8?q?=20=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该字段已无使用点,连带删掉 import 与 11 个测试构造实参;currentDriver 的注释改为只陈述当前归属判据。 --- .../transport/service/impl/DriverWaybillServiceImpl.java | 5 +---- .../transport/service/impl/DriverNodePunchPhotoTest.java | 2 -- .../transport/service/impl/DriverPunchFinishFlagTest.java | 2 -- .../transport/service/impl/DriverPunchNodeCardTest.java | 2 -- .../transport/service/impl/DriverTripAcceptRejectTest.java | 2 -- .../transport/service/impl/DriverTripCardTest.java | 4 ---- .../service/impl/DriverWaybillAutoCompleteTest.java | 2 -- .../service/impl/DriverWaybillCardLocationTest.java | 2 -- .../transport/service/impl/DriverWaybillNodePunchTest.java | 2 -- .../transport/service/impl/DriverWaybillOwnershipTest.java | 2 -- .../service/impl/DriverWaybillRejectDetachTest.java | 2 -- .../transport/service/impl/DriverWaybillTabStatusTest.java | 2 -- 12 files changed, 1 insertion(+), 28 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 8aab3912..9433a569 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -34,7 +34,6 @@ import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.core.tool.utils.Func; -import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -152,7 +151,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private final IWaybillService waybillService; private final IDriverService driverService; - private final IMpUserClient mpUserClient; private final IProcessConfigService processConfigService; private final ITransportVehicleService transportVehicleService; private final CertificationGate certificationGate; @@ -923,8 +921,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** * 当前登录司机:只按登录用户 ID 查司机档案,查不到即「无归属」。 *

- * 曾经查不到档案时按手机号回退匹配 driver.mobile,这让两个同号档案可以互相冒充, - * 已删除:手机号在归属链路上不再有任何作用。 + * 归属判据:driver_id = 当前登录人的司机档案 id;driver_phone 不参与判定。 */ private Driver currentDriver() { Long userId = AuthUtil.getUserId(); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java index bc544d0a..98dcafb8 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -12,7 +12,6 @@ import org.mockito.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; -import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -95,7 +94,6 @@ class DriverNodePunchPhotoTest { service = new DriverWaybillServiceImpl( waybillService, driverService, - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java index 06667c50..132a3b98 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java @@ -7,7 +7,6 @@ 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.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -92,7 +91,6 @@ class DriverPunchFinishFlagTest { service = new DriverWaybillServiceImpl( waybillService, mock(IDriverService.class), - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index 0009525f..25c4b5ed 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -10,7 +10,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springblade.core.tool.jackson.JsonUtil; -import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -87,7 +86,6 @@ class DriverPunchNodeCardTest { service = new DriverWaybillServiceImpl( waybillService, mock(IDriverService.class), - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java index 8e8e474d..cad12c15 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java @@ -14,7 +14,6 @@ import org.mockito.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.BladeUser; import org.springblade.core.secure.utils.AuthUtil; -import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -88,7 +87,6 @@ class DriverTripAcceptRejectTest { service = new DriverWaybillServiceImpl( waybillService, driverService, - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), certificationGate, diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java index d783008e..c0034f02 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java @@ -10,7 +10,6 @@ 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.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -74,7 +73,6 @@ class DriverTripCardTest { private IWaybillService waybillService; private IDriverService driverService; - private IMpUserClient mpUserClient; private ITripQueryService tripQueryService; private WaybillEnroutePunchMapper enroutePunchMapper; private WaybillNodePunchMapper nodePunchMapper; @@ -94,14 +92,12 @@ class DriverTripCardTest { void setUp() { waybillService = mock(IWaybillService.class); driverService = mock(IDriverService.class); - mpUserClient = mock(IMpUserClient.class); tripQueryService = mock(ITripQueryService.class); enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); nodePunchMapper = mock(WaybillNodePunchMapper.class); service = new DriverWaybillServiceImpl( waybillService, driverService, - mpUserClient, mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index 42b742fe..ab2bd8e5 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -11,7 +11,6 @@ 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.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -116,7 +115,6 @@ class DriverWaybillAutoCompleteTest { service = new DriverWaybillServiceImpl( waybillService, driverService, - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java index 13ace47b..af981add 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -7,7 +7,6 @@ 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.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -73,7 +72,6 @@ class DriverWaybillCardLocationTest { service = new DriverWaybillServiceImpl( waybillService, mock(IDriverService.class), - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java index f1223d3c..8c4ad4ec 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -7,7 +7,6 @@ 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.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -55,7 +54,6 @@ class DriverWaybillNodePunchTest { service = new DriverWaybillServiceImpl( mock(IWaybillService.class), mock(IDriverService.class), - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index ad19610d..2dc1e234 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -12,7 +12,6 @@ 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.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -76,7 +75,6 @@ class DriverWaybillOwnershipTest { service = new DriverWaybillServiceImpl( waybillService, driverService, - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 228d5800..dc4b349c 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -15,7 +15,6 @@ import org.mockito.ArgumentMatchers; 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.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -87,7 +86,6 @@ class DriverWaybillRejectDetachTest { service = new DriverWaybillServiceImpl( waybillService, driverService, - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), certificationGate, diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java index 256eff6b..029adb95 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -12,7 +12,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.MockedStatic; import org.springblade.core.secure.utils.AuthUtil; -import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -86,7 +85,6 @@ class DriverWaybillTabStatusTest { service = new DriverWaybillServiceImpl( waybillService, driverService, - mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), From 64ed3d476defc48f34d36093a5a0ff1c8aa911ce Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 22:15:32 +0800 Subject: [PATCH 13/27] =?UTF-8?q?:memo:=20=E8=BF=90=E6=AC=A1=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E6=B3=A8=E9=87=8A=E5=8F=AA=E9=99=88=E8=BF=B0=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 去掉「判据是 X 而不是 Y」「两段必须同时切否则」「缺省就等于把别人的运次发出去」这类推理与改删史表述,只留当前判据与行为。 --- .../org/springblade/transport/pojo/vo/TripQueryVO.java | 5 ++--- .../transport/service/impl/TripQueryServiceImpl.java | 8 +++----- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java index cbdc9f69..912b2b17 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TripQueryVO.java @@ -35,10 +35,9 @@ import java.util.Set; * 运次查询条件 *

* 司机端用 {@link #forDriver(Long)} 按司机档案 ID 收口;管理端用 {@link #forAdmin()} 不按司机收口。 - * 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。 + * 两者都不给时收口为空集。 *

- * 判据是司机档案 ID 而不是手机号:手机号在配载单/运单上只是派单时的快照,司机换号后并不跟随, - * 按号收口会让改过号的司机看不到自己手上的运次,号码被回收给别人时还会反向串号。 + * 归属判据只有司机档案 ID;driver_phone 是派单时的号码快照,不参与判定。 */ @Data @Schema(description = "运次查询条件") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java index 7c35161c..ee63e0b3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TripQueryServiceImpl.java @@ -389,12 +389,10 @@ public class TripQueryServiceImpl implements ITripQueryService { } /** - * 运次可见范围:配载段按配载单的司机档案 ID 收口,未配载段按运单的司机档案 ID 收口。 + * 运次可见范围:已配载段按配载单的 driver_id 收口,未配载段按运单的 driver_id 收口。 *

- * 判据是司机档案 ID 而不是手机号:司机换号后档案 ID 不变,而派单时落在配载单/运单上的手机号 - * 是快照,按号收口会让改过号的司机看不到自己手上的运次;号码被回收给别人时又会反向串号。 - * 两段必须同时切:只切一段,另一段就会把不属于该司机的运次放进来。 - * 缺省收口(空、0、负数)不放行,由 {@link #scoped} 在外层挡掉。 + * 归属判据只有司机档案 ID(null 或非正数视为无主),driver_phone 不参与判定。 + * 无主的运次由 {@link #scoped} 在外层挡掉。 */ private void applyScope(LambdaQueryWrapper wrapper, TripQueryVO condition) { if (condition.isAllDrivers()) { From 12025311be271e9dba955b47e2cff7fec9ad5319 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 22:15:32 +0800 Subject: [PATCH 14/27] =?UTF-8?q?:art:=20=E5=BD=92=E5=B1=9E=E6=94=B9?= =?UTF-8?q?=E9=80=A0=E7=9A=84=E8=A7=84=E8=8C=83=E8=BD=B4=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基线建表脚本补 blade_loading_manage.driver_id 与两表 (tenant_id, driver_id) 索引,与迁移脚本同名同位置;两份回填存档表改用 utf8mb4_general_ci;测试里的 var 改显式类型。 --- .../transport/service/impl/DriverResolveMobileTest.java | 5 +++-- .../blade_loading_manage_driver_id_backfill_20261028.sql | 4 ++-- doc/sql/transport/blade_tms_business.sql | 7 +++++-- .../blade_waybill_driver_id_backfill_20261028.sql | 4 ++-- 4 files changed, 12 insertions(+), 8 deletions(-) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java index 01db1969..89ba9503 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverResolveMobileTest.java @@ -96,8 +96,9 @@ class DriverResolveMobileTest { when(driverMapper.selectList(any())).thenReturn(List.of(profile(9001L))); Long driverId = null; - try (var ignored = org.mockito.Mockito.mockStatic(org.springblade.core.secure.utils.AuthUtil.class)) { - ignored.when(org.springblade.core.secure.utils.AuthUtil::getTenantId).thenReturn(TENANT_ID); + try (org.mockito.MockedStatic authUtil + = org.mockito.Mockito.mockStatic(org.springblade.core.secure.utils.AuthUtil.class)) { + authUtil.when(org.springblade.core.secure.utils.AuthUtil::getTenantId).thenReturn(TENANT_ID); driverId = service.resolveDriverIdByMobile(null, MOBILE); } assertEquals(9001L, driverId); diff --git a/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql index 8aa2da8a..1d82e778 100644 --- a/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql +++ b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql @@ -41,11 +41,11 @@ CREATE TABLE IF NOT EXISTS blade_loading_manage_driver_id_bak_20261028 ( loading_id bigint NOT NULL COMMENT '配载单ID', driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)', driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)', - driver_phone varchar(50) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', + driver_phone varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写', PRIMARY KEY (loading_id), KEY idx_driver_id_after (driver_id_after) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='20261028 配载单司机档案ID回填存档'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='20261028 配载单司机档案ID回填存档'; -- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。 -- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。 diff --git a/doc/sql/transport/blade_tms_business.sql b/doc/sql/transport/blade_tms_business.sql index a91fa295..4dbf5c84 100644 --- a/doc/sql/transport/blade_tms_business.sql +++ b/doc/sql/transport/blade_tms_business.sql @@ -300,7 +300,8 @@ CREATE TABLE `blade_waybill` ( KEY `idx_waybill_dept` (`dept_id`) USING BTREE, KEY `idx_waybill_plan_id` (`plan_id`) USING BTREE, KEY `idx_waybill_carrier_contract_id` (`carrier_contract_id`) USING BTREE, - KEY `idx_waybill_create_time` (`create_time`) USING BTREE + KEY `idx_waybill_create_time` (`create_time`) USING BTREE, + KEY `idx_waybill_tenant_driver_id` (`tenant_id`,`driver_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单管理'; -- ---------------------------- @@ -330,6 +331,7 @@ CREATE TABLE `blade_loading_manage` ( `trailer_vehicle_no` varchar(100) DEFAULT NULL COMMENT '挂车车牌号', `driver_name` varchar(100) DEFAULT NULL COMMENT '司机', `driver_phone` varchar(50) DEFAULT NULL COMMENT '联系电话', + `driver_id` bigint(20) DEFAULT NULL COMMENT '司机ID', `escort_name` varchar(100) DEFAULT NULL COMMENT '押运人', `escort_phone` varchar(50) DEFAULT NULL COMMENT '押运人手机号', `carrier_type` varchar(50) DEFAULT NULL COMMENT '承运类型', @@ -359,7 +361,8 @@ CREATE TABLE `blade_loading_manage` ( UNIQUE KEY `uk_loading_manage_no` (`loading_no`) USING BTREE, KEY `idx_loading_manage_carrier_contract_id` (`carrier_contract_id`) USING BTREE, KEY `idx_loading_manage_dept` (`dept_id`) USING BTREE, - KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE + KEY `idx_loading_manage_create_time` (`create_time`) USING BTREE, + KEY `idx_loading_manage_tenant_driver_id` (`tenant_id`,`driver_id`) USING BTREE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='配载管理'; -- ---------------------------- diff --git a/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql index e42a82b9..99836734 100644 --- a/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql +++ b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql @@ -44,11 +44,11 @@ CREATE TABLE IF NOT EXISTS blade_waybill_driver_id_bak_20261028 ( waybill_id bigint NOT NULL COMMENT '运单ID', driver_id_before bigint DEFAULT NULL COMMENT '回填前的司机ID(本脚本只处理 NULL)', driver_id_after bigint DEFAULT NULL COMMENT '回填写入的司机档案ID(blade_transport_driver.id)', - driver_phone varchar(50) COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', + driver_phone varchar(50) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '回填前的手机号快照,用来证明没有被改写', update_time_before datetime DEFAULT NULL COMMENT '回填前的更新时间,用来证明没有被改写', PRIMARY KEY (waybill_id), KEY idx_driver_id_after (driver_id_after) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='20261028 运单司机档案ID回填存档'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='20261028 运单司机档案ID回填存档'; -- 命中映射:只收「号码唯一命中一条未删除档案」的 NULL 行。 -- INSERT IGNORE + WHERE driver_id IS NULL:第二次执行不会重复建映射,已经填过的行不会再进映射。 From 231f4b9ac5cf989c5f6cee3c7ac0bd9062dbcd82 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 22:22:54 +0800 Subject: [PATCH 15/27] =?UTF-8?q?:bug:=20=E4=BF=AE=E6=AD=A3=E5=9B=9E?= =?UTF-8?q?=E5=A1=AB=E8=84=9A=E6=9C=AC=E5=BF=AB=E7=85=A7=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E7=9A=84=E6=8E=92=E5=BA=8F=E8=A7=84=E5=88=99=E6=B7=B7=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../blade_loading_manage_driver_id_backfill_20261028.sql | 5 +++-- .../transport/blade_waybill_driver_id_backfill_20261028.sql | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql index 1d82e778..b2a42d36 100644 --- a/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql +++ b/doc/sql/transport/blade_loading_manage_driver_id_backfill_20261028.sql @@ -22,8 +22,9 @@ -- ⑤ 逻辑删除的配载单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围; -- 这些单不会回到列表,回填对它们无副作用。 -- ⑥ 跨表比较显式 COLLATE:blade_loading_manage.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci, --- blade_transport_driver.mobile / tenant_id 是 utf8mb4_general_ci; +-- blade_transport_driver.mobile / tenant_id 与存档表都是 utf8mb4_general_ci; -- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。 +-- 第 2.4 节拿存档表比 driver_phone 同样要带 COLLATE utf8mb4_0900_ai_ci。 -- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。 -- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。 -- @@ -151,7 +152,7 @@ SELECT COUNT(*) AS wrong_driver_id SELECT COUNT(*) AS snapshot_rewritten FROM blade_loading_manage_driver_id_bak_20261028 b JOIN blade_loading_manage w ON w.id = b.loading_id - WHERE NOT (w.driver_phone <=> b.driver_phone) + WHERE NOT (w.driver_phone <=> b.driver_phone COLLATE utf8mb4_0900_ai_ci) OR NOT (w.update_time <=> b.update_time_before); -- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案) diff --git a/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql index 99836734..22417a64 100644 --- a/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql +++ b/doc/sql/transport/blade_waybill_driver_id_backfill_20261028.sql @@ -24,8 +24,9 @@ -- ⑤ 逻辑删除的运单同样回填:票面口径只有 driver_id IS NULL,不额外收窄范围; -- 这些单不会回到列表,回填对它们无副作用。 -- ⑥ 跨表比较显式 COLLATE:blade_waybill.driver_phone / tenant_id 是 utf8mb4_0900_ai_ci, --- blade_transport_driver.mobile / tenant_id 是 utf8mb4_general_ci; +-- blade_transport_driver.mobile / tenant_id 与存档表都是 utf8mb4_general_ci; -- 不显式 COLLATE 会抛 ERROR 1267 Illegal mix of collations,只有真跑库才会暴露。 +-- 第 2.4 节拿存档表比 driver_phone 同样要带 COLLATE utf8mb4_0900_ai_ci。 -- ⑦ 取数与写入全在 SQL 内完成,不经 JSON / 客户端中转:19 位雪花 ID 过 JSON 会丢精度。 -- ⑧ 分批执行(默认每批 500 行),每批独立提交,中断后重跑从剩余 NULL 行继续。 -- @@ -154,7 +155,7 @@ SELECT COUNT(*) AS wrong_driver_id SELECT COUNT(*) AS snapshot_rewritten FROM blade_waybill_driver_id_bak_20261028 b JOIN blade_waybill w ON w.id = b.waybill_id - WHERE NOT (w.driver_phone <=> b.driver_phone) + WHERE NOT (w.driver_phone <=> b.driver_phone COLLATE utf8mb4_0900_ai_ci) OR NOT (w.update_time <=> b.update_time_before); -- 2.5 冲突清单:同号多档案,这些行被跳过(期望 0 行;有输出就是「为什么没填」的答案) From 2e25754d7eeb0b4e14a28f12bbb83b685647f025 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 22:35:17 +0800 Subject: [PATCH 16/27] =?UTF-8?q?:recycle:=20=E5=8F=B8=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E8=A7=A3=E6=9E=90=E6=94=B6=E5=8F=A3=E5=88=B0?= =?UTF-8?q?=20user=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/DriverAppController.java | 5 +- .../transport/service/IDriverAppService.java | 11 +- .../service/impl/DriverAppServiceImpl.java | 86 ++---------- .../impl/DriverAppServiceImplTest.java | 125 ++++++++++-------- 4 files changed, 91 insertions(+), 136 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAppController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAppController.java index 6040145d..e43b197f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAppController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAppController.java @@ -54,9 +54,10 @@ public class DriverAppController extends BladeController { @GetMapping("/mine") @ApiOperationSupport(order = 1) - @Operation(summary = "当前登录司机档案", description = "按手机号匹配 blade_transport_driver.mobile;可传 mobile,未传则从登录态解析") + @Operation(summary = "当前登录司机档案", description = "按登录用户的 user_id 匹配 blade_transport_driver.user_id") + // mobile:旧版小程序仍会带这个参数,已不参与解析,保留只为不让老客户端报错 public R mine(@RequestParam(required = false) String mobile) { - return R.data(driverAppService.currentByPhone(mobile)); + return R.data(driverAppService.current()); } @GetMapping("/vehicles") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java index d7c7d34a..9e6d5b12 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java @@ -34,11 +34,11 @@ import java.util.List; public interface IDriverAppService { /** - * 当前登录用户对应的司机档案(按手机号匹配 blade_transport_driver.mobile) + * 当前登录用户对应的司机档案(按 user_id 匹配 blade_transport_driver.user_id) * - * @param mobile 小程序可显式传入当前用户手机号;为空时从登录态解析 + * @return 司机档案;未登录抛异常,查不到返回 null */ - DriverVO currentByPhone(String mobile); + DriverVO current(); /** * 当前司机绑定的车辆列表(driver.driving_vehicle ↔ vehicle.plate_no) @@ -49,10 +49,9 @@ public interface IDriverAppService { * 当前登录用户对应的司机**实体** *

* 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle}, - * 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配, - * 匹配不到再按 {@code user_id} 兜底。 + * 故这里返回实体而非视图对象。解析口径与 {@link #current()} 一致。 * - * @return 司机实体;未登录或查不到时返回 null + * @return 司机实体;未登录抛异常,查不到返回 null */ Driver currentDriverEntity(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java index 148ce41e..c60245f8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java @@ -27,10 +27,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; -import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; -import org.springblade.system.feign.IMpUserClient; -import org.springblade.system.pojo.entity.MpUser; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; @@ -57,17 +54,16 @@ public class DriverAppServiceImpl implements IDriverAppService { private final IDriverService driverService; private final ITransportVehicleService transportVehicleService; - private final IMpUserClient mpUserClient; @Override - public DriverVO currentByPhone(String mobile) { - Driver driver = currentDriver(mobile); + public DriverVO current() { + Driver driver = currentDriver(); return driver == null ? null : DriverWrapper.build().entityVO(driver); } @Override public List myVehicles() { - Driver driver = currentDriver(null); + Driver driver = currentDriver(); if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { return List.of(); } @@ -92,28 +88,23 @@ public class DriverAppServiceImpl implements IDriverAppService { @Override public Driver currentDriverEntity() { - return currentDriver(null); + return currentDriver(); } - private Driver currentDriver(String mobileHint) { + /** + * 当前司机的唯一解析入口:只按登录用户的 {@code user_id} 查档案。 + *

+ * 手机号不能用来认人:同一号码可能存在多条档案,按号码查「最近更新的一条」 + * 取到的可能是别人的档案;号码本身也会被改。 + */ + private Driver currentDriver() { Long userId = AuthUtil.getUserId(); if (userId == null || userId <= 0) { throw new ServiceException("未登录"); } - String phone = resolvePhone(userId, mobileHint); - Driver driver = null; - if (Func.isNotEmpty(phone)) { - driver = driverService.getOne(Wrappers.lambdaQuery() - .eq(Driver::getMobile, phone) - .orderByDesc(Driver::getUpdateTime) - .last("LIMIT 1")); - } - if (driver == null) { - driver = driverService.getOne(Wrappers.lambdaQuery() - .eq(Driver::getUserId, userId) - .last("LIMIT 1")); - } - return driver; + return driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getUserId, userId) + .last("LIMIT 1")); } private DriverVehicleCardVO toCard(TransportVehicle vehicle) { @@ -178,53 +169,4 @@ public class DriverAppServiceImpl implements IDriverAppService { return date == null ? "" : date.toString(); } - /** - * 解析用于匹配司机档案的手机号。 - * 优先级:前端传入且与本人一致的 mobile → JWT account(司机账号多为手机号)→ 用户中心 phone/account - */ - private String resolvePhone(Long userId, String mobileHint) { - String selfPhone = resolveSelfPhone(userId); - String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim(); - if (Func.isNotEmpty(hint)) { - if (Func.isEmpty(selfPhone)) { - // 解析不出本人号码时不能拿前端传的 mobile 去查: - // currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。 - // 这里返回空让它退回按 user_id 查,宁可查不到也不越权。 - return null; - } - if (!selfPhone.equals(hint)) { - throw new ServiceException("只能查询本人司机档案"); - } - return hint; - } - return selfPhone; - } - - private String resolveSelfPhone(Long userId) { - String account = AuthUtil.getUserAccount(); - if (isMobileLike(account)) { - return account.trim(); - } - // 小程序用户 phone 带 @Sensitive(MOBILE),经 Feign 回来是脱敏串, - // 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。 - R result = mpUserClient.userById(userId); - if (result != null && R.isSuccess(result) && result.getData() != null) { - String userAccount = result.getData().getAccount(); - if (isMobileLike(userAccount)) { - return userAccount.trim(); - } - } - return Func.isEmpty(account) ? null : account.trim(); - } - - /** - * 是否是「可作为司机档案手机号使用」的号码串。 - *

- * 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号 - * (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。 - */ - private boolean isMobileLike(String value) { - return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); - } - } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java index 9878ad99..746259c8 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java @@ -1,15 +1,16 @@ package org.springblade.transport.service.impl; +import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +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.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; -import org.springblade.core.tool.api.R; -import org.springblade.system.feign.IMpUserClient; -import org.springblade.system.pojo.entity.MpUser; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; @@ -17,19 +18,23 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ITransportVehicleService; import java.time.LocalDate; +import java.util.Date; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +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.when; /** - * 司机端车辆卡片(重新认证表单的数据来源)测试。 + * 司机端「当前司机」解析与车辆卡片(重新认证表单的数据来源)测试。 *

- * 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约: + * 解析侧盯住 ADR-0028 的唯一入口:登录用户 → {@code user_id} → 档案,手机号只是档案上的一个字段, + * 不参与查找;卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约: * 日期字段只能是 {@code yyyy-MM-dd} 或空串(不能是「长期」这类文字); * 标了长期有效的字段不能再下发一个作废的止期。 */ @@ -42,11 +47,17 @@ class DriverAppServiceImplTest { private ITransportVehicleService transportVehicleService; private DriverAppServiceImpl service; + @BeforeAll + static void initTableInfo() { + // 实体元数据就绪后,LambdaQueryWrapper 才能把 Driver::getUserId 解析成列名 + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), Driver.class); + } + @BeforeEach void setUp() { driverService = mock(IDriverService.class); transportVehicleService = mock(ITransportVehicleService.class); - service = new DriverAppServiceImpl(driverService, transportVehicleService, mock(IMpUserClient.class)); + service = new DriverAppServiceImpl(driverService, transportVehicleService); } @Test @@ -85,33 +96,69 @@ class DriverAppServiceImplTest { assertEquals("2028-12-04", card.getRoadTransportCertStartDate()); } - // ---------------------------------------------------------------- 手机号解析 + // ---------------------------------------------------------------- 当前司机解析 @Test - @DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案") - void twelveDigitAccountIsUsableAsPhone() { - assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName()); + @DisplayName("同号两条档案时取登录人自己那条——按手机号取「最近更新的一条」会把冒名者当成当前司机") + void currentDriverComesFromUserIdNotMobile() { + Driver owner = driver(9001L, USER_ID, "黄大富", MOBILE); + owner.setUpdateTime(new Date(1735689600000L)); + Driver impostor = driver(9002L, 7002L, "冒名者", MOBILE); + impostor.setUpdateTime(new Date(1767225600000L)); + + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenAnswer(invocation -> { + LambdaQueryWrapper wrapper = invocation.getArgument(0); + String sqlSegment = wrapper.getSqlSegment(); + if (sqlSegment.contains("mobile")) { + return impostor; + } + return sqlSegment.contains("user_id") ? owner : null; + }); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + // 司机账号本身就是手机号:手机号优先的实现正是靠它命中那条同号档案 + authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE); + assertEquals("黄大富", service.current().getDriverName()); + } } @Test - @DisplayName("小程序用户 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权") - void maskedUserPhoneFallsBackToAccount() { - MpUser user = new MpUser(); - user.setPhone("175****12082"); - user.setAccount("175874212082"); + @DisplayName("没有档案时返回空数据而不是异常——没档案的登录用户看到的是空页面、也不能操作") + void missingProfileReturnsEmptyInsteadOfFailing() { + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(null); - assertEquals("黄大富", currentByPhone(null, null, user).getDriverName()); + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + assertNull(service.current(), "查不到档案时档案为空"); + assertNull(service.currentDriverEntity(), "查不到档案时实体为空"); + assertTrue(service.myVehicles().isEmpty(), "查不到档案时车辆列表为空"); + } } @Test - @DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案") - void otherPeoplesMobileIsRejected() { - assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class, - () -> currentByPhone("13900000001", "175874212082", null)).getMessage()); + @DisplayName("未登录直接抛异常,不能当成「没有档案」静默返回空") + void anonymousIsRejected() { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(null); + assertEquals("未登录", assertThrows(ServiceException.class, + () -> service.current()).getMessage()); + assertEquals("未登录", assertThrows(ServiceException.class, service::myVehicles).getMessage()); + assertEquals("未登录", assertThrows(ServiceException.class, service::currentDriverEntity).getMessage()); + } } // ---------------------------------------------------------------- 辅助 + private Driver driver(Long id, Long userId, String driverName, String mobile) { + Driver driver = new Driver(); + driver.setId(id); + driver.setUserId(userId); + driver.setDriverName(driverName); + driver.setMobile(mobile); + return driver; + } + /** 造一辆只关心有效期三个字段的车 */ private TransportVehicle vehicle(LocalDate licenseStart, LocalDate licenseEnd, Integer licenseLongTerm) { TransportVehicle vehicle = new TransportVehicle(); @@ -129,13 +176,9 @@ class DriverAppServiceImplTest { return vehicle; } - /** 按当前登录司机的手机号跑一次「我的车辆」,取第一张卡片 */ + /** 按当前登录司机跑一次「我的车辆」,取第一张卡片 */ private DriverVehicleCardVO firstCard(TransportVehicle vehicle) { - Driver driver = new Driver(); - driver.setId(9001L); - driver.setUserId(USER_ID); - driver.setDriverName("韦司机"); - driver.setMobile(MOBILE); + Driver driver = driver(9001L, USER_ID, "韦司机", MOBILE); driver.setDrivingVehicle(vehicle.getPlateNo()); when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); @@ -143,39 +186,9 @@ class DriverAppServiceImplTest { try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); - // 账号本身就是手机号,resolvePhone 走这条短路分支,不再回调用户中心 - authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE); List cards = service.myVehicles(); assertEquals(1, cards.size(), "应当只返回绑定到本人的这一辆车"); return cards.get(0); } } - - /** - * 按给定的登录态跑一次 currentByPhone。 - * - * @param mobileHint 前端传入的 mobile,可为 null - * @param account JWT 里的账号,可为 null - * @param user 小程序用户,可为 null(表示走不到小程序用户中心) - */ - private Driver currentByPhone(String mobileHint, String account, MpUser user) { - IMpUserClient mpUserClient = mock(IMpUserClient.class); - if (user != null) { - when(mpUserClient.userById(USER_ID)).thenReturn(R.data(user)); - } - DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, mpUserClient); - - Driver driver = new Driver(); - driver.setId(9002L); - driver.setUserId(USER_ID); - driver.setDriverName("黄大富"); - driver.setMobile("175874212082"); - when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); - - try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { - authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); - authUtil.when(AuthUtil::getUserAccount).thenReturn(account); - return scoped.currentByPhone(mobileHint); - } - } } From 6ac6cf9bffad39f98505b07359bce52000760d19 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 23:53:32 +0800 Subject: [PATCH 17/27] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E9=85=8D?= =?UTF-8?q?=E8=BD=BD=E6=89=BF=E8=BF=90=E5=95=86=E4=B9=99=E6=96=B9=E4=BA=A4?= =?UTF-8?q?=E9=9B=86=E5=9C=A8=E7=BC=BA=E5=AE=A2=E6=88=B7=E5=90=88=E5=90=8C?= =?UTF-8?q?=E4=B8=8E=E6=97=A0=E5=8F=AF=E7=94=A8=E5=90=88=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E7=9A=84=E6=8A=A5=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 子运单全都没有客户合同时,甲方映射是空表,按 null 键取甲方抛 NPE,配载提交 500; 项目下没有可用承运商合同时乙方交集必然为空,被当成「无共同乙方」拦截,自运的多张子运单因此配不进去。 金蝶客商同步测试补 HCS 占位地址:本地未配置 thirdParty.hcs.baseUrl 时 Feign 客户端会让整个上下文起不来。 --- .../impl/LoadingManageServiceImpl.java | 8 +++++- .../CustomerArchiveSyncKingdeeDataTest.java | 4 ++- .../impl/LoadingManageAdmissionTest.java | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java index 63a9cbf4..41dd279c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/LoadingManageServiceImpl.java @@ -952,6 +952,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl contracts = availableCarrierContracts(projectIdsOf(waybills)); + // 项目下没有任何可用承运商合同时不存在乙方交集约束;承运类型为「承运商」由 validateCarrierContract 要求选择合同 + if (Func.isEmpty(contracts)) { + return; + } Set allowedPartyBs = intersectCarrierPartyBs(waybills, contracts); if (allowedPartyBs.isEmpty()) { throw new ServiceException("所选运单在同一项目下没有共同的承运商合同乙方,不能配载到同一配载单"); @@ -1071,7 +1075,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl Objects.equals(contract.getProjectId(), waybill.getProjectId())) .filter(contract -> Func.isEmpty(partyA) || Objects.equals(normalizePartyName(contract.getPartyA()), partyA)) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java index f06cbcc8..b05844f1 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java @@ -40,7 +40,9 @@ import org.springframework.boot.test.context.SpringBootTest; */ @SpringBootTest(classes = TransportApplication.class, properties = { "spring.profiles.active=dev", - "spring.main.allow-bean-definition-overriding=true" + "spring.main.allow-bean-definition-overriding=true", + // HCS 是可选外部集成,本地 Nacos 未配置该地址时 Feign 客户端会让整个上下文起不来;本测试不调用 HCS + "thirdParty.hcs.baseUrl=http://localhost:9" }) class CustomerArchiveSyncKingdeeDataTest { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java index b71ce064..f3bbe693 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/LoadingManageAdmissionTest.java @@ -294,6 +294,33 @@ class LoadingManageAdmissionTest { assertEquals("所选承运商合同不存在、未审核通过或已失效", exception.getMessage()); } + @Test + @DisplayName("子运单的客户合同快照为空时不按甲方过滤,取不到甲方也不空指针") + void missingCustomerContractDoesNotFilterByPartyA() { + Waybill withSnapshot = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A); + withSnapshot.setContractId(OTHER_CONTRACT_ID); + stubWaybills(withSnapshot, waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_A)); + stubContract(CONTRACT_ID, PROJECT_A); + + assertDoesNotThrow(() -> service.submit(submitPayload(List.of(1L, 2L)))); + } + + @Test + @DisplayName("项目下没有可用承运商合同时不按乙方交集拦截:自运的多张子运单照常提交") + void noAvailableCarrierContractSkipsPartyBCheck() { + stubWaybills( + waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A), + waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_A)); + when(contractManageService.list(anyContractWrapper())).thenReturn(List.of()); + LoadingManage payload = submitPayload(List.of(1L, 2L)); + payload.setCarrierType("自运"); + payload.setCarrierContractId(null); + payload.setDriverName("张三"); + payload.setDriverPhone("13800008000"); + + assertDoesNotThrow(() -> service.submit(payload)); + } + // ------------------------------------------------------------ 放行 @Test From 1b19d4e60c62e55875c586e5efa66390202e2417 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Tue, 27 Oct 2026 23:57:47 +0800 Subject: [PATCH 18/27] =?UTF-8?q?:rewind:=20=E5=9B=9E=E9=80=80=E9=87=91?= =?UTF-8?q?=E8=9D=B6=E5=AE=A2=E5=95=86=E5=90=8C=E6=AD=A5=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E7=9A=84=20HCS=20=E5=8D=A0=E4=BD=8D=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 该测试的上下文依赖本机 Nacos 配置,不在本仓库补默认值;本地跑全量时用 -Dtest='!CustomerArchiveSyncKingdeeDataTest' 跳过。 --- .../transport/CustomerArchiveSyncKingdeeDataTest.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java index b05844f1..f06cbcc8 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/CustomerArchiveSyncKingdeeDataTest.java @@ -40,9 +40,7 @@ import org.springframework.boot.test.context.SpringBootTest; */ @SpringBootTest(classes = TransportApplication.class, properties = { "spring.profiles.active=dev", - "spring.main.allow-bean-definition-overriding=true", - // HCS 是可选外部集成,本地 Nacos 未配置该地址时 Feign 客户端会让整个上下文起不来;本测试不调用 HCS - "thirdParty.hcs.baseUrl=http://localhost:9" + "spring.main.allow-bean-definition-overriding=true" }) class CustomerArchiveSyncKingdeeDataTest { From a52759678a9e3299cb3d8eb3fa9fc301ae2cbfad Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Wed, 28 Oct 2026 01:24:04 +0800 Subject: [PATCH 19/27] =?UTF-8?q?:sparkles:=20=E8=AE=A4=E8=AF=81=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=96=B0=E5=A2=9E=E4=BF=AE=E8=AE=A2=E7=89=88=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核员修订提交内容后需要留痕:snapshot_json 记「司机提交了什么」且永不修改, 修订版另存 audited_snapshot_json,「通过时认定了什么内容」才有据可查。 - 新增列 audited_snapshot_json longtext NULL,位置在 snapshot_json 之后,不进任何索引 - 幂等:查 information_schema 判列,列已存在即跳过;表不存在时故意报 1146 防假绿 - 同步基线建表脚本,避免「迁移加列、基线没同步」 --- .../blade_transport_cert_record_20261002.sql | 1 + ...rt_cert_record_audit_revision_20261028.sql | 68 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql index 8e9b8870..4d14057a 100644 --- a/doc/sql/transport/blade_transport_cert_record_20261002.sql +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -14,6 +14,7 @@ CREATE TABLE IF NOT EXISTS `blade_transport_cert_record` ( `biz_key` varchar(100) DEFAULT NULL COMMENT '展示键:司机姓名/车牌号', `status` int(11) NOT NULL COMMENT '审核状态:0审核中 1已通过 2已驳回 3已作废', `snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '该次提交的全量认证内容JSON,一经创建不可修改', + `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '审核通过时确定的修订版内容;NULL=未修订', `reject_reason` varchar(200) DEFAULT NULL COMMENT '驳回原因,仅状态为已驳回时有值', `submit_time` datetime DEFAULT NULL COMMENT '提交时间', `submit_user` bigint(20) DEFAULT NULL COMMENT '提交人', diff --git a/doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql b/doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql new file mode 100644 index 00000000..786742ef --- /dev/null +++ b/doc/sql/transport/blade_transport_cert_record_audit_revision_20261028.sql @@ -0,0 +1,68 @@ +-- ---------------------------- +-- 认证记录:新增修订版快照列 audited_snapshot_json(2026-10-28) +-- ---------------------------- +-- 背景:审核员可以在审核页面对提交内容做修订(全字段可改,身份字段除外)。 +-- 修订结果必须留痕:snapshot_json 是「司机当时提交了什么」,一经创建逐字节不可改; +-- 修订版另存一列,「审核通过时到底认定了什么内容」才查得清。 +-- +-- 语义: +-- NULL —— 未修订,生效内容即 snapshot_json; +-- 非 NULL —— 审核通过时认定的修订版全量 JSON,生效内容以它为准。 +-- 只在「通过」时同事务写入;驳回不写(驳回不产生生效内容,留修订版只会让人误读)。 +-- 修订人/修订时间复用 audit_user / audit_time,不新增列。 +-- +-- 只做结构变更,不回填数据:存量记录一律保持 NULL(历史审核确实没有修订动作)。 +-- +-- 不加索引:本列只在审核详情与生效内容读取时按主键取用,从不作为查询条件; +-- longtext 建索引会超出索引键长度并拖慢写入。 +-- +-- 幂等:先查 information_schema 判列,列已存在(或类型/可空性已符合)即跳过; +-- 只用 PREPARE 动态语句、不用存储过程,因此不需要 DELIMITER——按分号逐句切分的 +-- 客户端(含数据库驱动)也能直接执行。重复执行零变更。 + +SET @cert_record_schema = DATABASE(); + +-- ---------------------------- +-- 0. 表存在性守卫 +-- ---------------------------- +-- 表不存在时故意执行一条只读查询,让客户端报 1146「Table ... doesn't exist」; +-- 否则下面因查不到列而静默跳过,脚本会假绿。 +SET @cert_record_guard_sql = ( + SELECT IF(COUNT(*) > 0, + 'SELECT 1', + 'SELECT 1 FROM `blade_transport_cert_record` LIMIT 0') + FROM information_schema.tables + WHERE table_schema = @cert_record_schema + AND table_name = 'blade_transport_cert_record' +); +PREPARE cert_record_guard_stmt FROM @cert_record_guard_sql; +EXECUTE cert_record_guard_stmt; +DEALLOCATE PREPARE cert_record_guard_stmt; + +-- ---------------------------- +-- 1. 加列 audited_snapshot_json +-- ---------------------------- +-- 位置紧接 snapshot_json:两列是同一份内容的前后两态,放一起便于对照。 +-- 条件分三种:列不存在则 ADD;列存在但类型不是 longtext 或可空性不是 YES 则 MODIFY +-- (防止历史上的手工变更把列建成 varchar 而截断 JSON);其余情况跳过。 +SET @cert_record_audited_snapshot_sql = ( + SELECT IF(COUNT(*) = 0, + 'ALTER TABLE `blade_transport_cert_record` ADD COLUMN `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''审核通过时确定的修订版内容;NULL=未修订'' AFTER `snapshot_json`', + IF(SUM(data_type <> 'longtext' OR is_nullable <> 'YES') > 0, + 'ALTER TABLE `blade_transport_cert_record` MODIFY COLUMN `audited_snapshot_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT ''审核通过时确定的修订版内容;NULL=未修订''', + 'SELECT 1')) + FROM information_schema.columns + WHERE table_schema = @cert_record_schema + AND table_name = 'blade_transport_cert_record' + AND column_name = 'audited_snapshot_json' +); +PREPARE cert_record_audited_snapshot_stmt FROM @cert_record_audited_snapshot_sql; +EXECUTE cert_record_audited_snapshot_stmt; +DEALLOCATE PREPARE cert_record_audited_snapshot_stmt; + +-- ---------------------------- +-- 2. 回滚 +-- ---------------------------- +-- 只新增了一列、未进任何索引,回滚即删该列; +-- snapshot_json 的 updateStrategy=NEVER 与生成列 pending_biz_id、唯一键 uk_cert_record_pending 均不受影响: +-- ALTER TABLE `blade_transport_cert_record` DROP COLUMN `audited_snapshot_json`; From a06b2ead160df6276f990018b4b671373020af5c Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Wed, 28 Oct 2026 01:36:12 +0800 Subject: [PATCH 20/27] =?UTF-8?q?:sparkles:=20=E8=AE=A4=E8=AF=81=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E6=8E=A5=E5=8F=A3=E6=94=AF=E6=8C=81=E4=BF=AE=E8=AE=A2?= =?UTF-8?q?=E7=89=88=E5=86=85=E5=AE=B9=E5=86=99=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CertAuditRequest,audit 接口原地升级为 body 优先、query 兜底,缺 id/pass 返回 400 - 通过时同事务写入 audited_snapshot_json(驳回忽略修订);修订内容先校验可反序列化与关键字段再落库 - 写回档案与司机端「当前生效内容」改为修订版优先,为空回退 snapshot_json - 读不懂的历史快照不再静默跳过,改为抛错回滚,杜绝空内容被盖章通过 - 审核详情下发 auditedSnapshotJson;司机端 VO 增加 revised 标记,快照字段不随 VO 下发 --- .../transport/pojo/dto/CertAuditRequest.java | 64 +++++ .../pojo/entity/TransportCertRecord.java | 14 ++ .../transport/pojo/vo/CertAuditDetailVO.java | 7 + .../pojo/vo/TransportCertRecordVO.java | 8 + .../CertificationAuditController.java | 24 +- .../transport/service/ICertAuditService.java | 9 +- .../service/impl/CertAuditServiceImpl.java | 94 +++++-- .../service/impl/DriverCertServiceImpl.java | 6 +- .../support/CertificationRuleSupport.java | 21 ++ .../wrapper/TransportCertRecordWrapper.java | 3 + .../CertificationAuditEndpointTest.java | 119 +++++++++ .../impl/CertAuditServiceImplTest.java | 234 ++++++++++++++++-- .../impl/DriverCertServiceImplTest.java | 50 ++++ .../service/impl/PersonCertRoundTripTest.java | 44 ++-- ...ransportCertRecordWrapperRevisionTest.java | 86 +++++++ 15 files changed, 711 insertions(+), 72 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/controller/CertificationAuditEndpointTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java new file mode 100644 index 00000000..e34302f6 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java @@ -0,0 +1,64 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 管理端认证审核请求 + *

+ * 这个接口原本只有 query 参数({@code id} / {@code pass} / {@code rejectReason}), + * 新增修订能力后改为「body 优先、query 兜底」:老前端不改代码仍然能用, + * 新前端把修订内容放进 body。两者同时给出时以 body 为准,避免审核员的修订被旧值覆盖。 + * + * @author Chill + */ +@Data +@Schema(description = "管理端认证审核请求") +public class CertAuditRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "认证记录主键") + private Long id; + @Schema(description = "true 通过,false 驳回") + private Boolean pass; + @Schema(description = "驳回原因;驳回时必填,≤200 字") + private String rejectReason; + /** + * 审核员修订后的全量认证内容(JSON);空或空白表示不修订 + *

+ * 通过时才生效:它既是写回档案的依据,也会原样留痕到认证记录上。 + */ + @Schema(description = "审核员修订后的全量认证内容JSON;空表示不修订") + private String auditedSnapshotJson; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java index 4f1346f4..bfed3f33 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java @@ -28,6 +28,7 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; import io.swagger.v3.oas.annotations.media.Schema; @@ -125,6 +126,19 @@ public class TransportCertRecord extends TenantEntity { @TableField(updateStrategy = FieldStrategy.NEVER) @Schema(description = "该次提交的全量认证内容JSON") private String snapshotJson; + /** + * 审核通过时确定的修订版内容(JSON) + *

+ * 审核员可以在通过时直接改内容:司机提交的是 {@code snapshotJson}(永不可改), + * 最终写回档案、司机端读到的「当前生效内容」以本字段优先,为 null 才回退到 + * {@code snapshotJson}。为 null 即「审核员没有修订」。 + *

+ * {@code @JsonIgnore}:司机端的 {@code TransportCertRecordVO} 继承本实体, + * 修订版是审核过程的留痕,不该下发给司机。 + */ + @JsonIgnore + @Schema(description = "审核通过时确定的修订版内容JSON;null 表示未修订") + private String auditedSnapshotJson; /** * 驳回原因,仅当审核状态为已驳回时有值 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java index 212eb072..672a7826 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java @@ -81,6 +81,13 @@ public class CertAuditDetailVO implements Serializable { @Schema(description = "待审快照:本次提交的全量内容") private String pendingSnapshotJson; + /** + * 审核员在本次审核中填写的修订版内容;未修订时为 null + *

+ * 只在「通过」时才会落库,驳回不产生生效内容。审核页据此回显审核员改了什么。 + */ + @Schema(description = "本次审核提交的修订版内容;未修订时为 null") + private String auditedSnapshotJson; @Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null") private String effectiveSnapshotJson; @Schema(description = "生效快照记录ID;null 表示该对象从未认证通过") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java index c4bf8a40..0983df03 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java @@ -96,5 +96,13 @@ public class TransportCertRecordVO extends TransportCertRecord { @TableField(exist = false) @Schema(description = "司机手机号") private String driverMobile; + /** + * 该条记录是否经过审核员修订 + *

+ * 由服务层按 {@code auditedSnapshotJson} 是否有内容算出,前端据此打「已修订」标记。 + */ + @TableField(exist = false) + @Schema(description = "是否经过审核员修订") + private Boolean revised; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java index 6e6a024f..aed2c2da 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java @@ -36,6 +36,7 @@ import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.CertAuditRequest; import org.springblade.transport.pojo.vo.CertAuditCapabilityVO; import org.springblade.transport.pojo.vo.CertAuditDetailVO; import org.springblade.transport.pojo.vo.TransportCertRecordVO; @@ -43,6 +44,7 @@ import org.springblade.transport.service.ICertAuditService; import org.springblade.transport.support.CertAuditPermissions; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -92,13 +94,27 @@ public class CertificationAuditController extends BladeController { @PostMapping("/audit") @ApiOperationSupport(order = 3) - @Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变") + @Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变。通过时可携带修订后的全量认证内容,它将成为新的生效内容并原样留痕") @PreAuth(CertAuditPermissions.AUDIT_ACTION) public R audit( - @Parameter(description = "认证记录主键", required = true) @RequestParam Long id, - @Parameter(description = "是否通过", required = true) @RequestParam Boolean pass, + @Parameter(description = "审核请求体(推荐):id、pass、rejectReason、auditedSnapshotJson") + @RequestBody(required = false) CertAuditRequest body, + @Parameter(description = "认证记录主键(兼容老调用)") @RequestParam(required = false) Long id, + @Parameter(description = "是否通过(兼容老调用)") @RequestParam(required = false) Boolean pass, @Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) { - return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason)); + // 原地升级:老前端只发 query,新前端发 body。逐字段「body 优先、query 兜底」—— + // 两者都给时以 body 为准,否则审核员在表单里改的内容会被 URL 上的旧值悄悄覆盖。 + Long recordId = body != null && body.getId() != null ? body.getId() : id; + Boolean approved = body != null && body.getPass() != null ? body.getPass() : pass; + String reason = body != null && body.getRejectReason() != null ? body.getRejectReason() : rejectReason; + String revision = body == null ? null : body.getAuditedSnapshotJson(); + if (recordId == null) { + return R.fail("缺少认证记录ID"); + } + if (approved == null) { + return R.fail("缺少审核结果"); + } + return R.status(certAuditService.audit(recordId, Boolean.TRUE.equals(approved), reason, revision)); } // 本接口不挂 @PreAuth;无权限时返回 false。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java index bdfd3327..fdba48f2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java @@ -74,12 +74,13 @@ public interface ICertAuditService { /** * 审核一条认证记录 * - * @param recordId 认证记录主键 - * @param pass true 通过,false 驳回 - * @param rejectReason 驳回原因;驳回时必填,≤200 字 + * @param recordId 认证记录主键 + * @param pass true 通过,false 驳回 + * @param rejectReason 驳回原因;驳回时必填,≤200 字 + * @param auditedSnapshotJson 审核员修订后的全量认证内容;空或空白表示不修订,驳回时忽略 * @return 是否成功 */ - boolean audit(Long recordId, boolean pass, String rejectReason); + boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson); /** * 当前登录用户的审核能力:能否查看、能否审核 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index a35ee3a3..b6a3a8fe 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -138,20 +138,24 @@ public class CertAuditServiceImpl implements ICertAuditService { detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser())); detail.setRejectReason(record.getRejectReason()); detail.setPendingSnapshotJson(record.getSnapshotJson()); + // 审核员上次填过的修订版:详情页要能回显,否则重开页面就丢了 + detail.setAuditedSnapshotJson(record.getAuditedSnapshotJson()); // 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理 detail.setEffectiveRecordId(effectiveId); detail.setCertified(CertificationRuleSupport.isCertified(effectiveId)); if (Func.isNotEmpty(effectiveId)) { TransportCertRecord effective = certRecordService.getById(effectiveId); - detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson()); + // 生效一侧同样修订版优先:审核页是拿「新提交」和「当前生效内容」做 diff 的, + // 这里取旧值会让审核员看到一份从未生效过的差异 + detail.setEffectiveSnapshotJson(CertificationRuleSupport.effectiveSnapshotJson(effective)); } return detail; } @Override @Transactional(rollbackFor = Exception.class) - public boolean audit(Long recordId, boolean pass, String rejectReason) { + public boolean audit(Long recordId, boolean pass, String rejectReason, String auditedSnapshotJson) { TransportCertRecord record = requireRecord(recordId); // 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」, @@ -165,6 +169,13 @@ public class CertAuditServiceImpl implements ICertAuditService { + TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)"); } + // 修订版只有「通过」才可能生效:驳回不产生生效内容,传了也不该留痕, + // 否则历史里会出现一条「已驳回却带着生效内容」的记录。 + String revision = pass ? blankToNull(auditedSnapshotJson) : null; + // 写回档案的是「本次生效内容」:有修订用修订版,没有就用司机原提交。 + // 校验放在写库之前——格式不对就整体回滚,不能先落一条状态再报错。 + String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : requireUsableRevision(record, revision); + Long auditor = AuthUtil.getUserId(); LocalDateTime auditTime = LocalDateTime.now(); @@ -177,11 +188,13 @@ public class CertAuditServiceImpl implements ICertAuditService { : CertificationRuleSupport.reasonForReject(rejectReason)); patch.setAuditUser(auditor); patch.setAuditTime(auditTime); + // 未修订时留 null:框架的 not_null 更新策略会跳过该列,库里保持 NULL 而不是空串 + patch.setAuditedSnapshotJson(revision); certRecordService.updateById(patch); // 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE, // 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。 - updateCertTarget(record, pass, rejectReason); + updateCertTarget(record, pass, rejectReason, effectiveSnapshotJson); return true; } @@ -212,11 +225,12 @@ public class CertAuditServiceImpl implements ICertAuditService { * * 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。 * - * @param record 被审核的记录 - * @param pass 是否通过 - * @param rejectReason 驳回原因 + * @param record 被审核的记录 + * @param pass 是否通过 + * @param rejectReason 驳回原因 + * @param effectiveSnapshotJson 本次生效内容(修订版优先,否则司机原提交) */ - private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) { + private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason, String effectiveSnapshotJson) { boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType()); Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED; // 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列 @@ -232,7 +246,7 @@ public class CertAuditServiceImpl implements ICertAuditService { // 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值—— // 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。 // 与指针合并成同一次 UPDATE。 - applySnapshotToVehicle(vehicle, record.getSnapshotJson()); + applySnapshotToVehicle(vehicle, effectiveSnapshotJson); } else { vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } @@ -255,7 +269,7 @@ public class CertAuditServiceImpl implements ICertAuditService { // 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空), // 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。 // 与指针合并成同一次 UPDATE。 - applySnapshotToDriver(driver, record.getSnapshotJson()); + applySnapshotToDriver(driver, effectiveSnapshotJson); } else { driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } @@ -332,6 +346,47 @@ public class CertAuditServiceImpl implements ICertAuditService { } } + /** + * 校验审核员填写的修订版内容,通过后原样返回(作为本次生效内容) + *

+ * 修订版是审核员的「最终认定」,会直接写回档案。因此必须能反序列化成当前提交结构, + * 且带上判定「这份内容有效」的关键字段,否则写回等于什么也没写: + * 司机看身份证号与驾驶证号,车辆看行驶证号与核定载质量。 + *

+ * 不满足时抛错、整体回滚——审核员填错 JSON 是操作失误,不能悄悄降级成「不修订」, + * 那样他会以为自己的修订已经生效。 + * + * @param record 被审核的记录 + * @param revision 已去空白的修订版内容 + * @return 同一个修订版内容 + */ + private String requireUsableRevision(TransportCertRecord record, String revision) { + if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) { + VehicleCertSubmitRequest snapshot; + try { + snapshot = JSON.parseObject(revision, VehicleCertSubmitRequest.class); + } catch (Exception exception) { + throw new ServiceException("修订内容格式不正确,请检查后重试"); + } + if (snapshot == null + || (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg()))) { + throw new ServiceException("修订内容缺少行驶证号与核定载质量,无法写入车辆档案"); + } + return revision; + } + PersonCertSubmitRequest snapshot; + try { + snapshot = JSON.parseObject(revision, PersonCertSubmitRequest.class); + } catch (Exception exception) { + throw new ServiceException("修订内容格式不正确,请检查后重试"); + } + if (snapshot == null + || (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo()))) { + throw new ServiceException("修订内容缺少身份证号与驾驶证号,无法写入司机档案"); + } + return revision; + } + /** * 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象) *

@@ -345,33 +400,34 @@ public class CertAuditServiceImpl implements ICertAuditService { * 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), * 因此不会把管理端填过、司机端没填的字段清空。 *

- * 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**: - * 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败; - * 何况历史快照本来就存在,阻断会让这些对象再也审不过去。 - * 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号, - * 必须阻断。 + * 快照缺失、无法解析或不是当前字段定义时**直接拒绝本次通过并整体回滚**: + * 「通过」的语义是「把这批证件内容认定为生效值」,没有可用内容却放行, + * 等于给一条空记录盖上已通过的章——状态与内容不一致正是这次改造要消灭的。 + * 内容确实为空的历史记录,由审核员在审核时补全后重审即可。 + * 申报的身份证号已落在另一名司机名下时同样阻断——那会把两条档案写成同一个证件号。 * * @param driver 待更新的司机 patch * @param snapshotJson 该条记录的全量提交内容 */ private void applySnapshotToDriver(Driver driver, String snapshotJson) { if (Func.isEmpty(snapshotJson)) { - return; + throw new ServiceException("该认证记录没有提交内容,无法写入司机档案;请让司机重新提交一次认证"); } PersonCertSubmitRequest snapshot; try { snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class); } catch (Exception exception) { - return; + throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证"); } if (snapshot == null) { - return; + throw new ServiceException("该认证记录的提交内容无法解析,未写入司机档案;请重新提交一次认证"); } // 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况), // 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。 - // 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。 + // 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就拒绝。 if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) { - return; + throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照)," + + "未写入司机档案;请重新提交一次认证"); } // 身份证号要落到档案上,先确认它没有落在别人名下:重复证件号一旦落库, // 建档查重与按证件号识别同一人都会失效 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 703119df..82e05905 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 @@ -209,9 +209,11 @@ public class DriverCertServiceImpl implements IDriverCertService { Driver driver = requireCurrentDriver(); // 当前司机实体已加载过,直接读它身上的指针,不再回查一次 TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId()); + // 生效内容以修订版为准:审核员改过的值才是档案里真正生效的那份, + // 仍读 snapshot_json 会让司机端拿旧值覆盖审核员的改动 return effective == null ? null - : JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class); + : JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), PersonCertSubmitRequest.class); } @Override @@ -272,7 +274,7 @@ public class DriverCertServiceImpl implements IDriverCertService { TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId()); return effective == null ? null - : JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class); + : JSON.parseObject(CertificationRuleSupport.effectiveSnapshotJson(effective), VehicleCertSubmitRequest.class); } @Override diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java index 7fc8ce28..b6831191 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java @@ -90,6 +90,27 @@ public final class CertificationRuleSupport { * @param effectiveRecordId 生效快照指针 * @return 是否已认证 */ + /** + * 取一条记录的「当前生效内容」 + *

+ * 审核员可以在通过时修订内容:修订版存在则以修订版为准,否则回退到司机原提交 + * ({@code snapshot_json} 永不修改,它是司机当时提交了什么的历史证据)。 + *

+ * 写回档案、司机端读生效值、审核详情对比三处必须用同一套取法, + * 否则会出现「档案是新值、司机端还是旧值」这种自相矛盾。 + * + * @param record 认证记录,可为 null + * @return 生效内容 JSON;记录为空、或两者都为空时返回 null + */ + public static String effectiveSnapshotJson(TransportCertRecord record) { + if (record == null) { + return null; + } + return Func.isNotBlank(record.getAuditedSnapshotJson()) + ? record.getAuditedSnapshotJson() + : record.getSnapshotJson(); + } + public static boolean isCertified(Long effectiveRecordId) { return Func.isNotEmpty(effectiveRecordId); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java index f6a8d7e2..fdd8994d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java @@ -27,6 +27,7 @@ package org.springblade.transport.wrapper; import org.springblade.core.mp.support.BaseEntityWrapper; import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.pojo.entity.TransportCertRecord; @@ -70,6 +71,8 @@ public class TransportCertRecordWrapper extends BaseEntityWrapper + * 这个接口是「原地升级」的:老前端只发 query 参数,新前端发 JSON body 带修订内容。 + * 两种调用都必须能用,且 body 一旦给了就以 body 为准——否则审核员的修订会被 query 里的旧值覆盖。 + */ +class CertificationAuditEndpointTest { + + private static final Long RECORD_ID = 5001L; + private static final String REVISION = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\"}"; + + private ICertAuditService certAuditService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + certAuditService = mock(ICertAuditService.class); + when(certAuditService.audit(any(), anyBoolean(), any(), any())).thenReturn(true); + mockMvc = MockMvcBuilders.standaloneSetup(new CertificationAuditController(certAuditService)).build(); + } + + @Test + @DisplayName("老调用:只带 query 的 id/pass/rejectReason,行为不变") + void legacyQueryOnlyStillWorks() throws Exception { + mockMvc.perform(post("/certification-audit/audit") + .param("id", String.valueOf(RECORD_ID)) + .param("pass", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, true, null, null); + } + + @Test + @DisplayName("新调用:body 携带修订内容,透传到服务层") + void bodyCarriesRevision() throws Exception { + String body = """ + {"id": 5001, "pass": true, "auditedSnapshotJson": "{\\"realName\\":\\"李四\\",\\"idCardNo\\":\\"450100199001019999\\"}"} + """; + + mockMvc.perform(post("/certification-audit/audit") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, true, null, REVISION); + } + + @Test + @DisplayName("两种都带:以 body 为准(query 里的旧值不得覆盖修订)") + void bodyWinsOverQuery() throws Exception { + String body = """ + {"id": 5001, "pass": true} + """; + + mockMvc.perform(post("/certification-audit/audit") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .param("id", "6001") + .param("pass", "false")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, true, null, null); + } + + @Test + @DisplayName("body 只给一半时,缺的字段回退到 query") + void partialBodyFallsBackToQuery() throws Exception { + String body = """ + {"id": 5001, "pass": false} + """; + + mockMvc.perform(post("/certification-audit/audit") + .contentType(MediaType.APPLICATION_JSON) + .content(body) + .param("rejectReason", "行驶证模糊")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)); + + verify(certAuditService).audit(RECORD_ID, false, "行驶证模糊", null); + } + + @Test + @DisplayName("缺少 id 或 pass:按业务失败返回(code=400),不能把 null 传进服务层") + void missingIdOrPassIsRejected() throws Exception { + mockMvc.perform(post("/certification-audit/audit").param("pass", "true")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)); + + mockMvc.perform(post("/certification-audit/audit").param("id", String.valueOf(RECORD_ID))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(400)); + + verify(certAuditService, never()).audit(anyLong(), anyBoolean(), any(), any()); + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index fa461766..a38af5d7 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -96,7 +96,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); verify(certRecordService).updateById(recordCaptor.capture()); @@ -118,7 +118,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -141,7 +141,7 @@ class CertAuditServiceImplTest { when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); ServiceException exception = assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, true, null)); + () -> service.audit(RECORD_ID, true, null, null)); assertTrue(exception.getMessage().contains("字段定义不匹配"), exception.getMessage()); verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); } @@ -154,7 +154,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -169,7 +169,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -183,7 +183,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); verify(driverService).updateById(captor.capture()); @@ -199,7 +199,7 @@ class CertAuditServiceImplTest { existing.setDrivingVehicle("桂A12345,桂B67890"); when(driverService.getById(DRIVER_ID)).thenReturn(existing); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); verify(driverService).updateById(captor.capture()); @@ -216,7 +216,7 @@ class CertAuditServiceImplTest { when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(false); ServiceException exception = assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, true, null)); + () -> service.audit(RECORD_ID, true, null, null)); assertTrue(exception.getMessage().contains("未生效"), exception.getMessage()); } @@ -228,7 +228,7 @@ class CertAuditServiceImplTest { when(driverService.updateById(any(Driver.class))).thenReturn(false); ServiceException exception = assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, true, null)); + () -> service.audit(RECORD_ID, true, null, null)); assertTrue(exception.getMessage().contains("司机"), exception.getMessage()); } @@ -242,7 +242,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); - service.audit(RECORD_ID, false, "证件照片模糊"); + service.audit(RECORD_ID, false, "证件照片模糊", null); ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); verify(certRecordService).updateById(recordCaptor.capture()); @@ -264,7 +264,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId)); - service.audit(RECORD_ID, false, "运输证过期"); + service.audit(RECORD_ID, false, "运输证过期", null); // 指针字段没有被写成 null,也没有被写成新记录 ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); @@ -277,8 +277,8 @@ class CertAuditServiceImplTest { void rejectRequiresReason() { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null)); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " ")); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null, null)); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " ", null)); verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); } @@ -288,7 +288,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); assertThrows(ServiceException.class, - () -> service.audit(RECORD_ID, false, "长".repeat(201))); + () -> service.audit(RECORD_ID, false, "长".repeat(201), null)); } // ------------------------------------------------------------ 终态保护 @@ -303,9 +303,9 @@ class CertAuditServiceImplTest { TransportCertRecord record = vehicleRecord(terminal); when(certRecordService.getById(RECORD_ID)).thenReturn(record); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null), + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null, null), "状态 " + terminal + " 属终态,不应可再审"); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"), + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回", null), "状态 " + terminal + " 属终态,不应可再审"); } verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); @@ -315,13 +315,13 @@ class CertAuditServiceImplTest { @DisplayName("记录不存在:拒绝") void recordNotFound() { when(certRecordService.getById(RECORD_ID)).thenReturn(null); - assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null)); + assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null, null)); } @Test @DisplayName("记录主键为空:拒绝") void recordIdRequired() { - assertThrows(ServiceException.class, () -> service.audit(null, true, null)); + assertThrows(ServiceException.class, () -> service.audit(null, true, null, null)); } // ------------------------------------------------------------ 审核详情(diff 用) @@ -470,7 +470,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); verify(transportVehicleService).update(captor.capture()); @@ -494,7 +494,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); - service.audit(RECORD_ID, true, null); + service.audit(RECORD_ID, true, null, null); ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); verify(driverService).update(captor.capture()); @@ -518,7 +518,7 @@ class CertAuditServiceImplTest { when(certRecordService.getById(RECORD_ID)).thenReturn(record); when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); - service.audit(RECORD_ID, false, " 行驶证模糊 "); + service.audit(RECORD_ID, false, " 行驶证模糊 ", null); ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); verify(transportVehicleService).updateById(captor.capture()); @@ -527,6 +527,183 @@ class CertAuditServiceImplTest { verify(transportVehicleService, never()).update(any()); } + // ------------------------------------------------------------ 修订版 + + @Test + @DisplayName("修订版:通过时写入新列,且 snapshot_json 一个字都不改") + void approveStoresAuditedSnapshot() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + String revision = vehicleRevision(); + + service.audit(RECORD_ID, true, null, revision); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertEquals(revision, captor.getValue().getAuditedSnapshotJson(), + "修订版必须原样留痕,供事后追责"); + assertNull(captor.getValue().getSnapshotJson(), + "snapshot_json 是司机当时提交的内容,审核 patch 里不得出现"); + } + + @Test + @DisplayName("修订版:写回车辆档案的是修订内容,不是司机原提交") + void approveAppliesRevisionToVehicleProfile() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + String revision = vehicleRevision(); + + service.audit(RECORD_ID, true, null, revision); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals("修订后的所有人", captor.getValue().getOwnerName(), "档案要落修订后的内容"); + assertEquals(25000, captor.getValue().getApprovedLoadKg()); + assertEquals("450100999999", captor.getValue().getDrivingLicenseNo()); + } + + @Test + @DisplayName("修订版:司机档案同样以修订内容写回") + void approveAppliesRevisionToDriverProfile() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson("{}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + String revision = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\"," + + "\"driverFileNo\":\"450100888899\"}"; + + service.audit(RECORD_ID, true, null, revision); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + assertEquals("李四", captor.getValue().getDriverName()); + assertEquals("450100199001019999", captor.getValue().getIdCardNo()); + assertEquals("450100888899", captor.getValue().getDrivingLicenseNo()); + } + + @Test + @DisplayName("修订版:未提供时不写新列,生效内容仍是司机原提交") + void approveWithoutRevisionLeavesColumnNull() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null, null); + + ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(recordCaptor.capture()); + assertNull(recordCaptor.getValue().getAuditedSnapshotJson(), + "未修订时该列保持 NULL,不能塞一份复制品进来"); + ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(vehicleCaptor.capture()); + assertEquals("广西现代物流集团有限公司", vehicleCaptor.getValue().getOwnerName()); + } + + @Test + @DisplayName("修订版:纯空白等于未修订(前端清空输入框就是这种情况)") + void blankRevisionIsTreatedAsAbsent() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, true, null, " "); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertNull(captor.getValue().getAuditedSnapshotJson()); + } + + @Test + @DisplayName("修订版:JSON 无法解析时拒绝,且记录与档案都不写(事务回滚)") + void approveRejectsMalformedRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, "{不是JSON")); + assertTrue(exception.getMessage().contains("修订内容格式不正确"), exception.getMessage()); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); + } + + @Test + @DisplayName("修订版:司机缺身份证号与驾驶证号时拒绝(写回等于什么也没写)") + void approveRejectsIncompleteDriverRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING)); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, "{\"realName\":\"李四\"}")); + assertTrue(exception.getMessage().contains("身份证号"), exception.getMessage()); + verify(driverService, never()).updateById(any(Driver.class)); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("修订版:车辆缺行驶证号与核定载质量时拒绝") + void approveRejectsIncompleteVehicleRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, "{\"ownerName\":\"只改了个名字\"}")); + assertTrue(exception.getMessage().contains("核定载质量"), exception.getMessage()); + verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); + } + + @Test + @DisplayName("驳回:修订内容完全忽略,新列保持 NULL") + void rejectIgnoresRevision() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + + service.audit(RECORD_ID, false, "行驶证模糊", vehicleRevision()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).updateById(captor.capture()); + assertNull(captor.getValue().getAuditedSnapshotJson(), + "驳回不产生生效内容,留修订版只会让人误读"); + assertEquals(TransportCertRecord.STATUS_REJECTED, captor.getValue().getStatus()); + } + + @Test + @DisplayName("空快照不再静默通过:司机记录没有内容、也没有修订时拒绝") + void approveRejectsEmptyDriverSnapshot() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson(null); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, null)); + assertTrue(exception.getMessage().contains("司机档案"), exception.getMessage()); + verify(driverService, never()).updateById(any(Driver.class)); + } + + @Test + @DisplayName("审核详情:修订版内容随详情下发,生效一侧同样修订版优先") + void detailExposesAuditedSnapshot() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + record.setAuditedSnapshotJson("{\"vin\":\"REVISED\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + + TransportCertRecord effective = new TransportCertRecord(); + effective.setId(777L); + effective.setSnapshotJson("{\"vin\":\"OLD\"}"); + effective.setAuditedSnapshotJson("{\"vin\":\"OLD-REVISED\"}"); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + when(certRecordService.getById(777L)).thenReturn(effective); + + CertAuditDetailVO detail = service.detail(RECORD_ID); + + assertEquals("{\"vin\":\"REVISED\"}", detail.getAuditedSnapshotJson()); + assertEquals("{\"vin\":\"OLD-REVISED\"}", detail.getEffectiveSnapshotJson(), + "生效一侧也修订版优先,否则审核页拿旧值做对比"); + } + + private static String vehicleRevision() { + return "{\"drivingLicenseNo\":\"450100999999\",\"approvedLoadKg\":25000," + + "\"ownerName\":\"修订后的所有人\"}"; + } + // ------------------------------------------------------------ 辅助 private TransportCertRecord vehicleRecord(Integer status) { @@ -556,6 +733,19 @@ class CertAuditServiceImplTest { + "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}"; } + /** + * 一份字段齐全的司机认证快照 + *

+ * 司机侧的空快照不再「静默跳过」而是拒审,故通过类用例不能再拿 "{}" 走流程; + * 身份证号与驾驶证号是「这份快照是否为当前格式」的判据,缺一不可。 + */ + static String completeDriverSnapshot() { + return "{\"realName\":\"张三\",\"idCardNo\":\"450100199001011234\"," + + "\"driverFileNo\":\"450100888888\",\"driverClass\":\"A2\"," + + "\"headPhoto\":\"http://oss/head.jpg\",\"idCardFrontUrl\":\"http://oss/idcard-front.jpg\"," + + "\"idCardBackUrl\":\"http://oss/idcard-back.jpg\"}"; + } + private TransportCertRecord driverRecord(Integer status) { TransportCertRecord record = new TransportCertRecord(); record.setId(RECORD_ID); @@ -563,7 +753,7 @@ class CertAuditServiceImplTest { record.setBizId(DRIVER_ID); record.setBizKey("张三"); record.setStatus(status); - record.setSnapshotJson("{}"); + record.setSnapshotJson(completeDriverSnapshot()); return record; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index 7e99a3e5..10b8e190 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -358,6 +358,56 @@ class DriverCertServiceImplTest { assertNull(service.getEffectivePersonCert()); } + @Test + @DisplayName("读生效值:有修订版时返回修订版内容(司机端不能拿旧值覆盖审核员的改动)") + void getEffectivePersonCertPrefersRevision() { + Long effectiveId = 777L; + Driver driver = driver(PLATE); + driver.setEffectiveCertRecordId(effectiveId); + when(driverAppService.currentDriverEntity()).thenReturn(driver); + when(certRecordService.getById(effectiveId)).thenReturn(pointedRecord(effectiveId, + "司机原提交", "审核员修订")); + + PersonCertSubmitRequest result = service.getEffectivePersonCert(); + + assertEquals("审核员修订", result.getRealName()); + } + + @Test + @DisplayName("读生效值:车辆同理,修订版优先") + void getEffectiveVehicleCertPrefersRevision() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L)); + when(certRecordService.getById(777L)).thenReturn(pointedVehicleRecord(777L, + "司机原提交", "审核员修订")); + + VehicleCertSubmitRequest result = service.getEffectiveVehicleCert(VEHICLE_ID); + + assertEquals("审核员修订", result.getOwnerName()); + } + + private TransportCertRecord pointedRecord(Long id, String submittedName, String revisedName) { + PersonCertSubmitRequest submitted = new PersonCertSubmitRequest(); + submitted.setRealName(submittedName); + PersonCertSubmitRequest revised = new PersonCertSubmitRequest(); + revised.setRealName(revisedName); + TransportCertRecord record = record(id, TransportCertRecord.STATUS_APPROVED); + record.setSnapshotJson(JSON.toJSONString(submitted)); + record.setAuditedSnapshotJson(JSON.toJSONString(revised)); + return record; + } + + private TransportCertRecord pointedVehicleRecord(Long id, String submittedName, String revisedName) { + VehicleCertSubmitRequest submitted = new VehicleCertSubmitRequest(); + submitted.setOwnerName(submittedName); + VehicleCertSubmitRequest revised = new VehicleCertSubmitRequest(); + revised.setOwnerName(revisedName); + TransportCertRecord record = record(id, TransportCertRecord.STATUS_APPROVED); + record.setSnapshotJson(JSON.toJSONString(submitted)); + record.setAuditedSnapshotJson(JSON.toJSONString(revised)); + return record; + } + @Test @DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)") void resubmitKeepsEffectiveValue() { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java index 0fee6807..d6e3c229 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -108,7 +108,7 @@ class PersonCertRoundTripTest { void submitThenApprove() { // 1) 先走一遍完整流程,让该司机有一个生效值 Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); - certAuditService.audit(firstRecordId, true, null); + certAuditService.audit(firstRecordId, true, null, null); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), "审核通过后应能查到生效值"); @@ -120,7 +120,7 @@ class PersonCertRoundTripTest { "指针应仍指向原来那条已通过记录"); // 3) 审核通过:生效值才变成新值 - certAuditService.audit(secondRecordId, true, null); + certAuditService.audit(secondRecordId, true, null, null); assertEquals("张三改名", driverCertService.getEffectivePersonCert().getRealName(), "审核通过后生效值应切到新记录"); assertEquals(secondRecordId, driver.getEffectiveCertRecordId()); @@ -130,10 +130,10 @@ class PersonCertRoundTripTest { @DisplayName("驳回后生效值不变(已认证对象不会被驳回下线)") void rejectKeepsEffectiveValue() { Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); - certAuditService.audit(firstRecordId, true, null); + certAuditService.audit(firstRecordId, true, null, null); Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); - certAuditService.audit(secondRecordId, false, "证件照片模糊"); + certAuditService.audit(secondRecordId, false, "证件照片模糊", null); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), "驳回只终结该次提交,既得认证必须保持有效"); @@ -149,7 +149,7 @@ class PersonCertRoundTripTest { assertNull(driverCertService.getEffectivePersonCert(), "待审期间不应有生效值"); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName()); } @@ -171,18 +171,18 @@ class PersonCertRoundTripTest { // 被作废的那条不能再被审核(否则会把已作废的记录变成生效值) org.junit.jupiter.api.Assertions.assertThrows( org.springblade.core.log.exception.ServiceException.class, - () -> certAuditService.audit(first, true, null)); + () -> certAuditService.audit(first, true, null, null)); } @Test @DisplayName("已通过的记录不可再被审核(防止把生效快照改坏)") void approvedRecordCannotBeReaudited() { Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); org.junit.jupiter.api.Assertions.assertThrows( org.springblade.core.log.exception.ServiceException.class, - () -> certAuditService.audit(recordId, false, "事后反悔")); + () -> certAuditService.audit(recordId, false, "事后反悔", null)); assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(), "重复审核被拒后,生效值不受影响"); } @@ -314,7 +314,7 @@ class PersonCertRoundTripTest { request.setQualificationBack("http://example.com/q-back.jpg"); Long recordId = driverCertService.submitPersonCert(request); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); // 档案里必须是这次提交的内容:只切指针、不写内容的话, // 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的 @@ -342,7 +342,7 @@ class PersonCertRoundTripTest { request.setAddressRegion(" "); request.setAddress(""); Long recordId = driverCertService.submitPersonCert(request); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); // 框架的 not_null 更新策略只跳过 null:写空串会把管理端填过的住址清掉 assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion(), @@ -358,7 +358,7 @@ class PersonCertRoundTripTest { driver.setIdCardNo("110101199001011234"); Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); - certAuditService.audit(recordId, true, null); + certAuditService.audit(recordId, true, null, null); // 审核通过即认可这次申报:档案里必须也是申报的姓名, // 否则司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字 @@ -377,7 +377,7 @@ class PersonCertRoundTripTest { Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011999")); ServiceException exception = assertThrows(ServiceException.class, - () -> certAuditService.audit(recordId, true, null)); + () -> certAuditService.audit(recordId, true, null, null)); assertTrue(exception.getMessage().contains("另一名司机档案"), exception.getMessage()); // 真实环境下这次审核由 @Transactional 整体回滚(记录仍是待审); // 单测没有事务,故这里校验更直接的一点:拦截发生在写档案之前,别人的证件号没被写进来 @@ -392,25 +392,27 @@ class PersonCertRoundTripTest { PersonCertSubmitRequest request = personCert("张三", "110101199001011234"); request.setHeadPhoto("http://example.com/new-head.jpg"); Long recordId = driverCertService.submitPersonCert(request); - certAuditService.audit(recordId, false, "证件照片模糊"); + certAuditService.audit(recordId, false, "证件照片模糊", null); assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(), "驳回只终结该次提交,档案内容不得被改动"); } @Test - @DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过") - void legacySnapshotSkipsWriteBackWithoutFailingAudit() { - // 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null; - // 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败 + @DisplayName("历史快照:读不懂就拒绝通过并回滚(不再静默放行)") + void legacySnapshotBlocksAuditInsteadOfPassing() { + // 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null。 + // 「通过」的语义是「把这批内容认定为生效值」,内容读不懂就无从认定, + // 必须拒绝——静默放行等于给一条空记录盖上已通过的章。 Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234")); TransportCertRecord record = recordStore.get(recordId); record.setSnapshotJson("{\"legacyField\":\"早期格式的旧内容\"}"); - certAuditService.audit(recordId, true, null); + ServiceException exception = assertThrows(ServiceException.class, + () -> certAuditService.audit(recordId, true, null, null)); - assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(), - "快照读不懂不该阻断审核"); - assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换"); + assertTrue(exception.getMessage().contains("不匹配"), exception.getMessage()); + // 内存版记录表没有事务,这里只验最关键的一条:生效指针不能被切过去 + assertNull(driver.getEffectiveCertRecordId(), "审核失败不得切换生效指针"); } } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java new file mode 100644 index 00000000..df025a94 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/wrapper/TransportCertRecordWrapperRevisionTest.java @@ -0,0 +1,86 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.wrapper; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; +import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; +import org.springblade.transport.pojo.entity.TransportCertRecord; +import org.springblade.transport.pojo.vo.TransportCertRecordVO; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mockStatic; + +/** + * 认证记录视图对象的修订版标记与字段可见性 + *

+ * 两件事必须同时成立:司机端要能从 {@code revised} 看出「这条记录经过审核员修订」, + * 而又不能顺着 VO 把整份修订内容(含身份证号、证件照地址)再下发一遍。 + * + * @author Chill + */ +class TransportCertRecordWrapperRevisionTest { + + @Test + @DisplayName("修订版非空才算修订,空串与空白不算") + void marksRevisedOnlyWhenRevisionPresent() { + try (MockedStatic ignoredDisplay = mockStatic(UserDisplayCache.class); + MockedStatic ignoredUser = mockStatic(UserCache.class)) { + assertTrue(wrapper().fillDisplayFields(record("{\"realName\":\"张三\"}")).getRevised(), "有修订版应标记为已修订"); + assertFalse(wrapper().fillDisplayFields(record(null)).getRevised(), "无修订版不应标记"); + assertFalse(wrapper().fillDisplayFields(record("")).getRevised(), "空串不应标记"); + assertFalse(wrapper().fillDisplayFields(record(" ")).getRevised(), "空白不应标记"); + } + } + + @Test + @DisplayName("视图对象序列化不包含修订内容,只保留 revised 标记") + void voJsonOmitsRevisionContent() throws Exception { + TransportCertRecordVO vo = record("{\"realName\":\"张三\"}"); + vo.setRevised(Boolean.TRUE); + + String json = new ObjectMapper().writeValueAsString(vo); + + assertFalse(json.contains("auditedSnapshotJson"), "修订内容不得随 VO 下发:" + json); + assertTrue(json.contains("\"revised\":true"), "必须下发 revised 标记:" + json); + } + + private static TransportCertRecordWrapper wrapper() { + return TransportCertRecordWrapper.build(); + } + + private static TransportCertRecordVO record(String auditedSnapshotJson) { + TransportCertRecordVO vo = new TransportCertRecordVO(); + vo.setSnapshotJson("{\"realName\":\"李四\"}"); + vo.setAuditedSnapshotJson(auditedSnapshotJson); + return vo; + } + +} From df3b21922db5df5587ffa5e7e70e3e48dafbd81d Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Wed, 28 Oct 2026 02:25:02 +0800 Subject: [PATCH 21/27] =?UTF-8?q?:recycle:=20=E5=AE=A1=E6=A0=B8=E4=BF=AE?= =?UTF-8?q?=E8=AE=A2=EF=BC=9A=E8=A1=A5=E8=BA=AB=E4=BB=BD=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E7=AB=AF=E6=A0=A1=E9=AA=8C=E5=B9=B6=E6=95=B4?= =?UTF-8?q?=E7=90=86=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修订通过时比对原提交的身份字段(司机身份证号/驾驶证号、车牌号),原提交为空时允许补全;抽出 preferBody 与修订解析方法,统一许可头与注释口径。 --- .../transport/pojo/dto/CertAuditRequest.java | 4 +- .../CertificationAuditController.java | 30 ++++- .../service/impl/CertAuditServiceImpl.java | 120 +++++++++++++++--- .../support/CertificationRuleSupport.java | 18 +-- .../CertificationAuditEndpointTest.java | 28 +++- .../impl/CertAuditServiceImplTest.java | 53 ++++++++ 6 files changed, 213 insertions(+), 40 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java index e34302f6..00a69ce8 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/CertAuditRequest.java @@ -34,9 +34,7 @@ import java.io.Serializable; /** * 管理端认证审核请求 *

- * 这个接口原本只有 query 参数({@code id} / {@code pass} / {@code rejectReason}), - * 新增修订能力后改为「body 优先、query 兜底」:老前端不改代码仍然能用, - * 新前端把修订内容放进 body。两者同时给出时以 body 为准,避免审核员的修订被旧值覆盖。 + * 同一字段 body 与 query 都可传,两处都给时以 body 为准。 * * @author Chill */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java index aed2c2da..77c50af3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java @@ -49,6 +49,8 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.function.Function; + /** * 认证审核(管理端) *

@@ -102,12 +104,11 @@ public class CertificationAuditController extends BladeController { @Parameter(description = "认证记录主键(兼容老调用)") @RequestParam(required = false) Long id, @Parameter(description = "是否通过(兼容老调用)") @RequestParam(required = false) Boolean pass, @Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) { - // 原地升级:老前端只发 query,新前端发 body。逐字段「body 优先、query 兜底」—— - // 两者都给时以 body 为准,否则审核员在表单里改的内容会被 URL 上的旧值悄悄覆盖。 - Long recordId = body != null && body.getId() != null ? body.getId() : id; - Boolean approved = body != null && body.getPass() != null ? body.getPass() : pass; - String reason = body != null && body.getRejectReason() != null ? body.getRejectReason() : rejectReason; - String revision = body == null ? null : body.getAuditedSnapshotJson(); + // 同一字段 body 与 query 都可传,两处都给时以 body 为准。 + Long recordId = preferBody(body, CertAuditRequest::getId, id); + Boolean approved = preferBody(body, CertAuditRequest::getPass, pass); + String reason = preferBody(body, CertAuditRequest::getRejectReason, rejectReason); + String revision = preferBody(body, CertAuditRequest::getAuditedSnapshotJson, null); if (recordId == null) { return R.fail("缺少认证记录ID"); } @@ -125,4 +126,21 @@ public class CertificationAuditController extends BladeController { return R.data(certAuditService.capability()); } + /** + * 取一个审核参数:body 里给了就用 body 的,否则用 query 的 + * + * @param body 审核请求体,可为 null + * @param getter 从请求体取该字段 + * @param queryValue 该字段的 query 参数值 + * @param 字段类型 + * @return body 字段值;body 为 null 或该字段为 null 时返回 query 参数值 + */ + private static T preferBody(CertAuditRequest body, Function getter, T queryValue) { + if (body == null) { + return queryValue; + } + T bodyValue = getter.apply(body); + return bodyValue == null ? queryValue : bodyValue; + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index b6a3a8fe..91597a88 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -26,6 +26,7 @@ package org.springblade.transport.service.impl; import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; @@ -172,9 +173,13 @@ public class CertAuditServiceImpl implements ICertAuditService { // 修订版只有「通过」才可能生效:驳回不产生生效内容,传了也不该留痕, // 否则历史里会出现一条「已驳回却带着生效内容」的记录。 String revision = pass ? blankToNull(auditedSnapshotJson) : null; + // 校验放在写库之前——内容不可用或改动了身份字段就整体回滚,不能先落一条状态再报错。 + if (revision != null) { + requireUsableRevision(record, revision); + requireIdentityUnchanged(record, revision); + } // 写回档案的是「本次生效内容」:有修订用修订版,没有就用司机原提交。 - // 校验放在写库之前——格式不对就整体回滚,不能先落一条状态再报错。 - String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : requireUsableRevision(record, revision); + String effectiveSnapshotJson = revision == null ? record.getSnapshotJson() : revision; Long auditor = AuthUtil.getUserId(); LocalDateTime auditTime = LocalDateTime.now(); @@ -347,7 +352,7 @@ public class CertAuditServiceImpl implements ICertAuditService { } /** - * 校验审核员填写的修订版内容,通过后原样返回(作为本次生效内容) + * 校验审核员填写的修订版内容,不满足时抛错 *

* 修订版是审核员的「最终认定」,会直接写回档案。因此必须能反序列化成当前提交结构, * 且带上判定「这份内容有效」的关键字段,否则写回等于什么也没写: @@ -358,33 +363,108 @@ public class CertAuditServiceImpl implements ICertAuditService { * * @param record 被审核的记录 * @param revision 已去空白的修订版内容 - * @return 同一个修订版内容 */ - private String requireUsableRevision(TransportCertRecord record, String revision) { + private void requireUsableRevision(TransportCertRecord record, String revision) { + JSONObject parsed = parseRevision(revision); if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) { - VehicleCertSubmitRequest snapshot; - try { - snapshot = JSON.parseObject(revision, VehicleCertSubmitRequest.class); - } catch (Exception exception) { - throw new ServiceException("修订内容格式不正确,请检查后重试"); - } + VehicleCertSubmitRequest snapshot = toSnapshot(parsed, VehicleCertSubmitRequest.class); if (snapshot == null || (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg()))) { throw new ServiceException("修订内容缺少行驶证号与核定载质量,无法写入车辆档案"); } - return revision; - } - PersonCertSubmitRequest snapshot; - try { - snapshot = JSON.parseObject(revision, PersonCertSubmitRequest.class); - } catch (Exception exception) { - throw new ServiceException("修订内容格式不正确,请检查后重试"); + return; } + PersonCertSubmitRequest snapshot = toSnapshot(parsed, PersonCertSubmitRequest.class); if (snapshot == null || (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo()))) { throw new ServiceException("修订内容缺少身份证号与驾驶证号,无法写入司机档案"); } - return revision; + } + + /** + * 身份字段不可修订:修订版改动了身份证号、驾驶证号(司机)或车牌号(车辆)时拒绝 + *

+ * 审核只认定证件内容,身份字段标识「是谁」,改了审核的就是另一个对象。 + * 原提交里该字段为空时(旧数据、空快照)没有可比对的原值,不比对,允许补全。 + * + * @param record 被审核的记录 + * @param revision 已去空白的修订版内容 + */ + private void requireIdentityUnchanged(TransportCertRecord record, String revision) { + JSONObject parsed = parseRevision(revision); + if (parsed == null) { + return; + } + if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType())) { + VehicleCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), VehicleCertSubmitRequest.class); + if (original == null) { + return; + } + VehicleCertSubmitRequest revised = toSnapshot(parsed, VehicleCertSubmitRequest.class); + requireSameIdentity(original.getPlateNo(), revised.getPlateNo(), "修订内容不能修改车牌号"); + return; + } + PersonCertSubmitRequest original = parseSnapshotQuietly(record.getSnapshotJson(), PersonCertSubmitRequest.class); + if (original == null) { + return; + } + String message = "修订内容不能修改身份证号与驾驶证号"; + PersonCertSubmitRequest revised = toSnapshot(parsed, PersonCertSubmitRequest.class); + requireSameIdentity(original.getIdCardNo(), revised.getIdCardNo(), message); + requireSameIdentity(original.getDriverFileNo(), revised.getDriverFileNo(), message); + } + + /** + * 原提交有值时,修订版必须与之一致;原提交为空时允许补全 + * + * @param original 原提交里的身份字段值 + * @param revised 修订版里的身份字段值 + * @param message 不一致时抛出的文案 + */ + private void requireSameIdentity(String original, String revised, String message) { + if (Func.isEmpty(original)) { + return; + } + if (!original.equals(revised)) { + throw new ServiceException(message); + } + } + + /** + * 反序列化审核员填写的修订版 JSON + * + * @param revision 已去空白的修订版内容 + * @return 解析结果;内容为字面量 null 时返回 null + */ + private JSONObject parseRevision(String revision) { + try { + return JSON.parseObject(revision); + } catch (Exception exception) { + throw new ServiceException("修订内容格式不正确,请检查后重试"); + } + } + + /** + * 解析原提交快照;为空或无法解析时返回 null(此时没有可比对的原值) + * + * @param snapshotJson 原提交内容 + * @param clazz 目标结构 + * @param 提交结构类型 + * @return 解析结果;为空或解析失败时返回 null + */ + private T parseSnapshotQuietly(String snapshotJson, Class clazz) { + if (Func.isEmpty(snapshotJson)) { + return null; + } + try { + return JSON.parseObject(snapshotJson, clazz); + } catch (Exception exception) { + return null; + } + } + + private T toSnapshot(JSONObject parsed, Class clazz) { + return parsed == null ? null : parsed.toJavaObject(clazz); } /** @@ -402,7 +482,7 @@ public class CertAuditServiceImpl implements ICertAuditService { *

* 快照缺失、无法解析或不是当前字段定义时**直接拒绝本次通过并整体回滚**: * 「通过」的语义是「把这批证件内容认定为生效值」,没有可用内容却放行, - * 等于给一条空记录盖上已通过的章——状态与内容不一致正是这次改造要消灭的。 + * 等于给一条空记录盖上已通过的章,状态与内容互相矛盾。 * 内容确实为空的历史记录,由审核员在审核时补全后重审即可。 * 申报的身份证号已落在另一名司机名下时同样阻断——那会把两条档案写成同一个证件号。 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java index b6831191..d0566a01 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java @@ -81,15 +81,6 @@ public final class CertificationRuleSupport { return previousPending; } - /** - * 判定认证对象是否已认证 - *

- * 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」, - * 重新提交、待审、被驳回都不会让对象变回未认证。 - * - * @param effectiveRecordId 生效快照指针 - * @return 是否已认证 - */ /** * 取一条记录的「当前生效内容」 *

@@ -111,6 +102,15 @@ public final class CertificationRuleSupport { : record.getSnapshotJson(); } + /** + * 判定认证对象是否已认证 + *

+ * 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」, + * 重新提交、待审、被驳回都不会让对象变回未认证。 + * + * @param effectiveRecordId 生效快照指针 + * @return 是否已认证 + */ public static boolean isCertified(Long effectiveRecordId) { return Func.isNotEmpty(effectiveRecordId); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/CertificationAuditEndpointTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/CertificationAuditEndpointTest.java index 6b462d7f..b4127ea1 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/CertificationAuditEndpointTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/CertificationAuditEndpointTest.java @@ -1,3 +1,28 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ package org.springblade.transport.controller; import org.junit.jupiter.api.BeforeEach; @@ -22,8 +47,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. /** * 认证审核接口的入参绑定测试。 *

- * 这个接口是「原地升级」的:老前端只发 query 参数,新前端发 JSON body 带修订内容。 - * 两种调用都必须能用,且 body 一旦给了就以 body 为准——否则审核员的修订会被 query 里的旧值覆盖。 + * 同一字段 body 与 query 都可传,两处都给时以 body 为准。 */ class CertificationAuditEndpointTest { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index a38af5d7..942e7f97 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -699,6 +699,59 @@ class CertAuditServiceImplTest { "生效一侧也修订版优先,否则审核页拿旧值做对比"); } + @Test + @DisplayName("身份不可修订:司机修订改了身份证号时拒绝,记录不被改动") + void approveRejectsDriverIdentityChange() { + when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING)); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + String revision = "{\"realName\":\"李四\",\"idCardNo\":\"450100199001019999\"," + + "\"driverFileNo\":\"450100888888\"}"; + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, revision)); + + assertTrue(exception.getMessage().contains("身份证号"), exception.getMessage()); + verify(driverService, never()).updateById(any(Driver.class)); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("身份不可修订:车辆修订改了车牌号时拒绝(车牌不参与写回,也不能被悄悄换掉)") + void approveRejectsVehicleIdentityChange() { + String snapshot = "{\"plateNo\":\"桂A32523\",\"drivingLicenseNo\":\"450100123456\"," + + "\"approvedLoadKg\":18000,\"ownerName\":\"广西现代物流集团有限公司\"}"; + when(certRecordService.getById(RECORD_ID)) + .thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING, snapshot)); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null)); + String revision = "{\"plateNo\":\"桂A99999\",\"drivingLicenseNo\":\"450100999999\"," + + "\"approvedLoadKg\":25000}"; + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null, revision)); + + assertTrue(exception.getMessage().contains("车牌号"), exception.getMessage()); + verify(transportVehicleService, never()).updateById(any(TransportVehicle.class)); + verify(certRecordService, never()).updateById(any(TransportCertRecord.class)); + } + + @Test + @DisplayName("身份不可修订:原提交该字段为空时允许补全,正常通过") + void approveAllowsCompletingEmptyIdentity() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + record.setSnapshotJson("{\"realName\":\"张三\"}"); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + String revision = "{\"realName\":\"张三\",\"idCardNo\":\"450100199001011234\"," + + "\"driverFileNo\":\"450100888888\"}"; + + service.audit(RECORD_ID, true, null, revision); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + assertEquals("450100199001011234", captor.getValue().getIdCardNo(), + "原提交没有身份证号时修订版可以补上"); + } + private static String vehicleRevision() { return "{\"drivingLicenseNo\":\"450100999999\",\"approvedLoadKg\":25000," + "\"ownerName\":\"修订后的所有人\"}"; From 29bce66d63f38155af7d010f2e1ea67bd7ba741c Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Wed, 28 Oct 2026 16:14:20 +0800 Subject: [PATCH 22/27] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E7=AB=AF=E6=A1=A3=E6=A1=88=E7=BB=9F=E8=AE=A1=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=EF=BC=8C=E4=BE=9B=E5=B0=8F=E7=A8=8B=E5=BA=8F=E8=B5=84?= =?UTF-8?q?=E6=96=99=E9=A1=B5=E5=8F=96=E5=8F=B8=E6=9C=BA=E4=B8=8E=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E8=AF=81=E7=85=A7=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pojo/vo/ArchiveManageStatsVO.java | 47 ++++++++++++++ .../controller/ManageArchiveController.java | 64 +++++++++++++++++++ .../service/IManageArchiveService.java | 37 +++++++++++ .../impl/ManageArchiveServiceImpl.java | 54 ++++++++++++++++ 4 files changed, 202 insertions(+) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ArchiveManageStatsVO.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageArchiveService.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageArchiveServiceImpl.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ArchiveManageStatsVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ArchiveManageStatsVO.java new file mode 100644 index 00000000..085673f4 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ArchiveManageStatsVO.java @@ -0,0 +1,47 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 调度端档案统计(司机 + 车辆证件有效期) + */ +@Data +@Schema(description = "调度端档案统计") +public class ArchiveManageStatsVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "司机证件有效期统计") + private DriverExpiryStatVO driver = new DriverExpiryStatVO(); + + @Schema(description = "车辆证件有效期统计") + private TransportVehicleExpiryStatVO vehicle = new TransportVehicleExpiryStatVO(); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java new file mode 100644 index 00000000..e38ba183 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java @@ -0,0 +1,64 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.vo.ArchiveManageStatsVO; +import org.springblade.transport.service.IManageArchiveService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 调度端档案(小程序管理端) + *

+ * 对外完整路径:{@code /api/blade-transport/archive/manage/**} + * (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/**})。 + * 同时兼容未去前缀直连({@code /blade-transport/archive/manage/**})。 + * 仅需登录态,不挂管理端菜单鉴权。 + *

+ * 为什么单独一个接口:管理端 {@code /driver/expiry-stat}、{@code /transport-vehicle/expiry-stat} + * 挂在 {@code @PreAuth(menu = "driver")} / {@code @PreAuth(menu = "transport_vehicle")} 下, + * 小程序登录的令牌 roleId 是占位值 "0"、查不到 blade_role_menu,必然被拒(401 请求未授权)。 + */ +@RestController +@AllArgsConstructor +@RequestMapping({"/archive/manage", "/blade-transport/archive/manage"}) +@Tag(name = "调度端档案", description = "小程序调度端资料页档案统计") +public class ManageArchiveController extends BladeController { + + private final IManageArchiveService manageArchiveService; + + @GetMapping("/stats") + @ApiOperationSupport(order = 1) + @Operation(summary = "档案统计", description = "司机档案与车辆档案的总数、30 天内到期数、已到期数;不按组织收口") + public R stats() { + return R.data(manageArchiveService.stats()); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageArchiveService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageArchiveService.java new file mode 100644 index 00000000..478f7654 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IManageArchiveService.java @@ -0,0 +1,37 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.transport.pojo.vo.ArchiveManageStatsVO; + +/** + * 调度端档案统计 + */ +public interface IManageArchiveService { + + /** + * 司机与车辆档案的证件有效期统计 + */ + ArchiveManageStatsVO stats(); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageArchiveServiceImpl.java new file mode 100644 index 00000000..8bf23edf --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageArchiveServiceImpl.java @@ -0,0 +1,54 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import lombok.AllArgsConstructor; +import org.springblade.transport.pojo.vo.ArchiveManageStatsVO; +import org.springblade.transport.pojo.vo.DriverVO; +import org.springblade.transport.pojo.vo.TransportVehicleVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IManageArchiveService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springframework.stereotype.Service; + +/** + * 调度端档案统计 + */ +@Service +@AllArgsConstructor +public class ManageArchiveServiceImpl implements IManageArchiveService { + + private final IDriverService driverService; + private final ITransportVehicleService transportVehicleService; + + @Override + public ArchiveManageStatsVO stats() { + // 复用管理端同一套统计:today / warningDate 由各 service 的 prepareQuery 自己填, + // 不传筛选条件即全量计数(与小程序侧 searchDrivers 同口径,未按组织收口) + ArchiveManageStatsVO vo = new ArchiveManageStatsVO(); + vo.setDriver(driverService.expiryStat(new DriverVO())); + vo.setVehicle(transportVehicleService.expiryStat(new TransportVehicleVO())); + return vo; + } + +} From 85a3416b6e979512d60f8873908fa56e24bf5384 Mon Sep 17 00:00:00 2001 From: weicw <594098497@qq.com> Date: Wed, 28 Oct 2026 17:36:04 +0800 Subject: [PATCH 23/27] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=AB=AF=E5=8F=B8=E6=9C=BA=E4=B8=8E=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E6=A1=A3=E6=A1=88=E7=AE=A1=E7=90=86=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /archive/manage/driver|vehicle 的 page/detail/submit/status 八个接口,仅登录态 + 显式 admin 校验, 权限不足回业务 403(小程序把 401 当会话失效,不能回 401) - 分页支持 keyword(司机:姓名/手机号;车辆:车牌/VIN/所有人)与 expireStatus、status - 停用真正生效:/driver/vehicles 只回启用车辆、停用车辆不可提交车辆认证、 派单检索排除停用司机、选车列表改查车辆档案表并排除停用车辆 - 管理端保存不再重置车辆认证状态(此前每存一次就造出「审核中但没有待审记录」的死状态) --- .../transport/pojo/vo/DriverVO.java | 6 + .../transport/pojo/vo/TransportVehicleVO.java | 6 + .../controller/ManageArchiveController.java | 10 + .../ManageArchiveDriverController.java | 171 ++++++++++++++++++ .../ManageArchiveVehicleController.java | 169 +++++++++++++++++ .../transport/mapper/DriverMapper.xml | 5 + .../mapper/TransportVehicleMapper.xml | 5 + .../service/impl/DriverAppServiceImpl.java | 8 + .../service/impl/DriverCertServiceImpl.java | 18 ++ .../impl/ManageWaybillServiceImpl.java | 70 +++++-- .../impl/TransportVehicleServiceImpl.java | 6 +- 11 files changed, 458 insertions(+), 16 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveDriverController.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveVehicleController.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java index 5b22c7ac..1072b4be 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVO.java @@ -48,6 +48,12 @@ public class DriverVO extends Driver { @Serial private static final long serialVersionUID = 1L; + /** + * 搜索关键字:同时模糊匹配姓名与手机号(小程序列表用,管理端不传) + */ + @TableField(exist = false) + @Schema(description = "搜索关键字(姓名 / 手机号)") + private String keyword; /** * 证件有效期状态:within30-30天内到期,expired-已到期 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java index bd4ab362..97106813 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportVehicleVO.java @@ -48,6 +48,12 @@ public class TransportVehicleVO extends TransportVehicle { @Serial private static final long serialVersionUID = 1L; + /** + * 搜索关键字:同时模糊匹配车牌、VIN 与所有人(小程序列表用,管理端不传) + */ + @TableField(exist = false) + @Schema(description = "搜索关键字(车牌 / VIN / 所有人)") + private String keyword; /** * 证件有效期状态:within30-30天内到期,expired-已到期 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java index e38ba183..35442fb0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveController.java @@ -30,6 +30,7 @@ import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.vo.ArchiveManageStatsVO; import org.springblade.transport.service.IManageArchiveService; +import org.springblade.transport.support.MpUserDataScopeSupport; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -53,6 +54,7 @@ import org.springframework.web.bind.annotation.RestController; public class ManageArchiveController extends BladeController { private final IManageArchiveService manageArchiveService; + private final MpUserDataScopeSupport mpUserDataScopeSupport; @GetMapping("/stats") @ApiOperationSupport(order = 1) @@ -61,4 +63,12 @@ public class ManageArchiveController extends BladeController { return R.data(manageArchiveService.stats()); } + // 本接口不挂 @PreAuth;无权限返回 false,不抛 403,供小程序决定空态。 + @GetMapping("/capability") + @ApiOperationSupport(order = 2) + @Operation(summary = "档案管理能力判定", description = "当前登录是否为调度端管理员;false 时小程序显示无权限空态,不踢登录") + public R capability() { + return R.data(mpUserDataScopeSupport.isMpDispatcher()); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveDriverController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveDriverController.java new file mode 100644 index 00000000..7390edd6 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveDriverController.java @@ -0,0 +1,171 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.mp.support.Condition; +import org.springblade.core.mp.support.Query; +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.vo.DriverVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.support.MpUserDataScopeSupport; +import org.springblade.transport.wrapper.DriverWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +/** + * 调度端司机档案管理(小程序) + *

+ * 对外完整路径:{@code /api/blade-transport/archive/manage/driver/**} + * (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/driver/**}), + * 同时兼容未去前缀直连。 + *

+ * 为什么不复用管理端的 {@code /driver/**}:那些接口挂在 {@code @PreAuth(menu = "driver")} 下, + * 小程序令牌的 roleId 是占位值 "0"、查不到 blade_role_menu,对任何小程序账号都必然被拒。 + * 这里不挂 {@code @PreAuth},改成方法内显式判定管理员身份,且**权限不足必须回业务 403**: + * 小程序请求层把 401 一律当会话失效(清令牌 + 回登录页),回 401 会把越权访问变成被踢下线。 + * + * @author Chill + */ +@RestController +@AllArgsConstructor +@RequestMapping({"/archive/manage/driver", "/blade-transport/archive/manage/driver"}) +@Tag(name = "调度端司机档案", description = "小程序调度端司机管理") +public class ManageArchiveDriverController extends BladeController { + + private static final int DEFAULT_CURRENT = 1; + private static final int DEFAULT_SIZE = 10; + private static final int MAX_SIZE = 100; + + /** + * 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。 + */ + private static final int FORBIDDEN_CODE = 403; + + private static final String FORBIDDEN_MSG = "仅调度端管理员可管理司机档案"; + + private final IDriverService driverService; + private final MpUserDataScopeSupport mpUserDataScopeSupport; + + /** + * 分页 + */ + @GetMapping("/page") + @ApiOperationSupport(order = 1) + @Operation(summary = "司机档案分页", description = "keyword 同时模糊匹配姓名与手机号;status 不传为启用停用全量;expireStatus 取 within30 / expired") + public R> page(DriverVO driver, Query query) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + fillExpiryDate(driver); + return R.data(driverService.selectDriverPage(Condition.getPage(normalizeQuery(query)), driver)); + } + + /** + * 详情 + */ + @GetMapping("/detail") + @ApiOperationSupport(order = 2) + @Operation(summary = "司机档案详情") + public R detail(@Parameter(description = "司机主键", required = true) @RequestParam Long id) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + Driver query = new Driver(); + query.setId(id); + return R.data(DriverWrapper.build().entityVO(driverService.getOne(Condition.getQueryWrapper(query)))); + } + + /** + * 新增或修改 + */ + @PostMapping("/submit") + @ApiOperationSupport(order = 3) + @Operation(summary = "新增或修改司机档案", description = "直接写档案、立即生效,不生成待审快照") + public R submit(@Valid @RequestBody Driver driver) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + // 生效指针只由认证审核流水写(通过才建立生效快照),不接受管理端写入 + driver.setEffectiveCertRecordId(null); + return R.status(driverService.submit(driver)); + } + + /** + * 启停用 + */ + @PostMapping("/status") + @ApiOperationSupport(order = 4) + @Operation(summary = "启用 / 停用司机", description = "status:1 启用,2 停用。停用司机不进调度检索,但仍可登录") + public R status(@Parameter(description = "主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1 启用,2 停用", required = true) @RequestParam Integer status) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + return R.status(driverService.changeStatus(id, status)); + } + + private boolean canManage() { + return mpUserDataScopeSupport.isMpDispatcher(); + } + + private Query normalizeQuery(Query query) { + if (query == null) { + query = new Query(); + } + if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) { + query.setCurrent(DEFAULT_CURRENT); + } + if (query.getSize() == null || query.getSize() <= 0) { + query.setSize(DEFAULT_SIZE); + } + if (query.getSize() > MAX_SIZE) { + query.setSize(MAX_SIZE); + } + return query; + } + + private void fillExpiryDate(DriverVO driver) { + if (driver.getToday() == null) { + driver.setToday(LocalDate.now()); + } + if (driver.getWarningDate() == null) { + driver.setWarningDate(driver.getToday().plusDays(30)); + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveVehicleController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveVehicleController.java new file mode 100644 index 00000000..b6f930a1 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageArchiveVehicleController.java @@ -0,0 +1,169 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.mp.support.Condition; +import org.springblade.core.mp.support.Query; +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.TransportVehicleVO; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.MpUserDataScopeSupport; +import org.springblade.transport.wrapper.TransportVehicleWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDate; + +/** + * 调度端车辆档案管理(小程序) + *

+ * 对外完整路径:{@code /api/blade-transport/archive/manage/vehicle/**} + * (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /archive/manage/vehicle/**}), + * 同时兼容未去前缀直连。 + *

+ * 与管理端 {@code /transport-vehicle/**} 的关系、以及为什么必须回业务 403 而不是 401, + * 见 {@link ManageArchiveDriverController} 的类注释。 + * + * @author Chill + */ +@RestController +@AllArgsConstructor +@RequestMapping({"/archive/manage/vehicle", "/blade-transport/archive/manage/vehicle"}) +@Tag(name = "调度端车辆档案", description = "小程序调度端车辆管理") +public class ManageArchiveVehicleController extends BladeController { + + private static final int DEFAULT_CURRENT = 1; + private static final int DEFAULT_SIZE = 10; + private static final int MAX_SIZE = 100; + + /** + * 权限不足的业务码。小程序请求层把 401 当会话失效,接口一律不得回 401。 + */ + private static final int FORBIDDEN_CODE = 403; + + private static final String FORBIDDEN_MSG = "仅调度端管理员可管理车辆档案"; + + private final ITransportVehicleService transportVehicleService; + private final MpUserDataScopeSupport mpUserDataScopeSupport; + + /** + * 分页 + */ + @GetMapping("/page") + @ApiOperationSupport(order = 1) + @Operation(summary = "车辆档案分页", description = "keyword 同时模糊匹配车牌、VIN 与所有人;status 不传为启用停用全量;expireStatus 取 within30 / expired") + public R> page(TransportVehicleVO vehicle, Query query) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + fillExpiryDate(vehicle); + return R.data(transportVehicleService.selectTransportVehiclePage(Condition.getPage(normalizeQuery(query)), vehicle)); + } + + /** + * 详情 + */ + @GetMapping("/detail") + @ApiOperationSupport(order = 2) + @Operation(summary = "车辆档案详情") + public R detail(@Parameter(description = "车辆主键", required = true) @RequestParam Long id) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + TransportVehicle query = new TransportVehicle(); + query.setId(id); + return R.data(TransportVehicleWrapper.build().entityVO(transportVehicleService.getOne(Condition.getQueryWrapper(query)))); + } + + /** + * 新增或修改 + */ + @PostMapping("/submit") + @ApiOperationSupport(order = 3) + @Operation(summary = "新增或修改车辆档案", description = "直接写档案、立即生效,不生成待审快照,也不改动认证状态") + public R submit(@Valid @RequestBody TransportVehicle vehicle) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + // 生效指针只由认证审核流水写(通过才建立生效快照),不接受管理端写入 + vehicle.setEffectiveCertRecordId(null); + return R.status(transportVehicleService.submit(vehicle)); + } + + /** + * 启停用 + */ + @PostMapping("/status") + @ApiOperationSupport(order = 4) + @Operation(summary = "启用 / 停用车辆", description = "status:1 启用,2 停用。停用车辆不进车辆检索、司机端车辆列表不再出现") + public R status(@Parameter(description = "主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1 启用,2 停用", required = true) @RequestParam Integer status) { + if (!canManage()) { + return R.fail(FORBIDDEN_CODE, FORBIDDEN_MSG); + } + return R.status(transportVehicleService.changeStatus(id, status)); + } + + private boolean canManage() { + return mpUserDataScopeSupport.isMpDispatcher(); + } + + private Query normalizeQuery(Query query) { + if (query == null) { + query = new Query(); + } + if (query.getCurrent() == null || query.getCurrent() < DEFAULT_CURRENT) { + query.setCurrent(DEFAULT_CURRENT); + } + if (query.getSize() == null || query.getSize() <= 0) { + query.setSize(DEFAULT_SIZE); + } + if (query.getSize() > MAX_SIZE) { + query.setSize(MAX_SIZE); + } + return query; + } + + private void fillExpiryDate(TransportVehicleVO vehicle) { + if (vehicle.getToday() == null) { + vehicle.setToday(LocalDate.now()); + } + if (vehicle.getWarningDate() == null) { + vehicle.setWarningDate(vehicle.getToday().plusDays(30)); + } + } + +} 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 2f1b5e2a..1cdc59b7 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 @@ -147,6 +147,11 @@ is_deleted = 0 + + + + AND (driver_name LIKE #{driverKeywordLike} OR mobile LIKE #{driverKeywordLike}) + AND driver_name LIKE #{driverNameLike} 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 0d5857fd..f261e329 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 @@ -116,6 +116,11 @@ is_deleted = 0 + + + + AND (plate_no LIKE #{vehicleKeywordLike} OR vin LIKE #{vehicleKeywordLike} OR owner_name LIKE #{vehicleKeywordLike}) +