Переглянути джерело

fix(汇安融): 退款补 reason —— 上游必填,而规范里根本没有这一格

2026-09-24 真机实测:不带 reason 发起退款,上游回
「详细参数校验异常,退款原因(reason)不能为空!」整笔被拒。

这一格**在任何规范里都找不到**:5.4(表 8)的 biz 只有 order_no /
third_order_no 两行,kf-request-demo 没有退款示例,两版 docx 转写件
全文搜不到 reason。与先前 subject/title 那次是同一类缺陷 —— 规范
不是全集,上线前只能靠真机把缺的格子撞出来。

顺带一条读数:这条报错本身就证明 kf.alipay.trade.refund 被上游接受
了(请求走到了参数校验,而不是「接口不存在」)。交付说明里那条
「method 取值未被上游确认」到此解除,只剩注释里的依据。

改法(三处):
- HarRefundRequest 加 reason 字段,@JsonProperty("reason"),按上游
  字段名原样发;
- HarOpenRefundDTO 加**选填** reason:不设 @Size —— 长度上限上游没
  说过,编一个出来会把本来能成的调用方挡在门外;
- HarOrderService 调用方留空时补默认值「商户申请退款」,填了就按他
  填的走(这串会进商户账务记录,不该被默认值盖掉)。

调用方现有的请求体不用改,联调可以原地重试。

两向变异(脚本 logs/_har-reason-mutate.py,每次还原后 sha256 与注入
前逐字节一致):
- 去掉默认值 ⇒ 红 1(openRefund_sendsNonBlankReason_upstreamRejectsEmpty,
  实测 reason=null)
- 默认值盖掉调用方 ⇒ 红 1(openRefund_callerReasonWins)
- @JsonProperty 改成 refund_reason ⇒ 红 1(refund_hitsTheAlipayRefundPath,
  实测发出 {"order_no":"UP-1","refund_reason":"…"})

全量 mvn test:873 条,0 失败 0 错误。**要重新部署才生效。**
alphaH 2 тижнів тому
батько
коміт
9c48487961

+ 11 - 2
java/src/main/java/com/payment/platform/module/payment/har/client/dto/HarRefundRequest.java

@@ -7,13 +7,18 @@ import lombok.Data;
 /**
  * 订单退款业务参数 — 对应规范 5.4 请求参数(表 8)。
  *
- * <p><b>只有这两个字段,没有金额</b> —— 表 8 的 biz 就这两行,所以本通道是**全额退款**,
+ * <p><b>没有金额字段</b> —— 表 8 的 biz 没有金额行,所以本通道是**全额退款**,
  * 部分退款上游没有提供入口。哪天上游加了 amount,改动落在这里。
  *
- * <p>表 8 的两个字段「不为空」列都是空的(即都选填),但备注里写了
+ * <p>表 8 的 order_no / third_order_no 两格「不为空」列都是空的(即都选填),但备注里写了
  * 「与订单号同时存在时 忽略该属性」—— 所以两个都给也不会错,只是 order_no 说了算。
  * 我们的口径是优先给 order_no(上游单号),拿不到才退到 third_order_no,同
  * {@code HarOrderService#syncFromUpstream} 的查询口径。
+ *
+ * <p><b>{@code reason} 是线上要求而规范没写的那一格</b>:2026-09-24 真机实测,不带它整笔
+ * 退款被拒(「详细参数校验异常,退款原因(reason)不能为空!」)。表 8 只有两行、demo 没有
+ * 退款示例、两版 docx 转写件全文搜不到 reason。所以这张表**不完整**,别拿它当全集看。
+ * 长度的上限、以及是否还有别的隐藏必填格,至今未知 —— 上游没说过,这里不编。
  */
 @Data
 @JsonInclude(JsonInclude.Include.NON_NULL)
@@ -26,4 +31,8 @@ public class HarRefundRequest {
     /** 第三方订单号(我方 order_no)。与 order_no 同时存在时上游忽略该属性 */
     @JsonProperty("third_order_no")
     private String thirdOrderNo;
+
+    /** 退款原因。上游要求非空(规范未写);由上层保证非空,本类不做默认 */
+    @JsonProperty("reason")
+    private String reason;
 }

+ 9 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOpenRefundDTO.java

@@ -26,4 +26,13 @@ public class HarOpenRefundDTO {
 
     /** 企业自己的订单号 */
     private String outBizNo;
+
+    /**
+     * 退款原因。**选填**,留空由平台补一个默认值。
+     *
+     * <p>上游实际要求它非空(规范漏写),但长度上限未知 ⇒ 不设 @Size:
+     * 编一个长度上限出来,会把「本来能成」的调用方挡在门外,而那是我想象的契约。
+     * 超长由上游拒,报错原样透出。
+     */
+    private String reason;
 }

+ 13 - 0
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -65,6 +65,15 @@ public class HarOrderService {
      */
     private static final int FIRST_POLL_DELAY_SECONDS = 30;
 
+    /**
+     * 调用方没填退款原因时,发给上游的默认值。
+     *
+     * 上游要求 reason 非空(表 8 漏写了这一格),而它的长度上限未知 ⇒ 取一个短的、
+     * 语义中性的中文串。不写「开放 API 调用」之类带我方系统名的文案:这串会进商户的
+     * 账务记录,商户看不懂我们的内部分层。
+     */
+    private static final String DEFAULT_REFUND_REASON = "商户申请退款";
+
     private final HarOrderMapper orderMapper;
     private final HarMerchantService merchantService;
     private final HarClient harClient;
@@ -219,6 +228,10 @@ public class HarOrderService {
         } else {
             req.setThirdOrderNo(order.getOrderNo());
         }
+        // 上游要求 reason 非空(规范没写,2026-09-24 实测)。调用方留空时补默认值 ——
+        // 让每个接我们 API 的人去猜一个必填值,等于把这格变成会随机失败的坑。
+        // 调用方填了就按他填的走:这一格会进商户的账务记录,不该被默认值盖掉。
+        req.setReason(StrUtil.isBlank(dto.getReason()) ? DEFAULT_REFUND_REASON : dto.getReason());
 
         HarResponse response;
         try {

+ 6 - 1
java/src/test/java/com/payment/platform/module/payment/har/client/HarClientOutboundUrlTest.java

@@ -118,11 +118,16 @@ class HarClientOutboundUrlTest {
      *
      * <p>请求体一并钉住:5.4 的 biz 只有 order_no / third_order_no,**没有金额字段**
      * ⇒ 本通道只能全额退款。哪天出现 amount 字段,这条断言会提醒我们上游改了契约。
+     *
+     * <p>{@code reason} 不在这条「契约未变」的保护范围内 —— 它是**线上要求而规范没写**的
+     * 那一格(2026-09-24 实测:不带就整笔被拒)。这里只钉「发得出去、按原字段名发」,
+     * 非空由 {@code HarOrderServiceTest} 保证。
      */
     @Test
     void refund_hitsTheAlipayRefundPath() {
         HarRefundRequest req = new HarRefundRequest();
         req.setOrderNo("UP-1");
+        req.setReason("商户申请退款");
 
         new HarClient(props).refund(req);
 
@@ -131,7 +136,7 @@ class HarClientOutboundUrlTest {
         assertEquals("kf.alipay.trade.refund", method(),
                 "表 8 备注格的 kf.jh.stage.trade.refund 是不可信的那一列。"
                         + "若联调回「method 不存在」,要先去上游更正那张表,别照备注格改。");
-        assertEquals("{\"order_no\":\"UP-1\"}", lastForm.get().get("bizContent"),
+        assertEquals("{\"order_no\":\"UP-1\",\"reason\":\"商户申请退款\"}", lastForm.get().get("bizContent"),
                 "business 参数按上游字段名原样发,不做映射层;未设置的字段不出现(NON_NULL)");
     }
 

+ 39 - 0
java/src/test/java/com/payment/platform/module/payment/har/service/HarOrderServiceTest.java

@@ -1,5 +1,6 @@
 package com.payment.platform.module.payment.har.service;
 
+import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
@@ -640,6 +641,44 @@ class HarOrderServiceTest {
         return d;
     }
 
+    /**
+     * 上游**线上要求**退款的 biz 带非空 reason —— 2026-09-24 真机实测:
+     * 不带就回「详细参数校验异常,退款原因(reason)不能为空!」。
+     *
+     * 这一格**不在任何规范里**:上游表 8 的 biz 只有 order_no / third_order_no 两行,
+     * demo 没有退款示例,两版 docx 转写件全文也搜不到 reason。所以本用例钉的是
+     * **线上行为**,不是规范文本 —— 规范哪天补上,这里也不用改。
+     */
+    @Test
+    void openRefund_sendsNonBlankReason_upstreamRejectsEmpty() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+
+        service.openRefund(refundDto());
+
+        ArgumentCaptor<HarRefundRequest> req = ArgumentCaptor.forClass(HarRefundRequest.class);
+        verify(harClient).refund(req.capture());
+        assertTrue(StrUtil.isNotBlank(req.getValue().getReason()),
+                "调用方没填时必须给默认值,否则整笔退款被上游拒。实测 reason=" + req.getValue().getReason());
+    }
+
+    /** 调用方填了就按他填的走 —— 退款原因会进商户的账务记录,不该被默认值盖掉 */
+    @Test
+    void openRefund_callerReasonWins() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+        HarOpenRefundDTO dto = refundDto();
+        dto.setReason("用户申请退款");
+
+        service.openRefund(dto);
+
+        ArgumentCaptor<HarRefundRequest> req = ArgumentCaptor.forClass(HarRefundRequest.class);
+        verify(harClient).refund(req.capture());
+        assertEquals("用户申请退款", req.getValue().getReason());
+    }
+
     /** 只取 WHERE 段引用到的绑定值,不倒 `paramNameValuePairs` 全集 —— 后者会把 SET 的绑定值也算进来 */
     private static Set<Object> whereBoundValues(UpdateWrapper<?> w) {
         Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")