✨ 司机端认证提交与记录查询接口(车辆/个人)

实现司机在小程序上提交认证、查看生效值与认证记录的能力。

提交链路的结构性保证(这是本次改造的核心约束「提交审核不影响车辆正常使用」):
- 提交只产生一条新的待审记录,**绝不修改 effective_cert_record_id**,
  因此已认证的车重新提交后依然可用;待审内容在审核通过前只存在于记录表里;
- 同对象至多一条待审:落新记录前先把前一条待审转已作废,
  且只动作待审——已通过/已驳回是终态,尤其不碰生效快照指着的那条;
- 新记录状态显式设为待审:框架会把空 status 静默填成 1,而 1 在本表是「已通过」,
  不显式设置会让一次提交凭空变成审核通过;
- 新记录清空驳回原因,避免出现「审核中」却带着上一次驳回文案。

越权校验:司机只能提交/查看本人及本人绑定车辆的认证(复用 VehiclePlateSupport),
不可查看者返回业务异常而不是空列表——空列表会被误读成「没有记录」。

新增:
- DriverAuthController(/driver/auth/**,兼容 /blade-transport/driver/auth/**)
- IDriverCertService / DriverCertServiceImpl
- PersonCertSubmitRequest / VehicleCertSubmitRequest
- TransportCertRecordVO.statusText(状态文案统一在 Wrapper 翻译,四端一套说法)
- IDriverAppService.currentDriverEntity()(认证写入需要司机实体)

17 个用例覆盖票 02 的三条验收点与不变式(含「重新提交后生效值仍是旧的」)。
全模块 33 个测试通过:mvn -o test -pl blade-service/blade-transport -am
This commit is contained in:
2026-10-02 16:17:06 +08:00
parent e891100a23
commit 63762b9dd0
10 changed files with 1100 additions and 0 deletions
@@ -0,0 +1,90 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDate;
/**
* 司机端个人认证提交请求
* <p>
* 一次提交承载个人认证的**全部**内容(身份证 + 驾驶证 + 从业资格证),
* 三证一起提交、一起审核,不拆开。
*
* @author Chill
*/
@Data
@Schema(description = "司机端个人认证提交请求")
public class PersonCertSubmitRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "真实姓名")
private String realName;
@Schema(description = "身份证号")
private String idCardNo;
@Schema(description = "身份证正面照")
private String idCardFrontUrl;
@Schema(description = "身份证反面照")
private String idCardBackUrl;
@Schema(description = "身份证有效期起")
private LocalDate idCardValidStart;
@Schema(description = "身份证有效期止")
private LocalDate idCardValidEnd;
@Schema(description = "身份证长期有效")
private Integer idCardLongTerm;
@Schema(description = "驾驶证档案编号")
private String driverFileNo;
@Schema(description = "准驾车型")
private String driverClass;
@Schema(description = "驾驶证主页照片")
private String driverLicenseFrontUrl;
@Schema(description = "驾驶证副页照片")
private String driverLicenseBackUrl;
@Schema(description = "驾驶证有效期起")
private LocalDate driverValidStart;
@Schema(description = "驾驶证有效期止")
private LocalDate driverValidEnd;
@Schema(description = "驾驶证长期有效")
private Integer driverLongTerm;
@Schema(description = "从业资格证号")
private String qualificationNo;
@Schema(description = "从业资格证照片")
private String qualificationUrl;
@Schema(description = "从业资格证有效期止")
private LocalDate qualificationValidEnd;
@Schema(description = "大头照")
private String headPhoto;
}
@@ -0,0 +1,87 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.pojo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDate;
/**
* 司机端车辆认证提交请求
* <p>
* 一次提交承载该车辆认证的**全部**内容(行驶证 + 道路运输证),两证一起提交、一起审核。
* 提交只产生新的待审记录,不覆盖车辆当前已生效的认证内容。
*
* @author Chill
*/
@Data
@Schema(description = "司机端车辆认证提交请求")
public class VehicleCertSubmitRequest implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "车辆ID:新增时为空,重新认证时必填")
private Long id;
@Schema(description = "车牌号")
private String plateNo;
@Schema(description = "车辆类型")
private String vehicleType;
@Schema(description = "车辆识别代号(车架号)")
private String vin;
@Schema(description = "发动机号")
private String engineNo;
@Schema(description = "车牌颜色")
private String plateColor;
@Schema(description = "行驶证档案编号")
private String licenseNo;
@Schema(description = "行驶证主页照片")
private String licenseFrontUrl;
@Schema(description = "行驶证副页照片")
private String licenseBackUrl;
@Schema(description = "行驶证有效期起")
private LocalDate licenseValidStart;
@Schema(description = "行驶证有效期止")
private LocalDate licenseValidEnd;
@Schema(description = "行驶证长期有效")
private Integer licenseLongTerm;
@Schema(description = "道路运输证号")
private String roadTransportNo;
@Schema(description = "道路运输证照片")
private String roadTransportUrl;
@Schema(description = "道路运输证有效期起")
private LocalDate roadTransportValidStart;
@Schema(description = "道路运输证有效期止")
private LocalDate roadTransportValidEnd;
@Schema(description = "道路运输证长期有效")
private Integer roadTransportLongTerm;
}
@@ -70,5 +70,13 @@ public class TransportCertRecordVO extends TransportCertRecord {
@TableField(exist = false)
@Schema(description = "审核人姓名")
private String auditUserName;
/**
* 状态中文文案:审核中 / 已通过 / 已驳回 / 已作废
* <p>
* 由服务层统一翻译后下发,避免小程序、Web、接口三处各造一套说法。
*/
@TableField(exist = false)
@Schema(description = "状态中文文案")
private String statusText;
}
@@ -0,0 +1,121 @@
/**
* 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.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.vo.TransportCertRecordVO;
import org.springblade.transport.service.IDriverCertService;
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.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;
// ------------------------------------------------------------ 个人认证
@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));
}
@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));
}
}
@@ -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,99 @@
/**
* 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);
/**
* 读取某辆车当前生效的认证内容
*
* @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);
}
@@ -95,6 +95,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) {
@@ -0,0 +1,316 @@
/**
* 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.fastjson2.JSON;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import lombok.RequiredArgsConstructor;
import org.springblade.core.log.exception.ServiceException;
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.LocalDateTime;
import java.util.List;
import java.util.stream.Collectors;
/**
* 司机端认证服务实现
* <p>
* 提交链路只做三件事:把本次提交的内容**原样**存成一条新的待审记录、
* 把该对象前一条待审记录置为已作废、刷新展示缓存。
* <p>
* ⚠️ 提交**绝不**修改 {@code effective_cert_record_id},也**不覆盖**证件字段,
* 这正是「提交审核不影响车辆正常使用」的实现方式:待审内容只存在于记录表里,
* 在审核通过之前,该对象对外呈现的仍然是旧的生效值。
*
* @author Chill
*/
@Service
@RequiredArgsConstructor
public class DriverCertServiceImpl implements IDriverCertService {
private final IDriverAppService driverAppService;
private final IDriverService driverService;
private final ITransportVehicleService transportVehicleService;
private final ITransportCertRecordService transportCertRecordService;
@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.getId());
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();
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.getId());
transportCertRecordService.save(record);
updateVehicleDisplayCache(vehicle.getId(), TransportCertRecord.STATUS_PENDING, null);
return record.getId();
}
@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 在本表是「已通过」,那会让一次提交凭空变成审核通过。
*/
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);
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) {
if (Func.isEmpty(driver.getDrivingVehicle())) {
throw new ServiceException("当前司机未绑定任何车辆");
}
TransportVehicle vehicle = Func.isEmpty(request.getId())
? findByPlateWithinBound(driver, request.getPlateNo())
: transportVehicleService.getById(request.getId());
if (vehicle == null) {
throw new ServiceException("车辆不存在或未绑定到当前司机");
}
// 越权校验:无论按 id 还是按车牌取到,最后都核对一次绑定关系
if (!VehiclePlateSupport.isBound(driver.getDrivingVehicle(), vehicle.getPlateNo())) {
throw new ServiceException("无权操作该车辆的认证");
}
return vehicle;
}
private TransportVehicle findByPlateWithinBound(Driver driver, String plateNo) {
if (Func.isEmpty(plateNo)) {
throw new ServiceException("车牌号不能为空");
}
String normalized = VehiclePlateSupport.normalize(plateNo);
List<TransportVehicle> bound = transportVehicleService.list(
Wrappers.<TransportVehicle>lambdaQuery()
.in(TransportVehicle::getPlateNo,
VehiclePlateSupport.splitPlates(driver.getDrivingVehicle())));
return bound.stream()
.filter(item -> normalized.equals(VehiclePlateSupport.normalize(item.getPlateNo())))
.findFirst()
.orElse(null);
}
/**
* 校验当前登录用户能否查看该认证对象的记录
* <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;
}
}
@@ -30,6 +30,7 @@ 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;
@@ -51,6 +52,8 @@ public class TransportCertRecordWrapper extends BaseEntityWrapper<TransportCertR
transportCertRecordVO.setUpdateUserName(UserCache.getUserRealName(transportCertRecord.getUpdateUser()));
transportCertRecordVO.setSubmitUserName(UserCache.getUserRealName(transportCertRecord.getSubmitUser()));
transportCertRecordVO.setAuditUserName(UserCache.getUserRealName(transportCertRecord.getAuditUser()));
// 状态文案统一在此翻译,避免小程序、Web、接口各处各造一套说法
transportCertRecordVO.setStatusText(CertificationRuleSupport.statusText(transportCertRecord.getStatus()));
return transportCertRecordVO;
}
@@ -0,0 +1,359 @@
package org.springblade.transport.service.impl;
import com.alibaba.fastjson2.JSON;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springblade.core.log.exception.ServiceException;
import org.springblade.transport.pojo.dto.PersonCertSubmitRequest;
import org.springblade.transport.pojo.dto.VehicleCertSubmitRequest;
import org.springblade.transport.pojo.entity.Driver;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import org.springblade.transport.pojo.entity.TransportVehicle;
import org.springblade.transport.service.IDriverAppService;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.ITransportCertRecordService;
import org.springblade.transport.service.ITransportVehicleService;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 司机端认证提交链路测试。
* <p>
* 重点守住票 02 的三条验收点与不变式:
* 提交不得改变生效快照、同对象至多一条待审、越权必须拒绝。
*/
class DriverCertServiceImplTest {
private static final Long DRIVER_ID = 9001L;
private static final Long VEHICLE_ID = 8001L;
private static final String PLATE = "桂A32523";
private IDriverAppService driverAppService;
private IDriverService driverService;
private ITransportVehicleService transportVehicleService;
private ITransportCertRecordService certRecordService;
private DriverCertServiceImpl service;
@BeforeEach
void setUp() {
driverAppService = mock(IDriverAppService.class);
driverService = mock(IDriverService.class);
transportVehicleService = mock(ITransportVehicleService.class);
certRecordService = mock(ITransportCertRecordService.class);
service = new DriverCertServiceImpl(driverAppService, driverService,
transportVehicleService, certRecordService);
}
// ---------------------------------------------------------------- 车辆提交
@Test
@DisplayName("车辆提交:落一条待审记录,且状态必须是待审(不能被框架默认值变成已通过)")
void submitVehicleCreatesPendingRecord() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID))
.thenReturn(null);
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setId(VEHICLE_ID);
request.setPlateNo(PLATE);
request.setVin("VIN123");
service.submitVehicleCert(request);
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).save(captor.capture());
TransportCertRecord saved = captor.getValue();
assertEquals(TransportCertRecord.STATUS_PENDING, saved.getStatus());
assertEquals(TransportCertRecord.BIZ_TYPE_VEHICLE, saved.getBizType());
assertEquals(VEHICLE_ID, saved.getBizId());
assertEquals(PLATE, saved.getBizKey());
assertTrue(saved.getSnapshotJson().contains("VIN123"), "快照应承载本次提交的全量内容");
assertNull(saved.getRejectReason(), "新提交不得继承上一条的驳回原因");
}
@Test
@DisplayName("车辆提交:不修改生效快照指针(提交不影响车辆可用性)")
void submitVehicleDoesNotTouchEffectivePointer() {
Long effectiveId = 777L;
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(transportVehicleService.getById(VEHICLE_ID))
.thenReturn(vehicle(VEHICLE_ID, PLATE, effectiveId));
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setId(VEHICLE_ID);
service.submitVehicleCert(request);
// 更新车辆时只能用展示缓存字段,且绝不能带上 effectiveCertRecordId
ArgumentCaptor<TransportVehicle> captor = ArgumentCaptor.forClass(TransportVehicle.class);
verify(transportVehicleService).updateById(captor.capture());
TransportVehicle updated = captor.getValue();
assertNull(updated.getEffectiveCertRecordId(),
"提交链路不得写生效快照指针,否则「曾通过即永久可用」会被破坏");
assertEquals(TransportCertRecord.STATUS_PENDING, updated.getCertificationStatus());
}
@Test
@DisplayName("车辆提交:前一条待审被置为已作废,不存在两条待审")
void submitVehicleSupersedesPreviousPending() {
TransportCertRecord previous = record(500L, TransportCertRecord.STATUS_PENDING);
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, null));
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID))
.thenReturn(previous);
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setId(VEHICLE_ID);
service.submitVehicleCert(request);
ArgumentCaptor<TransportCertRecord> captor = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(captor.capture());
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, captor.getValue().getStatus());
assertEquals(500L, captor.getValue().getId(), "被作废的应是原来那条待审");
}
@Test
@DisplayName("车辆提交:前一条已是终态时不得被改动(尤其不能碰已通过的那条)")
void submitVehicleLeavesTerminalRecordsAlone() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, PLATE, 777L));
// selectPendingRecord 只返回待审;已通过的不该被它返回
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setId(VEHICLE_ID);
service.submitVehicleCert(request);
verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
}
// ---------------------------------------------------------------- 越权
@Test
@DisplayName("越权:提交未绑定到自己名下的车辆必须拒绝")
void submitVehicleRejectsUnboundVehicle() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(transportVehicleService.getById(VEHICLE_ID))
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setId(VEHICLE_ID);
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
verify(certRecordService, never()).save(any(TransportCertRecord.class));
}
@Test
@DisplayName("越权:车牌写法差异不影响绑定判定(间隔符/大小写)")
void submitVehicleAcceptsBoundVehicleWithMessyPlate() {
// 司机档案里带间隔符,车辆表里是干净写法
when(driverAppService.currentDriverEntity()).thenReturn(driver("桂A·32523"));
when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(VEHICLE_ID, "桂a32523", null));
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setId(VEHICLE_ID);
service.submitVehicleCert(request);
verify(certRecordService).save(any(TransportCertRecord.class));
}
@Test
@DisplayName("越权:司机未绑定任何车辆时提交必须拒绝")
void submitVehicleRejectsWhenNoBoundVehicle() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(null));
VehicleCertSubmitRequest request = new VehicleCertSubmitRequest();
request.setId(VEHICLE_ID);
assertThrows(ServiceException.class, () -> service.submitVehicleCert(request));
}
@Test
@DisplayName("越权:不可查看他人车辆的认证记录列表")
void listRecordsRejectsOthersVehicle() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(transportVehicleService.getById(VEHICLE_ID))
.thenReturn(vehicle(VEHICLE_ID, "桂B99999", null));
assertThrows(ServiceException.class,
() -> service.listRecords(TransportCertRecord.BIZ_TYPE_VEHICLE, VEHICLE_ID));
}
@Test
@DisplayName("越权:不可查看其他司机的认证记录列表")
void listRecordsRejectsOtherDriver() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
assertThrows(ServiceException.class,
() -> service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, 12345L));
}
// ---------------------------------------------------------------- 个人认证
@Test
@DisplayName("个人提交:落待审记录,且再提交时作废前一条待审")
void submitPersonCertSupersedes() {
TransportCertRecord previous = record(600L, TransportCertRecord.STATUS_PENDING);
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(certRecordService.selectPendingRecord(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID))
.thenReturn(previous);
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
request.setRealName("张三");
request.setIdCardNo("110101199001011234");
service.submitPersonCert(request);
ArgumentCaptor<TransportCertRecord> saved = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).save(saved.capture());
assertEquals(TransportCertRecord.STATUS_PENDING, saved.getValue().getStatus());
assertEquals(TransportCertRecord.BIZ_TYPE_DRIVER, saved.getValue().getBizType());
assertEquals(DRIVER_ID, saved.getValue().getBizId());
ArgumentCaptor<TransportCertRecord> updated = ArgumentCaptor.forClass(TransportCertRecord.class);
verify(certRecordService).updateById(updated.capture());
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, updated.getValue().getStatus());
}
@Test
@DisplayName("个人提交:不写生效快照指针(只刷展示缓存)")
void submitPersonCertDoesNotTouchEffectivePointer() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
PersonCertSubmitRequest request = new PersonCertSubmitRequest();
request.setRealName("张三");
service.submitPersonCert(request);
ArgumentCaptor<Driver> captor = ArgumentCaptor.forClass(Driver.class);
verify(driverService).updateById(captor.capture());
assertNull(captor.getValue().getEffectiveCertRecordId(),
"提交链路不得写生效快照指针");
}
@Test
@DisplayName("个人提交:内容为空必须拒绝")
void submitPersonCertRejectsEmpty() {
assertThrows(ServiceException.class, () -> service.submitPersonCert(null));
verify(certRecordService, never()).save(any(TransportCertRecord.class));
}
// ---------------------------------------------------------------- 生效值读取
@Test
@DisplayName("读生效值:有指针时返回指针指向的那条快照内容(不是最新提交)")
void getEffectivePersonCertReadsPointedRecord() {
Long effectiveId = 777L;
Driver driver = driver(PLATE);
driver.setEffectiveCertRecordId(effectiveId);
when(driverAppService.currentDriverEntity()).thenReturn(driver);
PersonCertSubmitRequest effectiveContent = new PersonCertSubmitRequest();
effectiveContent.setRealName("旧生效值");
TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED);
effective.setSnapshotJson(JSON.toJSONString(effectiveContent));
when(certRecordService.getById(effectiveId)).thenReturn(effective);
PersonCertSubmitRequest result = service.getEffectivePersonCert();
assertEquals("旧生效值", result.getRealName());
}
@Test
@DisplayName("读生效值:从未认证通过时返回 null(而不是抛错或返回最新提交)")
void getEffectivePersonCertReturnsNullWhenNeverCertified() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
assertNull(service.getEffectivePersonCert());
}
@Test
@DisplayName("重新提交后,生效值仍是旧的(核心验收点:提交不影响已认证状态)")
void resubmitKeepsEffectiveValue() {
Long effectiveId = 777L;
Driver driver = driver(PLATE);
driver.setEffectiveCertRecordId(effectiveId);
when(driverAppService.currentDriverEntity()).thenReturn(driver);
when(certRecordService.selectPendingRecord(any(), anyLong())).thenReturn(null);
PersonCertSubmitRequest oldContent = new PersonCertSubmitRequest();
oldContent.setRealName("旧的已通过内容");
TransportCertRecord effective = record(effectiveId, TransportCertRecord.STATUS_APPROVED);
effective.setSnapshotJson(JSON.toJSONString(oldContent));
when(certRecordService.getById(effectiveId)).thenReturn(effective);
// 提交新内容
PersonCertSubmitRequest newContent = new PersonCertSubmitRequest();
newContent.setRealName("新提交待审内容");
service.submitPersonCert(newContent);
// 生效值不受影响
PersonCertSubmitRequest stillEffective = service.getEffectivePersonCert();
assertEquals("旧的已通过内容", stillEffective.getRealName());
assertNotEquals("新提交待审内容", stillEffective.getRealName());
}
@Test
@DisplayName("记录列表:类型非法必须拒绝")
void listRecordsRejectsInvalidBizType() {
assertThrows(ServiceException.class, () -> service.listRecords("truck", 1L));
assertThrows(ServiceException.class, () -> service.listRecords(null, 1L));
}
@Test
@DisplayName("记录列表:本人司机记录按传入条件查询(走统一查询入口)")
void listRecordsForSelf() {
when(driverAppService.currentDriverEntity()).thenReturn(driver(PLATE));
when(certRecordService.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID))
.thenReturn(List.of());
assertTrue(service.listRecords(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID).isEmpty());
verify(certRecordService, times(1))
.selectRecordList(TransportCertRecord.BIZ_TYPE_DRIVER, DRIVER_ID);
}
// ---------------------------------------------------------------- 辅助
private Driver driver(String drivingVehicle) {
Driver driver = new Driver();
driver.setId(DRIVER_ID);
driver.setDriverName("张三");
driver.setDrivingVehicle(drivingVehicle);
return driver;
}
private TransportVehicle vehicle(Long id, String plateNo, Long effectiveId) {
TransportVehicle vehicle = new TransportVehicle();
vehicle.setId(id);
vehicle.setPlateNo(plateNo);
vehicle.setEffectiveCertRecordId(effectiveId);
return vehicle;
}
private TransportCertRecord record(Long id, Integer status) {
TransportCertRecord record = new TransportCertRecord();
record.setId(id);
record.setStatus(status);
record.setSnapshotJson("{}");
return record;
}
}