🐛 修 code-review 指出的三处问题:车牌匹配、提交人姓名为空、分页写法

1) **车牌匹配漏了规范化兜底(最严重)**
   DriverWaybillServiceImpl.assertCertified 把 splitPlates 返回的**规范化**车牌
   直接与 plate_no 原始列做精确 IN。库中存在带间隔符/横线/大小写差异的车牌时
   会命中 0 行,导致「名下无可用车辆」→ **已认证的司机被误判为未认证而拒绝接单**,
   与本次改造的核心验收「已认证的车重新提交后仍可正常接单」相悖。
   DriverCertServiceImpl.findByPlateWithinBound 同样问题:先精确 IN 取候选再规范化
   filter,而 filter 只能收窄不能放宽,漏掉的候选永远找不回来(等于没做规范化匹配)。

   改法:把「精确 IN 或 规范化后相等」抽成 VehiclePlateSupport.applyPlateMatch,
   三处调用点(含原先自己实现的 DriverAppServiceImpl.myVehicles)统一使用。
   并加测试断言 SQL 表达式覆盖 Java 侧剔除的全部字符、且必须带 OR 兜底,
   防止两边日后悄悄分叉。

2) **审核详情的提交人姓名恒为空**:CertAuditDetailVO.submitUserName 声明了却从未赋值,
   违反「VO 展示字段须由服务端用 UserCache 填好」的规范。已补。

3) **分页未走项目统一写法**:原用 Page.of 手搓,全模块无先例。改为
   Controller 收 Query + Condition.getPage(query),与 AccidentRecordController 等一致。
   Query 绑定的正是 current/size,故三端契约不受影响。

顺带删除无任何调用者的 ICertAuditService.getRecordOrNull(推测性抽象 + 纯转发)。

82 个测试通过。
This commit is contained in:
2026-10-02 17:30:48 +08:00
parent 0309d26184
commit 4de1ea5209
8 changed files with 121 additions and 45 deletions
@@ -32,6 +32,8 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
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.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
@@ -68,9 +70,8 @@ public class CertificationAuditController extends BladeController {
@RequestParam String bizType,
@Parameter(description = "展示键模糊匹配:司机姓名 / 车牌号")
@RequestParam(required = false) String bizKey,
@Parameter(description = "页码") @RequestParam(required = false) Integer current,
@Parameter(description = "每页条数") @RequestParam(required = false) Integer size) {
return R.data(certAuditService.pendingPage(bizType, bizKey, current, size));
Query query) {
return R.data(certAuditService.pendingPage(bizType, bizKey, Condition.getPage(query)));
}
@GetMapping("/detail")
@@ -44,12 +44,12 @@ public interface ICertAuditService {
* 待审记录分页(按认证对象类型)
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizKey 展示键模糊匹配,可为空
* @param pageNum 页码
* @param pageSize 每页条数
* @param bizKey 展示键模糊匹配,可为空
* @param page 分页参数(由 Controller 用 Condition.getPage 构造)
* @return 待审记录分页
*/
IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, Integer pageNum, Integer pageSize);
IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey,
IPage<TransportCertRecord> page);
/**
* 审核详情:同时返回待审快照与当前生效值,供前端做 diff
@@ -69,12 +69,4 @@ public interface ICertAuditService {
*/
boolean audit(Long recordId, boolean pass, String rejectReason);
/**
* 按主键取记录,供写指针时复用
*
* @param recordId 认证记录主键
* @return 认证记录;不存在返回 null
*/
TransportCertRecord getRecordOrNull(Long recordId);
}
@@ -28,11 +28,11 @@ package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.entity.TransportVehicle;
@@ -71,7 +71,7 @@ public class CertAuditServiceImpl implements ICertAuditService {
@Override
public IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey,
Integer pageNum, Integer pageSize) {
IPage<TransportCertRecord> page) {
if (!CertificationRuleSupport.isValidBizType(bizType)) {
throw new ServiceException("认证对象类型不正确");
}
@@ -80,9 +80,6 @@ public class CertAuditServiceImpl implements ICertAuditService {
.eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING)
.like(Func.isNotEmpty(bizKey), TransportCertRecord::getBizKey, bizKey)
.orderByDesc(TransportCertRecord::getSubmitTime);
Page<TransportCertRecord> page = Page.of(
Func.isEmpty(pageNum) ? 1 : pageNum,
Func.isEmpty(pageSize) ? 10 : pageSize);
return TransportCertRecordWrapper.build().pageVO(certRecordService.page(page, queryWrapper));
}
@@ -100,6 +97,8 @@ public class CertAuditServiceImpl implements ICertAuditService {
detail.setStatusText(CertificationRuleSupport.statusText(record.getStatus()));
detail.setSubmitTime(record.getSubmitTime());
detail.setSubmitUser(record.getSubmitUser());
// 面向前端的展示字段必须由服务端填好,否则审核页「提交人」一栏恒为空白
detail.setSubmitUserName(UserCache.getUserRealName(record.getSubmitUser()));
detail.setPendingSnapshotJson(record.getSnapshotJson());
// 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理
@@ -148,11 +147,6 @@ public class CertAuditServiceImpl implements ICertAuditService {
return true;
}
@Override
public TransportCertRecord getRecordOrNull(Long recordId) {
return Func.isEmpty(recordId) ? null : certRecordService.getById(recordId);
}
/**
* 更新认证对象的展示缓存,并在通过时把生效快照指针切到这条记录
* <p>
@@ -22,6 +22,7 @@
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
@@ -73,18 +74,11 @@ public class DriverAppServiceImpl implements IDriverAppService {
if (plates.isEmpty()) {
return List.of();
}
// 精确匹配 + 规范化匹配(兼容库中带间隔符/横线的车牌)
List<TransportVehicle> vehicles = transportVehicleService.list(Wrappers.<TransportVehicle>lambdaQuery()
.and(w -> {
w.in(TransportVehicle::getPlateNo, plates);
for (String plate : plates) {
w.or().apply(
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no),'·',''),'•',''),'.',''),'-',''),' ','') = {0}",
plate
);
}
})
.orderByDesc(TransportVehicle::getUpdateTime));
// 精确 + 规范化双条件匹配,统一走共享实现(避免各调用点各自实现而漏掉写法差异)
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery, plates);
List<TransportVehicle> vehicles = transportVehicleService.list(
plateQuery.orderByDesc(TransportVehicle::getUpdateTime));
// 去重(精确与规范化可能命中同一条)
Map<Long, TransportVehicle> uniq = new LinkedHashMap<>();
for (TransportVehicle vehicle : vehicles) {
@@ -25,6 +25,7 @@
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
@@ -263,11 +264,12 @@ public class DriverCertServiceImpl implements IDriverCertService {
throw new ServiceException("车牌号不能为空");
}
String normalized = VehiclePlateSupport.normalize(plateNo);
List<TransportVehicle> bound = transportVehicleService.list(
Wrappers.<TransportVehicle>lambdaQuery()
.in(TransportVehicle::getPlateNo,
VehiclePlateSupport.splitPlates(driver.getDrivingVehicle())));
return bound.stream()
// 车牌软关联必须同时容忍写法差异:只精确 IN 会漏掉带间隔符/大小写不同的车牌,
// 后置 filter 无法把漏掉的候选找回来(它只能收窄,不能放宽)
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery,
VehiclePlateSupport.splitPlates(driver.getDrivingVehicle()));
return transportVehicleService.list(plateQuery).stream()
.filter(item -> normalized.equals(VehiclePlateSupport.normalize(item.getPlateNo())))
.findFirst()
.orElse(null);
@@ -391,9 +391,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
if (currentBoundPlates(driver).isEmpty()) {
return;
}
List<TransportVehicle> boundVehicles = transportVehicleService.list(
Wrappers.<TransportVehicle>lambdaQuery()
.in(TransportVehicle::getPlateNo, currentBoundPlates(driver)));
// 用共享的车牌匹配条件:只做精确 IN 会漏掉库中带间隔符/大小写差异的车牌,
// 那会把已认证的司机误判为未认证而拒绝接单
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery, currentBoundPlates(driver));
List<TransportVehicle> boundVehicles = transportVehicleService.list(plateQuery);
boolean anyCertified = boundVehicles.stream().anyMatch(vehicle ->
certificationGate.isVehicleAvailable(
vehicle.getStatus() != null && vehicle.getStatus() == BladeConstant.DB_STATUS_NORMAL,
@@ -25,7 +25,9 @@
*/
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.TransportVehicle;
import java.util.ArrayList;
import java.util.LinkedHashSet;
@@ -110,4 +112,42 @@ public final class VehiclePlateSupport {
return splitPlates(drivingVehicle).contains(target);
}
/**
* 按车牌匹配车辆的 SQL 条件:精确匹配 **或** 规范化后匹配
* <p>
* ⚠️ 只做精确匹配是不够的。{@link #splitPlates} 返回的已是**规范化**车牌
* (去空格/间隔符/横线并转大写),若直接拿它与 {@code plate_no} 原始列做 IN,
* 库中任何带间隔符或大小写差异的车牌都会**匹配不到**。
* 后果不是「少查一条」,而是调用方据此判定「该司机名下没有可用车辆」——
* 认证门槛会因此把**已认证的司机误判成未认证并拒绝接单**。
* <p>
* 库中已知存在这类写法差异({@code DriverAppServiceImpl.myVehicles()} 早就为此加了
* 同样的兜底),故此处把规则收敛成一处,避免各调用点各自实现、各自漏掉。
*
* @param wrapper 查询条件
* @param normalizedPlates 已规范化的车牌列表
*/
public static void applyPlateMatch(LambdaQueryWrapper<TransportVehicle> wrapper,
List<String> normalizedPlates) {
if (normalizedPlates == null || normalizedPlates.isEmpty()) {
return;
}
wrapper.and(w -> {
w.in(TransportVehicle::getPlateNo, normalizedPlates);
for (String plate : normalizedPlates) {
w.or().apply(NORMALIZED_PLATE_SQL + " = {0}", plate);
}
});
}
/**
* 与 {@link #normalize} 等价的 SQL 表达式(列名固定为 {@code plate_no})
* <p>
* 两者必须保持一致:Java 侧剔除的字符集变了,这里也要跟着变。
* {@code VehiclePlateSupportTest} 会断言两边覆盖同一批字符,防止悄悄分叉。
*/
public static final String NORMALIZED_PLATE_SQL =
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no)"
+ ",'·',''),'•',''),'.',''),'.',''),'-',''),' ','')";
}
@@ -1,5 +1,12 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.BeforeAll;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import org.springblade.transport.pojo.entity.TransportVehicle;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -20,6 +27,15 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
*/
class VehiclePlateSupportTest {
@BeforeAll
static void initTableInfo() {
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
MybatisConfiguration configuration = new MybatisConfiguration();
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
assistant.setCurrentNamespace("test");
TableInfoHelper.initTableInfo(assistant, TransportVehicle.class);
}
@Test
@DisplayName("单个车牌:原样返回规范化结果")
void singlePlate() {
@@ -88,4 +104,39 @@ class VehiclePlateSupportTest {
assertFalse(VehiclePlateSupport.isBound("", ""));
}
@Test
@DisplayName("SQL 规范化表达式必须覆盖 Java 侧剔除的全部字符(否则两边会悄悄分叉)")
void sqlNormalizationMirrorsJava() {
String sql = VehiclePlateSupport.NORMALIZED_PLATE_SQL;
// Java 的 normalize 会剔除这些排版字符并转大写,SQL 必须一一对应
for (String noise : List.of("·", "•", ".", ".", "-", " ")) {
assertTrue(sql.contains("'" + noise + "',''"),
"SQL 规范化漏掉了字符 [" + noise + "],会导致该写法差异的车牌匹配不到");
}
assertTrue(sql.contains("UPPER(plate_no)"), "SQL 必须先转大写再比对");
assertTrue(sql.contains("plate_no"), "表达式作用的列应为 plate_no");
}
@Test
@DisplayName("车牌匹配条件是「精确 IN 或 规范化相等」,不能只有精确 IN")
void plateMatchIncludesNormalizedFallback() {
LambdaQueryWrapper<TransportVehicle> wrapper = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(wrapper, List.of("桂A32523"));
String sql = wrapper.getTargetSql();
assertTrue(sql.contains("plate_no IN"), "必须保留精确匹配,避免全表函数扫描");
assertTrue(sql.contains("REPLACE"), "必须带规范化兜底,否则脏车牌匹配不到");
assertTrue(sql.contains("OR"), "两者应是 OR 关系");
}
@Test
@DisplayName("空车牌列表不产生任何条件(避免 IN () 语法错误)")
void emptyPlateListAddsNoCondition() {
LambdaQueryWrapper<TransportVehicle> wrapper = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(wrapper, List.of());
assertEquals("", wrapper.getTargetSql().trim());
VehiclePlateSupport.applyPlateMatch(wrapper, null);
assertEquals("", wrapper.getTargetSql().trim());
}
}