From 87f4c3a830e18f3240cd641ef81f901f085ef2d8 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Fri, 2 Oct 2026 14:40:56 +0800 Subject: [PATCH 01/26] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E8=AE=B0=E5=BD=95=E4=B8=8D=E5=8F=AF=E5=8F=98=E5=BF=AB?= =?UTF-8?q?=E7=85=A7=E6=95=B0=E6=8D=AE=E6=A8=A1=E5=9E=8B=E4=B8=8E=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 ADR-0001/0002 建立司机与车辆共用的认证记录表 blade_transport_cert_record (biz_type 区分、载荷存 JSON、检索字段提升为真实列),并为司机/车辆表补生效快照指针。 不变式结构性保证: - I1 同对象至多一条待审:生成列 pending_biz_id + 唯一索引,数据库层拦截; 生成列同时排除 is_deleted,避免逻辑删除的待审记录永久占用唯一键。 - I3 快照不可变:snapshot_json 等 5 列标 FieldStrategy.NEVER,永不进入 UPDATE。 - status 承载审核状态(0审核中/1已通过/2已驳回/3已作废),新增时必须显式设置: 框架 resolveEntity 会把 null 的 status 静默填成 1(即「已通过」), 且继承来的 changeStatus 可直接写 status,两条通道均已封堵。 其他:司机表补身份证有效期三列并订正排序规则为 utf8mb4_general_ci (原先为 0900_ai_ci,与车辆表 JOIN 已发生过 Illegal mix of collations); 生效快照指针加 @JsonProperty(READ_ONLY),防止以 /submit 请求体自行「认证」自己; 同步基线建表脚本与认证审核菜单/权限码。本票不含任何业务接口。 --- .../transport/pojo/entity/Driver.java | 44 ++++ .../pojo/entity/TransportCertRecord.java | 156 +++++++++++++++ .../pojo/entity/TransportVehicle.java | 19 ++ .../pojo/vo/TransportCertRecordVO.java | 74 +++++++ .../transport/mapper/DriverMapper.xml | 14 +- .../mapper/TransportCertRecordMapper.java | 38 ++++ .../mapper/TransportVehicleMapper.xml | 2 + .../service/ITransportCertRecordService.java | 62 ++++++ .../impl/TransportCertRecordServiceImpl.java | 188 +++++++++++++++++ .../wrapper/TransportCertRecordWrapper.java | 57 ++++++ .../blade_transport_cert_record_20261002.sql | 189 ++++++++++++++++++ doc/sql/transport/blade_transport_driver.sql | 8 +- doc/sql/transport/blade_transport_vehicle.sql | 5 +- 13 files changed, 852 insertions(+), 4 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/TransportCertRecordMapper.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/service/ITransportCertRecordService.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportCertRecordServiceImpl.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java create mode 100644 doc/sql/transport/blade_transport_cert_record_20261002.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java index 1d13e2ec..80137624 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Driver.java @@ -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,31 @@ public class Driver extends TenantEntity { */ @Schema(description = "备注") private String remark; + /** + * 最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null 表示从未提交 + *

+ * 仅为列表展示而冗余,不是可用性判据;「是否已认证」一律读生效快照指针。 + */ + @Schema(description = "最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,null为从未提交") + private Integer certificationStatus; + /** + * 最新一次驳回原因展示缓存 + */ + @Schema(description = "最新一次驳回原因展示缓存") + private String certificationRejectReason; + /** + * 生效快照指针:指向 blade_transport_cert_record.id + *

+ * 「该司机是否已认证」的唯一判据。为 null 表示从未认证通过; + * 一经设置永不回退——提交、驳回、作废都不得修改它(见 ADR-0002)。 + *

+ * ⚠️ {@code READ_ONLY}:本字段只能由审核链路(03)写入。 + * 司机档案的 {@code /submit} 直接以本实体接收请求体,若允许反序列化, + * 调用方就能自行填一个记录 ID 把自己「认证」掉,绕过审核与 D10 的可用性门槛。 + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "生效快照指针") + private Long effectiveCertRecordId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java new file mode 100644 index 00000000..93d0aab7 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java @@ -0,0 +1,156 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +/** + * 认证记录实体类 + *

+ * 认证对象(司机 / 车辆)每一次提交所产生的、不可变的快照。 + * 记录一经创建,其 {@code snapshotJson} 与提交元信息永不修改;被同对象的后续提交取代时 + * 标记为「已作废」,而不是删除。判定某个对象「是否已认证」只看它持有的生效快照指针, + * 与记录表里的任何一条记录的状态都无关。 + *

+ * 设计依据:{@code docs/adr/0001-certification-record-immutable-snapshot.md}, + * 术语见根工作区 {@code GLOSSARY.md}。 + *

+ * ⚠️ {@code status} 承载的是「审核状态」,不是 BladeX 的「启用 / 停用」。 + * 该字段继承自 {@link TenantEntity},带来两条必须堵住的框架通道: + * 一是 {@code BaseServiceImpl#resolveEntity} 在插入时会把 {@code null} 的 {@code status} + * 静默填成 {@code 1}(恰好是 {@link #STATUS_APPROVED}); + * 二是继承来的 {@code changeStatus(ids, 1)} 会直接写 {@code status},等于批量通过审核。 + * 二者都已在 {@code TransportCertRecordServiceImpl} 中封堵(前者校验抛错、后者覆写抛错), + * 审核一律走 03 的审核接口。 + * + * @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; + + /** + * 认证对象类型:driver-司机,vehicle-车辆 + *

+ * {@code NEVER}:记录一经创建,认证对象的身份不可改(详见类注释的不变式 I3)。 + */ + @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),一经创建不可修改 + *

+ * {@code NEVER}:这是不变式 I3 的结构性保证——本字段永不进入 UPDATE 语句, + * 因此不存在「某条链路忘了保护快照」的可能。改内容只能靠新提交一条记录。 + */ + @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; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java index 4829cc33..d18a3008 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java @@ -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; @@ -224,5 +227,21 @@ public class TransportVehicle extends TenantEntity { /** 认证驳回原因 */ @Schema(description = "认证驳回原因") private String certificationRejectReason; + /** + * 生效快照指针:指向 blade_transport_cert_record.id + *

+ * 「该车辆是否已认证」的唯一判据。为 null 表示从未认证通过; + * 一经设置永不回退——重新提交、待审、被驳回都不得修改它(见 ADR-0002)。 + * 改造后 {@link #certificationStatus} 降级为「最新提交状态」的展示缓存。 + *

+ * ⚠️ {@code READ_ONLY}:本字段只能由审核链路(03)写入。 + * {@code POST /transport-vehicle/submit} 直接以本实体接收请求体, + * 若允许反序列化,调用方就能自行填一个记录 ID 把自己「认证」掉, + * 从而完全绕过审核与 D10 的可用性门槛。 + */ + @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "生效快照指针") + private Long effectiveCertRecordId; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java new file mode 100644 index 00000000..8275d7be --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/TransportCertRecordVO.java @@ -0,0 +1,74 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml index 45ad067b..89e87067 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/DriverMapper.xml @@ -46,6 +46,12 @@ + + + + + + @@ -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 + 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} + + AND r.biz_key LIKE CONCAT('%', #{bizKey}, '%') + + + + AND ( + d.driver_name LIKE CONCAT('%', #{driverName}, '%') + OR r.biz_key LIKE CONCAT('%', #{driverName}, '%') + ) + + + AND d.mobile LIKE CONCAT('%', #{driverMobile}, '%') + + ORDER BY + r.submit_time DESC + + + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java index 69274414..89af4d43 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java @@ -45,15 +45,22 @@ public interface ICertAuditService { *

* 审核页按「待审核 / 已通过 / 已驳回」分页签展示,故状态可传; * 不传时按待审核处理,兼容只想要待审队列的调用方。 + *

+ * 列表同时下发该记录的司机姓名与手机号,取的是司机档案的**当前**值 + * (认证记录里的姓名是提交时的历史值,手机号则从来不在快照里)。 + * 解析不到司机档案的记录,两个字段为空,不回落成提交时的值—— + * 那会让「同一个人的多条记录显示成不同的人」。 * - * @param bizType 认证对象类型:driver-司机,vehicle-车辆 - * @param bizKey 展示键模糊匹配,可为空 - * @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中) - * @param page 分页参数(由 Controller 用 Condition.getPage 构造) + * @param bizType 认证对象类型:driver-司机,vehicle-车辆 + * @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空 + * @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空 + * @param driverMobile 司机手机号模糊匹配,可为空 + * @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中) + * @param page 分页参数(由 Controller 用 Condition.getPage 构造) * @return 认证记录分页 */ - IPage pendingPage(String bizType, String bizKey, Integer status, - IPage page); + IPage pendingPage(String bizType, String bizKey, String driverName, String driverMobile, + Integer status, IPage page); /** * 审核详情:同时返回待审快照与当前生效值,供前端做 diff diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index a7a3a526..b274345c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -25,14 +25,13 @@ */ package org.springblade.transport.service.impl; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; +import org.springblade.transport.mapper.TransportCertRecordMapper; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportCertRecord; import org.springblade.transport.pojo.entity.TransportVehicle; @@ -48,6 +47,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.List; /** * 认证审核服务实现 @@ -66,24 +66,43 @@ import java.time.LocalDateTime; public class CertAuditServiceImpl implements ICertAuditService { private final ITransportCertRecordService certRecordService; + private final TransportCertRecordMapper certRecordMapper; private final ITransportVehicleService transportVehicleService; private final IDriverService driverService; @Override - public IPage pendingPage(String bizType, String bizKey, Integer status, - IPage page) { + public IPage pendingPage(String bizType, String bizKey, String driverName, String driverMobile, + Integer status, IPage page) { if (!CertificationRuleSupport.isValidBizType(bizType)) { throw new ServiceException("认证对象类型不正确"); } // 未传状态时按「审核中」处理:审核页默认停在待审页签, // 只关心待审队列的调用方也不必额外传参 Integer queryStatus = status == null ? TransportCertRecord.STATUS_PENDING : status; - LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery() - .eq(TransportCertRecord::getBizType, bizType) - .eq(TransportCertRecord::getStatus, queryStatus) - .like(Func.isNotEmpty(bizKey), TransportCertRecord::getBizKey, bizKey) - .orderByDesc(TransportCertRecord::getSubmitTime); - return TransportCertRecordWrapper.build().pageVO(certRecordService.page(page, queryWrapper)); + + // 司机姓名与手机号、以及按它们的筛选,都要在**分页之前**由 SQL 完成: + // 在 Java 层补字段再过滤会变成「先分页再过滤」,条数与总数都不准。 + List records = certRecordMapper.selectAuditPage(page, bizType, queryStatus, + blankToNull(bizKey), blankToNull(driverName), blankToNull(driverMobile)); + TransportCertRecordWrapper wrapper = TransportCertRecordWrapper.build(); + records.forEach(wrapper::fillDisplayFields); + return page.setRecords(records); + } + + /** + * 搜索项的空串与纯空白一律当作「没填」 + *

+ * 否则 {@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 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index f6b51273..11976336 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -352,6 +352,9 @@ public class DriverCertServiceImpl implements IDriverCertService { String drivingVehicle = Func.isEmpty(driver.getDrivingVehicle()) ? normalizedPlate : driver.getDrivingVehicle() + "," + normalizedPlate; + // 「最多 5 辆 + 车牌格式」在写入前校验:字段只有 varchar(100), + // 超限内容一旦写进去要么报错、要么被静默截断成半个车牌(还会被当成一辆车) + VehiclePlateSupport.validateBoundVehicles(drivingVehicle); Driver patch = new Driver(); patch.setId(driver.getId()); patch.setDrivingVehicle(drivingVehicle); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java index ee887b7c..277e05fb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverServiceImpl.java @@ -43,12 +43,12 @@ import org.springblade.system.pojo.enums.UserSex; import org.springblade.system.pojo.enums.UserType; import org.springblade.transport.excel.DriverExcel; import org.springblade.transport.mapper.DriverMapper; -import org.springblade.transport.mapper.TransportVehicleMapper; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverExpiryStatVO; import org.springblade.transport.pojo.vo.DriverVO; import org.springblade.transport.service.IDriverService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,14 +68,13 @@ public class DriverServiceImpl extends BaseServiceImpl imp private static final int NAME_MAX_LENGTH = 10; private static final int ID_CARD_MAX_LENGTH = 18; private static final int MOBILE_MAX_LENGTH = 20; - private static final int VEHICLE_NO_MAX_LENGTH = 30; + private static final int VEHICLE_NO_MAX_LENGTH = 100; private static final int SHORT_TEXT_MAX_LENGTH = 50; private static final int ADDRESS_MAX_LENGTH = 200; private static final int REMARK_MAX_LENGTH = 200; private static final int DEFAULT_ENABLED_STATUS = 1; private static final int DEFAULT_FALSE = 0; - private final TransportVehicleMapper transportVehicleMapper; private final IUserClient userClient; private final ISysClient sysClient; @@ -250,15 +249,11 @@ public class DriverServiceImpl extends BaseServiceImpl 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.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字"); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java index 32462dc5..612e9a1e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/TransportVehicleServiceImpl.java @@ -41,6 +41,7 @@ import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO; import org.springblade.transport.pojo.vo.TransportVehicleVO; import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.support.VehiclePlateSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -50,7 +51,6 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.regex.Pattern; import java.util.stream.Collectors; /** @@ -62,7 +62,6 @@ import java.util.stream.Collectors; @RequiredArgsConstructor public class TransportVehicleServiceImpl extends BaseServiceImpl implements ITransportVehicleService { - private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$"); private static final String PLATE_COLOR_BLUE = "蓝牌"; private static final String PLATE_COLOR_YELLOW = "黄牌"; private static final String PLATE_COLOR_GREEN = "绿牌"; @@ -262,7 +261,7 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl + * 与小程序端「一个司机最多添加 5 辆」的上限一致。后端此前完全没有这个约束, + * 而 {@code driving_vehicle} 只有 varchar(30)——5 个车牌连分隔符要 39 字符, + * 第 4 辆起就会写不进去或被静默截断(截断后剩下半个车牌,还会被当成一辆车)。 + */ + public static final int MAX_BOUND_VEHICLES = 5; + + /** + * 车牌格式:中文省份简称 + 字母 + 5~6 位(含挂/学/警/港/澳等特殊字) + *

+ * 与车辆建档时的车牌校验共用同一条规则:能绑到司机名下的车牌, + * 必须先能作为一辆车建档,否则两处口径分叉,会出现「绑得上但建不了档」。 + * 传入值必须是 {@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(); + } + + /** + * 校验「驾驶车辆」字段:车辆数上限与逐个车牌的格式 + *

+ * 规则只在这里维护一处——小程序新增车辆、管理端新增/编辑司机都调它。 + * 此前管理端把这个字段当作**单个**车牌校验(拿整串去车牌表里精确查), + * 而小程序写入的是逗号并列的多辆车,两边口径本就不一致: + * 一个绑了两辆车的司机,管理员在司机管理页一保存就会撞上「驾驶车辆不存在」。 + * + * @param drivingVehicle 司机档案里的驾驶车辆字段,可并列多辆 + * @throws ServiceException 车辆数超过上限,或存在格式非法的车牌 + */ + public static void validateBoundVehicles(String drivingVehicle) { + List 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 条件:精确匹配 **或** 规范化后匹配 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java index 843a6a92..60b9cea4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/TransportCertRecordWrapper.java @@ -48,12 +48,26 @@ public class TransportCertRecordWrapper extends BaseEntityWrapper + * 审核列表走的是自定义 SQL,查出来已经是 VO——它多带司机姓名与手机号两个字段, + * 这两个字段实体上没有、无法由实体转换得到,所以那条路径不能走 {@link #entityVO}。 + * 把填充逻辑单独暴露一次,避免 Service 里再复制一份翻译规则,日后两处走偏。 + * + * @param transportCertRecordVO 认证记录视图对象 + * @return 同一个对象,便于链式调用 + */ + public TransportCertRecordVO fillDisplayFields(TransportCertRecordVO transportCertRecordVO) { + transportCertRecordVO.setCreateUserName(UserCache.getUserRealName(transportCertRecordVO.getCreateUser())); + transportCertRecordVO.setUpdateUserName(UserCache.getUserRealName(transportCertRecordVO.getUpdateUser())); + transportCertRecordVO.setSubmitUserName(UserCache.getUserRealName(transportCertRecordVO.getSubmitUser())); + transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecordVO.getAuditUser())); // 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法 - transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecord.getStatus())); + transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecordVO.getStatus())); return transportCertRecordVO; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index 49776737..e03f9d9d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springblade.core.log.exception.ServiceException; import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.mapper.TransportCertRecordMapper; import org.springblade.transport.pojo.entity.TransportCertRecord; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.CertAuditDetailVO; @@ -36,6 +37,7 @@ class CertAuditServiceImplTest { private static final Long DRIVER_ID = 9001L; private ITransportCertRecordService certRecordService; + private TransportCertRecordMapper certRecordMapper; private ITransportVehicleService transportVehicleService; private IDriverService driverService; private CertAuditServiceImpl service; @@ -43,9 +45,10 @@ class CertAuditServiceImplTest { @BeforeEach void setUp() { certRecordService = mock(ITransportCertRecordService.class); + certRecordMapper = mock(TransportCertRecordMapper.class); transportVehicleService = mock(ITransportVehicleService.class); driverService = mock(IDriverService.class); - service = new CertAuditServiceImpl(certRecordService, transportVehicleService, driverService); + service = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService); } // ------------------------------------------------------------ 通过 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java index 2015fc67..deb2de2b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.mapper.TransportCertRecordMapper; import org.springblade.transport.pojo.entity.TransportCertRecord; import org.springblade.transport.service.IDriverAppService; import org.springblade.transport.service.IDriverService; @@ -38,6 +39,7 @@ class PersonCertRoundTripTest { private IDriverAppService driverAppService; private IDriverService driverService; private ITransportCertRecordService certRecordService; + private TransportCertRecordMapper certRecordMapper; private DriverCertServiceImpl driverCertService; private CertAuditServiceImpl certAuditService; @@ -56,6 +58,7 @@ class PersonCertRoundTripTest { driverAppService = mock(IDriverAppService.class); driverService = mock(IDriverService.class); certRecordService = mock(ITransportCertRecordService.class); + certRecordMapper = mock(TransportCertRecordMapper.class); ITransportVehicleService transportVehicleService = mock(ITransportVehicleService.class); driver = new Driver(); @@ -75,7 +78,7 @@ class PersonCertRoundTripTest { driverCertService = new DriverCertServiceImpl(driverAppService, driverService, transportVehicleService, certRecordService); - certAuditService = new CertAuditServiceImpl(certRecordService, transportVehicleService, driverService); + certAuditService = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java new file mode 100644 index 00000000..f2f7e9f9 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertAuditPageSqlTest.java @@ -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 的语句级验证。 + *

+ * 这条 SQL 有两个容易写坏、写坏了又不会报错的地方: + *

+ * 这里直接编译 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 params) { + MappedStatement statement = configuration.getMappedStatement( + "org.springblade.transport.mapper.TransportCertRecordMapper.selectAuditPage"); + Map parameters = new HashMap<>(params); + parameters.put("bizType", "driver"); + parameters.put("status", 0); + BoundSql boundSql = statement.getBoundSql(parameters); + return boundSql.getSql(); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java index 388d848f..445e0419 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/VehiclePlateSupportTest.java @@ -1,6 +1,7 @@ package org.springblade.transport.support; import org.junit.jupiter.api.BeforeAll; +import org.springblade.core.log.exception.ServiceException; import org.apache.ibatis.builder.MapperBuilderAssistant; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.MybatisConfiguration; @@ -14,6 +15,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -129,6 +131,58 @@ class VehiclePlateSupportTest { assertTrue(sql.contains("OR"), "两者应是 OR 关系"); } + @Test + @DisplayName("绑定数上限:5 辆放行,第 6 辆拒绝") + void boundVehicleCountLimit() { + String five = "桂A32523,桂B11111,桂C22222,桂D33333,桂E44444"; + VehiclePlateSupport.validateBoundVehicles(five); + assertEquals(5, VehiclePlateSupport.splitPlates(five).size()); + + // 不抛异常就是通过;第 6 辆必须拦下,否则字段放不下会被静默截断 + ServiceException exception = assertThrows(ServiceException.class, + () -> VehiclePlateSupport.validateBoundVehicles(five + ",桂F55555")); + assertTrue(exception.getMessage().contains("最多绑定5辆车"), exception.getMessage()); + } + + @Test + @DisplayName("绑定校验:空值与单辆车都放行") + void boundVehicleEmptyAndSingle() { + VehiclePlateSupport.validateBoundVehicles(null); + VehiclePlateSupport.validateBoundVehicles(""); + VehiclePlateSupport.validateBoundVehicles("桂A32523"); + } + + @Test + @DisplayName("绑定校验:车牌格式不合法时拒绝,且报出是哪个车牌") + void boundVehicleRejectsBadPlate() { + ServiceException exception = assertThrows(ServiceException.class, + () -> VehiclePlateSupport.validateBoundVehicles("桂A32523,乱写的车牌")); + assertTrue(exception.getMessage().contains("车牌号格式不正确"), exception.getMessage()); + } + + @Test + @DisplayName("车牌格式:普通、新能源(8位)、挂车/学车/警车都合法") + void validPlateFormats() { + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A32523")); + assertTrue(VehiclePlateSupport.isValidPlateNo("沪AE31312")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234挂")); + assertTrue(VehiclePlateSupport.isValidPlateNo("京A1234学")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A1234警")); + // 写法差异不影响判定:小写与带间隔符的车牌先规范化再比对 + assertTrue(VehiclePlateSupport.isValidPlateNo("桂a32523")); + assertTrue(VehiclePlateSupport.isValidPlateNo("桂A·32523")); + } + + @Test + @DisplayName("车牌格式:空值、乱码、位数不对一律不合法") + void invalidPlateFormats() { + assertFalse(VehiclePlateSupport.isValidPlateNo(null)); + assertFalse(VehiclePlateSupport.isValidPlateNo("")); + assertFalse(VehiclePlateSupport.isValidPlateNo("乱写的车牌")); + assertFalse(VehiclePlateSupport.isValidPlateNo("A32523")); + assertFalse(VehiclePlateSupport.isValidPlateNo("桂A325")); + } + @Test @DisplayName("空车牌列表不产生任何条件(避免 IN () 语法错误)") void emptyPlateListAddsNoCondition() { diff --git a/doc/sql/transport/blade_transport_driver.sql b/doc/sql/transport/blade_transport_driver.sql index d21eb393..19809bf5 100644 --- a/doc/sql/transport/blade_transport_driver.sql +++ b/doc/sql/transport/blade_transport_driver.sql @@ -13,7 +13,7 @@ CREATE TABLE `blade_transport_driver` ( `education` varchar(20) DEFAULT NULL COMMENT '学历', `address_region` varchar(100) DEFAULT NULL COMMENT '地址区划', `address` varchar(200) DEFAULT NULL COMMENT '详细地址', - `driving_vehicle` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号', + `driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔', `posts` varchar(100) DEFAULT NULL COMMENT '岗位,多个使用逗号分隔', `id_card_front` varchar(1000) DEFAULT NULL COMMENT '身份证正面照', `id_card_back` varchar(1000) DEFAULT NULL COMMENT '身份证反面照', diff --git a/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql new file mode 100644 index 00000000..34194030 --- /dev/null +++ b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql @@ -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 '驾驶车辆车牌号,多个用逗号分隔'; From 214ff38ef80451bee3cce7e7035dc19cd6e90b1c Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sat, 3 Oct 2026 15:35:22 +0800 Subject: [PATCH 20/26] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E9=81=93?= =?UTF-8?q?=E8=B7=AF=E8=BF=90=E8=BE=93=E8=AF=81OCR=E6=81=92=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=EF=BC=9A=E4=B8=8A=E6=B8=B8=E4=B8=8D=E5=BE=97=E6=9B=BF?= =?UTF-8?q?=E5=AE=83=E8=A1=A5=E9=BB=98=E8=AE=A4=E6=AD=A3=E5=8F=8D=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OcrTypeMapping.normalizeSide 对空值恒返回 front,控制器把这个 front 传给下游, 而道路运输证没有正副面参数(supportsSide 为 false),下游于是判 「传了 side 但该证件不支持」并直接抛错——小程序不传 side、传 front、 传空串三种方式实测全部失败,该证件 OCR 从来没能用过。 改为未指定时返回 null,把「默认取正面」交给知晓证件类型的一侧决定, 并补测试钉住这条契约。 --- .../controller/DriverAuthController.java | 3 +++ .../transport/support/OcrTypeMapping.java | 17 ++++++++++--- .../transport/support/OcrTypeMappingTest.java | 25 ++++++++++++++++--- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java index 6f7e7876..d759344b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAuthController.java @@ -143,6 +143,9 @@ public class DriverAuthController extends BladeController { public R 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())); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java index 61bf18f3..83afbf81 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrTypeMapping.java @@ -110,14 +110,23 @@ public final class OcrTypeMapping { /** * 规范化正反面参数 *

- * 小程序目前不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。 + * 小程序不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。 + *

+ * ⚠️ 调用方**必须原样把返回值交给下游,不要在传参前自行补默认值**: + * 这里的「默认正面」只对**支持正副面**的证件成立。道路运输证没有正副面参数, + * 若在调用方先把空值填成 {@code front} 再传下去,下游会看到「传了 side 但该证件 + * 不支持」而直接报错——本方法返回 null 才是「调用方没指定」,下游据此才分得清 + * 「默认取正面」和「这个证件根本不该带这个参数」。 * * @param side 正反面:front / back - * @return 规范化结果,恒为 front 或 back + * @return 规范化结果 front / back;调用方未指定时返回 null */ public static String normalizeSide(String side) { - // 归一化结果是大写,故此处比对 "BACK" - return "BACK".equals(normalize(side)) ? SIDE_BACK : SIDE_FRONT; + String normalized = normalize(side); + if (normalized.isEmpty()) { + return null; + } + return SIDE_BACK.equals(normalized.toLowerCase(Locale.ROOT)) ? SIDE_BACK : SIDE_FRONT; } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java index c88a8057..698d21df 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrTypeMappingTest.java @@ -5,6 +5,8 @@ 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; @@ -93,13 +95,28 @@ class OcrTypeMappingTest { } @Test - @DisplayName("正反面:小程序不传 side 时按正面处理") - void defaultSide() { - assertEquals("front", OcrTypeMapping.normalizeSide(null)); - assertEquals("front", OcrTypeMapping.normalizeSide("")); + @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()); + } + } From 04a7846328541640ae811aae54e8f3b3c5fa5f3f Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sat, 3 Oct 2026 16:34:47 +0800 Subject: [PATCH 21/26] =?UTF-8?q?:sparkles:=20=E8=BD=A6=E8=BE=86=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E6=89=A9=E5=88=B06=E5=BC=A0=E8=AF=81=E4=BB=B6?= =?UTF-8?q?=E5=9B=BE=EF=BC=8C=E5=AD=97=E6=AE=B5=E4=B8=8E=E8=BD=A6=E8=BE=86?= =?UTF-8?q?=E6=A1=A3=E6=A1=88=E5=AF=B9=E9=BD=90=EF=BC=9B=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=BB=BA=E8=BD=A6=E5=BF=85=E5=A1=AB=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 司机端车辆认证此前只有 3 张图、5 个字段,且语义与车辆管理不一致 (「副页」字段里装的其实是主页反面)。本次把证件内容整体对齐车辆表列: - 车辆表新增 owner_name(所有人:行驶证登记的单位名称,不是司机)与 vin; - 提交请求与司机端车辆卡片全量对齐表列,行驶证四张图各自独立、 不再把「主页反面」与「副页正面」挤在同一个字段; - 业务关系新增为司机自选(自有/管理挂靠/业务合作),不再由后端替用户猜。 顺带修掉三处会直接让功能不可用的缺陷: 1. 司机建车必失败:organization_name、approved_load_kg、business_relation、 energy_type、registration_no 五列 NOT NULL 且无默认值,而建车链路一个都不设置, 严格模式下 INSERT 直接报「doesn't have a default value」。已补齐并加服务端校验 (司机提交链路此前一句都不查,全靠前端)。 2. OCR 自动填充从未生效:后端按契约透传百度原始结果(中文键), 前端读的却是扁平字段名,永远 undefined 且不报错。新增 OcrFieldSupport 做归一化(键名别名容错、数值/日期/「长期」归一化、外廓尺寸拆三段、 有效期区间拆起止),响应的 fields 即前端直接回填的字段。 3. 审核通过不写回车辆档案:状态已通过、内容却还是旧的,自相矛盾。 现把生效快照的证件内容与指针合并成同一次 UPDATE 写回;快照是改造前 旧格式时明确报错,而不是静默写一堆空值。 --- .../pojo/dto/VehicleCertSubmitRequest.java | 70 ++- .../pojo/entity/TransportVehicle.java | 10 + .../transport/pojo/vo/BaiduOcrResultVO.java | 10 + .../pojo/vo/DriverVehicleCardVO.java | 92 +++- .../ocr/service/impl/BaiduOcrServiceImpl.java | 4 + .../service/impl/CertAuditServiceImpl.java | 68 +++ .../service/impl/DriverAppServiceImpl.java | 61 ++- .../service/impl/DriverCertServiceImpl.java | 152 ++++++- .../transport/support/OcrFieldSupport.java | 403 ++++++++++++++++++ .../impl/CertAuditServiceImplTest.java | 54 ++- .../impl/DriverCertServiceImplTest.java | 78 +++- .../support/OcrFieldSupportTest.java | 223 ++++++++++ doc/sql/transport/blade_transport_vehicle.sql | 2 + ...e_transport_vehicle_owner_vin_20261003.sql | 14 + 14 files changed, 1172 insertions(+), 69 deletions(-) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java create mode 100644 doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java index a05861a4..6cd65d78 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/VehicleCertSubmitRequest.java @@ -51,37 +51,65 @@ public class VehicleCertSubmitRequest implements Serializable { private Long id; @Schema(description = "车牌号") private String plateNo; - @Schema(description = "车辆类型") - private String vehicleType; - @Schema(description = "车辆识别代号(车架号)") - private String vin; - @Schema(description = "发动机号") - private String engineNo; + @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 licenseNo; - @Schema(description = "行驶证主页照片") - private String licenseFrontUrl; - @Schema(description = "行驶证副页照片") - private String licenseBackUrl; + 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 licenseValidStart; + private LocalDate drivingLicenseStartDate; @Schema(description = "行驶证有效期止") - private LocalDate licenseValidEnd; - @Schema(description = "行驶证长期有效") - private Integer licenseLongTerm; + private LocalDate drivingLicenseEndDate; + @Schema(description = "行驶证长期有效:0否 1是") + private Integer drivingLicenseLongTerm; @Schema(description = "道路运输证号") - private String roadTransportNo; + private String roadTransportCertNo; @Schema(description = "道路运输证照片") - private String roadTransportUrl; + private String roadTransportCertImage; @Schema(description = "道路运输证有效期起") - private LocalDate roadTransportValidStart; + private LocalDate roadTransportCertStartDate; @Schema(description = "道路运输证有效期止") - private LocalDate roadTransportValidEnd; - @Schema(description = "道路运输证长期有效") - private Integer roadTransportLongTerm; + private LocalDate roadTransportCertEndDate; + @Schema(description = "道路运输证长期有效:0否 1是") + private Integer roadTransportCertLongTerm; + + @Schema(description = "机动车登记编号") + private String registrationNo; + @Schema(description = "机动车登记本照片") + private String registrationImage; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java index 62f091f8..2e309bc6 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportVehicle.java @@ -66,6 +66,11 @@ public class TransportVehicle extends TenantEntity { */ @Schema(description = "车牌号") private String plateNo; + /** + * 所有人:行驶证登记的单位名称,不是司机 + */ + @Schema(description = "所有人(行驶证登记的单位名称,不是司机)") + private String ownerName; /** * 车牌颜色 */ @@ -76,6 +81,11 @@ public class TransportVehicle extends TenantEntity { */ @Schema(description = "车辆类型") private String vehicleType; + /** + * 车辆识别代号(车架号) + */ + @Schema(description = "车辆识别代号(车架号)") + private String vin; /** * 外廓长度,毫米 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java index d22af130..8b934612 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/BaiduOcrResultVO.java @@ -56,4 +56,14 @@ public class BaiduOcrResultVO implements Serializable { @Schema(description = "百度OCR原始返回结果") private Map result; + /** + * 归一化后的业务字段 + *

+ * 键与提交请求里的字段同名({@code plateNo}、{@code ownerName}…),调用方直接回填即可, + * 不需要认识百度那套中文键名。只包含**识别到**的字段,没识别到的不出现(而不是给空串), + * 调用方据此就能区分「没识别到」与「识别到空值」,不会把用户已填的内容覆盖成空。 + */ + @Schema(description = "归一化后的业务字段,键与提交请求同名") + private Map fields; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java index 46bbfc7e..1e8d48ed 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java @@ -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; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java index 3a606276..2ddbde55 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/ocr/service/impl/BaiduOcrServiceImpl.java @@ -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; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index b274345c..5da2ee5a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -25,6 +25,7 @@ */ 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; @@ -32,6 +33,7 @@ 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.VehicleCertSubmitRequest; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportCertRecord; import org.springblade.transport.pojo.entity.TransportVehicle; @@ -197,6 +199,11 @@ public class CertAuditServiceImpl implements ICertAuditService { vehicle.setCertificationRejectReason(reason); if (pass) { vehicle.setEffectiveCertRecordId(newEffectiveId); + // 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。 + // 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值—— + // 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。 + // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 + applySnapshotToVehicle(vehicle, record.getSnapshotJson()); } transportVehicleService.updateById(vehicle); return; @@ -211,6 +218,67 @@ public class CertAuditServiceImpl implements ICertAuditService { driverService.updateById(driver); } + /** + * 把生效快照里的车辆证件字段搬进车辆档案(就地写入同一个 patch 对象) + *

+ * 只搬**证件内容**,不碰两类字段: + *

    + *
  • {@code plateNo}:车牌是这辆车的身份,不是本次提交可以改的内容;
  • + *
  • {@code organizationName} / {@code useDepartment}:内部管理归属, + * 由管理端维护,不该被司机的提交内容覆盖。
  • + *
+ * 快照里为 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()); + } + /** * 取该对象当前的生效快照指针 * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java index 22c45d94..9acc9d76 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java @@ -42,6 +42,7 @@ import org.springblade.transport.support.VehiclePlateSupport; import org.springblade.transport.wrapper.DriverWrapper; import org.springframework.stereotype.Service; +import java.time.LocalDate; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -119,29 +120,55 @@ public class DriverAppServiceImpl implements IDriverAppService { 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; + /** + * 日期字段的展示值:长期有效显示「长期」,没填显示空串,其余显示日期 + *

+ * 卡片是「重新认证」表单的回填来源,空值必须是空串而不是「null」这类字符串, + * 否则用户会在日期框里看到一个字面量 null。 + * + * @param date 日期 + * @param longTerm 长期有效标志:1 表示长期 + * @return 展示文本 + */ + private String dateText(LocalDate date, Integer longTerm) { + if (date != null) { + return date.toString(); } - return Func.toStr(second, ""); + return Integer.valueOf(1).equals(longTerm) ? "长期" : ""; } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index 11976336..81699684 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -49,9 +49,11 @@ 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; /** @@ -70,11 +72,116 @@ import java.util.stream.Collectors; @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; + /** + * 司机端提交的车辆认证内容校验 + *

+ * 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过, + * 且这些字段里 {@code organization_name}、{@code approved_load_kg}、 + * {@code business_relation}、{@code energy_type}、{@code registration_no} + * 都是 NOT NULL 且无默认值——缺一个这条 INSERT 就直接报数据库错误, + * 用户看到的是一串英文异常,而不是「哪个字段没填」。 + * + * @param driver 当前司机 + * @param request 提交内容 + */ + private void validateCertContent(Driver driver, VehicleCertSubmitRequest request) { + if (Func.isEmpty(driver.getOrganizationName())) { + throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交"); + } + 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("请选择强制报废日期"); + } + } + + /** + * 文本必填 + 长度校验 + * + * @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); + } + } + + /** + * 有效期校验:未勾「长期有效」时起止必填,且止不得早于起 + * + * @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 (start == null || end == null) { + throw new ServiceException(emptyText); + } + if (end.isBefore(start)) { + throw new ServiceException(rangeText); + } + } + @Override @Transactional(rollbackFor = Exception.class) public Long submitPersonCert(PersonCertSubmitRequest request) { @@ -330,21 +437,44 @@ public class DriverCertServiceImpl implements IDriverCertService { */ private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request, String normalizedPlate) { + validateCertContent(driver, request); + 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.setDrivingLicenseNo(request.getLicenseNo()); - vehicle.setDrivingLicenseStartDate(request.getLicenseValidStart()); - vehicle.setDrivingLicenseEndDate(request.getLicenseValidEnd()); - vehicle.setDrivingLicenseLongTerm(request.getLicenseLongTerm()); - vehicle.setDrivingLicenseImage(request.getLicenseFrontUrl()); - vehicle.setDrivingLicenseMainBack(request.getLicenseBackUrl()); - vehicle.setRoadTransportCertNo(request.getRoadTransportNo()); - vehicle.setRoadTransportCertStartDate(request.getRoadTransportValidStart()); - vehicle.setRoadTransportCertEndDate(request.getRoadTransportValidEnd()); - vehicle.setRoadTransportCertLongTerm(request.getRoadTransportLongTerm()); - vehicle.setRoadTransportCertImage(request.getRoadTransportUrl()); + vehicle.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); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java new file mode 100644 index 00000000..ef4e7798 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/OcrFieldSupport.java @@ -0,0 +1,403 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is + * not liable for any claims arising from secondary or illegal development. + *

+ * 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 的原始结果归一化成业务字段 + *

+ * 百度返回的 {@code words_result} 键是**中文**(「号牌号码」「车辆识别代码」…), + * 同一含义在纸质证与电子证照之间还有不同写法。让小程序去认识这些中文键, + * 等于把百度接口的形状泄漏到前端,接口一改就要动多个端。 + *

+ * 归一化后下发的键与业务字段同名({@code plateNo}、{@code ownerName}…),前端拿到即可回填; + * 原始结果仍一并下发,归一化没覆盖到的字段不至于丢失。 + *

+ * 必须做别名容错的原因:官方文档列举的是「号牌号码」「车辆识别代码」, + * 而实际返回里也出现过「车牌号码」「车辆识别代号」这类写法,电子行驶证还多出 + * 「强制报废期止」「能源种类」等字段。写死单个键名的后果是**识别成功但什么也没填**—— + * 不报错、不抛异常,最难排查。 + * + * @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 extract(BaiduOcrType type, String side, JsonNode result) { + Map words = readWords(result); + Map fields = new LinkedHashMap<>(); + if (words.isEmpty()) { + return fields; + } + for (Map.Entry> 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 words, Map fields, + String alias, String startKey) { + if (fields.containsKey(startKey)) { + return; + } + String text = firstMatch(words, List.of(alias)); + if (Func.isEmpty(text)) { + return; + } + List dates = matchDates(text); + // 至少两段才算区间:只有一段说明那是止期本身,硬当起期会填错 + if (dates.size() >= 2) { + fields.put(startKey, dates.get(0)); + } + } + + /** + * 读取 {@code words_result} 成「中文键 → 文本值」 + * + * @param result 百度原始返回 + * @return 键值对;结构不符时返回空 Map + */ + private static Map readWords(JsonNode result) { + Map 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 words, List aliases) { + for (String alias : aliases) { + String value = words.get(alias); + if (Func.isNotEmpty(value)) { + return value; + } + } + // 精确键名没命中时按「包含」兜底:百度偶有「核定载质量(kg)」这类带后缀的键 + for (String alias : aliases) { + for (Map.Entry entry : words.entrySet()) { + if (entry.getKey().contains(alias) && Func.isNotEmpty(entry.getValue())) { + return entry.getValue(); + } + } + } + return null; + } + + /** + * 写入一个字段,按字段语义做值归一化 + *

+ * 数字字段转数字、日期字段转 {@code yyyy-MM-dd};「长期」不产出日期, + * 而是把对应的长期有效标志置 1——否则「长期」会被当成一个非法日期塞进日期框。 + * + * @param fields 结果集 + * @param key 业务字段名 + * @param raw 识别到的原始文本 + */ + private static void putField(Map 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); + } + + /** + * 「外廓尺寸」一项拆成外廓长/宽/高三个字段 + *

+ * 形如 {@code 5990×2300×3100},分隔符有 ×、x、*、空格等多种写法。 + * + * @param words 识别结果 + * @param fields 结果集 + */ + private static void splitOuterSize(Map words, Map fields) { + String size = firstMatch(words, List.of("外廓尺寸")); + if (Func.isEmpty(size)) { + return; + } + List 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> 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> idCardAliases() { + Map> 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> drivingLicenseAliases() { + Map> 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> vehicleLicenseAliases() { + Map> 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> roadTransportAliases() { + Map> 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); + } + + /** + * 日期归一化 + *

+ * 覆盖 {@code 2015年03月12日} / {@code 2015.3.9} / {@code 2015-03-12} / {@code 20150312} + * 这几种写法,统一成 {@code yyyy-MM-dd}。 + *

+ * 区间(如「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 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 matchDates(String text) { + List 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); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index e03f9d9d..352aef64 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -15,6 +15,7 @@ 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; @@ -75,6 +76,41 @@ class CertAuditServiceImplTest { "通过后指针应指向该记录"); } + @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 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() { @@ -252,16 +288,32 @@ class CertAuditServiceImplTest { // ------------------------------------------------------------ 辅助 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("{}"); + record.setSnapshotJson(snapshotJson); return record; } + /** + * 一份字段齐全的车辆认证快照 + *

+ * 审核通过时会把快照内容写回车辆档案,故用例不能再拿空快照走通过流程; + * 关键字段(档案编号、核定载质量)是「这份快照是否为当前格式」的判据。 + */ + 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); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index c1e48274..ea3dc404 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -17,9 +17,11 @@ 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; @@ -410,9 +412,7 @@ class DriverCertServiceImplTest { when(transportVehicleService.list(any(LambdaQueryWrapper.class))).thenReturn(List.of()); when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); - request.setPlateNo("桂A·32523"); // 带间隔符,应被规范化 - request.setVehicleType("重型货车"); + VehicleCertSubmitRequest request = completeVehicleRequest("桂A·32523"); service.submitVehicleCert(request); @@ -423,6 +423,14 @@ class DriverCertServiceImplTest { 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 driverCaptor = ArgumentCaptor.forClass(Driver.class); @@ -480,12 +488,76 @@ class DriverCertServiceImplTest { // ---------------------------------------------------------------- 辅助 + @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("所属组织")); + } + + /** + * 一份字段齐全的车辆认证提交内容 + *

+ * 车辆表有十个 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; } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java new file mode 100644 index 00000000..52530a56 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/OcrFieldSupportTest.java @@ -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 结果归一化测试。 + *

+ * 这一层挡的是「识别成功但什么都没填」——百度返回的是中文键,写死键名的代码在键名 + * 对不上的那天会静默失效:不抛异常、不报错,用户只看到识别完表单还是空的。 + * 故这里用真实形状的返回把别名容错与值归一化都钉住。 + */ +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 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 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 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 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 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 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 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 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 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 fields = OcrFieldSupport.extract(BaiduOcrType.VEHICLE_LICENSE, "front", result); + + assertFalse(fields.containsKey("outerLength")); + assertFalse(fields.containsKey("outerWidth")); + assertFalse(fields.containsKey("outerHeight")); + } + +} diff --git a/doc/sql/transport/blade_transport_vehicle.sql b/doc/sql/transport/blade_transport_vehicle.sql index 43000c2b..1aa8f30b 100644 --- a/doc/sql/transport/blade_transport_vehicle.sql +++ b/doc/sql/transport/blade_transport_vehicle.sql @@ -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 '外廓高度,毫米', diff --git a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql new file mode 100644 index 00000000..817034f3 --- /dev/null +++ b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql @@ -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`; From 11e62c172c1a2e34d08c28f025ff90a17e63c4c7 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sat, 3 Oct 2026 17:46:53 +0800 Subject: [PATCH 22/26] =?UTF-8?q?:bug:=20=E4=BF=AE=E8=BD=A6=E8=BE=86?= =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=AE=A4=E8=AF=81=E9=95=BF=E6=9C=9F=E6=9C=89?= =?UTF-8?q?=E6=95=88=E5=AD=97=E6=AE=B5=E5=9B=9E=E6=98=BE=EF=BC=8C=E6=9C=89?= =?UTF-8?q?=E6=95=88=E6=9C=9F=E4=B8=8D=E5=86=8D=E8=A6=81=E6=B1=82=E8=B5=B7?= =?UTF-8?q?=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 卡片接口不再把「长期」当日期下发,也不在长期有效时下发上一版证件残留的止期: 两者都会被「重新认证」表单原样回填再原样提交,前者进日期框后按 LocalDate 反序列化直接报错, 后者会让一辆长期有效的车带着一个早已作废的止期一路写回车辆行。 - 车辆有效期校验收敛为「止期必填、起期可空」,和管理端车辆表单同一口径: 起期只在两端都有值时才比较先后。司机端表单本来也不采集起期。 - 补 6 个测试:3 个盯卡片回显(含长期有效 + 残留止期、无止期两种), 3 个盯有效期校验(起期可空、止期必填、长期有效时止期可空)。 --- .../service/impl/DriverAppServiceImpl.java | 21 ++- .../service/impl/DriverCertServiceImpl.java | 16 ++- .../impl/DriverAppServiceImplTest.java | 125 ++++++++++++++++++ .../impl/DriverCertServiceImplTest.java | 59 +++++++++ 4 files changed, 208 insertions(+), 13 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java index 9acc9d76..ee8feb27 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java @@ -155,20 +155,27 @@ public class DriverAppServiceImpl implements IDriverAppService { } /** - * 日期字段的展示值:长期有效显示「长期」,没填显示空串,其余显示日期 + * 日期字段的下发值:只下发 {@code yyyy-MM-dd} 或空串,**从不下发「长期」这类文字** *

- * 卡片是「重新认证」表单的回填来源,空值必须是空串而不是「null」这类字符串, - * 否则用户会在日期框里看到一个字面量 null。 + * 卡片是「重新认证」表单的回填来源,值会直接进日期框,所以两条约束必须守住: + *

    + *
  • 长期有效时下发**空串**:长期与否由同名字段 {@code xxxLongTerm} 表达, + * 界面上显示「长期」是前端的事。把「长期」当日期下发,用户关掉长期有效开关后 + * 日期框里会留着一个字面量「长期」,提交时后端按 {@code LocalDate} 反序列化直接报错。
  • + *
  • 长期有效时**不下发止期**:止期在「长期」语义下已作废(管理端勾长期有效时也会清掉它), + * 下发旧值会被表单原样回传、审核通过后又写回车辆行,形成「长期有效 + 一个过期止期」 + * 的脏数据——车辆行上一次长期有效的提交正是这样留下 2040-08-14 的。
  • + *
* * @param date 日期 * @param longTerm 长期有效标志:1 表示长期 - * @return 展示文本 + * @return {@code yyyy-MM-dd} 或空串 */ private String dateText(LocalDate date, Integer longTerm) { - if (date != null) { - return date.toString(); + if (Integer.valueOf(1).equals(longTerm)) { + return ""; } - return Integer.valueOf(1).equals(longTerm) ? "长期" : ""; + return date == null ? "" : date.toString(); } /** diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index 81699684..d02d556e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -161,23 +161,27 @@ public class DriverCertServiceImpl implements IDriverCertService { } /** - * 有效期校验:未勾「长期有效」时起止必填,且止不得早于起 + * 有效期校验:未勾「长期有效」时**止期**必填;勾了长期有效则止期也不要求 + *

+ * 起期不再是必填:司机端表单不采集它(管理端车辆表单同样只填止期), + * 存量车辆档案里起期列全是空的,强制必填会让每一次重新认证都卡在提交前。 + * 起止先后只在**两端都有值**时才比较——只有止期时没有可比的对象。 * - * @param start 有效期起 + * @param start 有效期起,可为空 * @param end 有效期止 * @param longTerm 长期有效标志:1 表示长期 - * @param emptyText 缺值提示 - * @param rangeText 区间颠倒提示 + * @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 (start == null || end == null) { + if (end == null) { throw new ServiceException(emptyText); } - if (end.isBefore(start)) { + if (start != null && end.isBefore(start)) { throw new ServiceException(rangeText); } } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java new file mode 100644 index 00000000..0894e214 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverAppServiceImplTest.java @@ -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; + +/** + * 司机端车辆卡片(重新认证表单的数据来源)测试。 + *

+ * 卡片字段会**直接进表单**,所以这里盯的不是「显示得好不好看」,而是两条会让回显出错的契约: + * 日期字段只能是 {@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 = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + // 账号本身就是手机号,resolvePhone 走这条短路分支,不再回调用户中心 + authUtil.when(AuthUtil::getUserAccount).thenReturn(MOBILE); + List cards = service.myVehicles(); + assertEquals(1, cards.size(), "应当只返回绑定到本人的这一辆车"); + return cards.get(0); + } + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index ea3dc404..61afbac0 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -519,6 +519,65 @@ class DriverCertServiceImplTest { 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 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 captor = ArgumentCaptor.forClass(TransportCertRecord.class); + verify(certRecordService).save(captor.capture()); + assertTrue(captor.getValue().getSnapshotJson().contains("\"drivingLicenseLongTerm\":1"), + "长期有效标志要落进快照"); + } + /** * 一份字段齐全的车辆认证提交内容 *

From ee135cd15074f2710c5266bc84c0d9d8a05d3c04 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sat, 3 Oct 2026 17:56:56 +0800 Subject: [PATCH 23/26] =?UTF-8?q?:bug:=20=E9=87=8D=E6=96=B0=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E4=B9=9F=E8=A6=81=E8=BF=87=E5=86=85=E5=AE=B9=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=8F=AA=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E6=96=B0=E5=BB=BA=E8=BD=A6=E8=BE=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 起先 validateCertContent 只在 createVehicleForDriver 里调用,走 request.id 的重新认证路径 一句都不校验:待审内容虽然要等审核通过才写进车辆档案,但它已经是一条不可变记录, 并会在通过时按字段写回车辆行,于是「内容校验」对重新认证形同虚设。 - 内容校验提到 submitVehicleCert 里、resolveBoundVehicle 之前,两条路径共用; - 「所属组织非空」拆成 requireOrganizationForNewVehicle,仍只在新车建档时要求: 该列只在 INSERT 时需要,它属于内部管理归属,拿它拦住重新认证只会让司机无从下手。 - 补 2 个用例:重新认证传空「所有人」必须被拒;司机档案没有所属组织时重新认证仍应放行。 - 顺带把 7 个此前只传车牌/ID 的用例改用字段齐全的请求:否则它们会因为「车牌颜色不能为空」 提前失败,看着还绿、其实已经不再验证原本要验证的越权与作废逻辑。 --- .../service/impl/DriverCertServiceImpl.java | 36 ++++++---- .../impl/DriverCertServiceImplTest.java | 66 ++++++++++++++----- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index d02d556e..524e81d4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -90,19 +90,14 @@ public class DriverCertServiceImpl implements IDriverCertService { /** * 司机端提交的车辆认证内容校验 *

- * 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过, - * 且这些字段里 {@code organization_name}、{@code approved_load_kg}、 - * {@code business_relation}、{@code energy_type}、{@code registration_no} - * 都是 NOT NULL 且无默认值——缺一个这条 INSERT 就直接报数据库错误, - * 用户看到的是一串英文异常,而不是「哪个字段没填」。 + * 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过。 + * 校验发生在**落快照之前**——待审内容虽然要等审核通过才写进车辆档案,但它已经是一条 + * 不可变记录、并会在通过时按字段写回车辆行({@code applySnapshotToVehicle}), + * 校验只做在「新建车辆」那一条路上,重新认证就成了绕过校验的后门。 * - * @param driver 当前司机 * @param request 提交内容 */ - private void validateCertContent(Driver driver, VehicleCertSubmitRequest request) { - if (Func.isEmpty(driver.getOrganizationName())) { - throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交"); - } + 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); @@ -128,6 +123,21 @@ public class DriverCertServiceImpl implements IDriverCertService { } } + /** + * 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT + * 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。 + *

+ * 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属), + * 拿它拦住重新认证只会让司机无从下手。 + * + * @param driver 当前司机 + */ + private void requireOrganizationForNewVehicle(Driver driver) { + if (Func.isEmpty(driver.getOrganizationName())) { + throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交"); + } + } + /** * 文本必填 + 长度校验 * @@ -226,6 +236,9 @@ public class DriverCertServiceImpl implements IDriverCertService { throw new ServiceException("认证内容不能为空"); } Driver driver = requireCurrentDriver(); + // 校验在 resolveBoundVehicle 之前:它既覆盖「新建车辆」,也覆盖「重新认证」—— + // 后者原先一点校验都不做,内容全靠审核环节兜底 + validateCertContent(request); TransportVehicle vehicle = resolveBoundVehicle(driver, request); String snapshotJson = JSON.toJSONString(request); @@ -441,7 +454,8 @@ public class DriverCertServiceImpl implements IDriverCertService { */ private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request, String normalizedPlate) { - validateCertContent(driver, request); + // 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件 + requireOrganizationForNewVehicle(driver); TransportVehicle vehicle = new TransportVehicle(); vehicle.setPlateNo(normalizedPlate); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java index 61afbac0..43cac1ed 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverCertServiceImplTest.java @@ -73,9 +73,8 @@ class DriverCertServiceImplTest { when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)) .thenReturn(null); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); request.setId(VEHICLE_ID); - request.setPlateNo(PLATE); request.setVin("VIN123"); service.submitVehicleCert(request); @@ -99,9 +98,8 @@ class DriverCertServiceImplTest { when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null)); when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); - VehicleCertSubmitRequest vehicleRequest = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest vehicleRequest = completeVehicleRequest(PLATE); vehicleRequest.setId(VEHICLE_ID); - vehicleRequest.setPlateNo(PLATE); service.submitVehicleCert(vehicleRequest); PersonCertSubmitRequest personRequest = new PersonCertSubmitRequest(); @@ -129,7 +127,7 @@ class DriverCertServiceImplTest { .thenReturn(vehicle(VEHICLE_ID, PLATE, effectiveId)); when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); request.setId(VEHICLE_ID); service.submitVehicleCert(request); @@ -151,7 +149,7 @@ class DriverCertServiceImplTest { when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID)) .thenReturn(previous); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); request.setId(VEHICLE_ID); service.submitVehicleCert(request); @@ -169,7 +167,7 @@ class DriverCertServiceImplTest { // selectPendingRecord 只返回待审;已通过的不该被它返回 when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); request.setId(VEHICLE_ID); service.submitVehicleCert(request); @@ -185,7 +183,7 @@ class DriverCertServiceImplTest { when(transportVehicleService.getById(VEHICLE_ID)) .thenReturn(vehicle(VEHICLE_ID, "桂B99999", null)); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); request.setId(VEHICLE_ID); assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); @@ -200,7 +198,7 @@ class DriverCertServiceImplTest { when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null)); when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523"); request.setId(VEHICLE_ID); service.submitVehicleCert(request); @@ -212,7 +210,7 @@ class DriverCertServiceImplTest { void submitVehicleRejectsWhenNoBoundVehicle() { when(driverAppService.currentDriverEntity()).thenReturn(driver(null)); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); + VehicleCertSubmitRequest request = completeVehicleRequest(PLATE); request.setId(VEHICLE_ID); assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); @@ -448,8 +446,7 @@ class DriverCertServiceImplTest { when(transportVehicleService.list(any(LambdaQueryWrapper.class))) .thenReturn(List.of(vehicle(VEHICLE_ID, "桂A32523", null))); // 已登记,但不在本人名下 - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); - request.setPlateNo("桂A32523"); + VehicleCertSubmitRequest request = completeVehicleRequest("桂A32523"); assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); verify(transportVehicleService, never()).save(any(TransportVehicle.class)); @@ -464,8 +461,7 @@ class DriverCertServiceImplTest { .thenReturn(List.of(vehicle(VEHICLE_ID, "桂A·32523", null))); // 写法不同但同一辆 when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); - request.setPlateNo("桂a32523"); + VehicleCertSubmitRequest request = completeVehicleRequest("桂a32523"); service.submitVehicleCert(request); @@ -480,14 +476,50 @@ class DriverCertServiceImplTest { void submitVehicleRejectsBlankPlate() { when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A32523")); - VehicleCertSubmitRequest request = new VehicleCertSubmitRequest(); - request.setPlateNo(" "); + // 内容给全,确保被拒的原因是车牌为空而不是别的字段(否则这条用例形同虚设) + VehicleCertSubmitRequest request = completeVehicleRequest(" "); - assertThrows(ServiceException.class, () -> service.submitVehicleCert(request)); + 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() { From 17ae36710056df7ab860d24302b7434ee6b12108 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 19:16:38 +0800 Subject: [PATCH 24/26] =?UTF-8?q?:bug:=20=E8=AE=A4=E8=AF=81=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E8=8F=9C=E5=8D=95=E5=90=8D=E6=94=B9=E4=B8=BA=E3=80=8C?= =?UTF-8?q?=E5=8F=B8=E6=9C=BA=E5=AE=A1=E6=A0=B8=20/=20=E8=BD=A6=E8=BE=86?= =?UTF-8?q?=E5=AE=A1=E6=A0=B8=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原名「认证审核-司机 / 认证审核-车辆」与需求文档的表述不一致, 后台菜单显示为「司机审核」「车辆审核」——与 .scratch 里 审核页分页签那轮需求的措辞对齐。 只改 blade_menu 的 name 字段,code / alias / path / 权限码均不变, 故不影响权限校验与前端路由。 注意:该 INSERT 是 ON DUPLICATE KEY UPDATE,改脚本本身不会改库里已有的行, 要在已有环境生效需重跑本脚本(新环境按脚本建库则直接是新名)。 --- doc/sql/transport/blade_transport_cert_record_20261002.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql index 09bba84d..de7ad409 100644 --- a/doc/sql/transport/blade_transport_cert_record_20261002.sql +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -156,9 +156,9 @@ DELIMITER ; -- 列表页权限码 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), +(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), +(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`), From 4fa6d4c430724c1e0af783e11687e6825ff54182 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 22:11:04 +0800 Subject: [PATCH 25/26] =?UTF-8?q?:bug:=20=E4=B8=AA=E4=BA=BA=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=80=9A=E8=BF=87=E5=90=8E=E5=86=99=E5=9B=9E=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E6=A1=A3=E6=A1=88=EF=BC=8C=E5=B9=B6=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E8=B5=84=E6=A0=BC=E8=AF=81=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 个人认证通过时只切了生效指针与认证状态,证件内容一个字都没落到 blade_transport_driver:认证状态显示「已通过」,司机档案里却仍是 管理端当初录入的旧值(甚至为空),管理端与司机端各看各的。 车辆侧早有 applySnapshotToVehicle 做这件事,司机侧一直没有对应实现。 - 新增 applySnapshotToDriver,审核通过时与指针合并在同一次 UPDATE 写回。 只搬司机申报的证件内容,不碰身份标识(姓名/身份证号,由管理端建档确定) 与内部管理归属(司机类型/岗位/驾驶车辆/所属组织)。 - 快照缺失、解析失败或字段对不上时只跳过写回,不阻断审核:审核是独立于 写回的业务动作,不该因为一份读不懂的快照而失败,否则历史快照对应的 对象再也审不过去。 - PersonCertSubmitRequest 补 qualificationType / qualificationBack / qualificationValidStart / qualificationLongTerm 四个字段。 - qualificationUrl 改名为 qualificationFront,与司机档案列 qualification_front 及管理端用词统一;改名前的记录存的仍是旧键, 写回时回退读一次旧键,避免已填的封面页在审核通过时被丢掉。 - 补测试:证件内容写回、不覆盖身份字段、驳回不写回、历史快照跳过写回 但仍照常通过审核。 qualificationValidStart 只进快照、不写档案:司机档案表没有对应的开始日期列, 管理端的「从业资格证有效期起」同样是只收不存。此处保持一致,不新增列。 --- .../pojo/dto/PersonCertSubmitRequest.java | 12 +- .../service/impl/CertAuditServiceImpl.java | 89 +++++++++++++ .../service/impl/PersonCertRoundTripTest.java | 119 +++++++++++++++++- 3 files changed, 217 insertions(+), 3 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java index 29f20d5c..c33d030f 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PersonCertSubmitRequest.java @@ -77,12 +77,20 @@ public class PersonCertSubmitRequest implements Serializable { @Schema(description = "驾驶证长期有效") private Integer driverLongTerm; + @Schema(description = "从业资格证类型") + private String qualificationType; @Schema(description = "从业资格证号") private String qualificationNo; - @Schema(description = "从业资格证照片") - private String qualificationUrl; + @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; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index 5da2ee5a..89d0b0e6 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -33,6 +33,7 @@ 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; @@ -214,10 +215,98 @@ public class CertAuditServiceImpl implements ICertAuditService { driver.setCertificationRejectReason(reason); if (pass) { driver.setEffectiveCertRecordId(newEffectiveId); + // 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。 + // 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空), + // 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。 + // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 + applySnapshotToDriver(driver, record.getSnapshotJson()); } driverService.updateById(driver); } + /** + * 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象) + *

+ * 只搬**司机自己申报的证件内容**,不碰两类字段: + *

    + *
  • {@code realName} / {@code idCardNo}:身份标识, + * 由管理端建档时确定,司机端提交不覆盖(重复认证时会按证件号校验为同一人);
  • + *
  • {@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}: + * 内部管理归属与派工关系,由管理端维护,不该被司机的提交内容覆盖。
  • + *
+ * 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), + * 因此不会把管理端填过、司机端没填的字段清空。 + *

+ * 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**: + * 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败; + * 何况历史快照本来就存在,阻断会让这些对象再也审不过去。 + * + * @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()); + } + + /** + * 取从业资格证封面页地址,兼容改名前的旧键 + *

+ * 该字段原叫 {@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 对象) *

diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java index deb2de2b..b72b4182 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/PersonCertRoundTripTest.java @@ -65,12 +65,14 @@ class PersonCertRoundTripTest { 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; }); @@ -208,6 +210,48 @@ class PersonCertRoundTripTest { return JSON.parseObject(JSON.toJSONString(source), TransportCertRecord.class); } + /** + * 模拟框架的 not_null 更新策略:patch 里为 null 的列不写,非 null 才覆盖 + *

+ * 单测里直接整对象覆盖会丢掉这个语义,从而验不出「管理端填过、司机端没填的字段被清空」 + * 这类只有真实更新策略才暴露的问题。 + */ + 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); @@ -217,4 +261,77 @@ class PersonCertRoundTripTest { 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(), "指针仍应照常切换"); + } } From 7c4c887d74fd139582e3a5353239e5fe9bd2f2f7 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 23:24:22 +0800 Subject: [PATCH 26/26] =?UTF-8?q?:bug:=20=E4=BF=AE=E8=AE=A4=E8=AF=81?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=E6=B8=85=E7=A9=BA=E5=8F=B8=E6=9C=BA=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E7=BB=91=E5=AE=9A=EF=BC=8C=E5=AF=BC=E8=87=B4=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E5=88=97=E8=A1=A8=E6=B6=88=E5=A4=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Driver.drivingVehicle 标了 @TableField(updateStrategy = FieldStrategy.ALWAYS), 该策略忽略 null 直接写:任何一次 updateById 只要 patch 里没带这个字段, 就会把库里的 driving_vehicle 写成 NULL。而司机端的车辆列表正是按这一列 的车牌匹配出来的(DriverAppServiceImpl.myVehicles 开头就判空返回空列表), 清空即页面显示「暂无绑定车辆」,且司机在界面上无法自行恢复。 两条链路都会触发: - 提交认证:updateDriverDisplayCache 只 patch id/状态/驳回原因; - 审核通过:本次改动新增的快照写回,同样只 patch 证件字段。 已在两处 updateById 前把该车的原值搬进 patch,并补回归测试: 先确认去掉修复后测试确实失败(expected 桂A12345,桂B67890 but was null)。 保留 ALWAYS 策略不动——它保证了「能显式清空车辆绑定」这一能力, 问题在于 patch 式更新必须自己带上原值,而不是删掉策略。 --- .../service/impl/CertAuditServiceImpl.java | 22 +++++++++++++++++++ .../service/impl/DriverCertServiceImpl.java | 7 ++++++ .../impl/CertAuditServiceImplTest.java | 19 ++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java index 89d0b0e6..d1bedbb3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java @@ -213,6 +213,10 @@ public class CertAuditServiceImpl implements ICertAuditService { 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); // 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。 @@ -224,6 +228,24 @@ public class CertAuditServiceImpl implements ICertAuditService { driverService.updateById(driver); } + /** + * 把司机当前的车辆绑定原值搬进 patch,避免被 ALWAYS 更新策略清空 + *

+ * {@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 对象) *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java index 524e81d4..7246529d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverCertServiceImpl.java @@ -368,6 +368,13 @@ public class DriverCertServiceImpl implements IDriverCertService { 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); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java index 352aef64..ae268991 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java @@ -155,6 +155,25 @@ class CertAuditServiceImplTest { 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 captor = ArgumentCaptor.forClass(Driver.class); + verify(driverService).updateById(captor.capture()); + // driving_vehicle 标了 FieldStrategy.ALWAYS,patch 里为 null 就会把该列写成 NULL; + // 而司机端车辆列表正是按这一列匹配的,清空即「暂无绑定车辆」且界面上无法恢复 + assertEquals("桂A12345,桂B67890", captor.getValue().getDrivingVehicle(), + "审核通过不得把司机的车辆绑定清空"); + } + // ------------------------------------------------------------ 驳回 @Test