|
|
@@ -10,6 +10,7 @@ import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
import com.payment.platform.module.payment.stream.OrderEventPublisher;
|
|
|
import com.payment.platform.module.payment.unified.client.UnifiedApiException;
|
|
|
import com.payment.platform.module.payment.unified.client.UnifiedClient;
|
|
|
@@ -30,6 +31,7 @@ import org.slf4j.LoggerFactory;
|
|
|
import java.time.Duration;
|
|
|
import java.time.OffsetDateTime;
|
|
|
|
|
|
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
@@ -41,21 +43,24 @@ import static org.mockito.ArgumentMatchers.eq;
|
|
|
import static org.mockito.ArgumentMatchers.isNull;
|
|
|
import static org.mockito.ArgumentMatchers.same;
|
|
|
import static org.mockito.Mockito.atLeastOnce;
|
|
|
+import static org.mockito.Mockito.lenient;
|
|
|
import static org.mockito.Mockito.never;
|
|
|
import static org.mockito.Mockito.verify;
|
|
|
import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
import static org.mockito.Mockito.when;
|
|
|
|
|
|
/**
|
|
|
- * 退款查单同步与退款轮询(spec §7.3 / §7.4)。
|
|
|
+ * 退款查单同步与退款轮询(spec §7.4)。
|
|
|
*
|
|
|
* <p>本类盯住「不会报错但会锁死额度 / 让轮询空转」的地方:
|
|
|
* <ol>
|
|
|
* <li>配置按订单的 {@code merchant_id} 取(不看启停),不按退款行上的 {@code mch_no};</li>
|
|
|
* <li>只有 {@code applyRefundState} 能写业务状态 —— 查单腿自己写状态会绕过订单联动;</li>
|
|
|
* <li>未知/缺失状态不得落库(R11);</li>
|
|
|
+ * <li>退款成功前必须核对 {@code refundAmount}/{@code currency}(与通知腿同一钱安全规则);</li>
|
|
|
+ * <li>数据损坏:自动腿不抛、手动腿必须抛 {@link BusinessException};</li>
|
|
|
* <li>60 次 / 30 分钟停止后必须把 {@code next_query_time} 置空;</li>
|
|
|
- * <li>上游「退款单不存在」的业务拒绝本期不置 3(设计 §14 #14 未定),
|
|
|
+ * <li>上游「退款单不存在」的业务拒绝本期不置 3(设计 §14 #9 未定),
|
|
|
* 只能保持 0 并记 WARN —— 置 3 会提前释放额度,冒超退风险。</li>
|
|
|
* </ol>
|
|
|
*/
|
|
|
@@ -180,6 +185,17 @@ class UnifiedRefundSyncTest {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /** 金额/币种核对失败必须在退款行留下 AMOUNT_MISMATCH。 */
|
|
|
+ private void assertAmountMismatchMarked() {
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper<UnifiedRefundEntity>> w = updateCaptor();
|
|
|
+ verify(refundMapper, atLeastOnce()).update(isNull(), w.capture());
|
|
|
+ assertTrue(w.getAllValues().stream()
|
|
|
+ .map(LambdaUpdateWrapper::getSqlSet)
|
|
|
+ .anyMatch(s -> s != null && s.contains("err_code")),
|
|
|
+ "核对失败必须落库 err_code,实测: "
|
|
|
+ + w.getAllValues().stream().map(LambdaUpdateWrapper::getSqlSet).toList());
|
|
|
+ }
|
|
|
+
|
|
|
// ────────────────────────────── 配置取用 ──────────────────────────────
|
|
|
|
|
|
@Test
|
|
|
@@ -210,7 +226,8 @@ class UnifiedRefundSyncTest {
|
|
|
stubOrderAndMerchant();
|
|
|
// channelExtra 类敏感字段只允许进白名单快照,不得原样进快照/回调
|
|
|
when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
|
|
|
- "{\"state\":2,\"payAmount\":100,\"refundOrderId\":\"RF1\","
|
|
|
+ "{\"state\":2,\"payAmount\":100,\"refundAmount\":100,\"currency\":\"cny\","
|
|
|
+ + "\"refundOrderId\":\"RF1\","
|
|
|
+ "\"channelExtra\":{\"auth_code\":\"CODE\"},\"openid\":\"OPEN\"}"));
|
|
|
|
|
|
UnifiedRefundEntity applied = refund("2");
|
|
|
@@ -297,6 +314,114 @@ class UnifiedRefundSyncTest {
|
|
|
verifyNoInteractions(tx, publisher, callbackService);
|
|
|
}
|
|
|
|
|
|
+ // ──────────────────────── 金额/币种核对(I1,与通知腿同规则) ────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stateTwo_refundAmountMismatch_doesNotAdvance() {
|
|
|
+ stubOrderAndMerchant();
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
|
|
|
+ "{\"state\":2,\"payAmount\":100,\"refundAmount\":99,\"currency\":\"cny\"}"));
|
|
|
+ // 万一核对被删掉,走 apply 也不至于 NPE —— 本测试只允许「不调 apply」
|
|
|
+ lenient().when(tx.apply(any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
|
|
|
+ false, false, false, refund("2"), null));
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedRefundService.class);
|
|
|
+ try {
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
+ } finally {
|
|
|
+ ((Logger) LoggerFactory.getLogger(UnifiedRefundService.class))
|
|
|
+ .detachAppender(appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals("1", refund.getState(), "退款金额不符不得推进状态");
|
|
|
+ verifyNoInteractions(tx, publisher, callbackService);
|
|
|
+ assertEquals(1, refund.getQueryCount(), "上游响应算一次查询");
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
+ assertAmountMismatchMarked();
|
|
|
+ assertNoDirectStateWrite();
|
|
|
+ assertTrue(appender.list.stream().anyMatch(ev ->
|
|
|
+ ev.getLevel() == Level.ERROR
|
|
|
+ && ev.getFormattedMessage().contains("UR1")
|
|
|
+ && ev.getFormattedMessage().contains("99")),
|
|
|
+ "必须记 ERROR(含退款号与上游金额),实测: "
|
|
|
+ + appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stateTwo_refundAmountMissing_doesNotAdvance() {
|
|
|
+ stubOrderAndMerchant();
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
|
|
|
+ "{\"state\":2,\"payAmount\":100,\"currency\":\"cny\"}"));
|
|
|
+ lenient().when(tx.apply(any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
|
|
|
+ false, false, false, refund("2"), null));
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
+
|
|
|
+ assertEquals("1", refund.getState(), "refundAmount 缺失不得推进状态");
|
|
|
+ verifyNoInteractions(tx, publisher, callbackService);
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
+ assertAmountMismatchMarked();
|
|
|
+ assertNoDirectStateWrite();
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stateTwo_currencyMismatch_doesNotAdvance() {
|
|
|
+ stubOrderAndMerchant();
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
|
|
|
+ "{\"state\":2,\"payAmount\":100,\"refundAmount\":100,\"currency\":\"usd\"}"));
|
|
|
+ lenient().when(tx.apply(any(), any(), any(), any(), any(), any()))
|
|
|
+ .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
|
|
|
+ false, false, false, refund("2"), null));
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
+
|
|
|
+ assertEquals("1", refund.getState(), "币种不符不得推进状态");
|
|
|
+ verifyNoInteractions(tx, publisher, callbackService);
|
|
|
+ assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
+ assertAmountMismatchMarked();
|
|
|
+ assertNoDirectStateWrite();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 正对照:金额/币种一致必须放行。 */
|
|
|
+ @Test
|
|
|
+ void stateTwo_amountAndCurrencyMatch_applies() {
|
|
|
+ stubOrderAndMerchant();
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
|
|
|
+ "{\"state\":2,\"payAmount\":100,\"refundAmount\":100,\"currency\":\"cny\"}"));
|
|
|
+ when(tx.apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull()))
|
|
|
+ .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
|
|
|
+ false, false, false, refund("2"), null));
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
+
|
|
|
+ verify(tx).apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull());
|
|
|
+ verifyNoInteractions(publisher, callbackService);
|
|
|
+ assertEquals(1, refund.getQueryCount());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** currency 在响应中缺失不阻断(T14 brief:若存在才比对)。 */
|
|
|
+ @Test
|
|
|
+ void stateTwo_currencyMissing_applies() {
|
|
|
+ stubOrderAndMerchant();
|
|
|
+ when(client.queryRefund(any(), eq("UR1"))).thenReturn(json(
|
|
|
+ "{\"state\":2,\"payAmount\":100,\"refundAmount\":100}"));
|
|
|
+ when(tx.apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull()))
|
|
|
+ .thenReturn(new UnifiedRefundTxService.ApplyOutcome(
|
|
|
+ false, false, false, refund("2"), null));
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("1");
|
|
|
+ service.syncFromUpstream(refund);
|
|
|
+
|
|
|
+ verify(tx).apply(eq("UR1"), eq("2"), any(JsonNode.class), eq(100L), isNull(), isNull());
|
|
|
+ assertEquals(1, refund.getQueryCount());
|
|
|
+ }
|
|
|
+
|
|
|
// ────────────────────────────── 计数与退避 ──────────────────────────────
|
|
|
|
|
|
@Test
|
|
|
@@ -371,7 +496,7 @@ class UnifiedRefundSyncTest {
|
|
|
verifyNoInteractions(client, merchantService, orderService);
|
|
|
}
|
|
|
|
|
|
- // ──────────────────────── 上游「退款单不存在」(设计 §14 #14) ────────────────────────
|
|
|
+ // ──────────────────────── 上游「退款单不存在」(设计 §14 #9) ────────────────────────
|
|
|
|
|
|
@Test
|
|
|
void businessRejection_keepsStateAndOnlyWarns() {
|
|
|
@@ -389,7 +514,7 @@ class UnifiedRefundSyncTest {
|
|
|
}
|
|
|
|
|
|
assertEquals("0", refund.getState(),
|
|
|
- "设计 §14 #14 未定:业务拒绝本期不得置 3(会提前释放额度)");
|
|
|
+ "设计 §14 #9 未定:业务拒绝本期不得置 3(会提前释放额度)");
|
|
|
verifyNoInteractions(tx, publisher, callbackService);
|
|
|
assertEquals(1, refund.getQueryCount(), "上游回了响应也算一次查询");
|
|
|
assertScheduledIn(refund, UnifiedPollPolicy.nextDelaySeconds(0));
|
|
|
@@ -418,16 +543,91 @@ class UnifiedRefundSyncTest {
|
|
|
verifyNoInteractions(tx, refundMapper, publisher, callbackService);
|
|
|
}
|
|
|
|
|
|
+ // ──────────────────────── 数据损坏:订单/商户配置不存在 ────────────────────────
|
|
|
+
|
|
|
+ private void assertDataDamageHandled(UnifiedRefundEntity refund) {
|
|
|
+ assertNull(refund.getNextQueryTime(), "数据损坏时清空排期,避免每轮空转");
|
|
|
+ assertEquals(0, refund.getQueryCount(), "不计数:数据修复前不白烧重试次数");
|
|
|
+ }
|
|
|
+
|
|
|
@Test
|
|
|
void missingOrder_doesNotQueryAndClearsSchedule() {
|
|
|
when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(null);
|
|
|
|
|
|
UnifiedRefundEntity refund = refund("0");
|
|
|
- service.syncFromUpstream(refund);
|
|
|
+ assertDoesNotThrow(() -> service.syncFromUpstream(refund));
|
|
|
|
|
|
verifyNoInteractions(client, merchantService);
|
|
|
- assertNull(refund.getNextQueryTime(), "数据损坏时清空排期,避免每轮空转");
|
|
|
- assertEquals(0, refund.getQueryCount(), "不计数:数据修复前不白烧重试次数");
|
|
|
+ assertDataDamageHandled(refund);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingMerchantId_autoClearsSchedule() {
|
|
|
+ UnifiedOrderEntity order = order();
|
|
|
+ order.setMerchantId(null);
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order);
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
+ assertDoesNotThrow(() -> service.syncFromUpstream(refund));
|
|
|
+
|
|
|
+ verifyNoInteractions(client, merchantService);
|
|
|
+ assertDataDamageHandled(refund);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void merchantConfigMissing_autoClearsSchedule() {
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order());
|
|
|
+ when(merchantService.requireById(MERCHANT_ID))
|
|
|
+ .thenThrow(new BusinessException(404, "商户配置不存在"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
+ assertDoesNotThrow(() -> service.syncFromUpstream(refund));
|
|
|
+
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ assertDataDamageHandled(refund);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingOrder_manualThrowsBusinessException() {
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(null);
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.syncManually(refund));
|
|
|
+
|
|
|
+ assertEquals(500, ex.getCode(), "手动腿数据损坏必须转失败应答");
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ assertDataDamageHandled(refund);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingMerchantId_manualThrowsBusinessException() {
|
|
|
+ UnifiedOrderEntity order = order();
|
|
|
+ order.setMerchantId(null);
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order);
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.syncManually(refund));
|
|
|
+
|
|
|
+ assertEquals(500, ex.getCode(), "手动腿数据损坏必须转失败应答");
|
|
|
+ verifyNoInteractions(client, merchantService);
|
|
|
+ assertDataDamageHandled(refund);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void merchantConfigMissing_manualThrowsBusinessException() {
|
|
|
+ when(orderService.findByOrderNo(isNull(), eq("UP5"))).thenReturn(order());
|
|
|
+ when(merchantService.requireById(MERCHANT_ID))
|
|
|
+ .thenThrow(new BusinessException(404, "商户配置不存在"));
|
|
|
+
|
|
|
+ UnifiedRefundEntity refund = refund("0");
|
|
|
+ BusinessException ex = assertThrows(BusinessException.class,
|
|
|
+ () -> service.syncManually(refund));
|
|
|
+
|
|
|
+ assertEquals(500, ex.getCode(), "手动腿数据损坏必须转失败应答");
|
|
|
+ verifyNoInteractions(client);
|
|
|
+ assertDataDamageHandled(refund);
|
|
|
}
|
|
|
|
|
|
// ────────────────────────────── 手动同步(R39) ──────────────────────────────
|