✨ 认证记录状态机规则集中为 CertificationRuleSupport 并补测试

把「什么状态能做什么」从持久化里拎出来单独验证。这几条规则是认证改造的地基,
改坏的后果都是线上事故,故先锁住行为再写业务:

- prepareForSubmit:新提交一律待审,并清空驳回原因(否则会继承上一次的驳回文案,
  出现「审核中」却带着驳回原因的自相矛盾状态);
- supersedeIfPending:再次提交只作废前一条待审,已通过/已驳回/已作废都是终态不碰
  ——尤其是不得改动已通过的记录,它可能正被生效快照指针指着;
- isCertified:唯一判据是生效快照指针,故「曾通过即永久保持」;
- isAuditable:只有待审可审,终态再审会把生效快照改坏;
- statusText:四种状态各一个中文说法,与列注释、前端文案保持一致。

TransportCertRecord.REJECT_REASON_MAX_LENGTH 与 reject_reason 列宽(200)对齐。
9 个用例覆盖上述规则与边界(null、未知状态、超长原因)。

可离线执行:mvn -o test -pl blade-service/blade-transport -am
This commit is contained in:
2026-10-02 16:10:34 +08:00
parent 8a2d83b3cd
commit e891100a23
3 changed files with 308 additions and 0 deletions
@@ -91,6 +91,11 @@ public class TransportCertRecord extends TenantEntity {
*/
public static final Integer STATUS_SUPERSEDED = 3;
/**
* 驳回原因最大长度,与 {@code reject_reason} 列宽保持一致
*/
public static final int REJECT_REASON_MAX_LENGTH = 200;
/**
* 认证对象类型:driver-司机,vehicle-车辆
* <p>
@@ -0,0 +1,180 @@
/**
* BladeX Commercial License Agreement
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
* <p>
* Use of this software is governed by the Commercial License Agreement
* obtained after purchasing a license from BladeX.
* <p>
* 1. This software is for development use only under a valid license
* from BladeX.
* <p>
* 2. Redistribution of this software's source code to any third party
* without a commercial license is strictly prohibited.
* <p>
* 3. Licensees may copyright their own code but cannot use segments
* from this software for such purposes. Copyright of this software
* remains with BladeX.
* <p>
* Using this software signifies agreement to this License, and the software
* must not be used for illegal purposes.
* <p>
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
* not liable for any claims arising from secondary or illegal development.
* <p>
* Author: Chill Zhuang (bladejava@qq.com)
*/
package org.springblade.transport.support;
import org.springblade.core.tool.utils.Func;
import org.springblade.transport.pojo.entity.TransportCertRecord;
/**
* 认证记录状态机规则
* <p>
* 把「什么状态能做什么」集中在一处,与持久化分离,便于单独验证。
* 这几条规则是整个认证改造的地基,改坏的后果是线上事故:
* 司机重新提交认证后车辆掉出可用池,或同一辆车出现两条待审记录。
*
* @author Chill
*/
public final class CertificationRuleSupport {
private static final String STATUS_TEXT_PENDING = "审核中";
private static final String STATUS_TEXT_APPROVED = "已通过";
private static final String STATUS_TEXT_REJECTED = "已驳回";
private static final String STATUS_TEXT_SUPERSEDED = "已作废";
private static final String STATUS_TEXT_UNKNOWN = "未知状态";
private CertificationRuleSupport() {
}
/**
* 把一条新提交的记录准备成待审
* <p>
* 必须清空驳回原因:新提交是全新一次认证,不能继承上一次的驳回文案,
* 否则前端会显示「审核中」却带着旧的驳回原因。
*
* @param incoming 待入库的记录
*/
public static void prepareForSubmit(TransportCertRecord incoming) {
incoming.setStatus(TransportCertRecord.STATUS_PENDING);
incoming.setRejectReason(null);
}
/**
* 再次提交时,把该认证对象**前一条待审**记录转为已作废
* <p>
* 只动待审记录:已通过、已驳回、已作废都是终态,一律不碰。
* 特别是**不得改动已通过的记录**——它可能正被生效快照指针指着。
*
* @param previousPending 该对象当前那条待审记录,可为 null
* @return 需要更新为已作废的记录;无需更新时返回 null
*/
public static TransportCertRecord supersedeIfPending(TransportCertRecord previousPending) {
if (previousPending == null) {
return null;
}
if (!TransportCertRecord.STATUS_PENDING.equals(previousPending.getStatus())) {
return null;
}
previousPending.setStatus(TransportCertRecord.STATUS_SUPERSEDED);
return previousPending;
}
/**
* 判定认证对象是否已认证
* <p>
* 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」,
* 重新提交、待审、被驳回都不会让对象变回未认证。
*
* @param effectiveRecordId 生效快照指针
* @return 是否已认证
*/
public static boolean isCertified(Long effectiveRecordId) {
return Func.isNotEmpty(effectiveRecordId);
}
/**
* 判定一条记录是否可被审核
* <p>
* 只有待审记录可审。已通过/已驳回/已作废都是终态,再次审核会把生效快照改坏
* (例如把指针指着的那条「已通过」改成「已驳回」,生效值就没了)。
*
* @param record 认证记录
* @return 是否可审核
*/
public static boolean isAuditable(TransportCertRecord record) {
return record != null && TransportCertRecord.STATUS_PENDING.equals(record.getStatus());
}
/**
* 校验审核入参是否合法
*
* @param pass 是否通过
* @param rejectReason 驳回原因
* @return 是否合法
*/
public static boolean isRejectReasonValid(boolean pass, String rejectReason) {
if (pass) {
return true;
}
String reason = Func.toStr(rejectReason, "").trim();
return Func.isNotEmpty(reason)
&& reason.length() <= TransportCertRecord.REJECT_REASON_MAX_LENGTH;
}
/**
* 审核通过时的驳回原因:必须为 null,避免残留上一次的驳回文案
*
* @return 恒为 null
*/
public static String reasonForApprove() {
return null;
}
/**
* 审核驳回时的驳回原因:去首尾空白后落库
*
* @param rejectReason 原始原因
* @return 去空白后的原因
*/
public static String reasonForReject(String rejectReason) {
return Func.toStr(rejectReason, "").trim();
}
/**
* 状态对应的中文文案
* <p>
* 四端(小程序、Web、接口、库注释)必须用同一套说法,不要各造一套。
*
* @param status 状态值
* @return 中文文案
*/
public static String statusText(Integer status) {
if (TransportCertRecord.STATUS_PENDING.equals(status)) {
return STATUS_TEXT_PENDING;
}
if (TransportCertRecord.STATUS_APPROVED.equals(status)) {
return STATUS_TEXT_APPROVED;
}
if (TransportCertRecord.STATUS_REJECTED.equals(status)) {
return STATUS_TEXT_REJECTED;
}
if (TransportCertRecord.STATUS_SUPERSEDED.equals(status)) {
return STATUS_TEXT_SUPERSEDED;
}
return STATUS_TEXT_UNKNOWN;
}
/**
* 校验认证对象类型
*
* @param bizType 类型
* @return 是否为合法类型
*/
public static boolean isValidBizType(String bizType) {
return TransportCertRecord.BIZ_TYPE_DRIVER.equals(bizType)
|| TransportCertRecord.BIZ_TYPE_VEHICLE.equals(bizType);
}
}
@@ -0,0 +1,123 @@
package org.springblade.transport.support;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springblade.transport.pojo.entity.TransportCertRecord;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 认证记录状态机规则测试。
* <p>
* 这几条规则是整个认证改造的地基,一旦被改坏,症状是「司机重新提交认证后车辆掉出可用池」
* 或「同一辆车出现两条待审」,都属线上事故,故集中在一处并锁住行为。
*/
class CertificationRuleSupportTest {
@Test
@DisplayName("提交:前一条待审必须转已作废,而不是被删除")
void supersedePending() {
TransportCertRecord previousPending = record(TransportCertRecord.STATUS_PENDING);
TransportCertRecord superseded = CertificationRuleSupport.supersedeIfPending(previousPending);
// 返回的正是那条旧记录,且只改状态
assertEquals(TransportCertRecord.STATUS_SUPERSEDED, superseded.getStatus());
assertEquals(previousPending.getId(), superseded.getId());
}
@Test
@DisplayName("提交:前一条不是待审时,不得被改动(已通过/已驳回都是终态)")
void doesNotTouchNonPending() {
assertNull(CertificationRuleSupport.supersedeIfPending(null));
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_APPROVED)));
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_REJECTED)));
assertNull(CertificationRuleSupport.supersedeIfPending(record(TransportCertRecord.STATUS_SUPERSEDED)));
}
@Test
@DisplayName("提交:新记录一律是待审,且清空驳回原因(避免继承上一条的驳回文案)")
void newRecordIsPending() {
TransportCertRecord incoming = record(null);
incoming.setStatus(TransportCertRecord.STATUS_REJECTED);
incoming.setRejectReason("上一次的驳回原因");
CertificationRuleSupport.prepareForSubmit(incoming);
assertEquals(TransportCertRecord.STATUS_PENDING, incoming.getStatus());
assertNull(incoming.getRejectReason());
}
@Test
@DisplayName("生效快照判定:只看指针,与记录表里任何一条的状态都无关")
void effectiveIsDecidedByPointerOnly() {
assertFalse(CertificationRuleSupport.isCertified(null));
assertTrue(CertificationRuleSupport.isCertified(123456789L));
}
@Test
@DisplayName("可审核判定:只有待审记录可被审核,终态一律拒绝(防止把生效快照改坏)")
void onlyPendingIsAuditable() {
assertTrue(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_PENDING)));
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_APPROVED)));
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_REJECTED)));
assertFalse(CertificationRuleSupport.isAuditable(record(TransportCertRecord.STATUS_SUPERSEDED)));
assertFalse(CertificationRuleSupport.isAuditable(null));
}
@Test
@DisplayName("审核入参:驳回必须给原因,通过时原因无意义需清空")
void auditPayload() {
// 驳回无原因 -> 拒绝
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, null));
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, " "));
assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "证件照片模糊"));
// 通过时不需要原因
assertTrue(CertificationRuleSupport.isRejectReasonValid(true, null));
// 通过必须把原因清空,否则会残留上一次驳回文案
assertNull(CertificationRuleSupport.reasonForApprove());
assertEquals("证件照片模糊", CertificationRuleSupport.reasonForReject(" 证件照片模糊 "));
}
@Test
@DisplayName("驳回原因长度:超过 200 字拒绝(与列宽一致)")
void rejectReasonLength() {
assertTrue(CertificationRuleSupport.isRejectReasonValid(false, "短".repeat(200)));
assertFalse(CertificationRuleSupport.isRejectReasonValid(false, "长".repeat(201)));
}
@Test
@DisplayName("状态文案:四种状态各一个中文说法,未知状态不静默给空")
void statusText() {
assertEquals("审核中", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_PENDING));
assertEquals("已通过", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_APPROVED));
assertEquals("已驳回", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_REJECTED));
assertEquals("已作废", CertificationRuleSupport.statusText(TransportCertRecord.STATUS_SUPERSEDED));
assertEquals("未知状态", CertificationRuleSupport.statusText(99));
assertEquals("未知状态", CertificationRuleSupport.statusText(null));
}
@Test
@DisplayName("认证对象类型:只认 driver / vehicle,其它一律拒绝")
void bizTypeValidation() {
assertTrue(CertificationRuleSupport.isValidBizType("driver"));
assertTrue(CertificationRuleSupport.isValidBizType("vehicle"));
assertFalse(CertificationRuleSupport.isValidBizType("DRIVER"));
assertFalse(CertificationRuleSupport.isValidBizType("truck"));
assertFalse(CertificationRuleSupport.isValidBizType(null));
assertFalse(CertificationRuleSupport.isValidBizType(""));
}
private TransportCertRecord record(Integer status) {
TransportCertRecord record = new TransportCertRecord();
record.setId(1000L);
record.setStatus(status);
return record;
}
}