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/81] =?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/81] =?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/81] =?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/81] =?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/81] =?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/81] =?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 683351d7642c9c714163cb9bf52fce9b53009150 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 4 Oct 2026 20:26:16 +0800 Subject: [PATCH 25/81] =?UTF-8?q?:sparkles:=20=E6=94=BE=E5=BC=80=E7=BB=84?= =?UTF-8?q?=E7=BB=87=E7=B1=BB=E5=9E=8B=E5=B1=82=E7=BA=A7=E7=BA=A6=E6=9D=9F?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=B0=83=E5=BA=A6=E6=95=B0?= =?UTF-8?q?=E9=87=8F=E5=8D=95=E4=BD=8D=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组织树不再按类型约束上下级;计量维度扩展整车/里程/吨·公里;运单从货物明细补齐数量单位,用户查询支持模糊匹配。 Co-authored-by: Cursor --- .../springblade/system/mapper/UserMapper.xml | 9 ++- .../system/service/impl/DeptServiceImpl.java | 25 +------ .../impl/MeasurementUnitServiceImpl.java | 2 +- .../service/impl/WaybillServiceImpl.java | 69 ++++++++++++++++++- 4 files changed, 77 insertions(+), 28 deletions(-) diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml index 70dd2e9a..79fcb79f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml @@ -44,13 +44,16 @@ and bu.tenant_id = #{user.tenantId} - and bu.account = #{user.account} + and bu.account like concat(concat('%', #{user.account}), '%') - and bu.real_name = #{user.realName} + and bu.real_name like concat(concat('%', #{user.realName}), '%') - and bu.phone = #{user.phone} + and ( + bu.phone like concat(concat('%', #{user.phone}), '%') + or bu.account like concat(concat('%', #{user.phone}), '%') + ) and bu.user_type = #{user.userType} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index a07ec19c..d94020a4 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -437,7 +437,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (Func.isEmpty(dept.getTenantId())) { throw new ServiceException("租户ID不能为空"); } - validateDeptCategory(dept, parent); + validateDeptCategory(dept); validateDeptCode(dept, parent); return saveOrUpdate(dept); } @@ -619,27 +619,8 @@ public class DeptServiceImpl extends ServiceImpl implements ID return "Basic " + value; } - private void validateDeptCategory(Dept dept, Dept parent) { - // 顶级组织不要求选择上级组织 - if (parent == null) { - if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) { - throw new ServiceException("请选择承运商"); - } - return; - } - List allowedCategories; - if (BladeConstant.TOP_PARENT_ID.equals(parent.getParentId())) { - allowedCategories = "外部组织".equals(parent.getDeptName()) ? List.of(6) : List.of(1); - } else if (Integer.valueOf(1).equals(parent.getDeptCategory())) { - allowedCategories = List.of(2, 3); - } else if (List.of(2, 3).contains(parent.getDeptCategory())) { - allowedCategories = List.of(4, 5, 6); - } else { - throw new ServiceException("上级组织层级不支持新增下级组织"); - } - if (!allowedCategories.contains(dept.getDeptCategory())) { - throw new ServiceException("组织类型不符合上级组织层级规则"); - } + private void validateDeptCategory(Dept dept) { + // 组织树层级仅由 parentId / ancestors 决定,组织类型不再做上下级约束 if (Integer.valueOf(6).equals(dept.getDeptCategory()) && Func.isEmpty(dept.getCarrierCustomerId())) { throw new ServiceException("请选择承运商"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java index df18f4f4..4c43c474 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java @@ -50,7 +50,7 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl DIMENSIONS = Set.of("重量", "体积", "数量"); + private static final Set DIMENSIONS = Set.of("重量", "体积", "数量", "整车", "里程", "吨·公里"); @Override public IPage selectMeasurementUnitPage(IPage page, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 4d2217c6..5da21fac 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1458,7 +1458,9 @@ public class WaybillServiceImpl extends BaseServiceImpl } /** - * 完整录入多货物时,运单主表 quantity 按 goodsJson 数量合计回写,避免只落首行数量。 + * 从 goodsJson 回写运单主表货物字段: + * 1. 顶层数量单位 / 货物名称 / 货物类型为空时,取首条有效货物补齐; + * 2. 完整录入多货物时,数量按 goodsJson 合计回写。 */ private void syncQuantityFromGoodsJson(Waybill waybill) { if (waybill == null || Func.isEmpty(waybill.getGoodsJson())) { @@ -1470,7 +1472,53 @@ public class WaybillServiceImpl extends BaseServiceImpl } catch (Exception exception) { return; } - if (Func.isEmpty(goodsList) || goodsList.size() <= 1) { + if (Func.isEmpty(goodsList)) { + return; + } + Map firstGoods = null; + for (Map goods : goodsList) { + if (goods == null) { + continue; + } + firstGoods = goods; + break; + } + if (firstGoods != null) { + if (Func.isEmpty(waybill.getQuantityUnit())) { + String quantityUnit = firstNonBlank(firstGoods, "quantityUnit", "goodsQuantityUnit", "unit"); + if (Func.isNotEmpty(quantityUnit)) { + waybill.setQuantityUnit(quantityUnit); + } + } + if (Func.isEmpty(waybill.getCargoName())) { + String cargoName = firstNonBlank(firstGoods, "cargoName", "goodsName"); + if (Func.isNotEmpty(cargoName)) { + waybill.setCargoName(cargoName); + } + } + if (Func.isEmpty(waybill.getCargoType())) { + String cargoType = firstNonBlank(firstGoods, "cargoType", "goodsType"); + if (Func.isNotEmpty(cargoType)) { + waybill.setCargoType(cargoType); + } + } + if (waybill.getQuantity() == null && goodsList.size() == 1) { + Object quantityValue = firstGoods.get("quantity"); + if (quantityValue == null) { + quantityValue = firstGoods.get("cargoQuantity"); + } + if (quantityValue == null) { + quantityValue = firstGoods.get("goodsQuantity"); + } + if (quantityValue != null && Func.isNotEmpty(String.valueOf(quantityValue).trim())) { + BigDecimal quantity = new BigDecimal(String.valueOf(quantityValue).trim()); + if (!isSentinelMinusOne(quantity)) { + waybill.setQuantity(quantity); + } + } + } + } + if (goodsList.size() <= 1) { return; } BigDecimal total = BigDecimal.ZERO; @@ -1491,6 +1539,23 @@ public class WaybillServiceImpl extends BaseServiceImpl } } + private String firstNonBlank(Map goods, String... keys) { + if (goods == null || keys == null) { + return null; + } + for (String key : keys) { + Object value = goods.get(key); + if (value == null) { + continue; + } + String text = String.valueOf(value).trim(); + if (Func.isNotEmpty(text)) { + return text; + } + } + return null; + } + private void validate(Waybill waybill) { TransportBusinessSupport.validateRequired(waybill.getProjectName(), "项目不能为空"); TransportBusinessSupport.validateRequired(waybill.getContractName(), "客户合同不能为空"); From 7e9caf50453eeab83a5720e9e8a8163783bff6df Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 4 Oct 2026 20:48:03 +0800 Subject: [PATCH 26/81] =?UTF-8?q?:bug:=20=E5=85=BC=E5=AE=B9=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=9F=A5=E8=AF=A2=E4=BC=A0=E5=85=A5=E9=80=97=E5=8F=B7?= =?UTF-8?q?=E5=88=86=E9=9A=94=E7=9A=84=E7=BB=84=E7=BB=87ID?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户绑定多个组织时 deptId 为逗号拼接字符串,绑定为 Long 会失败,现取第一个有效 ID。 Co-authored-by: Cursor --- .../transport/config/DeptIdBindingAdvice.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/config/DeptIdBindingAdvice.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/DeptIdBindingAdvice.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/DeptIdBindingAdvice.java new file mode 100644 index 00000000..61845a9b --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/DeptIdBindingAdvice.java @@ -0,0 +1,58 @@ +/** + * 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.config; + +import org.springblade.core.tool.utils.Func; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.InitBinder; + +import java.beans.PropertyEditorSupport; + +/** + * 列表查询 deptId 绑定:用户可能绑定多个组织,前端会传入逗号分隔字符串。 + * 组织筛选项为单选,这里取第一个有效 ID,避免 Long 类型转换失败。 + * + * @author Chill + */ +@ControllerAdvice(basePackages = "org.springblade.transport.controller") +public class DeptIdBindingAdvice { + + @InitBinder + public void initBinder(WebDataBinder binder) { + binder.registerCustomEditor(Long.class, "deptId", new PropertyEditorSupport() { + @Override + public void setAsText(String text) { + if (Func.isBlank(text)) { + setValue(null); + return; + } + setValue(Func.firstLong(text)); + } + }); + } + +} From 4fa6d4c430724c1e0af783e11687e6825ff54182 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 22:11:04 +0800 Subject: [PATCH 27/81] =?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 28/81] =?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 From 378aa118427068d2c049e85e19d56d04bfc6c16a Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 23:43:05 +0800 Subject: [PATCH 29/81] =?UTF-8?q?:recycle:=20=E8=AE=A4=E8=AF=81=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E8=84=9A=E6=9C=AC=E8=A1=A5=E5=B9=82=E7=AD=89=E5=B9=B6?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B3=A8=E9=87=8A=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三个认证相关迁移脚本按项目既有约定整理,DDL 效果不变: - driving_vehicle 扩长、owner_name/vin 加列两处原为裸 ALTER,重复执行会报 「Duplicate column」或白白重建表;改为 DELIMITER 存储过程 + information_schema 判定,与 doc/sql/transport 下 upgrade_* 的既有写法一致,可重复执行。 扩长那条额外判定 character_maximum_length < 100:MODIFY COLUMN 会重建整表, 已达标就不必再跑一次。 - cert_record 的菜单段补注 parent_id 的出处(与 blade_transport_driver.sql / blade_transport_vehicle.sql / blade_vehicle_dispatch.sql 同一 ID),并说明 ON DUPLICATE KEY UPDATE 会把 parent_id / name 回写,对旧环境重跑即可收敛。 - 去掉注释里的 markdown 粗体(doc/sql/transport 的既有排版不用 markdown), 统一以 30 个破折号的横幅开头。 已在 dev 库 transport 连跑两遍验证:两遍均无报错,且列定义 116 行、索引 29 行、 三表排序规则与整理前逐项一致。 --- .../blade_transport_cert_record_20261002.sql | 19 +++++--- ..._driver_driving_vehicle_widen_20261003.sql | 34 ++++++++++++-- ...e_transport_vehicle_owner_vin_20261003.sql | 44 ++++++++++++++++--- 3 files changed, 82 insertions(+), 15 deletions(-) diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql index de7ad409..e5c445e8 100644 --- a/doc/sql/transport/blade_transport_cert_record_20261002.sql +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -1,14 +1,16 @@ +-- ---------------------------- -- 认证记录数据模型:新建 blade_transport_cert_record,为司机表、车辆表补认证字段与 -- 生效快照指针,订正司机表排序规则,并新增认证审核菜单。 --- +-- ---------------------------- -- 认证对象(司机 / 车辆)每次提交产生一条不可变记录,审核只改记录的状态, -- 不覆盖已提交的内容。司机与车辆共用本表,由 biz_type 区分。 -- --- ⚠️ effective_cert_record_id(生效快照指针)是判定「该对象是否已认证」的唯一依据: --- NULL = 从未审核通过;一经写入永不回退,提交、驳回、作废都不得修改它。 --- 因此「重新提交审核」不会让一辆已认证的车掉出可用池。 +-- effective_cert_record_id(生效快照指针)是判定「该对象是否已认证」的唯一依据: +-- NULL = 从未审核通过;一经写入永不回退,提交、驳回、作废都不得修改它。 +-- 因此「重新提交审核」不会让一辆已认证的车掉出可用池。 -- --- 幂等:全部语句可重复执行(建表 IF NOT EXISTS、加列先查 information_schema)。 +-- 幂等:全部语句可重复执行(建表 IF NOT EXISTS、加列先查 information_schema、 +-- 改列/改排序规则/改菜单都先判定当前状态;菜单用 ON DUPLICATE KEY UPDATE)。 -- MySQL 5.7+ 兼容。 -- ---------------------------- @@ -152,8 +154,13 @@ DELIMITER ; -- ---------------------------- -- 5. 菜单与权限码 --- 挂在「运力管理」(2079000000000000001) 下,与司机管理 / 车辆管理同级。 +-- 挂在「运力管理」(2079000000000000001,与 blade_transport_driver.sql / +-- blade_transport_vehicle.sql / blade_vehicle_dispatch.sql 用的是同一个 ID) 下, +-- 与司机管理 / 车辆管理同级。 -- 列表页权限码 cert_certification_audit_{driver|vehicle},审核动作加 _audit 后缀。 +-- +-- 幂等:ON DUPLICATE KEY UPDATE 同时把 parent_id / name 等回写, +-- 故对已存在(曾按早期版本建过菜单、或手工改过)的环境重跑即可收敛到本脚本的值。 -- ---------------------------- 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), 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 index 34194030..bbd9b162 100644 --- a/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql +++ b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql @@ -2,12 +2,38 @@ -- 司机「驾驶车辆」字段扩长:varchar(30) -> varchar(100) -- ---------------------------- -- 一个司机最多绑定 5 辆车(与小程序端「车辆认证一个司机最多添加 5 辆」一致), --- 该字段是**逗号并列的车牌列表**,5 个普通车牌连分隔符要 39 字符, +-- 该字段是逗号并列的车牌列表,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 '驾驶车辆车牌号,多个用逗号分隔'; +-- +-- 幂等:已是目标长度时不再执行(MODIFY COLUMN 会重建表,重复执行无意义且有大表代价)。 +-- MySQL 5.7+ 兼容。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_driver_driving_vehicle_widen_20261003`$$ +CREATE PROCEDURE `upgrade_transport_driver_driving_vehicle_widen_20261003`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_driver' + AND column_name = 'driving_vehicle' + AND character_maximum_length < 100 + ) THEN + ALTER TABLE `blade_transport_driver` + MODIFY COLUMN `driving_vehicle` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '驾驶车辆车牌号,多个用逗号分隔'; + END IF; +END$$ + +CALL `upgrade_transport_driver_driving_vehicle_widen_20261003`()$$ +DROP PROCEDURE `upgrade_transport_driver_driving_vehicle_widen_20261003`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql index 817034f3..982b4dbb 100644 --- a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql +++ b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql @@ -1,14 +1,48 @@ -- ---------------------------- -- 车辆档案补两列:所有人、车辆识别代号(车架号) -- ---------------------------- --- 所有人:行驶证上登记的单位名称(如「桂物物流集团」),**不是司机**, +-- 所有人:行驶证上登记的单位名称(如「桂物物流集团」),不是司机, -- 与「所属组织」也是两个字段——组织是内部管理归属,所有人是证件上的登记主体, -- 两者可能不同(挂靠车的所有人不是本组织)。 -- 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`; +-- 且 VIN 在原型里是选填项,不设默认值也不设 NOT NULL。 +-- +-- 幂等:逐列先查 information_schema,已存在则跳过(可重复执行)。 +-- MySQL 5.7+ 兼容。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_transport_vehicle_owner_vin_20261003`$$ +CREATE PROCEDURE `upgrade_transport_vehicle_owner_vin_20261003`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_vehicle' + AND column_name = 'owner_name' + ) THEN + ALTER TABLE `blade_transport_vehicle` + ADD COLUMN `owner_name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '所有人(行驶证登记的单位名称,不是司机)' AFTER `plate_no`; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_transport_vehicle' + AND column_name = 'vin' + ) THEN + ALTER TABLE `blade_transport_vehicle` + ADD COLUMN `vin` varchar(17) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '车辆识别代号(车架号)' AFTER `vehicle_type`; + END IF; +END$$ + +CALL `upgrade_transport_vehicle_owner_vin_20261003`()$$ +DROP PROCEDURE `upgrade_transport_vehicle_owner_vin_20261003`$$ + +DELIMITER ; From 4a65951adc098c777dfd8e0ccd0f25928fed3f38 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 23:46:39 +0800 Subject: [PATCH 30/81] =?UTF-8?q?:memo:=20=E7=B2=BE=E7=AE=80=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一版注释把决策理由、备选方案、实测数据都写进了脚本,篇幅盖过 SQL 本身 (cert_record 注释 58 行 / 非空 170 行),后续阅读反而更难抓到重点。 按同目录既有脚本的密度收敛:只留「从 SQL 本身看不出来」的事实 (字段业务含义、不可回退的指针语义、排序规则不能改的原因), 删掉论证过程与常识性说明。 cert_record 注释 58 -> 31 行,另两个 14 -> 6 行; DDL 一个字未动,重跑一遍验证仍无报错。 --- .../blade_transport_cert_record_20261002.sql | 61 ++++++------------- ..._driver_driving_vehicle_widen_20261003.sql | 14 +---- ...e_transport_vehicle_owner_vin_20261003.sql | 14 +---- 3 files changed, 23 insertions(+), 66 deletions(-) diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql index e5c445e8..20d199e7 100644 --- a/doc/sql/transport/blade_transport_cert_record_20261002.sql +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -2,16 +2,9 @@ -- 认证记录数据模型:新建 blade_transport_cert_record,为司机表、车辆表补认证字段与 -- 生效快照指针,订正司机表排序规则,并新增认证审核菜单。 -- ---------------------------- --- 认证对象(司机 / 车辆)每次提交产生一条不可变记录,审核只改记录的状态, --- 不覆盖已提交的内容。司机与车辆共用本表,由 biz_type 区分。 --- --- effective_cert_record_id(生效快照指针)是判定「该对象是否已认证」的唯一依据: --- NULL = 从未审核通过;一经写入永不回退,提交、驳回、作废都不得修改它。 --- 因此「重新提交审核」不会让一辆已认证的车掉出可用池。 --- --- 幂等:全部语句可重复执行(建表 IF NOT EXISTS、加列先查 information_schema、 --- 改列/改排序规则/改菜单都先判定当前状态;菜单用 ON DUPLICATE KEY UPDATE)。 --- MySQL 5.7+ 兼容。 +-- 司机与车辆共用本表,由 biz_type 区分;每次提交产生一条不可变记录, +-- 审核只改状态,不覆盖已提交内容。 +-- 全部语句可重复执行。MySQL 5.7+ 兼容。 -- ---------------------------- -- 1. 认证记录表 @@ -54,9 +47,8 @@ BEGIN DECLARE db_name varchar(128); SET db_name = DATABASE(); - -- ---- 2. 司机表:认证字段 + 生效快照指针 ---- - -- certification_status 是「最新提交状态」的展示缓存;初始为 NULL 表示「从未提交」, - -- 与「审核中(0)」语义不同,故不可用 0 兜底。 + -- 司机表:认证字段 + 生效快照指针 + -- certification_status 为 NULL 表示「从未提交」,与「审核中(0)」不同义。 IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_status') THEN ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交' AFTER `remark`; END IF; @@ -67,7 +59,7 @@ BEGIN ALTER TABLE `blade_transport_driver` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; END IF; - -- ---- 2.1 司机表:身份证有效期(小程序 person.vue 标为必填,原表缺列)---- + -- 司机表:身份证有效期(小程序标为必填,原表缺列) IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_start') THEN ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起' AFTER `id_card_back`; END IF; @@ -78,27 +70,20 @@ BEGIN ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是' AFTER `id_card_valid_end`; END IF; - -- ---- 3. 车辆表:生效快照指针 ---- - -- 现有 certification_status / certification_reject_reason 保留,降级为「最新提交状态」展示缓存; - -- 车辆可用性判定改读本指针。 + -- 车辆表:生效快照指针(车辆可用性判定改读它,认证状态列降级为展示缓存) + -- 该指针一经写入永不回退,提交/驳回/作废都不得改它,故重新提交不会让已认证的车掉出可用池。 IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' AND column_name = 'effective_cert_record_id') THEN ALTER TABLE `blade_transport_vehicle` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; END IF; - -- ---- 4. 司机表排序规则订正 ---- - -- blade_transport_driver 建表未显式指定 COLLATE,MySQL 8+ 会落到服务器默认 - -- utf8mb4_0900_ai_ci,与项目规范(utf8mb4_general_ci)不一致。 - -- blade_transport_vehicle 曾因同类问题在 JOIN 时报 Illegal mix of collations, - -- 故在此一并订正,避免司机认证的关联查询重演同一故障。 + -- 司机表排序规则订正:建表未显式指定 COLLATE,MySQL 8+ 会落到服务器默认 + -- utf8mb4_0900_ai_ci;车辆表曾因此报 Illegal mix of collations。 IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND table_collation <> 'utf8mb4_general_ci') THEN ALTER TABLE `blade_transport_driver` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; END IF; - -- ---- 4.1 待审唯一键自修复 ---- - -- 生成列必须同时排除「已逻辑删除」的行:MyBatis-Plus 的 removeById 只把 is_deleted 置 1, - -- 不会改 status。若生成列只看 status = 0,一条被逻辑删除的待审记录会继续占用唯一键, - -- 导致该认证对象永远无法再次提交。 - -- 建表语句已是正确版本;这里负责把按早期版本建过表的环境就地修正过来。 + -- 待审唯一键自修复:生成列必须排除已逻辑删除的行,否则被删的待审记录 + -- 会一直占用唯一键,该对象永远无法再次提交(removeById 只改 is_deleted)。 IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' @@ -108,12 +93,8 @@ BEGIN MODIFY COLUMN `pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照'; END IF; - -- ---- 4.2 tenant_id 收紧为 NOT NULL ---- - -- tenant_id 是待审唯一键 uk_cert_record_pending 的第一列,而 MySQL 的唯一索引不对 NULL 去重: - -- 若 tenant_id 允许为 NULL,两条 tenant_id IS NULL 的待审记录可以并存, - -- 「同一对象至多一条待审」的约束就被绕过了。 - -- 本库三张表(driver / vehicle / waybill)实测均无 tenant_id 为 NULL 的行, - -- 且「NOT NULL DEFAULT '000000'」是项目既有写法(doc/sql/transport 下 23 个脚本如此)。 + -- tenant_id 收紧为 NOT NULL:它是待审唯一键的第一列,而唯一索引不对 NULL 去重, + -- 允许 NULL 就能并存两条待审记录,绕开「同对象至多一条待审」。 IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' @@ -124,11 +105,8 @@ BEGIN MODIFY COLUMN `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID'; END IF; - -- ---- 4.3 车辆认证状态缓存词表对齐 ---- - -- 改造前车辆侧是「0认证中 1认证通过 2认证驳回」,不含「已作废」; - -- 而新流程会把「被同对象再次提交取代」写成 3,届时该列无对应语义。 - -- 取值 0/1/2 在两套词表下含义一致(进行中 / 通过 / 驳回),故这里只补 3 并统一措辞, - -- 不改变任何既有数据的含义。仅改列注释(元数据),不动数据。 + -- 车辆认证状态缓存词表对齐:旧词表不含「已作废」,新流程会写入 3。 + -- 0/1/2 两套词表同义,故只补措辞,不改数据。 IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' @@ -154,13 +132,8 @@ DELIMITER ; -- ---------------------------- -- 5. 菜单与权限码 --- 挂在「运力管理」(2079000000000000001,与 blade_transport_driver.sql / --- blade_transport_vehicle.sql / blade_vehicle_dispatch.sql 用的是同一个 ID) 下, --- 与司机管理 / 车辆管理同级。 +-- 挂在「运力管理」2079000000000000001 下,与司机管理 / 车辆管理同级。 -- 列表页权限码 cert_certification_audit_{driver|vehicle},审核动作加 _audit 后缀。 --- --- 幂等:ON DUPLICATE KEY UPDATE 同时把 parent_id / name 等回写, --- 故对已存在(曾按早期版本建过菜单、或手工改过)的环境重跑即可收敛到本脚本的值。 -- ---------------------------- 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), 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 index bbd9b162..48197fd1 100644 --- a/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql +++ b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql @@ -1,17 +1,9 @@ -- ---------------------------- -- 司机「驾驶车辆」字段扩长:varchar(30) -> varchar(100) -- ---------------------------- --- 一个司机最多绑定 5 辆车(与小程序端「车辆认证一个司机最多添加 5 辆」一致), --- 该字段是逗号并列的车牌列表,5 个普通车牌连分隔符要 39 字符, --- 新能源号牌(8 位)要 44 字符 —— 30 字符连 4 辆车都放不下。 --- 超长写入的后果不是报错而是静默截断:字段里会留下半个车牌, --- 而拆车牌的规则会把这段残渣当成一辆车,司机名下于是多出一辆不存在的车。 --- --- 排序规则保持不变(utf8mb4_general_ci):车辆表与认证记录表都用这个规则, --- 三张表之间已有按车牌的关联查询,改规则会引发 Illegal mix of collations。 --- --- 幂等:已是目标长度时不再执行(MODIFY COLUMN 会重建表,重复执行无意义且有大表代价)。 --- MySQL 5.7+ 兼容。 +-- 该字段是逗号并列的车牌列表,一个司机最多 5 辆;30 字符连 4 辆都放不下, +-- 超长会静默截断成半个车牌,被拆车牌逻辑当成一辆不存在的车。 +-- 排序规则不变:与车辆表、认证记录表之间有按车牌的关联查询。 DELIMITER $$ diff --git a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql index 982b4dbb..5209c445 100644 --- a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql +++ b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql @@ -1,17 +1,9 @@ -- ---------------------------- -- 车辆档案补两列:所有人、车辆识别代号(车架号) -- ---------------------------- --- 所有人:行驶证上登记的单位名称(如「桂物物流集团」),不是司机, --- 与「所属组织」也是两个字段——组织是内部管理归属,所有人是证件上的登记主体, --- 两者可能不同(挂靠车的所有人不是本组织)。 --- VIN:司机端车辆认证一直在收这个值并写进认证快照,但车辆表此前没有对应列, --- 于是它只存在于快照里、车辆档案上查不到。补列后档案与快照才一致。 --- --- 两列都允许为空:存量车辆(含订正补的那些)没有这两个值, --- 且 VIN 在原型里是选填项,不设默认值也不设 NOT NULL。 --- --- 幂等:逐列先查 information_schema,已存在则跳过(可重复执行)。 --- MySQL 5.7+ 兼容。 +-- owner_name 是行驶证上登记的单位名称,不是司机,与「所属组织」也是两个字段。 +-- vin 司机端认证一直在收并写入快照,车辆表此前没有对应列,补上后档案与快照才一致。 +-- 两列均可空:存量车辆没有值,VIN 在原型里也是选填。 DELIMITER $$ From 5061df8c78a84cabd60c6e06e156105540590196 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 23:49:33 +0800 Subject: [PATCH 31/81] =?UTF-8?q?:memo:=20=E5=88=A0=E9=99=A4=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E8=A7=A3=E9=87=8A=E6=80=A7=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQL 本身(列名、列 COMMENT、IF 条件)已经把意图说清楚了,注释在复述它、 或是在讲当时的排查过程,对读者没有增量信息。只留一处真正反直觉的: 唯一索引不对 NULL 去重,故 tenant_id 必须 NOT NULL。 注释 31 -> 8 行,DDL 未动,重跑两遍验证。 --- .../blade_transport_cert_record_20261002.sql | 26 ++++--------------- ..._driver_driving_vehicle_widen_20261003.sql | 8 ++---- ...e_transport_vehicle_owner_vin_20261003.sql | 8 ++---- 3 files changed, 9 insertions(+), 33 deletions(-) diff --git a/doc/sql/transport/blade_transport_cert_record_20261002.sql b/doc/sql/transport/blade_transport_cert_record_20261002.sql index 20d199e7..8e9b8870 100644 --- a/doc/sql/transport/blade_transport_cert_record_20261002.sql +++ b/doc/sql/transport/blade_transport_cert_record_20261002.sql @@ -1,10 +1,7 @@ -- ---------------------------- --- 认证记录数据模型:新建 blade_transport_cert_record,为司机表、车辆表补认证字段与 --- 生效快照指针,订正司机表排序规则,并新增认证审核菜单。 +-- 认证记录:建表,司机表 / 车辆表补认证字段与生效快照指针,订正司机表排序规则, +-- 并新增认证审核菜单。可重复执行。 -- ---------------------------- --- 司机与车辆共用本表,由 biz_type 区分;每次提交产生一条不可变记录, --- 审核只改状态,不覆盖已提交内容。 --- 全部语句可重复执行。MySQL 5.7+ 兼容。 -- ---------------------------- -- 1. 认证记录表 @@ -47,8 +44,6 @@ BEGIN DECLARE db_name varchar(128); SET db_name = DATABASE(); - -- 司机表:认证字段 + 生效快照指针 - -- certification_status 为 NULL 表示「从未提交」,与「审核中(0)」不同义。 IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'certification_status') THEN ALTER TABLE `blade_transport_driver` ADD COLUMN `certification_status` int(11) DEFAULT NULL COMMENT '最新提交状态展示缓存:0审核中 1已通过 2已驳回 3已作废,NULL为从未提交' AFTER `remark`; END IF; @@ -59,7 +54,6 @@ BEGIN ALTER TABLE `blade_transport_driver` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; END IF; - -- 司机表:身份证有效期(小程序标为必填,原表缺列) IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND column_name = 'id_card_valid_start') THEN ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_valid_start` date DEFAULT NULL COMMENT '身份证有效期起' AFTER `id_card_back`; END IF; @@ -70,20 +64,14 @@ BEGIN ALTER TABLE `blade_transport_driver` ADD COLUMN `id_card_long_term` int(11) DEFAULT '0' COMMENT '身份证长期有效:0否 1是' AFTER `id_card_valid_end`; END IF; - -- 车辆表:生效快照指针(车辆可用性判定改读它,认证状态列降级为展示缓存) - -- 该指针一经写入永不回退,提交/驳回/作废都不得改它,故重新提交不会让已认证的车掉出可用池。 IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' AND column_name = 'effective_cert_record_id') THEN ALTER TABLE `blade_transport_vehicle` ADD COLUMN `effective_cert_record_id` bigint(20) DEFAULT NULL COMMENT '生效快照指针:指向 blade_transport_cert_record.id,NULL 表示从未认证通过;一经设置永不回退' AFTER `certification_reject_reason`; END IF; - -- 司机表排序规则订正:建表未显式指定 COLLATE,MySQL 8+ 会落到服务器默认 - -- utf8mb4_0900_ai_ci;车辆表曾因此报 Illegal mix of collations。 IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = db_name AND table_name = 'blade_transport_driver' AND table_collation <> 'utf8mb4_general_ci') THEN ALTER TABLE `blade_transport_driver` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; END IF; - -- 待审唯一键自修复:生成列必须排除已逻辑删除的行,否则被删的待审记录 - -- 会一直占用唯一键,该对象永远无法再次提交(removeById 只改 is_deleted)。 IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' @@ -93,8 +81,7 @@ BEGIN MODIFY COLUMN `pending_biz_id` bigint(20) GENERATED ALWAYS AS (if((`status` = 0) and (`is_deleted` = 0),`biz_id`,NULL)) STORED COMMENT '待审唯一键:仅未删除的审核中记录非空,用于保证同对象至多一条待审快照'; END IF; - -- tenant_id 收紧为 NOT NULL:它是待审唯一键的第一列,而唯一索引不对 NULL 去重, - -- 允许 NULL 就能并存两条待审记录,绕开「同对象至多一条待审」。 + -- tenant_id 必须 NOT NULL:唯一索引不对 NULL 去重,允许 NULL 则同一对象可并存两条待审记录 IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_cert_record' @@ -105,8 +92,6 @@ BEGIN MODIFY COLUMN `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID'; END IF; - -- 车辆认证状态缓存词表对齐:旧词表不含「已作废」,新流程会写入 3。 - -- 0/1/2 两套词表同义,故只补措辞,不改数据。 IF EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = db_name AND table_name = 'blade_transport_vehicle' @@ -131,9 +116,8 @@ DROP PROCEDURE `upgrade_transport_cert_record_20261002`$$ DELIMITER ; -- ---------------------------- --- 5. 菜单与权限码 --- 挂在「运力管理」2079000000000000001 下,与司机管理 / 车辆管理同级。 --- 列表页权限码 cert_certification_audit_{driver|vehicle},审核动作加 _audit 后缀。 +-- 5. 菜单 +-- 挂在「运力管理」2079000000000000001 下;ON DUPLICATE KEY UPDATE 使重跑可覆盖旧值。 -- ---------------------------- 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), 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 index 48197fd1..b845ecba 100644 --- a/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql +++ b/doc/sql/transport/blade_transport_driver_driving_vehicle_widen_20261003.sql @@ -1,9 +1,5 @@ --- ---------------------------- --- 司机「驾驶车辆」字段扩长:varchar(30) -> varchar(100) --- ---------------------------- --- 该字段是逗号并列的车牌列表,一个司机最多 5 辆;30 字符连 4 辆都放不下, --- 超长会静默截断成半个车牌,被拆车牌逻辑当成一辆不存在的车。 --- 排序规则不变:与车辆表、认证记录表之间有按车牌的关联查询。 +-- 司机「驾驶车辆」字段扩长:varchar(30) -> varchar(100)。 +-- 该字段是逗号并列的车牌列表,一个司机最多 5 辆,30 字符不够;超长会静默截断成半个车牌。 DELIMITER $$ diff --git a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql index 5209c445..5ac828c0 100644 --- a/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql +++ b/doc/sql/transport/blade_transport_vehicle_owner_vin_20261003.sql @@ -1,9 +1,5 @@ --- ---------------------------- --- 车辆档案补两列:所有人、车辆识别代号(车架号) --- ---------------------------- --- owner_name 是行驶证上登记的单位名称,不是司机,与「所属组织」也是两个字段。 --- vin 司机端认证一直在收并写入快照,车辆表此前没有对应列,补上后档案与快照才一致。 --- 两列均可空:存量车辆没有值,VIN 在原型里也是选填。 +-- 车辆档案补两列:所有人(行驶证登记的单位名称,不是司机)、车辆识别代号(车架号)。 +-- 两列均可空,存量车辆没有值。 DELIMITER $$ From e3d860668b411876b81d7a44fd52e3d3b5f14213 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Sun, 4 Oct 2026 23:55:22 +0800 Subject: [PATCH 32/81] =?UTF-8?q?:memo:=20=E5=88=A0=E9=99=A4=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=88=86=E6=94=AF=E6=B3=A8=E9=87=8A=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E5=8F=99=E4=BA=8B=E6=80=A7=E6=8E=AA=E8=BE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「本改造的核心」「这正是要避免的事」「本次改造要保住的东西」这类 写作时的叙事从注释中去掉,只留读者需要的事实(反直觉的约束、坑、兼容性)。 测试的 @DisplayName 同步去掉「改造前/改造期间」等时间状语。 注释与文案调整,无逻辑改动;CertificationGateTest 等 35 个测试通过。 --- .../transport/pojo/entity/TransportVehicle.java | 2 +- .../service/impl/CertAuditServiceImpl.java | 8 ++++---- .../transport/support/CertificationGate.java | 15 +++++---------- .../service/impl/CertAuditServiceImplTest.java | 2 +- .../service/impl/PersonCertRoundTripTest.java | 4 ++-- .../transport/support/CertificationGateTest.java | 14 +++++--------- 6 files changed, 18 insertions(+), 27 deletions(-) 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 2e309bc6..1fe16eb9 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 @@ -243,7 +243,7 @@ public class TransportVehicle extends TenantEntity { * 「该车辆是否已认证」的唯一判据。为 null 表示从未认证通过; * 一经设置永不回退——重新提交、待审、被驳回都不得修改它, * 因此重新提交认证不会让一辆已认证的车掉出可用池。 - * 改造后 {@link #certificationStatus} 降级为「最新提交状态」的展示缓存。 + * {@link #certificationStatus} 是「最新提交状态」的展示缓存,不代表是否已认证。 *

* ⚠️ {@code READ_ONLY}:本字段只能由审核通过时写入。 * {@code POST /transport-vehicle/submit} 直接以本实体接收请求体, 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 d1bedbb3..07248769 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 @@ -55,10 +55,10 @@ import java.util.List; /** * 认证审核服务实现 *

- * 审核作用于**一条记录**。两条路径的差别是本次改造的关键: + * 审核作用于一条记录,两条路径的差别在于是否切换生效快照: *

    *
  • 通过:记录转已通过,并把该对象的生效快照指针切到它;
  • - *
  • 驳回:记录转已驳回并留原因,生效快照指针一个字都不动。
  • + *
  • 驳回:记录转已驳回并留原因,生效快照指针一个字都不动。
  • *
* 因此「已认证的车被驳回」不会让它掉出可用池,只是这次提交没被采纳。 * @@ -354,10 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService { } catch (Exception exception) { throw new ServiceException("该认证记录的提交内容无法解析,未写入车辆档案"); } - // 字段名对不上时 JSON 会解析成「全是 null」而不报错(改造前的历史快照就是这种情况), + // 字段名对不上时 JSON 会解析成「全是 null」而不报错(早期格式的历史快照就是这种情况), // 那样写回等于什么也没写。故用证件内容里的关键字段判定「这份快照是否是当前格式」。 if (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg())) { - throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是改造前的历史快照)," + throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是早期格式的历史快照)," + "未写入车辆档案;请重新提交一次认证"); } vehicle.setOwnerName(snapshot.getOwnerName()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java index cd5e4185..2af6ae62 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java @@ -33,18 +33,13 @@ import org.springframework.stereotype.Component; /** * 认证可用性门槛 *

- * 在原有的运营状态(启用 / 停用)之上**叠加**一层「是否已认证」: - *

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

- * ⚠️ 判据是**生效快照指针**,不是「最新提交状态」展示缓存。 - * 这一区别是本改造的核心:司机重新提交认证会把展示缓存变成「审核中」, - * 若照它判定,司机一提交认证就立刻接不了单——正是要避免的事。 - * 生效快照只在审核通过时才切换,故「曾认证通过即持续可用」。 + * 判据是生效快照指针,不是「最新提交状态」展示缓存——重新提交认证会把展示缓存 + * 变成「审核中」,照它判定会让司机一提交就接不了单。生效快照只在审核通过时切换。 *

- * 两个开关默认开启,可通过配置关闭以便线上快速回退到改造前的行为: + * 两个开关默认开启,可关掉以回退到不校验认证的行为: *

  * transport:
  *   certification:
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 ae268991..93416743 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
@@ -97,7 +97,7 @@ class CertAuditServiceImplTest {
 	}
 
 	@Test
-	@DisplayName("通过车辆认证:快照是改造前的旧格式时明确失败,不能「通过但什么也没写」")
+	@DisplayName("通过车辆认证:快照是旧格式时明确失败,不能「通过但什么也没写」")
 	void approveRejectsLegacySnapshot() {
 		// 旧快照的键名与当前字段定义不同,会解析成「全是 null」而不报错——最危险的一种情况
 		TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING,
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 b72b4182..ca07ae88 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
@@ -322,11 +322,11 @@ class PersonCertRoundTripTest {
 	@Test
 	@DisplayName("历史快照:读不懂就跳过写回,审核本身照常通过")
 	void legacySnapshotSkipsWriteBackWithoutFailingAudit() {
-		// 改造前的快照没有 idCardNo / driverFileNo,解析出来字段全是 null;
+		// 早期格式的快照没有 idCardNo / driverFileNo,解析出来字段全是 null;
 		// 此时既不能把 null 当成「司机没填」写进档案,也不能让这次审核直接失败
 		Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011234"));
 		TransportCertRecord record = recordStore.get(recordId);
-		record.setSnapshotJson("{\"legacyField\":\"改造前的旧内容\"}");
+		record.setSnapshotJson("{\"legacyField\":\"早期格式的旧内容\"}");
 
 		certAuditService.audit(recordId, true, null);
 
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java
index 08ee25b7..afa32c0f 100644
--- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java
@@ -10,10 +10,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
 /**
  * 认证可用性门槛测试。
  * 

- * 这是整个认证改造的**核心验收点**:一辆已认证的车被重新提交认证后,必须**仍可接单**。 - * 改造前的判定只看车辆的启用/停用状态;本门槛叠加一层「是否已认证」。 - * 之所以必须叠加在生效快照上而不是「最新提交状态」,正是因为重新提交会把 - * 「最新提交状态」变成「审核中」——若照它判定,司机一提交认证就立刻接不了单。 + * 关键场景:已认证的车被重新提交认证后仍可接单——判定必须走生效快照, + * 而重新提交会把「最新提交状态」变成「审核中」。 */ class CertificationGateTest { @@ -34,7 +32,7 @@ class CertificationGateTest { } @Test - @DisplayName("关闭后行为回到改造前:不因未认证而拦截(便于快速回退)") + @DisplayName("关闭后不因未认证而拦截(便于快速回退)") void disabledLetsEveryoneThrough() { gate.setVehicleGateEnabled(false); gate.setDriverGateEnabled(false); @@ -54,9 +52,7 @@ class CertificationGateTest { @Test @DisplayName("车辆:重新提交后【状态变成审核中】但仍有生效快照 → 仍可用") void resubmittedVehicleStaysAvailable() { - // 关键场景:司机提交了新资料,展示缓存变成「审核中」, - // 但 effective_cert_record_id 仍指着上一次通过的记录。 - // 门槛只看指针,故这辆车照跑不误——这就是本次改造要保住的东西。 + // 展示缓存变成「审核中」,但 effective_cert_record_id 仍指着上一次通过的记录。 assertTrue(gate.isVehicleAvailable(Boolean.TRUE, 7001L), "已认证的车重新提交后必须仍可接单,否则司机一提交就停工"); } @@ -90,7 +86,7 @@ class CertificationGateTest { } @Test - @DisplayName("司机门槛可单独关闭(改造期间用于渐进放开)") + @DisplayName("司机门槛可单独关闭") void driverGateCanBeDisabledIndependently() { gate.setDriverGateEnabled(false); assertTrue(gate.isDriverAvailable(null), "司机门槛关闭时不应拦截"); From 8d35abd23ea136894b29563402c90f8089843d38 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Mon, 5 Oct 2026 00:54:08 +0800 Subject: [PATCH 33/81] =?UTF-8?q?:sparkles:=20=E5=BC=82=E5=B8=B8=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E6=96=B0=E5=A2=9E=E7=BC=96=E8=BE=91=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 运管员可订正已上报的异常内容:异常类型、异常原因、上报说明、现场照片。 车牌、项目、承运商、配载单号由上报时从运单回填,不开放编辑,避免与运单脱钩。 编辑不改动处置状态。状态只能经跟进与完成流转,因为运单详情、运单列表、 首页计数与待办四处都以 disposal_status IN (pending, processing) 判定未完成, 允许编辑改状态会让已结案的异常重新长出来,而那四处判定无从知晓。 每次编辑按字段逐项比对生成变更说明,写入一条跟进记录留痕。 变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超过跟进内容的 varchar(500) 上限,故按 500 截断。 Permission 表达式用 hasMenu('exception_disposal_edit'): @PreAuth 的值由 SpEL 求值,root object 是 AuthFun, 写成裸字符串会被当作属性访问而在请求时抛错。 --- .../dto/ExceptionDisposalUpdateRequest.java | 63 ++++++++++++ .../ExceptionDisposalController.java | 12 +++ .../service/IExceptionDisposalService.java | 6 ++ .../impl/ExceptionDisposalServiceImpl.java | 96 +++++++++++++++++++ .../support/ExceptionDisposalPermissions.java | 47 +++++++++ .../ExceptionDisposalPermissionsTest.java | 73 ++++++++++++++ ..._exception_disposal_edit_menu_20261010.sql | 33 +++++++ 7 files changed, 330 insertions(+) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java create mode 100644 doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java new file mode 100644 index 00000000..b1f233f8 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalUpdateRequest.java @@ -0,0 +1,63 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 异常处置编辑请求 + *

+ * 只承载上报四件套。车牌、项目、承运商、配载单号等字段由上报时从运单回填, + * 不在此列,避免编辑后与运单脱钩。 + *

+ * 不含处置状态:状态只能经跟进与完成流转,编辑不得改变它。 + * + * @author Chill + */ +@Data +@Schema(description = "异常处置编辑请求") +public class ExceptionDisposalUpdateRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "异常处置ID") + private Long id; + + @Schema(description = "异常类型") + private String exceptionType; + + @Schema(description = "异常原因") + private String exceptionReason; + + @Schema(description = "上报说明") + private String reportDescription; + + @Schema(description = "现场照片,逗号分隔") + private String scenePhotos; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index 2fc52aa9..855f37ec 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -30,11 +30,14 @@ import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.mp.support.Condition; import org.springblade.core.mp.support.Query; +import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.support.ExceptionDisposalPermissions; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -102,4 +105,13 @@ public class ExceptionDisposalController extends BladeController { return R.success("批量完成成功"); } + @PostMapping("/update") + @PreAuth(ExceptionDisposalPermissions.EDIT) + @ApiOperationSupport(order = 7) + @Operation(summary = "编辑异常处置", description = "运管员订正上报内容;不改动处置状态") + public R update(@RequestBody ExceptionDisposalUpdateRequest request) { + exceptionDisposalService.update(request); + return R.success("编辑成功"); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index b04ee264..d23ca19c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -25,6 +25,7 @@ package org.springblade.transport.service; import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.dto.ExceptionDisposalUpdateRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; @@ -44,6 +45,11 @@ public interface IExceptionDisposalService extends BaseService 500) { + throw new ServiceException("上报说明不能超过500字"); + } + if (Func.isNotBlank(request.getExceptionReason()) && request.getExceptionReason().length() > 200) { + throw new ServiceException("异常原因不能超过200字"); + } + ExceptionDisposal disposal = getExisting(request.getId()); + + String exceptionType = request.getExceptionType().trim(); + String exceptionReason = Func.isBlank(request.getExceptionReason()) + ? null + : request.getExceptionReason().trim(); + String reportDescription = request.getReportDescription().trim(); + String scenePhotos = Func.isBlank(request.getScenePhotos()) + ? null + : request.getScenePhotos().trim(); + + String changeLog = buildChangeLog(disposal, exceptionType, exceptionReason, reportDescription, scenePhotos); + if (Func.isBlank(changeLog)) { + throw new ServiceException("未检测到任何修改"); + } + + disposal.setExceptionType(exceptionType); + disposal.setExceptionReason(exceptionReason); + disposal.setReportDescription(reportDescription); + disposal.setScenePhotos(scenePhotos); + disposal.setLatestFollowContent(changeLog); + updateById(disposal); + + ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); + record.setDisposalId(disposal.getId()); + record.setFollowContent(changeLog); + record.setFollowUser(AuthUtil.getUserId()); + record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowTime(LocalDateTime.now()); + followRecordMapper.insert(record); + } + + /** + * 逐字段比对生成变更说明,用于写入跟进记录。 + * 未发生变更时返回 null,调用方据此拒绝这次编辑。 + *

+ * 结果长度受 latest_follow_content / follow_content 的 varchar(500) 限制, + * 而变更说明会内嵌修改前后的原文,长文本字段一次编辑就可能超限,故须截断。 + */ + private String buildChangeLog(ExceptionDisposal before, String exceptionType, String exceptionReason, + String reportDescription, String scenePhotos) { + List changes = new ArrayList<>(); + appendChange(changes, "异常类型", before.getExceptionType(), exceptionType); + appendChange(changes, "异常原因", before.getExceptionReason(), exceptionReason); + appendChange(changes, "上报说明", before.getReportDescription(), reportDescription); + if (!Objects.equals(before.getScenePhotos(), scenePhotos)) { + changes.add("现场照片由 " + photoCount(before.getScenePhotos()) + " 张改为 " + photoCount(scenePhotos) + " 张"); + } + if (changes.isEmpty()) { + return null; + } + return truncate("编辑:" + String.join(";", changes)); + } + + private void appendChange(List changes, String label, String before, String after) { + String oldValue = Func.isBlank(before) ? "空" : before; + String newValue = Func.isBlank(after) ? "空" : after; + if (!Objects.equals(before, after)) { + changes.add(label + "由 " + oldValue + " 改为 " + newValue); + } + } + + /** 截断到字段上限内,留出省略号位置 */ + private String truncate(String text) { + return text.length() <= FOLLOW_CONTENT_MAX_LENGTH + ? text + : text.substring(0, FOLLOW_CONTENT_MAX_LENGTH - 1) + "…"; + } + + private int photoCount(String scenePhotos) { + return splitPhotos(scenePhotos).size(); + } + @Override @Transactional(rollbackFor = Exception.class) public void follow(ExceptionDisposalFollowRequest request) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java new file mode 100644 index 00000000..730f7453 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/ExceptionDisposalPermissions.java @@ -0,0 +1,47 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.support; + +/** + * 异常处置的权限表达式 + *

+ * 用菜单码而不是角色名:本项目的权限走「角色-菜单」授权。 + *

+ * 表达式由框架交给 SpEL 求值,root object 是 {@code AuthFun}, + * 故函数名必须与它的公开方法一致({@code hasMenu})。 + * 写成 {@code @PreAuth("exception_disposal_edit")} 这样的裸字符串会被当作 + * 属性访问而非权限判断,请求时抛 500 而不是 403。 + * + * @author Chill + */ +public final class ExceptionDisposalPermissions { + + /** + * 编辑异常处置的上报内容 + */ + public static final String EDIT = "hasMenu('exception_disposal_edit')"; + + private ExceptionDisposalPermissions() { + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java new file mode 100644 index 00000000..f082dc22 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/ExceptionDisposalPermissionsTest.java @@ -0,0 +1,73 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.core.secure.auth.AuthFun; +import org.springframework.expression.Expression; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 异常处置权限表达式的可求值性测试。 + *

+ * 表达式写在 {@code @PreAuth} 注解里,由框架在请求时交给 SpEL 求值, + * root object 是 {@code AuthFun}。写成裸字符串(如 {@code "exception_disposal_edit"}) + * 会被当作属性访问而抛异常,表现为接口 500 而不是 403——用测试把它提前到构建期。 + * + * @author Chill + */ +class ExceptionDisposalPermissionsTest { + + private final SpelExpressionParser parser = new SpelExpressionParser(); + + @Test + @DisplayName("编辑权限:拥有编辑菜单即通过") + void editWithMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_edit")).thenReturn(true); + + assertTrue(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "授予编辑菜单的用户应能编辑"); + } + + @Test + @DisplayName("编辑权限:没有编辑菜单则拒绝") + void editWithoutMenu() { + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, mock(AuthFun.class)), + "未授予编辑菜单的用户不得编辑"); + } + + @Test + @DisplayName("编辑权限:只有查看/跟进菜单不能编辑") + void editRequiresDedicatedMenu() { + AuthFun authFun = mock(AuthFun.class); + when(authFun.hasMenu("exception_disposal_view")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_follow")).thenReturn(true); + when(authFun.hasMenu("exception_disposal_complete")).thenReturn(true); + + assertFalse(evaluate(ExceptionDisposalPermissions.EDIT, authFun), + "能跟进、能完成不等于能编辑"); + } + + @Test + @DisplayName("表达式必须引用真实存在的菜单码") + void expressionReferencesRealMenuCode() { + assertTrue(ExceptionDisposalPermissions.EDIT.contains("exception_disposal_edit")); + assertFalse(ExceptionDisposalPermissions.EDIT.contains("hasAnyRole")); + } + + /** + * 用与框架相同的方式求值:AuthFun 作为 root object + */ + private boolean evaluate(String expression, AuthFun authFun) { + Expression parsed = parser.parseExpression(expression); + StandardEvaluationContext context = new StandardEvaluationContext(authFun); + return Boolean.TRUE.equals(parsed.getValue(context, Boolean.class)); + } + +} diff --git a/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql new file mode 100644 index 00000000..51ad9dfb --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_edit_menu_20261010.sql @@ -0,0 +1,33 @@ +-- 异常处置新增编辑权限码 +-- 日期:2026-10-10 + +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2090000000000002006,2090000000000002001,'exception_disposal_edit','编辑','exception_disposal_edit','','',5,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `parent_id`=VALUES(`parent_id`), + `name`=VALUES(`name`), + `path`=VALUES(`path`), + `sort`=VALUES(`sort`), + `is_deleted`=0; + +-- 授权给超级管理员角色。 +-- 不能按「持有 exception_disposal 菜单的角色」批量授权:司机角色同样持有该菜单, +-- 会被一并授予编辑权——而编辑是运管员才有的操作。 +-- 注意:运管角色当前并未持有 exception_disposal 菜单,故此处不涉及它; +-- 若日后把异常处置开放给运管,需另行授予本编辑码。 +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2090000000000002006 + (r.`id` % 1000), 2090000000000002006, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2090000000000002006 AND x.`role_id` = r.`id` + ); + +-- 幂等收口:本编辑码只应挂在超级管理员上,其余角色一律收回。 +-- 上面的 SELECT 是按授权规则下发,这条是按同一规则回收,二者合起来可重复执行。 +DELETE FROM `blade_role_menu` +WHERE `menu_id` = 2090000000000002006 + AND `role_id` NOT IN (SELECT `id` FROM `blade_role` WHERE `role_alias` = 'administrator'); From 7296a5af214b96e3a8134ca6c993ef379f37e9f8 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Mon, 5 Oct 2026 01:09:33 +0800 Subject: [PATCH 34/81] =?UTF-8?q?:sparkles:=20=E5=BC=82=E5=B8=B8=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E8=B7=9F=E8=BF=9B=E6=94=AF=E6=8C=81=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E9=99=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 跟进记录新增 attachments_json 列,存 [{name, url}] 形式的 JSON 数组。 跟进说明与附件至少填其一即可:只传附件时,follow_content 列为 NOT NULL, 故写入固定文案「已上传跟进附件」,列表按该文案展示。 建表脚本补 COLLATE:原先只写 CHARSET,落到 MySQL 8 服务器默认排序规则, 与库中实际的 utf8mb4_0900_ai_ci 不一致。 --- .../dto/ExceptionDisposalFollowRequest.java | 3 +++ .../entity/ExceptionDisposalFollowRecord.java | 3 +++ .../impl/ExceptionDisposalServiceImpl.java | 17 ++++++++---- .../blade_exception_disposal_20260812.sql | 5 ++-- ...n_disposal_follow_attachments_20261010.sql | 27 +++++++++++++++++++ 5 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java index d6dc67af..ec9e0caf 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/ExceptionDisposalFollowRequest.java @@ -46,4 +46,7 @@ public class ExceptionDisposalFollowRequest implements Serializable { @Schema(description = "跟进说明") private String followContent; + @Schema(description = "附件JSON,[{\"name\":\"...\",\"url\":\"...\"}]") + private String attachmentsJson; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java index a9921786..e5593217 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposalFollowRecord.java @@ -51,6 +51,9 @@ public class ExceptionDisposalFollowRecord extends TenantEntity { @Schema(description = "跟进说明") private String followContent; + @Schema(description = "附件JSON") + private String attachmentsJson; + @Schema(description = "跟进人") private Long followUser; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 155639de..67aeabd7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -267,23 +267,30 @@ public class ExceptionDisposalServiceImpl if (request == null || request.getId() == null) { throw new ServiceException("请选择需要跟进的异常"); } - if (Func.isBlank(request.getFollowContent())) { - throw new ServiceException("请填写跟进说明"); + boolean hasAttachments = Func.isNotBlank(request.getAttachmentsJson()); + if (Func.isBlank(request.getFollowContent()) && !hasAttachments) { + throw new ServiceException("请填写跟进说明或上传附件"); } - if (request.getFollowContent().length() > 500) { + if (Func.isNotBlank(request.getFollowContent()) && request.getFollowContent().length() > 500) { throw new ServiceException("跟进说明不能超过500字"); } ExceptionDisposal disposal = getExisting(request.getId()); if (STATUS_COMPLETED.equals(disposal.getDisposalStatus())) { throw new ServiceException("已完成的异常不允许继续跟进"); } + // 上传了附件但没写说明时,跟进说明留空,列表按附件回退展示 + String followContent = Func.isBlank(request.getFollowContent()) + ? null + : request.getFollowContent(); disposal.setDisposalStatus(STATUS_PROCESSING); - disposal.setLatestFollowContent(request.getFollowContent()); + disposal.setLatestFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent); updateById(disposal); ExceptionDisposalFollowRecord record = new ExceptionDisposalFollowRecord(); record.setDisposalId(disposal.getId()); - record.setFollowContent(request.getFollowContent()); + // follow_content 列为 NOT NULL + record.setFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent); + record.setAttachmentsJson(hasAttachments ? request.getAttachmentsJson() : null); record.setFollowUser(AuthUtil.getUserId()); record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId())); record.setFollowTime(LocalDateTime.now()); diff --git a/doc/sql/transport/blade_exception_disposal_20260812.sql b/doc/sql/transport/blade_exception_disposal_20260812.sql index 4b2c1769..e1dbfd92 100644 --- a/doc/sql/transport/blade_exception_disposal_20260812.sql +++ b/doc/sql/transport/blade_exception_disposal_20260812.sql @@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal` ( KEY `idx_exception_disposal_status` (`disposal_status`) USING BTREE, KEY `idx_exception_disposal_report_time` (`report_time`) USING BTREE, KEY `idx_exception_disposal_create_time` (`create_time`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常处置'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常处置'; CREATE TABLE IF NOT EXISTS `blade_exception_disposal_follow_record` ( `id` bigint(20) NOT NULL COMMENT '主键', @@ -48,13 +48,14 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal_follow_record` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', `disposal_id` bigint(20) NOT NULL COMMENT '异常处置ID', `follow_content` varchar(500) NOT NULL COMMENT '跟进说明', + `attachments_json` text DEFAULT NULL COMMENT '附件JSON', `follow_user` bigint(20) DEFAULT NULL COMMENT '跟进人', `follow_user_name` varchar(100) DEFAULT NULL COMMENT '跟进人姓名', `follow_time` datetime DEFAULT NULL COMMENT '跟进时间', PRIMARY KEY (`id`) USING BTREE, KEY `idx_exception_follow_disposal` (`disposal_id`) USING BTREE, KEY `idx_exception_follow_time` (`follow_time`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='异常处置跟进记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='异常处置跟进记录'; INSERT INTO `blade_menu` (`id`, `parent_id`, `code`, `name`, `alias`, `path`, `source`, `sort`, `category`, `action`, `is_open`, `component`, `remark`, `is_deleted`) VALUES diff --git a/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql b/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql new file mode 100644 index 00000000..84f6cccb --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_follow_attachments_20261010.sql @@ -0,0 +1,27 @@ +-- 异常处置跟进记录补充附件 +-- 日期:2026-10-10 +-- +-- 附件以 JSON 数组存放([{"name":"...","url":"..."}]),与本仓库其它附件字段一致。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_blade_exception_disposal_follow_attachments_20261010`$$ +CREATE PROCEDURE `upgrade_blade_exception_disposal_follow_attachments_20261010`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal_follow_record' + AND column_name = 'attachments_json') THEN + ALTER TABLE `blade_exception_disposal_follow_record` + ADD COLUMN `attachments_json` text DEFAULT NULL COMMENT '附件JSON' + AFTER `follow_content`; + END IF; +END$$ + +CALL `upgrade_blade_exception_disposal_follow_attachments_20261010`()$$ +DROP PROCEDURE `upgrade_blade_exception_disposal_follow_attachments_20261010`$$ + +DELIMITER ; From 7c30ead8396c684c45f1ce99d8ddc8a05b2aaf02 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Mon, 5 Oct 2026 15:10:24 +0800 Subject: [PATCH 35/81] =?UTF-8?q?:sparkles:=20=E5=BC=82=E5=B8=B8=E5=A4=84?= =?UTF-8?q?=E7=BD=AE=E6=94=AF=E6=8C=81=E5=BD=92=E5=B1=9E=E8=BF=87=E7=A8=8B?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E5=B9=B6=E6=96=B0=E5=A2=9E=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 异常上报强制选择过程节点,节点归属写入 node_code / node_name;节点存在未了结 异常时,该节点及其后节点不可打卡(异常门禁),被拦节点仍可展开查看回填内容。 另新增顺序门禁:前置打卡节点未完成时后续节点不可打卡且不可展开;在途节点不受 顺序门禁约束,但仍受异常门禁约束。 配套: - 过程配置「上传货量为是、货量类型为空」的脏配置订正为不采集货量 - 网关补充 /blade-resource/** 路由(图片上传,小程序打卡与异常上报照片均经此) --- .../pojo/entity/ExceptionDisposal.java | 6 + .../transport/pojo/vo/DriverPunchNodeVO.java | 26 +- .../service/IDriverWaybillService.java | 9 + .../impl/DriverWaybillServiceImpl.java | 228 +++++++++++++++++- .../impl/ExceptionDisposalServiceImpl.java | 20 +- .../support/WaybillProcessSupport.java | 156 +++++++++++- doc/nacos/routes/blade-gateway-dev.json | 22 ++ .../blade_exception_disposal_20260812.sql | 2 + ..._exception_disposal_node_code_20261005.sql | 37 +++ ...de_process_config_cargo_types_20261005.sql | 19 ++ 10 files changed, 517 insertions(+), 8 deletions(-) create mode 100644 doc/sql/transport/blade_exception_disposal_node_code_20261005.sql create mode 100644 doc/sql/transport/blade_process_config_cargo_types_20261005.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java index 96f5647e..67588e27 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ExceptionDisposal.java @@ -51,6 +51,12 @@ public class ExceptionDisposal extends TenantEntity { @Schema(description = "运单号") private String waybillNo; + @Schema(description = "过程节点 key(为空表示未指明节点)") + private String nodeCode; + + @Schema(description = "过程节点名称(上报时复制,此后不随过程配置变化)") + private String nodeName; + @Schema(description = "配载单号/总单号") private String loadingOrMasterNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index f7e88823..5806b545 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -4,6 +4,8 @@ */ package org.springblade.transport.pojo.vo; +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; @@ -34,9 +36,31 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "是否已打卡(在途=今日已打)") private Boolean done; - @Schema(description = "当前是否可打卡(顺序门禁 + 在途频次时段)") + @Schema(description = "当前是否可打卡(已打卡、被异常阻断、在途频次时段都会置否)") private Boolean actionable; + @Schema(description = "是否被未了结的异常阻断:该节点及其后的节点不可打卡") + private Boolean blocked; + + @Schema(description = "是否因前置节点未打卡而被顺序门禁拦住(在途豁免,恒为否)") + private Boolean blockedByOrder; + + @Schema(description = "顺序门禁拦住的原因,如「请先完成装货打卡」") + private String orderLockReason; + + @Schema(description = "是否允许展开:顺序门禁不允许,异常门禁允许(可查看回填内容)") + private Boolean expandable; + + @Schema(description = "该节点自身是否被上报了未了结异常(仅异常所在节点为是,其后被牵连阻断的节点为否)") + private Boolean hasException; + + @Schema(description = "阻断该节点的异常处置ID(司机端「异常详情」跳转用);未被阻断或异常已不存在时为空") + @JsonSerialize(using = ToStringSerializer.class) + private Long blockedExceptionId; + + @Schema(description = "不可打卡的原因,供司机端展示") + private String lockReason; + @Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)") private Boolean visible; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index be434257..30d7b5b8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -31,6 +31,8 @@ import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import java.util.Map; + /** * 司机端运单服务(小程序首页 / 列表) */ @@ -99,4 +101,11 @@ public interface IDriverWaybillService { */ boolean complete(Long id); + /** + * 按运单与节点 key 取该运单实际生效的过程节点配置;节点不存在时返回 null。 + *

+ * 运单优先用项目启用中的过程配置,不可用时回退运单自身快照。 + */ + Map findPunchNode(Long waybillId, String nodeCode); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index d38413bd..0ee9d7ab 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -33,11 +33,13 @@ import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.core.tool.utils.Func; +import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; import org.springblade.transport.pojo.dto.NodeSubmitDTO; import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.support.CertificationGate; @@ -72,11 +74,13 @@ import java.util.Arrays; import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.stream.Collectors; /** @@ -110,6 +114,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final DateTimeFormatter DATE_DOT_SHORT = DateTimeFormatter.ofPattern("MM.dd"); private static final DateTimeFormatter TIME_HM = DateTimeFormatter.ofPattern("HH:mm"); + /** + * 异常处置终态:已完成 / 已撤销。只有终态解除打卡门禁——「处理中」只表示有人 + * 跟进过,问题可能仍在,不放行。 + */ + private static final List SETTLED_EXCEPTION_STATUSES = Arrays.asList("completed", "revoked"); + + /** 被异常阻断时的不可打卡原因(司机端展示) */ + private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡"; + private final IWaybillService waybillService; private final IDriverService driverService; private final IProcessConfigService processConfigService; @@ -117,6 +130,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private final CertificationGate certificationGate; private final WaybillEnroutePunchMapper enroutePunchMapper; private final WaybillNodePunchMapper nodePunchMapper; + private final ExceptionDisposalMapper exceptionDisposalMapper; @Override public DriverWaybillCardVO currentTask() { @@ -252,15 +266,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); + String processJson = resolveProcessJson(waybill); Date lastPunchAt = findLastPunchTime(waybill.getId()); WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin( - resolveProcessJson(waybill), waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); + processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); if (!decision.punchEnabled()) { throw new ServiceException("该运单未启用在途打卡"); } if (decision.doneToday()) { throw new ServiceException("今日已完成在途打卡,不可重复打卡"); } + // 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束 + assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.transitNodeKey(processJson)); // 不做频次/时段门禁;定位按前端是否传参落库 Date now = new Date(); @@ -297,7 +314,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); - Map nodeCfg = findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); + String processJson = resolveProcessJson(waybill); + Map nodeCfg = findPunchNodeConfig(processJson, nodeCode); if (nodeCfg == null) { throw new ServiceException("该节点未启用打卡或不存在"); } @@ -308,6 +326,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (existed != null) { throw new ServiceException("该节点已打卡,不可重复打卡"); } + assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg)); + assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg); Date now = new Date(); WaybillNodePunch punch = new WaybillNodePunch(); @@ -441,6 +461,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); } + // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 + assertNoUnresolvedException(waybill.getId()); // 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单 return waybillService.completeWithoutDeptCheck(waybill.getId()); } @@ -764,11 +786,136 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .anyMatch(projectId::equals); } + /** + * 该运单未了结(未达终态)的异常处置所属节点 key。 + *

+ * 含空串表示存在未指明节点的异常(从全局入口上报),此类异常阻断该运单全部尚未打卡的节点。 + */ + @Override + public Map findPunchNode(Long waybillId, String nodeCode) { + if (waybillId == null || Func.isEmpty(nodeCode)) { + return null; + } + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + return null; + } + return findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); + } + + /** + * 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。 + *

+ * 打卡门禁与司机端的「异常」标签共用此口径,故两者永远一致: + * 只要标签还在,该节点就是被拦的;异常结案后标签与阻断同时消失。 + *

+ * 不含空串——未指明节点的异常没有对应节点,无从挂标签; + * 它是否阻断全单由 {@link #blockingException} 另行判定。 + */ + private Set unresolvedExceptionNodeCodes(Long waybillId) { + if (waybillId == null) { + return Collections.emptySet(); + } + return unresolvedExceptions(waybillId).stream() + .map(item -> Func.toStr(item.getNodeCode(), "").trim()) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + } + + /** + * 该运单未处理完成异常的节点 key 列表,**保留空串**(表示存在未指明节点的异常)。 + *

+ * 门禁判定需要知道「有没有未指明节点的异常」,故不能用上面那个已过滤空串的集合。 + */ + private List unresolvedExceptionNodeCodeList(Long waybillId) { + return unresolvedExceptions(waybillId).stream() + .map(item -> Func.toStr(item.getNodeCode(), "").trim()) + .collect(Collectors.toList()); + } + + /** 该运单未了结(未达终态)的异常处置,按上报时间升序 */ + private List unresolvedExceptions(Long waybillId) { + if (waybillId == null) { + return Collections.emptyList(); + } + return exceptionDisposalMapper.selectList(Wrappers.lambdaQuery() + .eq(ExceptionDisposal::getWaybillId, waybillId) + .eq(ExceptionDisposal::getIsDeleted, 0) + .notIn(ExceptionDisposal::getDisposalStatus, SETTLED_EXCEPTION_STATUSES) + .orderByAsc(ExceptionDisposal::getReportTime) + .orderByAsc(ExceptionDisposal::getId)); + } + + /** + * 取「正在阻断该运单打卡」的那条异常处置。 + *

+ * 多条未了结异常时取阻断区间最大的那条(最早节点优先、未指明节点最优先), + * 与 {@link WaybillProcessSupport#blockedNodeKeys} 的判定口径一致。 + */ + private ExceptionDisposal blockingException(Waybill waybill, String processJson) { + String earliestCode = WaybillProcessSupport.earliestBlockingNodeCode( + processJson, unresolvedExceptionNodeCodeList(waybill.getId())); + if (earliestCode == null) { + return null; + } + return unresolvedExceptions(waybill.getId()).stream() + .filter(item -> earliestCode.equals(Func.toStr(item.getNodeCode(), "").trim())) + .findFirst() + .orElse(null); + } + + /** + * 断言指定节点未被异常门禁阻断。 + *

+ * 阻断范围是未了结异常所在节点及其后的节点;前端禁用只是体验层,这里才是强制点。 + */ + private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) { + Set blocked = WaybillProcessSupport.blockedNodeKeys( + processJson, unresolvedExceptionNodeCodeList(waybillId)); + if (Func.isNotEmpty(nodeCode) && blocked.contains(nodeCode)) { + throw new ServiceException("该运单存在未处理异常,处理完成后才能继续打卡"); + } + } + + /** + * 断言该节点的前置打卡节点都已完成(顺序门禁)。 + *

+ * 与异常门禁并列的第二道约束:两者解除条件不同,故分别校验、各自报错, + * 司机才知道该等一等(顺序)还是该找人处理(异常)。 + */ + private void assertPrerequisitePunched(String processJson, Long waybillId, Map nodeCfg) { + // ⚠️ 不能复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置的 key 比不出来 + Set punched = nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)) + .stream() + .map(punch -> Func.toStr(punch.getNodeCode(), "").trim()) + .filter(Func::isNotEmpty) + .collect(Collectors.toSet()); + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg)); + if (!unfinished.isEmpty()) { + throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + } + } + + /** + * 断言该运单没有未了结的异常。 + *

+ * 完成运单前必须校验:完成接口本身不要求节点全部打卡,不挡住它就能从 + * 「完成运单」绕过打卡门禁。 + */ + private void assertNoUnresolvedException(Long waybillId) { + if (!unresolvedExceptionNodeCodeList(waybillId).isEmpty()) { + throw new ServiceException("该运单存在未处理异常,处理完成后才能完成运单"); + } + } + /** * 组装过程配置 punch=是 的打卡节点列表。 *

- * 未打卡节点均可打,不做顺序/时段门禁; - * 默认展开:第一个未完成的可见节点。 + * 节点自身不做先后顺序门禁(未打卡即可打,与打卡先后无关); + * 唯一的跨节点约束是异常门禁:存在未了结异常时,异常所在节点及其后的节点不可打卡。 + * 默认展开:第一个未完成、且未被异常阻断的可见节点。 * 非在途节点「已打卡」以节点打卡表为准。 * 节点字段(定位/货量/凭证)取自动态过程配置。 */ @@ -855,8 +1002,67 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodes.add(vo); } + // 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡。 + // 已打卡节点不受影响,仍可展开查看回填内容。 + ExceptionDisposal blocker = blockingException(waybill, processJson); + if (blocker != null) { + String blockerNodeCode = Func.toStr(blocker.getNodeCode(), "").trim(); + Set blockedKeys = WaybillProcessSupport.blockedNodeKeysFrom(processJson, blockerNodeCode); + for (DriverPunchNodeVO vo : nodes) { + if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) { + continue; + } + vo.setBlocked(true); + vo.setActionable(false); + vo.setBlockedExceptionId(blocker.getId()); + vo.setLockReason(LOCK_REASON_EXCEPTION); + } + } + + // 顺序门禁:前面还有打卡节点没打,本节点就不能打(在途豁免)。 + // 与异常门禁独立:两者可同时成立,也可只成立一个。 + Set punchedCodes = new HashSet<>(); for (DriverPunchNodeVO vo : nodes) { - if (!Boolean.TRUE.equals(vo.getDone())) { + if (Boolean.TRUE.equals(vo.getDone())) { + punchedCodes.add(vo.getKey()); + } + } + for (DriverPunchNodeVO vo : nodes) { + if (Boolean.TRUE.equals(vo.getDone())) { + continue; + } + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys( + processJson, punchedCodes, vo.getKey()); + if (unfinished.isEmpty()) { + continue; + } + vo.setBlockedByOrder(true); + vo.setActionable(false); + vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡"); + } + + // 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用); + // 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。 + for (DriverPunchNodeVO vo : nodes) { + boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder()); + vo.setExpandable(!blockedByOrder); + } + + // 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。 + // 被牵连阻断的后续节点没有异常上报,不挂标签——否则司机以为每个节点都出了事。 + Set exceptionNodeCodes = unresolvedExceptionNodeCodes(waybill.getId()); + if (!exceptionNodeCodes.isEmpty()) { + for (DriverPunchNodeVO vo : nodes) { + if (exceptionNodeCodes.contains(vo.getKey())) { + vo.setHasException(true); + } + } + } + + for (DriverPunchNodeVO vo : nodes) { + if (!Boolean.TRUE.equals(vo.getDone()) + && !Boolean.TRUE.equals(vo.getBlocked()) + && !Boolean.TRUE.equals(vo.getBlockedByOrder())) { vo.setDefaultExpanded(true); break; } @@ -864,6 +1070,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return nodes; } + /** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */ + private String prerequisiteNames(String processJson, Set nodeKeys) { + List names = new ArrayList<>(); + for (Map node : WaybillProcessSupport.listDriverPunchNodes(processJson)) { + String key = WaybillProcessSupport.nodeKey(node); + if (nodeKeys.contains(key)) { + names.add(WaybillProcessSupport.nodeName(node)); + } + } + return names.isEmpty() ? "" : String.join("、", names); + } + private Map findPunchNodeConfig(String processJson, String nodeCode) { List> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); for (Map cfg : punchConfigs) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 67aeabd7..57c96783 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -41,8 +41,10 @@ import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IExceptionDisposalService; import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -75,11 +77,14 @@ public class ExceptionDisposalServiceImpl private final ExceptionDisposalFollowRecordMapper followRecordMapper; private final IWaybillService waybillService; + private final IDriverWaybillService driverWaybillService; public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper, - IWaybillService waybillService) { + IWaybillService waybillService, + IDriverWaybillService driverWaybillService) { this.followRecordMapper = followRecordMapper; this.waybillService = waybillService; + this.driverWaybillService = driverWaybillService; } @Override @@ -133,6 +138,19 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请关联运单后再上报"); } + // 异常必须归属到某个过程节点:没有节点的异常无从定位阻断区间,只能整单停摆。 + // 节点名取自过程配置而非请求体:上报页传什么不影响入库,避免落进一个该节点并不叫的名字。 + String nodeCode = Func.toStr(request.getNodeCode(), "").trim(); + if (Func.isBlank(nodeCode)) { + throw new ServiceException("请选择异常节点"); + } + Map nodeCfg = driverWaybillService.findPunchNode(disposal.getWaybillId(), nodeCode); + if (nodeCfg == null) { + throw new ServiceException("异常节点不存在"); + } + disposal.setNodeCode(WaybillProcessSupport.nodeKey(nodeCfg)); + disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg)); + if (!save(disposal)) { throw new ServiceException("异常上报失败"); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 88140021..c04c46cd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -32,10 +32,14 @@ import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; import java.util.Date; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * 运单过程配置解析(对齐 web 端 waybill-manage / process-config) @@ -51,6 +55,9 @@ public final class WaybillProcessSupport { private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept"; private static final String NODE_TRANSIT = "transit"; private static final String NODE_TRANSIT_NAME = "在途"; + /** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */ + private static final String DISPOSAL_COMPLETED = "completed"; + private static final String DISPOSAL_REVOKED = "revoked"; private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm"); private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm"); @@ -285,8 +292,15 @@ public final class WaybillProcessSupport { return isTruthy(node.get("location")); } + /** + * 是否需要采集货量:勾了「上传货量」且至少勾了一个货量类型。 + *

+ * 以已勾选的类型为准——类型为空即不采集,不存在「没配类型所以重量/体积/数量全采集」。 + * 过程配置页对「到场」「回单」这类不采集货量的节点不提供「上传货量」开关, + * 这些节点曾被默认写成 uploadCargo=true 且 cargoTypes 为空,只看 uploadCargo 会误判为需要采集。 + */ public static boolean nodeNeedCargo(Map node) { - return isTruthy(node.get("uploadCargo")); + return isTruthy(node.get("uploadCargo")) && !nodeStringList(node, "cargoTypes").isEmpty(); } public static boolean nodeNeedVoucher(Map node) { @@ -327,6 +341,146 @@ public final class WaybillProcessSupport { return Func.isEmpty(name) ? nodeKey(node) : name; } + /** + * 指定节点因「顺序门禁」而不能打卡时,返回它前面尚未打卡的打卡节点 key;可以打卡时返回空集。 + *

+ * 「前面」按过程配置里打卡节点的排列计算;接单、回单不参与打卡,故不作为前置 + * (否则第一个打卡节点永远等不到前置打卡记录)。 + *

+ * **在途豁免顺序门禁**:它只回答「此刻在哪」,不表示某个环节完成; + * 因前置未打卡就禁止报位置,会让运管员在最需要知道车在哪的时候失去可见性。 + * + * @param processJson 过程配置 + * @param punchedNodeKeys 该运单已打卡的节点 key + * @param nodeCode 待判定的节点 key + */ + public static Set unfinishedPrerequisiteKeys( + String processJson, Collection punchedNodeKeys, String nodeCode) { + if (Func.isEmpty(nodeCode)) { + return Collections.emptySet(); + } + Set punched = punchedNodeKeys == null + ? Collections.emptySet() : new HashSet<>(punchedNodeKeys); + Set unfinished = new LinkedHashSet<>(); + for (Map node : listDriverPunchNodes(processJson)) { + // 在途不参与顺序:既不作为别人的前置,也不被别人拦 + if (isTransitNode(node)) { + continue; + } + String key = nodeKey(node); + if (nodeCode.equals(key)) { + // 走到自己:只关心它前面那些 + return unfinished; + } + if (!punched.contains(key)) { + unfinished.add(key); + } + } + // 节点不在本配置里:不做顺序判定,交给节点存在性校验去报错 + return Collections.emptySet(); + } + + /** + * 异常处置是否已了结(已完成 / 已撤销)。 + *

+ * 只有终态解除打卡门禁:「处理中」只表示有人跟进过,问题可能仍未解决,不放行。 + */ + public static boolean isSettledDisposal(String disposalStatus) { + String status = stringVal(disposalStatus); + return DISPOSAL_COMPLETED.equalsIgnoreCase(status) || DISPOSAL_REVOKED.equalsIgnoreCase(status); + } + + /** + * 过程配置里的在途节点 key;配置中无在途节点时返回空串。 + */ + public static String transitNodeKey(String processJson) { + for (Map node : listDriverPunchNodes(processJson)) { + if (isTransitNode(node)) { + return nodeKey(node); + } + } + return ""; + } + + /** + * 计算被异常门禁阻断的过程节点 key。 + *

+ * 阻断范围:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。 + * 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。 + *

+ * 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。 + * 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。 + * + * @param processJson 过程配置 + * @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常 + */ + public static Set blockedNodeKeys(String processJson, Collection unresolvedNodeCodes) { + String earliest = earliestBlockingNodeCode(processJson, unresolvedNodeCodes); + return blockedNodeKeysFrom(processJson, earliest); + } + + /** + * 取「最早的可打卡节点」上的未了结异常节点 key;未指明节点的异常(空串)优先。 + *

+ * 返回值供按 key 反查具体异常用;无异常可阻断时返回 null。 + */ + public static String earliestBlockingNodeCode(String processJson, Collection unresolvedNodeCodes) { + List> punchNodes = listDriverPunchNodes(processJson); + if (punchNodes.isEmpty() || unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return null; + } + Set reported = new HashSet<>(); + for (String code : unresolvedNodeCodes) { + String key = stringVal(code); + // 未指明节点的异常锁全单,优先级高于任何具体节点 + if (Func.isEmpty(key)) { + return ""; + } + reported.add(key); + } + for (Map node : punchNodes) { + String key = nodeKey(node); + if (reported.contains(key)) { + return key; + } + } + return null; + } + + /** + * 由某个起点节点计算阻断区间:该节点及其后所有节点。起点为 null 时返回空集。 + */ + public static Set blockedNodeKeysFrom(String processJson, String fromNodeCode) { + List> punchNodes = listDriverPunchNodes(processJson); + Set blocked = new LinkedHashSet<>(); + if (punchNodes.isEmpty() || fromNodeCode == null) { + return blocked; + } + // 空串代表「未指明节点」,阻断全部可打卡节点。 + // 新上报已强制要求节点,此分支只为**存量数据**保留——去掉它,那些没有节点的 + // 未了结异常会突然不再阻断任何节点,等于悄悄放开了打卡。 + if (Func.isEmpty(fromNodeCode)) { + for (Map node : punchNodes) { + blocked.add(nodeKey(node)); + } + return blocked; + } + int start = -1; + for (int i = 0; i < punchNodes.size(); i++) { + if (fromNodeCode.equals(nodeKey(punchNodes.get(i)))) { + start = i; + break; + } + } + if (start < 0) { + return blocked; + } + for (int i = start; i < punchNodes.size(); i++) { + blocked.add(nodeKey(punchNodes.get(i))); + } + return blocked; + } + @SuppressWarnings("unchecked") public static List> parseProcessNodes(String processJson) { if (Func.isEmpty(processJson)) { diff --git a/doc/nacos/routes/blade-gateway-dev.json b/doc/nacos/routes/blade-gateway-dev.json index d68db7a8..dce7bc48 100644 --- a/doc/nacos/routes/blade-gateway-dev.json +++ b/doc/nacos/routes/blade-gateway-dev.json @@ -49,6 +49,28 @@ ], "uri": "lb://blade-system" }, + { + "id": "resource-route", + "order": 0, + "predicates": [ + { + "name": "Path", + "args": { + "pattern": "/blade-resource/**" + } + } + ], + "filters": [ + { + "name": "RewritePath", + "args": { + "regexp": "/blade-resource/(?.*)", + "replacement": "/$\\{segment}" + } + } + ], + "uri": "http://127.0.0.1:8010" + }, { "id": "example-route", "order": 0, diff --git a/doc/sql/transport/blade_exception_disposal_20260812.sql b/doc/sql/transport/blade_exception_disposal_20260812.sql index e1dbfd92..1592b95d 100644 --- a/doc/sql/transport/blade_exception_disposal_20260812.sql +++ b/doc/sql/transport/blade_exception_disposal_20260812.sql @@ -12,6 +12,8 @@ CREATE TABLE IF NOT EXISTS `blade_exception_disposal` ( `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', `waybill_id` bigint(20) DEFAULT NULL COMMENT '运单ID', `waybill_no` varchar(100) DEFAULT NULL COMMENT '运单号', + `node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)', + `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)', `loading_or_master_no` varchar(100) DEFAULT NULL COMMENT '配载单号/总单号', `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_name` varchar(100) DEFAULT NULL COMMENT '项目', diff --git a/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql new file mode 100644 index 00000000..ca09481d --- /dev/null +++ b/doc/sql/transport/blade_exception_disposal_node_code_20261005.sql @@ -0,0 +1,37 @@ +-- 异常处置增加过程节点归属 +-- 日期:2026-10-05 +-- 节点归属用于打卡门禁:某节点有未了结异常时,该节点及其后的节点不可打卡。 +-- node_code 为空表示未指明节点(全局入口上报),此类异常阻断该运单所有尚未打卡的节点。 +-- node_name 在上报时复制,此后不随过程配置变化,与车牌、项目、承运商等展示字段同一口径。 + +DELIMITER $$ + +DROP PROCEDURE IF EXISTS `upgrade_blade_exception_disposal_node_code_20261005`$$ +CREATE PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`() +BEGIN + DECLARE db_name varchar(128); + SET db_name = DATABASE(); + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal' + AND column_name = 'node_code') THEN + ALTER TABLE `blade_exception_disposal` + ADD COLUMN `node_code` varchar(64) DEFAULT NULL COMMENT '过程节点 key(为空表示未指明节点)' + AFTER `waybill_no`; + END IF; + + IF NOT EXISTS (SELECT 1 FROM information_schema.columns + WHERE table_schema = db_name + AND table_name = 'blade_exception_disposal' + AND column_name = 'node_name') THEN + ALTER TABLE `blade_exception_disposal` + ADD COLUMN `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称(上报时复制,此后不随过程配置变化)' + AFTER `node_code`; + END IF; +END$$ + +CALL `upgrade_blade_exception_disposal_node_code_20261005`()$$ +DROP PROCEDURE `upgrade_blade_exception_disposal_node_code_20261005`$$ + +DELIMITER ; diff --git a/doc/sql/transport/blade_process_config_cargo_types_20261005.sql b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql new file mode 100644 index 00000000..b03a51bc --- /dev/null +++ b/doc/sql/transport/blade_process_config_cargo_types_20261005.sql @@ -0,0 +1,19 @@ +-- 订正过程配置中「上传货量为是、货量类型为空」的脏配置 +-- 日期:2026-10-05 +-- +-- 成因:过程配置页的「到场」「回单」两个节点模板缺 supportCargo 字段,货量开关 +-- 在界面上不渲染,导致这两个节点被写成 uploadCargo=true 且 cargoTypes 恒为空。 +-- 语义:「是否需要采集货量」以已勾选的货量类型为准——类型为空即不采集, +-- 不存在「没配类型所以三项全采集」。 +-- 影响:订正后这些节点在小程序不再显示货量表单。因司机端解析过程配置时优先取 +-- 启用中的活配置,本次订正会同时改变所有引用该项目的运单(含历史运单)的呈现。 +-- 范围:只订正过程配置表,不修改 blade_waybill.process_json 快照列(那是历史留痕)。 +-- 幂等:执行后目标子串不再存在,重复执行匹配 0 行。 + +UPDATE `blade_process_config` +SET `node_config_json` = REPLACE( + `node_config_json`, + '"uploadCargo":true,"cargoTypes":[]', + '"uploadCargo":false,"cargoTypes":[]') +WHERE `is_deleted` = 0 + AND `node_config_json` LIKE '%"uploadCargo":true,"cargoTypes":[]%'; From ffed7d328ce76629bc214ff6e8fad2941971c4fe Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 6 Oct 2026 00:29:28 +0800 Subject: [PATCH 36/81] =?UTF-8?q?:sparkles:=20=E6=89=93=E5=8D=A1=E7=85=A7?= =?UTF-8?q?=E7=89=87=E6=94=B9=E4=B8=BA=E5=BF=85=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 节点打卡与在途打卡都必须上传照片,缺照片直接拒绝。前端已做必填校验,此处拦的是 绕过客户端直接调接口的情况;空白字符串不算已上传。 --- .../service/impl/DriverWaybillServiceImpl.java | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 0ee9d7ab..dec19dae 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -279,6 +279,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { // 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束 assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.transitNodeKey(processJson)); // 不做频次/时段门禁;定位按前端是否传参落库 + assertPunchPhoto(dto.getPhoto() == null ? null : List.of(dto.getPhoto())); Date now = new Date(); WaybillEnroutePunch punch = new WaybillEnroutePunch(); @@ -328,6 +329,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg)); assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg); + assertPunchPhoto(dto.getPhotos()); Date now = new Date(); WaybillNodePunch punch = new WaybillNodePunch(); @@ -898,6 +900,22 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } } + /** + * 断言打卡照片已上传。 + *

+ * 照片是打卡的必填项(每个节点至少 1 张),此处拦的是绕过客户端直接调接口的情况 + * ——前端的必填提示不构成约束。 + */ + private void assertPunchPhoto(List photos) { + boolean hasPhoto = photos != null && photos.stream() + .filter(Objects::nonNull) + .map(String::trim) + .anyMatch(Func::isNotEmpty); + if (!hasPhoto) { + throw new ServiceException("请上传打卡照片"); + } + } + /** * 断言该运单没有未了结的异常。 *

From 19ddc445da4128f3064077d1defd4c84507b9b6f Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 6 Oct 2026 03:17:25 +0800 Subject: [PATCH 37/81] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=9C=A8?= =?UTF-8?q?=E9=80=94=E6=89=93=E5=8D=A1=E7=BB=95=E8=BF=87=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E9=97=A8=E7=A6=81=EF=BC=9A=E6=9C=AA=E4=BA=86=E7=BB=93=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E8=90=BD=E5=9C=A8=E5=9C=A8=E9=80=94=E4=B9=8B=E5=90=8E?= =?UTF-8?q?=E6=97=B6=E4=BB=8D=E5=8F=AF=E6=89=93=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在途的异常阻断此前按过程配置里节点的位置区间计算:异常节点及其后的节点不可打卡。 异常报在「卸货」这类排在在途之后的节点时,区间只有卸货、签收,在途被放行, 等于车越接近目的地越能报在途;而异常报在「装货」(在途之前)反而会锁住在途。 改为运单级判定:该运单只要存在未了结异常,在途就不可打卡,与异常归属哪个节点无关。 普通打卡节点仍按区间(异常所在节点及其后)阻断。判定集中在 WaybillProcessSupport.blockedNodeKeys,卡片禁用态与 /enroute/submit 的强制校验共用它。 异常落在非打卡节点上(接单/回单、或已从过程配置删掉的节点)时没有可跳转的具体异常, 故 blockedExceptionId 可能为空,卡片只显示不可打卡原因。 --- .../transport/pojo/vo/DriverPunchNodeVO.java | 2 +- .../impl/DriverWaybillServiceImpl.java | 21 +-- .../support/WaybillProcessSupport.java | 19 ++- .../support/WaybillProcessSupportTest.java | 132 ++++++++++++++++++ 4 files changed, 162 insertions(+), 12 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index 5806b545..337e2fd0 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -39,7 +39,7 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "当前是否可打卡(已打卡、被异常阻断、在途频次时段都会置否)") private Boolean actionable; - @Schema(description = "是否被未了结的异常阻断:该节点及其后的节点不可打卡") + @Schema(description = "是否被未了结的异常阻断:异常所在节点及其后的节点不可打卡;在途只要有未了结异常即不可打卡") private Boolean blocked; @Schema(description = "是否因前置节点未打卡而被顺序门禁拦住(在途豁免,恒为否)") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index dec19dae..40b11591 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -869,7 +869,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** * 断言指定节点未被异常门禁阻断。 *

- * 阻断范围是未了结异常所在节点及其后的节点;前端禁用只是体验层,这里才是强制点。 + * 普通节点:阻断范围是未了结异常所在节点及其后的节点。 + * 在途节点:该运单存在任何未了结异常即被阻断,与异常落在哪个节点无关。 + * 前端禁用只是体验层,这里才是强制点。 */ private void assertNodeNotBlocked(String processJson, Long waybillId, String nodeCode) { Set blocked = WaybillProcessSupport.blockedNodeKeys( @@ -931,8 +933,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { /** * 组装过程配置 punch=是 的打卡节点列表。 *

- * 节点自身不做先后顺序门禁(未打卡即可打,与打卡先后无关); - * 唯一的跨节点约束是异常门禁:存在未了结异常时,异常所在节点及其后的节点不可打卡。 + * 两道跨节点门禁:异常门禁(未了结异常所在节点及其后节点不可打卡,在途另按运单级—— + * 该运单有任何未了结异常就不可打卡)、顺序门禁(前置打卡节点未完成则不可打卡,在途豁免)。 * 默认展开:第一个未完成、且未被异常阻断的可见节点。 * 非在途节点「已打卡」以节点打卡表为准。 * 节点字段(定位/货量/凭证)取自动态过程配置。 @@ -1020,19 +1022,22 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodes.add(vo); } - // 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡。 + // 异常门禁:未了结异常所在节点、及其在过程配置顺序中位于其后的节点不可打卡; + // 在途另按运单级——该运单只要还有未了结异常,在途就不可打卡。 // 已打卡节点不受影响,仍可展开查看回填内容。 ExceptionDisposal blocker = blockingException(waybill, processJson); - if (blocker != null) { - String blockerNodeCode = Func.toStr(blocker.getNodeCode(), "").trim(); - Set blockedKeys = WaybillProcessSupport.blockedNodeKeysFrom(processJson, blockerNodeCode); + Set blockedKeys = WaybillProcessSupport.blockedNodeKeys( + processJson, unresolvedExceptionNodeCodeList(waybill.getId())); + if (!blockedKeys.isEmpty()) { for (DriverPunchNodeVO vo : nodes) { if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) { continue; } vo.setBlocked(true); vo.setActionable(false); - vo.setBlockedExceptionId(blocker.getId()); + if (blocker != null) { + vo.setBlockedExceptionId(blocker.getId()); + } vo.setLockReason(LOCK_REASON_EXCEPTION); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index c04c46cd..8e0291e8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -405,9 +405,14 @@ public final class WaybillProcessSupport { /** * 计算被异常门禁阻断的过程节点 key。 *

- * 阻断范围:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。 + * 普通打卡节点按区间阻断:未了结异常所在节点、及其在过程配置顺序中位于其后的所有节点。 * 未指明节点(nodeCode 为空)的异常无从定位区间,阻断全部可打卡节点。 *

+ * **在途节点按运单级阻断**:该运单只要存在任何未了结异常,在途就不可打卡, + * 与异常落在哪个节点无关。在途只回答「此刻在哪」,不表示某个环节完成, + * 区间对它没有意义——按区间算的话,异常报在「装货」(在途之前)会锁住在途, + * 报在「到货」(在途之后)反而放行,等于越接近目的地越能报在途。 + *

* 多个未了结异常时按**最早**的那个起算——它锁住的区间最大,覆盖其余异常。 * 需要知道具体是「哪条异常」在拦人时,用 {@link #earliestBlockingNodeCode}。 * @@ -415,8 +420,16 @@ public final class WaybillProcessSupport { * @param unresolvedNodeCodes 该运单未了结异常的节点 key;含空串表示存在未指明节点的异常 */ public static Set blockedNodeKeys(String processJson, Collection unresolvedNodeCodes) { - String earliest = earliestBlockingNodeCode(processJson, unresolvedNodeCodes); - return blockedNodeKeysFrom(processJson, earliest); + Set blocked = new LinkedHashSet<>( + blockedNodeKeysFrom(processJson, earliestBlockingNodeCode(processJson, unresolvedNodeCodes))); + if (unresolvedNodeCodes == null || unresolvedNodeCodes.isEmpty()) { + return blocked; + } + String transitKey = transitNodeKey(processJson); + if (Func.isNotEmpty(transitKey)) { + blocked.add(transitKey); + } + return blocked; } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java new file mode 100644 index 00000000..2aa846c2 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -0,0 +1,132 @@ +package org.springblade.transport.support; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 打卡门禁:异常门禁(节点区间 + 在途运单级)与顺序门禁(在途豁免)。 + *

+ * 这里的口径会同时决定司机端卡片的禁用态与后端提交接口的校验, + * 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与 + * {@link WaybillProcessSupport#unfinishedPrerequisiteKeys}, + * 所以断言下在它们身上就覆盖了两条链路。 + */ +class WaybillProcessSupportTest { + + /** 与线上「石灰石包头运输项目」实际下发的过程配置同序:在途排在发货之后、到货之前 */ + private static final String TRANSPORT_JSON = """ + [ + {"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"arrive_scene","name":"到场","type":"punch","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","type":"cargo","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","type":"cargo","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true} + ] + """; + + private static Set keys(String... values) { + return new LinkedHashSet<>(Arrays.asList(values)); + } + + // ------------------------------------------------------------ 异常门禁 + + @Test + @DisplayName("无未了结异常时谁都不阻断") + void withoutUnresolvedExceptionNothingIsBlocked() { + assertTrue(WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Collections.emptyList()).isEmpty()); + } + + @Test + @DisplayName("普通节点:异常所在节点及其后节点阻断,之前的不受影响") + void unresolvedExceptionBlocksNodeAndItsSuccessors() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("arrival")); + + assertTrue(blocked.containsAll(keys("arrival", "unload", "sign")), "异常所在节点及其后节点都要锁"); + assertFalse(blocked.contains("arrive_scene"), "异常之前的节点不该被锁"); + assertFalse(blocked.contains("load")); + assertFalse(blocked.contains("dispatch")); + // 在途是运单级的例外:异常落在它之后也锁,见 transitIsBlockedByExceptionOnLaterNode + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("在途:异常落在它之后的「卸货」时同样阻断——门禁是运单级,不看异常在哪个节点") + void transitIsBlockedByExceptionOnLaterNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload")); + + assertTrue(blocked.contains("transit"), + "运单还有未了结异常时,在途就不能打卡;按区间算会漏掉「异常在在途之后」这种最常见的情形"); + assertEquals(keys("transit", "unload", "sign"), blocked); + } + + @Test + @DisplayName("在途:异常落在它之前的「装货」时也阻断") + void transitIsBlockedByExceptionOnEarlierNode() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("load")); + + assertTrue(blocked.contains("transit")); + } + + @Test + @DisplayName("未指明节点的异常(存量数据)阻断全部打卡节点,含在途") + void exceptionWithoutNodeBlocksEverything() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Arrays.asList("")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("多节点上报异常时取最早的那个起算,在途仍被阻断") + void earliestExceptionDecidesTheRange() { + Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, List.of("unload", "arrive_scene")); + + assertEquals(keys("arrive_scene", "load", "dispatch", "transit", "arrival", "unload", "sign"), blocked); + } + + @Test + @DisplayName("配置里没有在途节点时不凭空造一个 key") + void withoutTransitNodeNothingIsInvented() { + String noTransit = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + assertEquals(keys("load", "unload"), WaybillProcessSupport.blockedNodeKeys(noTransit, List.of("load"))); + } + + // ------------------------------------------------------------ 顺序门禁 + + @Test + @DisplayName("顺序门禁:前面还有打卡节点没打时返回它们,在途不作为前置") + void unfinishedPrerequisitesIgnoreTransit() { + Set punched = keys("arrive_scene", "load", "dispatch"); + + Set unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, punched, "unload"); + + assertEquals(keys("arrival"), unfinished, + "「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)"); + } + + @Test + @DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集") + void transitIsExemptFromOrderGate() { + assertTrue( + WaybillProcessSupport.unfinishedPrerequisiteKeys(TRANSPORT_JSON, Collections.emptySet(), "transit").isEmpty(), + "在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了"); + } + +} From c50a86d5c409a8cc20ce0b04a51dc6a952e9ebe5 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 6 Oct 2026 04:40:43 +0800 Subject: [PATCH 38/81] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=89=93=E5=8D=A1=E8=AE=B0=E5=BD=95=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E7=AB=AF=E7=BC=96=E8=BE=91=EF=BC=88=E5=90=AB?= =?UTF-8?q?=E6=89=93=E5=8D=A1=E7=8A=B6=E6=80=81=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 POST /waybill-manage/punch-record/save:按运单当前过程配置补录或修正 节点打卡与在途打卡,打卡状态也在可编辑之列。 - punched=false 即取消打卡:清除该节点全部打卡流水(时间轴上「未打卡」的判据 就是没有流水);在途节点没有节点 key 可定位单条,故清空该运单全部在途流水 - 节点以当前过程配置为准回写 key/name,前端带旧值也不会写歪 - 编辑按字段显式更新:updateById 会跳过 null 字段,清空地址/货量/照片保存后不生效 - 编辑不改 exception_flag(与异常处置表绑定);管理端补录 driver_id 留空 - 凭证图按 [{type,url}] 回写,避免展示层按下标回推类型时换序串标 - 补 8 个服务单测 + 3 个 MockMvc 端点绑定测试 --- .../dto/WaybillPunchRecordSaveRequest.java | 114 +++++++ .../controller/WaybillController.java | 8 + .../transport/service/IWaybillService.java | 11 + .../service/impl/WaybillServiceImpl.java | 274 ++++++++++++++++ .../WaybillPunchRecordEndpointTest.java | 136 ++++++++ .../impl/WaybillPunchRecordSaveTest.java | 307 ++++++++++++++++++ 6 files changed, 850 insertions(+) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java new file mode 100644 index 00000000..763f8f31 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java @@ -0,0 +1,114 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 管理端打卡记录编辑请求 + *

+ * 承载时间轴上单条打卡记录的全部可编辑项,字段按过程配置取舍: + * 打卡定位(地址/经纬度)、上传货量(重量/体积/数量)、上传凭证(照片)。 + *

+ * {@code punched=false} 表示取消打卡,服务端会清除该节点的打卡流水 + * ——时间轴上「未打卡」的判据就是没有流水,故不能只改状态位。 + * + * @author Chill + */ +@Data +@Schema(description = "管理端打卡记录编辑请求") +public class WaybillPunchRecordSaveRequest implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID") + private Long waybillId; + + @Schema(description = "打卡类型:node=过程节点打卡,enroute=在途打卡") + private String type; + + @Schema(description = "过程节点 key") + private String nodeCode; + + @Schema(description = "过程节点名称") + private String nodeName; + + @Schema(description = "打卡状态:true 已打卡,false 未打卡") + private Boolean punched; + + @Schema(description = "打卡时间 yyyy-MM-dd HH:mm:ss,为空时取当前时间") + private String punchTime; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "经度") + private String longitude; + + @Schema(description = "纬度") + private String latitude; + + @Schema(description = "重量(吨)") + private String weight; + + @Schema(description = "体积(方)") + private String volume; + + @Schema(description = "数量(件)") + private String quantity; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "打卡凭证图,按上传顺序回写凭证类型") + private List photos = new ArrayList<>(); + + /** + * 打卡凭证图 + *

+ * 类型随图一起回写:展示层对「逗号分隔的裸 URL」按凭证类型下标回推标签, + * 编辑换了顺序就会串标,所以这里保留原类型。 + */ + @Data + @Schema(description = "打卡凭证图") + public static class Photo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "凭证类型") + private String type; + + @Schema(description = "图片URL") + private String url; + + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java index e7bc1194..b6394dcb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java @@ -60,6 +60,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.dto.WaybillMileageRequest; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.ICustomerArchiveService; import org.springblade.transport.service.IProjectApplyService; @@ -113,6 +114,13 @@ public class WaybillController extends BladeController { return R.data(waybillService.listPunchRecords(waybillId)); } + @PostMapping("/punch-record/save") + @ApiOperationSupport(order = 2) + @Operation(summary = "编辑打卡记录", description = "按过程配置补录/修正节点或在途打卡;punched=false 表示取消打卡,清除该节点打卡流水") + public R savePunchRecord(@RequestBody WaybillPunchRecordSaveRequest request) { + return R.status(waybillService.savePunchRecord(request)); + } + @PostMapping("/locate") @ApiOperationSupport(order = 1) @Operation(summary = "车辆实时定位", description = "按运单绑定车牌调用 LBS_LOCATE") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java index 7f3af7dc..8a5abe66 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java @@ -33,6 +33,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.dto.WaybillMileageRequest; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import java.util.List; @@ -59,6 +60,16 @@ public interface IWaybillService extends BaseService { */ WaybillPunchRecordsVO listPunchRecords(Long waybillId); + /** + * 管理端:编辑时间轴上的单条打卡记录(含打卡状态) + *

+ * punched=false 时清除该节点(在途节点为该运单全部在途流水)的打卡流水。 + * + * @param request 打卡记录编辑请求 + * @return 是否成功 + */ + boolean savePunchRecord(WaybillPunchRecordSaveRequest request); + /** * 运单车辆实时定位(按运单绑定车牌调用 LBS) * diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 5da21fac..3b4440c8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -28,9 +28,11 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.fasterxml.jackson.databind.JsonNode; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; @@ -49,6 +51,7 @@ import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; import org.springblade.transport.pojo.entity.WaybillNodePunch; import org.springblade.transport.pojo.dto.WaybillMileageRequest; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.pojo.vo.WaybillLocateVO; @@ -97,6 +100,9 @@ public class WaybillServiceImpl extends BaseServiceImpl private static final String STATUS_DRAFT = "draft"; + /** 打卡记录类型:在途打卡(其余一律按过程节点打卡处理) */ + private static final String ENROUTE_PUNCH_TYPE = "enroute"; + @jakarta.annotation.Resource private ILoadingManageService loadingManageService; @@ -483,6 +489,274 @@ public class WaybillServiceImpl extends BaseServiceImpl return vo; } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean savePunchRecord(WaybillPunchRecordSaveRequest request) { + if (request == null || request.getWaybillId() == null) { + throw new ServiceException("运单ID不能为空"); + } + Waybill waybill = getById(request.getWaybillId()); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + throw new ServiceException("运单不存在"); + } + // 只有显式传 false 才是取消打卡,未传按已打卡处理 + boolean punched = !Boolean.FALSE.equals(request.getPunched()); + if (ENROUTE_PUNCH_TYPE.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(request.getType()))) { + return saveEnroutePunchRecord(waybill, request, punched); + } + return saveNodePunchRecord(waybill, request, punched); + } + + /** + * 过程节点打卡:已打卡补录/修正,未打卡清除该节点流水。 + */ + private boolean saveNodePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + Long waybillId = waybill.getId(); + String processJson = resolveLiveProcessJson(waybill); + Map nodeConfig = findEnabledNodeConfig(processJson, request.getNodeCode(), request.getNodeName()); + // 节点以过程配置为准:配置改过 key/name 后,前端带上来的可能是旧值 + String nodeCode = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeCode()) + : WaybillProcessSupport.nodeKey(nodeConfig); + String nodeName = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeName()) + : WaybillProcessSupport.nodeName(nodeConfig); + if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) { + throw new ServiceException("过程节点不能为空"); + } + List rows = nodePunchRows(waybillId, nodeCode, nodeName); + if (!punched) { + if (!rows.isEmpty()) { + waybillNodePunchMapper.deleteBatchIds(rows.stream().map(WaybillNodePunch::getId).toList()); + } + return true; + } + WaybillNodePunch punch = pickLatestNodePunch(rows, nodeCode, nodeName); + boolean isNew = punch == null; + if (isNew) { + punch = new WaybillNodePunch(); + punch.setWaybillId(waybillId); + punch.setWaybillNo(waybill.getWaybillNo()); + // 管理端补录没有打卡司机,driver_id 留空,不记到任何司机头上 + punch.setExceptionFlag(0); + } + if (Func.isNotEmpty(nodeCode)) { + punch.setNodeCode(nodeCode); + } + if (Func.isNotEmpty(nodeName)) { + punch.setNodeName(nodeName); + } + punch.setPunchTime(parsePunchTime(request.getPunchTime())); + punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress())); + punch.setLongitude(parseCoordinate(request.getLongitude(), "经度")); + punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度")); + punch.setWeight(TransportBusinessSupport.trimToNull(request.getWeight())); + punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume())); + punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity())); + punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark())); + punch.setPhotos(encodePunchPhotos(request.getPhotos())); + // 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护 + if (isNew) { + waybillNodePunchMapper.insert(punch); + } else { + updateNodePunchFields(punch); + } + return true; + } + + /** + * 显式按字段更新节点打卡。 + *

+ * 不能用 updateById:它按「非空才更新」生成 SQL,把地址/货量/照片清空时这些字段为 null, + * 会被整条跳过——界面上删掉的内容保存后还在。 + */ + private void updateNodePunchFields(WaybillNodePunch punch) { + waybillNodePunchMapper.update(null, Wrappers.lambdaUpdate() + .eq(WaybillNodePunch::getId, punch.getId()) + .set(WaybillNodePunch::getNodeCode, punch.getNodeCode()) + .set(WaybillNodePunch::getNodeName, punch.getNodeName()) + .set(WaybillNodePunch::getPunchTime, punch.getPunchTime()) + .set(WaybillNodePunch::getAddress, punch.getAddress()) + .set(WaybillNodePunch::getLongitude, punch.getLongitude()) + .set(WaybillNodePunch::getLatitude, punch.getLatitude()) + .set(WaybillNodePunch::getWeight, punch.getWeight()) + .set(WaybillNodePunch::getVolume, punch.getVolume()) + .set(WaybillNodePunch::getQuantity, punch.getQuantity()) + .set(WaybillNodePunch::getRemark, punch.getRemark()) + .set(WaybillNodePunch::getPhotos, punch.getPhotos()) + // entity 传 null,MP 不会自动填充审计字段,这里补上 + .set(WaybillNodePunch::getUpdateTime, new Date()) + .set(WaybillNodePunch::getUpdateUser, AuthUtil.getUserId())); + } + + /** + * 在途打卡:时间轴上的在途节点只展示最新一条流水。 + */ + private boolean saveEnroutePunchRecord(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + Long waybillId = waybill.getId(); + List rows = waybillEnroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybillId) + .orderByAsc(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getId)); + if (!punched) { + // 在途节点没有节点 key 可定位单条,取消打卡即清空该运单全部在途流水, + // 否则更早的流水会顶上来继续显示已打卡 + if (!rows.isEmpty()) { + waybillEnroutePunchMapper.deleteBatchIds(rows.stream().map(WaybillEnroutePunch::getId).toList()); + } + return true; + } + WaybillEnroutePunch punch = rows.isEmpty() ? null : rows.get(rows.size() - 1); + boolean isNew = punch == null; + if (isNew) { + punch = new WaybillEnroutePunch(); + punch.setWaybillId(waybillId); + punch.setWaybillNo(waybill.getWaybillNo()); + } + punch.setPunchTime(parsePunchTime(request.getPunchTime())); + punch.setAddress(TransportBusinessSupport.trimToNull(request.getAddress())); + punch.setLongitude(parseCoordinate(request.getLongitude(), "经度")); + punch.setLatitude(parseCoordinate(request.getLatitude(), "纬度")); + // 在途打卡表只有一个照片字段(单张),多张只保留第一张 + punch.setPhoto(firstPunchPhotoUrl(request.getPhotos())); + if (isNew) { + waybillEnroutePunchMapper.insert(punch); + } else { + // 同节点打卡:显式 set,否则清空地址/照片保存后不生效 + waybillEnroutePunchMapper.update(null, Wrappers.lambdaUpdate() + .eq(WaybillEnroutePunch::getId, punch.getId()) + .set(WaybillEnroutePunch::getPunchTime, punch.getPunchTime()) + .set(WaybillEnroutePunch::getAddress, punch.getAddress()) + .set(WaybillEnroutePunch::getLongitude, punch.getLongitude()) + .set(WaybillEnroutePunch::getLatitude, punch.getLatitude()) + .set(WaybillEnroutePunch::getPhoto, punch.getPhoto()) + .set(WaybillEnroutePunch::getUpdateTime, new Date()) + .set(WaybillEnroutePunch::getUpdateUser, AuthUtil.getUserId())); + } + return true; + } + + /** + * 该节点名下的全部打卡流水(含历史),按打卡时间升序。 + *

+ * 取消打卡要清空全部,只软删最新一条会让更早的历史流水顶上来继续显示已打卡。 + */ + private List nodePunchRows(Long waybillId, String nodeCode, String nodeName) { + return waybillNodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId) + .orderByAsc(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getId)) + .stream() + .filter(punch -> { + String rowCode = TransportBusinessSupport.trimToEmpty(punch.getNodeCode()); + String rowName = TransportBusinessSupport.trimToEmpty(punch.getNodeName()); + if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(rowCode)) { + return true; + } + return Func.isNotEmpty(nodeName) && nodeName.equals(rowName); + }) + .toList(); + } + + /** + * 取该节点最新一条流水,口径与展示层一致:nodeCode 命中优先,未命中才回退 nodeName。 + */ + private WaybillNodePunch pickLatestNodePunch(List rows, String nodeCode, String nodeName) { + WaybillNodePunch byCode = null; + WaybillNodePunch byName = null; + for (WaybillNodePunch row : rows) { + if (Func.isNotEmpty(nodeCode) && nodeCode.equalsIgnoreCase(TransportBusinessSupport.trimToEmpty(row.getNodeCode()))) { + byCode = row; + } else if (Func.isNotEmpty(nodeName) && nodeName.equals(TransportBusinessSupport.trimToEmpty(row.getNodeName()))) { + byName = row; + } + } + return byCode != null ? byCode : byName; + } + + /** + * 过程配置中启用(未启用时不参与打卡,补录无从校验)的节点配置;找不到返回 null。 + */ + private Map findEnabledNodeConfig(String processJson, String nodeCode, String nodeName) { + String code = TransportBusinessSupport.trimToEmpty(nodeCode); + String name = TransportBusinessSupport.trimToEmpty(nodeName); + List> nodes = WaybillProcessSupport.listEnabledProcessNodes(processJson); + if (nodes.isEmpty()) { + nodes = WaybillProcessSupport.listDriverPunchNodes(processJson); + } + for (Map node : nodes) { + if (Func.isNotEmpty(code) && code.equalsIgnoreCase(WaybillProcessSupport.nodeKey(node))) { + return node; + } + if (Func.isNotEmpty(name) && name.equals(WaybillProcessSupport.nodeName(node))) { + return node; + } + } + return null; + } + + /** + * 打卡照片序列化:保留 [{type,url}] 结构,URL 为空的一律丢弃。 + */ + private String encodePunchPhotos(List photos) { + if (photos == null || photos.isEmpty()) { + return null; + } + List> items = new ArrayList<>(); + for (WaybillPunchRecordSaveRequest.Photo photo : photos) { + String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl()); + if (Func.isEmpty(url)) { + continue; + } + String type = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getType()); + Map item = new LinkedHashMap<>(); + item.put("type", Func.isEmpty(type) ? ("凭证" + (items.size() + 1)) : type); + item.put("url", url); + items.add(item); + } + return items.isEmpty() ? null : JsonUtil.toJson(items); + } + + private String firstPunchPhotoUrl(List photos) { + if (photos == null) { + return null; + } + for (WaybillPunchRecordSaveRequest.Photo photo : photos) { + String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl()); + if (Func.isNotEmpty(url)) { + return url; + } + } + return null; + } + + /** + * 打卡时间为空表示「此刻补录」;格式非法直接报错,不静默改成当前时间。 + */ + private Date parsePunchTime(String text) { + String value = TransportBusinessSupport.trimToEmpty(text); + if (Func.isEmpty(value)) { + return new Date(); + } + try { + return DateUtil.parse(value, DateUtil.PATTERN_DATETIME); + } catch (Exception exception) { + throw new ServiceException("打卡时间格式不正确,应为 yyyy-MM-dd HH:mm:ss"); + } + } + + private BigDecimal parseCoordinate(String text, String label) { + String value = TransportBusinessSupport.trimToEmpty(text); + if (Func.isEmpty(value)) { + return null; + } + try { + return new BigDecimal(value); + } catch (NumberFormatException exception) { + throw new ServiceException(label + "格式不正确"); + } + } + private WaybillNodePunch findLatestNodePunch(Map index, String nodeCode, String nodeName) { if (index == null || index.isEmpty()) { return null; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java new file mode 100644 index 00000000..81bbb9e1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/controller/WaybillPunchRecordEndpointTest.java @@ -0,0 +1,136 @@ +package org.springblade.transport.controller; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; +import org.springblade.transport.service.IContractManageService; +import org.springblade.transport.service.ICustomerArchiveService; +import org.springblade.transport.service.IProjectApplyService; +import org.springblade.transport.service.ITransportPlanService; +import org.springblade.transport.service.IWaybillImportBatchService; +import org.springblade.transport.service.IWaybillService; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * 打卡记录编辑接口的入参绑定测试。 + *

+ * 用真实的 HTTP 请求走一遍路由与 JSON 反序列化:字段名写错、凭证图数组结构对不上, + * 都会在这里失败,而不是等前端点保存时才发现。 + */ +class WaybillPunchRecordEndpointTest { + + private IWaybillService waybillService; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + WaybillController controller = new WaybillController( + waybillService, + mock(IProjectApplyService.class), + mock(IContractManageService.class), + mock(ICustomerArchiveService.class), + mock(ITransportPlanService.class), + mock(IWaybillImportBatchService.class)); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + @DisplayName("POST /waybill-manage/punch-record/save:绑定打卡明细与凭证图") + void bindPunchRecordBody() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(true); + String body = """ + { + "waybillId": 6001, + "type": "node", + "nodeCode": "load", + "nodeName": "装货", + "punched": true, + "punchTime": "2026-10-06 09:30:00", + "address": "上海市青浦区", + "longitude": "121.47", + "latitude": "31.23", + "weight": "12.5", + "volume": "", + "quantity": "3", + "remark": "补录", + "photos": [{"type": "委托单", "url": "https://oss/a.jpg"}] + } + """; + + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(200)) + .andExpect(jsonPath("$.success").value(true)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class); + verify(waybillService).savePunchRecord(captor.capture()); + WaybillPunchRecordSaveRequest request = captor.getValue(); + assertEquals(6001L, request.getWaybillId()); + assertEquals("node", request.getType()); + assertEquals("load", request.getNodeCode()); + assertEquals("装货", request.getNodeName()); + assertEquals(Boolean.TRUE, request.getPunched()); + assertEquals("2026-10-06 09:30:00", request.getPunchTime()); + assertEquals("上海市青浦区", request.getAddress()); + assertEquals("121.47", request.getLongitude()); + assertEquals("31.23", request.getLatitude()); + assertEquals("12.5", request.getWeight()); + assertEquals("3", request.getQuantity()); + assertEquals("补录", request.getRemark()); + assertNotNull(request.getPhotos()); + assertEquals(1, request.getPhotos().size()); + assertEquals("委托单", request.getPhotos().get(0).getType()); + assertEquals("https://oss/a.jpg", request.getPhotos().get(0).getUrl()); + } + + @Test + @DisplayName("取消打卡:punched=false 原样传入服务层") + void bindUnpunchedFlag() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(true); + String body = """ + {"waybillId": 6001, "type": "enroute", "punched": false} + """; + + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(WaybillPunchRecordSaveRequest.class); + verify(waybillService).savePunchRecord(captor.capture()); + assertEquals(Boolean.FALSE, captor.getValue().getPunched()); + assertEquals("enroute", captor.getValue().getType()); + } + + @Test + @DisplayName("服务层返回 false:响应标记为失败") + void reportFailureWhenServiceRejects() throws Exception { + when(waybillService.savePunchRecord(any())).thenReturn(false); + mockMvc.perform(post("/waybill-manage/punch-record/save") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"waybillId\": 6001}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(false)); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java new file mode 100644 index 00000000..e59ee890 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -0,0 +1,307 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; + +import java.lang.reflect.Field; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 管理端打卡记录编辑测试。 + *

+ * 守住三件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来 + * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段。 + */ +class WaybillPunchRecordSaveTest { + + private static final Long WAYBILL_ID = 6001L; + private static final String WAYBILL_NO = "YD202610060001"; + private static final String PROCESS_JSON = "[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true," + + "\"punch\":true,\"location\":true,\"uploadCargo\":true,\"cargoTypes\":[\"重量\",\"数量\"]," + + "\"uploadVoucher\":true,\"voucherTypes\":[\"委托单\"]}]"; + + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", enroutePunchMapper); + } + + // ------------------------------------------------------------ 过程节点打卡 + + @Test + @DisplayName("补录未打卡节点:新建流水,节点取过程配置,不记司机、异常标记为否") + void insertNodePunchWhenNoneExists() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + WaybillPunchRecordSaveRequest request = request("node", "LOAD", "装货"); + request.setPunched(true); + request.setPunchTime("2026-10-06 09:30:00"); + request.setWeight("12.5"); + request.setRemark("补录"); + request.setPhotos(List.of(photo("委托单", "https://oss/a.jpg"))); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + WaybillNodePunch saved = captor.getValue(); + // 大小写不一致也应按过程配置回写节点 key,否则司机端按 key 查不到这条打卡 + assertEquals("load", saved.getNodeCode()); + assertEquals("装货", saved.getNodeName()); + assertEquals(WAYBILL_ID, saved.getWaybillId()); + assertEquals(WAYBILL_NO, saved.getWaybillNo()); + assertNotNull(saved.getPunchTime()); + assertEquals("12.5", saved.getWeight()); + assertEquals(0, saved.getExceptionFlag()); + assertNull(saved.getDriverId(), "管理端补录不能记到司机头上"); + assertNotNull(saved.getPhotos()); + assertTrue(saved.getPhotos().contains("https://oss/a.jpg"), "照片URL应写入 photos"); + assertTrue(saved.getPhotos().contains("委托单"), "凭证类型应随照片一起写入"); + verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("编辑已打卡节点:改最新一条,不新建,也不改写异常标记") + @SuppressWarnings("unchecked") + void updateLatestNodePunch() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillNodePunch existed = nodePunch(7001L, "load", "装货"); + existed.setExceptionFlag(1); + existed.setWeight("10"); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(existed)); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPunchTime("2026-10-06 11:00:00"); + request.setWeight("20"); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(nodePunchMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains("20"), "重量应写进更新语句"); + // updateById 会跳过 null 字段:清空地址/货量/照片必须由显式 set 写 null 才生效 + assertTrue(values.contains(null), "被清空的字段必须显式写 null"); + assertFalse(wrapper.getSqlSet().contains("exception_flag"), + "异常标记与异常处置表绑定,编辑打卡不得动它"); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("取消打卡:清空该节点全部流水,历史流水不得顶上来") + @SuppressWarnings("unchecked") + void deleteAllNodePunchesWhenUnpunched() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())) + .thenReturn(List.of(nodePunch(7001L, "load", "装货"), nodePunch(7002L, "load", "装货"))); + + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(false); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(nodePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(7001L, 7002L), new HashSet<>(captor.getValue())); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 在途打卡 + + @Test + @DisplayName("编辑在途打卡:改最新一条,单张照片字段只留第一张") + @SuppressWarnings("unchecked") + void updateEnroutePunchKeepsFirstPhoto() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillEnroutePunch existed = new WaybillEnroutePunch(); + existed.setId(8001L); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of(existed)); + + WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途"); + request.setPunched(true); + request.setPunchTime("2026-10-06 12:00:00"); + request.setAddress("沪昆高速"); + request.setPhotos(List.of( + photo("货物照片", "https://oss/a.jpg"), + photo("货物照片", "https://oss/b.jpg"))); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(enroutePunchMapper).update(isNull(), captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains("https://oss/a.jpg"), "只保留第一张照片"); + assertFalse(values.contains("https://oss/b.jpg"), "第二张不应写入"); + assertTrue(values.contains("沪昆高速")); + assertTrue(wrapper.getSqlSet().contains("photo"), "照片字段必须显式更新"); + verify(enroutePunchMapper, never()).insert(any(WaybillEnroutePunch.class)); + } + + @Test + @DisplayName("取消在途打卡:清空该运单全部在途流水") + @SuppressWarnings("unchecked") + void deleteAllEnroutePunchesWhenUnpunched() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + WaybillEnroutePunch first = new WaybillEnroutePunch(); + first.setId(8001L); + WaybillEnroutePunch second = new WaybillEnroutePunch(); + second.setId(8002L); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of(first, second)); + + WaybillPunchRecordSaveRequest request = request("enroute", "transit", "在途"); + request.setPunched(false); + + service.savePunchRecord(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Collection.class); + verify(enroutePunchMapper).deleteBatchIds(captor.capture()); + assertEquals(Set.of(8001L, 8002L), new HashSet<>(captor.getValue())); + } + + // ------------------------------------------------------------ 入参校验 + + @Test + @DisplayName("运单不存在:拒绝保存") + void rejectMissingWaybill() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(null); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + } + + @Test + @DisplayName("打卡时间格式非法:报错而不是静默改成当前时间") + void rejectInvalidPunchTime() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPunchTime("2026/10/06 09:30"); + + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("经纬度非法:报错且不写库") + void rejectInvalidCoordinate() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setLongitude("东经120度"); + + assertThrows(ServiceException.class, () -> service.savePunchRecord(request)); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setProcessJson(PROCESS_JSON); + waybill.setIsDeleted(0); + return waybill; + } + + private WaybillNodePunch nodePunch(Long id, String nodeCode, String nodeName) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(id); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + punch.setNodeName(nodeName); + return punch; + } + + private WaybillPunchRecordSaveRequest request(String type, String nodeCode, String nodeName) { + WaybillPunchRecordSaveRequest request = new WaybillPunchRecordSaveRequest(); + request.setWaybillId(WAYBILL_ID); + request.setType(type); + request.setNodeCode(nodeCode); + request.setNodeName(nodeName); + return request; + } + + private WaybillPunchRecordSaveRequest.Photo photo(String type, String url) { + WaybillPunchRecordSaveRequest.Photo photo = new WaybillPunchRecordSaveRequest.Photo(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From 4e5b6ce8f38ca891b924cd96992bff77ef3d584b Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 6 Oct 2026 12:36:07 +0800 Subject: [PATCH 39/81] =?UTF-8?q?:sparkles:=20=E5=85=A8=E5=B1=80=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8A=E6=8A=A5=E5=BC=82=E5=B8=B8=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E6=8C=87=E5=AE=9A=E8=8A=82=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从小程序首页快捷格、打卡页底部按钮进入上报时不再要求选择节点,node_code / node_name 落空; 要指定环节只能从节点卡片进入(入口即已确定环节,节点名仍从过程配置回填、不信任请求体)。 未指明节点的异常无从定位阻断区间,按「阻断该运单全部未打卡节点」处理,含在途。 此前「异常必须归属节点」的校验与相应术语表述一并作废。 --- .../impl/ExceptionDisposalServiceImpl.java | 20 +++++++++---------- .../support/WaybillProcessSupport.java | 5 ++--- .../support/WaybillProcessSupportTest.java | 2 +- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 57c96783..65249bc3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -138,18 +138,18 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请关联运单后再上报"); } - // 异常必须归属到某个过程节点:没有节点的异常无从定位阻断区间,只能整单停摆。 - // 节点名取自过程配置而非请求体:上报页传什么不影响入库,避免落进一个该节点并不叫的名字。 + // 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节, + // 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。 + // 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。 String nodeCode = Func.toStr(request.getNodeCode(), "").trim(); - if (Func.isBlank(nodeCode)) { - throw new ServiceException("请选择异常节点"); + if (Func.isNotEmpty(nodeCode)) { + Map nodeCfg = driverWaybillService.findPunchNode(disposal.getWaybillId(), nodeCode); + if (nodeCfg == null) { + throw new ServiceException("异常节点不存在"); + } + disposal.setNodeCode(WaybillProcessSupport.nodeKey(nodeCfg)); + disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg)); } - Map nodeCfg = driverWaybillService.findPunchNode(disposal.getWaybillId(), nodeCode); - if (nodeCfg == null) { - throw new ServiceException("异常节点不存在"); - } - disposal.setNodeCode(WaybillProcessSupport.nodeKey(nodeCfg)); - disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg)); if (!save(disposal)) { throw new ServiceException("异常上报失败"); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 8e0291e8..2d685d0c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -469,9 +469,8 @@ public final class WaybillProcessSupport { if (punchNodes.isEmpty() || fromNodeCode == null) { return blocked; } - // 空串代表「未指明节点」,阻断全部可打卡节点。 - // 新上报已强制要求节点,此分支只为**存量数据**保留——去掉它,那些没有节点的 - // 未了结异常会突然不再阻断任何节点,等于悄悄放开了打卡。 + // 空串代表「未指明节点」——全局入口(首页快捷格、打卡页底部按钮)不指定环节, + // 这类异常无从定位区间,只能阻断全部可打卡节点。 if (Func.isEmpty(fromNodeCode)) { for (Map node : punchNodes) { blocked.add(nodeKey(node)); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index 2aa846c2..223a17ec 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -82,7 +82,7 @@ class WaybillProcessSupportTest { } @Test - @DisplayName("未指明节点的异常(存量数据)阻断全部打卡节点,含在途") + @DisplayName("未指明节点的异常(全局入口上报)阻断全部打卡节点,含在途") void exceptionWithoutNodeBlocksEverything() { Set blocked = WaybillProcessSupport.blockedNodeKeys(TRANSPORT_JSON, Arrays.asList("")); From bf37c478561ba3e24e92730eaffafffd661c30f3 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 6 Oct 2026 12:36:17 +0800 Subject: [PATCH 40/81] =?UTF-8?q?:sparkles:=20=E6=8E=A5=E5=8D=95=E5=90=8E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=B0=86=E6=8E=A5=E5=8D=95=E8=8A=82=E7=82=B9?= =?UTF-8?q?=E7=BD=AE=E4=B8=BA=E5=B7=B2=E6=89=93=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 管理端打卡时间轴上的「接单」节点此前恒显示未打卡:接单动作只写运单字段,不落打卡流水, 而时间轴的节点来源含接单/回单,没有流水即判为未打卡。 改为按运单的接单事实派生:司机确认接单即为已打卡,时间取接单时间;项目「无需确认接单」时, 运单越过接单阶段(进行中/已完成/已取消)即为已打卡,但没有完成时刻——运单上没有任何字段 记着那一刻,时间栏留空,不拿创建时间或更新时间编一个。 不写打卡流水的原因:接单环节有 5 个产生点(司机确认、免确认落库、复制运单、导入进行中、 配载单开始),其中两处是绕过 Java 逻辑的批量 SQL;接单事实另有 4 个清除点。写流水要在每处 挂点,漏一处就出现「待接单却已打卡」或「进行中却未打卡」;派生只需在 listPunchRecords 一处判定, 且历史运单立即正确,无需回填。打卡表 (waybill_id, node_code) 也没有唯一约束,幂等只能靠代码自保。 配套: - 接单节点不再允许补录或取消打卡:它由接单动作决定,人工订正只会造出一条与运单事实打架的 流水(时间轴显示已打卡、运单却还没人接单),故后端拒绝、前端不展示编辑入口 - 接单节点判据提为 WaybillProcessSupport.isAcceptNode(nodeCode, nodeName) 公开方法, 时间轴与补录校验共用同一份,不再各写一处硬编码 - 补 8 个时间轴用例(真接单带时间/待接单/已拒单/免确认进行中无时间/免确认草稿/免确认待执行/ 免确认已取消/普通节点不受影响)+ 1 个补录拒绝用例 --- .../service/impl/WaybillServiceImpl.java | 40 ++++ .../support/WaybillProcessSupport.java | 14 +- .../impl/WaybillPunchRecordListTest.java | 225 ++++++++++++++++++ .../impl/WaybillPunchRecordSaveTest.java | 17 ++ 4 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 3b4440c8..02878fae 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -458,6 +458,29 @@ public class WaybillServiceImpl extends BaseServiceImpl continue; } + // 接单节点不落打卡流水:它是否完成由运单的接单事实决定,不看打卡表。 + // 司机确认接单即为已打卡;项目「无需确认接单」时,运单进入进行中即视为该环节已完成。 + // 后者没有接单动作,也就没有完成时刻,时间栏留空——运单上没有任何字段记着那一刻。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + boolean accepted = WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus()); + boolean noConfirm = !WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson); + if (accepted) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(formatPunchTime(waybill.getDriverAcceptTime())); + } else if (noConfirm && passedAcceptStage(waybill.getBusinessStatus())) { + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(""); + } else { + item.setPunched(false); + item.setStatusName("未打卡"); + item.setPunchTime(""); + } + records.add(item); + continue; + } + WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName); if (punched != null) { List types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName); @@ -524,6 +547,11 @@ public class WaybillServiceImpl extends BaseServiceImpl if (Func.isEmpty(nodeCode) && Func.isEmpty(nodeName)) { throw new ServiceException("过程节点不能为空"); } + // 接单节点没有打卡流水:它是否完成由运单的接单事实决定,补录与取消都改不动它。 + // 放行只会造出一条与运单事实打架的流水——时间轴显示已打卡,运单却还没人接单。 + if (WaybillProcessSupport.isAcceptNode(nodeCode, nodeName)) { + throw new ServiceException("接单节点由接单动作决定,不支持补录或取消打卡"); + } List rows = nodePunchRows(waybillId, nodeCode, nodeName); if (!punched) { if (!rows.isEmpty()) { @@ -910,6 +938,18 @@ public class WaybillServiceImpl extends BaseServiceImpl return photo; } + /** + * 运单是否已越过接单阶段。 + *

+ * 正向列举进行中/已完成/已取消,不能用「不是终态」取反:终态里还包含草稿、待调度、 + * 调度中这些并未越过接单的阶段,取反会把它们判成已接单。 + */ + private static boolean passedAcceptStage(String businessStatus) { + return "running".equals(businessStatus) + || "completed".equals(businessStatus) + || "cancelled".equals(businessStatus); + } + private String formatPunchTime(Date time) { if (time == null) { return ""; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 2d685d0c..bdc9c0fb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -536,10 +536,18 @@ public final class WaybillProcessSupport { return "return".equals(key) || "回单".equals(name); } + /** + * 是否接单节点:key=accept 或 name=接单。 + *

+ * 管理端打卡时间轴与补录校验共用它——接单节点的完成由运单的接单事实决定, + * 不落打卡流水,两处的判据必须是同一个。 + */ + public static boolean isAcceptNode(String nodeCode, String nodeName) { + return "accept".equals(nodeCode) || "接单".equals(nodeName); + } + private static boolean isAcceptNode(Map node) { - String key = stringVal(node.get("key")); - String name = stringVal(node.get("name")); - return "accept".equals(key) || "接单".equals(name); + return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name"))); } private static boolean isTransitNode(Map node) { diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java new file mode 100644 index 00000000..c61dd1ec --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java @@ -0,0 +1,225 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO; + +import java.lang.reflect.Field; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 管理端打卡时间轴的接单节点判定测试。 + *

+ * 接单节点不落打卡流水,是否完成由运单的接单事实决定:司机确认接单即为已打卡; + * 项目「无需确认接单」时运单越过接单阶段即为已打卡,但没有完成时刻。 + * 这条口径若被改成「看打卡表」,时间轴上的接单会退回恒显示未打卡。 + */ +class WaybillPunchRecordListTest { + + private static final Long WAYBILL_ID = 6101L; + private static final String WAYBILL_NO = "YD202610060002"; + + /** 需确认接单:接单节点 + 一个普通打卡节点 */ + private static final String CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"yes\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + /** 无需确认接单 */ + private static final String NO_CONFIRM_ACCEPT_JSON = + "[{\"key\":\"accept\",\"name\":\"接单\",\"type\":\"confirm\",\"enabled\":true,\"punch\":true," + + "\"confirmMode\":\"no_confirm_accept\"}," + + "{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true}]"; + + private WaybillMapper waybillMapper; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private WaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + } + + @BeforeEach + void setUp() throws Exception { + waybillMapper = mock(WaybillMapper.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + service = new WaybillServiceImpl(); + inject("baseMapper", waybillMapper); + inject("waybillNodePunchMapper", nodePunchMapper); + inject("waybillEnroutePunchMapper", enroutePunchMapper); + } + + @Test + @DisplayName("司机确认接单:接单节点显示已打卡,时间取接单时间") + void acceptNodePunchedWhenDriverAccepted() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + waybill.setDriverAcceptTime(parseTime("2026-10-06 10:04:07")); + stubWaybill(waybill); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("2026-10-06 10:04:07", accept.getPunchTime()); + assertNull(accept.getId(), "派生记录没有打卡流水,不能带流水 id"); + } + + @Test + @DisplayName("待接单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenPending() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "pending")); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertFalse(accept.getPunched()); + assertEquals("未打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime()); + } + + @Test + @DisplayName("已拒单:接单节点仍是未打卡") + void acceptNodeUnpunchedWhenRejected() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "pending", "rejected")); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单且已进入进行中:接单节点已打卡,但没有完成时刻") + void acceptNodePunchedWithoutTimeWhenNoConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "running", null)); + + WaybillPunchRecordItemVO accept = acceptRecord(); + + assertTrue(accept.getPunched()); + assertEquals("已打卡", accept.getStatusName()); + assertEquals("", accept.getPunchTime(), "没有接单动作就没有完成时刻,不得拿别的字段编一个"); + } + + @Test + @DisplayName("无需确认接单但仍是草稿:接单节点未打卡") + void acceptNodeUnpunchedWhenDraft() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "draft", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("无需确认接单但停在待执行:接单节点未打卡") + void acceptNodeUnpunchedWhenPendingWithoutConfirm() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "pending", null)); + + assertFalse(acceptRecord().getPunched()); + } + + @Test + @DisplayName("已取消的无需确认接单运单:判为已打卡——状态本身不带是否曾进入进行中的历史") + void acceptNodePunchedWhenCancelled() { + stubWaybill(waybill(NO_CONFIRM_ACCEPT_JSON, "cancelled", null)); + + assertTrue(acceptRecord().getPunched()); + } + + @Test + @DisplayName("接单节点的派生不影响普通节点:有流水的普通节点照旧已打卡并带流水 id") + void otherNodesStillReadPunchTable() { + stubWaybill(waybill(CONFIRM_ACCEPT_JSON, "running", "accepted")); + WaybillNodePunch loadPunch = new WaybillNodePunch(); + loadPunch.setId(7001L); + loadPunch.setWaybillId(WAYBILL_ID); + loadPunch.setNodeCode("load"); + loadPunch.setNodeName("装货"); + loadPunch.setPunchTime(parseTime("2026-10-06 09:30:00")); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(loadPunch)); + + WaybillPunchRecordItemVO load = service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "load".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + assertTrue(load.getPunched()); + assertEquals("已打卡", load.getStatusName()); + assertEquals("2026-10-06 09:30:00", load.getPunchTime()); + assertEquals(7001L, load.getId()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubWaybill(Waybill waybill) { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + } + + private WaybillPunchRecordItemVO acceptRecord() { + return service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "accept".equals(record.getNodeCode())) + .findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有接单节点")); + } + + private Waybill waybill(String processJson, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo(WAYBILL_NO); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } + + private static Date parseTime(String text) { + try { + return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(text); + } catch (ParseException exception) { + throw new IllegalArgumentException(text, exception); + } + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java index e59ee890..0a50eee5 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -163,6 +163,23 @@ class WaybillPunchRecordSaveTest { verify(nodePunchMapper, never()).updateById(any(WaybillNodePunch.class)); } + @Test + @DisplayName("接单节点:补录与取消打卡都拒绝——它的完成由运单的接单事实决定") + void rejectAcceptNodePunchEdit() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + + WaybillPunchRecordSaveRequest punched = request("node", "accept", "接单"); + punched.setPunched(true); + assertThrows(ServiceException.class, () -> service.savePunchRecord(punched)); + + WaybillPunchRecordSaveRequest unpunched = request("node", "accept", "接单"); + unpunched.setPunched(false); + assertThrows(ServiceException.class, () -> service.savePunchRecord(unpunched)); + + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + verify(nodePunchMapper, never()).deleteBatchIds(any()); + } + // ------------------------------------------------------------ 在途打卡 @Test From 7e84b670b633fe0924c5a71f33a5f98c92ea3a83 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 12:16:22 +0800 Subject: [PATCH 41/81] =?UTF-8?q?:sparkles:=20=E8=8A=82=E7=82=B9=E5=BC=82?= =?UTF-8?q?=E5=B8=B8=E4=B8=8A=E6=8A=A5=E5=A2=9E=E5=8A=A0=E6=89=93=E5=8D=A1?= =?UTF-8?q?=E4=B8=8E=E9=87=8D=E5=A4=8D=E4=B8=8A=E6=8A=A5=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/IDriverWaybillService.java | 7 + .../impl/DriverWaybillServiceImpl.java | 14 + .../impl/ExceptionDisposalServiceImpl.java | 30 +- .../impl/DriverWaybillNodePunchTest.java | 111 ++++++ .../impl/ExceptionDisposalReportGateTest.java | 328 ++++++++++++++++++ 5 files changed, 489 insertions(+), 1 deletion(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index 30d7b5b8..a41d714a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -108,4 +108,11 @@ public interface IDriverWaybillService { */ Map findPunchNode(Long waybillId, String nodeCode); + /** + * 该运单的指定节点当前是否存在打卡流水;节点编码忽略大小写。 + *

+ * 以打卡流水为准而非运单当前节点:管理端取消打卡后这里要立刻回到未打卡。 + */ + boolean isNodePunched(Long waybillId, String nodeCode); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 40b11591..1f0e57af 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -805,6 +805,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); } + @Override + public boolean isNodePunched(Long waybillId, String nodeCode) { + if (waybillId == null || Func.isEmpty(nodeCode)) { + return false; + } + // 不复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置 key 比不出来 + String key = nodeCode.trim(); + return nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId)) + .stream() + .map(punch -> Func.toStr(punch.getNodeCode(), "").trim()) + .anyMatch(key::equalsIgnoreCase); + } + /** * 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 65249bc3..1d686126 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -147,7 +147,19 @@ public class ExceptionDisposalServiceImpl if (nodeCfg == null) { throw new ServiceException("异常节点不存在"); } - disposal.setNodeCode(WaybillProcessSupport.nodeKey(nodeCfg)); + String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + // 上报门禁之一:已打卡说明这个环节已经过去,事后才发现的问题走整单异常。 + // 在途例外——它可重复打卡、没有完成含义,打卡与否不说明环节是否已经过去。 + if (!WaybillProcessSupport.isTransitNodePublic(nodeCfg) + && driverWaybillService.isNodePunched(disposal.getWaybillId(), nodeKey)) { + throw new ServiceException("该节点已打卡,不能上报异常"); + } + // 上报门禁之二:同一节点同时至多一条未了结异常,处理中也占位。 + // 约束是同时性的:异常了结后该节点重新可以上报。 + if (hasUnresolvedNodeException(disposal.getWaybillId(), nodeKey)) { + throw new ServiceException("该节点已有未处理异常,处理完成后才能再次上报"); + } + disposal.setNodeCode(nodeKey); disposal.setNodeName(WaybillProcessSupport.nodeName(nodeCfg)); } @@ -157,6 +169,22 @@ public class ExceptionDisposalServiceImpl return toVO(disposal); } + /** + * 该运单的指定节点是否已有未了结(未达终态)的异常。 + *

+ * 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算, + * 「处理中」同样占位——它只是有人跟进过,问题可能仍在。 + * 节点编码忽略大小写:历史数据里的写法不一定与过程配置的 key 完全一致。 + */ + private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) { + List existing = baseMapper.selectList(Wrappers.lambdaQuery() + .eq(ExceptionDisposal::getWaybillId, waybillId) + .eq(ExceptionDisposal::getIsDeleted, 0)); + return existing.stream().anyMatch(item -> + nodeCode.equalsIgnoreCase(Func.toStr(item.getNodeCode(), "").trim()) + && !WaybillProcessSupport.isSettledDisposal(item.getDisposalStatus())); + } + /** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */ private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) { Waybill waybill = null; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java new file mode 100644 index 00000000..a045e054 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -0,0 +1,111 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 节点是否已打卡的判定。 + *

+ * 它读的是打卡流水表,而不是运单的当前节点:管理端取消打卡会把流水逻辑删除, + * 那时这里必须立刻回到未打卡,否则已取消的打卡仍会挡住异常上报。 + * 节点编码按忽略大小写比对——流水里的写法与过程配置的 key 不保证一致。 + */ +class DriverWaybillNodePunchTest { + + private static final Long WAYBILL_ID = 5201L; + + private WaybillNodePunchMapper nodePunchMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + } + + @BeforeEach + void setUp() { + nodePunchMapper = mock(WaybillNodePunchMapper.class); + service = new DriverWaybillServiceImpl( + mock(IWaybillService.class), + mock(IDriverService.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + mock(ExceptionDisposalMapper.class)); + } + + @Test + @DisplayName("没有该节点的打卡流水就是未打卡") + void withoutPunchRecordNodeIsNotPunched() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + + assertFalse(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("有该节点的打卡流水即已打卡") + void punchRecordMakesNodePunched() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("load"))); + + assertTrue(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("别的节点的打卡流水不算本节点已打卡") + void otherNodePunchDoesNotCount() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("arrive_scene"))); + + assertFalse(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("流水里的节点编码大小写与查询不一致时仍算同一节点") + void nodeCodeComparisonIgnoresCase() { + when(nodePunchMapper.selectList(any())).thenReturn(List.of(punch("LOAD"))); + + assertTrue(service.isNodePunched(WAYBILL_ID, "load")); + } + + @Test + @DisplayName("运单或节点缺失时判为未打卡,不抛异常") + void missingArgumentsMeanNotPunched() { + assertFalse(service.isNodePunched(null, "load")); + assertFalse(service.isNodePunched(WAYBILL_ID, " ")); + } + + private WaybillNodePunch punch(String nodeCode) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6201L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + return punch; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java new file mode 100644 index 00000000..658612a0 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -0,0 +1,328 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.cache.UserCache; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常上报门禁:已打卡的节点、已有未了结异常的节点都不能再上报。 + *

+ * 这里的口径决定「这个节点还能不能再报一条异常」,与打卡门禁是两回事: + * 一个管还能不能报,一个管还能不能打卡。两者共用同一份未了结判定, + * 但解除条件互不相同,所以断言分开下。 + */ +class ExceptionDisposalReportGateTest { + + private static final Long WAYBILL_ID = 5101L; + private static final Long USER_ID = 7001L; + + private ExceptionDisposalMapper disposalMapper; + private IWaybillService waybillService; + private IDriverWaybillService driverWaybillService; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + waybillService = mock(IWaybillService.class); + driverWaybillService = mock(IDriverWaybillService.class); + service = new ExceptionDisposalServiceImpl( + mock(ExceptionDisposalFollowRecordMapper.class), waybillService, driverWaybillService); + inject("baseMapper", disposalMapper); + + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); + when(disposalMapper.selectList(any())).thenReturn(List.of()); + when(disposalMapper.insert(any(ExceptionDisposal.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 已打卡 + + @Test + @DisplayName("已打卡的普通节点不能再上报异常") + void punchedNodeRejectsReport() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已打卡,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("在途已打卡仍可上报:它可重复打卡、没有完成含义,打卡与否不说明环节已经过去") + void punchedTransitNodeStillAcceptsReport() { + stubNode("transit", "在途", "transit"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "transit")).thenReturn(true); + + submitOk("transit"); + + assertEquals("transit", savedDisposal().getNodeCode()); + } + + @Test + @DisplayName("未打卡的普通节点照常可上报") + void unpunchedNodeAcceptsReport() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + + submitOk("load"); + + assertEquals("load", savedDisposal().getNodeCode()); + assertEquals("装货", savedDisposal().getNodeName()); + } + + // ------------------------------------------------------------ 已有未了结异常 + + @Test + @DisplayName("节点已有待处理异常时拒绝再报") + void pendingNodeExceptionRejectsReport() { + stubNode("load", "装货", "cargo"); + stubUnresolvedNodeException("load", "pending"); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + } + + @Test + @DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁") + void processingNodeExceptionRejectsReport() { + stubNode("load", "装货", "cargo"); + stubUnresolvedNodeException("load", "processing"); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + } + + @Test + @DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报") + void settledNodeExceptionAllowsReportAgain() { + stubNode("load", "装货", "cargo"); + when(disposalMapper.selectList(any())).thenReturn(List.of( + nodeException("load", "completed"), + nodeException("load", "revoked"))); + + submitOk("load"); + + assertEquals("load", savedDisposal().getNodeCode()); + } + + @Test + @DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上") + void otherNodesExceptionDoesNotBlockReport() { + stubNode("unload", "卸货", "cargo"); + when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("load", "pending"))); + + submitOk("unload"); + + assertEquals("unload", savedDisposal().getNodeCode()); + } + + @Test + @DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点") + void nodeCodeComparisonIgnoresCase() { + stubNode("load", "装货", "cargo"); + when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("LOAD", "pending"))); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + } + + @Test + @DisplayName("门禁拿过程配置里的节点 key 去比对,而不是请求体里那串写法") + void gateUsesNodeKeyResolvedFromProcessConfig() { + // 请求体写的是大写,过程配置里的 key 是小写;打卡流水按配置的 key 落库 + when(driverWaybillService.findPunchNode(WAYBILL_ID, "LOAD")).thenReturn(node("load", "装货", "cargo")); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true); + + ServiceException exception = submitExpectRejected("LOAD"); + + assertEquals("该节点已打卡,不能上报异常", exception.getMessage()); + } + + @Test + @DisplayName("未了结异常的查询按运单收敛,并排除已删除的行") + void unresolvedQueryIsScopedToWaybillAndSkipsDeletedRows() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + + submitOk("load"); + + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectList(captor.capture()); + String sql = captor.getValue().getTargetSql(); + + assertTrue(sql.contains("waybill_id"), + "未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql); + assertTrue(sql.contains("is_deleted"), + "已删除的异常不算未了结:" + sql); + } + + // ------------------------------------------------------------ 整单异常不受门禁 + + @Test + @DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报") + void waybillLevelReportIsNotGated() { + when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(null, "pending"))); + + submitOk(null); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).insert(captor.capture()); + assertNull(captor.getValue().getNodeCode(), "整单异常没有节点,不得凭空补一个"); + verify(driverWaybillService, never()).findPunchNode(any(), any()); + verify(driverWaybillService, never()).isNodePunched(any(), any()); + } + + @Test + @DisplayName("上报人取登录态,请求体里的上报人一律被忽略") + void reporterComesFromLoginState() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + ExceptionDisposal request = request("load"); + request.setReporterId(999999L); + request.setReporterName("冒充的人"); + + runSubmit(request); + + assertEquals(USER_ID, savedDisposal().getReporterId()); + assertEquals("韦司机", savedDisposal().getReporterName()); + } + + // ------------------------------------------------------------ 夹具 + + private void stubNode(String key, String name, String type) { + when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type)); + when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false); + } + + private void stubUnresolvedNodeException(String nodeCode, String status) { + when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(nodeCode, status))); + } + + private ServiceException submitExpectRejected(String nodeCode) { + ServiceException exception = assertThrows(ServiceException.class, () -> runSubmit(request(nodeCode))); + // 门禁在落库之前:被拒时库里不能多一行 + verify(disposalMapper, never()).insert(any(ExceptionDisposal.class)); + return exception; + } + + private void submitOk(String nodeCode) { + runSubmit(request(nodeCode)); + } + + private void runSubmit(ExceptionDisposal request) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class); + MockedStatic userCache = mockStatic(UserCache.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + userCache.when(() -> UserCache.getUserRealName(USER_ID)).thenReturn("韦司机"); + service.submitReport(request); + } + } + + private ExceptionDisposal savedDisposal() { + ArgumentCaptor captor = ArgumentCaptor.forClass(ExceptionDisposal.class); + verify(disposalMapper).insert(captor.capture()); + return captor.getValue(); + } + + private ExceptionDisposal request(String nodeCode) { + ExceptionDisposal request = new ExceptionDisposal(); + request.setWaybillId(WAYBILL_ID); + request.setExceptionType("cargo_damage"); + request.setReportDescription("装货时发现外包装破损"); + request.setNodeCode(nodeCode); + return request; + } + + private ExceptionDisposal nodeException(String nodeCode, String status) { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(6101L); + disposal.setWaybillId(WAYBILL_ID); + disposal.setNodeCode(nodeCode); + disposal.setDisposalStatus(status); + disposal.setIsDeleted(0); + disposal.setReportTime(LocalDateTime.of(2026, 10, 6, 9, 30)); + return disposal; + } + + private Map node(String key, String name, String type) { + Map cfg = new HashMap<>(); + cfg.put("key", key); + cfg.put("name", name); + cfg.put("type", type); + cfg.put("enabled", true); + cfg.put("punch", true); + return cfg; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setVehicleNo("桂AAW3255"); + waybill.setIsDeleted(0); + return waybill; + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} From 75cd448e2d3e73357548300dd1e9b9aeddfe2b7a Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 12:18:49 +0800 Subject: [PATCH 42/81] =?UTF-8?q?:sparkles:=20=E5=BC=82=E5=B8=B8=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E6=A0=A1=E9=AA=8C=E4=B8=8A=E6=8A=A5=E4=BA=BA=E5=BF=85?= =?UTF-8?q?=E9=A1=BB=E6=98=AF=E8=AF=A5=E8=BF=90=E5=8D=95=E7=9A=84=E6=89=BF?= =?UTF-8?q?=E8=BF=90=E5=8F=B8=E6=9C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/IDriverWaybillService.java | 8 + .../impl/DriverWaybillServiceImpl.java | 7 + .../impl/ExceptionDisposalServiceImpl.java | 5 + .../impl/DriverWaybillOwnershipTest.java | 172 ++++++++++++++++++ .../impl/ExceptionDisposalReportGateTest.java | 27 +++ 5 files changed, 219 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index a41d714a..e1735c5d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -115,4 +115,12 @@ public interface IDriverWaybillService { */ boolean isNodePunched(Long waybillId, String nodeCode); + /** + * 断言当前登录司机对该运单有归属:运单车牌须落在司机档案自述绑定的车牌内。 + *

+ * 无司机档案、未绑定车辆、车牌不匹配都会抛异常。管理端与调度员账号没有司机档案, + * 会停在「未找到当前登录司机档案」——这是刻意的,不提供代报。 + */ + void assertCurrentDriverOwnsWaybill(Long waybillId); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 1f0e57af..fc1c3297 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -819,6 +819,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .anyMatch(key::equalsIgnoreCase); } + @Override + public void assertCurrentDriverOwnsWaybill(Long waybillId) { + // 与司机打卡、司机接单共用同一套归属判定,不另立第二套规则 + Driver driver = requireCurrentDriver(); + loadDriverWaybill(waybillId, currentBoundPlates(driver)); + } + /** * 该运单「未处理完成的异常」所属节点 key(未达终态:已完成 / 已撤销)。 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 1d686126..a3d97307 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -138,6 +138,11 @@ public class ExceptionDisposalServiceImpl throw new ServiceException("请关联运单后再上报"); } + // 上报人只可能是该运单的承运司机本人,取登录态;请求体里的上报人一律忽略。 + // 归属按司机绑定车牌判定,与司机打卡、司机接单同一口径。 + // 整单异常同样受这条约束:没有节点不等于没有上报人。 + driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId()); + // 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节, // 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。 // 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。 diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java new file mode 100644 index 00000000..fa84fb62 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -0,0 +1,172 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.Collection; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 + *

+ * 口径只有一条:司机档案里自述绑定的车牌要与运单的主车或挂车车牌匹配。 + * 它不是「运单上的承运司机」——那两件事互不推导;也不是「谁提交了这份运单材料」。 + * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 + */ +class DriverWaybillOwnershipTest { + + private static final Long WAYBILL_ID = 5301L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + + private IDriverService driverService; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + } + + @Test + @DisplayName("登录账号没有司机档案时被拒:管理端与调度员不提供代报") + void withoutDriverProfileIsRejected() { + when(driverService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("未找到当前登录司机档案", exception.getMessage()); + verify(waybillService, never()).getOne(any()); + } + + @Test + @DisplayName("司机档案里没有绑定车辆时被拒") + void withoutBoundPlateIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(null)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("当前司机未绑定车辆", exception.getMessage()); + } + + @Test + @DisplayName("运单不在该司机绑定的车牌下时被拒") + void waybillOfAnotherPlateIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PLATE)); + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(WAYBILL_ID)); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("本车运单放行,且查询确实按司机绑定的车牌收敛") + void ownWaybillPasses() { + when(driverService.getOne(any())).thenReturn(driver(PLATE)); + when(waybillService.getOne(any())).thenReturn(waybill()); + + assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).getOne(captor.capture()); + LambdaQueryWrapper wrapper = captor.getValue(); + assertTrue(wrapper.getTargetSql().contains("vehicle_no"), + "归属查询必须落在运单车牌上:" + wrapper.getTargetSql()); + assertTrue(wrapper.getParamNameValuePairs().values().stream() + .anyMatch(value -> PLATE.equals(value) + || (value instanceof Collection values && values.contains(PLATE))), + "归属查询必须带上司机绑定的车牌:" + wrapper.getParamNameValuePairs()); + } + + @Test + @DisplayName("运单ID为空时被拒") + void withoutWaybillIdIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PLATE)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runOwnershipCheck(null)); + + assertEquals("运单ID不能为空", exception.getMessage()); + } + + // ------------------------------------------------------------ 夹具 + + private void runOwnershipCheck(Long waybillId) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + service.assertCurrentDriverOwnsWaybill(waybillId); + } + } + + private Driver driver(String drivingVehicle) { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDriverName("韦司机"); + driver.setDrivingVehicle(drivingVehicle); + return driver; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setVehicleNo(PLATE); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java index 658612a0..900b19ac 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -34,6 +34,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -234,6 +235,32 @@ class ExceptionDisposalReportGateTest { assertEquals("韦司机", savedDisposal().getReporterName()); } + // ------------------------------------------------------------ 上报人归属 + + @Test + @DisplayName("上报人不属于该运单时在落库前就被拒") + void reportIsRejectedWhenReporterDoesNotOwnWaybill() { + stubNode("load", "装货", "cargo"); + when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(false); + doThrow(new ServiceException("运单不存在或无权操作")) + .when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID); + + ServiceException exception = submitExpectRejected("load"); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("整单异常同样校验上报人:没有节点不等于没有上报人") + void waybillLevelReportAlsoChecksReporterOwnership() { + doThrow(new ServiceException("未找到当前登录司机档案")) + .when(driverWaybillService).assertCurrentDriverOwnsWaybill(WAYBILL_ID); + + ServiceException exception = submitExpectRejected(null); + + assertEquals("未找到当前登录司机档案", exception.getMessage()); + } + // ------------------------------------------------------------ 夹具 private void stubNode(String key, String name, String type) { From f9654fbe308a8283636f9bd8e77263a78d12bd32 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 12:23:02 +0800 Subject: [PATCH 43/81] =?UTF-8?q?:sparkles:=20=E8=8A=82=E7=82=B9=E5=8D=A1?= =?UTF-8?q?=E7=89=87=E4=B8=8B=E5=8F=91=E6=95=B4=E5=8D=95=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E6=A0=87=E8=AE=B0=E4=B8=8E=E7=9B=B8=E5=85=B3=E5=BC=82=E5=B8=B8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=EF=BC=8C=E5=88=97=E8=A1=A8=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=89=E8=BF=90=E5=8D=95=E7=B2=BE=E7=A1=AE=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/vo/DriverPunchNodeVO.java | 6 + .../impl/DriverWaybillServiceImpl.java | 18 ++ .../impl/ExceptionDisposalServiceImpl.java | 3 + .../support/WaybillProcessSupport.java | 39 +++ .../service/impl/DriverPunchNodeCardTest.java | 232 ++++++++++++++++++ .../impl/ExceptionDisposalListQueryTest.java | 115 +++++++++ .../support/WaybillProcessSupportTest.java | 60 +++++ 7 files changed, 473 insertions(+) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java index 337e2fd0..03f85510 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -54,6 +54,12 @@ public class DriverPunchNodeVO implements Serializable { @Schema(description = "该节点自身是否被上报了未了结异常(仅异常所在节点为是,其后被牵连阻断的节点为否)") private Boolean hasException; + @Schema(description = "该运单是否存在未了结的整单异常;整单异常不归属任何节点,故每个节点都带此标记") + private Boolean hasWaybillException; + + @Schema(description = "与该节点相关的未了结异常ID,按上报时间升序;相关 = 归属该节点 ∪ 整单异常") + private List relatedExceptionIds = new ArrayList<>(); + @Schema(description = "阻断该节点的异常处置ID(司机端「异常详情」跳转用);未被阻断或异常已不存在时为空") @JsonSerialize(using = ToStringSerializer.class) private Long blockedExceptionId; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index fc1c3297..c9138ac3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -1103,6 +1103,24 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } } + // 「整单异常」标签与相关异常 id:整单异常不归属任何节点(节点编码为空), + // 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。 + // 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点 + // 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。 + List unresolved = unresolvedExceptions(waybill.getId()); + List unresolvedCodes = unresolved.stream() + .map(item -> Func.toStr(item.getNodeCode(), "").trim()) + .collect(Collectors.toList()); + List unresolvedIds = unresolved.stream() + .map(ExceptionDisposal::getId) + .collect(Collectors.toList()); + boolean hasWaybillException = WaybillProcessSupport.hasWaybillLevelException(unresolvedCodes); + for (DriverPunchNodeVO vo : nodes) { + vo.setHasWaybillException(hasWaybillException); + vo.setRelatedExceptionIds( + WaybillProcessSupport.relatedExceptionIds(vo.getKey(), unresolvedCodes, unresolvedIds)); + } + for (DriverPunchNodeVO vo : nodes) { if (!Boolean.TRUE.equals(vo.getDone()) && !Boolean.TRUE.equals(vo.getBlocked()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index a3d97307..c56778a7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -385,6 +385,9 @@ public class ExceptionDisposalServiceImpl private LambdaQueryWrapper buildQuery(ExceptionDisposalVO query) { LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() .eq(ExceptionDisposal::getIsDeleted, 0) + // 按运单精确过滤,供司机端「本运单的异常」列表使用。 + // 不用运单号:它是模糊匹配,YD001 会命中 YD0011。 + .eq(query.getWaybillId() != null, ExceptionDisposal::getWaybillId, query.getWaybillId()) .like(Func.isNotEmpty(query.getWaybillNo()), ExceptionDisposal::getWaybillNo, query.getWaybillNo()) .like(Func.isNotEmpty(query.getVehicleNo()), ExceptionDisposal::getVehicleNo, query.getVehicleNo()) .like(Func.isNotEmpty(query.getReporterName()), ExceptionDisposal::getReporterName, query.getReporterName()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index bdc9c0fb..f890f5f7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -390,6 +390,45 @@ public final class WaybillProcessSupport { return DISPOSAL_COMPLETED.equalsIgnoreCase(status) || DISPOSAL_REVOKED.equalsIgnoreCase(status); } + /** + * 是否存在未了结的整单异常。 + *

+ * 整单异常不归属任何过程节点,它的节点编码为空;它作用于整张运单, + * 所以该运单每个可打卡节点都要挂上「整单异常」标记。 + */ + public static boolean hasWaybillLevelException(List unresolvedNodeCodes) { + if (unresolvedNodeCodes == null) { + return false; + } + return unresolvedNodeCodes.stream().anyMatch(code -> stringVal(code).isEmpty()); + } + + /** + * 与某节点相关的未了结异常 id,一律以字符串返回。 + *

+ * 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。 + * 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。 + *

+ * 顺序保持入参顺序,调用方按上报时间升序取出未了结异常即可。 + * id 转成字符串是因为主键是 int64,前端直收数字会丢精度。 + */ + public static List relatedExceptionIds(String nodeKey, List unresolvedNodeCodes, + List unresolvedIds) { + if (unresolvedNodeCodes == null || unresolvedIds == null) { + return Collections.emptyList(); + } + String key = stringVal(nodeKey); + List related = new ArrayList<>(); + int size = Math.min(unresolvedNodeCodes.size(), unresolvedIds.size()); + for (int i = 0; i < size; i++) { + String code = stringVal(unresolvedNodeCodes.get(i)); + if (code.isEmpty() || code.equalsIgnoreCase(key)) { + related.add(String.valueOf(unresolvedIds.get(i))); + } + } + return related; + } + /** * 过程配置里的在途节点 key;配置中无在途节点时返回空串。 */ diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java new file mode 100644 index 00000000..a3669df1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -0,0 +1,232 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverPunchNodeVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.time.LocalDateTime; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机端节点卡片上的异常标签与「相关异常」列表。 + *

+ * 两个字段回答两个不同的问题:这个节点上挂着哪些未了结异常、这张运单有没有整单异常。 + * 整单异常不归属任何节点,所以每个可打卡节点都挂它——包括已打卡的与在途的; + * 而某条节点异常只算它自己那个节点,被它牵连阻断的后续节点不算相关。 + */ +class DriverPunchNodeCardTest { + + private static final Long WAYBILL_ID = 5501L; + + /** 接单不参与打卡,故不出现在节点卡片里 */ + private static final String PROCESS_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private IWaybillService waybillService; + private WaybillNodePunchMapper nodePunchMapper; + private WaybillEnroutePunchMapper enroutePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper); + + Waybill waybill = waybill(); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(nodePunch("load"))); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("整单异常挂到每个可打卡节点,含已打卡的与在途的") + void waybillLevelExceptionIsTaggedOnEveryNode() { + stubUnresolved(nodeException("load", 11L), waybillException(12L)); + + Map nodes = punchNodes(); + + assertEquals(3, nodes.size(), "接单不是打卡节点,不该出现在卡片里"); + assertTrue(Boolean.TRUE.equals(nodes.get("load").getDone()), "装货这条流水已打卡"); + for (DriverPunchNodeVO node : nodes.values()) { + assertTrue(Boolean.TRUE.equals(node.getHasWaybillException()), + "整单异常作用于整张运单," + node.getKey() + " 也该挂标签"); + assertTrue(node.getRelatedExceptionIds().contains("12"), + node.getKey() + " 的相关异常里应当有整单异常"); + } + } + + @Test + @DisplayName("节点自己的异常只进它自己的相关列表,不牵连后续节点") + void nodeExceptionOnlyBelongsToItsOwnNode() { + stubUnresolved(nodeException("load", 11L)); + + Map nodes = punchNodes(); + + assertTrue(Boolean.TRUE.equals(nodes.get("load").getHasException())); + assertEquals(List.of("11"), nodes.get("load").getRelatedExceptionIds()); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasException()), + "签收只是被装货的异常牵连阻断,不挂「异常」标签"); + assertTrue(nodes.get("sign").getRelatedExceptionIds().isEmpty()); + assertFalse(Boolean.TRUE.equals(nodes.get("sign").getHasWaybillException())); + } + + @Test + @DisplayName("两种异常同时存在时,该节点的相关列表有两条且按上报时间升序") + void bothKindsAreListedInReportOrder() { + stubUnresolved(nodeException("load", 11L), waybillException(12L)); + + DriverPunchNodeVO load = punchNodes().get("load"); + + assertEquals(List.of("11", "12"), load.getRelatedExceptionIds()); + } + + @Test + @DisplayName("异常全部了结后两个字段都回到「无」") + void withoutUnresolvedExceptionsNoTagAndNoRelatedIds() { + Map nodes = punchNodes(); + + for (DriverPunchNodeVO node : nodes.values()) { + assertFalse(Boolean.TRUE.equals(node.getHasWaybillException())); + assertTrue(node.getRelatedExceptionIds().isEmpty()); + } + } + + @Test + @DisplayName("未了结异常按上报时间升序取出,卡片顺序才有意义") + void unresolvedExceptionsAreReadInReportOrder() { + stubUnresolved(nodeException("load", 11L)); + + punchNodes(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(exceptionDisposalMapper, atLeastOnce()).selectList(captor.capture()); + for (LambdaQueryWrapper wrapper : captor.getAllValues()) { + String sql = wrapper.getTargetSql(); + assertTrue(sql.contains("report_time"), "未了结异常要按上报时间取:" + sql); + assertTrue(sql.contains("disposal_status"), "未了结的判据要落在处置状态上:" + sql); + } + } + + @Test + @DisplayName("相关异常 id 序列化成字符串,前端拿到大整数不会丢精度") + void relatedExceptionIdsSerializeAsStrings() { + stubUnresolved(nodeException("load", 9007199254740993L)); + + String json = JsonUtil.toJson(punchNodes().get("load")); + + assertTrue(json.contains("\"9007199254740993\""), json); + } + + // ------------------------------------------------------------ 夹具 + + private void stubUnresolved(ExceptionDisposal... disposals) { + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(disposals)); + } + + private Map punchNodes() { + return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream() + .collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity())); + } + + private ExceptionDisposal nodeException(String nodeCode, Long id) { + return exception(id, nodeCode, LocalDateTime.of(2026, 10, 6, 9, 30)); + } + + private ExceptionDisposal waybillException(Long id) { + return exception(id, null, LocalDateTime.of(2026, 10, 6, 10, 0)); + } + + private ExceptionDisposal exception(Long id, String nodeCode, LocalDateTime reportTime) { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(id); + disposal.setWaybillId(WAYBILL_ID); + disposal.setNodeCode(nodeCode); + disposal.setDisposalStatus("pending"); + disposal.setIsDeleted(0); + disposal.setReportTime(reportTime); + return disposal; + } + + private WaybillNodePunch nodePunch(String nodeCode) { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6501L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode(nodeCode); + punch.setPunchTime(new Date()); + return punch; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java new file mode 100644 index 00000000..9d0dd4a1 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalListQueryTest.java @@ -0,0 +1,115 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.vo.ExceptionDisposalVO; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IWaybillService; + +import java.lang.reflect.Field; +import java.util.Collection; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 异常处置列表按运单过滤。 + *

+ * 过滤必须落在运单主键上:运单号是模糊匹配,{@code YD001} 会命中 {@code YD0011}, + * 从某个节点点进来的「本运单异常」会多出别人的记录。 + */ +class ExceptionDisposalListQueryTest { + + private static final Long WAYBILL_ID = 5401L; + + private ExceptionDisposalMapper disposalMapper; + private ExceptionDisposalServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() throws Exception { + disposalMapper = mock(ExceptionDisposalMapper.class); + service = new ExceptionDisposalServiceImpl( + mock(ExceptionDisposalFollowRecordMapper.class), + mock(IWaybillService.class), + mock(IDriverWaybillService.class)); + inject("baseMapper", disposalMapper); + when(disposalMapper.selectPage(any(), any())).thenReturn(new Page(1, 10)); + } + + @Test + @DisplayName("传运单ID时按主键精确过滤,而不是拿运单号模糊匹配") + void waybillIdFilterIsExact() { + ExceptionDisposalVO query = new ExceptionDisposalVO(); + query.setWaybillId(WAYBILL_ID); + + LambdaQueryWrapper wrapper = capturedQuery(query); + + assertTrue(wrapper.getTargetSql().contains("waybill_id ="), + "按运单过滤要用等值:" + wrapper.getTargetSql()); + assertTrue(wrapper.getParamNameValuePairs().values().stream() + .anyMatch(value -> WAYBILL_ID.equals(value) + || (value instanceof Collection values && values.contains(WAYBILL_ID))), + "过滤条件要带上运单ID:" + wrapper.getParamNameValuePairs()); + assertFalse(wrapper.getTargetSql().contains("waybill_no"), + "运单号是 like,会把前缀相同的运单一起捞回来:" + wrapper.getTargetSql()); + } + + @Test + @DisplayName("不传运单ID时不加运单条件,列表原有入口行为不变") + void withoutWaybillIdNoWaybillCondition() { + LambdaQueryWrapper wrapper = capturedQuery(new ExceptionDisposalVO()); + + assertFalse(wrapper.getTargetSql().contains("waybill_id"), wrapper.getTargetSql()); + } + + // ------------------------------------------------------------ 夹具 + + private LambdaQueryWrapper capturedQuery(ExceptionDisposalVO query) { + IPage page = service.selectPage(new Page<>(1, 10), query); + assertTrue(page.getRecords().isEmpty()); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectPage(any(), captor.capture()); + return captor.getValue(); + } + + private void inject(String field, Object value) throws Exception { + Class type = service.getClass(); + while (type != null) { + try { + Field declared = type.getDeclaredField(field); + declared.setAccessible(true); + declared.set(service, value); + return; + } catch (NoSuchFieldException ignored) { + type = type.getSuperclass(); + } + } + throw new IllegalStateException("未找到字段:" + field); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index 223a17ec..477f70ca 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -20,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; * 两边都调 {@link WaybillProcessSupport#blockedNodeKeys} 与 * {@link WaybillProcessSupport#unfinishedPrerequisiteKeys}, * 所以断言下在它们身上就覆盖了两条链路。 + *

+ * 同域还放着异常标签的组装规则:哪个节点该挂「整单异常」、哪些异常算与某节点相关。 */ class WaybillProcessSupportTest { @@ -129,4 +131,62 @@ class WaybillProcessSupportTest { "在途报的是「此刻在哪」,不该因为某个环节没打卡就报不了"); } + // ------------------------------------------------------------ 异常标签与相关异常 + + @Test + @DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点") + void relatedExceptionsCoverWaybillLevelForEveryNode() { + List nodeCodes = Arrays.asList("load", ""); + List ids = Arrays.asList(11L, 12L); + + assertEquals(Arrays.asList("11", "12"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids)); + assertEquals(Collections.singletonList("12"), WaybillProcessSupport.relatedExceptionIds("sign", nodeCodes, ids), + "装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事"); + } + + @Test + @DisplayName("相关异常 id 保持入参顺序,即上报时间升序") + void relatedExceptionIdsKeepReportOrder() { + List nodeCodes = Arrays.asList("", "load"); + List ids = Arrays.asList(21L, 22L); + + assertEquals(Arrays.asList("21", "22"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids)); + } + + @Test + @DisplayName("节点编码忽略大小写") + void relatedExceptionIdsIgnoreCase() { + assertEquals(Collections.singletonList("31"), + WaybillProcessSupport.relatedExceptionIds( + "load", Collections.singletonList("LOAD"), Collections.singletonList(31L))); + } + + @Test + @DisplayName("id 一律下发字符串:主键是 int64,前端直收数字会丢精度") + void relatedExceptionIdsAreStrings() { + List ids = WaybillProcessSupport.relatedExceptionIds( + "load", Collections.singletonList("load"), Collections.singletonList(9007199254740993L)); + + assertEquals(Collections.singletonList("9007199254740993"), ids); + } + + @Test + @DisplayName("整单异常的判定只看有没有不归属节点的异常") + void waybillLevelExceptionFlag() { + assertTrue(WaybillProcessSupport.hasWaybillLevelException(Arrays.asList("load", ""))); + assertTrue(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList(null))); + assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList("load"))); + assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList())); + assertFalse(WaybillProcessSupport.hasWaybillLevelException(null)); + } + + @Test + @DisplayName("异常全部了结后,整单标记与相关 id 都回到「无」") + void withoutUnresolvedExceptionsNothingIsTagged() { + assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList())); + assertTrue(WaybillProcessSupport + .relatedExceptionIds("load", Collections.emptyList(), Collections.emptyList()) + .isEmpty()); + } + } From 3d3ed0ca273e50f32bdee975c8e99e7fd50e3a15 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 13:24:02 +0800 Subject: [PATCH 44/81] =?UTF-8?q?:recycle:=20=E6=94=B6=E7=B4=A7=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E9=97=A8=E7=A6=81=E6=9F=A5=E8=AF=A2=E3=80=81=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=E5=B9=B3=E8=A1=8C=E5=88=97=E8=A1=A8=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=E8=8A=82=E7=82=B9=E7=BC=96=E7=A0=81?= =?UTF-8?q?=E6=AF=94=E8=BE=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 31 +++----- .../impl/ExceptionDisposalServiceImpl.java | 11 +-- .../support/WaybillProcessSupport.java | 45 +++++------ .../impl/ExceptionDisposalReportGateTest.java | 78 ++++++++++++------- .../support/WaybillProcessSupportTest.java | 65 ++++++---------- 5 files changed, 110 insertions(+), 120 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index c9138ac3..a2c9236c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -114,12 +114,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final DateTimeFormatter DATE_DOT_SHORT = DateTimeFormatter.ofPattern("MM.dd"); private static final DateTimeFormatter TIME_HM = DateTimeFormatter.ofPattern("HH:mm"); - /** - * 异常处置终态:已完成 / 已撤销。只有终态解除打卡门禁——「处理中」只表示有人 - * 跟进过,问题可能仍在,不放行。 - */ - private static final List SETTLED_EXCEPTION_STATUSES = Arrays.asList("completed", "revoked"); - /** 被异常阻断时的不可打卡原因(司机端展示) */ private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡"; @@ -811,17 +805,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return false; } // 不复用 latestNodePunchMap:它的 key 被 toLowerCase 过,与过程配置 key 比不出来 - String key = nodeCode.trim(); return nodePunchMapper.selectList(Wrappers.lambdaQuery() .eq(WaybillNodePunch::getWaybillId, waybillId)) .stream() - .map(punch -> Func.toStr(punch.getNodeCode(), "").trim()) - .anyMatch(key::equalsIgnoreCase); + .anyMatch(punch -> WaybillProcessSupport.sameNodeCode(nodeCode, punch.getNodeCode())); } @Override public void assertCurrentDriverOwnsWaybill(Long waybillId) { - // 与司机打卡、司机接单共用同一套归属判定,不另立第二套规则 + // 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定 Driver driver = requireCurrentDriver(); loadDriverWaybill(waybillId, currentBoundPlates(driver)); } @@ -864,7 +856,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return exceptionDisposalMapper.selectList(Wrappers.lambdaQuery() .eq(ExceptionDisposal::getWaybillId, waybillId) .eq(ExceptionDisposal::getIsDeleted, 0) - .notIn(ExceptionDisposal::getDisposalStatus, SETTLED_EXCEPTION_STATUSES) + .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES) .orderByAsc(ExceptionDisposal::getReportTime) .orderByAsc(ExceptionDisposal::getId)); } @@ -1107,18 +1099,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { // 它作用于整张运单,所以每个可打卡节点——包括已打卡的与在途的——都挂标签、都算相关。 // 相关只有两种来源:归属本节点的、以及整单的;被前序节点异常牵连阻断的节点 // 只是不能打卡,那条异常并不归属它,故不进入它的相关列表。 + // 未了结异常已按上报时间升序取出,过滤后仍保持该顺序;id 转字符串是因为主键是 int64, + // 前端直收数字会丢精度。 List unresolved = unresolvedExceptions(waybill.getId()); - List unresolvedCodes = unresolved.stream() - .map(item -> Func.toStr(item.getNodeCode(), "").trim()) - .collect(Collectors.toList()); - List unresolvedIds = unresolved.stream() - .map(ExceptionDisposal::getId) - .collect(Collectors.toList()); - boolean hasWaybillException = WaybillProcessSupport.hasWaybillLevelException(unresolvedCodes); + boolean hasWaybillException = unresolved.stream() + .anyMatch(item -> WaybillProcessSupport.isWaybillLevelException(item.getNodeCode())); for (DriverPunchNodeVO vo : nodes) { vo.setHasWaybillException(hasWaybillException); - vo.setRelatedExceptionIds( - WaybillProcessSupport.relatedExceptionIds(vo.getKey(), unresolvedCodes, unresolvedIds)); + vo.setRelatedExceptionIds(unresolved.stream() + .filter(item -> WaybillProcessSupport.relatesToNode(vo.getKey(), item.getNodeCode())) + .map(item -> String.valueOf(item.getId())) + .collect(Collectors.toList())); } for (DriverPunchNodeVO vo : nodes) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index c56778a7..5086aaf5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -54,6 +54,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.stream.Collectors; @@ -182,12 +183,12 @@ public class ExceptionDisposalServiceImpl * 节点编码忽略大小写:历史数据里的写法不一定与过程配置的 key 完全一致。 */ private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) { - List existing = baseMapper.selectList(Wrappers.lambdaQuery() + Long count = baseMapper.selectCount(Wrappers.lambdaQuery() .eq(ExceptionDisposal::getWaybillId, waybillId) - .eq(ExceptionDisposal::getIsDeleted, 0)); - return existing.stream().anyMatch(item -> - nodeCode.equalsIgnoreCase(Func.toStr(item.getNodeCode(), "").trim()) - && !WaybillProcessSupport.isSettledDisposal(item.getDisposalStatus())); + .eq(ExceptionDisposal::getIsDeleted, 0) + .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES) + .apply("LOWER(node_code) = {0}", nodeCode.toLowerCase(Locale.ROOT))); + return count != null && count > 0; } /** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */ diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index f890f5f7..dc0649d3 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -58,6 +58,8 @@ public final class WaybillProcessSupport { /** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */ private static final String DISPOSAL_COMPLETED = "completed"; private static final String DISPOSAL_REVOKED = "revoked"; + /** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */ + public static final List SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED); private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm"); private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm"); @@ -391,42 +393,31 @@ public final class WaybillProcessSupport { } /** - * 是否存在未了结的整单异常。 + * 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。 *

- * 整单异常不归属任何过程节点,它的节点编码为空;它作用于整张运单, - * 所以该运单每个可打卡节点都要挂上「整单异常」标记。 + * 空与空不算相等——节点编码为空表示整单,它不归属任何过程节点。 */ - public static boolean hasWaybillLevelException(List unresolvedNodeCodes) { - if (unresolvedNodeCodes == null) { - return false; - } - return unresolvedNodeCodes.stream().anyMatch(code -> stringVal(code).isEmpty()); + public static boolean sameNodeCode(String left, String right) { + String leftCode = stringVal(left); + String rightCode = stringVal(right); + return !leftCode.isEmpty() && leftCode.equalsIgnoreCase(rightCode); } /** - * 与某节点相关的未了结异常 id,一律以字符串返回。 + * 该异常是否为整单异常:异常不归属任何过程节点时,节点编码为空。 + */ + public static boolean isWaybillLevelException(String exceptionNodeCode) { + return stringVal(exceptionNodeCode).isEmpty(); + } + + /** + * 该异常是否与某节点相关。 *

* 相关只有两种来源:归属该节点的异常,以及整单异常(节点编码为空,对每个节点都算相关)。 * 被前序节点异常牵连阻断的节点不算相关——它自己并没有出过事。 - *

- * 顺序保持入参顺序,调用方按上报时间升序取出未了结异常即可。 - * id 转成字符串是因为主键是 int64,前端直收数字会丢精度。 */ - public static List relatedExceptionIds(String nodeKey, List unresolvedNodeCodes, - List unresolvedIds) { - if (unresolvedNodeCodes == null || unresolvedIds == null) { - return Collections.emptyList(); - } - String key = stringVal(nodeKey); - List related = new ArrayList<>(); - int size = Math.min(unresolvedNodeCodes.size(), unresolvedIds.size()); - for (int i = 0; i < size; i++) { - String code = stringVal(unresolvedNodeCodes.get(i)); - if (code.isEmpty() || code.equalsIgnoreCase(key)) { - related.add(String.valueOf(unresolvedIds.get(i))); - } - } - return related; + public static boolean relatesToNode(String nodeKey, String exceptionNodeCode) { + return isWaybillLevelException(exceptionNodeCode) || sameNodeCode(nodeKey, exceptionNodeCode); } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java index 900b19ac..6d0fee09 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -21,7 +21,8 @@ import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IWaybillService; import java.lang.reflect.Field; -import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -74,7 +75,7 @@ class ExceptionDisposalReportGateTest { inject("baseMapper", disposalMapper); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); - when(disposalMapper.selectList(any())).thenReturn(List.of()); + when(disposalMapper.selectCount(any())).thenReturn(0L); when(disposalMapper.insert(any(ExceptionDisposal.class))).thenReturn(1); } @@ -120,7 +121,7 @@ class ExceptionDisposalReportGateTest { @DisplayName("节点已有待处理异常时拒绝再报") void pendingNodeExceptionRejectsReport() { stubNode("load", "装货", "cargo"); - stubUnresolvedNodeException("load", "pending"); + stubUnresolvedCount(1L); ServiceException exception = submitExpectRejected("load"); @@ -131,46 +132,55 @@ class ExceptionDisposalReportGateTest { @DisplayName("节点异常已有人跟进(处理中)时同样拒绝:处理中不解除任何门禁") void processingNodeExceptionRejectsReport() { stubNode("load", "装货", "cargo"); - stubUnresolvedNodeException("load", "processing"); + stubUnresolvedCount(1L); ServiceException exception = submitExpectRejected("load"); assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + List params = capturedUnresolvedParams(); + assertTrue(params.contains("completed") && params.contains("revoked"), + "未了结 = 处置状态不在这两个终态里,故「处理中」也占位:" + params); } @Test @DisplayName("该节点的异常已完成、或被撤销后,节点重新可以上报") void settledNodeExceptionAllowsReportAgain() { stubNode("load", "装货", "cargo"); - when(disposalMapper.selectList(any())).thenReturn(List.of( - nodeException("load", "completed"), - nodeException("load", "revoked"))); + stubUnresolvedCount(0L); submitOk("load"); assertEquals("load", savedDisposal().getNodeCode()); + assertTrue(capturedUnresolvedParams().containsAll(List.of("completed", "revoked")), + "只有已完成、已撤销解除门禁,SQL 必须把它们排除在未了结之外"); } @Test @DisplayName("别的节点的未了结异常不占用本节点:约束只落在异常自己归属的节点上") void otherNodesExceptionDoesNotBlockReport() { stubNode("unload", "卸货", "cargo"); - when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("load", "pending"))); + stubUnresolvedCount(0L); submitOk("unload"); assertEquals("unload", savedDisposal().getNodeCode()); + assertTrue(capturedUnresolvedParams().contains("unload"), + "节点归属条件落在 SQL 上,查的是本次上报的节点:" + capturedUnresolvedParams()); } @Test @DisplayName("节点编码大小写不敏感:库里存 LOAD、过程配置叫 load,仍算同一节点") void nodeCodeComparisonIgnoresCase() { stubNode("load", "装货", "cargo"); - when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException("LOAD", "pending"))); + stubUnresolvedCount(1L); ServiceException exception = submitExpectRejected("load"); assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); + assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(node_code) ="), + "忽略大小写要落在 SQL 上:" + capturedUnresolvedQuery().getTargetSql()); + assertTrue(capturedUnresolvedParams().contains("load"), + "参数取节点 key 的小写形式:" + capturedUnresolvedParams()); } @Test @@ -193,15 +203,14 @@ class ExceptionDisposalReportGateTest { submitOk("load"); - ArgumentCaptor> captor = - ArgumentCaptor.forClass(LambdaQueryWrapper.class); - verify(disposalMapper).selectList(captor.capture()); - String sql = captor.getValue().getTargetSql(); + String sql = capturedUnresolvedQuery().getTargetSql(); assertTrue(sql.contains("waybill_id"), "未了结异常必须限在本运单内,否则别的运单的异常会挡住这次上报:" + sql); assertTrue(sql.contains("is_deleted"), "已删除的异常不算未了结:" + sql); + assertTrue(capturedUnresolvedParams().contains(WAYBILL_ID), + "运单条件取本次上报的运单:" + capturedUnresolvedParams()); } // ------------------------------------------------------------ 整单异常不受门禁 @@ -209,7 +218,7 @@ class ExceptionDisposalReportGateTest { @Test @DisplayName("不带节点的整单异常不受门禁约束,可有未了结整单异常仍继续上报") void waybillLevelReportIsNotGated() { - when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(null, "pending"))); + stubUnresolvedCount(1L); submitOk(null); @@ -218,6 +227,7 @@ class ExceptionDisposalReportGateTest { assertNull(captor.getValue().getNodeCode(), "整单异常没有节点,不得凭空补一个"); verify(driverWaybillService, never()).findPunchNode(any(), any()); verify(driverWaybillService, never()).isNodePunched(any(), any()); + verify(disposalMapper, never()).selectCount(any()); } @Test @@ -268,8 +278,33 @@ class ExceptionDisposalReportGateTest { when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false); } - private void stubUnresolvedNodeException(String nodeCode, String status) { - when(disposalMapper.selectList(any())).thenReturn(List.of(nodeException(nodeCode, status))); + private void stubUnresolvedCount(long count) { + when(disposalMapper.selectCount(any())).thenReturn(count); + } + + /** 上报门禁查未了结异常的那条 count 查询 */ + private LambdaQueryWrapper capturedUnresolvedQuery() { + ArgumentCaptor> captor = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(disposalMapper).selectCount(captor.capture()); + return captor.getValue(); + } + + /** 上述查询的参数值,集合型参数摊平成元素 */ + private List capturedUnresolvedParams() { + LambdaQueryWrapper query = capturedUnresolvedQuery(); + // MyBatis-Plus 的条件参数是惰性的:取过 SQL 片段之后 paramNameValuePairs 里才有值 + query.getTargetSql(); + Collection values = query.getParamNameValuePairs().values(); + List params = new ArrayList<>(); + for (Object value : values) { + if (value instanceof Collection nested) { + params.addAll(nested); + } else { + params.add(value); + } + } + return params; } private ServiceException submitExpectRejected(String nodeCode) { @@ -307,17 +342,6 @@ class ExceptionDisposalReportGateTest { return request; } - private ExceptionDisposal nodeException(String nodeCode, String status) { - ExceptionDisposal disposal = new ExceptionDisposal(); - disposal.setId(6101L); - disposal.setWaybillId(WAYBILL_ID); - disposal.setNodeCode(nodeCode); - disposal.setDisposalStatus(status); - disposal.setIsDeleted(0); - disposal.setReportTime(LocalDateTime.of(2026, 10, 6, 9, 30)); - return disposal; - } - private Map node(String key, String name, String type) { Map cfg = new HashMap<>(); cfg.put("key", key); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index 477f70ca..3584712d 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -135,58 +135,41 @@ class WaybillProcessSupportTest { @Test @DisplayName("整单异常对每个节点都算相关,节点自己的异常只算它自己那个节点") - void relatedExceptionsCoverWaybillLevelForEveryNode() { - List nodeCodes = Arrays.asList("load", ""); - List ids = Arrays.asList(11L, 12L); - - assertEquals(Arrays.asList("11", "12"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids)); - assertEquals(Collections.singletonList("12"), WaybillProcessSupport.relatedExceptionIds("sign", nodeCodes, ids), + void relatesToNodeCoversWaybillLevelForEveryNode() { + assertTrue(WaybillProcessSupport.relatesToNode("load", null), + "整单异常不归属任何节点,对每个节点都算相关"); + assertTrue(WaybillProcessSupport.relatesToNode("load", "")); + assertTrue(WaybillProcessSupport.relatesToNode("load", "load")); + assertFalse(WaybillProcessSupport.relatesToNode("sign", "load"), "装货的异常不归属签收——签收只是被它牵连阻断,不该显示成自己也出了事"); } @Test - @DisplayName("相关异常 id 保持入参顺序,即上报时间升序") - void relatedExceptionIdsKeepReportOrder() { - List nodeCodes = Arrays.asList("", "load"); - List ids = Arrays.asList(21L, 22L); - - assertEquals(Arrays.asList("21", "22"), WaybillProcessSupport.relatedExceptionIds("load", nodeCodes, ids)); + @DisplayName("节点编码忽略大小写,两侧空白不影响判定") + void relatesToNodeIgnoresCaseAndBlank() { + assertTrue(WaybillProcessSupport.relatesToNode("load", "LOAD")); + assertTrue(WaybillProcessSupport.relatesToNode(" load ", " LOAD ")); + assertFalse(WaybillProcessSupport.relatesToNode("load", "unload")); } @Test - @DisplayName("节点编码忽略大小写") - void relatedExceptionIdsIgnoreCase() { - assertEquals(Collections.singletonList("31"), - WaybillProcessSupport.relatedExceptionIds( - "load", Collections.singletonList("LOAD"), Collections.singletonList(31L))); - } - - @Test - @DisplayName("id 一律下发字符串:主键是 int64,前端直收数字会丢精度") - void relatedExceptionIdsAreStrings() { - List ids = WaybillProcessSupport.relatedExceptionIds( - "load", Collections.singletonList("load"), Collections.singletonList(9007199254740993L)); - - assertEquals(Collections.singletonList("9007199254740993"), ids); - } - - @Test - @DisplayName("整单异常的判定只看有没有不归属节点的异常") + @DisplayName("整单异常的判定只看节点编码是否为空") void waybillLevelExceptionFlag() { - assertTrue(WaybillProcessSupport.hasWaybillLevelException(Arrays.asList("load", ""))); - assertTrue(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList(null))); - assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.singletonList("load"))); - assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList())); - assertFalse(WaybillProcessSupport.hasWaybillLevelException(null)); + assertTrue(WaybillProcessSupport.isWaybillLevelException(null)); + assertTrue(WaybillProcessSupport.isWaybillLevelException("")); + assertTrue(WaybillProcessSupport.isWaybillLevelException(" ")); + assertFalse(WaybillProcessSupport.isWaybillLevelException("load")); } @Test - @DisplayName("异常全部了结后,整单标记与相关 id 都回到「无」") - void withoutUnresolvedExceptionsNothingIsTagged() { - assertFalse(WaybillProcessSupport.hasWaybillLevelException(Collections.emptyList())); - assertTrue(WaybillProcessSupport - .relatedExceptionIds("load", Collections.emptyList(), Collections.emptyList()) - .isEmpty()); + @DisplayName("节点编码相同判定:忽略大小写与两侧空白,空与空不算相等") + void sameNodeCodeIgnoresCaseAndBlank() { + assertTrue(WaybillProcessSupport.sameNodeCode("load", "LOAD")); + assertTrue(WaybillProcessSupport.sameNodeCode(" load ", "load")); + assertFalse(WaybillProcessSupport.sameNodeCode("load", "unload")); + assertFalse(WaybillProcessSupport.sameNodeCode("load", ""), "节点编码为空表示整单,不等于任何节点"); + assertFalse(WaybillProcessSupport.sameNodeCode("", ""), "空与空不算相等"); + assertFalse(WaybillProcessSupport.sameNodeCode(null, null)); } } From 10380632312a976e758189c68a165bca3c7681ce Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 13:33:07 +0800 Subject: [PATCH 45/81] =?UTF-8?q?:recycle:=20=E4=B8=8A=E6=8A=A5=E9=97=A8?= =?UTF-8?q?=E7=A6=81=E7=9A=84=E8=8A=82=E7=82=B9=E7=BC=96=E7=A0=81=E6=AF=94?= =?UTF-8?q?=E8=BE=83=E5=BF=BD=E7=95=A5=E9=A6=96=E5=B0=BE=E7=A9=BA=E7=99=BD?= =?UTF-8?q?=EF=BC=8C=E7=A7=BB=E9=99=A4=E5=A4=B1=E5=8E=BB=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E7=82=B9=E7=9A=84=E5=88=A4=E6=8D=AE=E6=96=B9=E6=B3=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/ExceptionDisposalServiceImpl.java | 4 ++-- .../transport/support/WaybillProcessSupport.java | 10 ---------- .../service/impl/ExceptionDisposalReportGateTest.java | 2 +- 3 files changed, 3 insertions(+), 13 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 5086aaf5..e8842af0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -180,14 +180,14 @@ public class ExceptionDisposalServiceImpl *

* 未了结的判据与打卡门禁同一份:已完成、已撤销之外的状态都算, * 「处理中」同样占位——它只是有人跟进过,问题可能仍在。 - * 节点编码忽略大小写:历史数据里的写法不一定与过程配置的 key 完全一致。 + * 节点编码忽略大小写与首尾空白:历史数据里的写法不一定与过程配置的 key 完全一致。 */ private boolean hasUnresolvedNodeException(Long waybillId, String nodeCode) { Long count = baseMapper.selectCount(Wrappers.lambdaQuery() .eq(ExceptionDisposal::getWaybillId, waybillId) .eq(ExceptionDisposal::getIsDeleted, 0) .notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES) - .apply("LOWER(node_code) = {0}", nodeCode.toLowerCase(Locale.ROOT))); + .apply("LOWER(TRIM(node_code)) = {0}", nodeCode.trim().toLowerCase(Locale.ROOT))); return count != null && count > 0; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index dc0649d3..7b69302b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -382,16 +382,6 @@ public final class WaybillProcessSupport { return Collections.emptySet(); } - /** - * 异常处置是否已了结(已完成 / 已撤销)。 - *

- * 只有终态解除打卡门禁:「处理中」只表示有人跟进过,问题可能仍未解决,不放行。 - */ - public static boolean isSettledDisposal(String disposalStatus) { - String status = stringVal(disposalStatus); - return DISPOSAL_COMPLETED.equalsIgnoreCase(status) || DISPOSAL_REVOKED.equalsIgnoreCase(status); - } - /** * 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。 *

diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java index 6d0fee09..36e5c086 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ExceptionDisposalReportGateTest.java @@ -177,7 +177,7 @@ class ExceptionDisposalReportGateTest { ServiceException exception = submitExpectRejected("load"); assertEquals("该节点已有未处理异常,处理完成后才能再次上报", exception.getMessage()); - assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(node_code) ="), + assertTrue(capturedUnresolvedQuery().getTargetSql().contains("LOWER(TRIM(node_code)) ="), "忽略大小写要落在 SQL 上:" + capturedUnresolvedQuery().getTargetSql()); assertTrue(capturedUnresolvedParams().contains("load"), "参数取节点 key 的小写形式:" + capturedUnresolvedParams()); From 2e5eae4d3288e9113a6a430db62d1e65af51e80e Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 16:49:19 +0800 Subject: [PATCH 46/81] =?UTF-8?q?:sparkles:=20=E6=89=93=E5=8D=A1=E7=85=A7?= =?UTF-8?q?=E7=89=87=E6=8C=89=E7=B1=BB=E5=9E=8B=E6=9C=80=E5=A4=9A=E4=BC=A0?= =?UTF-8?q?=209=20=E5=BC=A0=EF=BC=8C=E7=85=A7=E7=89=87=E5=88=97=E5=8A=A0?= =?UTF-8?q?=E5=AE=BD=E4=B8=BA=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 节点打卡提交新增 photoItems(带显式类型),photos 原样保留供旧版小程序按下标回推 - 打卡照片是每个打卡节点必有的类型,不由过程配置决定,与各凭证类型各自最多 9 张 - 新版提交要求每个类型至少 1 张;旧版仍只要求至少 1 张,避免打断未升级的小程序 - 管理端订正只校验每类上限,不校验必填 - photos 由 varchar(2000) 改 text:36 张约 4000 字符,旧列宽约 17 张即溢出 - 基线建表脚本补 COLLATE=utf8mb4_0900_ai_ci(与本机库实际排序规则一致) --- .../transport/pojo/dto/NodeSubmitDTO.java | 14 +- .../transport/pojo/dto/PunchPhotoDTO.java | 32 ++ .../impl/DriverWaybillServiceImpl.java | 126 +++++-- .../service/impl/WaybillServiceImpl.java | 35 +- .../support/WaybillProcessSupport.java | 21 ++ .../impl/DriverNodePunchPhotoTest.java | 337 ++++++++++++++++++ .../impl/WaybillPunchRecordSaveTest.java | 60 +++- .../blade_waybill_node_punch_20260911.sql | 4 +- ...aybill_node_punch_photos_text_20261007.sql | 10 + 9 files changed, 606 insertions(+), 33 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java create mode 100644 doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java index 6b16994f..0ba2fba4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java @@ -30,7 +30,19 @@ public class NodeSubmitDTO implements Serializable { @Schema(description = "定位信息") private Location location; - @Schema(description = "凭证照片 URL 列表") + /** + * 带类型的打卡照片,类型由提交方显式给出;非空时优先于 photos。 + */ + @Schema(description = "带类型的照片列表,非空时优先于 photos") + private List photoItems; + + /** + * 旧版小程序提交的裸 URL 列表,类型只能按下标回推过程配置的凭证类型。 + *

+ * 与 photoItems 并存是为了不强制升级小程序:新版提交 photoItems,旧版仍走这里; + * 等旧版本覆盖率达标后由后续改造删除。 + */ + @Schema(description = "凭证照片 URL 列表,类型按下标回推") private List photos; @Schema(description = "重量(吨)") diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java new file mode 100644 index 00000000..d7c4e20a --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/PunchPhotoDTO.java @@ -0,0 +1,32 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 带类型的打卡照片 + *

+ * 类型取值域是固定的「打卡照片」加该节点过程配置勾选的凭证类型,域外类型一律拒绝: + * 类型是照片分组的唯一依据,放进来路不明的类型会让回显分组错乱。 + */ +@Data +@Schema(description = "带类型的打卡照片") +public class PunchPhotoDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "照片类型") + private String type; + + @Schema(description = "图片URL") + private String url; + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index a2c9236c..d616bc17 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -38,6 +38,7 @@ import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.TransportVehicle; @@ -323,7 +324,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } assertNodeNotBlocked(processJson, waybill.getId(), WaybillProcessSupport.nodeKey(nodeCfg)); assertPrerequisitePunched(processJson, waybill.getId(), nodeCfg); - assertPunchPhoto(dto.getPhotos()); + // photoItems 是新版小程序带的类型化照片;photos 是旧版仍在提交的裸 URL 列表, + // 类型只能按下标回推过程配置的凭证类型。两者并存是为了不强制升级小程序。 + boolean typedPhotoItems = dto.getPhotoItems() != null && !dto.getPhotoItems().isEmpty(); + List> photoItems = typedPhotoItems + ? typedNodePunchPhotos(dto.getPhotoItems(), WaybillProcessSupport.nodePhotoTypes(nodeCfg)) + : legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes")); + assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems); Date now = new Date(); WaybillNodePunch punch = new WaybillNodePunch(); @@ -342,24 +349,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim()); } - if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) { - List urls = dto.getPhotos().stream() - .filter(Objects::nonNull) - .map(String::trim) - .filter(Func::isNotEmpty) - .collect(Collectors.toList()); - if (!urls.isEmpty()) { - List types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"); - List> photoItems = new ArrayList<>(); - for (int i = 0; i < urls.size(); i++) { - Map item = new LinkedHashMap<>(); - String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1)); - item.put("type", type); - item.put("url", urls.get(i)); - photoItems.add(item); - } - punch.setPhotos(JsonUtil.toJson(photoItems)); - } + if (!photoItems.isEmpty()) { + punch.setPhotos(JsonUtil.toJson(photoItems)); } punch.setWeight(trimOrNull(dto.getWeight())); punch.setVolume(trimOrNull(dto.getVolume())); @@ -916,9 +907,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 断言打卡照片已上传。 + * 断言打卡照片已上传(在途链路)。 *

- * 照片是打卡的必填项(每个节点至少 1 张),此处拦的是绕过客户端直接调接口的情况 + * 在途只有一张照片、不带类型,故只判有没有;此处拦的是绕过客户端直接调接口的情况 * ——前端的必填提示不构成约束。 */ private void assertPunchPhoto(List photos) { @@ -931,6 +922,99 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } } + /** + * 节点打卡的显式类型照片:类型由提交方给出,超出该节点取值域的直接拒绝。 + *

+ * 取值域是固定的「打卡照片」加过程配置勾选的凭证类型;类型是照片分组的唯一依据, + * 放进来路不明的类型会让回显分组错乱。 + */ + private List> typedNodePunchPhotos(List items, List allowedTypes) { + List> out = new ArrayList<>(); + for (PunchPhotoDTO item : items) { + if (item == null) { + continue; + } + String url = Func.toStr(item.getUrl(), "").trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = Func.toStr(item.getType(), "").trim(); + if (Func.isEmpty(type)) { + throw new ServiceException("照片类型不能为空"); + } + if (!allowedTypes.contains(type)) { + throw new ServiceException("照片类型「" + type + "」不属于该节点"); + } + out.add(nodePunchPhotoItem(type, url)); + } + return out; + } + + /** + * 节点打卡的旧版照片:裸 URL 列表,类型按下标取该节点的凭证类型,超出补「凭证N」。 + */ + private List> legacyNodePunchPhotos(List photos, List voucherTypes) { + List> out = new ArrayList<>(); + if (photos == null || photos.isEmpty()) { + return out; + } + List urls = photos.stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(Func::isNotEmpty) + .collect(Collectors.toList()); + for (int i = 0; i < urls.size(); i++) { + String type = i < voucherTypes.size() ? voucherTypes.get(i) : ("凭证" + (i + 1)); + out.add(nodePunchPhotoItem(type, urls.get(i))); + } + return out; + } + + private Map nodePunchPhotoItem(String type, String url) { + Map item = new LinkedHashMap<>(); + item.put("type", type); + item.put("url", url); + return item; + } + + /** + * 断言节点打卡照片满足类型口径。 + *

+ * 每个类型至多 9 张,按类型各自计数,不是节点合计。 + * 新版提交(photoItems)还需每个类型至少 1 张,含固定的「打卡照片」; + * 旧版只提交裸 URL 列表,类型按下标回推、表达不出「打卡照片」, + * 对它套用「每个类型至少 1 张」会让所有未升级的小程序提交失败,故旧版仍只要求至少有 1 张。 + */ + private void assertNodePunchPhotos(List> photoItems, Map nodeCfg, + boolean typedPhotoItems) { + Map countByType = countPunchPhotoByType(photoItems); + for (Map.Entry entry : countByType.entrySet()) { + if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) { + throw new ServiceException("「" + entry.getKey() + "」最多上传 " + + WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片"); + } + } + if (!typedPhotoItems) { + if (countByType.isEmpty()) { + throw new ServiceException("请上传打卡照片"); + } + return; + } + for (String type : WaybillProcessSupport.nodePhotoTypes(nodeCfg)) { + if (countByType.getOrDefault(type, 0) < 1) { + throw new ServiceException("「" + type + "」至少上传 1 张照片"); + } + } + } + + private Map countPunchPhotoByType(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + return countByType; + } + /** * 断言该运单没有未了结的异常。 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 02878fae..ce687574 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -582,7 +582,9 @@ public class WaybillServiceImpl extends BaseServiceImpl punch.setVolume(TransportBusinessSupport.trimToNull(request.getVolume())); punch.setQuantity(TransportBusinessSupport.trimToNull(request.getQuantity())); punch.setRemark(TransportBusinessSupport.trimToNull(request.getRemark())); - punch.setPhotos(encodePunchPhotos(request.getPhotos())); + List> photoItems = punchPhotoItems(request.getPhotos()); + assertPunchPhotoTypeLimit(photoItems); + punch.setPhotos(photoItems.isEmpty() ? null : JsonUtil.toJson(photoItems)); // 编辑不改 exception_flag:它与异常处置表的一致性由异常上报维护 if (isNew) { waybillNodePunchMapper.insert(punch); @@ -724,13 +726,13 @@ public class WaybillServiceImpl extends BaseServiceImpl } /** - * 打卡照片序列化:保留 [{type,url}] 结构,URL 为空的一律丢弃。 + * 打卡照片规范成 [{type,url}]:保留原结构,URL 为空的一律丢弃,类型为空按序号补「凭证N」。 */ - private String encodePunchPhotos(List photos) { - if (photos == null || photos.isEmpty()) { - return null; - } + private List> punchPhotoItems(List photos) { List> items = new ArrayList<>(); + if (photos == null || photos.isEmpty()) { + return items; + } for (WaybillPunchRecordSaveRequest.Photo photo : photos) { String url = photo == null ? "" : TransportBusinessSupport.trimToEmpty(photo.getUrl()); if (Func.isEmpty(url)) { @@ -742,7 +744,26 @@ public class WaybillServiceImpl extends BaseServiceImpl item.put("url", url); items.add(item); } - return items.isEmpty() ? null : JsonUtil.toJson(items); + return items; + } + + /** + * 断言打卡照片每种类型都不超过单类上限。 + *

+ * 上限按类型各自计数,不是节点合计:一个节点可以同时有「打卡照片」与多类凭证,各自最多 9 张。 + * 订正不校验必填——订正的是已经发生的事实,不是重新走一遍流程;必填只约束司机提交。 + */ + private void assertPunchPhotoTypeLimit(List> photoItems) { + Map countByType = new LinkedHashMap<>(); + for (Map item : photoItems) { + countByType.merge(item.get("type"), 1, Integer::sum); + } + for (Map.Entry entry : countByType.entrySet()) { + if (entry.getValue() > WaybillProcessSupport.MAX_PHOTOS_PER_TYPE) { + throw new ServiceException("「" + entry.getKey() + "」最多上传 " + + WaybillProcessSupport.MAX_PHOTOS_PER_TYPE + " 张照片"); + } + } } private String firstPunchPhotoUrl(List photos) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 7b69302b..7682948d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -60,6 +60,10 @@ public final class WaybillProcessSupport { private static final String DISPOSAL_REVOKED = "revoked"; /** 异常处置终态状态码集合:不在此集合内即未了结(processing 也在未了结之列) */ public static final List SETTLED_DISPOSAL_STATUSES = List.of(DISPOSAL_COMPLETED, DISPOSAL_REVOKED); + /** 每个打卡节点必有的照片类型,不由过程配置的凭证类型决定 */ + public static final String PUNCH_PHOTO_TYPE = "打卡照片"; + /** 单个照片类型的张数上限;打卡照片与每个凭证类型各自独立计数,不是节点合计 */ + public static final int MAX_PHOTOS_PER_TYPE = 9; private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm"); private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm"); @@ -309,6 +313,23 @@ public final class WaybillProcessSupport { return isTruthy(node.get("uploadVoucher")); } + /** + * 该节点的照片类型取值域:固定的「打卡照片」加过程配置勾选的凭证类型。 + *

+ * 打卡照片是每个打卡节点必有的照片类型,不由过程配置决定,与各凭证类型并列且互不替代。 + * 类型是照片分组的唯一依据,提交上来的类型必须落在这个取值域内,否则回显分组会错乱。 + */ + public static List nodePhotoTypes(Map node) { + List types = new ArrayList<>(); + types.add(PUNCH_PHOTO_TYPE); + for (String voucherType : nodeStringList(node, "voucherTypes")) { + if (!types.contains(voucherType)) { + types.add(voucherType); + } + } + return types; + } + @SuppressWarnings("unchecked") public static List nodeStringList(Map node, String field) { Object raw = node.get(field); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java new file mode 100644 index 00000000..d608a8ea --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -0,0 +1,337 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverPunchNodeVO; +import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 节点打卡照片的类型与张数口径。 + *

+ * 新版小程序按类型提交照片,类型由提交方给出;旧版仍提交裸 URL 列表,类型只能按下标回推凭证类型。 + * 两者并存期间,新版每个类型至少 1 张(含固定的「打卡照片」)、每类各自至多 9 张; + * 旧版必须照旧提交成功——用「每个类型至少 1 张」去拦旧版,等于让未升级的小程序一次性失效, + * 而旧版根本表达不出「打卡照片」。 + */ +class DriverNodePunchPhotoTest { + + private static final Long WAYBILL_ID = 5601L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; + private static final String WAYBILL_PHOTO = "https://oss/waybill.jpg"; + private static final String RECEIPT_PHOTO = "https://oss/receipt.jpg"; + /** 该节点勾了两类凭证,故照片类型取值域是「打卡照片 + 磅单 + 签收单」 */ + private static final String PROCESS_JSON = """ + [{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true, + "location":false,"uploadVoucher":true,"voucherTypes":["磅单","签收单"]}] + """; + + private IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + exceptionDisposalMapper); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.getOne(any())).thenReturn(waybill()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.updateById(any())).thenReturn(true); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + // ------------------------------------------------------------ 新版:显式类型 + + @Test + @DisplayName("显式类型按提交原样落库,「打卡照片」不会被改写成凭证类型") + void typedPhotoItemsKeepExplicitType() { + submit(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO)))); + + List> items = savedPhotoItems(); + assertEquals(List.of("打卡照片", "磅单", "签收单"), typesOf(items)); + assertEquals(List.of(CLOCK_PHOTO, WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items)); + } + + @Test + @DisplayName("域外类型被拒且不落库") + void typeOutsideNodeDomainIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO), + photo("卸货照片", "https://oss/unload.jpg")))); + + assertTrue(exception.getMessage().contains("卸货照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("缺少某个必填类型时被拒:每个类型至少 1 张") + void missingVoucherTypeIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("打卡照片", CLOCK_PHOTO), + photo("磅单", WAYBILL_PHOTO)))); + + assertTrue(exception.getMessage().contains("签收单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("缺少「打卡照片」时被拒:它不由过程配置决定,每个打卡节点都必须有") + void missingClockPhotoIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(List.of( + photo("磅单", WAYBILL_PHOTO), + photo("签收单", RECEIPT_PHOTO)))); + + assertTrue(exception.getMessage().contains("打卡照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("同一类型第 9 张可提交") + void ninthPhotoOfSameTypeIsAccepted() { + submit(nodeSubmit(typedPhotos("磅单", 9))); + + assertEquals(9, countByType(savedPhotoItems(), "磅单")); + } + + @Test + @DisplayName("同一类型第 10 张被拒且不落库:上限按类型各自计数") + void tenthPhotoOfSameTypeIsRejected() { + ServiceException exception = submitExpectRejected(nodeSubmit(typedPhotos("磅单", 10))); + + assertTrue(exception.getMessage().contains("磅单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 回显 + + @Test + @DisplayName("回显按类型原样透传:「打卡照片」不会被丢弃,也不会被改写成凭证类型") + void punchNodeDisplayKeepsEveryType() { + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of(storedPunch())); + + DriverPunchNodeVO node = service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream() + .filter(item -> "load".equals(item.getKey())) + .findFirst() + .orElseThrow(); + + assertEquals(List.of("打卡照片", "磅单"), + node.getPhotos().stream().map(DriverPunchPhotoVO::getType).toList()); + } + + // ------------------------------------------------------------ 旧版:裸 URL 列表 + + @Test + @DisplayName("旧版裸 URL 列表仍能提交成功,类型按下标回推凭证类型") + void legacyPhotoUrlsStillSubmit() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotos(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO)); + + submit(dto); + + List> items = savedPhotoItems(); + assertEquals(List.of("磅单", "签收单"), typesOf(items)); + assertEquals(List.of(WAYBILL_PHOTO, RECEIPT_PHOTO), urlsOf(items)); + } + + @Test + @DisplayName("旧版不带「打卡照片」也放行:裸 URL 列表表达不出这个类型") + void legacyPhotoUrlsWithoutClockPhotoStillSubmit() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotos(List.of(WAYBILL_PHOTO)); + + submit(dto); + + assertEquals(List.of("磅单"), typesOf(savedPhotoItems())); + } + + @Test + @DisplayName("旧版与新版都没带照片时被拒") + void withoutAnyPhotoIsRejected() { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + + ServiceException exception = submitExpectRejected(dto); + + assertTrue(exception.getMessage().contains("照片"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + // ------------------------------------------------------------ 夹具 + + private void submit(NodeSubmitDTO dto) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + service.submitNode(dto); + } + } + + private ServiceException submitExpectRejected(NodeSubmitDTO dto) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return assertThrows(ServiceException.class, () -> service.submitNode(dto)); + } + } + + /** 已入库的打卡流水;断言只在这里取一次插入参数 */ + private WaybillNodePunch savedPunch() { + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + return captor.getValue(); + } + + @SuppressWarnings("unchecked") + private List> savedPhotoItems() { + return (List>) JsonUtil.parse(savedPunch().getPhotos(), List.class); + } + + private List typesOf(List> items) { + return items.stream().map(item -> item.get("type")).toList(); + } + + private List urlsOf(List> items) { + return items.stream().map(item -> item.get("url")).toList(); + } + + private int countByType(List> items, String type) { + return (int) items.stream().filter(item -> type.equals(item.get("type"))).count(); + } + + /** 每个必填类型各 1 张,另有 count 张「磅单」 */ + private List typedPhotos(String type, int count) { + List items = new ArrayList<>(); + items.add(photo("打卡照片", CLOCK_PHOTO)); + items.add(photo("签收单", RECEIPT_PHOTO)); + for (int i = 0; i < count; i++) { + items.add(photo(type, "https://oss/" + type + i + ".jpg")); + } + return items; + } + + private NodeSubmitDTO nodeSubmit(List photoItems) { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode("load"); + dto.setPhotoItems(photoItems); + return dto; + } + + private PunchPhotoDTO photo(String type, String url) { + PunchPhotoDTO photo = new PunchPhotoDTO(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610070001"); + waybill.setVehicleNo(PLATE); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + + /** 库里的打卡流水:一条带「打卡照片」与「磅单」的回显数据 */ + private WaybillNodePunch storedPunch() { + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setId(6601L); + punch.setWaybillId(WAYBILL_ID); + punch.setNodeCode("load"); + punch.setNodeName("装货"); + punch.setPunchTime(new Date()); + punch.setPhotos(JsonUtil.toJson(List.of( + Map.of("type", "打卡照片", "url", CLOCK_PHOTO), + Map.of("type", "磅单", "url", WAYBILL_PHOTO)))); + return punch; + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java index 0a50eee5..6b41a1ef 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordSaveTest.java @@ -20,6 +20,7 @@ import org.springblade.transport.pojo.entity.WaybillEnroutePunch; import org.springblade.transport.pojo.entity.WaybillNodePunch; import java.lang.reflect.Field; +import java.util.ArrayList; import java.util.Collection; import java.util.HashSet; import java.util.List; @@ -41,8 +42,9 @@ import static org.mockito.Mockito.when; /** * 管理端打卡记录编辑测试。 *

- * 守住三件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来 - * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段。 + * 守住几件容易写坏的事:取消打卡必须清空该节点全部流水(只软删最新一条会让历史流水顶上来 + * 继续显示已打卡);编辑已有流水不得新建、也不得改写异常标记;在途打卡表只有单张照片字段; + * 订正的照片只限「每类各自最多 9 张」、不校验必填——订正的是已经发生的事实,不是重新走一遍流程。 */ class WaybillPunchRecordSaveTest { @@ -180,6 +182,52 @@ class WaybillPunchRecordSaveTest { verify(nodePunchMapper, never()).deleteBatchIds(any()); } + // ------------------------------------------------------------ 打卡照片张数 + + @Test + @DisplayName("同一类型第 9 张可保存:上限按类型各自计数") + void acceptNinthPhotoOfSameType() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(sameTypePhotos("委托单", 9)); + + assertTrue(service.savePunchRecord(request)); + } + + @Test + @DisplayName("同一类型第 10 张被拒且不落库") + void rejectTenthPhotoOfSameType() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(sameTypePhotos("委托单", 10)); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.savePunchRecord(request)); + + assertTrue(exception.getMessage().contains("委托单"), exception.getMessage()); + verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class)); + } + + @Test + @DisplayName("订正不校验照片必填:一张不传也能保存") + void missingPhotosDoNotBlockPunchRecordEdit() { + when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill()); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + WaybillPunchRecordSaveRequest request = request("node", "load", "装货"); + request.setPunched(true); + request.setPhotos(List.of()); + + assertTrue(service.savePunchRecord(request)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(WaybillNodePunch.class); + verify(nodePunchMapper).insert(captor.capture()); + assertNull(captor.getValue().getPhotos()); + } + // ------------------------------------------------------------ 在途打卡 @Test @@ -306,6 +354,14 @@ class WaybillPunchRecordSaveTest { return photo; } + private List sameTypePhotos(String type, int count) { + List photos = new ArrayList<>(); + for (int i = 0; i < count; i++) { + photos.add(photo(type, "https://oss/" + type + i + ".jpg")); + } + return photos; + } + private void inject(String field, Object value) throws Exception { Class type = service.getClass(); while (type != null) { diff --git a/doc/sql/transport/blade_waybill_node_punch_20260911.sql b/doc/sql/transport/blade_waybill_node_punch_20260911.sql index f595a23b..6ba2077c 100644 --- a/doc/sql/transport/blade_waybill_node_punch_20260911.sql +++ b/doc/sql/transport/blade_waybill_node_punch_20260911.sql @@ -11,7 +11,7 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` ( `longitude` decimal(12, 8) DEFAULT NULL COMMENT '经度', `latitude` decimal(12, 8) DEFAULT NULL COMMENT '纬度', `address` varchar(500) DEFAULT NULL COMMENT '打卡地址', - `photos` varchar(2000) DEFAULT NULL COMMENT '凭证照片URL,多张逗号分隔', + `photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张', `weight` varchar(32) DEFAULT NULL COMMENT '重量(吨)', `volume` varchar(32) DEFAULT NULL COMMENT '体积(方)', `quantity` varchar(32) DEFAULT NULL COMMENT '数量(件)', @@ -27,4 +27,4 @@ CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` ( PRIMARY KEY (`id`) USING BTREE, KEY `idx_node_punch_waybill` (`waybill_id`, `node_code`, `punch_time`) USING BTREE, KEY `idx_node_punch_driver` (`driver_id`) USING BTREE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单过程节点打卡记录'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运单过程节点打卡记录'; diff --git a/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql new file mode 100644 index 00000000..98a1673f --- /dev/null +++ b/doc/sql/transport/blade_waybill_node_punch_photos_text_20261007.sql @@ -0,0 +1,10 @@ +-- ---------------------------- +-- 节点打卡照片列加宽:varchar(2000) 改为 text +-- ---------------------------- +-- 一个节点可传「打卡照片 9 张 + 每个凭证类型各 9 张」,勾了 4 类凭证时共 45 张; +-- 库里实测每张照片含类型的 JSON 串约 111 字符,36 张约 4000 字符,已超 varchar(2000) 一倍。 +-- 超长时严格模式报 Data too long,非严格模式会静默截断成半个 JSON,后者更难查。 +-- 内容形状不变,仍是扁平 [{"type","url"}]。可重复执行。 + +ALTER TABLE `blade_waybill_node_punch` + MODIFY COLUMN `photos` text DEFAULT NULL COMMENT '照片 JSON 数组 [{type,url}],每类型至多 9 张'; From 7d8f2df56f8be72cff9acf7c74055fe189e81be6 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 18:21:29 +0800 Subject: [PATCH 47/81] =?UTF-8?q?:bug:=20=E5=AE=A1=E6=A0=B8=E6=9C=AA?= =?UTF-8?q?=E5=91=BD=E4=B8=AD=E7=9B=AE=E6=A0=87=E6=A1=A3=E6=A1=88=E6=97=B6?= =?UTF-8?q?=E6=95=B4=E4=BD=93=E5=9B=9E=E6=BB=9A=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E9=9D=99=E9=BB=98=E7=BD=AE=E4=B8=BA=E5=B7=B2=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核通过时对司机/车辆档案的 updateById 命中 0 行只返回 false、不抛异常,而这个返回值原先被丢弃,于是「认证记录已通过、档案一个字没变」能静默成立:生效快照指针没写进去,对象下次接单仍被判未认证,审核页却显示已通过。现在命中 0 行即抛异常并回滚整次审核,宁可报错也不留下状态与档案互相矛盾的数据。 --- .../service/impl/CertAuditServiceImpl.java | 23 ++++++++++++++-- .../impl/CertAuditServiceImplTest.java | 26 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) 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 07248769..4fe25a83 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 @@ -206,7 +206,7 @@ public class CertAuditServiceImpl implements ICertAuditService { // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 applySnapshotToVehicle(vehicle, record.getSnapshotJson()); } - transportVehicleService.updateById(vehicle); + requireTargetUpdated(transportVehicleService.updateById(vehicle), "车辆", record.getBizId()); return; } Driver driver = new Driver(); @@ -225,7 +225,26 @@ public class CertAuditServiceImpl implements ICertAuditService { // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 applySnapshotToDriver(driver, record.getSnapshotJson()); } - driverService.updateById(driver); + requireTargetUpdated(driverService.updateById(driver), "司机", record.getBizId()); + } + + /** + * 目标对象的写入必须命中一行,否则整体失败 + *

+ * {@code updateById} 命中 0 行时**不抛异常、只返回 false**。原先丢弃这个返回值, + * 于是「审核记录已通过、司机/车辆档案一个字没变」能静默成立:生效快照指针没写进去, + * 对象下次接单仍被判未认证,而审核页显示的是已通过。此处让事务整体回滚, + * 宁可报错也不留下状态与档案互相矛盾的数据。 + * + * @param updated 更新结果 + * @param target 对象名称,用于提示 + * @param id 对象主键 + */ + private void requireTargetUpdated(boolean updated, String target, Long id) { + if (!updated) { + throw new ServiceException("未能更新" + target + "档案(ID:" + id + + "),本次审核已回滚、未生效;请核对该对象是否存在、未被删除,且与认证记录属于同一租户"); + } } /** 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 93416743..503311a8 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 @@ -50,6 +50,9 @@ class CertAuditServiceImplTest { transportVehicleService = mock(ITransportVehicleService.class); driverService = mock(IDriverService.class); service = new CertAuditServiceImpl(certRecordService, certRecordMapper, transportVehicleService, driverService); + // 目标对象的写入默认成功;「写入一行都没命中」由专门的用例覆盖 + when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(true); + when(driverService.updateById(any(Driver.class))).thenReturn(true); } // ------------------------------------------------------------ 通过 @@ -174,6 +177,29 @@ class CertAuditServiceImplTest { "审核通过不得把司机的车辆绑定清空"); } + @Test + @DisplayName("通过:目标对象一行都没命中时必须整体失败,不得「记录已通过、档案没变」") + void approveFailsWhenVehicleNotUpdated() { + when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING)); + when(transportVehicleService.updateById(any(TransportVehicle.class))).thenReturn(false); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("未生效"), exception.getMessage()); + } + + @Test + @DisplayName("通过司机认证:司机档案没命中时同样必须整体失败,不得静默放行") + void approveFailsWhenDriverNotUpdated() { + when(certRecordService.getById(RECORD_ID)).thenReturn(driverRecord(TransportCertRecord.STATUS_PENDING)); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + when(driverService.updateById(any(Driver.class))).thenReturn(false); + + ServiceException exception = assertThrows(ServiceException.class, + () -> service.audit(RECORD_ID, true, null)); + assertTrue(exception.getMessage().contains("司机"), exception.getMessage()); + } + // ------------------------------------------------------------ 驳回 @Test From ac6318d174dc73b1f9b111560f272c310f61efce Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 18:21:38 +0800 Subject: [PATCH 48/81] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=AD=98?= =?UTF-8?q?=E9=87=8F=E5=8F=B8=E6=9C=BA/=E8=BD=A6=E8=BE=86=E8=A1=A5?= =?UTF-8?q?=E7=94=9F=E6=95=88=E5=BF=AB=E7=85=A7=E7=9A=84=E8=AE=A2=E6=AD=A3?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「能否接单」的判据是生效快照指针,该列随认证门槛一起新增时对存量对象一律为 NULL,门槛默认开启后存量司机与车辆全部被判未认证、接不了单。脚本给尚无快照的对象补一条已通过记录并把指针指过去,含备份、校验与回退;幂等,且能在已订正过的库上续跑(从段内最大号之后接着发号,不撞主键)。 --- ...blade_transport_cert_backfill_20261007.sql | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 doc/sql/transport/blade_transport_cert_backfill_20261007.sql diff --git a/doc/sql/transport/blade_transport_cert_backfill_20261007.sql b/doc/sql/transport/blade_transport_cert_backfill_20261007.sql new file mode 100644 index 00000000..91dd544e --- /dev/null +++ b/doc/sql/transport/blade_transport_cert_backfill_20261007.sql @@ -0,0 +1,277 @@ +-- ---------------------------- +-- 认证:存量司机 / 车辆补生效快照 +-- 写业务数据。执行前必须先建备份表(第 0 节),执行后必须跑第 3 节校验。可重复执行。 +-- ---------------------------- +-- +-- 为什么必须补: +-- 「能否接单」在运营状态之上叠加了认证门槛,判据是生效快照指针 +-- blade_transport_driver.effective_cert_record_id 与 +-- blade_transport_vehicle.effective_cert_record_id。这两列与门槛一起新增, +-- 新增时对存量对象一律为 NULL;门槛默认开启,于是存量司机与车辆全部被判成 +-- 「未认证」,接单被直接拒绝。它们在此之前一直正常承运,可用性是既成事实, +-- 不该因为换了判定方式就丢掉。 +-- +-- 处置口径:给尚无生效快照的存量对象补一条「已通过」记录,并把指针指过去。 +-- 这是自动认可、不是任何人的审核行为,故 audit_user / audit_time 一律留空, +-- 不凭空编造审核留痕。 +-- +-- 快照键名必须与 PersonCertSubmitRequest / VehicleCertSubmitRequest 完全一致。 +-- 键名对不上不会报错、只会静默丢值:司机端「重新认证」表单会缺字段, +-- 审核详情会把每个字段都报成「已变更」。改 DTO 字段名时务必同步本脚本。 +-- +-- 司机的从业资格证有效期起(qualificationValidStart)在快照里没有来源: +-- blade_transport_driver 没有对应列,管理端的同名录入项也是只收不存。 +-- 此处同样不造值,该键留空。 +-- +-- 司机快照里没有 mobile:审核列表的司机姓名/手机号取的是司机档案当前值, +-- 不是提交时的快照值,故无需写进快照。 +-- +-- 提交司机(submit_user)为什么一起补: +-- 审核列表的「司机姓名 / 司机手机号」取数路径是 submit_user -> 系统账号 -> 司机档案。 +-- submit_user 为空,这两列就永远空白,审核队列「找得到人、联系得上」的价值就没了。 +-- 车辆认证记录本身不含司机信息,其提交司机取「该车当前绑定的司机」—— +-- 车辆认证的提交链路强制校验只能操作本人绑定的车辆,故真实提交的车辆认证记录, +-- 其提交司机就是这辆车的绑定司机。一条车牌有多个绑定司机时 +-- (driving_vehicle 是逗号分隔的文本列、无唯一索引),取司机档案 update_time 最新者: +-- 库里没有绑定时间戳,这是可用的最好代理指标。 +-- 解析不到系统账号的(该司机根本没有 blade_user),留 NULL,不编造。 +-- +-- 号段:blade_transport_cert_record.id 由应用侧雪花算法发号(当前约 2.1e18), +-- 本脚本自行发号,必须落在应用永远到不了的区间,否则会与真实提交撞主键。 +-- 车辆与司机各占一段,段距 1e17,互不重叠,且都远低于 bigint 上限 9.22e18。 +-- 该号段同时是回退的识别依据(第 4 节)。 +-- ⚠️ 不要改成「取 MAX(id) + 偏移量」:雪花 id 每秒最多 +1000, +-- 取 MAX+1000000 的话应用约 17 分钟就能追平并开始撞号。 +-- ⚠️ 也不要把 id 读出来再拼 UPDATE 的 WHERE:id 约 2.1e18 超过 JS/JSON +-- 安全整数上限 2^53,传回数据库时末位被截断,UPDATE 会静默匹配 0 行—— +-- 记录插进去了、指针却没写,且不报任何错。故全程用 SQL 自关联 +-- (JOIN ... ON r.biz_id = v.id),id 只在库内流转。 +-- +-- 发号用会话变量而不是 ROW_NUMBER():窗口函数要 MySQL 8.0 起,本脚本按 5.7 也能执行写。 +-- +-- 前置条件:blade_transport_cert_record 表与两张业务表上的 +-- certification_status / certification_reject_reason / effective_cert_record_id +-- 列都已存在(见 blade_transport_cert_record_20261002.sql)。缺表时报 +-- 「Table ... doesn't exist」,先补跑那个脚本再执行本脚本。 + +-- ---------------------------- +-- 0. 备份(必须在订正之前执行,仅首次需要) +-- ---------------------------- +-- CREATE TABLE blade_transport_vehicle_certbak AS +-- SELECT id, certification_status, certification_reject_reason, effective_cert_record_id +-- FROM blade_transport_vehicle; +-- CREATE TABLE blade_transport_driver_certbak AS +-- SELECT id, certification_status, certification_reject_reason, effective_cert_record_id +-- FROM blade_transport_driver; +-- CREATE TABLE blade_transport_cert_record_bak AS SELECT * FROM blade_transport_cert_record; + +START TRANSACTION; + +-- ---------------------------- +-- 1. 为尚无生效快照的车辆补「已通过」记录 +-- ---------------------------- +-- 从段内已有的最大号之后接着发,而不是每次从段首发: +-- 订正过一次之后又导入了新车、再跑本脚本时,从段首发号会与已有记录撞主键。 +-- 变量存的是**段内序号**(段基址已减掉),下面 SELECT 里再加回段基址。 +SET @vehicle_seq := (SELECT IFNULL(MAX(id) - 9000000000000000000, 0) + FROM blade_transport_cert_record + WHERE id >= 9000000000000000000 AND id < 9100000000000000000); + +INSERT INTO blade_transport_cert_record + (id, tenant_id, biz_type, biz_id, biz_key, status, snapshot_json, + submit_time, submit_user, create_time, update_time, is_deleted) +SELECT 9000000000000000000 + (@vehicle_seq := @vehicle_seq + 1), + IFNULL(v.tenant_id, '000000'), 'vehicle', v.id, IFNULL(v.plate_no, ''), 1, + JSON_OBJECT( + 'plateNo', v.plate_no, + 'ownerName', v.owner_name, + 'plateColor', v.plate_color, + 'vehicleType', v.vehicle_type, + 'vin', v.vin, + 'outerLength', v.outer_length, + 'outerWidth', v.outer_width, + 'outerHeight', v.outer_height, + 'approvedLoadKg', v.approved_load_kg, + 'tractionMassKg', v.traction_mass_kg, + 'energyType', v.energy_type, + 'businessRelation', v.business_relation, + 'compulsoryScrapDate', v.compulsory_scrap_date, + 'compulsoryScrapLongTerm', IF(v.compulsory_scrap_long_term = 1, 1, 0), + 'drivingLicenseNo', v.driving_license_no, + 'drivingLicenseImage', v.driving_license_image, + 'drivingLicenseMainBack', v.driving_license_main_back, + 'drivingLicenseViceFront', v.driving_license_vice_front, + 'drivingLicenseViceBack', v.driving_license_vice_back, + 'drivingLicenseStartDate', v.driving_license_start_date, + 'drivingLicenseEndDate', v.driving_license_end_date, + 'drivingLicenseLongTerm', IF(v.driving_license_long_term = 1, 1, 0), + 'roadTransportCertNo', v.road_transport_cert_no, + 'roadTransportCertImage', v.road_transport_cert_image, + 'roadTransportCertStartDate', v.road_transport_cert_start_date, + 'roadTransportCertEndDate', v.road_transport_cert_end_date, + 'roadTransportCertLongTerm', IF(v.road_transport_cert_long_term = 1, 1, 0), + 'registrationNo', v.registration_no, + 'registrationImage', v.registration_image + ), + NOW(), + (SELECT dr.user_id + FROM blade_transport_driver dr + WHERE dr.is_deleted = 0 + AND dr.user_id IS NOT NULL + AND dr.driving_vehicle IS NOT NULL + AND FIND_IN_SET(v.plate_no, REPLACE(dr.driving_vehicle, ',', ',')) > 0 + ORDER BY dr.update_time DESC, dr.id DESC + LIMIT 1), + NOW(), NOW(), 0 +FROM blade_transport_vehicle v +LEFT JOIN blade_transport_cert_record r + ON r.biz_type = 'vehicle' AND r.biz_id = v.id + AND r.id >= 9000000000000000000 AND r.id < 9100000000000000000 +WHERE v.is_deleted = 0 + AND v.effective_cert_record_id IS NULL + AND r.id IS NULL +ORDER BY v.id; + +-- 回填指针:靠 biz_id 自关联,不把 id 带出数据库 +UPDATE blade_transport_vehicle v +JOIN blade_transport_cert_record r + ON r.biz_type = 'vehicle' AND r.biz_id = v.id + AND r.id >= 9000000000000000000 AND r.id < 9100000000000000000 AND r.status = 1 +SET v.effective_cert_record_id = r.id, + v.certification_status = 1, + v.certification_reject_reason = NULL +WHERE v.effective_cert_record_id IS NULL AND v.is_deleted = 0; + +-- ---------------------------- +-- 2. 为尚无生效快照的司机补「已通过」记录 +-- ---------------------------- +SET @driver_seq := (SELECT IFNULL(MAX(id) - 9100000000000000000, 0) + FROM blade_transport_cert_record + WHERE id >= 9100000000000000000 AND id < 9200000000000000000); + +INSERT INTO blade_transport_cert_record + (id, tenant_id, biz_type, biz_id, biz_key, status, snapshot_json, + submit_time, submit_user, create_time, update_time, is_deleted) +SELECT 9100000000000000000 + (@driver_seq := @driver_seq + 1), + IFNULL(d.tenant_id, '000000'), 'driver', d.id, IFNULL(d.driver_name, ''), 1, + JSON_OBJECT( + 'realName', d.driver_name, + 'idCardNo', d.id_card_no, + 'idCardFrontUrl', d.id_card_front, + 'idCardBackUrl', d.id_card_back, + 'idCardValidStart', d.id_card_valid_start, + 'idCardValidEnd', d.id_card_valid_end, + 'idCardLongTerm', IF(d.id_card_long_term = 1, 1, 0), + 'driverFileNo', d.driving_license_no, + 'driverClass', d.driving_type, + 'driverLicenseFrontUrl', d.driving_license_front, + 'driverLicenseBackUrl', d.driving_license_back, + 'driverValidStart', d.driving_license_start_date, + 'driverValidEnd', d.driving_license_end_date, + 'driverLongTerm', IF(d.driving_license_long_term = 1, 1, 0), + 'qualificationType', d.qualification_type, + 'qualificationNo', d.qualification_no, + 'qualificationFront', d.qualification_front, + 'qualificationBack', d.qualification_back, + 'qualificationValidEnd', d.qualification_end_date, + 'qualificationLongTerm', IF(d.qualification_long_term = 1, 1, 0), + 'headPhoto', d.head_photo + ), + NOW(), + -- 注册在司机档案上的用户 id 最直接;它为空时退回「按手机号找账号」 + -- (司机登录账号就是手机号)。两者都落空说明这名司机没有系统账号, + -- 此时留 NULL —— 审核列表的司机列本来就取不到人,编不出来。 + IFNULL(d.user_id, + (SELECT u.id + FROM blade_user u + WHERE u.account = d.mobile COLLATE utf8mb4_general_ci + ORDER BY u.id DESC + LIMIT 1)), + NOW(), NOW(), 0 +FROM blade_transport_driver d +LEFT JOIN blade_transport_cert_record r + ON r.biz_type = 'driver' AND r.biz_id = d.id + AND r.id >= 9100000000000000000 AND r.id < 9200000000000000000 +WHERE d.is_deleted = 0 + AND d.effective_cert_record_id IS NULL + AND r.id IS NULL +ORDER BY d.id; + +UPDATE blade_transport_driver d +JOIN blade_transport_cert_record r + ON r.biz_type = 'driver' AND r.biz_id = d.id + AND r.id >= 9100000000000000000 AND r.id < 9200000000000000000 AND r.status = 1 +SET d.effective_cert_record_id = r.id, + d.certification_status = 1, + d.certification_reject_reason = NULL +WHERE d.effective_cert_record_id IS NULL AND d.is_deleted = 0; + +COMMIT; + +-- ---------------------------- +-- 3. 校验(执行后必看,四个都必须返回 0) +-- ---------------------------- +-- 下列查询非 0 说明还有对象没有生效快照,门槛开着它们就不可用。 +-- SELECT COUNT(*) AS vehicle_missing FROM blade_transport_vehicle +-- WHERE effective_cert_record_id IS NULL AND is_deleted = 0; +-- SELECT COUNT(*) AS driver_missing FROM blade_transport_driver +-- WHERE effective_cert_record_id IS NULL AND is_deleted = 0; +-- +-- 下列查询非 0 说明指针指向的不是一条「已通过」记录,生效值取不到内容。 +-- SELECT COUNT(*) AS vehicle_bad_pointer +-- FROM blade_transport_vehicle v +-- LEFT JOIN blade_transport_cert_record r ON r.id = v.effective_cert_record_id +-- WHERE v.effective_cert_record_id IS NOT NULL AND (r.id IS NULL OR r.status <> 1); +-- SELECT COUNT(*) AS driver_bad_pointer +-- FROM blade_transport_driver d +-- LEFT JOIN blade_transport_cert_record r ON r.id = d.effective_cert_record_id +-- WHERE d.effective_cert_record_id IS NOT NULL AND (r.id IS NULL OR r.status <> 1); +-- +-- 快照字段完整性:确认关键字段没有因为键名写错而丢失。 +-- 司机快照必须含 driverClass(准驾车型),车辆快照必须含 drivingLicenseNo(行驶证档案编号)。 +-- SELECT COUNT(*) AS driver_snapshot_bad FROM blade_transport_cert_record +-- WHERE biz_type = 'driver' AND JSON_EXTRACT(snapshot_json, '$.driverClass') IS NULL; +-- SELECT COUNT(*) AS vehicle_snapshot_bad FROM blade_transport_cert_record +-- WHERE biz_type = 'vehicle' AND JSON_EXTRACT(snapshot_json, '$.drivingLicenseNo') IS NULL; +-- +-- 号段账目:号段内的记录数应等于本次订正的对象数(首次订正 = 车辆数 + 尚无快照的在用司机数), +-- 指针落在号段内的对象数也应等于它。两个数对不上说明有对象漏订正。 +-- SELECT COUNT(*) AS in_range_record FROM blade_transport_cert_record +-- WHERE id >= 9000000000000000000 AND id < 9200000000000000000; +-- SELECT COUNT(*) AS driver_backfilled FROM blade_transport_driver +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- SELECT COUNT(*) AS vehicle_backfilled FROM blade_transport_vehicle +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; + +-- ---------------------------- +-- 4. 回退 +-- ---------------------------- +-- 识别依据是 id 号段(>= 9.0e18),不是「备份表里没有的 id」: +-- 号段法不依赖备份表是否建过,也不会误删订正之后产生的真实提交记录。 +-- 回退前先把门槛开关置 false(transport.certification.driver-gate-enabled / +-- vehicle-gate-enabled),否则清掉指针的瞬间所有对象都不可用。 +-- 顺序:先清指针,再删记录;反了会在中间态留下悬空指针。 +-- START TRANSACTION; +-- UPDATE blade_transport_driver +-- SET effective_cert_record_id = NULL, certification_status = NULL, +-- certification_reject_reason = NULL +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- UPDATE blade_transport_vehicle +-- SET effective_cert_record_id = NULL, certification_status = 0, +-- certification_reject_reason = NULL +-- WHERE effective_cert_record_id >= 9000000000000000000 +-- AND effective_cert_record_id < 9200000000000000000; +-- DELETE FROM blade_transport_cert_record +-- WHERE id >= 9000000000000000000 AND id < 9200000000000000000; +-- COMMIT; +-- +-- 车辆 certification_status 还原为 0 是近似值:订正会把原本 0 与 1 的车都写成 1, +-- 原分布无法反推。这只影响页面上的状态标,不影响可用性(重跑订正会再次全部写成 1)。 +-- 要精确还原只能用订正前建好的 blade_transport_vehicle_certbak: +-- UPDATE blade_transport_vehicle v +-- JOIN blade_transport_vehicle_certbak b ON b.id = v.id +-- SET v.certification_status = b.certification_status, +-- v.certification_reject_reason = b.certification_reject_reason; From f3dc451802a473a5361d31647da2e7641879ed23 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 19:15:29 +0800 Subject: [PATCH 49/81] =?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=A7=93=E5=90=8D=E4=B8=8E?= =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E8=AF=81=E5=8F=B7=E4=B9=9F=E5=86=99=E5=9B=9E?= =?UTF-8?q?=E5=8F=B8=E6=9C=BA=E6=A1=A3=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审核通过时只写回证件内容,姓名与身份证号被当作「身份标识」跳过:司机申报「黄大富」,档案里却还是建档时的「黄宇富」,司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字。现在审核通过即认可整份申报内容,姓名与身份证号一并写回,档案与生效值一致。身份证号写回前先确认它没有落在另一名司机名下——该列没有唯一索引,直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效。 --- .../service/impl/CertAuditServiceImpl.java | 50 ++++++++++++++++--- .../service/impl/PersonCertRoundTripTest.java | 36 +++++++++++-- 2 files changed, 75 insertions(+), 11 deletions(-) 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 4fe25a83..9d1c07dc 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 @@ -27,6 +27,7 @@ package org.springblade.transport.service.impl; import com.alibaba.fastjson.JSON; 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; @@ -51,6 +52,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; +import java.util.Objects; /** * 认证审核服务实现 @@ -268,19 +270,21 @@ public class CertAuditServiceImpl implements ICertAuditService { /** * 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象) *

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

    - *
  • {@code realName} / {@code idCardNo}:身份标识, - * 由管理端建档时确定,司机端提交不覆盖(重复认证时会按证件号校验为同一人);
  • - *
  • {@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}: - * 内部管理归属与派工关系,由管理端维护,不该被司机的提交内容覆盖。
  • - *
+ * 搬**司机自己申报的全部实名内容**,姓名与身份证号也在其中:审核通过即认可这次申报, + * 档案必须与生效值一致;否则司机端卡片显示档案里的名字、「重新认证」表单回填快照里的名字, + * 同一个页面上出现两个名字。 + *

+ * 仍不碰 {@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}: + * 内部管理归属与派工关系由管理端维护,不该被司机的提交内容覆盖。 + *

* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过), * 因此不会把管理端填过、司机端没填的字段清空。 *

* 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**: * 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败; * 何况历史快照本来就存在,阻断会让这些对象再也审不过去。 + * 申报的身份证号已落在另一名司机名下时是例外——那会把两条档案写成同一个证件号, + * 必须阻断。 * * @param driver 待更新的司机 patch * @param snapshotJson 该条记录的全量提交内容 @@ -304,6 +308,11 @@ public class CertAuditServiceImpl implements ICertAuditService { if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) { return; } + // 身份证号要落到档案上,先确认它没有落在别人名下:该列没有唯一索引, + // 直接写会静默产生两条同证件号的档案,管理端的证件号查重从此失效 + requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId()); + driver.setDriverName(snapshot.getRealName()); + driver.setIdCardNo(snapshot.getIdCardNo()); driver.setIdCardFront(snapshot.getIdCardFrontUrl()); driver.setIdCardBack(snapshot.getIdCardBackUrl()); driver.setIdCardValidStart(snapshot.getIdCardValidStart()); @@ -325,6 +334,33 @@ public class CertAuditServiceImpl implements ICertAuditService { driver.setQualificationLongTerm(snapshot.getQualificationLongTerm()); } + /** + * 身份证号不得落在另一名司机名下 + *

+ * 司机申报的实名内容会写回档案,其中身份证号是管理端建档时的查重判据, + * 而 {@code blade_transport_driver.id_card_no} 上没有唯一索引:若申报的证件号 + * 已属于另一条档案,直接写会静默产生两条同证件号的档案,后续建档查重、 + * 按证件号识别同一人都将失效。此处阻断审核并整体回滚,交由管理端先处理档案归属。 + *

+ * 证件号与当前档案相同(绝大多数情况)、或库里尚无该号时不阻断; + * 快照没带证件号时无从判断,同样不阻断(历史快照就是这种)。 + * + * @param idCardNo 快照里的身份证号 + * @param driverId 本次要写入的司机主键 + */ + private void requireIdCardNotTaken(String idCardNo, Long driverId) { + if (Func.isEmpty(idCardNo)) { + return; + } + Driver owner = driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getIdCardNo, idCardNo) + .last("LIMIT 1")); + if (owner != null && !Objects.equals(owner.getId(), driverId)) { + throw new ServiceException("身份证号 " + idCardNo + " 已属于另一名司机档案(ID:" + owner.getId() + + "),本次审核已回滚、未生效;请先处理档案归属"); + } + } + /** * 取从业资格证封面页地址,兼容改名前的旧键 *

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 ca07ae88..14926434 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 @@ -4,6 +4,7 @@ import com.alibaba.fastjson2.JSON; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springblade.core.log.exception.ServiceException; import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.mapper.TransportCertRecordMapper; @@ -18,6 +19,8 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.Mockito.mock; @@ -217,6 +220,12 @@ class PersonCertRoundTripTest { * 这类只有真实更新策略才暴露的问题。 */ private void copyCertFields(Driver patch, Driver target) { + if (patch.getDriverName() != null) { + target.setDriverName(patch.getDriverName()); + } + if (patch.getIdCardNo() != null) { + target.setIdCardNo(patch.getIdCardNo()); + } if (patch.getIdCardFront() != null) { target.setIdCardFront(patch.getIdCardFront()); } @@ -292,19 +301,38 @@ class PersonCertRoundTripTest { } @Test - @DisplayName("审核通过:不覆盖姓名与身份证号(身份标识由管理端建档确定)") - void approveDoesNotOverwriteIdentity() { + @DisplayName("审核通过:姓名与身份证号跟着认证资料走,档案与生效值不再各说各话") + void approveOverwritesIdentity() { driver.setDriverName("张三(档案名)"); driver.setIdCardNo("110101199001011234"); Long recordId = driverCertService.submitPersonCert(personCert("张三改名", "110101199001011234")); certAuditService.audit(recordId, true, null); - assertEquals("张三(档案名)", driver.getDriverName(), - "姓名是建档时确定的身份字段,司机端提交不得把它改掉"); + // 审核通过即认可这次申报:档案里必须也是申报的姓名, + // 否则司机端卡片显示档案名、「重新认证」表单回填快照名,同一个页面上两个名字 + assertEquals("张三改名", driver.getDriverName()); assertEquals("110101199001011234", driver.getIdCardNo()); } + @Test + @DisplayName("审核通过:申报的身份证号已属于另一名司机时阻断审核,不得产生两条同号档案") + void approveRejectsIdCardTakenByAnotherDriver() { + Driver other = new Driver(); + other.setId(8888L); + other.setIdCardNo("110101199001011999"); + when(driverService.getOne(any())).thenReturn(other); + + Long recordId = driverCertService.submitPersonCert(personCert("张三", "110101199001011999")); + + ServiceException exception = assertThrows(ServiceException.class, + () -> certAuditService.audit(recordId, true, null)); + assertTrue(exception.getMessage().contains("另一名司机档案"), exception.getMessage()); + // 真实环境下这次审核由 @Transactional 整体回滚(记录仍是待审); + // 单测没有事务,故这里校验更直接的一点:拦截发生在写档案之前,别人的证件号没被写进来 + assertNull(driver.getIdCardNo(), "不得把属于别人的证件号写进当前档案"); + } + @Test @DisplayName("驳回:证件内容一个字都不写回档案") void rejectWritesNothingBack() { From 5496f487c74d2938410c4a2a91a086a6045ca0fd Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 19:30:31 +0800 Subject: [PATCH 50/81] =?UTF-8?q?:bug:=20=E8=BD=A6=E8=BE=86=E5=81=9C?= =?UTF-8?q?=E7=94=A8=E4=B8=8E=E6=9C=AA=E8=AE=A4=E8=AF=81=E5=88=86=E5=BC=80?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=EF=BC=8C=E4=B8=8D=E5=86=8D=E6=8A=8A=E5=81=9C?= =?UTF-8?q?=E7=94=A8=E6=8A=A5=E6=88=90=E3=80=8C=E8=AE=A4=E8=AF=81=E6=B2=A1?= =?UTF-8?q?=E9=80=9A=E8=BF=87=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 接单门槛对「车辆停用」和「车辆未认证」返回同一句「该车辆尚未通过认证审核,暂不可接单,请先提交认证」。生产上有一辆车已认证、只是被停用,司机照着提示做了两轮重新认证仍然接不了单——停用要找调度启用、认证才要提交资料,本是两件事。现在按运营状态优先逐车给出原因:停用报「该车辆已停用,请联系调度启用」,未认证才报认证。 --- .../impl/DriverWaybillServiceImpl.java | 15 ++++++--- .../transport/support/CertificationGate.java | 32 ++++++++++++------- .../support/CertificationGateTest.java | 22 ++++++++++--- 3 files changed, 48 insertions(+), 21 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index d616bc17..83987141 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -403,12 +403,17 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { LambdaQueryWrapper plateQuery = Wrappers.lambdaQuery(); VehiclePlateSupport.applyPlateMatch(plateQuery, currentBoundPlates(driver)); List boundVehicles = transportVehicleService.list(plateQuery); - boolean anyCertified = boundVehicles.stream().anyMatch(vehicle -> - certificationGate.isVehicleAvailable( + // 逐车取不可用原因:停用与未认证要分开报,否则「车已认证但被停用」 + // 会被报成「认证没通过」,把人引去做无用的重新认证 + String unavailableReason = boundVehicles.stream() + .map(vehicle -> certificationGate.vehicleUnavailableReason( vehicle.getStatus() != null && vehicle.getStatus() == BladeConstant.DB_STATUS_NORMAL, - vehicle.getEffectiveCertRecordId())); - if (!anyCertified) { - throw new ServiceException(certificationGate.vehicleUnavailableReason()); + vehicle.getEffectiveCertRecordId())) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + if (unavailableReason != null) { + throw new ServiceException(unavailableReason); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java index 2af6ae62..5a4ed782 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationGate.java @@ -76,13 +76,30 @@ public class CertificationGate { * @return 是否可用 */ public boolean isVehicleAvailable(Boolean enabledState, Long effectiveRecordId) { + return vehicleUnavailableReason(enabledState, effectiveRecordId) == null; + } + + /** + * 车辆不可用的原因 + *

+ * 停用与未认证是两件事,处置方式也不同:一个要找调度启用车辆,一个要去提交认证。 + * 合并成同一句话,会把「车已认证、只是被停用」误导成「认证没通过」, + * 让人白做一轮重新认证。 + * + * @param enabledState 运营状态:TRUE 启用 + * @param effectiveRecordId 生效快照指针 + * @return 不可用原因;可用时返回 null + */ + public String vehicleUnavailableReason(Boolean enabledState, Long effectiveRecordId) { if (!Boolean.TRUE.equals(enabledState)) { - return false; + return "该车辆已停用,暂不可接单,请联系调度启用"; } if (!vehicleGateEnabled) { - return true; + return null; } - return CertificationRuleSupport.isCertified(effectiveRecordId); + return CertificationRuleSupport.isCertified(effectiveRecordId) + ? null + : "该车辆尚未通过认证审核,暂不可接单,请先提交认证"; } /** @@ -98,15 +115,6 @@ public class CertificationGate { return CertificationRuleSupport.isCertified(effectiveRecordId); } - /** - * 车辆不可用的提示文案 - * - * @return 面向司机的中文提示 - */ - public String vehicleUnavailableReason() { - return "该车辆尚未通过认证审核,暂不可接单,请先提交认证"; - } - /** * 司机不可用的提示文案 * diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java index afa32c0f..8a9bbb0f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationGateTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -96,10 +97,23 @@ class CertificationGateTest { // ------------------------------------------------------------ 提示文案 @Test - @DisplayName("拒绝原因可读,且不泄露内部字段名") - void rejectReasonIsReadable() { - assertTrue(gate.vehicleUnavailableReason().contains("认证")); - assertFalse(gate.vehicleUnavailableReason().contains("effective_cert_record_id")); + @DisplayName("停用与未认证分开报:前者让人去启用车辆,后者才让人去认证") + void unavailableReasonsDistinguishDisabledFromUncertified() { + String disabled = gate.vehicleUnavailableReason(Boolean.FALSE, 7001L); + assertTrue(disabled.contains("停用"), disabled); + assertFalse(disabled.contains("认证审核"), "已认证的车被停用,不该报成认证没通过"); + + String uncertified = gate.vehicleUnavailableReason(Boolean.TRUE, null); + assertTrue(uncertified.contains("认证"), uncertified); + assertFalse(uncertified.contains("停用"), uncertified); + } + + @Test + @DisplayName("可用时没有原因;两种不可用文案都不泄露内部字段名") + void availableHasNoReasonAndTextIsReadable() { + assertNull(gate.vehicleUnavailableReason(Boolean.TRUE, 7001L)); + assertFalse(gate.vehicleUnavailableReason(Boolean.TRUE, null).contains("effective_cert_record_id")); + assertFalse(gate.vehicleUnavailableReason(Boolean.FALSE, null).contains("effective_cert_record_id")); assertTrue(gate.driverUnavailableReason().contains("认证")); } From 44197a4cffece82f28a97f8a272ab0cb900831d1 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Wed, 7 Oct 2026 23:00:28 +0800 Subject: [PATCH 51/81] =?UTF-8?q?:bug:=20=E5=AE=A1=E6=A0=B8=E9=80=9A?= =?UTF-8?q?=E8=BF=87=E4=B8=8E=E9=87=8D=E6=96=B0=E6=8F=90=E4=BA=A4=E6=97=B6?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E6=A1=A3=E6=A1=88=E4=B8=8A=E7=9A=84=E6=97=A7?= =?UTF-8?q?=E9=A9=B3=E5=9B=9E=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 司机/车辆档案上的 certification_reject_reason 是展示缓存,没有驳回原因时应为 NULL(建表即 DEFAULT NULL,数据订正脚本也写 NULL)。但框架的 not_null 更新策略会跳过 patch 里的 null 字段,把 null 交给 updateById 到不了 SQL,于是两处都留下了陈旧文案:审核通过后状态已是「已通过」,档案上却还挂着上一次的驳回原因;被驳回后重新提交,旧原因会一直挂到审核通过,而提交侧本就写明「新提交不能继承上一次的驳回文案」。现在这两条路径各显式发一条只改该列的语句,带主键条件,不碰生效快照指针、不碰车辆绑定,驳回时仍照常写入原因。改用 ALWAYS 更新策略会让改手机号、绑定车辆这类无关操作也清掉该列,故不做。 --- .../service/impl/CertAuditServiceImpl.java | 47 ++++++++-- .../service/impl/DriverCertServiceImpl.java | 13 +++ .../impl/CertAuditServiceImplTest.java | 86 +++++++++++++++++++ .../impl/DriverCertServiceImplTest.java | 43 ++++++++++ .../service/impl/PersonCertRoundTripTest.java | 13 +++ 5 files changed, 197 insertions(+), 5 deletions(-) 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 9d1c07dc..b7bb4e34 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 @@ -191,7 +191,6 @@ public class CertAuditServiceImpl implements ICertAuditService { private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) { boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType()); Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED; - String reason = pass ? null : CertificationRuleSupport.reasonForReject(rejectReason); // 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列 Long newEffectiveId = pass ? record.getId() : null; @@ -199,22 +198,25 @@ public class CertAuditServiceImpl implements ICertAuditService { TransportVehicle vehicle = new TransportVehicle(); vehicle.setId(record.getBizId()); vehicle.setCertificationStatus(status); - vehicle.setCertificationRejectReason(reason); if (pass) { vehicle.setEffectiveCertRecordId(newEffectiveId); // 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。 // 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值—— // 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。 - // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 + // 与指针合并成同一次 UPDATE。 applySnapshotToVehicle(vehicle, record.getSnapshotJson()); + } else { + vehicle.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } requireTargetUpdated(transportVehicleService.updateById(vehicle), "车辆", record.getBizId()); + if (pass) { + clearVehicleRejectReason(record.getBizId()); + } return; } Driver driver = new Driver(); driver.setId(record.getBizId()); driver.setCertificationStatus(status); - driver.setCertificationRejectReason(reason); // 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS, // 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL, // 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。 @@ -224,10 +226,45 @@ public class CertAuditServiceImpl implements ICertAuditService { // 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。 // 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空), // 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。 - // 与指针合并成同一次 UPDATE,不额外多写一次同一行。 + // 与指针合并成同一次 UPDATE。 applySnapshotToDriver(driver, record.getSnapshotJson()); + } else { + driver.setCertificationRejectReason(CertificationRuleSupport.reasonForReject(rejectReason)); } requireTargetUpdated(driverService.updateById(driver), "司机", record.getBizId()); + if (pass) { + clearDriverRejectReason(record.getBizId()); + } + } + + /** + * 清空车辆档案上的驳回原因展示缓存 + *

+ * 该列没有驳回原因时必须是 NULL(建表即 DEFAULT NULL)。但框架的 not_null 更新策略 + * 会跳过 patch 里的 null 字段,交给 updateById 的 null 到不了 SQL, + * 「已通过」的车辆会一直挂着上一次的驳回文案,故单独发一条只改该列的语句。 + * 它不碰生效快照指针。 + * + * @param vehicleId 车辆主键 + */ + private void clearVehicleRejectReason(Long vehicleId) { + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } + + /** + * 清空司机档案上的驳回原因展示缓存 + *

+ * 与车辆侧同理:null 交给 updateById 到不了 SQL,只能单独发一条只改该列的语句。 + * 它不碰生效快照指针,也不碰车辆绑定。 + * + * @param driverId 司机主键 + */ + private void clearDriverRejectReason(Long driverId) { + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); } /** 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 7246529d..f172a547 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 @@ -376,6 +376,13 @@ public class DriverCertServiceImpl implements IDriverCertService { patch.setDrivingVehicle(current.getDrivingVehicle()); } driverService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 重新提交时驳回原因应为空,但框架的 not_null 更新策略会跳过 patch 里的 null, + // 只写 null 到不了 SQL,旧驳回文案会一直挂到审核通过,故单独清一次 + driverService.update(Wrappers.lambdaUpdate() + .eq(Driver::getId, driverId) + .set(Driver::getCertificationRejectReason, null)); + } } /** @@ -389,6 +396,12 @@ public class DriverCertServiceImpl implements IDriverCertService { patch.setCertificationStatus(status); patch.setCertificationRejectReason(rejectReason); transportVehicleService.updateById(patch); + if (Func.isBlank(rejectReason)) { + // 与司机侧同理:null 交给 updateById 到不了 SQL,旧驳回文案会一直挂着 + transportVehicleService.update(Wrappers.lambdaUpdate() + .eq(TransportVehicle::getId, vehicleId) + .set(TransportVehicle::getCertificationRejectReason, null)); + } } /** 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 503311a8..e570361d 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 @@ -1,5 +1,11 @@ package org.springblade.transport.service.impl; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,7 +20,10 @@ import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.ITransportCertRecordService; import org.springblade.transport.service.ITransportVehicleService; +import java.util.Collection; + import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -43,6 +52,16 @@ class CertAuditServiceImplTest { private IDriverService driverService; private CertAuditServiceImpl service; + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + @BeforeEach void setUp() { certRecordService = mock(ITransportCertRecordService.class); @@ -330,6 +349,73 @@ class CertAuditServiceImplTest { assertNull(detail.getEffectiveRecordId()); } + // -------------------------------------------- 驳回原因的展示缓存 + + @Test + @DisplayName("通过车辆认证:显式清空档案上的驳回原因(not_null 策略会让 null 到不了 SQL)") + @SuppressWarnings("unchecked") + void approveClearsStaleVehicleRejectReason() { + 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(Wrapper.class); + verify(transportVehicleService).update(captor.capture()); + LambdaUpdateWrapper wrapper = + (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null(该列无原因时的取值)"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == VEHICLE_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + + @Test + @DisplayName("通过司机认证:同样显式清空驳回原因,且不顺带动车辆绑定") + @SuppressWarnings("unchecked") + void approveClearsStaleDriverRejectReason() { + TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(driverService.getById(DRIVER_ID)).thenReturn(driver(null)); + + service.audit(RECORD_ID, true, null); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "通过时必须显式清空驳回原因,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null)); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + assertFalse(wrapper.getSqlSet().contains("driving_vehicle"), + "清空驳回原因不得顺带动车辆绑定"); + } + + @Test + @DisplayName("驳回:原因去空白后写入档案,且不发清空语句") + void rejectWritesReasonAndDoesNotClear() { + TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING); + when(certRecordService.getById(RECORD_ID)).thenReturn(record); + when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L)); + + service.audit(RECORD_ID, false, " 行驶证模糊 "); + + ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class); + verify(transportVehicleService).updateById(captor.capture()); + assertEquals("行驶证模糊", captor.getValue().getCertificationRejectReason(), + "驳回原因去首尾空白后落库"); + verify(transportVehicleService, never()).update(any()); + } + // ------------------------------------------------------------ 辅助 private TransportCertRecord vehicleRecord(Integer status) { 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 43cac1ed..505f57a1 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 @@ -1,7 +1,13 @@ package org.springblade.transport.service.impl; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.alibaba.fastjson2.JSON; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -18,6 +24,7 @@ import org.springblade.transport.service.ITransportCertRecordService; import org.springblade.transport.service.ITransportVehicleService; import java.time.LocalDate; +import java.util.Collection; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -53,6 +60,16 @@ class DriverCertServiceImplTest { private ITransportCertRecordService certRecordService; private DriverCertServiceImpl service; + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, TransportVehicle.class); + } + @BeforeEach void setUp() { driverAppService = mock(IDriverAppService.class); @@ -279,6 +296,32 @@ class DriverCertServiceImplTest { "提交链路不得写生效快照指针"); } + @Test + @DisplayName("个人提交:清空上一次的驳回原因(null 交给 updateById 到不了 SQL)") + @SuppressWarnings("unchecked") + void submitPersonCertClearsStaleRejectReason() { + when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE)); + when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null); + + PersonCertSubmitRequest request = new PersonCertSubmitRequest(); + request.setRealName("张三"); + request.setIdCardNo("110101199001011234"); + + service.submitPersonCert(request); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(driverService).update(captor.capture()); + LambdaUpdateWrapper wrapper = (LambdaUpdateWrapper) captor.getValue(); + assertTrue(wrapper.getSqlSet().contains("certification_reject_reason"), + "重新提交时旧驳回原因必须清掉,实际: " + wrapper.getSqlSet()); + Collection values = wrapper.getParamNameValuePairs().values(); + assertTrue(values.contains(null), "清空必须显式写 null"); + assertTrue(wrapper.getSqlSegment().contains("id =") + && values.stream().anyMatch(v -> v instanceof Number + && ((Number) v).longValue() == DRIVER_ID), + "必须带主键条件且落在被审核的那一行,否则会更新整张表,实际: " + wrapper.getSqlSegment()); + } + @Test @DisplayName("个人提交:内容为空必须拒绝") void submitPersonCertRejectsEmpty() { 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 14926434..e4639e36 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 @@ -1,6 +1,10 @@ package org.springblade.transport.service.impl; import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -53,6 +57,15 @@ class PersonCertRoundTripTest { private Driver driver; private long idSequence = 100L; + @BeforeAll + static void initTableInfo() { + // 构造 LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Driver.class); + } + @BeforeEach void setUp() { recordStore.clear(); From f3ae784f200514d63204526f25f4a9daf0f3c80e Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 00:15:35 +0800 Subject: [PATCH 52/81] =?UTF-8?q?:bug:=20=E5=AF=BC=E5=85=A5=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E5=9C=B0=E7=82=B9=E5=90=8D=E7=A7=B0=E4=B8=BA=E7=A9=BA?= =?UTF-8?q?=E6=97=B6=E5=8F=B8=E6=9C=BA=E7=AB=AF=E5=9B=9E=E9=80=80=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E8=AF=A6=E7=BB=86=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 10 +- .../impl/DriverWaybillCardLocationTest.java | 136 ++++++++++++++++++ 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 83987141..72914b8b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -632,10 +632,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { DriverWaybillCardVO card = new DriverWaybillCardVO(); card.setId(waybill.getId()); card.setWaybillNo(waybill.getWaybillNo()); - card.setFromName(Func.toStr(waybill.getDepartureName(), "")); - card.setToName(Func.toStr(waybill.getArrivalName(), "")); - card.setFromAddress(Func.toStr(waybill.getDepartureAddress(), card.getFromName())); - card.setToAddress(Func.toStr(waybill.getArrivalAddress(), card.getToName())); + // 批量导入的运单只有发货/到货地址、没有地点名称,名称为空时回退到地址, + // 否则司机端首页、运单列表与打卡页的地点位置都是空的 + card.setFromName(Func.toStrWithEmpty(waybill.getDepartureName(), Func.toStr(waybill.getDepartureAddress(), ""))); + card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), ""))); + card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName())); + card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName())); card.setCargoNames(splitCargoNames(waybill.getCargoName())); card.setCargoCategory(Func.toStr(waybill.getCargoType(), "")); card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java new file mode 100644 index 00000000..92e98433 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -0,0 +1,136 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +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.when; + +/** + * 司机端卡片上的发货地 / 到货地。 + *

+ * 地点名称与详细地址是两列:批量导入的运单只填了地址(导入模板里没有「发货地」「收货地」列), + * 名称为空时卡片要回退到地址,否则司机端的地点位置是空的。 + */ +class DriverWaybillCardLocationTest { + + private static final Long WAYBILL_ID = 5601L; + + private static final String PROCESS_JSON = """ + [{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true}, + {"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}, + {"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + private static final String DEPARTURE_ADDRESS = "广西壮族自治区南宁市西乡塘区安吉大道"; + private static final String ARRIVAL_ADDRESS = "广西壮族自治区河池市金城江区金城中路1号"; + + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + WaybillNodePunchMapper nodePunchMapper = mock(WaybillNodePunchMapper.class); + WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + enroutePunchMapper, + nodePunchMapper, + exceptionDisposalMapper); + + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(enroutePunchMapper.selectOne(any())).thenReturn(null); + when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + } + + @Test + @DisplayName("地点名称为空(导入的运单)时,名称回退到详细地址") + void nameFallsBackToAddressWhenBlank() { + Waybill waybill = waybill(); + waybill.setDepartureName(null); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName(""); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals(DEPARTURE_ADDRESS, card.getFromName()); + assertEquals(ARRIVAL_ADDRESS, card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + assertEquals(DEPARTURE_ADDRESS, card.getPickupAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getUnloadAddress()); + } + + @Test + @DisplayName("地点名称有值时用它,不被地址覆盖") + void nameWinsWhenPresent() { + Waybill waybill = waybill(); + waybill.setDepartureName("南宁市西乡塘区"); + waybill.setDepartureAddress(DEPARTURE_ADDRESS); + waybill.setArrivalName("河池市金城江区"); + waybill.setArrivalAddress(ARRIVAL_ADDRESS); + when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); + + DriverWaybillCardVO card = service.detailPunchSnapshot(WAYBILL_ID); + + assertEquals("南宁市西乡塘区", card.getFromName()); + assertEquals("河池市金城江区", card.getToName()); + assertEquals(DEPARTURE_ADDRESS, card.getFromAddress()); + assertEquals(ARRIVAL_ADDRESS, card.getToAddress()); + } + + private Waybill waybill() { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD-20261007-0006"); + waybill.setProcessJson(PROCESS_JSON); + waybill.setBusinessStatus("running"); + waybill.setIsDeleted(0); + return waybill; + } + +} From 9f8ea5412d89f113033ed6ebb0e56027f0f61904 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 09:09:17 +0800 Subject: [PATCH 53/81] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E8=BF=87?= =?UTF-8?q?=E7=A8=8B=E5=88=A4=E5=AE=9A=E8=A1=A5=E5=85=85=E5=88=B0=E8=B4=A7?= =?UTF-8?q?=E5=8D=B8=E8=B4=A7=E7=AD=BE=E6=94=B6=E3=80=81=E6=9C=AB=E4=BD=8D?= =?UTF-8?q?=E6=89=93=E5=8D=A1=E4=B8=8E=E5=9B=9E=E5=8D=95=E6=97=A0=E9=9C=80?= =?UTF-8?q?=E7=A1=AE=E8=AE=A4=E5=AE=8C=E6=88=90=E4=B8=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../support/WaybillProcessSupport.java | 46 ++++++ .../support/WaybillProcessSupportTest.java | 140 ++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java index 7682948d..8de4c8b9 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -53,6 +53,7 @@ public final class WaybillProcessSupport { public static final String ACCEPT_REJECTED = "rejected"; private static final String CONFIRM_YES = "yes"; private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept"; + private static final String CONFIRM_NO_COMPLETE = "no_confirm_complete"; private static final String NODE_TRANSIT = "transit"; private static final String NODE_TRANSIT_NAME = "在途"; /** 异常处置终态:completed=已完成,revoked=已撤销。只有终态解除打卡门禁 */ @@ -244,6 +245,21 @@ public final class WaybillProcessSupport { return result; } + /** + * 本次打卡的节点是否是司机可打卡节点的末位。 + *

+ * 末位取「司机可打卡节点」序列:接单与回单不打卡,不参与排位—— + * 回单只要启用就必然排在配置最末,按全部启用节点取末位会永远取到回单。 + */ + public static boolean isLastDriverPunchNode(String processJson, String nodeCode) { + List> nodes = listDriverPunchNodes(processJson); + if (nodes.isEmpty()) { + return false; + } + String code = stringVal(nodeCode); + return !code.isEmpty() && code.equals(nodeKey(nodes.get(nodes.size() - 1))); + } + /** * 启用中的过程节点(按配置顺序,含接单/回单)。 */ @@ -591,6 +607,36 @@ public final class WaybillProcessSupport { return isAcceptNode(stringVal(node.get("key")), stringVal(node.get("name"))); } + /** + * 是否到货 / 卸货 / 签收节点:key 或中文名任一命中即算。 + */ + public static boolean isDeliveryNode(String nodeCode, String nodeName) { + String key = stringVal(nodeCode); + String name = stringVal(nodeName); + return "arrival".equals(key) || "到货".equals(name) + || "unload".equals(key) || "卸货".equals(name) + || "sign".equals(key) || "签收".equals(name); + } + + /** + * 回单是否无需确认完成:配置里没有回单节点、回单未启用,或启用且配置为「无需确认完成」。 + *

+ * 未启用的回单节点不看 confirmMode——没有回单环节就不会有人来确认; + * 启用但 confirmMode 缺失按「需确认完成」算。 + */ + public static boolean returnNeedsNoConfirmation(String processJson) { + for (Map node : parseProcessNodes(processJson)) { + if (!isReturnNode(node)) { + continue; + } + if (!isEnabled(node)) { + return true; + } + return CONFIRM_NO_COMPLETE.equals(stringVal(node.get("confirmMode"))); + } + return true; + } + private static boolean isTransitNode(Map node) { String key = stringVal(node.get("key")); String name = stringVal(node.get("name")); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java index 3584712d..fa3334c2 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/WaybillProcessSupportTest.java @@ -172,4 +172,144 @@ class WaybillProcessSupportTest { assertFalse(WaybillProcessSupport.sameNodeCode(null, null)); } + // ------------------------------------------------------------ 到货/卸货/签收 + + @Test + @DisplayName("到货/卸货/签收:key 或中文名任一命中即可,两者都为空不算") + void deliveryNodeMatchesKeyOrName() { + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isDeliveryNode("arrival", ""), "只有 key 也要认,别把中文名当必要条件"); + assertTrue(WaybillProcessSupport.isDeliveryNode("", "签收"), "只有中文名也要认——key 可能被改成自定义编码"); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货")); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途")); + assertFalse(WaybillProcessSupport.isDeliveryNode("", "")); + assertFalse(WaybillProcessSupport.isDeliveryNode(null, null)); + } + + // ------------------------------------------------------------ 末位司机打卡节点 + + /** 末位是「到货」,其后没有打卡节点 */ + private static final String ARRIVAL_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"arrival","name":"到货","enabled":true,"punch":true}] + """; + + /** 末位是「卸货」 */ + private static final String UNLOAD_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"unload","name":"卸货","enabled":true,"punch":true}] + """; + + /** 末位是「在途」 */ + private static final String TRANSIT_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true}] + """; + + /** 末位是「发货」 */ + private static final String DISPATCH_LAST_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"dispatch","name":"发货","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("末位打卡节点取「司机可打卡节点」的末位:接单与回单就算启用也不算") + void lastDriverPunchNodeSkipsAcceptAndReturn() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign"), + "回单启用在配置里排最末,但回单不打卡,末位打卡节点仍是签收"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "unload")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "arrival")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "return")); + } + + @Test + @DisplayName("末位是签收 / 到货 / 卸货才同时成立「末位」与「到货卸货签收」;末位是在途 / 发货只满足前者") + void lastNodeIsDeliveryOnlyForDeliveryTypes() { + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "sign") + && WaybillProcessSupport.isDeliveryNode("sign", "签收")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(ARRIVAL_LAST_JSON, "arrival") + && WaybillProcessSupport.isDeliveryNode("arrival", "到货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(UNLOAD_LAST_JSON, "unload") + && WaybillProcessSupport.isDeliveryNode("unload", "卸货")); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(TRANSIT_LAST_JSON, "transit"), + "在途排在末位时它确实是末位打卡节点"); + assertFalse(WaybillProcessSupport.isDeliveryNode("transit", "在途"), "在途不是可完成节点"); + assertTrue(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "dispatch")); + assertFalse(WaybillProcessSupport.isDeliveryNode("dispatch", "发货"), "发货不是可完成节点"); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(DISPATCH_LAST_JSON, "unload"), + "该配置里没有卸货节点,不能因为编码眼熟就认下"); + } + + @Test + @DisplayName("没有可打卡节点(或没有过程配置)时,谁都不是末位") + void lastDriverPunchNodeWithoutPunchNodes() { + String onlyAcceptAndReturn = """ + [{"key":"accept","name":"接单","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true}] + """; + + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "accept")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(onlyAcceptAndReturn, "return")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode("", "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(null, "sign")); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, null)); + assertFalse(WaybillProcessSupport.isLastDriverPunchNode(TRANSPORT_JSON, "")); + } + + // ------------------------------------------------------------ 回单是否无需确认完成 + + /** 回单启用且配置为「无需确认完成」 */ + private static final String RETURN_NO_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}] + """; + + /** 回单启用且配置为「需确认完成」 */ + private static final String RETURN_NEED_CONFIRM_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 回单未启用,但 confirmMode 写着「需确认完成」 */ + private static final String RETURN_DISABLED_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":false,"punch":true,"confirmMode":"yes"}] + """; + + /** 配置里根本没有回单节点 */ + private static final String NO_RETURN_JSON = """ + [{"key":"load","name":"装货","enabled":true,"punch":true}, + {"key":"sign","name":"签收","enabled":true,"punch":true}] + """; + + @Test + @DisplayName("回单启用时要看 confirmMode:只有「无需确认完成」才成立") + void enabledReturnNeedsConfirmMode() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NO_CONFIRM_JSON)); + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_NEED_CONFIRM_JSON), + "回单需要确认完成时,打完签收不能算整单完成"); + } + + @Test + @DisplayName("回单未启用、或配置里没有回单节点,一律视为无需确认完成") + void absentOrDisabledReturnNeedsNoConfirmation() { + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(RETURN_DISABLED_JSON), + "回单未启用时 confirmMode 写着需确认也不算数:没启用的环节不会有人来确认"); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(NO_RETURN_JSON)); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation("")); + assertTrue(WaybillProcessSupport.returnNeedsNoConfirmation(null)); + } + + @Test + @DisplayName("回单启用但 confirmMode 缺失时按「需确认完成」算") + void enabledReturnWithoutConfirmModeNeedsConfirmation() { + assertFalse(WaybillProcessSupport.returnNeedsNoConfirmation(TRANSPORT_JSON), + "TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单"); + } + } From 7c488b6f09030ec728b265f71ee49eae98d6bc96 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 09:24:30 +0800 Subject: [PATCH 54/81] =?UTF-8?q?:sparkles:=20=E5=8F=B8=E6=9C=BA=E6=89=93?= =?UTF-8?q?=E5=AE=8C=E6=9C=AB=E4=BD=8D=E5=88=B0=E8=B4=A7=E5=8D=B8=E8=B4=A7?= =?UTF-8?q?=E7=AD=BE=E6=94=B6=E8=8A=82=E7=82=B9=E4=B8=94=E5=9B=9E=E5=8D=95?= =?UTF-8?q?=E6=97=A0=E9=9C=80=E7=A1=AE=E8=AE=A4=E6=97=B6=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=BF=90=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/vo/DriverNodePunchVO.java | 3 + .../impl/DriverWaybillServiceImpl.java | 29 +- .../impl/DriverWaybillAutoCompleteTest.java | 248 ++++++++++++++++++ 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java index 4bca6317..691cb573 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java @@ -49,4 +49,7 @@ public class DriverNodePunchVO implements Serializable { @Schema(description = "数量") private String quantity; + @Schema(description = "本次打卡是否使运单自动完成") + private Boolean waybillCompleted; + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 72914b8b..a5aad0ef 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -360,7 +360,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { nodePunchMapper.insert(punch); advanceCurrentProcessNode(waybill, nodeCfg); - return toNodePunchVO(punch); + // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; + // 判定与找节点共用上面已解析的那份过程配置,不重新解析。 + String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); + String nodeName = WaybillProcessSupport.nodeName(nodeCfg); + boolean completed = false; + if (WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeKey) + && WaybillProcessSupport.isDeliveryNode(nodeKey, nodeName) + && WaybillProcessSupport.returnNeedsNoConfirmation(processJson) + && canAutoCompleteWaybill(waybill)) { + completed = waybillService.completeWithoutDeptCheck(waybill.getId()); + } + DriverNodePunchVO vo = toNodePunchVO(punch); + vo.setWaybillCompleted(completed); + return vo; } @Override @@ -1022,6 +1035,20 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return countByType; } + /** + * 自动完成的前置:与司机端「完成运单」同一套——业务状态允许完成,且没有未了结异常。 + *

+ * 不满足返回 false,由调用方静默跳过,打卡不受影响。 + */ + private boolean canAutoCompleteWaybill(Waybill waybill) { + String status = waybill.getBusinessStatus(); + boolean statusAllowed = STATUS_RUNNING.equals(status) + || STATUS_PENDING.equals(status) + || STATUS_WAITING_DISPATCH.equals(status) + || STATUS_DISPATCHING.equals(status); + return statusAllowed && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); + } + /** * 断言该运单没有未了结的异常。 *

diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java new file mode 100644 index 00000000..d7b86e23 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -0,0 +1,248 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +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.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.dto.PunchPhotoDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.entity.WaybillNodePunch; +import org.springblade.transport.pojo.vo.DriverNodePunchVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机打完末位打卡节点后运单自动完成。 + *

+ * 命中要四个条件同时成立:本次打卡的是「司机可打卡节点」的末位、该节点是到货/卸货/签收、 + * 回单无需确认完成、运单本身允许完成。任一不成立都只落打卡流水, + * 响应里的 waybillCompleted 始终取完成接口的返回值,前端据此决定是否提示「运单已完成」。 + */ +class DriverWaybillAutoCompleteTest { + + private static final Long WAYBILL_ID = 5801L; + private static final Long USER_ID = 7001L; + private static final String PLATE = "桂AAW3255"; + private static final String CLOCK_PHOTO = "https://oss/clock.jpg"; + + /** 末位打卡节点是「签收」,没有回单节点 */ + private static final String SIGN_LAST_JSON = """ + [{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}] + """; + + /** 末位打卡节点是「签收」,回单启用且需确认完成 */ + private static final String SIGN_LAST_RETURN_CONFIRM_JSON = """ + [{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}, + {"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}] + """; + + /** 末位打卡节点是「发货」,不是到货/卸货/签收 */ + private static final String DISPATCH_LAST_JSON = """ + [{"key":"dispatch","name":"发货","type":"cargo","enabled":true,"punch":true}] + """; + + private IWaybillService waybillService; + private IDriverService driverService; + private WaybillNodePunchMapper nodePunchMapper; + private ExceptionDisposalMapper exceptionDisposalMapper; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + // 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存 + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, Driver.class); + TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + driverService = mock(IDriverService.class); + nodePunchMapper = mock(WaybillNodePunchMapper.class); + exceptionDisposalMapper = mock(ExceptionDisposalMapper.class); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + mock(CertificationGate.class), + mock(WaybillEnroutePunchMapper.class), + nodePunchMapper, + exceptionDisposalMapper); + + when(driverService.getOne(any())).thenReturn(driver()); + when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(waybillService.updateById(any())).thenReturn(true); + when(nodePunchMapper.selectList(any())).thenReturn(List.of()); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of()); + when(nodePunchMapper.insert(any(WaybillNodePunch.class))).thenReturn(1); + } + + // ------------------------------------------------------------ 命中 + + @Test + @DisplayName("末位签收打卡且回单无需确认时调完成接口,响应标记 waybillCompleted") + void lastDeliveryPunchCompletesWaybill() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertTrue(vo.getWaybillCompleted(), "完成接口返回成功,响应要告诉前端运单已完成"); + } + + @Test + @DisplayName("waybillCompleted 取完成接口的返回值,不按判定命中臆断") + void completionFlagFollowsServiceReturn() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(false); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService).completeWithoutDeptCheck(WAYBILL_ID); + assertFalse(vo.getWaybillCompleted(), "完成接口自己说不算完成时,前端不能提示运单已完成"); + } + + // ------------------------------------------------------------ 不命中 + + @Test + @DisplayName("末位是发货时只打卡不完成") + void punchOnNonDeliveryLastNodeDoesNotComplete() { + givenWaybill(DISPATCH_LAST_JSON, "running"); + + DriverNodePunchVO vo = submit("dispatch"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("回单启用且需确认完成时只打卡不完成") + void returnNeedingConfirmationDoesNotComplete() { + givenWaybill(SIGN_LAST_RETURN_CONFIRM_JSON, "running"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + // ------------------------------------------------------------ 前置:运单本身能否完成 + + @Test + @DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功") + void draftWaybillIsPunchedButNotCompleted() { + givenWaybill(SIGN_LAST_JSON, "draft"); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + @Test + @DisplayName("待执行与调度中间态同样允许自动完成") + void dispatchableStatusesStillComplete() { + for (String status : List.of("pending", "waiting_dispatch", "dispatching")) { + givenWaybill(SIGN_LAST_JSON, status); + when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true); + + DriverNodePunchVO vo = submit("sign"); + + assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态"); + } + } + + @Test + @DisplayName("有未了结异常时不自动完成——与司机端「完成运单」同一口径,异常不落在打卡节点上也拦") + void unresolvedExceptionBlocksAutoComplete() { + givenWaybill(SIGN_LAST_JSON, "running"); + when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of(unresolvedException("unload"))); + + DriverNodePunchVO vo = submit("sign"); + + verify(waybillService, never()).completeWithoutDeptCheck(any()); + verify(nodePunchMapper).insert(any(WaybillNodePunch.class)); + assertFalse(vo.getWaybillCompleted()); + } + + // ------------------------------------------------------------ 夹具 + + private DriverNodePunchVO submit(String nodeCode) { + NodeSubmitDTO dto = new NodeSubmitDTO(); + dto.setWaybillId(WAYBILL_ID); + dto.setNodeCode(nodeCode); + dto.setPhotoItems(List.of(photo("打卡照片", CLOCK_PHOTO))); + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return service.submitNode(dto); + } + } + + private void givenWaybill(String processJson, String businessStatus) { + Waybill waybill = new Waybill(); + waybill.setId(WAYBILL_ID); + waybill.setWaybillNo("YD202610080001"); + waybill.setVehicleNo(PLATE); + waybill.setProcessJson(processJson); + waybill.setBusinessStatus(businessStatus); + waybill.setIsDeleted(0); + when(waybillService.getOne(any())).thenReturn(waybill); + } + + private PunchPhotoDTO photo(String type, String url) { + PunchPhotoDTO photo = new PunchPhotoDTO(); + photo.setType(type); + photo.setUrl(url); + return photo; + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(9001L); + driver.setUserId(USER_ID); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private ExceptionDisposal unresolvedException(String nodeCode) { + ExceptionDisposal exception = new ExceptionDisposal(); + exception.setNodeCode(nodeCode); + exception.setDisposalStatus("processing"); + return exception; + } + +} From 9f9ce7842eb0fe1a1ab55683c84ba77c93ed3f30 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Thu, 8 Oct 2026 10:07:00 +0800 Subject: [PATCH 55/81] =?UTF-8?q?:recycle:=20=E5=8F=B8=E6=9C=BA=E7=AB=AF?= =?UTF-8?q?=E5=8F=AF=E5=AE=8C=E6=88=90=E7=8A=B6=E6=80=81=E7=99=BD=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E6=94=B6=E6=95=9B=E4=B8=BA=E5=8D=95=E4=B8=80=E5=B8=B8?= =?UTF-8?q?=E9=87=8F=E5=B9=B6=E8=AE=A2=E6=AD=A3=E6=B3=A8=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../impl/DriverWaybillServiceImpl.java | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index a5aad0ef..33a84710 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -106,6 +106,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private static final List TAB_ALL_STATUSES = Arrays.asList( STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING, STATUS_COMPLETED ); + /** 允许完成运单的业务状态 */ + private static final List COMPLETABLE_STATUSES = Arrays.asList( + STATUS_RUNNING, STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING + ); private static final int DEFAULT_PREVIEW_SIZE = 2; private static final int MAX_PREVIEW_SIZE = 20; @@ -361,7 +365,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { advanceCurrentProcessNode(waybill, nodeCfg); // 末位打卡节点是到货/卸货/签收、且回单无需确认完成时,本次打卡一并把运单置为完成; - // 判定与找节点共用上面已解析的那份过程配置,不重新解析。 + // isLastDriverPunchNode 与 returnNeedsNoConfirmation 传的就是上面那份 processJson,未再调 resolveProcessJson 取过程配置。 String nodeKey = WaybillProcessSupport.nodeKey(nodeCfg); String nodeName = WaybillProcessSupport.nodeName(nodeCfg); boolean completed = false; @@ -460,10 +464,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { Driver driver = requireCurrentDriver(); Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); waybill = normalizeAcceptStatus(waybill); - if (!STATUS_RUNNING.equals(waybill.getBusinessStatus()) - && !STATUS_PENDING.equals(waybill.getBusinessStatus()) - && !STATUS_WAITING_DISPATCH.equals(waybill.getBusinessStatus()) - && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { + if (!COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus())) { throw new ServiceException("当前运单状态不允许完成"); } // 有未了结异常时不允许完成——完成接口不要求节点全部打卡,不挡住就能绕过打卡门禁 @@ -535,8 +536,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } switch (status) { case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES); - case 1 -> wrapper.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING, - STATUS_WAITING_DISPATCH, STATUS_DISPATCHING)); + case 1 -> wrapper.in(Waybill::getBusinessStatus, COMPLETABLE_STATUSES); case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED); case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED); default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES); @@ -1041,12 +1041,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { * 不满足返回 false,由调用方静默跳过,打卡不受影响。 */ private boolean canAutoCompleteWaybill(Waybill waybill) { - String status = waybill.getBusinessStatus(); - boolean statusAllowed = STATUS_RUNNING.equals(status) - || STATUS_PENDING.equals(status) - || STATUS_WAITING_DISPATCH.equals(status) - || STATUS_DISPATCHING.equals(status); - return statusAllowed && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); + return COMPLETABLE_STATUSES.contains(waybill.getBusinessStatus()) + && unresolvedExceptionNodeCodeList(waybill.getId()).isEmpty(); } /** From d5c6d103520582722a32a8c168c18b95971c8943 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Thu, 8 Oct 2026 19:37:26 +0800 Subject: [PATCH 56/81] =?UTF-8?q?:zap:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E5=8F=B8=E6=9C=BA=E7=AB=AF=E8=BF=90=E5=8D=95=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=E6=8C=89=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=8C=B9=E9=85=8D=EF=BC=8C?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E8=BD=A6=E7=89=8C=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../controller/DriverWaybillController.java | 8 +- .../impl/DriverWaybillServiceImpl.java | 96 +++++++++---------- .../impl/DriverWaybillOwnershipTest.java | 45 ++++----- 3 files changed, 71 insertions(+), 78 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index 36050850..af5d2c1e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -64,14 +64,14 @@ public class DriverWaybillController extends BladeController { @GetMapping("/current-task") @ApiOperationSupport(order = 1) - @Operation(summary = "首页:当前运输中任务", description = "当前司机绑定车牌下 businessStatus=running 的最新一条运单") + @Operation(summary = "首页:当前运输中任务", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=running 的最新一条运单") public R currentTask() { return R.data(driverWaybillService.currentTask()); } @GetMapping("/pending-preview") @ApiOperationSupport(order = 2) - @Operation(summary = "首页:待接运单预览", description = "当前司机绑定车牌下 businessStatus=pending 的预览列表与总数") + @Operation(summary = "首页:待接运单预览", description = "当前用户手机号匹配运单 driverPhone 且 businessStatus=pending 的预览列表与总数") public R pendingPreview( @Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) { return R.data(driverWaybillService.pendingPreview(size)); @@ -79,14 +79,14 @@ public class DriverWaybillController extends BladeController { @GetMapping("/counts") @ApiOperationSupport(order = 3) - @Operation(summary = "运单 Tab 统计", description = "仅统计当前司机绑定车牌对应的运单:全部 / 待接单 / 进行中 / 已完成") + @Operation(summary = "运单 Tab 统计", description = "仅统计当前用户手机号匹配运单 driverPhone 的运单:全部 / 待接单 / 进行中 / 已完成") public R counts() { return R.data(driverWaybillService.tabCounts()); } @GetMapping("/page") @ApiOperationSupport(order = 4) - @Operation(summary = "运单分页列表", description = "仅返回当前司机绑定车牌(driving_vehicle)匹配运单 vehicleNo/trailerVehicleNo 的数据;status:空=全部,0待接单,1运输中,2已完成") + @Operation(summary = "运单分页列表", description = "仅返回运单 driverPhone 等于当前用户手机号的数据;status:空=全部,0待接单,1运输中,2已完成") public R> page( @Parameter(description = "当前页") @RequestParam(required = false) Integer current, @Parameter(description = "每页条数") @RequestParam(required = false) Integer size, diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 83987141..050066de 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -129,12 +129,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO currentTask() { - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return null; } // 进行中:running,或无需确认接单但仍为 pending 的历史数据 - List candidates = waybillService.list(scopedQuery(plates) + List candidates = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING)) .orderByDesc(Waybill::getUpdateTime) .last("LIMIT 20")); @@ -150,12 +150,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillPreviewVO pendingPreview(Integer size) { DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO(); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return preview; } int limit = normalizePreviewSize(size); - List pendingList = waybillService.list(scopedQuery(plates) + List pendingList = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, PENDING_STATUSES) .orderByDesc(Waybill::getCreateTime)); List needAccept = pendingList.stream() @@ -170,11 +170,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillTabCountsVO tabCounts() { DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO(); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return vo; } - List waybills = waybillService.list(scopedQuery(plates) + List waybills = waybillService.list(scopedQuery(phone) .in(Waybill::getBusinessStatus, TAB_ALL_STATUSES)); long pending = 0; long doing = 0; @@ -206,12 +206,12 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); Page empty = new Page<>(pageNo, pageSize); - List plates = currentBoundPlates(); - if (plates.isEmpty()) { + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { return empty; } - LambdaQueryWrapper wrapper = scopedQuery(plates); + LambdaQueryWrapper wrapper = scopedQuery(phone); // 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页 applyStatusFilterForQuery(wrapper, status); applyKeyword(wrapper, keyword); @@ -234,8 +234,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public DriverWaybillCardVO detail(Long id) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); return toCard(normalizeAcceptStatus(waybill), true); } @@ -258,7 +258,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("运单ID不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + Waybill waybill = loadDriverWaybill(dto.getWaybillId()); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -307,7 +307,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("节点编码不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + Waybill waybill = loadDriverWaybill(dto.getWaybillId()); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -370,7 +370,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { // 认证门槛:司机本人须已认证,且承运车辆须已认证。 // 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。 assertCertified(driver); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + Waybill waybill = loadDriverWaybill(id); waybillService.syncDriverAcceptState(waybill); assertAcceptable(waybill); Date now = new Date(); @@ -420,8 +420,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override @Transactional(rollbackFor = Exception.class) public boolean reject(Long id, String reason) { - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); waybillService.syncDriverAcceptState(waybill); assertRejectable(waybill); String rejectReason = Func.isEmpty(reason) ? null : reason.trim(); @@ -444,8 +444,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (id == null) { throw new ServiceException("运单ID不能为空"); } - Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); waybill = normalizeAcceptStatus(waybill); if (!STATUS_RUNNING.equals(waybill.getBusinessStatus()) && !STATUS_PENDING.equals(waybill.getBusinessStatus()) @@ -483,14 +483,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } } - private Waybill loadDriverWaybill(Long id, List plates) { + private Waybill loadDriverWaybill(Long id) { if (id == null) { throw new ServiceException("运单ID不能为空"); } - if (plates.isEmpty()) { - throw new ServiceException("当前司机未绑定车辆"); + String phone = currentUserPhone(); + if (Func.isEmpty(phone)) { + throw new ServiceException("当前用户手机号为空,无法查找运单"); } - Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1")); + Waybill waybill = waybillService.getOne(scopedQuery(phone).eq(Waybill::getId, id).last("LIMIT 1")); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); } @@ -547,24 +548,13 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。 - * 绑定来源:blade_transport_driver.driving_vehicle + * 司机可见运单范围:运单司机手机号(driver_phone)等于当前用户手机号。 */ - private LambdaQueryWrapper scopedQuery(List plates) { - return Wrappers.lambdaQuery().and(w -> { - w.in(Waybill::getVehicleNo, plates) - .or().in(Waybill::getTrailerVehicleNo, plates); - for (String plate : plates) { - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - w.or().apply( - "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(trailer_vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", - plate - ); - } - }); + private LambdaQueryWrapper scopedQuery(String phone) { + String normalizedPhone = Func.toStr(phone, "").trim(); + return Wrappers.lambdaQuery().and(w -> w + .eq(Waybill::getDriverPhone, normalizedPhone) + .or().apply("REPLACE(IFNULL(driver_phone,''),' ','') = {0}", normalizedPhone)); } private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { @@ -580,13 +570,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { .or().like(Waybill::getVehicleNo, kw)); } - /** 当前登录司机绑定的规范化车牌列表;无司机或无绑定车牌则空 */ - private List currentBoundPlates() { + /** + * 当前用户手机号:优先司机档案 mobile,其次 JWT 账号(11 位手机号)。 + */ + private String currentUserPhone() { Driver driver = currentDriver(); - if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { - return List.of(); + if (driver != null && Func.isNotEmpty(driver.getMobile())) { + return driver.getMobile().trim(); } - return splitPlates(driver.getDrivingVehicle()); + String account = AuthUtil.getUserAccount(); + if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + return account.trim(); + } + return null; } /** @@ -809,9 +805,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { @Override public void assertCurrentDriverOwnsWaybill(Long waybillId) { - // 归属口径与司机打卡、司机接单一致:都按司机档案自述绑定的车牌判定 - Driver driver = requireCurrentDriver(); - loadDriverWaybill(waybillId, currentBoundPlates(driver)); + // 归属口径与司机打卡、司机接单一致:按当前用户手机号匹配运单 driverPhone + requireCurrentDriver(); + loadDriverWaybill(waybillId); } /** diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index fa84fb62..353c3f59 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -23,9 +23,6 @@ import org.springblade.transport.service.ITransportVehicleService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.CertificationGate; -import java.util.Collection; -import java.util.List; - import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -40,15 +37,14 @@ import static org.mockito.Mockito.when; /** * 司机对运单的归属校验(异常上报等按运单操作的前置条件)。 *

- * 口径只有一条:司机档案里自述绑定的车牌要与运单的主车或挂车车牌匹配。 - * 它不是「运单上的承运司机」——那两件事互不推导;也不是「谁提交了这份运单材料」。 + * 口径:当前用户手机号匹配运单 {@code driverPhone}。 * 管理端与调度员账号没有司机档案,会停在这里,这是刻意的:不提供代报。 */ class DriverWaybillOwnershipTest { private static final Long WAYBILL_ID = 5301L; private static final Long USER_ID = 7001L; - private static final String PLATE = "桂AAW3255"; + private static final String PHONE = "13800138000"; private IDriverService driverService; private IWaybillService waybillService; @@ -90,20 +86,21 @@ class DriverWaybillOwnershipTest { } @Test - @DisplayName("司机档案里没有绑定车辆时被拒") - void withoutBoundPlateIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(null)); + @DisplayName("司机档案与登录态都没有手机号时被拒") + void withoutPhoneIsRejected() { + Driver driver = driver(null); + when(driverService.getOne(any())).thenReturn(driver); ServiceException exception = assertThrows(ServiceException.class, () -> runOwnershipCheck(WAYBILL_ID)); - assertEquals("当前司机未绑定车辆", exception.getMessage()); + assertEquals("当前用户手机号为空,无法查找运单", exception.getMessage()); } @Test - @DisplayName("运单不在该司机绑定的车牌下时被拒") - void waybillOfAnotherPlateIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PLATE)); + @DisplayName("运单司机手机号与当前用户不一致时被拒") + void waybillOfAnotherPhoneIsRejected() { + when(driverService.getOne(any())).thenReturn(driver(PHONE)); when(waybillService.getOne(any())).thenReturn(null); ServiceException exception = assertThrows(ServiceException.class, @@ -113,9 +110,9 @@ class DriverWaybillOwnershipTest { } @Test - @DisplayName("本车运单放行,且查询确实按司机绑定的车牌收敛") + @DisplayName("本人手机号运单放行,且查询确实按司机手机号收敛") void ownWaybillPasses() { - when(driverService.getOne(any())).thenReturn(driver(PLATE)); + when(driverService.getOne(any())).thenReturn(driver(PHONE)); when(waybillService.getOne(any())).thenReturn(waybill()); assertDoesNotThrow(() -> runOwnershipCheck(WAYBILL_ID)); @@ -123,18 +120,17 @@ class DriverWaybillOwnershipTest { ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); verify(waybillService).getOne(captor.capture()); LambdaQueryWrapper wrapper = captor.getValue(); - assertTrue(wrapper.getTargetSql().contains("vehicle_no"), - "归属查询必须落在运单车牌上:" + wrapper.getTargetSql()); + assertTrue(wrapper.getTargetSql().contains("driver_phone"), + "归属查询必须落在运单司机手机号上:" + wrapper.getTargetSql()); assertTrue(wrapper.getParamNameValuePairs().values().stream() - .anyMatch(value -> PLATE.equals(value) - || (value instanceof Collection values && values.contains(PLATE))), - "归属查询必须带上司机绑定的车牌:" + wrapper.getParamNameValuePairs()); + .anyMatch(PHONE::equals), + "归属查询必须带上当前用户手机号:" + wrapper.getParamNameValuePairs()); } @Test @DisplayName("运单ID为空时被拒") void withoutWaybillIdIsRejected() { - when(driverService.getOne(any())).thenReturn(driver(PLATE)); + when(driverService.getOne(any())).thenReturn(driver(PHONE)); ServiceException exception = assertThrows(ServiceException.class, () -> runOwnershipCheck(null)); @@ -147,16 +143,17 @@ class DriverWaybillOwnershipTest { private void runOwnershipCheck(Long waybillId) { try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUserAccount).thenReturn(PHONE); service.assertCurrentDriverOwnsWaybill(waybillId); } } - private Driver driver(String drivingVehicle) { + private Driver driver(String mobile) { Driver driver = new Driver(); driver.setId(9001L); driver.setUserId(USER_ID); driver.setDriverName("韦司机"); - driver.setDrivingVehicle(drivingVehicle); + driver.setMobile(mobile); return driver; } @@ -164,7 +161,7 @@ class DriverWaybillOwnershipTest { Waybill waybill = new Waybill(); waybill.setId(WAYBILL_ID); waybill.setWaybillNo("YD202610070001"); - waybill.setVehicleNo(PLATE); + waybill.setDriverPhone(PHONE); waybill.setIsDeleted(0); return waybill; } From 5ed2a5c0fc832c1e0c18c975508a499fda45af82 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Thu, 8 Oct 2026 23:27:35 +0800 Subject: [PATCH 57/81] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=8F=B8=E6=9C=BA=E7=AB=AF=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E8=A1=8C=E9=A9=B6=E8=BD=A8=E8=BF=B9=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按打卡记录/计划时间自动推导起止日期后查询 LBS 轨迹。 Co-authored-by: Cursor --- .../controller/DriverWaybillController.java | 8 +++ .../service/IDriverWaybillService.java | 14 ++++ .../impl/DriverWaybillServiceImpl.java | 64 +++++++++++++++++++ 3 files changed, 86 insertions(+) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java index af5d2c1e..f966601d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -38,6 +38,7 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverWaybillService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -104,6 +105,13 @@ public class DriverWaybillController extends BladeController { return R.data(driverWaybillService.detail(id)); } + @PostMapping("/track") + @ApiOperationSupport(order = 11) + @Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)") + public R track(@Parameter(description = "运单ID", required = true) @RequestParam Long id) { + return R.data(driverWaybillService.track(id)); + } + @PostMapping("/accept") @ApiOperationSupport(order = 6) @Operation(summary = "司机确认接单", description = "过程配置接单为「是」时,司机确认接单后运单进入进行中") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java index e1735c5d..0d9d3f15 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -30,6 +30,7 @@ import org.springblade.transport.pojo.vo.DriverNodePunchVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import java.util.Map; @@ -70,6 +71,19 @@ public interface IDriverWaybillService { */ DriverWaybillCardVO detail(Long id); + /** + * 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。 + *

+ * 起止日期优先级: + *

    + *
  1. 有打卡且多于 1 条:首条打卡日 → 末条打卡日
  2. + *
  3. 有打卡且仅 1 条:首条打卡日 → 今天
  4. + *
  5. 无打卡有计划开始日:计划开始日 → 今天
  6. + *
  7. 无打卡无计划:3 天前 → 今天
  8. + *
+ */ + WaybillTrackVO track(Long id); + /** * 按运单ID组装详情打卡数据(punchNodes / enrouteRecords),不校验当前登录人是否为该司机。 * 供调度端 manage/detail 复用。 diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 050066de..36cfd859 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -56,6 +56,7 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IProcessConfigService; @@ -239,6 +240,69 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return toCard(normalizeAcceptStatus(waybill), true); } + @Override + public WaybillTrackVO track(Long id) { + requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id); + LocalDate[] range = resolveTrackDateRange(waybill); + return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString()); + } + + /** + * 小程序行驶轨迹查询区间: + * 1/2 有打卡 → 首条打卡日为开始;多于 1 条结束取末条日,仅 1 条结束取今天 + * 3 无打卡有计划开始日 → 计划开始日到今天 + * 4 无打卡无计划 → 3 天前到今天 + */ + private LocalDate[] resolveTrackDateRange(Waybill waybill) { + LocalDate today = LocalDate.now(); + List punchTimes = listAllPunchTimes(waybill.getId()); + if (!punchTimes.isEmpty()) { + LocalDate start = toLocalDate(punchTimes.get(0)); + LocalDate end = punchTimes.size() > 1 + ? toLocalDate(punchTimes.get(punchTimes.size() - 1)) + : today; + return clampTrackRange(start, end, today); + } + LocalDate planStart = waybill.getEstimatedStartTime(); + if (planStart != null) { + return clampTrackRange(planStart, today, today); + } + return clampTrackRange(today.minusDays(3), today, today); + } + + private LocalDate[] clampTrackRange(LocalDate start, LocalDate end, LocalDate today) { + LocalDate safeStart = start == null ? today.minusDays(3) : start; + LocalDate safeEnd = end == null ? today : end; + if (safeEnd.isBefore(safeStart)) { + safeEnd = safeStart; + } + return new LocalDate[]{safeStart, safeEnd}; + } + + private List listAllPunchTimes(Long waybillId) { + if (waybillId == null) { + return Collections.emptyList(); + } + List times = new ArrayList<>(); + nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId) + .isNotNull(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getPunchTime)) + .forEach(punch -> times.add(punch.getPunchTime())); + enroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybillId) + .isNotNull(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getPunchTime)) + .forEach(punch -> times.add(punch.getPunchTime())); + times.sort(Date::compareTo); + return times; + } + + private LocalDate toLocalDate(Date date) { + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + @Override public DriverWaybillCardVO detailPunchSnapshot(Long id) { if (id == null) { From ef9eab737f22d9385c03255ffa0a48d9243a4a04 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Fri, 9 Oct 2026 17:07:56 +0800 Subject: [PATCH 58/81] =?UTF-8?q?:sparkles:=20=E4=BC=98=E5=8C=96=E4=B8=BB?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=A0=A1=E9=AA=8C=E4=B8=8E=E8=BF=90=E5=8D=95?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=E7=A9=BA=E5=80=BC=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 电报略码改为选填且填写时唯一;用户支持账号姓名手机模糊查询;合同名称允许重复;项目情况说明扩至1000字;导入运单未填里程费用不再落库-1。 Co-authored-by: Cursor --- .../system/pojo/entity/RailwayStation.java | 2 + .../system/controller/UserController.java | 14 +++--- .../system/excel/RailwayStationExcel.java | 2 +- .../excel/RailwayStationExportExcel.java | 2 +- .../springblade/system/mapper/UserMapper.xml | 12 ++--- .../impl/RailwayStationServiceImpl.java | 23 +++++---- .../system/service/impl/UserServiceImpl.java | 38 +++++++++++++++ .../impl/ContractManageServiceImpl.java | 12 +---- .../service/impl/ProjectApplyServiceImpl.java | 28 +++++++++++ .../impl/WaybillImportBatchServiceImpl.java | 48 +++++++++++++++---- .../service/impl/WaybillServiceImpl.java | 15 +++++- .../transport/wrapper/WaybillWrapper.java | 5 +- doc/sql/bladex/bladex.kingbase.all.create.sql | 2 +- doc/sql/bladex/bladex.mysql.all.create.sql | 2 +- doc/sql/bladex/bladex.postgres.all.create.sql | 2 +- .../bladex/bladex.sqlserver.all.create.sql | 2 +- doc/sql/transport/blade_railway_station.sql | 2 +- ...telegraph_code_nullable_patch_20261009.sql | 5 ++ doc/sql/transport/transport.sql | 2 +- 19 files changed, 164 insertions(+), 54 deletions(-) create mode 100644 doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java index 2afb375e..af31a055 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/RailwayStation.java @@ -25,6 +25,7 @@ */ package org.springblade.system.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; @@ -63,6 +64,7 @@ public class RailwayStation extends BaseEntity { * 电报略码 */ @Schema(description = "电报略码") + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String telegraphCode; /** * 车站名称 diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java index ab5e7820..6aab4608 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java @@ -112,11 +112,12 @@ public class UserController { @GetMapping("/list") @BladeView(Views.Admin.class) @Parameters({ - @Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 3) - @Operation(summary = "列表", description = "传入account和realName") + @Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索") public R> list(@Parameter(hidden = true) @RequestParam Map user, Query query) { return R.data(userService.selectPage(user, query)); } @@ -128,11 +129,12 @@ public class UserController { @GetMapping("/page") @BladeView(Views.Admin.class) @Parameters({ - @Parameter(name = "account", description = "账号名", in = ParameterIn.QUERY, schema = @Schema(type = "string")), - @Parameter(name = "realName", description = "姓名", in = ParameterIn.QUERY, schema = @Schema(type = "string")) + @Parameter(name = "account", description = "账号名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "realName", description = "姓名(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "phone", description = "手机号(模糊)", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 4) - @Operation(summary = "列表", description = "传入account和realName") + @Operation(summary = "列表", description = "传入account、realName、phone,支持模糊搜索") public R> page(@Parameter(hidden = true) User user, Query query, Long deptId, BladeUser bladeUser) { IPage pages = userService.selectUserPage(Condition.getPage(query), user, deptId, (AuthUtil.isAdministrator() ? StringPool.EMPTY : bladeUser.getTenantId())); return R.data(UserWrapper.build().pageVO(pages)); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java index 3da00ca2..a7130e25 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExcel.java @@ -54,7 +54,7 @@ public class RailwayStationExcel implements Serializable { @ExcelProperty("TMIS国标编码*") private String tmisCode; - @ExcelProperty("电报略码*") + @ExcelProperty("电报略码") private String telegraphCode; @ExcelProperty("车站名称*") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java index ddaf8230..424cc0c9 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/excel/RailwayStationExportExcel.java @@ -56,7 +56,7 @@ public class RailwayStationExportExcel implements Serializable { @ExcelProperty("TMIS国标编码*") private String tmisCode; - @ExcelProperty("电报略码*") + @ExcelProperty("电报略码") private String telegraphCode; @ExcelProperty("车站名称*") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml index 79fcb79f..23fff0b7 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/UserMapper.xml @@ -44,16 +44,16 @@ and bu.tenant_id = #{user.tenantId} - and bu.account like concat(concat('%', #{user.account}), '%') + and bu.account like concat('%', #{user.account}, '%') - and bu.real_name like concat(concat('%', #{user.realName}), '%') + and ( + bu.real_name like concat('%', #{user.realName}, '%') + or bu.name like concat('%', #{user.realName}, '%') + ) - and ( - bu.phone like concat(concat('%', #{user.phone}), '%') - or bu.account like concat(concat('%', #{user.phone}), '%') - ) + and bu.phone like concat('%', #{user.phone}, '%') and bu.user_type = #{user.userType} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RailwayStationServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RailwayStationServiceImpl.java index f2d1b95e..5c475ea4 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RailwayStationServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/RailwayStationServiceImpl.java @@ -207,7 +207,7 @@ public class RailwayStationServiceImpl extends BaseServiceImpl implement @Override public IPage selectPage(Map user, Query query) { + String account = Func.toStr(user.remove("account")).trim(); + String realName = Func.toStr(user.remove("realName")).trim(); + String phone = Func.toStr(user.remove("phone")).trim(); QueryWrapper queryWrapper = Condition.getQueryWrapper(user, User.class); applyTenantScope(queryWrapper); + applyUserFuzzyFilters(queryWrapper, account, realName, phone); return UserWrapper.build().pageVO(page(Condition.getPage(query), queryWrapper)); } @@ -378,6 +382,7 @@ public class UserServiceImpl extends BaseServiceImpl implement @Override public IPage selectUserPage(IPage page, User user, Long deptId, String tenantId) { + normalizeUserSearch(user); List deptIdList = SysCache.getDeptChildIds(deptId); return page.setRecords(baseMapper.selectUserPage(page, user, deptIdList, tenantId)); } @@ -713,8 +718,12 @@ public class UserServiceImpl extends BaseServiceImpl implement @Override public List exportUser(Map user) { + String account = Func.toStr(user.remove("account")).trim(); + String realName = Func.toStr(user.remove("realName")).trim(); + String phone = Func.toStr(user.remove("phone")).trim(); QueryWrapper queryWrapper = Condition.getQueryWrapper(user, User.class); applyTenantScope(queryWrapper); + applyUserFuzzyFilters(queryWrapper, account, realName, phone); queryWrapper.lambda().eq(User::getIsDeleted, BladeConstant.DB_NOT_DELETED); return exportUser(queryWrapper); } @@ -1127,6 +1136,35 @@ public class UserServiceImpl extends BaseServiceImpl implement } } + /** + * 归一化用户列表搜索条件,避免首尾空格导致模糊匹配失效。 + */ + private void normalizeUserSearch(User user) { + if (user == null) { + return; + } + if (user.getAccount() != null) { + user.setAccount(user.getAccount().trim()); + } + if (user.getRealName() != null) { + user.setRealName(user.getRealName().trim()); + } + if (user.getPhone() != null) { + user.setPhone(user.getPhone().trim()); + } + } + + /** + * 账号名、姓名、手机号统一按模糊匹配过滤。 + */ + private void applyUserFuzzyFilters(QueryWrapper queryWrapper, String account, String realName, String phone) { + queryWrapper.lambda().like(StringUtil.isNotBlank(account), User::getAccount, account); + if (StringUtil.isNotBlank(realName)) { + queryWrapper.lambda().and(wrapper -> wrapper.like(User::getRealName, realName).or().like(User::getName, realName)); + } + queryWrapper.lambda().like(StringUtil.isNotBlank(phone), User::getPhone, phone); + } + @Override public void decryptPhone(IPhone user) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java index 50be61fb..9d2c291f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java @@ -670,7 +670,7 @@ public class ContractManageServiceImpl extends BaseServiceImpllambdaQuery() - .eq(ContractManage::getIsDeleted, 0) - .eq(ContractManage::getContractName, contractManage.getContractName()) - .ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId())); - if (count > 0) { - throw new ServiceException("合同名称已存在"); - } - } - private void validateContractPartyCombinationUnique(ContractManage contractManage) { if (Func.isEmpty(contractManage.getContractCategory()) || Func.isEmpty(contractManage.getPartyA()) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index 01dbd443..080cf984 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -770,6 +770,34 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl situationMap)) { + TransportBusinessSupport.validateLength(situationRemark, 1000, "项目情况说明不能超过1000个字"); + return; + } + Map data = (Map) situationMap; + TransportBusinessSupport.validateLength(Func.toStr(data.get("projectIntro")), 1000, "项目简介不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("profitRemark")), 1000, "盈利模式及利润测算情况说明不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("riskPoint")), 1000, "项目风险点不能超过1000个字"); + TransportBusinessSupport.validateLength(Func.toStr(data.get("emergencyPlan")), 1000, "风险防范措施及应急预案不能超过1000个字"); } private void validateChange(ProjectApply projectApply, String changeType) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index b1eb6ab9..a37f242a 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -455,12 +455,12 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, String field, String fieldName, String... aliases) { String value = stringValue(row, field, aliases); - if (Func.isEmpty(value)) return null; + if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) { + return null; + } try { return new BigDecimal(value); } catch (NumberFormatException exception) { @@ -524,8 +526,32 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, String field, String fieldName, List errors) { String value = stringValue(row, field, fieldName); - if (Func.isEmpty(value)) { + if (Func.isEmpty(value) || isImportOptionalNumberEmpty(value)) { return; } @@ -836,7 +862,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl row, List errors) { String freightTotalStr = stringValue(row, "freightTotal", "运费合计"); - if (Func.isEmpty(freightTotalStr)) { + if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) { return; } @@ -845,8 +871,10 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl } private void putIfNotEmpty(Map map, String key, Object value) { - if (Func.isNotEmpty(value)) { - map.put(key, value); + if (Func.isEmpty(value) || isSentinelMinusOneValue(value)) { + return; } + map.put(key, value); + } + + private boolean isSentinelMinusOneValue(Object value) { + if (value instanceof BigDecimal bigDecimal) { + return isSentinelMinusOne(bigDecimal); + } + if (value instanceof Number number) { + return Double.compare(number.doubleValue(), -1D) == 0; + } + return false; } private synchronized String nextCode() { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java index 65401f97..1dab5a5b 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java @@ -62,7 +62,7 @@ public class WaybillWrapper extends BaseEntityWrapper { waybillVO.setBusinessStatusName(businessStatusName(displayStatus)); waybillVO.setTransportTypeName(transportTypeName(waybill.getTransportType())); waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); - // 未维护里程等占位值 -1 对外展示为空 + // 未维护里程/费用等占位值 -1 对外展示为空 if (isSentinelMinusOne(waybillVO.getMileage())) { waybillVO.setMileage(null); } @@ -72,6 +72,9 @@ public class WaybillWrapper extends BaseEntityWrapper { if (isSentinelMinusOne(waybillVO.getUnitPrice())) { waybillVO.setUnitPrice(null); } + if (isSentinelMinusOne(waybillVO.getOtherFeeTotal())) { + waybillVO.setOtherFeeTotal(null); + } // 计划未选时 planId 可能为库内 -1 或序列化占位,统一清空避免前端回显 -1 if (waybillVO.getPlanId() != null && waybillVO.getPlanId() <= 0L) { waybillVO.setPlanId(null); diff --git a/doc/sql/bladex/bladex.kingbase.all.create.sql b/doc/sql/bladex/bladex.kingbase.all.create.sql index b7bf2e51..c1c00b5e 100644 --- a/doc/sql/bladex/bladex.kingbase.all.create.sql +++ b/doc/sql/bladex/bladex.kingbase.all.create.sql @@ -2442,7 +2442,7 @@ CREATE TABLE "blade_railway_station" ( "id" int8 NOT NULL, "code" varchar(20) NOT NULL, "tmis_code" varchar(5) NOT NULL, - "telegraph_code" varchar(3) NOT NULL, + "telegraph_code" varchar(3) NULL, "name" varchar(100) NOT NULL, "railway_line" varchar(100), "province_code" varchar(12), diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 9da80698..33d15e60 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -1550,7 +1550,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint NOT NULL COMMENT '主键', `code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '编码', `tmis_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车站名称', `railway_line` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属省份编码', diff --git a/doc/sql/bladex/bladex.postgres.all.create.sql b/doc/sql/bladex/bladex.postgres.all.create.sql index b7bf2e51..c1c00b5e 100644 --- a/doc/sql/bladex/bladex.postgres.all.create.sql +++ b/doc/sql/bladex/bladex.postgres.all.create.sql @@ -2442,7 +2442,7 @@ CREATE TABLE "blade_railway_station" ( "id" int8 NOT NULL, "code" varchar(20) NOT NULL, "tmis_code" varchar(5) NOT NULL, - "telegraph_code" varchar(3) NOT NULL, + "telegraph_code" varchar(3) NULL, "name" varchar(100) NOT NULL, "railway_line" varchar(100), "province_code" varchar(12), diff --git a/doc/sql/bladex/bladex.sqlserver.all.create.sql b/doc/sql/bladex/bladex.sqlserver.all.create.sql index 632f8014..69b7edd4 100644 --- a/doc/sql/bladex/bladex.sqlserver.all.create.sql +++ b/doc/sql/bladex/bladex.sqlserver.all.create.sql @@ -7592,7 +7592,7 @@ CREATE TABLE [dbo].[blade_railway_station] ( [id] bigint NOT NULL, [code] nvarchar(20) NOT NULL, [tmis_code] nvarchar(5) NOT NULL, - [telegraph_code] nvarchar(3) NOT NULL, + [telegraph_code] nvarchar(3) NULL, [name] nvarchar(100) NOT NULL, [railway_line] nvarchar(100) NULL, [province_code] nvarchar(12) NULL, diff --git a/doc/sql/transport/blade_railway_station.sql b/doc/sql/transport/blade_railway_station.sql index 17cf66c5..dc5e4979 100644 --- a/doc/sql/transport/blade_railway_station.sql +++ b/doc/sql/transport/blade_railway_station.sql @@ -6,7 +6,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint NOT NULL COMMENT '主键', `code` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '编码', `tmis_code` varchar(5) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码', `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '车站名称', `railway_line` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '所属省份编码', diff --git a/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql b/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql new file mode 100644 index 00000000..7977f448 --- /dev/null +++ b/doc/sql/transport/blade_railway_station_telegraph_code_nullable_patch_20261009.sql @@ -0,0 +1,5 @@ +-- 铁路车站电报略码改为非必填(有值时仍保持唯一) +-- 执行前请先备份数据库;如字段已是可空,可跳过本脚本。 + +ALTER TABLE `blade_railway_station` + MODIFY COLUMN `telegraph_code` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '电报码'; diff --git a/doc/sql/transport/transport.sql b/doc/sql/transport/transport.sql index e9ba572e..b2d174cd 100644 --- a/doc/sql/transport/transport.sql +++ b/doc/sql/transport/transport.sql @@ -1420,7 +1420,7 @@ CREATE TABLE `blade_railway_station` ( `id` bigint(20) NOT NULL COMMENT '主键', `code` varchar(20) NOT NULL COMMENT '编码', `tmis_code` varchar(5) NOT NULL COMMENT 'TMIS国标编码', - `telegraph_code` varchar(3) NOT NULL COMMENT '电报码', + `telegraph_code` varchar(3) DEFAULT NULL COMMENT '电报码', `name` varchar(100) NOT NULL COMMENT '车站名称', `railway_line` varchar(100) DEFAULT NULL COMMENT '所属铁路线路', `province_code` varchar(12) DEFAULT NULL COMMENT '所属省份编码', From 143c6f99fb3f8535890ec69987de9bfc80018c35 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 9 Oct 2026 20:13:38 +0800 Subject: [PATCH 59/81] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=8F=B8?= =?UTF-8?q?=E6=9C=BA=E7=AB=AF=E6=A1=A3=E6=A1=88=E6=8C=89=E8=84=B1=E6=95=8F?= =?UTF-8?q?=E6=89=8B=E6=9C=BA=E5=8F=B7=E5=8C=B9=E9=85=8D=E5=AF=BC=E8=87=B4?= =?UTF-8?q?=E6=9C=AC=E4=BA=BA=E6=A1=A3=E6=A1=88=E6=9F=A5=E4=B8=8D=E5=88=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/DriverAppServiceImpl.java | 37 ++++++++---- .../impl/DriverAppServiceImplTest.java | 57 +++++++++++++++++++ 2 files changed, 83 insertions(+), 11 deletions(-) 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 ee8feb27..1d09822f 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 @@ -186,7 +186,13 @@ public class DriverAppServiceImpl implements IDriverAppService { String selfPhone = resolveSelfPhone(userId); String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim(); if (Func.isNotEmpty(hint)) { - if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) { + if (Func.isEmpty(selfPhone)) { + // 解析不出本人号码时不能拿前端传的 mobile 去查: + // currentDriver 会直接按 mobile 等值查询,传入他人号码即可读到他人档案。 + // 这里返回空让它退回按 user_id 查,宁可查不到也不越权。 + return null; + } + if (!selfPhone.equals(hint)) { throw new ServiceException("只能查询本人司机档案"); } return hint; @@ -196,21 +202,30 @@ public class DriverAppServiceImpl implements IDriverAppService { private String resolveSelfPhone(Long userId) { String account = AuthUtil.getUserAccount(); - if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + if (isMobileLike(account)) { return account.trim(); } + // 用户中心的 phone 带 @Sensitive(MOBILE),经 Feign 回来是「175****12082」这类脱敏串, + // 拿它去 eq(mobile) 永远查不到、拿它比对本人的真实号码又必然不等。 + // 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。 R result = userClient.userInfoById(userId); - if (result == null || !R.isSuccess(result) || result.getData() == null) { - return Func.isEmpty(account) ? null : account.trim(); - } - User user = result.getData(); - if (Func.isNotEmpty(user.getPhone())) { - return user.getPhone().trim(); - } - if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) { - return user.getAccount().trim(); + if (result != null && R.isSuccess(result) && result.getData() != null) { + String userAccount = result.getData().getAccount(); + if (isMobileLike(userAccount)) { + return userAccount.trim(); + } } return Func.isEmpty(account) ? null : account.trim(); } + /** + * 是否是「可作为司机档案手机号使用」的号码串。 + *

+ * 只判 1 开头的 11~15 位纯数字:库里既有 11 位真实手机号,也有 12 位账号 + * (如 175874212082),写死 11 位会让这类账号的司机解析不出本人号码。 + */ + private boolean isMobileLike(String value) { + return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); + } + } 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 index 0894e214..6d2c0607 100644 --- 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 @@ -5,7 +5,10 @@ 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.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.entity.User; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; @@ -17,6 +20,7 @@ import java.time.LocalDate; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; @@ -81,6 +85,31 @@ class DriverAppServiceImplTest { assertEquals("2028-12-04", card.getRoadTransportCertStartDate()); } + // ---------------------------------------------------------------- 手机号解析 + + @Test + @DisplayName("12 位手机号也能解析出本人号码——库里存在 12 位司机账号,写死 11 位会让他们查不到自己的档案") + void twelveDigitAccountIsUsableAsPhone() { + assertEquals("黄大富", currentByPhone("175874212082", "175874212082", null).getDriverName()); + } + + @Test + @DisplayName("用户中心 phone 是脱敏串时改用其 account——脱敏 phone 既查不到档案,也会把本人真实号码误判成越权") + void maskedUserPhoneFallsBackToAccount() { + User user = new User(); + user.setPhone("175****12082"); + user.setAccount("175874212082"); + + assertEquals("黄大富", currentByPhone(null, null, user).getDriverName()); + } + + @Test + @DisplayName("传他人手机号一律拒绝——不能因为解析不到本人号码就放行,那等于允许按手机号查任意档案") + void otherPeoplesMobileIsRejected() { + assertEquals("只能查询本人司机档案", assertThrows(ServiceException.class, + () -> currentByPhone("13900000001", "175874212082", null)).getMessage()); + } + // ---------------------------------------------------------------- 辅助 /** 造一辆只关心有效期三个字段的车 */ @@ -122,4 +151,32 @@ class DriverAppServiceImplTest { } } + /** + * 按给定的登录态跑一次 currentByPhone。 + * + * @param mobileHint 前端传入的 mobile,可为 null + * @param account JWT 里的账号,可为 null + * @param user 用户中心返回的用户,可为 null(表示走不到用户中心) + */ + private Driver currentByPhone(String mobileHint, String account, User user) { + IUserClient userClient = mock(IUserClient.class); + if (user != null) { + when(userClient.userInfoById(USER_ID)).thenReturn(R.data(user)); + } + DriverAppServiceImpl scoped = new DriverAppServiceImpl(driverService, transportVehicleService, userClient); + + Driver driver = new Driver(); + driver.setId(9002L); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setMobile("175874212082"); + when(driverService.getOne(any(LambdaQueryWrapper.class))).thenReturn(driver); + + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + authUtil.when(AuthUtil::getUserAccount).thenReturn(account); + return scoped.currentByPhone(mobileHint); + } + } + } From b66984d3a1c0f46da51d6d2ff4b7ce53d89513e4 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Fri, 9 Oct 2026 20:47:22 +0800 Subject: [PATCH 60/81] =?UTF-8?q?:sparkles:=20=E8=AE=A4=E8=AF=81=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E8=AF=A6=E6=83=85=E4=B8=8B=E5=8F=91=E5=AE=A1=E6=A0=B8?= =?UTF-8?q?=E4=BA=BA=E4=B8=8E=E5=AE=A1=E6=A0=B8=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../springblade/transport/pojo/vo/CertAuditDetailVO.java | 6 ++++++ .../transport/service/impl/CertAuditServiceImpl.java | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java index 9df6f808..577ce62e 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java @@ -70,6 +70,12 @@ public class CertAuditDetailVO implements Serializable { private Long submitUser; @Schema(description = "本次提交人姓名") private String submitUserName; + @Schema(description = "本次审核人;未审核时为 null") + private Long auditUser; + @Schema(description = "本次审核人姓名;未审核时为空") + private String auditUserName; + @Schema(description = "审核时间(通过或驳回的时间);未审核时为 null") + private LocalDateTime auditTime; @Schema(description = "待审快照:本次提交的全量内容") private String pendingSnapshotJson; 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 b7bb4e34..0a36f125 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 @@ -126,6 +126,11 @@ public class CertAuditServiceImpl implements ICertAuditService { detail.setSubmitUser(record.getSubmitUser()); // 面向前端的展示字段必须由服务端填好,否则审核页「提交人」一栏恒为空白 detail.setSubmitUserName(UserCache.getUserRealName(record.getSubmitUser())); + // 审核留痕同理:审核人姓名由服务端查好,前端不做用户ID → 姓名的二次请求。 + // 记录还在「审核中」时审核人与审核时间都为空,由前端显示占位符 + detail.setAuditUser(record.getAuditUser()); + detail.setAuditTime(record.getAuditTime()); + detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser())); detail.setPendingSnapshotJson(record.getSnapshotJson()); // 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理 From e20afc0d55f84fc19d5332cece1f42371f777406 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sat, 10 Oct 2026 00:53:17 +0800 Subject: [PATCH 61/81] =?UTF-8?q?:sparkles:=20=E5=90=88=E5=90=8C=E7=BB=93?= =?UTF-8?q?=E7=AE=97=E5=B8=81=E7=A7=8D=E8=B4=AF=E9=80=9A=E5=BA=94=E6=94=B6?= =?UTF-8?q?=E5=BA=94=E4=BB=98=E4=B8=8E=E7=BB=93=E7=AE=97=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=90=88=E5=90=8C=E7=BC=96=E5=8F=B7=E4=B8=8E?= =?UTF-8?q?=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合同结算币种写入应收应付与预结算;总单运费默认币种统一为 RMB;同步合同申请日期、主要内容、部门合同编号前缀及用印材料字典等能力。 Co-authored-by: Cursor --- .../springblade/system/pojo/entity/Dept.java | 6 ++ .../transport/pojo/entity/ContractManage.java | 13 +++- .../transport/pojo/entity/Waybill.java | 6 ++ .../springblade/system/mapper/DeptMapper.xml | 2 + .../controller/ContractManageController.java | 10 ++- .../transport/excel/ContractManageExcel.java | 10 ++- .../service/IContractManageService.java | 8 ++ .../impl/ContractManageServiceImpl.java | 76 +++++++++++++++++-- .../service/impl/MasterOrderServiceImpl.java | 2 +- .../impl/PreSettlementServiceImpl.java | 22 +++++- .../ReceivablePayableDetailServiceImpl.java | 43 ++++++++++- .../service/impl/WaybillServiceImpl.java | 31 ++++++-- doc/sql/bladex/bladex.mysql.all.create.sql | 1 + ...de_contract_manage_apply_date_20261009.sql | 3 + ..._contract_manage_main_content_20261009.sql | 3 + ...ade_dept_contract_code_prefix_20261009.sql | 3 + ...attachment_type_seal_material_20261009.sql | 9 +++ .../blade_project_contract_management.sql | 6 +- 18 files changed, 229 insertions(+), 25 deletions(-) create mode 100644 doc/sql/transport/blade_contract_manage_apply_date_20261009.sql create mode 100644 doc/sql/transport/blade_contract_manage_main_content_20261009.sql create mode 100644 doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql create mode 100644 doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java index 18338ed0..c7d59b58 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java @@ -144,6 +144,12 @@ public class Dept extends TenantEntity { @Schema(description = "金蝶部门代码") private String kingdeeDeptCode; + /** + * 合同编码前缀(合同编号:前缀(年份)三位流水号) + */ + @Schema(description = "合同编码前缀") + private String contractCodePrefix; + /** * 组织简称 */ diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java index d56fec74..04f6a1b4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ContractManage.java @@ -61,10 +61,10 @@ public class ContractManage extends TenantEntity { @Schema(description = "所属项目") private String projectName; - @Schema(description = "所属组织ID") + @Schema(description = "合同负责部门ID") private Long organizationId; - @Schema(description = "所属组织") + @Schema(description = "合同负责部门") private String organizationName; @Schema(description = "合同类别") @@ -100,6 +100,9 @@ public class ContractManage extends TenantEntity { @Schema(description = "签订日期") private LocalDate signDate; + @Schema(description = "申请日期(提交正式合同时写入)") + private LocalDate applyDate; + @Schema(description = "结算方式") private String settlementMode; @@ -107,6 +110,7 @@ public class ContractManage extends TenantEntity { private String contractFormat; @Schema(description = "是否需要加盖法人章") + @TableField(insertStrategy = FieldStrategy.ALWAYS, updateStrategy = FieldStrategy.ALWAYS) private Integer legalSealFlag; @Schema(description = "一式份数") @@ -206,7 +210,10 @@ public class ContractManage extends TenantEntity { @Schema(description = "终止原因") private String terminateReason; - @Schema(description = "备注") + @Schema(description = "合同主要内容") + private String mainContent; + + @Schema(description = "其他事项说明") private String remark; } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java index dec1a0b5..d79f1502 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java @@ -25,6 +25,8 @@ 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.NullSerializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @@ -82,6 +84,7 @@ public class Waybill extends TenantEntity { @Schema(description = "型号") private String model; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "数量") private BigDecimal quantity; @@ -183,6 +186,7 @@ public class Waybill extends TenantEntity { @Schema(description = "押运人手机号") private String escortPhone; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "里程") private BigDecimal mileage; @@ -195,12 +199,14 @@ public class Waybill extends TenantEntity { @Schema(description = "预计完成日期") private LocalDate estimatedEndTime; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "单价") private BigDecimal unitPrice; @Schema(description = "计价单位") private String priceUnit; + @JsonSerialize(nullsUsing = NullSerializer.class) @Schema(description = "其他费用合计") private BigDecimal otherFeeTotal; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml index e125b43b..a2cb09de 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml @@ -13,6 +13,7 @@ + @@ -34,6 +35,7 @@ + diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java index b6d27107..3ff3eaf1 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ContractManageController.java @@ -177,8 +177,16 @@ public class ContractManageController extends BladeController { return R.data(contractManageService.expireStats(contractManage)); } - @GetMapping("/export-contract-manage") + @GetMapping("/next-contract-no") @ApiOperationSupport(order = 16) + @Operation(summary = "预览下一合同编号", description = "按合同负责部门编码前缀+年份+三位流水预览") + public R nextContractNo( + @Parameter(description = "合同负责部门ID", required = true) @RequestParam Long organizationId) { + return R.data(contractManageService.nextContractNo(organizationId)); + } + + @GetMapping("/export-contract-manage") + @ApiOperationSupport(order = 17) @Operation(summary = "导出合同管理") public void exportContractManage(ContractManageVO contractManage, @RequestParam(required = false) String ids, HttpServletResponse response) { List list = contractManageService.exportContractManage(contractManage, ids); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java index fe30218e..2e9fdbc0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/ContractManageExcel.java @@ -56,7 +56,7 @@ public class ContractManageExcel implements Serializable { private String contractName; @ExcelProperty("所属项目") private String projectName; - @ExcelProperty("所属组织") + @ExcelProperty("合同负责部门") private String organizationName; @ExcelProperty("合同类别") private String contractCategory; @@ -76,6 +76,10 @@ public class ContractManageExcel implements Serializable { private LocalDate temporaryEndDate; @ExcelProperty("经办人") private String handlerUserName; + @ExcelProperty("签订日期") + private LocalDate signDate; + @ExcelProperty("申请日期") + private LocalDate applyDate; @ExcelProperty("合同阶段") private String contractStage; @ExcelProperty("审核状态") @@ -105,7 +109,9 @@ public class ContractManageExcel implements Serializable { private String currentNode; @ExcelProperty("当前处理人") private String currentProcessor; - @ExcelProperty("备注") + @ExcelProperty("合同主要内容") + private String mainContent; + @ExcelProperty("其他事项说明") private String remark; @ExcelProperty("创建人") private String createUserName; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java index 71fef30c..e43ebcc7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IContractManageService.java @@ -64,4 +64,12 @@ public interface IContractManageService extends BaseService { Map expireStats(ContractManageVO contractManage); List exportContractManage(ContractManageVO contractManage, String ids); + /** + * 预览下一合同编号(按合同负责部门前缀 + 年份 + 三位流水) + * + * @param organizationId 合同负责部门ID + * @return 预览编号 + */ + String nextContractNo(Long organizationId); + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java index 9d2c291f..4c6cd10f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java @@ -32,7 +32,9 @@ import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; +import org.springblade.system.pojo.entity.Dept; import org.springblade.transport.excel.ContractManageExcel; import org.springblade.transport.mapper.ContractManageMapper; import org.springblade.transport.pojo.entity.ContractManage; @@ -55,6 +57,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * 合同管理 服务实现类 @@ -190,6 +194,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl latestList = list(Wrappers.lambdaQuery() + .select(ContractManage::getContractNo) + .eq(ContractManage::getIsDeleted, 0) + .eq(ContractManage::getOrganizationId, organizationId) + .likeRight(ContractManage::getContractNo, head) + .orderByDesc(ContractManage::getContractNo) + .last("LIMIT 50")); + Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$"); + int next = 1; + for (ContractManage item : latestList) { + String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo()); + if (Func.isEmpty(contractNo)) { + continue; + } + Matcher matcher = pattern.matcher(contractNo); + if (!matcher.matches()) { + continue; + } + next = Integer.parseInt(matcher.group(1)) + 1; + break; + } + if (next > 999) { + throw new ServiceException("当前部门本年合同流水号已达上限999"); + } + return head + String.format("%03d", next) + "号"; + } + private void refreshArchiveStatusOnApprove(ContractManage contractManage) { if (hasSealArchiveFile(contractManage.getContractFileJson())) { contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED); @@ -595,7 +644,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl> entry : originalApproved.entrySet()) { if (!requestKeys.contains(entry.getKey())) { - throw new ServiceException("已审核通过的合同文件不允许删除"); + throw new ServiceException("已审核通过的用印材料不允许删除"); } } return merged.isEmpty() ? null : JsonUtil.toJson(merged); @@ -671,7 +720,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl dispatch) { return String.join("\u0000", string(dispatch, "segmentNo", ""), string(dispatch, "carrierType", ""), string(dispatch, "carrierContractId", ""), string(dispatch, "carrierName", ""), string(dispatch, "driverName", ""), string(dispatch, "driverPhone", ""), string(dispatch, "vehicleNo", ""), string(dispatch, "captainName", ""), string(dispatch, "containerNo", ""), string(dispatch, "cabinNo", "")); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java index 146581ca..86f5eafe 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PreSettlementServiceImpl.java @@ -319,7 +319,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpllambdaQuery() .eq(PreSettlementSummaryFee::getPreSettlementId, settlement.getId())); } @@ -329,7 +329,7 @@ public class PreSettlementServiceImpl extends BaseServiceImpl 0) { + code = code.substring(0, separatorIndex).trim(); + } + if ("CNY".equals(code)) { + return LOCAL_CURRENCY; + } + return Func.isEmpty(code) ? LOCAL_CURRENCY : code; + } + private String resolveSettlementType(PreSettlementSaveRequest request, ContractManage contract) { if (Boolean.TRUE.equals(request.getAllowSourceMismatch()) && Func.isNotEmpty(request.getSettlementType())) { validateSettlementType(request.getSettlementType()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index 084f85dd..9edd897e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -80,6 +80,7 @@ import java.util.HashMap; import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -1468,7 +1469,7 @@ public class ReceivablePayableDetailServiceImpl detail.setMileage(normalizeGeneratedMileage(waybill.getMileage())); detail.setBatchNo(waybill.getBatchNo()); detail.setUnitPrice(unitPrice); - detail.setCurrency("RMB"); + detail.setCurrency(resolveCurrency(contract, waybill)); detail.setFreightAmount(freight); detail.setOtherFeeAmount(other); detail.setTotalAmount(total); @@ -2523,6 +2524,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, waybill)); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2554,6 +2556,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, masterGoods.isEmpty() ? null : masterGoods.get(0))); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2588,6 +2591,7 @@ public class ReceivablePayableDetailServiceImpl detail.setOtherFeeAmount(total.subtract(freight)); detail.setTotalAmount(total); detail.setUnitPrice(resolveContractUnitPrice(fees)); + detail.setCurrency(resolveCurrency(contract, waybills.get(0))); detail.setFeeItemsJson(aggregateFeeItemsJson(fees)); updateById(detail); } @@ -2886,6 +2890,43 @@ public class ReceivablePayableDetailServiceImpl return map; } + /** + * 结算币种优先取合同 settlementCurrency,其次运单 freightJson.currency,默认 RMB。 + */ + private String resolveCurrency(ContractManage contract, Waybill waybill) { + if (contract != null && Func.isNotEmpty(contract.getSettlementCurrency())) { + return normalizeCurrencyCode(contract.getSettlementCurrency()); + } + String freightCurrency = extractFreightCurrency(waybill); + if (Func.isNotEmpty(freightCurrency)) { + return normalizeCurrencyCode(freightCurrency); + } + return "RMB"; + } + + private String extractFreightCurrency(Waybill waybill) { + if (waybill == null || Func.isEmpty(waybill.getFreightJson())) { + return null; + } + Object currency = parseMap(waybill.getFreightJson()).get("currency"); + return currency == null ? null : String.valueOf(currency); + } + + private String normalizeCurrencyCode(String currency) { + if (Func.isEmpty(currency)) { + return "RMB"; + } + String code = currency.trim().toUpperCase(Locale.ROOT); + int separatorIndex = code.indexOf(" - "); + if (separatorIndex > 0) { + code = code.substring(0, separatorIndex).trim(); + } + if ("CNY".equals(code)) { + return "RMB"; + } + return code; + } + private Map parseMap(String json) { if (Func.isEmpty(json)) { return new LinkedHashMap<>(); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index e9784671..16619068 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1062,10 +1062,13 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setMasterNo(null); waybill.setMileageRemark(null); } - // 新建若已带业务状态(如复制提交)则在过程校正后回写,保证与原运单一致 + // 新建若已带业务状态(如复制提交已完成)则在过程校正后回写,保证与原运单一致 String preservedBusinessStatus = created ? TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()) : null; + if (isCompletedBusinessStatus(preservedBusinessStatus)) { + preservedBusinessStatus = "completed"; + } // 无过程快照时按项目回填;再按接单设置决定 pending / running fillProjectProcessConfig(waybill); prepare(waybill); @@ -1360,17 +1363,35 @@ public class WaybillServiceImpl extends BaseServiceImpl } /** - * 新建/复制运单若业务状态为已完成,按合同系统计费模式生成应收、应付明细。 - *

使用同事务入口,避免 REQUIRES_NEW 读不到尚未提交的新运单。

+ * 新建/复制运单若业务状态为已完成,按与「完成运单」相同的合同系统计费规则生成应收、应付明细。 + *

使用同事务入口 {@link IReceivablePayableDetailService#generateForImportedWaybills}, + * 避免 {@code generateForCompletedWaybills} 的 REQUIRES_NEW 读不到尚未提交的新运单。

*/ private void generateReceivablePayableIfCompleted(Waybill waybill) { if (waybill == null || Func.isEmpty(waybill.getId())) { return; } - if (!"completed".equals(waybill.getBusinessStatus())) { + Waybill saved = getById(waybill.getId()); + if (saved == null || Objects.equals(saved.getIsDeleted(), 1)) { return; } - receivablePayableDetailService.generateForImportedWaybills(List.of(waybill)); + String businessStatus = TransportBusinessSupport.trimToNull(saved.getBusinessStatus()); + if (Func.isEmpty(businessStatus)) { + businessStatus = TransportBusinessSupport.trimToNull(waybill.getBusinessStatus()); + } + if (!isCompletedBusinessStatus(businessStatus)) { + return; + } + // 统一落库英文状态码,与完成运单、列表展示口径一致 + if (!"completed".equals(businessStatus)) { + saved.setBusinessStatus("completed"); + updateById(saved); + } + receivablePayableDetailService.generateForImportedWaybills(List.of(saved)); + } + + private static boolean isCompletedBusinessStatus(String businessStatus) { + return "completed".equals(businessStatus) || "已完成".equals(businessStatus); } @Override diff --git a/doc/sql/bladex/bladex.mysql.all.create.sql b/doc/sql/bladex/bladex.mysql.all.create.sql index 33d15e60..8a7cb64e 100644 --- a/doc/sql/bladex/bladex.mysql.all.create.sql +++ b/doc/sql/bladex/bladex.mysql.all.create.sql @@ -304,6 +304,7 @@ CREATE TABLE `blade_dept` ( `full_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门全称', `dept_code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织编码', `kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '金蝶部门代码', + `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '合同编码前缀', `short_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织简称', `pinyin_mnemonic` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '拼音助记码', `mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码', diff --git a/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql b/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql new file mode 100644 index 00000000..8ebe38ed --- /dev/null +++ b/doc/sql/transport/blade_contract_manage_apply_date_20261009.sql @@ -0,0 +1,3 @@ +-- 合同管理新增申请日期:正式合同提交时写入当天日期 +ALTER TABLE `blade_contract_manage` + ADD COLUMN `apply_date` date DEFAULT NULL COMMENT '申请日期(提交正式合同时写入)' AFTER `sign_date`; diff --git a/doc/sql/transport/blade_contract_manage_main_content_20261009.sql b/doc/sql/transport/blade_contract_manage_main_content_20261009.sql new file mode 100644 index 00000000..ae0d144c --- /dev/null +++ b/doc/sql/transport/blade_contract_manage_main_content_20261009.sql @@ -0,0 +1,3 @@ +-- 合同管理新增合同主要内容(非必填,最多1000字) +ALTER TABLE `blade_contract_manage` + ADD COLUMN `main_content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同主要内容' AFTER `terminate_reason`; diff --git a/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql b/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql new file mode 100644 index 00000000..781b00b1 --- /dev/null +++ b/doc/sql/transport/blade_dept_contract_code_prefix_20261009.sql @@ -0,0 +1,3 @@ +-- 组织机构新增合同编码前缀:合同编号 = 前缀(年份)三位流水号,流水按部门独立 +ALTER TABLE `blade_dept` + ADD COLUMN `contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同编码前缀' AFTER `kingdee_dept_code`; diff --git a/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql b/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql new file mode 100644 index 00000000..a9a03e6c --- /dev/null +++ b/doc/sql/transport/blade_dict_biz_contract_attachment_type_seal_material_20261009.sql @@ -0,0 +1,9 @@ +-- 合同附件类型字典:合同文件 → 用印材料 +-- 业务字典 code = contract_attachment_type + +UPDATE blade_dict_biz +SET dict_key = '用印材料', + dict_value = '用印材料' +WHERE code = 'contract_attachment_type' + AND is_deleted = 0 + AND (dict_key = '合同文件' OR dict_value = '合同文件'); diff --git a/doc/sql/transport/blade_project_contract_management.sql b/doc/sql/transport/blade_project_contract_management.sql index 2d720698..b2208fbf 100644 --- a/doc/sql/transport/blade_project_contract_management.sql +++ b/doc/sql/transport/blade_project_contract_management.sql @@ -119,8 +119,8 @@ CREATE TABLE `blade_contract_manage` ( `contract_name` varchar(100) DEFAULT NULL COMMENT '合同名称', `project_id` bigint(20) DEFAULT NULL COMMENT '所属项目ID', `project_name` varchar(100) DEFAULT NULL COMMENT '所属项目', - `organization_id` bigint(20) DEFAULT NULL COMMENT '所属组织ID', - `organization_name` varchar(100) DEFAULT NULL COMMENT '所属组织', + `organization_id` bigint(20) DEFAULT NULL COMMENT '合同负责部门ID', + `organization_name` varchar(100) DEFAULT NULL COMMENT '合同负责部门', `contract_category` varchar(50) DEFAULT NULL COMMENT '合同类别', `sign_type` varchar(50) DEFAULT NULL COMMENT '签约类型', `party_a` varchar(200) DEFAULT NULL COMMENT '甲方', @@ -132,6 +132,7 @@ CREATE TABLE `blade_contract_manage` ( `handler_user_id` bigint(20) DEFAULT NULL COMMENT '经办人ID', `handler_user_name` varchar(100) DEFAULT NULL COMMENT '经办人', `sign_date` date DEFAULT NULL COMMENT '签订日期', + `apply_date` date DEFAULT NULL COMMENT '申请日期(提交正式合同时写入)', `settlement_mode` varchar(100) DEFAULT NULL COMMENT '结算方式', `contract_format` varchar(50) DEFAULT NULL COMMENT '合同格式', `legal_seal_flag` int(11) DEFAULT '0' COMMENT '是否需要加盖法人章', @@ -160,6 +161,7 @@ CREATE TABLE `blade_contract_manage` ( `change_reason` varchar(500) DEFAULT NULL COMMENT '变更原因', `change_attachments_json` text DEFAULT NULL COMMENT '待审批变更附件JSON', `terminate_reason` varchar(500) DEFAULT NULL COMMENT '终止原因', + `main_content` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '合同主要内容', `remark` varchar(2000) DEFAULT NULL COMMENT '备注', PRIMARY KEY (`id`) USING BTREE, KEY `idx_contract_project` (`project_id`) USING BTREE, From 4fe0c8a968c81a4db7432b9caa571dd9cd7eb907 Mon Sep 17 00:00:00 2001 From: weicw1996 Date: Sat, 10 Oct 2026 01:48:49 +0800 Subject: [PATCH 62/81] =?UTF-8?q?:sparkles:=20=E8=B0=83=E5=BA=A6=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E6=8A=8A=E5=8F=B8=E6=9C=BA=E5=B7=B2=E6=8B=92=E5=8D=95?= =?UTF-8?q?=E7=9A=84=E8=BF=90=E5=8D=95=E5=88=97=E4=B8=BA=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E6=B4=BE=E5=8D=95=E5=BE=85=E5=A4=84=E7=90=86=EF=BC=8C=E5=B9=B6?= =?UTF-8?q?=E5=9C=A8=E5=8F=B8=E6=9C=BA=E7=AB=AF=E8=AE=A9=E8=AF=A5=E8=BF=90?= =?UTF-8?q?=E5=8D=95=E8=84=B1=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/pojo/vo/AdminHomeBadgesVO.java | 3 + .../transport/pojo/vo/AdminHomeVO.java | 2 +- .../controller/ManageWaybillController.java | 2 +- .../impl/DriverWaybillServiceImpl.java | 38 ++- .../impl/ManageWaybillServiceImpl.java | 98 +++++++- .../impl/DriverWaybillRejectDetachTest.java | 230 ++++++++++++++++++ .../service/impl/ManageAdminHomeFeedTest.java | 198 +++++++++++++++ 7 files changed, 560 insertions(+), 11 deletions(-) create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java index d37bd5bd..bb6081fb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeBadgesVO.java @@ -44,4 +44,7 @@ public class AdminHomeBadgesVO implements Serializable { @Schema(description = "风险记录待办数(disposalStatus=pending)") private long risk; + @Schema(description = "重新派单待办数(司机已拒单且运单未取消未完成)") + private long pendingReassign; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java index 2302825d..25c34e4a 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/AdminHomeVO.java @@ -49,7 +49,7 @@ public class AdminHomeVO implements Serializable { @Schema(description = "快捷入口角标") private AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); - @Schema(description = "待处理事项(异常处置状态≠已完成)") + @Schema(description = "待处理事项(异常处置状态≠已完成,或司机已拒单待重新派单)") private List feed = new ArrayList<>(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index 119cf5b9..6d381639 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -72,7 +72,7 @@ public class ManageWaybillController extends BladeController { @GetMapping("/home") @ApiOperationSupport(order = 2) - @Operation(summary = "首页聚合", description = "统计 + 异常/风险角标 + 待处理事项(异常处置≠已完成)+ 当前用户名") + @Operation(summary = "首页聚合", description = "统计 + 异常/风险/重新派单角标 + 待处理事项(异常处置≠已完成,或司机已拒单待重新派单)+ 当前用户名") public R home() { return R.data(manageWaybillService.home()); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 33a84710..742cb529 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -262,7 +262,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { throw new ServiceException("运单ID不能为空"); } Driver driver = requireCurrentDriver(); - Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + // 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明) + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver), true); waybill = normalizeAcceptStatus(waybill); String processJson = resolveProcessJson(waybill); @@ -483,6 +484,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus())) { throw new ServiceException("该运单已接单"); } + // 拒单后运单已对本人脱离,不给自己接回来的入口;要改主意只能由调度重新派单 + if (WaybillProcessSupport.isRejected(waybill.getDriverAcceptStatus())) { + throw new ServiceException("该运单已拒单,请联系调度重新派单"); + } } private void assertRejectable(Waybill waybill) { @@ -498,13 +503,19 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } private Waybill loadDriverWaybill(Long id, List plates) { + return loadDriverWaybill(id, plates, false); + } + + /** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */ + private Waybill loadDriverWaybill(Long id, List plates, boolean includeRejected) { if (id == null) { throw new ServiceException("运单ID不能为空"); } if (plates.isEmpty()) { throw new ServiceException("当前司机未绑定车辆"); } - Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1")); + Waybill waybill = waybillService.getOne(scopedQuery(plates, includeRejected) + .eq(Waybill::getId, id).last("LIMIT 1")); if (waybill == null) { throw new ServiceException("运单不存在或无权操作"); } @@ -560,11 +571,23 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。 - * 绑定来源:blade_transport_driver.driving_vehicle + * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内, + * 且该运单未被这个司机拒单。绑定来源:blade_transport_driver.driving_vehicle + *

+ * 拒单后运单对司机彻底脱离——列表、计数、详情、打卡、接单一律不可达,等管理员重新派单。 + * 运单上的司机姓名/手机/车牌不清空,调度重新派单时要知道原来派给谁。 */ private LambdaQueryWrapper scopedQuery(List plates) { - return Wrappers.lambdaQuery().and(w -> { + return scopedQuery(plates, false); + } + + /** + * @param includeRejected true 时不排除已拒单的运单,仅限在途打卡使用: + * 拒单的运单不可能处于运输中,收口拦不到合法操作, + * 而保留原判据可避免「已拒单 + 进行中」的历史脏数据被误伤 + */ + private LambdaQueryWrapper scopedQuery(List plates, boolean includeRejected) { + LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().and(w -> { w.in(Waybill::getVehicleNo, plates) .or().in(Waybill::getTrailerVehicleNo, plates); for (String plate : plates) { @@ -578,6 +601,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { ); } }); + if (!includeRejected) { + wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus) + .or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)); + } + return wrapper; } private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 5854689f..1f4868f2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -57,6 +57,7 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashSet; @@ -85,6 +86,13 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { private static final String DISPOSAL_PROCESSING = "processing"; private static final String RISK_PENDING = "pending"; + /** 小程序首页待处理事项的类型:异常待处置 */ + private static final String TODO_EXCEPTION = "exception"; + /** 小程序首页待处理事项的类型:重新派单待处理 */ + private static final String TODO_REASSIGN = "reassign"; + /** 重派条目跳转页码:参数 id 是运单主键(不是运单号),该页据此回显运单;buyerPaid 恒为 0,与列表卡片一致 */ + private static final String REASSIGN_TARGET_URL = "/pages/waybill/reassign?id=%s&buyerPaid=0"; + private static final String EXCEPTION_YES = "exception"; private static final String EXCEPTION_NO = "normal"; private static final String TRANSPORT_COMMON = "common"; @@ -122,8 +130,9 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { AdminHomeBadgesVO badges = new AdminHomeBadgesVO(); badges.setException(home.getStats().getException()); badges.setRisk(countPendingRisks()); + badges.setPendingReassign(countPendingReassignments()); home.setBadges(badges); - home.setFeed(buildExceptionFeed()); + home.setFeed(buildAdminFeed()); return home; } @@ -561,18 +570,66 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { .eq(RiskDisposal::getDisposalStatus, RISK_PENDING)); } - private List buildExceptionFeed() { + /** 待重新派单:司机已拒单,且运单尚未取消/完成 */ + private long countPendingReassignments() { + return waybillService.count(pendingReassignQuery()); + } + + private LambdaQueryWrapper pendingReassignQuery() { + return Wrappers.lambdaQuery() + .eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED) + .in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING); + } + + /** + * 首页待处理事项:异常待处置 + 重新派单待处理。 + * 两类各按自己的业务发生时间(上报时刻 / 拒单时刻)倒序,合并后再整体倒序取前 N 条。 + * 排序时间随条目一起携带,不塞进 id 字段——id 在两型下各自是处置主键与运单主键。 + */ + List buildAdminFeed() { + List merged = new ArrayList<>(); + merged.addAll(buildExceptionFeed()); + merged.addAll(buildReassignFeed()); + merged.sort(java.util.Comparator.comparing( + (TimedTodo entry) -> entry.time, java.util.Comparator.nullsFirst(java.util.Comparator.naturalOrder()))); + java.util.Collections.reverse(merged); + int to = Math.min(merged.size(), FEED_LIMIT); + return merged.subList(0, to).stream().map(TimedTodo::item).collect(Collectors.toList()); + } + + /** 待处理事项 + 它的业务发生时间(仅用于排序) */ + private record TimedTodo(AdminTodoItemVO item, LocalDateTime time) { + } + + private List buildExceptionFeed() { List list = exceptionDisposalService.list(Wrappers.lambdaQuery() .in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING) .orderByDesc(ExceptionDisposal::getReportTime) .last("LIMIT " + FEED_LIMIT)); - return list.stream().map(this::toTodoItem).collect(Collectors.toList()); + return list.stream() + .map(disposal -> new TimedTodo(toTodoItem(disposal), exceptionTimeOf(disposal))) + .collect(Collectors.toList()); + } + + private List buildReassignFeed() { + List list = waybillService.list(pendingReassignQuery() + .orderByDesc(Waybill::getDriverRejectTime) + .last("LIMIT " + FEED_LIMIT)); + return list.stream() + .map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill))) + .collect(Collectors.toList()); + } + + private LocalDateTime exceptionTimeOf(ExceptionDisposal disposal) { + return disposal.getReportTime() != null + ? disposal.getReportTime() + : toLocalDateTime(disposal.getCreateTime()); } private AdminTodoItemVO toTodoItem(ExceptionDisposal disposal) { AdminTodoItemVO item = new AdminTodoItemVO(); item.setId(disposal.getId()); - item.setType("exception"); + item.setType(TODO_EXCEPTION); item.setTitle("异常待处置"); item.setTimeAgo(formatTimeAgo(disposal.getReportTime() != null ? disposal.getReportTime() @@ -585,6 +642,38 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return item; } + private AdminTodoItemVO toReassignTodoItem(Waybill waybill) { + AdminTodoItemVO item = new AdminTodoItemVO(); + item.setId(waybill.getId()); + item.setType(TODO_REASSIGN); + item.setTitle("重新派单待处理"); + item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill))); + item.setDesc(buildReassignDesc(waybill)); + item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), "")); + item.setActionLabel("重新派单"); + item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId())); + return item; + } + + /** 拒单时刻是「被拒了多久」的唯一依据;为空(历史数据)才回退到最后修改时间 */ + private LocalDateTime rejectTimeOf(Waybill waybill) { + if (waybill.getDriverRejectTime() != null) { + return toLocalDateTime(waybill.getDriverRejectTime()); + } + return toLocalDateTime(waybill.getUpdateTime()); + } + + /** 拒单原因的输入是可选的,没填时正文不能留下空的「:」 */ + private String buildReassignDesc(Waybill waybill) { + String driver = Func.toStr(waybill.getDriverName(), "原司机"); + String text = "运单已指派" + driver + ",被拒绝,请重新派单"; + String reason = Func.toStr(waybill.getDriverRejectReason(), "").trim(); + if (Func.isNotBlank(reason)) { + text = text + ":" + reason; + } + return text.length() > 80 ? text.substring(0, 80) + "…" : text; + } + private String buildExceptionDesc(ExceptionDisposal disposal) { String reporter = Func.toStr(disposal.getReporterName(), "司机"); String type = Func.toStr(disposal.getExceptionType(), "异常"); @@ -606,6 +695,7 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { return Func.toStr(AuthUtil.getUserName(), ""); } + /** 相对时间文案;时间取不到时留空,避免把「现在」当成发生时刻显示成「刚刚」 */ private String formatTimeAgo(LocalDateTime time) { if (time == null) { return ""; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java new file mode 100644 index 00000000..096a837c --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -0,0 +1,230 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.mockito.MockedStatic; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mapper.ExceptionDisposalMapper; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.CertificationGate; + +import java.util.List; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 司机拒单后运单对本人脱离。 + *

+ * 拒单不再只是一个标记:该运单从列表、计数、详情到接单全部不可达,等管理员重新派单。 + *

+ * 拦截落在司机端共用的取单条件上,而单元测试里 {@code waybillService} 是 mock、不会真的执行 + * 这个条件,所以「已拒单被排除」只能断言条件本身;过滤之后的行为则用「已过滤的输入」来验, + * 不假装 mock 会替我们过滤。 + */ +class DriverWaybillRejectDetachTest { + + private static final Long USER_ID = 7401L; + private static final Long DRIVER_ID = 9001L; + private static final String PLATE = "沪AE31312"; + private static final Long REJECTED_ID = 7201L; + + private IDriverService driverService; + private CertificationGate certificationGate; + private IWaybillService waybillService; + private DriverWaybillServiceImpl service; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + } + + @BeforeEach + void setUp() { + driverService = mock(IDriverService.class); + waybillService = mock(IWaybillService.class); + certificationGate = mock(CertificationGate.class); + lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); + service = new DriverWaybillServiceImpl( + waybillService, + driverService, + mock(IProcessConfigService.class), + mock(ITransportVehicleService.class), + certificationGate, + mock(WaybillEnroutePunchMapper.class), + mock(WaybillNodePunchMapper.class), + mock(ExceptionDisposalMapper.class)); + lenient().when(driverService.getOne(any())).thenReturn(driver()); + lenient().when(waybillService.updateById(any())).thenReturn(true); + // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; + // 真实实现是把入参校正后返回同一个对象 + lenient().when(waybillService.syncDriverAcceptState(any())) + .thenAnswer(invocation -> invocation.getArgument(0)); + } + + @Test + @DisplayName("首页待接预览的取单条件排除已拒单,且排除子句与车牌条件并列") + void pendingPreviewQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.pendingPreview(5)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), + "取单条件必须排除已拒单:" + sql); + assertTrue(sql.toUpperCase().contains("AND"), + "排除子句不能与车牌条件「或」在一起,否则等于没排除:" + sql); + } + + @Test + @DisplayName("运单列表的取单条件同样排除已拒单") + void pageQueryExcludesRejected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of()); + + runAsDriver(() -> service.page(1, 10, 0, null)); + + String sql = capturedListQuery().getTargetSql(); + assertTrue(sql.contains("driver_accept_status"), "列表也要排除已拒单:" + sql); + } + + @Test + @DisplayName("Tab 计数不再把已拒单算进待接单") + void tabCountsOnFilteredInputCountsOnlyUnrejected() { + // 查询层已排除拒单后落回来的输入:一张待接、一张运输中、一张已完成 + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"), + waybill(7204L, "YD-20261001-0004", "completed", "accepted"))); + + DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tabCounts()); + + assertEquals(1, counts.getPending(), "待接单只算那张未被拒的"); + assertEquals(1, counts.getDoing()); + assertEquals(1, counts.getDone()); + assertEquals(3, counts.getAll(), "全部 = 待接 + 进行中 + 已完成"); + } + + @Test + @DisplayName("未拒单的运单不被误伤:待接与运输中照常出现") + void unrejectedWaybillsUnaffected() { + when(waybillService.list(ArgumentMatchers.>any())).thenReturn(List.of( + waybill(7202L, "YD-20261001-0002", "pending", "pending"), + waybill(7203L, "YD-20261001-0003", "running", "accepted"))); + + IPage page = runAsDriver(() -> service.page(1, 10, null, null)); + + assertEquals(2, page.getTotal(), "未被拒的运单条数不变"); + assertEquals(2, page.getRecords().size()); + } + + @Test + @DisplayName("已拒单运单的详情不可达:取单条件已把它挡住") + void detailOfRejectedIsUnreachable() { + // 被取单条件排除后 getOne 落回 null,与「不属于这个司机」同一条报错 + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.detail(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("已拒单运单不能自行接回来") + void acceptOfRejectedIsRejected() { + when(waybillService.getOne(any())).thenReturn(null); + + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.accept(REJECTED_ID))); + + assertEquals("运单不存在或无权操作", exception.getMessage()); + } + + @Test + @DisplayName("在途打卡保留原取单判据,不排除已拒单的历史脏数据") + void enrouteSubmitKeepsUnfilteredScope() { + EnrouteSubmitDTO dto = new EnrouteSubmitDTO(); + dto.setWaybillId(REJECTED_ID); + when(waybillService.getOne(any())).thenReturn(rejectedWaybill()); + + // 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」, + // 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分 + ServiceException exception = assertThrows(ServiceException.class, + () -> runAsDriver(() -> service.submitEnroute(dto))); + + assertEquals("该运单未启用在途打卡", exception.getMessage(), + "在途打卡不应因运单已拒单而在取单阶段被挡下"); + verify(waybillService, org.mockito.Mockito.atLeastOnce()).getOne(any()); + } + + // ------------------------------------------------------------ 夹具 + + private T runAsDriver(Supplier action) { + try (MockedStatic authUtil = mockStatic(AuthUtil.class)) { + authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); + return action.get(); + } + } + + private LambdaQueryWrapper capturedListQuery() { + ArgumentCaptor> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(waybillService).list(captor.capture()); + return captor.getValue(); + } + + private Driver driver() { + Driver driver = new Driver(); + driver.setId(DRIVER_ID); + driver.setUserId(USER_ID); + driver.setDriverName("黄大富"); + driver.setDrivingVehicle(PLATE); + return driver; + } + + private Waybill rejectedWaybill() { + return waybill(REJECTED_ID, "YD-20261010-0001", "pending", "rejected"); + } + + private Waybill waybill(Long id, String no, String businessStatus, String acceptStatus) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(no); + waybill.setVehicleNo(PLATE); + waybill.setBusinessStatus(businessStatus); + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setIsDeleted(0); + return waybill; + } +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java new file mode 100644 index 00000000..1d27f177 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/ManageAdminHomeFeedTest.java @@ -0,0 +1,198 @@ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.mockito.ArgumentMatchers; +import org.springblade.transport.pojo.entity.ExceptionDisposal; +import org.springblade.transport.pojo.entity.Waybill; +import org.springblade.transport.pojo.vo.AdminTodoItemVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.IRiskDisposalService; +import org.springblade.transport.service.IWaybillService; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 调度端首页待处理事项:异常待处置 + 重新派单待处理。 + *

+ * 拒单条目的判据是「司机已拒单且运单未取消未完成」,必须与待处理运单列表的 needReassign + * 口径一致,否则两个页面的条数对不上;卡片上「多久之前」取拒单时刻,不取最后修改时间 + * (后者会被任何无关改动刷新)。 + *

+ * 这里直接验归并方法本身,不走 {@code home()}:那个入口会解析当前用户名、依赖 Spring 缓存, + * 在单测里没有上下文。 + */ +class ManageAdminHomeFeedTest { + + private static final Long EXCEPTION_ID = 8801L; + private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001"; + + private ManageWaybillServiceImpl service; + private IWaybillService waybillService; + private IExceptionDisposalService exceptionService; + + @BeforeAll + static void initTableInfo() { + MybatisConfiguration configuration = new MybatisConfiguration(); + MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, ""); + assistant.setCurrentNamespace("test"); + TableInfoHelper.initTableInfo(assistant, Waybill.class); + TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class); + } + + @BeforeEach + void setUp() { + waybillService = mock(IWaybillService.class); + exceptionService = mock(IExceptionDisposalService.class); + service = new ManageWaybillServiceImpl( + waybillService, + mock(IDriverService.class), + exceptionService, + mock(IRiskDisposalService.class), + mock(IDriverWaybillService.class)); + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(new ArrayList<>()); + } + + @Test + @DisplayName("拒单运单产出一条重新派单待处理,字段与跳转参数对齐小程序约定") + void rejectedWaybillAppearsAsReassignTodo() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7201L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + AdminTodoItemVO item = feed.get(0); + assertEquals("reassign", item.getType()); + assertEquals("重新派单待处理", item.getTitle()); + assertEquals("重新派单", item.getActionLabel()); + assertEquals(REJECTED_WAYBILL_NO, item.getWaybillNo()); + assertEquals(Long.valueOf(7201L), item.getId(), "重派条目应当带运单主键"); + assertEquals("运单已指派黄大富,被拒绝,请重新派单:时间冲突", item.getDesc()); + assertEquals("/pages/waybill/reassign?id=7201&buyerPaid=0", item.getTargetUrl(), + "跳转参数必须是运单主键:该页按主键回显运单,填运单号读不出数据"); + assertFalse(item.getTimeAgo().isEmpty(), "拒单时刻应换算成相对时间"); + } + + @Test + @DisplayName("拒单原因为空时正文不带空冒号") + void withoutRejectReasonKeepsDescClean() { + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7202L, "黄宇富", null))); + + AdminTodoItemVO item = service.buildAdminFeed().get(0); + + assertEquals("运单已指派黄宇富,被拒绝,请重新派单", item.getDesc()); + assertFalse(item.getDesc().contains(":"), "原因缺失时不得留下空的「:」"); + } + + @Test + @DisplayName("没有拒单时只出异常条目,不凭空造重派条目") + void withoutRejectedWaybillNoReassignTodo() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + + List feed = service.buildAdminFeed(); + + assertEquals(1, feed.size()); + assertEquals("exception", feed.get(0).getType()); + assertEquals("异常待处置", feed.get(0).getTitle()); + } + + @Test + @DisplayName("两类条目按各自业务发生时间倒序混排") + void feedIsMergedByBusinessTime() { + when(exceptionService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(exceptionDisposal())); + when(waybillService.list(ArgumentMatchers.>any())) + .thenReturn(List.of(rejectedWaybill(7203L, "黄大富", "时间冲突"))); + + List feed = service.buildAdminFeed(); + + assertEquals(2, feed.size()); + assertEquals(List.of("reassign", "exception"), + feed.stream().map(AdminTodoItemVO::getType).collect(Collectors.toList()), + "拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前"); + } + + @Test + @DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与") + void reassignQueryExcludesTerminalStatus() { + service.buildAdminFeed(); + + ArgumentCaptor> captor = ArgumentCaptor.forClass(Wrapper.class); + verify(waybillService).list(captor.capture()); + LambdaQueryWrapper wrapper = (LambdaQueryWrapper) captor.getValue(); + String sql = wrapper.getTargetSql(); + + assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql); + assertTrue(sql.contains("business_status"), + "已取消/已完成的运单不该再催重新派单:" + sql); + + Set values = new HashSet<>(wrapper.getParamNameValuePairs().values()); + assertTrue(values.contains("rejected"), "要带上已拒绝:" + values); + assertTrue(values.contains("pending") && values.contains("running"), + "业务状态只应放待执行与进行中:" + values); + } + + // ------------------------------------------------------------ 夹具 + + private Waybill rejectedWaybill(Long id, String driverName, String reason) { + Waybill waybill = new Waybill(); + waybill.setId(id); + waybill.setWaybillNo(REJECTED_WAYBILL_NO); + waybill.setDriverName(driverName); + waybill.setDriverAcceptStatus("rejected"); + waybill.setBusinessStatus("pending"); + waybill.setDriverRejectReason(reason); + waybill.setDriverRejectTime(yesterdayAt22()); + return waybill; + } + + private ExceptionDisposal exceptionDisposal() { + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setId(EXCEPTION_ID); + disposal.setWaybillNo("YD-20261006-0001"); + disposal.setReporterName("黄宇富"); + disposal.setExceptionType("交通异常"); + disposal.setDisposalStatus("pending"); + disposal.setReportTime(LocalDateTime.now().minusHours(26)); + return disposal; + } + + private Date yesterdayAt22() { + java.util.Calendar calendar = java.util.Calendar.getInstance(); + calendar.add(java.util.Calendar.DAY_OF_MONTH, -1); + calendar.set(java.util.Calendar.HOUR_OF_DAY, 22); + calendar.set(java.util.Calendar.MINUTE, 0); + calendar.set(java.util.Calendar.SECOND, 0); + calendar.set(java.util.Calendar.MILLISECOND, 0); + return calendar.getTime(); + } +} From 4e7193893f1ebe9176761b94b5a4f6e6e6254644 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sat, 10 Oct 2026 13:56:29 +0800 Subject: [PATCH 63/81] =?UTF-8?q?:sparkles:=20=E7=BB=84=E7=BB=87=E4=B8=8E?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E6=94=AF=E6=8C=81OA=E5=A2=9E=E9=87=8F?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=EF=BC=8C=E5=B9=B6=E5=AE=8C=E5=96=84=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=90=AF=E5=81=9C=E4=B8=8E=E6=80=A7=E5=88=AB=E7=A9=BA?= =?UTF-8?q?=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/system/pojo/entity/Dept.java | 8 + .../system/pojo/enums/UserSex.java | 6 +- .../system/pojo/vo/OaOrgSyncPageVO.java | 3 + .../system/pojo/vo/OaPersonSyncPageVO.java | 3 + .../springblade/system/pojo/entity/User.java | 6 + .../system/controller/DeptController.java | 18 +- .../system/controller/UserController.java | 18 +- .../system/convert/DeptConvert.java | 6 +- .../system/convert/UserConvert.java | 1 + .../springblade/system/mapper/DeptMapper.xml | 3 + .../system/service/IOASyncService.java | 24 +- .../system/service/IUserService.java | 9 + .../system/service/impl/DeptServiceImpl.java | 4 + .../service/impl/OASyncServiceImpl.java | 285 ++++++++++++++---- .../service/impl/OaUserListSyncHelper.java | 36 ++- .../system/service/impl/UserServiceImpl.java | 37 ++- .../system/wrapper/UserWrapper.java | 7 +- .../impl/CustomerArchiveServiceImpl.java | 32 +- .../service/impl/ProjectApplyServiceImpl.java | 3 - .../service/impl/WaybillServiceImpl.java | 6 +- .../blade_dept_oa_modified_20261010.sql | 4 + .../blade_user_oa_modified_20261010.sql | 4 + 22 files changed, 423 insertions(+), 100 deletions(-) create mode 100644 doc/sql/transport/blade_dept_oa_modified_20261010.sql create mode 100644 doc/sql/transport/blade_user_oa_modified_20261010.sql diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java index c7d59b58..15a3e94a 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/entity/Dept.java @@ -216,4 +216,12 @@ public class Dept extends TenantEntity { @TableField(updateStrategy = FieldStrategy.NOT_NULL) private String oaSupSubComId; + /** + * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 + */ + @JsonIgnore + @Schema(description = "OA上次更新时间", hidden = true) + @TableField(updateStrategy = FieldStrategy.NOT_NULL) + private String oaModified; + } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java index 3b37571b..05ca3571 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/UserSex.java @@ -35,8 +35,12 @@ public enum UserSex { if (name == null || name.isEmpty()) { return null; } + String sexName = name.trim(); + if ("-1".equals(sexName) || "0".equals(sexName) || "未知".equals(sexName)) { + return null; + } return Stream.of(values()) - .filter(e -> e.getName().equals(name)) + .filter(e -> e.getName().equals(sexName) || String.valueOf(e.getCode()).equals(sexName)) .map(UserSex::getCode) .findFirst().orElse(null); } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java index 590a8baf..d8390cf5 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaOrgSyncPageVO.java @@ -66,4 +66,7 @@ public class OaOrgSyncPageVO implements Serializable { @Schema(description = "是否已到最后一页") private Boolean finished; + + @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") + private String modifiedSince; } diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java index b493bdf3..b57811da 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/vo/OaPersonSyncPageVO.java @@ -63,4 +63,7 @@ public class OaPersonSyncPageVO implements Serializable { @Schema(description = "是否已到最后一页") private Boolean finished; + + @Schema(description = "增量同步使用的 OA modified 起始时间,后续分页需原样回传") + private String modifiedSince; } diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java index 3700a9e0..398ba356 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/User.java @@ -25,6 +25,7 @@ */ package org.springblade.system.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -131,6 +132,11 @@ public class User extends TenantEntity implements IPhone { * 是否是oa的账号 */ private Integer isOa; + /** + * OA上次更新时间(OA 的 modified 字段),仅同步比对使用 + */ + @TableField(updateStrategy = FieldStrategy.NOT_NULL) + private String oaModified; /** * 是否推送mk */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java index 67dbf494..39d60f72 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/DeptController.java @@ -93,7 +93,8 @@ public class DeptController extends BladeController { @PreAuth(menu = "dept") @GetMapping("/list") @Parameters({ - @Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 2) @@ -108,7 +109,8 @@ public class DeptController extends BladeController { @PreAuth(menu = "dept") @GetMapping("/lazy-list") @Parameters({ - @Parameter(name = "deptName", description = "部门名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "deptName", description = "组织名称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "shortName", description = "组织简称", in = ParameterIn.QUERY, schema = @Schema(type = "string")), @Parameter(name = "fullName", description = "部门全称", in = ParameterIn.QUERY, schema = @Schema(type = "string")) }) @ApiOperationSupport(order = 3) @@ -195,8 +197,10 @@ public class DeptController extends BladeController { @Operation(summary = "同步OA公司") public R syncOaCompany( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncCompanyPage(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncCompanyPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** @@ -208,8 +212,10 @@ public class DeptController extends BladeController { @Operation(summary = "同步OA部门") public R syncOaDepartment( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncDepartmentPage(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncDepartmentPage(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java index 6aab4608..697e5d9d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java @@ -171,8 +171,10 @@ public class UserController { @Operation(summary = "同步OA人员") public R syncIamAccounts( @RequestParam(defaultValue = "1") Integer current, - @RequestParam(defaultValue = "20") Integer size) { - return R.data(oaSyncService.syncPersonFromUserList(current, size)); + @RequestParam(defaultValue = "20") Integer size, + @RequestParam(defaultValue = "false") Boolean incremental, + @RequestParam(required = false) String modifiedSince) { + return R.data(oaSyncService.syncPersonFromUserList(current, size, Boolean.TRUE.equals(incremental), modifiedSince)); } /** @@ -186,6 +188,18 @@ public class UserController { return R.status(userService.updateUser(user)); } + /** + * 启用或停用 + */ + @IsAdmin + @PostMapping("/change-status") + @ApiOperationSupport(order = 6) + @Operation(summary = "启用停用", description = "传入id和status") + public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) { + return R.status(userService.changeUserStatus(id, status)); + } + /** * 删除 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java index d46b3c82..f7c1829b 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java @@ -47,7 +47,8 @@ public interface DeptConvert { // 父公司id转为父部门编码,要拼接前缀 @Mapping(source = "supsubcomid", target = "parentCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - @Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss") + // OA modified 单独落库,用于增量比对,避免覆盖本系统 updateTime + @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); @Mapping(source = "departmentname", target = "deptName") @@ -57,7 +58,8 @@ public interface DeptConvert { // 所属子公司id转为所属公司编码,要拼接前缀 @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - @Mapping(source = "modified", target = "updateTime", dateFormat = "yyyy-MM-dd HH:mm:ss") + // OA modified 单独落库,用于增量比对,避免覆盖本系统 updateTime + @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OADepartmentResponse department); /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java index 28f7004b..6decb305 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/UserConvert.java @@ -70,6 +70,7 @@ public interface UserConvert { @Mapping(source = "lastname", target = "realName") @Mapping(source = "mobile", target = "phone") @Mapping(source = "email", target = "email") + @Mapping(source = "modified", target = "oaModified") User baseConvert(OAPersonResponse person); @Mapping(source = "subcompanyname", target = "companyName") diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml index a2cb09de..f45626dd 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/DeptMapper.xml @@ -80,6 +80,9 @@ and dept.dept_name like concat(concat('%', #{param3.deptName}),'%') + + and dept.short_name like concat(concat('%', #{param3.shortName}),'%') + and dept.full_name like concat(concat('%', #{param3.fullName}),'%') diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java index 3e11ad4b..3b003897 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IOASyncService.java @@ -32,27 +32,33 @@ public interface IOASyncService { /** * 按页从 OA 人员接口同步组织与人员 * - * @param current 当前页,从 1 开始 - * @param size 每页条数 + * @param current 当前页,从 1 开始 + * @param size 每页条数 + * @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA) + * @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值 * @return 本页同步结果 */ - OaPersonSyncPageVO syncPersonFromUserList(int current, int size); + OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince); /** * 按页从 OA 公司接口同步公司 * - * @param current 当前页,从 1 开始 - * @param size 每页条数 + * @param current 当前页,从 1 开始 + * @param size 每页条数 + * @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA) + * @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值 * @return 本页同步结果 */ - OaOrgSyncPageVO syncCompanyPage(int current, int size); + OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince); /** * 按页从 OA 部门接口同步部门;最后一页完成后更新祖级列表 * - * @param current 当前页,从 1 开始 - * @param size 每页条数 + * @param current 当前页,从 1 开始 + * @param size 每页条数 + * @param incremental 是否增量同步(按本地最大 oaModified 过滤 OA) + * @param modifiedSince 增量起始时间;首屏可空,后续分页回传首屏返回值 * @return 本页同步结果 */ - OaOrgSyncPageVO syncDepartmentPage(int current, int size); + OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java index 7c3db61f..fedecc77 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IUserService.java @@ -93,6 +93,15 @@ public interface IUserService extends BaseService { */ boolean updateUser(User user); + /** + * 启用或停用用户(租户守卫校验用户归属,仅更新状态) + * + * @param userId 用户主键 + * @param status 状态:1 启用,0 停用 + * @return 是否成功 + */ + boolean changeUserStatus(Long userId, Integer status); + /** * 修改用户基本信息(仅限当前登录用户自身,忽略角色 / 部门 / 岗位 / 账号等敏感字段) * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index d94020a4..24f461de 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -434,6 +434,10 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (dept.getIsPlatformCompany() == null) { dept.setIsPlatformCompany(0); } + // 排序未填时默认 1 + if (dept.getSort() == null) { + dept.setSort(1); + } if (Func.isEmpty(dept.getTenantId())) { throw new ServiceException("租户ID不能为空"); } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index 7bd93f98..3829e7a6 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -60,6 +60,7 @@ public class OASyncServiceImpl implements IOASyncService { private final IOAClient oaClient; private final DeptConvert deptConvert; private final IDeptService deptService; + private final IUserService userService; private final IRoleService roleService; private final IMKPushService mkPushService; private final IDataSyncRecordService dataSyncRecordService; @@ -124,10 +125,10 @@ public class OASyncServiceImpl implements IOASyncService { @Transactional(rollbackFor = Exception.class) @Override - public OaPersonSyncPageVO syncPersonFromUserList(int current, int size) { + public OaPersonSyncPageVO syncPersonFromUserList(int current, int size, boolean incremental, String modifiedSince) { try { ComposeLogUtil.addLog(log); - return this.syncPersonFromOaPage(current, size); + return this.syncPersonFromOaPage(current, size, incremental, modifiedSince); } finally { ComposeLogUtil.removeLastLog(); } @@ -135,10 +136,10 @@ public class OASyncServiceImpl implements IOASyncService { @Transactional(rollbackFor = Exception.class) @Override - public OaOrgSyncPageVO syncCompanyPage(int current, int size) { + public OaOrgSyncPageVO syncCompanyPage(int current, int size, boolean incremental, String modifiedSince) { try { ComposeLogUtil.addLog(log); - return this.syncCompanyFromOaPage(current, size); + return this.syncCompanyFromOaPage(current, size, incremental, modifiedSince); } finally { ComposeLogUtil.removeLastLog(); } @@ -146,10 +147,10 @@ public class OASyncServiceImpl implements IOASyncService { @Transactional(rollbackFor = Exception.class) @Override - public OaOrgSyncPageVO syncDepartmentPage(int current, int size) { + public OaOrgSyncPageVO syncDepartmentPage(int current, int size, boolean incremental, String modifiedSince) { try { ComposeLogUtil.addLog(log); - return this.syncDepartmentFromOaPage(current, size); + return this.syncDepartmentFromOaPage(current, size, incremental, modifiedSince); } finally { ComposeLogUtil.removeLastLog(); } @@ -280,14 +281,17 @@ public class OASyncServiceImpl implements IOASyncService { /** * 按页从 OA 人员列表同步组织与人员 * - * @param current 当前页 - * @param size 每页条数 + * @param current 当前页 + * @param size 每页条数 + * @param incremental 是否增量同步 + * @param modifiedSince 增量起始时间(后续分页回传) * @return 本页同步结果 */ - private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size) { + private OaPersonSyncPageVO syncPersonFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); - OAPersonSearch personSearch = buildPersonSearch(null); + IncrementalStart incrementalStart = resolvePersonIncrementalStart(incremental, modifiedSince); + OAPersonSearch personSearch = buildPersonSearch(incrementalStart.getStartTime()); personSearch.setCurPage(pageNo); personSearch.setPageSize(pageSize); OAResponse oaResponse = oaClient.queryPersonPage(new OASearch<>(personSearch)); @@ -311,26 +315,31 @@ public class OASyncServiceImpl implements IOASyncService { pageVO.setFetchedCount(oaPersons.size()); pageVO.setSyncedCount(personSyncCount.getSyncedCount()); pageVO.setSkippedCount(personSyncCount.getSkippedCount()); + pageVO.setModifiedSince(incrementalStart.getModifiedSince()); boolean finished = oaPersons.isEmpty() || oaPersons.size() < pageSize || (long) pageNo * pageSize >= totalSize; pageVO.setFinished(finished); - ComposeLogUtil.getLastLog().info("OA人员分页同步完成 {}/{},成功{},跳过{}", - pageNo, totalSize, personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); + ComposeLogUtil.getLastLog().info("OA人员分页同步完成 mode={} {}/{},成功{},跳过{}", + incremental ? "incremental" : "full", pageNo, totalSize, + personSyncCount.getSyncedCount(), personSyncCount.getSkippedCount()); return pageVO; } /** * 按页从 OA 公司接口同步公司 * - * @param current 当前页 - * @param size 每页条数 + * @param current 当前页 + * @param size 每页条数 + * @param incremental 是否增量同步 + * @param modifiedSince 增量起始时间(后续分页回传) * @return 本页同步结果 */ - private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size) { + private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); - OACompanySearch companySearch = buildCompanySearch(null); + IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY); + OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime()); companySearch.setCurPage(pageNo); companySearch.setPageSize(pageSize); OAResponse oaResponse = oaClient.queryCompanyPage(new OASearch<>(companySearch)); @@ -347,6 +356,7 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("同步公司,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); } OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("company", pageNo, pageSize, totalSize, oaCompanies.size(), syncCount); + pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { // 公司全部分批入库后:重建层级,并删除上级不存在的数据 deptService.rebuildOaHierarchy(); @@ -354,19 +364,22 @@ public class OASyncServiceImpl implements IOASyncService { } CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); - ComposeLogUtil.getLastLog().info("OA公司分页同步完成 {}/{},成功{},跳过{}", - pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount()); + ComposeLogUtil.getLastLog().info("OA公司分页同步完成 mode={} {}/{},成功{},跳过{}", + incremental ? "incremental" : "full", pageNo, totalSize, + syncCount.getSyncedCount(), syncCount.getSkippedCount()); return pageVO; } /** * 按页从 OA 部门接口同步部门 * - * @param current 当前页 - * @param size 每页条数 + * @param current 当前页 + * @param size 每页条数 + * @param incremental 是否增量同步 + * @param modifiedSince 增量起始时间(后续分页回传) * @return 本页同步结果 */ - private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size) { + private OaOrgSyncPageVO syncDepartmentFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); String subCompanyIds = getOaSubCompanyIds(true); @@ -376,7 +389,8 @@ public class OASyncServiceImpl implements IOASyncService { emptyPageVO.setFinished(true); return emptyPageVO; } - OADepartmentSearch departmentSearch = buildDepartmentSearch(null, subCompanyIds); + IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.DEPT); + OADepartmentSearch departmentSearch = buildDepartmentSearch(incrementalStart.getStartTime(), subCompanyIds); departmentSearch.setCurPage(pageNo); departmentSearch.setPageSize(pageSize); OAResponse oaResponse = oaClient.queryDepartmentPage(new OASearch<>(departmentSearch)); @@ -393,6 +407,7 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("同步部门,本页未处理数据:{}", JSON.toJSONString(syncCount.getNotHandledList())); } OaOrgSyncPageVO pageVO = buildOrgSyncPageVO("department", pageNo, pageSize, totalSize, oaDepartments.size(), syncCount); + pageVO.setModifiedSince(incrementalStart.getModifiedSince()); if (Boolean.TRUE.equals(pageVO.getFinished())) { // 部门分批同步完成后,重建层级、清理无法挂载数据并刷新祖级列表 deptService.rebuildOaHierarchy(); @@ -401,11 +416,112 @@ public class OASyncServiceImpl implements IOASyncService { } CacheUtil.clear(SYS_CACHE); CacheUtil.clear(SYS_CACHE, Boolean.FALSE); - ComposeLogUtil.getLastLog().info("OA部门分页同步完成 {}/{},成功{},跳过{}", - pageNo, totalSize, syncCount.getSyncedCount(), syncCount.getSkippedCount()); + ComposeLogUtil.getLastLog().info("OA部门分页同步完成 mode={} {}/{},成功{},跳过{}", + incremental ? "incremental" : "full", pageNo, totalSize, + syncCount.getSyncedCount(), syncCount.getSkippedCount()); return pageVO; } + /** + * 解析增量同步起始时间:优先使用前端回传值,保证同阶段分页起点不变 + */ + private IncrementalStart resolveIncrementalStart(boolean incremental, String modifiedSince, DeptCategory deptCategory) { + if (!incremental) { + return IncrementalStart.empty(); + } + if (StringUtils.isNotBlank(modifiedSince)) { + try { + Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME); + return new IncrementalStart(startTime, modifiedSince.trim()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析回传 modifiedSince 失败:{},重新计算", modifiedSince); + } + } + Dept latest = deptService.getOne(Wrappers.lambdaQuery() + .select(Dept::getOaModified) + .eq(Dept::getIsOa, YES) + .eq(Dept::getDeptCategory, deptCategory.getCode()) + .isNotNull(Dept::getOaModified) + .ne(Dept::getOaModified, "") + .orderByDesc(Dept::getOaModified) + .last("limit 1"), false); + if (latest == null || StringUtils.isBlank(latest.getOaModified())) { + ComposeLogUtil.getLastLog().info("本地无 {} oaModified 记录,增量同步按全量拉取后按字段比对跳过", + deptCategory.getName()); + return IncrementalStart.empty(); + } + try { + Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME); + ComposeLogUtil.getLastLog().info("增量同步{} OA modified 起始时间:{}", + deptCategory.getName(), latest.getOaModified()); + return new IncrementalStart(startTime, latest.getOaModified()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析本地最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified()); + return IncrementalStart.empty(); + } + } + + /** + * 人员增量同步起始时间:优先回传值,否则取本地 OA 用户最大 oaModified + */ + private IncrementalStart resolvePersonIncrementalStart(boolean incremental, String modifiedSince) { + if (!incremental) { + return IncrementalStart.empty(); + } + if (StringUtils.isNotBlank(modifiedSince)) { + try { + Date startTime = DateUtil.parse(modifiedSince.trim(), DateUtil.PATTERN_DATETIME); + return new IncrementalStart(startTime, modifiedSince.trim()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析人员回传 modifiedSince 失败:{},重新计算", modifiedSince); + } + } + User latest = userService.getOne(Wrappers.lambdaQuery() + .select(User::getOaModified) + .eq(User::getIsOa, YES) + .isNotNull(User::getOaModified) + .ne(User::getOaModified, "") + .orderByDesc(User::getOaModified) + .last("limit 1"), false); + if (latest == null || StringUtils.isBlank(latest.getOaModified())) { + ComposeLogUtil.getLastLog().info("本地无人员 oaModified 记录,增量同步按全量拉取后按字段比对跳过"); + return IncrementalStart.empty(); + } + try { + Date startTime = DateUtil.parse(latest.getOaModified(), DateUtil.PATTERN_DATETIME); + ComposeLogUtil.getLastLog().info("增量同步人员 OA modified 起始时间:{}", latest.getOaModified()); + return new IncrementalStart(startTime, latest.getOaModified()); + } catch (Exception exception) { + ComposeLogUtil.getLastLog().warn("解析本地人员最大 oaModified 失败:{},回退为全量拉取", latest.getOaModified()); + return IncrementalStart.empty(); + } + } + + /** + * 增量同步起点 + */ + private static class IncrementalStart { + private final Date startTime; + private final String modifiedSince; + + private IncrementalStart(Date startTime, String modifiedSince) { + this.startTime = startTime; + this.modifiedSince = modifiedSince; + } + + private static IncrementalStart empty() { + return new IncrementalStart(null, null); + } + + private Date getStartTime() { + return startTime; + } + + private String getModifiedSince() { + return modifiedSince; + } + } + /** * 组装组织分页同步结果 */ @@ -507,14 +623,10 @@ public class OASyncServiceImpl implements IOASyncService { return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); } - // 查询数据库的部门,转换成map - Map deptMap = getAllCompanyDeptMap(); - - // 处理部门 - List notHandledList = handleDept(allParam, deptMap, DeptCategory.COMPANY); - int syncedCount = allParam.size() - notHandledList.size(); - int skippedCount = filteredSkipCount + notHandledList.size(); - return new OrgSyncCount(syncedCount, skippedCount, notHandledList); + // 查询已存在公司(含 oaModified),按编码索引 + Map existingDeptMap = loadExistingDeptMap(DeptCategory.COMPANY, null); + Map deptIdByCode = toDeptIdMap(existingDeptMap); + return persistSyncedDepts(allParam, existingDeptMap, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount); } /** @@ -558,39 +670,62 @@ public class OASyncServiceImpl implements IOASyncService { deptCodes.add(dept.getParentCode()); } }); - // 查询数据库的部门,转换成map。包含本页及上级编码,便于命中已入库的上级 - Map deptMap = deptCodes.isEmpty() ? new HashMap<>() : deptService.list(Wrappers.lambdaQuery() - .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) - .in(Dept::getDeptCode, deptCodes) - ).stream() - .filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode())) - .collect(Collectors.toMap(Dept::getDeptCode, Dept::getId, (a, b) -> b)); + // 查询数据库已有部门(含 oaModified) + Map existingDeptMap = loadExistingDeptMap(DeptCategory.DEPT, deptCodes); + Map deptIdByCode = toDeptIdMap(existingDeptMap); // 公司编码也放入 map,根部门挂到公司时才能解析父级 - companyDeptMap.forEach(deptMap::putIfAbsent); - // 处理部门 - List notHandledList = this.handleDept(allParam, deptMap, DeptCategory.DEPT); - int syncedCount = allParam.size() - notHandledList.size(); - int skippedCount = filteredSkipCount + notHandledList.size(); - return new OrgSyncCount(syncedCount, skippedCount, notHandledList); + companyDeptMap.forEach(deptIdByCode::putIfAbsent); + return persistSyncedDepts(allParam, existingDeptMap, deptIdByCode, DeptCategory.DEPT, filteredSkipCount); } /** - * 处理部门 - * @param allParam 待处理的部门 - * @param deptMap 数据库所有部门编码和id map - * @return 未处理的数据 + * 加载已存在组织,按部门编码索引 + * + * @param deptCategory 组织类型 + * @param deptCodes 限定编码;为空则按类型全量加载 */ - private List handleDept(List allParam, Map deptMap, DeptCategory deptCategory) { - // 数据库存在的所有部门id - Set existsDeptIds = new HashSet<>(deptMap.values()); + private Map loadExistingDeptMap(DeptCategory deptCategory, Set deptCodes) { + if (deptCodes != null && deptCodes.isEmpty()) { + return new HashMap<>(); + } + List deptList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, deptCategory.getCode()) + .in(deptCodes != null, Dept::getDeptCode, deptCodes) + .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId)); + return deptList.stream() + .filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode())) + .collect(Collectors.toMap(Dept::getDeptCode, dept -> dept, (a, b) -> b)); + } + + private Map toDeptIdMap(Map existingDeptMap) { + Map deptIdByCode = new HashMap<>(); + existingDeptMap.forEach((code, dept) -> deptIdByCode.put(code, dept.getId())); + return deptIdByCode; + } + + /** + * 持久化同步数据:不存在则新增;存在且 oaModified 不一致才修改 + * + * @param allParam 待处理数据 + * @param existingDeptMap 已存在组织(同类型) + * @param deptIdByCode 编码→id(含上级公司等) + * @param deptCategory 组织类型 + * @param filteredSkipCount 上游过滤跳过数 + * @return 同步统计 + */ + private OrgSyncCount persistSyncedDepts(List allParam, Map existingDeptMap, + Map deptIdByCode, DeptCategory deptCategory, + int filteredSkipCount) { + Set existsDeptIds = existingDeptMap.values().stream() + .map(Dept::getId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); allParam.forEach(dept -> { - if (deptMap.containsKey(dept.getDeptCode())) { - // 根据部门编码获取对应的部门id - dept.setId(deptMap.get(dept.getDeptCode())); + if (deptIdByCode.containsKey(dept.getDeptCode())) { + dept.setId(deptIdByCode.get(dept.getDeptCode())); } else { - // 没有就生成一个id dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); - deptMap.put(dept.getDeptCode(), dept.getId()); + deptIdByCode.put(dept.getDeptCode(), dept.getId()); } }); @@ -600,7 +735,7 @@ public class OASyncServiceImpl implements IOASyncService { if (dept.getParentId() != null) { continue; } - Long parentId = deptMap.get(dept.getParentCode()); + Long parentId = deptIdByCode.get(dept.getParentCode()); if (parentId != null) { dept.setParentId(parentId); continue; @@ -615,11 +750,10 @@ public class OASyncServiceImpl implements IOASyncService { deptCategory.getName(), unresolvedParentCount); } - // 父id不为空才新增;已存在的记录即使父id未解析也更新(空父id不会覆盖原值) + // 父id不为空才新增;已存在的记录即使父id未解析也允许更新(空父id不会覆盖原值) List list = allParam.stream() .filter(dept -> dept.getParentId() != null || existsDeptIds.contains(dept.getId())) .toList(); - // 不存在的新增 List addList = list.stream() .filter(dept -> !existsDeptIds.contains(dept.getId())) .toList(); @@ -627,18 +761,43 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size()); deptService.saveBatch(addList); } - // 存在的修改 + // 已存在且 oaModified 不一致(或不存在可比对值)才修改 List updateList = list.stream() .filter(dept -> existsDeptIds.contains(dept.getId())) + .filter(dept -> !shouldSkipOaUpdate(existingDeptMap.get(dept.getDeptCode()), dept)) .toList(); if (CollectionUtil.isNotEmpty(updateList)) { ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size()); deptService.updateBatchById(updateList); } - // 返回仍未保存的数据 - return allParam.stream() + int unchangedCount = (int) list.stream() + .filter(dept -> existsDeptIds.contains(dept.getId())) + .filter(dept -> shouldSkipOaUpdate(existingDeptMap.get(dept.getDeptCode()), dept)) + .count(); + List notHandledList = allParam.stream() .filter(dept -> dept.getParentId() == null && !existsDeptIds.contains(dept.getId())) .toList(); + int syncedCount = addList.size() + updateList.size(); + int skippedCount = filteredSkipCount + unchangedCount + notHandledList.size(); + if (unchangedCount > 0) { + ComposeLogUtil.getLastLog().info("跳过未变更{}:{}", deptCategory.getName(), unchangedCount); + } + return new OrgSyncCount(syncedCount, skippedCount, notHandledList); + } + + /** + * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段 + */ + private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) { + if (existing == null) { + return false; + } + String localModified = StringUtils.trimToEmpty(existing.getOaModified()); + String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); + if (localModified.isEmpty() || remoteModified.isEmpty()) { + return false; + } + return localModified.equals(remoteModified); } /** diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index 6a8a1884..eba6b8cd 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -249,6 +249,7 @@ public class OaUserListSyncHelper { }); Map userMap = new HashMap<>(); Set existsUserIds = new HashSet<>(); + Set unchangedUserIds = new HashSet<>(); users.forEach(user -> { User existingUser = existingByAccount.get(user.getAccount()); if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) { @@ -258,6 +259,9 @@ public class OaUserListSyncHelper { user.setId(existingUser.getId()); user.setPassword(null); existsUserIds.add(existingUser.getId()); + if (shouldSkipOaUpdate(existingUser, user)) { + unchangedUserIds.add(existingUser.getId()); + } } else { user.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); user.setPostId("-1"); @@ -279,12 +283,18 @@ public class OaUserListSyncHelper { } List updateUsers = users.stream() .filter(user -> existsUserIds.contains(user.getId())) + .filter(user -> !unchangedUserIds.contains(user.getId())) .toList(); if (CollectionUtil.isNotEmpty(updateUsers)) { ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size()); userService.updateBatchById(updateUsers); } + if (!unchangedUserIds.isEmpty()) { + ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size()); + } ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount); + int syncedCount = addUsers.size() + updateUsers.size(); + skippedCount += unchangedUserIds.size(); if (userMap.isEmpty()) { return new PersonSyncCount(0, skippedCount); } @@ -295,7 +305,13 @@ public class OaUserListSyncHelper { .collect(Collectors.toMap(this::getUserDeptKey, UserDept::getId, (first, second) -> second)); Set existsUserDeptIds = new HashSet<>(userDeptMap.values()); List userDeptList = oaPersons.stream() - .filter(oaPerson -> userMap.containsKey(userConvert.resolveAccount(oaPerson))) + .filter(oaPerson -> { + String account = userConvert.resolveAccount(oaPerson); + if (!userMap.containsKey(account)) { + return false; + } + return !unchangedUserIds.contains(userMap.get(account)); + }) .map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex)) .filter(Objects::nonNull) .toList(); @@ -333,6 +349,7 @@ public class OaUserListSyncHelper { .collect(Collectors.toSet()); String defaultRoleId = getDefaultRoleId(); List updateUserParams = list.stream() + .filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId())) .filter(userDeptIds -> StringUtils.isNotBlank(userDeptIds.getDeptIds()) && userDeptIds.getDeptIds().length() <= MAX_DEPT_ID_LENGTH) .map(userDeptIds -> { User user = new User(); @@ -347,7 +364,22 @@ public class OaUserListSyncHelper { if (CollectionUtil.isNotEmpty(updateUserParams)) { userService.updateBatchById(updateUserParams); } - return new PersonSyncCount(users.size(), skippedCount); + return new PersonSyncCount(syncedCount, skippedCount); + } + + /** + * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段 + */ + private boolean shouldSkipOaUpdate(User existing, User incoming) { + if (existing == null) { + return false; + } + String localModified = StringUtils.trimToEmpty(existing.getOaModified()); + String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); + if (localModified.isEmpty() || remoteModified.isEmpty()) { + return false; + } + return localModified.equals(remoteModified); } private Dept getOrCreateRootCompany() { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java index ec283ec2..3a463c4d 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserServiceImpl.java @@ -339,6 +339,23 @@ public class UserServiceImpl extends BaseServiceImpl implement return submitUserInfo(user) && submitUserDept(user) && submitUserScopes(user); } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean changeUserStatus(Long userId, Integer status) { + if (Func.isEmpty(userId)) { + throw new ServiceException("用户主键不能为空"); + } + if (!Objects.equals(status, DataStatusEnum.ENABLE.getCode()) && !Objects.equals(status, 0) + && !Objects.equals(status, DataStatusEnum.DISABLE.getCode())) { + throw new ServiceException("启停状态不正确"); + } + TenantGuard.verify(this, userId, USER); + Integer targetStatus = Objects.equals(status, DataStatusEnum.ENABLE.getCode()) + ? DataStatusEnum.ENABLE.getCode() : 0; + CacheUtil.clear(USER_CACHE); + return changeStatus(Collections.singletonList(userId), targetStatus); + } + @Override public boolean updateUserInfo(User user) { // 用户修改自身信息强制指定当前请求账号的ID @@ -364,7 +381,14 @@ public class UserServiceImpl extends BaseServiceImpl implement throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone())); } user.setPassword(null); - return updateById(user); + Integer originalSex = user.getSex(); + Integer normalizedSex = normalizeUserSex(originalSex); + user.setSex(normalizedSex); + boolean updated = updateById(user); + if (user.getId() != null && originalSex != null && normalizedSex == null) { + this.update(Wrappers.lambdaUpdate().set(User::getSex, null).eq(User::getId, user.getId())); + } + return updated; } private boolean submitUserDept(User user) { @@ -1086,6 +1110,7 @@ public class UserServiceImpl extends BaseServiceImpl implement throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", user.getPhone())); } CacheUtil.clear(USER_CACHE); + user.setSex(normalizeUserSex(user.getSex())); return save(user) && submitUserDept(user) && submitUserScopes(user); } @@ -1096,6 +1121,16 @@ public class UserServiceImpl extends BaseServiceImpl implement if (user.getIncludeNewCustomer() == null) user.setIncludeNewCustomer(0); } + /** + * 性别仅保留男 / 女 / 未知;空值或历史占位 -1 不回填,按空处理。 + */ + private Integer normalizeUserSex(Integer sex) { + if (Objects.equals(sex, 1) || Objects.equals(sex, 2) || Objects.equals(sex, 3)) { + return sex; + } + return null; + } + private boolean submitUserScopes(User user) { userDataScopeMapper.delete(Wrappers.lambdaQuery().eq(UserDataScope::getUserId, user.getId())); userCustomerScopeMapper.delete(Wrappers.lambdaQuery().eq(UserCustomerScope::getUserId, user.getId())); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java index 7dbfaa32..3630589b 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/UserWrapper.java @@ -38,6 +38,7 @@ import org.springblade.system.pojo.enums.DictEnum; import org.springblade.system.pojo.vo.UserVO; import java.util.List; +import java.util.Objects; /** * 包装类,返回视图层所需的字段 @@ -81,7 +82,11 @@ public class UserWrapper extends BaseEntityWrapper { userVO.setPostId(StringPool.EMPTY); userVO.setPostName("暂未分配"); } - userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex())); + if (user.getSex() == null || Objects.equals(user.getSex(), -1)) { + userVO.setSexName(""); + } else { + userVO.setSexName(DictCache.getValue(DictEnum.SEX, user.getSex())); + } userVO.setUserTypeName(DictCache.getValue(DictEnum.USER_TYPE, user.getUserType())); return userVO; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index a4bbad50..81b9cb44 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -38,6 +38,7 @@ import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.SysCache; import org.springblade.system.cache.UserCache; import org.springblade.transport.excel.CustomerArchiveExcel; import org.springblade.transport.mapper.CreditRatingStandardMapper; @@ -118,6 +119,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl deptIdList = Func.toLongList(customer.getDeptIds()); - if (Func.isEmpty(deptIdList)) { - throw new ServiceException("所属组织不正确"); - } - customer.setDeptId(deptIdList.get(0)); - } - customer.setDeptName(trimToEmpty(customer.getDeptName())); + applyFixedOwnerDept(customer); customer.setAccessType(Func.isEmpty(customer.getAccessType()) ? ACCESS_TEMPORARY : customer.getAccessType()); customer.setApprovalStatus(Func.isEmpty(customer.getApprovalStatus()) ? APPROVAL_DRAFT : customer.getApprovalStatus()); if (Func.isEmpty(customer.getStatus())) { @@ -1628,6 +1623,25 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl deptIdList = Func.toLongList(deptIds); + if (Func.isEmpty(deptIdList) || deptIdList.get(0) == null) { + throw new ServiceException("所属组织「" + FIXED_OWNER_DEPT_NAME + "」不正确"); + } + Long deptId = deptIdList.get(0); + customer.setDeptId(deptId); + customer.setDeptIds(String.valueOf(deptId)); + } + private String trimToEmpty(String value) { return value == null ? "" : value.trim(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index 080cf984..deb2065f 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -739,9 +739,6 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl 0) { - throw new ServiceException("项目应收账款额度不能超过项目资金使用额度"); - } validateDays(projectApply.getReceivableDays(), "应收账款回款期限"); validateDays(projectApply.getPaymentDays(), "回款账期"); validateAmount(projectApply.getProjectScale(), "项目规模", true); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 16619068..64c4837e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1926,7 +1926,7 @@ public class WaybillServiceImpl extends BaseServiceImpl if (isRoadTransport(waybill.getTransportType())) { if ("承运商".equals(waybill.getCarrierType())) { TransportBusinessSupport.validateRequired(waybill.getCarrierName(), "承运商不能为空"); - } else { + } else if (!isBatchImport(waybill)) { TransportBusinessSupport.validateRequired(waybill.getDriverName(), "司机不能为空"); } } @@ -2079,6 +2079,10 @@ public class WaybillServiceImpl extends BaseServiceImpl return value.contains("公路") || value.contains("道路") || value.contains("road") || "gl".equals(value); } + private boolean isBatchImport(Waybill waybill) { + return waybill != null && TransportBusinessSupport.DATA_SOURCE_BATCH_IMPORT.equals(waybill.getDataSource()); + } + private void fillJsonFromFlatFields(Waybill waybill) { if (Func.isEmpty(waybill.getTaskEntryMode())) { waybill.setTaskEntryMode("simple"); diff --git a/doc/sql/transport/blade_dept_oa_modified_20261010.sql b/doc/sql/transport/blade_dept_oa_modified_20261010.sql new file mode 100644 index 00000000..97960aff --- /dev/null +++ b/doc/sql/transport/blade_dept_oa_modified_20261010.sql @@ -0,0 +1,4 @@ +-- 机构表:记录 OA 的 modified(上次更新时间),用于增量同步比对 + +ALTER TABLE `blade_dept` + ADD COLUMN `oa_modified` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上次更新时间(modified)' AFTER `oa_sup_sub_com_id`; diff --git a/doc/sql/transport/blade_user_oa_modified_20261010.sql b/doc/sql/transport/blade_user_oa_modified_20261010.sql new file mode 100644 index 00000000..1b92853f --- /dev/null +++ b/doc/sql/transport/blade_user_oa_modified_20261010.sql @@ -0,0 +1,4 @@ +-- 用户表:记录 OA 的 modified(上次更新时间),用于增量同步比对 + +ALTER TABLE `blade_user` + ADD COLUMN `oa_modified` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT 'OA上次更新时间(modified)' AFTER `is_oa`; From a4287d8b15cdb26d018be54b8b2f9a8fa85fdd71 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sat, 10 Oct 2026 14:28:31 +0800 Subject: [PATCH 64/81] =?UTF-8?q?:bug:=20=E7=94=A8=E6=88=B7=E7=BC=93?= =?UTF-8?q?=E5=AD=98=E5=8F=8D=E5=BA=8F=E5=88=97=E5=8C=96=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E6=B8=85=E7=90=86=E5=B9=B6=E5=9B=9E=E6=BA=90=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E5=88=97=E8=A1=A8=E7=BF=BB=E8=AF=91=E5=A7=93?= =?UTF-8?q?=E5=90=8D=E6=97=B6=E6=8A=A5=20500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/system/cache/UserCache.java | 79 ++++++++++++++----- 1 file changed, 59 insertions(+), 20 deletions(-) diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java index 0dbd04fc..b181e1f5 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserCache.java @@ -25,6 +25,7 @@ */ package org.springblade.system.cache; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; @@ -42,6 +43,7 @@ import static org.springblade.core.launch.constant.FlowConstant.TASK_USR_PREFIX; * * @author Chill */ +@Slf4j public class UserCache { private static final String USER_CACHE_ID = "user:id:"; private static final String USER_CACHE_ACCOUNT = "user:account:"; @@ -73,10 +75,18 @@ public class UserCache { * @return */ public static User getUser(Long userId) { - return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> { - R result = getUserClient().userInfoById(userId); - return result.getData(); - }); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException exception) { + log.warn("用户缓存读取失败,清理损坏缓存后重试, userId={}", userId, exception); + clearBrokenUserCache(); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException retryException) { + log.warn("用户缓存重试仍失败,改为直连查询, userId={}", userId, retryException); + return loadUserById(userId); + } + } } /** @@ -89,20 +99,25 @@ public class UserCache { if (userId == null) { return StringPool.EMPTY; } - User user = getUser(userId); - if (user == null) { + try { + User user = getUser(userId); + if (user == null) { + return Func.toStr(userId); + } + if (StringUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StringUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StringUtil.isNotBlank(user.getAccount())) { + return user.getAccount(); + } + return Func.toStr(userId); + } catch (RuntimeException exception) { + log.warn("翻译用户姓名失败,回退为用户ID, userId={}", userId, exception); return Func.toStr(userId); } - if (StringUtil.isNotBlank(user.getRealName())) { - return user.getRealName(); - } - if (StringUtil.isNotBlank(user.getName())) { - return user.getName(); - } - if (StringUtil.isNotBlank(user.getAccount())) { - return user.getAccount(); - } - return Func.toStr(userId); } /** @@ -113,10 +128,34 @@ public class UserCache { * @return */ public static User getUser(String tenantId, String account) { - return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, tenantId + StringPool.DASH + account, () -> { - R result = getUserClient().userByAccount(tenantId, account); - return result.getData(); - }); + String accountKey = tenantId + StringPool.DASH + account; + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, accountKey, () -> loadUserByAccount(tenantId, account)); + } catch (RuntimeException exception) { + log.warn("用户缓存读取失败,清理损坏缓存后重试, accountKey={}", accountKey, exception); + clearBrokenUserCache(); + try { + return CacheUtil.get(USER_CACHE, USER_CACHE_ACCOUNT, accountKey, () -> loadUserByAccount(tenantId, account)); + } catch (RuntimeException retryException) { + log.warn("用户缓存重试仍失败,改为直连查询, accountKey={}", accountKey, retryException); + return loadUserByAccount(tenantId, account); + } + } + } + + private static User loadUserById(Long userId) { + R result = getUserClient().userInfoById(userId); + return result.getData(); + } + + private static User loadUserByAccount(String tenantId, String account) { + R result = getUserClient().userByAccount(tenantId, account); + return result.getData(); + } + + private static void clearBrokenUserCache() { + CacheUtil.clear(USER_CACHE); + CacheUtil.clear(USER_CACHE, Boolean.FALSE); } } From 68fede2caf3da946f5053fd8786108541c00b423 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sat, 10 Oct 2026 16:20:21 +0800 Subject: [PATCH 65/81] =?UTF-8?q?:bug:=20=E5=AE=8C=E5=96=84=E6=B8=AF?= =?UTF-8?q?=E5=8F=A3=E7=A0=81=E5=A4=B4/=E9=93=81=E8=B7=AF=E5=AF=BC?= =?UTF-8?q?=E5=85=A5=E5=A4=B1=E8=B4=A5=E5=8E=9F=E5=9B=A0=E4=B8=8E=E8=BD=AF?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=81=A2=E5=A4=8D=EF=BC=8C=E5=B9=B6=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E8=BF=90=E5=8D=95=E5=AF=BC=E5=85=A5=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../pojo/dto/WaybillImportBatchRequest.java | 6 + .../system/mapper/RailwayStationMapper.java | 5 + .../system/mapper/RailwayStationMapper.xml | 8 + .../service/impl/PortTerminalServiceImpl.java | 247 ++++++++++++++---- .../impl/RailwayStationServiceImpl.java | 13 +- .../controller/WaybillController.java | 28 +- .../impl/WaybillImportBatchServiceImpl.java | 9 + 7 files changed, 260 insertions(+), 56 deletions(-) diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java index e445395e..717766fa 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillImportBatchRequest.java @@ -32,6 +32,12 @@ public class WaybillImportBatchRequest { private Long planId; private String planName; private String remark; + /** 列表筛选:创建人 */ + private Long createUser; + /** 列表筛选:创建时间起(yyyy-MM-dd HH:mm:ss) */ + private String createTimeStart; + /** 列表筛选:创建时间止(yyyy-MM-dd HH:mm:ss) */ + private String createTimeEnd; private List> rows; /** 分批确认导入时,是否追加到已有批次(首批为 false,后续批为 true)。 */ private Boolean append; diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java index f06997f6..69c8c24e 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.java @@ -45,6 +45,11 @@ public interface RailwayStationMapper extends BaseMapper { */ RailwayStation selectByCodeIncludingDeleted(@Param("code") String code); + /** + * 按 TMIS 国标编码查询铁路车站(包含逻辑删除记录,用于导入恢复)。 + */ + RailwayStation selectByTmisCodeIncludingDeleted(@Param("tmisCode") String tmisCode); + /** * 恢复逻辑删除铁路车站。 */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml index 54d82bde..72d99a14 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/RailwayStationMapper.xml @@ -36,6 +36,14 @@ SELECT * FROM blade_railway_station WHERE code = #{code} + LIMIT 1 + + + diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java index 8e96be1d..a9f3fc1c 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/PortTerminalServiceImpl.java @@ -29,7 +29,8 @@ import com.baomidou.mybatisplus.core.conditions.Wrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import lombok.AllArgsConstructor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.tool.utils.BeanUtil; @@ -44,6 +45,7 @@ import org.springblade.system.pojo.entity.Region; import org.springblade.system.pojo.vo.PortTerminalVO; import org.springblade.system.service.IPortTerminalService; import org.springblade.system.service.IRegionService; +import org.springframework.dao.DataAccessException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -66,8 +68,9 @@ import java.util.regex.Pattern; * * @author Chill */ +@Slf4j @Service -@AllArgsConstructor +@RequiredArgsConstructor public class PortTerminalServiceImpl extends BaseServiceImpl implements IPortTerminalService { private static final String CATEGORY_PORT = "港口"; @@ -76,8 +79,12 @@ public class PortTerminalServiceImpl extends BaseServiceImpl T findCause(Throwable throwable, Class type) { + for (Throwable current = throwable; current != null; current = current.getCause()) { + if (type.isInstance(current)) { + return type.cast(current); + } + if (current.getCause() == current) { + break; + } + } + return null; + } + + private Throwable rootCause(Throwable throwable) { + Throwable root = throwable; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + return root; + } + + private String shortenMessage(String message) { + String value = trimToEmpty(message); + return value.length() > 200 ? value.substring(0, 200) + "..." : value; } @Override @@ -370,6 +441,26 @@ public class PortTerminalServiceImpl extends BaseServiceImpl cityList = regionService.list(Wrappers.lambdaQuery() - .eq(Region::getName, portTerminal.getCity()) - .eq(Region::getRegionLevel, 2)); - for (Region city : cityList) { - district = regionService.getOne(Wrappers.lambdaQuery() - .eq(Region::getParentCode, city.getCode()) - .eq(Region::getName, portTerminal.getDistrictName()), false); - if (Func.isNotEmpty(district)) { - break; - } - } - } - if (Func.isEmpty(district)) { - district = regionService.getOne(Wrappers.lambdaQuery() - .eq(Region::getName, portTerminal.getDistrictName()) - .eq(Region::getRegionLevel, 3), false); - } + district = findRegionByName(portTerminal.getDistrictName(), DISTRICT_REGION_LEVEL, city.getCode()); } if (Func.isEmpty(district)) { throw new ServiceException("请选择区县"); } - Region city = regionService.getById(district.getParentCode()); - if (Func.isEmpty(city)) { - throw new ServiceException("区县所属城市不存在"); - } - if (Func.isNotEmpty(portTerminal.getCity()) && !Objects.equals(portTerminal.getCity(), city.getName())) { - throw new ServiceException("区县与城市不匹配"); + if (!Objects.equals(district.getParentCode(), city.getCode())) { + throw new ServiceException("所属区县与所属城市不匹配"); } + + portTerminal.setProvinceCode(province.getCode()); + portTerminal.setProvinceName(province.getName()); portTerminal.setCity(city.getName()); portTerminal.setDistrictCode(district.getCode()); portTerminal.setDistrictName(district.getName()); portTerminal.setRegionCode(district.getCode()); } + /** + * 按名称查找区划,兼容「福建/福建省」「泉州/泉州市」等简写。 + */ + private Region findRegionByName(String name, int regionLevel, String parentCode) { + String normalized = trimToEmpty(name); + if (Func.isEmpty(normalized)) { + return null; + } + for (String candidate : regionNameCandidates(normalized)) { + LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery() + .eq(Region::getRegionLevel, regionLevel) + .eq(Region::getName, candidate); + if (Func.isNotEmpty(parentCode)) { + queryWrapper.eq(Region::getParentCode, parentCode); + } + Region region = regionService.getOne(queryWrapper, false); + if (Func.isNotEmpty(region)) { + return region; + } + } + return null; + } + + private List regionNameCandidates(String name) { + List candidates = new ArrayList<>(); + candidates.add(name); + if (name.endsWith("省") || name.endsWith("市") || name.endsWith("区") || name.endsWith("县") || name.endsWith("州")) { + String shortName = name.substring(0, name.length() - 1); + if (Func.isNotEmpty(shortName) && !candidates.contains(shortName)) { + candidates.add(shortName); + } + } else { + for (String suffix : List.of("省", "市", "区", "县", "州")) { + String fullName = name + suffix; + if (!candidates.contains(fullName)) { + candidates.add(fullName); + } + } + } + return candidates; + } + private void validate(PortTerminal portTerminal) { if (!CATEGORY_PORT.equals(portTerminal.getCategory()) && !CATEGORY_TERMINAL.equals(portTerminal.getCategory())) { throw new ServiceException("类型只能为港口或码头"); @@ -506,16 +630,17 @@ public class PortTerminalServiceImpl extends BaseServiceImpl option(contract.getId(), "contractName", contract.getContractName(), "projectId", contract.getProjectId())) .toList()); options.put("carriers", customerArchiveService.list().stream() - .filter(customer -> "承运商".equals(customer.getCustomerType()) && Integer.valueOf(1).equals(customer.getStatus())) + .filter(customer -> isCarrierCustomer(customer) && Integer.valueOf(1).equals(customer.getStatus())) .map(customer -> option(customer.getId(), "name", customer.getFullName(), null, null)) .toList()); - options.put("creators", new ArrayList<>()); + // 创建人选项取已有导入批次的创建人,保证筛选项与列表数据可对应。 + Set creatorIds = new LinkedHashSet<>(); + waybillImportBatchService.list(Wrappers.lambdaQuery() + .select(WaybillImportBatch::getCreateUser) + .eq(WaybillImportBatch::getIsDeleted, 0) + .isNotNull(WaybillImportBatch::getCreateUser)) + .stream() + .map(WaybillImportBatch::getCreateUser) + .filter(Objects::nonNull) + .forEach(creatorIds::add); + options.put("creators", creatorIds.stream() + .map(userId -> option(userId, "name", UserCache.getUserRealName(userId), null, null)) + .toList()); options.put("plans", transportPlanService.list().stream() .map(plan -> option(plan.getId(), "planName", plan.getPlanName(), "projectId", plan.getProjectId())) .toList()); @@ -372,6 +390,12 @@ public class WaybillController extends BladeController { return option; } + /** 客商类型支持多选(逗号分隔),包含「承运商」即视为承运商。 */ + private boolean isCarrierCustomer(CustomerArchive customer) { + String type = Func.toStr(customer.getCustomerType()).replace(",", ","); + return Func.toStrList(type).stream().anyMatch("承运商"::equals); + } + private static final class TextColumnStyleHandler implements SheetWriteHandler { private final int[] columnIndexes; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index a37f242a..1a585643 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -344,7 +344,16 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl entityPage = page(page, queryWrapper); List records = entityPage.getRecords().stream().map(this::toVO).toList(); Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); From a2e9e020b1d9548d9bd841b44427455fbd33cbb9 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Sun, 11 Oct 2026 23:55:02 +0800 Subject: [PATCH 66/81] =?UTF-8?q?mk=E8=B0=83=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transport/mk/MkProcessMessageService.java | 15 +--- .../transport/mk/MkProcessNodeHelper.java | 84 ++++++++++++++++++- .../CustomerArchivePublicProcessService.java | 17 +--- .../impl/CustomerArchiveServiceImpl.java | 6 +- 4 files changed, 88 insertions(+), 34 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java index 9443faa8..2e23f078 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessMessageService.java @@ -105,7 +105,7 @@ public class MkProcessMessageService { bizType, processId, loginName, JSON.toJSONString(processInfo)); if (isProcessFinished(processInfo)) { applyProcessResult(handler, bizType, bizIdText, processId, formValues, true); - } else if (isProcessRejected(processInfo)) { + } else if (MkProcessNodeHelper.isProcessRejected(processInfo)) { applyProcessResult(handler, bizType, bizIdText, processId, formValues, false); } else if (StringUtil.isNotBlank(bizIdText)) { try { @@ -123,19 +123,6 @@ public class MkProcessMessageService { return "30".equals(firstText(asMap(processInfo), "fdProcessStatus")); } - private static boolean isProcessRejected(Object processInfo) { - Map info = asMap(processInfo); - if (!"20".equals(firstText(info, "fdProcessStatus"))) { - return false; - } - Object historyHandlerInfos = asMap(info.get("fdTaskInfo")).get("historyHandlerInfos"); - if (!(historyHandlerInfos instanceof List historyList) || historyList.isEmpty()) { - return false; - } - Object lastHandler = historyList.get(historyList.size() - 1); - return "N6".equals(firstText(asMap(lastHandler), "nodeNumber")); - } - private void applyProcessResult(IMkProcessBizHandler handler, String bizType, String bizIdText, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java index c665e58f..6004bbb0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mk/MkProcessNodeHelper.java @@ -11,20 +11,47 @@ import java.util.Map; import java.util.stream.Collectors; /** - * 解析 MK 流程实例详情中的当前节点、当前处理人 + * 解析 MK 流程实例详情中的当前节点、当前处理人,以及流程是否驳回到起草节点。 */ public final class MkProcessNodeHelper { + /** + * 起草节点编号。审批节点编号随流程模板变化,不能用来判断驳回。 + */ + private static final String DRAFT_NODE_NUMBER = "N2"; + + private static final List CURRENT_NODE_KEYS = List.of("handlerInfos", "drafterInfos", "adminInfos"); + private MkProcessNodeHelper() { } + /** + * 流程未结束,且当前待办已经回到起草节点时,才视为审核驳回。 + * 中间审批节点通过后流程状态仍是 20,不能把历史节点编号当成驳回。 + */ + public static boolean isProcessRejected(Object processInfo) { + Map info = asMap(processInfo); + String processStatus = text(info.get("fdProcessStatus")); + if (!"11".equals(processStatus) && !"20".equals(processStatus)) { + return false; + } + return hasActiveDraftNode(asMap(info.get("fdTaskInfo"))); + } + public static String currentProcessor(Object processInfo) { return joinDistinct(extractTexts(asMap(processInfo).get("currentHandlers"), "fdName", "name"), "、"); } public static String currentNode(Object processInfo) { Map taskInfo = asMap(asMap(processInfo).get("fdTaskInfo")); - return joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、"); + List nodeNames = new ArrayList<>(); + for (String key : CURRENT_NODE_KEYS) { + nodeNames.addAll(extractActiveNodeNames(taskInfo.get(key))); + } + if (nodeNames.isEmpty()) { + nodeNames.addAll(extractTexts(taskInfo.get("handlerInfos"), "nodeName")); + } + return joinDistinct(nodeNames, "、"); } public static String text(String value, String fallback) { @@ -52,6 +79,59 @@ public final class MkProcessNodeHelper { } } + private static boolean hasActiveDraftNode(Map taskInfo) { + for (String key : CURRENT_NODE_KEYS) { + Object nodes = taskInfo.get(key); + if (!(nodes instanceof Collection collection)) { + continue; + } + for (Object item : collection) { + if (isActiveDraftNode(asMap(item))) { + return true; + } + } + } + return false; + } + + private static boolean isActiveDraftNode(Map node) { + if (!"20".equals(text(node.get("status")))) { + return false; + } + String nodeNumber = text(node.get("nodeNumber")); + if (Func.isEmpty(nodeNumber)) { + nodeNumber = text(node.get("nodeId")); + } + if (DRAFT_NODE_NUMBER.equals(nodeNumber)) { + return true; + } + String activityType = text(node.get("activityType")); + return "draft".equalsIgnoreCase(activityType) || "draftTask".equalsIgnoreCase(activityType); + } + + private static List extractActiveNodeNames(Object listObj) { + List result = new ArrayList<>(); + if (!(listObj instanceof Collection collection)) { + return result; + } + for (Object item : collection) { + Map node = asMap(item); + if (!"20".equals(text(node.get("status")))) { + continue; + } + String nodeName = text(node.get("nodeName")); + if (Func.isNotEmpty(nodeName)) { + result.add(nodeName); + } + } + return result; + } + + private static String text(Object value) { + String text = Func.toStr(value, "").trim(); + return "null".equalsIgnoreCase(text) ? "" : text; + } + private static List extractTexts(Object listObj, String... keys) { List result = new ArrayList<>(); if (!(listObj instanceof Collection collection) || keys == null) { diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchivePublicProcessService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchivePublicProcessService.java index 1c7aa6ae..81a00f48 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchivePublicProcessService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchivePublicProcessService.java @@ -12,12 +12,12 @@ import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.api.FR; import org.springblade.core.tool.utils.StringUtil; import org.springblade.process.feign.IBusinessProcessClient; +import org.springblade.transport.mk.MkProcessNodeHelper; import org.springblade.transport.service.ICustomerArchiveService; import org.springframework.stereotype.Service; import java.util.Collections; import java.util.HashMap; -import java.util.List; import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; @@ -97,7 +97,7 @@ public class CustomerArchivePublicProcessService { processId, loginName, JSON.toJSONString(processInfo)); if (isProcessFinished(processInfo)) { applyProcessResult(customerIdText, processId, formValues, true); - } else if (isProcessRejected(processInfo)) { + } else if (MkProcessNodeHelper.isProcessRejected(processInfo)) { applyProcessResult(customerIdText, processId, formValues, false); } else if (StringUtil.isNotBlank(customerIdText)) { try { @@ -115,19 +115,6 @@ public class CustomerArchivePublicProcessService { return "30".equals(firstText(asMap(processInfo), "fdProcessStatus")); } - private static boolean isProcessRejected(Object processInfo) { - Map info = asMap(processInfo); - if (!"20".equals(firstText(info, "fdProcessStatus"))) { - return false; - } - Object historyHandlerInfos = asMap(info.get("fdTaskInfo")).get("historyHandlerInfos"); - if (!(historyHandlerInfos instanceof List historyList) || historyList.isEmpty()) { - return false; - } - Object lastHandler = historyList.get(historyList.size() - 1); - return "N6".equals(firstText(asMap(lastHandler), "nodeNumber")); - } - private void applyProcessResult(String customerIdText, String processId, Map formValues, boolean approved) { String action = approved ? "审核通过" : "审核驳回"; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java index 81b9cb44..4b29ab75 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CustomerArchiveServiceImpl.java @@ -33,6 +33,7 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.transport.mk.MkProcessNodeHelper; import org.springblade.transport.support.TransportBusinessSupport; import org.springblade.core.tenant.annotation.TenantIgnore; import org.springblade.core.tool.jackson.JsonUtil; @@ -303,9 +304,8 @@ public class CustomerArchiveServiceImpl extends BaseServiceImpl taskInfo = asStringObjectMap(info.get("fdTaskInfo")); - String currentNode = joinDistinct(extractTexts(taskInfo.get("handlerInfos"), "nodeName"), "、"); + String currentProcessor = MkProcessNodeHelper.currentProcessor(processInfo); + String currentNode = MkProcessNodeHelper.currentNode(processInfo); if (Func.isEmpty(currentNode) && Func.isEmpty(currentProcessor)) { log.warn("同步客商当前节点跳过,未解析到节点名称或处理人 id={}", id); return false; From e19c3b100a235706fbf46c9ac887fac0df5389f5 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 12 Oct 2026 13:02:08 +0800 Subject: [PATCH 67/81] =?UTF-8?q?:sparkles:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=8B=AC=E7=AB=8B=E4=B8=BA=20blade=5Fmp=5Fus?= =?UTF-8?q?er=EF=BC=8C=E5=B9=B6=E5=85=BC=E5=AE=B9=E5=8F=B8=E6=9C=BA/?= =?UTF-8?q?=E8=BD=A6=E8=BE=86=20OCR=20=E5=AE=BD=E6=9D=BE=E6=97=A5=E6=9C=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../auth/config/BladeAuthConfiguration.java | 5 +- .../OAuth2UserInfoResponseAdvice.java | 20 +- .../auth/granter/WechatMiniTokenGranter.java | 31 +- .../auth/service/BladeUserDetailService.java | 73 ++++- .../org/springblade/auth/utils/TokenUtil.java | 40 ++- .../FlexibleLocalDateDeserializer.java | 148 ++++++++++ .../transport/pojo/entity/Driver.java | 13 +- .../pojo/entity/TransportVehicle.java | 9 + .../springblade/system/cache/MpUserCache.java | 114 ++++++++ .../system/cache/UserDisplayCache.java | 74 +++++ .../system/feign/IMpUserClient.java | 121 ++++++++ .../system/pojo/entity/MpUser.java | 131 +++++++++ .../system/pojo/entity/MpUserInfo.java | 69 +++++ .../system/pojo/enums/MpUserIdentity.java | 148 ++++++++++ .../springblade/system/utils/MpUserAuth.java | 65 +++++ .../system/controller/UserController.java | 42 +++ .../controller/UserPhoneController.java | 2 +- .../system/feign/MpUserClient.java | 110 +++++++ .../system/mapper/MpUserMapper.java | 34 +++ .../system/service/IMpUserService.java | 81 ++++++ .../service/impl/MpUserServiceImpl.java | 272 ++++++++++++++++++ .../service/impl/UserPhoneServiceImpl.java | 90 +++--- .../service/impl/CertAuditServiceImpl.java | 7 +- .../service/impl/DriverAppServiceImpl.java | 11 +- .../service/impl/DriverCertServiceImpl.java | 2 +- .../service/impl/DriverServiceImpl.java | 109 ++++--- .../impl/DriverWaybillServiceImpl.java | 79 ++++- .../impl/ExceptionDisposalServiceImpl.java | 12 +- .../impl/ManageWaybillServiceImpl.java | 9 +- .../wrapper/TransportCertRecordWrapper.java | 8 +- .../impl/DriverAppServiceImplTest.java | 21 +- .../impl/ExceptionDisposalReportGateTest.java | 6 +- doc/sql/transport/blade_mp_user_20261012.sql | 45 +++ .../blade_mp_user_migrate_driver_20261012.sql | 61 ++++ 34 files changed, 1892 insertions(+), 170 deletions(-) create mode 100644 blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/mapper/MpUserMapper.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java create mode 100644 doc/sql/transport/blade_mp_user_20261012.sql create mode 100644 doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql diff --git a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java index e75bdb50..f7e6386a 100644 --- a/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java +++ b/blade-auth/src/main/java/org/springblade/auth/config/BladeAuthConfiguration.java @@ -51,6 +51,7 @@ import org.springblade.core.oauth2.service.OAuth2UserService; import org.springblade.system.feign.IAuthLockClient; import org.springblade.system.feign.IAuthLogClient; import org.springblade.core.tenant.BladeTenantProperties; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.IUserClient; import org.springframework.boot.autoconfigure.AutoConfigureBefore; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -117,8 +118,8 @@ public class BladeAuthConfiguration { } @Bean - public OAuth2UserService oAuth2UserService(IUserClient userClient) { - return new BladeUserDetailService(userClient); + public OAuth2UserService oAuth2UserService(IUserClient userClient, IMpUserClient mpUserClient) { + return new BladeUserDetailService(userClient, mpUserClient); } @Primary diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java index 8e207ad1..3c190cc4 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java @@ -30,9 +30,11 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.oauth2.endpoint.OAuth2TokenEndPoint; import org.springblade.core.secure.BladeUser; import org.springblade.core.tool.api.R; +import org.springblade.core.tool.support.Kv; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.feign.ISysClient; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; @@ -72,10 +74,26 @@ public class OAuth2UserInfoResponseAdvice implements ResponseBodyAdvice if (userInfo == null) { return body; } - userInfo.setPermission(loadPermission(bladeUser.getRoleId())); + if (isMpUser(bladeUser)) { + // 小程序用户:按身份直接生成权限,不走系统角色菜单 + userInfo.setPermission(MpUserIdentity.toPermissions(bladeUser.getRoleName())); + } else { + userInfo.setPermission(loadPermission(bladeUser.getRoleId())); + } return userInfo; } + private boolean isMpUser(BladeUser bladeUser) { + Kv detail = bladeUser.getDetail(); + if (detail != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(detail.get("type")))) { + return true; + } + // 兼容:无系统角色且 roleName 为小程序身份 + return Func.isBlank(bladeUser.getRoleId()) + && Func.isNotBlank(bladeUser.getRoleName()) + && !MpUserIdentity.split(bladeUser.getRoleName()).isEmpty(); + } + private List loadPermission(String roleId) { if (Func.isBlank(roleId)) { return Collections.emptyList(); diff --git a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java index 1bdd67bf..33de5291 100644 --- a/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java +++ b/blade-auth/src/main/java/org/springblade/auth/granter/WechatMiniTokenGranter.java @@ -2,10 +2,12 @@ package org.springblade.auth.granter; import jakarta.servlet.http.HttpServletRequest; import lombok.extern.slf4j.Slf4j; +import org.springblade.auth.utils.TokenUtil; import org.springblade.core.oauth2.exception.UserInvalidException; import org.springblade.core.oauth2.granter.AbstractTokenGranter; import org.springblade.core.oauth2.handler.PasswordHandler; import org.springblade.core.oauth2.provider.OAuth2Request; +import org.springblade.core.oauth2.service.OAuth2Client; import org.springblade.core.oauth2.service.OAuth2ClientService; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.OAuth2UserService; @@ -13,7 +15,8 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.core.tool.utils.WebUtil; -import org.springblade.system.feign.IUserClient; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.thirdparty.wechat.constant.WechatMiniConstant; import org.springblade.thirdparty.wechat.exception.WechatMiniException; import org.springblade.thirdparty.wechat.pojo.vo.WechatPhoneVO; @@ -25,7 +28,8 @@ import org.springframework.stereotype.Component; * 微信小程序手机号一键登录。 *

* grant_type=wechat_applet,参数:loginCode(wx.login)、phoneCode(getPhoneNumber)。 - * 流程:换 openid + 手机号 → 按手机号查用户(不存在则拒绝)→ 记录 openid → 发令牌(与密码登录一致)。 + * 流程:换 openid + 手机号 → 按手机号查小程序用户(blade_mp_user,不存在则拒绝) + * → 记录 openid → 按身份发令牌(不依赖系统角色)。 */ @Slf4j @Component @@ -33,17 +37,17 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { private final OAuth2UserService userService; private final IWechatMiniService wechatMiniService; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; public WechatMiniTokenGranter(OAuth2ClientService clientService, OAuth2UserService userService, PasswordHandler passwordHandler, IWechatMiniService wechatMiniService, - IUserClient userClient) { + IMpUserClient mpUserClient) { super(clientService, userService, passwordHandler); this.userService = userService; this.wechatMiniService = wechatMiniService; - this.userClient = userClient; + this.mpUserClient = mpUserClient; } @Override @@ -54,7 +58,7 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { @Override public OAuth2User user(OAuth2Request request) { // 先校验客户端是否允许该授权类型,避免先调微信再因客户端配置失败 - var oauthClient = client(request); + OAuth2Client oauthClient = client(request); HttpServletRequest httpRequest = WebUtil.getRequest(); String loginCode = httpRequest.getParameter("loginCode"); @@ -78,16 +82,21 @@ public class WechatMiniTokenGranter extends AbstractTokenGranter { throw new UserInvalidException("未获取到微信手机号"); } - OAuth2User user = userService.loadByPhone(phone, request); - if (!userService.validateUser(user)) { + R mpUserResult = mpUserClient.userInfoByPhone(request.getTenantId(), phone); + if (mpUserResult == null || !mpUserResult.isSuccess() || mpUserResult.getData() == null + || mpUserResult.getData().getUser() == null) { throw new UserInvalidException("用户不存在,无法登录"); } - R bindResult = userClient.bindWxMiniOpenId( + OAuth2User user = TokenUtil.convertMpUser(mpUserResult.getData()); + if (!userService.validateUser(user)) { + throw new UserInvalidException("用户身份无效,无法登录"); + } + + R bindResult = mpUserClient.bindOpenId( request.getTenantId(), Func.toLong(user.getUserId()), - session.getOpenid(), - phone + session.getOpenid() ); if (bindResult == null || !bindResult.isSuccess()) { String msg = bindResult != null ? bindResult.getMsg() : "绑定 openid 失败"; diff --git a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java index 1efee4d4..fa5cbc46 100644 --- a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java +++ b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java @@ -18,9 +18,6 @@ * 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.auth.service; @@ -35,8 +32,11 @@ import org.springblade.core.oauth2.utils.OAuth2ExceptionUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.IUserClient; +import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.entity.UserInfo; +import org.springblade.system.pojo.enums.MpUserIdentity; import org.springblade.system.pojo.enums.UserType; import java.util.Optional; @@ -49,6 +49,7 @@ import java.util.Optional; @RequiredArgsConstructor public class BladeUserDetailService implements OAuth2UserService { private final IUserClient userClient; + private final IMpUserClient mpUserClient; @Override public OAuth2User loadByUserId(String userId, OAuth2Request request) { @@ -57,13 +58,29 @@ public class BladeUserDetailService implements OAuth2UserService { .filter(s -> !StringUtil.isBlank(s)) .orElse(UserType.WEB.getName()); - // 获取用户信息 - R result = userClient.userInfo(Func.toLong(userId), userType); - if (!result.isSuccess()) { - OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + // 小程序刷新/鉴权:直接查 blade_mp_user + if (MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(userType)) { + OAuth2User mpUser = loadMpUserById(userId); + if (mpUser == null) { + OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + } + return mpUser; } - // 构建oauth2用户信息 - return TokenUtil.convertUser(result.getData(), request); + + // 系统用户 + R result = userClient.userInfo(Func.toLong(userId), userType); + if (result != null && result.isSuccess() && result.getData() != null + && result.getData().getUser() != null) { + return TokenUtil.convertUser(result.getData(), request); + } + + // 兼容:未带 userType=mp 时,回落查询小程序用户(令牌刷新) + OAuth2User mpUser = loadMpUserById(userId); + if (mpUser != null) { + return mpUser; + } + OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); + return null; } @Override @@ -74,6 +91,15 @@ public class BladeUserDetailService implements OAuth2UserService { .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); + // 小程序用户:按账号查 blade_mp_user + if (MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(userType)) { + R mpResult = mpUserClient.userInfoByAccount(tenantId, username); + if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) { + return null; + } + return TokenUtil.convertMpUser(mpResult.getData()); + } + // 获取用户信息 R result = userClient.userInfo(tenantId, username, userType); if (!result.isSuccess()) { @@ -91,6 +117,11 @@ public class BladeUserDetailService implements OAuth2UserService { .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); + // 小程序用户类型:仅查 blade_mp_user + if (MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(userType)) { + return loadMpUserByPhone(tenantId, phone); + } + // 获取用户信息 R result = userClient.userInfoByPhone(tenantId, phone, userType); if (!result.isSuccess()) { @@ -108,4 +139,28 @@ public class BladeUserDetailService implements OAuth2UserService { .isPresent(); // 如果上述条件都满足,则返回true,否则返回false } + private OAuth2User loadMpUserById(String userId) { + if (Func.isBlank(userId)) { + return null; + } + try { + R result = mpUserClient.userInfoById(Func.toLong(userId)); + if (result != null && result.isSuccess() && result.getData() != null + && result.getData().getUser() != null) { + return TokenUtil.convertMpUser(result.getData()); + } + } catch (Exception exception) { + // 非小程序用户场景忽略,继续走系统用户 + } + return null; + } + + private OAuth2User loadMpUserByPhone(String tenantId, String phone) { + R result = mpUserClient.userInfoByPhone(tenantId, phone); + if (result == null || !result.isSuccess() || result.getData() == null) { + return null; + } + return TokenUtil.convertMpUser(result.getData()); + } + } diff --git a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java index b39311da..35a61d95 100644 --- a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java +++ b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java @@ -28,12 +28,14 @@ package org.springblade.auth.utils; import org.springblade.core.oauth2.provider.OAuth2Request; import org.springblade.core.oauth2.service.OAuth2User; import org.springblade.core.oauth2.service.impl.OAuth2UserDetail; +import org.springblade.core.tool.support.Kv; import org.springblade.core.tool.utils.Func; import org.springblade.system.cache.SysCache; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.entity.UserInfo; - -import java.util.Collections; +import org.springblade.system.pojo.enums.MpUserIdentity; /** * 认证工具类 @@ -75,6 +77,7 @@ public class TokenUtil { userDetail.setRealName(user.getRealName()); userDetail.setAccount(user.getAccount()); userDetail.setPassword(user.getPassword()); + userDetail.setPhone(user.getPhone()); userDetail.setDeptId(user.getDeptId()); userDetail.setPostId(user.getPostId()); userDetail.setRoleId(user.getRoleId()); @@ -85,4 +88,37 @@ public class TokenUtil { return userDetail; } + /** + * 小程序用户转换为OAuth2标准用户(身份直接写入 authorities,不走系统角色) + * + * @param userInfo 小程序用户信息 + * @return OAuth2User + */ + public static OAuth2User convertMpUser(MpUserInfo userInfo) { + if (userInfo == null || userInfo.getUser() == null) { + return null; + } + MpUser user = userInfo.getUser(); + OAuth2UserDetail userDetail = new OAuth2UserDetail(); + userDetail.setUserId(String.valueOf(user.getId())); + userDetail.setTenantId(user.getTenantId()); + userDetail.setName(user.getName()); + userDetail.setRealName(user.getRealName()); + userDetail.setAccount(user.getAccount()); + userDetail.setPassword(user.getPassword()); + userDetail.setPhone(user.getPhone()); + userDetail.setDeptId(user.getDeptId()); + userDetail.setPostId(user.getPostId()); + // 小程序用户不绑定系统角色,roleId 留空;身份放入 roleName/authorities + userDetail.setRoleId(""); + userDetail.setRoleName(Func.join(userInfo.getIdentities())); + userDetail.setAvatar(user.getAvatar()); + userDetail.setAuthorities(userInfo.getIdentities()); + userDetail.setPermissions(userInfo.getPermissions()); + userDetail.setDetail(userInfo.getDetail() == null + ? Kv.create().set("type", MpUserIdentity.DETAIL_TYPE) + : userInfo.getDetail()); + return userDetail; + } + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java new file mode 100644 index 00000000..9d16ee83 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/jackson/FlexibleLocalDateDeserializer.java @@ -0,0 +1,148 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.jackson; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; + +import java.io.IOException; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * LocalDate 宽松反序列化,对齐 OCR / 司机侧日期归一化规则。 + *

+ * 兼容写法: + *

    + *
  • ISO:{@code yyyy-MM-dd}
  • + *
  • 紧凑:{@code yyyyMMdd}
  • + *
  • 点分 / 斜杠 / 年月日:{@code 2018.3.9}、{@code 2026/03/17}、{@code 2030年05月20日}
  • + *
  • 任意非数字分隔:年与月、月与日之间只要不是数字即可
  • + *
  • 区间文本:按字段名取起期(first)或止期(last)
  • + *
+ * + * @author Chill + */ +public class FlexibleLocalDateDeserializer extends JsonDeserializer { + + /** + * 4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(与 OcrFieldSupport 一致) + */ + private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})"); + + private static final Pattern COMPACT_PATTERN = Pattern.compile("(\\d{4})(\\d{2})(\\d{2})"); + + @Override + public LocalDate deserialize(JsonParser parser, DeserializationContext context) throws IOException { + String text = parser.getValueAsString(); + if (text == null) { + return null; + } + String value = text.trim(); + if (value.isEmpty()) { + return null; + } + // 「长期」等非日期文案:按空值处理,避免把无法解析的字符串打成 400 + if (value.contains("长期") || value.contains("永久")) { + return null; + } + boolean preferLast = isEndDateField(parser.getCurrentName()); + LocalDate parsed = parseFlexible(value, preferLast); + if (parsed != null) { + return parsed; + } + return (LocalDate) context.handleWeirdStringValue(LocalDate.class, value, + "期望日期格式 yyyy-MM-dd / yyyyMMdd / yyyy年M月d日 等 OCR 常见写法"); + } + + /** + * 解析宽松日期文本;区间时按 preferLast 取首段或末段。 + * + * @param value 原始文本 + * @param preferLast true 取止期,false 取起期 + * @return 合法 LocalDate;无法识别时返回 null + */ + public static LocalDate parseFlexible(String value, boolean preferLast) { + if (value == null) { + return null; + } + String text = value.trim(); + if (text.isEmpty()) { + return null; + } + List candidates = matchDates(text); + if (!candidates.isEmpty()) { + return preferLast ? candidates.get(candidates.size() - 1) : candidates.get(0); + } + // 去掉所有非数字后恰为 8 位时按 yyyyMMdd 处理(如 20260317、2026-0317 混杂) + String compact = text.replaceAll("\\D", ""); + if (compact.length() == 8) { + return toLocalDate(compact.substring(0, 4), compact.substring(4, 6), compact.substring(6, 8)); + } + Matcher compactMatcher = COMPACT_PATTERN.matcher(text); + if (compactMatcher.find()) { + return toLocalDate(compactMatcher.group(1), compactMatcher.group(2), compactMatcher.group(3)); + } + return null; + } + + private static List matchDates(String text) { + List dates = new ArrayList<>(); + Matcher matcher = DATE_PATTERN.matcher(text); + while (matcher.find()) { + LocalDate date = toLocalDate(matcher.group(1), matcher.group(2), matcher.group(3)); + if (date != null) { + dates.add(date); + } + } + return dates; + } + + private static LocalDate toLocalDate(String yearText, String monthText, String dayText) { + try { + int year = Integer.parseInt(yearText); + int month = Integer.parseInt(monthText); + int day = Integer.parseInt(dayText); + return LocalDate.of(year, month, day); + } catch (NumberFormatException | DateTimeException exception) { + return null; + } + } + + /** + * 止期字段名通常以 End / EndDate 结尾,区间文本应取最后一段日期。 + */ + private static boolean isEndDateField(String fieldName) { + if (fieldName == null || fieldName.isEmpty()) { + return false; + } + return fieldName.endsWith("End") || fieldName.endsWith("EndDate") + || fieldName.contains("EndDate") || fieldName.contains("ValidEnd"); + } + +} 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 d1c91951..fc7c3ce8 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 @@ -29,12 +29,14 @@ 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.JsonDeserialize; 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 org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; @@ -67,6 +69,7 @@ public class Driver extends TenantEntity { * 出生年月 */ @Schema(description = "出生年月") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate birthday; /** * 性别 @@ -118,11 +121,13 @@ public class Driver extends TenantEntity { * 身份证有效期起 */ @Schema(description = "身份证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate idCardValidStart; /** * 身份证有效期止 */ @Schema(description = "身份证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate idCardValidEnd; /** * 身份证长期有效 @@ -148,11 +153,13 @@ public class Driver extends TenantEntity { * 驾驶证有效期起 */ @Schema(description = "驾驶证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseStartDate; /** * 驾驶证有效期止 */ @Schema(description = "驾驶证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseEndDate; /** * 驾驶证长期有效 @@ -183,6 +190,7 @@ public class Driver extends TenantEntity { * 从业资格证有效期止 */ @Schema(description = "从业资格证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate qualificationEndDate; /** * 从业资格证长期有效 @@ -210,9 +218,10 @@ public class Driver extends TenantEntity { @Schema(description = "手机号") private String mobile; /** - * 关联系统用户ID + * 关联小程序用户ID(blade_mp_user.id) */ - @Schema(description = "关联系统用户ID") + @Schema(description = "关联小程序用户ID") + @JsonSerialize(using = ToStringSerializer.class) private Long userId; /** * 与联系人关系 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 1fe16eb9..71f5958a 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 @@ -27,12 +27,14 @@ package org.springblade.transport.pojo.entity; import com.baomidou.mybatisplus.annotation.TableName; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; 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 org.springblade.transport.jackson.FlexibleLocalDateDeserializer; import java.io.Serial; import java.time.LocalDate; @@ -125,6 +127,7 @@ public class TransportVehicle extends TenantEntity { * 强制报废日期 */ @Schema(description = "强制报废日期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate compulsoryScrapDate; /** * 强制报废长期有效 @@ -145,11 +148,13 @@ public class TransportVehicle extends TenantEntity { * 行驶证有效期起 */ @Schema(description = "行驶证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseStartDate; /** * 行驶证有效期止 */ @Schema(description = "行驶证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate drivingLicenseEndDate; /** * 行驶证长期有效 @@ -185,11 +190,13 @@ public class TransportVehicle extends TenantEntity { * 道路运输证有效期起 */ @Schema(description = "道路运输证有效期起") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate roadTransportCertStartDate; /** * 道路运输证有效期止 */ @Schema(description = "道路运输证有效期止") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate roadTransportCertEndDate; /** * 道路运输证长期有效 @@ -205,6 +212,7 @@ public class TransportVehicle extends TenantEntity { * 道路运输年审有效期 */ @Schema(description = "道路运输年审有效期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate annualReviewEndDate; /** * 道路运输年审长期有效 @@ -220,6 +228,7 @@ public class TransportVehicle extends TenantEntity { * 机动车登记日期 */ @Schema(description = "机动车登记日期") + @JsonDeserialize(using = FlexibleLocalDateDeserializer.class) private LocalDate registrationDate; /** * 机动车登记本图片 diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java new file mode 100644 index 00000000..ccdff3cc --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/MpUserCache.java @@ -0,0 +1,114 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.cache; + +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.cache.utils.CacheUtil; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.SpringUtil; +import org.springblade.core.tool.utils.StringPool; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; + +/** + * 小程序用户缓存 + * + * @author Chill + */ +@Slf4j +public class MpUserCache { + + public static final String MP_USER_CACHE = "blade:mp_user"; + private static final String MP_USER_CACHE_ID = "mp-user:id:"; + + private static IMpUserClient mpUserClient; + + private static IMpUserClient getMpUserClient() { + if (mpUserClient == null) { + mpUserClient = SpringUtil.getBean(IMpUserClient.class); + } + return mpUserClient; + } + + /** + * 按主键获取小程序用户 + */ + public static MpUser getUser(Long userId) { + if (userId == null) { + return null; + } + try { + return CacheUtil.get(MP_USER_CACHE, MP_USER_CACHE_ID, userId, () -> loadUserById(userId)); + } catch (RuntimeException exception) { + log.warn("小程序用户缓存读取失败,改为直连查询, userId={}", userId, exception); + return loadUserById(userId); + } + } + + /** + * 获取小程序用户真实姓名(优先 realName → name → account → id) + */ + public static String getUserRealName(Long userId) { + if (userId == null) { + return StringPool.EMPTY; + } + try { + MpUser user = getUser(userId); + if (user == null) { + return Func.toStr(userId); + } + if (StringUtil.isNotBlank(user.getRealName())) { + return user.getRealName(); + } + if (StringUtil.isNotBlank(user.getName())) { + return user.getName(); + } + if (StringUtil.isNotBlank(user.getAccount())) { + return user.getAccount(); + } + return Func.toStr(userId); + } catch (RuntimeException exception) { + log.warn("翻译小程序用户姓名失败,回退为用户ID, userId={}", userId, exception); + return Func.toStr(userId); + } + } + + /** + * 清理小程序用户缓存 + */ + public static void clear() { + CacheUtil.clear(MP_USER_CACHE); + CacheUtil.clear(MP_USER_CACHE, Boolean.FALSE); + } + + private static MpUser loadUserById(Long userId) { + R result = getMpUserClient().userById(userId); + if (result == null || !result.isSuccess()) { + return null; + } + return result.getData(); + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.java new file mode 100644 index 00000000..0a63d35c --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/cache/UserDisplayCache.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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.cache; + +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringPool; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.User; + +/** + * 用户展示名解析(系统用户优先,未命中再查小程序用户) + *

+ * 小程序链路写入的审计人/提交人 ID 落在 blade_mp_user,需用本工具翻译姓名。 + * + * @author Chill + */ +public final class UserDisplayCache { + + private UserDisplayCache() { + } + + /** + * 翻译用户姓名:先 blade_user,再 blade_mp_user,都没有则回退 ID + */ + public static String getUserRealName(Long userId) { + if (userId == null) { + return StringPool.EMPTY; + } + User user = UserCache.getUser(userId); + if (user != null && Func.isNotEmpty(user.getId())) { + return resolveName(user.getRealName(), user.getName(), user.getAccount(), userId); + } + MpUser mpUser = MpUserCache.getUser(userId); + if (mpUser != null && Func.isNotEmpty(mpUser.getId())) { + return resolveName(mpUser.getRealName(), mpUser.getName(), mpUser.getAccount(), userId); + } + return Func.toStr(userId); + } + + private static String resolveName(String realName, String name, String account, Long userId) { + if (StringUtil.isNotBlank(realName)) { + return realName; + } + if (StringUtil.isNotBlank(name)) { + return name; + } + if (StringUtil.isNotBlank(account)) { + return account; + } + return Func.toStr(userId); + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java new file mode 100644 index 00000000..0a0a6e4b --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/feign/IMpUserClient.java @@ -0,0 +1,121 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.feign; + +import org.springblade.core.launch.constant.AppConstant; +import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +/** + * 小程序用户 Feign 接口 + * + * @author Chill + */ +@FeignClient( + value = AppConstant.APPLICATION_SYSTEM_NAME +) +public interface IMpUserClient { + + String API_PREFIX = "/feign/client/mp-user"; + String USER_BY_ID = API_PREFIX + "/user-by-id"; + String USER_BY_PHONE = API_PREFIX + "/user-by-phone"; + String USER_BY_ACCOUNT = API_PREFIX + "/user-by-account"; + String USER_INFO_BY_ID = API_PREFIX + "/info-by-id"; + String USER_INFO_BY_PHONE = API_PREFIX + "/info-by-phone"; + String USER_INFO_BY_ACCOUNT = API_PREFIX + "/info-by-account"; + String SAVE_USER = API_PREFIX + "/save-user"; + String UPDATE_USER = API_PREFIX + "/update-user"; + String BIND_OPENID = API_PREFIX + "/bind-openid"; + String SET_PASSWORD = API_PREFIX + "/set-password"; + + /** + * 按主键查询 + */ + @GetMapping(USER_BY_ID) + R userById(@RequestParam("userId") Long userId); + + /** + * 按租户 + 手机号查询 + */ + @GetMapping(USER_BY_PHONE) + R userByPhone(@RequestParam("tenantId") String tenantId, @RequestParam("phone") String phone); + + /** + * 按租户 + 账号查询 + */ + @GetMapping(USER_BY_ACCOUNT) + R userByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + + /** + * 按主键查询登录信息 + */ + @GetMapping(USER_INFO_BY_ID) + R userInfoById(@RequestParam("userId") Long userId); + + /** + * 按租户 + 手机号查询登录信息 + */ + @GetMapping(USER_INFO_BY_PHONE) + R userInfoByPhone(@RequestParam("tenantId") String tenantId, @RequestParam("phone") String phone); + + /** + * 按租户 + 账号查询登录信息 + */ + @GetMapping(USER_INFO_BY_ACCOUNT) + R userInfoByAccount(@RequestParam("tenantId") String tenantId, @RequestParam("account") String account); + + /** + * 新增小程序用户 + */ + @PostMapping(SAVE_USER) + R saveUser(@RequestBody MpUser user); + + /** + * 更新小程序用户 + */ + @PostMapping(UPDATE_USER) + R updateUser(@RequestBody MpUser user); + + /** + * 绑定/更新微信小程序 openid + */ + @PostMapping(BIND_OPENID) + R bindOpenId(@RequestParam("tenantId") String tenantId, + @RequestParam("userId") Long userId, + @RequestParam("openid") String openid); + + /** + * 设置小程序用户密码 + */ + @PostMapping(SET_PASSWORD) + R setPassword(@RequestParam("userId") Long userId, + @RequestParam("password") String password, + @RequestParam("password2") String password2); + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java new file mode 100644 index 00000000..6d613811 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUser.java @@ -0,0 +1,131 @@ +/** + * 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.system.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; +import org.springblade.core.tool.jackson.Sensitive; +import org.springblade.core.tool.sensitive.SensitiveType; + +import java.io.Serial; +import java.util.Date; + +/** + * 小程序用户实体(仅用于小程序登录,与 blade_user 分离) + * + * @author Chill + */ +@Data +@TableName("blade_mp_user") +@EqualsAndHashCode(callSuper = true) +@Schema(description = "小程序用户") +public class MpUser extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户编号 + */ + @Schema(description = "用户编号") + private String code; + /** + * 账号 + */ + @Schema(description = "账号") + private String account; + /** + * 密码 + */ + @Schema(description = "密码") + private String password; + /** + * 昵称 + */ + @Schema(description = "昵称") + private String name; + /** + * 真名 + */ + @Schema(description = "真名") + private String realName; + /** + * 头像 + */ + @Schema(description = "头像") + private String avatar; + /** + * 邮箱 + */ + @Sensitive(type = SensitiveType.EMAIL) + @Schema(description = "邮箱") + private String email; + /** + * 手机 + */ + @Sensitive(type = SensitiveType.MOBILE) + @Schema(description = "手机") + private String phone; + /** + * 生日 + */ + @Schema(description = "生日") + private Date birthday; + /** + * 性别 + */ + @Schema(description = "性别") + private Integer sex; + /** + * 微信小程序 openid + */ + @Schema(description = "微信小程序openid") + private String openid; + /** + * 小程序身份,逗号分隔:driver,admin + */ + @Schema(description = "小程序身份") + private String identities; + /** + * 部门id + */ + @Schema(description = "部门id") + private String deptId; + /** + * 岗位id + */ + @Schema(description = "岗位id") + private String postId; + /** + * 备注 + */ + @Schema(description = "备注") + private String remark; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java new file mode 100644 index 00000000..46bfb423 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/entity/MpUserInfo.java @@ -0,0 +1,69 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springblade.core.tool.support.Kv; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 小程序用户登录信息 + * + * @author Chill + */ +@Data +@Schema(description = "小程序用户信息") +public class MpUserInfo implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** + * 用户基础信息 + */ + @Schema(description = "小程序用户") + private MpUser user; + + /** + * 身份编码集合 + */ + @Schema(description = "身份集合") + private List identities; + + /** + * 权限标识集合 + */ + @Schema(description = "权限集合") + private List permissions; + + /** + * 拓展信息 + */ + @Schema(description = "拓展信息") + private Kv detail; + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java new file mode 100644 index 00000000..12c88b0f --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/enums/MpUserIdentity.java @@ -0,0 +1,148 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.pojo.enums; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 小程序用户身份枚举(可叠加,后续可扩展) + * + * @author Chill + */ +@Getter +@AllArgsConstructor +public enum MpUserIdentity { + + /** + * 司机 + */ + DRIVER("driver", "司机"), + + /** + * 管理员(调度端) + */ + ADMIN("admin", "管理员"), + ; + + /** + * 身份编码 + */ + private final String code; + /** + * 身份名称 + */ + private final String label; + + /** + * 管理员身份对应的小程序权限码(兼容原 mp_auth) + */ + public static final String PERMISSION_MP_AUTH = "mp_auth"; + + /** + * detail 中标记小程序用户的 type 值 + */ + public static final String DETAIL_TYPE = "mp"; + + /** + * 解析身份编码 + */ + public static MpUserIdentity of(String code) { + if (Func.isBlank(code)) { + return null; + } + String normalized = code.trim().toLowerCase(Locale.ROOT); + return Arrays.stream(values()) + .filter(item -> item.code.equals(normalized)) + .findFirst() + .orElse(null); + } + + /** + * 拆分身份串为编码列表(去重、保序) + */ + public static List split(String identities) { + if (Func.isBlank(identities)) { + return Collections.emptyList(); + } + Set identitySet = new LinkedHashSet<>(); + for (String part : Func.toStrList(identities)) { + if (Func.isBlank(part)) { + continue; + } + String code = part.trim().toLowerCase(Locale.ROOT); + if (of(code) != null || StringUtil.isNotBlank(code)) { + identitySet.add(code); + } + } + return new ArrayList<>(identitySet); + } + + /** + * 合并身份,确保包含指定身份 + */ + public static String merge(String identities, String... appendCodes) { + Set identitySet = new LinkedHashSet<>(split(identities)); + if (appendCodes != null) { + for (String code : appendCodes) { + if (Func.isNotBlank(code)) { + identitySet.add(code.trim().toLowerCase(Locale.ROOT)); + } + } + } + return identitySet.stream().collect(Collectors.joining(",")); + } + + /** + * 是否包含指定身份 + */ + public static boolean has(String identities, MpUserIdentity identity) { + if (identity == null) { + return false; + } + return split(identities).contains(identity.getCode()); + } + + /** + * 根据身份生成小程序权限码(不再依赖系统角色菜单) + */ + public static List toPermissions(String identities) { + List permissions = new ArrayList<>(); + if (has(identities, ADMIN)) { + permissions.add(PERMISSION_MP_AUTH); + } + return permissions; + } + +} diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java new file mode 100644 index 00000000..b1b54e77 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java @@ -0,0 +1,65 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.utils; + +import org.springblade.core.secure.BladeUser; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.support.Kv; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.enums.MpUserIdentity; + +/** + * 小程序登录态识别工具 + * + * @author Chill + */ +public final class MpUserAuth { + + private MpUserAuth() { + } + + /** + * 当前登录用户是否为小程序用户 + */ + public static boolean isMpUser() { + return isMpUser(AuthUtil.getUser()); + } + + /** + * 判断指定登录用户是否为小程序用户 + */ + public static boolean isMpUser(BladeUser bladeUser) { + if (bladeUser == null) { + return false; + } + Kv detail = bladeUser.getDetail(); + if (detail != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(detail.get("type")))) { + return true; + } + // 兼容:无系统角色且 roleName 为小程序身份 + return Func.isBlank(bladeUser.getRoleId()) + && Func.isNotBlank(bladeUser.getRoleName()) + && !MpUserIdentity.split(bladeUser.getRoleName()).isEmpty(); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java index 697e5d9d..34a468ad 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserController.java @@ -53,11 +53,14 @@ import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.StringPool; import org.springblade.system.excel.UserExcel; import org.springblade.system.excel.UserImporter; +import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.vo.OaPersonSyncPageVO; import org.springblade.system.pojo.vo.UserVO; import org.springblade.system.service.IOASyncService; +import org.springblade.system.service.IMpUserService; import org.springblade.system.service.IUserService; +import org.springblade.system.utils.MpUserAuth; import org.springblade.system.wrapper.UserWrapper; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -79,6 +82,7 @@ import java.util.Map; public class UserController { private final IUserService userService; + private final IMpUserService mpUserService; private final IOASyncService oaSyncService; /** @@ -101,6 +105,10 @@ public class UserController { @Operation(summary = "查看详情", description = "传入id") @GetMapping("/info") public R info(BladeUser user) { + if (MpUserAuth.isMpUser(user)) { + MpUser mpUser = mpUserService.getById(user.getUserId()); + return R.data(toMpUserVO(mpUser)); + } User detail = userService.getById(user.getUserId()); return R.data(UserWrapper.build().entityVO(detail)); } @@ -252,6 +260,7 @@ public class UserController { * 当前用户设置/重置登录密码(小程序首次设密、短信验证后改密) *

* 对外路径:/blade-system/user/password ;网关别名 /blade-user/password 亦可到达。 + * 小程序用户写入 blade_mp_user,系统用户仍写入 blade_user。 */ @PostMapping("/password") @ApiOperationSupport(order = 10) @@ -259,6 +268,9 @@ public class UserController { public R password(BladeUser user, @Parameter(description = "新密码", required = true) @RequestParam String password, @Parameter(description = "确认密码", required = true) @RequestParam String password2) { + if (MpUserAuth.isMpUser(user)) { + return R.status(mpUserService.setPassword(user.getUserId(), password, password2)); + } return R.status(userService.setPassword(user.getUserId(), password, password2)); } @@ -438,4 +450,34 @@ public class UserController { return R.data(list); } + /** + * 小程序用户转 UserVO(兼容原 /user/info 响应结构) + */ + private UserVO toMpUserVO(MpUser mpUser) { + if (mpUser == null) { + return null; + } + UserVO userVO = new UserVO(); + userVO.setId(mpUser.getId()); + userVO.setTenantId(mpUser.getTenantId()); + userVO.setCode(mpUser.getCode()); + userVO.setAccount(mpUser.getAccount()); + userVO.setName(mpUser.getName()); + userVO.setRealName(mpUser.getRealName()); + userVO.setAvatar(mpUser.getAvatar()); + userVO.setEmail(mpUser.getEmail()); + userVO.setPhone(mpUser.getPhone()); + userVO.setBirthday(mpUser.getBirthday()); + userVO.setSex(mpUser.getSex()); + userVO.setDeptId(mpUser.getDeptId()); + userVO.setPostId(mpUser.getPostId()); + userVO.setRemark(mpUser.getRemark()); + userVO.setStatus(mpUser.getStatus()); + userVO.setCreateTime(mpUser.getCreateTime()); + userVO.setUpdateTime(mpUser.getUpdateTime()); + // 身份放入 roleName,便于前端兼容展示 + userVO.setRoleName(mpUser.getIdentities()); + return userVO; + } + } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java index ecf7b715..cae8c860 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/UserPhoneController.java @@ -43,7 +43,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; /** - * 用户手机号变更(小程序「修改手机号」) + * 小程序用户手机号变更(写入 blade_mp_user) *

* 对外路径:/blade-system/user/phone/** ;网关别名 /blade-user/phone/** 亦可到达。 * diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java new file mode 100644 index 00000000..4d8eabfe --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/feign/MpUserClient.java @@ -0,0 +1,110 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.feign; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.AllArgsConstructor; +import org.springblade.core.tenant.annotation.NonDS; +import org.springblade.core.tool.api.R; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.service.IMpUserService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * 小程序用户 Feign 实现类 + * + * @author Chill + */ +@NonDS +@Hidden +@RestController +@AllArgsConstructor +public class MpUserClient implements IMpUserClient { + + private final IMpUserService mpUserService; + + @Override + @GetMapping(USER_BY_ID) + public R userById(Long userId) { + return R.data(mpUserService.getById(userId)); + } + + @Override + @GetMapping(USER_BY_PHONE) + public R userByPhone(String tenantId, String phone) { + return R.data(mpUserService.getByPhone(tenantId, phone)); + } + + @Override + @GetMapping(USER_BY_ACCOUNT) + public R userByAccount(String tenantId, String account) { + return R.data(mpUserService.getByAccount(tenantId, account)); + } + + @Override + @GetMapping(USER_INFO_BY_ID) + public R userInfoById(Long userId) { + return R.data(mpUserService.userInfo(userId)); + } + + @Override + @GetMapping(USER_INFO_BY_PHONE) + public R userInfoByPhone(String tenantId, String phone) { + return R.data(mpUserService.userInfoByPhone(tenantId, phone)); + } + + @Override + @GetMapping(USER_INFO_BY_ACCOUNT) + public R userInfoByAccount(String tenantId, String account) { + return R.data(mpUserService.userInfoByAccount(tenantId, account)); + } + + @Override + @PostMapping(SAVE_USER) + public R saveUser(@RequestBody MpUser user) { + return R.data(mpUserService.submit(user)); + } + + @Override + @PostMapping(UPDATE_USER) + public R updateUser(@RequestBody MpUser user) { + return R.data(mpUserService.updateUser(user)); + } + + @Override + @PostMapping(BIND_OPENID) + public R bindOpenId(String tenantId, Long userId, String openid) { + return R.data(mpUserService.bindOpenId(tenantId, userId, openid)); + } + + @Override + @PostMapping(SET_PASSWORD) + public R setPassword(Long userId, String password, String password2) { + return R.data(mpUserService.setPassword(userId, password, password2)); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MpUserMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MpUserMapper.java new file mode 100644 index 00000000..f6e53d79 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MpUserMapper.java @@ -0,0 +1,34 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.springblade.system.pojo.entity.MpUser; + +/** + * 小程序用户 Mapper + * + * @author Chill + */ +public interface MpUserMapper extends BaseMapper { +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java new file mode 100644 index 00000000..f05932ea --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java @@ -0,0 +1,81 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.service; + +import org.springblade.core.mp.base.BaseService; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; + +/** + * 小程序用户 服务类 + * + * @author Chill + */ +public interface IMpUserService extends BaseService { + + /** + * 按租户 + 手机号查询 + */ + MpUser getByPhone(String tenantId, String phone); + + /** + * 按租户 + 账号查询 + */ + MpUser getByAccount(String tenantId, String account); + + /** + * 按主键组装登录信息 + */ + MpUserInfo userInfo(Long userId); + + /** + * 按租户 + 手机号组装登录信息 + */ + MpUserInfo userInfoByPhone(String tenantId, String phone); + + /** + * 按租户 + 账号组装登录信息 + */ + MpUserInfo userInfoByAccount(String tenantId, String account); + + /** + * 保存小程序用户 + */ + boolean submit(MpUser user); + + /** + * 更新小程序用户 + */ + boolean updateUser(MpUser user); + + /** + * 绑定/更新 openid + */ + boolean bindOpenId(String tenantId, Long userId, String openid); + + /** + * 当前小程序用户设置密码 + */ + boolean setPassword(Long userId, String password, String password2); + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java new file mode 100644 index 00000000..da400e69 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -0,0 +1,272 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.common.utils.PasswordRuleUtil; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.tool.support.Kv; +import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.utils.DigestUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.core.tool.utils.StringUtil; +import org.springblade.system.cache.MpUserCache; +import org.springblade.system.mapper.MpUserMapper; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.entity.MpUserInfo; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.service.IMpUserService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Objects; + +/** + * 小程序用户 服务实现类 + * + * @author Chill + */ +@Service +@RequiredArgsConstructor +public class MpUserServiceImpl extends BaseServiceImpl implements IMpUserService { + + @Override + public MpUser getByPhone(String tenantId, String phone) { + if (Func.isBlank(tenantId) || Func.isBlank(phone)) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getPhone, phone) + .orderByDesc(MpUser::getCreateTime) + .last("LIMIT 1")); + } + + @Override + public MpUser getByAccount(String tenantId, String account) { + if (Func.isBlank(tenantId) || Func.isBlank(account)) { + return null; + } + return getOne(Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getAccount, account) + .orderByDesc(MpUser::getCreateTime) + .last("LIMIT 1")); + } + + @Override + public MpUserInfo userInfo(Long userId) { + if (Func.isEmpty(userId)) { + return null; + } + return buildUserInfo(getById(userId)); + } + + @Override + public MpUserInfo userInfoByPhone(String tenantId, String phone) { + return buildUserInfo(getByPhone(tenantId, phone)); + } + + @Override + public MpUserInfo userInfoByAccount(String tenantId, String account) { + return buildUserInfo(getByAccount(tenantId, account)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean submit(MpUser user) { + prepare(user); + validate(user); + MpUser existed = getByPhone(user.getTenantId(), user.getPhone()); + if (existed != null && Func.isNotEmpty(existed.getId())) { + throw new ServiceException("该手机号已存在小程序用户"); + } + boolean saved = save(user); + if (saved) { + MpUserCache.clear(); + } + return saved; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateUser(MpUser user) { + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户主键不能为空"); + } + prepare(user); + validate(user); + MpUser existed = getByPhone(user.getTenantId(), user.getPhone()); + if (existed != null && Func.isNotEmpty(existed.getId()) && !existed.getId().equals(user.getId())) { + throw new ServiceException("该手机号已存在小程序用户"); + } + boolean updated = updateById(user); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean bindOpenId(String tenantId, Long userId, String openid) { + if (Func.isBlank(tenantId) || Func.isEmpty(userId) || Func.isBlank(openid)) { + throw new ServiceException("绑定 openid 参数不完整"); + } + MpUser user = getById(userId); + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户不存在"); + } + if (!tenantId.equals(user.getTenantId())) { + throw new ServiceException("租户不匹配,无法绑定 openid"); + } + MpUser byOpenId = getOne(Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getOpenid, openid) + .last("LIMIT 1")); + if (byOpenId != null && !byOpenId.getId().equals(userId)) { + throw new ServiceException("该微信已绑定其他小程序用户"); + } + MpUser patch = new MpUser(); + patch.setId(userId); + patch.setOpenid(openid); + boolean updated = updateById(patch); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean setPassword(Long userId, String password, String password2) { + if (!Objects.equals(password, password2)) { + throw new ServiceException("两次输入密码不一致!"); + } + if (!PasswordRuleUtil.isValid(password)) { + throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE); + } + MpUser user = getById(userId); + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户不存在"); + } + MpUser patch = new MpUser(); + patch.setId(userId); + patch.setPassword(DigestUtil.encrypt(password)); + patch.setUpdateTime(DateUtil.now()); + boolean updated = updateById(patch); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + private MpUserInfo buildUserInfo(MpUser user) { + if (user == null || Func.isEmpty(user.getId())) { + return null; + } + List identities = MpUserIdentity.split(user.getIdentities()); + if (identities.isEmpty()) { + throw new ServiceException("小程序用户未配置身份,无法登录"); + } + MpUserInfo userInfo = new MpUserInfo(); + userInfo.setUser(user); + userInfo.setIdentities(identities); + userInfo.setPermissions(MpUserIdentity.toPermissions(user.getIdentities())); + userInfo.setDetail(Kv.create() + .set("type", MpUserIdentity.DETAIL_TYPE) + .set("identities", user.getIdentities()) + .set("openid", Func.toStr(user.getOpenid(), ""))); + return userInfo; + } + + private void prepare(MpUser user) { + if (user == null) { + return; + } + user.setAccount(trimToNull(user.getAccount())); + user.setPhone(trimToNull(user.getPhone())); + user.setName(trimToNull(user.getName())); + user.setRealName(trimToNull(user.getRealName())); + user.setAvatar(trimToNull(user.getAvatar())); + user.setEmail(trimToNull(user.getEmail())); + user.setOpenid(trimToNull(user.getOpenid())); + user.setDeptId(trimToNull(user.getDeptId())); + user.setPostId(trimToNull(user.getPostId())); + user.setRemark(trimToNull(user.getRemark())); + user.setIdentities(normalizeIdentities(user.getIdentities())); + if (Func.isBlank(user.getAccount()) && Func.isNotBlank(user.getPhone())) { + user.setAccount(user.getPhone()); + } + if (Func.isBlank(user.getName()) && Func.isNotBlank(user.getRealName())) { + user.setName(user.getRealName()); + } + if (user.getStatus() == null) { + user.setStatus(1); + } + } + + private void validate(MpUser user) { + if (user == null) { + throw new ServiceException("小程序用户不能为空"); + } + if (Func.isBlank(user.getTenantId())) { + throw new ServiceException("租户信息不能为空"); + } + if (Func.isBlank(user.getPhone())) { + throw new ServiceException("手机号不能为空"); + } + if (Func.isBlank(user.getIdentities())) { + throw new ServiceException("小程序身份不能为空"); + } + List identities = MpUserIdentity.split(user.getIdentities()); + if (identities.isEmpty()) { + throw new ServiceException("小程序身份不能为空"); + } + for (String code : identities) { + if (MpUserIdentity.of(code) == null) { + throw new ServiceException("不支持的小程序身份:" + code); + } + } + } + + private String normalizeIdentities(String identities) { + List identityList = MpUserIdentity.split(identities); + if (identityList.isEmpty()) { + return null; + } + return StringUtil.join(identityList); + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimValue = value.trim(); + return trimValue.isEmpty() ? null : trimValue; + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java index f6f26d92..605bfc08 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/UserPhoneServiceImpl.java @@ -18,9 +18,6 @@ * 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.system.service.impl; @@ -28,7 +25,6 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springblade.core.cache.utils.CacheUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.redis.cache.BladeRedis; import org.springblade.core.secure.utils.AuthUtil; @@ -37,21 +33,21 @@ import org.springblade.core.tool.utils.Func; import org.springblade.core.tool.utils.StringUtil; import org.springblade.resource.feign.ISmsClient; import org.springblade.resource.utils.SmsUtil; +import org.springblade.system.cache.MpUserCache; import org.springblade.system.pojo.dto.PhoneChangeDTO; import org.springblade.system.pojo.dto.PhoneVerifyDTO; -import org.springblade.system.pojo.entity.User; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.service.IMpUserService; import org.springblade.system.service.IUserPhoneService; -import org.springblade.system.service.IUserService; +import org.springblade.system.utils.MpUserAuth; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.util.regex.Pattern; -import static org.springblade.core.cache.constant.CacheConstant.USER_CACHE; - /** - * 用户手机号变更服务实现 + * 小程序用户手机号变更服务实现(写入 blade_mp_user) * * @author Chill */ @@ -68,24 +64,22 @@ public class UserPhoneServiceImpl implements IUserPhoneService { /** * 原手机号已校验凭证(Redis) */ - private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:user:phone:change:verified:"; + private static final String PHONE_CHANGE_VERIFIED_KEY = "blade:mp-user:phone:change:verified:"; private static final Duration PHONE_CHANGE_VERIFIED_TTL = Duration.ofMinutes(15); private static final Pattern MOBILE_PATTERN = Pattern.compile("^1[3-9]\\d{9}$"); - private final IUserService userService; + private final IMpUserService mpUserService; private final ISmsClient smsClient; private final BladeRedis bladeRedis; @Override public R sendCode(String phone) { + assertMpLogin(); String normalizedPhone = normalizePhone(phone); Long userId = AuthUtil.getUserId(); - if (Func.isEmpty(userId)) { - throw new ServiceException("请先登录"); - } - User currentUser = requireCurrentUser(userId); + MpUser currentUser = requireCurrentUser(userId); String tenantId = Func.toStr(currentUser.getTenantId(), AuthUtil.getTenantId()); boolean isCurrentPhone = StringUtil.equals(normalizedPhone, Func.toStr(currentUser.getPhone())); if (!isCurrentPhone) { @@ -100,16 +94,14 @@ public class UserPhoneServiceImpl implements IUserPhoneService { @Override public boolean verifyOldPhone(PhoneVerifyDTO phoneVerify) { + assertMpLogin(); Long userId = AuthUtil.getUserId(); - if (Func.isEmpty(userId)) { - throw new ServiceException("请先登录"); - } String id = Func.toStr(phoneVerify.getId()).trim(); String code = Func.toStr(phoneVerify.getCode()).trim(); if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) { throw new ServiceException("请先获取并填写验证码"); } - User currentUser = requireCurrentUser(userId); + MpUser currentUser = requireCurrentUser(userId); String oldPhone = Func.toStr(currentUser.getPhone()).trim(); if (StringUtil.isBlank(oldPhone)) { throw new ServiceException("当前账号未绑定手机号"); @@ -122,10 +114,8 @@ public class UserPhoneServiceImpl implements IUserPhoneService { @Override @Transactional(rollbackFor = Exception.class) public boolean changePhone(PhoneChangeDTO phoneChange) { + assertMpLogin(); Long userId = AuthUtil.getUserId(); - if (Func.isEmpty(userId)) { - throw new ServiceException("请先登录"); - } String verified = Func.toStr(bladeRedis.get(PHONE_CHANGE_VERIFIED_KEY + userId)); if (!StringUtil.equals(verified, "1")) { throw new ServiceException("请先完成原手机号验证"); @@ -136,7 +126,7 @@ public class UserPhoneServiceImpl implements IUserPhoneService { if (StringUtil.isBlank(id) || StringUtil.isBlank(code)) { throw new ServiceException("请先获取并填写验证码"); } - User currentUser = requireCurrentUser(userId); + MpUser currentUser = requireCurrentUser(userId); String oldPhone = Func.toStr(currentUser.getPhone()).trim(); if (StringUtil.equals(newPhone, oldPhone)) { throw new ServiceException("新手机号不可与当前手机号相同"); @@ -145,27 +135,49 @@ public class UserPhoneServiceImpl implements IUserPhoneService { assertPhoneAvailable(tenantId, newPhone, userId); validateSms(tenantId, id, code, newPhone); - User updateUser = new User(); + MpUser updateUser = new MpUser(); updateUser.setId(userId); + updateUser.setTenantId(tenantId); updateUser.setPhone(newPhone); + updateUser.setName(currentUser.getName()); + updateUser.setRealName(currentUser.getRealName()); + updateUser.setIdentities(currentUser.getIdentities()); + updateUser.setSex(currentUser.getSex()); + updateUser.setDeptId(currentUser.getDeptId()); + updateUser.setPostId(currentUser.getPostId()); + updateUser.setAvatar(currentUser.getAvatar()); + updateUser.setEmail(currentUser.getEmail()); + updateUser.setRemark(currentUser.getRemark()); + updateUser.setStatus(currentUser.getStatus()); // 账号若等于原手机号,同步更新,保证短信登录可用 if (StringUtil.isNotBlank(oldPhone) && StringUtil.equals(oldPhone, Func.toStr(currentUser.getAccount()))) { assertAccountAvailable(tenantId, newPhone, userId); updateUser.setAccount(newPhone); + } else { + updateUser.setAccount(currentUser.getAccount()); } - boolean updated = userService.updateById(updateUser); + boolean updated = mpUserService.updateUser(updateUser); if (!updated) { throw new ServiceException("手机号修改失败"); } bladeRedis.del(PHONE_CHANGE_VERIFIED_KEY + userId); - CacheUtil.clear(USER_CACHE); + MpUserCache.clear(); return true; } - private User requireCurrentUser(Long userId) { - User user = userService.getById(userId); + private void assertMpLogin() { + if (Func.isEmpty(AuthUtil.getUserId())) { + throw new ServiceException("请先登录"); + } + if (!MpUserAuth.isMpUser()) { + throw new ServiceException("仅支持小程序用户修改手机号"); + } + } + + private MpUser requireCurrentUser(Long userId) { + MpUser user = mpUserService.getById(userId); if (user == null) { - throw new ServiceException("用户不存在"); + throw new ServiceException("小程序用户不存在"); } return user; } @@ -178,11 +190,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService { } private void assertPhoneAvailable(String tenantId, String phone, Long excludeUserId) { - Long phoneCount = userService.count( - Wrappers.lambdaQuery() - .eq(User::getTenantId, tenantId) - .eq(User::getPhone, phone) - .ne(User::getId, excludeUserId) + Long phoneCount = mpUserService.count( + Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getPhone, phone) + .ne(MpUser::getId, excludeUserId) ); if (phoneCount != null && phoneCount > 0L) { throw new ServiceException(StringUtil.format("当前手机 [{}] 已存在!", phone)); @@ -190,11 +202,11 @@ public class UserPhoneServiceImpl implements IUserPhoneService { } private void assertAccountAvailable(String tenantId, String account, Long excludeUserId) { - Long accountCount = userService.count( - Wrappers.lambdaQuery() - .eq(User::getTenantId, tenantId) - .eq(User::getAccount, account) - .ne(User::getId, excludeUserId) + Long accountCount = mpUserService.count( + Wrappers.lambdaQuery() + .eq(MpUser::getTenantId, tenantId) + .eq(MpUser::getAccount, account) + .ne(MpUser::getId, excludeUserId) ); if (accountCount != null && accountCount > 0L) { throw new ServiceException(StringUtil.format("当前用户 [{}] 已存在!", account)); 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 0a36f125..09a6ece6 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.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.system.cache.UserDisplayCache; import org.springblade.transport.mapper.TransportCertRecordMapper; import org.springblade.transport.pojo.dto.PersonCertSubmitRequest; import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest; @@ -124,10 +125,8 @@ public class CertAuditServiceImpl implements ICertAuditService { detail.setStatusText(CertificationRuleSupport.statusText(record.getStatus())); detail.setSubmitTime(record.getSubmitTime()); detail.setSubmitUser(record.getSubmitUser()); - // 面向前端的展示字段必须由服务端填好,否则审核页「提交人」一栏恒为空白 - detail.setSubmitUserName(UserCache.getUserRealName(record.getSubmitUser())); - // 审核留痕同理:审核人姓名由服务端查好,前端不做用户ID → 姓名的二次请求。 - // 记录还在「审核中」时审核人与审核时间都为空,由前端显示占位符 + // 提交人来自小程序用户;审核人来自后台系统用户 + detail.setSubmitUserName(UserDisplayCache.getUserRealName(record.getSubmitUser())); detail.setAuditUser(record.getAuditUser()); detail.setAuditTime(record.getAuditTime()); detail.setAuditUserName(UserCache.getUserRealName(record.getAuditUser())); 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 1d09822f..148ce41e 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 @@ -29,8 +29,8 @@ import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; -import org.springblade.system.feign.IUserClient; -import org.springblade.system.pojo.entity.User; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.TransportVehicle; import org.springblade.transport.pojo.vo.DriverVehicleCardVO; @@ -57,7 +57,7 @@ public class DriverAppServiceImpl implements IDriverAppService { private final IDriverService driverService; private final ITransportVehicleService transportVehicleService; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; @Override public DriverVO currentByPhone(String mobile) { @@ -205,10 +205,9 @@ public class DriverAppServiceImpl implements IDriverAppService { if (isMobileLike(account)) { return account.trim(); } - // 用户中心的 phone 带 @Sensitive(MOBILE),经 Feign 回来是「175****12082」这类脱敏串, - // 拿它去 eq(mobile) 永远查不到、拿它比对本人的真实号码又必然不等。 + // 小程序用户 phone 带 @Sensitive(MOBILE),经 Feign 回来是脱敏串, // 因此这里只取未脱敏的 account;脱敏的 phone 一律不用。 - R result = userClient.userInfoById(userId); + R result = mpUserClient.userById(userId); if (result != null && R.isSuccess(result) && result.getData() != null) { String userAccount = result.getData().getAccount(); if (isMobileLike(userAccount)) { 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 f172a547..ca7ef54e 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 @@ -327,7 +327,7 @@ public class DriverCertServiceImpl implements IDriverCertService { * 状态必须显式设置为待审:框架在插入时会把空的 {@code status} 静默填成 1, * 而 1 在本表是「已通过」,那会让一次提交凭空变成审核通过。 * - * @param submitUser 提交人**用户ID**(blade_user.id)——审核页要靠它翻译成姓名。 + * @param submitUser 提交人**用户ID**(blade_mp_user.id)——审核页要靠它翻译成姓名。 * 不要传司机档案ID:两者都是雪花ID、类型也相同,传错不会报任何错, * 只会让「提交人」一栏显示成一串数字。 */ 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 3aca721d..97e7186b 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 @@ -36,12 +36,11 @@ import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.system.cache.UserCache; import org.springblade.core.tool.utils.Func; +import org.springblade.system.feign.IMpUserClient; import org.springblade.system.feign.ISysClient; -import org.springblade.system.feign.IUserClient; -import org.springblade.system.pojo.entity.User; -import org.springblade.system.pojo.entity.UserInfo; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.enums.MpUserIdentity; 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.pojo.entity.Driver; @@ -76,7 +75,7 @@ public class DriverServiceImpl extends BaseServiceImpl imp private static final int DEFAULT_ENABLED_STATUS = 1; private static final int DEFAULT_FALSE = 0; - private final IUserClient userClient; + private final IMpUserClient mpUserClient; private final ISysClient sysClient; @Override @@ -310,13 +309,13 @@ public class DriverServiceImpl extends BaseServiceImpl imp tenantId = driver.getTenantId(); } if (Func.isEmpty(tenantId)) { - throw new ServiceException("租户信息为空,无法同步系统用户"); + throw new ServiceException("租户信息为空,无法同步小程序用户"); } String mobile = driver.getMobile(); if (Func.isEmpty(mobile) || mobile.length() < 6) { - throw new ServiceException("手机号长度不足,无法同步系统用户"); + throw new ServiceException("手机号长度不足,无法同步小程序用户"); } - // 编辑时前端可能不传 userId,从库中补齐已关联用户 + // 编辑时前端可能不传 userId,从库中补齐已关联小程序用户 if (Func.isEmpty(driver.getUserId()) && Func.isNotEmpty(driver.getId())) { Driver dbDriver = getById(driver.getId()); if (dbDriver != null && Func.isNotEmpty(dbDriver.getUserId())) { @@ -324,95 +323,83 @@ public class DriverServiceImpl extends BaseServiceImpl imp } } - R roleResult = sysClient.getRoleIdByAlias(tenantId, "driver"); - if (roleResult == null || !R.isSuccess(roleResult) || Func.isEmpty(roleResult.getData())) { - throw new ServiceException("未配置角色别名为driver的角色,请先在系统角色中维护"); - } - String roleId = roleResult.getData(); - + // 部门可选:匹配到则写入小程序用户,匹配不到不阻断同步 + String deptId = null; R deptResult = sysClient.getDeptIds(tenantId, driver.getOrganizationName()); - if (deptResult == null || !R.isSuccess(deptResult) || Func.isEmpty(deptResult.getData())) { - throw new ServiceException("所属组织未匹配到系统部门,无法同步用户"); + if (deptResult != null && R.isSuccess(deptResult) && Func.isNotEmpty(deptResult.getData())) { + deptId = deptResult.getData(); } - String deptId = deptResult.getData(); - User existing = findExistingUser(driver, tenantId, mobile); + MpUser existing = findExistingMpUser(driver, tenantId, mobile); if (existing == null || Func.isEmpty(existing.getId())) { - // 司机未关联有效用户时新建;初始密码由系统用户保存逻辑按默认参数/强度规则填充 - createAndLinkDriverUser(driver, tenantId, mobile, roleId, deptId); + createAndLinkDriverMpUser(driver, tenantId, mobile, deptId); } else { - updateAndLinkDriverUser(driver, existing, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existing, tenantId, mobile, deptId); } } - private User findExistingUser(Driver driver, String tenantId, String mobile) { + private MpUser findExistingMpUser(Driver driver, String tenantId, String mobile) { if (Func.isNotEmpty(driver.getUserId())) { - R byId = userClient.userInfoById(driver.getUserId()); + R byId = mpUserClient.userById(driver.getUserId()); if (byId != null && R.isSuccess(byId) && byId.getData() != null && Func.isNotEmpty(byId.getData().getId())) { return byId.getData(); } - // 关联的用户已不存在,清空后按手机号重建 + // 关联的小程序用户已不存在,清空后按手机号重建 driver.setUserId(null); } - return findExistingUserByAccountOrPhone(tenantId, mobile); + return findExistingMpUserByPhone(tenantId, mobile); } - private User findExistingUserByAccountOrPhone(String tenantId, String mobile) { - R byAccount = userClient.userByAccount(tenantId, mobile); - if (byAccount != null && R.isSuccess(byAccount) && byAccount.getData() != null - && Func.isNotEmpty(byAccount.getData().getId())) { - return byAccount.getData(); - } - R byPhone = userClient.userInfoByPhone(tenantId, mobile, UserType.WEB.getName()); + private MpUser findExistingMpUserByPhone(String tenantId, String mobile) { + R byPhone = mpUserClient.userByPhone(tenantId, mobile); if (byPhone != null && R.isSuccess(byPhone) && byPhone.getData() != null - && byPhone.getData().getUser() != null && Func.isNotEmpty(byPhone.getData().getUser().getId())) { - return byPhone.getData().getUser(); + && Func.isNotEmpty(byPhone.getData().getId())) { + return byPhone.getData(); } return null; } - private void createAndLinkDriverUser(Driver driver, String tenantId, String mobile, String roleId, String deptId) { - User user = buildSyncUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId); - // 不传密码:交由 UserServiceImpl.saveUser 使用系统默认初始密码或自动生成合规密码 - user.setUserType(UserType.WEB.getCategory()); + private void createAndLinkDriverMpUser(Driver driver, String tenantId, String mobile, String deptId) { + MpUser user = buildSyncMpUser(null, tenantId, mobile, driver.getDriverName(), driver.getGender(), + deptId, MpUserIdentity.DRIVER.getCode()); - R saveResult = null; + R saveResult; try { - saveResult = userClient.saveUser(user); + saveResult = mpUserClient.saveUser(user); } catch (Exception ex) { - User existed = findExistingUserByAccountOrPhone(tenantId, mobile); + MpUser existed = findExistingMpUserByPhone(tenantId, mobile); if (existed != null) { - updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId); return; } - throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步系统用户失败"); + throw new ServiceException(Func.isNotEmpty(ex.getMessage()) ? ex.getMessage() : "同步小程序用户失败"); } if (!isFeignSuccess(saveResult)) { - User existed = findExistingUserByAccountOrPhone(tenantId, mobile); + MpUser existed = findExistingMpUserByPhone(tenantId, mobile); if (existed != null) { - updateAndLinkDriverUser(driver, existed, tenantId, mobile, roleId, deptId); + updateAndLinkDriverMpUser(driver, existed, tenantId, mobile, deptId); return; } - throw new ServiceException(resolveFeignError(saveResult, "同步系统用户失败")); + throw new ServiceException(resolveFeignError(saveResult, "同步小程序用户失败")); } - R created = userClient.userByAccount(tenantId, mobile); - User createdUser = (created != null && R.isSuccess(created)) ? created.getData() : null; + MpUser createdUser = findExistingMpUserByPhone(tenantId, mobile); if (createdUser == null || Func.isEmpty(createdUser.getId())) { - createdUser = findExistingUserByAccountOrPhone(tenantId, mobile); - } - if (createdUser == null || Func.isEmpty(createdUser.getId())) { - throw new ServiceException("系统用户创建成功但无法回查用户ID"); + throw new ServiceException("小程序用户创建成功但无法回查用户ID"); } linkDriverUserId(driver, createdUser.getId()); } - private void updateAndLinkDriverUser(Driver driver, User existing, String tenantId, String mobile, String roleId, String deptId) { - User user = buildSyncUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(), roleId, deptId); - R updateResult = userClient.updateUser(user); - // updateById 无字段变化时可能返回 false,但业务上仍视为成功,以接口 code 为准 + private void updateAndLinkDriverMpUser(Driver driver, MpUser existing, String tenantId, String mobile, String deptId) { + // 保留已有管理员等身份,确保包含司机身份 + String identities = MpUserIdentity.merge(existing.getIdentities(), MpUserIdentity.DRIVER.getCode()); + MpUser user = buildSyncMpUser(existing.getId(), tenantId, mobile, driver.getDriverName(), driver.getGender(), + deptId, identities); + // 更新时不覆盖 openid + user.setOpenid(null); + R updateResult = mpUserClient.updateUser(user); if (!isFeignSuccess(updateResult)) { - throw new ServiceException(resolveFeignError(updateResult, "同步更新系统用户失败")); + throw new ServiceException(resolveFeignError(updateResult, "同步更新小程序用户失败")); } linkDriverUserId(driver, existing.getId()); } @@ -433,9 +420,9 @@ public class DriverServiceImpl extends BaseServiceImpl imp return msg; } - private User buildSyncUser(Long userId, String tenantId, String mobile, String driverName, String gender, - String roleId, String deptId) { - User user = new User(); + private MpUser buildSyncMpUser(Long userId, String tenantId, String mobile, String driverName, String gender, + String deptId, String identities) { + MpUser user = new MpUser(); user.setId(userId); user.setTenantId(tenantId); user.setAccount(mobile); @@ -443,8 +430,8 @@ public class DriverServiceImpl extends BaseServiceImpl imp user.setName(driverName); user.setRealName(driverName); user.setSex(UserSex.getCodeByName(gender)); - user.setRoleId(roleId); user.setDeptId(deptId); + user.setIdentities(identities); return user; } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java index 83987141..9158ce0d 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -32,7 +32,10 @@ import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.constant.BladeConstant; +import org.springblade.core.tool.api.R; import org.springblade.core.tool.utils.Func; +import org.springblade.system.feign.IMpUserClient; +import org.springblade.system.pojo.entity.MpUser; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -56,6 +59,7 @@ import org.springblade.transport.pojo.vo.DriverPunchPhotoVO; import org.springblade.transport.pojo.vo.DriverWaybillCardVO; import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.pojo.vo.WaybillTrackVO; import org.springblade.transport.service.IDriverService; import org.springblade.transport.service.IDriverWaybillService; import org.springblade.transport.service.IProcessConfigService; @@ -120,6 +124,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { private final IWaybillService waybillService; private final IDriverService driverService; + private final IMpUserClient mpUserClient; private final IProcessConfigService processConfigService; private final ITransportVehicleService transportVehicleService; private final CertificationGate certificationGate; @@ -239,6 +244,52 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { return toCard(normalizeAcceptStatus(waybill), true); } + @Override + public WaybillTrackVO track(Long id) { + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + LocalDate[] range = resolveTrackDateRange(waybill); + return waybillService.trackVehicle(waybill.getId(), range[0].toString(), range[1].toString()); + } + + /** + * 按打卡记录 / 计划时间推导轨迹查询起止日期 + */ + private LocalDate[] resolveTrackDateRange(Waybill waybill) { + LocalDate today = LocalDate.now(); + List punchDates = new ArrayList<>(); + nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybill.getId()) + .isNotNull(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getPunchTime)).forEach(punch -> { + if (punch.getPunchTime() != null) { + punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + } + }); + enroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybill.getId()) + .isNotNull(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getPunchTime)).forEach(punch -> { + if (punch.getPunchTime() != null) { + punchDates.add(punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDate()); + } + }); + punchDates.sort(LocalDate::compareTo); + if (punchDates.size() > 1) { + return new LocalDate[]{punchDates.get(0), punchDates.get(punchDates.size() - 1)}; + } + if (punchDates.size() == 1) { + return new LocalDate[]{punchDates.get(0), today}; + } + if (waybill.getEstimatedStartTime() != null) { + return new LocalDate[]{waybill.getEstimatedStartTime(), today}; + } + if (waybill.getStartDate() != null) { + return new LocalDate[]{waybill.getStartDate(), today}; + } + return new LocalDate[]{today.minusDays(3), today}; + } + @Override public DriverWaybillCardVO detailPunchSnapshot(Long id) { if (id == null) { @@ -590,7 +641,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { } /** - * 当前登录司机:优先 userId,其次 JWT 账号(手机号)匹配 driver.mobile + * 当前登录司机:优先 userId,其次手机号(JWT 账号 / 小程序用户 account)匹配 driver.mobile */ private Driver currentDriver() { Long userId = AuthUtil.getUserId(); @@ -603,16 +654,36 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService { if (driver != null) { return driver; } - String account = AuthUtil.getUserAccount(); - if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + String phone = resolveSelfPhone(userId); + if (Func.isNotEmpty(phone)) { return driverService.getOne(Wrappers.lambdaQuery() - .eq(Driver::getMobile, account.trim()) + .eq(Driver::getMobile, phone) .orderByDesc(Driver::getUpdateTime) .last("LIMIT 1")); } return null; } + /** + * 解析当前小程序用户可用于匹配司机档案的手机号 + */ + private String resolveSelfPhone(Long userId) { + String account = AuthUtil.getUserAccount(); + if (isMobileLike(account)) { + return account.trim(); + } + R result = mpUserClient.userById(userId); + if (result != null && R.isSuccess(result) && result.getData() != null + && isMobileLike(result.getData().getAccount())) { + return result.getData().getAccount().trim(); + } + return Func.isEmpty(account) ? null : account.trim(); + } + + private boolean isMobileLike(String value) { + return Func.isNotEmpty(value) && value.trim().matches("^1\\d{10,14}$"); + } + private List splitPlates(String drivingVehicle) { return VehiclePlateSupport.splitPlates(drivingVehicle); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index e8842af0..d86e008c 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -31,7 +31,7 @@ import org.springblade.core.mp.base.BaseServiceImpl; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.mapper.ExceptionDisposalFollowRecordMapper; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; @@ -131,7 +131,7 @@ public class ExceptionDisposalServiceImpl disposal.setDisposalStatus(STATUS_PENDING); disposal.setReportTime(LocalDateTime.now()); disposal.setReporterId(AuthUtil.getUserId()); - disposal.setReporterName(UserCache.getUserRealName(AuthUtil.getUserId())); + disposal.setReporterName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); fillFromWaybill(disposal, request.getWaybillId(), request.getWaybillNo()); @@ -267,7 +267,7 @@ public class ExceptionDisposalServiceImpl record.setDisposalId(disposal.getId()); record.setFollowContent(changeLog); record.setFollowUser(AuthUtil.getUserId()); - record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); record.setFollowTime(LocalDateTime.now()); followRecordMapper.insert(record); } @@ -344,7 +344,7 @@ public class ExceptionDisposalServiceImpl record.setFollowContent(Func.isBlank(followContent) ? "已上传跟进附件" : followContent); record.setAttachmentsJson(hasAttachments ? request.getAttachmentsJson() : null); record.setFollowUser(AuthUtil.getUserId()); - record.setFollowUserName(UserCache.getUserRealName(AuthUtil.getUserId())); + record.setFollowUserName(UserDisplayCache.getUserRealName(AuthUtil.getUserId())); record.setFollowTime(LocalDateTime.now()); followRecordMapper.insert(record); } @@ -417,8 +417,8 @@ public class ExceptionDisposalServiceImpl if (vo == null) { return null; } - vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); - vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); + vo.setCreateUserName(UserDisplayCache.getUserRealName(entity.getCreateUser())); + vo.setUpdateUserName(UserDisplayCache.getUserRealName(entity.getUpdateUser())); vo.setDisposalStatusName(statusName(entity.getDisposalStatus())); vo.setScenePhotoList(splitPhotos(entity.getScenePhotos())); fillRouteAndCargo(vo, entity.getWaybillId()); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java index 1f4868f2..71327729 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ManageWaybillServiceImpl.java @@ -30,7 +30,7 @@ import lombok.RequiredArgsConstructor; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.Func; -import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.pojo.entity.Driver; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.RiskDisposal; @@ -688,11 +688,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService { } private String resolveUserName() { - String realName = UserCache.getUserRealName(AuthUtil.getUserId()); - if (Func.isNotBlank(realName)) { + String realName = UserDisplayCache.getUserRealName(AuthUtil.getUserId()); + if (Func.isNotBlank(realName) && !Func.toStr(AuthUtil.getUserId()).equals(realName)) { return realName; } - return Func.toStr(AuthUtil.getUserName(), ""); + String jwtName = Func.toStr(AuthUtil.getNickName(), AuthUtil.getUserName()); + return Func.toStr(jwtName, realName); } /** 相对时间文案;时间取不到时留空,避免把「现在」当成发生时刻显示成「刚刚」 */ 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 60b9cea4..f6a8d7e2 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 @@ -28,6 +28,7 @@ package org.springblade.transport.wrapper; import org.springblade.core.mp.support.BaseEntityWrapper; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.system.cache.UserCache; +import org.springblade.system.cache.UserDisplayCache; import org.springblade.transport.pojo.entity.TransportCertRecord; import org.springblade.transport.pojo.vo.TransportCertRecordVO; import org.springblade.transport.support.CertificationRuleSupport; @@ -62,9 +63,10 @@ public class TransportCertRecordWrapper extends BaseEntityWrapper authUtil = mockStatic(AuthUtil.class); - MockedStatic userCache = mockStatic(UserCache.class)) { + MockedStatic userCache = mockStatic(UserDisplayCache.class)) { authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID); - userCache.when(() -> UserCache.getUserRealName(USER_ID)).thenReturn("韦司机"); + userCache.when(() -> UserDisplayCache.getUserRealName(USER_ID)).thenReturn("韦司机"); service.submitReport(request); } } diff --git a/doc/sql/transport/blade_mp_user_20261012.sql b/doc/sql/transport/blade_mp_user_20261012.sql new file mode 100644 index 00000000..9d34d49c --- /dev/null +++ b/doc/sql/transport/blade_mp_user_20261012.sql @@ -0,0 +1,45 @@ +-- ---------------------------- +-- 小程序用户表(与 blade_user 分离,仅用于小程序登录) +-- 身份 identities:逗号分隔,当前支持 driver(司机)、admin(管理员),可同时拥有,后续可扩展 +-- ---------------------------- +CREATE TABLE IF NOT EXISTS `blade_mp_user` ( + `id` bigint NOT NULL COMMENT '主键', + `tenant_id` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '000000' COMMENT '租户ID', + `code` varchar(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户编号', + `account` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '账号(通常为手机号)', + `password` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '密码(小程序登录可不使用)', + `name` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '昵称', + `real_name` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '真名', + `avatar` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '头像', + `email` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '邮箱', + `phone` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机', + `birthday` datetime NULL DEFAULT NULL COMMENT '生日', + `sex` int NULL DEFAULT NULL COMMENT '性别', + `openid` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '微信小程序 openid', + `identities` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '小程序身份,逗号分隔:driver,admin', + `dept_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '部门id', + `post_id` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '岗位id', + `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '备注', + `create_user` bigint NULL DEFAULT NULL COMMENT '创建人', + `create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门', + `create_time` datetime NULL DEFAULT NULL COMMENT '创建时间', + `update_user` bigint NULL DEFAULT NULL COMMENT '修改人', + `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间', + `status` int NULL DEFAULT 1 COMMENT '状态', + `is_deleted` int NULL DEFAULT 0 COMMENT '是否已删除', + PRIMARY KEY (`id`) USING BTREE, + UNIQUE KEY `uk_mp_user_tenant_phone` (`tenant_id`, `phone`) USING BTREE, + UNIQUE KEY `uk_mp_user_tenant_openid` (`tenant_id`, `openid`) USING BTREE, + KEY `idx_mp_user_account` (`tenant_id`, `account`) USING BTREE, + KEY `idx_mp_user_identities` (`identities`) USING BTREE +) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '小程序用户表'; + +-- 司机表 user_id 语义变更为关联小程序用户 +ALTER TABLE `blade_transport_driver` + MODIFY COLUMN `user_id` bigint(20) DEFAULT NULL COMMENT '关联小程序用户ID(blade_mp_user.id)'; + +-- 示例:创建小程序管理员(调度端),identities 可同时包含 driver,admin +-- INSERT INTO `blade_mp_user` +-- (`id`, `tenant_id`, `account`, `name`, `real_name`, `phone`, `identities`, `create_time`, `status`, `is_deleted`) +-- VALUES +-- (2091900000000000001, '000000', '13800000000', '调度管理员', '调度管理员', '13800000000', 'admin', NOW(), 1, 0); diff --git a/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql b/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql new file mode 100644 index 00000000..9f3063b5 --- /dev/null +++ b/doc/sql/transport/blade_mp_user_migrate_driver_20261012.sql @@ -0,0 +1,61 @@ +-- ---------------------------- +-- 将已关联 blade_user 的司机迁移到 blade_mp_user(可选,按环境执行) +-- 注意:执行前请先执行 blade_mp_user_20261012.sql 建表 +-- 策略:小程序用户主键使用司机表 id(避免与 blade_user.id 冲突导致令牌刷新串用户) +-- 迁移后回写 driver.user_id;原 blade_user 账号不会自动删除,请人工确认后再清理 +-- 调度端管理员需另行在 blade_mp_user 中维护 identities 含 admin 的账号 +-- ---------------------------- + +-- 1) 按司机写入小程序用户 +INSERT INTO `blade_mp_user` ( + `id`, `tenant_id`, `account`, `name`, `real_name`, `phone`, `sex`, + `openid`, `identities`, `dept_id`, + `create_user`, `create_dept`, `create_time`, `update_user`, `update_time`, + `status`, `is_deleted` +) +SELECT + d.`id` AS `id`, + IFNULL(d.`tenant_id`, '000000') AS `tenant_id`, + d.`mobile` AS `account`, + d.`driver_name` AS `name`, + d.`driver_name` AS `real_name`, + d.`mobile` AS `phone`, + CASE + WHEN d.`gender` IN ('男', '1') THEN 1 + WHEN d.`gender` IN ('女', '2') THEN 2 + ELSE NULL + END AS `sex`, + oauth.`uuid` AS `openid`, + 'driver' AS `identities`, + u.`dept_id` AS `dept_id`, + d.`create_user`, + d.`create_dept`, + IFNULL(d.`create_time`, NOW()) AS `create_time`, + d.`update_user`, + IFNULL(d.`update_time`, NOW()) AS `update_time`, + IFNULL(d.`status`, 1) AS `status`, + 0 AS `is_deleted` +FROM `blade_transport_driver` d +LEFT JOIN `blade_user` u ON u.`id` = d.`user_id` AND u.`is_deleted` = 0 +LEFT JOIN `blade_user_oauth` oauth + ON oauth.`user_id` = d.`user_id` + AND oauth.`source` = 'WECHAT_MINI' + AND oauth.`is_deleted` = 0 +WHERE d.`is_deleted` = 0 + AND d.`mobile` IS NOT NULL + AND d.`mobile` <> '' + AND NOT EXISTS ( + SELECT 1 FROM `blade_mp_user` m + WHERE m.`id` = d.`id` + OR (m.`tenant_id` = IFNULL(d.`tenant_id`, '000000') AND m.`phone` = d.`mobile` AND m.`is_deleted` = 0) + ); + +-- 2) 回写司机关联的小程序用户ID +UPDATE `blade_transport_driver` d +INNER JOIN `blade_mp_user` m + ON m.`id` = d.`id` + OR (m.`tenant_id` = IFNULL(d.`tenant_id`, '000000') AND m.`phone` = d.`mobile` AND m.`is_deleted` = 0) +SET d.`user_id` = m.`id` +WHERE d.`is_deleted` = 0 + AND d.`mobile` IS NOT NULL + AND d.`mobile` <> ''; From 17ede93a04861e9306eb45965fc052c8ebb70834 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 12 Oct 2026 13:49:08 +0800 Subject: [PATCH 68/81] =?UTF-8?q?:sparkles:=20=E6=96=B0=E5=A2=9E=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E7=94=A8=E6=88=B7=E5=90=8E=E5=8F=B0=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E5=8F=A3=E4=B8=8E=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/system/pojo/vo/MpUserVO.java | 73 ++++++++++ .../system/controller/MpUserController.java | 134 ++++++++++++++++++ .../system/service/IMpUserService.java | 29 ++++ .../service/impl/MpUserServiceImpl.java | 108 ++++++++++++++ .../system/wrapper/MpUserWrapper.java | 78 ++++++++++ .../transport/blade_mp_user_menu_20261012.sql | 90 ++++++++++++ 6 files changed, 512 insertions(+) create mode 100644 blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/wrapper/MpUserWrapper.java create mode 100644 doc/sql/transport/blade_mp_user_menu_20261012.sql diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java new file mode 100644 index 00000000..c2ced763 --- /dev/null +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/pojo/vo/MpUserVO.java @@ -0,0 +1,73 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.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.system.pojo.entity.MpUser; + +import java.io.Serial; + +/** + * 小程序用户视图实体类 + * + * @author Chill + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Schema(description = "小程序用户") +public class MpUserVO extends MpUser { + @Serial + private static final long serialVersionUID = 1L; + + /** + * 身份名称(逗号分隔中文) + */ + @TableField(exist = false) + @Schema(description = "身份名称") + private String identitiesName; + + /** + * 性别名称 + */ + @TableField(exist = false) + @Schema(description = "性别名称") + private String sexName; + + /** + * 创建人姓名 + */ + @TableField(exist = false) + @Schema(description = "创建人姓名") + private String createUserName; + + /** + * 更新人姓名 + */ + @TableField(exist = false) + @Schema(description = "更新人姓名") + private String updateUserName; + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java b/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java new file mode 100644 index 00000000..b4c84f8e --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/controller/MpUserController.java @@ -0,0 +1,134 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.mp.support.Condition; +import org.springblade.core.mp.support.Query; +import org.springblade.core.secure.annotation.PreAuth; +import org.springblade.core.tenant.annotation.NonDS; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.vo.MpUserVO; +import org.springblade.system.service.IMpUserService; +import org.springblade.system.wrapper.MpUserWrapper; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** + * 小程序用户管理 控制器 + * + * @author Chill + */ +@NonDS +@RestController +@AllArgsConstructor +@PreAuth(menu = "mp_user") +@RequestMapping("mp-user") +@Tag(name = "小程序用户", description = "小程序用户管理") +public class MpUserController extends BladeController { + + private final IMpUserService mpUserService; + + /** + * 详情 + */ + @GetMapping("/detail") + @ApiOperationSupport(order = 1) + @Operation(summary = "详情", description = "传入id") + public R detail(MpUser mpUser) { + MpUser detail = mpUserService.getOne(Condition.getQueryWrapper(mpUser)); + if (detail == null) { + return R.fail("小程序用户不存在"); + } + return R.data(MpUserWrapper.build().entityVO(detail)); + } + + /** + * 分页 + */ + @GetMapping("/list") + @ApiOperationSupport(order = 2) + @Operation(summary = "分页", description = "传入查询条件") + public R> list(MpUser mpUser, Query query) { + IPage pages = mpUserService.selectMpUserPage(Condition.getPage(query), mpUser); + return R.data(MpUserWrapper.build().pageVO(pages)); + } + + /** + * 新增或修改 + */ + @PostMapping("/submit") + @ApiOperationSupport(order = 3) + @Operation(summary = "新增或修改", description = "传入MpUser") + public R submit(@Valid @RequestBody MpUser mpUser) { + return R.status(mpUserService.submitOrUpdate(mpUser)); + } + + /** + * 逻辑删除 + */ + @PostMapping("/remove") + @ApiOperationSupport(order = 4) + @Operation(summary = "逻辑删除", description = "传入ids") + public R remove(@Parameter(description = "主键集合", required = true) @RequestParam String ids) { + return R.status(mpUserService.removeMpUser(Func.toLongList(ids))); + } + + /** + * 启用 / 停用 + */ + @PostMapping("/change-status") + @ApiOperationSupport(order = 5) + @Operation(summary = "修改状态", description = "status:1启用,0停用") + public R changeStatus(@Parameter(description = "用户主键", required = true) @RequestParam Long id, + @Parameter(description = "状态:1启用,0停用", required = true) @RequestParam Integer status) { + return R.status(mpUserService.changeStatus(id, status)); + } + + /** + * 重置密码 + */ + @PostMapping("/reset-password") + @ApiOperationSupport(order = 6) + @Operation(summary = "重置密码", description = "传入userId集合,返回账号与明文密码") + public R> resetPassword( + @Parameter(description = "userId集合", required = true) @RequestParam String userIds) { + return R.data(mpUserService.resetPassword(Func.toLongList(userIds))); + } + +} diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java index f05932ea..c0294ced 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IMpUserService.java @@ -22,10 +22,14 @@ */ package org.springblade.system.service; +import com.baomidou.mybatisplus.core.metadata.IPage; import org.springblade.core.mp.base.BaseService; import org.springblade.system.pojo.entity.MpUser; import org.springblade.system.pojo.entity.MpUserInfo; +import java.util.List; +import java.util.Map; + /** * 小程序用户 服务类 * @@ -58,6 +62,11 @@ public interface IMpUserService extends BaseService { */ MpUserInfo userInfoByAccount(String tenantId, String account); + /** + * 后台分页查询 + */ + IPage selectMpUserPage(IPage page, MpUser query); + /** * 保存小程序用户 */ @@ -68,6 +77,26 @@ public interface IMpUserService extends BaseService { */ boolean updateUser(MpUser user); + /** + * 新增或修改(后台管理) + */ + boolean submitOrUpdate(MpUser user); + + /** + * 逻辑删除 + */ + boolean removeMpUser(List ids); + + /** + * 启用 / 停用 + */ + boolean changeStatus(Long id, Integer status); + + /** + * 重置密码为随机密码,返回 account -> 明文密码 + */ + Map resetPassword(List userIds); + /** * 绑定/更新 openid */ diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java index da400e69..93f744ba 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MpUserServiceImpl.java @@ -22,11 +22,13 @@ */ package org.springblade.system.service.impl; +import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import lombok.RequiredArgsConstructor; import org.springblade.common.utils.PasswordRuleUtil; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.mp.base.BaseServiceImpl; +import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.support.Kv; import org.springblade.core.tool.utils.DateUtil; import org.springblade.core.tool.utils.DigestUtil; @@ -41,7 +43,9 @@ import org.springblade.system.service.IMpUserService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.Objects; /** @@ -95,6 +99,19 @@ public class MpUserServiceImpl extends BaseServiceImpl imp return buildUserInfo(getByAccount(tenantId, account)); } + @Override + public IPage selectMpUserPage(IPage page, MpUser query) { + MpUser condition = query == null ? new MpUser() : query; + return page(page, Wrappers.lambdaQuery() + .like(Func.isNotBlank(condition.getPhone()), MpUser::getPhone, condition.getPhone()) + .like(Func.isNotBlank(condition.getAccount()), MpUser::getAccount, condition.getAccount()) + .like(Func.isNotBlank(condition.getRealName()), MpUser::getRealName, condition.getRealName()) + .like(Func.isNotBlank(condition.getName()), MpUser::getName, condition.getName()) + .like(Func.isNotBlank(condition.getIdentities()), MpUser::getIdentities, condition.getIdentities()) + .eq(condition.getStatus() != null, MpUser::getStatus, condition.getStatus()) + .orderByDesc(MpUser::getCreateTime)); + } + @Override @Transactional(rollbackFor = Exception.class) public boolean submit(MpUser user) { @@ -104,6 +121,7 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (existed != null && Func.isNotEmpty(existed.getId())) { throw new ServiceException("该手机号已存在小程序用户"); } + encryptPasswordIfPresent(user); boolean saved = save(user); if (saved) { MpUserCache.clear(); @@ -123,6 +141,8 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (existed != null && Func.isNotEmpty(existed.getId()) && !existed.getId().equals(user.getId())) { throw new ServiceException("该手机号已存在小程序用户"); } + // 后台编辑不改密码,避免空串覆盖 + user.setPassword(null); boolean updated = updateById(user); if (updated) { MpUserCache.clear(); @@ -130,6 +150,79 @@ public class MpUserServiceImpl extends BaseServiceImpl imp return updated; } + @Override + @Transactional(rollbackFor = Exception.class) + public boolean submitOrUpdate(MpUser user) { + if (user != null && Func.isNotEmpty(user.getId())) { + return updateUser(user); + } + return submit(user); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeMpUser(List ids) { + if (Func.isEmpty(ids)) { + throw new ServiceException("请选择要删除的小程序用户"); + } + boolean removed = deleteLogic(ids); + if (removed) { + MpUserCache.clear(); + } + return removed; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean changeStatus(Long id, Integer status) { + if (Func.isEmpty(id)) { + throw new ServiceException("小程序用户主键不能为空"); + } + if (status == null || (status != 0 && status != 1)) { + throw new ServiceException("状态仅支持启用(1)或停用(0)"); + } + MpUser user = getById(id); + if (user == null || Func.isEmpty(user.getId())) { + throw new ServiceException("小程序用户不存在"); + } + MpUser patch = new MpUser(); + patch.setId(id); + patch.setStatus(status); + boolean updated = updateById(patch); + if (updated) { + MpUserCache.clear(); + } + return updated; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Map resetPassword(List userIds) { + if (Func.isEmpty(userIds)) { + throw new ServiceException("请选择要重置密码的小程序用户"); + } + Map passwordMap = new LinkedHashMap<>(); + for (Long userId : userIds) { + MpUser user = getById(userId); + if (user == null || Func.isEmpty(user.getId())) { + continue; + } + String password = PasswordRuleUtil.generate(); + MpUser patch = new MpUser(); + patch.setId(userId); + patch.setPassword(DigestUtil.encrypt(password)); + patch.setUpdateTime(DateUtil.now()); + updateById(patch); + String account = Func.toStr(user.getAccount(), user.getPhone()); + passwordMap.put(account, password); + } + if (passwordMap.isEmpty()) { + throw new ServiceException("未找到可重置密码的小程序用户"); + } + MpUserCache.clear(); + return passwordMap; + } + @Override @Transactional(rollbackFor = Exception.class) public boolean bindOpenId(String tenantId, Long userId, String openid) { @@ -207,6 +300,9 @@ public class MpUserServiceImpl extends BaseServiceImpl imp if (user == null) { return; } + if (Func.isBlank(user.getTenantId())) { + user.setTenantId(AuthUtil.getTenantId()); + } user.setAccount(trimToNull(user.getAccount())); user.setPhone(trimToNull(user.getPhone())); user.setName(trimToNull(user.getName())); @@ -229,6 +325,18 @@ public class MpUserServiceImpl extends BaseServiceImpl imp } } + private void encryptPasswordIfPresent(MpUser user) { + if (user == null || Func.isBlank(user.getPassword())) { + user.setPassword(null); + return; + } + String password = user.getPassword().trim(); + if (!PasswordRuleUtil.isValid(password)) { + throw new ServiceException(PasswordRuleUtil.RULE_MESSAGE); + } + user.setPassword(DigestUtil.encrypt(password)); + } + private void validate(MpUser user) { if (user == null) { throw new ServiceException("小程序用户不能为空"); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/MpUserWrapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/MpUserWrapper.java new file mode 100644 index 00000000..2022dcd7 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/wrapper/MpUserWrapper.java @@ -0,0 +1,78 @@ +/** + * 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. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.system.wrapper; + +import org.springblade.core.mp.support.BaseEntityWrapper; +import org.springblade.core.tool.utils.BeanUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.cache.DictCache; +import org.springblade.system.cache.UserCache; +import org.springblade.system.pojo.entity.MpUser; +import org.springblade.system.pojo.enums.DictEnum; +import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.pojo.vo.MpUserVO; + +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 小程序用户包装类 + * + * @author Chill + */ +public class MpUserWrapper extends BaseEntityWrapper { + + public static MpUserWrapper build() { + return new MpUserWrapper(); + } + + @Override + public MpUserVO entityVO(MpUser mpUser) { + MpUserVO mpUserVO = Objects.requireNonNull(BeanUtil.copyProperties(mpUser, MpUserVO.class)); + // 密码哈希不下发前端 + mpUserVO.setPassword(null); + mpUserVO.setIdentitiesName(resolveIdentitiesName(mpUser.getIdentities())); + if (mpUser.getSex() != null) { + mpUserVO.setSexName(DictCache.getValue(DictEnum.SEX, mpUser.getSex())); + } + mpUserVO.setCreateUserName(UserCache.getUserRealName(mpUser.getCreateUser())); + mpUserVO.setUpdateUserName(UserCache.getUserRealName(mpUser.getUpdateUser())); + return mpUserVO; + } + + private String resolveIdentitiesName(String identities) { + List identityList = MpUserIdentity.split(identities); + if (identityList.isEmpty()) { + return ""; + } + return identityList.stream() + .map(code -> { + MpUserIdentity identity = MpUserIdentity.of(code); + return identity == null ? code : identity.getLabel(); + }) + .filter(Func::isNotBlank) + .collect(Collectors.joining(",")); + } + +} diff --git a/doc/sql/transport/blade_mp_user_menu_20261012.sql b/doc/sql/transport/blade_mp_user_menu_20261012.sql new file mode 100644 index 00000000..c918cd6b --- /dev/null +++ b/doc/sql/transport/blade_mp_user_menu_20261012.sql @@ -0,0 +1,90 @@ +-- ---------------------------- +-- 小程序用户管理菜单(挂在系统管理下) +-- 日期:2026-10-12 +-- ---------------------------- + +INSERT INTO `blade_menu` + (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) +VALUES + (2091901200000000101,1123598815738675203,'mp_user','小程序用户','menu','/system/mp-user','iconfont icon-yonghu',3,1,0,1,NULL,'小程序用户管理',0), + (2091901200000000102,2091901200000000101,'mp_user_add','新增','add','','',1,2,0,1,NULL,'',0), + (2091901200000000103,2091901200000000101,'mp_user_edit','编辑','edit','','',2,2,0,1,NULL,'',0), + (2091901200000000104,2091901200000000101,'mp_user_delete','删除','delete','','',3,2,0,1,NULL,'',0), + (2091901200000000105,2091901200000000101,'mp_user_view','查看','view','','',4,2,0,1,NULL,'',0), + (2091901200000000106,2091901200000000101,'mp_user_status','修改状态','status','','',5,2,0,1,NULL,'',0), + (2091901200000000107,2091901200000000101,'mp_user_reset','密码重置','reset','','',6,2,0,1,NULL,'',0) +ON DUPLICATE KEY UPDATE + `parent_id`=VALUES(`parent_id`), + `code`=VALUES(`code`), + `name`=VALUES(`name`), + `alias`=VALUES(`alias`), + `path`=VALUES(`path`), + `source`=VALUES(`source`), + `sort`=VALUES(`sort`), + `category`=VALUES(`category`), + `remark`=VALUES(`remark`), + `is_deleted`=0; + +-- 授权给超级管理员角色(幂等) +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000101 + (r.`id` % 1000), 2091901200000000101, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000101 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000102 + (r.`id` % 1000), 2091901200000000102, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000102 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000103 + (r.`id` % 1000), 2091901200000000103, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000103 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000104 + (r.`id` % 1000), 2091901200000000104, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000104 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000105 + (r.`id` % 1000), 2091901200000000105, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000105 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000106 + (r.`id` % 1000), 2091901200000000106, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000106 AND x.`role_id` = r.`id` + ); + +INSERT INTO `blade_role_menu` (`id`, `menu_id`, `role_id`) +SELECT 2091901200000000107 + (r.`id` % 1000), 2091901200000000107, r.`id` +FROM `blade_role` r +WHERE r.`role_alias` = 'administrator' + AND NOT EXISTS ( + SELECT 1 FROM `blade_role_menu` x + WHERE x.`menu_id` = 2091901200000000107 AND x.`role_id` = r.`id` + ); From d8b10377f0b023433e7029ffdec33989e8d798a4 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 12 Oct 2026 13:59:00 +0800 Subject: [PATCH 69/81] =?UTF-8?q?:bug:=20=E8=A1=A5=E9=BD=90=20DriverWaybil?= =?UTF-8?q?lServiceImpl=20=E5=8D=95=E6=B5=8B=E5=AF=B9=20IMpUserClient=20?= =?UTF-8?q?=E7=9A=84=E6=9E=84=E9=80=A0=E6=B3=A8=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../transport/service/impl/DriverNodePunchPhotoTest.java | 2 ++ .../transport/service/impl/DriverPunchNodeCardTest.java | 2 ++ .../transport/service/impl/DriverWaybillAutoCompleteTest.java | 2 ++ .../transport/service/impl/DriverWaybillCardLocationTest.java | 2 ++ .../transport/service/impl/DriverWaybillNodePunchTest.java | 2 ++ .../transport/service/impl/DriverWaybillOwnershipTest.java | 2 ++ .../transport/service/impl/DriverWaybillRejectDetachTest.java | 2 ++ 7 files changed, 14 insertions(+) diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java index d608a8ea..86b9a3e7 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -12,6 +12,7 @@ import org.mockito.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -92,6 +93,7 @@ class DriverNodePunchPhotoTest { service = new DriverWaybillServiceImpl( waybillService, driverService, + mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index a3669df1..ed1e5495 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -85,6 +86,7 @@ class DriverPunchNodeCardTest { service = new DriverWaybillServiceImpl( waybillService, mock(IDriverService.class), + mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index d7b86e23..1aef3f7c 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -9,6 +9,7 @@ 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.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -92,6 +93,7 @@ class DriverWaybillAutoCompleteTest { service = new DriverWaybillServiceImpl( waybillService, driverService, + mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java index 92e98433..86033d39 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -71,6 +72,7 @@ class DriverWaybillCardLocationTest { service = new DriverWaybillServiceImpl( waybillService, mock(IDriverService.class), + mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java index a045e054..483ed637 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -53,6 +54,7 @@ class DriverWaybillNodePunchTest { service = new DriverWaybillServiceImpl( mock(IWaybillService.class), mock(IDriverService.class), + mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index 353c3f59..035a2db1 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -12,6 +12,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -65,6 +66,7 @@ class DriverWaybillOwnershipTest { service = new DriverWaybillServiceImpl( waybillService, driverService, + mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), mock(CertificationGate.class), diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index 096a837c..3553c9c3 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -15,6 +15,7 @@ import org.mockito.ArgumentMatchers; import org.mockito.MockedStatic; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.system.feign.IMpUserClient; import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; @@ -80,6 +81,7 @@ class DriverWaybillRejectDetachTest { service = new DriverWaybillServiceImpl( waybillService, driverService, + mock(IMpUserClient.class), mock(IProcessConfigService.class), mock(ITransportVehicleService.class), certificationGate, From c3edca04a5d5c5c41e66b92d7e69640395a2552e Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 12 Oct 2026 14:22:33 +0800 Subject: [PATCH 70/81] =?UTF-8?q?:zap:=20=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E5=AF=86=E7=A0=81/=E7=9F=AD=E4=BF=A1=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=8C=89=20User-Type=3Dmp=20=E8=B5=B0=20blade=5Fmp=5Fuser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../auth/endpoint/Oauth2SmsEndpoint.java | 3 +- .../auth/service/BladeUserDetailService.java | 69 ++++++++++--------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java index b48ac804..dfa3b898 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/Oauth2SmsEndpoint.java @@ -88,7 +88,8 @@ public class Oauth2SmsEndpoint { return R.fail(USER_PHONE_NOT_FOUND); } // 校验手机是否已注册,防止恶意发送验证码 - OAuth2Request request = OAuth2Request.create(); + // buildArgs:带上 User-Type 等请求头,小程序传 User-Type=mp 时查 blade_mp_user + OAuth2Request request = OAuth2Request.create().buildArgs(); request.setTenantId(tenantId); OAuth2User oAuth2User = userService.loadByPhone(decryptedPhone, request); if (oAuth2User == null) { diff --git a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java index fa5cbc46..22904efe 100644 --- a/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java +++ b/blade-auth/src/main/java/org/springblade/auth/service/BladeUserDetailService.java @@ -53,13 +53,8 @@ public class BladeUserDetailService implements OAuth2UserService { @Override public OAuth2User loadByUserId(String userId, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); - - // 小程序刷新/鉴权:直接查 blade_mp_user - if (MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(userType)) { + // 小程序刷新/鉴权:User-Type=mp 时只查 blade_mp_user + if (isMpLoginRequest(request)) { OAuth2User mpUser = loadMpUserById(userId); if (mpUser == null) { OAuth2ExceptionUtil.throwFromCode(OAuth2ErrorCode.INVALID_USER); @@ -67,14 +62,14 @@ public class BladeUserDetailService implements OAuth2UserService { return mpUser; } - // 系统用户 + String userType = resolveUserType(request); R result = userClient.userInfo(Func.toLong(userId), userType); if (result != null && result.isSuccess() && result.getData() != null && result.getData().getUser() != null) { return TokenUtil.convertUser(result.getData(), request); } - // 兼容:未带 userType=mp 时,回落查询小程序用户(令牌刷新) + // 兼容:刷新令牌未带 User-Type=mp 时,回落查询小程序用户 OAuth2User mpUser = loadMpUserById(userId); if (mpUser != null) { return mpUser; @@ -85,14 +80,10 @@ public class BladeUserDetailService implements OAuth2UserService { @Override public OAuth2User loadByUsername(String username, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); - // 小程序用户:按账号查 blade_mp_user - if (MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(userType)) { + // 小程序账号密码 / 图形验证码登录:仅查 blade_mp_user + if (isMpLoginRequest(request)) { R mpResult = mpUserClient.userInfoByAccount(tenantId, username); if (mpResult == null || !mpResult.isSuccess() || mpResult.getData() == null) { return null; @@ -100,43 +91,55 @@ public class BladeUserDetailService implements OAuth2UserService { return TokenUtil.convertMpUser(mpResult.getData()); } - // 获取用户信息 + String userType = resolveUserType(request); R result = userClient.userInfo(tenantId, username, userType); - if (!result.isSuccess()) { + if (result == null || !result.isSuccess()) { return null; } - // 构建oauth2用户信息 return TokenUtil.convertUser(result.getData(), request); } @Override public OAuth2User loadByPhone(String phone, OAuth2Request request) { - // 获取用户参数 - String userType = Optional.ofNullable(request.getUserType()) - .filter(s -> !StringUtil.isBlank(s)) - .orElse(UserType.WEB.getName()); String tenantId = request.getTenantId(); - // 小程序用户类型:仅查 blade_mp_user - if (MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(userType)) { + // 小程序短信登录:仅查 blade_mp_user + if (isMpLoginRequest(request)) { return loadMpUserByPhone(tenantId, phone); } - // 获取用户信息 + String userType = resolveUserType(request); R result = userClient.userInfoByPhone(tenantId, phone, userType); - if (!result.isSuccess()) { - return null; + if (result != null && result.isSuccess() && result.getData() != null) { + return TokenUtil.convertUser(result.getData(), request); } - // 构建oauth2用户信息 - return TokenUtil.convertUser(result.getData(), request); + // 发短信校验等未带 User-Type 时,回落小程序用户,避免 mp 账号收不到验证码 + return loadMpUserByPhone(tenantId, phone); } @Override public boolean validateUser(OAuth2User user) { return Optional.ofNullable(user) - .filter(u -> u.getUserId() != null && !u.getUserId().isEmpty()) // 检查userId不为空 - .filter(u -> u.getAuthorities() != null && !u.getAuthorities().isEmpty()) // 检查authorities不为空 - .isPresent(); // 如果上述条件都满足,则返回true,否则返回false + .filter(u -> u.getUserId() != null && !u.getUserId().isEmpty()) + .filter(u -> u.getAuthorities() != null && !u.getAuthorities().isEmpty()) + .isPresent(); + } + + /** + * 是否小程序登录:请求头 User-Type=mp(Web 与小程序共用 saber3 客户端,不能按 clientId 分流) + */ + private boolean isMpLoginRequest(OAuth2Request request) { + return request != null && isMpUserType(request.getUserType()); + } + + private boolean isMpUserType(String userType) { + return MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(userType)); + } + + private String resolveUserType(OAuth2Request request) { + return Optional.ofNullable(request.getUserType()) + .filter(s -> !StringUtil.isBlank(s)) + .orElse(UserType.WEB.getName()); } private OAuth2User loadMpUserById(String userId) { @@ -150,7 +153,7 @@ public class BladeUserDetailService implements OAuth2UserService { return TokenUtil.convertMpUser(result.getData()); } } catch (Exception exception) { - // 非小程序用户场景忽略,继续走系统用户 + // 非小程序用户场景忽略 } return null; } From ea1829cd7529fa8444005adfc8557773e7f144bc Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Mon, 12 Oct 2026 14:47:17 +0800 Subject: [PATCH 71/81] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=B0=8F?= =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E4=BB=A4=E7=89=8C=E4=B8=A5=E6=A0=BC=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=20401=EF=BC=8C=E5=B9=B6=E5=BC=80=E6=94=BE=E8=B0=83?= =?UTF-8?q?=E5=BA=A6=E7=AB=AF=E6=89=93=E5=8D=A1=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../OAuth2UserInfoResponseAdvice.java | 15 ++------------- .../org/springblade/auth/utils/TokenUtil.java | 12 ++++++++---- .../springblade/system/utils/MpUserAuth.java | 14 ++++++++++++-- .../controller/ManageWaybillController.java | 19 +++++++++++++++++++ 4 files changed, 41 insertions(+), 19 deletions(-) diff --git a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java index 3c190cc4..32e1e1e3 100644 --- a/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java +++ b/blade-auth/src/main/java/org/springblade/auth/endpoint/OAuth2UserInfoResponseAdvice.java @@ -30,11 +30,11 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.oauth2.endpoint.OAuth2TokenEndPoint; import org.springblade.core.secure.BladeUser; import org.springblade.core.tool.api.R; -import org.springblade.core.tool.support.Kv; import org.springblade.core.tool.utils.BeanUtil; import org.springblade.core.tool.utils.Func; import org.springblade.system.feign.ISysClient; import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.utils.MpUserAuth; import org.springframework.core.MethodParameter; import org.springframework.http.MediaType; import org.springframework.http.converter.HttpMessageConverter; @@ -74,7 +74,7 @@ public class OAuth2UserInfoResponseAdvice implements ResponseBodyAdvice if (userInfo == null) { return body; } - if (isMpUser(bladeUser)) { + if (MpUserAuth.isMpUser(bladeUser)) { // 小程序用户:按身份直接生成权限,不走系统角色菜单 userInfo.setPermission(MpUserIdentity.toPermissions(bladeUser.getRoleName())); } else { @@ -83,17 +83,6 @@ public class OAuth2UserInfoResponseAdvice implements ResponseBodyAdvice return userInfo; } - private boolean isMpUser(BladeUser bladeUser) { - Kv detail = bladeUser.getDetail(); - if (detail != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(detail.get("type")))) { - return true; - } - // 兼容:无系统角色且 roleName 为小程序身份 - return Func.isBlank(bladeUser.getRoleId()) - && Func.isNotBlank(bladeUser.getRoleName()) - && !MpUserIdentity.split(bladeUser.getRoleName()).isEmpty(); - } - private List loadPermission(String roleId) { if (Func.isBlank(roleId)) { return Collections.emptyList(); diff --git a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java index 35a61d95..c4380b43 100644 --- a/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java +++ b/blade-auth/src/main/java/org/springblade/auth/utils/TokenUtil.java @@ -36,6 +36,7 @@ import org.springblade.system.pojo.entity.MpUserInfo; import org.springblade.system.pojo.entity.User; import org.springblade.system.pojo.entity.UserInfo; import org.springblade.system.pojo.enums.MpUserIdentity; +import org.springblade.system.utils.MpUserAuth; /** * 认证工具类 @@ -107,10 +108,13 @@ public class TokenUtil { userDetail.setAccount(user.getAccount()); userDetail.setPassword(user.getPassword()); userDetail.setPhone(user.getPhone()); - userDetail.setDeptId(user.getDeptId()); - userDetail.setPostId(user.getPostId()); - // 小程序用户不绑定系统角色,roleId 留空;身份放入 roleName/authorities - userDetail.setRoleId(""); + // 严格令牌模式要求 deptId/roleId 非空且 >=0;小程序用户用占位值,身份写在 roleName/authorities + userDetail.setDeptId(Func.toStr(user.getDeptId(), MpUserAuth.PLACEHOLDER_ID)); + if (Func.isBlank(userDetail.getDeptId()) || "-1".equals(userDetail.getDeptId())) { + userDetail.setDeptId(MpUserAuth.PLACEHOLDER_ID); + } + userDetail.setPostId(Func.toStr(user.getPostId(), MpUserAuth.PLACEHOLDER_ID)); + userDetail.setRoleId(MpUserAuth.PLACEHOLDER_ID); userDetail.setRoleName(Func.join(userInfo.getIdentities())); userDetail.setAvatar(user.getAvatar()); userDetail.setAuthorities(userInfo.getIdentities()); diff --git a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java index b1b54e77..d2e6482d 100644 --- a/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java +++ b/blade-service-api/blade-user-api/src/main/java/org/springblade/system/utils/MpUserAuth.java @@ -35,6 +35,14 @@ import org.springblade.system.pojo.enums.MpUserIdentity; */ public final class MpUserAuth { + /** + * 严格令牌模式下的占位 ID。 + *

+ * {@code blade.secure.strict-token=true} 时,AuthUtil.userIncomplete 要求 roleId/deptId + * 非空且数值 >= 0;小程序用户不绑定系统角色/部门,用 0 占位避免业务接口 401。 + */ + public static final String PLACEHOLDER_ID = "0"; + private MpUserAuth() { } @@ -56,8 +64,10 @@ public final class MpUserAuth { if (detail != null && MpUserIdentity.DETAIL_TYPE.equalsIgnoreCase(Func.toStr(detail.get("type")))) { return true; } - // 兼容:无系统角色且 roleName 为小程序身份 - return Func.isBlank(bladeUser.getRoleId()) + // 兼容:无真实系统角色(空或占位 0)且 roleName 为小程序身份 + String roleId = Func.toStr(bladeUser.getRoleId()); + boolean noSystemRole = Func.isBlank(roleId) || PLACEHOLDER_ID.equals(roleId); + return noSystemRole && Func.isNotBlank(bladeUser.getRoleName()) && !MpUserIdentity.split(bladeUser.getRoleName()).isEmpty(); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java index 6d381639..0b7dda41 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ManageWaybillController.java @@ -30,6 +30,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.AllArgsConstructor; import org.springblade.core.boot.ctrl.BladeController; import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.AdminDriverOptionVO; import org.springblade.transport.pojo.vo.AdminHomeStatsVO; @@ -37,7 +38,9 @@ import org.springblade.transport.pojo.vo.AdminHomeVO; import org.springblade.transport.pojo.vo.AdminVehicleOptionVO; import org.springblade.transport.pojo.vo.AdminWaybillCardVO; import org.springblade.transport.pojo.vo.AdminWaybillDetailVO; +import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.service.IManageWaybillService; +import org.springblade.transport.service.IWaybillService; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -62,6 +65,7 @@ import java.util.List; public class ManageWaybillController extends BladeController { private final IManageWaybillService manageWaybillService; + private final IWaybillService waybillService; @GetMapping("/stats") @ApiOperationSupport(order = 1) @@ -140,4 +144,19 @@ public class ManageWaybillController extends BladeController { return R.data(manageWaybillService.searchVehicles(keyword)); } + @GetMapping("/punch-records") + @ApiOperationSupport(order = 9) + @Operation(summary = "打卡记录", description = "小程序调度端:仅需登录态;按过程配置返回节点/在途打卡流水") + public R punchRecords( + @Parameter(description = "运单ID", required = true) @RequestParam Long waybillId) { + return R.data(waybillService.listPunchRecords(waybillId)); + } + + @PostMapping("/punch-record/save") + @ApiOperationSupport(order = 10) + @Operation(summary = "编辑打卡记录", description = "小程序调度端:补录/订正/取消打卡,仅需登录态") + public R savePunchRecord(@RequestBody WaybillPunchRecordSaveRequest request) { + return R.status(waybillService.savePunchRecord(request)); + } + } From e0da79d745c03d3d23716fecfe21d3db173cdba8 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 13 Oct 2026 12:44:39 +0800 Subject: [PATCH 72/81] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=20MinIO=20?= =?UTF-8?q?=E9=A2=84=E7=AD=BE=E5=90=8D=E8=B5=B0=E5=A4=96=E7=BD=91=20HTTPS?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E8=BF=87=E6=BB=A4=E6=97=A0=E6=95=88=E5=BA=94?= =?UTF-8?q?=E6=94=B6=E5=BA=94=E4=BB=98=E5=8F=98=E6=9B=B4=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/file/config/FileConfig.java | 8 +- .../service/impl/MinioFileServiceImpl.java | 90 +++++++++++++++++-- .../ReceivablePayableDetailServiceImpl.java | 87 ++++++++++++++---- doc/nacos/blade-prod.yaml | 6 ++ org/springblade/core/tool/node/TreeNode.java | 72 --------------- 5 files changed, 166 insertions(+), 97 deletions(-) delete mode 100644 org/springblade/core/tool/node/TreeNode.java diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java index 64632b22..88d14991 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java @@ -95,7 +95,13 @@ public class FileConfig { FileStorageProperties.StoreProperties mergedProperties = new FileStorageProperties.StoreProperties(); String fileStoragePrefix = "file.storage." + prefix; mergedProperties.setEndpoint(firstNotBlank(properties.getEndpoint(), environment.getProperty(fileStoragePrefix + ".endpoint"), environment.getProperty(prefix + ".endpoint"))); - mergedProperties.setTransformEndpoint(firstNotBlank(properties.getTransformEndpoint(), environment.getProperty(fileStoragePrefix + ".transform-endpoint"), environment.getProperty(prefix + ".transform-endpoint"))); + mergedProperties.setTransformEndpoint(firstNotBlank( + properties.getTransformEndpoint(), + environment.getProperty(fileStoragePrefix + ".transform-endpoint"), + environment.getProperty(prefix + ".transform-endpoint"), + // 兼容 blade-resource / oss 配置,避免仅改对象存储外网地址时分片上传仍走内网 HTTP + environment.getProperty("oss.transform-endpoint") + )); mergedProperties.setAccessKeyId(firstNotBlank(properties.getAccessKeyId(), environment.getProperty(fileStoragePrefix + ".access-key-id"), environment.getProperty(fileStoragePrefix + ".access-key"), environment.getProperty(prefix + ".access-key-id"), environment.getProperty(prefix + ".access-key"))); mergedProperties.setAccessKeySecret(firstNotBlank(properties.getAccessKeySecret(), environment.getProperty(fileStoragePrefix + ".access-key-secret"), environment.getProperty(fileStoragePrefix + ".secret-key"), environment.getProperty(prefix + ".access-key-secret"), environment.getProperty(prefix + ".secret-key"))); mergedProperties.setBucketName(firstNotBlank(properties.getBucketName(), environment.getProperty(fileStoragePrefix + ".bucket-name"), environment.getProperty(prefix + ".bucket-name"))); diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index 5214c628..4c8f53a5 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -25,11 +25,11 @@ */ package org.springblade.file.service.impl; +import io.minio.ComposeObjectArgs; +import io.minio.ComposeSource; import io.minio.GetObjectArgs; import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; -import io.minio.ComposeObjectArgs; -import io.minio.ComposeSource; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; import io.minio.UploadObjectArgs; @@ -42,6 +42,7 @@ import org.springblade.file.config.FileStorageProperties; import java.io.File; import java.io.InputStream; +import java.net.URI; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; @@ -64,6 +65,7 @@ public class MinioFileServiceImpl extends AbstractFileService { private final FileStorageProperties.StoreProperties properties; private final MinioClient minioClient; + private volatile MinioClient transformPresignClient; @Override public String initiateMultipartUpload(String objectKey) { @@ -73,9 +75,13 @@ public class MinioFileServiceImpl extends AbstractFileService { @Override public String getUploadPartUrl(String objectKey, String uploadId, Integer partNumber, Long expires) { try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() - .method(Method.PUT).bucket(properties.getBucketName()).object(partObjectKey(objectKey, uploadId, partNumber)) - .expiry(Math.toIntExact(resolveExpires(expires))).build()); + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + .method(Method.PUT) + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .expiry(Math.toIntExact(resolveExpires(expires))) + .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取分片上传链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("获取分片上传链接异常"); @@ -143,12 +149,13 @@ public class MinioFileServiceImpl extends AbstractFileService { return null; } try { - return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() + String presignedUrl = resolvePresignClient().getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder() .method(Method.GET) .bucket(properties.getBucketName()) .object(resolveObjectKey(objectKey)) .expiry(Math.toIntExact(resolveExpires(expires))) .build()); + return applyTransformPathPrefix(presignedUrl); } catch (Exception exception) { log.error("MinIO获取文件链接异常 objectKey:{}", objectKey, exception); throw new ServiceException("MinIO获取文件链接异常"); @@ -170,6 +177,77 @@ public class MinioFileServiceImpl extends AbstractFileService { } } + /** + * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 + * MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 + */ + private MinioClient resolvePresignClient() { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint)) { + return minioClient; + } + MinioClient cachedClient = transformPresignClient; + if (cachedClient != null) { + return cachedClient; + } + synchronized (this) { + if (transformPresignClient == null) { + transformPresignClient = MinioClient.builder() + .endpoint(resolveTransformOrigin(transformEndpoint)) + .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) + .build(); + } + return transformPresignClient; + } + } + + private String resolveTransformOrigin(String transformEndpoint) { + URI transformUri = URI.create(transformEndpoint.trim()); + if (StringUtil.isBlank(transformUri.getScheme()) || StringUtil.isBlank(transformUri.getHost())) { + throw new ServiceException("MinIO外网地址配置不正确"); + } + StringBuilder origin = new StringBuilder(); + origin.append(transformUri.getScheme()).append("://").append(transformUri.getHost()); + if (transformUri.getPort() > 0) { + origin.append(':').append(transformUri.getPort()); + } + return origin.toString(); + } + + private String applyTransformPathPrefix(String presignedUrl) { + String transformEndpoint = properties.getTransformEndpoint(); + if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) { + return presignedUrl; + } + String pathPrefix = normalizePathPrefix(URI.create(transformEndpoint.trim()).getPath()); + if (StringUtil.isBlank(pathPrefix)) { + return presignedUrl; + } + URI url = URI.create(presignedUrl); + String path = url.getRawPath() == null ? "" : url.getRawPath(); + if (path.equals(pathPrefix) || path.startsWith(pathPrefix + "/")) { + return presignedUrl; + } + StringBuilder result = new StringBuilder(); + result.append(url.getScheme()).append("://").append(url.getHost()); + if (url.getPort() > 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (StringUtil.isNotBlank(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String resolveObjectKey(String objectKey) { if (StringUtil.isBlank(properties.getRootDirectory())) { return objectKey; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index 9edd897e..0ce145f2 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -432,13 +432,23 @@ public class ReceivablePayableDetailServiceImpl @Override public IPage changeRecords(IPage page, Long detailId) { - LambdaQueryWrapper wrapper = Wrappers.lambdaQuery() - .eq(ReceivablePayableChangeRecord::getDetailId, detailId) - .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) - .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime); - IPage entityPage = changeRecordMapper.selectPage(new Page<>(page.getCurrent(), page.getSize()), wrapper); - Page voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal()); - voPage.setRecords(entityPage.getRecords().stream().map(record -> Objects.requireNonNull( + List meaningfulRecords = changeRecordMapper.selectList( + Wrappers.lambdaQuery() + .eq(ReceivablePayableChangeRecord::getDetailId, detailId) + .eq(ReceivablePayableChangeRecord::getIsDeleted, 0) + .orderByDesc(ReceivablePayableChangeRecord::getAdjustTime)) + .stream() + .filter(record -> hasMeaningfulChange(record.getChangeContent())) + .toList(); + long current = page.getCurrent(); + long size = page.getSize(); + int fromIndex = (int) Math.max(0L, (current - 1L) * size); + int toIndex = (int) Math.min(meaningfulRecords.size(), fromIndex + size); + List pageRecords = fromIndex >= meaningfulRecords.size() + ? List.of() + : meaningfulRecords.subList(fromIndex, toIndex); + Page voPage = new Page<>(current, size, meaningfulRecords.size()); + voPage.setRecords(pageRecords.stream().map(record -> Objects.requireNonNull( BeanUtil.copyProperties(record, ReceivablePayableChangeRecordVO.class))).toList()); return voPage; } @@ -613,14 +623,8 @@ public class ReceivablePayableDetailServiceImpl appendChange(changes, "运输单价", existing.getUnitPrice(), adjusted.getUnitPrice()); appendChange(changes, "里程", existing.getMileage(), mileage); appendChange(changes, "运输费", existing.getFreightAmount(), freightAmount); - if (!Objects.equals(existing.getRemark(), adjusted.getRemark())) { - changes.add("【备注】从[" + Objects.toString(existing.getRemark(), "") + "]调整为[" - + Objects.toString(adjusted.getRemark(), "") + "]"); - } - if (!Objects.equals(existing.getChangeReason(), adjusted.getChangeReason())) { - changes.add("【变更原因】从[" + Objects.toString(existing.getChangeReason(), "") + "]调整为[" - + Objects.toString(adjusted.getChangeReason(), "") + "]"); - } + appendChange(changes, "备注", existing.getRemark(), adjusted.getRemark()); + appendChange(changes, "变更原因", existing.getChangeReason(), adjusted.getChangeReason()); Map oldFeeItems = parseMap(existing.getFeeItemsJson()); for (String name : allowedFeeItems) { appendChange(changes, name, decimal(oldFeeItems.get(name)), money(effectiveFeeItems.get(name))); @@ -2367,12 +2371,56 @@ public class ReceivablePayableDetailServiceImpl } private void appendChange(List changes, String field, String before, String after) { - if (!Objects.equals(before, after)) { - changes.add("【" + field + "】从[" + Objects.toString(before, "") + "]调整为[" - + Objects.toString(after, "") + "]"); + String oldValue = normalizeChangeText(before); + String newValue = normalizeChangeText(after); + if (!Objects.equals(oldValue, newValue)) { + changes.add("【" + field + "】从[" + oldValue + "]调整为[" + newValue + "]"); } } + /** + * 变更文本归一化:null / 空白视为相同,避免出现「从[]调整为[]」无效记录 + */ + private String normalizeChangeText(String value) { + return value == null ? "" : value.trim(); + } + + /** + * 是否存在实际前后差异;整段内容均无变化时不展示 + */ + private boolean hasMeaningfulChange(String content) { + if (Func.isBlank(content)) { + return false; + } + String text = content.trim(); + int suffixStart = text.lastIndexOf('('); + if (suffixStart > 0 && text.endsWith(")")) { + text = text.substring(0, suffixStart); + } + boolean hasPart = false; + for (String part : text.split(";")) { + if (Func.isBlank(part)) { + continue; + } + hasPart = true; + if (isMeaningfulChangePart(part.trim())) { + return true; + } + } + return !hasPart; + } + + private boolean isMeaningfulChangePart(String part) { + int fromIdx = part.indexOf("从["); + int toIdx = part.indexOf("]调整为["); + if (fromIdx < 0 || toIdx < fromIdx || !part.endsWith("]")) { + return true; + } + String before = part.substring(fromIdx + 2, toIdx); + String after = part.substring(toIdx + 5, part.length() - 1); + return !Objects.equals(before, after); + } + private String formatValue(BigDecimal value) { return money(value).stripTrailingZeros().toPlainString(); } @@ -2617,6 +2665,9 @@ public class ReceivablePayableDetailServiceImpl } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo) { + if (!hasMeaningfulChange(content)) { + return; + } ReceivablePayableChangeRecord record = new ReceivablePayableChangeRecord(); record.setDetailId(detail.getId()); record.setLineNo(lineNo); diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index ab3d3562..5aabf2ac 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -120,6 +120,12 @@ baidu: request-timeout: 30s token-refresh-advance: 1m +# 凭证分片上传等走 blade-file,外网地址需单独配置(与 /resource/oss 的 transformEndpoint 保持一致) +file: + storage: + minio: + transform-endpoint: ${MINIO_TRANSFORM_ENDPOINT:https://tms-prod.xinghy56.com/oss} + powerjob: worker: server-address: 172.16.203.228:7700 diff --git a/org/springblade/core/tool/node/TreeNode.java b/org/springblade/core/tool/node/TreeNode.java deleted file mode 100644 index 114aa0c0..00000000 --- a/org/springblade/core/tool/node/TreeNode.java +++ /dev/null @@ -1,72 +0,0 @@ -/** - * 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.core.tool.node; - -import com.fasterxml.jackson.databind.annotation.JsonSerialize; -import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; -import lombok.Data; -import org.springblade.core.tool.utils.Func; - -import java.io.Serial; -import java.util.Objects; - -/** - * 树型节点类 - * - * @author smallchill - */ -@Data -public class TreeNode extends BaseNode { - - @Serial - private static final long serialVersionUID = 1L; - - private String title; - - @JsonSerialize(using = ToStringSerializer.class) - private Long key; - - @JsonSerialize(using = ToStringSerializer.class) - private Long value; - - @Override - public boolean equals(Object obj) { - if (this == obj) { - return true; - } - if (obj == null) { - return false; - } - TreeNode other = (TreeNode) obj; - return Func.equals(this.getId(), other.getId()); - } - - @Override - public int hashCode() { - return Objects.hash(id, parentId); - } - -} From 44b4ae2d74d72109b718d5ef4e43ef94529542ad Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 13 Oct 2026 12:55:15 +0800 Subject: [PATCH 73/81] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E8=BD=A6?= =?UTF-8?q?=E8=BE=86=E6=9C=AA=E5=A1=AB=E5=A4=96=E5=BB=93=E5=B0=BA=E5=AF=B8?= =?UTF-8?q?=E6=97=B6=E8=AF=A6=E6=83=85=E6=98=BE=E7=A4=BA=20-1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../service/impl/TransportVehicleServiceImpl.java | 15 +++++++++++++++ .../wrapper/TransportVehicleWrapper.java | 12 ++++++++++++ 2 files changed, 27 insertions(+) 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 467fb555..e28d7329 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 @@ -225,6 +225,11 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl Date: Tue, 13 Oct 2026 20:44:09 +0800 Subject: [PATCH 74/81] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=E5=87=AD?= =?UTF-8?q?=E8=AF=81=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=20Mixed=20Content?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=9C=8D=E5=8A=A1=E7=AB=AF=E4=BB=A3?= =?UTF-8?q?=E4=BC=A0=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/file/config/FileConfig.java | 20 +++++++++-- .../file/controller/FileTaskController.java | 22 +++++++++++++ .../file/service/IFileTaskService.java | 10 ++++++ .../service/impl/FileTaskServiceImpl.java | 27 +++++++++++++++ .../service/impl/MinioFileServiceImpl.java | 33 +++++++++++++++++++ doc/nacos/blade-prod.yaml | 5 +-- 6 files changed, 112 insertions(+), 5 deletions(-) diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java index 88d14991..3e578fef 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java @@ -3,6 +3,7 @@ package org.springblade.file.config; import com.aliyun.oss.OSSClientBuilder; import com.obs.services.ObsClient; import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.service.IFileService; @@ -20,6 +21,7 @@ import org.springframework.core.env.Environment; * @author bfhuange * @date 2024/10/21 */ +@Slf4j @Configuration public class FileConfig { @@ -63,6 +65,15 @@ public class FileConfig { private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) { validateStoreProperties(STORAGE_MINIO, properties); + if (StringUtil.isBlank(properties.getTransformEndpoint()) + && StringUtil.isNotBlank(properties.getEndpoint()) + && properties.getEndpoint().trim().toLowerCase().startsWith("http://")) { + log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传", + properties.getEndpoint()); + } else { + log.info("MinIO 文件存储已启用 endpoint={} transformEndpoint={}", + properties.getEndpoint(), properties.getTransformEndpoint()); + } MinioClient minioClient = MinioClient.builder() .endpoint(properties.getEndpoint()) .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) @@ -95,12 +106,15 @@ public class FileConfig { FileStorageProperties.StoreProperties mergedProperties = new FileStorageProperties.StoreProperties(); String fileStoragePrefix = "file.storage." + prefix; mergedProperties.setEndpoint(firstNotBlank(properties.getEndpoint(), environment.getProperty(fileStoragePrefix + ".endpoint"), environment.getProperty(prefix + ".endpoint"))); + // 资源管理页 /resource/oss 的外网地址写在库表,不进 Spring Environment;凭证分片必须单独配 file.storage.minio.transform-endpoint mergedProperties.setTransformEndpoint(firstNotBlank( properties.getTransformEndpoint(), environment.getProperty(fileStoragePrefix + ".transform-endpoint"), + environment.getProperty(fileStoragePrefix + ".transformEndpoint"), environment.getProperty(prefix + ".transform-endpoint"), - // 兼容 blade-resource / oss 配置,避免仅改对象存储外网地址时分片上传仍走内网 HTTP - environment.getProperty("oss.transform-endpoint") + environment.getProperty(prefix + ".transformEndpoint"), + environment.getProperty("oss.transform-endpoint"), + environment.getProperty("oss.transformEndpoint") )); mergedProperties.setAccessKeyId(firstNotBlank(properties.getAccessKeyId(), environment.getProperty(fileStoragePrefix + ".access-key-id"), environment.getProperty(fileStoragePrefix + ".access-key"), environment.getProperty(prefix + ".access-key-id"), environment.getProperty(prefix + ".access-key"))); mergedProperties.setAccessKeySecret(firstNotBlank(properties.getAccessKeySecret(), environment.getProperty(fileStoragePrefix + ".access-key-secret"), environment.getProperty(fileStoragePrefix + ".secret-key"), environment.getProperty(prefix + ".access-key-secret"), environment.getProperty(prefix + ".secret-key"))); @@ -113,7 +127,7 @@ public class FileConfig { private String firstNotBlank(String... values) { for (String value : values) { if (StringUtil.isNotBlank(value)) { - return value; + return value.trim(); } } return null; diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java index 62e4dabf..462de6e0 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/controller/FileTaskController.java @@ -111,4 +111,26 @@ public class FileTaskController extends BladeController { String uploadUrl = fileTaskService.getUploadUrl(id, partNumber); return R.data(uploadUrl); } + + @ApiLog("上传任务-服务端代传分片") + @PostMapping(value = "/uploadPart", consumes = org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @Parameters({ + @Parameter(name = "id", description = "文件任务id", in = ParameterIn.QUERY, schema = @Schema(type = "string")), + @Parameter(name = "partNumber", description = "分段编号", in = ParameterIn.QUERY, schema = @Schema(type = "int")) + }) + @Operation(summary = "服务端代传分片", description = "HTTPS 页面无法直连 HTTP MinIO 时,由服务端转发分片到对象存储") + public R uploadPart(Long id, Integer partNumber, org.springframework.web.multipart.MultipartFile file) throws java.io.IOException { + if (file == null || file.isEmpty()) { + return R.fail("分片内容不能为空"); + } + java.io.File tempFile = java.io.File.createTempFile("file-part-", ".bin"); + try { + file.transferTo(tempFile); + return R.data(fileTaskService.uploadPart(id, partNumber, tempFile)); + } finally { + if (tempFile.exists() && !tempFile.delete()) { + tempFile.deleteOnExit(); + } + } + } } diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java index ae394521..f47120a4 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/IFileTaskService.java @@ -74,6 +74,16 @@ public interface IFileTaskService extends IService { */ String getUploadUrl(Long id, Integer partNumber); + /** + * 服务端代传分片(HTTPS 页面无法直连 HTTP MinIO 时使用) + * + * @param id 文件任务id + * @param partNumber 分段编号 + * @param partFile 分片临时文件 + * @return ETag + */ + String uploadPart(Long id, Integer partNumber, java.io.File partFile); + /** * 取消文件任务 * @param taskIds diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java index b92fc66e..25a46bab 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/FileTaskServiceImpl.java @@ -259,6 +259,33 @@ public class FileTaskServiceImpl extends ServiceImpl i return fileService.getUploadPartUrl(fileTask.getObjectKey(), fileTask.getUploadId(), partNumber, null); } + @Override + public String uploadPart(Long id, Integer partNumber, java.io.File partFile) { + FileTask fileTask = this.getById(id); + Func.requireNotNull(fileTask, "文件任务不存在"); + if (FileTaskStatus.COMPLETED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已完成"); + } + if (FileTaskStatus.PAUSED.getCode().equals(fileTask.getStatus())) { + throw new ServiceException("文件任务已暂停"); + } + if (partFile == null || !partFile.exists()) { + throw new ServiceException("分片文件不存在"); + } + org.springblade.file.pojo.vo.PartEtagVO partEtag = fileService.uploadPart( + fileTask.getObjectKey(), + fileTask.getUploadId(), + partFile, + partNumber, + 0L, + partFile.length() + ); + if (partEtag == null || StringUtil.isBlank(partEtag.getEtag())) { + throw new ServiceException("分片上传未返回 ETag"); + } + return partEtag.getEtag().replace("\"", ""); + } + @Transactional(rollbackFor = Exception.class) @Override public void cancelBatchByIds(List taskIds) { diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index 4c8f53a5..bc85679a 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -30,6 +30,7 @@ import io.minio.ComposeSource; import io.minio.GetObjectArgs; import io.minio.GetPresignedObjectUrlArgs; import io.minio.MinioClient; +import io.minio.ObjectWriteResponse; import io.minio.PutObjectArgs; import io.minio.RemoveObjectArgs; import io.minio.UploadObjectArgs; @@ -39,8 +40,10 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.config.FileStorageProperties; +import org.springblade.file.pojo.vo.PartEtagVO; import java.io.File; +import java.io.FileInputStream; import java.io.InputStream; import java.net.URI; import java.nio.file.Files; @@ -88,6 +91,36 @@ public class MinioFileServiceImpl extends AbstractFileService { } } + @Override + public PartEtagVO uploadPart(String objectKey, String uploadId, File file, int partNumber, Long offset, Long partSize) { + long start = offset == null ? 0L : offset; + long size = partSize == null ? Math.max(0L, file.length() - start) : partSize; + try (FileInputStream inputStream = new FileInputStream(file)) { + long skipped = inputStream.skip(start); + if (skipped < start) { + throw new ServiceException("MinIO分段上传读取文件偏移失败"); + } + ObjectWriteResponse response = minioClient.putObject(PutObjectArgs.builder() + .bucket(properties.getBucketName()) + .object(partObjectKey(objectKey, uploadId, partNumber)) + .stream(inputStream, size, PART_SIZE) + .build()); + String etag = response.etag() == null ? "" : response.etag().replace("\"", ""); + return new PartEtagVO(etag, partNumber); + } catch (ServiceException serviceException) { + throw serviceException; + } catch (Exception exception) { + log.error("MinIO分段上传异常 objectKey:{} partNumber:{}", objectKey, partNumber, exception); + throw new ServiceException("MinIO分段上传异常"); + } + } + + @Override + public void cancelMultipartUpload(String objectKey, String uploadId) { + // MinIO 分片以独立对象模拟,取消时由任务层清理业务状态即可 + log.debug("MinIO取消分段上传 objectKey:{} uploadId:{}", objectKey, uploadId); + } + @Override public void completeMultipartUpload(String objectKey, String uploadId, List partEtags) { try { diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index 5aabf2ac..889576ad 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -120,11 +120,12 @@ baidu: request-timeout: 30s token-refresh-advance: 1m -# 凭证分片上传等走 blade-file,外网地址需单独配置(与 /resource/oss 的 transformEndpoint 保持一致) +# 凭证分片上传走 blade-file,与 /resource/oss 库表配置相互独立;外网地址必须在此配置并发布到 Nacos 后重启 blade-file +# 不要使用可被空环境变量覆盖的 ${VAR:default},否则仍会签发 http://内网IP 预签名导致 Mixed Content file: storage: minio: - transform-endpoint: ${MINIO_TRANSFORM_ENDPOINT:https://tms-prod.xinghy56.com/oss} + transform-endpoint: https://tms-prod.xinghy56.com/oss powerjob: worker: From a7bf2588b6a0888febc49df5ec1dad132383346c Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 13 Oct 2026 21:39:53 +0800 Subject: [PATCH 75/81] =?UTF-8?q?:bug:=20=E5=87=AD=E8=AF=81=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A5=E5=8F=A3=E5=85=BC=E5=AE=B9=E6=9C=AA=E5=8E=BB?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E7=9A=84=20blade-transport=20=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../transport/controller/VoucherManageController.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java index b7e1ef2c..69f53eb0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/VoucherManageController.java @@ -29,10 +29,16 @@ import org.springframework.web.bind.annotation.RestController; import java.util.List; import java.util.Map; +/** + * 对外路径:{@code /blade-transport/voucher-manage/**} + *

+ * 网关 StripPrefix 后落入 {@code /voucher-manage/**}; + * 同时兼容未去前缀直连 {@code /blade-transport/voucher-manage/**},避免 404 落到静态资源。 + */ @RestController @AllArgsConstructor @PreAuth(menu = "voucher_manage") -@RequestMapping("/voucher-manage") +@RequestMapping({"/voucher-manage", "/blade-transport/voucher-manage"}) @Tag(name = "凭证管理", description = "执行凭证批次管理") public class VoucherManageController extends BladeController { private final IVoucherManageService voucherManageService; From 7c7fe2e1c186371076fd7dea49ffc650790c1d8b Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 13 Oct 2026 22:14:50 +0800 Subject: [PATCH 76/81] =?UTF-8?q?:bug:=20=E4=BF=AE=E5=A4=8D=20MinIO=20endp?= =?UTF-8?q?oint=20=E5=90=AB=20path=20=E5=AF=BC=E8=87=B4=20blade-transport?= =?UTF-8?q?=20=E5=90=AF=E5=8A=A8=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../transport/config/VoucherMinioConfig.java | 39 +++++++++- .../impl/VoucherManageServiceImpl.java | 71 ++++++++++++++++++- doc/nacos/blade-prod.yaml | 6 +- 3 files changed, 109 insertions(+), 7 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java index 08ed5e38..5c0db187 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java @@ -5,19 +5,28 @@ package org.springblade.transport.config; import io.minio.MinioClient; +import lombok.extern.slf4j.Slf4j; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.net.URI; + /** * 凭证图片 MinIO 客户端配置。 *

* 连接参数由 Nacos 的 file.storage.minio 配置提供。 + *

    + *
  • {@code endpoint}:内网地址,供服务端 put/get,不能带 path
  • + *
  • {@code transform-endpoint}:外网地址(可带 /oss),仅用于浏览器预签名
  • + *
* 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端, - * 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动; - * 此时凭证相关接口会在调用时给出明确提示,而非启动即失败。 + * 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动。 */ +@Slf4j @Configuration @ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint") public class VoucherMinioConfig { @@ -27,6 +36,30 @@ public class VoucherMinioConfig { @Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint, @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String accessKey, @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) { - return MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build(); + String clientEndpoint = resolveClientEndpoint(endpoint); + if (!clientEndpoint.equals(endpoint == null ? null : endpoint.trim())) { + log.warn("MinIO endpoint 含 path,已剥离为 {}(原值={})。服务端读写请配置内网 endpoint,外网请用 transform-endpoint", + clientEndpoint, endpoint); + } + return MinioClient.builder().endpoint(clientEndpoint).credentials(accessKey, secretKey).build(); + } + + /** + * MinIO SDK 不允许 endpoint 带 path(例如 https://host/oss)。 + */ + public static String resolveClientEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return origin.toString(); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java index 12e2c34e..16d35e08 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java @@ -88,6 +88,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl minioClientProvider; private final String minioBucketName; private final String minioRootDirectory; + private final String minioTransformEndpoint; + private final String minioAccessKey; + private final String minioSecretKey; + private volatile MinioClient transformPresignClient; public VoucherManageServiceImpl(VoucherWaybillBatchMapper voucherWaybillBatchMapper, IProjectApplyService projectApplyService, IWaybillService waybillService, @@ -95,7 +99,10 @@ public class VoucherManageServiceImpl extends BaseServiceImpl minioClientProvider, @Value("${file.storage.minio.bucket-name:${minio.bucket-name:}}") String minioBucketName, - @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory) { + @Value("${file.storage.minio.root-directory:${minio.root-directory:}}") String minioRootDirectory, + @Value("${file.storage.minio.transform-endpoint:${minio.transform-endpoint:}}") String minioTransformEndpoint, + @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String minioAccessKey, + @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String minioSecretKey) { this.voucherWaybillBatchMapper = voucherWaybillBatchMapper; this.projectApplyService = projectApplyService; this.waybillService = waybillService; @@ -106,6 +113,9 @@ public class VoucherManageServiceImpl extends BaseServiceImpl 0) { + result.append(':').append(url.getPort()); + } + result.append(pathPrefix).append(path); + if (Func.isNotEmpty(url.getRawQuery())) { + result.append('?').append(url.getRawQuery()); + } + return result.toString(); + } + + private String normalizePathPrefix(String path) { + if (Func.isEmpty(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + private String normalizePlateNo(String plateNo) { return plateNo == null ? null : plateNo.replaceAll("[\\s-]", "").toUpperCase(); } diff --git a/doc/nacos/blade-prod.yaml b/doc/nacos/blade-prod.yaml index 889576ad..4a8b3e3e 100644 --- a/doc/nacos/blade-prod.yaml +++ b/doc/nacos/blade-prod.yaml @@ -120,11 +120,13 @@ baidu: request-timeout: 30s token-refresh-advance: 1m -# 凭证分片上传走 blade-file,与 /resource/oss 库表配置相互独立;外网地址必须在此配置并发布到 Nacos 后重启 blade-file -# 不要使用可被空环境变量覆盖的 ${VAR:default},否则仍会签发 http://内网IP 预签名导致 Mixed Content +# 对象存储(blade-file 分片上传 + blade-transport 凭证读写共用) +# endpoint:内网地址,禁止带 path,供服务端 put/get(如 http://tms-prod-oss:9000 或 http://IP:19000) +# transform-endpoint:外网 HTTPS,可带 /oss,仅用于浏览器预签名;切勿把该值填到 endpoint file: storage: minio: + # endpoint: http://内网MinIO地址:端口 transform-endpoint: https://tms-prod.xinghy56.com/oss powerjob: From 64b4ad99fccc3075e4c88e23a3703c4aae1258d6 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Tue, 13 Oct 2026 22:36:14 +0800 Subject: [PATCH 77/81] =?UTF-8?q?:bug:=20=E5=A4=96=E7=BD=91=E9=A2=84?= =?UTF-8?q?=E7=AD=BE=E5=90=8D=E5=9B=BA=E5=AE=9A=20MinIO=20region=EF=BC=8C?= =?UTF-8?q?=E9=81=BF=E5=85=8D=20GetBucketLocation=20=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=E5=88=B0=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../file/service/impl/MinioFileServiceImpl.java | 8 ++++++++ .../transport/service/impl/VoucherManageServiceImpl.java | 6 +++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index bc85679a..45741733 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -65,6 +65,8 @@ import java.util.UUID; public class MinioFileServiceImpl extends AbstractFileService { private static final long PART_SIZE = 10 * 1024 * 1024L; + /** MinIO 默认 region;固定后预签名无需再请求外网域名做 GetBucketLocation */ + private static final String DEFAULT_REGION = "us-east-1"; private final FileStorageProperties.StoreProperties properties; private final MinioClient minioClient; @@ -82,6 +84,7 @@ public class MinioFileServiceImpl extends AbstractFileService { .method(Method.PUT) .bucket(properties.getBucketName()) .object(partObjectKey(objectKey, uploadId, partNumber)) + .region(DEFAULT_REGION) .expiry(Math.toIntExact(resolveExpires(expires))) .build()); return applyTransformPathPrefix(presignedUrl); @@ -186,6 +189,7 @@ public class MinioFileServiceImpl extends AbstractFileService { .method(Method.GET) .bucket(properties.getBucketName()) .object(resolveObjectKey(objectKey)) + .region(DEFAULT_REGION) .expiry(Math.toIntExact(resolveExpires(expires))) .build()); return applyTransformPathPrefix(presignedUrl); @@ -212,7 +216,10 @@ public class MinioFileServiceImpl extends AbstractFileService { /** * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 + *

* MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 + * 外网域名通常是业务网关而非直连 MinIO,必须固定 region,否则 SDK 会请求 GetBucketLocation + * 并拿到 HTML 页面导致 XmlParserException。 */ private MinioClient resolvePresignClient() { String transformEndpoint = properties.getTransformEndpoint(); @@ -228,6 +235,7 @@ public class MinioFileServiceImpl extends AbstractFileService { transformPresignClient = MinioClient.builder() .endpoint(resolveTransformOrigin(transformEndpoint)) .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) + .region(DEFAULT_REGION) .build(); } return transformPresignClient; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java index 16d35e08..6f6b7d4e 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java @@ -1501,7 +1501,9 @@ public class VoucherManageServiceImpl extends BaseServiceImpl Date: Wed, 14 Oct 2026 00:50:27 +0800 Subject: [PATCH 78/81] =?UTF-8?q?:bug:=20MinIO=20=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=94=AF=E6=8C=81=20/oss=20=E8=B7=AF=E5=BE=84?= =?UTF-8?q?=E5=89=8D=E7=BC=80=E5=B9=B6=E5=9B=BA=E5=AE=9A=20region=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=90=88=E5=B9=B6=E5=88=86=E7=89=87=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../springblade/file/config/FileConfig.java | 18 ++- .../service/impl/MinioFileServiceImpl.java | 27 +--- .../file/util/MinioClientFactory.java | 121 ++++++++++++++++++ .../transport/config/VoucherMinioConfig.java | 66 ++++++++-- .../impl/VoucherManageServiceImpl.java | 7 +- doc/nacos/blade-prod.yaml | 4 +- 6 files changed, 197 insertions(+), 46 deletions(-) create mode 100644 blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java index 3e578fef..07748388 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/config/FileConfig.java @@ -11,6 +11,7 @@ import org.springblade.file.service.impl.LocalFileServiceImpl; import org.springblade.file.service.impl.MinioFileServiceImpl; import org.springblade.file.service.impl.OssFileServiceImpl; import org.springblade.file.service.impl.ObsFileServiceImpl; +import org.springblade.file.util.MinioClientFactory; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -65,19 +66,22 @@ public class FileConfig { private IFileService buildMinioFileService(FileStorageProperties.StoreProperties properties) { validateStoreProperties(STORAGE_MINIO, properties); + String endpointOrigin = MinioClientFactory.resolveOrigin(properties.getEndpoint()); + String endpointPathPrefix = MinioClientFactory.resolvePathPrefix(properties.getEndpoint()); + if (StringUtil.isNotBlank(endpointPathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址,transform-endpoint 使用外网 HTTPS", + endpointOrigin, endpointPathPrefix); + } if (StringUtil.isBlank(properties.getTransformEndpoint()) - && StringUtil.isNotBlank(properties.getEndpoint()) && properties.getEndpoint().trim().toLowerCase().startsWith("http://")) { log.warn("MinIO transform-endpoint 未配置,endpoint={}。HTTPS 页面分片直传会被浏览器 Mixed Content 拦截,将回退为服务端代传", properties.getEndpoint()); } else { - log.info("MinIO 文件存储已启用 endpoint={} transformEndpoint={}", - properties.getEndpoint(), properties.getTransformEndpoint()); + log.info("MinIO 文件存储已启用 endpoint={} pathPrefix={} transformEndpoint={}", + endpointOrigin, endpointPathPrefix, properties.getTransformEndpoint()); } - MinioClient minioClient = MinioClient.builder() - .endpoint(properties.getEndpoint()) - .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) - .build(); + MinioClient minioClient = MinioClientFactory.create( + properties.getEndpoint(), properties.getAccessKeyId(), properties.getAccessKeySecret()); return new MinioFileServiceImpl(properties, minioClient); } diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java index 45741733..c7d2ed4e 100644 --- a/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java +++ b/blade-service/blade-file/src/main/java/org/springblade/file/service/impl/MinioFileServiceImpl.java @@ -40,6 +40,7 @@ import lombok.extern.slf4j.Slf4j; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springblade.file.config.FileStorageProperties; +import org.springblade.file.util.MinioClientFactory; import org.springblade.file.pojo.vo.PartEtagVO; import java.io.File; @@ -65,8 +66,7 @@ import java.util.UUID; public class MinioFileServiceImpl extends AbstractFileService { private static final long PART_SIZE = 10 * 1024 * 1024L; - /** MinIO 默认 region;固定后预签名无需再请求外网域名做 GetBucketLocation */ - private static final String DEFAULT_REGION = "us-east-1"; + private static final String DEFAULT_REGION = MinioClientFactory.DEFAULT_REGION; private final FileStorageProperties.StoreProperties properties; private final MinioClient minioClient; @@ -216,10 +216,7 @@ public class MinioFileServiceImpl extends AbstractFileService { /** * 浏览器侧预签名优先使用外网地址,避免 HTTPS 页面访问 HTTP MinIO 被 Mixed Content 拦截。 - *

* MinIO SDK 不允许 endpoint 带 path,因此外网地址中的 /oss 等前缀在签名后单独拼回。 - * 外网域名通常是业务网关而非直连 MinIO,必须固定 region,否则 SDK 会请求 GetBucketLocation - * 并拿到 HTML 页面导致 XmlParserException。 */ private MinioClient resolvePresignClient() { String transformEndpoint = properties.getTransformEndpoint(); @@ -232,29 +229,13 @@ public class MinioFileServiceImpl extends AbstractFileService { } synchronized (this) { if (transformPresignClient == null) { - transformPresignClient = MinioClient.builder() - .endpoint(resolveTransformOrigin(transformEndpoint)) - .credentials(properties.getAccessKeyId(), properties.getAccessKeySecret()) - .region(DEFAULT_REGION) - .build(); + transformPresignClient = MinioClientFactory.create( + transformEndpoint, properties.getAccessKeyId(), properties.getAccessKeySecret()); } return transformPresignClient; } } - private String resolveTransformOrigin(String transformEndpoint) { - URI transformUri = URI.create(transformEndpoint.trim()); - if (StringUtil.isBlank(transformUri.getScheme()) || StringUtil.isBlank(transformUri.getHost())) { - throw new ServiceException("MinIO外网地址配置不正确"); - } - StringBuilder origin = new StringBuilder(); - origin.append(transformUri.getScheme()).append("://").append(transformUri.getHost()); - if (transformUri.getPort() > 0) { - origin.append(':').append(transformUri.getPort()); - } - return origin.toString(); - } - private String applyTransformPathPrefix(String presignedUrl) { String transformEndpoint = properties.getTransformEndpoint(); if (StringUtil.isBlank(transformEndpoint) || StringUtil.isBlank(presignedUrl)) { diff --git a/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java new file mode 100644 index 00000000..384dc61e --- /dev/null +++ b/blade-service/blade-file/src/main/java/org/springblade/file/util/MinioClientFactory.java @@ -0,0 +1,121 @@ +/** + * 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.file.util; + +import io.minio.MinioClient; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.tool.utils.StringUtil; + +import java.net.URI; +import java.util.concurrent.TimeUnit; + +/** + * MinIO 客户端构建。 + *

+ * SDK 不允许 endpoint 带 path;若配置为 {@code https://host/oss},则: + *

    + *
  • endpoint 使用 origin
  • + *
  • 通过 OkHttp 拦截器把 /oss 拼回每个 API 请求
  • + *
  • 固定 region,避免 GetBucketLocation 打到业务网关拿到 HTML
  • + *
+ */ +public final class MinioClientFactory { + + public static final String DEFAULT_REGION = "us-east-1"; + + private MinioClientFactory() { + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + EndpointParts parts = parseEndpoint(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(parts.origin()) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(parts.pathPrefix())) { + builder.httpClient(buildHttpClient(parts.pathPrefix()), true); + } + return builder.build(); + } + + public static String resolveOrigin(String endpoint) { + return parseEndpoint(endpoint).origin(); + } + + public static String resolvePathPrefix(String endpoint) { + return parseEndpoint(endpoint).pathPrefix(); + } + + private static EndpointParts parseEndpoint(String endpoint) { + if (StringUtil.isBlank(endpoint)) { + throw new ServiceException("MinIO endpoint 不能为空"); + } + URI uri = URI.create(endpoint.trim()); + if (StringUtil.isBlank(uri.getScheme()) || StringUtil.isBlank(uri.getHost())) { + throw new ServiceException("MinIO endpoint 配置不正确: " + endpoint); + } + StringBuilder origin = new StringBuilder(); + origin.append(uri.getScheme()).append("://").append(uri.getHost()); + if (uri.getPort() > 0) { + origin.append(':').append(uri.getPort()); + } + return new EndpointParts(origin.toString(), normalizePathPrefix(uri.getPath())); + } + + private static String normalizePathPrefix(String path) { + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); + } + + private record EndpointParts(String origin, String pathPrefix) { + } +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java index 5c0db187..269eb1a5 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/config/VoucherMinioConfig.java @@ -6,6 +6,9 @@ package org.springblade.transport.config; import io.minio.MinioClient; import lombok.extern.slf4j.Slf4j; +import okhttp3.HttpUrl; +import okhttp3.OkHttpClient; +import okhttp3.Request; import org.springblade.core.log.exception.ServiceException; import org.springblade.core.tool.utils.StringUtil; import org.springframework.beans.factory.annotation.Value; @@ -14,34 +17,49 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import java.net.URI; +import java.util.concurrent.TimeUnit; /** * 凭证图片 MinIO 客户端配置。 *

* 连接参数由 Nacos 的 file.storage.minio 配置提供。 *

    - *
  • {@code endpoint}:内网地址,供服务端 put/get,不能带 path
  • - *
  • {@code transform-endpoint}:外网地址(可带 /oss),仅用于浏览器预签名
  • + *
  • {@code endpoint}:建议内网地址;若误配为 https://host/oss,会自动剥离 path 并用拦截器补回
  • + *
  • {@code transform-endpoint}:外网地址(可带 /oss),用于浏览器预签名
  • *
- * 未配置 {@code file.storage.minio.endpoint} 时不注册该客户端, - * 以免凭证上传功能缺失配置导致整个 blade-transport 服务无法启动。 */ @Slf4j @Configuration @ConditionalOnProperty(prefix = "file.storage.minio", name = "endpoint") public class VoucherMinioConfig { + private static final String DEFAULT_REGION = "us-east-1"; + @Bean public MinioClient voucherMinioClient( @Value("${file.storage.minio.endpoint:${minio.endpoint:}}") String endpoint, @Value("${file.storage.minio.access-key-id:${minio.access-key:}}") String accessKey, @Value("${file.storage.minio.access-key-secret:${minio.secret-key:}}") String secretKey) { - String clientEndpoint = resolveClientEndpoint(endpoint); - if (!clientEndpoint.equals(endpoint == null ? null : endpoint.trim())) { - log.warn("MinIO endpoint 含 path,已剥离为 {}(原值={})。服务端读写请配置内网 endpoint,外网请用 transform-endpoint", - clientEndpoint, endpoint); + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + if (StringUtil.isNotBlank(pathPrefix)) { + log.warn("MinIO endpoint 含 path,将按 origin={} pathPrefix={} 访问;建议 endpoint 使用内网地址", + origin, pathPrefix); } - return MinioClient.builder().endpoint(clientEndpoint).credentials(accessKey, secretKey).build(); + return create(endpoint, accessKey, secretKey); + } + + public static MinioClient create(String endpoint, String accessKey, String secretKey) { + String origin = resolveClientEndpoint(endpoint); + String pathPrefix = resolvePathPrefix(endpoint); + MinioClient.Builder builder = MinioClient.builder() + .endpoint(origin) + .credentials(accessKey, secretKey) + .region(DEFAULT_REGION); + if (StringUtil.isNotBlank(pathPrefix)) { + builder.httpClient(buildHttpClient(pathPrefix), true); + } + return builder.build(); } /** @@ -62,4 +80,34 @@ public class VoucherMinioConfig { } return origin.toString(); } + + private static String resolvePathPrefix(String endpoint) { + URI uri = URI.create(endpoint.trim()); + String path = uri.getPath(); + if (StringUtil.isBlank(path) || "/".equals(path)) { + return ""; + } + String normalized = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + return normalized.startsWith("/") ? normalized : "/" + normalized; + } + + private static OkHttpClient buildHttpClient(String pathPrefix) { + return new OkHttpClient.Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .addInterceptor(chain -> { + Request original = chain.request(); + HttpUrl originalUrl = original.url(); + String encodedPath = originalUrl.encodedPath(); + if (encodedPath.startsWith(pathPrefix + "/") || encodedPath.equals(pathPrefix)) { + return chain.proceed(original); + } + HttpUrl newUrl = originalUrl.newBuilder() + .encodedPath(pathPrefix + encodedPath) + .build(); + return chain.proceed(original.newBuilder().url(newUrl).build()); + }) + .build(); + } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java index 6f6b7d4e..e01c3fc7 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/VoucherManageServiceImpl.java @@ -1525,11 +1525,8 @@ public class VoucherManageServiceImpl extends BaseServiceImpl Date: Wed, 14 Oct 2026 10:44:38 +0800 Subject: [PATCH 79/81] =?UTF-8?q?:sparkles:=20=E6=9C=BA=E6=9E=84OA?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=94=AF=E6=8C=81=E5=8F=8C=E9=A1=B6=E7=BA=A7?= =?UTF-8?q?=E4=B8=8EGWWL=E5=88=86=E6=AE=B5=E7=BC=96=E7=A0=81=E8=A7=84?= =?UTF-8?q?=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定集团锚点 id=135/GWWL 与独立外部组织顶级并存;公司/部门编码按上级直拼三位数字生成,并修正类型与孤儿清理逻辑。 Co-authored-by: Cursor --- .../system/pojo/enums/DeptCategory.java | 41 ++- .../system/convert/DeptConvert.java | 73 ++-- .../system/rule/tenant/TenantDeptRule.java | 3 +- .../system/service/IDeptService.java | 9 +- .../system/service/impl/DeptServiceImpl.java | 305 ++++++++++++++-- .../service/impl/OASyncServiceImpl.java | 335 ++++++++++++++---- .../service/impl/OaUserListSyncHelper.java | 30 +- .../springblade/system/util/DeptCodeUtil.java | 97 +++++ .../oa/constant/OAConvertConstant.java | 32 +- 9 files changed, 733 insertions(+), 192 deletions(-) create mode 100644 blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java diff --git a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java index cf21c31b..86e522e3 100644 --- a/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java +++ b/blade-service-api/blade-system-api/src/main/java/org/springblade/system/pojo/enums/DeptCategory.java @@ -4,29 +4,34 @@ import lombok.AllArgsConstructor; import lombok.Getter; /** - * 部门类型枚举 + * 组织类型枚举,与业务字典 organization_type 对齐 + * * @author bfhuange * @date 2024/8/26 */ @AllArgsConstructor @Getter public enum DeptCategory { - /** - * 公司 - */ - COMPANY(1, "公司"), - /** - * 部门 - */ - DEPT(2, "部门"), - ; + /** + * 集团 + */ + GROUP(1, "集团"), + /** + * 公司 + */ + COMPANY(2, "公司"), + /** + * 部门 + */ + DEPT(3, "部门"), + ; - /** - * 部门类型编码 - */ - private final Integer code; - /** - * 部门类型名称 - */ - private final String name; + /** + * 组织类型编码 + */ + private final Integer code; + /** + * 组织类型名称 + */ + private final String name; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java index f7c1829b..ba5bdf43 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/convert/DeptConvert.java @@ -44,94 +44,79 @@ public interface DeptConvert { @Mapping(source = "subcompanyname", target = "deptName") @Mapping(source = "subcompanydesc", target = "fullName") - // 父公司id转为父部门编码,要拼接前缀 @Mapping(source = "supsubcomid", target = "parentCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - // OA modified 单独落库,用于增量比对,避免覆盖本系统 updateTime @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OACompanyResponse company); @Mapping(source = "departmentname", target = "deptName") @Mapping(source = "departmentmark", target = "fullName") - // 父部门id转为父部门编码,要拼接前缀 @Mapping(source = "supdepid", target = "parentCode") - // 所属子公司id转为所属公司编码,要拼接前缀 @Mapping(source = "subcompanyid1", target = "belongCompanyCode") @Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss") - // OA modified 单独落库,用于增量比对,避免覆盖本系统 updateTime @Mapping(source = "modified", target = "oaModified") Dept baseConvert(OADepartmentResponse department); /** - * oa公司转本系统部门 - * @param company - * @return + * oa公司转本系统部门。组织编码由同步服务按 GWWL+三位数字 统一分配。 */ default Dept company2dept(OACompanyResponse company) { Dept dept = this.baseConvert(company); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.COMPANY_OA_PREFIX + company.getId()); + // 编码稍后按规则生成;此处仅落 OA 主键,供按 oaId 匹配与重建层级 + dept.setDeptCode(null); String parentCode = dept.getParentCode(); if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.COMPANY_OA_PREFIX + parentCode); - // 父部门id后续处理 + // 非顶级:parentCode 暂存上级 OA 主键,分配编码后再替换为正式组织编码 + dept.setParentCode(parentCode); + dept.setParentId(null); } else { - // 父部门编码是0,父id也设置为0 - dept.setParentId(OAConvertConstant.ROOT_PARENT_ID); + dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); } - // 公司所属公司编码就是他自己的部门编码 - dept.setBelongCompanyCode(dept.getDeptCode()); - // OA主键与上级OA主键,分批同步结束后用于重建层级 + dept.setBelongCompanyCode(null); dept.setOaId(company.getId()); dept.setOaSupSubComId(company.getSupsubcomid()); - // 显示顺序处理 dept.setSort(OAUtils.parseInt(company.getShoworder())); - // 部门类型,公司 dept.setDeptCategory(DeptCategory.COMPANY.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } /** - * oa部门转本系统部门 - * @param oaDept - * @param companyDeptMap 所有公司的部门编码与id map - * @return + * oa部门转本系统部门。组织编码由同步服务按「所属公司编码+三位数字」统一分配。 + * + * @param oaDept OA部门 + * @param companyByOaId 已同步公司(oaId → 本地公司) */ - default Dept dept2dept(OADepartmentResponse oaDept, Map companyDeptMap) { + default Dept dept2dept(OADepartmentResponse oaDept, Map companyByOaId) { Dept dept = this.baseConvert(oaDept); - // 部门编码添加前缀 - dept.setDeptCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + oaDept.getId()); - // OA主键;上级为根部门时记录所属公司OA主键,否则记录上级部门OA主键 + dept.setDeptCode(null); dept.setOaId(oaDept.getId()); if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { dept.setOaSupSubComId(oaDept.getSubcompanyid1()); } else { dept.setOaSupSubComId(oaDept.getSupdepid()); } - // 部门所属公司编码就是他所属公司编码加前缀 - dept.setBelongCompanyCode(OAConvertConstant.COMPANY_OA_PREFIX + dept.getBelongCompanyCode()); - String parentCode = dept.getParentCode(); - if (!OAConstant.ROOT_COMPANY_ID.equals(parentCode)) { - // 父部门编码不是0,拼接前缀 - dept.setParentCode(OAConvertConstant.DEPARTMENT_OA_PREFIX + parentCode); - // 父部门id后续处理 + Dept belongCompany = companyByOaId == null ? null : companyByOaId.get(oaDept.getSubcompanyid1()); + if (belongCompany != null) { + dept.setBelongCompanyCode(belongCompany.getDeptCode()); + dept.setParentId(null); + if (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid())) { + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + } else { + // 上级为部门:parentCode 暂存上级部门 OA 主键,重建层级时再解析 + dept.setParentCode(oaDept.getSupdepid()); + } } else { - // 父部门编码是0,设置为公司编码 - dept.setParentCode(dept.getBelongCompanyCode()); - // 根据父部门编码获取对应的id - dept.setParentId(companyDeptMap.get(dept.getParentCode())); + // 所属公司尚未解析时,暂存 OA 公司主键,后续补齐正式编码 + dept.setBelongCompanyCode(oaDept.getSubcompanyid1()); + dept.setParentCode(oaDept.getSupdepid()); } - - // 显示顺序处理 dept.setSort(OAUtils.parseInt(oaDept.getShoworder())); - // 部门类型,部门 dept.setDeptCategory(DeptCategory.DEPT.getCode()); dept.setSyncTime(new Date()); - // 是oa的 dept.setIsOa(CommonConstant.YES); return dept; } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java b/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java index ba893bfd..07a0ca8f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/rule/tenant/TenantDeptRule.java @@ -30,6 +30,7 @@ import org.springblade.core.literule.core.RuleComponent; import org.springblade.core.tool.constant.BladeConstant; import org.springblade.system.pojo.entity.Dept; import org.springblade.system.pojo.entity.Tenant; +import org.springblade.system.pojo.enums.DeptCategory; import org.springblade.system.rule.context.TenantContext; import static org.springblade.system.rule.constant.TenantRuleConstant.TENANT_DEPT_RULE; @@ -54,7 +55,7 @@ public class TenantDeptRule extends RuleComponent { dept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); dept.setDeptName(tenant.getTenantName()); dept.setFullName(tenant.getTenantName()); - dept.setDeptCategory(1); + dept.setDeptCategory(DeptCategory.GROUP.getCode()); dept.setSort(2); dept.setIsDeleted(BladeConstant.DB_NOT_DELETED); diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java index cee1baf1..b2afe5f0 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/IDeptService.java @@ -149,12 +149,19 @@ public interface IDeptService extends IService { boolean removeDept(String ids); /** - * 删除非顶级组织,顶级组织(父级为 0)保留 + * 清除非保留顶级外的组织;保留 id=135 集团锚点,以及名称为「外部组织」的顶级 * * @return 删除数量 */ int clearNonTopDepts(); + /** + * 确保双顶级:id=135 集团(编码 GWWL)+ 独立「外部组织」顶级 + * + * @return 集团顶级组织(id=135) + */ + Dept ensureOaSyncRootDept(); + /** * 新增或修改部门 *

diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java index 24f461de..c0a7c966 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/DeptServiceImpl.java @@ -27,6 +27,7 @@ package org.springblade.system.service.impl; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.fasterxml.jackson.databind.JsonNode; @@ -389,16 +390,193 @@ public class DeptServiceImpl extends ServiceImpl implements ID @Override @Transactional(rollbackFor = Exception.class) public int clearNonTopDepts() { - Long nonTopCount = this.count(Wrappers.lambdaQuery() - .isNotNull(Dept::getParentId) - .ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID)); - if (nonTopCount == null || nonTopCount == 0L) { + // 同步前清理:保留集团锚点 id=135,以及「外部组织」顶级;其余删除 + Dept groupRoot = this.ensureOaSyncRootDept(); + Dept externalRoot = this.ensureExternalOrgTopDept(); + Set keepIds = new HashSet<>(); + if (groupRoot != null && groupRoot.getId() != null) { + keepIds.add(groupRoot.getId()); + } + if (externalRoot != null && externalRoot.getId() != null) { + keepIds.add(externalRoot.getId()); + } + if (keepIds.isEmpty()) { return 0; } - this.remove(Wrappers.lambdaQuery() - .isNotNull(Dept::getParentId) - .ne(Dept::getParentId, BladeConstant.TOP_PARENT_ID)); - return nonTopCount.intValue(); + Long clearCount = this.count(Wrappers.lambdaQuery().notIn(Dept::getId, keepIds)); + if (clearCount == null || clearCount == 0L) { + return 0; + } + this.remove(Wrappers.lambdaQuery().notIn(Dept::getId, keepIds)); + return clearCount.intValue(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Dept ensureOaSyncRootDept() { + this.migrateLegacyOaDeptCategory(); + this.removeLegacyOaSyncRoots(); + Dept rootDept = this.getById(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + if (rootDept == null) { + rootDept = new Dept(); + rootDept.setId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + rootDept.setTenantId(tenantId); + rootDept.setParentId(BladeConstant.TOP_PARENT_ID); + rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setDeptCategory(DeptCategory.GROUP.getCode()); + rootDept.setSort(0); + rootDept.setStatus(DataStatusEnum.ENABLE.getCode()); + rootDept.setIsDeleted(BladeConstant.DB_NOT_DELETED); + rootDept.setIsOa(0); + rootDept.setIsPlatformCompany(0); + rootDept.setSyncTime(new Date()); + this.save(rootDept); + log.info("已创建 OA 同步集团顶级 id={} name={} code={}", + OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, + OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME, + OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + } else { + boolean wronglyNamedAsExternal = OAConvertConstant.EXTERNAL_ORG_NAME.equals(rootDept.getDeptName()); + boolean needUpdate = wronglyNamedAsExternal + || !OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE.equals(rootDept.getDeptCode()) + || !DeptCategory.GROUP.getCode().equals(rootDept.getDeptCategory()) + || !Objects.equals(BladeConstant.TOP_PARENT_ID, rootDept.getParentId()); + if (needUpdate) { + Dept updateParam = new Dept(); + updateParam.setId(rootDept.getId()); + if (wronglyNamedAsExternal || StringUtil.isBlank(rootDept.getDeptName())) { + updateParam.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + updateParam.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + updateParam.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setDeptName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setFullName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + rootDept.setShortName(OAConvertConstant.OA_SYNC_ROOT_DEPT_NAME); + } + updateParam.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + updateParam.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + updateParam.setDeptCategory(DeptCategory.GROUP.getCode()); + updateParam.setParentId(BladeConstant.TOP_PARENT_ID); + updateParam.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + updateParam.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + this.updateById(updateParam); + rootDept.setDeptCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setBelongCompanyCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + rootDept.setDeptCategory(DeptCategory.GROUP.getCode()); + rootDept.setParentId(BladeConstant.TOP_PARENT_ID); + rootDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + rootDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + log.info("已校正 OA 同步集团顶级 id={} name={} code={} category={}", + rootDept.getId(), rootDept.getDeptName(), rootDept.getDeptCode(), rootDept.getDeptCategory()); + } + } + this.ensureExternalOrgTopDept(); + return rootDept; + } + + /** + * 确保存在独立的「外部组织」顶级(与 id=135 集团并存,仅保留一个同名顶级) + */ + private Dept ensureExternalOrgTopDept() { + List externalTops = this.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptName, OAConvertConstant.EXTERNAL_ORG_NAME) + .and(wrapper -> wrapper + .isNull(Dept::getParentId) + .or() + .eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID)) + .ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .orderByAsc(Dept::getId)); + if (CollectionUtil.isNotEmpty(externalTops)) { + Dept keep = externalTops.get(0); + if (externalTops.size() > 1) { + List duplicateIds = externalTops.stream() + .skip(1) + .map(Dept::getId) + .filter(Objects::nonNull) + .toList(); + if (CollectionUtil.isNotEmpty(duplicateIds)) { + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, keep.getId()) + .in(Dept::getParentId, duplicateIds)); + this.removeByIds(duplicateIds); + log.info("已合并重复的外部组织顶级,保留 id={},删除{}", keep.getId(), duplicateIds); + } + } + return keep; + } + String tenantId = AuthUtil.getTenantId(); + if (StringUtil.isBlank(tenantId)) { + tenantId = BladeConstant.ADMIN_TENANT_ID; + } + Dept externalDept = new Dept(); + externalDept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); + externalDept.setTenantId(tenantId); + externalDept.setParentId(BladeConstant.TOP_PARENT_ID); + externalDept.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); + externalDept.setDeptName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setFullName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setShortName(OAConvertConstant.EXTERNAL_ORG_NAME); + externalDept.setDeptCode(null); + externalDept.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); + externalDept.setDeptCategory(DeptCategory.GROUP.getCode()); + externalDept.setSort(0); + externalDept.setStatus(DataStatusEnum.ENABLE.getCode()); + externalDept.setIsDeleted(BladeConstant.DB_NOT_DELETED); + externalDept.setIsOa(0); + externalDept.setIsPlatformCompany(0); + externalDept.setSyncTime(new Date()); + this.save(externalDept); + log.info("已创建外部组织顶级 id={}", externalDept.getId()); + return externalDept; + } + + /** + * 清理历史伪顶级(OACROOT);同名「桂物物流集团」仅清理非 id=135 的顶级 + */ + private void removeLegacyOaSyncRoots() { + List legacyRoots = this.list(Wrappers.lambdaQuery() + .ne(Dept::getId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .and(wrapper -> wrapper + .eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE) + .or(nameWrapper -> nameWrapper + .eq(Dept::getDeptName, OAConvertConstant.LEGACY_ROOT_DEPT_NAME) + .eq(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE) + .and(parentWrapper -> parentWrapper + .isNull(Dept::getParentId) + .or() + .eq(Dept::getParentId, BladeConstant.TOP_PARENT_ID))))); + if (CollectionUtil.isEmpty(legacyRoots)) { + return; + } + Set legacyIds = legacyRoots.stream().map(Dept::getId).filter(Objects::nonNull).collect(Collectors.toSet()); + this.update(Wrappers.lambdaUpdate() + .set(Dept::getParentId, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID) + .set(Dept::getParentCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .in(Dept::getParentId, legacyIds)); + this.removeByIds(legacyIds); + log.info("已清理历史伪顶级组织{}条:{}", legacyIds.size(), legacyIds); + } + + /** + * 按编码前缀对齐组织类型:OAC→公司、OAD→部门(覆盖历史 1/2 映射) + */ + private void migrateLegacyOaDeptCategory() { + this.update(Wrappers.lambdaUpdate() + .set(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.DEPARTMENT_OA_PREFIX)); + this.update(Wrappers.lambdaUpdate() + .set(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.COMPANY_OA_PREFIX) + .ne(Dept::getDeptCode, OAConvertConstant.LEGACY_ROOT_DEPT_CODE)); } @Override @@ -650,9 +828,16 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (StringUtil.isBlank(parent.getDeptCode())) { throw new ServiceException("请选择已配置组织编码的上级组织"); } - String pattern = Pattern.quote(parent.getDeptCode()) + "-\\d+"; - if (!deptCode.matches(pattern)) { - throw new ServiceException("组织编码格式应为:上级编码-分段数字"); + // 公司:GWWL+三位数字;部门:所属公司编码+三位数字;其他:上级编码+三位数字 + String expectedParentCode = parent.getDeptCode(); + if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + expectedParentCode = OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE; + } else if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory()) + && StringUtil.isNotBlank(dept.getBelongCompanyCode())) { + expectedParentCode = dept.getBelongCompanyCode(); + } + if (!org.springblade.system.util.DeptCodeUtil.matchesChildCode(expectedParentCode, deptCode)) { + throw new ServiceException("组织编码格式应为:" + expectedParentCode + "三位数字(不足补零,中间不加-)"); } this.ensureDeptCodeUnique(dept, deptCode); dept.setDeptCode(deptCode); @@ -724,8 +909,8 @@ public class DeptServiceImpl extends ServiceImpl implements ID log.info("没有记录OA主键的组织,跳过层级重建"); return; } - Map companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode()); - Map departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode()); + Map companyByOaId = this.indexOaCompanies(oaDeptList); + Map departmentByOaId = this.indexOaDepartments(oaDeptList); Map desiredParentIdMap = new LinkedHashMap<>(); Map desiredParentCodeMap = new HashMap<>(); for (Dept dept : oaDeptList) { @@ -778,21 +963,20 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (CollectionUtil.isEmpty(oaDeptList)) { return 0; } - Map companyByOaId = this.indexByOaId(oaDeptList, DeptCategory.COMPANY.getCode()); - Map departmentByOaId = this.indexByOaId(oaDeptList, DeptCategory.DEPT.getCode()); + Map companyByOaId = this.indexOaCompanies(oaDeptList); + Map departmentByOaId = this.indexOaDepartments(oaDeptList); Set deleteIds = new HashSet<>(); for (Dept dept : oaDeptList) { String parentOaId = dept.getOaSupSubComId(); - // 上级为空或为 0:视为本地锚点/根级,不作为孤儿删除 + // 上级为空或为 0:OA 顶级公司挂本地锚点,不作为孤儿 if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) { continue; } - boolean parentExists; - if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { - parentExists = companyByOaId.containsKey(parentOaId); - } else { - parentExists = departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId); - } + boolean isCompany = this.isOaCompany(dept); + boolean parentExists = isCompany + ? companyByOaId.containsKey(parentOaId) + : departmentByOaId.containsKey(parentOaId) || companyByOaId.containsKey(parentOaId); + // 全量数据整理后仍找不到上级:公司、部门一律删除,不改挂保留 if (!parentExists) { deleteIds.add(dept.getId()); } @@ -813,7 +997,7 @@ public class DeptServiceImpl extends ServiceImpl implements ID if (StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId)) { continue; } - Dept parent = DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory()) + Dept parent = this.isOaCompany(dept) ? companyByOaId.get(parentOaId) : Optional.ofNullable(departmentByOaId.get(parentOaId)).orElse(companyByOaId.get(parentOaId)); if (parent != null && deleteIds.contains(parent.getId())) { @@ -842,6 +1026,72 @@ public class DeptServiceImpl extends ServiceImpl implements ID return deleteIds.size(); } + /** + * 索引 OA 公司:同时兼容类型迁移前后的公司记录 + */ + private Map indexOaCompanies(List oaDeptList) { + Map oaIdMap = new HashMap<>(); + for (Dept dept : oaDeptList) { + if (!this.isOaCompany(dept) || StringUtil.isBlank(dept.getOaId())) { + continue; + } + Dept previous = oaIdMap.put(dept.getOaId(), dept); + if (previous != null) { + log.warn("OA公司主键重复,层级重建使用后一条 oaId={} id={} previousId={}", + dept.getOaId(), dept.getId(), previous.getId()); + } + } + return oaIdMap; + } + + private boolean isOaCompany(Dept dept) { + if (dept == null) { + return false; + } + if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + return true; + } + if (org.springblade.system.util.DeptCodeUtil.isCompanyCode(dept.getDeptCode())) { + return true; + } + return StringUtil.isNotBlank(dept.getDeptCode()) + && dept.getDeptCode().startsWith(OAConvertConstant.COMPANY_OA_PREFIX) + && !OAConvertConstant.LEGACY_ROOT_DEPT_CODE.equals(dept.getDeptCode()); + } + + private boolean isOaDepartment(Dept dept) { + if (dept == null) { + return false; + } + if (DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) { + return true; + } + if (StringUtil.isNotBlank(dept.getBelongCompanyCode()) + && org.springblade.system.util.DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode())) { + return true; + } + return StringUtil.isNotBlank(dept.getDeptCode()) + && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX); + } + + /** + * 索引 OA 部门:兼容类型迁移前后 + */ + private Map indexOaDepartments(List oaDeptList) { + Map oaIdMap = new HashMap<>(); + for (Dept dept : oaDeptList) { + if (!this.isOaDepartment(dept) || StringUtil.isBlank(dept.getOaId())) { + continue; + } + Dept previous = oaIdMap.put(dept.getOaId(), dept); + if (previous != null) { + log.warn("OA部门主键重复,层级重建使用后一条 oaId={} id={} previousId={}", + dept.getOaId(), dept.getId(), previous.getId()); + } + } + return oaIdMap; + } + /** * 按机构类型建立 OA 主键索引 */ @@ -866,19 +1116,22 @@ public class DeptServiceImpl extends ServiceImpl implements ID private OaParentLink resolveOaParentLink(Dept dept, Map companyByOaId, Map departmentByOaId) { String parentOaId = dept.getOaSupSubComId(); boolean rootParent = StringUtil.isBlank(parentOaId) || OAConstant.ROOT_COMPANY_ID.equals(parentOaId); - if (DeptCategory.COMPANY.getCode().equals(dept.getDeptCategory())) { + if (this.isOaCompany(dept)) { if (rootParent) { - return new OaParentLink(OAConvertConstant.ROOT_PARENT_ID, OAConstant.ROOT_COMPANY_ID); + // OA 顶级公司统一挂到本地顶级锚点下 + return new OaParentLink(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); } Dept parent = companyByOaId.get(parentOaId); if (parent == null || parent.getId() == null || Objects.equals(parent.getId(), dept.getId())) { + // 全量整理后仍无上级:跳过重建,交由孤儿清理直接删除 log.warn("未找到上级公司,跳过层级重建 oaId={} oaSupSubComId={} deptId={}", dept.getOaId(), parentOaId, dept.getId()); return null; } return new OaParentLink(parent.getId(), this.resolveParentCode(parent, OAConvertConstant.COMPANY_OA_PREFIX, parentOaId)); } - if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory())) { + if (!DeptCategory.DEPT.getCode().equals(dept.getDeptCategory()) + && !(StringUtil.isNotBlank(dept.getDeptCode()) && dept.getDeptCode().startsWith(OAConvertConstant.DEPARTMENT_OA_PREFIX))) { return null; } if (rootParent) { diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java index 3829e7a6..2156b11f 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OASyncServiceImpl.java @@ -22,6 +22,7 @@ import org.springblade.system.pojo.vo.OaOrgSyncPageVO; import org.springblade.system.pojo.vo.OaPersonSyncPageVO; import org.springblade.system.service.*; import org.springblade.system.util.DataSyncRecordUtils; +import org.springblade.system.util.DeptCodeUtil; import org.springblade.thirdparty.oa.constant.OAConstant; import org.springblade.thirdparty.oa.constant.OAConvertConstant; import org.springblade.thirdparty.oa.feign.IOAClient; @@ -73,6 +74,8 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.addLog(log); // 1. 同步公司和部门并记录 Date startTime = this.syncAndRecord(DataSyncRecordUtils::createOAOrgFetch, queryStartTime -> { + // 0. 确保双顶级:id=135 集团(GWWL) + 独立「外部组织」 + deptService.ensureOaSyncRootDept(); // 1. 同步公司 this.syncCompany(queryStartTime); // 公司分批入库后,重建层级并清理无法挂载的公司 @@ -338,6 +341,10 @@ public class OASyncServiceImpl implements IOASyncService { private OaOrgSyncPageVO syncCompanyFromOaPage(int current, int size, boolean incremental, String modifiedSince) { int pageNo = current < 1 ? 1 : current; int pageSize = size < 1 ? 20 : Math.min(size, 200); + // 首屏确保双顶级:id=135 集团(GWWL) + 独立「外部组织」 + if (pageNo == 1) { + deptService.ensureOaSyncRootDept(); + } IncrementalStart incrementalStart = resolveIncrementalStart(incremental, modifiedSince, DeptCategory.COMPANY); OACompanySearch companySearch = buildCompanySearch(incrementalStart.getStartTime()); companySearch.setCurPage(pageNo); @@ -437,10 +444,16 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("解析回传 modifiedSince 失败:{},重新计算", modifiedSince); } } + String codePrefix = DeptCategory.COMPANY.equals(deptCategory) + ? OAConvertConstant.COMPANY_OA_PREFIX + : OAConvertConstant.DEPARTMENT_OA_PREFIX; Dept latest = deptService.getOne(Wrappers.lambdaQuery() .select(Dept::getOaModified) .eq(Dept::getIsOa, YES) - .eq(Dept::getDeptCategory, deptCategory.getCode()) + .and(wrapper -> wrapper + .eq(Dept::getDeptCategory, deptCategory.getCode()) + .or() + .likeRight(Dept::getDeptCode, codePrefix)) .isNotNull(Dept::getOaModified) .ne(Dept::getOaModified, "") .orderByDesc(Dept::getOaModified) @@ -608,13 +621,8 @@ public class OASyncServiceImpl implements IOASyncService { if (CollectionUtil.isEmpty(oaCompanies)) { return OrgSyncCount.empty(); } - // 获取需要的公司名称 Set companyNames = syncAll ? Collections.emptySet() : getCompanyNames(); - // 转换数据 List allParam = oaCompanies.stream() - // 强制跳过上级公司为 0 的数据 - .filter(company -> !OAConstant.ROOT_COMPANY_ID.equals(company.getSupsubcomid())) - // 非全量同步时,筛选只要配置的公司 .filter(company -> syncAll || companyNames.contains(company.getSubcompanyname())) .map(deptConvert::company2dept) .toList(); @@ -623,10 +631,41 @@ public class OASyncServiceImpl implements IOASyncService { return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); } - // 查询已存在公司(含 oaModified),按编码索引 - Map existingDeptMap = loadExistingDeptMap(DeptCategory.COMPANY, null); - Map deptIdByCode = toDeptIdMap(existingDeptMap); - return persistSyncedDepts(allParam, existingDeptMap, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount); + Map existingByOaId = loadExistingByOaId(DeptCategory.COMPANY); + Map codeOwnerMap = loadCompanyCodeOwnerMap(); + Set usedCodes = new HashSet<>(codeOwnerMap.keySet()); + Map batchByOaId = new LinkedHashMap<>(); + for (Dept dept : allParam) { + this.assignCompanyCode(dept, existingByOaId, usedCodes); + batchByOaId.put(dept.getOaId(), dept); + } + // 上级公司编码:OA 上级为 0 → GWWL;否则取上级公司已分配编码 + for (Dept dept : allParam) { + if (OAConstant.ROOT_COMPANY_ID.equals(dept.getOaSupSubComId()) || StringUtils.isBlank(dept.getOaSupSubComId())) { + dept.setParentCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE); + dept.setParentId(OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + continue; + } + Dept parent = batchByOaId.get(dept.getOaSupSubComId()); + if (parent == null) { + parent = existingByOaId.get(dept.getOaSupSubComId()); + } + if (parent != null && StringUtils.isNotBlank(parent.getDeptCode())) { + dept.setParentCode(parent.getDeptCode()); + dept.setParentId(parent.getId()); + } else { + dept.setParentCode(null); + dept.setParentId(null); + } + } + Map deptIdByCode = new HashMap<>(); + existingByOaId.values().forEach(dept -> { + if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) { + deptIdByCode.put(dept.getDeptCode(), dept.getId()); + } + }); + deptIdByCode.put(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, OAConvertConstant.OA_SYNC_ROOT_DEPT_ID); + return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.COMPANY, filteredSkipCount); } /** @@ -639,103 +678,239 @@ public class OASyncServiceImpl implements IOASyncService { if (CollectionUtil.isEmpty(oaDepts)) { return OrgSyncCount.empty(); } - // 查询所有公司的编码和id的map - Map companyDeptMap = getAllCompanyDeptMap(); - // 根公司id + Map companyByOaId = loadCompanyByOaId(); String rootCompanyId = getRootCompanyId(); if (!syncAll && rootCompanyId == null) { return new OrgSyncCount(0, oaDepts.size(), Collections.emptyList()); } - // 根公司下要同步的部门名称 Set rootCompanyDeptNames = syncAll ? Collections.emptySet() : getRootCompanyDeptNames(); - // 转换数据 List allParam = oaDepts.stream() - // 全量同步时不过滤;否则:部门的公司不是根公司,或者部门名称在根公司下需要同步的部门列表中,且是根部门 .filter(oaDept -> syncAll || rootCompanyId == null || !rootCompanyId.equals(oaDept.getSubcompanyid1()) || (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname()))) - .map(dept -> deptConvert.dept2dept(dept, companyDeptMap)) + .map(dept -> deptConvert.dept2dept(dept, companyByOaId)) .toList(); int filteredSkipCount = oaDepts.size() - allParam.size(); if (CollectionUtil.isEmpty(allParam)) { return new OrgSyncCount(0, filteredSkipCount, Collections.emptyList()); } - Set deptCodes = new HashSet<>(); - allParam.forEach(dept -> { - if (StringUtils.isNotEmpty(dept.getDeptCode())) { - deptCodes.add(dept.getDeptCode()); + + Map existingByOaId = loadExistingByOaId(DeptCategory.DEPT); + Map nextSerialByCompany = new HashMap<>(); + Map> usedCodesByCompany = new HashMap<>(); + // 预载各公司下已有部门编码,保证分段连续 + for (Dept existing : existingByOaId.values()) { + if (StringUtils.isBlank(existing.getBelongCompanyCode()) || StringUtils.isBlank(existing.getDeptCode())) { + continue; } - if (StringUtils.isNotEmpty(dept.getParentCode())) { - deptCodes.add(dept.getParentCode()); + usedCodesByCompany + .computeIfAbsent(existing.getBelongCompanyCode(), key -> new HashSet<>()) + .add(existing.getDeptCode()); + } + Map batchByOaId = new LinkedHashMap<>(); + for (Dept dept : allParam) { + this.assignDepartmentCode(dept, existingByOaId, companyByOaId, usedCodesByCompany, nextSerialByCompany); + batchByOaId.put(dept.getOaId(), dept); + } + // 解析上级:根部门挂公司;子部门挂上级部门(本批或已存在) + Map deptIdByCode = new HashMap<>(); + companyByOaId.values().forEach(company -> { + if (StringUtils.isNotBlank(company.getDeptCode()) && company.getId() != null) { + deptIdByCode.put(company.getDeptCode(), company.getId()); } }); - // 查询数据库已有部门(含 oaModified) - Map existingDeptMap = loadExistingDeptMap(DeptCategory.DEPT, deptCodes); - Map deptIdByCode = toDeptIdMap(existingDeptMap); - // 公司编码也放入 map,根部门挂到公司时才能解析父级 - companyDeptMap.forEach(deptIdByCode::putIfAbsent); - return persistSyncedDepts(allParam, existingDeptMap, deptIdByCode, DeptCategory.DEPT, filteredSkipCount); + existingByOaId.values().forEach(dept -> { + if (StringUtils.isNotBlank(dept.getDeptCode()) && dept.getId() != null) { + deptIdByCode.put(dept.getDeptCode(), dept.getId()); + } + }); + for (Dept dept : allParam) { + if (dept.getParentId() != null && StringUtils.isNotBlank(dept.getParentCode()) + && DeptCodeUtil.matchesChildCode(dept.getBelongCompanyCode(), dept.getDeptCode()) + && dept.getBelongCompanyCode().equals(dept.getParentCode())) { + continue; + } + String parentOaId = dept.getOaSupSubComId(); + Dept belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode())) + .findFirst() + .orElse(null); + if (belongCompany != null && (OAConstant.ROOT_COMPANY_ID.equals(parentOaId) + || StringUtils.equals(parentOaId, belongCompany.getOaId()))) { + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + continue; + } + Dept parentDept = batchByOaId.get(parentOaId); + if (parentDept == null) { + parentDept = existingByOaId.get(parentOaId); + } + if (parentDept != null && StringUtils.isNotBlank(parentDept.getDeptCode())) { + dept.setParentCode(parentDept.getDeptCode()); + dept.setParentId(parentDept.getId()); + } else if (belongCompany != null) { + // 上级部门尚未就绪时先挂所属公司,分批结束后按 OA 主键重建 + dept.setParentCode(belongCompany.getDeptCode()); + dept.setParentId(belongCompany.getId()); + } else { + dept.setParentCode(null); + dept.setParentId(null); + } + } + return persistSyncedDepts(allParam, existingByOaId, deptIdByCode, DeptCategory.DEPT, filteredSkipCount); } /** - * 加载已存在组织,按部门编码索引 - * - * @param deptCategory 组织类型 - * @param deptCodes 限定编码;为空则按类型全量加载 + * 公司编码:GWWL+三位数字;已有合规编码则复用 */ - private Map loadExistingDeptMap(DeptCategory deptCategory, Set deptCodes) { - if (deptCodes != null && deptCodes.isEmpty()) { - return new HashMap<>(); + private void assignCompanyCode(Dept dept, Map existingByOaId, Set usedCodes) { + Dept existing = existingByOaId.get(dept.getOaId()); + if (existing != null && DeptCodeUtil.isCompanyCode(existing.getDeptCode())) { + dept.setDeptCode(existing.getDeptCode()); + dept.setBelongCompanyCode(existing.getDeptCode()); + usedCodes.add(existing.getDeptCode()); + return; } + int serial = DeptCodeUtil.nextSerial(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, usedCodes); + String deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial); + while (usedCodes.contains(deptCode)) { + serial++; + deptCode = DeptCodeUtil.formatChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, serial); + } + usedCodes.add(deptCode); + dept.setDeptCode(deptCode); + dept.setBelongCompanyCode(deptCode); + } + + /** + * 部门编码:所属公司编码+三位数字;已有合规编码则复用 + */ + private void assignDepartmentCode(Dept dept, Map existingByOaId, Map companyByOaId, + Map> usedCodesByCompany, Map nextSerialByCompany) { + Dept existing = existingByOaId.get(dept.getOaId()); + Dept belongCompany = null; + if (StringUtils.isNotBlank(dept.getBelongCompanyCode())) { + belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), dept.getBelongCompanyCode()) + || StringUtils.equals(company.getOaId(), dept.getBelongCompanyCode())) + .findFirst() + .orElse(null); + } + if (belongCompany == null && existing != null && StringUtils.isNotBlank(existing.getBelongCompanyCode())) { + String belongCode = existing.getBelongCompanyCode(); + belongCompany = companyByOaId.values().stream() + .filter(company -> StringUtils.equals(company.getDeptCode(), belongCode)) + .findFirst() + .orElse(null); + } + if (belongCompany == null) { + return; + } + final Dept resolvedCompany = belongCompany; + final String companyCode = resolvedCompany.getDeptCode(); + dept.setBelongCompanyCode(companyCode); + if (existing != null && DeptCodeUtil.matchesChildCode(companyCode, existing.getDeptCode())) { + dept.setDeptCode(existing.getDeptCode()); + usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()).add(existing.getDeptCode()); + return; + } + Set usedCodes = usedCodesByCompany.computeIfAbsent(companyCode, key -> new HashSet<>()); + int serial = nextSerialByCompany.computeIfAbsent(companyCode, + key -> DeptCodeUtil.nextSerial(companyCode, usedCodes)); + String deptCode = DeptCodeUtil.formatChildCode(companyCode, serial); + while (usedCodes.contains(deptCode)) { + serial++; + deptCode = DeptCodeUtil.formatChildCode(companyCode, serial); + } + nextSerialByCompany.put(companyCode, serial + 1); + usedCodes.add(deptCode); + dept.setDeptCode(deptCode); + } + + /** + * 按 oaId 加载已存在组织 + */ + private Map loadExistingByOaId(DeptCategory deptCategory) { List deptList = deptService.list(Wrappers.lambdaQuery() .eq(Dept::getDeptCategory, deptCategory.getCode()) - .in(deptCodes != null, Dept::getDeptCode, deptCodes) - .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId)); - return deptList.stream() - .filter(dept -> StringUtils.isNotEmpty(dept.getDeptCode())) - .collect(Collectors.toMap(Dept::getDeptCode, dept -> dept, (a, b) -> b)); - } - - private Map toDeptIdMap(Map existingDeptMap) { - Map deptIdByCode = new HashMap<>(); - existingDeptMap.forEach((code, dept) -> deptIdByCode.put(code, dept.getId())); - return deptIdByCode; + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") + .select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId, + Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId)); + Map byOaId = new HashMap<>(); + for (Dept dept : deptList) { + if (StringUtils.isNotBlank(dept.getOaId())) { + byOaId.put(dept.getOaId(), dept); + } + } + return byOaId; } /** - * 持久化同步数据:不存在则新增;存在且 oaModified 不一致才修改 - * - * @param allParam 待处理数据 - * @param existingDeptMap 已存在组织(同类型) - * @param deptIdByCode 编码→id(含上级公司等) - * @param deptCategory 组织类型 - * @param filteredSkipCount 上游过滤跳过数 - * @return 同步统计 + * 已占用的公司编码(GWWLxxx) */ - private OrgSyncCount persistSyncedDepts(List allParam, Map existingDeptMap, + private Map loadCompanyCodeOwnerMap() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .likeRight(Dept::getDeptCode, OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE) + .select(Dept::getId, Dept::getDeptCode, Dept::getOaId)); + Map codeMap = new HashMap<>(); + for (Dept dept : companyList) { + if (DeptCodeUtil.isCompanyCode(dept.getDeptCode())) { + codeMap.put(dept.getDeptCode(), dept); + } + } + return codeMap; + } + + /** + * 已同步公司:oaId → 公司 + */ + private Map loadCompanyByOaId() { + List companyList = deptService.list(Wrappers.lambdaQuery() + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "")); + Map byOaId = new HashMap<>(); + for (Dept dept : companyList) { + if (StringUtils.isNotBlank(dept.getOaId())) { + byOaId.put(dept.getOaId(), dept); + } + } + return byOaId; + } + + /** + * 持久化同步数据:按 oaId 匹配;不存在则新增;存在且 oaModified 不一致才修改 + */ + private OrgSyncCount persistSyncedDepts(List allParam, Map existingByOaId, Map deptIdByCode, DeptCategory deptCategory, int filteredSkipCount) { - Set existsDeptIds = existingDeptMap.values().stream() + Set existsDeptIds = existingByOaId.values().stream() .map(Dept::getId) .filter(Objects::nonNull) .collect(Collectors.toSet()); allParam.forEach(dept -> { - if (deptIdByCode.containsKey(dept.getDeptCode())) { + Dept existing = existingByOaId.get(dept.getOaId()); + if (existing != null && existing.getId() != null) { + dept.setId(existing.getId()); + } else if (StringUtils.isNotBlank(dept.getDeptCode()) && deptIdByCode.containsKey(dept.getDeptCode())) { dept.setId(deptIdByCode.get(dept.getDeptCode())); } else { dept.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); + } + if (StringUtils.isNotBlank(dept.getDeptCode())) { deptIdByCode.put(dept.getDeptCode(), dept.getId()); } }); - // 重新把没有父id的数据设置一下父id;上级尚未入库时,新增记录先挂到根节点,全部分批完成后重建层级并清理无法挂载的数据 int unresolvedParentCount = 0; for (Dept dept : allParam) { if (dept.getParentId() != null) { continue; } - Long parentId = deptIdByCode.get(dept.getParentCode()); + Long parentId = StringUtils.isNotBlank(dept.getParentCode()) ? deptIdByCode.get(dept.getParentCode()) : null; if (parentId != null) { dept.setParentId(parentId); continue; @@ -750,8 +925,8 @@ public class OASyncServiceImpl implements IOASyncService { deptCategory.getName(), unresolvedParentCount); } - // 父id不为空才新增;已存在的记录即使父id未解析也允许更新(空父id不会覆盖原值) List list = allParam.stream() + .filter(dept -> StringUtils.isNotBlank(dept.getDeptCode())) .filter(dept -> dept.getParentId() != null || existsDeptIds.contains(dept.getId())) .toList(); List addList = list.stream() @@ -761,10 +936,9 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().info("批量新增{}:{}", deptCategory.getName(), addList.size()); deptService.saveBatch(addList); } - // 已存在且 oaModified 不一致(或不存在可比对值)才修改 List updateList = list.stream() .filter(dept -> existsDeptIds.contains(dept.getId())) - .filter(dept -> !shouldSkipOaUpdate(existingDeptMap.get(dept.getDeptCode()), dept)) + .filter(dept -> !shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept)) .toList(); if (CollectionUtil.isNotEmpty(updateList)) { ComposeLogUtil.getLastLog().info("批量修改{}:{}", deptCategory.getName(), updateList.size()); @@ -772,10 +946,11 @@ public class OASyncServiceImpl implements IOASyncService { } int unchangedCount = (int) list.stream() .filter(dept -> existsDeptIds.contains(dept.getId())) - .filter(dept -> shouldSkipOaUpdate(existingDeptMap.get(dept.getDeptCode()), dept)) + .filter(dept -> shouldSkipOaUpdate(existingByOaId.get(dept.getOaId()), dept)) .count(); List notHandledList = allParam.stream() - .filter(dept -> dept.getParentId() == null && !existsDeptIds.contains(dept.getId())) + .filter(dept -> StringUtils.isBlank(dept.getDeptCode()) + || (dept.getParentId() == null && !existsDeptIds.contains(dept.getId()))) .toList(); int syncedCount = addList.size() + updateList.size(); int skippedCount = filteredSkipCount + unchangedCount + notHandledList.size(); @@ -786,12 +961,17 @@ public class OASyncServiceImpl implements IOASyncService { } /** - * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段 + * oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。 + * 组织编码已按新规则重算且与库中不一致时,即使 modified 相同也要更新。 */ private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) { if (existing == null) { return false; } + if (incoming != null && StringUtils.isNotBlank(incoming.getDeptCode()) + && !StringUtils.equals(existing.getDeptCode(), incoming.getDeptCode())) { + return false; + } String localModified = StringUtils.trimToEmpty(existing.getOaModified()); String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified()); if (localModified.isEmpty() || remoteModified.isEmpty()) { @@ -842,6 +1022,8 @@ public class OASyncServiceImpl implements IOASyncService { if (syncAll) { companyList = deptService.list(Wrappers.lambdaQuery() .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") ); } else { Set companyNames = this.getCompanyNames(); @@ -851,6 +1033,8 @@ public class OASyncServiceImpl implements IOASyncService { companyList = deptService.list(Wrappers.lambdaQuery() .in(Dept::getDeptName, companyNames) .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .isNotNull(Dept::getOaId) + .ne(Dept::getOaId, "") ); } return companyList.stream() @@ -951,13 +1135,20 @@ public class OASyncServiceImpl implements IOASyncService { return Collections.emptySet(); } // 查询父部门编码是根公司的,部门类型的,根公司下部门名称的部门 + Dept rootCompany = deptService.getOne(Wrappers.lambdaQuery() + .eq(Dept::getOaId, rootCompanyId) + .eq(Dept::getDeptCategory, DeptCategory.COMPANY.getCode()) + .last("limit 1"), false); + if (rootCompany == null || StringUtils.isBlank(rootCompany.getDeptCode())) { + return Collections.emptySet(); + } return deptService.list(Wrappers.lambdaQuery() .in(Dept::getDeptName, rootCompanyDeptNames) .eq(Dept::getDeptCategory, DeptCategory.DEPT.getCode()) - .eq(Dept::getParentCode, OAConvertConstant.COMPANY_OA_PREFIX + rootCompanyId) + .eq(Dept::getParentCode, rootCompany.getDeptCode()) ).stream() - .filter(dept -> StringUtils.isNotBlank(dept.getDeptCode())) - .map(dept -> dept.getDeptCode().replace(OAConvertConstant.DEPARTMENT_OA_PREFIX, "")) + .map(Dept::getOaId) + .filter(StringUtils::isNotBlank) .collect(Collectors.toSet()); } @@ -980,11 +1171,11 @@ public class OASyncServiceImpl implements IOASyncService { ComposeLogUtil.getLastLog().warn("未查询到根公司 名称:{}", companyName); return null; } - if (StringUtils.isBlank(dept.getDeptCode())) { + if (StringUtils.isBlank(dept.getOaId()) && StringUtils.isBlank(dept.getDeptCode())) { ComposeLogUtil.getLastLog().warn("根公司编码为空:{}", companyName); return null; } - // 部门编码去掉前缀,就是oa的id - return dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, ""); + return StringUtils.isNotBlank(dept.getOaId()) ? dept.getOaId() + : dept.getDeptCode().replace(OAConvertConstant.COMPANY_OA_PREFIX, ""); } } diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java index eba6b8cd..a67f4ff8 100644 --- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java +++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/OaUserListSyncHelper.java @@ -86,7 +86,7 @@ public class OaUserListSyncHelper { private final IRoleService roleService; /** - * 从人员数据提取二级公司、三级部门,并挂到「桂物物流集团」下 + * 从人员数据提取二级公司、三级部门,并挂到集团顶级(id=135/GWWL) 下 * * @param oaPersons OA人员 * @return 组织索引 @@ -383,33 +383,7 @@ public class OaUserListSyncHelper { } private Dept getOrCreateRootCompany() { - Dept rootCompany = deptService.getOne(Wrappers.lambdaQuery() - .eq(Dept::getDeptName, OAConvertConstant.ROOT_COMPANY_NAME) - .last("limit 1"), false); - if (rootCompany != null) { - return rootCompany; - } - rootCompany = new Dept(); - rootCompany.setId(DefaultIdentifierGenerator.getInstance().nextId(null)); - rootCompany.setTenantId(resolveTenantId()); - rootCompany.setParentId(BladeConstant.TOP_PARENT_ID); - rootCompany.setAncestors(String.valueOf(BladeConstant.TOP_PARENT_ID)); - rootCompany.setDeptName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setFullName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setShortName(OAConvertConstant.ROOT_COMPANY_NAME); - rootCompany.setDeptCode("OACROOT"); - rootCompany.setParentCode(String.valueOf(BladeConstant.TOP_PARENT_ID)); - rootCompany.setBelongCompanyCode("OACROOT"); - rootCompany.setDeptCategory(DeptCategory.COMPANY.getCode()); - rootCompany.setSort(0); - rootCompany.setStatus(DataStatusEnum.ENABLE.getCode()); - rootCompany.setIsDeleted(BladeConstant.DB_NOT_DELETED); - rootCompany.setIsOa(YES); - rootCompany.setIsPlatformCompany(0); - rootCompany.setSyncTime(new Date()); - deptService.save(rootCompany); - ComposeLogUtil.getLastLog().info("已创建顶级组织:{}", OAConvertConstant.ROOT_COMPANY_NAME); - return rootCompany; + return deptService.ensureOaSyncRootDept(); } private Dept upsertOrg(String name, String oaCode, Dept existing, Dept parent, String tenantId, diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java b/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java new file mode 100644 index 00000000..687a3a46 --- /dev/null +++ b/blade-service/blade-system/src/main/java/org/springblade/system/util/DeptCodeUtil.java @@ -0,0 +1,97 @@ +/** + * 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.system.util; + +import org.apache.commons.lang3.StringUtils; +import org.springblade.thirdparty.oa.constant.OAConvertConstant; + +import java.util.Collection; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 组织编码工具:上级编码直接拼接固定 3 位分段数字(不足补零,中间不加 "-") + * + * @author Chill + */ +public final class DeptCodeUtil { + + /** + * 分段数字位数 + */ + public static final int SEGMENT_WIDTH = 3; + + private DeptCodeUtil() { + } + + /** + * 生成子级编码:上级编码 + 三位数字 + */ + public static String formatChildCode(String parentCode, int serial) { + return parentCode + String.format("%0" + SEGMENT_WIDTH + "d", serial); + } + + /** + * 是否符合「上级编码 + 三位数字」格式(恰好多出 3 位数字) + */ + public static boolean matchesChildCode(String parentCode, String deptCode) { + if (StringUtils.isBlank(parentCode) || StringUtils.isBlank(deptCode)) { + return false; + } + return deptCode.matches("^" + Pattern.quote(parentCode) + "\\d{" + SEGMENT_WIDTH + "}$"); + } + + /** + * 是否为公司编码(GWWL + 三位数字) + */ + public static boolean isCompanyCode(String deptCode) { + return matchesChildCode(OAConvertConstant.OA_SYNC_ROOT_DEPT_CODE, deptCode); + } + + /** + * 在已有编码中取下一分段序号(从 1 起) + */ + public static int nextSerial(String parentCode, Collection existingCodes) { + if (StringUtils.isBlank(parentCode)) { + return 1; + } + Pattern pattern = Pattern.compile("^" + Pattern.quote(parentCode) + "(\\d{" + SEGMENT_WIDTH + "})$"); + int maxSerial = 0; + if (existingCodes != null) { + for (String code : existingCodes) { + if (StringUtils.isBlank(code)) { + continue; + } + Matcher matcher = pattern.matcher(code.trim()); + if (!matcher.matches()) { + continue; + } + maxSerial = Math.max(maxSerial, Integer.parseInt(matcher.group(1))); + } + } + return maxSerial + 1; + } +} diff --git a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java index 4f6027c0..e157641b 100644 --- a/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java +++ b/blade-third-party-api/blade-oa-api/src/main/java/org/springblade/thirdparty/oa/constant/OAConvertConstant.java @@ -31,7 +31,35 @@ public class OAConvertConstant { */ public static final Long ROOT_PARENT_ID = 0L; /** - * 同步人员时挂载的顶级组织名称 + * OA 同步挂载的本地顶级组织主键(集团) */ - public static final String ROOT_COMPANY_NAME = "桂物物流集团"; + public static final Long OA_SYNC_ROOT_DEPT_ID = 135L; + /** + * OA 同步挂载的本地顶级组织默认名称(集团;已存在时不覆盖原名称,除非误写成外部组织) + */ + public static final String OA_SYNC_ROOT_DEPT_NAME = "桂物物流集团"; + /** + * OA 同步挂载的本地顶级组织编码(集团) + */ + public static final String OA_SYNC_ROOT_DEPT_CODE = "GWWL"; + /** + * 承运商等业务使用的外部组织顶级名称(与集团顶级并存) + */ + public static final String EXTERNAL_ORG_NAME = "外部组织"; + /** + * 组织编码分段数字位数(不足补零) + */ + public static final int DEPT_CODE_SEGMENT_WIDTH = 3; + /** + * 历史人员同步创建的伪顶级编码,同步时需清理 + */ + public static final String LEGACY_ROOT_DEPT_CODE = "OACROOT"; + /** + * 历史人员同步创建的伪顶级名称(与集团同名时仅清理非 id=135 的记录) + */ + public static final String LEGACY_ROOT_DEPT_NAME = "桂物物流集团"; + /** + * 同步人员时挂载的顶级组织名称(兼容旧引用) + */ + public static final String ROOT_COMPANY_NAME = OA_SYNC_ROOT_DEPT_NAME; } From 19c5bc64f18ee9dd3c988c8e1bc00deddf6ad347 Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 14 Oct 2026 10:58:42 +0800 Subject: [PATCH 80/81] =?UTF-8?q?:bug:=20=E5=BA=94=E6=94=B6=E5=BA=94?= =?UTF-8?q?=E4=BB=98=E5=8F=98=E6=9B=B4=E8=AE=B0=E5=BD=95=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E6=8F=90=E4=BA=A4=E5=85=B1=E7=94=A8=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../impl/ReceivablePayableDetailServiceImpl.java | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index 0ce145f2..c26eeff0 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -481,8 +481,10 @@ public class ReceivablePayableDetailServiceImpl } List afterRows = activeCargoFees(detail.getId()); List changes = reconcileUpdatedFeeAmounts(beforeRows, afterRows); + LocalDateTime adjustTime = LocalDateTime.now(); for (int index = 0; index < changes.size(); index++) { - saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), String.format("%04d", index + 1)); + saveChangeRecord(detail, changes.get(index), request.getAdjustReason(), + String.format("%04d", index + 1), adjustTime); } } } @@ -650,8 +652,9 @@ public class ReceivablePayableDetailServiceImpl if (changes.isEmpty()) { throw new ServiceException("未修改任何费用数据"); } + LocalDateTime adjustTime = LocalDateTime.now(); for (int i = 0; i < changes.size(); i++) { - saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1)); + saveChangeRecord(detail, changes.get(i), changeReasons.get(i), String.format("%04d", i + 1), adjustTime); } refreshAdjustedDetail(detail, allRows); } @@ -2661,10 +2664,15 @@ public class ReceivablePayableDetailServiceImpl } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason) { - saveChangeRecord(detail, content, reason, "0001"); + saveChangeRecord(detail, content, reason, "0001", LocalDateTime.now()); } private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo) { + saveChangeRecord(detail, content, reason, lineNo, LocalDateTime.now()); + } + + private void saveChangeRecord(ReceivablePayableDetail detail, String content, String reason, String lineNo, + LocalDateTime adjustTime) { if (!hasMeaningfulChange(content)) { return; } @@ -2676,7 +2684,7 @@ public class ReceivablePayableDetailServiceImpl record.setAdjustUser(AuthUtil.getUserId()); record.setAdjustUserName(AuthUtil.getUserName()); record.setAdjustReason(reason); - record.setAdjustTime(LocalDateTime.now()); + record.setAdjustTime(adjustTime == null ? LocalDateTime.now() : adjustTime); changeRecordMapper.insert(record); } From 6f4812db75ea45aacbe4baffbf84a7443d9cf30f Mon Sep 17 00:00:00 2001 From: b2894lxlx <517289602@qq.com> Date: Wed, 14 Oct 2026 12:59:31 +0800 Subject: [PATCH 81/81] =?UTF-8?q?:sparkles:=20=E9=A1=B9=E7=9B=AE=E7=94=B3?= =?UTF-8?q?=E8=AF=B7=E5=B9=B3=E5=8F=B0=E5=85=AC=E5=8F=B8=E5=A4=9A=E9=80=89?= =?UTF-8?q?=EF=BC=8C=E8=BF=90=E5=8D=95=E5=AF=BC=E5=85=A5=E6=89=BF=E8=BF=90?= =?UTF-8?q?=E5=95=86=E7=AD=9B=E9=80=89=E4=B8=8E=E5=A4=8D=E5=88=B6=E8=8D=89?= =?UTF-8?q?=E7=A8=BF=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../transport/pojo/entity/ProjectApply.java | 6 +- .../service/impl/ProjectApplyServiceImpl.java | 110 +++++++++++++++--- .../ReceivablePayableDetailServiceImpl.java | 5 + .../impl/WaybillImportBatchServiceImpl.java | 58 ++++++++- .../service/impl/WaybillServiceImpl.java | 12 +- ...ct_apply_undertake_dept_multi_20261014.sql | 4 + .../blade_project_contract_management.sql | 8 +- 7 files changed, 171 insertions(+), 32 deletions(-) create mode 100644 doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java index 5526da8e..eb85e2c4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/ProjectApply.java @@ -70,10 +70,10 @@ public class ProjectApply extends TenantEntity { @Schema(description = "业务部门") private String businessDeptName; - @Schema(description = "承办部门ID") - private Long undertakeDeptId; + @Schema(description = "平台公司ID,多个逗号分隔") + private String undertakeDeptId; - @Schema(description = "承办部门") + @Schema(description = "平台公司,多个顿号分隔") private String undertakeDeptName; @Schema(description = "项目由来") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java index deb2065f..d0f06ea8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProjectApplyServiceImpl.java @@ -338,19 +338,23 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl queryWrapper = Wrappers.lambdaQuery().eq(ProjectApply::getIsDeleted, 0); if (TransportBusinessSupport.isAdministrator()) { - // 超管默认可查看全部未删除数据;仅在明确指定承办组织时按组织精确筛选。 + // 超管默认可查看全部未删除数据;仅在明确指定平台公司时按包含匹配筛选。 if (Objects.equals(projectApply.getAllDept(), 1) && Func.isNotEmpty(projectApply.getUndertakeDeptId())) { - queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId()); + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); } } else if (!Objects.equals(projectApply.getAllDept(), 1)) { Long currentDeptId = TransportBusinessSupport.currentDeptId("项目立项"); Long currentUserId = AuthUtil.getUserId(); - queryWrapper.and(wrapper -> wrapper.eq(ProjectApply::getUndertakeDeptId, currentDeptId) - .or().eq(ProjectApply::getCreateUser, currentUserId) - .or().eq(ProjectApply::getHandlerUserId, currentUserId) - .or().eq(ProjectApply::getPrincipalUserId, currentUserId)); + queryWrapper.and(wrapper -> { + if (currentDeptId != null) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", String.valueOf(currentDeptId)); + } + wrapper.or().eq(ProjectApply::getCreateUser, currentUserId) + .or().eq(ProjectApply::getHandlerUserId, currentUserId) + .or().eq(ProjectApply::getPrincipalUserId, currentUserId); + }); } else if (Func.isNotEmpty(projectApply.getUndertakeDeptId())) { - queryWrapper.eq(ProjectApply::getUndertakeDeptId, projectApply.getUndertakeDeptId()); + applyUndertakeDeptContains(queryWrapper, projectApply.getUndertakeDeptId()); } queryWrapper.like(Func.isNotEmpty(projectApply.getApplyNo()), ProjectApply::getApplyNo, projectApply.getApplyNo()) .like(Func.isNotEmpty(projectApply.getProjectCode()), ProjectApply::getProjectCode, projectApply.getProjectCode()) @@ -464,6 +468,7 @@ public class ProjectApplyServiceImpl extends BaseServiceImpl queryWrapper, String undertakeDeptId) { + if (Func.isBlank(undertakeDeptId) || queryWrapper == null) { + return; + } + List deptIds = Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + if (deptIds.isEmpty()) { + return; + } + queryWrapper.and(wrapper -> { + boolean first = true; + for (String deptId : deptIds) { + if (first) { + wrapper.apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + first = false; + } else { + wrapper.or().apply("FIND_IN_SET({0}, IFNULL(undertake_dept_id,''))", deptId); + } + } + }); + } + + private void normalizeUndertakeDept(ProjectApply projectApply) { + if (projectApply == null) { + return; + } + List deptIds = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptId()).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .distinct() + .toList(); + projectApply.setUndertakeDeptId(deptIds.isEmpty() ? null : String.join(",", deptIds)); + List deptNames = Arrays.stream(Func.toStr(projectApply.getUndertakeDeptName()).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .distinct() + .toList(); + projectApply.setUndertakeDeptName(deptNames.isEmpty() ? null : String.join("、", deptNames)); + } + + private Long firstUndertakeDeptId(String undertakeDeptId) { + return Arrays.stream(Func.toStr(undertakeDeptId).split("[,,]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .filter(id -> !"-1".equals(id)) + .map(Func::toLong) + .filter(id -> id != null && id > 0L) + .findFirst() + .orElse(null); + } + + private String firstUndertakeDeptName(String undertakeDeptName) { + return Arrays.stream(Func.toStr(undertakeDeptName).split("[,,、]")) + .map(String::trim) + .filter(Func::isNotEmpty) + .findFirst() + .orElse(null); + } + + private boolean containsUndertakeDept(String undertakeDeptId, Long deptId) { + if (deptId == null || Func.isBlank(undertakeDeptId)) { + return false; + } + String target = String.valueOf(deptId); + return Arrays.stream(undertakeDeptId.split("[,,]")) + .map(String::trim) + .anyMatch(target::equals); + } + } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java index c26eeff0..69e2b6cb 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java @@ -3037,6 +3037,11 @@ public class ReceivablePayableDetailServiceImpl } private String formatQuantity(BigDecimal quantity, String unit) { + // 无有效运输量(空/0/-1)时不展示数值与单位 + if (quantity == null || quantity.compareTo(BigDecimal.ZERO) == 0 + || quantity.compareTo(BigDecimal.valueOf(-1)) == 0) { + return "-"; + } return money(quantity).stripTrailingZeros().toPlainString() + (unit == null ? "" : " " + unit); } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java index 1a585643..5a40decd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillImportBatchServiceImpl.java @@ -342,10 +342,11 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl queryWrapper, Long carrierId) { + Long normalizedCarrierId = normalizeOptionalId(carrierId); + if (normalizedCarrierId == null) { + return; + } + CustomerArchive carrier = customerArchiveService.getById(normalizedCarrierId); + String carrierName = carrier == null ? null : Func.toStr(carrier.getFullName()).trim(); + queryWrapper.and(wrapper -> { + wrapper.apply("FIND_IN_SET({0}, IFNULL(carrier_ids,''))", String.valueOf(normalizedCarrierId)); + if (Func.isNotEmpty(carrierName)) { + wrapper.or().eq(WaybillImportBatch::getCarrierName, carrierName); + } + }); + } + @Override @Transactional(rollbackFor = Exception.class) public BusinessRemoveResultVO removeBatches(String ids) { @@ -405,8 +424,9 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl carrierIds = resolveCarrierIds(request); + batch.setCarrierIds(joinIds(carrierIds)); + batch.setCarrierName(resolveCarrierNames(carrierIds, request.getCarrierName())); batch.setImportStatus(importStatus); batch.setImportType(IMPORT_TYPE_SETTLEMENT.equals(request.getImportType()) ? IMPORT_TYPE_SETTLEMENT : IMPORT_TYPE_WAYBILL); ProjectApply project = projectApplyService.getById(request.getProjectId()); @@ -586,6 +606,38 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl resolveCarrierIds(WaybillImportBatchRequest request) { + if (request == null) { + return List.of(); + } + List carrierIds = Func.isEmpty(request.getCarrierIds()) + ? new ArrayList<>() + : request.getCarrierIds().stream() + .map(this::normalizeOptionalId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toCollection(ArrayList::new)); + if (Func.isNotEmpty(carrierIds)) { + return carrierIds; + } + Long carrierId = normalizeOptionalId(request.getCarrierId()); + if (carrierId != null) { + return List.of(carrierId); + } + String carrierName = Func.toStr(request.getCarrierName()).trim(); + if (Func.isEmpty(carrierName)) { + return List.of(); + } + CustomerArchive carrier = customerArchiveService.getOne(Wrappers.lambdaQuery() + .eq(CustomerArchive::getFullName, carrierName) + .eq(CustomerArchive::getIsDeleted, 0) + .last("limit 1")); + return carrier == null || carrier.getId() == null ? List.of() : List.of(carrier.getId()); + } + private String resolveCarrierNames(List carrierIds, String carrierName) { if (Func.isNotEmpty(carrierName)) return carrierName; if (Func.isEmpty(carrierIds)) return null; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 64c4837e..c850d180 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -1344,21 +1344,15 @@ public class WaybillServiceImpl extends BaseServiceImpl target.setFreightJson(source.getFreightJson()); target.setAttachmentsJson(source.getAttachmentsJson()); target.setRemark(source.getRemark()); - // 复制后业务状态与原运单保持一致 - String sourceBusinessStatus = source.getBusinessStatus(); - target.setBusinessStatus(sourceBusinessStatus); + // 复制新建固定为草稿,不沿用原运单业务状态 + target.setBusinessStatus(STATUS_DRAFT); target.setWaybillNo(nextCode()); clearDriverAcceptRecord(target); fillProjectProcessConfig(target); prepare(target); - applyDriverAcceptBusinessStatus(target); - if (Func.isNotEmpty(sourceBusinessStatus)) { - target.setBusinessStatus(sourceBusinessStatus); - } + target.setBusinessStatus(STATUS_DRAFT); validate(target); save(target); - // 原运单已完成时,复制后按合同系统计费规则生成应收应付明细 - generateReceivablePayableIfCompleted(target); return detail(target.getId()); } diff --git a/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql new file mode 100644 index 00000000..b2d5d222 --- /dev/null +++ b/doc/sql/transport/blade_project_apply_undertake_dept_multi_20261014.sql @@ -0,0 +1,4 @@ +-- 平台公司支持多选:ID 逗号分隔,名称顿号分隔 +ALTER TABLE `blade_project_apply` + MODIFY COLUMN `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + MODIFY COLUMN `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔'; diff --git a/doc/sql/transport/blade_project_contract_management.sql b/doc/sql/transport/blade_project_contract_management.sql index b2208fbf..59890b1b 100644 --- a/doc/sql/transport/blade_project_contract_management.sql +++ b/doc/sql/transport/blade_project_contract_management.sql @@ -18,8 +18,8 @@ CREATE TABLE `blade_project_apply` ( `project_type` varchar(50) DEFAULT NULL COMMENT '项目类型', `business_dept_id` bigint(20) DEFAULT NULL COMMENT '业务部门ID', `business_dept_name` varchar(100) DEFAULT NULL COMMENT '业务部门', - `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', - `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', + `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', `project_source` varchar(50) DEFAULT NULL COMMENT '项目由来', `source_remark` varchar(500) DEFAULT NULL COMMENT '项目由来说明', `fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', @@ -80,8 +80,8 @@ CREATE TABLE `blade_temporary_credit_limit` ( `project_id` bigint(20) DEFAULT NULL COMMENT '项目ID', `project_code` varchar(100) DEFAULT NULL COMMENT '项目编号', `project_name` varchar(100) DEFAULT NULL COMMENT '项目名称', - `undertake_dept_id` bigint(20) DEFAULT NULL COMMENT '承办部门ID', - `undertake_dept_name` varchar(100) DEFAULT NULL COMMENT '承办部门', + `undertake_dept_id` varchar(500) DEFAULT NULL COMMENT '平台公司ID,多个逗号分隔', + `undertake_dept_name` varchar(500) DEFAULT NULL COMMENT '平台公司,多个顿号分隔', `project_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '项目资金使用额度(万元)', `used_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '已使用项目资金额度(万元)', `remaining_fund_limit` decimal(18,2) DEFAULT NULL COMMENT '剩余项目资金使用额度(万元)',