✨ 审核列表新增司机姓名与手机号,并修正驾驶车辆绑定校验

审核页的司机姓名与手机号取司机档案的**当前值**,不是提交时的历史值:
手机号从未进入过认证快照(只有司机档案这一个来源),而队列的用途是
「找得到人、联系得上」。司机侧的认证对象就是记录本身;车辆侧记录不含任何
司机信息,取提交该记录的司机。存量订正记录里没有系统账号的那些司机解析不到,
两列为空属正常状态。

筛选必须发生在分页之前,故列表改为自定义 SQL;在 Java 层补字段再过滤会变成
「先分页再过滤」,每页条数与总数都不准。姓名搜索同时匹配档案当前姓名与提交时的
旧名,改名后按新名、旧名都搜得到。

顺带修掉两处既有缺陷:

1. 管理端保存绑定多辆车的司机会误报「驾驶车辆不存在」——小程序把车牌逗号并列
   写入 driving_vehicle,管理端却拿整串去车牌表精确查,两边口径不一致。
2. 「一个司机最多 5 辆车」后端从未校验,且 driving_vehicle 原为 varchar(30),
   5 个车牌连分隔符要 39 字符,超长写入会被静默截断成半个车牌(还会被当成一辆车)。
   列扩到 varchar(100) 并在写入前校验数量与车牌格式。
This commit is contained in:
2026-10-03 14:35:59 +08:00
parent bd01122409
commit cc997c1c0c
17 changed files with 469 additions and 42 deletions
@@ -78,5 +78,23 @@ public class TransportCertRecordVO extends TransportCertRecord {
@TableField(exist = false)
@Schema(description = "状态中文文案")
private String statusText;
/**
* 司机姓名(认证对象档案的**当前**姓名,不是提交时的历史值)
* <p>
* 司机侧取认证对象本身;车辆侧取提交该记录的司机。
* 解析不到档案时为空——存量订正记录里没有账号的那些司机补不出人,如实留空。
*/
@TableField(exist = false)
@Schema(description = "司机姓名")
private String driverName;
/**
* 司机手机号(认证对象档案的**当前**手机号,明文下发)
* <p>
* 审核员靠它联系司机核实材料,故不脱敏;认证快照里从来没有手机号,
* 它只有司机档案这一个来源。
*/
@TableField(exist = false)
@Schema(description = "司机手机号")
private String driverMobile;
}
@@ -63,17 +63,21 @@ public class CertificationAuditController extends BladeController {
@GetMapping("/pending-page")
@ApiOperationSupport(order = 1)
@Operation(summary = "认证记录列表", description = "按认证对象类型与审核状态分页查询,按提交时间倒序;状态不传时查审核中的记录")
@Operation(summary = "认证记录列表", description = "按认证对象类型与审核状态分页查询,按提交时间倒序;状态不传时查审核中的记录。列表同时下发该记录司机的姓名与手机号(司机档案当前值)")
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
public R<IPage<TransportCertRecordVO>> pendingPage(
@Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
@RequestParam String bizType,
@Parameter(description = "展示键模糊匹配:司机姓名 / 车牌号")
@Parameter(description = "展示键模糊匹配:提交时的司机姓名 / 车牌号")
@RequestParam(required = false) String bizKey,
@Parameter(description = "司机姓名模糊匹配:当前姓名,同时兼顾提交时的旧名")
@RequestParam(required = false) String driverName,
@Parameter(description = "司机手机号模糊匹配:支持只输后四位")
@RequestParam(required = false) String driverMobile,
@Parameter(description = "审核状态:0审核中 1已通过 2已驳回 3已作废;不传按0(审核中)处理")
@RequestParam(required = false) Integer status,
Query query) {
return R.data(certAuditService.pendingPage(bizType, bizKey, status, Condition.getPage(query)));
return R.data(certAuditService.pendingPage(bizType, bizKey, driverName, driverMobile, status, Condition.getPage(query)));
}
@GetMapping("/detail")
@@ -26,7 +26,12 @@
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Param;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import java.util.List;
/**
* 认证记录 Mapper 接口
@@ -35,4 +40,26 @@ import org.springblade.transport.pojo.entity.TransportCertRecord;
*/
public interface TransportCertRecordMapper extends BaseMapper<TransportCertRecord> {
/**
* 审核列表分页:认证记录 + 该记录的司机姓名与手机号
* <p>
* 必须走自定义 SQL 而不是单表 Wrapper:司机姓名与手机号取的是司机档案的**当前**值,
* 且「按司机姓名/手机号筛选」必须发生在**分页之前**——若在 Java 层补字段再过滤,
* 就变成「先分页再过滤」,每页条数与总数都不准。
*
* @param page 分页参数
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param status 审核状态:0审核中 1已通过 2已驳回 3已作废
* @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空
* @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空
* @param driverMobile 司机手机号模糊匹配,可为空
* @return 认证记录列表
*/
List<TransportCertRecordVO> selectAuditPage(IPage<TransportCertRecordVO> page,
@Param("bizType") String bizType,
@Param("status") Integer status,
@Param("bizKey") String bizKey,
@Param("driverName") String driverName,
@Param("driverMobile") String driverMobile);
}
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.transport.mapper.TransportCertRecordMapper">
<resultMap id="certRecordAuditMap" type="org.springblade.transport.pojo.vo.TransportCertRecordVO">
<result column="id" property="id"/>
<result column="tenant_id" property="tenantId"/>
<result column="biz_type" property="bizType"/>
<result column="biz_id" property="bizId"/>
<result column="biz_key" property="bizKey"/>
<result column="status" property="status"/>
<result column="reject_reason" property="rejectReason"/>
<result column="submit_time" property="submitTime"/>
<result column="submit_user" property="submitUser"/>
<result column="audit_time" property="auditTime"/>
<result column="audit_user" property="auditUser"/>
<result column="create_user" property="createUser"/>
<result column="create_dept" property="createDept"/>
<result column="create_time" property="createTime"/>
<result column="update_user" property="updateUser"/>
<result column="update_time" property="updateTime"/>
<result column="is_deleted" property="isDeleted"/>
<result column="driver_name" property="driverName"/>
<result column="driver_mobile" property="driverMobile"/>
</resultMap>
<!--
审核列表:认证记录 + 该记录的司机姓名与手机号(司机档案的当前值)。
司机是谁:司机侧就是认证对象本身(biz_id);车辆侧是这条记录的**提交司机**
(车辆认证强制「只能操作本人绑定车辆」,故提交司机必然是这辆车的绑定司机)。
提交人存的是用户 ID,而司机档案的 user_id 是同一个值,故按 user_id 定位。
子查询带 ORDER BY update_time DESC LIMIT 1 有实际作用,不是保险起见:
user_id 上没有唯一索引,脏数据里同一用户可能对应多条司机档案,
不收敛就会让一条记录在列表里出现多行(并让总数虚高)。
按 update_time 最新取,与司机端解析当前司机档案的既有口径一致。
-->
<select id="selectAuditPage" resultMap="certRecordAuditMap">
SELECT
r.id,
r.tenant_id,
r.biz_type,
r.biz_id,
r.biz_key,
r.status,
r.reject_reason,
r.submit_time,
r.submit_user,
r.audit_time,
r.audit_user,
r.create_user,
r.create_dept,
r.create_time,
r.update_user,
r.update_time,
r.is_deleted,
d.driver_name AS driver_name,
d.mobile AS driver_mobile
FROM
blade_transport_cert_record r
LEFT JOIN blade_transport_driver d
ON d.is_deleted = 0
AND d.id = CASE
WHEN r.biz_type = 'driver' THEN r.biz_id
ELSE (
SELECT dr.id
FROM blade_transport_driver dr
WHERE dr.is_deleted = 0
AND dr.user_id = r.submit_user
ORDER BY dr.update_time DESC, dr.id DESC
LIMIT 1
)
END
WHERE
r.is_deleted = 0
AND r.biz_type = #{bizType}
AND r.status = #{status}
<if test="bizKey != null and bizKey != ''">
AND r.biz_key LIKE CONCAT('%', #{bizKey}, '%')
</if>
<!--
姓名同时匹配「档案当前姓名」与「提交时的姓名」:
司机改名后,按新名与按旧名都应该搜得到他的记录,
只认一个会让审核员两头试。手机号则只有档案当前值一个来源。
-->
<if test="driverName != null and driverName != ''">
AND (
d.driver_name LIKE CONCAT('%', #{driverName}, '%')
OR r.biz_key LIKE CONCAT('%', #{driverName}, '%')
)
</if>
<if test="driverMobile != null and driverMobile != ''">
AND d.mobile LIKE CONCAT('%', #{driverMobile}, '%')
</if>
ORDER BY
r.submit_time DESC
</select>
</mapper>
@@ -45,15 +45,22 @@ public interface ICertAuditService {
* <p>
* 审核页按「待审核 / 已通过 / 已驳回」分页签展示,故状态可传;
* 不传时按待审核处理,兼容只想要待审队列的调用方。
* <p>
* 列表同时下发该记录的司机姓名与手机号,取的是司机档案的**当前**值
* (认证记录里的姓名是提交时的历史值,手机号则从来不在快照里)。
* 解析不到司机档案的记录,两个字段为空,不回落成提交时的值——
* 那会让「同一个人的多条记录显示成不同的人」。
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizKey 展示键模糊匹配,可为空
* @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中)
* @param page 分页参数(由 Controller 用 Condition.getPage 构造)
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空
* @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空
* @param driverMobile 司机手机号模糊匹配,可为空
* @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中)
* @param page 分页参数(由 Controller 用 Condition.getPage 构造)
* @return 认证记录分页
*/
IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, Integer status,
IPage<TransportCertRecord> page);
IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, String driverName, String driverMobile,
Integer status, IPage<TransportCertRecordVO> page);
/**
* 审核详情:同时返回待审快照与当前生效值,供前端做 diff
@@ -25,14 +25,13 @@
*/
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 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.mapper.TransportCertRecordMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.entity.TransportVehicle;
@@ -48,6 +47,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 认证审核服务实现
@@ -66,24 +66,43 @@ import java.time.LocalDateTime;
public class CertAuditServiceImpl implements ICertAuditService {
private final ITransportCertRecordService certRecordService;
private final TransportCertRecordMapper certRecordMapper;
private final ITransportVehicleService transportVehicleService;
private final IDriverService driverService;
@Override
public IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, Integer status,
IPage<TransportCertRecord> page) {
public IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, String driverName, String driverMobile,
Integer status, IPage<TransportCertRecordVO> page) {
if (!CertificationRuleSupport.isValidBizType(bizType)) {
throw new ServiceException("认证对象类型不正确");
}
// 未传状态时按「审核中」处理:审核页默认停在待审页签,
// 只关心待审队列的调用方也不必额外传参
Integer queryStatus = status == null ? TransportCertRecord.STATUS_PENDING : status;
LambdaQueryWrapper<TransportCertRecord> queryWrapper = Wrappers.<TransportCertRecord>lambdaQuery()
.eq(TransportCertRecord::getBizType, bizType)
.eq(TransportCertRecord::getStatus, queryStatus)
.like(Func.isNotEmpty(bizKey), TransportCertRecord::getBizKey, bizKey)
.orderByDesc(TransportCertRecord::getSubmitTime);
return TransportCertRecordWrapper.build().pageVO(certRecordService.page(page, queryWrapper));
// 司机姓名与手机号、以及按它们的筛选,都要在**分页之前**由 SQL 完成:
// 在 Java 层补字段再过滤会变成「先分页再过滤」,条数与总数都不准。
List<TransportCertRecordVO> records = certRecordMapper.selectAuditPage(page, bizType, queryStatus,
blankToNull(bizKey), blankToNull(driverName), blankToNull(driverMobile));
TransportCertRecordWrapper wrapper = TransportCertRecordWrapper.build();
records.forEach(wrapper::fillDisplayFields);
return page.setRecords(records);
}
/**
* 搜索项的空串与纯空白一律当作「没填」
* <p>
* 否则 {@code " "} 会变成 {@code LIKE '% %'},把「没输入」当成一个真实筛选条件。
*
* @param value 原始搜索值
* @return 去空白后的值;为空时返回 null
*/
private String blankToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
@Override
@@ -352,6 +352,9 @@ public class DriverCertServiceImpl implements IDriverCertService {
String drivingVehicle = Func.isEmpty(driver.getDrivingVehicle())
? normalizedPlate
: driver.getDrivingVehicle() + "," + normalizedPlate;
// 「最多 5 辆 + 车牌格式」在写入前校验:字段只有 varchar(100),
// 超限内容一旦写进去要么报错、要么被静默截断成半个车牌(还会被当成一辆车)
VehiclePlateSupport.validateBoundVehicles(drivingVehicle);
Driver patch = new Driver();
patch.setId(driver.getId());
patch.setDrivingVehicle(drivingVehicle);
@@ -43,12 +43,12 @@ import org.springblade.system.pojo.enums.UserSex;
import org.springblade.system.pojo.enums.UserType;
import org.springblade.transport.excel.DriverExcel;
import org.springblade.transport.mapper.DriverMapper;
import org.springblade.transport.mapper.TransportVehicleMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -68,14 +68,13 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
private static final int NAME_MAX_LENGTH = 10;
private static final int ID_CARD_MAX_LENGTH = 18;
private static final int MOBILE_MAX_LENGTH = 20;
private static final int VEHICLE_NO_MAX_LENGTH = 30;
private static final int VEHICLE_NO_MAX_LENGTH = 100;
private static final int SHORT_TEXT_MAX_LENGTH = 50;
private static final int ADDRESS_MAX_LENGTH = 200;
private static final int REMARK_MAX_LENGTH = 200;
private static final int DEFAULT_ENABLED_STATUS = 1;
private static final int DEFAULT_FALSE = 0;
private final TransportVehicleMapper transportVehicleMapper;
private final IUserClient userClient;
private final ISysClient sysClient;
@@ -250,15 +249,11 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
validateLength(driver.getIdCardNo(), ID_CARD_MAX_LENGTH, "身份证号不能超过18字");
validateLength(driver.getMobile(), MOBILE_MAX_LENGTH, "手机号不能超过20字");
validateLength(driver.getEmergencyContactMobile(), MOBILE_MAX_LENGTH, "紧急联系人手机号不能超过20字");
validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过30字");
if (Func.isNotEmpty(driver.getDrivingVehicle())) {
Long vehicleCount = transportVehicleMapper.selectCount(Wrappers.<TransportVehicle>lambdaQuery()
.eq(TransportVehicle::getIsDeleted, 0)
.eq(TransportVehicle::getPlateNo, driver.getDrivingVehicle()));
if (vehicleCount == null || vehicleCount <= 0) {
throw new ServiceException("驾驶车辆不存在,请重新选择");
}
}
validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过100字");
// 「驾驶车辆」是**逗号并列的多辆车**(小程序每绑一辆就追加一个车牌),
// 不是一个车牌。此前这里拿整串去车牌表精确查,绑了两辆车的司机
// 在司机管理页一保存就会撞上「驾驶车辆不存在」——校验口径必须与写入方一致。
VehiclePlateSupport.validateBoundVehicles(driver.getDrivingVehicle());
validateLength(driver.getDrivingLicenseNo(), SHORT_TEXT_MAX_LENGTH, "驾驶证档案编号不能超过50字");
validateLength(driver.getQualificationNo(), SHORT_TEXT_MAX_LENGTH, "资格证号不能超过50字");
validateLength(driver.getOrganizationName(), SHORT_TEXT_MAX_LENGTH, "所属组织不能超过50字");
@@ -41,6 +41,7 @@ import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO;
import org.springblade.transport.pojo.vo.TransportVehicleVO;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -50,7 +51,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -62,7 +62,6 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicleMapper, TransportVehicle> implements ITransportVehicleService {
private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$");
private static final String PLATE_COLOR_BLUE = "蓝牌";
private static final String PLATE_COLOR_YELLOW = "黄牌";
private static final String PLATE_COLOR_GREEN = "绿牌";
@@ -262,7 +261,7 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
if (Func.isEmpty(vehicle.getPlateNo())) {
throw new ServiceException("车牌号不能为空");
}
if (!PLATE_NO_PATTERN.matcher(vehicle.getPlateNo()).matches()) {
if (!VehiclePlateSupport.isValidPlateNo(vehicle.getPlateNo())) {
throw new ServiceException("车牌号格式不正确");
}
if (Func.isEmpty(vehicle.getPlateColor())) {
@@ -26,6 +26,7 @@
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.TransportVehicle;
@@ -34,6 +35,7 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 司机档案与车辆表之间的车牌软关联支持类
@@ -112,6 +114,58 @@ public final class VehiclePlateSupport {
return splitPlates(drivingVehicle).contains(target);
}
/**
* 一个司机最多能绑定的车辆数
* <p>
* 与小程序端「一个司机最多添加 5 辆」的上限一致。后端此前完全没有这个约束,
* 而 {@code driving_vehicle} 只有 varchar(30)——5 个车牌连分隔符要 39 字符,
* 第 4 辆起就会写不进去或被静默截断(截断后剩下半个车牌,还会被当成一辆车)。
*/
public static final int MAX_BOUND_VEHICLES = 5;
/**
* 车牌格式:中文省份简称 + 字母 + 5~6 位(含挂/学/警/港/澳等特殊字)
* <p>
* 与车辆建档时的车牌校验共用同一条规则:能绑到司机名下的车牌,
* 必须先能作为一辆车建档,否则两处口径分叉,会出现「绑得上但建不了档」。
* 传入值必须是 {@link #normalize} 之后的形态(已去排版字符并转大写)。
*/
private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$");
/**
* 车牌格式是否合法
*
* @param plateNo 车牌(会先做规范化)
* @return 是否合法
*/
public static boolean isValidPlateNo(String plateNo) {
String normalized = normalize(plateNo);
return Func.isNotEmpty(normalized) && PLATE_NO_PATTERN.matcher(normalized).matches();
}
/**
* 校验「驾驶车辆」字段:车辆数上限与逐个车牌的格式
* <p>
* 规则只在这里维护一处——小程序新增车辆、管理端新增/编辑司机都调它。
* 此前管理端把这个字段当作**单个**车牌校验(拿整串去车牌表里精确查),
* 而小程序写入的是逗号并列的多辆车,两边口径本就不一致:
* 一个绑了两辆车的司机,管理员在司机管理页一保存就会撞上「驾驶车辆不存在」。
*
* @param drivingVehicle 司机档案里的驾驶车辆字段,可并列多辆
* @throws ServiceException 车辆数超过上限,或存在格式非法的车牌
*/
public static void validateBoundVehicles(String drivingVehicle) {
List<String> plates = splitPlates(drivingVehicle);
if (plates.size() > MAX_BOUND_VEHICLES) {
throw new ServiceException("一个司机最多绑定" + MAX_BOUND_VEHICLES + "辆车");
}
for (String plate : plates) {
if (!PLATE_NO_PATTERN.matcher(plate).matches()) {
throw new ServiceException("车牌号格式不正确:" + plate);
}
}
}
/**
* 按车牌匹配车辆的 SQL 条件:精确匹配 **或** 规范化后匹配
* <p>
@@ -48,12 +48,26 @@ public class TransportCertRecordWrapper extends BaseEntityWrapper<TransportCertR
@Override
public TransportCertRecordVO entityVO(TransportCertRecord transportCertRecord) {
TransportCertRecordVO transportCertRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(transportCertRecord, TransportCertRecordVO.class));
transportCertRecordVO.setCreateUserName(UserCache.getUserRealName(transportCertRecord.getCreateUser()));
transportCertRecordVO.setUpdateUserName(UserCache.getUserRealName(transportCertRecord.getUpdateUser()));
transportCertRecordVO.setSubmitUserName(UserCache.getUserRealName(transportCertRecord.getSubmitUser()));
transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecord.getAuditUser()));
return fillDisplayFields(transportCertRecordVO);
}
/**
* 填充面向前端的展示字段:四个姓名与状态文案
* <p>
* 审核列表走的是自定义 SQL,查出来已经是 VO——它多带司机姓名与手机号两个字段,
* 这两个字段实体上没有、无法由实体转换得到,所以那条路径不能走 {@link #entityVO}。
* 把填充逻辑单独暴露一次,避免 Service 里再复制一份翻译规则,日后两处走偏。
*
* @param transportCertRecordVO 认证记录视图对象
* @return 同一个对象,便于链式调用
*/
public TransportCertRecordVO fillDisplayFields(TransportCertRecordVO transportCertRecordVO) {
transportCertRecordVO.setCreateUserName(UserCache.getUserRealName(transportCertRecordVO.getCreateUser()));
transportCertRecordVO.setUpdateUserName(UserCache.getUserRealName(transportCertRecordVO.getUpdateUser()));
transportCertRecordVO.setSubmitUserName(UserCache.getUserRealName(transportCertRecordVO.getSubmitUser()));
transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecordVO.getAuditUser()));
// 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法
transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecord.getStatus()));
transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecordVO.getStatus()));
return transportCertRecordVO;
}
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.mapper.TransportCertRecordMapper;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
@@ -36,6 +37,7 @@ class CertAuditServiceImplTest {
private static final Long DRIVER_ID = 9001L;
private ITransportCertRecordService certRecordService;
private TransportCertRecordMapper certRecordMapper;
private ITransportVehicleService transportVehicleService;
private IDriverService driverService;
private CertAuditServiceImpl service;
@@ -43,9 +45,10 @@ class CertAuditServiceImplTest {
@BeforeEach
void setUp() {
certRecordService = mock(ITransportCertRecordService.class);
certRecordMapper = mock(TransportCertRecordMapper.class);
transportVehicleService = mock(ITransportVehicleService.class);
driverService = mock(IDriverService.class);
service = new CertAuditServiceImpl(certRecordService, transportVehicleService, driverService);
service = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService);
}
// ------------------------------------------------------------ 通过
@@ -6,6 +6,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.mapper.TransportCertRecordMapper;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.service.IDriverAppService;
import org.springblade.transport.service.IDriverService;
@@ -38,6 +39,7 @@ class PersonCertRoundTripTest {
private IDriverAppService driverAppService;
private IDriverService driverService;
private ITransportCertRecordService certRecordService;
private TransportCertRecordMapper certRecordMapper;
private DriverCertServiceImpl driverCertService;
private CertAuditServiceImpl certAuditService;
@@ -56,6 +58,7 @@ class PersonCertRoundTripTest {
driverAppService = mock(IDriverAppService.class);
driverService = mock(IDriverService.class);
certRecordService = mock(ITransportCertRecordService.class);
certRecordMapper = mock(TransportCertRecordMapper.class);
ITransportVehicleService transportVehicleService = mock(ITransportVehicleService.class);
driver = new Driver();
@@ -75,7 +78,7 @@ class PersonCertRoundTripTest {
driverCertService = new DriverCertServiceImpl(driverAppService, driverService,
transportVehicleService, certRecordService);
certAuditService = new CertAuditServiceImpl(certRecordService, transportVehicleService, driverService);
certAuditService = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService);
}
@Test
@@ -0,0 +1,114 @@
package org.springblade.transport.support;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 审核列表自定义 SQL 的语句级验证。
* <p>
* 这条 SQL 有两个容易写坏、写坏了又不会报错的地方:
* <ul>
* <li><b>车型侧要按提交司机定位司机档案</b>。车辆表没有司机列,
* 定位靠 {@code dr.user_id = r.submit_user}。少了这个条件,
* 车辆记录的司机列会全部取不到值,而页面只是「空着」——不像故障。</li>
* <li><b>司机侧不能跟着走 submit_user</b>。存量订正记录没有提交人,
* 若司机侧也用 submit_user 定位,23 条有司机的记录反而会全部变空。</li>
* </ul>
* 这里直接编译 Mapper XML 并检查生成出来的语句,把「靠约定」变成「靠语句」。
*/
class CertAuditPageSqlTest {
private static Configuration configuration;
@BeforeAll
static void loadMapperXml() {
configuration = new Configuration();
String resource = "org/springblade/transport/mapper/TransportCertRecordMapper.xml";
try (InputStream input = CertAuditPageSqlTest.class.getClassLoader().getResourceAsStream(resource)) {
assertNotNull(input, "找不到 Mapper XML:" + resource + "(它必须与 Mapper 接口同包放在 src/main/java 下)");
new XMLMapperBuilder(input, configuration, resource, configuration.getSqlFragments()).parse();
} catch (Exception exception) {
throw new IllegalStateException("Mapper XML 解析失败:" + exception.getMessage(), exception);
}
}
@Test
@DisplayName("无搜索条件:仍按状态与对象类型过滤,并按提交时间倒序")
void baseConditions() {
String sql = sql(Map.of());
assertTrue(sql.contains("r.status = ?"), "必须按审核状态过滤");
assertTrue(sql.contains("r.biz_type = ?"), "必须按认证对象类型过滤");
assertTrue(sql.contains("r.is_deleted = 0"), "逻辑删除的记录不得出现在审核列表");
assertTrue(sql.contains("ORDER BY") && sql.contains("submit_time DESC"), "必须按提交时间倒序");
}
@Test
@DisplayName("司机档案定位:司机侧按对象主键,车辆侧按提交司机(user_id)")
void driverResolution() {
String sql = sql(Map.of());
assertTrue(sql.contains("WHEN r.biz_type = 'driver' THEN r.biz_id"),
"司机认证记录的司机就是认证对象本身");
assertTrue(sql.contains("dr.user_id = r.submit_user"),
"车辆认证记录没有司机列,只能靠提交司机定位——少了它车辆页的司机列会整列取不到值");
}
@Test
@DisplayName("司机档案收敛为一条:子查询必须带 ORDER BY ... LIMIT 1")
void driverResolutionCollapsesToOneRow() {
String sql = sql(Map.of());
assertTrue(sql.contains("ORDER BY dr.update_time DESC") && sql.contains("LIMIT 1"),
"user_id 上没有唯一索引,两条司机档案指向同一用户时会让一条记录在列表里出现多行、总数虚高");
}
@Test
@DisplayName("空搜索条件不产生任何 LIKE(不能把「没填」当成筛选条件)")
void blankConditionsAddNoFilter() {
String sql = sql(Map.of());
assertFalse(sql.contains("LIKE"), "没传搜索项时不应出现 LIKE:空串会被拼成 LIKE '%%'");
}
@Test
@DisplayName("姓名搜索:同时匹配档案当前姓名与提交时的姓名")
void nameSearchCoversBothCurrentAndHistorical() {
String sql = sql(Map.of("driverName", "黄"));
assertTrue(sql.contains("d.driver_name LIKE"), "必须能按档案当前姓名搜到");
assertTrue(sql.contains("r.biz_key LIKE"), "改名后按旧名也要能搜到,否则审核员得两头试");
}
@Test
@DisplayName("手机号搜索:只按司机档案的手机号匹配(快照里从来没有手机号)")
void mobileSearchUsesProfileOnly() {
String sql = sql(Map.of("driverMobile", "1208"));
assertTrue(sql.contains("d.mobile LIKE"), "手机号只有司机档案一个来源");
}
private String sql(Map<String, String> params) {
MappedStatement statement = configuration.getMappedStatement(
"org.springblade.transport.mapper.TransportCertRecordMapper.selectAuditPage");
Map<String, Object> parameters = new HashMap<>(params);
parameters.put("bizType", "driver");
parameters.put("status", 0);
BoundSql boundSql = statement.getBoundSql(parameters);
return boundSql.getSql();
}
}
@@ -1,6 +1,7 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.BeforeAll;
import org.springblade.core.log.exception.ServiceException;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
@@ -14,6 +15,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -129,6 +131,58 @@ class VehiclePlateSupportTest {
assertTrue(sql.contains("OR"), "两者应是 OR 关系");
}
@Test
@DisplayName("绑定数上限:5 辆放行,第 6 辆拒绝")
void boundVehicleCountLimit() {
String five = "桂A32523,桂B11111,桂C22222,桂D33333,桂E44444";
VehiclePlateSupport.validateBoundVehicles(five);
assertEquals(5, VehiclePlateSupport.splitPlates(five).size());
// 不抛异常就是通过;第 6 辆必须拦下,否则字段放不下会被静默截断
ServiceException exception = assertThrows(ServiceException.class,
() -> VehiclePlateSupport.validateBoundVehicles(five + ",桂F55555"));
assertTrue(exception.getMessage().contains("最多绑定5辆车"), exception.getMessage());
}
@Test
@DisplayName("绑定校验:空值与单辆车都放行")
void boundVehicleEmptyAndSingle() {
VehiclePlateSupport.validateBoundVehicles(null);
VehiclePlateSupport.validateBoundVehicles("");
VehiclePlateSupport.validateBoundVehicles("桂A32523");
}
@Test
@DisplayName("绑定校验:车牌格式不合法时拒绝,且报出是哪个车牌")
void boundVehicleRejectsBadPlate() {
ServiceException exception = assertThrows(ServiceException.class,
() -> VehiclePlateSupport.validateBoundVehicles("桂A32523,乱写的车牌"));
assertTrue(exception.getMessage().contains("车牌号格式不正确"), exception.getMessage());
}
@Test
@DisplayName("车牌格式:普通、新能源(8位)、挂车/学车/警车都合法")
void validPlateFormats() {
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A32523"));
assertTrue(VehiclePlateSupport.isValidPlateNo("沪AE31312"));
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234挂"));
assertTrue(VehiclePlateSupport.isValidPlateNo("京A1234学"));
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234警"));
// 写法差异不影响判定:小写与带间隔符的车牌先规范化再比对
assertTrue(VehiclePlateSupport.isValidPlateNo("桂a32523"));
assertTrue(VehiclePlateSupport.isValidPlateNo("桂A·32523"));
}
@Test
@DisplayName("车牌格式:空值、乱码、位数不对一律不合法")
void invalidPlateFormats() {
assertFalse(VehiclePlateSupport.isValidPlateNo(null));
assertFalse(VehiclePlateSupport.isValidPlateNo(""));
assertFalse(VehiclePlateSupport.isValidPlateNo("乱写的车牌"));
assertFalse(VehiclePlateSupport.isValidPlateNo("A32523"));
assertFalse(VehiclePlateSupport.isValidPlateNo("桂A325"));
}
@Test
@DisplayName("空车牌列表不产生任何条件(避免 IN () 语法错误)")
void emptyPlateListAddsNoCondition() {
+1 -1
View File
@@ -13,7 +13,7 @@ CREATE TABLE `blade_transport_driver` (
`education` varchar(20) DEFAULT NULL COMMENT '学历',
`address_region` varchar(100) DEFAULT NULL COMMENT '地址区划',
`address` varchar(200) DEFAULT NULL COMMENT '详细地址',
`driving_vehicle` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号',
`driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔',
`posts` varchar(100) DEFAULT NULL COMMENT '岗位,多个使用逗号分隔',
`id_card_front` varchar(1000) DEFAULT NULL COMMENT '身份证正面照',
`id_card_back` varchar(1000) DEFAULT NULL COMMENT '身份证反面照',
@@ -0,0 +1,13 @@
-- ----------------------------
-- 司机「驾驶车辆」字段扩长:varchar(30) -> varchar(100)
-- ----------------------------
-- 一个司机最多绑定 5 辆车(与小程序端「车辆认证一个司机最多添加 5 辆」一致),
-- 该字段是**逗号并列的车牌列表**,5 个普通车牌连分隔符要 39 字符,
-- 新能源号牌(8 位)要 44 字符 —— 30 字符连 4 辆车都放不下。
-- 超长写入的后果不是报错而是**静默截断**:字段里会留下半个车牌,
-- 而拆车牌的规则会把这段残渣当成一辆车,司机名下于是多出一辆不存在的车。
--
-- 排序规则保持不变(utf8mb4_general_ci):车辆表与认证记录表都用这个规则,
-- 三张表之间已有按车牌的关联查询,改规则会引发 Illegal mix of collations。
ALTER TABLE `blade_transport_driver`
MODIFY COLUMN `driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔';