From e891100a2368bb17930b7f9418598810d7db2369 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Fri, 2 Oct 2026 16:10:34 +0800 Subject: [PATCH] =?UTF-8?q?:sparkles:=20=E8=AE=A4=E8=AF=81=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E7=8A=B6=E6=80=81=E6=9C=BA=E8=A7=84=E5=88=99=E9=9B=86?= =?UTF-8?q?=E4=B8=AD=E4=B8=BA=20CertificationRuleSupport=20=E5=B9=B6?= =?UTF-8?q?=E8=A1=A5=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把「什么状态能做什么」从持久化里拎出来单独验证。这几条规则是认证改造的地基, 改坏的后果都是线上事故,故先锁住行为再写业务: - prepareForSubmit:新提交一律待审,并清空驳回原因(否则会继承上一次的驳回文案, 出现「审核中」却带着驳回原因的自相矛盾状态); - supersedeIfPending:再次提交只作废前一条待审,已通过/已驳回/已作废都是终态不碰 ——尤其是不得改动已通过的记录,它可能正被生效快照指针指着; - isCertified:唯一判据是生效快照指针,故「曾通过即永久保持」; - isAuditable:只有待审可审,终态再审会把生效快照改坏; - statusText:四种状态各一个中文说法,与列注释、前端文案保持一致。 TransportCertRecord.REJECT_REASON_MAX_LENGTH 与 reject_reason 列宽(200)对齐。 9 个用例覆盖上述规则与边界(null、未知状态、超长原因)。 可离线执行:mvn -o test -pl blade-service/blade-transport -am --- .../pojo/entity/TransportCertRecord.java | 5 + .../support/CertificationRuleSupport.java | 180 ++++++++++++++++++ .../support/CertificationRuleSupportTest.java | 123 ++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java create mode 100644 blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java index 7fb2b419..4f1346f4 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/TransportCertRecord.java @@ -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-车辆 *

diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java new file mode 100644 index 00000000..7fc8ce28 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/CertificationRuleSupport.java @@ -0,0 +1,180 @@ +/** + * 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.support; + +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.entity.TransportCertRecord; + +/** + * 认证记录状态机规则 + *

+ * 把「什么状态能做什么」集中在一处,与持久化分离,便于单独验证。 + * 这几条规则是整个认证改造的地基,改坏的后果是线上事故: + * 司机重新提交认证后车辆掉出可用池,或同一辆车出现两条待审记录。 + * + * @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() { + } + + /** + * 把一条新提交的记录准备成待审 + *

+ * 必须清空驳回原因:新提交是全新一次认证,不能继承上一次的驳回文案, + * 否则前端会显示「审核中」却带着旧的驳回原因。 + * + * @param incoming 待入库的记录 + */ + public static void prepareForSubmit(TransportCertRecord incoming) { + incoming.setStatus(TransportCertRecord.STATUS_PENDING); + incoming.setRejectReason(null); + } + + /** + * 再次提交时,把该认证对象**前一条待审**记录转为已作废 + *

+ * 只动待审记录:已通过、已驳回、已作废都是终态,一律不碰。 + * 特别是**不得改动已通过的记录**——它可能正被生效快照指针指着。 + * + * @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; + } + + /** + * 判定认证对象是否已认证 + *

+ * 唯一判据是生效快照指针。指针一旦写入永不回退,因此「曾通过即永久保持」, + * 重新提交、待审、被驳回都不会让对象变回未认证。 + * + * @param effectiveRecordId 生效快照指针 + * @return 是否已认证 + */ + public static boolean isCertified(Long effectiveRecordId) { + return Func.isNotEmpty(effectiveRecordId); + } + + /** + * 判定一条记录是否可被审核 + *

+ * 只有待审记录可审。已通过/已驳回/已作废都是终态,再次审核会把生效快照改坏 + * (例如把指针指着的那条「已通过」改成「已驳回」,生效值就没了)。 + * + * @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(); + } + + /** + * 状态对应的中文文案 + *

+ * 四端(小程序、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); + } + +} diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java new file mode 100644 index 00000000..ebfad643 --- /dev/null +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/support/CertificationRuleSupportTest.java @@ -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; + +/** + * 认证记录状态机规则测试。 + *

+ * 这几条规则是整个认证改造的地基,一旦被改坏,症状是「司机重新提交认证后车辆掉出可用池」 + * 或「同一辆车出现两条待审」,都属线上事故,故集中在一处并锁住行为。 + */ +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; + } + +}