diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java
new file mode 100644
index 00000000..9df6f808
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/CertAuditDetailVO.java
@@ -0,0 +1,91 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.vo;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.time.LocalDateTime;
+
+/**
+ * 认证审核详情视图
+ *
+ * 审核是「拿新提交的内容和当前生效的内容比」的过程,所以这里**同时**给出两侧:
+ * {@code pendingSnapshotJson} 是本次待审的全量内容,
+ * {@code effectiveSnapshotJson} 是该对象当前生效的全量内容。
+ * 前端对这两个 JSON 做字段级 diff,即可看出司机这次改了什么。
+ *
+ * 两侧的 JSON 结构完全一致(同一套提交结构),因此可以逐键直接比较。
+ *
+ * @author Chill
+ */
+@Data
+@Schema(description = "认证审核详情")
+public class CertAuditDetailVO implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "认证记录主键")
+ private Long id;
+ @Schema(description = "认证对象类型:driver-司机,vehicle-车辆")
+ private String bizType;
+ @Schema(description = "认证对象主键")
+ private Long bizId;
+ @Schema(description = "展示键:司机姓名 / 车牌号")
+ private String bizKey;
+ @Schema(description = "审核状态:0审核中 1已通过 2已驳回 3已作废")
+ private Integer status;
+ @Schema(description = "状态中文文案")
+ private String statusText;
+ @Schema(description = "本次提交时间")
+ private LocalDateTime submitTime;
+ @Schema(description = "本次提交人")
+ private Long submitUser;
+ @Schema(description = "本次提交人姓名")
+ private String submitUserName;
+
+ @Schema(description = "待审快照:本次提交的全量内容")
+ private String pendingSnapshotJson;
+ @Schema(description = "生效快照:该对象当前生效的全量内容;从未认证通过时为 null")
+ private String effectiveSnapshotJson;
+ @Schema(description = "生效快照记录ID;null 表示该对象从未认证通过")
+ private Long effectiveRecordId;
+
+ /**
+ * 该对象是否已认证
+ *
+ * 审核页据此提示「本次驳回不会影响该对象继续使用」——
+ * 已认证的对象即便本次被驳回,既得认证依然有效。
+ */
+ @TableField(exist = false)
+ @Schema(description = "该对象当前是否已认证")
+ private Boolean certified;
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java
new file mode 100644
index 00000000..e0399779
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/CertificationAuditController.java
@@ -0,0 +1,95 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.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.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.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;
+
+/**
+ * 认证审核(管理端)
+ *
+ * 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
+ * 这与旧的车辆认证审核接口有本质区别:旧接口作用于车辆行并原地覆盖,
+ * 在「生效值 / 待审值分离」之后语义已不成立,故已弃用。
+ */
+@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("hasAnyRole('administrator','admin')")
+ public R> pendingPage(
+ @Parameter(description = "认证对象类型:driver-司机,vehicle-车辆", required = true)
+ @RequestParam String bizType,
+ @Parameter(description = "展示键模糊匹配:司机姓名 / 车牌号")
+ @RequestParam(required = false) String bizKey,
+ @Parameter(description = "页码") @RequestParam(required = false) Integer current,
+ @Parameter(description = "每页条数") @RequestParam(required = false) Integer size) {
+ return R.data(certAuditService.pendingPage(bizType, bizKey, current, size));
+ }
+
+ @GetMapping("/detail")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "审核详情", description = "同时返回待审快照与当前生效值,供前端做字段级对比")
+ @PreAuth("hasAnyRole('administrator','admin')")
+ public R detail(
+ @Parameter(description = "认证记录主键", required = true) @RequestParam Long id) {
+ return R.data(certAuditService.detail(id));
+ }
+
+ @PostMapping("/audit")
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "审核认证记录", description = "通过使该记录成为生效快照;驳回只终结该记录,生效快照不变")
+ @PreAuth("hasAnyRole('administrator','admin')")
+ public R 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));
+ }
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java
index c21f3bf6..37596eb4 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/TransportVehicleController.java
@@ -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}。
+ *
+ * 保留而非删除,是因为它可能仍被小程序历史版本或其它系统调用;
+ * 调用时记录告警日志。它只写展示缓存,**不再影响可用性判定**
+ * (可用性只看 {@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));
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java
new file mode 100644
index 00000000..91df728e
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ICertAuditService.java
@@ -0,0 +1,80 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.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;
+
+/**
+ * 认证审核服务(管理端)
+ *
+ * 审核对象是**一条认证记录**,不是「一辆车」或「一个人」。
+ * 通过使该记录成为生效快照;驳回只终结该记录,生效快照保持不变。
+ *
+ * @author Chill
+ */
+public interface ICertAuditService {
+
+ /**
+ * 待审记录分页(按认证对象类型)
+ *
+ * @param bizType 认证对象类型:driver-司机,vehicle-车辆
+ * @param bizKey 展示键模糊匹配,可为空
+ * @param pageNum 页码
+ * @param pageSize 每页条数
+ * @return 待审记录分页
+ */
+ IPage pendingPage(String bizType, String bizKey, Integer pageNum, Integer pageSize);
+
+ /**
+ * 审核详情:同时返回待审快照与当前生效值,供前端做 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);
+
+ /**
+ * 按主键取记录,供写指针时复用
+ *
+ * @param recordId 认证记录主键
+ * @return 认证记录;不存在返回 null
+ */
+ TransportCertRecord getRecordOrNull(Long recordId);
+
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java
new file mode 100644
index 00000000..439bf5c6
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/CertAuditServiceImpl.java
@@ -0,0 +1,225 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.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 com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+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.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;
+
+/**
+ * 认证审核服务实现
+ *
+ * 审核作用于**一条记录**。两条路径的差别是本次改造的关键:
+ *
+ * - 通过:记录转已通过,并把该对象的生效快照指针切到它;
+ * - 驳回:记录转已驳回并留原因,生效快照指针一个字都不动。
+ *
+ * 因此「已认证的车被驳回」不会让它掉出可用池,只是这次提交没被采纳。
+ *
+ * @author Chill
+ */
+@Service
+@RequiredArgsConstructor
+public class CertAuditServiceImpl implements ICertAuditService {
+
+ private final ITransportCertRecordService certRecordService;
+ private final ITransportVehicleService transportVehicleService;
+ private final IDriverService driverService;
+
+ @Override
+ public IPage pendingPage(String bizType, String bizKey,
+ Integer pageNum, Integer pageSize) {
+ if (!CertificationRuleSupport.isValidBizType(bizType)) {
+ throw new ServiceException("认证对象类型不正确");
+ }
+ LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery()
+ .eq(TransportCertRecord::getBizType, bizType)
+ .eq(TransportCertRecord::getStatus, TransportCertRecord.STATUS_PENDING)
+ .like(Func.isNotEmpty(bizKey), TransportCertRecord::getBizKey, bizKey)
+ .orderByDesc(TransportCertRecord::getSubmitTime);
+ Page page = Page.of(
+ Func.isEmpty(pageNum) ? 1 : pageNum,
+ Func.isEmpty(pageSize) ? 10 : pageSize);
+ return TransportCertRecordWrapper.build().pageVO(certRecordService.page(page, queryWrapper));
+ }
+
+ @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.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;
+ }
+
+ @Override
+ public TransportCertRecord getRecordOrNull(Long recordId) {
+ return Func.isEmpty(recordId) ? null : certRecordService.getById(recordId);
+ }
+
+ /**
+ * 更新认证对象的展示缓存,并在通过时把生效快照指针切到这条记录
+ *
+ * 只发**一次** UPDATE:
+ *
+ * - 通过 —— 状态=已通过、清空驳回原因、指针指向本条(切换而非追加,故仍至多一条生效快照);
+ * - 驳回 —— 状态=已驳回、写入原因,**不带指针字段**,生效快照一个字都不动。
+ *
+ * 分两次写同一行除了多一次往返,还会让「先切指针、后被展示缓存更新覆盖」成为可能。
+ *
+ * @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);
+ }
+ transportVehicleService.updateById(vehicle);
+ return;
+ }
+ Driver driver = new Driver();
+ driver.setId(record.getBizId());
+ driver.setCertificationStatus(status);
+ driver.setCertificationRejectReason(reason);
+ if (pass) {
+ driver.setEffectiveCertRecordId(newEffectiveId);
+ }
+ driverService.updateById(driver);
+ }
+
+ /**
+ * 取该对象当前的生效快照指针
+ *
+ * @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;
+ }
+
+}
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java
new file mode 100644
index 00000000..49776737
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/CertAuditServiceImplTest.java
@@ -0,0 +1,289 @@
+package org.springblade.transport.service.impl;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.transport.pojo.entity.Driver;
+import org.springblade.transport.pojo.entity.TransportCertRecord;
+import org.springblade.transport.pojo.entity.TransportVehicle;
+import org.springblade.transport.pojo.vo.CertAuditDetailVO;
+import org.springblade.transport.service.IDriverService;
+import org.springblade.transport.service.ITransportCertRecordService;
+import org.springblade.transport.service.ITransportVehicleService;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * 管理端认证审核测试。
+ *
+ * 守住两条最容易写坏的不变式:
+ * 驳回**不得改变**生效快照;已终态记录**不可再审**(再审会把生效快照改坏)。
+ */
+class CertAuditServiceImplTest {
+
+ private static final Long RECORD_ID = 5001L;
+ private static final Long VEHICLE_ID = 8001L;
+ private static final Long DRIVER_ID = 9001L;
+
+ private ITransportCertRecordService certRecordService;
+ private ITransportVehicleService transportVehicleService;
+ private IDriverService driverService;
+ private CertAuditServiceImpl service;
+
+ @BeforeEach
+ void setUp() {
+ certRecordService = mock(ITransportCertRecordService.class);
+ transportVehicleService = mock(ITransportVehicleService.class);
+ driverService = mock(IDriverService.class);
+ service = new CertAuditServiceImpl(certRecordService, transportVehicleService, driverService);
+ }
+
+ // ------------------------------------------------------------ 通过
+
+ @Test
+ @DisplayName("通过:记录转已通过,并把该对象的生效快照指针切到它")
+ void approveSwitchesEffectivePointer() {
+ TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
+ when(certRecordService.getById(RECORD_ID)).thenReturn(record);
+ when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
+
+ service.audit(RECORD_ID, true, null);
+
+ ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
+ verify(certRecordService).updateById(recordCaptor.capture());
+ assertEquals(TransportCertRecord.STATUS_APPROVED, recordCaptor.getValue().getStatus());
+ assertNull(recordCaptor.getValue().getRejectReason(), "通过时必须清空驳回原因");
+ assertNotNull(recordCaptor.getValue().getAuditTime(), "审核时间必须留痕");
+ assertNotNull(recordCaptor.getValue().getAuditUser(), "审核人必须留痕");
+
+ ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
+ verify(transportVehicleService).updateById(vehicleCaptor.capture());
+ assertEquals(RECORD_ID, vehicleCaptor.getValue().getEffectiveCertRecordId(),
+ "通过后指针应指向该记录");
+ }
+
+ @Test
+ @DisplayName("通过:切换指针而不是追加,同对象仍只有一条生效快照")
+ void approveReplacesPointerInsteadOfAppending() {
+ Long oldEffectiveId = 111L;
+ TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
+ when(certRecordService.getById(RECORD_ID)).thenReturn(record);
+ when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(oldEffectiveId));
+
+ service.audit(RECORD_ID, true, null);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class);
+ verify(transportVehicleService).updateById(captor.capture());
+ assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId());
+ // 只更新了这一个字段所在的车辆行,没有新增任何"生效标记"行
+ verify(transportVehicleService, never()).save(any(TransportVehicle.class));
+ }
+
+ @Test
+ @DisplayName("通过:同步刷新展示缓存为已通过")
+ void approveRefreshesDisplayCache() {
+ when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
+ when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
+
+ service.audit(RECORD_ID, true, null);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class);
+ verify(transportVehicleService).updateById(captor.capture());
+ assertEquals(TransportCertRecord.STATUS_APPROVED, captor.getValue().getCertificationStatus());
+ }
+
+ @Test
+ @DisplayName("通过司机认证:指针写到司机表")
+ void approveDriverSwitchesDriverPointer() {
+ TransportCertRecord record = driverRecord(TransportCertRecord.STATUS_PENDING);
+ when(certRecordService.getById(RECORD_ID)).thenReturn(record);
+ when(driverService.getById(DRIVER_ID)).thenReturn(driver(null));
+
+ service.audit(RECORD_ID, true, null);
+
+ ArgumentCaptor captor = ArgumentCaptor.forClass(Driver.class);
+ verify(driverService).updateById(captor.capture());
+ assertEquals(RECORD_ID, captor.getValue().getEffectiveCertRecordId());
+ }
+
+ // ------------------------------------------------------------ 驳回
+
+ @Test
+ @DisplayName("驳回:记录转已驳回并留原因,但**生效快照与可用性不变**(核心不变式)")
+ void rejectKeepsEffectivePointer() {
+ Long existingEffectiveId = 777L;
+ TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
+ when(certRecordService.getById(RECORD_ID)).thenReturn(record);
+ when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
+
+ service.audit(RECORD_ID, false, "证件照片模糊");
+
+ ArgumentCaptor recordCaptor = ArgumentCaptor.forClass(TransportCertRecord.class);
+ verify(certRecordService).updateById(recordCaptor.capture());
+ assertEquals(TransportCertRecord.STATUS_REJECTED, recordCaptor.getValue().getStatus());
+ assertEquals("证件照片模糊", recordCaptor.getValue().getRejectReason());
+
+ // 展示缓存可以更新,但**指针绝不能被改动**
+ ArgumentCaptor vehicleCaptor = ArgumentCaptor.forClass(TransportVehicle.class);
+ verify(transportVehicleService).updateById(vehicleCaptor.capture());
+ assertNull(vehicleCaptor.getValue().getEffectiveCertRecordId(),
+ "驳回不得修改生效快照指针,否则已认证的车会被无辜下线");
+ assertEquals(TransportCertRecord.STATUS_REJECTED, vehicleCaptor.getValue().getCertificationStatus());
+ }
+
+ @Test
+ @DisplayName("驳回:已认证的对象被驳回后,生效快照指针仍指向原来那条")
+ void rejectNeverClearsExistingPointer() {
+ Long existingEffectiveId = 777L;
+ when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
+ when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(existingEffectiveId));
+
+ service.audit(RECORD_ID, false, "运输证过期");
+
+ // 指针字段没有被写成 null,也没有被写成新记录
+ ArgumentCaptor captor = ArgumentCaptor.forClass(TransportVehicle.class);
+ verify(transportVehicleService).updateById(captor.capture());
+ assertNull(captor.getValue().getEffectiveCertRecordId());
+ }
+
+ @Test
+ @DisplayName("驳回:不填原因必须拒绝")
+ void rejectRequiresReason() {
+ when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
+
+ assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, null));
+ assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, " "));
+ verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
+ }
+
+ @Test
+ @DisplayName("驳回:原因超过 200 字必须拒绝(与列宽一致)")
+ void rejectReasonTooLong() {
+ when(certRecordService.getById(RECORD_ID)).thenReturn(vehicleRecord(TransportCertRecord.STATUS_PENDING));
+
+ assertThrows(ServiceException.class,
+ () -> service.audit(RECORD_ID, false, "长".repeat(201)));
+ }
+
+ // ------------------------------------------------------------ 终态保护
+
+ @Test
+ @DisplayName("终态不可再审:已通过/已驳回/已作废一律拒绝")
+ void terminalRecordsCannotBeAuditedAgain() {
+ for (Integer terminal : new Integer[]{
+ TransportCertRecord.STATUS_APPROVED,
+ TransportCertRecord.STATUS_REJECTED,
+ TransportCertRecord.STATUS_SUPERSEDED}) {
+ TransportCertRecord record = vehicleRecord(terminal);
+ when(certRecordService.getById(RECORD_ID)).thenReturn(record);
+
+ assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null),
+ "状态 " + terminal + " 属终态,不应可再审");
+ assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, false, "驳回"),
+ "状态 " + terminal + " 属终态,不应可再审");
+ }
+ verify(certRecordService, never()).updateById(any(TransportCertRecord.class));
+ }
+
+ @Test
+ @DisplayName("记录不存在:拒绝")
+ void recordNotFound() {
+ when(certRecordService.getById(RECORD_ID)).thenReturn(null);
+ assertThrows(ServiceException.class, () -> service.audit(RECORD_ID, true, null));
+ }
+
+ @Test
+ @DisplayName("记录主键为空:拒绝")
+ void recordIdRequired() {
+ assertThrows(ServiceException.class, () -> service.audit(null, true, null));
+ }
+
+ // ------------------------------------------------------------ 审核详情(diff 用)
+
+ @Test
+ @DisplayName("审核详情:同时给出待审快照与当前生效值,供前端做 diff")
+ void detailProvidesBothSides() {
+ TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
+ record.setSnapshotJson("{\"vin\":\"NEW\"}");
+ when(certRecordService.getById(RECORD_ID)).thenReturn(record);
+
+ TransportCertRecord oldEffective = new TransportCertRecord();
+ oldEffective.setId(777L);
+ oldEffective.setSnapshotJson("{\"vin\":\"OLD\"}");
+ when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(777L));
+ when(certRecordService.getById(777L)).thenReturn(oldEffective);
+
+ CertAuditDetailVO detail = service.detail(RECORD_ID);
+
+ assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson());
+ assertEquals("{\"vin\":\"OLD\"}", detail.getEffectiveSnapshotJson(),
+ "审核页需要旧生效值才能看出司机改了什么");
+ assertEquals(777L, detail.getEffectiveRecordId());
+ }
+
+ @Test
+ @DisplayName("审核详情:从未认证过时,生效值一侧为 null(而不是报错)")
+ void detailWithoutEffective() {
+ TransportCertRecord record = vehicleRecord(TransportCertRecord.STATUS_PENDING);
+ record.setSnapshotJson("{\"vin\":\"NEW\"}");
+ when(certRecordService.getById(RECORD_ID)).thenReturn(record);
+ when(transportVehicleService.getById(VEHICLE_ID)).thenReturn(vehicle(null));
+
+ CertAuditDetailVO detail = service.detail(RECORD_ID);
+
+ assertEquals("{\"vin\":\"NEW\"}", detail.getPendingSnapshotJson());
+ assertNull(detail.getEffectiveSnapshotJson());
+ assertNull(detail.getEffectiveRecordId());
+ }
+
+ // ------------------------------------------------------------ 辅助
+
+ private TransportCertRecord vehicleRecord(Integer status) {
+ TransportCertRecord record = new TransportCertRecord();
+ record.setId(RECORD_ID);
+ record.setBizType(TransportCertRecord.BIZ_TYPE_VEHICLE);
+ record.setBizId(VEHICLE_ID);
+ record.setBizKey("桂A32523");
+ record.setStatus(status);
+ record.setSnapshotJson("{}");
+ return record;
+ }
+
+ private TransportCertRecord driverRecord(Integer status) {
+ TransportCertRecord record = new TransportCertRecord();
+ record.setId(RECORD_ID);
+ record.setBizType(TransportCertRecord.BIZ_TYPE_DRIVER);
+ record.setBizId(DRIVER_ID);
+ record.setBizKey("张三");
+ record.setStatus(status);
+ record.setSnapshotJson("{}");
+ return record;
+ }
+
+ private TransportVehicle vehicle(Long effectiveId) {
+ TransportVehicle vehicle = new TransportVehicle();
+ vehicle.setId(VEHICLE_ID);
+ vehicle.setPlateNo("桂A32523");
+ vehicle.setEffectiveCertRecordId(effectiveId);
+ return vehicle;
+ }
+
+ private Driver driver(Long effectiveId) {
+ Driver driver = new Driver();
+ driver.setId(DRIVER_ID);
+ driver.setDriverName("张三");
+ driver.setEffectiveCertRecordId(effectiveId);
+ return driver;
+ }
+
+}
diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java
new file mode 100644
index 00000000..ff45d30a
--- /dev/null
+++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/EffectivePointerUpdateSqlTest.java
@@ -0,0 +1,60 @@
+package org.springblade.transport.support;
+
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
+import com.baomidou.mybatisplus.core.metadata.TableInfo;
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
+import org.apache.ibatis.builder.MapperBuilderAssistant;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springblade.transport.pojo.entity.Driver;
+import org.springblade.transport.pojo.entity.TransportVehicle;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 生效快照指针的 UPDATE 语句级验证。
+ *
+ * 「驳回不得改变生效快照」这条不变式最终取决于:驳回时那次 UPDATE 会不会带上
+ * {@code effective_cert_record_id} 列。审核服务传的是 null,因此只要该列被
+ * 「值非 null 才写入」的 if 包住,驳回就物理上碰不到指针。
+ *
+ * 这里直接检查 MyBatis-Plus 生成的 SET 片段,把「靠约定」变成「靠语句」——
+ * 若哪天有人给该字段加了 {@code updateStrategy = ALWAYS},这个测试会立刻变红。
+ */
+class EffectivePointerUpdateSqlTest {
+
+ @Test
+ @DisplayName("车辆:指针列进入 UPDATE 片段,但被 null 判断包住(通过能写、驳回不碰)")
+ void vehiclePointerGuardedByNullCheck() {
+ assertPointerGuarded(TransportVehicle.class);
+ }
+
+ @Test
+ @DisplayName("司机:同上")
+ void driverPointerGuardedByNullCheck() {
+ assertPointerGuarded(Driver.class);
+ }
+
+ private void assertPointerGuarded(Class> clazz) {
+ String updateSql = tableInfo(clazz).getAllSqlSet(true, "et");
+
+ assertTrue(updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId}"),
+ clazz.getSimpleName() + ":通过审核必须能写指针,否则生效快照永远建立不起来");
+
+ // 必须是「被 包住」的形式;若变成无条件的 SET,
+ // 驳回时(传 null)就会把已认证对象的指针清空,等于把车/司机无辜下线。
+ assertTrue(
+ updateSql.contains("")
+ && updateSql.contains("effective_cert_record_id=#{eteffectiveCertRecordId},"),
+ clazz.getSimpleName() + ":指针列必须是「值非 null 才写」;"
+ + "无条件 SET 会让驳回清空已认证对象的指针");
+ }
+
+ private TableInfo tableInfo(Class> clazz) {
+ MybatisConfiguration configuration = new MybatisConfiguration();
+ MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
+ assistant.setCurrentNamespace("test");
+ return TableInfoHelper.initTableInfo(assistant, clazz);
+ }
+
+}