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); + } + +}