|
@@ -2,6 +2,7 @@ package com.payment.platform.module.payment.har.service;
|
|
|
|
|
|
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
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.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
import com.payment.platform.common.exception.BusinessException;
|
|
import com.payment.platform.common.exception.BusinessException;
|
|
|
import com.payment.platform.module.payment.har.client.HarApiException;
|
|
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.HarPayRequest;
|
|
|
import com.payment.platform.module.payment.har.client.dto.HarPrecreateRequest;
|
|
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.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.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.HarOrderCreateDTO;
|
|
|
import com.payment.platform.module.payment.har.dto.HarOrderVO;
|
|
import com.payment.platform.module.payment.har.dto.HarOrderVO;
|
|
|
import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
|
|
import com.payment.platform.module.payment.har.entity.HarMerchantEntity;
|
|
@@ -27,6 +30,10 @@ import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
|
|
|
import java.math.BigDecimal;
|
|
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.junit.jupiter.api.Assertions.*;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
@@ -609,4 +616,249 @@ class HarOrderServiceTest {
|
|
|
|
|
|
|
|
assertEquals("HAR1", service.openQuery(q).getOrderNo());
|
|
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());
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|