🔀 合并认证记录功能分支到 master

认证记录不可变快照数据模型、司机端提交与记录查询、管理端审核、
小程序证件 OCR 适配、接单可用性门槛(带开关可回退),含迁移脚本与单元测试。
This commit is contained in:
2026-10-04 23:38:39 +08:00
55 changed files with 6767 additions and 117 deletions
@@ -0,0 +1,103 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.mp.support.Condition;
import org.springblade.core.mp.support.Query;
import org.springblade.core.secure.annotation.PreAuth;
import org.springblade.core.tool.api.R;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.ICertAuditService;
import org.springblade.transport.support.CertAuditPermissions;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 认证审核(管理端)
* <p>
* 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
* 这与旧的车辆认证审核接口有本质区别:旧接口作用于车辆行并原地覆盖,
* 在「生效值 / 待审值分离」之后语义已不成立,故已弃用。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/certification-audit", "/blade-transport/certification-audit"})
@Tag(name = "认证审核", description = "管理端司机认证与车辆认证审核")
public class CertificationAuditController extends BladeController {
private final ICertAuditService certAuditService;
@GetMapping("/pending-page")
@ApiOperationSupport(order = 1)
@Operation(summary = "认证记录列表", description = "按认证对象类型与审核状态分页查询,按提交时间倒序;状态不传时查审核中的记录。列表同时下发该记录司机的姓名与手机号(司机档案当前值)")
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
public R<IPage<TransportCertRecordVO>> pendingPage(
@Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
@RequestParam String bizType,
@Parameter(description = "展示键模糊匹配:提交时的司机姓名 / 车牌号")
@RequestParam(required = false) String bizKey,
@Parameter(description = "司机姓名模糊匹配:当前姓名,同时兼顾提交时的旧名")
@RequestParam(required = false) String driverName,
@Parameter(description = "司机手机号模糊匹配:支持只输后四位")
@RequestParam(required = false) String driverMobile,
@Parameter(description = "审核状态:0审核中 1已通过 2已驳回 3已作废;不传按0(审核中)处理")
@RequestParam(required = false) Integer status,
Query query) {
return R.data(certAuditService.pendingPage(bizType, bizKey, driverName, driverMobile, status, Condition.getPage(query)));
}
@GetMapping("/detail")
@ApiOperationSupport(order = 2)
@Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比")
@PreAuth(CertAuditPermissions.AUDIT_VIEW)
public R<CertAuditDetailVO> detail(
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id) {
return R.data(certAuditService.detail(id));
}
@PostMapping("/audit")
@ApiOperationSupport(order = 3)
@Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
@PreAuth(CertAuditPermissions.AUDIT_ACTION)
public R<Boolean> audit(
@Parameter(description = "认证记录主键", required = true) @RequestParam Long id,
@Parameter(description = "是否通过", required = true) @RequestParam Boolean pass,
@Parameter(description = "驳回原因,驳回时必填") @RequestParam(required = false) String rejectReason) {
return R.status(certAuditService.audit(id, Boolean.TRUE.equals(pass), rejectReason));
}
}
@@ -0,0 +1,153 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.controller;
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.AllArgsConstructor;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.tool.api.R;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import org.springblade.transport.ocr.service.IBaiduOcrService;
import org.springblade.transport.pojo.dto.OcrRecognizeRequest;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.vo.BaiduOcrResultVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.IDriverCertService;
import org.springblade.transport.support.OcrTypeMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 司机端认证(小程序)
* <p>
* 对外路径:{@code /api/blade-transport/driver/auth/**}
* 同时兼容未去前缀直连 {@code /blade-transport/driver/auth/**}。
* <p>
* 提交审核**不影响**该对象当前的可用性:待审内容只落认证记录,
* 审核通过前对外呈现的仍是旧的生效值。
*/
@RestController
@AllArgsConstructor
@RequestMapping({"/driver/auth", "/blade-transport/driver/auth"})
@Tag(name = "司机端认证", description = "小程序司机个人认证与车辆认证")
public class DriverAuthController extends BladeController {
private final IDriverCertService driverCertService;
private final IBaiduOcrService baiduOcrService;
// ------------------------------------------------------------ 个人认证
@PostMapping("/person")
@ApiOperationSupport(order = 1)
@Operation(summary = "提交个人认证", description = "三证(身份证+驾驶证+从业资格证)一起提交,生成待审记录")
public R<Long> submitPerson(@RequestBody PersonCertSubmitRequest request) {
return R.data(driverCertService.submitPersonCert(request));
}
@GetMapping("/person")
@ApiOperationSupport(order = 2)
@Operation(summary = "个人认证生效值", description = "返回当前生效快照的内容;从未认证通过时 data 为 null")
public R<PersonCertSubmitRequest> person() {
return R.data(driverCertService.getEffectivePersonCert());
}
// ------------------------------------------------------------ 车辆认证
@PostMapping("/vehicle")
@ApiOperationSupport(order = 3)
@Operation(summary = "提交车辆认证", description = "生成待审记录;不改变该车已生效的认证内容")
public R<Long> submitVehicle(@RequestBody VehicleCertSubmitRequest request) {
return R.data(driverCertService.submitVehicleCert(request));
}
@PutMapping("/vehicle")
@ApiOperationSupport(order = 4)
@Operation(summary = "重新认证车辆", description = "与提交同一语义,语义上用于已有车辆重新认证")
public R<Long> updateVehicle(@RequestBody VehicleCertSubmitRequest request) {
return R.data(driverCertService.submitVehicleCert(request));
}
@DeleteMapping("/vehicle/{id}")
@ApiOperationSupport(order = 5)
@Operation(summary = "删除车辆认证", description = "对车辆做逻辑删除;认证记录保留,不物理删除")
public R<Boolean> deleteVehicle(@PathVariable Long id) {
return R.status(driverCertService.deleteVehicleCert(id));
}
@GetMapping("/vehicle/effective")
@ApiOperationSupport(order = 5)
@Operation(summary = "车辆认证生效值", description = "返回该车当前生效快照的内容")
public R<VehicleCertSubmitRequest> vehicleEffective(@RequestParam Long vehicleId) {
return R.data(driverCertService.getEffectiveVehicleCert(vehicleId));
}
// ------------------------------------------------------------ 认证记录
@GetMapping("/record/list")
@ApiOperationSupport(order = 6)
@Operation(summary = "认证记录列表", description = "按提交时间倒序;只能查本人或本人绑定车辆的记录")
public R<List<TransportCertRecordVO>> recordList(@RequestParam String bizType,
@RequestParam Long bizId) {
return R.data(driverCertService.listRecords(bizType, bizId));
}
@GetMapping("/record/detail")
@ApiOperationSupport(order = 7)
@Operation(summary = "认证记录详情", description = "内容取自该次提交的完整快照")
public R<TransportCertRecordVO> recordDetail(@RequestParam Long id) {
return R.data(driverCertService.getRecord(id));
}
// ------------------------------------------------------------ OCR
@PostMapping("/ocr")
@ApiOperationSupport(order = 8)
@Operation(summary = "证件OCR识别",
description = "按小程序既有契约收 {imageUrl, type, side?},转调百度OCR。"
+ "type 用小程序命名:idcard-身份证,driver-驾驶证,vehicle-行驶证,road_transport-道路运输证")
public R<BaiduOcrResultVO> ocr(@RequestBody OcrRecognizeRequest request) {
// 两侧对「驾驶证 / 行驶证」的用词正好相反,故必须经显式映射,不能把入参直接透传下游
BaiduOcrType ocrType = OcrTypeMapping.toBaiduType(request.getType());
// 未指定时传 null,不要在这里补 front:道路运输证没有正副面参数,
// 补了会变成「传了 side 但该证件不支持」,让这条链路恒失败。
// 「默认取正面」由知晓证件类型的一侧(OCR 服务)决定。
String side = OcrTypeMapping.normalizeSide(request.getSide());
return R.data(baiduOcrService.recognizeUrl(ocrType, side, request.getImageUrl()));
}
}
@@ -35,6 +35,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.boot.ctrl.BladeController;
import org.springblade.core.excel.util.ExcelUtil;
import org.springblade.core.mp.support.Condition;
@@ -67,6 +68,7 @@ import java.util.List;
*/
@RestController
@AllArgsConstructor
@Slf4j
@PreAuth(menu = "transport_vehicle")
@RequestMapping("/transport-vehicle")
@Tag(name = "车辆管理", description = "车辆管理")
@@ -132,11 +134,27 @@ public class TransportVehicleController extends BladeController {
return R.status(transportVehicleService.changeStatus(id, status));
}
/**
* 审核车辆认证(已弃用)
*
* @deprecated 本接口作用于**车辆行**并原地覆盖认证状态,在「生效值 / 待审值分离」
* 之后语义已不成立:一次审核应对应**一条认证记录**,且通过才建立生效快照、
* 驳回不得影响既得认证。请改用
* {@code POST /blade-transport/certification-audit/audit}。
* <p>
* 保留而非删除,是因为它可能仍被小程序历史版本或其它系统调用;
* 调用时记录告警日志。它只写展示缓存,**不再影响可用性判定**
* (可用性只看 {@code effective_cert_record_id})。
*/
@Deprecated
@PostMapping("/audit-certification")
@ApiOperationSupport(order = 6)
@Operation(summary = "审核车辆认证")
@Operation(summary = "审核车辆认证(已弃用)",
description = "已弃用:作用于车辆行且原地覆盖。请改用 /blade-transport/certification-audit/audit")
public R auditCertification(@RequestParam Long id, @RequestParam Integer certificationStatus,
@RequestParam(required = false) String rejectReason) {
log.warn("[认证审核] 调用了已弃用接口 audit-certification,vehicleId={}, status={};"
+ "请改用 /blade-transport/certification-audit/audit", id, certificationStatus);
return R.status(transportVehicleService.auditCertification(id, certificationStatus, rejectReason));
}
@@ -46,6 +46,12 @@
<result column="emergency_contact_name" property="emergencyContactName"/>
<result column="emergency_contact_mobile" property="emergencyContactMobile"/>
<result column="remark" property="remark"/>
<result column="id_card_valid_start" property="idCardValidStart"/>
<result column="id_card_valid_end" property="idCardValidEnd"/>
<result column="id_card_long_term" property="idCardLongTerm"/>
<result column="certification_status" property="certificationStatus"/>
<result column="certification_reject_reason" property="certificationRejectReason"/>
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
</resultMap>
<sql id="BaseColumn">
@@ -70,6 +76,9 @@
posts,
id_card_front,
id_card_back,
id_card_valid_start,
id_card_valid_end,
id_card_long_term,
head_photo,
driving_type,
driving_license_no,
@@ -91,7 +100,10 @@
organization_name,
emergency_contact_name,
emergency_contact_mobile,
remark
remark,
certification_status,
certification_reject_reason,
effective_cert_record_id
</sql>
<select id="selectByIdCardNoIncludingDeleted" resultType="org.springblade.transport.pojo.entity.Driver">
@@ -0,0 +1,65 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Param;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import java.util.List;
/**
* 认证记录 Mapper 接口
*
* @author Chill
*/
public interface TransportCertRecordMapper extends BaseMapper<TransportCertRecord> {
/**
* 审核列表分页:认证记录 + 该记录的司机姓名与手机号
* <p>
* 必须走自定义 SQL 而不是单表 Wrapper:司机姓名与手机号取的是司机档案的**当前**值,
* 且「按司机姓名/手机号筛选」必须发生在**分页之前**——若在 Java 层补字段再过滤,
* 就变成「先分页再过滤」,每页条数与总数都不准。
*
* @param page 分页参数
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param status 审核状态:0审核中 1已通过 2已驳回 3已作废
* @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空
* @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空
* @param driverMobile 司机手机号模糊匹配,可为空
* @return 认证记录列表
*/
List<TransportCertRecordVO> selectAuditPage(IPage<TransportCertRecordVO> page,
@Param("bizType") String bizType,
@Param("status") Integer status,
@Param("bizKey") String bizKey,
@Param("driverName") String driverName,
@Param("driverMobile") String driverMobile);
}
@@ -0,0 +1,100 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.springblade.transport.mapper.TransportCertRecordMapper">
<resultMap id="certRecordAuditMap" type="org.springblade.transport.pojo.vo.TransportCertRecordVO">
<result column="id" property="id"/>
<result column="tenant_id" property="tenantId"/>
<result column="biz_type" property="bizType"/>
<result column="biz_id" property="bizId"/>
<result column="biz_key" property="bizKey"/>
<result column="status" property="status"/>
<result column="reject_reason" property="rejectReason"/>
<result column="submit_time" property="submitTime"/>
<result column="submit_user" property="submitUser"/>
<result column="audit_time" property="auditTime"/>
<result column="audit_user" property="auditUser"/>
<result column="create_user" property="createUser"/>
<result column="create_dept" property="createDept"/>
<result column="create_time" property="createTime"/>
<result column="update_user" property="updateUser"/>
<result column="update_time" property="updateTime"/>
<result column="is_deleted" property="isDeleted"/>
<result column="driver_name" property="driverName"/>
<result column="driver_mobile" property="driverMobile"/>
</resultMap>
<!--
审核列表:认证记录 + 该记录的司机姓名与手机号(司机档案的当前值)。
司机是谁:司机侧就是认证对象本身(biz_id);车辆侧是这条记录的**提交司机**
(车辆认证强制「只能操作本人绑定车辆」,故提交司机必然是这辆车的绑定司机)。
提交人存的是用户 ID,而司机档案的 user_id 是同一个值,故按 user_id 定位。
子查询带 ORDER BY update_time DESC LIMIT 1 有实际作用,不是保险起见:
user_id 上没有唯一索引,脏数据里同一用户可能对应多条司机档案,
不收敛就会让一条记录在列表里出现多行(并让总数虚高)。
按 update_time 最新取,与司机端解析当前司机档案的既有口径一致。
-->
<select id="selectAuditPage" resultMap="certRecordAuditMap">
SELECT
r.id,
r.tenant_id,
r.biz_type,
r.biz_id,
r.biz_key,
r.status,
r.reject_reason,
r.submit_time,
r.submit_user,
r.audit_time,
r.audit_user,
r.create_user,
r.create_dept,
r.create_time,
r.update_user,
r.update_time,
r.is_deleted,
d.driver_name AS driver_name,
d.mobile AS driver_mobile
FROM
blade_transport_cert_record r
LEFT JOIN blade_transport_driver d
ON d.is_deleted = 0
AND d.id = CASE
WHEN r.biz_type = 'driver' THEN r.biz_id
ELSE (
SELECT dr.id
FROM blade_transport_driver dr
WHERE dr.is_deleted = 0
AND dr.user_id = r.submit_user
ORDER BY dr.update_time DESC, dr.id DESC
LIMIT 1
)
END
WHERE
r.is_deleted = 0
AND r.biz_type = #{bizType}
AND r.status = #{status}
<if test="bizKey != null and bizKey != ''">
AND r.biz_key LIKE CONCAT('%', #{bizKey}, '%')
</if>
<!--
姓名同时匹配「档案当前姓名」与「提交时的姓名」:
司机改名后,按新名与按旧名都应该搜得到他的记录,
只认一个会让审核员两头试。手机号则只有档案当前值一个来源。
-->
<if test="driverName != null and driverName != ''">
AND (
d.driver_name LIKE CONCAT('%', #{driverName}, '%')
OR r.biz_key LIKE CONCAT('%', #{driverName}, '%')
)
</if>
<if test="driverMobile != null and driverMobile != ''">
AND d.mobile LIKE CONCAT('%', #{driverMobile}, '%')
</if>
ORDER BY
r.submit_time DESC
</select>
</mapper>
@@ -48,6 +48,7 @@
<result column="remark" property="remark"/>
<result column="certification_status" property="certificationStatus"/>
<result column="certification_reject_reason" property="certificationRejectReason"/>
<result column="effective_cert_record_id" property="effectiveCertRecordId"/>
</resultMap>
<sql id="BaseColumn">
@@ -96,6 +97,7 @@
remark
, certification_status
, certification_reject_reason
, effective_cert_record_id
</sql>
<sql id="ExpiryWithin30Condition">
@@ -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;
}
@@ -0,0 +1,83 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
/**
* 认证审核服务(管理端)
* <p>
* 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
* 通过使该记录成为生效快照;驳回只终结该记录,生效快照保持不变。
*
* @author Chill
*/
public interface ICertAuditService {
/**
* 认证记录分页(按认证对象类型与审核状态)
* <p>
* 审核页按「待审核 / 已通过 / 已驳回」分页签展示,故状态可传;
* 不传时按待审核处理,兼容只想要待审队列的调用方。
* <p>
* 列表同时下发该记录的司机姓名与手机号,取的是司机档案的**当前**值
* (认证记录里的姓名是提交时的历史值,手机号则从来不在快照里)。
* 解析不到司机档案的记录,两个字段为空,不回落成提交时的值——
* 那会让「同一个人的多条记录显示成不同的人」。
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizKey 展示键模糊匹配(提交时的司机姓名 / 车牌号),可为空
* @param driverName 司机姓名模糊匹配(当前姓名,并兼顾提交时的旧名),可为空
* @param driverMobile 司机手机号模糊匹配,可为空
* @param status 审核状态:0审核中 1已通过 2已驳回 3已作废;为空时取 0(审核中)
* @param page 分页参数(由 Controller 用 Condition.getPage 构造)
* @return 认证记录分页
*/
IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, String driverName, String driverMobile,
Integer status, IPage<TransportCertRecordVO> page);
/**
* 审核详情:同时返回待审快照与当前生效值,供前端做 diff
*
* @param recordId 认证记录主键
* @return 审核详情
*/
CertAuditDetailVO detail(Long recordId);
/**
* 审核一条认证记录
*
* @param recordId 认证记录主键
* @param pass true 通过,false 驳回
* @param rejectReason 驳回原因;驳回时必填,≤200 字
* @return 是否成功
*/
boolean audit(Long recordId, boolean pass, String rejectReason);
}
@@ -22,6 +22,7 @@
*/
package org.springblade.transport.service;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.vo.DriverVehicleCardVO;
import org.springblade.transport.pojo.vo.DriverVO;
@@ -44,4 +45,15 @@ public interface IDriverAppService {
*/
List<DriverVehicleCardVO> myVehicles();
/**
* 当前登录用户对应的司机**实体**
* <p>
* 认证提交要写回司机表,且越权校验要读 {@code driving_vehicle},
* 故这里返回实体而非视图对象。解析顺序:先按登录手机号匹配,
* 匹配不到再按 {@code user_id} 兜底。
*
* @return 司机实体;未登录或查不到时返回 null
*/
Driver currentDriverEntity();
}
@@ -0,0 +1,111 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import java.util.List;
/**
* 司机端认证服务(小程序)
* <p>
* 认证对象是「一事一议」的:司机的个人认证与每一辆车的车辆认证各自独立成立、
* 独立审核、独立留痕。本服务只负责司机端能做什么,管理端审核在审核服务里。
*
* @author Chill
*/
public interface IDriverCertService {
/**
* 提交个人认证(全量:身份证 + 驾驶证 + 从业资格证,三证一起审)
* <p>
* 生成一条待审记录,并把本人前一条待审记录置为已作废。
* **不修改生效快照**:已认证的司机在本次审核通过前仍按旧生效值认定。
*
* @param request 个人认证内容
* @return 新生成的认证记录主键
*/
Long submitPersonCert(PersonCertSubmitRequest request);
/**
* 读取本人当前生效的个人认证内容(供个人认证卡片展示)
*
* @return 生效快照内容;从未认证通过时返回 null
*/
PersonCertSubmitRequest getEffectivePersonCert();
/**
* 提交车辆认证(新增或重新认证)
* <p>
* 生成一条待审记录,并把该车前一条待审记录置为已作废。
* **不修改生效快照**:已认证的车辆在本次审核通过前仍按旧生效值认定,
* 因此提交审核不会影响车辆正常接单。
*
* @param request 车辆认证内容
* @return 新生成的认证记录主键
*/
Long submitVehicleCert(VehicleCertSubmitRequest request);
/**
* 删除车辆认证(按逻辑删除处理)
* <p>
* 只把车辆标记为已删除,**认证记录一律保留**——记录是不可变快照,
* 也是「曾通过即永久可用」这一判定的依据,物理删除会让历史无从追溯。
* 只能删除本人绑定的车辆,越权拒绝。
*
* @param vehicleId 车辆主键
* @return 是否成功
*/
boolean deleteVehicleCert(Long vehicleId);
/**
* 读取某辆车当前生效的认证内容
*
* @param vehicleId 车辆主键
* @return 生效快照内容;从未认证通过时返回 null
*/
VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId);
/**
* 查询某认证对象的认证记录列表(按提交时间倒序)
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizId 认证对象主键
* @return 认证记录列表
*/
List<TransportCertRecordVO> listRecords(String bizType, Long bizId);
/**
* 查询单条认证记录详情
*
* @param recordId 认证记录主键
* @return 认证记录;不存在或无权查看时抛业务异常
*/
TransportCertRecordVO getRecord(Long recordId);
}
@@ -0,0 +1,62 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service;
import org.springblade.core.mp.base.BaseService;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import java.util.List;
/**
* 认证记录 服务类
* <p>
* 只承载认证记录本身的读写与数据模型约束的守护;提交、审核、可用性门槛等业务链路
* 由各自的业务服务实现。
*
* @author Chill
*/
public interface ITransportCertRecordService extends BaseService<TransportCertRecord> {
/**
* 某认证对象的认证记录列表,按提交时间倒序
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizId 认证对象主键
* @return 认证记录列表
*/
List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId);
/**
* 取某认证对象当前的待审快照(至多一条)
*
* @param bizType 认证对象类型:driver-司机,vehicle-车辆
* @param bizId 认证对象主键
* @return 待审快照,无则返回 null
*/
TransportCertRecord selectPendingRecord(String bizType, Long bizId);
}
@@ -0,0 +1,420 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.alibaba.fastjson.JSON;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.utils.Func;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.mapper.TransportCertRecordMapper;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.CertAuditDetailVO;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.ICertAuditService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportCertRecordService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationRuleSupport;
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
/**
* 认证审核服务实现
* <p>
* 审核作用于**一条记录**。两条路径的差别是本次改造的关键:
* <ul>
* <li><b>通过</b>:记录转已通过,并把该对象的生效快照指针切到它;</li>
* <li><b>驳回</b>:记录转已驳回并留原因,生效快照指针<b>一个字都不动</b>。</li>
* </ul>
* 因此「已认证的车被驳回」不会让它掉出可用池,只是这次提交没被采纳。
*
* @author Chill
*/
@Service
@RequiredArgsConstructor
public class CertAuditServiceImpl implements ICertAuditService {
private final ITransportCertRecordService certRecordService;
private final TransportCertRecordMapper certRecordMapper;
private final ITransportVehicleService transportVehicleService;
private final IDriverService driverService;
@Override
public IPage<TransportCertRecordVO> pendingPage(String bizType, String bizKey, String driverName, String driverMobile,
Integer status, IPage<TransportCertRecordVO> page) {
if (!CertificationRuleSupport.isValidBizType(bizType)) {
throw new ServiceException("认证对象类型不正确");
}
// 未传状态时按「审核中」处理:审核页默认停在待审页签,
// 只关心待审队列的调用方也不必额外传参
Integer queryStatus = status == null ? TransportCertRecord.STATUS_PENDING : status;
// 司机姓名与手机号、以及按它们的筛选,都要在**分页之前**由 SQL 完成:
// 在 Java 层补字段再过滤会变成「先分页再过滤」,条数与总数都不准。
List<TransportCertRecordVO> records = certRecordMapper.selectAuditPage(page, bizType, queryStatus,
blankToNull(bizKey), blankToNull(driverName), blankToNull(driverMobile));
TransportCertRecordWrapper wrapper = TransportCertRecordWrapper.build();
records.forEach(wrapper::fillDisplayFields);
return page.setRecords(records);
}
/**
* 搜索项的空串与纯空白一律当作「没填」
* <p>
* 否则 {@code " "} 会变成 {@code LIKE '% %'},把「没输入」当成一个真实筛选条件。
*
* @param value 原始搜索值
* @return 去空白后的值;为空时返回 null
*/
private String blankToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
@Override
public CertAuditDetailVO detail(Long recordId) {
TransportCertRecord record = requireRecord(recordId);
Long effectiveId = effectiveRecordIdOf(record.getBizType(), record.getBizId());
CertAuditDetailVO detail = new CertAuditDetailVO();
detail.setId(record.getId());
detail.setBizType(record.getBizType());
detail.setBizId(record.getBizId());
detail.setBizKey(record.getBizKey());
detail.setStatus(record.getStatus());
detail.setStatusText(CertificationRuleSupport.statusText(record.getStatus()));
detail.setSubmitTime(record.getSubmitTime());
detail.setSubmitUser(record.getSubmitUser());
// 面向前端的展示字段必须由服务端填好,否则审核页「提交人」一栏恒为空白
detail.setSubmitUserName(UserCache.getUserRealName(record.getSubmitUser()));
detail.setPendingSnapshotJson(record.getSnapshotJson());
// 生效值一侧:从未认证通过时为 null,前端按「无对比基准」处理
detail.setEffectiveRecordId(effectiveId);
detail.setCertified(CertificationRuleSupport.isCertified(effectiveId));
if (Func.isNotEmpty(effectiveId)) {
TransportCertRecord effective = certRecordService.getById(effectiveId);
detail.setEffectiveSnapshotJson(effective == null ? null : effective.getSnapshotJson());
}
return detail;
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean audit(Long recordId, boolean pass, String rejectReason) {
TransportCertRecord record = requireRecord(recordId);
// 终态不可再审。若允许,把指针指着的那条「已通过」改成「已驳回」,
// 生效值就凭空消失了——这正是必须挡住的原因。
if (!CertificationRuleSupport.isAuditable(record)) {
throw new ServiceException("该认证记录已是"
+ CertificationRuleSupport.statusText(record.getStatus()) + "状态,不可再次审核");
}
if (!CertificationRuleSupport.isRejectReasonValid(pass, rejectReason)) {
throw new ServiceException("请输入认证驳回原因(不超过"
+ TransportCertRecord.REJECT_REASON_MAX_LENGTH + "字)");
}
Long auditor = AuthUtil.getUserId();
LocalDateTime auditTime = LocalDateTime.now();
// 1) 记录本身:状态 + 审核留痕(审核人、审核时间写在这条记录上,不依赖会被覆盖的审计字段)
TransportCertRecord patch = new TransportCertRecord();
patch.setId(record.getId());
patch.setStatus(pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED);
patch.setRejectReason(pass
? CertificationRuleSupport.reasonForApprove()
: CertificationRuleSupport.reasonForReject(rejectReason));
patch.setAuditUser(auditor);
patch.setAuditTime(auditTime);
certRecordService.updateById(patch);
// 2) 认证对象:通过才切指针;驳回只刷展示缓存。两者合并为**一次** UPDATE,
// 避免对同一行连续写两次(第二次会把第一次的指针覆盖掉的风险也随之消失)。
updateCertTarget(record, pass, rejectReason);
return true;
}
/**
* 更新认证对象的展示缓存,并在通过时把生效快照指针切到这条记录
* <p>
* 只发**一次** UPDATE:
* <ul>
* <li>通过 —— 状态=已通过、清空驳回原因、指针指向本条(切换而非追加,故仍至多一条生效快照);</li>
* <li>驳回 —— 状态=已驳回、写入原因,**不带指针字段**,生效快照一个字都不动。</li>
* </ul>
* 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。
*
* @param record 被审核的记录
* @param pass 是否通过
* @param rejectReason 驳回原因
*/
private void updateCertTarget(TransportCertRecord record, boolean pass, String rejectReason) {
boolean vehicleTarget = TransportCertRecord.BIZ_TYPE_VEHICLE.equals(record.getBizType());
Integer status = pass ? TransportCertRecord.STATUS_APPROVED : TransportCertRecord.STATUS_REJECTED;
String reason = pass ? null : CertificationRuleSupport.reasonForReject(rejectReason);
// 只有通过才切指针;驳回时保持 null,框架的 not_null 更新策略会跳过该列
Long newEffectiveId = pass ? record.getId() : null;
if (vehicleTarget) {
TransportVehicle vehicle = new TransportVehicle();
vehicle.setId(record.getBizId());
vehicle.setCertificationStatus(status);
vehicle.setCertificationRejectReason(reason);
if (pass) {
vehicle.setEffectiveCertRecordId(newEffectiveId);
// 通过即生效:把这次快照的证件内容一并写回车辆档案,让「车辆档案 = 生效值」成立。
// 不写回的话,审核通过之后管理端与司机端看到的仍是提交之前的旧值——
// 状态显示「已通过」、内容却还是旧的,是最容易让人误判的一种不一致。
// 与指针合并成同一次 UPDATE,不额外多写一次同一行。
applySnapshotToVehicle(vehicle, record.getSnapshotJson());
}
transportVehicleService.updateById(vehicle);
return;
}
Driver driver = new Driver();
driver.setId(record.getBizId());
driver.setCertificationStatus(status);
driver.setCertificationRejectReason(reason);
// 必须带上 driving_vehicle 原值:该列是 FieldStrategy.ALWAYS,
// 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL,
// 而车辆列表正是按这一列匹配的(清空即「暂无绑定车辆」)。
copyDrivingVehicle(record.getBizId(), driver);
if (pass) {
driver.setEffectiveCertRecordId(newEffectiveId);
// 通过即生效:与车辆侧同理,把这次快照的证件内容一并写回司机档案。
// 不写回的话,司机档案里始终是管理端当初录入的旧值(甚至为空),
// 而认证状态已是「已通过」——状态与内容不一致最容易让人误判。
// 与指针合并成同一次 UPDATE,不额外多写一次同一行。
applySnapshotToDriver(driver, record.getSnapshotJson());
}
driverService.updateById(driver);
}
/**
* 把司机当前的车辆绑定原值搬进 patch,避免被 ALWAYS 更新策略清空
* <p>
* {@code driving_vehicle} 上标了 {@code FieldStrategy.ALWAYS},任何一次
* {@code updateById} 都会把该列写成 patch 里的值(null 也照写)。
* 而司机端的车辆列表是按这一列的车牌匹配出来的,一旦被清空,
* 页面就变成「暂无绑定车辆」,且无法从界面上恢复。
*
* @param driverId 司机主键
* @param patch 待更新的司机 patch(就地写入原值)
*/
private void copyDrivingVehicle(Long driverId, Driver patch) {
Driver current = driverService.getById(driverId);
if (current != null) {
patch.setDrivingVehicle(current.getDrivingVehicle());
}
}
/**
* 把生效快照里的司机证件字段搬进司机档案(就地写入同一个 patch 对象)
* <p>
* 只搬**司机自己申报的证件内容**,不碰两类字段:
* <ul>
* <li>{@code realName} / {@code idCardNo}:身份标识,
* 由管理端建档时确定,司机端提交不覆盖(重复认证时会按证件号校验为同一人);</li>
* <li>{@code driverType} / {@code posts} / {@code drivingVehicle} / {@code organizationName}:
* 内部管理归属与派工关系,由管理端维护,不该被司机的提交内容覆盖。</li>
* </ul>
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
* 因此不会把管理端填过、司机端没填的字段清空。
* <p>
* 快照缺失、无法解析或不是当前字段定义时**只跳过写回,不阻断审核**:
* 审核本身是独立于写回的业务动作(切指针、置状态),不该因为一份读不懂的快照而失败;
* 何况历史快照本来就存在,阻断会让这些对象再也审不过去。
*
* @param driver 待更新的司机 patch
* @param snapshotJson 该条记录的全量提交内容
*/
private void applySnapshotToDriver(Driver driver, String snapshotJson) {
if (Func.isEmpty(snapshotJson)) {
return;
}
PersonCertSubmitRequest snapshot;
try {
snapshot = JSON.parseObject(snapshotJson, PersonCertSubmitRequest.class);
} catch (Exception exception) {
return;
}
if (snapshot == null) {
return;
}
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(历史快照就是这种情况),
// 那样写回等于什么也没写,还会把档案里已有的值当成「没填」而跳过。
// 故用证件内容里的关键字段判定「这份快照是否是当前格式」,不是就不写。
if (Func.isEmpty(snapshot.getIdCardNo()) && Func.isEmpty(snapshot.getDriverFileNo())) {
return;
}
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
driver.setIdCardBack(snapshot.getIdCardBackUrl());
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
driver.setIdCardValidEnd(snapshot.getIdCardValidEnd());
driver.setIdCardLongTerm(snapshot.getIdCardLongTerm());
driver.setHeadPhoto(snapshot.getHeadPhoto());
driver.setDrivingType(snapshot.getDriverClass());
driver.setDrivingLicenseNo(snapshot.getDriverFileNo());
driver.setDrivingLicenseStartDate(snapshot.getDriverValidStart());
driver.setDrivingLicenseEndDate(snapshot.getDriverValidEnd());
driver.setDrivingLicenseLongTerm(snapshot.getDriverLongTerm());
driver.setDrivingLicenseFront(snapshot.getDriverLicenseFrontUrl());
driver.setDrivingLicenseBack(snapshot.getDriverLicenseBackUrl());
driver.setQualificationType(snapshot.getQualificationType());
driver.setQualificationNo(snapshot.getQualificationNo());
driver.setQualificationFront(qualificationFrontOf(snapshot, snapshotJson));
driver.setQualificationBack(snapshot.getQualificationBack());
driver.setQualificationEndDate(snapshot.getQualificationValidEnd());
driver.setQualificationLongTerm(snapshot.getQualificationLongTerm());
}
/**
* 取从业资格证封面页地址,兼容改名前的旧键
* <p>
* 该字段原叫 {@code qualificationUrl},与司机档案列 {@code qualification_front} 不一致,
* 已统一为 {@code qualificationFront}。改名之前提交的记录里存的仍是旧键,
* 直接按新键取值会得到 null,审核通过后反而把已填的封面页丢掉,故这里回退读一次旧键。
*
* @param snapshot 已解析的快照(可能因旧键而为 null)
* @param snapshotJson 原始快照 JSON,用于回退读取旧键
* @return 封面页地址;新旧键都没有时为 null
*/
private String qualificationFrontOf(PersonCertSubmitRequest snapshot, String snapshotJson) {
if (Func.isNotEmpty(snapshot.getQualificationFront())) {
return snapshot.getQualificationFront();
}
try {
return JSON.parseObject(snapshotJson).getString("qualificationUrl");
} catch (Exception exception) {
// 旧键缺失或 JSON 结构异常都不该阻断写回,没有封面页就不写这一列
return null;
}
}
/**
* 把生效快照里的车辆证件字段搬进车辆档案(就地写入同一个 patch 对象)
* <p>
* 只搬**证件内容**,不碰两类字段:
* <ul>
* <li>{@code plateNo}:车牌是这辆车的身份,不是本次提交可以改的内容;</li>
* <li>{@code organizationName} / {@code useDepartment}:内部管理归属,
* 由管理端维护,不该被司机的提交内容覆盖。</li>
* </ul>
* 快照里为 null 的字段不会写(框架的 not_null 更新策略会跳过),
* 因此不会把管理端填过、司机端没填的字段清空。
*
* @param vehicle 待更新的车辆 patch
* @param snapshotJson 该条记录的全量提交内容
*/
private void applySnapshotToVehicle(TransportVehicle vehicle, String snapshotJson) {
if (Func.isEmpty(snapshotJson)) {
throw new ServiceException("该认证记录没有提交内容,无法写入车辆档案");
}
VehicleCertSubmitRequest snapshot;
try {
snapshot = JSON.parseObject(snapshotJson, VehicleCertSubmitRequest.class);
} catch (Exception exception) {
throw new ServiceException("该认证记录的提交内容无法解析,未写入车辆档案");
}
// 字段名对不上时 JSON 会解析成「全是 null」而不报错(改造前的历史快照就是这种情况),
// 那样写回等于什么也没写。故用证件内容里的关键字段判定「这份快照是否是当前格式」。
if (Func.isEmpty(snapshot.getDrivingLicenseNo()) && Func.isEmpty(snapshot.getApprovedLoadKg())) {
throw new ServiceException("该认证记录的提交内容与当前字段定义不匹配(可能是改造前的历史快照),"
+ "未写入车辆档案;请重新提交一次认证");
}
vehicle.setOwnerName(snapshot.getOwnerName());
vehicle.setPlateColor(snapshot.getPlateColor());
vehicle.setVehicleType(snapshot.getVehicleType());
vehicle.setVin(snapshot.getVin());
vehicle.setOuterLength(snapshot.getOuterLength());
vehicle.setOuterWidth(snapshot.getOuterWidth());
vehicle.setOuterHeight(snapshot.getOuterHeight());
vehicle.setApprovedLoadKg(snapshot.getApprovedLoadKg());
vehicle.setTractionMassKg(snapshot.getTractionMassKg());
vehicle.setEnergyType(snapshot.getEnergyType());
vehicle.setBusinessRelation(snapshot.getBusinessRelation());
vehicle.setCompulsoryScrapDate(snapshot.getCompulsoryScrapDate());
vehicle.setCompulsoryScrapLongTerm(snapshot.getCompulsoryScrapLongTerm());
vehicle.setDrivingLicenseNo(snapshot.getDrivingLicenseNo());
vehicle.setDrivingLicenseImage(snapshot.getDrivingLicenseImage());
vehicle.setDrivingLicenseMainBack(snapshot.getDrivingLicenseMainBack());
vehicle.setDrivingLicenseViceFront(snapshot.getDrivingLicenseViceFront());
vehicle.setDrivingLicenseViceBack(snapshot.getDrivingLicenseViceBack());
vehicle.setDrivingLicenseStartDate(snapshot.getDrivingLicenseStartDate());
vehicle.setDrivingLicenseEndDate(snapshot.getDrivingLicenseEndDate());
vehicle.setDrivingLicenseLongTerm(snapshot.getDrivingLicenseLongTerm());
vehicle.setRoadTransportCertNo(snapshot.getRoadTransportCertNo());
vehicle.setRoadTransportCertImage(snapshot.getRoadTransportCertImage());
vehicle.setRoadTransportCertStartDate(snapshot.getRoadTransportCertStartDate());
vehicle.setRoadTransportCertEndDate(snapshot.getRoadTransportCertEndDate());
vehicle.setRoadTransportCertLongTerm(snapshot.getRoadTransportCertLongTerm());
vehicle.setRegistrationNo(snapshot.getRegistrationNo());
vehicle.setRegistrationImage(snapshot.getRegistrationImage());
}
/**
* 取该对象当前的生效快照指针
*
* @param bizType 认证对象类型
* @param bizId 认证对象主键
* @return 指针值;对象不存在或从未认证通过时返回 null
*/
private Long effectiveRecordIdOf(String bizType, Long bizId) {
if (TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType)) {
TransportVehicle vehicle = transportVehicleService.getById(bizId);
return vehicle == null ? null : vehicle.getEffectiveCertRecordId();
}
Driver driver = driverService.getById(bizId);
return driver == null ? null : driver.getEffectiveCertRecordId();
}
private TransportCertRecord requireRecord(Long recordId) {
if (Func.isEmpty(recordId)) {
throw new ServiceException("认证记录主键不能为空");
}
TransportCertRecord record = certRecordService.getById(recordId);
if (record == null) {
throw new ServiceException("认证记录不存在");
}
return record;
}
}
@@ -22,6 +22,7 @@
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
@@ -37,16 +38,14 @@ import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverAppService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.wrapper.DriverWrapper;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -72,22 +71,15 @@ public class DriverAppServiceImpl implements IDriverAppService {
if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) {
return List.of();
}
List<String> plates = splitPlates(driver.getDrivingVehicle());
List<String> plates = VehiclePlateSupport.splitPlates(driver.getDrivingVehicle());
if (plates.isEmpty()) {
return List.of();
}
// 精确匹配 + 规范化匹配(兼容库中带间隔符/横线的车牌)
List<TransportVehicle> vehicles = transportVehicleService.list(Wrappers.<TransportVehicle>lambdaQuery()
.and(w -> {
w.in(TransportVehicle::getPlateNo, plates);
for (String plate : plates) {
w.or().apply(
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no),'·',''),'•',''),'.',''),'-',''),' ','') = {0}",
plate
);
}
})
.orderByDesc(TransportVehicle::getUpdateTime));
// 精确 + 规范化双条件匹配,统一走共享实现(避免各调用点各自实现而漏掉写法差异)
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery, plates);
List<TransportVehicle> vehicles = transportVehicleService.list(
plateQuery.orderByDesc(TransportVehicle::getUpdateTime));
// 去重(精确与规范化可能命中同一条)
Map<Long, TransportVehicle> uniq = new LinkedHashMap<>();
for (TransportVehicle vehicle : vehicles) {
@@ -98,6 +90,11 @@ public class DriverAppServiceImpl implements IDriverAppService {
return uniq.values().stream().map(this::toCard).collect(Collectors.toList());
}
@Override
public Driver currentDriverEntity() {
return currentDriver(null);
}
private Driver currentDriver(String mobileHint) {
Long userId = AuthUtil.getUserId();
if (userId == null || userId <= 0) {
@@ -119,57 +116,66 @@ public class DriverAppServiceImpl implements IDriverAppService {
return driver;
}
private List<String> splitPlates(String drivingVehicle) {
String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",")
.replace(";", ",").replace("/", ",").replace("|", ",");
Set<String> plates = new LinkedHashSet<>();
for (String part : Func.toStrList(",", normalized)) {
if (Func.isEmpty(part)) {
continue;
}
String plate = normalizePlate(part);
if (Func.isNotEmpty(plate)) {
plates.add(plate);
}
}
return new ArrayList<>(plates);
}
/** 车牌规范化:去空格/横线/间隔符并转大写,便于与车辆表关联 */
private String normalizePlate(String plateNo) {
if (Func.isEmpty(plateNo)) {
return "";
}
return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT);
}
private DriverVehicleCardVO toCard(TransportVehicle vehicle) {
DriverVehicleCardVO card = new DriverVehicleCardVO();
card.setId(vehicle.getId());
card.setPlateNo(vehicle.getPlateNo());
card.setOwnerName(Func.toStr(vehicle.getOwnerName(), ""));
card.setPlateColor(Func.toStr(vehicle.getPlateColor(), ""));
card.setVehicleType(vehicle.getVehicleType());
card.setLicenseFrontUrl(Func.toStr(vehicle.getDrivingLicenseImage(), ""));
card.setLicenseBackUrl(firstNotEmpty(vehicle.getDrivingLicenseViceFront(), vehicle.getDrivingLicenseMainBack()));
card.setRoadTransportNo(Func.toStr(vehicle.getRoadTransportCertNo(), ""));
card.setRoadTransportUrl(Func.toStr(vehicle.getRoadTransportCertImage(), ""));
card.setVin("");
card.setEngineNo("");
if (vehicle.getDrivingLicenseEndDate() != null) {
card.setLicenseValidEnd(vehicle.getDrivingLicenseEndDate().toString());
} else if (Integer.valueOf(1).equals(vehicle.getDrivingLicenseLongTerm())) {
card.setLicenseValidEnd("长期");
} else {
card.setLicenseValidEnd("");
}
card.setVin(Func.toStr(vehicle.getVin(), ""));
card.setOuterLength(vehicle.getOuterLength());
card.setOuterWidth(vehicle.getOuterWidth());
card.setOuterHeight(vehicle.getOuterHeight());
card.setApprovedLoadKg(vehicle.getApprovedLoadKg());
card.setTractionMassKg(vehicle.getTractionMassKg());
card.setEnergyType(Func.toStr(vehicle.getEnergyType(), ""));
card.setBusinessRelation(Func.toStr(vehicle.getBusinessRelation(), ""));
card.setCompulsoryScrapDate(dateText(vehicle.getCompulsoryScrapDate(), vehicle.getCompulsoryScrapLongTerm()));
card.setCompulsoryScrapLongTerm(vehicle.getCompulsoryScrapLongTerm());
card.setDrivingLicenseNo(Func.toStr(vehicle.getDrivingLicenseNo(), ""));
// 行驶证四张图各自独立,不再把「主页反面」与「副页正面」挤在同一个字段里:
// 它们本来就是证件上不同的四面,混在一起会让「重新认证」回填出错的图。
card.setDrivingLicenseImage(Func.toStr(vehicle.getDrivingLicenseImage(), ""));
card.setDrivingLicenseMainBack(Func.toStr(vehicle.getDrivingLicenseMainBack(), ""));
card.setDrivingLicenseViceFront(Func.toStr(vehicle.getDrivingLicenseViceFront(), ""));
card.setDrivingLicenseViceBack(Func.toStr(vehicle.getDrivingLicenseViceBack(), ""));
card.setDrivingLicenseStartDate(dateText(vehicle.getDrivingLicenseStartDate(), null));
card.setDrivingLicenseEndDate(dateText(vehicle.getDrivingLicenseEndDate(), vehicle.getDrivingLicenseLongTerm()));
card.setDrivingLicenseLongTerm(vehicle.getDrivingLicenseLongTerm());
card.setRoadTransportCertNo(Func.toStr(vehicle.getRoadTransportCertNo(), ""));
card.setRoadTransportCertImage(Func.toStr(vehicle.getRoadTransportCertImage(), ""));
card.setRoadTransportCertStartDate(dateText(vehicle.getRoadTransportCertStartDate(), null));
card.setRoadTransportCertEndDate(dateText(vehicle.getRoadTransportCertEndDate(), vehicle.getRoadTransportCertLongTerm()));
card.setRoadTransportCertLongTerm(vehicle.getRoadTransportCertLongTerm());
card.setRegistrationNo(Func.toStr(vehicle.getRegistrationNo(), ""));
card.setRegistrationImage(Func.toStr(vehicle.getRegistrationImage(), ""));
card.setCertificationStatus(vehicle.getCertificationStatus());
return card;
}
private String firstNotEmpty(String first, String second) {
if (Func.isNotEmpty(first)) {
return first;
/**
* 日期字段的下发值:只下发 {@code yyyy-MM-dd} 或空串,**从不下发「长期」这类文字**
* <p>
* 卡片是「重新认证」表单的回填来源,值会直接进日期框,所以两条约束必须守住:
* <ul>
* <li>长期有效时下发**空串**:长期与否由同名字段 {@code xxxLongTerm} 表达,
* 界面上显示「长期」是前端的事。把「长期」当日期下发,用户关掉长期有效开关后
* 日期框里会留着一个字面量「长期」,提交时后端按 {@code LocalDate} 反序列化直接报错。</li>
* <li>长期有效时**不下发止期**:止期在「长期」语义下已作废(管理端勾长期有效时也会清掉它),
* 下发旧值会被表单原样回传、审核通过后又写回车辆行,形成「长期有效 + 一个过期止期」
* 的脏数据——车辆行上一次长期有效的提交正是这样留下 2040-08-14 的。</li>
* </ul>
*
* @param date 日期
* @param longTerm 长期有效标志:1 表示长期
* @return {@code yyyy-MM-dd} 或空串
*/
private String dateText(LocalDate date, Integer longTerm) {
if (Integer.valueOf(1).equals(longTerm)) {
return "";
}
return Func.toStr(second, "");
return date == null ? "" : date.toString();
}
/**
@@ -0,0 +1,561 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.IDriverAppService;
import org.springblade.transport.service.IDriverCertService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportCertRecordService;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationRuleSupport;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* 司机端认证服务实现
* <p>
* 提交链路只做三件事:把本次提交的内容**原样**存成一条新的待审记录、
* 把该对象前一条待审记录置为已作废、刷新展示缓存。
* <p>
* ⚠️ 提交**绝不**修改 {@code effective_cert_record_id},也**不覆盖**证件字段,
* 这正是「提交审核不影响车辆正常使用」的实现方式:待审内容只存在于记录表里,
* 在审核通过之前,该对象对外呈现的仍然是旧的生效值。
*
* @author Chill
*/
@Service
@RequiredArgsConstructor
public class DriverCertServiceImpl implements IDriverCertService {
/**
* 证件文本字段的长度上限:与车辆表列宽及管理端校验保持一致
*/
private static final int SHORT_TEXT_MAX_LENGTH = 50;
/**
* 所有人(行驶证登记的单位名称)长度上限
*/
private static final int OWNER_NAME_MAX_LENGTH = 100;
private final IDriverAppService driverAppService;
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final ITransportCertRecordService transportCertRecordService;
/**
* 司机端提交的车辆认证内容校验
* <p>
* 服务端必须独立校验一遍,不能依赖小程序的前端校验:前端可绕过。
* 校验发生在**落快照之前**——待审内容虽然要等审核通过才写进车辆档案,但它已经是一条
* 不可变记录、并会在通过时按字段写回车辆行({@code applySnapshotToVehicle}),
* 校验只做在「新建车辆」那一条路上,重新认证就成了绕过校验的后门。
*
* @param request 提交内容
*/
private void validateCertContent(VehicleCertSubmitRequest request) {
requireText(request.getPlateColor(), "车牌颜色不能为空", SHORT_TEXT_MAX_LENGTH);
requireText(request.getVehicleType(), "车辆类型不能为空", SHORT_TEXT_MAX_LENGTH);
requireText(request.getOwnerName(), "所有人不能为空", OWNER_NAME_MAX_LENGTH);
requireText(request.getEnergyType(), "能源类型不能为空", SHORT_TEXT_MAX_LENGTH);
requireText(request.getBusinessRelation(), "业务关系不能为空", SHORT_TEXT_MAX_LENGTH);
requireText(request.getDrivingLicenseNo(), "行驶证档案编号不能为空", SHORT_TEXT_MAX_LENGTH);
requireText(request.getRoadTransportCertNo(), "道路运输证号不能为空", SHORT_TEXT_MAX_LENGTH);
Optional.ofNullable(request.getVin())
.filter(Func::isNotEmpty)
.ifPresent(vin -> requireText(vin, "车架号格式不正确", 17));
requireNonNegative(request.getApprovedLoadKg(), "核定载质量不能为空", "核定载质量不能小于0");
requireNonNegative(request.getTractionMassKg(), "准牵引总质量不能为空", "准牵引总质量不能小于0");
requireNonNegative(request.getOuterLength(), "外廓长度不能为空", "外廓长度不能小于0");
requireNonNegative(request.getOuterWidth(), "外廓宽度不能为空", "外廓宽度不能小于0");
requireNonNegative(request.getOuterHeight(), "外廓高度不能为空", "外廓高度不能小于0");
requireDateRange(request.getDrivingLicenseStartDate(), request.getDrivingLicenseEndDate(),
request.getDrivingLicenseLongTerm(), "请选择行驶证有效期", "行驶证有效期止不能早于有效期起");
requireDateRange(request.getRoadTransportCertStartDate(), request.getRoadTransportCertEndDate(),
request.getRoadTransportCertLongTerm(), "请选择道路运输证有效期", "道路运输证有效期止不能早于有效期起");
if (Func.isEmpty(request.getCompulsoryScrapDate())
&& !Integer.valueOf(1).equals(request.getCompulsoryScrapLongTerm())) {
throw new ServiceException("请选择强制报废日期");
}
}
/**
* 建档前置条件:车辆行的 {@code organization_name} 是 NOT NULL 且无默认值,缺了这条 INSERT
* 直接报「Field 'organization_name' doesn't have a default value」,用户看到的是一串英文异常。
* <p>
* 只在**新建车辆**时要求:重新认证不会写这一列(它由管理端维护,属于内部管理归属),
* 拿它拦住重新认证只会让司机无从下手。
*
* @param driver 当前司机
*/
private void requireOrganizationForNewVehicle(Driver driver) {
if (Func.isEmpty(driver.getOrganizationName())) {
throw new ServiceException("司机档案缺少所属组织,请联系管理员补充后再提交");
}
}
/**
* 文本必填 + 长度校验
*
* @param value 待校验值
* @param emptyText 为空时的提示
* @param maxLength 长度上限
*/
private void requireText(String value, String emptyText, int maxLength) {
if (Func.isEmpty(value)) {
throw new ServiceException(emptyText);
}
if (value.trim().length() > maxLength) {
throw new ServiceException(emptyText.replace("不能为空", "") + "不能超过" + maxLength + "字");
}
}
/**
* 数值必填 + 非负校验
*
* @param value 待校验值
* @param emptyText 为空时的提示
* @param negativeText 为负数时的提示
*/
private void requireNonNegative(Integer value, String emptyText, String negativeText) {
if (value == null) {
throw new ServiceException(emptyText);
}
if (value < 0) {
throw new ServiceException(negativeText);
}
}
/**
* 有效期校验:未勾「长期有效」时**止期**必填;勾了长期有效则止期也不要求
* <p>
* 起期不再是必填:司机端表单不采集它(管理端车辆表单同样只填止期),
* 存量车辆档案里起期列全是空的,强制必填会让每一次重新认证都卡在提交前。
* 起止先后只在**两端都有值**时才比较——只有止期时没有可比的对象。
*
* @param start 有效期起,可为空
* @param end 有效期止
* @param longTerm 长期有效标志:1 表示长期
* @param emptyText 缺止期时的提示
* @param rangeText 区间颠倒时的提示
*/
private void requireDateRange(LocalDate start, LocalDate end, Integer longTerm,
String emptyText, String rangeText) {
if (Integer.valueOf(1).equals(longTerm)) {
return;
}
if (end == null) {
throw new ServiceException(emptyText);
}
if (start != null && end.isBefore(start)) {
throw new ServiceException(rangeText);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long submitPersonCert(PersonCertSubmitRequest request) {
if (Func.isEmpty(request)) {
throw new ServiceException("认证内容不能为空");
}
Driver driver = requireCurrentDriver();
String snapshotJson = JSON.toJSONString(request);
// 先作废本人上一条待审,再落新记录:任何时刻同一对象至多一条待审。
// 作废只动「待审」,已通过/已驳回都是终态,尤其不得碰生效快照指着的那条。
supersedePending(TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId());
TransportCertRecord record = newRecord(
TransportCertRecord.BIZ_TYPE_DRIVER, driver.getId(),
Func.toStr(driver.getDriverName(), ""), snapshotJson, driver.getUserId());
transportCertRecordService.save(record);
// 展示缓存:让小程序卡片显示「审核中」。可用性判定不看这个字段。
updateDriverDisplayCache(driver.getId(), TransportCertRecord.STATUS_PENDING, null);
return record.getId();
}
@Override
public PersonCertSubmitRequest getEffectivePersonCert() {
Driver driver = requireCurrentDriver();
// 当前司机实体已加载过,直接读它身上的指针,不再回查一次
TransportCertRecord effective = findEffectiveRecord(driver.getEffectiveCertRecordId());
return effective == null
? null
: JSON.parseObject(effective.getSnapshotJson(), PersonCertSubmitRequest.class);
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long submitVehicleCert(VehicleCertSubmitRequest request) {
if (Func.isEmpty(request)) {
throw new ServiceException("认证内容不能为空");
}
Driver driver = requireCurrentDriver();
// 校验在 resolveBoundVehicle 之前:它既覆盖「新建车辆」,也覆盖「重新认证」——
// 后者原先一点校验都不做,内容全靠审核环节兜底
validateCertContent(request);
TransportVehicle vehicle = resolveBoundVehicle(driver, request);
String snapshotJson = JSON.toJSONString(request);
supersedePending(TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId());
TransportCertRecord record = newRecord(
TransportCertRecord.BIZ_TYPE_VEHICLE, vehicle.getId(),
Func.toStr(vehicle.getPlateNo(), ""), snapshotJson, driver.getUserId());
transportCertRecordService.save(record);
updateVehicleDisplayCache(vehicle.getId(), TransportCertRecord.STATUS_PENDING, null);
return record.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean deleteVehicleCert(Long vehicleId) {
if (Func.isEmpty(vehicleId)) {
throw new ServiceException("车辆主键不能为空");
}
Driver driver = requireCurrentDriver();
TransportVehicle vehicle = transportVehicleService.getById(vehicleId);
if (vehicle == null) {
throw new ServiceException("车辆不存在");
}
// 越权校验:与提交链路同一套绑定判定
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权删除该车辆的认证");
}
// 逻辑删除车辆;认证记录**不删**——它是不可变快照,且是「曾通过即永久可用」的依据
return transportVehicleService.deleteLogic(Collections.singletonList(vehicleId));
}
@Override
public VehicleCertSubmitRequest getEffectiveVehicleCert(Long vehicleId) {
if (Func.isEmpty(vehicleId)) {
throw new ServiceException("车辆主键不能为空");
}
TransportVehicle vehicle = transportVehicleService.getById(vehicleId);
if (vehicle == null) {
throw new ServiceException("车辆不存在");
}
if (!VehiclePlateSupport.isBound(requireCurrentDriver().getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权查看该车辆的认证信息");
}
TransportCertRecord effective = findEffectiveRecord(vehicle.getEffectiveCertRecordId());
return effective == null
? null
: JSON.parseObject(effective.getSnapshotJson(), VehicleCertSubmitRequest.class);
}
@Override
public List<TransportCertRecordVO> listRecords(String bizType, Long bizId) {
requireValidBizType(bizType);
requireReadableTarget(bizType, bizId);
return transportCertRecordService.selectRecordList(bizType, bizId);
}
@Override
public TransportCertRecordVO getRecord(Long recordId) {
TransportCertRecord record = transportCertRecordService.getById(recordId);
if (record == null) {
throw new ServiceException("认证记录不存在");
}
requireReadableTarget(record.getBizType(), record.getBizId());
return TransportCertRecordWrapper.build().entityVO(record);
}
/**
* 把该认证对象当前那条待审记录置为已作废
*
* @param bizType 认证对象类型
* @param bizId 认证对象主键
*/
private void supersedePending(String bizType, Long bizId) {
TransportCertRecord pending = transportCertRecordService.selectPendingRecord(bizType, bizId);
TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(pending);
if (superseded != null) {
transportCertRecordService.updateById(superseded);
}
}
/**
* 构造一条待审记录
* <p>
* 状态必须显式设置为待审:框架在插入时会把空的 {@code status} 静默填成 1,
* 而 1 在本表是「已通过」,那会让一次提交凭空变成审核通过。
*
* @param submitUser 提交人**用户ID**(blade_user.id)——审核页要靠它翻译成姓名。
* 不要传司机档案ID:两者都是雪花ID、类型也相同,传错不会报任何错,
* 只会让「提交人」一栏显示成一串数字。
*/
private TransportCertRecord newRecord(String bizType, Long bizId, String bizKey,
String snapshotJson, Long submitUser) {
TransportCertRecord record = new TransportCertRecord();
record.setBizType(bizType);
record.setBizId(bizId);
record.setBizKey(bizKey);
record.setSnapshotJson(snapshotJson);
record.setSubmitTime(LocalDateTime.now());
record.setSubmitUser(submitUser);
CertificationRuleSupport.prepareForSubmit(record);
return record;
}
/**
* 按生效快照指针取那条记录
* <p>
* 只按指针查,不看记录表里任何一条的「最新状态」——这正是生效值与待审值分离的意义。
*
* @param effectiveRecordId 生效快照指针
* @return 生效快照记录;指针为空时返回 null
*/
private TransportCertRecord findEffectiveRecord(Long effectiveRecordId) {
return Func.isEmpty(effectiveRecordId)
? null
: transportCertRecordService.getById(effectiveRecordId);
}
/**
* 刷新司机侧的「最新提交状态」展示缓存
* <p>
* 只写展示缓存,**不碰生效快照指针**。
*/
private void updateDriverDisplayCache(Long driverId, Integer status, String rejectReason) {
Driver patch = new Driver();
patch.setId(driverId);
patch.setCertificationStatus(status);
patch.setCertificationRejectReason(rejectReason);
// 必须带上 driving_vehicle 原值:该列标了 FieldStrategy.ALWAYS,
// 更新时忽略 null 直接写,patch 里不带它就会把司机的车辆绑定清成 NULL,
// 而司机端的车辆列表正是按这一列的车牌匹配出来的(清空即「暂无绑定车辆」)。
Driver current = driverService.getById(driverId);
if (current != null) {
patch.setDrivingVehicle(current.getDrivingVehicle());
}
driverService.updateById(patch);
}
/**
* 刷新车辆侧的「最新提交状态」展示缓存
* <p>
* 只写展示缓存,**不碰生效快照指针**,也不覆盖车辆当前的证件字段。
*/
private void updateVehicleDisplayCache(Long vehicleId, Integer status, String rejectReason) {
TransportVehicle patch = new TransportVehicle();
patch.setId(vehicleId);
patch.setCertificationStatus(status);
patch.setCertificationRejectReason(rejectReason);
transportVehicleService.updateById(patch);
}
/**
* 解析本次提交指向的车辆,并校验它确实绑定在当前司机名下
*
* @param driver 当前司机
* @param request 提交内容
* @return 目标车辆
*/
private TransportVehicle resolveBoundVehicle(Driver driver, VehicleCertSubmitRequest request) {
// 按 id 定位:只能操作本人绑定的车,否则越权
if (Func.isNotEmpty(request.getId())) {
TransportVehicle vehicle = transportVehicleService.getById(request.getId());
if (vehicle == null) {
throw new ServiceException("车辆不存在");
}
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权操作该车辆的认证");
}
return vehicle;
}
String normalized = VehiclePlateSupport.normalize(request.getPlateNo());
if (Func.isEmpty(normalized)) {
throw new ServiceException("车牌号不能为空");
}
// 按车牌定位:已登记的车必须已绑定到本人;未登记的车视为「新增」,就地建档并绑定。
// 小程序「+ 新增车辆」允许司机直接填车牌,若这里只认已有绑定,
// 新车的提交必然失败——而司机名下还没有这辆车,也就永远无从绑定。
TransportVehicle existing = findVehicleByAnyPlateForm(normalized);
if (existing != null) {
if (existing.getIsDeleted() != null && existing.getIsDeleted() != 0) {
throw new ServiceException("该车牌已被删除,请联系管理员");
}
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), existing.getPlateNo())) {
// 已登记但不在本人名下,可能属于其他司机 —— 拒绝,不能顺手改归属
throw new ServiceException("该车牌已归属其他司机,请联系管理员");
}
return existing;
}
return createVehicleForDriver(driver, request, normalized);
}
/**
* 按车牌(容忍写法差异)查已登记的车辆
*
* @param normalizedPlate 已规范化的车牌
* @return 车辆;未登记返回 null
*/
private TransportVehicle findVehicleByAnyPlateForm(String normalizedPlate) {
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery, List.of(normalizedPlate));
return transportVehicleService.list(plateQuery).stream()
.filter(item -> normalizedPlate.equals(VehiclePlateSupport.normalize(item.getPlateNo())))
.findFirst()
.orElse(null);
}
/**
* 为司机新增一辆车并绑定到其名下
* <p>
* 只写入车辆的基本识别信息与本次提交的证件内容;车辆是否「已认证」仍由
* 生效快照指针决定,故此处建档**不会**让一辆未审核通过的车变得可用。
*
* @param driver 当前司机
* @param request 提交内容
* @param normalizedPlate 已规范化的车牌
* @return 新建的车辆
*/
private TransportVehicle createVehicleForDriver(Driver driver, VehicleCertSubmitRequest request,
String normalizedPlate) {
// 内容校验已在 submitVehicleCert 里统一做过;这里只剩「这条 INSERT 需要什么」的前置条件
requireOrganizationForNewVehicle(driver);
TransportVehicle vehicle = new TransportVehicle();
vehicle.setPlateNo(normalizedPlate);
// 所属组织取司机档案:车辆表的该列 NOT NULL 且无默认值,不填这条 INSERT 直接报
// 「Field 'organization_name' doesn't have a default value」(严格模式下必错)。
// 车辆归到司机所属组织名下,管理端按组织查看时才看得到这台车。
vehicle.setOrganizationName(driver.getOrganizationName());
vehicle.setOwnerName(request.getOwnerName());
vehicle.setPlateColor(request.getPlateColor());
vehicle.setVehicleType(request.getVehicleType());
vehicle.setVin(request.getVin());
vehicle.setOuterLength(request.getOuterLength());
vehicle.setOuterWidth(request.getOuterWidth());
vehicle.setOuterHeight(request.getOuterHeight());
vehicle.setApprovedLoadKg(request.getApprovedLoadKg());
vehicle.setTractionMassKg(request.getTractionMassKg());
vehicle.setEnergyType(request.getEnergyType());
vehicle.setBusinessRelation(request.getBusinessRelation());
vehicle.setCompulsoryScrapDate(request.getCompulsoryScrapDate());
vehicle.setCompulsoryScrapLongTerm(request.getCompulsoryScrapLongTerm());
// 行驶证四张图与车辆表四列一一对应,不再把「主页反面」塞进语义含糊的「副页」字段
vehicle.setDrivingLicenseNo(request.getDrivingLicenseNo());
vehicle.setDrivingLicenseImage(request.getDrivingLicenseImage());
vehicle.setDrivingLicenseMainBack(request.getDrivingLicenseMainBack());
vehicle.setDrivingLicenseViceFront(request.getDrivingLicenseViceFront());
vehicle.setDrivingLicenseViceBack(request.getDrivingLicenseViceBack());
vehicle.setDrivingLicenseStartDate(request.getDrivingLicenseStartDate());
vehicle.setDrivingLicenseEndDate(request.getDrivingLicenseEndDate());
vehicle.setDrivingLicenseLongTerm(request.getDrivingLicenseLongTerm());
vehicle.setRoadTransportCertNo(request.getRoadTransportCertNo());
vehicle.setRoadTransportCertStartDate(request.getRoadTransportCertStartDate());
vehicle.setRoadTransportCertEndDate(request.getRoadTransportCertEndDate());
vehicle.setRoadTransportCertLongTerm(request.getRoadTransportCertLongTerm());
vehicle.setRoadTransportCertImage(request.getRoadTransportCertImage());
// 机动车登记编号该列同样 NOT NULL:司机端是选填,缺值写空串而不是 null
vehicle.setRegistrationNo(Func.toStr(request.getRegistrationNo(), ""));
vehicle.setRegistrationImage(request.getRegistrationImage());
vehicle.setStatus(BladeConstant.DB_STATUS_NORMAL);
transportVehicleService.save(vehicle);
// 绑定到司机名下,否则本人后续仍无法操作这辆车
String drivingVehicle = Func.isEmpty(driver.getDrivingVehicle())
? normalizedPlate
: driver.getDrivingVehicle() + "," + normalizedPlate;
// 「最多 5 辆 + 车牌格式」在写入前校验:字段只有 varchar(100),
// 超限内容一旦写进去要么报错、要么被静默截断成半个车牌(还会被当成一辆车)
VehiclePlateSupport.validateBoundVehicles(drivingVehicle);
Driver patch = new Driver();
patch.setId(driver.getId());
patch.setDrivingVehicle(drivingVehicle);
driverService.updateById(patch);
driver.setDrivingVehicle(drivingVehicle);
return vehicle;
}
/**
* 校验当前登录用户能否查看该认证对象的记录
* <p>
* 司机只能查自己的;车辆必须是本人绑定的。越权一律拒绝,不返回空列表
* ——空列表会让调用方以为「没有记录」,而不是「无权查看」。
*/
private void requireReadableTarget(String bizType, Long bizId) {
if (Func.isEmpty(bizId)) {
throw new ServiceException("认证对象主键不能为空");
}
Driver driver = requireCurrentDriver();
if (TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)) {
if (!bizId.equals(driver.getId())) {
throw new ServiceException("无权查看其他司机的认证记录");
}
return;
}
TransportVehicle vehicle = transportVehicleService.getById(bizId);
if (vehicle == null) {
throw new ServiceException("车辆不存在");
}
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权查看该车辆的认证记录");
}
}
private void requireValidBizType(String bizType) {
if (!CertificationRuleSupport.isValidBizType(bizType)) {
throw new ServiceException("认证对象类型不正确");
}
}
private Driver requireCurrentDriver() {
Driver driver = driverAppService.currentDriverEntity();
if (driver == null) {
throw new ServiceException("未找到当前登录用户对应的司机档案");
}
return driver;
}
}
@@ -44,12 +44,12 @@ import org.springblade.system.pojo.enums.UserSex;
import org.springblade.system.pojo.enums.UserType;
import org.springblade.transport.excel.DriverExcel;
import org.springblade.transport.mapper.DriverMapper;
import org.springblade.transport.mapper.TransportVehicleMapper;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.DriverExpiryStatVO;
import org.springblade.transport.pojo.vo.DriverVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -69,14 +69,13 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
private static final int NAME_MAX_LENGTH = 10;
private static final int ID_CARD_MAX_LENGTH = 18;
private static final int MOBILE_MAX_LENGTH = 20;
private static final int VEHICLE_NO_MAX_LENGTH = 30;
private static final int VEHICLE_NO_MAX_LENGTH = 100;
private static final int SHORT_TEXT_MAX_LENGTH = 50;
private static final int ADDRESS_MAX_LENGTH = 200;
private static final int REMARK_MAX_LENGTH = 200;
private static final int DEFAULT_ENABLED_STATUS = 1;
private static final int DEFAULT_FALSE = 0;
private final TransportVehicleMapper transportVehicleMapper;
private final IUserClient userClient;
private final ISysClient sysClient;
@@ -255,15 +254,11 @@ public class DriverServiceImpl extends BaseServiceImpl<DriverMapper, Driver> imp
validateLength(driver.getIdCardNo(), ID_CARD_MAX_LENGTH, "身份证号不能超过18字");
validateLength(driver.getMobile(), MOBILE_MAX_LENGTH, "手机号不能超过20字");
validateLength(driver.getEmergencyContactMobile(), MOBILE_MAX_LENGTH, "紧急联系人手机号不能超过20字");
validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过30字");
if (Func.isNotEmpty(driver.getDrivingVehicle())) {
Long vehicleCount = transportVehicleMapper.selectCount(Wrappers.<TransportVehicle>lambdaQuery()
.eq(TransportVehicle::getIsDeleted, 0)
.eq(TransportVehicle::getPlateNo, driver.getDrivingVehicle()));
if (vehicleCount == null || vehicleCount <= 0) {
throw new ServiceException("驾驶车辆不存在,请重新选择");
}
}
validateLength(driver.getDrivingVehicle(), VEHICLE_NO_MAX_LENGTH, "驾驶车辆不能超过100字");
// 「驾驶车辆」是**逗号并列的多辆车**(小程序每绑一辆就追加一个车牌),
// 不是一个车牌。此前这里拿整串去车牌表精确查,绑了两辆车的司机
// 在司机管理页一保存就会撞上「驾驶车辆不存在」——校验口径必须与写入方一致。
VehiclePlateSupport.validateBoundVehicles(driver.getDrivingVehicle());
validateLength(driver.getDrivingLicenseNo(), SHORT_TEXT_MAX_LENGTH, "驾驶证档案编号不能超过50字");
validateLength(driver.getQualificationNo(), SHORT_TEXT_MAX_LENGTH, "资格证号不能超过50字");
validateLength(driver.getOrganizationName(), SHORT_TEXT_MAX_LENGTH, "所属组织不能超过50字");
@@ -31,12 +31,17 @@ import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.secure.utils.AuthUtil;
import org.springblade.core.tool.jackson.JsonUtil;
import org.springblade.core.tool.utils.DateUtil;
import org.springblade.core.tool.constant.BladeConstant;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
import org.springblade.transport.mapper.WaybillNodePunchMapper;
import org.springblade.transport.pojo.dto.EnrouteSubmitDTO;
import org.springblade.transport.pojo.dto.NodeSubmitDTO;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.pojo.entity.ProcessConfig;
import org.springblade.transport.pojo.entity.Waybill;
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
@@ -68,12 +73,10 @@ import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
/**
@@ -110,6 +113,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final IWaybillService waybillService;
private final IDriverService driverService;
private final IProcessConfigService processConfigService;
private final ITransportVehicleService transportVehicleService;
private final CertificationGate certificationGate;
private final WaybillEnroutePunchMapper enroutePunchMapper;
private final WaybillNodePunchMapper nodePunchMapper;
@@ -355,6 +360,9 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
@Transactional(rollbackFor = Exception.class)
public boolean accept(Long id) {
Driver driver = requireCurrentDriver();
// 认证门槛:司机本人须已认证,且承运车辆须已认证。
// 判据是各自持有的生效快照指针,故「已认证的车重新提交认证」不会拦下接单。
assertCertified(driver);
Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver));
waybillService.syncDriverAcceptState(waybill);
assertAcceptable(waybill);
@@ -368,6 +376,35 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
return waybillService.updateById(waybill);
}
/**
* 接单前的认证门槛校验
* <p>
* 司机的生效快照由其档案持有;车辆按司机绑定的车牌取。
* 任一不满足即拒绝接单,并给出可读原因。
*
* @param driver 当前司机
*/
private void assertCertified(Driver driver) {
if (!certificationGate.isDriverAvailable(driver.getEffectiveCertRecordId())) {
throw new ServiceException(certificationGate.driverUnavailableReason());
}
if (currentBoundPlates(driver).isEmpty()) {
return;
}
// 用共享的车牌匹配条件:只做精确 IN 会漏掉库中带间隔符/大小写差异的车牌,
// 那会把已认证的司机误判为未认证而拒绝接单
LambdaQueryWrapper<TransportVehicle> plateQuery = Wrappers.lambdaQuery();
VehiclePlateSupport.applyPlateMatch(plateQuery, currentBoundPlates(driver));
List<TransportVehicle> boundVehicles = transportVehicleService.list(plateQuery);
boolean anyCertified = boundVehicles.stream().anyMatch(vehicle ->
certificationGate.isVehicleAvailable(
vehicle.getStatus() != null && vehicle.getStatus() == BladeConstant.DB_STATUS_NORMAL,
vehicle.getEffectiveCertRecordId()));
if (!anyCertified) {
throw new ServiceException(certificationGate.vehicleUnavailableReason());
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean reject(Long id, String reason) {
@@ -563,26 +600,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
}
private List<String> splitPlates(String drivingVehicle) {
String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",")
.replace(";", ",").replace("/", ",").replace("|", ",");
Set<String> plates = new LinkedHashSet<>();
for (String part : Func.toStrList(",", normalized)) {
if (Func.isEmpty(part)) {
continue;
}
String plate = normalizePlate(part);
if (Func.isNotEmpty(plate)) {
plates.add(plate);
}
}
return new ArrayList<>(plates);
}
private String normalizePlate(String plateNo) {
if (Func.isEmpty(plateNo)) {
return "";
}
return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT);
return VehiclePlateSupport.splitPlates(drivingVehicle);
}
private int normalizePreviewSize(Integer size) {
@@ -0,0 +1,187 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.mp.base.BaseServiceImpl;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.mapper.TransportCertRecordMapper;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.ITransportCertRecordService;
import org.springblade.transport.wrapper.TransportCertRecordWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 认证记录 服务实现类
*
* @author Chill
*/
@Service
public class TransportCertRecordServiceImpl extends BaseServiceImpl<TransportCertRecordMapper, TransportCertRecord> implements ITransportCertRecordService {
/**
* 合法的审核状态取值
*/
private static final Set<Integer> VALID_STATUS = Set.of(
TransportCertRecord.STATUS_PENDING,
TransportCertRecord.STATUS_APPROVED,
TransportCertRecord.STATUS_REJECTED,
TransportCertRecord.STATUS_SUPERSEDED
);
@Override
@Transactional(rollbackFor = Exception.class)
public boolean save(TransportCertRecord transportCertRecord) {
validateRecord(transportCertRecord);
return super.save(transportCertRecord);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveBatch(Collection<TransportCertRecord> entityList, int batchSize) {
entityList.forEach(this::validateRecord);
return super.saveBatch(entityList, batchSize);
}
@Override
@Transactional(rollbackFor = Exception.class)
public boolean saveOrUpdateBatch(Collection<TransportCertRecord> entityList, int batchSize) {
entityList.forEach(this::validateRecord);
return super.saveOrUpdateBatch(entityList, batchSize);
}
/**
* 禁用继承自 {@code BaseService} 的「修改状态」
* <p>
* ⚠️ 本表的 {@code status} 是<b>审核状态</b>,不是「启用 / 停用」。
* 而 {@code BaseServiceImpl#changeStatus} 会绕过 {@link #validateRecord} 直接写 status,
* 调用 {@code changeStatus(ids, 1)} 即等于<b>批量通过审核</b>——
* 审核人、审核时间、驳回原因全部缺失,既丢失审核留痕,也会凭空造出生效快照。
* 审核必须逐条走带留痕的审核方法。
*
* @param ids 主键集合
* @param status 状态
* @return 永不返回,直接抛出
*/
@Override
public boolean changeStatus(List<Long> ids, Integer status) {
throw new ServiceException("认证记录不支持直接修改状态,请使用审核接口");
}
@Override
public List<TransportCertRecordVO> selectRecordList(String bizType, Long bizId) {
return list(recordQuery(bizType, bizId)).stream()
.map(TransportCertRecordWrapper.build()::entityVO)
.collect(Collectors.toList());
}
@Override
public TransportCertRecord selectPendingRecord(String bizType, Long bizId) {
validateBizTarget(bizType, bizId);
return getOne(Wrappers.<TransportCertRecord>lambdaQuery()
.eq(TransportCertRecord::getBizType, bizType)
.eq(TransportCertRecord::getBizId, bizId)
.eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING)
.last("LIMIT 1"));
}
/**
* 认证对象检索条件:按提交时间倒序
* <p>
* 排序用 {@code submit_time} 而非框架默认的 {@code create_time},是刻意为之:
* 认证记录的业务时间轴是<b>提交时间</b>,列表需按提交时间倒序展示每次提交。
*
* @param bizType 认证对象类型
* @param bizId 认证对象主键
* @return 检索条件
*/
private LambdaQueryWrapper<TransportCertRecord> recordQuery(String bizType, Long bizId) {
validateBizTarget(bizType, bizId);
return Wrappers.<TransportCertRecord>lambdaQuery()
.eq(TransportCertRecord::getBizType, bizType)
.eq(TransportCertRecord::getBizId, bizId)
.orderByDesc(TransportCertRecord::getSubmitTime);
}
/**
* 认证对象标识校验
*
* @param bizType 认证对象类型
* @param bizId 认证对象主键
*/
private void validateBizTarget(String bizType, Long bizId) {
if (Func.isEmpty(bizType) || Func.isEmpty(bizId)) {
throw new ServiceException("认证对象类型与主键不能为空");
}
}
/**
* 记录落库前的强制校验
* <p>
* ⚠️ 这里防的是框架的一个<b>静默</b>行为:{@code BaseServiceImpl#resolveEntity} 在插入时
* 若 {@code status} 为 {@code null},会填充 {@code BladeConstant.DB_STATUS_NORMAL}(值 1),
* 而 1 在本表恰好是 {@link TransportCertRecord#STATUS_APPROVED}。
* 那会让一条「忘了设状态」的提交凭空变成「已通过」,进而可能被当成生效快照。
* 故此处对空状态与非法取值直接拒绝,不依赖调用方自觉。
*
* @param transportCertRecord 待落库的认证记录
*/
private void validateRecord(TransportCertRecord transportCertRecord) {
if (Func.isEmpty(transportCertRecord)) {
throw new ServiceException("认证记录不能为空");
}
if (Func.isEmpty(transportCertRecord.getBizType())) {
throw new ServiceException("认证对象类型不能为空");
}
if (Func.isEmpty(transportCertRecord.getBizId())) {
throw new ServiceException("认证对象主键不能为空");
}
if (Func.isEmpty(transportCertRecord.getSnapshotJson())) {
throw new ServiceException("认证内容快照不能为空");
}
Integer status = transportCertRecord.getStatus();
if (Func.isEmpty(status)) {
throw new ServiceException("认证记录状态不能为空,不允许依赖框架默认值(默认值 1 会被误判为已通过)");
}
if (!VALID_STATUS.contains(status)) {
throw new ServiceException("认证记录状态取值不正确");
}
if (TransportCertRecord.STATUS_REJECTED.equals(status) && Func.isEmpty(transportCertRecord.getRejectReason())) {
throw new ServiceException("请输入认证驳回原因");
}
}
}
@@ -42,6 +42,7 @@ import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.pojo.vo.TransportVehicleExpiryStatVO;
import org.springblade.transport.pojo.vo.TransportVehicleVO;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -51,7 +52,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
@@ -63,7 +63,6 @@ import java.util.stream.Collectors;
@RequiredArgsConstructor
public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicleMapper, TransportVehicle> implements ITransportVehicleService {
private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$");
private static final String PLATE_COLOR_BLUE = "蓝牌";
private static final String PLATE_COLOR_YELLOW = "黄牌";
private static final String PLATE_COLOR_GREEN = "绿牌";
@@ -266,7 +265,7 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
if (Func.isEmpty(vehicle.getPlateNo())) {
throw new ServiceException("车牌号不能为空");
}
if (!PLATE_NO_PATTERN.matcher(vehicle.getPlateNo()).matches()) {
if (!VehiclePlateSupport.isValidPlateNo(vehicle.getPlateNo())) {
throw new ServiceException("车牌号格式不正确");
}
if (Func.isEmpty(vehicle.getPlateColor())) {
@@ -0,0 +1,62 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
/**
* 认证审核的权限表达式
* <p>
* 用菜单码而不是角色名。本项目的权限走「角色-菜单」授权,写死角色名有两个坑:
* 角色别名可能对不上(曾写成 {@code admin},而库里只有 {@code administrator}),
* 以及会漏掉实际承担审核的岗位(例如「运管」)。
* <p>
* 用表达式而不是 {@code @PreAuth(menu = "...")} 的单值形式,是因为同一组接口
* 由 {@code bizType} 参数区分、同时服务司机与车辆两侧,单值 menu 表达不了「二者之一」。
* <p>
* 表达式最终由框架交给 SpEL 求值,root object 是 {@code AuthFun},
* 故这里用的函数名必须与它的公开方法一致({@code hasMenu})。
* {@code CertAuditPermissionsTest} 会实际解析并求值这两个表达式,
* 一旦写错函数名或括号,测试即失败——而不是等到线上请求时抛 500。
*
* @author Chill
*/
public final class CertAuditPermissions {
/**
* 待审列表与审核详情:司机或车辆任一审核菜单即可
*/
public static final String AUDIT_VIEW =
"hasMenu('cert_certification_audit_driver') or hasMenu('cert_certification_audit_vehicle')";
/**
* 审核动作:司机或车辆任一「审核」按钮权限即可
*/
public static final String AUDIT_ACTION =
"hasMenu('cert_certification_audit_driver_audit') or hasMenu('cert_certification_audit_vehicle_audit')";
private CertAuditPermissions() {
}
}
@@ -0,0 +1,124 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import lombok.Getter;
import lombok.Setter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/**
* 认证可用性门槛
* <p>
* 在原有的运营状态(启用 / 停用)之上**叠加**一层「是否已认证」:
* <ul>
* <li>车辆:运营状态为启用,且存在生效快照;</li>
* <li>司机:存在生效快照。</li>
* </ul>
* <p>
* ⚠️ 判据是**生效快照指针**,不是「最新提交状态」展示缓存。
* 这一区别是本改造的核心:司机重新提交认证会把展示缓存变成「审核中」,
* 若照它判定,司机一提交认证就立刻接不了单——正是要避免的事。
* 生效快照只在审核通过时才切换,故「曾认证通过即持续可用」。
* <p>
* 两个开关默认开启,可通过配置关闭以便线上快速回退到改造前的行为:
* <pre>
* transport:
* certification:
* vehicle-gate-enabled: false
* driver-gate-enabled: false
* </pre>
*
* @author Chill
*/
@Component
@Getter
@Setter
public class CertificationGate {
/**
* 车辆认证门槛开关
*/
@Value("${transport.certification.vehicle-gate-enabled:true}")
private boolean vehicleGateEnabled = true;
/**
* 司机认证门槛开关
*/
@Value("${transport.certification.driver-gate-enabled:true}")
private boolean driverGateEnabled = true;
/**
* 判定车辆是否可用
* <p>
* 运营状态优先:停用的车即便已认证也不可用(认证门槛只叠加,不覆盖运营状态)。
*
* @param enabledState 运营状态:TRUE 启用
* @param effectiveRecordId 生效快照指针
* @return 是否可用
*/
public boolean isVehicleAvailable(Boolean enabledState, Long effectiveRecordId) {
if (!Boolean.TRUE.equals(enabledState)) {
return false;
}
if (!vehicleGateEnabled) {
return true;
}
return CertificationRuleSupport.isCertified(effectiveRecordId);
}
/**
* 判定司机是否可接单
*
* @param effectiveRecordId 生效快照指针
* @return 是否可用
*/
public boolean isDriverAvailable(Long effectiveRecordId) {
if (!driverGateEnabled) {
return true;
}
return CertificationRuleSupport.isCertified(effectiveRecordId);
}
/**
* 车辆不可用的提示文案
*
* @return 面向司机的中文提示
*/
public String vehicleUnavailableReason() {
return "该车辆尚未通过认证审核,暂不可接单,请先提交认证";
}
/**
* 司机不可用的提示文案
*
* @return 面向司机的中文提示
*/
public String driverUnavailableReason() {
return "您尚未通过认证审核,暂不可接单,请先完成认证";
}
}
@@ -0,0 +1,180 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.TransportCertRecord;
/**
* 认证记录状态机规则
* <p>
* 把「什么状态能做什么」集中在一处,与持久化分离,便于单独验证。
* 这几条规则是整个认证改造的地基,改坏的后果是线上事故:
* 司机重新提交认证后车辆掉出可用池,或同一辆车出现两条待审记录。
*
* @author Chill
*/
public final class CertificationRuleSupport {
private static final String STATUS_TEXT_PENDING = "审核中";
private static final String STATUS_TEXT_APPROVED = "已通过";
private static final String STATUS_TEXT_REJECTED = "已驳回";
private static final String STATUS_TEXT_SUPERSEDED = "已作废";
private static final String STATUS_TEXT_UNKNOWN = "未知状态";
private CertificationRuleSupport() {
}
/**
* 把一条新提交的记录准备成待审
* <p>
* 必须清空驳回原因:新提交是全新一次认证,不能继承上一次的驳回文案,
* 否则前端会显示「审核中」却带着旧的驳回原因。
*
* @param incoming 待入库的记录
*/
public static void prepareForSubmit(TransportCertRecord incoming) {
incoming.setStatus(TransportCertRecord.STATUS_PENDING);
incoming.setRejectReason(null);
}
/**
* 再次提交时,把该认证对象**前一条待审**记录转为已作废
* <p>
* 只动待审记录:已通过、已驳回、已作废都是终态,一律不碰。
* 特别是**不得改动已通过的记录**——它可能正被生效快照指针指着。
*
* @param previousPending 该对象当前那条待审记录,可为 null
* @return 需要更新为已作废的记录;无需更新时返回 null
*/
public static TransportCertRecord supersedeIfPending(TransportCertRecord previousPending) {
if (previousPending == null) {
return null;
}
if (!TransportCertRecord.STATUS_PENDING.equals(previousPending.getStatus())) {
return null;
}
previousPending.setStatus(TransportCertRecord.STATUS_SUPERSEDED);
return previousPending;
}
/**
* 判定认证对象是否已认证
* <p>
* 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」,
* 重新提交、待审、被驳回都不会让对象变回未认证。
*
* @param effectiveRecordId 生效快照指针
* @return 是否已认证
*/
public static boolean isCertified(Long effectiveRecordId) {
return Func.isNotEmpty(effectiveRecordId);
}
/**
* 判定一条记录是否可被审核
* <p>
* 只有待审记录可审。已通过/已驳回/已作废都是终态,再次审核会把生效快照改坏
* (例如把指针指着的那条「已通过」改成「已驳回」,生效值就没了)。
*
* @param record 认证记录
* @return 是否可审核
*/
public static boolean isAuditable(TransportCertRecord record) {
return record != null && TransportCertRecord.STATUS_PENDING.equals(record.getStatus());
}
/**
* 校验审核入参是否合法
*
* @param pass 是否通过
* @param rejectReason 驳回原因
* @return 是否合法
*/
public static boolean isRejectReasonValid(boolean pass, String rejectReason) {
if (pass) {
return true;
}
String reason = Func.toStr(rejectReason, "").trim();
return Func.isNotEmpty(reason)
&& reason.length() <= TransportCertRecord.REJECT_REASON_MAX_LENGTH;
}
/**
* 审核通过时的驳回原因:必须为 null,避免残留上一次的驳回文案
*
* @return 恒为 null
*/
public static String reasonForApprove() {
return null;
}
/**
* 审核驳回时的驳回原因:去首尾空白后落库
*
* @param rejectReason 原始原因
* @return 去空白后的原因
*/
public static String reasonForReject(String rejectReason) {
return Func.toStr(rejectReason, "").trim();
}
/**
* 状态对应的中文文案
* <p>
* 四端(小程序、Web、接口、库注释)必须用同一套说法,不要各造一套。
*
* @param status 状态值
* @return 中文文案
*/
public static String statusText(Integer status) {
if (TransportCertRecord.STATUS_PENDING.equals(status)) {
return STATUS_TEXT_PENDING;
}
if (TransportCertRecord.STATUS_APPROVED.equals(status)) {
return STATUS_TEXT_APPROVED;
}
if (TransportCertRecord.STATUS_REJECTED.equals(status)) {
return STATUS_TEXT_REJECTED;
}
if (TransportCertRecord.STATUS_SUPERSEDED.equals(status)) {
return STATUS_TEXT_SUPERSEDED;
}
return STATUS_TEXT_UNKNOWN;
}
/**
* 校验认证对象类型
*
* @param bizType 类型
* @return 是否为合法类型
*/
public static boolean isValidBizType(String bizType) {
return TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)
|| TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType);
}
}
@@ -0,0 +1,403 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import com.fasterxml.jackson.databind.JsonNode;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 把百度 OCR 的原始结果归一化成业务字段
* <p>
* 百度返回的 {@code words_result} 键是**中文**(「号牌号码」「车辆识别代码」…),
* 同一含义在纸质证与电子证照之间还有不同写法。让小程序去认识这些中文键,
* 等于把百度接口的形状泄漏到前端,接口一改就要动多个端。
* <p>
* 归一化后下发的键与业务字段同名({@code plateNo}、{@code ownerName}…),前端拿到即可回填;
* 原始结果仍一并下发,归一化没覆盖到的字段不至于丢失。
* <p>
* <b>必须做别名容错的原因</b>:官方文档列举的是「号牌号码」「车辆识别代码」,
* 而实际返回里也出现过「车牌号码」「车辆识别代号」这类写法,电子行驶证还多出
* 「强制报废期止」「能源种类」等字段。写死单个键名的后果是**识别成功但什么也没填**——
* 不报错、不抛异常,最难排查。
*
* @author Chill
*/
public final class OcrFieldSupport {
/**
* {@code words_result} 里每个条目形如 {@code {"words": "桂A12345"}}
*/
private static final String WORDS = "words";
/**
* 表示「长期有效」的词:命中后不产出日期,而把对应的长期有效标志置 1
*/
private static final String LONG_TERM_TEXT = "长期";
/**
* 日期写法:4 位年 + 1~2 位月 + 1~2 位日,中间允许任意非数字分隔(年月日 / 点 / 横线)
*/
private static final Pattern DATE_PATTERN = Pattern.compile("(\\d{4})\\D+(\\d{1,2})\\D+(\\d{1,2})");
private OcrFieldSupport() {
}
/**
* 按证件类型把原始结果归一化
*
* @param type 证件类型
* @param side 证件面:front / back(目前两个面共用一套字段别名)
* @param result 百度原始返回
* @return 归一化字段(键与业务字段同名);识别不到任何字段时返回空 Map
*/
public static Map<String, Object> extract(BaiduOcrType type, String side, JsonNode result) {
Map<String, String> words = readWords(result);
Map<String, Object> fields = new LinkedHashMap<>();
if (words.isEmpty()) {
return fields;
}
for (Map.Entry<String, List<String>> entry : aliasTable(type).entrySet()) {
String raw = firstMatch(words, entry.getValue());
if (Func.isEmpty(raw)) {
continue;
}
putField(fields, entry.getKey(), raw);
}
// 外廓尺寸是唯一「一项要拆成三个字段」的,单独处理
splitOuterSize(words, fields);
// 道路运输证的「有效期」常写成一个区间:止期已由上面取到最后一段,
// 起期要从同一段文本里补,否则用户得照着一张图再敲一遍
splitRangeStart(words, fields, "有效期", "roadTransportCertStartDate");
return fields;
}
/**
* 从「有效期」这类区间文本里取出起期,补进对应的起期字段
*
* @param words 识别结果
* @param fields 结果集
* @param alias 区间所在的键名
* @param startKey 要补的起期字段名
*/
private static void splitRangeStart(Map<String, String> words, Map<String, Object> fields,
String alias, String startKey) {
if (fields.containsKey(startKey)) {
return;
}
String text = firstMatch(words, List.of(alias));
if (Func.isEmpty(text)) {
return;
}
List<String> dates = matchDates(text);
// 至少两段才算区间:只有一段说明那是止期本身,硬当起期会填错
if (dates.size() >= 2) {
fields.put(startKey, dates.get(0));
}
}
/**
* 读取 {@code words_result} 成「中文键 → 文本值」
*
* @param result 百度原始返回
* @return 键值对;结构不符时返回空 Map
*/
private static Map<String, String> readWords(JsonNode result) {
Map<String, String> words = new LinkedHashMap<>();
if (result == null) {
return words;
}
JsonNode wordsResult = result.path("words_result");
if (!wordsResult.isObject()) {
return words;
}
wordsResult.fields().forEachRemaining(entry -> {
JsonNode value = entry.getValue();
if (value == null || value.isNull()) {
return;
}
// 可能是 {"words": "..."},也可能直接是字符串
String text = value.isObject() ? value.path(WORDS).asText("") : value.asText("");
if (Func.isNotEmpty(text)) {
words.put(entry.getKey(), text.trim());
}
});
return words;
}
/**
* 取第一个命中的别名值
*
* @param words 识别结果
* @param aliases 候选键名,按优先级排列
* @return 命中值;都没命中返回 null
*/
private static String firstMatch(Map<String, String> words, List<String> aliases) {
for (String alias : aliases) {
String value = words.get(alias);
if (Func.isNotEmpty(value)) {
return value;
}
}
// 精确键名没命中时按「包含」兜底:百度偶有「核定载质量(kg)」这类带后缀的键
for (String alias : aliases) {
for (Map.Entry<String, String> entry : words.entrySet()) {
if (entry.getKey().contains(alias) && Func.isNotEmpty(entry.getValue())) {
return entry.getValue();
}
}
}
return null;
}
/**
* 写入一个字段,按字段语义做值归一化
* <p>
* 数字字段转数字、日期字段转 {@code yyyy-MM-dd};「长期」不产出日期,
* 而是把对应的长期有效标志置 1——否则「长期」会被当成一个非法日期塞进日期框。
*
* @param fields 结果集
* @param key 业务字段名
* @param raw 识别到的原始文本
*/
private static void putField(Map<String, Object> fields, String key, String raw) {
String text = raw.trim();
if (isDateField(key)) {
if (text.contains(LONG_TERM_TEXT)) {
String longTermKey = longTermKeyOf(key);
if (longTermKey != null) {
fields.put(longTermKey, 1);
}
return;
}
String date = normalizeDate(text, key);
if (date != null) {
fields.put(key, date);
}
return;
}
if (isNumberField(key)) {
Integer number = normalizeNumber(text);
if (number != null) {
fields.put(key, number);
}
return;
}
fields.put(key, text);
}
/**
* 「外廓尺寸」一项拆成外廓长/宽/高三个字段
* <p>
* 形如 {@code 5990×2300×3100},分隔符有 ×、x、*、空格等多种写法。
*
* @param words 识别结果
* @param fields 结果集
*/
private static void splitOuterSize(Map<String, String> words, Map<String, Object> fields) {
String size = firstMatch(words, List.of("外廓尺寸"));
if (Func.isEmpty(size)) {
return;
}
List<String> parts = new ArrayList<>();
for (String part : size.split("[^0-9]+")) {
if (Func.isNotEmpty(part)) {
parts.add(part);
}
}
// 必须正好三段才写回:只识别到两段时多半是漏识别,
// 按顺序硬填会把宽度填成长度、高度填成宽度
if (parts.size() != 3) {
return;
}
String[] keys = {"outerLength", "outerWidth", "outerHeight"};
for (int index = 0; index < keys.length; index++) {
Integer number = normalizeNumber(parts.get(index));
if (number != null) {
fields.put(keys[index], number);
}
}
}
/**
* 各证件类型的字段别名表
*
* @param type 证件类型
* @return 「业务字段名 → 百度键名候选」
*/
private static Map<String, List<String>> aliasTable(BaiduOcrType type) {
if (type == null) {
return Map.of();
}
String name = type.name();
if (BaiduOcrType.ID_CARD.name().equals(name)) {
return idCardAliases();
}
if (BaiduOcrType.DRIVING_LICENSE.name().equals(name)) {
return drivingLicenseAliases();
}
if (BaiduOcrType.VEHICLE_LICENSE.name().equals(name)) {
return vehicleLicenseAliases();
}
if (BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE.name().equals(name)) {
return roadTransportAliases();
}
return Map.of();
}
private static Map<String, List<String>> idCardAliases() {
Map<String, List<String>> aliases = new LinkedHashMap<>();
aliases.put("realName", List.of("姓名"));
aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码"));
aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起"));
aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止"));
return aliases;
}
private static Map<String, List<String>> drivingLicenseAliases() {
Map<String, List<String>> aliases = new LinkedHashMap<>();
aliases.put("driverFileNo", List.of("档案编号", "证号"));
aliases.put("driverClass", List.of("准驾车型"));
aliases.put("driverValidStart", List.of("有效起始日期", "有效期起", "初次领证日期"));
aliases.put("driverValidEnd", List.of("有效期限", "有效期止", "有效期限止"));
return aliases;
}
private static Map<String, List<String>> vehicleLicenseAliases() {
Map<String, List<String>> aliases = new LinkedHashMap<>();
aliases.put("plateNo", List.of("号牌号码", "车牌号码", "号牌"));
aliases.put("vehicleType", List.of("车辆类型"));
aliases.put("ownerName", List.of("所有人"));
aliases.put("vin", List.of("车辆识别代码", "车辆识别代号", "车架号", "VIN"));
aliases.put("drivingLicenseNo", List.of("档案编号", "行驶证档案编号"));
aliases.put("plateColor", List.of("号牌颜色", "车牌颜色"));
aliases.put("energyType", List.of("能源种类", "能源类型"));
aliases.put("approvedLoadKg", List.of("核定载质量"));
aliases.put("tractionMassKg", List.of("准牵引总质量"));
aliases.put("compulsoryScrapDate", List.of("强制报废期止", "强制报废日期"));
aliases.put("drivingLicenseStartDate", List.of("注册日期", "发证日期"));
aliases.put("drivingLicenseEndDate", List.of("检验有效期止", "有效期止"));
return aliases;
}
private static Map<String, List<String>> roadTransportAliases() {
Map<String, List<String>> aliases = new LinkedHashMap<>();
aliases.put("roadTransportCertNo", List.of("道路运输证号", "运输证号"));
aliases.put("ownerName", List.of("业户名称", "所有人"));
aliases.put("roadTransportCertEndDate", List.of("有效期", "有效期止", "有效期至"));
return aliases;
}
private static boolean isDateField(String key) {
return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
}
private static boolean isNumberField(String key) {
return "approvedLoadKg".equals(key) || "tractionMassKg".equals(key);
}
private static String longTermKeyOf(String dateKey) {
if ("compulsoryScrapDate".equals(dateKey)) {
return "compulsoryScrapLongTerm";
}
if ("drivingLicenseEndDate".equals(dateKey)) {
return "drivingLicenseLongTerm";
}
if ("roadTransportCertEndDate".equals(dateKey)) {
return "roadTransportCertLongTerm";
}
return null;
}
/**
* 数字归一化:去掉「kg」「毫米」等单位与千分位后取整
*
* @param text 识别文本
* @return 整数;取不到合理数字返回 null
*/
private static Integer normalizeNumber(String text) {
String digits = text.replaceAll("[^0-9]", "");
// 载质量、外廓尺寸最多 6 位;过长说明识别串了字段,宁可不填也不填错
if (digits.isEmpty() || digits.length() > 6) {
return null;
}
return Integer.valueOf(digits);
}
/**
* 日期归一化
* <p>
* 覆盖 {@code 2015年03月12日} / {@code 2015.3.9} / {@code 2015-03-12} / {@code 20150312}
* 这几种写法,统一成 {@code yyyy-MM-dd}。
* <p>
* 区间(如「2015.03.12-2035.03.12」「2020-01-01 至 2030-01-01」)**按字段语义取一端**:
* 以 Start 结尾的取第一段,其余(止期类)取最后一段。取错一端不会报错,
* 只会把有效期起填成止——比不填更难发现。
*
* @param text 识别文本
* @param key 业务字段名,用于判断该取区间的哪一端
* @return 归一化日期;无法识别返回 null
*/
private static String normalizeDate(String text, String key) {
List<String> dates = matchDates(text);
if (!dates.isEmpty()) {
return key.endsWith("Start") ? dates.get(0) : dates.get(dates.size() - 1);
}
String compact = text.replaceAll("[^0-9]", "");
if (compact.length() == 8) {
return formatDate(compact);
}
return null;
}
/**
* 取出文本里所有日期(按出现顺序)
*
* @param text 识别文本
* @return 归一化后的日期列表
*/
private static List<String> matchDates(String text) {
List<String> dates = new ArrayList<>();
Matcher matcher = DATE_PATTERN.matcher(text);
while (matcher.find()) {
dates.add(String.format("%s-%02d-%02d", matcher.group(1),
Integer.parseInt(matcher.group(2)), Integer.parseInt(matcher.group(3))));
}
return dates;
}
private static String formatDate(String digits) {
return digits.substring(0, 4) + "-" + digits.substring(4, 6) + "-" + digits.substring(6, 8);
}
}
@@ -0,0 +1,148 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import org.springblade.transport.ocr.constant.BaiduOcrType;
import java.util.Locale;
import java.util.Map;
/**
* 证件类型映射:小程序命名 → 百度 OCR 证件类型
* <p>
* ⚠️ 两侧命名对「行驶证」和「驾驶证」的用词正好相反,是本类存在的主要理由:
* <table border="1">
* <caption>命名对照</caption>
* <tr><th>证件</th><th>小程序传</th><th>后端 OCR 用</th></tr>
* <tr><td>驾驶证</td><td>{@code driver}</td><td>{@code driving_license}</td></tr>
* <tr><td>行驶证</td><td>{@code vehicle}</td><td>{@code vehicle_license}</td></tr>
* </table>
* 若把 {@code vehicle} 直接透传给百度按 {@code driving_license} 处理,或反之,
* 调用方不会收到任何错误——拿到的是另一种证件的字段,问题只在人工核对时才发现。
* 故此处用显式白名单逐一对应,而不是把请求参数直接丢给下游。
* <p>
* 采用白名单而非「透传 + 兜底为通用识别」还有一个原因:
* 从业资格证没有对应的百度接口,静默降级成通用文字识别会让前端以为识别成功。
*
* @author Chill
*/
public final class OcrTypeMapping {
/**
* 允许的证件类型:小程序命名与后端既有命名都收,其余一律拒绝
*/
private static final Map<String, BaiduOcrType> TYPE_ALIASES = Map.ofEntries(
// 身份证
Map.entry("IDCARD", BaiduOcrType.ID_CARD),
Map.entry("ID_CARD", BaiduOcrType.ID_CARD),
// 驾驶证:小程序叫 driver
Map.entry("DRIVER", BaiduOcrType.DRIVING_LICENSE),
Map.entry("DRIVING_LICENSE", BaiduOcrType.DRIVING_LICENSE),
// 行驶证:小程序叫 vehicle
Map.entry("VEHICLE", BaiduOcrType.VEHICLE_LICENSE),
Map.entry("VEHICLE_LICENSE", BaiduOcrType.VEHICLE_LICENSE),
// 道路运输证
Map.entry("ROAD_TRANSPORT", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE),
Map.entry("ROAD_TRANSPORT_CERTIFICATE", BaiduOcrType.ROAD_TRANSPORT_CERTIFICATE)
);
/**
* 明确知道「没有对应 OCR 能力」的类型,单独给出可读提示
*/
private static final Map<String, String> KNOWN_UNSUPPORTED = Map.of(
"QUALIFICATION", "从业资格证"
);
private static final String SIDE_FRONT = "front";
private static final String SIDE_BACK = "back";
private OcrTypeMapping() {
}
/**
* 把调用方传来的证件类型转成百度 OCR 证件类型
*
* @param type 证件类型:小程序传 idcard / driver / vehicle / road_transport / qualification,
* 也接受后端既有命名 id_card / driving_license / vehicle_license / road_transport_certificate
* @return 百度 OCR 证件类型
* @throws IllegalArgumentException 类型为空、未知,或该证件不支持 OCR
*/
public static BaiduOcrType toBaiduType(String type) {
String normalized = normalize(type);
if (normalized.isEmpty()) {
throw new IllegalArgumentException("OCR证件类型不能为空");
}
BaiduOcrType baiduOcrType = TYPE_ALIASES.get(normalized);
if (baiduOcrType != null) {
return baiduOcrType;
}
String unsupportedName = KNOWN_UNSUPPORTED.get(normalized);
if (unsupportedName != null) {
// 不降级为通用识别:否则前端会拿到一堆通用文本却以为识别成功
throw new IllegalArgumentException(unsupportedName + "暂不支持自动识别,请手动填写");
}
throw new IllegalArgumentException("不支持的OCR证件类型:" + type);
}
/**
* 规范化正反面参数
* <p>
* 小程序不传该参数,按正面处理(身份证、行驶证、驾驶证的正页是常用场景)。
* <p>
* ⚠️ 调用方**必须原样把返回值交给下游,不要在传参前自行补默认值**:
* 这里的「默认正面」只对**支持正副面**的证件成立。道路运输证没有正副面参数,
* 若在调用方先把空值填成 {@code front} 再传下去,下游会看到「传了 side 但该证件
* 不支持」而直接报错——本方法返回 null 才是「调用方没指定」,下游据此才分得清
* 「默认取正面」和「这个证件根本不该带这个参数」。
*
* @param side 正反面:front / back
* @return 规范化结果 front / back;调用方未指定时返回 null
*/
public static String normalizeSide(String side) {
String normalized = normalize(side);
if (normalized.isEmpty()) {
return null;
}
return SIDE_BACK.equals(normalized.toLowerCase(Locale.ROOT)) ? SIDE_BACK : SIDE_FRONT;
}
/**
* 归一化:去首尾空白、去分隔符差异、统一大写
*
* @param value 原始值
* @return 归一化结果;空值返回空串
*/
private static String normalize(String value) {
if (value == null) {
return "";
}
return value.trim()
.replace('-', '_')
.replace(' ', '_')
.toUpperCase(Locale.ROOT);
}
}
@@ -0,0 +1,207 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.TransportVehicle;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 司机档案与车辆表之间的车牌软关联支持类
* <p>
* 司机「驾驶车辆」与车辆表「车牌号」之间**没有外键**,只有文本关联,
* 且库中实际存在写法差异:间隔符(·、•、.)、横线、空格、大小写不一致,
* 以及一个字段里用逗号/顿号/分号/斜杠/竖线并列多辆车。
* 认证提交时的「只能操作本人绑定车辆」校验依赖本类,因此匹配规则集中在此处维护,
* 避免各业务各自实现一套导致口径分叉。
*
* @author Chill
*/
public final class VehiclePlateSupport {
/**
* 并列车牌的常见分隔符(中英文顿号、逗号、分号、斜杠、竖线)
*/
private static final String SEPARATOR_PATTERN = "[,,、;;/|]";
/**
* 车牌中需要剔除的排版字符(各类空白、横线与间隔符)
*/
private static final String NOISE_PATTERN = "[\\s\\-·•..]";
private VehiclePlateSupport() {
}
/**
* 把「驾驶车辆」字段拆成规范化后的车牌列表(已去重、保持原顺序)
*
* @param drivingVehicle 司机档案里的驾驶车辆字段,可能并列多辆
* @return 规范化车牌列表;空值返回空列表
*/
public static List<String> splitPlates(String drivingVehicle) {
if (Func.isEmpty(drivingVehicle)) {
return new ArrayList<>();
}
String normalized = drivingVehicle.replaceAll(SEPARATOR_PATTERN, ",");
Set<String> plates = new LinkedHashSet<>();
for (String part : Func.toStrList(",", normalized)) {
String plate = normalize(part);
if (Func.isNotEmpty(plate)) {
plates.add(plate);
}
}
return new ArrayList<>(plates);
}
/**
* 车牌规范化:去掉排版字符并转大写,使不同写法能比对
*
* @param plateNo 原始车牌
* @return 规范化结果;空值返回空串
*/
public static String normalize(String plateNo) {
if (Func.isEmpty(plateNo)) {
return "";
}
return plateNo.trim().replaceAll(NOISE_PATTERN, "").toUpperCase(Locale.ROOT);
}
/**
* 判定某车牌是否属于该司机绑定的车辆
* <p>
* 空值一律判为「不绑定」——不能因为双方都为空就放行,那等于越权。
*
* @param drivingVehicle 司机档案里的驾驶车辆字段
* @param plateNo 待判定的车牌
* @return 是否绑定
*/
public static boolean isBound(String drivingVehicle, String plateNo) {
String target = normalize(plateNo);
if (Func.isEmpty(target)) {
return false;
}
return splitPlates(drivingVehicle).contains(target);
}
/**
* 一个司机最多能绑定的车辆数
* <p>
* 与小程序端「一个司机最多添加 5 辆」的上限一致。后端此前完全没有这个约束,
* 而 {@code driving_vehicle} 只有 varchar(30)——5 个车牌连分隔符要 39 字符,
* 第 4 辆起就会写不进去或被静默截断(截断后剩下半个车牌,还会被当成一辆车)。
*/
public static final int MAX_BOUND_VEHICLES = 5;
/**
* 车牌格式:中文省份简称 + 字母 + 5~6 位(含挂/学/警/港/澳等特殊字)
* <p>
* 与车辆建档时的车牌校验共用同一条规则:能绑到司机名下的车牌,
* 必须先能作为一辆车建档,否则两处口径分叉,会出现「绑得上但建不了档」。
* 传入值必须是 {@link #normalize} 之后的形态(已去排版字符并转大写)。
*/
private static final Pattern PLATE_NO_PATTERN = Pattern.compile("^[\\u4e00-\\u9fa5][A-Z][A-Z0-9挂学警港澳]{5,6}$");
/**
* 车牌格式是否合法
*
* @param plateNo 车牌(会先做规范化)
* @return 是否合法
*/
public static boolean isValidPlateNo(String plateNo) {
String normalized = normalize(plateNo);
return Func.isNotEmpty(normalized) && PLATE_NO_PATTERN.matcher(normalized).matches();
}
/**
* 校验「驾驶车辆」字段:车辆数上限与逐个车牌的格式
* <p>
* 规则只在这里维护一处——小程序新增车辆、管理端新增/编辑司机都调它。
* 此前管理端把这个字段当作**单个**车牌校验(拿整串去车牌表里精确查),
* 而小程序写入的是逗号并列的多辆车,两边口径本就不一致:
* 一个绑了两辆车的司机,管理员在司机管理页一保存就会撞上「驾驶车辆不存在」。
*
* @param drivingVehicle 司机档案里的驾驶车辆字段,可并列多辆
* @throws ServiceException 车辆数超过上限,或存在格式非法的车牌
*/
public static void validateBoundVehicles(String drivingVehicle) {
List<String> plates = splitPlates(drivingVehicle);
if (plates.size() > MAX_BOUND_VEHICLES) {
throw new ServiceException("一个司机最多绑定" + MAX_BOUND_VEHICLES + "辆车");
}
for (String plate : plates) {
if (!PLATE_NO_PATTERN.matcher(plate).matches()) {
throw new ServiceException("车牌号格式不正确:" + plate);
}
}
}
/**
* 按车牌匹配车辆的 SQL 条件:精确匹配 **或** 规范化后匹配
* <p>
* ⚠️ 只做精确匹配是不够的。{@link #splitPlates} 返回的已是**规范化**车牌
* (去空格/间隔符/横线并转大写),若直接拿它与 {@code plate_no} 原始列做 IN,
* 库中任何带间隔符或大小写差异的车牌都会**匹配不到**。
* 后果不是「少查一条」,而是调用方据此判定「该司机名下没有可用车辆」——
* 认证门槛会因此把**已认证的司机误判成未认证并拒绝接单**。
* <p>
* 库中已知存在这类写法差异({@code DriverAppServiceImpl.myVehicles()} 早就为此加了
* 同样的兜底),故此处把规则收敛成一处,避免各调用点各自实现、各自漏掉。
*
* @param wrapper 查询条件
* @param normalizedPlates 已规范化的车牌列表
*/
public static void applyPlateMatch(LambdaQueryWrapper<TransportVehicle> wrapper,
List<String> normalizedPlates) {
if (normalizedPlates == null || normalizedPlates.isEmpty()) {
return;
}
wrapper.and(w -> {
w.in(TransportVehicle::getPlateNo, normalizedPlates);
for (String plate : normalizedPlates) {
w.or().apply(NORMALIZED_PLATE_SQL + " = {0}", plate);
}
});
}
/**
* 与 {@link #normalize} 等价的 SQL 表达式(列名固定为 {@code plate_no})
* <p>
* 两者必须保持一致:Java 侧剔除的字符集变了,这里也要跟着变。
* {@code VehiclePlateSupportTest} 会断言两边覆盖同一批字符,防止悄悄分叉。
*/
public static final String NORMALIZED_PLATE_SQL =
"REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no)"
+ ",'·',''),'•',''),'.',''),'.',''),'-',''),' ','')";
}
@@ -0,0 +1,74 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.wrapper;
import org.springblade.core.mp.support.BaseEntityWrapper;
import org.springblade.core.tool.utils.BeanUtil;
import org.springblade.system.cache.UserCache;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.support.CertificationRuleSupport;
import java.util.Objects;
/**
* 认证记录包装类
*
* @author Chill
*/
public class TransportCertRecordWrapper extends BaseEntityWrapper<TransportCertRecord, TransportCertRecordVO> {
public static TransportCertRecordWrapper build() {
return new TransportCertRecordWrapper();
}
@Override
public TransportCertRecordVO entityVO(TransportCertRecord transportCertRecord) {
TransportCertRecordVO transportCertRecordVO = Objects.requireNonNull(BeanUtil.copyProperties(transportCertRecord, TransportCertRecordVO.class));
return fillDisplayFields(transportCertRecordVO);
}
/**
* 填充面向前端的展示字段:四个姓名与状态文案
* <p>
* 审核列表走的是自定义 SQL,查出来已经是 VO——它多带司机姓名与手机号两个字段,
* 这两个字段实体上没有、无法由实体转换得到,所以那条路径不能走 {@link #entityVO}。
* 把填充逻辑单独暴露一次,避免 Service 里再复制一份翻译规则,日后两处走偏。
*
* @param transportCertRecordVO 认证记录视图对象
* @return 同一个对象,便于链式调用
*/
public TransportCertRecordVO fillDisplayFields(TransportCertRecordVO transportCertRecordVO) {
transportCertRecordVO.setCreateUserName(UserCache.getUserRealName(transportCertRecordVO.getCreateUser()));
transportCertRecordVO.setUpdateUserName(UserCache.getUserRealName(transportCertRecordVO.getUpdateUser()));
transportCertRecordVO.setSubmitUserName(UserCache.getUserRealName(transportCertRecordVO.getSubmitUser()));
transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecordVO.getAuditUser()));
// 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法
transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecordVO.getStatus()));
return transportCertRecordVO;
}
}