|
|
@@ -1,5 +1,8 @@
|
|
|
package com.payment.platform.module.payment.leshouying.service;
|
|
|
|
|
|
+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.conditions.AbstractWrapper;
|
|
|
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
|
|
@@ -17,11 +20,14 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
|
|
import org.mockito.ArgumentCaptor;
|
|
|
import org.mockito.Mock;
|
|
|
import org.mockito.junit.jupiter.MockitoExtension;
|
|
|
+import org.slf4j.LoggerFactory;
|
|
|
|
|
|
import java.time.Duration;
|
|
|
import java.time.OffsetDateTime;
|
|
|
import java.util.Arrays;
|
|
|
+import java.util.LinkedHashMap;
|
|
|
import java.util.List;
|
|
|
+import java.util.Map;
|
|
|
import java.util.Set;
|
|
|
import java.util.regex.Matcher;
|
|
|
import java.util.regex.Pattern;
|
|
|
@@ -568,6 +574,128 @@ class LsyMerchantServiceTest {
|
|
|
verify(merchantMapper, never()).update(any(), any());
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * ★ {@code update} 返回 <b>0</b> ⇒ {@code NOT_FOUND},且<b>不抛</b>。
|
|
|
+ *
|
|
|
+ * <p>可达窗口只有「定位到写入之间那一行没了」(并发删除 / 主键换了)。正因为窗口窄,
|
|
|
+ * 它此前<b>零覆盖</b>:全 diff 里 {@code merchantMapper.update} 的桩只有三处
|
|
|
+ * {@code thenReturn(1)}、{@code thenReturn(0)} 一处都没有 ⇒ 删掉这段处理全量照样绿。
|
|
|
+ * 而它是这条路径上唯一把「DB 说一行都没改到」翻译成「怎么应答上游」的地方。
|
|
|
+ *
|
|
|
+ * <p>为什么不抛:调用方要的是一个结果去决定怎么应答上游,而这种情况的正确答案与
|
|
|
+ * 「找不到」<b>同向</b>(让上游重推)。抛出去会把「重推一次就好」变成一个 500。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void updateReviewStatusByMchNo_updateAffectsZeroRows_returnsNotFound_withoutThrowing() {
|
|
|
+ when(merchantMapper.selectList(any()))
|
|
|
+ .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
|
|
|
+ when(merchantMapper.update(any(), any())).thenReturn(0);
|
|
|
+
|
|
|
+ LsyReviewWriteOutcome outcome = assertDoesNotThrow(
|
|
|
+ () -> service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", "资料缺失"),
|
|
|
+ "并发窗口不是异常路径 —— 抛出去会把「重推一次就好」变成 500");
|
|
|
+
|
|
|
+ assertEquals(LsyReviewWriteOutcome.NOT_FOUND, outcome,
|
|
|
+ "更新命中 0 行 ⇒ 与「找不到」同向:让上游重推");
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ {@code reviewedReason} 在<b>所有</b> FAIL 分支的日志里都查得到。
|
|
|
+ *
|
|
|
+ * <p>需求逐字是「不落库,但<b>必须进日志</b>」,计划 {@code :708} 的依据是
|
|
|
+ * 「原因在日志里已经完整可查」。而一条被驳回的通知若走了「找不到行」,
|
|
|
+ * 调用方 {@code LsyOnboardingNotifyService} 在那条路径上<b>也不打报文原文</b>
|
|
|
+ * ⇒ 这里不打,这个原因在整条链上任何地方都查不到。
|
|
|
+ *
|
|
|
+ * <p>四条分支各有<b>独立</b>的日志语句,一条绿不能替代另一条 —— 所以逐条断言
|
|
|
+ * ({@code failBranchLogs} 对每条都先钉「它确实走了那条分支」)。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void updateReviewStatusByMchNo_everyFailBranch_logsReviewedReason() {
|
|
|
+ String reason = "营业执照过期-REASON-MARKER";
|
|
|
+
|
|
|
+ failBranchLogs(reason).forEach((branch, messages) ->
|
|
|
+ assertTrue(messages.stream().anyMatch(m -> m.contains(reason)),
|
|
|
+ branch + ":FAIL 分支的日志里必须能查到 reviewedReason"
|
|
|
+ + "(需求:「不落库,但必须进日志」)。实测: " + messages));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * ★ 上一条的另一半:这四个新实参的值来自<b>未认证报文</b>,所以每一个都要过 {@code flatten}。
|
|
|
+ *
|
|
|
+ * <p>与相邻实参同款({@code mchNo} / {@code localStatus} 早就是这么打的):
|
|
|
+ * {@code reviewed_reason} 是上游可控字符串,{@code \r\n} 能在日志里伪造出一整行
|
|
|
+ * (CWE-117),而这条链上的日志正是事后追查「这条通知为什么没落库」的唯一凭据。
|
|
|
+ */
|
|
|
+ @Test
|
|
|
+ void updateReviewStatusByMchNo_failBranchLogs_sanitizeReviewedReason() {
|
|
|
+ String forged = "资料缺失\r\n2026-01-01 00:00:00 ERROR 伪造的日志行";
|
|
|
+
|
|
|
+ failBranchLogs(forged).forEach((branch, messages) -> {
|
|
|
+ assertFalse(messages.isEmpty(),
|
|
|
+ branch + ":没有捕获到任何日志事件 —— appender 很可能挂错了 logger,本断言已失效");
|
|
|
+ List<String> dirty = messages.stream()
|
|
|
+ .filter(m -> m.contains("\n") || m.contains("\r")).toList();
|
|
|
+ assertTrue(dirty.isEmpty(),
|
|
|
+ branch + ":日志里出现行终止符 ⇒ 未认证报文可伪造日志行(CWE-117);实际=" + dirty);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 依次跑四条 <b>FAIL</b> 分支,返回「分支名 → 该分支产生的日志正文」。
|
|
|
+ *
|
|
|
+ * <p>分支与实现一一对应:空白 {@code mchNo} / 0 行 / 多行 / {@code update} 返回 0。
|
|
|
+ * 每条都<b>同时断言了它该返回的结果</b> —— 否则「这次到底走的是哪条分支」本身就没被钉住,
|
|
|
+ * 后面那条「日志里有原因」就可能是在另一条分支上绿的。
|
|
|
+ */
|
|
|
+ private Map<String, List<String>> failBranchLogs(String reason) {
|
|
|
+ Map<String, List<String>> out = new LinkedHashMap<>();
|
|
|
+
|
|
|
+ out.put("空白 mchNo", logsOf(() -> assertEquals(LsyReviewWriteOutcome.NOT_FOUND,
|
|
|
+ service.updateReviewStatusByMchNo(" ", "APPROVED", reason))));
|
|
|
+
|
|
|
+ out.put("0 行(查不到那个商户号)", logsOf(() -> {
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of());
|
|
|
+ assertEquals(LsyReviewWriteOutcome.NOT_FOUND,
|
|
|
+ service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", reason));
|
|
|
+ }));
|
|
|
+
|
|
|
+ out.put("多行(mch_no 命中重复)", logsOf(() -> {
|
|
|
+ when(merchantMapper.selectList(any())).thenReturn(List.of(
|
|
|
+ rowWithReviewStatus(1L, "PENDING"),
|
|
|
+ rowWithReviewStatus(EDIT_ID, "PENDING")));
|
|
|
+ assertEquals(LsyReviewWriteOutcome.AMBIGUOUS,
|
|
|
+ service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", reason));
|
|
|
+ }));
|
|
|
+
|
|
|
+ out.put("update 返回 0(定位到写入之间那一行被改动)", logsOf(() -> {
|
|
|
+ when(merchantMapper.selectList(any()))
|
|
|
+ .thenReturn(List.of(rowWithReviewStatus(EDIT_ID, "PENDING")));
|
|
|
+ when(merchantMapper.update(any(), any())).thenReturn(0);
|
|
|
+ assertEquals(LsyReviewWriteOutcome.NOT_FOUND,
|
|
|
+ service.updateReviewStatusByMchNo("Y52211090004", "APPROVED", reason));
|
|
|
+ }));
|
|
|
+
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 跑一段代码并返回它产生的全部日志正文。
|
|
|
+ * 挂 appender、跑、<b>无论成败都摘掉</b>(不摘会把后面用例的日志也吃进来)。
|
|
|
+ */
|
|
|
+ private static List<String> logsOf(Runnable scenario) {
|
|
|
+ Logger logger = (Logger) LoggerFactory.getLogger(LsyMerchantService.class);
|
|
|
+ ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
|
|
+ appender.start();
|
|
|
+ logger.addAppender(appender);
|
|
|
+ try {
|
|
|
+ scenario.run();
|
|
|
+ return appender.list.stream().map(ILoggingEvent::getFormattedMessage).toList();
|
|
|
+ } finally {
|
|
|
+ logger.detachAppender(appender);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
// ─────────────────────────────────── 夹具 ───────────────────────────────────
|
|
|
|
|
|
/** 页面送来的编辑请求体:只有 `id` + 5 个业务键(`merchant/index.vue` 的 FORM_FIELDS),服务端字段一个都不带 */
|