Ver código fonte

feat(汇安融): 退款原因落库并回显 —— 这一格存的是「我们发出去的那句话」

上游对空 reason 是整笔直接拒(2026-09-24 实测),而 reason 在规范里一格都没有。
9c48487 补了默认值把退款打通,但那句话发出去之后就只有上游知道了 —— 本地
一个字节都没记,页面自然也显示不出来。

这笔把它落下来:

* HarOrderEntity / HarOrderVO 加 refund_reason;
* openRefund 里先把这个串定下来(调用方填的,或留空时补的默认值),抢占那一笔
  条件更新与发往上游的 req.setReason 用的**是同一个串**,不是在两处各算一次 ——
  分开算就有漂移的余地,而漂移的那天没人查得出来「上游收到的」和「我们记的」
  哪句话是真的;
* rollbackRefundPreempt 把它写回 null:上游明确拒单时,页面上不该留着
  一句从来没发生过的退款原因。写 null 只能走 SET 子句 —— 实体的 null 会被
  MyBatis-Plus 的 NOT_NULL 策略整个跳过,那样「清空」是个空操作。

为什么存「我们发的」而不是「上游回的」:上游通知的 15 个字段里没有 reason,
退款响应的 detail 结构上游至今未公开。本地这一行是唯一记得住的地方。

交付 SQL: java/src/main/resources/db/har-order-refund-reason.sql
**部署前必须先执行**,否则 refund_reason 列不存在,每一笔退款都会在抢占那一步
失败(与 har-order-biz.sql 是同一类事故:前端默认带上这一列,后端 insert/update
直接报列不存在)。

顺带两处联调期诊断(只记日志,不改任何行为):
* syncFromUpstream:查询成功、状态已到支付成功/退款成功,三个金额却全空 ⇒
  打出 detail 原文;
* HarNotifyService#handle:支付/退款成功的通知里缺 platform_trade_no 或
  trade_time ⇒ 打出全部参数。
两处都是为了让「上游送了就要落库」这句话**可验证**:这份规范已被证伪过两次
(只认 subject 不认 title;reason 全篇没写却必填),而通知的重试窗口只有约
25 秒,错过就再也看不到原文。每次都报 ⇒ 上游压根不送,那时该改的是那句话,
不是再加日志。

读数:HarOrderServiceTest 50 条全绿(新增 2 条钉住「落库的那句 == 发出去的
那句」与「回退要清空」);全量 mvn -o test 875 绿 / BUILD SUCCESS。
alphaH 2 semanas atrás
pai
commit
0adfc2cc5f

+ 3 - 0
java/src/main/java/com/payment/platform/module/payment/har/dto/HarOrderVO.java

@@ -35,6 +35,8 @@ public class HarOrderVO {
     private String tradeStatus;
     private OffsetDateTime tradeTime;
     private OffsetDateTime refundTime;
+    /** 退款原因(我方发给上游的那个)。非退款单为 null */
+    private String refundReason;
     private OffsetDateTime upstreamCreateTime;
     private BigDecimal rcvAmount;
     private BigDecimal feeAmount;
@@ -69,6 +71,7 @@ public class HarOrderVO {
         vo.setTradeStatus(e.getTradeStatus());
         vo.setTradeTime(e.getTradeTime());
         vo.setRefundTime(e.getRefundTime());
+        vo.setRefundReason(e.getRefundReason());
         vo.setUpstreamCreateTime(e.getUpstreamCreateTime());
         vo.setRcvAmount(e.getRcvAmount());
         vo.setFeeAmount(e.getFeeAmount());

+ 8 - 0
java/src/main/java/com/payment/platform/module/payment/har/entity/HarOrderEntity.java

@@ -75,6 +75,14 @@ public class HarOrderEntity extends PaymentEnterpriseBaseEntity {
 
     private OffsetDateTime refundTime;
 
+    /**
+     * 退款原因 —— 存的是**我们发给上游的那一个**:调用方填的,或留空时补的默认值。
+     *
+     * 上游不会把它送回来(通知的字段清单里没有它,退款响应的 detail 上游未公开),
+     * 所以这一格是本地事实,不是上游回声。
+     */
+    private String refundReason;
+
     private OffsetDateTime upstreamCreateTime;
 
     private BigDecimal rcvAmount;

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

@@ -4,6 +4,7 @@ import cn.hutool.core.util.StrUtil;
 import com.payment.platform.module.payment.har.client.HarSigner;
 import com.payment.platform.module.payment.har.config.HarProperties;
 import com.payment.platform.module.payment.har.entity.HarOrderEntity;
+import com.payment.platform.module.payment.har.enums.HarOrderStatus;
 import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Service;
@@ -119,6 +120,18 @@ public class HarNotifyService {
         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={}, 全部参数={}",
+                    orderNo, newStatus, new TreeMap<>(params));
+        }
         orderService.reschedulePoll(order);
         orderMapper.updateById(order);
 

+ 32 - 7
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -201,13 +201,21 @@ public class HarOrderService {
         // 抢占:条件更新,守卫写在 WHERE 里(返回值不是守卫 —— 见乐收赢模块同款纪律)。
         // 走到这里与真正落库之间没有上游往返,但仍可能有回调/轮询并发改这一行,
         // 所以照旧只允许从「1 支付成功」改起。
+        // 退款原因先定下来:同一句话既要发给上游,也要落本地这一行。
+        // 上游对空 reason 是**整笔直接拒**(2026-09-24 实测),默认值由我们补;
+        // 调用方填了就按他填的走 —— 这一格会进商户的账务记录,不该被默认值盖掉。
+        String reason = StrUtil.isBlank(dto.getReason()) ? DEFAULT_REFUND_REASON : dto.getReason();
+
         order.setTradeStatus(HarOrderStatus.REFUNDING);
         order.setQueryCount(0);
         reschedulePoll(order);      // 「1 已支付」是终态、next_query_time 已为 null,不重排就没人去收敛
+        order.setRefundReason(reason);
         HarOrderEntity patch = new HarOrderEntity();
         patch.setTradeStatus(order.getTradeStatus());
         patch.setQueryCount(order.getQueryCount());
         patch.setNextQueryTime(order.getNextQueryTime());
+        // 与 trade_status 同一笔条件更新:抢占没命中就直接抛了,不会留下「原因存了但退款没发」
+        patch.setRefundReason(reason);
         // update(entity, wrapper) 不经过 AutoFillMetaObjectHandler,updated_time 只能自己写
         patch.setUpdatedTime(OffsetDateTime.now());
         UpdateWrapper<HarOrderEntity> guard = new UpdateWrapper<>();
@@ -228,10 +236,9 @@ public class HarOrderService {
         } else {
             req.setThirdOrderNo(order.getOrderNo());
         }
-        // 上游要求 reason 非空(规范没写,2026-09-24 实测)。调用方留空时补默认值 ——
-        // 让每个接我们 API 的人去猜一个必填值,等于把这格变成会随机失败的坑。
-        // 调用方填了就按他填的走:这一格会进商户的账务记录,不该被默认值盖掉。
-        req.setReason(StrUtil.isBlank(dto.getReason()) ? DEFAULT_REFUND_REASON : dto.getReason());
+        // 与抢占那一笔落库的是**同一个串**,不是在两处各算一次:
+        // 分开算就有漂移的余地,而漂移的那天没人查得出来「上游收到的」和「我们记的」哪句是真的。
+        req.setReason(reason);
 
         HarResponse response;
         try {
@@ -258,14 +265,17 @@ public class HarOrderService {
     /**
      * 回退退款抢占(上游明确拒绝时)。
      *
-     * 用 {@code update(null, wrapper)} 而不是实体:{@code next_query_time} 要写回 **null**
-     * 才能停掉轮询,而 MyBatis-Plus 默认的 NOT_NULL 策略会把实体里的 null 字段整个跳过 ——
-     * 那样这笔订单会被轮询永远捞起。代价是 updated_time 也得显式写(不经过实体就没有自动填充)。
+     * 用 {@code update(null, wrapper)} 而不是实体:{@code next_query_time} 与
+     * {@code refund_reason} 都要写回 **null** 才能表达「这笔退款没有发生」,
+     * 而 MyBatis-Plus 默认的 NOT_NULL 策略会把实体里的 null 字段整个跳过 ——
+     * 那样这笔订单会被轮询永远捞起、页面上还会留着一句从没发生过的退款原因。
+     * 代价是 updated_time 也得显式写(不经过实体就没有自动填充)。
      */
     private void rollbackRefundPreempt(HarOrderEntity order) {
         UpdateWrapper<HarOrderEntity> w = new UpdateWrapper<>();
         w.set("trade_status", HarOrderStatus.PAID)
                 .set("next_query_time", null)
+                .set("refund_reason", null)
                 .set("updated_time", OffsetDateTime.now())
                 .eq("id", order.getId())
                 .eq("trade_status", HarOrderStatus.REFUNDING);
@@ -275,6 +285,7 @@ public class HarOrderService {
         }
         order.setTradeStatus(HarOrderStatus.PAID);
         order.setNextQueryTime(null);
+        order.setRefundReason(null);
     }
 
     /**
@@ -434,6 +445,20 @@ public class HarOrderService {
         HarQueryDetail detail = HarResponseParser.toBean(response.detailRaw(), HarQueryDetail.class);
         if (detail != null) {
             applyQueryDetail(order, detail);
+            // 三个金额只有这一条路径能落库 —— 通知的 15 个字段里没有金额。
+            // 所以「上游送了就要落库」这句话在这里是可验证的:已经付掉/退掉的单,
+            // 三个金额却全空,只可能是「上游没送」或「字段名与我们对不上」。
+            // 后者不是假想:这份规范已被证伪过两次(只认 subject 不认 title;
+            // reason 全篇没写却必填)。detail 原文只在这里出现一次,联调期先留着 ——
+            // 它每次都报,说明上游压根不送,那时该改的是这句话,不是再加日志。
+            if ((HarOrderStatus.PAID.equals(order.getTradeStatus())
+                    || HarOrderStatus.REFUNDED.equals(order.getTradeStatus()))
+                    && order.getRcvAmount() == null
+                    && order.getFeeAmount() == null
+                    && order.getChannelAmount() == null) {
+                log.warn("[汇安融订单] 查询成功但三个金额全空: order_no={}, status={}, detail={}",
+                        order.getOrderNo(), order.getTradeStatus(), response.detailRaw());
+            }
         } else {
             order.setNextQueryTime(OffsetDateTime.now()
                     .plusSeconds(nextDelaySeconds(order.getQueryCount(), false)));

+ 39 - 0
java/src/main/resources/db/har-order-refund-reason.sql

@@ -0,0 +1,39 @@
+-- pay_har_order 新增 refund_reason 列(2026-09-24)
+-- 手工执行: psycopg2 / psql 连接 payment_platform_java 库
+--
+-- 【本脚本尚未执行】执行由用户做。
+--
+-- ── 背景 ─────────────────────────────────────────────────────────────
+-- 这一格存的是**我们发给上游的那句退款原因**:调用方在 10.4 里填的,
+-- 或留空时平台补的默认值「商户申请退款」。
+--
+-- 为什么是「我们发的」而不是「上游回的」:
+--   * 上游通知的字段清单里没有 reason(order_no / merchant_code / third_shop_id /
+--     title / trade_type / third_order_no / status / percent / trade_time / stage_num /
+--     create_time / refund_time / extra_params / platform_trade_no / sign —— 15 个,
+--     没有它);
+--   * 退款响应的 detail 结构上游至今未公开(见 HarClient#refund 的注释)。
+--   所以本地这一行是唯一记得住「这笔退款当时是怎么说的」的地方。
+--
+-- ── 为什么是 varchar(255) ────────────────────────────────────────────
+-- 上游没公布这一格的长度上限,所以请求 DTO 上**故意没有 @Size** ——
+-- 凭空写一个上限出来就是编契约。列宽在这里的作用是**兜底**,不是契约:
+-- 超长时由数据库报错,而不是静默截断(截断等于存一句假话)。
+--
+-- 兜底的方向是安全的:这一笔写入发生在**抢占**那一步,位置在上游调用之前
+-- (见 HarOrderService#openRefund),所以超长时整笔退款在**钱还没动**的时候就失败了,
+-- 不会出现「上游退成功、本地写不进去」。
+--
+-- 选填 ⇒ 不给默认值:没退过款的单就是 NULL,回退(上游拒单)时也会被写回 NULL。
+--
+-- ── 执行 ─────────────────────────────────────────────────────────────
+ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS refund_reason varchar(255);
+
+COMMENT ON COLUMN pay_har_order.refund_reason IS
+    '退款原因 —— 我方发给上游的那一个(调用方填的,或留空时补的默认值)。上游不会回传,非退款单为 NULL';
+
+-- ── 只读核对(执行后跑)──────────────────────────────────────────────
+-- SELECT column_name, data_type, character_maximum_length, is_nullable
+--   FROM information_schema.columns
+--  WHERE table_name = 'pay_har_order' AND column_name = 'refund_reason';
+-- 期望:refund_reason | character varying | 255 | YES

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

@@ -679,6 +679,54 @@ class HarOrderServiceTest {
         assertEquals("用户申请退款", req.getValue().getReason());
     }
 
+    /**
+     * 退款原因要落库。
+     *
+     * 这一格上游**不会送回来**:通知那 15 个字段里没有它,退款响应的 detail
+     * 上游未公开(见 HarClient#refund)。所以要存的就是**我们发给上游的那一个**。
+     *
+     * 落点选在抢占那一笔条件更新里,与 trade_status 同生共死:
+     * 抢占没命中就直接抛了,根本走不到这里 ⇒ 不会出现「原因存了但退款没发」。
+     */
+    @Test
+    void openRefund_preemptPersistsTheReasonWeSend() {
+        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("用户申请退款");
+
+        HarOrderVO vo = service.openRefund(dto);
+
+        ArgumentCaptor<HarOrderEntity> patch = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).update(patch.capture(), any());
+        assertEquals("用户申请退款", patch.getValue().getRefundReason(),
+                "落库的必须是实际发给上游的那个 reason。实测: " + patch.getValue().getRefundReason());
+        assertEquals("用户申请退款", vo.getRefundReason(),
+                "响应体也要带出来,否则调用方看不到自己填的原因。实测: " + vo.getRefundReason());
+    }
+
+    /**
+     * 调用方留空 ⇒ 发出去的是默认值,落库的也必须**是补完的那个默认值**,
+     * 不能是 null —— 否则「上游收到的原因」与「我们能查到的原因」是两句话。
+     */
+    @Test
+    void openRefund_preemptPersistsDefaultReasonWhenCallerBlank() {
+        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<HarOrderEntity> patch = ArgumentCaptor.forClass(HarOrderEntity.class);
+        verify(orderMapper).update(patch.capture(), any());
+        assertTrue(StrUtil.isNotBlank(patch.getValue().getRefundReason()),
+                "空白 reason 会被补成默认值,落库的也必须是补完的那个。实测: "
+                        + patch.getValue().getRefundReason());
+    }
+
     /** 只取 WHERE 段引用到的绑定值,不倒 `paramNameValuePairs` 全集 —— 后者会把 SET 的绑定值也算进来 */
     private static Set<Object> whereBoundValues(UpdateWrapper<?> w) {
         Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")
@@ -827,6 +875,9 @@ class HarOrderServiceTest {
         assertTrue(whereBoundValues(rollback).contains(HarOrderStatus.REFUNDING),
                 "回退也只允许从「退款中」改起 —— 中途到达的「退款成功」通知不许被覆盖。实测: "
                         + whereBoundValues(rollback));
+        // 原因也要一起清掉:这笔退款从没发生过,留着一句原因是页面对着一笔已支付的单说假话
+        assertTrue(rollback.getSqlSet().contains("refund_reason"),
+                "回退要把 reason 清成 null(写 null 只能走 SET 子句)。实测 SET: " + rollback.getSqlSet());
     }
 
     /** 优先用上游单号退款;拿不到时走 third_order_no(同查询口径) */