Ver código fonte

fix(进件): Task 6 评审 F1–F5(修复轮 1)—— 空值护栏注释改真话 + 日期锚 + 四条 FAIL 分支带驳回原因 + updated==0 补覆盖

评审裁决 Spec ✅ compliant / Quality Approved / Critical 0 / Important 0 / Minor 6。
本轮修 F1–F5。N1(验签前的到达日志)按主控裁定不动:我实测复核了裁定的四条依据
(与既有 /pay 腿同款、类 javadoc 自己写明刻意打在验签之前、每个实参都过 flatten、
服务层对同一事件照样记一条 rawBody),成立,无异议。

F1 LsyOnboardingNotifyService 空值护栏:**行为零改动**,只把注释改成真话。
原注释说「在那里 NPE 会让一次可疑回调变成一个 500,日志里反而什么都没有」——
这句在生产调用链上不成立:LeshouyingNotifyController:47-50 先 body.get(...) 打日志、
:50 才调服务,body == null 时 500 发生在控制器那一帧,服务里这行护栏轮不到。
改后的注释写明它真正挡的是「params 直接传 null」这一种(无它则 p 为 null,而下面每个
p.get 都在验签失败那条分支上,NPE 会让一次可疑回调变成 500、日志里反而什么都没有),
以及经控制器进来的报文走不到它。配一条用例把行为钉住。

F2 三处库侧读数补日期锚:「今天没有唯一索引」→「2026-09-23 时没有唯一索引」
(LsyReviewWriteOutcome:21、LsyMerchantService:157、:165)。用户即将做出删重复行的决策
并可能建上 uk_lsy_merchant_mch_no,那三句会当场变成假话;加锚后它们作为某日的观察为真。

F3 TenantInnerInterceptor:206:「本条**不走** (甲) 的反面证据」字面读法与上文相反
(:192 已写本条走 (乙)),改成「本条走 (乙) 而非 (甲) 的依据」。只动措辞,事实未动。

F4 updateReviewStatusByMchNo 四条 FAIL 分支(空白 mchNo / 0 行 / 多行 / update 影响 0 行)
的日志各带上 reviewedReason,且每个实参都过 LsyLogSanitizer.flatten(与相邻行同款);
:215-217 / :237-240 两条已有的成功分支未动。依据:需求原文是「不落库,但必须进日志」,
而此前只有成功路径打了原因,且调用方在那几条路径上也不打报文原文 ⇒ 一条被驳回的通知
若走了「找不到行」,它的原因在任何地方都查不到。javadoc 的 @param 同步写明这一点。

F5 updated == 0 分支此前零覆盖,补一条用例:stub update 返回 0,断言不抛且应答 NOT_FOUND。

测试:594 → 598(+4)。末次全量(改后冻结树,控制台汇总行)
Tests run: 598, Failures: 0, Errors: 0, Skipped: 0,BUILD SUCCESS(exit 0)。

变异验证 8 条,逐条「施加 → 跑 → 还原 → 逐字节 sha256 复核回基线」(8/8 还原 OK):
红 —— M-F1_guard(去掉空值护栏 ⇒ 新用例 NPE,Errors: 1);
M-F5_zero(updateReviewStatusByMchNo 里那句 if (updated == 0) 改 if (false) —— 同串在 save
里还有一处,只改第 1 次 ⇒ Failures: 3,含新用例 expected: <NOT_FOUND> but was: <APPLIED>);
M-F4-1/2/3/4(四条 FAIL 分支各自的原因实参换成空串 ⇒ 逐条只让「四条 FAIL 分支都打原因」
那条用例红,且点名正是被摘的那一分支);M-F4-5(四条都不再 flatten ⇒ 日志注入用例红,
注入的 CRLF 原样出现在日志里)。
绿 —— M-N(无害调换两个 .set 顺序 ⇒ 38 绿),并以 javap 给出 class 级证据:
class 校验和 0d3d1ec9→c7993fb0、updateReviewStatusByMchNo 偏移 286–301 两条 set 调用
顺序互换、LineNumberTable line 236/237 偏移随之移动,其余差异全是常量池下标重排
(故 class 文件字节数不变)。
alphaH 3 semanas atrás
pai
commit
5e12a66d58

+ 1 - 1
java/src/main/java/com/payment/platform/core/tenant/TenantInnerInterceptor.java

@@ -203,7 +203,7 @@ public class TenantInnerInterceptor extends TenantLineInnerInterceptor {
             //     **不得**用 selectOne / LIMIT 1,必须自己判多行;③ 索引建好后,
             //     updateReviewStatusByMchNo 那段「安全来自查询形状」的措辞**仍不必改** ——
             //     它是第二道闸,不是第一道(警告只在仍然成立时才删)。
-            //     ★ 本条**不走** (甲) 的反面证据:mch_no 是**上游发放**的平台商户号,
+            //     ★ 本条走 (乙) 而非 (甲) 的依据:mch_no 是**上游发放**的平台商户号,
             //     所以「它为什么天然指向唯一那一行」答得上来;而库里那两行是**同一租户**下的
             //     租户级/企业级映射并存 —— 那是这张表的设计用法,不是脏数据。
             "pay_lsy_order",

+ 1 - 1
java/src/main/java/com/payment/platform/module/payment/leshouying/enums/LsyReviewWriteOutcome.java

@@ -18,7 +18,7 @@ package com.payment.platform.module.payment.leshouying.enums;
  * 合成一个值会让<b>重放变成失败</b> ⇒ 上游对一条已送达的通知无限重推。
  *
  * <p><b>为什么 {@link #AMBIGUOUS} 必须是一个独立的、响亮的结果</b>:
- * {@code pay_lsy_merchant.mch_no} <b>今天没有唯一索引</b>(真库里实测有重复,见
+ * {@code pay_lsy_merchant.mch_no} <b>2026-09-23 时没有唯一索引</b>(该日真库实测有重复,见
  * {@code db/lsy-merchant-mch-no-uk.sql}),而本方法的调用方是无认证的审核通知入口 ——
  * 它发出的查询<b>不带租户谓词</b>,随便挑一行就可能挑到<b>别的租户</b>的行上。
  * 所以这条路径的第一道闸是<b>查询形状</b>(多行一律不写),唯一索引只是第二道闸。

+ 20 - 9
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantService.java

@@ -154,7 +154,7 @@ public class LsyMerchantService {
      * 本方法的调用方是<b>免登录</b>的审核通知入口:没有认证上下文 ⇒
      * {@code TenantInnerInterceptor} 的 {@code ignoreTable} 返回 {@code true} ⇒
      * <b>不追加任何租户谓词</b>。于是这次查询是<b>跨租户</b>的,
-     * 而 {@code pay_lsy_merchant.mch_no} <b>今天没有唯一索引</b> —— 真库实测
+     * 而 {@code pay_lsy_merchant.mch_no} <b>2026-09-23 时没有唯一索引</b> —— 该日真库实测
      * {@code mch_no='Y52211090004'} 有 <b>2</b> 行,且两行<b>都合法</b>
      * (一行 {@code enterprise_id} 有值、一行 NULL,分别落在 {@code uk_lsy_merchant_tenant_ent}
      * 与 {@code uk_lsy_merchant_tenant_level} 上)。这不是「理论上可能重复」。
@@ -162,7 +162,7 @@ public class LsyMerchantService {
      *
      * <p>所以本条路径的安全来自<b>查询形状</b>(多行一律不写),<b>不是</b>来自唯一性。
      * 唯一索引 {@code uk_lsy_merchant_mch_no} 是<b>第二道闸</b>(纵深防御 + 为将来的调用方立约束),
-     * 它今天建不起来(重复行挡着,且执行归用户),交付脚本是 {@code db/lsy-merchant-mch-no-uk.sql}。
+     * 它 2026-09-23 时建不起来(重复行挡着,且执行归用户),交付脚本是 {@code db/lsy-merchant-mch-no-uk.sql}。
      * 与 {@code TenantInnerInterceptor} 里那条「未认证的读不得用 {@code selectOne}」是同一件事。
      *
      * <h2>为什么 WHERE 用 {@code id} 而不是 {@code mch_no}</h2>
@@ -174,6 +174,10 @@ public class LsyMerchantService {
      *                       {@code getValue()},不是 {@code name()},也不是上游的码)
      * @param reviewedReason 上游给的驳回原因。★ 本批次<b>不落库</b>(表里没有 {@code reviewed_reason} 列),
      *                       它是<b>日志通道</b>,不是备用的落库通道 —— 别为了「用上这个参数」而找地方存它。
+     *                       ★ 它在<b>所有</b>返回路径上都进日志,<b>包括四条 FAIL 分支</b>
+     *                       —— 需求原文是「不落库,但<b>必须进日志</b>」。不能只留「办成了」那两条:
+     *                       一条被驳回的通知若走了「找不到行」,调用方({@code LsyOnboardingNotifyService})
+     *                       在那条路径上也不打报文原文 ⇒ 这里不打就整条链都查不到这个原因。
      * @return 四种结果之一,语义见 {@link LsyReviewWriteOutcome}。
      *         ★ {@code UNCHANGED} 与 {@code NOT_FOUND} 都「没写库」但含义相反:
      *         前者该应答成功(重放),后者该应答失败(让上游重推)。别把两者合成一个。
@@ -184,8 +188,10 @@ public class LsyMerchantService {
         // 「MP 里 eq(col, null) 照发谓词、绑一个真 null」。入参来自**未验签报文**,
         // 正是不该依赖隐含语义的地方。
         if (StrUtil.isBlank(mchNo)) {
-            log.error("审核通知写入口收到空白商户号,不查库、不写入: mchNo=[{}], localStatus={}",
-                    LsyLogSanitizer.flatten(mchNo), LsyLogSanitizer.flatten(localStatus));
+            log.error("审核通知写入口收到空白商户号,不查库、不写入: mchNo=[{}], localStatus={}, "
+                            + "reviewedReason={}",
+                    LsyLogSanitizer.flatten(mchNo), LsyLogSanitizer.flatten(localStatus),
+                    LsyLogSanitizer.flatten(reviewedReason));
             return LsyReviewWriteOutcome.NOT_FOUND;
         }
 
@@ -194,16 +200,19 @@ public class LsyMerchantService {
                 .eq(LsyMerchantEntity::getMchNo, mchNo));
 
         if (rows.isEmpty()) {
-            log.error("审核通知找不到商户映射行,应答失败让上游重推: mchNo={}", LsyLogSanitizer.flatten(mchNo));
+            log.error("审核通知找不到商户映射行,应答失败让上游重推: mchNo={}, reviewedReason={}",
+                    LsyLogSanitizer.flatten(mchNo), LsyLogSanitizer.flatten(reviewedReason));
             return LsyReviewWriteOutcome.NOT_FOUND;
         }
         if (rows.size() > 1) {
             // 响亮失败,且**一行都不写**。日志里带上命中的 id:这是人工处置(或用户裁定删哪一行)
             // 唯一能用的线索。
             log.error("审核通知按 mch_no 命中多行,拒绝回写(该列无唯一索引,"
-                            + "无认证查询不带租户谓词 ⇒ 挑一行等于赌租户): mchNo={}, 命中 {} 行, ids={}",
+                            + "无认证查询不带租户谓词 ⇒ 挑一行等于赌租户): mchNo={}, 命中 {} 行, ids={}, "
+                            + "reviewedReason={}",
                     LsyLogSanitizer.flatten(mchNo), rows.size(),
-                    rows.stream().map(LsyMerchantEntity::getId).toList());
+                    rows.stream().map(LsyMerchantEntity::getId).toList(),
+                    LsyLogSanitizer.flatten(reviewedReason));
             return LsyReviewWriteOutcome.AMBIGUOUS;
         }
 
@@ -230,8 +239,10 @@ public class LsyMerchantService {
         if (updated == 0) {
             // 定位到写入之间那一行没了(并发删除 / 主键换了)。不抛:调用方要的是「怎么应答上游」,
             // 而这种情况的正确答案与「找不到」同向 —— 让上游重推。
-            log.error("审核通知定向更新未命中任何行(定位与写入之间该行被改动过): mchNo={}, id={}",
-                    LsyLogSanitizer.flatten(mchNo), located.getId());
+            log.error("审核通知定向更新未命中任何行(定位与写入之间该行被改动过): "
+                            + "mchNo={}, id={}, reviewedReason={}",
+                    LsyLogSanitizer.flatten(mchNo), located.getId(),
+                    LsyLogSanitizer.flatten(reviewedReason));
             return LsyReviewWriteOutcome.NOT_FOUND;
         }
         log.info("审核通知已回写商户审核状态: mchNo={}, id={}, reviewStatus: {} -> {}, reviewedReason={}",

+ 9 - 3
java/src/main/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingNotifyService.java

@@ -102,9 +102,15 @@ public class LsyOnboardingNotifyService {
      */
     public String handleOnboardingNotify(Map<String, Object> params, String rawBody) {
         // 报文可能整个缺席({@code verify} 自己把 null / 空 map 判成 false,这里跟着它的口径走)。
-        // 这一行不是为了「好看」:下面每个 p.get 都在验签失败那条分支上,
-        // 而那条分支**必须**能打出「上游到底发了什么」—— 在那里 NPE 会让一次可疑回调
-        // 变成一个 500,日志里反而什么都没有。
+        // ★ 这一行防的是**将来的直接调用方**,不是今天这条链 —— 别把它读成「挡下了某种攻击」:
+        //   · 经控制器进来的报文**走不到这里**:LeshouyingNotifyController 在调本方法**之前**
+        //     就已经解引用了 body(先 body.get("merchant_no") 打日志、再调服务),
+        //     body == null 时 500 发生在控制器那一帧,本行轮不到。
+        //   · 它真正挡的是「params 直接传 null」这一种:没有它,p 就是 null,而下面每个
+        //     p.get 都在**验签失败**那条分支上 —— 那条分支**必须**能打出「上游到底发了什么」,
+        //     在那里 NPE 会让一次可疑回调变成一个 500,日志里反而什么都没有。
+        //   · 它做的只是把 null 归一成空 map ⇒ verify 必然判 false ⇒ 走「验签失败」那条应答。
+        //     (用例:LsyOnboardingNotifyServiceTest#onboardingNotify_nullBody_isRejectedAsBadSign_notNpe)
         Map<String, Object> p = (params == null) ? Map.of() : params;
 
         // ── ① 身份。验签失败 ⇒ 到此为止:不读、不写、不应答成功。

+ 128 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyMerchantServiceTest.java

@@ -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),服务端字段一个都不带 */

+ 26 - 0
java/src/test/java/com/payment/platform/module/payment/leshouying/service/LsyOnboardingNotifyServiceTest.java

@@ -124,6 +124,32 @@ class LsyOnboardingNotifyServiceTest {
         verify(merchantService, never()).updateReviewStatusByMchNo(any(), any(), any());
     }
 
+    /**
+     * 报文整个缺席({@code params == null})⇒ 走「验签失败」那条应答,<b>不是 NPE</b>。
+     *
+     * <p>★ 这条钉的是服务里那行空值护栏({@code params == null ? Map.of() : params})。
+     * 它<b>不是</b>「挡下了一次真实攻击」—— 经控制器进来的报文<b>走不到它</b>:
+     * {@code LeshouyingNotifyController} 在调本方法<b>之前</b>就已经解引用了 body
+     * (先 {@code body.get("merchant_no")} 打日志),{@code body == null} 时 500 发生在控制器那一帧。
+     * 所以本用例<b>直接调服务</b> —— 那正是这行护栏唯一可达的入口(将来若多一个直接调用方,
+     * 它就是那个调用方的行为契约)。
+     *
+     * <p>两向:把护栏改成 {@code Map<String, Object> p = params;}(其余不动),
+     * {@code verify} 仍会判 false(它自己判 null),但紧接着那条
+     * <b>必须打出「上游到底发了什么」</b>的日志要逐个 {@code p.get} ⇒ NPE。
+     * 也就是说:去掉护栏,本用例从「断言 FAIL」变成「抛 NPE」。
+     */
+    @Test
+    void onboardingNotify_nullBody_isRejectedAsBadSign_notNpe() {
+        String response = service.handleOnboardingNotify(null, "{}");
+
+        assertEquals(BAD_SIGN, response,
+                "报文缺席必须走「验签失败」那条应答(护栏把 null 归一成空 map),实测: " + response);
+        verify(merchantMapper, never()).selectList(any());
+        verify(merchantMapper, never()).update(any(), any());
+        verify(merchantService, never()).updateReviewStatusByMchNo(any(), any(), any());
+    }
+
     // ───────────────────────────────── 值域 ─────────────────────────────────
 
     /**