|
|
@@ -408,97 +408,100 @@ class LsySchedulerTest {
|
|
|
verify(orderMapper, never()).updateById(any());
|
|
|
}
|
|
|
|
|
|
- // ============ 关单被拒「该聚合码链接不存在或已过期」⇒ 本地收敛(2026-09-30) ============
|
|
|
+ // ============ 关单被拒「该聚合码链接不存在或已过期」(2026-09-30) ============
|
|
|
//
|
|
|
- // 【为什么改】原实现把这条拒绝记成带栈 ERROR 并**什么都不做**:订单留在 PAYING、
|
|
|
- // 每 5 分钟重试一次,直到 absoluteExpireHours(24 小时)才被绝对过期收走。而对外契约
|
|
|
- //(collection-openapi.md 的 FAQ「用户一直不付款,二维码会怎样?」)写的是
|
|
|
- // 「有效期默认 15 分钟(可用 time_expire 指定)。超时后订单状态变为 EXPIRED」——
|
|
|
- // 实现与契约相反,那个 EXPIRED 要等 24 小时才来。
|
|
|
+ // 【这一段的判据在 2026-09-30 从本类**搬进了服务** —— 所以这里只剩反向守卫】
|
|
|
+ // 原因:收这条拒绝的腿有**三条**(收银台按钮 / 开放 API / 本调度器第 ④ 条),
|
|
|
+ // 把它们各自的 catch 各写一遍,等于同一个上游措辞在三处各判各的、值也可能各写各的。
|
|
|
+ // 现在判在唯一的调用点 `LsyCancelService.closeQrOrder` 上,返回 "EXPIRED",
|
|
|
+ // 本调度器只是照常把它落库(`applyCloseResult`:PENDING 条件更新 + 通知下游)——
|
|
|
+ // 那条落库路径由上面 `applyCloseResult` 的 CLOSED 用例覆盖,此处再写一遍就退化成
|
|
|
+ // 「测 mock 返回了什么」。判据、90 次 0 成功的读数、以及「本地已过期」这个承重的
|
|
|
+ // 附加条件,全部记在 `LsyCancelServiceTest` 的三条用例与那里的注释里。
|
|
|
//
|
|
|
- // 【判据是复发的读数,不是形状】改变结论的正是原注释自认「我没有」的那份读数:
|
|
|
- // ① 2026-09-30 那次运行(logs/payment-platform.log):同一笔 QR1790675073,
|
|
|
- // 19 次 `/v3/qrclose` 请求 / 19 条这条拒绝 / **0 次成功**;
|
|
|
- // ② 2026-09-22 那份(java/logs/payment-platform-error.log):3 笔单 71 次拒绝,同样 0 次成功;
|
|
|
- // ③ 历史上**唯一成功过**的 qrclose 是探针拿**刚建出来**的码关的
|
|
|
- // (logs/_lsy-close-control.txt 对照二,`s-00006`,建单后约 1 秒即关)。
|
|
|
- // ⇒ 本腿只捞「已过期」的码,在 90 次实测里一次都没成功过。「上游不认这个 code_url
|
|
|
- // 属反常」这个前提被复发率推翻 —— 它就是这个腿的正常遭遇。
|
|
|
+ // 【原正向用例断言过 `patch.finishTime` 非空,现在**故意不写了**】那时收敛走
|
|
|
+ // `markExpired`(它会写 finish_time),现在走 `applyCloseResult`(不写)——
|
|
|
+ // 同一个上游拒绝,从按钮腿收敛和从调度腿收敛落下来的行必须**长得一样**,
|
|
|
+ // 所以取值只能有一条路。这一格的空缺今天不可见,已实测:`pay_lsy_order.finish_time`
|
|
|
+ // **没有读者** —— 本模块里唯一一处 `getFinishTime()` 是 `LsyOrderService.applyPayResult`
|
|
|
+ // 把刚写下的值回填进内存实体(不参与任何判定);前端 `frontend/src` 全文只有**退款**表
|
|
|
+ // 渲染 `finish_time`。写在这里是为了将来有人补这一格时知道它不是被漏掉的。
|
|
|
//
|
|
|
- // 【为什么不是 CLOSED】上游**没有**关掉它(它回的是拒绝)。写 CLOSED 等于替上游宣布一个
|
|
|
- // 它没做的动作,而 CLOSED 还在 LsyOrderStatus.REFUND_WRITE_FROM 里。EXPIRED 才是这里的事实:
|
|
|
- // 码已失效、谁也付不进来。同族的两处「句柄不可用」分支(closeQrOrder 的 code_url 缺失、
|
|
|
- // closeH5Order 的 trade_no 缺失)返回的也都是 EXPIRED。
|
|
|
+ /**
|
|
|
+ * 反向守卫:**别的**关单失败必须仍是带栈 ERROR,且一个字都不许改状态。
|
|
|
+ *
|
|
|
+ * <p>没有这一条,一个「凡关单抛异常就本地收敛」的实现能让上面那条全绿 —— 而那是把
|
|
|
+ * **真故障**(验签失败、商户未配置、参数被拒)当成「码已失效」:一笔**还能被支付**的单
|
|
|
+ * 会被本地判死,且再也不重试。失败方向与 {@link LsyReconcileScheduler} 里
|
|
|
+ * {@code upstreamSaysNotFound} 那条一致:判据只能是我们**测过的上游原文**,
|
|
|
+ * 措辞变了就退回吵闹的 ERROR + 保持原状。
|
|
|
+ *
|
|
|
+ * <p>★ 判据搬进服务之后,本条钉的**仍然是调度器这一层的失败方向**:服务对
|
|
|
+ * 「没测过的措辞」不接(见 `LsyCancelServiceTest#qrcode_其他上游失败_仍抛出`),
|
|
|
+ * 抛到这里必须还是 ERROR + 栈 + 状态不动。两条一起才封住「凡异常就收敛」这个错法
|
|
|
+ * ——只测服务那一层时,把调度器 catch 改成无条件收敛不会有任何测试变红。
|
|
|
+ */
|
|
|
@Test
|
|
|
- void closeExpiredQrOrders_上游说链接已不存在或已过期_本地收敛为EXPIRED() {
|
|
|
+ void closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态() {
|
|
|
LsyOrderEntity o = paying(0);
|
|
|
o.setPayScene("QRCODE");
|
|
|
o.setCodeUrl("https://test.gdyfsk.com/yfpay/s/s-00006");
|
|
|
- o.setCodeUrlExpireTime(OffsetDateTime.now().minusMinutes(1)); // 本腿的扫描条件:已过期
|
|
|
when(orderMapper.selectList(any())).thenReturn(List.of(o));
|
|
|
when(cancelService.closeOrder(o)).thenThrow(
|
|
|
- new LeshouyingApiException("FAIL", "该聚合码链接不存在或已过期!", "{}"));
|
|
|
- when(orderMapper.update(any(), any())).thenReturn(1);
|
|
|
+ new LeshouyingApiException("FAIL", "验签失败", "{}"));
|
|
|
|
|
|
withLogCapture(LsyReconcileScheduler.class, events -> {
|
|
|
assertDoesNotThrow(() -> scheduler().closeExpiredQrOrders());
|
|
|
|
|
|
- assertTrue(errors(events).isEmpty(),
|
|
|
- "码已失效是可解释的业务回答,不得记 ERROR,实测: " + errors(events));
|
|
|
- List<ILoggingEvent> warns = warns(events);
|
|
|
- assertEquals(1, warns.size(), "应恰有一条收敛 WARN,实测: " + messages(events));
|
|
|
- assertNull(warns.get(0).getThrowableProxy(), "收敛路径不挂栈");
|
|
|
- assertTrue(warns.get(0).getFormattedMessage().contains("OUT001"),
|
|
|
- "必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
|
|
|
- assertTrue(warns.get(0).getFormattedMessage().contains("该聚合码链接不存在或已过期"),
|
|
|
- "上游原文必须留下 —— 否则排障时看不出是哪种拒绝,实测: "
|
|
|
- + warns.get(0).getFormattedMessage());
|
|
|
+ assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
|
|
|
+ assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
|
|
|
+ .findFirst().orElseThrow().getThrowableProxy(),
|
|
|
+ "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
|
|
|
});
|
|
|
|
|
|
- var patch = org.mockito.ArgumentCaptor.forClass(LsyOrderEntity.class);
|
|
|
- var where = org.mockito.ArgumentCaptor.forClass(UpdateWrapper.class);
|
|
|
- verify(orderMapper).update(patch.capture(), where.capture());
|
|
|
- assertEquals("EXPIRED", patch.getValue().getStatus(), "写进去的必须是 EXPIRED,不是 CLOSED");
|
|
|
- assertNotNull(patch.getValue().getFinishTime(), "收敛要落结单时间(同 markExpired)");
|
|
|
- assertEquals(LsyOrderStatus.PENDING, allowedSourceStatuses(where.getValue()),
|
|
|
- "守卫仍是「还没终态」—— 回调抢先写了 SUCCESS 时这条不得覆盖它");
|
|
|
- assertEquals("EXPIRED", o.getStatus());
|
|
|
- verify(callbackService).notifyOrder(o);
|
|
|
- verify(orderMapper, never()).updateById(any());
|
|
|
+ verify(orderMapper, never()).update(any(), any());
|
|
|
+ assertEquals("PAYING", o.getStatus(), "没拿到「码已失效」这条判据时不得改动状态");
|
|
|
+ verify(callbackService, never()).notifyOrder(any());
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 反向守卫:**别的**关单失败必须仍是带栈 ERROR,且一个字都不许改状态。
|
|
|
+ * 反向守卫(二):**这一句原文**从服务里抛出来时,调度器也**不得**就地收敛。
|
|
|
*
|
|
|
- * <p>没有这一条,一个「凡关单抛异常就本地收敛」的实现能让上面那条全绿 —— 而那是把
|
|
|
- * **真故障**(验签失败、商户未配置、参数被拒)当成「码已失效」:一笔**还能被支付**的单
|
|
|
- * 会被本地判死,且再也不重试。失败方向与 {@link LsyReconcileScheduler} 里
|
|
|
- * {@code upstreamSaysNotFound} 那条一致:判据只能是我们**测过的上游原文**,
|
|
|
- * 措辞变了就退回吵闹的 ERROR + 保持原状。
|
|
|
+ * <p>它挡的是「把判据搬回调度器 catch」这个回退。判据一旦在服务之外再判一次,
|
|
|
+ * 承载它的那个附加条件(**本地也要已过期**)就不在那条路上了 —— 而那正是
|
|
|
+ * 2026-09-30 这次改判的全部要害:这句话有两个读法(「不存在」**或**「已过期」),
|
|
|
+ * 我们对「本地还活着的码会不会回这句话」没有任何读数,所以两句话必须**同时**成立
|
|
|
+ * 才允许判死(见 `LsyCancelServiceTest` 的段首注释)。
|
|
|
+ *
|
|
|
+ * <p>服务在「本地还没过期」时会**故意**把它抛出来(那是两边互相矛盾,是个异常)。
|
|
|
+ * 调度器跟着判死,就等于把那个矛盾当成终局:EXPIRED 不在 PAY_WRITE_FROM 里,
|
|
|
+ * 一笔可能还能被支付的单会被永久判死。
|
|
|
*
|
|
|
- * <p>本条在加实现**之前**就是绿的(钉的是既有行为),价值由变异证明:
|
|
|
- * 把收敛改成无条件 ⇒ 本条必须红。
|
|
|
+ * <p>本条在搬判据**之前**是绿的(当时这句由调度器自己接住),价值由变异证明:
|
|
|
+ * 在 catch 里加回一个按这句原文收敛的分支 ⇒ 本条必须红。
|
|
|
*/
|
|
|
@Test
|
|
|
- void closeExpiredQrOrders_其他上游失败_仍是带栈ERROR且不动状态() {
|
|
|
+ void closeExpiredQrOrders_服务原样抛出这一句_调度器不得就地收敛() {
|
|
|
LsyOrderEntity o = paying(0);
|
|
|
o.setPayScene("QRCODE");
|
|
|
o.setCodeUrl("https://test.gdyfsk.com/yfpay/s/s-00006");
|
|
|
+ o.setCodeUrlExpireTime(OffsetDateTime.now().minusMinutes(1));
|
|
|
when(orderMapper.selectList(any())).thenReturn(List.of(o));
|
|
|
when(cancelService.closeOrder(o)).thenThrow(
|
|
|
- new LeshouyingApiException("FAIL", "验签失败", "{}"));
|
|
|
+ new LeshouyingApiException("FAIL", "该聚合码链接不存在或已过期!", "{}"));
|
|
|
|
|
|
withLogCapture(LsyReconcileScheduler.class, events -> {
|
|
|
assertDoesNotThrow(() -> scheduler().closeExpiredQrOrders());
|
|
|
|
|
|
- assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
|
|
|
+ assertEquals(1, errors(events).size(),
|
|
|
+ "服务既然把它抛出来,就说明本地旁证没成立 —— 那是异常,必须留 ERROR,实测: "
|
|
|
+ + messages(events));
|
|
|
assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
|
|
|
- .findFirst().orElseThrow().getThrowableProxy(),
|
|
|
- "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
|
|
|
+ .findFirst().orElseThrow().getThrowableProxy(), "ERROR 必须带栈");
|
|
|
});
|
|
|
|
|
|
verify(orderMapper, never()).update(any(), any());
|
|
|
- assertEquals("PAYING", o.getStatus(), "没拿到「码已失效」这条判据时不得改动状态");
|
|
|
+ assertEquals("PAYING", o.getStatus(), "判据不成立时不得改动状态(本层的判据已搬进服务)");
|
|
|
verify(callbackService, never()).notifyOrder(any());
|
|
|
}
|
|
|
|