|
|
@@ -0,0 +1,324 @@
|
|
|
+package com.payment.platform.module.payment.har.service;
|
|
|
+
|
|
|
+import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
|
|
+import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
+import com.payment.platform.module.payment.apikey.entity.TenantApiKeyEntity;
|
|
|
+import com.payment.platform.module.payment.apikey.mapper.TenantApiKeyMapper;
|
|
|
+import com.payment.platform.module.payment.har.config.HarProperties;
|
|
|
+import com.payment.platform.module.payment.har.entity.HarOrderEntity;
|
|
|
+import com.payment.platform.module.payment.har.enums.HarNotifyStatus;
|
|
|
+import com.payment.platform.module.payment.har.enums.HarOrderStatus;
|
|
|
+import com.payment.platform.module.payment.har.mapper.HarOrderMapper;
|
|
|
+import com.payment.platform.module.payment.openapi.mapper.OpenConfMapper;
|
|
|
+import com.payment.platform.module.payment.stream.OrderEventPublisher;
|
|
|
+import org.apache.ibatis.builder.MapperBuilderAssistant;
|
|
|
+import org.junit.jupiter.api.AfterEach;
|
|
|
+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.web.client.RestTemplate;
|
|
|
+
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.nio.file.Files;
|
|
|
+import java.nio.file.Path;
|
|
|
+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.*;
|
|
|
+import static org.mockito.ArgumentMatchers.*;
|
|
|
+import static org.mockito.Mockito.*;
|
|
|
+
|
|
|
+/**
|
|
|
+ * 下游商家回调的推送与重试契约(2026-10-09 补齐)。
|
|
|
+ *
|
|
|
+ * 【三条主断言与它们各自防的回归】
|
|
|
+ * ① 缺地址**记 FAILED 落库**而不是打日志就跳过 —— 旧实现停在 PENDING,
|
|
|
+ * 而重试扫描的入口条件是 notify_status='FAILED',商户事后补配地址也永远
|
|
|
+ * 等不到这一笔(见 {@link HarCallbackServiceTest#notifyEnterprise_noUrl_recordsFailedForRetryInsteadOfSkipping})。
|
|
|
+ * ② 推送**单次**,线程内不再退避重试 —— 旧实现在回调线程里睡 1s、2s 共 3 次,
|
|
|
+ * 一个卡住的商户地址会占满回调池,而上游通知还等着我们尽快返回 success。
|
|
|
+ * ③ {@link HarCallbackService#retryFailed} 的扫描形状五要素(状态字面量、
|
|
|
+ * 计数上界、排序方向、LIMIT、取值来源)—— 任一写错都是「重试静默失效而套件全绿」。
|
|
|
+ *
|
|
|
+ * 【为什么断言 SET 段而不是实体】写库刻意**不走实体**(实体带 @Version:回调线程
|
|
|
+ * 可能在 syncFromUpstream 事务提交前读到旧行版本,updateById 会静默丢写)⇒ 只能走
|
|
|
+ * UpdateWrapper,断言也只能拆 SET 子句。取值断言用 setValueOf(只看 SET 段),
|
|
|
+ * WHERE 的绑定值用 whereBoundValues 单独看 —— 两张表混在一起时 contains 会假绿
|
|
|
+ * (同 HarOrderServiceTest 里那两段注释的理由)。
|
|
|
+ */
|
|
|
+@ExtendWith(MockitoExtension.class)
|
|
|
+class HarCallbackServiceTest {
|
|
|
+
|
|
|
+ private static final Long TENANT = 7L;
|
|
|
+
|
|
|
+ @Mock private TenantApiKeyMapper apiKeyMapper;
|
|
|
+ @Mock private OpenConfMapper confMapper;
|
|
|
+ @Mock private HarOrderMapper orderMapper;
|
|
|
+ @Mock private RestTemplate restTemplate;
|
|
|
+ @Mock private OrderEventPublisher publisher;
|
|
|
+
|
|
|
+ private HarProperties props;
|
|
|
+ private HarCallbackService service;
|
|
|
+
|
|
|
+ @BeforeEach
|
|
|
+ void setUp() {
|
|
|
+ props = new HarProperties();
|
|
|
+ props.setCallbackMaxRetries(8);
|
|
|
+ service = new HarCallbackService(apiKeyMapper, confMapper, orderMapper, restTemplate, props, publisher);
|
|
|
+ // 初始化 MyBatis-Plus lambda 元数据缓存,使 LambdaQueryWrapper.getSqlSegment() 可在无 Spring 上下文的单测中工作
|
|
|
+ // (同款引导见 LsyCallbackServiceTest / HarOrderServiceTest。)
|
|
|
+ MybatisConfiguration configuration = new MybatisConfiguration();
|
|
|
+ TableInfoHelper.initTableInfo(new MapperBuilderAssistant(configuration, ""), HarOrderEntity.class);
|
|
|
+ }
|
|
|
+
|
|
|
+ @AfterEach
|
|
|
+ void tearDown() {
|
|
|
+ // 回调线程池是 service 自有的(非 Spring 管理),单测里手动收,避免线程泄漏影响后续用例
|
|
|
+ service.shutdown();
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 夹具订单:只给「哪里都没配回调地址」用得着的三格 */
|
|
|
+ private HarOrderEntity order(String apiKey) {
|
|
|
+ HarOrderEntity o = new HarOrderEntity();
|
|
|
+ o.setOrderNo("HAR1");
|
|
|
+ o.setTenantId(TENANT);
|
|
|
+ o.setTradeStatus(HarOrderStatus.PAID);
|
|
|
+ o.setApiKey(apiKey);
|
|
|
+ return o;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把「Key 上有回调地址」这一前提收成一行 */
|
|
|
+ private void stubKeyWithUrl(String url) {
|
|
|
+ TenantApiKeyEntity key = new TenantApiKeyEntity();
|
|
|
+ key.setReturnUrl(url);
|
|
|
+ when(apiKeyMapper.selectOne(any())).thenReturn(key);
|
|
|
+ }
|
|
|
+
|
|
|
+ private void stubPushOk() {
|
|
|
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenReturn(new ResponseEntity<>("ok", HttpStatus.OK));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 缺地址不再静默跳过 —— 记 FAILED 落库,交给调度器重试。
|
|
|
+ *
|
|
|
+ * 【为什么这条是主断言】旧实现只 log 一句就 return(notify_status 停在 PENDING),
|
|
|
+ * 商户事后补配了地址也永远等不到这一笔:重试扫描的条件是 notify_status='FAILED'。
|
|
|
+ * 而 notify_count / last_notify_time 必须一起落:前者是重试的封顶计数,后者是
|
|
|
+ * 扫描的排序键 —— 少一个,重试扫描要么捞不到、要么排不对。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void notifyEnterprise_noUrl_recordsFailedForRetryInsteadOfSkipping() {
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ service.notifyEnterprise(order(null)); // apiKey 为空、租户也没配 ⇒ 哪里都没地址
|
|
|
+
|
|
|
+ verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class));
|
|
|
+
|
|
|
+ ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(orderMapper).update(isNull(), w.capture());
|
|
|
+ assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
|
|
|
+ "缺地址也要落 FAILED(重试扫描的入口条件)。实测 SET: " + w.getValue().getSqlSet());
|
|
|
+ assertTrue(w.getValue().getSqlSet().contains("notify_count = COALESCE(notify_count, 0) + 1"),
|
|
|
+ "计数要在 SQL 侧自增(并发重试时 read-modify-write 会丢计数,计数丢了上限形同虚设)。实测 SET: "
|
|
|
+ + w.getValue().getSqlSet());
|
|
|
+ assertNotNull(setValueOf(w.getValue(), "last_notify_time"),
|
|
|
+ "排序键不能为空。实测 SET: " + w.getValue().getSqlSet());
|
|
|
+ assertNotNull(setValueOf(w.getValue(), "updated_time"),
|
|
|
+ "update(null,wrapper) 不经自动填充,updated_time 要手写。实测 SET: " + w.getValue().getSqlSet());
|
|
|
+ assertTrue(whereBoundValues(w.getValue()).contains("HAR1"),
|
|
|
+ "WHERE 必须钉住这一单。实测: " + w.getValue().getSqlSegment());
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 有地址、推送 2xx ⇒ 记 SUCCESS + 计数 + 订单信号 */
|
|
|
+ @Test
|
|
|
+ void notifyEnterprise_pushOk_recordsSuccessWithCountAndSignal() {
|
|
|
+ stubKeyWithUrl("https://merchant.example.com/cb");
|
|
|
+ stubPushOk();
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ service.notifyEnterprise(order("K1"));
|
|
|
+
|
|
|
+ ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(orderMapper, timeout(3000)).update(isNull(), w.capture());
|
|
|
+ assertEquals(HarNotifyStatus.SUCCESS, setValueOf(w.getValue(), "notify_status"),
|
|
|
+ "2xx ⇒ SUCCESS。实测 SET: " + w.getValue().getSqlSet());
|
|
|
+ verify(restTemplate).postForEntity(eq("https://merchant.example.com/cb"),
|
|
|
+ any(HttpEntity.class), eq(String.class));
|
|
|
+ verify(publisher).changed(OrderEventPublisher.CHANNEL_HAR_ORDER, TENANT);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 推送抛异常 ⇒ 记 FAILED,且**恰好一次 POST** —— 线程内不重试。
|
|
|
+ *
|
|
|
+ * 旧实现在回调线程里睡 1s、2s 重试共 3 次;这条用例把「重试交给调度器」钉死,
|
|
|
+ * 它同时也挡住「顺手再加个退避循环」的回归(times(1) 会红)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void notifyEnterprise_pushThrows_recordsFailedWithSingleAttemptInThread() {
|
|
|
+ stubKeyWithUrl("https://merchant.example.com/cb");
|
|
|
+ when(restTemplate.postForEntity(anyString(), any(HttpEntity.class), eq(String.class)))
|
|
|
+ .thenThrow(new RuntimeException("connection refused"));
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ service.notifyEnterprise(order("K1"));
|
|
|
+
|
|
|
+ ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(orderMapper, timeout(3000)).update(isNull(), w.capture());
|
|
|
+ assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
|
|
|
+ "实测 SET: " + w.getValue().getSqlSet());
|
|
|
+ verify(restTemplate, times(1)).postForEntity(anyString(), any(HttpEntity.class), eq(String.class));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * {@code retryFailed()} 的扫描形状 —— 与乐收赢 LsyCallbackService#retryFailed 对齐。
|
|
|
+ *
|
|
|
+ * 【为什么五个要素全钉】状态字面量、计数上界、排序方向、LIMIT 任一写错,症状都是
|
|
|
+ * 「失败回调永远不被重试而套件全绿」—— 没有别的任务会断言这个查询。
|
|
|
+ * 【取值为什么定 3 而不是默认 8】钉住上界来自 HarProperties 而不是写死的数字。
|
|
|
+ * 【断言为什么分两半】MyBatis-Plus 的 .eq()/.lt() 不把值写进 SQL 文本(塞进
|
|
|
+ * paramNameValuePairs)⇒ 结构看 getSqlSegment(),取值看参数表,取值用**恰好相等**。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void retryFailed_pinsScanQuery() {
|
|
|
+ props.setCallbackMaxRetries(3);
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of());
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ ArgumentCaptor<LambdaQueryWrapper> scan = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
|
|
+ verify(orderMapper).selectList(scan.capture());
|
|
|
+ LambdaQueryWrapper<?> w = scan.getValue();
|
|
|
+ String sql = w.getSqlSegment();
|
|
|
+
|
|
|
+ assertEquals(Set.of(HarNotifyStatus.FAILED, 3), new HashSet<>(w.getParamNameValuePairs().values()),
|
|
|
+ "取值必须恰好是 {FAILED, 上限},实测: " + w.getParamNameValuePairs());
|
|
|
+ assertTrue(sql.contains("notify_status = #{"),
|
|
|
+ "必须按 notify_status 等值过滤,实测: " + sql);
|
|
|
+ assertTrue(sql.contains("notify_count < #{"),
|
|
|
+ "必须按 notify_count 严格小于过滤(达上限的单不再推),实测: " + sql);
|
|
|
+ assertTrue(sql.contains("ORDER BY last_notify_time ASC"),
|
|
|
+ "饿最久的先推,实测: " + sql);
|
|
|
+ assertTrue(sql.contains("LIMIT 50"),
|
|
|
+ "必须带 LIMIT,实测: " + sql);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 扫出来的每一单都要真的再推一次(扫描不是只记日志) */
|
|
|
+ @Test
|
|
|
+ void retryFailed_dispatchesPushForEachScannedOrder() {
|
|
|
+ HarOrderEntity first = order("K1");
|
|
|
+ HarOrderEntity second = order("K1");
|
|
|
+ second.setOrderNo("HAR2");
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(first, second));
|
|
|
+ stubKeyWithUrl("https://merchant.example.com/cb");
|
|
|
+ stubPushOk();
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ verify(restTemplate, timeout(3000).times(2))
|
|
|
+ .postForEntity(eq("https://merchant.example.com/cb"), any(HttpEntity.class), eq(String.class));
|
|
|
+ verify(orderMapper, timeout(3000).times(2)).update(isNull(), any());
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 重试时**仍然**没有地址 ⇒ 再记一次 FAILED(计数 +1)。
|
|
|
+ *
|
|
|
+ * 这次尝试也要计数:否则「一直没配地址」的单会被每轮调度无限重扫(上限永远够不着)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void retryFailed_scannedOrderWithoutUrl_recordsAnotherFailedAttempt() {
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(order(null)));
|
|
|
+ when(orderMapper.update(isNull(), any())).thenReturn(1);
|
|
|
+
|
|
|
+ service.retryFailed();
|
|
|
+
|
|
|
+ verify(restTemplate, never()).postForEntity(anyString(), any(), eq(String.class));
|
|
|
+ ArgumentCaptor<UpdateWrapper> w = ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
+ verify(orderMapper).update(isNull(), w.capture());
|
|
|
+ assertEquals(HarNotifyStatus.FAILED, setValueOf(w.getValue(), "notify_status"),
|
|
|
+ "实测 SET: " + w.getValue().getSqlSet());
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 扫描本身炸了(库抖动)不能把异常抛给调度线程 —— 那上面还挂着轮询主任务 */
|
|
|
+ @Test
|
|
|
+ void retryFailed_scanThrows_doesNotPropagate() {
|
|
|
+ when(orderMapper.selectList(any())).thenThrow(new RuntimeException("db down"));
|
|
|
+
|
|
|
+ assertDoesNotThrow(() -> service.retryFailed());
|
|
|
+ verify(orderMapper).selectList(any());
|
|
|
+ }
|
|
|
+
|
|
|
+ // ─────────────────────────── 交付脚本的 schema 前提 ───────────────────────────
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 重试腿的两列只存在于**交付给用户的建表脚本**里(本工程未接 Flyway,脚本由用户
|
|
|
+ * 手工执行)—— 代码这边只有实体映射与查询。脚本缺一列 ⇒ 每次回调尝试的 UPDATE
|
|
|
+ * 都抛「column does not exist」,被 {@code writeNotifyResult} 的 catch 吞成一行
|
|
|
+ * warn(表现:回调推了但状态永远 PENDING),且那段窗口事后补跑脚本也救不回来
|
|
|
+ * (PENDING 不在重试扫描的捞取范围内)。所以这里不读代码,直接读那份脚本。
|
|
|
+ *
|
|
|
+ * <p>封顶那句(存量不补推)是**用户裁定**的落地处:执行那一刻已有的 FAILED 单
|
|
|
+ * 不能因为新列默认 0 被当成「没试满」全部补推。删掉它、或把上限值改得与
|
|
|
+ * {@code har.callback-max-retries} 不一致(默认 8,生产配得比 8 大时脚本要同步改),
|
|
|
+ * 部署时就会把存量积压重推一轮 —— 而套件除了本用例没有任何红点。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void schemaScript_addsRetryColumnsAndSealsExistingFailedRows() throws Exception {
|
|
|
+ Path sql = Path.of("src/main/resources/db/har-callback-retry.sql");
|
|
|
+ assertTrue(Files.exists(sql), "找不到建表脚本(本用例读的是相对 java/ 的仓库路径): " + sql.toAbsolutePath());
|
|
|
+ String text = new String(Files.readAllBytes(sql), StandardCharsets.UTF_8);
|
|
|
+
|
|
|
+ assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS notify_count int NOT NULL DEFAULT 0.*"),
|
|
|
+ "notify_count 列不见了 —— 每次回调尝试都写它(SQL 侧自增),缺列即每次写库抛错");
|
|
|
+ assertTrue(text.matches("(?s).*ALTER TABLE pay_har_order ADD COLUMN IF NOT EXISTS last_notify_time timestamp.*"),
|
|
|
+ "last_notify_time 列不见了 —— 重试扫描的排序键,缺列即重试扫描每分钟抛一次错");
|
|
|
+ assertTrue(text.matches("(?s).*UPDATE pay_har_order SET notify_count = 8 WHERE notify_status = 'FAILED' AND notify_count = 0.*"),
|
|
|
+ "存量 FAILED 行的封顶句不见了/被改了 —— 「存量不补推」是 2026-10-09 的用户裁定:"
|
|
|
+ + "新列默认 0 会让存量失败单立刻被当成「没试满」全部重推。值必须 = har.callback-max-retries(默认 8)");
|
|
|
+ assertTrue(text.matches("(?s).*CREATE INDEX IF NOT EXISTS idx_har_order_callback_retry\\s+ON pay_har_order \\(last_notify_time\\) WHERE notify_status = 'FAILED'.*"),
|
|
|
+ "重试扫描的部分索引不见了 —— 该查询唯一的热路径(谓词 + 排序键一个索引全包)");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 取 SET 子句里某一列**绑定**的值 —— 只看 SET,不看 WHERE。
|
|
|
+ *
|
|
|
+ * 为什么不直接倒 paramNameValuePairs 全集:那张表 SET 与 WHERE 共用,
|
|
|
+ * contains 可能被另一段满足(同 HarOrderServiceTest#setValueOf 的理由)。
|
|
|
+ */
|
|
|
+ private static Object setValueOf(UpdateWrapper<?> w, String column) {
|
|
|
+ for (String item : w.getSqlSet().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: " + w.getSqlSet());
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 只取 WHERE 段引用到的绑定值,不倒参数表全集 —— 后者会把 SET 的绑定值也算进来 */
|
|
|
+ private static Set<Object> whereBoundValues(UpdateWrapper<?> 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;
|
|
|
+ }
|
|
|
+}
|