🔀 合并认证记录功能分支到 master
认证记录不可变快照数据模型、司机端提交与记录查询、管理端审核、 小程序证件 OCR 适配、接单可用性门槛(带开关可回退),含迁移脚本与单元测试。
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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.pojo.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 证件 OCR 识别请求(小程序)
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "证件OCR识别请求")
|
||||
public class OcrRecognizeRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "已上传图片的地址,必须是百度可访问的HTTP或HTTPS地址")
|
||||
private String imageUrl;
|
||||
@Schema(description = "证件类型:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证")
|
||||
private String type;
|
||||
@Schema(description = "正副面:front或back,默认front")
|
||||
private String side;
|
||||
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* 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.pojo.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 司机端个人认证提交请求
|
||||
* <p>
|
||||
* 一次提交承载个人认证的**全部**内容(身份证 + 驾驶证 + 从业资格证),
|
||||
* 三证一起提交、一起审核,不拆开。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "司机端个人认证提交请求")
|
||||
public class PersonCertSubmitRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "真实姓名")
|
||||
private String realName;
|
||||
@Schema(description = "身份证号")
|
||||
private String idCardNo;
|
||||
@Schema(description = "身份证正面照")
|
||||
private String idCardFrontUrl;
|
||||
@Schema(description = "身份证反面照")
|
||||
private String idCardBackUrl;
|
||||
@Schema(description = "身份证有效期起")
|
||||
private LocalDate idCardValidStart;
|
||||
@Schema(description = "身份证有效期止")
|
||||
private LocalDate idCardValidEnd;
|
||||
@Schema(description = "身份证长期有效")
|
||||
private Integer idCardLongTerm;
|
||||
|
||||
@Schema(description = "驾驶证档案编号")
|
||||
private String driverFileNo;
|
||||
@Schema(description = "准驾车型")
|
||||
private String driverClass;
|
||||
@Schema(description = "驾驶证主页照片")
|
||||
private String driverLicenseFrontUrl;
|
||||
@Schema(description = "驾驶证副页照片")
|
||||
private String driverLicenseBackUrl;
|
||||
@Schema(description = "驾驶证有效期起")
|
||||
private LocalDate driverValidStart;
|
||||
@Schema(description = "驾驶证有效期止")
|
||||
private LocalDate driverValidEnd;
|
||||
@Schema(description = "驾驶证长期有效")
|
||||
private Integer driverLongTerm;
|
||||
|
||||
@Schema(description = "从业资格证类型")
|
||||
private String qualificationType;
|
||||
@Schema(description = "从业资格证号")
|
||||
private String qualificationNo;
|
||||
@Schema(description = "从业资格证封面页")
|
||||
private String qualificationFront;
|
||||
@Schema(description = "从业资格证内容页")
|
||||
private String qualificationBack;
|
||||
@Schema(description = "从业资格证有效期起")
|
||||
private LocalDate qualificationValidStart;
|
||||
@Schema(description = "从业资格证有效期止")
|
||||
private LocalDate qualificationValidEnd;
|
||||
@Schema(description = "从业资格证长期有效")
|
||||
private Integer qualificationLongTerm;
|
||||
|
||||
@Schema(description = "大头照")
|
||||
private String headPhoto;
|
||||
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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.pojo.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* 司机端车辆认证提交请求
|
||||
* <p>
|
||||
* 一次提交承载该车辆认证的**全部**内容(行驶证 + 道路运输证),两证一起提交、一起审核。
|
||||
* 提交只产生新的待审记录,不覆盖车辆当前已生效的认证内容。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "司机端车辆认证提交请求")
|
||||
public class VehicleCertSubmitRequest implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "车辆ID:新增时为空,重新认证时必填")
|
||||
private Long id;
|
||||
@Schema(description = "车牌号")
|
||||
private String plateNo;
|
||||
@Schema(description = "所有人:行驶证登记的单位名称,不是司机")
|
||||
private String ownerName;
|
||||
@Schema(description = "车牌颜色")
|
||||
private String plateColor;
|
||||
@Schema(description = "车辆类型")
|
||||
private String vehicleType;
|
||||
@Schema(description = "车辆识别代号(车架号),选填")
|
||||
private String vin;
|
||||
|
||||
@Schema(description = "外廓长度,毫米")
|
||||
private Integer outerLength;
|
||||
@Schema(description = "外廓宽度,毫米")
|
||||
private Integer outerWidth;
|
||||
@Schema(description = "外廓高度,毫米")
|
||||
private Integer outerHeight;
|
||||
@Schema(description = "核定载质量,KG")
|
||||
private Integer approvedLoadKg;
|
||||
@Schema(description = "准牵引总质量,KG")
|
||||
private Integer tractionMassKg;
|
||||
@Schema(description = "能源类型")
|
||||
private String energyType;
|
||||
@Schema(description = "业务关系:自有 / 管理挂靠 / 业务合作")
|
||||
private String businessRelation;
|
||||
@Schema(description = "强制报废日期")
|
||||
private LocalDate compulsoryScrapDate;
|
||||
@Schema(description = "强制报废长期有效:0否 1是")
|
||||
private Integer compulsoryScrapLongTerm;
|
||||
|
||||
@Schema(description = "行驶证档案编号")
|
||||
private String drivingLicenseNo;
|
||||
@Schema(description = "行驶证主页正面照片")
|
||||
private String drivingLicenseImage;
|
||||
@Schema(description = "行驶证主页反面照片")
|
||||
private String drivingLicenseMainBack;
|
||||
@Schema(description = "行驶证副页正面照片")
|
||||
private String drivingLicenseViceFront;
|
||||
@Schema(description = "行驶证副页反面照片")
|
||||
private String drivingLicenseViceBack;
|
||||
@Schema(description = "行驶证有效期起")
|
||||
private LocalDate drivingLicenseStartDate;
|
||||
@Schema(description = "行驶证有效期止")
|
||||
private LocalDate drivingLicenseEndDate;
|
||||
@Schema(description = "行驶证长期有效:0否 1是")
|
||||
private Integer drivingLicenseLongTerm;
|
||||
|
||||
@Schema(description = "道路运输证号")
|
||||
private String roadTransportCertNo;
|
||||
@Schema(description = "道路运输证照片")
|
||||
private String roadTransportCertImage;
|
||||
@Schema(description = "道路运输证有效期起")
|
||||
private LocalDate roadTransportCertStartDate;
|
||||
@Schema(description = "道路运输证有效期止")
|
||||
private LocalDate roadTransportCertEndDate;
|
||||
@Schema(description = "道路运输证长期有效:0否 1是")
|
||||
private Integer roadTransportCertLongTerm;
|
||||
|
||||
@Schema(description = "机动车登记编号")
|
||||
private String registrationNo;
|
||||
@Schema(description = "机动车登记本照片")
|
||||
private String registrationImage;
|
||||
|
||||
}
|
||||
+45
@@ -28,6 +28,9 @@ 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.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -111,6 +114,21 @@ public class Driver extends TenantEntity {
|
||||
*/
|
||||
@Schema(description = "身份证反面照")
|
||||
private String idCardBack;
|
||||
/**
|
||||
* 身份证有效期起
|
||||
*/
|
||||
@Schema(description = "身份证有效期起")
|
||||
private LocalDate idCardValidStart;
|
||||
/**
|
||||
* 身份证有效期止
|
||||
*/
|
||||
@Schema(description = "身份证有效期止")
|
||||
private LocalDate idCardValidEnd;
|
||||
/**
|
||||
* 身份证长期有效
|
||||
*/
|
||||
@Schema(description = "身份证长期有效")
|
||||
private Integer idCardLongTerm;
|
||||
/**
|
||||
* 大头照
|
||||
*/
|
||||
@@ -221,5 +239,32 @@ public class Driver extends TenantEntity {
|
||||
*/
|
||||
@Schema(description = "备注")
|
||||
private String remark;
|
||||
/**
|
||||
* 最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null 表示从未提交
|
||||
* <p>
|
||||
* 仅为列表展示而冗余,<b>不是</b>可用性判据;「是否已认证」一律读生效快照指针。
|
||||
*/
|
||||
@Schema(description = "最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null为从未提交")
|
||||
private Integer certificationStatus;
|
||||
/**
|
||||
* 最新一次驳回原因展示缓存
|
||||
*/
|
||||
@Schema(description = "最新一次驳回原因展示缓存")
|
||||
private String certificationRejectReason;
|
||||
/**
|
||||
* 生效快照指针:指向 blade_transport_cert_record.id
|
||||
* <p>
|
||||
* 「该司机是否已认证」的<b>唯一</b>判据。为 null 表示从未认证通过;
|
||||
* 一经设置<b>永不回退</b>——提交、驳回、作废都不得修改它,
|
||||
* 因此重新提交认证不会让一个已认证的司机失去接单资格。
|
||||
* <p>
|
||||
* ⚠️ {@code READ_ONLY}:本字段<b>只能由审核通过时</b>写入。
|
||||
* 司机档案的 {@code /submit} 直接以本实体接收请求体,若允许反序列化,
|
||||
* 调用方就能自行填一个记录 ID 把自己「认证」掉,绕过审核。
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
@Schema(description = "生效快照指针")
|
||||
private Long effectiveCertRecordId;
|
||||
|
||||
}
|
||||
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 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.pojo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springblade.core.tenant.mp.TenantEntity;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 认证记录实体类
|
||||
* <p>
|
||||
* 认证对象(司机 / 车辆)<b>每一次提交</b>所产生的、<b>不可变</b>的快照。
|
||||
* 记录一经创建,其 {@code snapshotJson} 与提交元信息永不修改;被同对象的后续提交取代时
|
||||
* 标记为「已作废」,而<b>不是</b>删除。判定某个对象「是否已认证」只看它持有的生效快照指针,
|
||||
* 与记录表里的任何一条记录的状态都无关。
|
||||
* <p>
|
||||
* ⚠️ <b>{@code status} 承载的是「审核状态」,不是 BladeX 的「启用 / 停用」。</b>
|
||||
* 该字段继承自 {@link TenantEntity},带来两条必须堵住的框架通道:
|
||||
* 一是 {@code BaseServiceImpl#resolveEntity} 在插入时会把 {@code null} 的 {@code status}
|
||||
* <b>静默填成 {@code 1}</b>(恰好是 {@link #STATUS_APPROVED});
|
||||
* 二是继承来的 {@code changeStatus(ids, 1)} 会直接写 {@code status},等于批量通过审核。
|
||||
* 二者都已在 {@code TransportCertRecordServiceImpl} 中封堵(前者校验抛错、后者覆写抛错)。
|
||||
* 审核必须逐条、带审核人与审核时间落库,不得借道 {@code changeStatus} 批量改状态。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("blade_transport_cert_record")
|
||||
@Schema(description = "认证记录")
|
||||
public class TransportCertRecord extends TenantEntity {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 认证对象类型:司机
|
||||
*/
|
||||
public static final String BIZ_TYPE_DRIVER = "driver";
|
||||
/**
|
||||
* 认证对象类型:车辆
|
||||
*/
|
||||
public static final String BIZ_TYPE_VEHICLE = "vehicle";
|
||||
|
||||
/**
|
||||
* 审核状态:审核中(即该对象的「待审快照」)
|
||||
*/
|
||||
public static final Integer STATUS_PENDING = 0;
|
||||
/**
|
||||
* 审核状态:已通过(该对象最新一条已通过者即其「生效快照」)
|
||||
*/
|
||||
public static final Integer STATUS_APPROVED = 1;
|
||||
/**
|
||||
* 审核状态:已驳回(审核的否定结论,需带驳回原因)
|
||||
*/
|
||||
public static final Integer STATUS_REJECTED = 2;
|
||||
/**
|
||||
* 审核状态:已作废(被同对象的再次提交取代,非驳回)
|
||||
*/
|
||||
public static final Integer STATUS_SUPERSEDED = 3;
|
||||
|
||||
/**
|
||||
* 驳回原因最大长度,与 {@code reject_reason} 列宽保持一致
|
||||
*/
|
||||
public static final int REJECT_REASON_MAX_LENGTH = 200;
|
||||
|
||||
/**
|
||||
* 认证对象类型:driver-司机,vehicle-车辆
|
||||
* <p>
|
||||
* {@code NEVER}:记录一经创建,认证对象的身份不可改。
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
@Schema(description = "认证对象类型:driver-司机,vehicle-车辆")
|
||||
private String bizType;
|
||||
/**
|
||||
* 认证对象主键:司机ID 或 车辆ID(车辆按具体车辆,不是司机)
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
@Schema(description = "认证对象主键")
|
||||
private Long bizId;
|
||||
/**
|
||||
* 展示键:司机姓名 / 车牌号,供审核列表直接展示
|
||||
*/
|
||||
@Schema(description = "展示键:司机姓名 / 车牌号")
|
||||
private String bizKey;
|
||||
/**
|
||||
* 该次提交的全量认证内容(JSON),一经创建不可修改
|
||||
* <p>
|
||||
* {@code NEVER}:本字段<b>永不进入 UPDATE 语句</b>,因此不存在「某条链路
|
||||
* 忘了保护快照」的可能。改内容只能靠<b>新提交一条记录</b>。
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
@Schema(description = "该次提交的全量认证内容JSON")
|
||||
private String snapshotJson;
|
||||
/**
|
||||
* 驳回原因,仅当审核状态为已驳回时有值
|
||||
*/
|
||||
@Schema(description = "驳回原因")
|
||||
private String rejectReason;
|
||||
/**
|
||||
* 提交时间
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
@Schema(description = "提交时间")
|
||||
private LocalDateTime submitTime;
|
||||
/**
|
||||
* 提交人
|
||||
*/
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
@Schema(description = "提交人")
|
||||
private Long submitUser;
|
||||
/**
|
||||
* 审核时间
|
||||
*/
|
||||
@Schema(description = "审核时间")
|
||||
private LocalDateTime auditTime;
|
||||
/**
|
||||
* 审核人
|
||||
*/
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
@Schema(description = "审核人")
|
||||
private Long auditUser;
|
||||
|
||||
}
|
||||
+29
@@ -26,6 +26,9 @@
|
||||
package org.springblade.transport.pojo.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import com.fasterxml.jackson.databind.annotation.JsonSerialize;
|
||||
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
@@ -63,6 +66,11 @@ public class TransportVehicle extends TenantEntity {
|
||||
*/
|
||||
@Schema(description = "车牌号")
|
||||
private String plateNo;
|
||||
/**
|
||||
* 所有人:行驶证登记的单位名称,不是司机
|
||||
*/
|
||||
@Schema(description = "所有人(行驶证登记的单位名称,不是司机)")
|
||||
private String ownerName;
|
||||
/**
|
||||
* 车牌颜色
|
||||
*/
|
||||
@@ -73,6 +81,11 @@ public class TransportVehicle extends TenantEntity {
|
||||
*/
|
||||
@Schema(description = "车辆类型")
|
||||
private String vehicleType;
|
||||
/**
|
||||
* 车辆识别代号(车架号)
|
||||
*/
|
||||
@Schema(description = "车辆识别代号(车架号)")
|
||||
private String vin;
|
||||
/**
|
||||
* 外廓长度,毫米
|
||||
*/
|
||||
@@ -224,5 +237,21 @@ public class TransportVehicle extends TenantEntity {
|
||||
/** 认证驳回原因 */
|
||||
@Schema(description = "认证驳回原因")
|
||||
private String certificationRejectReason;
|
||||
/**
|
||||
* 生效快照指针:指向 blade_transport_cert_record.id
|
||||
* <p>
|
||||
* 「该车辆是否已认证」的<b>唯一</b>判据。为 null 表示从未认证通过;
|
||||
* 一经设置<b>永不回退</b>——重新提交、待审、被驳回都不得修改它,
|
||||
* 因此重新提交认证不会让一辆已认证的车掉出可用池。
|
||||
* 改造后 {@link #certificationStatus} 降级为「最新提交状态」的展示缓存。
|
||||
* <p>
|
||||
* ⚠️ {@code READ_ONLY}:本字段<b>只能由审核通过时</b>写入。
|
||||
* {@code POST /transport-vehicle/submit} 直接以本实体接收请求体,
|
||||
* 若允许反序列化,调用方就能自行填一个记录 ID 把自己「认证」掉,绕过审核。
|
||||
*/
|
||||
@JsonProperty(access = JsonProperty.Access.READ_ONLY)
|
||||
@JsonSerialize(using = ToStringSerializer.class)
|
||||
@Schema(description = "生效快照指针")
|
||||
private Long effectiveCertRecordId;
|
||||
|
||||
}
|
||||
|
||||
+10
@@ -56,4 +56,14 @@ public class BaiduOcrResultVO implements Serializable {
|
||||
@Schema(description = "百度OCR原始返回结果")
|
||||
private Map<String, Object> result;
|
||||
|
||||
/**
|
||||
* 归一化后的业务字段
|
||||
* <p>
|
||||
* 键与提交请求里的字段同名({@code plateNo}、{@code ownerName}…),调用方直接回填即可,
|
||||
* 不需要认识百度那套中文键名。只包含**识别到**的字段,没识别到的不出现(而不是给空串),
|
||||
* 调用方据此就能区分「没识别到」与「识别到空值」,不会把用户已填的内容覆盖成空。
|
||||
*/
|
||||
@Schema(description = "归一化后的业务字段,键与提交请求同名")
|
||||
private Map<String, Object> fields;
|
||||
|
||||
}
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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.pojo.vo;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 认证审核详情视图
|
||||
* <p>
|
||||
* 审核是「拿新提交的内容和当前生效的内容比」的过程,所以这里**同时**给出两侧:
|
||||
* {@code pendingSnapshotJson} 是本次待审的全量内容,
|
||||
* {@code effectiveSnapshotJson} 是该对象当前生效的全量内容。
|
||||
* 前端对这两个 JSON 做字段级 diff,即可看出司机这次改了什么。
|
||||
* <p>
|
||||
* 两侧的 JSON 结构完全一致(同一套提交结构),因此可以逐键直接比较。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "认证审核详情")
|
||||
public class CertAuditDetailVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "认证记录主键")
|
||||
private Long id;
|
||||
@Schema(description = "认证对象类型:driver-司机,vehicle-车辆")
|
||||
private String bizType;
|
||||
@Schema(description = "认证对象主键")
|
||||
private Long bizId;
|
||||
@Schema(description = "展示键:司机姓名 / 车牌号")
|
||||
private String bizKey;
|
||||
@Schema(description = "审核状态:0审核中 1已通过 2已驳回 3已作废")
|
||||
private Integer status;
|
||||
@Schema(description = "状态中文文案")
|
||||
private String statusText;
|
||||
@Schema(description = "本次提交时间")
|
||||
private LocalDateTime submitTime;
|
||||
@Schema(description = "本次提交人")
|
||||
private Long submitUser;
|
||||
@Schema(description = "本次提交人姓名")
|
||||
private String submitUserName;
|
||||
|
||||
@Schema(description = "待审快照:本次提交的全量内容")
|
||||
private String pendingSnapshotJson;
|
||||
@Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null")
|
||||
private String effectiveSnapshotJson;
|
||||
@Schema(description = "生效快照记录ID;null 表示该对象从未认证通过")
|
||||
private Long effectiveRecordId;
|
||||
|
||||
/**
|
||||
* 该对象是否已认证
|
||||
* <p>
|
||||
* 审核页据此提示「本次驳回不会影响该对象继续使用」——
|
||||
* 已认证的对象即便本次被驳回,既得认证依然有效。
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "该对象当前是否已认证")
|
||||
private Boolean certified;
|
||||
|
||||
}
|
||||
+76
-16
@@ -44,29 +44,89 @@ public class DriverVehicleCardVO implements Serializable {
|
||||
@Schema(description = "车牌号")
|
||||
private String plateNo;
|
||||
|
||||
@Schema(description = "所有人(行驶证登记的单位名称,不是司机)")
|
||||
private String ownerName;
|
||||
|
||||
@Schema(description = "车牌颜色")
|
||||
private String plateColor;
|
||||
|
||||
@Schema(description = "车辆类型")
|
||||
private String vehicleType;
|
||||
|
||||
@Schema(description = "行驶证主页照片")
|
||||
private String licenseFrontUrl;
|
||||
|
||||
@Schema(description = "行驶证副页照片")
|
||||
private String licenseBackUrl;
|
||||
|
||||
@Schema(description = "道路运输证号")
|
||||
private String roadTransportNo;
|
||||
|
||||
@Schema(description = "道路运输证照片")
|
||||
private String roadTransportUrl;
|
||||
|
||||
@Schema(description = "车架号")
|
||||
@Schema(description = "车辆识别代号(车架号),选填")
|
||||
private String vin;
|
||||
|
||||
@Schema(description = "发动机号")
|
||||
private String engineNo;
|
||||
@Schema(description = "外廓长度,毫米")
|
||||
private Integer outerLength;
|
||||
|
||||
@Schema(description = "外廓宽度,毫米")
|
||||
private Integer outerWidth;
|
||||
|
||||
@Schema(description = "外廓高度,毫米")
|
||||
private Integer outerHeight;
|
||||
|
||||
@Schema(description = "核定载质量,KG")
|
||||
private Integer approvedLoadKg;
|
||||
|
||||
@Schema(description = "准牵引总质量,KG")
|
||||
private Integer tractionMassKg;
|
||||
|
||||
@Schema(description = "能源类型")
|
||||
private String energyType;
|
||||
|
||||
@Schema(description = "业务关系:自有 / 管理挂靠 / 业务合作")
|
||||
private String businessRelation;
|
||||
|
||||
@Schema(description = "强制报废日期")
|
||||
private String compulsoryScrapDate;
|
||||
|
||||
@Schema(description = "强制报废长期有效:0否 1是")
|
||||
private Integer compulsoryScrapLongTerm;
|
||||
|
||||
@Schema(description = "行驶证档案编号")
|
||||
private String drivingLicenseNo;
|
||||
|
||||
@Schema(description = "行驶证主页正面照片")
|
||||
private String drivingLicenseImage;
|
||||
|
||||
@Schema(description = "行驶证主页反面照片")
|
||||
private String drivingLicenseMainBack;
|
||||
|
||||
@Schema(description = "行驶证副页正面照片")
|
||||
private String drivingLicenseViceFront;
|
||||
|
||||
@Schema(description = "行驶证副页反面照片")
|
||||
private String drivingLicenseViceBack;
|
||||
|
||||
@Schema(description = "行驶证有效期起")
|
||||
private String drivingLicenseStartDate;
|
||||
|
||||
@Schema(description = "行驶证有效期止")
|
||||
private String licenseValidEnd;
|
||||
private String drivingLicenseEndDate;
|
||||
|
||||
@Schema(description = "行驶证长期有效:0否 1是")
|
||||
private Integer drivingLicenseLongTerm;
|
||||
|
||||
@Schema(description = "道路运输证号")
|
||||
private String roadTransportCertNo;
|
||||
|
||||
@Schema(description = "道路运输证照片")
|
||||
private String roadTransportCertImage;
|
||||
|
||||
@Schema(description = "道路运输证有效期起")
|
||||
private String roadTransportCertStartDate;
|
||||
|
||||
@Schema(description = "道路运输证有效期止")
|
||||
private String roadTransportCertEndDate;
|
||||
|
||||
@Schema(description = "道路运输证长期有效:0否 1是")
|
||||
private Integer roadTransportCertLongTerm;
|
||||
|
||||
@Schema(description = "机动车登记编号")
|
||||
private String registrationNo;
|
||||
|
||||
@Schema(description = "机动车登记本照片")
|
||||
private String registrationImage;
|
||||
|
||||
@Schema(description = "认证状态:0认证中 1认证通过 2认证驳回")
|
||||
private Integer certificationStatus;
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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.pojo.vo;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
|
||||
import java.io.Serial;
|
||||
|
||||
/**
|
||||
* 认证记录视图实体类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@Schema(description = "认证记录")
|
||||
public class TransportCertRecordVO extends TransportCertRecord {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
/**
|
||||
* 创建人姓名
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "创建人姓名")
|
||||
private String createUserName;
|
||||
/**
|
||||
* 更新人姓名
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "更新人姓名")
|
||||
private String updateUserName;
|
||||
/**
|
||||
* 提交人姓名
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "提交人姓名")
|
||||
private String submitUserName;
|
||||
/**
|
||||
* 审核人姓名
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "审核人姓名")
|
||||
private String auditUserName;
|
||||
/**
|
||||
* 状态中文文案:审核中 / 已通过 / 已驳回 / 已作废
|
||||
* <p>
|
||||
* 由服务层统一翻译后下发,避免小程序、Web、接口三处各造一套说法。
|
||||
*/
|
||||
@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;
|
||||
|
||||
}
|
||||
@@ -55,6 +55,15 @@
|
||||
<groupId>io.minio</groupId>
|
||||
<artifactId>minio</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试:本模块此前的 src/test 下只有 main() 手跑脚本,没有可自动执行的测试。
|
||||
认证改造的提交/审核链路带状态机语义(同对象至多一条待审、提交不得改生效快照
|
||||
等),必须用可重复执行的单元测试守住,故引入 JUnit 5 与 Mockito。 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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.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 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;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
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.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 认证审核(管理端)
|
||||
* <p>
|
||||
* 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
|
||||
* 这与旧的车辆认证审核接口有本质区别:旧接口作用于车辆行并原地覆盖,
|
||||
* 在「生效值 / 待审值分离」之后语义已不成立,故已弃用。
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping({"/certification-audit", "/blade-transport/certification-audit"})
|
||||
@Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核")
|
||||
public class CertificationAuditController extends BladeController {
|
||||
|
||||
private final ICertAuditService certAuditService;
|
||||
|
||||
@GetMapping("/pending-page")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "认证记录列表", description = "按认证对象类型与审核状态分页查询,按提交时间倒序;状态不传时查审核中的记录。列表同时下发该记录司机的姓名与手机号(司机档案当前值)")
|
||||
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
|
||||
public R<IPage<TransportCertRecordVO>> pendingPage(
|
||||
@Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
|
||||
@RequestParam String bizType,
|
||||
@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, driverName, driverMobile, status, Condition.getPage(query)));
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比")
|
||||
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
|
||||
public R<CertAuditDetailVO> detail(
|
||||
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id) {
|
||||
return R.data(certAuditService.detail(id));
|
||||
}
|
||||
|
||||
@PostMapping("/audit")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
|
||||
@PreAuth(CertAuditPermissions.AUDIT_ACTION)
|
||||
public R<Boolean> audit(
|
||||
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id,
|
||||
@Parameter(description = "是否通过", required = true) @RequestParam Boolean pass,
|
||||
@Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) {
|
||||
return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason));
|
||||
}
|
||||
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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.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.ocr.constant.BaiduOcrType;
|
||||
import org.springblade.transport.ocr.service.IBaiduOcrService;
|
||||
import org.springblade.transport.pojo.dto.OcrRecognizeRequest;
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.IDriverCertService;
|
||||
import org.springblade.transport.support.OcrTypeMapping;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.PutMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
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.util.List;
|
||||
|
||||
/**
|
||||
* 司机端认证(小程序)
|
||||
* <p>
|
||||
* 对外路径:{@code /api/blade-transport/driver/auth/**}
|
||||
* 同时兼容未去前缀直连 {@code /blade-transport/driver/auth/**}。
|
||||
* <p>
|
||||
* 提交审核**不影响**该对象当前的可用性:待审内容只落认证记录,
|
||||
* 审核通过前对外呈现的仍是旧的生效值。
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@RequestMapping({"/driver/auth", "/blade-transport/driver/auth"})
|
||||
@Tag(name = "司机端认证", description = "小程序司机个人认证与车辆认证")
|
||||
public class DriverAuthController extends BladeController {
|
||||
|
||||
private final IDriverCertService driverCertService;
|
||||
private final IBaiduOcrService baiduOcrService;
|
||||
|
||||
// ------------------------------------------------------------ 个人认证
|
||||
|
||||
@PostMapping("/person")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "提交个人认证", description = "三证(身份证+驾驶证+从业资格证)一起提交,生成待审记录")
|
||||
public R<Long> submitPerson(@RequestBody PersonCertSubmitRequest request) {
|
||||
return R.data(driverCertService.submitPersonCert(request));
|
||||
}
|
||||
|
||||
@GetMapping("/person")
|
||||
@ApiOperationSupport(order = 2)
|
||||
@Operation(summary = "个人认证生效值", description = "返回当前生效快照的内容;从未认证通过时 data 为 null")
|
||||
public R<PersonCertSubmitRequest> person() {
|
||||
return R.data(driverCertService.getEffectivePersonCert());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 车辆认证
|
||||
|
||||
@PostMapping("/vehicle")
|
||||
@ApiOperationSupport(order = 3)
|
||||
@Operation(summary = "提交车辆认证", description = "生成待审记录;不改变该车已生效的认证内容")
|
||||
public R<Long> submitVehicle(@RequestBody VehicleCertSubmitRequest request) {
|
||||
return R.data(driverCertService.submitVehicleCert(request));
|
||||
}
|
||||
|
||||
@PutMapping("/vehicle")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "重新认证车辆", description = "与提交同一语义,语义上用于已有车辆重新认证")
|
||||
public R<Long> updateVehicle(@RequestBody VehicleCertSubmitRequest request) {
|
||||
return R.data(driverCertService.submitVehicleCert(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/vehicle/{id}")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "删除车辆认证", description = "对车辆做逻辑删除;认证记录保留,不物理删除")
|
||||
public R<Boolean> deleteVehicle(@PathVariable Long id) {
|
||||
return R.status(driverCertService.deleteVehicleCert(id));
|
||||
}
|
||||
|
||||
@GetMapping("/vehicle/effective")
|
||||
@ApiOperationSupport(order = 5)
|
||||
@Operation(summary = "车辆认证生效值", description = "返回该车当前生效快照的内容")
|
||||
public R<VehicleCertSubmitRequest> vehicleEffective(@RequestParam Long vehicleId) {
|
||||
return R.data(driverCertService.getEffectiveVehicleCert(vehicleId));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 认证记录
|
||||
|
||||
@GetMapping("/record/list")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "认证记录列表", description = "按提交时间倒序;只能查本人或本人绑定车辆的记录")
|
||||
public R<List<TransportCertRecordVO>> recordList(@RequestParam String bizType,
|
||||
@RequestParam Long bizId) {
|
||||
return R.data(driverCertService.listRecords(bizType, bizId));
|
||||
}
|
||||
|
||||
@GetMapping("/record/detail")
|
||||
@ApiOperationSupport(order = 7)
|
||||
@Operation(summary = "认证记录详情", description = "内容取自该次提交的完整快照")
|
||||
public R<TransportCertRecordVO> recordDetail(@RequestParam Long id) {
|
||||
return R.data(driverCertService.getRecord(id));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ OCR
|
||||
|
||||
@PostMapping("/ocr")
|
||||
@ApiOperationSupport(order = 8)
|
||||
@Operation(summary = "证件OCR识别",
|
||||
description = "按小程序既有契约收 {imageUrl, type, side?},转调百度OCR。"
|
||||
+ "type 用小程序命名:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证")
|
||||
public R<BaiduOcrResultVO> ocr(@RequestBody OcrRecognizeRequest request) {
|
||||
// 两侧对「驾驶证 / 行驶证」的用词正好相反,故必须经显式映射,不能把入参直接透传下游
|
||||
BaiduOcrType ocrType = OcrTypeMapping.toBaiduType(request.getType());
|
||||
// 未指定时传 null,不要在这里补 front:道路运输证没有正副面参数,
|
||||
// 补了会变成「传了 side 但该证件不支持」,让这条链路恒失败。
|
||||
// 「默认取正面」由知晓证件类型的一侧(OCR 服务)决定。
|
||||
String side = OcrTypeMapping.normalizeSide(request.getSide());
|
||||
return R.data(baiduOcrService.recognizeUrl(ocrType, side, request.getImageUrl()));
|
||||
}
|
||||
|
||||
}
|
||||
+19
-1
@@ -35,6 +35,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.boot.ctrl.BladeController;
|
||||
import org.springblade.core.excel.util.ExcelUtil;
|
||||
import org.springblade.core.mp.support.Condition;
|
||||
@@ -67,6 +68,7 @@ import java.util.List;
|
||||
*/
|
||||
@RestController
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
@PreAuth(menu = "transport_vehicle")
|
||||
@RequestMapping("/transport-vehicle")
|
||||
@Tag(name = "车辆管理", description = "车辆管理")
|
||||
@@ -132,11 +134,27 @@ public class TransportVehicleController extends BladeController {
|
||||
return R.status(transportVehicleService.changeStatus(id, status));
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核车辆认证(已弃用)
|
||||
*
|
||||
* @deprecated 本接口作用于**车辆行**并原地覆盖认证状态,在「生效值 / 待审值分离」
|
||||
* 之后语义已不成立:一次审核应对应**一条认证记录**,且通过才建立生效快照、
|
||||
* 驳回不得影响既得认证。请改用
|
||||
* {@code POST /blade-transport/certification-audit/audit}。
|
||||
* <p>
|
||||
* 保留而非删除,是因为它可能仍被小程序历史版本或其它系统调用;
|
||||
* 调用时记录告警日志。它只写展示缓存,**不再影响可用性判定**
|
||||
* (可用性只看 {@code effective_cert_record_id})。
|
||||
*/
|
||||
@Deprecated
|
||||
@PostMapping("/audit-certification")
|
||||
@ApiOperationSupport(order = 6)
|
||||
@Operation(summary = "审核车辆认证")
|
||||
@Operation(summary = "审核车辆认证(已弃用)",
|
||||
description = "已弃用:作用于车辆行且原地覆盖。请改用 /blade-transport/certification-audit/audit")
|
||||
public R auditCertification(@RequestParam Long id, @RequestParam Integer certificationStatus,
|
||||
@RequestParam(required = false) String rejectReason) {
|
||||
log.warn("[认证审核] 调用了已弃用接口 audit-certification,vehicleId={}, status={};"
|
||||
+ "请改用 /blade-transport/certification-audit/audit", id, certificationStatus);
|
||||
return R.status(transportVehicleService.auditCertification(id, certificationStatus, rejectReason));
|
||||
}
|
||||
|
||||
|
||||
+13
-1
@@ -46,6 +46,12 @@
|
||||
<result column="emergency_contact_name" property="emergencyContactName"/>
|
||||
<result column="emergency_contact_mobile" property="emergencyContactMobile"/>
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="id_card_valid_start" property="idCardValidStart"/>
|
||||
<result column="id_card_valid_end" property="idCardValidEnd"/>
|
||||
<result column="id_card_long_term" property="idCardLongTerm"/>
|
||||
<result column="certification_status" property="certificationStatus"/>
|
||||
<result column="certification_reject_reason" property="certificationRejectReason"/>
|
||||
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="BaseColumn">
|
||||
@@ -70,6 +76,9 @@
|
||||
posts,
|
||||
id_card_front,
|
||||
id_card_back,
|
||||
id_card_valid_start,
|
||||
id_card_valid_end,
|
||||
id_card_long_term,
|
||||
head_photo,
|
||||
driving_type,
|
||||
driving_license_no,
|
||||
@@ -91,7 +100,10 @@
|
||||
organization_name,
|
||||
emergency_contact_name,
|
||||
emergency_contact_mobile,
|
||||
remark
|
||||
remark,
|
||||
certification_status,
|
||||
certification_reject_reason,
|
||||
effective_cert_record_id
|
||||
</sql>
|
||||
|
||||
<select id="selectByIdCardNoIncludingDeleted" resultType="org.springblade.transport.pojo.entity.Driver">
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* 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.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 接口
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
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);
|
||||
|
||||
}
|
||||
+100
@@ -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>
|
||||
+2
@@ -48,6 +48,7 @@
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="certification_status" property="certificationStatus"/>
|
||||
<result column="certification_reject_reason" property="certificationRejectReason"/>
|
||||
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
|
||||
</resultMap>
|
||||
|
||||
<sql id="BaseColumn">
|
||||
@@ -96,6 +97,7 @@
|
||||
remark
|
||||
, certification_status
|
||||
, certification_reject_reason
|
||||
, effective_cert_record_id
|
||||
</sql>
|
||||
|
||||
<sql id="ExpiryWithin30Condition">
|
||||
|
||||
+4
@@ -36,6 +36,7 @@ import org.springblade.transport.ocr.config.BaiduOcrProperties;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
import org.springblade.transport.ocr.service.IBaiduOcrService;
|
||||
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
|
||||
import org.springblade.transport.support.OcrFieldSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.imageio.ImageIO;
|
||||
@@ -143,6 +144,9 @@ public class BaiduOcrServiceImpl implements IBaiduOcrService {
|
||||
response.setType(type.name());
|
||||
response.setSide(normalizedSide);
|
||||
response.setResult(objectMapper.convertValue(result, RESULT_TYPE));
|
||||
// 同时下发归一化字段:调用方不该去认识百度那套中文键名,
|
||||
// 且键名对不上时前端只会「静默什么都不填」,是最难查的一类问题
|
||||
response.setFields(OcrFieldSupport.extract(type, normalizedSide, result));
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
|
||||
/**
|
||||
* 认证审核服务(管理端)
|
||||
* <p>
|
||||
* 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
|
||||
* 通过使该记录成为生效快照;驳回只终结该记录,生效快照保持不变。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface ICertAuditService {
|
||||
|
||||
/**
|
||||
* 认证记录分页(按认证对象类型与审核状态)
|
||||
* <p>
|
||||
* 审核页按「待审核 / 已通过 / 已驳回」分页签展示,故状态可传;
|
||||
* 不传时按待审核处理,兼容只想要待审队列的调用方。
|
||||
* <p>
|
||||
* 列表同时下发该记录的司机姓名与手机号,取的是司机档案的**当前**值
|
||||
* (认证记录里的姓名是提交时的历史值,手机号则从来不在快照里)。
|
||||
* 解析不到司机档案的记录,两个字段为空,不回落成提交时的值——
|
||||
* 那会让「同一个人的多条记录显示成不同的人」。
|
||||
*
|
||||
* @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, String driverName, String driverMobile,
|
||||
Integer status, IPage<TransportCertRecordVO> page);
|
||||
|
||||
/**
|
||||
* 审核详情:同时返回待审快照与当前生效值,供前端做 diff
|
||||
*
|
||||
* @param recordId 认证记录主键
|
||||
* @return 审核详情
|
||||
*/
|
||||
CertAuditDetailVO detail(Long recordId);
|
||||
|
||||
/**
|
||||
* 审核一条认证记录
|
||||
*
|
||||
* @param recordId 认证记录主键
|
||||
* @param pass true 通过,false 驳回
|
||||
* @param rejectReason 驳回原因;驳回时必填,≤200 字
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean audit(Long recordId, boolean pass, String rejectReason);
|
||||
|
||||
}
|
||||
+12
@@ -22,6 +22,7 @@
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverVO;
|
||||
|
||||
@@ -44,4 +45,15 @@ public interface IDriverAppService {
|
||||
*/
|
||||
List<DriverVehicleCardVO> myVehicles();
|
||||
|
||||
/**
|
||||
* 当前登录用户对应的司机**实体**
|
||||
* <p>
|
||||
* 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle},
|
||||
* 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配,
|
||||
* 匹配不到再按 {@code user_id} 兜底。
|
||||
*
|
||||
* @return 司机实体;未登录或查不到时返回 null
|
||||
*/
|
||||
Driver currentDriverEntity();
|
||||
|
||||
}
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 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.service;
|
||||
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 司机端认证服务(小程序)
|
||||
* <p>
|
||||
* 认证对象是「一事一议」的:司机的个人认证与每一辆车的车辆认证各自独立成立、
|
||||
* 独立审核、独立留痕。本服务只负责司机端能做什么,管理端审核在审核服务里。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface IDriverCertService {
|
||||
|
||||
/**
|
||||
* 提交个人认证(全量:身份证 + 驾驶证 + 从业资格证,三证一起审)
|
||||
* <p>
|
||||
* 生成一条待审记录,并把本人前一条待审记录置为已作废。
|
||||
* **不修改生效快照**:已认证的司机在本次审核通过前仍按旧生效值认定。
|
||||
*
|
||||
* @param request 个人认证内容
|
||||
* @return 新生成的认证记录主键
|
||||
*/
|
||||
Long submitPersonCert(PersonCertSubmitRequest request);
|
||||
|
||||
/**
|
||||
* 读取本人当前生效的个人认证内容(供个人认证卡片展示)
|
||||
*
|
||||
* @return 生效快照内容;从未认证通过时返回 null
|
||||
*/
|
||||
PersonCertSubmitRequest getEffectivePersonCert();
|
||||
|
||||
/**
|
||||
* 提交车辆认证(新增或重新认证)
|
||||
* <p>
|
||||
* 生成一条待审记录,并把该车前一条待审记录置为已作废。
|
||||
* **不修改生效快照**:已认证的车辆在本次审核通过前仍按旧生效值认定,
|
||||
* 因此提交审核不会影响车辆正常接单。
|
||||
*
|
||||
* @param request 车辆认证内容
|
||||
* @return 新生成的认证记录主键
|
||||
*/
|
||||
Long submitVehicleCert(VehicleCertSubmitRequest request);
|
||||
|
||||
/**
|
||||
* 删除车辆认证(按逻辑删除处理)
|
||||
* <p>
|
||||
* 只把车辆标记为已删除,**认证记录一律保留**——记录是不可变快照,
|
||||
* 也是「曾通过即永久可用」这一判定的依据,物理删除会让历史无从追溯。
|
||||
* 只能删除本人绑定的车辆,越权拒绝。
|
||||
*
|
||||
* @param vehicleId 车辆主键
|
||||
* @return 是否成功
|
||||
*/
|
||||
boolean deleteVehicleCert(Long vehicleId);
|
||||
|
||||
/**
|
||||
* 读取某辆车当前生效的认证内容
|
||||
*
|
||||
* @param vehicleId 车辆主键
|
||||
* @return 生效快照内容;从未认证通过时返回 null
|
||||
*/
|
||||
VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId);
|
||||
|
||||
/**
|
||||
* 查询某认证对象的认证记录列表(按提交时间倒序)
|
||||
*
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param bizId 认证对象主键
|
||||
* @return 认证记录列表
|
||||
*/
|
||||
List<TransportCertRecordVO> listRecords(String bizType, Long bizId);
|
||||
|
||||
/**
|
||||
* 查询单条认证记录详情
|
||||
*
|
||||
* @param recordId 认证记录主键
|
||||
* @return 认证记录;不存在或无权查看时抛业务异常
|
||||
*/
|
||||
TransportCertRecordVO getRecord(Long recordId);
|
||||
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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.service;
|
||||
|
||||
import org.springblade.core.mp.base.BaseService;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证记录 服务类
|
||||
* <p>
|
||||
* 只承载认证记录本身的读写与数据模型约束的守护;提交、审核、可用性门槛等业务链路
|
||||
* 由各自的业务服务实现。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public interface ITransportCertRecordService extends BaseService<TransportCertRecord> {
|
||||
|
||||
/**
|
||||
* 某认证对象的认证记录列表,按提交时间倒序
|
||||
*
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param bizId 认证对象主键
|
||||
* @return 认证记录列表
|
||||
*/
|
||||
List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId);
|
||||
|
||||
/**
|
||||
* 取某认证对象当前的待审快照(至多一条)
|
||||
*
|
||||
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
|
||||
* @param bizId 认证对象主键
|
||||
* @return 待审快照,无则返回 null
|
||||
*/
|
||||
TransportCertRecord selectPendingRecord(String bizType, Long bizId);
|
||||
|
||||
}
|
||||
+420
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 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.service.impl;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
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.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.ICertAuditService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationRuleSupport;
|
||||
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 认证审核服务实现
|
||||
* <p>
|
||||
* 审核作用于**一条记录**。两条路径的差别是本次改造的关键:
|
||||
* <ul>
|
||||
* <li><b>通过</b>:记录转已通过,并把该对象的生效快照指针切到它;</li>
|
||||
* <li><b>驳回</b>:记录转已驳回并留原因,生效快照指针<b>一个字都不动</b>。</li>
|
||||
* </ul>
|
||||
* 因此「已认证的车被驳回」不会让它掉出可用池,只是这次提交没被采纳。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
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, String driverName, String driverMobile,
|
||||
Integer status, IPage<TransportCertRecordVO> page) {
|
||||
if (!CertificationRuleSupport.isValidBizType(bizType)) {
|
||||
throw new ServiceException("认证对象类型不正确");
|
||||
}
|
||||
// 未传状态时按「审核中」处理:审核页默认停在待审页签,
|
||||
// 只关心待审队列的调用方也不必额外传参
|
||||
Integer queryStatus = status == null ? TransportCertRecord.STATUS_PENDING : status;
|
||||
|
||||
// 司机姓名与手机号、以及按它们的筛选,都要在**分页之前**由 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
|
||||
public CertAuditDetailVO detail(Long recordId) {
|
||||
TransportCertRecord record = requireRecord(recordId);
|
||||
Long effectiveId = effectiveRecordIdOf(record.getBizType(), record.getBizId());
|
||||
|
||||
CertAuditDetailVO detail = new CertAuditDetailVO();
|
||||
detail.setId(record.getId());
|
||||
detail.setBizType(record.getBizType());
|
||||
detail.setBizId(record.getBizId());
|
||||
detail.setBizKey(record.getBizKey());
|
||||
detail.setStatus(record.getStatus());
|
||||
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,前端按「无对比基准」处理
|
||||
detail.setEffectiveRecordId(effectiveId);
|
||||
detail.setCertified(CertificationRuleSupport.isCertified(effectiveId));
|
||||
if (Func.isNotEmpty(effectiveId)) {
|
||||
TransportCertRecord effective = certRecordService.getById(effectiveId);
|
||||
detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson());
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean audit(Long recordId, boolean pass, String rejectReason) {
|
||||
TransportCertRecord record = requireRecord(recordId);
|
||||
|
||||
// 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」,
|
||||
// 生效值就凭空消失了——这正是必须挡住的原因。
|
||||
if (!CertificationRuleSupport.isAuditable(record)) {
|
||||
throw new ServiceException("该认证记录已是"
|
||||
+ CertificationRuleSupport.statusText(record.getStatus()) + "状态,不可再次审核");
|
||||
}
|
||||
if (!CertificationRuleSupport.isRejectReasonValid(pass, rejectReason)) {
|
||||
throw new ServiceException("请输入认证驳回原因(不超过"
|
||||
+ TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)");
|
||||
}
|
||||
|
||||
Long auditor = AuthUtil.getUserId();
|
||||
LocalDateTime auditTime = LocalDateTime.now();
|
||||
|
||||
// 1) 记录本身:状态 + 审核留痕(审核人、审核时间写在这条记录上,不依赖会被覆盖的审计字段)
|
||||
TransportCertRecord patch = new TransportCertRecord();
|
||||
patch.setId(record.getId());
|
||||
patch.setStatus(pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED);
|
||||
patch.setRejectReason(pass
|
||||
? CertificationRuleSupport.reasonForApprove()
|
||||
: CertificationRuleSupport.reasonForReject(rejectReason));
|
||||
patch.setAuditUser(auditor);
|
||||
patch.setAuditTime(auditTime);
|
||||
certRecordService.updateById(patch);
|
||||
|
||||
// 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE,
|
||||
// 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。
|
||||
updateCertTarget(record, pass, rejectReason);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新认证对象的展示缓存,并在通过时把生效快照指针切到这条记录
|
||||
* <p>
|
||||
* 只发**一次** UPDATE:
|
||||
* <ul>
|
||||
* <li>通过 —— 状态=已通过、清空驳回原因、指针指向本条(切换而非追加,故仍至多一条生效快照);</li>
|
||||
* <li>驳回 —— 状态=已驳回、写入原因,**不带指针字段**,生效快照一个字都不动。</li>
|
||||
* </ul>
|
||||
* 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。
|
||||
*
|
||||
* @param record 被审核的记录
|
||||
* @param pass 是否通过
|
||||
* @param rejectReason 驳回原因
|
||||
*/
|
||||
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) {
|
||||
boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType());
|
||||
Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED;
|
||||
String reason = pass ? null : CertificationRuleSupport.reasonForReject(rejectReason);
|
||||
// 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列
|
||||
Long newEffectiveId = pass ? record.getId() : null;
|
||||
|
||||
if (vehicleTarget) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(record.getBizId());
|
||||
vehicle.setCertificationStatus(status);
|
||||
vehicle.setCertificationRejectReason(reason);
|
||||
if (pass) {
|
||||
vehicle.setEffectiveCertRecordId(newEffectiveId);
|
||||
// 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。
|
||||
// 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值——
|
||||
// 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。
|
||||
// 与指针合并成同一次 UPDATE,不额外多写一次同一行。
|
||||
applySnapshotToVehicle(vehicle, record.getSnapshotJson());
|
||||
}
|
||||
transportVehicleService.updateById(vehicle);
|
||||
return;
|
||||
}
|
||||
Driver driver = new Driver();
|
||||
driver.setId(record.getBizId());
|
||||
driver.setCertificationStatus(status);
|
||||
driver.setCertificationRejectReason(reason);
|
||||
// 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS,
|
||||
// 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL,
|
||||
// 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。
|
||||
copyDrivingVehicle(record.getBizId(), driver);
|
||||
if (pass) {
|
||||
driver.setEffectiveCertRecordId(newEffectiveId);
|
||||
// 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。
|
||||
// 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空),
|
||||
// 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。
|
||||
// 与指针合并成同一次 UPDATE,不额外多写一次同一行。
|
||||
applySnapshotToDriver(driver, record.getSnapshotJson());
|
||||
}
|
||||
driverService.updateById(driver);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把司机当前的车辆绑定原值搬进 patch,避免被 ALWAYS 更新策略清空
|
||||
* <p>
|
||||
* {@code driving_vehicle} 上标了 {@code FieldStrategy.ALWAYS},任何一次
|
||||
* {@code updateById} 都会把该列写成 patch 里的值(null 也照写)。
|
||||
* 而司机端的车辆列表是按这一列的车牌匹配出来的,一旦被清空,
|
||||
* 页面就变成「暂无绑定车辆」,且无法从界面上恢复。
|
||||
*
|
||||
* @param driverId 司机主键
|
||||
* @param patch 待更新的司机 patch(就地写入原值)
|
||||
*/
|
||||
private void copyDrivingVehicle(Long driverId, Driver patch) {
|
||||
Driver current = driverService.getById(driverId);
|
||||
if (current != null) {
|
||||
patch.setDrivingVehicle(current.getDrivingVehicle());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象)
|
||||
* <p>
|
||||
* 只搬**司机自己申报的证件内容**,不碰两类字段:
|
||||
* <ul>
|
||||
* <li>{@code realName} / {@code idCardNo}:身份标识,
|
||||
* 由管理端建档时确定,司机端提交不覆盖(重复认证时会按证件号校验为同一人);</li>
|
||||
* <li>{@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}:
|
||||
* 内部管理归属与派工关系,由管理端维护,不该被司机的提交内容覆盖。</li>
|
||||
* </ul>
|
||||
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
|
||||
* 因此不会把管理端填过、司机端没填的字段清空。
|
||||
* <p>
|
||||
* 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**:
|
||||
* 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败;
|
||||
* 何况历史快照本来就存在,阻断会让这些对象再也审不过去。
|
||||
*
|
||||
* @param driver 待更新的司机 patch
|
||||
* @param snapshotJson 该条记录的全量提交内容
|
||||
*/
|
||||
private void applySnapshotToDriver(Driver driver, String snapshotJson) {
|
||||
if (Func.isEmpty(snapshotJson)) {
|
||||
return;
|
||||
}
|
||||
PersonCertSubmitRequest snapshot;
|
||||
try {
|
||||
snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class);
|
||||
} catch (Exception exception) {
|
||||
return;
|
||||
}
|
||||
if (snapshot == null) {
|
||||
return;
|
||||
}
|
||||
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况),
|
||||
// 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。
|
||||
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。
|
||||
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
|
||||
return;
|
||||
}
|
||||
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
|
||||
driver.setIdCardBack(snapshot.getIdCardBackUrl());
|
||||
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
|
||||
driver.setIdCardValidEnd(snapshot.getIdCardValidEnd());
|
||||
driver.setIdCardLongTerm(snapshot.getIdCardLongTerm());
|
||||
driver.setHeadPhoto(snapshot.getHeadPhoto());
|
||||
driver.setDrivingType(snapshot.getDriverClass());
|
||||
driver.setDrivingLicenseNo(snapshot.getDriverFileNo());
|
||||
driver.setDrivingLicenseStartDate(snapshot.getDriverValidStart());
|
||||
driver.setDrivingLicenseEndDate(snapshot.getDriverValidEnd());
|
||||
driver.setDrivingLicenseLongTerm(snapshot.getDriverLongTerm());
|
||||
driver.setDrivingLicenseFront(snapshot.getDriverLicenseFrontUrl());
|
||||
driver.setDrivingLicenseBack(snapshot.getDriverLicenseBackUrl());
|
||||
driver.setQualificationType(snapshot.getQualificationType());
|
||||
driver.setQualificationNo(snapshot.getQualificationNo());
|
||||
driver.setQualificationFront(qualificationFrontOf(snapshot, snapshotJson));
|
||||
driver.setQualificationBack(snapshot.getQualificationBack());
|
||||
driver.setQualificationEndDate(snapshot.getQualificationValidEnd());
|
||||
driver.setQualificationLongTerm(snapshot.getQualificationLongTerm());
|
||||
}
|
||||
|
||||
/**
|
||||
* 取从业资格证封面页地址,兼容改名前的旧键
|
||||
* <p>
|
||||
* 该字段原叫 {@code qualificationUrl},与司机档案列 {@code qualification_front} 不一致,
|
||||
* 已统一为 {@code qualificationFront}。改名之前提交的记录里存的仍是旧键,
|
||||
* 直接按新键取值会得到 null,审核通过后反而把已填的封面页丢掉,故这里回退读一次旧键。
|
||||
*
|
||||
* @param snapshot 已解析的快照(可能因旧键而为 null)
|
||||
* @param snapshotJson 原始快照 JSON,用于回退读取旧键
|
||||
* @return 封面页地址;新旧键都没有时为 null
|
||||
*/
|
||||
private String qualificationFrontOf(PersonCertSubmitRequest snapshot, String snapshotJson) {
|
||||
if (Func.isNotEmpty(snapshot.getQualificationFront())) {
|
||||
return snapshot.getQualificationFront();
|
||||
}
|
||||
try {
|
||||
return JSON.parseObject(snapshotJson).getString("qualificationUrl");
|
||||
} catch (Exception exception) {
|
||||
// 旧键缺失或 JSON 结构异常都不该阻断写回,没有封面页就不写这一列
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把生效快照里的车辆证件字段搬进车辆档案(就地写入同一个 patch 对象)
|
||||
* <p>
|
||||
* 只搬**证件内容**,不碰两类字段:
|
||||
* <ul>
|
||||
* <li>{@code plateNo}:车牌是这辆车的身份,不是本次提交可以改的内容;</li>
|
||||
* <li>{@code organizationName} / {@code useDepartment}:内部管理归属,
|
||||
* 由管理端维护,不该被司机的提交内容覆盖。</li>
|
||||
* </ul>
|
||||
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
|
||||
* 因此不会把管理端填过、司机端没填的字段清空。
|
||||
*
|
||||
* @param vehicle 待更新的车辆 patch
|
||||
* @param snapshotJson 该条记录的全量提交内容
|
||||
*/
|
||||
private void applySnapshotToVehicle(TransportVehicle vehicle, String snapshotJson) {
|
||||
if (Func.isEmpty(snapshotJson)) {
|
||||
throw new ServiceException("该认证记录没有提交内容,无法写入车辆档案");
|
||||
}
|
||||
VehicleCertSubmitRequest snapshot;
|
||||
try {
|
||||
snapshot = JSON.parseObject(snapshotJson, VehicleCertSubmitRequest.class);
|
||||
} catch (Exception exception) {
|
||||
throw new ServiceException("该认证记录的提交内容无法解析,未写入车辆档案");
|
||||
}
|
||||
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(改造前的历史快照就是这种情况),
|
||||
// 那样写回等于什么也没写。故用证件内容里的关键字段判定「这份快照是否是当前格式」。
|
||||
if (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg())) {
|
||||
throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是改造前的历史快照),"
|
||||
+ "未写入车辆档案;请重新提交一次认证");
|
||||
}
|
||||
vehicle.setOwnerName(snapshot.getOwnerName());
|
||||
vehicle.setPlateColor(snapshot.getPlateColor());
|
||||
vehicle.setVehicleType(snapshot.getVehicleType());
|
||||
vehicle.setVin(snapshot.getVin());
|
||||
vehicle.setOuterLength(snapshot.getOuterLength());
|
||||
vehicle.setOuterWidth(snapshot.getOuterWidth());
|
||||
vehicle.setOuterHeight(snapshot.getOuterHeight());
|
||||
vehicle.setApprovedLoadKg(snapshot.getApprovedLoadKg());
|
||||
vehicle.setTractionMassKg(snapshot.getTractionMassKg());
|
||||
vehicle.setEnergyType(snapshot.getEnergyType());
|
||||
vehicle.setBusinessRelation(snapshot.getBusinessRelation());
|
||||
vehicle.setCompulsoryScrapDate(snapshot.getCompulsoryScrapDate());
|
||||
vehicle.setCompulsoryScrapLongTerm(snapshot.getCompulsoryScrapLongTerm());
|
||||
vehicle.setDrivingLicenseNo(snapshot.getDrivingLicenseNo());
|
||||
vehicle.setDrivingLicenseImage(snapshot.getDrivingLicenseImage());
|
||||
vehicle.setDrivingLicenseMainBack(snapshot.getDrivingLicenseMainBack());
|
||||
vehicle.setDrivingLicenseViceFront(snapshot.getDrivingLicenseViceFront());
|
||||
vehicle.setDrivingLicenseViceBack(snapshot.getDrivingLicenseViceBack());
|
||||
vehicle.setDrivingLicenseStartDate(snapshot.getDrivingLicenseStartDate());
|
||||
vehicle.setDrivingLicenseEndDate(snapshot.getDrivingLicenseEndDate());
|
||||
vehicle.setDrivingLicenseLongTerm(snapshot.getDrivingLicenseLongTerm());
|
||||
vehicle.setRoadTransportCertNo(snapshot.getRoadTransportCertNo());
|
||||
vehicle.setRoadTransportCertImage(snapshot.getRoadTransportCertImage());
|
||||
vehicle.setRoadTransportCertStartDate(snapshot.getRoadTransportCertStartDate());
|
||||
vehicle.setRoadTransportCertEndDate(snapshot.getRoadTransportCertEndDate());
|
||||
vehicle.setRoadTransportCertLongTerm(snapshot.getRoadTransportCertLongTerm());
|
||||
vehicle.setRegistrationNo(snapshot.getRegistrationNo());
|
||||
vehicle.setRegistrationImage(snapshot.getRegistrationImage());
|
||||
}
|
||||
|
||||
/**
|
||||
* 取该对象当前的生效快照指针
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
* @return 指针值;对象不存在或从未认证通过时返回 null
|
||||
*/
|
||||
private Long effectiveRecordIdOf(String bizType, Long bizId) {
|
||||
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType)) {
|
||||
TransportVehicle vehicle = transportVehicleService.getById(bizId);
|
||||
return vehicle == null ? null : vehicle.getEffectiveCertRecordId();
|
||||
}
|
||||
Driver driver = driverService.getById(bizId);
|
||||
return driver == null ? null : driver.getEffectiveCertRecordId();
|
||||
}
|
||||
|
||||
private TransportCertRecord requireRecord(Long recordId) {
|
||||
if (Func.isEmpty(recordId)) {
|
||||
throw new ServiceException("认证记录主键不能为空");
|
||||
}
|
||||
TransportCertRecord record = certRecordService.getById(recordId);
|
||||
if (record == null) {
|
||||
throw new ServiceException("认证记录不存在");
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
}
|
||||
+64
-58
@@ -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;
|
||||
@@ -37,16 +38,14 @@ import org.springblade.transport.pojo.vo.DriverVO;
|
||||
import org.springblade.transport.service.IDriverAppService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.wrapper.DriverWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -72,22 +71,15 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> plates = splitPlates(driver.getDrivingVehicle());
|
||||
List<String> plates = VehiclePlateSupport.splitPlates(driver.getDrivingVehicle());
|
||||
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) {
|
||||
@@ -98,6 +90,11 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
return uniq.values().stream().map(this::toCard).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Driver currentDriverEntity() {
|
||||
return currentDriver(null);
|
||||
}
|
||||
|
||||
private Driver currentDriver(String mobileHint) {
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (userId == null || userId <= 0) {
|
||||
@@ -119,57 +116,66 @@ public class DriverAppServiceImpl implements IDriverAppService {
|
||||
return driver;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private DriverVehicleCardVO toCard(TransportVehicle vehicle) {
|
||||
DriverVehicleCardVO card = new DriverVehicleCardVO();
|
||||
card.setId(vehicle.getId());
|
||||
card.setPlateNo(vehicle.getPlateNo());
|
||||
card.setOwnerName(Func.toStr(vehicle.getOwnerName(), ""));
|
||||
card.setPlateColor(Func.toStr(vehicle.getPlateColor(), ""));
|
||||
card.setVehicleType(vehicle.getVehicleType());
|
||||
card.setLicenseFrontUrl(Func.toStr(vehicle.getDrivingLicenseImage(), ""));
|
||||
card.setLicenseBackUrl(firstNotEmpty(vehicle.getDrivingLicenseViceFront(), vehicle.getDrivingLicenseMainBack()));
|
||||
card.setRoadTransportNo(Func.toStr(vehicle.getRoadTransportCertNo(), ""));
|
||||
card.setRoadTransportUrl(Func.toStr(vehicle.getRoadTransportCertImage(), ""));
|
||||
card.setVin("");
|
||||
card.setEngineNo("");
|
||||
if (vehicle.getDrivingLicenseEndDate() != null) {
|
||||
card.setLicenseValidEnd(vehicle.getDrivingLicenseEndDate().toString());
|
||||
} else if (Integer.valueOf(1).equals(vehicle.getDrivingLicenseLongTerm())) {
|
||||
card.setLicenseValidEnd("长期");
|
||||
} else {
|
||||
card.setLicenseValidEnd("");
|
||||
}
|
||||
card.setVin(Func.toStr(vehicle.getVin(), ""));
|
||||
card.setOuterLength(vehicle.getOuterLength());
|
||||
card.setOuterWidth(vehicle.getOuterWidth());
|
||||
card.setOuterHeight(vehicle.getOuterHeight());
|
||||
card.setApprovedLoadKg(vehicle.getApprovedLoadKg());
|
||||
card.setTractionMassKg(vehicle.getTractionMassKg());
|
||||
card.setEnergyType(Func.toStr(vehicle.getEnergyType(), ""));
|
||||
card.setBusinessRelation(Func.toStr(vehicle.getBusinessRelation(), ""));
|
||||
card.setCompulsoryScrapDate(dateText(vehicle.getCompulsoryScrapDate(), vehicle.getCompulsoryScrapLongTerm()));
|
||||
card.setCompulsoryScrapLongTerm(vehicle.getCompulsoryScrapLongTerm());
|
||||
card.setDrivingLicenseNo(Func.toStr(vehicle.getDrivingLicenseNo(), ""));
|
||||
// 行驶证四张图各自独立,不再把「主页反面」与「副页正面」挤在同一个字段里:
|
||||
// 它们本来就是证件上不同的四面,混在一起会让「重新认证」回填出错的图。
|
||||
card.setDrivingLicenseImage(Func.toStr(vehicle.getDrivingLicenseImage(), ""));
|
||||
card.setDrivingLicenseMainBack(Func.toStr(vehicle.getDrivingLicenseMainBack(), ""));
|
||||
card.setDrivingLicenseViceFront(Func.toStr(vehicle.getDrivingLicenseViceFront(), ""));
|
||||
card.setDrivingLicenseViceBack(Func.toStr(vehicle.getDrivingLicenseViceBack(), ""));
|
||||
card.setDrivingLicenseStartDate(dateText(vehicle.getDrivingLicenseStartDate(), null));
|
||||
card.setDrivingLicenseEndDate(dateText(vehicle.getDrivingLicenseEndDate(), vehicle.getDrivingLicenseLongTerm()));
|
||||
card.setDrivingLicenseLongTerm(vehicle.getDrivingLicenseLongTerm());
|
||||
card.setRoadTransportCertNo(Func.toStr(vehicle.getRoadTransportCertNo(), ""));
|
||||
card.setRoadTransportCertImage(Func.toStr(vehicle.getRoadTransportCertImage(), ""));
|
||||
card.setRoadTransportCertStartDate(dateText(vehicle.getRoadTransportCertStartDate(), null));
|
||||
card.setRoadTransportCertEndDate(dateText(vehicle.getRoadTransportCertEndDate(), vehicle.getRoadTransportCertLongTerm()));
|
||||
card.setRoadTransportCertLongTerm(vehicle.getRoadTransportCertLongTerm());
|
||||
card.setRegistrationNo(Func.toStr(vehicle.getRegistrationNo(), ""));
|
||||
card.setRegistrationImage(Func.toStr(vehicle.getRegistrationImage(), ""));
|
||||
card.setCertificationStatus(vehicle.getCertificationStatus());
|
||||
return card;
|
||||
}
|
||||
|
||||
private String firstNotEmpty(String first, String second) {
|
||||
if (Func.isNotEmpty(first)) {
|
||||
return first;
|
||||
/**
|
||||
* 日期字段的下发值:只下发 {@code yyyy-MM-dd} 或空串,**从不下发「长期」这类文字**
|
||||
* <p>
|
||||
* 卡片是「重新认证」表单的回填来源,值会直接进日期框,所以两条约束必须守住:
|
||||
* <ul>
|
||||
* <li>长期有效时下发**空串**:长期与否由同名字段 {@code xxxLongTerm} 表达,
|
||||
* 界面上显示「长期」是前端的事。把「长期」当日期下发,用户关掉长期有效开关后
|
||||
* 日期框里会留着一个字面量「长期」,提交时后端按 {@code LocalDate} 反序列化直接报错。</li>
|
||||
* <li>长期有效时**不下发止期**:止期在「长期」语义下已作废(管理端勾长期有效时也会清掉它),
|
||||
* 下发旧值会被表单原样回传、审核通过后又写回车辆行,形成「长期有效 + 一个过期止期」
|
||||
* 的脏数据——车辆行上一次长期有效的提交正是这样留下 2040-08-14 的。</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param date 日期
|
||||
* @param longTerm 长期有效标志:1 表示长期
|
||||
* @return {@code yyyy-MM-dd} 或空串
|
||||
*/
|
||||
private String dateText(LocalDate date, Integer longTerm) {
|
||||
if (Integer.valueOf(1).equals(longTerm)) {
|
||||
return "";
|
||||
}
|
||||
return Func.toStr(second, "");
|
||||
return date == null ? "" : date.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+561
@@ -0,0 +1,561 @@
|
||||
/**
|
||||
* 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.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;
|
||||
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;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.IDriverAppService;
|
||||
import org.springblade.transport.service.IDriverCertService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationRuleSupport;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 司机端认证服务实现
|
||||
* <p>
|
||||
* 提交链路只做三件事:把本次提交的内容**原样**存成一条新的待审记录、
|
||||
* 把该对象前一条待审记录置为已作废、刷新展示缓存。
|
||||
* <p>
|
||||
* ⚠️ 提交**绝不**修改 {@code effective_cert_record_id},也**不覆盖**证件字段,
|
||||
* 这正是「提交审核不影响车辆正常使用」的实现方式:待审内容只存在于记录表里,
|
||||
* 在审核通过之前,该对象对外呈现的仍然是旧的生效值。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DriverCertServiceImpl implements IDriverCertService {
|
||||
|
||||
/**
|
||||
* 证件文本字段的长度上限:与车辆表列宽及管理端校验保持一致
|
||||
*/
|
||||
private static final int SHORT_TEXT_MAX_LENGTH = 50;
|
||||
|
||||
/**
|
||||
* 所有人(行驶证登记的单位名称)长度上限
|
||||
*/
|
||||
private static final int OWNER_NAME_MAX_LENGTH = 100;
|
||||
|
||||
private final IDriverAppService driverAppService;
|
||||
private final IDriverService driverService;
|
||||
private final ITransportVehicleService transportVehicleService;
|
||||
private final ITransportCertRecordService transportCertRecordService;
|
||||
|
||||
/**
|
||||
* 司机端提交的车辆认证内容校验
|
||||
* <p>
|
||||
* 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过。
|
||||
* 校验发生在**落快照之前**——待审内容虽然要等审核通过才写进车辆档案,但它已经是一条
|
||||
* 不可变记录、并会在通过时按字段写回车辆行({@code applySnapshotToVehicle}),
|
||||
* 校验只做在「新建车辆」那一条路上,重新认证就成了绕过校验的后门。
|
||||
*
|
||||
* @param request 提交内容
|
||||
*/
|
||||
private void validateCertContent(VehicleCertSubmitRequest request) {
|
||||
requireText(request.getPlateColor(), "车牌颜色不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getVehicleType(), "车辆类型不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getOwnerName(), "所有人不能为空", OWNER_NAME_MAX_LENGTH);
|
||||
requireText(request.getEnergyType(), "能源类型不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getBusinessRelation(), "业务关系不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getDrivingLicenseNo(), "行驶证档案编号不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
requireText(request.getRoadTransportCertNo(), "道路运输证号不能为空", SHORT_TEXT_MAX_LENGTH);
|
||||
Optional.ofNullable(request.getVin())
|
||||
.filter(Func::isNotEmpty)
|
||||
.ifPresent(vin -> requireText(vin, "车架号格式不正确", 17));
|
||||
requireNonNegative(request.getApprovedLoadKg(), "核定载质量不能为空", "核定载质量不能小于0");
|
||||
requireNonNegative(request.getTractionMassKg(), "准牵引总质量不能为空", "准牵引总质量不能小于0");
|
||||
requireNonNegative(request.getOuterLength(), "外廓长度不能为空", "外廓长度不能小于0");
|
||||
requireNonNegative(request.getOuterWidth(), "外廓宽度不能为空", "外廓宽度不能小于0");
|
||||
requireNonNegative(request.getOuterHeight(), "外廓高度不能为空", "外廓高度不能小于0");
|
||||
requireDateRange(request.getDrivingLicenseStartDate(), request.getDrivingLicenseEndDate(),
|
||||
request.getDrivingLicenseLongTerm(), "请选择行驶证有效期", "行驶证有效期止不能早于有效期起");
|
||||
requireDateRange(request.getRoadTransportCertStartDate(), request.getRoadTransportCertEndDate(),
|
||||
request.getRoadTransportCertLongTerm(), "请选择道路运输证有效期", "道路运输证有效期止不能早于有效期起");
|
||||
if (Func.isEmpty(request.getCompulsoryScrapDate())
|
||||
&& !Integer.valueOf(1).equals(request.getCompulsoryScrapLongTerm())) {
|
||||
throw new ServiceException("请选择强制报废日期");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT
|
||||
* 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。
|
||||
* <p>
|
||||
* 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属),
|
||||
* 拿它拦住重新认证只会让司机无从下手。
|
||||
*
|
||||
* @param driver 当前司机
|
||||
*/
|
||||
private void requireOrganizationForNewVehicle(Driver driver) {
|
||||
if (Func.isEmpty(driver.getOrganizationName())) {
|
||||
throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本必填 + 长度校验
|
||||
*
|
||||
* @param value 待校验值
|
||||
* @param emptyText 为空时的提示
|
||||
* @param maxLength 长度上限
|
||||
*/
|
||||
private void requireText(String value, String emptyText, int maxLength) {
|
||||
if (Func.isEmpty(value)) {
|
||||
throw new ServiceException(emptyText);
|
||||
}
|
||||
if (value.trim().length() > maxLength) {
|
||||
throw new ServiceException(emptyText.replace("不能为空", "") + "不能超过" + maxLength + "字");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 数值必填 + 非负校验
|
||||
*
|
||||
* @param value 待校验值
|
||||
* @param emptyText 为空时的提示
|
||||
* @param negativeText 为负数时的提示
|
||||
*/
|
||||
private void requireNonNegative(Integer value, String emptyText, String negativeText) {
|
||||
if (value == null) {
|
||||
throw new ServiceException(emptyText);
|
||||
}
|
||||
if (value < 0) {
|
||||
throw new ServiceException(negativeText);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有效期校验:未勾「长期有效」时**止期**必填;勾了长期有效则止期也不要求
|
||||
* <p>
|
||||
* 起期不再是必填:司机端表单不采集它(管理端车辆表单同样只填止期),
|
||||
* 存量车辆档案里起期列全是空的,强制必填会让每一次重新认证都卡在提交前。
|
||||
* 起止先后只在**两端都有值**时才比较——只有止期时没有可比的对象。
|
||||
*
|
||||
* @param start 有效期起,可为空
|
||||
* @param end 有效期止
|
||||
* @param longTerm 长期有效标志:1 表示长期
|
||||
* @param emptyText 缺止期时的提示
|
||||
* @param rangeText 区间颠倒时的提示
|
||||
*/
|
||||
private void requireDateRange(LocalDate start, LocalDate end, Integer longTerm,
|
||||
String emptyText, String rangeText) {
|
||||
if (Integer.valueOf(1).equals(longTerm)) {
|
||||
return;
|
||||
}
|
||||
if (end == null) {
|
||||
throw new ServiceException(emptyText);
|
||||
}
|
||||
if (start != null && end.isBefore(start)) {
|
||||
throw new ServiceException(rangeText);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long submitPersonCert(PersonCertSubmitRequest request) {
|
||||
if (Func.isEmpty(request)) {
|
||||
throw new ServiceException("认证内容不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
String snapshotJson = JSON.toJSONString(request);
|
||||
|
||||
// 先作废本人上一条待审,再落新记录:任何时刻同一对象至多一条待审。
|
||||
// 作废只动「待审」,已通过/已驳回都是终态,尤其不得碰生效快照指着的那条。
|
||||
supersedePending(TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId());
|
||||
|
||||
TransportCertRecord record = newRecord(
|
||||
TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId(),
|
||||
Func.toStr(driver.getDriverName(), ""), snapshotJson, driver.getUserId());
|
||||
transportCertRecordService.save(record);
|
||||
|
||||
// 展示缓存:让小程序卡片显示「审核中」。可用性判定不看这个字段。
|
||||
updateDriverDisplayCache(driver.getId(), TransportCertRecord.STATUS_PENDING, null);
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public PersonCertSubmitRequest getEffectivePersonCert() {
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 当前司机实体已加载过,直接读它身上的指针,不再回查一次
|
||||
TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId());
|
||||
return effective == null
|
||||
? null
|
||||
: JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long submitVehicleCert(VehicleCertSubmitRequest request) {
|
||||
if (Func.isEmpty(request)) {
|
||||
throw new ServiceException("认证内容不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 校验在 resolveBoundVehicle 之前:它既覆盖「新建车辆」,也覆盖「重新认证」——
|
||||
// 后者原先一点校验都不做,内容全靠审核环节兜底
|
||||
validateCertContent(request);
|
||||
TransportVehicle vehicle = resolveBoundVehicle(driver, request);
|
||||
String snapshotJson = JSON.toJSONString(request);
|
||||
|
||||
supersedePending(TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId());
|
||||
|
||||
TransportCertRecord record = newRecord(
|
||||
TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId(),
|
||||
Func.toStr(vehicle.getPlateNo(), ""), snapshotJson, driver.getUserId());
|
||||
transportCertRecordService.save(record);
|
||||
|
||||
updateVehicleDisplayCache(vehicle.getId(), TransportCertRecord.STATUS_PENDING, null);
|
||||
return record.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean deleteVehicleCert(Long vehicleId) {
|
||||
if (Func.isEmpty(vehicleId)) {
|
||||
throw new ServiceException("车辆主键不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
TransportVehicle vehicle = transportVehicleService.getById(vehicleId);
|
||||
if (vehicle == null) {
|
||||
throw new ServiceException("车辆不存在");
|
||||
}
|
||||
// 越权校验:与提交链路同一套绑定判定
|
||||
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
|
||||
throw new ServiceException("无权删除该车辆的认证");
|
||||
}
|
||||
// 逻辑删除车辆;认证记录**不删**——它是不可变快照,且是「曾通过即永久可用」的依据
|
||||
return transportVehicleService.deleteLogic(Collections.singletonList(vehicleId));
|
||||
}
|
||||
|
||||
@Override
|
||||
public VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId) {
|
||||
if (Func.isEmpty(vehicleId)) {
|
||||
throw new ServiceException("车辆主键不能为空");
|
||||
}
|
||||
TransportVehicle vehicle = transportVehicleService.getById(vehicleId);
|
||||
if (vehicle == null) {
|
||||
throw new ServiceException("车辆不存在");
|
||||
}
|
||||
if (!VehiclePlateSupport.isBound(requireCurrentDriver().getDrivingVehicle(), vehicle.getPlateNo())) {
|
||||
throw new ServiceException("无权查看该车辆的认证信息");
|
||||
}
|
||||
TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId());
|
||||
return effective == null
|
||||
? null
|
||||
: JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TransportCertRecordVO> listRecords(String bizType, Long bizId) {
|
||||
requireValidBizType(bizType);
|
||||
requireReadableTarget(bizType, bizId);
|
||||
return transportCertRecordService.selectRecordList(bizType, bizId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportCertRecordVO getRecord(Long recordId) {
|
||||
TransportCertRecord record = transportCertRecordService.getById(recordId);
|
||||
if (record == null) {
|
||||
throw new ServiceException("认证记录不存在");
|
||||
}
|
||||
requireReadableTarget(record.getBizType(), record.getBizId());
|
||||
return TransportCertRecordWrapper.build().entityVO(record);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把该认证对象当前那条待审记录置为已作废
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
*/
|
||||
private void supersedePending(String bizType, Long bizId) {
|
||||
TransportCertRecord pending = transportCertRecordService.selectPendingRecord(bizType, bizId);
|
||||
TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(pending);
|
||||
if (superseded != null) {
|
||||
transportCertRecordService.updateById(superseded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造一条待审记录
|
||||
* <p>
|
||||
* 状态必须显式设置为待审:框架在插入时会把空的 {@code status} 静默填成 1,
|
||||
* 而 1 在本表是「已通过」,那会让一次提交凭空变成审核通过。
|
||||
*
|
||||
* @param submitUser 提交人**用户ID**(blade_user.id)——审核页要靠它翻译成姓名。
|
||||
* 不要传司机档案ID:两者都是雪花ID、类型也相同,传错不会报任何错,
|
||||
* 只会让「提交人」一栏显示成一串数字。
|
||||
*/
|
||||
private TransportCertRecord newRecord(String bizType, Long bizId, String bizKey,
|
||||
String snapshotJson, Long submitUser) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setBizType(bizType);
|
||||
record.setBizId(bizId);
|
||||
record.setBizKey(bizKey);
|
||||
record.setSnapshotJson(snapshotJson);
|
||||
record.setSubmitTime(LocalDateTime.now());
|
||||
record.setSubmitUser(submitUser);
|
||||
CertificationRuleSupport.prepareForSubmit(record);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按生效快照指针取那条记录
|
||||
* <p>
|
||||
* 只按指针查,不看记录表里任何一条的「最新状态」——这正是生效值与待审值分离的意义。
|
||||
*
|
||||
* @param effectiveRecordId 生效快照指针
|
||||
* @return 生效快照记录;指针为空时返回 null
|
||||
*/
|
||||
private TransportCertRecord findEffectiveRecord(Long effectiveRecordId) {
|
||||
return Func.isEmpty(effectiveRecordId)
|
||||
? null
|
||||
: transportCertRecordService.getById(effectiveRecordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新司机侧的「最新提交状态」展示缓存
|
||||
* <p>
|
||||
* 只写展示缓存,**不碰生效快照指针**。
|
||||
*/
|
||||
private void updateDriverDisplayCache(Long driverId, Integer status, String rejectReason) {
|
||||
Driver patch = new Driver();
|
||||
patch.setId(driverId);
|
||||
patch.setCertificationStatus(status);
|
||||
patch.setCertificationRejectReason(rejectReason);
|
||||
// 必须带上 driving_vehicle 原值:该列标了 FieldStrategy.ALWAYS,
|
||||
// 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL,
|
||||
// 而司机端的车辆列表正是按这一列的车牌匹配出来的(清空即「暂无绑定车辆」)。
|
||||
Driver current = driverService.getById(driverId);
|
||||
if (current != null) {
|
||||
patch.setDrivingVehicle(current.getDrivingVehicle());
|
||||
}
|
||||
driverService.updateById(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新车辆侧的「最新提交状态」展示缓存
|
||||
* <p>
|
||||
* 只写展示缓存,**不碰生效快照指针**,也不覆盖车辆当前的证件字段。
|
||||
*/
|
||||
private void updateVehicleDisplayCache(Long vehicleId, Integer status, String rejectReason) {
|
||||
TransportVehicle patch = new TransportVehicle();
|
||||
patch.setId(vehicleId);
|
||||
patch.setCertificationStatus(status);
|
||||
patch.setCertificationRejectReason(rejectReason);
|
||||
transportVehicleService.updateById(patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析本次提交指向的车辆,并校验它确实绑定在当前司机名下
|
||||
*
|
||||
* @param driver 当前司机
|
||||
* @param request 提交内容
|
||||
* @return 目标车辆
|
||||
*/
|
||||
private TransportVehicle resolveBoundVehicle(Driver driver, VehicleCertSubmitRequest request) {
|
||||
// 按 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;
|
||||
}
|
||||
|
||||
String normalized = VehiclePlateSupport.normalize(request.getPlateNo());
|
||||
if (Func.isEmpty(normalized)) {
|
||||
throw new ServiceException("车牌号不能为空");
|
||||
}
|
||||
|
||||
// 按车牌定位:已登记的车必须已绑定到本人;未登记的车视为「新增」,就地建档并绑定。
|
||||
// 小程序「+ 新增车辆」允许司机直接填车牌,若这里只认已有绑定,
|
||||
// 新车的提交必然失败——而司机名下还没有这辆车,也就永远无从绑定。
|
||||
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, List.of(normalizedPlate));
|
||||
return transportVehicleService.list(plateQuery).stream()
|
||||
.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) {
|
||||
// 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件
|
||||
requireOrganizationForNewVehicle(driver);
|
||||
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setPlateNo(normalizedPlate);
|
||||
// 所属组织取司机档案:车辆表的该列 NOT NULL 且无默认值,不填这条 INSERT 直接报
|
||||
// 「Field 'organization_name' doesn't have a default value」(严格模式下必错)。
|
||||
// 车辆归到司机所属组织名下,管理端按组织查看时才看得到这台车。
|
||||
vehicle.setOrganizationName(driver.getOrganizationName());
|
||||
vehicle.setOwnerName(request.getOwnerName());
|
||||
vehicle.setPlateColor(request.getPlateColor());
|
||||
vehicle.setVehicleType(request.getVehicleType());
|
||||
vehicle.setVin(request.getVin());
|
||||
vehicle.setOuterLength(request.getOuterLength());
|
||||
vehicle.setOuterWidth(request.getOuterWidth());
|
||||
vehicle.setOuterHeight(request.getOuterHeight());
|
||||
vehicle.setApprovedLoadKg(request.getApprovedLoadKg());
|
||||
vehicle.setTractionMassKg(request.getTractionMassKg());
|
||||
vehicle.setEnergyType(request.getEnergyType());
|
||||
vehicle.setBusinessRelation(request.getBusinessRelation());
|
||||
vehicle.setCompulsoryScrapDate(request.getCompulsoryScrapDate());
|
||||
vehicle.setCompulsoryScrapLongTerm(request.getCompulsoryScrapLongTerm());
|
||||
// 行驶证四张图与车辆表四列一一对应,不再把「主页反面」塞进语义含糊的「副页」字段
|
||||
vehicle.setDrivingLicenseNo(request.getDrivingLicenseNo());
|
||||
vehicle.setDrivingLicenseImage(request.getDrivingLicenseImage());
|
||||
vehicle.setDrivingLicenseMainBack(request.getDrivingLicenseMainBack());
|
||||
vehicle.setDrivingLicenseViceFront(request.getDrivingLicenseViceFront());
|
||||
vehicle.setDrivingLicenseViceBack(request.getDrivingLicenseViceBack());
|
||||
vehicle.setDrivingLicenseStartDate(request.getDrivingLicenseStartDate());
|
||||
vehicle.setDrivingLicenseEndDate(request.getDrivingLicenseEndDate());
|
||||
vehicle.setDrivingLicenseLongTerm(request.getDrivingLicenseLongTerm());
|
||||
vehicle.setRoadTransportCertNo(request.getRoadTransportCertNo());
|
||||
vehicle.setRoadTransportCertStartDate(request.getRoadTransportCertStartDate());
|
||||
vehicle.setRoadTransportCertEndDate(request.getRoadTransportCertEndDate());
|
||||
vehicle.setRoadTransportCertLongTerm(request.getRoadTransportCertLongTerm());
|
||||
vehicle.setRoadTransportCertImage(request.getRoadTransportCertImage());
|
||||
// 机动车登记编号该列同样 NOT NULL:司机端是选填,缺值写空串而不是 null
|
||||
vehicle.setRegistrationNo(Func.toStr(request.getRegistrationNo(), ""));
|
||||
vehicle.setRegistrationImage(request.getRegistrationImage());
|
||||
vehicle.setStatus(BladeConstant.DB_STATUS_NORMAL);
|
||||
transportVehicleService.save(vehicle);
|
||||
|
||||
// 绑定到司机名下,否则本人后续仍无法操作这辆车
|
||||
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);
|
||||
driverService.updateById(patch);
|
||||
driver.setDrivingVehicle(drivingVehicle);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验当前登录用户能否查看该认证对象的记录
|
||||
* <p>
|
||||
* 司机只能查自己的;车辆必须是本人绑定的。越权一律拒绝,不返回空列表
|
||||
* ——空列表会让调用方以为「没有记录」,而不是「无权查看」。
|
||||
*/
|
||||
private void requireReadableTarget(String bizType, Long bizId) {
|
||||
if (Func.isEmpty(bizId)) {
|
||||
throw new ServiceException("认证对象主键不能为空");
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
if (TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)) {
|
||||
if (!bizId.equals(driver.getId())) {
|
||||
throw new ServiceException("无权查看其他司机的认证记录");
|
||||
}
|
||||
return;
|
||||
}
|
||||
TransportVehicle vehicle = transportVehicleService.getById(bizId);
|
||||
if (vehicle == null) {
|
||||
throw new ServiceException("车辆不存在");
|
||||
}
|
||||
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
|
||||
throw new ServiceException("无权查看该车辆的认证记录");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireValidBizType(String bizType) {
|
||||
if (!CertificationRuleSupport.isValidBizType(bizType)) {
|
||||
throw new ServiceException("认证对象类型不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private Driver requireCurrentDriver() {
|
||||
Driver driver = driverAppService.currentDriverEntity();
|
||||
if (driver == null) {
|
||||
throw new ServiceException("未找到当前登录用户对应的司机档案");
|
||||
}
|
||||
return driver;
|
||||
}
|
||||
|
||||
}
|
||||
+7
-12
@@ -44,12 +44,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;
|
||||
|
||||
@@ -69,14 +69,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;
|
||||
|
||||
@@ -255,15 +254,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字");
|
||||
|
||||
+40
-22
@@ -31,12 +31,17 @@ import org.springblade.core.log.exception.ServiceException;
|
||||
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.utils.Func;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
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 +73,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 +113,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 +360,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 +376,35 @@ 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;
|
||||
}
|
||||
// 用共享的车牌匹配条件:只做精确 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,
|
||||
vehicle.getEffectiveCertRecordId()));
|
||||
if (!anyCertified) {
|
||||
throw new ServiceException(certificationGate.vehicleUnavailableReason());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean reject(Long id, String reason) {
|
||||
@@ -563,26 +600,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) {
|
||||
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 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.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 org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.mp.base.BaseServiceImpl;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.mapper.TransportCertRecordMapper;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 认证记录 服务实现类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Service
|
||||
public class TransportCertRecordServiceImpl extends BaseServiceImpl<TransportCertRecordMapper, TransportCertRecord> implements ITransportCertRecordService {
|
||||
|
||||
/**
|
||||
* 合法的审核状态取值
|
||||
*/
|
||||
private static final Set<Integer> VALID_STATUS = Set.of(
|
||||
TransportCertRecord.STATUS_PENDING,
|
||||
TransportCertRecord.STATUS_APPROVED,
|
||||
TransportCertRecord.STATUS_REJECTED,
|
||||
TransportCertRecord.STATUS_SUPERSEDED
|
||||
);
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean save(TransportCertRecord transportCertRecord) {
|
||||
validateRecord(transportCertRecord);
|
||||
return super.save(transportCertRecord);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean saveBatch(Collection<TransportCertRecord> entityList, int batchSize) {
|
||||
entityList.forEach(this::validateRecord);
|
||||
return super.saveBatch(entityList, batchSize);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean saveOrUpdateBatch(Collection<TransportCertRecord> entityList, int batchSize) {
|
||||
entityList.forEach(this::validateRecord);
|
||||
return super.saveOrUpdateBatch(entityList, batchSize);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁用继承自 {@code BaseService} 的「修改状态」
|
||||
* <p>
|
||||
* ⚠️ 本表的 {@code status} 是<b>审核状态</b>,不是「启用 / 停用」。
|
||||
* 而 {@code BaseServiceImpl#changeStatus} 会绕过 {@link #validateRecord} 直接写 status,
|
||||
* 调用 {@code changeStatus(ids, 1)} 即等于<b>批量通过审核</b>——
|
||||
* 审核人、审核时间、驳回原因全部缺失,既丢失审核留痕,也会凭空造出生效快照。
|
||||
* 审核必须逐条走带留痕的审核方法。
|
||||
*
|
||||
* @param ids 主键集合
|
||||
* @param status 状态
|
||||
* @return 永不返回,直接抛出
|
||||
*/
|
||||
@Override
|
||||
public boolean changeStatus(List<Long> ids, Integer status) {
|
||||
throw new ServiceException("认证记录不支持直接修改状态,请使用审核接口");
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId) {
|
||||
return list(recordQuery(bizType, bizId)).stream()
|
||||
.map(TransportCertRecordWrapper.build()::entityVO)
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportCertRecord selectPendingRecord(String bizType, Long bizId) {
|
||||
validateBizTarget(bizType, bizId);
|
||||
return getOne(Wrappers.<TransportCertRecord>lambdaQuery()
|
||||
.eq(TransportCertRecord::getBizType, bizType)
|
||||
.eq(TransportCertRecord::getBizId, bizId)
|
||||
.eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证对象检索条件:按提交时间倒序
|
||||
* <p>
|
||||
* 排序用 {@code submit_time} 而非框架默认的 {@code create_time},是刻意为之:
|
||||
* 认证记录的业务时间轴是<b>提交时间</b>,列表需按提交时间倒序展示每次提交。
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
* @return 检索条件
|
||||
*/
|
||||
private LambdaQueryWrapper<TransportCertRecord> recordQuery(String bizType, Long bizId) {
|
||||
validateBizTarget(bizType, bizId);
|
||||
return Wrappers.<TransportCertRecord>lambdaQuery()
|
||||
.eq(TransportCertRecord::getBizType, bizType)
|
||||
.eq(TransportCertRecord::getBizId, bizId)
|
||||
.orderByDesc(TransportCertRecord::getSubmitTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证对象标识校验
|
||||
*
|
||||
* @param bizType 认证对象类型
|
||||
* @param bizId 认证对象主键
|
||||
*/
|
||||
private void validateBizTarget(String bizType, Long bizId) {
|
||||
if (Func.isEmpty(bizType) || Func.isEmpty(bizId)) {
|
||||
throw new ServiceException("认证对象类型与主键不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录落库前的强制校验
|
||||
* <p>
|
||||
* ⚠️ 这里防的是框架的一个<b>静默</b>行为:{@code BaseServiceImpl#resolveEntity} 在插入时
|
||||
* 若 {@code status} 为 {@code null},会填充 {@code BladeConstant.DB_STATUS_NORMAL}(值 1),
|
||||
* 而 1 在本表恰好是 {@link TransportCertRecord#STATUS_APPROVED}。
|
||||
* 那会让一条「忘了设状态」的提交凭空变成「已通过」,进而可能被当成生效快照。
|
||||
* 故此处对空状态与非法取值直接拒绝,不依赖调用方自觉。
|
||||
*
|
||||
* @param transportCertRecord 待落库的认证记录
|
||||
*/
|
||||
private void validateRecord(TransportCertRecord transportCertRecord) {
|
||||
if (Func.isEmpty(transportCertRecord)) {
|
||||
throw new ServiceException("认证记录不能为空");
|
||||
}
|
||||
if (Func.isEmpty(transportCertRecord.getBizType())) {
|
||||
throw new ServiceException("认证对象类型不能为空");
|
||||
}
|
||||
if (Func.isEmpty(transportCertRecord.getBizId())) {
|
||||
throw new ServiceException("认证对象主键不能为空");
|
||||
}
|
||||
if (Func.isEmpty(transportCertRecord.getSnapshotJson())) {
|
||||
throw new ServiceException("认证内容快照不能为空");
|
||||
}
|
||||
Integer status = transportCertRecord.getStatus();
|
||||
if (Func.isEmpty(status)) {
|
||||
throw new ServiceException("认证记录状态不能为空,不允许依赖框架默认值(默认值 1 会被误判为已通过)");
|
||||
}
|
||||
if (!VALID_STATUS.contains(status)) {
|
||||
throw new ServiceException("认证记录状态取值不正确");
|
||||
}
|
||||
if (TransportCertRecord.STATUS_REJECTED.equals(status) && Func.isEmpty(transportCertRecord.getRejectReason())) {
|
||||
throw new ServiceException("请输入认证驳回原因");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+2
-3
@@ -42,6 +42,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;
|
||||
|
||||
@@ -51,7 +52,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;
|
||||
|
||||
/**
|
||||
@@ -63,7 +63,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 = "绿牌";
|
||||
@@ -266,7 +265,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())) {
|
||||
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* 认证审核的权限表达式
|
||||
* <p>
|
||||
* 用菜单码而不是角色名。本项目的权限走「角色-菜单」授权,写死角色名有两个坑:
|
||||
* 角色别名可能对不上(曾写成 {@code admin},而库里只有 {@code administrator}),
|
||||
* 以及会漏掉实际承担审核的岗位(例如「运管」)。
|
||||
* <p>
|
||||
* 用表达式而不是 {@code @PreAuth(menu = "...")} 的单值形式,是因为同一组接口
|
||||
* 由 {@code bizType} 参数区分、同时服务司机与车辆两侧,单值 menu 表达不了「二者之一」。
|
||||
* <p>
|
||||
* 表达式最终由框架交给 SpEL 求值,root object 是 {@code AuthFun},
|
||||
* 故这里用的函数名必须与它的公开方法一致({@code hasMenu})。
|
||||
* {@code CertAuditPermissionsTest} 会实际解析并求值这两个表达式,
|
||||
* 一旦写错函数名或括号,测试即失败——而不是等到线上请求时抛 500。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class CertAuditPermissions {
|
||||
|
||||
/**
|
||||
* 待审列表与审核详情:司机或车辆任一审核菜单即可
|
||||
*/
|
||||
public static final String AUDIT_VIEW =
|
||||
"hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')";
|
||||
|
||||
/**
|
||||
* 审核动作:司机或车辆任一「审核」按钮权限即可
|
||||
*/
|
||||
public static final String AUDIT_ACTION =
|
||||
"hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')";
|
||||
|
||||
private CertAuditPermissions() {
|
||||
}
|
||||
|
||||
}
|
||||
+124
@@ -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 "您尚未通过认证审核,暂不可接单,请先完成认证";
|
||||
}
|
||||
|
||||
}
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* 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 org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
|
||||
/**
|
||||
* 认证记录状态机规则
|
||||
* <p>
|
||||
* 把「什么状态能做什么」集中在一处,与持久化分离,便于单独验证。
|
||||
* 这几条规则是整个认证改造的地基,改坏的后果是线上事故:
|
||||
* 司机重新提交认证后车辆掉出可用池,或同一辆车出现两条待审记录。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class CertificationRuleSupport {
|
||||
|
||||
private static final String STATUS_TEXT_PENDING = "审核中";
|
||||
private static final String STATUS_TEXT_APPROVED = "已通过";
|
||||
private static final String STATUS_TEXT_REJECTED = "已驳回";
|
||||
private static final String STATUS_TEXT_SUPERSEDED = "已作废";
|
||||
private static final String STATUS_TEXT_UNKNOWN = "未知状态";
|
||||
|
||||
private CertificationRuleSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条新提交的记录准备成待审
|
||||
* <p>
|
||||
* 必须清空驳回原因:新提交是全新一次认证,不能继承上一次的驳回文案,
|
||||
* 否则前端会显示「审核中」却带着旧的驳回原因。
|
||||
*
|
||||
* @param incoming 待入库的记录
|
||||
*/
|
||||
public static void prepareForSubmit(TransportCertRecord incoming) {
|
||||
incoming.setStatus(TransportCertRecord.STATUS_PENDING);
|
||||
incoming.setRejectReason(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 再次提交时,把该认证对象**前一条待审**记录转为已作废
|
||||
* <p>
|
||||
* 只动待审记录:已通过、已驳回、已作废都是终态,一律不碰。
|
||||
* 特别是**不得改动已通过的记录**——它可能正被生效快照指针指着。
|
||||
*
|
||||
* @param previousPending 该对象当前那条待审记录,可为 null
|
||||
* @return 需要更新为已作废的记录;无需更新时返回 null
|
||||
*/
|
||||
public static TransportCertRecord supersedeIfPending(TransportCertRecord previousPending) {
|
||||
if (previousPending == null) {
|
||||
return null;
|
||||
}
|
||||
if (!TransportCertRecord.STATUS_PENDING.equals(previousPending.getStatus())) {
|
||||
return null;
|
||||
}
|
||||
previousPending.setStatus(TransportCertRecord.STATUS_SUPERSEDED);
|
||||
return previousPending;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定认证对象是否已认证
|
||||
* <p>
|
||||
* 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」,
|
||||
* 重新提交、待审、被驳回都不会让对象变回未认证。
|
||||
*
|
||||
* @param effectiveRecordId 生效快照指针
|
||||
* @return 是否已认证
|
||||
*/
|
||||
public static boolean isCertified(Long effectiveRecordId) {
|
||||
return Func.isNotEmpty(effectiveRecordId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定一条记录是否可被审核
|
||||
* <p>
|
||||
* 只有待审记录可审。已通过/已驳回/已作废都是终态,再次审核会把生效快照改坏
|
||||
* (例如把指针指着的那条「已通过」改成「已驳回」,生效值就没了)。
|
||||
*
|
||||
* @param record 认证记录
|
||||
* @return 是否可审核
|
||||
*/
|
||||
public static boolean isAuditable(TransportCertRecord record) {
|
||||
return record != null && TransportCertRecord.STATUS_PENDING.equals(record.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验审核入参是否合法
|
||||
*
|
||||
* @param pass 是否通过
|
||||
* @param rejectReason 驳回原因
|
||||
* @return 是否合法
|
||||
*/
|
||||
public static boolean isRejectReasonValid(boolean pass, String rejectReason) {
|
||||
if (pass) {
|
||||
return true;
|
||||
}
|
||||
String reason = Func.toStr(rejectReason, "").trim();
|
||||
return Func.isNotEmpty(reason)
|
||||
&& reason.length() <= TransportCertRecord.REJECT_REASON_MAX_LENGTH;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核通过时的驳回原因:必须为 null,避免残留上一次的驳回文案
|
||||
*
|
||||
* @return 恒为 null
|
||||
*/
|
||||
public static String reasonForApprove() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核驳回时的驳回原因:去首尾空白后落库
|
||||
*
|
||||
* @param rejectReason 原始原因
|
||||
* @return 去空白后的原因
|
||||
*/
|
||||
public static String reasonForReject(String rejectReason) {
|
||||
return Func.toStr(rejectReason, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态对应的中文文案
|
||||
* <p>
|
||||
* 四端(小程序、Web、接口、库注释)必须用同一套说法,不要各造一套。
|
||||
*
|
||||
* @param status 状态值
|
||||
* @return 中文文案
|
||||
*/
|
||||
public static String statusText(Integer status) {
|
||||
if (TransportCertRecord.STATUS_PENDING.equals(status)) {
|
||||
return STATUS_TEXT_PENDING;
|
||||
}
|
||||
if (TransportCertRecord.STATUS_APPROVED.equals(status)) {
|
||||
return STATUS_TEXT_APPROVED;
|
||||
}
|
||||
if (TransportCertRecord.STATUS_REJECTED.equals(status)) {
|
||||
return STATUS_TEXT_REJECTED;
|
||||
}
|
||||
if (TransportCertRecord.STATUS_SUPERSEDED.equals(status)) {
|
||||
return STATUS_TEXT_SUPERSEDED;
|
||||
}
|
||||
return STATUS_TEXT_UNKNOWN;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验认证对象类型
|
||||
*
|
||||
* @param bizType 类型
|
||||
* @return 是否为合法类型
|
||||
*/
|
||||
public static boolean isValidBizType(String bizType) {
|
||||
return TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)
|
||||
|| TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType);
|
||||
}
|
||||
|
||||
}
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 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 com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 把百度 OCR 的原始结果归一化成业务字段
|
||||
* <p>
|
||||
* 百度返回的 {@code words_result} 键是**中文**(「号牌号码」「车辆识别代码」…),
|
||||
* 同一含义在纸质证与电子证照之间还有不同写法。让小程序去认识这些中文键,
|
||||
* 等于把百度接口的形状泄漏到前端,接口一改就要动多个端。
|
||||
* <p>
|
||||
* 归一化后下发的键与业务字段同名({@code plateNo}、{@code ownerName}…),前端拿到即可回填;
|
||||
* 原始结果仍一并下发,归一化没覆盖到的字段不至于丢失。
|
||||
* <p>
|
||||
* <b>必须做别名容错的原因</b>:官方文档列举的是「号牌号码」「车辆识别代码」,
|
||||
* 而实际返回里也出现过「车牌号码」「车辆识别代号」这类写法,电子行驶证还多出
|
||||
* 「强制报废期止」「能源种类」等字段。写死单个键名的后果是**识别成功但什么也没填**——
|
||||
* 不报错、不抛异常,最难排查。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class OcrFieldSupport {
|
||||
|
||||
/**
|
||||
* {@code words_result} 里每个条目形如 {@code {"words": "桂A12345"}}
|
||||
*/
|
||||
private static final String WORDS = "words";
|
||||
|
||||
/**
|
||||
* 表示「长期有效」的词:命中后不产出日期,而把对应的长期有效标志置 1
|
||||
*/
|
||||
private static final String LONG_TERM_TEXT = "长期";
|
||||
|
||||
/**
|
||||
* 日期写法:4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(年月日 / 点 / 横线)
|
||||
*/
|
||||
private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})");
|
||||
|
||||
private OcrFieldSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 按证件类型把原始结果归一化
|
||||
*
|
||||
* @param type 证件类型
|
||||
* @param side 证件面:front / back(目前两个面共用一套字段别名)
|
||||
* @param result 百度原始返回
|
||||
* @return 归一化字段(键与业务字段同名);识别不到任何字段时返回空 Map
|
||||
*/
|
||||
public static Map<String, Object> extract(BaiduOcrType type, String side, JsonNode result) {
|
||||
Map<String, String> words = readWords(result);
|
||||
Map<String, Object> fields = new LinkedHashMap<>();
|
||||
if (words.isEmpty()) {
|
||||
return fields;
|
||||
}
|
||||
for (Map.Entry<String, List<String>> entry : aliasTable(type).entrySet()) {
|
||||
String raw = firstMatch(words, entry.getValue());
|
||||
if (Func.isEmpty(raw)) {
|
||||
continue;
|
||||
}
|
||||
putField(fields, entry.getKey(), raw);
|
||||
}
|
||||
// 外廓尺寸是唯一「一项要拆成三个字段」的,单独处理
|
||||
splitOuterSize(words, fields);
|
||||
// 道路运输证的「有效期」常写成一个区间:止期已由上面取到最后一段,
|
||||
// 起期要从同一段文本里补,否则用户得照着一张图再敲一遍
|
||||
splitRangeStart(words, fields, "有效期", "roadTransportCertStartDate");
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从「有效期」这类区间文本里取出起期,补进对应的起期字段
|
||||
*
|
||||
* @param words 识别结果
|
||||
* @param fields 结果集
|
||||
* @param alias 区间所在的键名
|
||||
* @param startKey 要补的起期字段名
|
||||
*/
|
||||
private static void splitRangeStart(Map<String, String> words, Map<String, Object> fields,
|
||||
String alias, String startKey) {
|
||||
if (fields.containsKey(startKey)) {
|
||||
return;
|
||||
}
|
||||
String text = firstMatch(words, List.of(alias));
|
||||
if (Func.isEmpty(text)) {
|
||||
return;
|
||||
}
|
||||
List<String> dates = matchDates(text);
|
||||
// 至少两段才算区间:只有一段说明那是止期本身,硬当起期会填错
|
||||
if (dates.size() >= 2) {
|
||||
fields.put(startKey, dates.get(0));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 {@code words_result} 成「中文键 → 文本值」
|
||||
*
|
||||
* @param result 百度原始返回
|
||||
* @return 键值对;结构不符时返回空 Map
|
||||
*/
|
||||
private static Map<String, String> readWords(JsonNode result) {
|
||||
Map<String, String> words = new LinkedHashMap<>();
|
||||
if (result == null) {
|
||||
return words;
|
||||
}
|
||||
JsonNode wordsResult = result.path("words_result");
|
||||
if (!wordsResult.isObject()) {
|
||||
return words;
|
||||
}
|
||||
wordsResult.fields().forEachRemaining(entry -> {
|
||||
JsonNode value = entry.getValue();
|
||||
if (value == null || value.isNull()) {
|
||||
return;
|
||||
}
|
||||
// 可能是 {"words": "..."},也可能直接是字符串
|
||||
String text = value.isObject() ? value.path(WORDS).asText("") : value.asText("");
|
||||
if (Func.isNotEmpty(text)) {
|
||||
words.put(entry.getKey(), text.trim());
|
||||
}
|
||||
});
|
||||
return words;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取第一个命中的别名值
|
||||
*
|
||||
* @param words 识别结果
|
||||
* @param aliases 候选键名,按优先级排列
|
||||
* @return 命中值;都没命中返回 null
|
||||
*/
|
||||
private static String firstMatch(Map<String, String> words, List<String> aliases) {
|
||||
for (String alias : aliases) {
|
||||
String value = words.get(alias);
|
||||
if (Func.isNotEmpty(value)) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
// 精确键名没命中时按「包含」兜底:百度偶有「核定载质量(kg)」这类带后缀的键
|
||||
for (String alias : aliases) {
|
||||
for (Map.Entry<String, String> entry : words.entrySet()) {
|
||||
if (entry.getKey().contains(alias) && Func.isNotEmpty(entry.getValue())) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入一个字段,按字段语义做值归一化
|
||||
* <p>
|
||||
* 数字字段转数字、日期字段转 {@code yyyy-MM-dd};「长期」不产出日期,
|
||||
* 而是把对应的长期有效标志置 1——否则「长期」会被当成一个非法日期塞进日期框。
|
||||
*
|
||||
* @param fields 结果集
|
||||
* @param key 业务字段名
|
||||
* @param raw 识别到的原始文本
|
||||
*/
|
||||
private static void putField(Map<String, Object> fields, String key, String raw) {
|
||||
String text = raw.trim();
|
||||
if (isDateField(key)) {
|
||||
if (text.contains(LONG_TERM_TEXT)) {
|
||||
String longTermKey = longTermKeyOf(key);
|
||||
if (longTermKey != null) {
|
||||
fields.put(longTermKey, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
String date = normalizeDate(text, key);
|
||||
if (date != null) {
|
||||
fields.put(key, date);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (isNumberField(key)) {
|
||||
Integer number = normalizeNumber(text);
|
||||
if (number != null) {
|
||||
fields.put(key, number);
|
||||
}
|
||||
return;
|
||||
}
|
||||
fields.put(key, text);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「外廓尺寸」一项拆成外廓长/宽/高三个字段
|
||||
* <p>
|
||||
* 形如 {@code 5990×2300×3100},分隔符有 ×、x、*、空格等多种写法。
|
||||
*
|
||||
* @param words 识别结果
|
||||
* @param fields 结果集
|
||||
*/
|
||||
private static void splitOuterSize(Map<String, String> words, Map<String, Object> fields) {
|
||||
String size = firstMatch(words, List.of("外廓尺寸"));
|
||||
if (Func.isEmpty(size)) {
|
||||
return;
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
for (String part : size.split("[^0-9]+")) {
|
||||
if (Func.isNotEmpty(part)) {
|
||||
parts.add(part);
|
||||
}
|
||||
}
|
||||
// 必须正好三段才写回:只识别到两段时多半是漏识别,
|
||||
// 按顺序硬填会把宽度填成长度、高度填成宽度
|
||||
if (parts.size() != 3) {
|
||||
return;
|
||||
}
|
||||
String[] keys = {"outerLength", "outerWidth", "outerHeight"};
|
||||
for (int index = 0; index < keys.length; index++) {
|
||||
Integer number = normalizeNumber(parts.get(index));
|
||||
if (number != null) {
|
||||
fields.put(keys[index], number);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 各证件类型的字段别名表
|
||||
*
|
||||
* @param type 证件类型
|
||||
* @return 「业务字段名 → 百度键名候选」
|
||||
*/
|
||||
private static Map<String, List<String>> aliasTable(BaiduOcrType type) {
|
||||
if (type == null) {
|
||||
return Map.of();
|
||||
}
|
||||
String name = type.name();
|
||||
if (BaiduOcrType.ID_CARD.name().equals(name)) {
|
||||
return idCardAliases();
|
||||
}
|
||||
if (BaiduOcrType.DRIVING_LICENSE.name().equals(name)) {
|
||||
return drivingLicenseAliases();
|
||||
}
|
||||
if (BaiduOcrType.VEHICLE_LICENSE.name().equals(name)) {
|
||||
return vehicleLicenseAliases();
|
||||
}
|
||||
if (BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE.name().equals(name)) {
|
||||
return roadTransportAliases();
|
||||
}
|
||||
return Map.of();
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> idCardAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("realName", List.of("姓名"));
|
||||
aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码"));
|
||||
aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起"));
|
||||
aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> drivingLicenseAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("driverFileNo", List.of("档案编号", "证号"));
|
||||
aliases.put("driverClass", List.of("准驾车型"));
|
||||
aliases.put("driverValidStart", List.of("有效起始日期", "有效期起", "初次领证日期"));
|
||||
aliases.put("driverValidEnd", List.of("有效期限", "有效期止", "有效期限止"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> vehicleLicenseAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("plateNo", List.of("号牌号码", "车牌号码", "号牌"));
|
||||
aliases.put("vehicleType", List.of("车辆类型"));
|
||||
aliases.put("ownerName", List.of("所有人"));
|
||||
aliases.put("vin", List.of("车辆识别代码", "车辆识别代号", "车架号", "VIN"));
|
||||
aliases.put("drivingLicenseNo", List.of("档案编号", "行驶证档案编号"));
|
||||
aliases.put("plateColor", List.of("号牌颜色", "车牌颜色"));
|
||||
aliases.put("energyType", List.of("能源种类", "能源类型"));
|
||||
aliases.put("approvedLoadKg", List.of("核定载质量"));
|
||||
aliases.put("tractionMassKg", List.of("准牵引总质量"));
|
||||
aliases.put("compulsoryScrapDate", List.of("强制报废期止", "强制报废日期"));
|
||||
aliases.put("drivingLicenseStartDate", List.of("注册日期", "发证日期"));
|
||||
aliases.put("drivingLicenseEndDate", List.of("检验有效期止", "有效期止"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static Map<String, List<String>> roadTransportAliases() {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("roadTransportCertNo", List.of("道路运输证号", "运输证号"));
|
||||
aliases.put("ownerName", List.of("业户名称", "所有人"));
|
||||
aliases.put("roadTransportCertEndDate", List.of("有效期", "有效期止", "有效期至"));
|
||||
return aliases;
|
||||
}
|
||||
|
||||
private static boolean isDateField(String key) {
|
||||
return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
}
|
||||
|
||||
private static boolean isNumberField(String key) {
|
||||
return "approvedLoadKg".equals(key) || "tractionMassKg".equals(key);
|
||||
}
|
||||
|
||||
private static String longTermKeyOf(String dateKey) {
|
||||
if ("compulsoryScrapDate".equals(dateKey)) {
|
||||
return "compulsoryScrapLongTerm";
|
||||
}
|
||||
if ("drivingLicenseEndDate".equals(dateKey)) {
|
||||
return "drivingLicenseLongTerm";
|
||||
}
|
||||
if ("roadTransportCertEndDate".equals(dateKey)) {
|
||||
return "roadTransportCertLongTerm";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数字归一化:去掉「kg」「毫米」等单位与千分位后取整
|
||||
*
|
||||
* @param text 识别文本
|
||||
* @return 整数;取不到合理数字返回 null
|
||||
*/
|
||||
private static Integer normalizeNumber(String text) {
|
||||
String digits = text.replaceAll("[^0-9]", "");
|
||||
// 载质量、外廓尺寸最多 6 位;过长说明识别串了字段,宁可不填也不填错
|
||||
if (digits.isEmpty() || digits.length() > 6) {
|
||||
return null;
|
||||
}
|
||||
return Integer.valueOf(digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* 日期归一化
|
||||
* <p>
|
||||
* 覆盖 {@code 2015年03月12日} / {@code 2015.3.9} / {@code 2015-03-12} / {@code 20150312}
|
||||
* 这几种写法,统一成 {@code yyyy-MM-dd}。
|
||||
* <p>
|
||||
* 区间(如「2015.03.12-2035.03.12」「2020-01-01 至 2030-01-01」)**按字段语义取一端**:
|
||||
* 以 Start 结尾的取第一段,其余(止期类)取最后一段。取错一端不会报错,
|
||||
* 只会把有效期起填成止——比不填更难发现。
|
||||
*
|
||||
* @param text 识别文本
|
||||
* @param key 业务字段名,用于判断该取区间的哪一端
|
||||
* @return 归一化日期;无法识别返回 null
|
||||
*/
|
||||
private static String normalizeDate(String text, String key) {
|
||||
List<String> dates = matchDates(text);
|
||||
if (!dates.isEmpty()) {
|
||||
return key.endsWith("Start") ? dates.get(0) : dates.get(dates.size() - 1);
|
||||
}
|
||||
String compact = text.replaceAll("[^0-9]", "");
|
||||
if (compact.length() == 8) {
|
||||
return formatDate(compact);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出文本里所有日期(按出现顺序)
|
||||
*
|
||||
* @param text 识别文本
|
||||
* @return 归一化后的日期列表
|
||||
*/
|
||||
private static List<String> matchDates(String text) {
|
||||
List<String> dates = new ArrayList<>();
|
||||
Matcher matcher = DATE_PATTERN.matcher(text);
|
||||
while (matcher.find()) {
|
||||
dates.add(String.format("%s-%02d-%02d", matcher.group(1),
|
||||
Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3))));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
private static String formatDate(String digits) {
|
||||
return digits.substring(0, 4) + "-" + digits.substring(4, 6) + "-" + digits.substring(6, 8);
|
||||
}
|
||||
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* 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 org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 证件类型映射:小程序命名 → 百度 OCR 证件类型
|
||||
* <p>
|
||||
* ⚠️ 两侧命名对「行驶证」和「驾驶证」的用词正好相反,是本类存在的主要理由:
|
||||
* <table border="1">
|
||||
* <caption>命名对照</caption>
|
||||
* <tr><th>证件</th><th>小程序传</th><th>后端 OCR 用</th></tr>
|
||||
* <tr><td>驾驶证</td><td>{@code driver}</td><td>{@code driving_license}</td></tr>
|
||||
* <tr><td>行驶证</td><td>{@code vehicle}</td><td>{@code vehicle_license}</td></tr>
|
||||
* </table>
|
||||
* 若把 {@code vehicle} 直接透传给百度按 {@code driving_license} 处理,或反之,
|
||||
* 调用方不会收到任何错误——拿到的是另一种证件的字段,问题只在人工核对时才发现。
|
||||
* 故此处用显式白名单逐一对应,而不是把请求参数直接丢给下游。
|
||||
* <p>
|
||||
* 采用白名单而非「透传 + 兜底为通用识别」还有一个原因:
|
||||
* 从业资格证没有对应的百度接口,静默降级成通用文字识别会让前端以为识别成功。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class OcrTypeMapping {
|
||||
|
||||
/**
|
||||
* 允许的证件类型:小程序命名与后端既有命名都收,其余一律拒绝
|
||||
*/
|
||||
private static final Map<String, BaiduOcrType> TYPE_ALIASES = Map.ofEntries(
|
||||
// 身份证
|
||||
Map.entry("IDCARD", BaiduOcrType.ID_CARD),
|
||||
Map.entry("ID_CARD", BaiduOcrType.ID_CARD),
|
||||
// 驾驶证:小程序叫 driver
|
||||
Map.entry("DRIVER", BaiduOcrType.DRIVING_LICENSE),
|
||||
Map.entry("DRIVING_LICENSE", BaiduOcrType.DRIVING_LICENSE),
|
||||
// 行驶证:小程序叫 vehicle
|
||||
Map.entry("VEHICLE", BaiduOcrType.VEHICLE_LICENSE),
|
||||
Map.entry("VEHICLE_LICENSE", BaiduOcrType.VEHICLE_LICENSE),
|
||||
// 道路运输证
|
||||
Map.entry("ROAD_TRANSPORT", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE),
|
||||
Map.entry("ROAD_TRANSPORT_CERTIFICATE", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE)
|
||||
);
|
||||
|
||||
/**
|
||||
* 明确知道「没有对应 OCR 能力」的类型,单独给出可读提示
|
||||
*/
|
||||
private static final Map<String, String> KNOWN_UNSUPPORTED = Map.of(
|
||||
"QUALIFICATION", "从业资格证"
|
||||
);
|
||||
|
||||
private static final String SIDE_FRONT = "front";
|
||||
private static final String SIDE_BACK = "back";
|
||||
|
||||
private OcrTypeMapping() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把调用方传来的证件类型转成百度 OCR 证件类型
|
||||
*
|
||||
* @param type 证件类型:小程序传 idcard / driver / vehicle / road_transport / qualification,
|
||||
* 也接受后端既有命名 id_card / driving_license / vehicle_license / road_transport_certificate
|
||||
* @return 百度 OCR 证件类型
|
||||
* @throws IllegalArgumentException 类型为空、未知,或该证件不支持 OCR
|
||||
*/
|
||||
public static BaiduOcrType toBaiduType(String type) {
|
||||
String normalized = normalize(type);
|
||||
if (normalized.isEmpty()) {
|
||||
throw new IllegalArgumentException("OCR证件类型不能为空");
|
||||
}
|
||||
BaiduOcrType baiduOcrType = TYPE_ALIASES.get(normalized);
|
||||
if (baiduOcrType != null) {
|
||||
return baiduOcrType;
|
||||
}
|
||||
String unsupportedName = KNOWN_UNSUPPORTED.get(normalized);
|
||||
if (unsupportedName != null) {
|
||||
// 不降级为通用识别:否则前端会拿到一堆通用文本却以为识别成功
|
||||
throw new IllegalArgumentException(unsupportedName + "暂不支持自动识别,请手动填写");
|
||||
}
|
||||
throw new IllegalArgumentException("不支持的OCR证件类型:" + type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化正反面参数
|
||||
* <p>
|
||||
* 小程序不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。
|
||||
* <p>
|
||||
* ⚠️ 调用方**必须原样把返回值交给下游,不要在传参前自行补默认值**:
|
||||
* 这里的「默认正面」只对**支持正副面**的证件成立。道路运输证没有正副面参数,
|
||||
* 若在调用方先把空值填成 {@code front} 再传下去,下游会看到「传了 side 但该证件
|
||||
* 不支持」而直接报错——本方法返回 null 才是「调用方没指定」,下游据此才分得清
|
||||
* 「默认取正面」和「这个证件根本不该带这个参数」。
|
||||
*
|
||||
* @param side 正反面:front / back
|
||||
* @return 规范化结果 front / back;调用方未指定时返回 null
|
||||
*/
|
||||
public static String normalizeSide(String side) {
|
||||
String normalized = normalize(side);
|
||||
if (normalized.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return SIDE_BACK.equals(normalized.toLowerCase(Locale.ROOT)) ? SIDE_BACK : SIDE_FRONT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 归一化:去首尾空白、去分隔符差异、统一大写
|
||||
*
|
||||
* @param value 原始值
|
||||
* @return 归一化结果;空值返回空串
|
||||
*/
|
||||
private static String normalize(String value) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
return value.trim()
|
||||
.replace('-', '_')
|
||||
.replace(' ', '_')
|
||||
.toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
}
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 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 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;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 司机档案与车辆表之间的车牌软关联支持类
|
||||
* <p>
|
||||
* 司机「驾驶车辆」与车辆表「车牌号」之间**没有外键**,只有文本关联,
|
||||
* 且库中实际存在写法差异:间隔符(·、•、.)、横线、空格、大小写不一致,
|
||||
* 以及一个字段里用逗号/顿号/分号/斜杠/竖线并列多辆车。
|
||||
* 认证提交时的「只能操作本人绑定车辆」校验依赖本类,因此匹配规则集中在此处维护,
|
||||
* 避免各业务各自实现一套导致口径分叉。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public final class VehiclePlateSupport {
|
||||
|
||||
/**
|
||||
* 并列车牌的常见分隔符(中英文顿号、逗号、分号、斜杠、竖线)
|
||||
*/
|
||||
private static final String SEPARATOR_PATTERN = "[,,、;;/|]";
|
||||
|
||||
/**
|
||||
* 车牌中需要剔除的排版字符(各类空白、横线与间隔符)
|
||||
*/
|
||||
private static final String NOISE_PATTERN = "[\\s\\-·•..]";
|
||||
|
||||
private VehiclePlateSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 把「驾驶车辆」字段拆成规范化后的车牌列表(已去重、保持原顺序)
|
||||
*
|
||||
* @param drivingVehicle 司机档案里的驾驶车辆字段,可能并列多辆
|
||||
* @return 规范化车牌列表;空值返回空列表
|
||||
*/
|
||||
public static List<String> splitPlates(String drivingVehicle) {
|
||||
if (Func.isEmpty(drivingVehicle)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
String normalized = drivingVehicle.replaceAll(SEPARATOR_PATTERN, ",");
|
||||
Set<String> plates = new LinkedHashSet<>();
|
||||
for (String part : Func.toStrList(",", normalized)) {
|
||||
String plate = normalize(part);
|
||||
if (Func.isNotEmpty(plate)) {
|
||||
plates.add(plate);
|
||||
}
|
||||
}
|
||||
return new ArrayList<>(plates);
|
||||
}
|
||||
|
||||
/**
|
||||
* 车牌规范化:去掉排版字符并转大写,使不同写法能比对
|
||||
*
|
||||
* @param plateNo 原始车牌
|
||||
* @return 规范化结果;空值返回空串
|
||||
*/
|
||||
public static String normalize(String plateNo) {
|
||||
if (Func.isEmpty(plateNo)) {
|
||||
return "";
|
||||
}
|
||||
return plateNo.trim().replaceAll(NOISE_PATTERN, "").toUpperCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定某车牌是否属于该司机绑定的车辆
|
||||
* <p>
|
||||
* 空值一律判为「不绑定」——不能因为双方都为空就放行,那等于越权。
|
||||
*
|
||||
* @param drivingVehicle 司机档案里的驾驶车辆字段
|
||||
* @param plateNo 待判定的车牌
|
||||
* @return 是否绑定
|
||||
*/
|
||||
public static boolean isBound(String drivingVehicle, String plateNo) {
|
||||
String target = normalize(plateNo);
|
||||
if (Func.isEmpty(target)) {
|
||||
return false;
|
||||
}
|
||||
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>
|
||||
* ⚠️ 只做精确匹配是不够的。{@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)"
|
||||
+ ",'·',''),'•',''),'.',''),'.',''),'-',''),' ','')";
|
||||
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 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.wrapper;
|
||||
|
||||
import org.springblade.core.mp.support.BaseEntityWrapper;
|
||||
import org.springblade.core.tool.utils.BeanUtil;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
|
||||
import org.springblade.transport.support.CertificationRuleSupport;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 认证记录包装类
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
public class TransportCertRecordWrapper extends BaseEntityWrapper<TransportCertRecord, TransportCertRecordVO> {
|
||||
|
||||
public static TransportCertRecordWrapper build() {
|
||||
return new TransportCertRecordWrapper();
|
||||
}
|
||||
|
||||
@Override
|
||||
public TransportCertRecordVO entityVO(TransportCertRecord transportCertRecord) {
|
||||
TransportCertRecordVO transportCertRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(transportCertRecord, TransportCertRecordVO.class));
|
||||
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(transportCertRecordVO.getStatus()));
|
||||
return transportCertRecordVO;
|
||||
}
|
||||
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package org.springblade.transport;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
/**
|
||||
* 测试基础设施自检:确认 JUnit 5 能在本模块被发现并执行。
|
||||
*/
|
||||
class TestInfraSmokeTest {
|
||||
|
||||
@Test
|
||||
void junit5Runs() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
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.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;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 管理端认证审核测试。
|
||||
* <p>
|
||||
* 守住两条最容易写坏的不变式:
|
||||
* 驳回**不得改变**生效快照;已终态记录**不可再审**(再审会把生效快照改坏)。
|
||||
*/
|
||||
class CertAuditServiceImplTest {
|
||||
|
||||
private static final Long RECORD_ID = 5001L;
|
||||
private static final Long VEHICLE_ID = 8001L;
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
|
||||
private ITransportCertRecordService certRecordService;
|
||||
private TransportCertRecordMapper certRecordMapper;
|
||||
private ITransportVehicleService transportVehicleService;
|
||||
private IDriverService driverService;
|
||||
private CertAuditServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
certRecordService = mock(ITransportCertRecordService.class);
|
||||
certRecordMapper = mock(TransportCertRecordMapper.class);
|
||||
transportVehicleService = mock(ITransportVehicleService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
service = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 通过
|
||||
|
||||
@Test
|
||||
@DisplayName("通过:记录转已通过,并把该对象的生效快照指针切到它")
|
||||
void approveSwitchesEffectivePointer() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(recordCaptor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_APPROVED, recordCaptor.getValue().getStatus());
|
||||
assertNull(recordCaptor.getValue().getRejectReason(), "通过时必须清空驳回原因");
|
||||
assertNotNull(recordCaptor.getValue().getAuditTime(), "审核时间必须留痕");
|
||||
assertNotNull(recordCaptor.getValue().getAuditUser(), "审核人必须留痕");
|
||||
|
||||
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(vehicleCaptor.capture());
|
||||
assertEquals(RECORD_ID, vehicleCaptor.getValue().getEffectiveCertRecordId(),
|
||||
"通过后指针应指向该记录");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过车辆认证:把快照的证件内容写回车辆档案,让「档案 = 生效值」成立")
|
||||
void approveWritesSnapshotBackToVehicle() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
TransportVehicle patched = captor.getValue();
|
||||
assertEquals("桂物物流集团", patched.getOwnerName(), "所有人要跟着生效值更新");
|
||||
assertEquals("管理挂靠", patched.getBusinessRelation());
|
||||
assertEquals(18000, patched.getApprovedLoadKg());
|
||||
assertEquals(RECORD_ID, patched.getEffectiveCertRecordId(), "内容与指针必须是同一次 UPDATE");
|
||||
assertNull(patched.getPlateNo(), "车牌是车辆身份,不是本次提交能改的内容");
|
||||
assertNull(patched.getOrganizationName(), "所属组织由管理端维护,不该被提交内容覆盖");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过车辆认证:快照是改造前的旧格式时明确失败,不能「通过但什么也没写」")
|
||||
void approveRejectsLegacySnapshot() {
|
||||
// 旧快照的键名与当前字段定义不同,会解析成「全是 null」而不报错——最危险的一种情况
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING,
|
||||
"{\"licenseNo\":\"450100123456\",\"licenseFrontUrl\":\"http://oss/old.jpg\"}");
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.audit(RECORD_ID, true, null));
|
||||
assertTrue(exception.getMessage().contains("字段定义不匹配"), exception.getMessage());
|
||||
verify(transportVehicleService, never()).updateById(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过:切换指针而不是追加,同对象仍只有一条生效快照")
|
||||
void approveReplacesPointerInsteadOfAppending() {
|
||||
Long oldEffectiveId = 111L;
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId());
|
||||
// 只更新了这一个字段所在的车辆行,没有新增任何"生效标记"行
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过:同步刷新展示缓存为已通过")
|
||||
void approveRefreshesDisplayCache() {
|
||||
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<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_APPROVED, captor.getValue().getCertificationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过司机认证:指针写到司机表")
|
||||
void approveDriverSwitchesDriverPointer() {
|
||||
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverService).updateById(captor.capture());
|
||||
assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("通过司机认证:patch 必须带上车辆绑定,不得把 driving_vehicle 清空")
|
||||
void approveDriverKeepsDrivingVehicle() {
|
||||
TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
Driver existing = driver(null);
|
||||
existing.setDrivingVehicle("桂A12345,桂B67890");
|
||||
when(driverService.getById(DRIVER_ID)).thenReturn(existing);
|
||||
|
||||
service.audit(RECORD_ID, true, null);
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverService).updateById(captor.capture());
|
||||
// driving_vehicle 标了 FieldStrategy.ALWAYS,patch 里为 null 就会把该列写成 NULL;
|
||||
// 而司机端车辆列表正是按这一列匹配的,清空即「暂无绑定车辆」且界面上无法恢复
|
||||
assertEquals("桂A12345,桂B67890", captor.getValue().getDrivingVehicle(),
|
||||
"审核通过不得把司机的车辆绑定清空");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 驳回
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:记录转已驳回并留原因,但**生效快照与可用性不变**(核心不变式)")
|
||||
void rejectKeepsEffectivePointer() {
|
||||
Long existingEffectiveId = 777L;
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
|
||||
|
||||
service.audit(RECORD_ID, false, "证件照片模糊");
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(recordCaptor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_REJECTED, recordCaptor.getValue().getStatus());
|
||||
assertEquals("证件照片模糊", recordCaptor.getValue().getRejectReason());
|
||||
|
||||
// 展示缓存可以更新,但**指针绝不能被改动**
|
||||
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(vehicleCaptor.capture());
|
||||
assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(),
|
||||
"驳回不得修改生效快照指针,否则已认证的车会被无辜下线");
|
||||
assertEquals(TransportCertRecord.STATUS_REJECTED, vehicleCaptor.getValue().getCertificationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:已认证的对象被驳回后,生效快照指针仍指向原来那条")
|
||||
void rejectNeverClearsExistingPointer() {
|
||||
Long existingEffectiveId = 777L;
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
|
||||
|
||||
service.audit(RECORD_ID, false, "运输证过期");
|
||||
|
||||
// 指针字段没有被写成 null,也没有被写成新记录
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
assertNull(captor.getValue().getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:不填原因必须拒绝")
|
||||
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, " "));
|
||||
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:原因超过 200 字必须拒绝(与列宽一致)")
|
||||
void rejectReasonTooLong() {
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.audit(RECORD_ID, false, "长".repeat(201)));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 终态保护
|
||||
|
||||
@Test
|
||||
@DisplayName("终态不可再审:已通过/已驳回/已作废一律拒绝")
|
||||
void terminalRecordsCannotBeAuditedAgain() {
|
||||
for (Integer terminal : new Integer[]{
|
||||
TransportCertRecord.STATUS_APPROVED,
|
||||
TransportCertRecord.STATUS_REJECTED,
|
||||
TransportCertRecord.STATUS_SUPERSEDED}) {
|
||||
TransportCertRecord record = vehicleRecord(terminal);
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null),
|
||||
"状态 " + terminal + " 属终态,不应可再审");
|
||||
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"),
|
||||
"状态 " + terminal + " 属终态,不应可再审");
|
||||
}
|
||||
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录不存在:拒绝")
|
||||
void recordNotFound() {
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(null);
|
||||
assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录主键为空:拒绝")
|
||||
void recordIdRequired() {
|
||||
assertThrows(ServiceException.class, () -> service.audit(null, true, null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 审核详情(diff 用)
|
||||
|
||||
@Test
|
||||
@DisplayName("审核详情:同时给出待审快照与当前生效值,供前端做 diff")
|
||||
void detailProvidesBothSides() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
record.setSnapshotJson("{\"vin\":\"NEW\"}");
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
|
||||
TransportCertRecord oldEffective = new TransportCertRecord();
|
||||
oldEffective.setId(777L);
|
||||
oldEffective.setSnapshotJson("{\"vin\":\"OLD\"}");
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L));
|
||||
when(certRecordService.getById(777L)).thenReturn(oldEffective);
|
||||
|
||||
CertAuditDetailVO detail = service.detail(RECORD_ID);
|
||||
|
||||
assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson());
|
||||
assertEquals("{\"vin\":\"OLD\"}", detail.getEffectiveSnapshotJson(),
|
||||
"审核页需要旧生效值才能看出司机改了什么");
|
||||
assertEquals(777L, detail.getEffectiveRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核详情:从未认证过时,生效值一侧为 null(而不是报错)")
|
||||
void detailWithoutEffective() {
|
||||
TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
|
||||
record.setSnapshotJson("{\"vin\":\"NEW\"}");
|
||||
when(certRecordService.getById(RECORD_ID)).thenReturn(record);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
|
||||
|
||||
CertAuditDetailVO detail = service.detail(RECORD_ID);
|
||||
|
||||
assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson());
|
||||
assertNull(detail.getEffectiveSnapshotJson());
|
||||
assertNull(detail.getEffectiveRecordId());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 辅助
|
||||
|
||||
private TransportCertRecord vehicleRecord(Integer status) {
|
||||
return vehicleRecord(status, completeVehicleSnapshot());
|
||||
}
|
||||
|
||||
private TransportCertRecord vehicleRecord(Integer status, String snapshotJson) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(RECORD_ID);
|
||||
record.setBizType(TransportCertRecord.BIZ_TYPE_VEHICLE);
|
||||
record.setBizId(VEHICLE_ID);
|
||||
record.setBizKey("桂A32523");
|
||||
record.setStatus(status);
|
||||
record.setSnapshotJson(snapshotJson);
|
||||
return record;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份字段齐全的车辆认证快照
|
||||
* <p>
|
||||
* 审核通过时会把快照内容写回车辆档案,故用例不能再拿空快照走通过流程;
|
||||
* 关键字段(档案编号、核定载质量)是「这份快照是否为当前格式」的判据。
|
||||
*/
|
||||
static String completeVehicleSnapshot() {
|
||||
return "{\"drivingLicenseNo\":\"450100123456\",\"approvedLoadKg\":18000,"
|
||||
+ "\"ownerName\":\"桂物物流集团\",\"businessRelation\":\"管理挂靠\","
|
||||
+ "\"vehicleType\":\"重型货车\",\"driveLicenseImage\":\"http://oss/xsz.jpg\"}";
|
||||
}
|
||||
|
||||
private TransportCertRecord driverRecord(Integer status) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(RECORD_ID);
|
||||
record.setBizType(TransportCertRecord.BIZ_TYPE_DRIVER);
|
||||
record.setBizId(DRIVER_ID);
|
||||
record.setBizKey("张三");
|
||||
record.setStatus(status);
|
||||
record.setSnapshotJson("{}");
|
||||
return record;
|
||||
}
|
||||
|
||||
private TransportVehicle vehicle(Long effectiveId) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(VEHICLE_ID);
|
||||
vehicle.setPlateNo("桂A32523");
|
||||
vehicle.setEffectiveCertRecordId(effectiveId);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
private Driver driver(Long effectiveId) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
driver.setDriverName("张三");
|
||||
driver.setEffectiveCertRecordId(effectiveId);
|
||||
return driver;
|
||||
}
|
||||
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
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.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.system.feign.IUserClient;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机端车辆卡片(重新认证表单的数据来源)测试。
|
||||
* <p>
|
||||
* 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约:
|
||||
* 日期字段只能是 {@code yyyy-MM-dd} 或空串(不能是「长期」这类文字);
|
||||
* 标了长期有效的字段不能再下发一个作废的止期。
|
||||
*/
|
||||
class DriverAppServiceImplTest {
|
||||
|
||||
private static final String MOBILE = "15577122721";
|
||||
private static final Long USER_ID = 7001L;
|
||||
|
||||
private IDriverService driverService;
|
||||
private ITransportVehicleService transportVehicleService;
|
||||
private DriverAppServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
driverService = mock(IDriverService.class);
|
||||
transportVehicleService = mock(ITransportVehicleService.class);
|
||||
service = new DriverAppServiceImpl(driverService, transportVehicleService, mock(IUserClient.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长期有效且没有止期时下发空串,不能下发「长期」——否则它会被当日期填进日期框")
|
||||
void longTermWithoutEndDateSendsEmptyText() {
|
||||
DriverVehicleCardVO card = firstCard(vehicle(LocalDate.of(2022, 8, 17), null, 1));
|
||||
|
||||
assertEquals("", card.getDrivingLicenseEndDate(), "长期有效时日期字段只能是空串");
|
||||
assertEquals(1, card.getDrivingLicenseLongTerm(), "长期有效标志本身要照常下发");
|
||||
assertEquals("2022-08-17", card.getDrivingLicenseStartDate(), "起期有值时照常下发");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长期有效时即使库里还留着止期也不下发:那个止期是上一版证件作废的值")
|
||||
void longTermWithStaleEndDateSendsEmptyText() {
|
||||
DriverVehicleCardVO card = firstCard(vehicle(null, LocalDate.of(2040, 8, 14), 1));
|
||||
|
||||
assertEquals("", card.getDrivingLicenseEndDate());
|
||||
assertEquals("", card.getRoadTransportCertEndDate());
|
||||
assertEquals("", card.getCompulsoryScrapDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未标长期有效时照常下发止期,空值一律是空串而不是 null 字面量")
|
||||
void normalVehicleSendsRealDates() {
|
||||
TransportVehicle vehicle = vehicle(null, LocalDate.of(2030, 8, 14), 0);
|
||||
vehicle.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1));
|
||||
vehicle.setRoadTransportCertEndDate(null);
|
||||
vehicle.setRoadTransportCertLongTerm(0);
|
||||
vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 4));
|
||||
DriverVehicleCardVO card = firstCard(vehicle);
|
||||
|
||||
assertEquals("2030-08-14", card.getDrivingLicenseEndDate());
|
||||
assertEquals("2035-01-01", card.getCompulsoryScrapDate());
|
||||
assertEquals("", card.getRoadTransportCertEndDate(), "没填的止期是空串");
|
||||
assertEquals("2028-12-04", card.getRoadTransportCertStartDate());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 辅助
|
||||
|
||||
/** 造一辆只关心有效期三个字段的车 */
|
||||
private TransportVehicle vehicle(LocalDate licenseStart, LocalDate licenseEnd, Integer licenseLongTerm) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(8001L);
|
||||
vehicle.setPlateNo("桂AAW3255");
|
||||
vehicle.setDrivingLicenseStartDate(licenseStart);
|
||||
vehicle.setDrivingLicenseEndDate(licenseEnd);
|
||||
vehicle.setDrivingLicenseLongTerm(licenseLongTerm);
|
||||
// 三组有效期都按同一个长期有效标志造,便于一次断言三处
|
||||
vehicle.setCompulsoryScrapDate(null);
|
||||
vehicle.setCompulsoryScrapLongTerm(licenseLongTerm);
|
||||
vehicle.setRoadTransportCertStartDate(LocalDate.of(2028, 12, 3));
|
||||
vehicle.setRoadTransportCertEndDate(LocalDate.of(2043, 8, 18));
|
||||
vehicle.setRoadTransportCertLongTerm(licenseLongTerm);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
/** 按当前登录司机的手机号跑一次「我的车辆」,取第一张卡片 */
|
||||
private DriverVehicleCardVO firstCard(TransportVehicle vehicle) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(9001L);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("韦司机");
|
||||
driver.setMobile(MOBILE);
|
||||
driver.setDrivingVehicle(vehicle.getPlateNo());
|
||||
|
||||
when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of(vehicle));
|
||||
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
// 账号本身就是手机号,resolvePhone 走这条短路分支,不再回调用户中心
|
||||
authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE);
|
||||
List<DriverVehicleCardVO> cards = service.myVehicles();
|
||||
assertEquals(1, cards.size(), "应当只返回绑定到本人的这一辆车");
|
||||
return cards.get(0);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+672
@@ -0,0 +1,672 @@
|
||||
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;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.service.IDriverAppService;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
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.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机端认证提交链路测试。
|
||||
* <p>
|
||||
* 重点守住票 02 的三条验收点与不变式:
|
||||
* 提交不得改变生效快照、同对象至多一条待审、越权必须拒绝。
|
||||
*/
|
||||
class DriverCertServiceImplTest {
|
||||
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
private static final Long USER_ID = 7001L;
|
||||
private static final Long VEHICLE_ID = 8001L;
|
||||
private static final String PLATE = "桂A32523";
|
||||
|
||||
private IDriverAppService driverAppService;
|
||||
private IDriverService driverService;
|
||||
private ITransportVehicleService transportVehicleService;
|
||||
private ITransportCertRecordService certRecordService;
|
||||
private DriverCertServiceImpl service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
driverAppService = mock(IDriverAppService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
transportVehicleService = mock(ITransportVehicleService.class);
|
||||
certRecordService = mock(ITransportCertRecordService.class);
|
||||
service = new DriverCertServiceImpl(driverAppService, driverService,
|
||||
transportVehicleService, certRecordService);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 车辆提交
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:落一条待审记录,且状态必须是待审(不能被框架默认值变成已通过)")
|
||||
void submitVehicleCreatesPendingRecord() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID))
|
||||
.thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
request.setVin("VIN123");
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).save(captor.capture());
|
||||
TransportCertRecord saved = captor.getValue();
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, saved.getStatus());
|
||||
assertEquals(TransportCertRecord.BIZ_TYPE_VEHICLE, saved.getBizType());
|
||||
assertEquals(VEHICLE_ID, saved.getBizId());
|
||||
assertEquals(PLATE, saved.getBizKey());
|
||||
assertTrue(saved.getSnapshotJson().contains("VIN123"), "快照应承载本次提交的全量内容");
|
||||
assertNull(saved.getRejectReason(), "新提交不得继承上一条的驳回原因");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交人必须记用户ID:记成司机档案ID会让审核页「提交人」显示一串数字")
|
||||
void submitRecordsUserIdAsSubmitUser() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest vehicleRequest = completeVehicleRequest(PLATE);
|
||||
vehicleRequest.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(vehicleRequest);
|
||||
|
||||
PersonCertSubmitRequest personRequest = new PersonCertSubmitRequest();
|
||||
personRequest.setRealName("张三");
|
||||
service.submitPersonCert(personRequest);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService, times(2)).save(captor.capture());
|
||||
|
||||
for (TransportCertRecord saved : captor.getAllValues()) {
|
||||
assertEquals(USER_ID, saved.getSubmitUser(),
|
||||
"提交人必须是用户ID(审核页靠它翻译姓名),不能是司机档案ID");
|
||||
assertNotEquals(saved.getBizId(), saved.getSubmitUser(),
|
||||
"提交人与认证对象主键是两种ID,不应相同");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:不修改生效快照指针(提交不影响车辆可用性)")
|
||||
void submitVehicleDoesNotTouchEffectivePointer() {
|
||||
|
||||
Long effectiveId = 777L;
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, PLATE, effectiveId));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
// 更新车辆时只能用展示缓存字段,且绝不能带上 effectiveCertRecordId
|
||||
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).updateById(captor.capture());
|
||||
TransportVehicle updated = captor.getValue();
|
||||
assertNull(updated.getEffectiveCertRecordId(),
|
||||
"提交链路不得写生效快照指针,否则「曾通过即永久可用」会被破坏");
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, updated.getCertificationStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:前一条待审被置为已作废,不存在两条待审")
|
||||
void submitVehicleSupersedesPreviousPending() {
|
||||
TransportCertRecord previous = record(500L, TransportCertRecord.STATUS_PENDING);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID))
|
||||
.thenReturn(previous);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(captor.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, captor.getValue().getStatus());
|
||||
assertEquals(500L, captor.getValue().getId(), "被作废的应是原来那条待审");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆提交:前一条已是终态时不得被改动(尤其不能碰已通过的那条)")
|
||||
void submitVehicleLeavesTerminalRecordsAlone() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L));
|
||||
// selectPendingRecord 只返回待审;已通过的不该被它返回
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 越权
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:提交未绑定到自己名下的车辆必须拒绝")
|
||||
void submitVehicleRejectsUnboundVehicle() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
|
||||
verify(certRecordService, never()).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:车牌写法差异不影响绑定判定(间隔符/大小写)")
|
||||
void submitVehicleAcceptsBoundVehicleWithMessyPlate() {
|
||||
// 司机档案里带间隔符,车辆表里是干净写法
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523"));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523");
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
verify(certRecordService).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:司机未绑定任何车辆时提交必须拒绝")
|
||||
void submitVehicleRejectsWhenNoBoundVehicle() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(null));
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:不可查看他人车辆的认证记录列表")
|
||||
void listRecordsRejectsOthersVehicle() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.listRecords(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权:不可查看其他司机的认证记录列表")
|
||||
void listRecordsRejectsOtherDriver() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
|
||||
assertThrows(ServiceException.class,
|
||||
() -> service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, 12345L));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 个人认证
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:落待审记录,且再提交时作废前一条待审")
|
||||
void submitPersonCertSupersedes() {
|
||||
TransportCertRecord previous = record(600L, TransportCertRecord.STATUS_PENDING);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID))
|
||||
.thenReturn(previous);
|
||||
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName("张三");
|
||||
request.setIdCardNo("110101199001011234");
|
||||
|
||||
service.submitPersonCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> saved = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).save(saved.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, saved.getValue().getStatus());
|
||||
assertEquals(TransportCertRecord.BIZ_TYPE_DRIVER, saved.getValue().getBizType());
|
||||
assertEquals(DRIVER_ID, saved.getValue().getBizId());
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> updated = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).updateById(updated.capture());
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, updated.getValue().getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:不写生效快照指针(只刷展示缓存)")
|
||||
void submitPersonCertDoesNotTouchEffectivePointer() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName("张三");
|
||||
service.submitPersonCert(request);
|
||||
|
||||
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
|
||||
verify(driverService).updateById(captor.capture());
|
||||
assertNull(captor.getValue().getEffectiveCertRecordId(),
|
||||
"提交链路不得写生效快照指针");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("个人提交:内容为空必须拒绝")
|
||||
void submitPersonCertRejectsEmpty() {
|
||||
assertThrows(ServiceException.class, () -> service.submitPersonCert(null));
|
||||
verify(certRecordService, never()).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 生效值读取
|
||||
|
||||
@Test
|
||||
@DisplayName("读生效值:有指针时返回指针指向的那条快照内容(不是最新提交)")
|
||||
void getEffectivePersonCertReadsPointedRecord() {
|
||||
Long effectiveId = 777L;
|
||||
Driver driver = driver(PLATE);
|
||||
driver.setEffectiveCertRecordId(effectiveId);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
|
||||
PersonCertSubmitRequest effectiveContent = new PersonCertSubmitRequest();
|
||||
effectiveContent.setRealName("旧生效值");
|
||||
TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED);
|
||||
effective.setSnapshotJson(JSON.toJSONString(effectiveContent));
|
||||
when(certRecordService.getById(effectiveId)).thenReturn(effective);
|
||||
|
||||
PersonCertSubmitRequest result = service.getEffectivePersonCert();
|
||||
|
||||
assertEquals("旧生效值", result.getRealName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("读生效值:从未认证通过时返回 null(而不是抛错或返回最新提交)")
|
||||
void getEffectivePersonCertReturnsNullWhenNeverCertified() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
|
||||
assertNull(service.getEffectivePersonCert());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)")
|
||||
void resubmitKeepsEffectiveValue() {
|
||||
Long effectiveId = 777L;
|
||||
Driver driver = driver(PLATE);
|
||||
driver.setEffectiveCertRecordId(effectiveId);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
PersonCertSubmitRequest oldContent = new PersonCertSubmitRequest();
|
||||
oldContent.setRealName("旧的已通过内容");
|
||||
TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED);
|
||||
effective.setSnapshotJson(JSON.toJSONString(oldContent));
|
||||
when(certRecordService.getById(effectiveId)).thenReturn(effective);
|
||||
|
||||
// 提交新内容
|
||||
PersonCertSubmitRequest newContent = new PersonCertSubmitRequest();
|
||||
newContent.setRealName("新提交待审内容");
|
||||
service.submitPersonCert(newContent);
|
||||
|
||||
// 生效值不受影响
|
||||
PersonCertSubmitRequest stillEffective = service.getEffectivePersonCert();
|
||||
assertEquals("旧的已通过内容", stillEffective.getRealName());
|
||||
assertNotEquals("新提交待审内容", stillEffective.getRealName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录列表:类型非法必须拒绝")
|
||||
void listRecordsRejectsInvalidBizType() {
|
||||
assertThrows(ServiceException.class, () -> service.listRecords("truck", 1L));
|
||||
assertThrows(ServiceException.class, () -> service.listRecords(null, 1L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("记录列表:本人司机记录按传入条件查询(走统一查询入口)")
|
||||
void listRecordsForSelf() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(certRecordService.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID))
|
||||
.thenReturn(List.of());
|
||||
|
||||
assertTrue(service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID).isEmpty());
|
||||
verify(certRecordService, times(1))
|
||||
.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 删除车辆认证
|
||||
|
||||
@Test
|
||||
@DisplayName("删除车辆认证:逻辑删除车辆,且**不删**认证记录")
|
||||
void deleteVehicleCertIsLogicalOnly() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L));
|
||||
|
||||
service.deleteVehicleCert(VEHICLE_ID);
|
||||
|
||||
// 走的是 deleteLogic(is_deleted=1),不是物理删除
|
||||
verify(transportVehicleService).deleteLogic(java.util.Collections.singletonList(VEHICLE_ID));
|
||||
verify(transportVehicleService, never()).removeById(anyLong());
|
||||
// 认证记录是历史依据,任何情况下都不得被删
|
||||
verify(certRecordService, never()).removeById(anyLong());
|
||||
verify(certRecordService, never()).removeByIds(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除车辆认证:越权必须拒绝")
|
||||
void deleteVehicleCertRejectsUnbound() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID))
|
||||
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.deleteVehicleCert(VEHICLE_ID));
|
||||
verify(transportVehicleService, never()).deleteLogic(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("删除车辆认证:车牌写法差异不影响绑定判定")
|
||||
void deleteVehicleCertAcceptsMessyPlate() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523"));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null));
|
||||
|
||||
service.deleteVehicleCert(VEHICLE_ID);
|
||||
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 = completeVehicleRequest("桂A·32523");
|
||||
|
||||
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(),
|
||||
"新建的车不得自带生效快照——是否已认证只能由审核通过决定");
|
||||
assertEquals("桂物物流集团", vehicleCaptor.getValue().getOrganizationName(),
|
||||
"所属组织必须从司机档案带出:该列 NOT NULL 且无默认值,空着这条 INSERT 直接报错");
|
||||
assertEquals("管理挂靠", vehicleCaptor.getValue().getBusinessRelation(),
|
||||
"业务关系由司机在表单里选,服务端照实落库、不替用户猜");
|
||||
assertNotNull(vehicleCaptor.getValue().getRegistrationNo(),
|
||||
"机动车登记编号该列 NOT NULL:司机端选填,缺值要写空串而不是 null");
|
||||
assertEquals("桂物物流集团", vehicleCaptor.getValue().getOwnerName(),
|
||||
"所有人是行驶证上的登记主体,与所属组织是两个字段");
|
||||
|
||||
// 绑定:司机的 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 = completeVehicleRequest("桂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 = completeVehicleRequest("桂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 = completeVehicleRequest(" ");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertTrue(exception.getMessage().contains("车牌"), exception.getMessage());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 辅助
|
||||
|
||||
@Test
|
||||
@DisplayName("重新认证同样要过内容校验:只校验「新建车辆」等于给重新认证留了后门")
|
||||
void submitVehicleValidatesContentOnResubmit() {
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
request.setOwnerName("");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertEquals("所有人不能为空", exception.getMessage());
|
||||
verify(certRecordService, never()).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重新认证不要求司机档案有所属组织:那一列只在新建车辆时写,与管理归属无关")
|
||||
void submitVehicleResubmitDoesNotRequireOrganization() {
|
||||
Driver driver = driver(PLATE);
|
||||
driver.setOrganizationName(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
VehicleCertSubmitRequest request = completeVehicleRequest(PLATE);
|
||||
request.setId(VEHICLE_ID);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
verify(certRecordService).save(any(TransportCertRecord.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机建车:字段不齐时给可读提示,而不是让数据库报 NOT NULL")
|
||||
void submitVehicleRejectsIncompleteRequest() {
|
||||
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 = completeVehicleRequest("桂A32523");
|
||||
request.setApprovedLoadKg(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertEquals("核定载质量不能为空", exception.getMessage());
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机建车:没有所属组织时明确拒绝,不落一辆查不到归属的车")
|
||||
void submitVehicleRejectsDriverWithoutOrganization() {
|
||||
Driver driver = driver(null);
|
||||
driver.setOrganizationName(null);
|
||||
when(driverAppService.currentDriverEntity()).thenReturn(driver);
|
||||
when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of());
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(completeVehicleRequest("桂A32523")));
|
||||
assertTrue(exception.getMessage().contains("所属组织"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效期只填止期:起期为空也允许提交(司机端不采集起期)")
|
||||
void submitVehicleAcceptsBlankStartDate() {
|
||||
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 = completeVehicleRequest(PLATE);
|
||||
request.setDrivingLicenseStartDate(null);
|
||||
request.setRoadTransportCertStartDate(null);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportVehicle> vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
|
||||
verify(transportVehicleService).save(vehicleCaptor.capture());
|
||||
assertNotNull(vehicleCaptor.getValue().getDrivingLicenseEndDate(), "止期照常写入车辆行");
|
||||
assertNull(vehicleCaptor.getValue().getDrivingLicenseStartDate(), "起期为空就是空,不该编一个值出来");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有效期未勾长期有效时止期必填:起期可以空,止期不能")
|
||||
void submitVehicleRejectsBlankEndDate() {
|
||||
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 = completeVehicleRequest(PLATE);
|
||||
request.setDrivingLicenseStartDate(null);
|
||||
request.setDrivingLicenseEndDate(null);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submitVehicleCert(request));
|
||||
assertEquals("请选择行驶证有效期", exception.getMessage());
|
||||
verify(transportVehicleService, never()).save(any(TransportVehicle.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("长期有效时止期可以为空,此时不再比较起止先后")
|
||||
void submitVehicleAllowsLongTermWithoutEndDate() {
|
||||
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 = completeVehicleRequest(PLATE);
|
||||
request.setDrivingLicenseStartDate(LocalDate.of(2020, 1, 1));
|
||||
request.setDrivingLicenseEndDate(null);
|
||||
request.setDrivingLicenseLongTerm(1);
|
||||
|
||||
service.submitVehicleCert(request);
|
||||
|
||||
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
|
||||
verify(certRecordService).save(captor.capture());
|
||||
assertTrue(captor.getValue().getSnapshotJson().contains("\"drivingLicenseLongTerm\":1"),
|
||||
"长期有效标志要落进快照");
|
||||
}
|
||||
|
||||
/**
|
||||
* 一份字段齐全的车辆认证提交内容
|
||||
* <p>
|
||||
* 车辆表有十个 NOT NULL 且无默认值的列,少一个就会触发数据库错误而不是业务提示,
|
||||
* 故用例统一从这里取请求,避免每个用例各自拼一半字段。
|
||||
*/
|
||||
private VehicleCertSubmitRequest completeVehicleRequest(String plateNo) {
|
||||
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
|
||||
request.setPlateNo(plateNo);
|
||||
request.setVehicleType("重型货车");
|
||||
request.setOwnerName("桂物物流集团");
|
||||
request.setPlateColor("黄牌");
|
||||
request.setEnergyType("柴油");
|
||||
request.setBusinessRelation("管理挂靠");
|
||||
request.setApprovedLoadKg(18000);
|
||||
request.setTractionMassKg(35000);
|
||||
request.setOuterLength(5990);
|
||||
request.setOuterWidth(2300);
|
||||
request.setOuterHeight(3100);
|
||||
request.setDrivingLicenseNo("450100123456");
|
||||
request.setDrivingLicenseImage("http://oss.example.com/xsz-front.jpg");
|
||||
request.setDrivingLicenseStartDate(LocalDate.of(2018, 3, 9));
|
||||
request.setDrivingLicenseEndDate(LocalDate.of(2028, 3, 9));
|
||||
request.setRoadTransportCertNo("桂交运管0001");
|
||||
request.setRoadTransportCertStartDate(LocalDate.of(2020, 1, 1));
|
||||
request.setRoadTransportCertEndDate(LocalDate.of(2030, 1, 1));
|
||||
request.setCompulsoryScrapDate(LocalDate.of(2035, 1, 1));
|
||||
return request;
|
||||
}
|
||||
|
||||
private Driver driver(String drivingVehicle) {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
// 司机档案ID与用户ID是两个不同的ID:提交人字段要的是后者
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("张三");
|
||||
// 车辆表的 organization_name 是 NOT NULL 且无默认值:司机缺组织时建车必然失败,
|
||||
// 故建车链路要求它非空,fixture 也必须给
|
||||
driver.setOrganizationName("桂物物流集团");
|
||||
driver.setDrivingVehicle(drivingVehicle);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private TransportVehicle vehicle(Long id, String plateNo, Long effectiveId) {
|
||||
TransportVehicle vehicle = new TransportVehicle();
|
||||
vehicle.setId(id);
|
||||
vehicle.setPlateNo(plateNo);
|
||||
vehicle.setEffectiveCertRecordId(effectiveId);
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
private TransportCertRecord record(Long id, Integer status) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(id);
|
||||
record.setStatus(status);
|
||||
record.setSnapshotJson("{}");
|
||||
return record;
|
||||
}
|
||||
|
||||
}
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.alibaba.fastjson2.JSON;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
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;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
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.anyLong;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 个人认证的「提交 → 生效值不变 → 审核通过 → 生效值更新」闭环测试。
|
||||
* <p>
|
||||
* 这是司机认证最关键的一条业务闭环,也是单看提交侧或审核侧都验证不到的:
|
||||
* 提交之后司机侧查到的必须仍是**旧生效值**(否则等于提交即自动认证),
|
||||
* 审核通过之后才变成**新值**(否则认证永远不生效)。
|
||||
* <p>
|
||||
* 用一个内存版的记录存储把两侧接起来,避免两边各自 mock 成「恰好一致」。
|
||||
*/
|
||||
class PersonCertRoundTripTest {
|
||||
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
|
||||
private IDriverAppService driverAppService;
|
||||
private IDriverService driverService;
|
||||
private ITransportCertRecordService certRecordService;
|
||||
private TransportCertRecordMapper certRecordMapper;
|
||||
private DriverCertServiceImpl driverCertService;
|
||||
private CertAuditServiceImpl certAuditService;
|
||||
|
||||
/**
|
||||
* 内存记录表:Id → 记录
|
||||
*/
|
||||
private final Map<Long, TransportCertRecord> recordStore = new HashMap<>();
|
||||
private Driver driver;
|
||||
private long idSequence = 100L;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
recordStore.clear();
|
||||
idSequence = 100L;
|
||||
|
||||
driverAppService = mock(IDriverAppService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
certRecordService = mock(ITransportCertRecordService.class);
|
||||
certRecordMapper = mock(TransportCertRecordMapper.class);
|
||||
ITransportVehicleService transportVehicleService = mock(ITransportVehicleService.class);
|
||||
|
||||
driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
driver.setDriverName("张三");
|
||||
when(driverAppService.currentDriverEntity()).thenAnswer(invocation -> driver);
|
||||
// 司机表只持有指针,指针是被审核链路改的,这里让桩跟着内存状态走;
|
||||
// 证件字段也要跟着落地,否则「审核通过写回档案」这条链路在测试里验不到
|
||||
when(driverService.updateById(any(Driver.class))).thenAnswer(invocation -> {
|
||||
Driver patch = invocation.getArgument(0);
|
||||
if (patch.getEffectiveCertRecordId() != null) {
|
||||
driver.setEffectiveCertRecordId(patch.getEffectiveCertRecordId());
|
||||
}
|
||||
copyCertFields(patch, driver);
|
||||
return true;
|
||||
});
|
||||
|
||||
stubRecordStore();
|
||||
|
||||
driverCertService = new DriverCertServiceImpl(driverAppService, driverService,
|
||||
transportVehicleService, certRecordService);
|
||||
certAuditService = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交后仍是旧生效值,审核通过后才变新值")
|
||||
void submitThenApprove() {
|
||||
// 1) 先走一遍完整流程,让该司机有一个生效值
|
||||
Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
certAuditService.audit(firstRecordId, true, null);
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"审核通过后应能查到生效值");
|
||||
|
||||
// 2) 提交新内容:生效值必须**不变**
|
||||
Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"提交只产生待审记录,不得改变生效值——否则等于提交即自动认证");
|
||||
assertEquals(firstRecordId, driver.getEffectiveCertRecordId(),
|
||||
"指针应仍指向原来那条已通过记录");
|
||||
|
||||
// 3) 审核通过:生效值才变成新值
|
||||
certAuditService.audit(secondRecordId, true, null);
|
||||
assertEquals("张三改名", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"审核通过后生效值应切到新记录");
|
||||
assertEquals(secondRecordId, driver.getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回后生效值不变(已认证对象不会被驳回下线)")
|
||||
void rejectKeepsEffectiveValue() {
|
||||
Long firstRecordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
certAuditService.audit(firstRecordId, true, null);
|
||||
|
||||
Long secondRecordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
|
||||
certAuditService.audit(secondRecordId, false, "证件照片模糊");
|
||||
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"驳回只终结该次提交,既得认证必须保持有效");
|
||||
assertEquals(firstRecordId, driver.getEffectiveCertRecordId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("从未认证过时,审核通过前查不到生效值")
|
||||
void noEffectiveBeforeFirstApproval() {
|
||||
assertNull(driverCertService.getEffectivePersonCert());
|
||||
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
assertNull(driverCertService.getEffectivePersonCert(),
|
||||
"待审期间不应有生效值");
|
||||
|
||||
certAuditService.audit(recordId, true, null);
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("连续提交两次,不存在两条待审,且旧待审被作废")
|
||||
void consecutiveSubmitsLeaveSinglePending() {
|
||||
Long first = driverCertService.submitPersonCert(personCert("第一次", "110101199001011234"));
|
||||
Long second = driverCertService.submitPersonCert(personCert("第二次", "110101199001011234"));
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, recordStore.get(first).getStatus(),
|
||||
"前一条待审应被作废");
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, recordStore.get(second).getStatus());
|
||||
|
||||
long pendingCount = recordStore.values().stream()
|
||||
.filter(record -> TransportCertRecord.STATUS_PENDING.equals(record.getStatus()))
|
||||
.count();
|
||||
assertEquals(1L, pendingCount, "同一司机至多一条待审");
|
||||
|
||||
// 被作废的那条不能再被审核(否则会把已作废的记录变成生效值)
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springblade.core.log.exception.ServiceException.class,
|
||||
() -> certAuditService.audit(first, true, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已通过的记录不可再被审核(防止把生效快照改坏)")
|
||||
void approvedRecordCannotBeReaudited() {
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
org.junit.jupiter.api.Assertions.assertThrows(
|
||||
org.springblade.core.log.exception.ServiceException.class,
|
||||
() -> certAuditService.audit(recordId, false, "事后反悔"));
|
||||
assertEquals("张三", driverCertService.getEffectivePersonCert().getRealName(),
|
||||
"重复审核被拒后,生效值不受影响");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 内存记录存储
|
||||
|
||||
/**
|
||||
* 用内存 Map 承接记录读写,让两个服务真实地共享同一份状态
|
||||
*/
|
||||
private void stubRecordStore() {
|
||||
when(certRecordService.save(any(TransportCertRecord.class))).thenAnswer(invocation -> {
|
||||
TransportCertRecord record = invocation.getArgument(0);
|
||||
record.setId(++idSequence);
|
||||
recordStore.put(record.getId(), copyOf(record));
|
||||
return true;
|
||||
});
|
||||
when(certRecordService.updateById(any(TransportCertRecord.class))).thenAnswer(invocation -> {
|
||||
TransportCertRecord patch = invocation.getArgument(0);
|
||||
TransportCertRecord stored = recordStore.get(patch.getId());
|
||||
if (stored != null) {
|
||||
if (patch.getStatus() != null) {
|
||||
stored.setStatus(patch.getStatus());
|
||||
}
|
||||
stored.setRejectReason(patch.getRejectReason());
|
||||
stored.setAuditTime(patch.getAuditTime());
|
||||
stored.setAuditUser(patch.getAuditUser());
|
||||
}
|
||||
return true;
|
||||
});
|
||||
when(certRecordService.getById(anyLong()))
|
||||
.thenAnswer(invocation -> recordStore.get(invocation.getArgument(0, Long.class)));
|
||||
when(certRecordService.selectPendingRecord(any(), anyLong())).thenAnswer(invocation -> {
|
||||
String bizType = invocation.getArgument(0);
|
||||
Long bizId = invocation.getArgument(1);
|
||||
return recordStore.values().stream()
|
||||
.filter(record -> bizType.equals(record.getBizType())
|
||||
&& bizId.equals(record.getBizId())
|
||||
&& TransportCertRecord.STATUS_PENDING.equals(record.getStatus()))
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
});
|
||||
}
|
||||
|
||||
private TransportCertRecord copyOf(TransportCertRecord source) {
|
||||
return JSON.parseObject(JSON.toJSONString(source), TransportCertRecord.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟框架的 not_null 更新策略:patch 里为 null 的列不写,非 null 才覆盖
|
||||
* <p>
|
||||
* 单测里直接整对象覆盖会丢掉这个语义,从而验不出「管理端填过、司机端没填的字段被清空」
|
||||
* 这类只有真实更新策略才暴露的问题。
|
||||
*/
|
||||
private void copyCertFields(Driver patch, Driver target) {
|
||||
if (patch.getIdCardFront() != null) {
|
||||
target.setIdCardFront(patch.getIdCardFront());
|
||||
}
|
||||
if (patch.getIdCardBack() != null) {
|
||||
target.setIdCardBack(patch.getIdCardBack());
|
||||
}
|
||||
if (patch.getHeadPhoto() != null) {
|
||||
target.setHeadPhoto(patch.getHeadPhoto());
|
||||
}
|
||||
if (patch.getDrivingType() != null) {
|
||||
target.setDrivingType(patch.getDrivingType());
|
||||
}
|
||||
if (patch.getDrivingLicenseNo() != null) {
|
||||
target.setDrivingLicenseNo(patch.getDrivingLicenseNo());
|
||||
}
|
||||
if (patch.getDrivingLicenseFront() != null) {
|
||||
target.setDrivingLicenseFront(patch.getDrivingLicenseFront());
|
||||
}
|
||||
if (patch.getDrivingLicenseBack() != null) {
|
||||
target.setDrivingLicenseBack(patch.getDrivingLicenseBack());
|
||||
}
|
||||
if (patch.getQualificationType() != null) {
|
||||
target.setQualificationType(patch.getQualificationType());
|
||||
}
|
||||
if (patch.getQualificationNo() != null) {
|
||||
target.setQualificationNo(patch.getQualificationNo());
|
||||
}
|
||||
if (patch.getQualificationFront() != null) {
|
||||
target.setQualificationFront(patch.getQualificationFront());
|
||||
}
|
||||
if (patch.getQualificationBack() != null) {
|
||||
target.setQualificationBack(patch.getQualificationBack());
|
||||
}
|
||||
}
|
||||
|
||||
private PersonCertSubmitRequest personCert(String realName, String idCardNo) {
|
||||
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
|
||||
request.setRealName(realName);
|
||||
request.setIdCardNo(idCardNo);
|
||||
request.setIdCardFrontUrl("http://example.com/front.jpg");
|
||||
request.setIdCardBackUrl("http://example.com/back.jpg");
|
||||
return request;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致")
|
||||
void approveWritesCertContentBackToDriver() {
|
||||
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
|
||||
request.setHeadPhoto("http://example.com/head.jpg");
|
||||
request.setDriverClass("A2");
|
||||
request.setDriverFileNo("450100769875");
|
||||
request.setDriverLicenseFrontUrl("http://example.com/dl-front.jpg");
|
||||
request.setDriverLicenseBackUrl("http://example.com/dl-back.jpg");
|
||||
request.setQualificationType("道路旅客运输");
|
||||
request.setQualificationNo("Q0001");
|
||||
request.setQualificationFront("http://example.com/q-front.jpg");
|
||||
request.setQualificationBack("http://example.com/q-back.jpg");
|
||||
|
||||
Long recordId = driverCertService.submitPersonCert(request);
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
// 档案里必须是这次提交的内容:只切指针、不写内容的话,
|
||||
// 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的
|
||||
assertEquals("http://example.com/head.jpg", driver.getHeadPhoto());
|
||||
assertEquals("A2", driver.getDrivingType());
|
||||
assertEquals("450100769875", driver.getDrivingLicenseNo());
|
||||
assertEquals("http://example.com/dl-front.jpg", driver.getDrivingLicenseFront());
|
||||
assertEquals("http://example.com/dl-back.jpg", driver.getDrivingLicenseBack());
|
||||
assertEquals("道路旅客运输", driver.getQualificationType());
|
||||
assertEquals("Q0001", driver.getQualificationNo());
|
||||
assertEquals("http://example.com/q-front.jpg", driver.getQualificationFront());
|
||||
assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核通过:不覆盖姓名与身份证号(身份标识由管理端建档确定)")
|
||||
void approveDoesNotOverwriteIdentity() {
|
||||
driver.setDriverName("张三(档案名)");
|
||||
driver.setIdCardNo("110101199001011234");
|
||||
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234"));
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
assertEquals("张三(档案名)", driver.getDriverName(),
|
||||
"姓名是建档时确定的身份字段,司机端提交不得把它改掉");
|
||||
assertEquals("110101199001011234", driver.getIdCardNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回:证件内容一个字都不写回档案")
|
||||
void rejectWritesNothingBack() {
|
||||
driver.setHeadPhoto("http://example.com/original-head.jpg");
|
||||
|
||||
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
|
||||
request.setHeadPhoto("http://example.com/new-head.jpg");
|
||||
Long recordId = driverCertService.submitPersonCert(request);
|
||||
certAuditService.audit(recordId, false, "证件照片模糊");
|
||||
|
||||
assertEquals("http://example.com/original-head.jpg", driver.getHeadPhoto(),
|
||||
"驳回只终结该次提交,档案内容不得被改动");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过")
|
||||
void legacySnapshotSkipsWriteBackWithoutFailingAudit() {
|
||||
// 改造前的快照没有 idCardNo / driverFileNo,解析出来字段全是 null;
|
||||
// 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败
|
||||
Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
|
||||
TransportCertRecord record = recordStore.get(recordId);
|
||||
record.setSnapshotJson("{\"legacyField\":\"改造前的旧内容\"}");
|
||||
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_APPROVED, recordStore.get(recordId).getStatus(),
|
||||
"快照读不懂不该阻断审核");
|
||||
assertEquals(recordId, driver.getEffectiveCertRecordId(), "指针仍应照常切换");
|
||||
}
|
||||
}
|
||||
+114
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
+99
@@ -0,0 +1,99 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.core.secure.auth.AuthFun;
|
||||
import org.springframework.expression.Expression;
|
||||
import org.springframework.expression.spel.standard.SpelExpressionParser;
|
||||
import org.springframework.expression.spel.support.StandardEvaluationContext;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 认证审核权限表达式的可求值性测试。
|
||||
* <p>
|
||||
* 这些字符串写在 {@code @PreAuth} 注解里,由框架在**请求时**交给 SpEL 求值,
|
||||
* root object 是 {@code AuthFun}。名字写错不会在编译期或启动期暴露,
|
||||
* 只会在真实调用时抛异常(表现为接口 500)——用测试把它提前到构建期。
|
||||
* <p>
|
||||
* 这里用同一个 {@link SpelExpressionParser} 与同一套 root object 语义求值,
|
||||
* 与框架 {@code AuthAspect} 的做法一致。
|
||||
*/
|
||||
class CertAuditPermissionsTest {
|
||||
|
||||
private final SpelExpressionParser parser = new SpelExpressionParser();
|
||||
|
||||
@Test
|
||||
@DisplayName("查看权限:拥有司机审核菜单即可通过")
|
||||
void auditViewWithDriverMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true);
|
||||
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun),
|
||||
"只授予司机审核菜单的用户也应能查看待审列表");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查看权限:拥有车辆审核菜单即可通过")
|
||||
void auditViewWithVehicleMenu() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
when(authFun.hasMenu("cert_certification_audit_vehicle")).thenReturn(true);
|
||||
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_VIEW, authFun));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("查看权限:两个菜单都没有则拒绝")
|
||||
void auditViewWithoutAnyMenu() {
|
||||
assertFalse(evaluate(CertAuditPermissions.AUDIT_VIEW, mock(AuthFun.class)),
|
||||
"无任何审核菜单的用户不得查看待审列表");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核动作:只有查看权限、没有审核按钮权限时必须拒绝")
|
||||
void auditActionRequiresButtonPermission() {
|
||||
AuthFun authFun = mock(AuthFun.class);
|
||||
// 只给了列表菜单,没给「审核」按钮
|
||||
when(authFun.hasMenu("cert_certification_audit_driver")).thenReturn(true);
|
||||
|
||||
assertFalse(evaluate(CertAuditPermissions.AUDIT_ACTION, authFun),
|
||||
"能看待审列表不等于能执行审核");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核动作:任一审核按钮权限即可通过")
|
||||
void auditActionWithEitherButton() {
|
||||
AuthFun driverAuditor = mock(AuthFun.class);
|
||||
when(driverAuditor.hasMenu("cert_certification_audit_driver_audit")).thenReturn(true);
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, driverAuditor));
|
||||
|
||||
AuthFun vehicleAuditor = mock(AuthFun.class);
|
||||
when(vehicleAuditor.hasMenu("cert_certification_audit_vehicle_audit")).thenReturn(true);
|
||||
assertTrue(evaluate(CertAuditPermissions.AUDIT_ACTION, vehicleAuditor));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("表达式必须引用真实存在的菜单码(与票 01 建的一致)")
|
||||
void expressionsReferenceRealMenuCodes() {
|
||||
assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_driver"));
|
||||
assertTrue(CertAuditPermissions.AUDIT_VIEW.contains("cert_certification_audit_vehicle"));
|
||||
assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_driver_audit"));
|
||||
assertTrue(CertAuditPermissions.AUDIT_ACTION.contains("cert_certification_audit_vehicle_audit"));
|
||||
// 不应再出现角色名判断:库里不存在 admin 角色,且会漏掉「运管」岗位
|
||||
assertFalse(CertAuditPermissions.AUDIT_VIEW.contains("hasAnyRole"));
|
||||
assertFalse(CertAuditPermissions.AUDIT_ACTION.contains("hasAnyRole"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用与框架相同的方式求值:AuthFun 作为 root object
|
||||
*/
|
||||
private boolean evaluate(String expression, AuthFun authFun) {
|
||||
Expression parsed = parser.parseExpression(expression);
|
||||
StandardEvaluationContext context = new StandardEvaluationContext(authFun);
|
||||
return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class));
|
||||
}
|
||||
|
||||
}
|
||||
+110
@@ -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("认证"));
|
||||
}
|
||||
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.TransportCertRecord;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 认证记录状态机规则测试。
|
||||
* <p>
|
||||
* 这几条规则是整个认证改造的地基,一旦被改坏,症状是「司机重新提交认证后车辆掉出可用池」
|
||||
* 或「同一辆车出现两条待审」,都属线上事故,故集中在一处并锁住行为。
|
||||
*/
|
||||
class CertificationRuleSupportTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("提交:前一条待审必须转已作废,而不是被删除")
|
||||
void supersedePending() {
|
||||
TransportCertRecord previousPending = record(TransportCertRecord.STATUS_PENDING);
|
||||
|
||||
TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(previousPending);
|
||||
|
||||
// 返回的正是那条旧记录,且只改状态
|
||||
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, superseded.getStatus());
|
||||
assertEquals(previousPending.getId(), superseded.getId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交:前一条不是待审时,不得被改动(已通过/已驳回都是终态)")
|
||||
void doesNotTouchNonPending() {
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(null));
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_APPROVED)));
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_REJECTED)));
|
||||
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_SUPERSEDED)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交:新记录一律是待审,且清空驳回原因(避免继承上一条的驳回文案)")
|
||||
void newRecordIsPending() {
|
||||
TransportCertRecord incoming = record(null);
|
||||
incoming.setStatus(TransportCertRecord.STATUS_REJECTED);
|
||||
incoming.setRejectReason("上一次的驳回原因");
|
||||
|
||||
CertificationRuleSupport.prepareForSubmit(incoming);
|
||||
|
||||
assertEquals(TransportCertRecord.STATUS_PENDING, incoming.getStatus());
|
||||
assertNull(incoming.getRejectReason());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生效快照判定:只看指针,与记录表里任何一条的状态都无关")
|
||||
void effectiveIsDecidedByPointerOnly() {
|
||||
assertFalse(CertificationRuleSupport.isCertified(null));
|
||||
assertTrue(CertificationRuleSupport.isCertified(123456789L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("可审核判定:只有待审记录可被审核,终态一律拒绝(防止把生效快照改坏)")
|
||||
void onlyPendingIsAuditable() {
|
||||
assertTrue(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_PENDING)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_APPROVED)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_REJECTED)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_SUPERSEDED)));
|
||||
assertFalse(CertificationRuleSupport.isAuditable(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核入参:驳回必须给原因,通过时原因无意义需清空")
|
||||
void auditPayload() {
|
||||
// 驳回无原因 -> 拒绝
|
||||
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, null));
|
||||
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, " "));
|
||||
assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "证件照片模糊"));
|
||||
|
||||
// 通过时不需要原因
|
||||
assertTrue(CertificationRuleSupport.isRejectReasonValid(true, null));
|
||||
|
||||
// 通过必须把原因清空,否则会残留上一次驳回文案
|
||||
assertNull(CertificationRuleSupport.reasonForApprove());
|
||||
assertEquals("证件照片模糊", CertificationRuleSupport.reasonForReject(" 证件照片模糊 "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驳回原因长度:超过 200 字拒绝(与列宽一致)")
|
||||
void rejectReasonLength() {
|
||||
assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "短".repeat(200)));
|
||||
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, "长".repeat(201)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态文案:四种状态各一个中文说法,未知状态不静默给空")
|
||||
void statusText() {
|
||||
assertEquals("审核中", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_PENDING));
|
||||
assertEquals("已通过", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_APPROVED));
|
||||
assertEquals("已驳回", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_REJECTED));
|
||||
assertEquals("已作废", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_SUPERSEDED));
|
||||
assertEquals("未知状态", CertificationRuleSupport.statusText(99));
|
||||
assertEquals("未知状态", CertificationRuleSupport.statusText(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("认证对象类型:只认 driver / vehicle,其它一律拒绝")
|
||||
void bizTypeValidation() {
|
||||
assertTrue(CertificationRuleSupport.isValidBizType("driver"));
|
||||
assertTrue(CertificationRuleSupport.isValidBizType("vehicle"));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType("DRIVER"));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType("truck"));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType(null));
|
||||
assertFalse(CertificationRuleSupport.isValidBizType(""));
|
||||
}
|
||||
|
||||
private TransportCertRecord record(Integer status) {
|
||||
TransportCertRecord record = new TransportCertRecord();
|
||||
record.setId(1000L);
|
||||
record.setStatus(status);
|
||||
return record;
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfo;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 生效快照指针的 UPDATE 语句级验证。
|
||||
* <p>
|
||||
* 「驳回不得改变生效快照」这条不变式最终取决于:驳回时那次 UPDATE 会不会带上
|
||||
* {@code effective_cert_record_id} 列。审核服务传的是 null,因此只要该列被
|
||||
* 「值非 null 才写入」的 if 包住,驳回就物理上碰不到指针。
|
||||
* <p>
|
||||
* 这里直接检查 MyBatis-Plus 生成的 SET 片段,把「靠约定」变成「靠语句」——
|
||||
* 若哪天有人给该字段加了 {@code updateStrategy = ALWAYS},这个测试会立刻变红。
|
||||
*/
|
||||
class EffectivePointerUpdateSqlTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("车辆:指针列进入 UPDATE 片段,但被 null 判断包住(通过能写、驳回不碰)")
|
||||
void vehiclePointerGuardedByNullCheck() {
|
||||
assertPointerGuarded(TransportVehicle.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机:同上")
|
||||
void driverPointerGuardedByNullCheck() {
|
||||
assertPointerGuarded(Driver.class);
|
||||
}
|
||||
|
||||
private void assertPointerGuarded(Class<?> clazz) {
|
||||
String updateSql = tableInfo(clazz).getAllSqlSet(true, "et");
|
||||
|
||||
assertTrue(updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId}"),
|
||||
clazz.getSimpleName() + ":通过审核必须能写指针,否则生效快照永远建立不起来");
|
||||
|
||||
// 必须是「被 <if> 包住」的形式;若变成无条件的 SET,
|
||||
// 驳回时(传 null)就会把已认证对象的指针清空,等于把车/司机无辜下线。
|
||||
assertTrue(
|
||||
updateSql.contains("<if test=\"e['effectiveCertRecordId'] != null\">")
|
||||
&& updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId},</if>"),
|
||||
clazz.getSimpleName() + ":指针列必须是「值非 null 才写」;"
|
||||
+ "无条件 SET 会让驳回清空已认证对象的指针");
|
||||
}
|
||||
|
||||
private TableInfo tableInfo(Class<?> clazz) {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
return TableInfoHelper.initTableInfo(assistant, clazz);
|
||||
}
|
||||
|
||||
}
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
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.assertTrue;
|
||||
|
||||
/**
|
||||
* 百度 OCR 结果归一化测试。
|
||||
* <p>
|
||||
* 这一层挡的是「识别成功但什么都没填」——百度返回的是中文键,写死键名的代码在键名
|
||||
* 对不上的那天会静默失效:不抛异常、不报错,用户只看到识别完表单还是空的。
|
||||
* 故这里用真实形状的返回把别名容错与值归一化都钉住。
|
||||
*/
|
||||
class OcrFieldSupportTest {
|
||||
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
@Test
|
||||
@DisplayName("行驶证主页:官方字段名逐个归一化,外廓尺寸拆成三个")
|
||||
void vehicleLicenseFront() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result_num": 12,
|
||||
"words_result": {
|
||||
"号牌号码": {"words": "桂A32523"},
|
||||
"车辆类型": {"words": "重型厢式货车"},
|
||||
"所有人": {"words": "桂物物流集团"},
|
||||
"车辆识别代码": {"words": "LGAX2A130J1001234"},
|
||||
"档案编号": {"words": "450100123456"},
|
||||
"核定载质量": {"words": "18000kg"},
|
||||
"准牵引总质量": {"words": "35000"},
|
||||
"外廓尺寸": {"words": "5990×2300×3100"},
|
||||
"强制报废期止": {"words": "2030-05-20"},
|
||||
"能源种类": {"words": "柴油"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertEquals("桂A32523", fields.get("plateNo"));
|
||||
assertEquals("重型厢式货车", fields.get("vehicleType"));
|
||||
assertEquals("桂物物流集团", fields.get("ownerName"));
|
||||
assertEquals("LGAX2A130J1001234", fields.get("vin"));
|
||||
assertEquals("450100123456", fields.get("drivingLicenseNo"));
|
||||
assertEquals(18000, fields.get("approvedLoadKg"), "带 kg 单位也要能取到数字");
|
||||
assertEquals(35000, fields.get("tractionMassKg"));
|
||||
assertEquals(5990, fields.get("outerLength"));
|
||||
assertEquals(2300, fields.get("outerWidth"));
|
||||
assertEquals(3100, fields.get("outerHeight"));
|
||||
assertEquals("2030-05-20", fields.get("compulsoryScrapDate"));
|
||||
assertEquals("柴油", fields.get("energyType"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("键名写法差异:车牌号码 / 车辆识别代号 / 带单位后缀的键都要认")
|
||||
void aliasTolerance() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"车牌号码": {"words": "桂B11111"},
|
||||
"车辆识别代号": {"words": "LVSHCAMB7HF123456"},
|
||||
"核定载质量(kg)": {"words": "12000"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertEquals("桂B11111", fields.get("plateNo"));
|
||||
assertEquals("LVSHCAMB7HF123456", fields.get("vin"));
|
||||
assertEquals(12000, fields.get("approvedLoadKg"), "带后缀的键名要靠「包含」兜底命中");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("日期写法:年月日 / 点分 / 纯数字都归一化成 yyyy-MM-dd")
|
||||
void dateNormalization() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"强制报废期止": {"words": "2030年05月20日"},
|
||||
"注册日期": {"words": "2018.3.9"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertEquals("2030-05-20", fields.get("compulsoryScrapDate"));
|
||||
assertEquals("2018-03-09", fields.get("drivingLicenseStartDate"), "月日个位数要补零");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("「长期」不产出日期,而是把对应的长期有效标志置 1")
|
||||
void longTermBecomesFlag() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"强制报废期止": {"words": "长期"},
|
||||
"检验有效期止": {"words": "长期有效"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertFalse(fields.containsKey("compulsoryScrapDate"), "长期不能当成日期塞进日期框");
|
||||
assertEquals(1, fields.get("compulsoryScrapLongTerm"));
|
||||
assertEquals(1, fields.get("drivingLicenseLongTerm"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("身份证与驾驶证:键名归一化到个人认证的字段名")
|
||||
void idCardAndDrivingLicense() throws Exception {
|
||||
JsonNode idCard = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"姓名": {"words": "黄宇富"},
|
||||
"公民身份号码": {"words": "452730197911172318"},
|
||||
"签发日期": {"words": "2015-03-12"},
|
||||
"失效日期": {"words": "2035-03-12"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
Map<String, Object> person = OcrFieldSupport.extract(BaiduOcrType.ID_CARD, "front", idCard);
|
||||
assertEquals("黄宇富", person.get("realName"));
|
||||
assertEquals("452730197911172318", person.get("idCardNo"));
|
||||
assertEquals("2015-03-12", person.get("idCardValidStart"));
|
||||
assertEquals("2035-03-12", person.get("idCardValidEnd"));
|
||||
|
||||
JsonNode driving = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"档案编号": {"words": "451200976793"},
|
||||
"准驾车型": {"words": "B2D"},
|
||||
"有效起始日期": {"words": "2016-08-01"},
|
||||
"有效期限": {"words": "2026-08-01"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
Map<String, Object> license = OcrFieldSupport.extract(BaiduOcrType.DRIVING_LICENSE, "front", driving);
|
||||
assertEquals("451200976793", license.get("driverFileNo"));
|
||||
assertEquals("B2D", license.get("driverClass"));
|
||||
assertEquals("2016-08-01", license.get("driverValidStart"));
|
||||
assertEquals("2026-08-01", license.get("driverValidEnd"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("道路运输证:证号与业户名称")
|
||||
void roadTransport() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"道路运输证号": {"words": "桂交运管桂字450100123456号"},
|
||||
"业户名称": {"words": "桂物物流集团"},
|
||||
"有效期": {"words": "2020-01-01 至 2030-01-01"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE, null, result);
|
||||
|
||||
assertEquals("桂交运管桂字450100123456号", fields.get("roadTransportCertNo"));
|
||||
assertEquals("桂物物流集团", fields.get("ownerName"));
|
||||
assertEquals("2030-01-01", fields.get("roadTransportCertEndDate"), "止期字段取区间的最后一段,取第一段会把有效期起填成止");
|
||||
assertEquals("2020-01-01", fields.get("roadTransportCertStartDate"), "区间要同时带出起止,否则用户得照图再敲一遍");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("识别不到任何字段时返回空 Map,而不是一堆空串")
|
||||
void emptyResult() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{"words_result_num": 0, "words_result": {}}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertTrue(fields.isEmpty(), "空结果必须是空 Map:给空串会把用户已填内容覆盖掉");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("数值荒谬时不填:位数过多的串不可能是载质量,宁可留空也不填错")
|
||||
void absurdNumberIsSkipped() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"核定载质量": {"words": "识别串行123456789012"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertFalse(fields.containsKey("approvedLoadKg"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("外廓尺寸只识别到两段时不写回:硬填会让宽高错位")
|
||||
void outerSizeNeedsThreeParts() throws Exception {
|
||||
JsonNode result = MAPPER.readTree("""
|
||||
{
|
||||
"words_result": {
|
||||
"外廓尺寸": {"words": "5990×2300"}
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
Map<String, Object> fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result);
|
||||
|
||||
assertFalse(fields.containsKey("outerLength"));
|
||||
assertFalse(fields.containsKey("outerWidth"));
|
||||
assertFalse(fields.containsKey("outerHeight"));
|
||||
}
|
||||
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.ocr.constant.BaiduOcrType;
|
||||
|
||||
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.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 小程序证件类型 → 百度 OCR 证件类型的映射测试。
|
||||
* <p>
|
||||
* 两侧用的是两套命名,且**都不太直观**:
|
||||
* 小程序把「行驶证」叫 {@code vehicle}、把「驾驶证」叫 {@code driver}
|
||||
* (按业务直觉命名),而后端 OCR 沿用百度接口的 {@code vehicle_license}
|
||||
* (行驶证)与 {@code driving_license}(驾驶证)。
|
||||
* 这两个词一一对应关系相反,映射写错会让「行驶证」被当成「驾驶证」识别,
|
||||
* 结果是司机传了行驶证却拿到一堆驾驶证字段——而且不会报错。
|
||||
* 故此处逐一锁住。
|
||||
*/
|
||||
class OcrTypeMappingTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("身份证:小程序 idcard → 后端 ID_CARD")
|
||||
void idCard() {
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("idcard"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("驾驶证:小程序 driver → 后端 DRIVING_LICENSE(不是 vehicle_license)")
|
||||
void drivingLicense() {
|
||||
assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driver"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("行驶证:小程序 vehicle → 后端 VEHICLE_LICENSE(不是 driving_license)")
|
||||
void vehicleLicense() {
|
||||
assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("道路运输证:小程序 road_transport → 后端 ROAD_TRANSPORT_CERTIFICATE")
|
||||
void roadTransport() {
|
||||
assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE,
|
||||
OcrTypeMapping.toBaiduType("road_transport"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("从业资格证:后端无对应 OCR 能力,必须明确报错而不是退化成通用识别")
|
||||
void qualificationIsNotSilentlyDowngraded() {
|
||||
// 百度没有「从业资格证」专用接口。若这里悄悄退化成 GENERAL,
|
||||
// 前端会拿到一堆通用文本却以为识别成功了,故要求明确失败。
|
||||
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
|
||||
() -> OcrTypeMapping.toBaiduType("qualification"));
|
||||
assertTrue(ex.getMessage().contains("从业资格证"),
|
||||
"错误信息要指明是哪个证件类型不支持,便于前端提示用户手动填写");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("后端既有命名也接受(前端或其它调用方可能直接传后端名)")
|
||||
void acceptsBackendNames() {
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id_card"));
|
||||
assertEquals(BaiduOcrType.DRIVING_LICENSE, OcrTypeMapping.toBaiduType("driving_license"));
|
||||
assertEquals(BaiduOcrType.VEHICLE_LICENSE, OcrTypeMapping.toBaiduType("vehicle_license"));
|
||||
assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE,
|
||||
OcrTypeMapping.toBaiduType("road_transport_certificate"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("大小写与分隔符不敏感")
|
||||
void caseAndSeparatorInsensitive() {
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("IDCard"));
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType(" ID_CARD "));
|
||||
assertEquals(BaiduOcrType.ID_CARD, OcrTypeMapping.toBaiduType("id-card"));
|
||||
assertEquals(BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE,
|
||||
OcrTypeMapping.toBaiduType("Road-Transport"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未知类型:明确报错,不静默降级为通用识别")
|
||||
void unknownTypeFails() {
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("truck"));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType("general"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空值:明确报错")
|
||||
void emptyTypeFails() {
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(null));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(""));
|
||||
assertThrows(IllegalArgumentException.class, () -> OcrTypeMapping.toBaiduType(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("正反面:小程序不传 side 时返回 null,交由知晓证件类型的一侧决定默认值")
|
||||
void unsetSideStaysNull() {
|
||||
// 这里恒返回 front 会让道路运输证恒失败:它没有正副面参数,
|
||||
// 上游先填成 front,下游就变成「传了 side 但该证件不支持」——
|
||||
// 小程序不传 side 也会中招(实测三种传法全部报错)。
|
||||
assertNull(OcrTypeMapping.normalizeSide(null));
|
||||
assertNull(OcrTypeMapping.normalizeSide(""));
|
||||
assertNull(OcrTypeMapping.normalizeSide(" "));
|
||||
assertEquals("front", OcrTypeMapping.normalizeSide("front"));
|
||||
assertEquals("back", OcrTypeMapping.normalizeSide("back"));
|
||||
assertEquals("back", OcrTypeMapping.normalizeSide("BACK"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("正反面:道路运输证不带正副面参数,其余证件默认正面")
|
||||
void roadTransportHasNoSide() {
|
||||
// 下游据此区分「默认取正面」与「该证件不该带这个参数」,故这条契约必须有测试守
|
||||
assertFalse(OcrTypeMapping.toBaiduType("road_transport").supportsSide(),
|
||||
"道路运输证没有正副面参数,上游不得替它补 front");
|
||||
assertTrue(OcrTypeMapping.toBaiduType("vehicle").supportsSide());
|
||||
assertTrue(OcrTypeMapping.toBaiduType("driver").supportsSide());
|
||||
assertTrue(OcrTypeMapping.toBaiduType("idcard").supportsSide());
|
||||
}
|
||||
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
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;
|
||||
|
||||
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;
|
||||
|
||||
/**
|
||||
* 车牌匹配逻辑测试。
|
||||
* <p>
|
||||
* 司机档案的 {@code driving_vehicle} 与车辆表的 {@code plate_no} 是**文本软关联**,
|
||||
* 无外键,且库里已知存在间隔符/横线/全角等写法差异(例如「沪AE23411」在车辆表中不存在,
|
||||
* 而「桂A·32523」这类带间隔符的写法要靠规范化才能匹配上)。
|
||||
* 认证提交的越权校验依赖这套匹配,匹配漏判会让司机操作不了自己的车,
|
||||
* 误判则会让司机改到别人的车,故用测试锁住行为。
|
||||
*/
|
||||
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() {
|
||||
assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("多个车牌:中英文逗号、顿号、分号、斜杠、竖线都算分隔符")
|
||||
void multipleSeparators() {
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111", "桂C22222"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523,桂B11111、桂C22222"));
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523;桂B11111"));
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523/桂B11111"));
|
||||
assertEquals(
|
||||
List.of("桂A32523", "桂B11111"),
|
||||
VehiclePlateSupport.splitPlates("桂A32523|桂B11111"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("去重:同一车牌写两次只保留一条")
|
||||
void dedupe() {
|
||||
assertEquals(List.of("桂A32523"), VehiclePlateSupport.splitPlates("桂A32523,桂A32523"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("规范化:去空格/横线/间隔符并转大写")
|
||||
void normalize() {
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize(" 桂a32523 "));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A-32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A·32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A•32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A.32523"));
|
||||
assertEquals("桂A32523", VehiclePlateSupport.normalize("桂A 32523"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空值与脏数据:不抛异常,返回空串或空列表")
|
||||
void emptyAndDirty() {
|
||||
assertEquals("", VehiclePlateSupport.normalize(null));
|
||||
assertEquals("", VehiclePlateSupport.normalize(" "));
|
||||
assertTrue(VehiclePlateSupport.splitPlates(null).isEmpty());
|
||||
assertTrue(VehiclePlateSupport.splitPlates("").isEmpty());
|
||||
assertTrue(VehiclePlateSupport.splitPlates(",,、;;").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("绑定判定:写法差异不影响匹配(越权校验的关键)")
|
||||
void isBound() {
|
||||
// 司机档案里带间隔符,车辆表里是干净写法
|
||||
assertTrue(VehiclePlateSupport.isBound("桂A·32523", "桂A32523"));
|
||||
// 大小写差异
|
||||
assertTrue(VehiclePlateSupport.isBound("桂a32523", "桂A32523"));
|
||||
// 多车:命中其中一辆即可
|
||||
assertTrue(VehiclePlateSupport.isBound("桂A32523,桂B11111", "桂B11111"));
|
||||
// 未绑定
|
||||
assertFalse(VehiclePlateSupport.isBound("桂A32523", "桂B11111"));
|
||||
// 空值一律不绑定(不能因为双方都为空就放行——那等于越权)
|
||||
assertFalse(VehiclePlateSupport.isBound(null, "桂A32523"));
|
||||
assertFalse(VehiclePlateSupport.isBound("桂A32523", null));
|
||||
assertFalse(VehiclePlateSupport.isBound(null, null));
|
||||
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("绑定数上限: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() {
|
||||
LambdaQueryWrapper<TransportVehicle> wrapper = Wrappers.lambdaQuery();
|
||||
VehiclePlateSupport.applyPlateMatch(wrapper, List.of());
|
||||
assertEquals("", wrapper.getTargetSql().trim());
|
||||
VehiclePlateSupport.applyPlateMatch(wrapper, null);
|
||||
assertEquals("", wrapper.getTargetSql().trim());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
-- 认证记录数据模型:新建 blade_transport_cert_record,为司机表、车辆表补认证字段与
|
||||
-- 生效快照指针,订正司机表排序规则,并新增认证审核菜单。
|
||||
--
|
||||
-- 认证对象(司机 / 车辆)每次提交产生一条不可变记录,审核只改记录的状态,
|
||||
-- 不覆盖已提交的内容。司机与车辆共用本表,由 biz_type 区分。
|
||||
--
|
||||
-- ⚠️ effective_cert_record_id(生效快照指针)是判定「该对象是否已认证」的唯一依据:
|
||||
-- NULL = 从未审核通过;一经写入永不回退,提交、驳回、作废都不得修改它。
|
||||
-- 因此「重新提交审核」不会让一辆已认证的车掉出可用池。
|
||||
--
|
||||
-- 幂等:全部语句可重复执行(建表 IF NOT EXISTS、加列先查 information_schema)。
|
||||
-- MySQL 5.7+ 兼容。
|
||||
|
||||
-- ----------------------------
|
||||
-- 1. 认证记录表
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `blade_transport_cert_record` (
|
||||
`id` bigint(20) NOT NULL COMMENT '主键',
|
||||
`tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID',
|
||||
`biz_type` varchar(20) NOT NULL COMMENT '认证对象类型:driver-司机,vehicle-车辆',
|
||||
`biz_id` bigint(20) NOT NULL COMMENT '认证对象主键:司机ID或车辆ID(车辆按具体车辆,不是司机)',
|
||||
`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,一经创建不可修改',
|
||||
`reject_reason` varchar(200) DEFAULT NULL COMMENT '驳回原因,仅状态为已驳回时有值',
|
||||
`submit_time` datetime DEFAULT NULL COMMENT '提交时间',
|
||||
`submit_user` bigint(20) DEFAULT NULL COMMENT '提交人',
|
||||
`audit_time` datetime DEFAULT NULL COMMENT '审核时间',
|
||||
`audit_user` bigint(20) DEFAULT NULL COMMENT '审核人',
|
||||
`create_user` bigint(20) DEFAULT NULL COMMENT '创建人',
|
||||
`create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
`update_user` bigint(20) DEFAULT NULL COMMENT '修改人',
|
||||
`update_time` datetime DEFAULT NULL COMMENT '修改时间',
|
||||
`is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除',
|
||||
`pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_cert_record_pending` (`tenant_id`,`biz_type`,`pending_biz_id`) USING BTREE,
|
||||
KEY `idx_cert_record_biz` (`biz_type`,`biz_id`,`submit_time`) USING BTREE,
|
||||
KEY `idx_cert_record_status` (`biz_type`,`status`,`submit_time`) USING BTREE,
|
||||
KEY `idx_cert_record_submit_time` (`submit_time`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='认证记录';
|
||||
|
||||
-- ----------------------------
|
||||
-- 2~4. 加列与排序规则订正
|
||||
-- ----------------------------
|
||||
DELIMITER $$
|
||||
|
||||
DROP PROCEDURE IF EXISTS `upgrade_transport_cert_record_20261002`$$
|
||||
CREATE PROCEDURE `upgrade_transport_cert_record_20261002`()
|
||||
BEGIN
|
||||
DECLARE db_name varchar(128);
|
||||
SET db_name = DATABASE();
|
||||
|
||||
-- ---- 2. 司机表:认证字段 + 生效快照指针 ----
|
||||
-- certification_status 是「最新提交状态」的展示缓存;初始为 NULL 表示「从未提交」,
|
||||
-- 与「审核中(0)」语义不同,故不可用 0 兜底。
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_status') THEN
|
||||
ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交' AFTER `remark`;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_reject_reason') THEN
|
||||
ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_reject_reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最新一次驳回原因展示缓存' AFTER `certification_status`;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'effective_cert_record_id') THEN
|
||||
ALTER TABLE `blade_transport_driver` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`;
|
||||
END IF;
|
||||
|
||||
-- ---- 2.1 司机表:身份证有效期(小程序 person.vue 标为必填,原表缺列)----
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_start') THEN
|
||||
ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起' AFTER `id_card_back`;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_end') THEN
|
||||
ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_end` date DEFAULT NULL COMMENT '身份证有效期止' AFTER `id_card_valid_start`;
|
||||
END IF;
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_long_term') THEN
|
||||
ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是' AFTER `id_card_valid_end`;
|
||||
END IF;
|
||||
|
||||
-- ---- 3. 车辆表:生效快照指针 ----
|
||||
-- 现有 certification_status / certification_reject_reason 保留,降级为「最新提交状态」展示缓存;
|
||||
-- 车辆可用性判定改读本指针。
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' AND column_name = 'effective_cert_record_id') THEN
|
||||
ALTER TABLE `blade_transport_vehicle` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`;
|
||||
END IF;
|
||||
|
||||
-- ---- 4. 司机表排序规则订正 ----
|
||||
-- blade_transport_driver 建表未显式指定 COLLATE,MySQL 8+ 会落到服务器默认
|
||||
-- utf8mb4_0900_ai_ci,与项目规范(utf8mb4_general_ci)不一致。
|
||||
-- blade_transport_vehicle 曾因同类问题在 JOIN 时报 Illegal mix of collations,
|
||||
-- 故在此一并订正,避免司机认证的关联查询重演同一故障。
|
||||
IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND table_collation <> 'utf8mb4_general_ci') THEN
|
||||
ALTER TABLE `blade_transport_driver` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
|
||||
END IF;
|
||||
|
||||
-- ---- 4.1 待审唯一键自修复 ----
|
||||
-- 生成列必须同时排除「已逻辑删除」的行:MyBatis-Plus 的 removeById 只把 is_deleted 置 1,
|
||||
-- 不会改 status。若生成列只看 status = 0,一条被逻辑删除的待审记录会继续占用唯一键,
|
||||
-- 导致该认证对象永远无法再次提交。
|
||||
-- 建表语句已是正确版本;这里负责把按早期版本建过表的环境就地修正过来。
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record'
|
||||
AND column_name = 'pending_biz_id' AND generation_expression NOT LIKE '%is_deleted%'
|
||||
) THEN
|
||||
ALTER TABLE `blade_transport_cert_record`
|
||||
MODIFY COLUMN `pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照';
|
||||
END IF;
|
||||
|
||||
-- ---- 4.2 tenant_id 收紧为 NOT NULL ----
|
||||
-- tenant_id 是待审唯一键 uk_cert_record_pending 的第一列,而 MySQL 的唯一索引不对 NULL 去重:
|
||||
-- 若 tenant_id 允许为 NULL,两条 tenant_id IS NULL 的待审记录可以并存,
|
||||
-- 「同一对象至多一条待审」的约束就被绕过了。
|
||||
-- 本库三张表(driver / vehicle / waybill)实测均无 tenant_id 为 NULL 的行,
|
||||
-- 且「NOT NULL DEFAULT '000000'」是项目既有写法(doc/sql/transport 下 23 个脚本如此)。
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record'
|
||||
AND column_name = 'tenant_id' AND is_nullable = 'YES'
|
||||
) THEN
|
||||
UPDATE `blade_transport_cert_record` SET `tenant_id` = '000000' WHERE `tenant_id` IS NULL;
|
||||
ALTER TABLE `blade_transport_cert_record`
|
||||
MODIFY COLUMN `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID';
|
||||
END IF;
|
||||
|
||||
-- ---- 4.3 车辆认证状态缓存词表对齐 ----
|
||||
-- 改造前车辆侧是「0认证中 1认证通过 2认证驳回」,不含「已作废」;
|
||||
-- 而新流程会把「被同对象再次提交取代」写成 3,届时该列无对应语义。
|
||||
-- 取值 0/1/2 在两套词表下含义一致(进行中 / 通过 / 驳回),故这里只补 3 并统一措辞,
|
||||
-- 不改变任何既有数据的含义。仅改列注释(元数据),不动数据。
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle'
|
||||
AND column_name = 'certification_status' AND column_comment NOT LIKE '%已作废%'
|
||||
) THEN
|
||||
ALTER TABLE `blade_transport_vehicle`
|
||||
MODIFY COLUMN `certification_status` int(11) DEFAULT '0' COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废;可用性判定只看 effective_cert_record_id';
|
||||
END IF;
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle'
|
||||
AND column_name = 'certification_reject_reason' AND column_comment NOT LIKE '%展示缓存%'
|
||||
) THEN
|
||||
ALTER TABLE `blade_transport_vehicle`
|
||||
MODIFY COLUMN `certification_reject_reason` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '最新一次驳回原因展示缓存';
|
||||
END IF;
|
||||
END$$
|
||||
|
||||
CALL `upgrade_transport_cert_record_20261002`()$$
|
||||
DROP PROCEDURE `upgrade_transport_cert_record_20261002`$$
|
||||
|
||||
DELIMITER ;
|
||||
|
||||
-- ----------------------------
|
||||
-- 5. 菜单与权限码
|
||||
-- 挂在「运力管理」(2079000000000000001) 下,与司机管理 / 车辆管理同级。
|
||||
-- 列表页权限码 cert_certification_audit_{driver|vehicle},审核动作加 _audit 后缀。
|
||||
-- ----------------------------
|
||||
INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES
|
||||
(2079000000000000300, 2079000000000000001, 'cert_certification_audit_driver', '司机审核', 'cert_certification_audit_driver', '/transportCapacity/cert-audit-driver', 'iconfont iconicon_doc', 4, 1, 0, 1, '', '司机认证待审列表', 0),
|
||||
(2079000000000000301, 2079000000000000300, 'cert_certification_audit_driver_audit', '审核', 'cert_certification_audit_driver_audit', '', '', 1, 2, 0, 1, NULL, '通过/驳回司机认证', 0),
|
||||
(2079000000000000310, 2079000000000000001, 'cert_certification_audit_vehicle', '车辆审核', 'cert_certification_audit_vehicle', '/transportCapacity/cert-audit-vehicle', 'iconfont iconicon_doc', 5, 1, 0, 1, '', '车辆认证待审列表', 0),
|
||||
(2079000000000000311, 2079000000000000310, 'cert_certification_audit_vehicle_audit', '审核', 'cert_certification_audit_vehicle_audit', '', '', 1, 2, 0, 1, NULL, '通过/驳回车辆认证', 0)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
`parent_id` = VALUES(`parent_id`),
|
||||
`code` = VALUES(`code`),
|
||||
`name` = VALUES(`name`),
|
||||
`alias` = VALUES(`alias`),
|
||||
`path` = VALUES(`path`),
|
||||
`source` = VALUES(`source`),
|
||||
`sort` = VALUES(`sort`),
|
||||
`category` = VALUES(`category`),
|
||||
`action` = VALUES(`action`),
|
||||
`is_open` = VALUES(`is_open`),
|
||||
`component` = VALUES(`component`),
|
||||
`remark` = VALUES(`remark`),
|
||||
`is_deleted` = VALUES(`is_deleted`);
|
||||
@@ -13,10 +13,13 @@ 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 '身份证反面照',
|
||||
`id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起',
|
||||
`id_card_valid_end` date DEFAULT NULL COMMENT '身份证有效期止',
|
||||
`id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是',
|
||||
`head_photo` varchar(1000) DEFAULT NULL COMMENT '大头照',
|
||||
`driving_type` varchar(20) NOT NULL COMMENT '准驾车型',
|
||||
`driving_license_no` varchar(50) DEFAULT NULL COMMENT '驾驶证档案编号',
|
||||
@@ -39,6 +42,9 @@ CREATE TABLE `blade_transport_driver` (
|
||||
`emergency_contact_name` varchar(20) NOT NULL COMMENT '紧急联系人姓名',
|
||||
`emergency_contact_mobile` varchar(20) NOT NULL COMMENT '紧急联系人手机号',
|
||||
`remark` varchar(200) DEFAULT NULL COMMENT '备注',
|
||||
`certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交',
|
||||
`certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '最新一次驳回原因展示缓存',
|
||||
`effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退',
|
||||
`create_user` bigint(20) DEFAULT NULL COMMENT '创建人',
|
||||
`create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
@@ -54,7 +60,7 @@ CREATE TABLE `blade_transport_driver` (
|
||||
KEY `idx_transport_driver_license_end` (`driving_license_end_date`) USING BTREE,
|
||||
KEY `idx_transport_driver_qualification_end` (`qualification_end_date`) USING BTREE,
|
||||
KEY `idx_transport_driver_status` (`status`) USING BTREE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='司机管理';
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='司机管理';
|
||||
|
||||
-- ----------------------------
|
||||
-- Records of blade_menu for transport capacity driver
|
||||
|
||||
@@ -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 '驾驶车辆车牌号,多个用逗号分隔';
|
||||
@@ -8,8 +8,10 @@ CREATE TABLE `blade_transport_vehicle` (
|
||||
`organization_name` varchar(50) NOT NULL COMMENT '所属组织',
|
||||
`use_department` varchar(50) DEFAULT NULL COMMENT '使用部门',
|
||||
`plate_no` varchar(16) NOT NULL COMMENT '车牌号',
|
||||
`owner_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所有人(行驶证登记的单位名称,不是司机)',
|
||||
`plate_color` varchar(20) DEFAULT NULL COMMENT '车牌颜色',
|
||||
`vehicle_type` varchar(50) NOT NULL COMMENT '车辆类型',
|
||||
`vin` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆识别代号(车架号)',
|
||||
`outer_length` int(11) DEFAULT NULL COMMENT '外廓长度,毫米',
|
||||
`outer_width` int(11) DEFAULT NULL COMMENT '外廓宽度,毫米',
|
||||
`outer_height` int(11) DEFAULT NULL COMMENT '外廓高度,毫米',
|
||||
@@ -39,8 +41,9 @@ CREATE TABLE `blade_transport_vehicle` (
|
||||
`registration_date` date DEFAULT NULL COMMENT '机动车登记日期',
|
||||
`registration_image` varchar(1000) DEFAULT NULL COMMENT '机动车登记本图片',
|
||||
`remark` varchar(200) DEFAULT NULL COMMENT '备注',
|
||||
`certification_status` int(11) DEFAULT '0' COMMENT '认证状态:0认证中 1认证通过 2认证驳回',
|
||||
`certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '认证驳回原因',
|
||||
`certification_status` int(11) DEFAULT '0' COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废;可用性判定只看 effective_cert_record_id',
|
||||
`certification_reject_reason` varchar(200) DEFAULT NULL COMMENT '最新一次驳回原因展示缓存',
|
||||
`effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退',
|
||||
`create_user` bigint(20) DEFAULT NULL COMMENT '创建人',
|
||||
`create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门',
|
||||
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- ----------------------------
|
||||
-- 车辆档案补两列:所有人、车辆识别代号(车架号)
|
||||
-- ----------------------------
|
||||
-- 所有人:行驶证上登记的单位名称(如「桂物物流集团」),**不是司机**,
|
||||
-- 与「所属组织」也是两个字段——组织是内部管理归属,所有人是证件上的登记主体,
|
||||
-- 两者可能不同(挂靠车的所有人不是本组织)。
|
||||
-- VIN:司机端车辆认证一直在收这个值并写进认证快照,但车辆表此前没有对应列,
|
||||
-- 于是它只存在于快照里、车辆档案上查不到。补列后档案与快照才一致。
|
||||
--
|
||||
-- 两列都允许为空:存量车辆(含订正补的那些)没有这两个值,
|
||||
-- 且 VIN 在原型里是**选填**项,不设默认值也不设 NOT NULL。
|
||||
ALTER TABLE `blade_transport_vehicle`
|
||||
ADD COLUMN `owner_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所有人(行驶证登记的单位名称,不是司机)' AFTER `plate_no`,
|
||||
ADD COLUMN `vin` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆识别代号(车架号)' AFTER `vehicle_type`;
|
||||
Reference in New Issue
Block a user