Kaynağa Gözat

fix(汇安融): 通知按上游**实际的**字段名收 —— 规范第三次被证伪

2026-09-28 一笔真实支付的通知被自己上一批加的 WARN 打了出来,全文如下(键名照抄):

  actual_money=0.01, alipayOrderNo=..., alipay_order_no=..., merchantCode=...,
  orderNo=1156891538702077952, order_no=同, status=1, subject=测试0928,
  thirdOrderNo=HAR1301886673700786176, third_order_no=同, totalAmount=0.01,
  total_amount=0.01, tradeDate=2026-09-28 09:54:51, trade_date=同

读出来三件事,都不是推的:

1. **上游几乎每个字段都发两份**:camelCase + snake_case,值一致。
2. 规范里写的两个名字**两份都不是**:
     platform_trade_no ⇒ 实际 alipay_order_no / alipayOrderNo
     trade_time        ⇒ 实际 trade_date / tradeDate
   这是这份规范第三次被证伪(前两次:只认 subject 不认 title;reason 全篇没写却必填)。
3. **实收金额确实会从通知里来**:actual_money=0.01,而且**只有 snake_case 这一份**,
   没有 camelCase 孪生。此前「三个金额只能从查询路径落库」的结论**只对另外两个成立**
   (手续费/渠道成本至今没在任何一份报文里出现过)。

改法:加 pick(params, keys...) 按次序取第一个非空,收的每个名字都是**实测见过或
规范写过的**。规范那套名字**保留**(platform_trade_no / trade_time 仍先试)——
实测两套名字上游都发,没有任何读数说过哪一套会被撤掉,按「只留本次实测的那套」去删
就是又一次拿推理当读数。

顺带把 two 处旧判断按读数改掉:
* 上一批那行 WARN 的触发条件改成「收完仍缺 platform_trade_no / trade_time,
  或退款成功却没有退款时间」——退款通知的字段名**至今没见过**,不按想象补,
  等退款通知自己报上来;
* 用例 handle_amountsAreNotOverwrittenByNotify 的注释「通知报文不含实收」已被证伪,
  改成它真正钉住的那条不变式:**没送的格不许被清空**。

读数:HarNotifyServiceTest 18 条全绿(新增 3 条:真报文原文、只来 camelCase 一份、
规范那套名字仍被接受);全量 mvn -o test 878 绿 / BUILD SUCCESS。

一条红过的教训记在这里:新加的断言里我写了 LocalDateTime.toString() == "…10:30:00",
实测输出是 "…10:30" —— 秒为 0 时它把秒整段省掉。断言改成直接比 LocalDateTime,
不再经过 toString。
alphaH 2 hafta önce
ebeveyn
işleme
00c927d561

+ 66 - 19
java/src/main/java/com/payment/platform/module/payment/har/service/HarNotifyService.java

@@ -9,6 +9,7 @@ import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
 
+import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.time.OffsetDateTime;
 import java.time.ZoneId;
@@ -82,8 +83,10 @@ public class HarNotifyService {
             return SIGN_FAILED;
         }
 
-        // third_order_no 是我方订单号;上游自己的单号在 order_no 字段
-        String orderNo = params.get("third_order_no");
+        // third_order_no 是我方订单号;上游自己的单号在 order_no 字段。
+        // 两个名字都收:上游实际上对同一个字段发 camelCase + snake_case **两份**
+        // (值一致,2026-09-28 实测),这里两份都认,只来一份时也不至于定位不到订单。
+        String orderNo = pick(params, "third_order_no", "thirdOrderNo");
         if (StrUtil.isBlank(orderNo)) {
             log.error("[汇安融通知] 缺少 third_order_no,无法定位订单: {}", params);
             return SUCCESS;
@@ -108,28 +111,42 @@ public class HarNotifyService {
         }
 
         order.setTradeStatus(newStatus);
-        if (StrUtil.isNotBlank(params.get("order_no"))) {
-            order.setUpstreamOrderNo(params.get("order_no"));
+        String upstreamOrderNo = pick(params, "order_no", "orderNo");
+        if (upstreamOrderNo != null) {
+            order.setUpstreamOrderNo(upstreamOrderNo);
         }
-        if (StrUtil.isNotBlank(params.get("platform_trade_no"))) {
-            order.setPlatformTradeNo(params.get("platform_trade_no"));
+        // 规范里叫 platform_trade_no,上游实际叫 alipay_order_no / alipayOrderNo
+        // —— 2026-09-28 实测(规范第三次被证伪:前两次是 subject、reason)。
+        String platformTradeNo = pick(params, "platform_trade_no", "alipay_order_no", "alipayOrderNo");
+        if (platformTradeNo != null) {
+            order.setPlatformTradeNo(platformTradeNo);
         }
-        order.setTradeTime(parseTime(params.get("trade_time")));
-        order.setRefundTime(parseTime(params.get("refund_time")));
-        OffsetDateTime upstreamCreateTime = parseTime(params.get("create_time"));
+        // 规范里叫 trade_time,上游实际叫 trade_date / tradeDate
+        OffsetDateTime tradeTime = parseTime(pick(params, "trade_time", "trade_date", "tradeDate"));
+        order.setTradeTime(tradeTime);
+        // 退款通知的字段名**至今没见过** —— 只认规范里那个 refund_time,真实叫什么
+        // 等退款通知自己报上来(下面那行 WARN 正是在等它)。不按想象补名字。
+        OffsetDateTime refundTime = parseTime(pick(params, "refund_time"));
+        order.setRefundTime(refundTime);
+        OffsetDateTime upstreamCreateTime = parseTime(pick(params, "create_time"));
         if (upstreamCreateTime != null) {
             order.setUpstreamCreateTime(upstreamCreateTime);
         }
-        // 付款成功/退款成功的通知里,platform_trade_no 与 trade_time 按规范应当在场。
-        // 不在场就说明字段名与我们对不上 —— 这份规范已被证伪过两次(只认 subject 不认
-        // title;reason 全篇没写却必填)。通知的重试窗口只有约 25 秒,错过就再也看不到
-        // 原文,所以异常这件事必须在收到的那一刻记下来。
-        // 每次都报 ⇒ 上游就不送,那时该改的是这句话,不是再加日志。
-        if ((HarOrderStatus.PAID.equals(newStatus) || HarOrderStatus.REFUNDED.equals(newStatus))
-                && (StrUtil.isBlank(params.get("platform_trade_no"))
-                    || StrUtil.isBlank(params.get("trade_time")))) {
-            log.warn("[汇安融通知] 支付/退款成功的通知里缺 platform_trade_no 或 trade_time: "
-                            + "order_no={}, status={}, 全部参数={}",
+        // 实收金额:上游叫 actual_money,而且**只有这一份**,没有 camelCase 孪生。
+        // 它到底是不是「实收」:这一次的 0.01 单里它与 total_amount 相等,分辨不出来 ——
+        // 取它是因为另一个金额字段(total_amount)已经在了,两个同义字段不会并排发。
+        String actualMoney = pick(params, "actual_money");
+        if (actualMoney != null) {
+            order.setRcvAmount(parseAmount(actualMoney));
+        }
+        // 联调期诊断:上面几格填不上,就是字段名又变了。**整份参数**打出来 ——
+        // 通知重试窗口只有约 25 秒,错过就再也看不到原文。
+        // 每次都报 ⇒ 上游压根不送,那时该改的是这句话,不是再加日志。
+        boolean paidOrRefunded = HarOrderStatus.PAID.equals(newStatus)
+                || HarOrderStatus.REFUNDED.equals(newStatus);
+        boolean refundHasNoTime = HarOrderStatus.REFUNDED.equals(newStatus) && refundTime == null;
+        if ((paidOrRefunded && (platformTradeNo == null || tradeTime == null)) || refundHasNoTime) {
+            log.warn("[汇安融通知] 通知里缺关键字段: order_no={}, status={}, 全部参数={}",
                     orderNo, newStatus, new TreeMap<>(params));
         }
         orderService.reschedulePoll(order);
@@ -140,6 +157,36 @@ public class HarNotifyService {
         return SUCCESS;
     }
 
+    /**
+     * 按次序取第一个非空值。
+     *
+     * 存在的理由:上游对同一个字段发 camelCase + snake_case **两份**(2026-09-28 实测,
+     * 值一致),而规范里写的名字有时候两份都不是(platform_trade_no / trade_time)。
+     * 这里收的每一个名字都是**实测见过或规范写过的**,没有一个是照语义推出来的 ——
+     * 这份规范已经被证伪三次,按它推名字就是再造一次同样的缺陷。
+     */
+    private static String pick(Map<String, String> params, String... keys) {
+        for (String key : keys) {
+            if (StrUtil.isNotBlank(params.get(key))) {
+                return params.get(key);
+            }
+        }
+        return null;
+    }
+
+    /** 空串不能被解析成 0,否则对账口径失真 —— 同 HarOrderService#parseAmount */
+    private BigDecimal parseAmount(String value) {
+        if (StrUtil.isBlank(value)) {
+            return null;
+        }
+        try {
+            return new BigDecimal(value.trim());
+        } catch (NumberFormatException e) {
+            log.warn("[汇安融通知] 金额解析失败: {}", value);
+            return null;
+        }
+    }
+
     private OffsetDateTime parseTime(String value) {
         if (StrUtil.isBlank(value)) {
             return null;

+ 90 - 1
java/src/test/java/com/payment/platform/module/payment/har/service/HarNotifyServiceTest.java

@@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
 import java.math.BigDecimal;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
+import java.time.LocalDateTime;
 import java.util.Base64;
 import java.util.LinkedHashMap;
 import java.util.Map;
@@ -266,7 +267,10 @@ class HarNotifyServiceTest {
 
         service.handle(paidNotify());
 
-        // 通知报文不含实收/手续费,这些字段由主动查询补齐,不能被通知抹掉
+        // 这份报文里没有任何金额键 ⇒ 已有的实收金额不能被抹掉。
+        // 原注释写的是「通知报文不含实收/手续费」,**前半句已被证伪**(2026-09-28 实测
+        // 它带 actual_money);没被证伪的是这条不变式:**没送的那格不许被清空**。
+        // 手续费/渠道成本至今没在任何一份报文里出现过。
         assertEquals(new BigDecimal("12.20"), o.getRcvAmount());
     }
 
@@ -279,4 +283,89 @@ class HarNotifyServiceTest {
 
         verify(callbackService).notifyEnterprise(o);
     }
+
+    // ==================== 上游实际字段名(2026-09-28 实测) ====================
+
+    /**
+     * 2026-09-28 那条真实通知的报文,键名一个没改(只省掉 sign —— {@link #signed} 会
+     * 按上游同一套规则重签)。来源是 {@code HarNotifyService} 自己打的那行 WARN。
+     *
+     * 三处与规范对不上,都是这一次读数出来的:
+     * <pre>
+     *   platform_trade_no ⇒ 实际叫 alipayOrderNo / alipay_order_no
+     *   trade_time        ⇒ 实际叫 tradeDate / trade_date
+     *   实收金额          ⇒ 实际叫 actual_money(只有 snake_case 这一份,没有孪生)
+     * </pre>
+     * 其余字段上游几乎都发 camelCase + snake_case **两份**,值一致。
+     */
+    private Map<String, String> realPaidNotify() {
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("actual_money", "0.01");
+        raw.put("alipayOrderNo", "2026092823001417741424011978");
+        raw.put("alipay_order_no", "2026092823001417741424011978");
+        raw.put("merchantCode", "142357140143751171");
+        raw.put("merchant_code", "142357140143751171");
+        raw.put("orderNo", "1156891538702077952");
+        raw.put("order_no", "1156891538702077952");
+        raw.put("status", "1");
+        raw.put("subject", "测试0928");
+        raw.put("thirdOrderNo", "HAR1301886673700786176");
+        raw.put("third_order_no", "HAR1301886673700786176");
+        raw.put("totalAmount", "0.01");
+        raw.put("total_amount", "0.01");
+        raw.put("tradeDate", "2026-09-28 09:54:51");
+        raw.put("trade_date", "2026-09-28 09:54:51");
+        return signed(raw);
+    }
+
+    @Test
+    void handle_realUpstreamNames_landPlatformTradeNoTradeTimeAndRcvAmount() {
+        HarOrderEntity o = order("HAR1301886673700786176", HarOrderStatus.WAIT_PAY);
+        when(orderService.getByOrderNo("HAR1301886673700786176")).thenReturn(o);
+
+        assertEquals("success", service.handle(realPaidNotify()));
+
+        assertEquals(HarOrderStatus.PAID, o.getTradeStatus());
+        assertEquals("1156891538702077952", o.getUpstreamOrderNo());
+        assertEquals("2026092823001417741424011978", o.getPlatformTradeNo());
+        // 直接比 LocalDateTime:不带时区(换台机器不该红),也不踩 toString 的坑 ——
+        // 秒为 0 时 LocalDateTime.toString() 会把秒整段省掉(实测 10:30:00 打成 10:30)
+        assertEquals(LocalDateTime.of(2026, 9, 28, 9, 54, 51), o.getTradeTime().toLocalDateTime());
+        assertEquals(new BigDecimal("0.01"), o.getRcvAmount());
+    }
+
+    @Test
+    void handle_camelCaseOnlyReport_isAcceptedToo() {
+        Map<String, String> raw = new LinkedHashMap<>();
+        raw.put("actual_money", "0.01");
+        raw.put("alipayOrderNo", "PT-9");
+        raw.put("orderNo", "UP-9");
+        raw.put("status", "1");
+        raw.put("thirdOrderNo", "HAR1");
+        raw.put("tradeDate", "2026-09-28 09:54:51");
+
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(orderService.getByOrderNo("HAR1")).thenReturn(o);
+
+        assertEquals("success", service.handle(signed(raw)));
+
+        assertEquals("UP-9", o.getUpstreamOrderNo());
+        assertEquals("PT-9", o.getPlatformTradeNo());
+        assertEquals(LocalDateTime.of(2026, 9, 28, 9, 54, 51), o.getTradeTime().toLocalDateTime());
+        assertEquals(new BigDecimal("0.01"), o.getRcvAmount());
+    }
+
+    @Test
+    void handle_documentedNamesStillAccepted() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.WAIT_PAY);
+        when(orderService.getByOrderNo("HAR1")).thenReturn(o);
+
+        service.handle(paidNotify());
+
+        // 规范里的 platform_trade_no / trade_time 不能删:实测两套名字上游都发,
+        // 没有任何读数说过哪一套会被撤掉 —— 按「只留本次实测的那套」去删,
+        // 就是又一次拿推理当读数,而这份规范已经错过三次。
+        assertEquals("PT-1", o.getPlatformTradeNo());
+        assertEquals(LocalDateTime.of(2026, 9, 16, 10, 30), o.getTradeTime().toLocalDateTime());
+    }
 }