Selaa lähdekoodia

feat(汇安融): 退款服务 —— 先抢占「退款中」,再调上游

上游只有全额退款:规范 5.4(表 8)的 biz 只有 order_no / third_order_no,
没有金额字段,所以这里和调用方都没有金额可填。表 9 的 detail 结构上游从未
公开(只有「detail | 返回详细内容 | json」一行),所以不解析它 —— 猜字段名
就是捏造。退款结果由通知(表 12 带 status/refund_time)与主动查询(5.3 的
status 含 4)收敛,这两条路本模块已具备。

状态写入顺序是「先条件更新抢占用 4,再调上游」,不是反过来。反过来的致命
窗口是超时:钱可能已经退掉了,本地却还停在「1 支付成功」—— 那是终态、
next_query_time 早被 reschedulePoll 清成 null,不会再有轮询去问结果;
调用方看到失败一重试,就是对同一笔钱发第二道退款指令。

三条分支与各自的理由:
- 上游成功 ⇒ 停在「4 退款中」。同步返回不代表已到账,收敛到「2 退款成功」
  以通知/查询为准。
- 上游明确拒绝 ⇒ 条件回退到「1 支付成功」(回退也带 WHERE trade_status='4',
  中途到达的退款成功通知不许被覆盖),让调用方能重新发起。
- 结果未知(抛 HarApiException)⇒ **不回退**,保持退款中交给轮询。回退等于
  对调用方宣称这笔钱没退,而超时恰恰意味着可能已经退了。

不加 @Transactional:理由同 create —— 上游抛异常时本地状态必须存活,包了
事务会把抢占一起回滚,正好退回上面那个窗口。

回退用 update(null, wrapper) 而不是实体:next_query_time 要写回 null 才能停掉
轮询,而 MP 默认的 NOT_NULL 策略会把实体里的 null 字段整个跳过,那样这笔订单
会被轮询永远捞起。代价是 updated_time 也得显式写(不经过实体就没有自动填充)。

顺带把开放 API 的定位 + 归属比对抽成 requireOpenOrder,查单与退款共用一份:
归属比对是安全边界,两处各写一份必然漂移,而漂移的那处就是越权退款。
openQuery 的行为逐字未变,既有 8 条查单用例原样全绿。

读数:HarOrderServiceTest 46 条全绿(原 34 + 新增 12)。
变异:去掉抢占守卫里的 trade_status='1' ⇒ 恰好 1 条红
(openRefund_preemptCarriesPaidGuardAndSchedulesPoll,失败消息打出实际 WHERE
只剩 id = ?);还原 ⇒ 46 条全绿。
alphaH 2 viikkoa sitten
vanhempi
sitoutus
b36f3979c9

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

@@ -0,0 +1,29 @@
+package com.payment.platform.module.payment.har.dto;
+
+import lombok.Data;
+
+/**
+ * 开放 API 退款入参。order_no 与 out_biz_no 二选一。
+ *
+ * <p>与 {@link HarOpenOrderQueryDTO} 字段同形但是**两个 DTO**:它们是两个接口的契约,
+ * 各自会独立演进(退款的 biz 现在没有金额,上游哪天开了部分退款,金额落在退款 DTO 上;
+ * 查单那边则可能加日期筛选)。合成一个等于把两条演进线焊死。
+ *
+ * <p>本通道**只有全额退款** —— 上游 5.4 的 biz 只有 order_no / third_order_no,
+ * 没有金额字段,所以这里也没有金额可填。
+ */
+@Data
+public class HarOpenRefundDTO {
+
+    /** 选填。为空 ⇒ 按**租户**范围寻址(同一租户下不再区分企业) */
+    private String enterpriseId;
+
+    /** 选填。本租户配了多个商户号时必须指定,且命中的订单必须正是这个商户的 */
+    private String merchantCode;
+
+    /** 我方订单号(下单响应里返回的 order_no) */
+    private String orderNo;
+
+    /** 企业自己的订单号 */
+    private String outBizNo;
+}

+ 135 - 9
java/src/main/java/com/payment/platform/module/payment/har/service/HarOrderService.java

@@ -2,11 +2,13 @@ package com.payment.platform.module.payment.har.service;
 
 import cn.hutool.core.util.StrUtil;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.common.response.PageResult;
 import com.payment.platform.common.utils.SnowflakeIdGenerator;
 import com.payment.platform.core.security.LoginUser;
+import com.payment.platform.module.payment.har.client.HarApiException;
 import com.payment.platform.module.payment.har.client.HarClient;
 import com.payment.platform.module.payment.har.client.HarResponse;
 import com.payment.platform.module.payment.har.client.HarResponseParser;
@@ -15,7 +17,9 @@ import com.payment.platform.module.payment.har.client.dto.HarPrecreateDetail;
 import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
 import com.payment.platform.module.payment.har.client.dto.HarQueryDetail;
 import com.payment.platform.module.payment.har.client.dto.HarQueryRequest;
+import com.payment.platform.module.payment.har.client.dto.HarRefundRequest;
 import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOpenRefundDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderQueryDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderVO;
@@ -150,11 +154,133 @@ public class HarOrderService {
      * 越权与不存在都返回同一个「订单不存在」,避免通过错误信息探测他人订单是否存在。
      */
     public HarOrderVO openQuery(HarOpenOrderQueryDTO q) {
+        return HarOrderVO.of(requireOpenOrder(
+                q.getOrderNo(), q.getOutBizNo(), q.getEnterpriseId(), q.getMerchantCode()));
+    }
+
+    /**
+     * 开放 API 退款(全额)。
+     *
+     * <p><b>上游只有全额退款</b>:规范 5.4(表 8)的 biz 只有 order_no / third_order_no,
+     * 没有金额字段,所以本方法与调用方都没有金额可填。
+     *
+     * <p><b>状态写入顺序是「先抢占、后调上游」,不是反过来。</b> 反过来(先调上游再写状态)
+     * 的致命窗口是超时:钱可能已经退掉了,而本地还停在「1 支付成功」—— 那是终态、
+     * next_query_time 已被清空、不会再有轮询去问结果,调用方看到「失败」一重试
+     * 就是对同一笔钱发第二道退款指令。先抢占则超时后停在「4 退款中」这个**非终态**上,
+     * 轮询会去上游问出结果(5.3 的 status 含 4)。
+     *
+     * <p><b>不加 @Transactional</b>:同 {@link #create} 的理由 —— 上游抛异常时本地状态必须
+     * 存活,包了事务就会把抢占一起回滚,正好退回上面那个窗口。
+     *
+     * <p><b>不解析上游响应的 detail</b>:表 9 只写了「detail | 返回详细内容 | json」一行,
+     * 字段结构上游从未公开,猜字段名就是捏造。退款结果由通知(表 12 带 status / refund_time)
+     * 与主动查询收敛 —— 这两条路本模块已经具备。
+     *
+     * @return 退款受理后的订单快照,trade_status=4 退款中。**不代表已退款到账**,
+     *         到账以收敛后的「2 退款成功」为准
+     */
+    public HarOrderVO openRefund(HarOpenRefundDTO dto) {
+        HarOrderEntity order = requireOpenOrder(
+                dto.getOrderNo(), dto.getOutBizNo(), dto.getEnterpriseId(), dto.getMerchantCode());
+
+        if (!HarOrderStatus.PAID.equals(order.getTradeStatus())) {
+            // 尤其「2 退款成功」:再退一次就是重复退款
+            throw new BusinessException("订单当前状态不可退款: " + order.getTradeStatus());
+        }
+
+        // 抢占:条件更新,守卫写在 WHERE 里(返回值不是守卫 —— 见乐收赢模块同款纪律)。
+        // 走到这里与真正落库之间没有上游往返,但仍可能有回调/轮询并发改这一行,
+        // 所以照旧只允许从「1 支付成功」改起。
+        order.setTradeStatus(HarOrderStatus.REFUNDING);
+        order.setQueryCount(0);
+        reschedulePoll(order);      // 「1 已支付」是终态、next_query_time 已为 null,不重排就没人去收敛
+        HarOrderEntity patch = new HarOrderEntity();
+        patch.setTradeStatus(order.getTradeStatus());
+        patch.setQueryCount(order.getQueryCount());
+        patch.setNextQueryTime(order.getNextQueryTime());
+        // update(entity, wrapper) 不经过 AutoFillMetaObjectHandler,updated_time 只能自己写
+        patch.setUpdatedTime(OffsetDateTime.now());
+        UpdateWrapper<HarOrderEntity> guard = new UpdateWrapper<>();
+        guard.eq("id", order.getId())
+                .eq("trade_status", HarOrderStatus.PAID);
+        if (orderMapper.update(patch, guard) == 0) {
+            // 已被并发改过(回调刚写了退款成功 / 轮询刚写了支付成功…)⇒ 不覆盖,也不再发退款指令
+            log.info("[汇安融退款] 抢占未命中(订单状态已变更),不发起退款: order_no={}, status={}",
+                    order.getOrderNo(), order.getTradeStatus());
+            throw new BusinessException("订单当前状态不可退款: " + order.getTradeStatus());
+        }
+
+        HarRefundRequest req = new HarRefundRequest();
+        // 优先用上游单号;拿不到时用我方单号走 third_order_no(同查询口径,
+        // 下单超时的订单本地根本没有上游单号)。两个都给也不会错,上游以 order_no 为准。
+        if (StrUtil.isNotBlank(order.getUpstreamOrderNo())) {
+            req.setOrderNo(order.getUpstreamOrderNo());
+        } else {
+            req.setThirdOrderNo(order.getOrderNo());
+        }
+
+        HarResponse response;
+        try {
+            response = harClient.refund(req);
+        } catch (HarApiException e) {
+            // 结果未知(超时/断连):**不回退**。超时恰恰意味着上游可能已经退成功了,
+            // 回退成「支付成功」等于对调用方宣称这笔钱没退。留在「退款中」让轮询去问。
+            log.warn("[汇安融退款] 上游未响应,结果未知,订单保持退款中待收敛: order_no={}, err={}",
+                    order.getOrderNo(), e.getMessage());
+            throw new BusinessException(500,
+                    "退款结果未知(上游未响应),订单已置为退款中,请稍后查询订单状态确认");
+        }
+
+        if (!response.isSuccess()) {
+            // 上游明确拒绝 ⇒ 条件回退到「支付成功」,让调用方能重新发起。
+            // 回退同样带守卫:中途到达的「退款成功」通知不许被覆盖。
+            rollbackRefundPreempt(order);
+            throw new BusinessException("退款失败: " + response.msg());
+        }
+
+        return HarOrderVO.of(order);
+    }
+
+    /**
+     * 回退退款抢占(上游明确拒绝时)。
+     *
+     * 用 {@code update(null, wrapper)} 而不是实体:{@code next_query_time} 要写回 **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("updated_time", OffsetDateTime.now())
+                .eq("id", order.getId())
+                .eq("trade_status", HarOrderStatus.REFUNDING);
+        if (orderMapper.update(null, w) == 0) {
+            log.info("[汇安融退款] 回退未命中(状态已非退款中),不覆盖: order_no={}", order.getOrderNo());
+            return;
+        }
+        order.setTradeStatus(HarOrderStatus.PAID);
+        order.setNextQueryTime(null);
+    }
+
+    /**
+     * 按开放 API 的口径取一笔订单:定位 + 归属比对。
+     *
+     * 查单与退款**共用这一份** —— 归属比对是安全边界,两处各写一份必然会漂移,
+     * 而漂移的那一处就是越权读/越权退款。
+     *
+     * 除 TenantInnerInterceptor 施加的租户过滤外,额外比对 enterprise_id 与 merchant_code:
+     * 同一租户下可能有多个企业、多个商户,只靠租户过滤会让 A 企业动到 B 企业的订单。
+     * 越权与不存在都返回同一个「订单不存在」,避免通过错误信息探测他人订单是否存在。
+     */
+    private HarOrderEntity requireOpenOrder(String orderNo, String outBizNo,
+                                            String enterpriseId, String merchantCode) {
         HarOrderEntity order;
-        if (StrUtil.isNotBlank(q.getOrderNo())) {
-            order = getByOrderNo(q.getOrderNo());
-        } else if (StrUtil.isNotBlank(q.getOutBizNo())) {
-            order = getByOutBizNo(q.getEnterpriseId(), q.getOutBizNo());
+        if (StrUtil.isNotBlank(orderNo)) {
+            order = getByOrderNo(orderNo);
+        } else if (StrUtil.isNotBlank(outBizNo)) {
+            order = getByOutBizNo(enterpriseId, outBizNo);
         } else {
             throw new BusinessException("order_no 与 out_biz_no 至少填一个");
         }
@@ -164,18 +290,18 @@ public class HarOrderService {
         // 只有**传了** enterprise_id 才做企业维度的比对;不传即按租户范围
         // (与乐收赢一致 —— 那边只有租户这一维)。越权与不存在返回同一句话,
         // 避免通过错误信息探测他人订单是否存在。
-        if (StrUtil.isNotBlank(q.getEnterpriseId())
-                && !StrUtil.equals(order.getEnterpriseId(), q.getEnterpriseId())) {
+        if (StrUtil.isNotBlank(enterpriseId)
+                && !StrUtil.equals(order.getEnterpriseId(), enterpriseId)) {
             throw new BusinessException("订单不存在");
         }
         // 商户维度同理(2026-09-24 一租户多商户):传了就只认这个商户的订单。
         // 少了这句,调用方指定 M700 却可能收到 M701 的单 —— 返回体里的
         // merchant_code 与请求对不上,而调用方没有任何办法察觉。
-        if (StrUtil.isNotBlank(q.getMerchantCode())
-                && !StrUtil.equals(order.getMerchantCode(), q.getMerchantCode())) {
+        if (StrUtil.isNotBlank(merchantCode)
+                && !StrUtil.equals(order.getMerchantCode(), merchantCode)) {
             throw new BusinessException("订单不存在");
         }
-        return HarOrderVO.of(order);
+        return order;
     }
 
     /**

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

@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.har.service;
 
 import com.baomidou.mybatisplus.core.MybatisConfiguration;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
 import com.payment.platform.common.exception.BusinessException;
 import com.payment.platform.module.payment.har.client.HarApiException;
@@ -10,7 +11,9 @@ import com.payment.platform.module.payment.har.client.HarResponse;
 import com.payment.platform.module.payment.har.client.dto.HarPayRequest;
 import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
 import com.payment.platform.module.payment.har.client.dto.HarQueryRequest;
+import com.payment.platform.module.payment.har.client.dto.HarRefundRequest;
 import com.payment.platform.module.payment.har.dto.HarOpenOrderQueryDTO;
+import com.payment.platform.module.payment.har.dto.HarOpenRefundDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderCreateDTO;
 import com.payment.platform.module.payment.har.dto.HarOrderVO;
 import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
@@ -27,6 +30,10 @@ import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 
 import java.math.BigDecimal;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 import static org.junit.jupiter.api.Assertions.*;
 import static org.mockito.ArgumentMatchers.any;
@@ -609,4 +616,249 @@ class HarOrderServiceTest {
 
         assertEquals("HAR1", service.openQuery(q).getOrderNo());
     }
+
+    // ==================== 开放 API 退款 ====================
+    //
+    // 上游 5.4(表 8)的 biz 只有 order_no / third_order_no,**没有金额字段** ⇒ 只有全额退款。
+    // 表 9 响应的 detail 结构上游未公开 ⇒ 本模块不解析它,退款结果靠通知(表 12 带 status/refund_time)
+    // 与主动查询(5.3 的 status 含 4 退款中)收敛。
+
+    private static final long ORDER_ID = 9L;
+
+    /** 退款测试用的订单:必须带 id —— 抢占与回退都是 `WHERE id=?` 的条件更新 */
+    private HarOrderEntity paidOrder() {
+        HarOrderEntity o = order("HAR1", HarOrderStatus.PAID);
+        o.setId(ORDER_ID);
+        o.setMerchantCode("M001");
+        return o;
+    }
+
+    private HarOpenRefundDTO refundDto() {
+        HarOpenRefundDTO d = new HarOpenRefundDTO();
+        d.setEnterpriseId("E1");
+        d.setOrderNo("HAR1");
+        return d;
+    }
+
+    /** 只取 WHERE 段引用到的绑定值,不倒 `paramNameValuePairs` 全集 —— 后者会把 SET 的绑定值也算进来 */
+    private static Set<Object> whereBoundValues(UpdateWrapper<?> w) {
+        Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")
+                .matcher(w.getSqlSegment());
+        Set<Object> out = new LinkedHashSet<>();
+        while (m.find()) {
+            out.add(w.getParamNameValuePairs().get(m.group(1)));
+        }
+        return out;
+    }
+
+    /**
+     * 抢占必须**先于**上游调用。
+     *
+     * 顺序反过来的致命窗口是超时:钱可能已经退了,而本地还停在「1 支付成功」——
+     * 那是终态、不轮询(见 reschedulePoll),调用方一重试就是第二笔退款指令。
+     */
+    @Test
+    void openRefund_preemptsToRefundingBeforeCallingUpstream() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+
+        service.openRefund(refundDto());
+
+        InOrder inOrder = inOrder(orderMapper, harClient);
+        inOrder.verify(orderMapper).update(any(), any());
+        inOrder.verify(harClient).refund(any());
+    }
+
+    /**
+     * 抢占是条件更新:`WHERE id=? AND trade_status='1'`,且**排入轮询**。
+     *
+     * 「1 已支付」是终态,next_query_time 早被 reschedulePoll 清成 null,
+     * 而不重排的话轮询按 IS NOT NULL 捞单,这笔退款就永远没人去问结果。
+     */
+    @Test
+    void openRefund_preemptCarriesPaidGuardAndSchedulesPoll() {
+        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<HarOrderEntity> patch = ArgumentCaptor.forClass(HarOrderEntity.class);
+        ArgumentCaptor<UpdateWrapper> guard = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper).update(patch.capture(), guard.capture());
+
+        assertEquals(HarOrderStatus.REFUNDING, patch.getValue().getTradeStatus());
+        assertNotNull(patch.getValue().getNextQueryTime(), "抢占时就地排期,否则收敛不了");
+        assertEquals(0, patch.getValue().getQueryCount(), "退避计数归零:退款要尽快问出结果");
+        assertNotNull(patch.getValue().getUpdatedTime(), "update(entity,wrapper) 之外的字段不会自动填充");
+
+        String where = guard.getValue().getSqlSegment();
+        assertTrue(where.contains("id") && where.contains("trade_status"),
+                "守卫只能写在 WHERE 里(返回值不是守卫)。实测: " + where);
+        assertTrue(whereBoundValues(guard.getValue()).contains(HarOrderStatus.PAID),
+                "只允许从「支付成功」抢占。实测: " + whereBoundValues(guard.getValue()));
+        assertTrue(whereBoundValues(guard.getValue()).contains(ORDER_ID),
+                "必须钉住这一行。实测: " + whereBoundValues(guard.getValue()));
+    }
+
+    /** 抢占没命中(通知/轮询已改过状态)⇒ 拒绝,且**不得**再向上游发退款指令 */
+    @Test
+    void openRefund_preemptMissed_throwsAndDoesNotCallUpstream() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(0);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openRefund(refundDto()));
+
+        assertTrue(e.getMessage().contains("不可退款"), "实测: " + e.getMessage());
+        verify(harClient, never()).refund(any());
+    }
+
+    /** 非「支付成功」的订单一律不受理 —— 尤其 2 退款成功:再退一次就是重复退款 */
+    @Test
+    void openRefund_orderNotPaid_rejectedWithoutTouchingUpstream() {
+        for (String status : new String[]{HarOrderStatus.WAIT_PAY, HarOrderStatus.REFUNDING,
+                HarOrderStatus.REFUNDED, HarOrderStatus.CLOSED}) {
+            clearInvocations(orderMapper, harClient);
+            HarOrderEntity o = paidOrder();
+            o.setTradeStatus(status);
+            when(orderMapper.selectOne(any())).thenReturn(o);
+
+            BusinessException e = assertThrows(BusinessException.class,
+                    () -> service.openRefund(refundDto()), "状态 " + status + " 不该被受理");
+
+            assertTrue(e.getMessage().contains("不可退款"), "状态 " + status + " 实测: " + e.getMessage());
+            verify(orderMapper, never()).update(any(), any());
+            verify(harClient, never()).refund(any());
+        }
+    }
+
+    /** 同步返回不代表已退款到账:停在「退款中」,由通知/轮询收敛到「2 退款成功」 */
+    @Test
+    void openRefund_upstreamSuccess_staysRefunding() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("10000", null));
+
+        HarOrderVO vo = service.openRefund(refundDto());
+
+        assertEquals(HarOrderStatus.REFUNDING, vo.getTradeStatus());
+    }
+
+    /**
+     * 结果未知(超时)**不回退** —— 这是整个顺序设计的理由所在。
+     *
+     * 回退成「支付成功」等于宣称这笔钱没退,而超时恰恰意味着上游可能已经退了。
+     */
+    @Test
+    void openRefund_upstreamTimeout_keepsRefundingState() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenThrow(new HarApiException("read timeout"));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openRefund(refundDto()));
+
+        assertTrue(e.getMessage().contains("结果未知"), "实测: " + e.getMessage());
+        verify(orderMapper, never()).update(isNull(), any());
+    }
+
+    /** 上游明确拒绝 ⇒ 条件回退到「支付成功」,好让调用方能重新发起 */
+    @Test
+    void openRefund_upstreamNonSuccess_rollsBackToPaid() {
+        when(orderMapper.selectOne(any())).thenReturn(paidOrder());
+        when(orderMapper.update(any(), any())).thenReturn(1);
+        when(harClient.refund(any())).thenReturn(response("40004", null));
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openRefund(refundDto()));
+
+        assertTrue(e.getMessage().contains("退款失败"), "实测: " + e.getMessage());
+
+        ArgumentCaptor<HarOrderEntity> patch = ArgumentCaptor.forClass(HarOrderEntity.class);
+        ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
+        verify(orderMapper, times(2)).update(patch.capture(), w.capture());
+        assertNotNull(patch.getAllValues().get(0), "第一次是抢占,带 patch 实体");
+        assertNull(patch.getAllValues().get(1), "第二次是回退:写 null 只能走 update(null, wrapper)");
+        UpdateWrapper<?> rollback = w.getAllValues().get(1);
+        assertTrue(rollback.getSqlSet().contains("trade_status"), "实测 SET: " + rollback.getSqlSet());
+        assertTrue(rollback.getSqlSet().contains("next_query_time"),
+                "回退到终态必须把轮询停掉,实测 SET: " + rollback.getSqlSet());
+        assertTrue(whereBoundValues(rollback).contains(HarOrderStatus.REFUNDING),
+                "回退也只允许从「退款中」改起 —— 中途到达的「退款成功」通知不许被覆盖。实测: "
+                        + whereBoundValues(rollback));
+    }
+
+    /** 优先用上游单号退款;拿不到时走 third_order_no(同查询口径) */
+    @Test
+    void openRefund_prefersUpstreamOrderNo() {
+        HarOrderEntity o = paidOrder();
+        o.setUpstreamOrderNo("UP-1");
+        when(orderMapper.selectOne(any())).thenReturn(o);
+        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());
+        assertEquals("UP-1", req.getValue().getOrderNo());
+        assertNull(req.getValue().getThirdOrderNo(), "同 5.3/5.4 的备注:两个都给时上游忽略 third_order_no");
+    }
+
+    @Test
+    void openRefund_fallsBackToThirdOrderNo_whenUpstreamOrderNoUnknown() {
+        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());
+        assertNull(req.getValue().getOrderNo());
+        assertEquals("HAR1", req.getValue().getThirdOrderNo());
+    }
+
+    /** 归属口径与开放 API 查单**逐字相同**(两处共用 requireOpenOrder) */
+    @Test
+    void openRefund_otherEnterpriseOrder_throwsOrderNotFound() {
+        HarOrderEntity other = paidOrder();
+        other.setEnterpriseId("E2");
+        when(orderMapper.selectOne(any())).thenReturn(other);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openRefund(refundDto()));
+
+        assertEquals("订单不存在", e.getMessage(), "越权与不存在同一个说法");
+        verify(harClient, never()).refund(any());
+    }
+
+    @Test
+    void openRefund_otherMerchantOrder_throwsOrderNotFound() {
+        HarOrderEntity other = paidOrder();
+        other.setMerchantCode("M701");
+        HarOpenRefundDTO dto = refundDto();
+        dto.setMerchantCode("M700");
+        when(orderMapper.selectOne(any())).thenReturn(other);
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openRefund(dto));
+
+        assertEquals("订单不存在", e.getMessage());
+        verify(harClient, never()).refund(any());
+    }
+
+    @Test
+    void openRefund_neitherKeyProvided_throws() {
+        HarOpenRefundDTO dto = new HarOpenRefundDTO();
+        dto.setEnterpriseId("E1");
+
+        BusinessException e = assertThrows(BusinessException.class,
+                () -> service.openRefund(dto));
+
+        assertTrue(e.getMessage().contains("至少填一个"), "实测: " + e.getMessage());
+        verify(orderMapper, never()).selectOne(any());
+    }
 }