|
|
@@ -0,0 +1,817 @@
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
+
|
|
|
+import ch.qos.logback.classic.Level;
|
|
|
+import ch.qos.logback.classic.Logger;
|
|
|
+import ch.qos.logback.classic.spi.ILoggingEvent;
|
|
|
+import ch.qos.logback.core.read.ListAppender;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.payment.platform.common.exception.BusinessException;
|
|
|
+import com.payment.platform.module.payment.unified.client.UnifiedSigner;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedMerchantEntity;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedOrderEntity;
|
|
|
+import com.payment.platform.module.payment.unified.entity.UnifiedRefundEntity;
|
|
|
+import com.payment.platform.module.payment.unified.enums.UnifiedNotifyOutcome;
|
|
|
+import com.payment.platform.module.payment.unified.enums.UnifiedOrderStatus;
|
|
|
+import com.payment.platform.module.payment.unified.enums.UnifiedRefundStatus;
|
|
|
+import com.payment.platform.module.payment.unified.util.UnifiedUpstreamSnapshot;
|
|
|
+import org.junit.jupiter.api.BeforeEach;
|
|
|
+import org.junit.jupiter.api.Test;
|
|
|
+import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
+import org.mockito.ArgumentCaptor;
|
|
|
+import org.mockito.Mock;
|
|
|
+import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
+import java.util.Map;
|
|
|
+import java.util.stream.Collectors;
|
|
|
+
|
|
|
+import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertFalse;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNotSame;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.ArgumentMatchers.same;
|
|
|
+import static org.mockito.Mockito.doThrow;
|
|
|
+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 §4.4 / §7.5,裁定 R26/R31/R35/R41)。
|
|
|
+ *
|
|
|
+ * <p>本类盯住「验签/绑定不严会认假通知、金额核对不严会把少付标成已付」:
|
|
|
+ * <ol>
|
|
|
+ * <li>验签失败、商户未知、appId 不符、行归属不符 —— 全部 401,且不得碰订单/退款;</li>
|
|
|
+ * <li>{@code state} 缺失或空白回退 {@code orderState};两者都无/非法 → WARN + SUCCESS,
|
|
|
+ * 绝不落库(R26 / R11);</li>
|
|
|
+ * <li>订单 2/5 必须核对 {@code amount}/{@code currency},不符 → FAIL 且只写错误码;</li>
|
|
|
+ * <li>退款 2 必须核对 {@code payAmount}/{@code refundAmount}/{@code currency};</li>
|
|
|
+ * <li>租户比较用值相等(R31);快照只含白名单、不含 channelExtra(R35);</li>
|
|
|
+ * <li>未知单、重复通知 → SUCCESS(幂等应答),处理异常 → FAIL。</li>
|
|
|
+ * </ol>
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class UnifiedNotifyServiceTest {
|
|
|
+
|
|
|
+ private static final String MCH_NO = "M9001";
|
|
|
+ private static final String APP_ID = "APP-9001";
|
|
|
+ private static final String SECRET = "secret-9001";
|
|
|
+ private static final String ORDER_NO = "UN202610200001";
|
|
|
+ private static final String REFUND_NO = "UR202610200001";
|
|
|
+
|
|
|
+ /** 超出 Long 缓存,保证两个 Long 对象引用不同 —— 才能证明比较用的是值而不是引用。 */
|
|
|
+ private static final Long CFG_TENANT = Long.valueOf(1000L);
|
|
|
+
|
|
|
+ @Mock private UnifiedMerchantService merchantService;
|
|
|
+ @Mock private UnifiedOrderService orderService;
|
|
|
+ @Mock private UnifiedRefundService refundService;
|
|
|
+
|
|
|
+ private UnifiedNotifyService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ service = new UnifiedNotifyService(merchantService, orderService, refundService);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 订单通知:闸门 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void blankMchNo_returnsUnauthorized() {
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.remove("mchNo");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(params)),
|
|
|
+ "没有商户号就无从验签,必须 401");
|
|
|
+ verifyNoInteractions(merchantService, orderService, refundService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownMchNo_returnsUnauthorized() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO))
|
|
|
+ .thenThrow(new BusinessException(404, "商户不存在"));
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(orderParams())),
|
|
|
+ "未配置的商户号必须 401");
|
|
|
+ verifyNoInteractions(orderService, refundService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void badSignature_returnsUnauthorized_andNeverTouchesBusinessRows() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("sign", "00000000000000000000000000000000");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(params),
|
|
|
+ "签名不符必须 401");
|
|
|
+ verifyNoInteractions(orderService, refundService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void appIdMismatch_returnsUnauthorized() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("appId", "APP-OTHER");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(params)),
|
|
|
+ "appId 带了且不符必须 401");
|
|
|
+ verifyNoInteractions(orderService, refundService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void appIdMissing_warnsAndContinues() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.remove("appId");
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
|
|
|
+ UnifiedNotifyOutcome outcome;
|
|
|
+ try {
|
|
|
+ outcome = service.handleOrder(signed(params));
|
|
|
+ } finally {
|
|
|
+ detachLog(UnifiedNotifyService.class, appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome, "appId 是可选项,缺省不得拒绝");
|
|
|
+ assertTrue(hasLevel(appender, Level.WARN), "缺 appId 要留 WARN");
|
|
|
+ verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
|
|
|
+ eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 订单通知:状态与分发 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void validNotify_appliesStateAndSnapshot() {
|
|
|
+ UnifiedMerchantEntity cfg = cfg();
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg);
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ assertNotSame(order.getTenantId(), cfg.getTenantId(),
|
|
|
+ "前提:两个租户号是不同 Long 对象,引用比较会漏判");
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS,
|
|
|
+ service.handleOrder(signed(orderParams())));
|
|
|
+
|
|
|
+ ArgumentCaptor<JsonNode> snapshot = ArgumentCaptor.forClass(JsonNode.class);
|
|
|
+ verify(orderService).applyOrderState(same(order), eq("2"), snapshot.capture(),
|
|
|
+ eq("0"), eq("ok"));
|
|
|
+ verify(orderService, never()).markAmountMismatch(any(), any());
|
|
|
+ assertNotNull(snapshot.getValue());
|
|
|
+ assertTrue(UnifiedUpstreamSnapshot.toJson(snapshot.getValue()).contains("\"payOrderId\":\"P-1\""),
|
|
|
+ "快照要带白名单内的上游单号");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stateMissing_fallsBackToOrderState() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.remove("state");
|
|
|
+ params.put("orderState", "2");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)));
|
|
|
+
|
|
|
+ verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
|
|
|
+ eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void stateBlank_fallsBackToOrderState() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("state", " ");
|
|
|
+ params.put("orderState", "2");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)),
|
|
|
+ "R26:空白 state 必须回退 orderState");
|
|
|
+
|
|
|
+ verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
|
|
|
+ eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownState_returnsSuccessWithoutUpdate() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("state", "99");
|
|
|
+
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
|
|
|
+ UnifiedNotifyOutcome outcome;
|
|
|
+ try {
|
|
|
+ outcome = service.handleOrder(signed(params));
|
|
|
+ } finally {
|
|
|
+ detachLog(UnifiedNotifyService.class, appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome, "未知状态只能应答 SUCCESS");
|
|
|
+ assertTrue(hasLevel(appender, Level.WARN), "未知状态要留 WARN");
|
|
|
+ verifyNoInteractions(orderService, refundService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void missingStateAndOrderState_returnsSuccessWithoutUpdate() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.remove("state");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)),
|
|
|
+ "没有任何状态字段也只能应答 SUCCESS");
|
|
|
+ verifyNoInteractions(orderService, refundService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void unknownOrder_returnsSuccessAndLogsNoSensitiveValues() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(null);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("authCode", "SECRET-AUTH-123");
|
|
|
+ params.put("channelExtra", "{\"openid\":\"OPENID-SECRET-456\"}");
|
|
|
+ params.put("buyerUserId", "BUYER-SECRET-789");
|
|
|
+
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
|
|
|
+ UnifiedNotifyOutcome outcome;
|
|
|
+ try {
|
|
|
+ outcome = service.handleOrder(signed(params));
|
|
|
+ } finally {
|
|
|
+ detachLog(UnifiedNotifyService.class, appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome, "未知单要应答 SUCCESS,别让上游重推");
|
|
|
+ String logs = messages(appender);
|
|
|
+ assertTrue(logs.contains(ORDER_NO), "ERROR 日志要能按单号排障");
|
|
|
+ assertTrue(hasLevel(appender, Level.ERROR));
|
|
|
+ assertFalse(logs.contains("SECRET-AUTH-123"), "付款码不得进日志");
|
|
|
+ assertFalse(logs.contains("OPENID-SECRET-456"), "openid 不得进日志");
|
|
|
+ assertFalse(logs.contains("BUYER-SECRET-789"));
|
|
|
+ verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void orderRowOtherMchNo_returnsUnauthorized() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ order.setMchNo("M-OTHER");
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(orderParams())),
|
|
|
+ "行上的商户号与通知商户号不符 = 伪造/串单");
|
|
|
+ verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void orderRowOtherTenant_returnsUnauthorized() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ order.setTenantId(Long.valueOf(2000L));
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleOrder(signed(orderParams())),
|
|
|
+ "R31:租户号必须按值比较,不能看引用");
|
|
|
+ verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void repeatedNotify_applyReturnsFalse_stillSuccess() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(false);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(orderParams())),
|
|
|
+ "重复通知(条件更新落空)也应答 SUCCESS");
|
|
|
+ verify(orderService).applyOrderState(any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void applyThrows_returnsFail() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any()))
|
|
|
+ .thenThrow(new IllegalStateException("boom"));
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.FAIL, service.handleOrder(signed(orderParams())),
|
|
|
+ "处理异常不得被吞成 SUCCESS,否则上游不再重试");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 订单通知:金额核对 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void amountMismatch99_returnsFail() {
|
|
|
+ assertOrderAmountRejected("99");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void amountMismatchZero_returnsFail() {
|
|
|
+ assertOrderAmountRejected("0");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void amountMismatchNegative_returnsFail() {
|
|
|
+ assertOrderAmountRejected("-1");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void amountMissing_returnsFail() {
|
|
|
+ assertOrderAmountRejected(null);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void currencyMismatch_returnsFail() {
|
|
|
+ assertOrderAmountRejected("100", "usd");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void currencyMissing_isAcceptedForCnyOrder() {
|
|
|
+ assertOrderAmountAccepted("100", null);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void currencyIsCaseInsensitive() {
|
|
|
+ assertOrderAmountAccepted("100", "CNY");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void amountMismatchDetail_isFlattened() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("amount", "100\r\nINJECTED");
|
|
|
+ assertEquals(UnifiedNotifyOutcome.FAIL, service.handleOrder(signed(params)));
|
|
|
+
|
|
|
+ ArgumentCaptor<String> detail = ArgumentCaptor.forClass(String.class);
|
|
|
+ verify(orderService).markAmountMismatch(same(order), detail.capture());
|
|
|
+ assertFalse(detail.getValue().contains("\r") || detail.getValue().contains("\n"),
|
|
|
+ "写库/日志的 detail 必须是拍平后的单行");
|
|
|
+ verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void orderFailedState_skipsAmountCheckAndApplies() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("state", "3");
|
|
|
+ params.remove("amount");
|
|
|
+ params.remove("currency");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)),
|
|
|
+ "3/4/6 不是收款成功,不要求带金额");
|
|
|
+ verify(orderService).applyOrderState(same(order), eq("3"), any(JsonNode.class),
|
|
|
+ eq("0"), eq("ok"));
|
|
|
+ verify(orderService, never()).markAmountMismatch(any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void snapshot_onlyWhitelistFields_andAtMost8KB() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("channelExtra", "{\"authCode\":\"SECRET-AUTH\",\"openid\":\"SECRET-OPENID\","
|
|
|
+ + "\"buyerUserId\":\"SECRET-BUYER\"}");
|
|
|
+ params.put("authCode", "SECRET-AUTH");
|
|
|
+ params.put("openid", "SECRET-OPENID");
|
|
|
+ params.put("buyerUserId", "SECRET-BUYER");
|
|
|
+ StringBuilder big = new StringBuilder(20000);
|
|
|
+ for (int i = 0; i < 20000; i++) {
|
|
|
+ big.append('x');
|
|
|
+ }
|
|
|
+ params.put("errMsg", big.toString());
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)));
|
|
|
+
|
|
|
+ ArgumentCaptor<JsonNode> snapshot = ArgumentCaptor.forClass(JsonNode.class);
|
|
|
+ verify(orderService).applyOrderState(same(order), eq("2"), snapshot.capture(),
|
|
|
+ eq("0"), any());
|
|
|
+ String json = UnifiedUpstreamSnapshot.toJson(snapshot.getValue());
|
|
|
+ assertTrue(json.getBytes(StandardCharsets.UTF_8).length <= UnifiedUpstreamSnapshot.MAX_LENGTH,
|
|
|
+ "R35:落库快照不得超过 8KB");
|
|
|
+ assertFalse(json.contains("channelExtra"), "R35:channelExtra 不在白名单");
|
|
|
+ assertFalse(json.contains("authCode"));
|
|
|
+ assertFalse(json.contains("openid"));
|
|
|
+ assertFalse(json.contains("buyerUserId"));
|
|
|
+ assertFalse(json.contains("SECRET-AUTH"));
|
|
|
+ assertFalse(json.contains("SECRET-OPENID"));
|
|
|
+ assertFalse(json.contains("SECRET-BUYER"));
|
|
|
+ assertFalse(json.contains("\"sign\""), "签名也不得落库");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── 退款通知 ──────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void validRefundNotify_appliesRefundState() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundFlow(refund);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS,
|
|
|
+ service.handleRefund(signed(refundParams())));
|
|
|
+
|
|
|
+ verify(refundService).findForNotify("RID-1", REFUND_NO);
|
|
|
+ verify(orderService).findByOrderNo(isNull(), eq(ORDER_NO));
|
|
|
+ verify(refundService).applyRefundState(eq(REFUND_NO), eq("2"), any(JsonNode.class),
|
|
|
+ eq(100L), eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundWithoutUpstreamId_looksUpByMchRefundNo() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ when(refundService.findForNotify(null, REFUND_NO)).thenReturn(refund);
|
|
|
+ when(orderService.findByOrderNo(null, ORDER_NO))
|
|
|
+ .thenReturn(order(UnifiedOrderStatus.SUCCESS));
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ params.remove("refundOrderId");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)));
|
|
|
+ verify(refundService).findForNotify(isNull(), eq(REFUND_NO));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundStateBlank_fallsBackToOrderState() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundFlow(refund);
|
|
|
+
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ params.put("state", " ");
|
|
|
+ params.put("orderState", "2");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)));
|
|
|
+ verify(refundService).applyRefundState(eq(REFUND_NO), eq("2"), any(JsonNode.class),
|
|
|
+ eq(100L), eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundStateFailed_skipsAmountCheck() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundFlow(refund);
|
|
|
+
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ params.put("state", "3");
|
|
|
+ params.remove("payAmount");
|
|
|
+ params.remove("refundAmount");
|
|
|
+ params.remove("currency");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)),
|
|
|
+ "退款 3/4 不要求金额");
|
|
|
+ verify(refundService).applyRefundState(eq(REFUND_NO), eq("3"), any(JsonNode.class),
|
|
|
+ isNull(), eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundUnknownState_returnsSuccessWithoutLookup() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ params.put("state", "99");
|
|
|
+
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
|
|
|
+ UnifiedNotifyOutcome outcome;
|
|
|
+ try {
|
|
|
+ outcome = service.handleRefund(signed(params));
|
|
|
+ } finally {
|
|
|
+ detachLog(UnifiedNotifyService.class, appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome);
|
|
|
+ assertTrue(hasLevel(appender, Level.WARN));
|
|
|
+ verifyNoInteractions(refundService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundBadSignature_returnsUnauthorized() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ params.put("sign", "00000000000000000000000000000000");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleRefund(params));
|
|
|
+ verifyNoInteractions(refundService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundAppIdMismatch_returnsUnauthorized() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ params.put("appId", "APP-OTHER");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED, service.handleRefund(signed(params)));
|
|
|
+ verifyNoInteractions(refundService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundUnknownMerchant_returnsUnauthorized() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO))
|
|
|
+ .thenThrow(new BusinessException(404, "商户不存在"));
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
|
|
|
+ service.handleRefund(signed(refundParams())));
|
|
|
+ verifyNoInteractions(refundService, orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundUnknownRefund_returnsSuccessAndLogsRefundNo() {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ when(refundService.findForNotify("RID-1", REFUND_NO)).thenReturn(null);
|
|
|
+
|
|
|
+ ListAppender<ILoggingEvent> appender = attachLog(UnifiedNotifyService.class);
|
|
|
+ UnifiedNotifyOutcome outcome;
|
|
|
+ try {
|
|
|
+ outcome = service.handleRefund(signed(refundParams()));
|
|
|
+ } finally {
|
|
|
+ detachLog(UnifiedNotifyService.class, appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, outcome);
|
|
|
+ assertTrue(messages(appender).contains(REFUND_NO), "ERROR 日志要能按退款单号排障");
|
|
|
+ assertTrue(hasLevel(appender, Level.ERROR));
|
|
|
+ verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundRowOtherMchNo_returnsUnauthorized() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ refund.setMchNo("M-OTHER");
|
|
|
+ stubRefundLookup(refund);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
|
|
|
+ service.handleRefund(signed(refundParams())));
|
|
|
+ verifyNoInteractions(orderService);
|
|
|
+ verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundRowOtherTenant_returnsUnauthorized() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ refund.setTenantId(Long.valueOf(2000L));
|
|
|
+ stubRefundLookup(refund);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
|
|
|
+ service.handleRefund(signed(refundParams())),
|
|
|
+ "R31:退款行租户号必须按值比较");
|
|
|
+ verifyNoInteractions(orderService);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundOrderBindingMismatch_returnsUnauthorized() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundLookup(refund);
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.SUCCESS);
|
|
|
+ order.setTenantId(Long.valueOf(2000L));
|
|
|
+ when(orderService.findByOrderNo(null, ORDER_NO)).thenReturn(order);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
|
|
|
+ service.handleRefund(signed(refundParams())),
|
|
|
+ "退款与订单必须同商户同租户");
|
|
|
+ verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundOrderMissing_returnsUnauthorized() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundLookup(refund);
|
|
|
+ when(orderService.findByOrderNo(null, ORDER_NO)).thenReturn(null);
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.UNAUTHORIZED,
|
|
|
+ service.handleRefund(signed(refundParams())),
|
|
|
+ "退款指向不存在的订单 = 数据不可信");
|
|
|
+ verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundPayAmountMismatch_returnsFail() {
|
|
|
+ assertRefundAmountRejected("payAmount", "99");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundPayAmountMissing_returnsFail() {
|
|
|
+ assertRefundAmountRejected("payAmount", null);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundAmountMismatch_returnsFail() {
|
|
|
+ assertRefundAmountRejected("refundAmount", "99");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundAmountMissing_returnsFail() {
|
|
|
+ assertRefundAmountRejected("refundAmount", null);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundCurrencyMismatch_returnsFail() {
|
|
|
+ assertRefundAmountRejected("currency", "usd");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundCurrencyMissing_isAccepted() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundFlow(refund);
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ params.remove("currency");
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleRefund(signed(params)));
|
|
|
+ verify(refundService).applyRefundState(eq(REFUND_NO), eq("2"), any(JsonNode.class),
|
|
|
+ eq(100L), eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void refundApplyThrows_returnsFail() {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundFlow(refund);
|
|
|
+ doThrow(new IllegalStateException("boom")).when(refundService)
|
|
|
+ .applyRefundState(any(), any(), any(), any(), any(), any());
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.FAIL, service.handleRefund(signed(refundParams())));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ────────────────────────────── helpers ──────────────────────────────
|
|
|
+
|
|
|
+ private void assertOrderAmountRejected(String amount) {
|
|
|
+ assertOrderAmountRejected(amount, "cny");
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertOrderAmountRejected(String amount, String currency) {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ if (amount == null) {
|
|
|
+ params.remove("amount");
|
|
|
+ } else {
|
|
|
+ params.put("amount", amount);
|
|
|
+ }
|
|
|
+ if (currency == null) {
|
|
|
+ params.remove("currency");
|
|
|
+ } else {
|
|
|
+ params.put("currency", currency);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.FAIL, service.handleOrder(signed(params)),
|
|
|
+ "金额/币种不符必须 FAIL,让上游继续重试");
|
|
|
+ verify(orderService).markAmountMismatch(same(order), any());
|
|
|
+ verify(orderService, never()).applyOrderState(any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertOrderAmountAccepted(String amount, String currency) {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ UnifiedOrderEntity order = order(UnifiedOrderStatus.PAYING);
|
|
|
+ when(orderService.findForNotify("P-1", ORDER_NO)).thenReturn(order);
|
|
|
+ when(orderService.applyOrderState(any(), any(), any(), any(), any())).thenReturn(true);
|
|
|
+
|
|
|
+ Map<String, String> params = orderParams();
|
|
|
+ params.put("amount", amount);
|
|
|
+ if (currency == null) {
|
|
|
+ params.remove("currency");
|
|
|
+ } else {
|
|
|
+ params.put("currency", currency);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.SUCCESS, service.handleOrder(signed(params)));
|
|
|
+ verify(orderService, never()).markAmountMismatch(any(), any());
|
|
|
+ verify(orderService).applyOrderState(same(order), eq("2"), any(JsonNode.class),
|
|
|
+ eq("0"), eq("ok"));
|
|
|
+ }
|
|
|
+
|
|
|
+ private void assertRefundAmountRejected(String field, String value) {
|
|
|
+ UnifiedRefundEntity refund = refund(UnifiedRefundStatus.REFUNDING);
|
|
|
+ stubRefundFlow(refund);
|
|
|
+
|
|
|
+ Map<String, String> params = refundParams();
|
|
|
+ if (value == null) {
|
|
|
+ params.remove(field);
|
|
|
+ } else {
|
|
|
+ params.put(field, value);
|
|
|
+ }
|
|
|
+
|
|
|
+ assertEquals(UnifiedNotifyOutcome.FAIL, service.handleRefund(signed(params)),
|
|
|
+ field + " 不符/缺失必须 FAIL");
|
|
|
+ verify(refundService, never()).applyRefundState(any(), any(), any(), any(), any(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubRefundLookup(UnifiedRefundEntity refund) {
|
|
|
+ when(merchantService.requireByMchNo(MCH_NO)).thenReturn(cfg());
|
|
|
+ when(refundService.findForNotify("RID-1", REFUND_NO)).thenReturn(refund);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubRefundFlow(UnifiedRefundEntity refund) {
|
|
|
+ stubRefundLookup(refund);
|
|
|
+ when(orderService.findByOrderNo(null, ORDER_NO)).thenReturn(order(UnifiedOrderStatus.SUCCESS));
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedMerchantEntity cfg() {
|
|
|
+ UnifiedMerchantEntity cfg = new UnifiedMerchantEntity();
|
|
|
+ cfg.setMchNo(MCH_NO);
|
|
|
+ cfg.setAppId(APP_ID);
|
|
|
+ cfg.setAppSecret(SECRET);
|
|
|
+ cfg.setTenantId(Long.valueOf(1000L));
|
|
|
+ return cfg;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedOrderEntity order(String tradeStatus) {
|
|
|
+ UnifiedOrderEntity order = new UnifiedOrderEntity();
|
|
|
+ order.setId(1L);
|
|
|
+ order.setOrderNo(ORDER_NO);
|
|
|
+ order.setMchNo(MCH_NO);
|
|
|
+ order.setTenantId(Long.valueOf(1000L));
|
|
|
+ order.setAmount(100L);
|
|
|
+ order.setCurrency("cny");
|
|
|
+ order.setTradeStatus(tradeStatus);
|
|
|
+ return order;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedRefundEntity refund(String state) {
|
|
|
+ UnifiedRefundEntity refund = new UnifiedRefundEntity();
|
|
|
+ refund.setId(2L);
|
|
|
+ refund.setOrderId(1L);
|
|
|
+ refund.setOrderNo(ORDER_NO);
|
|
|
+ refund.setRefundNo(REFUND_NO);
|
|
|
+ refund.setMchNo(MCH_NO);
|
|
|
+ refund.setTenantId(Long.valueOf(1000L));
|
|
|
+ refund.setAmount(100L);
|
|
|
+ refund.setCurrency("cny");
|
|
|
+ refund.setState(state);
|
|
|
+ return refund;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, String> orderParams() {
|
|
|
+ Map<String, String> params = new LinkedHashMap<>();
|
|
|
+ params.put("mchNo", MCH_NO);
|
|
|
+ params.put("appId", APP_ID);
|
|
|
+ params.put("payOrderId", "P-1");
|
|
|
+ params.put("mchOrderNo", ORDER_NO);
|
|
|
+ params.put("state", "2");
|
|
|
+ params.put("amount", "100");
|
|
|
+ params.put("currency", "cny");
|
|
|
+ params.put("successTime", "1700000000000");
|
|
|
+ params.put("errCode", "0");
|
|
|
+ params.put("errMsg", "ok");
|
|
|
+ return params;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, String> refundParams() {
|
|
|
+ Map<String, String> params = new LinkedHashMap<>();
|
|
|
+ params.put("mchNo", MCH_NO);
|
|
|
+ params.put("appId", APP_ID);
|
|
|
+ params.put("refundOrderId", "RID-1");
|
|
|
+ params.put("mchRefundNo", REFUND_NO);
|
|
|
+ params.put("state", "2");
|
|
|
+ params.put("payAmount", "100");
|
|
|
+ params.put("refundAmount", "100");
|
|
|
+ params.put("currency", "cny");
|
|
|
+ params.put("errCode", "0");
|
|
|
+ params.put("errMsg", "ok");
|
|
|
+ return params;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static Map<String, String> signed(Map<String, String> params) {
|
|
|
+ Map<String, String> copy = new LinkedHashMap<>(params);
|
|
|
+ copy.put("sign", UnifiedSigner.sign(copy, SECRET));
|
|
|
+ return copy;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static ListAppender<ILoggingEvent> attachLog(Class<?> type) {
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(type);
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
|
+ appender.start();
|
|
|
+ logger.addAppender(appender);
|
|
|
+ return appender;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void detachLog(Class<?> type, ListAppender<ILoggingEvent> appender) {
|
|
|
+ ((Logger) LoggerFactory.getLogger(type)).detachAppender(appender);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static boolean hasLevel(ListAppender<ILoggingEvent> appender, Level level) {
|
|
|
+ return appender.list.stream().anyMatch(event -> event.getLevel() == level);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static String messages(ListAppender<ILoggingEvent> appender) {
|
|
|
+ return appender.list.stream()
|
|
|
+ .map(ILoggingEvent::getFormattedMessage)
|
|
|
+ .collect(Collectors.joining("\n"));
|
|
|
+ }
|
|
|
+}
|