|
|
@@ -0,0 +1,714 @@
|
|
|
+package com.payment.platform.module.payment.unified.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.fasterxml.jackson.databind.JsonNode;
|
|
|
+import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
+import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
|
|
|
+import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
|
|
|
+import com.payment.platform.module.payment.openapi.entity.OpenConfEntity;
|
|
|
+import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
|
|
|
+import com.payment.platform.module.payment.unified.config.UnifiedPayProperties;
|
|
|
+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.UnifiedNotifyStatus;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedOrderMapper;
|
|
|
+import com.payment.platform.module.payment.unified.mapper.UnifiedRefundMapper;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+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.springframework.http.HttpEntity;
|
|
|
+import org.springframework.http.HttpStatus;
|
|
|
+import org.springframework.http.ResponseEntity;
|
|
|
+import org.springframework.util.MultiValueMap;
|
|
|
+import org.springframework.web.client.RestTemplate;
|
|
|
+
|
|
|
+import java.time.OffsetDateTime;
|
|
|
+import java.util.ArrayList;
|
|
|
+import java.util.HashSet;
|
|
|
+import java.util.List;
|
|
|
+import java.util.Set;
|
|
|
+import java.util.regex.Matcher;
|
|
|
+import java.util.regex.Pattern;
|
|
|
+
|
|
|
+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;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertNull;
|
|
|
+import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
+import static org.junit.jupiter.api.Assertions.fail;
|
|
|
+import static org.mockito.ArgumentMatchers.any;
|
|
|
+import static org.mockito.ArgumentMatchers.anyString;
|
|
|
+import static org.mockito.ArgumentMatchers.eq;
|
|
|
+import static org.mockito.ArgumentMatchers.isNull;
|
|
|
+import static org.mockito.Mockito.never;
|
|
|
+import static org.mockito.Mockito.times;
|
|
|
+import static org.mockito.Mockito.verify;
|
|
|
+import static org.mockito.Mockito.verifyNoInteractions;
|
|
|
+import static org.mockito.Mockito.when;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 下游回调的报文契约、取址序、SSRF 防线与重试形状(spec §7.6,R21/R34/R35)。
|
|
|
+ *
|
|
|
+ * <p>【为什么这些是主断言】
|
|
|
+ * <ul>
|
|
|
+ * <li>**取址序**写反时其余测试全绿,只有地址断言会红 —— 而它决定钱事件推到哪个
|
|
|
+ * 商户服务器。</li>
|
|
|
+ * <li>**{@code content.upstream} 白名单**:上游通知混有我们上送的 {@code channelExtra}
|
|
|
+ * (付款码 / openid / buyerUserId)。整体转发 = 把付款凭据交给下游,是本模块的硬约束。
|
|
|
+ * 断言用**真实表单**解析,不读源码文本。</li>
|
|
|
+ * <li>**先 FAILED 再异步推送**:进程在推送中途崩溃时,行必须已经是 FAILED + count+1,
|
|
|
+ * 否则重试扫描永远捞不到它(扫描入口就是 {@code notify_status='FAILED'})。</li>
|
|
|
+ * <li>**SSRF**:恶意地址必须**一次出站都不发**,但仍记一次失败尝试(不能静默卡在 PENDING)。</li>
|
|
|
+ * </ul>
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class UnifiedCallbackServiceTest {
|
|
|
+
|
|
|
+ private static final Long TENANT = 7L;
|
|
|
+ private static final Long ORDER_DB_ID = 5L;
|
|
|
+
|
|
|
+ @Mock private UnifiedOrderMapper orderMapper;
|
|
|
+ @Mock private UnifiedRefundMapper refundMapper;
|
|
|
+ @Mock private TenantApiKeyMapper apiKeyMapper;
|
|
|
+ @Mock private OpenConfMapper confMapper;
|
|
|
+ @Mock private RestTemplate restTemplate;
|
|
|
+
|
|
|
+ private UnifiedPayProperties props;
|
|
|
+ private UnifiedCallbackService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ props = new UnifiedPayProperties();
|
|
|
+ props.setCallbackMaxRetries(3);
|
|
|
+ // Executor 传 Runnable::run:推送在调用线程内同步完成,断言无需等待。
|
|
|
+ service = new UnifiedCallbackService(orderMapper, refundMapper, apiKeyMapper, confMapper,
|
|
|
+ restTemplate, Runnable::run, props);
|
|
|
+ // 无 Spring 上下文时让 LambdaWrapper 能解析列名(同 HarCallbackServiceTest 的引导)
|
|
|
+ initTable(UnifiedOrderEntity.class);
|
|
|
+ initTable(UnifiedRefundEntity.class);
|
|
|
+ initTable(TenantApiKeyEntity.class);
|
|
|
+ initTable(OpenConfEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void initTable(Class<?> entityType) {
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), entityType);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 夹具 ─────────────────────────────
|
|
|
+
|
|
|
+ private static UnifiedOrderEntity order(String orderNo, String tradeStatus) {
|
|
|
+ UnifiedOrderEntity o = new UnifiedOrderEntity();
|
|
|
+ o.setId(ORDER_DB_ID);
|
|
|
+ o.setTenantId(TENANT);
|
|
|
+ o.setOrderNo(orderNo);
|
|
|
+ o.setOutTradeNo("C1");
|
|
|
+ o.setMchNo("M1");
|
|
|
+ o.setWayCode("QR_CASHIER");
|
|
|
+ o.setAmount(100L);
|
|
|
+ o.setRefundedAmount(20L);
|
|
|
+ o.setCurrency("cny");
|
|
|
+ o.setTradeStatus(tradeStatus);
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ private static UnifiedRefundEntity refund() {
|
|
|
+ UnifiedRefundEntity r = new UnifiedRefundEntity();
|
|
|
+ r.setId(9L);
|
|
|
+ r.setTenantId(TENANT);
|
|
|
+ r.setOrderId(ORDER_DB_ID);
|
|
|
+ r.setOrderNo("UP1");
|
|
|
+ r.setRefundNo("UR1");
|
|
|
+ r.setOutRefundNo("CR1");
|
|
|
+ r.setAmount(40L);
|
|
|
+ r.setCurrency("cny");
|
|
|
+ r.setState("2");
|
|
|
+ return r;
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubPushOk() {
|
|
|
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings({"rawtypes", "unchecked"})
|
|
|
+ private List<LambdaUpdateWrapper<?>> captureOrderUpdates(int expectedCalls) {
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper> captor = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
|
+ verify(orderMapper, times(expectedCalls)).update(isNull(), captor.capture());
|
|
|
+ List<LambdaUpdateWrapper<?>> captured = new ArrayList<>();
|
|
|
+ for (LambdaUpdateWrapper wrapper : captor.getAllValues()) {
|
|
|
+ captured.add(wrapper);
|
|
|
+ }
|
|
|
+ return captured;
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings({"rawtypes", "unchecked"})
|
|
|
+ private List<LambdaUpdateWrapper<?>> captureRefundUpdates(int expectedCalls) {
|
|
|
+ ArgumentCaptor<LambdaUpdateWrapper> captor = ArgumentCaptor.forClass(LambdaUpdateWrapper.class);
|
|
|
+ verify(refundMapper, times(expectedCalls)).update(isNull(), captor.capture());
|
|
|
+ List<LambdaUpdateWrapper<?>> captured = new ArrayList<>();
|
|
|
+ for (LambdaUpdateWrapper wrapper : captor.getAllValues()) {
|
|
|
+ captured.add(wrapper);
|
|
|
+ }
|
|
|
+ return captured;
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings({"rawtypes", "unchecked"})
|
|
|
+ private static HttpEntity<?> capturePush(RestTemplate restTemplate, String url) {
|
|
|
+ ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
|
|
|
+ verify(restTemplate).postForEntity(eq(url), captor.capture(), eq(String.class));
|
|
|
+ return captor.getValue();
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings("unchecked")
|
|
|
+ private static MultiValueMap<String, String> formOf(HttpEntity<?> entity) {
|
|
|
+ return (MultiValueMap<String, String>) entity.getBody();
|
|
|
+ }
|
|
|
+
|
|
|
+ private static JsonNode contentOf(MultiValueMap<String, String> form) throws Exception {
|
|
|
+ return new ObjectMapper().readTree(form.getFirst("content"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────── 确定性 notify_id ─────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyIds_areDeterministic() {
|
|
|
+ assertEquals("uo_UP1_2", UnifiedCallbackService.orderNotifyId("UP1", "2"));
|
|
|
+ assertEquals("ur_UR1_2", UnifiedCallbackService.refundNotifyId("UR1", "2"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 订单成功路径 ─────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyOrder_success_sendsOrderEventAndWhitelistsUpstream() throws Exception {
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl("https://merchant.example.com/cb");
|
|
|
+ order.setPayTime(OffsetDateTime.parse("2026-10-20T04:00:05Z"));
|
|
|
+ order.setUpstreamPayOrderId("P1");
|
|
|
+ order.setUpstreamChannelOrderNo("CH1");
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_DB_ID)).thenReturn(30L);
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ JsonNode upstream = new ObjectMapper().readTree("""
|
|
|
+ {"payOrderId":"P1","mchOrderNo":"UP1","state":"2","orderState":"2","amount":100,
|
|
|
+ "successTime":1760000000000,"channelExtra":{"auth_code":"284211","openid":"o-1"},
|
|
|
+ "openid":"o-1","sign":"DEADBEEF"}
|
|
|
+ """);
|
|
|
+
|
|
|
+ service.notifyOrder(order, upstream);
|
|
|
+
|
|
|
+ MultiValueMap<String, String> form = formOf(capturePush(restTemplate, "https://merchant.example.com/cb"));
|
|
|
+ assertEquals("uo_UP1_2", form.getFirst("notify_id"), "同一事件重试必须同一个 id,接收方可去重");
|
|
|
+ assertTrue(form.getFirst("timestamp") != null && form.getFirst("timestamp").matches("\\d{13}"),
|
|
|
+ "timestamp 必须是 13 位毫秒值,实测: " + form.getFirst("timestamp"));
|
|
|
+
|
|
|
+ JsonNode content = contentOf(form);
|
|
|
+ assertEquals("ORDER", content.path("event").asText());
|
|
|
+
|
|
|
+ JsonNode orderNode = content.path("order");
|
|
|
+ assertEquals("UP1", orderNode.path("order_no").asText());
|
|
|
+ assertEquals("C1", orderNode.path("out_trade_no").asText());
|
|
|
+ assertEquals("M1", orderNode.path("mch_no").asText());
|
|
|
+ assertEquals("QR_CASHIER", orderNode.path("way_code").asText());
|
|
|
+ assertEquals(100, orderNode.path("amount").asInt());
|
|
|
+ assertEquals(20, orderNode.path("refunded_amount").asInt());
|
|
|
+ assertEquals(70, orderNode.path("refundable_amount").asInt(),
|
|
|
+ "refundable = amount - sumOccupied(30);occupied 已含 state=2,不得再减 refunded_amount");
|
|
|
+ assertEquals("2", orderNode.path("trade_status").asText());
|
|
|
+ assertEquals("P1", orderNode.path("upstream_pay_order_id").asText());
|
|
|
+ assertEquals("CH1", orderNode.path("upstream_channel_order_no").asText());
|
|
|
+ assertEquals("2026-10-20T12:00:05+08:00", orderNode.path("pay_time").asText(),
|
|
|
+ "时间必须转成 Asia/Shanghai 的 OffsetDateTime 字符串");
|
|
|
+
|
|
|
+ JsonNode upstreamNode = content.path("upstream");
|
|
|
+ assertEquals("2", upstreamNode.path("state").asText());
|
|
|
+ assertEquals("P1", upstreamNode.path("payOrderId").asText(),
|
|
|
+ "白名单键名逐字;若用了全局 SNAKE_CASE mapper,这里会变成 pay_order_id");
|
|
|
+ assertEquals(1760000000000L, upstreamNode.path("successTime").asLong());
|
|
|
+ assertFalse(upstreamNode.has("channelExtra"), "channelExtra 绝不转发下游");
|
|
|
+ assertFalse(upstreamNode.has("openid"), "openid 绝不转发下游");
|
|
|
+ assertFalse(upstreamNode.has("sign"), "签名不转发下游");
|
|
|
+ assertFalse(upstreamNode.has("pay_order_id"), "白名单键名不得被改名");
|
|
|
+
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureOrderUpdates(2);
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(updates.get(0), "notify_status"),
|
|
|
+ "先写 FAILED 再推(进程崩溃时仍可被重试扫到)。实测 SET: " + updates.get(0).getSqlSet());
|
|
|
+ assertTrue(updates.get(0).getSqlSet().contains("notify_count = notify_count + 1"),
|
|
|
+ "首推也要计数(含首次)。实测 SET: " + updates.get(0).getSqlSet());
|
|
|
+ assertEquals(UnifiedNotifyStatus.SUCCESS, setValueOf(updates.get(1), "notify_status"),
|
|
|
+ "2xx 后写 SUCCESS。实测 SET: " + updates.get(1).getSqlSet());
|
|
|
+ assertFalse(updates.get(1).getSqlSet().contains("notify_count"),
|
|
|
+ "成功回写不得再加一次计数(否则一次推送烧掉两次上限)。实测 SET: " + updates.get(1).getSqlSet());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyOrder_non2xx_leavesFailedAttempt() {
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl("https://merchant.example.com/cb");
|
|
|
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>("boom", HttpStatus.INTERNAL_SERVER_ERROR));
|
|
|
+
|
|
|
+ service.notifyOrder(order, null);
|
|
|
+
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureOrderUpdates(1);
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(updates.get(0), "notify_status"));
|
|
|
+ assertTrue(updates.get(0).getSqlSet().contains("notify_count = notify_count + 1"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyOrder_noAddress_recordsFailedAttemptWithoutOutbound() {
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl(null);
|
|
|
+ order.setApiKey(null);
|
|
|
+ when(confMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ service.notifyOrder(order, null);
|
|
|
+
|
|
|
+ verifyNoInteractions(restTemplate);
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureOrderUpdates(1);
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(updates.get(0), "notify_status"),
|
|
|
+ "没地址也要落 FAILED(商户补配地址后调度器还能捞起)。实测 SET: " + updates.get(0).getSqlSet());
|
|
|
+ assertTrue(updates.get(0).getSqlSet().contains("notify_count = notify_count + 1"));
|
|
|
+ assertNotNull(setValueOf(updates.get(0), "last_notify_time"), "排序键不能为空");
|
|
|
+ assertTrue(whereBoundValues(updates.get(0)).contains("UP1"),
|
|
|
+ "WHERE 必须钉住这一单。实测: " + updates.get(0).getSqlSegment());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 订单取址序 ─────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyOrder_snapshotCallbackUrlWinsWithoutKeyLookup() {
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl("https://snapshot.example.com/cb");
|
|
|
+ order.setApiKey("K1");
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyOrder(order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://snapshot.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ verifyNoInteractions(apiKeyMapper, confMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyOrder_fallsBackToApiKeyReturnUrlBeforeOpenConf() {
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl(null);
|
|
|
+ order.setApiKey("K1");
|
|
|
+ TenantApiKeyEntity key = new TenantApiKeyEntity();
|
|
|
+ key.setApiKey("K1");
|
|
|
+ key.setReturnUrl("https://key.example.com/cb");
|
|
|
+ when(apiKeyMapper.selectOne(any())).thenReturn(key);
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyOrder(order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://key.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ verifyNoInteractions(confMapper);
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(apiKeyMapper).selectOne(query.capture());
|
|
|
+ assertTrue(query.getValue().getSqlSegment().contains("api_key = #{"),
|
|
|
+ "Key 必须按 api_key 精确查(该列全局唯一)。实测: " + query.getValue().getSqlSegment());
|
|
|
+ assertEquals(Set.of("K1"), new HashSet<>(query.getValue().getParamNameValuePairs().values()));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyOrder_fallsBackToLatestOpenConfRow() {
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl(null);
|
|
|
+ order.setApiKey(null);
|
|
|
+
|
|
|
+ OpenConfEntity older = new OpenConfEntity();
|
|
|
+ older.setTenantId(TENANT);
|
|
|
+ older.setReturnUrl("https://conf-old.example.com/cb");
|
|
|
+ older.setUpdatedTime(OffsetDateTime.parse("2026-10-01T00:00:00+08:00"));
|
|
|
+ OpenConfEntity newer = new OpenConfEntity();
|
|
|
+ newer.setTenantId(TENANT);
|
|
|
+ newer.setReturnUrl("https://conf-new.example.com/cb");
|
|
|
+ newer.setUpdatedTime(OffsetDateTime.parse("2026-10-19T00:00:00+08:00"));
|
|
|
+ // 模拟数据库已按 updated_time DESC 排好序的两行
|
|
|
+ when(confMapper.selectList(any())).thenReturn(List.of(newer, older));
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyOrder(order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://conf-new.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ verify(confMapper, never()).selectOne(any());
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(confMapper).selectList(query.capture());
|
|
|
+ assertTrue(query.getValue().getSqlSegment().contains("ORDER BY updated_time DESC"),
|
|
|
+ "open_conf 兜底必须取 updated_time 最新一行,实测: " + query.getValue().getSqlSegment());
|
|
|
+ assertTrue(query.getValue().getParamNameValuePairs().values().contains(TENANT),
|
|
|
+ "必须按租户过滤,实测: " + query.getValue().getParamNameValuePairs());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── SSRF ─────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void hostileCallbackUrls_areRejectedWithoutOutboundCall() {
|
|
|
+ List<String> hostile = List.of(
|
|
|
+ "http://localhost/x",
|
|
|
+ "http://127.0.0.1/x",
|
|
|
+ "http://127.8.9.10/x",
|
|
|
+ "http://[::1]/x",
|
|
|
+ "http://0.0.0.0/x",
|
|
|
+ "http://169.254.169.254/latest/meta-data",
|
|
|
+ "file:///etc/passwd",
|
|
|
+ "javascript:alert(1)",
|
|
|
+ "");
|
|
|
+ for (String url : hostile) {
|
|
|
+ assertFalse(UnifiedCallbackService.isAllowedCallbackUrl(url), "必须拒绝: " + url);
|
|
|
+ }
|
|
|
+ // 正对照:合法地址不得被误伤(私网回调是企业内网的合法用法,spec 明确不解析私网 IP)
|
|
|
+ assertTrue(UnifiedCallbackService.isAllowedCallbackUrl("https://example.com/cb"));
|
|
|
+ assertTrue(UnifiedCallbackService.isAllowedCallbackUrl("http://10.1.2.3/cb"));
|
|
|
+
|
|
|
+ when(confMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ for (String url : hostile) {
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl(url);
|
|
|
+ service.notifyOrder(order, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ verifyNoInteractions(restTemplate);
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureOrderUpdates(hostile.size());
|
|
|
+ for (LambdaUpdateWrapper<?> update : updates) {
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(update, "notify_status"),
|
|
|
+ "恶意/空地址也必须记一次失败尝试,不能卡在 PENDING");
|
|
|
+ assertTrue(update.getSqlSet().contains("notify_count = notify_count + 1"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 退款事件 ─────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyRefund_sendsRefundEventWithOrderSnapshot() throws Exception {
|
|
|
+ UnifiedRefundEntity refund = refund();
|
|
|
+ refund.setCallbackUrl("https://merchant.example.com/refund");
|
|
|
+ refund.setSuccessTime(OffsetDateTime.parse("2026-10-20T04:00:05Z"));
|
|
|
+ refund.setUpstreamRefundOrderId("R1");
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setRefundedAmount(40L);
|
|
|
+ when(refundMapper.sumOccupiedAmount(ORDER_DB_ID)).thenReturn(40L);
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ JsonNode upstream = new ObjectMapper().readTree("""
|
|
|
+ {"refundOrderId":"R1","mchRefundNo":"UR1","state":"2","refundAmount":40,"payAmount":100,
|
|
|
+ "channelExtra":{"auth_code":"284211"},"openid":"o-1"}
|
|
|
+ """);
|
|
|
+
|
|
|
+ service.notifyRefund(refund, order, upstream);
|
|
|
+
|
|
|
+ MultiValueMap<String, String> form = formOf(capturePush(restTemplate, "https://merchant.example.com/refund"));
|
|
|
+ assertEquals("ur_UR1_2", form.getFirst("notify_id"));
|
|
|
+
|
|
|
+ JsonNode content = contentOf(form);
|
|
|
+ assertEquals("REFUND", content.path("event").asText());
|
|
|
+
|
|
|
+ JsonNode refundNode = content.path("refund");
|
|
|
+ assertEquals("UR1", refundNode.path("refund_no").asText());
|
|
|
+ assertEquals("CR1", refundNode.path("out_refund_no").asText());
|
|
|
+ assertEquals("UP1", refundNode.path("order_no").asText());
|
|
|
+ assertEquals(40, refundNode.path("amount").asInt());
|
|
|
+ assertEquals("2", refundNode.path("state").asText());
|
|
|
+ assertEquals("R1", refundNode.path("upstream_refund_order_id").asText());
|
|
|
+ assertEquals("2026-10-20T12:00:05+08:00", refundNode.path("success_time").asText());
|
|
|
+
|
|
|
+ JsonNode orderNode = content.path("order");
|
|
|
+ assertEquals("2", orderNode.path("trade_status").asText(), "退款事件必须带订单当前快照");
|
|
|
+ assertEquals(40, orderNode.path("refunded_amount").asInt());
|
|
|
+ assertEquals(60, orderNode.path("refundable_amount").asInt(),
|
|
|
+ "refundable = 100 - occupied(40),不得再减 refunded");
|
|
|
+
|
|
|
+ JsonNode upstreamNode = content.path("upstream");
|
|
|
+ assertEquals("R1", upstreamNode.path("refundOrderId").asText());
|
|
|
+ assertFalse(upstreamNode.has("channelExtra"));
|
|
|
+ assertFalse(upstreamNode.has("openid"));
|
|
|
+
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureRefundUpdates(2);
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(updates.get(0), "notify_status"));
|
|
|
+ assertTrue(updates.get(0).getSqlSet().contains("notify_count = notify_count + 1"));
|
|
|
+ assertEquals(UnifiedNotifyStatus.SUCCESS, setValueOf(updates.get(1), "notify_status"));
|
|
|
+ assertTrue(whereBoundValues(updates.get(0)).contains("UR1"),
|
|
|
+ "退款状态按 refund_no 更新。实测: " + updates.get(0).getSqlSegment());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyRefund_refundCallbackUrlWinsOverOrderAndKey() {
|
|
|
+ UnifiedRefundEntity refund = refund();
|
|
|
+ refund.setCallbackUrl("https://refund-snapshot.example.com/cb");
|
|
|
+ refund.setApiKey("KR");
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl("https://order-snapshot.example.com/cb");
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyRefund(refund, order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://refund-snapshot.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ verifyNoInteractions(apiKeyMapper, confMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyRefund_fallsBackToOrderCallbackUrl() {
|
|
|
+ UnifiedRefundEntity refund = refund();
|
|
|
+ refund.setCallbackUrl(null);
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setCallbackUrl("https://order-snapshot.example.com/cb");
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyRefund(refund, order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://order-snapshot.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ verifyNoInteractions(apiKeyMapper, confMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyRefund_usesRefundApiKeyBeforeOrderApiKey() {
|
|
|
+ UnifiedRefundEntity refund = refund();
|
|
|
+ refund.setCallbackUrl(null);
|
|
|
+ refund.setApiKey("KR");
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ TenantApiKeyEntity key = new TenantApiKeyEntity();
|
|
|
+ key.setReturnUrl("https://key.example.com/cb");
|
|
|
+ when(apiKeyMapper.selectOne(any())).thenReturn(key);
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyRefund(refund, order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://key.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(apiKeyMapper).selectOne(query.capture());
|
|
|
+ // 先渲染 SQL 段:MyBatis-Plus 的绑定值是在 SQL 段求值时写进参数表的
|
|
|
+ query.getValue().getSqlSegment();
|
|
|
+ assertEquals(Set.of("KR"), new HashSet<>(query.getValue().getParamNameValuePairs().values()),
|
|
|
+ "退款行的 apiKey 优先(发起退款时记录的那把)");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyRefund_fallsBackToOrderApiKeyWhenRefundKeyBlank() {
|
|
|
+ UnifiedRefundEntity refund = refund();
|
|
|
+ refund.setCallbackUrl(null);
|
|
|
+ refund.setApiKey(null);
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ order.setApiKey("KO");
|
|
|
+ TenantApiKeyEntity key = new TenantApiKeyEntity();
|
|
|
+ key.setReturnUrl("https://order-key.example.com/cb");
|
|
|
+ when(apiKeyMapper.selectOne(any())).thenReturn(key);
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyRefund(refund, order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://order-key.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper> query = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(apiKeyMapper).selectOne(query.capture());
|
|
|
+ query.getValue().getSqlSegment();
|
|
|
+ assertEquals(Set.of("KO"), new HashSet<>(query.getValue().getParamNameValuePairs().values()),
|
|
|
+ "退款行 apiKey 为空时用订单 apiKey");
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyRefund_fallsBackToOpenConf() {
|
|
|
+ UnifiedRefundEntity refund = refund();
|
|
|
+ refund.setCallbackUrl(null);
|
|
|
+ refund.setApiKey(null);
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ OpenConfEntity conf = new OpenConfEntity();
|
|
|
+ conf.setReturnUrl("https://conf.example.com/cb");
|
|
|
+ when(confMapper.selectList(any())).thenReturn(List.of(conf));
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.notifyRefund(refund, order, null);
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://conf.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ verify(confMapper, never()).selectOne(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void notifyRefund_noAddress_recordsFailedWithoutOutbound() {
|
|
|
+ UnifiedRefundEntity refund = refund();
|
|
|
+ refund.setCallbackUrl(null);
|
|
|
+ refund.setApiKey(null);
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ when(confMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ service.notifyRefund(refund, order, null);
|
|
|
+
|
|
|
+ verifyNoInteractions(restTemplate);
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureRefundUpdates(1);
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(updates.get(0), "notify_status"));
|
|
|
+ assertTrue(updates.get(0).getSqlSet().contains("notify_count = notify_count + 1"));
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 重试 ─────────────────────────────
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void retryFailed_pinsScanQuery() {
|
|
|
+ props.setCallbackMaxRetries(4); // ≠ 默认值 3:钉住上界来自配置而不是写死
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ assertScanQuery(orderMapper);
|
|
|
+ assertScanQuery(refundMapper);
|
|
|
+ }
|
|
|
+
|
|
|
+ @SuppressWarnings({"rawtypes", "unchecked"})
|
|
|
+ private void assertScanQuery(Object mapper) {
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ if (mapper == orderMapper) {
|
|
|
+ verify(orderMapper).selectList(captor.capture());
|
|
|
+ } else {
|
|
|
+ verify(refundMapper).selectList(captor.capture());
|
|
|
+ }
|
|
|
+ LambdaQueryWrapper<?> query = captor.getValue();
|
|
|
+ // 先渲染 SQL 段:MyBatis-Plus 的绑定值是在 SQL 段求值时写进参数表的
|
|
|
+ String sql = query.getSqlSegment();
|
|
|
+ assertEquals(Set.of(UnifiedNotifyStatus.FAILED, 4),
|
|
|
+ new HashSet<>(query.getParamNameValuePairs().values()),
|
|
|
+ "取值必须恰好是 {FAILED, 上限},实测: " + query.getParamNameValuePairs());
|
|
|
+ assertTrue(sql.contains("notify_status = #{"), "必须按 notify_status 等值过滤,实测: " + sql);
|
|
|
+ assertTrue(sql.contains("notify_count < #{"), "达到上限的行不再扫,实测: " + sql);
|
|
|
+ assertTrue(sql.contains("ORDER BY last_notify_time ASC"), "饿最久的先推,实测: " + sql);
|
|
|
+ assertTrue(sql.contains("LIMIT 50"), "必须带 LIMIT,实测: " + sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void retryFailed_failedOrderCountTwo_pushesAndMarksSuccess() throws Exception {
|
|
|
+ UnifiedOrderEntity failed = order("UP1", "2");
|
|
|
+ failed.setCallbackUrl("https://merchant.example.com/cb");
|
|
|
+ failed.setNotifyStatus(UnifiedNotifyStatus.FAILED);
|
|
|
+ failed.setNotifyCount(2);
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(failed));
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ MultiValueMap<String, String> form = formOf(capturePush(restTemplate, "https://merchant.example.com/cb"));
|
|
|
+ assertEquals("uo_UP1_2", form.getFirst("notify_id"), "重试保持同一个 notify_id");
|
|
|
+ JsonNode content = contentOf(form);
|
|
|
+ assertEquals(0, content.path("upstream").size(), "重试没有上游快照 ⇒ upstream 是空对象");
|
|
|
+
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureOrderUpdates(2);
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(updates.get(0), "notify_status"));
|
|
|
+ assertTrue(updates.get(0).getSqlSet().contains("notify_count = notify_count + 1"),
|
|
|
+ "这次尝试也要计数(含首次共 3 次)");
|
|
|
+ assertEquals(UnifiedNotifyStatus.SUCCESS, setValueOf(updates.get(1), "notify_status"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void retryFailed_afterSuccess_secondScanDoesNotPushAgain() {
|
|
|
+ UnifiedOrderEntity failed = order("UP1", "2");
|
|
|
+ failed.setCallbackUrl("https://merchant.example.com/cb");
|
|
|
+ failed.setNotifyStatus(UnifiedNotifyStatus.FAILED);
|
|
|
+ failed.setNotifyCount(2);
|
|
|
+ // 第一轮扫到它;成功后状态变 SUCCESS,第二轮扫描(模拟数据库)为空
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(failed)).thenReturn(List.of());
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ verify(restTemplate, times(1)).postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void retryFailed_refundWithoutOrder_skipsWithoutTouchingRow() {
|
|
|
+ UnifiedRefundEntity failed = refund();
|
|
|
+ failed.setCallbackUrl("https://merchant.example.com/cb");
|
|
|
+ failed.setNotifyStatus(UnifiedNotifyStatus.FAILED);
|
|
|
+ failed.setNotifyCount(1);
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(failed));
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(null);
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ verifyNoInteractions(restTemplate);
|
|
|
+ verify(orderMapper, never()).update(isNull(), any());
|
|
|
+ verify(refundMapper, never()).update(isNull(), any());
|
|
|
+ verify(orderMapper).selectOne(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void retryFailed_refundWithOrder_pushesAndMarksSuccess() {
|
|
|
+ UnifiedRefundEntity failed = refund();
|
|
|
+ failed.setCallbackUrl("https://merchant.example.com/cb");
|
|
|
+ failed.setNotifyStatus(UnifiedNotifyStatus.FAILED);
|
|
|
+ failed.setNotifyCount(1);
|
|
|
+ UnifiedOrderEntity order = order("UP1", "2");
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(failed));
|
|
|
+ when(orderMapper.selectOne(any())).thenReturn(order);
|
|
|
+ stubPushOk();
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ verify(restTemplate).postForEntity(eq("https://merchant.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ List<LambdaUpdateWrapper<?>> updates = captureRefundUpdates(2);
|
|
|
+ assertEquals(UnifiedNotifyStatus.FAILED, setValueOf(updates.get(0), "notify_status"));
|
|
|
+ assertEquals(UnifiedNotifyStatus.SUCCESS, setValueOf(updates.get(1), "notify_status"));
|
|
|
+ }
|
|
|
+
|
|
|
+ @Test
|
|
|
+ void retryFailed_scanThrows_doesNotPropagate() {
|
|
|
+ when(orderMapper.selectList(any())).thenThrow(new RuntimeException("db down"));
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ assertDoesNotThrow(() -> service.retryFailed());
|
|
|
+
|
|
|
+ verify(orderMapper).selectList(any());
|
|
|
+ verify(refundMapper).selectList(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ───────────────────────────── 断言工具 ─────────────────────────────
|
|
|
+
|
|
|
+ /** 只取 SET 段里某一列的绑定值(SET 与 WHERE 共用参数表,混着找会假绿)。 */
|
|
|
+ private static Object setValueOf(LambdaUpdateWrapper<?> w, String column) {
|
|
|
+ String sqlSet = w.getSqlSet();
|
|
|
+ assertNotNull(sqlSet, "SET 段为空");
|
|
|
+ for (String item : sqlSet.split(",")) {
|
|
|
+ if (!item.startsWith(column + "=")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ Matcher m = Pattern.compile("paramNameValuePairs\\.(\\w+)").matcher(item);
|
|
|
+ assertTrue(m.find(), "这一列没有绑定值,形状不认识: " + item);
|
|
|
+ return w.getParamNameValuePairs().get(m.group(1));
|
|
|
+ }
|
|
|
+ fail("SET 子句里没有这一列: " + column + ",实测 SET: " + sqlSet);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 只取 WHERE 段引用到的绑定值,不倒参数表全集。 */
|
|
|
+ private static Set<Object> whereBoundValues(LambdaUpdateWrapper<?> w) {
|
|
|
+ Matcher m = Pattern.compile("paramNameValuePairs\\.([A-Za-z0-9_]+)")
|
|
|
+ .matcher(w.getSqlSegment());
|
|
|
+ Set<Object> out = new HashSet<>();
|
|
|
+ while (m.find()) {
|
|
|
+ out.add(w.getParamNameValuePairs().get(m.group(1)));
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+}
|