✨ 支持司机新增未登记车辆(原实现会让小程序「+ 新增车辆」必然失败)

小程序的车辆录入页允许司机直接填车牌提交,且空列表时提示
「暂无绑定车辆,点击「+ 新增车辆」添加」。但原实现只按「已完成绑定」查找车辆,
未登记的车牌一律抛「车辆不存在或未绑定」——而司机名下还没有这辆车,也就永远无从绑定,
形成死结:该入口必然提交失败(属票 07 的验收点)。

改为按车牌区分三种情形:
- 未登记 → 就地建档(写入识别信息与证件内容)并把车牌追加到司机名下;
- 已登记且属于本人 → 复用,不重复建档(容忍车牌写法差异);
- 已登记但不在本人名下 → 拒绝,**不顺手改归属**(避免抢车)。

关键约束:新建车辆**不带生效快照指针**,故建档本身不会让一辆未审核通过的车变得可用
——是否已认证仍只由审核通过决定。

4 个新用例覆盖上述三种情形与空车牌拒绝。全模块 89 个测试通过。
This commit is contained in:
2026-10-02 17:35:33 +08:00
parent c66af4c162
commit fbced4e6a0
2 changed files with 163 additions and 22 deletions
@@ -30,6 +30,7 @@ import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
@@ -263,38 +264,98 @@ public class DriverCertServiceImpl implements IDriverCertService {
* @return 目标车辆
*/
private TransportVehicle resolveBoundVehicle(Driver driver, VehicleCertSubmitRequest request) {
if (Func.isEmpty(driver.getDrivingVehicle())) {
throw new ServiceException("当前司机未绑定任何车辆");
// 按 id 定位:只能操作本人绑定的车,否则越权
if (Func.isNotEmpty(request.getId())) {
TransportVehicle vehicle = transportVehicleService.getById(request.getId());
if (vehicle == null) {
throw new ServiceException("车辆不存在");
}
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权操作该车辆的认证");
}
return vehicle;
}
TransportVehicle vehicle = Func.isEmpty(request.getId())
? findByPlateWithinBound(driver, request.getPlateNo())
: transportVehicleService.getById(request.getId());
if (vehicle == null) {
throw new ServiceException("车辆不存在或未绑定到当前司机");
}
// 越权校验:无论按 id 还是按车牌取到,最后都核对一次绑定关系
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权操作该车辆的认证");
}
return vehicle;
}
private TransportVehicle findByPlateWithinBound(Driver driver, String plateNo) {
if (Func.isEmpty(plateNo)) {
String normalized = VehiclePlateSupport.normalize(request.getPlateNo());
if (Func.isEmpty(normalized)) {
throw new ServiceException("车牌号不能为空");
}
String normalized = VehiclePlateSupport.normalize(plateNo);
// 车牌软关联必须同时容忍写法差异:只精确 IN 会漏掉带间隔符/大小写不同的车牌,
// 后置 filter 无法把漏掉的候选找回来(它只能收窄,不能放宽)
// 按车牌定位:已登记的车必须已绑定到本人;未登记的车视为「新增」,就地建档并绑定。
// 小程序「+ 新增车辆」允许司机直接填车牌,若这里只认已有绑定,
// 新车的提交必然失败——而司机名下还没有这辆车,也就永远无从绑定。
TransportVehicle existing = findVehicleByAnyPlateForm(normalized);
if (existing != null) {
if (existing.getIsDeleted() != null && existing.getIsDeleted() != 0) {
throw new ServiceException("该车牌已被删除,请联系管理员");
}
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), existing.getPlateNo())) {
// 已登记但不在本人名下,可能属于其他司机 —— 拒绝,不能顺手改归属
throw new ServiceException("该车牌已归属其他司机,请联系管理员");
}
return existing;
}
return createVehicleForDriver(driver, request, normalized);
}
/**
* 按车牌(容忍写法差异)查已登记的车辆
*
* @param normalizedPlate 已规范化的车牌
* @return 车辆;未登记返回 null
*/
private TransportVehicle findVehicleByAnyPlateForm(String normalizedPlate) {
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery,
VehiclePlateSupport.splitPlates(driver.getDrivingVehicle()));
VehiclePlateSupport.applyPlateMatch(plateQuery, List.of(normalizedPlate));
return transportVehicleService.list(plateQuery).stream()
.filter(item -> normalized.equals(VehiclePlateSupport.normalize(item.getPlateNo())))
.filter(item -> normalizedPlate.equals(VehiclePlateSupport.normalize(item.getPlateNo())))
.findFirst()
.orElse(null);
}
/**
* 为司机新增一辆车并绑定到其名下
* <p>
* 只写入车辆的基本识别信息与本次提交的证件内容;车辆是否「已认证」仍由
* 生效快照指针决定,故此处建档**不会**让一辆未审核通过的车变得可用。
*
* @param driver 当前司机
* @param request 提交内容
* @param normalizedPlate 已规范化的车牌
* @return 新建的车辆
*/
private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request,
String normalizedPlate) {
TransportVehicle vehicle = new TransportVehicle();
vehicle.setPlateNo(normalizedPlate);
vehicle.setPlateColor(request.getPlateColor());
vehicle.setVehicleType(request.getVehicleType());
vehicle.setDrivingLicenseNo(request.getLicenseNo());
vehicle.setDrivingLicenseStartDate(request.getLicenseValidStart());
vehicle.setDrivingLicenseEndDate(request.getLicenseValidEnd());
vehicle.setDrivingLicenseLongTerm(request.getLicenseLongTerm());
vehicle.setDrivingLicenseImage(request.getLicenseFrontUrl());
vehicle.setDrivingLicenseMainBack(request.getLicenseBackUrl());
vehicle.setRoadTransportCertNo(request.getRoadTransportNo());
vehicle.setRoadTransportCertStartDate(request.getRoadTransportValidStart());
vehicle.setRoadTransportCertEndDate(request.getRoadTransportValidEnd());
vehicle.setRoadTransportCertLongTerm(request.getRoadTransportLongTerm());
vehicle.setRoadTransportCertImage(request.getRoadTransportUrl());
vehicle.setStatus(BladeConstant.DB_STATUS_NORMAL);
transportVehicleService.save(vehicle);
// 绑定到司机名下,否则本人后续仍无法操作这辆车
String drivingVehicle = Func.isEmpty(driver.getDrivingVehicle())
? normalizedPlate
: driver.getDrivingVehicle() + "," + normalizedPlate;
Driver patch = new Driver();
patch.setId(driver.getId());
patch.setDrivingVehicle(drivingVehicle);
driverService.updateById(patch);
driver.setDrivingVehicle(drivingVehicle);
return vehicle;
}
/**
* 校验当前登录用户能否查看该认证对象的记录
* <p>
@@ -1,5 +1,6 @@
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.alibaba.fastjson2.JSON;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
@@ -369,6 +370,85 @@ class DriverCertServiceImplTest {
verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID));
}
// ---------------------------------------------------------------- 新增车辆
@Test
@DisplayName("新增车辆:车牌未登记时就地建档并绑定到本人,且新车不被视为已认证")
void submitVehicleCreatesUnregisteredVehicle() {
Driver driver = driver(null); // 司机名下还没有任何车
when(driverAppService.currentDriverEntity()).thenReturn(driver);
// 全库都查不到这个车牌
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setPlateNo("桂A·32523"); // 带间隔符,应被规范化
request.setVehicleType("重型货车");
service.submitVehicleCert(request);
// 建档:保存了一辆车,车挂牌照为规范化结果,且状态为启用
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).save(vehicleCaptor.capture());
assertEquals("桂A32523", vehicleCaptor.getValue().getPlateNo());
assertEquals("重型货车", vehicleCaptor.getValue().getVehicleType());
assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(),
"新建的车不得自带生效快照——是否已认证只能由审核通过决定");
// 绑定:司机的 driving_vehicle 被追加该车牌
ArgumentCaptor<Driver> driverCaptor = ArgumentCaptor.forClass(Driver.class);
verify(driverService).updateById(driverCaptor.capture());
assertEquals("桂A32523", driverCaptor.getValue().getDrivingVehicle());
// 仍然产生一条待审记录
verify(certRecordService).save(any(TransportCertRecord.class));
}
@Test
@DisplayName("新增车辆:车牌已登记在别人名下必须拒绝,不能顺手改归属")
void submitVehicleRejectsPlateOwnedByAnotherDriver() {
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂B11111"));
when(transportVehicleService.list(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(vehicle(VEHICLE_ID, "桂A32523", null))); // 已登记,但不在本人名下
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setPlateNo("桂A32523");
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
verify(certRecordService, never()).save(any(TransportCertRecord.class));
}
@Test
@DisplayName("新增车辆:已登记且属于本人时直接复用,不重复建档")
void submitVehicleReusesOwnRegisteredVehicle() {
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523"));
when(transportVehicleService.list(any(LambdaQueryWrapper.class)))
.thenReturn(List.of(vehicle(VEHICLE_ID, "桂A·32523", null))); // 写法不同但同一辆
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setPlateNo("桂a32523");
service.submitVehicleCert(request);
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).save(captor.capture());
assertEquals(VEHICLE_ID, captor.getValue().getBizId(), "应挂到既有车辆上");
}
@Test
@DisplayName("新增车辆:车牌为空必须拒绝")
void submitVehicleRejectsBlankPlate() {
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523"));
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setPlateNo(" ");
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
}
// ---------------------------------------------------------------- 辅助
private Driver driver(String drivingVehicle) {