Просмотр исходного кода

feat(统一支付): 状态枚举/轮询策略与配置

alphaH 1 неделя назад
Родитель
Сommit
b6a285dbad

+ 88 - 0
java/src/main/java/com/payment/platform/module/payment/unified/config/UnifiedPayProperties.java

@@ -0,0 +1,88 @@
+package com.payment.platform.module.payment.unified.config;
+
+import jakarta.annotation.PostConstruct;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+/**
+ * 统一支付平台配置(spec §6.1)。
+ *
+ * <p>★ **全部带代码默认值,不改 {@code application.yml}**:配置项里没有凭据,
+ * 商户密钥在库里按商户存。部署时如需覆盖,用同名环境变量/启动参数即可。
+ *
+ * <p>直接 {@code new} 即可构造(不依赖 Spring 上下文);{@link #logNotifyBaseUrl()}
+ * 只在容器 refresh 时由 {@code @PostConstruct} 触发。
+ */
+@Slf4j
+@Data
+@Component
+@ConfigurationProperties(prefix = "unified.pay")
+public class UnifiedPayProperties {
+
+    /** 上游网关。 */
+    private String baseUrl = "https://t-cube.huianrong.com/gateway";
+
+    /** 我们给上游的回调基址(含 context-path),拼路径见 {@link #notifyOrderUrl()}。 */
+    private String notifyBaseUrl = "https://qcsj88888.com/api/v1";
+
+    /** 连接超时(毫秒)。 */
+    private int connectTimeoutMs = 3000;
+
+    /** 读取超时(毫秒)。 */
+    private int readTimeoutMs = 10000;
+
+    /** 下游回调的超时(毫秒)。 */
+    private int callbackTimeoutMs = 5000;
+
+    /** 上游 reqTime 格式:{@code compact14} / {@code epoch13} / {@code epoch10}。 */
+    private String reqTimeFormat = "compact14";
+
+    /** 上游付款码字段名:{@code auth_code} / {@code authCode} / {@code both}。 */
+    private String authCodeKey = "auth_code";
+
+    /** 下游回调最大尝试次数(含首次)。 */
+    private int callbackMaxRetries = 3;
+
+    /** 下游回调线程池大小。 */
+    private int callbackPoolSize = 4;
+
+    /** 每轮扫描上限。 */
+    private int pollBatch = 50;
+
+    /** 订单轮询间隔(毫秒)。 */
+    private long orderPollIntervalMs = 10000;
+
+    /** 退款轮询间隔(毫秒)。 */
+    private long refundPollIntervalMs = 30000;
+
+    /** 失败回调重试扫描间隔(毫秒)。 */
+    private long callbackRetryIntervalMs = 60000;
+
+    /** 启动时打印回调基址(不含任何密钥),便于部署核对公网可达性。 */
+    @PostConstruct
+    void logNotifyBaseUrl() {
+        log.info("统一支付 notify-base-url={}", notifyBaseUrl);
+    }
+
+    /** 上游订单通知地址:{@code {notify-base-url}/payment/notify/unified/order}。 */
+    public String notifyOrderUrl() {
+        return trimmedNotifyBase() + "/payment/notify/unified/order";
+    }
+
+    /** 上游退款通知地址:{@code {notify-base-url}/payment/notify/unified/refund}。 */
+    public String notifyRefundUrl() {
+        return trimmedNotifyBase() + "/payment/notify/unified/refund";
+    }
+
+    /** 去掉基址末尾的斜杠(可能不止一个);空值按空串处理。 */
+    private String trimmedNotifyBase() {
+        String base = notifyBaseUrl == null ? "" : notifyBaseUrl;
+        int end = base.length();
+        while (end > 0 && base.charAt(end - 1) == '/') {
+            end--;
+        }
+        return base.substring(0, end);
+    }
+}

+ 24 - 0
java/src/main/java/com/payment/platform/module/payment/unified/enums/UnifiedNotifyStatus.java

@@ -0,0 +1,24 @@
+package com.payment.platform.module.payment.unified.enums;
+
+import java.util.Set;
+
+/** 下游回调推送状态(订单/退款共用)。值与库内 {@code notify_status} 一致。 */
+public final class UnifiedNotifyStatus {
+
+    /** 尚未回调(新单初始态)。 */
+    public static final String PENDING = "PENDING";
+
+    public static final String SUCCESS = "SUCCESS";
+
+    public static final String FAILED = "FAILED";
+
+    private static final Set<String> ALL = Set.of(PENDING, SUCCESS, FAILED);
+
+    private UnifiedNotifyStatus() {
+    }
+
+    /** 是否是已定义的回调状态({@code null} 不算)。 */
+    public static boolean isValid(String status) {
+        return status != null && ALL.contains(status);
+    }
+}

+ 75 - 0
java/src/main/java/com/payment/platform/module/payment/unified/enums/UnifiedOrderStatus.java

@@ -0,0 +1,75 @@
+package com.payment.platform.module.payment.unified.enums;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 统一支付订单状态(spec §4.1)。
+ *
+ * <p>值即契约:上游/下游/库内都用这些字符串,本地不做中文转义。
+ *
+ * <p>状态转换表(唯一依据,条件更新的 {@code WHERE trade_status IN (前态...)} 由
+ * {@link #previousStatesFor(String)} 反向给出):
+ * <pre>
+ *   0 GENERATED ──→ 1 PAYING ──→ 2 SUCCESS ──→ 5 REFUNDED
+ *        │              │            │
+ *        └──────────────┴──→ 3 FAILED / 4 REVOKED / 6 CLOSED
+ * </pre>
+ * 规则:{@code 0→1};{@code 0/1→2/3/4/6};{@code 2→5}。终态不回退,同态不算变化。
+ *
+ * <p>{@code SUCCESS(2)不在 {@link #FINAL} 里}:它还可以走向 {@code 5 REFUNDED},
+ * 所以退款后仍需允许 {@code 2→5} 回写。终止态是 3/4/5/6。
+ */
+public final class UnifiedOrderStatus {
+
+    public static final String GENERATED = "0";
+    public static final String PAYING = "1";
+    public static final String SUCCESS = "2";
+    public static final String FAILED = "3";
+    public static final String REVOKED = "4";
+    public static final String REFUNDED = "5";
+    public static final String CLOSED = "6";
+
+    /** 非终态 —— 需要轮询/等待回写收敛。 */
+    public static final Set<String> PENDING = Set.of(GENERATED, PAYING);
+
+    /** 终态 —— 不再轮询。注意 SUCCESS 不在此列(还能走 2→5)。 */
+    public static final Set<String> FINAL = Set.of(FAILED, REVOKED, REFUNDED, CLOSED);
+
+    private static final Set<String> ALL =
+            Set.of(GENERATED, PAYING, SUCCESS, FAILED, REVOKED, REFUNDED, CLOSED);
+
+    /** 目标状态 → 允许进入它的前态集合。没有入边的状态(GENERATED)不在此表。 */
+    private static final Map<String, Set<String>> PREVIOUS_STATES = Map.of(
+            PAYING, Set.of(GENERATED),
+            SUCCESS, Set.of(GENERATED, PAYING),
+            FAILED, Set.of(GENERATED, PAYING),
+            REVOKED, Set.of(GENERATED, PAYING),
+            REFUNDED, Set.of(SUCCESS),
+            CLOSED, Set.of(GENERATED, PAYING));
+
+    private UnifiedOrderStatus() {
+    }
+
+    /** 是否是已定义的订单状态({@code null} 不算)。 */
+    public static boolean isValid(String status) {
+        return status != null && ALL.contains(status);
+    }
+
+    /**
+     * 是否允许 {@code from → to}。
+     *
+     * <p>同态({@code from.equals(to)})返回 {@code false}:并发下另一线程已经写过终态时,
+     * 本次条件更新必须落空,而不是把同一状态再写一遍并当成「变化」。
+     */
+    public static boolean canTransition(String from, String to) {
+        return from != null && previousStatesFor(to).contains(from);
+    }
+
+    /**
+     * 允许进入 {@code to} 的前态集合;非法目标、或没有入边的初始态返回 {@code Set.of()}。
+     */
+    public static Set<String> previousStatesFor(String to) {
+        return to == null ? Set.of() : PREVIOUS_STATES.getOrDefault(to, Set.of());
+    }
+}

+ 67 - 0
java/src/main/java/com/payment/platform/module/payment/unified/enums/UnifiedRefundStatus.java

@@ -0,0 +1,67 @@
+package com.payment.platform.module.payment.unified.enums;
+
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 统一支付退款状态(spec §4.2)。
+ *
+ * <p>状态转换表:
+ * <pre>
+ *   0 GENERATED ──→ 1 REFUNDING ──→ 2 SUCCESS
+ *        │              │
+ *        └──────────────┴──→ 3 FAILED / 4 CLOSED
+ * </pre>
+ * 规则:{@code 0→1};{@code 0/1→2/3/4}。终态不回退,同态不算变化。
+ */
+public final class UnifiedRefundStatus {
+
+    public static final String GENERATED = "0";
+    public static final String REFUNDING = "1";
+    public static final String SUCCESS = "2";
+    public static final String FAILED = "3";
+    public static final String CLOSED = "4";
+
+    /** 非终态 —— 需要轮询/等待回写收敛。 */
+    public static final Set<String> PENDING = Set.of(GENERATED, REFUNDING);
+
+    /** 已占用退款额度(含成功)—— 退款申请与回写路径用它做并发/超额校验。 */
+    public static final Set<String> OCCUPIED = Set.of(GENERATED, REFUNDING, SUCCESS);
+
+    /** 终态 —— 不再轮询。 */
+    public static final Set<String> FINAL = Set.of(SUCCESS, FAILED, CLOSED);
+
+    private static final Set<String> ALL =
+            Set.of(GENERATED, REFUNDING, SUCCESS, FAILED, CLOSED);
+
+    /** 目标状态 → 允许进入它的前态集合。没有入边的状态(GENERATED)不在此表。 */
+    private static final Map<String, Set<String>> PREVIOUS_STATES = Map.of(
+            REFUNDING, Set.of(GENERATED),
+            SUCCESS, Set.of(GENERATED, REFUNDING),
+            FAILED, Set.of(GENERATED, REFUNDING),
+            CLOSED, Set.of(GENERATED, REFUNDING));
+
+    private UnifiedRefundStatus() {
+    }
+
+    /** 是否是已定义的退款状态({@code null} 不算)。 */
+    public static boolean isValid(String status) {
+        return status != null && ALL.contains(status);
+    }
+
+    /**
+     * 是否允许 {@code from → to}。
+     *
+     * <p>同态返回 {@code false}:并发重复回写必须落空,不算一次变化。
+     */
+    public static boolean canTransition(String from, String to) {
+        return from != null && previousStatesFor(to).contains(from);
+    }
+
+    /**
+     * 允许进入 {@code to} 的前态集合;非法目标、或没有入边的初始态返回 {@code Set.of()}。
+     */
+    public static Set<String> previousStatesFor(String to) {
+        return to == null ? Set.of() : PREVIOUS_STATES.getOrDefault(to, Set.of());
+    }
+}

+ 16 - 0
java/src/main/java/com/payment/platform/module/payment/unified/enums/UnifiedSource.java

@@ -0,0 +1,16 @@
+package com.payment.platform.module.payment.unified.enums;
+
+/**
+ * 订单/退款来源。入库取 {@link #name()}(与库内 {@code source} 列一致)。
+ *
+ * <ul>
+ *   <li>{@link #OPENAPI} —— 租户经开放 API(API Key)发起;</li>
+ *   <li>{@link #CASHIER} —— 收银台页面发起;</li>
+ *   <li>{@link #ADMIN} —— 后台管理端发起(下单路径**拒绝**此来源,见订单服务)。</li>
+ * </ul>
+ */
+public enum UnifiedSource {
+    OPENAPI,
+    CASHIER,
+    ADMIN
+}

+ 53 - 0
java/src/main/java/com/payment/platform/module/payment/unified/util/UnifiedPollPolicy.java

@@ -0,0 +1,53 @@
+package com.payment.platform.module.payment.unified.util;
+
+import java.time.OffsetDateTime;
+
+/**
+ * 统一支付轮询退避与停止条件(spec §7.2)。
+ *
+ * <p>退避序列(秒):{@code 5, 10, 20, 30, 60, 120, 300},之后封顶 300。
+ * 停止条件:查询次数达到 {@link #MAX_QUERY_COUNT},或订单创建已超过
+ * {@link #MAX_AGE_MINUTES} 分钟(两者是**或**关系)。
+ *
+ * <p>纯函数、无状态:调用方(订单/退款轮询服务)只依赖这里的读数,测试不启动 Spring。
+ */
+public final class UnifiedPollPolicy {
+
+    /** 最多查询次数({@code queryCount} 达到即停止)。 */
+    public static final int MAX_QUERY_COUNT = 60;
+
+    /** 订单从创建起最多追多久。 */
+    public static final int MAX_AGE_MINUTES = 30;
+
+    /** 下单失败后的重试间隔(秒)。 */
+    public static final int CREATE_FAILURE_RETRY_SECONDS = 15;
+
+    /** 收银台快照轮询的最小间隔(毫秒),防止顾客端高频刷新打上游。 */
+    public static final int CASHIER_SNAPSHOT_THROTTLE_MS = 3000;
+
+    private static final long[] DELAY_SECONDS = {5, 10, 20, 30, 60, 120, 300};
+
+    private UnifiedPollPolicy() {
+    }
+
+    /**
+     * 第 {@code queryCount} 次查询之后的等待秒数。
+     *
+     * <p>序列末端封顶 300 秒;{@code queryCount} 超出序列长度时一律 300。
+     * 负值按 0 处理(防御性,调用方正常从 0 开始计数)。
+     */
+    public static long nextDelaySeconds(int queryCount) {
+        int index = Math.min(Math.max(queryCount, 0), DELAY_SECONDS.length - 1);
+        return DELAY_SECONDS[index];
+    }
+
+    /**
+     * 是否应停止轮询:查询满 60 次,或创建时间早于「现在 - 30 分钟」。
+     *
+     * <p>恰好 30 分钟不算超过({@link OffsetDateTime#isBefore} 严格小于)。
+     */
+    public static boolean shouldStop(int queryCount, OffsetDateTime createdAt, OffsetDateTime now) {
+        return queryCount >= MAX_QUERY_COUNT
+                || createdAt.isBefore(now.minusMinutes(MAX_AGE_MINUTES));
+    }
+}

+ 48 - 0
java/src/test/java/com/payment/platform/module/payment/unified/config/UnifiedPayPropertiesTest.java

@@ -0,0 +1,48 @@
+package com.payment.platform.module.payment.unified.config;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * 配置默认值与回调地址拼接(spec §6.1,共 **13** 个字段)。
+ *
+ * <p>★ 计数以 spec §6.1 表格为准(13 行);brief/计划里写的「12 个」是笔误,
+ * 控制器裁定 R8 明确要求这里逐字段断言全部 13 个默认值。
+ *
+ * <p>直接 {@code new} 构造,不启动 Spring 上下文 —— 默认值必须在**代码里**成立,
+ * 而不是靠 application.yml 或测试资源兜底。
+ */
+class UnifiedPayPropertiesTest {
+
+    @Test
+    void 全部十三个默认值与规格一致() {
+        UnifiedPayProperties p = new UnifiedPayProperties();
+        assertEquals("https://t-cube.huianrong.com/gateway", p.getBaseUrl());
+        assertEquals("https://qcsj88888.com/api/v1", p.getNotifyBaseUrl());
+        assertEquals(3000, p.getConnectTimeoutMs());
+        assertEquals(10000, p.getReadTimeoutMs());
+        assertEquals(5000, p.getCallbackTimeoutMs());
+        assertEquals("compact14", p.getReqTimeFormat());
+        assertEquals("auth_code", p.getAuthCodeKey());
+        assertEquals(3, p.getCallbackMaxRetries());
+        assertEquals(4, p.getCallbackPoolSize());
+        assertEquals(50, p.getPollBatch());
+        assertEquals(10000, p.getOrderPollIntervalMs());
+        assertEquals(30000, p.getRefundPollIntervalMs());
+        assertEquals(60000, p.getCallbackRetryIntervalMs());
+    }
+
+    @Test
+    void 回调地址去掉基址末尾斜杠后拼接() {
+        UnifiedPayProperties p = new UnifiedPayProperties();
+        p.setNotifyBaseUrl("https://x/api/v1/");
+        assertEquals("https://x/api/v1/payment/notify/unified/order", p.notifyOrderUrl());
+        assertEquals("https://x/api/v1/payment/notify/unified/refund", p.notifyRefundUrl());
+
+        // 正对照:基址本来就不带斜杠时同样成立(排除「只在带斜杠时碰巧对」)
+        p.setNotifyBaseUrl("https://x/api/v1");
+        assertEquals("https://x/api/v1/payment/notify/unified/order", p.notifyOrderUrl());
+        assertEquals("https://x/api/v1/payment/notify/unified/refund", p.notifyRefundUrl());
+    }
+}

+ 144 - 0
java/src/test/java/com/payment/platform/module/payment/unified/enums/UnifiedStatusTest.java

@@ -0,0 +1,144 @@
+package com.payment.platform.module.payment.unified.enums;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 统一支付状态机的钉子(spec §4 / Task 2 brief)。
+ *
+ * <p>断言一律用**字面量**而不是常量名:钉的是入库/出网的那个值本身。
+ * 常量改名不该红,常量改值必须红。
+ *
+ * <p>状态转换表是后续所有条件更新({@code previousStatesFor} → {@code WHERE trade_status IN ...})
+ * 的唯一依据:同态不算变化(并发重复回写必须落空),终态不回退。
+ */
+class UnifiedStatusTest {
+
+    // ---------------------------------------------------------------- 订单
+
+    @Test
+    void 订单允许的状态转换() {
+        assertTrue(UnifiedOrderStatus.canTransition("0", "1"));
+        assertTrue(UnifiedOrderStatus.canTransition("0", "2"));
+        assertTrue(UnifiedOrderStatus.canTransition("0", "3"));
+        assertTrue(UnifiedOrderStatus.canTransition("0", "4"));
+        assertTrue(UnifiedOrderStatus.canTransition("0", "6"));
+        assertTrue(UnifiedOrderStatus.canTransition("1", "2"));
+        assertTrue(UnifiedOrderStatus.canTransition("1", "3"));
+        assertTrue(UnifiedOrderStatus.canTransition("1", "4"));
+        assertTrue(UnifiedOrderStatus.canTransition("1", "6"));
+        assertTrue(UnifiedOrderStatus.canTransition("2", "5"));
+    }
+
+    @Test
+    void 订单终态不回退且同态不算变化() {
+        assertFalse(UnifiedOrderStatus.canTransition("3", "2"));
+        assertFalse(UnifiedOrderStatus.canTransition("4", "2"));
+        assertFalse(UnifiedOrderStatus.canTransition("6", "2"));
+        assertFalse(UnifiedOrderStatus.canTransition("5", "2"));
+        assertFalse(UnifiedOrderStatus.canTransition("2", "2"));
+        assertFalse(UnifiedOrderStatus.canTransition("0", "0"));
+        assertFalse(UnifiedOrderStatus.canTransition("1", "1"));
+        assertFalse(UnifiedOrderStatus.canTransition("2", "99"));
+        assertFalse(UnifiedOrderStatus.canTransition("99", "1"));
+    }
+
+    @Test
+    void 订单前态集合() {
+        assertEquals(Set.of("0"), UnifiedOrderStatus.previousStatesFor("1"));
+        assertEquals(Set.of("0", "1"), UnifiedOrderStatus.previousStatesFor("2"));
+        assertEquals(Set.of("0", "1"), UnifiedOrderStatus.previousStatesFor("3"));
+        assertEquals(Set.of("0", "1"), UnifiedOrderStatus.previousStatesFor("4"));
+        assertEquals(Set.of("2"), UnifiedOrderStatus.previousStatesFor("5"));
+        assertEquals(Set.of("0", "1"), UnifiedOrderStatus.previousStatesFor("6"));
+        // 初始态没有前态;非法目标同样返回空集合
+        assertEquals(Set.of(), UnifiedOrderStatus.previousStatesFor("0"));
+        assertEquals(Set.of(), UnifiedOrderStatus.previousStatesFor("99"));
+    }
+
+    @Test
+    void 订单状态集合() {
+        assertEquals(Set.of("0", "1"), UnifiedOrderStatus.PENDING);
+        assertEquals(Set.of("3", "4", "5", "6"), UnifiedOrderStatus.FINAL);
+        assertFalse(UnifiedOrderStatus.isValid("99"));
+        assertTrue(UnifiedOrderStatus.isValid("6"));
+        for (String status : Set.of("0", "1", "2", "3", "4", "5", "6")) {
+            assertTrue(UnifiedOrderStatus.isValid(status), "应为合法订单状态: " + status);
+        }
+    }
+
+    // ---------------------------------------------------------------- 退款
+
+    @Test
+    void 退款允许的状态转换() {
+        assertTrue(UnifiedRefundStatus.canTransition("0", "1"));
+        assertTrue(UnifiedRefundStatus.canTransition("0", "2"));
+        assertTrue(UnifiedRefundStatus.canTransition("0", "3"));
+        assertTrue(UnifiedRefundStatus.canTransition("0", "4"));
+        assertTrue(UnifiedRefundStatus.canTransition("1", "2"));
+        assertTrue(UnifiedRefundStatus.canTransition("1", "3"));
+        assertTrue(UnifiedRefundStatus.canTransition("1", "4"));
+    }
+
+    @Test
+    void 退款终态不回退且同态不算变化() {
+        assertFalse(UnifiedRefundStatus.canTransition("2", "3"));
+        assertFalse(UnifiedRefundStatus.canTransition("2", "2"));
+        assertFalse(UnifiedRefundStatus.canTransition("0", "0"));
+        assertFalse(UnifiedRefundStatus.canTransition("1", "1"));
+        assertFalse(UnifiedRefundStatus.canTransition("3", "2"));
+        assertFalse(UnifiedRefundStatus.canTransition("4", "2"));
+        assertFalse(UnifiedRefundStatus.canTransition("0", "99"));
+        assertFalse(UnifiedRefundStatus.canTransition("99", "1"));
+    }
+
+    @Test
+    void 退款前态集合() {
+        assertEquals(Set.of("0"), UnifiedRefundStatus.previousStatesFor("1"));
+        assertEquals(Set.of("0", "1"), UnifiedRefundStatus.previousStatesFor("2"));
+        assertEquals(Set.of("0", "1"), UnifiedRefundStatus.previousStatesFor("3"));
+        assertEquals(Set.of("0", "1"), UnifiedRefundStatus.previousStatesFor("4"));
+        assertEquals(Set.of(), UnifiedRefundStatus.previousStatesFor("0"));
+        assertEquals(Set.of(), UnifiedRefundStatus.previousStatesFor("99"));
+    }
+
+    @Test
+    void 退款状态集合() {
+        assertEquals(Set.of("0", "1"), UnifiedRefundStatus.PENDING);
+        assertEquals(Set.of("0", "1", "2"), UnifiedRefundStatus.OCCUPIED);
+        assertEquals(Set.of("2", "3", "4"), UnifiedRefundStatus.FINAL);
+        assertFalse(UnifiedRefundStatus.isValid("99"));
+        assertTrue(UnifiedRefundStatus.isValid("4"));
+        for (String status : Set.of("0", "1", "2", "3", "4")) {
+            assertTrue(UnifiedRefundStatus.isValid(status), "应为合法退款状态: " + status);
+        }
+    }
+
+    // ------------------------------------------------- 回调状态 / 来源
+
+    @Test
+    void 回调状态常量与校验() {
+        assertEquals("PENDING", UnifiedNotifyStatus.PENDING);
+        assertEquals("SUCCESS", UnifiedNotifyStatus.SUCCESS);
+        assertEquals("FAILED", UnifiedNotifyStatus.FAILED);
+        assertTrue(UnifiedNotifyStatus.isValid("PENDING"));
+        assertTrue(UnifiedNotifyStatus.isValid("SUCCESS"));
+        assertTrue(UnifiedNotifyStatus.isValid("FAILED"));
+        assertFalse(UnifiedNotifyStatus.isValid("pending"));
+        assertFalse(UnifiedNotifyStatus.isValid("99"));
+        assertFalse(UnifiedNotifyStatus.isValid(null));
+    }
+
+    @Test
+    void 来源枚举值() {
+        assertEquals(3, UnifiedSource.values().length);
+        assertEquals("OPENAPI", UnifiedSource.OPENAPI.name());
+        assertEquals("CASHIER", UnifiedSource.CASHIER.name());
+        assertEquals("ADMIN", UnifiedSource.ADMIN.name());
+    }
+}

+ 57 - 0
java/src/test/java/com/payment/platform/module/payment/unified/util/UnifiedPollPolicyTest.java

@@ -0,0 +1,57 @@
+package com.payment.platform.module.payment.unified.util;
+
+import org.junit.jupiter.api.Test;
+
+import java.time.OffsetDateTime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * 轮询退避与停止条件(spec §7.2 / Task 2 brief)。
+ *
+ * <p>退避序列 5,10,20,30,60,120,300 秒,封顶 300;查询满 60 次或创建超过 30 分钟停止。
+ * 两个停止条件是**或**关系,任一成立都必须停。
+ */
+class UnifiedPollPolicyTest {
+
+    private static final OffsetDateTime NOW = OffsetDateTime.parse("2026-10-20T12:00:00+08:00");
+
+    @Test
+    void 退避序列逐档递增并封顶() {
+        assertEquals(5, UnifiedPollPolicy.nextDelaySeconds(0));
+        assertEquals(10, UnifiedPollPolicy.nextDelaySeconds(1));
+        assertEquals(20, UnifiedPollPolicy.nextDelaySeconds(2));
+        assertEquals(30, UnifiedPollPolicy.nextDelaySeconds(3));
+        assertEquals(60, UnifiedPollPolicy.nextDelaySeconds(4));
+        assertEquals(120, UnifiedPollPolicy.nextDelaySeconds(5));
+        assertEquals(300, UnifiedPollPolicy.nextDelaySeconds(6));
+        assertEquals(300, UnifiedPollPolicy.nextDelaySeconds(7));
+        assertEquals(300, UnifiedPollPolicy.nextDelaySeconds(99));
+        assertEquals(300, UnifiedPollPolicy.nextDelaySeconds(Integer.MAX_VALUE));
+    }
+
+    @Test
+    void 查询次数达到六十即停止() {
+        assertTrue(UnifiedPollPolicy.shouldStop(60, NOW.minusMinutes(1), NOW));
+        assertTrue(UnifiedPollPolicy.shouldStop(61, NOW.minusMinutes(1), NOW));
+        assertFalse(UnifiedPollPolicy.shouldStop(59, NOW.minusMinutes(29), NOW));
+    }
+
+    @Test
+    void 创建超过三十分钟即停止() {
+        assertTrue(UnifiedPollPolicy.shouldStop(1, NOW.minusMinutes(31), NOW));
+        // 恰好 30 分钟不算「超过」(判据是 isBefore)
+        assertFalse(UnifiedPollPolicy.shouldStop(1, NOW.minusMinutes(30), NOW));
+        assertFalse(UnifiedPollPolicy.shouldStop(1, NOW.minusMinutes(29), NOW));
+    }
+
+    @Test
+    void 常量与规格一致() {
+        assertEquals(60, UnifiedPollPolicy.MAX_QUERY_COUNT);
+        assertEquals(30, UnifiedPollPolicy.MAX_AGE_MINUTES);
+        assertEquals(15, UnifiedPollPolicy.CREATE_FAILURE_RETRY_SECONDS);
+        assertEquals(3000, UnifiedPollPolicy.CASHIER_SNAPSHOT_THROTTLE_MS);
+    }
+}