Browse Source

fix(统一支付): 收紧 0x 整数识别并快照回调判别键

- N-1:仅「0x + 全十六进制」按整数解析(0x[0-9a-f]+),
  0x.example.com / 0xpay.example.com / 0x0.merchant.com 不再被误判拒绝;
  0x7f000001 仍拒绝。
- N-2:trade_status/state 在 submit 前快照并传入状态回写,异步任务体
  不再读可变实体;event2 的 2xx 不会覆盖 event5 的 FAILED。
- 补三条域名正控(独立成方法)与执行级竞态测试。
alphaH 1 tuần trước cách đây
mục cha
commit
c62cc8462e

+ 42 - 22
java/src/main/java/com/payment/platform/module/payment/unified/service/UnifiedCallbackService.java

@@ -87,6 +87,8 @@ public class UnifiedCallbackService {
 
     /** IPv6 字面量的字符集(含内嵌 IPv4 的点);含 {@code %} 的 zone id 一律 fail-closed。 */
     private static final Pattern IPV6_LITERAL = Pattern.compile("[0-9a-f:.]+");
+    /** {@code 0x} 整数 IPv4:`0x` 之后必须**全部**是十六进制数字,否则是普通域名(`0xpay.example.com`)。 */
+    private static final Pattern HEX_INTEGER_HOST = Pattern.compile("0x[0-9a-f]+");
 
     /**
      * {@code content.upstream} 的白名单(R35,逐字取 spec §7.5)。只拷贝存在的字段;
@@ -134,7 +136,11 @@ public class UnifiedCallbackService {
         if (order == null) {
             return;
         }
-        String notifyId = orderNotifyId(order.getOrderNo(), order.getTradeStatus());
+        // 事件判别键在提交异步任务前快照:任务体只读这份局部值。调用方可能在 submit 之后
+        // 把同一实体改成下一个事件(trade_status 2→5),届时再从实体读会把 event2 的 2xx
+        // 写到 event5 的行上,覆盖掉 event5 的 FAILED。
+        String eventTradeStatus = order.getTradeStatus();
+        String notifyId = orderNotifyId(order.getOrderNo(), eventTradeStatus);
         String url;
         String content;
         try {
@@ -142,7 +148,7 @@ public class UnifiedCallbackService {
             if (!isAllowedCallbackUrl(url)) {
                 log.warn("[统一支付回调] 订单无可用的回调地址,记 FAILED 待重试: order_no={}, url={}",
                         order.getOrderNo(), UnifiedLogSanitizer.flatten(url));
-                writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
+                writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
                 return;
             }
             content = buildOrderContent(order, upstream);
@@ -151,19 +157,19 @@ public class UnifiedCallbackService {
             // 行若留在 PENDING,重试扫描(只选 FAILED)永远捞不到它,事件永久丢失。
             log.warn("[统一支付回调] 准备订单回调异常,记 FAILED 待重试: order_no={}, error={}",
                     order.getOrderNo(), UnifiedLogSanitizer.flatten(e.getMessage()));
-            writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
+            writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
             return;
         }
         if (content == null) {
-            writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
+            writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
             return;
         }
 
         // 先落 FAILED + 计数:崩溃窗口里这一单仍会被重试扫描捞起
-        writeOrderNotifyStatus(order, UnifiedNotifyStatus.FAILED, true);
+        writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.FAILED, true);
         submit(() -> {
             if (pushOnce(url, notifyId, content)) {
-                writeOrderNotifyStatus(order, UnifiedNotifyStatus.SUCCESS, false);
+                writeOrderNotifyStatus(order, eventTradeStatus, UnifiedNotifyStatus.SUCCESS, false);
             }
         }, "order_no=" + order.getOrderNo());
     }
@@ -179,7 +185,9 @@ public class UnifiedCallbackService {
         if (refund == null) {
             return;
         }
-        String notifyId = refundNotifyId(refund.getRefundNo(), refund.getState());
+        // 同 notifyOrder:判别键在 submit 前快照,异步任务体只读快照。
+        String eventState = refund.getState();
+        String notifyId = refundNotifyId(refund.getRefundNo(), eventState);
         String url;
         String content;
         try {
@@ -187,25 +195,25 @@ public class UnifiedCallbackService {
             if (!isAllowedCallbackUrl(url)) {
                 log.warn("[统一支付回调] 退款无可用的回调地址,记 FAILED 待重试: refund_no={}, url={}",
                         refund.getRefundNo(), UnifiedLogSanitizer.flatten(url));
-                writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
+                writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
                 return;
             }
             content = buildRefundContent(refund, order, upstream);
         } catch (Exception e) {
             log.warn("[统一支付回调] 准备退款回调异常,记 FAILED 待重试: refund_no={}, error={}",
                     refund.getRefundNo(), UnifiedLogSanitizer.flatten(e.getMessage()));
-            writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
+            writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
             return;
         }
         if (content == null) {
-            writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
+            writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
             return;
         }
 
-        writeRefundNotifyStatus(refund, UnifiedNotifyStatus.FAILED, true);
+        writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.FAILED, true);
         submit(() -> {
             if (pushOnce(url, notifyId, content)) {
-                writeRefundNotifyStatus(refund, UnifiedNotifyStatus.SUCCESS, false);
+                writeRefundNotifyStatus(refund, eventState, UnifiedNotifyStatus.SUCCESS, false);
             }
         }, "refund_no=" + refund.getRefundNo());
     }
@@ -515,13 +523,17 @@ public class UnifiedCallbackService {
      * <p><b>WHERE 必须带事件判别键 {@code trade_status}</b>:同一订单行会产生多个 ORDER
      * 事件(2、2→5)。旧事件的推送如果晚拿到 2xx,只按 {@code order_no} 回写会把新事件的
      * FAILED 覆盖成 SUCCESS,新事件就永远不再被重试扫描捞起。
+     *
+     * <p>判别键由调用方传入({@code eventTradeStatus}),不再从实体读:异步任务执行时
+     * 同一实体可能已被调用方推进到下一个事件,实时读会把判别键换成新事件。
      */
-    private void writeOrderNotifyStatus(UnifiedOrderEntity order, String status, boolean countAttempt) {
+    private void writeOrderNotifyStatus(UnifiedOrderEntity order, String eventTradeStatus,
+                                        String status, boolean countAttempt) {
         try {
             OffsetDateTime now = OffsetDateTime.now();
             LambdaUpdateWrapper<UnifiedOrderEntity> update = new LambdaUpdateWrapper<UnifiedOrderEntity>()
                     .eq(UnifiedOrderEntity::getOrderNo, order.getOrderNo())
-                    .eq(UnifiedOrderEntity::getTradeStatus, order.getTradeStatus())
+                    .eq(UnifiedOrderEntity::getTradeStatus, eventTradeStatus)
                     .set(UnifiedOrderEntity::getNotifyStatus, status)
                     .set(UnifiedOrderEntity::getLastNotifyTime, now)
                     .set(UnifiedOrderEntity::getUpdatedTime, now);
@@ -537,13 +549,14 @@ public class UnifiedCallbackService {
         }
     }
 
-    /** 退款侧同理:WHERE 带事件判别键 {@code state}。 */
-    private void writeRefundNotifyStatus(UnifiedRefundEntity refund, String status, boolean countAttempt) {
+    /** 退款侧同理:WHERE 带事件判别键 {@code state},值由调用方在 submit 前快照。 */
+    private void writeRefundNotifyStatus(UnifiedRefundEntity refund, String eventState,
+                                         String status, boolean countAttempt) {
         try {
             OffsetDateTime now = OffsetDateTime.now();
             LambdaUpdateWrapper<UnifiedRefundEntity> update = new LambdaUpdateWrapper<UnifiedRefundEntity>()
                     .eq(UnifiedRefundEntity::getRefundNo, refund.getRefundNo())
-                    .eq(UnifiedRefundEntity::getState, refund.getState())
+                    .eq(UnifiedRefundEntity::getState, eventState)
                     .set(UnifiedRefundEntity::getNotifyStatus, status)
                     .set(UnifiedRefundEntity::getLastNotifyTime, now)
                     .set(UnifiedRefundEntity::getUpdatedTime, now);
@@ -573,8 +586,9 @@ public class UnifiedCallbackService {
      * <ol>
      *   <li>host 先规范化:trim、小写、去尾部所有点、去 IPv6 方括号;</li>
      *   <li>非数字 host:只额外拒绝 {@code localhost} 与 {@code *.localhost},其余放行;</li>
-     *   <li>数字形态 host(纯数字 / {@code 0x} 开头 / 只含数字与点 / 含冒号的 IPv6):
-     *       按**字面量**解析后再判地址类别,解析失败一律拒绝(fail-closed)。</li>
+     *   <li>数字形态 host(纯数字 / {@code 0x} + 全十六进制 / 只含数字与点 / 含冒号的 IPv6):
+     *       按**字面量**解析后再判地址类别,解析失败一律拒绝(fail-closed)。
+     *       仅 {@code 0x} 前缀不算数字形态,`0x.example.com` 这类是域名。</li>
      * </ol>
      *
      * <p>这里只会对已在字符集上验证过的 IPv6 字面量调用 {@code InetAddress.getByName};
@@ -628,12 +642,18 @@ public class UnifiedCallbackService {
         return host;
     }
 
-    /** 数字形态:含冒号(IPv6)、{@code 0x} 开头(十六进制整数)、或只含数字与点。 */
+    /**
+     * 数字形态:含冒号(IPv6)、{@code 0x} + 全十六进制数字(十六进制整数)、或只含数字与点。
+     *
+     * <p>只看 {@code 0x} 前缀会把 {@code 0x.example.com}、{@code 0xpay.example.com} 这类合法
+     * 域名误判成整数并 fail-closed 拒绝,所以必须整串匹配 {@link #HEX_INTEGER_HOST};
+     * 不匹配的 `0x…` 落回域名分支。
+     */
     private static boolean isNumericHost(String host) {
         if (host.indexOf(':') >= 0) {
             return true;
         }
-        if (host.startsWith("0x")) {
+        if (HEX_INTEGER_HOST.matcher(host).matches()) {
             return true;
         }
         for (int i = 0; i < host.length(); i++) {
@@ -654,7 +674,7 @@ public class UnifiedCallbackService {
                     return false;
                 }
                 address = InetAddress.getByName(host);
-            } else if (host.startsWith("0x")) {
+            } else if (HEX_INTEGER_HOST.matcher(host).matches()) {
                 address = InetAddress.getByAddress(parseNumericIpv4(host.substring(2), 16));
             } else if (host.indexOf('.') >= 0) {
                 address = InetAddress.getByAddress(parseDottedIpv4(host));

+ 63 - 1
java/src/test/java/com/payment/platform/module/payment/unified/service/UnifiedCallbackServiceTest.java

@@ -381,6 +381,7 @@ class UnifiedCallbackServiceTest {
                 "http://[::]/x",
                 "http://[::ffff:169.254.169.254]/x",
                 "http://2130706433/x",
+                "http://0x7f000001/x",
                 "http://0127.0.0.1/x",
                 "http://0177.0.0.1/x",
                 "file:///etc/passwd",
@@ -392,7 +393,6 @@ class UnifiedCallbackServiceTest {
         // 正对照:合法地址不得被误伤(私网回调是企业内网的合法用法,spec 明确不解析私网 IP)
         assertTrue(UnifiedCallbackService.isAllowedCallbackUrl("https://example.com/cb"));
         assertTrue(UnifiedCallbackService.isAllowedCallbackUrl("http://10.1.2.3/cb"));
-
         when(confMapper.selectList(any())).thenReturn(List.of());
         for (String url : hostile) {
             UnifiedOrderEntity order = order("UP1", "2");
@@ -409,6 +409,27 @@ class UnifiedCallbackServiceTest {
         }
     }
 
+    // N-1 正控:0x 开头但并非「0x + 全十六进制」的是普通域名,不能按整数解析而误杀。
+    // 三条独立成方法:变异「去掉全匹配收窄」时三条各自红,而不是首条断言失败掩盖后两条。
+
+    @Test
+    void callbackUrl_0xPrefixedDomain_isAllowed() {
+        assertTrue(UnifiedCallbackService.isAllowedCallbackUrl("http://0x.example.com/cb"),
+                "0x.example.com 是普通域名");
+    }
+
+    @Test
+    void callbackUrl_0xPayPrefixedDomain_isAllowed() {
+        assertTrue(UnifiedCallbackService.isAllowedCallbackUrl("http://0xpay.example.com/cb"),
+                "0xpay.example.com 是普通域名");
+    }
+
+    @Test
+    void callbackUrl_0x0PrefixedDomain_isAllowed() {
+        assertTrue(UnifiedCallbackService.isAllowedCallbackUrl("http://0x0.merchant.com/cb"),
+                "0x0.merchant.com 是普通域名");
+    }
+
     /**
      * 端到端对照:用**生产** {@code RestTemplate} + 真实 {@code HttpServer}。三条等价本机
      * 字面量若通过过滤器就会真的连上(评审 E2E 探针实测 200),所以「0 命中」是行为证据,
@@ -843,6 +864,47 @@ class UnifiedCallbackServiceTest {
                 "event2 的成功写入必须只命中 trade_status=2 的行");
     }
 
+    /**
+     * N-2:异步任务执行前,调用方把**同一个实体**的 trade_status 从 2 改成 5(行也推进到 5)。
+     * 任务体若现读实体,就会用 5 当判别键命中新事件的行;必须用提交任务前的快照 2。
+     */
+    @Test
+    void asyncSuccess_usesTradeStatusSnapshotTakenBeforeSubmit() {
+        UnifiedOrderEntity order = order("UP1", "2");
+        order.setCallbackUrl("https://merchant.example.com/cb");
+
+        Map<String, Object> row = new HashMap<>();
+        row.put("trade_status", "2");
+        row.put("notify_status", UnifiedNotifyStatus.PENDING);
+        when(orderMapper.update(isNull(), any())).thenAnswer(invocation -> {
+            LambdaUpdateWrapper<?> wrapper = invocation.getArgument(1);
+            Object whereTradeStatus = whereValueOfOrNull(wrapper, "trade_status");
+            if (whereTradeStatus != null && !Objects.equals(whereTradeStatus, row.get("trade_status"))) {
+                return 0;
+            }
+            row.put("notify_status", setValueOf(wrapper, "notify_status"));
+            return 1;
+        });
+        when(refundMapper.sumOccupiedAmount(ORDER_DB_ID)).thenReturn(30L);
+        stubPushOk();
+
+        List<Runnable> pending = new ArrayList<>();
+        UnifiedCallbackService asyncService = new UnifiedCallbackService(orderMapper, refundMapper,
+                apiKeyMapper, confMapper, restTemplate, pending::add, props);
+
+        asyncService.notifyOrder(order, null);   // FAILED 写入(判别键 2)+ 排队
+        order.setTradeStatus("5");                // 调用方在提交任务后改同一实体
+        row.put("trade_status", "5");             // 行已推进到 event5
+        pending.get(0).run();                      // 2xx 回写必须仍用快照 2
+
+        assertEquals(UnifiedNotifyStatus.FAILED, row.get("notify_status"),
+                "任务体现读实体会拿 trade_status=5 命中新事件的行,把 event5 的 FAILED 覆盖掉");
+        List<LambdaUpdateWrapper<?>> updates = captureOrderUpdates(2);
+        assertEquals("2", whereValueOf(updates.get(0), "trade_status"));
+        assertEquals("2", whereValueOf(updates.get(1), "trade_status"),
+                "SUCCESS 回写必须用提交任务前的判别键快照,而不是线程执行时的实体值");
+    }
+
     /** I-3:快照构建(sumOccupiedAmount)抛异常时,行不能留在 PENDING —— 扫描只选 FAILED。 */
     @Test
     void notifyOrder_contentBuildThrows_recordsFailedWithoutOutbound() {