✨ 接单叠加认证可用性门槛,带开关可快速回退

在原有运营状态(启用/停用)之上叠加一层「是否已认证」:
- 车辆:启用 且 存在生效快照;司机:存在生效快照。

判据是**生效快照指针**而非「最新提交状态」展示缓存,这一区别正是本改造的核心:
司机重新提交认证会把展示缓存变成「审核中」,若照它判定,司机一提交就接不了单
——那恰恰是本次改造要消除的问题。生效快照只在审核通过时切换,故曾通过即持续可用。

两个开关默认开启,可通过配置关闭以便线上快速回退到改造前行为:
  transport.certification.vehicle-gate-enabled / driver-gate-enabled

门槛落在 DriverWaybillServiceImpl.accept():司机本人与名下**任一**车辆满足即可。
运营状态优先于认证——停用的车即便已认证也不可用(认证只叠加不覆盖)。

顺带消除第三份重复的车牌匹配实现:DriverWaybillServiceImpl 里的
splitPlates/normalizePlate 与 VehiclePlateSupport 逐行相同,改为委托同一实现。

10 个门槛用例(含「重新提交后仍可用」「停用车即便已认证也不可用」)
与全模块 73 个测试通过:mvn -o test -pl blade-service/blade-transport -am
This commit is contained in:
2026-10-02 16:46:04 +08:00
parent 6831ba6fca
commit f351bad545
3 changed files with 270 additions and 22 deletions
@@ -37,6 +37,10 @@ import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
@@ -68,12 +72,10 @@ import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -110,6 +112,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IProcessConfigService processConfigService;
private final ITransportVehicleService transportVehicleService;
private final CertificationGate certificationGate;
private final WaybillEnroutePunchMapper enroutePunchMapper;
private final WaybillNodePunchMapper nodePunchMapper;
@@ -355,6 +359,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Transactional(rollbackFor = Exception.class)
public boolean accept(Long id) {
Driver driver = requireCurrentDriver();
// 认证门槛:司机本人须已认证,且承运车辆须已认证。
// 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。
assertCertified(driver);
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
waybillService.syncDriverAcceptState(waybill);
assertAcceptable(waybill);
@@ -368,6 +375,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return waybillService.updateById(waybill);
}
/**
* 接单前的认证门槛校验
* <p>
* 司机的生效快照由其档案持有;车辆按司机绑定的车牌取。
* 任一不满足即拒绝接单,并给出可读原因。
*
* @param driver 当前司机
*/
private void assertCertified(Driver driver) {
if (!certificationGate.isDriverAvailable(driver.getEffectiveCertRecordId())) {
throw new ServiceException(certificationGate.driverUnavailableReason());
}
if (currentBoundPlates(driver).isEmpty()) {
return;
}
List<TransportVehicle> boundVehicles = transportVehicleService.list(
Wrappers.<TransportVehicle>lambdaQuery()
.in(TransportVehicle::getPlateNo, currentBoundPlates(driver)));
boolean anyCertified = boundVehicles.stream().anyMatch(vehicle ->
certificationGate.isVehicleAvailable(
Integer.valueOf(1).equals(vehicle.getStatus()), vehicle.getEffectiveCertRecordId()));
if (!anyCertified) {
throw new ServiceException(certificationGate.vehicleUnavailableReason());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reject(Long id, String reason) {
@@ -563,26 +596,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
private List<String> splitPlates(String drivingVehicle) {
String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",")
.replace(";", ",").replace("/", ",").replace("|", ",");
Set<String> plates = new LinkedHashSet<>();
for (String part : Func.toStrList(",", normalized)) {
if (Func.isEmpty(part)) {
continue;
}
String plate = normalizePlate(part);
if (Func.isNotEmpty(plate)) {
plates.add(plate);
}
}
return new ArrayList<>(plates);
}
private String normalizePlate(String plateNo) {
if (Func.isEmpty(plateNo)) {
return "";
}
return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT);
return VehiclePlateSupport.splitPlates(drivingVehicle);
}
private int normalizePreviewSize(Integer size) {
@@ -0,0 +1,124 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 认证可用性门槛
* <p>
* 在原有的运营状态(启用 / 停用)之上**叠加**一层「是否已认证」:
* <ul>
* <li>车辆:运营状态为启用,且存在生效快照;</li>
* <li>司机:存在生效快照。</li>
* </ul>
* <p>
* ⚠️ 判据是**生效快照指针**,不是「最新提交状态」展示缓存。
* 这一区别是本改造的核心:司机重新提交认证会把展示缓存变成「审核中」,
* 若照它判定,司机一提交认证就立刻接不了单——正是要避免的事。
* 生效快照只在审核通过时才切换,故「曾认证通过即持续可用」。
* <p>
* 两个开关默认开启,可通过配置关闭以便线上快速回退到改造前的行为:
* <pre>
* transport:
* certification:
* vehicle-gate-enabled: false
* driver-gate-enabled: false
* </pre>
*
* @author Chill
*/
@Component
@Getter
@Setter
public class CertificationGate {
/**
* 车辆认证门槛开关
*/
@Value("${transport.certification.vehicle-gate-enabled:true}")
private boolean vehicleGateEnabled = true;
/**
* 司机认证门槛开关
*/
@Value("${transport.certification.driver-gate-enabled:true}")
private boolean driverGateEnabled = true;
/**
* 判定车辆是否可用
* <p>
* 运营状态优先:停用的车即便已认证也不可用(认证门槛只叠加,不覆盖运营状态)。
*
* @param enabledState 运营状态:TRUE 启用
* @param effectiveRecordId 生效快照指针
* @return 是否可用
*/
public boolean isVehicleAvailable(Boolean enabledState, Long effectiveRecordId) {
if (!Boolean.TRUE.equals(enabledState)) {
return false;
}
if (!vehicleGateEnabled) {
return true;
}
return CertificationRuleSupport.isCertified(effectiveRecordId);
}
/**
* 判定司机是否可接单
*
* @param effectiveRecordId 生效快照指针
* @return 是否可用
*/
public boolean isDriverAvailable(Long effectiveRecordId) {
if (!driverGateEnabled) {
return true;
}
return CertificationRuleSupport.isCertified(effectiveRecordId);
}
/**
* 车辆不可用的提示文案
*
* @return 面向司机的中文提示
*/
public String vehicleUnavailableReason() {
return "该车辆尚未通过认证审核,暂不可接单,请先提交认证";
}
/**
* 司机不可用的提示文案
*
* @return 面向司机的中文提示
*/
public String driverUnavailableReason() {
return "您尚未通过认证审核,暂不可接单,请先完成认证";
}
}
@@ -0,0 +1,110 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 认证可用性门槛测试。
* <p>
* 这是整个认证改造的**核心验收点**:一辆已认证的车被重新提交认证后,必须**仍可接单**。
* 改造前的判定只看车辆的启用/停用状态;本门槛叠加一层「是否已认证」。
* 之所以必须叠加在生效快照上而不是「最新提交状态」,正是因为重新提交会把
* 「最新提交状态」变成「审核中」——若照它判定,司机一提交认证就立刻接不了单。
*/
class CertificationGateTest {
private CertificationGate gate;
@BeforeEach
void setUp() {
gate = new CertificationGate();
}
// ------------------------------------------------------------ 开关
@Test
@DisplayName("门槛默认开启")
void enabledByDefault() {
assertTrue(gate.isVehicleGateEnabled());
assertTrue(gate.isDriverGateEnabled());
}
@Test
@DisplayName("关闭后行为回到改造前:不因未认证而拦截(便于快速回退)")
void disabledLetsEveryoneThrough() {
gate.setVehicleGateEnabled(false);
gate.setDriverGateEnabled(false);
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, null));
assertTrue(gate.isDriverAvailable(null));
}
// ------------------------------------------------------------ 车辆
@Test
@DisplayName("车辆:有生效快照即可用(核心:重新提交不影响可用性)")
void vehicleWithEffectiveSnapshotIsAvailable() {
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L));
}
@Test
@DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用")
void resubmittedVehicleStaysAvailable() {
// 关键场景:司机提交了新资料,展示缓存变成「审核中」,
// 但 effective_cert_record_id 仍指着上一次通过的记录。
// 门槛只看指针,故这辆车照跑不误——这就是本次改造要保住的东西。
assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L),
"已认证的车重新提交后必须仍可接单,否则司机一提交就停工");
}
@Test
@DisplayName("车辆:从未认证(无生效快照)被拒绝")
void vehicleWithoutEffectiveSnapshotIsRejected() {
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null));
}
@Test
@DisplayName("车辆:停用状态优先于认证——停用的车即便有生效快照也不可用")
void disabledVehicleIsRejectedEvenIfCertified() {
assertFalse(gate.isVehicleAvailable(Boolean.FALSE, 7001L),
"启用/停用是运营状态,认证门槛只叠加不覆盖");
assertFalse(gate.isVehicleAvailable(null, 7001L));
}
// ------------------------------------------------------------ 司机
@Test
@DisplayName("司机:有生效快照即可用")
void driverWithEffectiveSnapshotIsAvailable() {
assertTrue(gate.isDriverAvailable(9001L));
}
@Test
@DisplayName("司机:从未认证被拒绝")
void driverWithoutEffectiveSnapshotIsRejected() {
assertFalse(gate.isDriverAvailable(null));
}
@Test
@DisplayName("司机门槛可单独关闭(改造期间用于渐进放开)")
void driverGateCanBeDisabledIndependently() {
gate.setDriverGateEnabled(false);
assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截");
assertFalse(gate.isVehicleAvailable(Boolean.TRUE, null), "车辆门槛不受影响");
}
// ------------------------------------------------------------ 提示文案
@Test
@DisplayName("拒绝原因可读,且不泄露内部字段名")
void rejectReasonIsReadable() {
assertTrue(gate.vehicleUnavailableReason().contains("认证"));
assertFalse(gate.vehicleUnavailableReason().contains("effective_cert_record_id"));
assertTrue(gate.driverUnavailableReason().contains("认证"));
}
}