|
@@ -1,9 +1,14 @@
|
|
|
package com.payment.platform.module.payment.leshouying.scheduler;
|
|
package com.payment.platform.module.payment.leshouying.scheduler;
|
|
|
|
|
|
|
|
|
|
+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.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
|
|
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
|
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
|
|
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
|
|
|
|
+import com.payment.platform.module.payment.leshouying.client.LeshouyingApiException;
|
|
|
import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
import com.payment.platform.module.payment.leshouying.config.LeshouyingProperties;
|
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyOrderEntity;
|
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
|
|
import com.payment.platform.module.payment.leshouying.entity.LsyRefundEntity;
|
|
@@ -18,6 +23,7 @@ import org.junit.jupiter.api.Test;
|
|
|
import org.junit.jupiter.api.extension.ExtendWith;
|
|
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 org.slf4j.LoggerFactory;
|
|
|
|
|
|
|
|
import java.time.OffsetDateTime;
|
|
import java.time.OffsetDateTime;
|
|
|
import java.util.LinkedHashMap;
|
|
import java.util.LinkedHashMap;
|
|
@@ -25,6 +31,7 @@ import java.util.LinkedHashSet;
|
|
|
import java.util.List;
|
|
import java.util.List;
|
|
|
import java.util.Map;
|
|
import java.util.Map;
|
|
|
import java.util.Set;
|
|
import java.util.Set;
|
|
|
|
|
+import java.util.function.Consumer;
|
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Matcher;
|
|
|
import java.util.regex.Pattern;
|
|
import java.util.regex.Pattern;
|
|
|
|
|
|
|
@@ -32,6 +39,7 @@ 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.assertFalse;
|
|
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
|
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.assertTrue;
|
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
import static org.mockito.ArgumentMatchers.any;
|
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
import static org.mockito.ArgumentMatchers.eq;
|
|
@@ -613,6 +621,153 @@ class LsySchedulerTest {
|
|
|
* <p>返回值刻意区分「谓词不在」({@code null}) 与「值不对」(非 null 的别的串):
|
|
* <p>返回值刻意区分「谓词不在」({@code null}) 与「值不对」(非 null 的别的串):
|
|
|
* 断言失败消息里会一并打出 {@code getSqlSegment()},两种成因当场可分。
|
|
* 断言失败消息里会一并打出 {@code getSqlSegment()},两种成因当场可分。
|
|
|
*/
|
|
*/
|
|
|
|
|
+ // ============ 上游「这笔单/这笔退款我这儿没有」是**正常业务回答**,不是异常 ============
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 未扫的聚合码单在 `/v3/queryorder` 上回的**就是** `return_code=FAIL` +「订单信息不存在!」,
|
|
|
|
|
+ * 而这是该单种类的**正常态**:差分对照实测,同一刻建的两笔同参聚合码单,
|
|
|
|
|
+ * 在关单动作**前后**都查不到(读数 `logs/_lsy-close-control.txt`)。⇒ 与关单无关,
|
|
|
|
|
+ * 是「未扫的聚合码单」这一类单在查询接口上的固有表现。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>`LeshouyingResponseParser.parseV3` 对 `return_code != SUCCESS` 一律**抛异常**
|
|
|
|
|
+ * (那是出站腿要的语义:通信层失败必须炸),于是这一跳穿过 `LsyQueryService.queryOrder`
|
|
|
|
|
+ * 落进本调度器的 `catch (Exception)`,被记成**带完整栈的 ERROR** —— 每 5 分钟一条,
|
|
|
|
|
+ * 直到该单被绝对过期收走。栈本身零信息量(每次都是同一条
|
|
|
|
|
+ * `postForRawJson → parseV3 → throw`),而一条恒假的 ERROR 会训练运维忽略 ERROR。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>【失败方向】判据是「上游原文**包含**某句话」,不是「凡是 LeshouyingApiException 就降级」:
|
|
|
|
|
+ * 上游改了措辞 ⇒ 我们退回 **noisy**(ERROR),**不会**退回 silent。这条不对称是故意的。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void reconcilePendingOrders_上游说订单不存在_记WARN不带栈() {
|
|
|
|
|
+ LsyOrderEntity o = paying(0);
|
|
|
|
|
+ o.setPayScene("QRCODE");
|
|
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(o));
|
|
|
|
|
+ when(queryService.queryOrder(o)).thenThrow(
|
|
|
|
|
+ new LeshouyingApiException("FAIL", "订单信息不存在!", "{}"));
|
|
|
|
|
+
|
|
|
|
|
+ withLogCapture(LsyReconcileScheduler.class, events -> {
|
|
|
|
|
+ assertDoesNotThrow(() -> scheduler().reconcilePendingOrders());
|
|
|
|
|
+
|
|
|
|
|
+ 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"),
|
|
|
|
|
+ "WARN 必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
|
|
|
|
|
+ assertTrue(warns.get(0).getFormattedMessage().contains("订单信息不存在"),
|
|
|
|
|
+ "上游原文必须留下 —— 否则这一条只剩「失败了」,排障时无从判断,实测: "
|
|
|
|
|
+ + warns.get(0).getFormattedMessage());
|
|
|
|
|
+ });
|
|
|
|
|
+ // 只改日志级别,**不改行为**:订单状态一个字都不许动
|
|
|
|
|
+ verify(orderService, never()).settleFromQuery(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 反向守卫:**别的**上游失败必须仍是带栈 ERROR。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>没有这一条,一个「把所有 LeshouyingApiException 都降成 WARN」的实现
|
|
|
|
|
+ * (更省事,也更像「顺手放宽」)会让上面那条全绿 —— 而验签失败、商户未配置、
|
|
|
|
|
+ * 参数被拒这类**每笔都会失败**的真故障会一起被静音。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>本条在加实现**之前**就是绿的(它钉的是既有行为),所以它的价值只能由变异证明:
|
|
|
|
|
+ * 把实现改成无条件降级 ⇒ 本条必须红。读数见提交信息。
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void reconcilePendingOrders_其他上游失败_仍是带栈ERROR() {
|
|
|
|
|
+ LsyOrderEntity o = paying(0);
|
|
|
|
|
+ o.setPayScene("QRCODE");
|
|
|
|
|
+ when(orderMapper.selectList(any())).thenReturn(List.of(o));
|
|
|
|
|
+ when(queryService.queryOrder(o)).thenThrow(
|
|
|
|
|
+ new LeshouyingApiException("FAIL", "验签失败", "{}"));
|
|
|
|
|
+
|
|
|
|
|
+ withLogCapture(LsyReconcileScheduler.class, events -> {
|
|
|
|
|
+ assertDoesNotThrow(() -> scheduler().reconcilePendingOrders());
|
|
|
|
|
+
|
|
|
|
|
+ assertEquals(1, errors(events).size(), "真故障必须留在 ERROR,实测: " + messages(events));
|
|
|
|
|
+ assertNotNull(events.stream().filter(e -> e.getLevel() == Level.ERROR)
|
|
|
|
|
+ .findFirst().orElseThrow().getThrowableProxy(),
|
|
|
|
|
+ "ERROR 必须带栈 —— 栈是查这条日志的人唯一能顺下去的东西");
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 退款腿同一形状 —— 依据是**实测量出来的**,不是「看着像所以顺手也改」:
|
|
|
|
|
+ * 拿一笔上游不认识的 `refund_trace_no` 打 `/v3/queryrefund`,实测回
|
|
|
|
|
+ * `return_code=FAIL` / `result_code=null` / `return_msg=订单信息不存在!`
|
|
|
|
|
+ * (原始字节 `logs/_lsy-lc4/qrefund-bogus-trace.bin`,422 B,sign=null)。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>而这正是本腿存在的理由:`LsyRefundService.createRefund` 把退款行**预插入**成 `CREATED`,
|
|
|
|
|
+ * 那一刻的语义是「我们记下了,但**还不知道上游有没有收到**」(见该方法 :175-181 的注释)。
|
|
|
|
|
+ * 预插入之后 POST 超时/连接重置的那些行,上游根本没见过这笔退款 —— 每轮对账问到的
|
|
|
|
|
+ * 就是这句话。把它记成带栈 ERROR,等于把**设计里明写的正常态**报成故障。
|
|
|
|
|
+ *
|
|
|
|
|
+ * <p>(同一个探针也测了 `/v3/qrclose`:回 `FAIL` +「该聚合码链接不存在或已过期!」。
|
|
|
|
|
+ * 但 `closeExpiredQrOrders` **没有**改成 WARN —— 扫描条件是「过期且仍 PAYING 且有 code_url」,
|
|
|
|
|
+ * 那种行是 qrpay 成功之后才有的,**我没有它复发的读数**,不凭形状推断。)
|
|
|
|
|
+ */
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void reconcilePendingRefunds_上游说订单不存在_记WARN不带栈() {
|
|
|
|
|
+ LsyRefundEntity r = pendingRefund();
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(r));
|
|
|
|
|
+ when(refundService.queryRefund(r)).thenThrow(
|
|
|
|
|
+ new LeshouyingApiException("FAIL", "订单信息不存在!", "{}"));
|
|
|
|
|
+
|
|
|
|
|
+ withLogCapture(LsyReconcileScheduler.class, events -> {
|
|
|
|
|
+ assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
|
|
|
|
|
+
|
|
|
|
|
+ assertTrue(errors(events).isEmpty(),
|
|
|
|
|
+ "实测这是正常业务回答,不得记 ERROR,实测: " + errors(events));
|
|
|
|
|
+ List<ILoggingEvent> warns = warns(events);
|
|
|
|
|
+ assertEquals(1, warns.size(), "实测: " + messages(events));
|
|
|
|
|
+ assertNull(warns.get(0).getThrowableProxy());
|
|
|
|
|
+ assertTrue(warns.get(0).getFormattedMessage().contains("RF001"),
|
|
|
|
|
+ "必须带得走是哪一笔,实测: " + warns.get(0).getFormattedMessage());
|
|
|
|
|
+ });
|
|
|
|
|
+ // ★ 饿死防线不得被本改动碰到:抛异常的行走的仍是同一条推游标的路。
|
|
|
|
|
+ verify(refundMapper).update(any(), any());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @Test
|
|
|
|
|
+ void reconcilePendingRefunds_其他上游失败_仍是带栈ERROR() {
|
|
|
|
|
+ LsyRefundEntity r = pendingRefund();
|
|
|
|
|
+ when(refundMapper.selectList(any())).thenReturn(List.of(r));
|
|
|
|
|
+ when(refundService.queryRefund(r)).thenThrow(
|
|
|
|
|
+ new LeshouyingApiException("FAIL", "验签失败", "{}"));
|
|
|
|
|
+
|
|
|
|
|
+ withLogCapture(LsyReconcileScheduler.class, events -> {
|
|
|
|
|
+ assertDoesNotThrow(() -> scheduler().reconcilePendingRefunds());
|
|
|
|
|
+ assertEquals(1, errors(events).size(), "实测: " + messages(events));
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 与 LsyQrPayTest / LsyNotifyServiceTest 同款(本仓库按测试文件各自持有一份,不抽公共类)。 */
|
|
|
|
|
+ private static void withLogCapture(Class<?> type, Consumer<List<ILoggingEvent>> body) {
|
|
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(type);
|
|
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
|
|
|
+ appender.start();
|
|
|
|
|
+ logger.addAppender(appender);
|
|
|
|
|
+ try {
|
|
|
|
|
+ body.accept(appender.list);
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ logger.detachAppender(appender);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static List<ILoggingEvent> warns(List<ILoggingEvent> events) {
|
|
|
|
|
+ return events.stream().filter(e -> e.getLevel() == Level.WARN).toList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static List<String> errors(List<ILoggingEvent> events) {
|
|
|
|
|
+ return events.stream().filter(e -> e.getLevel() == Level.ERROR)
|
|
|
|
|
+ .map(ILoggingEvent::getFormattedMessage).toList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static List<String> messages(List<ILoggingEvent> events) {
|
|
|
|
|
+ return events.stream().map(e -> e.getLevel() + " " + e.getFormattedMessage()).toList();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private static String equalityStatusGuardValue(AbstractWrapper<?, ?, ?> w) {
|
|
private static String equalityStatusGuardValue(AbstractWrapper<?, ?, ?> w) {
|
|
|
Matcher m = Pattern.compile("status = #\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
|
|
Matcher m = Pattern.compile("status = #\\{ew\\.paramNameValuePairs\\.([A-Za-z0-9_]+)\\}")
|
|
|
.matcher(w.getSqlSegment());
|
|
.matcher(w.getSqlSegment());
|