|
@@ -19,6 +19,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
import org.mockito.Mock;
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
|
|
|
|
|
|
|
+import java.time.OffsetDateTime;
|
|
|
import java.util.LinkedHashMap;
|
|
import java.util.LinkedHashMap;
|
|
|
import java.util.LinkedHashSet;
|
|
import java.util.LinkedHashSet;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
@@ -29,6 +30,7 @@ import java.util.regex.Pattern;
|
|
|
|
|
|
|
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
|
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
|
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.assertNotNull;
|
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
@@ -443,13 +445,22 @@ class LsySchedulerTest {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * 扫描条件必须是「非终态 **且** 超过阈值」。
|
|
|
|
|
|
|
+ * 扫描条件 = 非终态 **且 游标到期**,并且**按游标升序**取。
|
|
|
*
|
|
*
|
|
|
- * <p>少了 `status IN PENDING` 这一半,这条腿会对**每一笔历史退款**问上游一遍;
|
|
|
|
|
- * 少了时间阈值那一半,它会对刚发起的退款立刻问一次(那时上游必然回 PAY_IN_PROCESS)。
|
|
|
|
|
|
|
+ * <p>少了 `status IN PENDING` 这一半,这条腿会对**每一笔历史退款**问上游一遍。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ 「游标到期」替代「`created_time` 超过阈值」是 2026-09-22 的裁定(台账 §2.3)。
|
|
|
|
|
+ * 旧谓词是**单调**的 —— 一旦为真永远为真 —— 而卡住的行根本不写库
|
|
|
|
|
+ * (`LsyRefundService.settleFromQuery` 的在途/未知码分支一次 mapper 调用都没有)
|
|
|
|
|
+ * ⇒ 元组不移位 ⇒ 每轮返回的是**同一批**,`LIMIT 100` 之外的行**永远轮不到**。
|
|
|
|
|
+ * 饿死因此是**稳态**而非概率事件。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>★ `ORDER BY` 与谓词是**配套的、不可拆**:只换谓词不加排序,取到的仍是任意的一批;
|
|
|
|
|
+ * 只加排序不换谓词,谓词单调 ⇒ 「最老的一批」被反复取到,饿死反而变成**确定性的**。
|
|
|
|
|
+ * 两条断言因此写在同一个用例里 —— 它们描述的是同一个不变量。
|
|
|
*/
|
|
*/
|
|
|
@Test
|
|
@Test
|
|
|
- void reconcilePendingRefunds_扫描条件为非终态且超过阈值() {
|
|
|
|
|
|
|
+ void reconcilePendingRefunds_扫描条件为非终态且游标到期_并按游标升序() {
|
|
|
when(refundMapper.selectList(any())).thenReturn(List.of());
|
|
when(refundMapper.selectList(any())).thenReturn(List.of());
|
|
|
|
|
|
|
|
scheduler().reconcilePendingRefunds();
|
|
scheduler().reconcilePendingRefunds();
|
|
@@ -461,8 +472,93 @@ class LsySchedulerTest {
|
|
|
|
|
|
|
|
assertEquals(LsyRefundStatus.PENDING, allowedSourceStatuses(w),
|
|
assertEquals(LsyRefundStatus.PENDING, allowedSourceStatuses(w),
|
|
|
"退款对账的扫描必须是 status IN LsyRefundStatus.PENDING,实测: " + where);
|
|
"退款对账的扫描必须是 status IN LsyRefundStatus.PENDING,实测: " + where);
|
|
|
- assertTrue(where.contains("created_time <="),
|
|
|
|
|
- "必须带创建时间阈值,否则会对刚发起的退款立刻问上游,实测: " + where);
|
|
|
|
|
|
|
+ assertTrue(where.contains("next_query_time <="),
|
|
|
|
|
+ "必须按**会前进的游标**筛到期,实测: " + where);
|
|
|
|
|
+ assertFalse(where.contains("created_time"),
|
|
|
|
|
+ "不得再按 created_time 筛:单谓词=每轮取同一批=饿死,实测: " + where);
|
|
|
|
|
+ // 方向必须一起钉:`ORDER BY next_query_time` 单独成立时,DESC 也满足它 ——
|
|
|
|
|
+ // 而 DESC 恰好把机制**反向**(最老的一批永远排最前,饿死从"任意一批"升级成"确定的那批")。
|
|
|
|
|
+ assertTrue(where.contains("ORDER BY next_query_time ASC"),
|
|
|
|
|
+ "必须按游标**升序**取(升序 = 等得最久的先服务 = 轮转),实测: " + where);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 未收敛的行必须被推走 —— 这是消除饿死的**机制本体**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么必须推】`settleFromQuery` 返回 null 的行(上游回 `PAY_IN_PROCESS`、或回了
|
|
|
|
|
+ * 我们没见过的码)**一次库都不写**。对账腿若也不推它,它的 `next_query_time` 就永远停在
|
|
|
|
|
+ * 过去 ⇒ 每轮都排在 `ORDER BY next_query_time ASC` 的最前面 ⇒ 这样的行一旦多到 `LIMIT 100`,
|
|
|
|
|
+ * 后面的行**永远轮不到**。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【不变量】处理过的行,其新游标 `now + delay` **严格大于**所有「已到期且本轮未处理」的
|
|
|
|
|
+ * 行的游标 ⇒ 下一轮选中的 100 行与本轮选中的(仍留在 PENDING 的)**不相交** ⇒ 每行都在
|
|
|
|
|
+ * ⌈N/100⌉ 轮内必被问到。这条用例钉的就是那个严格大于。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void reconcilePendingRefunds_未收敛时必须把游标推到未来() {
|
|
|
|
|
+ LsyRefundEntity r = pendingRefund();
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(r));
|
|
|
|
|
+ when(refundService.queryRefund(r)).thenReturn(resp("PAY_IN_PROCESS"));
|
|
|
|
|
+ when(refundService.settleFromQuery(eq(r), any())).thenReturn(null); // 未流转
|
|
|
|
|
+
|
|
|
|
|
+ OffsetDateTime before = OffsetDateTime.now();
|
|
|
|
|
+ scheduler().reconcilePendingRefunds();
|
|
|
|
|
+
|
|
|
|
|
+ var patch = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
|
|
+ verify(refundMapper).update(patch.capture(), any());
|
|
|
|
|
+ OffsetDateTime next = patch.getValue().getNextQueryTime();
|
|
|
|
|
+ assertNotNull(next, "必须写 next_query_time —— 写成 null 等于把这一行踢出对账集");
|
|
|
|
|
+ assertTrue(next.isAfter(before.plusSeconds(props.getReconcileDelaySeconds() - 1)),
|
|
|
|
|
+ "必须推到 now + reconcileDelaySeconds 之后,实测: " + next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 查询**抛异常**的行走的也是同一条路:必须推。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>只对「未收敛」推是不够的。若某些行稳定地让客户端抛异常(参数被上游拒答、
|
|
|
|
|
+ * 特定 host 连不通等),它们的 `settleFromQuery` 根本不会被调用到,返回值那份判据
|
|
|
|
|
+ * **看不见它们** ⇒ 它们把 `LIMIT 100` 的头部占死,而「未收敛」那些行反而轮得到。
|
|
|
|
|
+ * 两种情形是同一个缺口的两副面孔,各需独立用例。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void reconcilePendingRefunds_查询抛异常时也必须把游标推到未来() {
|
|
|
|
|
+ LsyRefundEntity r = pendingRefund();
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(r));
|
|
|
|
|
+ when(refundService.queryRefund(r)).thenThrow(new RuntimeException("网络超时"));
|
|
|
|
|
+
|
|
|
|
|
+ OffsetDateTime before = OffsetDateTime.now();
|
|
|
|
|
+ assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
|
|
|
|
|
+
|
|
|
|
|
+ var patch = org.mockito.ArgumentCaptor.forClass(LsyRefundEntity.class);
|
|
|
|
|
+ verify(refundMapper).update(patch.capture(), any());
|
|
|
|
|
+ OffsetDateTime next = patch.getValue().getNextQueryTime();
|
|
|
|
|
+ assertNotNull(next, "抛异常的行走的也是同一条路,同样不得留在原地");
|
|
|
|
|
+ assertTrue(next.isAfter(before.plusSeconds(props.getReconcileDelaySeconds() - 1)),
|
|
|
|
|
+ "实测: " + next);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 推游标必须是**条件更新**,守卫 `status IN PENDING` 写在 WHERE 里。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【为什么这条不能靠返回值】`update` 的返回值是测试自己 stub 的,它证明不了 WHERE 长什么样。
|
|
|
|
|
+ * 已收敛的行(本轮真的写成了 SUCCESS/FAIL)不该再被写游标 —— 不是因为它危险,
|
|
|
|
|
+ * 是因为写入本身没有意义,而本模块只允许「守卫在 WHERE 里」这一种条件更新风格
|
|
|
|
|
+ * (`applyPayResult` / `markExpired` / `applyCloseResult` 同形)。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void reconcilePendingRefunds_推游标带非终态守卫() {
|
|
|
|
|
+ LsyRefundEntity r = pendingRefund();
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(r));
|
|
|
|
|
+ when(refundService.queryRefund(r)).thenReturn(resp("PAY_SUCCESS"));
|
|
|
|
|
+ when(refundService.settleFromQuery(eq(r), any())).thenReturn("SUCCESS");
|
|
|
|
|
+
|
|
|
|
|
+ scheduler().reconcilePendingRefunds();
|
|
|
|
|
+
|
|
|
|
|
+ var captor = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
|
|
+ verify(refundMapper, atLeastOnce()).update(any(), captor.capture());
|
|
|
|
|
+ assertEquals(LsyRefundStatus.PENDING, allowedSourceStatuses(captor.getValue()),
|
|
|
|
|
+ "推游标必须带 status IN PENDING 守卫,实测: " + captor.getValue().getSqlSegment());
|
|
|
|
|
+ verify(refundMapper, never()).updateById(any());
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@Test
|
|
@Test
|